Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dea472f443 | |||
| dccdb746ea | |||
| 080919fde6 |
@@ -1,17 +1,24 @@
|
|||||||
{
|
{
|
||||||
"phase": 0,
|
"phase": 0,
|
||||||
"stage": "grill",
|
"stage": "complete",
|
||||||
"milestone": "v0.14",
|
"milestone": "v0.14",
|
||||||
"milestone_slug": "ingress-bootstrap",
|
"milestone_slug": "ingress-bootstrap",
|
||||||
"phase_role": "pre_execution",
|
"phase_role": "pre_execution",
|
||||||
"attempts": 0,
|
"attempts": 0,
|
||||||
"updated_at": "2026-08-10T18:00:00Z",
|
"updated_at": "2026-08-10T18:27:00Z",
|
||||||
"milestone_complete": false,
|
"milestone_complete": false,
|
||||||
"previous_milestone": "v0.13",
|
"previous_milestone": "v0.13",
|
||||||
"phases_shipped": [],
|
"phases_shipped": ["P0"],
|
||||||
"tags_shipped": [],
|
"tags_shipped": ["v0.13.0"],
|
||||||
"requirements": {
|
"requirements": {
|
||||||
"covered": [],
|
"covered": [],
|
||||||
"partial": []
|
"partial": []
|
||||||
|
},
|
||||||
|
"binding_conditions": ["C-50","C-51","C-52","C-53","C-54","C-55","C-56","C-57","C-58","C-59","C-60","C-61"],
|
||||||
|
"load_bearing_rule": "R-024",
|
||||||
|
"ship": {
|
||||||
|
"tag": "v0.13.0",
|
||||||
|
"merged_to_milestone": true,
|
||||||
|
"release_created": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+16
@@ -167,3 +167,19 @@ pipelines:
|
|||||||
- docker push git.cloudinit.dev/coreci/orca:${VERSION}
|
- docker push git.cloudinit.dev/coreci/orca:${VERSION}
|
||||||
- docker push git.cloudinit.dev/coreci/orca:latest
|
- docker push git.cloudinit.dev/coreci/orca:latest
|
||||||
- docker logout git.cloudinit.dev
|
- docker logout git.cloudinit.dev
|
||||||
|
- name: container-publish-traefik
|
||||||
|
description: Build and publish orca-traefik OCI image (REQ-171, R-024)
|
||||||
|
image: docker:24-cli
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${GITEA_TOKEN}
|
||||||
|
VERSION: ${CI_COMMIT_TAG}
|
||||||
|
commands:
|
||||||
|
- docker build
|
||||||
|
-f Dockerfile.traefik
|
||||||
|
-t git.cloudinit.dev/coreci/orca-traefik:${VERSION}
|
||||||
|
-t git.cloudinit.dev/coreci/orca-traefik:latest
|
||||||
|
.
|
||||||
|
- echo "${GITEA_TOKEN}" | docker login git.cloudinit.dev -u cloudinit-bot --password-stdin
|
||||||
|
- docker push git.cloudinit.dev/coreci/orca-traefik:${VERSION}
|
||||||
|
- docker push git.cloudinit.dev/coreci/orca-traefik:latest
|
||||||
|
- docker logout git.cloudinit.dev
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Dockerfile.traefik — custom orca-traefik image (R-024)
|
||||||
|
#
|
||||||
|
# Extends the official traefik:v3.3.0 image with a baked default static
|
||||||
|
# config. The host-side /etc/traefik/traefik.yml (rendered by
|
||||||
|
# emitter.RenderTraefikStaticConfig) is mounted :ro at runtime to
|
||||||
|
# override this default — preserving the traefik-on-public-ip opt-out
|
||||||
|
# (REQ-100) and any site-local customisation.
|
||||||
|
#
|
||||||
|
# Dynamic config (routers, services, certs) is mounted from
|
||||||
|
# /etc/traefik/dynamic on the host — orca writes to it atomically via
|
||||||
|
# the SSH-push transport (C-10 protocol).
|
||||||
|
#
|
||||||
|
# Build:
|
||||||
|
# docker build -f Dockerfile.traefik -t git.cloudinit.dev/coreci/orca-traefik:v0.13.1 .
|
||||||
|
#
|
||||||
|
# Run (hybrid R-017 mode — nft DNATs :443/:80 to loopback):
|
||||||
|
# podman run -d --name orca-traefik --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 \
|
||||||
|
# git.cloudinit.dev/coreci/orca-traefik:v0.13.1
|
||||||
|
|
||||||
|
FROM traefik:v3.3.0
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.title="orca-traefik"
|
||||||
|
LABEL org.opencontainers.image.description="Custom Traefik image for Orca ingress (R-024)"
|
||||||
|
LABEL org.opencontainers.image.source="https://git.cloudinit.dev/coreci/orca"
|
||||||
|
|
||||||
|
COPY docker/orca-traefik/traefik.yml /etc/traefik/traefik.yml
|
||||||
|
COPY docker/orca-traefik/step-ca-root.crt /etc/orca/step-ca-root.crt
|
||||||
|
|
||||||
|
CMD ["--configFile=/etc/traefik/traefik.yml"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
entryPoints:
|
||||||
|
websecure:
|
||||||
|
address: "127.0.0.1:8443"
|
||||||
|
web:
|
||||||
|
address: "127.0.0.1:8080"
|
||||||
|
traefik:
|
||||||
|
address: "127.0.0.1:8081"
|
||||||
|
|
||||||
|
providers:
|
||||||
|
file:
|
||||||
|
directory: "/etc/traefik/dynamic"
|
||||||
|
watch: true
|
||||||
|
|
||||||
|
log:
|
||||||
|
level: INFO
|
||||||
|
format: json
|
||||||
|
|
||||||
|
accessLog:
|
||||||
|
format: json
|
||||||
+18
-15
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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, "'", "'\\''") + "'"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -211,3 +211,36 @@ else
|
|||||||
info "✓ container image ${IMAGE}:${VERSION} published"
|
info "✓ container image ${IMAGE}:${VERSION} published"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- publish orca-traefik container image (REQ-171, R-024) ----------------
|
||||||
|
# Custom traefik image with baked default static config. The host-side
|
||||||
|
# traefik.yml is mounted :ro at runtime to override the baked default.
|
||||||
|
# Skipped gracefully if docker is not on PATH (CI handles it via
|
||||||
|
# .coreci.yml container-publish-traefik step).
|
||||||
|
|
||||||
|
TRAEFIK_IMAGE="${CONTAINER_REGISTRY}/${CONTAINER_OWNER}/orca-traefik"
|
||||||
|
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
info "docker not found on PATH — skipping orca-traefik image publish (CI handles it)."
|
||||||
|
else
|
||||||
|
info "building orca-traefik image ${TRAEFIK_IMAGE}:${VERSION}..."
|
||||||
|
docker build \
|
||||||
|
-f Dockerfile.traefik \
|
||||||
|
-t "${TRAEFIK_IMAGE}:${VERSION}" \
|
||||||
|
-t "${TRAEFIK_IMAGE}:latest" \
|
||||||
|
"$REPO_ROOT"
|
||||||
|
|
||||||
|
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||||
|
info "GITEA_TOKEN not set — skipping orca-traefik push (image built locally only)."
|
||||||
|
else
|
||||||
|
if ! docker login "$CONTAINER_REGISTRY" -u cloudinit-bot --password-stdin <<<"$GITEA_TOKEN" 2>/dev/null; then
|
||||||
|
echo "$GITEA_TOKEN" | docker login "$CONTAINER_REGISTRY" -u cloudinit-bot --password-stdin
|
||||||
|
fi
|
||||||
|
info "pushing ${TRAEFIK_IMAGE}:${VERSION}..."
|
||||||
|
docker push "${TRAEFIK_IMAGE}:${VERSION}"
|
||||||
|
info "pushing ${TRAEFIK_IMAGE}:latest..."
|
||||||
|
docker push "${TRAEFIK_IMAGE}:latest"
|
||||||
|
docker logout "$CONTAINER_REGISTRY"
|
||||||
|
info "✓ orca-traefik image ${TRAEFIK_IMAGE}:${VERSION} published"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user