Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dea472f443 |
+18
-15
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
+14
-13
@@ -46,16 +46,21 @@ type TraefikEmitter struct{}
|
||||
// /etc/traefik/dynamic/orca-<spec.Name>.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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
+14
-13
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
+202
-52
@@ -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, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user