Validated catalog and deployment previews #3
@@ -72,7 +72,7 @@ Only the main application's HTTP port is published. The agent and game-managemen
|
||||
|
||||
## Status
|
||||
|
||||
The first two roadmap foundations are implemented: the main Go binary, embedded server-rendered UI, SQLite migrations, first-administrator bootstrap, local session authentication, and the restricted agent boundary with authenticated private requests, replay defense, canonical allowed-root enforcement, authenticated local registry and bounded Docker health/disk inspection. Deployment plans, container lifecycle operations and the WebAssembly runtime remain later roadmap work.
|
||||
The first three roadmap foundations are implemented: the main application and authentication, the restricted agent boundary, and the validated embedded catalog with immutable template snapshots, deterministic deployment previews and a SQLite draft-instance registry. Container lifecycle operations and the WebAssembly runtime remain later roadmap work.
|
||||
|
||||
## Validate the specification
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Package catalogdata embeds the built-in local game catalog.
|
||||
package catalogdata
|
||||
|
||||
import "embed"
|
||||
|
||||
// Files contains built-in templates and their packaged assets.
|
||||
//
|
||||
//go:embed */template.yaml */assets/*
|
||||
var Files embed.FS
|
||||
+12
-1
@@ -11,7 +11,9 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/web"
|
||||
)
|
||||
@@ -35,7 +37,16 @@ func run(logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
handler, err := web.NewHandler(auth.New(db), logger)
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
repository := sqlite.NewRepository(db)
|
||||
if err := repository.Sync(ctx, snapshots); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info("local catalog synchronized", "event", "catalog.synchronized", "template_count", len(snapshots))
|
||||
handler, err := web.NewHandlerWithRepository(auth.New(db), repository, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -51,6 +51,13 @@ and are canonicalized with symlinks resolved. For normal deployment, use the
|
||||
secret file and private control network defined in `compose.yaml`; never publish
|
||||
the agent port on the host.
|
||||
|
||||
At main-application startup, every embedded `catalog/*/template.yaml` is
|
||||
validated against `specs/template.schema.json`, checked for cross-reference and
|
||||
asset integrity, canonicalized deterministically and synchronized into SQLite.
|
||||
An existing template ID/version is immutable: changing its digest fails startup
|
||||
instead of silently replacing the snapshot. Deployment previews pin that digest
|
||||
and redact secret defaults before a draft instance can enter the registry.
|
||||
|
||||
## Contract changes
|
||||
|
||||
Template schema, manifest schema, normalized module API and agent deployment plan are versioned contracts.
|
||||
|
||||
@@ -3,7 +3,10 @@ module git.zaynet.fr/DoGaMa/DoGaMa-serv
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/dlclark/regexp2 v1.12.0
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3
|
||||
golang.org/x/crypto v0.53.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.56.0
|
||||
)
|
||||
|
||||
@@ -14,6 +17,7 @@ require (
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
modernc.org/libc v1.74.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
@@ -12,6 +14,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
@@ -20,8 +24,14 @@ golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTemplateNotFound = errors.New("template not found")
|
||||
ErrImmutableSnapshot = errors.New("template version is immutable")
|
||||
)
|
||||
|
||||
type Summary struct {
|
||||
ID string `json:"id"`
|
||||
Version string `json:"version"`
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
Description string `json:"description"`
|
||||
TrustStatus string `json:"trust_status"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
// Repository persists immutable catalog snapshots.
|
||||
type Repository interface {
|
||||
Sync(context.Context, []Snapshot) error
|
||||
List(context.Context) ([]Summary, error)
|
||||
Get(context.Context, string, string) (Snapshot, error)
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package catalog_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
)
|
||||
|
||||
func TestBuiltInCatalogValidatesDeterministically(t *testing.T) {
|
||||
first, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(first) != 1 || first[0].Template.ID != "palworld-official" || first[0].Digest != second[0].Digest || first[0].CanonicalYAML != second[0].CanonicalYAML {
|
||||
t.Fatalf("catalog snapshots = %#v, %#v", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaErrorsContainFieldPathAndLine(t *testing.T) {
|
||||
body, err := catalogdata.Files.ReadFile("palworld/template.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body = []byte(strings.Replace(string(body), "schema_version: 1", "schema_version: 2", 1))
|
||||
_, err = catalog.Validate(body, "palworld", catalogdata.Files)
|
||||
var validation *catalog.ValidationErrors
|
||||
if !errors.As(err, &validation) || len(validation.Issues) == 0 {
|
||||
t.Fatalf("validation error = %#v", err)
|
||||
}
|
||||
if validation.Issues[0].Path == "" || validation.Issues[0].Line == 0 {
|
||||
t.Fatalf("validation issue = %#v", validation.Issues[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossValidationRejectsUnknownBackupMount(t *testing.T) {
|
||||
body, err := catalogdata.Files.ReadFile("palworld/template.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body = []byte(strings.Replace(string(body), " - saved\n restart_after_backup", " - missing\n restart_after_backup", 1))
|
||||
_, err = catalog.Validate(body, "palworld", catalogdata.Files)
|
||||
var validation *catalog.ValidationErrors
|
||||
if !errors.As(err, &validation) {
|
||||
t.Fatalf("validation error = %#v", err)
|
||||
}
|
||||
found := false
|
||||
for _, issue := range validation.Issues {
|
||||
found = found || issue.Path == "/backup/source_mounts"
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("issues = %#v", validation.Issues)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// Package instance builds canonical deployment previews and draft registry entries.
|
||||
package instance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
)
|
||||
|
||||
var slugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
|
||||
type PreviewRequest struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
Slug string `json:"slug"`
|
||||
HostPorts map[string]int `json:"host_ports"`
|
||||
MountPaths map[string]string `json:"mount_paths"`
|
||||
Resources catalog.Resources `json:"resources"`
|
||||
DataOrigin string `json:"data_origin"`
|
||||
BackupRetention int `json:"backup_retention"`
|
||||
}
|
||||
|
||||
type Preview struct {
|
||||
Template TemplateReference `json:"template"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Slug string `json:"slug"`
|
||||
Image string `json:"image"`
|
||||
Ports []PortBinding `json:"ports"`
|
||||
Mounts []MountBinding `json:"mounts"`
|
||||
Resources catalog.Resources `json:"resources"`
|
||||
Settings []SettingPreview `json:"settings"`
|
||||
DataOrigin string `json:"data_origin"`
|
||||
Backup BackupPreview `json:"backup"`
|
||||
CanonicalJSON string `json:"canonical_json"`
|
||||
PlanDigest string `json:"plan_digest"`
|
||||
}
|
||||
|
||||
type TemplateReference struct {
|
||||
ID string `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
type PortBinding struct {
|
||||
ID string `json:"id"`
|
||||
Protocol string `json:"protocol"`
|
||||
Purpose string `json:"purpose"`
|
||||
ContainerPort int `json:"container_port"`
|
||||
HostPort int `json:"host_port,omitempty"`
|
||||
Publish bool `json:"publish"`
|
||||
}
|
||||
|
||||
type MountBinding struct {
|
||||
ID string `json:"id"`
|
||||
HostPath string `json:"host_path"`
|
||||
ContainerPath string `json:"container_path"`
|
||||
Category string `json:"category"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
}
|
||||
|
||||
type SettingPreview struct {
|
||||
ID string `json:"id"`
|
||||
Secret bool `json:"secret"`
|
||||
Configured bool `json:"configured"`
|
||||
Default any `json:"default,omitempty"`
|
||||
}
|
||||
|
||||
type BackupPreview struct {
|
||||
Strategy string `json:"strategy"`
|
||||
SourceMounts []string `json:"source_mounts"`
|
||||
RetentionCount int `json:"retention_count"`
|
||||
}
|
||||
|
||||
type Draft struct {
|
||||
ID string
|
||||
Preview Preview
|
||||
}
|
||||
|
||||
// Repository stores draft instances without performing external actions.
|
||||
type Repository interface {
|
||||
CreateDraft(context.Context, Draft) error
|
||||
}
|
||||
|
||||
// BuildPreview validates administrator choices and produces deterministic JSON.
|
||||
func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, error) {
|
||||
request.DisplayName = strings.TrimSpace(request.DisplayName)
|
||||
if request.DisplayName == "" || len(request.DisplayName) > 100 || !slugPattern.MatchString(request.Slug) {
|
||||
return Preview{}, errors.New("invalid display name or slug")
|
||||
}
|
||||
if request.DataOrigin != "new" && request.DataOrigin != "import" {
|
||||
return Preview{}, errors.New("data origin must be new or import")
|
||||
}
|
||||
if request.DataOrigin == "import" && !snapshot.Template.Imports.Supported {
|
||||
return Preview{}, errors.New("template does not support imports")
|
||||
}
|
||||
resources := request.Resources
|
||||
if resources.CPUCores == 0 {
|
||||
resources = snapshot.Template.Requirements.Recommended
|
||||
}
|
||||
minimum := snapshot.Template.Requirements.Minimum
|
||||
if resources.CPUCores < minimum.CPUCores || resources.MemoryMB < minimum.MemoryMB || resources.StorageGB < minimum.StorageGB {
|
||||
return Preview{}, errors.New("resources are below template minimum")
|
||||
}
|
||||
ports := make([]PortBinding, 0, len(snapshot.Template.Container.Ports))
|
||||
usedPorts := make(map[string]struct{})
|
||||
knownPorts := make(map[string]struct{}, len(snapshot.Template.Container.Ports))
|
||||
for _, port := range snapshot.Template.Container.Ports {
|
||||
knownPorts[port.ID] = struct{}{}
|
||||
hostPort := request.HostPorts[port.ID]
|
||||
if port.Publish && (hostPort < 1 || hostPort > 65535) {
|
||||
return Preview{}, fmt.Errorf("published port %s requires a valid host port", port.ID)
|
||||
}
|
||||
if !port.Publish && hostPort != 0 {
|
||||
return Preview{}, fmt.Errorf("private port %s cannot be published", port.ID)
|
||||
}
|
||||
key := fmt.Sprintf("%s/%d", port.Protocol, hostPort)
|
||||
if port.Publish {
|
||||
if _, exists := usedPorts[key]; exists {
|
||||
return Preview{}, errors.New("host port conflict in preview")
|
||||
}
|
||||
usedPorts[key] = struct{}{}
|
||||
}
|
||||
ports = append(ports, PortBinding{ID: port.ID, Protocol: port.Protocol, Purpose: port.Purpose, ContainerPort: port.ContainerPort, HostPort: hostPort, Publish: port.Publish})
|
||||
}
|
||||
for id := range request.HostPorts {
|
||||
if _, exists := knownPorts[id]; !exists {
|
||||
return Preview{}, fmt.Errorf("unknown port %s", id)
|
||||
}
|
||||
}
|
||||
sort.Slice(ports, func(i, j int) bool { return ports[i].ID < ports[j].ID })
|
||||
mounts := make([]MountBinding, 0, len(snapshot.Template.Storage.Mounts))
|
||||
knownMounts := make(map[string]struct{}, len(snapshot.Template.Storage.Mounts))
|
||||
for _, mount := range snapshot.Template.Storage.Mounts {
|
||||
knownMounts[mount.ID] = struct{}{}
|
||||
hostPath := request.MountPaths[mount.ID]
|
||||
if hostPath == "" || !filepath.IsAbs(hostPath) || filepath.Clean(hostPath) != hostPath {
|
||||
return Preview{}, fmt.Errorf("mount %s requires a canonical absolute path", mount.ID)
|
||||
}
|
||||
mounts = append(mounts, MountBinding{ID: mount.ID, HostPath: hostPath, ContainerPath: mount.ContainerPath, Category: mount.Category, ReadOnly: mount.ReadOnly})
|
||||
}
|
||||
for id := range request.MountPaths {
|
||||
if _, exists := knownMounts[id]; !exists {
|
||||
return Preview{}, fmt.Errorf("unknown mount %s", id)
|
||||
}
|
||||
}
|
||||
sort.Slice(mounts, func(i, j int) bool { return mounts[i].ID < mounts[j].ID })
|
||||
settings := make([]SettingPreview, 0, len(snapshot.Template.Configuration.Fields))
|
||||
for _, field := range snapshot.Template.Configuration.Fields {
|
||||
secret := field.Type == "secret"
|
||||
setting := SettingPreview{ID: field.ID, Secret: secret, Configured: !secret && field.Default != nil}
|
||||
if !secret {
|
||||
setting.Default = field.Default
|
||||
}
|
||||
settings = append(settings, setting)
|
||||
}
|
||||
sort.Slice(settings, func(i, j int) bool { return settings[i].ID < settings[j].ID })
|
||||
preview := Preview{
|
||||
Template: TemplateReference{ID: snapshot.Template.ID, Version: snapshot.Template.Version, Digest: snapshot.Digest},
|
||||
DisplayName: request.DisplayName,
|
||||
Slug: request.Slug,
|
||||
Image: snapshot.Template.Container.Image + ":" + snapshot.Template.Container.Tag,
|
||||
Ports: ports, Mounts: mounts, Resources: resources, Settings: settings,
|
||||
DataOrigin: request.DataOrigin,
|
||||
Backup: BackupPreview{Strategy: snapshot.Template.Backup.Strategy, SourceMounts: append([]string(nil), snapshot.Template.Backup.SourceMounts...), RetentionCount: request.BackupRetention},
|
||||
}
|
||||
if preview.Backup.RetentionCount < 1 || preview.Backup.RetentionCount > 1000 {
|
||||
return Preview{}, errors.New("backup retention must be between 1 and 1000")
|
||||
}
|
||||
sort.Strings(preview.Backup.SourceMounts)
|
||||
canonicalValue := preview
|
||||
canonicalValue.CanonicalJSON = ""
|
||||
canonicalValue.PlanDigest = ""
|
||||
canonical, err := json.Marshal(canonicalValue)
|
||||
if err != nil {
|
||||
return Preview{}, fmt.Errorf("encode deployment preview: %w", err)
|
||||
}
|
||||
digest := sha256.Sum256(canonical)
|
||||
preview.CanonicalJSON = string(canonical)
|
||||
preview.PlanDigest = hex.EncodeToString(digest[:])
|
||||
return preview, nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package instance_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
|
||||
)
|
||||
|
||||
func TestBuildPreviewIsDeterministicAndRedactsSecrets(t *testing.T) {
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := instance.PreviewRequest{
|
||||
DisplayName: "Family Palworld",
|
||||
Slug: "family-palworld",
|
||||
HostPorts: map[string]int{"game": 8211},
|
||||
MountPaths: map[string]string{"saved": filepath.Join(string(filepath.Separator), "srv", "game-servers", "family-palworld", "saved")},
|
||||
DataOrigin: "new",
|
||||
BackupRetention: 7,
|
||||
}
|
||||
first, err := instance.BuildPreview(snapshots[0], request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := instance.BuildPreview(snapshots[0], request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.PlanDigest != second.PlanDigest || first.CanonicalJSON != second.CanonicalJSON {
|
||||
t.Fatal("preview is not deterministic")
|
||||
}
|
||||
for _, setting := range first.Settings {
|
||||
if setting.Secret && setting.Default != nil {
|
||||
t.Fatalf("secret default leaked: %#v", setting)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPreviewRejectsPrivatePortPublicationAndLowResources(t *testing.T) {
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := instance.PreviewRequest{
|
||||
DisplayName: "Family Palworld", Slug: "family-palworld",
|
||||
HostPorts: map[string]int{"game": 8211, "rest_api": 8212},
|
||||
MountPaths: map[string]string{"saved": "/srv/game-servers/family-palworld/saved"},
|
||||
DataOrigin: "new", BackupRetention: 7,
|
||||
}
|
||||
if _, err := instance.BuildPreview(snapshots[0], base); err == nil {
|
||||
t.Fatal("private integration port unexpectedly published")
|
||||
}
|
||||
base.HostPorts = map[string]int{"game": 8211}
|
||||
base.Resources = catalog.Resources{CPUCores: 1, MemoryMB: 128, StorageGB: 1}
|
||||
if _, err := instance.BuildPreview(snapshots[0], base); err == nil {
|
||||
t.Fatal("below-minimum resources unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPreviewRejectsUnknownPortAndMountIDs(t *testing.T) {
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := instance.PreviewRequest{
|
||||
DisplayName: "Family Palworld", Slug: "family-palworld",
|
||||
HostPorts: map[string]int{"game": 8211, "unknown": 8212},
|
||||
MountPaths: map[string]string{"saved": "/srv/game-servers/family-palworld/saved"},
|
||||
DataOrigin: "new", BackupRetention: 7,
|
||||
}
|
||||
if _, err := instance.BuildPreview(snapshots[0], base); err == nil {
|
||||
t.Fatal("unknown port ID unexpectedly accepted")
|
||||
}
|
||||
base.HostPorts = map[string]int{"game": 8211}
|
||||
base.MountPaths["unknown"] = "/srv/game-servers/family-palworld/unknown"
|
||||
if _, err := instance.BuildPreview(snapshots[0], base); err == nil {
|
||||
t.Fatal("unknown mount ID unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
|
||||
)
|
||||
|
||||
// Repository persists catalog snapshots and the desired instance registry.
|
||||
type Repository struct {
|
||||
db *sql.DB
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewRepository(db *sql.DB) *Repository {
|
||||
return &Repository{db: db, now: time.Now}
|
||||
}
|
||||
|
||||
func (r *Repository) Sync(ctx context.Context, snapshots []catalog.Snapshot) error {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin catalog sync: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
now := r.now().UTC().Format(time.RFC3339Nano)
|
||||
for _, snapshot := range snapshots {
|
||||
var digest string
|
||||
err := tx.QueryRowContext(ctx, "SELECT digest FROM template_versions WHERE template_id = ? AND version = ?", snapshot.Template.ID, snapshot.Template.Version).Scan(&digest)
|
||||
if err == nil && digest != snapshot.Digest {
|
||||
return fmt.Errorf("%w: %s@%s", catalog.ErrImmutableSnapshot, snapshot.Template.ID, snapshot.Template.Version)
|
||||
}
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("check template snapshot: %w", err)
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO templates(id, origin, trust_status, active_version, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET active_version=excluded.active_version, updated_at=excluded.updated_at`,
|
||||
snapshot.Template.ID, snapshot.Origin, snapshot.Origin, snapshot.Template.Version, now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert catalog template: %w", err)
|
||||
}
|
||||
if digest == "" {
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO template_versions(template_id, version, schema_version, canonical_yaml, digest, game_id, game_name, description, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, snapshot.Template.ID, snapshot.Template.Version, snapshot.Template.SchemaVersion, snapshot.CanonicalYAML, snapshot.Digest, snapshot.Template.Game.ID, snapshot.Template.Game.Name, snapshot.Template.Game.Description, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert template snapshot: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit catalog sync: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) List(ctx context.Context) ([]catalog.Summary, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT t.id, t.active_version, v.game_id, v.game_name, v.description, t.trust_status, v.digest
|
||||
FROM templates t JOIN template_versions v ON v.template_id=t.id AND v.version=t.active_version ORDER BY v.game_name, t.id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list catalog: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []catalog.Summary
|
||||
for rows.Next() {
|
||||
var summary catalog.Summary
|
||||
if err := rows.Scan(&summary.ID, &summary.Version, &summary.GameID, &summary.GameName, &summary.Description, &summary.TrustStatus, &summary.Digest); err != nil {
|
||||
return nil, fmt.Errorf("scan catalog: %w", err)
|
||||
}
|
||||
result = append(result, summary)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) Get(ctx context.Context, id, version string) (catalog.Snapshot, error) {
|
||||
var canonical, digest, origin string
|
||||
err := r.db.QueryRowContext(ctx, `SELECT v.canonical_yaml, v.digest, t.origin FROM template_versions v JOIN templates t ON t.id=v.template_id
|
||||
WHERE v.template_id=? AND v.version=?`, id, version).Scan(&canonical, &digest, &origin)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return catalog.Snapshot{}, catalog.ErrTemplateNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return catalog.Snapshot{}, fmt.Errorf("load template snapshot: %w", err)
|
||||
}
|
||||
var template catalog.Template
|
||||
if err := json.Unmarshal([]byte(canonical), &template); err != nil {
|
||||
return catalog.Snapshot{}, fmt.Errorf("decode stored template snapshot: %w", err)
|
||||
}
|
||||
return catalog.Snapshot{Template: template, CanonicalYAML: canonical, Digest: digest, Origin: origin}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateDraft(ctx context.Context, draft instance.Draft) error {
|
||||
if draft.ID == "" {
|
||||
return errors.New("draft instance ID is required")
|
||||
}
|
||||
now := r.now().UTC().Format(time.RFC3339Nano)
|
||||
previewJSON, err := json.Marshal(draft.Preview)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode draft preview: %w", err)
|
||||
}
|
||||
_, err = r.db.ExecContext(ctx, `INSERT INTO instances(id, slug, display_name, template_id, template_version, template_digest, revision, lifecycle_state, preview_json, plan_digest, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1, 'draft', ?, ?, ?, ?)`, draft.ID, draft.Preview.Slug, draft.Preview.DisplayName, draft.Preview.Template.ID, draft.Preview.Template.Version, draft.Preview.Template.Digest, string(previewJSON), draft.Preview.PlanDigest, now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create draft instance: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package sqlite_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
|
||||
)
|
||||
|
||||
func TestCatalogSyncIsImmutableAndDraftPinsSnapshot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
repository := sqlite.NewRepository(db)
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.Sync(ctx, snapshots); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
summaries, err := repository.List(ctx)
|
||||
if err != nil || len(summaries) != 1 || summaries[0].Digest != snapshots[0].Digest {
|
||||
t.Fatalf("summaries = %#v, error = %v", summaries, err)
|
||||
}
|
||||
loaded, err := repository.Get(ctx, snapshots[0].Template.ID, snapshots[0].Template.Version)
|
||||
if err != nil || loaded.Digest != snapshots[0].Digest {
|
||||
t.Fatalf("loaded snapshot = %#v, error = %v", loaded, err)
|
||||
}
|
||||
tampered := snapshots[0]
|
||||
tampered.Digest = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
|
||||
if err := repository.Sync(ctx, []catalog.Snapshot{tampered}); !errors.Is(err, catalog.ErrImmutableSnapshot) {
|
||||
t.Fatalf("immutable snapshot error = %v", err)
|
||||
}
|
||||
preview, err := instance.BuildPreview(snapshots[0], instance.PreviewRequest{
|
||||
DisplayName: "Family Palworld", Slug: "family-palworld",
|
||||
HostPorts: map[string]int{"game": 8211},
|
||||
MountPaths: map[string]string{"saved": "/srv/game-servers/family-palworld/saved"},
|
||||
DataOrigin: "new", BackupRetention: 7,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.CreateDraft(ctx, instance.Draft{ID: "opaque-instance-id", Preview: preview}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var state, digest string
|
||||
if err := db.QueryRow("SELECT lifecycle_state, template_digest FROM instances WHERE id=?", "opaque-instance-id").Scan(&state, &digest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state != "draft" || digest != snapshots[0].Digest {
|
||||
t.Fatalf("draft state=%q digest=%q", state, digest)
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,8 @@ func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) {
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("got %d migrations, want 1", count)
|
||||
if count != 2 {
|
||||
t.Fatalf("got %d migrations, want 2", count)
|
||||
}
|
||||
var foreignKeys, busyTimeout int
|
||||
var journalMode string
|
||||
@@ -49,7 +49,7 @@ func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) {
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("reopened database has %d migrations, want 1", count)
|
||||
if count != 2 {
|
||||
t.Fatalf("reopened database has %d migrations, want 2", count)
|
||||
}
|
||||
}
|
||||
|
||||
+139
-4
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"html/template"
|
||||
"io"
|
||||
@@ -14,6 +15,8 @@ import (
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -47,9 +50,15 @@ var englishMessages = map[string]string{
|
||||
}
|
||||
|
||||
type server struct {
|
||||
auth *auth.Service
|
||||
templates *template.Template
|
||||
logger *slog.Logger
|
||||
auth *auth.Service
|
||||
templates *template.Template
|
||||
logger *slog.Logger
|
||||
repository repository
|
||||
}
|
||||
|
||||
type repository interface {
|
||||
catalog.Repository
|
||||
instance.Repository
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
@@ -61,12 +70,26 @@ type pageData struct {
|
||||
|
||||
// NewHandler constructs the complete HTTP application.
|
||||
func NewHandler(authService *auth.Service, logger *slog.Logger) (http.Handler, error) {
|
||||
return newHandler(authService, nil, logger)
|
||||
}
|
||||
|
||||
// NewHandlerWithRepository enables the authenticated catalog and draft APIs.
|
||||
func NewHandlerWithRepository(authService *auth.Service, repository repository, logger *slog.Logger) (http.Handler, error) {
|
||||
return newHandler(authService, repository, logger)
|
||||
}
|
||||
|
||||
func newHandler(authService *auth.Service, repository repository, logger *slog.Logger) (http.Handler, error) {
|
||||
templates, err := template.New("views").Funcs(template.FuncMap{"msg": message}).ParseFS(assets, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &server{auth: authService, templates: templates, logger: logger}
|
||||
s := &server{auth: authService, templates: templates, logger: logger, repository: repository}
|
||||
mux := http.NewServeMux()
|
||||
if repository != nil {
|
||||
mux.HandleFunc("GET /api/v1/catalog", s.catalogList)
|
||||
mux.HandleFunc("POST /api/v1/instances/preview", s.instancePreview)
|
||||
mux.HandleFunc("POST /api/v1/instances/drafts", s.instanceDraft)
|
||||
}
|
||||
mux.HandleFunc("GET /static/app.v1.css", s.stylesheet)
|
||||
mux.HandleFunc("GET /setup", s.setupForm)
|
||||
mux.HandleFunc("POST /setup", s.setupSubmit)
|
||||
@@ -77,6 +100,118 @@ func NewHandler(authService *auth.Service, logger *slog.Logger) (http.Handler, e
|
||||
return s.securityHeaders(mux), nil
|
||||
}
|
||||
|
||||
type previewAPIRequest struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
TemplateVersion string `json:"template_version"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Slug string `json:"slug"`
|
||||
HostPorts map[string]int `json:"host_ports"`
|
||||
MountPaths map[string]string `json:"mount_paths"`
|
||||
Resources catalog.Resources `json:"resources"`
|
||||
DataOrigin string `json:"data_origin"`
|
||||
BackupRetention int `json:"backup_retention"`
|
||||
}
|
||||
|
||||
func (s *server) catalogList(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireAPIUser(w, r, false); !ok {
|
||||
return
|
||||
}
|
||||
templates, err := s.repository.List(r.Context())
|
||||
if err != nil {
|
||||
s.apiProblem(w, http.StatusInternalServerError, "catalog_unavailable", "The catalog is unavailable.")
|
||||
return
|
||||
}
|
||||
s.apiJSON(w, http.StatusOK, struct {
|
||||
Templates []catalog.Summary `json:"templates"`
|
||||
}{Templates: templates})
|
||||
}
|
||||
|
||||
func (s *server) instancePreview(w http.ResponseWriter, r *http.Request) {
|
||||
request, preview, ok := s.buildAPIPreview(w, r)
|
||||
_ = request
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.apiJSON(w, http.StatusOK, preview)
|
||||
}
|
||||
|
||||
func (s *server) instanceDraft(w http.ResponseWriter, r *http.Request) {
|
||||
_, preview, ok := s.buildAPIPreview(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id := randomToken()
|
||||
if err := s.repository.CreateDraft(r.Context(), instance.Draft{ID: id, Preview: preview}); err != nil {
|
||||
s.apiProblem(w, http.StatusConflict, "draft_conflict", "The draft instance could not be created.")
|
||||
return
|
||||
}
|
||||
s.apiJSON(w, http.StatusCreated, map[string]string{"id": id, "state": "draft", "plan_digest": preview.PlanDigest})
|
||||
}
|
||||
|
||||
func (s *server) buildAPIPreview(w http.ResponseWriter, r *http.Request) (previewAPIRequest, instance.Preview, bool) {
|
||||
if _, ok := s.requireAPIUser(w, r, true); !ok {
|
||||
return previewAPIRequest{}, instance.Preview{}, false
|
||||
}
|
||||
var request previewAPIRequest
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
s.apiProblem(w, http.StatusBadRequest, "invalid_request", "The request is invalid.")
|
||||
return request, instance.Preview{}, false
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
s.apiProblem(w, http.StatusBadRequest, "invalid_request", "The request is invalid.")
|
||||
return request, instance.Preview{}, false
|
||||
}
|
||||
snapshot, err := s.repository.Get(r.Context(), request.TemplateID, request.TemplateVersion)
|
||||
if err != nil {
|
||||
s.apiProblem(w, http.StatusNotFound, "template_not_found", "The template version was not found.")
|
||||
return request, instance.Preview{}, false
|
||||
}
|
||||
preview, err := instance.BuildPreview(snapshot, instance.PreviewRequest{
|
||||
DisplayName: request.DisplayName, Slug: request.Slug, HostPorts: request.HostPorts,
|
||||
MountPaths: request.MountPaths, Resources: request.Resources, DataOrigin: request.DataOrigin,
|
||||
BackupRetention: request.BackupRetention,
|
||||
})
|
||||
if err != nil {
|
||||
s.apiProblem(w, http.StatusUnprocessableEntity, "invalid_preview", "The deployment preview is invalid.")
|
||||
return request, instance.Preview{}, false
|
||||
}
|
||||
return request, preview, true
|
||||
}
|
||||
|
||||
func (s *server) requireAPIUser(w http.ResponseWriter, r *http.Request, admin bool) (auth.User, bool) {
|
||||
user, err := s.currentUser(r)
|
||||
if err != nil {
|
||||
s.apiProblem(w, http.StatusUnauthorized, "authentication_required", "Authentication is required.")
|
||||
return auth.User{}, false
|
||||
}
|
||||
if admin && user.Role != "admin" {
|
||||
s.apiProblem(w, http.StatusForbidden, "permission_denied", "Permission denied.")
|
||||
return auth.User{}, false
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
session, err := r.Cookie(sessionCookie)
|
||||
if err != nil || !s.auth.ValidateCSRF(r.Context(), session.Value, r.Header.Get("X-CSRF-Token")) {
|
||||
s.apiProblem(w, http.StatusForbidden, "csrf_failed", "Request verification failed.")
|
||||
return auth.User{}, false
|
||||
}
|
||||
}
|
||||
return user, true
|
||||
}
|
||||
|
||||
func (s *server) apiJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func (s *server) apiProblem(w http.ResponseWriter, status int, code, message string) {
|
||||
s.apiJSON(w, status, map[string]string{"code": code, "message": message})
|
||||
}
|
||||
|
||||
func (s *server) setupForm(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireBootstrap(w, r, true) {
|
||||
return
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -11,10 +13,71 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
|
||||
)
|
||||
|
||||
func TestCatalogPreviewAndDraftAPIAuthorization(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
repository := sqlite.NewRepository(db)
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.Sync(ctx, snapshots); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authService := auth.New(db)
|
||||
if err := authService.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler, err := NewHandlerWithRepository(authService, repository, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unauthenticated := request(t, handler, http.MethodGet, "/api/v1/catalog", nil)
|
||||
assertStatus(t, unauthenticated, http.StatusUnauthorized)
|
||||
sessionCookieValue := &http.Cookie{Name: sessionCookie, Value: session.Token}
|
||||
catalogResponse := request(t, handler, http.MethodGet, "/api/v1/catalog", []*http.Cookie{sessionCookieValue})
|
||||
assertStatus(t, catalogResponse, http.StatusOK)
|
||||
if !strings.Contains(catalogResponse.Body.String(), "palworld-official") {
|
||||
t.Fatalf("catalog response = %s", catalogResponse.Body.String())
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"template_id": "palworld-official", "template_version": "1.0.0",
|
||||
"display_name": "Family Palworld", "slug": "family-palworld",
|
||||
"host_ports": map[string]int{"game": 8211},
|
||||
"mount_paths": map[string]string{"saved": "/srv/game-servers/family-palworld/saved"},
|
||||
"data_origin": "new", "backup_retention": 7,
|
||||
})
|
||||
denied := jsonRequest(t, handler, "/api/v1/instances/preview", payload, sessionCookieValue, "")
|
||||
assertStatus(t, denied, http.StatusForbidden)
|
||||
preview := jsonRequest(t, handler, "/api/v1/instances/preview", payload, sessionCookieValue, session.CSRFToken)
|
||||
assertStatus(t, preview, http.StatusOK)
|
||||
if !strings.Contains(preview.Body.String(), snapshots[0].Digest) {
|
||||
t.Fatalf("preview response = %s", preview.Body.String())
|
||||
}
|
||||
trailingJSON := jsonRequest(t, handler, "/api/v1/instances/preview", append(payload, []byte("{}")...), sessionCookieValue, session.CSRFToken)
|
||||
assertStatus(t, trailingJSON, http.StatusBadRequest)
|
||||
draft := jsonRequest(t, handler, "/api/v1/instances/drafts", payload, sessionCookieValue, session.CSRFToken)
|
||||
assertStatus(t, draft, http.StatusCreated)
|
||||
var count int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM instances WHERE lifecycle_state='draft'").Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("draft count = %d, error = %v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapAuthenticationAndLogoutFlow(t *testing.T) {
|
||||
handler := testHandler(t)
|
||||
|
||||
@@ -163,6 +226,17 @@ func formRequest(t *testing.T, handler http.Handler, target string, values url.V
|
||||
return response
|
||||
}
|
||||
|
||||
func jsonRequest(t *testing.T, handler http.Handler, target string, body []byte, session *http.Cookie, csrf string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(http.MethodPost, target, bytes.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-CSRF-Token", csrf)
|
||||
request.AddCookie(session)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func namedCookie(t *testing.T, response *httptest.ResponseRecorder, name string) *http.Cookie {
|
||||
t.Helper()
|
||||
for _, cookie := range response.Result().Cookies() {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
CREATE TABLE templates (
|
||||
id TEXT PRIMARY KEY,
|
||||
origin TEXT NOT NULL,
|
||||
trust_status TEXT NOT NULL,
|
||||
active_version TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE template_versions (
|
||||
template_id TEXT NOT NULL REFERENCES templates(id) ON DELETE RESTRICT,
|
||||
version TEXT NOT NULL,
|
||||
schema_version INTEGER NOT NULL,
|
||||
canonical_yaml TEXT NOT NULL,
|
||||
digest TEXT NOT NULL,
|
||||
game_id TEXT NOT NULL,
|
||||
game_name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (template_id, version),
|
||||
UNIQUE (digest)
|
||||
);
|
||||
|
||||
CREATE TABLE instances (
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
template_id TEXT NOT NULL,
|
||||
template_version TEXT NOT NULL,
|
||||
template_digest TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
lifecycle_state TEXT NOT NULL CHECK (lifecycle_state = 'draft'),
|
||||
preview_json TEXT NOT NULL,
|
||||
plan_digest TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (template_id, template_version) REFERENCES template_versions(template_id, version) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE INDEX instances_template_idx ON instances(template_id, template_version);
|
||||
@@ -0,0 +1,9 @@
|
||||
// Package specs exposes the checked-in machine-readable contracts.
|
||||
package specs
|
||||
|
||||
import "embed"
|
||||
|
||||
// Files contains the versioned JSON Schemas used at runtime.
|
||||
//
|
||||
//go:embed *.schema.json
|
||||
var Files embed.FS
|
||||
Reference in New Issue
Block a user