diff --git a/README.md b/README.md index d52b8a5..69d7f09 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/catalog/embed.go b/catalog/embed.go new file mode 100644 index 0000000..fc452b2 --- /dev/null +++ b/catalog/embed.go @@ -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 diff --git a/cmd/dogama/main.go b/cmd/dogama/main.go index 5531472..2cea9f0 100644 --- a/cmd/dogama/main.go +++ b/cmd/dogama/main.go @@ -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 } diff --git a/docs/contributing/development.md b/docs/contributing/development.md index 68e9114..94f76bf 100644 --- a/docs/contributing/development.md +++ b/docs/contributing/development.md @@ -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. diff --git a/go.mod b/go.mod index d50b4bb..6197250 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 4aafef3..a417fda 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/catalog/repository.go b/internal/catalog/repository.go new file mode 100644 index 0000000..524b26c --- /dev/null +++ b/internal/catalog/repository.go @@ -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) +} diff --git a/internal/catalog/template.go b/internal/catalog/template.go new file mode 100644 index 0000000..e822f81 --- /dev/null +++ b/internal/catalog/template.go @@ -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 +} diff --git a/internal/catalog/template_test.go b/internal/catalog/template_test.go new file mode 100644 index 0000000..c31c7fc --- /dev/null +++ b/internal/catalog/template_test.go @@ -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) + } +} diff --git a/internal/instance/preview.go b/internal/instance/preview.go new file mode 100644 index 0000000..9e8924d --- /dev/null +++ b/internal/instance/preview.go @@ -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 +} diff --git a/internal/instance/preview_test.go b/internal/instance/preview_test.go new file mode 100644 index 0000000..2a866f6 --- /dev/null +++ b/internal/instance/preview_test.go @@ -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") + } +} diff --git a/internal/persistence/sqlite/catalog.go b/internal/persistence/sqlite/catalog.go new file mode 100644 index 0000000..b070d5b --- /dev/null +++ b/internal/persistence/sqlite/catalog.go @@ -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 +} diff --git a/internal/persistence/sqlite/catalog_test.go b/internal/persistence/sqlite/catalog_test.go new file mode 100644 index 0000000..989b2b3 --- /dev/null +++ b/internal/persistence/sqlite/catalog_test.go @@ -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) + } +} diff --git a/internal/persistence/sqlite/store_test.go b/internal/persistence/sqlite/store_test.go index 9edaf0a..8e9cf3b 100644 --- a/internal/persistence/sqlite/store_test.go +++ b/internal/persistence/sqlite/store_test.go @@ -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) } } diff --git a/internal/web/server.go b/internal/web/server.go index 2e00146..9a5c9e5 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -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 diff --git a/internal/web/server_test.go b/internal/web/server_test.go index b918f70..6c64256 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -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() { diff --git a/migrations/0002_catalog_instances.sql b/migrations/0002_catalog_instances.sql new file mode 100644 index 0000000..ecdcbb0 --- /dev/null +++ b/migrations/0002_catalog_instances.sql @@ -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); diff --git a/specs/embed.go b/specs/embed.go new file mode 100644 index 0000000..96a4a38 --- /dev/null +++ b/specs/embed.go @@ -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