fix(deploy): start current Palworld template
CI / validate (pull_request) Failing after 8m3s

This commit is contained in:
2026-08-15 21:43:48 +02:00
parent 3ef12df011
commit b575b5260d
20 changed files with 438 additions and 43 deletions
+3
View File
@@ -2,6 +2,9 @@
.cache .cache
dist dist
data data
servers
backups
.playwright-cli
secrets secrets
*.db *.db
*.db-shm *.db-shm
+2 -1
View File
@@ -1,6 +1,6 @@
schema_version: 1 schema_version: 1
id: palworld-official id: palworld-official
version: 1.1.0 version: 1.1.1
source: source:
type: official type: official
@@ -33,6 +33,7 @@ container:
tag: v1.0.2.101103 tag: v1.0.2.101103
entrypoint: entrypoint:
- /pal/helper.sh - /pal/helper.sh
user_mode: image
arguments: arguments:
- -port=8211 - -port=8211
- -useperfthreads - -useperfthreads
+25 -13
View File
@@ -55,14 +55,14 @@ func run(logger *slog.Logger) error {
return err return err
} }
defer func() { _ = db.Close() }() defer func() { _ = db.Close() }()
repository := sqlite.NewRepository(db)
if err := catalog.InitializeOfficial(templatesRoot, catalogdata.Files); err != nil { if err := catalog.InitializeOfficial(templatesRoot, catalogdata.Files); err != nil {
return err return err
} }
scan, err := catalog.ScanDir(templatesRoot) scan, err := synchronizeCatalog(ctx, repository, templatesRoot)
if err != nil { if err != nil {
return err return err
} }
repository := sqlite.NewRepository(db)
keyFile := environment("DOGAMA_MASTER_KEY_FILE", "secrets/master_key") keyFile := environment("DOGAMA_MASTER_KEY_FILE", "secrets/master_key")
key, keyErr := loadMasterKey(keyFile) key, keyErr := loadMasterKey(keyFile)
if keyErr != nil { if keyErr != nil {
@@ -71,9 +71,6 @@ func run(logger *slog.Logger) error {
if keyErr = repository.SetSecretKey(key); keyErr != nil { if keyErr = repository.SetSecretKey(key); keyErr != nil {
return keyErr return keyErr
} }
if err := repository.Replace(ctx, scan.Valid); err != nil {
return err
}
logger.Info("local catalog synchronized", "event", "catalog.synchronized", "template_count", len(scan.Valid), "invalid_template_count", len(scan.Errors)) logger.Info("local catalog synchronized", "event", "catalog.synchronized", "template_count", len(scan.Valid), "invalid_template_count", len(scan.Errors))
var handler http.Handler var handler http.Handler
var lifecycle *instance.LifecycleService var lifecycle *instance.LifecycleService
@@ -121,14 +118,7 @@ func run(logger *slog.Logger) error {
cancel() cancel()
} }
handler, err = web.NewHandlerCompleteWithCatalogDeploymentAndRuntime(auth.New(db), repository, lifecycle, backupService, importService, auditService, notificationService, func(ctx context.Context) (catalog.ScanResult, error) { handler, err = web.NewHandlerCompleteWithCatalogDeploymentAndRuntime(auth.New(db), repository, lifecycle, backupService, importService, auditService, notificationService, func(ctx context.Context) (catalog.ScanResult, error) {
result, scanErr := catalog.ScanDir(templatesRoot) return synchronizeCatalog(ctx, repository, templatesRoot)
if scanErr != nil {
return result, scanErr
}
if syncErr := repository.Replace(ctx, result.Valid); syncErr != nil {
return result, syncErr
}
return result, nil
}, serversRoot, instance.NewModuleService(repository, repository, modulesRoot), logger) }, serversRoot, instance.NewModuleService(repository, repository, modulesRoot), logger)
if err != nil { if err != nil {
return err return err
@@ -168,6 +158,28 @@ func run(logger *slog.Logger) error {
} }
} }
// synchronizeCatalog indexes administrator-owned local templates first, then
// makes the currently shipped immutable snapshots active for new deployments.
// Replace retains older versions, so existing instances and diagnostics remain
// pinned to their original snapshot.
func synchronizeCatalog(ctx context.Context, repository *sqlite.Repository, templatesRoot string) (catalog.ScanResult, error) {
result, err := catalog.ScanDir(templatesRoot)
if err != nil {
return result, err
}
if err := repository.Replace(ctx, result.Valid); err != nil {
return result, err
}
current, err := catalog.LoadFS(catalogdata.Files, ".")
if err != nil {
return result, err
}
if err := repository.Sync(ctx, current); err != nil {
return result, err
}
return result, nil
}
func loadMasterKey(path string) ([]byte, error) { func loadMasterKey(path string) ([]byte, error) {
if _, err := internalsecrets.Ensure(path, 0o600); err != nil { if _, err := internalsecrets.Ensure(path, 0o600); err != nil {
return nil, errors.New("initialize encryption key file") return nil, errors.New("initialize encryption key file")
+2 -1
View File
@@ -73,7 +73,7 @@ Read this compact operational baseline before starting a milestone. Open detaile
- Notification delivery attempts are capped at five with exponential minute-scale backoff and never determine the originating operation result. - Notification delivery attempts are capped at five with exponential minute-scale backoff and never determine the originating operation result.
- Audit retention defaults to 30 days and 10,000 entries; zero explicitly selects unlimited retention/count within documented bounds. - Audit retention defaults to 30 days and 10,000 entries; zero explicitly selects unlimited retention/count within documented bounds.
- At least one active global administrator is always retained; deactivation revokes that user's sessions atomically. - At least one active global administrator is always retained; deactivation revokes that user's sessions atomically.
- The local template directory (`/var/lib/dogama/templates`, under the application data bind mount) is the catalog source of truth. Bundled templates are copied only when their destination files are absent; an administrator Scan validates each directory independently and refreshes the available SQLite index without network fetches. - The local template directory (`/var/lib/dogama/templates`, under the application data bind mount) is the catalog source of truth for administrator-owned customizations. Bundled templates are copied only when their destination files are absent; after every local scan, the current bundled immutable snapshots are synchronized into SQLite and selected for new deployments while older snapshots remain available for existing instances, audit and diagnostics.
- Administrators can persist bounded HTTP(S) template-repository definitions for future use. They are configuration only: remote retrieval, authentication, synchronization and automatic updates are deliberately unavailable, and Catalog Scan remains local-only. - Administrators can persist bounded HTTP(S) template-repository definitions for future use. They are configuration only: remote retrieval, authentication, synchronization and automatic updates are deliberately unavailable, and Catalog Scan remains local-only.
## Known limitations and debt ## Known limitations and debt
@@ -100,3 +100,4 @@ Read this compact operational baseline before starting a milestone. Open detaile
- Web access and i18n are stored in the initial SQLite schema. HTTP defaults to working session/CSRF cookies without Secure and without HSTS; HTTPS enforcement is explicit. - Web access and i18n are stored in the initial SQLite schema. HTTP defaults to working session/CSRF cookies without Secure and without HSTS; HTTPS enforcement is explicit.
- Lifecycle failures retain a bounded diagnostic record separate from Audit. The restricted agent captures Docker inspection state (including exit/OOM/timestamps/health) and a bounded log tail after a failed start; the application persists this record under the operation ID and writes a stable `DGM-*` error category. Existing SQLite stores receive the additive diagnostic table at open time. - Lifecycle failures retain a bounded diagnostic record separate from Audit. The restricted agent captures Docker inspection state (including exit/OOM/timestamps/health) and a bounded log tail after a failed start; the application persists this record under the operation ID and writes a stable `DGM-*` error category. Existing SQLite stores receive the additive diagnostic table at open time.
- Approved template helper assets are immutable mode `0555`, so an image-defined non-root user can execute a bind-mounted entrypoint while retaining no write access.
+6 -2
View File
@@ -49,7 +49,7 @@ func writeImmutableAsset(path string, content []byte) error {
if existingDigest != wantedDigest { if existingDigest != wantedDigest {
return errors.New("approved asset content conflict") return errors.New("approved asset content conflict")
} }
return nil return os.Chmod(path, 0o555)
} else if !errors.Is(err, os.ErrNotExist) { } else if !errors.Is(err, os.ErrNotExist) {
return errors.New("inspect approved asset path") return errors.New("inspect approved asset path")
} }
@@ -59,7 +59,11 @@ func writeImmutableAsset(path string, content []byte) error {
} }
temporaryPath := temporary.Name() temporaryPath := temporary.Name()
defer func() { _ = os.Remove(temporaryPath) }() defer func() { _ = os.Remove(temporaryPath) }()
if err = temporary.Chmod(0o500); err == nil { // Template entrypoint assets are bind-mounted into images that can run as a
// non-root image user. The host-side agent owns these files, so the image
// user must be able to read and execute an approved helper without gaining
// write access.
if err = temporary.Chmod(0o555); err == nil {
_, err = temporary.Write(content) _, err = temporary.Write(content)
} }
if err == nil { if err == nil {
+28
View File
@@ -0,0 +1,28 @@
package agent
import (
"os"
"path/filepath"
"testing"
)
func TestWriteImmutableAssetIsExecutableByImageUser(t *testing.T) {
path := filepath.Join(t.TempDir(), "helper.sh")
content := []byte("#!/bin/sh\nexit 0\n")
if err := writeImmutableAsset(path, content); err != nil {
t.Fatal(err)
}
if err := os.Chmod(path, 0o500); err != nil {
t.Fatal(err)
}
if err := writeImmutableAsset(path, content); err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got, want := info.Mode().Perm(), os.FileMode(0o555); got != want {
t.Fatalf("asset mode = %04o, want %04o so an image user can execute the bind-mounted entrypoint", got, want)
}
}
+48 -3
View File
@@ -10,6 +10,7 @@ import (
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv" "strconv"
@@ -165,16 +166,24 @@ func (d *dockerRuntime) Create(ctx context.Context, plan agentwire.DeploymentPla
bindings[key] = []portBinding{{HostIP: "0.0.0.0", HostPort: strconv.Itoa(port.HostPort)}} bindings[key] = []portBinding{{HostIP: "0.0.0.0", HostPort: strconv.Itoa(port.HostPort)}}
} }
} }
binds := make([]string, 0, len(plan.Mounts)) binds := make([]string, 0, len(plan.Mounts)+len(assets))
for _, mount := range plan.Mounts { for _, mount := range plan.Mounts {
hostPath, hostErr := d.hostBindPath(ctx, mount.HostPath)
if hostErr != nil {
return "", hostErr
}
mode := "rw" mode := "rw"
if mount.ReadOnly { if mount.ReadOnly {
mode = "ro" mode = "ro"
} }
binds = append(binds, mount.HostPath+":"+mount.ContainerPath+":"+mode) binds = append(binds, hostPath+":"+mount.ContainerPath+":"+mode)
} }
for _, asset := range assets { for _, asset := range assets {
binds = append(binds, asset.HostPath+":"+asset.ContainerPath+":ro") hostPath, hostErr := d.hostBindPath(ctx, asset.HostPath)
if hostErr != nil {
return "", hostErr
}
binds = append(binds, hostPath+":"+asset.ContainerPath+":ro")
} }
pidsLimit := int64(512) pidsLimit := int64(512)
payload := struct { payload := struct {
@@ -234,6 +243,42 @@ func (d *dockerRuntime) Create(ctx context.Context, plan agentwire.DeploymentPla
return created.ID, nil return created.ID, nil
} }
// hostBindPath translates an agent-container path to the exact host source
// recorded for this agent's own bind mount. Docker receives paths in the host
// namespace, not the agent container namespace.
func (d *dockerRuntime) hostBindPath(ctx context.Context, containerPath string) (string, error) {
self, err := os.Hostname()
if err != nil || self == "" {
return "", errors.New("agent container identity is unavailable")
}
response, err := d.call(ctx, http.MethodGet, dockerAPIVersion+"/containers/"+url.PathEscape(self)+"/json", nil, "", 128<<10)
if err != nil || response.status != http.StatusOK {
return "", errors.New("agent bind mount mapping is unavailable")
}
var inspection struct {
Mounts []struct {
Source string
Destination string
}
}
if json.Unmarshal(response.body, &inspection) != nil {
return "", errors.New("agent bind mount mapping is unavailable")
}
clean := filepath.Clean(containerPath)
for _, mount := range inspection.Mounts {
destination := filepath.Clean(mount.Destination)
if !filepath.IsAbs(mount.Source) || (clean != destination && !strings.HasPrefix(clean, destination+string(filepath.Separator))) {
continue
}
relative, relativeErr := filepath.Rel(destination, clean)
if relativeErr != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return "", errors.New("agent bind mount mapping is unavailable")
}
return filepath.Join(mount.Source, relative), nil
}
return "", errors.New("agent bind mount mapping is unavailable")
}
func mergeDockerLabels(custom, technical map[string]string) map[string]string { func mergeDockerLabels(custom, technical map[string]string) map[string]string {
result := make(map[string]string, len(custom)+len(technical)) result := make(map[string]string, len(custom)+len(technical))
for key, value := range custom { for key, value := range custom {
+27
View File
@@ -6,6 +6,7 @@ import (
"io" "io"
"net" "net"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
@@ -87,6 +88,14 @@ func TestDockerPingerUsesConfiguredUnixSocket(t *testing.T) {
} }
func TestDockerRuntimeCreatesFixedSecurityBaseline(t *testing.T) { func TestDockerRuntimeCreatesFixedSecurityBaseline(t *testing.T) {
hostRoot := t.TempDir()
if err := os.MkdirAll(filepath.Join(hostRoot, ".dogama"), 0o755); err != nil {
t.Fatal(err)
}
hostAsset := filepath.Join(hostRoot, ".dogama", "helper")
if err := os.WriteFile(hostAsset, []byte("#!/bin/sh\n"), 0o555); err != nil {
t.Fatal(err)
}
socket := filepath.Join(t.TempDir(), "docker.sock") socket := filepath.Join(t.TempDir(), "docker.sock")
listener, err := net.Listen("unix", socket) listener, err := net.Listen("unix", socket)
if err != nil { if err != nil {
@@ -95,6 +104,8 @@ func TestDockerRuntimeCreatesFixedSecurityBaseline(t *testing.T) {
createdBodies := make(chan []byte, 2) createdBodies := make(chan []byte, 2)
server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { switch r.URL.Path {
case "/v1.41/containers/" + hostname(t) + "/json":
_ = json.NewEncoder(w).Encode(map[string]any{"Mounts": []map[string]string{{"Source": hostRoot, "Destination": "/srv/games"}}})
case "/v1.41/images/create": case "/v1.41/images/create":
_, _ = w.Write([]byte("{}\n")) _, _ = w.Write([]byte("{}\n"))
case "/v1.41/containers/json": case "/v1.41/containers/json":
@@ -136,6 +147,7 @@ func TestDockerRuntimeCreatesFixedSecurityBaseline(t *testing.T) {
User string `json:"User"` User string `json:"User"`
Labels map[string]string `json:"Labels"` Labels map[string]string `json:"Labels"`
HostConfig struct { HostConfig struct {
Binds []string
NetworkMode string `json:"NetworkMode"` NetworkMode string `json:"NetworkMode"`
CapDrop []string `json:"CapDrop"` CapDrop []string `json:"CapDrop"`
SecurityOpt []string `json:"SecurityOpt"` SecurityOpt []string `json:"SecurityOpt"`
@@ -157,6 +169,21 @@ func TestDockerRuntimeCreatesFixedSecurityBaseline(t *testing.T) {
if payload.Labels["dashboard.name"] != "Summer" || payload.User != "1000:1001" { if payload.Labels["dashboard.name"] != "Summer" || payload.User != "1000:1001" {
t.Fatalf("custom Docker configuration = %#v user=%q", payload.Labels, payload.User) t.Fatalf("custom Docker configuration = %#v user=%q", payload.Labels, payload.User)
} }
if got := strings.Join(payload.HostConfig.Binds, " "); !strings.Contains(got, filepath.Join(hostRoot, "saved")+":/game/saved:rw") || !strings.Contains(got, hostAsset+":/pal/helper.sh:ro") {
t.Fatalf("host bind mappings = %q", got)
}
if info, err := os.Stat(hostAsset); err != nil || !info.Mode().IsRegular() {
t.Fatalf("asset became non-file after host bind translation: info=%#v error=%v", info, err)
}
}
func hostname(t *testing.T) string {
t.Helper()
value, err := os.Hostname()
if err != nil {
t.Fatal(err)
}
return value
} }
func TestDockerPingerDoesNotExposeConnectionDetails(t *testing.T) { func TestDockerPingerDoesNotExposeConnectionDetails(t *testing.T) {
+10 -4
View File
@@ -46,8 +46,11 @@ type ApprovedAsset struct {
func (p *PlanPolicy) Assets(plan agentwire.DeploymentPlan) ([]ApprovedAsset, error) { func (p *PlanPolicy) Assets(plan agentwire.DeploymentPlan) ([]ApprovedAsset, error) {
snapshot, ok := p.snapshots[plan.TemplateID+"@"+plan.TemplateVersion] snapshot, ok := p.snapshots[plan.TemplateID+"@"+plan.TemplateVersion]
if !ok || snapshot.Digest != plan.TemplateDigest { if !ok {
return nil, errors.New("unknown template snapshot") return nil, fmt.Errorf("template snapshot not registered: %s@%s", plan.TemplateID, plan.TemplateVersion)
}
if snapshot.Digest != plan.TemplateDigest {
return nil, fmt.Errorf("template snapshot digest mismatch: received=%s expected=%s", plan.TemplateDigest, snapshot.Digest)
} }
result := make([]ApprovedAsset, 0, len(snapshot.Template.Container.Assets)) result := make([]ApprovedAsset, 0, len(snapshot.Template.Container.Assets))
for _, asset := range snapshot.Template.Container.Assets { for _, asset := range snapshot.Template.Container.Assets {
@@ -68,8 +71,11 @@ func (p *PlanPolicy) Validate(plan agentwire.DeploymentPlan) error {
return errors.New("invalid deployment plan") return errors.New("invalid deployment plan")
} }
snapshot, ok := p.snapshots[plan.TemplateID+"@"+plan.TemplateVersion] snapshot, ok := p.snapshots[plan.TemplateID+"@"+plan.TemplateVersion]
if !ok || snapshot.Digest != plan.TemplateDigest { if !ok {
return errors.New("unknown template snapshot") return fmt.Errorf("template snapshot not registered: %s@%s", plan.TemplateID, plan.TemplateVersion)
}
if snapshot.Digest != plan.TemplateDigest {
return fmt.Errorf("template snapshot digest mismatch: received=%s expected=%s", plan.TemplateDigest, snapshot.Digest)
} }
template := snapshot.Template template := snapshot.Template
imagePrefix := template.Container.Image + ":" imagePrefix := template.Container.Image + ":"
+45
View File
@@ -0,0 +1,45 @@
package agent
import (
"path/filepath"
"testing"
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
)
func TestEmbeddedPalworldSnapshotMatchesApplicationDeploymentPlan(t *testing.T) {
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
if err != nil || len(snapshots) != 1 {
t.Fatalf("embedded snapshots = %#v, error = %v", snapshots, err)
}
snapshot := snapshots[0]
if snapshot.Template.ID != "palworld-official" || snapshot.Template.Version != "1.1.1" {
t.Fatalf("embedded Palworld snapshot = %s@%s", snapshot.Template.ID, snapshot.Template.Version)
}
preview, err := instance.BuildPreview(snapshot, instance.PreviewRequest{
DisplayName: "Snapshot consistency", Slug: "snapshot-consistency",
HostPorts: map[string]int{"game": 38211},
MountPaths: map[string]string{"saved": filepath.Join(t.TempDir(), "saved")},
DataOrigin: "new", BackupRetention: 7,
})
if err != nil {
t.Fatal(err)
}
plan, err := preview.DeploymentPlan("abcdefghijklmnopqrstuvwx")
if err != nil {
t.Fatal(err)
}
policy, err := NewPlanPolicy(snapshots, catalogdata.Files)
if err != nil {
t.Fatal(err)
}
known, ok := policy.snapshots[plan.TemplateID+"@"+plan.TemplateVersion]
if !ok || known.Digest != plan.TemplateDigest {
t.Fatalf("agent snapshot=%#v plan=%s@%s digest=%s", known, plan.TemplateID, plan.TemplateVersion, plan.TemplateDigest)
}
if err := policy.Validate(plan); err != nil {
t.Fatalf("application plan rejected by matching embedded snapshot: %v", err)
}
}
+10 -4
View File
@@ -141,8 +141,13 @@ func (s *service) checkPorts(w http.ResponseWriter, r *http.Request) {
func (s *service) createInstance(w http.ResponseWriter, r *http.Request) { func (s *service) createInstance(w http.ResponseWriter, r *http.Request) {
var plan agentwire.DeploymentPlan var plan agentwire.DeploymentPlan
if decodeJSON(r.Body, &plan) != nil || s.plans.Validate(plan) != nil { if err := decodeJSON(r.Body, &plan); err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "invalid_plan", "The deployment plan is invalid.") writeDiagnosticProblem(w, http.StatusUnprocessableEntity, "invalid_plan", "The deployment plan is invalid.", map[string]any{"phase": "preparation", "operation_error": redactDiagnostic(err.Error())})
return
}
if err := s.plans.Validate(plan); err != nil {
s.logger.Error("instance plan rejected", "event", "instance.create.plan_rejected", "instance_id", plan.InstanceID, "template_id", plan.TemplateID, "template_version", plan.TemplateVersion, "template_digest", plan.TemplateDigest, "error", err)
writeDiagnosticProblem(w, http.StatusUnprocessableEntity, "invalid_plan", "The deployment plan is invalid.", map[string]any{"phase": "preparation", "template_id": plan.TemplateID, "template_version": plan.TemplateVersion, "template_digest": plan.TemplateDigest, "operation_error": redactDiagnostic(err.Error())})
return return
} }
for index := range plan.Mounts { for index := range plan.Mounts {
@@ -183,7 +188,8 @@ func (s *service) createInstance(w http.ResponseWriter, r *http.Request) {
} }
containerID, err := s.docker.Create(r.Context(), plan, assets) containerID, err := s.docker.Create(r.Context(), plan, assets)
if err != nil { if err != nil {
writeProblem(w, http.StatusBadGateway, "container_create_failed", "The container could not be created.") s.logger.Error("instance create failed", "event", "instance.create.failed", "instance_id", plan.InstanceID, "error", err)
writeDiagnosticProblem(w, http.StatusBadGateway, "container_create_failed", "The container could not be created.", map[string]any{"operation_error": redactDiagnostic(err.Error())})
return return
} }
entry := RegisteredInstance{InstanceID: plan.InstanceID, ContainerID: containerID, PlanDigest: plan.PlanDigest} entry := RegisteredInstance{InstanceID: plan.InstanceID, ContainerID: containerID, PlanDigest: plan.PlanDigest}
@@ -415,7 +421,7 @@ func (s *service) boundState(ctx context.Context, entry RegisteredInstance) (age
if !inspection.Running { if !inspection.Running {
health = "stopped" health = "stopped"
} }
return agentwire.InstanceState{InstanceID: entry.InstanceID, ContainerID: entry.ContainerID, PlanDigest: entry.PlanDigest, Running: inspection.Running, Ready: inspection.Running && inspection.Health == "healthy", Health: health, ExitCode: inspection.ExitCode}, nil return agentwire.InstanceState{InstanceID: entry.InstanceID, ContainerID: entry.ContainerID, PlanDigest: entry.PlanDigest, Running: inspection.Running, Ready: inspection.Running && (inspection.Health == "healthy" || inspection.Health == "none"), Health: health, ExitCode: inspection.ExitCode}, nil
} }
func (s *service) bindingProblem(w http.ResponseWriter) { func (s *service) bindingProblem(w http.ResponseWriter) {
+24
View File
@@ -165,6 +165,30 @@ func TestAgentCreatesOnlyValidatedBoundInstances(t *testing.T) {
} }
} }
func TestAgentTreatsRunningContainerWithoutDockerHealthcheckAsReady(t *testing.T) {
root := t.TempDir()
secret := bytes.Repeat([]byte{0x31}, 32)
instanceID := "abcdefghijklmnopqrstuvwx"
plan := testPlan(t, instanceID, root)
docker := fakeDocker{inspection: agent.DockerInspection{
ContainerID: "container-" + instanceID, Running: true, Status: "running", Health: "none",
Labels: map[string]string{"io.dogama.managed": "true", "io.dogama.instance-id": instanceID, "io.dogama.plan-digest": plan.PlanDigest},
}}
server := httptest.NewServer(newTestHandler(t, root, secret, docker))
t.Cleanup(server.Close)
client, err := agentclient.New(server.URL, secret, server.Client())
if err != nil {
t.Fatal(err)
}
if _, err := client.CreateInstance(context.Background(), plan); err != nil {
t.Fatal(err)
}
state, err := client.InspectInstance(context.Background(), instanceID)
if err != nil || !state.Running || !state.Ready || state.Health != "none" {
t.Fatalf("state=%#v error=%v", state, err)
}
}
func TestAgentStartExitReturnsDockerDiagnosticWithoutLogs(t *testing.T) { func TestAgentStartExitReturnsDockerDiagnosticWithoutLogs(t *testing.T) {
root := t.TempDir() root := t.TempDir()
secret := bytes.Repeat([]byte{0x31}, 32) secret := bytes.Repeat([]byte{0x31}, 32)
+14 -2
View File
@@ -56,8 +56,20 @@ func (e *ProblemError) Error() string {
} }
// DiagnosticDetails lets the lifecycle service persist the agent's bounded // DiagnosticDetails lets the lifecycle service persist the agent's bounded
// Docker inspection without exposing it to unprivileged HTTP clients. // technical cause without exposing it to unprivileged HTTP clients.
func (e *ProblemError) DiagnosticDetails() map[string]any { return e.Details } func (e *ProblemError) DiagnosticDetails() map[string]any {
details := make(map[string]any, len(e.Details)+2)
for key, value := range e.Details {
details[key] = value
}
if e.Code != "" {
details["agent_code"] = e.Code
}
if e.Message != "" {
details["agent_message"] = e.Message
}
return details
}
// New constructs a client. The base URL must not contain credentials, a query // New constructs a client. The base URL must not contain credentials, a query
// or a path beyond an optional trailing slash. // or a path beyond an optional trailing slash.
+1
View File
@@ -66,6 +66,7 @@ type Template struct {
Image string `json:"image"` Image string `json:"image"`
Tag string `json:"tag"` Tag string `json:"tag"`
Entrypoint []string `json:"entrypoint,omitempty"` Entrypoint []string `json:"entrypoint,omitempty"`
UserMode string `json:"user_mode,omitempty"`
Arguments []string `json:"arguments,omitempty"` Arguments []string `json:"arguments,omitempty"`
Environment map[string]string `json:"environment,omitempty"` Environment map[string]string `json:"environment,omitempty"`
StopTimeoutSeconds int `json:"stop_timeout_seconds"` StopTimeoutSeconds int `json:"stop_timeout_seconds"`
+52 -10
View File
@@ -8,6 +8,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"sync" "sync"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire" "git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
) )
@@ -18,6 +19,8 @@ var (
ErrInvalidState = errors.New("invalid instance lifecycle state") ErrInvalidState = errors.New("invalid instance lifecycle state")
) )
const startupVerificationDelay = 3 * time.Second
type StoredInstance struct { type StoredInstance struct {
ID string ID string
Preview Preview Preview Preview
@@ -244,12 +247,41 @@ func (s *LifecycleService) StartInstall(ctx context.Context, instanceID, operati
lifecycle, observed := stateToLifecycle(state) lifecycle, observed := stateToLifecycle(state)
if progress, ok := s.repository.(ProgressRepository); ok { if progress, ok := s.repository.(ProgressRepository); ok {
_ = progress.SetOperationStep(ctx, operationID, "starting", "success") _ = progress.SetOperationStep(ctx, operationID, "starting", "success")
_ = progress.SetOperationStep(ctx, operationID, "verification", "running")
}
verified, verifyErr := s.verifyStarted(ctx, instanceID)
if verifyErr != nil {
if progress, ok := s.repository.(ProgressRepository); ok {
_ = progress.SetOperationStep(ctx, operationID, "verification", "failed")
}
return s.fail(ctx, operationID, instanceID, "verification_failed", verifyErr)
}
lifecycle, observed = stateToLifecycle(verified)
if progress, ok := s.repository.(ProgressRepository); ok {
_ = progress.SetOperationStep(ctx, operationID, "verification", "success") _ = progress.SetOperationStep(ctx, operationID, "verification", "success")
} }
return OperationResult{OperationID: operationID, InstanceID: instanceID, State: lifecycle, Observed: observed, ContainerID: state.ContainerID, AgentState: state}, nil return OperationResult{OperationID: operationID, InstanceID: instanceID, State: lifecycle, Observed: observed, ContainerID: verified.ContainerID, AgentState: verified}, nil
}) })
} }
func (s *LifecycleService) verifyStarted(ctx context.Context, instanceID string) (agentwire.InstanceState, error) {
timer := time.NewTimer(startupVerificationDelay)
defer timer.Stop()
select {
case <-ctx.Done():
return agentwire.InstanceState{}, ctx.Err()
case <-timer.C:
}
state, err := s.agent.InspectInstance(ctx, instanceID)
if err != nil {
return agentwire.InstanceState{}, err
}
if !state.Running || state.Health == "unhealthy" {
return agentwire.InstanceState{}, errors.New("container did not remain running during startup verification")
}
return state, nil
}
func (s *LifecycleService) FailInstall(ctx context.Context, operationID, instanceID, step string, cause error) (OperationResult, error) { func (s *LifecycleService) FailInstall(ctx context.Context, operationID, instanceID, step string, cause error) (OperationResult, error) {
if progress, ok := s.repository.(ProgressRepository); ok { if progress, ok := s.repository.(ProgressRepository); ok {
_ = progress.SetOperationStep(ctx, operationID, step, "failed") _ = progress.SetOperationStep(ctx, operationID, step, "failed")
@@ -489,7 +521,7 @@ func (s *LifecycleService) fail(ctx context.Context, operationID, instanceID, co
_ = progress.SetOperationStep(ctx, operationID, code, "failed") _ = progress.SetOperationStep(ctx, operationID, code, "failed")
} }
if diagnostics, ok := s.repository.(DiagnosticRepository); ok { if diagnostics, ok := s.repository.(DiagnosticRepository); ok {
_ = diagnostics.RecordDiagnostic(ctx, Diagnostic{OperationID: operationID, InstanceID: instanceID, Step: code, ErrorCode: stableCode, Details: diagnosticDetails(cause)}) _ = diagnostics.RecordDiagnostic(ctx, Diagnostic{OperationID: operationID, InstanceID: instanceID, Step: code, ErrorCode: stableCode, Details: diagnosticDetails(operationID, code, stableCode, cause)})
} }
if err := s.repository.FailOperation(ctx, operationID, "error", stableCode); err != nil { if err := s.repository.FailOperation(ctx, operationID, "error", stableCode); err != nil {
return OperationResult{}, err return OperationResult{}, err
@@ -497,24 +529,34 @@ func (s *LifecycleService) fail(ctx context.Context, operationID, instanceID, co
return OperationResult{OperationID: operationID, InstanceID: instanceID, State: "error", Observed: "unknown"}, fmt.Errorf("%s: %w", stableCode, cause) return OperationResult{OperationID: operationID, InstanceID: instanceID, State: "error", Observed: "unknown"}, fmt.Errorf("%s: %w", stableCode, cause)
} }
func diagnosticDetails(cause error) string { func diagnosticDetails(operationID, step, stableCode string, cause error) string {
if cause == nil { details := map[string]any{
return "" "operation_id": operationID,
"step": step,
"code": stableCode,
} }
type detailed interface{ DiagnosticDetails() map[string]any } type detailed interface{ DiagnosticDetails() map[string]any }
var value detailed var value detailed
if errors.As(cause, &value) { if errors.As(cause, &value) {
encoded, err := json.Marshal(value.DiagnosticDetails()) for key, detail := range value.DiagnosticDetails() {
if err == nil { details[key] = detail
return string(encoded)
} }
} else if cause != nil {
details["operation_error"] = cause.Error()
} }
return cause.Error() if _, exists := details["phase"]; !exists {
details["phase"] = step
}
encoded, err := json.Marshal(details)
if err != nil {
return ""
}
return string(encoded)
} }
func stableErrorCode(step string) string { func stableErrorCode(step string) string {
switch step { switch step {
case "agent_start_failed", "agent_restart_failed": case "agent_start_failed", "agent_restart_failed", "verification_failed":
return "DGM-START-001" return "DGM-START-001"
case "agent_create_failed": case "agent_create_failed":
return "DGM-DEPLOY-001" return "DGM-DEPLOY-001"
+62
View File
@@ -23,10 +23,27 @@ type lifecycleAgent struct {
type failingStartAgent struct{ lifecycleAgent } type failingStartAgent struct{ lifecycleAgent }
type rejectedPlanAgent struct{ lifecycleAgent }
func (a *failingStartAgent) StartInstance(context.Context, string) (agentwire.InstanceState, error) { func (a *failingStartAgent) StartInstance(context.Context, string) (agentwire.InstanceState, error) {
return agentwire.InstanceState{}, &agentclient.ProblemError{Status: 502, Code: "start_exited", Details: map[string]any{"state": "exited", "exit_code": 42, "started_at": "2026-01-01T00:00:00Z", "finished_at": "2026-01-01T00:00:01Z", "logs_tail": "", "error": "permission denied password=[REDACTED]"}} return agentwire.InstanceState{}, &agentclient.ProblemError{Status: 502, Code: "start_exited", Details: map[string]any{"state": "exited", "exit_code": 42, "started_at": "2026-01-01T00:00:00Z", "finished_at": "2026-01-01T00:00:01Z", "logs_tail": "", "error": "permission denied password=[REDACTED]"}}
} }
func (a *rejectedPlanAgent) CreateInstance(context.Context, agentwire.DeploymentPlan) (agentwire.InstanceState, error) {
return agentwire.InstanceState{}, &agentclient.ProblemError{
Status: 422,
Code: "invalid_plan",
Message: "The deployment plan is invalid.",
Details: map[string]any{
"phase": "preparation",
"template_id": "palworld-official",
"template_version": "1.1.1",
"template_digest": "digest-not-secret",
"operation_error": "template snapshot not registered: palworld-official@1.1.1",
},
}
}
func (a *lifecycleAgent) CreateInstance(_ context.Context, plan agentwire.DeploymentPlan) (agentwire.InstanceState, error) { func (a *lifecycleAgent) CreateInstance(_ context.Context, plan agentwire.DeploymentPlan) (agentwire.InstanceState, error) {
a.mu.Lock() a.mu.Lock()
defer a.mu.Unlock() defer a.mu.Unlock()
@@ -216,3 +233,48 @@ func TestManualStartPersistsAgentDiagnosticAndStableCode(t *testing.T) {
t.Fatalf("original cause was lost: %v", startErr) t.Fatalf("original cause was lost: %v", startErr)
} }
} }
func TestInstallPlanRejectedPersistsDiagnostic(t *testing.T) {
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
repository := sqlite.NewRepository(db)
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
if err != nil {
t.Fatal(err)
}
if err := repository.Sync(ctx, snapshots); err != nil {
t.Fatal(err)
}
preview, err := instance.BuildPreview(snapshots[0], instance.PreviewRequest{DisplayName: "Rejected", Slug: "rejected", HostPorts: map[string]int{"game": 38213}, MountPaths: map[string]string{"saved": filepath.Join(t.TempDir(), "saved")}, DataOrigin: "new", BackupRetention: 7})
if err != nil {
t.Fatal(err)
}
const id = "abcdefghijklmnopqrstuvwx"
if err := repository.CreateDraft(ctx, instance.Draft{ID: id, Preview: preview}); err != nil {
t.Fatal(err)
}
result, installErr := instance.NewLifecycleService(repository, &rejectedPlanAgent{}).Install(ctx, id)
if installErr == nil || !strings.Contains(installErr.Error(), "DGM-DEPLOY-001") || result.OperationID == "" {
t.Fatalf("result=%#v err=%v", result, installErr)
}
diagnostics, err := repository.ListDiagnostics(ctx, id, 10)
if err != nil || len(diagnostics) != 1 {
t.Fatalf("diagnostics=%#v err=%v", diagnostics, err)
}
diagnostic := diagnostics[0]
if diagnostic.OperationID != result.OperationID || diagnostic.ErrorCode != "DGM-DEPLOY-001" || diagnostic.Details == "" {
t.Fatalf("diagnostic=%#v", diagnostic)
}
for _, required := range []string{`"operation_id":"` + result.OperationID + `"`, `"phase":"preparation"`, `"agent_code":"invalid_plan"`, `"template_version":"1.1.1"`, `template snapshot not registered`} {
if !strings.Contains(diagnostic.Details, required) {
t.Fatalf("diagnostic details missing %q: %s", required, diagnostic.Details)
}
}
if strings.Contains(diagnostic.Details, "password=") || strings.Contains(diagnostic.Details, "secret=") {
t.Fatalf("diagnostic leaked a secret: %s", diagnostic.Details)
}
}
+3
View File
@@ -160,6 +160,9 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e
} }
if request.DockerUser.Mode == "" { if request.DockerUser.Mode == "" {
request.DockerUser.Mode = DockerUserDoGaMa request.DockerUser.Mode = DockerUserDoGaMa
if snapshot.Template.Container.UserMode == DockerUserImage {
request.DockerUser.Mode = DockerUserImage
}
} }
if err := ValidateDockerUser(request.DockerUser); err != nil { if err := ValidateDockerUser(request.DockerUser); err != nil {
return Preview{}, err return Preview{}, err
+27
View File
@@ -43,6 +43,33 @@ func TestBuildPreviewIsDeterministicAndRedactsSecrets(t *testing.T) {
} }
} }
func TestBuildPreviewUsesTemplateImageUserUnlessAdministratorSelectsOne(t *testing.T) {
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
if err != nil {
t.Fatal(err)
}
request := instance.PreviewRequest{
DisplayName: "Image user", Slug: "image-user", HostPorts: map[string]int{"game": 38211},
MountPaths: map[string]string{"saved": filepath.Join(t.TempDir(), "saved")}, DataOrigin: "new", BackupRetention: 7,
}
preview, err := instance.BuildPreview(snapshots[0], request)
if err != nil {
t.Fatal(err)
}
if preview.DockerUser.Mode != instance.DockerUserImage || preview.DockerUserValue != "" {
t.Fatalf("image-mode preview = %#v", preview.DockerUser)
}
plan, err := preview.DeploymentPlan("abcdefghijklmnopqrstuvwx")
if err != nil || plan.User != "" {
t.Fatalf("image-mode plan user=%q error=%v", plan.User, err)
}
request.DockerUser.Mode = instance.DockerUserDoGaMa
explicit, err := instance.BuildPreview(snapshots[0], request)
if err != nil || explicit.DockerUser.Mode != instance.DockerUserDoGaMa || explicit.DockerUserValue == "" {
t.Fatalf("explicit user preview=%#v error=%v", explicit.DockerUser, err)
}
}
func TestBuildPreviewRejectsPrivatePortPublicationAndLowResources(t *testing.T) { func TestBuildPreviewRejectsPrivatePortPublicationAndLowResources(t *testing.T) {
snapshots, err := catalog.LoadFS(catalogdata.Files, ".") snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
if err != nil { if err != nil {
+48 -3
View File
@@ -63,9 +63,13 @@ func TestCatalogSyncIsImmutableAndDraftPinsSnapshot(t *testing.T) {
if _, err := repository.BeginOperation(ctx, "operation-one", "opaque-instance-id", "install", "installing"); err != nil { if _, err := repository.BeginOperation(ctx, "operation-one", "opaque-instance-id", "install", "installing"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := repository.SetOperationStep(ctx, "operation-one", "installation", "running"); err != nil { t.Fatal(err) } if err := repository.SetOperationStep(ctx, "operation-one", "installation", "running"); err != nil {
t.Fatal(err)
}
progress, err := repository.GetOperationProgress(ctx, "operation-one") progress, err := repository.GetOperationProgress(ctx, "operation-one")
if err != nil || progress.GlobalStatus != "running" || progress.CurrentStep != "installation" || len(progress.Steps) != 7 { t.Fatalf("progress=%#v err=%v", progress, err) } if err != nil || progress.GlobalStatus != "running" || progress.CurrentStep != "installation" || len(progress.Steps) != 7 {
t.Fatalf("progress=%#v err=%v", progress, err)
}
if _, err := repository.BeginOperation(ctx, "operation-two", "opaque-instance-id", "start", "starting"); !errors.Is(err, instance.ErrOperationConflict) { if _, err := repository.BeginOperation(ctx, "operation-two", "opaque-instance-id", "start", "starting"); !errors.Is(err, instance.ErrOperationConflict) {
t.Fatalf("parallel operation error = %v", err) t.Fatalf("parallel operation error = %v", err)
} }
@@ -73,7 +77,9 @@ func TestCatalogSyncIsImmutableAndDraftPinsSnapshot(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
progress, err = repository.GetOperationProgress(ctx, "operation-one") progress, err = repository.GetOperationProgress(ctx, "operation-one")
if err != nil || progress.GlobalStatus != "failed" || progress.ErrorCode != "test_failure" || progress.FinishedAt == "" { t.Fatalf("failed progress=%#v err=%v", progress, err) } if err != nil || progress.GlobalStatus != "failed" || progress.ErrorCode != "test_failure" || progress.FinishedAt == "" {
t.Fatalf("failed progress=%#v err=%v", progress, err)
}
if _, err := repository.BeginOperation(ctx, "operation-three", "opaque-instance-id", "start", "starting"); err != nil { if _, err := repository.BeginOperation(ctx, "operation-three", "opaque-instance-id", "start", "starting"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -126,3 +132,42 @@ func TestReplaceMakesLocalCatalogDiskStateAuthoritative(t *testing.T) {
t.Fatalf("removed list = %#v, %v", list, err) t.Fatalf("removed list = %#v, %v", list, err)
} }
} }
func TestSyncActivatesCurrentBundledSnapshotWithoutDiscardingHistory(t *testing.T) {
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
repository := sqlite.NewRepository(db)
current, err := catalog.LoadFS(catalogdata.Files, ".")
if err != nil || len(current) != 1 {
t.Fatalf("current catalog = %#v, %v", current, err)
}
body, err := catalogdata.Files.ReadFile("palworld/template.yaml")
if err != nil {
t.Fatal(err)
}
oldBody := strings.Replace(string(body), "version: 1.1.1", "version: 1.1.0", 1)
old, err := catalog.Validate([]byte(oldBody), "palworld", catalogdata.Files)
if err != nil {
t.Fatal(err)
}
if err := repository.Replace(ctx, []catalog.Snapshot{old}); err != nil {
t.Fatal(err)
}
if err := repository.Sync(ctx, current); err != nil {
t.Fatal(err)
}
summaries, err := repository.List(ctx)
if err != nil || len(summaries) != 1 {
t.Fatalf("summaries = %#v, error = %v", summaries, err)
}
if summaries[0].Version != current[0].Template.Version || summaries[0].Digest != current[0].Digest {
t.Fatalf("active summary = %#v, want %s@%s", summaries[0], current[0].Template.ID, current[0].Template.Version)
}
if preserved, err := repository.Get(ctx, old.Template.ID, old.Template.Version); err != nil || preserved.Digest != old.Digest {
t.Fatalf("historical snapshot = %#v, error = %v", preserved, err)
}
}
+1
View File
@@ -59,6 +59,7 @@
"image": { "type": "string", "pattern": "^[a-zA-Z0-9._/-]+$", "maxLength": 300 }, "image": { "type": "string", "pattern": "^[a-zA-Z0-9._/-]+$", "maxLength": 300 },
"tag": { "type": "string", "pattern": "^[a-zA-Z0-9._-]+$", "maxLength": 128 }, "tag": { "type": "string", "pattern": "^[a-zA-Z0-9._-]+$", "maxLength": 128 },
"entrypoint": { "type": "array", "items": { "type": "string", "maxLength": 500 }, "maxItems": 8 }, "entrypoint": { "type": "array", "items": { "type": "string", "maxLength": 500 }, "maxItems": 8 },
"user_mode": { "type": "string", "enum": ["dogama", "image"] },
"arguments": { "type": "array", "items": { "type": "string", "maxLength": 500 }, "maxItems": 64 }, "arguments": { "type": "array", "items": { "type": "string", "maxLength": 500 }, "maxItems": 64 },
"environment": { "type": "object", "maxProperties": 64, "additionalProperties": { "type": "string", "maxLength": 4096 }, "propertyNames": { "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" } }, "environment": { "type": "object", "maxProperties": 64, "additionalProperties": { "type": "string", "maxLength": 4096 }, "propertyNames": { "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" } },
"assets": { "assets": {