Drydock Integration Reference

August 1, 2026 · View on GitHub

Verified against Drydock dev/v1.6 (app/agent/AgentClient.ts, app/agent/api/, and app/agent/EdgeAgentAdapter.ts).

Architecture

Drydock supports two agent connectivity patterns. Portwing implements both.

flowchart TB
    subgraph std ["Standard mode — inbound HTTP (implemented)"]
        direction LR
        SC["Drydock controller<br/>AgentClient.ts (polls /api/*)"]
        SL["Portwing<br/>HTTP server /api/*"]
        SC -- "HTTP / SSE · X-Dd-Agent-Secret" --> SL
    end

    subgraph edge ["Edge mode — outbound WebSocket (Drydock v1.6.0-rc.11+)"]
        direction LR
        EL["Portwing<br/>edge/client (outbound)"]
        EC["Drydock controller<br/>WebSocket srv /api/portwing/ws<br/>(v1.6.0-rc.11+ — Ed25519)"]
        EL -- "WSS · hello → welcome" --> EC
    end

    subgraph legacy ["Legacy SSE agent — original Drydock Node.js"]
        direction LR
        LC["Drydock controller<br/>AgentClient.ts"]
        LL["original Node.js agent<br/>express /api/* · dd:ack on connect"]
        LC -- "HTTP / SSE" --> LL
    end

Portwing Standard Mode replaces the Legacy SSE Agent. Edge Mode has Portwing dial outbound to the Drydock controller's /api/portwing/ws endpoint using the portwing/1.0 protocol; the controller endpoint shipped in Drydock 1.5 and requires an Ed25519-signed hello.


Standard Mode: Drydock v1.6.0-rc.11+ Handshake Sequence

Source: app/agent/AgentClient.ts:506–579

sequenceDiagram
    participant D as Drydock controller (AgentClient)
    participant L as Portwing (standard mode)
    D->>L: GET /api/events · X-Dd-Agent-Secret
    L-->>D: text/event-stream
    L->>D: data: dd:ack (immediate on connect)
    Note over D: handleAckEvent() → handshake()
    D->>L: GET /api/containers
    L-->>D: 200 [Container, ...]
    D->>L: GET /api/watchers
    L-->>D: 200 [ComponentDescriptor, ...]
    D->>L: GET /api/triggers
    L-->>D: 200 [ComponentDescriptor, ...]
    Note over D,L: connection established, SSE stays open
    L->>D: data: dd:container-added (on change)
    L->>D: data: dd:container-updated
    L->>D: data: dd:container-removed

Source citation:

  • AgentClient.ts:506handshake() deduplication guard
  • AgentClient.ts:519GET /api/containers
  • AgentClient.ts:547GET /api/watchers
  • AgentClient.ts:559GET /api/triggers
  • AgentClient.ts:717startSse()GET /api/events
  • AgentClient.ts:654parseSseLine() parses data: {...}\n\n

Edge Mode: Portwing WebSocket Handshake

Source: internal/edge/client.go, internal/protocol/messages.go

sequenceDiagram
    participant L as Portwing (edge client)
    participant D as Drydock controller
    L->>D: WSS /api/portwing/ws
    L->>D: hello {version, protocol, agentId, agentName, pubKeyId,<br/>timestamp, nonce, signature, dockerVersion,<br/>capabilities, drydockCompat, watcherTypes}
    D->>L: welcome {pollInterval: 300}
    L->>D: dd:component_sync {watchers: [...], triggers: []}
    L->>D: dd:container_sync {containers: [...]}
    L->>D: metrics {...}

The edge-mode hello is Ed25519-signed (pubKeyId/timestamp/nonce/signature); the controller endpoint is Ed25519-only and rejects token-hash hellos. The full hello payload (exact field set) is in SPEC.md §3.2.

Drydock version: Drydock v1.6.0-rc.11+ is required for Portwing v0.9.0's complete controller-owned watcher/update behavior in Standard and Edge modes. Earlier controllers can remain portwing/1.0 wire-compatible without implementing the additive transport/ownership/event markers.

Wire versus feature compatibility: the additive v0.9.0 markers do not bump DrydockCompat from 1.4.0. A successful connection alone does not prove the controller provides the v0.9.0 watcher/update feature path.


Endpoints Portwing Serves (Standard Mode)

All /api/* endpoints require X-Dd-Agent-Secret or X-Portwing-Token header.

Drydock CallPortwing EndpointMethodNotes
AgentClient.startSse() L717GET /api/eventsGETSSE stream; dd:ack on connect
AgentClient._doHandshake() L519GET /api/containersGET[]Container JSON
AgentClient._doHandshake() L547GET /api/watchersGET[]ComponentDescriptor
AgentClient._doHandshake() L559GET /api/triggersGET[]ComponentDescriptor (empty)
AgentClient.getWatcher() L1552GET /api/watchers/{type}/{name}GETSingle watcher descriptor
AgentClient.getContainerLogs() L1523GET /api/containers/{id}/logsGETPlain text or {logs:"..."}
AgentClient.deleteContainer() L1539DELETE /api/containers/{id}DELETE204 on success
AgentClient.watch() L1567POST /api/watchers/{type}/{name}POST501 (legacy route; controller runs the watcher through the Docker proxy)
AgentClient.watchContainer() L1586POST /api/watchers/{type}/{name}/container/{id}POST501 (same controller-owned path)
AgentClient.runRemoteTrigger() L1421POST /api/triggers/{type}/{name}POST501 (Portwing advertises no remote trigger)
AgentClient.runRemoteTriggerBatch() L1469POST /api/triggers/{type}/{name}/batchPOST501 (same compatibility behavior)
AgentClient.getLogEntries() L1503GET /api/log/entriesGET[] (no in-memory buffer)
Drydock probesGET /healthGET{"status":"ok"} (no auth)

SSE Event Shapes

Drydock parses SSE frames as data: <json>\n\n. Each frame is a JSON object with type and data fields (AgentClient.ts:659).

dd:ack (sent on every new SSE connection)

Drydock reads: version, os, arch, cpus, memoryGb, uptimeSeconds, lastSeen, logLevel, pollInterval (AgentClient.ts:755–782)

Portwing sends:

{
  "type": "dd:ack",
  "data": {
    "version": "0.9.0",
    "os": "linux",
    "arch": "amd64",
    "cpus": 4,
    "memoryGb": 15.6,
    "uptimeSeconds": 123,
    "lastSeen": "2026-06-11T12:00:00Z",
    "logLevel": "info",
    "pollInterval": "5m0s",
    "containers": {"total": 3, "running": 2, "stopped": 1},
    "images": 2
  }
}

Note: memoryGb is read from /proc/meminfo (no cgo) and rounded to one decimal GiB; non-Linux hosts report 0, which Drydock accepts. pollInterval is the agent's DD_POLL_INTERVAL as a Go duration string (Drydock's own agent sends a cron expression here — the field is informational, displayed as-is). Portwing 0.5.x and earlier sent memoryGb: 0 and omitted logLevel/pollInterval.

pollInterval appears in several shapes depending on the source, and none of them should be parsed as anything but opaque, display-only values: a Go duration string ("5m0s", from Portwing's Standard-mode dd:ack), a cron expression (from Drydock's own legacy Node.js agent), and a bare integer string ("300") on Drydock's REST AgentInfo surface. Drydock's Edge Mode welcome frame is different again — it sends pollInterval as a JSON number (e.g. 300), not a string (see the Edge Mode section below).

dd:container-added / dd:container-updated

{"type": "dd:container-added", "data": {<Container object>}}

dd:container-removed

Drydock reads only .data.id (AgentClient.ts:782). Portwing also sends name (harmless extra field).

{"type": "dd:container-removed", "data": {"id": "abc123", "name": "web"}}

Container Object Shape

Drydock's AgentClient._doHandshake() passes containers from GET /api/containers directly to storeContainer.insertContainer() / storeContainer.updateContainer(). Drydock expects these fields:

FieldTypeNotes
idstringDocker container ID
namestringContainer name (no leading /)
displayNamestringFrom dd.display.name label or falls back to name
displayIconstring?From dd.display.icon label
statusstringrunning, stopped, paused, restarting, dead, created
watcherstringWatcher name (Portwing: "docker" or from dd.watch label)
agentstring?Agent name (set by Drydock controller, stripped in agent responses)
image.idstringImage SHA
image.registryobject{name:"unknown", url:"docker.io"}; Portwing knows the registry URL, while the controller assigns its configured registry component
image.namestringImage name
image.tagobject{value:"latest", semver:false}
image.digestobject{watch:false, value:"sha256:…"}
image.architecturestringDocker image architecture, with the agent architecture as fallback
image.osstringDocker image OS, with the agent OS as fallback
updateAvailableboolAlways false in Portwing's raw inventory; Drydock enriches its controller-side watcher result
updateKindobjectAlways {"kind":"unknown"}
labelsobject?All Docker labels
detailsobject?Runtime details; ports and volumes use Drydock's string-array wire representation

Watcher Component Descriptor Shape

GET /api/watchers returns an array. GET /api/watchers/{type}/{name} returns one.

Drydock reads: type, name, configuration, metadata (AgentClient.ts:489–503).

Portwing returns:

[{
  "type": "docker",
  "name": "docker",
  "configuration": {
    "description": "Watches Docker containers for updates via Docker Engine API",
    "capabilities": ["container-sync", "labels"],
    "transport": "docker-api",
    "execution": "controller",
    "events": "portwing"
  }
}]

id and agent fields (present in Drydock's own mapComponentToItem) are not required by the AgentClient — it never reads them from the remote agent response.

Watcher and Update Execution Model

The watcher configuration makes ownership explicit:

  • transport: "docker-api" tells a compatible Drydock controller to run its native Docker watcher and update trigger through Portwing's authenticated, transparent Docker API proxy.
  • execution: "controller" keeps registry checks and update orchestration in Drydock instead of delegating them to Portwing's legacy watcher/trigger POST endpoints.
  • events: "portwing" makes Portwing the source of Docker lifecycle events, so Drydock does not open a duplicate Docker event stream through the proxy.

In Standard Mode those controller-owned Docker API calls use Portwing's inbound HTTP proxy. In Edge Mode Drydock sends the same calls over the correlated request/response transport on the outbound WebSocket. Portwing therefore participates in updates as the authenticated Docker transport and event source, even though Drydock owns watcher and trigger execution.

Portwing still returns no trigger components (GET /api/triggers and edge triggerTypes remain empty), while dd:component_sync contains the Docker watcher descriptor. The dd:trigger protocol vocabulary is retained for wire compatibility, but it is not a remote trigger advertisement; the watcher/trigger POST endpoints continue to return 501 for older clients.

The complete execution model requires Drydock v1.6.0-rc.11 or later. Older controllers can remain wire-compatible because the descriptor fields and Edge ordering are additive, but they do not provide full v0.9.0 feature compatibility.


Environment Variable Mapping

Drydock Controller Side (configures the connection to Portwing)

Drydock env var / configDescription
Agent hostPortwing hostname/IP (set in Drydock agent component config, no env var)
Agent portPortwing port (default 3000)
Agent secretShared secret → sent as X-Dd-Agent-Secret
DD_AGENT_ALLOW_INSECURE_SECRET=trueAllow secret over plain HTTP
Agent cafileCA cert for TLS verification
Agent certfile / keyfilemTLS certificate pair

Source: app/agent/components/Agent.ts:4–11, AgentClient.ts:247–258

Portwing Side (agent binary)

Portwing env varPurposeDrydock counterpart
TOKENShared secret (preferred)Drydock agent secret
DD_AGENT_SECRETShared secret (Drydock agent secret)Drydock agent secret
TOKEN_FILEPath to file containing tokenDrydock DD_AGENT_SECRET_FILE
DD_AGENT_SECRET_FILEPath to file containing tokenDrydock DD_AGENT_SECRET_FILE
TOKEN_HASHArgon2id PHC hash of token (standard mode only)n/a
PORTHTTP listen port (default 3000)Drydock agent port
BIND_ADDRESSStandard default 0.0.0.0; Edge operations default 127.0.0.1n/a
TLS_CERT / TLS_KEYTLS certificate/keyDrydock agent certfile/keyfile
CA_CERTCA cert for edge mode TLSDrydock agent cafile
TLS_SKIP_VERIFYSkip TLS verification in edge moden/a
DRYDOCK_URLEdge mode controller URL (agent dials out to /api/portwing/ws)n/a
AGENT_IDUUID for this agentDrydock registers via hello.agentId
AGENT_NAME 1Display name (default: hostname)Drydock agent component name
DD_POLL_INTERVAL 2Container refresh interval in s (default 300)n/a
DOCKER_SOCKETDocker socket pathn/a
LOG_LEVELdebug/info/warn/errorn/a
TRUSTED_PROXIESCIDR list for X-Forwarded-Forn/a

Compatibility Matrix

For the N-way version matrix (portwing version × Drydock version × sockguard preset × wire compat constant), see COMPATIBILITY.md at the repo root — that file is the canonical source for "does version X of one tool work with version Y of another." The table below is feature-level detail: which individual endpoints/messages are implemented and verified against a specific Drydock build.

FeatureStatusEvidence
GET /api/events SSE streamCOMPATIBLEPortwing: sse.go:48; Drydock: AgentClient.ts:717
dd:ack event on connectCOMPATIBLEPortwing: sse.go:74; Drydock: AgentClient.ts:1299
dd:ack fields (version, os, arch, cpus, memoryGb, uptimeSeconds, lastSeen, logLevel, pollInterval)COMPATIBLEDrydock AgentClient.ts:755–782 reads exactly these fields
dd:ack memoryGb=0 (non-Linux hosts)COMPATIBLEDrydock treats 0 as valid (no assertion on non-zero)
dd:container-added SSECOMPATIBLEPortwing: sse.go:174; Drydock: AgentClient.ts:1303
dd:container-updated SSECOMPATIBLEPortwing: sse.go:187; Drydock: AgentClient.ts:1303
dd:container-removed SSE ({id, name})COMPATIBLEDrydock only reads .id (AgentClient.ts:782); extra name is harmless
GET /api/containers returns []ContainerCOMPATIBLEPortwing: routes.go:27; Drydock: AgentClient.ts:519
Container shape (id, name, displayName, status, watcher, image, labels, details)COMPATIBLEPortwing model.go; Drydock model/container.js
GET /api/watchers returns []ComponentDescriptorCOMPATIBLEDescriptor selects transport=docker-api, execution=controller, and events=portwing
GET /api/watchers/{type}/{name} (single watcher)COMPATIBLEFixed in this PR: routes.go:handleWatcherGet
GET /api/triggers returns []COMPATIBLEPortwing advertises no remote trigger; compatible Drydock uses its controller-owned update trigger through the Docker proxy
GET /api/containers/{id}/logs plain textCOMPATIBLEDrydock logs.ts:134–145 accepts both plain text and {logs:"..."}
DELETE /api/containers/{id} returns 204COMPATIBLEPortwing: routes.go:92; Drydock: AgentClient.ts:1543
POST /api/watchers/{type}/{name}COMPATIBLE (501)Legacy route; Drydock runs its watcher through the transparent Docker proxy
POST /api/watchers/{type}/{name}/container/{id}COMPATIBLE (501)Same controller-owned execution path
POST /api/triggers/{type}/{name}COMPATIBLE (501)No Portwing remote trigger is advertised; Drydock applies updates through the proxy
POST /api/triggers/{type}/{name}/batchCOMPATIBLE (501)Same compatibility behavior
GET /api/log/entries returns []COMPATIBLEFixed in this PR: routes.go:handleLogEntries; Drydock AgentClient.ts:1503
Authentication via X-Dd-Agent-SecretCOMPATIBLEPortwing: server/http.go auth middleware; Drydock: api/index.ts:73
/health unauthenticatedCOMPATIBLEPortwing: routes.go:handleSimpleHealth; Drydock: api/index.ts:152
dd:watcher-snapshot SSE eventCOMPATIBLEEmitted after every poll cycle and on SSE connect (after dd:ack); see below
dd:update-applied / dd:update-failed SSEN-ADrydock owns these operation events; its Docker update calls still traverse Portwing and Portwing emits the resulting lifecycle events
dd:update-operation-changed SSEN-AController-owned update state; Portwing remains the Docker transport and lifecycle-event source
dd:batch-update-completed SSEN-AController-owned batch state; update calls still traverse Portwing
dd:security-alert / dd:security-scan-cycle-complete SSEN-APortwing does not perform security scanning
Edge Mode WebSocket (/api/portwing/ws)STABLE (portwing/1.0)Ed25519-only; correlated request/response messages carry controller-owned watcher/update Docker API calls. Portwing 0.8 adds continuous log streaming and the fleet-soak release gate.

Gaps Requiring Drydock-side Changes

None. GAP-1 below was originally identified as requiring a Drydock-side tolerance change; it has since been resolved Portwing-side.

GAP-1 (RESOLVED): dd:watcher-snapshot SSE event not emitted by Portwing

Resolution: Portwing now emits dd:watcher-snapshot from SSEBroadcaster after every container poll cycle (Adapter.OnContainerRefresh) and sends the current snapshot to each newly connected SSE client immediately after dd:ack. No Drydock-side change is needed.

What Drydock expects: When a watcher completes a full poll cycle, the original Drydock Node.js agent emits a dd:watcher-snapshot SSE event:

{
  "type": "dd:watcher-snapshot",
  "data": {
    "watcher": {"type": "docker", "name": "docker", "configuration": {...}},
    "containers": [<Container>, ...]
  }
}

Drydock's AgentClient.ts:1310 handles this event to prune stale containers (containers present in the previous inventory but absent from the snapshot are removed). Additionally, app/agent/api/event.ts:334 replays the last snapshot per watcher to newly connected SSE clients, so a reconnecting controller never misses the authoritative container list.

What Portwing does instead: Portwing emits individual dd:container-added, dd:container-updated, and dd:container-removed events. The controller receives these incremental events and maintains its own store. Pruning after reconnects depends on the handshake GET /api/containers call (which Drydock already does).

Impact: Low for most deployments. If a container is removed while the SSE connection is dropped and the controller reconnects, the handshake call to GET /api/containers will prune it (AgentClient.ts:535). The only edge case is a zero-container handshake (preserved as ambiguous by Drydock, AgentClient.ts:537) which relies on dd:watcher-snapshot to later prune stale entries.

How it was fixed: SSEBroadcaster.BroadcastWatcherSnapshot marshals the watcher descriptor plus the full cached inventory (ContainerManager.GetContainers, already rebuilt by the poll cycle that triggered the broadcast) — no extra cycle tracking was needed. The snapshot is also written to each new SSE client right after the dd:ack event so a reconnecting controller gets the authoritative list without waiting up to one poll interval.


Pre-built Docker Compose for Standard Mode

services:
  portwing:
    image: ghcr.io/codeswhat/portwing:latest
    environment:
      TOKEN: "${DD_AGENT_SECRET}"       # same secret Drydock agent config uses
      PORT: "3000"
      AGENT_NAME: "my-server"
    # The image runs as non-root UID 65532; grant the Docker socket's group:
    #   export DOCKER_SOCK_GID=$(stat -c '%g' /var/run/docker.sock)
    group_add:
      - "${DOCKER_SOCK_GID:?set to the GID of /var/run/docker.sock}"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    ports:
      - "3000:3000"
    restart: unless-stopped

In Drydock agent component configuration:

agent:
  my-server:
    host: http://my-server-ip
    port: 3000
    secret: "${DD_AGENT_SECRET}"

Footnotes

  1. In Edge Mode, the (sanitized) hello.agentName sent by Portwing is now honored by Drydock as the agent's display name, as of Drydock dev/v1.6. Sanitization lowercases the name, replaces runs of non-[a-z0-9-] characters with a single -, trims leading/trailing -, and truncates to 63 characters; an empty result falls back to portwing-edge-<agentId>.

  2. DD_POLL_INTERVAL is ignored in Edge Mode — the controller's welcome frame pollInterval is authoritative there (Drydock intentionally owns the refresh cadence in Edge Mode; this is a deliberate design, not a bug). It still applies as documented in Standard Mode.