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

355 lines
12 KiB
Go

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"
)
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 DockerInspection struct {
ContainerID string
Running bool
Health string
ExitCode int
Labels map[string]string
}
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,
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return dialer.DialContext(ctx, "unix", socketPath)
},
}
return &dockerRuntime{client: &http.Client{Transport: transport, Timeout: 10 * time.Minute}, network: network}, 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"`
User string `json:"User,omitempty"`
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, User: plan.User, Entrypoint: plan.Entrypoint, Cmd: plan.Arguments,
Labels: mergeDockerLabels(plan.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 mergeDockerLabels(custom, technical map[string]string) map[string]string {
result := make(map[string]string, len(custom)+len(technical))
for key, value := range custom {
result[key] = value
}
for key, value := range technical {
result[key] = value
}
return result
}
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()
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
}