v0.41.1: wire the controller dashboard into traefik (felhom.<domain> routing)

EnsureBaseStack now writes a traefik file-provider route
(Host(felhom.<domain>) -> http://felhom-controller:8080) and joins the
controller to traefik-public. Done post-pull (domain known) and idempotently
(write-if-changed + skip-if-connected), so felhom.<domain> reaches the
controller. Completes the v0.41.0 base-infra bring-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 15:40:43 +02:00
parent f1780100ee
commit 91736eb015
4 changed files with 124 additions and 1 deletions
+62 -1
View File
@@ -36,12 +36,22 @@ func (m *Manager) EnsureBaseStack() error {
}
base := m.cfg.Paths.StacksDir
traefikDir := filepath.Join(base, "traefik")
var errs []string
if err := m.ensureTraefik(filepath.Join(base, "traefik")); err != nil {
if err := m.ensureTraefik(traefikDir); err != nil {
errs = append(errs, fmt.Sprintf("traefik: %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 != "" {
if err := m.ensureCloudflared(filepath.Join(base, "cloudflared")); err != nil {
errs = append(errs, fmt.Sprintf("cloudflared: %v", err))
@@ -137,6 +147,57 @@ func (m *Manager) ensureFileBrowser(dir string) error {
return m.composeUp(dir)
}
// 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")
want := infra.RenderControllerRoute(domain)
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 := exec.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.
func containerOnNetwork(name, network string) bool {
out, err := exec.Command("docker", "inspect", "--format",
fmt.Sprintf("{{index .NetworkSettings.Networks %q}}", network), name).Output()
if err != nil {
return false
}
s := strings.TrimSpace(string(out))
return s != "" && s != "<no value>"
}
// 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 {