791cd4d6af
Build / build (push) Successful in 12m20s
Rebrand the project as Tinyforge to reflect its evolution from a Docker container watcher into a self-hosted mini CI/deployment platform. Rename covers: Go module path, Docker labels, DB/config filenames, JWT issuer, Dockerfile binary, docker-compose, CI workflows, frontend i18n, README with static sites docs, and all code comments.
341 lines
10 KiB
Go
341 lines
10 KiB
Go
package docker
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/moby/moby/api/types/container"
|
|
"github.com/moby/moby/api/types/mount"
|
|
"github.com/moby/moby/api/types/network"
|
|
"github.com/moby/moby/client"
|
|
)
|
|
|
|
// ContainerConfig holds all parameters needed to create a managed container.
|
|
type ContainerConfig struct {
|
|
// Name is the container name (deterministic: dw-{project}-{stage}-{tag}).
|
|
Name string
|
|
|
|
// Image is the full image reference including tag (e.g. "myapp:v1.2.3").
|
|
Image string
|
|
|
|
// Env is a list of environment variables in "KEY=VALUE" format.
|
|
Env []string
|
|
|
|
// ExposedPorts lists the container ports to publish (e.g. ["8080/tcp"]).
|
|
// Each port is mapped to a random host port via Docker auto-assignment.
|
|
ExposedPorts []string
|
|
|
|
// NetworkName is the Docker network to attach the container to at creation.
|
|
NetworkName string
|
|
|
|
// NetworkID is the ID of the Docker network (used for endpoint config).
|
|
NetworkID string
|
|
|
|
// Labels are additional labels to apply to the container.
|
|
// Tinyforge management labels are added automatically via Project, Stage, and InstanceID.
|
|
Labels map[string]string
|
|
|
|
// Project is the Tinyforge project name (used for labelling).
|
|
Project string
|
|
|
|
// Stage is the Tinyforge stage name (used for labelling).
|
|
Stage string
|
|
|
|
// InstanceID is the Tinyforge instance ID (used for labelling).
|
|
InstanceID string
|
|
|
|
// Mounts is a list of bind mounts to attach to the container.
|
|
Mounts []mount.Mount
|
|
|
|
// CpuLimit is the CPU limit in cores (e.g., 0.5, 1, 2). 0 = unlimited.
|
|
CpuLimit float64
|
|
|
|
// MemoryLimit is the memory limit in megabytes. 0 = unlimited.
|
|
MemoryLimit int
|
|
}
|
|
|
|
// sanitizeTag replaces characters that are invalid in Docker container names
|
|
// with hyphens and lowercases the result.
|
|
var invalidNameChars = regexp.MustCompile(`[^a-zA-Z0-9_.-]`)
|
|
|
|
// ContainerName builds a deterministic container name from project, stage, and tag.
|
|
func ContainerName(project, stage, tag string) string {
|
|
sanitizeComponent := func(s string) string {
|
|
s = invalidNameChars.ReplaceAllString(s, "-")
|
|
return strings.Trim(s, "-")
|
|
}
|
|
return fmt.Sprintf("dw-%s-%s-%s", sanitizeComponent(project), sanitizeComponent(stage), sanitizeComponent(tag))
|
|
}
|
|
|
|
// CreateContainer creates a new container with the given configuration.
|
|
// It returns the container ID on success.
|
|
func (c *Client) CreateContainer(ctx context.Context, cfg ContainerConfig) (string, error) {
|
|
// Build port bindings: each exposed port maps to a random host port.
|
|
exposedPorts := network.PortSet{}
|
|
portBindings := network.PortMap{}
|
|
for _, p := range cfg.ExposedPorts {
|
|
port, err := network.ParsePort(p)
|
|
if err != nil {
|
|
return "", fmt.Errorf("parse port %s: %w", p, err)
|
|
}
|
|
exposedPorts[port] = struct{}{}
|
|
portBindings[port] = []network.PortBinding{
|
|
{HostPort: ""}, // empty HostPort = auto-assign
|
|
}
|
|
}
|
|
|
|
// Merge Tinyforge labels with any additional labels.
|
|
labels := make(map[string]string)
|
|
for k, v := range cfg.Labels {
|
|
labels[k] = v
|
|
}
|
|
labels[LabelProject] = cfg.Project
|
|
labels[LabelStage] = cfg.Stage
|
|
labels[LabelInstanceID] = cfg.InstanceID
|
|
|
|
containerCfg := &container.Config{
|
|
Image: cfg.Image,
|
|
Env: cfg.Env,
|
|
ExposedPorts: exposedPorts,
|
|
Labels: labels,
|
|
}
|
|
|
|
hostCfg := &container.HostConfig{
|
|
PortBindings: portBindings,
|
|
RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyDisabled},
|
|
Mounts: cfg.Mounts,
|
|
Resources: containerResources(cfg.CpuLimit, cfg.MemoryLimit),
|
|
}
|
|
|
|
// Attach to network at creation time if specified.
|
|
var networkCfg *network.NetworkingConfig
|
|
if cfg.NetworkName != "" {
|
|
networkCfg = &network.NetworkingConfig{
|
|
EndpointsConfig: map[string]*network.EndpointSettings{
|
|
cfg.NetworkName: {
|
|
NetworkID: cfg.NetworkID,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
resp, err := c.api.ContainerCreate(ctx, client.ContainerCreateOptions{
|
|
Config: containerCfg,
|
|
HostConfig: hostCfg,
|
|
NetworkingConfig: networkCfg,
|
|
Name: cfg.Name,
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("create container %s: %w", cfg.Name, err)
|
|
}
|
|
|
|
return resp.ID, nil
|
|
}
|
|
|
|
// containerResources builds Docker resource constraints from CPU cores and memory MB.
|
|
func containerResources(cpuLimit float64, memoryLimitMB int) container.Resources {
|
|
r := container.Resources{}
|
|
if cpuLimit > 0 {
|
|
// NanoCPUs is in units of 1e-9 CPUs. 1 core = 1e9 nanoCPUs.
|
|
r.NanoCPUs = int64(cpuLimit * 1e9)
|
|
}
|
|
if memoryLimitMB > 0 {
|
|
r.Memory = int64(memoryLimitMB) * 1024 * 1024
|
|
}
|
|
return r
|
|
}
|
|
|
|
// StartContainer starts a stopped container.
|
|
func (c *Client) StartContainer(ctx context.Context, containerID string) error {
|
|
if _, err := c.api.ContainerStart(ctx, containerID, client.ContainerStartOptions{}); err != nil {
|
|
return fmt.Errorf("start container %s: %w", containerID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// StopContainer gracefully stops a running container with the given timeout in seconds.
|
|
// A nil timeout uses the Docker default (10 seconds).
|
|
func (c *Client) StopContainer(ctx context.Context, containerID string, timeoutSeconds int) error {
|
|
opts := client.ContainerStopOptions{}
|
|
if timeoutSeconds > 0 {
|
|
opts.Timeout = &timeoutSeconds
|
|
}
|
|
|
|
if _, err := c.api.ContainerStop(ctx, containerID, opts); err != nil {
|
|
return fmt.Errorf("stop container %s: %w", containerID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RemoveContainer removes a container. If force is true, a running container
|
|
// will be killed before removal.
|
|
func (c *Client) RemoveContainer(ctx context.Context, containerID string, force bool) error {
|
|
opts := client.ContainerRemoveOptions{
|
|
Force: force,
|
|
RemoveVolumes: true,
|
|
}
|
|
|
|
if _, err := c.api.ContainerRemove(ctx, containerID, opts); err != nil {
|
|
return fmt.Errorf("remove container %s: %w", containerID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RestartContainer restarts a container with the given timeout in seconds.
|
|
func (c *Client) RestartContainer(ctx context.Context, containerID string, timeoutSeconds int) error {
|
|
opts := client.ContainerRestartOptions{}
|
|
if timeoutSeconds > 0 {
|
|
opts.Timeout = &timeoutSeconds
|
|
}
|
|
|
|
if _, err := c.api.ContainerRestart(ctx, containerID, opts); err != nil {
|
|
return fmt.Errorf("restart container %s: %w", containerID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ManagedContainer holds summary information about a container managed by Tinyforge.
|
|
type ManagedContainer struct {
|
|
ID string
|
|
Name string
|
|
Image string
|
|
Status string
|
|
State string
|
|
Project string
|
|
Stage string
|
|
InstanceID string
|
|
Ports []uint16
|
|
}
|
|
|
|
// ListContainers returns all containers matching the given label filters.
|
|
// Pass nil or an empty map to list all Tinyforge managed containers.
|
|
// Label filters are key=value pairs applied as Docker label filters.
|
|
func (c *Client) ListContainers(ctx context.Context, labelFilters map[string]string) ([]ManagedContainer, error) {
|
|
filterArgs := make(client.Filters)
|
|
|
|
// Always filter by the Tinyforge project label to only return managed containers.
|
|
filterArgs.Add("label", LabelProject)
|
|
|
|
for k, v := range labelFilters {
|
|
if v != "" {
|
|
filterArgs.Add("label", k+"="+v)
|
|
} else {
|
|
filterArgs.Add("label", k)
|
|
}
|
|
}
|
|
|
|
listResult, err := c.api.ContainerList(ctx, client.ContainerListOptions{
|
|
All: true,
|
|
Filters: filterArgs,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list containers: %w", err)
|
|
}
|
|
|
|
result := make([]ManagedContainer, 0, len(listResult.Items))
|
|
for _, ctr := range listResult.Items {
|
|
name := ""
|
|
if len(ctr.Names) > 0 {
|
|
// Docker prefixes names with "/".
|
|
name = strings.TrimPrefix(ctr.Names[0], "/")
|
|
}
|
|
|
|
var ports []uint16
|
|
for _, p := range ctr.Ports {
|
|
if p.PublicPort > 0 {
|
|
ports = append(ports, p.PublicPort)
|
|
}
|
|
}
|
|
|
|
result = append(result, ManagedContainer{
|
|
ID: ctr.ID,
|
|
Name: name,
|
|
Image: ctr.Image,
|
|
Status: ctr.Status,
|
|
State: string(ctr.State),
|
|
Project: ctr.Labels[LabelProject],
|
|
Stage: ctr.Labels[LabelStage],
|
|
InstanceID: ctr.Labels[LabelInstanceID],
|
|
Ports: ports,
|
|
})
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// ContainerLogs returns a log stream for a container.
|
|
// If follow is true, the stream stays open for new log lines.
|
|
// tail specifies the number of lines from the end to return (e.g., "200").
|
|
func (c *Client) ContainerLogs(ctx context.Context, containerID string, follow bool, tail string) (io.ReadCloser, error) {
|
|
result, err := c.api.ContainerLogs(ctx, containerID, client.ContainerLogsOptions{
|
|
ShowStdout: true,
|
|
ShowStderr: true,
|
|
Follow: follow,
|
|
Tail: tail,
|
|
Timestamps: true,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("container logs %s: %w", containerID, err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// IsContainerRunning checks if a container is in the "running" state.
|
|
func (c *Client) IsContainerRunning(ctx context.Context, containerID string) (bool, error) {
|
|
inspectResult, err := c.api.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return inspectResult.Container.State != nil && inspectResult.Container.State.Running, nil
|
|
}
|
|
|
|
// InspectContainerPort returns the host port mapped to a given container port.
|
|
// This is useful after starting a container with auto-assigned ports.
|
|
func (c *Client) InspectContainerPort(ctx context.Context, containerID string, containerPort string) (uint16, error) {
|
|
inspectResult, err := c.api.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
|
|
if err != nil {
|
|
return 0, fmt.Errorf("inspect container %s: %w", containerID, err)
|
|
}
|
|
inspect := inspectResult.Container
|
|
|
|
port, err := network.ParsePort(containerPort)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("parse container port %s: %w", containerPort, err)
|
|
}
|
|
|
|
bindings, ok := inspect.NetworkSettings.Ports[port]
|
|
if !ok || len(bindings) == 0 {
|
|
return 0, fmt.Errorf("container %s: no binding for port %s", containerID, containerPort)
|
|
}
|
|
|
|
var hostPort uint16
|
|
for _, b := range bindings {
|
|
if b.HostPort != "" {
|
|
parsed := parsePort(b.HostPort)
|
|
if parsed > 0 {
|
|
hostPort = parsed
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if hostPort == 0 {
|
|
return 0, fmt.Errorf("container %s: no host port for %s", containerID, containerPort)
|
|
}
|
|
|
|
return hostPort, nil
|
|
}
|
|
|
|
// parsePort converts a port string to uint16. Returns 0 on failure.
|
|
func parsePort(s string) uint16 {
|
|
n, err := strconv.ParseUint(s, 10, 16)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return uint16(n)
|
|
}
|