Files
felhom-controller/controller/internal/stacks/infra.go
T

439 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package stacks
import (
"fmt"
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
)
const traefikNetwork = "traefik-public"
// EnsureBaseStack renders and deploys the base-infrastructure stacks (traefik, cloudflared,
// filebrowser) the controller needs for routing + external access. It is:
// - single-flight: fired both at first boot and on every health tick; a TryLock ensures two
// invocations never race on the same stack dir / run concurrent `compose up` on the same stack.
// - idempotent: each stack is skipped when its container is already running, so the healthy-state
// re-run is a cheap 3× `docker inspect` no-op.
// - non-fatal by contract: returns a joined error for the caller to LOG; it must never crash the
// controller. cloudflared is only deployed when a tunnel token is configured (LAN-only nodes
// legitimately run without it — see the dynamic protected set in monitor.EffectiveProtected).
//
// Deploy order is load-bearing: traefik-public network → traefik → cloudflared → filebrowser (the
// composes declare the network `external: true`, so it must exist first).
func (m *Manager) EnsureBaseStack() error {
if !m.infraMu.TryLock() {
m.logger.Printf("[INFO] [infra] EnsureBaseStack already in progress — skipping this invocation")
return nil
}
defer m.infraMu.Unlock()
if err := m.ensureTraefikNetwork(); err != nil {
return fmt.Errorf("base-infra: %w", err) // without the network, every stack `up` fails
}
base := m.cfg.Paths.StacksDir
traefikDir := filepath.Join(base, "traefik")
var errs []string
// R-753: the tunnel's own network, with cloudflared at a fixed address. Without it traefik and cloudflared keep the
// old shape (traefik trusts nothing) — the box still routes; it just cannot tell tunnel visitors apart.
tunnelOK := true
if err := m.ensureTunnelNetwork(); err != nil {
tunnelOK = false
errs = append(errs, fmt.Sprintf("tunnel network: %v", err))
}
// The forwarded-header clean-up BEFORE traefik.yml: the entrypoint names it, and a missing middleware would break
// every route on the box.
fwdOK := true
if err := m.ensureForwardedHeaders(traefikDir); err != nil {
fwdOK = false
errs = append(errs, fmt.Sprintf("forwarded headers: %v", err))
}
if !fwdOK {
m.logger.Printf("[WARN] [infra] traefik left as it is — its forwarded-header file could not be written")
} else if err := m.ensureTraefik(traefikDir, tunnelOK); err != nil {
errs = append(errs, fmt.Sprintf("traefik: %v", err))
}
// Write the backend-transports dynamic file. Done OUTSIDE ensureTraefik (which early-returns when
// traefik is already running) so a self-heal tick on an established node still materializes it.
// The traefik file watcher hot-loads it — no restart needed.
if err := m.ensureServersTransports(traefikDir); err != nil {
errs = append(errs, fmt.Sprintf("servers-transports: %v", err))
}
// Wire the controller's OWN dashboard route into traefik. Unlike filebrowser (which self-registers
// via Docker labels + network membership baked into its compose), the controller is started by the
// golden bootstrap before traefik-public exists and the v2 bootstrap carries no domain — so it can't
// self-label. We do it here, post-pull, where the domain is known: drop a file-provider route and
// join the controller to traefik-public so traefik can resolve felhom-controller:8080.
if err := m.wireController(traefikDir); err != nil {
errs = append(errs, fmt.Sprintf("controller-route: %v", err))
}
if m.cfg.Infrastructure.CFTunnelToken != "" {
// cloudflared moves to the tunnel network only once traefik is on it (it reaches traefik by name there); while
// traefik is down a running cloudflared is left alone, so an outage never flips it back and forth.
traefikUp := containerRunning("traefik")
cfTunnel := tunnelOK && traefikUp && containerOnNetwork("traefik", infra.TunnelNetwork)
if !traefikUp && containerRunning("cloudflared") {
m.logger.Printf("[INFO] [infra] cloudflared left as it is while traefik is not running")
} else if err := m.ensureCloudflared(filepath.Join(base, "cloudflared"), cfTunnel); err != nil {
errs = append(errs, fmt.Sprintf("cloudflared: %v", err))
}
} else {
m.logger.Printf("[INFO] [infra] cloudflared skipped — no cf_tunnel_token configured (LAN-only node)")
}
if err := m.ensureFileBrowser(filepath.Join(base, "filebrowser")); err != nil {
errs = append(errs, fmt.Sprintf("filebrowser: %v", err))
} else if err := m.EnsureFileBrowserAdminPassword(); err != nil {
// R-513: one-time; retried every tick until decided. Logged, never fatal to the base stack.
m.logger.Printf("[WARN] [infra] %v", err)
}
// samba (LAN network-sharing, R-7) — conditional deploy, same shape as cloudflared: only when the
// customer turned the feature on. reconcileSambaAt additionally holds off until a household SMB
// password exists. Deployed LAST (it joins no docker network — host networking by spike mandate).
if m.settings != nil && m.settings.GetSMBSettings().Enabled {
if err := m.ensureSamba(filepath.Join(base, SambaStackName)); err != nil {
errs = append(errs, fmt.Sprintf("samba: %v", err))
}
}
if len(errs) > 0 {
return fmt.Errorf("base-infra bring-up: %s", strings.Join(errs, "; "))
}
return nil
}
// ensureTraefik deploys traefik when it is not running, and RECONCILES a running one: when the rendered files differ
// from the ones on disk (a release changed the template — R-753 added the tunnel trust), it rewrites them and recreates
// the container, because traefik reads its static file only at start. Equal files → nothing (the healthy tick).
// A rewrite that would DROP the certificate resolver the running file has is refused (logged): the inputs that produce
// it (the customer's e-mail) are missing, and dropping it would cost the box its certificates.
// Pinned by TestEnsureTraefik_* (internal/stacks/infra_test.go).
func (m *Manager) ensureTraefik(dir string, tunnel bool) error {
files, err := infra.RenderTraefik(infra.TraefikData{
ACMEEmail: m.cfg.Customer.Email,
CFAPIToken: m.cfg.Infrastructure.CFAPIToken,
Tunnel: tunnel,
})
if err != nil {
return err
}
if containerRunning("traefik") {
changed := changedInfraFiles(dir, files)
if len(changed) == 0 {
return nil
}
if cur, err := os.ReadFile(filepath.Join(dir, "traefik.yml")); err == nil &&
strings.Contains(string(cur), "certResolver") && !strings.Contains(files["traefik.yml"].Content, "certResolver") {
m.logger.Printf("[WARN] [infra] traefik NOT reconciled: the new traefik.yml would drop the running certificate resolver (no customer e-mail in the config) — left as it is")
return nil
}
m.logger.Printf("[INFO] [infra] traefik config changed (%s, tunnel trust %v) — rewriting and recreating traefik (routing pauses a few seconds)", strings.Join(changed, ", "), tunnel)
if err := writeInfraFiles(dir, files); err != nil {
return err
}
return m.composeUp(dir, "--force-recreate")
}
m.logger.Printf("[INFO] [infra] deploying traefik → %s", dir)
if err := os.MkdirAll(filepath.Join(dir, "dynamic"), 0o755); err != nil {
return fmt.Errorf("mkdir dynamic: %w", err)
}
if err := os.MkdirAll(filepath.Join(dir, "certs"), 0o755); err != nil {
return fmt.Errorf("mkdir certs: %w", err)
}
// acme.json must exist as a 0600 file before traefik starts (it writes issued certs into it).
acme := filepath.Join(dir, "acme.json")
if _, err := os.Stat(acme); os.IsNotExist(err) {
if err := os.WriteFile(acme, []byte{}, 0o600); err != nil {
return fmt.Errorf("create acme.json: %w", err)
}
}
if err := os.Chmod(acme, 0o600); err != nil {
return fmt.Errorf("chmod acme.json: %w", err)
}
if err := writeInfraFiles(dir, files); err != nil {
return err
}
return m.composeUp(dir)
}
// ensureCloudflared deploys cloudflared, or reconciles a running one whose compose changed (R-753 moved it to the tunnel
// network at a fixed address); compose recreates the container when its networks change. The tunnel reconnects in a
// few seconds. Pinned by TestEnsureCloudflared_*.
func (m *Manager) ensureCloudflared(dir string, tunnel bool) error {
files, err := infra.RenderCloudflared(infra.CloudflaredData{CFTunnelToken: m.cfg.Infrastructure.CFTunnelToken, Tunnel: tunnel})
if err != nil {
return err
}
if containerRunning("cloudflared") {
if len(changedInfraFiles(dir, files)) == 0 {
return nil
}
m.logger.Printf("[INFO] [infra] cloudflared compose changed (tunnel network %v) — recreating cloudflared (the tunnel reconnects in seconds)", tunnel)
} else {
m.logger.Printf("[INFO] [infra] deploying cloudflared → %s (tunnel network %v)", dir, tunnel)
}
if err := writeInfraFiles(dir, files); err != nil {
return err
}
return m.composeUp(dir)
}
// ensureTunnelNetwork makes felhom-tunnel with its FIXED subnet (infra.TunnelSubnet). A network of that name with any
// other subnet is an error and is left alone: cloudflared could not take its address there, and traefik's trust would
// name an address nobody holds. Pinned by TestEnsureTunnelNetwork_*.
func (m *Manager) ensureTunnelNetwork() error {
inspect := func() (string, error) {
out, err := dockerexec.Command("docker", "network", "inspect", "--format",
"{{range .IPAM.Config}}{{.Subnet}} {{end}}", infra.TunnelNetwork).Output()
return strings.TrimSpace(string(out)), err
}
if got, err := inspect(); err == nil {
if got == infra.TunnelSubnet {
return nil
}
return fmt.Errorf("docker network %s exists with subnet %q, want %s — left alone; the tunnel keeps its old shape", infra.TunnelNetwork, got, infra.TunnelSubnet)
}
m.logger.Printf("[INFO] [infra] creating docker network %s (%s)", infra.TunnelNetwork, infra.TunnelSubnet)
out, err := dockerexec.Command("docker", "network", "create", "--driver", "bridge",
"--subnet", infra.TunnelSubnet, "--ip-range", infra.TunnelIPRange, "--gateway", infra.TunnelGateway,
infra.TunnelNetwork).CombinedOutput()
if err != nil {
if got, ierr := inspect(); ierr == nil && got == infra.TunnelSubnet {
return nil // created by a concurrent actor
}
return fmt.Errorf("network create %s: %s: %w", infra.TunnelNetwork, strings.TrimSpace(string(out)), err)
}
return nil
}
// ensureForwardedHeaders writes the forwarded-header clean-up middleware (infra.RenderForwardedHeaders) when its content
// changes. traefik.yml names it on the websecure entrypoint, so EnsureBaseStack writes it BEFORE traefik.yml.
func (m *Manager) ensureForwardedHeaders(traefikDir string) error {
dynDir := filepath.Join(traefikDir, "dynamic")
if err := os.MkdirAll(dynDir, 0o755); err != nil {
return fmt.Errorf("mkdir dynamic: %w", err)
}
path := filepath.Join(dynDir, "forwarded.yml")
want := infra.RenderForwardedHeaders()
if cur, err := os.ReadFile(path); err != nil || string(cur) != want {
if err := os.WriteFile(path, []byte(want), 0o644); err != nil {
return fmt.Errorf("write forwarded headers: %w", err)
}
m.logger.Printf("[INFO] [infra] wrote the forwarded-header clean-up → %s (%s)", path, infra.ForwardedMiddleware)
}
return nil
}
// changedInfraFiles names the rendered files whose content differs from the file on disk (absent counts as different).
func changedInfraFiles(dir string, files map[string]infra.FileSpec) []string {
var changed []string
for name, spec := range files {
cur, err := os.ReadFile(filepath.Join(dir, name))
if err != nil || string(cur) != spec.Content {
changed = append(changed, name)
}
}
sort.Strings(changed)
return changed
}
func (m *Manager) ensureFileBrowser(dir string) error {
if containerRunning("filebrowser") {
return m.reconcileFileBrowserImage(dir)
}
composePath := filepath.Join(dir, "docker-compose.yml")
if _, err := os.Stat(composePath); err == nil {
// Already provisioned but not running — bring it up WITHOUT regenerating, so the storage
// mounts that web.SyncFileBrowserMounts manages are preserved. Just `compose up -d`.
m.logger.Printf("[INFO] [infra] filebrowser compose exists — starting without regenerating")
return m.composeUp(dir)
}
m.logger.Printf("[INFO] [infra] deploying filebrowser → %s", dir)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("mkdir: %w", err)
}
// Initial render: no storage mounts yet, and no import source either (web.SyncFileBrowserMounts
// fills both in on the first storage-path change and owns all later regeneration). Rendering the
// import source here without its bind would produce a source with no path behind it — a broken
// sidebar entry — so both halves are deliberately deferred to the same place.
compose := infra.RenderFileBrowserCompose(m.cfg.Customer.Domain, nil)
config := infra.RenderFileBrowserConfig(nil, false)
if err := os.WriteFile(composePath, []byte(compose), 0o644); err != nil {
return fmt.Errorf("write docker-compose.yml: %w", err)
}
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(config), 0o644); err != nil {
return fmt.Errorf("write config.yaml: %w", err)
}
return m.composeUp(dir)
}
// ensureServersTransports writes the traefik file-provider dynamic config defining named backend
// transports (the insecure-skip-verify transport for self-signed HTTPS backends like Crafty). Like
// wireController, it writes only when the content changes so the traefik file watcher doesn't reload
// on every self-heal tick. Idempotent and cheap.
func (m *Manager) ensureServersTransports(traefikDir string) error {
dynDir := filepath.Join(traefikDir, "dynamic")
if err := os.MkdirAll(dynDir, 0o755); err != nil {
return fmt.Errorf("mkdir dynamic: %w", err)
}
path := filepath.Join(dynDir, "serverstransports.yml")
want := infra.RenderServersTransports()
if cur, err := os.ReadFile(path); err != nil || string(cur) != want {
if err := os.WriteFile(path, []byte(want), 0o644); err != nil {
return fmt.Errorf("write servers-transports: %w", err)
}
m.logger.Printf("[INFO] [infra] wrote backend transports → %s (%s)", path, infra.ServersTransportInsecure)
}
return nil
}
// controllerContainer is the fixed name of the in-guest controller container (set by the golden
// bootstrap `docker run --name`). traefik resolves it by this name once both share traefik-public.
const controllerContainer = "felhom-controller"
// wireController makes the controller dashboard reachable through traefik: it writes the file-provider
// route (Host(felhom.<domain>) → http://felhom-controller:8080) and connects the controller container
// to traefik-public. Both are idempotent — the route is written only when its content changes (so the
// traefik file watcher doesn't reload every health tick), and the network connect is skipped when the
// controller is already attached. Domain is required (it comes from the hub pull); a missing domain is
// a no-op (logged) rather than an error.
func (m *Manager) wireController(traefikDir string) error {
domain := m.cfg.Customer.Domain
if domain == "" {
m.logger.Printf("[WARN] [infra] controller route skipped — no customer domain configured")
return nil
}
dynDir := filepath.Join(traefikDir, "dynamic")
if err := os.MkdirAll(dynDir, 0o755); err != nil {
return fmt.Errorf("mkdir dynamic: %w", err)
}
routePath := filepath.Join(dynDir, "controller.yml")
// DNS-01 ACME configured (CF token + email) → this route anchors wildcard proactive issuance.
wildcardTLS := m.cfg.Infrastructure.CFAPIToken != "" && m.cfg.Customer.Email != ""
want := infra.RenderControllerRoute(domain, wildcardTLS)
if cur, err := os.ReadFile(routePath); err != nil || string(cur) != want {
if err := os.WriteFile(routePath, []byte(want), 0o644); err != nil {
return fmt.Errorf("write controller route: %w", err)
}
m.logger.Printf("[INFO] [infra] wrote controller route → %s (Host felhom.%s → felhom-controller:8080)", routePath, domain)
}
if !containerOnNetwork(controllerContainer, traefikNetwork) {
out, err := dockerexec.Command("docker", "network", "connect", traefikNetwork, controllerContainer).CombinedOutput()
if err != nil && !strings.Contains(string(out), "already exists") {
return fmt.Errorf("network connect %s: %s: %w", controllerContainer, strings.TrimSpace(string(out)), err)
}
m.logger.Printf("[INFO] [infra] connected %s to %s", controllerContainer, traefikNetwork)
}
return nil
}
// containerOnNetwork reports whether the named container is attached to the given docker network.
// We list the network names and match exactly — NOT `{{index .Networks "name"}}`, whose output for an
// absent key is "<nil>" (a non-empty string), which would falsely read as "already attached".
func containerOnNetwork(name, network string) bool {
out, err := dockerexec.Command("docker", "inspect", "--format",
"{{range $k, $_ := .NetworkSettings.Networks}}{{$k}}\n{{end}}", name).Output()
if err != nil {
return false
}
for _, n := range strings.Fields(string(out)) {
if n == network {
return true
}
}
return false
}
// ensureTraefikNetwork creates the external traefik-public docker network if absent (idempotent;
// tolerates a create/inspect race). Uses the docker CLI directly — it's a network op, not compose.
func (m *Manager) ensureTraefikNetwork() error {
if dockerexec.Command("docker", "network", "inspect", traefikNetwork).Run() == nil {
return nil
}
m.logger.Printf("[INFO] [infra] creating docker network %s", traefikNetwork)
out, err := dockerexec.Command("docker", "network", "create", traefikNetwork).CombinedOutput()
if err != nil {
// Tolerate a race where another actor created it between our inspect and create.
if dockerexec.Command("docker", "network", "inspect", traefikNetwork).Run() == nil {
return nil
}
return fmt.Errorf("network create %s: %s: %w", traefikNetwork, strings.TrimSpace(string(out)), err)
}
return nil
}
// composeUp runs `docker compose up -d [extra…]` in dir (DOMAIN injected by composeExecWithEnv).
func (m *Manager) composeUp(dir string, extra ...string) error {
out, err := m.composeExecWithEnv(dir, nil, append([]string{"up", "-d"}, extra...)...)
if err != nil {
return fmt.Errorf("compose up: %s: %w", truncateStr(strings.TrimSpace(out), 300), err)
}
return nil
}
// writeInfraFiles writes each rendered file at its required mode (enforced via Chmod so an existing
// file — e.g. a re-rendered .env — keeps 0600 even though WriteFile only honors mode on create).
func writeInfraFiles(dir string, files map[string]infra.FileSpec) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("mkdir: %w", err)
}
for name, spec := range files {
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(spec.Content), os.FileMode(spec.Mode)); err != nil {
return fmt.Errorf("write %s: %w", name, err)
}
if err := os.Chmod(p, os.FileMode(spec.Mode)); err != nil {
return fmt.Errorf("chmod %s: %w", name, err)
}
}
return nil
}
// containerRunning reports whether a container with the given name is currently running. It asks the
// daemon directly (works before the stack dir exists), mirroring monitor.checkProtectedContainers.
func containerRunning(name string) bool {
out, err := dockerexec.Command("docker", "inspect", "--format", "{{.State.Running}}", name).Output()
if err != nil {
return false
}
return strings.TrimSpace(string(out)) == "true"
}
var composeImageLine = regexp.MustCompile(`(?m)^(\s*image:\s*)(\S+)\s*$`)
// reconcileFileBrowserImage moves a RUNNING file browser to the pinned image (R-838, controller v0.291.0). Its compose
// file is owned by web.SyncFileBrowserMounts (the storage mounts), which runs only when storage changes — so before
// this, a release that raised FileBrowserImage never reached an installed box. Only the `image:` line is replaced;
// the mounts stay byte-for-byte. Same image (or no compose file) → nothing. Pinned by TestReconcileFileBrowserImage_*.
func (m *Manager) reconcileFileBrowserImage(dir string) error {
composePath := filepath.Join(dir, "docker-compose.yml")
cur, err := os.ReadFile(composePath)
if err != nil {
return nil // nothing provisioned by us here: leave it
}
mm := composeImageLine.FindSubmatch(cur)
if mm == nil || string(mm[2]) == infra.FileBrowserImage {
return nil
}
m.logger.Printf("[INFO] [infra] filebrowser runs %s, the pin is %s — moving it (mounts unchanged; the file browser pauses a few seconds)", mm[2], infra.FileBrowserImage)
out := composeImageLine.ReplaceAll(cur, []byte("${1}"+infra.FileBrowserImage))
if err := os.WriteFile(composePath, out, 0o644); err != nil {
return fmt.Errorf("write docker-compose.yml: %w", err)
}
return m.composeUp(dir)
}