Managed instance lifecycle foundation #4
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user