Files
DoGaMa-serv/internal/instance/preview.go
T

190 lines
7.3 KiB
Go

// 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
}