badc9c2a0c
gates / gates (push) Successful in 30s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
440 lines
20 KiB
Go
440 lines
20 KiB
Go
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, which ALSO runs at every controller start (web/server.go) and renders the
|
||
// pinned image — that is the main route; this is a second net for a start-up sync that failed. 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)
|
||
}
|