feat: inspect-image reads what a game's image needs #2
No files matched your search
@@ -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 <id>` 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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
// go run ./cmd/gbrgames import export.json # write games/<id>/ 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user