Files
glpi-neural-brain/internal/sourceagent/docker_controller.go
jbergner 440423c5b6
All checks were successful
release-tag / release-image (push) Successful in 2m43s
RC-3
2026-08-09 11:29:13 +02:00

780 lines
25 KiB
Go

package sourceagent
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
type DockerController struct {
socket string
composeBinary string
http *http.Client
apiPrefix string
engineVersion string
apiVersion string
}
func NewDockerController(ctx context.Context, socket, composeBinary string) (*DockerController, error) {
socket = strings.TrimSpace(socket)
if socket == "" {
socket = "/var/run/docker.sock"
}
if composeBinary == "" {
composeBinary = "docker"
}
transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, "unix", socket)
}}
d := &DockerController{socket: socket, composeBinary: composeBinary, http: &http.Client{Transport: transport, Timeout: 2 * time.Minute}}
if err := d.negotiate(ctx); err != nil {
return nil, err
}
return d, nil
}
func (d *DockerController) negotiate(ctx context.Context) error {
var version struct {
Version string `json:"Version"`
APIVersion string `json:"ApiVersion"`
}
if err := d.json(ctx, http.MethodGet, "/version", nil, &version); err != nil {
return fmt.Errorf("docker socket unavailable: %w", err)
}
if strings.TrimSpace(version.APIVersion) == "" {
return errors.New("docker engine did not report ApiVersion")
}
d.engineVersion, d.apiVersion = version.Version, version.APIVersion
d.apiPrefix = "/v" + version.APIVersion
return nil
}
func (d *DockerController) request(ctx context.Context, method, path string, body any) (*http.Response, error) {
var r io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
r = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, "http://docker"+path, r)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
return d.http.Do(req)
}
func (d *DockerController) json(ctx context.Context, method, path string, body, out any) error {
resp, err := d.request(ctx, method, path, body)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
return fmt.Errorf("docker API %s %s: HTTP %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(data)))
}
if out == nil {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
return nil
}
return json.NewDecoder(io.LimitReader(resp.Body, 16<<20)).Decode(out)
}
func (d *DockerController) ComposeAvailable(ctx context.Context) bool {
path, err := exec.LookPath(d.composeBinary)
if err != nil {
return false
}
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
cmd := exec.CommandContext(cctx, path, "compose", "version", "--short")
cmd.Env = append(os.Environ(), "DOCKER_HOST=unix://"+d.socket)
return cmd.Run() == nil
}
func (d *DockerController) Status(ctx context.Context) DockerControllerStatus {
st := DockerControllerStatus{Enabled: true, Socket: d.socket, Reachable: true, EngineVersion: d.engineVersion, APIVersion: d.apiVersion, ComposeAvailable: d.ComposeAvailable(ctx), LastRefresh: time.Now().UTC()}
inv, err := d.Inventory(ctx)
if err != nil {
st.Reachable = false
st.LastError = err.Error()
return st
}
// Heartbeats are intentionally bounded. The dashboard needs a useful
// inventory sample, not every Docker metadata field from a very large host.
if len(inv.Containers) > 100 {
inv.Containers = inv.Containers[:100]
}
if len(inv.Networks) > 100 {
inv.Networks = inv.Networks[:100]
}
if len(inv.Volumes) > 100 {
inv.Volumes = inv.Volumes[:100]
}
st.Inventory = inv
st.Containers = len(inv.Containers)
st.Networks = len(inv.Networks)
st.Volumes = len(inv.Volumes)
for _, c := range inv.Containers {
if c.State == "running" {
st.Running++
}
if c.Health == "unhealthy" {
st.Unhealthy++
}
}
return st
}
func (d *DockerController) Inventory(ctx context.Context) (DockerInventory, error) {
var rawContainers []struct {
ID string `json:"Id"`
Names []string `json:"Names"`
Image string `json:"Image"`
State string `json:"State"`
Status string `json:"Status"`
Labels map[string]string `json:"Labels"`
}
if err := d.json(ctx, http.MethodGet, d.apiPrefix+"/containers/json?all=1", nil, &rawContainers); err != nil {
return DockerInventory{}, err
}
out := DockerInventory{Containers: make([]DockerContainerSummary, 0, len(rawContainers))}
for _, c := range rawContainers {
health := ""
lower := strings.ToLower(c.Status)
if strings.Contains(lower, "(healthy)") {
health = "healthy"
} else if strings.Contains(lower, "(unhealthy)") {
health = "unhealthy"
} else if strings.Contains(lower, "health: starting") {
health = "starting"
}
// Labels may contain large Compose/Kubernetes metadata and are not needed
// by the controller dashboard. Omitting them keeps the signed heartbeat
// inventory comfortably inside its 64 KiB persistence budget.
out.Containers = append(out.Containers, DockerContainerSummary{ID: c.ID, Names: c.Names, Image: c.Image, State: c.State, Status: c.Status, Health: health})
}
var rawNetworks []struct {
ID string `json:"Id"`
Name, Driver, Scope string
}
if err := d.json(ctx, http.MethodGet, d.apiPrefix+"/networks", nil, &rawNetworks); err == nil {
for _, n := range rawNetworks {
out.Networks = append(out.Networks, DockerNetworkSummary{ID: n.ID, Name: n.Name, Driver: n.Driver, Scope: n.Scope})
}
}
var rawVolumes struct {
Volumes []struct{ Name, Driver, Scope string }
}
if err := d.json(ctx, http.MethodGet, d.apiPrefix+"/volumes", nil, &rawVolumes); err == nil {
for _, v := range rawVolumes.Volumes {
out.Volumes = append(out.Volumes, DockerVolumeSummary{Name: v.Name, Driver: v.Driver, Scope: v.Scope})
}
}
return out, nil
}
func (d *DockerController) Execute(ctx context.Context, claim ControllerClaim) ControllerJobResult {
started := time.Now().UTC()
result := ControllerJobResult{JobID: claim.Job.ID, Status: ControllerJobSucceeded, Result: map[string]any{}}
if err := validateControllerJob(claim.Job, claim.Policy); err != nil {
result.Status = ControllerJobFailed
result.Error = err.Error()
return result
}
if claim.Job.DryRun || claim.Policy.DryRun {
result.Result = map[string]any{"dry_run": true, "validated": true, "kind": claim.Job.Kind, "parameters": claim.Job.Parameters}
result.DurationMS = time.Since(started).Milliseconds()
return result
}
var err error
switch claim.Job.Kind {
case "inventory_refresh":
var inv DockerInventory
inv, err = d.Inventory(ctx)
if err == nil {
result.Result = map[string]any{"inventory": inv}
}
case "container_start", "container_stop", "container_restart", "container_remove":
err = d.containerLifecycle(ctx, claim.Job, claim.Policy, result.Result)
case "container_create":
err = d.containerCreate(ctx, claim.Job, claim.Policy, result.Result)
case "network_create", "network_remove":
err = d.networkAction(ctx, claim.Job, claim.Policy, result.Result)
case "volume_create", "volume_remove":
err = d.volumeAction(ctx, claim.Job, claim.Policy, result.Result)
case "compose_up", "compose_down", "compose_restart", "compose_pull", "compose_ps", "compute_capacity_compose", "compose_smoke_test":
err = d.composeAction(ctx, claim.Job, claim.Policy, result.Result)
case "evidence_http_probe":
err = d.evidenceProbe(ctx, claim.Job, claim.Policy, result.Result)
case "health_recovery":
job := claim.Job
job.Kind = "container_restart"
err = d.containerLifecycle(ctx, job, claim.Policy, result.Result)
default:
err = errors.New("unsupported controller action")
}
if err != nil {
result.Status = ControllerJobFailed
result.Error = err.Error()
}
result.DurationMS = time.Since(started).Milliseconds()
return result
}
func validateControllerJob(job ControllerJob, policy ControllerPolicy) error {
if !policy.Enabled {
return errors.New("docker controller master switch disabled")
}
if job.Autonomous && !policy.AutonomousEnabled {
return errors.New("autonomous docker controller actions disabled")
}
if job.Autonomous {
switch job.Kind {
case "evidence_http_probe", "health_recovery", "compute_capacity_compose", "compose_smoke_test":
default:
return errors.New("job kind is not permitted for autonomous controller execution")
}
}
if isDestructiveControllerKind(job.Kind) && !policy.AllowDestructive {
return errors.New("destructive docker controller actions are disabled")
}
return nil
}
func isDestructiveControllerKind(kind string) bool {
switch kind {
case "container_remove", "network_remove", "volume_remove", "compose_down":
return true
}
return false
}
func (d *DockerController) containerLifecycle(ctx context.Context, job ControllerJob, policy ControllerPolicy, out map[string]any) error {
name := paramString(job.Parameters, "container")
if name == "" {
return errors.New("container is required")
}
if protectedName(name, policy.ProtectedContainers) {
return errors.New("container is protected by controller policy")
}
id := url.PathEscape(name)
var method, path string = http.MethodPost, d.apiPrefix + "/containers/" + id + "/" + strings.TrimPrefix(job.Kind, "container_")
if job.Kind == "container_stop" || job.Kind == "container_restart" {
path += "?t=15"
}
if job.Kind == "container_remove" {
method = http.MethodDelete
path = d.apiPrefix + "/containers/" + id + "?force=false&v=false"
}
if err := d.json(ctx, method, path, nil, nil); err != nil {
return err
}
out["container"] = name
out["action"] = job.Kind
return nil
}
func (d *DockerController) containerCreate(ctx context.Context, job ControllerJob, policy ControllerPolicy, out map[string]any) error {
image := paramString(job.Parameters, "image")
name := paramString(job.Parameters, "name")
if image == "" || name == "" {
return errors.New("image and name are required")
}
if !imageAllowed(image, policy.AllowedImages) {
return errors.New("image is not allowed by controller policy")
}
if protectedName(name, policy.ProtectedContainers) {
return errors.New("container name is protected")
}
network := paramString(job.Parameters, "network")
if network == "" {
network = "bridge"
}
if network == "host" || network == "container" || strings.HasPrefix(network, "container:") {
return errors.New("host/container network modes are forbidden")
}
cmd := paramStrings(job.Parameters, "command", 32, 2048)
env := paramStrings(job.Parameters, "env", 64, 4096)
mounts, err := namedVolumeMounts(job.Parameters["volumes"], policy)
if err != nil {
return err
}
memory := paramInt64(job.Parameters, "memory_bytes", 512<<20)
if memory < 32<<20 {
memory = 32 << 20
}
if memory > 8<<30 {
memory = 8 << 30
}
nano := paramInt64(job.Parameters, "nano_cpus", 1_000_000_000)
if nano < 100_000_000 {
nano = 100_000_000
}
if nano > 4_000_000_000 {
nano = 4_000_000_000
}
body := map[string]any{"Image": image, "Cmd": cmd, "Env": env, "Tty": false, "HostConfig": map[string]any{"NetworkMode": network, "Privileged": false, "ReadonlyRootfs": true, "CapDrop": []string{"ALL"}, "SecurityOpt": []string{"no-new-privileges:true"}, "Memory": memory, "NanoCpus": nano, "Mounts": mounts}}
var created struct {
ID string `json:"Id"`
}
path := d.apiPrefix + "/containers/create?name=" + url.QueryEscape(name)
if err := d.json(ctx, http.MethodPost, path, body, &created); err != nil {
return err
}
out["container_id"] = created.ID
out["name"] = name
if paramBool(job.Parameters, "start", true) {
if err := d.json(ctx, http.MethodPost, d.apiPrefix+"/containers/"+url.PathEscape(created.ID)+"/start", nil, nil); err != nil {
return err
}
out["started"] = true
}
return nil
}
func (d *DockerController) networkAction(ctx context.Context, job ControllerJob, policy ControllerPolicy, out map[string]any) error {
name := paramString(job.Parameters, "network")
if name == "" {
return errors.New("network is required")
}
if protectedName(name, policy.ProtectedNetworks) {
return errors.New("network is protected")
}
if job.Kind == "network_create" {
driver := paramString(job.Parameters, "driver")
if driver == "" {
driver = "bridge"
}
if driver != "bridge" {
return errors.New("only bridge networks may be created by the controller")
}
var r struct{ ID string }
if err := d.json(ctx, http.MethodPost, d.apiPrefix+"/networks/create", map[string]any{"Name": name, "Driver": driver, "CheckDuplicate": true, "Internal": paramBool(job.Parameters, "internal", false)}, &r); err != nil {
return err
}
out["network_id"] = r.ID
} else {
if err := d.json(ctx, http.MethodDelete, d.apiPrefix+"/networks/"+url.PathEscape(name), nil, nil); err != nil {
return err
}
}
out["network"] = name
return nil
}
func (d *DockerController) volumeAction(ctx context.Context, job ControllerJob, policy ControllerPolicy, out map[string]any) error {
name := paramString(job.Parameters, "volume")
if name == "" {
return errors.New("volume is required")
}
if protectedName(name, policy.ProtectedVolumes) {
return errors.New("volume is protected")
}
if job.Kind == "volume_create" {
var r map[string]any
if err := d.json(ctx, http.MethodPost, d.apiPrefix+"/volumes/create", map[string]any{"Name": name, "Driver": "local"}, &r); err != nil {
return err
}
out["volume"] = r
} else {
if err := d.json(ctx, http.MethodDelete, d.apiPrefix+"/volumes/"+url.PathEscape(name)+"?force=false", nil, nil); err != nil {
return err
}
out["volume"] = name
}
return nil
}
func (d *DockerController) composeAction(ctx context.Context, job ControllerJob, policy ControllerPolicy, out map[string]any) error {
if !d.ComposeAvailable(ctx) {
return errors.New("docker compose CLI is not available in this controller agent")
}
file := paramString(job.Parameters, "compose_file")
safe, err := allowedComposeFile(file, policy.AllowedComposeRoots)
if err != nil {
return err
}
project := paramString(job.Parameters, "project")
if project == "" {
project = "brain-controller"
}
service := paramString(job.Parameters, "service")
run := func(args ...string) (string, error) {
base := []string{"compose", "-f", safe, "-p", project}
base = append(base, args...)
cmd := exec.CommandContext(ctx, d.composeBinary, base...)
cmd.Env = append(os.Environ(), "DOCKER_HOST=unix://"+d.socket)
data, err := cmd.CombinedOutput()
if len(data) > 256<<10 {
data = data[:256<<10]
}
return strings.TrimSpace(string(data)), err
}
out["compose_file"] = safe
out["project"] = project
if job.Kind == "compose_smoke_test" {
args := []string{"up", "-d", "--wait", "--wait-timeout", "120"}
if service != "" {
args = append(args, service)
}
up, err := run(args...)
out["up_output"] = up
if err != nil {
return fmt.Errorf("compose smoke-test up failed: %w: %s", err, up)
}
ps, psErr := run("ps", "--format", "json")
out["ps_output"] = ps
out["smoke_test_passed"] = psErr == nil
if paramBool(job.Parameters, "cleanup", false) {
if !policy.AllowDestructive {
return errors.New("smoke-test cleanup requires destructive actions to be enabled")
}
down, downErr := run("down", "--remove-orphans")
out["cleanup_output"] = down
if downErr != nil && psErr == nil {
psErr = downErr
}
}
if psErr != nil {
return fmt.Errorf("compose smoke-test verification failed: %w", psErr)
}
return nil
}
action := strings.TrimPrefix(job.Kind, "compose_")
if job.Kind == "compute_capacity_compose" {
action = "up"
}
var args []string
switch action {
case "up":
args = []string{"up", "-d"}
case "down":
args = []string{"down", "--remove-orphans"}
case "restart":
args = []string{"restart"}
case "pull":
args = []string{"pull"}
case "ps":
args = []string{"ps", "--format", "json"}
default:
return errors.New("unsupported compose action")
}
if service != "" && action != "down" {
args = append(args, service)
}
data, err := run(args...)
out["output"] = data
out["action"] = action
if err != nil {
return fmt.Errorf("docker compose %s failed: %w: %s", action, err, data)
}
return nil
}
func (d *DockerController) evidenceProbe(ctx context.Context, job ControllerJob, policy ControllerPolicy, out map[string]any) error {
target := paramString(job.Parameters, "url")
u, err := url.Parse(target)
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
return errors.New("evidence probe requires absolute http(s) url")
}
if err := validateEvidenceProbeTarget(ctx, u); err != nil {
return err
}
image := paramString(job.Parameters, "image")
if image == "" {
image = "curlimages/curl:8.11.1"
}
if !imageAllowed(image, policy.AllowedImages) {
return errors.New("evidence probe image is not allowed")
}
_ = d.pullImage(ctx, image)
name := "brain-evidence-" + strings.TrimPrefix(randomID("p"), "p-")
marker := "__BRAIN_PROBE_META__"
cmd := []string{"--silent", "--show-error", "--location", "--max-time", "25", "--connect-timeout", "10", "--max-filesize", "524288", "--output", "-", "--write-out", "\n" + marker + "%{http_code}|%{url_effective}|%{content_type}|%{size_download}", target}
body := map[string]any{"Image": image, "Cmd": cmd, "Tty": true, "AttachStdout": true, "AttachStderr": true, "HostConfig": map[string]any{"NetworkMode": "bridge", "AutoRemove": false, "Privileged": false, "ReadonlyRootfs": true, "CapDrop": []string{"ALL"}, "SecurityOpt": []string{"no-new-privileges:true"}, "Memory": int64(128 << 20), "NanoCpus": int64(500_000_000)}}
var created struct {
ID string `json:"Id"`
}
if err := d.json(ctx, http.MethodPost, d.apiPrefix+"/containers/create?name="+url.QueryEscape(name), body, &created); err != nil {
return err
}
defer d.json(context.Background(), http.MethodDelete, d.apiPrefix+"/containers/"+url.PathEscape(created.ID)+"?force=true&v=false", nil, nil)
if err := d.json(ctx, http.MethodPost, d.apiPrefix+"/containers/"+url.PathEscape(created.ID)+"/start", nil, nil); err != nil {
return err
}
var wait struct{ StatusCode int }
if err := d.json(ctx, http.MethodPost, d.apiPrefix+"/containers/"+url.PathEscape(created.ID)+"/wait?condition=not-running", nil, &wait); err != nil {
return err
}
resp, err := d.request(ctx, http.MethodGet, d.apiPrefix+"/containers/"+url.PathEscape(created.ID)+"/logs?stdout=1&stderr=1&tail=all", nil)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
text := string(raw)
idx := strings.LastIndex(text, "\n"+marker)
bodyBytes := raw
meta := ""
if idx >= 0 {
bodyBytes = raw[:idx]
meta = strings.TrimSpace(text[idx+1+len(marker):])
}
sum := sha256.Sum256(bodyBytes)
out["url"] = target
out["body_sha256"] = hex.EncodeToString(sum[:])
out["captured_bytes"] = len(bodyBytes)
out["exit_code"] = wait.StatusCode
if meta != "" {
parts := strings.Split(meta, "|")
if len(parts) >= 4 {
out["http_status"] = parts[0]
out["effective_url"] = parts[1]
out["content_type"] = parts[2]
out["download_bytes"] = parts[3]
}
}
if wait.StatusCode != 0 {
return fmt.Errorf("evidence probe exited with code %d", wait.StatusCode)
}
return nil
}
func validateEvidenceProbeTarget(ctx context.Context, u *url.URL) error {
if u == nil || u.User != nil {
return errors.New("evidence probe target must not contain credentials")
}
host := strings.TrimSpace(strings.ToLower(u.Hostname()))
if host == "" || host == "localhost" || strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") || strings.HasSuffix(host, ".internal") {
return errors.New("evidence probe target must be a public host")
}
unsafeIP := func(ip net.IP) bool {
return ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()
}
if literal := net.ParseIP(host); literal != nil {
if unsafeIP(literal) {
return errors.New("evidence probe target must not use a private or local IP")
}
return nil
}
lookupCtx, cancel := context.WithTimeout(ctx, 4*time.Second)
defer cancel()
addresses, err := net.DefaultResolver.LookupIPAddr(lookupCtx, host)
if err != nil || len(addresses) == 0 {
return errors.New("evidence probe target could not be resolved safely")
}
for _, address := range addresses {
if unsafeIP(address.IP) {
return errors.New("evidence probe target resolves to a private or local address")
}
}
return nil
}
func (d *DockerController) pullImage(ctx context.Context, image string) error {
repository, tag := splitDockerImageReference(image)
path := d.apiPrefix + "/images/create?fromImage=" + url.QueryEscape(repository)
if tag != "" {
path += "&tag=" + url.QueryEscape(tag)
}
resp, err := d.request(ctx, http.MethodPost, path, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return fmt.Errorf("docker image pull HTTP %d", resp.StatusCode)
}
_, err = io.Copy(io.Discard, io.LimitReader(resp.Body, 16<<20))
return err
}
func splitDockerImageReference(image string) (repository, tag string) {
image = strings.TrimSpace(image)
if image == "" {
return "", ""
}
// Digests are complete references and must not be split into tag syntax.
if strings.Contains(image, "@") {
return image, ""
}
lastSlash := strings.LastIndex(image, "/")
lastColon := strings.LastIndex(image, ":")
// A colon before the final slash belongs to a registry port, not to a tag.
if lastColon > lastSlash {
return image[:lastColon], image[lastColon+1:]
}
return image, ""
}
func imageAllowed(image string, allowed []string) bool {
for _, a := range allowed {
a = strings.TrimSpace(a)
if a == "*" || image == a {
return true
}
// Prefix allow rules must be explicit. A trailing ':', '/', or '@'
// expresses "all tags/children/digests"; plain names stay exact so an
// allowlist entry like "vendor/tool" cannot also permit
// "vendor/tool-malicious".
if (strings.HasSuffix(a, ":") || strings.HasSuffix(a, "/") || strings.HasSuffix(a, "@")) && strings.HasPrefix(image, a) {
return true
}
}
return false
}
func protectedName(name string, patterns []string) bool {
name = strings.TrimPrefix(strings.TrimSpace(name), "/")
for _, p := range patterns {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if ok, _ := filepath.Match(p, name); ok || strings.EqualFold(p, name) {
return true
}
}
return false
}
func allowedComposeFile(file string, roots []string) (string, error) {
if strings.TrimSpace(file) == "" {
return "", errors.New("compose_file is required")
}
abs, err := filepath.Abs(file)
if err != nil {
return "", err
}
resolved, err := filepath.EvalSymlinks(abs)
if err != nil {
return "", err
}
for _, root := range roots {
rr, err := filepath.EvalSymlinks(root)
if err != nil {
rr = filepath.Clean(root)
}
rel, err := filepath.Rel(rr, resolved)
if err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return resolved, nil
}
}
return "", errors.New("compose file is outside allowed compose roots")
}
func namedVolumeMounts(raw any, policy ControllerPolicy) ([]map[string]any, error) {
arr, ok := raw.([]any)
if !ok && raw != nil {
return nil, errors.New("volumes must be an array")
}
var out []map[string]any
for _, item := range arr {
m, ok := item.(map[string]any)
if !ok {
return nil, errors.New("invalid volume mount")
}
source := strings.TrimSpace(fmt.Sprint(m["source"]))
target := strings.TrimSpace(fmt.Sprint(m["target"]))
if source == "" || target == "" || !strings.HasPrefix(target, "/") {
return nil, errors.New("named volume source and absolute target are required")
}
if protectedName(source, policy.ProtectedVolumes) {
return nil, errors.New("volume is protected")
}
out = append(out, map[string]any{"Type": "volume", "Source": source, "Target": target, "ReadOnly": toBool(m["read_only"], false)})
}
return out, nil
}
func paramString(m map[string]any, key string) string {
if m == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(m[key]))
}
func paramBool(m map[string]any, key string, def bool) bool {
if m == nil {
return def
}
v, ok := m[key]
if !ok {
return def
}
return toBool(v, def)
}
func toBool(v any, def bool) bool {
switch x := v.(type) {
case bool:
return x
case string:
b, e := strconv.ParseBool(x)
if e == nil {
return b
}
case float64:
return x != 0
}
return def
}
func paramInt64(m map[string]any, key string, def int64) int64 {
if m == nil {
return def
}
switch v := m[key].(type) {
case float64:
return int64(v)
case int:
return int64(v)
case int64:
return v
case string:
n, e := strconv.ParseInt(v, 10, 64)
if e == nil {
return n
}
}
return def
}
func paramStrings(m map[string]any, key string, max, each int) []string {
if m == nil {
return nil
}
raw, ok := m[key].([]any)
if !ok {
if ss, ok := m[key].([]string); ok {
return ss
}
return nil
}
if len(raw) > max {
raw = raw[:max]
}
out := make([]string, 0, len(raw))
for _, v := range raw {
s := fmt.Sprint(v)
if len(s) > each {
s = s[:each]
}
out = append(out, s)
}
return out
}