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---
This commit is contained in:
Jon Chery
2026-08-10 20:04:20 +00:00
parent dccdb746ea
commit dea472f443
9 changed files with 444 additions and 118 deletions
+18 -15
View File
@@ -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")
}
}
+9 -4
View File
@@ -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)
}
+12 -11
View File
@@ -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 {