feat(instances): add Docker labels, user IDs, image tags and deferred recreation

This commit is contained in:
2026-08-07 20:54:19 +02:00
parent 5783474955
commit c820c9c0e0
30 changed files with 1056 additions and 49 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+2
View File
@@ -69,6 +69,8 @@ Before create or replace, the agent verifies:
- port protocols and container ports match the template and host ports do not conflict;
- resource limits are present and within administrator limits;
- labels use the reserved namespace and cannot be overridden;
- custom labels are bounded, may not use either `dogama.*` or the internal `io.dogama.*` namespace, and are merged before immutable technical labels;
- the optional Docker `User` is either an already validated numeric `UID:GID` value or omitted so the image `USER` applies;
- only approved DoGaMa networks are attached.
The canonical plan digest alone is not treated as approval. The agent embeds and
+4 -1
View File
@@ -14,7 +14,7 @@ SQLite is authoritative for product state. Runtime Docker state is reconciled in
| `template_versions` | Immutable validated snapshots | template_id, version, schema_version, canonical_yaml, digest |
| `modules` | Module identity and trust | id, source, trust_status, active_version |
| `module_versions` | Immutable installed artifacts | module_id, version, manifest, wasm_digest, path, api_range |
| `instances` | Desired and observed instance state | id, slug, display_name, template snapshot, revision, lifecycle_state, public_host |
| `instances` | Desired and observed instance state | id, derived slug, display_name, template snapshot, revision, lifecycle_state, Docker-user mode/UID/GID, image-tag mode/tag, custom labels, container_config_pending |
| `instance_settings` | Typed non-secret template values | instance_id, field_id, value_json |
| `instance_secrets` | Encrypted secret values | instance_id, field_id, key_version, nonce, ciphertext |
| `instance_ports` | Published and private bindings | instance_id, port_id, host_ip, host_port, container_port, protocol |
@@ -36,6 +36,9 @@ SQLite is authoritative for product state. Runtime Docker state is reconciled in
## Invariants
- IDs are opaque and stable; slugs are unique but mutable only through a controlled rename.
- Instance slugs are derived from display names, transliterated to lowercase ASCII and recalculated on rename; they are never canonical identifiers.
- The Docker-user selection is immutable after creation. Custom UID/GID values exist only for `custom`; DoGaMa never recursively changes file ownership.
- Desired container configuration is stored separately from the applied `plan_digest`; `container_config_pending` covers any replacement-requiring change without feature-specific flags.
- Released template and module versions are immutable. Editing creates a new version or an independent local copy.
- An instance pins a template snapshot and module version; catalog changes do not mutate it silently.
- There is at most one active mutating operation per instance.
+4
View File
@@ -49,6 +49,10 @@ The configurable failure policy is `abort_stop`, `stop_without_backup` or `force
Maintenance mode blocks ordinary user starts and shows an administrator message while preserving manager/admin access. Settings specify `immediate` or `restart_required`; pending restart changes are applied together through a controlled container replacement. Keep a small redacted configuration history.
Docker label and image-tag changes use the same generic desired-versus-applied mechanism. `immediate` stops and replaces the container, restores its prior running/stopped intent and preserves every bind-mounted data path. `next_start` sets `container_config_pending`; the next explicit start pulls the desired image, replaces the container, clears the flag and starts it. A stopped instance remains stopped during immediate replacement.
The Docker user is selected at creation (`dogama`, `custom`, or image-defined) and is never editable afterward because changing it could invalidate persistent-file permissions. Administrators must use backup, new-instance creation and restore to change ownership deliberately; DoGaMa never performs automatic recursive `chown`.
## Crash-loop protection
Track unexpected exits. Default circuit breaker: five restarts within ten minutes disables automatic restart and moves the instance to error. Manual administrator action after diagnosis resets it. Scheduled jobs do not fight this state.
+20
View File
@@ -43,6 +43,26 @@ Image updates are digest-aware. A mutable tag alone is never treated as proof th
The full update sequence and rollback behavior are normative in `docs/domain/instance-lifecycle.md`. Managers may trigger only updates allowed by global/instance policy; administrators choose channels and may pin a digest. Automatic updates remain disabled by default.
## Game-container labels, users and tags
Administrators can define global labels for game-server containers and instance-specific overrides, one `key=value` per line. Empty lines are ignored and only the first `=` separates the key. Instance labels override global labels; DoGaMa's technical labels always win. Both `dogama.*` and `io.dogama.*` are reserved.
Values support only `{{game.name}}`, `{{game.id}}`, `{{game.icon_url}}`, `{{instance.name}}`, `{{instance.id}}`, `{{instance.slug}}` and `{{server.name}}`. Unknown or malformed variables fail validation; this is substitution, not a general template language. For example:
```text
glance.name={{instance.name}}
glance.icon={{game.icon_url}}
glance.parent=DoGaMa
```
`{{game.icon_url}}` resolves to the unauthenticated, read-only `/public/game-icons/{game-id}` route. The route serves only embedded reviewed raster content with an explicit MIME type and cache policy; it is not a public catalog or administration API.
At creation, the Docker user is either the DoGaMa process UID/GID (default), an explicitly validated numeric UID/GID, or omitted to use the image-defined user. An image without `USER` may therefore run as root. The selection is immutable after creation.
The template's declared tag is the `tracked` default. An administrator may instead select a syntactically validated `pinned` tag and later return to tracked mode. Pinned means an explicitly selected mutable tag, not a digest: publishers can republish the same tag. Manual SHA-256 digest management is outside this milestone.
Label and tag changes can apply immediately or at the next start. Immediate application disconnects players and recreates only the container; bind-mounted persistent data remains. Deferred application uses the generic `container_config_pending` state.
## Template and module updates
- Updating a catalog template creates a new immutable version; instances remain pinned.
@@ -32,6 +32,7 @@
| CSRF/session theft | Secure HttpOnly SameSite cookies, CSRF token, TLS guidance, session rotation/revocation and idle/absolute expiry |
| Password attack | Modern password hashing, rate limits, backoff, generic errors, repeated-failure audit/notification |
| Supply-chain substitution | Immutable version snapshots, checksums, optional signatures/trust labels, digest-pinned images, controlled activation |
| Label/template injection | Structured key/value parsing, reserved namespaces, explicit substitution allowlist, no arbitrary template execution |
| Destructive mistake | Preview, recent authentication, typed-name confirmation, pre-restore/update backups and recoverable workflows |
| Resource exhaustion | Upload/extraction limits, job concurrency, per-instance locks, Docker limits, disk checks, notification/module bounds |
| Replay/race | Signed nonce/timestamp agent calls, idempotency keys, optimistic revisions and durable operation phases |
+4
View File
@@ -54,5 +54,9 @@ Restore shows overwritten data, safety-backup behavior and server downtime. It r
Global screens cover users, public address, approved storage roots (displayed, bootstrap-controlled where appropriate), catalog/modules, notification channels, audit retention/default 30 days, upload limits, disk thresholds and safety defaults.
The game-container label editor is a multiline `key=value` field with one label per line, the complete allowed-variable list, and explicit `apply immediately` versus `apply on next start` choices. Immediate application confirms that affected containers stop and are recreated, connected players disconnect, persistent data remains, and displays affected/running counts when known.
Instance creation includes Docker-user mode, conditional custom UID/GID, tracked/pinned image tag and optional labels. Existing-instance advanced configuration displays the Docker user read-only with the backup/new-instance/restore migration explanation, permits label and tag changes with the same application choices, and visibly reports `container_config_pending`.
The audit viewer is compact and filterable by time, actor, instance, action and outcome. It is not a raw log console.
+1 -1
View File
@@ -10,6 +10,7 @@ require (
github.com/tetratelabs/wazero v1.11.0
golang.org/x/crypto v0.53.0
golang.org/x/sys v0.47.0
golang.org/x/text v0.38.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.56.0
)
@@ -20,7 +21,6 @@ require (
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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
+15 -3
View File
@@ -141,6 +141,7 @@ func (d *dockerRuntime) Create(ctx context.Context, plan agentwire.DeploymentPla
pidsLimit := int64(512)
payload := struct {
Image string `json:"Image"`
User string `json:"User,omitempty"`
Entrypoint []string `json:"Entrypoint,omitempty"`
Cmd []string `json:"Cmd,omitempty"`
Labels map[string]string `json:"Labels"`
@@ -157,12 +158,12 @@ func (d *dockerRuntime) Create(ctx context.Context, plan agentwire.DeploymentPla
RestartPolicy map[string]string `json:"RestartPolicy"`
} `json:"HostConfig"`
}{
Image: plan.Image, Entrypoint: plan.Entrypoint, Cmd: plan.Arguments,
Labels: map[string]string{
Image: plan.Image, User: plan.User, Entrypoint: plan.Entrypoint, Cmd: plan.Arguments,
Labels: mergeDockerLabels(plan.Labels, map[string]string{
"io.dogama.managed": "true", "io.dogama.instance-id": plan.InstanceID,
"io.dogama.template-id": plan.TemplateID, "io.dogama.template-version": plan.TemplateVersion,
"io.dogama.plan-digest": plan.PlanDigest,
},
}),
ExposedPorts: exposed,
}
payload.HostConfig.Binds = binds
@@ -190,6 +191,17 @@ func (d *dockerRuntime) Create(ctx context.Context, plan agentwire.DeploymentPla
return created.ID, nil
}
func mergeDockerLabels(custom, technical map[string]string) map[string]string {
result := make(map[string]string, len(custom)+len(technical))
for key, value := range custom {
result[key] = value
}
for key, value := range technical {
result[key] = value
}
return result
}
func (d *dockerRuntime) cleanupPartialCreate(ctx context.Context, name string, plan agentwire.DeploymentPlan) {
inspection, err := d.Inspect(ctx, name)
if err != nil || inspection.Labels["io.dogama.managed"] != "true" || inspection.Labels["io.dogama.instance-id"] != plan.InstanceID || inspection.Labels["io.dogama.plan-digest"] != plan.PlanDigest {
+5 -1
View File
@@ -69,7 +69,7 @@ func TestDockerRuntimeCreatesFixedSecurityBaseline(t *testing.T) {
plan := agentwire.DeploymentPlan{
InstanceID: "abcdefghijklmnopqrstuvwx", TemplateID: "palworld-official", TemplateVersion: "1.0.0", PlanDigest: strings.Repeat("a", 64), Image: "example.invalid/game:1",
Ports: []agentwire.PlanPort{{ID: "game", Protocol: "udp", ContainerPort: 8211, HostPort: 38211, Publish: true}},
Mounts: []agentwire.PlanMount{{ID: "saved", HostPath: "/srv/games/saved", ContainerPath: "/game/saved"}}, Resources: agentwire.PlanResource{CPUCores: 2, MemoryMB: 1024, StorageGB: 10},
Mounts: []agentwire.PlanMount{{ID: "saved", HostPath: "/srv/games/saved", ContainerPath: "/game/saved"}}, Resources: agentwire.PlanResource{CPUCores: 2, MemoryMB: 1024, StorageGB: 10}, Labels: map[string]string{"dashboard.name": "Summer"}, User: "1000:1001",
}
if err := runtime.CheckPorts(context.Background(), plan.Ports); err != nil {
t.Fatal(err)
@@ -82,6 +82,7 @@ func TestDockerRuntimeCreatesFixedSecurityBaseline(t *testing.T) {
t.Fatalf("container ID = %q", id)
}
var payload struct {
User string `json:"User"`
Labels map[string]string `json:"Labels"`
HostConfig struct {
NetworkMode string `json:"NetworkMode"`
@@ -100,6 +101,9 @@ func TestDockerRuntimeCreatesFixedSecurityBaseline(t *testing.T) {
if payload.Labels["io.dogama.instance-id"] != plan.InstanceID || payload.Labels["io.dogama.plan-digest"] != plan.PlanDigest {
t.Fatalf("binding labels = %#v", payload.Labels)
}
if payload.Labels["dashboard.name"] != "Summer" || payload.User != "1000:1001" {
t.Fatalf("custom Docker configuration = %#v user=%q", payload.Labels, payload.User)
}
}
func TestDockerPingerDoesNotExposeConnectionDetails(t *testing.T) {
+3 -1
View File
@@ -6,6 +6,7 @@ import (
"io/fs"
"path"
"reflect"
"strings"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
@@ -71,7 +72,8 @@ func (p *PlanPolicy) Validate(plan agentwire.DeploymentPlan) error {
return errors.New("unknown template snapshot")
}
template := snapshot.Template
if plan.Image != template.Container.Image+":"+template.Container.Tag || !reflect.DeepEqual(plan.Entrypoint, template.Container.Entrypoint) || !reflect.DeepEqual(plan.Arguments, template.Container.Arguments) || plan.StopTimeoutSeconds != template.Container.StopTimeoutSeconds {
imagePrefix := template.Container.Image + ":"
if !strings.HasPrefix(plan.Image, imagePrefix) || !reflect.DeepEqual(plan.Entrypoint, template.Container.Entrypoint) || !reflect.DeepEqual(plan.Arguments, template.Container.Arguments) || plan.StopTimeoutSeconds != template.Container.StopTimeoutSeconds {
return errors.New("container plan differs from template")
}
if plan.Resources.CPUCores < template.Requirements.Minimum.CPUCores || plan.Resources.MemoryMB < template.Requirements.Minimum.MemoryMB || plan.Resources.StorageGB < template.Requirements.Minimum.StorageGB {
+14
View File
@@ -115,6 +115,20 @@ func (r *Registry) Remove(instanceID string) error {
return r.saveLocked(instances)
}
// Replace atomically persists a new binding for an already registered instance.
func (r *Registry) Replace(entry RegisteredInstance) error {
r.mu.Lock()
defer r.mu.Unlock()
instances := append([]RegisteredInstance(nil), r.instances...)
for index := range instances {
if instances[index].InstanceID == entry.InstanceID {
instances[index] = entry
return r.saveLocked(instances)
}
}
return errors.New("instance registration not found")
}
func (r *Registry) load() error {
info, err := os.Lstat(r.path)
if err != nil {
+55
View File
@@ -52,6 +52,7 @@ func NewHandlerWithDiskChecker(authenticator *Authenticator, paths *PathPolicy,
mux.HandleFunc("GET /v1/instances", server.listInstances)
mux.HandleFunc("POST /v1/check-ports", server.checkPorts)
mux.HandleFunc("POST /v1/instances", server.createInstance)
mux.HandleFunc("PUT /v1/instances/{id}", server.replaceInstance)
mux.HandleFunc("GET /v1/instances/{id}", server.inspectInstance)
mux.HandleFunc("GET /v1/instances/{id}/stats", server.instanceStats)
mux.HandleFunc("POST /v1/instances/{id}/start", server.startInstance)
@@ -190,6 +191,60 @@ func (s *service) createInstance(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, agentwire.InstanceState{InstanceID: plan.InstanceID, ContainerID: containerID, PlanDigest: plan.PlanDigest, Health: "stopped"})
}
func (s *service) replaceInstance(w http.ResponseWriter, r *http.Request) {
var plan agentwire.DeploymentPlan
id := r.PathValue("id")
if decodeJSON(r.Body, &plan) != nil || plan.InstanceID != id || s.plans.Validate(plan) != nil {
writeProblem(w, http.StatusUnprocessableEntity, "invalid_plan", "The replacement plan is invalid.")
return
}
entry, ok := s.registry.Get(id)
if !ok {
writeProblem(w, http.StatusNotFound, "registration_not_found", "The instance is not registered.")
return
}
oldState, err := s.boundState(r.Context(), entry)
if err != nil {
writeProblem(w, http.StatusConflict, "registration_mismatch", "The registered container binding is invalid.")
return
}
for index := range plan.Mounts {
canonical, pathErr := s.paths.Resolve(plan.Mounts[index].HostPath)
if pathErr != nil {
writeProblem(w, http.StatusUnprocessableEntity, "path_not_allowed", "A deployment path is not allowed.")
return
}
plan.Mounts[index].HostPath = canonical
}
assets, err := s.prepareAssets(plan)
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "asset_prepare_failed", "Approved template assets could not be prepared.")
return
}
if oldState.Running {
if err := s.docker.Stop(r.Context(), entry.ContainerID, plan.StopTimeoutSeconds); err != nil {
writeProblem(w, http.StatusBadGateway, "container_stop_failed", "The previous container could not be stopped.")
return
}
}
if err := s.docker.Delete(r.Context(), entry.ContainerID); err != nil {
writeProblem(w, http.StatusBadGateway, "container_delete_failed", "The previous container could not be removed.")
return
}
containerID, err := s.docker.Create(r.Context(), plan, assets)
if err != nil {
writeProblem(w, http.StatusBadGateway, "container_replace_failed", "The replacement container could not be created; persistent data was preserved.")
return
}
replacement := RegisteredInstance{InstanceID: id, ContainerID: containerID, PlanDigest: plan.PlanDigest}
if err := s.registry.Replace(replacement); err != nil {
_ = s.docker.Delete(r.Context(), containerID)
writeProblem(w, http.StatusInternalServerError, "registration_failed", "The replacement registration could not be persisted.")
return
}
writeJSON(w, http.StatusOK, agentwire.InstanceState{InstanceID: id, ContainerID: containerID, PlanDigest: plan.PlanDigest, Health: "stopped"})
}
func (s *service) inspectInstance(w http.ResponseWriter, r *http.Request) {
entry, ok := s.registration(w, r.PathValue("id"))
if !ok {
+6
View File
@@ -121,6 +121,12 @@ func (c *Client) CreateInstance(ctx context.Context, plan agentwire.DeploymentPl
return state, err
}
func (c *Client) ReplaceInstance(ctx context.Context, plan agentwire.DeploymentPlan) (agentwire.InstanceState, error) {
var state agentwire.InstanceState
err := c.do(ctx, http.MethodPut, instancePath(plan.InstanceID), plan, &state)
return state, err
}
func (c *Client) InspectInstance(ctx context.Context, instanceID string) (agentwire.InstanceState, error) {
var state agentwire.InstanceState
err := c.do(ctx, http.MethodGet, instancePath(instanceID), nil, &state)
+36 -13
View File
@@ -9,6 +9,7 @@ import (
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
)
@@ -22,19 +23,21 @@ var (
)
type DeploymentPlan struct {
SchemaVersion int `json:"schema_version"`
InstanceID string `json:"instance_id"`
TemplateID string `json:"template_id"`
TemplateVersion string `json:"template_version"`
TemplateDigest string `json:"template_digest"`
Image string `json:"image"`
Entrypoint []string `json:"entrypoint,omitempty"`
Arguments []string `json:"arguments,omitempty"`
Ports []PlanPort `json:"ports"`
Mounts []PlanMount `json:"mounts"`
Resources PlanResource `json:"resources"`
StopTimeoutSeconds int `json:"stop_timeout_seconds"`
PlanDigest string `json:"plan_digest"`
SchemaVersion int `json:"schema_version"`
InstanceID string `json:"instance_id"`
TemplateID string `json:"template_id"`
TemplateVersion string `json:"template_version"`
TemplateDigest string `json:"template_digest"`
Image string `json:"image"`
Entrypoint []string `json:"entrypoint,omitempty"`
Arguments []string `json:"arguments,omitempty"`
Ports []PlanPort `json:"ports"`
Mounts []PlanMount `json:"mounts"`
Resources PlanResource `json:"resources"`
StopTimeoutSeconds int `json:"stop_timeout_seconds"`
Labels map[string]string `json:"labels,omitempty"`
User string `json:"user,omitempty"`
PlanDigest string `json:"plan_digest"`
}
type PlanPort struct {
@@ -88,6 +91,26 @@ func (p DeploymentPlan) Validate() error {
if p.StopTimeoutSeconds < 5 || p.StopTimeoutSeconds > 900 || len(p.Ports) > 32 || len(p.Mounts) == 0 || len(p.Mounts) > 16 {
return errors.New("invalid deployment plan limits")
}
if len(p.Labels) > 64 || len(p.User) > 32 {
return errors.New("invalid deployment plan container configuration")
}
for key, value := range p.Labels {
lower := strings.ToLower(key)
if key == "" || len(key) > 255 || len(value) > 4096 || strings.HasPrefix(lower, "dogama.") || strings.HasPrefix(lower, "io.dogama.") {
return errors.New("invalid deployment plan label")
}
}
if p.User != "" {
parts := strings.Split(p.User, ":")
if len(parts) != 2 {
return errors.New("invalid deployment plan user")
}
for _, part := range parts {
if _, err := strconv.ParseUint(part, 10, 32); err != nil {
return errors.New("invalid deployment plan user")
}
}
}
portIDs := make(map[string]struct{}, len(p.Ports))
hostPorts := make(map[string]struct{})
for _, port := range p.Ports {
+16
View File
@@ -27,3 +27,19 @@ func TestDeploymentPlanDigestRejectsPrivilegedFieldSubstitution(t *testing.T) {
t.Fatal("image substitution preserved a valid binding")
}
}
func TestDeploymentPlanRejectsReservedLabelsAndInvalidUser(t *testing.T) {
plan := DeploymentPlan{SchemaVersion: DeploymentPlanVersion, InstanceID: "abcdefghijklmnopqrstuvwx", TemplateID: "palworld-official", TemplateVersion: "1.0.0", TemplateDigest: strings.Repeat("a", 64), Image: "example.invalid/game:1", Ports: []PlanPort{{ID: "game", Protocol: "udp", ContainerPort: 8211, HostPort: 38211, Publish: true}}, Mounts: []PlanMount{{ID: "saved", HostPath: filepath.Join(string(filepath.Separator), "srv", "games", "saved"), ContainerPath: "/game/saved"}}, Resources: PlanResource{CPUCores: 2, MemoryMB: 1024, StorageGB: 10}, StopTimeoutSeconds: 30, Labels: map[string]string{"dogama.managed": "false"}, User: "1000:1000"}
digest, _ := plan.CanonicalDigest()
plan.PlanDigest = digest
if err := plan.Validate(); err == nil {
t.Fatal("reserved label accepted")
}
plan.Labels = map[string]string{"dashboard.name": "server"}
plan.User = "root"
digest, _ = plan.CanonicalDigest()
plan.PlanDigest = digest
if err := plan.Validate(); err == nil {
t.Fatal("non-numeric Docker user accepted")
}
}
+91
View File
@@ -0,0 +1,91 @@
package instance
import (
"context"
"errors"
"strings"
)
type ConfigurationRepository interface {
GetGlobalLabels(context.Context) (map[string]string, error)
SetGlobalLabels(context.Context, map[string]string, bool) (affected int, running int, err error)
SaveInstanceConfiguration(context.Context, string, Preview, bool) error
ClearContainerConfigPending(context.Context, string, string) error
}
type ConfigurationStatus struct {
InstanceID string `json:"instance_id"`
ContainerConfigPending bool `json:"container_config_pending"`
Preview Preview `json:"configuration"`
}
func (s *LifecycleService) Configure(ctx context.Context, instanceID, labels string, tag ImageTag, immediate bool) (OperationResult, error) {
repository, ok := s.repository.(ConfigurationRepository)
if !ok {
return OperationResult{}, errors.New("container configuration is unavailable")
}
return s.exclusive(instanceID, func() (OperationResult, error) {
current, err := s.repository.GetInstance(ctx, instanceID)
if err != nil {
return OperationResult{}, err
}
parsed, err := ParseLabels(labels)
if err != nil {
return OperationResult{}, err
}
defaultTag := current.Preview.TemplateDefaultTag
if defaultTag == "" {
parts := strings.Split(current.Preview.Image, ":")
defaultTag = parts[len(parts)-1]
}
validated, err := ValidateImageTag(tag, defaultTag)
if err != nil {
return OperationResult{}, err
}
preview := current.Preview
preview.CustomLabels, preview.ImageTag = parsed, validated
base := preview.Image[:strings.LastIndex(preview.Image, ":")]
preview.Image = base + ":" + validated.Tag
if err := repository.SaveInstanceConfiguration(ctx, instanceID, preview, !immediate); err != nil {
return OperationResult{}, err
}
if !immediate || current.ContainerID == "" {
updated, _ := s.repository.GetInstance(ctx, instanceID)
return resultFrom(updated, ""), nil
}
agent, ok := s.agent.(replacementAgent)
if !ok {
return OperationResult{}, errors.New("container replacement is unavailable")
}
operationID, err := operationToken()
if err != nil {
return OperationResult{}, err
}
current, err = s.repository.BeginOperation(ctx, operationID, instanceID, "restart", "update")
if err != nil {
return OperationResult{}, err
}
plan, err := preview.DeploymentPlan(instanceID)
if err != nil {
return s.fail(ctx, operationID, instanceID, "invalid_plan", err)
}
state, err := agent.ReplaceInstance(ctx, plan)
if err != nil {
return s.fail(ctx, operationID, instanceID, "agent_replace_failed", err)
}
if current.DesiredRunning {
state, err = s.agent.StartInstance(ctx, instanceID)
if err != nil {
return s.fail(ctx, operationID, instanceID, "agent_start_failed", err)
}
}
lifecycle, observed := stateToLifecycle(state)
if err := s.repository.FinishOperation(ctx, operationID, lifecycle, observed, state.ContainerID, plan.PlanDigest, current.DesiredRunning, ""); err != nil {
return OperationResult{}, err
}
if err := repository.ClearContainerConfigPending(ctx, instanceID, plan.PlanDigest); err != nil {
return OperationResult{}, err
}
return OperationResult{OperationID: operationID, InstanceID: instanceID, State: lifecycle, Observed: observed, ContainerID: state.ContainerID, AgentState: state}, nil
})
}
+216
View File
@@ -0,0 +1,216 @@
package instance
import (
"errors"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
)
const (
DockerUserDoGaMa = "dogama"
DockerUserCustom = "custom"
DockerUserImage = "image"
ImageTagTracked = "tracked"
ImageTagPinned = "pinned"
)
var (
labelKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*(?:/[A-Za-z0-9][A-Za-z0-9_.-]*)?$`)
tagPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$`)
variablePattern = regexp.MustCompile(`\{\{([^{}]+)\}\}`)
)
var AllowedLabelVariables = []string{
"game.name", "game.id", "game.icon_url", "instance.name", "instance.id", "instance.slug", "server.name",
}
type DockerUser struct {
Mode string `json:"mode"`
UID *uint32 `json:"uid,omitempty"`
GID *uint32 `json:"gid,omitempty"`
}
type ImageTag struct {
Mode string `json:"mode"`
Tag string `json:"tag"`
}
type ContainerConfiguration struct {
Labels map[string]string `json:"labels"`
DockerUser DockerUser `json:"docker_user"`
ImageTag ImageTag `json:"image_tag"`
}
type LabelContext struct {
GameName, GameID, GameIconURL string
InstanceName, InstanceID, InstanceSlug string
ServerName string
}
func ParseLabels(input string) (map[string]string, error) {
labels := make(map[string]string)
for index, raw := range strings.Split(strings.ReplaceAll(input, "\r\n", "\n"), "\n") {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
separator := strings.IndexByte(line, '=')
if separator < 1 {
return nil, fmt.Errorf("label line %d must use key=value", index+1)
}
key := strings.TrimSpace(line[:separator])
if !labelKeyPattern.MatchString(key) {
return nil, fmt.Errorf("label line %d has an invalid key", index+1)
}
lower := strings.ToLower(key)
if strings.HasPrefix(lower, "dogama.") || strings.HasPrefix(lower, "io.dogama.") {
return nil, fmt.Errorf("label line %d uses a reserved DoGaMa key", index+1)
}
value := line[separator+1:]
if err := ValidateLabelTemplate(value); err != nil {
return nil, fmt.Errorf("label line %d: %w", index+1, err)
}
labels[key] = value
}
return labels, nil
}
func ValidateLabelTemplate(value string) error {
allowed := make(map[string]bool, len(AllowedLabelVariables))
for _, variable := range AllowedLabelVariables {
allowed[variable] = true
}
for _, match := range variablePattern.FindAllStringSubmatch(value, -1) {
if !allowed[match[1]] {
return fmt.Errorf("unknown label variable %q", match[1])
}
}
withoutKnown := variablePattern.ReplaceAllString(value, "")
if strings.Contains(withoutKnown, "{{") || strings.Contains(withoutKnown, "}}") {
return errors.New("invalid label variable syntax")
}
return nil
}
func ResolveLabels(labels map[string]string, context LabelContext) (map[string]string, error) {
values := map[string]string{
"game.name": context.GameName, "game.id": context.GameID, "game.icon_url": context.GameIconURL,
"instance.name": context.InstanceName, "instance.id": context.InstanceID, "instance.slug": context.InstanceSlug,
"server.name": context.ServerName,
}
result := make(map[string]string, len(labels))
for key, value := range labels {
if err := ValidateLabelTemplate(value); err != nil {
return nil, err
}
result[key] = variablePattern.ReplaceAllStringFunc(value, func(token string) string {
name := token[2 : len(token)-2]
return values[name]
})
}
return result, nil
}
func MergeLabels(technical, global, local map[string]string) map[string]string {
result := make(map[string]string, len(technical)+len(global)+len(local))
for key, value := range global {
result[key] = value
}
for key, value := range local {
result[key] = value
}
for key, value := range technical {
result[key] = value
}
return result
}
func FormatLabels(labels map[string]string) string {
keys := make([]string, 0, len(labels))
for key := range labels {
keys = append(keys, key)
}
sort.Strings(keys)
lines := make([]string, 0, len(keys))
for _, key := range keys {
lines = append(lines, key+"="+labels[key])
}
return strings.Join(lines, "\n")
}
func Slugify(value string) string {
decomposed := norm.NFD.String(strings.ToLower(strings.TrimSpace(value)))
var builder strings.Builder
separator := false
for _, r := range decomposed {
if unicode.Is(unicode.Mn, r) {
continue
}
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
if separator && builder.Len() > 0 {
builder.WriteByte('-')
}
builder.WriteRune(r)
separator = false
} else {
separator = true
}
}
return strings.Trim(builder.String(), "-")
}
func ValidateDockerUser(user DockerUser) error {
switch user.Mode {
case DockerUserDoGaMa, DockerUserImage:
if user.UID != nil || user.GID != nil {
return errors.New("UID and GID are only valid in custom mode")
}
case DockerUserCustom:
if user.UID == nil || user.GID == nil {
return errors.New("custom Docker user requires UID and GID")
}
default:
return errors.New("invalid Docker user mode")
}
return nil
}
func DockerUserValue(user DockerUser, processUID, processGID uint32) (string, error) {
if err := ValidateDockerUser(user); err != nil {
return "", err
}
switch user.Mode {
case DockerUserImage:
return "", nil
case DockerUserDoGaMa:
return strconv.FormatUint(uint64(processUID), 10) + ":" + strconv.FormatUint(uint64(processGID), 10), nil
default:
return strconv.FormatUint(uint64(*user.UID), 10) + ":" + strconv.FormatUint(uint64(*user.GID), 10), nil
}
}
func ValidateImageTag(value ImageTag, defaultTag string) (ImageTag, error) {
if value.Mode == "" {
value.Mode = ImageTagTracked
}
switch value.Mode {
case ImageTagTracked:
value.Tag = defaultTag
case ImageTagPinned:
if !tagPattern.MatchString(value.Tag) {
return ImageTag{}, errors.New("invalid pinned Docker image tag")
}
default:
return ImageTag{}, errors.New("invalid Docker image tag mode")
}
if !tagPattern.MatchString(value.Tag) {
return ImageTag{}, errors.New("invalid Docker image tag")
}
return value, nil
}
@@ -0,0 +1,62 @@
package instance
import "testing"
func TestLabelsAndVariables(t *testing.T) {
labels, err := ParseLabels("\n glance.name={{instance.name}}\nquery=a=b=c\n")
if err != nil || labels["query"] != "a=b=c" {
t.Fatalf("ParseLabels = %#v, %v", labels, err)
}
if _, err := ParseLabels("missing"); err == nil {
t.Fatal("line without separator accepted")
}
if _, err := ParseLabels("=empty"); err == nil {
t.Fatal("empty key accepted")
}
if _, err := ParseLabels("dogama.managed=false"); err == nil {
t.Fatal("reserved key accepted")
}
if _, err := ParseLabels("io.dogama.managed=false"); err == nil {
t.Fatal("technical key accepted")
}
if _, err := ParseLabels("x={{unknown}}"); err == nil {
t.Fatal("unknown variable accepted")
}
resolved, err := ResolveLabels(map[string]string{"x": "{{game.name}}/{{game.id}}/{{game.icon_url}}/{{instance.name}}/{{instance.id}}/{{instance.slug}}/{{server.name}}"}, LabelContext{GameName: "Palworld", GameID: "palworld", GameIconURL: "/public/game-icons/palworld", InstanceName: "Summer", InstanceID: "id", InstanceSlug: "summer", ServerName: "Server"})
if err != nil || resolved["x"] != "Palworld/palworld//public/game-icons/palworld/Summer/id/summer/Server" {
t.Fatalf("ResolveLabels = %#v, %v", resolved, err)
}
merged := MergeLabels(map[string]string{"io.dogama.managed": "true"}, map[string]string{"x": "global"}, map[string]string{"x": "local", "io.dogama.managed": "false"})
if merged["x"] != "local" || merged["io.dogama.managed"] != "true" {
t.Fatalf("MergeLabels = %#v", merged)
}
}
func TestSlugify(t *testing.T) {
cases := map[string]string{"Mon Serveur": "mon-serveur", "Été 2026": "ete-2026", "PvE / PvP": "pve-pvp", "Serveur !!! Test": "serveur-test", "---Été///PvE###1---": "ete-pve-1"}
for input, expected := range cases {
if actual := Slugify(input); actual != expected {
t.Errorf("Slugify(%q)=%q, want %q", input, actual, expected)
}
}
}
func TestDockerUserAndImageTag(t *testing.T) {
uid, gid := uint32(1000), uint32(1001)
if value, err := DockerUserValue(DockerUser{Mode: DockerUserDoGaMa}, 42, 43); err != nil || value != "42:43" {
t.Fatalf("dogama user = %q, %v", value, err)
}
if value, err := DockerUserValue(DockerUser{Mode: DockerUserCustom, UID: &uid, GID: &gid}, 0, 0); err != nil || value != "1000:1001" {
t.Fatalf("custom user = %q, %v", value, err)
}
if value, err := DockerUserValue(DockerUser{Mode: DockerUserImage}, 0, 0); err != nil || value != "" {
t.Fatalf("image user = %q, %v", value, err)
}
if _, err := ValidateImageTag(ImageTag{Mode: ImageTagPinned, Tag: "bad/tag"}, "stable"); err == nil {
t.Fatal("invalid tag accepted")
}
tracked, err := ValidateImageTag(ImageTag{Mode: ImageTagTracked}, "stable")
if err != nil || tracked.Tag != "stable" {
t.Fatalf("tracked tag = %#v, %v", tracked, err)
}
}
+15 -2
View File
@@ -25,6 +25,7 @@ type StoredInstance struct {
ContainerID string
PlanDigest string
DesiredRunning bool
ContainerConfigPending bool
}
type OperationResult struct {
@@ -33,6 +34,7 @@ type OperationResult struct {
State string `json:"state"`
Observed string `json:"observed_state"`
ContainerID string `json:"container_id,omitempty"`
ContainerConfigPending bool `json:"container_config_pending"`
AgentState agentwire.InstanceState `json:"agent_state,omitempty"`
}
@@ -56,6 +58,8 @@ type LifecycleAgent interface {
GetInstanceStats(context.Context, string) (agentwire.InstanceStats, error)
}
type replacementAgent interface { ReplaceInstance(context.Context, agentwire.DeploymentPlan) (agentwire.InstanceState, error) }
type LifecycleService struct {
repository LifecycleRepository
agent LifecycleAgent
@@ -118,7 +122,16 @@ func (s *LifecycleService) Start(ctx context.Context, instanceID string) (Operat
if err != nil {
return OperationResult{}, err
}
state, err := s.agent.StartInstance(ctx, instanceID)
var state agentwire.InstanceState
if current.ContainerConfigPending {
replacement, ok := s.agent.(replacementAgent)
if !ok { return s.fail(ctx, operationID, instanceID, "container_replace_unavailable", errors.New("container replacement is unavailable")) }
plan, planErr := current.Preview.DeploymentPlan(instanceID)
if planErr != nil { return s.fail(ctx, operationID, instanceID, "invalid_plan", planErr) }
state, err = replacement.ReplaceInstance(ctx, plan)
if err == nil { err = s.repository.(ConfigurationRepository).ClearContainerConfigPending(ctx, instanceID, plan.PlanDigest) }
}
if err == nil { state, err = s.agent.StartInstance(ctx, instanceID) }
if err != nil {
return s.fail(ctx, operationID, instanceID, "agent_start_failed", err)
}
@@ -319,7 +332,7 @@ func stateToLifecycle(state agentwire.InstanceState) (string, string) {
}
func resultFrom(current StoredInstance, operationID string) OperationResult {
return OperationResult{OperationID: operationID, InstanceID: current.ID, State: current.LifecycleState, Observed: current.ObservedState, ContainerID: current.ContainerID}
return OperationResult{OperationID: operationID, InstanceID: current.ID, State: current.LifecycleState, Observed: current.ObservedState, ContainerID: current.ContainerID, ContainerConfigPending: current.ContainerConfigPending}
}
func operationToken() (string, error) {
+69 -4
View File
@@ -28,6 +28,10 @@ type PreviewRequest struct {
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 {
@@ -48,6 +52,19 @@ type Preview struct {
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 {
@@ -105,9 +122,32 @@ type Repository interface {
// 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")
}
@@ -185,15 +225,17 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e
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,
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},
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")
@@ -215,11 +257,34 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e
// 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,
}
@@ -0,0 +1,9 @@
//go:build unix
package instance
import "golang.org/x/sys/unix"
func currentUIDGID() (uint32, uint32, error) {
return uint32(unix.Getuid()), uint32(unix.Getgid()), nil
}
@@ -0,0 +1,8 @@
//go:build windows
package instance
// Windows is a development/test host only; Linux deployment resolves the real process identity.
func currentUIDGID() (uint32, uint32, error) {
return 1000, 1000, nil
}
+8 -6
View File
@@ -103,8 +103,8 @@ func (r *Repository) CreateDraft(ctx context.Context, draft instance.Draft) erro
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)
_, 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, custom_labels_json, docker_user_mode, docker_uid, docker_gid, image_tag_mode, image_tag, 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, string(mustJSON(draft.Preview.CustomLabels)), draft.Preview.DockerUser.Mode, draft.Preview.DockerUser.UID, draft.Preview.DockerUser.GID, draft.Preview.ImageTag.Mode, draft.Preview.ImageTag.Tag, now, now)
if err != nil {
return fmt.Errorf("create draft instance: %w", err)
}
@@ -112,11 +112,11 @@ func (r *Repository) CreateDraft(ctx context.Context, draft instance.Draft) erro
}
func (r *Repository) GetInstance(ctx context.Context, id string) (instance.StoredInstance, error) {
return scanInstance(r.db.QueryRowContext(ctx, `SELECT id, preview_json, lifecycle_state, observed_state, COALESCE(container_id, ''), plan_digest, desired_running FROM instances WHERE id=? AND deleted_at IS NULL`, id))
return scanInstance(r.db.QueryRowContext(ctx, `SELECT id, preview_json, lifecycle_state, observed_state, COALESCE(container_id, ''), plan_digest, desired_running, container_config_pending FROM instances WHERE id=? AND deleted_at IS NULL`, id))
}
func (r *Repository) ListLifecycleInstances(ctx context.Context) ([]instance.StoredInstance, error) {
rows, err := r.db.QueryContext(ctx, `SELECT id, preview_json, lifecycle_state, observed_state, COALESCE(container_id, ''), plan_digest, desired_running FROM instances WHERE deleted_at IS NULL AND lifecycle_state != 'draft' ORDER BY id`)
rows, err := r.db.QueryContext(ctx, `SELECT id, preview_json, lifecycle_state, observed_state, COALESCE(container_id, ''), plan_digest, desired_running, container_config_pending FROM instances WHERE deleted_at IS NULL AND lifecycle_state != 'draft' ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("list lifecycle instances: %w", err)
}
@@ -138,7 +138,8 @@ func scanInstance(row rowScanner) (instance.StoredInstance, error) {
var value instance.StoredInstance
var previewJSON string
var desired int
err := row.Scan(&value.ID, &previewJSON, &value.LifecycleState, &value.ObservedState, &value.ContainerID, &value.PlanDigest, &desired)
var pending int
err := row.Scan(&value.ID, &previewJSON, &value.LifecycleState, &value.ObservedState, &value.ContainerID, &value.PlanDigest, &desired, &pending)
if errors.Is(err, sql.ErrNoRows) {
return instance.StoredInstance{}, instance.ErrInstanceNotFound
}
@@ -149,6 +150,7 @@ func scanInstance(row rowScanner) (instance.StoredInstance, error) {
return instance.StoredInstance{}, fmt.Errorf("decode instance preview: %w", err)
}
value.DesiredRunning = desired != 0
value.ContainerConfigPending = pending != 0
return value, nil
}
@@ -158,7 +160,7 @@ func (r *Repository) BeginOperation(ctx context.Context, operationID, instanceID
return instance.StoredInstance{}, fmt.Errorf("begin instance operation: %w", err)
}
defer func() { _ = tx.Rollback() }()
current, err := scanInstance(tx.QueryRowContext(ctx, `SELECT id, preview_json, lifecycle_state, observed_state, COALESCE(container_id, ''), plan_digest, desired_running FROM instances WHERE id=? AND deleted_at IS NULL`, instanceID))
current, err := scanInstance(tx.QueryRowContext(ctx, `SELECT id, preview_json, lifecycle_state, observed_state, COALESCE(container_id, ''), plan_digest, desired_running, container_config_pending FROM instances WHERE id=? AND deleted_at IS NULL`, instanceID))
if err != nil {
return instance.StoredInstance{}, err
}
@@ -0,0 +1,116 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
)
const globalLabelsKey = "game_container_labels"
func (r *Repository) GetGlobalLabels(ctx context.Context) (map[string]string, error) {
var body string
err := r.db.QueryRowContext(ctx, `SELECT value_json FROM system_settings WHERE key=?`, globalLabelsKey).Scan(&body)
if errors.Is(err, sql.ErrNoRows) {
return map[string]string{}, nil
}
if err != nil {
return nil, fmt.Errorf("load global game-container labels: %w", err)
}
var labels map[string]string
if err := json.Unmarshal([]byte(body), &labels); err != nil {
return nil, fmt.Errorf("decode global game-container labels: %w", err)
}
return labels, nil
}
func (r *Repository) SetGlobalLabels(ctx context.Context, labels map[string]string, pending bool) (int, int, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return 0, 0, err
}
defer func() { _ = tx.Rollback() }()
now := r.now().UTC().Format(time.RFC3339Nano)
body := string(mustJSON(labels))
if _, err := tx.ExecContext(ctx, `INSERT INTO system_settings(key,value_json,revision,updated_at) VALUES(?,?,1,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json, revision=system_settings.revision+1, updated_at=excluded.updated_at`, globalLabelsKey, body, now); err != nil {
return 0, 0, err
}
rows, err := tx.QueryContext(ctx, `SELECT id, preview_json, desired_running FROM instances WHERE deleted_at IS NULL AND lifecycle_state!='draft'`)
if err != nil {
return 0, 0, err
}
type update struct {
id, body string
running bool
}
var updates []update
for rows.Next() {
var id, previewBody string
var running int
if err := rows.Scan(&id, &previewBody, &running); err != nil {
rows.Close()
return 0, 0, err
}
var preview instance.Preview
if json.Unmarshal([]byte(previewBody), &preview) != nil {
rows.Close()
return 0, 0, errors.New("decode instance configuration")
}
preview.GlobalLabels = labels
updates = append(updates, update{id: id, body: string(mustJSON(preview)), running: running != 0})
}
rows.Close()
running := 0
pendingValue := 0
if pending {
pendingValue = 1
}
for _, update := range updates {
if update.running {
running++
}
if _, err := tx.ExecContext(ctx, `UPDATE instances SET preview_json=?, container_config_pending=?, revision=revision+1, updated_at=? WHERE id=?`, update.body, pendingValue, now, update.id); err != nil {
return 0, 0, err
}
}
if err := tx.Commit(); err != nil {
return 0, 0, err
}
return len(updates), running, nil
}
func (r *Repository) SaveInstanceConfiguration(ctx context.Context, id string, preview instance.Preview, pending bool) error {
body := string(mustJSON(preview))
labels := string(mustJSON(preview.CustomLabels))
pendingValue := 0
if pending {
pendingValue = 1
}
result, err := r.db.ExecContext(ctx, `UPDATE instances SET preview_json=?, custom_labels_json=?, image_tag_mode=?, image_tag=?, container_config_pending=?, revision=revision+1, updated_at=? WHERE id=? AND deleted_at IS NULL`, body, labels, preview.ImageTag.Mode, preview.ImageTag.Tag, pendingValue, r.now().UTC().Format(time.RFC3339Nano), id)
if err != nil {
return fmt.Errorf("save instance container configuration: %w", err)
}
changed, _ := result.RowsAffected()
if changed != 1 {
return instance.ErrInstanceNotFound
}
return nil
}
func (r *Repository) ClearContainerConfigPending(ctx context.Context, id, planDigest string) error {
_, err := r.db.ExecContext(ctx, `UPDATE instances SET container_config_pending=0, plan_digest=?, updated_at=? WHERE id=?`, planDigest, r.now().UTC().Format(time.RFC3339Nano), id)
return err
}
func mustJSON(value any) []byte {
body, err := json.Marshal(value)
if err != nil {
panic(err)
}
return body
}
+250 -15
View File
@@ -14,8 +14,10 @@ import (
"io"
"log/slog"
"net/http"
"regexp"
"time"
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/authorization"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/backup"
@@ -73,10 +75,12 @@ type repository interface {
}
type pageData struct {
Title string
CSRFToken string
Error string
User auth.User
Title string
CSRFToken string
Error string
User auth.User
GlobalLabels string
IsAdmin bool
}
// NewHandler constructs the complete HTTP application.
@@ -119,6 +123,7 @@ func newHandlerWithImports(authService *auth.Service, repository repository, lif
}
mux := http.NewServeMux()
if repository != nil {
mux.HandleFunc("GET /public/game-icons/{gameID}", s.publicGameIcon)
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)
@@ -127,6 +132,8 @@ func newHandlerWithImports(authService *auth.Service, repository repository, lif
mux.HandleFunc("POST /api/v1/installation-requests/{id}/review", s.installationRequestReview)
mux.HandleFunc("GET /api/v1/admin/users", s.userList)
mux.HandleFunc("POST /api/v1/admin/users", s.userCreate)
mux.HandleFunc("GET /api/v1/admin/game-container-labels", s.globalLabelsGet)
mux.HandleFunc("PUT /api/v1/admin/game-container-labels", s.globalLabelsPut)
mux.HandleFunc("GET /api/v1/instances/{id}/memberships", s.membershipList)
mux.HandleFunc("PUT /api/v1/instances/{id}/memberships/{userID}", s.membershipSet)
mux.HandleFunc("DELETE /api/v1/instances/{id}/memberships/{userID}", s.membershipDelete)
@@ -140,6 +147,8 @@ func newHandlerWithImports(authService *auth.Service, repository repository, lif
mux.HandleFunc("POST /api/v1/instances/{id}/stop", s.instanceStop)
mux.HandleFunc("POST /api/v1/instances/{id}/restart", s.instanceRestart)
mux.HandleFunc("DELETE /api/v1/instances/{id}", s.instanceDeleteContainer)
mux.HandleFunc("GET /api/v1/instances/{id}/container-configuration", s.instanceConfigurationGet)
mux.HandleFunc("PUT /api/v1/instances/{id}/container-configuration", s.instanceConfigurationPut)
}
if backupService != nil {
mux.HandleFunc("GET /api/v1/instances/{id}/backups", s.backupList)
@@ -159,21 +168,229 @@ func newHandlerWithImports(authService *auth.Service, repository repository, lif
mux.HandleFunc("GET /login", s.loginForm)
mux.HandleFunc("POST /login", s.loginSubmit)
mux.HandleFunc("POST /logout", s.logout)
mux.HandleFunc("POST /admin/game-container-labels", s.globalLabelsForm)
mux.HandleFunc("GET /", s.home)
return s.securityHeaders(mux), nil
}
var publicGameIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
func (s *server) publicGameIcon(w http.ResponseWriter, r *http.Request) {
gameID := r.PathValue("gameID")
if !publicGameIDPattern.MatchString(gameID) || gameID != "palworld" {
http.NotFound(w, r)
return
}
body, err := catalogdata.Files.ReadFile("palworld/assets/icon.png")
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
func requestBaseURL(r *http.Request) string {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
if forwarded := r.Header.Get("X-Forwarded-Proto"); forwarded == "http" || forwarded == "https" {
scheme = forwarded
}
return scheme + "://" + r.Host
}
type applicationModeRequest struct {
Apply string `json:"apply"`
}
func (s *server) globalLabelsGet(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAPIUser(w, r, false)
if !ok {
return
}
if actor.Role != "admin" {
s.apiProblem(w, http.StatusForbidden, "forbidden", "Administrator access is required.")
return
}
repository := s.repository.(instance.ConfigurationRepository)
labels, err := repository.GetGlobalLabels(r.Context())
if err != nil {
s.apiProblem(w, 500, "settings_unavailable", "The settings are unavailable.")
return
}
s.apiJSON(w, http.StatusOK, map[string]any{"labels": instance.FormatLabels(labels), "variables": instance.AllowedLabelVariables})
}
func (s *server) globalLabelsPut(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAPIUser(w, r, true)
if !ok {
return
}
if err := s.permissions.RequireRecentAdmin(actor); err != nil {
s.authorizationProblem(w, err)
return
}
var request struct {
Labels string `json:"labels"`
Apply string `json:"apply"`
}
if !s.decodeStrictJSON(w, r, &request) {
return
}
labels, err := instance.ParseLabels(request.Labels)
if err != nil {
s.apiProblem(w, 422, "invalid_labels", err.Error())
return
}
if request.Apply != "immediate" && request.Apply != "next_start" {
s.apiProblem(w, 422, "invalid_apply_mode", "Apply must be immediate or next_start.")
return
}
if request.Apply == "immediate" && s.lifecycle == nil {
s.apiProblem(w, http.StatusConflict, "lifecycle_unavailable", "Immediate application requires the Docker agent.")
return
}
repository := s.repository.(instance.ConfigurationRepository)
affected, running, err := repository.SetGlobalLabels(r.Context(), labels, true)
if err != nil {
s.apiProblem(w, 500, "settings_update_failed", "The settings could not be updated.")
return
}
if request.Apply == "immediate" && s.lifecycle != nil {
for _, current := range mustLifecycleInstances(r.Context(), s.repository) {
if _, err := s.lifecycle.Configure(r.Context(), current.ID, instance.FormatLabels(current.Preview.CustomLabels), current.Preview.ImageTag, true); err != nil {
s.apiProblem(w, 502, "container_replace_failed", "Global labels were saved, but one or more containers could not be recreated.")
return
}
}
}
s.apiJSON(w, http.StatusOK, map[string]any{"labels": instance.FormatLabels(labels), "affected_instances": affected, "running_instances": running, "container_config_pending": request.Apply == "next_start"})
}
func (s *server) globalLabelsForm(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if r.ParseForm() != nil {
s.problem(w, 400, message("error.form"))
return
}
actor, err := s.currentUser(r)
session, sessionErr := r.Cookie(sessionCookie)
if err != nil || sessionErr != nil || actor.Role != "admin" || !s.auth.ValidateCSRF(r.Context(), session.Value, r.FormValue("csrf_token")) {
s.problem(w, http.StatusForbidden, message("error.csrf"))
return
}
if err := s.permissions.RequireRecentAdmin(actor); err != nil {
s.problem(w, http.StatusForbidden, "Recent administrator authentication is required.")
return
}
labels, err := instance.ParseLabels(r.FormValue("labels"))
if err != nil {
s.problem(w, 422, err.Error())
return
}
apply := r.FormValue("apply")
if apply != "immediate" && apply != "next_start" {
s.problem(w, 422, "Invalid application mode.")
return
}
if apply == "immediate" && r.FormValue("confirm_disconnection") != "yes" {
s.problem(w, 422, "Confirm that players will be disconnected.")
return
}
repository := s.repository.(instance.ConfigurationRepository)
if _, _, err := repository.SetGlobalLabels(r.Context(), labels, true); err != nil {
s.problem(w, 500, message("error.internal"))
return
}
if apply == "immediate" {
if s.lifecycle == nil {
s.problem(w, 409, "The Docker agent is unavailable.")
return
}
for _, current := range mustLifecycleInstances(r.Context(), s.repository) {
if _, err := s.lifecycle.Configure(r.Context(), current.ID, instance.FormatLabels(current.Preview.CustomLabels), current.Preview.ImageTag, true); err != nil {
s.problem(w, 502, "The settings were saved, but a container could not be recreated.")
return
}
}
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func mustLifecycleInstances(ctx context.Context, repository repository) []instance.StoredInstance {
values, err := repository.ListLifecycleInstances(ctx)
if err != nil {
return nil
}
return values
}
func (s *server) instanceConfigurationGet(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceView); !ok {
return
}
current, err := s.repository.GetInstance(r.Context(), r.PathValue("id"))
if err != nil {
s.lifecycleProblem(w, err)
return
}
s.apiJSON(w, 200, map[string]any{"custom_labels": instance.FormatLabels(current.Preview.CustomLabels), "docker_user": current.Preview.DockerUser, "image_tag": current.Preview.ImageTag, "container_config_pending": current.ContainerConfigPending, "docker_user_immutable": true})
}
func (s *server) instanceConfigurationPut(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceConfigure); !ok {
return
}
var request struct {
Labels string `json:"labels"`
ImageTag instance.ImageTag `json:"image_tag"`
Apply string `json:"apply"`
}
if !s.decodeStrictJSON(w, r, &request) {
return
}
if request.Apply != "immediate" && request.Apply != "next_start" {
s.apiProblem(w, 422, "invalid_apply_mode", "Apply must be immediate or next_start.")
return
}
result, err := s.lifecycle.Configure(r.Context(), r.PathValue("id"), request.Labels, request.ImageTag, request.Apply == "immediate")
if err != nil {
s.apiProblem(w, 422, "invalid_container_configuration", err.Error())
return
}
s.apiJSON(w, 200, result)
}
func (s *server) decodeStrictJSON(w http.ResponseWriter, r *http.Request, value any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if decoder.Decode(value) != nil || decoder.Decode(&struct{}{}) != io.EOF {
s.apiProblem(w, 400, "invalid_request", "The request is invalid.")
return false
}
return true
}
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"`
ImportID string `json:"import_id"`
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"`
ImportID string `json:"import_id"`
CustomLabels string `json:"custom_labels"`
DockerUser instance.DockerUser `json:"docker_user"`
ImageTag instance.ImageTag `json:"image_tag"`
}
func (s *server) catalogList(w http.ResponseWriter, r *http.Request) {
@@ -376,11 +593,21 @@ func (s *server) buildAPIPreview(w http.ResponseWriter, r *http.Request) (previe
DisplayName: request.DisplayName, Slug: request.Slug, HostPorts: request.HostPorts,
MountPaths: request.MountPaths, Resources: request.Resources, DataOrigin: request.DataOrigin,
BackupRetention: request.BackupRetention, ImportID: request.ImportID,
CustomLabels: request.CustomLabels, DockerUser: request.DockerUser, ImageTag: request.ImageTag,
PublicBaseURL: requestBaseURL(r),
})
if err != nil {
s.apiProblem(w, http.StatusUnprocessableEntity, "invalid_preview", "The deployment preview is invalid.")
return request, instance.Preview{}, false
}
if configured, ok := s.repository.(instance.ConfigurationRepository); ok {
labels, labelErr := configured.GetGlobalLabels(r.Context())
if labelErr != nil {
s.apiProblem(w, http.StatusInternalServerError, "settings_unavailable", "The global settings are unavailable.")
return request, instance.Preview{}, false
}
preview.GlobalLabels = labels
}
return request, preview, true
}
@@ -888,7 +1115,15 @@ func (s *server) home(w http.ResponseWriter, r *http.Request) {
s.problem(w, http.StatusForbidden, message("error.csrf"))
return
}
s.render(w, http.StatusOK, "home.html", pageData{Title: message("dashboard.title"), User: user, CSRFToken: csrf.Value})
data := pageData{Title: message("dashboard.title"), User: user, CSRFToken: csrf.Value, IsAdmin: user.Role == "admin"}
if data.IsAdmin && s.repository != nil {
if configured, ok := s.repository.(instance.ConfigurationRepository); ok {
if labels, labelErr := configured.GetGlobalLabels(r.Context()); labelErr == nil {
data.GlobalLabels = instance.FormatLabels(labels)
}
}
}
s.render(w, http.StatusOK, "home.html", data)
}
func (s *server) currentUser(r *http.Request) (auth.User, error) {
+9
View File
@@ -74,6 +74,15 @@ func TestCatalogPreviewAndDraftAPIAuthorization(t *testing.T) {
if err != nil {
t.Fatal(err)
}
icon := request(t, handler, http.MethodGet, "/public/game-icons/palworld", nil)
assertStatus(t, icon, http.StatusOK)
if icon.Header().Get("Content-Type") != "image/png" {
t.Fatalf("icon content type = %q", icon.Header().Get("Content-Type"))
}
traversal := request(t, handler, http.MethodGet, "/public/game-icons/..%2Fprivate", nil)
if traversal.Code == http.StatusOK {
t.Fatal("icon traversal accepted")
}
unauthenticated := request(t, handler, http.MethodGet, "/api/v1/catalog", nil)
assertStatus(t, unauthenticated, http.StatusUnauthorized)
sessionCookieValue := &http.Cookie{Name: sessionCookie, Value: session.Token}
+2 -1
View File
@@ -5,7 +5,8 @@ header { display: flex; justify-content: space-between; align-items: center; pad
form { display: grid; gap: 1rem; }
header form { display: block; }
label { display: grid; gap: .35rem; font-weight: 600; }
input, button { box-sizing: border-box; padding: .7rem; font: inherit; border: 1px solid #8a94a6; border-radius: .35rem; }
input, textarea, button { box-sizing: border-box; padding: .7rem; font: inherit; border: 1px solid #8a94a6; border-radius: .35rem; }
.warning { padding: .75rem; border-left: .25rem solid #b54708; background: #fffaeb; color: #7a2e0e; }
button { border: 0; background: #3157d5; color: white; font-weight: 700; cursor: pointer; }
.error { padding: .75rem; border-left: .25rem solid #b42318; background: #fee4e2; color: #7a271a; }
@media (prefers-color-scheme: dark) { body { background: #111827; color: #e5e7eb; } main, header { background: #1f2937; } input { background: #111827; color: #e5e7eb; } }
+1 -1
View File
@@ -1,3 +1,3 @@
{{define "home.html"}}<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · DoGaMa</title><link rel="stylesheet" href="/static/app.v1.css"></head>
<body><header><strong>{{msg "brand"}}</strong><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">{{msg "logout.submit"}}</button></form></header><main><h1>{{msg "dashboard.title"}}</h1><p>{{msg "dashboard.signed_in"}} <strong>{{.User.Username}}</strong>.</p><p>{{msg "dashboard.ready"}}</p></main></body></html>{{end}}
<body><header><strong>{{msg "brand"}}</strong><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">{{msg "logout.submit"}}</button></form></header><main><h1>{{msg "dashboard.title"}}</h1><p>{{msg "dashboard.signed_in"}} <strong>{{.User.Username}}</strong>.</p><p>{{msg "dashboard.ready"}}</p>{{if .IsAdmin}}<section><h2>Game-container labels</h2><form method="post" action="/admin/game-container-labels"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Global labels<textarea name="labels" rows="8" placeholder="key=value">{{.GlobalLabels}}</textarea></label><p>One label per line. Available variables: <code>{{`{{game.name}}`}}</code>, <code>{{`{{game.id}}`}}</code>, <code>{{`{{game.icon_url}}`}}</code>, <code>{{`{{instance.name}}`}}</code>, <code>{{`{{instance.id}}`}}</code>, <code>{{`{{instance.slug}}`}}</code>, <code>{{`{{server.name}}`}}</code>.</p><label><input type="radio" name="apply" value="next_start" checked> Apply on next start</label><label><input type="radio" name="apply" value="immediate"> Apply immediately</label><aside class="warning"><strong>Immediate application stops and recreates affected containers.</strong> Connected players are disconnected immediately. Persistent data is preserved and each instance returns to its previous running or stopped state.</aside><label><input type="checkbox" name="confirm_disconnection" value="yes"> I understand the immediate-disconnection warning</label><button type="submit">Save game-container labels</button></form></section>{{end}}</main></body></html>{{end}}
@@ -0,0 +1,14 @@
ALTER TABLE instances ADD COLUMN custom_labels_json TEXT NOT NULL DEFAULT '{}';
ALTER TABLE instances ADD COLUMN docker_user_mode TEXT NOT NULL DEFAULT 'dogama' CHECK (docker_user_mode IN ('dogama', 'custom', 'image'));
ALTER TABLE instances ADD COLUMN docker_uid INTEGER CHECK (docker_uid BETWEEN 0 AND 4294967295);
ALTER TABLE instances ADD COLUMN docker_gid INTEGER CHECK (docker_gid BETWEEN 0 AND 4294967295);
ALTER TABLE instances ADD COLUMN image_tag_mode TEXT NOT NULL DEFAULT 'tracked' CHECK (image_tag_mode IN ('tracked', 'pinned'));
ALTER TABLE instances ADD COLUMN image_tag TEXT NOT NULL DEFAULT '';
ALTER TABLE instances ADD COLUMN container_config_pending INTEGER NOT NULL DEFAULT 0 CHECK (container_config_pending IN (0, 1));
CREATE TABLE system_settings (
key TEXT PRIMARY KEY,
value_json TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1),
updated_at TEXT NOT NULL
);