panel v0.9.2 — open-source game server manager
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
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},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user