Files
DoGaMa-serv/internal/agent/agent_server.go
T

439 lines
16 KiB
Go

// Package agent implements the deny-by-default privileged Docker boundary.
package agent
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"syscall"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
)
const maxDiskPaths = 16
type service struct {
paths *PathPolicy
registry *Registry
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, 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("PUT /v1/instances/{id}", server.replaceInstance)
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))
}
func (s *service) health(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := s.docker.Ping(ctx); err != nil {
s.logger.Warn("Docker daemon unavailable", "event", "agent.docker.unavailable")
writeProblem(w, http.StatusServiceUnavailable, "docker_unavailable", "The Docker daemon is unavailable.")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *service) checkDisk(w http.ResponseWriter, r *http.Request) {
var request struct {
Paths []string `json:"paths"`
}
if err := decodeJSON(r.Body, &request); err != nil || len(request.Paths) == 0 || len(request.Paths) > maxDiskPaths {
writeProblem(w, http.StatusBadRequest, "invalid_request", "The request is invalid.")
return
}
type diskInfo struct {
Path string `json:"path"`
BytesAvailable uint64 `json:"bytes_available"`
BytesTotal uint64 `json:"bytes_total"`
}
response := struct {
Paths []diskInfo `json:"paths"`
}{Paths: make([]diskInfo, 0, len(request.Paths))}
seen := make(map[string]struct{}, len(request.Paths))
for _, requested := range request.Paths {
canonical, err := s.paths.Resolve(requested)
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "path_not_allowed", "A requested path is not allowed.")
return
}
if _, exists := seen[canonical]; exists {
continue
}
seen[canonical] = struct{}{}
available, total, err := s.disk.AvailableBytes(canonical)
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "path_unavailable", "A requested path is unavailable.")
return
}
response.Paths = append(response.Paths, diskInfo{
Path: requested,
BytesAvailable: available,
BytesTotal: total,
})
}
writeJSON(w, http.StatusOK, response)
}
func (s *service) listInstances(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, struct {
Instances []RegisteredInstance `json:"instances"`
}{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) replaceInstance(w http.ResponseWriter, r *http.Request) {
var plan agentwire.DeploymentPlan
id := r.PathValue("id")
if decodeJSON(r.Body, &plan) != nil || plan.InstanceID != id || s.plans.Validate(plan) != nil {
writeProblem(w, http.StatusUnprocessableEntity, "invalid_plan", "The replacement plan is invalid.")
return
}
entry, ok := s.registry.Get(id)
if !ok {
writeProblem(w, http.StatusNotFound, "registration_not_found", "The instance is not registered.")
return
}
oldState, err := s.boundState(r.Context(), entry)
if err != nil {
writeProblem(w, http.StatusConflict, "registration_mismatch", "The registered container binding is invalid.")
return
}
for index := range plan.Mounts {
canonical, pathErr := s.paths.Resolve(plan.Mounts[index].HostPath)
if pathErr != nil {
writeProblem(w, http.StatusUnprocessableEntity, "path_not_allowed", "A deployment path is not allowed.")
return
}
plan.Mounts[index].HostPath = canonical
}
assets, err := s.prepareAssets(plan)
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "asset_prepare_failed", "Approved template assets could not be prepared.")
return
}
if oldState.Running {
if err := s.docker.Stop(r.Context(), entry.ContainerID, plan.StopTimeoutSeconds); err != nil {
writeProblem(w, http.StatusBadGateway, "container_stop_failed", "The previous container could not be stopped.")
return
}
}
if err := s.docker.Delete(r.Context(), entry.ContainerID); err != nil {
writeProblem(w, http.StatusBadGateway, "container_delete_failed", "The previous container could not be removed.")
return
}
containerID, err := s.docker.Create(r.Context(), plan, assets)
if err != nil {
writeProblem(w, http.StatusBadGateway, "container_replace_failed", "The replacement container could not be created; persistent data was preserved.")
return
}
replacement := RegisteredInstance{InstanceID: id, ContainerID: containerID, PlanDigest: plan.PlanDigest}
if err := s.registry.Replace(replacement); err != nil {
_ = s.docker.Delete(r.Context(), containerID)
writeProblem(w, http.StatusInternalServerError, "registration_failed", "The replacement registration could not be persisted.")
return
}
writeJSON(w, http.StatusOK, agentwire.InstanceState{InstanceID: id, 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")
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
w.Header().Set("X-Content-Type-Options", "nosniff")
next.ServeHTTP(w, r)
})
}
func decodeJSON(body io.Reader, destination any) error {
decoder := json.NewDecoder(body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(destination); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return errors.New("multiple JSON values")
}
return nil
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func writeProblem(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, struct {
Code string `json:"code"`
Message string `json:"message"`
}{Code: code, Message: message})
}