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) {