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