connector-deployer/CONTRACT.md
Rama main · réplica de lectura del repositorio original.
# connector-deployer — RPC contract (SEC-H-02)
The `connector-deployer` is the small, trusted, no-LLM service that holds
`/var/run/docker.sock`. `tool-generator` (which runs LLM/user-influenced connector
code) drops root + the socket and calls this service over an authenticated
internal RPC. The security property lives in `internal/spec` (allow-by-construction
`HostConfig`); this document is the operational contract the server implements.
## Config (process env — the deployer's OWN trusted settings; a caller influences none of it)
| Env | Default | Meaning |
|---|---|---|
| `PORT` | `5011` | deployer HTTP listen port |
| `ENVIRONMENT` | `development` | `production`/`prod` ⇒ auth fail-closed |
| `INTERNAL_SERVICE_SECRET` | (unset) | S2S shared secret; **required in prod** |
| `DEPLOYER_DOCKER_NETWORK` | `rsync-ai-mcp` | the pinned connector network (NetworkMode) |
| `MCP_SHARED_NETWORK` | `rsync-ai-mcp` | "also-join" net for sink reachability (usually == above ⇒ no-op) |
| `OAUTH_TOKENS_VOLUME_NAME` | `rsync-ai-oauth-tokens` | named volume for OAuth TokenManager; empty disables |
| `OAUTH_TOKENS_TARGET` | `/root/.rsync-ai` | mount target for the above |
| `TOOLS_DIR` | `/app/shared/mcp-connectors` | connector-artifacts root (mounted READ-ONLY) |
| `DOCKER_HOST` | `unix:///var/run/docker.sock` | daemon socket |
| `STACK_PREFIX` | `rsync-ai` | container-name prefix, `<STACK_PREFIX>-<id>-vX-Y-Z-mcp`; must match the orchestrator's so an isolated stack's compose-managed connectors are recognised as protected |
| `BUILDKIT_HOST` | (unset) | rootless buildkitd gRPC addr (`buildctl --addr`). **Set ⇒ BUILD runs OFF the daemon** (increment 2); unset ⇒ legacy in-daemon `docker build` (rollback valve) |
| `DEPLOYER_REGISTRY_PUSH` | `mcp-registry:5000` | registry buildkitd pushes to (compose DNS); used only when `BUILDKIT_HOST` set |
| `DEPLOYER_REGISTRY_PULL` | `127.0.0.1:5000` | registry the HOST daemon pulls from (loopback ⇒ auto-insecure, no daemon.json change); used only when `BUILDKIT_HOST` set |
## Auth
Every `/v1/*` request must carry `X-Internal-Secret: <INTERNAL_SERVICE_SECRET>`.
Mirror `tool_generator/deployment/routes.py::require_internal_secret`:
- secret set + header matches ⇒ allow; mismatch/absent ⇒ `401`.
- secret **unset**: allow (log a warning) only when `ENVIRONMENT` is exactly `development` or `dev` (trimmed, any case); every other value, unset included ⇒ `503 internal_secret_not_configured`.
## POST /v1/deploy — build-if-missing + run (the JIT connector spawn)
Untrusted request body — **DATA only** (no HostConfig-adjacent fields):
```json
{
"connector_id": "postgresql",
"version": "v1.0.0",
"context_subdir": "public/database/postgresql/versions/v1.0.0",
"container_name": "rsync-ai-postgresql-mcp",
"aliases": ["rsync-ai-postgresql-mcp", "postgresql-mcp"],
"env": {"PORT": "8000"},
"port": 0,
"recreate": false,
"build_args": {}
}
```
Validation (reject `400` on any failure): `connector_id` `^[a-z0-9][a-z0-9-]{0,62}$`;
`version` `^[A-Za-z0-9_.-]{1,64}$`; `container_name`/`aliases`/`env`/`port` per
`internal/spec` rules; `context_subdir` MUST contain no `..` segment, MUST resolve
inside `TOOLS_DIR`, and MUST contain a `Dockerfile`. The image ref is DERIVED as
`mcp-<connector_id>:<version>` — never taken from the request.
Behavior (mirror `docker_builder.py::start_container` semantics exactly):
1. **Protected compose containers** — if `container_name` is a compose-managed
connector (port the `_is_protected_compose_container` check): running ⇒ return ok
(skip); stopped ⇒ start it (never rebuild/replace — it may serve a live CDC
stream); missing ⇒ `409` with a "run docker compose up" message.
2. **Reuse running** — if a container of that name exists, is `running`, and
`recreate` is false ⇒ return ok (no rebuild).
3. Otherwise remove a stale/stopped container of that name if present.
4. **Ensure image** — if `mcp-<id>:<version>` is absent (or `recreate`): build it,
supplying the `shared` named context = the `public/` ancestor dir of `context_dir`
(same resolution as the Python). Two paths, selected by `BUILDKIT_HOST` (§ Config);
both produce a local `mcp-<id>:<version>` image with the SAME six labels
(`com.docker.compose.project=rsync-ai-mcp`, `com.docker.compose.service=<id>`,
`mcp.connector.version/name`, `mcp.managed=true`, `mcp.auto.generated=true`), a 900s
timeout, and the last ~1500 chars of output on failure:
- **`BUILDKIT_HOST` unset (legacy / rollback valve)** — the connector's Dockerfile
RUN steps build in the HOST daemon's BuildKit, mirroring `_cli_build_with_shared_context`:
`DOCKER_BUILDKIT=1 docker build --build-context shared=<public-ancestor> -t mcp-<id>:<version> <labels> <--build-arg k=v...> <context_dir>`.
- **`BUILDKIT_HOST` set (SEC-H-02 increment 2 — build OFF the daemon)** — the RUN
steps build in the ROOTLESS buildkitd sidecar (which holds no docker.sock) and are
pushed to the local registry: `buildctl --addr $BUILDKIT_HOST build --frontend dockerfile.v0 --local context=<context_dir> --local dockerfile=<context_dir> --opt filename=Dockerfile --opt context:shared=local:shared --local shared=<public-ancestor> <--opt label:...> <--opt build-arg:k=v...> --output type=image,name=$DEPLOYER_REGISTRY_PUSH/mcp-<id>:<version>,push=true,registry.insecure=true`.
The HOST daemon then **pulls** `$DEPLOYER_REGISTRY_PULL/mcp-<id>:<version>` (drained
to EOF) and **re-tags** it to the bare `mcp-<id>:<version>` — so steps 5/6, the
labels, and the response are byte-identical to the legacy path. The daemon fetches
finished layers only; it never runs untrusted build steps.
5. **Build the create payload** via `spec.BuildContainerSpec(req, cfg)` then
**call `spec.ValidateHostConfigSafe(hc, cfg)` and REFUSE with `500` if it errors**
(defense-in-depth — never create an unvalidated container). Also copy the JIT
labels onto `container.Config.Labels` (the `com.docker.compose.*` / `rsync-ai.*` /
`mcp.*` discovery labels from `start_container`, lines ~603-619) so api-gateway's
catalog and the compose-group view still see the container.
6. `ContainerCreate(cfg, hc, netCfg, name)` → `ContainerStart`. Then attach aliases
and also-join `MCP_SHARED_NETWORK` if it differs from the primary network (mirror
`start_container` lines ~654-689; non-fatal on alias failure).
Response `200`: `{"ok":true,"container_id":"<short>","status":"running","image":"mcp-<id>:<version>","built":true|false}`.
On build failure: `500` with the last ~1500 chars of build output (already scrubbed
by the connector log scrubber upstream; do not add PII here).
## POST /v1/undeploy — `{"container_name": "..."}`
Protected-compose ⇒ `409` (never tear down a compose-managed connector). Else
`stop(timeout=10)` + `remove(force)`. `200 {"ok":true}`. Missing container ⇒ `200`
(idempotent).
## GET /v1/status?name=<container_name>
`200 {"exists":bool,"status":"running|exited|created|...","container_id":"<short>"}`.
## GET /healthz
`200 {"ok":true}` after a successful daemon `Ping`; `503` if the daemon is unreachable.
## Build off the daemon (SEC-H-02 increment 2)
Increment 2 landed the rootless-build path in step 4: when `BUILDKIT_HOST` is set the
connector's (untrusted) Dockerfile RUN/FROM steps run in a rootless buildkitd sidecar
(no docker.sock, unprivileged uid in a userns) and are pushed to a local `registry:2`;
the host daemon only pulls the finished layers and CREATE/STARTs the container — so the
un-body-filterable build endpoint is off the trusted (socket-holding) surface. **The path
is opt-in everywhere, including prod** — `docker-compose.prod.yml` passes
`BUILDKIT_HOST=${DEPLOYER_BUILDKIT_HOST-}`, so the flag is unset unless an operator sets
`DEPLOYER_BUILDKIT_HOST=tcp://buildkitd:1234` in the prod `.env`; base/OSS/quickstart/CI
keep the legacy in-daemon build the same way. (Until 2026-07-29 the prod compose default
was `tcp://buildkitd:1234`, which armed the path on every prod bring-up despite the
cutover being documented as pending; arming is now the deliberate act and clearing the
var is still the instant revert.) See `docs/internal/jit-connector-sandboxing.md` §4/§6.
## Non-goals (future)
Opting OSS/quickstart into the rootless build (they keep the legacy socket-build today,
which still benefits from increment 1's tool-generator socket drop); mTLS on the
buildkitd gRPC endpoint (currently isolated on the private `rsync-build` network,
reachable only by the deployer); and automatic registry GC/retention.