Connector image HTTP contract
Exact HTTP surface of the Ads Sync connector images, the metadata verification consumers must do, and the build inputs that produce the images.
Every Ads Sync connector image runs one Python HTTP wrapper, airbyte-source-server.py, in front of a pinned Airbyte connector. A Qualified Run talks to that wrapper over HTTP; it never runs the connector command directly. This page is the wrapper contract for wrapper version ads-sync-wrapper-v0.5.
The build inputs live in the Ads Sync Reference Deployment at packages/ads-sync/deploy/images/. Consumers build the images themselves; the project publishes Dockerfiles, not built images.
images
| image | Dockerfile | base image (digest pinned in FROM) |
|---|---|---|
| Google Ads source | Dockerfile |
airbyte/source-google-ads:6.1.0@sha256:dea39deedba0a095f60159d808dfb47fa778e304846396d2ab2f04c951b480ed |
| Google Search Console source | Dockerfile.gsc |
airbyte/source-google-search-console:2.1.9@sha256:3ee78d227a25ec01a31b9f131b1b8d80afd6e3aaf0c0c2f1b09c7973190465b3 |
| Google Search Console source (token-only) | Dockerfile.gsc.access-token |
airbyte/source-google-search-console:2.1.9@sha256:3ee78d227a25ec01a31b9f131b1b8d80afd6e3aaf0c0c2f1b09c7973190465b3 |
| Meta Ads source | Dockerfile.meta |
airbyte/source-facebook-marketing:5.2.11@sha256:4d6c916b29862ded4b5b94feea0b8ef75899f34c364e4884312e50414b6d447c |
| Postgres destination | Dockerfile.postgres |
airbyte/destination-postgres:3.0.13@sha256:0b310bd46ba0e006757ea3dc1d3b8ef8e3bcf51c3a96f5460a836653b5ac4f4c |
| Postgres destination (preprovisioned schemas) | Dockerfile.postgres.preprovisioned |
airbyte/destination-postgres:3.0.13@sha256:0b310bd46ba0e006757ea3dc1d3b8ef8e3bcf51c3a96f5460a836653b5ac4f4c |
The pins equal supportedImageVersions in @patronage/ads-sync. Each Dockerfile also sets AIRBYTE_CONNECTOR_IMAGE to the same reference; /metadata reports it as connectorImage. Dockerfile.gsc.access-token is a build variant of Dockerfile.gsc; Dockerfile.postgres.preprovisioned is a build variant of Dockerfile.postgres. Each variant declares # ads-sync-build-variant-of: <reference>. Hosted Loop and Alchemy build those siblings because Alchemy’s external-Dockerfile path cannot pass --build-arg. The reference files keep the build arguments for one-off builds.
build command
IMAGE_REGISTRY=registry.example.com/ads-sync bash build-images.sh --push
Run build-images.sh with bash from packages/ads-sync/deploy/images/; the public tree does not preserve the executable bit. Without --push it loads the images into the local Docker daemon. IMAGE_TAG defaults to 0.3.0 and IMAGE_PLATFORM to linux/amd64. IMAGES defaults to google-ads gsc meta postgres. The hosted variants are gsc-access-token (Dockerfile.gsc.access-token) and postgres-preprovisioned (Dockerfile.postgres.preprovisioned); they take no mode arguments.
exact-match build steps
| build argument | default | alternative | step |
|---|---|---|---|
GSC_AUTH_MODE |
refresh_token |
access_token |
patch-gsc-manifest-auth.py rewrites the pinned manifest OAuth authenticator to a BearerAuthenticator that reads authorization.access_token |
POSTGRES_SCHEMA_MODE |
airbyte_owned |
preprovisioned |
patch-postgres-preprovisioned-schema.py rewrites the connector jar constant CREATE SCHEMA IF NOT EXISTS <schema>; to SELECT 1 /* preprovisioned <schema> */; |
| always | pin-postgres-java-cacerts.sh copies the destination image CA bundle to /app/java-cacerts; Dockerfile.postgres then sets DESTINATION_POSTGRES_OPTS to that JKS trust store |
Each patch script requires the pinned upstream text to match exactly once. On any drift it exits non-zero and the image build fails. The defaults are the single-tenant mode: the operator owns the Google refresh token, and the destination writer role has database-level CREATE so Airbyte owns schema DDL. Hosted Loop builds Dockerfile.gsc.access-token and Dockerfile.postgres.preprovisioned instead of passing those arguments. PlanetScale destination configs omit ssl_mode and set jdbc_url_params to sslmode=verify-full&sslfactory=org.postgresql.ssl.DefaultJavaSSLFactory, so the connector cannot replace certificate and hostname verification with its weaker require mode.
wrapper environment
| variable | set by | effect |
|---|---|---|
AIRBYTE_CONNECTOR_COMMAND |
Dockerfile | connector entrypoint the wrapper runs |
AIRBYTE_CONNECTOR_IMAGE |
Dockerfile | value of connectorImage in /metadata |
AIRBYTE_CONNECTOR_SERVICE |
Dockerfile | value of service in /metadata |
AIRBYTE_IMAGE_BUILD_OPTIONS |
Dockerfile | value of buildOptions in /metadata, for example postgres_schema_mode=airbyte_owned |
AIRBYTE_READ_TIMEOUT_SECONDS |
Dockerfile or runtime | source-session wall-clock budget; default 1860, Google Ads image sets 330 |
AIRBYTE_WRITE_TIMEOUT_SECONDS |
runtime | destination-session wall-clock budget; default 1260 |
AIRBYTE_MAX_ARTIFACT_BYTES |
runtime | maximum connector stdout spool; default 2 GiB |
AIRBYTE_MAX_DIAGNOSTIC_BYTES |
runtime | maximum connector stderr spool; default 8 MiB |
AIRBYTE_MAX_DESTINATION_INPUT_BYTES |
runtime | maximum streamed destination input spool; default 2 GiB |
AIRBYTE_JAVA_TRUST_STORE |
Dockerfile.postgres |
JKS path that /network-check reports on |
AIRBYTE_NETWORK_CHECK_PORTS |
runtime | comma-separated port allowlist for /network-check; default 5432 |
AIRBYTE_WRAPPER_PORT |
runtime | listen port; default 8080 |
endpoints
The wrapper listens on port 8080 on all interfaces (0.0.0.0). Control responses are JSON; artifact routes stream NDJSON or text.
network posture
The wrapper has no authentication on any route. Under Cloudflare Containers the port is reachable only through the Durable Object binding, and that network isolation is the only access control. Destination sessions run a write with a caller-supplied config, and /network-check opens a TCP connection to a caller-supplied host on an allowlisted port. Do not expose the container port on a host or a shared network. When you run an image outside Cloudflare Containers (for example under docker run for provider admission), bind the port to loopback only. Explicit session deletion removes the artifact spool and any credential-bearing config or catalog files, including a destination session deleted before its messages endpoint is called.
| method and path | request body | success | failure |
|---|---|---|---|
GET /metadata (also GET / and GET /health) |
none | 200 metadata object |
none |
GET /spec |
none | 200 run result |
502 run result, 504 timeout |
POST /check |
connector config object | 200 run result |
400, 502, 504 |
POST /discover |
connector config object | 200 run result |
400, 502, 504 |
POST /read-sessions |
{ sessionId, config, catalog, state? } |
200 artifact receipt |
400, 409, 413, 502, 504 |
POST /write-sessions |
{ sessionId, config, catalog } |
201 session receipt |
400, 409 |
POST /write-sessions/{sessionId}/messages |
NDJSON stream with an exact length header | 200 artifact receipt |
404, 413, 502, 504 |
GET /sessions/{sessionId}/stdout |
none | 200 streamed NDJSON |
404 |
GET /sessions/{sessionId}/stderr |
none | 200 streamed text |
404 |
DELETE /sessions/{sessionId} |
none | 204 and ephemeral files removed |
404 |
POST /network-check |
{ host, port } |
200 { connected: true, trustStore } |
400, 502 |
| any other path | 404 { "error": "Not found" } |
Any POST body must be a JSON object; a missing, non-JSON, or non-object body returns 400 { "error": ... }.
/metadata
{
"buildOptions": "postgres_schema_mode=airbyte_owned",
"connectorImage": "airbyte/destination-postgres:3.0.13@sha256:0b310bd46ba0e006757ea3dc1d3b8ef8e3bcf51c3a96f5460a836653b5ac4f4c",
"service": "airbyte-postgres-destination",
"status": "ok",
"wrapperVersion": "ads-sync-wrapper-v0.5"
}
buildOptions is an empty string on images without build modes.
artifact receipt
/read-sessions and the destination messages endpoint return a small receipt. Artifact bodies never appear in JSON:
{
"args": ["read", "--config", "<config>", "--catalog", "<config>"],
"exitCode": 0,
"sessionId": "source-123",
"stderrLength": 1234,
"stderrSha256": "…",
"stdoutLength": 56789,
"stdoutSha256": "…",
"success": true
}
args redacts every temporary JSON path as <config>. The status is 200 when the connector exits 0 and 502 otherwise. The wrapper drains stdout and stderr concurrently into bounded files on ephemeral container disk, hashing and counting bytes as it goes. The Qualified Run then streams those files into R2, redacting diagnostic lines and computing the durable manifest without assembling the artifact in Worker memory.
The wrapper writes each request object to a temporary file for the connector process. It enters the cleanup block before the first write, so a failed later write (for example, disk full while writing the catalog) still deletes the config file that holds the credential.
When the connector exceeds the wall-clock budget the wrapper returns 504 { "args": [...], "error": "Connector timed out" }. The wrapper starts the connector with a 30-second inner margin (60 seconds for a destination write) so the connector process ends before the HTTP budget does.
The connector always gets at least 30 seconds inside the budget. When AIRBYTE_READ_TIMEOUT_SECONDS or AIRBYTE_WRITE_TIMEOUT_SECONDS is smaller than the inner margin plus that floor (60 seconds for a source read, 90 for a destination write), the wrapper does not start the connector. It returns 500 { "args": [...], "error": "Connector timeout budget is too small", "minimumTimeoutSeconds": <n>, "timeoutSeconds": <configured> }. This is an image configuration error, not a run result.
source sessions
config and catalog must be objects; state is optional and passed through as --state. The wrapper runs the read, deletes credential-bearing input files, and returns a receipt. The caller streams stdout and stderr from the session routes, then deletes the session. A repeated live sessionId returns 409.
destination sessions
Create the session with config and catalog, then stream newline-delimited Airbyte messages to its /messages endpoint. An exact length is required before the connector starts: x-airbyte-content-length, or Content-Length when that header is still present. Cloudflare may strip Content-Length from a streamed container fetch; the Qualified Run seam therefore sends both. Transfer-Encoding: chunked is accepted when it is the only transfer encoding, and the decoded body must equal the declared length. The wrapper spools the request to bounded ephemeral disk and supplies that file as connector stdin. The receipt also reports inputLength and inputSha256; the caller verifies both against the stream it sent before accepting the write. State commit remains the caller’s decision after a successful write.
/network-check
host must be a non-empty hostname string and port an integer in the AIRBYTE_NETWORK_CHECK_PORTS allowlist; other values return 400. The wrapper opens one TCP connection with a 10-second timeout and does not send data or credentials.
{
"connected": true,
"trustStore": {
"destinationOptionsPresent": true,
"keytoolReadable": true,
"path": "/app/java-cacerts",
"readable": true
}
}
trustStore is null on images without AIRBYTE_JAVA_TRUST_STORE. A failed connection returns 502 { "connected": false, "errorType": "<Python exception class>" }, for example TimeoutError (timeout on the older Python in the pinned destination image) or ConnectionRefusedError. Use it as an image-level readiness probe for the destination: it proves the container can reach the Postgres host and that the pinned JVM trust store is readable before a /write spends the connector budget.
metadata verification
Before the first connector call of every Qualified Run, the consumer must:
GET /metadata.- Compare
connectorImagewithsupportedImageVersions[provider](orsupportedImageVersions.destination) as an exact string, digest included. - Compare
wrapperVersionwith the exact stringads-sync-wrapper-v0.5. - Fail the run on any mismatch. Do not retry a mismatch; a responding wrapper with the wrong image or version is a deployment error, not a transient condition.
Prefix, tag-only, or semver-range comparisons are not the contract. The pins are what the tests in packages/ads-sync/deploy/images/images.test.ts and the Qualified Run seam assert.
cold-start retry
A newly started Cloudflare Container can answer /metadata with a transient platform error, or refuse the connection, before the wrapper process listens. The Qualified Run seam performs a bounded, unavailable-only retry:
- retry only when the request did not reach a healthy wrapper: connection failure, or any
5xxresponse (the body is not inspected on a5xx); - never retry a
200whose fields mismatch, and never retry a4xx; - stop after the bounded attempt budget and fail the run.
The retry is seam behavior. A consumer that calls the wrapper directly must implement the same rule; the wrapper itself has no retry.
versioning
WRAPPER_VERSION in airbyte-source-server.py changes whenever this contract changes. Consumers pin the exact string; the seam and the internal Paitronage app pin ads-sync-wrapper-v0.5. Adding an endpoint, changing a response field, or changing an error status is a version bump.