diff --git a/cmd/root.go b/cmd/root.go index e93a26b..6700607 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -79,6 +79,9 @@ func sandboxConfig() map[string]string { if cfg.SSH.Key != "" { m["ssh_key"] = cfg.SSH.Key } + if cfg.SSH.StrictHostKeysEnabled() { + m["ssh_known_hosts"] = config.KnownHostsPath() + } m["provision"] = strconv.FormatBool(cfg.Provision.IsEnabled()) m["devtools"] = strconv.FormatBool(cfg.Provision.DevToolsEnabled()) if cfg.Network.Egress != "" { diff --git a/internal/config/config.go b/internal/config/config.go index 88dd977..0c07e6d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -55,8 +55,18 @@ type Defaults struct { } type SSH struct { - User string `toml:"user" env:"PIXELS_SSH_USER"` - Key string `toml:"key" env:"PIXELS_SSH_KEY"` + User string `toml:"user" env:"PIXELS_SSH_USER"` + Key string `toml:"key" env:"PIXELS_SSH_KEY"` + StrictHostKeys *bool `toml:"strict_host_keys" env:"PIXELS_SSH_STRICT_HOST_KEYS"` +} + +// StrictHostKeysEnabled returns whether SSH host key verification is enabled. +// Defaults to true when not explicitly set. +func (s *SSH) StrictHostKeysEnabled() bool { + if s.StrictHostKeys == nil { + return true + } + return *s.StrictHostKeys } type Checkpoint struct { @@ -203,6 +213,12 @@ func (t *TrueNAS) InsecureSkipVerifyValue() bool { return *t.InsecureSkipVerify } +// KnownHostsPath returns the path to the pixels-managed SSH known_hosts file. +func KnownHostsPath() string { + dir := filepath.Dir(configPath()) + return filepath.Join(dir, "known_hosts") +} + func expandHome(path string) string { if strings.HasPrefix(path, "~/") { if home, err := os.UserHomeDir(); err == nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8e3f15e..fca0601 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -507,6 +507,78 @@ func TestConfigPathDefault(t *testing.T) { } } +func TestStrictHostKeysEnabled(t *testing.T) { + tests := []struct { + name string + val *bool + want bool + }{ + {"nil defaults to true", nil, true}, + {"explicit true", boolPtr(true), true}, + {"explicit false", boolPtr(false), false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := SSH{StrictHostKeys: tt.val} + if got := s.StrictHostKeysEnabled(); got != tt.want { + t.Errorf("StrictHostKeysEnabled() = %v, want %v", got, tt.want) + } + }) + } +} + +func boolPtr(v bool) *bool { return &v } + +func TestStrictHostKeysFromFile(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + + cfgDir := filepath.Join(dir, "pixels") + if err := os.MkdirAll(cfgDir, 0o755); err != nil { + t.Fatal(err) + } + + content := ` +[ssh] +strict_host_keys = false +` + if err := os.WriteFile(filepath.Join(cfgDir, "config.toml"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.SSH.StrictHostKeysEnabled() { + t.Error("StrictHostKeysEnabled() = true, want false (set in TOML)") + } +} + +func TestStrictHostKeysEnvOverride(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("PIXELS_SSH_STRICT_HOST_KEYS", "false") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.SSH.StrictHostKeysEnabled() { + t.Error("StrictHostKeysEnabled() = true, want false (env override)") + } +} + +func TestKnownHostsPath(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + + got := KnownHostsPath() + want := filepath.Join(dir, "pixels", "known_hosts") + if got != want { + t.Errorf("KnownHostsPath() = %q, want %q", got, want) + } +} + func TestExpandHome(t *testing.T) { home, err := os.UserHomeDir() if err != nil { diff --git a/internal/provision/provision.go b/internal/provision/provision.go index 419fc3f..9f353e3 100644 --- a/internal/provision/provision.go +++ b/internal/provision/provision.go @@ -72,13 +72,13 @@ type Runner struct { } // NewRunner creates a Runner that executes commands over SSH. -func NewRunner(host, user, keyPath string) *Runner { +func NewRunner(host, user, keyPath, knownHostsPath string) *Runner { return &Runner{ Host: host, User: user, KeyPath: keyPath, exec: &sshExecutor{ - cc: ssh.ConnConfig{Host: host, User: user, KeyPath: keyPath}, + cc: ssh.NewConnConfig(host, user, keyPath, knownHostsPath), }, } } diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go index 5354cc1..258bea2 100644 --- a/internal/provision/provision_test.go +++ b/internal/provision/provision_test.go @@ -16,7 +16,7 @@ func TestZmxCmd(t *testing.T) { } func TestNewRunner(t *testing.T) { - r := NewRunner("10.0.0.1", "root", "/tmp/key") + r := NewRunner("10.0.0.1", "root", "/tmp/key", "/tmp/known_hosts") if r.Host != "10.0.0.1" { t.Errorf("Host = %q, want %q", r.Host, "10.0.0.1") } diff --git a/internal/ssh/ssh.go b/internal/ssh/ssh.go index 8933f65..6fd59a9 100644 --- a/internal/ssh/ssh.go +++ b/internal/ssh/ssh.go @@ -1,6 +1,7 @@ package ssh import ( + "bytes" "context" "errors" "fmt" @@ -11,14 +12,28 @@ import ( "sort" "strings" "time" + + "github.com/deevus/pixels/internal/config" ) // ConnConfig holds the parameters for an SSH connection. +// Use NewConnConfig to construct — it ensures secure defaults. type ConnConfig struct { - Host string - User string - KeyPath string - Env map[string]string // optional, for SetEnv forwarding + Host string + User string + KeyPath string + Env map[string]string // optional, for SetEnv forwarding + KnownHostsPath string // path to known_hosts file for accept-new verification +} + +// NewConnConfig creates a ConnConfig with the given parameters. +func NewConnConfig(host, user, keyPath, knownHostsPath string) ConnConfig { + return ConnConfig{ + Host: host, + User: user, + KeyPath: keyPath, + KnownHostsPath: knownHostsPath, + } } // WaitReady polls the host's SSH port until it accepts connections or the timeout expires. @@ -115,9 +130,13 @@ func TestAuth(ctx context.Context, cc ConnConfig) error { // It is exported for use by callers that need to construct custom exec.Cmd // with non-standard Stdin/Stdout/Stderr (e.g. sandbox backends). func Args(cc ConnConfig) []string { + knownHosts := cc.KnownHostsPath + if knownHosts == "" { + knownHosts = config.KnownHostsPath() + } args := []string{ - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=" + os.DevNull, + "-o", "StrictHostKeyChecking=accept-new", + "-o", "UserKnownHostsFile=" + knownHosts, "-o", "PasswordAuthentication=no", "-o", "LogLevel=ERROR", } @@ -150,6 +169,34 @@ func Args(cc ConnConfig) []string { return args } +// RemoveKnownHost removes all entries for the given host from the known_hosts +// file. This is used to clean up stale entries when containers are +// created, destroyed, or restored from snapshots. It is a no-op if the +// known_hosts file does not exist or the path is empty. +func RemoveKnownHost(knownHostsPath, host string) error { + if knownHostsPath == "" || host == "" { + return nil + } + data, err := os.ReadFile(knownHostsPath) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("reading known_hosts: %w", err) + } + + prefix := []byte(host + " ") + var kept []byte + for _, line := range bytes.SplitAfter(data, []byte("\n")) { + if !bytes.HasPrefix(line, prefix) { + kept = append(kept, line...) + } + } + + return os.WriteFile(knownHostsPath, kept, 0o600) +} + + // consoleArgs builds SSH arguments for an interactive console session. // When remoteCmd is non-empty, -t is inserted to force PTY allocation // and the command is appended after user@host. diff --git a/internal/ssh/ssh_test.go b/internal/ssh/ssh_test.go index e6e30f4..6aa7649 100644 --- a/internal/ssh/ssh_test.go +++ b/internal/ssh/ssh_test.go @@ -29,18 +29,53 @@ func TestSSHArgs(t *testing.T) { } }) - t.Run("uses os.DevNull for UserKnownHostsFile", func(t *testing.T) { + t.Run("always uses accept-new even without explicit KnownHostsPath", func(t *testing.T) { args := Args(ConnConfig{Host: "10.0.0.1", User: "pixel"}) - want := "UserKnownHostsFile=" + os.DevNull + foundAcceptNew := false + for _, a := range args { + if a == "StrictHostKeyChecking=accept-new" { + foundAcceptNew = true + } + if a == "StrictHostKeyChecking=no" { + t.Error("should never use StrictHostKeyChecking=no") + } + if strings.Contains(a, os.DevNull) { + t.Errorf("should never use DevNull for known hosts, got %q", a) + } + } + if !foundAcceptNew { + t.Errorf("expected StrictHostKeyChecking=accept-new, got %v", args) + } + }) + + t.Run("accept-new with KnownHostsPath", func(t *testing.T) { + khFile := "/tmp/pixels-test-known-hosts" + args := Args(ConnConfig{Host: "10.0.0.1", User: "pixel", KnownHostsPath: khFile}) + + // Should use accept-new instead of no. + foundAcceptNew := false + for _, a := range args { + if a == "StrictHostKeyChecking=accept-new" { + foundAcceptNew = true + } + if a == "StrictHostKeyChecking=no" { + t.Error("should not use StrictHostKeyChecking=no when KnownHostsPath is set") + } + } + if !foundAcceptNew { + t.Errorf("expected StrictHostKeyChecking=accept-new, got %v", args) + } + + // Should use the provided known hosts file. + want := "UserKnownHostsFile=" + khFile found := false for _, a := range args { if a == want { found = true - break } } if !found { - t.Errorf("sshArgs should contain %q, got %v", want, args) + t.Errorf("expected %q, got %v", want, args) } }) @@ -113,6 +148,46 @@ func TestSSHArgs(t *testing.T) { }) } +func TestRemoveKnownHost(t *testing.T) { + t.Run("no-op when file is empty string", func(t *testing.T) { + if err := RemoveKnownHost("", "10.0.0.1"); err != nil { + t.Errorf("expected no error, got %v", err) + } + }) + + t.Run("no-op when file does not exist", func(t *testing.T) { + if err := RemoveKnownHost("/tmp/nonexistent-known-hosts-file", "10.0.0.1"); err != nil { + t.Errorf("expected no error for missing file, got %v", err) + } + }) + + t.Run("removes entry from existing file", func(t *testing.T) { + dir := t.TempDir() + khFile := dir + "/known_hosts" + // Use valid ssh-ed25519 key data (32 bytes base64-encoded with key type prefix). + key1 := "10.0.0.1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBVlGh5YxGBMp/DO3OjAHsMR0DVQS2DJnpOqaGP2MkNl\n" + key2 := "10.0.0.2 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKxvLhGmlN1sdag3FISwEVfAGwC+v3+x0v6qIFNyGmNd\n" + if err := os.WriteFile(khFile, []byte(key1+key2), 0o600); err != nil { + t.Fatal(err) + } + + if err := RemoveKnownHost(khFile, "10.0.0.1"); err != nil { + t.Fatalf("RemoveKnownHost: %v", err) + } + + data, err := os.ReadFile(khFile) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "10.0.0.1") { + t.Errorf("expected 10.0.0.1 to be removed, file contains: %s", data) + } + if !strings.Contains(string(data), "10.0.0.2") { + t.Errorf("expected 10.0.0.2 to remain, file contains: %s", data) + } + }) +} + func TestConsoleArgs(t *testing.T) { t.Run("no remote command", func(t *testing.T) { cc := ConnConfig{Host: "10.0.0.1", User: "pixel", KeyPath: "/tmp/key"} diff --git a/sandbox/truenas/backend.go b/sandbox/truenas/backend.go index 86248e2..8007e62 100644 --- a/sandbox/truenas/backend.go +++ b/sandbox/truenas/backend.go @@ -16,6 +16,23 @@ import ( "github.com/deevus/pixels/sandbox" ) +// clearAndRefreshHostKey removes stale known_hosts entries for both IP and hostname, +// then waits for SSH readiness. +func (t *TrueNAS) clearAndRefreshHostKey(ctx context.Context, name, ip, hostname string, timeout time.Duration) { + t.clearKnownHosts(ip, hostname) + if err := t.ssh.WaitReady(ctx, hostname, timeout, nil); err != nil { + t.warnf("ssh wait %s: %v", name, err) + } +} + +// clearKnownHosts removes known_hosts entries for both IP and hostname. +func (t *TrueNAS) clearKnownHosts(ip, hostname string) { + if ip != "" { + ssh.RemoveKnownHost(t.cfg.knownHosts, ip) + } + ssh.RemoveKnownHost(t.cfg.knownHosts, hostname) +} + // Create creates a new container instance with the full provisioning flow: // NIC resolution, instance creation, provisioning, restart, IP poll, SSH wait. // When opts.Bare is true, only the instance is created (no provisioning or SSH wait). @@ -93,8 +110,7 @@ func (t *TrueNAS) Create(ctx context.Context, opts sandbox.CreateOpts) (*sandbox if needsProvision { if err := t.client.Provision(ctx, full, provOpts); err != nil { - // Non-fatal: continue without provisioning. - _ = err + t.warnf("provision %s: %v", name, err) } else if pubKey != "" { // Restart so rc.local runs on boot. _ = t.client.Virt.StopInstance(ctx, full, tnapi.StopVirtInstanceOpts{Timeout: 30}) @@ -122,8 +138,7 @@ func (t *TrueNAS) Create(ctx context.Context, opts sandbox.CreateOpts) (*sandbox // Wait for SSH readiness. if ip != "" { - cc := ssh.ConnConfig{Host: ip, User: t.cfg.sshUser, KeyPath: t.cfg.sshKey} - _ = t.ssh.WaitReady(ctx, cc.Host, 90*time.Second, nil) + t.clearAndRefreshHostKey(ctx, name, ip, full, 90*time.Second) } return &sandbox.Instance{ @@ -177,7 +192,7 @@ func (t *TrueNAS) Start(ctx context.Context, name string) error { ip := ipFromAliases(inst.Aliases) if ip != "" { - _ = t.ssh.WaitReady(ctx, ip, 30*time.Second, nil) + t.clearAndRefreshHostKey(ctx, name, ip, full, 30*time.Second) } return nil } @@ -199,12 +214,22 @@ func (t *TrueNAS) Delete(ctx context.Context, name string) error { // Best-effort stop. _ = t.client.Virt.StopInstance(ctx, full, tnapi.StopVirtInstanceOpts{Timeout: 30}) + // Resolve IP before deletion so we can clean up the known_hosts entry. + inst, _ := t.client.Virt.GetInstance(ctx, full) + var ip string + if inst != nil { + ip = ipFromAliases(inst.Aliases) + } + // Retry delete (Incus storage release timing). if err := retry.Do(ctx, 3, 2*time.Second, func(ctx context.Context) error { return t.client.Virt.DeleteInstance(ctx, full) }); err != nil { return fmt.Errorf("deleting %s: %w", name, err) } + + // Clean up known_hosts entries for the now-dead container. + t.clearKnownHosts(ip, full) return nil } @@ -279,7 +304,7 @@ func (t *TrueNAS) RestoreSnapshot(ctx context.Context, name, label string) error ip := ipFromAliases(inst.Aliases) if ip != "" { - _ = t.ssh.WaitReady(ctx, ip, 30*time.Second, nil) + t.clearAndRefreshHostKey(ctx, name, ip, full, 30*time.Second) } return nil } diff --git a/sandbox/truenas/config.go b/sandbox/truenas/config.go index b6106b3..a7d8a9a 100644 --- a/sandbox/truenas/config.go +++ b/sandbox/truenas/config.go @@ -24,8 +24,9 @@ type tnConfig struct { nicType string parent string - sshUser string - sshKey string + sshUser string + sshKey string + knownHosts string datasetPrefix string @@ -108,6 +109,7 @@ func parseCfg(m map[string]string) (*tnConfig, error) { if v := m["ssh_key"]; v != "" { c.sshKey = v } + c.knownHosts = m["ssh_known_hosts"] if v := m["dataset_prefix"]; v != "" { c.datasetPrefix = v diff --git a/sandbox/truenas/exec.go b/sandbox/truenas/exec.go index 4d5bd05..0502bae 100644 --- a/sandbox/truenas/exec.go +++ b/sandbox/truenas/exec.go @@ -15,8 +15,7 @@ import ( // custom Stdin/Stdout/Stderr, it builds a custom exec.Cmd using ssh.Args(). // Otherwise it delegates to ssh.Exec. func (t *TrueNAS) Run(ctx context.Context, name string, opts sandbox.ExecOpts) (int, error) { - ip, err := t.resolveRunningIP(ctx, name) - if err != nil { + if _, err := t.ensureRunning(ctx, name); err != nil { return 1, err } @@ -25,12 +24,8 @@ func (t *TrueNAS) Run(ctx context.Context, name string, opts sandbox.ExecOpts) ( user = "root" } - cc := ssh.ConnConfig{ - Host: ip, - User: user, - KeyPath: t.cfg.sshKey, - Env: envToMap(opts.Env), - } + cc := ssh.NewConnConfig(prefixed(name), user, t.cfg.sshKey, t.cfg.knownHosts) + cc.Env = envToMap(opts.Env) hasCustomIO := opts.Stdin != nil || opts.Stdout != nil || opts.Stderr != nil if hasCustomIO { @@ -54,30 +49,20 @@ func (t *TrueNAS) Run(ctx context.Context, name string, opts sandbox.ExecOpts) ( // Output executes a command and returns its combined stdout. func (t *TrueNAS) Output(ctx context.Context, name string, cmd []string) ([]byte, error) { - ip, err := t.resolveRunningIP(ctx, name) - if err != nil { + if _, err := t.ensureRunning(ctx, name); err != nil { return nil, err } - cc := ssh.ConnConfig{ - Host: ip, - User: t.cfg.sshUser, - KeyPath: t.cfg.sshKey, - } + cc := ssh.NewConnConfig(prefixed(name), t.cfg.sshUser, t.cfg.sshKey, t.cfg.knownHosts) return t.ssh.OutputQuiet(ctx, cc, cmd) } // Console attaches an interactive console session. func (t *TrueNAS) Console(ctx context.Context, name string, opts sandbox.ConsoleOpts) error { - ip, err := t.resolveRunningIP(ctx, name) - if err != nil { + if _, err := t.ensureRunning(ctx, name); err != nil { return err } - cc := ssh.ConnConfig{ - Host: ip, - User: t.cfg.sshUser, - KeyPath: t.cfg.sshKey, - Env: envToMap(opts.Env), - } + cc := ssh.NewConnConfig(prefixed(name), t.cfg.sshUser, t.cfg.sshKey, t.cfg.knownHosts) + cc.Env = envToMap(opts.Env) remoteCmd := strings.Join(opts.RemoteCmd, " ") return ssh.Console(cc, remoteCmd) } @@ -85,20 +70,16 @@ func (t *TrueNAS) Console(ctx context.Context, name string, opts sandbox.Console // Ready waits until the instance is reachable via SSH. If key auth fails, // it pushes the current machine's SSH public key via the TrueNAS file API. func (t *TrueNAS) Ready(ctx context.Context, name string, timeout time.Duration) error { - ip, err := t.resolveRunningIP(ctx, name) - if err != nil { + if _, err := t.ensureRunning(ctx, name); err != nil { return err } - if err := t.ssh.WaitReady(ctx, ip, timeout, nil); err != nil { + host := prefixed(name) + if err := t.ssh.WaitReady(ctx, host, timeout, nil); err != nil { return err } // Test key auth and push the key if it fails. - cc := ssh.ConnConfig{ - Host: ip, - User: t.cfg.sshUser, - KeyPath: t.cfg.sshKey, - } + cc := ssh.NewConnConfig(host, t.cfg.sshUser, t.cfg.sshKey, t.cfg.knownHosts) if err := ssh.TestAuth(ctx, cc); err != nil { pubKey := readSSHPubKey(t.cfg.sshKey) if pubKey == "" { diff --git a/sandbox/truenas/network.go b/sandbox/truenas/network.go index d4ce059..21dbc91 100644 --- a/sandbox/truenas/network.go +++ b/sandbox/truenas/network.go @@ -19,12 +19,11 @@ import ( // script, safe-apt wrapper, restricted sudoers via the TrueNAS API, then // SSHes in to install nftables and resolve domains. func (t *TrueNAS) SetEgressMode(ctx context.Context, name string, mode sandbox.EgressMode) error { - ip, err := t.resolveRunningIP(ctx, name) - if err != nil { + if _, err := t.ensureRunning(ctx, name); err != nil { return err } - cc := ssh.ConnConfig{Host: ip, User: "root", KeyPath: t.cfg.sshKey} full := prefixed(name) + cc := ssh.NewConnConfig(full, "root", t.cfg.sshKey, t.cfg.knownHosts) switch mode { case sandbox.EgressUnrestricted: @@ -106,12 +105,11 @@ func (t *TrueNAS) SetEgressMode(ctx context.Context, name string, mode sandbox.E // AllowDomain adds a domain to the egress allowlist and re-resolves. func (t *TrueNAS) AllowDomain(ctx context.Context, name, domain string) error { - ip, err := t.resolveRunningIP(ctx, name) - if err != nil { + if _, err := t.ensureRunning(ctx, name); err != nil { return err } - cc := ssh.ConnConfig{Host: ip, User: "root", KeyPath: t.cfg.sshKey} full := prefixed(name) + cc := ssh.NewConnConfig(full, "root", t.cfg.sshKey, t.cfg.knownHosts) // Ensure egress infrastructure exists. code, _ := t.ssh.ExecQuiet(ctx, cc, []string{"test -f /etc/pixels-egress-domains"}) @@ -152,12 +150,11 @@ func (t *TrueNAS) AllowDomain(ctx context.Context, name, domain string) error { // DenyDomain removes a domain from the egress allowlist and re-resolves. func (t *TrueNAS) DenyDomain(ctx context.Context, name, domain string) error { - ip, err := t.resolveRunningIP(ctx, name) - if err != nil { + if _, err := t.ensureRunning(ctx, name); err != nil { return err } - cc := ssh.ConnConfig{Host: ip, User: "root", KeyPath: t.cfg.sshKey} full := prefixed(name) + cc := ssh.NewConnConfig(full, "root", t.cfg.sshKey, t.cfg.knownHosts) out, err := t.ssh.OutputQuiet(ctx, cc, []string{"cat /etc/pixels-egress-domains"}) if err != nil { @@ -190,11 +187,10 @@ func (t *TrueNAS) DenyDomain(ctx context.Context, name, domain string) error { // GetPolicy returns the current egress policy for an instance. func (t *TrueNAS) GetPolicy(ctx context.Context, name string) (*sandbox.Policy, error) { - ip, err := t.resolveRunningIP(ctx, name) - if err != nil { + if _, err := t.ensureRunning(ctx, name); err != nil { return nil, err } - cc := ssh.ConnConfig{Host: ip, User: "root", KeyPath: t.cfg.sshKey} + cc := ssh.NewConnConfig(prefixed(name), "root", t.cfg.sshKey, t.cfg.knownHosts) code, _ := t.ssh.ExecQuiet(ctx, cc, []string{"test -f /etc/pixels-egress-domains"}) if code != 0 { diff --git a/sandbox/truenas/resolve.go b/sandbox/truenas/resolve.go index ec4ca84..6ffd771 100644 --- a/sandbox/truenas/resolve.go +++ b/sandbox/truenas/resolve.go @@ -20,26 +20,26 @@ func unprefixed(name string) string { return strings.TrimPrefix(name, containerPrefix) } -// resolveRunningIP returns the IP of a running container via the API. -func (t *TrueNAS) resolveRunningIP(ctx context.Context, name string) (string, error) { +// ensureRunning verifies the container is running and has a network address, +// returning the instance for callers that need its metadata (e.g. IP). +func (t *TrueNAS) ensureRunning(ctx context.Context, name string) (*tnapi.VirtInstance, error) { full := prefixed(name) instance, err := t.client.Virt.GetInstance(ctx, full) if err != nil { - return "", fmt.Errorf("looking up %s: %w", name, err) + return nil, fmt.Errorf("looking up %s: %w", name, err) } if instance == nil { - return "", fmt.Errorf("instance %q not found", name) + return nil, fmt.Errorf("instance %q not found", name) } if instance.Status != "RUNNING" { - return "", fmt.Errorf("instance %q is %s — start it first", name, instance.Status) + return nil, fmt.Errorf("instance %q is %s — start it first", name, instance.Status) } - ip := ipFromAliases(instance.Aliases) - if ip == "" { - return "", fmt.Errorf("no IP address for %s", name) + if ipFromAliases(instance.Aliases) == "" { + return nil, fmt.Errorf("no IP address for %s", name) } - return ip, nil + return instance, nil } // ipFromAliases extracts the first IPv4 address from a VirtInstance's aliases. diff --git a/sandbox/truenas/resolve_test.go b/sandbox/truenas/resolve_test.go index 184e12c..5b270ed 100644 --- a/sandbox/truenas/resolve_test.go +++ b/sandbox/truenas/resolve_test.go @@ -8,16 +8,15 @@ import ( tnapi "github.com/deevus/truenas-go" ) -func TestResolveRunningIP(t *testing.T) { +func TestEnsureRunning(t *testing.T) { tests := []struct { name string instance *tnapi.VirtInstance getErr error - wantIP string wantErr string }{ { - name: "API lookup", + name: "running with IP", instance: &tnapi.VirtInstance{ Name: "px-test", Status: "RUNNING", @@ -25,7 +24,6 @@ func TestResolveRunningIP(t *testing.T) { {Type: "INET", Address: "192.168.1.50"}, }, }, - wantIP: "192.168.1.50", }, { name: "API error", @@ -72,7 +70,7 @@ func TestResolveRunningIP(t *testing.T) { }, } - ip, err := tn.resolveRunningIP(context.Background(), "test") + inst, err := tn.ensureRunning(context.Background(), "test") if tt.wantErr != "" { if err == nil { t.Fatal("expected error, got nil") @@ -85,8 +83,8 @@ func TestResolveRunningIP(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if ip != tt.wantIP { - t.Errorf("ip = %q, want %q", ip, tt.wantIP) + if inst == nil { + t.Fatal("expected non-nil instance") } }) } diff --git a/sandbox/truenas/truenas.go b/sandbox/truenas/truenas.go index c5d701f..60905d7 100644 --- a/sandbox/truenas/truenas.go +++ b/sandbox/truenas/truenas.go @@ -4,6 +4,9 @@ package truenas import ( "context" + "fmt" + "io" + "os" "github.com/deevus/pixels/sandbox" ) @@ -23,6 +26,13 @@ type TrueNAS struct { client *Client cfg *tnConfig ssh sshRunner + warn io.Writer +} + +func (t *TrueNAS) warnf(format string, a ...any) { + if t.warn != nil { + fmt.Fprintf(t.warn, "pixels: "+format+"\n", a...) + } } // New creates a TrueNAS sandbox backend from a flat config map. @@ -41,6 +51,7 @@ func New(cfg map[string]string) (*TrueNAS, error) { client: client, cfg: c, ssh: realSSH{}, + warn: os.Stderr, }, nil } @@ -54,6 +65,7 @@ func NewForTest(client *Client, ssh sshRunner, cfg map[string]string) (*TrueNAS, client: client, cfg: c, ssh: ssh, + warn: os.Stderr, }, nil }