// Package updater implements the Generic Update system: pluggable // providers that fetch new versions of game files from SteamCMD, // GitHub releases, or a direct URL, then lay them down under the // instance's data path (bind mount) or container volume. // // The panel's manifest declares one or more update_providers per module; // the operator picks one to trigger. Each provider runs on the *Agent* // (close to the data) and streams progress back as LogLine envelopes // so the dashboard renders updates in the same events pane as normal // server output. package updater import ( "context" "fmt" "os" "path" "path/filepath" "github.com/dbledeez/panel/agent/internal/runtime" modulepkg "github.com/dbledeez/panel/pkg/module" ) // Context is the environment an update runs in. type Context struct { InstanceID string Manifest *modulepkg.Manifest ContainerID string // empty if container doesn't exist; present for running + stopped BrowseableRoot string // container-absolute install root, when applicable DataPath string // host bind-mount root, fallback Runtime runtime.Runtime // for container file ops + sidecar launches (future) Log func(line string) // streams a progress line upstream via LogLine // Steam login credentials for SteamCMD apps that refuse +login // anonymous (DayZ, Arma, etc.). Populated by the controller from its // encrypted steam_credentials store, forwarded via the mTLS-protected // gRPC stream. The agent redacts SteamPassword from log output before // it ever hits the bus; see steamcmd.go. SteamUsername string SteamPassword string } // Provider performs an update according to its kind. type Provider interface { Name() string Update(ctx context.Context, uc *Context, spec *modulepkg.UpdateProvider) error } // Get returns the Provider implementation for the given kind, or an // error if the kind is unknown. func Get(kind string) (Provider, error) { switch kind { case "direct": return &DirectProvider{}, nil case "github": return &GitHubProvider{}, nil case "steamcmd": return &SteamCMDProvider{}, nil default: return nil, fmt.Errorf("unknown update provider kind %q", kind) } } // writeToInstance puts data at the instance-relative path. Prefers // container-path ops when the instance has a BrowseableRoot and a live // container; otherwise falls back to writing under DataPath. func writeToInstance(ctx context.Context, uc *Context, targetPath string, data []byte) error { if targetPath == "" { return fmt.Errorf("target_path is required") } if uc.BrowseableRoot != "" && uc.ContainerID != "" { abs := path.Join(uc.BrowseableRoot, targetPath) // Ensure parent dir exists inside the container via runtime exec. // We don't have a helper in the interface; we just try to write // and rely on CopyFileToContainer returning a clear error if the // parent is missing. (itzg and most images pre-create /data.) return uc.Runtime.CopyFileToContainer(ctx, uc.ContainerID, abs, data) } if uc.DataPath == "" { return fmt.Errorf("no storage available: container has no BrowseableRoot and instance has no DataPath") } abs := filepath.Join(uc.DataPath, filepath.FromSlash(targetPath)) if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { return fmt.Errorf("mkdir %s: %w", filepath.Dir(abs), err) } if err := os.WriteFile(abs, data, 0o644); err != nil { return fmt.Errorf("write %s: %w", abs, err) } return nil }