307 lines
13 KiB
Go
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
|
|
}
|