package dispatch import ( "context" "fmt" "os" "path" "time" "github.com/dbledeez/panel/agent/internal/runtime" ) func medicEnvOr(k, def string) string { if v := os.Getenv(k); v != "" { return v } return def } // Medic host paths on the agent box (figaro, for 7DTD). Overridable via env so // the same agent binary works if 7DTD ever moves hosts. func regionRollingRoot() string { return medicEnvOr("PANEL_REGION_ROLLING_DIR", "/home/refuge/region-rolling") } func regionMedicBin() string { return medicEnvOr("PANEL_REGION_MEDIC_BIN", "/home/refuge/panel/bin/region-medic") } func discordTokenFile() string { return medicEnvOr("PANEL_DISCORD_TOKEN_FILE", "/home/refuge/panel/discord-bot-token") } func medicFileExists(p string) bool { st, err := os.Stat(p) return err == nil && !st.IsDir() } // runRegionMedicOnStart runs the autoheal CLI in a throwaway debian sidecar while // the 7DTD container is STILL STOPPED, just before the agent starts it: // - validate every .7rg in the active world's Region dir, // - heal corrupt ones from the newest clean rolling snapshot, then the panel // tar backups as a fallback, // - snapshot the now-clean Region dir to a fresh rolling slot, // - post a Discord summary (naming the server) if anything healed. // // The per-server medic config (enable / rolling-keep / Discord channel) lives in // the SAVES volume at region-medic.json — written by the panel's medic-config // endpoint via FsWrite (NO container recreate) and read by the CLI here in the // sidecar. The agent therefore always launches the sidecar for 7DTD and lets the // CLI decide; an absent/empty config just means defaults + no Discord. // // Best-effort by contract: any failure is logged + swallowed, never blocking the // start. 10-min hard cap so a wedged sidecar/daemon can't stall a boot. func (d *Dispatcher) runRegionMedicOnStart(ctx context.Context, rec *instanceRecord) { ctx, cancel := context.WithTimeout(ctx, 10*time.Minute) defer cancel() log := d.log.With("instance_id", rec.InstanceID, "medic", true) medicBin := regionMedicBin() if !medicFileExists(medicBin) { log.Warn("region-medic: binary missing, skipping pre-start heal", "bin", medicBin) return } savesVol := fmt.Sprintf("panel-%s-saves", rec.InstanceID) rollingDir := path.Join(regionRollingRoot(), rec.InstanceID) _ = os.MkdirAll(rollingDir, 0o755) backupsDir := path.Join(d.backupDir, rec.InstanceID) cmd := []string{"/rm", "autoheal", "-saves-root", "/saves", "-rolling-dir", "/rolling", "-keep", "2", // default; overridden by region-medic.json in the saves volume "-label", rec.InstanceID, "-apply", } vols := []runtime.VolumeSpec{ {Type: "volume", VolumeName: savesVol, ContainerPath: "/saves"}, {Type: "bind", HostPath: rollingDir, ContainerPath: "/rolling"}, {Type: "bind", HostPath: medicBin, ContainerPath: "/rm", ReadOnly: true}, } // debian:12-slim ships no CA bundle, so the CLI's HTTPS POST to Discord can't // verify the TLS cert ("x509: certificate signed by unknown authority"). Mount // the host's CA certs read-only — Go's x509 finds ca-certificates.crt there. if _, err := os.Stat("/etc/ssl/certs"); err == nil { vols = append(vols, runtime.VolumeSpec{Type: "bind", HostPath: "/etc/ssl/certs", ContainerPath: "/etc/ssl/certs", ReadOnly: true}) } // Panel tar backups are the heal fallback when no rolling slot has a clean // copy (e.g. very first boot, before any snapshot). Only mount if present. if st, err := os.Stat(backupsDir); err == nil && st.IsDir() { cmd = append(cmd, "-backups-dir", "/backups") vols = append(vols, runtime.VolumeSpec{Type: "bind", HostPath: backupsDir, ContainerPath: "/backups", ReadOnly: true}) } // The token is mounted whenever it exists; the CLI only posts if the saves // config names a channel. No -discord-channel flag — the CLI reads it from // /saves/region-medic.json. if tf := discordTokenFile(); medicFileExists(tf) { cmd = append(cmd, "-discord-token-file", "/discord-token") vols = append(vols, runtime.VolumeSpec{Type: "bind", HostPath: tf, ContainerPath: "/discord-token", ReadOnly: true}) } spec := runtime.InstanceSpec{ InstanceID: rec.InstanceID + "-medic", IsSidecar: true, Image: "debian:12-slim", Command: cmd, RestartPolicy: "no", Volumes: vols, } log.Info("region-medic: pre-start scan+heal") contID, err := d.runtime.Create(ctx, spec) if err != nil { log.Warn("region-medic: create sidecar failed", "err", err) return } defer func() { rmCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() _ = d.runtime.Remove(rmCtx, contID) }() if err := d.runtime.Start(ctx, contID); err != nil { log.Warn("region-medic: start sidecar failed", "err", err) return } logCtx, cancelLogs := context.WithCancel(ctx) defer cancelLogs() logDone := make(chan struct{}) go func() { defer close(logDone) _ = d.runtime.StreamLogs(logCtx, contID, func(_ runtime.LogStream, line string, _ time.Time) { log.Info("region-medic", "line", line) }) }() exitCode, err := d.runtime.Wait(ctx, contID) cancelLogs() <-logDone if err != nil { log.Warn("region-medic: wait sidecar failed", "err", err) return } if exitCode != 0 { log.Warn("region-medic: sidecar exited non-zero", "code", exitCode) return } log.Info("region-medic: pre-start heal complete") }