panel v0.9.1 — open-source game server manager

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 23:25:11 -07:00
commit 03a281d009
2161 changed files with 300880 additions and 0 deletions
@@ -0,0 +1,165 @@
package dispatch
import (
"context"
"strings"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// Auto-provision a freshly-generated clustered 7DTD world (zero-touch create),
// AND keep a visible "setting up" status until it is actually done so an operator
// who refreshes the dashboard sees it is not ready yet (don't restart it).
//
// A new cluster world bakes its decorations (decoration.7dt / multiblocks.7dt)
// with the engine's NATIVE block ids at generation. The entrypoint stamps the
// cluster CANONICAL .nim over the world's table every boot so shared inventory
// (.ttp) decodes uniformly — but native-baked decorations then resolve to a null
// Block under canonical -> DecoManager NRE -> the world loads but never reaches
// GameStartDone ("online but unjoinable"). The entrypoint fixes this by remapping
// the decorations native->canonical on the FIRST boot it sees them in the native
// band — which can only be a boot AFTER the gen boot (the running server writes
// decoration.7dt; the entrypoint can't see it until next start).
//
// This watcher (a) closes that 2-phase gap so create is zero-touch — it bounces
// the container ONCE so the entrypoint's remap runs — and (b) holds a persistent
// "Finishing cluster setup…" status detail (the dashboard renders it as a "setting
// up" pill instead of a green "running" one) until the world carries the
// .canon-provisioned marker, so a page refresh always shows it isn't done yet.
//
// Strict no-op for non-cluster servers (no /cluster bind -> the watcher exits) and
// already-canon worlds (deco in canon band -> "CANON" on the first probe -> the
// indicator clears immediately). Single-flight; the entrypoint is the backstop if
// the watcher ever misses (the world still provisions on its next restart).
// Mirrors the hang-guard single-flight container-restart pattern (hangguard.go).
const (
provisionPollInterval = 20 * time.Second
provisionPollTimeout = 15 * time.Minute
provisionExecTimeout = 20 * time.Second
provisionRestartGrace = 30 * time.Second
)
// provisionDetail is the persistent status detail shown while a clustered world is
// still aligning to the cluster canonical. The dashboard matches this text to paint
// a "setting up" pill, so a refresh shows the server isn't ready yet.
const provisionDetail = "Finishing cluster setup — aligning decorations to the cluster (it auto-restarts once); please wait, don't restart it."
// provisionProbeScript runs inside the container and classifies the active cluster
// world's provisioning state. Emits exactly one token on stdout:
//
// NOWORLD no recorded save dir (RWG world not yet picked in the Cluster tab)
// PROVISIONED .canon-provisioned marker present (already aligned)
// GENERATING no main.ttw yet (world still generating)
// CANON decorations already in the canon band (>=24000) -> nothing to do
// ATTEMPTED already bounced once and still not aligned -> stop (avoid a loop)
// NEEDS_ALIGN gen done; decorations native-banded OR not baked yet -> bounce once
//
// Gated on main.ttw (= gen finished) not on decoration.7dt existing, because some
// worlds only write decoration.7dt on the first save — the bounce's graceful save
// writes it (native), then the entrypoint remaps it on the align boot. First deco
// block-id = u16 @ offset 17 (hdr 5 + record id-offset 12), masked to 15 bits.
const provisionProbeScript = `SB="$(cat /game-saves/.panel-save-base 2>/dev/null)"; ` +
`[ -z "$SB" ] && { echo NOWORLD; exit 0; }; ` +
`[ -f "$SB/.canon-provisioned" ] && { echo PROVISIONED; exit 0; }; ` +
`[ -f "$SB/main.ttw" ] || { echo GENERATING; exit 0; }; ` +
`D="$SB/decoration.7dt"; ` +
`if [ -f "$D" ]; then raw="$(od -An -tu2 -j17 -N2 "$D" 2>/dev/null | tr -d ' ')"; id=$(( ${raw:-0} & 0x7FFF )); [ "$id" -ge 24000 ] && { echo CANON; exit 0; }; fi; ` +
`[ -f "$SB/.panel-provision-attempted" ] && { echo ATTEMPTED; exit 0; }; ` +
`echo NEEDS_ALIGN`
// provisionAttemptScript drops a durable one-bounce guard so a world that doesn't
// align after its single bounce is never bounced again (the entrypoint remains the
// backstop on ordinary restarts).
const provisionAttemptScript = `SB="$(cat /game-saves/.panel-save-base 2>/dev/null)"; ` +
`[ -n "$SB" ] && touch "$SB/.panel-provision-attempted" 2>/dev/null; true`
// autoProvisionClusterWorldOnStart starts (single-flight) the provision watcher for
// a just-started 7DTD instance. Returns immediately; the watch runs off a background
// goroutine bounded by provisionPollTimeout.
func (d *Dispatcher) autoProvisionClusterWorldOnStart(rec *instanceRecord) {
if rec.ModuleID != "7dtd" {
return
}
if !rec.provisionGuard.CompareAndSwap(false, true) {
return // a watcher is already running for this instance
}
go func() {
defer rec.provisionGuard.Store(false)
log := d.log.With("instance_id", rec.InstanceID, "auto_provision", true)
// Only clustered worlds provision (and show the indicator). Resolve the
// /cluster bind like clusterplayer.go; bail out for standalone servers.
mctx, mcancel := context.WithTimeout(context.Background(), 15*time.Second)
mounts, _ := d.runtime.ContainerMounts(mctx, "panel-"+rec.InstanceID)
mcancel()
clustered := false
for _, m := range mounts {
if m.Destination == "/cluster" && m.Type == "bind" && m.Source != "" {
clustered = true
break
}
}
if !clustered {
return
}
deadline := time.Now().Add(provisionPollTimeout)
delay := 5 * time.Second // first poll soon, to override a premature green "running"
var lastDetail string
bounced := false
emit := func(detail string) {
if detail == lastDetail {
return
}
lastDetail = detail
d.sendInstanceState(rec.InstanceID, panelv1.InstanceStatus_INSTANCE_STATUS_RUNNING, 0, detail)
}
for {
time.Sleep(delay)
delay = provisionPollInterval
if time.Now().After(deadline) {
return // gave up; the entrypoint aligns on the next ordinary restart
}
ectx, cancel := context.WithTimeout(context.Background(), provisionExecTimeout)
stdout, _, code, err := d.runtime.ExecCapture(ectx, rec.ContainerID, []string{"sh", "-c", provisionProbeScript})
cancel()
if err != nil || code != 0 {
continue // mid-boot / exec hiccup — leave the agent's own status alone
}
switch strings.TrimSpace(string(stdout)) {
case "CANON", "PROVISIONED":
emit("running") // fully set up — clear the "setting up" indicator
return
case "NEEDS_ALIGN":
emit(provisionDetail)
if !bounced {
bounced = true
actx, acancel := context.WithTimeout(context.Background(), provisionExecTimeout)
_, _, _, _ = d.runtime.ExecCapture(actx, rec.ContainerID, []string{"sh", "-c", provisionAttemptScript})
acancel()
log.Info("auto-provision: aligning new cluster world to canonical (one-time bounce)")
d.sendEnv(&panelv1.AgentEnvelope{
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
InstanceId: rec.InstanceID, Stream: "stdout", At: timestamppb.Now(),
Line: "[panel] auto-provision: aligning the new world's decorations to the cluster (one-time restart)…",
}},
})
rctx, rcancel := context.WithTimeout(context.Background(), 90*time.Second)
if err := d.runtime.Restart(rctx, rec.ContainerID, provisionRestartGrace); err != nil {
log.Error("auto-provision restart failed (entrypoint aligns on the next ordinary restart)", "err", err)
}
rcancel()
}
continue // keep watching across the bounce until the align boot marks it provisioned
default: // GENERATING / NOWORLD / ATTEMPTED — still setting up
emit(provisionDetail)
continue
}
}
}()
}