From 389ed5aff8a9679dc205771a42f277cbcefe155c Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 27 Mar 2026 21:08:57 +0300 Subject: [PATCH] feat(docker-watcher): phases 3+4 - Docker client & NPM client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- go.mod | 2 + internal/docker/client.go | 50 +++ internal/docker/container.go | 276 +++++++++++++++++ internal/docker/image.go | 103 ++++++ internal/docker/network.go | 53 ++++ internal/npm/client.go | 293 ++++++++++++++++++ internal/npm/types.go | 76 +++++ plans/docker-watcher-core/PLAN.md | 12 +- .../phase-3-docker-client.md | 80 ++++- .../docker-watcher-core/phase-4-npm-client.md | 52 +++- 10 files changed, 963 insertions(+), 34 deletions(-) create mode 100644 internal/docker/client.go create mode 100644 internal/docker/container.go create mode 100644 internal/docker/image.go create mode 100644 internal/docker/network.go create mode 100644 internal/npm/client.go create mode 100644 internal/npm/types.go diff --git a/go.mod b/go.mod index 53b458c..5970c4b 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module github.com/alexei/docker-watcher go 1.23 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/google/uuid v1.6.0 github.com/robfig/cron/v3 v3.0.1 diff --git a/internal/docker/client.go b/internal/docker/client.go new file mode 100644 index 0000000..f66d09a --- /dev/null +++ b/internal/docker/client.go @@ -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 +} diff --git a/internal/docker/container.go b/internal/docker/container.go new file mode 100644 index 0000000..ce61f4d --- /dev/null +++ b/internal/docker/container.go @@ -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) +} diff --git a/internal/docker/image.go b/internal/docker/image.go new file mode 100644 index 0000000..0e12c7e --- /dev/null +++ b/internal/docker/image.go @@ -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, " ") +} diff --git a/internal/docker/network.go b/internal/docker/network.go new file mode 100644 index 0000000..a36deeb --- /dev/null +++ b/internal/docker/network.go @@ -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 +} diff --git a/internal/npm/client.go b/internal/npm/client.go new file mode 100644 index 0000000..e07eb7d --- /dev/null +++ b/internal/npm/client.go @@ -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 +} diff --git a/internal/npm/types.go b/internal/npm/types.go new file mode 100644 index 0000000..5da2744 --- /dev/null +++ b/internal/npm/types.go @@ -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"` +} diff --git a/plans/docker-watcher-core/PLAN.md b/plans/docker-watcher-core/PLAN.md index bb6f805..d512ca1 100644 --- a/plans/docker-watcher-core/PLAN.md +++ b/plans/docker-watcher-core/PLAN.md @@ -24,9 +24,9 @@ A self-hosted tool that automates Docker container deployment with Nginx Proxy M ## Phases - [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) -- [ ] 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 2: Crypto & Config Seed Loader [domain: backend] → [subplan](./phase-2-crypto-config.md) +- [x] Phase 3: Docker Client [domain: backend] → [subplan](./phase-3-docker-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 6: Webhook Handler [domain: backend] → [subplan](./phase-6-webhook-handler.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 1: Scaffold & Store | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ | -| Phase 2: Crypto & Config | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | -| Phase 3: Docker Client | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | -| Phase 4: NPM Client | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | +| Phase 2: Crypto & Config | backend | ✅ Complete | ✅ Pass w/ notes | ⏭️ Skip (Big Bang) | ✅ | +| Phase 3: Docker Client | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ 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 6: Webhook Handler | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | | Phase 7: Deployer & Health | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | diff --git a/plans/docker-watcher-core/phase-3-docker-client.md b/plans/docker-watcher-core/phase-3-docker-client.md index c24868c..4c07f1b 100644 --- a/plans/docker-watcher-core/phase-3-docker-client.md +++ b/plans/docker-watcher-core/phase-3-docker-client.md @@ -1,6 +1,6 @@ # Phase 3: Docker Client -**Status:** ⬜ Not Started +**Status:** :white_check_mark: Complete **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** backend @@ -9,16 +9,16 @@ Implement the Docker Engine API wrapper for container lifecycle management — p ## Tasks -- [ ] 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 -- [ ] 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 -- [ ] Task 5: Implement `StartContainer(ctx, containerID)`, `StopContainer(ctx, containerID, timeout)`, `RemoveContainer(ctx, containerID, force)` -- [ ] Task 6: Implement `RestartContainer(ctx, containerID, timeout)` -- [ ] Task 7: Implement `ListContainers(ctx, filters)` — filter by labels to find managed containers -- [ ] Task 8: Implement `EnsureNetwork(ctx, networkName)` — create network if not exists -- [ ] 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 1: Create Docker client wrapper with socket connection (`/var/run/docker.sock`) +- [x] Task 2: Implement `PullImage(ctx, image, tag, authConfig)` — pull with optional registry auth +- [x] Task 3: Implement `InspectImage(ctx, image)` — extract EXPOSE ports, HEALTHCHECK, labels +- [x] Task 4: Implement `CreateContainer(ctx, config)` — create with name, image, env, ports, network, labels +- [x] Task 5: Implement `StartContainer(ctx, containerID)`, `StopContainer(ctx, containerID, timeout)`, `RemoveContainer(ctx, containerID, force)` +- [x] Task 6: Implement `RestartContainer(ctx, containerID, timeout)` +- [x] Task 7: Implement `ListContainers(ctx, filters)` — filter by labels to find managed containers +- [x] Task 8: Implement `EnsureNetwork(ctx, networkName)` — create network if not exists +- [x] Task 9: Implement `ConnectNetwork(ctx, networkID, containerID)` — attach container to network +- [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 - `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) ## Review Checklist -- [ ] All tasks completed -- [ ] Proper context propagation for cancellation -- [ ] Resource cleanup (close client, remove failed containers) -- [ ] No hardcoded values -- [ ] Error messages include container/image identifiers +- [x] All tasks completed +- [x] Proper context propagation for cancellation +- [x] Resource cleanup (close client, remove failed containers) +- [x] No hardcoded values +- [x] Error messages include container/image identifiers ## Handoff to Next 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) diff --git a/plans/docker-watcher-core/phase-4-npm-client.md b/plans/docker-watcher-core/phase-4-npm-client.md index a67ab67..ba11b0a 100644 --- a/plans/docker-watcher-core/phase-4-npm-client.md +++ b/plans/docker-watcher-core/phase-4-npm-client.md @@ -1,6 +1,6 @@ # Phase 4: NPM Client -**Status:** ⬜ Not Started +**Status:** ✅ Complete **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** backend @@ -9,15 +9,15 @@ Implement the Nginx Proxy Manager API client — JWT authentication, CRUD for pr ## Tasks -- [ ] 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 -- [ ] Task 3: Implement `CreateProxyHost(ctx, config)` — POST /api/nginx/proxy-hosts -- [ ] 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} -- [ ] Task 6: Implement `ListProxyHosts(ctx)` — GET /api/nginx/proxy-hosts -- [ ] 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.) -- [ ] Task 9: Handle JWT token expiry — re-authenticate automatically on 401 +- [x] Task 1: Create NPM client struct with base URL, cached JWT token, and auto-refresh +- [x] Task 2: Implement `Authenticate(ctx, email, password)` — POST /api/tokens, store JWT +- [x] Task 3: Implement `CreateProxyHost(ctx, config)` — POST /api/nginx/proxy-hosts +- [x] Task 4: Implement `UpdateProxyHost(ctx, id, config)` — PUT /api/nginx/proxy-hosts/{id} +- [x] Task 5: Implement `DeleteProxyHost(ctx, id)` — DELETE /api/nginx/proxy-hosts/{id} +- [x] Task 6: Implement `ListProxyHosts(ctx)` — GET /api/nginx/proxy-hosts +- [x] Task 7: Implement `FindProxyHostByDomain(ctx, domain)` — search existing hosts by domain name +- [x] Task 8: Define proxy host config struct (domain, forward host/port, SSL settings, etc.) +- [x] Task 9: Handle JWT token expiry — re-authenticate automatically on 401 ## Files to Modify/Create - `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 ## Handoff to Next 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.