// Package docker provides Docker client functionality. package docker import ( "bufio" "context" "errors" "fmt" "io" "log/slog" "os" "path/filepath" "regexp" "strconv" "strings" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/network" "github.com/docker/docker/client" "github.com/docker/docker/pkg/archive" "github.com/docker/go-connections/nat" "go.uber.org/fx" "git.eeqj.de/sneak/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/logger" ) // sshKeyPermissions is the file permission for SSH private keys. const sshKeyPermissions = 0o600 // workDirPermissions is the file permission for the work directory. const workDirPermissions = 0o750 // stopTimeoutSeconds is the timeout for stopping containers. const stopTimeoutSeconds = 10 // gitImage is the Docker image used for git operations. // alpine/git v2.47.2 - pulled 2025-12-30 const gitImage = "alpine/git@sha256:d86f367afb53d022acc4377741e7334bc20add161bb10234272b91b459b4b7d8" // ErrNotConnected is returned when Docker client is not connected. var ErrNotConnected = errors.New("docker client not connected") // ErrGitCloneFailed is returned when git clone fails. var ErrGitCloneFailed = errors.New("git clone failed") // ErrInvalidBranch is returned when a branch name contains invalid characters. var ErrInvalidBranch = errors.New("invalid branch name") // ErrInvalidCommitSHA is returned when a commit SHA is not a valid hex string. var ErrInvalidCommitSHA = errors.New("invalid commit SHA") // validBranchRe matches safe git branch names. var validBranchRe = regexp.MustCompile(`^[a-zA-Z0-9._/\-]+$`) // validCommitSHARe matches a full-length hex commit SHA. var validCommitSHARe = regexp.MustCompile(`^[0-9a-f]{40}$`) // Params contains dependencies for Client. type Params struct { fx.In Logger *logger.Logger Config *config.Config } // Client wraps the Docker client. type Client struct { docker *client.Client log *slog.Logger params *Params } // New creates a new Docker Client. func New(lifecycle fx.Lifecycle, params Params) (*Client, error) { dockerClient := &Client{ log: params.Logger.Get(), params: ¶ms, } // For testing, if lifecycle is nil, skip connection (tests mock Docker) if lifecycle == nil { return dockerClient, nil } lifecycle.Append(fx.Hook{ OnStart: func(ctx context.Context) error { return dockerClient.connect(ctx) }, OnStop: func(_ context.Context) error { return dockerClient.close() }, }) return dockerClient, nil } // IsConnected returns true if the Docker client is connected. func (c *Client) IsConnected() bool { return c.docker != nil } // BuildImageOptions contains options for building an image. type BuildImageOptions struct { ContextDir string DockerfilePath string Tags []string LogWriter io.Writer // Optional writer for build output } // BuildImage builds a Docker image from a context directory. func (c *Client) BuildImage( ctx context.Context, opts BuildImageOptions, ) (string, error) { if c.docker == nil { return "", ErrNotConnected } c.log.Info( "building docker image", "context", opts.ContextDir, "dockerfile", opts.DockerfilePath, ) imageID, err := c.performBuild(ctx, opts) if err != nil { return "", err } return imageID, nil } // CreateContainerOptions contains options for creating a container. type CreateContainerOptions struct { Name string Image string Env map[string]string Labels map[string]string Volumes []VolumeMount Ports []PortMapping Network string } // VolumeMount represents a volume mount. type VolumeMount struct { HostPath string ContainerPath string ReadOnly bool } // PortMapping represents a port mapping. type PortMapping struct { HostPort int ContainerPort int Protocol string // "tcp" or "udp" } // buildPortConfig converts port mappings to Docker port configuration. func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) { exposedPorts := make(nat.PortSet) portBindings := make(nat.PortMap) for _, p := range ports { proto := p.Protocol if proto == "" { proto = "tcp" } containerPort := nat.Port(fmt.Sprintf("%d/%s", p.ContainerPort, proto)) exposedPorts[containerPort] = struct{}{} portBindings[containerPort] = []nat.PortBinding{ { HostIP: "0.0.0.0", HostPort: strconv.Itoa(p.HostPort), }, } } return exposedPorts, portBindings } // CreateContainer creates a new container. func (c *Client) CreateContainer( ctx context.Context, opts CreateContainerOptions, ) (string, error) { if c.docker == nil { return "", ErrNotConnected } c.log.Info("creating container", "name", opts.Name, "image", opts.Image) // Convert env map to slice envSlice := make([]string, 0, len(opts.Env)) for key, val := range opts.Env { envSlice = append(envSlice, key+"="+val) } // Convert volumes to mounts mounts := make([]mount.Mount, 0, len(opts.Volumes)) for _, vol := range opts.Volumes { mounts = append(mounts, mount.Mount{ Type: mount.TypeBind, Source: vol.HostPath, Target: vol.ContainerPath, ReadOnly: vol.ReadOnly, }) } // Convert ports to exposed ports and port bindings exposedPorts, portBindings := buildPortConfig(opts.Ports) // Create container resp, err := c.docker.ContainerCreate(ctx, &container.Config{ Image: opts.Image, Env: envSlice, Labels: opts.Labels, ExposedPorts: exposedPorts, }, &container.HostConfig{ Mounts: mounts, PortBindings: portBindings, NetworkMode: container.NetworkMode(opts.Network), RestartPolicy: container.RestartPolicy{ Name: container.RestartPolicyUnlessStopped, }, }, &network.NetworkingConfig{}, nil, opts.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 { if c.docker == nil { return ErrNotConnected } c.log.Info("starting container", "id", containerID) err := c.docker.ContainerStart(ctx, containerID, container.StartOptions{}) if err != nil { return fmt.Errorf("failed to start container: %w", err) } return nil } // StopContainer stops a container. func (c *Client) StopContainer(ctx context.Context, containerID string) error { if c.docker == nil { return ErrNotConnected } c.log.Info("stopping container", "id", containerID) timeout := stopTimeoutSeconds err := c.docker.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout}) if err != nil { return fmt.Errorf("failed to stop container: %w", err) } return nil } // RemoveContainer removes a container. func (c *Client) RemoveContainer( ctx context.Context, containerID string, force bool, ) error { if c.docker == nil { return ErrNotConnected } c.log.Info("removing container", "id", containerID, "force", force) err := c.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: force}) if err != nil { return fmt.Errorf("failed to remove container: %w", err) } return nil } // ContainerLogs returns the logs for a container. func (c *Client) ContainerLogs( ctx context.Context, containerID string, tail string, ) (string, error) { if c.docker == nil { return "", ErrNotConnected } opts := container.LogsOptions{ ShowStdout: true, ShowStderr: true, Tail: tail, } reader, err := c.docker.ContainerLogs(ctx, containerID, opts) if err != nil { return "", fmt.Errorf("failed to get container logs: %w", err) } defer func() { closeErr := reader.Close() if closeErr != nil { c.log.Error("failed to close log reader", "error", closeErr) } }() logs, err := io.ReadAll(reader) if err != nil { return "", fmt.Errorf("failed to read container logs: %w", err) } return string(logs), nil } // IsContainerRunning checks if a container is running. func (c *Client) IsContainerRunning( ctx context.Context, containerID string, ) (bool, error) { if c.docker == nil { return false, ErrNotConnected } inspect, err := c.docker.ContainerInspect(ctx, containerID) if err != nil { return false, fmt.Errorf("failed to inspect container: %w", err) } return inspect.State.Running, nil } // IsContainerHealthy checks if a container is healthy. func (c *Client) IsContainerHealthy( ctx context.Context, containerID string, ) (bool, error) { if c.docker == nil { return false, ErrNotConnected } inspect, err := c.docker.ContainerInspect(ctx, containerID) if err != nil { return false, fmt.Errorf("failed to inspect container: %w", err) } // If no health check defined, consider running as healthy if inspect.State.Health == nil { return inspect.State.Running, nil } return inspect.State.Health.Status == "healthy", nil } // LabelUpaasID is the Docker label key used to identify containers managed by upaas. const LabelUpaasID = "upaas.id" // ContainerInfo contains basic information about a container. type ContainerInfo struct { ID string Running bool } // FindContainerByAppID finds a container by the upaas.id label. // Returns nil if no container is found. // //nolint:nilnil // returning nil,nil is idiomatic for "not found" func (c *Client) FindContainerByAppID( ctx context.Context, appID string, ) (*ContainerInfo, error) { if c.docker == nil { return nil, ErrNotConnected } filterArgs := filters.NewArgs() filterArgs.Add("label", LabelUpaasID+"="+appID) containers, err := c.docker.ContainerList(ctx, container.ListOptions{ All: true, Filters: filterArgs, }) if err != nil { return nil, fmt.Errorf("failed to list containers: %w", err) } if len(containers) == 0 { return nil, nil } // Return the first matching container ctr := containers[0] return &ContainerInfo{ ID: ctr.ID, Running: ctr.State == "running", }, nil } // cloneConfig holds configuration for a git clone operation. type cloneConfig struct { repoURL string branch string commitSHA string // Optional: specific commit to checkout sshPrivateKey string containerDir string // Path inside the upaas container (for file operations) hostDir string // Path on the Docker host (for bind mounts) keyFile string // Container path to SSH key file hostKeyFile string // Host path to SSH key file } // CloneResult contains the result of a git clone operation. type CloneResult struct { Output string // Combined stdout/stderr from git clone CommitSHA string // The HEAD commit SHA after clone/checkout } // CloneRepo clones a git repository using SSH and optionally checks out a specific commit. // containerDir is the path inside the upaas container (for writing files). // hostDir is the corresponding path on the Docker host (for bind mounts). // If commitSHA is provided, that specific commit will be checked out. func (c *Client) CloneRepo( ctx context.Context, repoURL, branch, commitSHA, sshPrivateKey, containerDir, hostDir string, ) (*CloneResult, error) { // Validate inputs to prevent shell injection if !validBranchRe.MatchString(branch) { return nil, fmt.Errorf("%w: %q", ErrInvalidBranch, branch) } if commitSHA != "" && !validCommitSHARe.MatchString(commitSHA) { return nil, fmt.Errorf("%w: %q", ErrInvalidCommitSHA, commitSHA) } if c.docker == nil { return nil, ErrNotConnected } c.log.Info("cloning repository", "url", repoURL, "branch", branch, "commit", commitSHA, "containerDir", containerDir, "hostDir", hostDir, ) // Clone to 'work' subdirectory, SSH key stays in build directory root cfg := &cloneConfig{ repoURL: repoURL, branch: branch, commitSHA: commitSHA, sshPrivateKey: sshPrivateKey, containerDir: filepath.Join(containerDir, "work"), hostDir: filepath.Join(hostDir, "work"), keyFile: filepath.Join(containerDir, "deploy_key"), hostKeyFile: filepath.Join(hostDir, "deploy_key"), } return c.performClone(ctx, cfg) } func (c *Client) performBuild( ctx context.Context, opts BuildImageOptions, ) (string, error) { // Create tar archive of build context tarArchive, err := archive.TarWithOptions(opts.ContextDir, &archive.TarOptions{}) if err != nil { return "", fmt.Errorf("failed to create build context: %w", err) } defer func() { closeErr := tarArchive.Close() if closeErr != nil { c.log.Error("failed to close tar archive", "error", closeErr) } }() // Build image resp, err := c.docker.ImageBuild(ctx, tarArchive, types.ImageBuildOptions{ Dockerfile: opts.DockerfilePath, Tags: opts.Tags, Remove: true, NoCache: false, }) if err != nil { return "", fmt.Errorf("failed to build image: %w", err) } defer func() { closeErr := resp.Body.Close() if closeErr != nil { c.log.Error("failed to close response body", "error", closeErr) } }() // Stream build output line by line for real-time log updates err = c.streamBuildOutput(resp.Body, opts.LogWriter) if err != nil { return "", err } // Get image ID if len(opts.Tags) > 0 { inspect, _, inspectErr := c.docker.ImageInspectWithRaw(ctx, opts.Tags[0]) if inspectErr != nil { return "", fmt.Errorf("failed to inspect image: %w", inspectErr) } return inspect.ID, nil } return "", nil } // scannerInitialBufferSize is the initial buffer size for the build log scanner. const scannerInitialBufferSize = 64 * 1024 // 64KB // scannerMaxBufferSize is the max buffer size for build log lines (base64 layers can be large). const scannerMaxBufferSize = 1024 * 1024 // 1MB // streamBuildOutput reads Docker build output line by line and writes to stdout and optional log writer. // Docker sends newline-delimited JSON, so reading line by line ensures each log entry is written immediately. func (c *Client) streamBuildOutput(body io.Reader, logWriter io.Writer) error { scanner := bufio.NewScanner(body) buf := make([]byte, 0, scannerInitialBufferSize) scanner.Buffer(buf, scannerMaxBufferSize) newline := []byte{'\n'} for scanner.Scan() { line := scanner.Bytes() // Write to stdout _, _ = os.Stdout.Write(line) _, _ = os.Stdout.Write(newline) // Write to log writer if provided if logWriter != nil { _, _ = logWriter.Write(line) _, _ = logWriter.Write(newline) } } scanErr := scanner.Err() if scanErr != nil { return fmt.Errorf("failed to read build output: %w", scanErr) } return nil } func (c *Client) performClone(ctx context.Context, cfg *cloneConfig) (*CloneResult, error) { // Create work directory for clone destination err := os.MkdirAll(cfg.containerDir, workDirPermissions) if err != nil { return nil, fmt.Errorf("failed to create work dir: %w", err) } // Write SSH key to temp file err = os.WriteFile(cfg.keyFile, []byte(cfg.sshPrivateKey), sshKeyPermissions) if err != nil { return nil, fmt.Errorf("failed to write SSH key: %w", err) } defer func() { removeErr := os.Remove(cfg.keyFile) if removeErr != nil { c.log.Error("failed to remove SSH key file", "error", removeErr) } }() containerID, err := c.createGitContainer(ctx, cfg) if err != nil { return nil, err } defer func() { _ = c.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: true}) }() return c.runGitClone(ctx, containerID) } func (c *Client) createGitContainer( ctx context.Context, cfg *cloneConfig, ) (string, error) { gitSSHCmd := "ssh -i /keys/deploy_key -o StrictHostKeyChecking=no" // Build the git command using environment variables to avoid shell injection. // Arguments are passed via env vars and quoted in the shell script. var script string if cfg.commitSHA != "" { // Clone without depth limit so we can checkout any commit, then checkout specific SHA script = `git clone --branch "$CLONE_BRANCH" "$CLONE_URL" /repo` + ` && cd /repo && git checkout "$CLONE_SHA"` + ` && echo COMMIT:$(git rev-parse HEAD)` } else { // Shallow clone of branch HEAD, then output commit SHA script = `git clone --depth 1 --branch "$CLONE_BRANCH" "$CLONE_URL" /repo` + ` && cd /repo && echo COMMIT:$(git rev-parse HEAD)` } env := []string{ "GIT_SSH_COMMAND=" + gitSSHCmd, "CLONE_URL=" + cfg.repoURL, "CLONE_BRANCH=" + cfg.branch, } if cfg.commitSHA != "" { env = append(env, "CLONE_SHA="+cfg.commitSHA) } entrypoint := []string{} cmd := []string{"sh", "-c", script} // Use host paths for Docker bind mounts (Docker runs on the host, not in our container) resp, err := c.docker.ContainerCreate(ctx, &container.Config{ Image: gitImage, Entrypoint: entrypoint, Cmd: cmd, Env: env, WorkingDir: "/", }, &container.HostConfig{ Mounts: []mount.Mount{ {Type: mount.TypeBind, Source: cfg.hostDir, Target: "/repo"}, { Type: mount.TypeBind, Source: cfg.hostKeyFile, Target: "/keys/deploy_key", ReadOnly: true, }, }, }, nil, nil, "", ) if err != nil { return "", fmt.Errorf("failed to create git container: %w", err) } return resp.ID, nil } func (c *Client) runGitClone(ctx context.Context, containerID string) (*CloneResult, error) { err := c.docker.ContainerStart(ctx, containerID, container.StartOptions{}) if err != nil { return nil, fmt.Errorf("failed to start git container: %w", err) } statusCh, errCh := c.docker.ContainerWait(ctx, containerID, container.WaitConditionNotRunning) select { case err := <-errCh: return nil, fmt.Errorf("error waiting for git container: %w", err) case status := <-statusCh: // Always capture logs for the result logs, _ := c.ContainerLogs(ctx, containerID, "100") if status.StatusCode != 0 { return nil, fmt.Errorf( "%w with status %d: %s", ErrGitCloneFailed, status.StatusCode, logs, ) } // Parse commit SHA from output (looks for "COMMIT:" line) commitSHA := parseCommitSHA(logs) return &CloneResult{Output: logs, CommitSHA: commitSHA}, nil } } // commitMarker is the prefix used to identify commit SHA in clone output. const commitMarker = "COMMIT:" // parseCommitSHA extracts the commit SHA from git clone output. // It looks for a line starting with "COMMIT:" and returns the SHA after it. func parseCommitSHA(output string) string { for line := range strings.SplitSeq(output, "\n") { line = strings.TrimSpace(line) sha, found := strings.CutPrefix(line, commitMarker) if found { return strings.TrimSpace(sha) } } return "" } func (c *Client) connect(ctx context.Context) error { opts := []client.Opt{ client.FromEnv, client.WithAPIVersionNegotiation(), } if c.params.Config.DockerHost != "" { opts = append(opts, client.WithHost(c.params.Config.DockerHost)) } docker, err := client.NewClientWithOpts(opts...) if err != nil { return fmt.Errorf("failed to create Docker client: %w", err) } // Test connection _, err = docker.Ping(ctx) if err != nil { return fmt.Errorf("failed to ping Docker: %w", err) } c.docker = docker c.log.Info("docker client connected") return nil } func (c *Client) close() error { if c.docker != nil { err := c.docker.Close() if err != nil { return fmt.Errorf("failed to close docker client: %w", err) } } return nil }