Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions mantle/cmd/kola/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ func init() {
ssv(&kola.Tags, "tag", []string{}, "Test tag to run. Can be specified multiple times.")
sv(&kola.Sharding, "sharding", "", "Provide e.g. 'hash:m/n' where m and n are integers, 1 <= m <= n. Only tests hashing to m will be run.")
bv(&kola.Options.SSHOnTestFailure, "ssh-on-test-failure", false, "SSH into a machine when tests fail")
bv(&kola.QEMUOptions.NoIgnition, "no-ignition", false, "Run without Ignition; provision SSH via systemd SMBIOS credentials (requires -p qemu and --qemu-image)")
sv(&kola.Options.Stream, "stream", "", "CoreOS stream ID (e.g. for Fedora CoreOS: stable, testing, next)")
sv(&kola.Options.CosaWorkdir, "workdir", "", "coreos-assembler working directory")
sv(&kola.Options.CosaBuildId, "build", "", "coreos-assembler build ID (or e.g. -1, -2, for previous builds)")
Expand Down Expand Up @@ -228,6 +229,15 @@ func syncOptionsImpl(useCosa bool) error {
return err
}

if kola.QEMUOptions.NoIgnition {
if kolaPlatform != "qemu" {
return fmt.Errorf("--no-ignition requires -p qemu")
}
if kola.QEMUOptions.DiskImage == "" {
return fmt.Errorf("--no-ignition requires --qemu-image")
}
}

// Choose an appropriate AWS instance type for the target architecture
if kolaPlatform == "aws" && kola.AWSOptions.InstanceType == "" {
switch kola.Options.CosaBuildArch {
Expand Down
8 changes: 8 additions & 0 deletions mantle/kola/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ const InstalledTestDefaultTest = "test.sh"
// Specifying this in the tags list is required to denote a need for Internet access
const NeedsInternetTag = "needs-internet"

// BootcBaseTag marks tests with no test-specific Ignition/Butane (no register.Test.UserData).
// They are intended to run when SSH is provisioned without custom Ignition (e.g. systemd/SMBIOS).
const BootcBaseTag = "bootc-base"

// PlatformIndependentTag is currently equivalent to platform: qemu, but that may change in the future.
// For more, see the doc in external-tests.md.
const PlatformIndependentTag = "platform-independent"
Expand Down Expand Up @@ -1857,6 +1861,10 @@ func runTest(h *harness.H, t *register.Test, pltfrm string, flight platform.Flig
WarningsAction: conf.FailWarnings,
EarlyRelease: h.Release,
TestExecTimeout: h.TimeoutContext(),
NoIgnition: QEMUOptions.NoIgnition,
}
if QEMUOptions.NoIgnition {
rconf.SSHUser = "root"
}
if t.HasFlag(register.AllowConfigWarnings) {
rconf.WarningsAction = conf.IgnoreWarnings
Expand Down
6 changes: 6 additions & 0 deletions mantle/kola/tests/coretest/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/pborman/uuid"

"github.com/coreos/coreos-assembler/mantle/kola"
"github.com/coreos/coreos-assembler/mantle/kola/register"
"github.com/coreos/coreos-assembler/mantle/platform"
)
Expand Down Expand Up @@ -57,6 +58,7 @@ func init() {
Run: LocalTests,
ClusterSize: 1,
NativeFuncs: nativeFuncs,
Tags: []string{kola.BootcBaseTag},
})
register.RegisterTest(&register.Test{
Name: "basic.uefi",
Expand All @@ -69,6 +71,7 @@ func init() {
MachineOptions: platform.MachineOptions{
Firmware: uefi,
},
Tags: []string{kola.BootcBaseTag},
})
register.RegisterTest(&register.Test{
Name: "basic.uefi-secure",
Expand All @@ -81,6 +84,7 @@ func init() {
MachineOptions: platform.MachineOptions{
Firmware: uefiSecure,
},
Tags: []string{kola.BootcBaseTag},
})
register.RegisterTest(&register.Test{
Name: "basic.nvme",
Expand All @@ -95,6 +99,7 @@ func init() {
MachineOptions: platform.MachineOptions{
Nvme: true,
},
Tags: []string{kola.BootcBaseTag},
})
register.RegisterTest(&register.Test{
Name: "rootfs.uuid",
Expand All @@ -116,6 +121,7 @@ func init() {
"ServicesDisabled": register.CreateNativeFuncWrap(TestServicesDisabledRHCOS),
},
Distros: []string{"rhcos"},
Tags: []string{kola.BootcBaseTag},
})
}

Expand Down
8 changes: 8 additions & 0 deletions mantle/platform/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ func NewBaseCluster(bf *BaseFlight, rconf *RuntimeConfig) (*BaseCluster, error)
}

func (bc *BaseCluster) SSHClient(ip string) (*ssh.Client, error) {
if bc.rconf.SSHUser != "" {
return bc.UserSSHClient(ip, bc.rconf.SSHUser)
}
sshClient, err := bc.bf.agent.NewClient(ip)
if err != nil {
return nil, err
Expand Down Expand Up @@ -182,6 +185,11 @@ func (bc *BaseCluster) appendSSH(m Machine) error {
return err
}
}
if bc.rconf.SSHUser != "" {
if _, err := fmt.Fprintf(sshBuf, " User %s\n", bc.rconf.SSHUser); err != nil {
return err
}
}
if _, err := fmt.Fprintf(sshBuf, ` HostName %s
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
Expand Down
41 changes: 36 additions & 5 deletions mantle/platform/machine/qemu/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,38 @@ func (qc *Cluster) NewMachineWithBuilder(userdata any, options platform.MachineO
// Use default builder if none provided
builder = qc.ensureBuilderDefaults(builder)

rconf := qc.RuntimeConf()
noIgnition := rconf.NoIgnition

if noIgnition {
if qc.flight.opts.SecureExecution {
return nil, errors.New("secure execution requires Ignition; not supported with --no-ignition")
}
if len(append(qc.flight.opts.BindRO, options.BindMountHostRO...)) > 0 {
return nil, errors.New("bind mounts require Ignition; not supported with --no-ignition")
}
}

qm, config, err := qc.createMachine(userdata)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createMachine seems to already handle the case where userdata could be nil. Would it be cleaner to instead keep using createMachine, and conditionalize whatever else is needed there on nil userdata?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I got the point here, but I kept createMachine and moved the Ignition vs SMBIOS split there. For bootc runs we key off --no-ignition rather than userdata == nil, since many normal tests pass nil userdata and still expect default Ignition. When the flag isn’t set, nil userdata still goes through RenderUserDataIfNeeded as before. Is that making sense to you? What would be another approach for this?

if err != nil {
return nil, err
}

qemuBuilder := platform.NewQemuBuilder()
qemuBuilder.SetConfig(config)
defer qemuBuilder.Close()
if noIgnition {
keys, err := qc.Keys()
if err != nil {
return nil, err
}
smbios, err := platform.SystemdSMBIOSSSHCredential(rconf.SSHUser, keys)
if err != nil {
return nil, err
}
qemuBuilder.Smbios = append(qemuBuilder.Smbios, smbios)
} else {
qemuBuilder.SetConfig(config)
}
if err := builder.InitBuilder(options, qemuBuilder); err != nil {
return nil, err
}
Expand All @@ -101,7 +125,9 @@ func (qc *Cluster) NewMachineWithBuilder(userdata any, options platform.MachineO
}
readonly := true
qemuBuilder.MountHost(src, dest, readonly)
config.MountHost(dest, readonly)
if config != nil {
config.MountHost(dest, readonly)
}
}

qemuBuilder.UUID = qm.id
Expand Down Expand Up @@ -220,6 +246,7 @@ func (qc *Cluster) ensureBuilderDefaults(builder *MachineBuilder) *MachineBuilde
}

// createMachine creates a new machine instance with its directory, config, and journal.
// When --no-ignition is set, userdata may be nil and no Ignition config is rendered.
func (qc *Cluster) createMachine(userdata any) (*machine, *conf.Conf, error) {
id := uuid.New()

Expand All @@ -228,9 +255,13 @@ func (qc *Cluster) createMachine(userdata any) (*machine, *conf.Conf, error) {
return nil, nil, err
}

config, err := qc.RenderUserDataIfNeeded(userdata)
if err != nil {
return nil, nil, err
var config *conf.Conf
var err error
if !qc.RuntimeConf().NoIgnition {
config, err = qc.RenderUserDataIfNeeded(userdata)
if err != nil {
return nil, nil, err
}
}

journal, err := platform.NewJournal(dir)
Expand Down
3 changes: 3 additions & 0 deletions mantle/platform/machine/qemu/flight.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ type Options struct {
// Option to create IBM cex based luks encryption
Cex bool

// NoIgnition skips Ignition; SSH keys are provisioned via systemd SMBIOS credentials.
NoIgnition bool

*platform.Options
}

Expand Down
6 changes: 6 additions & 0 deletions mantle/platform/platform.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,12 @@ type RuntimeConfig struct {
// in-flight SSH commands when the test times out. If nil,
// context.Background() is used (no timeout).
TestExecTimeout context.Context

// NoIgnition skips Ignition when launching QEMU VMs; SSH keys are
// provisioned via systemd SMBIOS credentials instead.
NoIgnition bool
// SSHUser overrides the default SSH user (core) when set (e.g. root with --no-ignition).
SSHUser string
}

// Wrap a StdoutPipe as a io.ReadCloser
Expand Down
12 changes: 11 additions & 1 deletion mantle/platform/qemu.go
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,9 @@ type QemuBuilder struct {
Pdeathsig bool
Argv []string

// Smbios entries are passed to QEMU as -smbios arguments (e.g. systemd credentials).
Smbios []string

// AppendKernelArgs are appended to the bootloader config
AppendKernelArgs string

Expand Down Expand Up @@ -1561,7 +1564,11 @@ func (builder *QemuBuilder) setupUefi(secureBoot bool) error {
fdset := builder.AddFd(vars)
builder.Append("-drive", fmt.Sprintf("file=/usr/share/edk2/ovmf/OVMF_CODE%s.fd,if=pflash,format=raw,unit=0,readonly=on,auto-read-only=off", varsVariant))
builder.Append("-drive", fmt.Sprintf("file=%s,if=pflash,format=raw,unit=1,readonly=off,auto-read-only=off", fdset))
builder.Append("-machine", "q35")
machine := "q35"
if len(builder.Smbios) > 0 {
machine = "q35,smm=on"
}
builder.Append("-machine", machine)
case "aarch64":
if secureBoot {
return fmt.Errorf("architecture %s doesn't have support for secure boot in kola", coreosarch.CurrentRpmArch())
Expand Down Expand Up @@ -1899,6 +1906,9 @@ func (builder *QemuBuilder) Exec() (*QemuInstance, error) {
if builder.UUID != "" {
argv = append(argv, "-uuid", builder.UUID)
}
for _, s := range builder.Smbios {
argv = append(argv, "-smbios", s)
}

// We never want a popup window
argv = append(argv, "-nographic")
Expand Down
65 changes: 65 additions & 0 deletions mantle/platform/smbios.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright 2026 Red Hat
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package platform

import (
"encoding/base64"
"fmt"
"strings"

"golang.org/x/crypto/ssh/agent"
)

const systemdCredentialPrefix = "io.systemd.credential.binary:"

// SystemdSMBIOSSSHCredential builds a QEMU -smbios type=11 value that provisions
// SSH authorized_keys via the systemd tmpfiles.extra system credential.
// See https://systemd.io/CREDENTIALS/
func SystemdSMBIOSSSHCredential(user string, keys []*agent.Key) (string, error) {
if user == "" {
return "", fmt.Errorf("SSH user must be set")
}
if len(keys) == 0 {
return "", fmt.Errorf("no SSH keys provided")
}

var keyLines []string
for _, key := range keys {
keyLines = append(keyLines, key.String())
}
keysContent := strings.Join(keyLines, "\n") + "\n"
keysB64 := base64.StdEncoding.EncodeToString([]byte(keysContent))

homeDir := sshHomeDir(user)
sshDirMode := "0700"
if user == "root" {
sshDirMode = "0750"
}

tmpfiles := fmt.Sprintf("d %s/.ssh %s %s %s -\nf~ %s/.ssh/authorized_keys 0600 %s %s - %s",
homeDir, sshDirMode, user, user,
homeDir, user, user,
keysB64)

tmpfilesB64 := base64.StdEncoding.EncodeToString([]byte(tmpfiles))
return fmt.Sprintf("type=11,value=%stmpfiles.extra=%s", systemdCredentialPrefix, tmpfilesB64), nil
}

func sshHomeDir(user string) string {
if user == "root" {
return "/root"
}
return fmt.Sprintf("/var/home/%s", user)
}
66 changes: 66 additions & 0 deletions mantle/platform/smbios_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright 2026 Red Hat
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package platform

import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"strings"
"testing"

"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
)

func TestSystemdSMBIOSSSHCredential(t *testing.T) {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
signer, err := ssh.NewSignerFromKey(priv)
if err != nil {
t.Fatal(err)
}
pub := signer.PublicKey()
keys := []*agent.Key{{Format: pub.Type(), Blob: pub.Marshal()}}

val, err := SystemdSMBIOSSSHCredential("root", keys)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(val, "type=11,value=io.systemd.credential.binary:tmpfiles.extra=") {
t.Fatalf("unexpected smbios value prefix: %q", val)
}

payloadB64 := strings.TrimPrefix(val, "type=11,value=io.systemd.credential.binary:tmpfiles.extra=")
tmpfiles, err := base64.StdEncoding.DecodeString(payloadB64)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(tmpfiles), "/root/.ssh") {
t.Fatalf("tmpfiles missing root ssh dir: %q", tmpfiles)
}
if !strings.Contains(string(tmpfiles), "authorized_keys") {
t.Fatalf("tmpfiles missing authorized_keys: %q", tmpfiles)
}
}

func TestSystemdSMBIOSSSHCredentialNoKeys(t *testing.T) {
_, err := SystemdSMBIOSSSHCredential("root", nil)
if err == nil {
t.Fatal("expected error for empty keys")
}
}