/* Copyright 2026 The Crossplane Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Package docker contains helpers for working with Docker-compatible container // runtimes. package docker import ( "archive/tar" "bytes" "context" "encoding/base64" "encoding/json" "fmt" "io" "path" "path/filepath" "slices" "strings" "github.com/docker/cli/cli/config" "github.com/google/go-containerregistry/pkg/name" archive "github.com/moby/go-archive" "github.com/moby/moby/api/pkg/stdcopy" "github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/network" "github.com/moby/moby/client" "github.com/spf13/afero" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" ) // Check attempts to connect to the local Docker daemon and returns an error if // it's unable to do so. func Check(ctx context.Context) error { cli, err := NewClient() if err != nil { return err } if _, err := cli.Ping(ctx, client.PingOptions{NegotiateAPIVersion: true}); err != nil { return errors.Wrap(err, "failed to ping docker daemon") } return nil } // GetContainerIDByName returns the ID of the container with the given name. func GetContainerIDByName(ctx context.Context, name string, includeStopped bool) (string, bool, error) { c, found, err := GetContainerByName(ctx, name, includeStopped) if err != nil { return "", false, err } if !found { return "", false, nil } return c.ID, true, nil } // GetContainerByName returns the container with the given name. func GetContainerByName(ctx context.Context, name string, includeStopped bool) (*container.Summary, bool, error) { cli, err := NewClient() if err != nil { return nil, false, err } cs, err := cli.ContainerList(ctx, client.ContainerListOptions{ Filters: make(client.Filters).Add("name", name), All: includeStopped, }) if err != nil { return nil, false, errors.Wrap(err, "failed to list containers") } if len(cs.Items) == 0 { return nil, false, nil } return &cs.Items[0], true, nil } // GetNetworkIDByName returns the ID of the network with the given name. func GetNetworkIDByName(ctx context.Context, name string) (string, bool, error) { cli, err := NewClient() if err != nil { return "", false, err } ns, err := cli.NetworkList(ctx, client.NetworkListOptions{ Filters: make(client.Filters).Add("name", name), }) if err != nil { return "", false, errors.Wrap(err, "failed to list networks") } if len(ns.Items) == 0 { return "", false, nil } return ns.Items[0].ID, true, nil } // StartContainer starts a container with the given name using the given image. func StartContainer(ctx context.Context, name, img string, opts ...StartContainerOption) (string, error) { cfg := &startContainerConfig{ containerConfig: &container.Config{ Image: img, }, } for _, opt := range opts { opt(cfg) } cli, err := NewClient() if err != nil { return "", err } if _, err := cli.ImageInspect(ctx, img); err != nil { auth, err := defaultRegistryAuth(img) if err != nil { return "", err } out, err := cli.ImagePull(ctx, img, client.ImagePullOptions{ RegistryAuth: auth, }) if err != nil { return "", errors.Wrapf(err, "failed to pull image %q", img) } if _, err := io.Copy(io.Discard, out); err != nil { return "", errors.Wrapf(err, "failed to read image pull output for %s", img) } } resp, err := cli.ContainerCreate(ctx, client.ContainerCreateOptions{ Config: cfg.containerConfig, HostConfig: cfg.hostConfig, Name: name, }) if err != nil { return "", errors.Wrap(err, "failed to create container") } for _, cpy := range cfg.copyFiles { if _, err := cli.CopyToContainer(ctx, resp.ID, client.CopyToContainerOptions{ DestinationPath: filepath.Clean(cpy.to), Content: bytes.NewReader(cpy.tarball), }); err != nil { return "", errors.Wrapf(err, "failed to copy files to container path %s", cpy.to) } } if _, err := cli.ContainerStart(ctx, resp.ID, client.ContainerStartOptions{}); err != nil { return "", errors.Wrap(err, "failed to start container") } for _, nid := range cfg.networks { if _, err := cli.NetworkConnect(ctx, nid, client.NetworkConnectOptions{Container: resp.ID}); err != nil { return "", errors.Wrapf(err, "failed to connect container to network %q", nid) } } return resp.ID, nil } func defaultRegistryAuth(imageName string) (string, error) { hostname, err := resolveRegistryFromImage(imageName) if err != nil { return "", errors.Wrapf(err, "cannot resolve registry for image %q", imageName) } cfg, err := config.Load(config.Dir()) if err != nil { return "", errors.Wrap(err, "cannot load Docker registry auth config") } auth, err := cfg.GetAuthConfig(hostname) if err != nil { return "", errors.Wrapf(err, "cannot get auth config for registry %q", hostname) } data, err := json.Marshal(auth) //nolint:gosec // Password is already in plaintext docker config. if err != nil { return "", errors.Wrap(err, "cannot marshal Docker auth config") } return base64.StdEncoding.EncodeToString(data), nil } func resolveRegistryFromImage(img string) (string, error) { ref, err := name.ParseReference(img, name.StrictValidation) if err != nil { return "", errors.Wrapf(err, "cannot parse image reference %q", img) } return ref.Context().RegistryStr(), nil } // StartContainerByID starts an existing container by ID. func StartContainerByID(ctx context.Context, id string) error { cli, err := NewClient() if err != nil { return err } _, err = cli.ContainerStart(ctx, id, client.ContainerStartOptions{}) return errors.Wrap(err, "failed to start container") } // CopyDirectoryToContainer copies a directory tree to an existing container directory. func CopyDirectoryToContainer(ctx context.Context, id, source, destination string) error { cli, err := NewClient() if err != nil { return err } tarball, err := archive.TarWithOptions(source, &archive.TarOptions{ IncludeSourceDir: true, }) if err != nil { return errors.Wrapf(err, "failed to archive %s", source) } defer tarball.Close() //nolint:errcheck // Best-effort close after streaming the archive. if _, err := cli.CopyToContainer(ctx, id, client.CopyToContainerOptions{ DestinationPath: filepath.Clean(destination), Content: tarball, }); err != nil { return errors.Wrapf(err, "failed to copy directory to container path %s", destination) } return nil } type startContainerConfig struct { containerConfig *container.Config hostConfig *container.HostConfig networks []string copyFiles []copyFilesConfig } type copyFilesConfig struct { to string tarball []byte } // StartContainerOption provides optional options for StartContainer. type StartContainerOption func(*startContainerConfig) // StartWithCommand sets the command to use when starting a container. func StartWithCommand(cmd []string) StartContainerOption { return func(cfg *startContainerConfig) { if cfg.containerConfig == nil { cfg.containerConfig = &container.Config{} } cfg.containerConfig.Cmd = cmd } } // StartWithBindMount adds a bind mount when starting a container. func StartWithBindMount(hostPath, containerPath string) StartContainerOption { return func(cfg *startContainerConfig) { if cfg.hostConfig == nil { cfg.hostConfig = &container.HostConfig{} } cfg.hostConfig.Binds = append(cfg.hostConfig.Binds, fmt.Sprintf("%s:%s", hostPath, containerPath)) } } // StartWithVolume adds a volume when starting a container. func StartWithVolume(path string) StartContainerOption { return func(cfg *startContainerConfig) { if cfg.containerConfig.Volumes == nil { cfg.containerConfig.Volumes = map[string]struct{}{} } cfg.containerConfig.Volumes[path] = struct{}{} } } // StartWithNetworkID adds a network to which a container should be added. func StartWithNetworkID(nid string) StartContainerOption { return func(cfg *startContainerConfig) { cfg.networks = append(cfg.networks, nid) } } // StartWithCopyFiles adds files that should be copied to the given path before // starting the container. func StartWithCopyFiles(tarball []byte, path string) StartContainerOption { return func(cfg *startContainerConfig) { cfg.copyFiles = append(cfg.copyFiles, copyFilesConfig{ to: path, tarball: tarball, }) } } // StartWithEnv adds environment variables that will be passed to the container. func StartWithEnv(env ...string) StartContainerOption { return func(cfg *startContainerConfig) { cfg.containerConfig.Env = append(cfg.containerConfig.Env, env...) } } // StartWithWorkingDirectory sets the working directory for the container. func StartWithWorkingDirectory(path string) StartContainerOption { return func(cfg *startContainerConfig) { cfg.containerConfig.WorkingDir = path } } // StopContainerByID stops and removes a container. func StopContainerByID(ctx context.Context, cid string) error { cli, err := NewClient() if err != nil { return err } if _, err := cli.ContainerStop(ctx, cid, client.ContainerStopOptions{}); err != nil { return errors.Wrap(err, "failed to stop container") } _, err = cli.ContainerRemove(ctx, cid, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}) return errors.Wrap(err, "failed to remove container") } // WaitForContainerByID waits for the container with the given ID to stop. func WaitForContainerByID(ctx context.Context, cid string) error { cli, err := NewClient() if err != nil { return err } wait := cli.ContainerWait(ctx, cid, client.ContainerWaitOptions{ Condition: container.WaitConditionNotRunning, }) select { case status := <-wait.Result: if status.StatusCode != 0 { out, err := cli.ContainerLogs(ctx, cid, client.ContainerLogsOptions{ShowStdout: true, ShowStderr: true}) if err != nil { return errors.Wrapf(err, "failed to get container logs") } logs := new(strings.Builder) if _, err := io.Copy(logs, out); err != nil { return errors.Wrapf(err, "failed to read container logs") } return fmt.Errorf("container exited with non-zero status: %d, logs: %s", status.StatusCode, logs.String()) } case err := <-wait.Error: return errors.Wrapf(err, "container unknown failure") } return nil } // RunContainerOption provides optional options for RunContainer. type RunContainerOption func(*runContainerConfig) type runContainerConfig struct { containerConfig *container.Config hostConfig *container.HostConfig networkConfig *network.NetworkingConfig stdin []byte } // RunWithCommand sets the command to run in the container. func RunWithCommand(cmd []string) RunContainerOption { return func(cfg *runContainerConfig) { cfg.containerConfig.Cmd = cmd } } // RunWithStdin provides data to write to the container's stdin. The container // is configured with OpenStdin and StdinOnce so it receives EOF after the data // is written. func RunWithStdin(data []byte) RunContainerOption { return func(cfg *runContainerConfig) { cfg.stdin = data cfg.containerConfig.OpenStdin = true cfg.containerConfig.StdinOnce = true cfg.containerConfig.AttachStdin = true } } // RunWithNetworkName connects the container to a Docker network by name. func RunWithNetworkName(name string) RunContainerOption { return func(cfg *runContainerConfig) { if cfg.networkConfig == nil { cfg.networkConfig = &network.NetworkingConfig{} } if cfg.networkConfig.EndpointsConfig == nil { cfg.networkConfig.EndpointsConfig = map[string]*network.EndpointSettings{} } cfg.networkConfig.EndpointsConfig[name] = &network.EndpointSettings{} } } // RunWithExtraHosts adds extra /etc/hosts entries to the container (e.g. // "host.docker.internal:host-gateway"). func RunWithExtraHosts(hosts []string) RunContainerOption { return func(cfg *runContainerConfig) { cfg.hostConfig.ExtraHosts = append(cfg.hostConfig.ExtraHosts, hosts...) } } // RunWithBindMount adds a bind mount to the container. func RunWithBindMount(hostPath, containerPath string) RunContainerOption { return func(cfg *runContainerConfig) { cfg.hostConfig.Binds = append(cfg.hostConfig.Binds, fmt.Sprintf("%s:%s", hostPath, containerPath)) } } // ContainerExitError is returned by RunContainer when the container exits with // a non-zero status. It carries the exit code so callers can branch on // well-known codes (e.g. the partial-output-on-fatal exit from // `crossplane internal render`) and the captured stderr so callers can surface // the failure details to users. Error() returns only the exit-code summary; // callers that want the stderr content in the error message should wrap with // errors.Wrapf using the Stderr field directly. type ContainerExitError struct { ExitCode int Stderr []byte } // Error implements error. func (e *ContainerExitError) Error() string { return fmt.Sprintf("container exited with status %d", e.ExitCode) } // RunContainer creates a container, optionally pipes stdin, waits for it to // exit, and returns stdout and stderr. The container is always removed on // return. This is intended for short-lived "run to completion" containers. // // On non-zero exit RunContainer returns the captured stdout, stderr, and a // *ContainerExitError carrying the exit code. Callers that need to recover a // partial stdout (e.g. exit code 3 from `crossplane internal render` per // crossplane/crossplane#7455) should errors.As against *ContainerExitError. func RunContainer(ctx context.Context, img string, opts ...RunContainerOption) ([]byte, []byte, error) { cfg := &runContainerConfig{ containerConfig: &container.Config{ Image: img, AttachStdout: true, AttachStderr: true, }, hostConfig: &container.HostConfig{}, } for _, opt := range opts { opt(cfg) } cli, err := NewClient() if err != nil { return nil, nil, err } // Pull the image if it's not already present. if _, err := cli.ImageInspect(ctx, img); err != nil { auth, authErr := defaultRegistryAuth(img) if authErr != nil { return nil, nil, authErr } out, pullErr := cli.ImagePull(ctx, img, client.ImagePullOptions{RegistryAuth: auth}) if pullErr != nil { return nil, nil, errors.Wrapf(pullErr, "failed to pull image %q", img) } if _, err := io.Copy(io.Discard, out); err != nil { return nil, nil, errors.Wrapf(err, "failed to read image pull output for %q", img) } } resp, err := cli.ContainerCreate(ctx, client.ContainerCreateOptions{ Config: cfg.containerConfig, HostConfig: cfg.hostConfig, NetworkingConfig: cfg.networkConfig, }) if err != nil { return nil, nil, errors.Wrap(err, "failed to create container") } defer func() { //nolint:contextcheck // Intentionally use a detached context for cleanup. _, _ = cli.ContainerRemove(context.Background(), resp.ID, client.ContainerRemoveOptions{Force: true}) }() // Attach before starting so we don't miss any output. Docker // multiplexes stdout/stderr with 8-byte frame headers when the // container is not using a TTY. attach, err := cli.ContainerAttach(ctx, resp.ID, client.ContainerAttachOptions{ Stream: true, Stdout: true, Stderr: true, Stdin: cfg.stdin != nil, }) if err != nil { return nil, nil, errors.Wrap(err, "failed to attach to container") } defer attach.Close() if _, err := cli.ContainerStart(ctx, resp.ID, client.ContainerStartOptions{}); err != nil { return nil, nil, errors.Wrap(err, "failed to start container") } // Write stdin data if provided, then close the write side so the // container sees EOF. if cfg.stdin != nil { if _, err := attach.Conn.Write(cfg.stdin); err != nil { return nil, nil, errors.Wrap(err, "failed to write to container stdin") } if err := attach.CloseWrite(); err != nil { return nil, nil, errors.Wrap(err, "failed to close container stdin") } } var stdout, stderr bytes.Buffer if _, err := stdcopy.StdCopy(&stdout, &stderr, attach.Reader); err != nil { return nil, nil, errors.Wrap(err, "failed to read container output") } // Wait for the container to finish. wait := cli.ContainerWait(ctx, resp.ID, client.ContainerWaitOptions{ Condition: container.WaitConditionNotRunning, }) select { case status := <-wait.Result: if status.StatusCode != 0 { return stdout.Bytes(), stderr.Bytes(), &ContainerExitError{ ExitCode: int(status.StatusCode), Stderr: stderr.Bytes(), } } case err := <-wait.Error: return nil, nil, errors.Wrap(err, "error waiting for container") } return stdout.Bytes(), stderr.Bytes(), nil } // CopyFromContainer copies files from a container to an afero filesystem. func CopyFromContainer(ctx context.Context, cid, basePath string, fs afero.Fs) error { cli, err := NewClient() if err != nil { return err } resp, err := cli.CopyFromContainer(ctx, cid, client.CopyFromContainerOptions{SourcePath: basePath}) if err != nil { return errors.Wrap(err, "failed to copy files from container") } defer func() { _ = resp.Content.Close() }() tarReader := tar.NewReader(resp.Content) // Limit files to 1GiB to avoid excessive memory usage. const maxFileSize = 1024 * 1024 * 1024 for { header, err := tarReader.Next() if errors.Is(err, io.EOF) { break } if err != nil { return errors.Wrap(err, "failed while reading tarball") } if header.Size > maxFileSize { return errors.Errorf("file %s is over 1GiB - refusing to copy", header.Name) } // Use path.Clean/path.Base rather than the filepath equivalents, since // tar files always use the Unix path separator. cleanedPath := path.Clean(strings.TrimPrefix(header.Name, path.Base(basePath)+"/")) if slices.Contains(strings.Split(cleanedPath, "/"), "..") { return errors.Errorf("file %s lives outside path %s - refusing to copy", cleanedPath, basePath) } switch header.Typeflag { case tar.TypeDir: if err := fs.MkdirAll(cleanedPath, 0o755); err != nil { return err } case tar.TypeReg: outFile, err := fs.Create(cleanedPath) if err != nil { return err } if _, err := io.Copy(outFile, tarReader); err != nil { //nolint:gosec // File size is checked above. if cerr := outFile.Close(); cerr != nil { err = errors.Wrap(cerr, "error while closing file") } return err } if cerr := outFile.Close(); cerr != nil { return errors.Wrapf(cerr, "error closing file %s", header.Name) } } } return nil } // TarFromContainer retrieves files from a container in a tarball (Docker's // native file transfer format). func TarFromContainer(ctx context.Context, cid, path string) ([]byte, error) { cli, err := NewClient() if err != nil { return nil, err } resp, err := cli.CopyFromContainer(ctx, cid, client.CopyFromContainerOptions{SourcePath: path}) if err != nil { return nil, errors.Wrap(err, "failed to copy files from container") } defer func() { _ = resp.Content.Close() }() return io.ReadAll(resp.Content) } // TarDirectory tars a directory. func TarDirectory(dir string, tarOptions archive.TarOptions) ([]byte, error) { rd, err := archive.TarWithOptions(dir, &tarOptions) // archive.TarResourceRebaseOpts(base, base)) if err != nil { return nil, errors.Wrapf(err, "failed to archive %s", dir) } defer rd.Close() //nolint:errcheck // Best-effort close after draining the archive. b, err := io.ReadAll(rd) if err != nil { return nil, errors.Wrap(err, "failed to read directory tarball") } return b, nil } // ConnectContainerToNetwork connects a container to a docker-network. // containerRef accepts a Docker container name or ID. // If the container is already connected, returns nil. func ConnectContainerToNetwork(ctx context.Context, containerRef, networkName string) error { cli, err := NewClient() if err != nil { return err } networkList, err := cli.NetworkList(ctx, client.NetworkListOptions{}) if err != nil { return errors.Wrap(err, "failed to list networks") } networkID := "" for _, network := range networkList.Items { if network.Name == networkName { networkID = network.ID } } if networkID == "" { return errors.Errorf("failed to find network ID for %q. Does it exist?", networkName) } inspectResult, err := cli.ContainerInspect(ctx, containerRef, client.ContainerInspectOptions{}) if err != nil { return errors.Wrapf(err, "failed to inspect container %q", containerRef) } if _, connected := inspectResult.Container.NetworkSettings.Networks[networkName]; !connected { _, err := cli.NetworkConnect(ctx, networkID, client.NetworkConnectOptions{ Container: inspectResult.Container.ID, }) if err != nil { return errors.Wrapf(err, "failed to connect container %q to %q", containerRef, networkName) } } return nil } // NewClient creates a new Docker client configured from environment variables. func NewClient() (*client.Client, error) { cli, err := client.New(client.FromEnv) if err != nil { return nil, errors.Wrap(err, "failed to create docker client") } return cli, nil }