From 238ceaaf79ebb66a2137ead877b94bdd2e60944b Mon Sep 17 00:00:00 2001 From: Hydra Date: Sun, 23 Aug 2026 14:49:18 +0300 Subject: [PATCH 1/2] update patch v0.6.8 --- CHANGELOG.md | 127 ++++++ apps/api/src/lib/compose-parser.ts | 299 +++++++++++-- .../src/lib/deployment-runtime-read.test.ts | 6 + apps/api/src/lib/deployment-runtime.ts | 36 +- apps/api/src/lib/environment-scope.ts | 17 + .../src/lib/local-row-no-host-channel.test.ts | 10 +- apps/api/src/lib/project-route-store.ts | 27 +- apps/api/src/lib/routing-domains.ts | 26 +- apps/api/src/lib/secret-env.ts | 37 +- .../src/modules/deployments/build-pipeline.ts | 95 ++-- .../src/modules/deployments/build.service.ts | 257 +++++++---- .../modules/deployments/clone-plan.test.ts | 9 + .../api/src/modules/deployments/clone-plan.ts | 70 +-- .../compose-configuration-error.ts | 14 + .../deployments/compose/build.service.test.ts | 86 +++- .../deployments/compose/build.service.ts | 67 ++- .../compose/carried-host-port.test.ts | 6 +- .../deployments/compose/deploy.service.ts | 74 +++- .../compose/project-services.test.ts | 46 ++ .../deployments/compose/project-services.ts | 11 +- .../deployments/compose/service-env-layers.ts | 74 ++-- .../modules/deployments/deployment.schema.ts | 52 ++- .../deployments/pinned-artifacts.test.ts | 20 +- .../modules/deployments/pinned-artifacts.ts | 29 +- .../deployments/pinned-host-ports.test.ts | 73 ++++ .../modules/deployments/pinned-host-ports.ts | 52 +++ apps/api/src/modules/deployments/preflight.ts | 60 ++- .../modules/deployments/prepare.service.ts | 10 +- .../src/modules/domains/domain.controller.ts | 3 +- apps/api/src/modules/domains/domain.schema.ts | 7 + .../api/src/modules/domains/domain.service.ts | 28 +- .../modules/domains/project-route.service.ts | 15 +- .../src/modules/github/github.local-auth.ts | 121 ++++-- .../src/modules/migration/docker-reconcile.ts | 2 + .../migration/handover-single-app.test.ts | 3 +- .../migrate.repo-only-build-args.test.ts | 124 ++++++ .../modules/migration/migrate.service.test.ts | 135 +++++- .../src/modules/migration/migrate.service.ts | 62 +-- .../modules/projects/project-crud.service.ts | 9 +- .../projects/project-runtime.service.ts | 62 ++- .../modules/projects/project-toggle.test.ts | 16 +- .../modules/projects/project.schema.test.ts | 19 + .../src/modules/projects/project.schema.ts | 13 + .../modules/services/service.controller.ts | 16 +- .../src/modules/services/service.routes.ts | 10 +- .../src/modules/services/service.schema.ts | 29 +- .../src/modules/services/service.service.ts | 82 +++- .../data-transfer/data-transfer.controller.ts | 154 ++++++- .../data-transfer/direct-transfer.service.ts | 329 ++++++++++++++ .../system/data-transfer/export.service.ts | 58 ++- .../system/data-transfer/import.service.ts | 56 ++- .../system/data-transfer/passphrase-crypto.ts | 14 + .../modules/system/data-transfer/selection.ts | 71 +++ .../src/modules/system/data-transfer/types.ts | 51 +++ apps/api/src/modules/system/system.routes.ts | 17 +- apps/api/test/lib/compose-parser.test.ts | 293 ++++++++++++- apps/api/test/lib/environment-scope.test.ts | 23 + apps/api/test/lib/mail-postfix-chroot.test.ts | 74 ++++ apps/api/test/lib/project-route-store.test.ts | 38 +- apps/api/test/lib/routing-domains.test.ts | 56 +++ apps/api/test/lib/secret-env.test.ts | 21 + apps/api/test/modules/data-transfer.test.ts | 153 ++++++- .../modules/deployments/build.service.test.ts | 409 ++++++++++++++++++ .../modules/deployments/clone-plan.test.ts | 124 ++++-- .../deployments/compose-build-context.test.ts | 37 +- .../compose-env-passthrough.test.ts | 84 +++- .../custom-domain-failure-retention.test.ts | 19 + .../reused-artifact-not-reclaimed.test.ts | 16 + .../deployments/rollback-frozen-env.test.ts | 53 ++- .../domains/domain-www-records.test.ts | 14 + .../modules/github/gh-identity-health.test.ts | 103 ++++- .../modules/migration/docker-inspect.test.ts | 23 +- .../projects/ensure-compose-services.test.ts | 44 +- .../modules/projects/retry-routing.test.ts | 52 +++ .../services/service-routing-patch.test.ts | 73 +++- .../services/service-schema-env.test.ts | 10 + .../services/service-update-env.test.ts | 100 ++++- .../system/instance-global-routes.test.ts | 3 + apps/cli/src/commands/service.ts | 80 +++- apps/cli/src/lib/compose.ts | 47 +- .../unit/compose-docker-config-mount.test.ts | 1 + .../unit/compose-edge-name-conflict.test.ts | 1 + .../test/unit/compose-env-preserve.test.ts | 27 ++ .../test/unit/compose-secret-rotation.test.ts | 1 + .../test/unit/compose-source-build.test.ts | 1 + .../test/unit/service-sync-mapping.test.ts | 76 +++- .../deploy/[slug]/components/Sidebar.tsx | 17 +- .../projects/[id]/[[...slug]]/page.tsx | 53 ++- .../services/ServiceDetailPanel.tsx | 68 +-- .../settings/_components/DataTransferTab.tsx | 318 +++++++++++++- .../data-transfer-direct.render.test.tsx | 28 ++ .../components/domains/DnsRecordsModal.tsx | 127 ++++-- .../domains/dns-records-modal.render.test.tsx | 45 ++ .../import-project/ComposeServices.tsx | 3 +- .../import-project/EnvironmentVariables.tsx | 6 +- .../environment-variables.render.test.tsx | 19 + .../migration/ServerMigrationWizard.tsx | 1 + .../src/context/ProjectSettingsContext.tsx | 50 ++- .../dashboard/src/context/deployment/types.ts | 2 + .../context/deployment/useDeploymentBuild.tsx | 2 + .../src/context/project-environments.test.ts | 97 +++++ .../src/context/project-environments.ts | 103 +++++ .../src/hooks/useProjectEndpoints.ts | 7 + .../src/i18n/locales/ar/settings.json | 14 + .../src/i18n/locales/de/settings.json | 14 + .../src/i18n/locales/en/settings.json | 38 +- .../src/i18n/locales/es/settings.json | 14 + .../src/i18n/locales/fr/settings.json | 14 + .../src/i18n/locales/ja/settings.json | 14 + .../src/i18n/locales/pt/settings.json | 14 + .../src/i18n/locales/tr/settings.json | 14 + .../src/i18n/locales/zh/settings.json | 14 + .../src/lib/api/data-transfer.test.ts | 80 ++++ apps/dashboard/src/lib/api/data-transfer.ts | 71 ++- apps/dashboard/src/lib/api/deploy.ts | 9 +- apps/dashboard/src/lib/api/domains.ts | 8 +- apps/dashboard/src/lib/api/endpoints.ts | 3 + apps/dashboard/src/lib/api/index.ts | 13 +- .../dashboard/src/lib/api/server-migration.ts | 2 + apps/dashboard/src/lib/api/services.ts | 17 +- apps/dashboard/src/lib/deployment-dns.test.ts | 100 +++++ apps/dashboard/src/lib/deployment-dns.ts | 58 +++ .../src/lib/service-env-payload.test.ts | 58 --- apps/dashboard/src/lib/service-env-payload.ts | 32 -- .../lib/service-env-storage-contract.test.ts | 21 + apps/email/Dockerfile | 2 +- apps/email/docker/entrypoint.sh | 33 +- apps/email/docker/postfix-chroot-etc.sh | 49 +++ .../src/runtime/bare-persistent-paths.test.ts | 28 ++ packages/adapters/src/runtime/bare.ts | 11 + packages/adapters/src/runtime/cloud.ts | 14 +- .../src/runtime/docker-build-args.test.ts | 48 ++ .../adapters/src/runtime/docker-build-args.ts | 26 ++ .../runtime/docker-build-subcontext.test.ts | 23 +- packages/adapters/src/runtime/docker.ts | 19 +- .../proxy/ensure-container-edge.test.ts | 90 ++++ .../src/system/proxy/ensure-container-edge.ts | 169 ++++++-- packages/adapters/src/types.ts | 3 + packages/core/src/audit-taxonomy.ts | 14 + packages/core/src/compose-spec.test.ts | 51 ++- packages/core/src/compose-spec.ts | 146 ++++++- packages/core/src/types.ts | 18 + .../db/drizzle/0109_service_build_args.sql | 9 + packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/dump-roundtrip.test.ts | 40 +- packages/db/src/dump.ts | 26 +- packages/db/src/index.ts | 2 + .../db/src/repos/compose-spec-command.test.ts | 123 ++++++ .../repos/domain-find-or-create.repo.test.ts | 51 +++ packages/db/src/repos/domain.repo.ts | 46 +- packages/db/src/repos/index.ts | 1 + .../project-pinned-host-ports.repo.test.ts | 120 +++++ packages/db/src/repos/project.repo.ts | 62 ++- .../repos/service-routing-normalize.test.ts | 165 +++++++ packages/db/src/repos/service.repo.ts | 163 ++++++- packages/db/src/schema/service.ts | 4 + 156 files changed, 7436 insertions(+), 947 deletions(-) create mode 100644 apps/api/src/lib/environment-scope.ts create mode 100644 apps/api/src/modules/deployments/compose-configuration-error.ts create mode 100644 apps/api/src/modules/deployments/compose/project-services.test.ts create mode 100644 apps/api/src/modules/deployments/pinned-host-ports.test.ts create mode 100644 apps/api/src/modules/deployments/pinned-host-ports.ts create mode 100644 apps/api/src/modules/migration/migrate.repo-only-build-args.test.ts create mode 100644 apps/api/src/modules/system/data-transfer/direct-transfer.service.ts create mode 100644 apps/api/src/modules/system/data-transfer/selection.ts create mode 100644 apps/api/test/lib/environment-scope.test.ts create mode 100644 apps/api/test/lib/mail-postfix-chroot.test.ts create mode 100644 apps/api/test/modules/deployments/custom-domain-failure-retention.test.ts create mode 100644 apps/dashboard/src/app/(dashboard)/settings/_components/data-transfer-direct.render.test.tsx create mode 100644 apps/dashboard/src/components/domains/dns-records-modal.render.test.tsx create mode 100644 apps/dashboard/src/context/project-environments.test.ts create mode 100644 apps/dashboard/src/context/project-environments.ts create mode 100644 apps/dashboard/src/lib/api/data-transfer.test.ts create mode 100644 apps/dashboard/src/lib/deployment-dns.test.ts create mode 100644 apps/dashboard/src/lib/deployment-dns.ts delete mode 100644 apps/dashboard/src/lib/service-env-payload.test.ts delete mode 100644 apps/dashboard/src/lib/service-env-payload.ts create mode 100644 apps/dashboard/src/lib/service-env-storage-contract.test.ts create mode 100644 apps/email/docker/postfix-chroot-etc.sh create mode 100644 packages/adapters/src/runtime/docker-build-args.test.ts create mode 100644 packages/adapters/src/runtime/docker-build-args.ts create mode 100644 packages/db/drizzle/0109_service_build_args.sql create mode 100644 packages/db/src/repos/domain-find-or-create.repo.test.ts create mode 100644 packages/db/src/repos/project-pinned-host-ports.repo.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index db764056d..2547e6b69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,133 @@ 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 + +- **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 still reserve their host ports** — allocation now combines + live socket occupancy with durable, server-scoped claims from active + single-app and Compose releases. A service may reuse its own carried port, but + cannot take a port pinned to an offline container on the same host (#682). +- **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 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..32ece1415 100644 --- a/apps/api/src/lib/deployment-runtime-read.test.ts +++ b/apps/api/src/lib/deployment-runtime-read.test.ts @@ -83,6 +83,12 @@ beforeEach(() => { }); 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("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"]); diff --git a/apps/api/src/lib/deployment-runtime.ts b/apps/api/src/lib/deployment-runtime.ts index 1fa2ab2b0..713b0ac24 100644 --- a/apps/api/src/lib/deployment-runtime.ts +++ b/apps/api/src/lib/deployment-runtime.ts @@ -265,6 +265,36 @@ async function resolveOrgServer( 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", + }; +} + /** * THE authority for "given the host platform + this deployment's snapshot, * where does it actually land?". Returns a concrete DeployTarget @@ -671,7 +701,7 @@ export async function resolveServerExecutor( isLocal: boolean; ssh: SshConfig | null; }> { - 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, @@ -683,7 +713,7 @@ export async function resolveServerExecutor( // 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. @@ -981,6 +1011,7 @@ export async function withDeploymentPlatform( runtime: RuntimeAdapter; routing: Platform["routing"]; ssl: Platform["ssl"]; + executor: Platform["executor"]; effectiveTarget: DeployTarget; serverId: string | null; }) => Promise, @@ -993,6 +1024,7 @@ 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, }); 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/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/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/modules/deployments/build-pipeline.ts b/apps/api/src/modules/deployments/build-pipeline.ts index 0f734be9d..0b5f2b185 100644 --- a/apps/api/src/modules/deployments/build-pipeline.ts +++ b/apps/api/src/modules/deployments/build-pipeline.ts @@ -88,6 +88,7 @@ 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 { auditPorts } from "./port-audit.service"; +import { listTargetPinnedHostPorts, pinnedHostPortsToAvoid } from "./pinned-host-ports"; import { verifyDeployedContainers } from "./stability-audit.service"; import { resolveReadinessGate, runReadinessGate, type ResolvedReadinessGate } from "./readiness-gate"; import { @@ -97,7 +98,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"; @@ -319,9 +325,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; @@ -362,6 +369,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. @@ -723,8 +755,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 +796,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 +824,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 +836,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 +872,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 @@ -954,8 +987,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(() => {}); @@ -1679,14 +1712,9 @@ async function executeServerDeploy(phase: DeployPhaseInputs): Promise { // 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); + const avoid = pinnedHostPortsToAvoid( + await listTargetPinnedHostPorts(project.organizationId, phase.serverId), + ); // 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). @@ -1830,10 +1858,10 @@ async function executeServerDeploy(phase: DeployPhaseInputs): Promise { ) : []; - // 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 +1871,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"); diff --git a/apps/api/src/modules/deployments/build.service.ts b/apps/api/src/modules/deployments/build.service.ts index 7dd2d84e6..af19ab6d0 100644 --- a/apps/api/src/modules/deployments/build.service.ts +++ b/apps/api/src/modules/deployments/build.service.ts @@ -52,6 +52,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"; @@ -257,6 +258,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; @@ -548,29 +552,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,10 +628,17 @@ async function reconcileComposeDrift( composePath: project.composePath ?? undefined, }); const services = info.services ?? []; - if (services.length === 0) return; + 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, - keepUnresolvedEnv(services, composeRows), + services, ); if (driftedNames.length > 0) { console.log( @@ -594,47 +646,40 @@ async function reconcileComposeDrift( ); } } 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; + } } /** @@ -1074,8 +1119,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 +1196,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,6 +1265,7 @@ 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 > @@ -1343,7 +1394,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; } @@ -1608,8 +1662,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( @@ -1632,6 +1690,12 @@ export async function redeployBuildSession( 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, organizationId: project.organizationId, @@ -1642,7 +1706,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, }); @@ -1803,7 +1867,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( @@ -1855,7 +1924,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 +1986,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 +2039,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 +2134,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..9bc45cce0 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 @@ -68,8 +68,10 @@ 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("pinnedHostPortsToAvoid"); + expect(block).toContain("allocatedHostPorts"); + expect(block).toContain("avoid,"); }); it("says so when it had to move a carried 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..5885ac897 100644 --- a/apps/api/src/modules/deployments/compose/deploy.service.ts +++ b/apps/api/src/modules/deployments/compose/deploy.service.ts @@ -91,6 +91,7 @@ import * as sessionManager from "../session-manager"; import { isStaticService, parseServicePort, serviceAliasExtras } from "../../../lib/deployable-service"; import { computeKeepSet } from "../image-gc"; import { auditPorts } from "../port-audit.service"; +import { listTargetPinnedHostPorts, pinnedHostPortsToAvoid } from "../pinned-host-ports"; import { recordUnstableServices, verifyDeployedContainers, @@ -726,12 +727,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, }); @@ -957,7 +959,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"); }); @@ -1201,14 +1205,17 @@ 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 pinnedHostPortClaims = + resolveRouteStrategy(project.routeStrategy) === "loopback-port" && + runtime.name !== "cloud" && + opts?.executor + ? await listTargetPinnedHostPorts(project.organizationId, opts.serverId ?? null) + : []; + 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. @@ -1505,10 +1512,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 @@ -2079,9 +2117,16 @@ export async function deployComposeServices( * 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 avoid = pinnedHostPortsToAvoid( + pinnedHostPortClaims, + carried + ? { projectId: project.id, serviceId: svc.id, port: carried } + : undefined, + ); + for (const port of allocatedHostPorts) avoid.add(port); const allocation = await allocateHostPort(opts.executor, { preferred: carried, - avoid: usedHostPorts, + avoid, }); const hostPort = allocation.port; if (carried && hostPort !== carried) { @@ -2095,13 +2140,14 @@ export async function deployComposeServices( 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 ` + + `${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", ); } - usedHostPorts.add(hostPort); + allocatedHostPorts.add(hostPort); pinnedHostPortByContainerPort.set(containerPort, hostPort); } serviceRuntimeConfig.ports = withLoopbackPublishAll( 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..b068e6c43 --- /dev/null +++ b/apps/api/src/modules/deployments/compose/project-services.test.ts @@ -0,0 +1,46 @@ +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); + }); +}); diff --git a/apps/api/src/modules/deployments/compose/project-services.ts b/apps/api/src/modules/deployments/compose/project-services.ts index a4e9bd628..4f4dbabee 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; @@ -63,6 +70,7 @@ export function projectServicesToDeployableServices( 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) ?? {}, @@ -150,4 +158,3 @@ export async function shouldUseProjectServicePipeline( // Fallback for compose projects that don't have synced service rows. return isMultiServiceProject(project); } - 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/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..789471db9 --- /dev/null +++ b/apps/api/src/modules/deployments/pinned-host-ports.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { pickHostPort } from "@repo/adapters"; +import { pinnedHostPortsToAvoid, type PinnedHostPort } from "./pinned-host-ports"; + +const claims: PinnedHostPort[] = [ + { projectId: "single", serviceId: null, port: 20001 }, + { projectId: "compose", serviceId: "api", port: 20002 }, + { projectId: "compose", serviceId: "worker", port: 20003 }, +]; + +describe("pinnedHostPortsToAvoid", () => { + 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", + 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", port: 20002 }, + ] satisfies PinnedHostPort[]; + + expect( + pinnedHostPortsToAvoid(duplicate, { + projectId: "compose", + serviceId: "api", + port: 20002, + }).has(20002), + ).toBe(true); + }); + + it("does not release an unowned preferred port", () => { + expect( + pinnedHostPortsToAvoid(claims, { + projectId: "compose", + serviceId: "missing", + 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", port: 20000 }, + ]; + expect( + pickHostPort(new Set(), { avoid: pinnedHostPortsToAvoid(firstRangePortClaimed) }), + ).toBe(20001); + }); + + it("lets a service keep its own carried port when nobody else claims it", () => { + const avoid = pinnedHostPortsToAvoid(claims, { + projectId: "compose", + serviceId: "api", + port: 20002, + }); + + expect(pickHostPort(new Set(), { preferred: 20002, avoid })).toBe(20002); + }); +}); 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..e0aa8ffc5 --- /dev/null +++ b/apps/api/src/modules/deployments/pinned-host-ports.ts @@ -0,0 +1,52 @@ +import { repos, type PinnedHostPort } from "@repo/db"; + +export type { PinnedHostPort }; + +export interface ReusablePinnedHostPort { + projectId: string; + serviceId: string | null; + port: number; +} + +/** + * 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 function listTargetPinnedHostPorts( + organizationId: string, + serverId: string | null, +): Promise { + return repos.project.listActivePinnedHostPorts(organizationId, serverId); +} + +/** + * 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 = claims.some( + (claim) => + claim.projectId === reusable.projectId && + claim.serviceId === reusable.serviceId && + claim.port === reusable.port, + ); + const anotherOwnerClaimsIt = claims.some( + (claim) => + claim.port === reusable.port && + (claim.projectId !== reusable.projectId || claim.serviceId !== reusable.serviceId), + ); + if (ownsClaim && !anotherOwnerClaimsIt) avoid.delete(reusable.port); + return avoid; +} diff --git a/apps/api/src/modules/deployments/preflight.ts b/apps/api/src/modules/deployments/preflight.ts index d21860b4e..522a3f23b 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 { @@ -1492,13 +1493,39 @@ 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") { + // 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), ); @@ -1511,17 +1538,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 +1586,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, 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/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..20ddb7d5c 100644 --- a/apps/api/src/modules/domains/project-route.service.ts +++ b/apps/api/src/modules/domains/project-route.service.ts @@ -220,13 +220,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,11 +238,10 @@ 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); @@ -252,7 +251,7 @@ export async function syncProjectRouteState( }); await persistProjectRouteState(project.id, nextState.publicEndpoints, projectDomains, { - preserveVerifiedCustom: input.preserveVerifiedCustom, + preserveCustomDomains: input.preserveCustomDomains, }); const refreshedDomains = await listProjectRouteRows(project.id); return deriveProjectRouteState(project, { projectDomains: refreshedDomains }); @@ -676,4 +675,4 @@ export async function reapplyProjectLiveRoutes( } finally { disposePlatform(resolved); } -} \ No newline at end of file +} 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..05dfe5a8f 100644 --- a/apps/api/src/modules/migration/handover-single-app.test.ts +++ b/apps/api/src/modules/migration/handover-single-app.test.ts @@ -69,7 +69,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..e6ccefd2b 100644 --- a/apps/api/src/modules/migration/migrate.service.ts +++ b/apps/api/src/modules/migration/migrate.service.ts @@ -15,7 +15,7 @@ */ import { repos, restoreSubgraph, PkCollisionError, type Service } from "@repo/db"; -import { slugify, safeErrorMessage, mergeAdvanced, type ComposeAdvanced } from "@repo/core"; +import { slugify, safeErrorMessage, mergeAdvanced } from "@repo/core"; import { buildNetworkAliases, type ContainerStatus } from "@repo/adapters"; import { serviceAliasExtras } from "../../lib/deployable-service"; import { COMPOSE_SENTINEL } from "../../lib/container-ref"; @@ -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"; @@ -56,24 +57,12 @@ const REPO_COMPOSE_FILES = ["docker-compose.yml", "docker-compose.yaml", "compos /** 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< + ComposeService, + "environmentTemplates" | "environmentMeta" +>; /** * Parse a LINKED repo's docker-compose into its services, so the migrate wizard @@ -118,24 +107,7 @@ 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 @@ -379,8 +351,18 @@ export function buildAdoptedServiceRows( 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 +398,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. @@ -601,6 +584,7 @@ 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)), diff --git a/apps/api/src/modules/projects/project-crud.service.ts b/apps/api/src/modules/projects/project-crud.service.ts index 7b32e1f68..9e4a33c86 100644 --- a/apps/api/src/modules/projects/project-crud.service.ts +++ b/apps/api/src/modules/projects/project-crud.service.ts @@ -735,7 +735,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( @@ -2365,4 +2371,3 @@ export async function getLatestDeploymentSession( : null, }; } - 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-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.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..2fc6466d0 100644 --- a/apps/api/src/modules/projects/project.schema.ts +++ b/apps/api/src/modules/projects/project.schema.ts @@ -170,6 +170,9 @@ 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 +181,16 @@ 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 })), 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..831415a05 100644 --- a/apps/api/src/modules/services/service.service.ts +++ b/apps/api/src/modules/services/service.service.ts @@ -3,7 +3,7 @@ */ 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 { aliasConflictsWithSiblings, getProjectType, isValidEnvKey, looksLikeSecretKey, mergeAdvanced, normalizeServiceLabel, normalizeAliasStrict, resolveCommandArgv, safeErrorMessage, withTimeout, type ComposeAdvanced, type ServiceContainerState, type StackId } from "@repo/core"; import { BuildLogger, DockerRuntime, @@ -209,8 +209,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, @@ -269,6 +273,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 ?? {}, @@ -618,6 +623,14 @@ 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 +644,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 ?? {}, @@ -727,6 +741,19 @@ export async function updateService( 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") { const name = patch.name.trim(); if (!name) { @@ -1165,17 +1192,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 +1250,7 @@ export async function syncComposeServices( image?: string; build?: string; dockerfile?: string; + buildArgs?: Record; ports?: string[]; dependsOn?: string[]; environment?: Record; @@ -2080,4 +2145,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/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/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/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/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..4be9d43b2 100644 --- a/apps/api/test/modules/deployments/build.service.test.ts +++ b/apps/api/test/modules/deployments/build.service.test.ts @@ -6,6 +6,7 @@ const { getForwardGitToServer, kickoffBuild, repos, + resolveProjectInfo, resolveProjectRouteState, resolveServicePipelineMode, resolveSmartRoute, @@ -21,10 +22,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 +37,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 +64,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,6 +99,7 @@ vi.mock("../../../src/modules/deployments/smart-route", () => ({ })); import { + redeployBuildSession, requestBuildAccess, resolveSnapshotTarget, triggerDeployment, @@ -96,6 +109,7 @@ 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 +157,8 @@ const composeServices = [ image: undefined, build: ".", dockerfile: "Dockerfile", + buildArgs: { APP_PACKAGE: "@myorg/web" }, + advanced: { buildArgTemplateKeys: [] }, ports: ["3000:3000"], dependsOn: [], environment: {}, @@ -262,9 +278,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 +298,7 @@ describe("triggerDeployment", () => { primarySlug: undefined, publicEndpoints: [], }); + resolveProjectInfo.mockResolvedValue({ services: composeServices }); resolveServicePipelineMode.mockResolvedValue({ useServicePipeline: true, servicePreflightServices: composeServices, @@ -313,6 +334,192 @@ 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 +600,169 @@ describe("triggerDeployment", () => { }), ); }); + + 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" } }), + ); + }); }); /** @@ -595,6 +965,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/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/reused-artifact-not-reclaimed.test.ts b/apps/api/test/modules/deployments/reused-artifact-not-reclaimed.test.ts index ebf7cd17a..b7258b079 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 @@ -111,4 +111,20 @@ describe("a reused artifact is not on the failure reclaim list", () => { expect(assignments).toHaveLength(1); expect(src).toMatch(/if \(!reusedArtifact\) provisioned\.imageRef = buildResult\.imageRef;/); }); + + 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/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/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-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/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