diff --git a/mantle/cmd/kola/options.go b/mantle/cmd/kola/options.go index f61c03ac99..b88864dcd1 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 virtiofs systemd 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 537dc43ee5..a4c08a0287 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 with kola --no-ignition. +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" @@ -1798,6 +1802,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 f1a7e6ea24..417cb5f8ad 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", 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/credentials.go b/mantle/platform/credentials.go new file mode 100644 index 0000000000..17add2c19d --- /dev/null +++ b/mantle/platform/credentials.go @@ -0,0 +1,84 @@ +// 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" + "os" + "path/filepath" + "strings" + + "golang.org/x/crypto/ssh/agent" +) + +// SystemdCredentialVirtiofsTag is the well-known virtiofs tag used to pass +// systemd credentials into a VM. The guest initramfs imports files from this +// share into /run/credentials/@initrd/. See also: +// https://github.com/coreos/fedora-coreos-config/pull/4230 +const SystemdCredentialVirtiofsTag = "io.systemd.credentials" + +const systemdTmpfilesExtraCredential = "tmpfiles.extra" + +// SystemdSSHtmpfilesExtra builds the tmpfiles.extra systemd credential content +// that provisions SSH authorized_keys for the given user. +// See https://systemd.io/CREDENTIALS/ +func SystemdSSHtmpfilesExtra(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" + } + + return 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), nil +} + +// WriteSystemdSSHCredentialsDir writes tmpfiles.extra into dir for import +// via a virtiofs share tagged SystemdCredentialVirtiofsTag. +func WriteSystemdSSHCredentialsDir(dir, user string, keys []*agent.Key) error { + content, err := SystemdSSHtmpfilesExtra(user, keys) + if err != nil { + return err + } + path := filepath.Join(dir, systemdTmpfilesExtraCredential) + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + return fmt.Errorf("writing %s: %w", path, err) + } + return nil +} + +func sshHomeDir(user string) string { + if user == "root" { + return "/root" + } + return fmt.Sprintf("/var/home/%s", user) +} diff --git a/mantle/platform/credentials_test.go b/mantle/platform/credentials_test.go new file mode 100644 index 0000000000..824a9d829a --- /dev/null +++ b/mantle/platform/credentials_test.go @@ -0,0 +1,110 @@ +// 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/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/base64" + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" +) + +func testSSHKeys(t *testing.T) []*agent.Key { + t.Helper() + signer, err := ssh.NewSignerFromKey(mustGenerateTestKey(t)) + if err != nil { + t.Fatal(err) + } + pub := signer.PublicKey() + return []*agent.Key{{ + Format: pub.Type(), + Blob: pub.Marshal(), + Comment: "test@example.com", + }} +} + +func mustGenerateTestKey(t *testing.T) any { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + return key +} + +func TestSystemdSSHtmpfilesExtra(t *testing.T) { + keys := testSSHKeys(t) + + content, err := SystemdSSHtmpfilesExtra("root", keys) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(content, "d /root/.ssh 0750 root root -") { + t.Errorf("missing .ssh directory line: %q", content) + } + if !strings.Contains(content, "f~ /root/.ssh/authorized_keys 0600 root root - ") { + t.Errorf("missing authorized_keys line: %q", content) + } + + parts := strings.Split(content, " - ") + if len(parts) < 2 { + t.Fatalf("expected base64 keys suffix in tmpfiles content: %q", content) + } + keysB64 := strings.TrimSpace(parts[len(parts)-1]) + decoded, err := base64.StdEncoding.DecodeString(keysB64) + if err != nil { + t.Fatalf("decoding keys: %v", err) + } + if !strings.Contains(string(decoded), "test@example.com") { + t.Errorf("decoded keys missing comment: %q", decoded) + } +} + +func TestWriteSystemdSSHCredentialsDir(t *testing.T) { + dir := t.TempDir() + keys := testSSHKeys(t) + + if err := WriteSystemdSSHCredentialsDir(dir, "root", keys); err != nil { + t.Fatal(err) + } + + path := filepath.Join(dir, systemdTmpfilesExtraCredential) + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "/root/.ssh/authorized_keys") { + t.Errorf("unexpected credential content: %q", data) + } +} + +func TestSystemdSSHtmpfilesExtraErrors(t *testing.T) { + _, err := SystemdSSHtmpfilesExtra("", nil) + if err == nil { + t.Fatal("expected error for empty user") + } + _, err = SystemdSSHtmpfilesExtra("root", nil) + if err == nil { + t.Fatal("expected error for empty keys") + } +} diff --git a/mantle/platform/machine/qemu/cluster.go b/mantle/platform/machine/qemu/cluster.go index 26362cdd66..8c0ccd930c 100644 --- a/mantle/platform/machine/qemu/cluster.go +++ b/mantle/platform/machine/qemu/cluster.go @@ -79,14 +79,28 @@ 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 { + qemuBuilder.SetConfig(config) + } if err := builder.InitBuilder(options, qemuBuilder); err != nil { return nil, err } @@ -106,7 +120,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 @@ -132,6 +148,31 @@ func (qc *Cluster) NewMachineWithBuilder(userdata any, options platform.MachineO } } + // When --no-ignition is set, provision SSH keys via virtiofs systemd + // credentials. This creates a temporary directory with a tmpfiles.extra + // systemd credential file that sets up ~/.ssh/authorized_keys for the + // SSH user, and shares it with the guest via virtiofs using the tag + // "io.systemd.credentials". The guest must have + // import-virtiofs-systemd-credentials installed to import these systemd + // credentials at boot. + // + // This approach works on all architectures (x86_64, aarch64, ppc64le, + // s390x) unlike SMBIOS OEM strings or fw_cfg which are limited to + // specific architectures. + if noIgnition { + keys, err := qc.Keys() + if err != nil { + return nil, fmt.Errorf("getting SSH keys: %w", err) + } + systemdCredDir, err := qemuBuilder.InitSystemdCredentialDir() + if err != nil { + return nil, err + } + if err := platform.WriteSystemdSSHCredentialsDir(systemdCredDir, rconf.SSHUser, keys); err != nil { + return nil, err + } + } + // Since we are on qemu let's just use non-network based journal // (if we have a config to add it to) so that we'll get it even // if offline OR if networking for some reason doesn't come up. @@ -161,9 +202,8 @@ func (qc *Cluster) NewMachineWithBuilder(userdata any, options platform.MachineO } // Run StartMachine, which blocks on the machine being booted up enough - // for SSH access, but only if we have a config, else there's no - // SSH key for us to use to get in so don't bother. - if config != nil { + // for SSH access. With --no-ignition, SSH keys come from virtiofs credentials. + if config != nil || noIgnition { if err := platform.StartMachine(qm, qm.journal); err != nil { qm.Destroy() return nil, err @@ -243,9 +283,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 313e17cd39..5d6743a319 100644 --- a/mantle/platform/machine/qemu/flight.go +++ b/mantle/platform/machine/qemu/flight.go @@ -68,6 +68,9 @@ type Options struct { // Option to create IBM cex based luks encryption Cex bool + // NoIgnition skips Ignition; SSH keys are provisioned via virtiofs credentials. + NoIgnition bool + *platform.Options } diff --git a/mantle/platform/platform.go b/mantle/platform/platform.go index 7adeef689c..695ffa4468 100644 --- a/mantle/platform/platform.go +++ b/mantle/platform/platform.go @@ -303,6 +303,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 virtiofs systemd credentials instead. + NoIgnition bool + // SSHUser overrides the default SSH user (core) when set. + SSHUser string } // Wrap a StdoutPipe as a io.ReadCloser diff --git a/mantle/platform/qemu.go b/mantle/platform/qemu.go index 59f332420e..7a461b0099 100644 --- a/mantle/platform/qemu.go +++ b/mantle/platform/qemu.go @@ -551,6 +551,13 @@ type QemuBuilder struct { virtioSerialID uint // hostMounts is an array of directories mounted (via 9p or virtiofs) from the host hostMounts []HostMount + // systemdCredentialDir is a host directory containing systemd credential + // files to share with the guest via virtiofs using SystemdCredentialVirtiofsTag. + // The guest must have import-virtiofs-systemd-credentials (or equivalent) to + // mount the share and import credentials into /run/credentials/@initrd/ or + // /run/credentials/@system/. + // See https://systemd.io/CREDENTIALS/ and https://github.com/systemd/systemd/issues/29175 + systemdCredentialDir string // fds is file descriptors we own to pass to qemu fds []*os.File @@ -854,6 +861,37 @@ func (builder *QemuBuilder) MountHost(source, dest string, readonly bool) { builder.hostMounts = append(builder.hostMounts, HostMount{src: source, dest: dest, readonly: readonly}) } +// InitSystemdCredentialDir creates a directory under the builder tempdir for +// systemd credential files. It is cleaned up when the builder or VM instance +// is destroyed. +func (builder *QemuBuilder) InitSystemdCredentialDir() (string, error) { + if err := builder.ensureTempdir(); err != nil { + return "", err + } + dir, err := os.MkdirTemp(builder.tempdir, "systemd-credentials-*") + if err != nil { + return "", fmt.Errorf("creating systemd credential dir: %w", err) + } + builder.systemdCredentialDir = dir + return dir, nil +} + +// MountSystemdCredentialDir shares a host directory with the guest via virtiofs +// using SystemdCredentialVirtiofsTag. The guest must run +// import-virtiofs-systemd-credentials (or equivalent) to mount this share and +// copy systemd credential files into /run/credentials/@initrd/ or +// /run/credentials/@system/. +// +// This provides a cross-architecture alternative to SMBIOS OEM strings and +// fw_cfg for passing systemd credentials to VMs. Unlike those mechanisms, +// virtiofs works on all architectures (x86_64, aarch64, ppc64le, s390x). +// +// See https://systemd.io/CREDENTIALS/ and fedora-coreos-config +// import-virtiofs-systemd-credentials. +func (builder *QemuBuilder) MountSystemdCredentialDir(dir string) { + builder.systemdCredentialDir = dir +} + // supportsFwCfg if the target system supports injecting // Ignition via the qemu -fw_cfg option. func (builder *QemuBuilder) supportsFwCfg() bool { @@ -1766,8 +1804,11 @@ func (builder *QemuBuilder) VirtioJournal(config *conf.Conf, queryArguments ...s } // createVirtiofsCmd returns a new command instance configured to launch virtiofsd. -func createVirtiofsCmd(directory, socketPath string) exec.Cmd { +func createVirtiofsCmd(directory, socketPath string, readonly bool) exec.Cmd { args := []string{"--sandbox", "none", "--socket-path", socketPath, "--shared-dir", "."} + if readonly { + args = append(args, "--readonly") + } // Work around https://gitlab.com/virtio-fs/virtiofsd/-/merge_requests/197 if os.Getuid() == 0 { args = append(args, "--modcaps=-mknod:-setfcap") @@ -2002,8 +2043,19 @@ func (builder *QemuBuilder) Exec() (*QemuInstance, error) { return nil, err } + // Build the list of virtiofs mounts: regular host mounts plus systemd credential dir + allVirtioFSMounts := make([]HostMount, 0, len(builder.hostMounts)+1) + allVirtioFSMounts = append(allVirtioFSMounts, builder.hostMounts...) + if builder.systemdCredentialDir != "" { + allVirtioFSMounts = append(allVirtioFSMounts, HostMount{ + src: builder.systemdCredentialDir, + dest: SystemdCredentialVirtiofsTag, + readonly: true, + }) + } + // Process virtiofs mounts - if len(builder.hostMounts) > 0 { + if len(allVirtioFSMounts) > 0 { if err := builder.ensureTempdir(); err != nil { return nil, err } @@ -2012,7 +2064,7 @@ func (builder *QemuBuilder) Exec() (*QemuInstance, error) { // Spawn off a virtiofsd helper per mounted path virtiofsHelpers := make(map[string]exec.Cmd) - for i, hostmnt := range builder.hostMounts { + for i, hostmnt := range allVirtioFSMounts { // By far the most common failure to spawn virtiofsd will be a typo'd source directory, // so let's synchronously check that ourselves here. if _, err := os.Stat(hostmnt.src); err != nil { @@ -2022,9 +2074,8 @@ func (builder *QemuBuilder) Exec() (*QemuInstance, error) { virtiofsdSocket := filepath.Join(builder.tempdir, fmt.Sprintf("virtiofsd-%d.sock", i)) builder.Append("-chardev", fmt.Sprintf("socket,id=%s,path=%s", virtiofsChar, virtiofsdSocket)) builder.Append("-device", fmt.Sprintf("vhost-user-fs-pci,queue-size=1024,chardev=%s,tag=%s", virtiofsChar, hostmnt.dest)) - plog.Debugf("creating virtiofs helper for %s", hostmnt.src) - // TODO: Honor hostmnt.readonly somehow here (add an option to virtiofsd) - p := createVirtiofsCmd(hostmnt.src, virtiofsdSocket) + plog.Debugf("creating virtiofs helper for %s (tag=%s)", hostmnt.src, hostmnt.dest) + p := createVirtiofsCmd(hostmnt.src, virtiofsdSocket, hostmnt.readonly) if err := p.Start(); err != nil { return nil, fmt.Errorf("failed to start virtiofsd") } @@ -2099,10 +2150,11 @@ func (builder *QemuBuilder) Exec() (*QemuInstance, error) { plog.Debugf("Started qemu (%v) with args: %v", inst.qemu.Pid(), argv) + cleanupInst = true + // Transfer ownership of the tempdir inst.tempdir = builder.tempdir builder.tempdir = "" - cleanupInst = false // Connect to the QMP socket which allows us to control qemu. We wait up to 30s // to avoid flakes on loaded CI systems. But, probably rather than bumping this @@ -2159,6 +2211,7 @@ func (builder *QemuBuilder) Exec() (*QemuInstance, error) { }() } + cleanupInst = false return &inst, nil }