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>
276 lines
7.7 KiB
Go
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
|
|
}
|