From 16440a89f22db440ee47ed2722e2b5b73829a81e Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Mon, 10 Aug 2026 16:09:48 +0000 Subject: [PATCH] feat(B): Traefik deployment to all nodes during init/join (REQ-165, REQ-167) - internal/traefik/install.go: shared Traefik installer (download + systemd unit + dynamic dir). Default v3.3.0, configurable. - orca init: installs Traefik on localhost (idempotent, non-fatal if offline) - proxmox bootstrap: installs Traefik on PVE host + downloads LXC template (default ubuntu-24.04, --lxc-template flag) - linux bootstrap: installs Traefik on worker - emitter/traefik.go: directory provider (was single file); register pve-ct/pve-vm in RegisterTraefik - --lxc-template flag on node join (default ubuntu-24.04) ---ci--- project: orca milestone: v0.12.18 phase: B status: complete requirements: covered: [165, 167] ---/ci--- --- internal/cli/init.go | 14 ++++++ internal/cli/node.go | 7 ++- internal/cli/traefik_install.go | 11 +++++ internal/emitter/traefik.go | 4 +- internal/emitter/traefik_test.go | 2 +- internal/linux/bootstrap.go | 15 ++++++ internal/proxmox/bootstrap.go | 19 ++++++++ internal/traefik/install.go | 78 ++++++++++++++++++++++++++++++++ 8 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 internal/cli/traefik_install.go create mode 100644 internal/traefik/install.go diff --git a/internal/cli/init.go b/internal/cli/init.go index e8cc06e..46b73e8 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -208,6 +208,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 { + if !jsonOutput { + fmt.Fprintf(out, "Traefik install 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}) + if !jsonOutput { + fmt.Fprintf(out, "Traefik installed: %s\n", traefikVersion) + } + } + // Step 5: OS detection. osDetected := detectOS() summary.OS = osDetected diff --git a/internal/cli/node.go b/internal/cli/node.go index 64a27d7..731286c 100644 --- a/internal/cli/node.go +++ b/internal/cli/node.go @@ -55,6 +55,7 @@ var ( joinSSHKey string joinSSHPort int joinHostKeyFP string + joinLXCTemplate string proxmoxUser string proxmoxRole string leaveID string @@ -186,6 +187,7 @@ func joinProxmox(cmd *cobra.Command) error { SSHPort: joinSSHPort, HostKeyFingerprint: joinHostKeyFP, Logger: newLogger(), + LXCTemplate: joinLXCTemplate, }) if err != nil { return fmt.Errorf("proxmox bootstrap: %w", err) @@ -250,7 +252,7 @@ func joinLinux(cmd *cobra.Command) error { SSHPort: joinSSHPort, HostKeyFingerprint: joinHostKeyFP, Logger: newLogger(), - }) + }) if err != nil { return fmt.Errorf("linux bootstrap: %w", err) } @@ -510,7 +512,8 @@ func init() { nodeJoinCmd.Flags().IntVar(&joinSSHPort, "ssh-port", 22, "SSH port for proxmox bootstrap (default 22)") nodeJoinCmd.Flags().StringVar(&proxmoxUser, "proxmox-user", "orca", "Linux system user to create on the proxmox host (config-overridable)") nodeJoinCmd.Flags().StringVar(&proxmoxRole, "proxmox-role", "OrcaOperator", "PVE custom role to create (config-overridable)") - nodeJoinCmd.Flags().StringVar(&joinHostKeyFP, "host-key-fingerprint", "", "SSH host key SHA256:base64 fingerprint (pre-pin; supersedes TOFU for --type proxmox)") + nodeJoinCmd.Flags().StringVar(&joinHostKeyFP, "host-key-fingerprint", "", "SSH host key SHA256:base64 fingerprint (pre-pin; supersedes TOFU for --type proxmox or --type linux)") + nodeJoinCmd.Flags().StringVar(&joinLXCTemplate, "lxc-template", "ubuntu-24.04", "LXC template for Proxmox (default ubuntu-24.04; alternatives: alpine-3.20, debian-12)") nodeLeaveCmd.Flags().StringVar(&leaveID, "id", "", "node id") nodeListCmd.Flags().BoolVar(&nodeWatch, "watch", false, "stream nodes until Ctrl-C (table refresh or --json per-event)") diff --git a/internal/cli/traefik_install.go b/internal/cli/traefik_install.go new file mode 100644 index 0000000..2a64701 --- /dev/null +++ b/internal/cli/traefik_install.go @@ -0,0 +1,11 @@ +package cli + +import ( + "git.cloudinit.dev/coreci/orca/internal/traefik" +) + +var traefikVersion = traefik.DefaultVersion + +func installTraefikLocal() error { + return traefik.InstallLocal(traefikVersion) +} diff --git a/internal/emitter/traefik.go b/internal/emitter/traefik.go index 425c45d..57c6faa 100644 --- a/internal/emitter/traefik.go +++ b/internal/emitter/traefik.go @@ -124,6 +124,8 @@ func RegisterTraefik(reg *Registry) { reg.Register("service:process", e) reg.Register("service:podman", e) reg.Register("service:wasm", e) + reg.Register("service:pve-ct", e) + reg.Register("service:pve-vm", e) } // renderTraefikYAML renders the Traefik dynamic-config YAML for the @@ -288,7 +290,7 @@ func renderTraefikStaticYAML(o TraefikStaticOpts) string { b.WriteString(fmt.Sprintf(" address: %q\n", "127.0.0.1:8081")) b.WriteString("\nproviders:\n") b.WriteString(" file:\n") - b.WriteString(fmt.Sprintf(" filename: %q\n", "/etc/traefik/dynamic/orca.yml")) + b.WriteString(fmt.Sprintf(" directory: %q\n", traefikDynamicDir)) b.WriteString(" watch: true\n") b.WriteString("\nlog:\n") b.WriteString(" level: INFO\n") diff --git a/internal/emitter/traefik_test.go b/internal/emitter/traefik_test.go index 4d882f2..9fe2b37 100644 --- a/internal/emitter/traefik_test.go +++ b/internal/emitter/traefik_test.go @@ -375,7 +375,7 @@ func TestTraefikEmitter_RenderStaticConfigHybrid(t *testing.T) { `address: "127.0.0.1:8081"`, "providers:", "file:", - `filename: "/etc/traefik/dynamic/orca.yml"`, + `directory: "/etc/traefik/dynamic"`, "watch: true", "log:", "level: INFO", diff --git a/internal/linux/bootstrap.go b/internal/linux/bootstrap.go index d7e344e..2fb819d 100644 --- a/internal/linux/bootstrap.go +++ b/internal/linux/bootstrap.go @@ -34,6 +34,7 @@ import ( "git.cloudinit.dev/coreci/orca/internal/certpaths" "git.cloudinit.dev/coreci/orca/internal/proxmox" "git.cloudinit.dev/coreci/orca/internal/security" + "git.cloudinit.dev/coreci/orca/internal/traefik" ) // DefaultSSHUser is the default SSH username for the initial connection. @@ -156,6 +157,20 @@ 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. + sshExecFn := func(cmd string) ([]byte, error) { + session, err := client.NewSession() + if err != nil { + return nil, err + } + defer session.Close() + return session.CombinedOutput(cmd) + } + if err := traefik.InstallRemote("", sshExecFn); err != nil { + opts.Logger.Warn("linux bootstrap: traefik install failed", "err", err) + } + // Step 5: Create the drift-events directory. if err := sshExec(client, fmt.Sprintf( "mkdir -p ~%s/drift-events && chown %s:%s ~%s/drift-events", diff --git a/internal/proxmox/bootstrap.go b/internal/proxmox/bootstrap.go index 9f9c175..71f83d5 100644 --- a/internal/proxmox/bootstrap.go +++ b/internal/proxmox/bootstrap.go @@ -38,6 +38,7 @@ import ( "git.cloudinit.dev/coreci/orca/internal/certpaths" "git.cloudinit.dev/coreci/orca/internal/security" + "git.cloudinit.dev/coreci/orca/internal/traefik" ) // DefaultProxmoxUser is the default Linux system user created on the @@ -82,6 +83,9 @@ type Options struct { HostKeyFingerprint string // Logger receives audit-log entries. If nil, slog.Default() is used. Logger *slog.Logger + // LXCTemplate is the LXC template to download during bootstrap + // (default "ubuntu-24.04"; alternatives: "alpine-3.20", "debian-12"). + LXCTemplate string } // Result is the outcome of a successful bootstrap. @@ -243,6 +247,21 @@ 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 9b: Download default LXC template (REQ-167, Phase C). + // Default: ubuntu-24.04. Configurable via --lxc-template. + template := opts.LXCTemplate + if template == "" { + template = "ubuntu-24.04" + } + _, _ = runRemote(fmt.Sprintf("pveam download local %s 2>/dev/null || true", shellQuote(template))) + log.Info("proxmox.bootstrap_ok", slog.String("event", "proxmox.bootstrap_ok"), slog.String("host", opts.Host), diff --git a/internal/traefik/install.go b/internal/traefik/install.go new file mode 100644 index 0000000..5d73fb4 --- /dev/null +++ b/internal/traefik/install.go @@ -0,0 +1,78 @@ +package traefik + +import ( + "fmt" + "os/exec" + "strings" +) + +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) +} + +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 +} + +type RemoteExecFunc func(cmd string) ([]byte, error) + +func InstallRemote(version string, execFn RemoteExecFunc) error { + if version == "" { + version = DefaultVersion + } + if out, err := execFn("command -v traefik"); err == nil && len(strings.TrimSpace(string(out))) > 0 { + _, _ = execFn("mkdir -p /etc/traefik/dynamic") + 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)) + } + 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() +} + +func writeSystemdUnit() { + _ = exec.Command("bash", "-c", fmt.Sprintf(`echo '%s' > /etc/systemd/system/orca-traefik.service`, systemdUnitContent())).Run() +} + +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` +}