@wtfalch/foundry 0.47.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/index.d.ts +3 -1
  2. package/dist/index.js +1 -0
  3. package/dist/migrations/0059_org_builtin_blue_green.sql +9 -0
  4. package/dist/org.d.ts +17 -1
  5. package/dist/org.js +24 -3
  6. package/dist/plan/archon-blue-green.js +15 -1
  7. package/dist/plan/blue-green-apply.d.ts +35 -0
  8. package/dist/plan/blue-green-apply.js +167 -16
  9. package/dist/plan/blue-green.d.ts +97 -2
  10. package/dist/plan/blue-green.js +214 -13
  11. package/dist/plan/database-dump.d.ts +37 -0
  12. package/dist/plan/database-dump.js +101 -0
  13. package/dist/plan/diff.js +3 -0
  14. package/dist/plan/executor.d.ts +48 -0
  15. package/dist/plan/executor.js +79 -20
  16. package/dist/plan/index.d.ts +2 -0
  17. package/dist/plan/index.js +4 -0
  18. package/dist/plan/locked-hostnames.d.ts +1 -0
  19. package/dist/plan/locked-hostnames.js +1 -0
  20. package/dist/plan/plan-apply.js +16 -7
  21. package/dist/plan/platform-diff.js +2 -1
  22. package/dist/plan/platform-providers.js +2 -0
  23. package/dist/plan/proxy-labels.d.ts +13 -1
  24. package/dist/plan/proxy-labels.js +26 -3
  25. package/dist/plan/release-host.d.ts +1 -1
  26. package/dist/plan/release-host.js +36 -14
  27. package/dist/plan/stage-env.d.ts +38 -0
  28. package/dist/plan/stage-env.js +52 -0
  29. package/dist/plan/stage-guards.d.ts +104 -0
  30. package/dist/plan/stage-guards.js +149 -0
  31. package/dist/plan/stage-isolation.d.ts +57 -0
  32. package/dist/plan/stage-isolation.js +248 -0
  33. package/dist/refuse-org.d.ts +1 -1
  34. package/dist/refuse-org.js +5 -3
  35. package/dist/release/decide.d.ts +5 -0
  36. package/dist/release/decide.js +10 -6
  37. package/dist/release/health.d.ts +6 -1
  38. package/dist/release/health.js +10 -2
  39. package/dist/release/run.d.ts +2 -0
  40. package/dist/release/run.js +2 -1
  41. package/dist/store/postgres.js +20 -1
  42. package/dist/store/schema.d.ts +39 -1
  43. package/dist/store/schema.js +3 -0
  44. package/package.json +1 -1
@@ -19,6 +19,7 @@ import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor }
19
19
  import type { EdgePortName } from './platform-org.js';
20
20
  import type { RequestHeaderPort } from './platform-providers.js';
21
21
  import { type ReleaseContext, type ReleaseHost } from './release-host.js';
22
+ import { type StageProbePort } from './stage-guards.js';
22
23
  import type { WorkerAppDatabasePort } from './worker-app-database.js';
23
24
  import { type KeysCustodyPort, type WorkerAppPort } from './worker-app.js';
24
25
  export declare function coolifyProjectMark(org: Pick<DeclaredOrg, 'id'>): string;
@@ -1731,6 +1732,11 @@ export interface FoundryStepExecutorPorts {
1731
1732
  * `edge.access` (see {@link EdgePort.access}). Kept for a host or a test that supplies its own.
1732
1733
  */
1733
1734
  access?: AccessPort;
1735
+ /**
1736
+ * The calls that prove a stage credential does not work against production (`plan/stage-guards.ts`). Without
1737
+ * one the probes are reported as not run, and the stage copy stays blocked.
1738
+ */
1739
+ stageProbes?: StageProbePort;
1734
1740
  /**
1735
1741
  * F4.ratelimits: overrides the rate limiting rule calls, which otherwise go through
1736
1742
  * `edge.ratelimits` (see {@link EdgePort.ratelimits}). Kept for a host or a test that supplies its own.
@@ -1909,6 +1915,41 @@ export declare function healthUrls(org: DeclaredOrg): string[];
1909
1915
  export declare const COOLIFY_PROXY_ORIGIN = "http://coolify-proxy:80";
1910
1916
  /** The one Access policy foundry puts on a platform Access app (#84). */
1911
1917
  export declare const ACCESS_POLICY_NAME = "foundry: allowed emails";
1918
+ /**
1919
+ * #164: the repo `coolifyAgora` builds the org's Agora from
1920
+ * (https://github.com/wtfalch/agora) -- the issue's own "How" names this
1921
+ * exact Dockerfile, and it is the only build strategy any app this file
1922
+ * provisions ever uses ({@link CMS_APP_DOCKERFILE}/
1923
+ * `CALLS_SERVICE_DOCKERFILE` below are all `dockerfile_location` into a
1924
+ * `createGithubAppApplication` build-from-repo call, never a registry
1925
+ * image): "Coolify builds each org's Agora from the agora repo" is not a
1926
+ * fresh product choice here, it is this file's one established convention,
1927
+ * followed rather than reopened (#164 "Decide" 1). The agora repo's own
1928
+ * Dockerfile (`apps/agora/Dockerfile`) already `EXPOSE`s 8080 and serves
1929
+ * from a build built at the repository root, the same one-repo shape
1930
+ * `CMS_REPO`'s own two apps use. This comment used to claim that needed no
1931
+ * `ports_exposes` of its own, on the theory that Coolify's github-app build
1932
+ * reads a Dockerfile's `EXPOSE` the same way it does for `cmsSite`'s two
1933
+ * apps above -- wrong (#506): Coolify never reads `EXPOSE` for a build it
1934
+ * runs itself, so without an explicit `agora.ts`'s `AGORA_APP_PORTS_EXPOSES`,
1935
+ * Traefik is left routing to a port nothing listens on, and a company's
1936
+ * Agora answers 502 behind the tunnel. Seen live on Company Included,
1937
+ * 2026-10-05.
1938
+ */
1939
+ export declare const AGORA_REPO = "wtfalch/agora";
1940
+ export declare const AGORA_APP_DOCKERFILE = "/apps/agora/Dockerfile";
1941
+ /**
1942
+ * #161 (Boule part): the repo `coolifyBoule` builds the org's Boule from
1943
+ * (https://github.com/wtfalch/boule), by the same convention as
1944
+ * {@link AGORA_REPO}: Coolify's github-app build from the repo, never a
1945
+ * registry image. Boule's own Dockerfile is at the repository root, serves on
1946
+ * 3000 (`EXPOSE 3000`) and runs `scripts/migrate-and-start.sh`, which applies
1947
+ * its migrations as the owner and then serves as the `<database>_rt` role.
1948
+ */
1949
+ export declare const BOULE_REPO = "wtfalch/boule";
1950
+ export declare const BOULE_APP_DOCKERFILE = "/Dockerfile";
1951
+ /** Boule's own `EXPOSE 3000` (its doc comment just above) -- the port its health check (#543) probes. */
1952
+ export declare const BOULE_PORT = 3000;
1912
1953
  export declare const LOG_DIR = "/app/logs";
1913
1954
  /** The company's Coolify destination (its Docker network `co-<slug>`), recorded when its first resource is created. */
1914
1955
  export declare const COOLIFY_DESTINATION_SCOPE_KEY = "coolify_destination";
@@ -2342,6 +2383,13 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2342
2383
  * a step for it fails, which the next plan reports instead.
2343
2384
  */
2344
2385
  private coolifyBlueGreen;
2386
+ /**
2387
+ * Stage's guards (`plan/stage-guards.ts`): the probe port the host supplied, and the Access gate over the stage
2388
+ * host. The gate is offered only when everything it needs is in hand (a domain, the platform's Access provider,
2389
+ * the company's sign-in organisation, an owner, an Access port) and no `hosting.access` rule names the host: a
2390
+ * bypass or service-token rule there would open stage, so no gate is made and the guard stays unrecorded.
2391
+ */
2392
+ private stageGuards;
2345
2393
  private coolifyApp;
2346
2394
  /**
2347
2395
  * #75: mounts a persistent volume at {@link LOG_DIR} on the app, unless one
@@ -8,7 +8,7 @@ import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, file
8
8
  import { adoptedKeysUnreadable, parseAdoptSnapshot } from './adopt.js';
9
9
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, DESKTOP_AUTH_PORT, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatCallsApplied, chatHasUpstreams, chatIntrospectionAppName, desiredAgoraEnv, desktopAuthInternalName, entryClientIdScopeKey, } from './agora.js';
10
10
  import { applyBlueGreenConnectors, applyBlueGreenServer, applyBlueGreenWeights, } from './blue-green-apply.js';
11
- import { bgKey, colorAppName, entrySubject, healthTokenName, liveAppNameFor, splitServers, } from './blue-green.js';
11
+ import { actionsKeyName, bgKey, blueGreenSubjects, colorAppName, healthTokenName, liveAppNameFor, mintActionsKey, splitServers, } from './blue-green.js';
12
12
  import { BouleOwnersError, httpBouleOwners } from './boule-owners-http.js';
13
13
  import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_ARCHON_PUSHED_AT_SCOPE_KEY, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_CUSTOMERS_ORG_ENV, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_OWNER_SEED_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, CUSTOMERS_SIGN_IN_METADATA_KEY, CUSTOMERS_SIGN_IN_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleArchonCredentialRef, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, bouleOwnerRefusedScopeKey, bouleOwnerSeededScopeKey, bouleOwnerSeeds, customersDirectoryRef, customersOrgIdOf, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
14
14
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, mcpCallbackUri, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
@@ -53,13 +53,14 @@ import { PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY
53
53
  import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appFolderDiffers, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
54
54
  import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, PLATFORM_MAIL_SERVER_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionDatabaseBackup, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
55
55
  import { companyAppPostLogoutUris, postLogoutAppliedValue, zitadelAppPostLogoutAppliedScopeKey, } from './post-logout-uris.js';
56
- import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, ENTRY_CONTAINER_LABELS, appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryProxyLabels, entryRoutes, entryRoutesMarker, ownsOikosName, routesWithoutToken, sameLabelSets, } from './proxy-labels.js';
56
+ import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, ENTRY_CONTAINER_LABELS, appLabelsAppliedScopeKey, builtinRoutes, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryProxyLabels, entryRoutes, entryRoutesMarker, ownsOikosName, routesWithoutToken, sameLabelSets, } from './proxy-labels.js';
57
57
  import { createReleaseHost } from './release-host.js';
58
58
  import { TUNNEL_HTTP_INGRESS_SCOPE_KEY, applyRemoteDatabase, mergeTcpIngress, recordedTcpRoutes, } from './remote-database.js';
59
59
  import { editSignInBindings } from './sign-in-bindings.js';
60
60
  import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
61
61
  import { SIGN_IN_BASE_PATH, SIGN_IN_ENV_LIVE_SCOPE_PREFIX, SIGN_IN_IN_APP_SCOPE_KEY, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_RATE_LIMIT_DESCRIPTION, SIGN_IN_RATE_LIMIT_EXPRESSION, SIGN_IN_RATE_LIMIT_SCOPE_KEY, SIGN_IN_TRUSTED_SCOPE_KEY, SIGN_IN_UPSTREAM_ENV, inAppBindingFor, inAppSignIn, inAppStateHosts, inAppStatePairs, inAppTargets, signInEnvLiveHosts, } from './sign-in.js';
62
62
  import { moveSigninDatabase } from './signin-db-move.js';
63
+ import { stageGateKey, stageHostname, } from './stage-guards.js';
63
64
  import { WORKER_APP_CALL_TIMEOUT_MS, applyWorkerApp, heldBrokerKey, teardownWorkerApp, withCallTimeout, } from './worker-app.js';
64
65
  import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
65
66
  /**
@@ -342,8 +343,8 @@ const CMS_APP_PORT = 3000;
342
343
  * Agora answers 502 behind the tunnel. Seen live on Company Included,
343
344
  * 2026-10-05.
344
345
  */
345
- const AGORA_REPO = 'wtfalch/agora';
346
- const AGORA_APP_DOCKERFILE = '/apps/agora/Dockerfile';
346
+ export const AGORA_REPO = 'wtfalch/agora';
347
+ export const AGORA_APP_DOCKERFILE = '/apps/agora/Dockerfile';
347
348
  /**
348
349
  * #161 (Boule part): the repo `coolifyBoule` builds the org's Boule from
349
350
  * (https://github.com/wtfalch/boule), by the same convention as
@@ -352,10 +353,10 @@ const AGORA_APP_DOCKERFILE = '/apps/agora/Dockerfile';
352
353
  * 3000 (`EXPOSE 3000`) and runs `scripts/migrate-and-start.sh`, which applies
353
354
  * its migrations as the owner and then serves as the `<database>_rt` role.
354
355
  */
355
- const BOULE_REPO = 'wtfalch/boule';
356
- const BOULE_APP_DOCKERFILE = '/Dockerfile';
356
+ export const BOULE_REPO = 'wtfalch/boule';
357
+ export const BOULE_APP_DOCKERFILE = '/Dockerfile';
357
358
  /** Boule's own `EXPOSE 3000` (its doc comment just above) -- the port its health check (#543) probes. */
358
- const BOULE_PORT = 3000;
359
+ export const BOULE_PORT = 3000;
359
360
  /**
360
361
  * Every app foundry deploys keeps its own logs across a redeploy, so a
361
362
  * database outage does not also erase the only other copy of the evidence
@@ -2886,7 +2887,7 @@ export class FoundryStepExecutor {
2886
2887
  */
2887
2888
  releaseHost(org, subject, context, renew) {
2888
2889
  const recorded = context.recordedIds();
2889
- const primaryBlueAppId = recorded[`coolify:app:${appStepKey(org, subject.id)}`];
2890
+ const primaryBlueAppId = recorded[subject.blueAppKey];
2890
2891
  const primaryUuid = recorded[COOLIFY_SERVER_SCOPE_KEY];
2891
2892
  if (!primaryBlueAppId || !primaryUuid)
2892
2893
  return null;
@@ -2951,23 +2952,23 @@ export class FoundryStepExecutor {
2951
2952
  });
2952
2953
  return undefined;
2953
2954
  }
2954
- const entry = org.hosting.entries.find((e) => e.key === action.desired.entry);
2955
- if (!entry || !entrySubject(org, entry)) {
2956
- throw new Error(`${action.kind} for ${org.slug}: no blue/green entry '${String(action.desired.entry)}'`);
2955
+ const found = blueGreenSubjects(org).find((s) => s.id === action.desired.entry);
2956
+ if (!found) {
2957
+ throw new Error(`${action.kind} for ${org.slug}: no blue/green app '${String(action.desired.entry)}'`);
2957
2958
  }
2958
- const primaryBlueAppId = recorded()[`coolify:app:${appStepKey(org, entry.key)}`];
2959
+ const primaryBlueAppId = recorded()[found.blueAppKey];
2959
2960
  if (!primaryBlueAppId) {
2960
- throw new Error(`${action.kind} for ${org.slug}: entry '${entry.key}' has no blue app recorded yet`);
2961
+ throw new Error(`${action.kind} for ${org.slug}: app '${found.id}' has no blue app recorded yet`);
2961
2962
  }
2962
2963
  // The blue app keeps the name it has: an app that ran before blue/green is adopted under its own name.
2963
- if (!recorded()[bgKey.blueName(entry.key)]) {
2964
+ if (!recorded()[bgKey.blueName(found.id)]) {
2964
2965
  const blue = await this.ports.coolify.getApplication(primaryBlueAppId);
2965
2966
  if (blue.name) {
2966
- context.recordScopeId(bgKey.blueName(entry.key), blue.name);
2967
+ context.recordScopeId(bgKey.blueName(found.id), blue.name);
2967
2968
  await context.persistScopeIds();
2968
2969
  }
2969
2970
  }
2970
- const subject = entrySubject(org, entry, recorded());
2971
+ const subject = blueGreenSubjects(org, recorded()).find((s) => s.id === found.id);
2971
2972
  const runtime = this.ports.runtime ?? {};
2972
2973
  const deps = {
2973
2974
  coolify: this.ports.coolify,
@@ -2980,17 +2981,39 @@ export class FoundryStepExecutor {
2980
2981
  if (typeof this.ports.heldKeys?.put !== 'function') {
2981
2982
  throw new Error(`${action.kind} for ${org.slug}: the executor's heldKeys cannot put the health token`);
2982
2983
  }
2983
- const token = await this.ensureSharedSecret(org, healthTokenName(entry.key), recorded());
2984
+ const token = await this.ensureSharedSecret(org, healthTokenName(subject.id), recorded());
2984
2985
  this.handledSecrets.add(token);
2985
2986
  return token;
2986
2987
  },
2988
+ actionsKey: async () => {
2989
+ const heldKeys = this.ports.heldKeys;
2990
+ if (typeof heldKeys?.put !== 'function') {
2991
+ throw new Error(`${action.kind} for ${org.slug}: the executor's heldKeys cannot put the server actions key`);
2992
+ }
2993
+ const put = heldKeys.put;
2994
+ const key = await mintActionsKey({
2995
+ id: subject.id,
2996
+ ref: sharedSecretRef(org.id, actionsKeyName(subject.id)),
2997
+ recorded,
2998
+ record: (k, v) => context.recordScopeId(k, v),
2999
+ persist: () => context.persistScopeIds(),
3000
+ heldKeys: {
3001
+ resolve: async (r) => (await heldKeys.resolve(r)) ?? null,
3002
+ put: (r, v) => put.call(heldKeys, r, v),
3003
+ },
3004
+ generate: () => randomBytes(32).toString('base64'),
3005
+ secret: (value) => void this.handledSecrets.add(value),
3006
+ });
3007
+ return key;
3008
+ },
2987
3009
  secret: (value) => void this.handledSecrets.add(value),
2988
3010
  runtime,
2989
3011
  sleep: runtime.sleep ?? defaultSleep,
2990
- label: `${action.system}:${action.kind}:${entry.key}`,
3012
+ label: `${action.system}:${action.kind}:${subject.id}`,
2991
3013
  primaryBlueAppId,
2992
3014
  logDir: LOG_DIR,
2993
3015
  isRunning: isRunningOrStarting,
3016
+ stageGuards: this.stageGuards(org, subject, recorded(), action.desired.accessProviderId),
2994
3017
  };
2995
3018
  if (action.kind === 'bluegreen-lb') {
2996
3019
  await applyBlueGreenWeights(deps, subject, { uuid: serverUuid, primaryUuid });
@@ -3000,6 +3023,38 @@ export class FoundryStepExecutor {
3000
3023
  }
3001
3024
  return undefined;
3002
3025
  }
3026
+ /**
3027
+ * Stage's guards (`plan/stage-guards.ts`): the probe port the host supplied, and the Access gate over the stage
3028
+ * host. The gate is offered only when everything it needs is in hand (a domain, the platform's Access provider,
3029
+ * the company's sign-in organisation, an owner, an Access port) and no `hosting.access` rule names the host: a
3030
+ * bypass or service-token rule there would open stage, so no gate is made and the guard stays unrecorded.
3031
+ */
3032
+ stageGuards(org, subject, recorded, providerId) {
3033
+ const guards = this.ports.stageProbes ? { probes: this.ports.stageProbes } : {};
3034
+ const claimValue = recorded[ZITADEL_ORG_SCOPE_KEY];
3035
+ const emails = org.owners.map((owner) => owner.email);
3036
+ if (!org.domain || typeof providerId !== 'string' || !providerId || !claimValue)
3037
+ return guards;
3038
+ if (emails.length === 0 || !(this.ports.access || this.ports.edge))
3039
+ return guards;
3040
+ const hostname = stageHostname(org.domain, subject.id);
3041
+ if ((org.hosting.access ?? []).some((rule) => rule.hostname === hostname))
3042
+ return guards;
3043
+ guards.gate = {
3044
+ hostname,
3045
+ create: () => this.accessPort().createGate({
3046
+ organisationId: org.id,
3047
+ key: stageGateKey(subject.id),
3048
+ entry: subject.id,
3049
+ hostnames: [hostname],
3050
+ providerId,
3051
+ claimName: GATE_CLAIM_NAME,
3052
+ claimValue,
3053
+ emails,
3054
+ }),
3055
+ };
3056
+ return guards;
3057
+ }
3003
3058
  async coolifyApp(action, org, context) {
3004
3059
  const recordedAppId = context.recordedIds()[`coolify:app:${action.key}`];
3005
3060
  if (action.desired.enabled === false) {
@@ -3128,7 +3183,7 @@ export class FoundryStepExecutor {
3128
3183
  autogenerate_domain: false,
3129
3184
  // A static site is served by Coolify's own web server on port 80.
3130
3185
  ports_exposes: isStatic ? '80' : entry.port === null ? undefined : String(entry.port),
3131
- is_auto_deploy_enabled: entry.autoDeploy,
3186
+ is_auto_deploy_enabled: appSettingsOf(entry).autoDeploy,
3132
3187
  // Also the folder a static entry serves: Coolify's static build
3133
3188
  // copies `base_directory`, and reads `publish_directory` only for a
3134
3189
  // built app.
@@ -7452,7 +7507,10 @@ export class FoundryStepExecutor {
7452
7507
  const loginHost = action.desired.loginHost ?? null;
7453
7508
  // A hosting entry's names (and its wildcard) go straight to its container on the company network:
7454
7509
  // the container carries `traefik.enable=false`, so the proxy has no route for it.
7455
- const entryOrigins = new Map(entryRoutes(org, context.recordedIds()).map((r) => [r.hostname, r.service]));
7510
+ const entryOrigins = new Map([
7511
+ ...entryRoutes(org, context.recordedIds()),
7512
+ ...builtinRoutes(org, context.recordedIds()),
7513
+ ].map((r) => [r.hostname, r.service]));
7456
7514
  const ingress = tunnelIngressHostnames(org, loginHost).map((hostname) => ({
7457
7515
  hostname,
7458
7516
  service: hostname === loginHost
@@ -9175,6 +9233,7 @@ export class FoundryStepExecutor {
9175
9233
  await this.accessPort().deleteAccessApp(target.id);
9176
9234
  return;
9177
9235
  case 'cloudflare:gate':
9236
+ case 'cloudflare:bg-gate':
9178
9237
  await this.accessPort().deleteGate(target.id);
9179
9238
  return;
9180
9239
  case 'ai:key': {
@@ -39,6 +39,8 @@ export { createZitadelAdminMinter } from './zitadel-machine-user.js';
39
39
  export { createZitadelLoginPolicy } from './zitadel-login-policy.js';
40
40
  export { createZitadelOrgMetadata } from './zitadel-org-metadata.js';
41
41
  export { createZitadelProjectRoles } from './zitadel-project-roles.js';
42
+ export { DumpError, dumpNow } from './database-dump.js';
43
+ export type { DatabaseDump } from './database-dump.js';
42
44
  export type { CallsSfuConnection, CallsSfuConnectionPort, CallsSfuKeysPort, CallsSfuSshConnector, CallsSfuPlatformDeps, } from './platform-sfu.js';
43
45
  export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
44
46
  export type { MailCopyJob, MailCopyPort, MailCopyStepResult } from './mail-copy.js';
@@ -22,6 +22,10 @@ export { createZitadelAdminMinter } from './zitadel-machine-user.js';
22
22
  export { createZitadelLoginPolicy } from './zitadel-login-policy.js';
23
23
  export { createZitadelOrgMetadata } from './zitadel-org-metadata.js';
24
24
  export { createZitadelProjectRoles } from './zitadel-project-roles.js';
25
+ // #170: the platform's shared calls SFU step -- narrow ports a host (archon)
26
+ // implements over held keys and its own SSH client, the same shape the rest
27
+ // of `platform-providers.ts` uses for Cloudflare.
28
+ export { DumpError, dumpNow } from './database-dump.js';
25
29
  export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
26
30
  export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
27
31
  export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeLoginHostResolver, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
@@ -20,4 +20,5 @@ export declare function hostingNames(org: DeclaredOrg, platformHostnames: readon
20
20
  locked: Set<string>;
21
21
  slug: string;
22
22
  siteOwnsApex: boolean;
23
+ builtinBlueGreen: boolean;
23
24
  };
@@ -58,5 +58,6 @@ export function hostingNames(org, platformHostnames) {
58
58
  locked: lockedHostnames(org, platformHostnames),
59
59
  slug: org.slug,
60
60
  siteOwnsApex: org.site.enabled && org.site.usesDomain && org.domain !== null,
61
+ builtinBlueGreen: org.boule?.blueGreen !== undefined || org.agora?.blueGreen !== undefined,
61
62
  };
62
63
  }
@@ -9,7 +9,7 @@ import { entryHasDatabase } from '../worker-app-config.js';
9
9
  import { AdoptRefusedError, adoptCandidates, adoptedKeysUnreadable, adoptedScopeKey, parseAdoptSnapshot, planAdoptFrom, } from './adopt.js';
10
10
  import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
11
11
  import { NetworkMoveBackupMissingError, OrgPostgresMigrationBackupMissingError, SigninDbMoveBackupMissingError, UpgradeBackupMissingError, platformDatabasesForService, requireFreshBackupForOrgPostgresMigration, requireFreshBackupForSigninMove, requireFreshBackupsForMove, requireFreshBackupsForUpgrades, } from './backup-gate.js';
12
- import { ARCHON_REPO, PRIMARY_SERVER_SCOPE_KEY, archonSubject, entrySubject, } from './blue-green.js';
12
+ import { ARCHON_REPO, PRIMARY_SERVER_SCOPE_KEY, archonSubject, blueGreenSubjects, declaresBlueGreen, lbOrigin, } from './blue-green.js';
13
13
  import { readLiveBuilds } from './build-record.js';
14
14
  import { LEGACY_BACKUP_NAMES } from './company-backups.js';
15
15
  import { unconfirmedUpgrades } from './confirm-upgrade.js';
@@ -1151,10 +1151,13 @@ export class FoundryPlanApply {
1151
1151
  const primaryUuid = context.recordedIds()[PRIMARY_SERVER_SCOPE_KEY];
1152
1152
  if (!store || !this.executor.releaseHost || !primaryUuid)
1153
1153
  return;
1154
- for (const entry of org.hosting.entries) {
1155
- const subject = entrySubject(org, entry, context.recordedIds());
1156
- if (!subject)
1154
+ for (const subject of blueGreenSubjects(org, context.recordedIds())) {
1155
+ // A built-in app's names reach its balancer only once the tunnel was last written with it: until then a
1156
+ // pinned check would answer from blue whatever color it names.
1157
+ if (subject.builtin &&
1158
+ !(context.recordedIds()[CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY] ?? '').includes(`"service":"${lbOrigin(subject)}"`)) {
1157
1159
  continue;
1160
+ }
1158
1161
  const host = this.executor.releaseHost(org, subject, context, () => this.renewLease(fence));
1159
1162
  if (!host)
1160
1163
  continue;
@@ -1224,7 +1227,7 @@ export class FoundryPlanApply {
1224
1227
  async runReleaseTick() {
1225
1228
  const results = [];
1226
1229
  for (const org of await this.store.list()) {
1227
- if (!org.hosting.entries.some((entry) => entry.blueGreen))
1230
+ if (!declaresBlueGreen(org))
1228
1231
  continue;
1229
1232
  try {
1230
1233
  await this.runOrgReleaseTick(org.id);
@@ -1307,7 +1310,7 @@ export class FoundryPlanApply {
1307
1310
  const sfuPortsOpen = await this.readSfuPortsOpen(org);
1308
1311
  const resumable = await this.ownResumableRefs(org);
1309
1312
  // Read only for an org that declares it; a failed read plans none and says so (`planBlueGreen`).
1310
- const blueGreenServers = org.hosting.entries.some((e) => e.blueGreen) && this.executor.blueGreenServers
1313
+ const blueGreenServers = declaresBlueGreen(org) && this.executor.blueGreenServers
1311
1314
  ? await this.executor.blueGreenServers().catch(() => undefined)
1312
1315
  : undefined;
1313
1316
  const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
@@ -2137,11 +2140,12 @@ function destroyStepDescriptions(targets) {
2137
2140
  /** Scope keys of the Coolify apps blue/green made: `bg.<entry>.app.<server>.<part>`, a database sidecar, a connector on another server. */
2138
2141
  const BLUE_GREEN_APP_KEY = /^(bg\.[^.:]+\.app\.[^.:]+\.[a-z]+|bgdb\.[^.:]+\.sidecar\.[^.:]+|cloudflare_tunnel_connector_app\.[^.:]+)$/;
2139
2142
  const BLUE_GREEN_DATABASE_KEY = /^bg\.[^.:]+\.stagedb$/;
2140
- /** The database route's Cloudflare parts: its Access app, its service token (also one minted and never delivered) and its DNS record (recorded as the route). */
2143
+ /** Stage's Access gate (`bg.<entry>.stagegate`), and the database route's Cloudflare parts: its Access app, its service token (also one minted and never delivered) and its DNS record (recorded as the route). */
2141
2144
  const BLUE_GREEN_EDGE_KEYS = [
2142
2145
  [/^bgdb\.[^.:]+\.access$/, 'bg-access'],
2143
2146
  [/^bgdb\.[^.:]+\.(token|undelivered)$/, 'bg-token'],
2144
2147
  [/^bgdb\.[^.:]+\.route$/, 'bg-dns'],
2148
+ [/^bg\.[^.:]+\.stagegate$/, 'bg-gate'],
2145
2149
  ];
2146
2150
  /** Every recorded blue/green app (then stage's database), in key order. A destroy target's `key` is the scope key itself. */
2147
2151
  function blueGreenDestroyTargets(serviceIds) {
@@ -2221,6 +2225,11 @@ const STEP_FIELDS = {
2221
2225
  'cloudflare:worker-app': 'hosting.entries.workerApp',
2222
2226
  };
2223
2227
  function stepField(action) {
2228
+ if (action.system === 'coolify' &&
2229
+ action.kind.startsWith('bluegreen') &&
2230
+ (action.desired.entry === 'boule' || action.desired.entry === 'agora')) {
2231
+ return `${action.desired.entry}.blueGreen`;
2232
+ }
2224
2233
  // An entry's ai key (F6a) is keyed `<orgId>:<entry>`; the org's own is `<orgId>`.
2225
2234
  if (action.system === 'ai' && action.kind === 'key' && action.key.includes(':')) {
2226
2235
  return 'hosting.entries.uses.ai';
@@ -730,7 +730,8 @@ export function appSettingsOf(entry) {
730
730
  return {
731
731
  folder: entry.folder,
732
732
  port: entry.port,
733
- autoDeploy: entry.autoDeploy,
733
+ // A blue/green entry deploys only through the release runner.
734
+ autoDeploy: entry.blueGreen ? false : entry.autoDeploy,
734
735
  ...(entry.buildPack === 'static' ? { staticFolderIn: 'base_directory' } : {}),
735
736
  };
736
737
  }
@@ -3890,6 +3890,8 @@ export const PLATFORM_ARCHON_PUBLIC_PATHS = [
3890
3890
  { name: 'vipps-agreement', path: '/vipps/agreement' },
3891
3891
  { name: 'usage-tick', path: '/api/usage/tick' },
3892
3892
  { name: 'reporting-collect', path: '/api/reporting/collect' },
3893
+ { name: 'release-health', path: '/api/reporting/release-health' },
3894
+ { name: 'health', path: '/api/health' },
3893
3895
  { name: 'org-changes', path: '/api/orgs/*/changes' },
3894
3896
  { name: 'org-settings', path: '/api/orgs/*/settings' },
3895
3897
  { name: 'org-apps', path: '/api/orgs/*/apps' },
@@ -51,7 +51,19 @@ export declare function entryRoutes(org: DeclaredOrg, recorded?: Readonly<Record
51
51
  hostname: string;
52
52
  service: string;
53
53
  }>;
54
- /** The scope value recording the routes the tunnel step last wrote; the planner compares it to {@link entryRoutes}. */
54
+ /**
55
+ * The tunnel routes of the company's Boule and Agora that run behind a balancer: their names go to it once the
56
+ * structure on the primary is applied. Until then (and for an app that does not declare blue/green) a name keeps
57
+ * the Coolify proxy origin it had, so none appears here.
58
+ */
59
+ export declare function builtinRoutes(org: DeclaredOrg, recorded?: Readonly<Record<string, string>>): Array<{
60
+ hostname: string;
61
+ service: string;
62
+ }>;
63
+ /**
64
+ * The scope value recording the routes the tunnel step last wrote; the planner compares it to {@link entryRoutes}.
65
+ * A built-in app's balancer route is added only when there is one, so a company without it keeps its marker.
66
+ */
55
67
  export declare function entryRoutesMarker(org: DeclaredOrg, recorded?: Readonly<Record<string, string>>): string;
56
68
  /**
57
69
  * A short digest of the spec; the same spec always gives the same hash. The token is left out: the
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { PRIMARY_SERVER_SCOPE_KEY, balancerApplied, lbAppName, liveAppNameFor, } from './blue-green.js';
2
+ import { BUILTIN_APPS, PRIMARY_SERVER_SCOPE_KEY, balancerApplied, builtinSubject, lbAppName, lbOrigin, liveAppNameFor, } from './blue-green.js';
3
3
  import { entryHostnames, wildcardHostname } from './hostnames.js';
4
4
  import { NO_DOMAIN_ZONE } from './own-hostnames.js';
5
5
  import { appNameFor } from './platform-diff.js';
@@ -85,9 +85,32 @@ export function entryRoutes(org, recorded = {}) {
85
85
  routes.push(wildcard);
86
86
  return routes;
87
87
  }
88
- /** The scope value recording the routes the tunnel step last wrote; the planner compares it to {@link entryRoutes}. */
88
+ /**
89
+ * The tunnel routes of the company's Boule and Agora that run behind a balancer: their names go to it once the
90
+ * structure on the primary is applied. Until then (and for an app that does not declare blue/green) a name keeps
91
+ * the Coolify proxy origin it had, so none appears here.
92
+ */
93
+ export function builtinRoutes(org, recorded = {}) {
94
+ const routes = [];
95
+ for (const app of BUILTIN_APPS) {
96
+ const subject = builtinSubject(org, app, recorded);
97
+ if (!subject || !balancerApplied(subject.id, recorded, recorded[PRIMARY_SERVER_SCOPE_KEY])) {
98
+ continue;
99
+ }
100
+ for (const hostname of subject.hostnames) {
101
+ routes.push({ hostname, service: lbOrigin(subject) });
102
+ }
103
+ }
104
+ return routes;
105
+ }
106
+ /**
107
+ * The scope value recording the routes the tunnel step last wrote; the planner compares it to {@link entryRoutes}.
108
+ * A built-in app's balancer route is added only when there is one, so a company without it keeps its marker.
109
+ */
89
110
  export function entryRoutesMarker(org, recorded = {}) {
90
- return JSON.stringify(entryRoutes(org, recorded));
111
+ const builtin = builtinRoutes(org, recorded);
112
+ const routes = entryRoutes(org, recorded);
113
+ return JSON.stringify(builtin.length > 0 ? [...routes, ...builtin] : routes);
91
114
  }
92
115
  /**
93
116
  * A short digest of the spec; the same spec always gives the same hash. The token is left out: the
@@ -21,7 +21,7 @@ export interface ReleaseHost {
21
21
  export interface ReleaseHostInput {
22
22
  subject: BlueGreenSubject;
23
23
  context: ReleaseContext;
24
- coolify: Pick<CoolifyPort, 'getApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'deployAndWait' | 'raw' | 'listStorages' | 'createFileStorage' | 'updateFileStorage'> & {
24
+ coolify: Pick<CoolifyPort, 'getApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deployAndWait' | 'raw' | 'listStorages' | 'createFileStorage' | 'updateFileStorage'> & {
25
25
  listApplicationDeployments?: CoolifyPort['listApplicationDeployments'];
26
26
  };
27
27
  /** Reads the entry's health token (`sharedSecretRef(org.id, healthTokenName(id))`). Never mints. */
@@ -1,6 +1,6 @@
1
1
  import { checkColorAddress, readReleaseHealth } from '../release/health.js';
2
2
  import { runRelease } from '../release/run.js';
3
- import { applyBlueGreenWeights } from './blue-green-apply.js';
3
+ import { applyBlueGreenWeights, copiedEnv } from './blue-green-apply.js';
4
4
  import { DEFAULT_WEIGHTS, LB_COLOR_HEADER, LB_PIN_HEADER, bgKey, formatWeights, parseWeights, reportingEnv, } from './blue-green.js';
5
5
  import { waitForNewDeployment } from './deploy-health.js';
6
6
  import { isNotFoundError } from './executor.js';
@@ -19,21 +19,23 @@ export function createReleaseHost(input) {
19
19
  const id = subject.id;
20
20
  const recorded = () => context.recordedIds();
21
21
  /** Every Coolify app of `color`, one per server that has the structure applied. */
22
- const colorApps = (color) => {
22
+ const colorAppsOnServers = (color) => {
23
23
  const ids = recorded();
24
24
  const apps = [];
25
25
  if (color === 'blue')
26
- apps.push(input.primaryBlueAppId);
26
+ apps.push({ appId: input.primaryBlueAppId, server: input.primaryUuid });
27
27
  const prefix = `bg.${id}.app.`;
28
28
  for (const key of Object.keys(ids).sort()) {
29
29
  if (!key.startsWith(prefix) || !key.endsWith(`.${color}`))
30
30
  continue;
31
31
  const value = ids[key];
32
+ const server = key.slice(prefix.length, key.length - color.length - 1);
32
33
  if (value)
33
- apps.push(value);
34
+ apps.push({ appId: value, server });
34
35
  }
35
36
  return apps;
36
37
  };
38
+ const colorApps = (color) => colorAppsOnServers(color).map((a) => a.appId);
37
39
  const primaryColorApp = (color) => color === 'blue' ? input.primaryBlueAppId : recorded()[bgKey.app(id, input.primaryUuid, color)];
38
40
  /**
39
41
  * Where the runner reaches a color: the entry's first public hostname, pinned to the color by the balancer's
@@ -66,21 +68,39 @@ export function createReleaseHost(input) {
66
68
  const deployTo = async (color, digest) => {
67
69
  if (!COMMIT.test(digest))
68
70
  throw new Error(`${input.label}: the release is not a built commit`);
69
- const token = await input.healthToken();
70
- if (!token)
71
- throw new Error(`${input.label}: the health token is not held`);
72
- input.secret(token);
73
- const apps = colorApps(color);
71
+ const token = subject.reporting ? await input.healthToken() : null;
72
+ if (subject.reporting) {
73
+ if (!token)
74
+ throw new Error(`${input.label}: the health token is not held`);
75
+ input.secret(token);
76
+ }
77
+ const apps = colorAppsOnServers(color);
74
78
  if (apps.length === 0)
75
79
  throw new Error(`${input.label}: ${color} has no app recorded`);
76
- for (const appId of apps) {
80
+ for (const { appId, server } of apps) {
77
81
  await input.renew();
78
82
  await coolify.updateApplication(appId, { git_commit_sha: digest });
83
+ // The color takes blue's env as it is now, so a change made after the structure apply (sign-in, mail,
84
+ // credentials) reaches it before it serves. Blue on the primary is the source itself.
85
+ let source = [];
86
+ if (appId !== input.primaryBlueAppId) {
87
+ try {
88
+ source = await coolify.listEnvs(input.primaryBlueAppId);
89
+ }
90
+ catch {
91
+ throw new Error(`${input.label}: reading the env of the blue app failed`);
92
+ }
93
+ }
94
+ const refreshed = copiedEnv({ label: input.label, secret: input.secret }, subject, source, {
95
+ uuid: server,
96
+ primaryUuid: input.primaryUuid,
97
+ });
98
+ const reporting = token ? reportingEnv({ color, release: digest, healthToken: token }) : [];
79
99
  try {
80
- await coolify.bulkUpsertEnvs(appId, reportingEnv({ color, release: digest, healthToken: token }).map((e) => ({
81
- ...e,
82
- literal: true,
83
- })));
100
+ const envs = [...refreshed, ...reporting];
101
+ if (envs.length > 0) {
102
+ await coolify.bulkUpsertEnvs(appId, envs.map((e) => ({ ...e, literal: true })));
103
+ }
84
104
  }
85
105
  catch {
86
106
  // A Coolify error can echo the request body, which holds the token.
@@ -128,6 +148,7 @@ export function createReleaseHost(input) {
128
148
  address: await checkColorAddress({
129
149
  ...addressOf(color),
130
150
  healthPath: subject.healthPath,
151
+ expectJson: subject.reporting && subject.healthPath === '/api/health',
131
152
  ...(input.fetch ? { fetchImpl: input.fetch } : {}),
132
153
  }),
133
154
  };
@@ -221,6 +242,7 @@ export async function runSubjectRelease(input) {
221
242
  entry: subject.config,
222
243
  now: input.now,
223
244
  deps: input.host.deps,
245
+ ...(subject.reporting ? {} : { hardOnly: true }),
224
246
  });
225
247
  for (const [key, value] of Object.entries(result.writes)) {
226
248
  if (value === null)
@@ -0,0 +1,38 @@
1
+ import { type StageKind } from './stage-isolation.js';
2
+ export interface StageEnvInput {
3
+ kind: StageKind;
4
+ /** Where the stage app is reached: its own container address, since stage has no hostname. */
5
+ appUrl: string;
6
+ logDir: string;
7
+ /** The stage database's URLs, already minted for stage. */
8
+ database: ReadonlyArray<{
9
+ key: string;
10
+ value: string;
11
+ }>;
12
+ /** The env of the live app. Read for the names it uses, and for the values on `SHARED_WITH_PROD`; nothing else is copied. */
13
+ prod: ReadonlyArray<{
14
+ key: string;
15
+ value?: string;
16
+ is_preview?: boolean;
17
+ }>;
18
+ /** The env stage holds now: a minted secret is kept, so a second apply does not rotate it. */
19
+ existing: ReadonlyArray<{
20
+ key: string;
21
+ value?: string;
22
+ is_preview?: boolean;
23
+ }>;
24
+ mint: () => string;
25
+ }
26
+ export interface StageEnv {
27
+ env: Array<{
28
+ key: string;
29
+ value: string;
30
+ }>;
31
+ /** Names the live app uses that stage does not get yet. A service is either in `env` or here. */
32
+ absent: string[];
33
+ }
34
+ /**
35
+ * The env of a stage app, built from the kind's allowlist and never from a copy of the live app's env. Every
36
+ * name is allowed and none is denied, or this throws a fixed text with the name.
37
+ */
38
+ export declare function buildStageEnv(input: StageEnvInput): StageEnv;