Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion services/platform/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions services/platform/docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions services/platform/tests/integration/container-image-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,38 @@ async function main(): Promise<number> {
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) {
Expand Down
36 changes: 16 additions & 20 deletions services/proxy/docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<EOF
echo "Backend routing: 0.5 lanes → ${BACKEND_UPSTREAM}"
BACKEND_BLOCK=$(cat <<EOF
handle /api/auth/* {
reverse_proxy ${BACKEND_UPSTREAM}
}
Expand Down Expand Up @@ -242,19 +245,12 @@ EOF
/# BACKEND_METRICS_PLACEHOLDER/ { print block; next }
{ print }
' "$CADDYFILE" > "${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
Expand Down
4 changes: 2 additions & 2 deletions tools/cli/src/commands/deploy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
11 changes: 6 additions & 5 deletions tools/cli/src/lib/actions/breaking-cutover-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 9 additions & 5 deletions tools/cli/src/lib/actions/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type StatefulService,
type StopGatedService,
STOP_GATED_SERVICES,
imageRef,
isRotatableService,
isStatefulService,
} from '../compose/types';
Expand Down Expand Up @@ -275,13 +276,16 @@ export async function deploy(options: DeployOptions): Promise<void> {
// 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),
),
),
];

Expand Down
6 changes: 3 additions & 3 deletions tools/cli/src/lib/actions/run-deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,9 @@ export async function runDeploy(options: RunDeployOptions): Promise<void> {
);
}

// 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,
Expand Down
5 changes: 4 additions & 1 deletion tools/cli/src/lib/compose/select-services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand All @@ -49,6 +50,7 @@ describe('selectDefaultServices', () => {
'backend-api',
'backend-worker',
'db',
'object-store',
'proxy',
]);
});
Expand Down Expand Up @@ -89,6 +91,7 @@ describe('selectDefaultServices', () => {
'backend-api',
'backend-worker',
'db',
'object-store',
'proxy',
]);
});
Expand Down
109 changes: 109 additions & 0 deletions tools/cli/src/lib/compose/services/compose-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (/<bucket>/*),
// 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
Expand All @@ -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<string, { image?: string }>;
};
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'],
);
});
});
Loading
Loading