Files
DoGaMa-serv/internal/agent/agent_server_test.go
T
codex b575b5260d
CI / validate (pull_request) Failing after 8m3s
fix(deploy): start current Palworld template
2026-08-15 21:43:48 +02:00

335 lines
12 KiB
Go

package agent_test
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
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
inspection agent.DockerInspection
logs string
}
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) EnsureNetwork(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) Logs(context.Context, string, int) (string, error) { return d.logs, d.err }
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()
secret := bytes.Repeat([]byte{0x31}, 32)
handler := newTestHandler(t, root, secret, fakeDocker{})
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
client, err := agentclient.New(server.URL, secret, server.Client())
if err != nil {
t.Fatal(err)
}
if err := client.Health(context.Background()); err != nil {
t.Fatal(err)
}
disks, err := client.CheckDisk(context.Background(), []string{root, root})
if err != nil {
t.Fatal(err)
}
if len(disks) != 1 || disks[0].Path != root || disks[0].BytesTotal == 0 {
t.Fatalf("disk response = %#v", disks)
}
instances, err := client.ListRegisteredInstances(context.Background())
if err != nil {
t.Fatal(err)
}
if len(instances) != 0 {
t.Fatalf("instances = %#v", instances)
}
}
func TestAgentRejectsUnauthenticatedAndOutsideRootRequests(t *testing.T) {
root := t.TempDir()
secret := bytes.Repeat([]byte{0x31}, 32)
handler := newTestHandler(t, root, secret, fakeDocker{})
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/instances", nil))
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated status = %d", recorder.Code)
}
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
client, err := agentclient.New(server.URL, secret, server.Client())
if err != nil {
t.Fatal(err)
}
_, err = client.CheckDisk(context.Background(), []string{filepath.Dir(root)})
var problem *agentclient.ProblemError
if !errors.As(err, &problem) || problem.Status != http.StatusUnprocessableEntity || problem.Code != "path_not_allowed" {
t.Fatalf("outside-root error = %#v", err)
}
}
func TestAgentReportsDockerUnavailableWithoutDetails(t *testing.T) {
root := t.TempDir()
secret := bytes.Repeat([]byte{0x31}, 32)
handler := newTestHandler(t, root, secret, fakeDocker{err: errors.New("sensitive socket detail")})
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
client, err := agentclient.New(server.URL, secret, server.Client())
if err != nil {
t.Fatal(err)
}
err = client.Health(context.Background())
var problem *agentclient.ProblemError
if !errors.As(err, &problem) || problem.Status != http.StatusServiceUnavailable || problem.Code != "docker_unavailable" {
t.Fatalf("health error = %#v", err)
}
}
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 TestAgentTreatsRunningContainerWithoutDockerHealthcheckAsReady(t *testing.T) {
root := t.TempDir()
secret := bytes.Repeat([]byte{0x31}, 32)
instanceID := "abcdefghijklmnopqrstuvwx"
plan := testPlan(t, instanceID, root)
docker := fakeDocker{inspection: agent.DockerInspection{
ContainerID: "container-" + instanceID, Running: true, Status: "running", Health: "none",
Labels: map[string]string{"io.dogama.managed": "true", "io.dogama.instance-id": instanceID, "io.dogama.plan-digest": plan.PlanDigest},
}}
server := httptest.NewServer(newTestHandler(t, root, secret, docker))
t.Cleanup(server.Close)
client, err := agentclient.New(server.URL, secret, server.Client())
if err != nil {
t.Fatal(err)
}
if _, err := client.CreateInstance(context.Background(), plan); err != nil {
t.Fatal(err)
}
state, err := client.InspectInstance(context.Background(), instanceID)
if err != nil || !state.Running || !state.Ready || state.Health != "none" {
t.Fatalf("state=%#v error=%v", state, err)
}
}
func TestAgentStartExitReturnsDockerDiagnosticWithoutLogs(t *testing.T) {
root := t.TempDir()
secret := bytes.Repeat([]byte{0x31}, 32)
instanceID := "abcdefghijklmnopqrstuvwx"
plan := testPlan(t, instanceID, root)
docker := fakeDocker{inspection: agent.DockerInspection{
ContainerID: "container-" + instanceID, Status: "exited", ExitCode: 23,
StartedAt: "2026-01-01T00:00:00Z", FinishedAt: "2026-01-01T00:00:01Z",
OOMKilled: true, Health: "unhealthy", Error: "permission denied password=never-leak",
Labels: map[string]string{"io.dogama.managed": "true", "io.dogama.instance-id": instanceID, "io.dogama.plan-digest": plan.PlanDigest},
}}
server := httptest.NewServer(newTestHandler(t, root, secret, docker))
t.Cleanup(server.Close)
client, err := agentclient.New(server.URL, secret, server.Client())
if err != nil {
t.Fatal(err)
}
if _, err := client.CreateInstance(context.Background(), plan); err != nil {
t.Fatal(err)
}
_, err = client.StartInstance(context.Background(), instanceID)
var problem *agentclient.ProblemError
if !errors.As(err, &problem) || problem.Code != "start_exited" {
t.Fatalf("start error = %#v", err)
}
if problem.Details["exit_code"] != float64(23) || problem.Details["oom_killed"] != true || problem.Details["logs_tail"] != "" {
t.Fatalf("diagnostic = %#v", problem.Details)
}
if strings.Contains(fmt.Sprint(problem.Details), "never-leak") {
t.Fatalf("secret leaked in diagnostic: %#v", problem.Details)
}
}
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 TestAgentRejectsArbitraryNetworkInAPIPlan(t *testing.T) {
root := t.TempDir()
secret := bytes.Repeat([]byte{0x31}, 32)
plan := testPlan(t, "abcdefghijklmnopqrstuvwx", root)
body, err := json.Marshal(plan)
if err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatal(err)
}
payload["network"] = "host"
body, err = json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/v1/instances", bytes.NewReader(body))
timestamp := time.Now().UTC().Format(time.RFC3339Nano)
nonce := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0x42}, 24))
request.Header.Set(agentwire.HeaderTimestamp, timestamp)
request.Header.Set(agentwire.HeaderNonce, nonce)
request.Header.Set("Authorization", agentwire.AuthorizationScheme+" "+agentwire.Signature(secret, request.Method, request.URL.EscapedPath(), timestamp, nonce, body))
recorder := httptest.NewRecorder()
newTestHandler(t, root, secret, fakeDocker{}).ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnprocessableEntity {
t.Fatalf("arbitrary network status = %d, want %d", recorder.Code, http.StatusUnprocessableEntity)
}
}
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 {
t.Fatal(err)
}
registry, err := agent.OpenRegistry(filepath.Join(t.TempDir(), "registry.json"), secret)
if err != nil {
t.Fatal(err)
}
authenticator, err := agent.NewAuthenticator(secret)
if err != nil {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
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)
}