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:25:11 -07:00
commit 03a281d009
2161 changed files with 300880 additions and 0 deletions
+212
View File
@@ -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},
})
}