Files
Containr/app/backend/internal/docker/client.go
T
Tomas DvorakandDevin 7839dc48a3 feat(deploy): per-service deploy queue, cancellable deployments, no-cache builds
- internal/deployqueue: serialized FIFO per service — concurrent deploys
  of one service no longer race (the container-name-conflict failure
  mode); different services still run in parallel.
- Deploys and rollbacks enqueue through the queue; a busy service
  returns status 'queued' and promotes when the active job finishes.
- POST /deployments/:id/cancel — queued jobs drop immediately
  (200 cancelled); active jobs get ctx cancelled so build/reconcile
  abort (202 cancelling, settles 'cancelled'); finished deployments
  return 409 NOT_CANCELLABLE.
- Redeploy and variables save now route through the same queue so they
  can't overlap a build; PUT /services/:id/variables accepts
  redeploy:true to apply env changes to running containers.
- no_cache on deployment create plumbs BuildRequest -> BuildOptions for
  nixpacks, dockerfile and railpack builders.
- CLI: deployments cancel, deploy --no-cache, variables set/unset
  --redeploy. MCP: containr_deployments_cancel; deploy/variables_set
  document no_cache/redeploy. UI: Cancel button + queued/cancelled
  badges on the deployments table. OpenAPI + types regenerated.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 16:23:28 +02:00

518 lines
15 KiB
Go

package docker
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"sort"
"strings"
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/events"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/registry"
"github.com/docker/docker/api/types/system"
"github.com/docker/docker/api/types/volume"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
)
// Client wraps the Docker client with additional functionality
type Client struct {
cli *client.Client
probeHost string
}
// NewClient creates a new Docker client
func NewClient() (*Client, error) {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return nil, fmt.Errorf("failed to create Docker client: %w", err)
}
// Test connection
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err = cli.Ping(ctx)
if err != nil {
return nil, fmt.Errorf("failed to connect to Docker daemon: %w", err)
}
return &Client{cli: cli}, nil
}
// HostProbeHost returns an address through which host-published container
// ports are reachable. When this process runs inside a container, host
// loopback is unreachable; the bridge gateway IP routes to ports published
// on 0.0.0.0. Result is cached for the process lifetime.
func (c *Client) HostProbeHost(ctx context.Context) string {
if c.probeHost != "" {
return c.probeHost
}
host := "127.0.0.1"
if self, err := c.self(ctx); err == nil && self.NetworkSettings != nil {
for _, n := range self.NetworkSettings.Networks {
if n != nil && n.Gateway != "" {
host = n.Gateway
break
}
}
}
c.probeHost = host
return host
}
// OwnNetworkName returns the name of the platform network this process's
// container is attached to, or "" when not running containerized. Per-project
// and default Docker networks are never selected; among remaining networks
// the lexicographically first wins so the choice is deterministic.
func (c *Client) OwnNetworkName(ctx context.Context) string {
self, err := c.self(ctx)
if err != nil || self.NetworkSettings == nil {
return ""
}
names := make([]string, 0, len(self.NetworkSettings.Networks))
for name := range self.NetworkSettings.Networks {
switch {
case name == "bridge" || name == "host" || name == "none":
case strings.HasPrefix(name, "containr-proj-"):
default:
names = append(names, name)
}
}
sort.Strings(names)
if len(names) == 0 {
return ""
}
return names[0]
}
// self inspects the container this process runs in (hostname == container ID).
func (c *Client) self(ctx context.Context) (types.ContainerJSON, error) {
hostname, err := os.Hostname()
if err != nil || hostname == "" {
return types.ContainerJSON{}, fmt.Errorf("hostname unavailable")
}
return c.cli.ContainerInspect(ctx, hostname)
}
// ConnectSelfToNetwork attaches this process's container to the given network.
// No-op when already attached or when not running containerized.
func (c *Client) ConnectSelfToNetwork(ctx context.Context, networkName string) error {
hostname, err := os.Hostname()
if err != nil || hostname == "" {
return fmt.Errorf("hostname unavailable")
}
err = c.cli.NetworkConnect(ctx, networkName, hostname, &network.EndpointSettings{})
if err != nil && strings.Contains(strings.ToLower(err.Error()), "already exists") {
return nil
}
return err
}
// ListContainers returns all containers
func (c *Client) ListContainers(ctx context.Context, all bool) ([]types.Container, error) {
return c.cli.ContainerList(ctx, container.ListOptions{
All: all,
})
}
// ListContainersFiltered returns containers matching the given filters.
func (c *Client) ListContainersFiltered(ctx context.Context, f filters.Args, all bool) ([]types.Container, error) {
return c.cli.ContainerList(ctx, container.ListOptions{
All: all,
Filters: f,
})
}
// GetContainer returns detailed information about a specific container
func (c *Client) GetContainer(ctx context.Context, containerID string) (types.ContainerJSON, error) {
return c.cli.ContainerInspect(ctx, containerID)
}
// CreateContainer creates a new container
func (c *Client) CreateContainer(ctx context.Context, config ContainerConfig) (string, error) {
containerConfig := &container.Config{
Image: config.Image,
Cmd: config.Cmd,
Env: config.Env,
Labels: config.Labels,
ExposedPorts: config.ExposedPorts,
Healthcheck: config.Healthcheck,
}
hostConfig := &container.HostConfig{
RestartPolicy: container.RestartPolicy{
Name: container.RestartPolicyMode(config.RestartPolicy),
},
PortBindings: config.PortBindings,
Mounts: config.Mounts,
Resources: container.Resources{
Memory: config.Memory,
NanoCPUs: config.NanoCPUs,
},
NetworkMode: container.NetworkMode(config.NetworkMode),
}
networkingConfig := &network.NetworkingConfig{
EndpointsConfig: config.Networks,
}
resp, err := c.cli.ContainerCreate(
ctx,
containerConfig,
hostConfig,
networkingConfig,
nil,
config.Name,
)
if err != nil {
return "", fmt.Errorf("failed to create container: %w", err)
}
return resp.ID, nil
}
// StartContainer starts a container
func (c *Client) StartContainer(ctx context.Context, containerID string) error {
return c.cli.ContainerStart(ctx, containerID, container.StartOptions{})
}
// StopContainer stops a container
func (c *Client) StopContainer(ctx context.Context, containerID string, timeout *time.Duration) error {
var timeoutInt *int
if timeout != nil {
t := int(timeout.Seconds())
timeoutInt = &t
}
return c.cli.ContainerStop(ctx, containerID, container.StopOptions{
Timeout: timeoutInt,
})
}
// RestartContainer restarts a container
func (c *Client) RestartContainer(ctx context.Context, containerID string, timeout *time.Duration) error {
var timeoutInt *int
if timeout != nil {
t := int(timeout.Seconds())
timeoutInt = &t
}
return c.cli.ContainerRestart(ctx, containerID, container.StopOptions{
Timeout: timeoutInt,
})
}
// RemoveContainer removes a container
func (c *Client) RemoveContainer(ctx context.Context, containerID string, force bool) error {
return c.cli.ContainerRemove(ctx, containerID, container.RemoveOptions{
Force: force,
})
}
// GetContainerLogs returns logs for a container
func (c *Client) GetContainerLogs(ctx context.Context, containerID string, options LogOptions) (io.ReadCloser, error) {
return c.cli.ContainerLogs(ctx, containerID, container.LogsOptions{
ShowStdout: options.Stdout,
ShowStderr: options.Stderr,
Follow: options.Follow,
Tail: options.Tail,
Timestamps: options.Timestamps,
})
}
// CopyFromContainer streams a tar archive of srcPath out of a container.
// The container only needs to exist — it does not have to be running.
func (c *Client) CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error) {
reader, _, err := c.cli.CopyFromContainer(ctx, containerID, srcPath)
return reader, err
}
// GetContainerStats returns real-time resource usage statistics for a container
func (c *Client) GetContainerStats(ctx context.Context, containerID string, stream bool) (*container.StatsResponseReader, error) {
resp, err := c.cli.ContainerStats(ctx, containerID, stream)
if err != nil {
return nil, err
}
return &resp, nil
}
// ListImages returns all images
func (c *Client) ListImages(ctx context.Context, all bool) ([]image.Summary, error) {
return c.cli.ImageList(ctx, image.ListOptions{
All: all,
})
}
// PullImage pulls an image from a registry. The pull runs as the returned
// stream is consumed — callers must drain and close it (or use
// PullImageWait) or the pull may be aborted.
func (c *Client) PullImage(ctx context.Context, ref string, auth registry.AuthConfig) (io.ReadCloser, error) {
authStr, _ := registry.EncodeAuthConfig(auth)
return c.cli.ImagePull(ctx, ref, image.PullOptions{
RegistryAuth: authStr,
})
}
// PullImageWait pulls an image and blocks until the pull stream completes.
func (c *Client) PullImageWait(ctx context.Context, ref string, auth registry.AuthConfig) error {
reader, err := c.PullImage(ctx, ref, auth)
if err != nil {
return err
}
defer reader.Close()
// The daemon performs the pull while streaming progress; drain it fully
// and surface any errorDetail the daemon reports in the stream.
dec := json.NewDecoder(reader)
var msg struct {
ErrorDetail *struct {
Message string `json:"message"`
} `json:"errorDetail"`
Error string `json:"error"`
}
for dec.Decode(&msg) == nil {
if msg.ErrorDetail != nil {
return fmt.Errorf("%s", msg.ErrorDetail.Message)
}
if msg.Error != "" {
return fmt.Errorf("%s", msg.Error)
}
}
return nil
}
// BuildImage builds an image from a Dockerfile
func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options BuildOptions) (types.ImageBuildResponse, error) {
return c.cli.ImageBuild(ctx, buildContext, types.ImageBuildOptions{
Dockerfile: options.Dockerfile,
Tags: options.Tags,
BuildArgs: options.BuildArgs,
Labels: options.Labels,
Remove: options.Remove,
NoCache: options.NoCache,
})
}
// RemoveImage removes an image
func (c *Client) RemoveImage(ctx context.Context, imageID string, force bool) ([]image.DeleteResponse, error) {
return c.cli.ImageRemove(ctx, imageID, image.RemoveOptions{
Force: force,
})
}
// TagImage tags an image
func (c *Client) TagImage(ctx context.Context, imageID, ref string) error {
return c.cli.ImageTag(ctx, imageID, ref)
}
// ListNetworks returns all networks
func (c *Client) ListNetworks(ctx context.Context) ([]network.Summary, error) {
return c.cli.NetworkList(ctx, network.ListOptions{})
}
// CreateNetwork creates a new network
func (c *Client) CreateNetwork(ctx context.Context, config NetworkConfig) (string, error) {
resp, err := c.cli.NetworkCreate(ctx, config.Name, network.CreateOptions{
Driver: config.Driver,
Internal: config.Internal,
Labels: config.Labels,
})
if err != nil {
return "", err
}
return resp.ID, nil
}
// RemoveNetwork removes a network
func (c *Client) RemoveNetwork(ctx context.Context, networkID string) error {
return c.cli.NetworkRemove(ctx, networkID)
}
// ConnectNetwork connects a container to a network
func (c *Client) ConnectNetwork(ctx context.Context, networkID, containerID string, config network.EndpointSettings) error {
return c.cli.NetworkConnect(ctx, networkID, containerID, &config)
}
// DisconnectNetwork disconnects a container from a network
func (c *Client) DisconnectNetwork(ctx context.Context, networkID, containerID string, force bool) error {
return c.cli.NetworkDisconnect(ctx, networkID, containerID, force)
}
// ListVolumes returns all volumes
func (c *Client) ListVolumes(ctx context.Context) (volume.ListResponse, error) {
return c.cli.VolumeList(ctx, volume.ListOptions{})
}
// CreateVolume creates a new volume
func (c *Client) CreateVolume(ctx context.Context, config VolumeConfig) (volume.Volume, error) {
return c.cli.VolumeCreate(ctx, volume.CreateOptions{
Name: config.Name,
Driver: config.Driver,
Labels: config.Labels,
DriverOpts: config.DriverOpts,
})
}
// RemoveVolume removes a volume
func (c *Client) RemoveVolume(ctx context.Context, volumeID string, force bool) error {
return c.cli.VolumeRemove(ctx, volumeID, force)
}
// GetSystemInfo returns system-wide information
func (c *Client) GetSystemInfo(ctx context.Context) (system.Info, error) {
return c.cli.Info(ctx)
}
// GetDiskUsage returns Docker disk usage information
func (c *Client) GetDiskUsage(ctx context.Context) (types.DiskUsage, error) {
return c.cli.DiskUsage(ctx, types.DiskUsageOptions{})
}
// GetEvents returns Docker events
func (c *Client) GetEvents(ctx context.Context, options EventOptions) (io.ReadCloser, error) {
resp, errChan := c.cli.Events(ctx, events.ListOptions{
Since: options.Since,
Until: options.Until,
Filters: options.Filters,
})
// Convert the channel to a reader
r, w := io.Pipe()
go func() {
defer w.Close()
for {
select {
case event, ok := <-resp:
if !ok {
return
}
// Write event data to pipe
w.Write([]byte(fmt.Sprintf("%v\n", event)))
case err := <-errChan:
if err != nil {
w.CloseWithError(err)
return
}
case <-ctx.Done():
return
}
}
}()
return r, nil
}
// ExecCreate creates an exec instance in a container
func (c *Client) ExecCreate(ctx context.Context, containerID string, config ExecConfig) (types.IDResponse, error) {
return c.cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{
Cmd: config.Cmd,
Env: config.Env,
WorkingDir: config.WorkingDir,
User: config.User,
AttachStdin: config.AttachStdin,
AttachStdout: config.AttachStdout,
AttachStderr: config.AttachStderr,
Tty: config.Tty,
})
}
// ExecStart starts an exec instance
func (c *Client) ExecStart(ctx context.Context, execID string, config ExecStartConfig) error {
return c.cli.ContainerExecStart(ctx, execID, container.ExecStartOptions{
Detach: config.Detach,
Tty: config.Tty,
})
}
// ExecInspect returns information about an exec instance
func (c *Client) ExecInspect(ctx context.Context, execID string) (container.ExecInspect, error) {
return c.cli.ContainerExecInspect(ctx, execID)
}
// ExecRun runs a command in a container and returns its combined
// stdout/stderr output and exit code.
func (c *Client) ExecRun(ctx context.Context, containerID string, cmd []string) (string, int, error) {
exec, err := c.cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{
Cmd: cmd,
AttachStdout: true,
AttachStderr: true,
})
if err != nil {
return "", -1, err
}
resp, err := c.cli.ContainerExecAttach(ctx, exec.ID, container.ExecAttachOptions{})
if err != nil {
return "", -1, err
}
defer resp.Close()
var buf bytes.Buffer
if _, err := stdcopy.StdCopy(&buf, &buf, resp.Reader); err != nil {
return "", -1, err
}
inspect, err := c.cli.ContainerExecInspect(ctx, exec.ID)
if err != nil {
return buf.String(), -1, err
}
return buf.String(), inspect.ExitCode, nil
}
// GetImageInfo returns information about a Docker image
func (c *Client) GetImageInfo(ctx context.Context, imageName string) (*ImageInfo, error) {
images, err := c.cli.ImageList(ctx, image.ListOptions{})
if err != nil {
return nil, err
}
for _, img := range images {
for _, tag := range img.RepoTags {
if tag == imageName || tag == imageName+":latest" {
return &ImageInfo{
ID: img.ID,
RepoTags: img.RepoTags,
Size: img.Size,
Created: img.Created,
Labels: img.Labels,
RepoDigests: img.RepoDigests,
Digest: getDigestFromRepoTags(img.RepoDigests),
}, nil
}
}
}
return nil, fmt.Errorf("image not found: %s", imageName)
}
// PushImage pushes an image to a registry
func (c *Client) PushImage(ctx context.Context, imageName, registryURL string) error {
auth := registry.AuthConfig{}
authStr, _ := registry.EncodeAuthConfig(auth)
_, err := c.cli.ImagePush(ctx, imageName, image.PushOptions{
RegistryAuth: authStr,
})
return err
}
// Close closes the Docker client connection
func (c *Client) Close() error {
return c.cli.Close()
}
// Helper function to extract digest from repo digests
func getDigestFromRepoTags(digests []string) string {
if len(digests) > 0 {
return digests[0]
}
return ""
}