Panel — public source drop (v0.9.0)
Self-hostable game-server control panel: controller + agent + 26 game modules. One-line install (prebuilt release, no Go required): curl -fsSL https://git.pdxtechs.com/dbledeez/panel/raw/branch/main/install.sh | sudo bash Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dbledeez/panel/agent/internal/runtime"
|
||||
"github.com/dbledeez/panel/pkg/steamvdf"
|
||||
)
|
||||
|
||||
// FetchAppInfoBranches runs a one-shot SteamCMD sidecar with
|
||||
//
|
||||
// +login anonymous +app_info_update 1 +app_info_print <appID> +quit
|
||||
//
|
||||
// captures its stdout, and parses the depots.branches map (branch name →
|
||||
// latest buildid). CHECK-ONLY: no game volumes are mounted, so this can
|
||||
// never touch an install. Only the shared panel-steamcmd-auth volume is
|
||||
// attached (same as update runs) so Steam's client config cache persists
|
||||
// across calls.
|
||||
//
|
||||
// tag disambiguates the sidecar container name per caller (usually the
|
||||
// instance id); appID appears too so parallel checks for different apps
|
||||
// can't collide.
|
||||
func FetchAppInfoBranches(ctx context.Context, rt runtime.Runtime, tag, appID string, log func(string)) (map[string]string, error) {
|
||||
if appID == "" {
|
||||
return nil, fmt.Errorf("app_info: app_id is required")
|
||||
}
|
||||
args := []string{
|
||||
"+login", "anonymous",
|
||||
"+app_info_update", "1",
|
||||
"+app_info_print", appID,
|
||||
"+quit",
|
||||
}
|
||||
spec := runtime.InstanceSpec{
|
||||
InstanceID: fmt.Sprintf("%s-appinfo-%s", tag, appID),
|
||||
IsSidecar: true,
|
||||
Image: sidecarImage,
|
||||
Command: args,
|
||||
Volumes: []runtime.VolumeSpec{{
|
||||
VolumeName: "panel-steamcmd-auth",
|
||||
ContainerPath: "/root/.local/share/Steam",
|
||||
Type: "volume",
|
||||
}},
|
||||
RestartPolicy: "no",
|
||||
// Same seccomp note as the update sidecar (steamcmd.go): newer
|
||||
// Docker default profiles ENOSYS a syscall the 32-bit Steam
|
||||
// runtime needs.
|
||||
SecurityOpts: []string{"seccomp=unconfined"},
|
||||
}
|
||||
contID, err := rt.Create(ctx, spec)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create app_info sidecar: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
rmCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
_ = rt.Remove(rmCtx, contID)
|
||||
}()
|
||||
if err := rt.Start(ctx, contID); err != nil {
|
||||
return nil, fmt.Errorf("start app_info sidecar: %w", err)
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
logCtx, cancelLogs := context.WithCancel(ctx)
|
||||
defer cancelLogs()
|
||||
logDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(logDone)
|
||||
_ = rt.StreamLogs(logCtx, contID, func(_ runtime.LogStream, line string, _ time.Time) {
|
||||
out.WriteString(line)
|
||||
out.WriteByte('\n')
|
||||
})
|
||||
}()
|
||||
exitCode, err := rt.Wait(ctx, contID)
|
||||
cancelLogs()
|
||||
<-logDone
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wait app_info sidecar: %w", err)
|
||||
}
|
||||
if log != nil {
|
||||
log(fmt.Sprintf("app_info: steamcmd exit %d, %d bytes of output", exitCode, out.Len()))
|
||||
}
|
||||
// SteamCMD sometimes exits non-zero even after printing valid app
|
||||
// info — parse first, only surface the exit code when parsing fails.
|
||||
branches, perr := steamvdf.ParseAppInfoBranches(out.String(), appID)
|
||||
if perr != nil {
|
||||
if exitCode != 0 {
|
||||
return nil, fmt.Errorf("app_info: steamcmd exit %d (%v)", exitCode, perr)
|
||||
}
|
||||
return nil, perr
|
||||
}
|
||||
return branches, nil
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
archivepkg "github.com/dbledeez/panel/agent/internal/archive"
|
||||
modulepkg "github.com/dbledeez/panel/pkg/module"
|
||||
"github.com/dbledeez/panel/pkg/version"
|
||||
)
|
||||
|
||||
// DirectProvider fetches a URL and writes the response body to a target
|
||||
// path under the instance. Used for vanilla Minecraft jars, custom mod
|
||||
// packs hosted on any HTTP server, etc.
|
||||
type DirectProvider struct{}
|
||||
|
||||
// Name identifies this provider kind in logs / manifest.
|
||||
func (DirectProvider) Name() string { return "direct" }
|
||||
|
||||
const maxDirectDownloadBytes = 1 << 30 // 1 GiB sanity cap
|
||||
|
||||
// Update downloads spec.URL and writes it to spec.TargetPath relative
|
||||
// to the instance. Optional SHA256 verification is reserved for a future
|
||||
// `sha256:` field on the spec.
|
||||
func (DirectProvider) Update(ctx context.Context, uc *Context, spec *modulepkg.UpdateProvider) error {
|
||||
if spec.URL == "" {
|
||||
return fmt.Errorf("direct provider: url is required")
|
||||
}
|
||||
if spec.TargetPath == "" {
|
||||
return fmt.Errorf("direct provider: target_path is required")
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", spec.URL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("new request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", version.UserAgent())
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Minute}
|
||||
uc.Log(fmt.Sprintf("GET %s", spec.URL))
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("http: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("http status %d", resp.StatusCode)
|
||||
}
|
||||
if resp.ContentLength > 0 {
|
||||
uc.Log(fmt.Sprintf("Content-Length: %d bytes", resp.ContentLength))
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxDirectDownloadBytes+1))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
if int64(len(data)) > maxDirectDownloadBytes {
|
||||
return fmt.Errorf("response exceeds %d-byte cap", maxDirectDownloadBytes)
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
uc.Log(fmt.Sprintf("downloaded %d bytes (sha256:%s)", len(data), hex.EncodeToString(sum[:])))
|
||||
|
||||
// Archive handling. Some direct URLs serve a whole game tree as an
|
||||
// archive (factorio's headless .tar.xz) — dumping those bytes as a
|
||||
// single file at TargetPath is never what the module wants. Extract
|
||||
// when the module opted in (spec.Extract) or when the content is a
|
||||
// recognized archive AND TargetPath looks like a directory (no file
|
||||
// extension). A TargetPath with an extension is always written
|
||||
// verbatim: minecraft server.jar IS a zip by magic bytes, and
|
||||
// terraria's entrypoint expects its .zip left on disk.
|
||||
format := archivepkg.DetectFormat(data, spec.URL)
|
||||
if spec.Extract && format == "" {
|
||||
return fmt.Errorf("extract requested but content is not a recognized archive (url %s)", spec.URL)
|
||||
}
|
||||
if format != "" && (spec.Extract || path.Ext(path.Base(spec.TargetPath)) == "") {
|
||||
uc.Log(fmt.Sprintf("content is a %s archive — extracting into %s", format, spec.TargetPath))
|
||||
entries, bytesOut, err := extractToInstance(ctx, uc, spec.TargetPath, data, spec.URL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("extract into %s: %w", spec.TargetPath, err)
|
||||
}
|
||||
uc.Log(fmt.Sprintf("extracted %d entries (%d bytes) into %s", entries, bytesOut, spec.TargetPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := writeToInstance(ctx, uc, spec.TargetPath, data); err != nil {
|
||||
return fmt.Errorf("write target: %w", err)
|
||||
}
|
||||
uc.Log(fmt.Sprintf("wrote %s", spec.TargetPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
// containerTargetAbs resolves a provider target_path to a container-
|
||||
// absolute path. An absolute target that is equal to or under one of
|
||||
// the module's declared browseable roots or volume mount points is
|
||||
// used as-is (multi-root modules: factorio's /game volume sits BESIDE
|
||||
// its /game-saves default root). Anything else keeps the historical
|
||||
// behavior of joining under BrowseableRoot — terraria's entrypoint,
|
||||
// for one, expects /terraria-server.zip to land at
|
||||
// /game-saves/terraria-server.zip via exactly that join.
|
||||
func containerTargetAbs(uc *Context, targetPath string) string {
|
||||
if strings.HasPrefix(targetPath, "/") && uc.Manifest != nil {
|
||||
cleaned := path.Clean(targetPath)
|
||||
var roots []string
|
||||
if d := uc.Manifest.Runtime.Docker; d != nil {
|
||||
for _, r := range d.BrowseableRoots {
|
||||
if r.Path != "" {
|
||||
roots = append(roots, r.Path)
|
||||
}
|
||||
}
|
||||
for _, v := range d.Volumes {
|
||||
if v.Container != "" {
|
||||
roots = append(roots, v.Container)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, r := range roots {
|
||||
if cleaned == r || strings.HasPrefix(cleaned+"/", r+"/") {
|
||||
return cleaned
|
||||
}
|
||||
}
|
||||
}
|
||||
return path.Join(uc.BrowseableRoot, targetPath)
|
||||
}
|
||||
|
||||
// extractToInstance expands archive `data` into the instance-relative
|
||||
// directory targetPath. Mirrors writeToInstance's storage preference:
|
||||
// container-path ops when the instance has a BrowseableRoot and a
|
||||
// container (re-encode the archive as one tar stream and let Docker
|
||||
// extract it in place — same pattern as the file manager's FsExtract),
|
||||
// falling back to plain filesystem extraction under DataPath.
|
||||
func extractToInstance(ctx context.Context, uc *Context, targetPath string, data []byte, originalName string) (int64, int64, error) {
|
||||
if targetPath == "" {
|
||||
return 0, 0, fmt.Errorf("target_path is required")
|
||||
}
|
||||
if uc.BrowseableRoot != "" && uc.ContainerID != "" {
|
||||
destAbs := containerTargetAbs(uc, targetPath)
|
||||
// Best-effort mkdir; only possible on a running container, and
|
||||
// the usual target is a volume mount point that already exists.
|
||||
_, _, _, _ = uc.Runtime.ExecCapture(ctx, uc.ContainerID, []string{"sh", "-c", "mkdir -p -- '" + destAbs + "'"})
|
||||
pr, pw := io.Pipe()
|
||||
emitErr := make(chan error, 1)
|
||||
var entries, bytesOut int64
|
||||
go func() {
|
||||
n, b, err := archivepkg.WriteAsTar(data, originalName, pw)
|
||||
entries, bytesOut = n, b
|
||||
_ = pw.CloseWithError(err)
|
||||
emitErr <- err
|
||||
}()
|
||||
if err := uc.Runtime.CopyTarToContainer(ctx, uc.ContainerID, destAbs, pr); err != nil {
|
||||
// Prefer the encoder's error when it carries archive-format
|
||||
// detail — but NOT when it's just the pipe slamming shut
|
||||
// because Docker aborted the copy (that would mask the real
|
||||
// error, e.g. "destination directory does not exist").
|
||||
if encErr := <-emitErr; encErr != nil && !errors.Is(encErr, io.ErrClosedPipe) {
|
||||
return entries, bytesOut, encErr
|
||||
}
|
||||
return entries, bytesOut, fmt.Errorf("copy to container %s: %w", destAbs, err)
|
||||
}
|
||||
if encErr := <-emitErr; encErr != nil {
|
||||
return entries, bytesOut, encErr
|
||||
}
|
||||
return entries, bytesOut, nil
|
||||
}
|
||||
if uc.DataPath == "" {
|
||||
return 0, 0, fmt.Errorf("no storage available: container has no BrowseableRoot and instance has no DataPath")
|
||||
}
|
||||
destAbs := filepath.Join(uc.DataPath, filepath.FromSlash(targetPath))
|
||||
if err := os.MkdirAll(destAbs, 0o755); err != nil {
|
||||
return 0, 0, fmt.Errorf("mkdir %s: %w", destAbs, err)
|
||||
}
|
||||
return archivepkg.Walk(data, originalName, func(name string, mode int64, isDir bool, body io.Reader) error {
|
||||
safe := archivepkg.SafeEntryPath(name)
|
||||
if safe == "" {
|
||||
return nil
|
||||
}
|
||||
out := filepath.Join(destAbs, filepath.FromSlash(safe))
|
||||
if isDir {
|
||||
return os.MkdirAll(out, 0o755)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
perm := os.FileMode(mode & 0o777)
|
||||
if perm == 0 {
|
||||
perm = 0o644
|
||||
}
|
||||
f, err := os.OpenFile(out, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(f, body); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ulikunitz/xz"
|
||||
|
||||
modulepkg "github.com/dbledeez/panel/pkg/module"
|
||||
)
|
||||
|
||||
// makeFactorioTarXz builds a tiny tar.xz shaped like factorio's
|
||||
// headless tarball (factorio/bin/x64/factorio).
|
||||
func makeFactorioTarXz(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
xw, err := xz.NewWriter(&buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tw := tar.NewWriter(xw)
|
||||
body := []byte("ELF-not-really")
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "factorio/bin/x64/factorio", Typeflag: tar.TypeReg, Mode: 0o755, Size: int64(len(body))}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write(body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := xw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func testCtx(t *testing.T, dataPath string) *Context {
|
||||
t.Helper()
|
||||
return &Context{
|
||||
InstanceID: "test",
|
||||
DataPath: dataPath,
|
||||
Log: func(string) {},
|
||||
}
|
||||
}
|
||||
|
||||
// The factorio case: archive content + extensionless URL + extract:true
|
||||
// must land the extracted tree under TargetPath, not a raw blob.
|
||||
func TestDirectExtractsArchiveIntoTargetDir(t *testing.T) {
|
||||
blob := makeFactorioTarXz(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write(blob)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
spec := &modulepkg.UpdateProvider{Kind: "direct", URL: srv.URL + "/get-download/stable/headless/linux64", TargetPath: "/game", Extract: true}
|
||||
if err := (DirectProvider{}).Update(context.Background(), testCtx(t, dir), spec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := filepath.Join(dir, "game", "factorio", "bin", "x64", "factorio")
|
||||
data, err := os.ReadFile(bin)
|
||||
if err != nil {
|
||||
t.Fatalf("expected extracted binary at %s: %v", bin, err)
|
||||
}
|
||||
if string(data) != "ELF-not-really" {
|
||||
t.Fatalf("binary content mismatch: %q", data)
|
||||
}
|
||||
// The old bug: raw blob written AS the target path.
|
||||
if fi, err := os.Stat(filepath.Join(dir, "game")); err != nil || !fi.IsDir() {
|
||||
t.Fatalf("target path should be a directory, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Same archive, no explicit extract flag: extensionless TargetPath
|
||||
// still triggers extraction (directory heuristic).
|
||||
func TestDirectHeuristicExtractsWithoutFlag(t *testing.T) {
|
||||
blob := makeFactorioTarXz(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write(blob)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
spec := &modulepkg.UpdateProvider{Kind: "direct", URL: srv.URL + "/linux64", TargetPath: "/game"}
|
||||
if err := (DirectProvider{}).Update(context.Background(), testCtx(t, dir), spec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "game", "factorio", "bin", "x64", "factorio")); err != nil {
|
||||
t.Fatalf("expected extraction: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The terraria/minecraft guard: a TargetPath WITH a file extension is
|
||||
// written verbatim even when the content has archive magic (jars are
|
||||
// zips; terraria's entrypoint unzips its own .zip).
|
||||
func TestDirectWritesArchiveVerbatimWhenTargetHasExtension(t *testing.T) {
|
||||
// A zip blob (like a .jar or terraria-server zip).
|
||||
var zipBuf bytes.Buffer
|
||||
zipBuf.Write([]byte{0x50, 0x4b, 0x03, 0x04}) // zip local-header magic
|
||||
zipBuf.WriteString("rest-of-zip-not-parsed")
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write(zipBuf.Bytes())
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
spec := &modulepkg.UpdateProvider{Kind: "direct", URL: srv.URL + "/terraria-server-1449.zip", TargetPath: "/terraria-server.zip"}
|
||||
if err := (DirectProvider{}).Update(context.Background(), testCtx(t, dir), spec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, "terraria-server.zip"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(data, zipBuf.Bytes()) {
|
||||
t.Fatal("zip should have been written verbatim")
|
||||
}
|
||||
}
|
||||
|
||||
// extract:true on non-archive content is a hard error, not a silent
|
||||
// verbatim write.
|
||||
func TestDirectExtractFlagOnNonArchiveErrors(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("just text"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
spec := &modulepkg.UpdateProvider{Kind: "direct", URL: srv.URL + "/blob", TargetPath: "/game", Extract: true}
|
||||
if err := (DirectProvider{}).Update(context.Background(), testCtx(t, dir), spec); err == nil {
|
||||
t.Fatal("expected error for extract on non-archive content")
|
||||
}
|
||||
}
|
||||
|
||||
// containerTargetAbs: absolute targets matching a declared root or
|
||||
// volume mount stay container-absolute (factorio's /game volume);
|
||||
// anything else keeps the historical BrowseableRoot join (terraria's
|
||||
// /terraria-server.zip → /game-saves/terraria-server.zip).
|
||||
func TestContainerTargetAbs(t *testing.T) {
|
||||
uc := &Context{
|
||||
BrowseableRoot: "/game-saves",
|
||||
Manifest: &modulepkg.Manifest{
|
||||
Runtime: modulepkg.Runtime{Docker: &modulepkg.DockerSpec{
|
||||
BrowseableRoots: []modulepkg.BrowseableRoot{{Name: "Saves", Path: "/game-saves"}},
|
||||
Volumes: []modulepkg.Volume{
|
||||
{Type: "volume", Name: "panel-$INSTANCE_ID-saves", Container: "/game-saves"},
|
||||
{Type: "volume", Name: "panel-$INSTANCE_ID-game", Container: "/game"},
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
cases := map[string]string{
|
||||
"/game": "/game",
|
||||
"/game/sub": "/game/sub",
|
||||
"/game-saves/mods": "/game-saves/mods",
|
||||
"/terraria-server.zip": "/game-saves/terraria-server.zip",
|
||||
"relative/file": "/game-saves/relative/file",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := containerTargetAbs(uc, in); got != want {
|
||||
t.Errorf("containerTargetAbs(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Non-archive content with an extensionless target keeps the old
|
||||
// single-file write behavior.
|
||||
func TestDirectNonArchiveStillWritesFile(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("binary-ish payload"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
spec := &modulepkg.UpdateProvider{Kind: "direct", URL: srv.URL + "/blob", TargetPath: "/some/file"}
|
||||
if err := (DirectProvider{}).Update(context.Background(), testCtx(t, dir), spec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, "some", "file"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "binary-ish payload" {
|
||||
t.Fatalf("content mismatch: %q", data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
modulepkg "github.com/dbledeez/panel/pkg/module"
|
||||
"github.com/dbledeez/panel/pkg/version"
|
||||
)
|
||||
|
||||
// GitHubProvider queries a repo's latest release, finds an asset whose
|
||||
// name matches the provider's AssetRegex, and writes it to TargetPath.
|
||||
type GitHubProvider struct{}
|
||||
|
||||
// Name identifies this provider kind in logs / manifest.
|
||||
func (GitHubProvider) Name() string { return "github" }
|
||||
|
||||
type githubAsset struct {
|
||||
Name string `json:"name"`
|
||||
DownloadURL string `json:"browser_download_url"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
type githubRelease struct {
|
||||
Name string `json:"name"`
|
||||
TagName string `json:"tag_name"`
|
||||
Assets []githubAsset `json:"assets"`
|
||||
}
|
||||
|
||||
// Update fetches the latest release of spec.Repo, picks the first asset
|
||||
// matching spec.AssetRegex, and writes its bytes to spec.TargetPath.
|
||||
func (GitHubProvider) Update(ctx context.Context, uc *Context, spec *modulepkg.UpdateProvider) error {
|
||||
if spec.Repo == "" {
|
||||
return fmt.Errorf("github provider: repo is required (owner/name)")
|
||||
}
|
||||
if spec.AssetRegex == "" {
|
||||
return fmt.Errorf("github provider: asset_regex is required")
|
||||
}
|
||||
if spec.TargetPath == "" {
|
||||
return fmt.Errorf("github provider: target_path is required")
|
||||
}
|
||||
re, err := regexp.Compile(spec.AssetRegex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("asset_regex: %w", err)
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", spec.Repo)
|
||||
uc.Log(fmt.Sprintf("querying GitHub: %s", apiURL))
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("new request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", version.UserAgent())
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("http: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return fmt.Errorf("github api %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
var release githubRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return fmt.Errorf("decode release: %w", err)
|
||||
}
|
||||
uc.Log(fmt.Sprintf("latest release: %s (%s), %d assets", release.Name, release.TagName, len(release.Assets)))
|
||||
|
||||
var chosen *githubAsset
|
||||
for i := range release.Assets {
|
||||
if re.MatchString(release.Assets[i].Name) {
|
||||
chosen = &release.Assets[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if chosen == nil {
|
||||
return fmt.Errorf("no asset matches %q (saw: %s)", spec.AssetRegex, assetNames(release.Assets))
|
||||
}
|
||||
uc.Log(fmt.Sprintf("downloading asset %s (%d bytes)", chosen.Name, chosen.Size))
|
||||
|
||||
direct := &modulepkg.UpdateProvider{
|
||||
Kind: "direct",
|
||||
URL: chosen.DownloadURL,
|
||||
TargetPath: spec.TargetPath,
|
||||
}
|
||||
return DirectProvider{}.Update(ctx, uc, direct)
|
||||
}
|
||||
|
||||
func assetNames(as []githubAsset) string {
|
||||
out := ""
|
||||
for _, a := range as {
|
||||
if out != "" {
|
||||
out += ", "
|
||||
}
|
||||
out += a.Name
|
||||
}
|
||||
if out == "" {
|
||||
out = "(none)"
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentmodule "github.com/dbledeez/panel/agent/internal/module"
|
||||
"github.com/dbledeez/panel/agent/internal/runtime"
|
||||
modulepkg "github.com/dbledeez/panel/pkg/module"
|
||||
)
|
||||
|
||||
// SteamCMDProvider runs `steamcmd +force_install_dir <path> +login
|
||||
// anonymous +app_update <appid> [-beta <branch>] validate +quit` in a
|
||||
// one-shot sidecar container that mounts the target instance's volumes.
|
||||
// The sidecar writes game files into the same Docker volumes the main
|
||||
// container reads from, so "update then start" just works.
|
||||
//
|
||||
// Preconditions:
|
||||
// - Target instance's container must NOT be running (Steam holds file
|
||||
// locks that conflict with a live server). Error clearly otherwise.
|
||||
// - Manifest must declare at least one volume; install_path resolves
|
||||
// to spec.InstallPath → BrowseableRoot → first volume's container path.
|
||||
type SteamCMDProvider struct{}
|
||||
|
||||
// Name identifies this provider kind in logs / manifest.
|
||||
func (SteamCMDProvider) Name() string { return "steamcmd" }
|
||||
|
||||
// sidecarImage is the official Valve-maintained SteamCMD image. Its
|
||||
// ENTRYPOINT is `steamcmd`, so we pass steamcmd's `+...` flags as Cmd.
|
||||
const sidecarImage = "steamcmd/steamcmd:latest"
|
||||
|
||||
// redactSteamPassword replaces a password in a cmdline arg slice with
|
||||
// "[REDACTED]" so the Console tab never echoes it. Used before logging
|
||||
// steamcmd argv.
|
||||
func redactSteamPassword(args []string, password string) []string {
|
||||
if password == "" {
|
||||
return args
|
||||
}
|
||||
out := make([]string, len(args))
|
||||
for i, a := range args {
|
||||
if a == password {
|
||||
out[i] = "[REDACTED]"
|
||||
continue
|
||||
}
|
||||
out[i] = a
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// redactPasswordLine strips the password substring from a log line if it
|
||||
// ever appears (e.g. SteamCMD echoing `+login user PASS` back). Returns
|
||||
// the original string when password is empty so anonymous/public flows
|
||||
// are zero-cost.
|
||||
func redactPasswordLine(line, password string) string {
|
||||
if password == "" || len(password) < 4 {
|
||||
return line
|
||||
}
|
||||
if !strings.Contains(line, password) {
|
||||
return line
|
||||
}
|
||||
return strings.ReplaceAll(line, password, "[REDACTED]")
|
||||
}
|
||||
|
||||
func (SteamCMDProvider) Update(ctx context.Context, uc *Context, spec *modulepkg.UpdateProvider) error {
|
||||
if spec.AppID == "" {
|
||||
return fmt.Errorf("steamcmd provider: app_id is required")
|
||||
}
|
||||
|
||||
// Refuse if the main container is still running — Steam locks files.
|
||||
mainName := "panel-" + uc.InstanceID
|
||||
if state, err := uc.Runtime.InspectByName(ctx, mainName); err == nil && state.Status == "running" {
|
||||
return fmt.Errorf("stop the instance first — container %q is running; SteamCMD conflicts with a live server", mainName)
|
||||
}
|
||||
|
||||
installPath := spec.InstallPath
|
||||
if installPath == "" {
|
||||
installPath = uc.BrowseableRoot
|
||||
}
|
||||
if installPath == "" {
|
||||
return fmt.Errorf("steamcmd provider: install_path missing and module has no browseable_root/volumes to default from")
|
||||
}
|
||||
|
||||
// Mount the same volumes as the target instance so SteamCMD writes
|
||||
// where the main container will later read.
|
||||
volumes := agentmodule.ResolveVolumes(uc.Manifest, uc.InstanceID, uc.DataPath)
|
||||
if len(volumes) == 0 {
|
||||
return fmt.Errorf("steamcmd provider: module declares no volumes to mount")
|
||||
}
|
||||
|
||||
// Platform override MUST come before +login — SteamCMD evaluates its
|
||||
// forced-platform state at login time, not at app_update time. Used for
|
||||
// apps that ship only a Windows depot (run via Wine on Linux hosts).
|
||||
args := []string{}
|
||||
if spec.Platform != "" {
|
||||
args = append(args, "+@sSteamCmdForcePlatformType", spec.Platform)
|
||||
}
|
||||
args = append(args, "+force_install_dir", installPath)
|
||||
|
||||
// Non-anonymous Steam login for paid apps (DayZ, Arma). If the module
|
||||
// declared requires_steam_login and the controller forwarded creds,
|
||||
// use them. Otherwise fall back to anonymous (most free-to-download
|
||||
// dedicated servers work anonymously).
|
||||
//
|
||||
// CRITICAL for UX: pass ONLY the username when possible. SteamCMD's
|
||||
// `+login <user>` uses the cached refresh token from config.vdf
|
||||
// (persisted in panel-steamcmd-auth) — no Steam Guard push, no
|
||||
// password round-trip. Passing `+login <user> <password>` forces a
|
||||
// fresh password auth every time, which triggers a 2FA push on every
|
||||
// call even when a perfectly valid cached token is already on disk.
|
||||
//
|
||||
// First-ever login ceremony happens in the controller (steamauth.go)
|
||||
// with the full password+guard flow to prime the cache. After that,
|
||||
// this path rides the cache. If the cache is stale/expired, SteamCMD
|
||||
// exits non-zero — the retry loop below re-runs which will hit the
|
||||
// same error; the operator then clicks Update, which the controller
|
||||
// can short-circuit to re-run the login modal (the `steam_login_required`
|
||||
// response path).
|
||||
needsLogin := spec.RequiresSteamLogin && uc.SteamUsername != ""
|
||||
if needsLogin {
|
||||
args = append(args, "+login", uc.SteamUsername)
|
||||
} else {
|
||||
args = append(args, "+login", "anonymous")
|
||||
}
|
||||
updateArgs := []string{"+app_update", spec.AppID}
|
||||
if spec.Beta != "" {
|
||||
updateArgs = append(updateArgs, "-beta", spec.Beta)
|
||||
}
|
||||
if !spec.SkipValidate {
|
||||
updateArgs = append(updateArgs, "validate")
|
||||
}
|
||||
args = append(args, updateArgs...)
|
||||
args = append(args, "+quit")
|
||||
|
||||
uc.Log(fmt.Sprintf("steamcmd: image=%s install_path=%s app_id=%s beta=%s", sidecarImage, installPath, spec.AppID, spec.Beta))
|
||||
uc.Log(fmt.Sprintf("steamcmd argv: %v", redactSteamPassword(args, uc.SteamPassword)))
|
||||
|
||||
// SteamCMD is notoriously flaky on first invocation — exit 8 ("missing
|
||||
// configuration"), exit 2 (generic "retry later"), and Steam auth races
|
||||
// are all recoverable by simply running the same command again. AMP,
|
||||
// LinuxGSM, and Valve's own Steam client all retry on these codes. We
|
||||
// mirror that behaviour so operators don't have to click Update twice.
|
||||
const maxAttempts = 4
|
||||
var lastExit int
|
||||
var ranSuccessfully bool
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
exitCode, err := runSteamCMDOnce(ctx, uc, sidecarImage, args, volumes, attempt)
|
||||
lastExit = exitCode
|
||||
if err == nil && exitCode == 0 {
|
||||
uc.Log(fmt.Sprintf("steamcmd: completed successfully (app_id=%s, attempts=%d)", spec.AppID, attempt))
|
||||
ranSuccessfully = true
|
||||
break
|
||||
}
|
||||
// Transient exit codes SteamCMD is known to recover from. Anything
|
||||
// else we surface immediately (missing disk space, bad app id, etc.).
|
||||
transient := err == nil && (exitCode == 2 || exitCode == 5 || exitCode == 8)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !transient || attempt == maxAttempts {
|
||||
break
|
||||
}
|
||||
backoff := time.Duration(attempt*attempt*3) * time.Second // 3s, 12s, 27s
|
||||
uc.Log(fmt.Sprintf("steamcmd: exit %d — transient, retrying in %s (attempt %d/%d)", exitCode, backoff, attempt+1, maxAttempts))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize: SteamCMD with `+force_install_dir <p>` stages downloads
|
||||
// at <p>/steamapps/downloading/<appid>/ and atomically renames into
|
||||
// <p>/ on success. When SteamCMD exits with state 0x602 (often after
|
||||
// concurrent runs interrupt finalization, or a `validate` pass races
|
||||
// the move) the bytes are on disk but never get renamed — operator
|
||||
// sees an empty install dir and an exit-8 error from the panel.
|
||||
//
|
||||
// This step runs unconditionally after the retry loop. If the staging
|
||||
// dir is empty or absent (the happy path), it's a cheap no-op. If it
|
||||
// has content, we cp -a it into place and the install completes.
|
||||
finalized, ferr := finalizeStagingDir(ctx, uc, installPath, spec.AppID, volumes)
|
||||
if ferr != nil {
|
||||
uc.Log(fmt.Sprintf("steamcmd: finalize check failed: %s (continuing anyway)", ferr.Error()))
|
||||
} else if finalized {
|
||||
uc.Log("steamcmd: recovered staged install (state 0x602 workaround)")
|
||||
return nil
|
||||
}
|
||||
|
||||
if ranSuccessfully {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("steamcmd exited %d after %d attempt(s)", lastExit, maxAttempts)
|
||||
}
|
||||
|
||||
// finalizeStagingDir checks for a stranded SteamCMD staging directory
|
||||
// at <installPath>/steamapps/downloading/<appid>/ and, if present and
|
||||
// non-empty, moves its contents into <installPath>/. Runs as a one-shot
|
||||
// alpine sidecar with the same volume mounts as the SteamCMD run so
|
||||
// it has access to the install path. Returns true if a recovery move
|
||||
// actually happened, false if there was nothing to do.
|
||||
//
|
||||
// The script is intentionally defensive:
|
||||
// - mv is preferred over cp+rm because it's atomic on the same FS.
|
||||
// - If both source and destination have entries with the same name,
|
||||
// mv -f overwrites (rare; happens when steamcmd partially renamed
|
||||
// before dying).
|
||||
// - We exit 0 on "no staging dir" so this is a no-op for the happy
|
||||
// path.
|
||||
func finalizeStagingDir(ctx context.Context, uc *Context, installPath, appID string, volumes []runtime.VolumeSpec) (bool, error) {
|
||||
const stagingMarker = "/__staging__"
|
||||
script := fmt.Sprintf(`set -e
|
||||
DL=%q/steamapps/downloading/%s
|
||||
DEST=%q
|
||||
if [ ! -d "$DL" ] || [ -z "$(ls -A "$DL" 2>/dev/null)" ]; then
|
||||
echo "no-staging"
|
||||
exit 0
|
||||
fi
|
||||
echo "finalizing $DL -> $DEST"
|
||||
mkdir -p "$DEST"
|
||||
# mv every top-level entry; -f overwrites partial renames.
|
||||
for f in "$DL"/* "$DL"/.[!.]* "$DL"/..?*; do
|
||||
[ -e "$f" ] || continue
|
||||
mv -f "$f" "$DEST/" 2>&1 || cp -a "$f" "$DEST/" && rm -rf "$f"
|
||||
done
|
||||
rm -rf "$DL"
|
||||
touch %q
|
||||
echo "finalized"
|
||||
`, installPath, appID, installPath, installPath+stagingMarker)
|
||||
|
||||
sidecarSpec := runtime.InstanceSpec{
|
||||
InstanceID: fmt.Sprintf("%s-steamcmd-finalize", uc.InstanceID),
|
||||
IsSidecar: true,
|
||||
Image: "alpine:latest",
|
||||
Command: []string{"sh", "-c", script},
|
||||
Volumes: volumes,
|
||||
RestartPolicy: "no",
|
||||
}
|
||||
contID, err := uc.Runtime.Create(ctx, sidecarSpec)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("create finalize sidecar: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
rmCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
_ = uc.Runtime.Remove(rmCtx, contID)
|
||||
}()
|
||||
if err := uc.Runtime.Start(ctx, contID); err != nil {
|
||||
return false, fmt.Errorf("start finalize sidecar: %w", err)
|
||||
}
|
||||
logCtx, cancelLogs := context.WithCancel(ctx)
|
||||
defer cancelLogs()
|
||||
logDone := make(chan struct{})
|
||||
var lastLine string
|
||||
go func() {
|
||||
defer close(logDone)
|
||||
_ = uc.Runtime.StreamLogs(logCtx, contID, func(_ runtime.LogStream, line string, _ time.Time) {
|
||||
lastLine = strings.TrimSpace(line)
|
||||
uc.Log("finalize: " + lastLine)
|
||||
})
|
||||
}()
|
||||
exitCode, err := uc.Runtime.Wait(ctx, contID)
|
||||
cancelLogs()
|
||||
<-logDone
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("wait finalize sidecar: %w", err)
|
||||
}
|
||||
if exitCode != 0 {
|
||||
return false, fmt.Errorf("finalize sidecar exit %d", exitCode)
|
||||
}
|
||||
return lastLine == "finalized", nil
|
||||
}
|
||||
|
||||
// runSteamCMDOnce is a single run of the steamcmd sidecar; called in a loop
|
||||
// by the retry wrapper. Returns the container's exit code on clean
|
||||
// lifecycle (even non-zero) or an error on infrastructure failure.
|
||||
func runSteamCMDOnce(ctx context.Context, uc *Context, image string, args []string, volumes []runtime.VolumeSpec, attempt int) (int, error) {
|
||||
sidecarID := fmt.Sprintf("%s-steamcmd-%d", uc.InstanceID, attempt)
|
||||
// Mount the panel-wide SteamCMD auth volume at /root/.local/share/Steam
|
||||
// (the real data dir of the steamcmd:latest image — NOT /root/Steam).
|
||||
// This persists config/config.vdf (login refresh token) + ssfn sentry,
|
||||
// which lets subsequent `+login <user>` runs skip the 2FA push. Anon
|
||||
// logins ignore this dir — no harm.
|
||||
volumesWithAuth := append([]runtime.VolumeSpec(nil), volumes...)
|
||||
volumesWithAuth = append(volumesWithAuth, runtime.VolumeSpec{
|
||||
VolumeName: "panel-steamcmd-auth",
|
||||
ContainerPath: "/root/.local/share/Steam",
|
||||
Type: "volume",
|
||||
})
|
||||
sidecarSpec := runtime.InstanceSpec{
|
||||
InstanceID: sidecarID,
|
||||
IsSidecar: true,
|
||||
Image: image,
|
||||
Command: args,
|
||||
Volumes: volumesWithAuth,
|
||||
RestartPolicy: "no",
|
||||
// Docker 29.4+ default seccomp ENOSYS's a syscall the 32-bit
|
||||
// Steam runtime needs ("CreateBoundSocket error 38"). Older
|
||||
// Docker (29.1.x on princess/ivy) doesn't filter it. Unconfine
|
||||
// for the sidecar so install/validate works across hosts.
|
||||
SecurityOpts: []string{"seccomp=unconfined"},
|
||||
}
|
||||
contID, err := uc.Runtime.Create(ctx, sidecarSpec)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create sidecar: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
rmCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
_ = uc.Runtime.Remove(rmCtx, contID)
|
||||
}()
|
||||
if err := uc.Runtime.Start(ctx, contID); err != nil {
|
||||
return 0, fmt.Errorf("start sidecar: %w", err)
|
||||
}
|
||||
logCtx, cancelLogs := context.WithCancel(ctx)
|
||||
defer cancelLogs()
|
||||
logDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(logDone)
|
||||
_ = uc.Runtime.StreamLogs(logCtx, contID, func(_ runtime.LogStream, line string, _ time.Time) {
|
||||
uc.Log(redactPasswordLine(line, uc.SteamPassword))
|
||||
})
|
||||
}()
|
||||
exitCode, err := uc.Runtime.Wait(ctx, contID)
|
||||
cancelLogs()
|
||||
<-logDone
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("wait sidecar: %w", err)
|
||||
}
|
||||
return exitCode, nil
|
||||
}
|
||||
Reference in New Issue
Block a user