diff --git a/CHANGELOG.md b/CHANGELOG.md index db764056d..805a952bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,145 @@ All notable changes to Openship. Versions follow [semver](https://semver.org); the in-app updater surfaces critical advisories from `release-advisories.json`. +## 0.6.8 + +Compose projects now deploy as the services they declare, with their build +arguments and dynamic environment intact from import through rollback. Instance +moves can go directly from one self-hosted installation to another without +copying an encryption key, and the file workflow shows and filters what it will +carry. This release also hardens custom-domain ownership, host-port allocation, +Git credential boundaries, and edge recovery across Linux and macOS. + +### Instance transfer + +- **Move an instance directly, credentials included** — the destination creates a + single-use, ten-minute receive code for Replace or Merge mode; the source + encrypts the selected database rows and plaintext credential bundle directly + to that destination, and the destination immediately re-encrypts every + transferred secret under its own instance key. + Server SSH passwords and keys, environment variables, tokens, registry and DNS + credentials, and backup credentials move without sharing either instance's + `BETTER_AUTH_SECRET` or managing a transfer passphrase (#656). +- **See and filter the export before downloading it** — Settings now shows the + durable row count and a count beside each optional history group. Analytics, + audit/notification activity, backup history, incident history, and migration + history can be included independently; configuration, projects, services, + servers, users, and credential records always stay in the portable core. + Leaving the selection absent preserves the legacy full export (#656). +- **Credential-bearing files cannot be imported half-unlocked** — the offline + export/import path remains available, but a file containing a sealed credential + bundle now requires its passphrase before any database write. Invalid bundles + are rejected before restore rather than importing rows whose secrets were + silently scrubbed. + +### Compose and environments + +- **Compose services drive their own builds** — a project with `composePath` is + materialized into the service pipeline instead of falling through to one + generic Dockerfile build. Map and list forms of `build.args`, bare arguments, + and `${...}` expressions are stored per service and survive CLI sync, + reconciliation, migration adoption, redeploy snapshots, and rollback. Docker + socket, SSH, batch, and cloud builds all use the same argument resolver, and + Openship's build-command logging does not print argument values (#689). +- **Removing Compose build arguments removes the stored arguments** — an empty + `args` map or a deleted `args` key clears stale values and interpolation + provenance, while snapshots created before build arguments existed remain + non-destructive during rollback (#689). +- **Unsupported Compose builds fail before deployment** — malformed arguments, + repository-escaping or remote contexts, and build features Openship cannot + reproduce no longer leave the old service shape running silently. A declared + Compose project with no materialized rows is scanned once to bootstrap its + topology, while an explicit single-app choice remains single-app. +- **Dynamic Compose environment resolves at deploy time** — raw expressions such + as + `postgresql://user:${POSTGRES_PASSWORD:?set it}@postgres:5432/app` are evaluated + against the final project, frozen-release, inline, and service-scoped layers. + Embedded requirements, passthrough keys, defaults, nested expressions, and + escaped dollars retain Compose semantics; an unresolved required variable + fails that service with the missing key named (#673). +- **Manual service variables are durable overrides** — the service Environment + tab now owns service-scoped environment rows rather than rewriting the + Compose-owned `service.environment` object. Values added in the UI therefore + survive a Compose reparse and project redeploy. Saving an unrevealed secret — + including renaming its key — preserves its existing ciphertext instead of + storing the mask or deleting the value. + +### Deployments and routing + +- **Prebuilt container images can be tracked as releases** — a single-app + project can resolve versions from GitHub Releases or an HTTPS version feed, + optionally pin a version, and render that tag into a registry image template. + Deploy and Update pull the application image directly on Docker or Cloud — no + Git clone, Dockerfile build, or release archive — while drift detection compares + deployed and upstream semver. Successful Docker releases freeze the immutable + registry digest so rollback can reacquire the exact image after local retention + expires; source changes apply atomically across environments and are available + in both the dashboard Source tab and `openship project release-image` (#694). +- **Apply really means restart without rebuild for a single app** — Docker and + host-mode projects reuse the active deployment's retained artifact even though + they have no service rows. Apply never fetches Git or silently turns into a + rebuild; if the artifact is gone, it says to Redeploy instead (#674). +- **The DNS checkpoint covers Docker and every Compose route** — pressing Deploy + now shows records for single-app custom domains, service scalar routes, and + multi-route Compose endpoints. Multiple hostnames appear together, `www` is + grouped with its apex, and a remote deployment preview uses the selected + server's public address rather than this instance's address (#663). +- **Redeploy cannot detach a custom domain** — deployment reconciliation preserves + both pending and verified custom-domain rows and their live targets when a + release omits them. A failed deployment rolls back only generated managed + routes, never user-owned custom configuration, and concurrent hostname claims + use database-authoritative ownership instead of letting the losing project + route another project's domain (#675). +- **Stopped containers cannot turn an old hostname into another app** — loopback + ports now have database-enforced, physical-target-wide ownership across + organizations. Allocation reserves the port before Docker binds it, treats + local and “This Server” as one namespace, and persists every routed Compose + port through stopped and reconciling states. A stale vhost/TLS certificate can + therefore never be repointed accidentally when a later deployment starts + listening on the same number (#682, GHSA-284v-9jw3-jfhx). +- **Retry routing repairs the edge first** — when `openship-edge` is stopped or + missing, Retry Routing reconciles and health-checks it before touching vhosts. + An unrecoverable edge returns an actionable warning immediately instead of + hanging until the route request times out (#693). + +### Hosts and edge + +- **Docker source acquisition follows the actual transport** — preflight and the + build pipeline now share one source-location plan. Local socket and TCP daemon + builds prepare source on the API host, remote SSH Docker builds may clone on + the target, and bare or cloud builds retain their own boundaries. A local + server row therefore no longer asks a nonexistent remote clone path to use + ambient Git credentials (#654). +- **macOS edge mounts use physical host paths** — bind sources are canonicalized + on the machine that owns the Docker daemon, so `/var` and `/etc` resolve to + their `/private/...` targets before Docker Desktop or OrbStack sees them. A + healthy-looking edge with stale logical mounts is recreated onto the same + vhost, certificate, ACME, and static-data directories (#692). + +### Security + +- **An explicit GitHub CLI config directory is an isolation boundary** — fallback + token discovery reads exactly the `hosts.yml` selected by GitHub CLI precedence + on Linux, macOS, and Windows. If `GH_CONFIG_DIR` or `XDG_CONFIG_HOME` is set but + missing or tokenless, Openship no longer falls through to another user's home + directory and borrows that credential (#687). + +### Openship Mail + +- **Inbound SMTP has DNS inside Postfix's chroot** — every mail-engine boot now + refreshes `resolv.conf` and the supporting NSS files inside the persistent + Postfix spool before the supervisor starts. The engine fails closed if no + resolver can be installed, rather than starting an SMTP service that rejects + every legitimate sender with `450 Helo Host not found` (#686). + +### Dashboard + +- **Project environments update without a refresh** — creating an environment + commits it to shared state immediately and reconciles the canonical list; + deleting one removes it from every affected cache and navigates to the best + surviving sibling. A failed follow-up read no longer makes a successful create + look like it failed (#657). + ## 0.6.6 Mail learns to receive, and third-party secrets get one home. Openship Mail now @@ -28,7 +167,7 @@ own page, and uploads stop failing at 1 MB. - **One store for third-party secrets** — a provider registry (container registries, Cloudflare, and room for what comes next) behind one table, with every secret sealed in a single `enc1:` envelope rather than a column per field. - A credential is verified against its provider *before* it is stored, so a bad + A credential is verified against its provider _before_ it is stored, so a bad token is rejected where you paste it instead of where a deploy needs it. - **Private images pull on every host** — registry auth is resolved per image from that store (#581). On a remote host the config goes to a temporary @@ -117,7 +256,7 @@ own page, and uploads stop failing at 1 MB. - **`openship reset-admin-password` works on a Compose install** — it authenticated with `~/.openship/internal-token`, a file the Compose path never writes: the api container is booted with the `INTERNAL_TOKEN` from - `~/.openship/compose/.env`. So on a Compose box the command *minted* a brand-new + `~/.openship/compose/.env`. So on a Compose box the command _minted_ a brand-new random token, sent that, and reported `Unauthorized` — the lockout-recovery command was unusable on exactly the install that needed it. Which token this box is running with is now resolved in one place, readers never mint, and a @@ -956,7 +1095,7 @@ across the MCP integration and custom domains. controls are editable on a first deploy too, and the choice is applied when the project is created. They used to render read-only until the project existed, which was the one moment you were actually looking at them. The card also names - what a retained version *is* on your project — built files for a static site, + what a retained version _is_ on your project — built files for a static site, images otherwise — instead of talking about images either way. - **The wizard's Advanced panel says what's in it** — it listed only the build location while hiding the rollback window and clone location; it now names each @@ -992,6 +1131,7 @@ Upgrade note: this release drops an unused `artifact_retained_at` column from th per-service deployment table. Nothing read or wrote it. ### MCP + - **Guided deploy flows** — the MCP server now ships a prompt catalog (`deploy-from-git`, `deploy-a-folder`, `install-catalog-app`, and an orientation overview) so an AI client follows the correct tool sequence @@ -1006,11 +1146,12 @@ per-service deployment table. Nothing read or wrote it. nothing to work with. ### Custom domains + - **`www` is its own domain, not an attachment to yours** — "Include www" always created a second hostname, but the pieces around it still treated the pair as one thing. Renewing SSL for a domain issued the `www` certificate inside the same operation, unguarded: a `www` that wasn't pointed at the server yet failed - *after* the apex had already succeeded, and the apex was reported as broken. + _after_ the apex had already succeeded, and the apex was reported as broken. Adding a domain with the switch on also showed you only the apex's DNS record, so `www` never resolved, its certificate could never be issued, and every deploy retried a hostname that had been set up to fail. Both hostnames now get @@ -1056,6 +1197,7 @@ reliable, and a batch of fixes lands across the control plane for a more stable release. ### CLI + - **A finished install opens the control panel, not the setup wizard** — bare `openship` (and the from-source `openship-dev`) now recognizes a Docker Compose install (the default on Linux). Re-running after setup manages the running @@ -1066,6 +1208,7 @@ release. installed (which reported "stopped" for a healthy stack). ### Migrations & remote Docker + - **The SSH → Docker bridge no longer hangs or false-fails a healthy server** — migrating from another platform (Coolify/Dokploy/Dokku) or adopting a running Docker host could stall the reachability check — or drop the request outright — @@ -1075,12 +1218,14 @@ release. fresh connection when a channel opens dead. Contributed by @jbermudez00 (#271). ### Mail + - **Mail-server setup works from the desktop app** — the iRedMail engine is now shipped inside the packaged desktop app (and the CLI bundle) and located by an explicit path, fixing the `Transfer iRedMail Engine … tar: could not chdir` failure on install. ### Fixes + - **Self-hosted GitHub connect is token-first** — a remote (VPS) instance pastes an access token inline in the Library, with no `gh auth login` hints; the gh CLI path is now desktop-only, where it belongs. @@ -1098,6 +1243,7 @@ A security fix for the edge, migrations that behave like a native repo project, and a batch of routing/reliability fixes. ### Security + - **Unrouted HTTPS hosts are rejected, not cross-served** — the edge now owns a `443` default server that refuses any hostname it doesn't route (one you removed, never added, or merely pointed at the box's IP). Before this, such a @@ -1106,10 +1252,11 @@ and a batch of routing/reliability fixes. bare and containerized edge. Critical — see the in-app advisory. ### Migrations + - **A migrated project is now a native repo project** — a migrated compose stack redeploys like any repo project: it reclones and **rebuilds `build:` services** and pulls `image:` ones, instead of failing on a frozen build tag (`404 no such - image`). The running image is reused only **once**, at cutover. +image`). The running image is reused only **once**, at cutover. - **The whole compose is the deployment plan** — the migrate screen lists every repo compose service, not just running containers, so a service with no container (e.g. `redis`, or an app that wasn't up) is built/pulled and routed @@ -1125,6 +1272,7 @@ and a batch of routing/reliability fixes. match for every service. ### Fixes + - **Service state is never guessed from the database** — Start/Stop/Restart, logs, terminal, backup/restore and volume sizes resolve the container against the host first, so a redeploy that replaced it no longer leaves them failing with @@ -1146,12 +1294,14 @@ Native Apple Silicon builds, drop-in compatibility with other platforms' deploy config, and a batch of self-hosting and reliability fixes. ### Downloads + - **Native Apple Silicon (arm64) desktop app** — macOS now ships separate **arm64** and Intel **x64** dmgs (both built and SHA-256-checksummed in CI), so Apple Silicon Macs run natively instead of under Rosetta. Windows (x64) and Linux (AppImage) are unchanged. ### Deploy · stack detection + - **Deploys repos already configured for another platform, as-is** — the stack detector now reads **`railway.toml`/`railway.json`** and **`vercel.json`** (build / install / start / output commands, framework, and routing) and folds @@ -1163,6 +1313,7 @@ config, and a batch of self-hosting and reliability fixes. same engine, for the repo root and each monorepo sub-app. ### Self-hosting + - **Deploys to your own server by default** — a self-hosted instance targets the server it runs on, never Openship Cloud, unless you explicitly choose cloud. - **Health checks work when the control plane is containerized** — the @@ -1173,11 +1324,13 @@ config, and a batch of self-hosting and reliability fixes. a box already broken by the old pin. ### CLI + - **`openship stop` actually stops** — the service and its children are reaped by process group and any ports it held are swept, so a restart can't strand the old process on a new port. ### Reliability & fixes + - Malformed JSON request bodies now return **400**, not 500. - **Cloud static-output path is confined** — the Pages output path resolves through one shared, sandboxed resolver so a build can't escape its output dir. @@ -1195,6 +1348,7 @@ Apps and Jobs grow up, a self-hosted server can now talk to GitHub on its own, Backups get a real home, and a batch of delete/login/database reliability fixes. ### Apps + - **Day-2 app settings** — installed apps now expose a curated settings surface (schema-driven) so you can change an app's real config after install without digging through raw env. Edits go through a safe env-merge and tell you whether @@ -1208,11 +1362,13 @@ Backups get a real home, and a batch of delete/login/database reliability fixes. as **Coming soon** (dimmed, not installable) for this release. ### Jobs + - **Automated backups show up in Jobs** (read-only) — backup schedules run on the same job runner as everything else (zero duplication), so their next/last run sits right next to your system and custom jobs. ### Servers · GitHub + - **Connect GitHub on a server** — each self-hosted server now authenticates to GitHub on its own, from a dedicated **GitHub** tab: sign in with a device code (like `gh`), paste a token, generate an SSH key to add to your account, or use @@ -1222,16 +1378,19 @@ Backups get a real home, and a batch of delete/login/database reliability fixes. work without your desktop online. ### Backups + - **Redesigned Backups** — per-destination storage stats, a sticky status rail, and clickable rows that open a per-destination detail page showing exactly which projects and services back up there. ### Cloud + - **Per-user project cap** — Openship Cloud enforces a hard cap on projects per user (env `CLOUD_MAX_PROJECTS_PER_USER`, default 2), at both create and folder-upload/ensure. Self-hosted is unmetered. ### Reliability & polish + - **Deletes never get stuck** — project deletion shows a real **Deleting** state, and when the source teardown can't complete you get a clean **"Delete from storage"** option that drops the record immediately (leftover resources are @@ -1254,6 +1413,7 @@ A large feature + hardening release across the deploy flow, the app catalog, routing, servers, jobs, and the build toolchain. ### Deploy + - Redesigned **"Where do you want to deploy?"** step: unified page-style header with the **Continue** action aligned to the config column, and a **collapsed, searchable server picker** (with an inline "Add your own server"). @@ -1262,6 +1422,7 @@ routing, servers, jobs, and the build toolchain. workspace-prepare, cloud local-build). Fixes `pnpm: not found` on deploy. ### Apps + - **Searchable, category-tabbed one-click app catalog**, expanded to 15 production-ready self-hosted apps: Convex, n8n, Ghost, Directus, NocoDB, Metabase, Grafana, Gitea, code-server, Uptime Kuma, Vaultwarden, FreshRSS, @@ -1269,6 +1430,7 @@ routing, servers, jobs, and the build toolchain. - Home "Apps" card refreshed; catalog cards show real brand logos. ### Routing & domains (single source of truth) + - Custom domains on **service-based projects** now flow through the same verify → DNS-records → SSL pipe as single-app domains: a verifiable pending row is minted on add/create/edit, one canonical hostname normalizer is shared @@ -1276,24 +1438,29 @@ routing, servers, jobs, and the build toolchain. certbot is gated on verification (no wasted Let's Encrypt attempts). ### Servers + - Redesigned servers page (tabs, live reachability, country flags). - Per-server **Git** auth tab (token / SSH key / deploy keys) with a comfortable full-width card; connect-on-server credentials honored in preflight. ### Jobs + - Jobs page gains **search** + an at-a-glance **status filter sidebar** (running / failed / scheduled / disabled), shown once custom jobs exist. ### Team & workspace + - **Invite member** is only offered where it works (team orgs on a multi-user instance); single-user/personal instances are guided to migrate or create a team org instead of hitting a dead end. ### Add service + - The **Openship Cloud** image tab shows a "Connect to Openship Cloud" CTA when the instance isn't linked, and the source switcher has clearer contrast. ### Other + - Docker migration flow, per-project/service backups, unified connectivity checks, Arabic (RTL) localization, marketing roadmap page, and desktop window polish (macOS traffic-light inset). diff --git a/apps/api/src/lib/compose-parser.ts b/apps/api/src/lib/compose-parser.ts index 705de7fc6..e4fe72c7a 100644 --- a/apps/api/src/lib/compose-parser.ts +++ b/apps/api/src/lib/compose-parser.ts @@ -8,6 +8,7 @@ import { parse as parseYaml } from "yaml"; import { commandToArgv, + composeBuildIssues, composeMountIssues, composeMountToSpec, composePortToSpec, @@ -33,9 +34,18 @@ export interface ComposeService { image?: string; build?: string; dockerfile?: string; + /** Per-service Docker build arguments from `build.args`. Kept separate from + * runtime environment: two services may build the same Dockerfile with + * different args, and those values must reach only their own image build. */ + buildArgs?: Record; ports: string[]; dependsOn: string[]; environment: Record; + /** + * Original Compose expressions, kept only until persistence converts those + * keys back to their raw form. Never returned by service read APIs. + */ + environmentTemplates?: Record; environmentMeta?: Record; volumes: string[]; command?: string; @@ -115,6 +125,8 @@ export interface ComposeEnvironmentMeta { /** The file declares this one mandatory (`:?` / `?`). Only set when it also * came back unresolved, i.e. alongside `source: "missing"`. */ required?: boolean; + /** Unresolved variable names inside an embedded expression. Names only. */ + unresolvedVariables?: string[]; } export interface ComposeParseOptions { @@ -140,7 +152,10 @@ export interface ComposeParseOptions { * `required` in `environmentMeta` (the wizard's "Needs value" state) and listed * in `missingRequired`. */ -export function parseComposeFile(content: string, options: ComposeParseOptions = {}): ComposeParseResult { +export function parseComposeFile( + content: string, + options: ComposeParseOptions = {}, +): ComposeParseResult { // `merge: true` is required, not cosmetic: the parser defaults to YAML 1.2, // where `<<` is an ordinary key. Compose files that hoist shared config into // an anchor (`x-environment: &shared` + `<<: *shared`) otherwise lose every @@ -163,21 +178,52 @@ export function parseComposeFile(content: string, options: ComposeParseOptions = const svc = def as Record; const build = parseBuild(svc.build, interpolationEnv); const environment = parseEnvironment(svc.environment, interpolationEnv); - const advanced = parseAdvanced(svc, interpolationEnv, name, unsupported); - collectUnsupported(name, svc, unsupported); + const parsedAdvanced = parseAdvanced(svc, interpolationEnv, name, unsupported); + // An empty marker is meaningful: it says this BUILD declaration came from a + // provenance-aware parser. Stamp it even when the current `build:` block has + // no `args` key, so removing that key clears previously stored args through + // non-authoritative snapshot-safe sync paths. A legacy snapshot has no marker + // and therefore still treats an omitted buildArgs field as "no opinion". + const hasEnvironmentDeclaration = Object.hasOwn(svc, "environment"); + const hasBuildDeclaration = Object.hasOwn(svc, "build"); + const advanced: ComposeAdvanced | undefined = + parsedAdvanced || hasEnvironmentDeclaration || hasBuildDeclaration + ? { + ...(parsedAdvanced ?? {}), + ...(hasEnvironmentDeclaration && { + environmentTemplateKeys: Object.keys(environment.templates), + }), + ...(hasBuildDeclaration && { + buildArgTemplateKeys: build.templateKeys, + }), + } + : undefined; + collectUnsupported(name, svc, unsupported, interpolationEnv); services.push({ name, - image: typeof svc.image === "string" ? interpolateComposeString(svc.image, interpolationEnv) : undefined, + image: + typeof svc.image === "string" + ? interpolateComposeString(svc.image, interpolationEnv) + : undefined, build: build.context, dockerfile: build.dockerfile, + ...(build.args && { buildArgs: build.args }), ports: parsePorts(svc.ports, interpolationEnv), dependsOn: parseDependsOn(svc.depends_on), environment: environment.values, - ...(Object.keys(environment.metadata).length > 0 && { environmentMeta: environment.metadata }), + ...(Object.keys(environment.templates).length > 0 && { + environmentTemplates: environment.templates, + }), + ...(Object.keys(environment.metadata).length > 0 && { + environmentMeta: environment.metadata, + }), volumes: parseVolumes(svc.volumes, interpolationEnv), ...parseCommand(svc.command, interpolationEnv), - restart: typeof svc.restart === "string" ? interpolateComposeString(svc.restart, interpolationEnv) : undefined, + restart: + typeof svc.restart === "string" + ? interpolateComposeString(svc.restart, interpolationEnv) + : undefined, ...(advanced && { advanced }), }); } @@ -234,16 +280,96 @@ function reportMissingRequired( // ─── Field parsers ─────────────────────────────────────────────────────────── -function parseBuild(build: unknown, env: Record): { context?: string; dockerfile?: string } { - if (typeof build === "string") return { context: interpolateComposeString(build, env) }; +function parseBuildArgs( + raw: unknown, + env: Record, +): { + args?: Record; + templateKeys: string[]; +} { + const args: Record = {}; + const templateKeys = new Set(); + + const preserveValue = (key: string, value: string): string => { + // Evaluate once only to collect `${VAR:?message}` diagnostics. Persist the + // expression itself so the final deployment-scoped build environment—not a + // scan-time .env preview—decides its value. + if (value.includes("$")) { + interpolateComposeString(value, env); + templateKeys.add(key); + } else { + // List form permits duplicate keys; the last declaration wins in Compose, + // so its provenance must win here too. + templateKeys.delete(key); + } + return value; + }; + + // Compose accepts both map form (`KEY: value`) and list form + // (`KEY=value` / bare `KEY`). A bare/null value imports from Compose's + // invocation environment at BUILD time. Values containing Compose expressions + // are also kept raw and resolved at build time; eagerly persisting their + // scan-time value leaked .env values and made later env edits ineffective. + if (Array.isArray(raw)) { + for (const entry of raw) { + if (typeof entry !== "string") continue; + const equals = entry.indexOf("="); + const rawKey = equals >= 0 ? entry.slice(0, equals) : entry; + const key = interpolateComposeString(rawKey, env).trim(); + if (!key) continue; + if (equals >= 0) args[key] = preserveValue(key, entry.slice(equals + 1)); + else { + args[key] = null; + templateKeys.delete(key); + } + } + } else if (raw && typeof raw === "object") { + for (const [key, value] of Object.entries(raw as Record)) { + if (!key) continue; + if (value === null || value === undefined) { + args[key] = null; + templateKeys.delete(key); + } else if (["string", "number", "boolean"].includes(typeof value)) { + args[key] = preserveValue(key, String(value)); + } + } + } + + return { + ...(Object.keys(args).length > 0 && { args }), + templateKeys: [...templateKeys], + }; +} + +function parseBuild( + build: unknown, + env: Record, +): { + context?: string; + dockerfile?: string; + args?: Record; + templateKeys: string[]; +} { + if (typeof build === "string") { + return { + context: interpolateComposeString(build, env), + templateKeys: [], + }; + } if (build && typeof build === "object") { const b = build as Record; + const parsedArgs = parseBuildArgs(b.args, env); return { - context: (typeof b.context === "string" ? interpolateComposeString(b.context, env) : undefined) ?? ".", - dockerfile: typeof b.dockerfile === "string" ? interpolateComposeString(b.dockerfile, env) : undefined, + context: + (typeof b.context === "string" ? interpolateComposeString(b.context, env) : undefined) ?? + ".", + dockerfile: + typeof b.dockerfile === "string" ? interpolateComposeString(b.dockerfile, env) : undefined, + args: parsedArgs.args, + templateKeys: parsedArgs.templateKeys, }; } - return {}; + return { templateKeys: [] }; } function parsePorts(ports: unknown, env: Record): string[] { @@ -273,12 +399,17 @@ function parseDependsOn(deps: unknown): string[] { function parseEnvironment( env: unknown, interpolationEnv: Record, -): { values: Record; metadata: Record } { - if (!env) return { values: {}, metadata: {} }; +): { + values: Record; + templates: Record; + metadata: Record; +} { + if (!env) return { values: {}, templates: {}, metadata: {} }; // Array form: ["KEY=value", "KEY2=value2"] if (Array.isArray(env)) { const values: Record = {}; + const templates: Record = {}; const metadata: Record = {}; for (const item of env) { if (typeof item !== "string") continue; @@ -288,37 +419,43 @@ function parseEnvironment( const rawValue = item.slice(eqIdx + 1); const resolved = resolveComposeValue(rawValue, interpolationEnv); values[key] = resolved.value; + if (rawValue.includes("$")) templates[key] = rawValue; if (resolved.meta) metadata[key] = resolved.meta; } else { const key = interpolateComposeString(item, interpolationEnv); const resolved = resolveBareEnvironmentKey(key, interpolationEnv); values[key] = resolved.value; + templates[key] = `$${key}`; if (resolved.meta) metadata[key] = resolved.meta; } } - return { values, metadata }; + return { values, templates, metadata }; } // Object form: { KEY: value } if (typeof env === "object") { const values: Record = {}; + const templates: Record = {}; const metadata: Record = {}; for (const [key, val] of Object.entries(env as Record)) { if (val == null) { const resolved = resolveBareEnvironmentKey(key, interpolationEnv); values[key] = resolved.value; + templates[key] = `$${key}`; if (resolved.meta) metadata[key] = resolved.meta; continue; } - const resolved = resolveComposeValue(String(val), interpolationEnv); + const rawValue = String(val); + const resolved = resolveComposeValue(rawValue, interpolationEnv); values[key] = resolved.value; + if (rawValue.includes("$")) templates[key] = rawValue; if (resolved.meta) metadata[key] = resolved.meta; } - return { values, metadata }; + return { values, templates, metadata }; } - return { values: {}, metadata: {} }; + return { values: {}, templates: {}, metadata: {} }; } function parseVolumes(vols: unknown, env: Record): string[] { @@ -401,7 +538,13 @@ function parseAdvanced( const resources = parseServiceResources(svc, env); if (resources) advanced.resources = resources; - const networkMode = parseNamespaceField(svc.network_mode, "network_mode", env, serviceName, unsupported); + const networkMode = parseNamespaceField( + svc.network_mode, + "network_mode", + env, + serviceName, + unsupported, + ); if (networkMode) advanced.networkMode = networkMode; const pidMode = parseNamespaceField(svc.pid, "pid", env, serviceName, unsupported); @@ -547,6 +690,7 @@ function collectUnsupported( serviceName: string, svc: Record, unsupported: ComposeUnsupportedField[], + env: Record, ): void { for (const [key, reason] of Object.entries(UNSUPPORTED_SERVICE_KEYS)) { if (!requestsSomething(svc[key])) continue; @@ -590,6 +734,12 @@ function collectUnsupported( }); } + for (const issue of composeBuildIssues(svc.build, { + interpolate: (value) => interpolateComposeString(value, env), + })) { + unsupported.push({ service: serviceName, ...issue }); + } + collectUnsupportedMounts(serviceName, svc.volumes, unsupported); } @@ -622,7 +772,10 @@ function parseComposeMemory(raw: unknown): number | undefined { return raw > 0 ? Math.floor(raw / (1024 * 1024)) : undefined; } if (typeof raw !== "string") return undefined; - const m = raw.trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*([kmgt]?)b?$/); + const m = raw + .trim() + .toLowerCase() + .match(/^(\d+(?:\.\d+)?)\s*([kmgt]?)b?$/); if (!m) return undefined; const value = parseFloat(m[1]!); if (!Number.isFinite(value) || value <= 0) return undefined; @@ -652,8 +805,7 @@ function parseServiceResources( svc: Record, env: Record, ): { cpuCores?: number; memoryMb?: number } | undefined { - const interp = (v: unknown) => - typeof v === "string" ? interpolateComposeString(v, env) : v; + const interp = (v: unknown) => (typeof v === "string" ? interpolateComposeString(v, env) : v); let memoryMb = parseComposeMemory(interp(svc.mem_limit)); let cpuCores = parseComposeCpus(interp(svc.cpus)); @@ -682,7 +834,10 @@ function parseServiceResources( * and `disable: true` both collapse to `disable`. Durations are kept as compose * strings ("30s") — the runtime converts to nanoseconds at create time. */ -function parseHealthcheck(hc: unknown, env: Record): ComposeHealthcheck | undefined { +function parseHealthcheck( + hc: unknown, + env: Record, +): ComposeHealthcheck | undefined { if (!hc || typeof hc !== "object") return undefined; const h = hc as Record; const result: ComposeHealthcheck = {}; @@ -707,7 +862,11 @@ function parseHealthcheck(hc: unknown, env: Record): ComposeHeal } const dur = (v: unknown): string | undefined => - typeof v === "string" ? interpolateComposeString(v, env) : typeof v === "number" ? String(v) : undefined; + typeof v === "string" + ? interpolateComposeString(v, env) + : typeof v === "number" + ? String(v) + : undefined; const interval = dur(h.interval); if (interval) result.interval = interval; @@ -887,6 +1046,88 @@ function interpolateComposeString(input: string, env: Record): s return (out + protectedInput.slice(cursor)).replaceAll(escapedDollar, "$"); } +function interpolateComposeStringWithMissing( + input: string, + env: Record, +): { value: string; missing: Map } { + const parent = missingRequiredSinks.get(env); + const missing = new Map(); + missingRequiredSinks.set(env, missing); + try { + return { value: interpolateComposeString(input, env), missing }; + } finally { + if (parent) { + missingRequiredSinks.set(env, parent); + for (const [key, message] of missing) { + if (!parent.has(key)) parent.set(key, message); + } + } else { + missingRequiredSinks.delete(env); + } + } +} + +export interface ComposeEnvironmentResolution { + env: Record; + missingRequired: ComposeMissingVariable[]; +} + +/** + * Resolve persisted Compose environment expressions against the env that will + * actually reach a service. Expressions may refer to another templated key, so + * iterate to a fixed point instead of depending on YAML key order. Required + * variables are reported only after convergence; callers can fail the service + * without ever logging a value. + */ +export function resolveComposeEnvironmentTemplates( + env: Record, + templates: Record, +): ComposeEnvironmentResolution { + const resolved = { ...env }; + const entries = Object.entries(templates); + const evaluate = (key: string, expression: string) => { + // A self-reference reads the lower-layer value, not the result we produced + // on the previous fixed-point pass (`A=${A}x` must not grow x forever). + const scope = { ...resolved }; + if (Object.hasOwn(env, key)) scope[key] = env[key]!; + else delete scope[key]; + return interpolateComposeString(expression, scope); + }; + + for (let pass = 0; pass <= entries.length; pass++) { + let changed = false; + for (const [key, expression] of entries) { + const value = evaluate(key, expression); + if (resolved[key] !== value) { + resolved[key] = value; + changed = true; + } + } + if (!changed) break; + } + + const missing = new Map(); + // One final stable pass records only requirements that remain unresolved. + for (const [key, expression] of entries) { + const scope = { ...resolved }; + if (Object.hasOwn(env, key)) scope[key] = env[key]!; + else delete scope[key]; + const final = interpolateComposeStringWithMissing(expression, scope); + resolved[key] = final.value; + for (const [variable, message] of final.missing) { + if (!missing.has(variable)) missing.set(variable, message); + } + } + + return { + env: resolved, + missingRequired: [...missing].map(([variable, message]) => ({ + variable, + ...(message && { message }), + })), + }; +} + function resolveComposeValue( input: string, env: Record, @@ -923,7 +1164,7 @@ function resolveComposeValue( }; } - const value = interpolateComposeString(input, env); + const { value, missing } = interpolateComposeStringWithMissing(input, env); if (!input.includes("$")) return { value }; return { @@ -932,6 +1173,10 @@ function resolveComposeValue( source: "interpolated", resolvedValue: value, expression: input, + ...(missing.size > 0 && { + required: true, + unresolvedVariables: [...missing.keys()], + }), }, }; } @@ -974,7 +1219,11 @@ function resolveInterpolationExpression( switch (operator) { case undefined: - return { value: hasValue ? value : "", source: hasValue ? "env-file" : "missing", variable: key }; + return { + value: hasValue ? value : "", + source: hasValue ? "env-file" : "missing", + variable: key, + }; case ":-": if (isNonEmpty) return { value, source: "env-file", variable: key }; { diff --git a/apps/api/src/lib/deployment-runtime-read.test.ts b/apps/api/src/lib/deployment-runtime-read.test.ts index bcad7979d..4e580eab9 100644 --- a/apps/api/src/lib/deployment-runtime-read.test.ts +++ b/apps/api/src/lib/deployment-runtime-read.test.ts @@ -21,6 +21,8 @@ const h = vi.hoisted(() => ({ /** Every DockerRuntime.create — transport plus, for ssh, the host it dialed. */ creates: [] as Array<{ transport: string; host?: string }>, platformCalls: 0, + serverGets: [] as string[], + serverLists: 0, })); vi.mock("@repo/adapters", () => ({ @@ -31,11 +33,16 @@ vi.mock("@repo/adapters", () => ({ }, }, createHostExecutor: () => ({}), - createPlatform: async () => { + createPlatform: async (config: { executor?: unknown; localHost?: boolean }) => { // Standing in for the expensive build this resolver exists to avoid: if a // read path ever reaches it for an on-box target, the count catches it. h.platformCalls++; - return { target: "selfhosted", runtime: { name: "platform-runtime" } }; + return { + target: "selfhosted", + runtime: { name: "platform-runtime" }, + executor: config.executor ?? null, + localHost: config.localHost ?? false, + }; }, })); @@ -44,7 +51,14 @@ vi.mock("./controller-helpers", () => ({ })); vi.mock("./ssh-manager", () => ({ - sshManager: { acquire: async () => ({}) }, + sshManager: { + acquire: async () => ({ + readFile: async (path: string) => { + if (path === "/etc/machine-id") return "0123456789abcdef0123456789abcdef\n"; + throw new Error("missing"); + }, + }), + }, // Echo the server's host so the ssh transport reveals WHICH server was picked. buildSshConfig: async (server: { sshHost: string }) => ({ host: server.sshHost, @@ -53,16 +67,30 @@ vi.mock("./ssh-manager", () => ({ }), })); -vi.mock("./provision-lock", () => ({ createProvisionLock: () => ({ run: (f: () => unknown) => f() }) })); +vi.mock("./provision-lock", () => ({ + createProvisionLock: () => ({ run: (f: () => unknown) => f() }), +})); vi.mock("./cloud/client", () => ({ cloudClient: {}, getOrgCloudToken: async () => null })); vi.mock("./cloud/transport", () => ({ resolveOrgCloudUserId: async () => null })); vi.mock("@repo/db", () => ({ repos: { server: { - getInOrganization: async (id: string) => ({ id, isLocal: false, sshHost: `host-of-${id}`, sshPort: 22, sshUser: "root" }), - listByOrganization: async () => [ - { id: "only-server", isLocal: false, sshHost: "host-of-only-server", sshPort: 22, sshUser: "root" }, - ], + getInOrganization: async (id: string) => { + h.serverGets.push(id); + return { id, isLocal: false, sshHost: `host-of-${id}`, sshPort: 22, sshUser: "root" }; + }, + listByOrganization: async () => { + h.serverLists++; + return [ + { + id: "only-server", + isLocal: false, + sshHost: "host-of-only-server", + sshPort: 22, + sshUser: "root", + }, + ]; + }, }, }, })); @@ -80,9 +108,42 @@ beforeEach(() => { h.baseTarget = "selfhosted"; h.creates = []; h.platformCalls = 0; + h.serverGets = []; + h.serverLists = 0; }); describe("resolveDeploymentRuntimeForRead — reaches the deploy's host, without the platform", () => { + it("plans the concrete transport and server id for an implicit single-server target", async () => { + await expect(mod.resolvePlannedTargetTopology("server", undefined, "org1")).resolves.toEqual({ + serverId: "only-server", + dockerTransport: "ssh", + }); + }); + + it("uses that concrete fallback server as the host-port collision domain", async () => { + const resolved = await mod.resolveDeploymentPlatform( + { deployTarget: "server", runtimeMode: "docker" }, + { + organizationId: "org1", + basePlatform: { + target: "selfhosted", + runtime: { name: "docker" }, + } as never, + }, + ); + + expect(resolved.serverId).toBe("only-server"); + expect(resolved.hostPortTarget).toEqual({ + targetKey: expect.stringMatching(/^host:[0-9a-f]{64}$/), + legacyTargetKeys: ["server:only-server"], + stable: true, + }); + // One fallback selection feeds platform construction and host identity. No + // second get-by-id round trip rebuilds the same target from the database. + expect(h.serverLists).toBe(1); + expect(h.serverGets).toEqual([]); + }); + it("server target → the pinned server's docker, never the local socket", async () => { await read({ deployTarget: "server", serverId: "srv-9" }); expect(sshHosts()).toEqual(["host-of-srv-9"]); @@ -93,9 +154,11 @@ describe("resolveDeploymentRuntimeForRead — reaches the deploy's host, without it("REGRESSION: server target with NO serverId still goes to the server resolver", async () => { // resolveServerExecutor falls back to the org's single server; short-circuiting // to the socket here would read containers off the orchestrator instead. - await read({ deployTarget: "server" }); + const resolved = await read({ deployTarget: "server" }); expect(sshHosts()).toEqual(["host-of-only-server"]); expect(socketCalls()).toBe(0); + expect(resolved.serverId).toBe("only-server"); + expect(resolved.hostPortTarget?.legacyTargetKeys).toEqual(["server:only-server"]); }); it("a recorded serverId wins even when deployTarget says otherwise", async () => { diff --git a/apps/api/src/lib/deployment-runtime.ts b/apps/api/src/lib/deployment-runtime.ts index 1fa2ab2b0..171405a46 100644 --- a/apps/api/src/lib/deployment-runtime.ts +++ b/apps/api/src/lib/deployment-runtime.ts @@ -33,6 +33,12 @@ import { isConnectionLoss } from "./remote-state"; import { resolveAcmeProviderOptions } from "./acme-config"; import { findLocalServer } from "./startup/self-server"; import { registryAuthResolver } from "../modules/credentials/registry-auth"; +import { + LOCAL_HOST_PORT_TARGET, + resolveHostPortTargetIdentity, + type HostPortConnectionLocator, + type HostPortTargetIdentity, +} from "./host-port-target"; /** * The shape of `deployment.meta` JSONB. Snapshotted per-deploy — @@ -61,6 +67,10 @@ export interface DeploymentMeta { * drift banner's `current` anchor. */ releaseVersion?: string; + /** Raw upstream release tag and the concrete prebuilt image frozen for a + * container-release deployment. */ + releaseTag?: string; + releaseImageRef?: string; /** * "local" | "server" — where the build runs. A local build targeting cloud * keeps the project LOCAL-canonical and uploads the output to a cloud @@ -137,13 +147,20 @@ export interface DeploymentMeta { */ export function resolveDeploymentStaticRoot( deployment: Pick, - project: { hasServer?: boolean | null; workloadType?: string | null; outputDirectory?: string | null }, + project: { + hasServer?: boolean | null; + workloadType?: string | null; + outputDirectory?: string | null; + }, ): string | null { // Only a STATIC workload serves a release directory. A worker also has // `hasServer=false` but its containerId is a real container, not a doc-root, so // classify by workload — not the legacy boolean — or a worker's stop/start would // dial a bogus static path (#538-B). - if (resolveWorkload(project.workloadType, project.hasServer) !== "static" || !deployment.containerId) { + if ( + resolveWorkload(project.workloadType, project.hasServer) !== "static" || + !deployment.containerId + ) { return null; } const meta = (deployment.meta ?? {}) as DeploymentMeta; @@ -208,9 +225,12 @@ export interface ResolvedDeploymentPlatform { usesManagedRouting: boolean; /** The server ID used for SSH targets (null for local/cloud). */ serverId: string | null; + /** Physical TCP bind namespace used by durable claims and allocation locks. */ + hostPortTarget: HostPortTargetIdentity | null; } type OrgServer = NonNullable>>; +type ResolvedServerTarget = Awaited>; /** * Resolve the org's deploy-target server and RETURN THE ROW (not just the @@ -227,9 +247,7 @@ async function resolveOrgServer( organizationId: string | undefined, ): Promise { if (!organizationId) { - throw new Error( - "Cannot resolve a server deployment target without an organization ID", - ); + throw new Error("Cannot resolve a server deployment target without an organization ID"); } if (serverId) { @@ -262,7 +280,39 @@ async function resolveOrgServer( throw new Error("No server configured. Add your SSH server in Settings."); } - throw new Error("Deployment target is a server, but this deployment has no server ID. Redeploy and select a server explicitly."); + throw new Error( + "Deployment target is a server, but this deployment has no server ID. Redeploy and select a server explicitly.", + ); +} + +async function resolveServerTargetTopology( + serverId: string | undefined, + organizationId: string | undefined, +): Promise<{ server: OrgServer; isLocal: boolean }> { + const server = await resolveOrgServer(serverId, organizationId); + return { server, isLocal: await isLocalHostRow(server) }; +} + +/** + * Read-only transport topology for preflight. It uses the same org-scoped + * server selection and local-host predicate as runtime construction, without + * acquiring an SSH/host executor merely to answer where Docker source can run. + */ +export async function resolvePlannedTargetTopology( + target: DeployTarget, + serverId: string | undefined, + organizationId: string | undefined, +): Promise<{ + serverId: string | null; + dockerTransport: "socket" | "ssh" | undefined; +}> { + if (target === "local") return { serverId: null, dockerTransport: "socket" }; + if (target !== "server") return { serverId: null, dockerTransport: undefined }; + const { server, isLocal } = await resolveServerTargetTopology(serverId, organizationId); + return { + serverId: server.id, + dockerTransport: isLocal ? "socket" : "ssh", + }; } /** @@ -272,7 +322,10 @@ async function resolveOrgServer( * and the build pipeline both route through this so their notion of the target * can never drift (a drift caused the self-hosted→cloud-preflight 403). */ -export function resolveEffectiveTarget(base: Platform["target"], snapshot: DeploymentMeta): DeployTarget { +export function resolveEffectiveTarget( + base: Platform["target"], + snapshot: DeploymentMeta, +): DeployTarget { // AUTO-DETECT, don't hardcode per host platform: a deployment PINNED to a // specific server always routes over SSH to that server — whether the host is // a self-hosted box OR the DESKTOP app operating a remote server. Only the SaaS @@ -295,7 +348,10 @@ export function resolveEffectiveTarget(base: Platform["target"], snapshot: Deplo return "cloud"; } -export function usesManagedRouting(base: Platform["target"], effectiveTarget: DeployTarget): boolean { +export function usesManagedRouting( + base: Platform["target"], + effectiveTarget: DeployTarget, +): boolean { // Managed (local OpenResty) routing applies only to on-box targets. A cloud // target — including the local-orchestrated cloud deploy — routes via cloud // pages/edge, not the local proxy. @@ -344,28 +400,70 @@ async function resolveCloudPlatformForOrg(organizationId?: string): Promise { + return resolveHostPortTargetIdentity({ + localHost: target.isLocal, + serverId: target.id, + executor: target.executor, + connection: target.hostPortConnection, + }); +} + +/** + * Resolve one local/server deployment target once and derive every consumer + * from it: platform, concrete server id, and physical host-port identity. + */ +async function resolveSelfHostedDeploymentTarget( + target: "local" | "server", + runtimeMode: RuntimeMode, + serverId: string | undefined, + organizationId: string | undefined, +): Promise> { + if (target === "local") { + return { + platform: await resolveTargetPlatform("local", runtimeMode, undefined, organizationId), + serverId: null, + hostPortTarget: LOCAL_HOST_PORT_TARGET, + }; + } + + const resolvedServer = await resolveServerExecutor(serverId, organizationId); + return { + platform: await createPlatformForResolvedServer(resolvedServer, runtimeMode, organizationId), + serverId: resolvedServer.id, + hostPortTarget: await resolveServerHostPortTarget(resolvedServer), + }; +} + export async function resolveDeploymentPlatform( snapshot: DeploymentMeta, opts?: { organizationId?: string; basePlatform?: Platform }, ): Promise { const basePlatform = opts?.basePlatform ?? platform(); const effectiveTarget = resolveEffectiveTarget(basePlatform.target, snapshot); - const runtimeMode = snapshot.runtimeMode ?? (basePlatform.runtime.name === "docker" ? "docker" : "bare"); + const runtimeMode = + snapshot.runtimeMode ?? (basePlatform.runtime.name === "docker" ? "docker" : "bare"); if (effectiveTarget === "local" || effectiveTarget === "server") { - const resolvedServerId = effectiveTarget === "server" ? (snapshot.serverId ?? null) : null; - const targetPlatform = await resolveTargetPlatform( + const resolvedTarget = await resolveSelfHostedDeploymentTarget( effectiveTarget, runtimeMode, snapshot.serverId, opts?.organizationId, ); return { - platform: targetPlatform, + ...resolvedTarget, effectiveTarget, runtimeMode, usesManagedRouting: usesManagedRouting(basePlatform.target, effectiveTarget), - serverId: resolvedServerId, }; } @@ -391,6 +489,7 @@ export async function resolveDeploymentPlatform( runtimeMode, usesManagedRouting: usesManagedRouting(basePlatform.target, effectiveTarget), serverId: null, + hostPortTarget: null, }; } @@ -412,6 +511,48 @@ export async function resolveDeploymentPlatform( * For server targets, the executor is acquired from `sshManager` (pooled, * idle-TTL, auto-retry) instead of creating a fresh SSH connection. */ +async function createPlatformForResolvedServer( + resolved: ResolvedServerTarget, + runtimeMode: RuntimeMode, + organizationId?: string, +): Promise { + const resolveRegistryAuth = organizationId ? registryAuthResolver(organizationId) : undefined; + const { id, executor, isLocal, ssh } = resolved; + + // The auto-registered "This Server" row IS the OpenShip host (VPS / + // server-host mode): local host executor, host docker socket (DooD), + // everything on-box. + if (isLocal) { + return createPlatform({ + target: "selfhosted", + runtime: runtimeMode, + executor, + localHost: true, + docker: + runtimeMode === "docker" + ? { transport: "socket" as const, resolveRegistryAuth } + : undefined, + nginx: resolveAcmeProviderOptions(), + provisionLock: createProvisionLock("provision:local"), + }); + } + + return createPlatform({ + target: "selfhosted", + runtime: runtimeMode, + executor, + ssh: ssh!, + docker: + runtimeMode === "docker" + ? { ...toDockerSshTransport(ssh!, executor), resolveRegistryAuth } + : undefined, + nginx: resolveAcmeProviderOptions(), + // Serialize provisioning per target server, so concurrent deploys (across + // projects / single-app + compose) never race apt/openresty/networks/state. + provisionLock: createProvisionLock(`provision:server:${id}`), + }); +} + export async function resolveTargetPlatform( target: "local" | "server", runtimeMode: RuntimeMode = "bare", @@ -423,39 +564,15 @@ export async function resolveTargetPlatform( // ONE resolution for the server's executor + transport (isLocal → host // executor + socket docker; else → pooled SSH). Shared with // createServerDockerRuntime / createServerCommandExecutor — no drift. - const { id, executor, isLocal, ssh } = await resolveServerExecutor( - serverId, - organizationId, - ); - - // The auto-registered "This Server" row IS the OpenShip host (VPS / - // server-host mode): local host executor, host docker socket (DooD), - // everything on-box. - if (isLocal) { - return createPlatform({ - target: "selfhosted", - runtime: runtimeMode, - executor, - localHost: true, - docker: runtimeMode === "docker" ? { transport: "socket" as const } : undefined, - nginx: resolveAcmeProviderOptions(), - provisionLock: createProvisionLock("provision:local"), - }); - } - - return createPlatform({ - target: "selfhosted", - runtime: runtimeMode, - executor, // ← managed executor from pool - ssh: ssh!, - docker: runtimeMode === "docker" ? toDockerSshTransport(ssh!, executor) : undefined, - nginx: resolveAcmeProviderOptions(), - // Serialize provisioning per target server, so concurrent deploys (across - // projects / single-app + compose) never race apt/openresty/networks/state. - provisionLock: createProvisionLock(`provision:server:${id}`), - }); + const resolved = await resolveServerExecutor(serverId, organizationId); + return createPlatformForResolvedServer(resolved, runtimeMode, organizationId); } + // Bind registry credential lookup to the deployment's organization at the + // platform factory. Every local Docker pull then uses the same tenant-safe + // resolver as the server helper above. + const resolveRegistryAuth = organizationId ? registryAuthResolver(organizationId) : undefined; + // "local" is not a destination anyone picks — it is the ABSENCE of a binding // (no cloud workspace, no serverId), so it always means "this box". Nothing // offers it: `project.server_id` is ON DELETE SET NULL, so deleting a server is @@ -490,9 +607,8 @@ export async function resolveTargetPlatform( // would otherwise read as REMOTE — turning off the containerized edge provider // and the same-path-mount rule (`sharedMountExecutor`) for the local box. localHost: true, - docker: runtimeMode === "docker" - ? { transport: "socket" as const } - : undefined, + docker: + runtimeMode === "docker" ? { transport: "socket" as const, resolveRegistryAuth } : undefined, nginx: resolveAcmeProviderOptions(), // Still serialize provisioning: two local deploys share the same host's // openresty/docker/state. Same lock name as the isLocal row's branch, because @@ -518,7 +634,15 @@ export async function createServerDockerRuntime( serverId: string | undefined, organizationId: string, ): Promise { - const { executor, isLocal, ssh } = await resolveServerExecutor(serverId, organizationId); + const resolved = await resolveServerExecutor(serverId, organizationId); + return createDockerRuntimeForResolvedServer(resolved, organizationId); +} + +async function createDockerRuntimeForResolvedServer( + resolved: Pick>, "executor" | "isLocal" | "ssh">, + organizationId: string, +): Promise { + const { executor, isLocal, ssh } = resolved; // Registry credentials for every pull this runtime makes, bound to THIS org. Injected // rather than read from the host's docker config: it is the only source that works on // every install shape, and binding the org here means no later call site can resolve @@ -670,20 +794,27 @@ export async function resolveServerExecutor( conn: { host: string; port: number; user: string }; isLocal: boolean; ssh: SshConfig | null; + hostPortConnection: HostPortConnectionLocator; }> { - const server = await resolveOrgServer(serverId, organizationId); + const { server, isLocal } = await resolveServerTargetTopology(serverId, organizationId); const conn = { host: server.sshHost || "127.0.0.1", port: server.sshPort ?? 22, user: server.sshUser || "root", }; + const hostPortConnection: HostPortConnectionLocator = { + sshHost: server.sshHost, + sshPort: server.sshPort, + sshJumpHost: server.sshJumpHost, + sshArgs: server.sshArgs, + }; // isLocal "This Server" OR a row that actually points at THIS host (a plain SSH // row for the local box — loopback / SERVER_IP — in the box-owning org). Both // resolve to the local host executor + mounted docker socket (DooD); dialing SSH // to them hits the API's own loopback (no sshd) — the "Can't reach 127.0.0.1" // failure. Org-gated (isLocalHostRow) so a teammate's org can't mint a host-root // target from a loopback row. - if (await isLocalHostRow(server)) { + if (isLocal) { // Self-heal the persisted flag so EVERY `server.isLocal` consumer (edge, // domains, tunnels, the servers list) agrees — not just this resolver. // One-time, idempotent, best-effort; never blocks or fails the deploy. @@ -700,6 +831,7 @@ export async function resolveServerExecutor( conn, isLocal: true, ssh: null, + hostPortConnection, }; } const executor = await sshManager.acquire(server.id); @@ -707,7 +839,7 @@ export async function resolveServerExecutor( if (!ssh) { throw new Error("Invalid SSH configuration. Check host, auth method, and credentials."); } - return { id: server.id, executor, conn, isLocal: false, ssh }; + return { id: server.id, executor, conn, isLocal: false, ssh, hostPortConnection }; } /** @@ -717,7 +849,11 @@ export async function resolveServerExecutor( export async function createServerCommandExecutor( serverId: string, organizationId: string, -): Promise<{ executor: CommandExecutor; conn: { host: string; port: number; user: string }; isLocal: boolean }> { +): Promise<{ + executor: CommandExecutor; + conn: { host: string; port: number; user: string }; + isLocal: boolean; +}> { const { executor, conn, isLocal } = await resolveServerExecutor(serverId, organizationId); return { executor, conn, isLocal }; } @@ -763,6 +899,10 @@ export async function resolveDeploymentRuntime( routing: Platform["routing"]; effectiveTarget: DeployTarget; serverId: string | null; + /** Physical bind namespace used by durable host-port ownership. */ + hostPortTarget: HostPortTargetIdentity | null; + /** Executor that reaches the same host as `routing` (null on cloud). */ + executor: Platform["executor"]; }> { const snapshot = (dep.meta ?? {}) as DeploymentMeta; const resolved = await resolveDeploymentPlatform(snapshot, { @@ -773,6 +913,8 @@ export async function resolveDeploymentRuntime( routing: resolved.platform.routing, effectiveTarget: resolved.effectiveTarget, serverId: resolved.serverId, + hostPortTarget: resolved.hostPortTarget, + executor: resolved.platform.executor, }; } @@ -815,7 +957,9 @@ export async function deploymentContainerIds( // know how many containers this deployment has, and falling back to the single // `containerId` would quietly act on one of them. const rows = await repos.service.listByDeployment(dep.id); - const serviceIds = [...new Set(rows.map((r) => r.containerId).filter((id): id is string => !!id))]; + const serviceIds = [ + ...new Set(rows.map((r) => r.containerId).filter((id): id is string => !!id)), + ]; if (serviceIds.length > 0) return serviceIds; // The compose sentinel is a marker, not a container: returning it made a pause // report success having stopped nothing (docker 404 → `isAbsent` → swallowed). @@ -981,8 +1125,11 @@ export async function withDeploymentPlatform( runtime: RuntimeAdapter; routing: Platform["routing"]; ssl: Platform["ssl"]; + executor: Platform["executor"]; effectiveTarget: DeployTarget; serverId: string | null; + /** Physical TCP bind namespace matching this exact routing/executor target. */ + hostPortTarget: HostPortTargetIdentity | null; }) => Promise, ): Promise { const resolved = await resolveDeploymentPlatform((dep.meta ?? {}) as DeploymentMeta, { @@ -993,8 +1140,10 @@ export async function withDeploymentPlatform( runtime: resolved.platform.runtime, routing: resolved.platform.routing, ssl: resolved.platform.ssl, + executor: resolved.platform.executor, effectiveTarget: resolved.effectiveTarget, serverId: resolved.serverId, + hostPortTarget: resolved.hostPortTarget, }); } catch (err) { throw asHostUnreachable(err); @@ -1024,7 +1173,11 @@ function asHostUnreachable(err: unknown): unknown { export async function resolveDeploymentRuntimeForRead( dep: Pick, -): Promise<{ runtime: RuntimeAdapter; serverId: string | null }> { +): Promise<{ + runtime: RuntimeAdapter; + serverId: string | null; + hostPortTarget: HostPortTargetIdentity | null; +}> { // Services are containers even when the app itself deploys "bare" — pin docker // so a bare project's sidecars still resolve a docker runtime (matches // resolveServicePlatform's long-standing behaviour). @@ -1032,18 +1185,29 @@ export async function resolveDeploymentRuntimeForRead( const effectiveTarget = resolveEffectiveTarget(platform().target, snapshot); if (effectiveTarget === "server") { + const target = await resolveServerExecutor(snapshot.serverId, dep.organizationId); return { - runtime: await createServerDockerRuntime(snapshot.serverId, dep.organizationId), - // Same value resolveDeploymentPlatform reports: the RECORDED id, which - // streaming callers use to retain/release the pooled SSH connection. - serverId: snapshot.serverId ?? null, + runtime: await createDockerRuntimeForResolvedServer(target, dep.organizationId), + // The concrete id selected by the same org-scoped resolution that built + // the transport; legacy implicit-single-server snapshots must not report + // null or resolve a different row on a second lookup. + serverId: target.id, + hostPortTarget: await resolveServerHostPortTarget(target), }; } if (effectiveTarget === "local") { - return { runtime: await DockerRuntime.create({ transport: "socket" }), serverId: null }; + return { + runtime: await DockerRuntime.create({ transport: "socket" }), + serverId: null, + hostPortTarget: LOCAL_HOST_PORT_TARGET, + }; } const resolved = await resolveDeploymentPlatform(snapshot, { organizationId: dep.organizationId, }); - return { runtime: resolved.platform.runtime, serverId: resolved.serverId }; + return { + runtime: resolved.platform.runtime, + serverId: resolved.serverId, + hostPortTarget: resolved.hostPortTarget, + }; } diff --git a/apps/api/src/lib/derived-local-target.test.ts b/apps/api/src/lib/derived-local-target.test.ts index 0f43c94f3..b9998707a 100644 --- a/apps/api/src/lib/derived-local-target.test.ts +++ b/apps/api/src/lib/derived-local-target.test.ts @@ -129,14 +129,21 @@ describe("derived local target — one machine, one executor path", () => { h.localRow = { id: "srv-local" }; await resolveTargetPlatform("server", "docker", "srv-local", "org1"); const picked = last(); - await resolveTargetPlatform("local", "docker"); + await resolveTargetPlatform("local", "docker", undefined, "org1"); const derived = last(); // The whole point of the convergence: the wizard door and the no-binding door // cannot end up on different rules for the same box. expect(derived.executor).toBe(picked.executor); expect(derived.localHost).toBe(true); - expect(derived.docker).toEqual(picked.docker); + expect(derived.docker).toMatchObject({ + transport: "socket", + resolveRegistryAuth: expect.any(Function), + }); + expect(picked.docker).toMatchObject({ + transport: "socket", + resolveRegistryAuth: expect.any(Function), + }); // Same host being provisioned → same lock, or two "local" deploys would race // openresty/docker/state on one machine. expect((derived.provisionLock as { name: string }).name).toBe( diff --git a/apps/api/src/lib/environment-scope.ts b/apps/api/src/lib/environment-scope.ts new file mode 100644 index 000000000..f1a4d8a28 --- /dev/null +++ b/apps/api/src/lib/environment-scope.ts @@ -0,0 +1,17 @@ +import { Type } from "@sinclair/typebox"; +import { AppError, ENVIRONMENTS, type Environment } from "@repo/core"; + +/** One schema and parser backed by the canonical environment list in @repo/core. */ +export const EnvironmentScopeSchema = Type.Union( + ENVIRONMENTS.map((environment) => Type.Literal(environment)), +); + +export function parseOptionalEnvironmentScope(value: unknown): Environment | undefined { + if (value === undefined) return undefined; + + if (typeof value !== "string" || !ENVIRONMENTS.includes(value as Environment)) { + throw new AppError(`environment must be one of: ${ENVIRONMENTS.join(", ")}`, 400); + } + + return value as Environment; +} diff --git a/apps/api/src/lib/host-port-target.test.ts b/apps/api/src/lib/host-port-target.test.ts new file mode 100644 index 000000000..772b473c2 --- /dev/null +++ b/apps/api/src/lib/host-port-target.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { CommandExecutor } from "@repo/adapters"; + +import { + LOCAL_HOST_PORT_TARGET, + normalizeHostPortConnectionLocator, + normalizeTargetHostId, + normalizeTargetMachineId, + resolveHostPortTargetIdentity, +} from "./host-port-target"; + +function executorWithFiles(files: Record): CommandExecutor { + return { + readFile: vi.fn(async (path: string) => { + const value = files[path]; + if (value instanceof Error) throw value; + if (value === undefined) throw new Error("missing"); + return value; + }), + } as unknown as CommandExecutor; +} + +const connection = { + sshHost: "Deploy.Example.COM.", + sshPort: 22, + sshJumpHost: "Jump.Example.COM.", + sshArgs: " -o ProxyCommand=none ", +}; + +describe("host-port target identity", () => { + it("validates OS and OpenShip target ids before trusting them", () => { + expect(normalizeTargetMachineId("0123456789ABCDEF0123456789ABCDEF\n")).toBe( + "0123456789abcdef0123456789abcdef", + ); + expect(normalizeTargetMachineId("0".repeat(32))).toBeNull(); + expect(normalizeTargetMachineId("uninitialized")).toBeNull(); + expect(normalizeTargetHostId("550E8400-E29B-41D4-A716-446655440000")).toBe( + "550e8400-e29b-41d4-a716-446655440000", + ); + expect(normalizeTargetHostId("../../another-host")).toBeNull(); + }); + + it("collapses different server rows reaching the same target-issued machine id", async () => { + const files = { "/etc/machine-id": "0123456789abcdef0123456789abcdef\n" }; + const first = await resolveHostPortTargetIdentity({ + localHost: false, + serverId: "server-one", + executor: executorWithFiles(files), + connection, + }); + const second = await resolveHostPortTargetIdentity({ + localHost: false, + serverId: "server-two", + executor: executorWithFiles(files), + connection: { ...connection, sshHost: "an-alias.example.com" }, + }); + + expect(first.targetKey).toMatch(/^host:[0-9a-f]{64}$/); + expect(second.targetKey).toBe(first.targetKey); + expect(first.stable).toBe(true); + expect(first.legacyTargetKeys).toEqual(["server:server-one"]); + expect(second.legacyTargetKeys).toEqual(["server:server-two"]); + }); + + it("uses an existing OpenShip host id when machine-id is unavailable", async () => { + const result = await resolveHostPortTargetIdentity({ + localHost: false, + serverId: "server-one", + executor: executorWithFiles({ + "/etc/machine-id": new Error("unreadable"), + "/var/lib/openship/host-id": "550e8400-e29b-41d4-a716-446655440000\n", + }), + connection, + }); + + expect(result.targetKey).toMatch(/^host:[0-9a-f]{64}$/); + expect(result.stable).toBe(true); + }); + + it("normalizes a credential-free connection locator only as a last resort", async () => { + expect(normalizeHostPortConnectionLocator(connection)).toBe( + "ssh://deploy.example.com:22?jump=jump.example.com&args=-o%20ProxyCommand%3Dnone", + ); + const first = await resolveHostPortTargetIdentity({ + localHost: false, + serverId: "one", + executor: executorWithFiles({ + "/etc/machine-id": "invalid", + "/var/lib/openship/host-id": "invalid", + }), + connection, + }); + const second = await resolveHostPortTargetIdentity({ + localHost: false, + serverId: "two", + executor: executorWithFiles({}), + connection: { + sshHost: "deploy.example.com", + sshPort: null, + sshJumpHost: "jump.example.com", + sshArgs: "-o ProxyCommand=none", + }, + }); + + expect(second.targetKey).toBe(first.targetKey); + expect(first.stable).toBe(false); + expect(second.stable).toBe(false); + }); + + it("atomically creates a stable OpenShip host id before using the locator fallback", async () => { + const executor = executorWithFiles({ + "/etc/machine-id": new Error("missing"), + "/var/lib/openship/host-id": new Error("missing"), + }); + executor.exec = vi.fn(async () => "550e8400-e29b-41d4-a716-446655440000\n"); + + const result = await resolveHostPortTargetIdentity({ + localHost: false, + serverId: "server-one", + executor, + connection, + }); + + expect(result.stable).toBe(true); + expect(result.targetKey).toMatch(/^host:[0-9a-f]{64}$/); + expect(executor.exec).toHaveBeenCalledTimes(1); + }); + + it("keeps all local and isLocal paths in the local collision domain", async () => { + const executor = executorWithFiles({}); + const result = await resolveHostPortTargetIdentity({ + localHost: true, + serverId: "this-server-row", + executor, + connection, + }); + + expect(result).toBe(LOCAL_HOST_PORT_TARGET); + expect(executor.readFile).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/lib/host-port-target.ts b/apps/api/src/lib/host-port-target.ts new file mode 100644 index 000000000..a155c380f --- /dev/null +++ b/apps/api/src/lib/host-port-target.ts @@ -0,0 +1,187 @@ +import { createHash, randomUUID } from "node:crypto"; + +import type { CommandExecutor } from "@repo/adapters"; +import type { HostPortTargetKey } from "@repo/db"; + +/** + * One physical TCP bind namespace. + * + * `targetKey` is the authority for new claims and locks. `legacyTargetKeys` + * remain read-only aliases while claims backfilled by older migrations still + * use a mutable server-row id. + */ +export interface HostPortTargetIdentity { + targetKey: HostPortTargetKey; + legacyTargetKeys: HostPortTargetKey[]; + /** False only for the credential-free SSH-locator fallback. */ + stable: boolean; +} + +export interface HostPortConnectionLocator { + sshHost: string; + sshPort?: number | null; + sshJumpHost?: string | null; + sshArgs?: string | null; +} + +const MACHINE_ID_PATH = "/etc/machine-id"; +const OPENSHIP_HOST_ID_PATH = "/var/lib/openship/host-id"; +const MACHINE_ID_RE = /^[0-9a-f]{32}$/i; +const HOST_ID_RE = + /^(?:[0-9a-f]{32}|[0-9a-f]{64}|[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/i; +const stableTargetIds = new WeakMap< + CommandExecutor, + { source: "machine-id" | "openship-host-id"; value: string } +>(); + +function nonZeroHex(value: string): boolean { + return /[1-9a-f]/i.test(value.replaceAll("-", "")); +} + +export function normalizeTargetMachineId(value: string): string | null { + const normalized = value.trim().toLowerCase(); + return MACHINE_ID_RE.test(normalized) && nonZeroHex(normalized) ? normalized : null; +} + +export function normalizeTargetHostId(value: string): string | null { + const normalized = value.trim().toLowerCase(); + return HOST_ID_RE.test(normalized) && nonZeroHex(normalized) ? normalized : null; +} + +function normalizeSshHost(value: string): string { + let normalized = value.trim().toLowerCase(); + if (normalized.startsWith("[") && normalized.endsWith("]")) { + normalized = normalized.slice(1, -1); + } + // A trailing root-label dot changes no DNS endpoint. + return normalized.endsWith(".") ? normalized.slice(0, -1) : normalized; +} + +/** Deterministic, credential-free fallback when the target cannot expose an id. */ +export function normalizeHostPortConnectionLocator(locator: HostPortConnectionLocator): string { + const host = normalizeSshHost(locator.sshHost); + if (!host) throw new Error("Cannot identify a host-port target without an SSH host"); + const port = locator.sshPort ?? 22; + if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) { + throw new Error("Cannot identify a host-port target with an invalid SSH port"); + } + const jumpHost = locator.sshJumpHost ? normalizeSshHost(locator.sshJumpHost) : ""; + // Extra connection options can carry a ProxyJump that is not represented by + // sshJumpHost. Collapse whitespace for stability, but do not lowercase paths. + const sshArgs = locator.sshArgs?.trim().replace(/\s+/g, " ") ?? ""; + return `ssh://${host}:${port}?jump=${encodeURIComponent(jumpHost)}&args=${encodeURIComponent(sshArgs)}`; +} + +function fingerprint( + source: "machine-id" | "openship-host-id" | "connection", + value: string, +): string { + return createHash("sha256").update(`${source}\0${value}`, "utf8").digest("hex"); +} + +async function readValidatedTargetId( + executor: CommandExecutor, +): Promise<{ source: "machine-id" | "openship-host-id"; value: string } | null> { + const cached = stableTargetIds.get(executor); + if (cached) return cached; + // machine-id is the OS-issued, normally world-readable identity. Prefer it so + // all SSH users converge even when a private OpenShip host-id has narrower + // permissions. The OpenShip id is the stable fallback for targets without one. + try { + const machineId = normalizeTargetMachineId(await executor.readFile(MACHINE_ID_PATH)); + if (machineId) { + const found = { source: "machine-id" as const, value: machineId }; + stableTargetIds.set(executor, found); + return found; + } + } catch { + // Fall through to the existing OpenShip target id, then the locator. + } + try { + const hostId = normalizeTargetHostId(await executor.readFile(OPENSHIP_HOST_ID_PATH)); + if (hostId) { + const found = { source: "openship-host-id" as const, value: hostId }; + stableTargetIds.set(executor, found); + return found; + } + } catch { + // A deterministic connection locator is the last-resort identity. + } + return null; +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +/** + * Persist a target-issued identity when the OS has no readable machine-id. + * + * The noclobber create is atomic across API replicas and duplicate server rows: + * one candidate wins, every caller reads the winner. Try the login first and + * then passwordless sudo, matching the privilege contract required to manage + * the edge on that same host. Failure is non-destructive and falls back to an + * explicitly unstable connection identity below. + */ +async function ensureTargetHostId(executor: CommandExecutor): Promise { + const candidate = randomUUID(); + const dir = "/var/lib/openship"; + const path = OPENSHIP_HOST_ID_PATH; + const script = + `umask 022; mkdir -p ${shellQuote(dir)}; ` + + `if [ ! -e ${shellQuote(path)} ]; then ` + + `(set -C; printf '%s\\n' ${shellQuote(candidate)} > ${shellQuote(path)}) 2>/dev/null || true; ` + + `fi; cat ${shellQuote(path)}`; + for (const command of [script, `sudo -n sh -c ${shellQuote(script)}`]) { + try { + const value = normalizeTargetHostId(await executor.exec(command)); + if (value) return value; + } catch { + // Try the elevated form, then return an unstable locator identity. + } + } + return null; +} + +export const LOCAL_HOST_PORT_TARGET: HostPortTargetIdentity = Object.freeze({ + targetKey: "local", + legacyTargetKeys: [], + stable: true, +}); + +/** + * Resolve a remote server row to the physical machine it reaches. + * + * Server ids are mutable database aliases, so they are never the authority for + * new claims. A target-issued id survives row deletion/recreation and lets two + * rows that reach the same machine share one lock and collision domain. + */ +export async function resolveHostPortTargetIdentity(input: { + localHost: boolean; + serverId: string | null; + executor: CommandExecutor | null; + connection: HostPortConnectionLocator | null; +}): Promise { + if (input.localHost) return LOCAL_HOST_PORT_TARGET; + if (!input.serverId || !input.executor || !input.connection) { + throw new Error("Cannot resolve a remote host-port target without its server and executor"); + } + + let stableId = await readValidatedTargetId(input.executor); + if (!stableId) { + const created = await ensureTargetHostId(input.executor); + if (created) { + stableId = { source: "openship-host-id", value: created }; + stableTargetIds.set(input.executor, stableId); + } + } + const physicalFingerprint = stableId + ? fingerprint(stableId.source, stableId.value) + : fingerprint("connection", normalizeHostPortConnectionLocator(input.connection)); + + return { + targetKey: `host:${physicalFingerprint}`, + legacyTargetKeys: [`server:${input.serverId}`], + stable: Boolean(stableId), + }; +} diff --git a/apps/api/src/lib/local-row-no-host-channel.test.ts b/apps/api/src/lib/local-row-no-host-channel.test.ts index 7317258e2..515427d62 100644 --- a/apps/api/src/lib/local-row-no-host-channel.test.ts +++ b/apps/api/src/lib/local-row-no-host-channel.test.ts @@ -45,7 +45,8 @@ vi.mock("./provision-lock", () => ({ createProvisionLock: () => ({ run: (f: () => unknown) => f() }), })); -const { resolveServerExecutor, hostChannelDeployNotice } = await import("./deployment-runtime"); +const { resolvePlannedTargetTopology, resolveServerExecutor, hostChannelDeployNotice } = + await import("./deployment-runtime"); const { HostChannelUnavailableError } = await import("@repo/adapters"); const resolve = () => resolveServerExecutor("srv-local", "org1"); @@ -56,6 +57,13 @@ beforeEach(() => { }); describe("resolveServerExecutor — local row with no host channel", () => { + it("plans socket Docker source without acquiring a host command channel", async () => { + await expect( + resolvePlannedTargetTopology("server", "srv-local", "org1"), + ).resolves.toEqual({ serverId: "srv-local", dockerTransport: "socket" }); + expect(h.acquire).not.toHaveBeenCalled(); + }); + it("still resolves when host control is switched off", async () => { h.acquire.mockRejectedValue( new HostChannelUnavailableError("disabled", "Host control is disabled on this instance."), diff --git a/apps/api/src/lib/project-route-store.ts b/apps/api/src/lib/project-route-store.ts index 7335f2ada..129d173de 100644 --- a/apps/api/src/lib/project-route-store.ts +++ b/apps/api/src/lib/project-route-store.ts @@ -16,14 +16,14 @@ interface SyncProjectPublicRoutesInput { endpoints?: StoredPublicEndpoint[] | null; currentDomains?: Domain[] | null; /** - * When true, a VERIFIED custom domain is never destroyed by this sync: a row - * the desired set omits is kept (not deleted), and a desired route that carries - * no port/path never nulls the row's live target. Only the DEPLOY pipeline sets - * this — a deploy that resolved to the wrong target (e.g. "local") must not - * erase a user's proven custom domain (the Access-URL-regressed-to-localhost - * bug). The Domains editor leaves it false so explicit removals/edits still win. + * When true, custom-domain configuration is never destroyed by this sync: an + * omitted row is kept, and a desired route with no target never nulls its + * stored target. Only deployment reconciliation sets this. Verification is a + * lifecycle state, not ownership: pending domains are just as user-owned as + * verified ones. The Domains editor leaves this false so explicit removals and + * edits remain authoritative. */ - preserveVerifiedCustom?: boolean; + preserveCustomDomains?: boolean; } interface DesiredProjectRoute { @@ -126,10 +126,10 @@ export async function syncProjectPublicRoutes( for (const domain of existingDomains) { if (!desiredByHostname.has(domain.hostname.toLowerCase())) { - // Keep a verified custom domain the deploy didn't mention — see - // preserveVerifiedCustom. A row absent from the desired set is otherwise an - // explicit removal, which the editor path (flag off) still performs. - if (input.preserveVerifiedCustom && domain.domainType === "custom" && domain.verified) { + // A deploy payload describes this release, not the user's durable domain + // configuration. Keep every custom row it omits, including pending rows; + // only the editor path (flag off) may make omission mean explicit removal. + if (input.preserveCustomDomains && domain.domainType === "custom") { continue; } await repos.domain.remove(domain.id); @@ -234,7 +234,8 @@ export async function syncProjectPublicRoutes( // exactly what regressed the Access URL to localhost. An explicit new value is // still applied; only a "no target" desired route is treated as "leave as-is". const protectTarget = - input.preserveVerifiedCustom && existing.verified && (existing.domainType ?? route.domainType) === "custom"; + input.preserveCustomDomains && + (existing.domainType ?? route.domainType) === "custom"; if ((existing.serviceId ?? null) !== null) patch.serviceId = null; if ( (existing.targetPort ?? null) !== (route.targetPort ?? null) && @@ -276,4 +277,4 @@ export async function syncProjectPublicRoutes( } return endpoints; -} \ No newline at end of file +} diff --git a/apps/api/src/lib/project-service-upstream.test.ts b/apps/api/src/lib/project-service-upstream.test.ts index 2d8033a39..d615f0b57 100644 --- a/apps/api/src/lib/project-service-upstream.test.ts +++ b/apps/api/src/lib/project-service-upstream.test.ts @@ -147,7 +147,14 @@ describe("pickProjectPortOwner", () => { it("reads exposedPort, which outranks the ports list", () => { const withExposedPort = [ - { id: "svc-api", name: "api", enabled: true, exposed: true, exposedPort: "4000", ports: ["5000"] }, + { + id: "svc-api", + name: "api", + enabled: true, + exposed: true, + exposedPort: "4000", + ports: ["5000"], + }, ]; expect( pickProjectPortOwner({ @@ -176,7 +183,9 @@ describe("pickProjectPortOwner", () => { ]); // Without the domain row, list order decides; with it, the canonical row wins — // the same answer `deployment.containerId` held (pickPrimaryServiceId). - expect(pickProjectPortOwner({ port: 3000, services: clash, rowByService: clashRows })?.serviceId).toBe("svc-a"); + expect( + pickProjectPortOwner({ port: 3000, services: clash, rowByService: clashRows })?.serviceId, + ).toBe("svc-a"); expect( pickProjectPortOwner({ port: 3000, @@ -294,15 +303,17 @@ describe("pickProjectPortOwner multi-publish pairing", () => { }); it("still resolves the first mapping correctly", () => { - expect( - pickProjectPortOwner({ port: 8080, services: multi, rowByService: row }), - ).toMatchObject({ containerPort: 3000, via: "published-port" }); + expect(pickProjectPortOwner({ port: 8080, services: multi, rowByService: row })).toMatchObject({ + containerPort: 3000, + via: "published-port", + }); }); it("dials a container port directly when the service listens on it", () => { - expect( - pickProjectPortOwner({ port: 4000, services: multi, rowByService: row }), - ).toMatchObject({ containerPort: 4000, via: "container-port" }); + expect(pickProjectPortOwner({ port: 4000, services: multi, rowByService: row })).toMatchObject({ + containerPort: 4000, + via: "container-port", + }); }); it("reads the container side first WITHIN one service when the number is on both", () => { @@ -359,13 +370,14 @@ describe("deploy and live upstream resolution agree", () => { getContainerInfo: async () => ({ containerId: "c", status: "running", ip, hostPort }) as never, }); - const cases: Array<{ label: string; strategy: "loopback-port" | "container-ip"; port: number }> = [ - { label: "container-port match, loopback", strategy: "loopback-port", port: 3000 }, - { label: "container-port match, container-ip", strategy: "container-ip", port: 3000 }, - { label: "published-port match, loopback", strategy: "loopback-port", port: 8080 }, - { label: "published-port match, container-ip", strategy: "container-ip", port: 8080 }, - { label: "no match at all", strategy: "loopback-port", port: 9999 }, - ]; + const cases: Array<{ label: string; strategy: "loopback-port" | "container-ip"; port: number }> = + [ + { label: "container-port match, loopback", strategy: "loopback-port", port: 3000 }, + { label: "container-port match, container-ip", strategy: "container-ip", port: 3000 }, + { label: "published-port match, loopback", strategy: "loopback-port", port: 8080 }, + { label: "published-port match, container-ip", strategy: "container-ip", port: 8080 }, + { label: "no match at all", strategy: "loopback-port", port: 9999 }, + ]; const mapped = [ { id: "svc-postgres", name: "postgres", enabled: true, exposed: false, ports: ["5432"] }, @@ -453,7 +465,10 @@ describe("buildProjectServiceUpstream host-port attribution", () => { ]; // 32001 is 9000's pin (the primary routed port). 9001 has none. const multiRows = new Map([ - ["svc-minio", { serviceId: "svc-minio", containerId: "c-minio", ip: "10.0.0.7", hostPort: 32001 }], + [ + "svc-minio", + { serviceId: "svc-minio", containerId: "c-minio", ip: "10.0.0.7", hostPort: 32001 }, + ], ]); it("does NOT dial another port's pin for a multi-port service", () => { @@ -478,7 +493,10 @@ describe("buildProjectServiceUpstream host-port attribution", () => { port: 3000, services: single, rowByService: new Map([ - ["svc-web", { serviceId: "svc-web", containerId: "c-web", ip: "10.0.0.2", hostPort: 32770 }], + [ + "svc-web", + { serviceId: "svc-web", containerId: "c-web", ip: "10.0.0.2", hostPort: 32770 }, + ], ]), })?.url, ).toBe("http://127.0.0.1:32770"); @@ -499,8 +517,8 @@ describe("buildProjectServiceUpstream host-port attribution", () => { describe("buildProjectServiceUpstream with a per-port host-port map", () => { /** * The exact answer, once the caller has one. A deploy holds the runtime's report of - * every binding UNIONED with the pins it just published, so it can say what container - * port 9001 is published on rather than guessing from the one scalar the row keeps. + * every binding UNIONED with the pins it just published and persists that map, so + * deploy-time and later route re-applies make the same per-port decision. */ const multi = [ { id: "svc-minio", name: "minio", enabled: true, exposed: true, ports: ["9000", "9001"] }, @@ -528,6 +546,28 @@ describe("buildProjectServiceUpstream with a per-port host-port map", () => { ).toBe("http://127.0.0.1:34101"); }); + it("uses the durable service-deployment map after the deploy result is gone", () => { + expect( + buildProjectServiceUpstream({ + strategy: "loopback-port", + port: 9001, + services: multi, + rowByService: new Map([ + [ + "svc-minio", + { + serviceId: "svc-minio", + containerId: "c-minio", + ip: "10.0.0.7", + hostPort: 34100, + hostPorts: { "9000": 34100, "9001": 34101 }, + }, + ], + ]), + })?.url, + ).toBe("http://127.0.0.1:34101"); + }); + it("treats a map with no entry for the port as 'not published', not 'use a sibling'", () => { expect( buildProjectServiceUpstream({ diff --git a/apps/api/src/lib/project-service-upstream.ts b/apps/api/src/lib/project-service-upstream.ts index 1fdb63b95..5df0afb70 100644 --- a/apps/api/src/lib/project-service-upstream.ts +++ b/apps/api/src/lib/project-service-upstream.ts @@ -45,14 +45,33 @@ export interface UpstreamCandidateRow { serviceId: string; containerId?: string | null; ip?: string | null; - /** The ONE host port the row persists. Arbitrary for a multi-port container — see - * `hostPortByContainerPort`, which a DEPLOY can supply and a stored row cannot. */ + /** Legacy/primary host-port scalar. Arbitrary for a multi-port container. */ hostPort?: number | null; + /** Durable CONTAINER-port → host-port bindings persisted on a service deployment. */ + hostPorts?: Record | null; /** CONTAINER port → host port, when the caller knows the full picture (a deploy * holds the runtime's report unioned with the pins it just published). */ hostPortByContainerPort?: Record; } +/** Concrete per-port bindings, preferring the just-observed deploy result over its + * persisted cache. Legacy migration markers are intentionally ignored. */ +function rowHostPortEntries( + row: UpstreamCandidateRow | undefined, +): Array<{ container: number; host: number }> { + const bindings = row?.hostPortByContainerPort ?? row?.hostPorts; + return Object.entries(bindings ?? {}).flatMap(([container, host]) => { + const parsed = Number(container); + return Number.isInteger(parsed) && + parsed > 0 && + parsed <= 65_535 && + Number.isInteger(host) && + host > 0 + ? [{ container: parsed, host }] + : []; + }); +} + /** Domain-row fields `pickPrimaryServiceId` breaks a tie on. */ export interface UpstreamCandidateDomain { verified: boolean; @@ -100,11 +119,9 @@ function containerPorts(service: UpstreamCandidateService): number[] { * 9090 must dial 4000; reading "the service's port" would answer 3000 and route the * domain at the wrong app. * - * The live row's publish has no declared mapping to pair with — adoption strips - * declared publishes (migrate.service.ts `normalizeHostPorts`), so only the row still - * knows the number — hence a `null` container side there. That still MATCHES the - * service, which is the valuable half: under the default loopback-port strategy the - * container port is never dialed at all. + * A legacy row's scalar publish has no declared mapping to pair with — adoption + * strips declared publishes — so its container side can still be `null`. New rows + * persist the complete map and therefore retain the exact pairing. */ function publishedPortPairs( service: UpstreamCandidateService, @@ -118,7 +135,12 @@ function publishedPortPairs( for (const entry of service.ports ?? []) { add(parseServiceHostPort(entry), parseServicePort(entry)); } - if (row?.hostPort != null) add(row.hostPort, resolveServicePort(service, null)); + const observed = rowHostPortEntries(row); + if (observed.length > 0) { + for (const binding of observed) add(binding.host, binding.container); + } else if (row?.hostPort != null) { + add(row.hostPort, resolveServicePort(service, null)); + } return pairs; } @@ -226,11 +248,9 @@ export function buildProjectServiceUpstream( /** * Which host port belongs to the port we resolved. * - * A `service_deployment` row carries ONE scalar `hostPort`, and on the deploy path it - * is the pin of the service's PRIMARY routed port — the deploy pins only the ports its - * own service routes use, so a project-level route's port may have no pin at all. - * Applying the scalar to a container port it doesn't belong to is not a near miss: it - * dials a DIFFERENT app on the same box. + * The legacy scalar `hostPort` is the pin of the service's PRIMARY routed port. + * Applying it to another container port is not a near miss: it dials a DIFFERENT app + * on the same box. * * A per-port map answers it exactly, and its ABSENCE of an entry is meaningful — that * port simply isn't published, so the upstream is the container IP rather than a @@ -246,11 +266,13 @@ export function buildProjectServiceUpstream( ...(chosen?.ports ?? []).map((entry) => parseServicePort(entry)), ].filter((port): port is number => port !== null), ); - const hostPort = row.hostPortByContainerPort - ? row.hostPortByContainerPort[owner.containerPort] - : declaredContainerPorts.size <= 1 - ? row.hostPort - : undefined; + const hostPortEntries = rowHostPortEntries(row); + const hostPort = + hostPortEntries.length > 0 + ? hostPortEntries.find((binding) => binding.container === owner.containerPort)?.host + : declaredContainerPorts.size <= 1 + ? row.hostPort + : undefined; const url = buildUpstreamUrl({ strategy: input.strategy, @@ -280,6 +302,7 @@ export async function resolveProjectServiceUpstream( input: PortMatchInput & { strategy: RouteStrategy; runtime: Parameters[0]["runtime"]; + requireLiveObservation?: boolean; }, ): Promise<{ url: string; owner: ProjectPortOwner } | null> { const owner = pickProjectPortOwner(input); @@ -294,7 +317,8 @@ export async function resolveProjectServiceUpstream( containerPort: owner.containerPort, // The persisted row is a CACHE of a past live read — `resolveLiveUpstreamUrl` // consults it only when it can't ask the daemon itself. - stored: { ip: row.ip, hostPort: row.hostPort }, + stored: { ip: row.ip, hostPort: row.hostPort, hostPorts: row.hostPorts }, + requireLiveObservation: input.requireLiveObservation, }); return url ? { url, owner } : null; } diff --git a/apps/api/src/lib/public-endpoints.ts b/apps/api/src/lib/public-endpoints.ts index 624f79c49..fab1c3cfc 100644 --- a/apps/api/src/lib/public-endpoints.ts +++ b/apps/api/src/lib/public-endpoints.ts @@ -2,6 +2,7 @@ import type { Domain, Project, Service, ServicePublicEndpoint } from "@repo/db"; import { SYSTEM, ValidationError, + isLoopbackHost as isCoreLoopbackHost, resolveServiceHostnameLabel, resolveRedirectStatus, normalizeCustomHostname, @@ -33,8 +34,7 @@ export function isReservedLoopbackPort(port: number): boolean { /** True for a loopback host (the only place isReservedLoopbackPort applies). */ export function isLoopbackHost(host: string): boolean { - const h = host.trim().toLowerCase(); - return h === "localhost" || h === "::1" || /^127(?:\.\d{1,3}){3}$/.test(h); + return isCoreLoopbackHost(host); } export interface StoredPublicEndpoint { @@ -252,11 +252,14 @@ function routeRowsToPublicEndpoints( function primaryProjectDomain(projectDomains?: ProjectDomainRow[] | null): string | undefined { const projectLevelDomains = (projectDomains ?? []).filter( - (domain) => !domain.serviceId && inferPublicRouteDomainType(domain.hostname, domain.domainType) === "custom", + (domain) => + !domain.serviceId && + inferPublicRouteDomainType(domain.hostname, domain.domainType) === "custom", ); - const primaryDomain = projectLevelDomains.find((domain) => domain.isPrimary) - ?? projectLevelDomains.find((domain) => domain.verified) - ?? projectLevelDomains[0]; + const primaryDomain = + projectLevelDomains.find((domain) => domain.isPrimary) ?? + projectLevelDomains.find((domain) => domain.verified) ?? + projectLevelDomains[0]; return normalizeCustomDomain(primaryDomain?.hostname); } @@ -272,12 +275,10 @@ function normalizeStoredPublicEndpoint( const port = normalizePort(endpoint.port); const targetPath = normalizeTargetPath(endpoint.targetPath); const domainType = endpoint.domainType === "custom" ? "custom" : "free"; - const domain = domainType === "free" - ? normalizeSlug(endpoint.domain ?? opts?.freeDomainFallback) - : undefined; - const customDomain = domainType === "custom" - ? normalizeCustomDomain(endpoint.customDomain) - : undefined; + const domain = + domainType === "free" ? normalizeSlug(endpoint.domain ?? opts?.freeDomainFallback) : undefined; + const customDomain = + domainType === "custom" ? normalizeCustomDomain(endpoint.customDomain) : undefined; const hasPortTarget = port !== null; const hasPathTarget = Boolean(targetPath); @@ -302,15 +303,15 @@ export function normalizeStoredPublicEndpoints( if (!endpoints?.length) return []; return endpoints - .map((endpoint, index) => normalizeStoredPublicEndpoint( - endpoint, - index === 0 && opts?.primaryFreeDomainFallback - ? { freeDomainFallback: opts.primaryFreeDomainFallback } - : undefined, - )) - .filter( - (endpoint): endpoint is StoredPublicEndpoint => endpoint !== null, - ); + .map((endpoint, index) => + normalizeStoredPublicEndpoint( + endpoint, + index === 0 && opts?.primaryFreeDomainFallback + ? { freeDomainFallback: opts.primaryFreeDomainFallback } + : undefined, + ), + ) + .filter((endpoint): endpoint is StoredPublicEndpoint => endpoint !== null); } function alignPrimaryStoredPublicEndpoint( @@ -345,19 +346,22 @@ export function resolveStoredPublicEndpoints(opts: { const explicitTargetPort = normalizePort(opts.targetPort); const explicitTargetPath = normalizeTargetPath(opts.targetPath); - const explicitTarget = (explicitTargetPort !== null) !== Boolean(explicitTargetPath) - ? (explicitTargetPort !== null + const explicitTarget = + (explicitTargetPort !== null) !== Boolean(explicitTargetPath) + ? explicitTargetPort !== null ? { port: explicitTargetPort } - : { targetPath: explicitTargetPath! }) - : null; + : { targetPath: explicitTargetPath! } + : null; if (explicitCustomDomain) { return explicitTarget - ? [{ - customDomain: explicitCustomDomain, - ...explicitTarget, - domainType: "custom", - } satisfies StoredPublicEndpoint] + ? [ + { + customDomain: explicitCustomDomain, + ...explicitTarget, + domainType: "custom", + } satisfies StoredPublicEndpoint, + ] : []; } @@ -374,11 +378,13 @@ export function resolveStoredPublicEndpoints(opts: { const primaryCustomDomain = primaryProjectDomain(opts.projectDomains); if (primaryCustomDomain) { return explicitTarget - ? [{ - customDomain: primaryCustomDomain, - ...explicitTarget, - domainType: "custom", - } satisfies StoredPublicEndpoint] + ? [ + { + customDomain: primaryCustomDomain, + ...explicitTarget, + domainType: "custom", + } satisfies StoredPublicEndpoint, + ] : []; } @@ -393,11 +399,13 @@ export function resolveStoredPublicEndpoints(opts: { return []; } - return [{ - ...explicitTarget, - domain: slug, - domainType: "free", - } satisfies StoredPublicEndpoint]; + return [ + { + ...explicitTarget, + domain: slug, + domainType: "free", + } satisfies StoredPublicEndpoint, + ]; } export function syncStoredPublicEndpoints(opts: { @@ -487,9 +495,9 @@ export function storedPublicEndpointsNeedCloud( // `domainType` is intentionally NOT read below — classification is purely by // hostname truth (customDomain / domain). Accepting it as OPTIONAL lets deploy // preflight and stale/migrated rows (domainType absent) reuse this one predicate. - endpoints?: - | Array>> - | null, + endpoints?: Array< + Partial> + > | null, ): boolean { if (!endpoints?.length) return false; return endpoints.some((endpoint) => cloudManagedHostnameOf(endpoint) !== null); @@ -518,7 +526,13 @@ export function storedPublicEndpointsNeedCloud( export function resolveServicePublicEndpoints( service: Pick< Service, - "exposed" | "exposedPort" | "ports" | "domain" | "customDomain" | "domainType" | "publicEndpoints" + | "exposed" + | "exposedPort" + | "ports" + | "domain" + | "customDomain" + | "domainType" + | "publicEndpoints" > & { name?: string | null; kind?: string | null }, opts?: { projectSlug?: string }, ): StoredPublicEndpoint[] { @@ -618,8 +632,8 @@ function primaryScalars(primary: ServicePublicEndpoint): { } { return { exposedPort: String(primary.port), - domain: primary.domainType === "free" ? primary.domain ?? null : null, - customDomain: primary.domainType === "custom" ? primary.customDomain ?? null : null, + domain: primary.domainType === "free" ? (primary.domain ?? null) : null, + customDomain: primary.domainType === "custom" ? (primary.customDomain ?? null) : null, domainType: primary.domainType, }; } @@ -715,11 +729,15 @@ export function mergeServiceRoutingPatch(opts: { // disagreed with that test about what "named" means. const domain = domainType === "free" - ? (patch.domain !== undefined ? patch.domain : stored?.domain ?? null) + ? patch.domain !== undefined + ? patch.domain + : (stored?.domain ?? null) : null; const customDomain = domainType === "custom" - ? (patch.customDomain !== undefined ? patch.customDomain : stored?.customDomain ?? null) + ? patch.customDomain !== undefined + ? patch.customDomain + : (stored?.customDomain ?? null) : null; const scalars = { exposed, exposedPort, domain, customDomain, domainType } as const; @@ -786,12 +804,14 @@ export function mergeServiceRoutingPatch(opts: { // owns this hostname (that IS a port change, and replacing it in place keeps // the primary primary). Otherwise this is a new route: append. const byPort = current.findIndex((route) => route.port === upserted.port); - const at = byPort >= 0 - ? byPort - : current.findIndex((route) => routeIdentity(route) === routeIdentity(upserted)); - const next = at >= 0 - ? current.map((route, index) => (index === at ? upserted : route)) - : [...current, upserted]; + const at = + byPort >= 0 + ? byPort + : current.findIndex((route) => routeIdentity(route) === routeIdentity(upserted)); + const next = + at >= 0 + ? current.map((route, index) => (index === at ? upserted : route)) + : [...current, upserted]; const upsertedAt = at >= 0 ? at : next.length - 1; // Backstop for a row that already held the same hostname on two ports. const deduped = next.filter( @@ -936,9 +956,9 @@ export interface ProjectAccess { * verified row, else none. The single primary-selection rule the detail Access * URL, the list card's primaryDomain, and the favicon refresh all share, so * those surfaces can never disagree on which domain is "the" one. */ -export function pickCanonicalDomainRow< - T extends Pick, ->(rows: T[] | null | undefined): T | null { +export function pickCanonicalDomainRow>( + rows: T[] | null | undefined, +): T | null { const verified = (rows ?? []).filter((row) => row.verified); return verified.find((row) => row.isPrimary) ?? verified[0] ?? null; } @@ -1015,4 +1035,4 @@ export function resolveProjectAccess(input: { } return { url: null, host: null, kind: "none", isLocal: false, urls: [] }; -} \ No newline at end of file +} diff --git a/apps/api/src/lib/release-dist.ts b/apps/api/src/lib/release-dist.ts index eb8ceec47..a383810aa 100644 --- a/apps/api/src/lib/release-dist.ts +++ b/apps/api/src/lib/release-dist.ts @@ -21,8 +21,13 @@ export { resolveReleaseDist, resolveReleaseDistOrNull, fetchLatestRelease, + resolveLatestGitHubReleaseVersion, resolveLatestReleaseTag, + resolveLatestReleaseVersion, resolveLatestVersion, + resolveReleaseVersion, + ReleaseVersionUnavailableError, type ReleaseDistSpec, type ReleaseDistResult, + type ResolvedReleaseVersion, } from "./release-resolver"; diff --git a/apps/api/src/lib/release-resolver.ts b/apps/api/src/lib/release-resolver.ts index b49dce06b..d918a4a4c 100644 --- a/apps/api/src/lib/release-resolver.ts +++ b/apps/api/src/lib/release-resolver.ts @@ -21,8 +21,10 @@ import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { GithubReleasePayload, ReleaseSource } from "@repo/core"; import { renderAssetName } from "@repo/core"; +import { env } from "../config/env"; import { APP_VERSION } from "./app-version"; -import { assertPublicHttps, fetchAndExtractRelease } from "./release-download"; +import { fetchAndExtractRelease } from "./release-download"; +import { safeFetch } from "./safe-fetch"; const __dirname = (() => { try { @@ -68,7 +70,11 @@ function computeDataDir(): string { export class ReleaseDistMissingError extends Error { readonly code = "RELEASE_DIST_MISSING" as const; - constructor(public readonly name: string, distPath: string, options?: { cause?: unknown }) { + constructor( + public readonly name: string, + distPath: string, + options?: { cause?: unknown }, + ) { super( `Release dist for "${name}" not found at ${distPath}. Build it locally, ` + `set its env override to a prebuilt dir, or ensure the release asset exists.`, @@ -103,7 +109,7 @@ export interface ReleaseDistResult { /** Resolve (and download on miss) the prebuilt dist directory for a release source. */ export async function resolveReleaseDist(spec: ReleaseDistSpec): Promise { - const version = spec.version.replace(/^v/, ""); + const version = spec.version.replace(/^v/i, ""); const tag = `v${version}`; // Slot 1: env override. @@ -156,7 +162,7 @@ export async function resolveReleaseDist(spec: ReleaseDistSpec): Promise { try { @@ -191,11 +234,17 @@ export async function fetchLatestRelease(repo: string): Promise { +/** Latest published GitHub tag with both normalized + exact identities. */ +export async function resolveLatestGitHubReleaseVersion( + repo: string, +): Promise { const release = await fetchLatestRelease(repo); - const tag = release?.tag_name?.trim(); - return tag ? tag.replace(/^v/, "") : null; + return resolvedVersion(release?.tag_name); +} + +/** Latest release tag (leading "v" stripped), or null. Compatibility wrapper. */ +export async function resolveLatestReleaseTag(repo: string): Promise { + return (await resolveLatestGitHubReleaseVersion(repo))?.version ?? null; } /** @@ -203,26 +252,53 @@ export async function resolveLatestReleaseTag(repo: string): Promise { +export async function resolveLatestReleaseVersion( + source: ReleaseSource, +): Promise { if (source.mode === "url") { if (!source.versionUrl) return null; return fetchVersionFromUrl(source.versionUrl); } - return source.repo ? resolveLatestReleaseTag(source.repo) : null; + return source.repo ? resolveLatestGitHubReleaseVersion(source.repo) : null; +} + +/** + * Resolve the version a deployment should ship. Explicit webhook/manual input + * wins, then a configured pin, then the latest upstream release. An unavailable + * arbitrary upstream never silently borrows Openship's own API version. + */ +export async function resolveReleaseVersion( + source: ReleaseSource, + opts?: { version?: string }, +): Promise { + const resolved = + resolvedVersion(opts?.version) ?? + resolvedVersion(source.pinnedVersion) ?? + (await resolveLatestReleaseVersion(source)); + if (!resolved) throw new ReleaseVersionUnavailableError(source); + return resolved; } -async function fetchVersionFromUrl(url: string): Promise { +/** Newest normalized version, or null. Compatibility wrapper for drift callers. */ +export async function resolveLatestVersion(source: ReleaseSource): Promise { + return (await resolveLatestReleaseVersion(source))?.version ?? null; +} + +async function fetchVersionFromUrl(url: string): Promise { try { - assertPublicHttps(url, "releaseSource.versionUrl"); - const ctl = new AbortController(); - const timer = setTimeout(() => ctl.abort(), 10_000); - const res = await fetch(url, { + // User-controlled URL: safeFetch resolves once, validates + pins the chosen + // IP, and repeats that validation for every redirect. Self-hosted installs + // may intentionally use a LAN release feed; multi-tenant cloud never may. + const res = await safeFetch(url, { headers: { "User-Agent": "openship" }, - redirect: "follow", - signal: ctl.signal, - }).finally(() => clearTimeout(timer)); + timeoutMs: 10_000, + maxRedirects: 5, + maxBodyBytes: 8192, + allowPrivate: !env.CLOUD_MODE, + }); if (!res.ok) return null; const body = (await res.text()).trim(); if (!body) return null; @@ -231,12 +307,12 @@ async function fetchVersionFromUrl(url: string): Promise { try { const parsed = JSON.parse(body) as { version?: unknown; tag_name?: unknown }; const v = typeof parsed.version === "string" ? parsed.version : parsed.tag_name; - return typeof v === "string" && v.trim() ? v.trim().replace(/^v/, "") : null; + return typeof v === "string" ? resolvedVersion(v) : null; } catch { return null; } } - return body.replace(/^v/, ""); + return resolvedVersion(body); } catch { return null; } diff --git a/apps/api/src/lib/route-apply.service.test.ts b/apps/api/src/lib/route-apply.service.test.ts new file mode 100644 index 000000000..73d5cfd63 --- /dev/null +++ b/apps/api/src/lib/route-apply.service.test.ts @@ -0,0 +1,303 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const reserveObserved = vi.hoisted(() => vi.fn()); +const prepareTarget = vi.hoisted(() => vi.fn()); +const convergeTarget = vi.hoisted(() => vi.fn()); +const withTargetLock = vi.hoisted(() => vi.fn(async (_target, run) => run())); +vi.mock("../modules/deployments/observed-host-port-claims", async (importOriginal) => ({ + ...(await importOriginal()), + reserveObservedLoopbackPublishes: reserveObserved, +})); +vi.mock("../modules/deployments/pinned-host-ports", () => ({ + convergeTargetHostPortClaimsUnlocked: convergeTarget, + prepareTargetPinnedHostPorts: prepareTarget, + withHostPortTargetLock: withTargetLock, +})); + +vi.mock("./controller-helpers", () => ({ + platform: () => ({ routing: { removeRoute: vi.fn() } }), +})); +vi.mock("./deployment-runtime", () => ({ + disposePlatform: vi.fn(), + resolveDeploymentPlatform: vi.fn(), +})); +vi.mock("./cloud-route.service", () => ({ + reapplyCloudProjectRoute: vi.fn(), + removeCloudProjectRoute: vi.fn(), +})); + +import { reconcileProjectRoutes } from "./route-apply.service"; + +const target = { targetKey: "local" as const, legacyTargetKeys: [], stable: true }; +const edgeProxy = { listLoopbackUpstreamPortsStrict: vi.fn(async () => new Set()) }; +const project = { + id: "proj_1", + slug: "app", + organizationId: "org_1", + activeDeploymentId: "dep_1", + cloudWorkspaceId: null, + webhookDomain: null, + routingConfig: null, +}; + +describe("reconcileProjectRoutes host-port ownership gate", () => { + beforeEach(() => { + reserveObserved.mockReset().mockResolvedValue(undefined); + prepareTarget.mockReset().mockResolvedValue([]); + convergeTarget.mockReset().mockResolvedValue({ released: 0, retained: [] }); + withTargetLock.mockClear(); + }); + + it("fails closed before any edge mutation when a claim conflicts", async () => { + const conflict = new Error("host port belongs to another project"); + reserveObserved.mockRejectedValueOnce(conflict); + const routing = { registerRoute: vi.fn(), removeRoute: vi.fn() }; + + await expect( + reconcileProjectRoutes(project, { + routing: routing as never, + hostPortTarget: target, + edgeProxy, + removes: [{ hostname: "old.example.com", isCustomDomain: true }], + registers: [ + { + hostname: "app.example.com", + isCustomDomain: true, + targetUrl: "http://127.0.0.1:23000", + observedLoopbackPublishes: [ + { serviceId: "svc_1", containerPort: 3000, hostPort: 23000 }, + ], + }, + ], + }), + ).rejects.toBe(conflict); + + expect(routing.removeRoute).not.toHaveBeenCalled(); + expect(routing.registerRoute).not.toHaveBeenCalled(); + expect(withTargetLock).toHaveBeenCalledWith(target, expect.any(Function)); + }); + + it("refuses a loopback route whose stable workload metadata is missing", async () => { + const routing = { registerRoute: vi.fn(), removeRoute: vi.fn() }; + + await expect( + reconcileProjectRoutes(project, { + routing: routing as never, + hostPortTarget: target, + edgeProxy, + registers: [ + { + hostname: "app.example.com", + isCustomDomain: true, + targetUrl: "http://127.0.0.1:23000", + }, + ], + }), + ).rejects.toThrow("without stable workload ownership"); + expect(reserveObserved).not.toHaveBeenCalled(); + expect(routing.registerRoute).not.toHaveBeenCalled(); + }); + + it("reserves all direct and path loopback publishes before registration", async () => { + const order: string[] = []; + reserveObserved.mockImplementation(async () => { + order.push("reserve"); + }); + convergeTarget.mockImplementation(async () => { + order.push("converge"); + return { released: 0, retained: [] }; + }); + const routing = { + removeRoute: vi.fn(), + registerRoute: vi.fn(async () => { + order.push("register"); + }), + }; + + await reconcileProjectRoutes(project, { + routing: routing as never, + hostPortTarget: target, + edgeProxy, + registers: [ + { + hostname: "app.example.com", + isCustomDomain: true, + targetUrl: "http://127.0.0.1:23000", + proxyLocations: [{ pathPrefix: "/api/", targetUrl: "http://127.0.0.1:23001" }], + observedLoopbackPublishes: [ + { serviceId: "svc_web", containerPort: 3000, hostPort: 23000 }, + { serviceId: "svc_api", containerPort: 4000, hostPort: 23001 }, + ], + }, + ], + }); + + expect(reserveObserved).toHaveBeenCalledWith({ + target, + projectId: "proj_1", + publishes: [ + { serviceId: "svc_web", containerPort: 3000, hostPort: 23000 }, + { serviceId: "svc_api", containerPort: 4000, hostPort: 23001 }, + ], + }); + expect(convergeTarget).toHaveBeenCalledWith({ + target, + projectId: "proj_1", + desiredPublishes: [ + { serviceId: "svc_web", containerPort: 3000, hostPort: 23000 }, + { serviceId: "svc_api", containerPort: 4000, hostPort: 23001 }, + ], + edgeProxy, + }); + expect(order).toEqual(["reserve", "register", "converge"]); + }); + + it("does not let one vhost reserve another vhost's port through stray metadata", async () => { + const routing = { registerRoute: vi.fn(), removeRoute: vi.fn() }; + + await reconcileProjectRoutes(project, { + routing: routing as never, + hostPortTarget: target, + edgeProxy, + registers: [ + { + hostname: "web.example.com", + isCustomDomain: true, + targetUrl: "http://127.0.0.1:23000", + observedLoopbackPublishes: [ + { serviceId: "svc_web", containerPort: 3000, hostPort: 23000 }, + // This belongs to the API vhost below and must not be attributed to + // the web registration merely because it is dialled somewhere in + // the same reconciliation batch. + { serviceId: "svc_wrong", containerPort: 9999, hostPort: 23001 }, + ], + }, + { + hostname: "api.example.com", + isCustomDomain: true, + targetUrl: "http://127.0.0.1:23001", + observedLoopbackPublishes: [ + { serviceId: "svc_api", containerPort: 4000, hostPort: 23001 }, + ], + }, + ], + }); + + expect(reserveObserved).toHaveBeenCalledWith({ + target, + projectId: "proj_1", + publishes: [ + { serviceId: "svc_web", containerPort: 3000, hostPort: 23000 }, + { serviceId: "svc_api", containerPort: 4000, hostPort: 23001 }, + ], + }); + }); + + it("serializes removal-only cleanup and converges with no desired publish", async () => { + const order: string[] = []; + const routing = { + registerRoute: vi.fn(), + removeRoute: vi.fn(async () => { + order.push("remove"); + }), + }; + convergeTarget.mockImplementation(async () => { + order.push("converge"); + return { released: 1, retained: [] }; + }); + + await reconcileProjectRoutes(project, { + routing: routing as never, + hostPortTarget: target, + edgeProxy, + removes: [{ hostname: "old.example.com", isCustomDomain: true }], + }); + + expect(withTargetLock).toHaveBeenCalledWith(target, expect.any(Function)); + expect(convergeTarget).toHaveBeenCalledWith({ + target, + projectId: "proj_1", + desiredPublishes: [], + edgeProxy, + }); + expect(order).toEqual(["remove", "converge"]); + }); + + it("converges only publishes whose best-effort route registration succeeded", async () => { + const routing = { + removeRoute: vi.fn(), + registerRoute: vi.fn(async ({ domain }: { domain: string }) => { + if (domain === "failed.example.com") throw new Error("edge reload failed"); + }), + }; + + await reconcileProjectRoutes(project, { + routing: routing as never, + hostPortTarget: target, + edgeProxy, + registers: [ + { + hostname: "live.example.com", + isCustomDomain: true, + targetUrl: "http://127.0.0.1:23000", + observedLoopbackPublishes: [ + { serviceId: "svc_live", containerPort: 3000, hostPort: 23000 }, + ], + }, + { + hostname: "failed.example.com", + isCustomDomain: true, + targetUrl: "http://127.0.0.1:23001", + observedLoopbackPublishes: [ + { serviceId: "svc_failed", containerPort: 4000, hostPort: 23001 }, + ], + }, + ], + }); + + // Both must be collision-gated before the first edge write. The failed + // write is excluded from desired live state so convergence can release its + // unused pre-reservation if the fresh edge scan does not observe it. + expect(reserveObserved).toHaveBeenCalledWith({ + target, + projectId: "proj_1", + publishes: [ + { serviceId: "svc_live", containerPort: 3000, hostPort: 23000 }, + { serviceId: "svc_failed", containerPort: 4000, hostPort: 23001 }, + ], + }); + expect(convergeTarget).toHaveBeenCalledWith({ + target, + projectId: "proj_1", + desiredPublishes: [{ serviceId: "svc_live", containerPort: 3000, hostPort: 23000 }], + edgeProxy, + }); + }); + + it("does not claim an upstream suppressed by a host redirect", async () => { + const routing = { registerRoute: vi.fn(), removeRoute: vi.fn() }; + + await reconcileProjectRoutes(project, { + routing: routing as never, + hostPortTarget: target, + edgeProxy, + registers: [ + { + hostname: "www.example.com", + isCustomDomain: true, + targetUrl: "http://127.0.0.1:23000", + redirectHost: { target: "example.com", statusCode: 301 }, + }, + ], + }); + + expect(prepareTarget).not.toHaveBeenCalled(); + expect(reserveObserved).not.toHaveBeenCalled(); + expect(convergeTarget).toHaveBeenCalledWith({ + target, + projectId: "proj_1", + desiredPublishes: [], + edgeProxy, + }); + }); +}); diff --git a/apps/api/src/lib/route-apply.service.ts b/apps/api/src/lib/route-apply.service.ts index 83248dd66..068d842be 100644 --- a/apps/api/src/lib/route-apply.service.ts +++ b/apps/api/src/lib/route-apply.service.ts @@ -25,12 +25,14 @@ import type { Deployment } from "@repo/db"; import type { + EdgeProxyApi, Platform, RouteProxyLocation, RouteRedirect, RouteHeaderRule, RouteHostRedirect, } from "@repo/adapters"; +import { edgeProxyFor } from "@repo/adapters"; import { safeErrorMessage, sanitizeProxySettings, type RoutingConfig } from "@repo/core"; import { platform } from "./controller-helpers"; import { @@ -45,6 +47,17 @@ import { type CloudRouteProject, } from "./cloud-route.service"; import { webhookProxyTarget } from "../config"; +import type { HostPortTargetIdentity } from "./host-port-target"; +import { + loopbackHostPortFromUrl, + reserveObservedLoopbackPublishes, + type ObservedLoopbackPublish, +} from "../modules/deployments/observed-host-port-claims"; +import { + convergeTargetHostPortClaimsUnlocked, + prepareTargetPinnedHostPorts, + withHostPortTargetLock, +} from "../modules/deployments/pinned-host-ports"; export interface RouteReconcileProject extends CloudRouteProject { webhookDomain?: string | null; @@ -101,6 +114,12 @@ export interface RouteRegister { * the same treatment a domain/port edit already gets. */ redirectHost?: RouteHostRedirect; + /** + * Stable workload ownership for every loopback upstream this vhost dials, + * including `proxyLocations`. Required for loopback routes: a host-port URL + * alone cannot identify which service/container port owns the bind. + */ + observedLoopbackPublishes?: ObservedLoopbackPublish[]; } export interface RouteRemove { @@ -115,6 +134,10 @@ export async function reconcileProjectRoutes( deployment?: Deployment | null; /** Pre-resolved self-hosted routing (avoids a second resolveDeploymentRuntime). */ routing?: Platform["routing"]; + /** Required alongside pre-resolved routing when a register dials loopback. */ + hostPortTarget?: HostPortTargetIdentity | null; + /** Strict inventory for a pre-resolved routing target. */ + edgeProxy?: Pick; registers?: RouteRegister[]; removes?: RouteRemove[]; }, @@ -174,7 +197,9 @@ export async function reconcileProjectRoutes( await local .removeRoute(r.hostname) .catch((err) => - console.warn(`[route-apply] fallback removeRoute ${r.hostname} failed (non-fatal): ${safeErrorMessage(err)}`), + console.warn( + `[route-apply] fallback removeRoute ${r.hostname} failed (non-fatal): ${safeErrorMessage(err)}`, + ), ); } } @@ -186,60 +211,182 @@ export async function reconcileProjectRoutes( return; } - const webhookHost = project.webhookDomain?.trim().toLowerCase() || null; - // Sanitized here, not trusted from the row: the API validates on write, but a - // value could also have been seeded from a repo config or an older schema, and - // this string is interpolated into generated nginx config. - const proxy = sanitizeProxySettings(project.routingConfig?.proxy); + const loopbackPublishesByRegister = new Map(); + const dialledLoopbackPorts = new Set(); + const missingOwnershipPorts = new Set(); + for (const register of registers) { + // A canonical host redirect emits no upstream locations. A static route + // emits only its explicit proxyLocations; its targetUrl (when supplied by + // an older caller) is not rendered as location `/` and must not pin a port. + const renderedUrls = register.redirectHost + ? [] + : [ + ...(register.staticRoot ? [] : [register.targetUrl]), + ...(register.proxyLocations?.map((location) => location.targetUrl) ?? []), + ]; + const registerPorts = new Set(); + for (const url of renderedUrls) { + const port = loopbackHostPortFromUrl(url); + if (!port) continue; + registerPorts.add(port); + dialledLoopbackPorts.add(port); + } + const publishes = (register.observedLoopbackPublishes ?? []).filter((publish) => + registerPorts.has(publish.hostPort), + ); + loopbackPublishesByRegister.set(register, publishes); + const describedPorts = new Set(publishes.map((publish) => publish.hostPort)); + for (const port of registerPorts) { + if (!describedPorts.has(port)) missingOwnershipPorts.add(port); + } + } + // Keep the routing provider, strict inventory, and physical identity from + // the same resolved platform. Removal-only and loopback→container-IP/static + // mutations need this context too: they create no new loopback publish, but + // they are exactly when an obsolete durable claim becomes reclaimable. + const hostPortTarget = resolved?.hostPortTarget ?? opts.hostPortTarget ?? null; + const edgeProxy = resolved?.platform.executor + ? edgeProxyFor(resolved.platform.executor, "openresty", { ours: true }) + : (opts.edgeProxy ?? null); + const claimContext = + hostPortTarget && edgeProxy ? { target: hostPortTarget, edgeProxy } : undefined; - for (const r of removes) { - await routing - .removeRoute(r.hostname) - .catch((err) => - console.warn(`[route-apply] removeRoute ${r.hostname} failed (non-fatal): ${safeErrorMessage(err)}`), + let loopbackGuard: + | { + target: HostPortTargetIdentity; + edgeProxy: Pick; + publishes: ObservedLoopbackPublish[]; + } + | undefined; + if (dialledLoopbackPorts.size > 0) { + // Reuse the per-vhost filtering above rather than rebuilding this list + // against the global port set. Otherwise stray metadata attached to one + // register could reserve a different register's port even though that + // first vhost never renders it. + const publishes = registers.flatMap( + (register) => loopbackPublishesByRegister.get(register) ?? [], + ); + if (missingOwnershipPorts.size > 0) { + throw new Error( + `Refusing loopback route without stable workload ownership for host port(s): ${[...missingOwnershipPorts].join(", ")}`, ); + } + if (!hostPortTarget) { + throw new Error("Refusing loopback route without a resolved physical host-port target"); + } + if (!edgeProxy) { + throw new Error("Refusing loopback route without a strict target edge inventory"); + } + loopbackGuard = { target: hostPortTarget, edgeProxy, publishes }; } - for (const r of registers) { - // A route serves `/` from ONE of two things: a host directory (static, files - // on disk) or an upstream. Neither → nothing to serve. - if (!r.staticRoot && !r.targetUrl) { - console.warn( - `[route-apply] no upstream or static root resolved for ${r.hostname} — route not applied (redeploy to re-sync)`, - ); - continue; + const applyRoutes = async () => { + if (loopbackGuard) { + // A legacy/deleted DB row can leave a vhost with no durable claim. Import + // every observed edge port into the canonical namespace before trusting a + // stored/live upstream. An unreadable edge rejects here; it is never treated + // as empty. + await prepareTargetPinnedHostPorts({ + target: loopbackGuard.target, + edgeProxy: loopbackGuard.edgeProxy, + }); + // The collision gate is deliberately before removals and registrations. + // A foreign owner conflict propagates; no route mutation occurs. + await reserveObservedLoopbackPublishes({ + target: loopbackGuard.target, + projectId: project.id, + publishes: loopbackGuard.publishes, + }); } - const isWebhook = r.webhook ?? (!!webhookHost && r.hostname.toLowerCase() === webhookHost); - await routing - .registerRoute({ - domain: r.hostname, - tls: true, - // A custom domain's TLS is ours to terminate, so the edge must keep a :443 - // listener up for it even before its cert exists — otherwise HTTPS for it - // falls through to the edge's 443 catch-all, which answers with a - // domain-less placeholder cert and the branded not-found page, i.e. the - // domain reads as unconfigured rather than pending (#308). - // A free *.opsh.io host is fronted by Cloud's edge; not ours. - terminatesTlsLocally: r.isCustomDomain, - // staticRoot wins when present: it is the more specific instruction, and a - // caller that resolved a doc root has already decided this domain serves - // files. registerRoute keys off which one is set. - ...(r.staticRoot ? { staticRoot: r.staticRoot } : { targetUrl: r.targetUrl! }), - // Project-wide tunables, applied on the LIVE path too so raising an upload - // limit takes effect on save rather than waiting for a redeploy — the same - // treatment a domain/port edit already gets. - ...(proxy ? { proxy } : {}), - ...(isWebhook ? { webhookProxy: webhookProxyTarget } : {}), - ...(r.proxyLocations?.length ? { proxyLocations: r.proxyLocations } : {}), - ...(r.redirects?.length ? { redirects: r.redirects } : {}), - ...(r.headerRules?.length ? { headerRules: r.headerRules } : {}), - ...(r.cleanUrls ? { cleanUrls: true } : {}), - ...(r.trailingSlash === undefined ? {} : { trailingSlash: r.trailingSlash }), - ...(r.redirectHost ? { redirectHost: r.redirectHost } : {}), - }) - .catch((err) => - console.warn(`[route-apply] registerRoute ${r.hostname} failed (non-fatal): ${safeErrorMessage(err)}`), - ); + + const webhookHost = project.webhookDomain?.trim().toLowerCase() || null; + // Sanitized here, not trusted from the row: the API validates on write, but a + // value could also have been seeded from a repo config or an older schema, and + // this string is interpolated into generated nginx config. + const proxy = sanitizeProxySettings(project.routingConfig?.proxy); + const successfulPublishes: ObservedLoopbackPublish[] = []; + + for (const r of removes) { + await routing + .removeRoute(r.hostname) + .catch((err) => + console.warn( + `[route-apply] removeRoute ${r.hostname} failed (non-fatal): ${safeErrorMessage(err)}`, + ), + ); + } + + for (const r of registers) { + // A route serves `/` from ONE of two things: a host directory (static, files + // on disk) or an upstream. Neither → nothing to serve. + if (!r.staticRoot && !r.targetUrl) { + console.warn( + `[route-apply] no upstream or static root resolved for ${r.hostname} — route not applied (redeploy to re-sync)`, + ); + continue; + } + const isWebhook = r.webhook ?? (!!webhookHost && r.hostname.toLowerCase() === webhookHost); + try { + await routing.registerRoute({ + domain: r.hostname, + tls: true, + // A custom domain's TLS is ours to terminate, so the edge must keep a :443 + // listener up for it even before its cert exists — otherwise HTTPS for it + // falls through to the edge's 443 catch-all, which answers with a + // domain-less placeholder cert and the branded not-found page, i.e. the + // domain reads as unconfigured rather than pending (#308). + // A free *.opsh.io host is fronted by Cloud's edge; not ours. + terminatesTlsLocally: r.isCustomDomain, + // staticRoot wins when present: it is the more specific instruction, and a + // caller that resolved a doc root has already decided this domain serves + // files. registerRoute keys off which one is set. + ...(r.staticRoot ? { staticRoot: r.staticRoot } : { targetUrl: r.targetUrl! }), + // Project-wide tunables, applied on the LIVE path too so raising an upload + // limit takes effect on save rather than waiting for a redeploy — the same + // treatment a domain/port edit already gets. + ...(proxy ? { proxy } : {}), + ...(isWebhook ? { webhookProxy: webhookProxyTarget } : {}), + ...(r.proxyLocations?.length ? { proxyLocations: r.proxyLocations } : {}), + ...(r.redirects?.length ? { redirects: r.redirects } : {}), + ...(r.headerRules?.length ? { headerRules: r.headerRules } : {}), + ...(r.cleanUrls ? { cleanUrls: true } : {}), + ...(r.trailingSlash === undefined ? {} : { trailingSlash: r.trailingSlash }), + ...(r.redirectHost ? { redirectHost: r.redirectHost } : {}), + }); + successfulPublishes.push(...(loopbackPublishesByRegister.get(r) ?? [])); + } catch (err) { + console.warn( + `[route-apply] registerRoute ${r.hostname} failed (non-fatal): ${safeErrorMessage(err)}`, + ); + } + } + + if (claimContext) { + try { + await convergeTargetHostPortClaimsUnlocked({ + target: claimContext.target, + projectId: project.id, + // A failed best-effort registration is not desired live state. The + // fresh edge scan below still retains any old route it can observe, + // while a pre-reserved port that never became reachable is released. + desiredPublishes: successfulPublishes, + edgeProxy: claimContext.edgeProxy, + }); + } catch (error) { + // The route mutation is already best-effort and the safe fallback is + // to KEEP every claim. Surface the deferred cleanup without turning a + // successfully committed DB edit into an HTTP failure. + console.warn( + `[route-apply] host-port claim convergence deferred (claims retained): ${safeErrorMessage(error)}`, + ); + } + } + }; + + if (claimContext) { + await withHostPortTargetLock(claimContext.target, applyRoutes); + } else { + await applyRoutes(); } } finally { // Only ours to release when we resolved it — a caller-supplied `opts.routing` diff --git a/apps/api/src/lib/routing-domains.ts b/apps/api/src/lib/routing-domains.ts index 65b57df67..438075942 100644 --- a/apps/api/src/lib/routing-domains.ts +++ b/apps/api/src/lib/routing-domains.ts @@ -705,7 +705,7 @@ export async function ensureRouteDomainRecord(opts: { projectId: string; route: PlannedRouteDomain; domainByHostname: Map; -}): Promise { +}): Promise<{ domain: Domain | null; created: boolean }> { const { projectId, route, domainByHostname } = opts; const key = route.hostname.toLowerCase(); @@ -728,7 +728,9 @@ export async function ensureRouteDomainRecord(opts: { // to a hostname with no row at all, and inside the `owner &&` branch this would fall // through to `findOrCreate` and MINT a project-owned row for the very host the claim // exists to protect. - if (await routableWithoutOwnership(route.hostname, projectId, owner)) return null; + if (await routableWithoutOwnership(route.hostname, projectId, owner)) { + return { domain: null, created: false }; + } if (owner && owner.projectId !== projectId) { throw new ConflictError( @@ -769,20 +771,20 @@ export async function ensureRouteDomainRecord(opts: { await repos.domain.update(existing.id, patch); const updated = { ...existing, ...patch } as Domain; domainByHostname.set(key, updated); - return updated; + return { domain: updated, created: false }; } - return existing; + return { domain: existing, created: false }; } if (!route.createIfMissing) { - return null; + return { domain: null, created: false }; } // A custom domain minted at deploy time (no prior add) starts PENDING with a // challenge token so the Verify pipe can run; host-managed routes go active. const isNewCustom = route.domainType === "custom"; - const created = await repos.domain.findOrCreate({ + const result = await repos.domain.findOrCreateWithStatus({ projectId, serviceId: route.serviceId, hostname: route.hostname, @@ -797,8 +799,16 @@ export async function ensureRouteDomainRecord(opts: { verifiedAt: isNewCustom ? null : new Date(), verificationToken: isNewCustom ? generateToken(route.hostname) : undefined, }); - domainByHostname.set(key, created); - return created; + // The ownership read above and the insert are separate statements. If a + // foreign project won that race, findOrCreateWithStatus returns its row; it + // must not be installed in this project's route map or edge configuration. + if (result.domain.projectId !== projectId) { + throw new ConflictError( + `Hostname ${route.hostname} is routed by another project and cannot be claimed here.`, + ); + } + domainByHostname.set(key, result.domain); + return result; } /** diff --git a/apps/api/src/lib/secret-env.ts b/apps/api/src/lib/secret-env.ts index 3565af642..bd237a815 100644 --- a/apps/api/src/lib/secret-env.ts +++ b/apps/api/src/lib/secret-env.ts @@ -142,15 +142,38 @@ export function hasMaskedValue( * shallow copy — the caller's stored object is left untouched. Anything without * an `environment` map passes through unchanged. */ -export function maskServiceEnv | null }>( +export function maskServiceEnv< + T extends { + environment?: Record | null; + environmentTemplates?: Record | null; + advanced?: { environmentTemplateKeys?: string[]; [key: string]: unknown } | null; + }, +>( svc: T | null | undefined, ): T | null | undefined { - if (!svc || !svc.environment) return svc; - return { ...svc, environment: maskEnv(svc.environment) }; + if (!svc) return svc; + if (!svc.environment && !svc.environmentTemplates) return svc; + // `environmentTemplates` is transient parser provenance. Its expressions can + // contain literal defaults, so never serialize it even though the persisted + // raw copy is already protected by blanket environment masking. + const { environmentTemplates: _templates, ...publicService } = svc; + const advanced = svc.advanced ? { ...svc.advanced } : svc.advanced; + if (advanced) delete advanced.environmentTemplateKeys; + return { + ...publicService, + ...(svc.environment ? { environment: maskEnv(svc.environment) } : {}), + ...(advanced !== undefined ? { advanced } : {}), + } as T; } /** Map `maskServiceEnv` over a list, tolerating null/undefined. */ -export function maskServicesEnv | null }>( +export function maskServicesEnv< + T extends { + environment?: Record | null; + environmentTemplates?: Record | null; + advanced?: { environmentTemplateKeys?: string[]; [key: string]: unknown } | null; + }, +>( svcs: T[] | null | undefined, ): T[] { if (!svcs) return []; @@ -166,6 +189,7 @@ interface EnvMetaLike { resolvedValue?: string; expression?: string; required?: boolean; + unresolvedVariables?: string[]; } /** @@ -185,6 +209,9 @@ export function maskEnvironmentMeta( ...(m.source !== undefined && { source: m.source }), ...(m.variable !== undefined && { variable: m.variable }), ...(m.required !== undefined && { required: m.required }), + ...(m.unresolvedVariables !== undefined && { + unresolvedVariables: [...m.unresolvedVariables], + }), ...(m.resolvedValue !== undefined && { resolvedValue: maskValue(m.resolvedValue) }), ...(m.defaultValue !== undefined && { defaultValue: maskValue(m.defaultValue) }), }; @@ -200,7 +227,9 @@ export function maskEnvironmentMeta( export function maskScanService< T extends { environment?: Record | null; + environmentTemplates?: Record | null; environmentMeta?: Record | null; + advanced?: { environmentTemplateKeys?: string[]; [key: string]: unknown } | null; }, >(svc: T): T { // svc is always a concrete service here (mapped from a scan list). diff --git a/apps/api/src/lib/server-target.ts b/apps/api/src/lib/server-target.ts index e1b207c91..8b06a06ec 100644 --- a/apps/api/src/lib/server-target.ts +++ b/apps/api/src/lib/server-target.ts @@ -1,4 +1,5 @@ import { repos, type Project } from "@repo/db"; +import { isLoopbackHost as isCoreLoopbackHost } from "@repo/core"; import { env } from "../config/env"; interface DeploymentSnapshotLike { @@ -24,10 +25,7 @@ async function resolveSnapshotServerHost( snapshot?: DeploymentSnapshotLike | null, ): Promise { if (snapshot?.serverId) { - const server = await repos.server.getInOrganization( - snapshot.serverId, - organizationId, - ); + const server = await repos.server.getInOrganization(snapshot.serverId, organizationId); if (server?.sshHost) return server.sshHost; return null; } @@ -39,10 +37,7 @@ export async function resolveServerHost( organizationId: string, serverId?: string, ): Promise { - return resolveSnapshotServerHost( - organizationId, - serverId ? { serverId } : null, - ); + return resolveSnapshotServerHost(organizationId, serverId ? { serverId } : null); } export async function resolveProjectServerHost(project?: Project): Promise { @@ -81,9 +76,7 @@ export function isIpLiteral(value: string): boolean { * must treat that as "unknown", not a real address. */ export function isLoopbackHost(host: string | null | undefined): boolean { - if (!host) return false; - const h = host.trim().toLowerCase(); - return h === "127.0.0.1" || h === "::1" || h === "localhost" || h === "0.0.0.0" || h.startsWith("127."); + return isCoreLoopbackHost(host); } /** diff --git a/apps/api/src/lib/startup/self-services.ts b/apps/api/src/lib/startup/self-services.ts index c5f989d83..bde5429c4 100644 --- a/apps/api/src/lib/startup/self-services.ts +++ b/apps/api/src/lib/startup/self-services.ts @@ -39,6 +39,14 @@ function publishedPort(c: DockerContainerSummary): number | null { return null; } +/** Every published binding in the durable service-deployment JSON shape. */ +function publishedPorts(c: DockerContainerSummary): Record | null { + const entries = (c.ports ?? []) + .filter((port) => port.publicPort) + .map((port) => [String(port.privatePort), port.publicPort!] as const); + return entries.length > 0 ? Object.fromEntries(entries) : null; +} + /** Compose spec strings for a container's published ports (`3001:3001`). */ export function portSpecs(c: DockerContainerSummary): string[] { const seen = new Set(); @@ -134,6 +142,7 @@ export async function linkSelfAppServices( status: container.state === "running" ? "success" : "failure", imageRef: container.image ?? null, hostPort: publishedPort(container), + hostPorts: publishedPorts(container), ip: container.ip ?? null, }) .catch(() => {}); diff --git a/apps/api/src/lib/upstream-url.test.ts b/apps/api/src/lib/upstream-url.test.ts index b71718b6b..4d0d91754 100644 --- a/apps/api/src/lib/upstream-url.test.ts +++ b/apps/api/src/lib/upstream-url.test.ts @@ -4,6 +4,7 @@ import { resolveLiveUpstreamUrl, resolveRouteStrategy, resolveUpstreamUrl, + usesHostLoopbackUpstream, } from "./upstream-url"; /** A docker-shaped runtime whose live inspect we control. */ @@ -26,14 +27,19 @@ function dockerRuntime(opts: { describe("buildUpstreamUrl", () => { it("dials the loopback host port when the workload publishes one", () => { expect( - buildUpstreamUrl({ strategy: "loopback-port", ip: "172.19.0.2", hostPort: 4000, containerPort: 3001 }), + buildUpstreamUrl({ + strategy: "loopback-port", + ip: "172.19.0.2", + hostPort: 4000, + containerPort: 3001, + }), ).toBe("http://127.0.0.1:4000"); }); it("falls back to the container IP when nothing is published", () => { - expect(buildUpstreamUrl({ strategy: "loopback-port", ip: "172.19.0.2", containerPort: 3001 })).toBe( - "http://172.19.0.2:3001", - ); + expect( + buildUpstreamUrl({ strategy: "loopback-port", ip: "172.19.0.2", containerPort: 3001 }), + ).toBe("http://172.19.0.2:3001"); }); it("returns null when neither a host port nor an ip is known", () => { @@ -42,9 +48,49 @@ describe("buildUpstreamUrl", () => { it("ignores a published host port under the container-ip strategy", () => { expect( - buildUpstreamUrl({ strategy: "container-ip", ip: "172.19.0.2", hostPort: 4000, containerPort: 3001 }), + buildUpstreamUrl({ + strategy: "container-ip", + ip: "172.19.0.2", + hostPort: 4000, + containerPort: 3001, + }), + ).toBe("http://172.19.0.2:3001"); + }); + + it("selects the persisted publish for the requested container port", () => { + expect( + buildUpstreamUrl({ + strategy: "loopback-port", + ip: "172.19.0.2", + hostPort: 4000, + hostPorts: { "3000": 4000, "3001": 4001 }, + containerPort: 3001, + }), + ).toBe("http://127.0.0.1:4001"); + }); + + it("does not borrow the scalar when a persisted map proves this port is unpublished", () => { + expect( + buildUpstreamUrl({ + strategy: "loopback-port", + ip: "172.19.0.2", + hostPort: 4000, + hostPorts: { "3000": 4000 }, + containerPort: 3001, + }), ).toBe("http://172.19.0.2:3001"); }); + + it("keeps scalar-only migrated rows backwards compatible", () => { + expect( + buildUpstreamUrl({ + strategy: "loopback-port", + hostPort: 4000, + hostPorts: { __legacy__: 4000 }, + containerPort: 3001, + }), + ).toBe("http://127.0.0.1:4000"); + }); }); describe("resolveRouteStrategy", () => { @@ -58,6 +104,23 @@ describe("resolveRouteStrategy", () => { }); }); +describe("usesHostLoopbackUpstream", () => { + const topologyRuntime = (name: string, containerIp: boolean) => ({ + name, + supports: (capability: string) => capability === "containerIp" && containerIp, + }); + + it("accounts for runtime topology instead of trusting only the stored strategy", () => { + expect(usesHostLoopbackUpstream("container-ip", topologyRuntime("docker", true))).toBe(false); + expect(usesHostLoopbackUpstream("loopback-port", topologyRuntime("docker", true))).toBe(true); + expect(usesHostLoopbackUpstream("container-ip", topologyRuntime("bare", true))).toBe(true); + expect(usesHostLoopbackUpstream("container-ip", topologyRuntime("host-only", false))).toBe( + true, + ); + expect(usesHostLoopbackUpstream("loopback-port", topologyRuntime("cloud", true))).toBe(false); + }); +}); + describe("resolveUpstreamUrl", () => { it("uses the passed host port without touching the runtime", async () => { const runtime = dockerRuntime({}); @@ -83,6 +146,27 @@ describe("resolveUpstreamUrl", () => { }), ).resolves.toBe("http://172.19.0.2:3001"); }); + + it("uses the reserved host port when container IP is unsupported", async () => { + const getContainerIp = vi.fn(async () => { + throw new Error("container IP is unavailable"); + }); + + await expect( + resolveUpstreamUrl({ + strategy: "container-ip", + runtime: { + name: "host-only", + supports: () => false, + getContainerIp, + }, + containerId: "c1", + containerPort: 3001, + hostPort: 20_041, + }), + ).resolves.toBe("http://127.0.0.1:20041"); + expect(getContainerIp).not.toHaveBeenCalled(); + }); }); describe("resolveLiveUpstreamUrl", () => { @@ -145,6 +229,48 @@ describe("resolveLiveUpstreamUrl", () => { ).resolves.toBe("http://127.0.0.1:4000"); }); + it("route writers refuse a cached host port when live inspection is unavailable", async () => { + await expect( + resolveLiveUpstreamUrl({ + strategy: "loopback-port", + runtime: dockerRuntime({ ip: null }), + containerId: "c1", + containerPort: 3001, + stored: { ip: "172.19.0.2", hostPort: 4000 }, + requireLiveObservation: true, + }), + ).resolves.toBeNull(); + }); + + it("route writers refuse a stopped container's retained HostConfig publish", async () => { + await expect( + resolveLiveUpstreamUrl({ + strategy: "loopback-port", + runtime: dockerRuntime({ info: { status: "stopped", hostPort: 4000 } }), + containerId: "c1", + containerPort: 3001, + stored: { hostPort: 4000 }, + requireLiveObservation: true, + }), + ).resolves.toBeNull(); + }); + + it("uses the requested port's durable binding when live inspection is unavailable", async () => { + await expect( + resolveLiveUpstreamUrl({ + strategy: "loopback-port", + runtime: dockerRuntime({ ip: null }), + containerId: "c1", + containerPort: 3001, + stored: { + ip: "172.19.0.2", + hostPort: 4000, + hostPorts: { "3000": 4000, "3001": 4001 }, + }, + }), + ).resolves.toBe("http://127.0.0.1:4001"); + }); + it("keeps the last-known ip when the container cannot be inspected and nothing was published", async () => { await expect( resolveLiveUpstreamUrl({ @@ -248,13 +374,16 @@ describe("resolveLiveUpstreamUrl — multi-port containers", () => { const multiPortRuntime = (map: Record, scalar?: number) => ({ name: "docker" as const, supports: (cap: string) => cap === "containerIp" || cap === "containerInfo", - getContainerInfo: vi.fn(async () => ({ - containerId: "c1", - status: "running", - ip: "172.19.0.9", - hostPort: scalar, - hostPortByContainerPort: map, - }) as never), + getContainerInfo: vi.fn( + async () => + ({ + containerId: "c1", + status: "running", + ip: "172.19.0.9", + hostPort: scalar, + hostPortByContainerPort: map, + }) as never, + ), getContainerIp: vi.fn(async () => "172.19.0.9"), }); @@ -322,12 +451,15 @@ describe("resolveLiveUpstreamUrl — multi-port containers", () => { const legacy = { name: "docker" as const, supports: (cap: string) => cap === "containerIp" || cap === "containerInfo", - getContainerInfo: vi.fn(async () => ({ - containerId: "c1", - status: "running", - ip: "172.19.0.9", - hostPort: 4000, - }) as never), + getContainerInfo: vi.fn( + async () => + ({ + containerId: "c1", + status: "running", + ip: "172.19.0.9", + hostPort: 4000, + }) as never, + ), getContainerIp: vi.fn(async () => "172.19.0.9"), }; expect( diff --git a/apps/api/src/lib/upstream-url.ts b/apps/api/src/lib/upstream-url.ts index b52f9a75a..57644c891 100644 --- a/apps/api/src/lib/upstream-url.ts +++ b/apps/api/src/lib/upstream-url.ts @@ -19,11 +19,35 @@ export type RouteStrategy = "loopback-port" | "container-ip"; /** Concrete route strategies, plus "auto" (resolves to a concrete one). */ export type RouteStrategySetting = RouteStrategy | "auto"; -type UpstreamRuntime = Pick; +type UpstreamRuntime = Pick; + +/** Runtime surface needed to decide which network namespace an edge dials. */ +type RouteTopologyRuntime = Pick; + +/** + * Whether a self-hosted route can dial through the target host's loopback + * namespace. + * + * `routeStrategy` is an operator preference, not proof that a bridge address is + * available. Bare workloads always bind the host directly, and a runtime which + * cannot expose a container IP must use a published host port even when the + * stored preference is `container-ip`. Every pre-bind claim/lock decision uses + * this predicate so the allocator and the eventual proxy target cannot disagree. + */ +export function usesHostLoopbackUpstream( + strategy: RouteStrategy, + runtime: RouteTopologyRuntime, +): boolean { + return ( + runtime.name !== "cloud" && + (strategy === "loopback-port" || runtime.name === "bare" || !runtime.supports("containerIp")) + ); +} /** Runtime surface needed to read a container's CURRENT publishing. */ -type LiveUpstreamRuntime = UpstreamRuntime & - Pick & { getContainerInfo?: RuntimeAdapter["getContainerInfo"] }; +type LiveUpstreamRuntime = UpstreamRuntime & { + getContainerInfo?: RuntimeAdapter["getContainerInfo"]; +}; /** * Last-known upstream for a container, as persisted on `service_deployment`. @@ -32,6 +56,44 @@ type LiveUpstreamRuntime = UpstreamRuntime & export interface StoredUpstream { ip?: string | null; hostPort?: number | null; + /** Durable CONTAINER-port → host-port bindings from `service_deployment`. + * When at least one concrete entry exists, the map is authoritative: a missing + * key means that container port is not published and must not borrow the scalar + * (which may belong to a sibling port). */ + hostPorts?: Record | null; +} + +/** Resolve one stored publish without ever applying a sibling port's scalar. + * + * `__legacy__` is the migration marker used for old scalar-only rows. It is not a + * per-container-port answer, so a map containing only that marker deliberately + * falls back to `hostPort` for backwards compatibility. */ +export function storedHostPortFor( + stored: Pick | undefined, + containerPort: number, +): number | undefined { + const entries = Object.entries(stored?.hostPorts ?? {}).filter(([container, host]) => { + const parsed = Number(container); + return ( + Number.isInteger(parsed) && + parsed > 0 && + parsed <= 65_535 && + Number.isInteger(host) && + host > 0 + ); + }); + if (entries.length === 0) return stored?.hostPort ?? undefined; + + const exact = entries.find(([container]) => Number(container) === containerPort)?.[1]; + if (exact !== undefined) return exact; + + // Some project-level routes are authored with the published side of a compose + // mapping. If that exact number belongs to this container, dialing it is safe. + if (entries.some(([, host]) => host === containerPort)) return containerPort; + + // A concrete map exists and this port is absent: it is not published. Returning + // the scalar here is the cross-port misroute this map exists to prevent. + return undefined; } export interface ResolveUpstreamArgs { @@ -48,8 +110,8 @@ export interface ResolveUpstreamArgs { * The PURE upstream-URL core — the single source of truth for the proxy_pass * target string. loopback-port with a host port → `127.0.0.1:`; * otherwise `:` (null when the ip is unknown). Callers that - * already hold the ip + hostPort (routing-API sites reading persisted - * `service_deployment.{ip,hostPort}`) use this directly; the runtime-aware + * already hold the ip + host-port bindings (routing-API sites reading persisted + * `service_deployment.{ip,hostPort,hostPorts}`) use this directly; the runtime-aware * `resolveUpstreamUrl` resolves the ip first, then delegates here — so every * route-registration site funnels through ONE function, no fork. */ @@ -57,25 +119,37 @@ export function buildUpstreamUrl(args: { strategy: RouteStrategy; ip?: string | null; hostPort?: number | null; + hostPorts?: Record | null; containerPort: number; }): string | null { - if (args.strategy === "loopback-port" && args.hostPort) { - return `http://127.0.0.1:${args.hostPort}`; + const hostPort = storedHostPortFor(args, args.containerPort); + if (args.strategy === "loopback-port" && hostPort) { + return `http://127.0.0.1:${hostPort}`; } return args.ip ? `http://${args.ip}:${args.containerPort}` : null; } export async function resolveUpstreamUrl(args: ResolveUpstreamArgs): Promise { const { strategy, runtime, containerId, containerPort, hostPort } = args; + const usesHostLoopback = usesHostLoopbackUpstream(strategy, runtime); - // Loopback-port: dial the pinned host port when there is one. (Bare has no - // hostPort — it falls through below, where bare's getContainerIp returns - // 127.0.0.1, i.e. the same 127.0.0.1:.) - if (strategy === "loopback-port" && hostPort) { + // Dial the reserved publish whenever this topology needs host loopback. That + // includes a runtime without container-IP support even if the stored strategy + // says `container-ip`; ignoring its hostPort would dial the unrelated + // 127.0.0.1: instead. Bare has no separate publish and falls + // through to its own 127.0.0.1: identity below. + if (usesHostLoopback && hostPort) { return `http://127.0.0.1:${hostPort}`; } - const ip = runtime.supports("containerIp") ? await runtime.getContainerIp(containerId) : "127.0.0.1"; - return buildUpstreamUrl({ strategy, ip, hostPort, containerPort }); + const ip = runtime.supports("containerIp") + ? await runtime.getContainerIp(containerId) + : "127.0.0.1"; + return buildUpstreamUrl({ + strategy: usesHostLoopback ? "loopback-port" : "container-ip", + ip, + hostPort, + containerPort, + }); } /** @@ -94,13 +168,18 @@ async function readLiveHostPort( containerId: string, strategy: RouteStrategy, containerPort: number, -): Promise<{ known: boolean; hostPort?: number }> { - // container-ip never dials a host port, and a bare workload owns - // `127.0.0.1:` outright — neither has a publish to read. - if (strategy !== "loopback-port" || runtime.name === "bare") return { known: true }; + requireRunning: boolean, +): Promise<{ known: boolean; running?: boolean; hostPort?: number }> { + const usesHostLoopback = usesHostLoopbackUpstream(strategy, runtime); + // Bare owns `127.0.0.1:` outright and has no separate publish to read. + if (runtime.name === "bare") return { known: true, running: true }; + if (!usesHostLoopback && !requireRunning) return { known: true }; if (!runtime.getContainerInfo || !runtime.supports("containerInfo")) return { known: false }; try { const info = await runtime.getContainerInfo(containerId); + const running = info.status === "running"; + if (requireRunning && !running) return { known: true, running: false }; + if (!usesHostLoopback) return { known: true, running }; // A `missing` container answers too: it is gone, so it publishes nothing and // a stored port must not resurrect it. // @@ -111,7 +190,7 @@ async function readLiveHostPort( const byPort = info.hostPortByContainerPort; if (byPort) { const published = byPort[containerPort]; - if (published) return { known: true, hostPort: published }; + if (published) return { known: true, running, hostPort: published }; // `containerPort` is what the CALLER believes it is, and a project-level route // carries whichever side of the mapping the operator typed (project-route's // primary-container fallback passes it straight through). When the number is a @@ -120,7 +199,7 @@ async function readLiveHostPort( // listens on. Keys win over values so a container mapping 3000→8080 alongside // 8080→34100 still resolves 8080 to its own publish. if (Object.values(byPort).includes(containerPort)) { - return { known: true, hostPort: containerPort }; + return { known: true, running, hostPort: containerPort }; } // Neither side matches: the port genuinely isn't published. "known, none" is // what makes the caller fall through to the container IP instead of borrowing a @@ -128,9 +207,9 @@ async function readLiveHostPort( // lists only PUBLISHED ports, so a lone entry says nothing about whether the // container also listens on this one unexposed (minio publishing 9001 and not // 9000 is exactly that shape). - return { known: true }; + return { known: true, running }; } - return { known: true, hostPort: info.hostPort }; + return { known: true, running, hostPort: info.hostPort }; } catch { return { known: false }; } @@ -153,10 +232,32 @@ export async function resolveLiveUpstreamUrl(args: { containerId: string; containerPort: number; stored?: StoredUpstream; + /** + * Route writers set this so a failed inspect, missing/stopped container, or + * stale imported cache can never mint a new vhost to a recycled address. The + * default remains fail-soft for read-only callers that only need last-known + * state. + */ + requireLiveObservation?: boolean; }): Promise { - const { strategy, runtime, containerId, containerPort, stored } = args; - const live = await readLiveHostPort(runtime, containerId, strategy, containerPort); - const hostPort = live.known ? live.hostPort : (stored?.hostPort ?? undefined); + const { + strategy, + runtime, + containerId, + containerPort, + stored, + requireLiveObservation = false, + } = args; + const usesHostLoopback = usesHostLoopbackUpstream(strategy, runtime); + const live = await readLiveHostPort( + runtime, + containerId, + strategy, + containerPort, + requireLiveObservation, + ); + if (requireLiveObservation && (!live.known || live.running === false)) return null; + const hostPort = live.known ? live.hostPort : storedHostPortFor(stored, containerPort); const url = await resolveUpstreamUrl({ strategy, runtime, @@ -164,7 +265,13 @@ export async function resolveLiveUpstreamUrl(args: { containerPort, hostPort, }).catch(() => null); - return url ?? buildUpstreamUrl({ strategy, ip: stored?.ip, hostPort, containerPort }); + if (url || requireLiveObservation) return url; + return buildUpstreamUrl({ + strategy: usesHostLoopback ? "loopback-port" : "container-ip", + ip: stored?.ip, + hostPort, + containerPort, + }); } /** @@ -172,8 +279,6 @@ export async function resolveLiveUpstreamUrl(args: { * any unknown/legacy value) → "loopback-port", the safe default for bare + docker * self-host. "container-ip" is honored only when explicitly chosen. */ -export function resolveRouteStrategy( - setting: string | null | undefined, -): RouteStrategy { +export function resolveRouteStrategy(setting: string | null | undefined): RouteStrategy { return setting === "container-ip" ? "container-ip" : "loopback-port"; } diff --git a/apps/api/src/modules/deployments/build-execution-plan.test.ts b/apps/api/src/modules/deployments/build-execution-plan.test.ts index ef38d046a..c37701af5 100644 --- a/apps/api/src/modules/deployments/build-execution-plan.test.ts +++ b/apps/api/src/modules/deployments/build-execution-plan.test.ts @@ -134,6 +134,39 @@ describe("resolveBuildRuntimeModes (pre-resolve flip, as data)", () => { ).toEqual({ buildRuntimeMode: undefined, serveRuntimeMode: undefined }); } }); + + it("prebuilt single-app image → Docker locally/remotely, Cloud unchanged", () => { + for (const [baseTarget, effectiveTarget] of [ + ["desktop", "local"], + ["desktop", "server"], + ["selfhosted", "local"], + ["selfhosted", "server"], + ] as const) { + expect( + resolveBuildRuntimeModes({ + workload: "web", + serverId: effectiveTarget === "server" ? "srv_1" : null, + baseTarget, + effectiveTarget, + willRunServices: false, + hasPrebuiltImage: true, + }), + ).toEqual({ buildRuntimeMode: "docker", serveRuntimeMode: "docker" }); + } + + for (const baseTarget of ["cloud", "selfhosted"] as const) { + expect( + resolveBuildRuntimeModes({ + workload: "web", + serverId: null, + baseTarget, + effectiveTarget: "cloud", + willRunServices: false, + hasPrebuiltImage: true, + }), + ).toEqual({ buildRuntimeMode: undefined, serveRuntimeMode: undefined }); + } + }); }); describe("resolveDeployRouting (post-resolve, keyed off runtime.name)", () => { @@ -163,13 +196,21 @@ describe("resolveDeployRouting (post-resolve, keyed off runtime.name)", () => { it("static + docker runtime → sandbox build, file-serve, doc-root already extracted", () => { expect( resolveDeployRouting({ workload: "static", runtimeName: "docker", outputDirectory: "dist" }), - ).toEqual({ buildMode: "static-sandbox", deployMode: "static-file-serve", staticServeOutputDir: "" }); + ).toEqual({ + buildMode: "static-sandbox", + deployMode: "static-file-serve", + staticServeOutputDir: "", + }); }); it("static + bare runtime → bare build, file-serve from the output directory", () => { expect( resolveDeployRouting({ workload: "static", runtimeName: "bare", outputDirectory: "dist" }), - ).toEqual({ buildMode: "static-bare", deployMode: "static-file-serve", staticServeOutputDir: "dist" }); + ).toEqual({ + buildMode: "static-bare", + deployMode: "static-file-serve", + staticServeOutputDir: "dist", + }); }); }); diff --git a/apps/api/src/modules/deployments/build-execution-plan.ts b/apps/api/src/modules/deployments/build-execution-plan.ts index 9fbe1e8a4..e9ba38cbc 100644 --- a/apps/api/src/modules/deployments/build-execution-plan.ts +++ b/apps/api/src/modules/deployments/build-execution-plan.ts @@ -54,7 +54,13 @@ export function resolveBuildRuntimeModes(input: { baseTarget: "desktop" | "selfhosted" | "cloud"; effectiveTarget: "local" | "server" | "cloud"; willRunServices: boolean; + /** A single-app OCI image is already built, but still needs a container + * runtime to pull and run it. Bare mode cannot consume that artifact. */ + hasPrebuiltImage?: boolean; }): BuildRuntimeModes { + if (input.hasPrebuiltImage && input.effectiveTarget !== "cloud") { + return { buildRuntimeMode: "docker", serveRuntimeMode: "docker" }; + } if (input.willRunServices || input.workload === "worker") { return { buildRuntimeMode: "docker", serveRuntimeMode: "docker" }; } diff --git a/apps/api/src/modules/deployments/build-pipeline.prebuilt-image.test.ts b/apps/api/src/modules/deployments/build-pipeline.prebuilt-image.test.ts new file mode 100644 index 000000000..b48e6c404 --- /dev/null +++ b/apps/api/src/modules/deployments/build-pipeline.prebuilt-image.test.ts @@ -0,0 +1,693 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findBuildSessionByDeploymentId: vi.fn(), + updateDeploymentStatus: vi.fn(), + updateBuildSession: vi.fn(), + findDeploymentById: vi.fn(), + prepareImage: vi.fn(), + build: vi.fn(), + deploy: vi.fn(), + destroy: vi.fn(), + stop: vi.fn(), + start: vi.fn(), + getContainerInfo: vi.fn(), + runDeployPipeline: vi.fn(), + resolveBuildGitToken: vi.fn(), + openDeployRelay: vi.fn(), + onFailure: vi.fn(), + onCancelled: vi.fn(), + onSuccess: vi.fn(), + reportPipelineError: vi.fn(), + setDeploymentStatus: vi.fn(), + onDeploymentReady: vi.fn(), + appendLog: vi.fn(), + ensureRoutingReady: vi.fn(), + prepareTargetPinnedHostPorts: vi.fn(), + allocateAndReservePinnedHostPort: vi.fn(), + reserveTargetPinnedHostPort: vi.fn(), + convergeTargetHostPortClaims: vi.fn(), + convergeTargetHostPortClaimsUnlocked: vi.fn(), + withHostPortTargetLock: vi.fn((_target, fn: () => unknown) => fn()), +})); + +vi.mock("@repo/db", () => ({ + schema: {}, + repos: { + deployment: { + findBuildSessionByDeploymentId: (...args: unknown[]) => + mocks.findBuildSessionByDeploymentId(...args), + updateStatus: (...args: unknown[]) => mocks.updateDeploymentStatus(...args), + updateBuildSession: (...args: unknown[]) => mocks.updateBuildSession(...args), + findById: (...args: unknown[]) => mocks.findDeploymentById(...args), + }, + service: { + listByDeployment: vi.fn(async () => []), + syncFromCompose: vi.fn(async () => undefined), + }, + serviceDeployment: { listByDeployment: vi.fn(async () => []) }, + domain: { + listByProject: vi.fn(async () => []), + remove: vi.fn(async () => undefined), + }, + project: { update: vi.fn(async () => undefined) }, + }, +})); + +vi.mock("@repo/adapters", () => { + class BuildLogger { + constructor(private readonly callback?: (entry: unknown) => void) {} + + log(message: string, level = "info") { + this.callback?.({ timestamp: new Date().toISOString(), message, level }); + } + + step(phase: string, status: string, message: string) { + this.callback?.({ + timestamp: new Date().toISOString(), + phase, + status, + message, + level: "info", + }); + } + } + + return { + BuildLogger, + BareRuntime: class BareRuntime {}, + DockerRuntime: class DockerRuntime {}, + CloudRuntime: class CloudRuntime {}, + STATIC_RELEASE_BASE: "/opt/openship/static/releases", + sharedMountExecutor: vi.fn(async () => null), + resolveStaticOutputPath: (id: string) => id, + ensurePortAvailable: vi.fn(async () => undefined), + allocateHostPort: vi.fn(async () => ({ port: 30_000, scanned: true })), + pickHostPort: vi.fn(() => 30_000), + edgeProxyFor: vi.fn(() => ({ listLoopbackUpstreamPortsStrict: vi.fn() })), + isHostChannelUnavailableError: vi.fn(() => false), + runDeployPipeline: (...args: unknown[]) => mocks.runDeployPipeline(...args), + isMultiServiceRuntime: vi.fn(() => false), + ensureEdge: vi.fn(async (_executor, install, options) => ({ + migrated: false, + value: await install(options.promptUser), + })), + }; +}); + +vi.mock("../../lib/controller-helpers", () => ({ platform: vi.fn() })); + +vi.mock("../../lib/deployment-runtime", () => ({ + disposeRuntime: vi.fn(), + resolveDeploymentRuntime: vi.fn(), + resolveDeploymentPlatform: vi.fn(), + resolveEffectiveTarget: vi.fn(() => "local"), + hostChannelDeployNotice: vi.fn(() => null), +})); + +vi.mock("../domains/project-route.service", () => ({ + resolveProjectRouteState: vi.fn(async () => ({ + publicEndpoints: [], + primarySlug: "release-app", + })), +})); + +vi.mock("../github/clone-auth", () => ({ + cloneOnServerAvailable: vi.fn(() => ({ available: false })), + resolveBuildGitToken: (...args: unknown[]) => mocks.resolveBuildGitToken(...args), +})); + +vi.mock("../../lib/git-forwarding", () => ({ + openDeployRelay: (...args: unknown[]) => mocks.openDeployRelay(...args), +})); + +vi.mock("../../lib/org-actor", () => ({ resolveOrgOwner: vi.fn(async () => null) })); +vi.mock("../settings/settings.service", () => ({ + resolveStrategy: vi.fn(async () => "server"), +})); +vi.mock("../../lib/encryption", () => ({ + decryptEnvMap: (env: Record) => env, +})); +vi.mock("../../lib/resources", () => ({ + resolveRuntimeResources: vi.fn(() => ({})), + resolveBuildResources: vi.fn(() => ({})), +})); +vi.mock("../../lib/request-context", () => ({ buildBackgroundContext: vi.fn(() => ({})) })); + +vi.mock("./session-manager", () => ({ + createSession: vi.fn(), + appendLog: (...args: unknown[]) => mocks.appendLog(...args), + updateStatus: vi.fn(), + promptUser: vi.fn(), + endSession: vi.fn(), +})); + +vi.mock("./service-checks", () => ({ + preCreateServiceDeployments: vi.fn(async () => new Map()), + emitServiceCheckRun: vi.fn(async () => undefined), + emitInitialServiceChecks: vi.fn(async () => undefined), + rollupDeploymentStatus: vi.fn(() => "ready"), +})); + +vi.mock("./compose", () => ({ + executeComposePipeline: vi.fn(), + resolveProjectServicePreflightServices: vi.fn(async () => []), + shouldUseProjectServicePipeline: vi.fn(async () => false), +})); + +vi.mock("../backups/triggers/pre-deploy", () => ({ + firePreDeployBackups: vi.fn(async () => ({ enqueued: 0, failed: 0 })), +})); + +vi.mock("./deployment-lifecycle", () => ({ + onFailure: (...args: unknown[]) => mocks.onFailure(...args), + onSuccess: (...args: unknown[]) => mocks.onSuccess(...args), + onCancelled: (...args: unknown[]) => mocks.onCancelled(...args), + reportPipelineError: (...args: unknown[]) => mocks.reportPipelineError(...args), + setDeploymentStatus: (...args: unknown[]) => mocks.setDeploymentStatus(...args), + routeIssuesWarning: vi.fn(() => "routing warning"), +})); + +vi.mock("./rollback", () => ({ + onDeploymentReady: (...args: unknown[]) => mocks.onDeploymentReady(...args), +})); + +vi.mock("../../lib/routing-domains", () => ({ + auditRoutedDomainTls: vi.fn(async () => []), + buildProjectRouteDomains: vi.fn(() => []), + createTrackedSslProvider: vi.fn((ssl) => ssl), + ensureRouteDomainRecord: vi.fn(), + toRoutedDomainInputs: vi.fn(() => []), + withEnsuredDomainRecord: vi.fn((route) => route), +})); + +vi.mock("../../lib/openship-manifest-sync", () => ({ + syncProjectToServerManifest: vi.fn(async () => undefined), +})); +vi.mock("./attach-linked-networks", () => ({ attachLinkedNetworks: vi.fn(async () => undefined) })); +vi.mock("./port-audit.service", () => ({ auditPorts: vi.fn(async () => []) })); +vi.mock("./stability-audit.service", () => ({ verifyDeployedContainers: vi.fn(async () => []) })); +vi.mock("./readiness-gate", () => ({ + resolveReadinessGate: vi.fn(() => ({ active: false })), + runReadinessGate: vi.fn(), +})); +vi.mock("./output-audit.service", () => ({ + auditStaticOutput: vi.fn(async () => []), + describeOutputFinding: vi.fn(() => ""), + outputFindingIsBroken: vi.fn(() => false), + staticOutputTargets: vi.fn(() => []), +})); +vi.mock("../../lib/managed-edge-proxy", () => ({ + syncManagedEdgeRoutes: vi.fn(async () => ({ failures: [] })), + edgeUnsyncedWarning: vi.fn(() => ""), +})); +vi.mock("../../lib/project-routing-fields", () => ({ + compileProjectRoutingFields: vi.fn(() => ({})), +})); +vi.mock("../../lib/edge-challenge", () => ({ ensureEdgeChallengeReady: vi.fn() })); +vi.mock("../../lib/edge-vhost-repair", () => ({ repairEdgeVhosts: vi.fn() })); +vi.mock("../../lib/edge-reconcile", () => ({ + ensureRoutingReady: (...args: unknown[]) => mocks.ensureRoutingReady(...args), +})); +vi.mock("../../lib/acme-config", () => ({ resolveAcmeProviderOptions: vi.fn(() => ({})) })); +vi.mock("../../lib/ssh-manager", () => ({ sshManager: {} })); +vi.mock("./pinned-host-ports", () => ({ + listTargetPinnedHostPorts: vi.fn(async () => []), + prepareTargetPinnedHostPorts: (...args: unknown[]) => mocks.prepareTargetPinnedHostPorts(...args), + allocateAndReservePinnedHostPort: (...args: unknown[]) => + mocks.allocateAndReservePinnedHostPort(...args), + releaseNewPinnedHostPortClaims: vi.fn(async () => 0), + findOwnedPinnedHostPort: vi.fn(() => undefined), + reserveTargetPinnedHostPort: (...args: unknown[]) => mocks.reserveTargetPinnedHostPort(...args), + convergeTargetHostPortClaims: (...args: unknown[]) => mocks.convergeTargetHostPortClaims(...args), + convergeTargetHostPortClaimsUnlocked: (...args: unknown[]) => + mocks.convergeTargetHostPortClaimsUnlocked(...args), + pinnedHostPortsToAvoid: vi.fn(() => new Set()), + ownsReusablePinnedHostPort: vi.fn(() => false), + withHostPortTargetLock: (target: unknown, fn: () => unknown) => + mocks.withHostPortTargetLock(target, fn), +})); + +function allocatePinnedHostPort(input: { + allocate: (options: { preferred?: number }) => Promise<{ port: number; scanned: boolean }>; + cachedPreferred?: number; + owner: { projectId: string; serviceId: string | null; containerPort: number | null }; +}) { + return input.allocate({ preferred: input.cachedPreferred }).then((allocation) => ({ + ...allocation, + preferred: input.cachedPreferred, + claim: { + id: "hpc_test", + targetKey: "local", + ...input.owner, + port: allocation.port, + createdAt: new Date(0), + updatedAt: new Date(0), + }, + })); +} + +import { platform } from "../../lib/controller-helpers"; +import { resolveDeploymentPlatform } from "../../lib/deployment-runtime"; +import { kickoffBuild } from "./build-pipeline"; + +const SOURCE_IMAGE = "ghcr.io/acme/release-app:v1.2.3"; +const RESOLVED_IMAGE = "ghcr.io/acme/release-app@sha256:abc123"; + +function runtime() { + return { + name: "docker", + capabilities: new Set(["prebuiltImage", "deploy", "containerIp"]), + supports: (capability: string) => + capability === "prebuiltImage" || capability === "deploy" || capability === "containerIp", + prepareImage: (...args: unknown[]) => mocks.prepareImage(...args), + build: (...args: unknown[]) => mocks.build(...args), + deploy: (...args: unknown[]) => mocks.deploy(...args), + destroy: (...args: unknown[]) => mocks.destroy(...args), + stop: (...args: unknown[]) => mocks.stop(...args), + start: (...args: unknown[]) => mocks.start(...args), + getContainerInfo: (...args: unknown[]) => mocks.getContainerInfo(...args), + getContainerIp: async () => "172.18.0.2", + }; +} + +let resolvedRuntime: ReturnType; + +function project(overrides: Record = {}) { + return { + id: "project-1", + organizationId: "org-1", + name: "Release app", + slug: "release-app", + routeStrategy: "container-ip", + rollbackStrategy: "snapshot", + activeDeploymentId: null, + ...overrides, + } as never; +} + +function snapshot() { + return { + organizationId: "org-1", + repoUrl: "", + branch: "main", + framework: "docker", + buildImage: "node:22", + runtimeImage: "node:22-alpine", + packageManager: "npm", + installCommand: "", + buildCommand: "", + outputDirectory: "", + productionPaths: [], + volumes: [], + rootDirectory: ".", + port: 8080, + startCommand: "", + resources: null, + buildResources: null, + hasServer: true, + hasBuild: false, + source: "image", + build: "prebuilt", + workload: "web", + runtimeMode: "docker", + serviceDeploymentMode: "single", + releaseVersion: "1.2.3", + releaseTag: "v1.2.3", + releaseImageRef: SOURCE_IMAGE, + }; +} + +function deployment(overrides: Record = {}) { + return { + id: "deployment-1", + projectId: "project-1", + organizationId: "org-1", + environment: "production", + branch: "main", + commitSha: null, + trigger: "update", + status: "queued", + envVars: { API_TOKEN: "secret" }, + meta: snapshot(), + ...overrides, + } as never; +} + +async function run(dep = deployment(), projectOverrides: Record = {}) { + mocks.findDeploymentById.mockResolvedValue(dep); + const sessionId = await kickoffBuild(project(projectOverrides), dep); + expect(sessionId).toBe("build-session-1"); + return dep; +} + +describe("single-app prebuilt release-image pipeline", () => { + beforeEach(() => { + vi.clearAllMocks(); + const adapter = runtime(); + resolvedRuntime = adapter; + + mocks.findBuildSessionByDeploymentId.mockResolvedValue({ id: "build-session-1" }); + mocks.updateDeploymentStatus.mockResolvedValue(undefined); + mocks.updateBuildSession.mockResolvedValue(undefined); + mocks.setDeploymentStatus.mockResolvedValue(undefined); + mocks.getContainerInfo.mockResolvedValue({ ipAddress: "172.18.0.2" }); + mocks.destroy.mockResolvedValue(undefined); + mocks.prepareImage.mockResolvedValue({ + sessionId: "build-session-1", + status: "deploying", + imageRef: RESOLVED_IMAGE, + durationMs: 12, + artifactOwned: false, + }); + mocks.deploy.mockResolvedValue({ + status: "success", + containerId: "container-1", + url: "http://172.18.0.2:8080", + }); + mocks.runDeployPipeline.mockImplementation(async (env, input) => { + const result = await env.activate(input.config, () => undefined); + return { + status: "success", + containerId: result.containerId, + url: result.url, + }; + }); + mocks.onFailure.mockImplementation(async (ctx) => { + if (ctx.provisioned.imageRef) await ctx.runtime.destroy(ctx.provisioned.imageRef); + }); + mocks.onCancelled.mockImplementation(async (ctx) => { + if (ctx.provisioned.imageRef) await ctx.runtime.destroy(ctx.provisioned.imageRef); + }); + mocks.onSuccess.mockResolvedValue(undefined); + mocks.reportPipelineError.mockResolvedValue(undefined); + mocks.onDeploymentReady.mockResolvedValue(undefined); + mocks.ensureRoutingReady.mockResolvedValue({ edgeDown: false }); + mocks.prepareTargetPinnedHostPorts.mockResolvedValue([]); + mocks.allocateAndReservePinnedHostPort.mockImplementation(allocatePinnedHostPort); + mocks.reserveTargetPinnedHostPort.mockImplementation(async (_target, claim) => claim); + mocks.convergeTargetHostPortClaims.mockResolvedValue({ released: 0, retained: [] }); + mocks.convergeTargetHostPortClaimsUnlocked.mockResolvedValue({ released: 0, retained: [] }); + + const executor = { + exec: vi.fn(async () => ""), + readFile: vi.fn(async () => ""), + }; + const system = { ensureFeature: vi.fn(async () => undefined) }; + + vi.mocked(platform).mockReturnValue({ + target: "selfhosted", + runtime: adapter, + routing: null, + ssl: null, + system, + executor, + localHost: true, + } as never); + vi.mocked(resolveDeploymentPlatform).mockResolvedValue({ + platform: { + target: "selfhosted", + runtime: adapter, + routing: null, + ssl: null, + system, + executor, + localHost: true, + }, + effectiveTarget: "local", + serverId: null, + hostPortTarget: { targetKey: "local", legacyTargetKeys: [], stable: true }, + runtimeMode: "docker", + usesManagedRouting: false, + } as never); + }); + + it("pulls the frozen image, skips every source-build path, deploys it as prebuilt, and freezes the digest", async () => { + await run(); + await vi.waitFor(() => expect(mocks.onSuccess).toHaveBeenCalledTimes(1)); + + expect(mocks.prepareImage).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "build-session-1", + projectId: "project-1", + slug: "release-app", + imageRef: SOURCE_IMAGE, + envVars: { API_TOKEN: "secret", PORT: "8080" }, + forcePull: true, + }), + expect.anything(), + ); + expect(mocks.build).not.toHaveBeenCalled(); + expect(mocks.resolveBuildGitToken).not.toHaveBeenCalled(); + expect(mocks.openDeployRelay).not.toHaveBeenCalled(); + + expect(mocks.deploy).toHaveBeenCalledWith( + expect.objectContaining({ + imageRef: RESOLVED_IMAGE, + prebuiltImage: true, + startCommand: "", + }), + expect.any(Function), + ); + expect(mocks.onSuccess).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + metaPatch: expect.objectContaining({ releaseImageRef: RESOLVED_IMAGE }), + }), + ); + }); + + it("inventories migrated edge routes before reserving a loopback host port", async () => { + mocks.runDeployPipeline.mockImplementationOnce(async (env, input) => { + await env.preflight(input.config, async () => "migrate"); + const result = await env.activate(input.config, () => undefined); + return { + status: "success", + containerId: result.containerId, + url: result.url, + }; + }); + + await run(deployment(), { routeStrategy: "loopback-port" }); + await vi.waitFor(() => expect(mocks.onSuccess).toHaveBeenCalledTimes(1)); + + expect(mocks.ensureRoutingReady).toHaveBeenCalledTimes(1); + expect(mocks.prepareTargetPinnedHostPorts).toHaveBeenCalledTimes(1); + expect(mocks.allocateAndReservePinnedHostPort).toHaveBeenCalledTimes(1); + expect(mocks.ensureRoutingReady.mock.invocationCallOrder[0]).toBeLessThan( + mocks.prepareTargetPinnedHostPorts.mock.invocationCallOrder[0]!, + ); + expect(mocks.prepareTargetPinnedHostPorts.mock.invocationCallOrder[0]).toBeLessThan( + mocks.allocateAndReservePinnedHostPort.mock.invocationCallOrder[0]!, + ); + expect(mocks.deploy).toHaveBeenCalledWith( + expect.objectContaining({ hostPort: 30_000 }), + expect.any(Function), + ); + expect(mocks.convergeTargetHostPortClaimsUnlocked).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: "project-1", + desiredPublishes: [{ serviceId: null, containerPort: 8080, hostPort: 30_000 }], + }), + ); + expect(mocks.convergeTargetHostPortClaims).not.toHaveBeenCalled(); + expect(mocks.deploy.mock.invocationCallOrder[0]).toBeLessThan( + mocks.convergeTargetHostPortClaimsUnlocked.mock.invocationCallOrder[0]!, + ); + expect(mocks.convergeTargetHostPortClaimsUnlocked.mock.invocationCallOrder[0]).toBeLessThan( + mocks.onSuccess.mock.invocationCallOrder[0]!, + ); + }); + + it("converges a container-IP transition to an empty desired set under its own target lock", async () => { + await run(); + await vi.waitFor(() => expect(mocks.onSuccess).toHaveBeenCalledTimes(1)); + + expect(mocks.convergeTargetHostPortClaims).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: "project-1", + desiredPublishes: [], + }), + ); + expect(mocks.convergeTargetHostPortClaimsUnlocked).not.toHaveBeenCalled(); + expect(mocks.convergeTargetHostPortClaims.mock.invocationCallOrder[0]).toBeLessThan( + mocks.onSuccess.mock.invocationCallOrder[0]!, + ); + }); + + it("keeps a successful deploy ready and surfaces a deferred claim cleanup", async () => { + mocks.convergeTargetHostPortClaims.mockRejectedValueOnce(new Error("edge scan unavailable")); + + await run(); + await vi.waitFor(() => expect(mocks.onSuccess).toHaveBeenCalledTimes(1)); + + expect(mocks.onFailure).not.toHaveBeenCalled(); + expect(mocks.onSuccess).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + warningMessage: expect.stringContaining("Host-port reservation cleanup was deferred"), + }), + ); + }); + + it("protects a bare explicit container-ip deploy from stale edge ports before activation", async () => { + // Bare still binds the target host's loopback namespace. The user-facing + // strategy cannot turn that physical topology into a container bridge. + resolvedRuntime.name = "bare"; + mocks.prepareTargetPinnedHostPorts.mockResolvedValue([ + { + id: "hpc_quarantine_20000", + targetKey: "local", + projectId: "__openship_host_port_quarantine__", + serviceId: null, + containerPort: null, + port: 20_000, + createdAt: new Date(0), + updatedAt: new Date(0), + }, + ]); + mocks.runDeployPipeline.mockImplementationOnce(async (env, input) => { + await env.preflight(input.config, async () => "migrate"); + const result = await env.activate(input.config, () => undefined); + return { + status: "success", + containerId: result.containerId, + url: result.url, + }; + }); + + await run(deployment(), { routeStrategy: "container-ip" }); + await vi.waitFor(() => expect(mocks.onSuccess).toHaveBeenCalledTimes(1)); + + expect(mocks.withHostPortTargetLock).toHaveBeenCalledTimes(1); + expect(mocks.prepareTargetPinnedHostPorts).toHaveBeenCalledTimes(1); + expect(mocks.allocateAndReservePinnedHostPort).not.toHaveBeenCalled(); + expect(mocks.reserveTargetPinnedHostPort).toHaveBeenCalledWith( + { targetKey: "local", legacyTargetKeys: [], stable: true }, + { + projectId: "project-1", + serviceId: null, + containerPort: 8080, + port: 8080, + }, + ); + expect(mocks.prepareTargetPinnedHostPorts.mock.invocationCallOrder[0]).toBeLessThan( + mocks.reserveTargetPinnedHostPort.mock.invocationCallOrder[0]!, + ); + expect(mocks.reserveTargetPinnedHostPort.mock.invocationCallOrder[0]).toBeLessThan( + mocks.deploy.mock.invocationCallOrder[0]!, + ); + }); + + it("validates the live route target against its durable owner before cutover", async () => { + mocks.getContainerInfo.mockResolvedValue({ + ipAddress: "172.18.0.2", + hostPort: 30_000, + }); + mocks.runDeployPipeline.mockImplementationOnce(async (env, input) => { + await env.preflight(input.config, async () => "migrate"); + const result = await env.activate(input.config, () => undefined); + const targetUrl = await env.resolveTargetUrl(result.containerId, input.config.port); + return { + status: "success", + containerId: result.containerId, + url: targetUrl, + }; + }); + + await run(deployment(), { routeStrategy: "loopback-port" }); + await vi.waitFor(() => expect(mocks.onSuccess).toHaveBeenCalledTimes(1)); + + expect(mocks.reserveTargetPinnedHostPort).toHaveBeenCalledWith( + { targetKey: "local", legacyTargetKeys: [], stable: true }, + { + projectId: "project-1", + serviceId: null, + containerPort: 8080, + port: 30_000, + }, + ); + expect(mocks.allocateAndReservePinnedHostPort.mock.invocationCallOrder[0]).toBeLessThan( + mocks.reserveTargetPinnedHostPort.mock.invocationCallOrder[0]!, + ); + }); + + it("does not reclaim the foreign Docker image when deployment fails after preparation", async () => { + mocks.runDeployPipeline.mockResolvedValue({ status: "failed", error: "route failed" }); + + await run(); + await vi.waitFor(() => expect(mocks.onFailure).toHaveBeenCalledTimes(1)); + + const lifecycleContext = mocks.onFailure.mock.calls[0]?.[0]; + expect(lifecycleContext.provisioned).toEqual({}); + expect(mocks.destroy).not.toHaveBeenCalledWith(RESOLVED_IMAGE); + expect(mocks.build).not.toHaveBeenCalled(); + expect(mocks.convergeTargetHostPortClaims).not.toHaveBeenCalled(); + expect(mocks.convergeTargetHostPortClaimsUnlocked).not.toHaveBeenCalled(); + }); + + it("strictly converges a fresh failed-attempt claim only after workload cleanup", async () => { + mocks.runDeployPipeline.mockImplementationOnce(async (env, input) => { + await env.preflight(input.config, async () => "migrate"); + const activated = await env.activate(input.config, () => undefined); + return { + status: "failed", + containerId: activated.containerId, + error: "health check failed", + }; + }); + + await run(deployment(), { routeStrategy: "loopback-port" }); + await vi.waitFor(() => expect(mocks.onFailure).toHaveBeenCalledTimes(1)); + + expect(mocks.destroy).toHaveBeenCalledWith("container-1"); + expect(mocks.convergeTargetHostPortClaimsUnlocked).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: "project-1", + desiredPublishes: [], + }), + ); + expect(mocks.destroy.mock.invocationCallOrder[0]).toBeLessThan( + mocks.convergeTargetHostPortClaimsUnlocked.mock.invocationCallOrder[0]!, + ); + }); + + it("retains a fresh failed-attempt claim when workload cleanup fails", async () => { + mocks.destroy.mockRejectedValueOnce(new Error("daemon unavailable")); + mocks.runDeployPipeline.mockImplementationOnce(async (env, input) => { + await env.preflight(input.config, async () => "migrate"); + const activated = await env.activate(input.config, () => undefined); + return { + status: "failed", + containerId: activated.containerId, + error: "health check failed", + }; + }); + + await run(deployment(), { routeStrategy: "loopback-port" }); + await vi.waitFor(() => expect(mocks.onFailure).toHaveBeenCalledTimes(1)); + + expect(mocks.convergeTargetHostPortClaims).not.toHaveBeenCalled(); + expect(mocks.convergeTargetHostPortClaimsUnlocked).not.toHaveBeenCalled(); + }); + + it("does not reclaim or deploy the foreign Docker image when preparation is cancelled", async () => { + mocks.prepareImage.mockResolvedValue({ + sessionId: "build-session-1", + status: "cancelled", + imageRef: RESOLVED_IMAGE, + durationMs: 4, + artifactOwned: false, + }); + + await run(deployment({ trigger: "manual" })); + await vi.waitFor(() => expect(mocks.onCancelled).toHaveBeenCalledTimes(1)); + + const lifecycleContext = mocks.onCancelled.mock.calls[0]?.[0]; + expect(lifecycleContext.provisioned).toEqual({}); + expect(mocks.destroy).not.toHaveBeenCalledWith(RESOLVED_IMAGE); + expect(mocks.deploy).not.toHaveBeenCalled(); + expect(mocks.runDeployPipeline).not.toHaveBeenCalled(); + expect(mocks.build).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/deployments/build-pipeline.ts b/apps/api/src/modules/deployments/build-pipeline.ts index 0f734be9d..76dabd2c6 100644 --- a/apps/api/src/modules/deployments/build-pipeline.ts +++ b/apps/api/src/modules/deployments/build-pipeline.ts @@ -32,14 +32,17 @@ import { resolveStaticOutputPath, ensurePortAvailable, allocateHostPort, - pickHostPort, - isHostChannelUnavailableError, runDeployPipeline, isMultiServiceRuntime, ensureEdge, + edgeProxyFor, } from "@repo/adapters"; import { platform } from "../../lib/controller-helpers"; -import { resolveUpstreamUrl, resolveRouteStrategy } from "../../lib/upstream-url"; +import { + resolveUpstreamUrl, + resolveRouteStrategy, + usesHostLoopbackUpstream, +} from "../../lib/upstream-url"; import { compileProjectRoutingFields } from "../../lib/project-routing-fields"; import { webhookProxyTarget } from "../../config"; import { @@ -51,7 +54,6 @@ import { } from "../../lib/deployment-runtime"; import { isRealContainerRef } from "../../lib/container-ref"; import { ensureRoutingReady } from "../../lib/edge-reconcile"; -import { sshManager } from "../../lib/ssh-manager"; import { resolveBuildRuntimeModes, resolveDeployRouting, @@ -86,10 +88,34 @@ import { import { firePreDeployBackups } from "../backups/triggers/pre-deploy"; import { buildBackgroundContext } from "../../lib/request-context"; import * as sessionManager from "./session-manager"; -import { onFailure, onSuccess, onCancelled, reportPipelineError, setDeploymentStatus, routeIssuesWarning, type LifecycleContext } from "./deployment-lifecycle"; +import { + onFailure, + onSuccess, + onCancelled, + reportPipelineError, + setDeploymentStatus, + routeIssuesWarning, + type LifecycleContext, +} from "./deployment-lifecycle"; import { auditPorts } from "./port-audit.service"; +import { + allocateAndReservePinnedHostPort, + convergeTargetHostPortClaims, + convergeTargetHostPortClaimsUnlocked, + findOwnedPinnedHostPort, + prepareTargetPinnedHostPorts, + reserveTargetPinnedHostPort, + withHostPortTargetLock, + type AllocatedPinnedHostPort, +} from "./pinned-host-ports"; +import type { HostPortTargetIdentity } from "../../lib/host-port-target"; +import { reserveResolvedLoopbackRoutes } from "./observed-host-port-claims"; import { verifyDeployedContainers } from "./stability-audit.service"; -import { resolveReadinessGate, runReadinessGate, type ResolvedReadinessGate } from "./readiness-gate"; +import { + resolveReadinessGate, + runReadinessGate, + type ResolvedReadinessGate, +} from "./readiness-gate"; import { auditStaticOutput, describeOutputFinding, @@ -97,7 +123,12 @@ import { staticOutputTargets, } from "./output-audit.service"; import { createBuildConfig } from "./build-config"; -import { pinnedAppImage, pinnedStaticDir, snapshotNeedsGitSource } from "./pinned-artifacts"; +import { + pinnedAppImage, + pinnedStaticDir, + refreshAppDeploymentId, + snapshotNeedsGitSource, +} from "./pinned-artifacts"; import { snapshotToClass } from "./deployment-class"; import { shouldRetainArtifact } from "./rollback/restore-plan"; import { resolveClonePlan } from "./clone-plan"; @@ -109,9 +140,7 @@ import { shouldUseProjectServicePipeline, } from "./compose"; import { serviceKind, type DeployableService } from "../../lib/deployable-service"; -import { - resolveProjectRouteState, -} from "../domains/project-route.service"; +import { resolveProjectRouteState } from "../domains/project-route.service"; import { type DeploymentConfigSnapshot } from "./build.service"; import * as settingsService from "../settings/settings.service"; @@ -239,7 +268,10 @@ export async function kickoffBuild(project: Project, dep: Deployment): Promise { +async function markDeploymentFailedFromOutside( + deploymentId: string, + error: unknown, +): Promise { const message = safeErrorMessage(error); try { const dep = await repos.deployment.findById(deploymentId).catch(() => null); @@ -252,12 +284,16 @@ async function markDeploymentFailedFromOutside(deploymentId: string, error: unkn return; } await repos.deployment.updateStatus(deploymentId, "failed").catch(() => {}); - const buildSession = await repos.deployment.findBuildSessionByDeploymentId(deploymentId).catch(() => null); + const buildSession = await repos.deployment + .findBuildSessionByDeploymentId(deploymentId) + .catch(() => null); if (buildSession) { - await repos.deployment.updateBuildSession(buildSession.id, { - status: "failed", - finishedAt: new Date(), - }).catch(() => {}); + await repos.deployment + .updateBuildSession(buildSession.id, { + status: "failed", + finishedAt: new Date(), + }) + .catch(() => {}); } // SSE: surface the error to anyone watching the stream and close it. sessionManager.appendLog(deploymentId, { @@ -267,11 +303,13 @@ async function markDeploymentFailedFromOutside(deploymentId: string, error: unkn }); sessionManager.updateStatus(deploymentId, "failed"); } catch (handlerErr) { - console.error(`[DEPLOY] markDeploymentFailedFromOutside crashed for ${deploymentId}:`, handlerErr); + console.error( + `[DEPLOY] markDeploymentFailedFromOutside crashed for ${deploymentId}:`, + handlerErr, + ); } } - /** * Hand the finished deployment to the rollback orchestrator: it retains the * previous release (stopping a durable unit when the project keeps artifacts), @@ -319,9 +357,10 @@ async function archivePreviousDeployment( * host filesystem. The deploy step promotes those files again, exactly as it * promotes a freshly-extracted build. * - * Returns null when nothing is pinned or the artifact has since been reclaimed, - * which is the caller's signal to build from source. A pin is a hint, never a - * promise — retention may have run between planning a restore and executing it. + * Ordinary rollback pins return null when their artifact has been reclaimed, + * which lets the caller rebuild from source. A refresh marker is stricter: Apply + * explicitly promises no rebuild, so a missing active artifact throws and tells + * the user to Redeploy instead of silently shipping different code. */ async function reuseRetainedArtifact(opts: { snapshot: DeploymentConfigSnapshot; @@ -335,7 +374,11 @@ async function reuseRetainedArtifact(opts: { const { snapshot, runtime, buildSessionId, targetExecutor, logger } = opts; const reuse = (artifactRef: string) => { - logger.step("build", "completed", `Reusing retained artifact ${artifactRef} — no rebuild needed`); + logger.step( + "build", + "completed", + `Reusing retained artifact ${artifactRef} — no rebuild needed`, + ); return { sessionId: buildSessionId, status: "deploying" as const, @@ -362,6 +405,31 @@ async function reuseRetainedArtifact(opts: { return exists ? reuse(staticDir) : gone(staticDir); } + const refreshFrom = refreshAppDeploymentId(snapshot); + if (refreshFrom) { + if (runtime instanceof BareRuntime) { + const release = await runtime.retainedReleaseArtifact(refreshFrom); + if (release) return reuse(release); + throw new Error( + `Cannot refresh without rebuilding: the active release ${refreshFrom} is no longer retained. Use Redeploy instead.`, + ); + } + + if (!(runtime instanceof DockerRuntime)) { + throw new Error( + `Apply without rebuilding is not supported by the ${runtime.name} runtime. Use Redeploy instead.`, + ); + } + + const image = pinnedAppImage(snapshot); + if (!image || !(await runtime.imageExistsLocally(image).catch(() => false))) { + throw new Error( + "Cannot refresh without rebuilding because the active container image is unavailable. Use Redeploy instead.", + ); + } + return reuse(image); + } + const image = pinnedAppImage(snapshot); if (!image) return null; // Only Docker's artifact is an image; any other runtime takes its normal path. @@ -520,7 +588,8 @@ async function executeBuildAndDeploy(project: Project, dep: Deployment, buildSes // but keep a BARE serve/lifecycle identity (files served by the edge — a // persisted "docker" would make rollback/purge 404-no-op on the release dir and // leak it). Cloud static + Docker-less desktop-local static keep their own mode. - const willRunServices = (await resolveServicePipelineMode(project, snapshot)).useServicePipeline; + const willRunServices = (await resolveServicePipelineMode(project, snapshot)) + .useServicePipeline; // The runtime/workload axis, resolved from the frozen snapshot triple // (issue #538). `web` | `worker` | `static` replaces the old `hasServer` // boolean, which couldn't tell a portless worker from a static site. @@ -531,12 +600,15 @@ async function executeBuildAndDeploy(project: Project, dep: Deployment, buildSes baseTarget: plat.target, effectiveTarget: resolveEffectiveTarget(plat.target, snapshot), willRunServices, + hasPrebuiltImage: Boolean(snapshot.releaseImageRef), }); if (runtimeModes.buildRuntimeMode === "docker") { logger.log( willRunServices ? "→ Services require the Docker runtime — running this service deploy on Docker.\n" - : "→ Static build runs in a Docker sandbox; files are served by the edge.\n", + : snapshot.releaseImageRef + ? "→ Prebuilt release image requires Docker — pulling and running it without a source build.\n" + : "→ Static build runs in a Docker sandbox; files are served by the edge.\n", ); } @@ -621,7 +693,10 @@ async function executeBuildAndDeploy(project: Project, dep: Deployment, buildSes // Best-effort: fan-out is a dashboard concern. A crash here must // not block the main build. console.warn(`[build] preCreateServiceDeployments crashed for ${dep.id}:`, err); - return new Map(); + return new Map< + string, + { id: string; serviceId: string; serviceName: string; targeted: boolean } + >(); }); await emitInitialServiceChecks(serviceFanOut, project, dep); @@ -643,9 +718,7 @@ async function executeBuildAndDeploy(project: Project, dep: Deployment, buildSes (dep.envVars ?? {}) as Record, (key: string, err: unknown) => { failedEnvKeys.push(key); - console.warn( - `[build] failed to decrypt env var ${key}: ${safeErrorMessage(err)}`, - ); + console.warn(`[build] failed to decrypt env var ${key}: ${safeErrorMessage(err)}`); }, ); // Surface dropped env in the BUILD LOG (not just the server console) so a @@ -723,8 +796,9 @@ async function executeBuildAndDeploy(project: Project, dep: Deployment, buildSes isDesktop: plat.target === "desktop", forwardGitCredentials: snapshot.forwardGitCredentials, repoIsGithub: !!project.gitOwner, + dockerTransport: runtime instanceof DockerRuntime ? runtime.transport.kind : undefined, }); - const cloneOnServer = clonePlan.runsOnServer; + const cloneOnTarget = clonePlan.cloneRunsOnTarget; // The relay needs a real SSH reverse tunnel — `reverseForward` exists on every // SSH executor and is absent only on a LocalExecutor (relay.ts). This is the // TRUE capability gate (not the server's SSH auth method); combined with the @@ -763,18 +837,18 @@ async function executeBuildAndDeploy(project: Project, dep: Deployment, buildSes projectId: project.id, owner: project.gitOwner ?? undefined, repo: project.gitRepo ?? undefined, - buildStrategy: clonePlan.cloneBuildStrategy, + buildStrategy: clonePlan.cloneCredentialPurpose, // Only meaningful for an on-server clone — lets a per-server GitHub auth // config (device token / PAT / SSH key) win for that server. - serverId: clonePlan.runsOnServer ? resolved.serverId : null, + serverId: clonePlan.cloneRunsOnTarget ? resolved.serverId : null, allowRelayFallback, // Docker clone-on-server can degrade to an api-host clone, so resolve // gracefully (a LOCAL fallback credential, flagged apiHostFallback) instead // of hard-failing at token resolution after the server is provisioned. - allowApiHostFallback: clonePlan.dockerClonesOnServer, + allowApiHostFallback: clonePlan.dockerClonesOnTarget, // Lets the chain ask the target server whether it already reaches this // repo on its own — only consulted for a clone that runs THERE. - serverExecutor: clonePlan.runsOnServer ? targetExecutor : null, + serverExecutor: clonePlan.cloneRunsOnTarget ? targetExecutor : null, repoUrl: snapshot.repoUrl, onLog: (message) => logger.log(message), }) @@ -791,9 +865,9 @@ async function executeBuildAndDeploy(project: Project, dep: Deployment, buildSes // The rule itself lives with the credential type (`cloneOnServerAvailable`), so a capability // check shown in the picker and the decision made here can never disagree. const cloneCredentialAvailable = cloneOnServerAvailable(gitCred).available; - const effectiveCloneOnServer = - cloneOnServer && (runtime.name === "bare" || cloneCredentialAvailable); - if (cloneOnServer && runtime.name !== "bare" && !cloneCredentialAvailable) { + const effectiveCloneOnTarget = + cloneOnTarget && (runtime.name === "bare" || cloneCredentialAvailable); + if (cloneOnTarget && runtime.name !== "bare" && !cloneCredentialAvailable) { logger.log( "Clone-on-server was requested, but nothing can authenticate the clone on the build host — " + "the server has no GitHub identity of its own, no App/PAT token is available, and no git " + @@ -803,7 +877,7 @@ async function executeBuildAndDeploy(project: Project, dep: Deployment, buildSes "add a per-project clone token.", "warn", ); - } else if (effectiveCloneOnServer && gitCred.relay) { + } else if (effectiveCloneOnTarget && gitCred.relay) { logger.log( "Cloning on the build host via your forwarded git identity — the credential is used for this build only and never persisted on the server.", ); @@ -839,14 +913,14 @@ async function executeBuildAndDeploy(project: Project, dep: Deployment, buildSes // orchestrator transferring the context. The credential arrives either via // the relay (gitCredentialHelperPath, set once the relay is open) or the // short-lived token already on buildConfig.gitToken. - buildConfig.cloneOnServer = effectiveCloneOnServer; + buildConfig.cloneOnServer = effectiveCloneOnTarget; // Per-server SSH clone credential (ssh-server-key / ssh-deploy-key mode). // Consumed by the adapter clone step (git@github.com + GIT_SSH_COMMAND). if (gitCred.ssh) buildConfig.gitSsh = gitCred.ssh; // The server authenticates with its own credentials. Gated on the clone // actually running there: on the api-host path this names an identity that // isn't ours to use, and the adapter would find no credential at all. - if (gitCred.ambient && effectiveCloneOnServer) buildConfig.gitAmbient = gitCred.ambient; + if (gitCred.ambient && effectiveCloneOnTarget) buildConfig.gitAmbient = gitCred.ambient; // Desktop git-credential relay opener, shared by the single-app and compose // paths. Opens the reverse tunnel + remote helper (nothing persisted on the @@ -915,9 +989,7 @@ async function executeBuildAndDeploy(project: Project, dep: Deployment, buildSes // monorepo entry in causes a ghost compose-kind row to be inserted // alongside the real monorepo row (no DB unique constraint on // (projectId, name)). Filter to compose-kind before handing it off. - const composeOnly = snapshot.composeServices?.filter( - (s) => serviceKind(s) === "compose", - ); + const composeOnly = snapshot.composeServices?.filter((s) => serviceKind(s) === "compose"); if (composeOnly?.length) { // removeMissing: false — this list is the release's frozen snapshot, not // an authoritative inventory. On a rollback it predates services added @@ -943,6 +1015,7 @@ async function executeBuildAndDeploy(project: Project, dep: Deployment, buildSes system, executor: targetExecutor, localHost: resolved.platform.localHost, + hostPortTarget: resolved.hostPortTarget, usesManagedRouting, logger, ctx, @@ -954,8 +1027,8 @@ async function executeBuildAndDeploy(project: Project, dep: Deployment, buildSes gitToken: gitCred.token, gitCredentialHelperPath: composeRelay?.scriptPath, gitSsh: gitCred.ssh, - gitAmbient: effectiveCloneOnServer ? gitCred.ambient : undefined, - cloneOnServer: effectiveCloneOnServer, + gitAmbient: effectiveCloneOnTarget ? gitCred.ambient : undefined, + cloneOnServer: effectiveCloneOnTarget, }); } finally { if (composeRelay) await composeRelay.close().catch(() => {}); @@ -1023,7 +1096,34 @@ async function executeBuildAndDeploy(project: Project, dep: Deployment, buildSes staticExecutor, logger, }); - const buildResult = reusedArtifact ?? (await buildFromSource()); + let preparedReleaseImage: BuildResult | null = null; + const releaseImageRef = snapshot.releaseImageRef?.trim(); + if (!reusedArtifact && releaseImageRef) { + if (!runtime.supports("prebuiltImage") || !runtime.prepareImage) { + throw new Error( + `The ${runtime.name} runtime cannot deploy a prebuilt container image. Choose Docker or Cloud.`, + ); + } + preparedReleaseImage = await runtime.prepareImage( + { + sessionId: buildSessionId, + projectId: project.id, + slug: project.slug ?? undefined, + imageRef: releaseImageRef, + envVars: { + ...envMap, + ...(workload === "web" ? { PORT: String(snapshot.port) } : {}), + }, + resources: prodResources, + // An explicit update is the one operation that promises to refresh a + // mutable upstream ref. Versioned release refs are otherwise reused + // when already present on the target. + forcePull: dep.trigger === "update", + }, + logger, + ); + } + const buildResult = reusedArtifact ?? preparedReleaseImage ?? (await buildFromSource()); // ONLY an artifact this deploy PRODUCED goes on the cleanup list. `provisioned` // exists so onFailure/onCancelled can reclaim a half-built image or build dir, @@ -1036,7 +1136,9 @@ async function executeBuildAndDeploy(project: Project, dep: Deployment, buildSes // un-restorable; for a server app it is `removeImage` on the retained tag. A // PIN is meant to be exempt from reclamation, and reclaiming it on the way to // reporting a FAILED deploy is the one moment nothing else will notice. - if (!reusedArtifact) provisioned.imageRef = buildResult.imageRef; + if (!reusedArtifact && buildResult.artifactOwned !== false) { + provisioned.imageRef = buildResult.imageRef; + } // A reused STATIC release is a directory whose doc-root offset was decided when // its files were extracted, so it is read back from that release instead of @@ -1069,6 +1171,14 @@ async function executeBuildAndDeploy(project: Project, dep: Deployment, buildSes extra: { imageRef: buildResult.imageRef, buildDurationMs: buildResult.durationMs }, }); + // Keep the exact lock decision on the phase. Post-route claim convergence + // must use the unlocked helper only when this wrapper really acquired the + // target lock; inferring that again inside the phase risks a nested lock when + // a restored release changes the effective serve mode. + const hostPortTargetLockHeld = + deployRouting.deployMode === "server" && + resolved.effectiveTarget !== "cloud" && + usesHostLoopbackUpstream(resolveRouteStrategy(project.routeStrategy), runtime); const phase: DeployPhaseInputs = { ctx, project, @@ -1084,6 +1194,8 @@ async function executeBuildAndDeploy(project: Project, dep: Deployment, buildSes baseTarget: plat.target, effectiveTarget: resolved.effectiveTarget, serverId: resolved.serverId, + hostPortTarget: resolved.hostPortTarget, + hostPortTargetLockHeld, usesManagedRouting, routeState, buildResult, @@ -1098,7 +1210,12 @@ async function executeBuildAndDeploy(project: Project, dep: Deployment, buildSes if (deployRouting.deployMode === "static-edge") { await executeStaticEdgeDeploy(phase, runtime as CloudRuntime); } else { - await executeServerDeploy(phase); + if (hostPortTargetLockHeld && !phase.hostPortTarget) { + throw new Error("Cannot allocate a routed host port without a physical target identity"); + } + await (hostPortTargetLockHeld + ? withHostPortTargetLock(phase.hostPortTarget!, () => executeServerDeploy(phase)) + : executeServerDeploy(phase)); } } catch (err) { const message = err instanceof Error ? err.message : "Unknown error"; @@ -1135,6 +1252,10 @@ interface DeployPhaseInputs { baseTarget: string; effectiveTarget: string; serverId: string | null; + /** Physical host key used by durable claims and the allocation lock. */ + hostPortTarget: HostPortTargetIdentity | null; + /** Whether executeServerDeploy is already inside the physical-target lock. */ + hostPortTargetLockHeld: boolean; usesManagedRouting: boolean; routeState: Awaited>; buildResult: BuildResult; @@ -1162,7 +1283,18 @@ async function executeStaticEdgeDeploy( phase: DeployPhaseInputs, runtime: CloudRuntime, ): Promise { - const { ctx, project, dep, snapshot, buildSessionId, routeState, buildResult, envMap, prodResources, logger } = phase; + const { + ctx, + project, + dep, + snapshot, + buildSessionId, + routeState, + buildResult, + envMap, + prodResources, + logger, + } = phase; logger.step("deploy", "running", "Deploying to edge (static)..."); @@ -1268,6 +1400,17 @@ function buildDeployEnvironment( /** Sink for a failure the gate decided to WARN about rather than veto. The * caller folds these into the deploy's action-required warning. */ onReadinessWarning: (detail: string) => void; + /** Actual target topology after runtime capabilities are accounted for. */ + usesHostLoopback: boolean; + /** The reservation is chosen during preflight, after this environment exists. */ + reservedHostPort?: () => number | undefined; + /** + * Reserve any loopback host ports after edge convergence, but before the + * ordinary live-port check and activation. This ordering is security + * sensitive: a migrated vhost must enter the durable avoid-set before a new + * workload can capture the port it still dials. + */ + prepareHostPorts?: (config: DeployConfig) => Promise; }, ): DeployEnvironment { const { runtime, system, targetExecutor, routeState, logger, effectiveTarget, project } = phase; @@ -1358,7 +1501,7 @@ function buildDeployEnvironment( // deploys and runs on its port; routing is flagged action-required // and retried later (route registration below is also best-effort). try { - if (plannedDomains.length > 0) { + if (plannedDomains.length > 0 || deps.prepareHostPorts) { // Routing needs OpenResty on 80/443. If a foreign proxy already // holds them, HOLD the deploy and prompt (migrate / take over / // cancel) — the same session prompt flow used for port conflicts. @@ -1408,6 +1551,13 @@ function buildDeployEnvironment( } } + // This is deliberately outside the best-effort routing catch. A route + // may be optional, but reusing a port that an unreadable/stale vhost + // still targets is not: it can serve one project's container through + // another project's hostname. The strict inventory either imports the + // observed ports into durable claims or aborts before the old workload + // is stopped and before the new one binds. + await deps.prepareHostPorts?.(cfg); await serve.ensurePorts(cfg, promptUser); } : undefined, @@ -1426,7 +1576,9 @@ function buildDeployEnvironment( if (targetExecutor) return targetExecutor.rm(id); return previousRuntime.destroy(id); } - return previousRuntime.name === "bare" ? previousRuntime.stop(id) : previousRuntime.destroy(id); + return previousRuntime.name === "bare" + ? previousRuntime.stop(id) + : previousRuntime.destroy(id); }, // The non-overlap pre-stop: free the port, but keep the old workload // restorable until the new one proves out. STOP for both runtimes — Docker @@ -1436,8 +1588,7 @@ function buildDeployEnvironment( // A path-shaped id is a static release DIR: nothing is "running", so there is // no port to free and nothing to stop. Leaving it in place is what makes it // restorable; `deactivate` above still removes it on the overlap/success path. - deactivateRetaining: (id) => - id.includes("/") ? Promise.resolve() : previousRuntime.stop(id), + deactivateRetaining: (id) => (id.includes("/") ? Promise.resolve() : previousRuntime.stop(id)), // Discard the retained container after success. Container runtimes only: bare's // stopped release is a DIRECTORY owned by the retention/rollback window, and // destroying it here would delete a release that rollback still expects (bare @@ -1452,16 +1603,30 @@ function buildDeployEnvironment( resolveRoute: serve.resolveRoute, resolveTargetUrl: async (id, port) => { const strategy = resolveRouteStrategy(phase.project.routeStrategy); - // loopback-port: dial the container's published LOOPBACK host port (read - // live — works with a pinned or random publish). Bare has none → the - // helper falls back to 127.0.0.1:. A container with no host port - // falls back to the bridge IP. The post-activate health check gates all - // of this, so a bad target fails the deploy, not the live route. + // Host-loopback topologies dial the reserved publish selected in preflight. + // Read it back from the runtime only as a fallback for an older/unpinned + // activation. Bare has no separate publish: the resolver uses its + // 127.0.0.1: identity. let hostPort: number | undefined; - if (strategy === "loopback-port" && runtime.name !== "bare") { - hostPort = (await runtime.getContainerInfo(id).catch(() => null))?.hostPort ?? undefined; + if (deps.usesHostLoopback && runtime.name !== "bare") { + hostPort = + deps.reservedHostPort?.() ?? + (await runtime.getContainerInfo(id).catch(() => null))?.hostPort ?? + undefined; } - return resolveUpstreamUrl({ strategy, runtime, containerId: id, containerPort: port, hostPort }); + const targetUrl = await resolveUpstreamUrl({ + strategy, + runtime, + containerId: id, + containerPort: port, + hostPort, + }); + await reserveResolvedLoopbackRoutes({ + target: phase.hostPortTarget, + projectId: project.id, + routes: [{ targetUrl, serviceId: null, containerPort: port }], + }); + return targetUrl; }, }; } @@ -1469,9 +1634,20 @@ function buildDeployEnvironment( /** Server deploy via runDeployPipeline (VM / Docker / Bare). Handles static-self-hosted too. */ async function executeServerDeploy(phase: DeployPhaseInputs): Promise { const { - ctx, project, dep, snapshot, buildSessionId, - runtime, routing, ssl, usesManagedRouting, - routeState, buildResult, envMap, prodResources, logger, + ctx, + project, + dep, + snapshot, + buildSessionId, + runtime, + routing, + ssl, + usesManagedRouting, + routeState, + buildResult, + envMap, + prodResources, + logger, } = phase; // Static sites are served as files by the edge (OpenResty `root`), regardless @@ -1498,7 +1674,7 @@ async function executeServerDeploy(phase: DeployPhaseInputs): Promise { // extracted it (release root), else the configured output dir (bare build). const staticServeOutputDir = phase.deployRouting.staticServeOutputDir; - // loopback-port routing pins a stable LOOPBACK host port for docker so the + // Any host-loopback topology pins a stable LOOPBACK host port for docker so the // edge target is predictable and survives container restarts. Reused across // redeploys (persisted on the project); allocated once on first deploy from a // live-probed free port. Bare owns 127.0.0.1: and needs none. The @@ -1506,6 +1682,7 @@ async function executeServerDeploy(phase: DeployPhaseInputs): Promise { // allocates a host port on the orchestrator (cloud routes via Oblien, not a // loopback port) — defense-in-depth against a cloud→host-exec slip. const routeStrategy = resolveRouteStrategy(project.routeStrategy); + const usesHostLoopback = usesHostLoopbackUpstream(routeStrategy, runtime); // The project's OPT-IN readiness gate. Inactive unless the project configured // one, and inactive is the default — so by default nothing waits on the app @@ -1567,11 +1744,13 @@ async function executeServerDeploy(phase: DeployPhaseInputs): Promise { // Overlap (run-new-then-swap, zero-downtime) needs a unique container + // its own host port; a pinned loopback port can't be double-bound and bare // binds a fixed port → stop-first. - canOverlap: runtime.name !== "bare" && routeStrategy !== "loopback-port", + canOverlap: !usesHostLoopback, ensureRuntimeReady: async () => { const system = phase.system; if (!system) return; - await system.ensureFeature("deploy", (entry) => logger.log(`${entry.message}\n`, entry.level)); + await system.ensureFeature("deploy", (entry) => + logger.log(`${entry.message}\n`, entry.level), + ); }, ensurePorts: async (cfg, promptUser) => { const executor = phase.targetExecutor; @@ -1606,7 +1785,8 @@ async function executeServerDeploy(phase: DeployPhaseInputs): Promise { new Set( (routeState.publicEndpoints.length > 0 ? routeState.publicEndpoints - : [{ port: cfg.port }]) + : [{ port: cfg.port }] + ) .map((endpoint) => endpoint.port ?? cfg.port) .filter((port): port is number => Number.isFinite(port)), ), @@ -1621,7 +1801,9 @@ async function executeServerDeploy(phase: DeployPhaseInputs): Promise { // source apps so injected internal hosts (e.g. db:5432) resolve. The // compose path does this in compose/deploy.service.ts; this closes the // single-container gap. Advisory — never fails the deploy. - await attachLinkedNetworks(project.id, runtime, (m, level) => logger.log(`${m}\n`, level)); + await attachLinkedNetworks(project.id, runtime, (m, level) => + logger.log(`${m}\n`, level), + ); return deployed; }, resolveRoute: undefined, @@ -1666,75 +1848,116 @@ async function executeServerDeploy(phase: DeployPhaseInputs): Promise { } : baseServe; - let pinnedHostPort: number | undefined = project.hostPort ?? undefined; - if ( - routeStrategy === "loopback-port" && - !isStaticFileServe && - !isWorker && - runtime.name !== "bare" && - phase.effectiveTarget !== "cloud" - ) { - if (!pinnedHostPort) { - // Avoid host ports already pinned to OTHER projects in this org — their - // containers may not be listening right now, so the live scan alone - // wouldn't see them and two projects could collide on the same loopback - // port (ensurePortAvailable is only the deploy-time backstop). - const avoid = ( - await repos.project - .listByOrganization(project.organizationId, { perPage: 1000 }) - .then((r) => r.rows) - .catch(() => [] as { id: string; hostPort: number | null }[]) - ) - .filter((p) => p.id !== project.id && typeof p.hostPort === "number") - .map((p) => p.hostPort as number); - // The deploy's own executor when it has one; otherwise the POOLED host - // channel — never a bare `createHostExecutor()`, which builds a fresh - // SSH connection per call and leaked one sshd session each time (#291). - const allocation = phase.targetExecutor - ? await allocateHostPort(phase.targetExecutor, { avoid }) - : // No executor for this target, so the pooled host channel is the only way to - // read the box's live ports. When that channel is unusable, degrade to the - // SAME answer scanPorts gives an executor it can't reach — "couldn't scan", - // pick from `avoid` alone — instead of failing the deploy. Docker publishes - // the port either way; refusing to deploy over an unreadable scan would make - // a blocked channel take down container deploys it never touches (#490). - await sshManager - .withHostExecutor((exec) => allocateHostPort(exec, { avoid })) - .catch((e) => { - if (!isHostChannelUnavailableError(e)) throw e; - return { port: pickHostPort(new Set(), { avoid }), scanned: false }; + let pinnedHostPort: number | undefined; + const attemptedHostPortAllocations: Array< + Pick + > = []; + const needsHostLoopbackClaim = + usesHostLoopback && !isStaticFileServe && !isWorker && phase.effectiveTarget !== "cloud"; + if (needsHostLoopbackClaim && (!phase.hostPortTarget || !phase.targetExecutor)) { + throw new Error("Cannot inspect routed host ports without a physical target executor"); + } + + // Allocation is a PRE-FLIGHT effect, not an input-construction effect. In + // particular it must run after edge migration: the migration may import a + // stopped project's vhost to 127.0.0.1:X, and X has to be quarantined before + // this deployment chooses a port. The promise makes the hook idempotent if a + // pipeline wrapper probes preflight more than once. + let hostPortPreparation: Promise | null = null; + const prepareHostPorts = needsHostLoopbackClaim + ? async (config: DeployConfig): Promise => { + hostPortPreparation ??= (async () => { + const target = phase.hostPortTarget!; + const executor = phase.targetExecutor!; + const claims = await prepareTargetPinnedHostPorts({ + target, + edgeProxy: edgeProxyFor(executor, "openresty", { ours: true }), + }); + + if (runtime.name !== "bare") { + // Durable claims are host-wide, including other organizations and + // stopped containers. The target lock surrounding this phase keeps + // inventory → reservation → Docker bind atomic against other deploys. + const owner = { + projectId: project.id, + serviceId: null, + containerPort: snapshot.port, + } as const; + const allocation = await allocateAndReservePinnedHostPort({ + target, + claims, + owner, + // Prefer the target's reservation, not this targetless cache. The + // helper consults claims first, so a migration cannot move source + // ownership. + cachedPreferred: project.hostPort, + allowLegacyContainerPort: true, + allocate: (allocationOptions) => allocateHostPort(executor, allocationOptions), }); - pinnedHostPort = allocation.port; - // A scan that couldn't RUN is not "nothing is listening". Say so here or the - // bind failure that follows blames Docker for a host we simply couldn't read - // — the #490 pattern, where the cause never appears in the log. - if (!allocation.scanned) { - logger.log( - `Couldn't read live port occupancy on the target, so ${pinnedHostPort} avoids only ` + - `the ports pinned to other projects. If publishing it fails as "already allocated", ` + - `this is why — check that Openship can reach this host (Servers → this box).\n`, - "warn", - ); + attemptedHostPortAllocations.push(allocation); + pinnedHostPort = allocation.port; + // A scan that couldn't RUN is not "nothing is listening". Say so + // here or the bind failure blames Docker for a host we couldn't read. + if (!allocation.scanned) { + logger.log( + `Couldn't read live port occupancy on the target, so ${pinnedHostPort} avoids only ` + + `database-pinned ports. If publishing it fails as "already allocated", ` + + `check that Openship can reach this host (Servers → this box).\n`, + "warn", + ); + } + return; + } + + // Bare apps bind their declared ports directly on the host. They + // participate in the same namespace or a stopped bare app's old vhost + // can be captured by a later Docker allocation. + const barePorts = new Set( + (routeState.publicEndpoints.length > 0 + ? routeState.publicEndpoints + : [{ port: snapshot.port }] + ) + .map((endpoint) => endpoint.port ?? snapshot.port) + .filter((port): port is number => Number.isSafeInteger(port) && port > 0), + ); + for (const port of barePorts) { + const owner = { projectId: project.id, serviceId: null, containerPort: port } as const; + const exact = findOwnedPinnedHostPort(claims, owner); + const legacy = claims.find( + (claim) => + claim.projectId === project.id && + claim.serviceId === null && + claim.containerPort === null && + claim.port === port, + ); + const claim = await reserveTargetPinnedHostPort(target, { + ...owner, + containerPort: exact ? exact.containerPort : legacy ? legacy.containerPort : port, + port, + }); + attemptedHostPortAllocations.push({ claim, previousClaim: exact ?? legacy }); + } + })(); + + await hostPortPreparation; + // runDeployPipeline passes this same mutable config from preflight into + // activate. Assign only after the reservation is durable. + config.hostPort = pinnedHostPort; } - await repos.project - .update(project.id, { hostPort: pinnedHostPort }) - .catch((err) => logger.log(`Couldn't persist host port ${pinnedHostPort}: ${safeErrorMessage(err)}\n`, "warn")); - } - } else { - pinnedHostPort = undefined; // don't publish a pinned port under container-ip / bare / static - } + : undefined; const deployConfig: DeployConfig = { deploymentId: dep.id, projectId: project.id, buildSessionId, imageRef: buildResult.imageRef!, + prebuiltImage: Boolean(snapshot.releaseImageRef), environment: dep.environment, port: snapshot.port, // A worker publishes and dials nothing: the runtime skips ExposedPorts / // PortBindings / PORT (issue #538-B). `port` is left set but inert. portless: isWorker, - hostPort: pinnedHostPort, + ...(pinnedHostPort !== undefined ? { hostPort: pinnedHostPort } : {}), // The build may override the start command once it knows the output shape // (e.g. Next.js standalone → `node server.js` instead of `next start`). startCommand: buildResult.startCommand ?? snapshot.startCommand, @@ -1813,27 +2036,26 @@ async function executeServerDeploy(phase: DeployPhaseInputs): Promise { // than nuke every route. The plannedHostnames check is belt-and-braces: // never prune a hostname this same deploy is registering. const activeRouteIds = new Set( - routeState.publicEndpoints - .map((endpoint) => endpoint.id) - .filter((id): id is string => !!id), + routeState.publicEndpoints.map((endpoint) => endpoint.id).filter((id): id is string => !!id), ); const plannedHostnames = new Set(plannedDomains.map((domain) => domain.hostname.toLowerCase())); - const obsoleteProjectDomains = activeRouteIds.size > 0 - ? projectDomains.filter( - (domain) => - !domain.serviceId && - // Never sweep a user-connected custom domain (may be portless / not a - // build endpoint) — only free/generated routes are eligible. - domain.domainType !== "custom" && - !activeRouteIds.has(domain.id) && - !plannedHostnames.has(domain.hostname.toLowerCase()), - ) - : []; + const obsoleteProjectDomains = + activeRouteIds.size > 0 + ? projectDomains.filter( + (domain) => + !domain.serviceId && + // Never sweep a user-connected custom domain (may be portless / not a + // build endpoint) — only free/generated routes are eligible. + domain.domainType !== "custom" && + !activeRouteIds.has(domain.id) && + !plannedHostnames.has(domain.hostname.toLowerCase()), + ) + : []; - // Persist a domain record for each planned route. Track the ones we - // CREATE here (vs pre-existing rows) so they can be rolled back if the - // deploy fails — otherwise a failed deploy leaves orphan domain rows - // that resurface as routes on the next deploy. + // Persist a domain record for each planned route. Track only generated rows + // this call authoritatively created so they can be rolled back if the deploy + // fails. Custom domains are durable user configuration, not deployment + // artifacts: a failed redeploy must never detach them from the project. const createdDomainIds: string[] = []; const domainClaimWarnings: string[] = []; // Only domains we could CLAIM get routed. A hostname owned by another project @@ -1843,20 +2065,21 @@ async function executeServerDeploy(phase: DeployPhaseInputs): Promise { const routableDomains: typeof plannedDomains = []; for (const route of plannedDomains) { try { - const created = await ensureRouteDomainRecord({ + const ensured = await ensureRouteDomainRecord({ projectId: project.id, route, domainByHostname, }); - if (created && !projectDomains.some((d) => d.id === created.id)) { - createdDomainIds.push(created.id); + const domainRecord = ensured.domain; + if (ensured.created && domainRecord && domainRecord.domainType !== "custom") { + createdDomainIds.push(domainRecord.id); logger.log(`Created domain record for "${route.hostname}".\n`); } // Same ordering hole the compose path has: the plan above was built from the // rows read BEFORE this loop, so a hostname minted right here was planned // with no row and came out `provisionSsl: false` — see // withEnsuredDomainRecord. Re-resolve against the row that exists now. - routableDomains.push(withEnsuredDomainRecord(route, created)); + routableDomains.push(withEnsuredDomainRecord(route, domainRecord)); } catch (err) { const message = safeErrorMessage(err); logger.log(`Skipping domain "${route.hostname}" (not routed — ${message}).\n`, "warn"); @@ -1881,6 +2104,9 @@ async function executeServerDeploy(phase: DeployPhaseInputs): Promise { plannedDomains, readinessGate, onReadinessWarning: (detail) => readinessWarnings.push(detail), + usesHostLoopback, + reservedHostPort: () => pinnedHostPort, + ...(prepareHostPorts ? { prepareHostPorts } : {}), }); // Gate on the ROUTABLE set, not the plan: `routableDomains` carries the @@ -1904,9 +2130,7 @@ async function executeServerDeploy(phase: DeployPhaseInputs): Promise { // otherwise orphan. Skip the one runDeployPipeline already handles and the // sentinel. Best-effort; never blocks the deploy. if (prevDep) { - const prevServiceDeps = await repos.service - .listByDeployment(prevDep.id) - .catch(() => []); + const prevServiceDeps = await repos.service.listByDeployment(prevDep.id).catch(() => []); for (const sd of prevServiceDeps) { if (!isRealContainerRef(sd.containerId) || sd.containerId === prevDep.containerId) { continue; @@ -1980,21 +2204,83 @@ async function executeServerDeploy(phase: DeployPhaseInputs): Promise { // prev-deactivation can never reach it — that's exactly how containers // piled up (3 for one project). Destroy it via the current runtime now. // Static deploys have no container. Best-effort + idempotent. + let failedWorkloadCleaned = !deployResult.containerId || isStaticFileServe; if (deployResult.containerId && !isStaticFileServe) { - await runtime.destroy(deployResult.containerId).catch((err) => + try { + await runtime.destroy(deployResult.containerId); + failedWorkloadCleaned = true; + } catch (err) { logger.log( `Warning: failed to clean up container after deploy failure: ${safeErrorMessage(err)}\n`, "warn", - ), + ); + } + } + if (phase.hostPortTarget && attemptedHostPortAllocations.length > 0) { + // Never blindly release a fresh claim after activation. If cleanup failed, + // the workload can still own the bind; and even though route failures are + // normally best-effort, a future pipeline change must not turn a written + // vhost into an unclaimed upstream. Once workload cleanup is confirmed, the + // same strict edge-scan convergence used on success can release only ports + // proven absent while preserving every prior exact owner. + const hasLegacyPreviousClaim = attemptedHostPortAllocations.some( + (allocation) => allocation.previousClaim && allocation.previousClaim.containerPort === null, ); + if (!failedWorkloadCleaned) { + logger.log( + "Host-port reservation cleanup deferred because the failed workload could not be stopped; all reservations were retained.\n", + "warn", + ); + } else if (!phase.targetExecutor || hasLegacyPreviousClaim) { + logger.log( + "Host-port reservation cleanup deferred because prior ownership could not be proven exactly; all reservations were retained.\n", + "warn", + ); + } else { + const desiredPublishes = attemptedHostPortAllocations.flatMap((allocation) => { + const previous = allocation.previousClaim; + return previous?.containerPort !== null && previous?.containerPort !== undefined + ? [ + { + serviceId: previous.serviceId, + containerPort: previous.containerPort, + hostPort: previous.port, + }, + ] + : []; + }); + const convergence = { + target: phase.hostPortTarget, + projectId: project.id, + desiredPublishes, + edgeProxy: edgeProxyFor(phase.targetExecutor, "openresty", { ours: true }), + }; + try { + if (phase.hostPortTargetLockHeld) { + await convergeTargetHostPortClaimsUnlocked(convergence); + } else { + await convergeTargetHostPortClaims(convergence); + } + } catch (err) { + logger.log( + `Host-port reservation cleanup deferred; uncertain reservations were retained safely. ${safeErrorMessage(err)}\n`, + "warn", + ); + } + } } // Roll back the domain rows this deploy created — it didn't take, so // its routes must not linger (they'd resurface as planned routes next // deploy). Best-effort; pre-existing rows are left untouched. for (const id of createdDomainIds) { - await repos.domain.remove(id).catch((err) => - logger.log(`Warning: failed to roll back domain record: ${safeErrorMessage(err)}\n`, "warn"), - ); + await repos.domain + .remove(id) + .catch((err) => + logger.log( + `Warning: failed to roll back domain record: ${safeErrorMessage(err)}\n`, + "warn", + ), + ); } await onFailure(ctx, deployResult.error, buildResult.durationMs, { errorCode: deployResult.errorCode, @@ -2003,6 +2289,19 @@ async function executeServerDeploy(phase: DeployPhaseInputs): Promise { return; } + // Allocation authority is the target-scoped claim written before activation. + // Keep the historical scalar only as a routing/UI compatibility cache, and do + // not move it to a migration target until that target is genuinely live. + if (pinnedHostPort !== undefined && pinnedHostPort !== project.hostPort) { + await repos.project.update(project.id, { hostPort: pinnedHostPort }).catch((err) => { + logger.log( + `Warning: the deployment is live and its host port is reserved, but the project cache ` + + `could not be updated: ${safeErrorMessage(err)}\n`, + "warn", + ); + }); + } + const postSync = await runPostDeploySync({ plannedDomains, obsoleteProjectDomains, @@ -2017,6 +2316,47 @@ async function executeServerDeploy(phase: DeployPhaseInputs): Promise { logger, }); + // Route mutation is complete now, including removal of obsolete vhosts in + // runPostDeploySync. Converge only at this boundary: releasing earlier could + // let another deployment inherit a port which an old edge route still dials. + // + // This also runs when the NEW topology is container-IP/static. Its desired set + // is then empty, which is how a successful loopback -> non-loopback transition + // reclaims the old reservation. Such a path did not take the outer allocation + // lock, so it acquires the lock here instead of calling the unlocked helper. + let hostPortClaimWarning: string | undefined; + if (phase.hostPortTarget && phase.targetExecutor) { + const desiredPublishes = needsHostLoopbackClaim + ? runtime.name === "bare" + ? [...new Set(attemptedHostPortAllocations.map((allocation) => allocation.claim.port))].map( + (port) => ({ serviceId: null, containerPort: port, hostPort: port }), + ) + : pinnedHostPort !== undefined + ? [{ serviceId: null, containerPort: snapshot.port, hostPort: pinnedHostPort }] + : [] + : []; + const convergence = { + target: phase.hostPortTarget, + projectId: project.id, + desiredPublishes, + edgeProxy: edgeProxyFor(phase.targetExecutor, "openresty", { ours: true }), + }; + try { + const result = phase.hostPortTargetLockHeld + ? await convergeTargetHostPortClaimsUnlocked(convergence) + : await convergeTargetHostPortClaims(convergence); + if (result.released > 0) { + logger.log( + `Released ${result.released} obsolete host-port reservation${result.released === 1 ? "" : "s"}.\n`, + ); + } + } catch (error) { + hostPortClaimWarning = + "Host-port reservation cleanup was deferred; uncertain reservations were retained safely."; + logger.log(`${hostPortClaimWarning} ${safeErrorMessage(error)}\n`, "warn"); + } + } + // Advisory port check — confirm the app is actually listening on its exposed // port(s) from INSIDE the instance. Runs after the deploy is live and never // throws (auditPorts is fully guarded), so it can't fail or delay-revert the @@ -2027,7 +2367,8 @@ async function executeServerDeploy(phase: DeployPhaseInputs): Promise { new Set( (deployConfig.publicEndpoints && deployConfig.publicEndpoints.length > 0 ? deployConfig.publicEndpoints - : [{ port: deployConfig.port }]) + : [{ port: deployConfig.port }] + ) .map((endpoint) => endpoint.port ?? deployConfig.port) .filter((port): port is number => Number.isFinite(port)), ), @@ -2059,6 +2400,12 @@ async function executeServerDeploy(phase: DeployPhaseInputs): Promise { // `metaPatch` is spread into deployment.meta (persisted) and read back for the // SSE payload in onSuccess, so both live + refresh see the same result. const metaPatch: Record = {}; + // Docker preparation resolves a registry tag to its immutable repo digest. + // Freeze that exact reference into the successful deployment so a rollback + // can re-pull the same bytes even after local image retention expires. + if (snapshot.releaseImageRef && runtime.name === "docker" && buildResult.imageRef) { + metaPatch.releaseImageRef = buildResult.imageRef; + } if (portCheck.length > 0) metaPatch.portCheck = portCheck; if (outputCheck.length > 0) metaPatch.outputCheck = outputCheck; // Persist WHERE this deploy serves from. It can't be recomputed later: the read @@ -2139,9 +2486,11 @@ async function executeServerDeploy(phase: DeployPhaseInputs): Promise { // absent from the persisted log — invisible on replay, in history, and to the // CLI. Emitting it here (matching the compose path's wording) makes the server // the single writer for every warning, so the client never has to add one. - const deployWarnings = [metaPatch.deployWarning, metaPatch.readinessWarning].filter( - (warning): warning is string => typeof warning === "string" && warning.length > 0, - ); + const deployWarnings = [ + metaPatch.deployWarning, + metaPatch.readinessWarning, + hostPortClaimWarning, + ].filter((warning): warning is string => typeof warning === "string" && warning.length > 0); const warningMessage = deployWarnings.join(" · "); if (warningMessage) { logger.log(`Deployment completed with warnings: ${warningMessage}\n`, "warn"); @@ -2185,8 +2534,13 @@ async function runPostDeploySync(opts: { logger: BuildLogger; }): Promise<{ warningMessage?: string }> { const { - plannedDomains, obsoleteProjectDomains, routing, usesManagedRouting, - organizationId, serverId, logger, + plannedDomains, + obsoleteProjectDomains, + routing, + usesManagedRouting, + organizationId, + serverId, + logger, } = opts; // Collect free-domain edge-sync failures so a self-hosted + free-.opsh.io @@ -2215,13 +2569,19 @@ async function runPostDeploySync(opts: { if (routing) { await routing.removeRoute(domain.hostname).catch((err) => { const message = safeErrorMessage(err); - logger.log(`Warning: failed to remove stale route ${domain.hostname}: ${message}\n`, "warn"); + logger.log( + `Warning: failed to remove stale route ${domain.hostname}: ${message}\n`, + "warn", + ); }); } await repos.domain.remove(domain.id).catch((err) => { const message = safeErrorMessage(err); - logger.log(`Warning: failed to remove stale domain record ${domain.hostname}: ${message}\n`, "warn"); + logger.log( + `Warning: failed to remove stale domain record ${domain.hostname}: ${message}\n`, + "warn", + ); }); } diff --git a/apps/api/src/modules/deployments/build.service.ts b/apps/api/src/modules/deployments/build.service.ts index 7dd2d84e6..c12732b75 100644 --- a/apps/api/src/modules/deployments/build.service.ts +++ b/apps/api/src/modules/deployments/build.service.ts @@ -26,6 +26,8 @@ import { getRuntimeImage, isFullCommitSha, isReleaseProvider, + releaseArtifactKind, + renderReleaseImage, looksLikeSecretKey, resolveProjectVolumes, type StackId, @@ -37,10 +39,7 @@ import { type BuildKind, type WorkloadType, } from "@repo/core"; -import type { - LogEntry, - ResourceConfig, -} from "@repo/adapters"; +import type { LogEntry, ResourceConfig } from "@repo/adapters"; import { resolveCloudResourceConfig } from "./cloud-resources"; import { resolveEnvDirtyServiceIds } from "./env-drift"; import type { TBuildAccessBody } from "./deployment.schema"; @@ -52,6 +51,7 @@ import { resolveSmartRoute } from "./smart-route"; import { snapshotNeedsGitSource, withoutPinnedArtifacts } from "./pinned-artifacts"; import { deploymentWorkload, projectToClass, snapshotToClass } from "./deployment-class"; import { resolveProjectInfo } from "./prepare.service"; +import { ComposeConfigurationError } from "./compose-configuration-error"; import { getFolderSession } from "../projects/folder/session-store"; import { hasMaskedValue, unmaskEnv } from "../../lib/secret-env"; import { assertValidCustomDomains, customHostnamesOf } from "../../lib/custom-domain-guard"; @@ -83,7 +83,11 @@ import { syncProjectRouteState, } from "../domains/project-route.service"; import { kickoffBuild, resolveServicePipelineMode } from "./build-pipeline"; -import { resolveReleaseDist, resolveLatestVersion, readApiVersion } from "../../lib/release-resolver"; +import { + resolveReleaseDist, + resolveReleaseVersion, + ReleaseVersionUnavailableError, +} from "../../lib/release-resolver"; import { env } from "../../config"; function throwPreflightFailure(preflight: PreflightResult): never { @@ -207,14 +211,19 @@ export interface DeploymentConfigSnapshot { /** Absolute path to a local project directory (alternative to repoUrl) */ localPath?: string; /** - * Release/dist source (gitProvider === "release"). Resolved by - * `applyReleaseSourceToSnapshot` in the async entry points: the semver - * version deployed, the asset it came from, and the source repo — captured - * so history/rollback and the drift banner have a stable anchor. `localPath` - * above points at the resolved dist dir and `buildCommand` is emptied - * (deploy-only, no build). + * Release source (gitProvider === "release"). Resolved by + * `applyReleaseSourceToSnapshot` in the async entry points: the semver plus + * either an extracted archive (`localPath`) or a concrete registry image + * (`releaseImageRef`). Captured so history, rollback, and drift all share the + * same stable anchor; neither artifact runs a source build. */ releaseVersion?: string; + /** Raw upstream tag (for example `v1.2.3`). Kept separately from the + * normalized releaseVersion so an image template using `{tag}` is stable. */ + releaseTag?: string; + /** Concrete prebuilt image selected for this release. This is deliberately + * separate from buildImage, which is the builder used for source builds. */ + releaseImageRef?: string; releaseAsset?: string; releaseRepo?: string; /** Build strategy: "server" (build in workspace) or "local" (build on host) */ @@ -257,6 +266,9 @@ export interface DeploymentConfigSnapshot { /** Single-app twin of `handoverImages` — the whole release is this one image. * Set by a rollback restore; consumed by the single-app build phase. */ handoverAppImage?: string; + /** Env-only refresh of a single app. Reuse this active deployment's retained + * artifact and fail closed if it is unavailable — never fall into a rebuild. */ + refreshAppDeploymentId?: string; /** STATIC twin: a retained release DIRECTORY on the host to promote again * (static releases have no image). Set by a rollback restore. */ handoverStaticDir?: string; @@ -374,10 +386,7 @@ function toRuntimeMode(value: string | null | undefined): "bare" | "docker" | un /** Build a config snapshot from the project - pure pass-through, no fallbacks. * All values must be set by prepare / ensureProject before this is called. */ -export function buildConfigSnapshot( - project: Project, - branch?: string, -): DeploymentConfigSnapshot { +export function buildConfigSnapshot(project: Project, branch?: string): DeploymentConfigSnapshot { const runtimeImage = resolveRuntimeImage(project); return { @@ -428,17 +437,14 @@ export function buildConfigSnapshot( } /** - * Resolve a release/dist-source project (`gitProvider === "release"`) into a - * deployable snapshot: pick the version, download/locate the prebuilt dist, - * and point the snapshot's `localPath` at it with the build step emptied. The - * rest of the pipeline then treats it exactly like a `localPath` no-build - * deploy — no bespoke pipeline. `buildConfigSnapshot` is sync/pure, so this - * async resolution runs in the deploy entry points (requestBuildAccess / - * triggerDeployment) after the snapshot is built, mirroring `startWebmailDeploy`. + * Resolve a release-source project (`gitProvider === "release"`) into one + * explicit frozen artifact. Archive releases resolve to a local directory; + * image releases render a concrete registry reference. `buildImage` is never + * touched: it configures source-build sandboxes and is not a deploy artifact. * - * Version precedence: explicit `opts.version` (webhook release tag / redeploy - * pin) → `releaseSource.pinnedVersion` → newest advertised (github latest tag - * or `versionUrl`) → the API's own version (mono-version fallback). + * Version precedence lives in resolveReleaseVersion: explicit webhook/redeploy + * tag → pinnedVersion → newest advertised. There is intentionally no fallback + * to OpenShip's own package version for arbitrary projects. * * Mutates `snapshot` in place and returns the resolved semver (no leading "v"). */ @@ -447,14 +453,6 @@ export async function applyReleaseSourceToSnapshot( snapshot: DeploymentConfigSnapshot, opts?: { version?: string }, ): Promise { - // Backstop: release/dist resolution downloads + extracts a prebuilt dir onto - // THIS box (~/.openship) — a self-hosted runtime op that must never run on the - // multi-tenant SaaS control plane. Creation is already blocked in cloud mode - // (resolveProjectSource); this also covers redeploy/webhook paths for any - // project that predates the gate. - if (env.CLOUD_MODE) { - throw new ForbiddenError("Release/dist source deploys are not available in cloud mode"); - } const source = (project.releaseSource as ReleaseSource | null) ?? null; if (!source) { throw new AppError( @@ -464,15 +462,49 @@ export async function applyReleaseSourceToSnapshot( ); } - const version = - stripV(opts?.version) || - stripV(source.pinnedVersion) || - (await resolveLatestVersion(source)) || - readApiVersion(); + let release: Awaited>; + try { + release = await resolveReleaseVersion(source, { version: opts?.version }); + } catch (err) { + if (err instanceof ReleaseVersionUnavailableError) { + throw new AppError(err.message, 424, "RELEASE_VERSION_UNAVAILABLE"); + } + throw err; + } + + if (releaseArtifactKind(source) === "image") { + if (snapshotToClass(snapshot).workload === "static") { + throw new AppError( + "A prebuilt container image must run as a web app or worker, not a static-file deployment.", + 400, + "RELEASE_IMAGE_STATIC_UNSUPPORTED", + ); + } + snapshot.releaseImageRef = renderReleaseImage(source.imageTemplate!, release); + snapshot.releaseVersion = release.version; + snapshot.releaseTag = release.tag; + snapshot.releaseRepo = source.mode === "github" ? source.repo : undefined; + snapshot.releaseAsset = undefined; + snapshot.repoUrl = ""; + snapshot.localPath = undefined; + snapshot.installCommand = ""; + snapshot.buildCommand = ""; + snapshot.hasBuild = false; + snapshot.source = "image"; + snapshot.build = "prebuilt"; + if (!project.cloudWorkspaceId) snapshot.runtimeMode = "docker"; + return release.version; + } + + // Archive resolution downloads + extracts onto this control plane. Registry + // images do not, so only this legacy artifact kind is unavailable in SaaS. + if (env.CLOUD_MODE) { + throw new ForbiddenError("Release archive projects are not available in cloud mode"); + } const result = await resolveReleaseDist({ name: project.slug || project.id, - version, + version: release.version, source, }); @@ -483,16 +515,13 @@ export async function applyReleaseSourceToSnapshot( snapshot.repoUrl = ""; snapshot.buildCommand = ""; snapshot.releaseVersion = result.version; + snapshot.releaseTag = release.tag; + snapshot.releaseImageRef = undefined; snapshot.releaseAsset = result.asset; snapshot.releaseRepo = source.mode === "github" ? source.repo : undefined; return result.version; } -function stripV(v: string | null | undefined): string | undefined { - const t = v?.trim(); - return t ? t.replace(/^v/, "") : undefined; -} - async function resolveLatestCommitInfo(ctx: RequestContext, project: Project, branch: string) { if (!project.gitOwner || !project.gitRepo) { return {}; @@ -548,29 +577,70 @@ async function resolveProjectBranch(ctx: RequestContext, project: Project, branc * Re-parse the repo's current docker-compose and 3-way reconcile it against the * stored service rows (repos.service.reconcileFromCompose): services the user * hasn't edited auto-update to the repo; edited services are preserved and flagged - * (`driftSpec`) for review. Best-effort — a repo/parse failure, a non-compose or - * local-source project, or an empty parse leaves the rows untouched and NEVER - * blocks the deploy. GitHub-source compose projects only. + * (`driftSpec`) for review. Existing rows reconcile best-effort. Bootstrapping an + * explicitly compose-shaped project is strict: a bad/empty declared file must + * block instead of silently falling through to the generic single-app builder. + * Non-compose and local-source projects are unchanged. GitHub source only. * * `changedPaths` (webhook only) is an optimization: when we have a definite, - * non-empty changed-file list that does NOT include a compose file, skip the - * repo scan entirely — the compose can't have changed. When it's absent (manual - * redeploy) or empty, reconcile runs to be safe. + * non-empty changed-file list that does NOT include a compose file, skip drift + * scans for an already-materialized project. Bootstrap always scans once: an + * optimization must not leave a declared compose project with zero services. + * When the list is absent (manual redeploy) or empty, reconcile runs to be safe. */ const COMPOSE_PATH_RE = /(^|\/)(docker-compose|compose)\.ya?ml$/i; +function composeCouldHaveChanged(project: Project, changedPaths: string[]): boolean { + const declared = project.composePath?.trim().replace(/^\.\//, "").replace(/\/$/, ""); + return changedPaths.some((rawPath) => { + const changed = rawPath.replace(/^\.\//, ""); + if (COMPOSE_PATH_RE.test(changed)) return true; + if (!declared) return false; + return changed === declared || changed.startsWith(`${declared}/`); + }); +} + +/** A stored baseline written before a newly modeled compose field existed must + * be normalized once even when the triggering push only changed application + * code. `buildArgs` is the version marker here: every current `toComposeSpec` + * writes it (including `{}`), while pre-#689 baselines omit it. A null baseline + * likewise still needs its first repo reconciliation. */ +function composeRowsNeedBaselineUpgrade( + rows: Array<{ kind?: string | null; importedSpec?: unknown }>, +): boolean { + return rows.some((row) => { + if (row.kind !== "compose") return false; + const baseline = row.importedSpec; + return !baseline || typeof baseline !== "object" || !Object.hasOwn(baseline, "buildArgs"); + }); +} + async function reconcileComposeDrift( ctx: RequestContext, project: Project, branch: string, changedPaths?: string[] | null, ) { + let bootstrapping = false; try { if (!project.gitOwner || !project.gitRepo) return; // local/no-git source → nothing to re-parse - if (changedPaths && changedPaths.length > 0 && !changedPaths.some((p) => COMPOSE_PATH_RE.test(p))) { + const composeRows = await listProjectComposeServices(project.id); + const hasComposeRows = composeRows.some((s) => s.kind === "compose"); + bootstrapping = !hasComposeRows && isMultiServiceProject(project); + if (!hasComposeRows && !bootstrapping) return; // not a compose project + const needsBaselineUpgrade = composeRowsNeedBaselineUpgrade(composeRows); + // changedPaths is only a drift optimization. A declared compose project + // with no rows must scan once regardless of which file triggered the first + // webhook; otherwise the service pipeline is selected with an empty service + // set and the project can never bootstrap. + if ( + !bootstrapping && + !needsBaselineUpgrade && + changedPaths && + changedPaths.length > 0 && + !composeCouldHaveChanged(project, changedPaths) + ) { return; // this push didn't touch the compose file → no drift possible } - const composeRows = await listProjectComposeServices(project.id); - if (!composeRows.some((s) => s.kind === "compose")) return; // not a compose project const info = await resolveProjectInfo({ source: "github", owner: project.gitOwner, @@ -583,58 +653,55 @@ async function reconcileComposeDrift( composePath: project.composePath ?? undefined, }); const services = info.services ?? []; - if (services.length === 0) return; - const { driftedNames } = await repos.service.reconcileFromCompose( - project.id, - keepUnresolvedEnv(services, composeRows), - ); + if (services.length === 0) { + if (bootstrapping) { + throw new Error( + `The configured compose path "${project.composePath ?? "repository root"}" contains no services.`, + ); + } + return; + } + const { driftedNames } = await repos.service.reconcileFromCompose(project.id, services); if (driftedNames.length > 0) { console.log( `[compose-drift] ${project.id}: kept user edits on ${driftedNames.join(", ")} (pending review)`, ); } } catch (err) { + if (bootstrapping) { + throw new AppError( + `Could not initialize compose services from "${project.composePath ?? "repository root"}": ${safeErrorMessage(err)}`, + 400, + ); + } + // A transient repository/API failure may safely keep the last imported + // shape for an existing project. A file we did read but cannot represent + // must fail closed: otherwise this deploy silently runs the stale service + // definition after the author changed a build target, secret, SSH option, + // malformed arg, or another unsupported Compose field. + if (err instanceof ComposeConfigurationError) { + throw new AppError( + `Could not refresh compose services from "${project.composePath ?? "repository root"}": ${safeErrorMessage(err)}`, + 400, + ); + } console.warn(`[compose-drift] reconcile skipped for ${project.id}:`, err); } } -/** - * A re-parse of the repo's compose resolves `${DB_PASSWORD}` against the repo's - * own `.env` — which for a secret is exactly the file that ISN'T committed, so it - * comes back "". Handing that to the 3-way merge reads as "upstream cleared this - * value" and, on an unedited row, auto-applies it: the password the user typed in - * the wizard is wiped on the next push deploy. - * - * So for env keys whose value came from a variable the parse could NOT resolve, - * keep the stored row's value. The key stays present (dropping it would delete - * the variable from the container instead), and a real upstream edit — a new key, - * a changed literal, a different `${VAR:-default}` — still drifts normally. - */ -function keepUnresolvedEnv< - T extends { - name: string; - environment?: Record; - environmentMeta?: Record; - }, ->(parsed: T[], stored: { name: string; environment?: unknown }[]): T[] { - const storedByName = new Map( - stored.map((row) => [row.name, (row.environment as Record | null) ?? {}]), - ); - return parsed.map((svc) => { - const meta = svc.environmentMeta; - if (!meta || !svc.environment) return svc; - const storedEnv = storedByName.get(svc.name); - if (!storedEnv) return svc; // new upstream service — nothing to preserve - let patched: Record | undefined; - for (const [key, value] of Object.entries(svc.environment)) { - if (value !== "" || meta[key]?.source !== "missing") continue; - const kept = storedEnv[key]; - if (!kept) continue; - patched ??= { ...svc.environment }; - patched[key] = kept; - } - return patched ? { ...svc, environment: patched } : svc; - }); +/** Freeze an auto-discovered service shape into the release snapshot. This is + * what makes a composePath bootstrap visible in deployment metadata and keeps a + * later rollback self-contained. An explicit single-app choice never reaches + * this helper because resolveServicePipelineMode returns false for it. */ +function freezeResolvedServicePipeline( + snapshot: DeploymentConfigSnapshot, + resolved: { useServicePipeline: boolean; servicePreflightServices: DeployableService[] }, +): void { + if (!resolved.useServicePipeline) return; + snapshot.serviceDeploymentMode ??= "services"; + if (!snapshot.composeServices?.length && resolved.servicePreflightServices.length > 0) { + snapshot.composeServices = resolved.servicePreflightServices; + } } /** @@ -964,10 +1031,7 @@ export async function createQueuedDeployment(opts: { export { subscribe as subscribeToBuildSession } from "./session-manager"; /** Resolve a pending pipeline prompt (e.g. port conflict). */ -export async function respondToPrompt( - deploymentId: string, - action: string, -): Promise { +export async function respondToPrompt(deploymentId: string, action: string): Promise { await loadDeployment(deploymentId); return sessionManager.respondToPrompt(deploymentId, action); } @@ -978,11 +1042,12 @@ export async function respondToPrompt( * with neither is dropped downstream (see deriveEnvironmentPublicEndpoints), so * pick the one the project's shape needs. */ -function defaultFreeEndpoint(project: { - slug: string; - hasServer: boolean; - port: number | null; -}): { domain: string; domainType: "free"; port?: string; targetPath?: string } { +function defaultFreeEndpoint(project: { slug: string; hasServer: boolean; port: number | null }): { + domain: string; + domainType: "free"; + port?: string; + targetPath?: string; +} { return project.hasServer && project.port ? { domain: project.slug, domainType: "free", port: String(project.port) } : { domain: project.slug, domainType: "free", targetPath: "/" }; @@ -1039,9 +1104,7 @@ export async function requestBuildAccess( // Folder-upload: resolve the session UP FRONT — its scanned compose services // feed the service-mode decision below. The snapshot mutations it drives still // happen further down, after target resolution (which the upload mode overrides). - const uploadSession = input.uploadSessionId - ? getFolderSession(input.uploadSessionId) - : undefined; + const uploadSession = input.uploadSessionId ? getFolderSession(input.uploadSessionId) : undefined; if (input.uploadSessionId && (!uploadSession || uploadSession.orgId !== ctx.organizationId)) { throw new AppError("Upload session not found or expired — re-upload the folder.", 400); } @@ -1074,8 +1137,12 @@ export async function requestBuildAccess( // CREATES the missing ones (native) and, for freshly-adopted rows (importedSpec // null), bootstraps their baseline while KEEPING the adopted image — so mapped // services reuse their running image (no rebuild) and everything else in the - // compose is taken from the repo. Best-effort; self-guards to compose+git projects. - await reconcileComposeDrift(ctx, project, resolvedBranch); + // compose is taken from the repo. An explicit single-app deploy is an + // authoritative topology choice: do not parse, materialize, or validate the + // declared compose file behind the caller's back. + if (serviceDeploymentMode !== "single") { + await reconcileComposeDrift(ctx, project, resolvedBranch); + } // #336: the wizard sees compose env MASKED, so a deploy request can echo the // "••••••••" sentinel back. Recover the real values before they're persisted @@ -1147,11 +1214,12 @@ export async function requestBuildAccess( projectDomains, nextPublicEndpoints, slug: routeState.publicEndpoints.find((endpoint) => endpoint.domainType === "free")?.domain, - // A deploy must never delete or null a user's VERIFIED custom domain, even + // A deploy must never delete or null a user's custom domain, even // if this deploy's endpoint set omitted it or lost its port (e.g. a target - // that mis-resolved to "local"). The Domains editor keeps the default (off) - // so explicit removals still apply. - preserveVerifiedCustom: true, + // that mis-resolved to "local"). Pending verification is still durable + // user configuration. The Domains editor keeps the default (off), so an + // explicit removal there still applies. + preserveCustomDomains: true, }); routeState = routing; } @@ -1215,12 +1283,17 @@ export async function requestBuildAccess( project, snapshot, ); + freezeResolvedServicePipeline(snapshot, { useServicePipeline, servicePreflightServices }); // Resolve the snapshot's target (deployTarget + serverId + runtimeMode) from // the single source of truth shared with triggerDeployment — UI override > // cloudWorkspaceId > active-deployment meta. Keeps the two deploy entry points // from diverging on where a project deploys. - const resolvedTarget = await resolveSnapshotTarget(project, { deployTarget, serverId, runtimeMode }); + const resolvedTarget = await resolveSnapshotTarget(project, { + deployTarget, + serverId, + runtimeMode, + }); snapshot.deployTarget = resolvedTarget.deployTarget; snapshot.serverId = resolvedTarget.serverId; snapshot.runtimeMode = resolvedTarget.runtimeMode; @@ -1247,14 +1320,13 @@ export async function requestBuildAccess( // default, and a redeploy then resolves to that default (bare) — silently // flipping a docker/sandbox project to direct-on-host. Best-effort: a failed // persist must not block the deploy. Only write when it actually changed. - if ( - (runtimeMode === "bare" || runtimeMode === "docker") && - runtimeMode !== project.runtimeMode - ) { + if ((runtimeMode === "bare" || runtimeMode === "docker") && runtimeMode !== project.runtimeMode) { await repos.project .update(project.id, { runtimeMode }) .catch((err) => - console.warn(`[requestBuildAccess] failed to persist runtimeMode: ${safeErrorMessage(err)}`), + console.warn( + `[requestBuildAccess] failed to persist runtimeMode: ${safeErrorMessage(err)}`, + ), ); } @@ -1323,11 +1395,7 @@ export async function requestBuildAccess( const env = environment || "production"; // ── Resolve commit info from the branch HEAD ──── - const { commitSha, commitMessage } = await resolveLatestCommitInfo( - ctx, - project, - snapshot.branch, - ); + const { commitSha, commitMessage } = await resolveLatestCommitInfo(ctx, project, snapshot.branch); // ── Resolve rollback context (shared helper — single default) ───────── const { rollbackStrategy, commitShaBefore } = await resolveRollbackContext( @@ -1343,7 +1411,10 @@ export async function requestBuildAccess( // no env at all even though `PATCH /api/projects/:id/env` succeeded. let deploymentEnvVars = encryptEnvVars(envVars); if (!deploymentEnvVars) { - const rawEnvMap = await repos.project.getEnvMap(project.id, env); + // A deployment snapshot is project-scoped. Service-scoped rows are loaded + // live, per service, by the compose deployer; flattening them into this map + // leaks one service's values into every other service and destroys scope. + const rawEnvMap = await repos.project.getEnvMap(project.id, env, null); deploymentEnvVars = Object.keys(rawEnvMap).length > 0 ? rawEnvMap : null; } @@ -1409,7 +1480,6 @@ export async function requestBuildAccess( }; } - /** * Cancel an in-flight deployment. * @@ -1569,9 +1639,18 @@ export async function redeployBuildSession( // "redeploy latest commit" semantics below). Re-resolving also guards against // a frozen snapshot whose cached dist dir was since pruned. if (isReleaseProvider(project.gitProvider)) { - await applyReleaseSourceToSnapshot(project, meta, { - version: opts?.useExistingCommit ? frozenMeta?.releaseVersion : undefined, - }); + // A same-version redeploy of an image release must replay the exact frozen + // reference, even if the project template has since changed. The runtime + // will re-pull it when the local artifact was pruned. Archive releases still + // re-resolve their frozen version because their cached directory may be gone. + const canReplayFrozenImage = opts?.useExistingCommit && Boolean(frozenMeta?.releaseImageRef); + if (!canReplayFrozenImage) { + await applyReleaseSourceToSnapshot(project, meta, { + version: opts?.useExistingCommit + ? (frozenMeta?.releaseTag ?? frozenMeta?.releaseVersion) + : undefined, + }); + } } // Two redeploy modes: @@ -1608,8 +1687,12 @@ export async function redeployBuildSession( // override an explicit user choice on the original deployment. // Reconcile upstream compose drift BEFORE reading the rows, so this redeploy // picks up repo changes on unedited services (and flags edited ones). See - // reconcileComposeDrift — best-effort, never blocks. - await reconcileComposeDrift(ctx, project, branch); + // reconcileComposeDrift. A composePath bootstrap is intentionally strict; + // an explicitly frozen single-app deployment must remain single and must not + // materialize compose rows as a side effect of redeploying it. + if (meta.serviceDeploymentMode !== "single") { + await reconcileComposeDrift(ctx, project, branch); + } const currentComposeRows = await listProjectComposeServices(project.id).catch(() => []); const currentComposeServices = projectServicesToDeployableServices( @@ -1627,10 +1710,13 @@ export async function redeployBuildSession( }; // ── Resolve rollback context (shared helper — single default) ───────── - const { rollbackStrategy, commitShaBefore } = await resolveRollbackContext( - project, - branch, - ); + const { rollbackStrategy, commitShaBefore } = await resolveRollbackContext(project, branch); + + // Normal redeploy means current configuration + latest commit. The old + // deployment's envVars is a release snapshot and belongs only to rollback. + // Service-scoped rows stay out of this flat capture: the compose deployer + // reads them live per service and applies them after compose inline env. + const currentProjectEnv = await repos.project.getEnvMap(project.id, oldDep.environment, null); const dep = await createQueuedDeployment({ projectId: project.id, @@ -1642,7 +1728,7 @@ export async function redeployBuildSession( environment: oldDep.environment, framework: oldDep.framework || refreshedMeta.framework, meta: metaWithPrevious(refreshedMeta, project), - envVars: oldDep.envVars as Record | null, + envVars: Object.keys(currentProjectEnv).length > 0 ? currentProjectEnv : null, rollbackStrategy, commitShaBefore, }); @@ -1676,9 +1762,15 @@ export async function startBuild(deploymentId: string) { // be overwritten mid-flight. Resolving a blocker creates a NEW deployment // (redeploy), it never restarts this one. if ( - ["building", "deploying", "ready", "failed", "cancelled", "action_required", "no_changes"].includes( - dep.status, - ) + [ + "building", + "deploying", + "ready", + "failed", + "cancelled", + "action_required", + "no_changes", + ].includes(dep.status) ) { return { success: true, @@ -1803,7 +1895,12 @@ export async function triggerDeployment( // adopted Docker migration, which builds nothing — the exemption preflight // already makes), and a ROLLBACK replaying pinned artifacts. Both used to be // refused here, before preflight could apply its own, smarter rule. - if (!project.gitUrl && !project.localPath && !isReleaseProvider(project.gitProvider)) { + if ( + !data.refresh && + !project.gitUrl && + !project.localPath && + !isReleaseProvider(project.gitProvider) + ) { const sourceless = data.reuseSnapshot ? snapshotNeedsGitSource(data.reuseSnapshot.meta) : snapshotNeedsGitSource( @@ -1817,11 +1914,19 @@ export async function triggerDeployment( } } // GitHub access gate (default-deny; webhook ctx is the org owner and - // passes). Covers manual trigger / redeploy paths routed through here. - await assertGitHubRepoAccess(ctx, { - owner: project.gitOwner, - repo: project.gitRepo, - }); + // passes). A reused snapshot that needs no Git source is an exact artifact + // replay, so it must not be judged against a repository linked *after* the + // target deployment was created. That is particularly important for a + // release-image rollback after the project has since been relinked to Git. + // Any replay that still clones source remains gated as usual. + const needsGitRepositoryAccess = + !data.reuseSnapshot || snapshotNeedsGitSource(data.reuseSnapshot.meta); + if (needsGitRepositoryAccess) { + await assertGitHubRepoAccess(ctx, { + owner: project.gitOwner, + repo: project.gitRepo, + }); + } const branch = await resolveProjectBranch(ctx, project, data.branch); const environment = data.environment ?? "production"; @@ -1855,7 +1960,9 @@ export async function triggerDeployment( // Reconcile upstream compose drift before the pipeline reads service rows — // covers webhook (git push) + manual triggers. Skip atomic rollback: it must // ship the frozen snapshot verbatim. `changedPaths` (webhook) lets it skip the - // repo scan when the push didn't touch the compose file. Best-effort; never blocks. + // repo scan when the push didn't touch the compose file. Existing projects + // reconcile best-effort; a declared compose project with no rows is strict so + // it cannot silently fall through with an empty service set. if (!data.reuseSnapshot && data.trigger !== "rollback") { await reconcileComposeDrift(ctx, project, branch, data.changedPaths); } @@ -1915,6 +2022,38 @@ export async function triggerDeployment( project, snapshot, ); + freezeResolvedServicePipeline(snapshot, { useServicePipeline, servicePreflightServices }); + + // Resolve once, before preflight: a single-app refresh is a pinned-artifact + // deploy, so preflight and git-token resolution must both see that it needs no + // source/build. The active row is also the artifact owner for Bare releases. + let refreshActive: Awaited> | null = null; + if (data.refresh) { + refreshActive = project.activeDeploymentId + ? await repos.deployment.findById(project.activeDeploymentId).catch(() => null) + : null; + if (!refreshActive) { + throw new AppError("Nothing to refresh yet — deploy the project first.", 409); + } + + if (!useServicePipeline) { + const workload = snapshotToClass(snapshot).workload; + if (workload === "static") { + throw new AppError( + "This is a static site, so it has no running environment to refresh. Use Redeploy when its build environment changes.", + 409, + ); + } + if (snapshot.deployTarget === "cloud") { + throw new AppError( + "Apply without rebuilding is not available for this cloud app yet. Use Redeploy to apply its environment changes.", + 409, + ); + } + snapshot.refreshAppDeploymentId = refreshActive.id; + if (refreshActive.imageRef) snapshot.handoverAppImage = refreshActive.imageRef; + } + } // ── Preflight: validate config before creating any resources ──── await runDeploymentPreflight(snapshot, routeState, { @@ -1936,27 +2075,17 @@ export async function triggerDeployment( if (reuse) { encryptedEnvVars = reuse.envVars; } else { - const rawEnvMap = await repos.project.getEnvMap(project.id, environment); + const rawEnvMap = await repos.project.getEnvMap(project.id, environment, null); encryptedEnvVars = Object.keys(rawEnvMap).length > 0 ? rawEnvMap : null; } // ── Resolve commit info: fetch HEAD from GitHub if not provided ──── let commitSha = requestedCommitSha; let commitMessage = data.commitMessage; - if (data.refresh) { - // Refresh recreates the running containers with current env — it never - // pulls new code or builds. Reuse the active deployment's commit if it has - // one (for display/versioning), but DON'T require it: a local/compose - // project may carry no commit, and refresh doesn't need one. Only require - // that something is actually deployed to refresh. - const active = project.activeDeploymentId - ? await repos.deployment.findById(project.activeDeploymentId).catch(() => null) - : null; - if (!active) { - throw new Error("Nothing to refresh yet — deploy the project first."); - } - commitSha = active.commitSha ?? commitSha; - commitMessage = commitMessage ?? active.commitMessage ?? undefined; + if (data.refresh && refreshActive) { + // Refresh never pulls new code. Keep the active commit only for display. + commitSha = refreshActive.commitSha ?? commitSha; + commitMessage = commitMessage ?? refreshActive.commitMessage ?? undefined; } // Fetch HEAD only for a deploy that actually needs SOURCE. A refresh must // never touch git; neither must a restore whose artifacts are all pinned (its @@ -2041,12 +2170,18 @@ export async function triggerDeployment( // leave forceAll=false with no subset → the compose build treats it as // "build everything" and re-clones — the exact opposite of a refresh. Fail // loudly instead. - if (target.length === 0) { - throw new Error("Nothing to refresh — no enabled services to re-apply config to."); + if (target.length === 0 && useServicePipeline) { + throw new AppError( + "Nothing to refresh — this services project has no enabled services to re-apply config to.", + 409, + ); } finalForceAll = false; - finalServiceIds = target; - refreshServiceIds = target; + // A single app has no service rows by design. Its refresh marker above + // drives retained-artifact reuse; leaving these undefined keeps it on the + // single-app pipeline without turning an empty subset into "build all". + finalServiceIds = target.length > 0 ? target : undefined; + refreshServiceIds = target.length > 0 ? target : undefined; } const dep = await createQueuedDeployment({ diff --git a/apps/api/src/modules/deployments/clone-plan.test.ts b/apps/api/src/modules/deployments/clone-plan.test.ts index 9cc6c2f29..7edcadaa0 100644 --- a/apps/api/src/modules/deployments/clone-plan.test.ts +++ b/apps/api/src/modules/deployments/clone-plan.test.ts @@ -9,6 +9,7 @@ const base: ClonePlanInput = { buildStrategy: "server", isDesktop: true, repoIsGithub: true, + dockerTransport: "ssh", }; describe("resolveClonePlan — relayEligible (forward is the default on desktop)", () => { @@ -38,4 +39,12 @@ describe("resolveClonePlan — relayEligible (forward is the default on desktop) }).relayEligible, ).toBe(false); }); + + it("is NOT eligible when Docker uses a local socket with no target clone channel", () => { + const plan = resolveClonePlan({ ...base, dockerTransport: "socket" }); + expect(plan.sourceLocation).toBe("api-host"); + expect(plan.cloneRunsOnTarget).toBe(false); + expect(plan.cloneCredentialPurpose).toBe("local"); + expect(plan.relayEligible).toBe(false); + }); }); diff --git a/apps/api/src/modules/deployments/clone-plan.ts b/apps/api/src/modules/deployments/clone-plan.ts index 637101b88..28ba19ab7 100644 --- a/apps/api/src/modules/deployments/clone-plan.ts +++ b/apps/api/src/modules/deployments/clone-plan.ts @@ -3,7 +3,8 @@ * what credential that clone needs. * * This was previously derived independently in two places — the build pipeline - * (`cloneOnServer` + the git-token purpose) and preflight (`dockerClonesOnServer` + * (the adapter's `cloneOnServer` flag + git-token purpose) and preflight + * (`dockerClonesOnTarget` * + the remote-clone credential checks). Because the same decision was computed * from slightly different expressions, they drifted: preflight would pass a * config the pipeline then rejected (e.g. an api-host clone that preflight knew @@ -12,7 +13,7 @@ * pipeline will do. * * The "credential actually available? → fall back to api-host" adjustment stays - * in the pipeline (`effectiveCloneOnServer`) because it depends on the resolved + * in the pipeline (`effectiveCloneOnTarget`) because it depends on the resolved * token, which is runtime state, not config. */ @@ -49,21 +50,25 @@ export interface ClonePlanInput { * adapter re-validates the URL (github + https) before downloading and falls * back to clone. Local/imported projects → false → unchanged. */ repoIsGithub?: boolean; + /** How Docker reaches its daemon. Source can be prepared on the target only + * when that transport also carries a command executor (SSH). Socket/TCP + * builds receive a context prepared on the API host. Omitted for non-Docker + * runtimes and retained as SSH-compatible for legacy callers. */ + dockerTransport?: "socket" | "ssh" | "tcp"; } export interface ClonePlan { - /** The clone runs directly on the deploy server — bare always, docker on the - * explicit "clone on the server" opt-in. (Pipeline's `cloneOnServer`.) */ - runsOnServer: boolean; - /** The DOCKER-only on-server clone (excludes bare, which has its own hard-fail + /** Physical auth/filesystem boundary where source acquisition happens. */ + sourceLocation: "api-host" | "target" | "cloud-workspace"; + /** The clone runs through the target's command executor. Bare server builds + * always do; Docker only can when its daemon transport is SSH. */ + cloneRunsOnTarget: boolean; + /** The DOCKER-only target clone (excludes bare, which has its own hard-fail * preflight checks). This is preflight's warn-case. */ - dockerClonesOnServer: boolean; - /** The clone runs on the api-host / orchestrator (local to it) — so the local - * gh identity is valid and no shippable token is required. */ - runsLocally: boolean; + dockerClonesOnTarget: boolean; /** BuildStrategy to resolve the clone credential with (resolveBuildGitToken): * "local" → local gh / broad resolver chain; "server" → shippable App/PAT. */ - cloneBuildStrategy: "local" | "server"; + cloneCredentialPurpose: "local" | "server"; /** Desktop relay eligible: forward the operator's gh identity to the server for * an on-server clone (nothing persisted). Requires the desktop app + opt-in. */ relayEligible: boolean; @@ -89,26 +94,36 @@ export function relayConfigEligible(input: { export function resolveClonePlan(input: ClonePlanInput): ClonePlan { const onServer = input.effectiveTarget === "server"; + // Only an SSH Docker transport has both a remote daemon AND a command channel + // that can acquire source beside it. A socket/TCP transport can build there, + // but its context must be prepared by the API host. Undefined preserves the + // old remote-SSH assumption for callers that do not construct Docker runtimes. + const dockerCanCloneOnTarget = + input.dockerTransport === undefined || input.dockerTransport === "ssh"; + const localDockerBuildRequested = + input.buildStrategy === "local" && input.cloneStrategy !== "server"; - // Docker acquires source ON THE SERVER when the deploy opted in + // Docker acquires source ON THE TARGET when the deploy opted in // (cloneStrategy="server") OR the repo is a GitHub HTTPS remote — the server // downloads the tarball directly, skipping the orchestrator clone + context // transfer. Bare has its own always-on-server path (below), so it's excluded - // here. Whether it truly runs on the server still hinges on a shippable - // credential; effectiveCloneOnServer degrades to an api-host clone otherwise - // (allowApiHostFallback is driven by dockerClonesOnServer). + // here. Whether it truly runs on the target still hinges on a shippable + // credential; effectiveCloneOnTarget degrades to an api-host clone otherwise + // (allowApiHostFallback is driven by dockerClonesOnTarget). const dockerServerSide = onServer && !input.runtimeIsBare && + dockerCanCloneOnTarget && + !localDockerBuildRequested && (input.cloneStrategy === "server" || input.repoIsGithub === true); - // Pipeline: the clone runs on the server (bare always; docker per above). - const runsOnServer = + // Pipeline: the clone runs on the target (bare always; docker per above). + const cloneRunsOnTarget = onServer && !!input.serverId && (input.runtimeIsBare || dockerServerSide); // Preflight warn-case + api-host-fallback gate: DOCKER (non-bare) acquiring on // the server. Bare is handled by the separate hard-fail remote-build checks. - const dockerClonesOnServer = dockerServerSide; + const dockerClonesOnTarget = dockerServerSide; // The clone's credential purpose follows WHERE THE CLONE RUNS, not where the // build runs: a local build clones on this machine, and a server deploy that @@ -133,21 +148,24 @@ export function resolveClonePlan(input: ClonePlanInput): ClonePlan { // because the rule lived inline in preflight and not in the shared plan. Keep the // two agreeing: a local target is a host-local clone on BOTH sides. // - // runsLocally MUST imply !runsOnServer — otherwise a contradictory config + // apiHostClone MUST imply !cloneRunsOnTarget — otherwise a contradictory config // (buildStrategy="local" + cloneStrategy="server") would tag an on-server clone // as local and ship the operator's local gh/OAuth token off-host to the remote. - const runsLocally = - !runsOnServer && (input.effectiveTarget !== "cloud" || input.buildStrategy === "local"); + const sourceLocation: ClonePlan["sourceLocation"] = cloneRunsOnTarget + ? "target" + : input.effectiveTarget === "cloud" && input.buildStrategy !== "local" + ? "cloud-workspace" + : "api-host"; return { - runsOnServer, - dockerClonesOnServer, - runsLocally, - cloneBuildStrategy: runsLocally ? "local" : "server", + sourceLocation, + cloneRunsOnTarget, + dockerClonesOnTarget, + cloneCredentialPurpose: sourceLocation === "api-host" ? "local" : "server", // Forward is the DEFAULT for a desktop server clone (secure + atomic: clone // on the build host with the operator's gh identity, nothing persisted), // opt-out via forwardGitCredentials === false. Real capability (SSH tunnel + // local gh) is verified at runtime; this is the config-level eligibility. - relayEligible: runsOnServer && relayConfigEligible(input), + relayEligible: cloneRunsOnTarget && relayConfigEligible(input), }; } diff --git a/apps/api/src/modules/deployments/compose-configuration-error.ts b/apps/api/src/modules/deployments/compose-configuration-error.ts new file mode 100644 index 000000000..89210d3ef --- /dev/null +++ b/apps/api/src/modules/deployments/compose-configuration-error.ts @@ -0,0 +1,14 @@ +/** + * A repository Compose file was read successfully enough to determine that its + * requested deployment cannot be represented safely. + * + * Callers may retry ordinary source/network failures with the last imported + * service shape, but must never do that for this error: deploying stale Compose + * configuration after the file changed is less safe than refusing the deploy. + */ +export class ComposeConfigurationError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "ComposeConfigurationError"; + } +} diff --git a/apps/api/src/modules/deployments/compose/build.service.test.ts b/apps/api/src/modules/deployments/compose/build.service.test.ts index 10b28e3c4..bf055a7bb 100644 --- a/apps/api/src/modules/deployments/compose/build.service.test.ts +++ b/apps/api/src/modules/deployments/compose/build.service.test.ts @@ -19,7 +19,7 @@ vi.mock("../session-manager", () => ({ broadcastInstallPhase: vi.fn(), })); -import { buildComposeImages } from "./build.service"; +import { buildComposeImages, resolveComposeBuildArgs } from "./build.service"; /** * These pin the AUTHOR-FACING contract of an inline catalog build (`advanced.build`): @@ -82,10 +82,12 @@ function repoService( kind: string; build: string | null; dockerfile: string | null; + buildArgs: Record | null; rootDirectory: string | null; buildCommand: string | null; startCommand: string | null; framework: string | null; + advanced: { buildArgTemplateKeys?: string[] } | null; }> = {}, ) { // Spread, not `??` per field: an explicit `build: null` (a monorepo row) has to @@ -124,6 +126,7 @@ async function run( services: unknown[], onContext?: (root: string, item: Captured) => void | Promise, snapshotOverrides?: Partial, + buildEnvVars: Record = {}, ) { listByProjectMock.mockResolvedValue(services); const captured: Captured[] = []; @@ -162,7 +165,7 @@ async function run( logger: new BuildLogger(() => {}), snapshot: { ...SNAPSHOT, ...snapshotOverrides } as never, buildSessionId: "sess", - buildEnvVars: {}, + buildEnvVars, buildResources: DEFAULT_RESOURCE_CONFIG, }); return { result, captured }; @@ -314,6 +317,85 @@ describe("buildComposeImages — inline catalog build materialization", () => { * These pin the producer side: which rows get a narrowed context, and which must not. */ describe("buildComposeImages — declared build context", () => { + it("isolates build args for services sharing one context and Dockerfile (#689)", async () => { + const shared = { + build: "../../", + dockerfile: "services/shared/Dockerfile", + }; + const { captured } = await run( + [ + repoService({ + id: "svc-api", + name: "api", + ...shared, + buildArgs: { + APP_PACKAGE: "@myorg/api", + SHARED_VERSION: null, + CHANNEL: "${RELEASE_CHANNEL:-stable}", + VERSIONED: "pkg-${SHARED_VERSION}", + }, + advanced: { buildArgTemplateKeys: ["CHANNEL", "VERSIONED"] }, + }), + repoService({ + id: "svc-worker", + name: "worker", + ...shared, + buildArgs: { APP_PACKAGE: "@myorg/worker", UNSET: null }, + }), + ], + undefined, + { rootDirectory: "deploy/docker-compose" }, + { SHARED_VERSION: "1.2.3" }, + ); + + expect(captured).toHaveLength(2); + expect(captured.map(({ config }) => config.buildContextDirectory)).toEqual(["", ""]); + expect(captured.map(({ config }) => config.dockerfilePath)).toEqual([ + "services/shared/Dockerfile", + "services/shared/Dockerfile", + ]); + expect(captured[0].config.buildArgs).toEqual({ + APP_PACKAGE: "@myorg/api", + SHARED_VERSION: "1.2.3", + CHANNEL: "stable", + VERSIONED: "pkg-1.2.3", + }); + expect(captured[1].config.buildArgs).toEqual({ APP_PACKAGE: "@myorg/worker" }); + }); + + it("reports missing required build-arg variables without values", () => { + expect(() => + resolveComposeBuildArgs({ TOKEN: "${BUILD_TOKEN:?set BUILD_TOKEN}" }, {}, ["TOKEN"]), + ).toThrow(/BUILD_TOKEN/); + }); + + it("resolves only raw-parser templates against the invocation environment", () => { + expect( + resolveComposeBuildArgs( + { + A: "one", + B: "${A:-two}", + C: "${HOST}", + SELF: "${SELF:-fallback}", + CLI_LITERAL: "$HOME", + ESCAPED: "$$HOME", + }, + { HOST: "host", SELF: "env-self", HOME: "/private/home" }, + ["B", "C", "SELF", "ESCAPED"], + ), + ).toEqual({ + A: "one", + // Compose uses the invocation environment, not sibling build args. + B: "two", + C: "host", + SELF: "env-self", + // CLI-normalized/direct values are literal even when they contain `$`. + CLI_LITERAL: "$HOME", + // Raw Compose `$$` becomes one literal dollar exactly once. + ESCAPED: "$HOME", + }); + }); + it("hands the runtime the service's build directory as the docker context", async () => { const { captured } = await run([repoService()]); diff --git a/apps/api/src/modules/deployments/compose/build.service.ts b/apps/api/src/modules/deployments/compose/build.service.ts index 7999bb38a..9654e87c1 100644 --- a/apps/api/src/modules/deployments/compose/build.service.ts +++ b/apps/api/src/modules/deployments/compose/build.service.ts @@ -17,7 +17,7 @@ import type { BuildResult, } from "@repo/adapters"; import { BuildLogger, STATIC_RELEASE_BASE } from "@repo/adapters"; -import type { ComposeAdvanced } from "@repo/core"; +import { composeBuildIssues, type ComposeAdvanced } from "@repo/core"; import { repos, type Deployment, type Project, type Service } from "@repo/db"; import { @@ -35,6 +35,7 @@ import { resolveSubAppRecipe, } from "../../../lib/deployable-service"; import { normalizeProjectRootDirectory } from "../../../lib/project-root-detector"; +import { resolveComposeEnvironmentTemplates } from "../../../lib/compose-parser"; import { resolveServicePort } from "./domain-helpers"; function sanitizeComposeImageName(value: string): string { @@ -46,6 +47,41 @@ function sanitizeComposeImageName(value: string): string { ); } +/** Resolve Compose build args against this deployment's final build environment. + * Bare/null keys are omitted when unavailable (preserving Dockerfile defaults), + * while persisted `${...}` expressions are evaluated here rather than leaking or + * freezing a scan-time `.env` value. */ +export function resolveComposeBuildArgs( + raw: Record | null | undefined, + buildEnv: Record, + templateKeys: readonly string[] = [], +): Record | undefined { + if (!raw) return undefined; + const resolved: Record = {}; + const missing = new Set(); + const templates = new Set(templateKeys); + for (const [key, value] of Object.entries(raw)) { + if (value === null) { + if (Object.hasOwn(buildEnv, key)) resolved[key] = buildEnv[key]!; + } else if (templates.has(key)) { + // Compose interpolates every args value against the invocation environment + // independently. Sibling args are not variables (`A=one`, `B=${A:-two}` + // yields B=two unless the invocation env itself defines A). + const dynamic = resolveComposeEnvironmentTemplates(buildEnv, { [key]: value }); + for (const item of dynamic.missingRequired) missing.add(item.variable); + resolved[key] = dynamic.env[key] ?? ""; + } else { + resolved[key] = value; + } + } + if (missing.size > 0) { + throw new Error( + `Compose build arguments require missing variable(s): ${[...missing].join(", ")}`, + ); + } + return Object.keys(resolved).length > 0 ? resolved : undefined; +} + /** A catalog template ships this service's Docker build context INLINE * (`advanced.build`) — no repo, no pullable image. */ function hasInlineBuild(service: { advanced: unknown }): boolean { @@ -174,8 +210,8 @@ async function materializeInlineBuildContexts(buildable: Service[]): Promise segment.length > 0 && segment !== ".", ); @@ -196,9 +237,9 @@ export function resolveComposeBuildContext(composeDirectory: string, context: st resolved.push(segment); continue; } - // `..` above the clone root has nothing to resolve against — keep the compose - // directory rather than emitting a path outside the checkout. - if (resolved.length === 0) return normalizedDirectory; + if (resolved.length === 0) { + throw new Error("Invalid Compose build context: path escapes the linked repository."); + } resolved.pop(); } @@ -510,13 +551,10 @@ export async function buildComposeImages(opts: { // the root too: its context is the materialized root and the per-service subdir // rides in the Dockerfile path, which is what makes a template author's // `COPY /` resolve. - const buildContextDirectory = - !inlineBuild && service.build != null ? context : undefined; + const buildContextDirectory = !inlineBuild && service.build != null ? context : undefined; const dockerfile = inlineBuild?.dockerfile ?? service.dockerfile; const from = - buildContextDirectory !== undefined - ? `build context ${context || "."}` - : (context || "."); + buildContextDirectory !== undefined ? `build context ${context || "."}` : context || "."; opts.logger.log( `Building ${isMonorepo ? "monorepo app" : "compose service"} "${service.name}" from ${from}${dockerfile ? ` using ${dockerfile}` : ""}...\n`, "info", @@ -614,6 +652,11 @@ export async function buildComposeImages(opts: { rootDirectory: context, buildContextDirectory, dockerfilePath: dockerfile ?? undefined, + buildArgs: resolveComposeBuildArgs( + service.buildArgs, + opts.buildEnvVars, + service.advanced?.buildArgTemplateKeys, + ), // Inline catalog build: the source IS the materialized root, so // prepareSourceTree copies it instead of cloning a repo. ...(inlineBuild ? { localPath: inlineBuild.root } : {}), diff --git a/apps/api/src/modules/deployments/compose/carried-host-port.test.ts b/apps/api/src/modules/deployments/compose/carried-host-port.test.ts index 9b9b50797..398e3807b 100644 --- a/apps/api/src/modules/deployments/compose/carried-host-port.test.ts +++ b/apps/api/src/modules/deployments/compose/carried-host-port.test.ts @@ -48,18 +48,16 @@ describe("pickHostPort's preferred-port contract", () => { }); describe("the deploy routes the carried port through the allocator", () => { - const src = readFileSync( - new URL("./deploy.service.ts", import.meta.url), - "utf8", - ); + const src = readFileSync(new URL("./deploy.service.ts", import.meta.url), "utf8"); /** The loopback-port allocation block, bounded by its own loop. */ const block = (() => { const from = src.indexOf("for (const containerPort of routedContainerPorts) {"); - return src.slice(from, src.indexOf("usedHostPorts.add(hostPort);", from)); + return src.slice(from, src.indexOf("serviceRuntimeConfig.ports =", from)); })(); it("passes the carried port as `preferred`, not as the answer", () => { - expect(block).toContain("preferred: carried"); + expect(block).toContain("cachedPreferred"); + expect(block).toContain("allocateAndReservePinnedHostPort"); }); it("no longer short-circuits the allocator when a carried port exists", () => { @@ -68,8 +66,11 @@ describe("the deploy routes the carried port through the allocator", () => { expect(block).not.toMatch(/if \(carried\) \{\s*hostPort = carried;/); }); - it("still avoids ports this same deploy already handed out", () => { - expect(block).toContain("avoid: usedHostPorts"); + it("still avoids durable host claims and ports this same deploy already handed out", () => { + expect(block).toContain("claims: pinnedHostPortClaims"); + expect(block).toContain("allocatedHostPorts"); + expect(block).toContain("additionalAvoid: allocatedHostPorts"); + expect(block).toContain("pinnedHostPortClaims.push(allocation.claim)"); }); it("says so when it had to move a carried port", () => { @@ -83,4 +84,23 @@ describe("the deploy routes the carried port through the allocator", () => { // that case now returns the carried port, so the warning is the only signal. expect(block).toContain("allocation.scanned"); }); + + it("migrates/converges the edge before importing its ports into allocation", () => { + const edgePreflight = src.indexOf("const edge = await ensureEdge("); + const strictInventory = src.indexOf("await prepareTargetPinnedHostPorts({"); + + expect(edgePreflight).toBeGreaterThan(-1); + expect(strictInventory).toBeGreaterThan(edgePreflight); + expect(src.slice(edgePreflight, strictInventory)).toContain("ensureRoutingReady"); + }); + + it("validates every resolved loopback target under its service owner before routing", () => { + const resolverStart = src.indexOf("resolveTargetUrl:", src.indexOf("const deployEnv")); + const resolverEnd = src.indexOf("const deployResult = await runDeployPipeline", resolverStart); + const resolver = src.slice(resolverStart, resolverEnd); + + expect(resolver).toContain("reserveResolvedLoopbackRoutes"); + expect(resolver).toContain("serviceId: svc.id"); + expect(resolver).toContain("containerPort: port"); + }); }); diff --git a/apps/api/src/modules/deployments/compose/deploy.service.ts b/apps/api/src/modules/deployments/compose/deploy.service.ts index dc78f6aea..0267d6ea8 100644 --- a/apps/api/src/modules/deployments/compose/deploy.service.ts +++ b/apps/api/src/modules/deployments/compose/deploy.service.ts @@ -40,8 +40,11 @@ import { BareRuntime, BuildLogger, DockerRuntime, + ensureEdge, + ownsBuiltImage, STATIC_RELEASE_BASE, allocateHostPort, + edgeProxyFor, rootOrDegrade, resolveEnvironment, runDeployPipeline, @@ -53,6 +56,7 @@ import { type MultiServiceDeployConfig, type MultiServiceDeployResult, type MultiServiceRuntimeAdapter, + type PromptUserFn, type ResourceConfig, type RouteRegistrationOptions, type RoutingProvider, @@ -64,11 +68,7 @@ import { isLoopbackHost, resolveServerHost } from "../../../lib/server-target"; import { resolveEdgeTargetHost } from "../../../lib/edge-target"; import { containerIdForService } from "../../services/service-container"; import { isConnectionLoss } from "../../../lib/remote-state"; -import { - appConfigHostPath, - withAppConfigHost, - writeAppConfigFile, -} from "./app-config-host"; +import { appConfigHostPath, withAppConfigHost, writeAppConfigFile } from "./app-config-host"; import { auditRoutedDomainTls, buildProjectRouteDomains, @@ -87,10 +87,26 @@ import { } from "../../../lib/public-endpoints"; import { ensureManagedEdgeProxy } from "../../../lib/managed-edge-proxy"; import { ensureRoutingReady } from "../../../lib/edge-reconcile"; +import { resolveAcmeProviderOptions } from "../../../lib/acme-config"; import * as sessionManager from "../session-manager"; -import { isStaticService, parseServicePort, serviceAliasExtras } from "../../../lib/deployable-service"; +import { + isStaticService, + parseServicePort, + serviceAliasExtras, +} from "../../../lib/deployable-service"; import { computeKeepSet } from "../image-gc"; import { auditPorts } from "../port-audit.service"; +import { + allocateAndReservePinnedHostPort, + convergeTargetHostPortClaims, + convergeTargetHostPortClaimsUnlocked, + prepareTargetPinnedHostPorts, + releaseNewPinnedHostPortClaims, + withHostPortTargetLock, + type AllocatedPinnedHostPort, +} from "../pinned-host-ports"; +import type { HostPortTargetIdentity } from "../../../lib/host-port-target"; +import { reserveResolvedLoopbackRoutes } from "../observed-host-port-claims"; import { recordUnstableServices, verifyDeployedContainers, @@ -99,10 +115,7 @@ import { } from "../stability-audit.service"; import { resolveReadinessGate, type ResolvedReadinessGate } from "../readiness-gate"; import { probeDeployedReadiness } from "../readiness-probe"; -import { - hostChannelDeployNotice, - type PortCheckResult, -} from "../../../lib/deployment-runtime"; +import { hostChannelDeployNotice, type PortCheckResult } from "../../../lib/deployment-runtime"; import { buildProjectServiceUpstream, describeCandidatePorts, @@ -113,17 +126,19 @@ import { resolveServicePort } from "./domain-helpers"; import { mergeServiceDeployEnv } from "./service-env-layers"; import { compileProjectRoutingFields } from "../../../lib/project-routing-fields"; import { buildCompositeRegistration, buildDomainFanoutRegistrations } from "./composite-route"; +import { collectComposeRoutePortDemands } from "./route-port-demands"; import { newerThanRestoredRelease, serviceKind } from "./project-services"; import { OUT_OF_SCOPE_SKIP_REASON, isUntargetedAndUndeployable, resolveDeployImage, } from "./service-scope"; -import { buildUpstreamUrl, resolveRouteStrategy } from "../../../lib/upstream-url"; import { - withLoopbackPublishAll, - upstreamHostPortFor, -} from "../../../lib/loopback-publish"; + buildUpstreamUrl, + resolveRouteStrategy, + usesHostLoopbackUpstream, +} from "../../../lib/upstream-url"; +import { withLoopbackPublishAll, upstreamHostPortFor } from "../../../lib/loopback-publish"; export interface ComposeDeployResult { /** `reconciling` when at least one service's outcome is UNKNOWN because the @@ -160,7 +175,7 @@ export interface ComposeDeployResult { containerId?: string; status: string; ip?: string; - /** The ONE host port `service_deployment` persists — the pinned/primary one. */ + /** Compatibility scalar for the pinned/primary host port. */ hostPort?: number; /** * Every published binding, keyed by CONTAINER port → host port. @@ -168,8 +183,8 @@ export interface ComposeDeployResult { * The scalar above cannot answer "what is port N published on" for a container * publishing several: it is the pinned PRIMARY, so a project-level route on any * other port was dialed at the primary's publish and reached a different app. - * `service_deployment` holds one number, so this lives on the in-memory result - * and is re-read live by the routing paths that run later. + * Persisted in `service_deployment.host_ports` and also carried on the live + * result so routing in this pass does not need to read it back. */ hostPortByContainerPort?: Record; error?: string; @@ -580,7 +595,6 @@ function resolveServiceResources( }; } - function createServiceRuntimeConfig(opts: { project: Project; dep: Deployment; @@ -726,12 +740,13 @@ async function prepareServiceRoutes(opts: { const domainKey = route.hostname.toLowerCase(); const beforeRecord = routeContext.domainByHostname.get(domainKey); try { - const domainRecord = await ensureRouteDomainRecord({ + const ensureResult = await ensureRouteDomainRecord({ projectId: project.id, route, domainByHostname: routeContext.domainByHostname, }); - if (!beforeRecord && domainRecord) { + const domainRecord = ensureResult.domain; + if (ensureResult.created && domainRecord) { logger.log(`Created domain record for "${route.hostname}".\n`, "info", { serviceName: service.name, }); @@ -760,48 +775,85 @@ async function prepareServiceRoutes(opts: { return { routes: ensured, warnings }; } +export interface ComposeDeployOptions { + builtImages?: Map; + buildFailures?: Map; + resources?: ResourceConfig; + buildSessionId?: string; + routing?: RoutingProvider; + ssl?: SslProvider; + system?: SystemManager | null; + usesManagedRouting?: boolean; + serverId?: string; + /** Smart (partial) redeploy: recreate ONLY these services; leave the + * rest running and carry their previous runtime row forward. Undefined + * = full deploy (recreate every enabled service). */ + targetServiceIds?: Set; + /** Decoupled single-service provision (add/Start one app, reusing the + * ACTIVE deployment id — not a fresh one). Strictly scopes the run to + * `targetServiceIds`: non-targets are never (re)deployed, marked + * unavailable, or reaped, and the target's row is UPSERTed (the reused + * deployment id may already carry a row for it). Never set by the full/ + * partial deploy pipeline (which always runs against a fresh deployment). */ + strictScope?: boolean; + routeOptions?: RouteRegistrationOptions; + /** Target host command executor (SSH for a server, local for this machine). + * Used to write an app template's generated config files (`advanced.files`) + * onto the Docker host so they can be bind-mounted read-only into the + * service. Null on cloud (no host bind-mount) → file services are skipped. */ + executor?: CommandExecutor | null; + /** The deploy target is the machine this process runs on (`platform.localHost`). + * Host-path writes then go through the host channel instead of `executor`, + * which for a plain local target is a LocalExecutor — the CONTAINER's own + * filesystem on a Compose install. */ + localHost?: boolean; + /** Physical TCP bind namespace resolved from the actual deployment target. */ + hostPortTarget?: HostPortTargetIdentity | null; + /** Interactive edge-conflict hold for a full project deploy. Direct service + * starts omit it and fail closed instead of guessing about a foreign proxy. */ + promptUser?: PromptUserFn; +} + /** * Deploy all services for a compose project. * Called from the compose pipeline after the build phase. + * + * Allocation, Docker bind, route registration, and persistence are one + * target-serialized critical section. This wrapper is intentionally the lock + * boundary so direct single-service Start/Add calls cannot bypass it. */ export async function deployComposeServices( project: Project, dep: Deployment, runtime: MultiServiceRuntimeAdapter, logger: BuildLogger, - opts?: { - builtImages?: Map; - buildFailures?: Map; - resources?: ResourceConfig; - buildSessionId?: string; - routing?: RoutingProvider; - ssl?: SslProvider; - system?: SystemManager | null; - usesManagedRouting?: boolean; - serverId?: string; - /** Smart (partial) redeploy: recreate ONLY these services; leave the - * rest running and carry their previous runtime row forward. Undefined - * = full deploy (recreate every enabled service). */ - targetServiceIds?: Set; - /** Decoupled single-service provision (add/Start one app, reusing the - * ACTIVE deployment id — not a fresh one). Strictly scopes the run to - * `targetServiceIds`: non-targets are never (re)deployed, marked - * unavailable, or reaped, and the target's row is UPSERTed (the reused - * deployment id may already carry a row for it). Never set by the full/ - * partial deploy pipeline (which always runs against a fresh deployment). */ - strictScope?: boolean; - routeOptions?: RouteRegistrationOptions; - /** Target host command executor (SSH for a server, local for this machine). - * Used to write an app template's generated config files (`advanced.files`) - * onto the Docker host so they can be bind-mounted read-only into the - * service. Null on cloud (no host bind-mount) → file services are skipped. */ - executor?: CommandExecutor | null; - /** The deploy target is the machine this process runs on (`platform.localHost`). - * Host-path writes then go through the host channel instead of `executor`, - * which for a plain local target is a LocalExecutor — the CONTAINER's own - * filesystem on a Compose install. */ - localHost?: boolean; - }, + opts?: ComposeDeployOptions, +): Promise { + const needsHostPortLock = usesHostLoopbackUpstream( + resolveRouteStrategy(project.routeStrategy), + runtime, + ); + if (!needsHostPortLock) { + return deployComposeServicesUnlocked(project, dep, runtime, logger, opts); + } + if (!opts?.executor) { + throw new Error("Cannot deploy loopback-routed services without a physical target executor"); + } + const target = opts?.hostPortTarget; + if (!target) { + throw new Error("Cannot deploy loopback-routed services without a physical host identity"); + } + return withHostPortTargetLock(target, () => + deployComposeServicesUnlocked(project, dep, runtime, logger, opts), + ); +} + +async function deployComposeServicesUnlocked( + project: Project, + dep: Deployment, + runtime: MultiServiceRuntimeAdapter, + logger: BuildLogger, + opts?: ComposeDeployOptions, ): Promise { // Generated app secrets, BEFORE any env is read below. A catalog app whose install died // part-way keeps a service row with the generated values missing, and the installer only @@ -865,6 +917,13 @@ export async function deployComposeServices( const hostNotice = hostChannelDeployNotice(opts?.executor); if (hostNotice) logger.log(`${hostNotice}\n`, "warn"); + const routeStrategy = resolveRouteStrategy(project.routeStrategy); + const usesHostLoopback = usesHostLoopbackUpstream(routeStrategy, runtime); + // All route writers use the same effective topology that drove the pre-bind + // lock, inventory, and allocation. The stored preference can say + // `container-ip` while a bare/no-containerIp runtime still requires loopback. + const upstreamStrategy = usesHostLoopback ? "loopback-port" : "container-ip"; + logger.log("Preparing shared service group for project services...\n"); const group = await runtime.ensureServiceGroup({ @@ -924,20 +983,38 @@ export async function deployComposeServices( usesManagedRouting: opts.usesManagedRouting ?? false, }), ]; + const needsStrictLoopbackInventory = usesHostLoopback && Boolean(opts.executor); await opts.system.ensureFeature("deploy", systemLog); // Routing/SSL toolchain is best-effort — domains are optional, so failing to // install OpenResty/certbot must NOT fail the deploy. The services still run; // routing is flagged action-required and retried later. try { - if (plannedRoutes.length > 0) { + if (plannedRoutes.length > 0 || needsStrictLoopbackInventory) { // Components + edge convergence as ONE step — see ensureRoutingReady for why // the second half can't live inside ensureFeature. Without an executor // there's no box to converge (cloud), so components alone are correct. if (opts.executor) { - await ensureRoutingReady(opts.executor, opts.system, { - onLog: systemLog, - }); + const edge = await ensureEdge( + opts.executor, + (promptUser) => + ensureRoutingReady(opts.executor!, opts.system!, { + onLog: systemLog, + promptUser, + }), + { + promptUser: opts.promptUser, + onLog: systemLog, + nginx: resolveAcmeProviderOptions(), + }, + ); + if (edge.migrated && !edge.ok) { + logger.log( + "Edge migration failed and the previous proxy was restored. " + + "Loopback-routed services will not allocate a port unless its routes can be inventoried safely.\n", + "warn", + ); + } } else { await opts.system.ensureFeature("routing", systemLog); } @@ -957,7 +1034,9 @@ export async function deployComposeServices( } } - const projectEnvMap = await repos.project.getEnvMap(project.id, dep.environment); + // Service-scoped rows are loaded separately below and must not leak into the + // project layer or another service. + const projectEnvMap = await repos.project.getEnvMap(project.id, dep.environment, null); const decryptedProjectEnv = decryptEnvMap(projectEnvMap, (key) => { logger.log(`Warning: failed to decrypt project env var "${key}", skipping.\n`, "warn"); }); @@ -1099,6 +1178,23 @@ export async function deployComposeServices( }; } + // Compute the COMPLETE loopback demand before any container starts. Service + // hostnames are only one source: project domains, the monorepo composite, and + // migration fan-out can all dial an otherwise-unexposed service. Those routes + // are registered after the service loop, which is too late to add a publish or + // reserve it safely; their ports must enter the same allocation path now. + const hostLoopbackRoutePortDemands = + routeContext && usesHostLoopback + ? collectComposeRoutePortDemands({ + project, + services: enabled, + domainRows: [...domainByHostname.values()], + previousRows: previousServiceDeps, + runtimeName: runtime.name, + usesManagedRouting: routeContext.usesManagedRouting, + }) + : new Map>(); + const results: ComposeDeployResult["services"] = []; const portChecks: PortCheckResult[] = []; /** Exposed services to port-probe, collected in the deploy loop and run together @@ -1119,6 +1215,11 @@ export async function deployComposeServices( // Per-domain routing failures across all services (domains are optional — // never fatal). Aggregated into the deployment's routing action-required signal. const composeRouteWarnings: string[] = []; + let hostPortClaimWarning: string | undefined; + // Claim convergence is the final ownership cutover. If an obsolete + // workload cannot be stopped, its route may still be restored later, so its + // host-port ownership must remain reserved even though the new routes are up. + let hostPortClaimReapSafe = true; let successful = 0; /** * Names of services a scoped deploy left alone because it did not target them and they @@ -1201,14 +1302,25 @@ export async function deployComposeServices( composeRouteWarnings.push(message); }; - // loopback-port routing (compose): host ports pinned this deploy, so two - // services in the same pass never collide on an allocation. Seed with every - // previous service's port so a fresh allocation never lands on one that a - // later service is about to reuse. - const usedHostPorts = new Set(); - for (const prev of previousByServiceId.values()) { - if (prev.hostPort) usedHostPorts.add(prev.hostPort); - } + // Durable claims cover stopped/crashed containers that a live socket scan + // cannot see. They are host-scoped: two different servers may safely use the + // same loopback port. Allocations from THIS pass are tracked separately so a + // carried claim can be released only for its owner without erasing a sibling. + const hostPortTarget = opts?.hostPortTarget ?? null; + const pinnedHostPortClaims = + usesHostLoopback && opts?.executor + ? hostPortTarget + ? await prepareTargetPinnedHostPorts({ + target: hostPortTarget, + edgeProxy: edgeProxyFor(opts.executor, "openresty", { ours: true }), + }) + : (() => { + throw new Error( + "Cannot allocate a loopback-routed service port without a physical host identity", + ); + })() + : []; + const allocatedHostPorts = new Set(); // #438: app-template config files (`advanced.files`) are host-side state living // under `/var/lib/openship`, the root-owned tree the edge's own vhosts sit in. @@ -1308,6 +1420,11 @@ export async function deployComposeServices( ? null : (carried.hostPort ?? live.hostPort) : (carried.hostPort ?? null); + const carriedHostPorts = live + ? Object.keys(live.hostPortByContainerPort ?? {}).length > 0 + ? (live.hostPortByContainerPort ?? null) + : null + : (carried.hostPorts ?? null); await repos.service.upsertServiceDeployment({ deploymentId: dep.id, serviceId: svc.id, @@ -1316,6 +1433,7 @@ export async function deployComposeServices( status: "success", imageRef: carried.imageRef ?? null, hostPort: carriedHostPort, + hostPorts: carriedHostPorts, ip: carriedIp, }); // The #506 correction above has to land on the ACTIVE deployment's row too: @@ -1328,10 +1446,16 @@ export async function deployComposeServices( // scanner needs to see a moved mutable tag — on the LIVE release's row. if ( project.activeDeploymentId !== dep.id && - (carriedIp !== (carried.ip ?? null) || carriedHostPort !== (carried.hostPort ?? null)) + (carriedIp !== (carried.ip ?? null) || + carriedHostPort !== (carried.hostPort ?? null) || + JSON.stringify(carriedHostPorts ?? {}) !== JSON.stringify(carried.hostPorts ?? {})) ) { await repos.service - .updateServiceDeployment(carried.id, { ip: carriedIp, hostPort: carriedHostPort }) + .updateServiceDeployment(carried.id, { + ip: carriedIp, + hostPort: carriedHostPort, + hostPorts: carriedHostPorts, + }) .catch(() => {}); } // Decoupled single-service add on a mesh runtime (cloud): this peer is @@ -1354,6 +1478,9 @@ export async function deployComposeServices( status: carried.status, ip: carriedIp ?? undefined, hostPort: carriedHostPort ?? undefined, + ...(carriedHostPorts + ? { hostPortByContainerPort: carriedHostPorts as Record } + : {}), carried: true, }); // A carried-forward container is a valid namespace provider — it's running, @@ -1505,10 +1632,41 @@ export async function deployComposeServices( project: decryptedProjectEnv, frozen: depEnv, inline: (svc.environment as Record) ?? {}, + templateKeys: svc.advanced?.environmentTemplateKeys, service: decryptedServiceEnv, }, frozenEnvWins, ); + if (layered.missingRequired.length > 0) { + const names = [...new Set(layered.missingRequired.map((item) => item.variable))]; + const message = + `Required Compose environment ${names.length === 1 ? "variable is" : "variables are"} ` + + `not configured: ${names.join(", ")}`; + logger.log(`Service "${svc.name}" failed: ${message}\n`, "error", { + serviceName: svc.name, + }); + sessionManager.broadcastServiceStatus(dep.id, { + serviceName: svc.name, + serviceId: svc.id, + status: "failed", + error: message, + }); + await repos.service.markServiceDeploymentFailed({ + deploymentId: dep.id, + serviceId: svc.id, + serviceName: svc.name, + imageRef: opts?.builtImages?.get(svc.id) ?? svc.image ?? null, + errorMessage: message, + }); + results.push({ + serviceId: svc.id, + serviceName: svc.name, + status: "failed", + error: message, + }); + unavailableServiceNames.add(svc.name); + continue; + } // Say so when a variable is not what any UI shows. The service Env tab and // the wizard both keep rendering the empty value this merge ignored, so the // deploy log is the only surface that can explain the container — same @@ -1533,7 +1691,9 @@ export async function deployComposeServices( svc.name, `no public URL is known for ${unresolvedEnvUrls .map((u) => `${u.key}=${u.tokens.join("")}`) - .join(", ")} — ${unresolvedEnvUrls.length === 1 ? "that variable is" : "those variables are"} left UNSET rather than blank`, + .join( + ", ", + )} — ${unresolvedEnvUrls.length === 1 ? "that variable is" : "those variables are"} left UNSET rather than blank`, ); } @@ -1974,7 +2134,10 @@ export async function deployComposeServices( // just belongs on the service that owns the interfaces, and the log says so. if (hasNoRoutableAddress) { const providers = composeNamespaceDependencies(svc.advanced as ComposeAdvanced | null); - const where = providers.length > 0 ? `shares ${providers.join(", ")}'s network namespace` : "has no network of its own"; + const where = + providers.length > 0 + ? `shares ${providers.join(", ")}'s network namespace` + : "has no network of its own"; logger.log( `Service "${svc.name}" ${where}, so it has no address to route to — skipping its ` + `domains and its published ports. Move them to the service it shares.\n`, @@ -2033,76 +2196,114 @@ export async function deployComposeServices( // pinning only `proxyRoutes[0]` while `resolveTargetUrl` returned that single // port for every route made each extra subdomain silently proxy to the FIRST // route's port. minio's s3 host served the console; convex's http host served - // the 3210 API. Only the primary port is persisted (`service_deployment` - // holds one), which is why the extras are re-pinned and re-registered on - // every deploy rather than carried. - const composeRouteStrategy = resolveRouteStrategy(project.routeStrategy); + // the 3210 API. Every mapping is now claimed and persisted, so stopped + // secondary routes are just as durable as the primary. const routedContainerPorts = [ ...new Set( - proxyRoutes - .map((r) => r.targetPort) - .filter((p): p is number => typeof p === "number" && p > 0), + [ + ...proxyRoutes.map((route) => route.targetPort), + ...(hostLoopbackRoutePortDemands.get(svc.id) ?? []), + ].filter((port): port is number => typeof port === "number" && port > 0), ), ]; const primaryRoutedPort = routedContainerPorts[0]; /** routed container port → the loopback host port WE pinned for it. */ const pinnedHostPortByContainerPort = new Map(); + const serviceHostPortAllocations: Array< + Pick + > = []; let servicePinnedHostPort: number | undefined; if ( - composeRouteStrategy === "loopback-port" && - runtime.name !== "cloud" && + usesHostLoopback && primaryRoutedPort !== undefined && // A container with no endpoint of its own publishes nothing — allocating a // host port would burn it and pin a route to an upstream that never binds. !hasNoRoutableAddress && opts?.executor ) { - for (const containerPort of routedContainerPorts) { - // Only the primary reuses the carried port: it is the one persisted, so - // it is the only one whose previous value is knowable. - const carried = - containerPort === primaryRoutedPort - ? previousByServiceId.get(svc.id)?.hostPort - : undefined; - /** - * A carried port is a PREFERENCE, never a given. - * - * It used to be taken verbatim whenever one existed, which is right for the case it was - * written for — a redeploy on the same host, where the port was ours and still is. It is - * wrong the moment the host changes: a MIGRATION carries the source's port to a target - * that knows nothing about it, and if anything there holds it Docker refuses the bind - * with "port is already allocated" and the service (plus everything depending on it) - * fails. A host port is a property of the HOST, not of the project, so it cannot travel - * with one. - * - * `preferred` is the allocator's own word for exactly this: keep it if it's free, pick - * another if it isn't. Passing it there rather than branching around the allocator means - * one rule for both cases and no second place that decides what a free port is. - */ - const allocation = await allocateHostPort(opts.executor, { - preferred: carried, - avoid: usedHostPorts, - }); - const hostPort = allocation.port; - if (carried && hostPort !== carried) { - logger.log( - `Host port ${carried} for ${svc.name} is taken on this server — using ${hostPort}. ` + - `(Expected when a project moves to a different host.)\n`, - ); - } - // "Couldn't read occupancy" is not "nothing is listening" — without this the - // bind failure that follows blames Docker for an unreachable host (#490). - if (!allocation.scanned) { - logger.log( - `Couldn't read live port occupancy on the target, so ${allocation.port} for ` + - `${svc.name} avoids only ports this deploy already took. If publishing it fails ` + - `as "already allocated", check that Openship can reach this host ` + - `(Servers → this box).\n`, - "warn", - ); + try { + for (const containerPort of routedContainerPorts) { + // Every routed port is persisted now. Legacy releases know only the + // primary scalar, so that remains the fallback for old rows. + const previousRow = previousByServiceId.get(svc.id); + const owner = { + projectId: project.id, + serviceId: svc.id, + containerPort, + } as const; + const mapped = previousRow?.hostPorts?.[String(containerPort)]; + const cachedPreferred = + typeof mapped === "number" && mapped > 0 + ? mapped + : containerPort === primaryRoutedPort + ? previousRow?.hostPort + : undefined; + /** + * A carried port is a PREFERENCE, never a given. + * + * It used to be taken verbatim whenever one existed, which is right for the case it was + * written for — a redeploy on the same host, where the port was ours and still is. It is + * wrong the moment the host changes: a MIGRATION carries the source's port to a target + * that knows nothing about it, and if anything there holds it Docker refuses the bind + * with "port is already allocated" and the service (plus everything depending on it) + * fails. A host port is a property of the HOST, not of the project, so it cannot travel + * with one. + * + * `preferred` is the allocator's own word for exactly this: keep it if it's free, pick + * another if it isn't. Passing it there rather than branching around the allocator means + * one rule for both cases and no second place that decides what a free port is. + */ + const allocation = await allocateAndReservePinnedHostPort({ + target: hostPortTarget!, + claims: pinnedHostPortClaims, + owner, + cachedPreferred, + // A scalar has no container-port identity. It represented the primary + // route historically, so never let it stand in for a secondary route. + allowLegacyContainerPort: containerPort === primaryRoutedPort, + additionalAvoid: allocatedHostPorts, + allocate: (allocationOptions) => allocateHostPort(opts.executor!, allocationOptions), + }); + serviceHostPortAllocations.push(allocation); + const carried = allocation.preferred; + const hostPort = allocation.port; + if (carried && hostPort !== carried) { + logger.log( + `Host port ${carried} for ${svc.name} is taken on this server — using ${hostPort}. ` + + `(Expected when a project moves to a different host.)\n`, + ); + } + // "Couldn't read occupancy" is not "nothing is listening" — without this the + // bind failure that follows blames Docker for an unreachable host (#490). + if (!allocation.scanned) { + logger.log( + `Couldn't read live port occupancy on the target, so ${allocation.port} for ` + + `${svc.name} avoids database-pinned ports and ports this deploy already took. ` + + `If publishing it fails ` + + `as "already allocated", check that Openship can reach this host ` + + `(Servers → this box).\n`, + "warn", + ); + } + + // Keep this pass's newly committed claim visible to the next service. + pinnedHostPortClaims.push(allocation.claim); + allocatedHostPorts.add(hostPort); + pinnedHostPortByContainerPort.set(containerPort, hostPort); } - usedHostPorts.add(hostPort); - pinnedHostPortByContainerPort.set(containerPort, hostPort); + } catch (allocationError) { + // No container or route exists yet. Roll back only reservations this + // attempt created; carried claims may still protect an older vhost. + await releaseNewPinnedHostPortClaims(hostPortTarget!, serviceHostPortAllocations).catch( + (releaseError) => + logger.log( + `Warning: failed to release unrouted host-port reservations for "${svc.name}": ` + + `${safeErrorMessage(releaseError)}\n`, + "warn", + { serviceName: svc.name }, + ), + ); + throw allocationError; } serviceRuntimeConfig.ports = withLoopbackPublishAll( serviceRuntimeConfig.ports, @@ -2113,9 +2314,12 @@ export async function deployComposeServices( let deployedContainerId: string | undefined; let deployedContainerCleaned = false; + // Kept outside the try so a connection loss after Docker returned can still + // persist every binding reported before the transport disappeared. + let serviceResult: MultiServiceDeployResult | undefined; + let pipelineReachedReady = false; try { const previous = previousByServiceId.get(svc.id); - let serviceResult: MultiServiceDeployResult | undefined; const serviceLogger = createServicePipelineLogger(logger, svc.name, svc.id); const routeDomains = toRoutedDomainInputs(proxyRoutes); const deployEnv: DeployEnvironment = { @@ -2137,7 +2341,6 @@ export async function deployComposeServices( resolveTargetUrl: proxyRoutes.length > 0 ? async (containerId, port) => { - const strategy = resolveRouteStrategy(project.routeStrategy); const sameSvc = serviceResult?.containerId === containerId; // Prefer the port WE pinned+published for THIS container port // (deterministic); fall back to the port the deploy result @@ -2151,13 +2354,27 @@ export async function deployComposeServices( resultHostPort: serviceResult?.hostPort, sameService: sameSvc, }); - // loopback-port → the service's published host port; else the - // container IP (cached from the deploy result when we can). - if (strategy === "loopback-port" && hostPort) { - return buildUpstreamUrl({ strategy, hostPort, containerPort: port }); - } - const ip = sameSvc ? serviceResult?.ip : await runtime.getContainerIp(containerId); - return buildUpstreamUrl({ strategy, ip, hostPort, containerPort: port }); + // The effective topology, not only the stored preference, decides + // whether to dial the reserved publish or the container IP. + const targetUrl = + usesHostLoopback && hostPort + ? buildUpstreamUrl({ + strategy: upstreamStrategy, + hostPort, + containerPort: port, + }) + : buildUpstreamUrl({ + strategy: upstreamStrategy, + ip: sameSvc ? serviceResult?.ip : await runtime.getContainerIp(containerId), + hostPort, + containerPort: port, + }); + await reserveResolvedLoopbackRoutes({ + target: hostPortTarget, + projectId: project.id, + routes: [{ targetUrl, serviceId: svc.id, containerPort: port }], + }); + return targetUrl; } : undefined, }; @@ -2216,6 +2433,7 @@ export async function deployComposeServices( } throw new Error(deployResult.error ?? `Failed to deploy service "${svc.name}"`); } + pipelineReachedReady = true; const result = serviceResult ?? { containerId: deployResult.containerId!, @@ -2225,6 +2443,10 @@ export async function deployComposeServices( // happened to report first, so the persisted value matches the live route // and the next redeploy reuses the same target. const persistedHostPort = servicePinnedHostPort ?? result.hostPort ?? null; + const persistedHostPorts: Record = { + ...(serviceResult?.hostPortByContainerPort ?? {}), + ...Object.fromEntries(pinnedHostPortByContainerPort), + }; // UPSERT, never a plain insert — a row for this (deployment, service) pair may // already exist by the time we get here, from either of two writers: @@ -2244,6 +2466,7 @@ export async function deployComposeServices( imageRef: image, imageDigest: result.imageDigest ?? null, hostPort: persistedHostPort, + hostPorts: Object.keys(persistedHostPorts).length > 0 ? persistedHostPorts : null, ip: result.ip ?? null, }); @@ -2257,13 +2480,9 @@ export async function deployComposeServices( // The per-port map the runtime reported, UNIONED with the pins this pass // published — the pins are what the vhosts dial, and they are the answer for // any port docker had not bound yet when it was inspected. - ...(() => { - const byPort: Record = { - ...(serviceResult?.hostPortByContainerPort ?? {}), - ...Object.fromEntries(pinnedHostPortByContainerPort), - }; - return Object.keys(byPort).length ? { hostPortByContainerPort: byPort } : {}; - })(), + ...(Object.keys(persistedHostPorts).length > 0 + ? { hostPortByContainerPort: persistedHostPorts } + : {}), }); // Now resolvable as a namespace provider for the services after it. Set only // on success: a dependent must never be pointed at a container that failed. @@ -2322,7 +2541,12 @@ export async function deployComposeServices( // tag, so two deployment rows legitimately reference one image; removing // "the previous one" then deletes an image another retained release (or the // one we just restored FROM, if the user rolls forward again) still needs. - if (previous?.imageRef && previous.imageRef !== image && runtime instanceof DockerRuntime) { + if ( + previous?.imageRef && + previous.imageRef !== image && + runtime instanceof DockerRuntime && + ownsBuiltImage(previous.imageRef) + ) { const keep = await retentionKeepSet(); if (keep.has(previous.imageRef)) { logger.log( @@ -2403,6 +2627,11 @@ export async function deployComposeServices( // Upsert for the same reason as the success write above: this pair may already // carry a pre-created `skipped` row, and a unique violation here would replace an // unknown-but-probably-fine outcome with a hard deploy failure. + const indeterminateHostPorts: Record = { + ...(serviceResult?.hostPortByContainerPort ?? {}), + ...Object.fromEntries(pinnedHostPortByContainerPort), + }; + const indeterminateHostPort = servicePinnedHostPort ?? serviceResult?.hostPort ?? null; await repos.service.upsertServiceDeployment({ deploymentId: dep.id, serviceId: svc.id, @@ -2410,17 +2639,27 @@ export async function deployComposeServices( containerId: deployedContainerId, status: "indeterminate", imageRef: image, + hostPort: indeterminateHostPort, + hostPorts: Object.keys(indeterminateHostPorts).length > 0 ? indeterminateHostPorts : null, }); results.push({ serviceId: svc.id, serviceName: svc.name, containerId: deployedContainerId, status: "indeterminate", + hostPort: indeterminateHostPort ?? undefined, + ...(Object.keys(indeterminateHostPorts).length > 0 + ? { hostPortByContainerPort: indeterminateHostPorts } + : {}), }); indeterminateServiceNames.add(svc.name); } else { if (deployedContainerId && !deployedContainerCleaned) { - await runtime.destroy(deployedContainerId).catch((destroyErr) => { + try { + await runtime.destroy(deployedContainerId); + deployedContainerCleaned = true; + } catch (destroyErr) { + hostPortClaimReapSafe = false; const destroyMessage = destroyErr instanceof Error ? destroyErr.message : "Unknown error"; logger.log( @@ -2430,7 +2669,32 @@ export async function deployComposeServices( serviceName: svc.name, }, ); - }); + } + } + if (!pipelineReachedReady && hostPortTarget && serviceHostPortAllocations.length > 0) { + if (!deployedContainerId) { + // Activation never returned a workload id, so no route could have + // been resolved or written. This is the one post-allocation failure + // boundary where direct rollback is provably pre-route. + await releaseNewPinnedHostPortClaims(hostPortTarget, serviceHostPortAllocations).catch( + (releaseError) => + logger.log( + `Warning: failed to release unrouted host-port reservations for "${svc.name}": ` + + `${safeErrorMessage(releaseError)}\n`, + "warn", + { serviceName: svc.name }, + ), + ); + } else { + // Once activation returned, retain the reservation even when the + // best-effort destroy succeeded. A later full strict convergence + // can prove the edge/workload transition; this catch block cannot. + logger.log( + `Host-port reservations for "${svc.name}" were retained until the next successful reconciliation.\n`, + "warn", + { serviceName: svc.name }, + ); + } } logger.log(`Service "${svc.name}" failed: ${message}\n`, "error", { serviceName: svc.name, @@ -2476,9 +2740,7 @@ export async function deployComposeServices( const probes = Promise.all( portAuditTargets.map(async (target) => { const [pc] = await auditPorts(runtime, target.containerId, [target.port], logger); - return pc - ? { ...pc, serviceId: target.serviceId, serviceName: target.serviceName } - : null; + return pc ? { ...pc, serviceId: target.serviceId, serviceName: target.serviceName } : null; }), ); const audited = await Promise.race([ @@ -2575,9 +2837,7 @@ export async function deployComposeServices( f.target.serviceId && readinessByServiceId.get(f.target.serviceId)?.onFailure === "fail", ); - for (const finding of findings.filter( - (f) => !f.verdict.ok && !vetoing.includes(f), - )) { + for (const finding of findings.filter((f) => !f.verdict.ok && !vetoing.includes(f))) { // "warn": say what didn't hold, but leave the service's deploy result alone // so the stack stays up. Opting into the watch to get the signal must not // also opt into a veto. @@ -2839,9 +3099,7 @@ export async function deployComposeServices( const domainRows = needsDomainMap ? [...domainByHostname.values()] : await repos.domain.listByProject(project.id).catch(() => []); - const candidates = enabled.filter((svc) => - withContainer.some((r) => r.serviceId === svc.id), - ); + const candidates = enabled.filter((svc) => withContainer.some((r) => r.serviceId === svc.id)); const primaryId = pickPrimaryServiceId(candidates, domainRows); return ( withContainer.find((r) => r.serviceId === primaryId)?.containerId ?? @@ -2935,7 +3193,7 @@ export async function deployComposeServices( // just created and their publishing is what is about to be persisted. The port's // owning service is picked by the resolver the live re-apply shares. const resolved = buildProjectServiceUpstream({ - strategy: resolveRouteStrategy(project.routeStrategy), + strategy: upstreamStrategy, port: route.targetPort, services: enabled, rowByService: projectUpstreamRows, @@ -2970,6 +3228,17 @@ export async function deployComposeServices( ); seenRouteDomains.add(routeKey); try { + await reserveResolvedLoopbackRoutes({ + target: hostPortTarget, + projectId: project.id, + routes: [ + { + targetUrl: resolved.url, + serviceId: resolved.owner.serviceId, + containerPort: resolved.owner.containerPort, + }, + ], + }); await routeContext.routing.registerRoute({ domain: route.hostname, targetUrl: resolved.url, @@ -3035,17 +3304,37 @@ export async function deployComposeServices( try { // Reusable routing core (shared with the routing API): resolve each // service's live upstream from this deploy's results. + const resolvedRouteOwners = new Map< + string, + { + targetUrl: string; + serviceId: string; + containerPort: number; + } + >(); const resolveTargetUrl = (serviceId: string) => { const svc = enabled.find((s) => s.id === serviceId); const res = results.find((r) => r.serviceId === serviceId); - const port = svc ? resolveServicePublicPort(svc) : undefined; + // Composite/fan-out config itself is the exposure demand. Do not gate it + // on the service owning a separate hostname (`service.exposed`): project + // routes intentionally reach internal services. + const port = svc ? (resolveServicePort(svc, project.port) ?? undefined) : undefined; if (!port) return null; - return buildUpstreamUrl({ - strategy: resolveRouteStrategy(project.routeStrategy), + const targetUrl = buildUpstreamUrl({ + strategy: upstreamStrategy, ip: res?.ip, hostPort: res?.hostPort, + hostPorts: res?.hostPortByContainerPort, containerPort: port, }); + if (targetUrl) { + resolvedRouteOwners.set(`${serviceId}\0${port}`, { + targetUrl, + serviceId, + containerPort: port, + }); + } + return targetUrl; }; const composite = buildCompositeRegistration({ services: enabled, @@ -3071,6 +3360,18 @@ export async function deployComposeServices( : null; }, }); + const fanoutRegistrations = buildDomainFanoutRegistrations({ + routes: project.compositeRoutes, + resolveTargetUrl, + }); + // Resolve every topology-aware target first, then validate the complete + // set before the first vhost is mutated. A conflict cannot leave half of a + // composite/fan-out route set pointing at an unowned loopback port. + await reserveResolvedLoopbackRoutes({ + target: hostPortTarget, + projectId: project.id, + routes: resolvedRouteOwners.values(), + }); if (composite) { const r = composite.register; await routeContext.routing.registerRoute({ @@ -3110,15 +3411,15 @@ export async function deployComposeServices( // These hostnames ARE project domains, so the live path (project-route.service) puts // the project's vercel.json rules on them. Spread them here too or the deploy would // strip what a live re-apply installed. - for (const reg of buildDomainFanoutRegistrations({ - routes: project.compositeRoutes, - resolveTargetUrl, - })) { + for (const reg of fanoutRegistrations) { // CONCATENATED, not overwritten: spreading the fan-out's locations after the // compiled ones would ASSIGN over them, silently dropping a vercel.json external // rewrite on a path-routed domain. Fan-out first, so its explicit per-path // upstreams are matched ahead of a broader compiled rule. - const proxyLocations = [...(reg.proxyLocations ?? []), ...(routingFields.proxyLocations ?? [])]; + const proxyLocations = [ + ...(reg.proxyLocations ?? []), + ...(routingFields.proxyLocations ?? []), + ]; await routeContext.routing.registerRoute({ domain: reg.hostname, tls: true, @@ -3153,6 +3454,7 @@ export async function deployComposeServices( mutated = true; logger.log(`Stopped disabled service container (${previous.containerId.slice(0, 12)}).\n`); } catch (err) { + hostPortClaimReapSafe = false; const message = err instanceof Error ? err.message : "Unknown error"; logger.log(`Warning: failed to stop disabled service container: ${message}\n`, "warn"); } @@ -3191,12 +3493,81 @@ export async function deployComposeServices( mutated = true; logger.log(`Stopped previous single-app container (${prevContainerId.slice(0, 12)}).\n`); } catch (err) { + hostPortClaimReapSafe = false; const message = err instanceof Error ? err.message : "Unknown error"; logger.log(`Warning: failed to stop previous single-app container: ${message}\n`, "warn"); } } } + // Every route writer and every old-workload reap has now settled. Only here + // can a superseded claim be released: before this point an old vhost can still + // dial its port, or a failed reap can leave a workload that a later repair may + // put back behind that vhost. + // + // Do not converge an indeterminate deployment. A dropped connection means we + // do not know which route writes reached the target, so retaining every claim + // until reconciliation is the only safe answer. + const hasIndeterminateHostPortOutcome = results.some( + (result) => result.status === "indeterminate", + ); + if (hostPortTarget && opts?.executor && successful > 0 && !opts?.strictScope) { + if (hasIndeterminateHostPortOutcome) { + logger.log( + "Host-port reservation cleanup deferred until deployment reconciliation; all reservations were retained.\n", + "warn", + ); + } else if (!hostPortClaimReapSafe) { + hostPortClaimWarning = + "Host-port reservation cleanup was deferred because an obsolete workload could not be stopped; reservations were retained safely."; + logger.log(`${hostPortClaimWarning}\n`, "warn"); + } else { + // Build the desired set only after the final successful route writers and + // reaps. A result recorded earlier is not sufficient authority to release + // ownership while either of those later stages is still pending. + const resultByServiceId = new Map(results.map((result) => [result.serviceId, result])); + const desiredPublishes = usesHostLoopback + ? [...hostLoopbackRoutePortDemands].flatMap(([serviceId, containerPorts]) => { + const result = resultByServiceId.get(serviceId); + if (!result || result.status === "failed") return []; + return [...containerPorts].flatMap((containerPort, index) => { + const mappedHostPort = result.hostPortByContainerPort?.[containerPort]; + // Legacy releases persisted only one scalar. It is attributable + // only when this service has exactly one routed demand; with two + // or more ports there is no safe way to know which one it means. + const hostPort = + mappedHostPort ?? + (containerPorts.size === 1 && index === 0 ? result.hostPort : undefined); + return hostPort !== undefined ? [{ serviceId, containerPort, hostPort }] : []; + }); + }) + : []; + const convergence = { + target: hostPortTarget, + projectId: project.id, + desiredPublishes, + edgeProxy: edgeProxyFor(opts.executor, "openresty", { ours: true }), + }; + try { + // The public wrapper took the target lock for the current loopback + // topology. A container-IP/static transition did not, and must acquire + // it here while converging to an intentionally empty desired set. + const converged = usesHostLoopback + ? await convergeTargetHostPortClaimsUnlocked(convergence) + : await convergeTargetHostPortClaims(convergence); + if (converged.released > 0) { + logger.log( + `Released ${converged.released} obsolete host-port reservation${converged.released === 1 ? "" : "s"}.\n`, + ); + } + } catch (error) { + hostPortClaimWarning = + "Host-port reservation cleanup was deferred; uncertain reservations were retained safely."; + logger.log(`${hostPortClaimWarning} ${safeErrorMessage(error)}\n`, "warn"); + } + } + } + // Routed, but is it actually serving HTTPS? One shared auditor with the // single-app pipeline — `composeRouteWarnings` is passed so a host already // reported as UNROUTED isn't also reported as routed-without-a-cert. @@ -3231,6 +3602,7 @@ export async function deployComposeServices( ? stabilityWarnings.join("; ") : undefined, skipNotice, + hostPortClaimWarning, ] .filter(Boolean) .join("; ") || undefined; diff --git a/apps/api/src/modules/deployments/compose/pipeline-cancel.test.ts b/apps/api/src/modules/deployments/compose/pipeline-cancel.test.ts index 81d34c7c7..aa5d644b0 100644 --- a/apps/api/src/modules/deployments/compose/pipeline-cancel.test.ts +++ b/apps/api/src/modules/deployments/compose/pipeline-cancel.test.ts @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ onReconciling: vi.fn(async () => {}), onSuccess: vi.fn(async () => {}), setDeploymentStatus: vi.fn(async () => {}), + promptUser: vi.fn(async () => "migrate"), })); vi.mock("@repo/db", () => ({ repos: {} })); @@ -30,6 +31,7 @@ vi.mock("../deployment-lifecycle", () => ({ vi.mock("../session-manager", () => ({ broadcastServiceStatus: vi.fn(), broadcastInstallPhase: vi.fn(), + promptUser: mocks.promptUser, })); import { executeComposePipeline } from "./pipeline"; @@ -147,5 +149,9 @@ describe("executeComposePipeline — cancellation", () => { expect(mocks.onCancelled).not.toHaveBeenCalled(); expect(mocks.setDeploymentStatus).toHaveBeenCalledWith("d1", "deploying", expect.anything()); + const deployOpts = mocks.deployComposeServices.mock.calls[0]?.[4]; + const prompt = { promptId: "edge_conflict" }; + await expect(deployOpts.promptUser(prompt)).resolves.toBe("migrate"); + expect(mocks.promptUser).toHaveBeenCalledWith("d1", prompt); }); }); diff --git a/apps/api/src/modules/deployments/compose/pipeline.ts b/apps/api/src/modules/deployments/compose/pipeline.ts index 5c581a8bf..fba79eb79 100644 --- a/apps/api/src/modules/deployments/compose/pipeline.ts +++ b/apps/api/src/modules/deployments/compose/pipeline.ts @@ -45,6 +45,7 @@ import { composeDeployMadeNoChanges, deployComposeServices } from "./deploy.serv import { COMPOSE_SENTINEL } from "../../../lib/container-ref"; import { safeErrorMessage } from "@repo/core"; import * as sessionManager from "../session-manager"; +import type { HostPortTargetIdentity } from "../../../lib/host-port-target"; export interface ComposePipelineOpts { project: Project; @@ -62,6 +63,8 @@ export interface ComposePipelineOpts { /** The target IS this machine (`platform.localHost`) — host-path writes go * through the host channel, not `executor`. */ localHost?: boolean; + /** Physical TCP bind namespace resolved from the actual deployment target. */ + hostPortTarget?: HostPortTargetIdentity | null; usesManagedRouting: boolean; logger: BuildLogger; ctx: LifecycleContext; @@ -99,6 +102,7 @@ export async function executeComposePipeline(opts: ComposePipelineOpts): Promise system, executor, localHost, + hostPortTarget, usesManagedRouting, logger, ctx, @@ -163,7 +167,10 @@ export async function executeComposePipeline(opts: ComposePipelineOpts): Promise for (const [serviceId, imageRef] of composeBuild.builtImageRefs) { await cleanupBuildArtifact(runtime, imageRef).catch((err) => { const detail = safeErrorMessage(err); - logger.log(`Warning: failed to clean up built service image ${serviceId}: ${detail}\n`, "warn"); + logger.log( + `Warning: failed to clean up built service image ${serviceId}: ${detail}\n`, + "warn", + ); }); } await onCancelled(ctx, composeBuild.durationMs); @@ -193,6 +200,8 @@ export async function executeComposePipeline(opts: ComposePipelineOpts): Promise system, executor, localHost, + hostPortTarget, + promptUser: (prompt) => sessionManager.promptUser(dep.id, prompt), usesManagedRouting, serverId: snapshot.serverId, targetServiceIds, @@ -223,7 +232,10 @@ export async function executeComposePipeline(opts: ComposePipelineOpts): Promise for (const [serviceId, imageRef] of composeBuild.builtImageRefs) { await cleanupBuildArtifact(runtime, imageRef).catch((err) => { const detail = safeErrorMessage(err); - logger.log(`Warning: failed to clean up built service image ${serviceId}: ${detail}\n`, "warn"); + logger.log( + `Warning: failed to clean up built service image ${serviceId}: ${detail}\n`, + "warn", + ); }); } await onFailure(ctx, composeResult.error ?? "Compose deploy failed", composeBuild.durationMs); @@ -249,7 +261,10 @@ export async function executeComposePipeline(opts: ComposePipelineOpts): Promise if (deployedServiceIds.has(serviceId)) continue; await cleanupBuildArtifact(runtime, imageRef).catch((err) => { const detail = safeErrorMessage(err); - logger.log(`Warning: failed to clean up unused service image ${serviceId}: ${detail}\n`, "warn"); + logger.log( + `Warning: failed to clean up unused service image ${serviceId}: ${detail}\n`, + "warn", + ); }); } @@ -259,10 +274,7 @@ export async function executeComposePipeline(opts: ComposePipelineOpts): Promise // → project attention + Domains-tab dot), cleared by Retry routing / next deploy. const routingWarning = composeResult.routeWarnings?.length || composeResult.tlsPendingDomains?.length - ? routeIssuesWarning( - composeResult.routeWarnings ?? [], - composeResult.tlsPendingDomains ?? [], - ) + ? routeIssuesWarning(composeResult.routeWarnings ?? [], composeResult.tlsPendingDomains ?? []) : undefined; const successWarning = routingWarning ?? composeResult.warning; sessionManager.broadcastInstallPhase(dep.id, { id: "ready", status: "done" }); @@ -300,5 +312,3 @@ export async function executeComposePipeline(opts: ComposePipelineOpts): Promise }, }); } - - diff --git a/apps/api/src/modules/deployments/compose/project-services.test.ts b/apps/api/src/modules/deployments/compose/project-services.test.ts new file mode 100644 index 000000000..103192161 --- /dev/null +++ b/apps/api/src/modules/deployments/compose/project-services.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { repos } = vi.hoisted(() => ({ + repos: { + service: { listByProject: vi.fn() }, + }, +})); + +vi.mock("@repo/db", async (importOriginal) => ({ + ...(await importOriginal>()), + repos, +})); + +import { isMultiServiceProject, shouldUseProjectServicePipeline } from "./project-services"; + +describe("composePath service-pipeline bootstrap (#689)", () => { + beforeEach(() => { + vi.clearAllMocks(); + repos.service.listByProject.mockResolvedValue([]); + }); + + it("treats an explicit composePath as service topology before rows exist", async () => { + const project = { + id: "project-1", + framework: "docker", + composePath: "deploy/stack.yml", + } as any; + + expect(isMultiServiceProject(project)).toBe(true); + await expect(shouldUseProjectServicePipeline(project)).resolves.toBe(true); + }); + + it("keeps an ordinary Dockerfile project on the single-app pipeline", async () => { + const project = { + id: "project-1", + framework: "docker", + composePath: null, + } as any; + + expect(isMultiServiceProject(project)).toBe(false); + await expect(shouldUseProjectServicePipeline(project)).resolves.toBe(false); + }); + + it("does not let retained disabled service rows hijack a single-app deploy", async () => { + repos.service.listByProject.mockResolvedValue([ + { id: "service-disabled", kind: "compose", enabled: false }, + ]); + const project = { + id: "project-1", + framework: "docker", + composePath: null, + } as any; + + await expect(shouldUseProjectServicePipeline(project)).resolves.toBe(false); + }); + + it("uses the service pipeline when at least one retained service is enabled", async () => { + repos.service.listByProject.mockResolvedValue([ + { id: "service-disabled", kind: "compose", enabled: false }, + { id: "service-enabled", kind: "monorepo", enabled: true }, + ]); + const project = { + id: "project-1", + framework: "docker", + composePath: null, + } as any; + + await expect(shouldUseProjectServicePipeline(project)).resolves.toBe(true); + }); +}); diff --git a/apps/api/src/modules/deployments/compose/project-services.ts b/apps/api/src/modules/deployments/compose/project-services.ts index a4e9bd628..703632113 100644 --- a/apps/api/src/modules/deployments/compose/project-services.ts +++ b/apps/api/src/modules/deployments/compose/project-services.ts @@ -16,7 +16,14 @@ import { getProjectType, type ComposeAdvanced, type StackId } from "@repo/core"; import { serviceKind, type DeployableService } from "../../../lib/deployable-service"; export { serviceKind } from "../../../lib/deployable-service"; -export function isMultiServiceProject(project: Pick): boolean { +export function isMultiServiceProject( + project: Pick & { composePath?: string | null }, +): boolean { + // A declared compose file is authoritative project shape, even for a legacy + // row whose framework still says plain "docker" and has no service rows yet. + // This is also the bootstrap signal reconcileComposeDrift uses to create the + // first rows; without it composePath only affected scanning, not deployment. + if (project.composePath?.trim()) return true; const framework = project.framework as StackId | undefined; if (!framework) return false; @@ -55,41 +62,44 @@ export function projectServicesToDeployableServices( services: Service[], everDeployedByServiceId?: Map, ): DeployableService[] { - return services.map((s): DeployableService => ({ - kind: serviceKind(s), - everDeployed: everDeployedByServiceId?.get(s.id), - enabled: s.enabled, - name: s.name, - image: s.image ?? undefined, - build: s.build ?? undefined, - dockerfile: s.dockerfile ?? undefined, - ports: (s.ports as string[] | null) ?? [], - dependsOn: (s.dependsOn as string[] | null) ?? [], - environment: (s.environment as Record | null) ?? {}, - volumes: (s.volumes as string[] | null) ?? [], - command: s.command ?? undefined, - commandArgv: (s.commandArgv as string[] | null) ?? null, // #332 - restart: s.restart ?? undefined, - // Carried so the frozen `meta.composeServices` snapshot can replay a - // release's healthcheck / readiness / generated files / resource caps / - // east-west alias. Dropping it meant a rollback re-ran the release with - // those stripped. - advanced: (s.advanced as ComposeAdvanced | null) ?? undefined, - exposed: s.exposed, - exposedPort: s.exposedPort ?? undefined, - domain: s.domain ?? undefined, - customDomain: s.customDomain ?? undefined, - domainType: s.domainType === "custom" ? "custom" : "free", - publicEndpoints: (s.publicEndpoints as DeployableService["publicEndpoints"]) ?? undefined, - rootDirectory: s.rootDirectory ?? undefined, - installCommand: s.installCommand ?? undefined, - buildCommand: s.buildCommand ?? undefined, - startCommand: s.startCommand ?? undefined, - outputDirectory: s.outputDirectory ?? undefined, - framework: s.framework ?? undefined, - packageManager: s.packageManager ?? undefined, - buildImage: s.buildImage ?? undefined, - })); + return services.map( + (s): DeployableService => ({ + kind: serviceKind(s), + everDeployed: everDeployedByServiceId?.get(s.id), + enabled: s.enabled, + name: s.name, + image: s.image ?? undefined, + build: s.build ?? undefined, + dockerfile: s.dockerfile ?? undefined, + buildArgs: (s.buildArgs as Record | null) ?? undefined, + ports: (s.ports as string[] | null) ?? [], + dependsOn: (s.dependsOn as string[] | null) ?? [], + environment: (s.environment as Record | null) ?? {}, + volumes: (s.volumes as string[] | null) ?? [], + command: s.command ?? undefined, + commandArgv: (s.commandArgv as string[] | null) ?? null, // #332 + restart: s.restart ?? undefined, + // Carried so the frozen `meta.composeServices` snapshot can replay a + // release's healthcheck / readiness / generated files / resource caps / + // east-west alias. Dropping it meant a rollback re-ran the release with + // those stripped. + advanced: (s.advanced as ComposeAdvanced | null) ?? undefined, + exposed: s.exposed, + exposedPort: s.exposedPort ?? undefined, + domain: s.domain ?? undefined, + customDomain: s.customDomain ?? undefined, + domainType: s.domainType === "custom" ? "custom" : "free", + publicEndpoints: (s.publicEndpoints as DeployableService["publicEndpoints"]) ?? undefined, + rootDirectory: s.rootDirectory ?? undefined, + installCommand: s.installCommand ?? undefined, + buildCommand: s.buildCommand ?? undefined, + startCommand: s.startCommand ?? undefined, + outputDirectory: s.outputDirectory ?? undefined, + framework: s.framework ?? undefined, + packageManager: s.packageManager ?? undefined, + buildImage: s.buildImage ?? undefined, + }), + ); } /** @@ -130,9 +140,7 @@ export async function resolveProjectServicePreflightServices( // the dead-row preflight carve-out) from a fresh row about to be deployed // for the first time (still an unknown until it either succeeds or fails). const latestByService = await repos.serviceDeployment.latestByProject(projectId); - const everDeployedByServiceId = new Map( - services.map((s) => [s.id, latestByService.has(s.id)]), - ); + const everDeployedByServiceId = new Map(services.map((s) => [s.id, latestByService.has(s.id)])); return projectServicesToDeployableServices( services.filter((service) => service.enabled), everDeployedByServiceId, @@ -144,10 +152,14 @@ export async function shouldUseProjectServicePipeline( requestServices?: DeployableService[] | null, ): Promise { if (requestServices?.length) return true; - // Both compose AND monorepo rows trigger the unified pipeline. - if ((await listProjectComposeServices(project.id)).length > 0) return true; + // Both compose AND monorepo rows trigger the unified pipeline, but disabled + // rows are retained configuration rather than deployable topology. Counting + // them here routes a single-app project with only disabled sidecars into an + // empty service deploy (notably after switching it to a release image). + if ((await listProjectComposeServices(project.id)).some((service) => service.enabled !== false)) { + return true; + } // Fallback for compose projects that don't have synced service rows. return isMultiServiceProject(project); } - diff --git a/apps/api/src/modules/deployments/compose/route-port-demands.test.ts b/apps/api/src/modules/deployments/compose/route-port-demands.test.ts new file mode 100644 index 000000000..fb956471b --- /dev/null +++ b/apps/api/src/modules/deployments/compose/route-port-demands.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; + +import { collectComposeRoutePortDemands } from "./route-port-demands"; + +const project = { + id: "proj-a", + name: "app", + slug: "app", + port: null, + routeStrategy: "loopback-port", + routingConfig: null, + compositeRoutes: null, +}; + +const service = (overrides: Record = {}) => + ({ + id: "svc-web", + projectId: "proj-a", + name: "web", + kind: "compose", + enabled: true, + exposed: false, + exposedPort: null, + ports: ["8080:3000"], + publicEndpoints: null, + ...overrides, + }) as never; + +describe("collectComposeRoutePortDemands", () => { + it("pins an unexposed service reached only by a project-level published-port route", () => { + const demands = collectComposeRoutePortDemands({ + project: project as never, + services: [service()], + domainRows: [ + { + id: "dom-a", + projectId: "proj-a", + serviceId: null, + hostname: "app.example.com", + targetPort: 8080, + targetPath: null, + domainType: "custom", + verified: true, + isPrimary: true, + } as never, + ], + runtimeName: "docker", + usesManagedRouting: true, + }); + + // The domain names the declared host side (8080), but Docker and the edge + // need the claim for the container side (3000) that will be republished on a + // pinned loopback host port. + expect([...demands.get("svc-web")!]).toEqual([3000]); + }); + + it("unions service, composite, and fan-out demands without duplicates", () => { + const frontend = service({ + id: "svc-front", + name: "front", + kind: "monorepo", + framework: "vite", + exposed: true, + exposedPort: "4173", + ports: [], + customDomain: "app.example.com", + domainType: "custom", + }); + const backend = service({ + id: "svc-api", + name: "api", + kind: "monorepo", + framework: "express", + startCommand: "node server.js", + exposed: false, + ports: ["3000"], + }); + const worker = service({ id: "svc-worker", name: "worker", ports: ["9000"] }); + + const demands = collectComposeRoutePortDemands({ + project: { + ...project, + routingConfig: { rewrites: [{ source: "/api/(.*)", destination: "/api/$1" }] }, + compositeRoutes: [ + { + hostname: "fanout.example.com", + isCustomDomain: true, + rootServiceId: "svc-api", + locations: [{ pathPrefix: "/jobs", serviceId: "svc-worker" }], + }, + ], + } as never, + services: [frontend, backend, worker], + domainRows: [], + runtimeName: "docker", + usesManagedRouting: true, + }); + + expect([...demands.get("svc-api")!]).toEqual([3000]); + expect([...demands.get("svc-worker")!]).toEqual([9000]); + expect([...demands.get("svc-front")!]).toEqual([4173]); + }); +}); diff --git a/apps/api/src/modules/deployments/compose/route-port-demands.ts b/apps/api/src/modules/deployments/compose/route-port-demands.ts new file mode 100644 index 000000000..df0149aee --- /dev/null +++ b/apps/api/src/modules/deployments/compose/route-port-demands.ts @@ -0,0 +1,120 @@ +import type { Domain, Project, Service, ServiceDeployment } from "@repo/db"; + +import { resolveServicePort } from "../../../lib/deployable-service"; +import { + pickProjectPortOwner, + type UpstreamCandidateRow, +} from "../../../lib/project-service-upstream"; +import { buildProjectRouteDomains, buildServiceRouteDomains } from "../../../lib/routing-domains"; +import { planCompositeRoute } from "./composite-route"; + +export type ComposeRoutePortDemands = Map>; + +/** + * Every container port a self-hosted vhost can dial during a services deploy. + * + * Service domains are only one source. Project-level domains may target an + * unexposed service, while the vercel-style composite and migration fan-out + * vhosts can reach services which own no hostname of their own. Allocation must + * see the union before Docker starts; discovering one of these routes afterwards + * leaves a vhost pointing at an unclaimed port that can later be recycled. + */ +export function collectComposeRoutePortDemands(opts: { + project: Project; + services: Service[]; + domainRows: Domain[]; + previousRows?: ServiceDeployment[]; + runtimeName: string; + usesManagedRouting: boolean; +}): ComposeRoutePortDemands { + const { + project, + services, + domainRows, + previousRows = [], + runtimeName, + usesManagedRouting, + } = opts; + const demands: ComposeRoutePortDemands = new Map(); + const add = (serviceId: string, containerPort: number | null | undefined) => { + if ( + containerPort == null || + !Number.isSafeInteger(containerPort) || + containerPort < 1 || + containerPort > 65_535 + ) { + return; + } + const ports = demands.get(serviceId) ?? new Set(); + ports.add(containerPort); + demands.set(serviceId, ports); + }; + + const domainByHostname = new Map(domainRows.map((row) => [row.hostname.toLowerCase(), row])); + for (const service of services) { + for (const route of buildServiceRouteDomains({ + project, + service, + runtimeName, + usesManagedRouting, + domainByHostname, + })) { + add(service.id, route.targetPort); + } + } + + // `pickProjectPortOwner` deliberately requires a container-backed candidate on + // live route paths. During planning the new containers do not exist yet, so a + // sentinel marks every enabled definition as available while the previous row + // contributes any durable host-side mapping the operator may have selected. + const previousByService = new Map(previousRows.map((row) => [row.serviceId, row])); + const plannedRows = new Map( + services.map((service) => { + const previous = previousByService.get(service.id); + return [ + service.id, + { + serviceId: service.id, + containerId: previous?.containerId ?? `planned:${service.id}`, + hostPort: previous?.hostPort, + hostPorts: previous?.hostPorts, + }, + ]; + }), + ); + const projectRoutes = buildProjectRouteDomains({ + project, + projectDomains: domainRows, + runtimeName, + usesManagedRouting, + }); + for (const route of projectRoutes) { + if (route.targetPort == null) continue; + const owner = pickProjectPortOwner({ + port: route.targetPort, + services, + rowByService: plannedRows, + domainRows, + }); + if (owner) add(owner.serviceId, owner.containerPort); + } + + // A composite's backend is always proxied even though the static frontend is + // served from disk. The persisted fan-out is explicit routing configuration: + // root and path services are route demands independently of `service.exposed`. + const composite = planCompositeRoute(services, { + rewrites: project.routingConfig?.rewrites, + }); + if (composite) { + const backend = services.find((service) => service.id === composite.backendServiceId); + if (backend) add(backend.id, resolveServicePort(backend, project.port)); + } + for (const route of project.compositeRoutes ?? []) { + for (const serviceId of [route.rootServiceId, ...route.locations.map((loc) => loc.serviceId)]) { + const service = services.find((candidate) => candidate.id === serviceId); + if (service) add(service.id, resolveServicePort(service, project.port)); + } + } + + return demands; +} diff --git a/apps/api/src/modules/deployments/compose/service-env-layers.ts b/apps/api/src/modules/deployments/compose/service-env-layers.ts index 4f2a99851..7275b1bbb 100644 --- a/apps/api/src/modules/deployments/compose/service-env-layers.ts +++ b/apps/api/src/modules/deployments/compose/service-env-layers.ts @@ -8,22 +8,23 @@ * the deploy no longer used. One rule, imported. */ +import { + resolveComposeEnvironmentTemplates, + type ComposeMissingVariable, +} from "../../../lib/compose-parser"; + /** The four env layers a compose service is deployed with, before token resolution. */ export interface ServiceEnvLayers { /** * Project-scoped rows, live. - * - * NOTE the deploy caller builds this with an UNSCOPED `getEnvMap(projectId, - * environment)`, so today it is really project rows ∪ every other service's - * service-scoped rows. That is a pre-existing defect in the caller, not in this - * layering, but it is why {@link mergeServiceDeployEnv} describes what it - * compares against as "already layered" rather than "the project's". */ project: Record; /** This deployment's frozen capture (`dep.envVars`, decrypted). Flat, unscoped. */ frozen: Record; /** The compose file's inline `environment:` for this service. */ inline: Record; + /** Names of inline values stored as their original Compose expressions. */ + templateKeys?: readonly string[]; /** Service-scoped rows for this service, live. */ service: Record; } @@ -42,32 +43,20 @@ export interface MergedServiceEnv { * displaying the empty value this merge decided to ignore. */ deferredEmpty: string[]; + /** Required Compose variables still absent after every env layer was merged. */ + missingRequired: ComposeMissingVariable[]; } /** * Whether an inline compose empty value yields to a value an earlier layer * already supplied. * - * The rule, in one sentence an operator can predict: **an empty inline value - * never clears a configured one.** It exists because compose's passthrough idiom - * (`FOO: ${FOO:-}` / `FOO: ${FOO}`) parses to `""` whenever nothing resolved the - * placeholder, and that `""` is persisted onto the service row with its - * provenance stripped — the row has no `environmentMeta`, so by deploy time - * "the author wrote an empty literal" and "nothing filled this placeholder in" - * are the same three characters. Before this rule the placeholder won, so the - * project-level value the operator had just typed was replaced with `""` — - * which is worse than unset, since `FOO=` also masks the image's own `ENV` - * default (issue #614). - * - * The cost, accepted deliberately: an inline empty that WAS authored on purpose - * no longer clears a project-level value for that one service. `advanced` JSONB - * could carry the parser's `environmentMeta.source` onto the row and let this - * decide on recorded intent instead of value shape (the `entrypoint`/#575 - * precedent) — that is the endgame, and it is what would also fix the sibling - * case this cannot: a PARTIALLY interpolated value, where `postgres://${USER}: - * ${PASS}@db/${DB}` resolves to the non-empty garbage `postgres://:@db/` and - * still wins. Until then the escape hatch for a deliberate blank is an empty - * SERVICE-SCOPED env row, which is layered after this and never skipped. + * This is now a LEGACY-row fallback. Provenance-aware rows persist their raw + * expressions plus `environmentTemplateKeys`; those are resolved after all env + * layers exist, while an authored empty literal correctly remains empty. Rows + * created before that marker cannot distinguish an unresolved passthrough from + * an authored blank, so they retain the conservative issue-#614 behavior: an + * empty inline value does not erase an already configured non-empty value. */ export function inlineEmptyDefers( inlineValue: string, @@ -81,10 +70,10 @@ export function inlineEmptyDefers( } /** - * Layer a service's env. Service rows beat inline compose env beats project rows - * — so the compose UI can override a global per service — with the ONE exception - * that an empty inline value defers instead of clearing ({@link - * inlineEmptyDefers}). + * Layer a service's env. Service rows beat inline compose env beats project rows. + * Raw Compose templates resolve after those layers exist, which lets an embedded + * `${VAR}` consume a project- or service-scoped value. Only an unmarked legacy + * empty value uses {@link inlineEmptyDefers}. * * `frozenWins` moves the frozen layer LAST, which is what makes a rollback replay * the release it restores instead of running old code against today's config — @@ -110,11 +99,17 @@ export function mergeServiceDeployEnv( ): MergedServiceEnv { const env: Record = { ...layers.project }; const deferredEmpty: string[] = []; + const templateKeys = new Set(layers.templateKeys ?? []); + const hasTemplateProvenance = layers.templateKeys !== undefined; if (!frozenWins) Object.assign(env, layers.frozen); for (const [key, value] of Object.entries(layers.inline)) { - if (inlineEmptyDefers(value, env[key])) { + // A template is evaluated after all layers exist, so it can consume a + // service-scoped secret. Do not let its scan-time/raw representation become + // part of the lookup first (especially for self-passthrough `${KEY}`). + if (templateKeys.has(key)) continue; + if (!hasTemplateProvenance && inlineEmptyDefers(value, env[key])) { deferredEmpty.push(key); continue; } @@ -127,9 +122,24 @@ export function mergeServiceDeployEnv( if (frozenWins) Object.assign(env, layers.frozen); + const higherPriorityTemplateTargets = new Set(Object.keys(layers.service)); + if (frozenWins) { + for (const key of Object.keys(layers.frozen)) higherPriorityTemplateTargets.add(key); + } + const templates = Object.fromEntries( + [...templateKeys] + .filter((key) => !higherPriorityTemplateTargets.has(key) && key in layers.inline) + .map((key) => [key, layers.inline[key]!]), + ); + const dynamic = resolveComposeEnvironmentTemplates(env, templates); + // A key that a later layer supplied anyway was never really "deferred" — // reporting it would name a variable whose value this decision didn't pick. const decidedLater = (key: string) => key in layers.service || (frozenWins && key in layers.frozen); - return { env, deferredEmpty: deferredEmpty.filter((key) => !decidedLater(key)) }; + return { + env: dynamic.env, + deferredEmpty: deferredEmpty.filter((key) => !decidedLater(key)), + missingRequired: dynamic.missingRequired, + }; } diff --git a/apps/api/src/modules/deployments/deployment.schema.ts b/apps/api/src/modules/deployments/deployment.schema.ts index dc45f7855..2d9f2277d 100644 --- a/apps/api/src/modules/deployments/deployment.schema.ts +++ b/apps/api/src/modules/deployments/deployment.schema.ts @@ -15,9 +15,7 @@ export const DeploymentIdParam = Type.Object({ export const ListDeploymentsQuery = Type.Object({ projectId: Type.Optional(Type.String()), - environment: Type.Optional(Type.Union([ - Type.Literal("production"), Type.Literal("preview"), - ])), + environment: Type.Optional(Type.Union([Type.Literal("production"), Type.Literal("preview")])), page: Type.Optional(Type.Number({ minimum: 1, default: 1 })), perPage: Type.Optional(Type.Number({ minimum: 1, maximum: 100, default: 20 })), }); @@ -28,9 +26,7 @@ export const TriggerDeployBody = Type.Object({ projectId: Type.String({ minLength: 1 }), branch: Type.Optional(Type.String({ default: "main" })), commitSha: Type.Optional(Type.String()), - environment: Type.Optional(Type.Union([ - Type.Literal("production"), Type.Literal("preview"), - ])), + environment: Type.Optional(Type.Union([Type.Literal("production"), Type.Literal("preview")])), }); /** Public endpoint (domain/route) as sent by the deploy wizard. */ @@ -62,6 +58,7 @@ const BuildServiceInput = Type.Object({ image: Type.Optional(Type.String()), build: Type.Optional(Type.String()), dockerfile: Type.Optional(Type.String()), + buildArgs: Type.Optional(Type.Record(Type.String(), Type.Union([Type.String(), Type.Null()]))), ports: Type.Array(Type.String()), dependsOn: Type.Array(Type.String()), environment: Type.Record(Type.String(), Type.String()), @@ -74,6 +71,19 @@ const BuildServiceInput = Type.Object({ // unchanged string from disturbing argv; this lets a client be explicit. commandArgv: Type.Optional(Type.Array(Type.String())), restart: Type.Optional(Type.String()), + // Raw-parser provenance. Other advanced keys are accepted at runtime so the + // deploy snapshot can continue carrying healthchecks/resources/etc.; this one + // is named in the static schema because build execution reads it directly. + advanced: Type.Optional( + Type.Object( + { + buildArgTemplateKeys: Type.Optional( + Type.Array(Type.String({ pattern: "^[A-Za-z_][A-Za-z0-9_]*$" })), + ), + }, + { additionalProperties: true }, + ), + ), exposed: Type.Optional(Type.Boolean()), exposedPort: Type.Optional(Type.String()), domain: Type.Optional(Type.String()), @@ -106,10 +116,14 @@ const BuildServiceInput = Type.Object({ export const BuildAccessBody = Type.Object({ projectId: Type.String({ description: "Target project id (from projects/ensure). Required." }), uploadSessionId: Type.Optional( - Type.String({ description: "Folder-upload session id — deploys the uploaded source instead of git." }), + Type.String({ + description: "Folder-upload session id — deploys the uploaded source instead of git.", + }), ), branch: Type.Optional(Type.String({ description: "Git branch (git-source projects)." })), - environment: Type.Optional(Type.String({ description: "production | preview (default production)." })), + environment: Type.Optional( + Type.String({ description: "production | preview (default production)." }), + ), envVars: Type.Optional( Type.Record(Type.String(), Type.String(), { description: "Runtime env vars { KEY: value }." }), ), @@ -119,18 +133,26 @@ export const BuildAccessBody = Type.Object({ }), ), buildStrategy: Type.Optional( - Type.Union([Type.Literal("server"), Type.Literal("local")], { description: "Where the build runs." }), + Type.Union([Type.Literal("server"), Type.Literal("local")], { + description: "Where the build runs.", + }), ), deployTarget: Type.Optional( Type.Union([Type.Literal("local"), Type.Literal("server"), Type.Literal("cloud")], { description: "Usually omit for folder uploads — the upload session mode decides.", }), ), - serverId: Type.Optional(Type.String({ description: "Target server id when deployTarget='server'." })), + serverId: Type.Optional( + Type.String({ description: "Target server id when deployTarget='server'." }), + ), runtimeMode: Type.Optional(Type.Union([Type.Literal("bare"), Type.Literal("docker")])), - serviceDeploymentMode: Type.Optional(Type.Union([Type.Literal("services"), Type.Literal("single")])), + serviceDeploymentMode: Type.Optional( + Type.Union([Type.Literal("services"), Type.Literal("single")]), + ), services: Type.Optional( - Type.Array(BuildServiceInput, { description: "Compose / multi-service definitions (services mode)." }), + Type.Array(BuildServiceInput, { + description: "Compose / multi-service definitions (services mode).", + }), ), serviceIds: Type.Optional( Type.Array(Type.String(), { @@ -171,12 +193,14 @@ export const PrepareDeployBody = Type.Object({ owner: Type.Optional(Type.String({ description: "GitHub repo owner (github source)." })), repo: Type.Optional(Type.String({ description: "GitHub repo name (github source)." })), branch: Type.Optional(Type.String({ description: "Git branch (github source)." })), - path: Type.Optional(Type.String({ description: "Local filesystem path (local source; self-hosted only)." })), + path: Type.Optional( + Type.String({ description: "Local filesystem path (local source; self-hosted only)." }), + ), composePath: Type.Optional( Type.String({ maxLength: 300, description: - "Where the compose file lives when it is not at the auto-detected root — the file itself (\"deploy/stack.yml\", which also covers non-standard filenames) or the directory holding it (\"deploy/docker-compose\"). Detects the project as a compose/services deploy; errors when no compose file is there.", + 'Where the compose file lives when it is not at the auto-detected root — the file itself ("deploy/stack.yml", which also covers non-standard filenames) or the directory holding it ("deploy/docker-compose"). Detects the project as a compose/services deploy; errors when no compose file is there.', }), ), env: Type.Optional( diff --git a/apps/api/src/modules/deployments/observed-host-port-claims.test.ts b/apps/api/src/modules/deployments/observed-host-port-claims.test.ts new file mode 100644 index 000000000..6353e5cbb --- /dev/null +++ b/apps/api/src/modules/deployments/observed-host-port-claims.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { HostPortTargetIdentity } from "../../lib/host-port-target"; +import { + observedLoopbackPublishFromUrl, + reserveObservedLoopbackPublishes, + reserveResolvedLoopbackRoutes, +} from "./observed-host-port-claims"; + +const reserve = vi.hoisted(() => vi.fn()); +vi.mock("./pinned-host-ports", () => ({ reserveTargetPinnedHostPort: reserve })); + +const target: HostPortTargetIdentity = { + targetKey: "local", + legacyTargetKeys: [], + stable: true, +}; + +describe("observed host-port claims", () => { + beforeEach(() => reserve.mockReset().mockResolvedValue({})); + + it("extracts only concrete HTTP(S) loopback publishes", () => { + expect( + observedLoopbackPublishFromUrl({ + targetUrl: "http://127.0.0.1:23000", + serviceId: "svc_api", + containerPort: 3000, + }), + ).toEqual({ serviceId: "svc_api", containerPort: 3000, hostPort: 23000 }); + expect( + observedLoopbackPublishFromUrl({ + targetUrl: "https://[::1]:24443", + serviceId: null, + containerPort: 8443, + }), + ).toEqual({ serviceId: null, containerPort: 8443, hostPort: 24443 }); + expect( + observedLoopbackPublishFromUrl({ + targetUrl: "http://172.18.0.4:3000", + serviceId: "svc_api", + containerPort: 3000, + }), + ).toBeNull(); + expect( + observedLoopbackPublishFromUrl({ + targetUrl: "tcp://127.0.0.1:23000", + serviceId: "svc_api", + containerPort: 3000, + }), + ).toBeNull(); + }); + + it("reserves every distinct exact mapping under the physical target", async () => { + await reserveObservedLoopbackPublishes({ + target, + projectId: "proj_1", + publishes: [ + { serviceId: "svc_api", containerPort: 3000, hostPort: 23000 }, + { serviceId: "svc_api", containerPort: 3001, hostPort: 23001 }, + { serviceId: "svc_api", containerPort: 3000, hostPort: 23000 }, + ], + }); + + expect(reserve.mock.calls).toEqual([ + [target, { projectId: "proj_1", serviceId: "svc_api", containerPort: 3000, port: 23000 }], + [target, { projectId: "proj_1", serviceId: "svc_api", containerPort: 3001, port: 23001 }], + ]); + }); + + it("propagates a conflict and does not reserve later mappings", async () => { + const conflict = new Error("reserved by another owner"); + reserve.mockRejectedValueOnce(conflict); + + await expect( + reserveObservedLoopbackPublishes({ + target, + projectId: "proj_1", + publishes: [ + { serviceId: "svc_api", containerPort: 3000, hostPort: 23000 }, + { serviceId: "svc_api", containerPort: 3001, hostPort: 23001 }, + ], + }), + ).rejects.toBe(conflict); + expect(reserve).toHaveBeenCalledTimes(1); + }); + + it("ignores bridge URLs but requires a physical target for loopback", async () => { + await expect( + reserveResolvedLoopbackRoutes({ + target: null, + projectId: "proj_1", + routes: [ + { + targetUrl: "http://172.18.0.4:3000", + serviceId: "svc_api", + containerPort: 3000, + }, + ], + }), + ).resolves.toBeUndefined(); + + await expect( + reserveResolvedLoopbackRoutes({ + target: null, + projectId: "proj_1", + routes: [ + { + targetUrl: "http://127.0.0.1:23000", + serviceId: "svc_api", + containerPort: 3000, + }, + ], + }), + ).rejects.toThrow("without a resolved physical host-port target"); + }); + + it("rejects loopback routes whose workload owner has no valid container port", async () => { + await expect( + reserveResolvedLoopbackRoutes({ + target, + projectId: "proj_1", + routes: [ + { + targetUrl: "http://127.0.0.1:23000", + serviceId: "svc_api", + containerPort: 0, + }, + ], + }), + ).rejects.toThrow("without a valid container-port owner"); + expect(reserve).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/deployments/observed-host-port-claims.ts b/apps/api/src/modules/deployments/observed-host-port-claims.ts new file mode 100644 index 000000000..9f5968775 --- /dev/null +++ b/apps/api/src/modules/deployments/observed-host-port-claims.ts @@ -0,0 +1,117 @@ +import type { HostPortTargetIdentity } from "../../lib/host-port-target"; +import { isLoopbackHost } from "@repo/core"; +import { reserveTargetPinnedHostPort } from "./pinned-host-ports"; + +export interface ObservedLoopbackPublish { + serviceId: string | null; + containerPort: number; + hostPort: number; +} + +function validPort(value: number): boolean { + return Number.isSafeInteger(value) && value > 0 && value <= 65_535; +} + +/** + * Turn one route upstream into the exact durable ownership tuple it proves. + * Bridge/container-IP routes are deliberately ignored: only a loopback URL + * observes a bind in the physical host's TCP namespace. + */ +export function observedLoopbackPublishFromUrl(input: { + targetUrl: string | null | undefined; + serviceId: string | null; + containerPort: number; +}): ObservedLoopbackPublish | null { + if (!input.targetUrl || !validPort(input.containerPort)) return null; + const hostPort = loopbackHostPortFromUrl(input.targetUrl); + if (!hostPort) return null; + return { + serviceId: input.serviceId, + containerPort: input.containerPort, + hostPort, + }; +} + +/** The concrete physical host port a loopback HTTP(S) upstream dials. */ +export function loopbackHostPortFromUrl(targetUrl: string | null | undefined): number | null { + if (!targetUrl) return null; + try { + const url = new URL(targetUrl); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + const hostname = url.hostname.replace(/^\[|\]$/g, ""); + if (!isLoopbackHost(hostname)) return null; + const hostPort = url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 80; + if (!validPort(hostPort)) return null; + return hostPort; + } catch { + return null; + } +} + +/** + * Persist every loopback publish observed from live Docker/route state before a + * caller registers an edge route to it. The repository's unique indexes are the + * final arbiter: an exact repeat is idempotent; another owner raises and the + * caller must fail closed. Nothing here catches or overwrites that conflict. + */ +export async function reserveObservedLoopbackPublishes(input: { + target: HostPortTargetIdentity; + projectId: string; + publishes: Iterable; +}): Promise { + const seen = new Set(); + for (const publish of input.publishes) { + if (!publish || !validPort(publish.containerPort) || !validPort(publish.hostPort)) continue; + const key = `${publish.serviceId ?? ""}\0${publish.containerPort}\0${publish.hostPort}`; + if (seen.has(key)) continue; + seen.add(key); + await reserveTargetPinnedHostPort(input.target, { + projectId: input.projectId, + serviceId: publish.serviceId, + containerPort: publish.containerPort, + port: publish.hostPort, + }); + } +} + +/** + * Validate the concrete upstream URLs a route is about to publish and reserve + * every loopback bind under its exact workload owner. Non-loopback URLs consume + * no host TCP port and are ignored. A loopback URL without a stable physical + * target is rejected: accepting it would recreate a host-global ownership guess. + * + * Keep this immediately before route registration. Allocation protects the + * normal path; this is the final fail-closed gate if a resolver ever returns a + * stale or mismatched publish. + */ +export async function reserveResolvedLoopbackRoutes(input: { + target: HostPortTargetIdentity | null | undefined; + projectId: string; + routes: Iterable<{ + targetUrl: string | null | undefined; + serviceId: string | null; + containerPort: number; + }>; +}): Promise { + const publishes: ObservedLoopbackPublish[] = []; + for (const route of input.routes) { + const hostPort = loopbackHostPortFromUrl(route.targetUrl); + if (!hostPort) continue; + const publish = observedLoopbackPublishFromUrl(route); + if (!publish) { + throw new Error( + `Refusing loopback route without a valid container-port owner for host port ${hostPort}`, + ); + } + publishes.push(publish); + } + if (publishes.length === 0) return; + if (!input.target) { + throw new Error("Refusing loopback route without a resolved physical host-port target"); + } + await reserveObservedLoopbackPublishes({ + target: input.target, + projectId: input.projectId, + publishes, + }); +} diff --git a/apps/api/src/modules/deployments/pinned-artifacts.test.ts b/apps/api/src/modules/deployments/pinned-artifacts.test.ts index c496494ac..67198b430 100644 --- a/apps/api/src/modules/deployments/pinned-artifacts.test.ts +++ b/apps/api/src/modules/deployments/pinned-artifacts.test.ts @@ -3,6 +3,7 @@ import { hasPinnedArtifacts, pinnedAppImage, pinnedImageForService, + refreshAppDeploymentId, snapshotNeedsGitSource, withoutPinnedArtifacts, } from "./pinned-artifacts"; @@ -31,12 +32,22 @@ describe("pinned artifact lookup", () => { expect(hasPinnedArtifacts(snapshot)).toBe(true); expect(hasPinnedArtifacts({ handoverImages: { web: " " } })).toBe(false); expect(hasPinnedArtifacts({})).toBe(false); + expect(hasPinnedArtifacts({ refreshAppDeploymentId: "dep_live" })).toBe(true); }); it("strips both fields and leaves the rest of the snapshot alone", () => { - const stripped = withoutPinnedArtifacts({ ...snapshot, hasBuild: true }); + const stripped = withoutPinnedArtifacts({ + ...snapshot, + refreshAppDeploymentId: "dep_live", + hasBuild: true, + }); expect(stripped).toEqual({ hasBuild: true }); }); + + it("normalizes the active deployment marker", () => { + expect(refreshAppDeploymentId({ refreshAppDeploymentId: " dep_live " })).toBe("dep_live"); + expect(refreshAppDeploymentId({ refreshAppDeploymentId: " " })).toBeUndefined(); + }); }); describe("snapshotNeedsGitSource — the clone / token / GitHub-access gate", () => { @@ -47,6 +58,13 @@ describe("snapshotNeedsGitSource — the clone / token / GitHub-access gate", () expect( snapshotNeedsGitSource({ repoUrl: repo, hasBuild: true, handoverAppImage: "openship/app:1" }), ).toBe(false); + expect( + snapshotNeedsGitSource({ + repoUrl: repo, + hasBuild: true, + refreshAppDeploymentId: "dep_live", + }), + ).toBe(false); }); it("#538-A: a Dockerfile app (hasBuild=false) STILL clones its git repo for build context", () => { diff --git a/apps/api/src/modules/deployments/pinned-artifacts.ts b/apps/api/src/modules/deployments/pinned-artifacts.ts index ac6c1bf59..46f2b5b18 100644 --- a/apps/api/src/modules/deployments/pinned-artifacts.ts +++ b/apps/api/src/modules/deployments/pinned-artifacts.ts @@ -14,8 +14,9 @@ * (`build-pipeline.ts`) and the commit resolver (`build.service.ts`) can't drift * on what counts as pinned. * - * A pinned image is a HINT, never a guarantee: the tag may have been reclaimed - * since. Every consumer treats a missing image as "build it normally". + * Rollback/migration pins are hints: if retention reclaimed one, consumers may + * build normally. A refresh marker is a promise not to rebuild; its consumer + * fails closed when the active artifact is unavailable. */ import { classNeedsGitSource } from "@repo/core"; @@ -30,6 +31,13 @@ export interface PinnedArtifactSnapshot extends SnapshotClassInput { handoverImages?: Record; /** Single-app equivalent: the whole release is this one image. */ handoverAppImage?: string; + /** + * Env-only single-app refresh: reuse the ACTIVE deployment's retained + * artifact. Docker pairs this with handoverAppImage; Bare resolves the + * deployment's release directory itself. Unlike rollback pins, absence is a + * hard refresh failure — refresh must never silently become a source build. + */ + refreshAppDeploymentId?: string; /** * STATIC releases have no image: their artifact is a release DIRECTORY on the * host that the edge serves (see BareRuntime.deployStatic). Pinning it lets a @@ -67,6 +75,12 @@ export function pinnedAppImage( return nonEmpty(snapshot?.handoverAppImage); } +export function refreshAppDeploymentId( + snapshot: PinnedArtifactSnapshot | null | undefined, +): string | undefined { + return nonEmpty(snapshot?.refreshAppDeploymentId); +} + /** The pinned release DIRECTORY for a static deploy, if any. Absolute by * construction — a relative value is not a host path and is ignored. */ export function pinnedStaticDir( @@ -78,7 +92,9 @@ export function pinnedStaticDir( /** Does this snapshot pin anything at all? */ export function hasPinnedArtifacts(snapshot: PinnedArtifactSnapshot | null | undefined): boolean { - if (pinnedAppImage(snapshot) || pinnedStaticDir(snapshot)) return true; + if (pinnedAppImage(snapshot) || pinnedStaticDir(snapshot) || refreshAppDeploymentId(snapshot)) { + return true; + } return Object.values(snapshot?.handoverImages ?? {}).some((ref) => !!nonEmpty(ref)); } @@ -88,6 +104,7 @@ export function withoutPinnedArtifacts(snapsho handoverImages: _images, handoverAppImage: _app, handoverStaticDir: _static, + refreshAppDeploymentId: _refreshApp, ...rest } = snapshot; return rest as T; @@ -125,7 +142,11 @@ export function snapshotNeedsGitSource( // token. `source === "git"` is true iff there's a repo to fetch (non-empty // repoUrl, not an upload/release), so a localPath/upload/image deploy stays // git-free and a public repo still resolves anonymously downstream. - return classNeedsGitSource(snapshotToClass(snapshot)) && !pinnedAppImage(snapshot); + return ( + classNeedsGitSource(snapshotToClass(snapshot)) && + !pinnedAppImage(snapshot) && + !refreshAppDeploymentId(snapshot) + ); } /** diff --git a/apps/api/src/modules/deployments/pinned-host-ports.test.ts b/apps/api/src/modules/deployments/pinned-host-ports.test.ts new file mode 100644 index 000000000..996be5e69 --- /dev/null +++ b/apps/api/src/modules/deployments/pinned-host-ports.test.ts @@ -0,0 +1,669 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { pickHostPort } from "@repo/adapters"; +import { + allocateAndReservePinnedHostPort, + convergeTargetHostPortClaims, + convergeTargetHostPortClaimsUnlocked, + findOwnedPinnedHostPort, + listTargetPinnedHostPorts, + pinnedHostPortsToAvoid, + prepareTargetPinnedHostPorts, + releaseNewPinnedHostPortClaims, + type PinnedHostPort, +} from "./pinned-host-ports"; +import type { HostPortTargetIdentity } from "../../lib/host-port-target"; + +const claimRepo = vi.hoisted(() => ({ + reserve: vi.fn(), + quarantine: vi.fn(), + release: vi.fn(), + releaseQuarantine: vi.fn(), + list: vi.fn(), +})); + +vi.mock("@repo/db", () => ({ + HOST_PORT_QUARANTINE_OWNER: "__host_port_quarantine__", + HostPortClaimConflictError: class HostPortClaimConflictError extends Error { + conflict: "port" | "owner"; + constructor(conflict: "port" | "owner") { + super(conflict); + this.conflict = conflict; + } + }, + repos: { + hostPortClaim: { + listHostPortClaims: claimRepo.list, + reserveHostPortClaim: claimRepo.reserve, + reserveQuarantinedHostPortClaim: claimRepo.quarantine, + releaseHostPortClaim: claimRepo.release, + releaseQuarantinedHostPortClaim: claimRepo.releaseQuarantine, + }, + }, + withAdvisoryLock: async (_key: string, fn: () => Promise) => fn(), +})); + +const claims: PinnedHostPort[] = [ + { projectId: "single", serviceId: null, containerPort: null, port: 20001 }, + { projectId: "compose", serviceId: "api", containerPort: 3000, port: 20002 }, + { projectId: "compose", serviceId: "worker", containerPort: 4000, port: 20003 }, +]; + +const localTarget: HostPortTargetIdentity = { + targetKey: "local", + legacyTargetKeys: [], + stable: true, +}; +const remoteTarget: HostPortTargetIdentity = { + targetKey: `host:${"a".repeat(64)}`, + legacyTargetKeys: ["server:srv-a"], + stable: true, +}; + +const storedClaim = (id: string, targetKey: string, input: PinnedHostPort) => ({ + id, + targetKey, + ...input, + createdAt: new Date(0), + updatedAt: new Date(0), +}); + +describe("pinnedHostPortsToAvoid", () => { + beforeEach(() => { + claimRepo.reserve.mockReset(); + claimRepo.quarantine.mockReset(); + claimRepo.list.mockReset(); + claimRepo.release.mockReset().mockResolvedValue(true); + claimRepo.releaseQuarantine.mockReset().mockResolvedValue(true); + claimRepo.reserve.mockImplementation(async (input) => ({ + id: "hpc_test", + ...input, + createdAt: new Date(0), + updatedAt: new Date(0), + })); + claimRepo.quarantine.mockImplementation(async (input) => ({ + id: "hpc_quarantine", + ...input, + projectId: "__host_port_quarantine__", + serviceId: "__host_port_quarantine__", + containerPort: input.port, + createdAt: new Date(0), + updatedAt: new Date(0), + })); + }); + + it("reads legacy server aliases until a canonical port supersedes them", async () => { + claimRepo.list.mockImplementation(async (targetKey: string) => + targetKey.startsWith("host:") + ? [ + { + ...claims[0], + id: "canonical", + targetKey, + createdAt: new Date(0), + updatedAt: new Date(0), + }, + ] + : [ + { + ...claims[0], + id: "same-port-legacy", + targetKey, + createdAt: new Date(0), + updatedAt: new Date(0), + }, + { + ...claims[1], + id: "legacy-only", + targetKey, + createdAt: new Date(0), + updatedAt: new Date(0), + }, + ], + ); + + const found = await listTargetPinnedHostPorts(remoteTarget); + expect(found.map((claim) => claim.id)).toEqual(["canonical", "legacy-only"]); + }); + + it("quarantines an edge port under the canonical key even when a legacy alias claims it", async () => { + claimRepo.list.mockImplementation(async (targetKey: string) => + targetKey.startsWith("server:") + ? [ + { + ...claims[0], + id: "legacy", + targetKey, + createdAt: new Date(0), + updatedAt: new Date(0), + }, + ] + : [], + ); + claimRepo.quarantine.mockImplementationOnce(async (input) => ({ + id: "quarantine", + ...input, + createdAt: new Date(0), + updatedAt: new Date(0), + })); + + await prepareTargetPinnedHostPorts({ + target: remoteTarget, + edgeProxy: { listLoopbackUpstreamPortsStrict: async () => new Set([20001]) }, + }); + + expect(claimRepo.quarantine).toHaveBeenCalledWith({ + targetKey: remoteTarget.targetKey, + port: 20001, + }); + }); + + it("blocks allocation preparation when edge inventory is inconclusive", async () => { + const failure = new Error("edge config unreadable"); + await expect( + prepareTargetPinnedHostPorts({ + target: remoteTarget, + edgeProxy: { listLoopbackUpstreamPortsStrict: async () => Promise.reject(failure) }, + }), + ).rejects.toBe(failure); + expect(claimRepo.quarantine).not.toHaveBeenCalled(); + }); + + it("refuses claims when the remote target has only a mutable connection identity", async () => { + const scan = vi.fn(async () => new Set()); + await expect( + prepareTargetPinnedHostPorts({ + target: { ...remoteTarget, stable: false }, + edgeProxy: { listLoopbackUpstreamPortsStrict: scan }, + }), + ).rejects.toThrow("no stable host identity"); + expect(scan).not.toHaveBeenCalled(); + expect(claimRepo.quarantine).not.toHaveBeenCalled(); + }); + + it("prefers an exact container claim and uses a legacy scalar only when allowed", () => { + expect( + findOwnedPinnedHostPort(claims, { + projectId: "compose", + serviceId: "api", + containerPort: 3000, + })?.port, + ).toBe(20002); + + const legacy: PinnedHostPort[] = [ + { projectId: "compose", serviceId: "legacy", containerPort: null, port: 20004 }, + ]; + const owner = { projectId: "compose", serviceId: "legacy", containerPort: 8080 }; + expect(findOwnedPinnedHostPort(legacy, owner)).toBeUndefined(); + expect(findOwnedPinnedHostPort(legacy, owner, { allowLegacyContainerPort: true })?.port).toBe( + 20004, + ); + }); + + it("never treats a quarantine sentinel as reusable, even for an imported matching id", () => { + const quarantine: PinnedHostPort[] = [ + { + projectId: "__host_port_quarantine__", + serviceId: "__host_port_quarantine__", + containerPort: 20005, + port: 20005, + }, + ]; + const owner = { + projectId: "__host_port_quarantine__", + serviceId: "__host_port_quarantine__", + containerPort: 20005, + }; + + expect(findOwnedPinnedHostPort(quarantine, owner)).toBeUndefined(); + expect(pinnedHostPortsToAvoid(quarantine, { ...owner, port: 20005 }).has(20005)).toBe(true); + }); + + it("reserves every offline-capable database claim by default", () => { + expect([...pinnedHostPortsToAvoid(claims)].sort()).toEqual([20001, 20002, 20003]); + }); + + it("releases only the carried claim owned by the service being redeployed", () => { + const avoid = pinnedHostPortsToAvoid(claims, { + projectId: "compose", + serviceId: "api", + containerPort: 3000, + port: 20002, + }); + + expect(avoid.has(20002)).toBe(false); + expect(avoid.has(20001)).toBe(true); + expect(avoid.has(20003)).toBe(true); + }); + + it("does not release a port that another owner also claims", () => { + const duplicate = [ + ...claims, + { projectId: "other", serviceId: "web", containerPort: 3000, port: 20002 }, + ] satisfies PinnedHostPort[]; + + expect( + pinnedHostPortsToAvoid(duplicate, { + projectId: "compose", + serviceId: "api", + containerPort: 3000, + port: 20002, + }).has(20002), + ).toBe(true); + }); + + it("does not release an unowned preferred port", () => { + expect( + pinnedHostPortsToAvoid(claims, { + projectId: "compose", + serviceId: "missing", + containerPort: 3000, + port: 20001, + }).has(20001), + ).toBe(true); + }); + + it("makes the allocator skip a pinned port even when no container is listening", () => { + expect(pickHostPort(new Set(), { avoid: pinnedHostPortsToAvoid(claims) })).toBe(20000); + + const firstRangePortClaimed: PinnedHostPort[] = [ + { projectId: "offline", serviceId: "api", containerPort: 3000, port: 20000 }, + ]; + expect(pickHostPort(new Set(), { avoid: pinnedHostPortsToAvoid(firstRangePortClaimed) })).toBe( + 20001, + ); + }); + + it("blocks GHSA-284v-9jw3-jfhx when only a stale edge vhost still names the port", async () => { + const canonicalClaims: Array> = []; + claimRepo.list.mockImplementation(async (targetKey: string) => + targetKey === localTarget.targetKey ? canonicalClaims : [], + ); + claimRepo.quarantine.mockImplementationOnce(async ({ targetKey, port }) => { + const quarantine = storedClaim("stale-edge-quarantine", targetKey, { + projectId: "__host_port_quarantine__", + serviceId: "__host_port_quarantine__", + containerPort: port, + port, + }); + canonicalClaims.push(quarantine); + return quarantine; + }); + + // Project A's container is stopped/crash-looping: the live-listener scan + // below is intentionally empty. Its forgotten vhost is the only remaining + // evidence that 20000 must not be handed to project B. + const claimsAfterEdgeInventory = await prepareTargetPinnedHostPorts({ + target: localTarget, + edgeProxy: { listLoopbackUpstreamPortsStrict: async () => new Set([20000]) }, + }); + const allocation = await allocateAndReservePinnedHostPort({ + target: localTarget, + claims: claimsAfterEdgeInventory, + owner: { projectId: "project-b", serviceId: "echo", containerPort: 8080 }, + allocate: async (options) => ({ + port: pickHostPort(new Set(), options), + scanned: true, + }), + }); + + expect(claimRepo.quarantine).toHaveBeenCalledWith({ targetKey: "local", port: 20000 }); + expect(allocation.port).toBe(20001); + expect(claimRepo.reserve).toHaveBeenCalledWith( + expect.objectContaining({ projectId: "project-b", port: 20001 }), + ); + }); + + it("lets a service keep its own carried port when nobody else claims it", () => { + const avoid = pinnedHostPortsToAvoid(claims, { + projectId: "compose", + serviceId: "api", + containerPort: 3000, + port: 20002, + }); + + expect(pickHostPort(new Set(), { preferred: 20002, avoid })).toBe(20002); + }); + + it("allocates around a stopped owner and commits the new claim before returning", async () => { + const events: string[] = []; + claimRepo.reserve.mockImplementationOnce(async (input) => { + events.push("reserved"); + return { + id: "hpc_new", + ...input, + createdAt: new Date(0), + updatedAt: new Date(0), + }; + }); + + const result = await allocateAndReservePinnedHostPort({ + target: localTarget, + claims: [{ projectId: "stopped", serviceId: null, containerPort: 3000, port: 20000 }], + owner: { projectId: "new", serviceId: null, containerPort: 3000 }, + allocate: async (options) => { + events.push("allocated"); + return { port: pickHostPort(new Set(), options), scanned: true }; + }, + }); + + expect(result.port).toBe(20001); + expect(events).toEqual(["allocated", "reserved"]); + expect(claimRepo.reserve).toHaveBeenCalledWith({ + targetKey: "local", + projectId: "new", + serviceId: null, + containerPort: 3000, + port: 20001, + }); + }); + + it("reuses an occupied port only when the target claim proves exact ownership", async () => { + const owner = { projectId: "compose", serviceId: "api", containerPort: 3000 }; + const result = await allocateAndReservePinnedHostPort({ + target: remoteTarget, + claims, + owner, + cachedPreferred: 29999, + allocate: async (options) => ({ + port: pickHostPort(new Set([20002]), options), + scanned: true, + }), + }); + + expect(result.port).toBe(20002); + expect(claimRepo.reserve).toHaveBeenCalledWith( + expect.objectContaining({ targetKey: remoteTarget.targetKey, ...owner, port: 20002 }), + ); + }); + + it("preserves null as a legacy claim identity instead of treating it as missing", async () => { + const legacy: PinnedHostPort = { + projectId: "legacy", + serviceId: "api", + containerPort: null, + port: 20004, + }; + await allocateAndReservePinnedHostPort({ + target: localTarget, + claims: [legacy], + owner: { projectId: "legacy", serviceId: "api", containerPort: 8080 }, + allowLegacyContainerPort: true, + allocate: async (options) => ({ + port: pickHostPort(new Set(), options), + scanned: true, + }), + }); + + expect(claimRepo.reserve).toHaveBeenCalledWith( + expect.objectContaining({ containerPort: null, port: 20004 }), + ); + }); + + it("rolls back only claims created by an unrouted failed activation", async () => { + const fresh = await allocateAndReservePinnedHostPort({ + target: localTarget, + claims: [], + owner: { projectId: "new", serviceId: "api", containerPort: 3000 }, + allocate: async () => ({ port: 20010, scanned: true }), + }); + const carried = await allocateAndReservePinnedHostPort({ + target: localTarget, + claims: [{ projectId: "old", serviceId: "api", containerPort: 3000, port: 20011 }], + owner: { projectId: "old", serviceId: "api", containerPort: 3000 }, + allocate: async () => ({ port: 20011, scanned: true }), + }); + + await expect(releaseNewPinnedHostPortClaims(localTarget, [fresh, carried])).resolves.toBe(1); + expect(claimRepo.release).toHaveBeenCalledTimes(1); + expect(claimRepo.release).toHaveBeenCalledWith( + expect.objectContaining({ + targetKey: "local", + projectId: "new", + serviceId: "api", + containerPort: 3000, + port: 20010, + }), + ); + }); + + it("converges canonical and legacy claims only after a fresh strict edge scan", async () => { + const projectId = "project-a"; + const canonical = remoteTarget.targetKey; + const legacy = remoteTarget.legacyTargetKeys[0]!; + const desired = storedClaim("desired", canonical, { + projectId, + serviceId: "api", + containerPort: 3000, + port: 20010, + }); + claimRepo.list.mockImplementation(async (targetKey: string) => + targetKey === canonical + ? [ + desired, + storedClaim("observed-current", canonical, { + projectId, + serviceId: "worker", + containerPort: 4000, + port: 20011, + }), + storedClaim("stale-current", canonical, { + projectId, + serviceId: "old", + containerPort: 5000, + port: 20012, + }), + storedClaim("foreign", canonical, { + projectId: "project-b", + serviceId: "api", + containerPort: 3000, + port: 20013, + }), + storedClaim("observed-quarantine", canonical, { + projectId: "__host_port_quarantine__", + serviceId: "__host_port_quarantine__", + containerPort: 20014, + port: 20014, + }), + storedClaim("stale-quarantine", canonical, { + projectId: "__host_port_quarantine__", + serviceId: "__host_port_quarantine__", + containerPort: 20015, + port: 20015, + }), + ] + : [ + storedClaim("desired-legacy", legacy, { + projectId, + serviceId: "api", + containerPort: 3000, + port: 20010, + }), + storedClaim("stale-legacy", legacy, { + projectId, + serviceId: "old-legacy", + containerPort: 6000, + port: 20016, + }), + storedClaim("observed-legacy", legacy, { + projectId, + serviceId: "live-legacy", + containerPort: 7000, + port: 20017, + }), + storedClaim("foreign-legacy", legacy, { + projectId: "project-b", + serviceId: "worker", + containerPort: 4000, + port: 20018, + }), + storedClaim("stale-quarantine-legacy", legacy, { + projectId: "__host_port_quarantine__", + serviceId: "__host_port_quarantine__", + containerPort: 20019, + port: 20019, + }), + storedClaim("observed-quarantine-legacy", legacy, { + projectId: "__host_port_quarantine__", + serviceId: "__host_port_quarantine__", + containerPort: 20020, + port: 20020, + }), + ], + ); + const events: string[] = []; + claimRepo.reserve.mockImplementation(async (input) => { + events.push("reserve"); + return storedClaim("desired", input.targetKey, { + projectId: input.projectId, + serviceId: input.serviceId, + containerPort: input.containerPort, + port: input.port, + }); + }); + claimRepo.release.mockImplementation(async () => { + events.push("release-workload"); + return true; + }); + claimRepo.releaseQuarantine.mockImplementation(async () => { + events.push("release-quarantine"); + return true; + }); + const scan = vi.fn(async (opts?: { refresh?: boolean }) => { + events.push("scan"); + expect(opts).toEqual({ refresh: true }); + return new Set([20010, 20011, 20014, 20017, 20020]); + }); + + const result = await convergeTargetHostPortClaims({ + target: remoteTarget, + projectId, + desiredPublishes: [{ serviceId: "api", containerPort: 3000, hostPort: 20010 }], + edgeProxy: { listLoopbackUpstreamPortsStrict: scan }, + }); + + expect(events.slice(0, 2)).toEqual(["reserve", "scan"]); + expect(result.released).toBe(5); + expect(result.retained.map((claim) => claim.id)).toEqual([ + "desired", + "observed-current", + "observed-legacy", + ]); + expect(claimRepo.release).toHaveBeenCalledTimes(3); + expect(claimRepo.release).toHaveBeenCalledWith( + expect.objectContaining({ targetKey: legacy, port: 20010, projectId }), + ); + expect(claimRepo.release).not.toHaveBeenCalledWith( + expect.objectContaining({ projectId: "project-b" }), + ); + expect(claimRepo.releaseQuarantine).toHaveBeenCalledTimes(2); + expect(claimRepo.releaseQuarantine).not.toHaveBeenCalledWith( + expect.objectContaining({ port: 20014 }), + ); + expect(claimRepo.releaseQuarantine).not.toHaveBeenCalledWith( + expect.objectContaining({ port: 20020 }), + ); + }); + + it("releases nothing when the required post-write edge refresh is inconclusive", async () => { + const failure = new Error("edge config unreadable"); + const scan = vi.fn(async () => Promise.reject(failure)); + + await expect( + convergeTargetHostPortClaimsUnlocked({ + target: localTarget, + projectId: "project-a", + desiredPublishes: [{ serviceId: null, containerPort: 3000, hostPort: 20010 }], + edgeProxy: { listLoopbackUpstreamPortsStrict: scan }, + }), + ).rejects.toBe(failure); + + expect(claimRepo.reserve).toHaveBeenCalledTimes(1); + expect(scan).toHaveBeenCalledWith({ refresh: true }); + expect(claimRepo.list).not.toHaveBeenCalled(); + expect(claimRepo.release).not.toHaveBeenCalled(); + expect(claimRepo.releaseQuarantine).not.toHaveBeenCalled(); + }); + + it("releases nothing if a desired reservation disappears before the claim read-back", async () => { + claimRepo.list.mockResolvedValue([ + storedClaim("stale-current", "local", { + projectId: "project-a", + serviceId: "old", + containerPort: 4000, + port: 20011, + }), + ]); + + await expect( + convergeTargetHostPortClaimsUnlocked({ + target: localTarget, + projectId: "project-a", + desiredPublishes: [{ serviceId: "api", containerPort: 3000, hostPort: 20010 }], + edgeProxy: { listLoopbackUpstreamPortsStrict: async () => new Set() }, + }), + ).rejects.toThrow("disappeared before convergence completed"); + + expect(claimRepo.release).not.toHaveBeenCalled(); + expect(claimRepo.releaseQuarantine).not.toHaveBeenCalled(); + }); + + it("validates the complete desired claim set before reserving or scanning", async () => { + const scan = vi.fn(async () => new Set()); + + await expect( + convergeTargetHostPortClaimsUnlocked({ + target: localTarget, + projectId: "project-a", + desiredPublishes: [ + { serviceId: "api", containerPort: 3000, hostPort: 20010 }, + { serviceId: "worker", containerPort: 4000, hostPort: 20010 }, + ], + edgeProxy: { listLoopbackUpstreamPortsStrict: scan }, + }), + ).rejects.toThrow("one host port to multiple owners"); + await expect( + convergeTargetHostPortClaimsUnlocked({ + target: localTarget, + projectId: "project-a", + desiredPublishes: [ + { serviceId: "api", containerPort: 3000, hostPort: 20010 }, + { serviceId: "api", containerPort: 3000, hostPort: 20011 }, + ], + edgeProxy: { listLoopbackUpstreamPortsStrict: scan }, + }), + ).rejects.toThrow("one owner to multiple host ports"); + + expect(claimRepo.reserve).not.toHaveBeenCalled(); + expect(scan).not.toHaveBeenCalled(); + expect(claimRepo.list).not.toHaveBeenCalled(); + }); + + it("plans the whole cleanup before deleting and fails closed on malformed quarantine", async () => { + claimRepo.list.mockResolvedValue([ + storedClaim("stale-current", "local", { + projectId: "project-a", + serviceId: null, + containerPort: 3000, + port: 20010, + }), + storedClaim("malformed-quarantine", "local", { + projectId: "__host_port_quarantine__", + serviceId: "__host_port_quarantine__", + containerPort: 3000, + port: 20011, + }), + ]); + + await expect( + convergeTargetHostPortClaimsUnlocked({ + target: localTarget, + projectId: "project-a", + desiredPublishes: [], + edgeProxy: { listLoopbackUpstreamPortsStrict: async () => new Set() }, + }), + ).rejects.toThrow("Malformed host-port quarantine claim"); + + expect(claimRepo.release).not.toHaveBeenCalled(); + expect(claimRepo.releaseQuarantine).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/deployments/pinned-host-ports.ts b/apps/api/src/modules/deployments/pinned-host-ports.ts new file mode 100644 index 000000000..d83acd831 --- /dev/null +++ b/apps/api/src/modules/deployments/pinned-host-ports.ts @@ -0,0 +1,512 @@ +import { + HostPortClaimConflictError, + HOST_PORT_QUARANTINE_OWNER, + repos, + type HostPortClaim, + type HostPortTargetKey, +} from "@repo/db"; +import type { AllocateHostPortOptions, EdgeProxyApi, HostPortAllocation } from "@repo/adapters"; +import type { HostPortTargetIdentity } from "../../lib/host-port-target"; +import { createProvisionLock } from "../../lib/provision-lock"; + +/** The allocation helpers need only the stable ownership fields from a claim. */ +export type PinnedHostPort = Pick< + HostPortClaim, + "projectId" | "serviceId" | "containerPort" | "port" +>; + +export interface PinnedHostPortOwner { + projectId: string; + serviceId: string | null; + containerPort: number | null; +} + +export interface ReusablePinnedHostPort extends PinnedHostPortOwner { + port: number; +} + +/** One authoritative loopback publish that should remain claimed after reconciliation. */ +export interface DesiredHostPortPublish { + serviceId: string | null; + containerPort: number; + hostPort: number; +} + +export interface ConvergeTargetHostPortClaimsInput { + target: HostPortTargetIdentity; + projectId: string; + desiredPublishes: Iterable; + edgeProxy: Pick; +} + +export interface ConvergeTargetHostPortClaimsResult { + /** Exact workload/quarantine rows removed across canonical and legacy keys. */ + released: number; + /** This project's rows still protected by a desired or observed edge route. */ + retained: HostPortClaim[]; +} + +/** + * Serialize allocation through Docker bind + persistence for one physical + * target. The in-process + Postgres lock prevents two API replicas from both + * observing the same free port before either container starts listening. + */ +export function withHostPortTargetLock( + target: HostPortTargetIdentity, + fn: () => Promise, +): Promise { + return createProvisionLock(`host-port:${target.targetKey}`).run(fn); +} + +function targetKeys(target: HostPortTargetIdentity): HostPortTargetKey[] { + return [...new Set([target.targetKey, ...target.legacyTargetKeys])]; +} + +function assertStableTarget(target: HostPortTargetIdentity): void { + if (target.stable) return; + throw new Error( + "Cannot safely reserve loopback ports because this target has no stable host identity. " + + "Make /etc/machine-id readable or allow Openship to create /var/lib/openship/host-id.", + ); +} + +async function listClaimsByKey(targetKey: HostPortTargetKey): Promise { + return repos.hostPortClaim.listHostPortClaims(targetKey); +} + +/** + * Durable port claims for the exact host this deploy targets. + * + * Deliberately does not catch database failures: continuing with an incomplete + * set can steal a stopped container's port, which is worse than failing this + * deploy before it mutates the host. Live socket scanning remains the second, + * complementary half of allocation. + */ +export async function listTargetPinnedHostPorts( + target: HostPortTargetIdentity, +): Promise { + const [canonicalClaims, ...legacyClaimSets] = await Promise.all( + targetKeys(target).map(listClaimsByKey), + ); + const canonicalPorts = new Set(canonicalClaims.map((claim) => claim.port)); + // A canonical row naturally supersedes every server-row alias on that port. + // Until then, aliases remain part of allocation so stopped legacy workloads + // keep their reservation after upgrading. + return [ + ...canonicalClaims, + ...legacyClaimSets.flat().filter((claim) => !canonicalPorts.has(claim.port)), + ]; +} + +/** + * Commit ownership before Docker is allowed to bind. The database unique index + * is the final arbiter even if an allocator is accidentally called outside the + * target lock. + */ +export function reserveTargetPinnedHostPort( + target: HostPortTargetIdentity, + claim: ReusablePinnedHostPort, +): Promise { + assertStableTarget(target); + return repos.hostPortClaim.reserveHostPortClaim({ + targetKey: target.targetKey, + ...claim, + }); +} + +/** + * Read the target's own edge before allocation and quarantine every loopback + * upstream which has no canonical claim. + * + * Legacy aliases deliberately do not suppress quarantine: duplicate server + * rows can carry contradictory backfilled owners for one physical port. The + * canonical sentinel forces the first post-upgrade deploy off that ambiguous + * port. The strict edge API rejects an inconclusive scan, so an unreadable edge + * can never be mistaken for an empty one. + * + * The caller must hold {@link withHostPortTargetLock}; quarantine is kept until + * a future route-aware reconciliation can prove the vhost is gone. + */ +export async function prepareTargetPinnedHostPorts(input: { + target: HostPortTargetIdentity; + edgeProxy: Pick; +}): Promise { + assertStableTarget(input.target); + const observedPorts = await input.edgeProxy.listLoopbackUpstreamPortsStrict(); + const canonicalClaims = await listClaimsByKey(input.target.targetKey); + const canonicalPorts = new Set(canonicalClaims.map((claim) => claim.port)); + + for (const port of observedPorts) { + if (!Number.isSafeInteger(port) || port < 1 || port > 65_535 || canonicalPorts.has(port)) { + continue; + } + try { + await repos.hostPortClaim.reserveQuarantinedHostPortClaim({ + targetKey: input.target.targetKey, + port, + }); + canonicalPorts.add(port); + } catch (error) { + // A caller outside the target lock may have legitimately won the unique + // port race. Its canonical claim protects the port just as well; owner + // conflicts or database failures remain fatal. + if (!(error instanceof HostPortClaimConflictError) || error.conflict !== "port") { + throw error; + } + canonicalPorts.add(port); + } + } + + return listTargetPinnedHostPorts(input.target); +} + +function assertValidPort(value: number, field: "containerPort" | "hostPort"): void { + if (!Number.isSafeInteger(value) || value < 1 || value > 65_535) { + throw new RangeError(`${field} must be an integer between 1 and 65535`); + } +} + +function normalizeDesiredPublishes( + projectId: string, + publishes: Iterable, +): DesiredHostPortPublish[] { + if (typeof projectId !== "string" || !projectId.trim()) { + throw new TypeError("projectId must not be empty"); + } + if (projectId === HOST_PORT_QUARANTINE_OWNER) { + throw new TypeError("The host-port quarantine owner is reserved for internal use"); + } + + const normalized: DesiredHostPortPublish[] = []; + const hostPortOwners = new Map(); + const ownerPorts = new Map(); + for (const publish of publishes) { + if (!publish || typeof publish !== "object") { + throw new TypeError("desiredPublishes must contain valid loopback publishes"); + } + if ( + publish.serviceId !== null && + (typeof publish.serviceId !== "string" || !publish.serviceId.trim()) + ) { + throw new TypeError("serviceId must be null or a non-empty id"); + } + if (publish.serviceId === HOST_PORT_QUARANTINE_OWNER) { + throw new TypeError("The host-port quarantine owner is reserved for internal use"); + } + assertValidPort(publish.containerPort, "containerPort"); + assertValidPort(publish.hostPort, "hostPort"); + + const owner = JSON.stringify([publish.serviceId, publish.containerPort]); + const existingOwner = hostPortOwners.get(publish.hostPort); + if (existingOwner !== undefined && existingOwner !== owner) { + throw new TypeError("desiredPublishes assigns one host port to multiple owners"); + } + const existingPort = ownerPorts.get(owner); + if (existingPort !== undefined && existingPort !== publish.hostPort) { + throw new TypeError("desiredPublishes assigns one owner to multiple host ports"); + } + if (existingPort === publish.hostPort) continue; + + hostPortOwners.set(publish.hostPort, owner); + ownerPorts.set(owner, publish.hostPort); + normalized.push(publish); + } + return normalized; +} + +function exactDesiredClaim( + claim: HostPortClaim, + projectId: string, + desired: DesiredHostPortPublish, +): boolean { + return ( + claim.projectId === projectId && + claim.serviceId === desired.serviceId && + claim.containerPort === desired.containerPort && + claim.port === desired.hostPort + ); +} + +function exactQuarantineClaim(claim: HostPortClaim): boolean { + return ( + claim.projectId === HOST_PORT_QUARANTINE_OWNER && + claim.serviceId === HOST_PORT_QUARANTINE_OWNER && + claim.containerPort === claim.port + ); +} + +/** + * Converge durable claims after route mutation while the caller already holds + * {@link withHostPortTargetLock}. + * + * Desired mappings are reserved before any read or release. A forced fresh edge + * scan then proves which old ports are still dialled. If that scan or any + * database read fails, no claim is released. Other projects are never touched; + * legacy aliases are removed only when this project's canonical desired claim + * already protects the same physical port. + */ +export async function convergeTargetHostPortClaimsUnlocked( + input: ConvergeTargetHostPortClaimsInput, +): Promise { + assertStableTarget(input.target); + const desiredPublishes = normalizeDesiredPublishes(input.projectId, input.desiredPublishes); + + for (const publish of desiredPublishes) { + await reserveTargetPinnedHostPort(input.target, { + projectId: input.projectId, + serviceId: publish.serviceId, + containerPort: publish.containerPort, + port: publish.hostPort, + }); + } + + // Convergence runs after route writes, so the allocation-time memoized scan is + // not authoritative here. A failed refresh rejects and releases nothing. + const observedPorts = await input.edgeProxy.listLoopbackUpstreamPortsStrict({ refresh: true }); + const knownTargetKeys = targetKeys(input.target); + const claimSets = await Promise.all(knownTargetKeys.map(listClaimsByKey)); + const allClaims = claimSets.flat(); + const desiredByPort = new Map(desiredPublishes.map((publish) => [publish.hostPort, publish])); + const desiredCanonicalPorts = new Set(); + for (const publish of desiredPublishes) { + if ( + !allClaims.some( + (claim) => + claim.targetKey === input.target.targetKey && + exactDesiredClaim(claim, input.projectId, publish), + ) + ) { + throw new Error( + `Desired host-port claim ${publish.hostPort} disappeared before convergence completed`, + ); + } + desiredCanonicalPorts.add(publish.hostPort); + } + + const retained: HostPortClaim[] = []; + const workloadReleases: Array<{ claim: HostPortClaim; targetKey: HostPortTargetKey }> = []; + const quarantineReleases: Array<{ claim: HostPortClaim; targetKey: HostPortTargetKey }> = []; + + for (const claim of allClaims) { + const claimTargetKey = knownTargetKeys.find((targetKey) => targetKey === claim.targetKey); + if (!claimTargetKey) { + throw new Error(`Host-port claim returned from an unexpected target: ${claim.targetKey}`); + } + if (isQuarantineClaim(claim)) { + if (!exactQuarantineClaim(claim)) { + throw new Error(`Malformed host-port quarantine claim on ${claim.targetKey}`); + } + // A desired canonical workload row, reserved above, supersedes a legacy + // quarantine alias even while the new route dials that same physical port. + if (observedPorts.has(claim.port) && !desiredCanonicalPorts.has(claim.port)) continue; + quarantineReleases.push({ claim, targetKey: claimTargetKey }); + continue; + } + + if (claim.projectId !== input.projectId) continue; + const desired = desiredByPort.get(claim.port); + const isCanonicalDesired = + claim.targetKey === input.target.targetKey && + desired !== undefined && + exactDesiredClaim(claim, input.projectId, desired); + if (isCanonicalDesired || (observedPorts.has(claim.port) && !desired)) { + retained.push(claim); + continue; + } + // An observed row reaches here only when it is a legacy alias superseded by + // the verified canonical desired row. An unobserved row needs no replacement. + workloadReleases.push({ claim, targetKey: claimTargetKey }); + } + + let released = 0; + for (const { claim, targetKey } of workloadReleases) { + if ( + await repos.hostPortClaim.releaseHostPortClaim({ + targetKey, + port: claim.port, + projectId: claim.projectId, + serviceId: claim.serviceId, + containerPort: claim.containerPort, + }) + ) { + released += 1; + } + } + for (const { claim, targetKey } of quarantineReleases) { + if ( + await repos.hostPortClaim.releaseQuarantinedHostPortClaim({ + targetKey, + port: claim.port, + }) + ) { + released += 1; + } + } + + return { released, retained }; +} + +/** Acquire the physical-target lock and safely converge this project's claims. */ +export function convergeTargetHostPortClaims( + input: ConvergeTargetHostPortClaimsInput, +): Promise { + return withHostPortTargetLock(input.target, () => convergeTargetHostPortClaimsUnlocked(input)); +} + +/** + * Find this workload's reservation on the target. Exact per-container claims + * win; a null-container legacy scalar is accepted only as a migration fallback. + */ +export function findOwnedPinnedHostPort( + claims: readonly PinnedHostPort[], + owner: PinnedHostPortOwner, + opts?: { allowLegacyContainerPort?: boolean }, +): PinnedHostPort | undefined { + const sameService = (claim: PinnedHostPort) => + !isQuarantineClaim(claim) && + claim.projectId === owner.projectId && + claim.serviceId === owner.serviceId; + return ( + claims.find((claim) => sameService(claim) && claim.containerPort === owner.containerPort) ?? + (opts?.allowLegacyContainerPort + ? claims.find((claim) => sameService(claim) && claim.containerPort === null) + : undefined) + ); +} + +export interface AllocateAndReservePinnedHostPortInput { + target: HostPortTargetIdentity; + claims: readonly PinnedHostPort[]; + owner: PinnedHostPortOwner; + /** Compatibility cache only; a reservation on this target always wins. */ + cachedPreferred?: number | null; + allowLegacyContainerPort?: boolean; + additionalAvoid?: Iterable; + allocate: (options: AllocateHostPortOptions) => Promise; +} + +export interface AllocatedPinnedHostPort extends HostPortAllocation { + preferred?: number; + previousClaim?: PinnedHostPort; + claim: HostPortClaim; +} + +/** + * Roll back reservations created by an activation attempt that never wrote a + * route. Carried claims are never touched: an older vhost or stopped workload + * may still depend on them. Callers must invoke this only while holding the + * target lock and only before any route for the attempted workload was written. + */ +export async function releaseNewPinnedHostPortClaims( + target: HostPortTargetIdentity, + allocations: Iterable>, +): Promise { + let released = 0; + for (const allocation of allocations) { + if (allocation.previousClaim) continue; + const claim = allocation.claim; + if (claim.targetKey !== target.targetKey) { + throw new Error("Cannot release a host-port claim from another physical target"); + } + if ( + await repos.hostPortClaim.releaseHostPortClaim({ + targetKey: claim.targetKey, + port: claim.port, + projectId: claim.projectId, + serviceId: claim.serviceId, + containerPort: claim.containerPort, + }) + ) { + released += 1; + } + } + return released; +} + +/** + * The single allocation seam for routed host ports. + * + * Callers hold {@link withHostPortTargetLock} around this call through the + * workload bind. Within that section we combine durable claims with live socket + * occupancy and then reserve the selected number before returning it to Docker. + */ +export async function allocateAndReservePinnedHostPort( + input: AllocateAndReservePinnedHostPortInput, +): Promise { + const previousClaim = findOwnedPinnedHostPort(input.claims, input.owner, { + allowLegacyContainerPort: input.allowLegacyContainerPort, + }); + const preferred = previousClaim?.port ?? input.cachedPreferred ?? undefined; + const reusable = previousClaim + ? { + ...input.owner, + containerPort: previousClaim.containerPort, + port: previousClaim.port, + } + : undefined; + const avoid = pinnedHostPortsToAvoid(input.claims, reusable); + for (const port of input.additionalAvoid ?? []) avoid.add(port); + + const allocation = await input.allocate({ + preferred, + avoid, + reuseOccupiedPreferred: reusable + ? ownsReusablePinnedHostPort(input.claims, reusable) && !avoid.has(reusable.port) + : false, + }); + const claim = await reserveTargetPinnedHostPort(input.target, { + ...input.owner, + // `null` is a real legacy identity, not a missing value. Preserve it until + // route-aware cleanup can upgrade/release the row without a reservation gap. + containerPort: previousClaim ? previousClaim.containerPort : input.owner.containerPort, + port: allocation.port, + }); + return { ...allocation, preferred, previousClaim, claim }; +} + +/** + * Convert owned claims into an allocator avoid-set, optionally releasing the + * caller's own carried claim. A number is released only when no other owner + * claims it, so corrupt/legacy duplicate rows fail safe instead of letting one + * service erase a sibling's reservation. + */ +export function pinnedHostPortsToAvoid( + claims: readonly PinnedHostPort[], + reusable?: ReusablePinnedHostPort, +): Set { + const avoid = new Set(claims.map((claim) => claim.port)); + if (!reusable) return avoid; + + const ownsClaim = ownsReusablePinnedHostPort(claims, reusable); + const anotherOwnerClaimsIt = claims.some( + (claim) => + claim.port === reusable.port && + (claim.projectId !== reusable.projectId || + claim.serviceId !== reusable.serviceId || + (claim.containerPort !== null && claim.containerPort !== reusable.containerPort)), + ); + if (ownsClaim && !anotherOwnerClaimsIt) avoid.delete(reusable.port); + return avoid; +} + +/** Whether the exact workload/port owns this pin on the target being queried. */ +export function ownsReusablePinnedHostPort( + claims: readonly PinnedHostPort[], + reusable: ReusablePinnedHostPort, +): boolean { + return claims.some( + (claim) => + !isQuarantineClaim(claim) && + claim.projectId === reusable.projectId && + claim.serviceId === reusable.serviceId && + (claim.containerPort === null || claim.containerPort === reusable.containerPort) && + claim.port === reusable.port, + ); +} + +/** Quarantine can never become a reusable workload claim, even after import. */ +export function isQuarantineClaim(claim: Pick): boolean { + return ( + claim.projectId === HOST_PORT_QUARANTINE_OWNER || claim.serviceId === HOST_PORT_QUARANTINE_OWNER + ); +} diff --git a/apps/api/src/modules/deployments/preflight.ts b/apps/api/src/modules/deployments/preflight.ts index d21860b4e..7db7fead7 100644 --- a/apps/api/src/modules/deployments/preflight.ts +++ b/apps/api/src/modules/deployments/preflight.ts @@ -15,6 +15,7 @@ import type { DeploymentConfigSnapshot } from "./build.service"; import { platform } from "../../lib/controller-helpers"; import { resolveEffectiveTarget, + resolvePlannedTargetTopology, usesManagedRouting as usesManagedRoutingFor, } from "../../lib/deployment-runtime"; import { @@ -520,10 +521,7 @@ async function checkPublicEndpoints( // Collection-level rule: a cloud static deploy supports at most one // explicit path-targeted endpoint. - if ( - isCloudStatic && - endpoints.filter((e) => typeof e.targetPath === "string").length > 1 - ) { + if (isCloudStatic && endpoints.filter((e) => typeof e.targetPath === "string").length > 1) { checks.push( fail( "endpoint-static-cloud-shape", @@ -600,13 +598,21 @@ async function checkPublicEndpoints( const hostname = endpoint.customDomain ? normalizeCustomHostname(endpoint.customDomain) : ""; if (!hostname) { checks.push( - fail(idOf("domain"), `Endpoint domain (${label})`, "Custom endpoint domains cannot be empty."), + fail( + idOf("domain"), + `Endpoint domain (${label})`, + "Custom endpoint domains cannot be empty.", + ), ); return; } if (seenHostnames.has(hostname)) { checks.push( - fail(idOf("domain"), `Endpoint domain (${label})`, `Duplicate domain configured: ${hostname}`), + fail( + idOf("domain"), + `Endpoint domain (${label})`, + `Duplicate domain configured: ${hostname}`, + ), ); return; } @@ -618,7 +624,11 @@ async function checkPublicEndpoints( const slug = endpoint.domain?.trim().toLowerCase(); if (!slug) { checks.push( - fail(idOf("slug"), `Endpoint subdomain (${label})`, "Free endpoint subdomains cannot be empty."), + fail( + idOf("slug"), + `Endpoint subdomain (${label})`, + "Free endpoint subdomains cannot be empty.", + ), ); return; } @@ -630,7 +640,11 @@ async function checkPublicEndpoints( const hostname = `${slug}.${baseDomain}`; if (seenHostnames.has(hostname)) { checks.push( - fail(idOf("domain"), `Endpoint domain (${label})`, `Duplicate domain configured: ${hostname}`), + fail( + idOf("domain"), + `Endpoint domain (${label})`, + `Duplicate domain configured: ${hostname}`, + ), ); return; } @@ -651,7 +665,11 @@ async function checkPublicEndpoints( ? await requestCloudPreflight(snapshot, { customDomain: lk.hostname }) : cloud; const result = await checkCustomDomain(lk.hostname, endpointCloud, snapshot); - return { ...result, id: `endpoint-${lk.index}-domain`, label: `Endpoint domain (${lk.label})` }; + return { + ...result, + id: `endpoint-${lk.index}-domain`, + label: `Endpoint domain (${lk.label})`, + }; } // Redeploy reclaiming a subdomain this project already holds live is not // a conflict — skip the cloud availability probe entirely for it. @@ -790,8 +808,7 @@ async function resolveCloudPreflight( // to ping cloud preflight. Cloud-target deploys obviously need it // too (cloud IS doing the deploy). Single authority shared with the pipeline. const usesManagedRouting = usesManagedRoutingFor(plat.target, effectiveTarget); - const hasManagedPublicEndpoints = - storedPublicEndpointsNeedCloud(opts?.publicEndpoints); + const hasManagedPublicEndpoints = storedPublicEndpointsNeedCloud(opts?.publicEndpoints); // The project-level free-domain slug is a routable web hostname only for a // single-app project. In services mode there is no project domain — each // service routes via its own endpoint (needsManagedComposeDomains), so an @@ -801,8 +818,7 @@ async function resolveCloudPreflight( const needsManagedProjectDomain = (!opts?.multiService && !!opts?.slug && !opts?.customDomain && usesManagedRouting) || (usesManagedRouting && hasManagedPublicEndpoints); - const needsManagedComposeDomains = - composeServicesNeedCloud(opts?.composeServices, opts?.slug); + const needsManagedComposeDomains = composeServicesNeedCloud(opts?.composeServices, opts?.slug); const needsCloudPreflight = effectiveTarget === "cloud" || needsManagedProjectDomain || needsManagedComposeDomains; const requestInput = opts?.publicEndpoints?.length @@ -827,6 +843,17 @@ async function resolveCloudPreflight( function checkConfig(snapshot: DeploymentConfigSnapshot, opts?: PreflightOptions): PreflightCheck { const missing: string[] = []; + const releaseImageRef = snapshot.releaseImageRef?.trim(); + + if (releaseImageRef && opts?.multiService) { + return { + id: "config", + label: "Service configuration", + status: "fail", + message: + "A project-level release image deploys one app. Configure images on the individual services for a multi-service project.", + }; + } // A FULLY PINNED deploy (a rollback restoring a retained artifact, a migration // cutover) builds nothing and clones nothing: it runs an artifact that was @@ -849,10 +876,12 @@ function checkConfig(snapshot: DeploymentConfigSnapshot, opts?: PreflightOptions // A folder-upload deploy has no git and no host path — its source is the // pre-staged upload workspace (`sourceStaged`, set by requestBuildAccess). // That's a valid source, so it satisfies both the source and branch checks. - if (!snapshot.repoUrl && !snapshot.localPath && !snapshot.sourceStaged) { + if (!snapshot.repoUrl && !snapshot.localPath && !snapshot.sourceStaged && !releaseImageRef) { missing.push("repository URL or local path"); } - if (!snapshot.branch && !snapshot.localPath && !snapshot.sourceStaged) missing.push("branch"); + if (!snapshot.branch && !snapshot.localPath && !snapshot.sourceStaged && !releaseImageRef) { + missing.push("branch"); + } if (opts?.multiService) { // A services/compose deploy needs a project repo/localPath ONLY when some @@ -982,14 +1011,16 @@ function checkConfig(snapshot: DeploymentConfigSnapshot, opts?: PreflightOptions // image), so buildImage is never consumed — refusing the deploy for a missing // buildImage there is wrong (it blocked repo-Dockerfile + self-app deploys). // Mirrors the multi-service branch's dockerfile/build check. #231 - if (snapshot.framework !== "docker" && !snapshot.buildImage) missing.push("build image"); + if (!releaseImageRef && snapshot.framework !== "docker" && !snapshot.buildImage) { + missing.push("build image"); + } // A single-project Dockerfile owns install, build, and process startup. Those // buildpack commands are deliberately empty after repository/folder detection // and are not consumed by the Docker pipeline. The workload-specific checks // below still enforce a port for web apps. const dockerOwnsBuild = snapshot.framework === "docker"; - if (!dockerOwnsBuild && snapshot.hasBuild && !snapshot.installCommand) { + if (!releaseImageRef && !dockerOwnsBuild && snapshot.hasBuild && !snapshot.installCommand) { missing.push("install command"); } @@ -997,7 +1028,9 @@ function checkConfig(snapshot: DeploymentConfigSnapshot, opts?: PreflightOptions if (cls.workload === "web") { // A web app is reached on a port and must declare how it starts and listens. // Dockerfile apps inherit their process command from the image. - if (!dockerOwnsBuild && !snapshot.startCommand) missing.push("start command"); + // A prebuilt image may intentionally inherit its Dockerfile CMD. + if (!releaseImageRef && !dockerOwnsBuild && !snapshot.startCommand) + missing.push("start command"); if (!snapshot.port) missing.push("port"); } else if (cls.workload === "worker") { // A worker is a portless long-running container (#538-B): it needs a command @@ -1227,9 +1260,7 @@ async function checkCustomDomainSelfHosted( * at the cloud edge directly. Non-blocking; the .opsh.io free domain * stays attached so the deploy still ships. */ -async function checkCustomDomainCloudCname( - customDomain: string, -): Promise { +async function checkCustomDomainCloudCname(customDomain: string): Promise { const records = await resolveRecords(customDomain, "CNAME", { timeoutMs: DOMAIN_CHECK_TIMEOUT_MS, }); @@ -1418,11 +1449,12 @@ export async function runPreflightChecks( const hasEndpointRouting = !!opts?.publicEndpoints?.length; const hasManagedProjectDomain = !opts?.multiService && - !hasEndpointRouting && !!opts?.slug && !opts?.customDomain && usesManagedRouting; - const hasManagedPublicEndpoints = - storedPublicEndpointsNeedCloud(opts?.publicEndpoints); - const hasManagedComposeDomains = - composeServicesNeedCloud(opts?.composeServices, opts?.slug); + !hasEndpointRouting && + !!opts?.slug && + !opts?.customDomain && + usesManagedRouting; + const hasManagedPublicEndpoints = storedPublicEndpointsNeedCloud(opts?.publicEndpoints); + const hasManagedComposeDomains = composeServicesNeedCloud(opts?.composeServices, opts?.slug); const cloudRequirement = effectiveTarget === "cloud" ? "cloud-runtime" @@ -1492,16 +1524,37 @@ export async function runPreflightChecks( // demands, and the deploy-time clone goes anonymous (clone-auth.ts). const ghRepo = parseGithubOwnerRepo(snapshot.repoUrl, opts?.gitOwner, opts?.gitRepo); const repoIsPublic = ghRepo ? await isPublicRepo(ghRepo.owner, ghRepo.repo) : false; + const needsGitCredentialPlan = + !repoIsPublic && !!opts?.gitOwner && snapshotNeedsGitSource(snapshot, opts?.composeServices); + const runtimeMode = snapshot.runtimeMode ?? "docker"; + const plannedTarget = needsGitCredentialPlan + ? await resolvePlannedTargetTopology( + effectiveTarget, + snapshot.serverId, + snapshot.organizationId, + ) + : null; + const clonePlan = resolveClonePlan({ + effectiveTarget, + serverId: plannedTarget?.serverId ?? snapshot.serverId, + runtimeIsBare: runtimeMode === "bare", + cloneStrategy: snapshot.cloneStrategy, + buildStrategy: effectiveBuildStrategy, + isDesktop: plat.target === "desktop", + forwardGitCredentials: snapshot.forwardGitCredentials, + repoIsGithub: !!opts?.gitOwner, + dockerTransport: runtimeMode === "docker" ? plannedTarget?.dockerTransport : undefined, + }); - // GitHub App installation check — only relevant when the repo is cloned on a - // REMOTE build worker (server build). A LOCAL build ("Build on this machine") - // clones on the API host using local credentials (gh CLI / OAuth), so the - // cloud App installation is irrelevant — skip it. This mirrors the - // remote-clone-token check below, which already passes for local builds. - if (!repoIsPublic && getGitHubAuthMode() === "app" && effectiveBuildStrategy !== "local") { - checks.push( - await checkGitHubAppInstallation(githubCtx, opts?.gitOwner), - ); + // Installation auth is relevant only beyond the API-host credential boundary. + // In particular, a server-row deployment over a local Docker socket still + // acquires source in the API container and must not be treated as remote. + if ( + needsGitCredentialPlan && + getGitHubAuthMode() === "app" && + clonePlan.cloneCredentialPurpose === "server" + ) { + checks.push(await checkGitHubAppInstallation(githubCtx, opts?.gitOwner)); } // A remote clone credential is only needed when the repo is actually cloned @@ -1511,17 +1564,15 @@ export async function runPreflightChecks( // the API host), and cloud builds clone inside the workspace. So the two // credential checks below apply only to bare + server; otherwise the clone is // local and these checks would wrongly demand a remote/App/cloud credential. - const runtimeMode = snapshot.runtimeMode ?? "docker"; const clonesOnRemote = - !repoIsPublic && + needsGitCredentialPlan && runtimeMode === "bare" && // Only a WEB workload can build on a bare remote worker: static apps build // in a Docker sandbox and workers build in Docker (both clone on the // orchestrator), so neither ever needs a remote clone credential even when // runtimeMode is "bare" (#538-B). snapshotToClass(snapshot).workload === "web" && - effectiveTarget === "server" && - effectiveBuildStrategy !== "local"; + clonePlan.cloneRunsOnTarget; if (clonesOnRemote) { // Remote-build credential check. For App-scoped modes (app / cloud-app): @@ -1561,20 +1612,7 @@ export async function runPreflightChecks( // is already covered by the hard-fail clonesOnRemote checks above. // Same clone decision the build pipeline uses (resolveClonePlan) — so this // credential check verifies exactly the clone the pipeline will perform. - const dockerClonesOnServer = resolveClonePlan({ - effectiveTarget, - serverId: snapshot.serverId, - runtimeIsBare: runtimeMode === "bare", - cloneStrategy: snapshot.cloneStrategy, - buildStrategy: effectiveBuildStrategy, - isDesktop: plat.target === "desktop", - forwardGitCredentials: snapshot.forwardGitCredentials, - // GitHub projects carry a parsed gitOwner; docker acquires the source - // tarball on the server for them. Same structured signal the pipeline uses - // (`!!project.gitOwner`) so the two decisions can't drift. - repoIsGithub: !!opts?.gitOwner, - }).dockerClonesOnServer; - if (dockerClonesOnServer) { + if (needsGitCredentialPlan && clonePlan.dockerClonesOnTarget) { checks.push( await checkCloneOnServerCredential( githubCtx, @@ -1592,12 +1630,25 @@ export async function runPreflightChecks( if (opts?.composeServices?.length) { checks.push( - ...(await checkComposeServiceDomains(opts.composeServices, opts.slug, cloudPreflight, snapshot)), + ...(await checkComposeServiceDomains( + opts.composeServices, + opts.slug, + cloudPreflight, + snapshot, + )), ); } if (opts?.publicEndpoints?.length) { - checks.push(...(await checkPublicEndpoints(snapshot, opts.publicEndpoints, cloudPreflight, opts.ctx, opts.projectId))); + checks.push( + ...(await checkPublicEndpoints( + snapshot, + opts.publicEndpoints, + cloudPreflight, + opts.ctx, + opts.projectId, + )), + ); } // Catch the "this deploy will have no public URL" foot-gun: self-hosted, @@ -1611,9 +1662,7 @@ export async function runPreflightChecks( const hasAnyEndpointDomain = (opts?.publicEndpoints ?? []).some( (endpoint) => !!endpoint.domain || !!endpoint.customDomain, ); - const hasAnyComposeExposed = (opts?.composeServices ?? []).some( - (service) => service.exposed, - ); + const hasAnyComposeExposed = (opts?.composeServices ?? []).some((service) => service.exposed); const willHavePublicUrl = effectiveTarget === "cloud" || cloudRequirement !== "none" || diff --git a/apps/api/src/modules/deployments/prepare.service.ts b/apps/api/src/modules/deployments/prepare.service.ts index 9f0a75ed8..e7657d618 100644 --- a/apps/api/src/modules/deployments/prepare.service.ts +++ b/apps/api/src/modules/deployments/prepare.service.ts @@ -50,6 +50,7 @@ import { } from "@repo/core"; import { env } from "../../config"; import { createGitHubReader, type ProjectReader } from "./project-reader"; +import { ComposeConfigurationError } from "./compose-configuration-error"; const PREPARE_FILE_CONTENTS = [ ...MANIFEST_FILES, @@ -112,7 +113,7 @@ export interface ResolveOptions { } /** Thrown when a declared `composePath` has no compose file behind it. */ -class ComposePathNotFoundError extends Error { +class ComposePathNotFoundError extends ComposeConfigurationError { constructor(message: string) { super(message); this.name = "ComposePathNotFoundError"; @@ -959,7 +960,10 @@ function toProjectInfo( // only parse when compose IS this root's stack. const detail = err instanceof Error && err.message ? err.message : "Unknown parser error"; const where = opts?.declaredCompose ? ` at "${projectRoot.rootDirectory || "."}"` : ""; - throw new Error(`Could not parse the Docker Compose file${where}: ${detail}`, { cause: err }); + throw new ComposeConfigurationError( + `Could not parse the Docker Compose file${where}: ${detail}`, + { cause: err }, + ); } // A BLOCKING key refuses the import, outside the parse try/catch so it never @@ -972,7 +976,7 @@ function toProjectInfo( const blocking = blockingComposeFields(unsupportedCompose ?? []); if (blocking.length > 0) { const where = opts?.declaredCompose ? ` at "${projectRoot.rootDirectory || "."}"` : ""; - throw new Error( + throw new ComposeConfigurationError( `The Docker Compose file${where} declares options Openship can't deploy faithfully:\n` + describeBlockingComposeFields(blocking), ); diff --git a/apps/api/src/modules/deployments/rollback/restore-plan.test.ts b/apps/api/src/modules/deployments/rollback/restore-plan.test.ts index dab414a4a..3732d7a3f 100644 --- a/apps/api/src/modules/deployments/rollback/restore-plan.test.ts +++ b/apps/api/src/modules/deployments/rollback/restore-plan.test.ts @@ -7,6 +7,8 @@ import { type RestorePlanInput, } from "./restore-plan"; +const FROZEN_RELEASE_IMAGE = `ghcr.io/acme/app@sha256:${"a".repeat(64)}`; + /** A restorable single-app release on a Docker host, unless overridden. */ const input = (over: Partial = {}): RestorePlanInput => ({ target: { @@ -77,6 +79,62 @@ describe("planRestore — docker (image is the artifact)", () => { expect(plan).toEqual({ mode: "rebuild", commitSha: "abc1234def" }); }); + it("keeps the retained-image path instant for a release image", () => { + const plan = planRestore( + input({ + target: { + imageRef: FROZEN_RELEASE_IMAGE, + meta: { releaseImageRef: FROZEN_RELEASE_IMAGE, serviceDeploymentMode: "single" }, + } as never, + }), + ); + expect(plan).toEqual({ + mode: "redeploy-pinned", + handoverImages: {}, + handoverAppImage: FROZEN_RELEASE_IMAGE, + rebuildServices: [], + }); + }); + + it("reacquires the immutable frozen release image when local retention expired", () => { + const plan = planRestore( + input({ + target: { + imageRef: "ghcr.io/acme/app:v1.2.3", + meta: { + // This is what the successful deploy actually prepared. The tag in + // imageRef (and today's project template) must not replace it. + releaseImageRef: FROZEN_RELEASE_IMAGE, + serviceDeploymentMode: "single", + }, + } as never, + imagePresent: () => false, + }), + ); + expect(plan).toEqual({ + mode: "reacquire-image", + releaseImageRef: FROZEN_RELEASE_IMAGE, + }); + expect(planNeedsRepository(plan)).toBe(false); + }); + + it("reacquires a frozen release image without a commit", () => { + const plan = planRestore( + input({ + target: { + imageRef: FROZEN_RELEASE_IMAGE, + commitSha: null, + meta: { releaseImageRef: FROZEN_RELEASE_IMAGE }, + } as never, + imagePresent: () => false, + }), + ); + expect(plan).toEqual({ + mode: "reacquire-image", + releaseImageRef: FROZEN_RELEASE_IMAGE, + }); + }); + it("never treats the compose sentinel as a real image", () => { // A compose release stores "compose" in image_ref; taking it literally is // what made restore try `createContainer({ Image: "compose" })`. @@ -153,6 +211,24 @@ describe("planRestore — compose (per-service images)", () => { expect(plan).toEqual({ mode: "rebuild", commitSha: "abc1234def" }); }); + it("never treats a stray single-app release ref as a compose fallback", () => { + const plan = planRestore( + input({ + target: { + imageRef: COMPOSE_SENTINEL, + commitSha: null, + meta: { releaseImageRef: FROZEN_RELEASE_IMAGE, serviceDeploymentMode: "services" }, + } as never, + serviceImages: services, + imagePresent: () => false, + }), + ); + expect(plan).toMatchObject({ + mode: "ineligible", + code: ROLLBACK_ERROR_CODES.ARTIFACT_GONE, + }); + }); + it("skips rows with no service name (nothing to key a handover by)", () => { const plan = planRestore( input({ @@ -229,9 +305,7 @@ describe("planRestore — bare/cloud (the unit is the artifact)", () => { }); it("falls back when the unit id is gone", () => { - const plan = planRestore( - input({ unitRestore: true, target: { containerId: null } as never }), - ); + const plan = planRestore(input({ unitRestore: true, target: { containerId: null } as never })); expect(plan.mode).toBe("redeploy-pinned"); }); }); @@ -249,6 +323,21 @@ describe("planNeedsRepository — what gates the clone + GitHub access", () => { expect(planNeedsRepository(planRestore(input({ imagePresent: () => false })))).toBe(true); }); + it("is false when an immutable release image will be reacquired", () => { + const plan = planRestore( + input({ + target: { + imageRef: FROZEN_RELEASE_IMAGE, + commitSha: null, + meta: { releaseImageRef: FROZEN_RELEASE_IMAGE }, + } as never, + imagePresent: () => false, + }), + ); + expect(plan.mode).toBe("reacquire-image"); + expect(planNeedsRepository(plan)).toBe(false); + }); + it("is false for an ineligible plan (nothing will run)", () => { expect( planNeedsRepository(planRestore(input({ project: { activeDeploymentId: "dep-old" } }))), diff --git a/apps/api/src/modules/deployments/rollback/restore-plan.ts b/apps/api/src/modules/deployments/rollback/restore-plan.ts index 41eea54a9..5cd7435b5 100644 --- a/apps/api/src/modules/deployments/rollback/restore-plan.ts +++ b/apps/api/src/modules/deployments/rollback/restore-plan.ts @@ -22,13 +22,20 @@ * health gate and the stabilization watch all come from * the deploy pipeline rather than being re-implemented. * + * "reacquire-image" A single-app release image aged out locally, but the + * target's frozen snapshot recorded its concrete registry + * reference (normally an immutable repo digest). Replay + * that snapshot so the normal prebuilt-image path pulls + * the exact artifact again — no repository, commit, or + * current release template involved. + * * "rebuild" No artifact left, but we know the commit. Same deploy * call, minus the pinned images: it re-clones and rebuilds * that commit. Slower, always correct. * - * INVARIANT: a rollback never dead-ends. If the target's commit is known it is - * restorable; the only question is instant vs rebuild. That's why "the artifact - * was pruned" is not an error here — it's just the slower branch. + * INVARIANT: a rollback never dead-ends when it has a reproducible source. A + * frozen release-image ref is reacquired; a known commit is rebuilt. That's why + * "the artifact was pruned" is not itself an error — it selects a slower branch. * * Pure and synchronous by construction (image presence is resolved by the * caller and passed in), so every branch is unit-testable without a daemon — @@ -46,8 +53,7 @@ export const ROLLBACK_ERROR_CODES = { UNSUPPORTED_RUNTIME: "ROLLBACK_UNSUPPORTED_RUNTIME", } as const; -export type RollbackErrorCode = - (typeof ROLLBACK_ERROR_CODES)[keyof typeof ROLLBACK_ERROR_CODES]; +export type RollbackErrorCode = (typeof ROLLBACK_ERROR_CODES)[keyof typeof ROLLBACK_ERROR_CODES]; /** * Does this project want past artifacts held so a restore can skip the build? @@ -80,6 +86,11 @@ export type RestorePlan = * (a service with no pinned image simply falls into the buildable set). */ rebuildServices: string[]; } + | { + mode: "reacquire-image"; + /** Concrete reference frozen by the successful target deployment. */ + releaseImageRef: string; + } | { mode: "rebuild"; commitSha: string } | { mode: "ineligible"; code: RollbackErrorCode; message: string }; @@ -118,9 +129,7 @@ export interface RestorePlanInput { * container/workspace id — the same convention `DockerRuntime.destroy` and * `cleanupBuildArtifact` key off. Its artifact is that directory of built files. */ -export function staticReleaseDir(target: { - containerId: string | null; -}): string | null { +export function staticReleaseDir(target: { containerId: string | null }): string | null { const id = target.containerId?.trim(); return id && id.startsWith("/") ? id : null; } @@ -216,6 +225,27 @@ export function planRestore(input: RestorePlanInput): RestorePlan { }; } + // A tracked single-app release image is reproducible without source code. A + // successful deploy freezes the concrete ref it actually prepared (Docker + // replaces a mutable tag with its repo digest), so replaying this snapshot can + // pull those exact bytes after local image GC. Do this BEFORE the commit + // fallback: release-image projects legitimately have no commit, and rebuilding + // a now-linked repository would restore a different source entirely. + const frozen = target.meta as + | { releaseImageRef?: unknown; serviceDeploymentMode?: unknown } + | null + | undefined; + const frozenReleaseImageRef = + typeof frozen?.releaseImageRef === "string" ? usableRef(frozen.releaseImageRef) : null; + const hasNamedServices = (input.serviceImages ?? []).some((row) => !!row.serviceName?.trim()); + const composeRelease = + target.imageRef?.trim() === COMPOSE_SENTINEL || + frozen?.serviceDeploymentMode === "services" || + hasNamedServices; + if (frozenReleaseImageRef && !composeRelease) { + return { mode: "reacquire-image", releaseImageRef: frozenReleaseImageRef }; + } + // Nothing retained — but the commit is the other artifact. if (target.commitSha) { return { mode: "rebuild", commitSha: target.commitSha }; @@ -229,8 +259,9 @@ export function planRestore(input: RestorePlanInput): RestorePlan { }; } -/** Does this plan need the repository? Only a rebuild clones — which is what - * lets an instant restore skip the GitHub-access gate and the git token. */ +/** Does this plan need the repository? Only a rebuild (including a mixed compose + * restore) clones. Reacquiring a frozen registry image needs the registry, not + * a Git repository, commit, or source token. */ export function planNeedsRepository(plan: RestorePlan): boolean { if (plan.mode === "rebuild") return true; if (plan.mode === "redeploy-pinned") return plan.rebuildServices.length > 0; diff --git a/apps/api/src/modules/deployments/rollback/rollback-orchestrator.test.ts b/apps/api/src/modules/deployments/rollback/rollback-orchestrator.test.ts new file mode 100644 index 000000000..cb719d3d9 --- /dev/null +++ b/apps/api/src/modules/deployments/rollback/rollback-orchestrator.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const FROZEN_RELEASE_IMAGE = `ghcr.io/acme/app@sha256:${"b".repeat(64)}`; + +interface TriggerRequest { + projectId: string; + branch?: string | null; + trigger: string; + forceAll: boolean; + commitSha?: string; + commitShaBefore?: string; + reuseSnapshot: { + envVars: Record | null; + meta: Record; + }; +} + +const h = vi.hoisted(() => ({ + imagePresent: false, + inspectedImages: [] as string[], + triggerDeployment: vi.fn(), + target: null as Record | null, + active: null as Record | null, + project: null as Record | null, +})); + +vi.mock("@repo/db", () => ({ + repos: { + deployment: { + findById: async (id: string) => + id === h.target?.id ? h.target : id === h.active?.id ? h.active : null, + }, + project: { findById: async () => h.project }, + service: { listByDeployment: async () => [] }, + serviceDeployment: { effectiveImagesAsOf: async () => new Map() }, + member: { listByOrganization: async () => [{ userId: "org-owner" }] }, + }, +})); + +vi.mock("@repo/adapters", () => { + class DockerRuntime { + name = "docker"; + + supports(): boolean { + return false; + } + + async imageExistsLocally(ref: string): Promise { + h.inspectedImages.push(ref); + return h.imagePresent; + } + + async dispose(): Promise {} + } + + return { DockerRuntime }; +}); + +vi.mock("../../../lib/deployment-runtime", async () => { + const { DockerRuntime } = await import("@repo/adapters"); + return { + // The production class intentionally has a private constructor; the mock + // factory supplies a public test double at runtime, so instantiate it from + // its prototype without weakening the production constructor contract. + resolveDeploymentRuntime: async () => ({ runtime: Object.create(DockerRuntime.prototype) }), + }; +}); + +vi.mock("../build.service", () => ({ + checkNoActiveBuild: vi.fn(), + triggerDeployment: h.triggerDeployment, +})); + +import { rollback } from "./rollback-orchestrator"; + +beforeEach(() => { + h.imagePresent = false; + h.inspectedImages = []; + h.triggerDeployment.mockReset(); + h.triggerDeployment.mockResolvedValue({ deployment: { id: "dep-restored" } }); + + h.target = { + id: "dep-target", + projectId: "project-1", + organizationId: "org-1", + status: "ready", + containerId: "old-container", + imageRef: "ghcr.io/acme/app:v1.2.3", + commitSha: null, + commitShaBefore: null, + commitMessage: "Release v1.2.3", + branch: null, + environment: "production", + envVars: { API_KEY: "encrypted-frozen" }, + artifactRetainedAt: new Date("2026-08-01T00:00:00Z"), + createdAt: new Date("2026-08-01T00:00:00Z"), + meta: { + framework: "docker", + branch: "frozen-release-branch", + source: "image", + build: "prebuilt", + workload: "web", + serviceDeploymentMode: "single", + releaseVersion: "1.2.3", + releaseTag: "v1.2.3", + releaseImageRef: FROZEN_RELEASE_IMAGE, + // A pin from an earlier restore must not turn reacquisition into a local + // handover. The planner proved the local image is absent. + handoverAppImage: "ghcr.io/acme/app:stale-local-pin", + }, + }; + h.active = { + id: "dep-active", + commitSha: "newer-commit", + }; + h.project = { + id: "project-1", + organizationId: "org-1", + activeDeploymentId: "dep-active", + defaultRollbackStrategy: "snapshot", + gitProvider: "release", + // Deliberately different from the frozen ref. Rollback must not render this + // current template or resolve its current tag. + releaseSource: { + mode: "github", + artifactKind: "image", + repo: "acme/app", + imageTemplate: "ghcr.io/acme/app:changed-{tag}", + }, + }; +}); + +describe("rollback — reacquire a frozen release image", () => { + it("replays the immutable snapshot without a commit, repository, or stale local pin", async () => { + await rollback("dep-target"); + + expect(h.inspectedImages).toEqual(["ghcr.io/acme/app:v1.2.3"]); + expect(h.triggerDeployment).toHaveBeenCalledTimes(1); + const [, request] = h.triggerDeployment.mock.calls[0] as [unknown, TriggerRequest]; + + expect(request).toMatchObject({ + projectId: "project-1", + branch: "frozen-release-branch", + trigger: "rollback", + forceAll: true, + commitShaBefore: "newer-commit", + }); + expect(request.commitSha).toBeUndefined(); + expect(request.reuseSnapshot.envVars).toEqual({ API_KEY: "encrypted-frozen" }); + expect(request.reuseSnapshot.meta.releaseImageRef).toBe(FROZEN_RELEASE_IMAGE); + expect(request.reuseSnapshot.meta.releaseTag).toBe("v1.2.3"); + expect(request.reuseSnapshot.meta.handoverAppImage).toBeUndefined(); + expect(JSON.stringify(request.reuseSnapshot.meta)).not.toContain("changed-{tag}"); + }); +}); diff --git a/apps/api/src/modules/deployments/rollback/rollback-orchestrator.ts b/apps/api/src/modules/deployments/rollback/rollback-orchestrator.ts index d89d29d58..6d64815c6 100644 --- a/apps/api/src/modules/deployments/rollback/rollback-orchestrator.ts +++ b/apps/api/src/modules/deployments/rollback/rollback-orchestrator.ts @@ -19,16 +19,18 @@ * ── Restore ────────────────────────────────────────────────────────────── * * `rollback(id)` asks `planRestore` how this particular release can come back - * (see restore-plan.ts for the three modes and why), then executes: + * (see restore-plan.ts for the modes and why), then executes: * - * redeploy-pinned / rebuild → ONE `triggerDeployment` call carrying the - * target's frozen config + env, with its images pinned when they're still - * on the host. A restore is a real deployment: it gets the deploy pipeline's - * env, ports, volumes, labels, network, routing, health gate and - * stabilization watch for free, its own logs and SSE stream, and — because - * `onSuccess` reuses the version number for a commit — rolling back to v2 - * shows up as v2 again. The active pointer only ever moves FORWARD on - * success, so a failed restore leaves the current release serving. + * redeploy-pinned / reacquire-image / rebuild → ONE `triggerDeployment` call + * carrying the target's frozen config + env. Retained images are pinned; + * an expired release image is pulled again from its frozen concrete ref; + * only a source rebuild uses the commit/repository. A restore is a real + * deployment: it gets the deploy pipeline's env, ports, volumes, labels, + * network, routing, health gate and stabilization watch for free, its own + * logs and SSE stream, and — because `onSuccess` reuses the version number + * for a commit — rolling back to v2 shows up as v2 again. The active pointer + * only ever moves FORWARD on success, so a failed restore leaves the current + * release serving. * * unit-swap → `runtime.makeActive`, then probe and commit the pointer, with * a compensating swap-back if the DB write fails. @@ -143,8 +145,8 @@ export async function onDeploymentReady(opts: { * Resolve HOW a rollback to this deployment would run, without running it. * * Shared by `rollback()` and the restore-plan endpoint, so the confirm dialog's - * copy ("instant" vs "rebuild"), the GitHub-access gate and the executor can - * never disagree about the mode. + * copy (instant, registry reacquisition, or source rebuild), the GitHub-access + * gate and the executor can never disagree about the mode. */ export async function resolveRestorePlan(targetDeploymentId: string): Promise<{ target: Deployment; @@ -188,9 +190,9 @@ export async function resolveRestorePlan(targetDeploymentId: string): Promise<{ if (staticDir) staticDirPresent = await hostPathExists(target, staticDir); } catch (err) { // Host unreachable / server row gone: we can't prove an artifact is there, so - // plan the safe branch (rebuild) rather than promising an instant restore. + // plan a safe non-retained recovery rather than promising an instant restore. console.warn( - `[rollback] Could not inspect the host for ${target.id}; planning a rebuild: ${safeErrorMessage(err)}`, + `[rollback] Could not inspect the host for ${target.id}; planning artifact recovery: ${safeErrorMessage(err)}`, ); } @@ -285,7 +287,8 @@ export async function rollback(targetDeploymentId: string): Promise { /** * Restore by deploying the target's frozen snapshot again, with its retained - * images pinned (`redeploy-pinned`) or rebuilt from its commit (`rebuild`). + * images pinned (`redeploy-pinned`), a frozen release image pulled again + * (`reacquire-image`), or source rebuilt from its commit (`rebuild`). * * Calls `triggerDeployment` (build.service) directly — no cycle: the * orchestrator already statically depends on build.service (for @@ -295,7 +298,7 @@ export async function rollback(targetDeploymentId: string): Promise { async function restoreViaRedeploy( target: Deployment, project: NonNullable>>, - plan: Extract, + plan: Extract, ): Promise { // Where are we rolling back FROM? The currently-active release's commit — // recorded so this restore is itself reversible. @@ -315,15 +318,24 @@ async function restoreViaRedeploy( // Ship the target's CAPTURED config + env verbatim — not a fresh snapshot from // the project's current columns / env_var table — so the restore runs exactly // what originally ran. `handoverImages` / `handoverAppImage` are the only - // fields we overwrite: they pin the retained artifacts so the deploy skips the - // build (and, when everything is pinned, the clone and git token too). + // fields we overwrite: handover fields pin retained artifacts; the reacquire + // branch reasserts the exact release ref selected from this same snapshot. const frozen = (target.meta ?? {}) as DeploymentConfigSnapshot; const meta = withoutPinnedArtifacts({ ...frozen }); if (plan.mode === "redeploy-pinned") { if (Object.keys(plan.handoverImages).length > 0) meta.handoverImages = plan.handoverImages; if (plan.handoverAppImage) meta.handoverAppImage = plan.handoverAppImage; if (plan.handoverStaticDir) meta.handoverStaticDir = plan.handoverStaticDir; + } else if (plan.mode === "reacquire-image") { + // Carry the ref selected by the pure plan back onto the cloned snapshot. Do + // not call the release resolver here: today's project template/source may + // differ, while this digest is the artifact the target actually ran. + meta.releaseImageRef = plan.releaseImageRef; } + const replayBranch = + plan.mode === "reacquire-image" + ? (typeof meta.branch === "string" && meta.branch.trim()) || target.branch?.trim() || "main" + : target.branch; // Attribute the deploy to an org member so token resolution has an actor; the // triggerer is the system. @@ -338,8 +350,13 @@ async function restoreViaRedeploy( await triggerDeployment(rollbackCtx, { projectId: target.projectId, - branch: target.branch, - commitSha: target.commitSha ?? undefined, + // Supplying a concrete frozen/default branch also prevents the generic + // trigger path from asking today's linked repository for its default branch. + branch: replayBranch, + // A release-image reacquisition is intentionally commit-free. Passing even + // a display-only abbreviated SHA makes triggerDeployment canonicalize it + // through the project's CURRENT repository, violating rollback isolation. + commitSha: plan.mode === "reacquire-image" ? undefined : (target.commitSha ?? undefined), commitMessage: target.commitMessage ?? (target.commitSha ? `Rollback to ${target.commitSha.slice(0, 7)}` : "Rollback"), @@ -609,10 +626,7 @@ export const PIN_ERROR_CODES = { ARTIFACT_GONE: "PIN_ARTIFACT_GONE", } as const; -export async function setPin( - deploymentId: string, - pinned: boolean, -): Promise { +export async function setPin(deploymentId: string, pinned: boolean): Promise { const dep = await repos.deployment.findById(deploymentId); if (!dep) { throw new AppError("Deployment not found", 404, "DEPLOYMENT_NOT_FOUND"); diff --git a/apps/api/src/modules/domains/domain.controller.ts b/apps/api/src/modules/domains/domain.controller.ts index c2d3992dc..019bb37da 100644 --- a/apps/api/src/modules/domains/domain.controller.ts +++ b/apps/api/src/modules/domains/domain.controller.ts @@ -249,7 +249,7 @@ export async function setPrimary(c: Context) { /** POST /domains/preview - get DNS records for a hostname (no DB write) */ export async function preview(c: Context) { const ctx = getRequestContext(c); - const body = await c.req.json<{ hostname: string; includeWww?: boolean }>(); + const body = await c.req.json<{ hostname: string; includeWww?: boolean; serverId?: string }>(); if (!body.hostname?.trim()) { return c.json({ error: "hostname is required" }, 400); } @@ -257,6 +257,7 @@ export async function preview(c: Context) { body.hostname.trim().toLowerCase(), ctx.organizationId, body.includeWww === true, + body.serverId, ); return c.json({ data: result }); } diff --git a/apps/api/src/modules/domains/domain.schema.ts b/apps/api/src/modules/domains/domain.schema.ts index 80e136c6d..2a3b6b7bf 100644 --- a/apps/api/src/modules/domains/domain.schema.ts +++ b/apps/api/src/modules/domains/domain.schema.ts @@ -61,6 +61,13 @@ export const UploadCertBody = Type.Object({ /** POST /preview — side-effect-free DNS-records preview for a hostname. */ export const PreviewDomainBody = Type.Object({ hostname: Type.String({ minLength: 1, maxLength: 253, description: "Hostname to preview DNS records for." }), + serverId: Type.Optional( + Type.String({ + minLength: 1, + maxLength: 128, + description: "Selected self-hosted deployment target whose public host should populate A records.", + }), + ), includeWww: Type.Optional( Type.Boolean({ description: diff --git a/apps/api/src/modules/domains/domain.service.ts b/apps/api/src/modules/domains/domain.service.ts index b7e6bd666..c26f56a1c 100644 --- a/apps/api/src/modules/domains/domain.service.ts +++ b/apps/api/src/modules/domains/domain.service.ts @@ -27,7 +27,7 @@ import { } from "../../lib/domain-ssl"; import { getRoutingBaseDomain } from "../../lib/routing-domains"; import { resolveRecords } from "../../lib/dns-resolver"; -import { resolveProjectServerHost, resolveLocalServerHost, resolveInstancePublicIp, isLoopbackHost } from "../../lib/server-target"; +import { resolveProjectServerHost, resolveLocalServerHost, resolveInstancePublicIp, resolveServerHost, isLoopbackHost } from "../../lib/server-target"; import { reconcileProjectRoutes } from "../../lib/route-apply.service"; import { releaseManagedHostnames } from "../../lib/managed-edge-proxy"; import { generateToken } from "../../lib/domain-token"; @@ -429,7 +429,7 @@ export async function ensurePendingServiceDomain(opts: { // findOrCreate (not create) so a concurrent insert of the same brand-new // hostname races safely to the existing row instead of throwing 23505 — the // caller path (createService) isn't wrapped in a try/catch. - const row = await repos.domain.findOrCreate({ + const result = await repos.domain.findOrCreateWithStatus({ projectId: opts.projectId, serviceId: opts.serviceId, hostname, @@ -440,7 +440,12 @@ export async function ensurePendingServiceDomain(opts: { isPrimary: false, verificationToken: generateToken(hostname), }); - return { created: true, domainId: row?.id ?? null }; + if (result.domain.projectId !== opts.projectId) { + throw new ConflictError( + `The domain "${hostname}" is already connected to another project.`, + ); + } + return { created: result.created, domainId: result.domain.id }; } /** @@ -465,15 +470,16 @@ export async function removeServiceDomain(opts: { } } -// ─── Preview records (no auth, no DB write) ────────────────────────────────── +// ─── Preview records (no DB write) ─────────────────────────────────────────── export async function previewRecords( hostname: string, organizationId?: string, includeWww = false, + serverId?: string, ) { const token = generateToken(hostname); - return buildRecords(hostname, token, undefined, false, organizationId, includeWww); + return buildRecords(hostname, token, undefined, false, organizationId, includeWww, serverId); } // ─── Get DNS records (existing domain) ─────────────────────────────────────── @@ -1553,6 +1559,8 @@ async function buildRecords( * the panel must show ITS record too. Without this the user turned www on and * saw only the apex record, then wondered why www never resolved. */ includeWww = false, + /** Explicit pre-deploy target. Existing domain rows resolve through project. */ + previewServerId?: string, ): Promise<{ mode: "cloud" | "selfhosted" | "external"; records: DnsRecord[] }> { const wwwHostname = includeWww ? wwwSiblingHostname(hostname) : null; const { target, runtime } = platform(); @@ -1609,17 +1617,19 @@ async function buildRecords( // front would answer with its own IP — so it's a hint, not a gate. Read the // box's public address (resolved once at ensure-server): the deployed project's // server, else this org's "This Server" row for the pre-deploy preview. - let serverIp = - (await resolveProjectServerHost(project)) ?? - (organizationId ? await resolveLocalServerHost(organizationId) : null); + let serverIp = previewServerId && organizationId + ? await resolveServerHost(organizationId, previewServerId).catch(() => null) + : (await resolveProjectServerHost(project)) ?? + (organizationId ? await resolveLocalServerHost(organizationId) : null); // A loopback is the local row's display host when no public IP was known at // registration — useless as "point your domain here". Re-detect live for this // (user-initiated, off-hot-path) preview; leave EMPTY so the UI shows a // placeholder rather than a dead `127.0.0.1` the operator would copy verbatim. - if (!serverIp || isLoopbackHost(serverIp)) { + if ((!serverIp || isLoopbackHost(serverIp)) && !previewServerId) { const detected = await resolveInstancePublicIp().catch(() => null); serverIp = detected && !isLoopbackHost(detected) ? detected : null; } + if (isLoopbackHost(serverIp)) serverIp = null; const records: DnsRecord[] = [ { type: "A", host: routeHost, name: routeName, value: serverIp ?? "" }, ]; diff --git a/apps/api/src/modules/domains/project-route.service.ts b/apps/api/src/modules/domains/project-route.service.ts index d063b0e43..38b3627ff 100644 --- a/apps/api/src/modules/domains/project-route.service.ts +++ b/apps/api/src/modules/domains/project-route.service.ts @@ -1,6 +1,6 @@ import { repos, type Domain, type Project } from "@repo/db"; import { safeErrorMessage } from "@repo/core"; -import { resolveServedStaticPath } from "@repo/adapters"; +import { edgeProxyFor, resolveServedStaticPath } from "@repo/adapters"; import { compileProjectRoutingFields } from "../../lib/project-routing-fields"; import { isLoopbackHost, @@ -34,6 +34,7 @@ import { type RouteRegister, type RouteRemove, } from "../../lib/route-apply.service"; +import { observedLoopbackPublishFromUrl } from "../deployments/observed-host-port-claims"; type ProjectRouteProject = Pick; type RouteStateProject = Pick; @@ -64,19 +65,23 @@ export function deriveEnvironmentPublicEndpoints( if (!primaryEndpoint) return []; if (primaryEndpoint.targetPath) { - return [{ - targetPath: primaryEndpoint.targetPath, - domain: normalizedSlug, - domainType: "free", - }]; + return [ + { + targetPath: primaryEndpoint.targetPath, + domain: normalizedSlug, + domainType: "free", + }, + ]; } if (primaryEndpoint.port !== undefined) { - return [{ - port: primaryEndpoint.port, - domain: normalizedSlug, - domainType: "free", - }]; + return [ + { + port: primaryEndpoint.port, + domain: normalizedSlug, + domainType: "free", + }, + ]; } return []; @@ -99,7 +104,10 @@ function draftEndpointsWithIds( endpoints: StoredPublicEndpoint[], ): ProjectRouteEndpoint[] { const idByHostname = new Map( - normalizeProjectRouteRows(projectDomains).map((domain) => [domain.hostname.toLowerCase(), domain.id]), + normalizeProjectRouteRows(projectDomains).map((domain) => [ + domain.hostname.toLowerCase(), + domain.id, + ]), ); return endpoints.map((endpoint, index) => { @@ -212,7 +220,7 @@ export async function resolveProjectRouteState( project: ProjectRouteProject, opts?: { projectDomains?: Domain[] }, ): Promise { - const projectDomains = opts?.projectDomains ?? await listProjectRouteRows(project.id); + const projectDomains = opts?.projectDomains ?? (await listProjectRouteRows(project.id)); return deriveProjectRouteState(project, { projectDomains }); } @@ -220,13 +228,13 @@ export async function persistProjectRouteState( projectId: string, publicEndpoints: StoredPublicEndpoint[], projectDomains?: Domain[], - opts?: { preserveVerifiedCustom?: boolean }, + opts?: { preserveCustomDomains?: boolean }, ): Promise { await syncProjectPublicRoutes({ projectId, endpoints: publicEndpoints, currentDomains: projectDomains, - preserveVerifiedCustom: opts?.preserveVerifiedCustom, + preserveCustomDomains: opts?.preserveCustomDomains, }); } @@ -238,21 +246,20 @@ export async function syncProjectRouteState( slug?: string | null; customDomain?: string | null; /** - * Deploy-only: never destroy a verified custom domain during this sync (see - * syncProjectPublicRoutes). Left unset by the Domains editor so explicit - * removals still apply. + * Deploy-only: never destroy custom-domain configuration during this sync. + * Left unset by the Domains editor so explicit removals still apply. */ - preserveVerifiedCustom?: boolean; + preserveCustomDomains?: boolean; }, ): Promise { - const projectDomains = input.projectDomains ?? await listProjectRouteRows(project.id); + const projectDomains = input.projectDomains ?? (await listProjectRouteRows(project.id)); const nextState = deriveNextProjectRouteState(project, { ...input, projectDomains, }); await persistProjectRouteState(project.id, nextState.publicEndpoints, projectDomains, { - preserveVerifiedCustom: input.preserveVerifiedCustom, + preserveCustomDomains: input.preserveCustomDomains, }); const refreshedDomains = await listProjectRouteRows(project.id); return deriveProjectRouteState(project, { projectDomains: refreshedDomains }); @@ -504,16 +511,25 @@ export async function reapplyProjectLiveRoutes( console.warn( `[project-route] ${project.slug}: deployment ${deployment.id} has no containerId (target=${effectiveTarget}) — skipping single-app route registration`, ); - await reconcileProjectRoutes(project, { routing, removes }); + await reconcileProjectRoutes(project, { + routing, + hostPortTarget: resolved.hostPortTarget, + ...(resolved.platform.executor + ? { edgeProxy: edgeProxyFor(resolved.platform.executor, "openresty", { ours: true }) } + : {}), + removes, + }); await pushProjectRules(project.id, serverId ?? null, previousHostnames).catch(() => {}); // Shared-dict state is RAM: the analytics collection switches have to be re-pushed // whenever routing is applied, or an nginx restart silently reverts them to off. - await pushProjectAnalyticsConfig(project.id, serverId ?? null, previousHostnames).catch(() => {}); + await pushProjectAnalyticsConfig(project.id, serverId ?? null, previousHostnames).catch( + () => {}, + ); syncAddedManagedEdge(); return; } - const resolveTargetUrl = async (port: number, hostname: string): Promise => { + const resolveTargetUrl = async (port: number, hostname: string) => { const strategy = resolveRouteStrategy(project.routeStrategy); // The port's owning SERVICE first, then the release's own primary container. // Both are attempted rather than one or the other, so nothing that resolved @@ -524,23 +540,33 @@ export async function reapplyProjectLiveRoutes( // reads the container's published host port LIVE; bare / no-host-port fall back // to the container IP (or 127.0.0.1 bare). let url: string | null = null; + let owner: { serviceId: string | null; containerPort: number } = { + serviceId: + liveRows.find((row) => row.containerId === primaryContainerId)?.serviceId ?? null, + containerPort: port, + }; if (serviceUpstreams) { - const resolved = await resolveProjectServiceUpstream({ + const serviceResolved = await resolveProjectServiceUpstream({ strategy, runtime, port, ...serviceUpstreams, + requireLiveObservation: true, }); - if (resolved) { + if (serviceResolved) { // Say WHICH service the domain ended up pointed at, and at WHAT. Silence // here is what made #618 undiagnosable from outside: a verified domain // holding a certificate with no vhost looks the same whichever step dropped // it, and a route pointed at the wrong sibling port looks like an app bug. console.log( - `[project-route] ${project.slug}: ${hostname} → service "${resolved.owner.serviceName}" ` + - `at ${resolved.url} (port ${port}, matched by ${resolved.owner.via})`, + `[project-route] ${project.slug}: ${hostname} → service "${serviceResolved.owner.serviceName}" ` + + `at ${serviceResolved.url} (port ${port}, matched by ${serviceResolved.owner.via})`, ); - url = resolved.url; + url = serviceResolved.url; + owner = { + serviceId: serviceResolved.owner.serviceId, + containerPort: serviceResolved.owner.containerPort, + }; } } if (!url && primaryContainerId) { @@ -549,6 +575,7 @@ export async function reapplyProjectLiveRoutes( runtime, containerId: primaryContainerId, containerPort: port, + requireLiveObservation: true, }); } if (!url) { @@ -577,7 +604,12 @@ export async function reapplyProjectLiveRoutes( ); return null; } - return url; + const observed = observedLoopbackPublishFromUrl({ + targetUrl: url, + serviceId: owner.serviceId, + containerPort: owner.containerPort, + }); + return { url, observed }; }; // Where a path-targeted (static) domain serves its files from — the SAME @@ -652,14 +684,27 @@ export async function reapplyProjectLiveRoutes( console.warn(`[project-route] ${project.slug}: no port for ${domain.hostname} — skipping`); continue; } - const targetUrl = await resolveTargetUrl(port, domain.hostname); - if (!targetUrl) continue; - registers.push({ ...common, ...routingFields, targetUrl }); + const target = await resolveTargetUrl(port, domain.hostname); + if (!target) continue; + registers.push({ + ...common, + ...routingFields, + targetUrl: target.url, + ...(target.observed ? { observedLoopbackPublishes: [target.observed] } : {}), + }); } // The webhook-proxy location is re-attached automatically for the project's // webhookDomain inside reconcileProjectRoutes. - await reconcileProjectRoutes(project, { routing, registers, removes }); + await reconcileProjectRoutes(project, { + routing, + hostPortTarget: resolved.hostPortTarget, + ...(resolved.platform.executor + ? { edgeProxy: edgeProxyFor(resolved.platform.executor, "openresty", { ours: true }) } + : {}), + registers, + removes, + }); // Re-sync per-route edge rules (rate-limit / ban / allow-deny) for the current // hostnames. Best-effort — the DB is the source of truth; a failure defers to @@ -667,7 +712,9 @@ export async function reapplyProjectLiveRoutes( await pushProjectRules(project.id, serverId ?? null, previousHostnames).catch(() => {}); // Shared-dict state is RAM: the analytics collection switches have to be re-pushed // whenever routing is applied, or an nginx restart silently reverts them to off. - await pushProjectAnalyticsConfig(project.id, serverId ?? null, previousHostnames).catch(() => {}); + await pushProjectAnalyticsConfig(project.id, serverId ?? null, previousHostnames).catch( + () => {}, + ); // Register the newly-added managed slug(s) on the cloud edge (the "add" half // of the edit; dropped slugs were deregistered above). Per-route — unchanged @@ -676,4 +723,4 @@ export async function reapplyProjectLiveRoutes( } finally { disposePlatform(resolved); } -} \ No newline at end of file +} diff --git a/apps/api/src/modules/domains/routing-apply.service.test.ts b/apps/api/src/modules/domains/routing-apply.service.test.ts index e66ddd7f9..87ac663da 100644 --- a/apps/api/src/modules/domains/routing-apply.service.test.ts +++ b/apps/api/src/modules/domains/routing-apply.service.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const projectRepo = vi.hoisted(() => ({ findById: vi.fn() })); const deploymentRepo = vi.hoisted(() => ({ findById: vi.fn() })); const serviceRepo = vi.hoisted(() => ({ listByProject: vi.fn(), listByDeployment: vi.fn() })); +const domainRepo = vi.hoisted(() => ({ listByProject: vi.fn() })); const resolveDeploymentRuntime = vi.hoisted(() => vi.fn()); const usesManagedRouting = vi.hoisted(() => vi.fn().mockReturnValue(false)); @@ -17,6 +18,7 @@ vi.mock("@repo/db", async (importOriginal) => { project: projectRepo, deployment: deploymentRepo, service: serviceRepo, + domain: domainRepo, }, }; }); @@ -37,17 +39,27 @@ vi.mock("../../lib/controller-helpers", () => ({ platform: () => ({ target: "sel import { applyProjectRouting } from "./routing-apply.service"; -/** The single register `applyProjectRouting` emitted for the fan-out domain. */ -function emittedRegister() { +function emittedRegisters() { expect(reconcileProjectRoutes).toHaveBeenCalledTimes(1); const [, opts] = reconcileProjectRoutes.mock.calls[0]; - expect(opts.registers).toHaveLength(1); - return opts.registers[0]; + return opts.registers; +} + +/** The topology-aware writer is intentionally last for a shared hostname. */ +function emittedRegister() { + const registers = emittedRegisters(); + expect(registers.length).toBeGreaterThan(0); + return registers.at(-1); } function runtimeReturning(opts: { /** undefined → the live inspect throws. */ - info?: { status?: string; ip?: string; hostPort?: number }; + info?: { + status?: string; + ip?: string; + hostPort?: number; + hostPortByContainerPort?: Record; + }; ip?: string | null; }) { resolveDeploymentRuntime.mockResolvedValue({ @@ -66,9 +78,22 @@ function runtimeReturning(opts: { } /** `service_deployment` row for the migrated service. */ -function storeRow(row: { containerId?: string | null; ip?: string | null; hostPort?: number | null }) { +function storeRow(row: { + containerId?: string | null; + ip?: string | null; + hostPort?: number | null; + hostPorts?: Record | null; +}) { serviceRepo.listByDeployment.mockResolvedValue([ - { id: "sd_1", serviceId: "svc_1", deploymentId: "dep_1", containerId: "container_1", ip: null, hostPort: null, ...row }, + { + id: "sd_1", + serviceId: "svc_1", + deploymentId: "dep_1", + containerId: "container_1", + ip: null, + hostPort: null, + ...row, + }, ]); } @@ -115,6 +140,7 @@ describe("applyProjectRouting — upstream resolution", () => { ]); storeRow({}); + domainRepo.listByProject.mockResolvedValue([]); runtimeReturning({ info: { ip: "172.19.0.2" } }); usesManagedRouting.mockReturnValue(false); reconcileProjectRoutes.mockResolvedValue(undefined); @@ -151,23 +177,37 @@ describe("applyProjectRouting — upstream resolution", () => { expect(emittedRegister().targetUrl).toBe("http://127.0.0.1:4000"); }); - it("keeps the last-known upstream when the container cannot be inspected", async () => { - // A failed inspect is not evidence that nothing is published, so the working - // vhost must survive the re-apply rather than being repointed or dropped. + it("does not re-register a cached loopback upstream when the container cannot be inspected", async () => { + // A failed inspect proves neither that this container still owns the port nor + // that the port is unused. Leave the existing vhost untouched; re-registering + // its cached target could point the hostname at a different workload. storeRow({ ip: "172.19.0.2", hostPort: 4000 }); runtimeReturning({ ip: null }); await applyProjectRouting("proj_1"); - expect(emittedRegister().targetUrl).toBe("http://127.0.0.1:4000"); + expect(reconcileProjectRoutes).not.toHaveBeenCalled(); }); - it("falls back to the stored row for a service with no container yet", async () => { + it("does not trust a cached per-port binding when the container cannot be inspected", async () => { + storeRow({ + ip: "172.19.0.2", + hostPort: 4000, + hostPorts: { "3000": 4000, "3001": 4001 }, + }); + runtimeReturning({ ip: null }); + + await applyProjectRouting("proj_1"); + + expect(reconcileProjectRoutes).not.toHaveBeenCalled(); + }); + + it("does not route a cached address for a service with no container", async () => { storeRow({ containerId: null, ip: "172.19.0.2" }); await applyProjectRouting("proj_1"); - expect(emittedRegister().targetUrl).toBe("http://172.19.0.2:3001"); + expect(reconcileProjectRoutes).not.toHaveBeenCalled(); }); it("honours an explicit container-ip strategy over a published host port", async () => { @@ -179,6 +219,129 @@ describe("applyProjectRouting — upstream resolution", () => { expect(emittedRegister().targetUrl).toBe("http://172.19.0.2:3001"); }); + + it("reapplies every service-owned port with its exact live loopback ownership", async () => { + projectRepo.findById.mockResolvedValue({ + ...project("loopback-port"), + compositeRoutes: [], + }); + serviceRepo.listByProject.mockResolvedValue([ + { + id: "svc_1", + projectId: "proj_1", + name: "web", + enabled: true, + exposed: true, + exposedPort: "3001", + domainType: "custom", + customDomain: "app.example.com", + publicEndpoints: [ + { port: 3001, domainType: "custom", customDomain: "app.example.com" }, + { port: 3002, domainType: "custom", customDomain: "admin.example.com" }, + ], + ports: ["3001", "3002"], + kind: "compose", + }, + ]); + storeRow({ + ip: "172.19.0.2", + hostPort: 4001, + hostPorts: { "3001": 4001, "3002": 4002 }, + }); + runtimeReturning({ + info: { + ip: "172.19.0.2", + hostPort: 4001, + hostPortByContainerPort: { 3001: 4001, 3002: 4002 }, + }, + }); + + await applyProjectRouting("proj_1"); + + expect(emittedRegisters()).toMatchObject([ + { + hostname: "app.example.com", + targetUrl: "http://127.0.0.1:4001", + observedLoopbackPublishes: [{ serviceId: "svc_1", containerPort: 3001, hostPort: 4001 }], + }, + { + hostname: "admin.example.com", + targetUrl: "http://127.0.0.1:4002", + observedLoopbackPublishes: [{ serviceId: "svc_1", containerPort: 3002, hostPort: 4002 }], + }, + ]); + }); + + it("preserves a service canonical redirect without claiming its suppressed upstream", async () => { + projectRepo.findById.mockResolvedValue({ + ...project("loopback-port"), + compositeRoutes: [], + }); + serviceRepo.listByProject.mockResolvedValue([ + { + id: "svc_1", + projectId: "proj_1", + name: "web", + enabled: true, + exposed: true, + exposedPort: "3001", + domainType: "custom", + customDomain: "app.example.com", + publicEndpoints: [ + { port: 3001, domainType: "custom", customDomain: "app.example.com" }, + { port: 3001, domainType: "custom", customDomain: "www.app.example.com" }, + ], + ports: ["3001"], + kind: "compose", + }, + ]); + domainRepo.listByProject.mockResolvedValue([ + { + id: "dom-app", + projectId: "proj_1", + serviceId: "svc_1", + hostname: "app.example.com", + redirectTo: null, + redirectStatus: null, + }, + { + id: "dom-www", + projectId: "proj_1", + serviceId: "svc_1", + hostname: "www.app.example.com", + redirectTo: "app.example.com", + redirectStatus: 308, + }, + ]); + storeRow({ + ip: "172.19.0.2", + hostPort: 4001, + hostPorts: { "3001": 4001 }, + }); + runtimeReturning({ + info: { + ip: "172.19.0.2", + hostPort: 4001, + hostPortByContainerPort: { 3001: 4001 }, + }, + }); + + await applyProjectRouting("proj_1"); + + const registers = emittedRegisters(); + expect( + registers.find((register: { hostname: string }) => register.hostname === "app.example.com"), + ).toMatchObject({ + observedLoopbackPublishes: [{ serviceId: "svc_1", containerPort: 3001, hostPort: 4001 }], + }); + const redirect = registers.find( + (register: { hostname: string }) => register.hostname === "www.app.example.com", + ); + expect(redirect).toMatchObject({ + redirectHost: { target: "app.example.com", statusCode: 308 }, + }); + expect(redirect.observedLoopbackPublishes).toBeUndefined(); + }); }); /** @@ -263,6 +426,7 @@ describe("applyProjectRouting — static frontend composite", () => { meta: { deployTarget: "local", runtimeMode: "docker" }, }); serviceRepo.listByProject.mockResolvedValue([web, api]); + domainRepo.listByProject.mockResolvedValue([]); rows(RELEASE_DIR); runtimeReturning({ info: {}, ip: "172.19.0.5" }); usesManagedRouting.mockReturnValue(false); @@ -272,7 +436,14 @@ describe("applyProjectRouting — static frontend composite", () => { it("serves the frontend from its release directory and proxies the backend", async () => { await applyProjectRouting("proj_1"); - const register = emittedRegister(); + const registers = emittedRegisters(); + expect(registers).toHaveLength(2); + expect(registers[0]).toMatchObject({ + hostname: "app.example.com", + staticRoot: RELEASE_DIR, + }); + expect(registers[0].proxyLocations).toBeUndefined(); + const register = registers.at(-1); expect(register).toMatchObject({ hostname: "app.example.com", staticRoot: RELEASE_DIR, @@ -332,7 +503,9 @@ describe("applyProjectRouting — static frontend composite", () => { await applyProjectRouting("proj_1"); - expect(reconcileProjectRoutes).not.toHaveBeenCalled(); + expect(emittedRegisters()).toMatchObject([ + { hostname: "app.example.com", staticRoot: RELEASE_DIR }, + ]); const logged = warn.mock.calls.flat().join(" "); expect(logged).toMatch(/backend has no live upstream/); expect(logged).not.toMatch(/frontend has neither/); diff --git a/apps/api/src/modules/domains/routing-apply.service.ts b/apps/api/src/modules/domains/routing-apply.service.ts index b8122749e..dd8abf61f 100644 --- a/apps/api/src/modules/domains/routing-apply.service.ts +++ b/apps/api/src/modules/domains/routing-apply.service.ts @@ -3,21 +3,21 @@ * rebuild — the counterpart to the deploy-time composite registration, used when * the user edits routing from the Routing/Domains tab (`PUT /projects/:id/routing`). * - * Two emitters over one parsed `RoutingConfig`: - * - Self-hosted → `buildCompositeRegistration` → OpenResty via the shared - * `reconcileProjectRoutes` dispatch. + * Two emitters over the persisted live topology: + * - Self-hosted → canonical service-owned routes followed by composite and + * migration fan-out overlays → OpenResty through `reconcileProjectRoutes`. * - Cloud → `compileRoutingToOblien` → the Oblien edge via `routes.set`. * `routes.set` ATOMICALLY REPLACES a hostname's edge behavior, so the cloud path * always compiles the COMPLETE table (what backs `/` + overrides) — never a - * partial one. Both paths cover the same shape (the 1-static + 1-server monorepo - * composite) and are best-effort: the config row is already persisted by the - * caller, so a live-apply failure logs and defers to the next deploy. + * partial one. Both paths are best-effort: the project edit is already persisted + * by the caller, so a live-apply failure logs and defers to the next deploy. */ import { repos } from "@repo/db"; import { safeErrorMessage } from "@repo/core"; import { CloudRuntime, + edgeProxyFor, PAGE_CONTAINER_PREFIX, compileRoutingToOblien, type OblienRoutingContext, @@ -34,17 +34,18 @@ import { reconcileProjectRoutes } from "../../lib/route-apply.service"; import { compileProjectRoutingFields } from "../../lib/project-routing-fields"; import { resolveServicePort } from "../../lib/deployable-service"; import { isArtifactRef } from "../../lib/container-ref"; -import { buildServiceRouteDomain } from "../../lib/routing-domains"; +import { buildServiceRouteDomain, buildServiceRouteDomains } from "../../lib/routing-domains"; +import { resolveRouteRedirect } from "../../lib/domain-redirect"; import { buildCompositeRegistration, buildDomainFanoutRegistrations, planCompositeRoute, } from "../deployments/compose/composite-route"; +import { resolveLiveUpstreamUrl, resolveRouteStrategy } from "../../lib/upstream-url"; import { - buildUpstreamUrl, - resolveLiveUpstreamUrl, - resolveRouteStrategy, -} from "../../lib/upstream-url"; + observedLoopbackPublishFromUrl, + type ObservedLoopbackPublish, +} from "../deployments/observed-host-port-claims"; export async function applyProjectRouting(projectId: string): Promise { const project = await repos.project.findById(projectId); @@ -78,43 +79,100 @@ export async function applyProjectRouting(projectId: string): Promise { // Self-hosted: compile to OpenResty locations and reconcile the domain. if (!routing) return; + const domainRows = await repos.domain.listByProject(project.id); const rowByService = new Map(liveRows.map((row) => [row.serviceId, row])); + const domainByHostname = new Map( + domainRows.map((domain) => [domain.hostname.toLowerCase(), domain]), + ); const routeStrategy = resolveRouteStrategy(project.routeStrategy); + const observedByUrl = new Map(); + const rememberObservedPublish = ( + serviceId: string, + containerPort: number, + targetUrl: string | null | undefined, + ) => { + const observed = observedLoopbackPublishFromUrl({ + targetUrl, + serviceId, + containerPort, + }); + if (!observed || !targetUrl) return; + const current = observedByUrl.get(targetUrl) ?? []; + if ( + !current.some( + (item) => + item.serviceId === observed.serviceId && item.containerPort === observed.containerPort, + ) + ) { + current.push(observed); + observedByUrl.set(targetUrl, current); + } + }; + + // Build service-owned routes with the same canonical planner used by deploy + // and service edits. A strategy-only project save must rewrite these vhosts + // too; otherwise an unchanged Compose service can be carried forever behind + // the old topology. + const serviceRoutePlans = defs + .filter((def) => def.enabled) + .flatMap((def) => + buildServiceRouteDomains({ + project, + service: def, + runtimeName: runtime.name, + usesManagedRouting: managed, + domainByHostname, + }).map((route) => ({ def, route })), + ); + const liveServiceHostnames = serviceRoutePlans.map(({ route }) => route.hostname); - // One live-upstream resolver, shared by the vercel composite AND the migration - // path-fan-out. Resolved from the LIVE container (not the service_deployment - // row) so a workload with no loopback publish — migrated, adopted in place — - // routes at its container IP instead of a dead 127.0.0.1:. Awaited up - // front because the composite/fan-out builders take a sync resolver. + // One live-upstream inventory, shared by service routes, the vercel + // composite, and migration fan-out. Resolve every distinct (service, port) + // up front because the composite/fan-out builders take a synchronous resolver. + // Live observation is mandatory: cached bridge IPs and host ports can be + // reassigned after a container disappears. + const portsByService = new Map>(); + const requirePort = (serviceId: string, port: number | null | undefined) => { + if (!port) return; + const ports = portsByService.get(serviceId) ?? new Set(); + ports.add(port); + portsByService.set(serviceId, ports); + }; + for (const { def, route } of serviceRoutePlans) requirePort(def.id, route.targetPort); + for (const def of defs) requirePort(def.id, resolveServicePort(def, project.port)); + + const upstreamKey = (serviceId: string, containerPort: number) => + `${serviceId}\0${containerPort}`; const liveUpstreams = new Map(); await Promise.all( - defs.map(async (def) => { - const row = rowByService.get(def.id); - const port = resolveServicePort(def, project.port); - if (!port || !row?.containerId) return; - liveUpstreams.set( - def.id, - await resolveLiveUpstreamUrl({ - strategy: routeStrategy, - runtime, - containerId: row.containerId, - containerPort: port, - stored: { ip: row.ip, hostPort: row.hostPort }, - }), - ); + [...portsByService].flatMap(([serviceId, ports]) => { + const row = rowByService.get(serviceId); + if (!row?.containerId) return []; + return [...ports].map(async (containerPort) => { + liveUpstreams.set( + upstreamKey(serviceId, containerPort), + await resolveLiveUpstreamUrl({ + strategy: routeStrategy, + runtime, + containerId: row.containerId!, + containerPort, + stored: { ip: row.ip, hostPort: row.hostPort, hostPorts: row.hostPorts }, + requireLiveObservation: true, + }), + ); + }); }), ); + const resolveTargetUrlForPort = (serviceId: string, containerPort: number) => { + const targetUrl = liveUpstreams.get(upstreamKey(serviceId, containerPort)) ?? null; + rememberObservedPublish(serviceId, containerPort, targetUrl); + return targetUrl; + }; const resolveTargetUrl = (serviceId: string) => { - // A service with no container to inspect (cloud peer, not yet deployed) - // still resolves from its persisted row. - const live = liveUpstreams.get(serviceId); - if (live !== undefined) return live; - const def = defs.find((s) => s.id === serviceId); - const row = rowByService.get(serviceId); + const def = defs.find((candidate) => candidate.id === serviceId); const port = def ? resolveServicePort(def, project.port) : null; - if (!port) return null; - return buildUpstreamUrl({ strategy: routeStrategy, ip: row?.ip, hostPort: row?.hostPort, containerPort: port }); + return port ? resolveTargetUrlForPort(serviceId, port) : null; }; /** @@ -135,21 +193,46 @@ export async function applyProjectRouting(projectId: string): Promise { return isArtifactRef(ref) ? ref!.trim() : null; }; + const routingFields = compileProjectRoutingFields(project.routingConfig); + const serviceRegisters = serviceRoutePlans.flatMap(({ def, route }) => { + if (!route.targetPort) return []; + const redirectHost = resolveRouteRedirect(route, liveServiceHostnames); + const staticRoot = resolveStaticRoot(def.id); + const targetUrl = staticRoot ? null : resolveTargetUrlForPort(def.id, route.targetPort); + // A failed live inspection is not authority to replace a working vhost + // with a cached address. Leave this one untouched; a later retry/deploy can + // re-observe it. Static services are authoritative through their release dir. + if (!redirectHost && !staticRoot && !targetUrl) return []; + const observed = targetUrl + ? observedLoopbackPublishFromUrl({ + targetUrl, + serviceId: def.id, + containerPort: route.targetPort, + }) + : null; + return [ + { + ...routingFields, + hostname: route.hostname, + port: route.targetPort, + isCustomDomain: route.domainType === "custom", + ...(staticRoot ? { staticRoot } : targetUrl ? { targetUrl } : {}), + ...(redirectHost ? { redirectHost } : {}), + // Redirect vhosts render no upstream at all. Do not describe the + // service's otherwise-live target as dialled ownership; the shared + // reconciler also filters this defensively from rendered URLs. + ...(!redirectHost && observed ? { observedLoopbackPublishes: [observed] } : {}), + }, + ]; + }); + const composite = buildCompositeRegistration({ services: defs, routingConfig: project.routingConfig, resolveTargetUrl, resolveStaticRoot, resolveDomain: (serviceId) => { - const def = defs.find((s) => s.id === serviceId); - const domain = def - ? buildServiceRouteDomain({ - project, - service: def, - runtimeName: runtime.name, - usesManagedRouting: managed, - }) - : null; + const domain = serviceRoutePlans.find(({ def }) => def.id === serviceId)?.route ?? null; return domain ? { hostname: domain.hostname, isCustomDomain: domain.domainType === "custom" } : null; @@ -192,7 +275,6 @@ export async function applyProjectRouting(projectId: string): Promise { // EXCEPT the fan-out one, and the deploy path (which does carry them) then // disagreed with the live path about the same vhost. The composite is left alone: // it compiles its own topology-aware superset with the backend it resolved. - const routingFields = compileProjectRoutingFields(project.routingConfig); const fanout = buildDomainFanoutRegistrations({ routes: project.compositeRoutes, resolveTargetUrl, @@ -200,13 +282,40 @@ export async function applyProjectRouting(projectId: string): Promise { // CONCATENATED, not overwritten — same rule and same order as the deploy path: // the fan-out's explicit per-path upstreams first, then the compiled rules, or // the spread would ASSIGN over them and drop a vercel.json external rewrite. - const proxyLocations = [...(reg.proxyLocations ?? []), ...(routingFields.proxyLocations ?? [])]; + const proxyLocations = [ + ...(reg.proxyLocations ?? []), + ...(routingFields.proxyLocations ?? []), + ]; return { ...reg, ...routingFields, ...(proxyLocations.length ? { proxyLocations } : {}) }; }); - const registers = [...(composite ? [composite.register] : []), ...fanout]; + const topologyRegisters = [...(composite ? [composite.register] : []), ...fanout].map( + (register) => { + const observedLoopbackPublishes = register.redirectHost + ? [] + : [ + register.targetUrl, + ...(register.proxyLocations?.map((location) => location.targetUrl) ?? []), + ].flatMap((url) => (url ? (observedByUrl.get(url) ?? []) : [])); + return observedLoopbackPublishes.length > 0 + ? { ...register, observedLoopbackPublishes } + : register; + }, + ); + // Last-writer order is part of the routing contract. A composite/fan-out + // registration is richer than a service's base vhost, so it must overwrite + // the service register when they intentionally share a hostname. + const registers = [...serviceRegisters, ...topologyRegisters]; if (registers.length > 0) { - await reconcileProjectRoutes(project, { deployment, routing, registers }); + await reconcileProjectRoutes(project, { + deployment, + routing, + hostPortTarget: resolved.hostPortTarget, + ...(resolved.platform.executor + ? { edgeProxy: edgeProxyFor(resolved.platform.executor, "openresty", { ours: true }) } + : {}), + registers, + }); } } catch (err) { console.warn( diff --git a/apps/api/src/modules/github/github.local-auth.ts b/apps/api/src/modules/github/github.local-auth.ts index 8510743a3..82b2391bf 100644 --- a/apps/api/src/modules/github/github.local-auth.ts +++ b/apps/api/src/modules/github/github.local-auth.ts @@ -7,7 +7,8 @@ * * Resolution order: * 1. `gh auth token` subprocess (works on any OS where `gh` is in PATH) - * 2. Read `~/.config/gh/hosts.yml` directly (fallback when `gh` binary is missing) + * 2. Read the single `hosts.yml` selected by GitHub CLI's config precedence + * (fallback when the `gh` binary is missing) * * This module also exposes `getLocalGhStatus()` - a convenience that validates * the resolved token against the GitHub API and returns the user profile. @@ -29,7 +30,7 @@ import { execFile } from "child_process"; import { readFile } from "fs/promises"; import { homedir } from "os"; -import { join } from "path"; +import { join, win32 } from "path"; import { createOAuthDeviceAuth } from "@octokit/auth-oauth-device"; import { repos } from "@repo/db"; import { env } from "../../config/env"; @@ -336,7 +337,9 @@ const GH_FALLBACK_PATHS = [ /** One-shot exec attempt — resolves to the trimmed stdout on success, * or an error object the caller can log. Used to walk fallback paths * without burying the actual ENOENT/EPERM under a silent null. */ -function tryGhExec(bin: string): Promise<{ token: string } | { error: NodeJS.ErrnoException; stderr?: string }> { +function tryGhExec( + bin: string, +): Promise<{ token: string } | { error: NodeJS.ErrnoException; stderr?: string }> { return new Promise((resolve) => { execFile(bin, ["auth", "token"], { timeout: 10_000 }, (err, stdout, stderr) => { if (err) return resolve({ error: err as NodeJS.ErrnoException, stderr: stderr?.toString() }); @@ -399,48 +402,82 @@ async function ghAuthTokenViaCli(): Promise { return null; } +export interface GhConfigEnvironment { + GH_CONFIG_DIR?: string; + XDG_CONFIG_HOME?: string; + AppData?: string; + APPDATA?: string; +} + /** - * Read token from the gh CLI config file. Tries (in order): - * - $GH_CONFIG_DIR/hosts.yml (explicit override) - * - $XDG_CONFIG_HOME/gh/hosts.yml (XDG spec) - * - ~/.config/gh/hosts.yml (default) + * The one `hosts.yml` location GitHub CLI would select. * - * Logs the path it actually attempted on failure so operators can see - * the resolved location. + * Overrides are alternatives, not a fallback chain: once an operator isolates + * the process with `GH_CONFIG_DIR` (or XDG), a missing/tokenless file must not + * disclose another user's credential from the default home directory (#687). */ -async function ghAuthTokenViaConfig(): Promise { - const candidates: string[] = []; - if (process.env.GH_CONFIG_DIR) candidates.push(join(process.env.GH_CONFIG_DIR, "hosts.yml")); - if (process.env.XDG_CONFIG_HOME) - candidates.push(join(process.env.XDG_CONFIG_HOME, "gh", "hosts.yml")); - candidates.push(join(homedir(), ".config", "gh", "hosts.yml")); - - for (const path of candidates) { - try { - const raw = await readFile(path, "utf-8"); - // Simple line-by-line YAML parse — look for `oauth_token:` under `github.com:` - const ghSection = raw.split(/\n/).reduce<{ inGithub: boolean; token: string | null }>( - (acc, line) => { - if (/^github\.com:/i.test(line.trim())) acc.inGithub = true; - else if (/^\S/.test(line)) acc.inGithub = false; - if (acc.inGithub) { - const m = line.match(/^\s+oauth_token:\s*(.+)/); - if (m && !acc.token) acc.token = m[1].trim(); - } - return acc; - }, - { inGithub: false, token: null }, - ); - if (ghSection.token) { - systemDebug("gh-cli", `resolved token from ${path}`); - return ghSection.token; - } - systemDebug("gh-cli", `${path}: parsed but no oauth_token for github.com`); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - systemDebug("gh-cli", `${path}: ${code ?? "read error"}`); - } +export function resolveGhHostsPath( + environment: GhConfigEnvironment = process.env, + homeDirectory: string = homedir(), + platform: NodeJS.Platform = process.platform, +): string { + const pathJoin = platform === "win32" ? win32.join : join; + if (environment.GH_CONFIG_DIR) { + return pathJoin(environment.GH_CONFIG_DIR, "hosts.yml"); + } + if (environment.XDG_CONFIG_HOME) { + return pathJoin(environment.XDG_CONFIG_HOME, "gh", "hosts.yml"); + } + const appData = environment.AppData || environment.APPDATA; + if (platform === "win32" && appData) { + return win32.join(appData, "GitHub CLI", "hosts.yml"); + } + return pathJoin(homeDirectory, ".config", "gh", "hosts.yml"); +} + +export interface GhConfigLookupOptions { + environment?: GhConfigEnvironment; + homeDirectory?: string; + platform?: NodeJS.Platform; + read?: (path: string, encoding: BufferEncoding) => Promise; +} + +/** Read a token from exactly the authoritative GitHub CLI config location. */ +export async function ghAuthTokenViaConfig( + options: GhConfigLookupOptions = {}, +): Promise { + const path = resolveGhHostsPath( + options.environment ?? process.env, + options.homeDirectory ?? homedir(), + options.platform ?? process.platform, + ); + const reader = + options.read ?? ((file: string, encoding: BufferEncoding) => readFile(file, encoding)); + + try { + const raw = await reader(path, "utf-8"); + // Simple line-by-line YAML parse — look for `oauth_token:` under `github.com:` + const ghSection = raw.split(/\n/).reduce<{ inGithub: boolean; token: string | null }>( + (acc, line) => { + if (/^github\.com:/i.test(line.trim())) acc.inGithub = true; + else if (/^\S/.test(line)) acc.inGithub = false; + if (acc.inGithub) { + const m = line.match(/^\s+oauth_token:\s*(.+)/); + if (m && !acc.token) acc.token = m[1].trim(); + } + return acc; + }, + { inGithub: false, token: null }, + ); + if (ghSection.token) { + systemDebug("gh-cli", `resolved token from ${path}`); + return ghSection.token; + } + systemDebug("gh-cli", `${path}: parsed but no oauth_token for github.com`); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + systemDebug("gh-cli", `${path}: ${code ?? "read error"}`); } } return null; diff --git a/apps/api/src/modules/migration/docker-reconcile.ts b/apps/api/src/modules/migration/docker-reconcile.ts index d899ea0f9..9f2231c62 100644 --- a/apps/api/src/modules/migration/docker-reconcile.ts +++ b/apps/api/src/modules/migration/docker-reconcile.ts @@ -60,6 +60,7 @@ export interface DiscoveredService { /** compose build context (set → adoption builds this Dockerfile). */ build?: string; dockerfile?: string; + buildArgs?: Record; /** compose-style "host:container[/proto]" strings, from actual bindings. */ ports: string[]; env: Record; @@ -568,6 +569,7 @@ export function toDiscoveredService( imageId: detail.imageId, build: declared?.build, dockerfile: declared?.dockerfile, + buildArgs: declared?.buildArgs, ports, env, ...(Object.keys(envImageDefaults).length > 0 && { envImageDefaults }), diff --git a/apps/api/src/modules/migration/handover-single-app.test.ts b/apps/api/src/modules/migration/handover-single-app.test.ts index fc682370a..ecbf35bd6 100644 --- a/apps/api/src/modules/migration/handover-single-app.test.ts +++ b/apps/api/src/modules/migration/handover-single-app.test.ts @@ -17,8 +17,15 @@ import { readFileSync } from "node:fs"; */ const orch = readFileSync(new URL("./migration.orchestrator.ts", import.meta.url), "utf8"); const request = (() => { - const from = orch.indexOf("const dep = await requestBuildAccess(ctx, {"); - return orch.slice(from, orch.indexOf("});", from)); + // Anchor on semantic statements, not Prettier's current argument layout. + // `requestBuildAccess(ctx, {` may be one line or several; the assignment + // immediately after the call is the stable boundary for this request. + const from = orch.indexOf("const dep = await requestBuildAccess("); + const to = orch.indexOf("deploymentId = dep.deployment_id;", from); + if (from < 0 || to < 0) { + throw new Error("Could not locate the migration target deployment request"); + } + return orch.slice(from, to); })(); describe("the migration's target deploy", () => { @@ -69,7 +76,8 @@ describe("no git source is fetched when the image is pinned", () => { ); it("the clone decision reads the pin", () => { - expect(pinned).toContain("classNeedsGitSource(snapshotToClass(snapshot)) && !pinnedAppImage(snapshot)"); + expect(pinned).toContain("classNeedsGitSource(snapshotToClass(snapshot))"); + expect(pinned).toContain("!pinnedAppImage(snapshot)"); }); it("and the pipeline asks that one resolver rather than deciding again", () => { diff --git a/apps/api/src/modules/migration/migrate.repo-only-build-args.test.ts b/apps/api/src/modules/migration/migrate.repo-only-build-args.test.ts new file mode 100644 index 000000000..d14663e46 --- /dev/null +++ b/apps/api/src/modules/migration/migrate.repo-only-build-args.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + discoverServerStack: vi.fn(), + ensureProject: vi.fn(), + excludeAlreadyManaged: vi.fn(), + syncFromCompose: vi.fn(), + updateService: vi.fn(), + findProject: vi.fn(), +})); + +vi.mock("@repo/db", async (importOriginal) => ({ + ...(await importOriginal>()), + repos: { + project: { findById: h.findProject }, + service: { + syncFromCompose: h.syncFromCompose, + update: h.updateService, + }, + }, +})); + +vi.mock("../projects/project-crud.service", () => ({ + ensureProject: h.ensureProject, + createServicesProjectWithId: vi.fn(), +})); + +vi.mock("./docker-inspect.service", () => ({ + discoverServerStack: h.discoverServerStack, +})); + +vi.mock("./managed-containers", () => ({ + excludeAlreadyManaged: h.excludeAlreadyManaged, +})); + +import { adoptServerStack, type RepoComposeService } from "./migrate.service"; +import type { DiscoveredService } from "./docker-reconcile"; + +const discovered = { + name: "running-api", + source: "container", + containerId: "container-api", + image: "myorg/api:running", + running: true, + ports: [], + env: {}, + volumes: [], + networks: [], + dependsOn: [], + warnings: [], +} as DiscoveredService; + +const repoService = ( + name: string, + appPackage: string, + templateKeys: string[] = [], +): RepoComposeService => ({ + name, + build: "../../", + dockerfile: "services/shared/Dockerfile", + buildArgs: { APP_PACKAGE: appPackage }, + advanced: { buildArgTemplateKeys: templateKeys }, + ports: [], + environment: {}, + dependsOn: [], + volumes: [], +}); + +describe("adoptServerStack — repo-only build args (#689)", () => { + beforeEach(() => { + vi.clearAllMocks(); + h.discoverServerStack.mockResolvedValue({ + services: [discovered], + groups: [{ project: "legacy", services: [discovered] }], + }); + h.excludeAlreadyManaged.mockImplementation(async (services) => services); + h.ensureProject.mockResolvedValue({ project_id: "project-1", created: true }); + h.findProject.mockResolvedValue({ id: "project-1", slug: "migrated" }); + h.syncFromCompose.mockImplementation( + async (_projectId: string, rows: Array & { name: string }>) => + rows.map((row, index: number) => ({ + ...row, + id: `service-${index + 1}`, + namespaceVolumes: false, + rootDirectory: null, + })), + ); + }); + + it("passes args for mapped and not-yet-running repo services into the single sync", async () => { + const repoServices = new Map([ + ["api", repoService("api", "${API_PACKAGE:-@myorg/api}", ["APP_PACKAGE"])], + ["worker", repoService("worker", "@myorg/worker")], + ]); + + await adoptServerStack({ + serverId: "server-1", + organizationId: "org-1", + projectName: "Migrated", + serviceNames: ["running-api"], + serviceRenames: { "running-api": "api" }, + repoServices, + }); + + expect(h.syncFromCompose).toHaveBeenCalledOnce(); + const rows = h.syncFromCompose.mock.calls[0]![1] as Array<{ + name: string; + buildArgs?: Record; + advanced?: { buildArgTemplateKeys?: string[] }; + }>; + expect(rows.map(({ name, buildArgs, advanced }) => ({ name, buildArgs, advanced }))).toEqual([ + { + name: "api", + buildArgs: { APP_PACKAGE: "${API_PACKAGE:-@myorg/api}" }, + advanced: { buildArgTemplateKeys: ["APP_PACKAGE"] }, + }, + { + name: "worker", + buildArgs: { APP_PACKAGE: "@myorg/worker" }, + advanced: { buildArgTemplateKeys: [] }, + }, + ]); + }); +}); diff --git a/apps/api/src/modules/migration/migrate.service.test.ts b/apps/api/src/modules/migration/migrate.service.test.ts index 80b16d284..3b0d7edb9 100644 --- a/apps/api/src/modules/migration/migrate.service.test.ts +++ b/apps/api/src/modules/migration/migrate.service.test.ts @@ -1,6 +1,18 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import type { DiscoveredService } from "./docker-reconcile"; -import { buildAdoptedServiceRows, type RepoComposeService } from "./migrate.service"; + +const getFileContent = vi.hoisted(() => vi.fn()); + +vi.mock("../github/github.service", async (importOriginal) => ({ + ...(await importOriginal>()), + getFileContent, +})); + +import { + buildAdoptedServiceRows, + parseRepoCompose, + type RepoComposeService, +} from "./migrate.service"; const repoSvc = (over: Partial & { name: string }): RepoComposeService => ({ ports: [], @@ -24,9 +36,73 @@ const svc = (over: Partial & { name: string }): DiscoveredSer ...over, }) as DiscoveredService; +describe("parseRepoCompose — build args (#689)", () => { + it("keeps distinct args for services that share a Dockerfile", async () => { + getFileContent.mockReset(); + getFileContent.mockResolvedValueOnce({ + content: ` +services: + api: + build: + context: ../../ + dockerfile: services/shared/Dockerfile + args: + APP_PACKAGE: "@myorg/api" + FROM_ENV: + CHANNEL: "\${RELEASE_CHANNEL:-stable}" + worker: + build: + context: ../../ + dockerfile: services/shared/Dockerfile + args: + APP_PACKAGE: "@myorg/worker" +`, + }); + + const services = await parseRepoCompose( + {} as Parameters[0], + "myorg", + "monorepo", + "main", + ); + + expect( + services.map(({ name, build, dockerfile, buildArgs, advanced }) => ({ + name, + build, + dockerfile, + buildArgs, + advanced, + })), + ).toEqual([ + { + name: "api", + build: "../../", + dockerfile: "services/shared/Dockerfile", + buildArgs: { + APP_PACKAGE: "@myorg/api", + FROM_ENV: null, + CHANNEL: "${RELEASE_CHANNEL:-stable}", + }, + advanced: { buildArgTemplateKeys: ["CHANNEL"] }, + }, + { + name: "worker", + build: "../../", + dockerfile: "services/shared/Dockerfile", + buildArgs: { APP_PACKAGE: "@myorg/worker" }, + advanced: { buildArgTemplateKeys: [] }, + }, + ]); + }); +}); + describe("buildAdoptedServiceRows — adoption never host-publishes an adopted port (#388)", () => { it("drops a bare/expose-only container port so an internal DB (e.g. postgres 5432) isn't re-published on a random host port", () => { - const { rows } = buildAdoptedServiceRows([svc({ name: "postgres", ports: ["5432"] })], new Set(["postgres"])); + const { rows } = buildAdoptedServiceRows( + [svc({ name: "postgres", ports: ["5432"] })], + new Set(["postgres"]), + ); expect(rows[0]?.ports ?? []).toEqual([]); }); @@ -35,7 +111,10 @@ describe("buildAdoptedServiceRows — adoption never host-publishes an adopted p // would bind a random loopback port). `exposedPort` records what the container // LISTENS on without publishing anything, which is what both the Domains tab's // findServiceByPort and the project-level route resolver match on (#618). - const { rows } = buildAdoptedServiceRows([svc({ name: "postgres", ports: ["5432"] })], new Set(["postgres"])); + const { rows } = buildAdoptedServiceRows( + [svc({ name: "postgres", ports: ["5432"] })], + new Set(["postgres"]), + ); expect(rows[0]?.ports ?? []).toEqual([]); expect(rows[0]?.exposedPort).toBe("5432"); }); @@ -76,7 +155,10 @@ describe("buildAdoptedServiceRows — adoption never host-publishes an adopted p }); it("keeps the container port for an edge-owned 80/443 publish (OpenResty routes to it)", () => { - const { rows } = buildAdoptedServiceRows([svc({ name: "web", ports: ["80:3000"] })], new Set(["web"])); + const { rows } = buildAdoptedServiceRows( + [svc({ name: "web", ports: ["80:3000"] })], + new Set(["web"]), + ); expect(rows[0]?.ports).toEqual(["3000"]); }); @@ -91,7 +173,9 @@ describe("buildAdoptedServiceRows — adoption never host-publishes an adopted p it("warns the operator once per service, naming the stripped host ports and the route path", () => { const service = svc({ name: "web", ports: ["8080:80"] }); buildAdoptedServiceRows([service], new Set(["web"])); - expect(service.warnings.some((w) => w.includes("8080") && w.includes("Domains tab"))).toBe(true); + expect(service.warnings.some((w) => w.includes("8080") && w.includes("Domains tab"))).toBe( + true, + ); }); it("flags an off-box publish as external exposure that was dropped (the security-meaningful signal)", () => { @@ -138,16 +222,18 @@ describe("buildAdoptedServiceRows — repo-service rename (migration mapping)", name: "postgres", image: "postgres:16-alpine", volumes: [ - { type: "volume", source: "openship-openship-postgres", target: "/var/lib/postgresql/data", rw: true }, + { + type: "volume", + source: "openship-openship-postgres", + target: "/var/lib/postgresql/data", + rw: true, + }, ] as DiscoveredService["volumes"], }), ]; - const { rows, renames } = buildAdoptedServiceRows( - chosen, - new Set(["postgres"]), - undefined, - { postgres: "db" }, - ); + const { rows, renames } = buildAdoptedServiceRows(chosen, new Set(["postgres"]), undefined, { + postgres: "db", + }); expect(rows).toHaveLength(1); expect(rows[0]!.name).toBe("db"); // adopted under the repo service name expect(rows[0]!.volumes).toEqual(["openship-openship-postgres:/var/lib/postgresql/data"]); // volume verbatim @@ -166,7 +252,12 @@ describe("buildAdoptedServiceRows — repo-service rename (migration mapping)", }); it("falls back to the discovered name when unmapped (identity renames)", () => { - const { rows, renames, handover } = buildAdoptedServiceRows([svc({ name: "web" })], new Set(["web"]), undefined, undefined); + const { rows, renames, handover } = buildAdoptedServiceRows( + [svc({ name: "web" })], + new Set(["web"]), + undefined, + undefined, + ); expect(rows[0]!.name).toBe("web"); expect(renames).toEqual({ web: "web" }); expect(handover).toEqual({}); // no repo → legacy image-only, nothing handed over @@ -180,7 +271,16 @@ describe("buildAdoptedServiceRows — native rows from the mapped repo compose", // reclones + rebuilds), NOT the frozen tag — and the running image is reused // exactly once via `handover`. const chosen = [svc({ name: "openship-api", image: "openship/openship-api:bld_stale" })]; - const repoServices = new Map([["api", repoSvc({ name: "api", build: "./apps/api" })]]); + const repoServices = new Map([ + [ + "api", + repoSvc({ + name: "api", + build: "./apps/api", + buildArgs: { APP_PACKAGE: "@myorg/api", FROM_ENV: null }, + }), + ], + ]); const { rows, handover } = buildAdoptedServiceRows( chosen, new Set(["openship-api"]), @@ -190,13 +290,16 @@ describe("buildAdoptedServiceRows — native rows from the mapped repo compose", ); expect(rows[0]!.name).toBe("api"); expect(rows[0]!.build).toBe("./apps/api"); // native source → Redeploy rebuilds + expect(rows[0]!.buildArgs).toEqual({ APP_PACKAGE: "@myorg/api", FROM_ENV: null }); expect(rows[0]!.image).toBeUndefined(); // NOT the stale bld_ tag expect(handover).toEqual({ api: "openship/openship-api:bld_stale" }); // reuse once }); it("an image: repo service (postgres) → pulls its registry image, no build, no handover", () => { const chosen = [svc({ name: "postgres", image: "postgres:16-alpine" })]; - const repoServices = new Map([["postgres", repoSvc({ name: "postgres", image: "postgres:16-alpine" })]]); + const repoServices = new Map([ + ["postgres", repoSvc({ name: "postgres", image: "postgres:16-alpine" })], + ]); const { rows, handover } = buildAdoptedServiceRows( chosen, new Set(["postgres"]), diff --git a/apps/api/src/modules/migration/migrate.service.ts b/apps/api/src/modules/migration/migrate.service.ts index ad2c62468..0c9b112d4 100644 --- a/apps/api/src/modules/migration/migrate.service.ts +++ b/apps/api/src/modules/migration/migrate.service.ts @@ -15,8 +15,8 @@ */ import { repos, restoreSubgraph, PkCollisionError, type Service } from "@repo/db"; -import { slugify, safeErrorMessage, mergeAdvanced, type ComposeAdvanced } from "@repo/core"; -import { buildNetworkAliases, type ContainerStatus } from "@repo/adapters"; +import { slugify, safeErrorMessage, mergeAdvanced } from "@repo/core"; +import { buildNetworkAliases, type ContainerInfo, type ContainerStatus } from "@repo/adapters"; import { serviceAliasExtras } from "../../lib/deployable-service"; import { COMPOSE_SENTINEL } from "../../lib/container-ref"; import { isControlPlaneProject } from "../../lib/controller-helpers"; @@ -27,6 +27,7 @@ import { blockingComposeFields, describeBlockingComposeFields, parseComposeFile, + type ComposeService, } from "../../lib/compose-parser"; import { unmaskEnv } from "../../lib/secret-env"; import { createServerDockerRuntime } from "../../lib/deployment-runtime"; @@ -51,29 +52,19 @@ type ParsedComposeList = Parameters[1]; const DEPLOYMENT_ID_RE = /^dep_[A-Za-z0-9]+$/; /** Compose file names to probe in a linked repo (mirrors prepare.service COMPOSE_FILES). */ -const REPO_COMPOSE_FILES = ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"]; +const REPO_COMPOSE_FILES = [ + "docker-compose.yml", + "docker-compose.yaml", + "compose.yml", + "compose.yaml", +]; /** Compose-service shape returned to the migrate wizard's mapping step. Carries * enough to render a full native service card (env + deps), so a repo service * with no running container (e.g. `redis`) is a first-class, editable unit. */ -export interface RepoComposeService { - name: string; - build?: string; - dockerfile?: string; - image?: string; - ports: string[]; - environment: Record; - dependsOn: string[]; - volumes: string[]; - command?: string; - commandArgv?: string[] | null; // #332 - restart?: string; - /** Extended compose keys (healthcheck, resource caps, shared namespaces). Must be - * declared here AND forwarded into the row below — carrying it only out of the - * parser leaves it stranded on this shape, which is how the blob went missing - * from every migrated stack in the first place. */ - advanced?: ComposeAdvanced; -} +/** Parser service minus scan-only provenance. Deriving this shape prevents a + * new compose-owned field from being stranded in another handwritten map. */ +export type RepoComposeService = Omit; /** * Parse a LINKED repo's docker-compose into its services, so the migrate wizard @@ -118,34 +109,18 @@ export async function parseRepoCompose( describeBlockingComposeFields(blocking), ); } - return parsed.services.map((s) => ({ - name: s.name, - build: s.build ?? undefined, - dockerfile: s.dockerfile ?? undefined, - image: s.image ?? undefined, - ports: s.ports ?? [], - environment: s.environment ?? {}, - dependsOn: s.dependsOn ?? [], - volumes: s.volumes ?? [], - command: s.command ?? undefined, - commandArgv: s.commandArgv ?? null, // #332 - restart: s.restart ?? undefined, - // Extended keys (healthcheck, resource caps, shared namespaces). This - // hand-written map dropped the whole blob, so a migrated stack lost its - // healthchecks and per-service caps along with them — the same - // field-by-field omission #533 is about. - advanced: s.advanced ?? undefined, - })); + return parsed.services.map( + ({ environmentTemplates: _templates, environmentMeta: _meta, ...service }) => service, + ); } catch (err) { // RETHROWN, not swallowed. Returning [] showed the wizard's mapping step an empty // repo-service list with no reason why — issue #339's symptom, which the native path // fixed the same way. A blocking key (above) surfaces through here too: the file is // valid, it just asks for something that cannot be deployed faithfully, and unlike a // missing env value there is nothing the wizard could collect to resolve it. - throw new Error( - `Could not use the repo's Docker Compose file: ${safeErrorMessage(err)}`, - { cause: err }, - ); + throw new Error(`Could not use the repo's Docker Compose file: ${safeErrorMessage(err)}`, { + cause: err, + }); } } return []; @@ -181,7 +156,9 @@ export function containerStatusToServiceStatus(status: ContainerStatus): string * reattach-status.test.ts; delegating to the native rollup silently turned a half-down * re-attached stack green. */ -export function deriveDeploymentStatus(states: ContainerStatus[]): "ready" | "partial_failure" | "failed" { +export function deriveDeploymentStatus( + states: ContainerStatus[], +): "ready" | "partial_failure" | "failed" { const running = states.filter((s) => s === "running").length; if (running === states.length && running > 0) return "ready"; if (running > 0) return "partial_failure"; @@ -376,11 +353,23 @@ export function buildAdoptedServiceRows( // • No mapping (no repo linked / unmapped) → adopt the running image as-is // (legacy: we have no build source, so reuse the image). Cross-server the // image is transferred (docker save|load) so the target has it. - const repo = repoServices?.get(uniqueNames[i]) ?? repoServices?.get(perService(serviceRenames, s) ?? s.name); + const repo = + repoServices?.get(uniqueNames[i]) ?? + repoServices?.get(perService(serviceRenames, s) ?? s.name); const native = repo && (repo.build || repo.image); const source = native - ? { image: repo.image, build: repo.build, dockerfile: repo.dockerfile } - : { image: s.image, build: s.image ? undefined : s.build, dockerfile: s.image ? undefined : s.dockerfile }; + ? { + image: repo.image, + build: repo.build, + dockerfile: repo.dockerfile, + buildArgs: repo.buildArgs, + } + : { + image: s.image, + build: s.image ? undefined : s.build, + dockerfile: s.image ? undefined : s.dockerfile, + buildArgs: s.image ? undefined : s.buildArgs, + }; // Hand the running image to the deploy for the one-time cutover: a native // `build:` row would otherwise rebuild on its very first deploy. Only when we // actually have a running image to reuse. @@ -416,6 +405,7 @@ export function buildAdoptedServiceRows( image: source.image, build: source.build, dockerfile: source.dockerfile, + buildArgs: source.buildArgs, ports, ...(exposedPort ? { exposedPort } : {}), // Only keep dependencies on services we're also adopting. @@ -461,7 +451,6 @@ export function buildAdoptedServiceRows( return { rows, renames, rowNameByDiscovered: Object.fromEntries(firstUnique), handover }; } - export async function adoptServerStack(opts: { serverId: string; organizationId: string; @@ -505,7 +494,20 @@ export async function adoptServerStack(opts: { * and the returned `handover` lets the first deploy reuse the running image. */ repoServices?: Map; }): Promise { - const { serverId, organizationId, projectName, serviceNames, serviceContainerIds, sameServer, volumeStrategies, serviceSubpaths, serviceEnv, serviceRenames, flatDocker, repoServices } = opts; + const { + serverId, + organizationId, + projectName, + serviceNames, + serviceContainerIds, + sameServer, + volumeStrategies, + serviceSubpaths, + serviceEnv, + serviceRenames, + flatDocker, + repoServices, + } = opts; const stack = await discoverServerStack(serverId, organizationId, undefined, { flatDocker }); // Resolve names within ONE group when the caller scoped the adopt — a bare @@ -569,7 +571,12 @@ export async function adoptServerStack(opts: { ); } - const { rows: parsed, renames, rowNameByDiscovered, handover } = buildAdoptedServiceRows( + const { + rows: parsed, + renames, + rowNameByDiscovered, + handover, + } = buildAdoptedServiceRows( chosen, // Derived from `chosen`, which is post-scope and post-control-plane-exclusion. undefined, @@ -601,13 +608,14 @@ export async function adoptServerStack(opts: { image: rs.image, build: rs.build, dockerfile: rs.dockerfile, + buildArgs: rs.buildArgs, ports, // Keep deps only on services this project actually has (adopted or new). dependsOn: (rs.dependsOn ?? []).filter((d) => repoServices.has(d) || adoptedNames.has(d)), // #336: restore masked sentinels from the repo compose env (real values). environment: serviceEnv?.[name] ? unmaskEnv(serviceEnv[name], rs.environment ?? {}) - : rs.environment ?? {}, + : (rs.environment ?? {}), volumes: rs.volumes ?? [], command: rs.command, commandArgv: rs.commandArgv ?? null, // #332 @@ -627,11 +635,9 @@ export async function adoptServerStack(opts: { // on that, see the `created === false` control-plane guard below), so the default // deleted every OTHER compose service row of that project, cascading its // service_deployment history and orphaning its running containers. - const createdServices = await repos.service.syncFromCompose( - project_id, - [...parsed, ...newRows], - { removeMissing: false }, - ); + const createdServices = await repos.service.syncFromCompose(project_id, [...parsed, ...newRows], { + removeMissing: false, + }); // Apply the per-service options keyed by the DISCOVERED name: iterate `chosen` // (discovered), resolve the created row by its FINAL (possibly-renamed) name, @@ -686,6 +692,25 @@ interface AttachPlacement { status: ContainerStatus; ip?: string; hostPort?: number; + hostPorts?: Record | null; +} + +/** Convert the runtime's numeric-keyed binding map into the JSON shape stored on + * `service_deployment`. An inspected container with no publishes is explicit null. */ +function durableHostPorts( + bindings: ContainerInfo["hostPortByContainerPort"], +): Record | null { + const entries = Object.entries(bindings ?? {}).filter(([container, host]) => { + const parsed = Number(container); + return ( + Number.isInteger(parsed) && + parsed > 0 && + parsed <= 65_535 && + Number.isInteger(host) && + host > 0 + ); + }); + return entries.length > 0 ? Object.fromEntries(entries) : null; } /** @@ -697,7 +722,9 @@ interface AttachPlacement { */ async function readAttachPlacements( rt: { - getContainerInfo: (id: string) => Promise<{ status: ContainerStatus; ip?: string; hostPort?: number }>; + getContainerInfo: ( + id: string, + ) => Promise>; resolveImageDigest?: (ref: string) => Promise; }, entries: Array<{ service: Service; disc?: DiscoveredService }>, @@ -707,9 +734,13 @@ async function readAttachPlacements( let status: ContainerStatus = disc?.running ? "running" : "stopped"; let ip: string | undefined; let hostPort: number | undefined; + let hostPorts: Record | null | undefined; if (disc?.containerId) { const info = await rt.getContainerInfo(disc.containerId).catch(() => null); - if (info) ({ status, ip, hostPort } = info); + if (info) { + ({ status, ip, hostPort } = info); + hostPorts = durableHostPorts(info.hostPortByContainerPort); + } } // The digest of the image this container is ACTUALLY running. A deploy records it // (deploy.service → `result.imageDigest`); adopt recorded nothing, and since @@ -727,6 +758,7 @@ async function readAttachPlacements( status, ip, hostPort, + hostPorts, }; }), ); @@ -798,6 +830,7 @@ async function writeAttachedRuntime(opts: { imageRef: p.image ?? null, imageDigest: p.imageDigest ?? null, hostPort: p.hostPort ?? null, + hostPorts: p.hostPorts ?? null, ip: p.ip ?? null, }); } @@ -977,9 +1010,9 @@ export async function joinReusedContainersToGroup(opts: { /** * Refresh a RESTORED deployment's runtime rows against live docker: the snapshot - * carried each container's ip/hostPort as of the last deploy, but IPs change on + * carried each container's ip/host-port bindings as of the last deploy, but IPs change on * restart. Re-read `getContainerInfo` per service_deployment container, update - * ip/hostPort/status, and recompute the deployment badge from the live states. + * ip/host-port map/status, and recompute the deployment badge from the live states. * Best-effort — the Services tab is a live read anyway, so a failure here only * leaves the stored ip/status at their (last-deploy) snapshot values. */ @@ -1010,6 +1043,7 @@ async function refreshRestoredRuntime( // the row claim a 127.0.0.1 publish the container doesn't have (#506). // `info === null` = couldn't ask → keep the last-known values. hostPort: info ? (info.hostPort ?? null) : (sd.hostPort ?? null), + hostPorts: info ? durableHostPorts(info.hostPortByContainerPort) : (sd.hostPorts ?? null), ip: info ? (info.ip ?? null) : (sd.ip ?? null), }); } diff --git a/apps/api/src/modules/migration/migration.orchestrator.ts b/apps/api/src/modules/migration/migration.orchestrator.ts index e410490aa..010069e5d 100644 --- a/apps/api/src/modules/migration/migration.orchestrator.ts +++ b/apps/api/src/modules/migration/migration.orchestrator.ts @@ -42,6 +42,9 @@ import { readEdgeFile, writeEdgeFile, edgeProxy, + edgeProxyFor, + type EdgeProxyApi, + type Platform, type ServiceHandle, type TransferEndpoint, type TransferMode, @@ -61,7 +64,10 @@ import { requestBuildAccess } from "../deployments/build.service"; import { restartServiceContainer, updateService } from "../services/service.service"; import { describeLiveState, resolveLiveServiceState } from "../services/live-state"; import { applyProjectRouting } from "../domains/routing-apply.service"; -import { resolveProjectRouteState, reapplyProjectLiveRoutes } from "../domains/project-route.service"; +import { + resolveProjectRouteState, + reapplyProjectLiveRoutes, +} from "../domains/project-route.service"; import { linkProjectRepo } from "../projects/project-crud.service"; import type { ProjectCompositeRoute, ProxySettings } from "@repo/core"; import { teardownProject } from "../projects/project-teardown"; @@ -80,6 +86,11 @@ import { excludeAlreadyManaged } from "./managed-containers"; import { perService, selectDiscoveredServices } from "./select-services"; import { isMovableBind } from "./migration-preflight"; import { migrationRunBus } from "./migration.sse"; +import type { HostPortTargetIdentity } from "../../lib/host-port-target"; +import { + convergeTargetHostPortClaimsUnlocked, + withHostPortTargetLock, +} from "../deployments/pinned-host-ports"; /** Per-service volume ownership for a same-server migration. * "reuse" (default) = seize the original volume in place (zero copy). @@ -97,6 +108,64 @@ export interface ProgressUpdate { totalBytes: number | null; } +/** + * Retire a project's managed routes on its old physical target and release only + * claims that a strict, post-mutation edge inventory proves are no longer used. + * + * The route writes and convergence deliberately share the physical-target lock: + * otherwise a concurrent deploy could reserve/re-register a port between the + * removal and the scan. Any route failure suppresses the entire release pass; + * an uncertain removal must fail closed. The convergence primitive itself uses + * a forced-fresh strict scan and exact ownership checks. + */ +export async function retireSourceManagedRoutes(input: { + projectId: string; + hostnames: Iterable; + routing: Pick; + target: HostPortTargetIdentity; + edgeProxy: Pick; + /** False while even one source workload survived destructive cutover. */ + releaseClaims: boolean; +}): Promise { + await withHostPortTargetLock(input.target, async () => { + let routesRemoved = true; + for (const hostname of new Set(input.hostnames)) { + try { + // Idempotent (rm -rf semantics), so a hostname the source never served + // is a no-op rather than an error. + await input.routing.removeRoute(hostname); + } catch (err) { + routesRemoved = false; + console.warn( + `[migration] source edge: removeRoute ${hostname} failed; host-port claims retained:`, + safeErrorMessage(err), + ); + } + } + + // A surviving source container can still hold or later reclaim its bind. + // Likewise, a failed route removal is uncertain even when the other vhosts + // were removed successfully. In either case, retain every claim. + if (!input.releaseClaims || !routesRemoved) return; + + try { + await convergeTargetHostPortClaimsUnlocked({ + target: input.target, + projectId: input.projectId, + desiredPublishes: [], + edgeProxy: input.edgeProxy, + }); + } catch (err) { + // Source destruction already completed and the target is serving. Claim + // cleanup is best-effort; the safe failure mode is durable retention. + console.warn( + `[migration] source host-port claim convergence deferred (claims retained):`, + safeErrorMessage(err), + ); + } + }); +} + export interface StartMigrationInput { organizationId: string; sourceServerId: string; @@ -584,7 +653,9 @@ class MigrationOrchestratorImpl { ): Promise { const { organizationId, sourceServerId, serviceNames } = input; const sameServer = sourceServerId === input.targetServerId; - log(`${sameServer ? "same-server" : "cross-server"} migration of ${serviceNames.length} service(s): ${serviceNames.join(", ")}`); + log( + `${sameServer ? "same-server" : "cross-server"} migration of ${serviceNames.length} service(s): ${serviceNames.join(", ")}`, + ); const stack = await discoverServerStack(sourceServerId, organizationId, undefined, { flatDocker: input.flatDocker, }); @@ -639,7 +710,6 @@ class MigrationOrchestratorImpl { const attachChosen = chosen.filter((s) => isAttach(s)); const deployChosen = chosen.filter((s) => !isAttach(s)); - // Parse the linked repo's compose so adopted rows take their NATIVE // build/image spec (mapped by the wizard) instead of a frozen running-image // tag — the fix that makes a later Redeploy reclone + rebuild rather than @@ -669,11 +739,7 @@ class MigrationOrchestratorImpl { return { chosen, attachChosen, deployChosen, adopt, repoServices }; } - private async run( - ctx: RequestContext, - id: string, - input: StartMigrationInput, - ): Promise { + private async run(ctx: RequestContext, id: string, input: StartMigrationInput): Promise { const { organizationId, sourceServerId, targetServerId, serviceNames } = input; const sameServer = sourceServerId === targetServerId; let scannedContainerIds: Record = {}; @@ -722,9 +788,12 @@ class MigrationOrchestratorImpl { // volume copy) and rollback restarted only one; and `resolveScannedContainerId` // looks this up BY ROW NAME, so a renamed or `-2`-suffixed row never resolved // its container and fell back to a guessed volume name. - const rowNameOf = (svc: (typeof chosen)[number]) => perService(adopt.renames, svc) ?? svc.name; + const rowNameOf = (svc: (typeof chosen)[number]) => + perService(adopt.renames, svc) ?? svc.name; scannedContainerIds = Object.fromEntries( - deployChosen.filter((s) => s.containerId).map((s) => [rowNameOf(s), s.containerId as string]), + deployChosen + .filter((s) => s.containerId) + .map((s) => [rowNameOf(s), s.containerId as string]), ); // Row-keyed too: moveData works in adopted service ROWS, so handing it the // request's identity-keyed map would match nothing. @@ -738,9 +807,11 @@ class MigrationOrchestratorImpl { // carry an image, so the deploy reuses it regardless — a GitHub hiccup must // never block the (destructive) migration. if (input.gitSource) { - const linked = await linkProjectRepo(ctx, projectId, input.gitSource).catch( - (err) => ({ ok: false as const, code: "invalid" as const, message: safeErrorMessage(err) }), - ); + const linked = await linkProjectRepo(ctx, projectId, input.gitSource).catch((err) => ({ + ok: false as const, + code: "invalid" as const, + message: safeErrorMessage(err), + })); if (!linked.ok) { console.warn(`[migration] ${id}: repo link skipped (${linked.code})`); } @@ -952,52 +1023,54 @@ class MigrationOrchestratorImpl { await this.transition(id, "deploying"); log(`deploying to target server…`); { - const dep = await requestBuildAccess(ctx, { - projectId, - deployTarget: "server", - serverId: targetServerId, - runtimeMode: "docker", - serviceDeploymentMode: "services", - // Deploy ONLY the new/moved rows. `requestBuildAccess` ignores an EMPTY list - // (`serviceIds && length > 0`), which would silently mean "deploy everything" - // and recreate the reuse set — so the caller refuses to get here with one - // (see the guard above this block). - serviceIds: deployRowIds, - // One-time cutover: native `build:` rows reuse the transferred/running - // image on THIS deploy (no rebuild); a later Redeploy has no handover - // and rebuilds from the repo. - handoverImages: adopt.handover, + const dep = await requestBuildAccess( + ctx, + { + projectId, + deployTarget: "server", + serverId: targetServerId, + runtimeMode: "docker", + serviceDeploymentMode: "services", + // Deploy ONLY the new/moved rows. `requestBuildAccess` ignores an EMPTY list + // (`serviceIds && length > 0`), which would silently mean "deploy everything" + // and recreate the reuse set — so the caller refuses to get here with one + // (see the guard above this block). + serviceIds: deployRowIds, + // One-time cutover: native `build:` rows reuse the transferred/running + // image on THIS deploy (no rebuild); a later Redeploy has no handover + // and rebuilds from the repo. + handoverImages: adopt.handover, + /** + * The SINGLE-APP twin of the map above, and the reason a moved single app rebuilt + * itself from source on the target. + * + * `handoverImages` is the COMPOSE field: `pinnedServiceImage` looks a service NAME up + * in it. A single-app deploy asks `pinnedAppImage`, which reads this scalar — and + * `snapshotNeedsGitSource` keys off the same thing, so with it unset the target cloned + * the repo and ran a full `docker build`. For `makieon` that meant streaming 725 MB of + * image across, then rebuilding it from git anyway: minutes of wasted work whose only + * visible symptom was a migration that looked stuck on its last step. + * + * Set only when the workload IS one service, so a compose project keeps using the map. + */ + ...(Object.keys(adopt.handover).length === 1 + ? { handoverAppImage: Object.values(adopt.handover)[0] } + : {}), + }, /** - * The SINGLE-APP twin of the map above, and the reason a moved single app rebuilt - * itself from source on the target. + * EXCLUSIVE scope, not just "prefer these". * - * `handoverImages` is the COMPOSE field: `pinnedServiceImage` looks a service NAME up - * in it. A single-app deploy asks `pinnedAppImage`, which reads this scalar — and - * `snapshotNeedsGitSource` keys off the same thing, so with it unset the target cloned - * the repo and ran a full `docker build`. For `makieon` that meant streaming 725 MB of - * image across, then rebuilding it from git anyway: minutes of wasted work whose only - * visible symptom was a migration that looked stuck on its last step. - * - * Set only when the workload IS one service, so a compose project keeps using the map. + * `serviceIds` on its own means "build these, CARRY the rest forward", and carry + * reads `project.activeDeploymentId` — which is null here: a freshly adopted + * project has no previous release, and this run's own runtime rows are written by + * `attachLiveRuntime` AFTER the deploy. So without this the reuse rows would be + * neither carried nor skipped: enabled and holding a real image, they'd deploy + * normally and put a SECOND container on the still-running originals' bare + * volumes (reuse rows keep `namespaceVolumes: false`) — two writers on one + * dataset, the exact opposite of what reuse mode promises. */ - ...(Object.keys(adopt.handover).length === 1 - ? { handoverAppImage: Object.values(adopt.handover)[0] } - : {}), - }, - /** - * EXCLUSIVE scope, not just "prefer these". - * - * `serviceIds` on its own means "build these, CARRY the rest forward", and carry - * reads `project.activeDeploymentId` — which is null here: a freshly adopted - * project has no previous release, and this run's own runtime rows are written by - * `attachLiveRuntime` AFTER the deploy. So without this the reuse rows would be - * neither carried nor skipped: enabled and holding a real image, they'd deploy - * normally and put a SECOND container on the still-running originals' bare - * volumes (reuse rows keep `namespaceVolumes: false`) — two writers on one - * dataset, the exact opposite of what reuse mode promises. - */ - { strictServiceScope: true }, - ); + { strictServiceScope: true }, + ); deploymentId = dep.deployment_id; await this.transition(id, "deploying", { deploymentId }); log(`target deployment ${deploymentId} started; verifying health…`); @@ -1156,7 +1229,11 @@ class MigrationOrchestratorImpl { this.throwIfCancelled(id); await this.transition(id, "cutover"); log(`cutover: stopping + removing the source originals`); - const { failed } = await this.cutover(sourceServerId, organizationId, scannedContainerIds); + const { failed } = await this.cutover( + sourceServerId, + organizationId, + scannedContainerIds, + ); await this.transition(id, "succeeded"); // The migration DID succeed — the target is live — so the status stays `succeeded`. // But a container still standing on the old server is something the operator has to @@ -1234,9 +1311,7 @@ class MigrationOrchestratorImpl { sourceProjectSlug?: string, ): Promise { const rtA = await createServerDockerRuntime(sourceServerId, organizationId); - const rtB = sameServer - ? null - : await createServerDockerRuntime(targetServerId, organizationId); + const rtB = sameServer ? null : await createServerDockerRuntime(targetServerId, organizationId); try { // Quiesce originals for a consistent copy (and to free ports/volumes on // a same-server redeploy). Best-effort — a missing container is fine. @@ -1278,8 +1353,7 @@ class MigrationOrchestratorImpl { * compression that never ran, so both the flag and the log say what actually * happens. "auto"/unset stays OFF — a fast LAN link usually beats the compressor. */ - const compress = - transfer.compression === "zstd" || transfer.compression === "gzip"; + const compress = transfer.compression === "zstd" || transfer.compression === "gzip"; if (transfer.compression === "zstd") { log("compression 'zstd' is not available over rsync — using rsync -z (zlib)"); } @@ -1528,7 +1602,11 @@ class MigrationOrchestratorImpl { ); if (!verdict) continue; const name = task.dst.sourceId; - if (relayOurPrefix && name.startsWith(relayOurPrefix) && name.length > relayOurPrefix.length) { + if ( + relayOurPrefix && + name.startsWith(relayOurPrefix) && + name.length > relayOurPrefix.length + ) { log( `${name}: left on the target by an earlier attempt at this move — ` + `its contents are replaced by this transfer`, @@ -1580,7 +1658,9 @@ class MigrationOrchestratorImpl { onProgress?.({ task, kind: "volume", movedBytes: relayMoved(), totalBytes: null }); }, }); - log(`${t.label}/${t.src.sourceId}: ${r.strategy} (${r.compression}) — ${r.bytesMoved} bytes`); + log( + `${t.label}/${t.src.sourceId}: ${r.strategy} (${r.compression}) — ${r.bytesMoved} bytes`, + ); return r.bytesMoved; } catch (err) { const message = safeErrorMessage(err); @@ -1734,7 +1814,9 @@ class MigrationOrchestratorImpl { const ourSlug = sourceProjectSlug || projectSlug; const ourVolumePrefix = ourSlug ? scopedVolumeName(ourSlug, "") : null; const isOurNamespacedVolume = (name: string) => - Boolean(ourVolumePrefix) && name.startsWith(ourVolumePrefix!) && name.length > ourVolumePrefix!.length; + Boolean(ourVolumePrefix) && + name.startsWith(ourVolumePrefix!) && + name.length > ourVolumePrefix!.length; log( `conflict resolution: ${Object.keys(conflictResolution).length ? JSON.stringify(conflictResolution) : "none"}` + `; enumerated volumes: ${[...volumeNames].join(", ") || "none"}`, @@ -1757,7 +1839,9 @@ class MigrationOrchestratorImpl { if (!occupied.has(name)) continue; if (fallback) { resolution[name] = fallback; - log(`conflict ${name}: no explicit choice — applying '${fallback}' (matches your other choices)`); + log( + `conflict ${name}: no explicit choice — applying '${fallback}' (matches your other choices)`, + ); continue; } // OUR OWN DEBRIS IS NOT A CONFLICT. @@ -1776,7 +1860,10 @@ class MigrationOrchestratorImpl { const users = await target.executor .exec(`docker ps --filter volume=${sq(name)} --format '{{.Names}}' 2>/dev/null || true`) .catch(() => ""); - const running = users.split("\n").map((s) => s.trim()).filter(Boolean); + const running = users + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); if (running.length === 0) { resolution[name] = "override"; log( @@ -1822,20 +1909,20 @@ class MigrationOrchestratorImpl { customPaths: customPaths.map((c) => c.source), }).catch(() => null); const totalBytes = sized && sized.totalBytes > 0 ? sized.totalBytes : null; - log(`transfer plan: ${totalBytes ?? "?"} bytes across ${volumeNames.size} volume(s), ` + - `${bindPaths.size} bind(s), ${imagesToMove.length} image(s), ${customPaths.length} path(s)`); + log( + `transfer plan: ${totalBytes ?? "?"} bytes across ${volumeNames.size} volume(s), ` + + `${bindPaths.size} bind(s), ${imagesToMove.length} image(s), ${customPaths.length} path(s)`, + ); const bytesByTask = new Map(); - const track = - (task: string, kind: "image" | "volume") => - (bytes: number) => { - // Per-task floor: a resumed rsync re-reports from a lower offset, so - // clamp to the max seen — the bar never rewinds on a resume/retry. - bytesByTask.set(task, Math.max(bytesByTask.get(task) ?? 0, bytes)); - let movedBytes = 0; - for (const b of bytesByTask.values()) movedBytes += b; - onProgress?.({ task, kind, movedBytes, totalBytes }); - }; + const track = (task: string, kind: "image" | "volume") => (bytes: number) => { + // Per-task floor: a resumed rsync re-reports from a lower offset, so + // clamp to the max seen — the bar never rewinds on a resume/retry. + bytesByTask.set(task, Math.max(bytesByTask.get(task) ?? 0, bytes)); + let movedBytes = 0; + for (const b of bytesByTask.values()) movedBytes += b; + onProgress?.({ task, kind, movedBytes, totalBytes }); + }; // Images first — sequential (large; save|load contends on the link). Every // image the source has locally is MOVED as data (docker save|load), so a @@ -1895,20 +1982,29 @@ class MigrationOrchestratorImpl { if (action === "keep") { log(`volume ${it.ref}: keeping existing target data (not transferred)`); } else { - const dstName = action === "clone" ? scopedVolumeName(projectSlug, it.ref) : undefined; + const dstName = + action === "clone" ? scopedVolumeName(projectSlug, it.ref) : undefined; // No push here — `targetVolumes` was recorded in full before the pool started, // precisely so a mid-transfer abort still leaves a cleanable record. await link.transferVolume(it.ref, track(`volume:${it.ref}`, "volume"), dstName); verifyVolumes.push({ src: it.ref, dst: dstName ?? it.ref }); } - } else if (it.kind === "bind") await link.transferBind(it.ref, track(`bind:${it.ref}`, "volume")); + } else if (it.kind === "bind") + await link.transferBind(it.ref, track(`bind:${it.ref}`, "volume")); else await link.transferPath(it.source, it.dest, track(`path:${it.source}`, "volume")); } catch (err) { const missing = err instanceof PathMissingError; const message = safeErrorMessage(err); const pending: PendingItem = it.kind === "path" - ? { key: `path:${it.source}`, kind: "path", source: it.source, dest: it.dest, reason: missing ? "missing" : "error", message } + ? { + key: `path:${it.source}`, + kind: "path", + source: it.source, + dest: it.dest, + reason: missing ? "missing" : "error", + message, + } : { key: `${it.kind}:${it.ref}`, kind: it.kind, @@ -1944,7 +2040,9 @@ class MigrationOrchestratorImpl { continue; } const ok = Math.abs(srcBytes - dstBytes) <= Math.max(4096, srcBytes * 0.01); - log(`verify ${v.dst}: source ${srcBytes} → target ${dstBytes} bytes ${ok ? "✓" : "⚠ size mismatch"}`); + log( + `verify ${v.dst}: source ${srcBytes} → target ${dstBytes} bytes ${ok ? "✓" : "⚠ size mismatch"}`, + ); } let total = 0; @@ -2318,8 +2416,6 @@ class MigrationOrchestratorImpl { return base || `the deployment ended as "${dep.status}"`; } - /** Destroy the originals on the source (by scanned container id — they carry - * no openship.* labels). Never removes the source's volumes. */ /** * Remove this project's vhosts from the SOURCE server's edge, after a confirmed * project-move cutover. @@ -2329,37 +2425,38 @@ class MigrationOrchestratorImpl { * the wrong box and delete the vhosts that just started serving. * * Best-effort, and deliberately so — unlike the pause path, which fails loudly because - * a failed removal means a site the operator asked to stop is still up. Here the site - * is already up on the target and the source's containers are already gone; a leftover - * vhost is a 502 on a machine nothing should be pointing at any more. Failing the - * cutover for it would strand a run whose destructive half already succeeded. + * a failed removal means a site the operator asked to stop is still up. Here the target + * is already serving and source cleanup has already been attempted; a leftover vhost is + * a 502 (or an unsafe surviving copy) on the old box. Failing the cutover for route/claim + * maintenance would strand a run whose destructive half already ran. Claims are released + * only when every original was removed and every route removal succeeded. */ private async retireSourceRoutes( projectId: string, sourceServerId: string, organizationId: string, + releaseClaims: boolean, ): Promise { try { const hostnames = (await repos.domain.listByProject(projectId)).map((d) => d.hostname); - if (hostnames.length === 0) return; await withDeploymentPlatform( { meta: { deployTarget: "server", serverId: sourceServerId, runtimeMode: "docker" }, organizationId, } as Parameters[0], - async ({ routing }) => { - for (const hostname of hostnames) { - // Idempotent (rm -rf semantics), so a hostname the source never served is a - // no-op rather than an error. - await routing - .removeRoute(hostname) - .catch((err) => - console.warn( - `[migration] source edge: removeRoute ${hostname} failed:`, - safeErrorMessage(err), - ), - ); + async ({ routing, executor, hostPortTarget }) => { + if (!hostPortTarget || !executor) { + throw new Error("Source server did not resolve a physical host-port target"); } + + await retireSourceManagedRoutes({ + projectId, + hostnames, + routing, + target: hostPortTarget, + edgeProxy: edgeProxyFor(executor, "openresty", { ours: true }), + releaseClaims, + }); }, ); } catch (err) { @@ -2424,27 +2521,23 @@ class MigrationOrchestratorImpl { async cancel( id: string, organizationId: string, - ): Promise< - | { ok: true } - | { ok: false; status: number; error: string } - > { + ): Promise<{ ok: true } | { ok: false; status: number; error: string }> { const run = await repos.dockerMigrationRun.findById(id); if (!run || run.organizationId !== organizationId) { return { ok: false, status: 404, error: "Migration not found" }; } const CANCELLABLE = ["queued", "adopting", "moving_data", "deploying", "verifying"]; if (!CANCELLABLE.includes(run.status)) { - return { ok: false, status: 409, error: `Migration is not cancellable (status: ${run.status})` }; + return { + ok: false, + status: 409, + error: `Migration is not cancellable (status: ${run.status})`, + }; } const reg = this.cancelByRun.get(id) ?? { cancelled: false }; reg.cancelled = true; this.cancelByRun.set(id, reg); - await this.killTransfer( - run.sourceServerId, - run.targetServerId, - run.organizationId, - reg.runTag, - ); + await this.killTransfer(run.sourceServerId, run.targetServerId, run.organizationId, reg.runTag); return { ok: true }; } @@ -2480,22 +2573,22 @@ class MigrationOrchestratorImpl { confirmationToken: string, kill: boolean, ): Promise< - | { ok: true; leftBehind: LeftBehindContainer[] } - | { ok: false; status: number; error: string } + { ok: true; leftBehind: LeftBehindContainer[] } | { ok: false; status: number; error: string } > { const run = await repos.dockerMigrationRun.findById(id); if (!run || run.organizationId !== organizationId) { return { ok: false, status: 404, error: "Migration not found" }; } if (run.status !== "awaiting_cutover") { - return { ok: false, status: 409, error: `Migration is not awaiting cutover (status: ${run.status})` }; + return { + ok: false, + status: 409, + error: `Migration is not awaiting cutover (status: ${run.status})`, + }; } const expected = Buffer.from(run.confirmationToken ?? ""); const supplied = Buffer.from(confirmationToken ?? ""); - if ( - expected.length !== supplied.length || - !crypto.timingSafeEqual(expected, supplied) - ) { + if (expected.length !== supplied.length || !crypto.timingSafeEqual(expected, supplied)) { return { ok: false, status: 403, error: "Invalid confirmation token" }; } @@ -2518,7 +2611,12 @@ class MigrationOrchestratorImpl { // than not answering, and would silently win for as long as DNS still resolves // there (or anyone hits that IP directly). if (run.mode === "project_move" && run.projectId) { - await this.retireSourceRoutes(run.projectId, run.sourceServerId, organizationId); + await this.retireSourceRoutes( + run.projectId, + run.sourceServerId, + organizationId, + failed.length === 0, + ); } } else if ( !kill && @@ -2567,7 +2665,11 @@ class MigrationOrchestratorImpl { return { ok: false, status: 404, error: "Migration not found" }; } if (run.status !== "partial") { - return { ok: false, status: 409, error: `Migration is not resumable (status: ${run.status})` }; + return { + ok: false, + status: 409, + error: `Migration is not resumable (status: ${run.status})`, + }; } if (!run.sourceServerId || !run.targetServerId) { return { ok: false, status: 409, error: "Source/target server is no longer available" }; @@ -2659,7 +2761,9 @@ class MigrationOrchestratorImpl { const stillPending: PendingItem[] = []; const resolvedServices = new Set(); try { - log(`resume: retrying ${toRetry.length}, skipping ${pending.length - toRetry.length} item(s)`); + log( + `resume: retrying ${toRetry.length}, skipping ${pending.length - toRetry.length} item(s)`, + ); const [source, target] = await Promise.all([ createServerCommandExecutor(run.sourceServerId!, organizationId), createServerCommandExecutor(run.targetServerId!, organizationId), @@ -2959,17 +3063,35 @@ class MigrationOrchestratorImpl { // (idempotent) cutover — destroying an already-gone container is a no-op — // and mark it succeeded. if (run.status === "cutover") { + let sourceFullyRetired = false; if (run.sourceServerId) { try { - await this.cutover(run.sourceServerId, run.organizationId, scanned); + const { failed } = await this.cutover(run.sourceServerId, run.organizationId, scanned); + sourceFullyRetired = failed.length === 0; } catch (err) { console.warn(`[migration] recovery cutover ${run.id} failed:`, safeErrorMessage(err)); } + + // A crash can land after source destruction but before route/claim + // retirement. Replay that half idempotently. If cleanup was partial or + // unreachable, remove the stale source routes but retain every durable + // port claim for the surviving/unknown workload. + if (run.mode === "project_move" && run.projectId) { + await this.retireSourceRoutes( + run.projectId, + run.sourceServerId, + run.organizationId, + sourceFullyRetired, + ); + } } await repos.dockerMigrationRun .transition(run.id, "succeeded") .catch((err) => - console.warn(`[migration] recovery transition ${run.id} failed:`, safeErrorMessage(err)), + console.warn( + `[migration] recovery transition ${run.id} failed:`, + safeErrorMessage(err), + ), ); continue; } @@ -2998,8 +3120,7 @@ class MigrationOrchestratorImpl { } await repos.dockerMigrationRun .transition(run.id, "rolled_back", { - errorMessage: - "Recovered after an interruption — the original containers were restarted.", + errorMessage: "Recovered after an interruption — the original containers were restarted.", }) .catch((err) => console.warn(`[migration] recovery transition ${run.id} failed:`, safeErrorMessage(err)), diff --git a/apps/api/src/modules/migration/source-route-retirement.test.ts b/apps/api/src/modules/migration/source-route-retirement.test.ts new file mode 100644 index 000000000..417c20360 --- /dev/null +++ b/apps/api/src/modules/migration/source-route-retirement.test.ts @@ -0,0 +1,203 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { repos } from "@repo/db"; +import type { HostPortTargetIdentity } from "../../lib/host-port-target"; + +const h = vi.hoisted(() => ({ + lock: vi.fn(), + converge: vi.fn(), +})); + +vi.mock("../deployments/pinned-host-ports", () => ({ + withHostPortTargetLock: (...args: unknown[]) => h.lock(...args), + convergeTargetHostPortClaimsUnlocked: (...args: unknown[]) => h.converge(...args), +})); + +import { migrationOrchestrator, retireSourceManagedRoutes } from "./migration.orchestrator"; + +const target: HostPortTargetIdentity = { + targetKey: "host:source-machine", + legacyTargetKeys: ["server:source"], + stable: true, +}; + +describe("retireSourceManagedRoutes", () => { + beforeEach(() => { + vi.clearAllMocks(); + h.lock.mockImplementation(async (_target: unknown, fn: () => Promise) => fn()); + h.converge.mockResolvedValue({ released: 1, retained: [] }); + }); + + it("serializes route removal and unlocked claim convergence under one target lock", async () => { + const order: string[] = []; + h.lock.mockImplementation(async (_target: unknown, fn: () => Promise) => { + order.push("lock-enter"); + const result = await fn(); + order.push("lock-exit"); + return result; + }); + const routing = { + removeRoute: vi.fn(async (hostname: string) => { + order.push(`remove:${hostname}`); + }), + }; + const edgeProxy = { listLoopbackUpstreamPortsStrict: vi.fn() }; + h.converge.mockImplementation(async () => { + order.push("converge"); + return { released: 1, retained: [] }; + }); + + await retireSourceManagedRoutes({ + projectId: "project-1", + hostnames: ["one.example.com", "two.example.com", "one.example.com"], + routing, + target, + edgeProxy, + releaseClaims: true, + }); + + expect(h.lock).toHaveBeenCalledWith(target, expect.any(Function)); + expect(routing.removeRoute).toHaveBeenCalledTimes(2); + expect(h.converge).toHaveBeenCalledWith({ + target, + projectId: "project-1", + desiredPublishes: [], + edgeProxy, + }); + expect(order).toEqual([ + "lock-enter", + "remove:one.example.com", + "remove:two.example.com", + "converge", + "lock-exit", + ]); + }); + + it("removes routes but retains every claim while a source workload survived", async () => { + const routing = { removeRoute: vi.fn().mockResolvedValue(undefined) }; + + await retireSourceManagedRoutes({ + projectId: "project-1", + hostnames: ["app.example.com"], + routing, + target, + edgeProxy: { listLoopbackUpstreamPortsStrict: vi.fn() }, + releaseClaims: false, + }); + + expect(routing.removeRoute).toHaveBeenCalledWith("app.example.com"); + expect(h.converge).not.toHaveBeenCalled(); + }); + + it("retains every claim when any route removal is uncertain", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const routing = { + removeRoute: vi + .fn() + .mockRejectedValueOnce(new Error("source edge unavailable")) + .mockResolvedValueOnce(undefined), + }; + + await expect( + retireSourceManagedRoutes({ + projectId: "project-1", + hostnames: ["one.example.com", "two.example.com"], + routing, + target, + edgeProxy: { listLoopbackUpstreamPortsStrict: vi.fn() }, + releaseClaims: true, + }), + ).resolves.toBeUndefined(); + + expect(routing.removeRoute).toHaveBeenCalledTimes(2); + expect(h.converge).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("host-port claims retained"), + "source edge unavailable", + ); + warn.mockRestore(); + }); + + it("keeps completed cutover best-effort when the strict scan or database convergence fails", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + h.converge.mockRejectedValueOnce(new Error("strict edge scan unavailable")); + + await expect( + retireSourceManagedRoutes({ + projectId: "project-1", + hostnames: [], + routing: { removeRoute: vi.fn() }, + target, + edgeProxy: { listLoopbackUpstreamPortsStrict: vi.fn() }, + releaseClaims: true, + }), + ).resolves.toBeUndefined(); + + // Empty hostname sets still converge: they can occur after domain deletion, + // and stale claims must not survive forever when the workloads are gone. + expect(h.converge).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("claims retained"), + "strict edge scan unavailable", + ); + warn.mockRestore(); + }); +}); + +describe("interrupted project-move cutover recovery", () => { + it.each([ + { failed: [], releaseClaims: true }, + { + failed: [{ name: "web", containerId: "container-1", reason: "still running" }], + releaseClaims: false, + }, + ])( + "replays source route retirement with releaseClaims=$releaseClaims", + async ({ failed, releaseClaims }) => { + const run = { + id: "migration-1", + status: "cutover", + mode: "project_move", + projectId: "project-1", + organizationId: "org-1", + sourceServerId: "source-server", + targetServerId: "target-server", + scannedContainerIds: { web: "container-1" }, + }; + const listInFlight = vi + .spyOn(repos.dockerMigrationRun, "listInFlight") + .mockResolvedValue([run] as never); + const transition = vi + .spyOn(repos.dockerMigrationRun, "transition") + .mockResolvedValue(undefined as never); + const internals = migrationOrchestrator as unknown as { + cutover: () => Promise<{ failed: typeof failed }>; + retireSourceRoutes: ( + projectId: string, + sourceServerId: string, + organizationId: string, + releaseClaims: boolean, + ) => Promise; + }; + const cutover = vi.spyOn(internals, "cutover").mockResolvedValue({ failed }); + const retireRoutes = vi.spyOn(internals, "retireSourceRoutes").mockResolvedValue(undefined); + + await migrationOrchestrator.recoverInterruptedMigrations(); + + expect(cutover).toHaveBeenCalledWith("source-server", "org-1", { + web: "container-1", + }); + expect(retireRoutes).toHaveBeenCalledWith( + "project-1", + "source-server", + "org-1", + releaseClaims, + ); + expect(transition).toHaveBeenCalledWith("migration-1", "succeeded"); + + retireRoutes.mockRestore(); + cutover.mockRestore(); + transition.mockRestore(); + listInFlight.mockRestore(); + }, + ); +}); diff --git a/apps/api/src/modules/projects/orphan-gc-schedule.test.ts b/apps/api/src/modules/projects/orphan-gc-schedule.test.ts new file mode 100644 index 000000000..197e48b69 --- /dev/null +++ b/apps/api/src/modules/projects/orphan-gc-schedule.test.ts @@ -0,0 +1,257 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + orphans: [] as Array>, + listAll: vi.fn(), + findProject: vi.fn(async (): Promise | undefined> => undefined), + deleteOrphan: vi.fn(async () => {}), + bumpAttempt: vi.fn(async () => {}), + isReachable: vi.fn(async () => true), + resolveDeploymentPlatform: vi.fn(), + disposePlatform: vi.fn(), + removeRoute: vi.fn(async () => {}), + destroy: vi.fn(async () => {}), + convergeClaims: vi.fn(async () => ({ + released: 0, + retained: [] as Array<{ port: number }>, + })), + edgeProxyFor: vi.fn(), + edgeProxy: { listLoopbackUpstreamPortsStrict: vi.fn() }, + executor: { exec: vi.fn() }, + releaseManagedHostnames: vi.fn(async () => ({ failures: [] as string[] })), +})); + +vi.mock("@repo/db", () => ({ + repos: { + project: { findById: h.findProject }, + orphanedResource: { + listAll: h.listAll, + delete: h.deleteOrphan, + bumpAttempt: h.bumpAttempt, + }, + }, +})); + +vi.mock("@repo/adapters", () => ({ + DockerRuntime: class DockerRuntime {}, + edgeProxyFor: h.edgeProxyFor, + isRuntimeNotFoundError: () => false, + ownsBuiltImage: () => false, +})); + +vi.mock("../../lib/server-reachability", () => ({ + createReachabilityProbe: () => ({ isReachable: h.isReachable }), +})); + +vi.mock("../../lib/remote-state", () => ({ isConnectionLoss: () => false })); + +vi.mock("../../lib/deployment-runtime", () => ({ + resolveDeploymentPlatform: h.resolveDeploymentPlatform, + disposePlatform: h.disposePlatform, +})); + +vi.mock("../deployments/pinned-host-ports", () => ({ + convergeTargetHostPortClaims: h.convergeClaims, +})); + +vi.mock("../../lib/managed-edge-proxy", () => ({ + releaseManagedHostnames: h.releaseManagedHostnames, +})); + +import { runOrphanSweep } from "./orphan-gc-schedule"; + +const routeOrphan = (over: Record = {}) => ({ + id: "orphan-route-1", + organizationId: "org-1", + serverId: "server-1", + resourceType: "route", + ref: "app.example.com", + projectId: "project-1", + label: "project route", + runtimeMode: "docker", + payload: null, + attempts: 0, + lastAttemptAt: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + ...over, +}); + +const resolvedPlatform = (over: Record = {}) => ({ + platform: { + target: "selfhosted", + runtime: { name: "docker", destroy: h.destroy }, + routing: { removeRoute: h.removeRoute }, + ssl: {}, + system: null, + executor: h.executor, + localHost: false, + }, + effectiveTarget: "server", + runtimeMode: "docker", + usesManagedRouting: false, + serverId: "server-1", + hostPortTarget: { + targetKey: `host:${"a".repeat(64)}`, + legacyTargetKeys: ["server:server-1"], + stable: true, + }, + ...over, +}); + +beforeEach(() => { + vi.clearAllMocks(); + h.orphans = []; + h.listAll.mockImplementation(async () => h.orphans); + h.resolveDeploymentPlatform.mockResolvedValue(resolvedPlatform()); + h.findProject.mockResolvedValue(undefined); + h.convergeClaims.mockResolvedValue({ released: 0, retained: [] }); + h.edgeProxyFor.mockReturnValue(h.edgeProxy); +}); + +describe("runOrphanSweep route claim lifecycle", () => { + it("removes the route, freshly converges its target claims, then deletes the orphan row", async () => { + h.orphans = [routeOrphan()]; + + await expect(runOrphanSweep()).resolves.toEqual({ reclaimed: 1, deferred: 0 }); + + expect(h.isReachable).toHaveBeenCalledWith("server-1"); + expect(h.resolveDeploymentPlatform).toHaveBeenCalledWith( + { deployTarget: "server", runtimeMode: "docker", serverId: "server-1" }, + { organizationId: "org-1" }, + ); + expect(h.removeRoute).toHaveBeenCalledWith("app.example.com"); + expect(h.edgeProxyFor).toHaveBeenCalledWith(h.executor, "openresty", { ours: true }); + expect(h.convergeClaims).toHaveBeenCalledWith({ + target: { + targetKey: `host:${"a".repeat(64)}`, + legacyTargetKeys: ["server:server-1"], + stable: true, + }, + projectId: "project-1", + desiredPublishes: [], + edgeProxy: h.edgeProxy, + }); + expect(h.deleteOrphan).toHaveBeenCalledWith("orphan-route-1"); + expect(h.bumpAttempt).not.toHaveBeenCalled(); + + expect(h.removeRoute.mock.invocationCallOrder[0]).toBeLessThan( + h.convergeClaims.mock.invocationCallOrder[0]!, + ); + expect(h.convergeClaims.mock.invocationCallOrder[0]).toBeLessThan( + h.deleteOrphan.mock.invocationCallOrder[0]!, + ); + }); + + it("defers the orphan and keeps its row when fresh claim convergence fails", async () => { + h.orphans = [routeOrphan()]; + h.convergeClaims.mockRejectedValueOnce(new Error("strict edge scan failed")); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(runOrphanSweep()).resolves.toEqual({ reclaimed: 0, deferred: 1 }); + + expect(h.removeRoute).toHaveBeenCalledWith("app.example.com"); + expect(h.convergeClaims).toHaveBeenCalledOnce(); + expect(h.bumpAttempt).toHaveBeenCalledWith("orphan-route-1"); + expect(h.deleteOrphan).not.toHaveBeenCalled(); + expect(errorLog).toHaveBeenCalledWith( + "[orphan-gc] route app.example.com failed:", + "strict edge scan failed", + ); + + errorLog.mockRestore(); + }); + + it("defers while the originating project row still exists", async () => { + h.orphans = [routeOrphan()]; + h.findProject.mockResolvedValueOnce({ id: "project-1", deletionInProgress: true }); + + await expect(runOrphanSweep()).resolves.toEqual({ reclaimed: 0, deferred: 1 }); + + expect(h.bumpAttempt).toHaveBeenCalledWith("orphan-route-1"); + expect(h.resolveDeploymentPlatform).not.toHaveBeenCalled(); + expect(h.removeRoute).not.toHaveBeenCalled(); + expect(h.convergeClaims).not.toHaveBeenCalled(); + expect(h.deleteOrphan).not.toHaveBeenCalled(); + }); + + it("keeps the route orphan while another vhost still protects a project claim", async () => { + h.orphans = [routeOrphan()]; + h.convergeClaims.mockResolvedValueOnce({ + released: 0, + retained: [{ port: 23_000 }], + }); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(runOrphanSweep()).resolves.toEqual({ reclaimed: 0, deferred: 1 }); + + expect(h.bumpAttempt).toHaveBeenCalledWith("orphan-route-1"); + expect(h.deleteOrphan).not.toHaveBeenCalled(); + expect(errorLog).toHaveBeenCalledWith( + "[orphan-gc] route app.example.com failed:", + expect.stringContaining("23000"), + ); + + errorLog.mockRestore(); + }); + + it("does not remove a route or release claims while a same-target workload cleanup failed", async () => { + h.orphans = [ + routeOrphan({ + id: "orphan-container-1", + resourceType: "container", + ref: "container-1", + label: "container 1", + }), + routeOrphan(), + ]; + h.destroy.mockRejectedValueOnce(new Error("container destroy failed")); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(runOrphanSweep()).resolves.toEqual({ reclaimed: 0, deferred: 2 }); + + expect(h.bumpAttempt).toHaveBeenCalledWith("orphan-container-1"); + expect(h.bumpAttempt).toHaveBeenCalledWith("orphan-route-1"); + expect(h.removeRoute).not.toHaveBeenCalled(); + expect(h.convergeClaims).not.toHaveBeenCalled(); + expect(h.deleteOrphan).not.toHaveBeenCalled(); + + errorLog.mockRestore(); + }); +}); + +describe("runOrphanSweep target selection", () => { + it("resolves a null-server docker orphan as local self-hosted, never cloud", async () => { + h.orphans = [ + routeOrphan({ + id: "orphan-container-1", + serverId: null, + resourceType: "container", + ref: "container-1", + projectId: null, + }), + ]; + h.resolveDeploymentPlatform.mockResolvedValue( + resolvedPlatform({ + effectiveTarget: "local", + serverId: null, + hostPortTarget: { targetKey: "local", legacyTargetKeys: [], stable: true }, + platform: { + ...resolvedPlatform().platform, + localHost: true, + }, + }), + ); + + await expect(runOrphanSweep()).resolves.toEqual({ reclaimed: 1, deferred: 0 }); + + expect(h.resolveDeploymentPlatform).toHaveBeenCalledOnce(); + expect(h.resolveDeploymentPlatform).toHaveBeenCalledWith( + { deployTarget: "local", runtimeMode: "docker" }, + { organizationId: "org-1" }, + ); + expect(h.isReachable).not.toHaveBeenCalled(); + expect(h.destroy).toHaveBeenCalledWith("container-1"); + expect(h.releaseManagedHostnames).not.toHaveBeenCalled(); + expect(h.deleteOrphan).toHaveBeenCalledWith("orphan-container-1"); + }); +}); diff --git a/apps/api/src/modules/projects/orphan-gc-schedule.ts b/apps/api/src/modules/projects/orphan-gc-schedule.ts index b5b051e4e..e958c1f90 100644 --- a/apps/api/src/modules/projects/orphan-gc-schedule.ts +++ b/apps/api/src/modules/projects/orphan-gc-schedule.ts @@ -16,21 +16,21 @@ import { repos, type OrphanedResource } from "@repo/db"; import { DockerRuntime, + edgeProxyFor, isRuntimeNotFoundError, + ownsBuiltImage, type Platform, - type RuntimeAdapter, } from "@repo/adapters"; import { safeErrorMessage } from "@repo/core"; import { createReachabilityProbe } from "../../lib/server-reachability"; import { isConnectionLoss } from "../../lib/remote-state"; -import { - disposePlatform, - resolveTargetPlatform, - resolveDeploymentPlatform, -} from "../../lib/deployment-runtime"; +import { disposePlatform, resolveDeploymentPlatform } from "../../lib/deployment-runtime"; +import { convergeTargetHostPortClaims } from "../deployments/pinned-host-ports"; +import { releaseManagedHostnames } from "../../lib/managed-edge-proxy"; /** Destroy one orphaned resource via the right adapter op; not-found = done. */ -async function destroyOrphanResource(runtime: RuntimeAdapter, o: OrphanedResource): Promise { +async function destroyOrphanResource(platform: Platform, o: OrphanedResource): Promise { + const runtime = platform.runtime; try { switch (o.resourceType) { case "container": @@ -39,7 +39,12 @@ async function destroyOrphanResource(runtime: RuntimeAdapter, o: OrphanedResourc await runtime.destroy(o.ref); return; case "image": - if (runtime instanceof DockerRuntime) await runtime.removeImage(o.ref); + // Old releases may have recorded pulled/adopted images as orphans + // before manifest collection enforced ownership. Never turn that stale + // bookkeeping into permission to untag shared registry cache. + if (runtime instanceof DockerRuntime && ownsBuiltImage(o.ref)) { + await runtime.removeImage(o.ref); + } return; case "volume": if (runtime instanceof DockerRuntime) await runtime.removeVolume(o.ref); @@ -47,6 +52,9 @@ async function destroyOrphanResource(runtime: RuntimeAdapter, o: OrphanedResourc case "network": if (runtime instanceof DockerRuntime) await runtime.removeNetwork(o.ref); return; + case "route": + await platform.routing.removeRoute(o.ref); + return; default: return; } @@ -67,7 +75,10 @@ async function reclaimOrphan( probe: ReturnType, ): Promise { // Cloud resource: no TCP notion — resolve the cloud runtime for the org. - if (o.runtimeMode === "cloud" || !o.serverId) { + // A null server id with docker/bare mode is the local self-hosted target, not + // cloud; older code conflated the two and silently "reclaimed" local orphans + // through a cloud adapter that never touched the host. + if (o.runtimeMode === "cloud" || (!o.serverId && !o.runtimeMode)) { let cloudPlatform: Platform | null = null; try { const { platform } = await resolveDeploymentPlatform( @@ -76,7 +87,15 @@ async function reclaimOrphan( ); cloudPlatform = platform; if (platform.runtime.name !== "cloud") return false; - await destroyOrphanResource(platform.runtime, o); + await destroyOrphanResource(platform, o); + if (o.resourceType === "route") { + const { failures } = await releaseManagedHostnames([o.ref], { + organizationId: o.organizationId, + }); + if (failures.length > 0) { + throw new Error(`Cloud edge route not released: ${failures.join(", ")}`); + } + } return true; } catch (err) { // Cloud API unreachable → defer; anything else is a real failure. @@ -87,23 +106,51 @@ async function reclaimOrphan( } } - // Server-backed: fast-fail if the host still isn't answering. - if (!(await probe.isReachable(o.serverId))) return false; + // Server-backed: fast-fail if the remote host still isn't answering. A local + // orphan has no server row to probe and resolves through this process's host + // target directly. + if (o.serverId && !(await probe.isReachable(o.serverId))) return false; // A docker-mode server platform binds a Docker-over-SSH bridge, and this runs // per orphan on a SCHEDULE — releasing it is what keeps a recurring sweep from // accumulating one loopback listener per reclaim, forever. - const platform = await resolveTargetPlatform( - "server", - o.runtimeMode === "bare" ? "bare" : "docker", - o.serverId, - o.organizationId, + const resolved = await resolveDeploymentPlatform( + { + deployTarget: o.serverId ? "server" : "local", + runtimeMode: o.runtimeMode === "bare" ? "bare" : "docker", + ...(o.serverId ? { serverId: o.serverId } : {}), + }, + { organizationId: o.organizationId }, ); try { - await destroyOrphanResource(platform.runtime, o); + await destroyOrphanResource(resolved.platform, o); + if ( + o.resourceType === "route" && + o.projectId && + resolved.hostPortTarget && + resolved.platform.executor + ) { + // The route is gone; only a fresh dump from this same physical target may + // release its detached project claims. A failed dump throws, keeping the + // orphan row for a later retry and every claim intact. + const converged = await convergeTargetHostPortClaims({ + target: resolved.hostPortTarget, + projectId: o.projectId, + desiredPublishes: [], + edgeProxy: edgeProxyFor(resolved.platform.executor, "openresty", { ours: true }), + }); + if (converged.retained.length > 0) { + const ports = [...new Set(converged.retained.map((claim) => claim.port))].sort( + (a, b) => a - b, + ); + throw new Error( + `the target edge still references this project's host port(s): ${ports.join(", ")}`, + ); + } + } return true; } finally { - disposePlatform(platform); + disposePlatform(resolved); } } @@ -114,11 +161,59 @@ export async function runOrphanSweep(): Promise<{ reclaimed: number; deferred: n const probe = createReachabilityProbe(); let reclaimed = 0; let deferred = 0; + const targetGroupKey = (orphan: OrphanedResource): string | null => + orphan.projectId + ? [ + orphan.organizationId, + orphan.projectId, + orphan.serverId + ? `server:${orphan.serverId}` + : orphan.runtimeMode === "cloud" || !orphan.runtimeMode + ? "cloud" + : "local", + ].join("\0") + : null; + // A route disappearance proves only that the edge stopped dialling a port; it + // cannot prove a failed/stopped workload surrendered the bind. Hold route + // cleanup—and therefore claim convergence—until every non-route orphan for the + // same project/target has been reclaimed. Counts are decremented only after the + // orphan row itself is deleted successfully. + const pendingResourcesByTarget = new Map(); + for (const orphan of orphans) { + const key = targetGroupKey(orphan); + if (!key || orphan.resourceType === "route") continue; + pendingResourcesByTarget.set(key, (pendingResourcesByTarget.get(key) ?? 0) + 1); + } for (const o of orphans) { try { + // Orphan rows are written just before the originating project is hard + // deleted. If a later unlink/delete step failed—or this sweep races that + // narrow window—the project still exists and remains authoritative. Never + // let GC tear down its workload/routes or release its claims. + if (o.projectId && (await repos.project.findById(o.projectId))) { + await repos.orphanedResource.bumpAttempt(o.id); + deferred++; + continue; + } + const groupKey = targetGroupKey(o); + if ( + o.resourceType === "route" && + groupKey && + (pendingResourcesByTarget.get(groupKey) ?? 0) > 0 + ) { + await repos.orphanedResource.bumpAttempt(o.id); + deferred++; + continue; + } if (await reclaimOrphan(o, probe)) { await repos.orphanedResource.delete(o.id); + if (groupKey && o.resourceType !== "route") { + pendingResourcesByTarget.set( + groupKey, + Math.max(0, (pendingResourcesByTarget.get(groupKey) ?? 1) - 1), + ); + } reclaimed++; } else { await repos.orphanedResource.bumpAttempt(o.id); diff --git a/apps/api/src/modules/projects/project-cleanup.ref-shape.test.ts b/apps/api/src/modules/projects/project-cleanup.ref-shape.test.ts index f17d4cf51..8898a91a1 100644 --- a/apps/api/src/modules/projects/project-cleanup.ref-shape.test.ts +++ b/apps/api/src/modules/projects/project-cleanup.ref-shape.test.ts @@ -37,27 +37,41 @@ const h = vi.hoisted(() => ({ deployments: [] as Record[], /** deployment id → its service_deployment rows. */ serviceRows: {} as Record[]>, + projectServices: [] as Record[], + domains: [] as Record[], + derivedServiceRoutes: [] as Array<{ hostname: string }>, + resolvedByDeployment: {} as Record>, + resolveErrors: {} as Record, keep: { images: new Set(), containers: new Set() }, removeRoute: vi.fn(async () => {}), + convergeClaims: vi.fn(async () => ({ + released: 0, + retained: [] as Array<{ port: number }>, + })), + isReachable: vi.fn(async () => true), + getServer: vi.fn(async () => null as Record | null), })); vi.mock("@repo/db", () => ({ repos: { service: { - // Empty on purpose: a non-empty project service list pulls in the - // adopted-container host sweep and the service-route block, neither of - // which classifies a ref. - listByProject: vi.fn(async () => []), + listByProject: vi.fn(async () => h.projectServices), listByDeployment: vi.fn(async (depId: string) => h.serviceRows[depId] ?? []), }, deployment: { listByProject: vi.fn(async () => ({ rows: h.deployments })) }, - domain: { listByProject: vi.fn(async () => []) }, - server: { getInOrganization: vi.fn(async () => null) }, + domain: { listByProject: vi.fn(async () => h.domains) }, + server: { getInOrganization: h.getServer }, }, })); vi.mock("../../lib/deployment-runtime", () => ({ - resolveDeploymentRuntime: vi.fn(async () => ({ runtime: h.runtime })), + resolveDeploymentRuntime: vi.fn(async (dep: { id: string }) => + h.resolveErrors[dep.id] + ? Promise.reject(h.resolveErrors[dep.id]) + : h.resolvedByDeployment[dep.id] + ? { runtime: h.runtime, ...h.resolvedByDeployment[dep.id] } + : { runtime: h.runtime }, + ), disposeRuntime: vi.fn(), resolveDeploymentPlatform: vi.fn(async () => { throw new Error("no cloud workspace in these fixtures"); @@ -71,15 +85,20 @@ vi.mock("../../lib/controller-helpers", () => ({ })); vi.mock("./cleanup-keep-set", () => ({ computeCleanupKeepSet: vi.fn(async () => h.keep) })); -vi.mock("../../lib/routing-domains", () => ({ buildServiceRouteDomain: () => null })); +vi.mock("../../lib/routing-domains", () => ({ + buildServiceRouteDomains: () => h.derivedServiceRoutes, +})); vi.mock("../../lib/managed-edge-proxy", () => ({ releaseManagedHostnames: vi.fn(async () => ({ failures: [] as string[] })), })); vi.mock("../../lib/server-reachability", () => ({ - createReachabilityProbe: () => ({ isReachable: async () => true }), + createReachabilityProbe: () => ({ isReachable: h.isReachable }), })); vi.mock("../../lib/cloud/transport", () => ({ resolveOrgCloudUserId: vi.fn(async () => null) })); vi.mock("../services/live-state", () => ({ resolveLiveServiceState: () => new Map() })); +vi.mock("../deployments/pinned-host-ports", () => ({ + convergeTargetHostPortClaims: h.convergeClaims, +})); import { DockerRuntime } from "@repo/adapters"; import { @@ -95,6 +114,8 @@ const STATIC_BUILD_DIR = "/opt/openship/static/.builds/bld_1-svc_1"; const STATIC_RELEASE_DIR = "/opt/openship/static/releases/dep_1"; /** A tag: slashes inside, never a LEADING one — which is exactly the test. */ const IMAGE_TAG = "openship/app-web:bld_1"; +/** Pulled registry content belongs to the registry/daemon cache, not one deployment. */ +const FOREIGN_IMAGE = `ghcr.io/acme/release@sha256:${"a".repeat(64)}`; const project = { id: "p1", @@ -143,7 +164,14 @@ beforeEach(() => { vi.clearAllMocks(); h.deployments = []; h.serviceRows = {}; + h.projectServices = []; + h.domains = []; + h.derivedServiceRoutes = []; + h.resolvedByDeployment = {}; + h.resolveErrors = {}; h.keep = { images: new Set(), containers: new Set() }; + h.isReachable.mockResolvedValue(true); + h.getServer.mockResolvedValue(null); }); describe("collectProjectManifest — a ref is classified by its shape", () => { @@ -170,6 +198,16 @@ describe("collectProjectManifest — a ref is classified by its shape", () => { expect(typesOf(manifest, STATIC_BUILD_DIR)).toEqual(["artifact"]); }); + it("never claims pulled registry images as project-owned cleanup resources", async () => { + h.deployments = [deployment({ imageRef: FOREIGN_IMAGE })]; + h.serviceRows.dep_1 = [serviceRow({ imageRef: "postgres:17" })]; + + const manifest = await collectProjectManifest(project as never); + + expect(typesOf(manifest, FOREIGN_IMAGE)).toEqual([]); + expect(typesOf(manifest, "postgres:17")).toEqual([]); + }); + it("the deployment's OWN image_ref is an artifact when it holds a directory", async () => { const singleAppOutput = "/opt/openship/static/.builds/bld_2"; h.deployments = [deployment({ imageRef: singleAppOutput })]; @@ -205,6 +243,88 @@ describe("collectProjectManifest — a ref is classified by its shape", () => { expect(docker.inspectNamedVolumes).toHaveBeenCalledWith("9f1c2b3a4d5e"); expect(typesOf(manifest, STATIC_RELEASE_DIR)).toEqual(["artifact"]); }); + + it("keeps identical artifact refs distinct across historical server targets", async () => { + h.deployments = [ + deployment({ id: "dep_a", imageRef: STATIC_BUILD_DIR }), + deployment({ id: "dep_b", imageRef: STATIC_BUILD_DIR }), + ]; + h.resolvedByDeployment = { + dep_a: { + serverId: "server-a", + hostPortTarget: { + targetKey: `host:${"a".repeat(64)}`, + legacyTargetKeys: [], + stable: true, + }, + executor: null, + }, + dep_b: { + serverId: "server-b", + hostPortTarget: { + targetKey: `host:${"b".repeat(64)}`, + legacyTargetKeys: [], + stable: true, + }, + executor: null, + }, + }; + + const manifest = await collectProjectManifest(project as never); + const artifacts = manifest.resources.filter( + (resource) => resource.type === "artifact" && resource.ref === STATIC_BUILD_DIR, + ); + + expect(artifacts).toHaveLength(2); + expect(artifacts.map((resource) => resource.serverId).sort()).toEqual(["server-a", "server-b"]); + expect(artifacts.every((resource) => resource.runtimeMode === "docker")).toBe(true); + }); + + it("carries an unreachable historical server as a deferred route target", async () => { + h.deployments = [ + deployment({ + id: "dep_remote", + containerId: "remote-container", + meta: { serverId: "server-remote", runtimeMode: "bare" }, + }), + ]; + h.isReachable.mockResolvedValueOnce(false); + h.getServer.mockResolvedValueOnce({ id: "server-remote" }); + + const manifest = await collectProjectManifest(project as never); + + expect(manifest.unreachableRouteTargets).toEqual([ + { serverId: "server-remote", runtimeMode: "bare" }, + ]); + expect(manifest.resources).toContainEqual( + expect.objectContaining({ + type: "unreachable", + ref: "remote-container", + serverId: "server-remote", + runtimeMode: "bare", + }), + ); + }); + + it("fails closed when a local deployment with known resources cannot resolve", async () => { + h.deployments = [deployment({ id: "dep_local", containerId: "local-container" })]; + h.resolveErrors.dep_local = new Error("local runtime unavailable"); + + await expect(collectProjectManifest(project as never)).rejects.toThrow( + "Could not resolve cleanup target for deployment dep_local", + ); + }); + + it("deduplicates stored and derived service routes while keeping every endpoint", async () => { + h.projectServices = [{ id: "svc_1", name: "web" }]; + h.domains = [{ hostname: "WEB.EXAMPLE.COM" }]; + h.derivedServiceRoutes = [{ hostname: "web.example.com" }, { hostname: "api.example.com" }]; + + const manifest = await collectProjectManifest(project as never); + const routes = manifest.resources.filter((resource) => resource.type === "route"); + + expect(routes.map((route) => route.ref).sort()).toEqual(["api.example.com", "web.example.com"]); + }); }); describe("executeCleanup — the resource type selects the verb", () => { @@ -226,6 +346,154 @@ describe("executeCleanup — the resource type selects the verb", () => { expect(docker.destroy).toHaveBeenCalledTimes(1); expect(docker.destroy).toHaveBeenCalledWith(STATIC_BUILD_DIR); }); + + it("removes a route from every resolved physical deployment target", async () => { + const first = vi.fn(async () => {}); + const second = vi.fn(async () => {}); + const manifest: CleanupManifest = { + projectId: "p1", + organizationId: "org1", + resources: [ + { type: "route", ref: "app.example.com", label: "route app.example.com", runtime: null }, + ], + routeContexts: [ + { + key: "host:a", + routing: { removeRoute: first } as never, + hostPortTarget: { + targetKey: `host:${"a".repeat(64)}`, + legacyTargetKeys: [], + stable: true, + }, + serverId: "srv-a", + runtimeMode: "docker", + edgeProxy: { listLoopbackUpstreamPortsStrict: vi.fn(async () => new Set()) }, + }, + { + key: "host:b", + routing: { removeRoute: second } as never, + hostPortTarget: { + targetKey: `host:${"b".repeat(64)}`, + legacyTargetKeys: [], + stable: true, + }, + serverId: "srv-b", + runtimeMode: "docker", + edgeProxy: { listLoopbackUpstreamPortsStrict: vi.fn(async () => new Set()) }, + }, + ], + }; + + const result = await executeCleanup(manifest); + + expect(result.failed).toEqual([]); + expect(first).toHaveBeenCalledWith("app.example.com"); + expect(second).toHaveBeenCalledWith("app.example.com"); + expect(h.convergeClaims).toHaveBeenCalledTimes(2); + expect(h.convergeClaims).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + target: expect.objectContaining({ targetKey: `host:${"a".repeat(64)}` }), + projectId: "p1", + desiredPublishes: [], + }), + ); + expect(h.convergeClaims).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + target: expect.objectContaining({ targetKey: `host:${"b".repeat(64)}` }), + projectId: "p1", + desiredPublishes: [], + }), + ); + }); + + it("keeps cleanup incomplete while the target edge still protects a project claim", async () => { + h.convergeClaims.mockResolvedValueOnce({ + released: 0, + retained: [{ port: 20_123 }], + }); + const manifest: CleanupManifest = { + projectId: "p1", + resources: [], + routeContexts: [ + { + key: "local", + routing: { removeRoute: vi.fn(async () => {}) } as never, + hostPortTarget: { targetKey: "local", legacyTargetKeys: [], stable: true }, + serverId: null, + runtimeMode: "docker", + edgeProxy: { listLoopbackUpstreamPortsStrict: vi.fn(async () => new Set()) }, + }, + ], + }; + + const result = await executeCleanup(manifest); + + expect(result.failed).toEqual([ + expect.objectContaining({ + ref: "local", + type: "route", + error: expect.stringContaining("20123"), + }), + ]); + }); + + it("never releases claims after any workload or route cleanup failure", async () => { + vi.useFakeTimers(); + try { + h.convergeClaims.mockClear(); + const removeRoute = vi.fn(async () => { + throw new Error("remote edge reload failed"); + }); + const manifest: CleanupManifest = { + projectId: "p1", + resources: [ + { type: "route", ref: "app.example.com", label: "route app.example.com", runtime: null }, + ], + routeContexts: [ + { + key: "local", + routing: { removeRoute } as never, + hostPortTarget: { targetKey: "local", legacyTargetKeys: [], stable: true }, + serverId: null, + runtimeMode: "docker", + edgeProxy: { listLoopbackUpstreamPortsStrict: vi.fn(async () => new Set()) }, + }, + ], + }; + + const pending = executeCleanup(manifest); + await vi.runAllTimersAsync(); + const result = await pending; + + expect(result.failed).toEqual([ + expect.objectContaining({ ref: "app.example.com", type: "route" }), + ]); + expect(removeRoute).toHaveBeenCalledTimes(2); + expect(h.convergeClaims).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("does not misdirect an unreachable remote route removal to the process edge", async () => { + const manifest: CleanupManifest = { + projectId: "p1", + organizationId: "org1", + resources: [ + { type: "route", ref: "app.example.com", label: "route app.example.com", runtime: null }, + ], + routeContexts: [], + unreachableRouteTargets: [{ serverId: "server-remote", runtimeMode: "docker" }], + }; + + const result = await executeCleanup(manifest); + + expect(result.failed).toEqual([]); + expect(h.removeRoute).not.toHaveBeenCalled(); + expect(h.convergeClaims).not.toHaveBeenCalled(); + }); }); describe("collectDeploymentManifest — protectRetained covers directories too", () => { @@ -246,4 +514,14 @@ describe("collectDeploymentManifest — protectRetained covers directories too", expect(typesOf(manifest, liveDocRoot)).toEqual([]); expect(typesOf(manifest, STATIC_BUILD_DIR)).toEqual(["artifact"]); }); + + it("never schedules a pulled release image for removal", async () => { + const manifest = await collectDeploymentManifest( + deployment({ id: "dep_release", imageRef: FOREIGN_IMAGE }) as never, + project as never, + { protectRetained: false }, + ); + + expect(typesOf(manifest, FOREIGN_IMAGE)).toEqual([]); + }); }); diff --git a/apps/api/src/modules/projects/project-cleanup.service.ts b/apps/api/src/modules/projects/project-cleanup.service.ts index 8bb337a32..023b40dab 100644 --- a/apps/api/src/modules/projects/project-cleanup.service.ts +++ b/apps/api/src/modules/projects/project-cleanup.service.ts @@ -12,7 +12,14 @@ */ import { repos, type Project, type Deployment } from "@repo/db"; -import { DockerRuntime, type RuntimeAdapter } from "@repo/adapters"; +import { + DockerRuntime, + edgeProxyFor, + ownsBuiltImage, + type EdgeProxyApi, + type RoutingProvider, + type RuntimeAdapter, +} from "@repo/adapters"; import { safeErrorMessage } from "@repo/core"; import { platform } from "../../lib/controller-helpers"; import { @@ -24,10 +31,12 @@ import { import { resolveOrgCloudUserId } from "../../lib/cloud/transport"; import { isArtifactRef } from "../../lib/container-ref"; import { computeCleanupKeepSet } from "./cleanup-keep-set"; -import { buildServiceRouteDomain } from "../../lib/routing-domains"; +import { buildServiceRouteDomains } from "../../lib/routing-domains"; import { releaseManagedHostnames } from "../../lib/managed-edge-proxy"; import { createReachabilityProbe } from "../../lib/server-reachability"; import { resolveLiveServiceState, type LiveMatchKind } from "../services/live-state"; +import type { HostPortTargetIdentity } from "../../lib/host-port-target"; +import { convergeTargetHostPortClaims } from "../deployments/pinned-host-ports"; /** Identity keys a DELETE may act on: each one proves the container is this * project's. Deliberately excludes `compose` (see ResolveLiveStateInput.tiers). */ @@ -65,11 +74,12 @@ export interface CleanupResource { label: string; /** The runtime to use for destroy/removeImage/removeVolume - null for routes. */ runtime: RuntimeAdapter | null; - /** Server the resource lives on (set on `unreachable` items) — carried into - * the orphaned_resource row so GC can probe + reclaim it later. */ - serverId?: string; + /** Server the resource lives on. Null means the local host or cloud, as + * distinguished by `runtimeMode`. Carried into an orphan row verbatim so a + * multi-target teardown never retries an old resource on the newest server. */ + serverId?: string | null; /** Runtime mode (docker | bare | cloud) for the orphaned_resource row so GC - * resolves the right adapter. Set on `unreachable` items. */ + * resolves the right adapter. */ runtimeMode?: string; } @@ -83,6 +93,40 @@ export interface CleanupManifest { */ organizationId?: string; resources: CleanupResource[]; + /** Every per-deployment runtime opened while collecting this manifest. */ + runtimes?: RuntimeAdapter[]; + /** + * Every reachable physical target on which this project's deployments may + * have written a vhost. Route teardown must use these target-bound providers, + * never the API process's global edge, or a migrated/remote route survives + * deletion while its database row disappears. + */ + routeContexts?: CleanupRouteContext[]; + /** + * Historical targets that are known to exist but could not be reached while + * collecting the manifest. They deliberately carry no live adapter. Teardown + * records one route orphan per hostname/target so GC can remove the remote + * vhost and converge its detached claims when the server returns. + */ + unreachableRouteTargets?: CleanupUnreachableRouteTarget[]; +} + +export interface CleanupUnreachableRouteTarget { + /** Server-row identity GC can resolve once reachability returns. */ + serverId: string; + runtimeMode: "docker" | "bare"; +} + +export interface CleanupRouteContext { + /** Stable physical collision-domain key; also deduplicates server-row aliases. */ + key: string; + routing: RoutingProvider; + hostPortTarget: HostPortTargetIdentity; + /** Concrete target locator used if force-orphan GC must retry this vhost. */ + serverId: string | null; + runtimeMode: "docker" | "bare"; + /** Strict post-mutation inventory used before durable claims are reclaimed. */ + edgeProxy: Pick; } /** Per-service summary of what will be removed when this service's container is destroyed. */ @@ -136,7 +180,50 @@ export async function collectProjectManifest( const services = await repos.service.listByProject(project.id).catch(() => []); const seenContainers = new Set(); const seenVolumes = new Set(); + const seenRouteHostnames = new Set(); const dockerRuntimes = new Set(); + const resolvedRuntimes = new Set(); + const routeContexts = new Map(); + const unreachableRouteTargets = new Map(); + type CollectedTarget = { + key: string; + serverId: string | null; + runtimeMode: "docker" | "bare" | "cloud"; + }; + const runtimeTargets = new Map(); + const resourceKey = (target: CollectedTarget, ref: string) => `${target.key}\0${ref}`; + const targetFields = (target: CollectedTarget) => ({ + serverId: target.serverId, + runtimeMode: target.runtimeMode, + }); + const pushRoute = (hostname: string, label: string) => { + const normalized = hostname.trim().toLowerCase(); + if (!normalized || seenRouteHostnames.has(normalized)) return; + seenRouteHostnames.add(normalized); + resources.push({ type: "route", ref: normalized, label, runtime: null }); + }; + const targetForResolved = ( + resolved: Awaited>, + dep: Deployment, + ): CollectedTarget => { + const runtimeMode = + resolved.runtime.name === "cloud" + ? "cloud" + : resolved.runtime.name === "bare" + ? "bare" + : "docker"; + return { + key: + resolved.hostPortTarget?.targetKey ?? + (resolved.serverId + ? `server:${resolved.serverId}` + : runtimeMode === "cloud" + ? `cloud:${dep.containerId ?? ((dep.meta ?? {}) as DeploymentMeta).workspaceId ?? dep.id}` + : `local:${runtimeMode}`), + serverId: resolved.serverId, + runtimeMode, + }; + }; // Op-scoped reachability memo (single source: sshManager). Lets us fast-fail // an unreachable server in ~2.5s instead of hanging on SSH connect timeouts. const reachProbe = createReachabilityProbe(); @@ -147,8 +234,9 @@ export async function collectProjectManifest( runtimeMode: string | undefined, labelPrefix: string, ) => { - if (seenContainers.has(containerId)) return; - seenContainers.add(containerId); + const key = `server:${serverId}\0${containerId}`; + if (seenContainers.has(key)) return; + seenContainers.add(key); resources.push({ type: "unreachable", ref: containerId, @@ -159,9 +247,15 @@ export async function collectProjectManifest( }); }; - const pushContainer = (containerId: string, runtime: RuntimeAdapter, labelPrefix: string) => { - if (seenContainers.has(containerId)) return; - seenContainers.add(containerId); + const pushContainer = ( + containerId: string, + runtime: RuntimeAdapter, + labelPrefix: string, + target: CollectedTarget, + ) => { + const key = resourceKey(target, containerId); + if (seenContainers.has(key)) return; + seenContainers.add(key); // A static deploy's containerId IS its release directory. Both branches of // destroyResourceOnce call runtime.destroy, so this reclassification is // behaviour-preserving — but it makes the manifest TYPE match the thing, which @@ -174,6 +268,7 @@ export async function collectProjectManifest( ref: containerId, label: `${labelPrefix} files ${containerId}`, runtime, + ...targetFields(target), }); return; } @@ -182,6 +277,7 @@ export async function collectProjectManifest( ref: containerId, label: `${labelPrefix} ${containerId.slice(0, 12)}`, runtime, + ...targetFields(target), }); }; @@ -192,6 +288,7 @@ export async function collectProjectManifest( containerId: string, runtime: RuntimeAdapter, labelPrefix: string, + target: CollectedTarget, ) => { if (!wipeVolumes || !(runtime instanceof DockerRuntime)) return; // A release directory cannot have mounts. Inspecting it is guaranteed to fail @@ -204,13 +301,15 @@ export async function collectProjectManifest( `inspect volumes ${labelPrefix}`, ).catch(() => [] as string[]); for (const name of names) { - if (seenVolumes.has(name)) continue; - seenVolumes.add(name); + const key = resourceKey(target, name); + if (seenVolumes.has(key)) continue; + seenVolumes.add(key); resources.push({ type: "volume", ref: name, label: `${labelPrefix} volume ${name}`, runtime, + ...targetFields(target), }); } }; @@ -230,15 +329,22 @@ export async function collectProjectManifest( * compose static sub-app: those rows carry no containerId, so `pushContainer` * never runs for them and this is the single entry their doc-root ever gets. */ - const pushImageOrArtifact = (ref: string, runtime: RuntimeAdapter, tagLabel: string) => { - if (seenImages.has(ref)) return; - seenImages.add(ref); + const pushImageOrArtifact = ( + ref: string, + runtime: RuntimeAdapter, + tagLabel: string, + target: CollectedTarget, + ) => { + const key = resourceKey(target, ref); + if (seenImages.has(key)) return; + seenImages.add(key); if (isArtifactRef(ref)) { resources.push({ type: "artifact", ref, label: `static output ${ref}`, runtime, + ...targetFields(target), }); return; } @@ -248,7 +354,12 @@ export async function collectProjectManifest( console.warn(`[cleanup] skipping image ${ref}: ${runtime.name} runtime cannot remove images`); return; } - resources.push({ type: "image", ref, label: tagLabel, runtime }); + // Registry/adopted images are shared daemon cache, not deployment-owned + // artifacts. This is the same structural ownership rule Docker purge and + // image GC use; project deletion must not turn a pulled release image (or a + // service image such as postgres:17) into something Openship may untag. + if (!ownsBuiltImage(ref)) return; + resources.push({ type: "image", ref, label: tagLabel, runtime, ...targetFields(target) }); }; for (const dep of allDeps) { @@ -265,12 +376,15 @@ export async function collectProjectManifest( await repos.server.getInOrganization(serverId, dep.organizationId).catch(() => null), ); if (serverStillExists) { - const mode = meta.runtimeMode; - const serviceRows = await repos.service.listByDeployment(dep.id).catch(() => []); + const mode = meta.runtimeMode === "bare" ? "bare" : "docker"; + unreachableRouteTargets.set(serverId, { serverId, runtimeMode: mode }); + const serviceRows = await repos.service.listByDeployment(dep.id); for (const sd of serviceRows) { - if (sd.containerId) pushUnreachable(sd.containerId, serverId, mode, "service container"); + if (sd.containerId) + pushUnreachable(sd.containerId, serverId, mode, "service container"); } - if (dep.containerId) pushUnreachable(dep.containerId, serverId, mode, "deployment container"); + if (dep.containerId) + pushUnreachable(dep.containerId, serverId, mode, "deployment container"); } else { console.warn( `[cleanup] skipping deployment ${dep.id} — server ${serverId} removed from org`, @@ -281,8 +395,28 @@ export async function collectProjectManifest( } let runtime: RuntimeAdapter; + let resourceTarget: CollectedTarget; try { - ({ runtime } = await resolveDeploymentRuntime(dep)); + const resolved = await resolveDeploymentRuntime(dep); + runtime = resolved.runtime; + resourceTarget = targetForResolved(resolved, dep); + runtimeTargets.set(runtime, resourceTarget); + resolvedRuntimes.add(runtime); + if (resolved.hostPortTarget && resolved.executor) { + const key = resolved.hostPortTarget.targetKey; + if (!routeContexts.has(key)) { + routeContexts.set(key, { + key, + routing: resolved.routing, + hostPortTarget: resolved.hostPortTarget, + // The local collision namespace is resolved locally during GC even + // when it originally arrived through a "This Server" row. + serverId: key === "local" ? null : resolved.serverId, + runtimeMode: runtime.name === "bare" ? "bare" : "docker", + edgeProxy: edgeProxyFor(resolved.executor, "openresty", { ours: true }), + }); + } + } } catch (err) { // Couldn't resolve the runtime. Two very different cases: // • The target server was REMOVED from the org → its containers are @@ -299,13 +433,35 @@ export async function collectProjectManifest( .catch(() => null), ) : false; - if (dep.containerId && serverStillExists) { - resources.push({ - type: "unreachable", - ref: dep.containerId, - label: `deployment container ${dep.containerId.slice(0, 12)} (server unreachable)`, - runtime: null, - }); + if (serverStillExists && meta.serverId) { + const serverId = meta.serverId!; + const mode = meta.runtimeMode === "bare" ? "bare" : "docker"; + unreachableRouteTargets.set(serverId, { serverId, runtimeMode: mode }); + const serviceRows = await repos.service.listByDeployment(dep.id); + for (const sd of serviceRows) { + if (sd.containerId) pushUnreachable(sd.containerId, serverId, mode, "service container"); + } + if (dep.containerId) + pushUnreachable(dep.containerId, serverId, mode, "deployment container"); + } else if (!meta.serverId) { + // A local/cloud target has no removable server row that could explain + // the failure. Silently skipping its known refs would let teardown drop + // the only DB record for a workload/artifact we never even attempted to + // destroy. Fail manifest collection closed and let the caller retry. + const serviceRows = await repos.service.listByDeployment(dep.id); + const hasKnownResources = + !!dep.containerId || + !!dep.imageRef || + serviceRows.some((row) => !!row.containerId || !!row.imageRef); + if (hasKnownResources) { + for (const opened of resolvedRuntimes) disposeRuntime(opened); + throw new Error( + `Could not resolve cleanup target for deployment ${dep.id}: ${safeErrorMessage(err)}`, + ); + } + console.warn( + `[cleanup] skipping unresolvable empty deployment ${dep.id}: ${safeErrorMessage(err)}`, + ); } else { console.warn( `[cleanup] skipping unresolvable deployment ${dep.id} (server gone or never deployed): ${safeErrorMessage(err)}`, @@ -324,8 +480,8 @@ export async function collectProjectManifest( const serviceRows = await repos.service.listByDeployment(dep.id); for (const sd of serviceRows) { if (sd.containerId) { - await pushVolumesForContainer(sd.containerId, runtime, "service"); - pushContainer(sd.containerId, runtime, "service container"); + await pushVolumesForContainer(sd.containerId, runtime, "service", resourceTarget); + pushContainer(sd.containerId, runtime, "service container", resourceTarget); } // Per-service compose/monorepo images (openship/-:bld_…-svc_…) // OR, for a static sub-app, its doc-root DIRECTORY. These are the REAL @@ -334,20 +490,30 @@ export async function collectProjectManifest( // A static sub-app has no containerId, so this is the ONLY entry its // doc-root ever gets. if (sd.imageRef) { - pushImageOrArtifact(sd.imageRef, runtime, `service image ${sd.imageRef.slice(0, 24)}`); + pushImageOrArtifact( + sd.imageRef, + runtime, + `service image ${sd.imageRef.slice(0, 24)}`, + resourceTarget, + ); } } // Main deployment container - same order. if (dep.containerId) { - await pushVolumesForContainer(dep.containerId, runtime, "deployment"); - pushContainer(dep.containerId, runtime, "deployment container"); + await pushVolumesForContainer(dep.containerId, runtime, "deployment", resourceTarget); + pushContainer(dep.containerId, runtime, "deployment container", resourceTarget); } // The deployment's own image tag, or (single-app static) its extracted // build directory. Deduplicated across the manifest. if (dep.imageRef) { - pushImageOrArtifact(dep.imageRef, runtime, `image ${dep.imageRef.slice(0, 24)}`); + pushImageOrArtifact( + dep.imageRef, + runtime, + `image ${dep.imageRef.slice(0, 24)}`, + resourceTarget, + ); } } @@ -363,8 +529,16 @@ export async function collectProjectManifest( // from gaining a spurious local-host network resource. const sweepRuntimes = new Set(dockerRuntimes); const localRuntime = platform().runtime; - if (localRuntime instanceof DockerRuntime) sweepRuntimes.add(localRuntime); + if (localRuntime instanceof DockerRuntime) { + sweepRuntimes.add(localRuntime); + runtimeTargets.set(localRuntime, { key: "local", serverId: null, runtimeMode: "docker" }); + } for (const docker of sweepRuntimes) { + const target = runtimeTargets.get(docker); + if (!target) { + console.warn(`[cleanup] skipping unlocated runtime sweep for project ${project.id}`); + continue; + } if (!docker.supports("projectContainerSweep") || !docker.listProjectContainerIds) continue; const ids = await withTimeout( docker.listProjectContainerIds(project.id), @@ -374,8 +548,8 @@ export async function collectProjectManifest( for (const id of ids) { // Enumerate volumes BEFORE the container is destroyed (same reason as // the DB-tracked path) so a wipeVolumes teardown still sees the mounts. - await pushVolumesForContainer(id, docker, "orphan"); - pushContainer(id, docker, "orphan container"); + await pushVolumesForContainer(id, docker, "orphan", target); + pushContainer(id, docker, "orphan container", target); } } @@ -392,6 +566,8 @@ export async function collectProjectManifest( if (ownServices.length > 0) { const targets = ownServices.map((s) => ({ id: s.id, name: s.name })); for (const docker of sweepRuntimes) { + const target = runtimeTargets.get(docker); + if (!target) continue; if (!docker.supports("hostContainerQuery") || !docker.listAllContainers) continue; const containers = await withTimeout( docker.listAllContainers(), @@ -412,8 +588,8 @@ export async function collectProjectManifest( }); for (const match of matches.values()) { if (!match.containerId) continue; - await pushVolumesForContainer(match.containerId, docker, "adopted"); - pushContainer(match.containerId, docker, "adopted container"); + await pushVolumesForContainer(match.containerId, docker, "adopted", target); + pushContainer(match.containerId, docker, "adopted container", target); } } } @@ -426,6 +602,8 @@ export async function collectProjectManifest( // hard delete. Deduped via `seenImages`. Base/third-party images are PULLED // (unlabeled) so they can never be selected. Best-effort + bounded. for (const docker of sweepRuntimes) { + const target = runtimeTargets.get(docker); + if (!target) continue; const imgs = await withTimeout( docker.listProjectImages(project.id), INSPECT_TIMEOUT_MS, @@ -433,13 +611,16 @@ export async function collectProjectManifest( ).catch(() => [] as Awaited>); for (const img of imgs) { const ref = img.repoTags[0] ?? img.id; // readable tag if present, else id - if (seenImages.has(ref) || seenImages.has(img.id)) continue; - seenImages.add(ref); + const refKey = resourceKey(target, ref); + const idKey = resourceKey(target, img.id); + if (seenImages.has(refKey) || seenImages.has(idKey)) continue; + seenImages.add(refKey); resources.push({ type: "image", ref, label: `orphan image ${ref.slice(0, 24)}`, runtime: docker, + ...targetFields(target), }); } } @@ -453,7 +634,18 @@ export async function collectProjectManifest( // tears the workspace down on Oblien — fixes "deleted locally but still // live on Openship Cloud". De-duped against any deployment container that // already covers it. - if (project.cloudWorkspaceId && !seenContainers.has(project.cloudWorkspaceId)) { + const cloudWorkspaceTarget: CollectedTarget | null = project.cloudWorkspaceId + ? { + key: `cloud:${project.cloudWorkspaceId}`, + serverId: null, + runtimeMode: "cloud", + } + : null; + if ( + project.cloudWorkspaceId && + cloudWorkspaceTarget && + !seenContainers.has(resourceKey(cloudWorkspaceTarget, project.cloudWorkspaceId)) + ) { try { // BOUNDED: this resolution mints a cloud token (cloudFetch, no native // timeout). Without withTimeout a cloud-side hang would stall manifest @@ -470,12 +662,13 @@ export async function collectProjectManifest( // Guard against a non-cloud base resolving to local/server (a pure // self-hosted project never has a cloud workspace anyway). if (cloudPlatform.runtime.name === "cloud") { - seenContainers.add(project.cloudWorkspaceId); + seenContainers.add(resourceKey(cloudWorkspaceTarget, project.cloudWorkspaceId)); resources.push({ type: "cloud_workspace", ref: project.cloudWorkspaceId, label: `cloud workspace ${project.cloudWorkspaceId}`, runtime: cloudPlatform.runtime, + ...targetFields(cloudWorkspaceTarget), }); } else { // Don't silently drop it — an orphaned workspace should be visible. @@ -507,6 +700,7 @@ export async function collectProjectManifest( ref: project.cloudWorkspaceId, label: `cloud workspace ${project.cloudWorkspaceId} (cloud unreachable)`, runtime: null, + runtimeMode: "cloud", }); } } @@ -516,40 +710,36 @@ export async function collectProjectManifest( // One per docker runtime (Docker installs are per-machine), keyed off // project slug to match the `openship-` naming in DockerRuntime. for (const docker of dockerRuntimes) { + const target = runtimeTargets.get(docker); + if (!target) continue; resources.push({ type: "network", ref: project.slug, label: `network openship-${project.slug}`, runtime: docker, + ...targetFields(target), }); } // ── Domain routes (project-level) ────────────────────────────────── const domains = await repos.domain.listByProject(project.id).catch(() => []); for (const d of domains) { - resources.push({ - type: "route", - ref: d.hostname, - label: `route ${d.hostname}`, - runtime: null, // routes use routing adapter, not runtime - }); + pushRoute(d.hostname, `route ${d.hostname}`); } - // ── Service routes ───────────────────────────────────────────────── + // ── Service-route fallback ───────────────────────────────────────── + // Deployed routes normally have domain rows and were added above. Derive the + // configured routes as a legacy/crash fallback, then feed them through the + // same hostname set so one vhost is never deleted twice. for (const svc of services) { - const route = buildServiceRouteDomain({ + const routes = buildServiceRouteDomains({ project, service: svc, runtimeName: "bare", usesManagedRouting: true, }); - if (route) { - resources.push({ - type: "route", - ref: route.hostname, - label: `service route ${route.hostname}`, - runtime: null, - }); + for (const route of routes) { + pushRoute(route.hostname, `service route ${route.hostname}`); } } @@ -570,7 +760,14 @@ export async function collectProjectManifest( }; resources.sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]); - return { projectId: project.id, organizationId: project.organizationId, resources }; + return { + projectId: project.id, + organizationId: project.organizationId, + resources, + runtimes: [...resolvedRuntimes], + routeContexts: [...routeContexts.values()], + unreachableRouteTargets: [...unreachableRouteTargets.values()], + }; } /** @@ -597,7 +794,10 @@ export async function previewProjectDeletion(project: Project): Promise(); + const serviceContainerByServiceId = new Map< + string, + { containerId: string; runtime: RuntimeAdapter } + >(); for (const dep of allDeps) { let runtime: RuntimeAdapter; @@ -683,7 +883,8 @@ export async function previewProjectDeletion(project: Project): Promise n + s.volumes.length, 0); + const totalVolumes = + deploymentVolumes.length + previewServices.reduce((n, s) => n + s.volumes.length, 0); return { projectId: project.id, @@ -785,7 +986,12 @@ export async function collectDeploymentManifest( resources.push( isArtifactRef(containerId) ? { type: "artifact", ref: containerId, label: `files ${containerId}`, runtime } - : { type: "container", ref: containerId, label: `container ${containerId.slice(0, 12)}`, runtime }, + : { + type: "container", + ref: containerId, + label: `container ${containerId.slice(0, 12)}`, + runtime, + }, ); } @@ -810,6 +1016,7 @@ export async function collectDeploymentManifest( return; } if (!(runtime instanceof DockerRuntime)) return; + if (!ownsBuiltImage(ref)) return; resources.push({ type: "image", ref, label: tagLabel, runtime }); }; pushImageOrArtifact(dep.imageRef, `image ${(dep.imageRef ?? "").slice(0, 24)}`); @@ -842,10 +1049,7 @@ const DESTROY_TIMEOUT_MS = 30_000; function withTimeout(p: Promise, ms: number, label: string): Promise { let timer: ReturnType; const timeout = new Promise((_, reject) => { - timer = setTimeout( - () => reject(new Error(`cleanup timed out after ${ms}ms: ${label}`)), - ms, - ); + timer = setTimeout(() => reject(new Error(`cleanup timed out after ${ms}ms: ${label}`)), ms); // Don't let the timer keep the process alive once the race settles. (timer as { unref?: () => void }).unref?.(); }); @@ -859,37 +1063,85 @@ export async function executeCleanup( opts?: { concurrency?: number }, ): Promise { const concurrency = opts?.concurrency ?? DEFAULT_CONCURRENCY; - const { routing } = platform(); + // A project can have deployment history on several physical targets after a + // migration. Remove its vhosts from each reachable target. Falling back to + // the process edge preserves cleanup for old manifests with no target context. + const routeContexts = + manifest.routeContexts && manifest.routeContexts.length > 0 + ? manifest.routeContexts + : (manifest.unreachableRouteTargets?.length ?? 0) > 0 + ? [] + : [ + { + key: "fallback:process-edge", + routing: platform().routing, + hostPortTarget: null, + edgeProxy: null, + }, + ]; const result: CleanupResult = { total: manifest.resources.length, succeeded: 0, failed: [] }; // The manifest's resources CARRY their runtime — collection resolves one per // deployment and hands it over for destruction here, so this is the first point // at which the transports are finished with. See `disposeManifestRuntimes`. try { + // Process in bounded batches + for (let i = 0; i < manifest.resources.length; i += concurrency) { + const batch = manifest.resources.slice(i, i + concurrency); + const settled = await Promise.allSettled( + batch.map((resource) => destroyResource(resource, routeContexts, manifest.organizationId)), + ); - // Process in bounded batches - for (let i = 0; i < manifest.resources.length; i += concurrency) { - const batch = manifest.resources.slice(i, i + concurrency); - const settled = await Promise.allSettled( - batch.map((resource) => destroyResource(resource, routing, manifest.organizationId)), - ); + for (let j = 0; j < settled.length; j++) { + if (settled[j].status === "fulfilled") { + result.succeeded++; + } else { + const resource = batch[j]; + const reason = settled[j] as PromiseRejectedResult; + result.failed.push({ + ref: resource.ref, + label: resource.label, + error: safeErrorMessage(reason.reason), + type: resource.type, + }); + } + } + } - for (let j = 0; j < settled.length; j++) { - if (settled[j].status === "fulfilled") { - result.succeeded++; - } else { - const resource = batch[j]; - const reason = settled[j] as PromiseRejectedResult; - result.failed.push({ - ref: resource.ref, - label: resource.label, - error: safeErrorMessage(reason.reason), - type: resource.type, - }); + // Claims are a resource too. Reclaim them only after ALL workload and route + // cleanup succeeded. A fresh edge scan proves vhost absence, but it cannot + // prove a failed/stopped container no longer owns the bind; releasing in + // that state would let another project inherit its durable port reservation. + if (result.failed.length === 0) { + for (const routeContext of manifest.routeContexts ?? []) { + result.total += 1; + try { + const converged = await convergeTargetHostPortClaims({ + target: routeContext.hostPortTarget, + projectId: manifest.projectId, + desiredPublishes: [], + edgeProxy: routeContext.edgeProxy, + }); + if (converged.retained.length > 0) { + const ports = [...new Set(converged.retained.map((claim) => claim.port))].sort( + (a, b) => a - b, + ); + throw new Error( + `the edge still references this project's host port(s): ${ports.join(", ")}`, + ); + } + result.succeeded += 1; + } catch (error) { + result.failed.push({ + ref: routeContext.key, + label: `host-port claims on ${routeContext.key}`, + error: safeErrorMessage(error), + type: "route", + }); + } } } - } - return result; + return result; } finally { disposeManifestRuntimes(manifest); } @@ -906,7 +1158,11 @@ export async function executeCleanup( * deployment on every enforced delete. */ export function disposeManifestRuntimes(manifest: CleanupManifest): void { - for (const runtime of new Set(manifest.resources.map((r) => r.runtime))) { + const runtimes = [ + ...manifest.resources.map((resource) => resource.runtime), + ...(manifest.runtimes ?? []), + ]; + for (const runtime of new Set(runtimes)) { disposeRuntime(runtime ?? undefined); } } @@ -914,12 +1170,12 @@ export function disposeManifestRuntimes(manifest: CleanupManifest): void { /** Destroy a single resource with one retry on failure. */ async function destroyResource( resource: CleanupResource, - routing: ReturnType["routing"], + routeContexts: ReadonlyArray>, organizationId?: string, ): Promise { try { await withTimeout( - destroyResourceOnce(resource, routing, organizationId), + destroyResourceOnce(resource, routeContexts, organizationId), DESTROY_TIMEOUT_MS, resource.label, ); @@ -927,7 +1183,7 @@ async function destroyResource( // Retry once after backoff (also bounded — the retry can hang too). await new Promise((r) => setTimeout(r, RETRY_DELAY_MS)); await withTimeout( - destroyResourceOnce(resource, routing, organizationId), + destroyResourceOnce(resource, routeContexts, organizationId), DESTROY_TIMEOUT_MS, resource.label, ); @@ -936,7 +1192,7 @@ async function destroyResource( async function destroyResourceOnce( resource: CleanupResource, - routing: ReturnType["routing"], + routeContexts: ReadonlyArray>, organizationId?: string, ): Promise { switch (resource.type) { @@ -969,8 +1225,13 @@ async function destroyResourceOnce( return; } case "route": { - // Local vhost first — that stops the box serving the hostname. - await routing.removeRoute(resource.ref); + // Remove the vhost from every physical target this project has reached. + // A migration can leave old deployment history on the source host; using + // only the process-global edge would report success while a remote vhost + // continued serving the hostname. + for (const { routing } of routeContexts) { + await routing.removeRoute(resource.ref); + } // Then the Cloud edge record, which is a SEPARATE resource for a managed // `*.opsh.io` hostname. Without this a deleted project left its free URL // resolving and its globally-unique slug reserved, so the org could not @@ -1003,5 +1264,3 @@ async function destroyResourceOnce( // executor but as a named, audited, idempotent step sequence with a // deletion lock + force-cancel + 207 partial-success support. Anything new // should call teardownProject(). - - diff --git a/apps/api/src/modules/projects/project-crud.service.ts b/apps/api/src/modules/projects/project-crud.service.ts index 7b32e1f68..faa1f7845 100644 --- a/apps/api/src/modules/projects/project-crud.service.ts +++ b/apps/api/src/modules/projects/project-crud.service.ts @@ -21,12 +21,17 @@ import { compareSemver, compareCommitSha, isReleaseProvider, + releaseArtifactKind, + renderReleaseImage, + validateReleaseRepository, + validateReleaseVersionUrl, isBehind, GITHUB_REPO, normalizeRollbackWindow, normalizeAliasStrict, aliasConflictsWithSiblings, normalizeFramework, + isServicesFramework, deriveProjectDeployTarget, resolveWorkload, toWorkloadType, @@ -38,7 +43,11 @@ import { } from "@repo/core"; import type { ResourceConfig } from "@repo/adapters"; import { encodeResources } from "../../lib/resources"; -import { resolveLatestVersion, resolveLatestReleaseTag, readApiVersion } from "../../lib/release-resolver"; +import { + resolveLatestVersion, + resolveLatestReleaseTag, + readApiVersion, +} from "../../lib/release-resolver"; import { resolveLatestImageDigest } from "../../lib/image-registry"; import { env } from "../../config"; import { assertResourceInOrg } from "../../lib/controller-helpers"; @@ -65,11 +74,7 @@ import { applyProjectRouting } from "../domains/routing-apply.service"; import { syncProjectManagedEdge } from "./project-runtime.service"; import { normalizeStoredPublicEndpoints, publicEndpointHostname } from "../../lib/public-endpoints"; import { assertFreeEndpointsAllowed } from "../../lib/free-domain-guard"; -import { - currentPlanTier, - planProjectLimit, - PlanUpgradeRequiredError, -} from "../../lib/plan-guard"; +import { currentPlanTier, planProjectLimit, PlanUpgradeRequiredError } from "../../lib/plan-guard"; import { assertValidCustomDomains, customHostnamesOf } from "../../lib/custom-domain-guard"; import { hasMaskedValue, unmaskEnv } from "../../lib/secret-env"; import { getFolderSession } from "./folder/session-store"; @@ -78,6 +83,7 @@ import type { TCreateProjectEnvironmentBody, TEnsureProjectBody, TUpdateProjectBody, + TSetReleaseSourceBody, } from "./project.schema"; import { UpdateProjectBody } from "./project.schema"; @@ -104,6 +110,7 @@ const GIT_SOURCE_IDENTITY_KEYS = new Set([ "gitOwner", "gitRepo", "installationId", + "releaseSource", ]); /** @@ -160,7 +167,10 @@ function readDeployMeta( if (!p.cloudWorkspaceId && !serverId && !p.activeDeploymentId) { return { deployTarget: null, serverId: null }; } - const deployTarget = deriveProjectDeployTarget({ cloudWorkspaceId: p.cloudWorkspaceId, serverId }); + const deployTarget = deriveProjectDeployTarget({ + cloudWorkspaceId: p.cloudWorkspaceId, + serverId, + }); // The pair must agree. A cloud-bound project can still carry a server id — from the // column it was bound to before it moved, or from the snapshot of that last deploy — // and emitting both is how a card ends up labelled "Cloud" while holding a server's @@ -224,7 +234,9 @@ async function loadActiveMigration(projectId: string) { } /** {@link loadActiveMigration} for a whole list — ONE statement for N projects, same rules. */ -async function loadActiveMigrations(projectIds: string[]): Promise> { +async function loadActiveMigrations( + projectIds: string[], +): Promise> { if (!MIGRATIONS_POSSIBLE) return new Map(); try { return await repos.dockerMigrationRun.findActiveForProjects(projectIds); @@ -409,20 +421,173 @@ function projectGitUrl(owner?: string | null, repo?: string | null) { return owner && repo ? `https://github.com/${owner}/${repo}.git` : undefined; } +/** Validate and normalize one complete release source before it can become a + * project's source identity. Keeping this at the service boundary means create, + * ensure and the explicit source-transition endpoint cannot persist shapes the + * resolver/runtime interpret differently. */ +function normalizeReleaseSource(input: ReleaseSource): ReleaseSource { + if (!input || typeof input !== "object") { + throw new ValidationError("Release source must be an object."); + } + if (input.mode !== "github" && input.mode !== "url") { + throw new ValidationError('Release source mode must be "github" or "url".'); + } + + let artifactKind: ReturnType; + try { + artifactKind = releaseArtifactKind(input); + } catch (err) { + throw new ValidationError(safeErrorMessage(err)); + } + + type ReleaseSourceStringKey = Exclude< + keyof ReleaseSource, + "mode" | "artifactKind" | "trackReleases" + >; + const optionalString = (key: ReleaseSourceStringKey): string | undefined => { + const value = input[key]; + if (value === undefined) return undefined; + if (typeof value !== "string") { + throw new ValidationError(`releaseSource.${key} must be a string.`); + } + return value.trim() || undefined; + }; + + if (input.trackReleases !== undefined && typeof input.trackReleases !== "boolean") { + throw new ValidationError("releaseSource.trackReleases must be a boolean."); + } + + const strings = { + repo: optionalString("repo"), + assetTemplate: optionalString("assetTemplate"), + imageTemplate: optionalString("imageTemplate"), + os: optionalString("os"), + arch: optionalString("arch"), + distUrl: optionalString("distUrl"), + sha256Url: optionalString("sha256Url"), + sha256: optionalString("sha256"), + versionUrl: optionalString("versionUrl"), + channel: optionalString("channel"), + pinnedVersion: optionalString("pinnedVersion"), + }; + + // Persist an explicit allow-list, not the request object. Apart from keeping + // imported/runtime JSON honest, this prevents a future caller from smuggling + // an unrelated field into releaseSource and accidentally turning it into a + // second source contract that only one layer understands. + const source: ReleaseSource = { + mode: input.mode, + ...(input.artifactKind !== undefined ? { artifactKind } : {}), + ...(strings.repo ? { repo: strings.repo } : {}), + ...(strings.assetTemplate ? { assetTemplate: strings.assetTemplate } : {}), + ...(strings.imageTemplate ? { imageTemplate: strings.imageTemplate } : {}), + ...(strings.os ? { os: strings.os } : {}), + ...(strings.arch ? { arch: strings.arch } : {}), + ...(strings.distUrl ? { distUrl: strings.distUrl } : {}), + ...(strings.sha256Url ? { sha256Url: strings.sha256Url } : {}), + ...(strings.sha256 ? { sha256: strings.sha256 } : {}), + ...(strings.versionUrl ? { versionUrl: strings.versionUrl } : {}), + ...(strings.channel ? { channel: strings.channel } : {}), + ...(strings.pinnedVersion ? { pinnedVersion: strings.pinnedVersion } : {}), + ...(input.trackReleases !== undefined ? { trackReleases: input.trackReleases } : {}), + }; + + if (source.mode === "github") { + const invalidRepo = validateReleaseRepository(source.repo ?? ""); + if (invalidRepo) throw new ValidationError(invalidRepo); + } + + if (artifactKind === "image") { + if (!source.imageTemplate) { + throw new ValidationError("A container release source requires imageTemplate."); + } + if (source.mode === "url" && !source.versionUrl && !source.pinnedVersion) { + throw new ValidationError( + "A URL-based container release requires versionUrl or pinnedVersion.", + ); + } + if (source.versionUrl) { + const invalidUrl = validateReleaseVersionUrl(source.versionUrl); + if (invalidUrl) throw new ValidationError(invalidUrl); + } + if ( + source.assetTemplate || + source.distUrl || + source.sha256Url || + source.sha256 || + source.os || + source.arch + ) { + throw new ValidationError( + "Container release sources cannot include archive asset, dist, checksum, OS, or architecture fields.", + ); + } + if (source.mode === "url" && source.repo) { + throw new ValidationError("A URL-based release source cannot also specify a GitHub repo."); + } + if (source.mode === "github" && source.versionUrl) { + throw new ValidationError("A GitHub release source cannot also specify versionUrl."); + } + try { + // A pinned release is already known, so validate the exact reference the + // first deployment will use. Dynamic sources use a representative safe + // tag to validate placement, placeholders and the resulting OCI shape + // without resolving any network source during configuration. + const tag = source.pinnedVersion ?? "v1.2.3"; + renderReleaseImage(source.imageTemplate, { version: tag.replace(/^v/i, ""), tag }); + } catch (err) { + throw new ValidationError(safeErrorMessage(err)); + } + } else if (source.imageTemplate) { + throw new ValidationError( + 'imageTemplate requires artifactKind: "image"; omitted artifactKind is the legacy archive mode.', + ); + } + + return source; +} + function resolveProjectSource(data: TCreateProjectBody) { // Release/dist source: a prebuilt dist, no git repo and no stored localPath // (its dir is resolved per-deploy). The source repo, if any, lives in // releaseSource — the project-level gitOwner/gitRepo columns stay null so the // commit-drift path is never taken for it. const isRelease = isReleaseProvider(data.gitProvider); - // Release/dist deploys resolve a prebuilt dir onto THIS box's filesystem - // (download + extract into ~/.openship) — a self-hosted runtime concern. - // Blocked in cloud mode, same as localPath below: the SaaS builds in Oblien - // sandboxes and must never write a tenant's dist onto the shared control plane. - if (isRelease && env.CLOUD_MODE) { + if (isRelease && !data.releaseSource) { + throw new ValidationError("A release project requires releaseSource."); + } + const releaseSource = isRelease + ? normalizeReleaseSource(data.releaseSource as ReleaseSource) + : null; + const isReleaseImage = releaseSource !== null && releaseArtifactKind(releaseSource) === "image"; + if ( + isReleaseImage && + (data.projectType === "services" || data.composePath || isServicesFramework(data.framework)) + ) { + throw new ValidationError( + "A project-level release image deploys one app; configure images on individual services for a multi-service project.", + ); + } + const releaseWorkload = isReleaseImage + ? resolveWorkloadColumns({ + workloadType: data.workloadType, + hasServer: data.hasServer, + productionMode: data.productionMode, + }) + : null; + if (releaseWorkload?.workloadType === "static") { + throw new ValidationError( + "A prebuilt container image must be configured as a web app or worker, not a static site.", + ); + } + // Archive releases resolve a prebuilt dir onto THIS box's filesystem and are + // therefore self-hosted-only. Container releases are materialized by the + // selected runtime (Docker pull / Cloud image workspace) and are cloud-safe. + if (isRelease && env.CLOUD_MODE && releaseArtifactKind(releaseSource!) === "archive") { throw new ForbiddenError("Release/dist source projects are not available in cloud mode"); } - const safeLocalPath = !isRelease && data.localPath && !env.CLOUD_MODE ? data.localPath : undefined; + const safeLocalPath = + !isRelease && data.localPath && !env.CLOUD_MODE ? data.localPath : undefined; const gitOwner = isRelease || safeLocalPath ? undefined : data.gitOwner; const gitRepo = isRelease || safeLocalPath ? undefined : data.gitRepo; @@ -432,7 +597,7 @@ function resolveProjectSource(data: TCreateProjectBody) { gitRepo, gitProvider: isRelease ? "release" : safeLocalPath ? "local" : (data.gitProvider ?? "github"), gitUrl: projectGitUrl(gitOwner, gitRepo), - releaseSource: isRelease ? ((data.releaseSource as ReleaseSource | undefined) ?? null) : null, + releaseSource, }; } @@ -461,11 +626,7 @@ function environmentNameFromSlug(slug: string) { ); } -async function ensureProjectApp( - data: TCreateProjectBody, - slug: string, - organizationId: string, -) { +async function ensureProjectApp(data: TCreateProjectBody, slug: string, organizationId: string) { let app = await repos.projectGroup.findBySlugInOrg(organizationId, slug); if (app) return { app, created: false }; @@ -543,14 +704,19 @@ function buildProductionProjectInput( organizationId: string, ): Omit { const source = resolveProjectSource(data); + const isReleaseImage = + source.releaseSource !== null && releaseArtifactKind(source.releaseSource) === "image"; // Workload triad, resolved once. Absent any axis signal a new project is a web // app (hasServer=true / host) — the historical create default. const workload = resolveWorkloadColumns({ workloadType: data.workloadType, hasServer: data.hasServer, productionMode: data.productionMode, - }) ?? { workloadType: "web" as WorkloadType, hasServer: true, productionMode: "host" as ProductionMode }; - + }) ?? { + workloadType: "web" as WorkloadType, + hasServer: true, + productionMode: "host" as ProductionMode, + }; return { organizationId, groupId, @@ -584,16 +750,14 @@ function buildProductionProjectInput( productionMode: workload.productionMode, port: data.port ?? 3000, hasServer: workload.hasServer, - hasBuild: data.hasBuild ?? true, + hasBuild: isReleaseImage ? false : (data.hasBuild ?? true), workloadType: workload.workloadType, // Source/build axes are explicit OVERRIDES only — null means "derive at // read time from framework/source", which is what every existing row does. - sourceKind: data.sourceKind ?? null, - buildKind: data.buildKind ?? null, + sourceKind: isReleaseImage ? "image" : (data.sourceKind ?? null), + buildKind: isReleaseImage ? "prebuilt" : (data.buildKind ?? null), workspacePrepareCommand: - data.projectType === "monorepo" - ? data.monorepoWorkspace?.prepareCommand ?? null - : null, + data.projectType === "monorepo" ? (data.monorepoWorkspace?.prepareCommand ?? null) : null, routingConfig: data.routingConfig ?? null, rollbackWindow: data.rollbackWindow !== undefined ? normalizeRollbackWindow(data.rollbackWindow) : null, @@ -615,14 +779,13 @@ function buildProductionProjectInput( // not supported on the bare runtime". Git apps/monorepos stay null (chosen at // deploy time). runtimeMode: - data.projectType === "services" || data.projectType === "docker" ? "docker" : null, + isReleaseImage || data.projectType === "services" || data.projectType === "docker" + ? "docker" + : null, }; } -async function persistMonorepoApps( - projectId: string, - data: TCreateProjectBody, -): Promise { +async function persistMonorepoApps(projectId: string, data: TCreateProjectBody): Promise { if (data.projectType !== "monorepo" || !data.monorepoApps?.length) return; // #336: monorepo rows are masked on read too (withDrift has no kind filter), @@ -665,7 +828,7 @@ async function persistMonorepoApps( domainType: app.domainType ?? "free", environment: hasMaskedValue(app.environment) ? unmaskEnv(app.environment, storedEnvByName.get(app.name) ?? null) - : app.environment ?? {}, + : (app.environment ?? {}), })), ); } @@ -735,7 +898,13 @@ async function persistComposeServices( }); } - await repos.service.syncFromCompose(projectId, services); + // The ensure contract requires the FULL freshly scanned compose service list + // (and already removes rows missing from it), so it is authoritative about + // compose-owned fields too. In particular, omitting `buildArgs` after removing + // the whole `args:` key must clear stale values rather than replay them. + await repos.service.syncFromCompose(projectId, services, { + composeAuthoritative: true, + }); } async function createProductionProject( @@ -790,12 +959,15 @@ async function createProductionProject( ...((data as Partial).services ?? []), ]); const { app, created: appCreated } = await ensureProjectApp(data, slug, organizationId); - const routing = deriveNextProjectRouteState({ - slug, - }, { - nextPublicEndpoints: data.publicEndpoints, - slug, - }); + const routing = deriveNextProjectRouteState( + { + slug, + }, + { + nextPublicEndpoints: data.publicEndpoints, + slug, + }, + ); try { const created = await repos.project.create( @@ -909,7 +1081,8 @@ export async function linkProjectRepo( const { organizationId } = ctx; const owner = input.owner?.trim(); const repo = input.repo?.trim(); - if (!owner || !repo) return { ok: false, code: "invalid", message: "owner and repo are required" }; + if (!owner || !repo) + return { ok: false, code: "invalid", message: "owner and repo are required" }; const project = await repos.project.findById(projectId); try { @@ -920,6 +1093,14 @@ export async function linkProjectRepo( const gitUrl = projectGitUrl(owner, repo); const defaultBranch = await resolveDefaultBranch(ctx, owner, repo, input.branch); + // A project_app is one source identity even if an old/partial write left its + // environments inconsistent. Linking Git converges the whole group, so clear + // release-only class overrides when ANY sibling still carries that source. + const leavingReleaseSource = project!.groupId + ? (await repos.project.listByGroup(project!.groupId)).some((sibling) => + isReleaseProvider(sibling.gitProvider), + ) + : isReleaseProvider(project!.gitProvider); const gitFields: Record = { gitProvider: "github", @@ -927,6 +1108,22 @@ export async function linkProjectRepo( gitRepo: repo, gitBranch: defaultBranch, gitUrl, + // Source transition: a Git repo and a release image are mutually exclusive. + // Clear every release-only/clone-bypass override atomically so the next + // deploy derives its normal source/build class from the linked repository. + releaseSource: null, + localPath: null, + sourceKind: null, + // Release projects deliberately override these columns to describe a + // prebuilt artifact. Clear those overrides when (and only when) leaving a + // release source. Relinking an ordinary Git/local project must retain its + // intentional Docker/build/runtime settings. + ...(leavingReleaseSource + ? { buildKind: null, hasBuild: true, runtimeMode: null, startCommand: null } + : {}), + webhookId: null, + installationId: null, + autoDeploy: false, }; const strategy = await resolveWebhookStrategy(project!); @@ -955,32 +1152,135 @@ export async function linkProjectRepo( } } - await repos.project.update(projectId, gitFields); if (project!.groupId) { const sharedGitFields = { gitProvider: "github", gitOwner: owner, gitRepo: repo, gitUrl, - installationId: (gitFields.installationId as number | undefined) ?? input.installationId, - ...(typeof gitFields.webhookId === "number" ? { webhookId: gitFields.webhookId } : {}), + installationId: + typeof gitFields.installationId === "number" + ? gitFields.installationId + : (input.installationId ?? null), + releaseSource: null, + localPath: null, + sourceKind: null, + ...(leavingReleaseSource + ? { buildKind: null, hasBuild: true, runtimeMode: null, startCommand: null } + : {}), + webhookId: typeof gitFields.webhookId === "number" ? gitFields.webhookId : null, + autoDeploy: Boolean(gitFields.autoDeploy), }; - await repos.projectGroup.update(project!.groupId, { + await repos.project.updateSourceByApp(project!.groupId, sharedGitFields, { gitProvider: "github", gitOwner: owner, gitRepo: repo, gitUrl, - installationId: (gitFields.installationId as number | undefined) ?? input.installationId, + installationId: sharedGitFields.installationId, }); - const siblings = await repos.project.listByGroup(project!.groupId); - await Promise.all( - siblings - .filter((sibling) => sibling.id !== projectId) - .map((sibling) => repos.project.update(sibling.id, sharedGitFields)), - ); + // Environments intentionally keep their own branches; only the environment + // the operator linked adopts the selected/default branch. + await repos.project.update(projectId, { gitBranch: defaultBranch }); + } else { + await repos.project.update(projectId, gitFields); + } + + return { + ok: true, + owner, + repo, + branch: defaultBranch, + strategy, + autoDeploy: !!gitFields.autoDeploy, + }; +} + +/** Atomically transition a whole project-environment group to a tracked + * prebuilt container release. This is intentionally separate from generic + * PATCH: source identity spans several columns and must never be half-written. */ +export async function setProjectReleaseImageSource( + projectId: string, + organizationId: string, + input: TSetReleaseSourceBody, +) { + const project = await repos.project.findById(projectId); + assertResourceInOrg(project, "Project", organizationId, projectId); + + const source = normalizeReleaseSource(input as ReleaseSource); + if (releaseArtifactKind(source) !== "image") { + throw new ValidationError('artifactKind must be "image" for this source transition.'); } - return { ok: true, owner, repo, branch: defaultBranch, strategy, autoDeploy: !!gitFields.autoDeploy }; + const siblings = project!.groupId + ? await repos.project.listByGroup(project!.groupId) + : [project!]; + for (const sibling of siblings) { + if (sibling.composePath?.trim() || isServicesFramework(sibling.framework)) { + throw new ValidationError( + `Environment "${sibling.environmentName ?? sibling.name}" is configured for multiple services. Configure release images on its individual services instead.`, + ); + } + if (resolveWorkload(sibling.workloadType, sibling.hasServer) === "static") { + throw new ValidationError( + `Environment "${sibling.environmentName ?? sibling.name}" is static. Change it to a web app or worker before selecting a container image source.`, + ); + } + } + const serviceSets = await Promise.all( + siblings.map((sibling) => repos.service.listByProject(sibling.id)), + ); + for (const services of serviceSets) { + const enabledServices = services.filter((service) => service.enabled !== false); + if (enabledServices.length > 0) { + throw new ValidationError( + "A project-level release image deploys one app. Remove or disable project services, or configure release images per service.", + ); + } + } + + const isExistingReleaseImage = siblings.every( + (sibling) => + isReleaseProvider(sibling.gitProvider) && + sibling.releaseSource !== null && + releaseArtifactKind(sibling.releaseSource as ReleaseSource) === "image", + ); + const releaseFields = { + gitProvider: "release", + gitOwner: null, + gitRepo: null, + gitUrl: null, + installationId: null, + localPath: null, + releaseSource: source, + sourceKind: "image", + buildKind: "prebuilt", + hasBuild: false, + runtimeMode: "docker", + // On the initial source transition, a command from Git/local is not an + // image override, so clear it and preserve the image's baked-in command. + // On image-to-image edits, omit the column entirely: every environment may + // already carry an intentional command override and must retain it. + ...(isExistingReleaseImage ? {} : { startCommand: null }), + composePath: null, + webhookId: null, + webhookDomain: null, + autoDeploy: false, + } as const; + + if (project!.groupId) { + await repos.project.updateSourceByApp(project!.groupId, releaseFields, { + gitProvider: "release", + gitOwner: null, + gitRepo: null, + gitUrl: null, + installationId: null, + }); + } else { + await repos.project.update(projectId, releaseFields); + } + + const updated = await repos.project.findById(projectId); + return enrichProject(updated!); } /** Exported for the project CLONE, which needs the same "-2, -3, …" rule a fresh project gets — @@ -1096,7 +1396,10 @@ async function findProjectByAppSlug( */ export async function assertProjectQuota(organizationId: string): Promise { if (!env.CLOUD_MODE) { - const { total } = await repos.projectGroup.listByOrganization(organizationId, { page: 1, perPage: 1 }); + const { total } = await repos.projectGroup.listByOrganization(organizationId, { + page: 1, + perPage: 1, + }); if (total >= SYSTEM.PROJECTS.MAX_PER_USER) { throw new ValidationError(`Project limit reached (${SYSTEM.PROJECTS.MAX_PER_USER})`); } @@ -1105,7 +1408,10 @@ export async function assertProjectQuota(organizationId: string): Promise const planCap = await planProjectLimit(organizationId); const cap = planCap ?? env.CLOUD_MAX_PROJECTS_PER_USER; - const { total } = await repos.projectGroup.listByOrganization(organizationId, { page: 1, perPage: 1 }); + const { total } = await repos.projectGroup.listByOrganization(organizationId, { + page: 1, + perPage: 1, + }); if (total >= cap) { throw new PlanUpgradeRequiredError( `Your plan includes ${cap} projects and you're using ${total}. Upgrade to add more.`, @@ -1115,10 +1421,7 @@ export async function assertProjectQuota(organizationId: string): Promise } } -export async function ensureProject( - data: EnsureProjectBody, - organizationId: string, -) { +export async function ensureProject(data: EnsureProjectBody, organizationId: string) { const nameSlug = slugify(data.name); const desiredSlug = data.slug || nameSlug; @@ -1140,11 +1443,7 @@ export async function ensureProject( // No existing match → this ensure will create. Enforce the cap here too // (the folder-upload deploy flow reaches creation only through ensure). await assertProjectQuota(organizationId); - project = await createProductionProject( - data, - desiredSlug, - organizationId, - ); + project = await createProductionProject(data, desiredSlug, organizationId); created = true; } else { // Defensive: if we matched an existing project but its org_id doesn't @@ -1280,10 +1579,10 @@ export async function listProjects( // organizationId is required across the codebase — the route-level // requirePermission middleware ensures it's set before the controller runs. - const { rows: projects } = await repos.project.listByOrganization( - organizationId, - { page: 1, perPage: 1000 }, - ); + const { rows: projects } = await repos.project.listByOrganization(organizationId, { + page: 1, + perPage: 1000, + }); const byGroup = new Map(); for (const p of projects) { @@ -1314,10 +1613,7 @@ export async function getProject(projectId: string, organizationId: string) { // ─── Create project ────────────────────────────────────────────────────────── /** @scope org — only reads organizationId as a DB key. */ -export async function createProject( - data: TCreateProjectBody, - organizationId: string, -) { +export async function createProject(data: TCreateProjectBody, organizationId: string) { const slug = slugify(data.name); await assertProjectQuota(organizationId); @@ -1334,6 +1630,32 @@ export async function createProject( // ─── Update project ────────────────────────────────────────────────────────── +/** + * Re-emit the complete live route surface in its required last-writer order. + * Project-level rows establish the base vhosts; service/composite/fan-out + * registrations then replace only the hostnames whose richer topology they own. + * Both halves are best-effort because the project edit is already persisted. + */ +async function reapplyCompleteProjectRouting( + project: Project, + previousHostnames: string[], + options?: Parameters[2], +) { + const projectRoutes = options + ? reapplyProjectLiveRoutes(project, previousHostnames, options) + : reapplyProjectLiveRoutes(project, previousHostnames); + await projectRoutes.catch((err) => + console.warn( + `[updateProject] project route re-apply failed (non-fatal, applies next deploy): ${safeErrorMessage(err)}`, + ), + ); + await applyProjectRouting(project.id).catch((err) => + console.warn( + `[updateProject] service/topology route re-apply failed (non-fatal, applies next deploy): ${safeErrorMessage(err)}`, + ), + ); +} + export async function updateProject( projectId: string, data: TUpdateProjectBody, @@ -1403,9 +1725,7 @@ export async function updateProject( update.routeStrategy !== undefined && !["auto", "loopback-port", "container-ip"].includes(update.routeStrategy as string) ) { - throw new ValidationError( - "routeStrategy must be 'auto', 'loopback-port', or 'container-ip'", - ); + throw new ValidationError("routeStrategy must be 'auto', 'loopback-port', or 'container-ip'"); } // ── monorepoSharedPaths validation ────────────────────────────────── @@ -1414,11 +1734,8 @@ export async function updateProject( // deployable service would force-rebuild every service on every push // to web (defeating the point of smart per-service deploys). if (data.monorepoSharedPaths !== undefined && data.monorepoSharedPaths !== null) { - const normalize = (s: string) => - s.trim().replace(/^\/+/, "").replace(/\/+$/, "").toLowerCase(); - const prefixes = data.monorepoSharedPaths - .map(normalize) - .filter((s) => s.length > 0); + const normalize = (s: string) => s.trim().replace(/^\/+/, "").replace(/\/+$/, "").toLowerCase(); + const prefixes = data.monorepoSharedPaths.map(normalize).filter((s) => s.length > 0); if (prefixes.length > 0) { const services = await repos.service.listByProject(projectId).catch(() => []); const serviceRoots = services @@ -1426,7 +1743,8 @@ export async function updateProject( .filter((s) => s.length > 0); const overlap = prefixes.find((prefix) => serviceRoots.some( - (root) => root === prefix || root.startsWith(`${prefix}/`) || prefix.startsWith(`${root}/`), + (root) => + root === prefix || root.startsWith(`${prefix}/`) || prefix.startsWith(`${root}/`), ), ); if (overlap) { @@ -1442,9 +1760,7 @@ export async function updateProject( // ── defaultRollbackStrategy ──────────────────────────────────────── if (data.defaultRollbackStrategy !== undefined) { if (data.defaultRollbackStrategy !== "git" && data.defaultRollbackStrategy !== "snapshot") { - throw new ValidationError( - `defaultRollbackStrategy must be "git" or "snapshot"`, - ); + throw new ValidationError(`defaultRollbackStrategy must be "git" or "snapshot"`); } update.defaultRollbackStrategy = data.defaultRollbackStrategy; } @@ -1459,9 +1775,7 @@ export async function updateProject( } else { const alias = normalizeAliasStrict(String(data.internalAlias)); if (!alias) { - throw new ValidationError( - "internalAlias must contain at least one letter or digit", - ); + throw new ValidationError("internalAlias must contain at least one letter or digit"); } // Reject an internalAlias that collides with a sidecar service's name or // custom alias on this project's network (embedded DNS is first-match). @@ -1491,7 +1805,9 @@ export async function updateProject( // No slug term: the slug is immutable here (PROJECT_IDENTITY_KEYS), so a rename // never re-syncs routes — which is the point. Its hostname is edited as a domain. const routesReapplied = - data.publicEndpoints !== undefined || update.port !== undefined; + data.publicEndpoints !== undefined || + update.port !== undefined || + (update.routeStrategy !== undefined && update.routeStrategy !== p.routeStrategy); if (routesReapplied) { // Snapshot the live hostnames before the sync so re-application can tear // down any the edit drops — AND so the free-cloud gate only fires for @@ -1511,10 +1827,7 @@ export async function updateProject( // (the latter also covers a PENDING route that has no domain row yet), so a // remaining pending route is never mistaken for net-new. const priorHosts = new Set( - [ - ...previousHostnames, - ...(beforeState?.publicEndpoints ?? []).map((e) => e.hostname), - ] + [...previousHostnames, ...(beforeState?.publicEndpoints ?? []).map((e) => e.hostname)] .filter((h): h is string => typeof h === "string" && h.length > 0) .map((h) => h.trim().toLowerCase()), ); @@ -1554,13 +1867,9 @@ export async function updateProject( // covers every managed hostname on the project, including the ones added by // this edit. Letting the re-apply sync them too raced its own follow-up — // two challenges for one target, the second resetting the first's token. - await reapplyProjectLiveRoutes(refreshed, previousHostnames, { + await reapplyCompleteProjectRouting(refreshed, previousHostnames, { managedEdgeSyncedByCaller: true, - }).catch((err) => - console.warn( - `[updateProject] live route re-apply failed (non-fatal): ${safeErrorMessage(err)}`, - ), - ); + }); // A free (*.opsh.io) domain resolves only through Openship Cloud's edge. // reapplyProjectLiveRoutes handles the self-hosted OpenResty side; the // managed edge must be re-registered too or an edited/added free URL @@ -1585,26 +1894,13 @@ export async function updateProject( // the live deployment without a rebuild — the routing counterpart to the // domain/port re-sync above. Self-hosted → OpenResty, cloud → the Oblien edge; // best-effort internally. - if (data.routingConfig !== undefined) { - await applyProjectRouting(projectId); - // `applyProjectRouting` only emits the COMPOSITE (1 static + 1 server) and migration - // fan-out shapes — for a single-app or lone-static project it builds no registers and - // returns having written nothing, so the Domains-tab save reported success and changed - // nothing on the edge. The per-domain path is what carries the rules for those, so run - // it here. - // - // Skipped when the block above already queued one: that re-apply is fire-and-forget, - // and two writers on one vhost can interleave their snapshot/rollback — the loser - // restores a file the winner had already replaced. - if (!routesReapplied) { - const forRouting = await repos.project.findById(projectId); - if (forRouting) { - await reapplyProjectLiveRoutes(forRouting, []).catch((err) => - console.warn( - `[updateProject] routing re-apply failed (non-fatal, applies next deploy): ${safeErrorMessage(err)}`, - ), - ); - } + // Skipped when the block above already queued the complete ordered pass: + // concurrent writers on one vhost can interleave snapshot/rollback, and the + // loser may restore a file the winner already replaced. + if (data.routingConfig !== undefined && !routesReapplied) { + const forRouting = await repos.project.findById(projectId); + if (forRouting) { + await reapplyCompleteProjectRouting(forRouting, []); } } @@ -1627,10 +1923,7 @@ export async function updateProject( // ─── Project environments ─────────────────────────────────────────────────── -export async function listProjectEnvironments( - projectId: string, - organizationId: string, -) { +export async function listProjectEnvironments(projectId: string, organizationId: string) { const p = await repos.project.findById(projectId); assertResourceInOrg(p, "Project", organizationId, projectId); @@ -1698,7 +1991,9 @@ export async function createProjectEnvironment( const branches = await listGitHubBranches(ctx, base.gitOwner, base.gitRepo); const exists = branches.some((branch) => branch.name === gitBranch); if (!exists) { - throw new ValidationError(`Branch "${gitBranch}" was not found for ${base.gitOwner}/${base.gitRepo}`); + throw new ValidationError( + `Branch "${gitBranch}" was not found for ${base.gitOwner}/${base.gitRepo}`, + ); } } @@ -1724,6 +2019,7 @@ export async function createProjectEnvironment( gitBranch, gitUrl: app?.gitUrl ?? base.gitUrl, installationId: app?.installationId ?? base.installationId, + releaseSource: base.releaseSource, framework: base.framework, packageManager: base.packageManager, installCommand: base.installCommand, @@ -1739,6 +2035,10 @@ export async function createProjectEnvironment( port: base.port, hasServer: base.hasServer, hasBuild: base.hasBuild, + sourceKind: base.sourceKind, + buildKind: base.buildKind, + workloadType: base.workloadType, + runtimeMode: base.runtimeMode, resources: base.resources, buildResources: base.buildResources, sleepMode: base.sleepMode, @@ -1871,7 +2171,14 @@ export function releaseSourceKey(p: Project): string { if (!isReleaseProvider(p.gitProvider)) return `self:${p.appTemplateId ?? ""}`; const s = (p.releaseSource as ReleaseSource | null) ?? null; if (!s) return "none"; - return [s.mode, s.repo ?? "", s.versionUrl ?? "", s.pinnedVersion ?? ""].join("|"); + return [ + s.mode, + releaseArtifactKind(s), + s.repo ?? "", + s.versionUrl ?? "", + s.pinnedVersion ?? "", + s.imageTemplate ?? "", + ].join("|"); } /** Image services whose upstream digest is worth resolving (image-only, enabled). */ @@ -1942,7 +2249,7 @@ export async function resolveUpstreamDrift( const source = (p.releaseSource as ReleaseSource | null) ?? null; if (!source) return { supported: false }; const latestVersion = source.pinnedVersion - ? source.pinnedVersion.replace(/^v/, "") + ? source.pinnedVersion.replace(/^v/i, "") : await resolveLatestVersion(source); return { supported: true, @@ -2062,7 +2369,9 @@ export async function evaluateDrift(p: Project, upstream: UpstreamDrift) { // pressing Update quiets every surface immediately. const latestInProgress = behind && latestSha - ? Boolean(await repos.deployment.findInProgressByCommit(p.id, latestSha).catch(() => undefined)) + ? Boolean( + await repos.deployment.findInProgressByCommit(p.id, latestSha).catch(() => undefined), + ) : false; return { supported: true as const, @@ -2234,11 +2543,7 @@ export async function resolveProjectWebhookState( return { strategy, webhookActive, installationInstalled, sharedWebhookId }; } -export async function setBranch( - projectId: string, - branch: string, - organizationId: string, -) { +export async function setBranch(projectId: string, branch: string, organizationId: string) { const p = await repos.project.findById(projectId); assertResourceInOrg(p, "Project", organizationId, projectId); @@ -2342,10 +2647,7 @@ export async function listProjectDeployments( // ─── Deployment session ────────────────────────────────────────────────────── -export async function getLatestDeploymentSession( - projectId: string, - organizationId: string, -) { +export async function getLatestDeploymentSession(projectId: string, organizationId: string) { const p = await repos.project.findById(projectId); assertResourceInOrg(p, "Project", organizationId, projectId); @@ -2365,4 +2667,3 @@ export async function getLatestDeploymentSession( : null, }; } - diff --git a/apps/api/src/modules/projects/project-environment-create.test.ts b/apps/api/src/modules/projects/project-environment-create.test.ts index d7224ad99..5e59ed48f 100644 --- a/apps/api/src/modules/projects/project-environment-create.test.ts +++ b/apps/api/src/modules/projects/project-environment-create.test.ts @@ -31,9 +31,24 @@ const h = vi.hoisted(() => ({ environmentSlug: "production", environmentType: "production", gitProvider: "github", + framework: "node", gitOwner: "acme", gitRepo: "site", gitBranch: "main", + gitUrl: "https://github.com/acme/site.git", + installationId: 42 as number | null, + localPath: null as string | null, + releaseSource: null as Record | null, + sourceKind: null as string | null, + buildKind: null as string | null, + workloadType: "web", + runtimeMode: null as string | null, + hasBuild: true, + hasServer: true, + startCommand: "npm start" as string | null, + webhookId: 17 as number | null, + webhookDomain: "hooks.example.com" as string | null, + autoDeploy: true, isApp: false, activeDeploymentId: null as string | null, serverId: null as string | null, @@ -43,6 +58,7 @@ const h = vi.hoisted(() => ({ siblings: [] as Array>, branches: [{ name: "main" }, { name: "staging" }] as Array<{ name: string }>, creates: [] as Array>, + groupCreates: [] as Array>, freeGateCalls: 0, persistedRoutes: [] as Array, })); @@ -62,6 +78,12 @@ vi.mock("@repo/db", () => ({ projectGroup: { findById: async () => ({ id: "grp_1", name: "Site", slug: "site" }), findBySlugInOrg: async () => null, + listByOrganization: async () => ({ rows: [], total: 0 }), + create: async (input: Record) => { + h.groupCreates.push(input); + return { ...input, id: "grp_new" }; + }, + softDelete: async () => {}, }, deployment: { findById: async () => null, listByProject: async () => ({ rows: [] }) }, service: { listByProject: async () => [] }, @@ -123,9 +145,28 @@ const ctx = { userId: "user_1", organizationId: "org_1" } as never; describe("createProjectEnvironment", () => { beforeEach(() => { + Object.assign(h.base, { + gitProvider: "github", + framework: "node", + gitOwner: "acme", + gitRepo: "site", + gitUrl: "https://github.com/acme/site.git", + installationId: 42, + localPath: null, + releaseSource: null, + sourceKind: null, + buildKind: null, + runtimeMode: null, + hasBuild: true, + startCommand: "npm start", + webhookId: 17, + webhookDomain: "hooks.example.com", + autoDeploy: true, + }); h.siblings = [{ ...h.base }]; h.branches = [{ name: "main" }, { name: "staging" }]; h.creates = []; + h.groupCreates = []; h.freeGateCalls = 0; h.persistedRoutes = []; }); @@ -186,4 +227,232 @@ describe("createProjectEnvironment", () => { expect(h.creates[0]!.groupId).toBe("grp_1"); expect(env.gitBranch).toBe("staging"); }); + + it("copies a release image's source and runtime class into a new environment", async () => { + const releaseSource = { + mode: "github", + artifactKind: "image", + repo: "acme/site", + imageTemplate: "ghcr.io/acme/site:{tag}", + pinnedVersion: "v2.0.0", + trackReleases: true, + }; + Object.assign(h.base, { + gitProvider: "release", + gitOwner: null, + gitRepo: null, + gitUrl: null, + installationId: null, + localPath: null, + releaseSource, + sourceKind: "image", + buildKind: "prebuilt", + runtimeMode: "docker", + hasBuild: false, + startCommand: null, + }); + + const { createProjectEnvironment } = await load(); + await createProjectEnvironment("proj_prod", ctx, { + environmentName: "Staging", + sourceMode: "branch", + } as never); + + expect(h.creates).toHaveLength(1); + expect(h.creates[0]).toMatchObject({ + gitProvider: "release", + gitOwner: null, + gitRepo: null, + gitUrl: null, + installationId: null, + localPath: null, + releaseSource, + sourceKind: "image", + buildKind: "prebuilt", + runtimeMode: "docker", + hasBuild: false, + workloadType: "web", + startCommand: null, + // A new environment never inherits webhook bookkeeping. + webhookId: null, + webhookDomain: null, + }); + }); +}); + +describe("createProject — release image source", () => { + beforeEach(() => { + Object.assign(h.base, { + gitProvider: "github", + framework: "node", + gitOwner: "acme", + gitRepo: "site", + gitUrl: "https://github.com/acme/site.git", + installationId: 42, + localPath: null, + releaseSource: null, + sourceKind: null, + buildKind: null, + runtimeMode: null, + hasBuild: true, + startCommand: "npm start", + webhookId: 17, + webhookDomain: "hooks.example.com", + autoDeploy: true, + }); + h.creates = []; + h.groupCreates = []; + h.siblings = []; + }); + + it("persists the complete source and freezes image/prebuilt/docker as one class", async () => { + const releaseSource = { + mode: "github", + artifactKind: "image", + repo: "acme/release-app", + imageTemplate: "ghcr.io/acme/release-app:{tag}", + pinnedVersion: "v3.4.5", + trackReleases: true, + }; + const { createProject } = await load(); + + await createProject( + { + name: "Release App", + gitProvider: "release", + releaseSource, + framework: "node", + workloadType: "web", + port: 8080, + } as never, + "org_1", + ); + + expect(h.groupCreates).toHaveLength(1); + expect(h.creates).toHaveLength(1); + expect(h.creates[0]).toMatchObject({ + gitProvider: "release", + releaseSource, + hasBuild: false, + sourceKind: "image", + buildKind: "prebuilt", + runtimeMode: "docker", + workloadType: "web", + hasServer: true, + }); + expect(h.creates[0]?.gitOwner).toBeUndefined(); + expect(h.creates[0]?.gitRepo).toBeUndefined(); + expect(h.creates[0]?.gitUrl).toBeUndefined(); + expect(h.creates[0]?.localPath).toBeUndefined(); + }); + + it("normalizes only the supported release-source contract", async () => { + const { createProject } = await load(); + + await createProject( + { + name: "Normalized Release App", + gitProvider: "release", + releaseSource: { + mode: "github", + artifactKind: "image", + repo: " acme/release-app ", + imageTemplate: " ghcr.io/acme/release-app:{tag} ", + pinnedVersion: " v3.4.5 ", + trackReleases: false, + unrecognizedInternalField: "must-not-persist", + }, + framework: "node", + workloadType: "web", + } as never, + "org_1", + ); + + expect(h.creates[0]?.releaseSource).toEqual({ + mode: "github", + artifactKind: "image", + repo: "acme/release-app", + imageTemplate: "ghcr.io/acme/release-app:{tag}", + pinnedVersion: "v3.4.5", + trackReleases: false, + }); + }); + + it("rejects an unsafe external version source before writing any rows", async () => { + const { createProject } = await load(); + + await expect( + createProject( + { + name: "Unsafe Release App", + gitProvider: "release", + releaseSource: { + mode: "url", + artifactKind: "image", + versionUrl: "http://metadata.internal/latest", + imageTemplate: "registry.example.com/acme/app:{tag}", + }, + framework: "node", + workloadType: "web", + port: 8080, + } as never, + "org_1", + ), + ).rejects.toThrow(/must use HTTPS/); + + expect(h.groupCreates).toHaveLength(0); + expect(h.creates).toHaveLength(0); + }); + + it("rejects a pinned tag that cannot produce a valid image reference", async () => { + const { createProject } = await load(); + + await expect( + createProject( + { + name: "Invalid Release App", + gitProvider: "release", + releaseSource: { + mode: "url", + artifactKind: "image", + pinnedVersion: "release/1.2.3", + imageTemplate: "registry.example.com/acme/app:{tag}", + }, + framework: "node", + workloadType: "web", + port: 8080, + } as never, + "org_1", + ), + ).rejects.toThrow(/invalid/); + + expect(h.groupCreates).toHaveLength(0); + expect(h.creates).toHaveLength(0); + }); + + it("rejects a services-class framework before writing any rows", async () => { + const { createProject } = await load(); + + await expect( + createProject( + { + name: "Compose Release App", + gitProvider: "release", + releaseSource: { + mode: "github", + artifactKind: "image", + repo: "acme/release-app", + imageTemplate: "ghcr.io/acme/release-app:{tag}", + }, + framework: "docker-compose", + workloadType: "web", + port: 8080, + } as never, + "org_1", + ), + ).rejects.toThrow(/individual services/); + + expect(h.groupCreates).toHaveLength(0); + expect(h.creates).toHaveLength(0); + }); }); diff --git a/apps/api/src/modules/projects/project-rename.test.ts b/apps/api/src/modules/projects/project-rename.test.ts index cb2e8e86d..61341a21a 100644 --- a/apps/api/src/modules/projects/project-rename.test.ts +++ b/apps/api/src/modules/projects/project-rename.test.ts @@ -28,7 +28,28 @@ const h = vi.hoisted(() => ({ name: "Next Server Info", slug: "next-server-info", environmentSlug: "production", + environmentName: "Production", internalAlias: null as string | null, + gitProvider: "github", + framework: "node", + gitOwner: "acme" as string | null, + gitRepo: "old-app" as string | null, + gitBranch: "main", + gitUrl: "https://github.com/acme/old-app.git" as string | null, + installationId: 91 as number | null, + localPath: "/srv/old-app" as string | null, + releaseSource: null as Record | null, + sourceKind: null as string | null, + buildKind: null as string | null, + workloadType: "web", + hasServer: true, + hasBuild: true, + runtimeMode: null as string | null, + startCommand: "npm start" as string | null, + composePath: "compose.yml" as string | null, + webhookId: 77 as number | null, + webhookDomain: "hooks.example.com" as string | null, + autoDeploy: true, activeDeploymentId: null as string | null, serverId: null as string | null, resources: null, @@ -40,6 +61,11 @@ const h = vi.hoisted(() => ({ groupUpdates: [] as Array>, routeSyncs: [] as Array>, liveRouteReapplies: 0, + sourceUpdates: [] as Array<{ + groupId: string; + projectFields: Record; + groupFields: Record; + }>, })); vi.mock("@repo/db", () => ({ @@ -50,6 +76,14 @@ vi.mock("@repo/db", () => ({ h.projectUpdates.push(patch); Object.assign(h.project, patch); }, + updateSourceByApp: async ( + groupId: string, + projectFields: Record, + groupFields: Record, + ) => { + h.sourceUpdates.push({ groupId, projectFields, groupFields }); + Object.assign(h.project, projectFields); + }, findBySlugInOrg: async (_org: string, slug: string) => h.bySlug[slug] ?? null, listByGroup: async () => [{ ...h.project }], }, @@ -102,7 +136,7 @@ vi.mock("../github/github.service", () => ({ resolveDefaultBranch: async () => "main", listBranches: async () => [], getLatestCommit: async () => null, - resolveWebhookStrategy: async () => ({}), + resolveWebhookStrategy: async () => "none", })); vi.mock("../github/github.auth", () => ({ getInstallationIdByOrg: async () => undefined, @@ -132,6 +166,29 @@ describe("project rename — the slug is immutable", () => { h.groupUpdates = []; h.routeSyncs = []; h.liveRouteReapplies = 0; + h.sourceUpdates = []; + Object.assign(h.project, { + gitProvider: "github", + framework: "node", + gitOwner: "acme", + gitRepo: "old-app", + gitBranch: "main", + gitUrl: "https://github.com/acme/old-app.git", + installationId: 91, + localPath: "/srv/old-app", + releaseSource: null, + sourceKind: null, + buildKind: null, + workloadType: "web", + hasServer: true, + hasBuild: true, + runtimeMode: null, + startCommand: "npm start", + composePath: "compose.yml", + webhookId: 77, + webhookDomain: "hooks.example.com", + autoDeploy: true, + }); }); it("writes the new name and leaves the slug alone", async () => { @@ -189,3 +246,221 @@ describe("project rename — the slug is immutable", () => { expect(h.projectUpdates.at(0)?.name).toBe("Next Server Info Staging"); }); }); + +describe("project source transitions", () => { + beforeEach(() => { + h.projectUpdates = []; + h.sourceUpdates = []; + Object.assign(h.project, { + gitProvider: "github", + gitOwner: "acme", + gitRepo: "old-app", + gitBranch: "main", + gitUrl: "https://github.com/acme/old-app.git", + installationId: 91, + localPath: "/srv/old-app", + releaseSource: null, + sourceKind: null, + buildKind: null, + workloadType: "web", + hasServer: true, + hasBuild: true, + runtimeMode: null, + startCommand: "npm start", + composePath: null, + webhookId: 77, + webhookDomain: "hooks.example.com", + autoDeploy: true, + }); + }); + + it("sets the full release source and clears stale Git/local/process identity atomically", async () => { + const releaseSource = { + mode: "github", + artifactKind: "image", + repo: "acme/release-app", + imageTemplate: "ghcr.io/acme/release-app:{tag}", + pinnedVersion: "v2.3.4", + trackReleases: true, + }; + const { setProjectReleaseImageSource } = await load(); + + await setProjectReleaseImageSource("proj_1", "org_1", releaseSource as never); + + expect(h.sourceUpdates).toHaveLength(1); + expect(h.sourceUpdates[0]).toEqual({ + groupId: "grp_1", + projectFields: { + gitProvider: "release", + gitOwner: null, + gitRepo: null, + gitUrl: null, + installationId: null, + localPath: null, + releaseSource, + sourceKind: "image", + buildKind: "prebuilt", + hasBuild: false, + runtimeMode: "docker", + startCommand: null, + composePath: null, + webhookId: null, + webhookDomain: null, + autoDeploy: false, + }, + groupFields: { + gitProvider: "release", + gitOwner: null, + gitRepo: null, + gitUrl: null, + installationId: null, + }, + }); + }); + + it("preserves intentional command overrides when editing an existing image source", async () => { + const previousSource = { + mode: "github", + artifactKind: "image", + repo: "acme/release-app", + imageTemplate: "ghcr.io/acme/release-app:{tag}", + pinnedVersion: "v2.3.4", + }; + Object.assign(h.project, { + gitProvider: "release", + releaseSource: previousSource, + sourceKind: "image", + buildKind: "prebuilt", + hasBuild: false, + runtimeMode: "docker", + startCommand: "./serve --foreground", + }); + const nextSource = { ...previousSource, pinnedVersion: "v2.4.0" }; + const { setProjectReleaseImageSource } = await load(); + + await setProjectReleaseImageSource("proj_1", "org_1", nextSource as never); + + expect(h.sourceUpdates).toHaveLength(1); + expect(h.sourceUpdates[0]?.projectFields.releaseSource).toEqual(nextSource); + expect(h.sourceUpdates[0]?.projectFields).not.toHaveProperty("startCommand"); + }); + + it("rejects a services-class framework before changing source identity", async () => { + Object.assign(h.project, { framework: "docker-compose", composePath: null }); + const { setProjectReleaseImageSource } = await load(); + + await expect( + setProjectReleaseImageSource("proj_1", "org_1", { + mode: "github", + artifactKind: "image", + repo: "acme/release-app", + imageTemplate: "ghcr.io/acme/release-app:{tag}", + } as never), + ).rejects.toThrow(/multiple services/); + + expect(h.sourceUpdates).toHaveLength(0); + }); + + it("does not let generic PATCH mutate releaseSource", async () => { + const { updateProject } = await load(); + const attempted = { + mode: "github", + artifactKind: "image", + repo: "evil/repoint", + imageTemplate: "ghcr.io/evil/repoint:{tag}", + pinnedVersion: "v9.9.9", + }; + + await updateProject("proj_1", { releaseSource: attempted } as never, "org_1"); + + expect(h.project.releaseSource).toBeNull(); + expect(h.projectUpdates).toHaveLength(1); + expect(h.projectUpdates[0]).not.toHaveProperty("releaseSource"); + }); + + it("linking Git clears the release-image class before adopting the repository", async () => { + Object.assign(h.project, { + gitProvider: "release", + gitOwner: null, + gitRepo: null, + gitUrl: null, + installationId: null, + localPath: null, + releaseSource: { + mode: "github", + artifactKind: "image", + repo: "acme/release-app", + imageTemplate: "ghcr.io/acme/release-app:{tag}", + pinnedVersion: "v2.3.4", + }, + sourceKind: "image", + buildKind: "prebuilt", + hasBuild: false, + runtimeMode: "docker", + startCommand: "/app/run-release", + }); + const { linkProjectRepo } = await load(); + + await expect( + linkProjectRepo({ userId: "user_1", organizationId: "org_1" } as never, "proj_1", { + owner: "acme", + repo: "source-app", + branch: "main", + }), + ).resolves.toMatchObject({ ok: true, owner: "acme", repo: "source-app", branch: "main" }); + + expect(h.sourceUpdates).toHaveLength(1); + expect(h.sourceUpdates[0]?.projectFields).toMatchObject({ + gitProvider: "github", + gitOwner: "acme", + gitRepo: "source-app", + gitUrl: "https://github.com/acme/source-app.git", + releaseSource: null, + localPath: null, + sourceKind: null, + buildKind: null, + hasBuild: true, + runtimeMode: null, + startCommand: null, + webhookId: null, + autoDeploy: false, + }); + expect(h.sourceUpdates[0]?.projectFields).not.toHaveProperty("webhookDomain"); + expect(h.projectUpdates).toContainEqual({ gitBranch: "main" }); + }); + + it("does not reset build/runtime settings when relinking an ordinary project", async () => { + Object.assign(h.project, { + gitProvider: "github", + localPath: null, + releaseSource: null, + sourceKind: "git", + buildKind: "dockerfile", + hasBuild: false, + runtimeMode: "docker", + startCommand: "./custom-start", + }); + const { linkProjectRepo } = await load(); + + await linkProjectRepo({ userId: "user_1", organizationId: "org_1" } as never, "proj_1", { + owner: "acme", + repo: "new-source", + branch: "main", + }); + + expect(h.sourceUpdates).toHaveLength(1); + const fields = h.sourceUpdates[0]!.projectFields; + expect(fields).toMatchObject({ + gitProvider: "github", + gitOwner: "acme", + gitRepo: "new-source", + releaseSource: null, + localPath: null, + sourceKind: null, + }); + expect(fields).not.toHaveProperty("buildKind"); + expect(fields).not.toHaveProperty("hasBuild"); + expect(fields).not.toHaveProperty("runtimeMode"); + expect(fields).not.toHaveProperty("startCommand"); + }); +}); diff --git a/apps/api/src/modules/projects/project-runtime.service.ts b/apps/api/src/modules/projects/project-runtime.service.ts index 53569ab3e..0d0dfa94e 100644 --- a/apps/api/src/modules/projects/project-runtime.service.ts +++ b/apps/api/src/modules/projects/project-runtime.service.ts @@ -3,7 +3,7 @@ */ import { repos } from "@repo/db"; -import { AppError, NotFoundError, ValidationError } from "@repo/core"; +import { AppError, NotFoundError, ValidationError, safeErrorMessage } from "@repo/core"; import { checkEdge, edgeProxy } from "@repo/adapters"; import type { LogEntry, ImportedSite, RuntimeAdapter } from "@repo/adapters"; import { @@ -15,6 +15,7 @@ import { import { isAbsent, isAlreadyInState } from "../../lib/remote-state"; import { assertNotControlPlane, assertResourceInOrg } from "../../lib/controller-helpers"; import { syncManagedEdgeRoutes, edgeUnsyncedWarning } from "../../lib/managed-edge-proxy"; +import { reconcileServerEdge } from "../../lib/edge-reconcile"; import { resolveManagedHostname } from "../../lib/routing-domains"; import { sshManager } from "../../lib/ssh-manager"; import { applyProjectRouting } from "../domains/routing-apply.service"; @@ -297,13 +298,16 @@ async function startOne(runtime: RuntimeAdapter, containerId: string): Promise {}); const serverId = p.serverId ?? (dep?.meta as { serverId?: string } | null)?.serverId ?? undefined; + + // Route application reaches into openship-edge. Reconcile it BEFORE any route + // read/write so a stopped or missing container is revived rather than leaving + // docker exec/config reload calls to sit until the request timeout (#693). + const edgeRecoveryWarning = await recoverProjectEdge(p, dep); + if (edgeRecoveryWarning) { + const fresh = p.activeDeploymentId ? await repos.deployment.findById(p.activeDeploymentId) : null; + await markRoutingWarning(fresh, edgeRecoveryWarning).catch(() => {}); + return { ok: false, warning: edgeRecoveryWarning }; + } + await restoreCustomPortsFromEdge(p, serverId).catch(() => {}); // Live re-apply is best-effort, but its failure must NOT clear the warning. @@ -378,6 +393,41 @@ export async function retryProjectRouting( return { ok: true }; } +/** + * Ensure the edge serving this project's domains exists and is healthy before + * retry touches its vhosts. Uses deployment-platform resolution so the same + * repair works for this host and for an SSH target server. + */ +async function recoverProjectEdge( + project: NonNullable>>, + dep: Awaited> | null, +): Promise { + if (!dep) return null; + const domains = await repos.domain.listByProject(project.id).catch(() => []); + if (domains.length === 0) return null; + + try { + return await withDeploymentPlatform(dep, async ({ executor, effectiveTarget }) => { + if (effectiveTarget === "cloud") return null; + if (!executor) { + return "Couldn't retry routing because the deployment target has no host executor."; + } + + const recovery = await reconcileServerEdge(executor, { onLog: () => {} }); + if (recovery.error) { + return `Couldn't restore the edge before retrying routing: ${recovery.error}`; + } + + const status = await checkEdge(executor); + return status.healthy + ? null + : `Couldn't restore the edge before retrying routing: ${status.message}`; + }); + } catch (err) { + return `Couldn't restore the edge before retrying routing: ${safeErrorMessage(err)}`; + } +} + /** * "Are this project's routes actually being served?" — null when yes (or when the * question doesn't apply), else the operator-facing reason. @@ -548,5 +598,3 @@ async function markRoutingWarning( meta.deployWarning = warning; await repos.deployment.updateStatus(dep.id, dep.status, { meta }); } - - diff --git a/apps/api/src/modules/projects/project-teardown.record-only.test.ts b/apps/api/src/modules/projects/project-teardown.record-only.test.ts index 3cc71591a..c408b2f4d 100644 --- a/apps/api/src/modules/projects/project-teardown.record-only.test.ts +++ b/apps/api/src/modules/projects/project-teardown.record-only.test.ts @@ -53,10 +53,12 @@ const h = vi.hoisted(() => ({ clearDeletionInProgress: vi.fn(async () => {}), listByGroup: vi.fn(async () => [{ id: "p1" }]), softDeleteGroup: vi.fn(async () => {}), - orphanCreate: vi.fn(async () => {}), + orphanCreate: vi.fn(async (_row: Record) => ({ id: "orphan-created" })), + orphanDelete: vi.fn(async () => {}), collectProjectManifest: vi.fn(async () => ({ projectId: "p1", resources: [] })), executeCleanup: vi.fn(async () => ({ total: 0, succeeded: 0, failed: [] })), + disposeManifestRuntimes: vi.fn(), removeProjectFromServerManifests: vi.fn(async () => {}), cancelBuildSession: vi.fn(async () => ({ success: true })), deleteGitHubWebhook: vi.fn(async () => {}), @@ -80,7 +82,7 @@ vi.mock("@repo/db", () => ({ }, backupRun: { listInFlightByProject: vi.fn(async () => []) }, backupRestore: { listInFlightByProject: vi.fn(async () => []) }, - orphanedResource: { create: h.orphanCreate }, + orphanedResource: { create: h.orphanCreate, delete: h.orphanDelete }, projectConnection: { listBySource: vi.fn(async () => h.consumers) }, }, })); @@ -92,6 +94,7 @@ vi.mock("./project-connection.service", () => ({ vi.mock("./project-cleanup.service", () => ({ collectProjectManifest: h.collectProjectManifest, executeCleanup: h.executeCleanup, + disposeManifestRuntimes: h.disposeManifestRuntimes, })); vi.mock("../../lib/openship-manifest-sync", () => ({ removeProjectFromServerManifests: h.removeProjectFromServerManifests, @@ -325,3 +328,166 @@ describe("teardownProject — record-only delete touches nothing on the server", expect(stepOf(res.steps, "runtime_cleanup")?.status).toBe("ok"); }); }); + +describe("teardownProject — deferred multi-target cleanup", () => { + it("records every known route on an unreachable historical target", async () => { + h.collectProjectManifest.mockResolvedValueOnce({ + projectId: "p1", + organizationId: "org1", + resources: [ + { + type: "unreachable", + ref: "container-remote", + label: "remote container", + runtime: null, + serverId: "server-old", + runtimeMode: "docker", + }, + { + type: "route", + ref: "app.example.com", + label: "route app.example.com", + runtime: null, + }, + ], + routeContexts: [], + unreachableRouteTargets: [{ serverId: "server-old", runtimeMode: "docker" }], + } as never); + h.executeCleanup.mockResolvedValueOnce({ total: 1, succeeded: 1, failed: [] }); + + const res = await teardownProject(ctx, "p1", { force: false }); + + expect(res.rowDeleted).toBe(true); + expect(h.orphanCreate).toHaveBeenCalledWith( + expect.objectContaining({ + serverId: "server-old", + resourceType: "container", + ref: "container-remote", + runtimeMode: "docker", + }), + ); + expect(h.orphanCreate).toHaveBeenCalledWith( + expect.objectContaining({ + serverId: "server-old", + resourceType: "route", + ref: "app.example.com", + runtimeMode: "docker", + }), + ); + }); + + it("force-orphans each resource on its own historical target", async () => { + h.collectProjectManifest.mockResolvedValueOnce({ + projectId: "p1", + organizationId: "org1", + resources: [ + { + type: "container", + ref: "container-a", + label: "container a", + runtime: { name: "docker" }, + serverId: "server-a", + runtimeMode: "docker", + }, + { + type: "artifact", + ref: "/srv/releases/b", + label: "artifact b", + runtime: { name: "bare" }, + serverId: "server-b", + runtimeMode: "bare", + }, + { + type: "route", + ref: "app.example.com", + label: "route app.example.com", + runtime: null, + }, + ], + routeContexts: [ + { + key: "host-c", + serverId: "server-c", + runtimeMode: "docker", + routing: {}, + hostPortTarget: {}, + edgeProxy: {}, + }, + ], + unreachableRouteTargets: [{ serverId: "server-d", runtimeMode: "bare" }], + } as never); + + const res = await teardownProject(ctx, "p1", { force: false, forceOrphan: true }); + + expect(res.rowDeleted).toBe(true); + const created = h.orphanCreate.mock.calls.map(([row]) => row); + expect(created).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ref: "container-a", + serverId: "server-a", + runtimeMode: "docker", + }), + expect.objectContaining({ + ref: "/srv/releases/b", + serverId: "server-b", + runtimeMode: "bare", + }), + expect.objectContaining({ + resourceType: "route", + ref: "app.example.com", + serverId: "server-c", + }), + expect.objectContaining({ + resourceType: "route", + ref: "app.example.com", + serverId: "server-d", + }), + ]), + ); + expect(h.disposeManifestRuntimes).toHaveBeenCalledOnce(); + expect(h.executeCleanup).not.toHaveBeenCalled(); + }); + + it("keeps the project row when durable orphan tracking fails", async () => { + h.collectProjectManifest.mockResolvedValueOnce({ + projectId: "p1", + organizationId: "org1", + resources: [ + { + type: "unreachable", + ref: "container-remote", + label: "remote container", + runtime: null, + serverId: "server-old", + runtimeMode: "docker", + }, + { + type: "route", + ref: "app.example.com", + label: "route app.example.com", + runtime: null, + }, + ], + routeContexts: [], + unreachableRouteTargets: [{ serverId: "server-old", runtimeMode: "docker" }], + } as never); + h.executeCleanup.mockResolvedValueOnce({ total: 1, succeeded: 1, failed: [] }); + h.orphanCreate + .mockResolvedValueOnce({ id: "tracked-before-failure" }) + .mockRejectedValueOnce(new Error("database unavailable")); + + const res = await teardownProject(ctx, "p1", { force: false }); + + expect(res.rowDeleted).toBe(false); + expect(stepOf(res.steps, "persist_orphans")).toEqual( + expect.objectContaining({ + status: "failed", + error: expect.stringContaining("database unavailable"), + }), + ); + expect(h.deleteHard).not.toHaveBeenCalled(); + expect(h.orphanDelete).toHaveBeenCalledWith("tracked-before-failure"); + expect(h.clearDeletionInProgress).toHaveBeenCalledWith("p1"); + }); +}); diff --git a/apps/api/src/modules/projects/project-teardown.ts b/apps/api/src/modules/projects/project-teardown.ts index e5b7a884a..1c15ebff7 100644 --- a/apps/api/src/modules/projects/project-teardown.ts +++ b/apps/api/src/modules/projects/project-teardown.ts @@ -199,9 +199,7 @@ export async function getActiveProjectState(projectId: string): Promise r.id), blocking: parts.length > 0, summary: - parts.length === 0 - ? "No active work" - : `Cannot delete while in-flight: ${parts.join(", ")}`, + parts.length === 0 ? "No active work" : `Cannot delete while in-flight: ${parts.join(", ")}`, }; } @@ -412,7 +410,32 @@ export async function teardownProject( // sweep can still find + reclaim them after the project row (their only // record) is gone. Only happens on the row-dropping path: a kept row keeps // the resources tracked via the project itself, so no orphan record needed. - orphaned = await persistOrphans(ctx.organizationId, projectId, orphanCandidates); + try { + orphaned = await persistOrphans(ctx.organizationId, projectId, orphanCandidates); + if (orphanCandidates.length > 0) { + push({ + step: "persist_orphans", + status: "ok", + details: `${orphaned.length} resource(s) queued for deferred cleanup`, + }); + } + } catch (err) { + // Dropping the project without a durable retry record would turn a + // reachable-later vhost/workload into an untracked permanent orphan. + // Keep the row and deletion lock semantics intact so the whole operation + // can be retried safely. + push({ + step: "persist_orphans", + status: "failed", + error: safeErrorMessage(err), + }); + push({ + step: "delete_db_row", + status: "skipped", + details: "kept: deferred cleanup resources could not be recorded", + }); + return finalize(steps, false); + } } // ── Step 4b: unlink this app from every project it was wired into. ─── @@ -629,12 +652,7 @@ async function stepDeleteWebhook( return; } try { - await deleteGitHubWebhook( - ctx, - project.gitOwner, - project.gitRepo, - project.webhookId, - ); + await deleteGitHubWebhook(ctx, project.gitOwner, project.gitRepo, project.webhookId); push({ step: "github_webhook", status: "ok", details: `hook ${project.webhookId}` }); } catch (err) { // GitHub returns 404 when the hook is already gone — treat as a @@ -677,6 +695,18 @@ async function stepRuntimeCleanup( push: (s: TeardownStep) => void, ): Promise { const orphans: OrphanCandidate[] = []; + const orphanKeys = new Set(); + const addOrphan = (candidate: OrphanCandidate) => { + const key = [ + candidate.serverId ?? "", + candidate.runtimeMode ?? "", + candidate.resourceType, + candidate.ref, + ].join("\0"); + if (orphanKeys.has(key)) return; + orphanKeys.add(key); + orphans.push(candidate); + }; let manifest; try { manifest = await collectProjectManifest(project, { wipeVolumes }); @@ -689,7 +719,7 @@ async function stepRuntimeCleanup( return orphans; } - if (manifest.resources.length === 0) { + if (manifest.resources.length === 0 && (manifest.routeContexts?.length ?? 0) === 0) { push({ step: "runtime_cleanup", status: "skipped", details: "no resources" }); return orphans; } @@ -702,20 +732,37 @@ async function stepRuntimeCleanup( const destroyable = manifest.resources.filter((r) => r.type !== "unreachable"); for (const r of unreachable) { - orphans.push({ + addOrphan({ serverId: r.serverId ?? null, - resourceType: "container", + resourceType: r.runtimeMode === "cloud" ? "cloud_workspace" : "container", ref: r.ref, label: r.label, runtimeMode: r.runtimeMode ?? null, }); } + // An unreachable historical target has no live routing/edge adapter to put in + // `routeContexts`, but its vhost and detached claims still exist. Record the + // exact server now; GC will resolve that target, remove every known hostname, + // and only then run a fresh claim convergence when the host returns. + const routeResources = destroyable.filter((resource) => resource.type === "route"); + for (const target of manifest.unreachableRouteTargets ?? []) { + for (const route of routeResources) { + addOrphan({ + serverId: target.serverId, + resourceType: "route", + ref: route.ref, + label: `${route.label} (server:${target.serverId})`, + runtimeMode: target.runtimeMode, + }); + } + } + const orphanNote = unreachable.length ? `; ${unreachable.length} orphaned (server unreachable)` : ""; - if (destroyable.length === 0) { + if (destroyable.length === 0 && (manifest.routeContexts?.length ?? 0) === 0) { // Nothing reachable to destroy — only unreachable orphans. The delete // proceeds (row drops); GC reclaims the orphans later. push({ @@ -729,18 +776,55 @@ async function stepRuntimeCleanup( // Force-orphan short-circuit: the operator chose "delete from storage anyway", // so DON'T attempt the inline SSH destroy at all (that's the call that can hang // ~80s on a slow/failing runtime and is why the escape felt stuck). Record - // every reachable resource as an orphan for the GC sweep and let the row drop - // now. Reachable manifest items don't carry their own serverId/runtimeMode, so - // stamp them with the project's primary target (same as the post-failure path). + // every reachable resource on its collected historical target for the GC sweep + // and let the row drop now. The latest-deployment fallback exists only for an + // older/test manifest that predates per-resource target identity. if (forceOrphan) { - const target = await resolvePrimaryTarget(project.id); + const hasRouteTargets = + (manifest.routeContexts?.length ?? 0) > 0 || + (manifest.unreachableRouteTargets?.length ?? 0) > 0; + const needsLegacyTarget = destroyable.some( + (resource) => !resource.runtimeMode && (resource.type !== "route" || !hasRouteTargets), + ); + const legacyTarget = needsLegacyTarget ? await resolvePrimaryTarget(project.id) : null; for (const r of destroyable) { - orphans.push({ - serverId: r.serverId ?? target.serverId, + if (r.type === "route") { + // A migrated project can have the same vhost on several physical + // targets. Record one retry per target; collapsing them onto the latest + // server would let GC declare the other copies reclaimed without ever + // touching them. + for (const routeTarget of manifest.routeContexts ?? []) { + addOrphan({ + serverId: routeTarget.serverId, + resourceType: "route", + ref: r.ref, + label: `${r.label} (${routeTarget.key})`, + runtimeMode: routeTarget.runtimeMode, + }); + } + // Unreachable targets were fanned out above. Only a legacy manifest with + // neither target list needs the old latest-target fallback. + if ( + (manifest.routeContexts?.length ?? 0) > 0 || + (manifest.unreachableRouteTargets?.length ?? 0) > 0 + ) { + continue; + } + } + addOrphan({ + serverId: r.serverId ?? legacyTarget?.serverId ?? null, resourceType: r.type === "unreachable" ? "container" : r.type, ref: r.ref, label: r.label, - runtimeMode: r.runtimeMode ?? target.runtimeMode, + runtimeMode: + r.runtimeMode ?? + (r.runtime?.name === "cloud" + ? "cloud" + : r.runtime?.name === "bare" + ? "bare" + : r.runtime?.name === "docker" + ? "docker" + : (legacyTarget?.runtimeMode ?? null)), }); } push({ @@ -763,6 +847,9 @@ async function stepRuntimeCleanup( projectId: manifest.projectId, organizationId: manifest.organizationId, resources: destroyable, + runtimes: manifest.runtimes, + routeContexts: manifest.routeContexts, + unreachableRouteTargets: manifest.unreachableRouteTargets, }); const realFailures = result.failed; const details = @@ -798,17 +885,22 @@ async function resolvePrimaryTarget( return { serverId: meta.serverId ?? null, runtimeMode: meta.runtimeMode ?? null }; } -/** Persist orphan candidates so the GC sweep can reclaim them after the project - * row is gone. Best-effort per row — a failed insert is logged, not fatal. */ +/** Persist every orphan candidate before the project row may disappear. + * + * This is logically all-or-nothing: if one insert fails, best-effort roll back + * the rows created by this attempt and reject the teardown. The GC also refuses + * to touch any orphan whose project row still exists, so even an unsuccessful + * rollback cannot reclaim a live project's resources. */ async function persistOrphans( organizationId: string, projectId: string, candidates: OrphanCandidate[], ): Promise { const out: OrphanedResourceSummary[] = []; + const createdIds: string[] = []; for (const c of candidates) { try { - await repos.orphanedResource.create({ + const created = await repos.orphanedResource.create({ organizationId, serverId: c.serverId, resourceType: c.resourceType, @@ -817,9 +909,11 @@ async function persistOrphans( label: c.label, runtimeMode: c.runtimeMode, }); + if (created?.id) createdIds.push(created.id); out.push({ ref: c.ref, label: c.label, serverId: c.serverId }); } catch (err) { - console.error(`[teardown] failed to record orphan ${c.ref}:`, safeErrorMessage(err)); + await Promise.allSettled(createdIds.map((id) => repos.orphanedResource.delete(id))); + throw new Error(`Failed to record deferred cleanup for ${c.label}: ${safeErrorMessage(err)}`); } } return out; diff --git a/apps/api/src/modules/projects/project-toggle.test.ts b/apps/api/src/modules/projects/project-toggle.test.ts index af0f9714c..33aac450d 100644 --- a/apps/api/src/modules/projects/project-toggle.test.ts +++ b/apps/api/src/modules/projects/project-toggle.test.ts @@ -140,7 +140,12 @@ vi.mock("../../lib/deployment-runtime", () => ({ }, withDeploymentPlatform: async ( _dep: unknown, - fn: (resolved: { routing: unknown; serverId: string | null }) => Promise, + fn: (resolved: { + routing: unknown; + executor: { exec: (command: string) => Promise<{ stdout: string; stderr: string; code: number }> }; + effectiveTarget: "server"; + serverId: string | null; + }) => Promise, ) => { try { return await fn({ @@ -150,6 +155,8 @@ vi.mock("../../lib/deployment-runtime", () => ({ await h.removeRoute(hostname); }, }, + executor: { exec: async () => ({ stdout: "", stderr: "", code: 0 }) }, + effectiveTarget: "server", serverId: "srv_1", }); } finally { @@ -176,6 +183,13 @@ vi.mock("../../lib/managed-edge-proxy", () => ({ syncManagedEdgeRoutes: async () => ({ failures: [] }), edgeUnsyncedWarning: () => "", })); +vi.mock("../../lib/edge-reconcile", () => ({ + reconcileServerEdge: async () => ({ + converted: false, + updated: false, + edgeDown: false, + }), +})); vi.mock("../../lib/routing-domains", () => ({ resolveManagedHostname: () => ({ isManaged: false }) })); vi.mock("../../lib/ssh-manager", () => ({ sshManager: { diff --git a/apps/api/src/modules/projects/project.controller.ts b/apps/api/src/modules/projects/project.controller.ts index 4888d489d..5ca3f51da 100644 --- a/apps/api/src/modules/projects/project.controller.ts +++ b/apps/api/src/modules/projects/project.controller.ts @@ -15,17 +15,12 @@ import { serviceKind } from "../../lib/deployable-service"; import { reconcileProjectRoutes } from "../../lib/route-apply.service"; import { compileProjectRoutingFields } from "../../lib/project-routing-fields"; import { - isLoopbackHost, isReservedLoopbackPort, pickCanonicalDomainRow, pickPrimaryServiceId, resolveProjectAccess, } from "../../lib/public-endpoints"; -import { - buildUpstreamUrl, - resolveLiveUpstreamUrl, - resolveRouteStrategy, -} from "../../lib/upstream-url"; +import { resolveLiveUpstreamUrl, resolveRouteStrategy } from "../../lib/upstream-url"; import { getRequestContext } from "../../lib/request-context"; import type { RequestContext } from "../../lib/request-context"; import { permission } from "../../lib/permission"; @@ -44,6 +39,7 @@ import type { TUpdateProjectBody, TMergeEnvVarsBody, TUpdateResourcesBody, + TSetReleaseSourceBody, } from "./project.schema"; import { stat } from "node:fs/promises"; import { repos, type Domain, type Project } from "@repo/db"; @@ -81,15 +77,15 @@ import { import { getInstallUrl } from "../github/github.auth"; import { ensureSharedWebhook } from "./project-git-webhook"; import { listProjectRouteRows, resolveProjectRouteState } from "../domains/project-route.service"; +import { + loopbackHostPortFromUrl, + observedLoopbackPublishFromUrl, +} from "../deployments/observed-host-port-claims"; // Track which servers have had Lua scripts deployed this session const luaDeployedServers = new Set(); -function logEnsureProjectError( - userId: string, - body: TEnsureProjectBody, - err: unknown, -) { +function logEnsureProjectError(userId: string, body: TEnsureProjectBody, err: unknown) { console.error("[PROJECT] Failed to ensure project", { userId, projectId: body.projectId, @@ -152,7 +148,6 @@ export async function ensure(c: Context) { // ─── Projects CRUD ─────────────────────────────────────────────────────────── - /** * Project ids a scoped token is allowed to SEE, or null when the caller is not * a scoped token (no filtering — normal role visibility applies). For an "own @@ -181,14 +176,17 @@ export async function getHome(c: Context) { // visible projects (prevents the common confusion of "I deployed // something but it doesn't show up" when the session active org is // a freshly-created empty team org). - let result: { rows: Awaited>["rows"]; total: number }; + let result: { + rows: Awaited>["rows"]; + total: number; + }; try { result = await projectService.listProjects(organizationId, { page: 1, // Scoped tokens own few projects but they may sit anywhere in the org's // set, so widen the fetch before filtering to the owned ids below. perPage: scopedIds ? 1000 : 100, - }); + }); } catch (err) { // Migrations not yet applied — PGlite first-boot case. Return an // explicit empty payload with no other-org hints (we can't query @@ -212,7 +210,12 @@ export async function getHome(c: Context) { return c.json({ success: true, projects: [], - numbers: { total_projects: 0, total_active_projects: 0, total_deployments: 0, total_success_deployments: 0 }, + numbers: { + total_projects: 0, + total_active_projects: 0, + total_deployments: 0, + total_success_deployments: 0, + }, otherOrgs: [], }); } @@ -231,16 +234,21 @@ export async function getHome(c: Context) { // reconnect" client-side from `deployTarget === 'cloud'` + // CloudContext.connected — no duplicate server-side flag. const projectIds = result.rows.map((p) => p.id); - const [enrichedProjectsResolved, latestByProject, primariesByProject, servicesByProject, deployStats] = - await Promise.all([ - projectService.enrichProjectsBatch(result.rows), - repos.deployment.findLatestByProjects(projectIds), - repos.domain.getPrimariesByProjects(projectIds), - repos.service.listByProjects(projectIds), - // Real Activity-card counts (was hardcoded 0). Scoped to the visible - // project ids, so scoped tokens only see their own deployments. - repos.deployment.statsByProjects(projectIds), - ]); + const [ + enrichedProjectsResolved, + latestByProject, + primariesByProject, + servicesByProject, + deployStats, + ] = await Promise.all([ + projectService.enrichProjectsBatch(result.rows), + repos.deployment.findLatestByProjects(projectIds), + repos.domain.getPrimariesByProjects(projectIds), + repos.service.listByProjects(projectIds), + // Real Activity-card counts (was hardcoded 0). Scoped to the visible + // project ids, so scoped tokens only see their own deployments. + repos.deployment.statsByProjects(projectIds), + ]); const projects = enrichedProjectsResolved.map((enriched, idx) => { const original = result.rows[idx]; @@ -279,9 +287,7 @@ export async function getHome(c: Context) { // Batch lookup names + project counts. Names come from one // findManyById; counts still go through projectService per org // (each is a SELECT COUNT — fine at N < 20 memberships). - const orgs = await repos.organization - .findManyById(otherOrgIds) - .catch(() => []); + const orgs = await repos.organization.findManyById(otherOrgIds).catch(() => []); const orgsById = new Map(orgs.map((o) => [o.id, o])); otherOrgs = await Promise.all( otherOrgIds.map(async (otherOrgId) => { @@ -422,7 +428,11 @@ export async function getById(c: Context) { const ctx = getRequestContext(c); const { userId, organizationId } = ctx; const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "read" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "read", + }); const project = await projectService.getProject(id, organizationId); refreshProjectFaviconIfStale(project); return c.json({ data: project }); @@ -434,7 +444,11 @@ export async function listEnvironments(c: Context) { const ctx = getRequestContext(c); const { userId, organizationId } = ctx; const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "read" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "read", + }); const data = await projectService.listProjectEnvironments(id, organizationId); return c.json({ success: true, data }); } @@ -443,7 +457,11 @@ export async function createEnvironment(c: Context) { const ctx = getRequestContext(c); const { userId, organizationId } = ctx; const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "write" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "write", + }); const body = await c.req.json(); if (!body.environmentName?.trim()) { @@ -476,7 +494,11 @@ export async function update(c: Context) { const ctx = getRequestContext(c); const { userId, organizationId } = ctx; const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "write" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "write", + }); const body = await c.req.json(); const project = await projectService.updateProject(id, body, organizationId); audit.recordAsync(auditContextFrom(c, organizationId, userId), { @@ -513,7 +535,11 @@ export async function remove(c: Context) { const ctx = getRequestContext(c); const { userId, organizationId } = ctx; const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "admin" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "admin", + }); const force = c.req.query("force") === "true"; // Orphan-and-drop even when a resource on a REACHABLE server won't destroy @@ -734,7 +760,11 @@ export async function deletionPreview(c: Context) { const ctx = getRequestContext(c); const { organizationId } = ctx; const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "read" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "read", + }); const { repos } = await import("@repo/db"); const project = await repos.project.findById(id); try { @@ -752,7 +782,11 @@ export async function listEnvVars(c: Context) { const ctx = getRequestContext(c); const { userId, organizationId } = ctx; const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "read" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "read", + }); const environment = c.req.query("environment"); const vars = await projectService.listEnvVars(id, organizationId, environment); return c.json({ data: vars }); @@ -762,7 +796,11 @@ export async function mergeEnvVars(c: Context) { const ctx = getRequestContext(c); const { userId, organizationId } = ctx; const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "write" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "write", + }); const body = await c.req.json(); const result = await projectService.mergeEnvVars(id, organizationId, body); audit.recordAsync(auditContextFrom(c, organizationId, userId), { @@ -784,7 +822,11 @@ export async function mergeEnvVars(c: Context) { export async function getResources(c: Context) { const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "read" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "read", + }); // org AFTER assert (cross-org rebind safety — see enable/disable). const { organizationId } = getRequestContext(c); const resources = await projectService.getResources(id, organizationId); @@ -795,7 +837,11 @@ export async function getResources(c: Context) { * measured snapshot size and the host's free disk, for the rollback label. */ export async function getRollbackCapacity(c: Context) { const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "read" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "read", + }); const { organizationId } = getRequestContext(c); const { getRollbackCapacity: read } = await import("./rollback-capacity.service"); return c.json({ data: await read(id, organizationId) }); @@ -825,7 +871,11 @@ export async function outputCheck(c: Context) { export async function updateResources(c: Context) { const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "write" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "write", + }); // org AFTER assert (cross-org rebind safety — see enable/disable). const { userId, organizationId } = getRequestContext(c); const body = await c.req.json(); @@ -855,7 +905,11 @@ export async function getCloneToken(c: Context) { const ctx = getRequestContext(c); const { userId, organizationId } = ctx; const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "read" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "read", + }); const project = await projectService.getProject(id, organizationId); return c.json({ hasToken: !!project.cloneTokenEncrypted, @@ -879,7 +933,11 @@ export async function updateCloneToken(c: Context) { const ctx = getRequestContext(c); const { userId, organizationId } = ctx; const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "admin" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "admin", + }); const body = await c.req.json().catch(() => ({})); const rawToken = body?.token; @@ -984,7 +1042,7 @@ export async function listLocal(c: Context) { const result = await projectService.listProjects(organizationId, { page: 1, perPage: scopedIds ? 1000 : 100, - }); + }); let localProjects = result.rows.filter((p) => p.gitProvider === "local"); // Scoped-token isolation: only the projects this token may see. if (scopedIds) localProjects = localProjects.filter((p) => scopedIds.has(p.id)); @@ -1003,7 +1061,11 @@ export async function runtimeLogs(c: Context) { const ctx = getRequestContext(c); const { userId, organizationId } = ctx; const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "read" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "read", + }); const tail = c.req.query("tail") ? Number(c.req.query("tail")) : undefined; try { @@ -1022,7 +1084,11 @@ export async function runtimeLogStream(c: Context) { const ctx = getRequestContext(c); const { userId, organizationId } = ctx; const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "read" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "read", + }); const tail = c.req.query("tail") ? Number(c.req.query("tail")) : undefined; return streamSSE(c, async (sseStream) => { @@ -1080,10 +1146,8 @@ function extractCloudStreamToken(result: unknown): { stream_url: string; token: let node: unknown = result; for (let depth = 0; depth < 4 && node && typeof node === "object"; depth++) { const obj = node as Record; - const streamUrl = - obj.stream_url ?? obj.streamUrl ?? obj.url ?? obj.sse_url ?? obj.endpoint; - const token = - obj.token ?? obj.stream_token ?? obj.streamToken ?? obj.access_token ?? obj.jwt; + const streamUrl = obj.stream_url ?? obj.streamUrl ?? obj.url ?? obj.sse_url ?? obj.endpoint; + const token = obj.token ?? obj.stream_token ?? obj.streamToken ?? obj.access_token ?? obj.jwt; if (typeof streamUrl === "string" && typeof token === "string") { return { stream_url: streamUrl, token }; } @@ -1119,7 +1183,11 @@ export async function serverLogStreamToken(c: Context) { const ctx = getRequestContext(c); const { userId, organizationId } = ctx; const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "read" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "read", + }); const project = await repos.project.findById(id); try { @@ -1184,7 +1252,11 @@ export async function serverLogStream(c: Context) { const ctx = getRequestContext(c); const { organizationId } = ctx; const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "read" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "read", + }); const project = await repos.project.findById(id); try { @@ -1268,7 +1340,11 @@ export async function recentServerLogs(c: Context) { const ctx = getRequestContext(c); const { organizationId } = ctx; const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "read" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "read", + }); const project = await repos.project.findById(id); try { @@ -1283,7 +1359,10 @@ export async function recentServerLogs(c: Context) { // tracked domain — same plural fan-out as getAnalyticsOverview — so a multi-route // project's recent-log view is never empty just because the primary happens to be idle. const requested = c.req.query("domain"); - const sources = await resolveProjectTrafficSources(id, requested ? { domain: requested } : undefined); + const sources = await resolveProjectTrafficSources( + id, + requested ? { domain: requested } : undefined, + ); if (sources.length === 0) { return c.json({ logs: [] }); } @@ -1358,7 +1437,11 @@ export async function getGitInfo(c: Context) { const userId = ctx.userId; const organizationId = ctx.organizationId; const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "read" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "read", + }); const info = await projectService.getGitInfo(id, organizationId); // No repo linked yet — the normal state for upload/local projects, not a @@ -1421,7 +1504,11 @@ export async function listBranches(c: Context) { const ctx = getRequestContext(c); const { userId, organizationId } = ctx; const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "read" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "read", + }); const info = await projectService.getGitInfo(id, organizationId); if (!info.gitOwner || !info.gitRepo) { @@ -1446,7 +1533,11 @@ export async function listBranches(c: Context) { */ export async function linkRepo(c: Context) { const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "write" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "write", + }); // ctx AFTER assert — resource-scoped org for cross-org callers; a stale org makes // linkProjectRepo's assertResourceInOrg throw 404 and mis-attributes the audit // record to the session org (see setSleepMode). @@ -1458,7 +1549,12 @@ export async function linkRepo(c: Context) { installationId?: number; }>(); - const result = await projectService.linkProjectRepo(ctx, id, { owner, repo, branch, installationId }); + const result = await projectService.linkProjectRepo(ctx, id, { + owner, + repo, + branch, + installationId, + }); if (!result.ok) { if (result.code === "not_found") return c.json({ error: "Project not found" }, 404); @@ -1500,6 +1596,47 @@ export async function linkRepo(c: Context) { }); } +/** PUT /projects/:id/release-image-source — complete source transition, never + * a partial generic project patch. */ +export async function setReleaseImageSource(c: Context) { + const id = param(c, "id"); + const ctx = getRequestContext(c); + await permission.assert(ctx, { resourceType: "project", resourceId: id, action: "write" }); + const before = await repos.project.findById(id); + const body = await c.req.json(); + const project = await projectService.setProjectReleaseImageSource(id, ctx.organizationId, body); + + // The transition clears this group's push automation. If nobody else in the + // organization uses the shared repo hook, disable it remotely as cleanup. + if (before?.gitOwner && before.gitRepo && before.autoDeploy) { + await disableSharedWebhookIfUnused( + ctx, + ctx.organizationId, + before.gitOwner, + before.gitRepo, + before.webhookId, + ).catch(() => {}); + } + + audit.recordAsync(auditContextFrom(c, ctx.organizationId, ctx.userId), { + eventType: "project.updated", + resourceType: "project", + resourceId: id, + before: { + gitProvider: before?.gitProvider ?? null, + gitOwner: before?.gitOwner ?? null, + gitRepo: before?.gitRepo ?? null, + }, + after: { + action: "release-image-source.set", + gitProvider: project.gitProvider, + releaseSource: project.releaseSource, + }, + }); + + return c.json({ data: project }); +} + async function disableSharedWebhookIfUnused( ctx: RequestContext, organizationId: string, @@ -1524,7 +1661,11 @@ export async function setAutoDeploy(c: Context) { const userId = ctx.userId; const organizationId = ctx.organizationId; const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "write" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "write", + }); const { enabled } = await c.req.json<{ enabled: boolean }>(); const project = await repos.project.findById(id); try { @@ -1563,7 +1704,7 @@ export async function setAutoDeploy(c: Context) { // User has a verified domain - direct webhook delivery if (enabled) { // strategy === "domain" ⟹ webhookDomain is set (resolveWebhookStrategy). - const webhookUrl = domainWebhookUrl(project.webhookDomain!); + const webhookUrl = domainWebhookUrl(project.webhookDomain!); const webhookId = await ensureSharedWebhook(ctx, project, owner, repo, webhookUrl); if (!webhookId) { return c.json( @@ -1577,7 +1718,13 @@ export async function setAutoDeploy(c: Context) { await repos.project.update(id, { autoDeploy: true }); } else { await repos.project.update(id, { autoDeploy: false }); - await disableSharedWebhookIfUnused(ctx, project.organizationId, owner, repo, project.webhookId); + await disableSharedWebhookIfUnused( + ctx, + project.organizationId, + owner, + repo, + project.webhookId, + ); } } else if (enabled) { // "repo" strategy - manage repo-level webhooks @@ -1595,7 +1742,13 @@ export async function setAutoDeploy(c: Context) { } else { // Disable this environment. Keep the repo webhook while sibling environments still use it. await repos.project.update(id, { autoDeploy: false }); - await disableSharedWebhookIfUnused(ctx, project.organizationId, owner, repo, project.webhookId); + await disableSharedWebhookIfUnused( + ctx, + project.organizationId, + owner, + repo, + project.webhookId, + ); } } catch (err) { const msg = safeErrorMessage(err); @@ -1681,7 +1834,11 @@ export async function setWebhookDomain(c: Context) { const ctx = getRequestContext(c); const { userId, organizationId } = ctx; const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "write" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "write", + }); const { domain: hostname } = await c.req.json<{ domain: string | null }>(); const project = await repos.project.findById(id); @@ -1787,40 +1944,40 @@ async function reRegisterDomainRoute( domainRows, ); const primarySvc = - svcDeps.find((s) => s.serviceId === primaryId && (s.containerId || s.ip)) ?? - svcDeps.find((s) => s.containerId) ?? - svcDeps.find((s) => s.ip); - if (!primarySvc) return; + svcDeps.find((s) => s.serviceId === primaryId && s.containerId) ?? + svcDeps.find((s) => s.containerId); + if (!primarySvc?.containerId) return; // The port the app LISTENS on. `hostPort` is a publish, not a container port, // so it must not stand in for one — resolveLiveUpstreamUrl derives the host // side itself. const containerPort = project.port ?? 3000; const strategy = resolveRouteStrategy(project.routeStrategy); - const stored = { ip: primarySvc.ip, hostPort: primarySvc.hostPort }; + const stored = { + ip: primarySvc.ip, + hostPort: primarySvc.hostPort, + hostPorts: primarySvc.hostPorts, + }; - let runtime: RuntimeAdapter | undefined; - if (primarySvc.containerId) { - try { - ({ runtime } = await resolveDeploymentRuntimeForRead(dep)); - } catch (err) { - console.warn( - `[Webhook Domain] could not resolve runtime for ${hostname}, using stored row: ${safeErrorMessage(err)}`, - ); - } + let runtime: RuntimeAdapter; + try { + ({ runtime } = await resolveDeploymentRuntimeForRead(dep)); + } catch (err) { + console.warn( + `[Webhook Domain] could not resolve the live runtime for ${hostname}; leaving its route unchanged: ${safeErrorMessage(err)}`, + ); + return; } - let targetUrl: string | null; + let targetUrl: string | null = null; try { - targetUrl = - runtime && primarySvc.containerId - ? await resolveLiveUpstreamUrl({ - strategy, - runtime, - containerId: primarySvc.containerId, - containerPort, - stored, - }) - : buildUpstreamUrl({ strategy, ...stored, containerPort }); + targetUrl = await resolveLiveUpstreamUrl({ + strategy, + runtime, + containerId: primarySvc.containerId, + containerPort, + stored, + requireLiveObservation: true, + }); } finally { await runtime?.dispose?.().catch(() => {}); } @@ -1831,11 +1988,17 @@ async function reRegisterDomainRoute( // 9145) — a member with a verified domain could otherwise proxy their vhost // straight at an internal service. Mirrors resolveTargetUrl in // project-route.service.ts. - const upstream = targetUrl.match(/^https?:\/\/([^:/]+):(\d+)$/); - const upstreamPort = upstream ? Number(upstream[2]) : undefined; - if (upstream && isLoopbackHost(upstream[1]) && isReservedLoopbackPort(Number(upstream[2]))) { + let upstreamPort: number | undefined; + try { + const parsed = new URL(targetUrl); + upstreamPort = parsed.port ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80; + } catch { + return; + } + const loopbackPort = loopbackHostPortFromUrl(targetUrl); + if (loopbackPort && isReservedLoopbackPort(loopbackPort)) { console.warn( - `[Webhook Domain] refusing reserved loopback upstream port ${upstream[2]} for ${hostname}`, + `[Webhook Domain] refusing reserved loopback upstream port ${loopbackPort} for ${hostname}`, ); return; } @@ -1853,6 +2016,14 @@ async function reRegisterDomainRoute( port: upstreamPort ?? containerPort, isCustomDomain: false, webhook: enableWebhook, + ...(() => { + const observed = observedLoopbackPublishFromUrl({ + targetUrl, + serviceId: primarySvc.serviceId, + containerPort, + }); + return observed ? { observedLoopbackPublishes: [observed] } : {}; + })(), }, ], }); @@ -1865,7 +2036,11 @@ export async function setBranch(c: Context) { const ctx = getRequestContext(c); const { userId, organizationId } = ctx; const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "write" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "write", + }); const { branch } = await c.req.json<{ branch: string }>(); if (!branch) return c.json({ error: "branch is required" }, 400); const result = await projectService.setBranch(id, branch, organizationId); @@ -1884,7 +2059,11 @@ export async function setOptions(c: Context) { const ctx = getRequestContext(c); const { userId, organizationId } = ctx; const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "write" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "write", + }); const body = await c.req.json>(); const result = await projectService.updateOptions(id, body, organizationId); audit.recordAsync(auditContextFrom(c, organizationId, userId), { @@ -1909,7 +2088,11 @@ export async function setOptions(c: Context) { */ export async function getCommitStatus(c: Context) { const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "read" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "read", + }); // ctx AFTER assert — resource-scoped org for cross-org callers; a stale org makes // getProjectDrift's assertResourceInOrg throw 404 (see setSleepMode). const ctx = getRequestContext(c); @@ -1926,7 +2109,11 @@ export async function getCommitStatus(c: Context) { */ export async function getPendingActions(c: Context) { const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "read" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "read", + }); // ctx AFTER assert: it rebinds organizationId to the resource's org for cross-org // (grant/admin) access. Read before, getProjectPendingActions would get the stale // session org and drop every item on the org check → []. Same rule as setSleepMode. @@ -1940,7 +2127,11 @@ export async function getPendingActions(c: Context) { export async function setSleepMode(c: Context) { const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "write" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "write", + }); // Read AFTER assert: permission.assert rebinds ctx.organizationId to the // resource's org for cross-org access (admin/grant). Capturing it before // would pass the stale session-active org → wrong-org 404 for multi-org @@ -1962,7 +2153,11 @@ export async function setSleepMode(c: Context) { export async function enable(c: Context) { const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "write" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "write", + }); // Read org AFTER assert — it rebinds ctx to the resource's org for // cross-org access; the pre-assert value would be the stale active org. const { userId, organizationId } = getRequestContext(c); @@ -1982,7 +2177,11 @@ export async function enable(c: Context) { export async function disable(c: Context) { const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "write" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "write", + }); // Read org AFTER assert — it rebinds ctx to the resource's org for // cross-org access; the pre-assert value would be the stale active org. const { userId, organizationId } = getRequestContext(c); @@ -2002,7 +2201,11 @@ export async function disable(c: Context) { * (200, ok:false) when it still can't sync so the UI re-surfaces guidance. */ export async function retryRouting(c: Context) { const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "write" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "write", + }); const { userId, organizationId } = getRequestContext(c); try { const result = await projectService.retryProjectRouting(id, organizationId); @@ -2027,7 +2230,11 @@ export async function listDeployments(c: Context) { const ctx = getRequestContext(c); const { userId, organizationId } = ctx; const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "read" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "read", + }); const page = Number(c.req.query("page") ?? 1); const perPage = Number(c.req.query("perPage") ?? 20); const environment = c.req.query("environment") ?? undefined; @@ -2051,7 +2258,11 @@ export async function deploymentSession(c: Context) { const ctx = getRequestContext(c); const { userId, organizationId } = ctx; const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "read" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "read", + }); const result = await projectService.getLatestDeploymentSession(id, organizationId); return c.json(result); } @@ -2062,7 +2273,11 @@ export async function getInfo(c: Context) { const ctx = getRequestContext(c); const { userId, organizationId } = ctx; const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "read" }); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "read", + }); const project = await projectService.getProject(id, organizationId); const environments = await projectService.listProjectEnvironments(id, organizationId); // The LATEST deployment, for the blocked-deploy flag. `getProject` resolves the @@ -2159,7 +2374,10 @@ export async function getInfo(c: Context) { // project pays none of it. const webhookState = project.gitOwner && project.gitRepo - ? await projectService.resolveProjectWebhookState(organizationId, { ...project, deployTarget }) + ? await projectService.resolveProjectWebhookState(organizationId, { + ...project, + deployTarget, + }) : null; return c.json({ @@ -2201,8 +2419,16 @@ export async function connectDomain(c: Context) { const ctx = getRequestContext(c); const { userId, organizationId } = ctx; const id = param(c, "id"); - await permission.assert(getRequestContext(c), { resourceType: "project", resourceId: id, action: "write" }); - const body = await c.req.json<{ domain: string; includeWww?: boolean; externalIngress?: boolean }>(); + await permission.assert(getRequestContext(c), { + resourceType: "project", + resourceId: id, + action: "write", + }); + const body = await c.req.json<{ + domain: string; + includeWww?: boolean; + externalIngress?: boolean; + }>(); if (!body.domain?.trim()) { return c.json({ success: false, error: "Domain is required" }, 400); diff --git a/apps/api/src/modules/projects/project.routes.ts b/apps/api/src/modules/projects/project.routes.ts index 04ea622a9..d583d3527 100644 --- a/apps/api/src/modules/projects/project.routes.ts +++ b/apps/api/src/modules/projects/project.routes.ts @@ -33,6 +33,7 @@ import { MergeEnvVarsBody, UpdateResourcesBody, LinkRepoBody, + SetReleaseSourceBody, SetAutoDeployBody, SetBranchBody, SetSleepModeBody, @@ -68,8 +69,16 @@ r.get("/:id/edge-config", { tag: "project:read", localOnly: true }, edgeConfig.g /* ─── Route rules (self-hosted OpenResty edge: rate-limit · ban · allow/deny) ── */ r.get("/:id/route-rules", { tag: "project:read", localOnly: true }, routeRules.listRouteRules); r.post("/:id/route-rules", { tag: "project:write", localOnly: true }, routeRules.createRouteRule); -r.patch("/:id/route-rules/:ruleId", { tag: "project:write", localOnly: true }, routeRules.updateRouteRule); -r.delete("/:id/route-rules/:ruleId", { tag: "project:write", localOnly: true }, routeRules.deleteRouteRule); +r.patch( + "/:id/route-rules/:ruleId", + { tag: "project:write", localOnly: true }, + routeRules.updateRouteRule, +); +r.delete( + "/:id/route-rules/:ruleId", + { tag: "project:write", localOnly: true }, + routeRules.deleteRouteRule, +); /* ─── Health (container incidents recorded by the health watch) ─────────── */ r.get( @@ -133,7 +142,8 @@ r.post( { tag: "project:write", collection: true, localOnly: true }, bodyLimit({ maxSize: 300_000_000, - onError: (c) => c.json({ error: "Upload exceeds the 300MB limit.", code: "PAYLOAD_TOO_LARGE" }, 413), + onError: (c) => + c.json({ error: "Upload exceeds the 300MB limit.", code: "PAYLOAD_TOO_LARGE" }, 413), }), folder.uploadRelay, ); @@ -155,11 +165,7 @@ r.post( }, ctrl.ensure, ); -r.get( - "/", - { tag: "project:list", mcp: { description: "List projects in the org." } }, - ctrl.list, -); +r.get("/", { tag: "project:list", mcp: { description: "List projects in the org." } }, ctrl.list); r.post( "/", { @@ -178,7 +184,10 @@ r.post( /* ─── Projects CRUD ────────────────────────────────────────────────────── */ r.get( "/:id", - { tag: "project:read", mcp: { description: "Get a project by id — config, source, routes, status." } }, + { + tag: "project:read", + mcp: { description: "Get a project by id — config, source, routes, status." }, + }, cloudProjectProxy, ctrl.getById, ); @@ -193,8 +202,24 @@ r.patch( ctrl.update, ); r.delete("/:id", { tag: "project:admin" }, cloudProjectProxy, ctrl.remove); -r.get("/:id/info", { tag: "project:read", mcp: { description: "Get a project's detailed info (runtime, build, source)." } }, cloudProjectProxy, ctrl.getInfo); -r.get("/:id/environments", { tag: "project:read", mcp: { description: "List a project's environments (production / previews)." } }, cloudProjectProxy, ctrl.listEnvironments); +r.get( + "/:id/info", + { + tag: "project:read", + mcp: { description: "Get a project's detailed info (runtime, build, source)." }, + }, + cloudProjectProxy, + ctrl.getInfo, +); +r.get( + "/:id/environments", + { + tag: "project:read", + mcp: { description: "List a project's environments (production / previews)." }, + }, + cloudProjectProxy, + ctrl.listEnvironments, +); r.post( "/:id/environments", { @@ -205,26 +230,111 @@ r.post( cloudProjectProxy, ctrl.createEnvironment, ); -r.get("/:id/deletion-preview", { tag: "project:read", mcp: { description: "Preview what deleting this project would remove (read-only)." } }, cloudProjectProxy, ctrl.deletionPreview); +r.get( + "/:id/deletion-preview", + { + tag: "project:read", + mcp: { description: "Preview what deleting this project would remove (read-only)." }, + }, + cloudProjectProxy, + ctrl.deletionPreview, +); /* ─── Build options ────────────────────────────────────────────────────── */ -r.post("/:id/options", { tag: "project:write", body: SetOptionsBody, mcp: { description: "Set build/deploy options for a project." } }, cloudProjectProxy, ctrl.setOptions); -r.post("/:id/port-check", { tag: "project:read", readOnly: true, mcp: { description: "Live port-reachability check for the project's active deployment (advisory)." } }, cloudProjectProxy, ctrl.portCheck); -r.post("/:id/output-check", { tag: "project:read", readOnly: true, mcp: { description: "Live static-output check for the project's active deployment (advisory; static apps)." } }, cloudProjectProxy, ctrl.outputCheck); +r.post( + "/:id/options", + { + tag: "project:write", + body: SetOptionsBody, + mcp: { description: "Set build/deploy options for a project." }, + }, + cloudProjectProxy, + ctrl.setOptions, +); +r.post( + "/:id/port-check", + { + tag: "project:read", + readOnly: true, + mcp: { + description: "Live port-reachability check for the project's active deployment (advisory).", + }, + }, + cloudProjectProxy, + ctrl.portCheck, +); +r.post( + "/:id/output-check", + { + tag: "project:read", + readOnly: true, + mcp: { + description: + "Live static-output check for the project's active deployment (advisory; static apps).", + }, + }, + cloudProjectProxy, + ctrl.outputCheck, +); /* ─── Enable / Disable ─────────────────────────────────────────────────── */ -r.post("/:id/enable", { tag: "project:write", mcp: { description: "Enable a project (allow deploys / bring online)." } }, cloudProjectProxy, ctrl.enable); -r.post("/:id/disable", { tag: "project:write", mcp: { description: "Disable a project (pause deploys / take offline)." } }, cloudProjectProxy, ctrl.disable); +r.post( + "/:id/enable", + { + tag: "project:write", + mcp: { description: "Enable a project (allow deploys / bring online)." }, + }, + cloudProjectProxy, + ctrl.enable, +); +r.post( + "/:id/disable", + { + tag: "project:write", + mcp: { description: "Disable a project (pause deploys / take offline)." }, + }, + cloudProjectProxy, + ctrl.disable, +); /* ─── Retry free-domain edge routing (no rebuild) ──────────────────────── */ -r.post("/:id/routing/retry", { tag: "project:write", mcp: { description: "Retry syncing the project's free .opsh.io edge route (no rebuild); clears the routing 'Action Required' warning on success." } }, cloudProjectProxy, ctrl.retryRouting); +r.post( + "/:id/routing/retry", + { + tag: "project:write", + mcp: { + description: + "Retry syncing the project's free .opsh.io edge route (no rebuild); clears the routing 'Action Required' warning on success.", + }, + }, + cloudProjectProxy, + ctrl.retryRouting, +); /* ─── Set up / take over the self-hosted edge (OpenResty on 80/443) + apply the project's routes, WITHOUT a container redeploy. SSE so the port-80/443 takeover consent can be prompted mid-flight (answered via .../respond). ── */ -r.get("/:id/routing/edge-status", { tag: "project:read", mcp: { description: "Check whether the project's server edge (OpenResty on 80/443) is already set up." } }, ensureEdgeCtrl.edgeStatus); -r.post("/:id/routing/ensure-edge/stream", { tag: "project:write" }, ensureEdgeCtrl.ensureEdgeStream); -r.post("/:id/routing/ensure-edge/respond", { tag: "project:write" }, ensureEdgeCtrl.ensureEdgeRespond); +r.get( + "/:id/routing/edge-status", + { + tag: "project:read", + mcp: { + description: + "Check whether the project's server edge (OpenResty on 80/443) is already set up.", + }, + }, + ensureEdgeCtrl.edgeStatus, +); +r.post( + "/:id/routing/ensure-edge/stream", + { tag: "project:write" }, + ensureEdgeCtrl.ensureEdgeStream, +); +r.post( + "/:id/routing/ensure-edge/respond", + { tag: "project:write" }, + ensureEdgeCtrl.ensureEdgeRespond, +); /* ─── Environment variables ────────────────────────────────────────────── */ // Project-scoped bulk routes (no per-env_var id in the URL) → gate on the @@ -233,7 +343,15 @@ r.post("/:id/routing/ensure-edge/respond", { tag: "project:write" }, ensureEdgeC // project:env_var:* tags required a :envVarId param these routes don't have, // so the permission middleware 400'd before the handler. Secret VALUES stay // protected by masking in listEnvVars, not by the route tag. -r.get("/:id/env", { tag: "project:read", mcp: { description: "List a project's environment variables (secret values masked)." } }, cloudProjectProxy, ctrl.listEnvVars); +r.get( + "/:id/env", + { + tag: "project:read", + mcp: { description: "List a project's environment variables (secret values masked)." }, + }, + cloudProjectProxy, + ctrl.listEnvVars, +); // Project env edits go through the MERGE path (PATCH) only — the old destructive // full-replace PUT was removed (it could wipe/corrupt masked secrets and had no // remaining caller; the editor sends a diff via mergeEnvVars). @@ -244,7 +362,9 @@ r.patch( // Validated by the auto-wired tbValidator (spec.body) → a wrong-shape body // is a 400, not a 500 in the service's data.upserts.map. #231 body: MergeEnvVarsBody, - mcp: { description: "Merge env var changes (upserts + deletes); untouched vars are preserved." }, + mcp: { + description: "Merge env var changes (upserts + deletes); untouched vars are preserved.", + }, }, cloudProjectProxy, ctrl.mergeEnvVars, @@ -255,8 +375,21 @@ r.get("/:id/clone-token", { tag: "project:read" }, cloudProjectProxy, ctrl.getCl r.patch("/:id/clone-token", { tag: "project:admin" }, cloudProjectProxy, ctrl.updateCloneToken); /* ─── Git ──────────────────────────────────────────────────────────────── */ -r.get("/:id/git", { tag: "project:read", mcp: { description: "Get the project's linked git repository info." } }, cloudProjectProxy, ctrl.getGitInfo); -r.get("/:id/commit-status", { tag: "project:read", mcp: { description: "Compare the deployed commit against the remote HEAD." } }, cloudProjectProxy, ctrl.getCommitStatus); +r.get( + "/:id/git", + { tag: "project:read", mcp: { description: "Get the project's linked git repository info." } }, + cloudProjectProxy, + ctrl.getGitInfo, +); +r.get( + "/:id/commit-status", + { + tag: "project:read", + mcp: { description: "Compare the deployed commit against the remote HEAD." }, + }, + cloudProjectProxy, + ctrl.getCommitStatus, +); /* ─── Pending actions (everything waiting on a human) ───────────────────── */ r.get( @@ -271,23 +404,133 @@ r.get( cloudProjectProxy, ctrl.getPendingActions, ); -r.post("/:id/git/link", { tag: "project:write", body: LinkRepoBody, mcp: { description: "Link a git repository to the project." } }, cloudProjectProxy, ctrl.linkRepo); -r.get("/:id/branches", { tag: "project:read", mcp: { description: "List the linked repository's branches." } }, cloudProjectProxy, ctrl.listBranches); -r.post("/:id/auto-deploy", { tag: "project:write", body: SetAutoDeployBody, mcp: { description: "Enable/disable auto-deploy on push." } }, cloudProjectProxy, ctrl.setAutoDeploy); +r.post( + "/:id/git/link", + { + tag: "project:write", + body: LinkRepoBody, + mcp: { description: "Link a git repository to the project." }, + }, + cloudProjectProxy, + ctrl.linkRepo, +); +r.put( + "/:id/release-image-source", + { + tag: "project:write", + body: SetReleaseSourceBody, + mcp: { + description: + "Atomically configure this single-app project to track and deploy a prebuilt container image from GitHub releases or a version URL.", + }, + }, + cloudProjectProxy, + ctrl.setReleaseImageSource, +); +r.get( + "/:id/branches", + { tag: "project:read", mcp: { description: "List the linked repository's branches." } }, + cloudProjectProxy, + ctrl.listBranches, +); +r.post( + "/:id/auto-deploy", + { + tag: "project:write", + body: SetAutoDeployBody, + mcp: { description: "Enable/disable auto-deploy on push." }, + }, + cloudProjectProxy, + ctrl.setAutoDeploy, +); r.post("/:id/webhook-domain", { tag: "project:write" }, cloudProjectProxy, ctrl.setWebhookDomain); -r.post("/:id/branch", { tag: "project:write", body: SetBranchBody, mcp: { description: "Set the project's deploy branch." } }, cloudProjectProxy, ctrl.setBranch); +r.post( + "/:id/branch", + { + tag: "project:write", + body: SetBranchBody, + mcp: { description: "Set the project's deploy branch." }, + }, + cloudProjectProxy, + ctrl.setBranch, +); /* ─── Incoming webhooks (generic per-project trigger hooks) ─────────────── */ -r.get("/:id/incoming-webhooks", { tag: "project:read", mcp: { description: "List a project's incoming webhooks (dynamic trigger URLs)." } }, cloudProjectProxy, incomingWebhooks.list); -r.post("/:id/incoming-webhooks", { tag: "project:write", body: CreateIncomingWebhookBody, mcp: { description: "Create an incoming webhook that fires a deploy or job when its URL is called." } }, cloudProjectProxy, incomingWebhooks.create); -r.patch("/:id/incoming-webhooks/:hookId", { tag: "project:write", body: UpdateIncomingWebhookBody, mcp: { description: "Update an incoming webhook (name/enabled/action/auth)." } }, cloudProjectProxy, incomingWebhooks.update); -r.post("/:id/incoming-webhooks/:hookId/rotate", { tag: "project:write", mcp: { description: "Rotate an incoming webhook's token / HMAC secret." } }, cloudProjectProxy, incomingWebhooks.rotate); -r.delete("/:id/incoming-webhooks/:hookId", { tag: "project:write", mcp: { description: "Delete an incoming webhook." } }, cloudProjectProxy, incomingWebhooks.remove); -r.get("/:id/incoming-webhooks/:hookId/deliveries", { tag: "project:read", mcp: { description: "List one incoming webhook's recent deliveries (paginated)." } }, cloudProjectProxy, incomingWebhooks.hookDeliveries); -r.get("/:id/webhook-deliveries", { tag: "project:read", mcp: { description: "List a project's webhook delivery feed — GitHub pushes + custom hooks (paginated)." } }, cloudProjectProxy, incomingWebhooks.deliveries); +r.get( + "/:id/incoming-webhooks", + { + tag: "project:read", + mcp: { description: "List a project's incoming webhooks (dynamic trigger URLs)." }, + }, + cloudProjectProxy, + incomingWebhooks.list, +); +r.post( + "/:id/incoming-webhooks", + { + tag: "project:write", + body: CreateIncomingWebhookBody, + mcp: { + description: "Create an incoming webhook that fires a deploy or job when its URL is called.", + }, + }, + cloudProjectProxy, + incomingWebhooks.create, +); +r.patch( + "/:id/incoming-webhooks/:hookId", + { + tag: "project:write", + body: UpdateIncomingWebhookBody, + mcp: { description: "Update an incoming webhook (name/enabled/action/auth)." }, + }, + cloudProjectProxy, + incomingWebhooks.update, +); +r.post( + "/:id/incoming-webhooks/:hookId/rotate", + { + tag: "project:write", + mcp: { description: "Rotate an incoming webhook's token / HMAC secret." }, + }, + cloudProjectProxy, + incomingWebhooks.rotate, +); +r.delete( + "/:id/incoming-webhooks/:hookId", + { tag: "project:write", mcp: { description: "Delete an incoming webhook." } }, + cloudProjectProxy, + incomingWebhooks.remove, +); +r.get( + "/:id/incoming-webhooks/:hookId/deliveries", + { + tag: "project:read", + mcp: { description: "List one incoming webhook's recent deliveries (paginated)." }, + }, + cloudProjectProxy, + incomingWebhooks.hookDeliveries, +); +r.get( + "/:id/webhook-deliveries", + { + tag: "project:read", + mcp: { + description: + "List a project's webhook delivery feed — GitHub pushes + custom hooks (paginated).", + }, + }, + cloudProjectProxy, + incomingWebhooks.deliveries, +); /* ─── Resources ────────────────────────────────────────────────────────── */ -r.get("/:id/resources", { tag: "project:read", mcp: { description: "Get the project's CPU/RAM/disk resource config." } }, cloudProjectProxy, ctrl.getResources); +r.get( + "/:id/resources", + { tag: "project:read", mcp: { description: "Get the project's CPU/RAM/disk resource config." } }, + cloudProjectProxy, + ctrl.getResources, +); r.get( "/:id/rollback-capacity", { @@ -313,29 +556,77 @@ r.patch( r.post("/:id/resources", { tag: "project:write" }, cloudProjectProxy, ctrl.updateResources); /* ─── Sleep mode ───────────────────────────────────────────────────────── */ -r.post("/:id/sleep-mode", { tag: "project:write", body: SetSleepModeBody, mcp: { description: "Set the project's sleep mode (auto_sleep / always_on)." } }, cloudProjectProxy, ctrl.setSleepMode); +r.post( + "/:id/sleep-mode", + { + tag: "project:write", + body: SetSleepModeBody, + mcp: { description: "Set the project's sleep mode (auto_sleep / always_on)." }, + }, + cloudProjectProxy, + ctrl.setSleepMode, +); /* ─── Deployments ──────────────────────────────────────────────────────── */ -r.get("/:id/deployments", { tag: "project:deployment:list", mcp: { description: "List a project's deployments (history, statuses)." } }, cloudProjectProxy, ctrl.listDeployments); -r.post("/:id/deployment-session", { tag: "project:read", readOnly: true }, cloudProjectProxy, ctrl.deploymentSession); +r.get( + "/:id/deployments", + { + tag: "project:deployment:list", + mcp: { description: "List a project's deployments (history, statuses)." }, + }, + cloudProjectProxy, + ctrl.listDeployments, +); +r.post( + "/:id/deployment-session", + { tag: "project:read", readOnly: true }, + cloudProjectProxy, + ctrl.deploymentSession, +); /* ─── Custom domain ────────────────────────────────────────────────────── */ r.post("/:id/connect", { tag: "project:write" }, cloudProjectProxy, ctrl.connectDomain); /* ─── Runtime logs ─────────────────────────────────────────────────────── */ -r.get("/:id/logs", { tag: "project:read", mcp: { description: "Fetch the project's runtime logs (non-streaming)." } }, cloudProjectProxy, ctrl.runtimeLogs); +r.get( + "/:id/logs", + { + tag: "project:read", + mcp: { description: "Fetch the project's runtime logs (non-streaming)." }, + }, + cloudProjectProxy, + ctrl.runtimeLogs, +); r.get("/:id/logs/stream", { tag: "project:read" }, cloudProjectProxy, ctrl.runtimeLogStream); /* ─── Server HTTP request logs ─────────────────────────────────────────── */ -r.get("/:id/server-logs/recent", { tag: "project:read", mcp: { description: "Fetch recent HTTP request logs for the project." } }, cloudProjectProxy, ctrl.recentServerLogs); -r.get("/:id/server-logs/stream-token", { tag: "project:read" }, cloudProjectProxy, ctrl.serverLogStreamToken); +r.get( + "/:id/server-logs/recent", + { tag: "project:read", mcp: { description: "Fetch recent HTTP request logs for the project." } }, + cloudProjectProxy, + ctrl.recentServerLogs, +); +r.get( + "/:id/server-logs/stream-token", + { tag: "project:read" }, + cloudProjectProxy, + ctrl.serverLogStreamToken, +); r.get("/:id/server-logs/stream", { tag: "project:read" }, cloudProjectProxy, ctrl.serverLogStream); /* ─── Project transfer / promote (local → cloud) ───────────────────────── */ // Self-hosted ONLY: promote pushes a LOCAL project to the SaaS, and bring-home // pulls it back. Meaningless on the SaaS itself (it IS the cloud), so localOnly // 404s them there — never proxied, never run in CLOUD_MODE. -r.post("/:id/transfer/to-cloud", { tag: "project:admin", localOnly: true }, transfer.transferToCloud); -r.post("/:id/transfer/to-self-hosted", { tag: "project:admin", localOnly: true }, transfer.transferToSelfHosted); +r.post( + "/:id/transfer/to-cloud", + { tag: "project:admin", localOnly: true }, + transfer.transferToCloud, +); +r.post( + "/:id/transfer/to-self-hosted", + { tag: "project:admin", localOnly: true }, + transfer.transferToSelfHosted, +); export const projectRoutes = r.hono; diff --git a/apps/api/src/modules/projects/project.schema.test.ts b/apps/api/src/modules/projects/project.schema.test.ts index d301ca883..d61a2712e 100644 --- a/apps/api/src/modules/projects/project.schema.test.ts +++ b/apps/api/src/modules/projects/project.schema.test.ts @@ -66,3 +66,22 @@ describe("publicEndpoints — empty set", () => { expect(ensure(tooMany)).toBe(false); }); }); + +describe("EnsureProjectBody — compose build args (#689)", () => { + it("accepts the prepare response verbatim, including interpolation provenance", () => { + expect( + Value.Check(EnsureProjectBody, { + name: "my-stack", + services: [ + { + name: "api", + build: ".", + dockerfile: "Dockerfile", + buildArgs: { APP_PACKAGE: "@myorg/api", CHANNEL: "${CHANNEL:-stable}" }, + advanced: { buildArgTemplateKeys: ["CHANNEL"] }, + }, + ], + }), + ).toBe(true); + }); +}); diff --git a/apps/api/src/modules/projects/project.schema.ts b/apps/api/src/modules/projects/project.schema.ts index 2326d0148..d9753a0e4 100644 --- a/apps/api/src/modules/projects/project.schema.ts +++ b/apps/api/src/modules/projects/project.schema.ts @@ -104,10 +104,7 @@ const EnvironmentEnum = Type.Union([ Type.Literal("development"), ]); -const EnvironmentSourceModeEnum = Type.Union([ - Type.Literal("branch"), - Type.Literal("manual"), -]); +const EnvironmentSourceModeEnum = Type.Union([Type.Literal("branch"), Type.Literal("manual")]); const PublicEndpointSchema = Type.Object({ port: Type.Optional(Type.Number({ minimum: 1, maximum: 65535 })), @@ -170,6 +167,7 @@ const ComposeServiceSchema = Type.Object({ image: Type.Optional(Type.String({ maxLength: 500 })), build: Type.Optional(Type.String({ maxLength: 500 })), dockerfile: Type.Optional(Type.String({ maxLength: 500 })), + buildArgs: Type.Optional(Type.Record(Type.String(), Type.Union([Type.String(), Type.Null()]))), ports: Type.Optional(Type.Array(Type.String({ maxLength: 100 }), { maxItems: 50 })), dependsOn: Type.Optional(Type.Array(Type.String({ maxLength: 100 }), { maxItems: 50 })), environment: Type.Optional(Type.Record(Type.String(), Type.String())), @@ -178,6 +176,15 @@ const ComposeServiceSchema = Type.Object({ // #332: structured argv passed through from folder/scan (compose Cmd, no `sh -c`). commandArgv: Type.Optional(Type.Array(Type.String({ maxLength: 2000 }), { maxItems: 100 })), restart: Type.Optional(Type.String({ maxLength: 50 })), + advanced: Type.Optional( + Type.Object( + {}, + { + additionalProperties: true, + description: "Extended compose block (including names-only build-arg template provenance).", + }, + ), + ), exposed: Type.Optional(Type.Boolean()), exposedPort: Type.Optional(Type.String({ maxLength: 100 })), domain: Type.Optional(Type.String({ maxLength: 63 })), @@ -289,7 +296,10 @@ const RoutingConfigSchema = Type.Object({ Type.Object({ source: Type.String({ maxLength: 2000 }), headers: Type.Array( - Type.Object({ key: Type.String({ maxLength: 200 }), value: Type.String({ maxLength: 4000 }) }), + Type.Object({ + key: Type.String({ maxLength: 200 }), + value: Type.String({ maxLength: 4000 }), + }), { maxItems: 50 }, ), }), @@ -304,15 +314,17 @@ const RoutingConfigSchema = Type.Object({ }); /** - * Release/dist source config (gitProvider === "release"). A prebuilt dist is - * deployed with no build, version-tracked. `mode: "github"` pulls a release - * asset from a repo; `mode: "url"` pulls an external HTTPS tarball (sha256 - * REQUIRED). Mirrors the `ReleaseSource` type in @repo/core. + * Version-tracked release source (gitProvider === "release"). Legacy/explicit + * archive mode downloads a release dist; image mode renders a registry image + * and runs it without a source build. Mirrors ReleaseSource in @repo/core. */ -const ReleaseSourceSchema = Type.Object({ +export const ReleaseSourceSchema = Type.Object({ mode: Type.Union([Type.Literal("github"), Type.Literal("url")]), + /** Missing means the legacy archive behavior for existing rows. */ + artifactKind: Type.Optional(Type.Union([Type.Literal("archive"), Type.Literal("image")])), repo: Type.Optional(Type.String({ maxLength: 200 })), assetTemplate: Type.Optional(Type.String({ maxLength: 200 })), + imageTemplate: Type.Optional(Type.String({ maxLength: 500 })), os: Type.Optional(Type.String({ maxLength: 32 })), arch: Type.Optional(Type.String({ maxLength: 32 })), distUrl: Type.Optional(Type.String({ maxLength: 2000 })), @@ -324,6 +336,13 @@ const ReleaseSourceSchema = Type.Object({ trackReleases: Type.Optional(Type.Boolean()), }); +/** Full source transition for a single-app prebuilt container release. */ +export const SetReleaseSourceBody = Type.Object({ + ...ReleaseSourceSchema.properties, + artifactKind: Type.Literal("image"), + imageTemplate: Type.String({ minLength: 1, maxLength: 500 }), +}); + export const CreateProjectBody = Type.Object({ name: Type.String({ minLength: 1, maxLength: 100 }), /** Override the auto-generated slug (used as free subdomain: slug.opsh.io) */ @@ -424,7 +443,10 @@ export const CreateProjectBody = Type.Object({ * overlaps an existing service's `rootDirectory`. */ monorepoSharedPaths: Type.Optional( - Type.Union([Type.Null(), Type.Array(Type.String({ minLength: 1, maxLength: 200 }), { maxItems: 50 })]), + Type.Union([ + Type.Null(), + Type.Array(Type.String({ minLength: 1, maxLength: 200 }), { maxItems: 50 }), + ]), ), /** Routing config from the repo's vercel.json (see RoutingConfigSchema). */ routingConfig: Type.Optional(Type.Union([Type.Null(), RoutingConfigSchema])), @@ -445,11 +467,7 @@ export const CreateProjectBody = Type.Object({ * supported on Docker Desktop). Ignored by bare + cloud runtimes. */ routeStrategy: Type.Optional( - Type.Union([ - Type.Literal("auto"), - Type.Literal("loopback-port"), - Type.Literal("container-ip"), - ]), + Type.Union([Type.Literal("auto"), Type.Literal("loopback-port"), Type.Literal("container-ip")]), ), /** * Deploy-time readiness gate. Omitted/null = OFF, which is the default for @@ -510,7 +528,9 @@ export const EnsureProjectBody = Type.Composite([ export const FolderSessionBody = Type.Object( { stack: Type.Optional( - Type.String({ description: "Stack hint (e.g. 'vite','nextjs'); picks the cloud build image." }), + Type.String({ + description: "Stack hint (e.g. 'vite','nextjs'); picks the cloud build image.", + }), ), packageManager: Type.Optional(Type.String({ description: "npm | pnpm | yarn | bun." })), name: Type.Optional(Type.String({ description: "Project name." })), @@ -586,13 +606,19 @@ export const UpdateResourcesBody = Type.Object({ export const LinkRepoBody = Type.Object({ owner: Type.String({ minLength: 1, description: "GitHub repo owner." }), repo: Type.String({ minLength: 1, description: "GitHub repo name." }), - branch: Type.Optional(Type.String({ description: "Deploy branch (defaults to the repo default)." })), - installationId: Type.Optional(Type.Number({ description: "GitHub App installation id, when known." })), + branch: Type.Optional( + Type.String({ description: "Deploy branch (defaults to the repo default)." }), + ), + installationId: Type.Optional( + Type.Number({ description: "GitHub App installation id, when known." }), + ), }); /** POST /:id/auto-deploy — enable/disable auto-deploy on push. */ export const SetAutoDeployBody = Type.Object({ - enabled: Type.Boolean({ description: "Whether a push to the deploy branch triggers a redeploy." }), + enabled: Type.Boolean({ + description: "Whether a push to the deploy branch triggers a redeploy.", + }), }); /** POST /:id/branch — set the deploy branch. */ @@ -658,3 +684,4 @@ export type TUpdateProjectBody = Static & { export type TCreateProjectEnvironmentBody = Static; export type TMergeEnvVarsBody = Static; export type TUpdateResourcesBody = Static; +export type TSetReleaseSourceBody = Static; diff --git a/apps/api/src/modules/projects/project.service.ts b/apps/api/src/modules/projects/project.service.ts index 9a767a5d1..8cf88e975 100644 --- a/apps/api/src/modules/projects/project.service.ts +++ b/apps/api/src/modules/projects/project.service.ts @@ -19,6 +19,7 @@ export { createProject, updateProject, linkProjectRepo, + setProjectReleaseImageSource, getGitInfo, resolveProjectWebhookState, setBranch, diff --git a/apps/api/src/modules/services/service.controller.ts b/apps/api/src/modules/services/service.controller.ts index 88fd0f2a0..c91660b62 100644 --- a/apps/api/src/modules/services/service.controller.ts +++ b/apps/api/src/modules/services/service.controller.ts @@ -9,11 +9,12 @@ */ import type { Context } from "hono"; -import { AppError } from "@repo/core"; +import { AppError, type ComposeAdvanced } from "@repo/core"; import { streamSSE } from "../../lib/sse"; import { param } from "../../lib/controller-helpers"; import { getRequestContext } from "../../lib/request-context"; import { parseRevealKeys, pickRevealed } from "../../lib/env-reveal"; +import { parseOptionalEnvironmentScope } from "../../lib/environment-scope"; import { audit, auditContextFrom } from "../../lib/audit"; import { sshManager } from "../../lib/ssh-manager"; import * as serviceService from "./service.service"; @@ -73,11 +74,16 @@ export async function revealEnv(c: Context) { const serviceId = param(c, "serviceId"); // Outside the try: a 400 from key validation must not be reported as a // reveal failure. Body may be absent on a malformed client call. - const body = await c.req.json<{ keys?: unknown }>().catch(() => ({}) as { keys?: unknown }); + const body = await c.req + .json<{ keys?: unknown; environment?: unknown }>() + .catch(() => ({}) as { keys?: unknown; environment?: unknown }); const keys = parseRevealKeys(body.keys); + const revealEnvironment = parseOptionalEnvironmentScope(body.environment); try { - const stored = await serviceService.revealServiceEnv(ctx, projectId, serviceId); + const stored = revealEnvironment + ? await serviceService.revealServiceEnvVars(ctx, projectId, serviceId, revealEnvironment) + : await serviceService.revealServiceEnv(ctx, projectId, serviceId); const environment = pickRevealed(stored, keys); c.set("auditAfter", { revealedEnvKeys: Object.keys(environment) }); return c.json({ success: true, environment }); @@ -242,6 +248,7 @@ export async function syncFromCompose(c: Context) { image?: string; build?: string; dockerfile?: string; + buildArgs?: Record; ports?: string[]; dependsOn?: string[]; environment?: Record; @@ -250,6 +257,9 @@ export async function syncFromCompose(c: Context) { /** #332: exact argv — no `sh -c`. Wins over the lossy `command` string. */ commandArgv?: string[]; restart?: string; + /** Raw Compose interpolation provenance and the remaining extended + * compose fields accepted by the sync schema. */ + advanced?: ComposeAdvanced; exposed?: boolean; exposedPort?: string; domain?: string; diff --git a/apps/api/src/modules/services/service.routes.ts b/apps/api/src/modules/services/service.routes.ts index 141052d22..3f46ed3c3 100644 --- a/apps/api/src/modules/services/service.routes.ts +++ b/apps/api/src/modules/services/service.routes.ts @@ -84,12 +84,10 @@ r.get( ctrl.getById, ); r.post( - // #336: real (unmasked) compose env for the keys named in the body — never the - // whole map. Write-gated on purpose: read-only callers only ever see the masked - // map from GET /:serviceId. POST, not GET, because the requested key names are - // a body (out of proxy access logs and browser history) and are unbounded by - // URL length. No mcp block: revealing secrets stays a dashboard action, off the - // automation surface. + // #336: real env for named keys only. With `environment`, reads service-scoped + // env_var rows; without it, reads compose-inline values for import/config forms. + // Write-gated on purpose. POST keeps key names out of URLs and proxy logs. + // No mcp block: revealing secrets stays a dashboard action, off automation. "/:serviceId/env-reveal", { tag: "project:service:write" }, cloudProjectProxy, diff --git a/apps/api/src/modules/services/service.schema.ts b/apps/api/src/modules/services/service.schema.ts index 7e8805c05..7de2ace27 100644 --- a/apps/api/src/modules/services/service.schema.ts +++ b/apps/api/src/modules/services/service.schema.ts @@ -15,6 +15,7 @@ */ import { Type, type Static } from "@sinclair/typebox"; +import { EnvironmentScopeSchema } from "../../lib/environment-scope"; import { MonorepoSubAppFieldsSchema } from "../projects/project.schema"; export const ServiceIdParam = Type.Object({ @@ -86,9 +87,7 @@ const AdvancedSchema = Type.Object( timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 600 })), stabilization: Type.Optional(Type.Boolean()), stabilizationSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 600 })), - onFailure: Type.Optional( - Type.Union([Type.Literal("warn"), Type.Literal("fail")]), - ), + onFailure: Type.Optional(Type.Union([Type.Literal("warn"), Type.Literal("fail")])), }, { additionalProperties: false }, ), @@ -137,10 +136,15 @@ const AdvancedSchema = Type.Object( * (back to the image default). */ entrypoint: Type.Optional( - Type.Union([ - Type.Array(Type.String({ maxLength: 2000 }), { maxItems: 100 }), - Type.Null(), - ]), + Type.Union([Type.Array(Type.String({ maxLength: 2000 }), { maxItems: 100 }), Type.Null()]), + ), + /** Names-only provenance for raw Compose build-arg expressions. It must + * round-trip with a service so a read/edit/write cannot turn an escaped + * literal `$` into a second interpolation at deploy time. */ + buildArgTemplateKeys: Type.Optional( + Type.Array(Type.String({ pattern: "^[A-Za-z_][A-Za-z0-9_]*$" }), { + maxItems: 500, + }), ), }, { additionalProperties: false }, @@ -155,6 +159,7 @@ const ComposeFieldsBlock = { image: Type.Optional(Type.String({ maxLength: 500 })), build: Type.Optional(Type.String({ maxLength: 500 })), dockerfile: Type.Optional(Type.String({ maxLength: 500 })), + buildArgs: Type.Optional(Type.Record(Type.String(), Type.Union([Type.String(), Type.Null()]))), ports: Type.Optional(Type.Array(Type.String({ maxLength: 100 }), { maxItems: 50 })), dependsOn: Type.Optional(Type.Array(Type.String({ maxLength: 120 }), { maxItems: 50 })), environment: Type.Optional(Type.Record(Type.String(), Type.String())), @@ -293,6 +298,9 @@ export const SyncServicesBody = Type.Object({ Type.String({ description: "Build context, relative to the compose file." }), ), dockerfile: Type.Optional(Type.String()), + buildArgs: Type.Optional( + Type.Record(Type.String(), Type.Union([Type.String(), Type.Null()])), + ), ports: Type.Optional( Type.Array(Type.String(), { description: 'Compose port mappings, e.g. "8080:80".' }), ), @@ -366,14 +374,11 @@ export const SyncServicesBody = Type.Object({ export const SetServiceEnvVarsBody = Type.Object( { - environment: Type.Union([ - Type.Literal("production"), - Type.Literal("preview"), - Type.Literal("development"), - ]), + environment: EnvironmentScopeSchema, vars: Type.Array( Type.Object( { + sourceId: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })), key: Type.String({ minLength: 1, maxLength: 256 }), value: Type.String({ maxLength: 10000 }), isSecret: Type.Optional(Type.Boolean({ default: false })), diff --git a/apps/api/src/modules/services/service.service.ts b/apps/api/src/modules/services/service.service.ts index ef34eb473..77ef7637a 100644 --- a/apps/api/src/modules/services/service.service.ts +++ b/apps/api/src/modules/services/service.service.ts @@ -2,12 +2,34 @@ * Service business logic - CRUD and compose sync. */ -import { normalizeRoutingFields, repos, composeSpecDiff, type Project, type Service, type ServicePublicEndpoint } from "@repo/db"; -import { aliasConflictsWithSiblings, getProjectType, mergeAdvanced, normalizeServiceLabel, normalizeAliasStrict, resolveCommandArgv, safeErrorMessage, withTimeout, type ComposeAdvanced, type ServiceContainerState, type StackId } from "@repo/core"; +import { + normalizeRoutingFields, + repos, + composeSpecDiff, + type Project, + type Service, + type ServicePublicEndpoint, +} from "@repo/db"; +import { + aliasConflictsWithSiblings, + getProjectType, + isValidEnvKey, + looksLikeSecretKey, + mergeAdvanced, + normalizeServiceLabel, + normalizeAliasStrict, + resolveCommandArgv, + safeErrorMessage, + withTimeout, + type ComposeAdvanced, + type ServiceContainerState, + type StackId, +} from "@repo/core"; import { BuildLogger, DockerRuntime, isMultiServiceRuntime, + ownsBuiltImage, type LogEntry, type ContainerStatus, type RuntimeAdapter, @@ -16,7 +38,14 @@ import { scopedVolumeName, type CommandExecutor } from "@repo/adapters"; import { isArtifactRef } from "../../lib/container-ref"; import { execInContainer } from "../../lib/agent-exec"; import { encrypt, decrypt } from "../../lib/encryption"; -import { ENV_MASK, hasMaskedValue, maskDriftChanges, maskServiceEnv, mergeServiceEnv, unmaskEnv } from "../../lib/secret-env"; +import { + ENV_MASK, + hasMaskedValue, + maskDriftChanges, + maskServiceEnv, + mergeServiceEnv, + unmaskEnv, +} from "../../lib/secret-env"; import { assertNotControlPlane, assertNotControlPlaneById, @@ -59,7 +88,11 @@ import { import { resolveRuntimeResources } from "../../lib/resources"; import { assertFreeEndpointsAllowed } from "../../lib/free-domain-guard"; import { assertPlanAllowsServices, assertRunningServiceQuota } from "../../lib/plan-guard"; -import { ensurePendingServiceDomain, removeServiceDomain, reuseServerCertForDomain } from "../domains/domain.service"; +import { + ensurePendingServiceDomain, + removeServiceDomain, + reuseServerCertForDomain, +} from "../domains/domain.service"; import { buildUpstreamUrl, resolveLiveUpstreamUrl, @@ -71,6 +104,8 @@ import { type RouteRegister, type RouteRemove, } from "../../lib/route-apply.service"; +import type { HostPortTargetIdentity } from "../../lib/host-port-target"; +import { observedLoopbackPublishFromUrl } from "../deployments/observed-host-port-claims"; import { compileProjectRoutingFields } from "../../lib/project-routing-fields"; import type { TCreateServiceBody, @@ -88,11 +123,7 @@ const ROUTE_EDGE_APPLY_TIMEOUT_MS = 6000; // ─── Helpers ───────────────────────────────────────────────────────────────── /** Verify a service exists and belongs to a project in the given org */ -async function assertServiceAccess( - ctx: RequestContext, - projectId: string, - serviceId: string, -) { +async function assertServiceAccess(ctx: RequestContext, projectId: string, serviceId: string) { const project = await repos.project.findById(projectId); assertResourceInOrg(project, "Project", ctx.organizationId, projectId); const svc = await repos.service.findById(serviceId); @@ -209,8 +240,12 @@ export async function validateServiceName( export { aliasConflictsWithSiblings }; function withDrift(svc: Service) { + // The baselines are internal merge state. Returning them would bypass the + // environment masker (and now may contain raw Compose expressions with + // literal defaults); clients consume the already-masked `drift.changes` only. + const { importedSpec: _importedSpec, driftSpec: _driftSpec, ...publicService } = svc; return { - ...maskServiceEnv(svc), + ...maskServiceEnv(publicService), drift: svc.driftSpec ? { changes: maskDriftChanges(composeSpecDiff(svc.importedSpec ?? {}, svc.driftSpec)) } : null, @@ -225,11 +260,7 @@ export async function listServices(ctx: RequestContext, projectId: string) { return (await repos.service.listByProject(projectId)).map(withDrift); } -export async function getService( - ctx: RequestContext, - projectId: string, - serviceId: string, -) { +export async function getService(ctx: RequestContext, projectId: string, serviceId: string) { const { svc } = await assertServiceAccess(ctx, projectId, serviceId); return withDrift(svc); } @@ -269,6 +300,7 @@ export async function acceptServiceDrift( image: theirs.image ?? null, build: theirs.build ?? null, dockerfile: theirs.dockerfile ?? null, + buildArgs: theirs.buildArgs ?? {}, ports: theirs.ports ?? [], dependsOn: theirs.dependsOn ?? [], environment: theirs.environment ?? {}, @@ -293,11 +325,7 @@ export async function acceptServiceDrift( * Keep the user's edits: advance the baseline to the upstream spec (so it stops * re-flagging on every deploy) WITHOUT changing the row's current values. */ -export async function keepServiceDrift( - ctx: RequestContext, - projectId: string, - serviceId: string, -) { +export async function keepServiceDrift(ctx: RequestContext, projectId: string, serviceId: string) { const { svc } = await assertServiceAccess(ctx, projectId, serviceId); if (!svc.driftSpec) return withDrift(svc); await repos.service.update(serviceId, { importedSpec: svc.driftSpec, driftSpec: null }); @@ -550,9 +578,7 @@ export async function createService( // sentinel the client sent is dropped (never persist "••••••••"). Warn so a // real value accidentally lost this way is traceable. if (data.environment && hasMaskedValue(data.environment)) { - console.warn( - `[services] create "${name}": dropping masked env value(s) with no stored source`, - ); + console.warn(`[services] create "${name}": dropping masked env value(s) with no stored source`); data = { ...data, environment: unmaskEnv(data.environment, null) }; } @@ -618,6 +644,11 @@ export async function createService( // strips the `null`-means-remove sentinels the update path accepts, so a // caller can send one payload shape to both. const advanced = mergeAdvanced(null, data.advanced); + if (data.buildArgs !== undefined && !Object.hasOwn(data.advanced ?? {}, "buildArgTemplateKeys")) { + // Direct/manual values are literal. Raw Compose parsing supplies its own + // non-empty marker when interpolation is required. + advanced.buildArgTemplateKeys = []; + } // Same alias gate as updateService — normalize + reject invalid/colliding // custom aliases BEFORE the insert, so a create can't persist an alias the // update path would refuse. No serviceId yet, so pass "" — every existing @@ -631,6 +662,7 @@ export async function createService( image: trimOrNull(data.image), build: trimOrNull(data.build), dockerfile: trimOrNull(data.dockerfile), + buildArgs: data.buildArgs ?? {}, ports: data.ports ?? [], dependsOn: data.dependsOn ?? [], environment: data.environment ?? {}, @@ -724,7 +756,22 @@ export async function updateService( // deep merge would make a partially-specified healthcheck inherit stale fields. if ("advanced" in patch) { patch.advanced = mergeAdvanced(svc.advanced as ComposeAdvanced | null, patch.advanced); - await validateServiceAlias(projectId, serviceId, patch.advanced as ComposeAdvanced, project.internalAlias); + await validateServiceAlias( + projectId, + serviceId, + patch.advanced as ComposeAdvanced, + project.internalAlias, + ); + } + + if ("buildArgs" in patch && !Object.hasOwn(data.advanced ?? {}, "buildArgTemplateKeys")) { + // A manual arg edit replaces the old value's provenance as well as its + // value. Otherwise a literal `$HOME` could inherit a repo-template marker + // and be expanded on the next deploy. + patch.advanced = mergeAdvanced( + ("advanced" in patch ? patch.advanced : svc.advanced) as ComposeAdvanced | null, + { buildArgTemplateKeys: [] }, + ); } if ("name" in patch && typeof patch.name === "string") { @@ -791,9 +838,7 @@ export async function updateService( // mergeServiceRoutingPatch. Before this, an array on the row shadowed the // scalars outright: the user's chosen custom domain was dropped and the gate // below then judged the stored "free" primary instead. - const normalized = normalizeRoutingPatch( - mergeServiceRoutingPatch({ patch, stored: svc }), - ); + const normalized = normalizeRoutingPatch(mergeServiceRoutingPatch({ patch, stored: svc })); // Write the merged routing through VERBATIM, nulls included. `normalized` is // the row's whole intended routing state (unexposing only closes the gate — it @@ -853,6 +898,7 @@ export async function updateService( // Resolved below only when there's a container to inspect; disposed in the // `finally` because it may own an SSH bridge to the serving box. let runtime: RuntimeAdapter | undefined; + let hostPortTarget: HostPortTargetIdentity | null | undefined; try { const runtimeName = platform().runtime.name; // `enabled` / `exposed` are non-nullable DB columns - no need to @@ -861,15 +907,28 @@ export async function updateService( // Diff the SET of routes (a service can publish several ports). A hostname // present before but gone now is removed; every current route is // (re-)registered (register is additive/idempotent upstream). - const oldRoutes = buildServiceRouteDomains({ project, service: svc, runtimeName, usesManagedRouting: true }); + const oldRoutes = buildServiceRouteDomains({ + project, + service: svc, + runtimeName, + usesManagedRouting: true, + }); const nextRoutes = isRoutable - ? buildServiceRouteDomains({ project, service: updated, runtimeName, usesManagedRouting: true }) + ? buildServiceRouteDomains({ + project, + service: updated, + runtimeName, + usesManagedRouting: true, + }) : []; const nextByHost = new Map(nextRoutes.map((route) => [route.hostname.toLowerCase(), route])); const removes: RouteRemove[] = oldRoutes .filter((route) => !nextByHost.has(route.hostname.toLowerCase())) - .map((route) => ({ hostname: route.hostname, isCustomDomain: route.domainType === "custom" })); + .map((route) => ({ + hostname: route.hostname, + isCustomDomain: route.domainType === "custom", + })); // Single reused path: cloud → page/workspace primitives, self-hosted → // the deployment's own routing (local box or remote server/sandbox). @@ -883,18 +942,19 @@ export async function updateService( // loopback port when there is one, else the container IP. Publishing a // domain is exactly when a migrated/adopted workload gets its first route, // and those containers were never published to 127.0.0.1 — so the stored - // row is only a cache here, used when the container can't be inspected. - // Cloud ignores targetUrl. + // row is only a hint for a successful live inspection. A cached host port + // or bridge IP is not ownership evidence: after a stop/remove, either can + // belong to another workload. Cloud ignores targetUrl. let stored: StoredUpstream | undefined; let containerId: string | undefined; if (isRoutable && nextRoutes.length > 0 && dep && project.activeDeploymentId) { const rows = await repos.service.listByDeployment(project.activeDeploymentId); const row = rows.find((r) => r.serviceId === serviceId); - stored = { ip: row?.ip, hostPort: row?.hostPort }; + stored = { ip: row?.ip, hostPort: row?.hostPort, hostPorts: row?.hostPorts }; containerId = row?.containerId ?? undefined; if (containerId) { try { - ({ runtime } = await resolveDeploymentRuntimeForRead(dep)); + ({ runtime, hostPortTarget } = await resolveDeploymentRuntimeForRead(dep)); } catch (err) { console.warn( `[SERVICE] ${svc.name}: could not resolve runtime for upstream, using stored row: ${safeErrorMessage(err)}`, @@ -912,13 +972,28 @@ export async function updateService( containerId, containerPort, stored, + requireLiveObservation: true, })) ?? undefined ); } - return buildUpstreamUrl({ strategy, ip: stored?.ip, hostPort: stored?.hostPort, containerPort }) ?? undefined; + // This is a live route write. If a self-hosted row names no container, + // or its runtime could not be resolved, leave the existing vhost alone + // instead of re-registering a targetless cache that may have been reused. + if (dep) return undefined; + return ( + buildUpstreamUrl({ + strategy, + ip: stored?.ip, + hostPort: stored?.hostPort, + hostPorts: stored?.hostPorts, + containerPort, + }) ?? undefined + ); }; const targetUrls = await Promise.all( - nextRoutes.map((route) => (route.targetPort ? resolveTargetUrl(route.targetPort) : undefined)), + nextRoutes.map((route) => + route.targetPort ? resolveTargetUrl(route.targetPort) : undefined, + ), ); // The project's compiled vercel.json rules. The DEPLOY path already puts them on // a service's own domain (via `serviceRouteOptions`), and `registerRoute` @@ -931,6 +1006,16 @@ export async function updateService( targetUrl: targetUrls[i], port: route.targetPort, isCustomDomain: route.domainType === "custom", + ...(() => { + const observed = route.targetPort + ? observedLoopbackPublishFromUrl({ + targetUrl: targetUrls[i], + serviceId, + containerPort: route.targetPort, + }) + : null; + return observed ? { observedLoopbackPublishes: [observed] } : {}; + })(), })); // Authoritative port: the upstream above is rebuilt from the LIVE @@ -989,7 +1074,12 @@ export async function updateService( // the background. Otherwise the modal spins and times out on a change that // already applied (the reported "keeps loading, but it took effect"). const applyEdge = (async () => { - await reconcileProjectRoutes(project, { deployment: dep, registers, removes }); + await reconcileProjectRoutes(project, { + deployment: dep, + hostPortTarget, + registers, + removes, + }); // AFTER reconcile so installDomainCert re-registers the vhost with TLS on // top of the live HTTP route — adopt an existing cert for a freshly // published custom domain (migration / takeover) instead of ACME. @@ -1018,11 +1108,7 @@ export async function updateService( * can't hang the delete request past the DB-row removal (the authoritative op). */ const SERVICE_TEARDOWN_TIMEOUT_MS = 20_000; -export async function deleteService( - ctx: RequestContext, - projectId: string, - serviceId: string, -) { +export async function deleteService(ctx: RequestContext, projectId: string, serviceId: string) { const { project, svc } = await assertServiceAccess(ctx, projectId, serviceId); // The self-app project's services ARE the Openship stack (api, dashboard, edge, // postgres, redis), linked so the dashboard can show their state, logs and shell. @@ -1070,13 +1156,12 @@ export async function deleteService( // image (postgres:16-alpine, redis:7-alpine) is PULLED, shared, and must // never be removed. if (isArtifactRef(serviceDeployment.imageRef)) { - await platform.runtime - .destroy(serviceDeployment.imageRef!) - .catch((err: unknown) => { - console.error(`[SERVICE] Failed to remove static output for ${svc.name}:`, err); - }); + await platform.runtime.destroy(serviceDeployment.imageRef!).catch((err: unknown) => { + console.error(`[SERVICE] Failed to remove static output for ${svc.name}:`, err); + }); } else if ( - serviceDeployment.imageRef?.startsWith("openship/") && + serviceDeployment.imageRef && + ownsBuiltImage(serviceDeployment.imageRef) && platform.runtime instanceof DockerRuntime ) { await platform.runtime @@ -1165,17 +1250,54 @@ export async function setServiceEnvVars( ) { await assertServiceAccess(ctx, projectId, serviceId); - // Encrypt values before storage - const encrypted = data.vars.map((v) => ({ - key: v.key, - value: encrypt(v.value), - isSecret: v.isSecret, - })); + const seenKeys = new Set(); + for (const variable of data.vars) { + if (!isValidEnvKey(variable.key)) throw new Error(`invalid-env-key:${variable.key}`); + if (seenKeys.has(variable.key)) throw new Error(`duplicate-env-key:${variable.key}`); + seenKeys.add(variable.key); + } + + // GET masks secrets. Preserve the existing ciphertext when that sentinel is + // submitted unchanged; never encrypt and persist the sentinel itself. The + // stable row id also lets a masked secret be renamed without revealing it. + const existing = await repos.project.listEnvVars(projectId, data.environment, serviceId); + const existingByKey = new Map(existing.map((row) => [row.key, row])); + const existingById = new Map(existing.map((row) => [row.id, row])); + const usedSourceIds = new Set(); + const encrypted = data.vars.map((v) => { + const prior = v.sourceId ? existingById.get(v.sourceId) : existingByKey.get(v.key); + if (v.sourceId && !prior) throw new Error(`invalid-env-source:${v.sourceId}`); + if (prior?.id) { + if (usedSourceIds.has(prior.id)) throw new Error(`duplicate-env-source:${prior.id}`); + usedSourceIds.add(prior.id); + } + if (v.value === ENV_MASK) { + if (!prior?.isSecret) throw new Error(`masked-env-without-source:${v.key}`); + return { key: v.key, value: prior.value, isSecret: v.isSecret ?? prior.isSecret }; + } + return { + key: v.key, + value: encrypt(v.value), + isSecret: v.isSecret ?? prior?.isSecret ?? looksLikeSecretKey(v.key), + }; + }); await repos.project.bulkSetEnvVars(projectId, data.environment, encrypted, serviceId); return { count: encrypted.length }; } +/** Full internal map; the controller returns only explicitly requested keys. */ +export async function revealServiceEnvVars( + ctx: RequestContext, + projectId: string, + serviceId: string, + environment: string, +): Promise> { + await assertServiceAccess(ctx, projectId, serviceId); + const rows = await repos.project.listEnvVars(projectId, environment, serviceId); + return Object.fromEntries(rows.map((row) => [row.key, decrypt(row.value)])); +} + // ─── Compose Sync ──────────────────────────────────────────────────────────── export async function syncComposeServices( @@ -1186,6 +1308,7 @@ export async function syncComposeServices( image?: string; build?: string; dockerfile?: string; + buildArgs?: Record; ports?: string[]; dependsOn?: string[]; environment?: Record; @@ -1463,7 +1586,8 @@ export async function getActiveServiceContainers( matchedBy: null, duplicates: [], }; - if (!hint?.containerId) return { ...base, status: "stopped" as ServiceContainerState }; + if (!hint?.containerId) + return { ...base, status: "stopped" as ServiceContainerState }; const info = await runtime.getContainerInfo(hint.containerId).catch(() => null); return { ...base, @@ -1595,7 +1719,8 @@ export async function getServiceVolumeSizes( partial: parsed.length > 0, }); - if (parsed.length === 0) return { measurable: true, volumes: [], totalBytes: null, partial: false }; + if (parsed.length === 0) + return { measurable: true, volumes: [], totalBytes: null, partial: false }; if (!project.activeDeploymentId) return unmeasured(false); const dep = await repos.deployment.findById(project.activeDeploymentId); @@ -1663,20 +1788,36 @@ export async function getServiceVolumeSizes( } } - const volumes = await bounded(parsed, VOL_SIZE_CONCURRENCY, async (p): Promise => { - const hostPath = p.target ? mountsByDest.get(p.target) : undefined; - let bytes: number | null; - if (hostPath) { - bytes = await duBytes(executor, hostPath); - } else if (p.kind === "bind" && p.source) { - bytes = await duBytes(executor, p.source); - } else if (p.kind === "named" && p.source) { - bytes = await namedVolumeBytesByName(executor, project.slug, p.source, !!svc.namespaceVolumes); - } else { - bytes = null; // anonymous volume with no running container → unknown - } - return { raw: p.raw, source: p.source, target: p.target, kind: p.kind, readOnly: p.readOnly, bytes }; - }); + const volumes = await bounded( + parsed, + VOL_SIZE_CONCURRENCY, + async (p): Promise => { + const hostPath = p.target ? mountsByDest.get(p.target) : undefined; + let bytes: number | null; + if (hostPath) { + bytes = await duBytes(executor, hostPath); + } else if (p.kind === "bind" && p.source) { + bytes = await duBytes(executor, p.source); + } else if (p.kind === "named" && p.source) { + bytes = await namedVolumeBytesByName( + executor, + project.slug, + p.source, + !!svc.namespaceVolumes, + ); + } else { + bytes = null; // anonymous volume with no running container → unknown + } + return { + raw: p.raw, + source: p.source, + target: p.target, + kind: p.kind, + readOnly: p.readOnly, + bytes, + }; + }, + ); let totalBytes: number | null = null; let partial = false; @@ -1704,11 +1845,7 @@ export async function getServiceVolumeSizes( * (it's the only key an adopted container with a foreign name has) — see * live-state.ts for the resolution order. */ -async function resolveServiceContainer( - ctx: RequestContext, - projectId: string, - serviceId: string, -) { +async function resolveServiceContainer(ctx: RequestContext, projectId: string, serviceId: string) { const project = await repos.project.findById(projectId); assertResourceInOrg(project, "Project", ctx.organizationId, projectId); if (!project.activeDeploymentId) throw new Error("No active deployment"); @@ -1811,7 +1948,9 @@ async function provisionServiceContainer( const resolved = await resolveServicePlatform(project, dep); const runtime = resolved.platform.runtime; if (!isMultiServiceRuntime(runtime)) { - throw new Error(`The ${runtime.name} runtime cannot run services — enable Docker on this target.`); + throw new Error( + `The ${runtime.name} runtime cannot run services — enable Docker on this target.`, + ); } // Surface the per-service provisioning trace (and any Oblien failure reason) @@ -1843,6 +1982,7 @@ async function provisionServiceContainer( system: resolved.platform.system, executor: resolved.platform.executor, localHost: resolved.platform.localHost, + hostPortTarget: resolved.hostPortTarget, usesManagedRouting: resolved.usesManagedRouting, serverId: resolved.serverId ?? undefined, }); @@ -1898,11 +2038,7 @@ export async function stopServiceContainer( serviceId: string, ) { await assertNotControlPlaneById(projectId); - const { runtime, containerId, row } = await resolveServiceContainer( - ctx, - projectId, - serviceId, - ); + const { runtime, containerId, row } = await resolveServiceContainer(ctx, projectId, serviceId); try { await runtime.stop(containerId); // Deploy-history bookkeeping only — the panel reads state from the host. @@ -1954,11 +2090,7 @@ export async function restartServiceContainer( : null; const service = (await repos.service.listByProject(projectId)).find((s) => s.id === serviceId); if (dep && service) { - const staleEnvKeys = await resolveStaleEnvKeysForService( - project, - dep.environment, - serviceId, - ); + const staleEnvKeys = await resolveStaleEnvKeysForService(project, dep.environment, serviceId); if (staleEnvKeys.length > 0) { // Channel-neutral on purpose: this message is rendered in a dashboard // toast, a CLI stderr line, and an MCP tool result. It names both routes @@ -1977,11 +2109,7 @@ export async function restartServiceContainer( } } - const { runtime, containerId, row } = await resolveServiceContainer( - ctx, - projectId, - serviceId, - ); + const { runtime, containerId, row } = await resolveServiceContainer(ctx, projectId, serviceId); try { await runtime.restart(containerId); if (row) { @@ -1999,11 +2127,7 @@ export async function getServiceRuntimeLogs( serviceId: string, tail?: number, ) { - const { runtime, containerId } = await resolveServiceContainer( - ctx, - projectId, - serviceId, - ); + const { runtime, containerId } = await resolveServiceContainer(ctx, projectId, serviceId); try { return await runtime.getRuntimeLogs(containerId, tail); } finally { @@ -2080,4 +2204,3 @@ export async function streamServiceRuntimeLogs( }; return { cleanup, serverId }; } - diff --git a/apps/api/src/modules/system/data-transfer/data-transfer.controller.ts b/apps/api/src/modules/system/data-transfer/data-transfer.controller.ts index 8dec5f3af..978111a3b 100644 --- a/apps/api/src/modules/system/data-transfer/data-transfer.controller.ts +++ b/apps/api/src/modules/system/data-transfer/data-transfer.controller.ts @@ -23,20 +23,43 @@ import { MigrationAlreadyInProgressError, MigrationLockAcquireError, } from "../migration/migration-lock"; -import { exportInstance } from "./export.service"; +import { exportInstance, previewInstanceExport } from "./export.service"; import { CloudInstanceNotTransferableError } from "./errors"; import { importInstance, InvalidTransferFileError } from "./import.service"; import { WrongPassphraseError } from "./passphrase-crypto"; -import type { DataTransferFile, ImportMode } from "./types"; +import { InvalidExportSelectionError } from "./selection"; +import { + createDirectReceiveSession, + DirectTransferDestinationError, + DirectTransferSessionError, + InvalidDirectTransferCodeError, + receiveDirectTransfer, + sendDirectTransfer, +} from "./direct-transfer.service"; +import type { + DataTransferFile, + DirectTransferEnvelope, + ExportSelection, + ImportMode, +} from "./types"; interface ExportBody { passphrase?: string; + selection?: ExportSelection; } interface ImportBody { file?: DataTransferFile; passphrase?: string; mode?: ImportMode; } +interface CreateReceiveBody { + apiBase?: string; + mode?: ImportMode; +} +interface SendDirectBody { + code?: string; + selection?: ExportSelection; +} function readPassphrase(v: unknown): string | undefined { return typeof v === "string" && v.length > 0 ? v : undefined; @@ -50,23 +73,146 @@ export async function exportInstanceHandler(c: Context) { let file: DataTransferFile; try { - file = await exportInstance({ passphrase: readPassphrase(body.passphrase) }); + file = await exportInstance({ + passphrase: readPassphrase(body.passphrase), + selection: body.selection, + }); } catch (err) { if (err instanceof CloudInstanceNotTransferableError) { return c.json({ error: err.message, code: err.code }, 403); } + if (err instanceof InvalidExportSelectionError) { + return c.json({ error: err.message, code: err.code }, 400); + } throw err; } audit.recordAsync(auditContextFrom(c, ctx.organizationId, ctx.userId), { eventType: "instance.data.exported", resourceType: "instance", - after: { hasSecrets: !!file.secrets, tableCount: Object.keys(file.dump.tables).length }, + after: { + hasSecrets: !!file.secrets, + tableCount: Object.keys(file.dump.tables).length, + rowCount: file.summary?.rows, + history: file.selection?.history, + }, }); return c.json(file); } +export async function previewInstanceExportHandler(c: Context) { + const ctx = getRequestContext(c); + await assertInstanceAdmin(ctx); + try { + return c.json(await previewInstanceExport()); + } catch (err) { + if (err instanceof CloudInstanceNotTransferableError) { + return c.json({ error: err.message, code: err.code }, 403); + } + throw err; + } +} + +export async function createDirectReceiveSessionHandler(c: Context) { + const ctx = getRequestContext(c); + await assertInstanceAdmin(ctx); + const body = ((await c.req.json().catch(() => ({}))) ?? {}) as CreateReceiveBody; + if (typeof body.apiBase !== "string") { + return c.json({ error: "Missing destination API URL.", code: "INVALID_DIRECT_TRANSFER_CODE" }, 400); + } + try { + const session = createDirectReceiveSession({ + apiBase: body.apiBase, + mode: body.mode === "merge" ? "merge" : "wipe", + }); + audit.recordAsync(auditContextFrom(c, ctx.organizationId, ctx.userId), { + eventType: "instance.data.receive_code_created", + resourceType: "instance", + after: { mode: session.mode, expiresAt: session.expiresAt }, + }); + return c.json(session); + } catch (err) { + if (err instanceof InvalidDirectTransferCodeError) { + return c.json({ error: err.message, code: err.code }, 400); + } + if (err instanceof DirectTransferSessionError) { + return c.json({ error: err.message, code: err.code }, 409); + } + throw err; + } +} + +export async function sendDirectTransferHandler(c: Context) { + const ctx = getRequestContext(c); + await assertInstanceAdmin(ctx); + const body = ((await c.req.json().catch(() => ({}))) ?? {}) as SendDirectBody; + if (typeof body.code !== "string") { + return c.json({ error: "Missing receive code.", code: "INVALID_DIRECT_TRANSFER_CODE" }, 400); + } + try { + const result = await sendDirectTransfer({ code: body.code, selection: body.selection }); + audit.recordAsync(auditContextFrom(c, ctx.organizationId, ctx.userId), { + eventType: "instance.data.sent", + resourceType: "instance", + after: { + destination: result.destination, + rowsRestored: result.rowsRestored, + secretsRehydrated: result.secretsRehydrated, + }, + }); + return c.json(result); + } catch (err) { + if ( + err instanceof InvalidDirectTransferCodeError || + err instanceof DirectTransferSessionError || + err instanceof DirectTransferDestinationError + ) { + return c.json({ error: err.message, code: err.code }, 400); + } + if (err instanceof CloudInstanceNotTransferableError) { + return c.json({ error: err.message, code: err.code }, 403); + } + if (err instanceof InvalidExportSelectionError) { + return c.json({ error: err.message, code: err.code }, 400); + } + throw err; + } +} + +/** Public capability endpoint: the encrypted receive code is the authorization. */ +export async function receiveDirectTransferHandler(c: Context) { + let envelope: DirectTransferEnvelope; + try { + envelope = await c.req.json(); + } catch { + return c.json({ error: "Invalid encrypted transfer body.", code: "INVALID_DIRECT_TRANSFER_CODE" }, 400); + } + try { + return c.json(await receiveDirectTransfer(envelope)); + } catch (err) { + if (err instanceof InvalidDirectTransferCodeError) { + return c.json({ error: err.message, code: err.code }, 400); + } + if (err instanceof DirectTransferSessionError) { + return c.json({ error: err.message, code: err.code }, 410); + } + if (err instanceof CloudInstanceNotTransferableError) { + return c.json({ error: err.message, code: err.code }, 403); + } + if (err instanceof InvalidTransferFileError || err instanceof WrongPassphraseError) { + return c.json({ error: err.message, code: err.code }, 400); + } + if (err instanceof PkCollisionError) { + return c.json({ error: err.message, code: "PK_COLLISION" }, 409); + } + if (err instanceof MigrationAlreadyInProgressError || err instanceof MigrationLockAcquireError) { + return c.json({ error: "The destination is busy. Generate a new code and try again shortly.", code: "BUSY" }, 503); + } + throw err; + } +} + export async function importInstanceHandler(c: Context) { const ctx = getRequestContext(c); await assertInstanceAdmin(ctx); diff --git a/apps/api/src/modules/system/data-transfer/direct-transfer.service.ts b/apps/api/src/modules/system/data-transfer/direct-transfer.service.ts new file mode 100644 index 000000000..e64cf3c85 --- /dev/null +++ b/apps/api/src/modules/system/data-transfer/direct-transfer.service.ts @@ -0,0 +1,329 @@ +/** + * One-time, instance-to-instance transfer. + * + * The destination creates an ephemeral X25519 keypair and an unguessable + * capability. The source encrypts the scrubbed dump + plaintext credential + * bundle directly to that public key. Only the destination can open it, and it + * immediately re-encrypts every credential under its own instance key. + */ + +import { + createHash, + createPublicKey, + diffieHellman, + generateKeyPairSync, + hkdfSync, + randomBytes, + randomUUID, + timingSafeEqual, + type KeyObject, +} from "node:crypto"; + +import { decryptWithKey, encryptWithKey } from "../../../lib/encryption"; +import { prepareInstanceExport } from "./export.service"; +import { importPreparedInstance } from "./import.service"; +import type { + DirectTransferConnection, + DirectTransferEnvelope, + DirectTransferPayload, + DirectTransferResult, + ExportSelection, + ImportMode, + ImportResult, +} from "./types"; + +const SESSION_TTL_MS = 10 * 60_000; +const MAX_ACTIVE_SESSIONS = 20; +const DIRECT_RECEIVE_PATH = "system/data-transfer/direct/receive"; +const KEY_CONTEXT = Buffer.from("openship-direct-transfer-v1", "utf8"); +const DIRECT_RUNTIME_ID = randomUUID(); + +interface ReceiveSession { + id: string; + tokenHash: Buffer; + privateKey: KeyObject; + mode: ImportMode; + expiresAtMs: number; + consuming: boolean; +} + +const receiveSessions = new Map(); + +export class InvalidDirectTransferCodeError extends Error { + readonly code = "INVALID_DIRECT_TRANSFER_CODE" as const; + constructor(message = "The receive code is invalid or malformed.") { + super(message); + this.name = "InvalidDirectTransferCodeError"; + } +} + +export class DirectTransferSessionError extends Error { + readonly code = "DIRECT_TRANSFER_SESSION_UNAVAILABLE" as const; + constructor(message = "The receive code expired, was already used, or is not available on this instance.") { + super(message); + this.name = "DirectTransferSessionError"; + } +} + +export class DirectTransferDestinationError extends Error { + readonly code = "DIRECT_TRANSFER_DESTINATION_FAILED" as const; + constructor(message: string) { + super(message); + this.name = "DirectTransferDestinationError"; + } +} + +function tokenDigest(token: string): Buffer { + return createHash("sha256").update(token).digest(); +} + +function cleanupSessions(now = Date.now()): void { + for (const [id, session] of receiveSessions) { + if (session.expiresAtMs <= now) receiveSessions.delete(id); + } +} + +function normalizeApiBase(raw: string): string { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new InvalidDirectTransferCodeError("The destination API URL is invalid."); + } + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new InvalidDirectTransferCodeError("The destination must use an HTTP or HTTPS URL."); + } + if (url.username || url.password || url.search || url.hash) { + throw new InvalidDirectTransferCodeError("The destination URL cannot contain credentials, query parameters, or a fragment."); + } + url.pathname = `${url.pathname.replace(/\/+$/, "")}/`; + return url.toString(); +} + +function assertConnection(value: unknown): DirectTransferConnection { + if (!value || typeof value !== "object") throw new InvalidDirectTransferCodeError(); + const item = value as Record; + if ( + item.version !== 1 || + typeof item.apiBase !== "string" || + typeof item.recipientRuntimeId !== "string" || + typeof item.sessionId !== "string" || + typeof item.token !== "string" || + typeof item.recipientPublicKey !== "string" || + (item.mode !== "wipe" && item.mode !== "merge") || + typeof item.expiresAt !== "string" + ) { + throw new InvalidDirectTransferCodeError(); + } + if ( + item.recipientRuntimeId.length < 20 || + item.recipientRuntimeId.length > 100 || + item.sessionId.length > 100 || + item.token.length < 32 || + item.token.length > 200 + ) { + throw new InvalidDirectTransferCodeError(); + } + const expiresAtMs = Date.parse(item.expiresAt); + if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) { + throw new DirectTransferSessionError("The receive code has expired. Generate a new one on the destination."); + } + return { + version: 1, + apiBase: normalizeApiBase(item.apiBase), + recipientRuntimeId: item.recipientRuntimeId, + sessionId: item.sessionId, + token: item.token, + recipientPublicKey: item.recipientPublicKey, + mode: item.mode, + expiresAt: item.expiresAt, + }; +} + +export function encodeDirectTransferCode(connection: DirectTransferConnection): string { + return Buffer.from(JSON.stringify(connection), "utf8").toString("base64url"); +} + +export function decodeDirectTransferCode(code: string): DirectTransferConnection { + if (typeof code !== "string" || code.length < 40 || code.length > 8_192) { + throw new InvalidDirectTransferCodeError(); + } + try { + return assertConnection(JSON.parse(Buffer.from(code.trim(), "base64url").toString("utf8"))); + } catch (error) { + if (error instanceof InvalidDirectTransferCodeError || error instanceof DirectTransferSessionError) { + throw error; + } + throw new InvalidDirectTransferCodeError(); + } +} + +function deriveTransferKey(privateKey: KeyObject, publicKey: KeyObject, sessionId: string): Buffer { + const shared = diffieHellman({ privateKey, publicKey }); + return Buffer.from(hkdfSync("sha256", shared, Buffer.from(sessionId, "utf8"), KEY_CONTEXT, 32)); +} + +export function sealDirectTransferPayload( + connection: DirectTransferConnection, + payload: DirectTransferPayload, +): DirectTransferEnvelope { + let recipientPublicKey: KeyObject; + try { + recipientPublicKey = createPublicKey({ + key: Buffer.from(connection.recipientPublicKey, "base64"), + format: "der", + type: "spki", + }); + if (recipientPublicKey.asymmetricKeyType !== "x25519") throw new Error("wrong key type"); + } catch { + throw new InvalidDirectTransferCodeError("The receive code contains an invalid destination key."); + } + + const { publicKey: senderPublicKey, privateKey: senderPrivateKey } = generateKeyPairSync("x25519"); + const key = deriveTransferKey(senderPrivateKey, recipientPublicKey, connection.sessionId); + return { + version: 1, + sessionId: connection.sessionId, + senderPublicKey: senderPublicKey.export({ format: "der", type: "spki" }).toString("base64"), + blob: encryptWithKey(key, JSON.stringify(payload)), + }; +} + +export function createDirectReceiveSession(opts: { + apiBase: string; + mode: ImportMode; +}): { code: string; expiresAt: string; mode: ImportMode } { + cleanupSessions(); + if (receiveSessions.size >= MAX_ACTIVE_SESSIONS) { + throw new DirectTransferSessionError("Too many active receive codes. Wait for an existing code to expire."); + } + + const id = randomUUID(); + const token = randomBytes(32).toString("base64url"); + const { publicKey, privateKey } = generateKeyPairSync("x25519"); + const expiresAtMs = Date.now() + SESSION_TTL_MS; + receiveSessions.set(id, { + id, + tokenHash: tokenDigest(token), + privateKey, + mode: opts.mode, + expiresAtMs, + consuming: false, + }); + + const expiresAt = new Date(expiresAtMs).toISOString(); + const connection: DirectTransferConnection = { + version: 1, + apiBase: normalizeApiBase(opts.apiBase), + recipientRuntimeId: DIRECT_RUNTIME_ID, + sessionId: id, + token, + recipientPublicKey: publicKey.export({ format: "der", type: "spki" }).toString("base64"), + mode: opts.mode, + expiresAt, + }; + return { code: encodeDirectTransferCode(connection), expiresAt, mode: opts.mode }; +} + +export async function sendDirectTransfer(opts: { + code: string; + selection?: ExportSelection; + fetchImpl?: typeof fetch; +}): Promise { + const connection = decodeDirectTransferCode(opts.code); + if (connection.recipientRuntimeId === DIRECT_RUNTIME_ID) { + throw new InvalidDirectTransferCodeError("The receive code belongs to this same instance. Generate it on the destination instance."); + } + const prepared = await prepareInstanceExport(opts.selection); + const payload: DirectTransferPayload = { + version: 1, + authorizationToken: connection.token, + file: prepared.file, + secrets: prepared.secrets, + }; + const envelope = sealDirectTransferPayload(connection, payload); + + const receiveUrl = new URL(DIRECT_RECEIVE_PATH, connection.apiBase); + let response: Response; + try { + response = await (opts.fetchImpl ?? fetch)(receiveUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(envelope), + redirect: "error", + signal: AbortSignal.timeout(10 * 60_000), + }); + } catch (error) { + throw new DirectTransferDestinationError( + error instanceof Error ? `Could not reach the destination: ${error.message}` : "Could not reach the destination.", + ); + } + + const body = await response.json().catch(() => null) as (ImportResult & { error?: string }) | null; + if (!response.ok || !body) { + throw new DirectTransferDestinationError(body?.error || `Destination returned HTTP ${response.status}.`); + } + return { ...body, destination: receiveUrl.origin }; +} + +export async function receiveDirectTransfer(envelope: DirectTransferEnvelope): Promise { + cleanupSessions(); + if ( + !envelope || + envelope.version !== 1 || + typeof envelope.sessionId !== "string" || + typeof envelope.senderPublicKey !== "string" || + typeof envelope.blob !== "string" + ) { + throw new InvalidDirectTransferCodeError("The encrypted transfer envelope is invalid."); + } + + const session = receiveSessions.get(envelope.sessionId); + if (!session || session.consuming || session.expiresAtMs <= Date.now()) { + throw new DirectTransferSessionError(); + } + + let payload: DirectTransferPayload; + try { + const senderPublicKey = createPublicKey({ + key: Buffer.from(envelope.senderPublicKey, "base64"), + format: "der", + type: "spki", + }); + if (senderPublicKey.asymmetricKeyType !== "x25519") throw new Error("wrong key type"); + const key = deriveTransferKey(session.privateKey, senderPublicKey, session.id); + payload = JSON.parse(decryptWithKey(key, envelope.blob)) as DirectTransferPayload; + } catch { + throw new InvalidDirectTransferCodeError("The transfer could not be authenticated or decrypted."); + } + + if ( + payload?.version !== 1 || + typeof payload.authorizationToken !== "string" || + !payload.file + ) { + throw new InvalidDirectTransferCodeError("The decrypted transfer payload is invalid."); + } + const suppliedHash = tokenDigest(payload.authorizationToken); + if (!timingSafeEqual(session.tokenHash, suppliedHash)) { + throw new DirectTransferSessionError(); + } + + // Consume atomically before the first database await. A receive code can + // authorize exactly one import attempt, including when that import fails. + session.consuming = true; + try { + return await importPreparedInstance({ + file: payload.file, + secrets: payload.secrets ?? null, + mode: session.mode, + }); + } finally { + receiveSessions.delete(session.id); + } +} + +/** Test-only visibility without exposing private session material. */ +export function clearDirectReceiveSessionsForTest(): void { + if (process.env.NODE_ENV === "test") receiveSessions.clear(); +} diff --git a/apps/api/src/modules/system/data-transfer/export.service.ts b/apps/api/src/modules/system/data-transfer/export.service.ts index 37a129021..15f72b34b 100644 --- a/apps/api/src/modules/system/data-transfer/export.service.ts +++ b/apps/api/src/modules/system/data-transfer/export.service.ts @@ -4,23 +4,30 @@ * payload so the file carries secrets ONLY inside the sealed bundle. */ -import { dumpSubgraph, stripEncryptedInPlace } from "@repo/db"; +import { countInstanceSubgraphTables, dumpSubgraph, stripEncryptedInPlace } from "@repo/db"; import { env } from "../../../config/env"; import { CloudInstanceNotTransferableError } from "./errors"; import { sealSecretBundle } from "./passphrase-crypto"; import { extractPlaintext } from "./secret-codec"; import { SECRET_COLUMNS } from "./secret-registry"; -import type { DataTransferFile, SecretBundle, SecretEntry } from "./types"; +import { resolveExportSelection, summarizeExportCounts } from "./selection"; +import type { DataTransferFile, ExportPreview, ExportSelection, SecretBundle, SecretEntry } from "./types"; -export async function exportInstance(opts: { passphrase?: string }): Promise { - // GATE 1: never export a multi-tenant SaaS instance (would leak all tenants). +export async function previewInstanceExport(): Promise { + if (env.CLOUD_MODE) throw new CloudInstanceNotTransferableError(); + return summarizeExportCounts(await countInstanceSubgraphTables()); +} + +/** Build a scrubbed snapshot plus its in-memory plaintext credential bundle. */ +export async function prepareInstanceExport( + selectionInput?: ExportSelection, +): Promise<{ file: DataTransferFile; secrets: SecretBundle | null }> { if (env.CLOUD_MODE) throw new CloudInstanceNotTransferableError(); - const dump = await dumpSubgraph({ kind: "instance" }); + const { selection, excludedTables } = resolveExportSelection(selectionInput); + const dump = await dumpSubgraph({ kind: "instance" }, { excludeTables: excludedTables }); - // Decrypt each secret cell (source instance can read its own data) into the - // bundle BEFORE stripping the payload. const entries: SecretEntry[] = []; for (const spec of SECRET_COLUMNS) { const rows = dump.tables[spec.sqlName]; @@ -33,19 +40,36 @@ export async function exportInstance(opts: { passphrase?: string }): Promise 0 ? sealSecretBundle(bundle, opts.passphrase) : null; + return { + file: { + kind: "openship-instance-export", + envelopeVersion: 1, + createdAt: new Date().toISOString(), + sourceDriver: dump.sourceDriver, + selection, + summary: { + rows: Object.values(dump.tables).reduce((count, rows) => count + rows.length, 0), + tables: Object.keys(dump.tables).length, + }, + dump, + secrets: null, + }, + secrets: entries.length > 0 ? { version: 1, entries } : null, + }; +} +export async function exportInstance(opts: { + passphrase?: string; + selection?: ExportSelection; +}): Promise { + const prepared = await prepareInstanceExport(opts.selection); return { - kind: "openship-instance-export", - envelopeVersion: 1, - createdAt: new Date().toISOString(), - sourceDriver: dump.sourceDriver, - dump, - secrets, + ...prepared.file, + secrets: + opts.passphrase && prepared.secrets + ? sealSecretBundle(prepared.secrets, opts.passphrase) + : null, }; } diff --git a/apps/api/src/modules/system/data-transfer/import.service.ts b/apps/api/src/modules/system/data-transfer/import.service.ts index d3244b1e0..8b938f151 100644 --- a/apps/api/src/modules/system/data-transfer/import.service.ts +++ b/apps/api/src/modules/system/data-transfer/import.service.ts @@ -15,7 +15,7 @@ import { db, eq, inArray, restoreSubgraph } from "@repo/db"; import { env } from "../../../config/env"; import { withMigrationLock } from "../migration/migration-lock"; import { CloudInstanceNotTransferableError } from "./errors"; -import { openSecretBundle } from "./passphrase-crypto"; +import { openTransferSecrets } from "./passphrase-crypto"; import { sealForInstance } from "./secret-codec"; import { SECRET_COLUMNS, type SecretColumn } from "./secret-registry"; import type { DataTransferFile, ImportMode, ImportResult, SecretBundle, SecretEntry } from "./types"; @@ -61,6 +61,37 @@ function assertValidEnvelope(file: DataTransferFile): void { } } +function assertValidSecretBundle(bundle: SecretBundle | null): void { + if (!bundle) return; + if (bundle.version !== 1 || !Array.isArray(bundle.entries)) { + throw new InvalidTransferFileError("The credential bundle is invalid."); + } + const schemes = new Set(["scalar", "enc1", "map", "notification-config", "plaintext"]); + for (const entry of bundle.entries) { + if ( + !entry || + typeof entry.table !== "string" || + typeof entry.id !== "string" || + typeof entry.column !== "string" || + !schemes.has(entry.scheme) + ) { + throw new InvalidTransferFileError("The credential bundle contains an invalid entry."); + } + if (entry.value !== undefined && typeof entry.value !== "string") { + throw new InvalidTransferFileError("The credential bundle contains an invalid scalar value."); + } + for (const values of [entry.map, entry.config]) { + if ( + values !== undefined && + (!values || typeof values !== "object" || Array.isArray(values) || + Object.values(values).some((value) => typeof value !== "string")) + ) { + throw new InvalidTransferFileError("The credential bundle contains an invalid mapped value."); + } + } + } +} + /** Distinct secret tables → their drizzle table + pk column. */ function secretTables(): Map { const out = new Map(); @@ -96,17 +127,28 @@ export async function importInstance(opts: { passphrase?: string; mode: ImportMode; }): Promise { - const { file, mode } = opts; + if (env.CLOUD_MODE) throw new CloudInstanceNotTransferableError(); + assertValidEnvelope(opts.file); + return importPreparedInstance({ + file: opts.file, + secrets: openTransferSecrets(opts.file.secrets, opts.passphrase), + mode: opts.mode, + }); +} + +/** Restore a snapshot whose credential bundle has already been authenticated. */ +export async function importPreparedInstance(opts: { + file: DataTransferFile; + secrets: SecretBundle | null; + mode: ImportMode; +}): Promise { + const { file, mode, secrets: bundle } = opts; // GATE 1: never import (esp. wipe) onto a multi-tenant SaaS instance — a // wipe restore TRUNCATEs every tenant. Refuse before opening the bundle. if (env.CLOUD_MODE) throw new CloudInstanceNotTransferableError(); assertValidEnvelope(file); + assertValidSecretBundle(bundle); - // Open the bundle FIRST — a wrong passphrase throws here, before any write. - let bundle: SecretBundle | null = null; - if (file.secrets && opts.passphrase) { - bundle = openSecretBundle(file.secrets, opts.passphrase); - } const secretsSkipped = !bundle; const rowsRestored = Object.values(file.dump.tables).reduce((n, rows) => n + rows.length, 0); diff --git a/apps/api/src/modules/system/data-transfer/passphrase-crypto.ts b/apps/api/src/modules/system/data-transfer/passphrase-crypto.ts index b8b724c3c..67bf7bb83 100644 --- a/apps/api/src/modules/system/data-transfer/passphrase-crypto.ts +++ b/apps/api/src/modules/system/data-transfer/passphrase-crypto.ts @@ -58,3 +58,17 @@ export function openSecretBundle(sealed: SealedSecrets, passphrase: string): Sec throw new WrongPassphraseError(); } } + +/** + * Resolve the optional credential envelope for import. A file without an + * envelope is intentionally credential-free; a file with one must always be + * unlocked instead of silently importing scrubbed credential columns. + */ +export function openTransferSecrets( + sealed: SealedSecrets | null, + passphrase?: string, +): SecretBundle | null { + if (!sealed) return null; + if (!passphrase) throw new WrongPassphraseError(); + return openSecretBundle(sealed, passphrase); +} diff --git a/apps/api/src/modules/system/data-transfer/selection.ts b/apps/api/src/modules/system/data-transfer/selection.ts new file mode 100644 index 000000000..3f2868829 --- /dev/null +++ b/apps/api/src/modules/system/data-transfer/selection.ts @@ -0,0 +1,71 @@ +import type { ExportHistoryCategory, ExportPreview, ExportSelection } from "./types"; + +export const EXPORT_HISTORY_CATEGORIES = [ + "analytics", + "activity", + "backups", + "incidents", + "migrations", +] as const satisfies readonly ExportHistoryCategory[]; + +/** Optional, high-volume history. Durable configuration is always exported. */ +export const HISTORY_TABLES: Record = { + analytics: ["server_analytics", "server_analytics_geo", "resource_usage"], + // Kept together because notification_delivery.auditEventId references audit_event. + activity: ["audit_event", "notification_delivery"], + // Kept together because backup_restore.runId references backup_run. + backups: ["backup_run", "backup_restore"], + incidents: ["service_incident"], + migrations: ["docker_migration_run"], +}; + +export class InvalidExportSelectionError extends Error { + readonly code = "INVALID_EXPORT_SELECTION" as const; + constructor(category: string) { + super(`Unknown export history category: ${category}`); + this.name = "InvalidExportSelectionError"; + } +} + +export function summarizeExportCounts(tableCounts: Record): ExportPreview { + const history = Object.fromEntries( + Object.entries(HISTORY_TABLES).map(([category, tables]) => [ + category, + tables.reduce((sum, table) => sum + (tableCounts[table] ?? 0), 0), + ]), + ) as ExportPreview["history"]; + const historyTables = new Set(Object.values(HISTORY_TABLES).flat()); + const core = Object.entries(tableCounts).reduce( + (sum, [table, rows]) => sum + (historyTables.has(table) ? 0 : rows), + 0, + ); + return { + core, + history, + total: core + Object.values(history).reduce((sum, rows) => sum + rows, 0), + }; +} + +/** Missing selection preserves the legacy full-instance export. */ +export function resolveExportSelection(selection?: ExportSelection): { + selection: ExportSelection; + excludedTables: string[]; +} { + const raw = selection?.history; + if (raw !== undefined && !Array.isArray(raw)) { + throw new InvalidExportSelectionError(String(raw)); + } + const requested = raw ?? [...EXPORT_HISTORY_CATEGORIES]; + const allowed = new Set(EXPORT_HISTORY_CATEGORIES); + for (const category of requested) { + if (!allowed.has(category)) throw new InvalidExportSelectionError(String(category)); + } + + const history = [...new Set(requested)] as ExportHistoryCategory[]; + const included = new Set(history); + const excludedTables = EXPORT_HISTORY_CATEGORIES + .filter((category) => !included.has(category)) + .flatMap((category) => [...HISTORY_TABLES[category]]); + + return { selection: { history }, excludedTables }; +} diff --git a/apps/api/src/modules/system/data-transfer/types.ts b/apps/api/src/modules/system/data-transfer/types.ts index 85c509359..b34da4000 100644 --- a/apps/api/src/modules/system/data-transfer/types.ts +++ b/apps/api/src/modules/system/data-transfer/types.ts @@ -11,6 +11,24 @@ import type { DatabaseDump } from "@repo/db"; export type ImportMode = "wipe" | "merge"; +export type ExportHistoryCategory = + | "analytics" + | "activity" + | "backups" + | "incidents" + | "migrations"; + +export interface ExportSelection { + /** Optional history groups. Durable configuration is always included. */ + history: ExportHistoryCategory[]; +} + +export interface ExportPreview { + core: number; + history: Record; + total: number; +} + /** How a given column is encrypted at rest — drives decrypt/re-encrypt dispatch. */ export type SecretScheme = "scalar" | "enc1" | "map" | "notification-config" | "plaintext"; @@ -49,6 +67,9 @@ export interface DataTransferFile { envelopeVersion: 1; createdAt: string; sourceDriver: "pg" | "pglite"; + /** Absent on legacy files, which always contained all history groups. */ + selection?: ExportSelection; + summary?: { rows: number; tables: number }; dump: DatabaseDump; /** null = the export carried no secrets (no passphrase given). */ secrets: SealedSecrets | null; @@ -70,3 +91,33 @@ export interface ImportResult { */ localPathProjects: Array<{ slug: string; localPath: string }>; } + +/** One-time capability copied from the destination to the source instance. */ +export interface DirectTransferConnection { + version: 1; + apiBase: string; + recipientRuntimeId: string; + sessionId: string; + token: string; + recipientPublicKey: string; + mode: ImportMode; + expiresAt: string; +} + +export interface DirectTransferEnvelope { + version: 1; + sessionId: string; + senderPublicKey: string; + blob: string; +} + +export interface DirectTransferPayload { + version: 1; + authorizationToken: string; + file: DataTransferFile; + secrets: SecretBundle | null; +} + +export interface DirectTransferResult extends ImportResult { + destination: string; +} diff --git a/apps/api/src/modules/system/system.routes.ts b/apps/api/src/modules/system/system.routes.ts index 4b30f67c0..85f54294a 100644 --- a/apps/api/src/modules/system/system.routes.ts +++ b/apps/api/src/modules/system/system.routes.ts @@ -275,6 +275,22 @@ r.post("/migration/switch-back", { tag: "settings:admin" }, requireInstanceAdmin * that check resolves a caller-selected org and every user is owner of their * own personal org (GHSA-rwq6-r63g-3c8h). Do not "restore" it here. */ +r.get("/data-transfer/preview", { tag: "settings:admin" }, requireInstanceAdmin(), dataTransfer.previewInstanceExportHandler); +r.post("/data-transfer/direct/session", { tag: "settings:admin" }, requireInstanceAdmin(), dataTransfer.createDirectReceiveSessionHandler); +r.post("/data-transfer/direct/send", { tag: "settings:admin" }, requireInstanceAdmin(), dataTransfer.sendDirectTransferHandler); +r.public( + "post", + "/data-transfer/direct/receive", + { + reason: "One-time instance receive capability — payload is ECDH-encrypted and authorized by the expiring token inside it.", + rateLimit: "auth-tight", + }, + bodyLimit({ + maxSize: 700_000_000, + onError: (c) => c.json({ error: "Direct transfer exceeds the 700MB limit.", code: "PAYLOAD_TOO_LARGE" }, 413), + }), + dataTransfer.receiveDirectTransferHandler, +); r.post("/data-transfer/export", { tag: "settings:admin" }, requireInstanceAdmin(), dataTransfer.exportInstanceHandler); r.use( "/data-transfer/import", @@ -286,4 +302,3 @@ r.use( r.post("/data-transfer/import", { tag: "settings:admin" }, requireInstanceAdmin(), dataTransfer.importInstanceHandler); export const systemRoutes = r.hono; - diff --git a/apps/api/test/e2e/routing-rules.e2e.test.ts b/apps/api/test/e2e/routing-rules.e2e.test.ts index 958d75a75..b834b45c3 100644 --- a/apps/api/test/e2e/routing-rules.e2e.test.ts +++ b/apps/api/test/e2e/routing-rules.e2e.test.ts @@ -299,10 +299,6 @@ http { // A catch-all rewrite on a vhost that ALSO carries our webhook location and a // composite backend — the precedence case that leaked signed payloads. - const catchAll = compileVercelRouting({ - rewrites: [{ source: "/:path*", destination: "http://127.0.0.1:9902/x/:path*" }], - }); - expect(catchAll.skipped).toEqual([]); out["hooks"] = await renderVhost({ domain: "hooks.test", tls: false, @@ -310,7 +306,12 @@ http { webhookProxy: "http://127.0.0.1:9903", proxyLocations: [ { pathPrefix: "/api/", targetUrl: "http://127.0.0.1:9904" }, - ...catchAll.proxyLocations, + { + pathPrefix: "/", + targetUrl: "http://127.0.0.1:9902", + pattern: "/(.*)", + upstreamPath: "/x/$1", + }, ], } as unknown as RouteConfig); @@ -466,9 +467,7 @@ http { it("serves clean URLs and canonicalises the .html form", async () => { expect((await ask(port, "/about", "clean.test")).body.trim()).toBe("ABOUT-HTML"); - expect((await ask(port, "/about.html", "clean.test")).location).toBe( - "http://clean.test/about", - ); + expect((await ask(port, "/about.html", "clean.test")).location).toBe("http://clean.test/about"); const { hops, final } = await follow(port, "/", "clean.test"); expect(hops).toEqual([]); expect(final.body.trim()).toBe("ROOT-INDEX"); diff --git a/apps/api/test/lib/compose-parser.test.ts b/apps/api/test/lib/compose-parser.test.ts index 71e9c47c7..42505ac6c 100644 --- a/apps/api/test/lib/compose-parser.test.ts +++ b/apps/api/test/lib/compose-parser.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { blockingComposeFields, parseComposeEnvFile, parseComposeFile } from "../../src/lib/compose-parser"; +import { + blockingComposeFields, + parseComposeEnvFile, + parseComposeFile, + resolveComposeEnvironmentTemplates, +} from "../../src/lib/compose-parser"; describe("parseComposeFile", () => { it("resolves Docker Compose environment interpolation from .env content", () => { @@ -154,13 +159,15 @@ describe("parseComposeEnvFile - quoting, escapes, comments, edge cases", () => { }); it("ignores blank lines and comments", () => { - expect(parseComposeEnvFile(` + expect( + parseComposeEnvFile(` # A leading comment FOO=bar # An indented comment BAZ=qux -`)).toEqual({ FOO: "bar", BAZ: "qux" }); +`), + ).toEqual({ FOO: "bar", BAZ: "qux" }); }); it("strips trailing inline comments outside quotes", () => { @@ -356,6 +363,163 @@ services: expect(worker?.build).toBe("./services/worker"); }); + it("extracts service-specific build args in map and list form (#689)", () => { + const parsed = parseComposeFile( + ` +services: + api: + build: + context: ../../ + dockerfile: services/shared/Dockerfile + args: + APP_PACKAGE: "@myorg/api" + FEATURE_FLAG: true + FROM_ENV: + FROM_TEMPLATE: "\${FROM_ENV}" + worker: + build: + context: ../../ + dockerfile: services/shared/Dockerfile + args: + - APP_PACKAGE=@myorg/worker + - FROM_ENV + - EMPTY= +`, + { env: { FROM_ENV: "resolved" } }, + ); + + expect(parsed.services.find((service) => service.name === "api")?.buildArgs).toEqual({ + APP_PACKAGE: "@myorg/api", + FEATURE_FLAG: "true", + FROM_ENV: null, + FROM_TEMPLATE: "${FROM_ENV}", + }); + expect(parsed.services.find((service) => service.name === "worker")?.buildArgs).toEqual({ + APP_PACKAGE: "@myorg/worker", + FROM_ENV: null, + EMPTY: "", + }); + expect( + parsed.services.find((service) => service.name === "api")?.advanced?.buildArgTemplateKeys, + ).toEqual(["FROM_TEMPLATE"]); + expect( + parsed.services.find((service) => service.name === "worker")?.advanced?.buildArgTemplateKeys, + ).toEqual([]); + }); + + it("keeps required build-arg expressions raw and reports their missing variable", () => { + const parsed = parseComposeFile(` +services: + api: + build: + context: . + args: + TOKEN: \${BUILD_TOKEN:?set BUILD_TOKEN} +`); + + expect(parsed.services[0]?.buildArgs).toEqual({ + TOKEN: "${BUILD_TOKEN:?set BUILD_TOKEN}", + }); + expect(parsed.services[0]?.advanced?.buildArgTemplateKeys).toEqual(["TOKEN"]); + expect(parsed.missingRequired).toEqual([ + { variable: "BUILD_TOKEN", message: "set BUILD_TOKEN" }, + ]); + }); + + it("tracks escaped dollars so they are expanded exactly once", () => { + const parsed = parseComposeFile(` +services: + api: + build: + args: + HOME_REF: "$$HOME" +`); + + expect(parsed.services[0]?.buildArgs).toEqual({ HOME_REF: "$$HOME" }); + expect(parsed.services[0]?.advanced?.buildArgTemplateKeys).toEqual(["HOME_REF"]); + }); + + it("marks an explicitly empty build args block so sync can clear stale stored args", () => { + const parsed = parseComposeFile(` +services: + api: + build: + context: . + args: {} +`); + + expect(parsed.services[0]?.buildArgs).toBeUndefined(); + expect(parsed.services[0]?.advanced?.buildArgTemplateKeys).toEqual([]); + }); + + it("marks a build declaration after its entire args key is removed", () => { + const parsed = parseComposeFile(` +services: + api: + build: + context: . +`); + + expect(parsed.services[0]?.buildArgs).toBeUndefined(); + expect(parsed.services[0]?.advanced?.buildArgTemplateKeys).toEqual([]); + }); + + it("preserves unresolved bare build args as unset so deploy can use its invocation env", () => { + const [service] = parseComposeFile(` +services: + api: + build: + context: . + args: + UNSET_MAP: + -ignored-object: [] +`).services; + expect(service?.buildArgs).toEqual({ UNSET_MAP: null }); + }); + + it("blocks Compose build behavior Openship cannot reproduce", () => { + const parsed = parseComposeFile(` +services: + api: + build: + context: . + target: release + ssh: + - default=private-material + secrets: + - npm_token +`); + + expect( + blockingComposeFields(parsed.unsupported) + .map((issue) => issue.field) + .sort(), + ).toEqual(["build.secrets", "build.ssh", "build.target"]); + expect(JSON.stringify(parsed.unsupported)).not.toContain("private-material"); + }); + + it("blocks malformed build args and contexts outside the linked repository", () => { + const malformed = parseComposeFile(` +services: + api: + build: + context: . + args: + - BAD-KEY=never-log-this +`); + expect(blockingComposeFields(malformed.unsupported)).toEqual([ + expect.objectContaining({ field: "build.args[0]", blocking: true }), + ]); + expect(JSON.stringify(malformed.unsupported)).not.toContain("never-log-this"); + + for (const context of ["https://github.com/acme/app.git", "/srv/app"]) { + const parsed = parseComposeFile(`services:\n api:\n build: ${context}\n`); + expect(blockingComposeFields(parsed.unsupported)).toEqual([ + expect.objectContaining({ field: "build.context", blocking: true }), + ]); + } + }); + it("extracts image-only services (no build, just image)", () => { const parsed = parseComposeFile(` services: @@ -509,7 +673,14 @@ services: { envFileContent: "API_TOKEN=abc123\n" }, ); // interpolation resolves first, THEN shell-split → argv (no sh -c). - expect(parsed.services[0]?.commandArgv).toEqual(["node", "app.js", "--token", "abc123", "--port", "3000"]); + expect(parsed.services[0]?.commandArgv).toEqual([ + "node", + "app.js", + "--token", + "abc123", + "--port", + "3000", + ]); }); it("empty list command → [] (clears image CMD) (#332)", () => { @@ -693,8 +864,9 @@ services: }); it("keeps the author's message verbatim, punctuation and all", () => { - expect(parseComposeFile(compose("DB_URL:?DB_URL must be set (see README)")).missingRequired) - .toEqual([{ variable: "DB_URL", message: "DB_URL must be set (see README)" }]); + expect( + parseComposeFile(compose("DB_URL:?DB_URL must be set (see README)")).missingRequired, + ).toEqual([{ variable: "DB_URL", message: "DB_URL must be set (see README)" }]); }); it("flags the env row as required + missing so the wizard can prompt for it", () => { @@ -816,14 +988,18 @@ describe("parseComposeFile — service resource limits", () => { it("parses the swarm form (deploy.resources.limits)", () => { const parsed = parseComposeFile( - svc(" deploy:\n resources:\n limits:\n memory: 3072M\n cpus: '1.5'\n"), + svc( + " deploy:\n resources:\n limits:\n memory: 3072M\n cpus: '1.5'\n", + ), ); expect(parsed.services[0]?.advanced?.resources).toEqual({ cpuCores: 1.5, memoryMb: 3072 }); }); it("lets the more specific deploy block win over the short form", () => { const parsed = parseComposeFile( - svc(" mem_limit: 512m\n deploy:\n resources:\n limits:\n memory: 8g\n"), + svc( + " mem_limit: 512m\n deploy:\n resources:\n limits:\n memory: 8g\n", + ), ); expect(parsed.services[0]?.advanced?.resources?.memoryMb).toBe(8192); }); @@ -932,9 +1108,7 @@ describe("parseComposeFile — shutdown behavior (stop_signal / stop_grace_perio const svc = (body: string) => `services:\n app:\n image: nginx\n${body}`; it("stores stop_signal and stop_grace_period on advanced without warning", () => { - const parsed = parseComposeFile( - svc(" stop_signal: SIGINT\n stop_grace_period: 1m30s\n"), - ); + const parsed = parseComposeFile(svc(" stop_signal: SIGINT\n stop_grace_period: 1m30s\n")); expect(parsed.services[0]?.advanced?.stopSignal).toBe("SIGINT"); expect(parsed.services[0]?.advanced?.stopGracePeriod).toBe("1m30s"); // The whole point of the fix: these keys are honored, not reported dropped. @@ -1032,7 +1206,9 @@ describe("parseComposeFile — dropped-key reporting", () => { it("names each host-level key it can't honor, as a warning", () => { const parsed = parseComposeFile( - svc(" privileged: true\n cap_add:\n - NET_ADMIN\n sysctls:\n net.ipv4.ip_forward: '1'\n"), + svc( + " privileged: true\n cap_add:\n - NET_ADMIN\n sysctls:\n net.ipv4.ip_forward: '1'\n", + ), ); expect(parsed.unsupported.map((u) => u.field).sort()).toEqual([ "cap_add", @@ -1089,7 +1265,9 @@ describe("parseComposeFile — dropped-key reporting", () => { expect(honored.services[0]?.advanced?.resources?.memoryMb).toBe(1024); const partly = parseComposeFile( - svc(" deploy:\n replicas: 3\n resources:\n limits:\n memory: 1g\n"), + svc( + " deploy:\n replicas: 3\n resources:\n limits:\n memory: 1g\n", + ), ); expect(partly.unsupported.map((u) => u.field)).toEqual(["deploy"]); expect(partly.services[0]?.advanced?.resources?.memoryMb).toBe(1024); @@ -1126,3 +1304,92 @@ describe("parseComposeFile — a key set to its own default is not a loss", () = expect(parsed.unsupported.map((u) => u.field).sort()).toEqual(["pids_limit", "privileged"]); }); }); + +describe("parseComposeFile — deploy-time environment templates (#673)", () => { + it("keeps the raw embedded expression beside its scan-time preview", () => { + const service = parseComposeFile(` +services: + api: + image: example/api + environment: + DATABASE_URL: postgresql://user:\${POSTGRES_PASSWORD:?set it}@db:5432/app + LITERAL: fixed +`).services[0]!; + + expect(service.environment.DATABASE_URL).toBe("postgresql://user:@db:5432/app"); + expect(service.environmentTemplates).toEqual({ + DATABASE_URL: "postgresql://user:${POSTGRES_PASSWORD:?set it}@db:5432/app", + }); + expect(service.advanced?.environmentTemplateKeys).toEqual(["DATABASE_URL"]); + expect(service.environmentMeta?.DATABASE_URL).toMatchObject({ + source: "interpolated", + required: true, + unresolvedVariables: ["POSTGRES_PASSWORD"], + }); + }); + + it("marks list/object passthrough forms and preserves escaped dollars", () => { + const parsed = parseComposeFile(` +services: + api: + image: example/api + environment: + - TOKEN + - PRICE=$$5 + worker: + image: example/worker + environment: + TOKEN: +`); + + expect(parsed.services[0]?.environmentTemplates).toEqual({ + TOKEN: "$TOKEN", + PRICE: "$$5", + }); + expect(parsed.services[1]?.environmentTemplates).toEqual({ TOKEN: "$TOKEN" }); + }); + + it("writes an empty provenance marker when every value is literal", () => { + const service = parseComposeFile(` +services: + api: + image: example/api + environment: + EMPTY: "" +`).services[0]!; + + expect(service.environmentTemplates).toBeUndefined(); + expect(service.advanced?.environmentTemplateKeys).toEqual([]); + }); + + it("keeps Compose default, alternate, nested, and escaped-dollar semantics", () => { + const resolved = resolveComposeEnvironmentTemplates( + { EMPTY: "", SET: "value" }, + { + DEFAULT: "${MISSING:-fallback}", + EMPTY_IS_SET: "${EMPTY-default}", + ALTERNATE: "${SET:+enabled}", + NESTED: "${OUTER:-${INNER:-nested-fallback}}", + ESCAPED: "$$TOKEN", + }, + ); + + expect(resolved.env).toMatchObject({ + DEFAULT: "fallback", + EMPTY_IS_SET: "", + ALTERNATE: "enabled", + NESTED: "nested-fallback", + ESCAPED: "$TOKEN", + }); + expect(resolved.missingRequired).toEqual([]); + }); + + it("reads a self-reference from the lower layer without fixed-point growth", () => { + const resolved = resolveComposeEnvironmentTemplates( + { PATH: "/usr/bin" }, + { PATH: "${PATH}:/app/bin" }, + ); + + expect(resolved.env.PATH).toBe("/usr/bin:/app/bin"); + }); +}); diff --git a/apps/api/test/lib/environment-scope.test.ts b/apps/api/test/lib/environment-scope.test.ts new file mode 100644 index 000000000..f4a377674 --- /dev/null +++ b/apps/api/test/lib/environment-scope.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { Value } from "@sinclair/typebox/value"; +import { ENVIRONMENTS } from "@repo/core"; +import { + EnvironmentScopeSchema, + parseOptionalEnvironmentScope, +} from "../../src/lib/environment-scope"; + +describe("environment scope", () => { + it.each(ENVIRONMENTS)("accepts %s everywhere", (environment) => { + expect(parseOptionalEnvironmentScope(environment)).toBe(environment); + expect(Value.Check(EnvironmentScopeSchema, environment)).toBe(true); + }); + + it("allows an omitted optional scope", () => { + expect(parseOptionalEnvironmentScope(undefined)).toBeUndefined(); + }); + + it.each([null, 1, "staging"])("rejects invalid scope %j", (environment) => { + expect(() => parseOptionalEnvironmentScope(environment)).toThrow("environment must be one of"); + expect(Value.Check(EnvironmentScopeSchema, environment)).toBe(false); + }); +}); diff --git a/apps/api/test/lib/host-executor-guard.test.ts b/apps/api/test/lib/host-executor-guard.test.ts index 7b47bdd00..14ee13be8 100644 --- a/apps/api/test/lib/host-executor-guard.test.ts +++ b/apps/api/test/lib/host-executor-guard.test.ts @@ -106,8 +106,14 @@ describe("target resolution never registers this box", () => { * the call site names `loopback-port`, and matching that would pass for the wrong reason. */ const PIPELINES = [ - "../../src/modules/deployments/build-pipeline.ts", - "../../src/modules/deployments/compose/deploy.service.ts", + { + file: "../../src/modules/deployments/build-pipeline.ts", + executionScope: "async function executeBuildAndDeploy", + }, + { + file: "../../src/modules/deployments/compose/deploy.service.ts", + executionScope: "async function deployComposeServicesUnlocked", + }, ]; /** Drop block and line comments so an index comparison reads CODE positions. */ @@ -116,9 +122,16 @@ function code(src: string): string { } describe("both deploy pipelines announce a demoted host channel", () => { - for (const rel of PIPELINES) { - it(`${rel} emits the notice before any host touchpoint, on every route strategy`, () => { - const src = code(read(rel)); + for (const { file, executionScope } of PIPELINES) { + it(`${file} emits the notice before any host touchpoint, on every route strategy`, () => { + const fileSource = code(read(file)); + const scope = fileSource.indexOf(executionScope); + expect(scope, `${executionScope} is missing`).toBeGreaterThan(-1); + // Scope the ordering check to the function that actually performs the + // deployment. The Compose public wrapper may inspect routeStrategy to choose + // the target-wide allocation lock, but it performs no host operation; the + // unlocked implementation remains the one place that fans out to them. + const src = fileSource.slice(scope); const notice = src.indexOf("hostChannelDeployNotice("); expect(notice, "hostChannelDeployNotice() is never called").toBeGreaterThan(-1); // Before the port allocation and the routing preflight, whose own hints are the diff --git a/apps/api/test/lib/mail-postfix-chroot.test.ts b/apps/api/test/lib/mail-postfix-chroot.test.ts new file mode 100644 index 000000000..e6defc4ed --- /dev/null +++ b/apps/api/test/lib/mail-postfix-chroot.test.ts @@ -0,0 +1,74 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const SCRIPT = join(import.meta.dirname, "../../../../apps/email/docker/postfix-chroot-etc.sh"); +const ENTRYPOINT = readFileSync( + join(import.meta.dirname, "../../../../apps/email/docker/entrypoint.sh"), + "utf8", +); + +const temporaryRoots: string[] = []; + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "openship-postfix-chroot-")); + temporaryRoots.push(root); + const source = join(root, "source-etc"); + const target = join(root, "spool", "etc"); + mkdirSync(source, { recursive: true }); + mkdirSync(target, { recursive: true }); + return { source, target }; +} + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("Postfix chroot DNS/NSS reconciliation (GH-686)", () => { + it("populates an empty persistent spool from the runtime container files", () => { + const { source, target } = fixture(); + writeFileSync(join(source, "resolv.conf"), "nameserver 127.0.0.11\n"); + writeFileSync(join(source, "hosts"), "127.0.0.1 localhost\n"); + writeFileSync(join(source, "nsswitch.conf"), "hosts: files dns\n"); + writeFileSync(join(source, "services"), "smtp 25/tcp\n"); + + execFileSync("bash", [SCRIPT, source, target]); + + expect(readFileSync(join(target, "resolv.conf"), "utf8")).toBe("nameserver 127.0.0.11\n"); + expect(readFileSync(join(target, "hosts"), "utf8")).toBe("127.0.0.1 localhost\n"); + expect(readFileSync(join(target, "nsswitch.conf"), "utf8")).toBe("hosts: files dns\n"); + expect(readFileSync(join(target, "services"), "utf8")).toBe("smtp 25/tcp\n"); + }); + + it("refreshes stale resolver data on every boot instead of seeding only once", () => { + const { source, target } = fixture(); + writeFileSync(join(source, "resolv.conf"), "nameserver 127.0.0.11\n"); + writeFileSync(join(target, "resolv.conf"), "nameserver 192.0.2.1\n"); + + execFileSync("bash", [SCRIPT, source, target]); + expect(readFileSync(join(target, "resolv.conf"), "utf8")).toBe("nameserver 127.0.0.11\n"); + + writeFileSync(join(source, "resolv.conf"), "nameserver 10.0.0.53\n"); + execFileSync("bash", [SCRIPT, source, target]); + expect(readFileSync(join(target, "resolv.conf"), "utf8")).toBe("nameserver 10.0.0.53\n"); + }); + + it("fails closed when no usable resolver can be installed", () => { + const { source, target } = fixture(); + + const result = spawnSync("bash", [SCRIPT, source, target], { encoding: "utf8" }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("resolv.conf is missing or empty"); + }); + + it("runs the reconciliation before the mail supervisor starts", () => { + const reconcile = ENTRYPOINT.indexOf("postfix-chroot-etc.sh"); + const supervisor = ENTRYPOINT.indexOf('exec "$@"'); + + expect(reconcile).toBeGreaterThan(-1); + expect(supervisor).toBeGreaterThan(reconcile); + }); +}); diff --git a/apps/api/test/lib/project-route-store.test.ts b/apps/api/test/lib/project-route-store.test.ts index f6f0b8ea7..75a2e3891 100644 --- a/apps/api/test/lib/project-route-store.test.ts +++ b/apps/api/test/lib/project-route-store.test.ts @@ -255,10 +255,10 @@ describe("syncProjectPublicRoutes", () => { }); }); - // Fix 2b: a DEPLOY (flag on) that mis-resolved its target must never erase a - // user's proven custom domain — the nulling/removal that regressed the Access URL - // to localhost. The Domains EDITOR (flag off) keeps full authority to remove/edit. - describe("preserveVerifiedCustom", () => { + // A DEPLOY (flag on) that omits or mis-resolves a custom domain must never + // erase user configuration. Verification is lifecycle, not ownership. The + // Domains EDITOR (flag off) keeps full authority to remove/edit. + describe("preserveCustomDomains", () => { const verifiedCustom = { id: "dom_api", projectId: "proj_123", @@ -280,7 +280,7 @@ describe("syncProjectPublicRoutes", () => { // Deploy resolved to only the free route; the custom domain is absent. endpoints: [{ port: 3000, domain: "myapp", domainType: "free" }], currentDomains: [verifiedCustom], - preserveVerifiedCustom: true, + preserveCustomDomains: true, }); expect(domainRepo.remove).not.toHaveBeenCalled(); @@ -291,21 +291,21 @@ describe("syncProjectPublicRoutes", () => { projectId: "proj_123", endpoints: [{ port: 3000, domain: "myapp", domainType: "free" }], currentDomains: [verifiedCustom], - // preserveVerifiedCustom omitted → editor authority. + // preserveCustomDomains omitted → editor authority. }); expect(domainRepo.remove).toHaveBeenCalledWith("dom_api"); }); - it("does NOT protect an UNVERIFIED custom domain — the guard is verified-only", async () => { + it("KEEPS an omitted PENDING custom domain during deployment reconciliation", async () => { await syncProjectPublicRoutes({ projectId: "proj_123", endpoints: [{ port: 3000, domain: "myapp", domainType: "free" }], currentDomains: [{ ...verifiedCustom, verified: false, status: "pending" }], - preserveVerifiedCustom: true, + preserveCustomDomains: true, }); - expect(domainRepo.remove).toHaveBeenCalledWith("dom_api"); + expect(domainRepo.remove).not.toHaveBeenCalled(); }); it("does NOT protect a FREE domain — the guard is custom-only", async () => { @@ -318,7 +318,7 @@ describe("syncProjectPublicRoutes", () => { hostname: "old-slug.opsh.io", domainType: "free", }], - preserveVerifiedCustom: true, + preserveCustomDomains: true, }); expect(domainRepo.remove).toHaveBeenCalledWith("dom_free"); @@ -333,7 +333,21 @@ describe("syncProjectPublicRoutes", () => { // Desired route survives normalization (has a path) but carries no port. endpoints: [{ targetPath: "/api", customDomain: "api.openship.io", domainType: "custom" }], currentDomains: [verifiedCustom], - preserveVerifiedCustom: true, + preserveCustomDomains: true, + }); + + const patch = domainRepo.update.mock.calls.find(([id]: [string]) => id === "dom_api")?.[1] as + | Record + | undefined; + expect(patch && "targetPort" in patch).toBeFalsy(); + }); + + it("does NOT null a pending custom domain's port during deployment reconciliation", async () => { + await syncProjectPublicRoutes({ + projectId: "proj_123", + endpoints: [{ targetPath: "/api", customDomain: "api.openship.io", domainType: "custom" }], + currentDomains: [{ ...verifiedCustom, verified: false, status: "pending" }], + preserveCustomDomains: true, }); const patch = domainRepo.update.mock.calls.find(([id]: [string]) => id === "dom_api")?.[1] as @@ -355,4 +369,4 @@ describe("syncProjectPublicRoutes", () => { ); }); }); -}); \ No newline at end of file +}); diff --git a/apps/api/test/lib/proxy-settings-e2e.test.ts b/apps/api/test/lib/proxy-settings-e2e.test.ts index 34f6a3984..40a024dd1 100644 --- a/apps/api/test/lib/proxy-settings-e2e.test.ts +++ b/apps/api/test/lib/proxy-settings-e2e.test.ts @@ -65,7 +65,7 @@ async function applyAndRead(proxy?: unknown) { await reconcileProjectRoutes(project(proxy), { routing: routing as never, registers: [ - { hostname: "app.example.com", targetUrl: "http://127.0.0.1:3000", isCustomDomain: false }, + { hostname: "app.example.com", targetUrl: "http://172.18.0.2:3000", isCustomDomain: false }, ], }); return files.get(`${SITES}/app-example-com.conf`) ?? ""; diff --git a/apps/api/test/lib/proxy-settings-wiring.test.ts b/apps/api/test/lib/proxy-settings-wiring.test.ts index 7a979d1cd..b251a81e0 100644 --- a/apps/api/test/lib/proxy-settings-wiring.test.ts +++ b/apps/api/test/lib/proxy-settings-wiring.test.ts @@ -16,8 +16,7 @@ import { UpdateProjectBody } from "../../src/modules/projects/project.schema"; * would save successfully and then silently do nothing. */ -const validate = (proxy: unknown) => - Value.Check(UpdateProjectBody, { routingConfig: { proxy } }); +const validate = (proxy: unknown) => Value.Check(UpdateProjectBody, { routingConfig: { proxy } }); describe("ProxySettings — the API schema and the renderer agree", () => { const GOOD = [ @@ -35,19 +34,21 @@ describe("ProxySettings — the API schema and the renderer agree", () => { it("accepts every valid shape, and the renderer keeps it", () => { for (const proxy of GOOD) { expect(validate(proxy), `schema rejected ${JSON.stringify(proxy)}`).toBe(true); - expect(sanitizeProxySettings(proxy), `renderer dropped ${JSON.stringify(proxy)}`).toEqual(proxy); + expect(sanitizeProxySettings(proxy), `renderer dropped ${JSON.stringify(proxy)}`).toEqual( + proxy, + ); } }); const BAD = [ - { clientMaxBodySize: "25" }, // no unit — nginx would read it as bytes - { clientMaxBodySize: "25mb" }, // not an nginx size suffix - { clientMaxBodySize: "0m" }, // leading zero excluded by the regex + { clientMaxBodySize: "25" }, // no unit — nginx would read it as bytes + { clientMaxBodySize: "25mb" }, // not an nginx size suffix + { clientMaxBodySize: "0m" }, // leading zero excluded by the regex { clientMaxBodySize: "-5m" }, { clientMaxBodySize: "25m; root /etc" }, // the injection this guards against - { proxyReadTimeout: "300" }, // no unit - { proxyReadTimeout: "300ms" }, // not one of the accepted time forms - { clientMaxBodySize: 25 }, // wrong type + { proxyReadTimeout: "300" }, // no unit + { proxyReadTimeout: "300ms" }, // not one of the accepted time forms + { clientMaxBodySize: 25 }, // wrong type ]; it("rejects malformed values at the API, and the renderer drops them too", () => { @@ -55,7 +56,8 @@ describe("ProxySettings — the API schema and the renderer agree", () => { expect(validate(proxy), `schema accepted ${JSON.stringify(proxy)}`).toBe(false); // Belt and braces: even if one slipped past the schema, nothing reaches config. expect( - sanitizeProxySettings(proxy)?.clientMaxBodySize ?? sanitizeProxySettings(proxy)?.proxyReadTimeout, + sanitizeProxySettings(proxy)?.clientMaxBodySize ?? + sanitizeProxySettings(proxy)?.proxyReadTimeout, ).toBeUndefined(); } }); @@ -131,7 +133,9 @@ describe("ProxySettings — every directive in the table is wired end to end", ( const proxy = { [spec.key]: badFor(spec) }; expect(validate(proxy), `schema accepted bad ${spec.directive}`).toBe(false); expect( - sanitizeProxySettings(proxy)?.[spec.key as keyof ReturnType & string], + sanitizeProxySettings(proxy)?.[ + spec.key as keyof ReturnType & string + ], `renderer kept bad ${spec.directive}`, ).toBeUndefined(); } @@ -145,7 +149,10 @@ describe("ProxySettings — every directive in the table is wired end to end", ( for (const attack of attacks) { const proxy = { [spec.key]: attack }; expect(validate(proxy), `schema accepted ${spec.directive}=${attack}`).toBe(false); - expect(sanitizeProxySettings(proxy), `renderer kept ${spec.directive}=${attack}`).toBeUndefined(); + expect( + sanitizeProxySettings(proxy), + `renderer kept ${spec.directive}=${attack}`, + ).toBeUndefined(); } } }); @@ -338,8 +345,8 @@ describe("compiled vercel.json rules — the live reconcile writers carry them t // Spreading the compiled fields over a fan-out register would ASSIGN over its // per-path upstreams: the domain keeps serving, with `/v3` quietly pointing at the // root service instead of the API. Same order as the deploy path — fan-out first. - expect(source("../../src/modules/domains/routing-apply.service.ts")).toContain( - "[...(reg.proxyLocations ?? []), ...(routingFields.proxyLocations ?? [])]", - ); + expect( + source("../../src/modules/domains/routing-apply.service.ts").replace(/\s+/g, ""), + ).toContain("...(reg.proxyLocations??[]),...(routingFields.proxyLocations??[])"); }); }); diff --git a/apps/api/test/lib/release-dist.test.ts b/apps/api/test/lib/release-dist.test.ts index 71261614b..40bfac4fa 100644 --- a/apps/api/test/lib/release-dist.test.ts +++ b/apps/api/test/lib/release-dist.test.ts @@ -3,24 +3,34 @@ import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -// The download + SSRF guard live in release-download; mock them so these tests +// The download lives in release-download; mock it so these tests // exercise ONLY the 3-slot resolution + latest-version logic (no network, no fs -// extraction). assertPublicHttps → no-op; fetchAndExtractRelease → controllable. +// extraction). fetchAndExtractRelease → controllable. vi.mock("../../src/lib/release-download", () => ({ fetchAndExtractRelease: vi.fn(), - assertPublicHttps: vi.fn(), })); +// URL-mode version discovery is user-controlled and must go through the DNS- +// pinned safe client. Mock the transport, then assert the security policy the +// resolver hands it rather than touching the network. +vi.mock("../../src/lib/safe-fetch", () => ({ safeFetch: vi.fn() })); + import { resolveReleaseDist, resolveReleaseDistOrNull, + resolveLatestReleaseVersion, resolveLatestVersion, + resolveReleaseVersion, ReleaseDistMissingError, + ReleaseVersionUnavailableError, } from "../../src/lib/release-dist"; import { fetchAndExtractRelease } from "../../src/lib/release-download"; +import { safeFetch } from "../../src/lib/safe-fetch"; +import { env } from "../../src/config/env"; import type { ReleaseSource } from "@repo/core"; const fetchMock = fetchAndExtractRelease as unknown as ReturnType; +const safeFetchMock = safeFetch as unknown as ReturnType; let root: string; const ENV_KEY = "TEST_RELEASE_DIST_OVERRIDE"; @@ -28,6 +38,7 @@ const ENV_KEY = "TEST_RELEASE_DIST_OVERRIDE"; beforeEach(() => { root = mkdtempSync(join(tmpdir(), "release-dist-")); fetchMock.mockReset(); + safeFetchMock.mockReset(); delete process.env[ENV_KEY]; }); @@ -190,41 +201,129 @@ describe("resolveLatestVersion", () => { globalThis.fetch = realFetch; }); - it("github: strips the leading v from the latest release tag", async () => { + it("github: keeps the publisher's raw tag alongside the normalized version", async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ tag_name: "v3.4.5" }), }) as unknown as typeof fetch; - expect(await resolveLatestVersion(github)).toBe("3.4.5"); + + expect(await resolveLatestReleaseVersion(github)).toEqual({ + version: "3.4.5", + tag: "v3.4.5", + }); }); - it("url: reads a bare version body from versionUrl", async () => { + it("keeps resolveLatestVersion as a normalized-string compatibility API", async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, - text: async () => "v7.8.9\n", + json: async () => ({ tag_name: "v3.4.5" }), }) as unknown as typeof fetch; + expect(await resolveLatestVersion(github)).toBe("3.4.5"); + }); + + it("url: reads a bare version through the DNS-pinned, redirect-safe client", async () => { + const nativeFetch = vi.fn(); + globalThis.fetch = nativeFetch as unknown as typeof fetch; + safeFetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => "v7.8.9\n", + }); const source: ReleaseSource = { mode: "url", distUrl: "https://cdn/x-{version}.tgz", versionUrl: "https://cdn/latest.txt", }; + + expect(await resolveLatestReleaseVersion(source)).toEqual({ + version: "7.8.9", + tag: "v7.8.9", + }); expect(await resolveLatestVersion(source)).toBe("7.8.9"); + expect(safeFetchMock).toHaveBeenCalledWith("https://cdn/latest.txt", { + headers: { "User-Agent": "openship" }, + timeoutMs: 10_000, + maxRedirects: 5, + maxBodyBytes: 8192, + allowPrivate: !env.CLOUD_MODE, + }); + expect(nativeFetch).not.toHaveBeenCalled(); }); - it("url: parses a JSON {version} body", async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ + it("url: parses JSON version and tag_name bodies without losing the raw tag", async () => { + safeFetchMock.mockResolvedValueOnce({ ok: true, + status: 200, text: async () => JSON.stringify({ version: "10.0.1" }), - }) as unknown as typeof fetch; + }); const source: ReleaseSource = { mode: "url", distUrl: "https://cdn/x.tgz", versionUrl: "https://cdn/latest.json", }; - expect(await resolveLatestVersion(source)).toBe("10.0.1"); + expect(await resolveLatestReleaseVersion(source)).toEqual({ + version: "10.0.1", + tag: "10.0.1", + }); + + safeFetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => JSON.stringify({ tag_name: "v10.0.2" }), + }); + expect(await resolveLatestReleaseVersion(source)).toEqual({ + version: "10.0.2", + tag: "v10.0.2", + }); + }); + + it("url: fails soft when the SSRF-safe client rejects the target", async () => { + safeFetchMock.mockRejectedValue(new Error("SSRF_BLOCKED")); + const source: ReleaseSource = { + mode: "url", + versionUrl: "https://metadata.internal/latest", + }; + + await expect(resolveLatestReleaseVersion(source)).resolves.toBeNull(); }); it("url with no versionUrl → null (no drift source)", async () => { expect(await resolveLatestVersion({ mode: "url", distUrl: "https://cdn/x.tgz" })).toBeNull(); + expect(safeFetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("resolveReleaseVersion", () => { + it("uses explicit input, then pinnedVersion, without consulting upstream", async () => { + const source: ReleaseSource = { + mode: "url", + versionUrl: "https://cdn/latest.txt", + pinnedVersion: "v2.0.0", + }; + + await expect(resolveReleaseVersion(source, { version: "v1.4.0" })).resolves.toEqual({ + version: "1.4.0", + tag: "v1.4.0", + }); + await expect(resolveReleaseVersion(source)).resolves.toEqual({ + version: "2.0.0", + tag: "v2.0.0", + }); + expect(safeFetchMock).not.toHaveBeenCalled(); + }); + + it("throws a typed error instead of borrowing Openship's API version", async () => { + safeFetchMock.mockRejectedValue(new Error("upstream unavailable")); + const source: ReleaseSource = { + mode: "url", + versionUrl: "https://cdn.example.com/latest.txt", + }; + + const failure = resolveReleaseVersion(source); + await expect(failure).rejects.toBeInstanceOf(ReleaseVersionUnavailableError); + await expect(resolveReleaseVersion(source)).rejects.toMatchObject({ + code: "RELEASE_VERSION_UNAVAILABLE", + message: expect.stringContaining("Set pinnedVersion"), + }); }); }); diff --git a/apps/api/test/lib/route-apply-proxy.test.ts b/apps/api/test/lib/route-apply-proxy.test.ts index cd3b2353b..ef48e02af 100644 --- a/apps/api/test/lib/route-apply-proxy.test.ts +++ b/apps/api/test/lib/route-apply-proxy.test.ts @@ -24,7 +24,16 @@ const project = (proxy?: unknown) => ({ routingConfig: proxy ? ({ proxy } as never) : null, }); -const REGISTER = [{ hostname: "app.example.com", targetUrl: "http://127.0.0.1:3000", isCustomDomain: false }]; +// Request-limit behavior is independent of host-port ownership. Use a bridge +// upstream so this focused test does not bypass the loopback route guard with +// invented ownership metadata. +const REGISTER = [ + { + hostname: "app.example.com", + targetUrl: "http://172.18.0.2:3000", + isCustomDomain: false, + }, +]; function fakeRouting() { const registerRoute = vi.fn(async () => {}); @@ -84,7 +93,13 @@ describe("reconcileProjectRoutes — project request limits", () => { await reconcileProjectRoutes(project({ clientMaxBodySize: "50m" }), { routing, - registers: [{ hostname: "site.example.com", staticRoot: "/opt/openship/static/site", isCustomDomain: false }], + registers: [ + { + hostname: "site.example.com", + staticRoot: "/opt/openship/static/site", + isCustomDomain: false, + }, + ], }); expect(registerRoute.mock.calls[0][0]).toMatchObject({ diff --git a/apps/api/test/lib/routing-domains.test.ts b/apps/api/test/lib/routing-domains.test.ts index 7b11cdcb7..d2cd5fb63 100644 --- a/apps/api/test/lib/routing-domains.test.ts +++ b/apps/api/test/lib/routing-domains.test.ts @@ -7,10 +7,16 @@ vi.mock("@repo/db", () => ({ updateSsl: vi.fn(), markVerifiedActive: vi.fn(), findOrCreate: vi.fn(), + findOrCreateWithStatus: vi.fn(), + findByHostname: vi.fn(), }, }, })); +vi.mock("../../src/lib/domain-claims", () => ({ + routableWithoutOwnership: vi.fn().mockResolvedValue(false), +})); + // The per-host ACME lock talks to Postgres in prod; make it a pass-through here. vi.mock("../../src/lib/provision-lock", () => ({ createProvisionLock: () => ({ run: (fn: () => unknown) => fn() }), @@ -26,11 +32,61 @@ import { serviceCustomHostnames, getRoutingBaseDomain, createTrackedSslProvider, + ensureRouteDomainRecord, resolveRouteDestination, resolveServiceEndpointHostname, withEnsuredDomainRecord, } from "../../src/lib/routing-domains"; +describe("ensureRouteDomainRecord", () => { + const route = { + hostname: "app.example.com", + domainType: "custom", + targetPort: 3000, + createIfMissing: true, + } as any; + + beforeEach(() => { + vi.mocked(repos.domain.findByHostname).mockReset().mockResolvedValue(undefined); + vi.mocked(repos.domain.findOrCreateWithStatus).mockReset(); + }); + + it("returns database-authoritative creation provenance", async () => { + const domain = { + id: "dom_app", + projectId: "proj_a", + hostname: route.hostname, + domainType: "custom", + } as any; + vi.mocked(repos.domain.findOrCreateWithStatus).mockResolvedValue({ domain, created: true }); + const domainByHostname = new Map(); + + await expect( + ensureRouteDomainRecord({ projectId: "proj_a", route, domainByHostname }), + ).resolves.toEqual({ domain, created: true }); + expect(domainByHostname.get(route.hostname)).toBe(domain); + }); + + it("rejects a foreign project that wins the create race", async () => { + const raced = { + id: "dom_foreign", + projectId: "proj_b", + hostname: route.hostname, + domainType: "custom", + } as any; + vi.mocked(repos.domain.findOrCreateWithStatus).mockResolvedValue({ + domain: raced, + created: false, + }); + const domainByHostname = new Map(); + + await expect( + ensureRouteDomainRecord({ projectId: "proj_a", route, domainByHostname }), + ).rejects.toThrow("another project"); + expect(domainByHostname.size).toBe(0); + }); +}); + const customSvc = { id: "svc_web", name: "web", diff --git a/apps/api/test/lib/secret-env.test.ts b/apps/api/test/lib/secret-env.test.ts index b650b29c6..9f2813fa6 100644 --- a/apps/api/test/lib/secret-env.test.ts +++ b/apps/api/test/lib/secret-env.test.ts @@ -197,6 +197,7 @@ describe("maskEnvironmentMeta", () => { source: "missing", variable: "POSTGRES_PASSWORD", required: true, + unresolvedVariables: ["POSTGRES_PASSWORD"], resolvedValue: "", }, }), @@ -205,6 +206,7 @@ describe("maskEnvironmentMeta", () => { source: "missing", variable: "POSTGRES_PASSWORD", required: true, + unresolvedVariables: ["POSTGRES_PASSWORD"], resolvedValue: "", }, }); @@ -224,6 +226,25 @@ describe("maskScanService", () => { // input untouched expect(svc.environment.PASSWORD).toBe("secret"); }); + + test("never returns transient raw environment expressions", () => { + const expression = "postgres://user:${PASSWORD:-literal-secret}@db/app"; + const masked = maskScanService({ + name: "api", + environment: { DATABASE_URL: "postgres://user:literal-secret@db/app" }, + environmentTemplates: { DATABASE_URL: expression }, + advanced: { + environmentTemplateKeys: ["DATABASE_URL"], + readiness: { enabled: true }, + }, + }); + + expect(masked.environment.DATABASE_URL).toBe(ENV_MASK); + expect("environmentTemplates" in masked).toBe(false); + expect(masked.advanced).toEqual({ readiness: { enabled: true } }); + expect(JSON.stringify(masked)).not.toContain(expression); + expect(JSON.stringify(masked)).not.toContain("literal-secret"); + }); }); describe("maskDeploymentEnv", () => { diff --git a/apps/api/test/lib/upstream-url.test.ts b/apps/api/test/lib/upstream-url.test.ts index 84d02bf7e..82ea6805d 100644 --- a/apps/api/test/lib/upstream-url.test.ts +++ b/apps/api/test/lib/upstream-url.test.ts @@ -2,10 +2,12 @@ import { describe, it, expect } from "vitest"; import { resolveUpstreamUrl, resolveRouteStrategy } from "../../src/lib/upstream-url"; const dockerRuntime = { + name: "docker", supports: (c: string) => c === "containerIp", getContainerIp: async () => "172.18.0.5", }; const bareRuntime = { + name: "bare", supports: (c: string) => c === "containerIp", getContainerIp: async () => "127.0.0.1", }; @@ -58,7 +60,7 @@ describe("resolveUpstreamUrl", () => { it("returns null when the container IP can't be resolved", async () => { const url = await resolveUpstreamUrl({ strategy: "container-ip", - runtime: { supports: () => true, getContainerIp: async () => null }, + runtime: { name: "docker", supports: () => true, getContainerIp: async () => null }, containerId: "gone", containerPort: 3000, }); diff --git a/apps/api/test/modules/data-transfer.test.ts b/apps/api/test/modules/data-transfer.test.ts index 7f30ef8da..2807e974b 100644 --- a/apps/api/test/modules/data-transfer.test.ts +++ b/apps/api/test/modules/data-transfer.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; // Skip the full zod-validated env (which refuses to load outside desktop mode // without INTERNAL_TOKEN); the crypto helpers only need BETTER_AUTH_SECRET. vi.mock("../../src/config/env", () => ({ - env: { BETTER_AUTH_SECRET: "test-secret-for-data-transfer-unit-tests" }, + env: { BETTER_AUTH_SECRET: "test-secret-for-data-transfer-unit-tests", CLOUD_MODE: false }, })); import { encrypt, decrypt } from "../../src/lib/encryption"; @@ -11,11 +11,30 @@ import { encryptSecretField, decryptSecretField } from "../../src/lib/credential import { sealSecretBundle, openSecretBundle, + openTransferSecrets, WrongPassphraseError, } from "../../src/modules/system/data-transfer/passphrase-crypto"; import { extractPlaintext, sealForInstance } from "../../src/modules/system/data-transfer/secret-codec"; -import type { SecretColumn } from "../../src/modules/system/data-transfer/secret-registry"; +import { SECRET_COLUMNS, type SecretColumn } from "../../src/modules/system/data-transfer/secret-registry"; +import { + EXPORT_HISTORY_CATEGORIES, + HISTORY_TABLES, + InvalidExportSelectionError, + resolveExportSelection, + summarizeExportCounts, +} from "../../src/modules/system/data-transfer/selection"; import type { SecretBundle } from "../../src/modules/system/data-transfer/types"; +import { + clearDirectReceiveSessionsForTest, + createDirectReceiveSession, + decodeDirectTransferCode, + DirectTransferSessionError, + receiveDirectTransfer, + sealDirectTransferPayload, + sendDirectTransfer, +} from "../../src/modules/system/data-transfer/direct-transfer.service"; +import { importPreparedInstance, InvalidTransferFileError } from "../../src/modules/system/data-transfer/import.service"; +import type { DataTransferFile, DirectTransferPayload } from "../../src/modules/system/data-transfer/types"; // The codec only reads scheme/secretPaths/sqlName/column, so a minimal cast is // enough to exercise it without touching the DB-backed registry. @@ -43,6 +62,74 @@ describe("passphrase-crypto", () => { const sealed = sealSecretBundle(bundle, "correct horse"); expect(() => openSecretBundle(sealed, "wrong")).toThrow(WrongPassphraseError); }); + + it("requires a transfer secret whenever the export contains credentials", () => { + const sealed = sealSecretBundle(bundle, "correct horse"); + expect(() => openTransferSecrets(sealed)).toThrow(WrongPassphraseError); + expect(openTransferSecrets(null)).toBeNull(); + }); +}); + +describe("one-time direct instance transfer", () => { + it("creates a decodable, expiring destination capability", () => { + clearDirectReceiveSessionsForTest(); + const created = createDirectReceiveSession({ apiBase: "https://new.example/api/", mode: "wipe" }); + const decoded = decodeDirectTransferCode(created.code); + expect(decoded.apiBase).toBe("https://new.example/api/"); + expect(decoded.mode).toBe("wipe"); + expect(decoded.token.length).toBeGreaterThan(32); + expect(Date.parse(decoded.expiresAt)).toBeGreaterThan(Date.now()); + }); + + it("authenticates and decrypts once, then consumes the receive code", async () => { + clearDirectReceiveSessionsForTest(); + const created = createDirectReceiveSession({ apiBase: "https://new.example/api/", mode: "wipe" }); + const connection = decodeDirectTransferCode(created.code); + const payload: DirectTransferPayload = { + version: 1, + authorizationToken: connection.token, + // Deliberately invalid after decryption: proves the encrypted capability + // opened, while stopping before any restore query/write. + file: { kind: "bad" } as unknown as DataTransferFile, + secrets: null, + }; + const envelope = sealDirectTransferPayload(connection, payload); + + await expect(receiveDirectTransfer(envelope)).rejects.toThrow(InvalidTransferFileError); + await expect(receiveDirectTransfer(envelope)).rejects.toThrow(DirectTransferSessionError); + }); + + it("rejects a payload that does not know the capability token", async () => { + clearDirectReceiveSessionsForTest(); + const created = createDirectReceiveSession({ apiBase: "https://new.example/api/", mode: "merge" }); + const connection = decodeDirectTransferCode(created.code); + const envelope = sealDirectTransferPayload(connection, { + version: 1, + authorizationToken: "not-the-token", + file: { kind: "bad" } as unknown as DataTransferFile, + secrets: null, + }); + await expect(receiveDirectTransfer(envelope)).rejects.toThrow(DirectTransferSessionError); + }); + + it("refuses a receive code generated by the same instance before building a dump", async () => { + clearDirectReceiveSessionsForTest(); + const created = createDirectReceiveSession({ apiBase: "https://same.example/api/", mode: "wipe" }); + await expect(sendDirectTransfer({ code: created.code })).rejects.toThrow("same instance"); + }); + + it("validates a decrypted credential bundle before the first restore operation", async () => { + const file = { + kind: "openship-instance-export", + envelopeVersion: 1, + dump: { scope: { kind: "instance" }, tables: {} }, + } as unknown as DataTransferFile; + await expect(importPreparedInstance({ + file, + secrets: { version: 1, entries: [{ table: "env_var", id: "1", column: "value", scheme: "scalar", value: 42 }] } as never, + mode: "wipe", + })).rejects.toThrow(InvalidTransferFileError); + }); }); describe("secret-codec round-trips (extract → seal → decrypt)", () => { @@ -96,3 +183,65 @@ describe("secret-codec round-trips (extract → seal → decrypt)", () => { expect(extractPlaintext(spec("scalar", "value"), "id1", "")).toBeNull(); }); }); + +describe("server credential transfer coverage (#656)", () => { + it("registers every SSH credential column for decrypt and destination re-encryption", () => { + const columns = SECRET_COLUMNS + .filter((entry) => entry.sqlName === "servers") + .map((entry) => [entry.column, entry.scheme]); + + expect(columns).toEqual([ + ["sshPassword", "enc1"], + ["sshPrivateKey", "enc1"], + ["sshKeyPassphrase", "enc1"], + ]); + }); +}); + +describe("dependency-safe export filtering (#656)", () => { + it("summarizes core and each optional history group for the pre-export UI", () => { + expect(summarizeExportCounts({ + project: 3, + servers: 2, + resource_usage: 100, + server_analytics: 20, + audit_event: 7, + notification_delivery: 4, + backup_run: 5, + backup_restore: 2, + service_incident: 6, + docker_migration_run: 1, + })).toEqual({ + core: 5, + history: { analytics: 120, activity: 11, backups: 7, incidents: 6, migrations: 1 }, + total: 150, + }); + }); + + it("keeps the legacy full export when selection is omitted", () => { + expect(resolveExportSelection()).toEqual({ + selection: { history: [...EXPORT_HISTORY_CATEGORIES] }, + excludedTables: [], + }); + }); + + it("excludes only unselected optional history groups", () => { + const result = resolveExportSelection({ history: ["incidents"] }); + expect(result.selection.history).toEqual(["incidents"]); + expect(result.excludedTables).toEqual([ + ...HISTORY_TABLES.analytics, + ...HISTORY_TABLES.activity, + ...HISTORY_TABLES.backups, + ...HISTORY_TABLES.migrations, + ]); + expect(result.excludedTables).not.toContain("service_incident"); + expect(result.excludedTables).not.toContain("servers"); + expect(result.excludedTables).not.toContain("project"); + }); + + it("rejects arbitrary table/category input", () => { + expect(() => + resolveExportSelection({ history: ["servers" as never] }), + ).toThrow(InvalidExportSelectionError); + }); +}); diff --git a/apps/api/test/modules/deployments/build.service.test.ts b/apps/api/test/modules/deployments/build.service.test.ts index 22f8adb3c..f0dd0352f 100644 --- a/apps/api/test/modules/deployments/build.service.test.ts +++ b/apps/api/test/modules/deployments/build.service.test.ts @@ -1,4 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; const { assertGitHubRepoAccess, @@ -6,6 +9,7 @@ const { getForwardGitToServer, kickoffBuild, repos, + resolveProjectInfo, resolveProjectRouteState, resolveServicePipelineMode, resolveSmartRoute, @@ -21,10 +25,12 @@ const { project: { findById: vi.fn(), getEnvMap: vi.fn(), + listEnvVarChangeMeta: vi.fn(), update: vi.fn(), }, deployment: { findById: vi.fn(), + findInProgressByCommit: vi.fn(), listByProject: vi.fn(), getLatestSuccessfulForBranch: vi.fn(), create: vi.fn(), @@ -34,9 +40,14 @@ const { }, service: { listByProject: vi.fn(), + reconcileFromCompose: vi.fn(), syncFromCompose: vi.fn(), }, + serviceDeployment: { + latestByProject: vi.fn(), + }, }, + resolveProjectInfo: vi.fn(), resolveProjectRouteState: vi.fn(), resolveServicePipelineMode: vi.fn(), resolveSmartRoute: vi.fn(), @@ -56,6 +67,10 @@ vi.mock("../../../src/modules/deployments/preflight", () => ({ runPreflightChecks, })); +vi.mock("../../../src/modules/deployments/prepare.service", () => ({ + resolveProjectInfo, +})); + vi.mock("../../../src/modules/deployments/build-pipeline", () => ({ kickoffBuild, resolveServicePipelineMode, @@ -87,15 +102,19 @@ vi.mock("../../../src/modules/deployments/smart-route", () => ({ })); import { + applyReleaseSourceToSnapshot, + redeployBuildSession, requestBuildAccess, resolveSnapshotTarget, triggerDeployment, type DeploymentConfigSnapshot, } from "../../../src/modules/deployments/build.service"; +import type { ReleaseSource } from "@repo/core"; import { newFolderSessionId, putFolderSession, } from "../../../src/modules/projects/folder/session-store"; +import { ComposeConfigurationError } from "../../../src/modules/deployments/compose-configuration-error"; const ctx = { userId: "user-1", organizationId: "org-1" } as any; @@ -143,6 +162,8 @@ const composeServices = [ image: undefined, build: ".", dockerfile: "Dockerfile", + buildArgs: { APP_PACKAGE: "@myorg/web" }, + advanced: { buildArgTemplateKeys: [] }, ports: ["3000:3000"], dependsOn: [], environment: {}, @@ -180,6 +201,146 @@ function baseSnapshot(): DeploymentConfigSnapshot { }; } +function releaseSnapshot( + overrides: Partial = {}, +): DeploymentConfigSnapshot { + return { + ...baseSnapshot(), + repoUrl: "https://github.com/acme/app.git", + branch: "main", + framework: "node", + buildImage: "node:22-custom-builder", + runtimeImage: "node:22-alpine", + installCommand: "npm ci", + buildCommand: "npm run build", + outputDirectory: "dist", + productionPaths: ["dist", "node_modules"], + volumes: [], + startCommand: "npm start", + hasServer: true, + hasBuild: true, + source: "git", + build: "buildpack", + workload: "web", + localPath: "/srv/old-source", + composeServices: undefined, + ...overrides, + }; +} + +describe("applyReleaseSourceToSnapshot", () => { + it("freezes the normalized version, raw tag, and rendered image without repurposing buildImage", async () => { + const source: ReleaseSource = { + mode: "github", + artifactKind: "image", + repo: "acme/app", + imageTemplate: "ghcr.io/acme/app:{tag}", + pinnedVersion: "v1.2.3", + }; + const project = baseProject({ + gitProvider: "release", + releaseSource: source, + localPath: null, + framework: "node", + }); + const snapshot = releaseSnapshot(); + + await expect(applyReleaseSourceToSnapshot(project as never, snapshot)).resolves.toBe("1.2.3"); + + expect(snapshot).toMatchObject({ + releaseVersion: "1.2.3", + releaseTag: "v1.2.3", + releaseImageRef: "ghcr.io/acme/app:v1.2.3", + releaseRepo: "acme/app", + repoUrl: "", + installCommand: "", + buildCommand: "", + hasBuild: false, + source: "image", + build: "prebuilt", + runtimeMode: "docker", + }); + expect(snapshot.localPath).toBeUndefined(); + // buildImage is the source-build sandbox, never the application artifact. + expect(snapshot.buildImage).toBe("node:22-custom-builder"); + expect(snapshot.runtimeImage).toBe("node:22-alpine"); + // A caller-supplied image command remains an intentional override. + expect(snapshot.startCommand).toBe("npm start"); + }); + + it("keeps legacy archive releases on the extracted-directory path", async () => { + const previousDataDir = process.env.OPENSHIP_DATA_DIR; + const dataDir = mkdtempSync(join(tmpdir(), "openship-release-archive-")); + const extracted = join(dataDir, "my-stack-dist", "v2.4.0"); + mkdirSync(extracted, { recursive: true }); + process.env.OPENSHIP_DATA_DIR = dataDir; + + try { + const source: ReleaseSource = { + mode: "github", + // Deliberately omitted: legacy rows default to archive. + repo: "acme/archive-app", + pinnedVersion: "v2.4.0", + }; + const project = baseProject({ + gitProvider: "release", + releaseSource: source, + localPath: null, + framework: "node", + }); + const snapshot = releaseSnapshot({ source: "image", build: "prebuilt" }); + + await expect(applyReleaseSourceToSnapshot(project as never, snapshot)).resolves.toBe("2.4.0"); + + expect(snapshot).toMatchObject({ + releaseVersion: "2.4.0", + releaseTag: "v2.4.0", + releaseRepo: "acme/archive-app", + localPath: extracted, + repoUrl: "", + buildCommand: "", + installCommand: "npm ci", + hasBuild: true, + }); + expect(snapshot.releaseImageRef).toBeUndefined(); + expect(snapshot.buildImage).toBe("node:22-custom-builder"); + } finally { + if (previousDataDir === undefined) delete process.env.OPENSHIP_DATA_DIR; + else process.env.OPENSHIP_DATA_DIR = previousDataDir; + rmSync(dataDir, { recursive: true, force: true }); + } + }); + + it("rejects a container release configured as a static-file workload", async () => { + const source: ReleaseSource = { + mode: "github", + artifactKind: "image", + repo: "acme/app", + imageTemplate: "ghcr.io/acme/app:{version}", + pinnedVersion: "1.2.3", + }; + const project = baseProject({ + gitProvider: "release", + releaseSource: source, + localPath: null, + framework: "static", + }); + const snapshot = releaseSnapshot({ + framework: "static", + workload: "static", + build: "static", + hasServer: false, + startCommand: "", + }); + + await expect(applyReleaseSourceToSnapshot(project as never, snapshot)).rejects.toMatchObject({ + statusCode: 400, + code: "RELEASE_IMAGE_STATIC_UNSUPPORTED", + }); + expect(snapshot.releaseImageRef).toBeUndefined(); + }); +}); + /** * The single place that decides a snapshot's target. The durable `project.serverId` * (Fix 2a) is what stops a server-hosted project from regressing to "local" on a @@ -222,18 +383,16 @@ describe("resolveSnapshotTarget", () => { }); it("lets cloud win over a stray serverId and drops the serverId", async () => { - const t = await resolveSnapshotTarget( - project({ cloudWorkspaceId: "ws_1", serverId: "srv_1" }), - ); + const t = await resolveSnapshotTarget(project({ cloudWorkspaceId: "ws_1", serverId: "srv_1" })); expect(t.deployTarget).toBe("cloud"); expect(t.serverId).toBeUndefined(); }); it("lets an explicit override win over the durable binding", async () => { - const t = await resolveSnapshotTarget( - project({ serverId: "srv_1" }), - { deployTarget: "server", serverId: "srv_override" }, - ); + const t = await resolveSnapshotTarget(project({ serverId: "srv_1" }), { + deployTarget: "server", + serverId: "srv_override", + }); expect(t).toMatchObject({ deployTarget: "server", serverId: "srv_override" }); }); @@ -262,9 +421,13 @@ describe("triggerDeployment", () => { repos.project.findById.mockResolvedValue(baseProject()); repos.project.getEnvMap.mockResolvedValue({}); + repos.project.listEnvVarChangeMeta.mockResolvedValue([]); // Only read by the best-effort compose-drift reconcile (git projects). repos.service.listByProject.mockResolvedValue([]); + repos.service.reconcileFromCompose.mockResolvedValue({ driftedNames: [] }); + repos.serviceDeployment.latestByProject.mockResolvedValue(new Map()); repos.deployment.listByProject.mockResolvedValue({ rows: [] }); + repos.deployment.findInProgressByCommit.mockResolvedValue(null); repos.deployment.getLatestSuccessfulForBranch.mockResolvedValue(null); repos.deployment.create.mockResolvedValue({ id: "dep-1", projectId: "project-1" }); repos.deployment.createBuildSession.mockResolvedValue(undefined); @@ -278,6 +441,7 @@ describe("triggerDeployment", () => { primarySlug: undefined, publicEndpoints: [], }); + resolveProjectInfo.mockResolvedValue({ services: composeServices }); resolveServicePipelineMode.mockResolvedValue({ useServicePipeline: true, servicePreflightServices: composeServices, @@ -313,6 +477,186 @@ describe("triggerDeployment", () => { ); }); + it("bootstraps a declared composePath even when the first webhook changed another file (#689)", async () => { + const commitSha = "1eeaf7692a19ee6e7ecb64b9d1a5c3ee7c0ac2f5"; + let storedRows: Record[] = []; + repos.service.listByProject.mockImplementation(async () => storedRows); + repos.service.reconcileFromCompose.mockImplementation(async (_projectId, parsed) => { + storedRows = parsed.map((service: Record, index: number) => ({ + ...service, + id: `svc-${index}`, + projectId: "project-1", + kind: "compose", + enabled: true, + exposed: service.exposed ?? false, + })); + return { services: storedRows, driftedNames: [] }; + }); + const actualPipeline = await vi.importActual< + typeof import("../../../src/modules/deployments/build-pipeline") + >("../../../src/modules/deployments/build-pipeline"); + resolveServicePipelineMode.mockImplementationOnce(actualPipeline.resolveServicePipelineMode); + repos.project.findById.mockResolvedValue( + baseProject({ + framework: "docker", + composePath: "deploy/stack.yml", + gitProvider: "github", + gitUrl: "https://github.com/acme/app.git", + gitOwner: "acme", + gitRepo: "app", + localPath: null, + }), + ); + + await triggerDeployment(ctx, { + projectId: "project-1", + branch: "main", + commitSha, + trigger: "webhook", + changedPaths: ["apps/api/src/index.ts"], + }); + + expect(resolveProjectInfo).toHaveBeenCalledWith( + expect.objectContaining({ + source: "github", + owner: "acme", + repo: "app", + branch: "main", + composePath: "deploy/stack.yml", + }), + ); + expect(repos.service.reconcileFromCompose).toHaveBeenCalledWith("project-1", composeServices); + expect(runPreflightChecks).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + multiService: true, + composeServices: expect.arrayContaining([ + expect.objectContaining({ name: "web", build: ".", dockerfile: "Dockerfile" }), + ]), + }), + ); + expect(repos.deployment.create).toHaveBeenCalledWith( + expect.objectContaining({ + meta: expect.objectContaining({ + serviceDeploymentMode: "services", + composeServices: expect.arrayContaining([ + expect.objectContaining({ name: "web", build: ".", dockerfile: "Dockerfile" }), + ]), + }), + }), + ); + expect(syncProjectRouteState).not.toHaveBeenCalled(); + expect(kickoffBuild).toHaveBeenCalledWith( + expect.objectContaining({ id: "project-1" }), + expect.objectContaining({ id: "dep-1" }), + ); + }); + + it("backfills pre-buildArgs compose baselines on a code-only webhook (#689)", async () => { + repos.project.findById.mockResolvedValue( + baseProject({ + composePath: "deploy/stack.yml", + gitProvider: "github", + gitUrl: "https://github.com/acme/app.git", + gitOwner: "acme", + gitRepo: "app", + localPath: null, + }), + ); + repos.service.listByProject.mockResolvedValue([ + { + ...composeServices[0], + projectId: "project-1", + // A real baseline written before #689 has no `buildArgs` key at all. + importedSpec: { image: null, build: ".", dockerfile: "Dockerfile" }, + }, + ]); + + await triggerDeployment(ctx, { + projectId: "project-1", + branch: "main", + commitSha: "1eeaf7692a19ee6e7ecb64b9d1a5c3ee7c0ac2f5", + trigger: "webhook", + changedPaths: ["apps/api/src/index.ts"], + }); + + expect(resolveProjectInfo).toHaveBeenCalledOnce(); + expect(repos.service.reconcileFromCompose).toHaveBeenCalledWith("project-1", composeServices); + }); + + it("keeps the code-only webhook fast path after the compose baseline is current", async () => { + repos.project.findById.mockResolvedValue( + baseProject({ + composePath: "deploy/stack.yml", + gitProvider: "github", + gitUrl: "https://github.com/acme/app.git", + gitOwner: "acme", + gitRepo: "app", + localPath: null, + }), + ); + repos.service.listByProject.mockResolvedValue([ + { + ...composeServices[0], + projectId: "project-1", + importedSpec: { buildArgs: { APP_PACKAGE: "@myorg/web" } }, + }, + ]); + + await triggerDeployment(ctx, { + projectId: "project-1", + branch: "main", + commitSha: "1eeaf7692a19ee6e7ecb64b9d1a5c3ee7c0ac2f5", + trigger: "webhook", + changedPaths: ["apps/api/src/index.ts"], + }); + + expect(resolveProjectInfo).not.toHaveBeenCalled(); + expect(repos.service.reconcileFromCompose).not.toHaveBeenCalled(); + }); + + it("refuses an existing-project redeploy when changed Compose config is unsafe", async () => { + repos.project.findById.mockResolvedValue( + baseProject({ + composePath: "deploy/stack.yml", + gitProvider: "github", + gitUrl: "https://github.com/acme/app.git", + gitOwner: "acme", + gitRepo: "app", + localPath: null, + }), + ); + repos.service.listByProject.mockResolvedValue([ + { + ...composeServices[0], + projectId: "project-1", + importedSpec: { buildArgs: { APP_PACKAGE: "@myorg/web" } }, + }, + ]); + resolveProjectInfo.mockRejectedValueOnce( + new ComposeConfigurationError( + "The Docker Compose file declares options Openship can't deploy faithfully: build.target", + ), + ); + + await expect( + triggerDeployment(ctx, { + projectId: "project-1", + branch: "main", + commitSha: "1eeaf7692a19ee6e7ecb64b9d1a5c3ee7c0ac2f5", + trigger: "webhook", + changedPaths: ["deploy/stack.yml"], + }), + ).rejects.toMatchObject({ + statusCode: 400, + message: expect.stringContaining("build.target"), + }); + + expect(repos.service.reconcileFromCompose).not.toHaveBeenCalled(); + expect(repos.deployment.create).not.toHaveBeenCalled(); + expect(kickoffBuild).not.toHaveBeenCalled(); + }); + /** * `commitSha` is a free string on the wire (`openship deploy --commit 1eeaf76`, * the MCP deploy tool, a CI script) and git checks out an abbreviation happily — @@ -393,6 +737,223 @@ describe("triggerDeployment", () => { }), ); }); + + it("replays a frozen release image without authorizing a repository linked later", async () => { + repos.project.findById.mockResolvedValue( + baseProject({ + gitProvider: "github", + gitUrl: "https://github.com/acme/current-source.git", + gitOwner: "acme", + gitRepo: "current-source", + localPath: null, + framework: "node", + }), + ); + const frozenImage = `ghcr.io/acme/release-app@sha256:${"c".repeat(64)}`; + const frozenSnapshot = releaseSnapshot({ + repoUrl: "", + localPath: undefined, + hasBuild: false, + source: "image", + build: "prebuilt", + workload: "web", + releaseImageRef: frozenImage, + composeServices: undefined, + }); + resolveServicePipelineMode.mockResolvedValueOnce({ + useServicePipeline: false, + servicePreflightServices: [], + useSingleAppPipeline: true, + }); + + await triggerDeployment(ctx, { + projectId: "project-1", + branch: "frozen-release-branch", + trigger: "rollback", + reuseSnapshot: { + meta: frozenSnapshot, + envVars: { API_KEY: "encrypted-frozen" }, + }, + }); + + expect(assertGitHubRepoAccess).not.toHaveBeenCalled(); + expect(getCommitByRef).not.toHaveBeenCalled(); + expect(repos.deployment.create).toHaveBeenCalledWith( + expect.objectContaining({ + commitSha: undefined, + meta: expect.objectContaining({ releaseImageRef: frozenImage }), + }), + ); + }); + + it("refreshes a single app from its active artifact with zero service rows (#674)", async () => { + repos.project.findById.mockResolvedValue( + baseProject({ + activeDeploymentId: "dep-live", + framework: "nextjs", + gitProvider: "github", + gitUrl: "https://github.com/acme/app.git", + gitOwner: "acme", + gitRepo: "app", + localPath: null, + }), + ); + repos.deployment.findById.mockResolvedValue({ + id: "dep-live", + imageRef: "openship/app:bld_live", + commitSha: "abc123", + commitMessage: "live commit", + createdAt: new Date("2026-08-23T00:00:00Z"), + }); + repos.service.listByProject.mockResolvedValue([]); + resolveServicePipelineMode.mockResolvedValue({ + useServicePipeline: false, + servicePreflightServices: [], + useSingleAppPipeline: true, + }); + + await triggerDeployment(ctx, { + projectId: "project-1", + environment: "production", + refresh: true, + }); + + expect(repos.deployment.create).toHaveBeenCalledWith( + expect.objectContaining({ + commitSha: "abc123", + forceAll: false, + meta: expect.objectContaining({ + refreshAppDeploymentId: "dep-live", + handoverAppImage: "openship/app:bld_live", + }), + }), + ); + const meta = repos.deployment.create.mock.calls.at(-1)?.[0]?.meta; + expect(meta.targetServiceIds).toBeUndefined(); + expect(meta.refreshServiceIds).toBeUndefined(); + }); + + it("returns an actionable 409 for a services project with nothing enabled", async () => { + repos.project.findById.mockResolvedValue(baseProject({ activeDeploymentId: "dep-live" })); + repos.deployment.findById.mockResolvedValue({ + id: "dep-live", + createdAt: new Date("2026-08-23T00:00:00Z"), + }); + repos.service.listByProject.mockResolvedValue([]); + + await expect( + triggerDeployment(ctx, { projectId: "project-1", refresh: true }), + ).rejects.toMatchObject({ statusCode: 409 }); + expect(repos.deployment.create).not.toHaveBeenCalled(); + }); + + it("returns an actionable 409 when there is no active deployment to refresh", async () => { + repos.project.findById.mockResolvedValue( + baseProject({ + framework: "nextjs", + activeDeploymentId: null, + }), + ); + resolveServicePipelineMode.mockResolvedValue({ + useServicePipeline: false, + servicePreflightServices: [], + useSingleAppPipeline: true, + }); + + await expect( + triggerDeployment(ctx, { projectId: "project-1", refresh: true }), + ).rejects.toMatchObject({ statusCode: 409 }); + expect(repos.deployment.create).not.toHaveBeenCalled(); + }); + + it("returns an actionable 409 for a static single-app project", async () => { + repos.project.findById.mockResolvedValue( + baseProject({ + framework: "nextjs", + activeDeploymentId: "dep-live", + productionMode: "static", + hasServer: false, + }), + ); + repos.deployment.findById.mockResolvedValue({ + id: "dep-live", + createdAt: new Date("2026-08-23T00:00:00Z"), + }); + resolveServicePipelineMode.mockResolvedValue({ + useServicePipeline: false, + servicePreflightServices: [], + useSingleAppPipeline: true, + }); + + await expect( + triggerDeployment(ctx, { projectId: "project-1", refresh: true }), + ).rejects.toMatchObject({ statusCode: 409 }); + expect(repos.deployment.create).not.toHaveBeenCalled(); + }); + + it("returns an actionable 409 for a cloud single-app project", async () => { + repos.project.findById.mockResolvedValue( + baseProject({ + framework: "nextjs", + activeDeploymentId: "dep-live", + cloudWorkspaceId: "ws-live", + }), + ); + repos.deployment.findById.mockResolvedValue({ + id: "dep-live", + imageRef: "ws-live", + createdAt: new Date("2026-08-23T00:00:00Z"), + }); + resolveServicePipelineMode.mockResolvedValue({ + useServicePipeline: false, + servicePreflightServices: [], + useSingleAppPipeline: true, + }); + + await expect( + triggerDeployment(ctx, { projectId: "project-1", refresh: true }), + ).rejects.toMatchObject({ statusCode: 409 }); + expect(repos.deployment.create).not.toHaveBeenCalled(); + }); +}); + +describe("redeployBuildSession environment snapshot", () => { + beforeEach(() => { + vi.clearAllMocks(); + const project = baseProject({ activeDeploymentId: "dep-old" }); + repos.deployment.findById.mockResolvedValue({ + id: "dep-old", + projectId: project.id, + organizationId: project.organizationId, + branch: "main", + environment: "production", + framework: "docker-compose", + commitSha: "old-sha", + commitMessage: "old commit", + envVars: { FROM_OLD_RELEASE: "stale" }, + meta: baseSnapshot(), + }); + repos.project.findById.mockResolvedValue(project); + repos.project.getEnvMap.mockResolvedValue({ MANUAL_ENV: "keep-me" }); + repos.service.listByProject.mockResolvedValue([]); + repos.deployment.listByProject.mockResolvedValue({ rows: [] }); + repos.deployment.getLatestSuccessfulForBranch.mockResolvedValue(null); + repos.deployment.create.mockResolvedValue({ id: "dep-new", projectId: project.id }); + repos.deployment.createBuildSession.mockResolvedValue(undefined); + repos.deployment.supersedeReconciling.mockResolvedValue(undefined); + repos.deployment.supersedePendingDecisions.mockResolvedValue(undefined); + assertGitHubRepoAccess.mockResolvedValue(undefined); + resolveStrategy.mockResolvedValue("local"); + kickoffBuild.mockResolvedValue("session-new"); + }); + + it("uses current project env and keeps service scopes out of the flat snapshot", async () => { + await redeployBuildSession(ctx, "dep-old"); + expect(repos.project.getEnvMap).toHaveBeenCalledWith("project-1", "production", null); + expect(repos.deployment.create).toHaveBeenCalledWith( + expect.objectContaining({ envVars: { MANUAL_ENV: "keep-me" } }), + ); + }); }); /** @@ -516,14 +1077,32 @@ describe("requestBuildAccess — folder-upload compose services", () => { it("#336: recovers the real env when the caller echoes the mask sentinel", async () => { const uploadSessionId = seedSession({ services: [ - { name: "api", image: "ghcr.io/acme/api:1", ports: [], dependsOn: [], environment: { API_TOKEN: "real-token" }, volumes: [] }, + { + name: "api", + image: "ghcr.io/acme/api:1", + ports: [], + dependsOn: [], + environment: { API_TOKEN: "real-token" }, + volumes: [], + }, ], }); const requested = [ - { name: "api", image: "ghcr.io/acme/api:1", ports: [], dependsOn: [], environment: { API_TOKEN: "••••••••" }, volumes: [] }, + { + name: "api", + image: "ghcr.io/acme/api:1", + ports: [], + dependsOn: [], + environment: { API_TOKEN: "••••••••" }, + volumes: [], + }, ]; - await requestBuildAccess(ctx, { projectId: "project-1", uploadSessionId, services: requested as any }); + await requestBuildAccess(ctx, { + projectId: "project-1", + uploadSessionId, + services: requested as any, + }); expect(repos.service.syncFromCompose).toHaveBeenCalledWith( "project-1", @@ -536,10 +1115,21 @@ describe("requestBuildAccess — folder-upload compose services", () => { const uploadSessionId = seedSession({ services: [] }); repos.service.listByProject.mockResolvedValue([]); const requested = [ - { name: "api", image: "x", ports: [], dependsOn: [], environment: { GHOST: "••••••••", REAL: "keep" }, volumes: [] }, + { + name: "api", + image: "x", + ports: [], + dependsOn: [], + environment: { GHOST: "••••••••", REAL: "keep" }, + volumes: [], + }, ]; - await requestBuildAccess(ctx, { projectId: "project-1", uploadSessionId, services: requested as any }); + await requestBuildAccess(ctx, { + projectId: "project-1", + uploadSessionId, + services: requested as any, + }); expect(repos.service.syncFromCompose).toHaveBeenCalledWith( "project-1", @@ -595,6 +1185,45 @@ describe("requestBuildAccess — folder-upload compose services", () => { ); }); + it("does not parse or materialize compose for an explicit single-app deploy (#689)", async () => { + const actualPipeline = await vi.importActual< + typeof import("../../../src/modules/deployments/build-pipeline") + >("../../../src/modules/deployments/build-pipeline"); + resolveServicePipelineMode.mockImplementationOnce(actualPipeline.resolveServicePipelineMode); + repos.project.findById.mockResolvedValue( + baseProject({ + framework: "docker", + composePath: "deploy/stack.yml", + gitProvider: "github", + gitUrl: "https://github.com/acme/app.git", + gitOwner: "acme", + gitRepo: "app", + localPath: null, + }), + ); + + await requestBuildAccess(ctx, { + projectId: "project-1", + serviceDeploymentMode: "single", + }); + + expect(resolveProjectInfo).not.toHaveBeenCalled(); + expect(repos.service.reconcileFromCompose).not.toHaveBeenCalled(); + expect(repos.service.syncFromCompose).not.toHaveBeenCalled(); + expect(runPreflightChecks).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ multiService: false, composeServices: [] }), + ); + const meta = repos.deployment.create.mock.calls.at(-1)?.[0]?.meta; + expect(meta.serviceDeploymentMode).toBe("single"); + expect(meta.composeServices).toBeUndefined(); + expect(syncProjectRouteState).toHaveBeenCalled(); + expect(kickoffBuild).toHaveBeenCalledWith( + expect.objectContaining({ id: "project-1" }), + expect.objectContaining({ id: "dep-1" }), + ); + }); + it("rejects an unknown or expired upload session", async () => { await expect( requestBuildAccess(ctx, { projectId: "project-1", uploadSessionId: "nope" }), diff --git a/apps/api/test/modules/deployments/clone-plan.test.ts b/apps/api/test/modules/deployments/clone-plan.test.ts index 53930962f..0136f6501 100644 --- a/apps/api/test/modules/deployments/clone-plan.test.ts +++ b/apps/api/test/modules/deployments/clone-plan.test.ts @@ -15,42 +15,42 @@ const base: ClonePlanInput = { describe("resolveClonePlan", () => { it("local build → clone runs locally with a local credential", () => { const plan = resolveClonePlan({ ...base, effectiveTarget: "server", buildStrategy: "local" }); - expect(plan.runsOnServer).toBe(false); - expect(plan.runsLocally).toBe(true); - expect(plan.cloneBuildStrategy).toBe("local"); + expect(plan.cloneRunsOnTarget).toBe(false); + expect(plan.sourceLocation).toBe("api-host"); + expect(plan.cloneCredentialPurpose).toBe("local"); }); it("docker + server + api-host clone → api-host clone (local credential), not on server", () => { const plan = resolveClonePlan({ ...base, cloneStrategy: "api-host" }); - expect(plan.runsOnServer).toBe(false); - expect(plan.runsLocally).toBe(true); - expect(plan.cloneBuildStrategy).toBe("local"); + expect(plan.cloneRunsOnTarget).toBe(false); + expect(plan.sourceLocation).toBe("api-host"); + expect(plan.cloneCredentialPurpose).toBe("local"); }); it("docker + server + clone-on-server → on-server clone with a shippable (server) credential", () => { const plan = resolveClonePlan({ ...base, cloneStrategy: "server" }); - expect(plan.runsOnServer).toBe(true); - expect(plan.dockerClonesOnServer).toBe(true); - expect(plan.runsLocally).toBe(false); - expect(plan.cloneBuildStrategy).toBe("server"); + expect(plan.cloneRunsOnTarget).toBe(true); + expect(plan.dockerClonesOnTarget).toBe(true); + expect(plan.sourceLocation).toBe("target"); + expect(plan.cloneCredentialPurpose).toBe("server"); expect(plan.relayEligible).toBe(false); // non-desktop }); it("bare + server → always clones on the server with a server credential", () => { const plan = resolveClonePlan({ ...base, runtimeIsBare: true, cloneStrategy: "api-host" }); - expect(plan.runsOnServer).toBe(true); - expect(plan.dockerClonesOnServer).toBe(false); // bare excluded from the docker warn-case - expect(plan.cloneBuildStrategy).toBe("server"); + expect(plan.cloneRunsOnTarget).toBe(true); + expect(plan.dockerClonesOnTarget).toBe(false); // bare excluded from the docker warn-case + expect(plan.cloneCredentialPurpose).toBe("server"); }); it("SECURITY: contradictory buildStrategy=local + cloneStrategy=server never emits a LOCAL credential for an on-server clone", () => { const plan = resolveClonePlan({ ...base, cloneStrategy: "server", buildStrategy: "local" }); // The clone physically runs on the remote server... - expect(plan.runsOnServer).toBe(true); + expect(plan.cloneRunsOnTarget).toBe(true); // ...so the credential purpose MUST be "server" (shippable) — never "local", // which would ship the operator's broad gh/OAuth token off-host. - expect(plan.runsLocally).toBe(false); - expect(plan.cloneBuildStrategy).toBe("server"); + expect(plan.sourceLocation).toBe("target"); + expect(plan.cloneCredentialPurpose).toBe("server"); }); it("desktop + forwardGitCredentials + on-server clone → relay eligible", () => { @@ -60,7 +60,7 @@ describe("resolveClonePlan", () => { isDesktop: true, forwardGitCredentials: true, }); - expect(plan.runsOnServer).toBe(true); + expect(plan.cloneRunsOnTarget).toBe(true); expect(plan.relayEligible).toBe(true); }); @@ -71,9 +71,9 @@ describe("resolveClonePlan", () => { serverId: null, buildStrategy: "server", }); - expect(plan.runsOnServer).toBe(false); - expect(plan.runsLocally).toBe(false); - expect(plan.cloneBuildStrategy).toBe("server"); + expect(plan.cloneRunsOnTarget).toBe(false); + expect(plan.sourceLocation).toBe("cloud-workspace"); + expect(plan.cloneCredentialPurpose).toBe("server"); }); /** @@ -92,23 +92,23 @@ describe("resolveClonePlan", () => { it("defaulted buildStrategy=server still clones locally with a local credential", () => { const plan = resolveClonePlan({ ...localBase, buildStrategy: "server" }); - expect(plan.runsOnServer).toBe(false); - expect(plan.runsLocally).toBe(true); - expect(plan.cloneBuildStrategy).toBe("local"); + expect(plan.cloneRunsOnTarget).toBe(false); + expect(plan.sourceLocation).toBe("api-host"); + expect(plan.cloneCredentialPurpose).toBe("local"); }); it("explicit buildStrategy=local is unchanged", () => { const plan = resolveClonePlan({ ...localBase, buildStrategy: "local" }); - expect(plan.runsLocally).toBe(true); - expect(plan.cloneBuildStrategy).toBe("local"); + expect(plan.sourceLocation).toBe("api-host"); + expect(plan.cloneCredentialPurpose).toBe("local"); }); it("a bare runtime on a local target does not become an on-server clone", () => { - // runsOnServer requires effectiveTarget==="server" AND a serverId; bare only + // cloneRunsOnTarget requires effectiveTarget==="server" AND a serverId; bare only // forces on-server WITHIN that. A local target has neither. const plan = resolveClonePlan({ ...localBase, runtimeIsBare: true }); - expect(plan.runsOnServer).toBe(false); - expect(plan.cloneBuildStrategy).toBe("local"); + expect(plan.cloneRunsOnTarget).toBe(false); + expect(plan.cloneCredentialPurpose).toBe("local"); }); it("never relay-eligible: there is no remote build host to forward to", () => { @@ -120,4 +120,72 @@ describe("resolveClonePlan", () => { expect(plan.relayEligible).toBe(false); }); }); + + describe("Docker source location follows transport capability (#654)", () => { + it("local socket: server-row deployment acquires source on the API host", () => { + const plan = resolveClonePlan({ + ...base, + repoIsGithub: true, + cloneStrategy: "server", + dockerTransport: "socket", + }); + expect(plan.sourceLocation).toBe("api-host"); + expect(plan.cloneRunsOnTarget).toBe(false); + expect(plan.dockerClonesOnTarget).toBe(false); + expect(plan.cloneCredentialPurpose).toBe("local"); + }); + + it("TCP daemon: context is prepared on the API host because there is no command channel", () => { + const plan = resolveClonePlan({ + ...base, + repoIsGithub: true, + dockerTransport: "tcp", + }); + expect(plan.sourceLocation).toBe("api-host"); + expect(plan.cloneRunsOnTarget).toBe(false); + }); + + it("remote SSH daemon: target source acquisition remains available", () => { + const plan = resolveClonePlan({ + ...base, + repoIsGithub: true, + dockerTransport: "ssh", + }); + expect(plan.sourceLocation).toBe("target"); + expect(plan.cloneRunsOnTarget).toBe(true); + expect(plan.dockerClonesOnTarget).toBe(true); + }); + + it("local build strategy stays API-host unless explicit target cloning was requested", () => { + const automatic = resolveClonePlan({ + ...base, + repoIsGithub: true, + buildStrategy: "local", + cloneStrategy: "api-host", + dockerTransport: "ssh", + }); + expect(automatic.sourceLocation).toBe("api-host"); + + const explicit = resolveClonePlan({ + ...base, + repoIsGithub: true, + buildStrategy: "local", + cloneStrategy: "server", + dockerTransport: "ssh", + }); + expect(explicit.sourceLocation).toBe("target"); + expect(explicit.cloneCredentialPurpose).toBe("server"); + }); + + it("bare runtime still uses its target executor; Docker transport is irrelevant", () => { + const plan = resolveClonePlan({ + ...base, + runtimeIsBare: true, + dockerTransport: "socket", + }); + expect(plan.sourceLocation).toBe("target"); + expect(plan.cloneRunsOnTarget).toBe(true); + expect(plan.dockerClonesOnTarget).toBe(false); + }); + }); }); diff --git a/apps/api/test/modules/deployments/compose-build-context.test.ts b/apps/api/test/modules/deployments/compose-build-context.test.ts index d527e41bf..3c936b6fd 100644 --- a/apps/api/test/modules/deployments/compose-build-context.test.ts +++ b/apps/api/test/modules/deployments/compose-build-context.test.ts @@ -61,19 +61,32 @@ describe("resolveComposeBuildContext", () => { }); describe("paths that escape the clone root", () => { - // There is no such directory in the checkout, so there is nothing to build - // there. Fall back to the compose directory rather than emitting a path that - // walks out of the clone. - it("falls back to the compose directory", () => { - expect(resolveComposeBuildContext("deploy", "../../../etc")).toBe("deploy"); - expect(resolveComposeBuildContext("deploy/docker-compose", "../../../..")).toBe( - "deploy/docker-compose", + it.each([ + ["deploy", "../../../etc"], + ["deploy/docker-compose", "../../../.."], + ["", "../outside"], + [".", "../outside"], + ])("refuses compose directory %j with context %j", (composeDirectory, context) => { + expect(() => resolveComposeBuildContext(composeDirectory, context)).toThrow( + /escapes the linked repository/i, ); - expect(resolveComposeBuildContext("", "../outside")).toBe(""); - expect(resolveComposeBuildContext(".", "../outside")).toBe(""); }); }); + it.each([ + "https://github.com/acme/app.git", + "git@github.com:acme/app.git", + "/srv/app", + "~/app", + "C:\\app", + "", + " ", + ])("refuses non-repository context %j", (context) => { + expect(() => resolveComposeBuildContext("deploy", context)).toThrow( + /invalid compose build context/i, + ); + }); + it("handles backslash separators in a declared context", () => { expect(resolveComposeBuildContext("deploy", "sub\\api")).toBe("deploy/sub/api"); }); @@ -89,9 +102,11 @@ describe("resolveComposeBuildContext", () => { expect(resolveComposeBuildContext("/deploy/", "api")).toBe("deploy/api"); }); - it("keeps the escape fallback consistent for every spelling of the root", () => { + it("keeps root-relative validation consistent for every spelling of the root", () => { for (const root of ["", ".", "./"]) { - expect(resolveComposeBuildContext(root, "../outside")).toBe(""); + expect(() => resolveComposeBuildContext(root, "../outside")).toThrow( + /escapes the linked repository/i, + ); expect(resolveComposeBuildContext(root, "./api")).toBe("api"); } }); diff --git a/apps/api/test/modules/deployments/compose-env-passthrough.test.ts b/apps/api/test/modules/deployments/compose-env-passthrough.test.ts index 9fbefc318..cbfb9eefc 100644 --- a/apps/api/test/modules/deployments/compose-env-passthrough.test.ts +++ b/apps/api/test/modules/deployments/compose-env-passthrough.test.ts @@ -160,27 +160,89 @@ services: expect(merged.deferredEmpty).toEqual(["HTTP_PROXY"]); }); - it("KNOWN GAP: a partially interpolated value is not empty, so it still wins", () => { - // The same bug class as #614 with a one-line-different compose file. A - // value-shaped rule cannot reach it; only carrying the parser's meta can. - // Pinned so the gap is visible rather than assumed fixed. - const inline = - parseComposeFile(` + it("resolves an embedded expression against the final service-scoped env (#673)", () => { + const service = parseComposeFile(` services: api: image: my-app:latest environment: - DATABASE_URL: postgres://\${POSTGRES_USER}:\${POSTGRES_PASSWORD}@db:5432/\${POSTGRES_DB} -`).services[0]?.environment ?? {}; + POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} + DATABASE_URL: postgresql://username:\${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/app +`).services[0]!; + const inline = { ...service.environment, ...service.environmentTemplates }; + + const merged = mergeServiceDeployEnv( + layers({ + inline, + templateKeys: service.advanced?.environmentTemplateKeys, + service: { POSTGRES_PASSWORD: "service-secret" }, + }), + false, + ); + + expect(merged.env.POSTGRES_PASSWORD).toBe("service-secret"); + expect(merged.env.DATABASE_URL).toBe( + "postgresql://username:service-secret@postgres:5432/app", + ); + expect(merged.missingRequired).toEqual([]); + expect(merged.deferredEmpty).toEqual([]); + }); + + it("reports a required embedded variable when no layer provides it", () => { + const service = parseComposeFile(` +services: + api: + image: my-app:latest + environment: + DATABASE_URL: postgresql://username:\${POSTGRES_PASSWORD:?set it}@postgres:5432/app +`).services[0]!; + + const merged = mergeServiceDeployEnv( + layers({ + inline: { ...service.environment, ...service.environmentTemplates }, + templateKeys: service.advanced?.environmentTemplateKeys, + }), + false, + ); + + expect(merged.missingRequired).toEqual([ + { variable: "POSTGRES_PASSWORD", message: "set it" }, + ]); + }); + + it("lets a higher-priority service value replace the templated target entirely", () => { + const service = parseComposeFile(` +services: + api: + image: my-app:latest + environment: + DATABASE_URL: postgresql://username:\${POSTGRES_PASSWORD:?set it}@postgres:5432/app +`).services[0]!; + + const merged = mergeServiceDeployEnv( + layers({ + inline: { ...service.environment, ...service.environmentTemplates }, + templateKeys: service.advanced?.environmentTemplateKeys, + service: { DATABASE_URL: "manual-service-url" }, + }), + false, + ); - expect(inline.DATABASE_URL).toBe("postgres://:@db:5432/"); + expect(merged.env.DATABASE_URL).toBe("manual-service-url"); + expect(merged.missingRequired).toEqual([]); + }); + it("honors an authored empty literal when parser provenance is available", () => { const merged = mergeServiceDeployEnv( - layers({ project: { DATABASE_URL: "postgres://real:s3cret@db:5432/prod" }, inline }), + layers({ + project: { HTTP_PROXY: "http://corp:3128" }, + inline: { HTTP_PROXY: "" }, + templateKeys: [], + }), false, ); - expect(merged.env.DATABASE_URL).toBe("postgres://:@db:5432/"); + expect(merged.env.HTTP_PROXY).toBe(""); expect(merged.deferredEmpty).toEqual([]); }); diff --git a/apps/api/test/modules/deployments/compose-host-channel-notice.test.ts b/apps/api/test/modules/deployments/compose-host-channel-notice.test.ts index 808aa2c9e..744297e02 100644 --- a/apps/api/test/modules/deployments/compose-host-channel-notice.test.ts +++ b/apps/api/test/modules/deployments/compose-host-channel-notice.test.ts @@ -26,6 +26,19 @@ const h = vi.hoisted(() => ({ sshPort: 22, sshUser: "root", }, + convergeTargetHostPortClaims: vi.fn(), + convergeTargetHostPortClaimsUnlocked: vi.fn(), + prepareTargetPinnedHostPorts: vi.fn(), + allocateAndReservePinnedHostPort: vi.fn(), + releaseNewPinnedHostPortClaims: vi.fn(), + reserveResolvedLoopbackRoutes: vi.fn(), + upsertServiceDeployment: vi.fn(), + services: [] as Array>, + previousServiceRows: [] as Array>, + previousDeployment: { id: "d-old", containerId: "compose", createdAt: null } as Record< + string, + unknown + >, })); vi.mock("@repo/db", () => ({ @@ -36,9 +49,31 @@ vi.mock("@repo/db", () => ({ update: async () => {}, }, service: { - listByProject: async () => [ - { id: "svc-web", name: "web", enabled: true, dependsOn: [], advanced: null }, - ], + listByProject: async () => h.services, + listByDeployment: async () => h.previousServiceRows, + upsertServiceDeployment: (...args: unknown[]) => h.upsertServiceDeployment(...args), + markServiceDeploymentFailed: async () => undefined, + }, + deployment: { + findById: async () => h.previousDeployment, + }, + project: { + getEnvMap: async () => ({}), + listEnvVarChangeMeta: async () => [], + }, + domain: { + listByProject: async () => [], + findByHostname: async () => null, + findOrCreateWithStatus: async (input: Record) => ({ + domain: { + id: `dom-${String(input.hostname)}`, + status: "pending", + verified: false, + sslStatus: "none", + ...input, + }, + created: true, + }), }, }, })); @@ -54,10 +89,24 @@ vi.mock("../../../src/lib/provision-lock", () => ({ createProvisionLock: () => ({ run: (f: () => unknown) => f() }), })); +vi.mock("../../../src/modules/deployments/pinned-host-ports", () => ({ + withHostPortTargetLock: (_target: unknown, fn: () => unknown) => fn(), + prepareTargetPinnedHostPorts: (...args: unknown[]) => h.prepareTargetPinnedHostPorts(...args), + convergeTargetHostPortClaims: (...args: unknown[]) => h.convergeTargetHostPortClaims(...args), + convergeTargetHostPortClaimsUnlocked: (...args: unknown[]) => + h.convergeTargetHostPortClaimsUnlocked(...args), + allocateAndReservePinnedHostPort: (...args: unknown[]) => + h.allocateAndReservePinnedHostPort(...args), + releaseNewPinnedHostPortClaims: (...args: unknown[]) => h.releaseNewPinnedHostPortClaims(...args), +})); + +vi.mock("../../../src/modules/deployments/observed-host-port-claims", () => ({ + reserveResolvedLoopbackRoutes: (...args: unknown[]) => h.reserveResolvedLoopbackRoutes(...args), +})); + const { resolveServerExecutor } = await import("../../../src/lib/deployment-runtime"); -const { deployComposeServices } = await import( - "../../../src/modules/deployments/compose/deploy.service" -); +const { deployComposeServices } = + await import("../../../src/modules/deployments/compose/deploy.service"); /** Collects what the deploy log was told, in order. */ function recordingLogger() { @@ -65,23 +114,67 @@ function recordingLogger() { const logger = { log: (message: string, level = "info") => lines.push({ message, level }), step: () => {}, + callback: (entry: { message: string; level?: string }) => + lines.push({ message: entry.message, level: entry.level ?? "info" }), } as unknown as BuildLogger; return { logger, lines }; } /** Stops the deploy at the first thing after the notice, so the test exercises the * emission point and its ORDER without needing a Docker host. */ -function haltingRuntime() { +function haltingRuntime(name: "docker" | "cloud" = "docker", containerIp = true) { return { - name: "docker", - ensureServiceGroup: async () => { + name, + supports: (capability: string) => capability === "containerIp" && containerIp, + ensureServiceGroup: vi.fn(async () => { throw new Error("halt: nothing past the notice is under test"); - }, + }), + } as unknown as MultiServiceRuntimeAdapter; +} + +function carriedRuntime(containerIp = true) { + return { + name: "docker", + supports: (capability: string) => + capability === "containerInfo" || (capability === "containerIp" && containerIp), + ensureServiceGroup: vi.fn(async () => ({ id: "group-1" })), + getContainerInfo: vi.fn(async () => ({ + status: "running", + ip: "172.18.0.2", + hostPort: 30_000, + hostPortByContainerPort: { 8080: 30_000 }, + })), + destroy: vi.fn(async () => undefined), + } as unknown as MultiServiceRuntimeAdapter; +} + +function startingRuntime() { + return { + name: "docker", + unsupportedComposeKeys: new Set(), + supports: (capability: string) => capability === "containerIp", + ensureServiceGroup: vi.fn(async () => ({ id: "group-1" })), + deployServiceWorkload: vi.fn(async () => ({ + status: "running", + containerId: "container-new", + ip: "172.18.0.3", + })), + destroy: vi.fn(async () => undefined), + getContainerIp: vi.fn(async () => "172.18.0.3"), } as unknown as MultiServiceRuntimeAdapter; } const project = { id: "p1", slug: "app", organizationId: "org1" } as unknown as Project; -const dep = { id: "d1", organizationId: "org1", environment: "production" } as unknown as Deployment; +const dep = { + id: "d1", + organizationId: "org1", + environment: "production", +} as unknown as Deployment; +const localHostPortTarget = { + targetKey: "local" as const, + legacyTargetKeys: [], + stable: true, +}; async function demotedExecutor(): Promise { const { executor } = await resolveServerExecutor("srv-local", "org1"); @@ -90,8 +183,86 @@ async function demotedExecutor(): Promise { beforeEach(() => { vi.unstubAllEnvs(); + vi.clearAllMocks(); + h.services = [ + { + id: "svc-web", + projectId: "p1", + name: "web", + enabled: true, + dependsOn: [], + advanced: null, + ports: ["8080"], + image: "nginx:alpine", + exposed: true, + exposedPort: "8080", + domainType: "custom", + customDomain: "web.example.com", + publicEndpoints: [], + }, + ]; + h.previousServiceRows = [ + { + id: "sd-old", + deploymentId: "d-old", + serviceId: "svc-web", + serviceName: "web", + containerId: "container-old", + status: "success", + imageRef: "nginx:alpine", + ip: "172.18.0.2", + hostPort: 30_000, + hostPorts: { 8080: 30_000 }, + }, + ]; + h.previousDeployment = { id: "d-old", containerId: "compose", createdAt: null }; + h.prepareTargetPinnedHostPorts.mockResolvedValue([]); + h.allocateAndReservePinnedHostPort.mockImplementation(async (input) => ({ + port: 30_000, + scanned: true, + claim: { + id: "hpc-web", + targetKey: "local", + ...input.owner, + port: 30_000, + createdAt: new Date(0), + updatedAt: new Date(0), + }, + })); + h.releaseNewPinnedHostPortClaims.mockResolvedValue(0); + h.reserveResolvedLoopbackRoutes.mockResolvedValue([]); + h.convergeTargetHostPortClaims.mockResolvedValue({ released: 0, retained: [] }); + h.convergeTargetHostPortClaimsUnlocked.mockResolvedValue({ released: 0, retained: [] }); + h.upsertServiceDeployment.mockResolvedValue(undefined); }); +function addDisabledPreviousService() { + h.services.push({ + id: "svc-disabled", + projectId: "p1", + name: "disabled", + enabled: false, + dependsOn: [], + advanced: null, + ports: ["9090"], + image: "nginx:alpine", + exposed: false, + publicEndpoints: [], + }); + h.previousServiceRows.push({ + id: "sd-disabled", + deploymentId: "d-old", + serviceId: "svc-disabled", + serviceName: "disabled", + containerId: "container-disabled", + status: "success", + imageRef: "nginx:alpine", + ip: "172.18.0.4", + hostPort: 30_001, + hostPorts: { 9090: 30_001 }, + }); +} + describe("compose deploy — host channel unavailable", () => { it("states the skip, the reason and the reassurance, before any host touchpoint", async () => { // The real demotion path: host control off is the same typed error an @@ -102,19 +273,26 @@ describe("compose deploy — host channel unavailable", () => { const { logger, lines } = recordingLogger(); await expect( - deployComposeServices(project, dep, haltingRuntime(), logger, { executor }), + deployComposeServices(project, dep, haltingRuntime(), logger, { + executor, + hostPortTarget: localHostPortTarget, + }), ).rejects.toThrow(/halt/); const notice = lines.find((l) => l.message.includes("Host operations are unavailable")); - expect(notice, `no host-channel notice in the deploy log:\n${lines.map((l) => l.message).join("")}`) - .toBeDefined(); + expect( + notice, + `no host-channel notice in the deploy log:\n${lines.map((l) => l.message).join("")}`, + ).toBeDefined(); expect(notice!.level).toBe("warn"); // The reason, with the remedy the executor refuses every call with. expect(notice!.message).toContain("OPENSHIP_HOST_CONTROL=false"); // From @repo/core — the deploy in progress still succeeds. expect(notice!.message).toContain("Ordinary deploys to this box still work"); // Said ONCE per deploy, not per touchpoint. - expect(lines.filter((l) => l.message.includes("Host operations are unavailable"))).toHaveLength(1); + expect(lines.filter((l) => l.message.includes("Host operations are unavailable"))).toHaveLength( + 1, + ); // Before the first host-touching step, so it reads as the cause of what follows // rather than as a footnote to it. expect(lines.indexOf(notice!)).toBeLessThan( @@ -136,7 +314,7 @@ describe("compose deploy — host channel unavailable", () => { dep, haltingRuntime(), logger, - { executor }, + { executor, hostPortTarget: localHostPortTarget }, ), ).rejects.toThrow(/halt/); expect( @@ -153,7 +331,10 @@ describe("compose deploy — host channel unavailable", () => { const { logger, lines } = recordingLogger(); await expect( deployComposeServices(project, dep, haltingRuntime(), logger, { - executor: { exec: async () => ({ code: 0, stdout: "", stderr: "" }) } as unknown as CommandExecutor, + executor: { + exec: async () => ({ code: 0, stdout: "", stderr: "" }), + } as unknown as CommandExecutor, + hostPortTarget: localHostPortTarget, }), ).rejects.toThrow(/halt/); expect(lines.some((l) => l.message.includes("Host operations are unavailable"))).toBe(false); @@ -162,8 +343,304 @@ describe("compose deploy — host channel unavailable", () => { it("stays quiet on cloud, which has no executor and no host", async () => { const { logger, lines } = recordingLogger(); await expect( - deployComposeServices(project, dep, haltingRuntime(), logger, { executor: null }), + deployComposeServices(project, dep, haltingRuntime("cloud"), logger, { executor: null }), ).rejects.toThrow(/halt/); expect(lines.some((l) => l.message.includes("Host operations are unavailable"))).toBe(false); }); + + it("fails before container activation when a loopback target has no executor", async () => { + const runtime = haltingRuntime(); + const ensureServiceGroup = vi.mocked(runtime.ensureServiceGroup); + const { logger, lines } = recordingLogger(); + + await expect( + deployComposeServices(project, dep, runtime, logger, { + executor: null, + hostPortTarget: localHostPortTarget, + }), + ).rejects.toThrow("physical target executor"); + expect(ensureServiceGroup).not.toHaveBeenCalled(); + }); + + it("does the same for explicit container-ip when the runtime has no container IP", async () => { + const runtime = haltingRuntime("docker", false); + const ensureServiceGroup = vi.mocked(runtime.ensureServiceGroup); + const { logger } = recordingLogger(); + + await expect( + deployComposeServices( + { ...project, routeStrategy: "container-ip" } as unknown as Project, + dep, + runtime, + logger, + { executor: null, hostPortTarget: localHostPortTarget }, + ), + ).rejects.toThrow("physical target executor"); + expect(ensureServiceGroup).not.toHaveBeenCalled(); + }); + + it("converges a non-loopback transition to an empty desired set under its own lock", async () => { + const { logger } = recordingLogger(); + const result = await deployComposeServices( + { + ...project, + activeDeploymentId: "d-old", + routeStrategy: "container-ip", + } as unknown as Project, + dep, + carriedRuntime(), + logger, + { + executor: { exec: vi.fn(async () => ({ code: 0, stdout: "", stderr: "" })) } as never, + hostPortTarget: localHostPortTarget, + targetServiceIds: new Set(), + }, + ); + + expect(result.status).toBe("ready"); + expect(h.convergeTargetHostPortClaims).toHaveBeenCalledWith( + expect.objectContaining({ projectId: "p1", desiredPublishes: [] }), + ); + expect(h.convergeTargetHostPortClaimsUnlocked).not.toHaveBeenCalled(); + }); + + it("converges only after every obsolete service workload has been stopped", async () => { + addDisabledPreviousService(); + const runtime = carriedRuntime(); + const destroy = vi.mocked(runtime.destroy); + const { logger } = recordingLogger(); + + const result = await deployComposeServices( + { + ...project, + activeDeploymentId: "d-old", + routeStrategy: "container-ip", + } as unknown as Project, + dep, + runtime, + logger, + { + executor: { exec: vi.fn(async () => ({ code: 0, stdout: "", stderr: "" })) } as never, + hostPortTarget: localHostPortTarget, + targetServiceIds: new Set(), + }, + ); + + expect(result.status).toBe("ready"); + expect(destroy).toHaveBeenCalledWith("container-disabled"); + expect(destroy.mock.invocationCallOrder[0]).toBeLessThan( + h.convergeTargetHostPortClaims.mock.invocationCallOrder[0]!, + ); + }); + + it("retains claims when an obsolete service workload cannot be stopped", async () => { + addDisabledPreviousService(); + const runtime = carriedRuntime(); + vi.mocked(runtime.destroy).mockRejectedValueOnce(new Error("daemon unavailable")); + const { logger, lines } = recordingLogger(); + + const result = await deployComposeServices( + { + ...project, + activeDeploymentId: "d-old", + routeStrategy: "container-ip", + } as unknown as Project, + dep, + runtime, + logger, + { + executor: { exec: vi.fn(async () => ({ code: 0, stdout: "", stderr: "" })) } as never, + hostPortTarget: localHostPortTarget, + targetServiceIds: new Set(), + }, + ); + + expect(result.status).toBe("ready"); + expect(result.warning).toContain("obsolete workload could not be stopped"); + expect(h.convergeTargetHostPortClaims).not.toHaveBeenCalled(); + expect(h.convergeTargetHostPortClaimsUnlocked).not.toHaveBeenCalled(); + expect( + lines.some( + (line) => line.level === "warn" && line.message.includes("reservations were retained"), + ), + ).toBe(true); + }); + + it("keeps a ready Compose deploy ready and surfaces deferred claim cleanup", async () => { + h.convergeTargetHostPortClaims.mockRejectedValueOnce(new Error("edge scan unavailable")); + const { logger, lines } = recordingLogger(); + const result = await deployComposeServices( + { + ...project, + activeDeploymentId: "d-old", + routeStrategy: "container-ip", + } as unknown as Project, + dep, + carriedRuntime(), + logger, + { + executor: { exec: vi.fn(async () => ({ code: 0, stdout: "", stderr: "" })) } as never, + hostPortTarget: localHostPortTarget, + targetServiceIds: new Set(), + }, + ); + + expect(result.status).toBe("ready"); + expect(result.warning).toContain("Host-port reservation cleanup was deferred"); + expect( + lines.some( + (line) => + line.level === "warn" && + line.message.includes("Host-port reservation cleanup was deferred"), + ), + ).toBe(true); + }); + + it("never releases an activated claim when failed-workload cleanup is uncertain", async () => { + h.reserveResolvedLoopbackRoutes.mockRejectedValueOnce( + new Error("route ownership verification failed"), + ); + const runtime = startingRuntime(); + vi.mocked(runtime.destroy).mockRejectedValue(new Error("daemon unavailable")); + const { logger, lines } = recordingLogger(); + const routing = { + registerRoute: vi.fn(async () => undefined), + removeRoute: vi.fn(async () => undefined), + } as never; + const ssl = { + provisionCert: vi.fn(async () => ({ verified: false })), + renewCert: vi.fn(), + verifyCert: vi.fn(), + installCert: vi.fn(), + } as never; + + const result = await deployComposeServices( + { + ...project, + activeDeploymentId: null, + routeStrategy: "loopback-port", + } as unknown as Project, + dep, + runtime, + logger, + { + executor: { exec: vi.fn(async () => ({ code: 0, stdout: "", stderr: "" })) } as never, + hostPortTarget: localHostPortTarget, + routing, + ssl, + usesManagedRouting: true, + }, + ); + + expect(result.status).toBe("failed"); + expect(h.allocateAndReservePinnedHostPort).toHaveBeenCalled(); + expect(h.releaseNewPinnedHostPortClaims).not.toHaveBeenCalled(); + expect(h.convergeTargetHostPortClaims).not.toHaveBeenCalled(); + expect(h.convergeTargetHostPortClaimsUnlocked).not.toHaveBeenCalled(); + expect(lines.some((line) => line.message.includes("retained until the next"))).toBe(true); + }); + + it("uses the already-held target lock for a loopback deploy", async () => { + const { logger } = recordingLogger(); + const routing = { + registerRoute: vi.fn(async () => undefined), + removeRoute: vi.fn(async () => undefined), + } as never; + const ssl = { + provisionCert: vi.fn(async () => ({ verified: false })), + renewCert: vi.fn(), + verifyCert: vi.fn(), + installCert: vi.fn(), + } as never; + const result = await deployComposeServices( + { + ...project, + activeDeploymentId: "d-old", + routeStrategy: "loopback-port", + } as unknown as Project, + dep, + carriedRuntime(), + logger, + { + executor: { exec: vi.fn(async () => ({ code: 0, stdout: "", stderr: "" })) } as never, + hostPortTarget: localHostPortTarget, + targetServiceIds: new Set(), + routing, + ssl, + usesManagedRouting: true, + }, + ); + + expect(result.status).toBe("ready"); + expect(h.convergeTargetHostPortClaimsUnlocked).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: "p1", + desiredPublishes: [{ serviceId: "svc-web", containerPort: 8080, hostPort: 30_000 }], + }), + ); + expect(h.convergeTargetHostPortClaims).not.toHaveBeenCalled(); + }); + + it("does not run project-wide convergence for strict single-service scope", async () => { + const { logger } = recordingLogger(); + const result = await deployComposeServices( + { + ...project, + activeDeploymentId: "d-old", + routeStrategy: "loopback-port", + } as unknown as Project, + dep, + carriedRuntime(), + logger, + { + executor: { exec: vi.fn(async () => ({ code: 0, stdout: "", stderr: "" })) } as never, + hostPortTarget: localHostPortTarget, + targetServiceIds: new Set(), + strictScope: true, + }, + ); + + expect(result.status).toBe("ready"); + expect(h.convergeTargetHostPortClaims).not.toHaveBeenCalled(); + expect(h.convergeTargetHostPortClaimsUnlocked).not.toHaveBeenCalled(); + }); + + it("retains every claim while a started service has an indeterminate outcome", async () => { + h.upsertServiceDeployment + .mockRejectedValueOnce(new Error("Channel open failure: connection lost")) + .mockResolvedValueOnce(undefined); + const { logger } = recordingLogger(); + + const result = await deployComposeServices( + { + ...project, + activeDeploymentId: null, + routeStrategy: "loopback-port", + } as unknown as Project, + dep, + startingRuntime(), + logger, + { + executor: { exec: vi.fn(async () => ({ code: 0, stdout: "", stderr: "" })) } as never, + hostPortTarget: localHostPortTarget, + routing: { + registerRoute: vi.fn(async () => undefined), + removeRoute: vi.fn(async () => undefined), + } as never, + ssl: { + provisionCert: vi.fn(async () => ({ verified: false })), + renewCert: vi.fn(), + verifyCert: vi.fn(), + installCert: vi.fn(), + } as never, + usesManagedRouting: true, + }, + ); + + expect(result.status).toBe("reconciling"); + expect(h.allocateAndReservePinnedHostPort).toHaveBeenCalled(); + expect(h.releaseNewPinnedHostPortClaims).not.toHaveBeenCalled(); + expect(h.convergeTargetHostPortClaims).not.toHaveBeenCalled(); + expect(h.convergeTargetHostPortClaimsUnlocked).not.toHaveBeenCalled(); + }); }); diff --git a/apps/api/test/modules/deployments/custom-domain-failure-retention.test.ts b/apps/api/test/modules/deployments/custom-domain-failure-retention.test.ts new file mode 100644 index 000000000..5a2faf3a8 --- /dev/null +++ b/apps/api/test/modules/deployments/custom-domain-failure-retention.test.ts @@ -0,0 +1,19 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("failed deployment domain cleanup (#675)", () => { + it("tracks only authoritatively-created, non-custom domains for rollback", () => { + const source = readFileSync( + resolve(import.meta.dirname, "../../../src/modules/deployments/build-pipeline.ts"), + "utf8", + ); + + expect(source).toContain( + 'if (ensured.created && domainRecord && domainRecord.domainType !== "custom")', + ); + expect(source).not.toContain( + "!projectDomains.some((d) => d.id === created.id)", + ); + }); +}); diff --git a/apps/api/test/modules/deployments/deployment-status.test.ts b/apps/api/test/modules/deployments/deployment-status.test.ts index 7f7f41121..9403baa95 100644 --- a/apps/api/test/modules/deployments/deployment-status.test.ts +++ b/apps/api/test/modules/deployments/deployment-status.test.ts @@ -48,7 +48,8 @@ const SETTLED_STATUS_GUARDS: Array<{ { what: "startBuild idempotency guard", file: "apps/api/src/modules/deployments/build.service.ts", - anchor: '"building", "deploying", "ready", "failed", "cancelled", "action_required"', + anchor: + '[\n "building",\n "deploying",\n "ready",\n "failed",\n "cancelled",\n "action_required",', breaks: "POST /:id/build re-runs the build on an already-settled row", }, { @@ -146,9 +147,8 @@ describe("the in-flight vocabulary stays one vocabulary", () => { // `action_required` is settled — the artifact is gone and clearing the blocker // starts a NEW deploy. If it leaked into this set, the row would hold the // one-in-flight-per-project slot forever and block every future deploy. - const { IN_FLIGHT_DEPLOY_STATUSES, deploymentIsInFlight } = await import( - "../../../src/modules/projects/deployment-flags" - ); + const { IN_FLIGHT_DEPLOY_STATUSES, deploymentIsInFlight } = + await import("../../../src/modules/projects/deployment-flags"); expect(IN_FLIGHT_DEPLOY_STATUSES.has("action_required")).toBe(false); expect(deploymentIsInFlight({ status: "action_required" } as never)).toBe(false); expect(deploymentIsInFlight({ status: "deploying" } as never)).toBe(true); diff --git a/apps/api/test/modules/deployments/preflight.test.ts b/apps/api/test/modules/deployments/preflight.test.ts index 737f8d056..6cac99ebf 100644 --- a/apps/api/test/modules/deployments/preflight.test.ts +++ b/apps/api/test/modules/deployments/preflight.test.ts @@ -53,9 +53,10 @@ describe("runPreflightChecks", () => { slug: input.slug ? { available: input.slug !== "taken-endpoint", - message: input.slug === "taken-endpoint" - ? "\"taken-endpoint.openship.test\" is already taken. Choose a different subdomain." - : undefined, + message: + input.slug === "taken-endpoint" + ? '"taken-endpoint.openship.test" is already taken. Choose a different subdomain.' + : undefined, } : undefined, })); @@ -66,26 +67,29 @@ describe("runPreflightChecks", () => { }); it("checks free-domain availability for every public endpoint", async () => { - const result = await runPreflightChecks({ - repoUrl: "https://github.com/acme/app.git", - branch: "main", - buildImage: "node:22", - installCommand: "npm install", - buildCommand: "npm run build", - startCommand: "npm start", - port: 3000, - hasBuild: true, - hasServer: true, - deployTarget: "server", - organizationId: "org-1", - } as any, { - ctx: { userId: "user-1", organizationId: "org-1" } as any, - buildStrategy: "local", - publicEndpoints: [ - { port: 3000, domain: "taken-endpoint", domainType: "free" }, - { port: 4000, domain: "ok-endpoint", domainType: "free" }, - ], - }); + const result = await runPreflightChecks( + { + repoUrl: "https://github.com/acme/app.git", + branch: "main", + buildImage: "node:22", + installCommand: "npm install", + buildCommand: "npm run build", + startCommand: "npm start", + port: 3000, + hasBuild: true, + hasServer: true, + deployTarget: "server", + organizationId: "org-1", + } as any, + { + ctx: { userId: "user-1", organizationId: "org-1" } as any, + buildStrategy: "local", + publicEndpoints: [ + { port: 3000, domain: "taken-endpoint", domainType: "free" }, + { port: 4000, domain: "ok-endpoint", domainType: "free" }, + ], + }, + ); expect(result.ok).toBe(false); expect(result.checks).toEqual( @@ -102,34 +106,35 @@ describe("runPreflightChecks", () => { }), ]), ); - expect( - preflightFn.mock.calls.some(([input]) => input && input.slug === "taken-endpoint"), - ).toBe(true); - expect( - preflightFn.mock.calls.some(([input]) => input && input.slug === "ok-endpoint"), - ).toBe(true); + expect(preflightFn.mock.calls.some(([input]) => input && input.slug === "taken-endpoint")).toBe( + true, + ); + expect(preflightFn.mock.calls.some(([input]) => input && input.slug === "ok-endpoint")).toBe( + true, + ); }); it("accepts static path-targeted public endpoints", async () => { - const result = await runPreflightChecks({ - repoUrl: "https://github.com/acme/docs.git", - branch: "main", - buildImage: "node:22", - installCommand: "npm install", - buildCommand: "npm run build", - startCommand: "", - port: 3000, - hasBuild: true, - hasServer: false, - deployTarget: "cloud", - organizationId: "org-1", - } as any, { - ctx: { userId: "user-1", organizationId: "org-1" } as any, - buildStrategy: "local", - publicEndpoints: [ - { targetPath: "/docs", domain: "docs-site", domainType: "free" }, - ], - }); + const result = await runPreflightChecks( + { + repoUrl: "https://github.com/acme/docs.git", + branch: "main", + buildImage: "node:22", + installCommand: "npm install", + buildCommand: "npm run build", + startCommand: "", + port: 3000, + hasBuild: true, + hasServer: false, + deployTarget: "cloud", + organizationId: "org-1", + } as any, + { + ctx: { userId: "user-1", organizationId: "org-1" } as any, + buildStrategy: "local", + publicEndpoints: [{ targetPath: "/docs", domain: "docs-site", domainType: "free" }], + }, + ); expect(result.ok).toBe(true); expect(result.checks.some((check) => check.status === "fail")).toBe(false); @@ -218,6 +223,72 @@ describe("runPreflightChecks", () => { expect(result.checks.some((check) => check.message?.includes("start command"))).toBe(false); }); + it("accepts a source-less single-app release image and its image-owned command", async () => { + const result = await runPreflightChecks( + { + repoUrl: "", + branch: "", + localPath: undefined, + releaseImageRef: "ghcr.io/acme/app:v1.2.3", + framework: "node", + buildImage: "", + installCommand: "", + buildCommand: "", + startCommand: "", + port: 8080, + hasBuild: false, + hasServer: true, + source: "image", + build: "prebuilt", + workload: "web", + deployTarget: "server", + organizationId: "org-1", + } as any, + { + ctx: { userId: "user-1", organizationId: "org-1" } as any, + buildStrategy: "local", + }, + ); + + const config = result.checks.find((check) => check.id === "config"); + expect(result.ok).toBe(true); + expect(config).toMatchObject({ status: "pass" }); + expect(config?.message ?? "").not.toMatch( + /repository URL|local path|branch|build image|install command|start command/, + ); + }); + + it("rejects a project-level release image in the multi-service pipeline", async () => { + const result = await runPreflightChecks( + { + repoUrl: "", + branch: "", + releaseImageRef: "ghcr.io/acme/app:v1.2.3", + port: 8080, + hasBuild: false, + hasServer: true, + source: "image", + build: "prebuilt", + workload: "web", + deployTarget: "server", + organizationId: "org-1", + } as any, + { + ctx: { userId: "user-1", organizationId: "org-1" } as any, + buildStrategy: "local", + multiService: true, + composeServices: [], + }, + ); + + expect(result.ok).toBe(false); + expect(result.checks.find((check) => check.id === "config")).toMatchObject({ + label: "Service configuration", + status: "fail", + message: expect.stringContaining("deploys one app"), + }); + }); + it("still requires a port for a single Dockerfile web project", async () => { const result = await runPreflightChecks( { @@ -284,9 +355,7 @@ describe("runPreflightChecks", () => { }), ]), ); - expect( - result.checks.find((c) => c.id === "config")?.message, - ).toContain("orphaned-app"); + expect(result.checks.find((c) => c.id === "config")?.message).toContain("orphaned-app"); }); it("still hard-fails a monorepo sub-app missing commands when it HAS been deployed before", async () => { @@ -398,10 +467,7 @@ describe("runPreflightChecks", () => { }); it("a buildpack worker with no run command fails for the command, not the port", async () => { - const result = await runPreflightChecks( - { ...workerBase, startCommand: "" } as any, - opts, - ); + const result = await runPreflightChecks({ ...workerBase, startCommand: "" } as any, opts); const config = result.checks.find((c) => c.id === "config"); expect(config?.status).toBe("fail"); expect(config?.message ?? "").toContain("start command"); @@ -599,11 +665,9 @@ describe("runPreflightChecks", () => { ); expect(result.ok).toBe(false); - expect( - result.checks.some( - (c) => c.code === "CLOUD_REQUIRED_MANAGED_COMPOSE_DOMAINS", - ), - ).toBe(true); + expect(result.checks.some((c) => c.code === "CLOUD_REQUIRED_MANAGED_COMPOSE_DOMAINS")).toBe( + true, + ); }); it("does NOT false-fail a CONNECTED org's compose deploy on a service that inherits the default *.opsh.io subdomain", async () => { diff --git a/apps/api/test/modules/deployments/reused-artifact-not-reclaimed.test.ts b/apps/api/test/modules/deployments/reused-artifact-not-reclaimed.test.ts index ebf7cd17a..8a2dcd225 100644 --- a/apps/api/test/modules/deployments/reused-artifact-not-reclaimed.test.ts +++ b/apps/api/test/modules/deployments/reused-artifact-not-reclaimed.test.ts @@ -29,7 +29,9 @@ vi.mock("../../../src/lib/notification-dispatcher", () => ({ notification: { emit: vi.fn() }, })); vi.mock("../../../src/lib/audit", () => ({ audit: { recordAsync: vi.fn(), record: vi.fn() } })); -vi.mock("../../../src/lib/favicon-detector", () => ({ detectAndStoreFavicon: vi.fn(async () => {}) })); +vi.mock("../../../src/lib/favicon-detector", () => ({ + detectAndStoreFavicon: vi.fn(async () => {}), +})); vi.mock("../../../src/modules/mail/webmail/webmail-install.service", () => ({ onWebmailDeployed: vi.fn(async () => {}), })); @@ -109,6 +111,27 @@ describe("a reused artifact is not on the failure reclaim list", () => { // anywhere on this path puts a PINNED artifact back on the reclaim list. const assignments = src.match(/provisioned\.imageRef\s*=/g) ?? []; expect(assignments).toHaveLength(1); - expect(src).toMatch(/if \(!reusedArtifact\) provisioned\.imageRef = buildResult\.imageRef;/); + expect(src).toMatch( + /if \(!reusedArtifact && buildResult\.artifactOwned !== false\) \{\s*provisioned\.imageRef = buildResult\.imageRef;\s*\}/, + ); + }); + + it("a refresh fails closed when its active artifact is gone", () => { + const src = readFileSync( + resolve(import.meta.dirname, "../../../src/modules/deployments/build-pipeline.ts"), + "utf8", + ); + const refreshStart = src.indexOf("const refreshFrom = refreshAppDeploymentId(snapshot)"); + const ordinaryPinStart = src.indexOf( + "const image = pinnedAppImage(snapshot)", + refreshStart + 1, + ); + const refreshBranch = src.slice(refreshStart, ordinaryPinStart); + + expect(refreshStart).toBeGreaterThan(-1); + expect(ordinaryPinStart).toBeGreaterThan(refreshStart); + expect(refreshBranch).toContain("Cannot refresh without rebuilding"); + expect(refreshBranch).toContain("Use Redeploy instead"); + expect(refreshBranch).not.toContain("return gone("); }); }); diff --git a/apps/api/test/modules/deployments/rollback-frozen-env.test.ts b/apps/api/test/modules/deployments/rollback-frozen-env.test.ts index c9f835917..a63cd087a 100644 --- a/apps/api/test/modules/deployments/rollback-frozen-env.test.ts +++ b/apps/api/test/modules/deployments/rollback-frozen-env.test.ts @@ -49,19 +49,19 @@ describe("mergeServiceDeployEnv", () => { expect(merged.API_KEY).toBe("release"); }); - it("keeps service env winning on a normal deploy", () => { - // Unchanged behaviour for every non-rollback deploy: the compose UI can still - // override a global per service. + it("keeps a manual service env_var over compose on a normal project redeploy", () => { const merged = mergeServiceDeployEnv( layers({ - project: { API_KEY: "project-live" }, - frozen: { API_KEY: "this-deploys-snapshot" }, - inline: { API_KEY: "compose-inline" }, - service: { API_KEY: "service-live" }, + project: { PROJECT_ONLY: "project-live" }, + frozen: { PROJECT_ONLY: "captured" }, + inline: { COMPOSE_ONLY: "compose", MANUAL_KEY: "compose-old" }, + service: { MANUAL_KEY: "manually-added" }, }), false, ); - expect(merged.API_KEY).toBe("service-live"); + expect(merged).toEqual({ + PROJECT_ONLY: "captured", COMPOSE_ONLY: "compose", MANUAL_KEY: "manually-added", + }); }); it("does not delete keys the snapshot never captured", () => { @@ -126,6 +126,43 @@ describe("frozen env and {{publicUrl}} tokens", () => { }); }); +describe("frozen env and Compose templates", () => { + it("resolves an old release's expression against that release's frozen env", () => { + const merged = mergeLayers( + layers({ + project: { POSTGRES_PASSWORD: "today" }, + frozen: { POSTGRES_PASSWORD: "release-secret" }, + inline: { + DATABASE_URL: "postgresql://user:${POSTGRES_PASSWORD:?set it}@db/app", + }, + templateKeys: ["DATABASE_URL"], + }), + true, + ); + + expect(merged.env.DATABASE_URL).toBe( + "postgresql://user:release-secret@db/app", + ); + expect(merged.missingRequired).toEqual([]); + }); + + it("does not re-evaluate a target value frozen directly in the release", () => { + const merged = mergeLayers( + layers({ + frozen: { DATABASE_URL: "postgresql://frozen-value" }, + inline: { + DATABASE_URL: "postgresql://user:${POSTGRES_PASSWORD:?set it}@db/app", + }, + templateKeys: ["DATABASE_URL"], + }), + true, + ); + + expect(merged.env.DATABASE_URL).toBe("postgresql://frozen-value"); + expect(merged.missingRequired).toEqual([]); + }); +}); + describe("diffFrozenEnv", () => { it("never emits a value from either side", () => { // The whole surface is serialized and compared against every secret in play: diff --git a/apps/api/test/modules/domains/domain-www-records.test.ts b/apps/api/test/modules/domains/domain-www-records.test.ts index 6882b3b7c..83ea6b8a7 100644 --- a/apps/api/test/modules/domains/domain-www-records.test.ts +++ b/apps/api/test/modules/domains/domain-www-records.test.ts @@ -23,6 +23,7 @@ vi.mock("@repo/db", () => ({ let platformTarget: "local" | "cloud" = "local"; const cloudVerifyDomain = vi.fn(); +const resolveSelectedServerHost = vi.fn(); vi.mock("../../../src/lib/controller-helpers", async (importOriginal) => { const actual = await importOriginal(); @@ -42,6 +43,7 @@ vi.mock("../../../src/lib/server-target", async (importOriginal) => { resolveProjectServerHost: vi.fn().mockResolvedValue("203.0.113.10"), resolveLocalServerHost: vi.fn().mockResolvedValue("203.0.113.10"), resolveInstancePublicIp: vi.fn().mockResolvedValue("203.0.113.10"), + resolveServerHost: resolveSelectedServerHost, }; }); @@ -53,6 +55,7 @@ beforeEach(() => { cloudVerifyDomain.mockResolvedValue({ requiredRecords: { cname: { target: "edge.opsh.io" } }, }); + resolveSelectedServerHost.mockResolvedValue("198.51.100.42"); }); describe("previewRecords — self-hosted", () => { @@ -63,6 +66,17 @@ describe("previewRecords — self-hosted", () => { expect(records[0]).toMatchObject({ type: "A", host: "freshs", name: "freshs.hekai.org" }); }); + it("uses the selected remote Docker server for a pre-deploy preview (#663)", async () => { + const { records } = await previewRecords( + "app.example.com", + "org_1", + false, + "server_remote", + ); + expect(resolveSelectedServerHost).toHaveBeenCalledWith("org_1", "server_remote"); + expect(records[0]).toMatchObject({ type: "A", value: "198.51.100.42" }); + }); + it("adds the www A record when the toggle is on", async () => { const { records } = await previewRecords("freshs.hekai.org", "org_1", true); expect(records).toHaveLength(2); diff --git a/apps/api/test/modules/domains/project-route.service.test.ts b/apps/api/test/modules/domains/project-route.service.test.ts index 248f00d74..05151fba8 100644 --- a/apps/api/test/modules/domains/project-route.service.test.ts +++ b/apps/api/test/modules/domains/project-route.service.test.ts @@ -100,23 +100,13 @@ describe("shouldRefuseLoopbackRoute", () => { describe("deriveEnvironmentPublicEndpoints", () => { it("clones an explicit proxy target without inventing a fallback port", () => { - expect( - deriveEnvironmentPublicEndpoints( - [{ port: 4010 }], - "preview-app", - ), - ).toEqual([ + expect(deriveEnvironmentPublicEndpoints([{ port: 4010 }], "preview-app")).toEqual([ { port: 4010, domain: "preview-app", domainType: "free" }, ]); }); it("clones an explicit static path target without inventing a port", () => { - expect( - deriveEnvironmentPublicEndpoints( - [{ targetPath: "/docs" }], - "preview-docs", - ), - ).toEqual([ + expect(deriveEnvironmentPublicEndpoints([{ targetPath: "/docs" }], "preview-docs")).toEqual([ { targetPath: "/docs", domain: "preview-docs", domainType: "free" }, ]); }); @@ -172,7 +162,7 @@ describe("reapplyProjectLiveRoutes self-app loopback route (issue #129)", () => }); resolveRuntime.mockResolvedValue({ routing: { provider: "bare" }, - runtime: { supports: () => false }, + runtime: { name: "bare", supports: () => false }, effectiveTarget: "local", serverId: null, }); @@ -183,7 +173,12 @@ describe("reapplyProjectLiveRoutes self-app loopback route (issue #129)", () => expect(reconcile).toHaveBeenCalledTimes(1); expect(reconcile.mock.calls[0][1].registers).toEqual([ - { hostname: "panel.example.com", targetUrl: "http://127.0.0.1:3001", isCustomDomain: false }, + { + hostname: "panel.example.com", + targetUrl: "http://127.0.0.1:3001", + isCustomDomain: false, + observedLoopbackPublishes: [{ serviceId: null, containerPort: 3001, hostPort: 3001 }], + }, ]); }); @@ -245,7 +240,7 @@ describe("reapplyProjectLiveRoutes static (path-targeted) routes", () => { deregisterManagedEdge.mockReset().mockResolvedValue({ failures: [] }); resolveRuntime.mockResolvedValue({ routing: { provider: "bare" }, - runtime: { supports: () => false }, + runtime: { name: "bare", supports: () => false }, effectiveTarget: "local", serverId: null, }); @@ -278,7 +273,9 @@ describe("reapplyProjectLiveRoutes static (path-targeted) routes", () => { ...staticProject, routingConfig: { redirects: [{ source: "/blog/:path*", destination: "/news/:path*", permanent: true }], - headers: [{ source: "/api/(.*)", headers: [{ key: "Cache-Control", value: "no-store" }] }], + headers: [ + { source: "/api/(.*)", headers: [{ key: "Cache-Control", value: "no-store" }] }, + ], cleanUrls: true, trailingSlash: false, }, @@ -433,6 +430,7 @@ describe("reapplyProjectLiveRoutes static (path-targeted) routes", () => { expect(reconcile.mock.calls[0][1].registers[0]).toMatchObject({ hostname: "sadsa.opsh.io", targetUrl: "http://127.0.0.1:3000", + observedLoopbackPublishes: [{ serviceId: null, containerPort: 3000, hostPort: 3000 }], }); }); }); @@ -464,7 +462,9 @@ describe("deriveNextProjectRouteState custom-hostname gate", () => { it("accepts a real hostname, scheme and all", () => { const state = deriveNextProjectRouteState(project, { - nextPublicEndpoints: [{ customDomain: "HTTPS://App.Example.com/", domainType: "custom", port: 3000 }], + nextPublicEndpoints: [ + { customDomain: "HTTPS://App.Example.com/", domainType: "custom", port: 3000 }, + ], }); expect(state.publicEndpoints[0]).toMatchObject({ customDomain: "app.example.com" }); @@ -477,16 +477,18 @@ describe("deriveNextProjectRouteState custom-hostname gate", () => { * submission INTRODUCES are refused; the endpoint list is authoritative, so every * save echoes the stored set back. */ - const legacyRow = [{ - id: "dom-legacy", - hostname: "localhost", - isPrimary: true, - serviceId: null, - targetPort: 3000, - targetPath: null, - domainType: "custom", - verified: false, - }] as never; + const legacyRow = [ + { + id: "dom-legacy", + hostname: "localhost", + isPrimary: true, + serviceId: null, + targetPort: 3000, + targetPath: null, + domainType: "custom", + verified: false, + }, + ] as never; it("does not throw on a bad hostname that is already stored", () => { expect(() => deriveNextProjectRouteState(project, { projectDomains: legacyRow })).not.toThrow(); @@ -508,7 +510,9 @@ describe("deriveNextProjectRouteState custom-hostname gate", () => { expect(() => deriveNextProjectRouteState(project, { projectDomains: legacyRow, - nextPublicEndpoints: [{ customDomain: "app.example.com", domainType: "custom", port: 3000 }], + nextPublicEndpoints: [ + { customDomain: "app.example.com", domainType: "custom", port: 3000 }, + ], }), ).not.toThrow(); }); @@ -580,12 +584,32 @@ describe("reapplyProjectLiveRoutes multi-service project-level routes (issue #61 verified: true, }); - /** container-ip makes the resolved containerPort visible in the asserted URL. */ - const containerIpRuntime = { + /** A route writer must observe the container as RUNNING before publishing. */ + const liveDockerRuntime = { name: "docker", - supports: (feature: string) => feature === "containerIp", - getContainerIp: async (id: string) => - ({ "c-web": "10.0.0.2", "c-postgres": "10.0.0.3", "c-worker": "10.0.0.4" })[id] ?? null, + supports: (feature: string) => feature === "containerInfo" || feature === "containerIp", + getContainerInfo: async (id: string) => { + const rows = await listServicesByDeployment(); + const row = rows.find( + (candidate: { containerId?: string | null }) => candidate.containerId === id, + ); + return row + ? { + containerId: id, + status: "running", + ip: row.ip ?? undefined, + hostPort: row.hostPort ?? undefined, + hostPortByContainerPort: row.hostPorts ?? undefined, + } + : { containerId: id, status: "missing" }; + }, + getContainerIp: async (id: string) => { + const rows = await listServicesByDeployment(); + return ( + rows.find((candidate: { containerId?: string | null }) => candidate.containerId === id) + ?.ip ?? null + ); + }, }; beforeEach(() => { @@ -605,11 +629,9 @@ describe("reapplyProjectLiveRoutes multi-service project-level routes (issue #61 }); listServicesByProject.mockResolvedValue(services); listServicesByDeployment.mockResolvedValue(liveRows); - // No containerInfo support → the live host-port read is "couldn't ask", so the - // stored row's port is used (the #506 rule) instead of being invented. resolveRuntime.mockResolvedValue({ routing: { provider: "docker" }, - runtime: { name: "docker", supports: () => false }, + runtime: liveDockerRuntime, effectiveTarget: "server", serverId: "srv-1", }); @@ -622,7 +644,12 @@ describe("reapplyProjectLiveRoutes multi-service project-level routes (issue #61 expect(reconcile).toHaveBeenCalledTimes(1); expect(reconcile.mock.calls[0][1].registers).toEqual([ - { hostname: "app.example.com", isCustomDomain: true, targetUrl: "http://127.0.0.1:3000" }, + { + hostname: "app.example.com", + isCustomDomain: true, + targetUrl: "http://127.0.0.1:3000", + observedLoopbackPublishes: [{ serviceId: "svc-web", containerPort: 3000, hostPort: 3000 }], + }, ]); }); @@ -630,7 +657,7 @@ describe("reapplyProjectLiveRoutes multi-service project-level routes (issue #61 listByProject.mockResolvedValue([projectDomain(3000)]); resolveRuntime.mockResolvedValue({ routing: { provider: "docker" }, - runtime: containerIpRuntime, + runtime: liveDockerRuntime, effectiveTarget: "server", serverId: "srv-1", }); @@ -652,7 +679,7 @@ describe("reapplyProjectLiveRoutes multi-service project-level routes (issue #61 ]); resolveRuntime.mockResolvedValue({ routing: { provider: "docker" }, - runtime: containerIpRuntime, + runtime: liveDockerRuntime, effectiveTarget: "server", serverId: "srv-1", }); @@ -674,7 +701,7 @@ describe("reapplyProjectLiveRoutes multi-service project-level routes (issue #61 listByProject.mockResolvedValue([projectDomain(9999)]); resolveRuntime.mockResolvedValue({ routing: { provider: "docker" }, - runtime: containerIpRuntime, + runtime: liveDockerRuntime, effectiveTarget: "server", serverId: "srv-1", }); @@ -697,7 +724,7 @@ describe("reapplyProjectLiveRoutes multi-service project-level routes (issue #61 }); resolveRuntime.mockResolvedValue({ routing: { provider: "docker" }, - runtime: containerIpRuntime, + runtime: liveDockerRuntime, effectiveTarget: "server", serverId: "srv-1", }); @@ -722,7 +749,12 @@ describe("reapplyProjectLiveRoutes multi-service project-level routes (issue #61 await reapplyProjectLiveRoutes(project, []); expect(reconcile.mock.calls[0][1].registers).toEqual([ - { hostname: "app.example.com", isCustomDomain: true, targetUrl: "http://127.0.0.1:32770" }, + { + hostname: "app.example.com", + isCustomDomain: true, + targetUrl: "http://127.0.0.1:32770", + observedLoopbackPublishes: [{ serviceId: "svc-web", containerPort: 3000, hostPort: 32770 }], + }, ]); }); @@ -739,7 +771,7 @@ describe("reapplyProjectLiveRoutes multi-service project-level routes (issue #61 }); resolveRuntime.mockResolvedValue({ routing: { provider: "docker" }, - runtime: containerIpRuntime, + runtime: liveDockerRuntime, effectiveTarget: "server", serverId: "srv-1", }); @@ -778,7 +810,12 @@ describe("reapplyProjectLiveRoutes multi-service project-level routes (issue #61 routing: { provider: "docker" }, runtime: { name: "docker", - supports: (feature: string) => feature === "containerIp", + supports: (feature: string) => feature === "containerInfo" || feature === "containerIp", + getContainerInfo: async (id: string) => ({ + containerId: id, + status: "running", + ip: id === "c-single" ? "10.0.0.9" : undefined, + }), getContainerIp: async (id: string) => (id === "c-single" ? "10.0.0.9" : null), }, effectiveTarget: "server", diff --git a/apps/api/test/modules/github/gh-identity-health.test.ts b/apps/api/test/modules/github/gh-identity-health.test.ts index 64469672b..9d04a6e94 100644 --- a/apps/api/test/modules/github/gh-identity-health.test.ts +++ b/apps/api/test/modules/github/gh-identity-health.test.ts @@ -42,7 +42,11 @@ vi.mock("../../../src/config/env", () => ({ env: {}, runtimeTarget: { id: "local vi.mock("@octokit/auth-oauth-device", () => ({ createOAuthDeviceAuth: vi.fn() })); -import { getLocalGhStatus } from "../../../src/modules/github/github.local-auth"; +import { + getLocalGhStatus, + ghAuthTokenViaConfig, + resolveGhHostsPath, +} from "../../../src/modules/github/github.local-auth"; /** GitHub's /user answering with `status`. */ function githubUserReturns(status: number, body: unknown = {}) { @@ -76,6 +80,103 @@ beforeEach(() => { decrypt.mockReturnValue("ghp_live_token"); }); +describe("GitHub CLI config isolation", () => { + it("applies GitHub CLI's documented config-path precedence", () => { + expect( + resolveGhHostsPath( + { GH_CONFIG_DIR: "/isolated/gh", XDG_CONFIG_HOME: "/isolated/xdg" }, + "/home/operator", + "linux", + ), + ).toBe("/isolated/gh/hosts.yml"); + + expect( + resolveGhHostsPath({ XDG_CONFIG_HOME: "/isolated/xdg" }, "/home/operator", "linux"), + ).toBe("/isolated/xdg/gh/hosts.yml"); + + expect(resolveGhHostsPath({}, "/home/operator", "linux")).toBe( + "/home/operator/.config/gh/hosts.yml", + ); + + expect( + resolveGhHostsPath( + { APPDATA: "C:\\Users\\operator\\AppData\\Roaming" }, + "C:\\Users\\operator", + "win32", + ), + ).toBe("C:\\Users\\operator\\AppData\\Roaming\\GitHub CLI\\hosts.yml"); + }); + + it.each([ + { + name: "GH_CONFIG_DIR", + environment: { GH_CONFIG_DIR: "/isolated/gh" }, + expected: "/isolated/gh/hosts.yml", + }, + { + name: "XDG_CONFIG_HOME", + environment: { XDG_CONFIG_HOME: "/isolated/xdg" }, + expected: "/isolated/xdg/gh/hosts.yml", + }, + ])( + "does not fall back to another user's home when $name is set", + async ({ environment, expected }) => { + const read = vi.fn(async (path: string) => { + if (path === "/home/other-user/.config/gh/hosts.yml") { + return "github.com:\n oauth_token: ghp_wrong_user\n"; + } + throw Object.assign(new Error("missing isolated config"), { code: "ENOENT" }); + }); + + await expect( + ghAuthTokenViaConfig({ + environment, + homeDirectory: "/home/other-user", + platform: "linux", + read, + }), + ).resolves.toBeNull(); + + expect(read).toHaveBeenCalledOnce(); + expect(read).toHaveBeenCalledWith(expected, "utf-8"); + }, + ); + + it("does not fall back when the authoritative config exists without a GitHub token", async () => { + const read = vi.fn(async (path: string) => { + if (path === "/isolated/gh/hosts.yml") return "example.com:\n oauth_token: other\n"; + return "github.com:\n oauth_token: ghp_wrong_user\n"; + }); + + await expect( + ghAuthTokenViaConfig({ + environment: { GH_CONFIG_DIR: "/isolated/gh" }, + homeDirectory: "/home/other-user", + platform: "linux", + read, + }), + ).resolves.toBeNull(); + + expect(read).toHaveBeenCalledOnce(); + expect(read).toHaveBeenCalledWith("/isolated/gh/hosts.yml", "utf-8"); + }); + + it("still reads the normal home config when no override is present", async () => { + const read = vi.fn(async () => "github.com:\n oauth_token: ghp_expected_user\n"); + + await expect( + ghAuthTokenViaConfig({ + environment: {}, + homeDirectory: "/home/operator", + platform: "linux", + read, + }), + ).resolves.toBe("ghp_expected_user"); + + expect(read).toHaveBeenCalledWith("/home/operator/.config/gh/hosts.yml", "utf-8"); + }); +}); + describe("getLocalGhStatus — credential health", () => { it("reports the identity and its method when GitHub accepts it", async () => { githubUserReturns(200, { login: "hydralerne", id: 7, avatar_url: "https://avatars/1" }); diff --git a/apps/api/test/modules/migration/docker-inspect.test.ts b/apps/api/test/modules/migration/docker-inspect.test.ts index 809a2930d..2d53edc86 100644 --- a/apps/api/test/modules/migration/docker-inspect.test.ts +++ b/apps/api/test/modules/migration/docker-inspect.test.ts @@ -20,6 +20,12 @@ const COMPOSE = ` services: web: image: myapp-web:latest + build: + context: ../../ + dockerfile: services/shared/Dockerfile + args: + APP_PACKAGE: "@myorg/web" + INHERIT_FROM_ENV: depends_on: [db] ports: ["8080:3000"] db: @@ -55,7 +61,12 @@ const DB: DockerContainerDetail = { networks: ["myapp_default", "myapp_backend"], mounts: [ { type: "volume", name: "myapp_pgdata", destination: "/var/lib/postgresql/data", rw: true }, - { type: "bind", source: "/etc/myapp/pg.conf", destination: "/etc/postgresql/postgresql.conf", rw: false }, + { + type: "bind", + source: "/etc/myapp/pg.conf", + destination: "/etc/postgresql/postgresql.conf", + rw: false, + }, ], ports: [{ privatePort: 5432, type: "tcp" }], restart: { name: "always" }, @@ -141,6 +152,12 @@ describe("reconcileStack", () => { it("merges compose declaration with inspect truth for compose services", () => { const web = stack.services.find((s) => s.name === "web")!; expect(web.source).toBe("compose"); + expect(web.build).toBe("../../"); + expect(web.dockerfile).toBe("services/shared/Dockerfile"); + expect(web.buildArgs).toEqual({ + APP_PACKAGE: "@myorg/web", + INHERIT_FROM_ENV: null, + }); expect(web.dependsOn).toEqual(["db"]); expect(web.ports).toEqual(["8080:3000"]); // PATH is filtered as docker-injected noise; app env survives. @@ -207,9 +224,7 @@ services: }); it("reports only in-use named volumes, with their consumers", () => { - expect(stack.volumes).toEqual([ - { name: "myapp_pgdata", driver: "local", inUseBy: ["db"] }, - ]); + expect(stack.volumes).toEqual([{ name: "myapp_pgdata", driver: "local", inUseBy: ["db"] }]); }); it("warns about custom networks it will flatten", () => { diff --git a/apps/api/test/modules/projects/ensure-compose-services.test.ts b/apps/api/test/modules/projects/ensure-compose-services.test.ts index 0e57103ca..e67d87840 100644 --- a/apps/api/test/modules/projects/ensure-compose-services.test.ts +++ b/apps/api/test/modules/projects/ensure-compose-services.test.ts @@ -118,7 +118,9 @@ describe("ensureProject compose services", () => { ); expect(result.created).toBe(true); - expect(serviceRepo.syncFromCompose).toHaveBeenCalledWith("proj_new", scannedServices); + expect(serviceRepo.syncFromCompose).toHaveBeenCalledWith("proj_new", scannedServices, { + composeAuthoritative: true, + }); }); it("re-syncs the services when updating an existing project", async () => { @@ -136,7 +138,9 @@ describe("ensureProject compose services", () => { ); expect(result.created).toBe(false); - expect(serviceRepo.syncFromCompose).toHaveBeenCalledWith("proj_1", scannedServices); + expect(serviceRepo.syncFromCompose).toHaveBeenCalledWith("proj_1", scannedServices, { + composeAuthoritative: true, + }); }); it("leaves the service table alone when the request carries no services", async () => { @@ -205,9 +209,11 @@ describe("ensureProject compose services — masked env", () => { "org_1", ); - expect(serviceRepo.syncFromCompose).toHaveBeenCalledWith("proj_new", [ - expect.objectContaining({ environment: { DB_PASSWORD: "s3cret" } }), - ]); + expect(serviceRepo.syncFromCompose).toHaveBeenCalledWith( + "proj_new", + [expect.objectContaining({ environment: { DB_PASSWORD: "s3cret" } })], + { composeAuthoritative: true }, + ); }); it("restores from the stored row when re-ensuring an existing project", async () => { @@ -221,17 +227,21 @@ describe("ensureProject compose services — masked env", () => { "org_1", ); - expect(serviceRepo.syncFromCompose).toHaveBeenCalledWith("proj_1", [ - expect.objectContaining({ environment: { DB_PASSWORD: "from-row" } }), - ]); + expect(serviceRepo.syncFromCompose).toHaveBeenCalledWith( + "proj_1", + [expect.objectContaining({ environment: { DB_PASSWORD: "from-row" } })], + { composeAuthoritative: true }, + ); }); it("drops a masked value with no source instead of persisting the sentinel", async () => { await ensureProject({ name: "my-stack", services: maskedServices } as any, "org_1"); - expect(serviceRepo.syncFromCompose).toHaveBeenCalledWith("proj_new", [ - expect.objectContaining({ environment: {} }), - ]); + expect(serviceRepo.syncFromCompose).toHaveBeenCalledWith( + "proj_new", + [expect.objectContaining({ environment: {} })], + { composeAuthoritative: true }, + ); }); it("ignores an upload session belonging to another org", async () => { @@ -242,9 +252,11 @@ describe("ensureProject compose services — masked env", () => { "org_1", ); - expect(serviceRepo.syncFromCompose).toHaveBeenCalledWith("proj_new", [ - expect.objectContaining({ environment: {} }), - ]); + expect(serviceRepo.syncFromCompose).toHaveBeenCalledWith( + "proj_new", + [expect.objectContaining({ environment: {} })], + { composeAuthoritative: true }, + ); }); it("passes revealed/edited values through untouched", async () => { @@ -256,7 +268,9 @@ describe("ensureProject compose services — masked env", () => { "org_1", ); - expect(serviceRepo.syncFromCompose).toHaveBeenCalledWith("proj_new", edited); + expect(serviceRepo.syncFromCompose).toHaveBeenCalledWith("proj_new", edited, { + composeAuthoritative: true, + }); // No mask anywhere → no need to read rows back at all. expect(serviceRepo.listByProject).not.toHaveBeenCalled(); }); diff --git a/apps/api/test/modules/projects/project-route-update.test.ts b/apps/api/test/modules/projects/project-route-update.test.ts index 578b66f24..43d6e8c9a 100644 --- a/apps/api/test/modules/projects/project-route-update.test.ts +++ b/apps/api/test/modules/projects/project-route-update.test.ts @@ -17,6 +17,8 @@ const routeState = vi.hoisted(() => ({ syncProjectRouteState: vi.fn(), })); +const applyProjectRouting = vi.hoisted(() => vi.fn()); + vi.mock("@repo/db", async (importOriginal) => { const actual = await importOriginal(); return { @@ -40,7 +42,7 @@ vi.mock("../../../src/modules/domains/project-route.service", () => ({ })); vi.mock("../../../src/modules/domains/routing-apply.service", () => ({ - applyProjectRouting: vi.fn(), + applyProjectRouting, })); import { updateProject } from "../../../src/modules/projects/project-crud.service"; @@ -68,6 +70,7 @@ describe("updateProject route persistence", () => { routeState.reapplyProjectLiveRoutes.mockReset(); routeState.resolveProjectRouteState.mockReset(); routeState.syncProjectRouteState.mockReset(); + applyProjectRouting.mockReset().mockResolvedValue(undefined); projectRepo.findById.mockResolvedValue(project); routeState.listProjectRouteRows.mockResolvedValue([{ hostname: "old.example.com" }]); @@ -111,6 +114,49 @@ describe("updateProject route persistence", () => { }); }); + it("re-applies project routes before service and topology routes for a routing edit", async () => { + await updateProject( + project.id, + { routingConfig: { rewrites: [] } } as never, + project.organizationId, + ); + + expect(routeState.reapplyProjectLiveRoutes).toHaveBeenCalledWith(project, []); + expect(applyProjectRouting).toHaveBeenCalledWith(project.id); + expect(routeState.reapplyProjectLiveRoutes.mock.invocationCallOrder[0]).toBeLessThan( + applyProjectRouting.mock.invocationCallOrder[0]!, + ); + }); + + it("treats a route-strategy change as a complete ordered live route re-apply", async () => { + await updateProject( + project.id, + { routeStrategy: "container-ip" } as never, + project.organizationId, + ); + await vi.waitFor(() => expect(applyProjectRouting).toHaveBeenCalledWith(project.id)); + + expect(routeState.reapplyProjectLiveRoutes).toHaveBeenCalledWith(project, ["old.example.com"], { + managedEdgeSyncedByCaller: true, + }); + expect(routeState.reapplyProjectLiveRoutes.mock.invocationCallOrder[0]).toBeLessThan( + applyProjectRouting.mock.invocationCallOrder[0]!, + ); + }); + + it("does not rewrite live routes for a no-op route-strategy save", async () => { + projectRepo.findById.mockResolvedValue({ ...project, routeStrategy: "container-ip" }); + + await updateProject( + project.id, + { routeStrategy: "container-ip" } as never, + project.organizationId, + ); + + expect(routeState.reapplyProjectLiveRoutes).not.toHaveBeenCalled(); + expect(applyProjectRouting).not.toHaveBeenCalled(); + }); + /** * #342: PATCH /projects/:id used to store any non-empty customDomain verbatim. * `localhost` then reached the edge as `server_name localhost;` on the next route @@ -189,7 +235,11 @@ describe("updateProject route persistence", () => { it("accepts a pasted https:// hostname", async () => { await updateProject( project.id, - { publicEndpoints: [{ customDomain: "https://app.example.com/", domainType: "custom", port: 4321 }] }, + { + publicEndpoints: [ + { customDomain: "https://app.example.com/", domainType: "custom", port: 4321 }, + ], + }, project.organizationId, ); diff --git a/apps/api/test/modules/projects/retry-routing.test.ts b/apps/api/test/modules/projects/retry-routing.test.ts index 707d7ab96..2792ee4ba 100644 --- a/apps/api/test/modules/projects/retry-routing.test.ts +++ b/apps/api/test/modules/projects/retry-routing.test.ts @@ -12,6 +12,8 @@ const withExecutor = vi.hoisted(() => vi.fn()); const applyProjectRouting = vi.hoisted(() => vi.fn()); const reapplyProjectLiveRoutes = vi.hoisted(() => vi.fn()); const syncManagedEdgeRoutes = vi.hoisted(() => vi.fn()); +const withDeploymentPlatform = vi.hoisted(() => vi.fn()); +const reconcileServerEdge = vi.hoisted(() => vi.fn()); vi.mock("@repo/db", async (importOriginal) => { const actual = await importOriginal(); @@ -35,8 +37,11 @@ vi.mock("../../../src/lib/managed-edge-proxy", () => ({ vi.mock("../../../src/lib/deployment-runtime", () => ({ resolveDeploymentRuntime: vi.fn(), + withDeploymentPlatform, })); +vi.mock("../../../src/lib/edge-reconcile", () => ({ reconcileServerEdge })); + vi.mock("../../../src/modules/domains/routing-apply.service", () => ({ applyProjectRouting, })); @@ -95,6 +100,11 @@ describe("retryProjectRouting — safe self-heal", () => { applyProjectRouting.mockResolvedValue(undefined); reapplyProjectLiveRoutes.mockResolvedValue(undefined); syncManagedEdgeRoutes.mockResolvedValue({ failures: [] }); + reconcileServerEdge.mockResolvedValue({ converted: false, updated: false, edgeDown: false }); + withDeploymentPlatform.mockImplementation( + async (_dep: unknown, fn: (resolved: { executor: unknown; effectiveTarget: string }) => Promise) => + fn({ executor: {}, effectiveTarget: "server" }), + ); // withExecutor(serverId, fn) → run fn with a dummy executor. withExecutor.mockImplementation(async (_serverId: string, fn: (e: unknown) => Promise) => fn({}), @@ -130,6 +140,47 @@ describe("retryProjectRouting — safe self-heal", () => { expect(domainRepo.update).toHaveBeenCalledWith("dom_api", { targetPort: 4000 }); }); + it("revives a stopped or missing edge before applying any route configuration (#693)", async () => { + domainRepo.listByProject.mockResolvedValue([nulledCustomRow({ targetPort: 4000 })]); + + const result = await retryProjectRouting("proj_1", "org_1"); + + expect(result).toEqual({ ok: true }); + expect(reconcileServerEdge).toHaveBeenCalledOnce(); + expect(checkEdge).toHaveBeenCalled(); + expect(reconcileServerEdge.mock.invocationCallOrder[0]).toBeLessThan( + reapplyProjectLiveRoutes.mock.invocationCallOrder[0]!, + ); + expect(reconcileServerEdge.mock.invocationCallOrder[0]).toBeLessThan( + applyProjectRouting.mock.invocationCallOrder[0]!, + ); + }); + + it("fails fast with the recovery reason instead of issuing edge commands when revival fails (#693)", async () => { + domainRepo.listByProject.mockResolvedValue([nulledCustomRow({ targetPort: 4000 })]); + reconcileServerEdge.mockResolvedValue({ + converted: false, + updated: false, + edgeDown: true, + error: "docker start openship-edge failed", + }); + + const result = await retryProjectRouting("proj_1", "org_1"); + + expect(result).toEqual({ + ok: false, + warning: "Couldn't restore the edge before retrying routing: docker start openship-edge failed", + }); + expect(reapplyProjectLiveRoutes).not.toHaveBeenCalled(); + expect(applyProjectRouting).not.toHaveBeenCalled(); + expect(checkEdge).not.toHaveBeenCalled(); + expect(deploymentRepo.updateStatus).toHaveBeenCalledWith( + "dep_1", + "ready", + { meta: expect.objectContaining({ edgeUnsynced: true }) }, + ); + }); + it("leaves the row unchanged when the edge has no live upstream (never guesses)", async () => { domainRepo.listByProject.mockResolvedValue([nulledCustomRow()]); siteFor.mockResolvedValue(null); @@ -157,6 +208,7 @@ describe("retryProjectRouting — safe self-heal", () => { const result = await retryProjectRouting("proj_1", "org_1"); expect(result).toEqual({ ok: true }); + expect(reconcileServerEdge).not.toHaveBeenCalled(); expect(withExecutor).not.toHaveBeenCalled(); expect(domainRepo.update).not.toHaveBeenCalled(); }); diff --git a/apps/api/test/modules/services/service-route-upstream.test.ts b/apps/api/test/modules/services/service-route-upstream.test.ts index 55130a5fd..edf76a77f 100644 --- a/apps/api/test/modules/services/service-route-upstream.test.ts +++ b/apps/api/test/modules/services/service-route-upstream.test.ts @@ -26,7 +26,13 @@ vi.mock("@repo/db", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - repos: { ...actual.repos, project: projectRepo, service: serviceRepo, deployment: deploymentRepo, domain: domainRepo }, + repos: { + ...actual.repos, + project: projectRepo, + service: serviceRepo, + deployment: deploymentRepo, + domain: domainRepo, + }, }; }); @@ -58,7 +64,13 @@ vi.mock("../../../src/lib/deployment-runtime", async (importOriginal) => { import { updateService } from "../../../src/modules/services/service.service"; const ctx = { organizationId: "org_1" } as never; -const project = { id: "proj_1", organizationId: "org_1", slug: "kuma", activeDeploymentId: "dep_1", routeStrategy: "auto" }; +const project = { + id: "proj_1", + organizationId: "org_1", + slug: "kuma", + activeDeploymentId: "dep_1", + routeStrategy: "auto", +}; /** A migrated single-service project: listens on 3001, custom domain attached. */ const migratedService = () => ({ @@ -130,7 +142,13 @@ describe("service route upstream (migration cutover)", () => { }); // The row still claims a loopback publish from an earlier deploy. serviceRepo.listByDeployment.mockResolvedValue([ - { serviceId: "svc_1", deploymentId: "dep_1", containerId: "container_1", ip: "172.19.0.2", hostPort: 3001 }, + { + serviceId: "svc_1", + deploymentId: "dep_1", + containerId: "container_1", + ip: "172.19.0.2", + hostPort: 3001, + }, ]); liveContainer({ ip: "172.19.0.2" }); }); @@ -150,12 +168,20 @@ describe("service route upstream (migration cutover)", () => { expect(registeredTarget()?.targetUrl).toBe("http://127.0.0.1:43001"); }); - it("keeps the last-known upstream when the container cannot be inspected", async () => { + it("does not re-register a cached upstream when the container cannot be inspected", async () => { liveContainer(null); await publishRoute(); - expect(registeredTarget()?.targetUrl).toBe("http://127.0.0.1:3001"); + expect(registeredTarget()?.targetUrl).toBeUndefined(); + }); + + it("does not re-register a cache when the target runtime cannot be resolved", async () => { + resolveDeploymentRuntimeForRead.mockRejectedValueOnce(new Error("host unavailable")); + + await publishRoute(); + + expect(registeredTarget()?.targetUrl).toBeUndefined(); }); it("releases the runtime it opened to inspect the container", async () => { @@ -166,14 +192,20 @@ describe("service route upstream (migration cutover)", () => { expect(dispose).toHaveBeenCalled(); }); - it("does not open a runtime when the service has no container yet", async () => { + it("does not route a stored bridge IP when the service has no container", async () => { serviceRepo.listByDeployment.mockResolvedValue([ - { serviceId: "svc_1", deploymentId: "dep_1", containerId: null, ip: "172.19.0.2", hostPort: null }, + { + serviceId: "svc_1", + deploymentId: "dep_1", + containerId: null, + ip: "172.19.0.2", + hostPort: null, + }, ]); await publishRoute(); expect(resolveDeploymentRuntimeForRead).not.toHaveBeenCalled(); - expect(registeredTarget()?.targetUrl).toBe("http://172.19.0.2:3001"); + expect(registeredTarget()?.targetUrl).toBeUndefined(); }); }); diff --git a/apps/api/test/modules/services/service-routing-patch.test.ts b/apps/api/test/modules/services/service-routing-patch.test.ts index 9519ca4a2..42c3a847f 100644 --- a/apps/api/test/modules/services/service-routing-patch.test.ts +++ b/apps/api/test/modules/services/service-routing-patch.test.ts @@ -53,7 +53,11 @@ vi.mock("../../../src/lib/controller-helpers", async (importOriginal) => { return { ...actual, platform: () => ({ runtime: { name: "docker" } }) }; }); -import { createService, updateService } from "../../../src/modules/services/service.service"; +import { + acceptServiceDrift, + createService, + updateService, +} from "../../../src/modules/services/service.service"; const ctx = { organizationId: "org_1" } as never; const project = { id: "proj_1", organizationId: "org_1", slug: "acme" }; @@ -299,6 +303,73 @@ describe("service routing patch", () => { expect(serviceRepo.create).toHaveBeenCalled(); }); + it("persists build args when a service is created manually (#689)", async () => { + await createService(ctx, project.id, { + name: "api", + build: ".", + dockerfile: "Dockerfile", + buildArgs: { APP_PACKAGE: "@myorg/api", FROM_ENV: null }, + } as never); + + expect(serviceRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + buildArgs: { APP_PACKAGE: "@myorg/api", FROM_ENV: null }, + advanced: { buildArgTemplateKeys: [] }, + }), + ); + }); + + it("makes a manual build-arg update literal without dropping other advanced config", async () => { + serviceRepo.findById.mockResolvedValue({ + ...multiRouteService(), + buildArgs: { HOME_REF: "${HOME}" }, + advanced: { + buildArgTemplateKeys: ["HOME_REF"], + readiness: { enabled: true }, + }, + }); + + await updateService(ctx, project.id, "svc_1", { + buildArgs: { HOME_REF: "$HOME" }, + } as never); + + expect(serviceRepo.update).toHaveBeenCalledWith( + "svc_1", + expect.objectContaining({ + buildArgs: { HOME_REF: "$HOME" }, + advanced: { + buildArgTemplateKeys: [], + readiness: { enabled: true }, + }, + }), + ); + }); + + it("applies build args when an upstream drift is accepted (#689)", async () => { + const drifted = { + ...multiRouteService(), + build: ".", + dockerfile: "Dockerfile", + buildArgs: { APP_PACKAGE: "@myorg/old" }, + importedSpec: { buildArgs: { APP_PACKAGE: "@myorg/old" } }, + driftSpec: { buildArgs: { APP_PACKAGE: "@myorg/api" } }, + }; + serviceRepo.findById + .mockResolvedValueOnce(drifted) + .mockResolvedValueOnce({ + ...drifted, + buildArgs: { APP_PACKAGE: "@myorg/api" }, + driftSpec: null, + }); + + await acceptServiceDrift(ctx, project.id, "svc_1"); + + expect(serviceRepo.update).toHaveBeenCalledWith( + "svc_1", + expect.objectContaining({ buildArgs: { APP_PACKAGE: "@myorg/api" } }), + ); + }); + // #424: a container answers to BOTH its name and its custom alias on the // project network, so every write path (create name, rename, alias, project // internalAlias) must reject a value already taken by any of those. The old diff --git a/apps/api/test/modules/services/service-schema-env.test.ts b/apps/api/test/modules/services/service-schema-env.test.ts index b41dcc125..c2fafe49b 100644 --- a/apps/api/test/modules/services/service-schema-env.test.ts +++ b/apps/api/test/modules/services/service-schema-env.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { ENV_MASK } from "@repo/core"; import { CreateServiceBody, + SetServiceEnvVarsBody, SyncServicesBody, UpdateServiceBody, } from "../../../src/modules/services/service.schema"; @@ -65,3 +66,12 @@ describe("create and sync stay non-nullable", () => { expect(checkSync({ NODE_ENV: "production" })).toBe(true); }); }); + +describe("SetServiceEnvVarsBody masked-row identity", () => { + it("accepts the source row id used to rename an unrevealed secret", () => { + expect(Value.Check(SetServiceEnvVarsBody, { + environment: "production", + vars: [{ sourceId: "env_1", key: "RENAMED_TOKEN", value: ENV_MASK, isSecret: true }], + })).toBe(true); + }); +}); diff --git a/apps/api/test/modules/services/service-update-env.test.ts b/apps/api/test/modules/services/service-update-env.test.ts index 8fddda88f..9f9c72cf4 100644 --- a/apps/api/test/modules/services/service-update-env.test.ts +++ b/apps/api/test/modules/services/service-update-env.test.ts @@ -1,7 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ENV_MASK } from "@repo/core"; -const projectRepo = vi.hoisted(() => ({ findById: vi.fn() })); +const projectRepo = vi.hoisted(() => ({ + findById: vi.fn(), listEnvVars: vi.fn(), bulkSetEnvVars: vi.fn(), +})); const serviceRepo = vi.hoisted(() => ({ findById: vi.fn(), update: vi.fn(), @@ -16,7 +18,8 @@ vi.mock("@repo/db", async (importOriginal) => { }; }); -import { updateService } from "../../../src/modules/services/service.service"; +import { decrypt, encrypt } from "../../../src/lib/encryption"; +import { revealServiceEnvVars, setServiceEnvVars, updateService } from "../../../src/modules/services/service.service"; const ctx = { organizationId: "org_1" } as never; const project = { id: "proj_1", organizationId: "org_1", internalAlias: null }; @@ -47,11 +50,104 @@ const written = () => serviceRepo.update.mock.calls.at(-1)?.[1] as Record { projectRepo.findById.mockReset().mockResolvedValue(project); + projectRepo.listEnvVars.mockReset().mockResolvedValue([]); + projectRepo.bulkSetEnvVars.mockReset().mockResolvedValue(undefined); serviceRepo.findById.mockReset().mockResolvedValue(row()); serviceRepo.update.mockReset().mockResolvedValue(undefined); serviceRepo.listByProject.mockReset().mockResolvedValue([]); }); +describe("service-scoped env_var editor", () => { + it("round-trips an unchanged masked secret without encrypting the mask", async () => { + const ciphertext = encrypt("real-secret"); + projectRepo.listEnvVars.mockResolvedValue([ + { id: "env_1", key: "API_TOKEN", value: ciphertext, isSecret: true }, + ]); + await setServiceEnvVars(ctx, project.id, "svc_inventar", { + environment: "production", + vars: [{ key: "API_TOKEN", value: ENV_MASK, isSecret: true }], + }); + expect(projectRepo.bulkSetEnvVars).toHaveBeenCalledWith( + project.id, "production", + [{ key: "API_TOKEN", value: ciphertext, isSecret: true }], + "svc_inventar", + ); + }); + + it("renames an unrevealed secret by stable row identity without losing its value", async () => { + const ciphertext = encrypt("real-secret"); + projectRepo.listEnvVars.mockResolvedValue([ + { id: "env_1", key: "OLD_API_TOKEN", value: ciphertext, isSecret: true }, + ]); + + await setServiceEnvVars(ctx, project.id, "svc_inventar", { + environment: "production", + vars: [{ sourceId: "env_1", key: "NEW_API_TOKEN", value: ENV_MASK, isSecret: true }], + }); + + expect(projectRepo.bulkSetEnvVars).toHaveBeenCalledWith( + project.id, "production", + [{ key: "NEW_API_TOKEN", value: ciphertext, isSecret: true }], + "svc_inventar", + ); + }); + + it("rejects an unknown or reused source identity before replacing the scope", async () => { + const ciphertext = encrypt("real-secret"); + projectRepo.listEnvVars.mockResolvedValue([ + { id: "env_1", key: "API_TOKEN", value: ciphertext, isSecret: true }, + ]); + + await expect(setServiceEnvVars(ctx, project.id, "svc_inventar", { + environment: "production", + vars: [{ sourceId: "missing", key: "RENAMED", value: ENV_MASK, isSecret: true }], + })).rejects.toThrow("invalid-env-source:missing"); + expect(projectRepo.bulkSetEnvVars).not.toHaveBeenCalled(); + + await expect(setServiceEnvVars(ctx, project.id, "svc_inventar", { + environment: "production", + vars: [ + { sourceId: "env_1", key: "RENAMED_ONE", value: ENV_MASK, isSecret: true }, + { sourceId: "env_1", key: "RENAMED_TWO", value: ENV_MASK, isSecret: true }, + ], + })).rejects.toThrow("duplicate-env-source:env_1"); + expect(projectRepo.bulkSetEnvVars).not.toHaveBeenCalled(); + }); + + it("stores a new manual variable in env_var and protects secret-looking keys", async () => { + await setServiceEnvVars(ctx, project.id, "svc_inventar", { + environment: "production", vars: [{ key: "MANUAL_API_KEY", value: "keep-me" }], + }); + const vars = projectRepo.bulkSetEnvVars.mock.calls.at(-1)?.[2]; + expect(vars[0]).toMatchObject({ key: "MANUAL_API_KEY", isSecret: true }); + expect(decrypt(vars[0].value)).toBe("keep-me"); + }); + + it("rejects a mask with no stored source", async () => { + await expect(setServiceEnvVars(ctx, project.id, "svc_inventar", { + environment: "production", vars: [{ key: "GHOST", value: ENV_MASK, isSecret: true }], + })).rejects.toThrow("masked-env-without-source:GHOST"); + expect(projectRepo.bulkSetEnvVars).not.toHaveBeenCalled(); + }); + + it("rejects duplicate keys before replacing the scope", async () => { + await expect(setServiceEnvVars(ctx, project.id, "svc_inventar", { + environment: "production", + vars: [{ key: "DUPLICATE", value: "one" }, { key: "DUPLICATE", value: "two" }], + })).rejects.toThrow("duplicate-env-key:DUPLICATE"); + expect(projectRepo.bulkSetEnvVars).not.toHaveBeenCalled(); + }); + + it("reveals service-scoped env_var values", async () => { + projectRepo.listEnvVars.mockResolvedValue([ + { key: "MANUAL_ONLY", value: encrypt("service-value"), isSecret: true }, + ]); + await expect(revealServiceEnvVars( + ctx, project.id, "svc_inventar", "production", + )).resolves.toEqual({ MANUAL_ONLY: "service-value" }); + }); +}); + describe("updateService — environment partial updates merge rather than replace", () => { it("preserves untouched environment variables when applying a single-field probe or partial update", async () => { await updateService(ctx, project.id, "svc_inventar", { diff --git a/apps/api/test/modules/system/instance-global-routes.test.ts b/apps/api/test/modules/system/instance-global-routes.test.ts index 21608b3d8..9edbef9fc 100644 --- a/apps/api/test/modules/system/instance-global-routes.test.ts +++ b/apps/api/test/modules/system/instance-global-routes.test.ts @@ -76,6 +76,9 @@ const INSTANCE_GLOBAL: Array<[string, string]> = [ ["post", "/migration/start-cloud"], ["post", "/migration/start-tunnel"], ["post", "/migration/switch-back"], + ["get", "/data-transfer/preview"], + ["post", "/data-transfer/direct/session"], + ["post", "/data-transfer/direct/send"], ["post", "/data-transfer/export"], ["post", "/data-transfer/import"], ]; diff --git a/apps/cli/src/commands/project.ts b/apps/cli/src/commands/project.ts index 0d75fce51..3b3f7d60c 100644 --- a/apps/cli/src/commands/project.ts +++ b/apps/cli/src/commands/project.ts @@ -13,6 +13,12 @@ import { apiRequest, paginate, ApiError } from "../lib/api-client"; import { sseRequest } from "../lib/sse"; import { fetchCaps, requireSelfHost } from "../lib/caps"; import { isJsonMode, printJson, printTable, ok, err, info } from "../lib/output"; +import { + renderReleaseImage, + validateReleaseRepository, + validateReleaseVersionUrl, + type ReleaseSource, +} from "@repo/core"; // ─── Shared helpers ────────────────────────────────────────────────────────── @@ -40,7 +46,10 @@ function printProject(project: Record): void { ["name", project.name], ["slug", project.slug], ["framework", project.framework], - ["gitRepo", project.gitOwner && project.gitRepo ? `${project.gitOwner}/${project.gitRepo}` : null], + [ + "gitRepo", + project.gitOwner && project.gitRepo ? `${project.gitOwner}/${project.gitRepo}` : null, + ], ["gitBranch", project.gitBranch], ["autoDeploy", project.autoDeploy], ["status", project.status], @@ -53,6 +62,61 @@ function printProject(project: Record): void { const ENVIRONMENTS = ["production", "preview", "development"]; +interface ReleaseImageOptions { + imageTemplate: string; + githubRepo?: string; + versionUrl?: string; + pin?: string; +} + +/** Build the complete source-transition payload before touching the API. */ +export function releaseImageSourceFromOptions(opts: ReleaseImageOptions): ReleaseSource { + const imageTemplate = opts.imageTemplate.trim(); + const repo = opts.githubRepo?.trim() || undefined; + const versionUrl = opts.versionUrl?.trim() || undefined; + const pinnedVersion = opts.pin?.trim() || undefined; + const validationTag = pinnedVersion ?? "v1.2.3"; + renderReleaseImage(imageTemplate, { + version: validationTag.replace(/^v/i, ""), + tag: validationTag, + }); + + // Repository and external URL are competing discovery modes. A pin is valid + // for either mode: it intentionally overrides discovery without discarding + // where future/latest releases come from when the pin is later removed. + if (repo && versionUrl) { + throw new Error("--github-repo cannot be combined with --version-url."); + } + if (repo) { + const invalidRepo = validateReleaseRepository(repo); + if (invalidRepo) + throw new Error(invalidRepo.replace("GitHub release repository", "--github-repo")); + return { + artifactKind: "image", + mode: "github", + imageTemplate, + repo, + ...(pinnedVersion ? { pinnedVersion } : {}), + }; + } + + if (!versionUrl && !pinnedVersion) { + throw new Error("Pass --github-repo, --version-url, or --pin."); + } + if (versionUrl) { + const invalidUrl = validateReleaseVersionUrl(versionUrl); + if (invalidUrl) throw new Error(invalidUrl.replace("Release version URL", "--version-url")); + } + + return { + artifactKind: "image", + mode: "url", + imageTemplate, + ...(versionUrl ? { versionUrl } : {}), + ...(pinnedVersion ? { pinnedVersion } : {}), + }; +} + // ─── list ──────────────────────────────────────────────────────────────────── // GET /api/projects → { data, total, page, perPage } (project.routes.ts:46) const listCmd = new Command("list") @@ -88,6 +152,39 @@ const getCmd = new Command("get") }), ); +// ─── release-image ─────────────────────────────────────────────────────────── +// PUT /api/projects/:id/release-image-source — atomic source transition. +const releaseImageCmd = new Command("release-image") + .description("Track and deploy a versioned prebuilt container image") + .argument("", "Project ID") + .requiredOption( + "--image-template ", + "Image reference with {tag} or {version}, e.g. ghcr.io/acme/api:{tag}", + ) + .option("--github-repo ", "Resolve versions from GitHub Releases") + .option("--version-url ", "HTTPS endpoint returning a version or tag") + .option("--pin ", "Deploy a fixed version instead of the discovered latest release") + .action( + action(async (id: string, opts: ReleaseImageOptions) => { + const source = releaseImageSourceFromOptions(opts); + const result = await apiRequest<{ data: Record }>( + `/projects/${encodeURIComponent(id)}/release-image-source`, + { method: "PUT", body: JSON.stringify(source) }, + ); + if (isJsonMode()) { + printJson(result.data); + return; + } + const upstream = + source.mode === "github" + ? `GitHub Releases (${source.repo})` + : source.versionUrl + ? `version URL (${source.versionUrl})` + : `pinned version (${source.pinnedVersion})`; + ok(`\n Project ${id} now deploys ${source.imageTemplate} from ${upstream}\n`); + }), + ); + // ─── create ────────────────────────────────────────────────────────────────── // POST /api/projects → { data } 201 (project.routes.ts:47, body = CreateProjectBody) const createCmd = new Command("create") @@ -100,10 +197,7 @@ const createCmd = new Command("create") .option("--framework ", "Stack/framework id") .option("--local-path ", "Local source path") .option("--port ", "Container port", (v) => Number(v)) - .option( - "--type ", - "Project type: app | docker | services | monorepo", - ) + .option("--type ", "Project type: app | docker | services | monorepo") .action( action(async (opts) => { const body: Record = { name: opts.name }; @@ -507,7 +601,9 @@ const logsCmd = new Command("logs") return; } - for await (const ev of sseRequest(`/projects/${encodeURIComponent(id)}/logs/stream${tailQs}`)) { + for await (const ev of sseRequest( + `/projects/${encodeURIComponent(id)}/logs/stream${tailQs}`, + )) { if (ev.event === "error") { const parsed = safeParse(ev.data); err(` ${(parsed?.error as string) ?? ev.data}`); @@ -596,6 +692,7 @@ export const projectCommand = new Command("project") projectCommand.addCommand(listCmd); projectCommand.addCommand(getCmd); +projectCommand.addCommand(releaseImageCmd); projectCommand.addCommand(createCmd); projectCommand.addCommand(deleteCmd); projectCommand.addCommand(envCmd); diff --git a/apps/cli/src/commands/service.ts b/apps/cli/src/commands/service.ts index 3cafc772f..a79969b0e 100644 --- a/apps/cli/src/commands/service.ts +++ b/apps/cli/src/commands/service.ts @@ -15,6 +15,7 @@ import { createInterface } from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; import { commandToArgv, + composeBuildIssues, composeMountIssues, composeMountToSpec, composePortToSpec, @@ -199,10 +200,7 @@ const createCmd = stackCommand("create") .option("--depends-on ", "Service this depends on (repeatable)", collect, []) .option("--env ", "Compose environment default (repeatable)", collect, []) .option("--command ", "Override the container command") - .option( - "--restart ", - "Restart policy: no | always | on-failure | unless-stopped", - ) + .option("--restart ", "Restart policy: no | always | on-failure | unless-stopped") .option("--expose", "Expose the service publicly through managed routing") .option("--exposed-port ", "Container port to expose publicly") .option("--domain