diff --git a/mantle/cmd/kola/options.go b/mantle/cmd/kola/options.go index 0561f297a7..f2b150d8dc 100644 --- a/mantle/cmd/kola/options.go +++ b/mantle/cmd/kola/options.go @@ -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)") @@ -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 { diff --git a/mantle/kola/harness.go b/mantle/kola/harness.go index 3340b47aa4..1b299d2649 100644 --- a/mantle/kola/harness.go +++ b/mantle/kola/harness.go @@ -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" @@ -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 diff --git a/mantle/kola/tests/coretest/core.go b/mantle/kola/tests/coretest/core.go index 2d8a3592cf..e6389af8e6 100644 --- a/mantle/kola/tests/coretest/core.go +++ b/mantle/kola/tests/coretest/core.go @@ -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" ) @@ -57,6 +58,7 @@ func init() { Run: LocalTests, ClusterSize: 1, NativeFuncs: nativeFuncs, + Tags: []string{kola.BootcBaseTag}, }) register.RegisterTest(®ister.Test{ Name: "basic.uefi", @@ -69,6 +71,7 @@ func init() { MachineOptions: platform.MachineOptions{ Firmware: uefi, }, + Tags: []string{kola.BootcBaseTag}, }) register.RegisterTest(®ister.Test{ Name: "basic.uefi-secure", @@ -81,6 +84,7 @@ func init() { MachineOptions: platform.MachineOptions{ Firmware: uefiSecure, }, + Tags: []string{kola.BootcBaseTag}, }) register.RegisterTest(®ister.Test{ Name: "basic.nvme", @@ -95,6 +99,7 @@ func init() { MachineOptions: platform.MachineOptions{ Nvme: true, }, + Tags: []string{kola.BootcBaseTag}, }) register.RegisterTest(®ister.Test{ Name: "rootfs.uuid", @@ -116,6 +121,7 @@ func init() { "ServicesDisabled": register.CreateNativeFuncWrap(TestServicesDisabledRHCOS), }, Distros: []string{"rhcos"}, + Tags: []string{kola.BootcBaseTag}, }) } diff --git a/mantle/platform/cluster.go b/mantle/platform/cluster.go index 52a33257f1..77bae75c0a 100644 --- a/mantle/platform/cluster.go +++ b/mantle/platform/cluster.go @@ -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 @@ -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 diff --git a/mantle/platform/machine/qemu/cluster.go b/mantle/platform/machine/qemu/cluster.go index b794b73215..3e7d1ca4e1 100644 --- a/mantle/platform/machine/qemu/cluster.go +++ b/mantle/platform/machine/qemu/cluster.go @@ -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) 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 } @@ -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 @@ -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() @@ -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) diff --git a/mantle/platform/machine/qemu/flight.go b/mantle/platform/machine/qemu/flight.go index 1ceab87fe1..bf7e4c3dc3 100644 --- a/mantle/platform/machine/qemu/flight.go +++ b/mantle/platform/machine/qemu/flight.go @@ -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 } diff --git a/mantle/platform/platform.go b/mantle/platform/platform.go index 810a22d0cd..6b143e2252 100644 --- a/mantle/platform/platform.go +++ b/mantle/platform/platform.go @@ -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 diff --git a/mantle/platform/qemu.go b/mantle/platform/qemu.go index 431d37dc5c..20361f2c7d 100644 --- a/mantle/platform/qemu.go +++ b/mantle/platform/qemu.go @@ -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 @@ -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()) @@ -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") diff --git a/mantle/platform/smbios.go b/mantle/platform/smbios.go new file mode 100644 index 0000000000..b64c18af7a --- /dev/null +++ b/mantle/platform/smbios.go @@ -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) +} diff --git a/mantle/platform/smbios_test.go b/mantle/platform/smbios_test.go new file mode 100644 index 0000000000..00efeb57b8 --- /dev/null +++ b/mantle/platform/smbios_test.go @@ -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") + } +}