// Package docker provides Docker client functionality. package docker import ( "context" "errors" "fmt" "io" "log/slog" "os" "path/filepath" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "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" "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 // stopTimeoutSeconds is the timeout for stopping containers. const stopTimeoutSeconds = 10 // 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") // 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 } // 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 Network string } // VolumeMount represents a volume mount. type VolumeMount struct { HostPath string ContainerPath string ReadOnly bool } // 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, }) } // Create container resp, err := c.docker.ContainerCreate(ctx, &container.Config{ Image: opts.Image, Env: envSlice, Labels: opts.Labels, }, &container.HostConfig{ Mounts: mounts, 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 } // cloneConfig holds configuration for a git clone operation. type cloneConfig struct { repoURL string branch string sshPrivateKey string destDir string keyFile string } // CloneRepo clones a git repository using SSH. func (c *Client) CloneRepo( ctx context.Context, repoURL, branch, sshPrivateKey, destDir string, ) error { if c.docker == nil { return ErrNotConnected } c.log.Info("cloning repository", "url", repoURL, "branch", branch, "dest", destDir) cfg := &cloneConfig{ repoURL: repoURL, branch: branch, sshPrivateKey: sshPrivateKey, destDir: destDir, keyFile: filepath.Join(destDir, ".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) } }() // Read build output (logs to stdout for now) _, err = io.Copy(os.Stdout, resp.Body) if err != nil { return "", fmt.Errorf("failed to read build output: %w", 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 } func (c *Client) performClone(ctx context.Context, cfg *cloneConfig) error { // Write SSH key to temp file err := os.WriteFile(cfg.keyFile, []byte(cfg.sshPrivateKey), sshKeyPermissions) if err != nil { return 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 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" resp, err := c.docker.ContainerCreate(ctx, &container.Config{ Image: "alpine/git:latest", Cmd: []string{ "clone", "--depth", "1", "--branch", cfg.branch, cfg.repoURL, "/repo", }, Env: []string{"GIT_SSH_COMMAND=" + gitSSHCmd}, WorkingDir: "/", }, &container.HostConfig{ Mounts: []mount.Mount{ {Type: mount.TypeBind, Source: cfg.destDir, Target: "/repo"}, { Type: mount.TypeBind, Source: cfg.keyFile, 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) error { err := c.docker.ContainerStart(ctx, containerID, container.StartOptions{}) if err != nil { return fmt.Errorf("failed to start git container: %w", err) } statusCh, errCh := c.docker.ContainerWait(ctx, containerID, container.WaitConditionNotRunning) select { case err := <-errCh: return fmt.Errorf("error waiting for git container: %w", err) case status := <-statusCh: if status.StatusCode != 0 { logs, _ := c.ContainerLogs(ctx, containerID, "100") return fmt.Errorf( "%w with status %d: %s", ErrGitCloneFailed, status.StatusCode, logs, ) } } return nil } 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 }