Files
DoGaMa-serv/internal/catalog/template.go
T
codex 35c11aff6d
CI / validate (pull_request) Successful in 26m22s
feat(vrising): support template runtime requirements
2026-08-25 22:32:15 +02:00

649 lines
21 KiB
Go

// Package catalog validates and loads immutable game-template snapshots.
package catalog
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"sort"
"strings"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/specs"
"github.com/dlclark/regexp2"
"github.com/santhosh-tekuri/jsonschema/v6"
"gopkg.in/yaml.v3"
)
const templateSchemaURL = "https://dogama.dev/schemas/template-v1.json"
const moduleManifestSchemaURL = "https://dogama.dev/schemas/module-manifest-v1.json"
const maxModuleBundleBytes = 16 << 20
// ValidationIssue points to one invalid field without exposing input secrets.
type ValidationIssue struct {
Path string `json:"path"`
Line int `json:"line,omitempty"`
Message string `json:"message"`
}
// ValidationErrors groups field-specific template errors.
type ValidationErrors struct{ Issues []ValidationIssue }
func (e *ValidationErrors) Error() string {
return fmt.Sprintf("template validation failed with %d issue(s)", len(e.Issues))
}
// Template is the validated subset needed to build a deployment preview.
type Template struct {
SchemaVersion int `json:"schema_version"`
ID string `json:"id"`
Version string `json:"version"`
Source struct {
Type string `json:"type"`
} `json:"source"`
Game struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Artwork struct {
Logo string `json:"logo"`
Image string `json:"image"`
Poster string `json:"poster"`
Attribution string `json:"attribution"`
} `json:"artwork"`
} `json:"game"`
Requirements struct {
Minimum Resources `json:"minimum"`
Recommended Resources `json:"recommended"`
} `json:"requirements"`
Container struct {
Image string `json:"image"`
Tag string `json:"tag"`
Entrypoint []string `json:"entrypoint,omitempty"`
UserMode string `json:"user_mode,omitempty"`
Arguments []string `json:"arguments,omitempty"`
Environment map[string]string `json:"environment,omitempty"`
Capabilities struct {
Add []string `json:"add,omitempty"`
} `json:"capabilities,omitempty"`
StopTimeoutSeconds int `json:"stop_timeout_seconds"`
Ports []Port `json:"ports"`
Assets []struct {
Source string `json:"source"`
Destination string `json:"destination"`
ReadOnly bool `json:"read_only"`
} `json:"assets"`
} `json:"container"`
Storage struct {
Mounts []Mount `json:"mounts"`
} `json:"storage"`
Configuration struct {
Fields []ConfigField `json:"fields"`
} `json:"configuration"`
Capabilities []string `json:"capabilities"`
Integration *struct {
ModuleID string `json:"module_id"`
PortID string `json:"port_id"`
} `json:"integration,omitempty"`
Module *struct {
Path string `json:"path"`
} `json:"module,omitempty"`
Backup struct {
Strategy string `json:"strategy"`
SourceMounts []string `json:"source_mounts"`
} `json:"backup"`
Healthcheck struct {
Type string `json:"type"`
PortID string `json:"port_id,omitempty"`
StartupTimeoutSeconds int `json:"startup_timeout_seconds"`
IntervalSeconds int `json:"interval_seconds"`
} `json:"healthcheck"`
Imports struct {
Supported bool `json:"supported"`
AcceptedFormats []string `json:"accepted_formats"`
MaxExtractedSizeGB int `json:"max_extracted_size_gb"`
RequiredPaths []string `json:"required_paths"`
DestinationMount string `json:"destination_mount"`
DestinationRelativePath string `json:"destination_relative_path"`
RequiresStoppedServer bool `json:"requires_stopped_server"`
} `json:"imports"`
Mods struct {
Supported bool `json:"supported"`
Provider string `json:"provider,omitempty"`
DestinationMount string `json:"destination_mount,omitempty"`
RestartRequired bool `json:"restart_required"`
} `json:"mods"`
Updates struct {
BackupBeforeUpdate bool `json:"backup_before_update"`
AutomaticDefault bool `json:"automatic_default"`
RollbackOnFailure bool `json:"rollback_on_failure"`
HealthTimeoutSeconds int `json:"health_timeout_seconds"`
CandidateTag string `json:"candidate_tag,omitempty"`
CandidateDigest string `json:"candidate_digest,omitempty"`
} `json:"updates"`
}
type Resources struct {
CPUCores float64 `json:"cpu_cores"`
MemoryMB int `json:"memory_mb"`
StorageGB int `json:"storage_gb"`
Other []string `json:"other,omitempty"`
}
type Port struct {
ID string `json:"id"`
ContainerPort int `json:"container_port"`
Protocol string `json:"protocol"`
Purpose string `json:"purpose"`
Publish bool `json:"publish"`
}
type Mount struct {
ID string `json:"id"`
ContainerPath string `json:"container_path"`
Category string `json:"category"`
Backup bool `json:"backup"`
ReadOnly bool `json:"read_only"`
}
type ConfigField struct {
ID string `json:"id"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
Type string `json:"type"`
Visibility string `json:"visibility"`
Required bool `json:"required"`
Default any `json:"default,omitempty"`
Minimum *float64 `json:"minimum,omitempty"`
Maximum *float64 `json:"maximum,omitempty"`
Pattern string `json:"pattern,omitempty"`
Values []any `json:"values,omitempty"`
Target ConfigTarget `json:"target"`
}
// ConfigTarget is deliberately data-only. INI targets are relative to an
// explicitly declared writable mount, never to a host path.
type ConfigTarget struct {
Kind string `json:"kind"`
Name string `json:"name"`
Mount string `json:"mount,omitempty"`
File string `json:"file,omitempty"`
Section string `json:"section,omitempty"`
Key string `json:"key,omitempty"`
}
// Snapshot is an immutable validated template version.
type Snapshot struct {
Template Template
CanonicalYAML string
Digest string
Origin string
AssetRoot string
ModuleFiles map[string][]byte `json:"-"`
}
// LoadFS validates every template.yaml below root and returns stable snapshots.
func LoadFS(source fs.FS, root string) ([]Snapshot, error) {
pattern := path.Join(root, "*", "template.yaml")
names, err := fs.Glob(source, pattern)
if err != nil {
return nil, fmt.Errorf("list catalog templates: %w", err)
}
sort.Strings(names)
if len(names) == 0 {
return nil, errors.New("catalog contains no templates")
}
seen := make(map[string]struct{}, len(names))
result := make([]Snapshot, 0, len(names))
for _, name := range names {
body, err := fs.ReadFile(source, name)
if err != nil {
return nil, fmt.Errorf("read template %s: %w", name, err)
}
snapshot, err := Validate(body, path.Dir(name), source)
if err != nil {
return nil, fmt.Errorf("validate %s: %w", name, err)
}
key := snapshot.Template.ID + "@" + snapshot.Template.Version
if _, exists := seen[key]; exists {
return nil, fmt.Errorf("duplicate template version %s", key)
}
seen[key] = struct{}{}
result = append(result, snapshot)
}
return result, nil
}
// ScanResult reports a best-effort scan of a local template directory.
type ScanResult struct {
Found int
Valid []Snapshot
Errors []ScanError
}
type ScanError struct {
Template string `json:"template"`
Message string `json:"message"`
}
// ScanDir scans immediate template directories. Invalid templates are reported
// separately, so they cannot make valid templates disappear.
func ScanDir(root string) (ScanResult, error) {
entries, err := os.ReadDir(root)
if err != nil {
return ScanResult{}, fmt.Errorf("read template directory: %w", err)
}
result := ScanResult{}
seen := map[string]struct{}{}
source := os.DirFS(root)
for _, entry := range entries {
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
name := entry.Name()
body, readErr := os.ReadFile(filepath.Join(root, name, "template.yaml"))
if errors.Is(readErr, os.ErrNotExist) {
continue
}
result.Found++
if readErr != nil {
result.Errors = append(result.Errors, ScanError{name, "template cannot be read"})
continue
}
if symlinkErr := rejectSymlinks(filepath.Join(root, name)); symlinkErr != nil {
result.Errors = append(result.Errors, ScanError{name, "template contains unsupported symbolic links"})
continue
}
snapshot, validateErr := Validate(body, name, source)
if validateErr != nil {
result.Errors = append(result.Errors, ScanError{name, publicValidationMessage(validateErr)})
continue
}
key := snapshot.Template.ID + "@" + snapshot.Template.Version
if _, duplicate := seen[key]; duplicate {
result.Errors = append(result.Errors, ScanError{name, "duplicate template ID and version"})
continue
}
seen[key] = struct{}{}
result.Valid = append(result.Valid, snapshot)
}
sort.Slice(result.Valid, func(i, j int) bool { return result.Valid[i].Template.ID < result.Valid[j].Template.ID })
return result, nil
}
func rejectSymlinks(root string) error {
return filepath.WalkDir(root, func(_ string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.Type()&fs.ModeSymlink != 0 {
return errors.New("symbolic link")
}
return nil
})
}
func publicValidationMessage(err error) string {
var validation *ValidationErrors
if errors.As(err, &validation) && len(validation.Issues) != 0 {
return validation.Issues[0].Path + ": " + validation.Issues[0].Message
}
return "template is invalid"
}
// Validate applies YAML safety, JSON Schema and cross-field validation.
func Validate(body []byte, assetRoot string, source fs.FS) (Snapshot, error) {
var document yaml.Node
decoder := yaml.NewDecoder(bytes.NewReader(body))
if err := decoder.Decode(&document); err != nil {
return Snapshot{}, &ValidationErrors{Issues: []ValidationIssue{{Path: "/", Message: "invalid YAML"}}}
}
if hasAlias(&document) {
return Snapshot{}, &ValidationErrors{Issues: []ValidationIssue{{Path: "/", Line: document.Line, Message: "YAML aliases are not allowed"}}}
}
var raw any
if err := document.Decode(&raw); err != nil {
return Snapshot{}, &ValidationErrors{Issues: []ValidationIssue{{Path: "/", Message: "invalid YAML value"}}}
}
canonical, err := json.Marshal(raw)
if err != nil {
return Snapshot{}, fmt.Errorf("canonicalize template: %w", err)
}
schema, err := compileSchema()
if err != nil {
return Snapshot{}, err
}
if err := schema.Validate(raw); err != nil {
return Snapshot{}, validationErrors(err, &document)
}
var template Template
if err := json.Unmarshal(canonical, &template); err != nil {
return Snapshot{}, fmt.Errorf("decode validated template: %w", err)
}
issues := crossValidate(template, assetRoot, source)
if len(issues) != 0 {
return Snapshot{}, &ValidationErrors{Issues: issues}
}
moduleFiles, moduleIssues := collectModuleFiles(template, assetRoot, source)
if len(moduleIssues) != 0 {
return Snapshot{}, &ValidationErrors{Issues: moduleIssues}
}
pretty, _ := json.MarshalIndent(raw, "", " ")
digester := sha256.New()
_, _ = digester.Write(canonical)
for _, name := range sortedModuleFiles(moduleFiles) {
_, _ = digester.Write([]byte{0})
_, _ = digester.Write([]byte(name))
_, _ = digester.Write([]byte{0})
_, _ = digester.Write(moduleFiles[name])
}
digest := digester.Sum(nil)
return Snapshot{
Template: template,
CanonicalYAML: string(pretty) + "\n",
Digest: hex.EncodeToString(digest),
Origin: template.Source.Type,
AssetRoot: assetRoot,
ModuleFiles: moduleFiles,
}, nil
}
func compileSchema() (*jsonschema.Schema, error) {
body, err := specs.Files.ReadFile("template.schema.json")
if err != nil {
return nil, fmt.Errorf("read template schema: %w", err)
}
compiler := jsonschema.NewCompiler()
compiler.AssertFormat()
compiler.UseRegexpEngine(compileECMAScript)
var document any
if err := json.Unmarshal(body, &document); err != nil {
return nil, fmt.Errorf("decode template schema: %w", err)
}
if err := compiler.AddResource(templateSchemaURL, document); err != nil {
return nil, fmt.Errorf("load template schema: %w", err)
}
schema, err := compiler.Compile(templateSchemaURL)
if err != nil {
return nil, fmt.Errorf("compile template schema: %w", err)
}
return schema, nil
}
func compileModuleManifestSchema() (*jsonschema.Schema, error) {
body, err := specs.Files.ReadFile("module-manifest.schema.json")
if err != nil {
return nil, err
}
compiler := jsonschema.NewCompiler()
compiler.AssertFormat()
compiler.UseRegexpEngine(compileECMAScript)
var document any
if err := json.Unmarshal(body, &document); err != nil {
return nil, err
}
if err := compiler.AddResource(moduleManifestSchemaURL, document); err != nil {
return nil, err
}
return compiler.Compile(moduleManifestSchemaURL)
}
type ecmaRegexp regexp2.Regexp
func (expression *ecmaRegexp) MatchString(value string) bool {
matched, err := (*regexp2.Regexp)(expression).MatchString(value)
return err == nil && matched
}
func (expression *ecmaRegexp) String() string {
return (*regexp2.Regexp)(expression).String()
}
func compileECMAScript(pattern string) (jsonschema.Regexp, error) {
expression, err := regexp2.Compile(pattern, regexp2.ECMAScript)
if err != nil {
return nil, err
}
return (*ecmaRegexp)(expression), nil
}
func validationErrors(err error, document *yaml.Node) error {
var validation *jsonschema.ValidationError
if !errors.As(err, &validation) {
return err
}
leaves := make([]*jsonschema.ValidationError, 0)
var walk func(*jsonschema.ValidationError)
walk = func(current *jsonschema.ValidationError) {
if len(current.Causes) == 0 {
leaves = append(leaves, current)
return
}
for _, cause := range current.Causes {
walk(cause)
}
}
walk(validation)
issues := make([]ValidationIssue, 0, len(leaves))
for _, leaf := range leaves {
pointer := "/" + strings.Join(leaf.InstanceLocation, "/")
issues = append(issues, ValidationIssue{Path: pointer, Line: lineFor(document, leaf.InstanceLocation), Message: "value does not satisfy the template schema"})
}
return &ValidationErrors{Issues: issues}
}
func crossValidate(template Template, assetRoot string, source fs.FS) []ValidationIssue {
var issues []ValidationIssue
for field, asset := range map[string]string{
"logo": template.Game.Artwork.Logo,
"image": template.Game.Artwork.Image,
"poster": template.Game.Artwork.Poster,
} {
if asset == "" {
continue
}
if _, err := fs.Stat(source, path.Join(assetRoot, asset)); err != nil {
issues = append(issues, ValidationIssue{
Path: "/game/artwork/" + field,
Message: "artwork asset is missing",
})
}
}
ports := make(map[string]Port)
for _, port := range template.Container.Ports {
if _, exists := ports[port.ID]; exists {
issues = append(issues, ValidationIssue{Path: "/container/ports", Message: "port IDs must be unique"})
}
ports[port.ID] = port
}
mounts := make(map[string]Mount)
for _, mount := range template.Storage.Mounts {
if _, exists := mounts[mount.ID]; exists {
issues = append(issues, ValidationIssue{Path: "/storage/mounts", Message: "mount IDs must be unique"})
}
mounts[mount.ID] = mount
}
fields := make(map[string]struct{})
for _, field := range template.Configuration.Fields {
if _, exists := fields[field.ID]; exists {
issues = append(issues, ValidationIssue{Path: "/configuration/fields", Message: "field IDs must be unique"})
}
fields[field.ID] = struct{}{}
if field.Type == "secret" && (field.Visibility != "secret" || field.Default != nil) {
issues = append(issues, ValidationIssue{Path: "/configuration/fields/" + field.ID, Message: "secret fields require secret visibility and no default"})
}
if field.Target.Kind == "ini" {
mount, ok := mounts[field.Target.Mount]
if !ok || mount.ReadOnly || field.Target.File == "" || field.Target.Key == "" || strings.HasPrefix(field.Target.File, "/") || strings.Contains(field.Target.File, "..") {
issues = append(issues, ValidationIssue{Path: "/configuration/fields/" + field.ID + "/target", Message: "INI target must use a writable declared mount and relative file"})
}
}
}
for _, mountID := range template.Backup.SourceMounts {
mount, exists := mounts[mountID]
if !exists || !mount.Backup {
issues = append(issues, ValidationIssue{Path: "/backup/source_mounts", Message: "backup sources must reference backup-enabled mounts"})
}
}
if template.Integration != nil {
port, exists := ports[template.Integration.PortID]
if !exists || port.Purpose != "integration" {
issues = append(issues, ValidationIssue{Path: "/integration/port_id", Message: "integration must reference an integration port"})
}
}
if template.Integration != nil && template.Module == nil {
issues = append(issues, ValidationIssue{Path: "/module", Message: "integration requires a template-local module"})
}
if template.Healthcheck.PortID != "" {
if _, exists := ports[template.Healthcheck.PortID]; !exists {
issues = append(issues, ValidationIssue{Path: "/healthcheck/port_id", Message: "healthcheck port does not exist"})
}
}
if template.Imports.Supported {
if _, exists := mounts[template.Imports.DestinationMount]; !exists {
issues = append(issues, ValidationIssue{Path: "/imports/destination_mount", Message: "import destination mount does not exist"})
}
}
if template.Requirements.Recommended.CPUCores < template.Requirements.Minimum.CPUCores || template.Requirements.Recommended.MemoryMB < template.Requirements.Minimum.MemoryMB || template.Requirements.Recommended.StorageGB < template.Requirements.Minimum.StorageGB {
issues = append(issues, ValidationIssue{Path: "/requirements/recommended", Message: "recommended resources must not be below minimum resources"})
}
for _, asset := range template.Container.Assets {
if _, err := fs.Stat(source, path.Join(assetRoot, asset.Source)); err != nil {
issues = append(issues, ValidationIssue{Path: "/container/assets/" + asset.Source, Message: "asset is missing"})
}
}
return issues
}
func collectModuleFiles(template Template, assetRoot string, source fs.FS) (map[string][]byte, []ValidationIssue) {
if template.Module == nil {
return nil, nil
}
if !validModulePath(template.Module.Path) {
return nil, []ValidationIssue{{Path: "/module/path", Message: "module path must remain inside the template module directory"}}
}
root := path.Join(assetRoot, "module")
files := map[string][]byte{}
var totalBytes int
err := fs.WalkDir(source, root, func(name string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
return nil
}
if entry.Type()&fs.ModeSymlink != 0 {
return errors.New("symbolic link")
}
body, err := fs.ReadFile(source, name)
if err != nil {
return err
}
totalBytes += len(body)
if totalBytes > maxModuleBundleBytes {
return errors.New("module bundle exceeds size limit")
}
rel := strings.TrimPrefix(name, strings.TrimSuffix(assetRoot, "/")+"/")
if !strings.HasPrefix(rel, "module/") {
return errors.New("invalid module path")
}
files[rel] = body
return nil
})
if err != nil {
return nil, []ValidationIssue{{Path: "/module", Message: "module directory cannot be read"}}
}
if _, ok := files[template.Module.Path]; !ok {
return nil, []ValidationIssue{{Path: "/module/path", Message: "declared module manifest is missing"}}
}
if err := validateModuleManifest(files[template.Module.Path], files); err != nil {
return nil, []ValidationIssue{{Path: "/module", Message: "declared module bundle is invalid"}}
}
return files, nil
}
func validateModuleManifest(body []byte, files map[string][]byte) error {
var raw any
if err := yaml.Unmarshal(body, &raw); err != nil {
return err
}
schema, err := compileModuleManifestSchema()
if err != nil || schema.Validate(raw) != nil {
return errors.New("invalid manifest")
}
var manifest struct {
Artifacts struct {
WASM string `yaml:"wasm"`
} `yaml:"artifacts"`
}
if err := yaml.Unmarshal(body, &manifest); err != nil || manifest.Artifacts.WASM == "" {
return errors.New("invalid artifact")
}
if _, ok := files["module/"+manifest.Artifacts.WASM]; !ok {
return errors.New("missing wasm artifact")
}
return nil
}
func validModulePath(value string) bool {
return value != "" && !path.IsAbs(value) && path.Clean(value) == value && strings.HasPrefix(value, "module/") && !strings.Contains(value, "..")
}
func sortedModuleFiles(files map[string][]byte) []string {
names := make([]string, 0, len(files))
for name := range files {
names = append(names, name)
}
sort.Strings(names)
return names
}
func hasAlias(node *yaml.Node) bool {
if node.Kind == yaml.AliasNode {
return true
}
for _, child := range node.Content {
if hasAlias(child) {
return true
}
}
return false
}
func lineFor(document *yaml.Node, segments []string) int {
node := document
if node.Kind == yaml.DocumentNode && len(node.Content) != 0 {
node = node.Content[0]
}
for _, segment := range segments {
if node.Kind == yaml.MappingNode {
found := false
for index := 0; index+1 < len(node.Content); index += 2 {
if node.Content[index].Value == segment {
node = node.Content[index+1]
found = true
break
}
}
if !found {
return node.Line
}
continue
}
if node.Kind == yaml.SequenceNode {
var index int
if _, err := fmt.Sscanf(segment, "%d", &index); err != nil || index < 0 || index >= len(node.Content) {
return node.Line
}
node = node.Content[index]
}
}
return node.Line
}