Compare commits

..

1 Commits

Author SHA1 Message Date
Jon Chery dea472f443 feat(P2): podman traefik reconciler + TLS model fix (REQ-172)
Replace internal/traefik/install.go binary+systemd installer with a
podman-container reconciler (R-024). The reconciler is idempotent:
inspect → start-if-stopped → pull+run-if-absent.

Container run flags (research-validated):
  --restart=unless-stopped (not always; research Topic 6)
  --network host (binds 127.0.0.1:8080/8443 on host/LXC loopback)
  -v /etc/traefik/traefik.yml:ro (overrides baked default; C-58)
  -v /etc/traefik/dynamic:ro (orca writes atomically via SSH-push)
  -v /etc/orca/step-ca-root.crt:ro (future mTLS; v0.14 uses tls:{})
  No :Z SELinux flag (research Topic 7)

C-50: ensurePodmanLocal/Remote installs podman if absent.
C-57: removeLegacySystemdUnitLocal/Remote stops+disables+removes
  the v0.13 orca-traefik.service + /usr/local/bin/traefik before
  starting the podman container (upgrade path).
  upgrade.go cutover rewritten to use the reconciler.

TLS model fix (research Topic 4): drop certResolver: orca from
dynamic config (traefik v3.3 only supports acme/tailscale resolvers,
not CA-file-based). Emit tls: {} instead. Real mTLS via dynamic
tls.certificates + clientAuth.caFiles deferred to v0.15 (grill
G-003, confidence 0.55 < 0.60).

Callsites updated:
  init.go: installTraefikLocal → ensureTraefikContainerLocal
  linux/bootstrap.go: traefik.InstallRemote → EnsureTraefikContainerRemote
  proxmox/bootstrap.go: same
  traefik_install.go: wrapper updated

Tests: internal/traefik/install_test.go (new) — ImageRef, podmanRunArgs,
  container-running/stopped/absent paths, legacy systemd removal (C-57).

---ci---
project: orca
phase: 2
milestone: v0.14
status: execute
---/ci---
2026-08-10 20:04:20 +00:00
9 changed files with 444 additions and 118 deletions
+18 -15
View File
@@ -1,19 +1,11 @@
package cli package cli
import ( import (
"bufio"
"context" "context"
"crypto/x509" "crypto/x509"
"encoding/pem" "encoding/pem"
"fmt" "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/acl"
"git.cloudinit.dev/coreci/orca/internal/certpaths" "git.cloudinit.dev/coreci/orca/internal/certpaths"
"git.cloudinit.dev/coreci/orca/internal/identity" "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/secrets"
"git.cloudinit.dev/coreci/orca/internal/security" "git.cloudinit.dev/coreci/orca/internal/security"
"git.cloudinit.dev/coreci/orca/internal/store" "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 ( 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). // Step 4d: Ensure orca-traefik podman container on the lead node
// Traefik is the data-plane ingress. Idempotent. // (REQ-172, R-024). Replaces v0.13 binary+systemd install.
if err := installTraefikLocal(); err != nil { // 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 { 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()}) summary.Steps = append(summary.Steps, stepResult{Label: "traefik", Status: "skipped", Detail: err.Error()})
} else { } 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 { if !jsonOutput {
fmt.Fprintf(out, "Traefik installed: %s\n", traefikVersion) fmt.Fprintf(out, "Traefik container: running (podman orca-traefik)\n")
} }
} }
+9 -4
View File
@@ -1,11 +1,16 @@
package cli package cli
import ( import (
"context"
"git.cloudinit.dev/coreci/orca/internal/traefik" "git.cloudinit.dev/coreci/orca/internal/traefik"
) )
var traefikVersion = traefik.DefaultVersion // ensureTraefikContainerLocal ensures the orca-traefik podman container
// is running on the local host (R-024). Replaces the v0.13
func installTraefikLocal() error { // installTraefikLocal binary+systemd installer.
return traefik.InstallLocal(traefikVersion) func ensureTraefikContainerLocal() error {
ctx, cancel := context.WithTimeout(context.Background(), 120_000_000_000) // 2min for pull
defer cancel()
return traefik.EnsureTraefikContainerLocal(ctx, version)
} }
+12 -11
View File
@@ -29,7 +29,7 @@ import (
var ( var (
upgradeTo string upgradeTo string
upgradeImportCA bool upgradeImportCA bool
upgradeForce bool upgradeForce bool
upgradeDryRun bool upgradeDryRun bool
) )
@@ -84,12 +84,12 @@ type cutoverFS interface {
// realCutoverFS is the production cutoverFS backed by the real os. // realCutoverFS is the production cutoverFS backed by the real os.
type realCutoverFS struct{} 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 { func (realCutoverFS) WriteFile(path string, content []byte, mode os.FileMode) error {
return os.WriteFile(path, content, mode) return os.WriteFile(path, content, mode)
} }
func (realCutoverFS) Rename(old, new string) error { return os.Rename(old, new) } func (realCutoverFS) Rename(old, new string) error { return os.Rename(old, new) }
func (realCutoverFS) Remove(path string) error { return os.Remove(path) } func (realCutoverFS) Remove(path string) error { return os.Remove(path) }
func (realCutoverFS) Stat(path string) (os.FileInfo, error) { return os.Stat(path) } func (realCutoverFS) Stat(path string) (os.FileInfo, error) { return os.Stat(path) }
// cutoverFSOverride is the package-level test seam for the cutover // 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. // UpgradeResult is the JSON-serializable summary of an upgrade run.
type UpgradeResult struct { type UpgradeResult struct {
TargetVersion string `json:"target_version"` TargetVersion string `json:"target_version"`
CurrentVersion string `json:"current_version"` CurrentVersion string `json:"current_version"`
DryRun bool `json:"dry_run"` DryRun bool `json:"dry_run"`
MigratedV08 bool `json:"migrated_v08"` MigratedV08 bool `json:"migrated_v08"`
CutoverNeeded bool `json:"cutover_needed"` CutoverNeeded bool `json:"cutover_needed"`
CutoverOK bool `json:"cutover_ok,omitempty"` 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) return false, fmt.Errorf("cutover: atomic rename %s → %s: %w", tmpPath, traefikYml, err)
} }
if _, err := runner.Run(ctx, "systemctl", "restart", "traefik"); err != nil { 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 {
// Restart failed — restore from backup. slog.Warn("cutover: legacy systemd unit removal failed (non-fatal if already removed)", "err", err)
_ = cfs.Rename(backupPath, traefikYml) }
return false, fmt.Errorf("cutover: restart traefik: %w", 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` 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 { if _, err := runner.Run(ctx, "bash", "-c", nftCmd); err != nil {
+14 -13
View File
@@ -46,16 +46,21 @@ type TraefikEmitter struct{}
// /etc/traefik/dynamic/orca-<spec.Name>.yaml. // /etc/traefik/dynamic/orca-<spec.Name>.yaml.
const traefikDynamicDir = "/etc/traefik/dynamic" const traefikDynamicDir = "/etc/traefik/dynamic"
// traefikRouterTLSCertResolver is the Traefik cert-resolver name that // traefikRouterTLSCertResolver is the Traefik cert-resolver name from
// the orca step-ca integration configures on the Traefik static config // v0.11. As of v0.14 (RESEARCH_v0.14 Topic 4), traefik v3.3 only
// (P10 / v0.10 wires the step-ca root into this resolver). The // supports acme/tailscale certResolvers — CA-file-based resolvers do
// dynamic-config file references it by name. // 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" const traefikRouterTLSCertResolver = "orca"
// defaultTrustDomain is the SPIFFE trust domain used in the rendered // defaultTrustDomain is the SPIFFE trust domain. v0.14 removed the
// TLS stanza when the spec does not carry an explicit trust domain. // TLS domains stanza from the dynamic config (replaced with tls: {}).
// The step-ca provisioner (P10) overrides this at render time via the // Retained for documentation; will be used by v0.15 mTLS.
// node argument; for P02 the emitter renders the placeholder. //
// Deprecated: v0.14 removed TLS domains from the dynamic config.
const defaultTrustDomain = "cluster.orca.local" const defaultTrustDomain = "cluster.orca.local"
// Render renders the Traefik dynamic-config YAML for a Service // 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 routerName := "orca-" + spec.Name
serviceName := "orca-" + spec.Name serviceName := "orca-" + spec.Name
rule := fmt.Sprintf("PathPrefix(\"/%s\")", spec.Name) rule := fmt.Sprintf("PathPrefix(\"/%s\")", spec.Name)
trustDomain := defaultTrustDomain
b.WriteString("http:\n") b.WriteString("http:\n")
b.WriteString(" routers:\n") b.WriteString(" routers:\n")
b.WriteString(fmt.Sprintf(" %s:\n", routerName)) b.WriteString(fmt.Sprintf(" %s:\n", routerName))
b.WriteString(fmt.Sprintf(" rule: %s\n", rule)) b.WriteString(fmt.Sprintf(" rule: %s\n", rule))
b.WriteString(fmt.Sprintf(" service: %s\n", serviceName)) b.WriteString(fmt.Sprintf(" service: %s\n", serviceName))
b.WriteString(" tls:\n") 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(" services:\n") b.WriteString(" services:\n")
b.WriteString(fmt.Sprintf(" %s:\n", serviceName)) b.WriteString(fmt.Sprintf(" %s:\n", serviceName))
b.WriteString(" loadBalancer:\n") b.WriteString(" loadBalancer:\n")
+2 -5
View File
@@ -53,11 +53,8 @@ func TestTraefikEmitter_RenderBasic(t *testing.T) {
if !strings.Contains(c, "unix:///run/orca/alloc-node-1/port-http.sock") { if !strings.Contains(c, "unix:///run/orca/alloc-node-1/port-http.sock") {
t.Errorf("content missing socket server URL\n%s", c) t.Errorf("content missing socket server URL\n%s", c)
} }
if !strings.Contains(c, "certResolver: orca") { if !strings.Contains(c, "tls: {}") {
t.Errorf("content missing 'certResolver: orca'\n%s", c) t.Errorf("content missing 'tls: {}' (v0.14: certResolver dropped, tls empty stanza)\n%s", c)
}
if !strings.Contains(c, "domains:") {
t.Errorf("content missing TLS domains\n%s", c)
} }
if !strings.Contains(c, "healthCheck:") { if !strings.Contains(c, "healthCheck:") {
t.Errorf("content missing 'healthCheck:'\n%s", c) t.Errorf("content missing 'healthCheck:'\n%s", c)
+14 -13
View File
@@ -48,13 +48,13 @@ const DefaultSSHPort = 22
// Options configures a Linux worker bootstrap run. // Options configures a Linux worker bootstrap run.
type Options struct { type Options struct {
Host string Host string
SSHUser string SSHUser string
SSHKeyPath string SSHKeyPath string
OrcaUser string OrcaUser string
SSHPort int SSHPort int
HostKeyFingerprint string HostKeyFingerprint string
Logger *slog.Logger Logger *slog.Logger
} }
// Result is the outcome of a successful bootstrap. // 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) opts.Logger.Info("linux bootstrap: user created", "user", opts.OrcaUser)
// Step 4d: Install Traefik on the remote host (REQ-165, Phase B). // Step 4d: Ensure orca-traefik podman container on the remote host
// Traefik is the data-plane ingress; SSH is control plane only. // (REQ-172, R-024). Replaces v0.13 binary+systemd install.
sshExecFn := func(cmd string) ([]byte, error) { sshExecFn := func(cmd string) ([]byte, error) {
session, err := client.NewSession() session, err := client.NewSession()
if err != nil { if err != nil {
@@ -167,8 +167,10 @@ func BootstrapLinux(ctx context.Context, opts Options) (*Result, error) {
defer session.Close() defer session.Close()
return session.CombinedOutput(cmd) return session.CombinedOutput(cmd)
} }
if err := traefik.InstallRemote("", sshExecFn); err != nil { ctx, cancelContainer := context.WithTimeout(ctx, 120*time.Second)
opts.Logger.Warn("linux bootstrap: traefik install failed", "err", err) 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. // Step 5: Create the drift-events directory.
@@ -188,7 +190,7 @@ func BootstrapLinux(ctx context.Context, opts Options) (*Result, error) {
return &Result{ return &Result{
NodeName: opts.Host, NodeName: opts.Host,
NodeAddress: fmt.Sprintf("%s:8443", opts.Host), NodeAddress: fmt.Sprintf("%s:8443", opts.Host),
HostKeyFingerprint: hostKeyFP, HostKeyFingerprint: hostKeyFP,
}, nil }, nil
} }
@@ -226,5 +228,4 @@ func pinnedHostKeyCallback(expectedSHA256Base64 string, capturedKey *ssh.PublicK
return cb, nil return cb, nil
} }
var _ = security.WriteAtomic var _ = security.WriteAtomic
+7 -5
View File
@@ -247,11 +247,13 @@ func BootstrapProxmox(ctx context.Context, opts Options) (*Result, error) {
return nil, fmt.Errorf("validate sudoers: %w", err) return nil, fmt.Errorf("validate sudoers: %w", err)
} }
// Step 9a: Install Traefik on the Proxmox host (REQ-165, Phase B). // Step 9a: Ensure orca-traefik podman container on the Proxmox host
// Traefik runs on the PVE OS as the data-plane ingress; SSH is // (REQ-172, R-024). Replaces v0.13 binary+systemd install.
// control plane only. Idempotent: skips if binary already exists. // In native mode (default for v0.14 P5), the container runs inside
if err := traefik.InstallRemote("", runRemote); err != nil { // an LXC with nesting. For now, this installs on the PVE host OS.
log.Warn("proxmox.traefik_install_failed", "err", err) // 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). // Step 9b: Download default LXC template (REQ-167, Phase C).
+202 -52
View File
@@ -1,78 +1,228 @@
package traefik package traefik
import ( import (
"context"
"fmt" "fmt"
"os/exec" "os/exec"
"strings" "strings"
) )
// DefaultVersion is the traefik version tag for the orca-traefik image.
const DefaultVersion = "v3.3.0" const DefaultVersion = "v3.3.0"
func downloadURL(version string) string { // DefaultImage is the full image reference for the orca-traefik container.
return fmt.Sprintf("https://github.com/traefik/traefik/releases/download/%s/traefik_%s_linux_amd64.tar.gz", version, version) // 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 { // ContainerName is the podman container name for the traefik data plane.
if version == "" { const ContainerName = "orca-traefik"
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
}
// 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) type RemoteExecFunc func(cmd string) ([]byte, error)
func InstallRemote(version string, execFn RemoteExecFunc) error { // ImageRef returns the full image:tag reference for the orca-traefik
if version == "" { // container. If version is empty or "dev"/"0.1.0-dev", it falls back to
version = DefaultVersion // "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 { tag = strings.TrimPrefix(tag, "v")
_, _ = execFn("mkdir -p /etc/traefik/dynamic") 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 return nil
} }
url := downloadURL(version) // Attempt apt-get install (Ubuntu/Debian).
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) cmd := exec.CommandContext(ctx, "bash", "-c",
if out, err := execFn(installCmd); err != nil { "apt-get update -qq && apt-get install -y -qq podman conmon crun fuse-overlayfs 2>&1")
return fmt.Errorf("download traefik on remote: %w (output: %s)", err, string(out)) 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 return nil
} }
func ensureDirs() { // ensurePodmanRemote checks if podman is installed on the remote host
_ = exec.Command("mkdir", "-p", "/etc/traefik/dynamic").Run() // 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() { // removeLegacySystemdUnitLocal stops, disables, and removes the legacy
_ = exec.Command("bash", "-c", fmt.Sprintf(`echo '%s' > /etc/systemd/system/orca-traefik.service`, systemdUnitContent())).Run() // 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 { // removeLegacySystemdUnitRemote is the remote SSH variant (C-57).
return `[Unit] func removeLegacySystemdUnitRemote(execFn RemoteExecFunc) error {
Description=Orca Traefik Data Plane 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`
After=network.target _, _ = execFn(cmd)
return nil
[Service] }
Type=simple
ExecStart=/usr/local/bin/traefik --configFile=/etc/traefik/traefik.yml // ensureDirsLocal creates /etc/traefik/dynamic and /etc/orca locally.
Restart=on-failure func ensureDirsLocal() error {
RestartSec=5s if err := exec.Command("mkdir", "-p", "/etc/traefik/dynamic", "/etc/orca").Run(); err != nil {
return fmt.Errorf("mkdir: %w", err)
[Install] }
WantedBy=multi-user.target` 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, "'", "'\\''") + "'"
} }
+166
View File
@@ -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 }