upaas/internal/docker/client.go
sneak ab7e917b03 Add real-time deployment updates and refactor JavaScript
- Add deploy stats (last deploy time, total count) to dashboard
- Add recent-deployments API endpoint for real-time updates
- Add live build logs to deployments history page
- Fix git clone regression (preserve entrypoint for simple clones)
- Refactor JavaScript into shared app.js with page init functions
- Deploy button disables immediately on click
- Auto-refresh deployment list and logs during builds
- Format JavaScript with Prettier (4-space indent)
2026-01-01 05:22:56 +07:00

670 lines
16 KiB
Go

// Package docker provides Docker client functionality.
package docker
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strconv"
"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")
// 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: &params,
}
// 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
}
// 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) {
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)
}
}()
// Read build output - write to stdout and optional log writer
var output io.Writer = os.Stdout
if opts.LogWriter != nil {
output = io.MultiWriter(os.Stdout, opts.LogWriter)
}
_, err = io.Copy(output, 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) (*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 based on whether we have a specific commit SHA
var cmd []string
var entrypoint []string
if cfg.commitSHA != "" {
// Clone without depth limit so we can checkout any commit, then checkout specific SHA
// Using sh -c to run multiple commands - need to clear entrypoint
script := fmt.Sprintf(
"git clone --branch %s %s /repo && cd /repo && git checkout %s",
cfg.branch, cfg.repoURL, cfg.commitSHA,
)
entrypoint = []string{}
cmd = []string{"sh", "-c", script}
} else {
// Shallow clone of branch HEAD - use default git entrypoint
entrypoint = nil
cmd = []string{"clone", "--depth", "1", "--branch", cfg.branch, cfg.repoURL, "/repo"}
}
// 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: []string{"GIT_SSH_COMMAND=" + gitSSHCmd},
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,
)
}
return &CloneResult{Output: logs}, 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
}