From 40e7372dab718a117b5bc1da544a60f0a09afb48 Mon Sep 17 00:00:00 2001 From: Greg Date: Sun, 25 Oct 2026 01:42:55 -0400 Subject: [PATCH] feat: inspect-image reads what a game's image needs `gbrgames inspect-image -game ` pulls the image and reads, without starting the game (probe runs with --network none and bypasses the entrypoint): its user, accounts, home folder owners and modes, tools (java, steamcmd, proton...), startup scripts and the variables they read. It then checks the template against those facts: - an image built around its own user (a locked home owned by another uid, or a declared USER) needs a matching containerUser - the startup command must read settings under the names the node gives them (the agent's toUpperSnakeCase), and must not read unset variables that have no default On the template from the first commit it reports both ARK bugs found by boot tests today (uid 25000; ${RCON_PORT} vs RCONPORT); the current five templates pass. Boot tests now run it first, so such templates fail in seconds instead of after a boot. -json prints the facts for tooling. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 + cmd/gbrgames/inspect.go | 506 +++++++++++++++++++++++++++++++++++ cmd/gbrgames/inspect_test.go | 129 +++++++++ cmd/gbrgames/main.go | 5 +- scripts/boot-test.sh | 7 + 5 files changed, 652 insertions(+), 1 deletion(-) create mode 100644 cmd/gbrgames/inspect.go create mode 100644 cmd/gbrgames/inspect_test.go diff --git a/README.md b/README.md index f68b618..2ddff4d 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,12 @@ go run ./cmd/gbrgames validate # what CI runs on every push go run ./cmd/gbrgames import admin-games.export.json # pull templates from GET /api/v1/admin/games ``` +`go run ./cmd/gbrgames inspect-image -game ` pulls the game's Docker image and reads what it needs +without starting the game: its user, home folder permissions, ports, tools and startup scripts. It then +checks the template against that, e.g. an image built around its own user needs `containerUser`, and a +startup command must read settings under the names the node gives them (`RCONPort` arrives as +`RCONPORT`). Boot tests run it first, so these fail in seconds. `-json` prints the facts for tooling. + Besides the schemas, `validate` checks that each folder name matches its `id`, that referenced config keys exist, that consents don't default to accepted, and that no credentials or private network addresses end up in a template. diff --git a/cmd/gbrgames/inspect.go b/cmd/gbrgames/inspect.go new file mode 100644 index 0000000..ee7b2f6 --- /dev/null +++ b/cmd/gbrgames/inspect.go @@ -0,0 +1,506 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +// Facts about a game server image, read without starting the game: Docker metadata plus one short probe +// (no network) for users, home folder permissions, tools and startup scripts. The template checks and, +// later, the AI builder work from these instead of guessing from web search. +type imageFacts struct { + Image string `json:"image"` + User string `json:"user"` // image's USER; "" = root + Env map[string]string `json:"env"` + ExposedPorts []string `json:"exposedPorts"` + Volumes []string `json:"volumes"` + Entrypoint []string `json:"entrypoint"` + Cmd []string `json:"cmd"` + WorkingDir string `json:"workingDir"` + Users []imageUser `json:"users"` // regular accounts (uid 1000-64999) + Homes []homeDir `json:"homes"` // /home/* and /root + Tools map[string]string `json:"tools"` // e.g. java -> /usr/bin/java + Scripts map[string]string `json:"scripts"` // startup scripts, path -> content (truncated) + EnvRead []string `json:"envRead"` // variables those scripts read + ProbeError string `json:"probeError,omitempty"` + // What the node needs to run this image: set when the image is built around a user other than 1000:1000 + SuggestedContainerUser string `json:"suggestedContainerUser,omitempty"` + SuggestionReason string `json:"suggestionReason,omitempty"` +} + +type imageUser struct { + Name string `json:"name"` + UID int `json:"uid"` + GID int `json:"gid"` + Home string `json:"home"` +} + +type homeDir struct { + Path string `json:"path"` + UID int `json:"uid"` + GID int `json:"gid"` + Mode string `json:"mode"` // octal, e.g. "700" +} + +const ( + nodeUser = "1000:1000" + maxScriptBytes = 60000 + probeMarker = "@@gbr-probe@@" +) + +// Variables every shell or runtime provides; reading them says nothing about the game. +var commonEnv = map[string]bool{ + "HOME": true, "PATH": true, "USER": true, "PWD": true, "SHELL": true, "TERM": true, "HOSTNAME": true, + "LANG": true, "LC_ALL": true, "UID": true, "GID": true, "IFS": true, "OLDPWD": true, "TMPDIR": true, + "XDG_RUNTIME_DIR": true, "LD_LIBRARY_PATH": true, "DEBIAN_FRONTEND": true, "BASH_SOURCE": true, +} + +var ( + envRef = regexp.MustCompile(`\$\{?([A-Z][A-Z0-9_]{2,})\b`) + camelSplit = regexp.MustCompile(`([a-z])([A-Z]+)`) + absPath = regexp.MustCompile(`(?:^|[\s'"=;&|(])(/[A-Za-z0-9_./-]+)`) + // ${VAR:-x} / ${VAR-x}: the command copes when VAR is unset + defaultedRef = regexp.MustCompile(`\$\{([A-Z][A-Z0-9_]{2,}):?-`) + // VAR=... or export VAR=... inside the command + assignedVars = regexp.MustCompile(`(?:^|[\s;&|(])(?:export\s+)?([A-Z][A-Z0-9_]{2,})=`) +) + +func uidOf(user string) string { + uid, _, _ := strings.Cut(user, ":") + return uid +} + +// envName is the environment variable a config key reaches the container as: the node-agent's +// toUpperSnakeCase (RCONPort -> RCONPORT, ServerAdminPassword -> SERVER_ADMIN_PASSWORD). +func envName(key string) string { + return strings.ToUpper(camelSplit.ReplaceAllString(key, "${1}_${2}")) +} + +func runInspectImage(args []string) error { + fs := flag.NewFlagSet("inspect-image", flag.ExitOnError) + gamesDir := fs.String("games", "games", "directory holding one folder per game") + gameID := fs.String("game", "", "also check this game's template against the image") + asJSON := fs.Bool("json", false, "print the facts as JSON") + noPull := fs.Bool("no-pull", false, "use the local image instead of pulling it") + fs.Parse(args) + + var game map[string]any + image := fs.Arg(0) + if *gameID != "" { + g, err := loadMap(filepath.Join(*gamesDir, *gameID, "game.yaml")) + if err != nil { + return err + } + game = g + if image == "" { + image, _ = g["defaultDockerImage"].(string) + } + } + if image == "" { + return errors.New("inspect-image needs an IMAGE or -game ID") + } + + if !*noPull { + if out, err := exec.Command("docker", "pull", "-q", image).CombinedOutput(); err != nil { + return fmt.Errorf("docker pull %s: %v: %s", image, err, strings.TrimSpace(string(out))) + } + } + facts, err := inspectImage(image, startupScriptPaths(game)) + if err != nil { + return err + } + + var problems, notes []string + if game != nil { + problems, notes = checkTemplate(game, facts) + } + + if *asJSON { + out := map[string]any{"facts": facts} + if game != nil { + out["problems"], out["notes"] = problems, notes + } + b, _ := json.MarshalIndent(out, "", " ") + fmt.Println(string(b)) + } else { + printFacts(facts) + if game != nil { + fmt.Printf("\nTemplate %s against this image:\n", *gameID) + for _, p := range problems { + fmt.Println(" PROBLEM " + p) + } + for _, n := range notes { + fmt.Println(" note " + n) + } + if len(problems) == 0 { + fmt.Println(" no problems found") + } + } + } + if len(problems) > 0 { + return fmt.Errorf("%d problem(s) with %s", len(problems), *gameID) + } + return nil +} + +func inspectImage(image string, extraScripts []string) (*imageFacts, error) { + out, err := exec.Command("docker", "image", "inspect", "--format", "{{json .Config}}", image).Output() + if err != nil { + return nil, fmt.Errorf("docker image inspect %s: %w", image, err) + } + var cfg struct { + User string + Env []string + ExposedPorts map[string]any + Volumes map[string]any + Entrypoint []string + Cmd []string + WorkingDir string + } + if err := json.Unmarshal(out, &cfg); err != nil { + return nil, fmt.Errorf("parse image config: %w", err) + } + f := &imageFacts{ + Image: image, User: cfg.User, Env: map[string]string{}, Entrypoint: cfg.Entrypoint, Cmd: cfg.Cmd, + WorkingDir: cfg.WorkingDir, ExposedPorts: sortedSet(cfg.ExposedPorts), Volumes: sortedSet(cfg.Volumes), + Tools: map[string]string{}, Scripts: map[string]string{}, + } + for _, kv := range cfg.Env { + if k, v, ok := strings.Cut(kv, "="); ok { + f.Env[k] = v + } + } + + scripts := append(append([]string{}, firstWord(cfg.Entrypoint), firstWord(cfg.Cmd)), extraScripts...) + probe, err := runProbe(image, scripts) + if err != nil { + f.ProbeError = err.Error() + } else { + parseProbe(f, probe) + } + suggestContainerUser(f) + return f, nil +} + +// runProbe reads users, home folders, tools and scripts from inside the image. --network none: the probe +// never needs the internet, and the image's own entrypoint is bypassed so nothing of the game starts. +func runProbe(image string, scripts []string) (string, error) { + script := ` +m="` + probeMarker + `" +echo "$m passwd"; cat /etc/passwd 2>/dev/null +echo "$m homes"; for d in /home/* /root; do [ -d "$d" ] && stat -c '%n|%u|%g|%a' "$d" 2>/dev/null; done +echo "$m tools" +for t in java steamcmd steamcmd.sh proton wine wine64 bash python3 node dotnet; do p=$(command -v "$t" 2>/dev/null) && echo "$t|$p"; done +for d in /home/*/steamcmd /opt/steamcmd /steamcmd /usr/games/steamcmd; do [ -e "$d" ] && echo "steamcmd-dir|$d"; done +for s in "$@"; do + [ -n "$s" ] || continue + p=$(command -v "$s" 2>/dev/null || echo "$s") + [ -f "$p" ] || continue + [ "$(head -c 2 "$p")" = "#!" ] || continue + echo "$m script $p"; head -c ` + strconv.Itoa(maxScriptBytes) + ` "$p"; echo +done +` + args := append([]string{"run", "--rm", "--network", "none", "--entrypoint", "sh", image, "-c", script, "probe"}, scripts...) + cmd := exec.Command("docker", args...) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + if err := cmd.Run(); err != nil && stdout.Len() == 0 { + return "", fmt.Errorf("probe could not run a shell in the image: %v %s", err, strings.TrimSpace(stderr.String())) + } + return stdout.String(), nil +} + +func parseProbe(f *imageFacts, out string) { + section, scriptPath := "", "" + var script strings.Builder + flush := func() { + if scriptPath != "" { + f.Scripts[scriptPath] = script.String() + } + scriptPath = "" + script.Reset() + } + for _, line := range strings.Split(out, "\n") { + if rest, ok := strings.CutPrefix(line, probeMarker+" "); ok { + flush() + section = rest + if p, ok := strings.CutPrefix(rest, "script "); ok { + section, scriptPath = "script", p + } + continue + } + switch section { + case "passwd": + parts := strings.Split(line, ":") + if len(parts) < 6 { + continue + } + uid, _ := strconv.Atoi(parts[2]) + gid, _ := strconv.Atoi(parts[3]) + if uid >= 1000 && uid < 65000 { + f.Users = append(f.Users, imageUser{Name: parts[0], UID: uid, GID: gid, Home: parts[5]}) + } + case "homes": + parts := strings.Split(line, "|") + if len(parts) == 4 { + uid, _ := strconv.Atoi(parts[1]) + gid, _ := strconv.Atoi(parts[2]) + f.Homes = append(f.Homes, homeDir{Path: parts[0], UID: uid, GID: gid, Mode: parts[3]}) + } + case "tools": + if name, path, ok := strings.Cut(line, "|"); ok { + f.Tools[name] = path + } + case "script": + script.WriteString(line + "\n") + } + } + flush() + + seen := map[string]bool{} + for _, body := range f.Scripts { + for _, m := range envRef.FindAllStringSubmatch(body, -1) { + if !commonEnv[m[1]] { + seen[m[1]] = true + } + } + } + f.EnvRead = sortedKeysBool(seen) +} + +// suggestContainerUser works out whether the image only works as a specific user. Images either declare +// it (USER) or are built around an account whose home folder nobody else may enter (ARK's gameserver, +// uid 25000, with /home/gameserver at 700). +func suggestContainerUser(f *imageFacts) { + if u := strings.TrimSpace(f.User); u != "" && u != "root" && u != "0" { + resolved := resolveUser(f, u) + if resolved != "" && resolved != nodeUser { + f.SuggestedContainerUser = resolved + f.SuggestionReason = fmt.Sprintf("the image declares USER %s", u) + } + return + } + for _, h := range f.Homes { + if h.Path == "/root" || h.UID == 0 || strconv.Itoa(h.UID) == uidOf(nodeUser) { + continue + } + mode, err := strconv.ParseInt(h.Mode, 8, 32) + if err != nil || mode&0o005 != 0 { // others can enter it, so 1000:1000 would get in too + continue + } + // The account's own group if it has one; the folder's group otherwise + gid := h.GID + for _, acc := range f.Users { + if acc.UID == h.UID { + gid = acc.GID + } + } + f.SuggestedContainerUser = fmt.Sprintf("%d:%d", h.UID, gid) + f.SuggestionReason = fmt.Sprintf("%s belongs to uid %d with mode %s, so the node's default %s cannot enter it", h.Path, h.UID, h.Mode, nodeUser) + return + } +} + +func resolveUser(f *imageFacts, u string) string { + name, group, _ := strings.Cut(u, ":") + for _, acc := range f.Users { + if acc.Name == name || strconv.Itoa(acc.UID) == name { + if group == "" { + return fmt.Sprintf("%d:%d", acc.UID, acc.GID) + } + if _, err := strconv.Atoi(group); err == nil { + return fmt.Sprintf("%d:%s", acc.UID, group) + } + return fmt.Sprintf("%d:%d", acc.UID, acc.GID) + } + } + if _, err := strconv.Atoi(name); err == nil { + if group == "" { + group = name + } + return name + ":" + group + } + return "" +} + +// checkTemplate compares a game template with what the image needs. Problems are things that stop or +// silently break the server; notes are worth a look but often fine. +func checkTemplate(game map[string]any, f *imageFacts) (problems, notes []string) { + set, _ := game["containerUser"].(string) + effective := set + if effective == "" { + effective = nodeUser + } + // Only the uid decides whether the server can enter its folders; the group may reasonably differ + if f.SuggestedContainerUser != "" && uidOf(effective) != uidOf(f.SuggestedContainerUser) { + problems = append(problems, fmt.Sprintf("image needs containerUser %q (%s), template runs as %s", + f.SuggestedContainerUser, f.SuggestionReason, effective)) + } + if f.ProbeError != "" { + notes = append(notes, "could not look inside the image: "+f.ProbeError) + } + + // Everything the container will have in its environment: the image's own defaults plus one variable + // per config key, named the way the node-agent names them + provided := map[string]bool{} + for k := range f.Env { + provided[k] = true + } + defaults, _ := game["defaultConfig"].(map[string]any) + byLoose := map[string]string{} + for key := range defaults { + n := envName(key) + provided[n] = true + byLoose[strings.ReplaceAll(n, "_", "")] = n + } + if cmd, _ := game["defaultStartupCommand"].(string); cmd != "" { + for _, m := range assignedVars.FindAllStringSubmatch(cmd, -1) { + provided[m[1]] = true // set by the command itself, e.g. export ASA_START_PARAMS=... + } + withDefault := map[string]bool{} + for _, m := range defaultedRef.FindAllStringSubmatch(cmd, -1) { + withDefault[m[1]] = true + } + for _, v := range uniqueRefs(cmd) { + if provided[v] || commonEnv[v] { + continue + } + near, misnamed := byLoose[strings.ReplaceAll(v, "_", "")] + switch { + case misnamed: + // The RCON bug: ${RCON_PORT:-27020} while the RCONPort setting arrives as RCONPORT, so the + // default always wins and the owner's setting is silently ignored + problems = append(problems, fmt.Sprintf("startup command reads ${%s}, but the setting arrives as %s", v, near)) + case !withDefault[v]: + problems = append(problems, fmt.Sprintf("startup command reads ${%s}, which nothing sets and has no default", v)) + } + } + } + + ports, _ := game["defaultPorts"].([]any) + templatePorts := map[string]bool{} + for _, p := range ports { + templatePorts[containerPort(fmt.Sprint(p))] = true + } + for _, p := range f.ExposedPorts { + if !templatePorts[p] { + notes = append(notes, fmt.Sprintf("image exposes %s, template does not publish it (fine for RCON/admin ports)", p)) + } + } + return problems, notes +} + +// containerPort turns "8211:8211/udp" or "7777/udp" or "25565" into Docker's "8211/udp" form. +func containerPort(p string) string { + if i := strings.LastIndex(p, ":"); i >= 0 { + p = p[i+1:] + } + if !strings.Contains(p, "/") { + p += "/tcp" + } + return p +} + +// startupScriptPaths picks absolute paths out of a template's startup command (e.g. ARK's +// /usr/bin/start_server) so the probe reads those scripts too. +func startupScriptPaths(game map[string]any) []string { + cmd, _ := game["defaultStartupCommand"].(string) + var paths []string + for _, m := range absPath.FindAllStringSubmatch(cmd, -1) { + if p := m[1]; !strings.HasPrefix(p, "/server") && !strings.HasPrefix(p, "/data") && !strings.HasPrefix(p, "/home/") { + paths = append(paths, p) + } + } + return paths +} + +func uniqueRefs(s string) []string { + seen := map[string]bool{} + for _, m := range envRef.FindAllStringSubmatch(s, -1) { + seen[m[1]] = true + } + return sortedKeysBool(seen) +} + +func printFacts(f *imageFacts) { + fmt.Printf("Image: %s\n", f.Image) + user := f.User + if user == "" { + user = "(root)" + } + fmt.Printf("Default user: %s\n", user) + if f.SuggestedContainerUser != "" { + fmt.Printf("Needs: containerUser %q, because %s\n", f.SuggestedContainerUser, f.SuggestionReason) + } + fmt.Printf("Entrypoint: %s\n", strings.Join(append(append([]string{}, f.Entrypoint...), f.Cmd...), " ")) + fmt.Printf("Ports: %s\n", strings.Join(f.ExposedPorts, ", ")) + fmt.Printf("Volumes: %s\n", strings.Join(f.Volumes, ", ")) + for _, u := range f.Users { + fmt.Printf("User: %s (uid %d, gid %d, home %s)\n", u.Name, u.UID, u.GID, u.Home) + } + for _, h := range f.Homes { + fmt.Printf("Home: %s owned by %d:%d mode %s\n", h.Path, h.UID, h.GID, h.Mode) + } + if len(f.Tools) > 0 { + var t []string + for _, k := range sortedKeysString(f.Tools) { + t = append(t, k+"="+f.Tools[k]) + } + fmt.Printf("Tools: %s\n", strings.Join(t, ", ")) + } + for _, p := range sortedKeysString(f.Scripts) { + fmt.Printf("Script: %s (%d bytes)\n", p, len(f.Scripts[p])) + } + if len(f.EnvRead) > 0 { + fmt.Printf("Scripts read: %s\n", strings.Join(f.EnvRead, ", ")) + } + if len(f.Env) > 0 { + fmt.Printf("Image env: %s\n", strings.Join(sortedKeysString(f.Env), ", ")) + } + if f.ProbeError != "" { + fmt.Printf("Probe: %s\n", f.ProbeError) + } +} + +func firstWord(s []string) string { + if len(s) == 0 { + return "" + } + return s[0] +} + +func sortedSet(m map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func sortedKeysBool(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func sortedKeysString(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/cmd/gbrgames/inspect_test.go b/cmd/gbrgames/inspect_test.go new file mode 100644 index 0000000..8b80946 --- /dev/null +++ b/cmd/gbrgames/inspect_test.go @@ -0,0 +1,129 @@ +package main + +import ( + "reflect" + "strings" + "testing" +) + +func TestEnvNameMatchesNodeAgent(t *testing.T) { + for key, want := range map[string]string{ + "RCONPort": "RCONPORT", + "ServerAdminPassword": "SERVER_ADMIN_PASSWORD", + "AllowFlyerCarryPvE": "ALLOW_FLYER_CARRY_PV_E", + "bDisableStructurePlacementCollision": "B_DISABLE_STRUCTURE_PLACEMENT_COLLISION", + "RCON_PASSWORD": "RCON_PASSWORD", + "MaxPlayers": "MAX_PLAYERS", + } { + if got := envName(key); got != want { + t.Errorf("envName(%q) = %q, want %q", key, got, want) + } + } +} + +// Probe output as ARK's image produces it (trimmed) +const arkProbe = probeMarker + ` passwd +root:x:0:0:root:/root:/bin/bash +gameserver:x:25000:25000::/home/gameserver:/bin/bash +nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin +` + probeMarker + ` homes +/home/gameserver|25000|1000|700 +/root|0|0|700 +` + probeMarker + ` tools +bash|/usr/bin/bash +` + probeMarker + ` script /usr/bin/start_server +#!/bin/bash +cd /home/gameserver/steamcmd +echo "$ASA_START_PARAMS ${ENABLE_DEBUG} $HOME" +` + +func arkFacts() *imageFacts { + f := &imageFacts{Env: map[string]string{}, Tools: map[string]string{}, Scripts: map[string]string{}} + parseProbe(f, arkProbe) + suggestContainerUser(f) + return f +} + +func TestProbeParsing(t *testing.T) { + f := arkFacts() + if len(f.Users) != 1 || f.Users[0].Name != "gameserver" || f.Users[0].UID != 25000 { + t.Fatalf("users = %+v", f.Users) + } + if len(f.Homes) != 2 || f.Homes[0].Mode != "700" { + t.Fatalf("homes = %+v", f.Homes) + } + if !strings.Contains(f.Scripts["/usr/bin/start_server"], "steamcmd") { + t.Fatalf("script not captured: %q", f.Scripts) + } + if want := []string{"ASA_START_PARAMS", "ENABLE_DEBUG"}; !reflect.DeepEqual(f.EnvRead, want) { + t.Errorf("envRead = %v, want %v (HOME is common and left out)", f.EnvRead, want) + } +} + +func TestSuggestContainerUser(t *testing.T) { + // Locked home owned by another uid: use that account, with its own group + if f := arkFacts(); f.SuggestedContainerUser != "25000:25000" { + t.Errorf("ARK: suggested %q, want 25000:25000", f.SuggestedContainerUser) + } + + // uid 1000 images need nothing + f := &imageFacts{Homes: []homeDir{{Path: "/home/steam", UID: 1000, GID: 1000, Mode: "700"}}} + suggestContainerUser(f) + if f.SuggestedContainerUser != "" { + t.Errorf("uid 1000 image: suggested %q", f.SuggestedContainerUser) + } + + // A home others may enter doesn't force a user + f = &imageFacts{Homes: []homeDir{{Path: "/home/app", UID: 2000, GID: 2000, Mode: "755"}}} + suggestContainerUser(f) + if f.SuggestedContainerUser != "" { + t.Errorf("open home: suggested %q", f.SuggestedContainerUser) + } + + // A declared USER is resolved through /etc/passwd + f = &imageFacts{User: "steam", Users: []imageUser{{Name: "steam", UID: 4242, GID: 4243}}} + suggestContainerUser(f) + if f.SuggestedContainerUser != "4242:4243" { + t.Errorf("declared USER: suggested %q", f.SuggestedContainerUser) + } +} + +func TestCheckTemplate(t *testing.T) { + f := arkFacts() + game := map[string]any{ + "defaultConfig": map[string]any{"RCONPort": 27020, "MaxPlayers": 70}, + // Mirrors ARK's old command: ASA_START_PARAMS is assigned, SERVER_MAP has a default, RCON_PORT is the + // misnamed one, MAX_PLAYERS is set by a setting, MISSING has no default at all + "defaultStartupCommand": `export ASA_START_PARAMS="${ASA_START_PARAMS:-${SERVER_MAP:-TheIsland}?RCONPort=${RCON_PORT:-27020} -Max=${MAX_PLAYERS:-70} ${MISSING}}"`, + } + problems, _ := checkTemplate(game, f) + joined := strings.Join(problems, "\n") + for _, want := range []string{"containerUser \"25000:25000\"", "${RCON_PORT}, but the setting arrives as RCONPORT", "${MISSING}"} { + if !strings.Contains(joined, want) { + t.Errorf("missing problem %q in:\n%s", want, joined) + } + } + for _, unwanted := range []string{"ASA_START_PARAMS", "SERVER_MAP", "MAX_PLAYERS}"} { + if strings.Contains(joined, unwanted) { + t.Errorf("false problem about %s in:\n%s", unwanted, joined) + } + } + if len(problems) != 3 { + t.Errorf("got %d problems, want 3:\n%s", len(problems), joined) + } + + // With the fixes, only the deliberately missing variable is left; 25000:1000 counts (same uid) + game["containerUser"] = "25000:1000" + game["defaultStartupCommand"] = `export ASA_START_PARAMS="${ASA_START_PARAMS:-x}?RCONPort=${RCONPORT:-27020}"` + if problems, _ := checkTemplate(game, f); len(problems) != 0 { + t.Errorf("fixed template still has problems: %v", problems) + } +} + +func TestContainerPort(t *testing.T) { + for in, want := range map[string]string{"8211:8211/udp": "8211/udp", "7777/udp": "7777/udp", "25565": "25565/tcp"} { + if got := containerPort(in); got != want { + t.Errorf("containerPort(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/cmd/gbrgames/main.go b/cmd/gbrgames/main.go index 3505b7c..829d15a 100644 --- a/cmd/gbrgames/main.go +++ b/cmd/gbrgames/main.go @@ -4,6 +4,7 @@ // go run ./cmd/gbrgames import export.json # write games// from GET /api/v1/admin/games // go run ./cmd/gbrgames export -out p.json # build the games_published payload CI sends to the panel // go run ./cmd/gbrgames boottest-payload ID # build the deploy message the node-agent's BootTest runs +// go run ./cmd/gbrgames inspect-image -game ID # read the image's facts and check the template against them package main import ( @@ -25,6 +26,8 @@ func main() { err = runExport(os.Args[2:]) case "boottest-payload": err = runBootTestPayload(os.Args[2:]) + case "inspect-image": + err = runInspectImage(os.Args[2:]) default: usage() } @@ -35,6 +38,6 @@ func main() { } func usage() { - fmt.Fprintln(os.Stderr, "usage: gbrgames validate [-games DIR] | import [-games DIR] [-all] EXPORT.json | export [-repo R] [-commit SHA] [-out FILE] | boottest-payload [-out FILE] ID") + fmt.Fprintln(os.Stderr, "usage: gbrgames validate [-games DIR] | import [-games DIR] [-all] EXPORT.json | export [-repo R] [-commit SHA] [-out FILE] | boottest-payload [-out FILE] ID | inspect-image [-game ID] [-json] [-no-pull] [IMAGE]") os.Exit(2) } diff --git a/scripts/boot-test.sh b/scripts/boot-test.sh index 0d50956..a77ed01 100755 --- a/scripts/boot-test.sh +++ b/scripts/boot-test.sh @@ -23,6 +23,13 @@ go build -o "$gbrgames" ./cmd/gbrgames || exit 1 passed=() failed=() skipped=() for id in "$@"; do echo "::group::boot test: $id" + # Seconds instead of a full boot: catches templates that can't work with their image (wrong + # container user, startup command reading a variable under the wrong name) + if ! "$gbrgames" inspect-image -game "$id"; then + failed+=("$id") + echo "::endgroup::" + continue + fi payload=$("$gbrgames" boottest-payload "$id") code=$? if [ $code -eq 3 ]; then