feat: add validated catalog and deployment previews

This commit is contained in:
2026-08-06 21:00:42 +00:00
parent 6e22a70f1c
commit 6a83216bf8
18 changed files with 1214 additions and 10 deletions
+1 -1
View File
@@ -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
+9
View File
@@ -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
View File
@@ -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
}
+7
View File
@@ -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.
+4
View File
@@ -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
+10
View File
@@ -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=
+28
View File
@@ -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)
}
+371
View File
@@ -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
}
+60
View File
@@ -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)
}
}
+189
View File
@@ -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
}
+83
View File
@@ -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")
}
}
+112
View File
@@ -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)
}
}
+4 -4
View File
@@ -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
View File
@@ -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
+74
View File
@@ -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() {
+40
View File
@@ -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);
+9
View File
@@ -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