panel v0.9.2 — public release
Self-hostable game server control panel: Go controller + agent, 26 game modules, embedded web UI. One-line install via install.sh / install.ps1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
package dispatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/dbledeez/panel/agent/internal/updater"
|
||||
modulepkg "github.com/dbledeez/panel/pkg/module"
|
||||
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
|
||||
)
|
||||
|
||||
// Use a type alias so the file's handler signatures don't bleed the
|
||||
// modulepkg import into their exposed type names.
|
||||
type modulepkg_UpdateProvider = modulepkg.UpdateProvider
|
||||
|
||||
// registerUpdater stores the cancel func for the in-flight updater of
|
||||
// an instance. Returns (slot, true) when registration succeeds, or
|
||||
// (nil, false) when another updater is already running for the same
|
||||
// instance.
|
||||
//
|
||||
// We refuse instead of preempting because preempting races the docker
|
||||
// daemon's name cleanup: cancelling the prior context returns instantly,
|
||||
// but the prior steamcmd sidecar (e.g. panel-<id>-steamcmd-1) takes a
|
||||
// few seconds to actually exit and be removed. A new updater that
|
||||
// charges in immediately hits a "name already in use" Docker error and,
|
||||
// worse, leaves SteamCMD's staging directory mid-finalize (state 0x602)
|
||||
// — files stranded under <install>/steamapps/downloading/<appid>/.
|
||||
//
|
||||
// Concrete failure mode that prompted this guard:
|
||||
// - panelctl create palworld-test → auto-triggers first update.
|
||||
// - Operator (impatiently) runs panelctl update palworld-test 13s later.
|
||||
// - Old behavior cancelled the auto-update mid-validate, then the new
|
||||
// run failed name-conflict, then SteamCMD looped on exit-8 / 0x602.
|
||||
//
|
||||
// The delete path uses cancelUpdater (different entry), so this guard
|
||||
// doesn't block teardown.
|
||||
func (d *Dispatcher) registerUpdater(instanceID string, cancel context.CancelFunc) (*updaterSlot, bool) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if existing := d.updaters[instanceID]; existing != nil {
|
||||
return nil, false
|
||||
}
|
||||
slot := &updaterSlot{cancel: cancel}
|
||||
d.updaters[instanceID] = slot
|
||||
return slot, true
|
||||
}
|
||||
|
||||
// unregisterUpdater clears the registration ONLY IF the current entry
|
||||
// is still our slot — pointer compare guards against a later updater
|
||||
// having taken over.
|
||||
func (d *Dispatcher) unregisterUpdater(instanceID string, slot *updaterSlot) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.updaters[instanceID] == slot {
|
||||
delete(d.updaters, instanceID)
|
||||
}
|
||||
}
|
||||
|
||||
// cancelUpdater stops any in-flight updater for this instance and
|
||||
// removes the registration. Called from the delete path so the sidecar
|
||||
// gets torn down before we try to remove the volume.
|
||||
func (d *Dispatcher) cancelUpdater(instanceID string) {
|
||||
d.mu.Lock()
|
||||
slot := d.updaters[instanceID]
|
||||
delete(d.updaters, instanceID)
|
||||
d.mu.Unlock()
|
||||
if slot != nil && slot.cancel != nil {
|
||||
slot.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Update handler. Controller sends ControllerEnvelope_Update; agent:
|
||||
//
|
||||
// 1. Looks up the instance + module manifest.
|
||||
// 2. Picks the update provider by id (or defaults to the first one).
|
||||
// 3. Emits a synchronous UpdateResult{accepted=true} so the panel can
|
||||
// un-gray the button and show "update started".
|
||||
// 4. Spawns a goroutine that runs the provider, streaming each progress
|
||||
// line as a LogLine{stream="update"}. Final sentinel line
|
||||
// "update:ok" or "update:error:<msg>" tells the UI we're done.
|
||||
//
|
||||
// Progress is not correlation-routed because the UI subscribes to the
|
||||
// normal event bus and picks up LogLines directly.
|
||||
|
||||
func (d *Dispatcher) handleUpdate(corrID string, req *panelv1.UpdateRequest) {
|
||||
d.handleUpdateWithHook(corrID, req, nil)
|
||||
}
|
||||
|
||||
// handleUpdateWithHook is the internal form that optionally fires onComplete
|
||||
// once the provider run finishes (success or failure). Used by the auto-
|
||||
// update-on-create path to chain a Start after the first-time download and
|
||||
// to clear UI state if the update errors out. The normal controller-driven
|
||||
// Update flow passes nil so the hook stays opt-in.
|
||||
func (d *Dispatcher) handleUpdateWithHook(corrID string, req *panelv1.UpdateRequest, onComplete func(err error)) {
|
||||
rec, err := d.lookupRecord(req.InstanceId)
|
||||
if err != nil {
|
||||
d.sendUpdateResult(corrID, false, err.Error(), "", "")
|
||||
return
|
||||
}
|
||||
manifest, ok := d.modules.Get(rec.ModuleID)
|
||||
if !ok {
|
||||
d.sendUpdateResult(corrID, false, "module not in registry", "", "")
|
||||
return
|
||||
}
|
||||
|
||||
// Special pseudo-provider "_basemods": short-circuits to the third-
|
||||
// party base-mod installer (Allocs + PrismaCore) instead of running
|
||||
// the module's normal updater. Reuses the Update RPC plumbing so the
|
||||
// UI gets the same "updating" pulse + error surface as a real update.
|
||||
// Reasoning for the underscore prefix: real provider ids never start
|
||||
// with one (manifest schema convention), so collisions are impossible.
|
||||
if req.ProviderId == "_basemods" {
|
||||
d.sendUpdateResult(corrID, true, "", "_basemods", "basemods")
|
||||
d.log.Info("install-base-mods: kicked", "instance_id", req.InstanceId)
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
err := d.installBaseMods(ctx, req.InstanceId, manifest, rec.DataPath)
|
||||
if err != nil {
|
||||
d.log.Error("install-base-mods: failed", "instance_id", req.InstanceId, "err", err)
|
||||
} else {
|
||||
d.log.Info("install-base-mods: done", "instance_id", req.InstanceId)
|
||||
}
|
||||
if onComplete != nil {
|
||||
onComplete(err)
|
||||
}
|
||||
}()
|
||||
return
|
||||
}
|
||||
|
||||
// Pick provider: by id if supplied, else first in manifest.
|
||||
var spec *modulepkg_UpdateProvider
|
||||
for i := range manifest.UpdateProviders {
|
||||
p := &manifest.UpdateProviders[i]
|
||||
if req.ProviderId == "" || p.ID == req.ProviderId {
|
||||
spec = providerAdapter(p)
|
||||
break
|
||||
}
|
||||
}
|
||||
if spec == nil {
|
||||
d.sendUpdateResult(corrID, false, fmt.Sprintf("no provider %q on module %s", req.ProviderId, rec.ModuleID), "", "")
|
||||
return
|
||||
}
|
||||
|
||||
prov, err := updater.Get(spec.Kind)
|
||||
if err != nil {
|
||||
d.sendUpdateResult(corrID, false, err.Error(), spec.ID, spec.Kind)
|
||||
return
|
||||
}
|
||||
|
||||
// Reject if another updater is already running on this instance —
|
||||
// see registerUpdater for why we refuse instead of preempting.
|
||||
//
|
||||
// Timeout bumped 30 → 90 min (2026-05-04) — Steam CDN can rate-limit
|
||||
// anonymous-login pulls down to 1-3 MB/s, and a 17 GB game (7DTD,
|
||||
// ARK SA) at 5 MB/s = 56 min, easily over 30. Killing the sidecar
|
||||
// at 30 min leaves a partial install + a confused operator. 90 min
|
||||
// covers the realistic worst case with margin; truly hung steamcmd
|
||||
// still gets killed eventually so we don't burn an updater slot
|
||||
// forever.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Minute)
|
||||
slot, ok := d.registerUpdater(req.InstanceId, cancel)
|
||||
if !ok {
|
||||
cancel()
|
||||
d.sendUpdateResult(corrID, false, "another update is already in progress for this instance", spec.ID, spec.Kind)
|
||||
d.log.Info("update rejected: already running", "instance_id", req.InstanceId, "provider_id", spec.ID)
|
||||
return
|
||||
}
|
||||
|
||||
// Accept synchronously — run async.
|
||||
d.sendUpdateResult(corrID, true, "", spec.ID, spec.Kind)
|
||||
d.log.Info("update started", "instance_id", req.InstanceId, "provider_id", spec.ID, "kind", spec.Kind)
|
||||
|
||||
// Surface the in-flight update as a server-authoritative status.
|
||||
// Without this, the panel's status pill stayed at whatever it was
|
||||
// before the update (often "stopped" or worse, "crashed" if the
|
||||
// previous run had hard-exited) — operators saw "crashed" while
|
||||
// SteamCMD was actually downloading several GB. Emitting UPDATING
|
||||
// here gives every dashboard viewer the same accurate "installing"
|
||||
// pulse, regardless of which client kicked off the update.
|
||||
d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_UPDATING, 0, "updating:"+spec.Kind)
|
||||
|
||||
go func() {
|
||||
defer cancel()
|
||||
defer d.unregisterUpdater(req.InstanceId, slot)
|
||||
sink := func(line string) {
|
||||
d.sendEnv(&panelv1.AgentEnvelope{
|
||||
SentAt: timestamppb.Now(),
|
||||
Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
|
||||
InstanceId: rec.InstanceID,
|
||||
Stream: "update",
|
||||
At: timestamppb.Now(),
|
||||
Line: line,
|
||||
}},
|
||||
})
|
||||
}
|
||||
sink(fmt.Sprintf("---- update started: provider=%s kind=%s ----", spec.ID, spec.Kind))
|
||||
uc := &updater.Context{
|
||||
InstanceID: rec.InstanceID,
|
||||
Manifest: manifest,
|
||||
ContainerID: rec.ContainerID,
|
||||
BrowseableRoot: rec.BrowseableRoot,
|
||||
DataPath: rec.DataPath,
|
||||
Runtime: d.runtime,
|
||||
Log: sink,
|
||||
// Steam login (if any) is forwarded by the controller for
|
||||
// modules that set `requires_steam_login`. Fields are empty
|
||||
// for anonymous-login apps — steamcmd.go falls back to that.
|
||||
SteamUsername: req.SteamUsername,
|
||||
SteamPassword: req.SteamPassword,
|
||||
}
|
||||
// ctx + cancel + slot were registered before the goroutine launched
|
||||
// (see registerUpdater above). The defer at the top of the goroutine
|
||||
// handles cleanup; the dispatcher's delete path can still abort us
|
||||
// via cancelUpdater.
|
||||
if err := prov.Update(ctx, uc, spec); err != nil {
|
||||
sink(fmt.Sprintf("update:error: %s", err.Error()))
|
||||
d.log.Warn("update failed", "instance_id", req.InstanceId, "err", err)
|
||||
// Clear the UPDATING pill — surface the failure as CRASHED with
|
||||
// a structured detail so operators see "install_failed: …" in
|
||||
// the UI rather than the pill being stuck on UPDATING forever.
|
||||
d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_CRASHED, -1, "install_failed: "+err.Error())
|
||||
if onComplete != nil {
|
||||
onComplete(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
sink("update:ok")
|
||||
d.log.Info("update complete", "instance_id", req.InstanceId, "provider_id", spec.ID)
|
||||
// Update succeeded — drop UPDATING back to STOPPED. handleStart's
|
||||
// own state emission supersedes if an auto-start follows. Without
|
||||
// this, a manually-triggered update left the pill on UPDATING
|
||||
// indefinitely until the next start/stop cycle.
|
||||
d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_STOPPED, 0, "installed")
|
||||
if onComplete != nil {
|
||||
onComplete(nil)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (d *Dispatcher) sendUpdateResult(corrID string, accepted bool, errMsg, providerID, providerKind string) {
|
||||
d.sendEnv(&panelv1.AgentEnvelope{
|
||||
CorrelationId: corrID,
|
||||
SentAt: timestamppb.Now(),
|
||||
Payload: &panelv1.AgentEnvelope_UpdateResult{
|
||||
UpdateResult: &panelv1.UpdateResult{
|
||||
Accepted: accepted,
|
||||
Error: errMsg,
|
||||
ProviderId: providerID,
|
||||
ProviderKind: providerKind,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// providerAdapter is a pass-through that pins us to the modulepkg type
|
||||
// without needing to add another import alias at the top of this file.
|
||||
func providerAdapter(p *modulepkg_UpdateProvider) *modulepkg_UpdateProvider { return p }
|
||||
Reference in New Issue
Block a user