Files
DoGaMa-serv/internal/catalog/template.go
T

385 lines
13 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"
"path"
"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"
// 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"`
} `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"`
Arguments []string `json:"arguments,omitempty"`
StopTimeoutSeconds int `json:"stop_timeout_seconds"`
Ports []Port `json:"ports"`
Assets []struct {
Source string `json:"source"`
Destination string `json:"destination"`
SHA256 string `json:"sha256"`
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"`
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"`
}
type Resources struct {
CPUCores float64 `json:"cpu_cores"`
MemoryMB int `json:"memory_mb"`
StorageGB int `json:"storage_gb"`
}
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"`
Type string `json:"type"`
Visibility string `json:"visibility"`
Required bool `json:"required"`
Default any `json:"default,omitempty"`
}
// Snapshot is an immutable validated template version.
type Snapshot struct {
Template Template
CanonicalYAML string
Digest string
Origin string
AssetRoot string
}
// 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
}
// 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}
}
pretty, _ := json.MarshalIndent(raw, "", " ")
digest := sha256.Sum256(canonical)
return Snapshot{
Template: template,
CanonicalYAML: string(pretty) + "\n",
Digest: hex.EncodeToString(digest[:]),
Origin: template.Source.Type,
AssetRoot: assetRoot,
}, 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
}
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
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"})
}
}
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.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 {
body, err := fs.ReadFile(source, path.Join(assetRoot, asset.Source))
digest := sha256.Sum256(body)
if err != nil || hex.EncodeToString(digest[:]) != asset.SHA256 {
issues = append(issues, ValidationIssue{Path: "/container/assets/" + asset.Source, Message: "asset is missing or its checksum does not match"})
}
}
return issues
}
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
}