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:01:33 -07:00
commit 0a941f3ba6
2161 changed files with 300771 additions and 0 deletions
+261
View File
@@ -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 }