panel public release
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user