From 6e5834ab5e04eb4bb9032435755b0484ab888f5e Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Mon, 31 Aug 2026 01:47:06 +0800 Subject: [PATCH] fix(platform): make the 0.5 fresh deploy reach a working stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.5.0 cannot deploy itself on a clean host — five independent defects, found by walking the released artifacts through the documented fresh-deploy journey: - tale deploy derived its pull list mechanically as tale-${service}, inventing tale-backend-api / tale-backend-worker images that were never built (the backend tier runs the platform image). Service → image now goes through imageRef/imageRepoForService, shared by the compose creators and the pull list, with THIRD_PARTY_IMAGES carrying the minio pin. - The generated backend services lacked DATABASE_URL, so both roles crash-looped on the env schema. Mirrors compose.yml, failing closed on a missing DB_PASSWORD. - object-store was in the stateful compose but in NO deploy tier, so the explicit 'up -d ' never started the blob store (uploads dead, worker ENOTFOUND at boot). It joins STATEFUL + STOP_GATED, which also makes status/reset see it; the backend tier now depends_on it. - waitForHealthy only accepted 'healthy', so backend-worker — whose healthcheck is deliberately disabled (no HTTP surface) — burned the full 300s timeout on every deploy. Running + no configured healthcheck now passes. - The proxy entrypoint kept the whole 0.5 lane block (auth/app routes, /events, machine doors, //*) behind the cutover's BACKEND_UPSTREAM reversibility switch; with Convex gone, unset no longer means 0.4 lanes — it means uploads, live updates and every machine door 404. The lanes inject unconditionally now, the variable stays as an override. The platform image also lost the shipped config catalogs the retired convex image used to bake (/app/system, /app/builtin — every provider read 500'd, orgs seeded nothing) and never owned /app/data, so the backend roles hit EACCES on the root-owned org-config volume. The Dockerfile bakes both catalogs and owns the mount point; the entrypoint re-asserts ownership for volumes a v0.5.0 image already booted against. Guards: compose-parity locks service→image to release.yml's build matrix, backend env keys to compose.yml, the minio pin to one value, and the proxy entrypoint to unconditional lanes; container-image-test now fails any platform image missing the catalogs or an app-owned /app/data. Also corrects the 0.4-era guard texts (--accept-data-loss help, the run-deploy comment, and the docblock promising a container-side backstop that retired with the Convex runtime). --- services/platform/Dockerfile | 16 ++- services/platform/docker-entrypoint.sh | 11 ++ .../tests/integration/container-image-test.ts | 32 +++++ services/proxy/docker-entrypoint.sh | 36 +++--- tools/cli/src/commands/deploy/index.ts | 4 +- .../src/lib/actions/breaking-cutover-guard.ts | 11 +- tools/cli/src/lib/actions/deploy.ts | 14 ++- tools/cli/src/lib/actions/run-deploy.ts | 6 +- .../src/lib/compose/select-services.test.ts | 5 +- .../compose/services/compose-parity.test.ts | 109 ++++++++++++++++++ .../services/create-backend-services.ts | 31 +++-- .../lib/compose/services/create-db-service.ts | 4 +- .../services/create-object-storage-service.ts | 6 +- .../services/create-platform-service.ts | 4 +- .../compose/services/create-proxy-service.ts | 4 +- .../services/create-sandbox-egress-service.ts | 4 +- .../create-sandbox-llm-gateway-service.ts | 4 +- .../services/create-sandbox-service.ts | 4 +- tools/cli/src/lib/compose/types.ts | 42 ++++++- tools/cli/src/lib/docker/wait-for-healthy.ts | 9 ++ 20 files changed, 296 insertions(+), 60 deletions(-) diff --git a/services/platform/Dockerfile b/services/platform/Dockerfile index 39e97a5ad2..890a09b400 100644 --- a/services/platform/Dockerfile +++ b/services/platform/Dockerfile @@ -452,9 +452,23 @@ COPY --from=pruner --chown=app:app /app/services/platform/package.json ./ # below. They are plain .sql text; the `knowledge-db` container applies the # same tree with dbmate for the default database it owns. COPY --chown=app:app services/db/migrations/knowledge-db ./db/migrations/knowledge-db +# The shipped config catalogs, baked into THIS image since 0.5 — the retired +# convex image used to carry them (its Dockerfile did these COPYs), and the +# teardown left no image doing so: v0.5.0 500s on every provider read and +# seeds no org catalog. system/ = the org-independent registries +# (providers/models/harnesses/connectors) at TALE_CONFIG_SYSTEM_DIR; +# builtin/ = the per-org seed catalog at TALE_CONFIG_BUILTIN_DIR. +COPY --chown=app:app configs/platform/system/ /app/system/ +COPY --chown=app:app configs/platform/custom/ /app/builtin/ COPY --from=pruner --chown=app:app /app/services/platform/docker-entrypoint.sh /app/services/platform/env.sh ./ -RUN chmod +x ./docker-entrypoint.sh +# /app/data is the org-config volume's mount point; owning it here makes a +# NEW named volume initialize app-writable for the backend roles. The +# entrypoint re-asserts ownership for volumes that already exist root-owned +# (everything a v0.5.0 image ever booted against). +RUN chmod +x ./docker-entrypoint.sh \ + && mkdir -p /app/data \ + && chown app:app /app/data EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:3000/api/health || exit 1 diff --git a/services/platform/docker-entrypoint.sh b/services/platform/docker-entrypoint.sh index 8088c6ed73..845f892b16 100644 --- a/services/platform/docker-entrypoint.sh +++ b/services/platform/docker-entrypoint.sh @@ -97,6 +97,17 @@ install_ssrf_firewall() { if [ "$(id -u)" = '0' ]; then install_ssrf_firewall + # The org-config volume mounts root-owned on first attach (and volumes a + # v0.5.0 image ever booted against stayed that way — it never chowned + # them), while every role runs as `app` and the backend must WRITE the + # tree (the default object-store connection, governance files, SSO + # connections). Top level only: everything deeper is app-created once this + # succeeds, and a recursive walk would tax large config trees on every + # boot. The web role mounts it read-only — hence best-effort. + if [ -d /app/data ]; then + chown app:app /app/data 2>/dev/null || \ + log_warn "could not chown /app/data (read-only mount or unsupported fs)" + fi # Dev image opt-out: the hot-reload watchers (`vite build --watch`) must write # to dist/ and read the host-owned bind-mounted source, and running as root # sidesteps uid-mismatch permission errors. vite only writes container-local diff --git a/services/platform/tests/integration/container-image-test.ts b/services/platform/tests/integration/container-image-test.ts index b049ffe0bf..f21a64b9d7 100644 --- a/services/platform/tests/integration/container-image-test.ts +++ b/services/platform/tests/integration/container-image-test.ts @@ -246,6 +246,38 @@ async function main(): Promise { if (!foundSecret) r.pass(`${svc}: no secrets baked in`); } + // 3b. Platform ships the config catalogs and an app-owned data mount point. + // The retired convex image used to bake these; v0.5.0 shipped WITHOUT them: + // every provider read 500'd on the missing /app/system, org scaffolding had + // no /app/builtin seed catalog, and the backend roles (uid app) hit EACCES + // writing the root-owned org-config volume at /app/data. + header('Checking platform config catalogs'); + { + const img = images.get('platform'); + if (img) { + const probe = await capture([ + 'docker', + 'run', + '--rm', + '--entrypoint=', + img, + 'sh', + '-c', + 'ls /app/system/providers | head -1; ls /app/builtin | head -1; stat -c %U /app/data', + ]); + const [firstProvider, firstBuiltin, dataOwner] = probe.stdout + .trim() + .split('\n') + .map((line) => line.trim()); + if (firstProvider) r.pass(`platform: /app/system/providers is populated`); + else r.fail(`platform: /app/system/providers missing or empty`); + if (firstBuiltin) r.pass(`platform: /app/builtin seed catalog present`); + else r.fail(`platform: /app/builtin missing or empty`); + if (dataOwner === 'app') r.pass(`platform: /app/data owned by app`); + else r.fail(`platform: /app/data owner is '${dataOwner}', expected app`); + } + } + // 4. Health check defined header('Checking HEALTHCHECK instruction'); for (const svc of SERVICES) { diff --git a/services/proxy/docker-entrypoint.sh b/services/proxy/docker-entrypoint.sh index 75d4d80c31..a6c322ff26 100644 --- a/services/proxy/docker-entrypoint.sh +++ b/services/proxy/docker-entrypoint.sh @@ -111,22 +111,25 @@ sed -i "s|{[\$]SITE_ORIGIN:[^}]*}|${SITE_URL}|" "$CADDYFILE" sed -i "s|{[\$]DOCS_ORIGIN:[^}]*}|${DOCS_URL}|" "$CADDYFILE" # ============================================================================ -# 0.5 backend-api routing (the Convex→Postgres cutover) +# Backend-api routing # ============================================================================ -# BACKEND_UPSTREAM (host:port, e.g. `backend-api:3005`) turns on the migrated -# lanes. Everything the pg backend owns is listed here explicitly — auth, the -# app API, the hint stream, both machine doors, SSO/SCIM/trusted-headers on -# BOTH their 0.5-native and 0.4 `/http_api/...` paths (registered IdP redirect +# Everything the pg backend owns is listed here explicitly — auth, the app +# API, the hint stream, both machine doors, SSO/SCIM/trusted-headers on BOTH +# their 0.5-native and 0.4 `/http_api/...` paths (registered IdP redirect # URIs carry the old ones), the control channel the CLI drains through, the -# cloud-import OAuth callbacks and the WebDAV protocol door. Anything not -# named keeps flowing to Convex, so the cutover stays reversible: unset the -# variable and the stack is back on 0.4 lanes. +# cloud-import OAuth callbacks and the WebDAV protocol door. +# +# BACKEND_UPSTREAM began life as the cutover's reversibility switch (unset ⇒ +# lanes fall back to Convex). The Convex runtime is gone, so an unset value +# no longer means "0.4 lanes" — it means uploads, live updates and every +# machine door 404 (v0.5.0 shipped that way). The lanes are therefore ALWAYS +# injected; the variable remains an override for split deployments. OBJECT_STORE_BUCKET="${OBJECT_STORE_BUCKET:-tale-blobs}" OBJECT_STORE_UPSTREAM="${OBJECT_STORE_UPSTREAM:-object-store:9000}" +BACKEND_UPSTREAM="${BACKEND_UPSTREAM:-backend-api:3005}" -if [ -n "${BACKEND_UPSTREAM:-}" ]; then - echo "Backend routing: 0.5 lanes → ${BACKEND_UPSTREAM}" - BACKEND_BLOCK=$(cat < "${CADDYFILE}.tmp" && mv "${CADDYFILE}.tmp" "$CADDYFILE" -else - echo "Backend routing: off (BACKEND_UPSTREAM unset — all lanes stay on Convex)" - sed -i "/# BACKEND_PLACEHOLDER/d" "$CADDYFILE" - sed -i "/# BACKEND_METRICS_PLACEHOLDER/d" "$CADDYFILE" -fi # The WebDAV door moves with the backend too. Its handle keeps the body cap # and only swaps upstream, through Caddy's own env placeholder — one export # here so the two stay in sync without a second templating pass. -if [ -n "${BACKEND_UPSTREAM:-}" ]; then - WEBDAV_UPSTREAM="${BACKEND_UPSTREAM}" - export WEBDAV_UPSTREAM -fi +WEBDAV_UPSTREAM="${BACKEND_UPSTREAM}" +export WEBDAV_UPSTREAM # Inject base path stripping for subpath deployments if [ -n "$BASE_PATH" ]; then diff --git a/tools/cli/src/commands/deploy/index.ts b/tools/cli/src/commands/deploy/index.ts index a0aeb82c63..9d4138b7d8 100644 --- a/tools/cli/src/commands/deploy/index.ts +++ b/tools/cli/src/commands/deploy/index.ts @@ -43,8 +43,8 @@ export function createDeployCommand(): Command { ) .option( '--accept-data-loss', - 'Expert override for the 0.4 breaking-cutover guard: deploy a >= 0.4 ' + - 'CLI over a pre-0.4 instance although its data becomes permanently ' + + 'Expert override for the breaking-cutover guard: deploy a >= 0.5 ' + + 'CLI over a pre-0.5 instance although its data becomes permanently ' + 'unreadable. Normally you want a fresh deployment instead.', false, ) diff --git a/tools/cli/src/lib/actions/breaking-cutover-guard.ts b/tools/cli/src/lib/actions/breaking-cutover-guard.ts index fc59c2bd39..4113fd8fd1 100644 --- a/tools/cli/src/lib/actions/breaking-cutover-guard.ts +++ b/tools/cli/src/lib/actions/breaking-cutover-guard.ts @@ -45,16 +45,17 @@ function refusalMessage(runningVersion: string | null): string { ` - Stay on 0.4.x for this instance: use a 0.4.x CLI; hotfixes ship from the release/0.4 branch.`, ` - Move to 0.5: create a FRESH deployment (new project directory via \`tale init\`, new volumes) and re-onboard users and content.`, `Docs: self-hosted → operate → upgrades → "0.4 → 0.5: breaking cutover".`, - `Expert override: --accept-data-loss (CLI) / TALE_ACCEPT_DATA_LOSS=1 (container) — the existing data will NOT be readable afterwards.`, + `Expert override: tale deploy --accept-data-loss — the existing data will NOT be readable afterwards.`, ].join('\n'); } /** * Refuse a cross-baseline in-place deploy BEFORE anything is touched (no - * image pull, no snapshot, no recreate). A container-side backstop with the - * same semantics lives in docker-entrypoint.sh for non-CLI operators - * (`[migrations][breaking-cutover]` marker); this guard exists to turn that - * late, opaque failure into an immediate, explained refusal. + * image pull, no snapshot, no recreate). This CLI guard is the ONLY + * enforcement point: the 0.4-era container-side backstop + * (`[migrations][breaking-cutover]` in docker-entrypoint.sh) retired with + * the Convex runtime it inspected — a non-CLI operator who hand-rolls + * compose over pre-0.5 volumes gets an empty database, not a refusal. * * Detection is the running (or last-deployed) platform version, not the * migration ledger: every pre-0.4 install replayed the migration chain on diff --git a/tools/cli/src/lib/actions/deploy.ts b/tools/cli/src/lib/actions/deploy.ts index 4cac7ab790..3e76b2df33 100644 --- a/tools/cli/src/lib/actions/deploy.ts +++ b/tools/cli/src/lib/actions/deploy.ts @@ -18,6 +18,7 @@ import { type StatefulService, type StopGatedService, STOP_GATED_SERVICES, + imageRef, isRotatableService, isStatefulService, } from '../compose/types'; @@ -275,13 +276,16 @@ export async function deploy(options: DeployOptions): Promise { // Pull all required images first. The sandbox tier (sandbox + // sandbox-egress) is now a stateful always-roll singleton, so its images // are pulled here via statefulToUpdate like the rest — no special-casing. + // Service → image goes through imageRef (shared with the compose + // creators): the backend tier runs the platform image, so a mechanical + // `tale-${service}` would pull images that were never built. Dedup'd + // because several services can share one image. logger.step(`${prefix}Pulling images...`); const imagesToPull = [ - ...rotatableToUpdate.map( - (s) => `${env.GHCR_REGISTRY}/tale-${s}:${version}`, - ), - ...statefulToUpdate.map( - (s) => `${env.GHCR_REGISTRY}/tale-${s}:${version}`, + ...new Set( + [...rotatableToUpdate, ...statefulToUpdate].map((s) => + imageRef(serviceConfig, s), + ), ), ]; diff --git a/tools/cli/src/lib/actions/run-deploy.ts b/tools/cli/src/lib/actions/run-deploy.ts index 2515bbabc8..3853977cb8 100644 --- a/tools/cli/src/lib/actions/run-deploy.ts +++ b/tools/cli/src/lib/actions/run-deploy.ts @@ -110,9 +110,9 @@ export async function runDeploy(options: RunDeployOptions): Promise { ); } - // Refuse a cross-baseline in-place deploy (a pre-0.4 instance under a - // >= 0.4 CLI) before pulling images or snapshotting volumes — there is no - // upgrade path across the 0.4 baseline reset. + // Refuse a cross-baseline in-place deploy (a pre-baseline instance under a + // post-baseline CLI) before pulling images or snapshotting volumes — there + // is no upgrade path across BREAKING_BASELINE (0.5.0: Convex → Postgres). await checkBreakingCutover({ deployDir: projectDir, targetVersion: version, diff --git a/tools/cli/src/lib/compose/select-services.test.ts b/tools/cli/src/lib/compose/select-services.test.ts index 493d0c462d..42b6b98905 100644 --- a/tools/cli/src/lib/compose/select-services.test.ts +++ b/tools/cli/src/lib/compose/select-services.test.ts @@ -30,8 +30,9 @@ describe('selectDefaultServices', () => { stop: false, isStopGatedRunning: ALL_RUNNING, }); - expect(sel.leftRunning).toEqual(['db', 'proxy']); + expect(sel.leftRunning).toEqual(['db', 'object-store', 'proxy']); expect(sel.stateful).not.toContain('db'); + expect(sel.stateful).not.toContain('object-store'); expect(sel.stateful).not.toContain('proxy'); }); @@ -49,6 +50,7 @@ describe('selectDefaultServices', () => { 'backend-api', 'backend-worker', 'db', + 'object-store', 'proxy', ]); }); @@ -89,6 +91,7 @@ describe('selectDefaultServices', () => { 'backend-api', 'backend-worker', 'db', + 'object-store', 'proxy', ]); }); diff --git a/tools/cli/src/lib/compose/services/compose-parity.test.ts b/tools/cli/src/lib/compose/services/compose-parity.test.ts index 48ea3fa58b..e4e77e7575 100644 --- a/tools/cli/src/lib/compose/services/compose-parity.test.ts +++ b/tools/cli/src/lib/compose/services/compose-parity.test.ts @@ -8,6 +8,13 @@ import { parse } from 'yaml'; import { setProjectId } from '../../project/project-context'; import { generateStatefulCompose } from '../generators/generate-stateful-compose'; import type { ServiceConfig } from '../types'; +import { + ALL_SERVICES, + THIRD_PARTY_IMAGES, + imageRef, + imageRepoForService, + isValidService, +} from '../types'; import { createBackendApiService, createBackendWorkerService, @@ -249,6 +256,22 @@ describe('blob-backend parity (the deployment cannot accept an upload without it expect(body).not.toContain('rewrite'); }); + test('the proxy injects the backend lanes unconditionally', () => { + // BACKEND_UPSTREAM began as the cutover's reversibility switch; with the + // Convex runtime gone, "unset" must mean the DEFAULT backend, not + // "skip the lanes" — v0.5.0 shipped the skip: uploads (//*), + // live updates (/events) and every machine door 404'd under `tale + // deploy`, which never set the variable. + const entrypoint = readFileSync( + resolve(repoRoot, 'services/proxy/docker-entrypoint.sh'), + 'utf8', + ); + expect(entrypoint).toContain( + 'BACKEND_UPSTREAM="${BACKEND_UPSTREAM:-backend-api:3005}"', + ); + expect(entrypoint).not.toContain('-n "${BACKEND_UPSTREAM'); + }); + test('nothing routes to the retired runtime any more', () => { // The proxy used to fall back to `convex:*` for everything the backend // list did not name. That service is gone, so a fallback is a 502 — every @@ -269,3 +292,89 @@ describe('blob-backend parity (the deployment cannot accept an upload without it expect(password).toContain('OBJECT_STORE_SECRET_KEY:?'); }); }); + +describe('service → image parity', () => { + // The bug this locks down: `tale deploy` derived its pull list mechanically + // as `tale-${service}` while the backend tier runs the platform image, so + // v0.5.0's first fresh deploy pulled two images that were never built + // (tale-backend-api, tale-backend-worker) and aborted. Service → image now + // goes through imageRef/imageRepoForService for the compose creators AND + // the deploy pull list; these tests hold the map to what actually exists. + + test('the backend tier maps to the platform image', () => { + expect(imageRepoForService('backend-api')).toBe('tale-platform'); + expect(imageRepoForService('backend-worker')).toBe('tale-platform'); + }); + + test('every generated tale image matches imageRef for its service', () => { + const stateful = parse(generateStatefulCompose(config, 'localhost')) as { + services: Record; + }; + const taleImageServices = Object.entries(stateful.services).filter( + ([, svc]) => svc.image?.startsWith(`${config.registry}/`), + ); + expect(taleImageServices.length).toBeGreaterThan(0); + for (const [name, svc] of taleImageServices) { + if (!isValidService(name)) { + throw new Error(`unexpected tale-image service: ${name}`); + } + expect(svc.image).toBe(imageRef(config, name)); + } + }); + + test('CLI backend services set every env key compose.yml sets', () => { + // The bug this locks down: compose.yml wired DATABASE_URL into the + // backend tier but the CLI generator did not, so a `tale deploy` stack + // crash-looped on the env schema while `docker compose up` worked. + // Values may differ (the CLI fails closed on DB_PASSWORD); the KEY set + // must not drift. + const cliServices = { + 'backend-api': createBackendApiService(config), + 'backend-worker': createBackendWorkerService(config), + } as const; + for (const [name, cliService] of Object.entries(cliServices)) { + const composeEnv = compose.services[name]?.environment ?? {}; + const cliEnv = cliService.environment ?? {}; + for (const key of Object.keys(composeEnv)) { + expect(`${name}:${key}:${key in cliEnv}`).toBe(`${name}:${key}:true`); + } + } + }); + + test('every service image repo is one release.yml actually builds', () => { + // The pull list can only name images the release pipeline pushes — this + // is the cross-artifact fact the v0.5.0 deploy regression violated. + const releaseYml = readFileSync( + resolve(repoRoot, '.github/workflows/release.yml'), + 'utf8', + ); + const built = new Set( + [...releaseYml.matchAll(/- \{ name: ([a-z0-9-]+) \}/g)].map((m) => m[1]), + ); + expect(built.size).toBeGreaterThan(0); + const taleServices = ALL_SERVICES.filter( + ( + s, + ): s is Exclude< + (typeof ALL_SERVICES)[number], + keyof typeof THIRD_PARTY_IMAGES + > => !(s in THIRD_PARTY_IMAGES), // third-party pins aren't built here + ); + for (const service of taleServices) { + const repo = imageRepoForService(service).replace(/^tale-/, ''); + expect(built).toContain(repo); + } + }); + + test('the object-store pin is one value, shared by every lane', () => { + // compose.yml, the CLI creator, and the deploy pull list must agree on + // the minio pin; THIRD_PARTY_IMAGES is the source the CLI lanes share and + // this holds compose.yml to it. + expect(createObjectStorageService(config).image).toBe( + THIRD_PARTY_IMAGES['object-store'], + ); + expect(compose.services['object-store']?.image).toBe( + THIRD_PARTY_IMAGES['object-store'], + ); + }); +}); diff --git a/tools/cli/src/lib/compose/services/create-backend-services.ts b/tools/cli/src/lib/compose/services/create-backend-services.ts index 15db78bf8f..28724a2e47 100644 --- a/tools/cli/src/lib/compose/services/create-backend-services.ts +++ b/tools/cli/src/lib/compose/services/create-backend-services.ts @@ -1,16 +1,20 @@ import { getProjectId } from '../../../utils/load-env'; import { EXTRA_HOSTS } from '../generators/constants'; import type { ComposeService, ServiceConfig } from '../types'; -import { DEFAULT_LOGGING } from '../types'; +import { DEFAULT_LOGGING, imageRef } from '../types'; /** The api's container port — the proxy's `BACKEND_UPSTREAM` target. */ const BACKEND_API_PORT = 3005; -function backendBase(config: ServiceConfig): ComposeService { +function backendBase( + config: ServiceConfig, + service: 'backend-api' | 'backend-worker', +): ComposeService { return { - // The SAME image as platform: the backend and the web tier share their - // wire contracts, so they must never version-skew. - image: `${config.registry}/tale-platform:${config.version}`, + // The SAME image as platform (imageRepoForService maps the backend tier + // to it): the backend and the web tier share their wire contracts, so + // they must never version-skew. + image: imageRef(config, service), // NET_ADMIN: the entrypoint installs the SSRF egress firewall (iptables // REJECT for IMDS + link-local + RFC1918) before dropping privileges. // This tier opens sockets outside the pinned-IP fetch path (yt-dlp, @@ -33,6 +37,9 @@ function backendBase(config: ServiceConfig): ComposeService { restart: 'unless-stopped', depends_on: { db: { condition: 'service_healthy' }, + // Both roles seed the deployment-default blob connection at boot — + // without the store they crash-loop on ENOTFOUND. Mirrors compose.yml. + 'object-store': { condition: 'service_healthy' }, }, logging: DEFAULT_LOGGING, extra_hosts: EXTRA_HOSTS, @@ -50,11 +57,18 @@ function backendBase(config: ServiceConfig): ComposeService { */ export function createBackendApiService(config: ServiceConfig): ComposeService { return { - ...backendBase(config), + ...backendBase(config, 'backend-api'), container_name: `${getProjectId()}-backend-api`, environment: { TALE_ROLE: 'api', PORT: String(BACKEND_API_PORT), + // The application store. Interpolated by docker compose from the + // project .env at up-time: `tale init` generates DB_PASSWORD; the + // tale-db image's init scripts own the `tale` role and `tale_app` + // database. `:?` fails the up on a missing password instead of booting + // against a guessed default (mirrors the object-store key below). + DATABASE_URL: + 'postgresql://${POSTGRES_USER:-tale}:${DB_PASSWORD:?DB_PASSWORD is required}@db:5432/${APP_DB_NAME:-tale_app}', TALE_CONFIG_DIR: '/app/data', SANDBOX_HTTP_API_BASE_URL: `http://backend-api:${BACKEND_API_PORT}`, // The bundled blob store the backend seeds the deployment default @@ -94,10 +108,13 @@ export function createBackendWorkerService( config: ServiceConfig, ): ComposeService { return { - ...backendBase(config), + ...backendBase(config, 'backend-worker'), container_name: `${getProjectId()}-backend-worker`, environment: { TALE_ROLE: 'worker', + // Same store as the api — see createBackendApiService. + DATABASE_URL: + 'postgresql://${POSTGRES_USER:-tale}:${DB_PASSWORD:?DB_PASSWORD is required}@db:5432/${APP_DB_NAME:-tale_app}', TALE_CONFIG_DIR: '/app/data', SANDBOX_HTTP_API_BASE_URL: `http://backend-api:${BACKEND_API_PORT}`, // The bundled blob store the backend seeds the deployment default diff --git a/tools/cli/src/lib/compose/services/create-db-service.ts b/tools/cli/src/lib/compose/services/create-db-service.ts index 6ecaebec3b..8f216d5c22 100644 --- a/tools/cli/src/lib/compose/services/create-db-service.ts +++ b/tools/cli/src/lib/compose/services/create-db-service.ts @@ -1,10 +1,10 @@ import { getProjectId } from '../../../utils/load-env'; import type { ComposeService, ServiceConfig } from '../types'; -import { DEFAULT_LOGGING } from '../types'; +import { DEFAULT_LOGGING, imageRef } from '../types'; export function createDbService(config: ServiceConfig): ComposeService { return { - image: `${config.registry}/tale-db:${config.version}`, + image: imageRef(config, 'db'), container_name: `${getProjectId()}-db`, stop_grace_period: '60s', shm_size: '256mb', diff --git a/tools/cli/src/lib/compose/services/create-object-storage-service.ts b/tools/cli/src/lib/compose/services/create-object-storage-service.ts index cea8c22b0f..a7e28fedbd 100644 --- a/tools/cli/src/lib/compose/services/create-object-storage-service.ts +++ b/tools/cli/src/lib/compose/services/create-object-storage-service.ts @@ -1,6 +1,6 @@ import { getProjectId } from '../../../utils/load-env'; import type { ComposeService, ServiceConfig } from '../types'; -import { DEFAULT_LOGGING } from '../types'; +import { DEFAULT_LOGGING, imageRef } from '../types'; /** * The deployment's BLOB store. @@ -18,10 +18,10 @@ import { DEFAULT_LOGGING } from '../types'; * compose-parity.test.ts asserts they agree. */ export function createObjectStorageService( - _config: ServiceConfig, + config: ServiceConfig, ): ComposeService { return { - image: 'minio/minio:RELEASE.2025-04-22T22-12-26Z', + image: imageRef(config, 'object-store'), container_name: `${getProjectId()}-object-store`, // Let in-flight multipart writes finish before SIGKILL. stop_grace_period: '30s', diff --git a/tools/cli/src/lib/compose/services/create-platform-service.ts b/tools/cli/src/lib/compose/services/create-platform-service.ts index a7d64ec63f..7b8bdf1726 100644 --- a/tools/cli/src/lib/compose/services/create-platform-service.ts +++ b/tools/cli/src/lib/compose/services/create-platform-service.ts @@ -1,14 +1,14 @@ import { getProjectId } from '../../../utils/load-env'; import { EXTRA_HOSTS } from '../generators/constants'; import type { ComposeService, DeploymentColor, ServiceConfig } from '../types'; -import { DEFAULT_LOGGING } from '../types'; +import { DEFAULT_LOGGING, imageRef } from '../types'; export function createPlatformService( config: ServiceConfig, color: DeploymentColor, ): ComposeService { return { - image: `${config.registry}/tale-platform:${config.version}`, + image: imageRef(config, 'platform'), container_name: `${getProjectId()}-platform-${color}`, // Phase 2 (split): /app/data lives in convex-data, mounted read-only so // server.ts can watch config files and serve branding images. Platform diff --git a/tools/cli/src/lib/compose/services/create-proxy-service.ts b/tools/cli/src/lib/compose/services/create-proxy-service.ts index be7aa30880..0493c9b55b 100644 --- a/tools/cli/src/lib/compose/services/create-proxy-service.ts +++ b/tools/cli/src/lib/compose/services/create-proxy-service.ts @@ -1,14 +1,14 @@ import { getProjectId } from '../../../utils/load-env'; import { EXTRA_HOSTS } from '../generators/constants'; import type { ComposeService, ServiceConfig } from '../types'; -import { DEFAULT_LOGGING } from '../types'; +import { DEFAULT_LOGGING, imageRef } from '../types'; export function createProxyService( config: ServiceConfig, hostAlias: string, ): ComposeService { return { - image: `${config.registry}/tale-proxy:${config.version}`, + image: imageRef(config, 'proxy'), container_name: `${getProjectId()}-proxy`, ports: ['80:80', '443:443'], volumes: ['caddy-data:/data', 'caddy-config:/config'], diff --git a/tools/cli/src/lib/compose/services/create-sandbox-egress-service.ts b/tools/cli/src/lib/compose/services/create-sandbox-egress-service.ts index 269a801a8b..effea842f6 100644 --- a/tools/cli/src/lib/compose/services/create-sandbox-egress-service.ts +++ b/tools/cli/src/lib/compose/services/create-sandbox-egress-service.ts @@ -1,6 +1,6 @@ import { getProjectId } from '../../../utils/load-env'; import type { ComposeService, ServiceConfig } from '../types'; -import { DEFAULT_LOGGING } from '../types'; +import { DEFAULT_LOGGING, imageRef } from '../types'; /** * Sandbox egress proxy — tinyproxy on `sandbox` (faces the runtime @@ -35,7 +35,7 @@ export function createSandboxEgressService( // sandbox network. The container-local iptables SSRF fence (IMDS/RFC1918) // runs inside it. return { - image: `${config.registry}/tale-sandbox-egress:${config.version}`, + image: imageRef(config, 'sandbox-egress'), container_name: `${getProjectId()}-sandbox-egress`, env_file: ['.env'], restart: 'unless-stopped', diff --git a/tools/cli/src/lib/compose/services/create-sandbox-llm-gateway-service.ts b/tools/cli/src/lib/compose/services/create-sandbox-llm-gateway-service.ts index 209b261dc4..25b031c9a0 100644 --- a/tools/cli/src/lib/compose/services/create-sandbox-llm-gateway-service.ts +++ b/tools/cli/src/lib/compose/services/create-sandbox-llm-gateway-service.ts @@ -1,6 +1,6 @@ import { getProjectId } from '../../../utils/load-env'; import type { ComposeService, ServiceConfig } from '../types'; -import { DEFAULT_LOGGING } from '../types'; +import { DEFAULT_LOGGING, imageRef } from '../types'; /** * Sandbox LLM gateway service. Fronts every model provider with a single @@ -24,7 +24,7 @@ export function createSandboxLlmGatewayService( config: ServiceConfig, ): ComposeService { return { - image: `${config.registry}/tale-sandbox-llm-gateway:${config.version}`, + image: imageRef(config, 'sandbox-llm-gateway'), container_name: `${getProjectId()}-sandbox-llm-gateway`, env_file: ['.env'], restart: 'unless-stopped', diff --git a/tools/cli/src/lib/compose/services/create-sandbox-service.ts b/tools/cli/src/lib/compose/services/create-sandbox-service.ts index 889ef05591..fbd099225f 100644 --- a/tools/cli/src/lib/compose/services/create-sandbox-service.ts +++ b/tools/cli/src/lib/compose/services/create-sandbox-service.ts @@ -1,6 +1,6 @@ import { getProjectId } from '../../../utils/load-env'; import type { ComposeService, ServiceConfig } from '../types'; -import { DEFAULT_LOGGING } from '../types'; +import { DEFAULT_LOGGING, imageRef } from '../types'; /** * Sandbox spawner — thin stateless docker-run service. @@ -30,7 +30,7 @@ import { DEFAULT_LOGGING } from '../types'; */ export function createSandboxService(config: ServiceConfig): ComposeService { return { - image: `${config.registry}/tale-sandbox:${config.version}`, + image: imageRef(config, 'sandbox'), container_name: `${getProjectId()}-sandbox`, // Graceful drain on stop: the spawner's SIGTERM handler stops accepting new // executions, cancels in-flight ones, and waits up to ~20s for them to tear diff --git a/tools/cli/src/lib/compose/types.ts b/tools/cli/src/lib/compose/types.ts index 07637a757b..3677f11014 100644 --- a/tools/cli/src/lib/compose/types.ts +++ b/tools/cli/src/lib/compose/types.ts @@ -83,6 +83,10 @@ const BACKEND_TIER_SERVICES = ['backend-api', 'backend-worker'] as const; export const STATEFUL_SERVICES = [ 'db', + // The blob store: S3 is the only blob backend, so a deployment without one + // refuses every upload. Left out of this list, `tale deploy`'s explicit + // `up -d ` never started it at all. + 'object-store', 'proxy', 'sandbox-llm-gateway', // Sandbox tier — the single spawner and its egress proxy. Rolled in place @@ -107,7 +111,7 @@ export const ALL_SERVICES = [ * because recreating Postgres / the proxy means an availability blip that a * routine app-tier roll shouldn't incur. */ -export const STOP_GATED_SERVICES = ['db', 'proxy'] as const; +export const STOP_GATED_SERVICES = ['db', 'object-store', 'proxy'] as const; /** * Always-roll-in-place tier — deployed via the stateful compose on EVERY * default deploy. `sandbox-llm-gateway` is a singleton that owns the single @@ -143,3 +147,39 @@ export function isRotatableService(name: string): name is RotatableService { export function isStatefulService(name: string): name is StatefulService { return (STATEFUL_SERVICES as readonly string[]).includes(name); } + +/** + * Services that run a pinned THIRD-PARTY image instead of a `tale-*` one from + * our registry. The pin lives here — the single source the compose creators + * and the deploy pull list share. + */ +export const THIRD_PARTY_IMAGES = { + 'object-store': 'minio/minio:RELEASE.2025-04-22T22-12-26Z', +} as const satisfies Partial>; + +/** + * The `tale-*` image repository a service runs, without the registry prefix. + * Every service ships its own `tale-` image EXCEPT the backend tier, + * which runs the platform image (`TALE_ROLE` picks api/worker at boot) — + * deriving the repository mechanically from the service name invents images + * that were never built. Callers wanting a pullable reference use `imageRef`, + * which also covers the third-party services this function does not. + */ +export function imageRepoForService( + service: Exclude, +): string { + return (BACKEND_TIER_SERVICES as readonly string[]).includes(service) + ? 'tale-platform' + : `tale-${service}`; +} + +/** The full image reference a service runs under the given registry+version. */ +export function imageRef( + config: Pick, + service: ServiceName, +): string { + if (service in THIRD_PARTY_IMAGES) { + return THIRD_PARTY_IMAGES[service as keyof typeof THIRD_PARTY_IMAGES]; + } + return `${config.registry}/${imageRepoForService(service as Exclude)}:${config.version}`; +} diff --git a/tools/cli/src/lib/docker/wait-for-healthy.ts b/tools/cli/src/lib/docker/wait-for-healthy.ts index 25ec8a5bb2..ee634f8cce 100644 --- a/tools/cli/src/lib/docker/wait-for-healthy.ts +++ b/tools/cli/src/lib/docker/wait-for-healthy.ts @@ -103,6 +103,15 @@ export async function waitForHealthy( return true; } + // A container with NO configured healthcheck can never report + // 'healthy' — running is the strongest signal it has. The worker tier + // disables its check deliberately (no HTTP surface), and waiting on it + // burned the full timeout on every deploy. + if (health === 'none') { + logger.success(`${containerName} is running (no healthcheck defined)`); + return true; + } + logger.debug(`${containerName} health status: ${health}`); if (emitHeartbeat && Date.now() - lastHeartbeat >= HEARTBEAT_MS) {