190 lines
7.3 KiB
Go
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
|
|
}
|