4ccccc6fe2
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>
262 lines
10 KiB
Go
262 lines
10 KiB
Go
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 }
|