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:
2026-03-27 21:08:57 +03:00
parent cdf21682d6
commit 389ed5aff8
10 changed files with 963 additions and 34 deletions
+6 -6
View File
@@ -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) | ⬜ |
@@ -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
<!-- 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)
+41 -11
View File
@@ -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
<!-- 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.