- Clone specific commit SHA from webhook instead of just branch HEAD - Log webhook payload in deployment logs - Add build/deploy timing to ntfy and Slack notifications - Implement container rollback on deploy failure - Remove old container only after successful deployment - Show relative times in deployment history (hover for full date) - Update port mappings UI with labeled text inputs - Add footer with version info, license, and repo link - Format deploy key comment as upaas_DATE_appname
666 lines
16 KiB
Go
666 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: ¶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
|
|
}
|
|
|
|
// 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
|
|
|
|
if cfg.commitSHA != "" {
|
|
// Clone without depth limit so we can checkout any commit, then checkout specific SHA
|
|
// Using sh -c to run multiple commands
|
|
script := fmt.Sprintf(
|
|
"git clone --branch %s %s /repo && cd /repo && git checkout %s",
|
|
cfg.branch, cfg.repoURL, cfg.commitSHA,
|
|
)
|
|
cmd = []string{"sh", "-c", script}
|
|
} else {
|
|
// Shallow clone of branch HEAD
|
|
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: []string{}, // Clear entrypoint when using sh -c
|
|
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
|
|
}
|