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
This commit is contained in:
523
internal/docker/client.go
Normal file
523
internal/docker/client.go
Normal file
@@ -0,0 +1,523 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user