upaas/internal/docker/client.go
sneak 3f9d83c436 Initial commit with server startup infrastructure
Core infrastructure:
- Uber fx dependency injection
- Chi router with middleware stack
- SQLite database with embedded migrations
- Embedded templates and static assets
- Structured logging with slog

Features implemented:
- Authentication (login, logout, session management, argon2id hashing)
- App management (create, edit, delete, list)
- Deployment pipeline (clone, build, deploy, health check)
- Webhook processing for Gitea
- Notifications (ntfy, Slack)
- Environment variables, labels, volumes per app
- SSH key generation for deploy keys

Server startup:
- Server.Run() starts HTTP server on configured port
- Server.Shutdown() for graceful shutdown
- SetupRoutes() wires all handlers with chi router
2025-12-29 15:46:03 +07:00

524 lines
12 KiB
Go

// 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: &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
}
// 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
}