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--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 /steamapps/downloading//. // // 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:" 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 }