package dispatch import ( "context" "fmt" "os" "path" "sync" "time" "google.golang.org/protobuf/types/known/timestamppb" "github.com/dbledeez/panel/agent/internal/updater" "github.com/dbledeez/panel/pkg/steamvdf" panelv1 "github.com/dbledeez/panel/proto/panel/v1" ) // Update-available check (WI-14). CHECK-ONLY: reads the installed // buildid from the instance's Steam appmanifest ACF and the latest // buildid for its branch via `steamcmd +app_info_print` — it never // starts an update or touches the install. // appInfoCacheTTL is how long a fetched branch map is reused before a // fresh steamcmd run. app_info runs cost a sidecar spin-up (~10-30 s), // so checks across many instances of the same game share one fetch. const appInfoCacheTTL = 15 * time.Minute type appInfoCacheEntry struct { branches map[string]string fetchedAt time.Time } var ( appInfoCacheMu sync.Mutex appInfoCache = map[string]appInfoCacheEntry{} // key: app_id // appInfoFetchMu single-flights concurrent fetches per process; a // coarse lock is fine — checks are rare and operator-initiated. appInfoFetchMu sync.Mutex ) func (d *Dispatcher) handleUpdateCheck(corrID string, req *panelv1.UpdateCheckRequest) { fail := func(msg string) { d.sendUpdateCheckResult(corrID, &panelv1.UpdateCheckResult{ InstanceId: req.InstanceId, Error: msg, }) } rec, err := d.lookupRecord(req.InstanceId) if err != nil { fail(err.Error()) return } manifest, ok := d.modules.Get(rec.ModuleID) if !ok { fail("module not in registry") return } // Same provider selection as handleUpdate: by id when supplied, else // first in the manifest. var spec *modulepkg_UpdateProvider for i := range manifest.UpdateProviders { p := &manifest.UpdateProviders[i] if req.ProviderId == "" || p.ID == req.ProviderId { spec = p break } } if spec == nil { fail(fmt.Sprintf("no provider %q on module %s", req.ProviderId, rec.ModuleID)) return } if spec.Kind != "steamcmd" { fail(fmt.Sprintf("update check supports steamcmd providers only (provider %q is %q)", spec.ID, spec.Kind)) return } if spec.AppID == "" { fail("provider has no app_id") return } // The check spins a steamcmd sidecar (tens of seconds) — never block // the dispatch read loop. go func() { branch := spec.Beta branchKey := branch if branchKey == "" { branchKey = "public" } res := &panelv1.UpdateCheckResult{ InstanceId: req.InstanceId, AppId: spec.AppID, Branch: branchKey, } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() // Installed side: appmanifest ACF under the provider's install // root. Prefer betakey/buildid straight from the manifest file. if acf, rerr := d.readInstalledACF(ctx, rec, spec.InstallPath, spec.AppID); rerr != nil { res.Error = "read appmanifest: " + rerr.Error() } else if m, perr := steamvdf.ParseAppManifest(string(acf)); perr != nil { res.Error = "parse appmanifest: " + perr.Error() } else { res.InstalledBuildid = m.BuildID if m.BetaKey != "" { // Trust the on-disk truth over the manifest's declared // beta — this is exactly the "bounced between branches" // case the check exists to expose. res.Branch = m.BetaKey branchKey = m.BetaKey } } // Latest side: branch map via steamcmd app_info (15-min cache). branches, cached, ferr := d.latestBranches(ctx, rec.InstanceID, spec.AppID, req.Refresh) if ferr != nil { if res.Error != "" { res.Error += "; " } res.Error += "fetch latest: " + ferr.Error() } else { res.Cached = cached if bid, ok := branches[branchKey]; ok { res.LatestBuildid = bid } else if res.Error == "" { res.Error = fmt.Sprintf("branch %q not in Steam branches map (%d branches known)", branchKey, len(branches)) } } res.UpdateAvailable = res.InstalledBuildid != "" && res.LatestBuildid != "" && res.InstalledBuildid != res.LatestBuildid d.log.Info("update check", "instance_id", req.InstanceId, "app_id", spec.AppID, "branch", branchKey, "installed", res.InstalledBuildid, "latest", res.LatestBuildid, "update_available", res.UpdateAvailable, "cached", res.Cached, "err", res.Error) d.sendUpdateCheckResult(corrID, res) }() } // latestBranches returns the branch→buildid map for appID, from cache // when fresh (unless refresh), otherwise via a steamcmd sidecar run. func (d *Dispatcher) latestBranches(ctx context.Context, instanceID, appID string, refresh bool) (map[string]string, bool, error) { if !refresh { appInfoCacheMu.Lock() e, ok := appInfoCache[appID] appInfoCacheMu.Unlock() if ok && time.Since(e.fetchedAt) < appInfoCacheTTL { return e.branches, true, nil } } appInfoFetchMu.Lock() defer appInfoFetchMu.Unlock() // Re-check under the fetch lock — a concurrent check may have just // filled the cache while we waited. if !refresh { appInfoCacheMu.Lock() e, ok := appInfoCache[appID] appInfoCacheMu.Unlock() if ok && time.Since(e.fetchedAt) < appInfoCacheTTL { return e.branches, true, nil } } branches, err := updater.FetchAppInfoBranches(ctx, d.runtime, instanceID, appID, func(line string) { d.log.Debug("app_info", "instance_id", instanceID, "line", line) }) if err != nil { return nil, false, err } appInfoCacheMu.Lock() appInfoCache[appID] = appInfoCacheEntry{branches: branches, fetchedAt: time.Now()} appInfoCacheMu.Unlock() return branches, false, nil } // readInstalledACF reads steamapps/appmanifest_.acf under the // provider's install root, using the same container-first/host-fallback // mechanics as the FsRead RPC (fs helper sidecar works on stopped // instances too). func (d *Dispatcher) readInstalledACF(ctx context.Context, rec *instanceRecord, installPath, appID string) ([]byte, error) { rel := path.Join("steamapps", fmt.Sprintf("appmanifest_%s.acf", appID)) if d.useContainerOps(rec) { root := installPath if root == "" { root = rec.BrowseableRoot } abs, err := safeJoinAny(root, append(rootPaths(rec), root), path.Join(root, rel)) if err != nil { return nil, err } targetID, err := d.getFsTargetContainerID(ctx, rec) if err != nil { return nil, err } return d.runtime.CopyFileFromContainer(ctx, targetID, abs) } if rec.DataPath == "" { return nil, fmt.Errorf("no file storage available") } abs, err := safeJoinHost(rec.DataPath, rel) if err != nil { return nil, err } return os.ReadFile(abs) } func (d *Dispatcher) sendUpdateCheckResult(corrID string, res *panelv1.UpdateCheckResult) { d.sendEnv(&panelv1.AgentEnvelope{ CorrelationId: corrID, SentAt: timestamppb.Now(), Payload: &panelv1.AgentEnvelope_UpdateCheckResult{UpdateCheckResult: res}, }) }