Files
gbr-games/cmd/gbrgames/firstboot.go
T
gregandClaude Opus 5.5 c75190d8c4
Validate game templates / validate (push) Successful in 19s
Validate game templates / boot-test (push) Skipped
Validate game templates / publish (push) Skipped
feat(builder): research, build and a worker for the AI builder
- research: searches SearXNG, reads the best pages through the reader, has the
  model list the server's variants, then checks every Docker image and Steam
  app it named for real before an admin sees it.
- build: first boot of the variant's images (skipping ones that need root),
  a draft by the model, then the draft deployed with the node-agent's
  BootTest like CI does; crashes, error lines and saves outside /server go
  back to the model, three boots at most.
- worker: polls the panel's builder queue (outbound only), runs research or
  build in a fresh clone, streams the output as the job log, and opens the
  result as a pull request from the bot account.
- Dockerfile.builder, a workflow publishing gbr-builder:latest, and
  BUILDER.md. Everything about where it runs comes from environment
  variables; deployment files live outside this public repo.

draft now keeps its conversation in a drafter, so build can send boot
failures back to the same model session. The boot-test runner comment no
longer names its VM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:51:29 -04:00

801 lines
27 KiB
Go

package main
import (
"archive/tar"
"bufio"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// first-boot starts a game server once with none of a template's settings applied, and records what it
// does on its own: its output (to find the "ready" and error lines) and the files it creates (to learn
// the real config file, format and defaults). It mirrors how the node-agent runs servers: same volumes,
// user, SteamCMD install and startup prefix, but without a restart policy, so a crash shows as a crash.
// Same prefix the node-agent puts in front of every startup command (DockerService)
const agentStartupPrefix = "mkdir -p ~/.steam/sdk64 /server/.steam/sdk64 2>/dev/null; " +
"[ -f /home/steam/steamcmd/linux64/steamclient.so ] && cp -f /home/steam/steamcmd/linux64/steamclient.so ~/.steam/sdk64/ 2>/dev/null; " +
"[ -f /server/linux64/steamclient.so ] && cp -f /server/linux64/steamclient.so ~/.steam/sdk64/ 2>/dev/null; cd /server && "
var (
ansi = regexp.MustCompile(`\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\a]*\a`)
readyWords = regexp.MustCompile(`(?i)\b(ready|started|listening|done|advertis\w*|online|accepting connections|initiali[sz]ed|server is running|now running|startup complete|full startup)\b`)
// Words that mean the whole server is up, not just one part of it ("RCON Listener started")
strongReady = regexp.MustCompile(`(?i)\b(ready|done|advertis\w*|accepting connections|server is running|startup complete|full startup|server started)\b`)
errorWords = regexp.MustCompile(`(?i)\b(error|exception|fatal|failed|permission denied|segmentation fault|could not|unable to|cannot)\b`)
// Leading timestamps and [bracketed] prefixes that change on every run
linePrefix = regexp.MustCompile(`^\s*(?:(?:\[[^\]]*\]|\d{4}-\d\d-\d\d[T ][0-9:.,]+Z?|\d\d:\d\d:\d\d(?:[.,]\d+)?)[\s:|-]*)+`)
numberRun = regexp.MustCompile(`[0-9]+(?:\\\.[0-9]+)*`)
)
// Files worth copying out: what a server writes as its settings, plus its own log files
var configExt = map[string]bool{".ini": true, ".cfg": true, ".conf": true, ".json": true, ".properties": true,
".yaml": true, ".yml": true, ".toml": true, ".xml": true, ".txt": true, ".log": true}
// Game data, engine files and caches: never settings
var skipDirs = []string{"steamapps/", "Binaries/", "Content/", "Engine/", "linux64/", "linux32/", ".steam/", "Steam/",
"compatdata/", "libraries/", "cache/", "steamcmd/", "versions/", "plugins/", "mods/", "jre/", "java/"}
type bootSpec struct {
Name, Image, User, Startup, SteamAppID string
MemoryMb int
Env map[string]string
}
type logLine struct {
At float64 `json:"at"` // seconds after the container started
Source string `json:"source"` // "console" or a file path
Text string `json:"text"`
}
type createdFile struct {
Path string `json:"path"`
Size int64 `json:"size"`
}
type setting struct {
File string `json:"file"`
Section string `json:"section,omitempty"`
Key string `json:"key"`
Value string `json:"value"`
}
type bootReport struct {
Image string `json:"image"`
User string `json:"user"`
Outcome string `json:"outcome"`
ExitCode *int `json:"exitCode,omitempty"`
RanSeconds float64 `json:"ranSeconds"`
LastOutputAt float64 `json:"lastOutputAt"`
ConsoleLines int `json:"consoleLines"`
ReadyCandidates []logLine `json:"readyCandidates"`
ErrorLines []logLine `json:"errorLines"`
SuggestedReady string `json:"suggestedReadyPattern,omitempty"`
SuggestedExample string `json:"suggestedReadyExample,omitempty"`
FilesCreated []createdFile `json:"filesCreated"`
ConfigFiles []string `json:"configFiles"`
Settings []setting `json:"settings"`
ContainerChanges []string `json:"containerChanges"`
}
func runFirstBoot(args []string) error {
fs := flag.NewFlagSet("first-boot", flag.ExitOnError)
gamesDir := fs.String("games", "games", "directory holding one folder per game")
gameID := fs.String("game", "", "take image, install, startup and user from this game's template (not its settings)")
image := fs.String("image", "", "image to boot when there is no template yet")
user := fs.String("user", "", "uid:gid to run as (default: template containerUser, else what the image needs, else 1000:1000)")
startup := fs.String("startup", "", "startup command (default: the image's own entrypoint)")
steamApp := fs.String("steam-app", "", "install this SteamCMD app into /server first")
memory := fs.Int("memory", 0, "memory limit in MB (default: template requiredMemoryMb, else 4096)")
maxRun := fs.Duration("max", 20*time.Minute, "stop after this long even if the server keeps logging")
quiet := fs.Duration("quiet", 2*time.Minute, "stop once the server has printed nothing for this long")
outDir := fs.String("out", "", "where to write the report (default firstboot-out/<name>)")
keep := fs.Bool("keep", false, "keep the container and volumes for a closer look")
var envFlags multiFlag
fs.Var(&envFlags, "env", "KEY=VALUE for the container (repeatable), e.g. EULA=TRUE")
fs.Parse(args)
spec, err := buildBootSpec(*gamesDir, *gameID, *image, *startup, *steamApp, *memory, envFlags)
if err != nil {
return err
}
if out, err := exec.Command("docker", "network", "inspect", bootTestNetwork).CombinedOutput(); err != nil {
return fmt.Errorf("docker network %s is missing; first-boot only runs isolated (see RUNNER.md): %s", bootTestNetwork, strings.TrimSpace(string(out)))
}
if out, err := exec.Command("docker", "pull", "-q", spec.Image).CombinedOutput(); err != nil {
return fmt.Errorf("docker pull %s: %v: %s", spec.Image, err, strings.TrimSpace(string(out)))
}
facts, err := inspectImage(spec.Image, nil)
if err != nil {
return err
}
switch {
case *user != "":
spec.User = *user
case spec.User == "" && facts.SuggestedContainerUser != "":
spec.User = facts.SuggestedContainerUser
fmt.Printf("[first-boot] running as %s: %s\n", spec.User, facts.SuggestionReason)
case spec.User == "":
spec.User = nodeUser
}
if *outDir == "" {
*outDir = filepath.Join("firstboot-out", spec.Name)
}
if err := os.MkdirAll(filepath.Join(*outDir, "files"), 0o755); err != nil {
return err
}
b := &booter{spec: spec, facts: facts, container: "firstboot-" + spec.Name, outDir: *outDir}
if !*keep {
defer b.cleanup()
}
report, err := b.run(*maxRun, *quiet)
if err != nil {
return err
}
if err := writeBootReport(*outDir, report, b.console()); err != nil {
return err
}
printBootReport(report, *outDir)
return nil
}
func buildBootSpec(gamesDir, gameID, image, startup, steamApp string, memory int, envFlags []string) (*bootSpec, error) {
spec := &bootSpec{Image: image, Startup: startup, SteamAppID: steamApp, MemoryMb: memory, Env: map[string]string{}}
if gameID != "" {
g, err := loadMap(filepath.Join(gamesDir, gameID, "game.yaml"))
if err != nil {
return nil, err
}
spec.Name = gameID
if spec.Image == "" {
spec.Image, _ = g["defaultDockerImage"].(string)
}
if spec.Startup == "" {
spec.Startup, _ = g["defaultStartupCommand"].(string)
}
spec.User, _ = g["containerUser"].(string)
switch g["installMethod"] {
case "STEAMCMD":
if ic, ok := g["installConfig"].(map[string]any); ok && spec.SteamAppID == "" {
spec.SteamAppID = fmt.Sprint(ic["appId"])
}
case "BASH_SCRIPT":
return nil, errors.New("first-boot does not run BASH_SCRIPT installs yet")
}
if spec.MemoryMb == 0 {
if n, ok := g["requiredMemoryMb"].(json.Number); ok {
m, _ := n.Int64()
spec.MemoryMb = int(m)
}
}
// Only what the server can't start without (test.yaml): EULAs, required IDs. Not the template's
// settings, so the server writes its own defaults.
if test, err := loadMap(filepath.Join(gamesDir, gameID, "test.yaml")); err == nil {
if cfg, ok := test["config"].(map[string]any); ok {
for k, v := range cfg {
spec.Env[envName(k)] = resolvePlaceholders(fmt.Sprint(v))
}
}
if secrets, ok := test["secrets"].(map[string]any); ok {
for k, name := range secrets {
val, ok := os.LookupEnv(fmt.Sprint(name))
if !ok || val == "" {
return nil, fmt.Errorf("%s needs secret %s for %s; set it in the environment", gameID, name, k)
}
spec.Env[envName(k)] = val
}
}
}
}
if spec.Image == "" {
return nil, errors.New("first-boot needs -game ID or -image IMAGE")
}
if spec.Name == "" {
spec.Name = strings.Trim(regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(strings.ToLower(spec.Image), "-"), "-")
}
if spec.MemoryMb == 0 {
spec.MemoryMb = 4096
}
for _, kv := range envFlags {
k, v, ok := strings.Cut(kv, "=")
if !ok {
return nil, fmt.Errorf("-env %q: want KEY=VALUE", kv)
}
spec.Env[k] = v
}
return spec, nil
}
type booter struct {
spec *bootSpec
facts *imageFacts
container string
outDir string
mounts map[string]string // mount path -> volume name
mu sync.Mutex
lines []logLine
lastAt time.Time
started time.Time
}
func (b *booter) run(maxRun, quiet time.Duration) (*bootReport, error) {
exec.Command("docker", "rm", "-f", b.container).Run() // leftovers from an interrupted run
// Same layout as the node: one volume at /server and /data, plus a fresh one per declared VOLUME
data := b.container + "-data"
b.mounts = map[string]string{"/server": data, "/data": data}
for i, v := range b.facts.Volumes {
if _, ok := b.mounts[v]; !ok {
b.mounts[v] = fmt.Sprintf("%s-v%d", b.container, i)
}
}
for _, vol := range uniqueValues(b.mounts) {
if err := docker("volume", "create", vol); err != nil {
return nil, err
}
if err := docker("run", "--rm", "-v", vol+":/v", "alpine", "chown", "-R", b.spec.User, "/v"); err != nil {
return nil, err
}
}
if b.spec.SteamAppID != "" {
fmt.Printf("[first-boot] installing Steam app %s\n", b.spec.SteamAppID)
// Like the node-agent: steamcmd's image runs as uid 1000, then the volume goes to the game's user
steam := "/home/steam/steamcmd/steamcmd.sh +login anonymous +quit && /home/steam/steamcmd/steamcmd.sh +force_install_dir /server +login anonymous +app_update " +
b.spec.SteamAppID + " validate +quit"
if err := docker("run", "--rm", "-v", data+":/server", "alpine", "chown", "-R", nodeUser, "/server"); err != nil {
return nil, err
}
cmd := exec.Command("docker", "run", "--rm", "--network", bootTestNetwork, "-v", data+":/server", "cm2network/steamcmd", "bash", "-c", steam)
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("steamcmd install of %s failed: %w", b.spec.SteamAppID, err)
}
if err := docker("run", "--rm", "-v", data+":/server", "alpine", "chown", "-R", b.spec.User, "/server"); err != nil {
return nil, err
}
}
runArgs := []string{"run", "-d", "-t", "--init", "--name", b.container, "--user", b.spec.User,
"--security-opt", "no-new-privileges:true", "--pids-limit", "1024", "--network", bootTestNetwork,
"-m", fmt.Sprintf("%dm", b.spec.MemoryMb), "--memory-swap", fmt.Sprintf("%dm", b.spec.MemoryMb)}
for mount, vol := range b.mounts {
runArgs = append(runArgs, "-v", vol+":"+mount)
}
for _, k := range sortedKeysString(b.spec.Env) {
runArgs = append(runArgs, "-e", k+"="+b.spec.Env[k])
}
runArgs = append(runArgs, b.spec.Image)
if b.spec.Startup != "" {
runArgs = append(runArgs, "bash", "-c", agentStartupPrefix+b.spec.Startup)
}
fmt.Printf("[first-boot] starting %s as %s (memory %d MB)\n", b.spec.Image, b.spec.User, b.spec.MemoryMb)
if out, err := exec.Command("docker", runArgs...).CombinedOutput(); err != nil {
return nil, fmt.Errorf("docker run: %v: %s", err, strings.TrimSpace(string(out)))
}
b.started, b.lastAt = time.Now(), time.Now()
logs := exec.Command("docker", "logs", "-f", b.container)
pr, pw := io.Pipe()
logs.Stdout, logs.Stderr = pw, pw
if err := logs.Start(); err != nil {
return nil, err
}
done := make(chan struct{})
go func() {
sc := bufio.NewScanner(pr)
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
for sc.Scan() {
b.addLine("console", sc.Text())
}
close(done)
}()
report := &bootReport{Image: b.spec.Image, User: b.spec.User}
minRun := time.Minute
for {
time.Sleep(2 * time.Second)
elapsed := time.Since(b.started)
running := inspectField(b.container, "{{.State.Running}}") == "true"
b.mu.Lock()
silent, count := time.Since(b.lastAt), len(b.lines)
b.mu.Unlock()
switch {
case !running:
code, _ := strconv.Atoi(inspectField(b.container, "{{.State.ExitCode}}"))
report.ExitCode = &code
report.Outcome = fmt.Sprintf("exited by itself with code %d after %.0fs", code, elapsed.Seconds())
if inspectField(b.container, "{{.State.OOMKilled}}") == "true" {
report.Outcome += fmt.Sprintf(" (killed: over the %d MB memory limit)", b.spec.MemoryMb)
}
case elapsed >= minRun && count > 0 && silent >= quiet:
report.Outcome = fmt.Sprintf("running and quiet for %.0fs after %.0fs; stopped gracefully", silent.Seconds(), elapsed.Seconds())
case elapsed >= maxRun:
report.Outcome = fmt.Sprintf("still running and logging after %.0fs (the -max limit); stopped gracefully", elapsed.Seconds())
default:
continue
}
break
}
report.RanSeconds = time.Since(b.started).Seconds()
// Many servers only write their settings when they shut down
exec.Command("docker", "stop", "-t", "60", b.container).Run()
pw.Close()
<-done
b.mu.Lock()
report.ConsoleLines = len(b.lines)
if n := len(b.lines); n > 0 {
report.LastOutputAt = b.lines[n-1].At
}
console := append([]logLine{}, b.lines...)
b.mu.Unlock()
if err := b.collectFiles(report); err != nil {
return nil, err
}
report.ContainerChanges = b.containerChanges()
analyzeLines(report, console, b.fileLines(report), report.RanSeconds)
return report, nil
}
func (b *booter) addLine(source, raw string) {
text := strings.TrimRight(ansi.ReplaceAllString(raw, ""), "\r \t")
if text == "" {
return
}
b.mu.Lock()
b.lines = append(b.lines, logLine{At: time.Since(b.started).Round(100 * time.Millisecond).Seconds(), Source: source, Text: text})
b.lastAt = time.Now()
b.mu.Unlock()
}
func (b *booter) console() []logLine {
b.mu.Lock()
defer b.mu.Unlock()
return append([]logLine{}, b.lines...)
}
// collectFiles lists everything the server left in its volumes and copies out the config and log files.
func (b *booter) collectFiles(report *bootReport) error {
for _, vol := range uniqueValues(b.mounts) {
mount := mountFor(b.mounts, vol)
out, err := exec.Command("docker", "run", "--rm", "-v", vol+":/v:ro", "alpine", "find", "/v", "-type", "f", "-exec", "stat", "-c", "%s|%n", "{}", "+").Output()
if err != nil {
return fmt.Errorf("listing %s: %w", mount, err)
}
var wanted []string
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
sizeStr, p, ok := strings.Cut(line, "|")
if !ok {
continue
}
size, _ := strconv.ParseInt(sizeStr, 10, 64)
rel := strings.TrimPrefix(p, "/v/")
report.FilesCreated = append(report.FilesCreated, createdFile{Path: path.Join(mount, rel), Size: size})
if isConfigCandidate(rel, size) {
wanted = append(wanted, rel)
}
}
if len(wanted) > 150 {
wanted = wanted[:150]
}
if len(wanted) == 0 {
continue
}
if err := b.copyOut(vol, mount, wanted, report); err != nil {
return err
}
}
sort.Slice(report.FilesCreated, func(i, j int) bool { return report.FilesCreated[i].Path < report.FilesCreated[j].Path })
return nil
}
func isConfigCandidate(rel string, size int64) bool {
ext := strings.ToLower(path.Ext(rel))
if !configExt[ext] {
return false
}
for _, d := range skipDirs {
if strings.HasPrefix(rel, d) || strings.Contains(rel, "/"+d) {
return false
}
}
switch ext {
case ".log":
return size <= 5<<20
case ".txt":
return size <= 16<<10
}
return size <= 256<<10
}
func (b *booter) copyOut(vol, mount string, files []string, report *bootReport) error {
args := append([]string{"run", "--rm", "-v", vol + ":/v:ro", "alpine", "tar", "cf", "-", "-C", "/v"}, files...)
cmd := exec.Command("docker", args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
tr := tar.NewReader(stdout)
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
if h.Typeflag != tar.TypeReg {
continue
}
content, err := io.ReadAll(tr)
if err != nil {
return err
}
full := path.Join(mount, h.Name)
dest := filepath.Join(b.outDir, "files", filepath.FromSlash(strings.TrimPrefix(full, "/")))
os.MkdirAll(filepath.Dir(dest), 0o755)
if err := os.WriteFile(dest, content, 0o644); err != nil {
return err
}
if strings.HasSuffix(full, ".log") {
continue
}
report.ConfigFiles = append(report.ConfigFiles, full)
report.Settings = append(report.Settings, parseConfig(full, string(content))...)
}
return cmd.Wait()
}
// fileLines reads the copied log files, so ready and error lines that never reach the console (ARK
// writes to ShooterGame.log) are found too.
func (b *booter) fileLines(report *bootReport) []logLine {
var out []logLine
for _, f := range report.FilesCreated {
if !strings.HasSuffix(f.Path, ".log") {
continue
}
raw, err := os.ReadFile(filepath.Join(b.outDir, "files", filepath.FromSlash(strings.TrimPrefix(f.Path, "/"))))
if err != nil {
continue
}
for _, l := range strings.Split(string(raw), "\n") {
if t := strings.TrimSpace(ansi.ReplaceAllString(l, "")); t != "" {
out = append(out, logLine{At: -1, Source: f.Path, Text: t})
}
}
}
return out
}
// containerChanges lists files the server wrote outside its volumes: settings there are lost when the
// node recreates the container, which a template needs to know about.
func (b *booter) containerChanges() []string {
out, err := exec.Command("docker", "diff", b.container).Output()
if err != nil {
return nil
}
var changes []string
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
kind, p, ok := strings.Cut(line, " ")
if !ok || kind == "D" {
continue
}
skip := false
for _, prefix := range []string{"/tmp", "/proc", "/sys", "/dev", "/run", "/var/log", "/var/cache", "/var/tmp"} {
if p == prefix || strings.HasPrefix(p, prefix+"/") {
skip = true
}
}
for mount := range b.mounts {
if p == mount || strings.HasPrefix(p, mount+"/") {
skip = true
}
}
if !skip && configExt[strings.ToLower(path.Ext(p))] {
changes = append(changes, kind+" "+p)
}
}
return changes
}
func (b *booter) cleanup() {
exec.Command("docker", "rm", "-f", b.container).Run()
for _, vol := range uniqueValues(b.mounts) {
exec.Command("docker", "volume", "rm", vol).Run()
}
}
// analyzeLines picks out ready-looking and error lines and turns the last ready line into a readyPattern
// suggestion (plus the line itself as readyExample, which validation checks the pattern against).
// Console lines printed after stopAt are the server shutting down ("Done", "RCON client started" from
// the stop command), so they never count as ready.
func analyzeLines(report *bootReport, console, files []logLine, stopAt float64) {
var running []logLine
for _, l := range console {
if l.At < stopAt {
running = append(running, l)
}
}
for _, l := range append(append([]logLine{}, console...), files...) {
switch {
case errorWords.MatchString(l.Text):
report.ErrorLines = append(report.ErrorLines, l)
case readyWords.MatchString(l.Text):
report.ReadyCandidates = append(report.ReadyCandidates, l)
}
}
report.ErrorLines = lastN(report.ErrorLines, 25)
report.ReadyCandidates = lastN(report.ReadyCandidates, 15)
// The last ready-looking console line before the stop; log files (no timestamps of ours, and they
// contain the shutdown too) only when the console has none
var pick *logLine
for _, words := range []*regexp.Regexp{strongReady, readyWords} {
for i := range running {
if !errorWords.MatchString(running[i].Text) && words.MatchString(running[i].Text) {
pick = &running[i]
}
}
if pick != nil {
break
}
for i := range files {
if !errorWords.MatchString(files[i].Text) && words.MatchString(files[i].Text) {
pick = &files[i]
break // the first one in a file is the startup, later ones may be the shutdown
}
}
if pick != nil {
break
}
}
if pick != nil {
if pattern := suggestPattern(pick.Text); pattern != "" {
report.SuggestedReady, report.SuggestedExample = pattern, pick.Text
}
}
}
// suggestPattern turns a concrete log line into a regex that matches it on every run: timestamps and
// [prefixes] dropped, numbers generalized.
func suggestPattern(line string) string {
core := strings.TrimSpace(linePrefix.ReplaceAllString(line, ""))
if len(core) < 4 {
return ""
}
if len(core) > 80 {
core = core[:80]
if i := strings.LastIndex(core, " "); i > 20 {
core = core[:i]
}
}
pattern := numberRun.ReplaceAllString(regexp.QuoteMeta(core), `[0-9.]+`)
if re, err := regexp.Compile(pattern); err != nil || !re.MatchString(line) {
return ""
}
return pattern
}
// parseConfig reads settings out of a config file: JSON objects, INI/properties-style key=value lines
// (including Unreal's OptionSettings=(A=1,B="x") lists) and top-level YAML keys.
func parseConfig(file, content string) []setting {
var out []setting
switch strings.ToLower(path.Ext(file)) {
case ".json":
var obj map[string]any
if json.Unmarshal([]byte(content), &obj) != nil {
return nil
}
for _, k := range sortedKeysAny(obj) {
if sub, ok := obj[k].(map[string]any); ok {
for _, sk := range sortedKeysAny(sub) {
out = append(out, setting{File: file, Section: k, Key: sk, Value: jsonValue(sub[sk])})
}
continue
}
out = append(out, setting{File: file, Key: k, Value: jsonValue(obj[k])})
}
return out
case ".yaml", ".yml":
for _, line := range strings.Split(content, "\n") {
if line == "" || line[0] == ' ' || line[0] == '#' || line[0] == '-' {
continue
}
if k, v, ok := strings.Cut(line, ":"); ok {
out = append(out, setting{File: file, Key: strings.TrimSpace(k), Value: strings.Trim(strings.TrimSpace(v), `"'`)})
}
}
return out
case ".xml":
return nil // listed and copied; structure varies too much to flatten
}
section := ""
for _, raw := range strings.Split(content, "\n") {
line := strings.TrimSpace(strings.TrimPrefix(raw, "\ufeff"))
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "//") {
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
section = strings.Trim(line, "[]")
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
if strings.HasPrefix(v, "(") && strings.HasSuffix(v, ")") {
for _, item := range splitTopLevel(v[1 : len(v)-1]) {
if ik, iv, ok := strings.Cut(item, "="); ok {
out = append(out, setting{File: file, Section: k, Key: strings.TrimSpace(ik), Value: strings.Trim(strings.TrimSpace(iv), `"`)})
}
}
continue
}
out = append(out, setting{File: file, Section: section, Key: k, Value: strings.Trim(v, `"`)})
}
return out
}
// splitTopLevel splits on commas that are not inside quotes or parentheses.
func splitTopLevel(s string) []string {
var parts []string
depth, quoted, start := 0, false, 0
for i, c := range s {
switch {
case c == '"':
quoted = !quoted
case quoted:
case c == '(':
depth++
case c == ')':
depth--
case c == ',' && depth == 0:
parts = append(parts, s[start:i])
start = i + 1
}
}
return append(parts, s[start:])
}
func writeBootReport(dir string, report *bootReport, console []logLine) error {
var log strings.Builder
for _, l := range console {
fmt.Fprintf(&log, "[%7.1fs] %s\n", l.At, l.Text)
}
if err := os.WriteFile(filepath.Join(dir, "console.log"), []byte(log.String()), 0o644); err != nil {
return err
}
b, _ := json.MarshalIndent(report, "", " ")
return os.WriteFile(filepath.Join(dir, "report.json"), b, 0o644)
}
func printBootReport(r *bootReport, dir string) {
fmt.Printf("\n[first-boot] %s as %s: %s\n", r.Image, r.User, r.Outcome)
fmt.Printf(" console: %d lines, last one at %.0fs\n", r.ConsoleLines, r.LastOutputAt)
if r.SuggestedReady != "" {
fmt.Printf(" readyPattern suggestion: %s\n readyExample: %s\n", r.SuggestedReady, r.SuggestedExample)
}
for _, l := range lastN(r.ReadyCandidates, 5) {
fmt.Printf(" ready? %s\n", describeLine(l))
}
for _, l := range lastN(r.ErrorLines, 5) {
fmt.Printf(" error? %s\n", describeLine(l))
}
fmt.Printf(" files created: %d; config files read: %d; settings found: %d\n", len(r.FilesCreated), len(r.ConfigFiles), len(r.Settings))
for _, f := range r.ConfigFiles {
n := 0
for _, s := range r.Settings {
if s.File == f {
n++
}
}
fmt.Printf(" %s (%d settings)\n", f, n)
}
if len(r.ContainerChanges) > 0 {
fmt.Printf(" written outside the volumes (lost when the node recreates the container): %s\n", strings.Join(r.ContainerChanges, ", "))
}
fmt.Printf(" full report: %s\n", filepath.Join(dir, "report.json"))
}
func describeLine(l logLine) string {
where := l.Source
if l.At >= 0 {
where = fmt.Sprintf("%.0fs", l.At)
}
text := l.Text
if len(text) > 140 {
text = text[:140] + "..."
}
return fmt.Sprintf("[%s] %s", where, text)
}
func docker(args ...string) error {
if out, err := exec.Command("docker", args...).CombinedOutput(); err != nil {
return fmt.Errorf("docker %s: %v: %s", args[0], err, strings.TrimSpace(string(out)))
}
return nil
}
func inspectField(container, format string) string {
out, _ := exec.Command("docker", "inspect", "-f", format, container).Output()
return strings.TrimSpace(string(out))
}
func uniqueValues(m map[string]string) []string {
seen := map[string]bool{}
var out []string
for _, v := range m {
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
sort.Strings(out)
return out
}
// mountFor names a volume by its first mount path (the data volume is both /server and /data).
func mountFor(mounts map[string]string, vol string) string {
var paths []string
for p, v := range mounts {
if v == vol {
paths = append(paths, p)
}
}
sort.Strings(paths)
if len(paths) > 1 && paths[0] == "/data" {
return "/server"
}
return paths[0]
}
func lastN[T any](s []T, n int) []T {
if len(s) > n {
return s[len(s)-n:]
}
return s
}
func jsonValue(v any) string {
if s, ok := v.(string); ok {
return s
}
b, _ := json.Marshal(v)
return string(b)
}
func sortedKeysAny(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
type multiFlag []string
func (m *multiFlag) String() string { return strings.Join(*m, ",") }
func (m *multiFlag) Set(v string) error { *m = append(*m, v); return nil }