diff --git a/cmd/metis/gate_test.go b/cmd/metis/gate_test.go index b213380..52f7b36 100644 --- a/cmd/metis/gate_test.go +++ b/cmd/metis/gate_test.go @@ -35,6 +35,11 @@ if [[ "${1:-}" == "-R" ]]; then set -- $2 case "${1:-}" in stat) + link="$(awk -v path="${2:-}" '$1=="symlink" && $2==path {print $3}' "${state}" | tail -n1)" + if [ -n "${link}" ]; then + printf 'Type: symlink\nFast link dest: "%s"\n' "${link}" + exit 0 + fi mode="$(awk -v path="${2:-}" '$1=="sif" && $2==path {print $4}' "${state}" | tail -n1)" mode="${mode: -4}" printf 'Mode: %s\n' "${mode}" diff --git a/cmd/metis/main_test.go b/cmd/metis/main_test.go index f1d343f..ec7dcf9 100644 --- a/cmd/metis/main_test.go +++ b/cmd/metis/main_test.go @@ -73,6 +73,11 @@ if [[ "${1:-}" == "-R" ]]; then set -- $2 case "${1:-}" in stat) + link="$(awk -v path="${2:-}" '$1=="symlink" && $2==path {print $3}' "${state}" | tail -n1)" + if [ -n "${link}" ]; then + printf 'Type: symlink\nFast link dest: "%s"\n' "${link}" + exit 0 + fi mode="$(awk -v path="${2:-}" '$1=="sif" && $2==path {print $4}' "${state}" | tail -n1)" mode="${mode: -4}" printf 'Mode: %s\n' "${mode}" @@ -166,6 +171,11 @@ if [[ "${1:-}" == "-R" ]]; then set -- $2 case "${1:-}" in stat) + link="$(awk -v path="${2:-}" '$1=="symlink" && $2==path {print $3}' "${state}" | tail -n1)" + if [ -n "${link}" ]; then + printf 'Type: symlink\nFast link dest: "%s"\n' "${link}" + exit 0 + fi mode="$(awk -v path="${2:-}" '$1=="sif" && $2==path {print $4}' "${state}" | tail -n1)" mode="${mode: -4}" printf 'Mode: %s\n' "${mode}" diff --git a/cmd/metis/remote_edge_test.go b/cmd/metis/remote_edge_test.go index 6052c47..72a3593 100644 --- a/cmd/metis/remote_edge_test.go +++ b/cmd/metis/remote_edge_test.go @@ -402,6 +402,11 @@ if [[ "${1:-}" == "-R" ]]; then set -- $2 case "${1:-}" in stat) + link="$(awk -v path="${2:-}" '$1=="symlink" && $2==path {print $3}' "${state}" | tail -n1)" + if [ -n "${link}" ]; then + printf 'Type: symlink\nFast link dest: "%s"\n' "${link}" + exit 0 + fi mode="$(awk -v path="${2:-}" '$1=="sif" && $2==path {print $4}' "${state}" | tail -n1)" mode="${mode: -4}" printf 'Mode: %s\n' "${mode}" diff --git a/docs/node-administration.md b/docs/node-administration.md new file mode 100644 index 0000000..7812acd --- /dev/null +++ b/docs/node-administration.md @@ -0,0 +1,54 @@ +# Node administration after recovery + +Use the normal node SSH account and password-protected sudo. Passwords live in +Vault at `kv/atlas/nodes/` under `atlas_password` and `root_password`. +The SSH key remains the normal login mechanism; a console root password does +not require allowing root password login over SSH. + +Recovery injects a native `metis-node-identity.service`, enabled in the image. +It applies passwords even when cloud-init is absent. Each new injection creates +`/etc/metis/node-identity.pending`, so an old completion marker in the base image +cannot suppress the new identity. Cloud-init and the native unit serialize on +one lock. Failed credential setup remains a failed unit with the pending marker +intact; the Longhorn first-boot helper no longer ignores that failure. + +Both administrator passwords must be supplied. The generated first-boot file is +root-readable only. After applying passwords, the script replaces it with the +non-secret boot metadata. This is removal from the active filesystem, not a +claim of forensic erasure from flash media or old image copies. + +Default Metis passwordless command grants are removed. Existing sudo-group +administration remains password protected. Do not provision a substitute +NOPASSWD rule when password setup fails. + +## Existing-node repair, October 4, 2026 + +Titan-12/13/19 retained root SSH keys but had locked atlas passwords; their root +passwords also differed from Vault. Both accounts were restored to their existing +Vault credentials without rebooting. A separate fresh SSH session authenticated +sudo with the Vault atlas password and reached UID 0 on each node. Titan-20/21's +existing unlocked root passwords were also restored to their Vault values. + +The Atlas repository contains `scripts/node_admin_access.py` for an explicit +hostname-checked audit/repair. It accepts credentials only on stdin and emits +booleans, never passwords or hashes. Consult Atlas's cluster operator guide for +the current verification record and unavailable nodes. Existing native Ubuntu +root-account locks do not prevent full administration through atlas and sudo. + +Do not apply a recovered node's old firstboot.env blindly to a live node: fetch +that node's current Vault record and verify the hostname first. Verify independent +password-backed sudo before retiring a legacy Metis grant. + +## Checks after burning a replacement image + +1. `systemctl status metis-node-identity.service` +2. Confirm `/etc/metis/node-identity.pending` is absent. +3. Log in over a separate SSH connection and run `sudo -k -v`, using the stored + atlas password; then `sudo id -u` must print `0`. +4. Confirm `firstboot.env` contains no password variables, without printing the + file into shared logs. Preserve the existing authorized keys. +5. Check host/storage health before uncordoning the Kubernetes node through Flux. + +The image and script tests exercise password failure, successful retry, repeated +execution, ext4 symlink enablement, and credential-file permissions. These tests +are not a physical image boot validation; do that on the next replacement medium. diff --git a/overlays/rpi4-armbian-longhorn-root/usr/local/sbin/metis-rpi4-longhorn-firstboot.sh b/overlays/rpi4-armbian-longhorn-root/usr/local/sbin/metis-rpi4-longhorn-firstboot.sh index e4fb2b3..bec319d 100755 --- a/overlays/rpi4-armbian-longhorn-root/usr/local/sbin/metis-rpi4-longhorn-firstboot.sh +++ b/overlays/rpi4-armbian-longhorn-root/usr/local/sbin/metis-rpi4-longhorn-firstboot.sh @@ -5,7 +5,6 @@ marker="/var/lib/metis/rpi4-longhorn-firstboot.done" env_file="/etc/metis/firstboot.env" key_file="/etc/metis/authorized_keys" fstab_append="/etc/metis/fstab.append" -sudoers_file="/etc/metis/sudoers-hecate" default_groups=(tty disk dialout sudo audio video plugdev games users systemd-journal input render netdev) exec > >(tee -a /var/log/metis-rpi4-longhorn-firstboot.log) 2>&1 @@ -143,19 +142,8 @@ if [ -s "${key_file}" ]; then fi fi -if [ -s "${sudoers_file}" ]; then - install -d -m 755 /etc/sudoers.d - install -m 440 "${sudoers_file}" /etc/sudoers.d/90-hecate-atlas - if command -v visudo >/dev/null 2>&1; then - if ! visudo -cf /etc/sudoers.d/90-hecate-atlas >/dev/null 2>&1; then - echo "WARN: invalid /etc/sudoers.d/90-hecate-atlas generated by metis; removing it." - rm -f /etc/sudoers.d/90-hecate-atlas - fi - fi -fi - if [ -x /usr/local/sbin/metis-apply-node-identity.sh ]; then - /usr/local/sbin/metis-apply-node-identity.sh || true + /usr/local/sbin/metis-apply-node-identity.sh fi rm -f /root/.not_logged_in_yet diff --git a/pkg/image/rootfs.go b/pkg/image/rootfs.go index 6ee3cd0..9c3eed9 100644 --- a/pkg/image/rootfs.go +++ b/pkg/image/rootfs.go @@ -270,6 +270,13 @@ func writeExt4Files(fsPath string, files []inject.FileSpec) error { destPath := "/" + strings.TrimPrefix(filepath.ToSlash(f.Path), "/") localPath := filepath.Join(stageDir, filepath.FromSlash(f.Path)) commands = append(commands, fmt.Sprintf("rm %s", destPath)) + if f.Symlink != "" { + if filepath.IsAbs(f.Symlink) || strings.ContainsAny(f.Symlink, "\n\r\t \"") { + return fmt.Errorf("invalid relative link target for %s", destPath) + } + commands = append(commands, fmt.Sprintf("symlink %s %s", destPath, f.Symlink)) + continue + } commands = append(commands, fmt.Sprintf("write %s %s", localPath, destPath)) commands = append(commands, fmt.Sprintf("sif %s mode 0%o", destPath, uint32(0o100000|f.Mode.Perm()))) } @@ -340,6 +347,13 @@ func verifyExt4File(fsPath string, file inject.FileSpec, workDir string) error { if err != nil { return fmt.Errorf("verify %s: %w: %s", destPath, err, string(statOut)) } + if file.Symlink != "" { + if !strings.Contains(string(statOut), "Type: symlink") || + !strings.Contains(string(statOut), fmt.Sprintf("Fast link dest: \"%s\"", file.Symlink)) { + return fmt.Errorf("verify %s symlink mismatch", destPath) + } + return nil + } expectedMode := fmt.Sprintf("Mode: %04o", file.Mode.Perm()) if !strings.Contains(string(statOut), expectedMode) { return fmt.Errorf("verify %s mode: expected %s in %s", destPath, expectedMode, string(statOut)) diff --git a/pkg/image/systemd_enablement_test.go b/pkg/image/systemd_enablement_test.go new file mode 100644 index 0000000..cbf4016 --- /dev/null +++ b/pkg/image/systemd_enablement_test.go @@ -0,0 +1,44 @@ +package image + +import ( + "metis/pkg/inject" + "os" + "os/exec" + "path/filepath" + "testing" +) + +// Use a disposable filesystem image to verify actual debugfs symlink semantics. +func TestSystemdSymlinkOnExt4(t *testing.T) { + for _, tool := range []string{"mkfs.ext4", "debugfs"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skip("filesystem tools unavailable") + } + } + fs := filepath.Join(t.TempDir(), "test.ext4") + f, err := os.Create(fs) + if err != nil { + t.Fatal(err) + } + if err = f.Truncate(16 * 1024 * 1024); err != nil { + t.Fatal(err) + } + if err = f.Close(); err != nil { + t.Fatal(err) + } + if out, err := exec.Command("mkfs.ext4", "-q", "-F", fs).CombinedOutput(); err != nil { + t.Fatalf("mkfs: %v %s", err, out) + } + files := []inject.FileSpec{ + {Path: "etc/systemd/system/metis-node-identity.service", Content: []byte("[Unit]\nDescription=Test\n"), Mode: 0644, RootFS: true}, + {Path: "etc/systemd/system/multi-user.target.wants/metis-node-identity.service", Symlink: "../metis-node-identity.service", RootFS: true}, + } + for i := 0; i < 2; i++ { + if err := writeExt4Files(fs, files); err != nil { + t.Fatal(err) + } + } + if err := verifyExt4File(fs, inject.FileSpec{Path: files[1].Path, Symlink: "../wrong.service"}, t.TempDir()); err == nil { + t.Fatal("wrong target accepted") + } +} diff --git a/pkg/inject/inject.go b/pkg/inject/inject.go index 933464d..5f4469b 100644 --- a/pkg/inject/inject.go +++ b/pkg/inject/inject.go @@ -6,6 +6,8 @@ import ( "path/filepath" ) +var setFileMode = os.Chmod + // Injector writes node config into a mounted image (boot/root paths supplied by caller). type Injector struct { BootPath string @@ -17,7 +19,8 @@ type FileSpec struct { Path string Content []byte Mode os.FileMode - RootFS bool // if true, write under root path; else boot path + RootFS bool // if true, write under root path; else boot path + Symlink string // relative target for native systemd enablement } // Write materializes the requested files under the boot or root mount because @@ -32,9 +35,24 @@ func (i *Injector) Write(files []FileSpec) error { if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return fmt.Errorf("mkdir %s: %w", filepath.Dir(target), err) } + if f.Symlink != "" { + if current, err := os.Readlink(target); err == nil && current == f.Symlink { + continue + } + // Refuse to replace unrelated files in the mounted image. + if err := os.Symlink(f.Symlink, target); err != nil { + return fmt.Errorf("link %s: %w", target, err) + } + continue + } if err := os.WriteFile(target, f.Content, f.Mode); err != nil { return fmt.Errorf("write %s: %w", target, err) } + // WriteFile retains an existing file's permissions, including loose + // permissions inherited from a recovery image. + if err := setFileMode(target, f.Mode); err != nil { + return fmt.Errorf("chmod %s: %w", target, err) + } } return nil } diff --git a/pkg/inject/inject_test.go b/pkg/inject/inject_test.go index fc9300a..fcb64d8 100644 --- a/pkg/inject/inject_test.go +++ b/pkg/inject/inject_test.go @@ -39,3 +39,46 @@ func TestWriteReturnsFilesystemErrors(t *testing.T) { t.Fatal("expected write error for root path file") } } + +func TestWriteSystemdEnablementAndSecretPermissions(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "secret") + if err := os.WriteFile(target, []byte("old"), 0644); err != nil { + t.Fatal(err) + } + files := []FileSpec{ + {Path: "secret", Content: []byte("synthetic"), Mode: 0600, RootFS: true}, + {Path: "system/wants/unit", Symlink: "../unit", RootFS: true}, + } + inj := Injector{RootPath: root} + for n := 0; n < 2; n++ { + if err := inj.Write(files); err != nil { + t.Fatal(err) + } + } + info, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatal("old loose permissions retained") + } + link, err := os.Readlink(filepath.Join(root, "system/wants/unit")) + if err != nil || link != "../unit" { + t.Fatal(link, err) + } + if err := inj.Write([]FileSpec{{Path: "secret", Symlink: "../unit", RootFS: true}}); err == nil { + t.Fatal("overwrote existing regular file") + } +} + +func TestSecretPermissionFailureIsReported(t *testing.T) { + previous := setFileMode + setFileMode = func(string, os.FileMode) error { return os.ErrPermission } + defer func() { setFileMode = previous }() + inj := Injector{RootPath: t.TempDir()} + err := inj.Write([]FileSpec{{Path: "secret", Content: []byte("synthetic"), Mode: 0600, RootFS: true}}) + if err == nil { + t.Fatal("secret permission failure was ignored") + } +} diff --git a/pkg/plan/inject.go b/pkg/plan/inject.go index 0d4f63c..8320616 100644 --- a/pkg/plan/inject.go +++ b/pkg/plan/inject.go @@ -107,6 +107,9 @@ func Inject(inv *inventory.Inventory, nodeName, boot, root string) error { func buildFiles(cfg *config.NodeConfig, sec *secrets.NodeSecrets) ([]inject.FileSpec, error) { files := []inject.FileSpec{ + {Path: "etc/metis/node-identity.pending", Content: []byte("Apply the injected node identity before accepting recovery as complete.\n"), Mode: 0o600, RootFS: true}, + {Path: "etc/systemd/system/metis-node-identity.service", Content: []byte(nodeIdentityUnitContent()), Mode: 0o644, RootFS: true}, + {Path: "etc/systemd/system/multi-user.target.wants/metis-node-identity.service", Symlink: "../metis-node-identity.service", RootFS: true}, {Path: "etc/hostname", Content: []byte(cfg.Hostname + "\n"), Mode: 0o644, RootFS: true}, {Path: "etc/hosts", Content: []byte(hostsContent(cfg.Hostname)), Mode: 0o644, RootFS: true}, {Path: "etc/rancher/k3s/config.yaml", Content: []byte(k3sConfigContent(cfg)), Mode: 0o644, RootFS: true}, @@ -157,21 +160,6 @@ func buildFiles(cfg *config.NodeConfig, sec *secrets.NodeSecrets) ([]inject.File RootFS: true, }) } - if cfg.SSHUser == "atlas" { - sudoers := hecateSudoersContent(cfg.SSHUser) - files = append(files, inject.FileSpec{ - Path: "etc/sudoers.d/90-hecate-atlas", - Content: []byte(sudoers), - Mode: 0o440, - RootFS: true, - }) - files = append(files, inject.FileSpec{ - Path: "etc/metis/sudoers-hecate", - Content: []byte(sudoers), - Mode: 0o440, - RootFS: true, - }) - } if len(cfg.Fstab) > 0 { files = append(files, inject.FileSpec{ Path: "etc/metis/fstab.append", @@ -346,13 +334,6 @@ func fstabAppendContent(cfg *config.NodeConfig) string { return strings.Join(lines, "\n") + "\n" } -func hecateSudoersContent(user string) string { - return fmt.Sprintf( - "%s ALL=(ALL) NOPASSWD: /usr/bin/systemctl, /usr/sbin/poweroff, /sbin/poweroff, /usr/local/bin/hecate, /usr/local/bin/k3s, /usr/bin/k3s\n", - user, - ) -} - func collectOverlays(class *inventory.NodeClass) ([]inject.FileSpec, error) { var files []inject.FileSpec if class == nil { diff --git a/pkg/plan/inject_test.go b/pkg/plan/inject_test.go index 95b4e9b..7653fe9 100644 --- a/pkg/plan/inject_test.go +++ b/pkg/plan/inject_test.go @@ -147,7 +147,7 @@ func TestSecretsWrite(t *testing.T) { } } -func TestBuildFilesAddsHecateSudoersForAtlas(t *testing.T) { +func TestBuildFilesDoesNotGrantPasswordlessSudo(t *testing.T) { cfg := &config.NodeConfig{ Hostname: "n1", IP: "10.0.0.10", @@ -165,13 +165,10 @@ func TestBuildFilesAddsHecateSudoersForAtlas(t *testing.T) { for _, f := range files { pathMap[f.Path] = string(f.Content) } - sudoers, ok := pathMap["etc/sudoers.d/90-hecate-atlas"] - if !ok || !strings.Contains(sudoers, "atlas ALL=(ALL) NOPASSWD: /usr/bin/systemctl") { - t.Fatalf("sudoers file missing/incorrect: %s", sudoers) - } - backup, ok := pathMap["etc/metis/sudoers-hecate"] - if !ok || backup != sudoers { - t.Fatalf("metis sudoers backup missing/incorrect: %s", backup) + for _, name := range []string{"etc/sudoers.d/90-hecate-atlas", "etc/metis/sudoers-hecate"} { + if _, ok := pathMap[name]; ok { + t.Fatalf("unexpected passwordless sudo grant: %s", name) + } } } diff --git a/pkg/plan/node_identity.go b/pkg/plan/node_identity.go index 769e210..bf53037 100644 --- a/pkg/plan/node_identity.go +++ b/pkg/plan/node_identity.go @@ -70,15 +70,20 @@ set -euo pipefail marker="/var/lib/metis/node-identity-applied.done" env_file="/etc/metis/firstboot.env" +pending="/etc/metis/node-identity.pending" key_file="/etc/metis/authorized_keys" -sudoers_file="/etc/metis/sudoers-hecate" default_groups=(adm sudo tty disk dialout audio video plugdev games users systemd-journal input render netdev) -if [ -f "${marker}" ]; then +if [ -f "${marker}" ] && [ ! -f "${pending}" ]; then exit 0 fi mkdir -p /var/lib/metis +umask 077 +# Cloud-init and native first boot can arrive together. Only one applies identity. +exec 9>/var/lib/metis/node-identity.lock +flock -x 9 +if [ -f "${marker}" ] && [ ! -f "${pending}" ]; then exit 0; fi if [ -f "${env_file}" ]; then # shellcheck disable=SC1090 . "${env_file}" @@ -88,6 +93,13 @@ atlas_user="${METIS_ATLAS_USER:-atlas}" ssh_user="${METIS_SSH_USER:-${atlas_user}}" atlas_password="${METIS_ATLAS_PASSWORD:-}" root_password="${METIS_ROOT_PASSWORD:-}" +if [ -z "${atlas_password}" ] || [ -z "${root_password}" ]; then + echo "Metis identity requires both administrator passwords; no completion marker written" >&2 + exit 1 +fi +case "${atlas_password}${root_password}" in + *$'\n'*|*$'\r'*) echo "Invalid password record" >&2; exit 1 ;; +esac group_list=() for group_name in "${default_groups[@]}"; do @@ -111,7 +123,7 @@ ensure_user() { useradd -m -s /bin/bash "${user_name}" fi elif [ -n "${group_csv}" ]; then - usermod -a -G "${group_csv}" "${user_name}" || true + usermod -a -G "${group_csv}" "${user_name}" fi } @@ -161,16 +173,19 @@ if [ -s "${key_file}" ]; then fi fi -if [ -s "${sudoers_file}" ]; then - install -d -m 755 /etc/sudoers.d - install -m 440 "${sudoers_file}" /etc/sudoers.d/90-hecate-atlas - if command -v visudo >/dev/null 2>&1; then - visudo -cf /etc/sudoers.d/90-hecate-atlas >/dev/null 2>&1 || rm -f /etc/sudoers.d/90-hecate-atlas - fi -fi - -systemctl restart ssh.service >/dev/null 2>&1 || systemctl restart sshd.service >/dev/null 2>&1 || systemctl restart ssh.socket >/dev/null 2>&1 || true +# Remove only the obsolete Metis-owned passwordless command grants. +rm -f /etc/sudoers.d/90-hecate-atlas /etc/metis/sudoers-hecate +visudo -c >/dev/null +# Keep boot metadata needed by other first-boot helpers, without passwords. +clean_env="$(mktemp /etc/metis/firstboot.env.XXXXXX)" +for variable in METIS_HOSTNAME METIS_SSH_USER METIS_ATLAS_USER METIS_K3S_VERSION; do + printf '%s=%q\n' "${variable}" "${!variable-}" >> "${clean_env}" +done +chmod 600 "${clean_env}" +mv "${clean_env}" "${env_file}" +unset atlas_password root_password METIS_ATLAS_PASSWORD METIS_ROOT_PASSWORD touch "${marker}" +rm -f "${pending}" ` } @@ -235,3 +250,24 @@ func shellQuote(value string) string { } return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" } + +// nodeIdentityUnitContent makes password setup independent of cloud-init support. +func nodeIdentityUnitContent() string { + return `[Unit] +Description=Apply Metis node identity once +After=local-fs.target cloud-config.service +Before=k3s-agent.service +ConditionPathExists=/etc/metis/firstboot.env +ConditionPathExists=/etc/metis/node-identity.pending + +[Service] +Type=oneshot +UMask=0077 +ExecStart=/usr/local/sbin/metis-apply-node-identity.sh +RemainAfterExit=yes +TimeoutStartSec=120 + +[Install] +WantedBy=multi-user.target +` +} diff --git a/pkg/plan/node_identity_boot_test.go b/pkg/plan/node_identity_boot_test.go new file mode 100644 index 0000000..f44c688 --- /dev/null +++ b/pkg/plan/node_identity_boot_test.go @@ -0,0 +1,91 @@ +package plan + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "metis/pkg/config" +) + +// Exercise the generated script with synthetic credentials and fake host tools. +func TestIdentityFailureAndRetry(t *testing.T) { + root := t.TempDir() + etc := filepath.Join(root, "etc/metis") + state := filepath.Join(root, "var/lib/metis") + bin := filepath.Join(root, "bin") + for _, dir := range []string{etc, state, bin, filepath.Join(root, "etc/sudoers.d")} { + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + } + envPath := filepath.Join(etc, "firstboot.env") + synthetic := "METIS_HOSTNAME='test'\nMETIS_ATLAS_PASSWORD='synthetic-atlas'\nMETIS_ROOT_PASSWORD='synthetic-root'\n" + if err := os.WriteFile(envPath, []byte(synthetic), 0600); err != nil { + t.Fatal(err) + } + commands := map[string]string{ + "id": "exit 0", "getent": "exit 1", "visudo": "exit 0", + "chpasswd": "cat >/dev/null\n[ \"${FAIL_PASSWORD:-0}\" = 0 ]", + } + for name, body := range commands { + if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\n"+body+"\n"), 0700); err != nil { + t.Fatal(err) + } + } + script := strings.NewReplacer("/etc/metis", etc, "/var/lib/metis", state, "/etc/sudoers.d", filepath.Join(root, "etc/sudoers.d")).Replace(nodeIdentityScriptContent()) + path := filepath.Join(root, "identity.sh") + if err := os.WriteFile(path, []byte(script), 0700); err != nil { + t.Fatal(err) + } + run := func(fail string) error { + cmd := exec.Command("bash", path) + cmd.Env = append(os.Environ(), "PATH="+bin+":"+os.Getenv("PATH"), "FAIL_PASSWORD="+fail) + out, err := cmd.CombinedOutput() + if strings.Contains(string(out), "synthetic-") { + t.Fatal("password in output") + } + return err + } + if run("1") == nil { + t.Fatal("password failure must propagate") + } + marker := filepath.Join(state, "node-identity-applied.done") + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatal("failed setup marked complete") + } + if err := run("0"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(marker); err != nil { + t.Fatal(err) + } + clean, err := os.ReadFile(envPath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(clean), "PASSWORD") || strings.Contains(string(clean), "synthetic-") { + t.Fatal("password persisted after application") + } + if err := run("1"); err != nil { + t.Fatal("completed setup should not reset passwords", err) + } +} + +func TestIdentityEnabledWithoutCloudInit(t *testing.T) { + files, err := buildFiles(&config.NodeConfig{Hostname: "test", SSHUser: "atlas"}, nil) + if err != nil { + t.Fatal(err) + } + enabled := false + for _, f := range files { + if f.Path == "etc/systemd/system/multi-user.target.wants/metis-node-identity.service" { + enabled = f.Symlink == "../metis-node-identity.service" + } + } + if !enabled { + t.Fatal("identity service is not enabled in image") + } +} diff --git a/pkg/plan/workflow_test.go b/pkg/plan/workflow_test.go index ad06389..48e4762 100644 --- a/pkg/plan/workflow_test.go +++ b/pkg/plan/workflow_test.go @@ -161,6 +161,11 @@ if [[ "${1:-}" == "-R" ]]; then set -- $2 case "${1:-}" in stat) + link="$(awk -v path="${2:-}" '$1=="symlink" && $2==path {print $3}' "${state}" | tail -n1)" + if [ -n "${link}" ]; then + printf 'Type: symlink\nFast link dest: "%s"\n' "${link}" + exit 0 + fi mode="$(awk -v path="${2:-}" '$1=="sif" && $2==path {print $4}' "${state}" | tail -n1)" mode="${mode: -4}" printf 'Mode: %s\n' "${mode}"