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

307 lines
13 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/agentwire"
"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"`
ImportID string `json:"import_id,omitempty"`
CustomLabels string `json:"custom_labels,omitempty"`
DockerUser DockerUser `json:"docker_user"`
ImageTag ImageTag `json:"image_tag"`
PublicBaseURL string `json:"-"`
}
type Preview struct {
Template TemplateReference `json:"template"`
DisplayName string `json:"display_name"`
Slug string `json:"slug"`
Image string `json:"image"`
Entrypoint []string `json:"entrypoint,omitempty"`
Arguments []string `json:"arguments,omitempty"`
StopTimeoutSeconds int `json:"stop_timeout_seconds"`
StartupTimeoutSeconds int `json:"startup_timeout_seconds"`
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"`
Import ImportPreview `json:"import,omitempty"`
CanonicalJSON string `json:"canonical_json"`
PlanDigest string `json:"plan_digest"`
CustomLabels map[string]string `json:"custom_labels"`
GlobalLabels map[string]string `json:"global_labels"`
DockerUser DockerUser `json:"docker_user"`
DockerUserValue string `json:"docker_user_value,omitempty"`
ImageTag ImageTag `json:"image_tag"`
TemplateDefaultTag string `json:"template_default_tag"`
Game GameReference `json:"game"`
}
type GameReference struct {
ID string `json:"id"`
Name string `json:"name"`
IconURL string `json:"icon_url"`
}
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 ImportPreview struct {
ID string `json:"id,omitempty"`
DestinationMount string `json:"destination_mount,omitempty"`
DestinationRelativePath string `json:"destination_relative_path,omitempty"`
}
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)
request.Slug = Slugify(request.DisplayName)
if request.DisplayName == "" || len(request.DisplayName) > 100 || !slugPattern.MatchString(request.Slug) {
return Preview{}, errors.New("invalid display name or slug")
}
customLabels, err := ParseLabels(request.CustomLabels)
if err != nil {
return Preview{}, err
}
if request.DockerUser.Mode == "" {
request.DockerUser.Mode = DockerUserDoGaMa
}
if err := ValidateDockerUser(request.DockerUser); err != nil {
return Preview{}, err
}
tag, err := ValidateImageTag(request.ImageTag, snapshot.Template.Container.Tag)
if err != nil {
return Preview{}, err
}
uid, gid, err := currentUIDGID()
if err != nil && request.DockerUser.Mode == DockerUserDoGaMa {
return Preview{}, err
}
userValue, err := DockerUserValue(request.DockerUser, uid, gid)
if err != nil {
return Preview{}, err
}
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")
}
if request.DataOrigin == "import" && request.ImportID == "" {
return Preview{}, errors.New("validated import is required")
}
if request.DataOrigin == "new" && request.ImportID != "" {
return Preview{}, errors.New("import is only valid for imported data")
}
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 + ":" + tag.Tag,
Entrypoint: append([]string(nil), snapshot.Template.Container.Entrypoint...),
Arguments: append([]string(nil), snapshot.Template.Container.Arguments...),
StopTimeoutSeconds: snapshot.Template.Container.StopTimeoutSeconds,
StartupTimeoutSeconds: snapshot.Template.Healthcheck.StartupTimeoutSeconds,
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},
Import: ImportPreview{ID: request.ImportID, DestinationMount: snapshot.Template.Imports.DestinationMount, DestinationRelativePath: snapshot.Template.Imports.DestinationRelativePath},
CustomLabels: customLabels, DockerUser: request.DockerUser, DockerUserValue: userValue, ImageTag: tag, TemplateDefaultTag: snapshot.Template.Container.Tag,
Game: GameReference{ID: snapshot.Template.Game.ID, Name: snapshot.Template.Game.Name, IconURL: strings.TrimRight(request.PublicBaseURL, "/") + "/public/game-icons/" + snapshot.Template.Game.ID},
}
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
}
// DeploymentPlan converts a persisted preview into the only container plan
// accepted by the restricted agent. The digest binds every privileged field.
func (p Preview) DeploymentPlan(instanceID string) (agentwire.DeploymentPlan, error) {
if p.DockerUser.Mode == "" {
p.DockerUser.Mode = DockerUserDoGaMa
}
if p.DockerUserValue == "" && p.DockerUser.Mode == DockerUserDoGaMa {
uid, gid, userErr := currentUIDGID()
if userErr != nil {
return agentwire.DeploymentPlan{}, userErr
}
p.DockerUserValue, userErr = DockerUserValue(p.DockerUser, uid, gid)
if userErr != nil {
return agentwire.DeploymentPlan{}, userErr
}
}
context := LabelContext{GameName: p.Game.Name, GameID: p.Game.ID, GameIconURL: p.Game.IconURL, InstanceName: p.DisplayName, InstanceID: instanceID, InstanceSlug: p.Slug, ServerName: p.DisplayName}
global, err := ResolveLabels(p.GlobalLabels, context)
if err != nil {
return agentwire.DeploymentPlan{}, err
}
local, err := ResolveLabels(p.CustomLabels, context)
if err != nil {
return agentwire.DeploymentPlan{}, err
}
plan := agentwire.DeploymentPlan{
SchemaVersion: agentwire.DeploymentPlanVersion,
InstanceID: instanceID,
TemplateID: p.Template.ID, TemplateVersion: p.Template.Version, TemplateDigest: p.Template.Digest,
Image: p.Image, Entrypoint: append([]string(nil), p.Entrypoint...), Arguments: append([]string(nil), p.Arguments...),
Labels: MergeLabels(nil, global, local), User: p.DockerUserValue,
Resources: agentwire.PlanResource{CPUCores: p.Resources.CPUCores, MemoryMB: p.Resources.MemoryMB, StorageGB: p.Resources.StorageGB},
StopTimeoutSeconds: p.StopTimeoutSeconds,
}
for _, port := range p.Ports {
plan.Ports = append(plan.Ports, agentwire.PlanPort{ID: port.ID, Protocol: port.Protocol, ContainerPort: port.ContainerPort, HostPort: port.HostPort, Publish: port.Publish})
}
for _, mount := range p.Mounts {
plan.Mounts = append(plan.Mounts, agentwire.PlanMount{ID: mount.ID, HostPath: mount.HostPath, ContainerPath: mount.ContainerPath, ReadOnly: mount.ReadOnly})
}
digest, err := plan.CanonicalDigest()
if err != nil {
return agentwire.DeploymentPlan{}, err
}
plan.PlanDigest = digest
if err := plan.Validate(); err != nil {
return agentwire.DeploymentPlan{}, err
}
return plan, nil
}