Files
gbr-games/cmd/gbrgames/validate.go
T
gregandClaude Opus 5.5 5dfc0cc794
Validate game templates / validate (push) Successful in 20s
Validate game templates / boot-test (push) Skipped
Validate game templates / publish (push) Skipped
fix(builder): drafts don't use CI secrets, and a missing one is fed back
The second Valheim build stopped before its boot test: told that
test.yaml's password "looks like a credential; map it under 'secrets'",
the model mapped it to a CI secret nobody created, and boottest-payload's
missing-secret exit ended the build.

- The validator now says to remove a test password first (boot tests fill
  password settings with a throwaway value); secrets only when the server
  can't start without a real one.
- Drafts with test.yaml 'secrets' fail the draft checks.
- build turns boottest-payload's missing-secret exit into feedback for the
  model instead of an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:52:18 -04:00

276 lines
7.7 KiB
Go

package main
import (
"bytes"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/santhosh-tekuri/jsonschema/v6"
"gopkg.in/yaml.v3"
)
var (
// Config keys whose default value must never be a real credential.
sensitiveKey = regexp.MustCompile(`(?i)(pass(word)?|secret|token|api[_-]?key|rcon)`)
// A value made only of panel placeholders, e.g. "{saved:RCON_PASSWORD}".
placeholderOnly = regexp.MustCompile(`^(\{[A-Za-z0-9_:.-]+\})+$`)
// RFC 1918 addresses: home-lab and VPC details have no place in a public repo.
privateIP = regexp.MustCompile(`\b(10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})\b`)
)
func runValidate(args []string) error {
fs := flag.NewFlagSet("validate", flag.ExitOnError)
gamesDir := fs.String("games", "games", "directory holding one folder per game")
schemaDir := fs.String("schemas", "schema", "directory holding game.schema.json and test.schema.json")
fs.Parse(args)
gameSchema, err := compileSchema(filepath.Join(*schemaDir, "game.schema.json"))
if err != nil {
return err
}
testSchema, err := compileSchema(filepath.Join(*schemaDir, "test.schema.json"))
if err != nil {
return err
}
entries, err := os.ReadDir(*gamesDir)
if err != nil {
return err
}
failed, checked := 0, 0
for _, e := range entries {
if !e.IsDir() {
continue
}
checked++
problems := validateGame(filepath.Join(*gamesDir, e.Name()), e.Name(), gameSchema, testSchema)
if len(problems) == 0 {
fmt.Printf("ok %s\n", e.Name())
continue
}
failed++
fmt.Printf("FAIL %s\n", e.Name())
for _, p := range problems {
fmt.Printf(" - %s\n", p)
}
}
if checked == 0 {
return fmt.Errorf("no games found in %s", *gamesDir)
}
if failed > 0 {
return fmt.Errorf("%d of %d games failed validation", failed, checked)
}
fmt.Printf("\nall %d games valid\n", checked)
return nil
}
func validateGame(dir, folder string, gameSchema, testSchema *jsonschema.Schema) []string {
var problems []string
add := func(format string, a ...any) { problems = append(problems, fmt.Sprintf(format, a...)) }
raw, err := os.ReadFile(filepath.Join(dir, "game.yaml"))
if err != nil {
return []string{"missing game.yaml"}
}
game, err := loadYAML(raw)
if err != nil {
return []string{"game.yaml: " + err.Error()}
}
if err := gameSchema.Validate(game); err != nil {
add("game.yaml does not match schema:\n%s", indent(schemaErrors(err)))
return problems // later checks assume the basic shape is right
}
g := game.(map[string]any)
if g["id"] != folder {
add("id %q must match folder name %q", g["id"], folder)
}
scanPrivateIPs(raw, "game.yaml", add)
// Go's regexp is a subset of Java's, which the node-agent uses; anything that compiles here works there
patterns, _ := g["failPatterns"].([]any)
if p, ok := g["readyPattern"].(string); ok {
patterns = append(patterns, p)
}
for _, p := range patterns {
if _, err := regexp.Compile(fmt.Sprint(p)); err != nil {
add("pattern %q does not compile: %v", p, err)
}
}
defaults, _ := g["defaultConfig"].(map[string]any)
props := schemaProperties(g)
known := map[string]bool{}
for k := range defaults {
known[k] = true
}
for k := range props {
known[k] = true
}
// publicIpConfigKey and maxPlayersConfigKey are injected by the panel, so only the map key must already exist.
if key, ok := g["mapConfigKey"].(string); ok && key != "" && !known[key] {
add("mapConfigKey %q is not in defaultConfig or configSchema", key)
}
for _, key := range sortedKeys(defaults) {
if sensitiveKey.MatchString(key) && isCredential(defaults[key]) {
add("defaultConfig.%s looks like a credential; leave it empty or use a placeholder like {saved:%s}", key, key)
}
}
for _, key := range sortedKeys(props) {
prop, _ := props[key].(map[string]any)
if sensitiveKey.MatchString(key) && isCredential(prop["default"]) {
add("configSchema.properties.%s.default looks like a credential; leave it empty", key)
}
}
for _, key := range sortedKeys(props) {
prop, _ := props[key].(map[string]any)
if c, ok := prop["x-consent"]; !ok || c == false {
continue
}
if truthy(prop["default"]) || truthy(defaults[key]) {
add("consent %q must not default to true: users have to accept it themselves", key)
}
}
testRaw, err := os.ReadFile(filepath.Join(dir, "test.yaml"))
if errors.Is(err, os.ErrNotExist) {
return problems
}
if err != nil {
add("test.yaml: %v", err)
return problems
}
test, err := loadYAML(testRaw)
if err != nil {
add("test.yaml: %v", err)
return problems
}
if err := testSchema.Validate(test); err != nil {
add("test.yaml does not match schema:\n%s", indent(schemaErrors(err)))
return problems
}
scanPrivateIPs(testRaw, "test.yaml", add)
t := test.(map[string]any)
cfg, _ := t["config"].(map[string]any)
secrets, _ := t["secrets"].(map[string]any)
for _, key := range sortedKeys(cfg) {
if !known[key] {
add("test.yaml config.%s is not a key of this game", key)
}
if sensitiveKey.MatchString(key) {
add("test.yaml config.%s looks like a credential; remove it (boot tests fill password settings with a throwaway "+
"value), or, only if the server can't start without a real one, map it under 'secrets' to a CI secret", key)
}
}
for _, key := range sortedKeys(secrets) {
if !known[key] {
add("test.yaml secrets.%s is not a key of this game", key)
}
}
if example, ok := t["readyExample"].(string); ok {
pattern, _ := g["readyPattern"].(string)
if re, err := regexp.Compile(pattern); pattern == "" || err != nil {
add("test.yaml has a readyExample but the game has no valid readyPattern")
} else if !re.MatchString(example) {
add("readyPattern %q does not match test.yaml readyExample %q", pattern, example)
}
}
return problems
}
func compileSchema(path string) (*jsonschema.Schema, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw))
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
id, _ := doc.(map[string]any)["$id"].(string)
if id == "" {
return nil, fmt.Errorf("%s: missing $id", path)
}
c := jsonschema.NewCompiler()
if err := c.AddResource(id, doc); err != nil {
return nil, err
}
return c.Compile(id)
}
// loadYAML decodes YAML into the JSON data model the schema validator expects.
func loadYAML(raw []byte) (any, error) {
var v any
if err := yaml.Unmarshal(raw, &v); err != nil {
return nil, err
}
if v == nil {
return nil, errors.New("file is empty")
}
j, err := json.Marshal(v)
if err != nil {
return nil, err
}
return jsonschema.UnmarshalJSON(bytes.NewReader(j))
}
func schemaProperties(g map[string]any) map[string]any {
s, _ := g["configSchema"].(map[string]any)
p, _ := s["properties"].(map[string]any)
return p
}
func scanPrivateIPs(raw []byte, file string, add func(string, ...any)) {
for i, line := range strings.Split(string(raw), "\n") {
if ip := privateIP.FindString(line); ip != "" {
add("%s line %d contains private address %s", file, i+1, ip)
}
}
}
func schemaErrors(err error) string {
var ve *jsonschema.ValidationError
if errors.As(err, &ve) {
return fmt.Sprintf("%v", ve)
}
return err.Error()
}
func indent(s string) string {
return " " + strings.ReplaceAll(strings.TrimSpace(s), "\n", "\n ")
}
func isCredential(v any) bool {
s, ok := v.(string)
return ok && s != "" && !placeholderOnly.MatchString(s)
}
func truthy(v any) bool {
switch x := v.(type) {
case bool:
return x
case string:
return strings.EqualFold(strings.TrimSpace(x), "true")
}
return false
}
func sortedKeys(m map[string]any) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}