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