372 lines
12 KiB
Go
372 lines
12 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"`
|
|
StopTimeoutSeconds int `json:"stop_timeout_seconds"`
|
|
Ports []Port `json:"ports"`
|
|
Assets []struct {
|
|
Source string `json:"source"`
|
|
SHA256 string `json:"sha256"`
|
|
} `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"`
|
|
} `json:"healthcheck"`
|
|
Imports struct {
|
|
Supported bool `json:"supported"`
|
|
DestinationMount string `json:"destination_mount"`
|
|
} `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
|
|
}
|
|
|
|
// 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,
|
|
}, 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
|
|
}
|