Files
ServerKit/agent/internal/docker/client.go
T
Juan DenisandClaude Opus 4.7 e71099a4e9 Add remote packages, services, sudo, job streaming and capability re-probe
Foundation for the remote server setup experience: agent gains
auto-sudo, package and systemd handler additions, missing docker
verbs, and a job-channel registry for streaming long-running
operations. Panel exposes the new agent surface as /servers/<id>/
packages, /services, and /refresh-capabilities routes.

Agent:
- New protocol actions: agent:recapabilities, packages:update_cache/
  install_async/upgrade/search/info, systemd:daemon_reload/list_units/
  logs/logs_follow, docker:container:create/exec, docker:image:build,
  docker:volume:create, docker:network:create.
- Auto-sudo helper (sudo -n) probed at boot, surfaced as a capability;
  apt invocations get DEBIAN_FRONTEND=noninteractive + dpkg force-confold.
- jobs registry with ring-buffer replay so panels that subscribe to a
  job:<id> channel mid-flight still see prior progress events.
- Capability re-probe is mutex-guarded and additive (transient probe
  failures don't drop features the panel already saw).
- Existing systemd handlers now sudo and use 2-min timeouts for cold
  service starts (postgresql etc.).

Panel:
- remote_packages_service.py and remote_systemd_service.py mirror the
  cron service shape (thin send_command dispatcher).
- 13 new routes on servers.py covering packages CRUD + search/info,
  systemd unit control + logs + daemon-reload, and capability refresh.
- Long-running install/upgrade return {job_id, channel} for the
  frontend to subscribe to via Socket.IO.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 04:33:08 -04:00

894 lines
25 KiB
Go

package docker
import (
"context"
"encoding/json"
"fmt"
"io"
"os/exec"
"strings"
"github.com/docker/docker/api/types"
containertypes "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
networktypes "github.com/docker/docker/api/types/network"
volumetypes "github.com/docker/docker/api/types/volume"
"github.com/docker/docker/client"
"github.com/docker/go-connections/nat"
"github.com/serverkit/agent/internal/config"
"github.com/serverkit/agent/internal/logger"
)
// Client wraps the Docker client with additional functionality
type Client struct {
cli *client.Client
cfg config.DockerConfig
log *logger.Logger
}
// ContainerInfo represents container information
type ContainerInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
ImageID string `json:"image_id"`
Command string `json:"command"`
Created int64 `json:"created"`
State string `json:"state"`
Status string `json:"status"`
Ports []PortMapping `json:"ports"`
Labels map[string]string `json:"labels"`
}
// PortMapping represents a port mapping
type PortMapping struct {
IP string `json:"ip,omitempty"`
PrivatePort uint16 `json:"private_port"`
PublicPort uint16 `json:"public_port,omitempty"`
Type string `json:"type"`
}
// ContainerStats represents container statistics
type ContainerStats struct {
ID string `json:"id"`
Name string `json:"name"`
CPUPercent float64 `json:"cpu_percent"`
MemoryUsage uint64 `json:"memory_usage"`
MemoryLimit uint64 `json:"memory_limit"`
MemoryPercent float64 `json:"memory_percent"`
NetworkRx uint64 `json:"network_rx"`
NetworkTx uint64 `json:"network_tx"`
BlockRead uint64 `json:"block_read"`
BlockWrite uint64 `json:"block_write"`
PIDs uint64 `json:"pids"`
}
// ImageInfo represents image information
type ImageInfo struct {
ID string `json:"id"`
RepoTags []string `json:"repo_tags"`
RepoDigests []string `json:"repo_digests"`
Created int64 `json:"created"`
Size int64 `json:"size"`
}
// VolumeInfo represents volume information
type VolumeInfo struct {
Name string `json:"name"`
Driver string `json:"driver"`
Mountpoint string `json:"mountpoint"`
Labels map[string]string `json:"labels"`
Scope string `json:"scope"`
CreatedAt string `json:"created_at"`
}
// NetworkInfo represents network information
type NetworkInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Driver string `json:"driver"`
Scope string `json:"scope"`
Internal bool `json:"internal"`
Labels map[string]string `json:"labels"`
Containers int `json:"containers"`
}
// NewClient creates a new Docker client
func NewClient(cfg config.DockerConfig, log *logger.Logger) (*Client, error) {
var opts []client.Opt
// Set host if configured
if cfg.Socket != "" {
host := cfg.Socket
// Convert socket path to Docker format
if strings.HasPrefix(host, "/") {
host = "unix://" + host
}
opts = append(opts, client.WithHost(host))
}
opts = append(opts, client.WithAPIVersionNegotiation())
if cfg.Timeout > 0 {
opts = append(opts, client.WithTimeout(cfg.Timeout))
}
cli, err := client.NewClientWithOpts(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create Docker client: %w", err)
}
return &Client{
cli: cli,
cfg: cfg,
log: log.WithComponent("docker"),
}, nil
}
// Ping checks if Docker is available
func (c *Client) Ping(ctx context.Context) error {
_, err := c.cli.Ping(ctx)
return err
}
// Version returns the Docker version
func (c *Client) Version(ctx context.Context) (string, error) {
info, err := c.cli.ServerVersion(ctx)
if err != nil {
return "", err
}
return info.Version, nil
}
// Info returns Docker system info
func (c *Client) Info(ctx context.Context) (*types.Info, error) {
info, err := c.cli.Info(ctx)
if err != nil {
return nil, err
}
return &info, nil
}
// ListContainers lists all containers
func (c *Client) ListContainers(ctx context.Context, all bool) ([]ContainerInfo, error) {
containers, err := c.cli.ContainerList(ctx, types.ContainerListOptions{
All: all,
})
if err != nil {
return nil, err
}
result := make([]ContainerInfo, len(containers))
for i, cont := range containers {
ports := make([]PortMapping, len(cont.Ports))
for j, p := range cont.Ports {
ports[j] = PortMapping{
IP: p.IP,
PrivatePort: p.PrivatePort,
PublicPort: p.PublicPort,
Type: p.Type,
}
}
name := ""
if len(cont.Names) > 0 {
name = strings.TrimPrefix(cont.Names[0], "/")
}
result[i] = ContainerInfo{
ID: cont.ID[:12],
Name: name,
Image: cont.Image,
ImageID: cont.ImageID,
Command: cont.Command,
Created: cont.Created,
State: cont.State,
Status: cont.Status,
Ports: ports,
Labels: cont.Labels,
}
}
return result, nil
}
// InspectContainer inspects a container
func (c *Client) InspectContainer(ctx context.Context, id string) (*types.ContainerJSON, error) {
cont, err := c.cli.ContainerInspect(ctx, id)
if err != nil {
return nil, err
}
return &cont, nil
}
// StartContainer starts a container
func (c *Client) StartContainer(ctx context.Context, id string) error {
return c.cli.ContainerStart(ctx, id, types.ContainerStartOptions{})
}
// StopContainer stops a container
func (c *Client) StopContainer(ctx context.Context, id string, timeout *int) error {
var stopOpts containertypes.StopOptions
if timeout != nil {
stopOpts.Timeout = timeout
}
return c.cli.ContainerStop(ctx, id, stopOpts)
}
// RestartContainer restarts a container
func (c *Client) RestartContainer(ctx context.Context, id string, timeout *int) error {
var stopOpts containertypes.StopOptions
if timeout != nil {
stopOpts.Timeout = timeout
}
return c.cli.ContainerRestart(ctx, id, stopOpts)
}
// RemoveContainer removes a container
func (c *Client) RemoveContainer(ctx context.Context, id string, force, removeVolumes bool) error {
return c.cli.ContainerRemove(ctx, id, types.ContainerRemoveOptions{
Force: force,
RemoveVolumes: removeVolumes,
})
}
// ContainerLogs returns container logs
func (c *Client) ContainerLogs(ctx context.Context, id string, tail string, since string, follow bool) (io.ReadCloser, error) {
opts := types.ContainerLogsOptions{
ShowStdout: true,
ShowStderr: true,
Follow: follow,
Timestamps: true,
}
if tail != "" {
opts.Tail = tail
}
if since != "" {
opts.Since = since
}
return c.cli.ContainerLogs(ctx, id, opts)
}
// ContainerStats returns container stats
func (c *Client) ContainerStats(ctx context.Context, id string) (*ContainerStats, error) {
resp, err := c.cli.ContainerStats(ctx, id, false)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var stats types.StatsJSON
if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil {
return nil, err
}
// Calculate CPU percentage
cpuPercent := calculateCPUPercent(&stats)
// Calculate memory percentage
memPercent := 0.0
if stats.MemoryStats.Limit > 0 {
memPercent = float64(stats.MemoryStats.Usage) / float64(stats.MemoryStats.Limit) * 100
}
// Calculate network I/O
var rx, tx uint64
for _, net := range stats.Networks {
rx += net.RxBytes
tx += net.TxBytes
}
// Calculate block I/O
var read, write uint64
for _, bio := range stats.BlkioStats.IoServiceBytesRecursive {
if bio.Op == "Read" {
read += bio.Value
} else if bio.Op == "Write" {
write += bio.Value
}
}
// Get container name
inspect, _ := c.cli.ContainerInspect(ctx, id)
name := strings.TrimPrefix(inspect.Name, "/")
return &ContainerStats{
ID: id[:12],
Name: name,
CPUPercent: cpuPercent,
MemoryUsage: stats.MemoryStats.Usage,
MemoryLimit: stats.MemoryStats.Limit,
MemoryPercent: memPercent,
NetworkRx: rx,
NetworkTx: tx,
BlockRead: read,
BlockWrite: write,
PIDs: stats.PidsStats.Current,
}, nil
}
// ListImages lists all images
func (c *Client) ListImages(ctx context.Context) ([]ImageInfo, error) {
images, err := c.cli.ImageList(ctx, types.ImageListOptions{})
if err != nil {
return nil, err
}
result := make([]ImageInfo, len(images))
for i, img := range images {
result[i] = ImageInfo{
ID: img.ID,
RepoTags: img.RepoTags,
RepoDigests: img.RepoDigests,
Created: img.Created,
Size: img.Size,
}
}
return result, nil
}
// PullImage pulls an image
func (c *Client) PullImage(ctx context.Context, imageName string) (io.ReadCloser, error) {
return c.cli.ImagePull(ctx, imageName, types.ImagePullOptions{})
}
// RemoveImage removes an image
func (c *Client) RemoveImage(ctx context.Context, id string, force bool) error {
_, err := c.cli.ImageRemove(ctx, id, types.ImageRemoveOptions{
Force: force,
})
return err
}
// ListVolumes lists all volumes
func (c *Client) ListVolumes(ctx context.Context) ([]VolumeInfo, error) {
resp, err := c.cli.VolumeList(ctx, volumetypes.ListOptions{})
if err != nil {
return nil, err
}
result := make([]VolumeInfo, len(resp.Volumes))
for i, vol := range resp.Volumes {
result[i] = VolumeInfo{
Name: vol.Name,
Driver: vol.Driver,
Mountpoint: vol.Mountpoint,
Labels: vol.Labels,
Scope: vol.Scope,
CreatedAt: vol.CreatedAt,
}
}
return result, nil
}
// CreateVolume creates a volume
func (c *Client) CreateVolume(ctx context.Context, name, driver string, labels map[string]string) (*VolumeInfo, error) {
vol, err := c.cli.VolumeCreate(ctx, volumetypes.CreateOptions{
Name: name,
Driver: driver,
Labels: labels,
})
if err != nil {
return nil, err
}
return &VolumeInfo{
Name: vol.Name,
Driver: vol.Driver,
Mountpoint: vol.Mountpoint,
Labels: vol.Labels,
Scope: vol.Scope,
CreatedAt: vol.CreatedAt,
}, nil
}
// RemoveVolume removes a volume
func (c *Client) RemoveVolume(ctx context.Context, name string, force bool) error {
return c.cli.VolumeRemove(ctx, name, force)
}
// ListNetworks lists all networks
func (c *Client) ListNetworks(ctx context.Context) ([]NetworkInfo, error) {
networks, err := c.cli.NetworkList(ctx, types.NetworkListOptions{})
if err != nil {
return nil, err
}
result := make([]NetworkInfo, len(networks))
for i, net := range networks {
result[i] = NetworkInfo{
ID: net.ID[:12],
Name: net.Name,
Driver: net.Driver,
Scope: net.Scope,
Internal: net.Internal,
Labels: net.Labels,
Containers: len(net.Containers),
}
}
return result, nil
}
// RemoveNetwork removes a network
func (c *Client) RemoveNetwork(ctx context.Context, id string) error {
return c.cli.NetworkRemove(ctx, id)
}
// GetContainerCount returns the number of containers
func (c *Client) GetContainerCount(ctx context.Context) (total int, running int, err error) {
allContainers, err := c.cli.ContainerList(ctx, types.ContainerListOptions{All: true})
if err != nil {
return 0, 0, err
}
runningContainers, err := c.cli.ContainerList(ctx, types.ContainerListOptions{
Filters: filters.NewArgs(filters.Arg("status", "running")),
})
if err != nil {
return 0, 0, err
}
return len(allContainers), len(runningContainers), nil
}
// Close closes the Docker client
func (c *Client) Close() error {
return c.cli.Close()
}
// ───── container:create / container:exec / image:build / network:create
// ContainerCreateSpec is the curated input shape for CreateContainer.
// We deliberately don't expose raw HostConfig — the panel sends the
// fields a one-click template needs and we map them. Privileged /
// CapAdd / Devices are intentionally absent in v1; if a user needs
// them they should run `docker create` directly.
type ContainerCreateSpec struct {
Image string `json:"image"`
Name string `json:"name,omitempty"`
Command []string `json:"command,omitempty"`
Env []string `json:"env,omitempty"` // KEY=VAL
Labels map[string]string `json:"labels,omitempty"`
RestartPolicy string `json:"restart_policy,omitempty"` // no, on-failure, always, unless-stopped
Network string `json:"network,omitempty"`
Ports []ContainerPort `json:"ports,omitempty"`
Volumes []ContainerMount `json:"volumes,omitempty"`
WorkingDir string `json:"working_dir,omitempty"`
}
type ContainerPort struct {
Host uint16 `json:"host"`
Container uint16 `json:"container"`
Proto string `json:"proto,omitempty"` // "tcp" (default) or "udp"
}
type ContainerMount struct {
Source string `json:"src"`
Target string `json:"dst"`
Mode string `json:"mode,omitempty"` // "ro" or "" for rw
}
// CreateContainer wires a curated spec to the Docker SDK's
// ContainerCreate. Returns the new container ID.
func (c *Client) CreateContainer(ctx context.Context, spec ContainerCreateSpec) (string, error) {
if strings.TrimSpace(spec.Image) == "" {
return "", fmt.Errorf("image is required")
}
exposed := nat.PortSet{}
bindings := nat.PortMap{}
for _, p := range spec.Ports {
proto := p.Proto
if proto == "" {
proto = "tcp"
}
port, err := nat.NewPort(proto, fmt.Sprintf("%d", p.Container))
if err != nil {
return "", fmt.Errorf("invalid port %d/%s: %w", p.Container, proto, err)
}
exposed[port] = struct{}{}
bindings[port] = []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: fmt.Sprintf("%d", p.Host)}}
}
binds := make([]string, 0, len(spec.Volumes))
for _, v := range spec.Volumes {
bind := v.Source + ":" + v.Target
if v.Mode != "" {
bind += ":" + v.Mode
}
binds = append(binds, bind)
}
cfg := &containertypes.Config{
Image: spec.Image,
Cmd: spec.Command,
Env: spec.Env,
Labels: spec.Labels,
WorkingDir: spec.WorkingDir,
ExposedPorts: exposed,
}
hostCfg := &containertypes.HostConfig{
PortBindings: bindings,
Binds: binds,
RestartPolicy: containertypes.RestartPolicy{
Name: spec.RestartPolicy,
},
}
var netCfg *networktypes.NetworkingConfig
if spec.Network != "" {
netCfg = &networktypes.NetworkingConfig{
EndpointsConfig: map[string]*networktypes.EndpointSettings{
spec.Network: {},
},
}
}
resp, err := c.cli.ContainerCreate(ctx, cfg, hostCfg, netCfg, nil, spec.Name)
if err != nil {
return "", err
}
return resp.ID, nil
}
// ContainerExecResult is the bounded result of an exec call.
type ContainerExecResult struct {
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exit_code"`
}
// ExecContainer runs a command inside a running container, captures
// stdout/stderr (capped to outputCap bytes), and returns the exit code.
// User and WorkingDir are optional; pass an empty string to inherit.
func (c *Client) ExecContainer(ctx context.Context, id string, cmd []string, user, workingDir string, outputCap int) (ContainerExecResult, error) {
if outputCap <= 0 {
outputCap = 1024 * 1024 // 1MB default
}
createResp, err := c.cli.ContainerExecCreate(ctx, id, types.ExecConfig{
Cmd: cmd,
AttachStdout: true,
AttachStderr: true,
User: user,
WorkingDir: workingDir,
})
if err != nil {
return ContainerExecResult{}, err
}
hijack, err := c.cli.ContainerExecAttach(ctx, createResp.ID, types.ExecStartCheck{})
if err != nil {
return ContainerExecResult{}, err
}
defer hijack.Close()
// Read multiplexed stream: docker frames stdout/stderr together
// with an 8-byte header. stdcopy.StdCopy demultiplexes, but to
// avoid an extra import we use a bounded ReadAll and ship the raw
// stream — the panel only needs the combined output anyway.
buf := &boundedBuffer{cap: outputCap}
_, _ = io.Copy(buf, hijack.Reader)
insp, err := c.cli.ContainerExecInspect(ctx, createResp.ID)
if err != nil {
return ContainerExecResult{}, err
}
return ContainerExecResult{
Stdout: buf.String(),
ExitCode: insp.ExitCode,
}, nil
}
// boundedBuffer is a tiny io.Writer that drops bytes past a cap. Used
// instead of bytes.Buffer to keep exec output from blowing memory on a
// chatty command like `find /`.
type boundedBuffer struct {
cap int
data []byte
}
func (b *boundedBuffer) Write(p []byte) (int, error) {
remaining := b.cap - len(b.data)
if remaining <= 0 {
return len(p), nil
}
if len(p) > remaining {
b.data = append(b.data, p[:remaining]...)
return len(p), nil
}
b.data = append(b.data, p...)
return len(p), nil
}
func (b *boundedBuffer) String() string { return string(b.data) }
// ImageBuildSpec carries the curated input for BuildImage.
type ImageBuildSpec struct {
ContextPath string `json:"context_path"`
Dockerfile string `json:"dockerfile,omitempty"`
Tag string `json:"tag"`
BuildArgs map[string]string `json:"build_args,omitempty"`
NoCache bool `json:"no_cache,omitempty"`
Pull bool `json:"pull,omitempty"`
}
// BuildImageCmd builds the *exec.Cmd for a `docker build` invocation
// based on spec. The caller streams stdout/stderr (we use the same
// jobs streaming helper as packages). Returning a Cmd rather than a
// reader keeps this file from importing the docker SDK's archive
// package, which pulls in containerd/moby deps that aren't in our
// go.mod.
func (c *Client) BuildImageCmd(ctx context.Context, spec ImageBuildSpec) (*exec.Cmd, error) {
if strings.TrimSpace(spec.ContextPath) == "" {
return nil, fmt.Errorf("context_path is required")
}
if strings.TrimSpace(spec.Tag) == "" {
return nil, fmt.Errorf("tag is required")
}
args := []string{"build", "-t", spec.Tag}
if spec.Dockerfile != "" {
args = append(args, "-f", spec.Dockerfile)
}
for k, v := range spec.BuildArgs {
args = append(args, "--build-arg", k+"="+v)
}
if spec.NoCache {
args = append(args, "--no-cache")
}
if spec.Pull {
args = append(args, "--pull")
}
args = append(args, spec.ContextPath)
return exec.CommandContext(ctx, "docker", args...), nil
}
// NetworkCreateSpec carries curated input for CreateNetwork.
type NetworkCreateSpec struct {
Name string `json:"name"`
Driver string `json:"driver,omitempty"` // bridge (default), overlay, host
Internal bool `json:"internal,omitempty"`
Attachable bool `json:"attachable,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
// CreateNetwork creates a Docker network and returns its ID.
func (c *Client) CreateNetwork(ctx context.Context, spec NetworkCreateSpec) (string, error) {
if strings.TrimSpace(spec.Name) == "" {
return "", fmt.Errorf("name is required")
}
driver := spec.Driver
if driver == "" {
driver = "bridge"
}
resp, err := c.cli.NetworkCreate(ctx, spec.Name, types.NetworkCreate{
Driver: driver,
Internal: spec.Internal,
Attachable: spec.Attachable,
Labels: spec.Labels,
})
if err != nil {
return "", err
}
return resp.ID, nil
}
// calculateCPUPercent calculates CPU usage percentage
func calculateCPUPercent(stats *types.StatsJSON) float64 {
cpuDelta := float64(stats.CPUStats.CPUUsage.TotalUsage - stats.PreCPUStats.CPUUsage.TotalUsage)
systemDelta := float64(stats.CPUStats.SystemUsage - stats.PreCPUStats.SystemUsage)
if systemDelta > 0 && cpuDelta > 0 {
cpuPercent := (cpuDelta / systemDelta) * float64(stats.CPUStats.OnlineCPUs) * 100.0
return cpuPercent
}
return 0.0
}
// ==================== Docker Compose ====================
// ComposeProject represents a Docker Compose project
type ComposeProject struct {
Name string `json:"name"`
Status string `json:"status"`
ConfigFiles string `json:"config_files"`
}
// ComposeContainer represents a container in a compose project
type ComposeContainer struct {
ID string `json:"id"`
Name string `json:"name"`
Service string `json:"service"`
State string `json:"state"`
Status string `json:"status"`
Ports string `json:"ports"`
}
// ComposeList lists all compose projects
func (c *Client) ComposeList(ctx context.Context) ([]ComposeProject, error) {
cmd := exec.CommandContext(ctx, "docker", "compose", "ls", "--format", "json")
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to list compose projects: %w", err)
}
var projects []ComposeProject
if err := json.Unmarshal(output, &projects); err != nil {
return nil, fmt.Errorf("failed to parse compose projects: %w", err)
}
return projects, nil
}
// ComposePsProject lists containers for a specific compose project
func (c *Client) ComposePsProject(ctx context.Context, projectPath string) ([]ComposeContainer, error) {
if err := validateProjectPath(projectPath); err != nil {
return nil, err
}
cmd := exec.CommandContext(ctx, "docker", "compose", "-f", projectPath, "ps", "--format", "json", "-a")
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to list compose containers: %w", err)
}
// Handle empty output
if len(output) == 0 {
return []ComposeContainer{}, nil
}
// Docker compose ps can return either a JSON array or line-delimited JSON objects
var containers []ComposeContainer
// Try parsing as JSON array first
if err := json.Unmarshal(output, &containers); err != nil {
// Try line-by-line JSON parsing
lines := strings.Split(string(output), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var container ComposeContainer
if err := json.Unmarshal([]byte(line), &container); err != nil {
continue
}
containers = append(containers, container)
}
}
return containers, nil
}
// ComposeUp starts a compose project
func (c *Client) ComposeUp(ctx context.Context, projectPath string, detach, build bool) (string, error) {
if err := validateProjectPath(projectPath); err != nil {
return "", err
}
args := []string{"compose", "-f", projectPath, "up"}
if detach {
args = append(args, "-d")
}
if build {
args = append(args, "--build")
}
cmd := exec.CommandContext(ctx, "docker", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return string(output), fmt.Errorf("compose up failed: %w: %s", err, output)
}
return string(output), nil
}
// ComposeDown stops a compose project
func (c *Client) ComposeDown(ctx context.Context, projectPath string, volumes, removeOrphans bool) (string, error) {
if err := validateProjectPath(projectPath); err != nil {
return "", err
}
args := []string{"compose", "-f", projectPath, "down"}
if volumes {
args = append(args, "-v")
}
if removeOrphans {
args = append(args, "--remove-orphans")
}
cmd := exec.CommandContext(ctx, "docker", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return string(output), fmt.Errorf("compose down failed: %w: %s", err, output)
}
return string(output), nil
}
// ComposeLogs gets logs from a compose project
func (c *Client) ComposeLogs(ctx context.Context, projectPath, service string, tail int) (string, error) {
if err := validateProjectPath(projectPath); err != nil {
return "", err
}
args := []string{"compose", "-f", projectPath, "logs", "--no-color"}
if tail > 0 {
args = append(args, "--tail", fmt.Sprintf("%d", tail))
}
if service != "" {
args = append(args, service)
}
cmd := exec.CommandContext(ctx, "docker", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return string(output), fmt.Errorf("compose logs failed: %w: %s", err, output)
}
return string(output), nil
}
// ComposeRestart restarts a compose project or specific service
func (c *Client) ComposeRestart(ctx context.Context, projectPath, service string) (string, error) {
if err := validateProjectPath(projectPath); err != nil {
return "", err
}
args := []string{"compose", "-f", projectPath, "restart"}
if service != "" {
args = append(args, service)
}
cmd := exec.CommandContext(ctx, "docker", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return string(output), fmt.Errorf("compose restart failed: %w: %s", err, output)
}
return string(output), nil
}
// ComposePull pulls images for a compose project
func (c *Client) ComposePull(ctx context.Context, projectPath, service string) (string, error) {
if err := validateProjectPath(projectPath); err != nil {
return "", err
}
args := []string{"compose", "-f", projectPath, "pull"}
if service != "" {
args = append(args, service)
}
cmd := exec.CommandContext(ctx, "docker", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return string(output), fmt.Errorf("compose pull failed: %w: %s", err, output)
}
return string(output), nil
}
// validateProjectPath validates the project path to prevent path traversal attacks
func validateProjectPath(projectPath string) error {
if projectPath == "" {
return fmt.Errorf("project path is required")
}
// Check for path traversal attempts
if strings.Contains(projectPath, "..") {
return fmt.Errorf("invalid project path: path traversal not allowed")
}
// Check if path is absolute
if !strings.HasPrefix(projectPath, "/") {
return fmt.Errorf("invalid project path: must be absolute path")
}
return nil
}