feat(docker-watcher): phases 3+4 - Docker client & NPM client
Phase 3: Docker Engine API wrapper — pull/inspect images, container lifecycle (create/start/stop/remove/restart), network management, label-based container tracking, deterministic naming. Phase 4: Nginx Proxy Manager API client — JWT auth with auto-refresh, CRUD for proxy hosts, domain-based host lookup.
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
// Labels applied to all containers managed by docker-watcher.
|
||||
const (
|
||||
LabelProject = "docker-watcher.project"
|
||||
LabelStage = "docker-watcher.stage"
|
||||
LabelInstanceID = "docker-watcher.instance-id"
|
||||
)
|
||||
|
||||
// Client wraps the Docker Engine API client.
|
||||
type Client struct {
|
||||
api client.APIClient
|
||||
}
|
||||
|
||||
// New creates a new Docker client connected to the default Docker socket.
|
||||
func New() (*Client, error) {
|
||||
api, err := client.NewClientWithOpts(
|
||||
client.FromEnv,
|
||||
client.WithAPIVersionNegotiation(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create docker client: %w", err)
|
||||
}
|
||||
|
||||
return &Client{api: api}, nil
|
||||
}
|
||||
|
||||
// Close releases resources held by the Docker client.
|
||||
func (c *Client) Close() error {
|
||||
if err := c.api.Close(); err != nil {
|
||||
return fmt.Errorf("close docker client: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ping checks connectivity to the Docker daemon.
|
||||
func (c *Client) Ping(ctx context.Context) error {
|
||||
_, err := c.api.Ping(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ping docker daemon: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/go-connections/nat"
|
||||
)
|
||||
|
||||
// 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.
|
||||
// docker-watcher management labels are added automatically via Project, Stage, and InstanceID.
|
||||
Labels map[string]string
|
||||
|
||||
// Project is the docker-watcher project name (used for labelling).
|
||||
Project string
|
||||
|
||||
// Stage is the docker-watcher stage name (used for labelling).
|
||||
Stage string
|
||||
|
||||
// InstanceID is the docker-watcher instance ID (used for labelling).
|
||||
InstanceID string
|
||||
}
|
||||
|
||||
// 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 := nat.PortSet{}
|
||||
portBindings := nat.PortMap{}
|
||||
for _, p := range cfg.ExposedPorts {
|
||||
port := nat.Port(p)
|
||||
exposedPorts[port] = struct{}{}
|
||||
portBindings[port] = []nat.PortBinding{
|
||||
{HostIP: "0.0.0.0", HostPort: ""}, // empty HostPort = auto-assign
|
||||
}
|
||||
}
|
||||
|
||||
// Merge docker-watcher 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},
|
||||
}
|
||||
|
||||
// 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, containerCfg, hostCfg, networkCfg, nil, cfg.Name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create container %s: %w", cfg.Name, err)
|
||||
}
|
||||
|
||||
return resp.ID, nil
|
||||
}
|
||||
|
||||
// StartContainer starts a stopped container.
|
||||
func (c *Client) StartContainer(ctx context.Context, containerID string) error {
|
||||
if err := c.api.ContainerStart(ctx, containerID, container.StartOptions{}); 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 := container.StopOptions{}
|
||||
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 := container.RemoveOptions{
|
||||
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 := container.StopOptions{}
|
||||
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 docker-watcher.
|
||||
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 docker-watcher 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 := filters.NewArgs()
|
||||
|
||||
// Always filter by the docker-watcher 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)
|
||||
}
|
||||
}
|
||||
|
||||
containers, err := c.api.ContainerList(ctx, container.ListOptions{
|
||||
All: true,
|
||||
Filters: filterArgs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list containers: %w", err)
|
||||
}
|
||||
|
||||
result := make([]ManagedContainer, 0, len(containers))
|
||||
for _, ctr := range containers {
|
||||
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: ctr.State,
|
||||
Project: ctr.Labels[LabelProject],
|
||||
Stage: ctr.Labels[LabelStage],
|
||||
InstanceID: ctr.Labels[LabelInstanceID],
|
||||
Ports: ports,
|
||||
})
|
||||
}
|
||||
|
||||
return result, 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) {
|
||||
inspect, err := c.api.ContainerInspect(ctx, containerID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("inspect container %s: %w", containerID, err)
|
||||
}
|
||||
|
||||
port := nat.Port(containerPort)
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/api/types/registry"
|
||||
)
|
||||
|
||||
// ImageInfo holds metadata extracted from a Docker image inspection.
|
||||
type ImageInfo struct {
|
||||
// ExposedPorts lists the ports declared via EXPOSE in the Dockerfile (e.g. ["8080/tcp"]).
|
||||
ExposedPorts []string
|
||||
|
||||
// Healthcheck is the CMD string from the image's HEALTHCHECK instruction, if any.
|
||||
Healthcheck string
|
||||
|
||||
// Labels are the key-value pairs defined in the image metadata.
|
||||
Labels map[string]string
|
||||
}
|
||||
|
||||
// PullImage pulls an image from a registry. If authConfig is non-empty, it is
|
||||
// used as the base64-encoded JSON auth payload for private registries.
|
||||
// The image reference should be in the form "repository:tag".
|
||||
func (c *Client) PullImage(ctx context.Context, imageRef string, tag string, authConfig string) error {
|
||||
ref := imageRef
|
||||
if tag != "" {
|
||||
ref = imageRef + ":" + tag
|
||||
}
|
||||
|
||||
opts := image.PullOptions{}
|
||||
if authConfig != "" {
|
||||
opts.RegistryAuth = authConfig
|
||||
}
|
||||
|
||||
reader, err := c.api.ImagePull(ctx, ref, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pull image %s: %w", ref, err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
// Drain the pull progress stream to completion.
|
||||
if _, err := io.Copy(io.Discard, reader); err != nil {
|
||||
return fmt.Errorf("read pull response for %s: %w", ref, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// InspectImage retrieves metadata from a local image.
|
||||
func (c *Client) InspectImage(ctx context.Context, imageRef string) (ImageInfo, error) {
|
||||
inspect, _, err := c.api.ImageInspectWithRaw(ctx, imageRef)
|
||||
if err != nil {
|
||||
return ImageInfo{}, fmt.Errorf("inspect image %s: %w", imageRef, err)
|
||||
}
|
||||
|
||||
info := ImageInfo{
|
||||
Labels: inspect.Config.Labels,
|
||||
}
|
||||
|
||||
// Extract exposed ports.
|
||||
for port := range inspect.Config.ExposedPorts {
|
||||
info.ExposedPorts = append(info.ExposedPorts, string(port))
|
||||
}
|
||||
|
||||
// Extract healthcheck command.
|
||||
if inspect.Config.Healthcheck != nil && len(inspect.Config.Healthcheck.Test) > 0 {
|
||||
// The Test slice is ["CMD", "arg1", "arg2", ...] or ["CMD-SHELL", "cmd string"].
|
||||
// Join all parts after the first element for a readable representation.
|
||||
if len(inspect.Config.Healthcheck.Test) > 1 {
|
||||
info.Healthcheck = joinArgs(inspect.Config.Healthcheck.Test[1:])
|
||||
}
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// EncodeRegistryAuth builds a base64-encoded JSON auth string suitable for
|
||||
// Docker API calls. Pass empty strings for anonymous access.
|
||||
func EncodeRegistryAuth(username, password, serverAddress string) (string, error) {
|
||||
cfg := registry.AuthConfig{
|
||||
Username: username,
|
||||
Password: password,
|
||||
ServerAddress: serverAddress,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode registry auth: %w", err)
|
||||
}
|
||||
|
||||
return base64.URLEncoding.EncodeToString(data), nil
|
||||
}
|
||||
|
||||
// joinArgs joins string arguments with spaces.
|
||||
func joinArgs(args []string) string {
|
||||
return strings.Join(args, " ")
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
)
|
||||
|
||||
// EnsureNetwork creates a Docker network with the given name if it does not
|
||||
// already exist. It returns the network ID in all cases.
|
||||
func (c *Client) EnsureNetwork(ctx context.Context, networkName string) (string, error) {
|
||||
// Check if the network already exists.
|
||||
filterArgs := filters.NewArgs()
|
||||
filterArgs.Add("name", networkName)
|
||||
|
||||
networks, err := c.api.NetworkList(ctx, network.ListOptions{
|
||||
Filters: filterArgs,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list networks for %s: %w", networkName, err)
|
||||
}
|
||||
|
||||
// NetworkList with a name filter may return partial matches, so check exact name.
|
||||
for _, n := range networks {
|
||||
if n.Name == networkName {
|
||||
return n.ID, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Create the network.
|
||||
resp, err := c.api.NetworkCreate(ctx, networkName, network.CreateOptions{
|
||||
Driver: "bridge",
|
||||
Labels: map[string]string{
|
||||
LabelProject: "docker-watcher",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create network %s: %w", networkName, err)
|
||||
}
|
||||
|
||||
return resp.ID, nil
|
||||
}
|
||||
|
||||
// ConnectNetwork attaches a container to an existing network.
|
||||
func (c *Client) ConnectNetwork(ctx context.Context, networkID string, containerID string) error {
|
||||
err := c.api.NetworkConnect(ctx, networkID, containerID, &network.EndpointSettings{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect container %s to network %s: %w", containerID, networkID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user