// Package docker implements the container runtime adapter using Docker API. package docker import ( "archive/tar" "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" "io" "maps" "math" "os" "path/filepath" "sort" "strconv" "strings" "sync" "time" "github.com/bnema/zerowrap" cerrdefs "github.com/containerd/errdefs" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/registry" "github.com/docker/docker/api/types/strslice" "github.com/docker/docker/api/types/volume" "github.com/docker/docker/client" "github.com/docker/docker/pkg/stdcopy" "github.com/docker/go-connections/nat" "github.com/docker/go-units" "github.com/bnema/gordon/internal/boundaries/out" "github.com/bnema/gordon/internal/domain" runtimepkg "github.com/bnema/gordon/pkg/runtime" ) // Runtime implements the ContainerRuntime interface using Docker API. type Runtime struct { client *client.Client } var _ out.ContainerRuntime = (*Runtime)(nil) var _ out.VolumeArchiveExporter = (*Runtime)(nil) type pipeReadCloser struct { pr *io.PipeReader pw *io.PipeWriter original io.Closer cleanup func() error once sync.Once } type removeOnCloseFile struct { *os.File path string } func (p *pipeReadCloser) Read(b []byte) (int, error) { return p.pr.Read(b) } func (p *pipeReadCloser) Close() error { var closeErr error p.once.Do(func() { if err := p.pw.CloseWithError(errors.New("reader closed")); err != nil && !errors.Is(err, io.ErrClosedPipe) { closeErr = err } if err := p.pr.Close(); err != nil && closeErr == nil { closeErr = err } if p.original != nil { if err := p.original.Close(); err != nil && closeErr == nil { closeErr = err } } if p.cleanup != nil { if err := p.cleanup(); err != nil && closeErr == nil { closeErr = err } } }) return closeErr } func (f *removeOnCloseFile) Close() error { closeErr := f.File.Close() removeErr := os.Remove(f.path) if closeErr != nil { return closeErr } return removeErr } // NewRuntime creates a new Docker runtime instance. func NewRuntime() (*Runtime, error) { cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) if err != nil { return nil, fmt.Errorf("failed to create Docker client: %w", err) } return &Runtime{ client: cli, }, nil } // NewRuntimeWithSocket creates a Docker runtime targeting a specific socket path. func NewRuntimeWithSocket(socketPath string) (*Runtime, error) { host := "unix://" + socketPath cli, err := client.NewClientWithOpts( client.WithHost(host), client.WithAPIVersionNegotiation(), ) if err != nil { return nil, fmt.Errorf("failed to create Docker client for %s: %w", socketPath, err) } return &Runtime{client: cli}, nil } // NewRuntimeWithClient creates a new Docker runtime instance with a custom client (for testing). func NewRuntimeWithClient(cli *client.Client) *Runtime { return &Runtime{ client: cli, } } // CreateContainer creates a new container. func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerConfig) (*domain.Container, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "CreateContainer", "container_name": config.Name, "image": config.Image, }) log := zerowrap.FromCtx(ctx) exposedPorts, portBindings, err := buildPortBindings(config) if err != nil { return nil, err } binds := buildVolumeBinds(config, log) // Create container configuration containerConfig := &container.Config{ Image: config.Image, Hostname: config.Hostname, Env: config.Env, ExposedPorts: exposedPorts, WorkingDir: config.WorkingDir, Cmd: config.Cmd, Labels: config.Labels, User: config.User, } resources := container.Resources{ Memory: config.MemoryLimit, NanoCPUs: config.NanoCPUs, Ulimits: []*units.Ulimit{ {Name: "nofile", Soft: 65536, Hard: 65536}, }, } if config.PidsLimit > 0 { resources.PidsLimit = &config.PidsLimit } capDrop := strslice.StrSlice{"ALL"} capAdd := strslice.StrSlice{"CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID", "NET_BIND_SERVICE"} if config.CapDrop != nil { capDrop = strslice.StrSlice(config.CapDrop) } if config.CapAdd != nil { capAdd = strslice.StrSlice(config.CapAdd) } hostConfig := &container.HostConfig{ PortBindings: portBindings, AutoRemove: config.AutoRemove, Binds: binds, NetworkMode: container.NetworkMode(config.NetworkMode), Resources: resources, SecurityOpt: []string{"no-new-privileges:true"}, CapDrop: capDrop, CapAdd: capAdd, ReadonlyRootfs: config.ReadOnlyRootFS, } if config.RestartPolicy != "" { hostConfig.RestartPolicy = container.RestartPolicy{Name: container.RestartPolicyMode(config.RestartPolicy)} } // Create network configuration for container var networkConfig *network.NetworkingConfig if config.NetworkMode != "" && config.NetworkMode != "default" && config.NetworkMode != "bridge" { networkConfig = &network.NetworkingConfig{ EndpointsConfig: map[string]*network.EndpointSettings{ config.NetworkMode: { Aliases: config.Aliases, }, }, } if config.Hostname != "" { networkConfig.EndpointsConfig[config.NetworkMode].NetworkID = config.NetworkMode } } // Create the container resp, err := r.client.ContainerCreate(ctx, containerConfig, hostConfig, networkConfig, nil, config.Name) if err != nil { return nil, log.WrapErr(err, "failed to create container") } log.Info().Str(zerowrap.FieldEntityID, resp.ID).Msg("container created") // Inspect the created container to get full details return r.InspectContainer(ctx, resp.ID) } func buildPortBindings(config *domain.ContainerConfig) (nat.PortSet, nat.PortMap, error) { exposedPorts := make(nat.PortSet) portBindings := make(nat.PortMap) addLegacyPortBindings(config.Ports, exposedPorts, portBindings) if err := addExplicitPortBindings(config.PortPublishes, exposedPorts, portBindings); err != nil { return nil, nil, err } return exposedPorts, portBindings, nil } func addLegacyPortBindings(ports []int, exposedPorts nat.PortSet, portBindings nat.PortMap) { for _, port := range ports { containerPort := nat.Port(fmt.Sprintf("%d/tcp", port)) exposedPorts[containerPort] = struct{}{} // Bind to random available port on localhost only. // SECURITY: Using 127.0.0.1 prevents direct access from the network, // forcing all traffic through Gordon's reverse proxy where auth, // rate limiting, and security headers are applied. portBindings[containerPort] = []nat.PortBinding{{HostIP: "127.0.0.1", HostPort: "0"}} } } func addExplicitPortBindings(publishes []domain.ContainerPortPublish, exposedPorts nat.PortSet, portBindings nat.PortMap) error { for _, publish := range publishes { if publish.Protocol != domain.NetworkProtocolTCP && publish.Protocol != domain.NetworkProtocolUDP { return fmt.Errorf("unsupported container port publish protocol %q", publish.Protocol) } containerPort := nat.Port(fmt.Sprintf("%d/%s", publish.ContainerPort, publish.Protocol)) exposedPorts[containerPort] = struct{}{} portBindings[containerPort] = append(portBindings[containerPort], nat.PortBinding{ HostIP: publish.HostIP, HostPort: strconv.Itoa(publish.HostPort), }) } return nil } func buildVolumeBinds(config *domain.ContainerConfig, log zerowrap.Logger) []string { binds := make([]string, 0, len(config.Volumes)+len(config.ReadOnlyVolumes)) for containerPath, volumeName := range config.Volumes { bind := fmt.Sprintf("%s:%s", volumeName, containerPath) binds = append(binds, bind) log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding volume mount") } for containerPath, volumeName := range config.ReadOnlyVolumes { bind := fmt.Sprintf("%s:%s:ro", volumeName, containerPath) binds = append(binds, bind) log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding read-only volume mount") } return binds } // StartContainer starts a container. func (r *Runtime) StartContainer(ctx context.Context, containerID string) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "StartContainer", zerowrap.FieldEntityID: containerID, }) log := zerowrap.FromCtx(ctx) err := r.client.ContainerStart(ctx, containerID, container.StartOptions{}) if err != nil { return log.WrapErr(err, "failed to start container") } log.Info().Msg("container started") return nil } // WaitForContainer waits for a container to stop running. func (r *Runtime) WaitForContainer(ctx context.Context, containerID string) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "WaitForContainer", zerowrap.FieldEntityID: containerID, }) log := zerowrap.FromCtx(ctx) statusCh, errCh := r.client.ContainerWait(ctx, containerID, container.WaitConditionNotRunning) select { case err := <-errCh: if err != nil { return log.WrapErr(err, "failed to wait for container") } case <-statusCh: } log.Debug().Msg("container wait completed") return nil } // StopContainer stops a container. func (r *Runtime) StopContainer(ctx context.Context, containerID string) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "StopContainer", zerowrap.FieldEntityID: containerID, }) log := zerowrap.FromCtx(ctx) timeout := 20 // 20 seconds before SIGKILL err := r.client.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout}) if err != nil { return log.WrapErr(err, "failed to stop container") } log.Info().Msg("container stopped") return nil } // RestartContainer restarts a container. func (r *Runtime) RestartContainer(ctx context.Context, containerID string) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "RestartContainer", zerowrap.FieldEntityID: containerID, }) log := zerowrap.FromCtx(ctx) timeout := 20 // 20 seconds before SIGKILL err := r.client.ContainerRestart(ctx, containerID, container.StopOptions{Timeout: &timeout}) if err != nil { return log.WrapErr(err, "failed to restart container") } log.Info().Msg("container restarted") return nil } // RemoveContainer removes a container. func (r *Runtime) RemoveContainer(ctx context.Context, containerID string, force bool) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "RemoveContainer", zerowrap.FieldEntityID: containerID, "force": force, }) log := zerowrap.FromCtx(ctx) err := r.client.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: force}) if err != nil { return log.WrapErr(err, "failed to remove container") } log.Info().Msg("container removed") return nil } // RenameContainer renames a container. func (r *Runtime) RenameContainer(ctx context.Context, containerID, newName string) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "RenameContainer", zerowrap.FieldEntityID: containerID, "new_name": newName, }) log := zerowrap.FromCtx(ctx) err := r.client.ContainerRename(ctx, containerID, newName) if err != nil { return log.WrapErr(err, "failed to rename container") } log.Info().Msg("container renamed") return nil } // ListContainers lists containers. func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Container, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "ListContainers", "all": all, }) log := zerowrap.FromCtx(ctx) containers, err := r.client.ContainerList(ctx, container.ListOptions{All: all}) if err != nil { return nil, log.WrapErr(err, "failed to list containers") } var result []*domain.Container for _, c := range containers { // Extract ports var ports []int for _, port := range c.Ports { if port.PublicPort > 0 { ports = append(ports, int(port.PublicPort)) } } // Get the primary name (remove leading slash) name := "" if len(c.Names) > 0 { name = strings.TrimPrefix(c.Names[0], "/") } volumeMounts := make([]domain.ContainerVolumeMount, 0, len(c.Mounts)) for _, m := range c.Mounts { volumeMounts = append(volumeMounts, domain.ContainerVolumeMount{ Name: m.Name, Type: string(m.Type), Source: m.Source, Destination: m.Destination, ReadOnly: !m.RW, }) } result = append(result, &domain.Container{ ID: c.ID, Image: c.Image, ImageID: c.ImageID, Name: name, Status: c.State, // Use State (e.g., "running") not Status (e.g., "Up 2 days") Ports: ports, Labels: c.Labels, VolumeMounts: volumeMounts, Created: time.Unix(c.Created, 0), }) } return result, nil } // InspectContainer inspects a container. func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*domain.Container, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "InspectContainer", zerowrap.FieldEntityID: containerID, }) log := zerowrap.FromCtx(ctx) resp, err := r.client.ContainerInspect(ctx, containerID) if err != nil { return nil, log.WrapErr(err, "failed to inspect container") } // Extract published ports var ports []int if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil { for _, bindings := range resp.NetworkSettings.Ports { for _, binding := range bindings { if binding.HostPort != "" { if port, err := strconv.Atoi(binding.HostPort); err == nil { ports = append(ports, port) } } } } } // Get container name (remove leading slash) name := strings.TrimPrefix(resp.Name, "/") created, _ := time.Parse(time.RFC3339Nano, resp.Created) volumeMounts := make([]domain.ContainerVolumeMount, 0, len(resp.Mounts)) for _, m := range resp.Mounts { volumeMounts = append(volumeMounts, domain.ContainerVolumeMount{ Name: m.Name, Type: string(m.Type), Source: m.Source, Destination: m.Destination, ReadOnly: !m.RW, }) } return &domain.Container{ ID: resp.ID, Image: resp.Config.Image, ImageID: resp.Image, Name: name, Status: resp.State.Status, ExitCode: resp.State.ExitCode, Ports: ports, Labels: resp.Config.Labels, VolumeMounts: volumeMounts, Created: created, }, nil } // GetContainerLogs gets container logs. func (r *Runtime) GetContainerLogs(ctx context.Context, containerID string, follow bool) (io.ReadCloser, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "GetContainerLogs", zerowrap.FieldEntityID: containerID, }) log := zerowrap.FromCtx(ctx) logs, err := r.client.ContainerLogs(ctx, containerID, container.LogsOptions{ ShowStdout: true, ShowStderr: true, Follow: follow, Timestamps: true, }) if err != nil { return nil, log.WrapErr(err, "failed to get container logs") } return logs, nil } // PullImage pulls an image. func (r *Runtime) PullImage(ctx context.Context, imageRef string) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "PullImage", "image": imageRef, }) log := zerowrap.FromCtx(ctx) log.Info().Msg("pulling image") reader, err := r.client.ImagePull(ctx, imageRef, image.PullOptions{}) if err != nil { return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image") } defer reader.Close() // Read the response to completion (this is required for the pull to complete) _, err = io.Copy(io.Discard, reader) if err != nil { return log.WrapErr(err, "failed to read pull response") } log.Info().Msg("image pulled successfully") return nil } // PullImageWithAuth pulls an image with authentication. func (r *Runtime) PullImageWithAuth(ctx context.Context, imageRef, username, password string) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "PullImageWithAuth", "image": imageRef, "username": username, }) log := zerowrap.FromCtx(ctx) log.Info().Msg("pulling image with authentication") // Extract registry server address from image reference // e.g., "registry.bnema.dev/image:tag" -> "registry.bnema.dev" serverAddress := imageRef if idx := strings.Index(imageRef, "/"); idx > 0 { serverAddress = imageRef[:idx] } // Create authentication configuration authConfig := registry.AuthConfig{ Username: username, Password: password, ServerAddress: serverAddress, } // Encode authentication to base64 JSON // Note: Docker API accepts both StdEncoding and URLEncoding, but Podman // may be more strict. Using StdEncoding for maximum compatibility. authConfigBytes, err := json.Marshal(authConfig) if err != nil { return log.WrapErr(err, "failed to marshal auth config") } authStr := base64.StdEncoding.EncodeToString(authConfigBytes) log.Debug(). Str("server_address", serverAddress). Bool("has_auth", username != "" || password != ""). Msg("auth config for pull") // Pull with authentication reader, err := r.client.ImagePull(ctx, imageRef, image.PullOptions{ RegistryAuth: authStr, }) if err != nil { return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image with auth") } defer reader.Close() // Read the response to completion (this is required for the pull to complete) _, err = io.Copy(io.Discard, reader) if err != nil { return log.WrapErr(err, "failed to read pull response") } log.Info().Msg("image pulled successfully with authentication") return nil } // TagImage tags a locally available image with a new reference. func (r *Runtime) TagImage(ctx context.Context, sourceRef, targetRef string) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "TagImage", "source": sourceRef, "target": targetRef, }) log := zerowrap.FromCtx(ctx) if err := r.client.ImageTag(ctx, sourceRef, targetRef); err != nil { return log.WrapErr(err, "failed to tag image") } log.Info().Msg("image tagged successfully") return nil } // UntagImage removes a tag from an image without deleting the underlying image layers. func (r *Runtime) UntagImage(ctx context.Context, imageRef string) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "UntagImage", "image": imageRef, }) log := zerowrap.FromCtx(ctx) // ImageRemove with PruneChildren=false only removes the tag, not the image layers _, err := r.client.ImageRemove(ctx, imageRef, image.RemoveOptions{ Force: false, PruneChildren: false, }) if err != nil { return log.WrapErr(err, "failed to untag image") } log.Debug().Msg("image untagged successfully") return nil } // RemoveImage removes an image. func (r *Runtime) RemoveImage(ctx context.Context, imageRef string, force bool) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "RemoveImage", "image": imageRef, "force": force, }) log := zerowrap.FromCtx(ctx) _, err := r.client.ImageRemove(ctx, imageRef, image.RemoveOptions{Force: force}) if err != nil { return log.WrapErr(err, "failed to remove image") } log.Info().Msg("image removed") return nil } // ListImages lists images. func (r *Runtime) ListImages(ctx context.Context) ([]string, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "ListImages", }) log := zerowrap.FromCtx(ctx) images, err := r.client.ImageList(ctx, image.ListOptions{}) if err != nil { return nil, log.WrapErr(err, "failed to list images") } var result []string for _, img := range images { for _, tag := range img.RepoTags { if tag != ":" { result = append(result, tag) } } } return result, nil } // ListImagesDetailed lists images with metadata. func (r *Runtime) ListImagesDetailed(ctx context.Context) ([]runtimepkg.ImageDetail, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "ListImagesDetailed", }) log := zerowrap.FromCtx(ctx) images, err := r.client.ImageList(ctx, image.ListOptions{All: true}) if err != nil { return nil, log.WrapErr(err, "failed to list detailed images") } result := make([]runtimepkg.ImageDetail, 0, len(images)) for _, img := range images { result = append(result, runtimepkg.ImageDetail{ ID: img.ID, RepoTags: img.RepoTags, Size: img.Size, Created: time.Unix(img.Created, 0), }) } return result, nil } // PruneImages prunes unused images and reports reclaimed space. func (r *Runtime) PruneImages(ctx context.Context, danglingOnly bool) (runtimepkg.PruneReport, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "PruneImages", "dangling_only": danglingOnly, }) log := zerowrap.FromCtx(ctx) pruneFilters := filters.NewArgs() if danglingOnly { pruneFilters.Add("dangling", "true") } pruneResult, err := r.client.ImagesPrune(ctx, pruneFilters) if err != nil { return runtimepkg.PruneReport{}, log.WrapErr(err, "failed to prune images") } deletedIDs := make([]string, 0, len(pruneResult.ImagesDeleted)) for _, deleted := range pruneResult.ImagesDeleted { if deleted.Deleted != "" { deletedIDs = append(deletedIDs, deleted.Deleted) } if deleted.Untagged != "" { deletedIDs = append(deletedIDs, deleted.Untagged) } } spaceReclaimed := pruneResult.SpaceReclaimed if spaceReclaimed > math.MaxInt64 { log.Warn(). Uint64("space_reclaimed_bytes", spaceReclaimed). Int64("space_reclaimed_capped_bytes", math.MaxInt64). Msg("space reclaimed exceeds int64 max; capping value") spaceReclaimed = uint64(math.MaxInt64) } //nolint:gosec // spaceReclaimed is capped to MaxInt64 above spaceReclaimedInt := int64(spaceReclaimed) return runtimepkg.PruneReport{ DeletedIDs: deletedIDs, SpaceReclaimed: spaceReclaimedInt, }, nil } // Ping checks if Docker is responsive. func (r *Runtime) Ping(ctx context.Context) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "Ping", }) log := zerowrap.FromCtx(ctx) _, err := r.client.Ping(ctx) if err != nil { return log.WrapErr(err, "Docker ping failed") } return nil } // Version returns Docker version. func (r *Runtime) Version(ctx context.Context) (string, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "Version", }) log := zerowrap.FromCtx(ctx) version, err := r.client.ServerVersion(ctx) if err != nil { return "", log.WrapErr(err, "failed to get Docker version") } return version.Version, nil } // IsContainerRunning checks if a container is running. func (r *Runtime) IsContainerRunning(ctx context.Context, containerID string) (bool, error) { ctr, err := r.InspectContainer(ctx, containerID) if err != nil { return false, err } return ctr.Status == string(domain.ContainerStatusRunning), nil } // GetContainerHealthStatus returns Docker healthcheck status when available. func (r *Runtime) GetContainerHealthStatus(ctx context.Context, containerID string) (string, bool, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "GetContainerHealthStatus", zerowrap.FieldEntityID: containerID, }) log := zerowrap.FromCtx(ctx) resp, err := r.client.ContainerInspect(ctx, containerID) if err != nil { return "", false, log.WrapErr(err, "failed to inspect container") } // Detect configured healthchecks from container config rather than relying on // State.Health presence. Some runtimes can expose an empty State.Health struct // even when no healthcheck is configured. hasHealthcheck := hasConfiguredHealthcheck(resp.Config) if !hasHealthcheck { return "", false, nil } if resp.State == nil || resp.State.Health == nil { return "", true, nil } return resp.State.Health.Status, true, nil } func hasConfiguredHealthcheck(cfg *container.Config) bool { if cfg == nil || cfg.Healthcheck == nil || len(cfg.Healthcheck.Test) == 0 { return false } // Docker uses ["NONE"] to explicitly disable healthchecks inherited from base images. return !strings.EqualFold(strings.TrimSpace(cfg.Healthcheck.Test[0]), "NONE") } // GetContainerPort gets the host port for a container's internal port. func (r *Runtime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "GetContainerPort", zerowrap.FieldEntityID: containerID, "internal_port": internalPort, }) log := zerowrap.FromCtx(ctx) resp, err := r.client.ContainerInspect(ctx, containerID) if err != nil { return 0, log.WrapErr(err, "failed to inspect container") } if resp.NetworkSettings == nil || resp.NetworkSettings.Ports == nil { return 0, fmt.Errorf("no port mappings found for container %s", containerID) } containerPort := nat.Port(fmt.Sprintf("%d/tcp", internalPort)) bindings, exists := resp.NetworkSettings.Ports[containerPort] if !exists || len(bindings) == 0 { return 0, fmt.Errorf("port %d not mapped for container %s", internalPort, containerID) } hostPort, err := strconv.Atoi(bindings[0].HostPort) if err != nil { return 0, fmt.Errorf("invalid host port for container %s: %w", containerID, err) } return hostPort, nil } // GetImageExposedPorts gets the exposed ports from an image. func (r *Runtime) GetImageExposedPorts(ctx context.Context, imageRef string) ([]int, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "GetImageExposedPorts", "image": imageRef, }) log := zerowrap.FromCtx(ctx) imageInspect, err := r.client.ImageInspect(ctx, imageRef) if err != nil { return nil, log.WrapErr(err, "failed to inspect image") } var ports []int if imageInspect.Config != nil && imageInspect.Config.ExposedPorts != nil { for portSpec := range imageInspect.Config.ExposedPorts { // Parse port from format like "80/tcp" portStr := strings.Split(portSpec, "/")[0] if port, err := strconv.Atoi(portStr); err == nil { ports = append(ports, port) } } } if len(ports) == 0 { return nil, fmt.Errorf("no EXPOSE directives found in image %s - please add EXPOSE directive to Dockerfile", imageRef) } // Sort ports with HTTP-friendly ports first (avoid SSH port 22, etc.) // Priority: 80, 8080, 3000, 8000, 5000, then others sorted ascending sortPortsHTTPFirst(ports) log.Info().Ints("exposed_ports", ports).Msg("found exposed ports in image") return ports, nil } // sortPortsHTTPFirst sorts ports with common HTTP ports first, avoiding SSH (22). func sortPortsHTTPFirst(ports []int) { priority := map[int]int{ 80: 0, 443: 1, 8080: 2, 3000: 3, 8000: 4, 5000: 5, 9000: 6, } sort.Slice(ports, func(i, j int) bool { pi, oki := priority[ports[i]] pj, okj := priority[ports[j]] if oki && okj { return pi < pj } if oki { return true } if okj { return false } // Neither is a priority port - sort ascending but push 22 to the end if ports[i] == 22 { return false } if ports[j] == 22 { return true } return ports[i] < ports[j] }) } // GetContainerExposedPorts gets all exposed ports from a running container. func (r *Runtime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "GetContainerExposedPorts", zerowrap.FieldEntityID: containerID, }) log := zerowrap.FromCtx(ctx) resp, err := r.client.ContainerInspect(ctx, containerID) if err != nil { return nil, log.WrapErr(err, "failed to inspect container") } var ports []int if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil { for portSpec := range resp.NetworkSettings.Ports { // Parse port from format like "80/tcp" portStr := strings.Split(string(portSpec), "/")[0] if port, err := strconv.Atoi(portStr); err == nil { ports = append(ports, port) } } } return ports, nil } // GetContainerNetworkInfo gets container's internal IP and automatically detects the best port to use. func (r *Runtime) GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "GetContainerNetworkInfo", zerowrap.FieldEntityID: containerID, }) log := zerowrap.FromCtx(ctx) resp, err := r.client.ContainerInspect(ctx, containerID) if err != nil { return "", 0, log.WrapErr(err, "failed to inspect container") } // Get container's internal IP address var containerIP string if resp.NetworkSettings != nil && resp.NetworkSettings.Networks != nil { // Use the first available network (usually bridge or custom network) for _, net := range resp.NetworkSettings.Networks { if net.IPAddress != "" { containerIP = net.IPAddress break } } } if containerIP == "" { return "", 0, fmt.Errorf("no IP address found for container %s", containerID) } // Get exposed ports from container var exposedPorts []int if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil { for portSpec := range resp.NetworkSettings.Ports { // Parse port from format like "80/tcp" portStr := strings.Split(string(portSpec), "/")[0] if port, err := strconv.Atoi(portStr); err == nil { exposedPorts = append(exposedPorts, port) } } } if len(exposedPorts) == 0 { return "", 0, fmt.Errorf("no EXPOSE directives found for container %s - please add EXPOSE directive to Dockerfile", containerID) } // Sort ports with HTTP-friendly ports first, pushing SSH (22) to the end sortPortsHTTPFirst(exposedPorts) selectedPort := exposedPorts[0] log.Info(). Str("ip", containerIP). Int("selected_port", selectedPort). Ints("exposed_ports", exposedPorts). Msg("container network info detected") return containerIP, selectedPort, nil } // GetContainerNetwork returns the network name a container is connected to. func (r *Runtime) GetContainerNetwork(ctx context.Context, containerID string) (string, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "GetContainerNetwork", zerowrap.FieldEntityID: containerID, }) log := zerowrap.FromCtx(ctx) resp, err := r.client.ContainerInspect(ctx, containerID) if err != nil { return "", log.WrapErr(err, "failed to inspect container") } networks := resp.NetworkSettings if networks == nil || networks.Networks == nil || len(networks.Networks) == 0 { return "bridge", nil } // Priority: gordon-* networks first, then bridge, then any other selected := "" hasBridge := false for name := range networks.Networks { if strings.HasPrefix(name, "gordon-") { selected = name break } if name == "bridge" { hasBridge = true } else if selected == "" { selected = name } } // If no gordon-* network found, prefer bridge over random network if selected == "" || (hasBridge && !strings.HasPrefix(selected, "gordon-")) { if hasBridge { selected = "bridge" } } if selected == "" { return "bridge", nil } log.Debug().Str("network", selected).Msg("container network detected") return selected, nil } // InspectImageVolumes gets the volume mount points declared in the image. func (r *Runtime) InspectImageVolumes(ctx context.Context, imageRef string) ([]string, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "InspectImageVolumes", "image": imageRef, }) log := zerowrap.FromCtx(ctx) imageInspect, err := r.client.ImageInspect(ctx, imageRef) if err != nil { return nil, log.WrapErr(err, "failed to inspect image") } var volumes []string if imageInspect.Config != nil && imageInspect.Config.Volumes != nil { for volumePath := range imageInspect.Config.Volumes { volumes = append(volumes, volumePath) } } if len(volumes) > 0 { log.Info().Strs("volumes", volumes).Msg("found VOLUME directives in image") } else { log.Debug().Msg("no VOLUME directives found in image") } return volumes, nil } // VolumeExists checks if a Docker volume exists. func (r *Runtime) VolumeExists(ctx context.Context, volumeName string) (bool, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "VolumeExists", "volume": volumeName, }) log := zerowrap.FromCtx(ctx) _, err := r.client.VolumeInspect(ctx, volumeName) if err != nil { if cerrdefs.IsNotFound(err) { return false, nil } return false, log.WrapErr(err, "failed to inspect volume") } return true, nil } // CreateVolume creates a new Docker volume. func (r *Runtime) CreateVolume(ctx context.Context, volumeName string) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "CreateVolume", "volume": volumeName, }) log := zerowrap.FromCtx(ctx) _, err := r.client.VolumeCreate(ctx, volume.CreateOptions{ Name: volumeName, Labels: map[string]string{ domain.LabelManaged: "true", domain.LabelCreated: time.Now().UTC().Format(time.RFC3339), }, }) if err != nil { return log.WrapErr(err, "failed to create volume") } log.Info().Msg("volume created") return nil } // RemoveVolume removes a Docker volume. func (r *Runtime) RemoveVolume(ctx context.Context, volumeName string, force bool) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "RemoveVolume", "volume": volumeName, "force": force, }) log := zerowrap.FromCtx(ctx) err := r.client.VolumeRemove(ctx, volumeName, force) if err != nil { if cerrdefs.IsNotFound(err) { log.Debug().Msg("volume not found, already removed") return nil } return log.WrapErr(err, "failed to remove volume") } log.Info().Msg("volume removed") return nil } // ListVolumes returns all named volumes with their usage status. func (r *Runtime) ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error) { volumeList, err := r.client.VolumeList(ctx, volume.ListOptions{}) if err != nil { return nil, fmt.Errorf("failed to list volumes: %w", err) } // Get all containers to determine volume usage containers, err := r.client.ContainerList(ctx, container.ListOptions{All: true}) if err != nil { return nil, fmt.Errorf("failed to list containers for volume usage: %w", err) } // Build a map of volume name -> container names volumeUsers := make(map[string][]string) for _, c := range containers { for _, m := range c.Mounts { if m.Type == "volume" { name := "" if len(c.Names) > 0 { name = strings.TrimPrefix(c.Names[0], "/") } volumeUsers[m.Name] = append(volumeUsers[m.Name], name) } } } var result []*domain.VolumeInfo for _, v := range volumeList.Volumes { if v == nil { continue } var size int64 if v.UsageData != nil { size = v.UsageData.Size } created, _ := time.Parse(time.RFC3339, v.CreatedAt) users := volumeUsers[v.Name] info := &domain.VolumeInfo{ Name: v.Name, Driver: v.Driver, MountPoint: v.Mountpoint, Size: size, CreatedAt: created, InUse: len(users) > 0, Containers: users, Labels: v.Labels, } result = append(result, info) } return result, nil } // ExportVolumeArchive exports a named volume as a compressed tar archive stream. func (r *Runtime) ExportVolumeArchive(ctx context.Context, req domain.VolumeArchiveRequest) (*domain.VolumeArchiveResult, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "ExportVolumeArchive", "volume": req.VolumeName, }) log := zerowrap.FromCtx(ctx) if err := validateVolumeArchiveRequest(req); err != nil { return nil, err } if err := r.ensureVolumeArchiveHelperImage(ctx, req.HelperImage, log); err != nil { return nil, err } created, cleanup, err := r.createVolumeArchiveHelper(ctx, req, log) if err != nil { return nil, err } if err := r.client.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil { _ = cleanup() return nil, log.WrapErr(err, "failed to start volume archive helper container") } stream, err := r.attachVolumeArchiveStream(ctx, created.ID, cleanup, log) if err != nil { _ = cleanup() return nil, err } return &domain.VolumeArchiveResult{Stream: stream}, nil } func validateVolumeArchiveRequest(req domain.VolumeArchiveRequest) error { if strings.TrimSpace(req.VolumeName) == "" { return fmt.Errorf("volume name is required") } if strings.TrimSpace(req.HelperImage) == "" { return fmt.Errorf("helper image is required") } return nil } func (r *Runtime) ensureVolumeArchiveHelperImage(ctx context.Context, imageRef string, log zerowrap.Logger) error { if _, err := r.client.ImageInspect(ctx, imageRef); err != nil { if !cerrdefs.IsNotFound(err) { return log.WrapErr(err, "failed to inspect volume archive helper image") } return r.PullImage(ctx, imageRef) } return nil } func (r *Runtime) createVolumeArchiveHelper(ctx context.Context, req domain.VolumeArchiveRequest, log zerowrap.Logger) (container.CreateResponse, func() error, error) { cmd, err := volumeArchiveCommand(req.Compression) if err != nil { return container.CreateResponse{}, nil, err } created, err := r.client.ContainerCreate(ctx, volumeArchiveContainerConfig(req.HelperImage, cmd), volumeArchiveHostConfig(req.VolumeName), nil, nil, fmt.Sprintf("gordon-volume-backup-%d", time.Now().UTC().UnixNano())) if err != nil { return container.CreateResponse{}, nil, log.WrapErr(err, "failed to create volume archive helper container") } var cleanupOnce sync.Once cleanup := func() error { var cleanupErr error cleanupOnce.Do(func() { removeCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() cleanupErr = r.RemoveContainer(removeCtx, created.ID, true) }) return cleanupErr } return created, cleanup, nil } func volumeArchiveContainerConfig(imageRef, cmd string) *container.Config { return &container.Config{ Image: imageRef, Cmd: []string{"sh", "-c", cmd}, AttachStdout: true, AttachStderr: true, NetworkDisabled: true, Labels: map[string]string{ domain.LabelManaged: "true", "gordon.purpose": "volume-backup", }, } } func volumeArchiveHostConfig(volumeName string) *container.HostConfig { return &container.HostConfig{ AutoRemove: false, Mounts: []mount.Mount{{ Type: mount.TypeVolume, Source: volumeName, Target: "/volume", ReadOnly: true, }}, } } func (r *Runtime) attachVolumeArchiveStream(ctx context.Context, containerID string, cleanup func() error, log zerowrap.Logger) (io.ReadCloser, error) { logs, err := r.client.ContainerLogs(ctx, containerID, container.LogsOptions{ShowStdout: true, ShowStderr: true, Follow: true}) if err != nil { return nil, log.WrapErr(err, "failed to attach volume archive stream") } statusCh, errCh := r.client.ContainerWait(ctx, containerID, container.WaitConditionNotRunning) pr, pw := io.Pipe() go streamVolumeArchiveLogs(logs, pw, statusCh, errCh, cleanup, log) return &pipeReadCloser{pr: pr, pw: pw, original: logs, cleanup: cleanup}, nil } func streamVolumeArchiveLogs(logs io.ReadCloser, pw *io.PipeWriter, statusCh <-chan container.WaitResponse, errCh <-chan error, cleanup func() error, log zerowrap.Logger) { defer logs.Close() defer func() { if err := cleanup(); err != nil { log.Warn().Err(err).Msg("failed to clean up volume archive helper container") } }() var stderr bytes.Buffer _, copyErr := stdcopy.StdCopy(pw, &stderr, logs) statusCode, waitErr := waitForVolumeArchiveContainer(statusCh, errCh) closeVolumeArchivePipe(pw, copyErr, waitErr, statusCode, stderr.String()) } func waitForVolumeArchiveContainer(statusCh <-chan container.WaitResponse, errCh <-chan error) (int64, error) { for statusCh != nil || errCh != nil { select { case err, ok := <-errCh: if !ok { errCh = nil continue } if err != nil { return 0, err } case status, ok := <-statusCh: if !ok { return 0, fmt.Errorf("volume archive helper container wait channel closed") } return status.StatusCode, nil } } return 0, fmt.Errorf("volume archive helper container wait channels closed") } func closeVolumeArchivePipe(pw *io.PipeWriter, copyErr, waitErr error, statusCode int64, stderr string) { switch { case copyErr != nil: _ = pw.CloseWithError(copyErr) case waitErr != nil: _ = pw.CloseWithError(waitErr) case statusCode != 0: _ = pw.CloseWithError(fmt.Errorf("volume archive helper exited with code %d: %s", statusCode, strings.TrimSpace(stderr))) default: _ = pw.Close() } } func volumeArchiveCommand(compression domain.VolumeBackupCompression) (string, error) { switch compression { case domain.VolumeBackupCompressionGzip: return "cd /volume && tar -czf - .", nil case domain.VolumeBackupCompressionZstd: return "cd /volume && fifo=/tmp/gordon-volume-backup.tar && mkfifo $fifo && zstd -T0 -c $fifo & zstd_pid=$! && tar -cf $fifo .; tar_status=$?; wait $zstd_pid; zstd_status=$?; if [ $tar_status -ne 0 ]; then exit $tar_status; fi; exit $zstd_status", nil default: return "", fmt.Errorf("unsupported volume backup compression: %s", compression) } } // InspectImageEnv gets the environment variables declared in the image. func (r *Runtime) InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "InspectImageEnv", "image": imageRef, }) log := zerowrap.FromCtx(ctx) imageInspect, err := r.client.ImageInspect(ctx, imageRef) if err != nil { return nil, log.WrapErr(err, "failed to inspect image") } var envVars []string if imageInspect.Config != nil && imageInspect.Config.Env != nil { envVars = imageInspect.Config.Env } if len(envVars) > 0 { log.Debug(). Int(zerowrap.FieldCount, len(envVars)). Strs("env_keys", imageEnvKeys(envVars)). Msg("found ENV directives in image") } else { log.Debug().Msg("no ENV directives found in image") } return envVars, nil } // GetImageLabels returns the labels defined on an image. func imageEnvKeys(envVars []string) []string { keys := make([]string, 0, len(envVars)) for _, envVar := range envVars { key, _, ok := strings.Cut(envVar, "=") if !ok || key == "" { keys = append(keys, "[malformed]") continue } keys = append(keys, key) } sort.Strings(keys) return keys } func (r *Runtime) GetImageLabels(ctx context.Context, imageRef string) (map[string]string, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "GetImageLabels", "image": imageRef, }) log := zerowrap.FromCtx(ctx) imageInspect, err := r.client.ImageInspect(ctx, imageRef) if err != nil { return nil, log.WrapErr(err, "failed to inspect image") } labels := make(map[string]string) if imageInspect.Config != nil && imageInspect.Config.Labels != nil { labels = imageInspect.Config.Labels } log.Debug().Int("label_count", len(labels)).Msg("retrieved image labels") return labels, nil } // GetImageID returns the unique image ID (sha256 digest) for the given image reference. func (r *Runtime) GetImageID(ctx context.Context, imageRef string) (string, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "GetImageID", "image": imageRef, }) log := zerowrap.FromCtx(ctx) imageInspect, err := r.client.ImageInspect(ctx, imageRef) if err != nil { return "", log.WrapErr(err, "failed to inspect image") } return imageInspect.ID, nil } // CreateNetwork creates a new Docker network. func (r *Runtime) CreateNetwork(ctx context.Context, name string, config domain.NetworkConfig) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "CreateNetwork", "network": name, }) log := zerowrap.FromCtx(ctx) driver := config.Driver if driver == "" { driver = "bridge" } labels := make(map[string]string, len(config.Labels)+1) maps.Copy(labels, config.Labels) labels[domain.LabelManaged] = "true" createOptions := network.CreateOptions{ Driver: driver, Internal: config.Internal, Labels: labels, } _, err := r.client.NetworkCreate(ctx, name, createOptions) if err != nil { return log.WrapErr(err, "failed to create network") } log.Info().Str("driver", driver).Msg("network created") return nil } // RemoveNetwork removes a Docker network. func (r *Runtime) RemoveNetwork(ctx context.Context, name string) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "RemoveNetwork", "network": name, }) log := zerowrap.FromCtx(ctx) err := r.client.NetworkRemove(ctx, name) if err != nil { if cerrdefs.IsNotFound(err) { log.Debug().Msg("network not found, already removed") return nil } return log.WrapErr(err, "failed to remove network") } log.Info().Msg("network removed") return nil } // ListNetworks lists all Docker networks. func (r *Runtime) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "ListNetworks", }) log := zerowrap.FromCtx(ctx) networks, err := r.client.NetworkList(ctx, network.ListOptions{}) if err != nil { return nil, log.WrapErr(err, "failed to list networks") } var result []*domain.NetworkInfo for _, net := range networks { var containers []string for containerID := range net.Containers { containers = append(containers, containerID) } result = append(result, &domain.NetworkInfo{ ID: net.ID, Name: net.Name, Driver: net.Driver, Containers: containers, Labels: net.Labels, }) } return result, nil } // NetworkExists checks if a Docker network exists. func (r *Runtime) NetworkExists(ctx context.Context, name string) (bool, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "NetworkExists", "network": name, }) log := zerowrap.FromCtx(ctx) _, err := r.client.NetworkInspect(ctx, name, network.InspectOptions{}) if err != nil { if cerrdefs.IsNotFound(err) { return false, nil } return false, log.WrapErr(err, "failed to inspect network") } return true, nil } // ConnectContainerToNetwork connects a container to a network. func (r *Runtime) ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "ConnectContainerToNetwork", "container": containerName, "network": networkName, }) log := zerowrap.FromCtx(ctx) err := r.client.NetworkConnect(ctx, networkName, containerName, &network.EndpointSettings{}) if err != nil { return log.WrapErr(err, "failed to connect container to network") } log.Info().Msg("container connected to network") return nil } // DisconnectContainerFromNetwork disconnects a container from a network. func (r *Runtime) DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "DisconnectContainerFromNetwork", "container": containerName, "network": networkName, }) log := zerowrap.FromCtx(ctx) err := r.client.NetworkDisconnect(ctx, networkName, containerName, false) if err != nil { return log.WrapErr(err, "failed to disconnect container from network") } log.Info().Msg("container disconnected from network") return nil } // ExecInContainer executes a command in a running container. func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd []string) (*out.ExecResult, error) { if len(cmd) == 0 { return nil, fmt.Errorf("command cannot be empty") } ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "ExecInContainer", zerowrap.FieldEntityID: containerID, "command": cmd[0], "arg_count": len(cmd) - 1, }) log := zerowrap.FromCtx(ctx) execResp, err := r.client.ContainerExecCreate(ctx, containerID, container.ExecOptions{ Cmd: cmd, AttachStdout: true, AttachStderr: true, }) if err != nil { return nil, log.WrapErr(err, "failed to create exec") } attachCtx := ctx var cancel context.CancelFunc if _, hasDeadline := ctx.Deadline(); !hasDeadline { attachCtx, cancel = context.WithTimeout(ctx, 5*time.Minute) defer cancel() } attachResp, err := r.client.ContainerExecAttach(attachCtx, execResp.ID, container.ExecAttachOptions{}) if err != nil { return nil, log.WrapErr(err, "failed to attach to exec") } defer attachResp.Close() if deadline, ok := attachCtx.Deadline(); ok && attachResp.Conn != nil { _ = attachResp.Conn.SetReadDeadline(deadline) } stdout, stderr, err := parseExecOutput(attachResp.Reader) if err != nil { return nil, log.WrapErr(err, "failed to read exec output") } inspectResp, err := r.client.ContainerExecInspect(ctx, execResp.ID) if err != nil { return nil, log.WrapErr(err, "failed to inspect exec result") } return &out.ExecResult{ ExitCode: inspectResp.ExitCode, Stdout: stdout, Stderr: stderr, }, nil } func parseExecOutput(reader io.Reader) ([]byte, []byte, error) { var stdout bytes.Buffer var stderr bytes.Buffer if _, err := stdcopy.StdCopy(&stdout, &stderr, reader); err != nil { return nil, nil, err } return stdout.Bytes(), stderr.Bytes(), nil } // CopyFromContainer copies a file from a container and returns a content stream. func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "CopyFromContainer", zerowrap.FieldEntityID: containerID, "src_path": srcPath, }) log := zerowrap.FromCtx(ctx) tarReader, _, err := r.client.CopyFromContainer(ctx, containerID, srcPath) if err != nil { return nil, log.WrapErr(err, "failed to copy from container") } // The response is a tar archive - we need to extract the requested file stream. reader, err := extractFileFromTar(tarReader, srcPath) if err != nil { _ = tarReader.Close() return nil, log.WrapErr(err, "failed to extract file from tar") } return reader, nil } // extractFileFromTar extracts a single file from a tar archive. func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser, error) { tr := tar.NewReader(reader) targetName := normalizedTarPath(targetPath) targetBase := filepath.Base(targetName) var fallback *removeOnCloseFile fallbackMatches := 0 for { header, err := tr.Next() if err == io.EOF { break } if err != nil { cleanupFallback(fallback) _ = reader.Close() return nil, err } if header.Typeflag != tar.TypeReg { continue } headerName := normalizedTarPath(header.Name) if headerName == targetName { cleanupFallback(fallback) return streamCurrentTarFile(reader, tr, header.Size), nil } if headerName == targetBase { fallbackMatches++ if fallbackMatches == 1 { fallback, err = bufferCurrentTarFile(tr, header.Size) if err != nil { _ = reader.Close() return nil, err } } } } _ = reader.Close() if fallbackMatches == 1 { if _, err := fallback.Seek(0, io.SeekStart); err != nil { cleanupFallback(fallback) return nil, err } return fallback, nil } cleanupFallback(fallback) if fallbackMatches > 1 { return nil, fmt.Errorf("ambiguous basename matches in container archive for: %s", targetPath) } return nil, fmt.Errorf("file not found in container: %s", targetPath) } func streamCurrentTarFile(reader io.ReadCloser, tr *tar.Reader, size int64) io.ReadCloser { pr, pw := io.Pipe() go func() { defer reader.Close() _, copyErr := io.CopyN(pw, tr, size) if copyErr != nil { _ = pw.CloseWithError(copyErr) return } _ = pw.Close() }() return &pipeReadCloser{pr: pr, pw: pw, original: reader} } func bufferCurrentTarFile(tr *tar.Reader, size int64) (*removeOnCloseFile, error) { file, err := os.CreateTemp("", "gordon-copy-from-container-*") if err != nil { return nil, err } fallback := &removeOnCloseFile{File: file, path: file.Name()} if _, err := io.CopyN(fallback, tr, size); err != nil { cleanupFallback(fallback) return nil, err } return fallback, nil } func cleanupFallback(fallback *removeOnCloseFile) { if fallback != nil { _ = fallback.Close() } } func normalizedTarPath(path string) string { path = filepath.ToSlash(filepath.Clean(path)) return strings.TrimPrefix(path, "/") } const maxExtractedEnvFileSize = 1 << 20 // 1 MiB func readExtractedEnvFile(reader io.Reader) ([]byte, error) { limited := io.LimitReader(reader, maxExtractedEnvFileSize+1) data, err := io.ReadAll(limited) if err != nil { return nil, err } if len(data) > maxExtractedEnvFileSize { return nil, fmt.Errorf("extracted env file exceeds %d bytes", maxExtractedEnvFileSize) } return data, nil } // ExtractEnvFileFromImage extracts an env file from an image. // This creates a temporary container, copies the file, and removes the container. func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ zerowrap.FieldLayer: "adapter", zerowrap.FieldAdapter: "docker", zerowrap.FieldAction: "ExtractEnvFileFromImage", "image": imageRef, "env_file": envFilePath, }) log := zerowrap.FromCtx(ctx) log.Info().Msg("extracting env file from image") // Create a temporary container without starting it containerConfig := &container.Config{ Image: imageRef, Cmd: []string{"true"}, // Dummy command } resp, err := r.client.ContainerCreate(ctx, containerConfig, nil, nil, nil, "") if err != nil { return nil, log.WrapErr(err, "failed to create temporary container") } tempContainerID := resp.ID log.Debug().Str("temp_container_id", tempContainerID).Msg("created temporary container") // Ensure cleanup defer func() { if err := r.client.ContainerRemove(ctx, tempContainerID, container.RemoveOptions{Force: true}); err != nil { log.Warn().Err(err).Str("temp_container_id", tempContainerID).Msg("failed to remove temporary container") } }() // Copy the env file from the container reader, err := r.CopyFromContainer(ctx, tempContainerID, envFilePath) if err != nil { return nil, err } defer reader.Close() data, err := readExtractedEnvFile(reader) if err != nil { return nil, log.WrapErr(err, "failed to read extracted env file") } log.Info().Int("size", len(data)).Msg("env file extracted from image") return data, nil }