Add String() methods to ImageID, ContainerID, and UnparsedURL. Replace all string() casts with .String() method calls throughout the codebase for cleaner interface compliance.
767 lines
20 KiB
Go
767 lines
20 KiB
Go
// Package docker provides Docker client functionality.
|
|
package docker
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
dockertypes "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/image"
|
|
"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,
|
|
) (ImageID, 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,
|
|
) (ContainerID, 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 ContainerID(resp.ID), nil
|
|
}
|
|
|
|
// StartContainer starts a container.
|
|
func (c *Client) StartContainer(ctx context.Context, containerID ContainerID) error {
|
|
if c.docker == nil {
|
|
return ErrNotConnected
|
|
}
|
|
|
|
c.log.Info("starting container", "id", containerID)
|
|
|
|
err := c.docker.ContainerStart(ctx, containerID.String(), 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 ContainerID) error {
|
|
if c.docker == nil {
|
|
return ErrNotConnected
|
|
}
|
|
|
|
c.log.Info("stopping container", "id", containerID)
|
|
|
|
timeout := stopTimeoutSeconds
|
|
|
|
err := c.docker.ContainerStop(ctx, containerID.String(), 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 ContainerID,
|
|
force bool,
|
|
) error {
|
|
if c.docker == nil {
|
|
return ErrNotConnected
|
|
}
|
|
|
|
c.log.Info("removing container", "id", containerID, "force", force)
|
|
|
|
err := c.docker.ContainerRemove(ctx, containerID.String(), 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 ContainerID,
|
|
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.String(), 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 ContainerID,
|
|
) (bool, error) {
|
|
if c.docker == nil {
|
|
return false, ErrNotConnected
|
|
}
|
|
|
|
inspect, err := c.docker.ContainerInspect(ctx, containerID.String())
|
|
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 ContainerID,
|
|
) (bool, error) {
|
|
if c.docker == nil {
|
|
return false, ErrNotConnected
|
|
}
|
|
|
|
inspect, err := c.docker.ContainerInspect(ctx, containerID.String())
|
|
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 ContainerID
|
|
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: ContainerID(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)
|
|
}
|
|
|
|
// RemoveImage removes a Docker image by ID or tag.
|
|
// It returns nil if the image was successfully removed or does not exist.
|
|
func (c *Client) RemoveImage(ctx context.Context, imageID ImageID) error {
|
|
_, err := c.docker.ImageRemove(ctx, imageID.String(), image.RemoveOptions{
|
|
Force: true,
|
|
PruneChildren: true,
|
|
})
|
|
if err != nil && !client.IsErrNotFound(err) {
|
|
return fmt.Errorf("failed to remove image %s: %w", imageID, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) performBuild(
|
|
ctx context.Context,
|
|
opts BuildImageOptions,
|
|
) (ImageID, 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, dockertypes.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 ImageID(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)
|
|
}
|
|
}()
|
|
|
|
gitContainerID, err := c.createGitContainer(ctx, cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
defer func() {
|
|
_ = c.docker.ContainerRemove(ctx, gitContainerID.String(), container.RemoveOptions{Force: true})
|
|
}()
|
|
|
|
return c.runGitClone(ctx, gitContainerID)
|
|
}
|
|
|
|
func (c *Client) createGitContainer(
|
|
ctx context.Context,
|
|
cfg *cloneConfig,
|
|
) (ContainerID, 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 ContainerID(resp.ID), nil
|
|
}
|
|
|
|
func (c *Client) runGitClone(ctx context.Context, containerID ContainerID) (*CloneResult, error) {
|
|
err := c.docker.ContainerStart(ctx, containerID.String(), container.StartOptions{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to start git container: %w", err)
|
|
}
|
|
|
|
statusCh, errCh := c.docker.ContainerWait(ctx, containerID.String(), 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:<sha>" 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
|
|
}
|