dea472f443
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---
229 lines
9.4 KiB
Go
229 lines
9.4 KiB
Go
package traefik
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
)
|
|
|
|
// DefaultVersion is the traefik version tag for the orca-traefik image.
|
|
const DefaultVersion = "v3.3.0"
|
|
|
|
// 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"
|
|
|
|
// 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)
|
|
|
|
// 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)
|
|
}
|
|
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
|
|
}
|
|
// 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))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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, "'", "'\\''") + "'"
|
|
}
|