Merge pull request 'Managed instance lifecycle foundation' (#4) from codex/instance-lifecycle-foundation into main

Reviewed-on: #4
Reviewed-by: tony <1+tony@noreply.localhost>
This commit was merged in pull request #4.
This commit is contained in:
2026-08-07 16:19:26 +02:00
31 changed files with 2435 additions and 86 deletions
+1 -1
View File
@@ -72,7 +72,7 @@ Only the main application's HTTP port is published. The agent and game-managemen
## Status
The first three roadmap foundations are implemented: the main application and authentication, the restricted agent boundary, and the validated embedded catalog with immutable template snapshots, deterministic deployment previews and a SQLite draft-instance registry. Container lifecycle operations and the WebAssembly runtime remain later roadmap work.
The first four roadmap foundations are implemented: the main application and authentication, the restricted agent boundary, the validated embedded catalog and the registered instance lifecycle. Administrators can install, inspect, start, stop, restart and safely remove only a container through authenticated typed operations. SQLite records serialized operation phases and desired/observed state, while the agent independently checks the immutable template, canonical plan, ports, storage, resources and registration binding. Backups, per-instance authorization, updates and the WebAssembly runtime remain later roadmap work.
## Validate the specification
+13 -3
View File
@@ -11,7 +11,9 @@ import (
"syscall"
"time"
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agent"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
)
func main() {
@@ -35,21 +37,29 @@ func run(logger *slog.Logger) error {
if err != nil {
return err
}
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
if err != nil {
return err
}
plans, err := agent.NewPlanPolicy(snapshots, catalogdata.Files)
if err != nil {
return err
}
authenticator, err := agent.NewAuthenticator(config.Secret)
if err != nil {
return err
}
docker, err := agent.NewDockerPinger(config.DockerSocket)
docker, err := agent.NewDockerRuntime(config.DockerSocket, config.DockerNetwork)
if err != nil {
return err
}
handler := agent.NewHandler(authenticator, paths, registry, docker, logger)
handler := agent.NewHandler(authenticator, paths, plans, registry, docker, logger)
server := &http.Server{
Addr: config.ListenAddress,
Handler: handler,
ReadHeaderTimeout: 3 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Minute,
IdleTimeout: 30 * time.Second,
MaxHeaderBytes: 16 << 10,
}
+55 -2
View File
@@ -2,6 +2,7 @@
package main
import (
"bytes"
"context"
"errors"
"log/slog"
@@ -12,8 +13,10 @@ import (
"time"
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentclient"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/web"
)
@@ -46,16 +49,49 @@ func run(logger *slog.Logger) error {
return err
}
logger.Info("local catalog synchronized", "event", "catalog.synchronized", "template_count", len(snapshots))
handler, err := web.NewHandlerWithRepository(auth.New(db), repository, logger)
var handler http.Handler
var lifecycle *instance.LifecycleService
agentURL, tokenFile := os.Getenv("DOGAMA_AGENT_URL"), os.Getenv("DOGAMA_AGENT_TOKEN_FILE")
if agentURL == "" && tokenFile == "" {
logger.Warn("instance lifecycle disabled", "event", "lifecycle.disabled")
handler, err = web.NewHandlerWithRepository(auth.New(db), repository, logger)
} else {
if agentURL == "" || tokenFile == "" {
return errors.New("DOGAMA_AGENT_URL and DOGAMA_AGENT_TOKEN_FILE must be configured together")
}
secret, readErr := os.ReadFile(tokenFile)
if readErr != nil {
return errors.New("read agent token file")
}
secret = bytes.TrimSuffix(bytes.TrimSuffix(secret, []byte("\n")), []byte("\r"))
agent, clientErr := agentclient.New(agentURL, secret, &http.Client{Timeout: 15 * time.Minute})
if clientErr != nil {
return clientErr
}
lifecycle = instance.NewLifecycleService(repository, agent)
reconcileCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
if recoverErr := lifecycle.RecoverInterruptedOperations(reconcileCtx); recoverErr != nil {
cancel()
return recoverErr
}
if reconcileErr := lifecycle.ReconcileAll(reconcileCtx); reconcileErr != nil {
logger.Warn("instance reconciliation incomplete", "event", "lifecycle.reconcile.failed")
}
cancel()
handler, err = web.NewHandlerWithLifecycle(auth.New(db), repository, agent, logger)
}
if err != nil {
return err
}
if lifecycle != nil {
go reconcileInstances(ctx, lifecycle, logger)
}
server := &http.Server{
Addr: listenAddress,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
WriteTimeout: 15 * time.Minute,
IdleTimeout: 60 * time.Second,
}
errCh := make(chan error, 1)
@@ -76,6 +112,23 @@ func run(logger *slog.Logger) error {
}
}
func reconcileInstances(ctx context.Context, lifecycle *instance.LifecycleService, logger *slog.Logger) {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
reconcileCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
if err := lifecycle.ReconcileAll(reconcileCtx); err != nil {
logger.Warn("periodic instance reconciliation incomplete", "event", "lifecycle.reconcile.failed")
}
cancel()
}
}
}
func environment(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
+2
View File
@@ -33,6 +33,7 @@ services:
DOGAMA_AGENT_TOKEN_FILE: /run/secrets/agent_token
DOGAMA_AGENT_REGISTRY_PATH: /var/lib/dogama-agent/registry.json
DOGAMA_DOCKER_SOCKET: /var/run/docker.sock
DOGAMA_DOCKER_NETWORK: dogama-games
DOGAMA_ALLOWED_SERVER_ROOT: /srv/game-servers
DOGAMA_ALLOWED_BACKUP_ROOT: /srv/game-backups
secrets:
@@ -55,6 +56,7 @@ networks:
control:
internal: true
games:
name: dogama-games
volumes:
agent_state:
+17 -6
View File
@@ -7,19 +7,24 @@ The agent reduces the chance that an application bug becomes arbitrary Docker co
## Private API foundation
The same-host V1 agent listens on the private control network only. The current
foundation exposes three authenticated routes:
foundation exposes authenticated health, capacity and typed lifecycle routes:
- `GET /v1/health` verifies that the configured Docker Unix socket answers its
bounded `_ping` request without exposing daemon details;
- `POST /v1/check-disk` accepts at most 16 existing absolute paths and returns
capacity only after symlink-aware allowed-root validation;
- `GET /v1/instances` returns only entries from the authenticated agent-local
registry.
registry;
- `POST /v1/check-ports` reports only whether requested bindings are available;
- `POST /v1/instances` creates a validated and registered container;
- `GET /v1/instances/{id}` and `/stats` inspect only a bound registration;
- typed `start`, `stop`, `restart` and container `DELETE` routes operate only on
that registered identity.
Every route, including health, requires request authentication. Container
creation and mutation routes remain closed until the canonical deployment-plan
contract and template validation are implemented in the following roadmap
milestones. The agent never exposes its internal Docker HTTP client as a proxy.
Every route, including health, requires request authentication. The agent never
exposes its internal Docker HTTP client as a proxy. Lifecycle requests are
idempotent where meaningful and re-inspect the container identity and binding
labels before mutation.
## Allowed V1 operations
@@ -66,6 +71,12 @@ Before create or replace, the agent verifies:
- labels use the reserved namespace and cannot be overridden;
- only approved DoGaMa networks are attached.
The canonical plan digest alone is not treated as approval. The agent embeds and
validates the same catalog, then independently compares every privileged field
to the pinned immutable template snapshot before checking paths or pulling an
image. A caller cannot make a substituted image or mount valid merely by
recomputing a digest.
V1 templates do not expose arbitrary Docker security options. The agent applies a secure fixed baseline: no-new-privileges where compatible, dropped capabilities by default, bounded PIDs and a non-host network mode.
## Authentication and replay defense
+17 -2
View File
@@ -28,7 +28,7 @@ tests/integration/
## Initial application development
The initial main application requires Go 1.25. SQLite is provided by the pure-Go `modernc.org/sqlite` driver, so neither cgo nor a system SQLite development library is required. It reads only bootstrap settings from `DOGAMA_LISTEN_ADDRESS` (default `:8080`) and `DOGAMA_DATABASE_PATH` (default `dogama.db`). Run it with:
The initial main application requires Go 1.25. SQLite is provided by the pure-Go `modernc.org/sqlite` driver, so neither cgo nor a system SQLite development library is required. It reads bootstrap settings from `DOGAMA_LISTEN_ADDRESS` (default `:8080`), `DOGAMA_DATABASE_PATH` (default `dogama.db`), `DOGAMA_AGENT_URL` and `DOGAMA_AGENT_TOKEN_FILE`. The two agent settings must either both be present or both be absent; lifecycle routes remain disabled when developing without an agent. Run it with:
```sh
go run ./cmd/dogama
@@ -45,12 +45,27 @@ go run ./cmd/dogama-agent
It fails closed unless `DOGAMA_AGENT_TOKEN_FILE` references a 32-byte-or-longer
secret and at least one of `DOGAMA_ALLOWED_SERVER_ROOT` or
`DOGAMA_ALLOWED_BACKUP_ROOT` is configured. Its bootstrap-only defaults are
`:8081`, `/var/run/docker.sock` and
`:8081`, `/var/run/docker.sock`, the fixed `dogama-games` Docker network and
`/var/lib/dogama-agent/registry.json`. The configured roots must already exist
and are canonicalized with symlinks resolved. For normal deployment, use the
secret file and private control network defined in `compose.yaml`; never publish
the agent port on the host.
The agent loads the same embedded validated catalog as the main application.
Before Docker access it independently matches image, entrypoint, arguments,
container ports, mount destinations, resource minimums and stop timeout against
the pinned template snapshot. Mount sources are created one directory at a time
below configured roots with symlinks refused. Docker containers always use the
fixed restricted baseline; callers cannot provide labels, capabilities, devices,
network modes or arbitrary Docker options.
Lifecycle API operations are administrator-only until the per-instance
authorization milestone. Install, start, stop, restart and container-only delete
are serialized per instance and recorded in `instance_operations`. Desired and
observed states are reconciled at startup and every minute. Container-only delete
removes neither the SQLite intent nor host paths; player data and backups remain
untouched and the missing container stays visible for reconciliation.
At main-application startup, every embedded `catalog/*/template.yaml` is
validated against `specs/template.schema.json`, checked for cross-reference and
asset integrity, canonicalized deterministically and synchronized into SQLite.
+8 -1
View File
@@ -12,6 +12,14 @@ any state -> error | unknown | intervention_required
`container_running` is an observation, not the `online` state. Online requires the template health probe or module readiness check to succeed within its startup timeout.
The milestone-4 foundation persists each mutually exclusive action before
dispatch and keeps desired lifecycle state separate from observed Docker state.
It reconciles registered instances at application startup and periodically.
Until a template's module readiness adapter is available, a running container
whose readiness cannot be established is `degraded`, never optimistically
`online`. Docker automatic restart is disabled, so later scheduler work cannot
create an unbounded crash loop before the circuit-breaker policy is implemented.
## Creation
1. Select a validated template version.
@@ -73,4 +81,3 @@ Scopes 3 and 4 are off by default, require typed-name confirmation and are audit
## Reconciliation
At startup and periodically, compare desired registry state with agent-inspected registered instances. Unknown Docker containers are ignored. Missing or altered managed containers become `unknown` or `intervention_required`; DoGaMa does not silently recreate or adopt them when data safety is uncertain.
+78
View File
@@ -0,0 +1,78 @@
package agent
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
)
func (s *service) prepareAssets(plan agentwire.DeploymentPlan) ([]AssetMount, error) {
approved, err := s.plans.Assets(plan)
if err != nil || len(approved) == 0 {
return nil, err
}
assetRoot := filepath.Join(filepath.Dir(plan.Mounts[0].HostPath), ".dogama", "assets")
assetRoot, err = s.paths.Prepare(assetRoot)
if err != nil {
return nil, errors.New("asset root is not allowed")
}
result := make([]AssetMount, 0, len(approved))
for _, asset := range approved {
digest := sha256.Sum256(asset.Content)
if hex.EncodeToString(digest[:]) != asset.SHA256 {
return nil, errors.New("approved asset integrity check failed")
}
target := filepath.Join(assetRoot, asset.SHA256)
if err := writeImmutableAsset(target, asset.Content); err != nil {
return nil, err
}
result = append(result, AssetMount{HostPath: target, ContainerPath: asset.Destination})
}
return result, nil
}
func writeImmutableAsset(path string, content []byte) error {
if info, err := os.Lstat(path); err == nil {
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return errors.New("approved asset path is not a regular file")
}
existing, err := os.ReadFile(path)
if err != nil {
return errors.New("read approved asset")
}
existingDigest, wantedDigest := sha256.Sum256(existing), sha256.Sum256(content)
if existingDigest != wantedDigest {
return errors.New("approved asset content conflict")
}
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return errors.New("inspect approved asset path")
}
temporary, err := os.CreateTemp(filepath.Dir(path), ".asset-*")
if err != nil {
return fmt.Errorf("create approved asset: %w", err)
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if err = temporary.Chmod(0o500); err == nil {
_, err = temporary.Write(content)
}
if err == nil {
err = temporary.Sync()
}
if closeErr := temporary.Close(); err == nil {
err = closeErr
}
if err != nil {
return errors.New("write approved asset")
}
if err := os.Rename(temporaryPath, path); err != nil {
return errors.New("publish approved asset")
}
return nil
}
+6
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
)
// Config contains bootstrap-only settings for the restricted agent.
@@ -15,6 +16,7 @@ type Config struct {
AllowedRoots []string
RegistryPath string
DockerSocket string
DockerNetwork string
}
// LoadConfig reads the agent's bootstrap settings and shared secret file.
@@ -42,10 +44,14 @@ func LoadConfig() (Config, error) {
AllowedRoots: roots,
RegistryPath: environment("DOGAMA_AGENT_REGISTRY_PATH", "/var/lib/dogama-agent/registry.json"),
DockerSocket: environment("DOGAMA_DOCKER_SOCKET", "/var/run/docker.sock"),
DockerNetwork: environment("DOGAMA_DOCKER_NETWORK", "dogama-games"),
}
if !filepath.IsAbs(config.RegistryPath) || !filepath.IsAbs(config.DockerSocket) {
return Config{}, errors.New("registry and Docker socket paths must be absolute")
}
if !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`).MatchString(config.DockerNetwork) {
return Config{}, errors.New("docker network name is invalid")
}
return config, nil
}
+1 -1
View File
@@ -27,7 +27,7 @@ func TestLoadConfigReadsSecretFileAndRoots(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(config.Secret, secret) || len(config.AllowedRoots) != 1 || config.ListenAddress != ":8081" {
if !bytes.Equal(config.Secret, secret) || len(config.AllowedRoots) != 1 || config.ListenAddress != ":8081" || config.DockerNetwork != "dogama-games" {
t.Fatalf("config = %#v", config)
}
}
+305 -18
View File
@@ -1,32 +1,62 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
)
// DockerPinger is the only Docker capability needed by the foundation agent.
// Container mutation is added only with validated deployment plans.
type DockerPinger interface {
const dockerAPIVersion = "/v1.41"
type DockerRuntime interface {
Ping(context.Context) error
CheckPorts(context.Context, []agentwire.PlanPort) error
Create(context.Context, agentwire.DeploymentPlan, []AssetMount) (string, error)
Start(context.Context, string) error
Stop(context.Context, string, int) error
Restart(context.Context, string, int) error
Delete(context.Context, string) error
Inspect(context.Context, string) (DockerInspection, error)
Stats(context.Context, string) (agentwire.InstanceStats, error)
}
type dockerPinger struct {
client *http.Client
type DockerInspection struct {
ContainerID string
Running bool
Health string
ExitCode int
Labels map[string]string
}
// NewDockerPinger constructs a client pinned to one configured Unix socket.
func NewDockerPinger(socketPath string) (DockerPinger, error) {
type AssetMount struct {
HostPath string
ContainerPath string
}
type dockerRuntime struct {
client *http.Client
network string
}
func NewDockerRuntime(socketPath, network string) (DockerRuntime, error) {
if !filepath.IsAbs(socketPath) {
return nil, errors.New("docker socket path must be absolute")
}
if strings.TrimSpace(network) == "" {
return nil, errors.New("docker network is required")
}
dialer := &net.Dialer{Timeout: 2 * time.Second}
transport := &http.Transport{
DisableCompression: true,
@@ -34,22 +64,279 @@ func NewDockerPinger(socketPath string) (DockerPinger, error) {
return dialer.DialContext(ctx, "unix", socketPath)
},
}
return &dockerPinger{client: &http.Client{Transport: transport, Timeout: 3 * time.Second}}, nil
return &dockerRuntime{client: &http.Client{Transport: transport, Timeout: 10 * time.Minute}, network: network}, nil
}
func (d *dockerPinger) Ping(ctx context.Context) error {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://docker/_ping", nil)
if err != nil {
return err
}
response, err := d.client.Do(request)
if err != nil {
func (d *dockerRuntime) Ping(ctx context.Context) error {
response, err := d.call(ctx, http.MethodGet, "/_ping", nil, "", 16)
if err != nil || response.status != http.StatusOK || strings.TrimSpace(string(response.body)) != "OK" {
return errors.New("docker daemon is unavailable")
}
return nil
}
func (d *dockerRuntime) CheckPorts(ctx context.Context, ports []agentwire.PlanPort) error {
response, err := d.call(ctx, http.MethodGet, dockerAPIVersion+"/containers/json?all=1", nil, "", 1<<20)
if err != nil || response.status != http.StatusOK {
return errors.New("host port availability check failed")
}
var containers []struct {
Ports []struct {
PublicPort int `json:"PublicPort"`
Type string `json:"Type"`
} `json:"Ports"`
}
if json.Unmarshal(response.body, &containers) != nil {
return errors.New("host port availability check failed")
}
used := make(map[string]struct{})
for _, container := range containers {
for _, port := range container.Ports {
if port.PublicPort > 0 {
used[fmt.Sprintf("%s/%d", port.Type, port.PublicPort)] = struct{}{}
}
}
}
for _, port := range ports {
if !port.Publish {
continue
}
if _, exists := used[fmt.Sprintf("%s/%d", port.Protocol, port.HostPort)]; exists {
return errors.New("requested host port is unavailable")
}
}
return nil
}
func (d *dockerRuntime) Create(ctx context.Context, plan agentwire.DeploymentPlan, assets []AssetMount) (string, error) {
pullPath := dockerAPIVersion + "/images/create?fromImage=" + url.QueryEscape(plan.Image)
response, err := d.call(ctx, http.MethodPost, pullPath, nil, "", 8<<20)
if err != nil || response.status < 200 || response.status >= 300 || !validPullResponse(response.body) {
return "", errors.New("docker image pull failed")
}
type portBinding struct {
HostIP string `json:"HostIp"`
HostPort string `json:"HostPort"`
}
exposed := make(map[string]struct{}, len(plan.Ports))
bindings := make(map[string][]portBinding)
for _, port := range plan.Ports {
key := fmt.Sprintf("%d/%s", port.ContainerPort, port.Protocol)
exposed[key] = struct{}{}
if port.Publish {
bindings[key] = []portBinding{{HostIP: "0.0.0.0", HostPort: strconv.Itoa(port.HostPort)}}
}
}
binds := make([]string, 0, len(plan.Mounts))
for _, mount := range plan.Mounts {
mode := "rw"
if mount.ReadOnly {
mode = "ro"
}
binds = append(binds, mount.HostPath+":"+mount.ContainerPath+":"+mode)
}
for _, asset := range assets {
binds = append(binds, asset.HostPath+":"+asset.ContainerPath+":ro")
}
pidsLimit := int64(512)
payload := struct {
Image string `json:"Image"`
Entrypoint []string `json:"Entrypoint,omitempty"`
Cmd []string `json:"Cmd,omitempty"`
Labels map[string]string `json:"Labels"`
ExposedPorts map[string]struct{} `json:"ExposedPorts"`
HostConfig struct {
Binds []string `json:"Binds"`
PortBindings map[string][]portBinding `json:"PortBindings"`
Memory int64 `json:"Memory"`
NanoCPUs int64 `json:"NanoCpus"`
PidsLimit *int64 `json:"PidsLimit"`
CapDrop []string `json:"CapDrop"`
SecurityOpt []string `json:"SecurityOpt"`
NetworkMode string `json:"NetworkMode"`
RestartPolicy map[string]string `json:"RestartPolicy"`
} `json:"HostConfig"`
}{
Image: plan.Image, Entrypoint: plan.Entrypoint, Cmd: plan.Arguments,
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
payload.HostConfig.PortBindings = bindings
payload.HostConfig.Memory = int64(plan.Resources.MemoryMB) * 1024 * 1024
payload.HostConfig.NanoCPUs = int64(plan.Resources.CPUCores * 1_000_000_000)
payload.HostConfig.PidsLimit = &pidsLimit
payload.HostConfig.CapDrop = []string{"ALL"}
payload.HostConfig.SecurityOpt = []string{"no-new-privileges:true"}
payload.HostConfig.NetworkMode = d.network
payload.HostConfig.RestartPolicy = map[string]string{"Name": "no"}
name := "dogama-" + strings.ToLower(plan.InstanceID)
response, err = d.call(ctx, http.MethodPost, dockerAPIVersion+"/containers/create?name="+url.QueryEscape(name), payload, "application/json", 64<<10)
if err != nil || response.status < 200 || response.status >= 300 {
d.cleanupPartialCreate(ctx, name, plan)
return "", errors.New("docker container creation failed")
}
var created struct {
ID string `json:"Id"`
}
if json.Unmarshal(response.body, &created) != nil || created.ID == "" {
d.cleanupPartialCreate(ctx, name, plan)
return "", errors.New("docker returned an invalid container identity")
}
return created.ID, nil
}
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 {
return
}
_ = d.Delete(ctx, inspection.ContainerID)
}
func validPullResponse(body []byte) bool {
decoder := json.NewDecoder(bytes.NewReader(body))
seen := false
for {
var event struct {
Error string `json:"error"`
ErrorDetail *struct {
Message string `json:"message"`
} `json:"errorDetail"`
}
err := decoder.Decode(&event)
if errors.Is(err, io.EOF) {
return seen
}
if err != nil || event.Error != "" || event.ErrorDetail != nil {
return false
}
seen = true
}
}
func (d *dockerRuntime) Start(ctx context.Context, id string) error {
return d.expectNoContent(ctx, http.MethodPost, dockerAPIVersion+"/containers/"+url.PathEscape(id)+"/start")
}
func (d *dockerRuntime) Stop(ctx context.Context, id string, timeout int) error {
return d.expectNoContent(ctx, http.MethodPost, dockerAPIVersion+"/containers/"+url.PathEscape(id)+"/stop?t="+strconv.Itoa(timeout))
}
func (d *dockerRuntime) Restart(ctx context.Context, id string, timeout int) error {
return d.expectNoContent(ctx, http.MethodPost, dockerAPIVersion+"/containers/"+url.PathEscape(id)+"/restart?t="+strconv.Itoa(timeout))
}
func (d *dockerRuntime) Delete(ctx context.Context, id string) error {
return d.expectNoContent(ctx, http.MethodDelete, dockerAPIVersion+"/containers/"+url.PathEscape(id))
}
func (d *dockerRuntime) Inspect(ctx context.Context, id string) (DockerInspection, error) {
response, err := d.call(ctx, http.MethodGet, dockerAPIVersion+"/containers/"+url.PathEscape(id)+"/json", nil, "", 256<<10)
if err != nil || response.status != http.StatusOK {
return DockerInspection{}, errors.New("registered container inspection failed")
}
var payload struct {
ID string `json:"Id"`
Config struct {
Labels map[string]string `json:"Labels"`
} `json:"Config"`
State struct {
Running bool `json:"Running"`
ExitCode int `json:"ExitCode"`
Health *struct {
Status string `json:"Status"`
} `json:"Health"`
} `json:"State"`
}
if json.Unmarshal(response.body, &payload) != nil || payload.ID == "" {
return DockerInspection{}, errors.New("docker returned invalid inspection data")
}
health := "none"
if payload.State.Health != nil {
health = payload.State.Health.Status
}
return DockerInspection{ContainerID: payload.ID, Running: payload.State.Running, Health: health, ExitCode: payload.State.ExitCode, Labels: payload.Config.Labels}, nil
}
func (d *dockerRuntime) Stats(ctx context.Context, id string) (agentwire.InstanceStats, error) {
response, err := d.call(ctx, http.MethodGet, dockerAPIVersion+"/containers/"+url.PathEscape(id)+"/stats?stream=false&one-shot=true", nil, "", 512<<10)
if err != nil || response.status != http.StatusOK {
return agentwire.InstanceStats{}, errors.New("registered container statistics failed")
}
var payload struct {
CPUStats struct {
CPUUsage struct {
TotalUsage uint64 `json:"total_usage"`
} `json:"cpu_usage"`
SystemUsage uint64 `json:"system_cpu_usage"`
OnlineCPUs uint32 `json:"online_cpus"`
} `json:"cpu_stats"`
PreCPUStats struct {
CPUUsage struct {
TotalUsage uint64 `json:"total_usage"`
} `json:"cpu_usage"`
SystemUsage uint64 `json:"system_cpu_usage"`
} `json:"precpu_stats"`
MemoryStats struct{ Usage, Limit uint64 } `json:"memory_stats"`
}
if json.Unmarshal(response.body, &payload) != nil {
return agentwire.InstanceStats{}, errors.New("docker returned invalid statistics")
}
cpuDelta := payload.CPUStats.CPUUsage.TotalUsage - payload.PreCPUStats.CPUUsage.TotalUsage
systemDelta := payload.CPUStats.SystemUsage - payload.PreCPUStats.SystemUsage
percentage := 0.0
if systemDelta > 0 {
cpus := payload.CPUStats.OnlineCPUs
if cpus == 0 {
cpus = 1
}
percentage = float64(cpuDelta) / float64(systemDelta) * float64(cpus) * 100
}
return agentwire.InstanceStats{CPUPercentage: percentage, MemoryBytes: payload.MemoryStats.Usage, MemoryLimit: payload.MemoryStats.Limit}, nil
}
type dockerResponse struct {
status int
body []byte
}
func (d *dockerRuntime) call(ctx context.Context, method, path string, input any, contentType string, limit int64) (dockerResponse, error) {
var body io.Reader
if input != nil {
encoded, err := json.Marshal(input)
if err != nil {
return dockerResponse{}, err
}
body = bytes.NewReader(encoded)
}
request, err := http.NewRequestWithContext(ctx, method, "http://docker"+path, body)
if err != nil {
return dockerResponse{}, err
}
if contentType != "" {
request.Header.Set("Content-Type", contentType)
}
response, err := d.client.Do(request)
if err != nil {
return dockerResponse{}, errors.New("docker daemon is unavailable")
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, 16))
if err != nil || response.StatusCode != http.StatusOK || strings.TrimSpace(string(body)) != "OK" {
return fmt.Errorf("docker daemon ping failed with HTTP %d", response.StatusCode)
responseBody, err := io.ReadAll(io.LimitReader(response.Body, limit+1))
if err != nil || int64(len(responseBody)) > limit {
return dockerResponse{}, errors.New("docker response is invalid")
}
return dockerResponse{status: response.StatusCode, body: responseBody}, nil
}
func (d *dockerRuntime) expectNoContent(ctx context.Context, method, path string) error {
response, err := d.call(ctx, method, path, nil, "", 64<<10)
if err != nil || (response.status != http.StatusNoContent && response.status != http.StatusNotModified) {
return errors.New("docker lifecycle operation failed")
}
return nil
}
+72 -2
View File
@@ -2,10 +2,15 @@ package agent
import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"path/filepath"
"strings"
"testing"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
)
func TestDockerPingerUsesConfiguredUnixSocket(t *testing.T) {
@@ -23,7 +28,7 @@ func TestDockerPingerUsesConfiguredUnixSocket(t *testing.T) {
})}
go func() { _ = server.Serve(listener) }()
t.Cleanup(func() { _ = server.Shutdown(context.Background()) })
pinger, err := NewDockerPinger(socket)
pinger, err := NewDockerRuntime(socket, "dogama-games")
if err != nil {
t.Fatal(err)
}
@@ -32,8 +37,73 @@ func TestDockerPingerUsesConfiguredUnixSocket(t *testing.T) {
}
}
func TestDockerRuntimeCreatesFixedSecurityBaseline(t *testing.T) {
socket := filepath.Join(t.TempDir(), "docker.sock")
listener, err := net.Listen("unix", socket)
if err != nil {
t.Fatal(err)
}
createdBodies := make(chan []byte, 1)
server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/v1.41/images/create":
_, _ = w.Write([]byte("{}\n"))
case r.URL.Path == "/v1.41/containers/json":
_, _ = w.Write([]byte("[]"))
case r.URL.Path == "/v1.41/containers/create":
body, _ := io.ReadAll(r.Body)
createdBodies <- body
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"Id":"container-1"}`))
default:
http.NotFound(w, r)
}
})}
go func() { _ = server.Serve(listener) }()
t.Cleanup(func() { _ = server.Shutdown(context.Background()) })
runtime, err := NewDockerRuntime(socket, "dogama-games")
if err != nil {
t.Fatal(err)
}
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},
}
if err := runtime.CheckPorts(context.Background(), plan.Ports); err != nil {
t.Fatal(err)
}
id, err := runtime.Create(context.Background(), plan, []AssetMount{{HostPath: "/srv/games/.dogama/helper", ContainerPath: "/pal/helper.sh"}})
if err != nil {
t.Fatal(err)
}
if id != "container-1" {
t.Fatalf("container ID = %q", id)
}
var payload struct {
Labels map[string]string `json:"Labels"`
HostConfig struct {
NetworkMode string `json:"NetworkMode"`
CapDrop []string `json:"CapDrop"`
SecurityOpt []string `json:"SecurityOpt"`
Memory int64 `json:"Memory"`
NanoCPUs int64 `json:"NanoCpus"`
} `json:"HostConfig"`
}
if err := json.Unmarshal(<-createdBodies, &payload); err != nil {
t.Fatal(err)
}
if payload.HostConfig.NetworkMode != "dogama-games" || len(payload.HostConfig.CapDrop) != 1 || payload.HostConfig.CapDrop[0] != "ALL" || len(payload.HostConfig.SecurityOpt) != 1 || payload.HostConfig.Memory <= 0 || payload.HostConfig.NanoCPUs <= 0 {
t.Fatalf("insecure Docker host config: %#v", payload.HostConfig)
}
if payload.Labels["io.dogama.instance-id"] != plan.InstanceID || payload.Labels["io.dogama.plan-digest"] != plan.PlanDigest {
t.Fatalf("binding labels = %#v", payload.Labels)
}
}
func TestDockerPingerDoesNotExposeConnectionDetails(t *testing.T) {
pinger, err := NewDockerPinger(filepath.Join(t.TempDir(), "missing.sock"))
pinger, err := NewDockerRuntime(filepath.Join(t.TempDir(), "missing.sock"), "dogama-games")
if err != nil {
t.Fatal(err)
}
+51 -5
View File
@@ -58,17 +58,63 @@ func (p *PathPolicy) Resolve(path string) (string, error) {
return "", ErrInvalidPath
}
for _, root := range p.roots {
relative, err := filepath.Rel(root, resolved)
if err != nil {
continue
}
if relative == "." || (relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))) {
if withinRoot(root, resolved) {
return resolved, nil
}
}
return "", ErrPathOutsideRoots
}
// Prepare creates a deployment mount below an allowed root one directory at a
// time and refuses every symlink in the path. Existing data is never replaced.
func (p *PathPolicy) Prepare(path string) (string, error) {
if path == "" || !filepath.IsAbs(path) || filepath.Clean(path) != path {
return "", ErrInvalidPath
}
for _, root := range p.roots {
if !withinRoot(root, path) || path == root {
continue
}
relative, err := filepath.Rel(root, path)
if err != nil {
continue
}
current := root
valid := true
for _, component := range strings.Split(relative, string(filepath.Separator)) {
if component == "" || component == "." || component == ".." {
valid = false
break
}
current = filepath.Join(current, component)
info, err := os.Lstat(current)
if errors.Is(err, os.ErrNotExist) {
if err := os.Mkdir(current, 0o700); err != nil {
valid = false
break
}
info, err = os.Lstat(current)
}
if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
valid = false
break
}
}
if valid {
resolved, err := filepath.EvalSymlinks(path)
if err == nil && withinRoot(root, resolved) {
return resolved, nil
}
}
}
return "", ErrPathOutsideRoots
}
func withinRoot(root, path string) bool {
relative, err := filepath.Rel(root, path)
return err == nil && (relative == "." || (relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))))
}
// RootCount returns the number of canonical roots without disclosing them.
func (p *PathPolicy) RootCount() int {
return len(p.roots)
+20
View File
@@ -70,3 +70,23 @@ func TestPathPolicyRejectsSymlinkEscape(t *testing.T) {
t.Fatalf("symlink escape error = %v", err)
}
}
func TestPathPolicyPreparesOnlyNonSymlinkDirectoriesBelowRoot(t *testing.T) {
root := t.TempDir()
policy, err := NewPathPolicy([]string{root})
if err != nil {
t.Fatal(err)
}
prepared := filepath.Join(root, "instance-a", "saved")
resolved, err := policy.Prepare(prepared)
if err != nil || resolved != prepared {
t.Fatalf("Prepare() = %q, %v", resolved, err)
}
outside := t.TempDir()
if err := os.Symlink(outside, filepath.Join(root, "escape")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
if _, err := policy.Prepare(filepath.Join(root, "escape", "saved")); err == nil {
t.Fatal("symlink escape was prepared")
}
}
+104
View File
@@ -0,0 +1,104 @@
package agent
import (
"errors"
"fmt"
"io/fs"
"path"
"reflect"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
)
// PlanPolicy independently binds privileged deployment fields to validated,
// embedded template snapshots. The agent never trusts a caller-supplied image,
// container port, mount destination or security-sensitive command by itself.
type PlanPolicy struct {
snapshots map[string]catalog.Snapshot
assets fs.FS
}
func NewPlanPolicy(snapshots []catalog.Snapshot, assets fs.FS) (*PlanPolicy, error) {
if len(snapshots) == 0 {
return nil, errors.New("agent plan policy requires validated templates")
}
if assets == nil {
return nil, errors.New("agent plan policy requires embedded assets")
}
policy := &PlanPolicy{snapshots: make(map[string]catalog.Snapshot, len(snapshots)), assets: assets}
for _, snapshot := range snapshots {
key := snapshot.Template.ID + "@" + snapshot.Template.Version
if _, exists := policy.snapshots[key]; exists {
return nil, errors.New("duplicate agent template snapshot")
}
policy.snapshots[key] = snapshot
}
return policy, nil
}
type ApprovedAsset struct {
Destination string
SHA256 string
Content []byte
}
func (p *PlanPolicy) Assets(plan agentwire.DeploymentPlan) ([]ApprovedAsset, error) {
snapshot, ok := p.snapshots[plan.TemplateID+"@"+plan.TemplateVersion]
if !ok || snapshot.Digest != plan.TemplateDigest {
return nil, errors.New("unknown template snapshot")
}
result := make([]ApprovedAsset, 0, len(snapshot.Template.Container.Assets))
for _, asset := range snapshot.Template.Container.Assets {
if !asset.ReadOnly {
return nil, errors.New("writable template asset is not allowed")
}
content, err := fs.ReadFile(p.assets, path.Join(snapshot.AssetRoot, asset.Source))
if err != nil {
return nil, fmt.Errorf("read approved template asset: %w", err)
}
result = append(result, ApprovedAsset{Destination: asset.Destination, SHA256: asset.SHA256, Content: content})
}
return result, nil
}
func (p *PlanPolicy) Validate(plan agentwire.DeploymentPlan) error {
if p == nil || plan.Validate() != nil {
return errors.New("invalid deployment plan")
}
snapshot, ok := p.snapshots[plan.TemplateID+"@"+plan.TemplateVersion]
if !ok || snapshot.Digest != plan.TemplateDigest {
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 {
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 {
return errors.New("container resources are below template minimum")
}
if len(plan.Ports) != len(template.Container.Ports) || len(plan.Mounts) != len(template.Storage.Mounts) {
return errors.New("container plan shape differs from template")
}
ports := make(map[string]agentwire.PlanPort, len(plan.Ports))
for _, port := range plan.Ports {
ports[port.ID] = port
}
for _, expected := range template.Container.Ports {
actual, ok := ports[expected.ID]
if !ok || actual.Protocol != expected.Protocol || actual.ContainerPort != expected.ContainerPort || actual.Publish != expected.Publish {
return errors.New("container port differs from template")
}
}
mounts := make(map[string]agentwire.PlanMount, len(plan.Mounts))
for _, mount := range plan.Mounts {
mounts[mount.ID] = mount
}
for _, expected := range template.Storage.Mounts {
actual, ok := mounts[expected.ID]
if !ok || actual.ContainerPath != expected.ContainerPath || actual.ReadOnly != expected.ReadOnly {
return errors.New("container mount differs from template")
}
}
return nil
}
+51
View File
@@ -68,6 +68,53 @@ func (r *Registry) List() []RegisteredInstance {
return result
}
func (r *Registry) Get(instanceID string) (RegisteredInstance, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
for _, entry := range r.instances {
if entry.InstanceID == instanceID {
return entry, true
}
}
return RegisteredInstance{}, false
}
func (r *Registry) Register(entry RegisteredInstance) error {
if entry.InstanceID == "" || entry.ContainerID == "" || entry.PlanDigest == "" {
return errors.New("incomplete agent registration")
}
r.mu.Lock()
defer r.mu.Unlock()
for _, existing := range r.instances {
if existing.InstanceID == entry.InstanceID || existing.ContainerID == entry.ContainerID {
if existing == entry {
return nil
}
return errors.New("instance registration conflict")
}
}
instances := append(append([]RegisteredInstance(nil), r.instances...), entry)
return r.saveLocked(instances)
}
func (r *Registry) Remove(instanceID string) error {
r.mu.Lock()
defer r.mu.Unlock()
instances := make([]RegisteredInstance, 0, len(r.instances))
found := false
for _, entry := range r.instances {
if entry.InstanceID == instanceID {
found = true
continue
}
instances = append(instances, entry)
}
if !found {
return errors.New("instance is not registered")
}
return r.saveLocked(instances)
}
func (r *Registry) load() error {
info, err := os.Lstat(r.path)
if err != nil {
@@ -92,6 +139,10 @@ func (r *Registry) load() error {
func (r *Registry) save(instances []RegisteredInstance) error {
r.mu.Lock()
defer r.mu.Unlock()
return r.saveLocked(instances)
}
func (r *Registry) saveLocked(instances []RegisteredInstance) error {
payload := registryPayload{Version: registryVersion, Instances: append([]RegisteredInstance(nil), instances...)}
envelope := registryEnvelope{Payload: payload, MAC: r.mac(payload)}
body, err := json.Marshal(envelope)
+266 -8
View File
@@ -10,6 +10,8 @@ import (
"net/http"
"syscall"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
)
const maxDiskPaths = 16
@@ -17,17 +19,45 @@ const maxDiskPaths = 16
type service struct {
paths *PathPolicy
registry *Registry
docker DockerPinger
plans *PlanPolicy
docker DockerRuntime
logger *slog.Logger
disk DiskChecker
}
type DiskChecker interface {
AvailableBytes(string) (uint64, uint64, error)
}
type statfsDiskChecker struct{}
func (statfsDiskChecker) AvailableBytes(path string) (uint64, uint64, error) {
var filesystem syscall.Statfs_t
if err := syscall.Statfs(path, &filesystem); err != nil || filesystem.Bsize <= 0 {
return 0, 0, errors.New("filesystem unavailable")
}
blockSize := uint64(filesystem.Bsize)
return blockSize * filesystem.Bavail, blockSize * filesystem.Blocks, nil
}
// NewHandler constructs the complete authenticated private agent API.
func NewHandler(authenticator *Authenticator, paths *PathPolicy, registry *Registry, docker DockerPinger, logger *slog.Logger) http.Handler {
server := &service{paths: paths, registry: registry, docker: docker, logger: logger}
func NewHandler(authenticator *Authenticator, paths *PathPolicy, plans *PlanPolicy, registry *Registry, docker DockerRuntime, logger *slog.Logger) http.Handler {
return NewHandlerWithDiskChecker(authenticator, paths, plans, registry, docker, statfsDiskChecker{}, logger)
}
func NewHandlerWithDiskChecker(authenticator *Authenticator, paths *PathPolicy, plans *PlanPolicy, registry *Registry, docker DockerRuntime, disk DiskChecker, logger *slog.Logger) http.Handler {
server := &service{paths: paths, plans: plans, registry: registry, docker: docker, disk: disk, logger: logger}
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/health", server.health)
mux.HandleFunc("POST /v1/check-disk", server.checkDisk)
mux.HandleFunc("GET /v1/instances", server.listInstances)
mux.HandleFunc("POST /v1/check-ports", server.checkPorts)
mux.HandleFunc("POST /v1/instances", server.createInstance)
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)
mux.HandleFunc("POST /v1/instances/{id}/stop", server.stopInstance)
mux.HandleFunc("POST /v1/instances/{id}/restart", server.restartInstance)
mux.HandleFunc("DELETE /v1/instances/{id}", server.deleteInstance)
return server.headers(authenticator.Middleware(mux))
}
@@ -69,16 +99,15 @@ func (s *service) checkDisk(w http.ResponseWriter, r *http.Request) {
continue
}
seen[canonical] = struct{}{}
var filesystem syscall.Statfs_t
if err := syscall.Statfs(canonical, &filesystem); err != nil || filesystem.Bsize <= 0 {
available, total, err := s.disk.AvailableBytes(canonical)
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "path_unavailable", "A requested path is unavailable.")
return
}
blockSize := uint64(filesystem.Bsize)
response.Paths = append(response.Paths, diskInfo{
Path: requested,
BytesAvailable: blockSize * filesystem.Bavail,
BytesTotal: blockSize * filesystem.Blocks,
BytesAvailable: available,
BytesTotal: total,
})
}
writeJSON(w, http.StatusOK, response)
@@ -90,6 +119,235 @@ func (s *service) listInstances(w http.ResponseWriter, _ *http.Request) {
}{Instances: s.registry.List()})
}
func (s *service) checkPorts(w http.ResponseWriter, r *http.Request) {
var request struct {
Ports []agentwire.PlanPort `json:"ports"`
}
if decodeJSON(r.Body, &request) != nil || len(request.Ports) == 0 || len(request.Ports) > 32 {
writeProblem(w, http.StatusBadRequest, "invalid_request", "The request is invalid.")
return
}
if err := s.docker.CheckPorts(r.Context(), request.Ports); err != nil {
writeProblem(w, http.StatusConflict, "port_unavailable", "A requested host port is unavailable.")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"available": true})
}
func (s *service) createInstance(w http.ResponseWriter, r *http.Request) {
var plan agentwire.DeploymentPlan
if decodeJSON(r.Body, &plan) != nil || s.plans.Validate(plan) != nil {
writeProblem(w, http.StatusUnprocessableEntity, "invalid_plan", "The deployment plan is invalid.")
return
}
for index := range plan.Mounts {
mount := &plan.Mounts[index]
canonical, err := s.paths.Prepare(mount.HostPath)
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "path_not_allowed", "A deployment path is not allowed.")
return
}
mount.HostPath = canonical
available, _, diskErr := s.disk.AvailableBytes(canonical)
if diskErr != nil || available < uint64(plan.Resources.StorageGB)*1024*1024*1024 {
writeProblem(w, http.StatusUnprocessableEntity, "insufficient_disk", "A deployment path has insufficient disk space.")
return
}
}
if existing, ok := s.registry.Get(plan.InstanceID); ok {
if existing.PlanDigest != plan.PlanDigest {
writeProblem(w, http.StatusConflict, "registration_conflict", "The instance registration conflicts with the deployment plan.")
return
}
state, err := s.boundState(r.Context(), existing)
if err != nil {
writeProblem(w, http.StatusConflict, "registration_mismatch", "The registered container binding is invalid.")
return
}
writeJSON(w, http.StatusOK, state)
return
}
if err := s.docker.CheckPorts(r.Context(), plan.Ports); err != nil {
writeProblem(w, http.StatusConflict, "port_unavailable", "A requested host port is unavailable.")
return
}
assets, err := s.prepareAssets(plan)
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "asset_prepare_failed", "Approved template assets could not be prepared.")
return
}
containerID, err := s.docker.Create(r.Context(), plan, assets)
if err != nil {
writeProblem(w, http.StatusBadGateway, "container_create_failed", "The container could not be created.")
return
}
entry := RegisteredInstance{InstanceID: plan.InstanceID, ContainerID: containerID, PlanDigest: plan.PlanDigest}
if err := s.registry.Register(entry); err != nil {
_ = s.docker.Delete(r.Context(), containerID)
writeProblem(w, http.StatusInternalServerError, "registration_failed", "The container registration could not be persisted.")
return
}
writeJSON(w, http.StatusCreated, agentwire.InstanceState{InstanceID: plan.InstanceID, 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 {
return
}
state, err := s.boundState(r.Context(), entry)
if err != nil {
writeProblem(w, http.StatusConflict, "registration_mismatch", "The registered container binding is invalid.")
return
}
writeJSON(w, http.StatusOK, state)
}
func (s *service) instanceStats(w http.ResponseWriter, r *http.Request) {
entry, ok := s.registration(w, r.PathValue("id"))
if !ok {
return
}
if _, err := s.boundState(r.Context(), entry); err != nil {
writeProblem(w, http.StatusConflict, "registration_mismatch", "The registered container binding is invalid.")
return
}
stats, err := s.docker.Stats(r.Context(), entry.ContainerID)
if err != nil {
writeProblem(w, http.StatusBadGateway, "stats_unavailable", "Container statistics are unavailable.")
return
}
stats.InstanceID = entry.InstanceID
writeJSON(w, http.StatusOK, stats)
}
func (s *service) startInstance(w http.ResponseWriter, r *http.Request) {
entry, ok := s.registration(w, r.PathValue("id"))
if !ok {
return
}
state, err := s.boundState(r.Context(), entry)
if err != nil {
s.bindingProblem(w)
return
}
if !state.Running {
if err := s.docker.Start(r.Context(), entry.ContainerID); err != nil {
writeProblem(w, http.StatusBadGateway, "start_failed", "The registered container could not be started.")
return
}
state.Running, state.Health = true, "starting"
}
writeJSON(w, http.StatusOK, state)
}
func (s *service) stopInstance(w http.ResponseWriter, r *http.Request) {
entry, ok := s.registration(w, r.PathValue("id"))
if !ok {
return
}
state, err := s.boundState(r.Context(), entry)
if err != nil {
s.bindingProblem(w)
return
}
var request struct {
TimeoutSeconds int `json:"timeout_seconds"`
}
if decodeJSON(r.Body, &request) != nil || request.TimeoutSeconds < 5 || request.TimeoutSeconds > 900 {
writeProblem(w, http.StatusBadRequest, "invalid_request", "The stop timeout is invalid.")
return
}
if state.Running {
if err := s.docker.Stop(r.Context(), entry.ContainerID, request.TimeoutSeconds); err != nil {
writeProblem(w, http.StatusBadGateway, "stop_failed", "The registered container could not be stopped.")
return
}
state.Running, state.Ready, state.Health = false, false, "stopped"
}
writeJSON(w, http.StatusOK, state)
}
func (s *service) restartInstance(w http.ResponseWriter, r *http.Request) {
entry, ok := s.registration(w, r.PathValue("id"))
if !ok {
return
}
state, err := s.boundState(r.Context(), entry)
if err != nil {
s.bindingProblem(w)
return
}
var request struct {
TimeoutSeconds int `json:"timeout_seconds"`
}
if decodeJSON(r.Body, &request) != nil || request.TimeoutSeconds < 5 || request.TimeoutSeconds > 900 {
writeProblem(w, http.StatusBadRequest, "invalid_request", "The restart timeout is invalid.")
return
}
if state.Running {
err = s.docker.Restart(r.Context(), entry.ContainerID, request.TimeoutSeconds)
} else {
err = s.docker.Start(r.Context(), entry.ContainerID)
}
if err != nil {
writeProblem(w, http.StatusBadGateway, "restart_failed", "The registered container could not be restarted.")
return
}
state.Running, state.Ready, state.Health = true, false, "starting"
writeJSON(w, http.StatusOK, state)
}
func (s *service) deleteInstance(w http.ResponseWriter, r *http.Request) {
entry, ok := s.registry.Get(r.PathValue("id"))
if !ok {
w.WriteHeader(http.StatusNoContent)
return
}
state, err := s.boundState(r.Context(), entry)
if err != nil {
s.bindingProblem(w)
return
}
if state.Running {
writeProblem(w, http.StatusConflict, "instance_running", "The instance must be stopped before container deletion.")
return
}
if err := s.docker.Delete(r.Context(), entry.ContainerID); err != nil {
writeProblem(w, http.StatusBadGateway, "delete_failed", "The registered container could not be deleted.")
return
}
if err := s.registry.Remove(entry.InstanceID); err != nil {
writeProblem(w, http.StatusInternalServerError, "registry_update_failed", "The agent registry could not be updated.")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *service) registration(w http.ResponseWriter, id string) (RegisteredInstance, bool) {
entry, ok := s.registry.Get(id)
if !ok {
writeProblem(w, http.StatusNotFound, "instance_not_registered", "The instance is not registered.")
}
return entry, ok
}
func (s *service) boundState(ctx context.Context, entry RegisteredInstance) (agentwire.InstanceState, error) {
inspection, err := s.docker.Inspect(ctx, entry.ContainerID)
if err != nil || inspection.ContainerID != entry.ContainerID || inspection.Labels["io.dogama.managed"] != "true" || inspection.Labels["io.dogama.instance-id"] != entry.InstanceID || inspection.Labels["io.dogama.plan-digest"] != entry.PlanDigest {
return agentwire.InstanceState{}, errors.New("registration binding mismatch")
}
health := inspection.Health
if !inspection.Running {
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
}
func (s *service) bindingProblem(w http.ResponseWriter) {
writeProblem(w, http.StatusConflict, "registration_mismatch", "The registered container binding is invalid.")
}
func (s *service) headers(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
+135 -4
View File
@@ -11,15 +11,47 @@ import (
"path/filepath"
"testing"
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agent"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentclient"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
)
type fakeDocker struct {
err error
err error
inspection agent.DockerInspection
}
func (d fakeDocker) Ping(context.Context) error { return d.err }
type fakeDisk struct{}
func (fakeDisk) AvailableBytes(string) (uint64, uint64, error) { return 1 << 50, 1 << 50, nil }
func (d fakeDocker) Ping(context.Context) error { return d.err }
func (d fakeDocker) CheckPorts(context.Context, []agentwire.PlanPort) error { return d.err }
func (d fakeDocker) Create(_ context.Context, plan agentwire.DeploymentPlan, _ []agent.AssetMount) (string, error) {
if d.err != nil {
return "", d.err
}
return "container-" + plan.InstanceID, nil
}
func (d fakeDocker) Start(context.Context, string) error { return d.err }
func (d fakeDocker) Stop(context.Context, string, int) error { return d.err }
func (d fakeDocker) Restart(context.Context, string, int) error { return d.err }
func (d fakeDocker) Delete(context.Context, string) error { return d.err }
func (d fakeDocker) Inspect(_ context.Context, id string) (agent.DockerInspection, error) {
if d.err != nil {
return agent.DockerInspection{}, d.err
}
inspection := d.inspection
if inspection.ContainerID == "" {
inspection = agent.DockerInspection{ContainerID: id, Labels: map[string]string{}}
}
return inspection, nil
}
func (d fakeDocker) Stats(context.Context, string) (agentwire.InstanceStats, error) {
return agentwire.InstanceStats{MemoryBytes: 42}, d.err
}
func TestAuthenticatedAgentClientOperations(t *testing.T) {
root := t.TempDir()
@@ -89,7 +121,98 @@ func TestAgentReportsDockerUnavailableWithoutDetails(t *testing.T) {
}
}
func newTestHandler(t *testing.T, root string, secret []byte, docker agent.DockerPinger) http.Handler {
func TestAgentCreatesOnlyValidatedBoundInstances(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,
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)
}
created, err := client.CreateInstance(context.Background(), plan)
if err != nil {
t.Fatal(err)
}
if created.InstanceID != instanceID || created.ContainerID == "" {
t.Fatalf("created state = %#v", created)
}
if _, err := client.StartInstance(context.Background(), instanceID); err != nil {
t.Fatal(err)
}
if _, err := client.StopInstance(context.Background(), instanceID, 30); err != nil {
t.Fatal(err)
}
if err := client.DeleteContainer(context.Background(), instanceID); err != nil {
t.Fatal(err)
}
if _, err := client.InspectInstance(context.Background(), instanceID); err == nil {
t.Fatal("deleted registration remained targetable")
}
}
func TestAgentRejectsPlanSubstitutionAndEscapingMount(t *testing.T) {
root := t.TempDir()
secret := bytes.Repeat([]byte{0x31}, 32)
server := httptest.NewServer(newTestHandler(t, root, secret, fakeDocker{}))
t.Cleanup(server.Close)
client, err := agentclient.New(server.URL, secret, server.Client())
if err != nil {
t.Fatal(err)
}
plan := testPlan(t, "abcdefghijklmnopqrstuvwx", root)
plan.Image = "attacker.example/other:latest"
digest, _ := plan.CanonicalDigest()
plan.PlanDigest = digest
_, err = client.CreateInstance(context.Background(), plan)
var problem *agentclient.ProblemError
if !errors.As(err, &problem) || problem.Code != "invalid_plan" {
t.Fatalf("substitution error = %#v", err)
}
plan = testPlan(t, "zyxwvutsrqponmlkjihgfedc", root)
plan.Mounts[0].HostPath = filepath.Dir(root)
digest, _ = plan.CanonicalDigest()
plan.PlanDigest = digest
_, err = client.CreateInstance(context.Background(), plan)
if !errors.As(err, &problem) || problem.Code != "path_not_allowed" {
t.Fatalf("escaping path error = %#v", err)
}
}
func testPlan(t *testing.T, instanceID, root string) agentwire.DeploymentPlan {
t.Helper()
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
if err != nil {
t.Fatal(err)
}
template := snapshots[0].Template
plan := agentwire.DeploymentPlan{
SchemaVersion: 1, InstanceID: instanceID, TemplateID: template.ID, TemplateVersion: template.Version,
TemplateDigest: snapshots[0].Digest, Image: template.Container.Image + ":" + template.Container.Tag,
Entrypoint: template.Container.Entrypoint, Arguments: template.Container.Arguments,
Resources: agentwire.PlanResource{CPUCores: template.Requirements.Recommended.CPUCores, MemoryMB: template.Requirements.Recommended.MemoryMB, StorageGB: template.Requirements.Recommended.StorageGB}, StopTimeoutSeconds: template.Container.StopTimeoutSeconds,
}
for _, port := range template.Container.Ports {
plan.Ports = append(plan.Ports, agentwire.PlanPort{ID: port.ID, Protocol: port.Protocol, ContainerPort: port.ContainerPort, HostPort: map[bool]int{true: 38211, false: 0}[port.Publish], Publish: port.Publish})
}
for _, mount := range template.Storage.Mounts {
plan.Mounts = append(plan.Mounts, agentwire.PlanMount{ID: mount.ID, HostPath: filepath.Join(root, "instance", mount.ID), ContainerPath: mount.ContainerPath, ReadOnly: mount.ReadOnly})
}
digest, err := plan.CanonicalDigest()
if err != nil {
t.Fatal(err)
}
plan.PlanDigest = digest
return plan
}
func newTestHandler(t *testing.T, root string, secret []byte, docker agent.DockerRuntime) http.Handler {
t.Helper()
paths, err := agent.NewPathPolicy([]string{root})
if err != nil {
@@ -104,5 +227,13 @@ func newTestHandler(t *testing.T, root string, secret []byte, docker agent.Docke
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
return agent.NewHandler(authenticator, paths, registry, docker, logger)
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
if err != nil {
t.Fatal(err)
}
plans, err := agent.NewPlanPolicy(snapshots, catalogdata.Files)
if err != nil {
t.Fatal(err)
}
return agent.NewHandlerWithDiskChecker(authenticator, paths, plans, registry, docker, fakeDisk{}, logger)
}
+54
View File
@@ -109,6 +109,60 @@ func (c *Client) ListRegisteredInstances(ctx context.Context) ([]RegisteredInsta
return response.Instances, nil
}
func (c *Client) CheckPorts(ctx context.Context, ports []agentwire.PlanPort) error {
return c.do(ctx, http.MethodPost, "/v1/check-ports", struct {
Ports []agentwire.PlanPort `json:"ports"`
}{Ports: ports}, nil)
}
func (c *Client) CreateInstance(ctx context.Context, plan agentwire.DeploymentPlan) (agentwire.InstanceState, error) {
var state agentwire.InstanceState
err := c.do(ctx, http.MethodPost, "/v1/instances", 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)
return state, err
}
func (c *Client) StartInstance(ctx context.Context, instanceID string) (agentwire.InstanceState, error) {
var state agentwire.InstanceState
err := c.do(ctx, http.MethodPost, instancePath(instanceID)+"/start", nil, &state)
return state, err
}
func (c *Client) StopInstance(ctx context.Context, instanceID string, timeoutSeconds int) (agentwire.InstanceState, error) {
var state agentwire.InstanceState
err := c.do(ctx, http.MethodPost, instancePath(instanceID)+"/stop", struct {
TimeoutSeconds int `json:"timeout_seconds"`
}{TimeoutSeconds: timeoutSeconds}, &state)
return state, err
}
func (c *Client) RestartInstance(ctx context.Context, instanceID string, timeoutSeconds int) (agentwire.InstanceState, error) {
var state agentwire.InstanceState
err := c.do(ctx, http.MethodPost, instancePath(instanceID)+"/restart", struct {
TimeoutSeconds int `json:"timeout_seconds"`
}{TimeoutSeconds: timeoutSeconds}, &state)
return state, err
}
func (c *Client) DeleteContainer(ctx context.Context, instanceID string) error {
return c.do(ctx, http.MethodDelete, instancePath(instanceID), nil, nil)
}
func (c *Client) GetInstanceStats(ctx context.Context, instanceID string) (agentwire.InstanceStats, error) {
var stats agentwire.InstanceStats
err := c.do(ctx, http.MethodGet, instancePath(instanceID)+"/stats", nil, &stats)
return stats, err
}
func instancePath(instanceID string) string {
return "/v1/instances/" + url.PathEscape(instanceID)
}
func (c *Client) do(ctx context.Context, method, path string, input, output any) error {
var body []byte
var err error
+151
View File
@@ -0,0 +1,151 @@
package agentwire
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"regexp"
"sort"
"strings"
)
const DeploymentPlanVersion = 1
var (
instanceIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{20,128}$`)
templateIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
componentIDPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
digestPattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
)
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"`
}
type PlanPort struct {
ID string `json:"id"`
Protocol string `json:"protocol"`
ContainerPort int `json:"container_port"`
HostPort int `json:"host_port"`
Publish bool `json:"publish"`
}
type PlanMount struct {
ID string `json:"id"`
HostPath string `json:"host_path"`
ContainerPath string `json:"container_path"`
ReadOnly bool `json:"read_only"`
}
type PlanResource struct {
CPUCores float64 `json:"cpu_cores"`
MemoryMB int `json:"memory_mb"`
StorageGB int `json:"storage_gb"`
}
func (p DeploymentPlan) CanonicalDigest() (string, error) {
copyPlan := p
copyPlan.PlanDigest = ""
copyPlan.Entrypoint = append([]string(nil), p.Entrypoint...)
copyPlan.Arguments = append([]string(nil), p.Arguments...)
copyPlan.Ports = append([]PlanPort(nil), p.Ports...)
copyPlan.Mounts = append([]PlanMount(nil), p.Mounts...)
sort.Slice(copyPlan.Ports, func(i, j int) bool { return copyPlan.Ports[i].ID < copyPlan.Ports[j].ID })
sort.Slice(copyPlan.Mounts, func(i, j int) bool { return copyPlan.Mounts[i].ID < copyPlan.Mounts[j].ID })
body, err := json.Marshal(copyPlan)
if err != nil {
return "", fmt.Errorf("encode deployment plan: %w", err)
}
digest := sha256.Sum256(body)
return hex.EncodeToString(digest[:]), nil
}
func (p DeploymentPlan) Validate() error {
if p.SchemaVersion != DeploymentPlanVersion || !instanceIDPattern.MatchString(p.InstanceID) || !templateIDPattern.MatchString(p.TemplateID) {
return errors.New("invalid deployment plan identity")
}
if strings.TrimSpace(p.TemplateVersion) == "" || !digestPattern.MatchString(p.TemplateDigest) || strings.TrimSpace(p.Image) == "" {
return errors.New("invalid deployment plan template")
}
if p.Resources.CPUCores <= 0 || p.Resources.CPUCores > 256 || p.Resources.MemoryMB < 128 || p.Resources.MemoryMB > 4*1024*1024 || p.Resources.StorageGB < 1 || p.Resources.StorageGB > 100000 {
return errors.New("invalid deployment plan resources")
}
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")
}
portIDs := make(map[string]struct{}, len(p.Ports))
hostPorts := make(map[string]struct{})
for _, port := range p.Ports {
if !componentIDPattern.MatchString(port.ID) || (port.Protocol != "tcp" && port.Protocol != "udp") || port.ContainerPort < 1 || port.ContainerPort > 65535 {
return errors.New("invalid deployment plan port")
}
if _, exists := portIDs[port.ID]; exists {
return errors.New("duplicate deployment plan port")
}
portIDs[port.ID] = struct{}{}
if port.Publish {
if port.HostPort < 1 || port.HostPort > 65535 {
return errors.New("invalid published host port")
}
key := fmt.Sprintf("%s/%d", port.Protocol, port.HostPort)
if _, exists := hostPorts[key]; exists {
return errors.New("duplicate published host port")
}
hostPorts[key] = struct{}{}
} else if port.HostPort != 0 {
return errors.New("private port cannot have a host port")
}
}
mountIDs := make(map[string]struct{}, len(p.Mounts))
destinations := make(map[string]struct{}, len(p.Mounts))
for _, mount := range p.Mounts {
if !componentIDPattern.MatchString(mount.ID) || !filepath.IsAbs(mount.HostPath) || filepath.Clean(mount.HostPath) != mount.HostPath || !strings.HasPrefix(mount.ContainerPath, "/") || filepath.Clean(mount.ContainerPath) != mount.ContainerPath || mount.ContainerPath == "/" || strings.ContainsRune(mount.ContainerPath, '\x00') {
return errors.New("invalid deployment plan mount")
}
if _, exists := mountIDs[mount.ID]; exists {
return errors.New("duplicate deployment plan mount")
}
if _, exists := destinations[mount.ContainerPath]; exists {
return errors.New("duplicate deployment plan mount destination")
}
mountIDs[mount.ID] = struct{}{}
destinations[mount.ContainerPath] = struct{}{}
}
expected, err := p.CanonicalDigest()
if err != nil || !digestPattern.MatchString(p.PlanDigest) || p.PlanDigest != expected {
return errors.New("deployment plan digest mismatch")
}
return nil
}
type InstanceState struct {
InstanceID string `json:"instance_id"`
ContainerID string `json:"container_id"`
PlanDigest string `json:"plan_digest"`
Running bool `json:"running"`
Ready bool `json:"ready"`
Health string `json:"health"`
ExitCode int `json:"exit_code,omitempty"`
}
type InstanceStats struct {
InstanceID string `json:"instance_id"`
CPUPercentage float64 `json:"cpu_percentage"`
MemoryBytes uint64 `json:"memory_bytes"`
MemoryLimit uint64 `json:"memory_limit"`
}
+29
View File
@@ -0,0 +1,29 @@
package agentwire
import (
"path/filepath"
"strings"
"testing"
)
func TestDeploymentPlanDigestRejectsPrivilegedFieldSubstitution(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,
}
digest, err := plan.CanonicalDigest()
if err != nil {
t.Fatal(err)
}
plan.PlanDigest = digest
if err := plan.Validate(); err != nil {
t.Fatal(err)
}
plan.Image = "attacker.invalid/game:latest"
if err := plan.Validate(); err == nil {
t.Fatal("image substitution preserved a valid binding")
}
}
+16 -8
View File
@@ -53,13 +53,17 @@ type Template struct {
Recommended Resources `json:"recommended"`
} `json:"requirements"`
Container struct {
Image string `json:"image"`
Tag string `json:"tag"`
StopTimeoutSeconds int `json:"stop_timeout_seconds"`
Ports []Port `json:"ports"`
Image string `json:"image"`
Tag string `json:"tag"`
Entrypoint []string `json:"entrypoint,omitempty"`
Arguments []string `json:"arguments,omitempty"`
StopTimeoutSeconds int `json:"stop_timeout_seconds"`
Ports []Port `json:"ports"`
Assets []struct {
Source string `json:"source"`
SHA256 string `json:"sha256"`
Source string `json:"source"`
Destination string `json:"destination"`
SHA256 string `json:"sha256"`
ReadOnly bool `json:"read_only"`
} `json:"assets"`
} `json:"container"`
Storage struct {
@@ -78,8 +82,10 @@ type Template struct {
SourceMounts []string `json:"source_mounts"`
} `json:"backup"`
Healthcheck struct {
Type string `json:"type"`
PortID string `json:"port_id,omitempty"`
Type string `json:"type"`
PortID string `json:"port_id,omitempty"`
StartupTimeoutSeconds int `json:"startup_timeout_seconds"`
IntervalSeconds int `json:"interval_seconds"`
} `json:"healthcheck"`
Imports struct {
Supported bool `json:"supported"`
@@ -123,6 +129,7 @@ type Snapshot struct {
CanonicalYAML string
Digest string
Origin string
AssetRoot string
}
// LoadFS validates every template.yaml below root and returns stable snapshots.
@@ -197,6 +204,7 @@ func Validate(body []byte, assetRoot string, source fs.FS) (Snapshot, error) {
CanonicalYAML: string(pretty) + "\n",
Digest: hex.EncodeToString(digest[:]),
Origin: template.Source.Type,
AssetRoot: assetRoot,
}, nil
}
+331
View File
@@ -0,0 +1,331 @@
package instance
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"sync"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
)
var (
ErrInstanceNotFound = errors.New("instance not found")
ErrOperationConflict = errors.New("instance operation conflict")
ErrInvalidState = errors.New("invalid instance lifecycle state")
)
type StoredInstance struct {
ID string
Preview Preview
LifecycleState string
ObservedState string
ContainerID string
PlanDigest string
DesiredRunning bool
}
type OperationResult struct {
OperationID string `json:"operation_id,omitempty"`
InstanceID string `json:"instance_id"`
State string `json:"state"`
Observed string `json:"observed_state"`
ContainerID string `json:"container_id,omitempty"`
AgentState agentwire.InstanceState `json:"agent_state,omitempty"`
}
type LifecycleRepository interface {
GetInstance(context.Context, string) (StoredInstance, error)
ListLifecycleInstances(context.Context) ([]StoredInstance, error)
BeginOperation(context.Context, string, string, string, string) (StoredInstance, error)
FinishOperation(context.Context, string, string, string, string, string, bool, string) error
FailOperation(context.Context, string, string, string) error
UpdateObservation(context.Context, string, string, string, string, bool, string) error
RecoverInterruptedOperations(context.Context) error
}
type LifecycleAgent interface {
CreateInstance(context.Context, agentwire.DeploymentPlan) (agentwire.InstanceState, error)
InspectInstance(context.Context, string) (agentwire.InstanceState, error)
StartInstance(context.Context, string) (agentwire.InstanceState, error)
StopInstance(context.Context, string, int) (agentwire.InstanceState, error)
RestartInstance(context.Context, string, int) (agentwire.InstanceState, error)
DeleteContainer(context.Context, string) error
GetInstanceStats(context.Context, string) (agentwire.InstanceStats, error)
}
type LifecycleService struct {
repository LifecycleRepository
agent LifecycleAgent
locks sync.Map
}
func NewLifecycleService(repository LifecycleRepository, agent LifecycleAgent) *LifecycleService {
return &LifecycleService{repository: repository, agent: agent}
}
func (s *LifecycleService) Install(ctx context.Context, instanceID string) (OperationResult, error) {
return s.exclusive(instanceID, func() (OperationResult, error) {
current, err := s.repository.GetInstance(ctx, instanceID)
if err != nil {
return OperationResult{}, err
}
if current.LifecycleState != "draft" && current.ContainerID != "" {
return resultFrom(current, ""), nil
}
operationID, err := operationToken()
if err != nil {
return OperationResult{}, err
}
current, err = s.repository.BeginOperation(ctx, operationID, instanceID, "install", "installing")
if err != nil {
return OperationResult{}, err
}
plan, err := current.Preview.DeploymentPlan(instanceID)
if err != nil {
return s.fail(ctx, operationID, instanceID, "invalid_plan", err)
}
state, err := s.agent.CreateInstance(ctx, plan)
if err != nil {
return s.fail(ctx, operationID, instanceID, "agent_create_failed", err)
}
if err := s.repository.FinishOperation(ctx, operationID, "stopped", "stopped", state.ContainerID, plan.PlanDigest, false, ""); err != nil {
return OperationResult{}, err
}
return OperationResult{OperationID: operationID, InstanceID: instanceID, State: "stopped", Observed: "stopped", ContainerID: state.ContainerID, AgentState: state}, nil
})
}
func (s *LifecycleService) Start(ctx context.Context, instanceID string) (OperationResult, error) {
return s.exclusive(instanceID, func() (OperationResult, error) {
current, err := s.repository.GetInstance(ctx, instanceID)
if err != nil {
return OperationResult{}, err
}
if current.ContainerID == "" {
return OperationResult{}, ErrInvalidState
}
if current.DesiredRunning && (current.LifecycleState == "online" || current.LifecycleState == "starting" || current.LifecycleState == "degraded") {
return s.inspectAndPersist(ctx, current, "")
}
operationID, err := operationToken()
if err != nil {
return OperationResult{}, err
}
current, err = s.repository.BeginOperation(ctx, operationID, instanceID, "start", "starting")
if err != nil {
return OperationResult{}, err
}
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, current.PlanDigest, true, ""); err != nil {
return OperationResult{}, err
}
return OperationResult{OperationID: operationID, InstanceID: instanceID, State: lifecycle, Observed: observed, ContainerID: state.ContainerID, AgentState: state}, nil
})
}
func (s *LifecycleService) Stop(ctx context.Context, instanceID string) (OperationResult, error) {
return s.exclusive(instanceID, func() (OperationResult, error) {
current, err := s.repository.GetInstance(ctx, instanceID)
if err != nil {
return OperationResult{}, err
}
if current.ContainerID == "" {
return OperationResult{}, ErrInvalidState
}
if !current.DesiredRunning && current.LifecycleState == "stopped" {
return resultFrom(current, ""), nil
}
operationID, err := operationToken()
if err != nil {
return OperationResult{}, err
}
current, err = s.repository.BeginOperation(ctx, operationID, instanceID, "stop", "stopping")
if err != nil {
return OperationResult{}, err
}
state, err := s.agent.StopInstance(ctx, instanceID, current.Preview.StopTimeoutSeconds)
if err != nil {
return s.fail(ctx, operationID, instanceID, "agent_stop_failed", err)
}
if err := s.repository.FinishOperation(ctx, operationID, "stopped", "stopped", state.ContainerID, current.PlanDigest, false, ""); err != nil {
return OperationResult{}, err
}
return OperationResult{OperationID: operationID, InstanceID: instanceID, State: "stopped", Observed: "stopped", ContainerID: state.ContainerID, AgentState: state}, nil
})
}
func (s *LifecycleService) Restart(ctx context.Context, instanceID string) (OperationResult, error) {
return s.exclusive(instanceID, func() (OperationResult, error) {
current, err := s.repository.GetInstance(ctx, instanceID)
if err != nil {
return OperationResult{}, err
}
if current.ContainerID == "" {
return OperationResult{}, ErrInvalidState
}
operationID, err := operationToken()
if err != nil {
return OperationResult{}, err
}
current, err = s.repository.BeginOperation(ctx, operationID, instanceID, "restart", "starting")
if err != nil {
return OperationResult{}, err
}
state, err := s.agent.RestartInstance(ctx, instanceID, current.Preview.StopTimeoutSeconds)
if err != nil {
return s.fail(ctx, operationID, instanceID, "agent_restart_failed", err)
}
lifecycle, observed := stateToLifecycle(state)
if err := s.repository.FinishOperation(ctx, operationID, lifecycle, observed, state.ContainerID, current.PlanDigest, true, ""); err != nil {
return OperationResult{}, err
}
return OperationResult{OperationID: operationID, InstanceID: instanceID, State: lifecycle, Observed: observed, ContainerID: state.ContainerID, AgentState: state}, nil
})
}
func (s *LifecycleService) DeleteContainer(ctx context.Context, instanceID string) (OperationResult, error) {
return s.exclusive(instanceID, func() (OperationResult, error) {
current, err := s.repository.GetInstance(ctx, instanceID)
if err != nil {
return OperationResult{}, err
}
if current.ContainerID == "" {
return resultFrom(current, ""), nil
}
if current.DesiredRunning || (current.LifecycleState != "stopped" && current.LifecycleState != "deleting" && current.LifecycleState != "intervention_required") {
return OperationResult{}, ErrInvalidState
}
operationID, err := operationToken()
if err != nil {
return OperationResult{}, err
}
_, err = s.repository.BeginOperation(ctx, operationID, instanceID, "delete_container", "deleting")
if err != nil {
return OperationResult{}, err
}
if err := s.agent.DeleteContainer(ctx, instanceID); err != nil {
return s.fail(ctx, operationID, instanceID, "agent_delete_failed", err)
}
if err := s.repository.FinishOperation(ctx, operationID, "unknown", "missing", "", current.PlanDigest, false, ""); err != nil {
return OperationResult{}, err
}
return OperationResult{OperationID: operationID, InstanceID: instanceID, State: "unknown", Observed: "missing"}, nil
})
}
func (s *LifecycleService) Inspect(ctx context.Context, instanceID string) (OperationResult, error) {
current, err := s.repository.GetInstance(ctx, instanceID)
if err != nil {
return OperationResult{}, err
}
if current.ContainerID == "" {
return resultFrom(current, ""), nil
}
return s.inspectAndPersist(ctx, current, "")
}
func (s *LifecycleService) Stats(ctx context.Context, instanceID string) (agentwire.InstanceStats, error) {
current, err := s.repository.GetInstance(ctx, instanceID)
if err != nil {
return agentwire.InstanceStats{}, err
}
if current.ContainerID == "" {
return agentwire.InstanceStats{}, ErrInvalidState
}
return s.agent.GetInstanceStats(ctx, instanceID)
}
func (s *LifecycleService) ReconcileAll(ctx context.Context) error {
instances, err := s.repository.ListLifecycleInstances(ctx)
if err != nil {
return err
}
for _, current := range instances {
if current.ContainerID == "" {
continue
}
if _, err := s.inspectAndPersist(ctx, current, ""); err != nil {
operationID, tokenErr := operationToken()
if tokenErr != nil {
return tokenErr
}
if _, beginErr := s.repository.BeginOperation(ctx, operationID, current.ID, "reconcile", "unknown"); beginErr == nil {
_ = s.repository.FailOperation(ctx, operationID, "unknown", "agent_reconcile_failed")
}
}
}
return nil
}
func (s *LifecycleService) RecoverInterruptedOperations(ctx context.Context) error {
return s.repository.RecoverInterruptedOperations(ctx)
}
func (s *LifecycleService) inspectAndPersist(ctx context.Context, current StoredInstance, operationID string) (OperationResult, error) {
state, err := s.agent.InspectInstance(ctx, current.ID)
if err != nil {
return OperationResult{}, err
}
lifecycle, observed := stateToLifecycle(state)
if current.LifecycleState == "intervention_required" {
lifecycle = "intervention_required"
}
if !current.DesiredRunning && !state.Running {
lifecycle = "stopped"
}
if operationID != "" {
if err := s.repository.FinishOperation(ctx, operationID, lifecycle, observed, state.ContainerID, current.PlanDigest, current.DesiredRunning, ""); err != nil {
return OperationResult{}, err
}
} else if err := s.repository.UpdateObservation(ctx, current.ID, lifecycle, observed, state.ContainerID, current.DesiredRunning, ""); err != nil {
return OperationResult{}, err
}
return OperationResult{OperationID: operationID, InstanceID: current.ID, State: lifecycle, Observed: observed, ContainerID: state.ContainerID, AgentState: state}, nil
}
func (s *LifecycleService) fail(ctx context.Context, operationID, instanceID, code string, cause error) (OperationResult, error) {
if err := s.repository.FailOperation(ctx, operationID, "error", code); err != nil {
return OperationResult{}, err
}
return OperationResult{OperationID: operationID, InstanceID: instanceID, State: "error", Observed: "unknown"}, fmt.Errorf("%s: %w", code, cause)
}
func (s *LifecycleService) exclusive(instanceID string, action func() (OperationResult, error)) (OperationResult, error) {
lockValue, _ := s.locks.LoadOrStore(instanceID, &sync.Mutex{})
lock := lockValue.(*sync.Mutex)
lock.Lock()
defer lock.Unlock()
return action()
}
func stateToLifecycle(state agentwire.InstanceState) (string, string) {
if !state.Running {
return "stopped", "stopped"
}
if state.Ready {
return "online", "ready"
}
if state.Health == "unhealthy" || state.Health == "none" {
return "degraded", "degraded"
}
return "starting", "running"
}
func resultFrom(current StoredInstance, operationID string) OperationResult {
return OperationResult{OperationID: operationID, InstanceID: current.ID, State: current.LifecycleState, Observed: current.ObservedState, ContainerID: current.ContainerID}
}
func operationToken() (string, error) {
buffer := make([]byte, 24)
if _, err := rand.Read(buffer); err != nil {
return "", fmt.Errorf("generate operation ID: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buffer), nil
}
+118
View File
@@ -0,0 +1,118 @@
package instance_test
import (
"context"
"path/filepath"
"sync"
"testing"
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
)
type lifecycleAgent struct {
mu sync.Mutex
running bool
created int
}
func (a *lifecycleAgent) CreateInstance(_ context.Context, plan agentwire.DeploymentPlan) (agentwire.InstanceState, error) {
a.mu.Lock()
defer a.mu.Unlock()
a.created++
return agentwire.InstanceState{InstanceID: plan.InstanceID, ContainerID: "container-1", PlanDigest: plan.PlanDigest, Health: "stopped"}, nil
}
func (a *lifecycleAgent) InspectInstance(_ context.Context, id string) (agentwire.InstanceState, error) {
a.mu.Lock()
defer a.mu.Unlock()
return agentwire.InstanceState{InstanceID: id, ContainerID: "container-1", Running: a.running, Ready: a.running, Health: map[bool]string{true: "healthy", false: "stopped"}[a.running]}, nil
}
func (a *lifecycleAgent) StartInstance(ctx context.Context, id string) (agentwire.InstanceState, error) {
a.mu.Lock()
a.running = true
a.mu.Unlock()
return a.InspectInstance(ctx, id)
}
func (a *lifecycleAgent) StopInstance(ctx context.Context, id string, _ int) (agentwire.InstanceState, error) {
a.mu.Lock()
a.running = false
a.mu.Unlock()
return a.InspectInstance(ctx, id)
}
func (a *lifecycleAgent) RestartInstance(ctx context.Context, id string, _ int) (agentwire.InstanceState, error) {
return a.StartInstance(ctx, id)
}
func (a *lifecycleAgent) DeleteContainer(context.Context, string) error { return nil }
func (a *lifecycleAgent) GetInstanceStats(_ context.Context, id string) (agentwire.InstanceStats, error) {
return agentwire.InstanceStats{InstanceID: id, MemoryBytes: 42}, nil
}
func TestLifecycleInstallStartStopAndSafeContainerDeletion(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: "Family Palworld", Slug: "family-palworld", 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)
}
if err := repository.CreateDraft(ctx, instance.Draft{ID: "abcdefghijklmnopqrstuvwx", Preview: preview}); err != nil {
t.Fatal(err)
}
agent := &lifecycleAgent{}
service := instance.NewLifecycleService(repository, agent)
installed, err := service.Install(ctx, "abcdefghijklmnopqrstuvwx")
if err != nil {
t.Fatal(err)
}
if installed.State != "stopped" || installed.ContainerID == "" {
t.Fatalf("installed = %#v", installed)
}
if _, err := service.Install(ctx, "abcdefghijklmnopqrstuvwx"); err != nil {
t.Fatal(err)
}
if agent.created != 1 {
t.Fatalf("idempotent install created %d containers", agent.created)
}
started, err := service.Start(ctx, "abcdefghijklmnopqrstuvwx")
if err != nil {
t.Fatal(err)
}
if started.State != "online" || started.Observed != "ready" {
t.Fatalf("started = %#v", started)
}
stopped, err := service.Stop(ctx, "abcdefghijklmnopqrstuvwx")
if err != nil {
t.Fatal(err)
}
if stopped.State != "stopped" {
t.Fatalf("stopped = %#v", stopped)
}
deleted, err := service.DeleteContainer(ctx, "abcdefghijklmnopqrstuvwx")
if err != nil {
t.Fatal(err)
}
if deleted.State != "unknown" || deleted.Observed != "missing" {
t.Fatalf("deleted = %#v", deleted)
}
var mounts string
if err := db.QueryRow("SELECT preview_json FROM instances WHERE id=?", "abcdefghijklmnopqrstuvwx").Scan(&mounts); err != nil || mounts == "" {
t.Fatalf("instance intent/player paths were removed: %v", err)
}
}
+54 -17
View File
@@ -13,6 +13,7 @@ import (
"sort"
"strings"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
)
@@ -29,18 +30,22 @@ type PreviewRequest struct {
}
type Preview struct {
Template TemplateReference `json:"template"`
DisplayName string `json:"display_name"`
Slug string `json:"slug"`
Image string `json:"image"`
Ports []PortBinding `json:"ports"`
Mounts []MountBinding `json:"mounts"`
Resources catalog.Resources `json:"resources"`
Settings []SettingPreview `json:"settings"`
DataOrigin string `json:"data_origin"`
Backup BackupPreview `json:"backup"`
CanonicalJSON string `json:"canonical_json"`
PlanDigest string `json:"plan_digest"`
Template TemplateReference `json:"template"`
DisplayName string `json:"display_name"`
Slug string `json:"slug"`
Image string `json:"image"`
Entrypoint []string `json:"entrypoint,omitempty"`
Arguments []string `json:"arguments,omitempty"`
StopTimeoutSeconds int `json:"stop_timeout_seconds"`
StartupTimeoutSeconds int `json:"startup_timeout_seconds"`
Ports []PortBinding `json:"ports"`
Mounts []MountBinding `json:"mounts"`
Resources catalog.Resources `json:"resources"`
Settings []SettingPreview `json:"settings"`
DataOrigin string `json:"data_origin"`
Backup BackupPreview `json:"backup"`
CanonicalJSON string `json:"canonical_json"`
PlanDigest string `json:"plan_digest"`
}
type TemplateReference struct {
@@ -163,11 +168,15 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e
}
sort.Slice(settings, func(i, j int) bool { return settings[i].ID < settings[j].ID })
preview := Preview{
Template: TemplateReference{ID: snapshot.Template.ID, Version: snapshot.Template.Version, Digest: snapshot.Digest},
DisplayName: request.DisplayName,
Slug: request.Slug,
Image: snapshot.Template.Container.Image + ":" + snapshot.Template.Container.Tag,
Ports: ports, Mounts: mounts, Resources: resources, Settings: settings,
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,
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},
}
@@ -187,3 +196,31 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e
preview.PlanDigest = hex.EncodeToString(digest[:])
return preview, nil
}
// DeploymentPlan converts a persisted preview into the only container plan
// accepted by the restricted agent. The digest binds every privileged field.
func (p Preview) DeploymentPlan(instanceID string) (agentwire.DeploymentPlan, error) {
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...),
Resources: agentwire.PlanResource{CPUCores: p.Resources.CPUCores, MemoryMB: p.Resources.MemoryMB, StorageGB: p.Resources.StorageGB},
StopTimeoutSeconds: p.StopTimeoutSeconds,
}
for _, port := range p.Ports {
plan.Ports = append(plan.Ports, agentwire.PlanPort{ID: port.ID, Protocol: port.Protocol, ContainerPort: port.ContainerPort, HostPort: port.HostPort, Publish: port.Publish})
}
for _, mount := range p.Mounts {
plan.Mounts = append(plan.Mounts, agentwire.PlanMount{ID: mount.ID, HostPath: mount.HostPath, ContainerPath: mount.ContainerPath, ReadOnly: mount.ReadOnly})
}
digest, err := plan.CanonicalDigest()
if err != nil {
return agentwire.DeploymentPlan{}, err
}
plan.PlanDigest = digest
if err := plan.Validate(); err != nil {
return agentwire.DeploymentPlan{}, err
}
return plan, nil
}
+177
View File
@@ -110,3 +110,180 @@ func (r *Repository) CreateDraft(ctx context.Context, draft instance.Draft) erro
}
return nil
}
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))
}
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`)
if err != nil {
return nil, fmt.Errorf("list lifecycle instances: %w", err)
}
defer rows.Close()
var result []instance.StoredInstance
for rows.Next() {
value, err := scanInstance(rows)
if err != nil {
return nil, err
}
result = append(result, value)
}
return result, rows.Err()
}
type rowScanner interface{ Scan(...any) error }
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)
if errors.Is(err, sql.ErrNoRows) {
return instance.StoredInstance{}, instance.ErrInstanceNotFound
}
if err != nil {
return instance.StoredInstance{}, fmt.Errorf("scan instance: %w", err)
}
if err := json.Unmarshal([]byte(previewJSON), &value.Preview); err != nil {
return instance.StoredInstance{}, fmt.Errorf("decode instance preview: %w", err)
}
value.DesiredRunning = desired != 0
return value, nil
}
func (r *Repository) BeginOperation(ctx context.Context, operationID, instanceID, kind, lifecycleState string) (instance.StoredInstance, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
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))
if err != nil {
return instance.StoredInstance{}, err
}
var active int
err = tx.QueryRowContext(ctx, `SELECT 1 FROM instance_operations WHERE instance_id=? AND state='running' LIMIT 1`, instanceID).Scan(&active)
if err == nil {
return instance.StoredInstance{}, instance.ErrOperationConflict
}
if !errors.Is(err, sql.ErrNoRows) {
return instance.StoredInstance{}, fmt.Errorf("check active operation: %w", err)
}
now := r.now().UTC().Format(time.RFC3339Nano)
if _, err := tx.ExecContext(ctx, `INSERT INTO instance_operations(id, instance_id, kind, state, phase, created_at, updated_at) VALUES (?, ?, ?, 'running', 'dispatch', ?, ?)`, operationID, instanceID, kind, now, now); err != nil {
return instance.StoredInstance{}, fmt.Errorf("insert instance operation: %w", err)
}
if _, err := tx.ExecContext(ctx, `UPDATE instances SET lifecycle_state=?, last_error_code=NULL, updated_at=? WHERE id=?`, lifecycleState, now, instanceID); err != nil {
return instance.StoredInstance{}, fmt.Errorf("mark instance operation: %w", err)
}
if err := tx.Commit(); err != nil {
return instance.StoredInstance{}, fmt.Errorf("commit instance operation: %w", err)
}
current.LifecycleState = lifecycleState
return current, nil
}
func (r *Repository) FinishOperation(ctx context.Context, operationID, lifecycleState, observedState, containerID, planDigest string, desiredRunning bool, errorCode string) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin operation completion: %w", err)
}
defer func() { _ = tx.Rollback() }()
now := r.now().UTC().Format(time.RFC3339Nano)
desired := 0
if desiredRunning {
desired = 1
}
result, err := tx.ExecContext(ctx, `UPDATE instance_operations SET state='succeeded', phase='complete', error_code=NULL, updated_at=?, completed_at=? WHERE id=? AND state='running'`, now, now, operationID)
if err != nil {
return fmt.Errorf("complete instance operation: %w", err)
}
changed, _ := result.RowsAffected()
if changed != 1 {
return instance.ErrOperationConflict
}
var container any
if containerID != "" {
container = containerID
}
_, err = tx.ExecContext(ctx, `UPDATE instances SET lifecycle_state=?, observed_state=?, container_id=?, plan_digest=?, desired_running=?, last_error_code=?, updated_at=? WHERE id=(SELECT instance_id FROM instance_operations WHERE id=?)`, lifecycleState, observedState, container, planDigest, desired, nullable(errorCode), now, operationID)
if err != nil {
return fmt.Errorf("update completed instance: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit operation completion: %w", err)
}
return nil
}
func (r *Repository) FailOperation(ctx context.Context, operationID, lifecycleState, errorCode string) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin operation failure: %w", err)
}
defer func() { _ = tx.Rollback() }()
now := r.now().UTC().Format(time.RFC3339Nano)
result, err := tx.ExecContext(ctx, `UPDATE instance_operations SET state='failed', phase='failed', error_code=?, updated_at=?, completed_at=? WHERE id=? AND state='running'`, errorCode, now, now, operationID)
if err != nil {
return fmt.Errorf("fail instance operation: %w", err)
}
changed, _ := result.RowsAffected()
if changed != 1 {
return instance.ErrOperationConflict
}
_, err = tx.ExecContext(ctx, `UPDATE instances SET lifecycle_state=?, observed_state='unknown', last_error_code=?, updated_at=? WHERE id=(SELECT instance_id FROM instance_operations WHERE id=?)`, lifecycleState, errorCode, now, operationID)
if err != nil {
return fmt.Errorf("update failed instance: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit operation failure: %w", err)
}
return nil
}
func (r *Repository) UpdateObservation(ctx context.Context, instanceID, lifecycleState, observedState, containerID string, desiredRunning bool, errorCode string) error {
desired := 0
if desiredRunning {
desired = 1
}
var container any
if containerID != "" {
container = containerID
}
result, err := r.db.ExecContext(ctx, `UPDATE instances SET lifecycle_state=?, observed_state=?, container_id=?, desired_running=?, last_error_code=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, lifecycleState, observedState, container, desired, nullable(errorCode), r.now().UTC().Format(time.RFC3339Nano), instanceID)
if err != nil {
return fmt.Errorf("update instance observation: %w", err)
}
changed, _ := result.RowsAffected()
if changed != 1 {
return instance.ErrInstanceNotFound
}
return nil
}
func (r *Repository) RecoverInterruptedOperations(ctx context.Context) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin interrupted-operation recovery: %w", err)
}
defer func() { _ = tx.Rollback() }()
now := r.now().UTC().Format(time.RFC3339Nano)
if _, err := tx.ExecContext(ctx, `UPDATE instances SET lifecycle_state='intervention_required', last_error_code='operation_interrupted', updated_at=? WHERE id IN (SELECT instance_id FROM instance_operations WHERE state='running')`, now); err != nil {
return fmt.Errorf("mark interrupted instances: %w", err)
}
if _, err := tx.ExecContext(ctx, `UPDATE instance_operations SET state='intervention_required', phase='interrupted', error_code='operation_interrupted', updated_at=?, completed_at=? WHERE state='running'`, now, now); err != nil {
return fmt.Errorf("mark interrupted operations: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit interrupted-operation recovery: %w", err)
}
return nil
}
func nullable(value string) any {
if value == "" {
return nil
}
return value
}
@@ -59,4 +59,29 @@ func TestCatalogSyncIsImmutableAndDraftPinsSnapshot(t *testing.T) {
if state != "draft" || digest != snapshots[0].Digest {
t.Fatalf("draft state=%q digest=%q", state, digest)
}
if _, err := repository.BeginOperation(ctx, "operation-one", "opaque-instance-id", "install", "installing"); err != nil {
t.Fatal(err)
}
if _, err := repository.BeginOperation(ctx, "operation-two", "opaque-instance-id", "start", "starting"); !errors.Is(err, instance.ErrOperationConflict) {
t.Fatalf("parallel operation error = %v", err)
}
if err := repository.FailOperation(ctx, "operation-one", "error", "test_failure"); err != nil {
t.Fatal(err)
}
if _, err := repository.BeginOperation(ctx, "operation-three", "opaque-instance-id", "start", "starting"); err != nil {
t.Fatal(err)
}
if err := repository.RecoverInterruptedOperations(ctx); err != nil {
t.Fatal(err)
}
var operationState string
if err := db.QueryRow("SELECT state FROM instance_operations WHERE id='operation-three'").Scan(&operationState); err != nil {
t.Fatal(err)
}
if err := db.QueryRow("SELECT lifecycle_state FROM instances WHERE id='opaque-instance-id'").Scan(&state); err != nil {
t.Fatal(err)
}
if operationState != "intervention_required" || state != "intervention_required" {
t.Fatalf("recovered operation=%q instance=%q", operationState, state)
}
}
+72 -4
View File
@@ -2,10 +2,14 @@ package sqlite_test
import (
"context"
"database/sql"
"path/filepath"
"strings"
"testing"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/migrations"
)
func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) {
@@ -20,8 +24,8 @@ func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) {
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 2 {
t.Fatalf("got %d migrations, want 2", count)
if count != 3 {
t.Fatalf("got %d migrations, want 3", count)
}
var foreignKeys, busyTimeout int
var journalMode string
@@ -49,7 +53,71 @@ func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) {
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 2 {
t.Fatalf("reopened database has %d migrations, want 2", count)
if count != 3 {
t.Fatalf("reopened database has %d migrations, want 3", count)
}
}
func TestLifecycleMigrationPreservesMilestoneThreeDrafts(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "dogama.db")
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatal(err)
}
if _, err := db.ExecContext(ctx, `CREATE TABLE schema_migrations (version TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`); err != nil {
t.Fatal(err)
}
for _, name := range []string{"0001_initial.sql", "0002_catalog_instances.sql"} {
body, err := migrations.Files.ReadFile(name)
if err != nil {
t.Fatal(err)
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
for _, statement := range strings.Split(string(body), ";") {
if strings.TrimSpace(statement) == "" {
continue
}
if _, err = tx.ExecContext(ctx, statement); err != nil {
_ = tx.Rollback()
t.Fatal(err)
}
}
if _, err := tx.ExecContext(ctx, "INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)", name, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
_ = tx.Rollback()
t.Fatal(err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
}
now := time.Now().UTC().Format(time.RFC3339Nano)
if _, err := db.ExecContext(ctx, `INSERT INTO templates(id, origin, trust_status, active_version, created_at, updated_at) VALUES ('template', 'official', 'official', '1.0.0', ?, ?)`, now, now); err != nil {
t.Fatal(err)
}
if _, err := db.ExecContext(ctx, `INSERT INTO template_versions(template_id, version, schema_version, canonical_yaml, digest, game_id, game_name, description, created_at) VALUES ('template', '1.0.0', 1, '{}', ?, 'game', 'Game', 'Description', ?)`, strings.Repeat("a", 64), now); err != nil {
t.Fatal(err)
}
if _, err := 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 ('instance', 'instance', 'Instance', 'template', '1.0.0', ?, 1, 'draft', '{}', ?, ?, ?)`, strings.Repeat("a", 64), strings.Repeat("b", 64), now, now); err != nil {
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
db, err = sqlite.Open(ctx, path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
var lifecycle, observed string
if err := db.QueryRowContext(ctx, "SELECT lifecycle_state, observed_state FROM instances WHERE id='instance'").Scan(&lifecycle, &observed); err != nil {
t.Fatal(err)
}
if lifecycle != "draft" || observed != "unknown" {
t.Fatalf("migrated lifecycle=%q observed=%q", lifecycle, observed)
}
}
+118 -4
View File
@@ -2,6 +2,8 @@
package web
import (
"bytes"
"context"
"crypto/rand"
"crypto/subtle"
"embed"
@@ -54,11 +56,13 @@ type server struct {
templates *template.Template
logger *slog.Logger
repository repository
lifecycle *instance.LifecycleService
}
type repository interface {
catalog.Repository
instance.Repository
instance.LifecycleRepository
}
type pageData struct {
@@ -70,25 +74,40 @@ type pageData struct {
// NewHandler constructs the complete HTTP application.
func NewHandler(authService *auth.Service, logger *slog.Logger) (http.Handler, error) {
return newHandler(authService, nil, logger)
return newHandler(authService, nil, nil, logger)
}
// NewHandlerWithRepository enables the authenticated catalog and draft APIs.
func NewHandlerWithRepository(authService *auth.Service, repository repository, logger *slog.Logger) (http.Handler, error) {
return newHandler(authService, repository, logger)
return newHandler(authService, repository, nil, logger)
}
func newHandler(authService *auth.Service, repository repository, logger *slog.Logger) (http.Handler, error) {
// NewHandlerWithLifecycle enables privileged instance lifecycle operations
// through the restricted agent boundary.
func NewHandlerWithLifecycle(authService *auth.Service, repository repository, agent instance.LifecycleAgent, logger *slog.Logger) (http.Handler, error) {
return newHandler(authService, repository, instance.NewLifecycleService(repository, agent), logger)
}
func newHandler(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, logger *slog.Logger) (http.Handler, error) {
templates, err := template.New("views").Funcs(template.FuncMap{"msg": message}).ParseFS(assets, "templates/*.html")
if err != nil {
return nil, err
}
s := &server{auth: authService, templates: templates, logger: logger, repository: repository}
s := &server{auth: authService, templates: templates, logger: logger, repository: repository, lifecycle: lifecycle}
mux := http.NewServeMux()
if repository != nil {
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)
if lifecycle != nil {
mux.HandleFunc("GET /api/v1/instances/{id}", s.instanceInspect)
mux.HandleFunc("GET /api/v1/instances/{id}/stats", s.instanceStats)
mux.HandleFunc("POST /api/v1/instances/{id}/install", s.instanceInstall)
mux.HandleFunc("POST /api/v1/instances/{id}/start", s.instanceStart)
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 /static/app.v1.css", s.stylesheet)
mux.HandleFunc("GET /setup", s.setupForm)
@@ -148,6 +167,101 @@ func (s *server) instanceDraft(w http.ResponseWriter, r *http.Request) {
s.apiJSON(w, http.StatusCreated, map[string]string{"id": id, "state": "draft", "plan_digest": preview.PlanDigest})
}
func (s *server) instanceInspect(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAPIUser(w, r, true); !ok {
return
}
result, err := s.lifecycle.Inspect(r.Context(), r.PathValue("id"))
if err != nil {
s.lifecycleProblem(w, err)
return
}
s.apiJSON(w, http.StatusOK, result)
}
func (s *server) instanceStats(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAPIUser(w, r, true); !ok {
return
}
stats, err := s.lifecycle.Stats(r.Context(), r.PathValue("id"))
if err != nil {
s.lifecycleProblem(w, err)
return
}
s.apiJSON(w, http.StatusOK, stats)
}
func (s *server) instanceInstall(w http.ResponseWriter, r *http.Request) {
s.lifecycleAction(w, r, s.lifecycle.Install)
}
func (s *server) instanceStart(w http.ResponseWriter, r *http.Request) {
s.lifecycleAction(w, r, s.lifecycle.Start)
}
func (s *server) instanceStop(w http.ResponseWriter, r *http.Request) {
s.lifecycleAction(w, r, s.lifecycle.Stop)
}
func (s *server) instanceRestart(w http.ResponseWriter, r *http.Request) {
s.lifecycleAction(w, r, s.lifecycle.Restart)
}
func (s *server) lifecycleAction(w http.ResponseWriter, r *http.Request, action func(context.Context, string) (instance.OperationResult, error)) {
if _, ok := s.requireAPIUser(w, r, true); !ok {
return
}
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
body, err := io.ReadAll(r.Body)
if err != nil || len(bytes.TrimSpace(body)) != 0 {
s.apiProblem(w, http.StatusBadRequest, "invalid_request", "The request is invalid.")
return
}
}
result, err := action(r.Context(), r.PathValue("id"))
if err != nil {
s.lifecycleProblem(w, err)
return
}
s.apiJSON(w, http.StatusOK, result)
}
func (s *server) instanceDeleteContainer(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAPIUser(w, r, true); !ok {
return
}
var request struct {
Scope string `json:"scope"`
}
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if decoder.Decode(&request) != nil || request.Scope != "container_only" || decoder.Decode(&struct{}{}) != io.EOF {
s.apiProblem(w, http.StatusUnprocessableEntity, "unsafe_delete_scope", "Only container-only deletion is available; player data and backups are preserved.")
return
}
result, err := s.lifecycle.DeleteContainer(r.Context(), r.PathValue("id"))
if err != nil {
s.lifecycleProblem(w, err)
return
}
s.apiJSON(w, http.StatusOK, result)
}
func (s *server) lifecycleProblem(w http.ResponseWriter, err error) {
status, code := http.StatusBadGateway, "lifecycle_failed"
switch {
case errors.Is(err, instance.ErrInstanceNotFound):
status, code = http.StatusNotFound, "instance_not_found"
case errors.Is(err, instance.ErrOperationConflict):
status, code = http.StatusConflict, "operation_conflict"
case errors.Is(err, instance.ErrInvalidState):
status, code = http.StatusConflict, "invalid_instance_state"
}
s.apiProblem(w, status, code, "The instance operation could not be completed.")
}
func (s *server) buildAPIPreview(w http.ResponseWriter, r *http.Request) (previewAPIRequest, instance.Preview, bool) {
if _, ok := s.requireAPIUser(w, r, true); !ok {
return previewAPIRequest{}, instance.Preview{}, false
+42
View File
@@ -14,11 +14,34 @@ import (
"testing"
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
)
type webLifecycleAgent struct{}
func (webLifecycleAgent) CreateInstance(_ context.Context, plan agentwire.DeploymentPlan) (agentwire.InstanceState, error) {
return agentwire.InstanceState{InstanceID: plan.InstanceID, ContainerID: "container-1", PlanDigest: plan.PlanDigest, Health: "stopped"}, nil
}
func (webLifecycleAgent) InspectInstance(_ context.Context, id string) (agentwire.InstanceState, error) {
return agentwire.InstanceState{InstanceID: id, ContainerID: "container-1", Health: "stopped"}, nil
}
func (webLifecycleAgent) StartInstance(_ context.Context, id string) (agentwire.InstanceState, error) {
return agentwire.InstanceState{InstanceID: id, ContainerID: "container-1", Running: true, Ready: true, Health: "healthy"}, nil
}
func (webLifecycleAgent) StopInstance(_ context.Context, id string, _ int) (agentwire.InstanceState, error) {
return agentwire.InstanceState{InstanceID: id, ContainerID: "container-1", Health: "stopped"}, nil
}
func (webLifecycleAgent) RestartInstance(ctx context.Context, id string, _ int) (agentwire.InstanceState, error) {
return webLifecycleAgent{}.StartInstance(ctx, id)
}
func (webLifecycleAgent) DeleteContainer(context.Context, string) error { return nil }
func (webLifecycleAgent) GetInstanceStats(_ context.Context, id string) (agentwire.InstanceStats, error) {
return agentwire.InstanceStats{InstanceID: id}, nil
}
func TestCatalogPreviewAndDraftAPIAuthorization(t *testing.T) {
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
@@ -72,10 +95,29 @@ func TestCatalogPreviewAndDraftAPIAuthorization(t *testing.T) {
assertStatus(t, trailingJSON, http.StatusBadRequest)
draft := jsonRequest(t, handler, "/api/v1/instances/drafts", payload, sessionCookieValue, session.CSRFToken)
assertStatus(t, draft, http.StatusCreated)
var draftResponse map[string]string
if err := json.Unmarshal(draft.Body.Bytes(), &draftResponse); err != nil {
t.Fatal(err)
}
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM instances WHERE lifecycle_state='draft'").Scan(&count); err != nil || count != 1 {
t.Fatalf("draft count = %d, error = %v", count, err)
}
lifecycleHandler, err := NewHandlerWithLifecycle(authService, repository, webLifecycleAgent{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
installPath := "/api/v1/instances/" + draftResponse["id"] + "/install"
deniedInstall := jsonRequest(t, lifecycleHandler, installPath, nil, sessionCookieValue, "")
assertStatus(t, deniedInstall, http.StatusForbidden)
install := jsonRequest(t, lifecycleHandler, installPath, nil, sessionCookieValue, session.CSRFToken)
assertStatus(t, install, http.StatusOK)
unsafeDelete := httptest.NewRequest(http.MethodDelete, "/api/v1/instances/"+draftResponse["id"], strings.NewReader(`{"scope":"player_data"}`))
unsafeDelete.AddCookie(sessionCookieValue)
unsafeDelete.Header.Set("X-CSRF-Token", session.CSRFToken)
unsafeResponse := httptest.NewRecorder()
lifecycleHandler.ServeHTTP(unsafeResponse, unsafeDelete)
assertStatus(t, unsafeResponse, http.StatusUnprocessableEntity)
}
func TestBootstrapAuthenticationAndLogoutFlow(t *testing.T) {
+46
View File
@@ -0,0 +1,46 @@
ALTER TABLE instances RENAME TO instances_v2;
CREATE TABLE instances (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
template_id TEXT NOT NULL,
template_version TEXT NOT NULL,
template_digest TEXT NOT NULL,
revision INTEGER NOT NULL CHECK (revision >= 1),
lifecycle_state TEXT NOT NULL CHECK (lifecycle_state IN ('draft', 'installing', 'stopped', 'starting', 'online', 'stopping', 'backup', 'restore', 'update', 'degraded', 'error', 'unknown', 'intervention_required', 'deleting', 'deleted')),
observed_state TEXT NOT NULL DEFAULT 'unknown' CHECK (observed_state IN ('unknown', 'missing', 'stopped', 'running', 'ready', 'degraded')),
preview_json TEXT NOT NULL,
plan_digest TEXT NOT NULL,
container_id TEXT,
desired_running INTEGER NOT NULL DEFAULT 0 CHECK (desired_running IN (0, 1)),
last_error_code TEXT,
deleted_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (template_id, template_version) REFERENCES template_versions(template_id, version) ON DELETE RESTRICT
);
INSERT INTO instances(id, slug, display_name, template_id, template_version, template_digest, revision, lifecycle_state, preview_json, plan_digest, created_at, updated_at)
SELECT id, slug, display_name, template_id, template_version, template_digest, revision, lifecycle_state, preview_json, plan_digest, created_at, updated_at
FROM instances_v2;
DROP TABLE instances_v2;
CREATE INDEX instances_template_idx ON instances(template_id, template_version);
CREATE INDEX instances_lifecycle_idx ON instances(lifecycle_state);
CREATE TABLE instance_operations (
id TEXT PRIMARY KEY,
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE RESTRICT,
kind TEXT NOT NULL CHECK (kind IN ('install', 'start', 'stop', 'restart', 'delete_container', 'reconcile')),
state TEXT NOT NULL CHECK (state IN ('running', 'succeeded', 'failed', 'intervention_required')),
phase TEXT NOT NULL,
error_code TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
completed_at TEXT
);
CREATE UNIQUE INDEX instance_operation_active_idx ON instance_operations(instance_id) WHERE state = 'running';
CREATE INDEX instance_operation_history_idx ON instance_operations(instance_id, created_at DESC);