295eb22826
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
213 lines
6.6 KiB
Go
213 lines
6.6 KiB
Go
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_<appID>.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},
|
|
})
|
|
}
|