diff --git a/internal/cli/init.go b/internal/cli/init.go index 5d29ed8..9bd6e78 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -1,19 +1,11 @@ package cli import ( + "bufio" "context" "crypto/x509" "encoding/pem" "fmt" - "bufio" - "os" - "strings" - "os/exec" - "path/filepath" - "time" - "github.com/google/uuid" - "golang.org/x/crypto/ssh" - "github.com/spf13/cobra" "git.cloudinit.dev/coreci/orca/internal/acl" "git.cloudinit.dev/coreci/orca/internal/certpaths" "git.cloudinit.dev/coreci/orca/internal/identity" @@ -22,6 +14,14 @@ import ( "git.cloudinit.dev/coreci/orca/internal/secrets" "git.cloudinit.dev/coreci/orca/internal/security" "git.cloudinit.dev/coreci/orca/internal/store" + "github.com/google/uuid" + "github.com/spf13/cobra" + "golang.org/x/crypto/ssh" + "os" + "os/exec" + "path/filepath" + "strings" + "time" ) const ( @@ -251,17 +251,20 @@ func runInit(out interface{ Write([]byte) (int, error) }) error { } } - // Step 4d: Install Traefik on the lead node (REQ-165, Phase B). - // Traefik is the data-plane ingress. Idempotent. - if err := installTraefikLocal(); err != nil { + // Step 4d: Ensure orca-traefik podman container on the lead node + // (REQ-172, R-024). Replaces v0.13 binary+systemd install. + // The container runs traefik from the orca-traefik image with + // --network host, --restart=unless-stopped, and volume mounts for + // dynamic config + step-ca root CA. Idempotent. + if err := ensureTraefikContainerLocal(); err != nil { if !jsonOutput { - fmt.Fprintf(out, "Traefik install skipped: %v\n", err) + fmt.Fprintf(out, "Traefik container skipped: %v\n", err) } summary.Steps = append(summary.Steps, stepResult{Label: "traefik", Status: "skipped", Detail: err.Error()}) } else { - summary.Steps = append(summary.Steps, stepResult{Label: "traefik", Status: "ok", Detail: traefikVersion}) + summary.Steps = append(summary.Steps, stepResult{Label: "traefik", Status: "ok", Detail: "podman container running"}) if !jsonOutput { - fmt.Fprintf(out, "Traefik installed: %s\n", traefikVersion) + fmt.Fprintf(out, "Traefik container: running (podman orca-traefik)\n") } } diff --git a/internal/cli/traefik_install.go b/internal/cli/traefik_install.go index 2a64701..f0f1945 100644 --- a/internal/cli/traefik_install.go +++ b/internal/cli/traefik_install.go @@ -1,11 +1,16 @@ package cli import ( + "context" + "git.cloudinit.dev/coreci/orca/internal/traefik" ) -var traefikVersion = traefik.DefaultVersion - -func installTraefikLocal() error { - return traefik.InstallLocal(traefikVersion) +// ensureTraefikContainerLocal ensures the orca-traefik podman container +// is running on the local host (R-024). Replaces the v0.13 +// installTraefikLocal binary+systemd installer. +func ensureTraefikContainerLocal() error { + ctx, cancel := context.WithTimeout(context.Background(), 120_000_000_000) // 2min for pull + defer cancel() + return traefik.EnsureTraefikContainerLocal(ctx, version) } diff --git a/internal/cli/upgrade.go b/internal/cli/upgrade.go index 96d1268..1f61be6 100644 --- a/internal/cli/upgrade.go +++ b/internal/cli/upgrade.go @@ -29,7 +29,7 @@ import ( var ( upgradeTo string upgradeImportCA bool - upgradeForce bool + upgradeForce bool upgradeDryRun bool ) @@ -84,12 +84,12 @@ type cutoverFS interface { // realCutoverFS is the production cutoverFS backed by the real os. type realCutoverFS struct{} -func (realCutoverFS) ReadFile(path string) ([]byte, error) { return os.ReadFile(path) } +func (realCutoverFS) ReadFile(path string) ([]byte, error) { return os.ReadFile(path) } func (realCutoverFS) WriteFile(path string, content []byte, mode os.FileMode) error { return os.WriteFile(path, content, mode) } -func (realCutoverFS) Rename(old, new string) error { return os.Rename(old, new) } -func (realCutoverFS) Remove(path string) error { return os.Remove(path) } +func (realCutoverFS) Rename(old, new string) error { return os.Rename(old, new) } +func (realCutoverFS) Remove(path string) error { return os.Remove(path) } func (realCutoverFS) Stat(path string) (os.FileInfo, error) { return os.Stat(path) } // cutoverFSOverride is the package-level test seam for the cutover @@ -160,9 +160,9 @@ func acquireUpgradeLock() (func(), error) { // UpgradeResult is the JSON-serializable summary of an upgrade run. type UpgradeResult struct { - TargetVersion string `json:"target_version"` - CurrentVersion string `json:"current_version"` - DryRun bool `json:"dry_run"` + TargetVersion string `json:"target_version"` + CurrentVersion string `json:"current_version"` + DryRun bool `json:"dry_run"` MigratedV08 bool `json:"migrated_v08"` CutoverNeeded bool `json:"cutover_needed"` CutoverOK bool `json:"cutover_ok,omitempty"` @@ -379,10 +379,11 @@ func performCutover(ctx context.Context, runner commandRunner, out interface{ Wr return false, fmt.Errorf("cutover: atomic rename %s → %s: %w", tmpPath, traefikYml, err) } - if _, err := runner.Run(ctx, "systemctl", "restart", "traefik"); err != nil { - // Restart failed — restore from backup. - _ = cfs.Rename(backupPath, traefikYml) - return false, fmt.Errorf("cutover: restart traefik: %w", err) + if _, err := runner.Run(ctx, "bash", "-c", "systemctl stop orca-traefik.service 2>/dev/null; systemctl disable orca-traefik.service 2>/dev/null; rm -f /etc/systemd/system/orca-traefik.service /usr/local/bin/traefik; systemctl daemon-reload; true"); err != nil { + slog.Warn("cutover: legacy systemd unit removal failed (non-fatal if already removed)", "err", err) + } + if err := ensureTraefikContainerLocal(); err != nil { + slog.Warn("cutover: podman traefik container ensure failed", "err", err) } nftCmd := `nft add table inet orca_redirect; nft 'add chain inet orca_redirect prerouting { type nat hook prerouting priority -100; }'; nft add rule inet orca_redirect prerouting tcp dport 443 dnat to 127.0.0.1:8443` if _, err := runner.Run(ctx, "bash", "-c", nftCmd); err != nil { diff --git a/internal/emitter/traefik.go b/internal/emitter/traefik.go index 57c6faa..c8ec065 100644 --- a/internal/emitter/traefik.go +++ b/internal/emitter/traefik.go @@ -46,16 +46,21 @@ type TraefikEmitter struct{} // /etc/traefik/dynamic/orca-.yaml. const traefikDynamicDir = "/etc/traefik/dynamic" -// traefikRouterTLSCertResolver is the Traefik cert-resolver name that -// the orca step-ca integration configures on the Traefik static config -// (P10 / v0.10 wires the step-ca root into this resolver). The -// dynamic-config file references it by name. +// traefikRouterTLSCertResolver is the Traefik cert-resolver name from +// v0.11. As of v0.14 (RESEARCH_v0.14 Topic 4), traefik v3.3 only +// supports acme/tailscale certResolvers — CA-file-based resolvers do +// not exist. The dynamic config now emits `tls: {}` instead. Real +// mTLS via dynamic tls.certificates + clientAuth.caFiles is deferred +// to v0.15. This constant is retained for documentation. +// +// Deprecated: v0.14 removed certResolver from the dynamic config. const traefikRouterTLSCertResolver = "orca" -// defaultTrustDomain is the SPIFFE trust domain used in the rendered -// TLS stanza when the spec does not carry an explicit trust domain. -// The step-ca provisioner (P10) overrides this at render time via the -// node argument; for P02 the emitter renders the placeholder. +// defaultTrustDomain is the SPIFFE trust domain. v0.14 removed the +// TLS domains stanza from the dynamic config (replaced with tls: {}). +// Retained for documentation; will be used by v0.15 mTLS. +// +// Deprecated: v0.14 removed TLS domains from the dynamic config. const defaultTrustDomain = "cluster.orca.local" // Render renders the Traefik dynamic-config YAML for a Service @@ -175,17 +180,13 @@ func renderTraefikYAMLWeighted(spec *jobspec.WorkloadSpec, node *Node, drain boo routerName := "orca-" + spec.Name serviceName := "orca-" + spec.Name rule := fmt.Sprintf("PathPrefix(\"/%s\")", spec.Name) - trustDomain := defaultTrustDomain b.WriteString("http:\n") b.WriteString(" routers:\n") b.WriteString(fmt.Sprintf(" %s:\n", routerName)) b.WriteString(fmt.Sprintf(" rule: %s\n", rule)) b.WriteString(fmt.Sprintf(" service: %s\n", serviceName)) - b.WriteString(" tls:\n") - b.WriteString(fmt.Sprintf(" certResolver: %s\n", traefikRouterTLSCertResolver)) - b.WriteString(" domains:\n") - b.WriteString(fmt.Sprintf(" - main: %q\n", trustDomain)) + b.WriteString(" tls: {}\n") b.WriteString(" services:\n") b.WriteString(fmt.Sprintf(" %s:\n", serviceName)) b.WriteString(" loadBalancer:\n") diff --git a/internal/emitter/traefik_test.go b/internal/emitter/traefik_test.go index 9fe2b37..e2d5af9 100644 --- a/internal/emitter/traefik_test.go +++ b/internal/emitter/traefik_test.go @@ -53,11 +53,8 @@ func TestTraefikEmitter_RenderBasic(t *testing.T) { if !strings.Contains(c, "unix:///run/orca/alloc-node-1/port-http.sock") { t.Errorf("content missing socket server URL\n%s", c) } - if !strings.Contains(c, "certResolver: orca") { - t.Errorf("content missing 'certResolver: orca'\n%s", c) - } - if !strings.Contains(c, "domains:") { - t.Errorf("content missing TLS domains\n%s", c) + if !strings.Contains(c, "tls: {}") { + t.Errorf("content missing 'tls: {}' (v0.14: certResolver dropped, tls empty stanza)\n%s", c) } if !strings.Contains(c, "healthCheck:") { t.Errorf("content missing 'healthCheck:'\n%s", c) diff --git a/internal/linux/bootstrap.go b/internal/linux/bootstrap.go index 2fb819d..ee7f364 100644 --- a/internal/linux/bootstrap.go +++ b/internal/linux/bootstrap.go @@ -48,13 +48,13 @@ const DefaultSSHPort = 22 // Options configures a Linux worker bootstrap run. type Options struct { - Host string - SSHUser string - SSHKeyPath string - OrcaUser string - SSHPort int - HostKeyFingerprint string - Logger *slog.Logger + Host string + SSHUser string + SSHKeyPath string + OrcaUser string + SSHPort int + HostKeyFingerprint string + Logger *slog.Logger } // Result is the outcome of a successful bootstrap. @@ -157,8 +157,8 @@ func BootstrapLinux(ctx context.Context, opts Options) (*Result, error) { } opts.Logger.Info("linux bootstrap: user created", "user", opts.OrcaUser) - // Step 4d: Install Traefik on the remote host (REQ-165, Phase B). - // Traefik is the data-plane ingress; SSH is control plane only. + // Step 4d: Ensure orca-traefik podman container on the remote host + // (REQ-172, R-024). Replaces v0.13 binary+systemd install. sshExecFn := func(cmd string) ([]byte, error) { session, err := client.NewSession() if err != nil { @@ -167,8 +167,10 @@ func BootstrapLinux(ctx context.Context, opts Options) (*Result, error) { defer session.Close() return session.CombinedOutput(cmd) } - if err := traefik.InstallRemote("", sshExecFn); err != nil { - opts.Logger.Warn("linux bootstrap: traefik install failed", "err", err) + ctx, cancelContainer := context.WithTimeout(ctx, 120*time.Second) + defer cancelContainer() + if err := traefik.EnsureTraefikContainerRemote(ctx, "", sshExecFn); err != nil { + opts.Logger.Warn("linux bootstrap: traefik container ensure failed", "err", err) } // Step 5: Create the drift-events directory. @@ -188,7 +190,7 @@ func BootstrapLinux(ctx context.Context, opts Options) (*Result, error) { return &Result{ NodeName: opts.Host, - NodeAddress: fmt.Sprintf("%s:8443", opts.Host), + NodeAddress: fmt.Sprintf("%s:8443", opts.Host), HostKeyFingerprint: hostKeyFP, }, nil } @@ -226,5 +228,4 @@ func pinnedHostKeyCallback(expectedSHA256Base64 string, capturedKey *ssh.PublicK return cb, nil } - var _ = security.WriteAtomic diff --git a/internal/proxmox/bootstrap.go b/internal/proxmox/bootstrap.go index 2634d35..c9f5f05 100644 --- a/internal/proxmox/bootstrap.go +++ b/internal/proxmox/bootstrap.go @@ -247,11 +247,13 @@ func BootstrapProxmox(ctx context.Context, opts Options) (*Result, error) { return nil, fmt.Errorf("validate sudoers: %w", err) } - // Step 9a: Install Traefik on the Proxmox host (REQ-165, Phase B). - // Traefik runs on the PVE OS as the data-plane ingress; SSH is - // control plane only. Idempotent: skips if binary already exists. - if err := traefik.InstallRemote("", runRemote); err != nil { - log.Warn("proxmox.traefik_install_failed", "err", err) + // Step 9a: Ensure orca-traefik podman container on the Proxmox host + // (REQ-172, R-024). Replaces v0.13 binary+systemd install. + // In native mode (default for v0.14 P5), the container runs inside + // an LXC with nesting. For now, this installs on the PVE host OS. + // Idempotent: no-op if container already running. + if err := traefik.EnsureTraefikContainerRemote(ctx, "", runRemote); err != nil { + log.Warn("proxmox.traefik_container_failed", "err", err) } // Step 9b: Download default LXC template (REQ-167, Phase C). diff --git a/internal/traefik/install.go b/internal/traefik/install.go index 5d73fb4..f82cfab 100644 --- a/internal/traefik/install.go +++ b/internal/traefik/install.go @@ -1,78 +1,228 @@ package traefik import ( + "context" "fmt" "os/exec" "strings" ) +// DefaultVersion is the traefik version tag for the orca-traefik image. const DefaultVersion = "v3.3.0" -func downloadURL(version string) string { - return fmt.Sprintf("https://github.com/traefik/traefik/releases/download/%s/traefik_%s_linux_amd64.tar.gz", version, version) -} +// DefaultImage is the full image reference for the orca-traefik container. +// The tag is resolved at runtime from the orca version (or "latest" for +// dev builds). The image is built from Dockerfile.traefik and published +// per release (REQ-171). +const DefaultImage = "git.cloudinit.dev/coreci/orca-traefik" -func InstallLocal(version string) error { - if version == "" { - version = DefaultVersion - } - if _, err := exec.LookPath("traefik"); err == nil { - ensureDirs() - return nil - } - url := downloadURL(version) - cmd := exec.Command("bash", "-c", - fmt.Sprintf(`curl -fsSL %s | tar -xzf - -C /usr/local/bin/ traefik && chmod +x /usr/local/bin/traefik`, url)) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("download traefik %s: %w (output: %s)", version, err, string(out)) - } - ensureDirs() - writeSystemdUnit() - _ = exec.Command("systemctl", "daemon-reload").Run() - _ = exec.Command("systemctl", "enable", "--now", "orca-traefik").Run() - return nil -} +// ContainerName is the podman container name for the traefik data plane. +const ContainerName = "orca-traefik" +// RemoteExecFunc runs a command on a remote host and returns combined +// output. It is the same signature used by the v0.13 binary installer +// and by the proxmox/linux bootstrap SSH sessions. type RemoteExecFunc func(cmd string) ([]byte, error) -func InstallRemote(version string, execFn RemoteExecFunc) error { - if version == "" { - version = DefaultVersion +// ImageRef returns the full image:tag reference for the orca-traefik +// container. If version is empty or "dev"/"0.1.0-dev", it falls back to +// "latest" (dev builds don't have a published tag). +func ImageRef(version string) string { + tag := version + if tag == "" || tag == "dev" || tag == "0.1.0-dev" || strings.HasSuffix(tag, "-dev") { + return fmt.Sprintf("%s:latest", DefaultImage) } - if out, err := execFn("command -v traefik"); err == nil && len(strings.TrimSpace(string(out))) > 0 { - _, _ = execFn("mkdir -p /etc/traefik/dynamic") + tag = strings.TrimPrefix(tag, "v") + return fmt.Sprintf("%s:v%s", DefaultImage, tag) +} + +// podmanRunArgs returns the podman run arguments for the traefik +// container. The container uses --network host so traefik binds +// 127.0.0.1:8080/8443 directly on the host (or LXC) loopback. nft +// DNATs public :443/:80 to those loopback ports (R-017/R-024). +// +// Volume mounts (no SELinux :Z flag — research finding Topic 7): +// - /etc/traefik/traefik.yml:ro — static config (overrides baked default; C-58) +// - /etc/traefik/dynamic:ro — dynamic config (orca writes atomically via SSH-push) +// - /etc/orca/step-ca-root.crt:ro — step-ca root CA (for future mTLS; v0.14 uses tls:{}) +func podmanRunArgs(imageRef string) []string { + return []string{ + "run", "-d", + "--name", ContainerName, + "--restart=unless-stopped", + "--network", "host", + "-v", "/etc/traefik/traefik.yml:/etc/traefik/traefik.yml:ro", + "-v", "/etc/traefik/dynamic:/etc/traefik/dynamic:ro", + "-v", "/etc/orca/step-ca-root.crt:/etc/orca/step-ca-root.crt:ro", + imageRef, + } +} + +// EnsureTraefikContainerLocal ensures the orca-traefik podman container +// is running on the local host. It is idempotent: +// 1. If the container is running → no-op. +// 2. If the container exists but is stopped → start it. +// 3. If the container does not exist → pull the image + run it. +// +// C-50: if podman is not installed, attempts apt-get install. If that +// fails, returns an error with install instructions. +// +// C-57: if a legacy v0.13 systemd service exists (orca-traefik.service), +// it is stopped, disabled, and removed before starting the container. +func EnsureTraefikContainerLocal(ctx context.Context, version string) error { + imageRef := ImageRef(version) + if err := ensurePodmanLocal(ctx); err != nil { + return err + } + if err := removeLegacySystemdUnitLocal(ctx); err != nil { + // Non-fatal: legacy unit may not exist on fresh installs. + _ = err + } + if err := ensureDirsLocal(); err != nil { + return fmt.Errorf("traefik: ensure dirs: %w", err) + } + return reconcileContainerLocal(ctx, imageRef) +} + +// EnsureTraefikContainerRemote ensures the orca-traefik podman container +// is running on a remote host (via SSH exec). Same idempotent logic as +// EnsureTraefikContainerLocal but over the provided exec function. +func EnsureTraefikContainerRemote(ctx context.Context, version string, execFn RemoteExecFunc) error { + imageRef := ImageRef(version) + if err := ensurePodmanRemote(execFn); err != nil { + return err + } + if err := removeLegacySystemdUnitRemote(execFn); err != nil { + _ = err // non-fatal + } + if _, err := execFn("mkdir -p /etc/traefik/dynamic /etc/orca"); err != nil { + return fmt.Errorf("traefik: ensure remote dirs: %w", err) + } + return reconcileContainerRemote(execFn, imageRef) +} + +// ensurePodmanLocal checks if podman is installed locally and attempts +// to install it if absent (C-50). +func ensurePodmanLocal(ctx context.Context) error { + if _, err := exec.LookPath("podman"); err == nil { return nil } - url := downloadURL(version) - installCmd := fmt.Sprintf(`curl -fsSL %s | tar -xzf - -C /usr/local/bin/ traefik && chmod +x /usr/local/bin/traefik && mkdir -p /etc/traefik/dynamic`, url) - if out, err := execFn(installCmd); err != nil { - return fmt.Errorf("download traefik on remote: %w (output: %s)", err, string(out)) + // Attempt apt-get install (Ubuntu/Debian). + cmd := exec.CommandContext(ctx, "bash", "-c", + "apt-get update -qq && apt-get install -y -qq podman conmon crun fuse-overlayfs 2>&1") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("podman not found and apt-get install failed: %w (output: %s).\nInstall podman manually: apt-get install podman conmon crun fuse-overlayfs", err, string(out)) } - unit := systemdUnitContent() - _, _ = execFn(fmt.Sprintf(`echo '%s' > /etc/systemd/system/orca-traefik.service`, unit)) - _, _ = execFn("systemctl daemon-reload && systemctl enable --now orca-traefik") return nil } -func ensureDirs() { - _ = exec.Command("mkdir", "-p", "/etc/traefik/dynamic").Run() +// ensurePodmanRemote checks if podman is installed on the remote host +// and attempts to install it if absent (C-50). +func ensurePodmanRemote(execFn RemoteExecFunc) error { + if out, err := execFn("command -v podman"); err == nil && len(strings.TrimSpace(string(out))) > 0 { + return nil + } + // Attempt apt-get install on the remote host. + cmd := "apt-get update -qq && apt-get install -y -qq podman conmon crun fuse-overlayfs 2>&1" + if out, err := execFn(cmd); err != nil { + return fmt.Errorf("podman not found on remote and apt-get install failed: %w (output: %s)", err, string(out)) + } + return nil } -func writeSystemdUnit() { - _ = exec.Command("bash", "-c", fmt.Sprintf(`echo '%s' > /etc/systemd/system/orca-traefik.service`, systemdUnitContent())).Run() +// removeLegacySystemdUnitLocal stops, disables, and removes the legacy +// v0.13 orca-traefik.service systemd unit + /usr/local/bin/traefik +// binary (C-57). Idempotent — no-op if the unit doesn't exist. +func removeLegacySystemdUnitLocal(ctx context.Context) error { + // Check if the legacy unit exists. + if _, err := exec.CommandContext(ctx, "systemctl", "is-active", "orca-traefik.service").CombinedOutput(); err == nil { + // Unit is active or exists — stop + disable it. + _ = exec.CommandContext(ctx, "systemctl", "stop", "orca-traefik.service").Run() + _ = exec.CommandContext(ctx, "systemctl", "disable", "orca-traefik.service").Run() + } + // Remove the unit file and binary. + _ = exec.CommandContext(ctx, "rm", "-f", "/etc/systemd/system/orca-traefik.service").Run() + _ = exec.CommandContext(ctx, "rm", "-f", "/usr/local/bin/traefik").Run() + _ = exec.CommandContext(ctx, "systemctl", "daemon-reload").Run() + return nil } -func systemdUnitContent() string { - return `[Unit] -Description=Orca Traefik Data Plane -After=network.target - -[Service] -Type=simple -ExecStart=/usr/local/bin/traefik --configFile=/etc/traefik/traefik.yml -Restart=on-failure -RestartSec=5s - -[Install] -WantedBy=multi-user.target` +// removeLegacySystemdUnitRemote is the remote SSH variant (C-57). +func removeLegacySystemdUnitRemote(execFn RemoteExecFunc) error { + cmd := `systemctl is-active orca-traefik.service 2>/dev/null && systemctl stop orca-traefik.service 2>/dev/null; systemctl disable orca-traefik.service 2>/dev/null; rm -f /etc/systemd/system/orca-traefik.service /usr/local/bin/traefik; systemctl daemon-reload 2>/dev/null; true` + _, _ = execFn(cmd) + return nil +} + +// ensureDirsLocal creates /etc/traefik/dynamic and /etc/orca locally. +func ensureDirsLocal() error { + if err := exec.Command("mkdir", "-p", "/etc/traefik/dynamic", "/etc/orca").Run(); err != nil { + return fmt.Errorf("mkdir: %w", err) + } + return nil +} + +// reconcileContainerLocal implements the idempotent pull+run logic +// locally (C-50). +func reconcileContainerLocal(ctx context.Context, imageRef string) error { + // Check if the container is already running. + out, err := exec.CommandContext(ctx, "podman", "inspect", "--format", "{{.State.Running}}", ContainerName).CombinedOutput() + if err == nil { + v := strings.TrimSpace(string(out)) + if v == "true" { + return nil // already running + } + // Container exists but is stopped — start it. + if _, err := exec.CommandContext(ctx, "podman", "start", ContainerName).CombinedOutput(); err != nil { + return fmt.Errorf("podman start %s: %w", ContainerName, err) + } + return nil + } + + // Container does not exist — pull + run. + if out, err := exec.CommandContext(ctx, "podman", "pull", imageRef).CombinedOutput(); err != nil { + return fmt.Errorf("podman pull %s: %w (output: %s)", imageRef, err, string(out)) + } + args := append([]string{}, podmanRunArgs(imageRef)...) + if out, err := exec.CommandContext(ctx, "podman", args...).CombinedOutput(); err != nil { + return fmt.Errorf("podman run: %w (output: %s)", err, string(out)) + } + // Enable podman-restart.service for reboot persistence (research Topic 6). + _ = exec.CommandContext(ctx, "systemctl", "enable", "--now", "podman-restart.service").Run() + return nil +} + +// reconcileContainerRemote implements the idempotent pull+run logic +// over SSH exec. +func reconcileContainerRemote(execFn RemoteExecFunc, imageRef string) error { + // Check if the container is already running. + out, err := execFn(fmt.Sprintf("podman inspect --format '{{.State.Running}}' %s 2>/dev/null", ContainerName)) + if err == nil { + v := strings.TrimSpace(string(out)) + if v == "true" { + return nil // already running + } + // Container exists but is stopped — start it. + if _, err := execFn(fmt.Sprintf("podman start %s 2>/dev/null", ContainerName)); err != nil { + return fmt.Errorf("podman start %s: %w", ContainerName, err) + } + return nil + } + + // Container does not exist — pull + run. + if out, err := execFn(fmt.Sprintf("podman pull %s", shellQuote(imageRef))); err != nil { + return fmt.Errorf("podman pull %s: %w (output: %s)", imageRef, err, string(out)) + } + runArgs := strings.Join(podmanRunArgs(imageRef), " ") + if out, err := execFn(fmt.Sprintf("podman %s", runArgs)); err != nil { + return fmt.Errorf("podman run: %w (output: %s)", err, string(out)) + } + // Enable podman-restart.service for reboot persistence (research Topic 6). + _, _ = execFn("systemctl enable --now podman-restart.service 2>/dev/null || true") + return nil +} + +// shellQuote wraps a string in single quotes for shell-safe usage. +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } diff --git a/internal/traefik/install_test.go b/internal/traefik/install_test.go new file mode 100644 index 0000000..9d79960 --- /dev/null +++ b/internal/traefik/install_test.go @@ -0,0 +1,166 @@ +package traefik + +import ( + "context" + "strings" + "testing" +) + +func TestImageRef(t *testing.T) { + tests := []struct { + version string + want string + }{ + {"v0.13.1", "git.cloudinit.dev/coreci/orca-traefik:v0.13.1"}, + {"0.13.1", "git.cloudinit.dev/coreci/orca-traefik:v0.13.1"}, + {"", "git.cloudinit.dev/coreci/orca-traefik:latest"}, + {"dev", "git.cloudinit.dev/coreci/orca-traefik:latest"}, + {"0.1.0-dev", "git.cloudinit.dev/coreci/orca-traefik:latest"}, + {"v1.2.3-dev", "git.cloudinit.dev/coreci/orca-traefik:latest"}, + } + for _, tt := range tests { + got := ImageRef(tt.version) + if got != tt.want { + t.Errorf("ImageRef(%q) = %q, want %q", tt.version, got, tt.want) + } + } +} + +func TestPodmanRunArgs(t *testing.T) { + args := podmanRunArgs("git.cloudinit.dev/coreci/orca-traefik:v0.13.1") + joined := strings.Join(args, " ") + checks := []string{ + "run -d", + "--name orca-traefik", + "--restart=unless-stopped", + "--network host", + "/etc/traefik/traefik.yml:/etc/traefik/traefik.yml:ro", + "/etc/traefik/dynamic:/etc/traefik/dynamic:ro", + "/etc/orca/step-ca-root.crt:/etc/orca/step-ca-root.crt:ro", + "git.cloudinit.dev/coreci/orca-traefik:v0.13.1", + } + for _, c := range checks { + if !strings.Contains(joined, c) { + t.Errorf("podmanRunArgs missing %q\nfull: %s", c, joined) + } + } + // Ensure no :Z flag (research Topic 7) + if strings.Contains(joined, ":Z") { + t.Errorf("podmanRunArgs should NOT contain :Z SELinux flag\nfull: %s", joined) + } + // Ensure --restart=always is NOT used (research Topic 6) + if strings.Contains(joined, "--restart=always") { + t.Errorf("podmanRunArgs should use --restart=unless-stopped, not --restart=always\nfull: %s", joined) + } +} + +func TestEnsureTraefikContainerRemote_ContainerRunning(t *testing.T) { + var cmds []string + execFn := func(cmd string) ([]byte, error) { + cmds = append(cmds, cmd) + if strings.Contains(cmd, "podman inspect") { + return []byte("true\n"), nil + } + return []byte(""), nil + } + err := EnsureTraefikContainerRemote(context.Background(), "v0.13.1", execFn) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should have checked inspect and found it running — no pull/run. + if len(cmds) < 1 { + t.Fatal("expected at least 1 command (inspect)") + } + for _, c := range cmds { + if strings.Contains(c, "podman pull") { + t.Errorf("should not pull when container is running: %s", c) + } + if strings.Contains(c, "podman run") { + t.Errorf("should not run when container is running: %s", c) + } + } +} + +func TestEnsureTraefikContainerRemote_ContainerStopped(t *testing.T) { + var cmds []string + execFn := func(cmd string) ([]byte, error) { + cmds = append(cmds, cmd) + if strings.Contains(cmd, "podman inspect") { + return []byte("false\n"), nil // stopped + } + return []byte(""), nil + } + err := EnsureTraefikContainerRemote(context.Background(), "v0.13.1", execFn) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should have started the container. + foundStart := false + for _, c := range cmds { + if strings.Contains(c, "podman start orca-traefik") { + foundStart = true + } + } + if !foundStart { + t.Errorf("expected 'podman start orca-traefik' when container is stopped\ncommands: %v", cmds) + } +} + +func TestEnsureTraefikContainerRemote_ContainerAbsent(t *testing.T) { + var cmds []string + execFn := func(cmd string) ([]byte, error) { + cmds = append(cmds, cmd) + if strings.Contains(cmd, "podman inspect") { + return nil, &execError{"inspect failed: no such container"} + } + return []byte(""), nil + } + err := EnsureTraefikContainerRemote(context.Background(), "v0.13.1", execFn) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should have pulled and run. + foundPull := false + foundRun := false + for _, c := range cmds { + if strings.Contains(c, "podman pull") { + foundPull = true + } + if strings.Contains(c, "podman run -d") { + foundRun = true + } + } + if !foundPull { + t.Errorf("expected 'podman pull' when container is absent\ncommands: %v", cmds) + } + if !foundRun { + t.Errorf("expected 'podman run -d' when container is absent\ncommands: %v", cmds) + } +} + +func TestEnsureTraefikContainerRemote_LegacySystemdRemoval(t *testing.T) { + var cmds []string + execFn := func(cmd string) ([]byte, error) { + cmds = append(cmds, cmd) + if strings.Contains(cmd, "podman inspect") { + return []byte("true\n"), nil // container running + } + return []byte(""), nil + } + _ = EnsureTraefikContainerRemote(context.Background(), "v0.13.1", execFn) + // Should include legacy systemd unit removal command (C-57). + foundLegacyRemoval := false + for _, c := range cmds { + if strings.Contains(c, "orca-traefik.service") && strings.Contains(c, "stop") { + foundLegacyRemoval = true + } + } + if !foundLegacyRemoval { + t.Errorf("expected legacy systemd unit removal command (C-57)\ncommands: %v", cmds) + } +} + +// execError is a simple error type for testing. +type execError struct{ msg string } + +func (e *execError) Error() string { return e.msg }