5232609719
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
145 lines
5.3 KiB
Go
145 lines
5.3 KiB
Go
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")
|
|
}
|