311 lines
12 KiB
Go
311 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 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)
|
|
}
|