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:
@@ -3,6 +3,8 @@ module github.com/alexei/docker-watcher
|
|||||||
go 1.23
|
go 1.23
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/docker/docker v27.5.1+incompatible
|
||||||
|
github.com/docker/go-connections v0.5.0
|
||||||
github.com/go-chi/chi/v5 v5.2.1
|
github.com/go-chi/chi/v5 v5.2.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
package npm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is an HTTP client for the Nginx Proxy Manager API.
|
||||||
|
// It handles JWT authentication, automatic token refresh, and CRUD for proxy hosts.
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
httpClient *http.Client
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
token string
|
||||||
|
expiry time.Time
|
||||||
|
email string
|
||||||
|
password string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates an NPM client targeting the given base URL (e.g. "http://npm:81/api").
|
||||||
|
// The returned client is not yet authenticated — call Authenticate before other methods.
|
||||||
|
func New(baseURL string) *Client {
|
||||||
|
return &Client{
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate obtains a JWT from the NPM API and caches it for future requests.
|
||||||
|
// The credentials are also stored so the client can re-authenticate automatically on 401.
|
||||||
|
func (c *Client) Authenticate(ctx context.Context, email, password string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.email = email
|
||||||
|
c.password = password
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
return c.authenticate(ctx, email, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) authenticate(ctx context.Context, email, password string) error {
|
||||||
|
body, err := json.Marshal(authRequest{
|
||||||
|
Identity: email,
|
||||||
|
Secret: password,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal auth request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/tokens", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create auth request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("send auth request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read auth response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("authenticate: status %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var authResp authResponse
|
||||||
|
if err := json.Unmarshal(respBody, &authResp); err != nil {
|
||||||
|
return fmt.Errorf("decode auth response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expiry, err := time.Parse(time.RFC3339, authResp.Expires)
|
||||||
|
if err != nil {
|
||||||
|
// If we cannot parse the expiry, set a conservative 12-hour window.
|
||||||
|
expiry = time.Now().Add(12 * time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.token = authResp.Token
|
||||||
|
c.expiry = expiry
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateProxyHost creates a new proxy host and returns the created resource.
|
||||||
|
func (c *Client) CreateProxyHost(ctx context.Context, config ProxyHostConfig) (ProxyHost, error) {
|
||||||
|
var host ProxyHost
|
||||||
|
if err := c.doJSON(ctx, http.MethodPost, "/nginx/proxy-hosts", config, &host); err != nil {
|
||||||
|
return ProxyHost{}, fmt.Errorf("create proxy host: %w", err)
|
||||||
|
}
|
||||||
|
return host, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProxyHost updates an existing proxy host by ID and returns the updated resource.
|
||||||
|
func (c *Client) UpdateProxyHost(ctx context.Context, id int, config ProxyHostConfig) (ProxyHost, error) {
|
||||||
|
var host ProxyHost
|
||||||
|
path := fmt.Sprintf("/nginx/proxy-hosts/%d", id)
|
||||||
|
if err := c.doJSON(ctx, http.MethodPut, path, config, &host); err != nil {
|
||||||
|
return ProxyHost{}, fmt.Errorf("update proxy host %d: %w", id, err)
|
||||||
|
}
|
||||||
|
return host, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteProxyHost deletes a proxy host by ID.
|
||||||
|
func (c *Client) DeleteProxyHost(ctx context.Context, id int) error {
|
||||||
|
path := fmt.Sprintf("/nginx/proxy-hosts/%d", id)
|
||||||
|
if err := c.doJSON(ctx, http.MethodDelete, path, nil, nil); err != nil {
|
||||||
|
return fmt.Errorf("delete proxy host %d: %w", id, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListProxyHosts returns all proxy hosts.
|
||||||
|
func (c *Client) ListProxyHosts(ctx context.Context) ([]ProxyHost, error) {
|
||||||
|
var hosts []ProxyHost
|
||||||
|
if err := c.doJSON(ctx, http.MethodGet, "/nginx/proxy-hosts", nil, &hosts); err != nil {
|
||||||
|
return nil, fmt.Errorf("list proxy hosts: %w", err)
|
||||||
|
}
|
||||||
|
return hosts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindProxyHostByDomain searches existing proxy hosts for one that serves the given domain.
|
||||||
|
// Returns the matching host and true if found, or a zero-value ProxyHost and false otherwise.
|
||||||
|
func (c *Client) FindProxyHostByDomain(ctx context.Context, domain string) (ProxyHost, bool, error) {
|
||||||
|
hosts, err := c.ListProxyHosts(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return ProxyHost{}, false, fmt.Errorf("find proxy host by domain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
needle := strings.ToLower(domain)
|
||||||
|
for _, h := range hosts {
|
||||||
|
for _, d := range h.DomainNames {
|
||||||
|
if strings.ToLower(d) == needle {
|
||||||
|
return h, true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProxyHost{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// doJSON performs an authenticated JSON API request. If the token is expired or a 401
|
||||||
|
// is received, it automatically re-authenticates and retries the request once.
|
||||||
|
func (c *Client) doJSON(ctx context.Context, method, path string, reqBody any, result any) error {
|
||||||
|
if err := c.ensureToken(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.doJSONOnce(ctx, method, path, reqBody, result)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we got a 401, attempt re-auth and retry once.
|
||||||
|
if isUnauthorized(err) {
|
||||||
|
c.mu.Lock()
|
||||||
|
email := c.email
|
||||||
|
password := c.password
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if authErr := c.authenticate(ctx, email, password); authErr != nil {
|
||||||
|
return fmt.Errorf("re-authenticate after 401: %w", authErr)
|
||||||
|
}
|
||||||
|
return c.doJSONOnce(ctx, method, path, reqBody, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// errUnauthorized is a sentinel used to detect 401 responses for automatic re-auth.
|
||||||
|
type errUnauthorized struct {
|
||||||
|
wrapped error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *errUnauthorized) Error() string { return e.wrapped.Error() }
|
||||||
|
func (e *errUnauthorized) Unwrap() error { return e.wrapped }
|
||||||
|
|
||||||
|
func isUnauthorized(err error) bool {
|
||||||
|
var target *errUnauthorized
|
||||||
|
return errors.As(err, &target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doJSONOnce(ctx context.Context, method, path string, reqBody any, result any) error {
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if reqBody != nil {
|
||||||
|
data, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal request body: %w", err)
|
||||||
|
}
|
||||||
|
bodyReader = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
token := c.token
|
||||||
|
c.mu.Unlock()
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("send request %s %s: %w", method, path, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
return &errUnauthorized{
|
||||||
|
wrapped: fmt.Errorf("status 401: %s", string(respBody)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("npm api %s %s: status %d: %s", method, path, resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE returns 200 with no body.
|
||||||
|
if result != nil && len(respBody) > 0 {
|
||||||
|
if err := json.Unmarshal(respBody, result); err != nil {
|
||||||
|
return fmt.Errorf("decode response: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureToken checks if the cached token is still valid and re-authenticates if needed.
|
||||||
|
func (c *Client) ensureToken(ctx context.Context) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
token := c.token
|
||||||
|
expiry := c.expiry
|
||||||
|
email := c.email
|
||||||
|
password := c.password
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if token == "" {
|
||||||
|
return fmt.Errorf("npm client not authenticated: call Authenticate first")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the token 5 minutes before expiry to avoid race conditions.
|
||||||
|
if time.Now().Add(5 * time.Minute).After(expiry) {
|
||||||
|
if err := c.authenticate(ctx, email, password); err != nil {
|
||||||
|
return fmt.Errorf("refresh expired token: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON allows boolInt to decode both JSON booleans and 0/1 integers.
|
||||||
|
func (b *boolInt) UnmarshalJSON(data []byte) error {
|
||||||
|
s := strings.TrimSpace(string(data))
|
||||||
|
switch s {
|
||||||
|
case "true", "1":
|
||||||
|
*b = true
|
||||||
|
case "false", "0", "null":
|
||||||
|
*b = false
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("cannot unmarshal %q as boolInt", s)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON encodes boolInt as a JSON boolean.
|
||||||
|
func (b boolInt) MarshalJSON() ([]byte, error) {
|
||||||
|
if b {
|
||||||
|
return []byte("true"), nil
|
||||||
|
}
|
||||||
|
return []byte("false"), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package npm
|
||||||
|
|
||||||
|
// ProxyHostConfig holds the input fields for creating or updating a proxy host.
|
||||||
|
type ProxyHostConfig struct {
|
||||||
|
DomainNames []string `json:"domain_names"`
|
||||||
|
ForwardScheme string `json:"forward_scheme"`
|
||||||
|
ForwardHost string `json:"forward_host"`
|
||||||
|
ForwardPort int `json:"forward_port"`
|
||||||
|
CertificateID int `json:"certificate_id"`
|
||||||
|
SSLForced bool `json:"ssl_forced"`
|
||||||
|
BlockExploits bool `json:"block_exploits"`
|
||||||
|
CachingEnabled bool `json:"caching_enabled"`
|
||||||
|
AllowWebsocket bool `json:"allow_websocket_upgrade"`
|
||||||
|
HTTP2Support bool `json:"http2_support"`
|
||||||
|
AdvancedConfig string `json:"advanced_config"`
|
||||||
|
HSTSEnabled bool `json:"hsts_enabled"`
|
||||||
|
HSTSSubdomains bool `json:"hsts_subdomains"`
|
||||||
|
AccessListID int `json:"access_list_id"`
|
||||||
|
Meta Meta `json:"meta"`
|
||||||
|
Locations []any `json:"locations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta holds metadata tags for a proxy host.
|
||||||
|
type Meta struct {
|
||||||
|
LetsEncryptAgree bool `json:"letsencrypt_agree"`
|
||||||
|
DNSChallenge bool `json:"dns_challenge"`
|
||||||
|
LetsEncryptEmail string `json:"letsencrypt_email,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProxyHost represents a proxy host as returned by the NPM API.
|
||||||
|
type ProxyHost struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
DomainNames []string `json:"domain_names"`
|
||||||
|
ForwardScheme string `json:"forward_scheme"`
|
||||||
|
ForwardHost string `json:"forward_host"`
|
||||||
|
ForwardPort int `json:"forward_port"`
|
||||||
|
CertificateID any `json:"certificate_id"`
|
||||||
|
SSLForced boolInt `json:"ssl_forced"`
|
||||||
|
BlockExploits boolInt `json:"block_exploits"`
|
||||||
|
CachingEnabled boolInt `json:"caching_enabled"`
|
||||||
|
AllowWebsocket boolInt `json:"allow_websocket_upgrade"`
|
||||||
|
HTTP2Support boolInt `json:"http2_support"`
|
||||||
|
AdvancedConfig string `json:"advanced_config"`
|
||||||
|
HSTSEnabled boolInt `json:"hsts_enabled"`
|
||||||
|
HSTSSubdomains boolInt `json:"hsts_subdomains"`
|
||||||
|
AccessListID int `json:"access_list_id"`
|
||||||
|
Meta Meta `json:"meta"`
|
||||||
|
Enabled boolInt `json:"enabled"`
|
||||||
|
CreatedOn string `json:"created_on"`
|
||||||
|
ModifiedOn string `json:"modified_on"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// boolInt handles the NPM API's inconsistent use of 0/1 integers for boolean fields.
|
||||||
|
type boolInt bool
|
||||||
|
|
||||||
|
// authRequest is the request body for POST /api/tokens.
|
||||||
|
type authRequest struct {
|
||||||
|
Identity string `json:"identity"`
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// authResponse is the response body from POST /api/tokens.
|
||||||
|
type authResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Expires string `json:"expires"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiError represents an error response from the NPM API.
|
||||||
|
type apiError struct {
|
||||||
|
Error apiErrorDetail `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiErrorDetail struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
}
|
||||||
@@ -24,9 +24,9 @@ A self-hosted tool that automates Docker container deployment with Nginx Proxy M
|
|||||||
## Phases
|
## Phases
|
||||||
|
|
||||||
- [x] Phase 1: Project Scaffold & SQLite Store [domain: backend] → [subplan](./phase-1-scaffold-store.md)
|
- [x] Phase 1: Project Scaffold & SQLite Store [domain: backend] → [subplan](./phase-1-scaffold-store.md)
|
||||||
- [ ] Phase 2: Crypto & Config Seed Loader [domain: backend] → [subplan](./phase-2-crypto-config.md)
|
- [x] Phase 2: Crypto & Config Seed Loader [domain: backend] → [subplan](./phase-2-crypto-config.md)
|
||||||
- [ ] Phase 3: Docker Client [domain: backend] → [subplan](./phase-3-docker-client.md)
|
- [x] Phase 3: Docker Client [domain: backend] → [subplan](./phase-3-docker-client.md)
|
||||||
- [ ] Phase 4: NPM Client [domain: backend] → [subplan](./phase-4-npm-client.md)
|
- [x] Phase 4: NPM Client [domain: backend] → [subplan](./phase-4-npm-client.md)
|
||||||
- [ ] Phase 5: Registry Client & Poller [domain: backend] → [subplan](./phase-5-registry-poller.md)
|
- [ ] Phase 5: Registry Client & Poller [domain: backend] → [subplan](./phase-5-registry-poller.md)
|
||||||
- [ ] Phase 6: Webhook Handler [domain: backend] → [subplan](./phase-6-webhook-handler.md)
|
- [ ] Phase 6: Webhook Handler [domain: backend] → [subplan](./phase-6-webhook-handler.md)
|
||||||
- [ ] Phase 7: Deployer & Health Checker [domain: backend] → [subplan](./phase-7-deployer.md)
|
- [ ] Phase 7: Deployer & Health Checker [domain: backend] → [subplan](./phase-7-deployer.md)
|
||||||
@@ -47,9 +47,9 @@ A self-hosted tool that automates Docker container deployment with Nginx Proxy M
|
|||||||
| Phase | Domain | Status | Review | Build | Committed |
|
| Phase | Domain | Status | Review | Build | Committed |
|
||||||
| ----- | ------ | ------ | ------ | ----- | --------- |
|
| ----- | ------ | ------ | ------ | ----- | --------- |
|
||||||
| Phase 1: Scaffold & Store | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ |
|
| Phase 1: Scaffold & Store | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ |
|
||||||
| Phase 2: Crypto & Config | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
|
| Phase 2: Crypto & Config | backend | ✅ Complete | ✅ Pass w/ notes | ⏭️ Skip (Big Bang) | ✅ |
|
||||||
| Phase 3: Docker Client | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
|
| Phase 3: Docker Client | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ |
|
||||||
| Phase 4: NPM Client | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
|
| Phase 4: NPM Client | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ |
|
||||||
| Phase 5: Registry & Poller | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
|
| Phase 5: Registry & Poller | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
|
||||||
| Phase 6: Webhook Handler | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
|
| Phase 6: Webhook Handler | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
|
||||||
| Phase 7: Deployer & Health | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
|
| Phase 7: Deployer & Health | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Phase 3: Docker Client
|
# Phase 3: Docker Client
|
||||||
|
|
||||||
**Status:** ⬜ Not Started
|
**Status:** :white_check_mark: Complete
|
||||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
**Domain:** backend
|
**Domain:** backend
|
||||||
|
|
||||||
@@ -9,16 +9,16 @@ Implement the Docker Engine API wrapper for container lifecycle management — p
|
|||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
- [ ] Task 1: Create Docker client wrapper with socket connection (`/var/run/docker.sock`)
|
- [x] Task 1: Create Docker client wrapper with socket connection (`/var/run/docker.sock`)
|
||||||
- [ ] Task 2: Implement `PullImage(ctx, image, tag, authConfig)` — pull with optional registry auth
|
- [x] Task 2: Implement `PullImage(ctx, image, tag, authConfig)` — pull with optional registry auth
|
||||||
- [ ] Task 3: Implement `InspectImage(ctx, image)` — extract EXPOSE ports, HEALTHCHECK, labels
|
- [x] Task 3: Implement `InspectImage(ctx, image)` — extract EXPOSE ports, HEALTHCHECK, labels
|
||||||
- [ ] Task 4: Implement `CreateContainer(ctx, config)` — create with name, image, env, ports, network, labels
|
- [x] Task 4: Implement `CreateContainer(ctx, config)` — create with name, image, env, ports, network, labels
|
||||||
- [ ] Task 5: Implement `StartContainer(ctx, containerID)`, `StopContainer(ctx, containerID, timeout)`, `RemoveContainer(ctx, containerID, force)`
|
- [x] Task 5: Implement `StartContainer(ctx, containerID)`, `StopContainer(ctx, containerID, timeout)`, `RemoveContainer(ctx, containerID, force)`
|
||||||
- [ ] Task 6: Implement `RestartContainer(ctx, containerID, timeout)`
|
- [x] Task 6: Implement `RestartContainer(ctx, containerID, timeout)`
|
||||||
- [ ] Task 7: Implement `ListContainers(ctx, filters)` — filter by labels to find managed containers
|
- [x] Task 7: Implement `ListContainers(ctx, filters)` — filter by labels to find managed containers
|
||||||
- [ ] Task 8: Implement `EnsureNetwork(ctx, networkName)` — create network if not exists
|
- [x] Task 8: Implement `EnsureNetwork(ctx, networkName)` — create network if not exists
|
||||||
- [ ] Task 9: Implement `ConnectNetwork(ctx, networkID, containerID)` — attach container to network
|
- [x] Task 9: Implement `ConnectNetwork(ctx, networkID, containerID)` — attach container to network
|
||||||
- [ ] Task 10: Add docker-watcher labels to all managed containers (`docker-watcher.project`, `docker-watcher.stage`, `docker-watcher.instance-id`)
|
- [x] Task 10: Add docker-watcher labels to all managed containers (`docker-watcher.project`, `docker-watcher.stage`, `docker-watcher.instance-id`)
|
||||||
|
|
||||||
## Files to Modify/Create
|
## Files to Modify/Create
|
||||||
- `internal/docker/client.go` — Docker client wrapper, connection setup
|
- `internal/docker/client.go` — Docker client wrapper, connection setup
|
||||||
@@ -42,11 +42,57 @@ Implement the Docker Engine API wrapper for container lifecycle management — p
|
|||||||
- Auth config for private registries will come from the store (encrypted tokens)
|
- Auth config for private registries will come from the store (encrypted tokens)
|
||||||
|
|
||||||
## Review Checklist
|
## Review Checklist
|
||||||
- [ ] All tasks completed
|
- [x] All tasks completed
|
||||||
- [ ] Proper context propagation for cancellation
|
- [x] Proper context propagation for cancellation
|
||||||
- [ ] Resource cleanup (close client, remove failed containers)
|
- [x] Resource cleanup (close client, remove failed containers)
|
||||||
- [ ] No hardcoded values
|
- [x] No hardcoded values
|
||||||
- [ ] Error messages include container/image identifiers
|
- [x] Error messages include container/image identifiers
|
||||||
|
|
||||||
## Handoff to Next Phase
|
## Handoff to Next Phase
|
||||||
<!-- Filled in by the implementation agent after completing this phase. -->
|
|
||||||
|
### Exported API surface (`internal/docker` package)
|
||||||
|
|
||||||
|
**Client lifecycle:**
|
||||||
|
- `docker.New() (*Client, error)` — creates client with env-based config and API version negotiation
|
||||||
|
- `(*Client).Close() error` — releases resources
|
||||||
|
- `(*Client).Ping(ctx) error` — checks daemon connectivity
|
||||||
|
|
||||||
|
**Image operations (`image.go`):**
|
||||||
|
- `(*Client).PullImage(ctx, imageRef, tag, authConfig) error` — pulls image; authConfig is base64-encoded JSON (use `EncodeRegistryAuth` helper)
|
||||||
|
- `(*Client).InspectImage(ctx, imageRef) (ImageInfo, error)` — returns `ImageInfo{ExposedPorts, Healthcheck, Labels}`
|
||||||
|
- `docker.EncodeRegistryAuth(username, password, serverAddress) (string, error)` — builds auth payload for `PullImage`
|
||||||
|
|
||||||
|
**Container operations (`container.go`):**
|
||||||
|
- `(*Client).CreateContainer(ctx, ContainerConfig) (containerID string, error)` — creates container with labels, env, ports, network
|
||||||
|
- `(*Client).StartContainer(ctx, containerID) error`
|
||||||
|
- `(*Client).StopContainer(ctx, containerID, timeoutSeconds) error`
|
||||||
|
- `(*Client).RemoveContainer(ctx, containerID, force) error`
|
||||||
|
- `(*Client).RestartContainer(ctx, containerID, timeoutSeconds) error`
|
||||||
|
- `(*Client).ListContainers(ctx, labelFilters) ([]ManagedContainer, error)` — always scoped to docker-watcher labels
|
||||||
|
- `(*Client).InspectContainerPort(ctx, containerID, containerPort) (uint16, error)` — gets auto-assigned host port
|
||||||
|
- `docker.ContainerName(project, stage, tag) string` — deterministic name: `dw-{project}-{stage}-{tag-sanitized}`
|
||||||
|
|
||||||
|
**Network operations (`network.go`):**
|
||||||
|
- `(*Client).EnsureNetwork(ctx, networkName) (networkID string, error)` — idempotent create-if-not-exists
|
||||||
|
- `(*Client).ConnectNetwork(ctx, networkID, containerID) error`
|
||||||
|
|
||||||
|
**Label constants:**
|
||||||
|
- `docker.LabelProject` = `"docker-watcher.project"`
|
||||||
|
- `docker.LabelStage` = `"docker-watcher.stage"`
|
||||||
|
- `docker.LabelInstanceID` = `"docker-watcher.instance-id"`
|
||||||
|
|
||||||
|
**Key types:**
|
||||||
|
- `docker.ContainerConfig` — input for `CreateContainer` (Name, Image, Env, ExposedPorts, NetworkName, NetworkID, Labels, Project, Stage, InstanceID)
|
||||||
|
- `docker.ImageInfo` — output of `InspectImage` (ExposedPorts, Healthcheck, Labels)
|
||||||
|
- `docker.ManagedContainer` — output of `ListContainers` (ID, Name, Image, Status, State, Project, Stage, InstanceID, Ports)
|
||||||
|
|
||||||
|
### Dependencies added
|
||||||
|
- `github.com/docker/docker v27.5.1+incompatible`
|
||||||
|
- `github.com/docker/go-connections v0.5.0`
|
||||||
|
- Run `go mod tidy` to resolve transitive dependencies before building
|
||||||
|
|
||||||
|
### Conventions maintained
|
||||||
|
- `context.Context` as first parameter on all methods
|
||||||
|
- Errors wrapped with `fmt.Errorf("context: %w", err)`
|
||||||
|
- Package-level constants for labels
|
||||||
|
- Immutable patterns (new maps created rather than mutating input)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Phase 4: NPM Client
|
# Phase 4: NPM Client
|
||||||
|
|
||||||
**Status:** ⬜ Not Started
|
**Status:** ✅ Complete
|
||||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
**Domain:** backend
|
**Domain:** backend
|
||||||
|
|
||||||
@@ -9,15 +9,15 @@ Implement the Nginx Proxy Manager API client — JWT authentication, CRUD for pr
|
|||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
- [ ] Task 1: Create NPM client struct with base URL, cached JWT token, and auto-refresh
|
- [x] Task 1: Create NPM client struct with base URL, cached JWT token, and auto-refresh
|
||||||
- [ ] Task 2: Implement `Authenticate(ctx, email, password)` — POST /api/tokens, store JWT
|
- [x] Task 2: Implement `Authenticate(ctx, email, password)` — POST /api/tokens, store JWT
|
||||||
- [ ] Task 3: Implement `CreateProxyHost(ctx, config)` — POST /api/nginx/proxy-hosts
|
- [x] Task 3: Implement `CreateProxyHost(ctx, config)` — POST /api/nginx/proxy-hosts
|
||||||
- [ ] Task 4: Implement `UpdateProxyHost(ctx, id, config)` — PUT /api/nginx/proxy-hosts/{id}
|
- [x] Task 4: Implement `UpdateProxyHost(ctx, id, config)` — PUT /api/nginx/proxy-hosts/{id}
|
||||||
- [ ] Task 5: Implement `DeleteProxyHost(ctx, id)` — DELETE /api/nginx/proxy-hosts/{id}
|
- [x] Task 5: Implement `DeleteProxyHost(ctx, id)` — DELETE /api/nginx/proxy-hosts/{id}
|
||||||
- [ ] Task 6: Implement `ListProxyHosts(ctx)` — GET /api/nginx/proxy-hosts
|
- [x] Task 6: Implement `ListProxyHosts(ctx)` — GET /api/nginx/proxy-hosts
|
||||||
- [ ] Task 7: Implement `FindProxyHostByDomain(ctx, domain)` — search existing hosts by domain name
|
- [x] Task 7: Implement `FindProxyHostByDomain(ctx, domain)` — search existing hosts by domain name
|
||||||
- [ ] Task 8: Define proxy host config struct (domain, forward host/port, SSL settings, etc.)
|
- [x] Task 8: Define proxy host config struct (domain, forward host/port, SSL settings, etc.)
|
||||||
- [ ] Task 9: Handle JWT token expiry — re-authenticate automatically on 401
|
- [x] Task 9: Handle JWT token expiry — re-authenticate automatically on 401
|
||||||
|
|
||||||
## Files to Modify/Create
|
## Files to Modify/Create
|
||||||
- `internal/npm/client.go` — NPM API client, auth, HTTP helpers
|
- `internal/npm/client.go` — NPM API client, auth, HTTP helpers
|
||||||
@@ -45,4 +45,34 @@ Implement the Nginx Proxy Manager API client — JWT authentication, CRUD for pr
|
|||||||
- [ ] Struct types match NPM API contract
|
- [ ] Struct types match NPM API contract
|
||||||
|
|
||||||
## Handoff to Next Phase
|
## Handoff to Next Phase
|
||||||
<!-- Filled in by the implementation agent after completing this phase. -->
|
|
||||||
|
### What was built
|
||||||
|
|
||||||
|
- `internal/npm/types.go` — `ProxyHostConfig` (create/update input), `ProxyHost` (API response), `Meta`, auth types, and `boolInt` custom JSON type for NPM's 0/1 boolean fields.
|
||||||
|
- `internal/npm/client.go` — Full NPM API client with JWT auth, auto-refresh, and CRUD.
|
||||||
|
|
||||||
|
### Public API surface
|
||||||
|
|
||||||
|
```go
|
||||||
|
npm.New(baseURL string) *Client
|
||||||
|
(*Client).Authenticate(ctx, email, password string) error
|
||||||
|
(*Client).CreateProxyHost(ctx, config ProxyHostConfig) (ProxyHost, error)
|
||||||
|
(*Client).UpdateProxyHost(ctx, id int, config ProxyHostConfig) (ProxyHost, error)
|
||||||
|
(*Client).DeleteProxyHost(ctx, id int) error
|
||||||
|
(*Client).ListProxyHosts(ctx) ([]ProxyHost, error)
|
||||||
|
(*Client).FindProxyHostByDomain(ctx, domain string) (ProxyHost, bool, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key design decisions
|
||||||
|
|
||||||
|
- JWT token is cached with expiry; auto-refreshed 5 minutes before expiry or on 401.
|
||||||
|
- Credentials are stored in memory after `Authenticate` to enable transparent re-auth.
|
||||||
|
- All HTTP errors include the response body text for debugging.
|
||||||
|
- Credentials are never included in error messages.
|
||||||
|
- `boolInt` type handles NPM API's inconsistent 0/1 vs true/false for boolean fields.
|
||||||
|
- `FindProxyHostByDomain` does case-insensitive matching against all domain names.
|
||||||
|
|
||||||
|
### Dependencies for next phase
|
||||||
|
|
||||||
|
- Caller must provide decrypted NPM credentials (email + password from settings via `crypto.Decrypt`).
|
||||||
|
- `ProxyHost.ID` (int) maps to `Instance.NpmProxyID` in the store for tracking.
|
||||||
|
|||||||
Reference in New Issue
Block a user