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/)") 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 }