feat(instances): add Docker labels, user IDs, image tags and deferred recreation
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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
|
||||
);
|
||||
Reference in New Issue
Block a user