- Replace UUID with ULID for app ID generation (lexicographically sortable) - Remove container_id column from apps table (migration 002) - Add upaas.id Docker label to identify containers by app ID - Implement FindContainerByAppID in Docker client to query by label - Update handlers and deploy service to use label-based container lookup - Show system-managed upaas.id label in UI with editing disabled Container association is now determined dynamically via Docker label rather than stored in the database, making the system more resilient to container recreation or external changes.
570 lines
13 KiB
Go
570 lines
13 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/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"
|
|
"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
|
|
}
|
|
|
|
// 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
|
|
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
|
|
}
|