Skip to content
Open
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 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)")
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 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"
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 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 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
84 changes: 84 additions & 0 deletions mantle/platform/credentials.go
Original file line number Diff line number Diff line change
@@ -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)
}
110 changes: 110 additions & 0 deletions mantle/platform/credentials_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading