@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
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, OrgRefusal, OrgRefusalCode, WorkerAppConfig, EntryGate, EntryVolume, HostingEntryUses, EntryAuth, EntryFiles, EntryAi, EnvValue, EnvHeldSecret, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, OrgServiceDatabase, OrgServiceSchema, ServicesConfig, SignInConfig, VersionPin, VersionPinPart, } from './org.js';
1
+ export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, BlueGreenConfig, OrgBuiltinApp, OrgRefusal, OrgRefusalCode, WorkerAppConfig, EntryGate, EntryVolume, HostingEntryUses, EntryAuth, EntryFiles, EntryAi, EnvValue, EnvHeldSecret, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, OrgServiceDatabase, OrgServiceSchema, ServicesConfig, SignInConfig, VersionPin, VersionPinPart, } from './org.js';
2
2
  export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, orgServiceDatabase, orgServiceDatabases, SFU_SLOT_MAX, VERSION_PIN_PARTS, isMainEntry, mainEntry, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateCustomers, validateSignIn, validateVersionPin, validateSite, } from './org.js';
3
3
  export { refuseOrg } from './refuse-org.js';
4
4
  export { workerAppHasBuild } from './worker-app-config.js';
@@ -29,6 +29,8 @@ export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAudit
29
29
  export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, PlatformCloudflareReader, PlatformCoolifyReader, PlatformLiveReaders, StepAction, PlannedStep, StepSystem, PlanDetail, OrgPostgresPlan, PlatformPlanDetail, MailRoute, AdoptCandidate, AdoptInput, AdoptPlan, AdoptSnapshot, AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, FoundryApplyOptions, UnconfirmedUpgrade, } from './plan/index.js';
30
30
  export { COMPANY_DESTINATION_NAME_PREFIX, COMPANY_NETWORKS, DEFAULT_MAX_DESTINATIONS, DestinationCapacityError, PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, placed, placedOnPlatform, validateNetworkName, } from './plan/index.js';
31
31
  export { AdoptRefusedError } from './plan/index.js';
32
+ export { DumpError, dumpNow } from './plan/index.js';
33
+ export type { DatabaseDump } from './plan/index.js';
32
34
  export { InvalidMigrationConfirmationError, ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY, OrgPostgresMigrationBackupMissingError, OrgPostgresMigrationNotConfirmedError, } from './plan/index.js';
33
35
  export type { OrgPostgresMigrationConfirmation } from './plan/index.js';
34
36
  export { NetworkMoveBackupMissingError, UpgradeBackupMissingError } from './plan/index.js';
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ export { createPostgresReleaseStore } from './store/postgres.js';
18
18
  export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryReleaseRequests, foundryRequestedChanges, } from './store/schema.js';
19
19
  export { COMPANY_DESTINATION_NAME_PREFIX, COMPANY_NETWORKS, DEFAULT_MAX_DESTINATIONS, DestinationCapacityError, PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, placed, placedOnPlatform, validateNetworkName, } from './plan/index.js';
20
20
  export { AdoptRefusedError } from './plan/index.js';
21
+ export { DumpError, dumpNow } from './plan/index.js';
21
22
  export { InvalidMigrationConfirmationError, ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY, OrgPostgresMigrationBackupMissingError, OrgPostgresMigrationNotConfirmedError, } from './plan/index.js';
22
23
  export { NetworkMoveBackupMissingError, UpgradeBackupMissingError } from './plan/index.js';
23
24
  export { createEdgeAccess, createEdgeDns, edgeDnsRecordOwners, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
@@ -0,0 +1,9 @@
1
+ -- @wtfalch/foundry: `boule_blue_green` and `agora_blue_green` are `org.boule.blueGreen` and
2
+ -- `org.agora.blueGreen` as JSON ({ steps?, stepHoldMinutes? }); null (the default) keeps the company's
3
+ -- Boule or Agora on one app. Copies 0056_platform_archon_blue_green.sql: nullable columns added with
4
+ -- `if not exists`, so it is safe to run at container start on live rows.
5
+ -- Mirrors src/store/schema.ts's `declaredOrgs`.
6
+
7
+ alter table foundry_declared_orgs
8
+ add column if not exists boule_blue_green jsonb,
9
+ add column if not exists agora_blue_green jsonb;
package/dist/org.d.ts CHANGED
@@ -283,7 +283,7 @@ export type AccessRule = {
283
283
  /** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
284
284
  export declare const APP_ENTRY_KEY = "app";
285
285
  /** Why a validator refused. `code` is stable and never reworded; `message` is for people. */
286
- export type OrgRefusalCode = 'hosting_tunnel_needs_hosting' | 'issuer_app_name_missing' | 'issuer_app_name_not_unique' | 'issuer_app_kind_unknown' | 'issuer_app_name_reserved_agora' | 'issuer_app_name_reserved_boule' | 'hosting_needs_entry' | 'hosting_main_count' | 'entry_key_not_unique' | 'ai_webhook_path_not_unique' | 'worker_route_not_unique' | 'database_not_unique' | 'access_hostname_not_unique' | 'access_hostname_invalid' | 'access_needs_hosting' | 'access_kind_unknown' | 'access_service_token_name_missing' | 'access_hostname_locked' | 'access_hostname_not_served' | 'gate_limit_invalid' | 'gate_needs_domain' | 'gate_key_invalid' | 'gate_key_not_unique' | 'gate_needs_hostname' | 'gate_hostname_not_entry_hostname' | 'gate_hostname_held_twice' | 'gate_hostname_locked' | 'gate_limit_exceeded' | 'wildcard_needs_domain' | 'wildcard_held_twice' | 'hostnames_need_domain' | 'hostname_needed' | 'hostname_invalid' | 'hostname_not_unique' | 'hostname_locked' | 'entry_key_invalid' | 'entry_repo_invalid' | 'entry_folder_invalid' | 'entry_branch_empty' | 'entry_build_pack_unknown' | 'entry_build_pack_unavailable' | 'entry_port_invalid' | 'entry_health_path_invalid' | 'entry_capabilities_invalid' | 'entry_database_needs_hosting' | 'blue_green_needs_hostname' | 'env_name_empty' | 'env_name_reserved' | 'env_source_platform_mail_key' | 'env_source_unknown' | 'env_shared_secret_name_invalid' | 'env_held_secret_name_mismatch' | 'env_held_secret_name_invalid' | 'env_looks_like_secret' | 'blue_green_not_object' | 'blue_green_steps_length' | 'blue_green_steps_range' | 'blue_green_steps_order' | 'blue_green_steps_end' | 'blue_green_hold_invalid' | 'volumes_not_list' | 'volumes_too_many' | 'volume_name_invalid' | 'volume_name_logs' | 'volume_name_not_unique' | 'volume_mount_path_invalid' | 'volume_mount_path_too_long' | 'volume_mount_path_log_dir' | 'volume_mount_path_forbidden' | 'volume_mount_path_not_unique' | 'auth_needs_hosting' | 'auth_users_invalid' | 'auth_registration_invalid' | 'registration_open_needs_customers' | 'auth_replaces_broker_entry_invalid' | 'auth_replaces_broker_entry_owned' | 'auth_needs_exact_hostname' | 'mcp_needs_open_customers' | 'mcp_path_invalid' | 'mcp_onboarding_path_invalid' | 'auth_sign_in_invalid' | 'email_code_needs_customers' | 'client_name_taken' | 'ai_needs_hosting' | 'ai_offerings_invalid' | 'ai_permissions_invalid' | 'ai_webhook_path_invalid' | 'ai_webhook_needs_exact_hostname' | 'ai_env_clash' | 'files_needs_hosting' | 'files_collections_invalid' | 'files_permissions_invalid' | 'files_share_host_invalid' | 'files_share_host_needs_domain' | 'files_env_clash' | 'worker_needs_hosting' | 'worker_needs_repo' | 'worker_key_too_long' | 'worker_artifact_invalid' | 'worker_route_invalid' | 'worker_route_off_domain' | 'worker_rate_limit_name_missing' | 'worker_rate_limit_namespace_missing' | 'worker_rate_limit_invalid' | 'worker_rate_limit_period_invalid' | 'worker_app_invalid' | 'edge_not_true' | 'edge_needs_hosting' | 'edge_needs_domain' | 'edge_env_clash' | 'customers_invalid' | 'customers_sign_up_needs_enabled' | 'registration_open_needs_sign_up';
286
+ export type OrgRefusalCode = 'hosting_tunnel_needs_hosting' | 'issuer_app_name_missing' | 'issuer_app_name_not_unique' | 'issuer_app_kind_unknown' | 'issuer_app_name_reserved_agora' | 'issuer_app_name_reserved_boule' | 'hosting_needs_entry' | 'hosting_main_count' | 'entry_key_not_unique' | 'ai_webhook_path_not_unique' | 'worker_route_not_unique' | 'database_not_unique' | 'access_hostname_not_unique' | 'access_hostname_invalid' | 'access_needs_hosting' | 'access_kind_unknown' | 'access_service_token_name_missing' | 'access_hostname_locked' | 'access_hostname_not_served' | 'gate_limit_invalid' | 'gate_needs_domain' | 'gate_key_invalid' | 'gate_key_not_unique' | 'gate_needs_hostname' | 'gate_hostname_not_entry_hostname' | 'gate_hostname_held_twice' | 'gate_hostname_locked' | 'gate_limit_exceeded' | 'wildcard_needs_domain' | 'wildcard_held_twice' | 'hostnames_need_domain' | 'hostname_needed' | 'hostname_invalid' | 'hostname_not_unique' | 'hostname_locked' | 'entry_key_invalid' | 'entry_key_reserved' | 'entry_repo_invalid' | 'entry_folder_invalid' | 'entry_branch_empty' | 'entry_build_pack_unknown' | 'entry_build_pack_unavailable' | 'entry_port_invalid' | 'entry_health_path_invalid' | 'entry_capabilities_invalid' | 'entry_database_needs_hosting' | 'blue_green_needs_hostname' | 'env_name_empty' | 'env_name_reserved' | 'env_source_platform_mail_key' | 'env_source_unknown' | 'env_shared_secret_name_invalid' | 'env_held_secret_name_mismatch' | 'env_held_secret_name_invalid' | 'env_looks_like_secret' | 'builtin_app_invalid' | 'blue_green_not_object' | 'blue_green_steps_length' | 'blue_green_steps_range' | 'blue_green_steps_order' | 'blue_green_steps_end' | 'blue_green_hold_invalid' | 'volumes_not_list' | 'volumes_too_many' | 'volume_name_invalid' | 'volume_name_logs' | 'volume_name_not_unique' | 'volume_mount_path_invalid' | 'volume_mount_path_too_long' | 'volume_mount_path_log_dir' | 'volume_mount_path_forbidden' | 'volume_mount_path_not_unique' | 'auth_needs_hosting' | 'auth_users_invalid' | 'auth_registration_invalid' | 'registration_open_needs_customers' | 'auth_replaces_broker_entry_invalid' | 'auth_replaces_broker_entry_owned' | 'auth_needs_exact_hostname' | 'mcp_needs_open_customers' | 'mcp_path_invalid' | 'mcp_onboarding_path_invalid' | 'auth_sign_in_invalid' | 'email_code_needs_customers' | 'client_name_taken' | 'ai_needs_hosting' | 'ai_offerings_invalid' | 'ai_permissions_invalid' | 'ai_webhook_path_invalid' | 'ai_webhook_needs_exact_hostname' | 'ai_env_clash' | 'files_needs_hosting' | 'files_collections_invalid' | 'files_permissions_invalid' | 'files_share_host_invalid' | 'files_share_host_needs_domain' | 'files_env_clash' | 'worker_needs_hosting' | 'worker_needs_repo' | 'worker_key_too_long' | 'worker_artifact_invalid' | 'worker_route_invalid' | 'worker_route_off_domain' | 'worker_rate_limit_name_missing' | 'worker_rate_limit_namespace_missing' | 'worker_rate_limit_invalid' | 'worker_rate_limit_period_invalid' | 'worker_app_invalid' | 'edge_not_true' | 'edge_needs_hosting' | 'edge_needs_domain' | 'edge_env_clash' | 'customers_invalid' | 'customers_sign_up_needs_enabled' | 'registration_open_needs_sign_up';
287
287
  /**
288
288
  * One refusal: a stable `code`, the dotted `path` into {@link DeclaredOrg} it is about
289
289
  * (`hosting.entries.terminal.uses.auth.registration`) and the sentence for people.
@@ -385,6 +385,14 @@ export interface HostingEntry {
385
385
  */
386
386
  blueGreen?: BlueGreenConfig;
387
387
  }
388
+ /**
389
+ * `boule` and `agora` on a company: the built-in apps foundry runs for every company (`<slug>-boule`,
390
+ * `<slug>-agora`). Only blue/green is declared here; absent means off. Optional so every existing literal and
391
+ * fixture compiles unchanged.
392
+ */
393
+ export interface OrgBuiltinApp {
394
+ blueGreen?: BlueGreenConfig;
395
+ }
388
396
  /** The traffic steps (percent to the new color) and the hold at each step. Both default in `plan/blue-green.ts`. */
389
397
  export interface BlueGreenConfig {
390
398
  /** Ascending, each 1..100, ending at 100. */
@@ -615,11 +623,15 @@ export interface HostingNames {
615
623
  locked: ReadonlySet<string>;
616
624
  slug?: string;
617
625
  siteOwnsApex?: boolean;
626
+ /** Boule or Agora declares `blueGreen`: the blue/green key names are reserved for the entries too. */
627
+ builtinBlueGreen?: boolean;
618
628
  }
619
629
  /** {@link validateHosting} with the code and path of the first refusal. */
620
630
  export declare function refuseHosting(hosting: HostingConfig, names?: HostingNames): OrgRefusal | null;
621
631
  /** `blueGreen`: steps ascending, each a whole number 1..100, the last 100; a hold of 1..1440 minutes. */
622
632
  export declare function validateBlueGreen(value: unknown, at: string): string | null;
633
+ /** {@link refuseBlueGreen} for the `blueGreen` of the company's Boule and Agora; the same codes. */
634
+ export declare function refuseBuiltinBlueGreen(org: Pick<DeclaredOrg, 'boule' | 'agora'>): OrgRefusal | null;
623
635
  export declare function validateDomain(domain: string): string | null;
624
636
  /**
625
637
  * A broker entry name foundry writes itself: Archon's, and any company's Boule.
@@ -1035,6 +1047,10 @@ export interface DeclaredOrg {
1035
1047
  services?: ServicesConfig;
1036
1048
  /** Absent means `{ inApp: false }`. Optional so every existing literal and fixture compiles unchanged. */
1037
1049
  signIn?: SignInConfig;
1050
+ /** The company's Boule. Absent means no blue/green. */
1051
+ boule?: OrgBuiltinApp;
1052
+ /** The company's Agora, a static app: its release moves on hard fails and time only. Absent means no blue/green. */
1053
+ agora?: OrgBuiltinApp;
1038
1054
  /** Absent means `{ enabled: false, signUp: false }`. Optional so every existing literal and fixture compiles unchanged. */
1039
1055
  customers?: CustomersConfig;
1040
1056
  /** Parts of this company held on their own version. Absent or null follows the platform. Ignored for the owner company. */
package/dist/org.js CHANGED
@@ -156,13 +156,14 @@ export function refuseHosting(hosting, names) {
156
156
  if (hosting.enabled && hosting.entries.length === 0) {
157
157
  return refusal('hosting_needs_entry', 'hosting.entries', 'hosting.enabled requires at least one entry');
158
158
  }
159
+ const reserveKeys = names?.builtinBlueGreen === true || hosting.entries.some((e) => e.blueGreen !== undefined);
159
160
  const seenKeys = new Set();
160
161
  for (const entry of hosting.entries) {
161
162
  if (seenKeys.has(entry.key)) {
162
163
  return refusal('entry_key_not_unique', `hosting.entries.${entry.key}.key`, `hosting.entries key '${entry.key}' is not unique`);
163
164
  }
164
165
  seenKeys.add(entry.key);
165
- const invalid = validateHostingEntry(entry, hosting.enabled);
166
+ const invalid = validateHostingEntry(entry, hosting.enabled, reserveKeys);
166
167
  if (invalid)
167
168
  return invalid;
168
169
  const invalidAuth = validateEntryAuth(entry, hosting, names?.domain);
@@ -358,6 +359,8 @@ function validateEntryHostnames(hosting, names) {
358
359
  return null;
359
360
  }
360
361
  const ENTRY_KEY = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
362
+ /** Names of the parts blue/green adds to an app (`<app>-stage`, `-blue`, `-green`, `-lb`): an entry of that name would adopt one. */
363
+ const RESERVED_ENTRY_KEY = /^(stage|blue|green|lb)$|-(stage|blue|green|lb)$/;
361
364
  const VOLUME_MOUNT_PATH = /^(\/(?!\.\.?(\/|$))[A-Za-z0-9._-]+)+$/;
362
365
  /** The log volume's mount path; `executor.ts`'s `LOG_DIR` is the same value. */
363
366
  const LOG_DIR_PATH = '/app/logs';
@@ -381,15 +384,18 @@ const REPO = /^wtfalch\/[A-Za-z0-9._-]+$/;
381
384
  // The env names `pushAppEnv` and `pushDatabaseUrl` set themselves.
382
385
  const FOUNDRY_ENV_NAME = /^(APP_URL|LOG_DIR|OWNER_USER_ID|AUTH_.*|DATABASE_URL.*)$/;
383
386
  // Set only on the colors of a blueGreen entry; any other entry may declare them.
384
- const BLUE_GREEN_ENV_NAME = /^REPORTING_(COLOR|RELEASE|HEALTH_TOKEN)$/;
387
+ const BLUE_GREEN_ENV_NAME = /^(REPORTING_(COLOR|RELEASE|HEALTH_TOKEN)|NEXT_SERVER_ACTIONS_ENCRYPTION_KEY)$/;
385
388
  const SECRET_ENV_NAME = /_(KEY|SECRET|TOKEN|PASSWORD)$/;
386
389
  const HELD_SECRETS = "declare it as { from: 'heldSecret' }, not a plain value";
387
390
  /** The rules for one {@link HostingEntry}; `null` when it is valid. */
388
- function validateHostingEntry(entry, enabled) {
391
+ function validateHostingEntry(entry, enabled, reserveKeys) {
389
392
  const at = `hosting.entries '${entry.key}'`;
390
393
  if (!ENTRY_KEY.test(entry.key)) {
391
394
  return refusal('entry_key_invalid', `hosting.entries.${entry.key}.key`, `hosting.entries key '${entry.key}' must be a DNS label (lowercase letters, digits and hyphens, 1-63 characters)`);
392
395
  }
396
+ if (reserveKeys && RESERVED_ENTRY_KEY.test(entry.key)) {
397
+ return refusal('entry_key_reserved', `hosting.entries.${entry.key}.key`, `hosting.entries key '${entry.key}' is reserved: stage, blue, green and lb, and any key ending in -stage, -blue, -green or -lb, name the parts blue/green adds to an app`);
398
+ }
393
399
  if (entry.repo !== null && !REPO.test(entry.repo)) {
394
400
  return refusal('entry_repo_invalid', `hosting.entries.${entry.key}.repo`, `${at} repo must be a repo under wtfalch, or null`);
395
401
  }
@@ -494,6 +500,21 @@ function refuseBlueGreen(value, at, path) {
494
500
  }
495
501
  return null;
496
502
  }
503
+ /** {@link refuseBlueGreen} for the `blueGreen` of the company's Boule and Agora; the same codes. */
504
+ export function refuseBuiltinBlueGreen(org) {
505
+ for (const app of ['boule', 'agora']) {
506
+ const value = org[app];
507
+ if (value === undefined)
508
+ continue;
509
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
510
+ return refusal('builtin_app_invalid', app, `${app} must be an object with an optional blueGreen`);
511
+ }
512
+ const refused = refuseBlueGreen(value.blueGreen, app, `${app}.blueGreen`);
513
+ if (refused)
514
+ return refused;
515
+ }
516
+ return null;
517
+ }
497
518
  /** `volumes`: a bounded list of named volumes at distinct absolute paths, none on the log volume's path or name,
498
519
  * on `/app` or under a system path. */
499
520
  function validateEntryVolumes(entry, at) {
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { sharedSecretRef } from '../org.js';
3
3
  import { applyBlueGreenServer } from './blue-green-apply.js';
4
- import { ARCHON_BG_ORIGIN_SCOPE_KEY, DEFAULT_WEIGHTS, PRIMARY_SERVER_SCOPE_KEY, bgKey, healthTokenName, lbOrigin, splitServers, } from './blue-green.js';
4
+ import { ARCHON_BG_ORIGIN_SCOPE_KEY, DEFAULT_WEIGHTS, PRIMARY_SERVER_SCOPE_KEY, actionsKeyName, bgKey, healthTokenName, lbOrigin, mintActionsKey, splitServers, } from './blue-green.js';
5
5
  /**
6
6
  * platform-archon on the blue/green path (`platform.archon.blueGreen`): the archon app keeps its name and is
7
7
  * blue; green, the balancer and stage are new apps beside it on the primary server. The tunnel moves to the
@@ -58,6 +58,20 @@ export async function applyArchonBlueGreen(run) {
58
58
  primaryBlueAppId: run.blueAppId,
59
59
  logDir: run.logDir,
60
60
  isRunning: run.isRunning,
61
+ adoptRunningBlue: true,
62
+ actionsKey: async () => {
63
+ const key = await mintActionsKey({
64
+ id,
65
+ ref: sharedSecretRef(run.ownerOrgId, actionsKeyName(id)),
66
+ recorded: run.recorded,
67
+ record: run.record,
68
+ persist: run.persist,
69
+ heldKeys: run.heldKeys,
70
+ generate: () => randomBytes(32).toString('base64'),
71
+ secret: run.secret,
72
+ });
73
+ return key;
74
+ },
61
75
  };
62
76
  await applyBlueGreenServer(deps, subject, { uuid: primary, primaryUuid: primary });
63
77
  // The structure is applied and every part ran: now, and only now, the tunnel moves.
@@ -3,6 +3,7 @@ import { ensureConnectorsOnServers } from './cloudflared-connector.js';
3
3
  import { type DeployHealthRuntime } from './deploy-health.js';
4
4
  import type { TunnelPort } from './edge-tunnels.js';
5
5
  import type { CoolifyPort } from './executor.js';
6
+ import { type StageGuardDeps } from './stage-guards.js';
6
7
  /** The Coolify calls this module makes. */
7
8
  type Coolify = Pick<CoolifyPort, 'findApplicationByName' | 'getApplication' | 'createGithubAppApplication' | 'createDockerfileApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deploy' | 'deployAndWait' | 'listStorages' | 'createPersistentStorage' | 'createFileStorage' | 'updateFileStorage' | 'requireGithubApp' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'raw'> & {
8
9
  listApplicationDeployments?: CoolifyPort['listApplicationDeployments'];
@@ -31,6 +32,21 @@ export interface BlueGreenApplyDeps {
31
32
  primaryBlueAppId: string;
32
33
  logDir: string;
33
34
  isRunning: (status: string | undefined) => boolean;
35
+ /**
36
+ * The primary blue is the process running this apply (platform-archon). Its first apply writes the reporting
37
+ * env but does not redeploy it: a restart would cut the apply, and the env is read at its next start.
38
+ */
39
+ adoptRunningBlue?: boolean;
40
+ /**
41
+ * The subject's server actions key (held, minted once). Blue and green are built with it; stage gets a key of
42
+ * its own. Absent: no key is set.
43
+ */
44
+ actionsKey?: () => Promise<string>;
45
+ /**
46
+ * What stage's guards need from the host: the probe port and the Access gate. A missing part leaves that
47
+ * guard unrecorded ("not run", never passed), so `stageGuardsReady` stays false and the copy does not start.
48
+ */
49
+ stageGuards?: StageGuardDeps;
34
50
  }
35
51
  /** What writing a balancer's config needs; {@link BlueGreenApplyDeps} and the release runner both have it. */
36
52
  export interface LbConfigDeps {
@@ -60,6 +76,25 @@ export declare function applyBlueGreenWeights(deps: LbConfigDeps, subject: BlueG
60
76
  uuid: string;
61
77
  primaryUuid: string;
62
78
  }): Promise<void>;
79
+ /**
80
+ * The primary blue's env for another color: the database host swapped when the server is not the primary, and
81
+ * never the reporting names or the actions key, which differ by design. The structure apply and a deploy both
82
+ * copy through this, so a color is never deployed with an older copy than the structure apply wrote.
83
+ */
84
+ export declare function copiedEnv(deps: {
85
+ label: string;
86
+ secret: (value: string) => void;
87
+ }, subject: Pick<BlueGreenSubject, 'base'>, source: Array<{
88
+ key: string;
89
+ value?: string;
90
+ is_preview?: boolean;
91
+ }>, server: {
92
+ uuid: string;
93
+ primaryUuid: string;
94
+ }): Array<{
95
+ key: string;
96
+ value: string;
97
+ }>;
63
98
  /** A connector for the org's tunnel on each given server; the primary's is the existing one. */
64
99
  export declare function applyBlueGreenConnectors(input: {
65
100
  coolify: Parameters<typeof ensureConnectorsOnServers>[0]['coolify'];
@@ -1,10 +1,13 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { COLORS, LB_CONFIG_PATH, bgKey, colorAppName, colorBackend, formatWeights, lbAppName, lbDockerfile, parseWeights, renderLbConfig, reportingEnv, stageAppName, structureHash, } from './blue-green.js';
2
+ import { ACTIONS_KEY_ENV, COLORS, LB_CONFIG_PATH, bgKey, colorAppName, colorBackend, formatWeights, lbAppName, lbDockerfile, parseWeights, prodResources, renderLbConfig, reportingEnv, stageAppName, stageAppUrl, stageKindOf, stageResources, structureHash, } from './blue-green.js';
3
3
  import { recordBuiltCommit } from './build-record.js';
4
4
  import { ensureConnectorsOnServers } from './cloudflared-connector.js';
5
5
  import { waitForNewDeployment } from './deploy-health.js';
6
6
  import { ENTRY_CONTAINER_LABELS } from './proxy-labels.js';
7
7
  import { remoteDatabaseAddress, remoteDatabaseHost } from './remote-database.js';
8
+ import { buildStageEnv } from './stage-env.js';
9
+ import { buildStageProbes, runStageProbes, stageProbesRecord, } from './stage-guards.js';
10
+ import { assertStageIsolated, envClass, stageIsolationHash, } from './stage-isolation.js';
8
11
  /** Env names whose value is a credential: registered before they are copied. */
9
12
  const SECRETISH = /(KEY|SECRET|TOKEN|PASSWORD|DATABASE_URL)/;
10
13
  /** The env names that name the database. */
@@ -35,10 +38,16 @@ export async function applyBlueGreenServer(deps, subject, server) {
35
38
  if (!weights)
36
39
  throw new Error(`${deps.label}: the recorded weights are not valid`);
37
40
  const ids = {};
38
- if (isPrimary)
41
+ if (isPrimary) {
39
42
  ids.blue = deps.primaryBlueAppId;
43
+ // A merge must not redeploy blue past the release runner.
44
+ await coolify.updateApplication(deps.primaryBlueAppId, { is_auto_deploy_enabled: false });
45
+ }
40
46
  const sourceEnv = await coolify.listEnvs(deps.primaryBlueAppId);
41
- const token = await deps.healthToken();
47
+ const token = subject.reporting ? await deps.healthToken() : null;
48
+ const actionsKey = subject.reporting && deps.actionsKey ? await deps.actionsKey() : null;
49
+ if (actionsKey)
50
+ deps.secret(actionsKey);
42
51
  for (const color of COLORS) {
43
52
  const name = isPrimary && color === 'blue' ? subject.primaryBlueName : colorAppName(subject.base, color);
44
53
  if (!ids[color]) {
@@ -53,12 +62,20 @@ export async function applyBlueGreenServer(deps, subject, server) {
53
62
  const appId = ids[color];
54
63
  const copied = isPrimary && color === 'blue' ? [] : copiedEnv(deps, subject, sourceEnv, server);
55
64
  const release = deps.recorded()[bgKey.digest(id, color)];
56
- const reporting = reportingEnv({ color, release, healthToken: token });
65
+ const reporting = token === null ? [] : reportingEnv({ color, release, healthToken: token });
57
66
  // A running color reads its reporting env at start: when it changed, the color restarts before the
58
67
  // tunnel can move to it.
59
68
  const reportingChanged = await reportingEnvDiffers(deps, appId, reporting);
60
- await upsertEnv(deps, appId, [...copied, ...reporting]);
61
- await ensureDeployed(deps, appId, name, reportingChanged);
69
+ await upsertEnv(deps, appId, [
70
+ ...copied,
71
+ ...reporting,
72
+ ...(actionsKey ? [{ key: ACTIONS_KEY_ENV, value: actionsKey, buildTime: true }] : []),
73
+ ]);
74
+ const adoptingBlue = deps.adoptRunningBlue === true &&
75
+ isPrimary &&
76
+ color === 'blue' &&
77
+ !deps.recorded()[bgKey.applied(id, server.uuid)];
78
+ await ensureDeployed(deps, appId, name, reportingChanged && !adoptingBlue);
62
79
  }
63
80
  const lbName = lbAppName(subject.base);
64
81
  const lbId = await ensureLoadBalancer(deps, subject, server.uuid, lbName, isPrimary);
@@ -103,16 +120,24 @@ async function writeLbConfig(deps, subject, server, lbId, weights) {
103
120
  deps.record(bgKey.lb(subject.id, server.uuid), formatWeights(weights));
104
121
  await deps.persist();
105
122
  }
106
- /** The primary blue's env for another color: the database host swapped when the server is not the primary. */
107
- function copiedEnv(deps, subject, source, server) {
123
+ /**
124
+ * The primary blue's env for another color: the database host swapped when the server is not the primary, and
125
+ * never the reporting names or the actions key, which differ by design. The structure apply and a deploy both
126
+ * copy through this, so a color is never deployed with an older copy than the structure apply wrote.
127
+ */
128
+ export function copiedEnv(deps, subject, source, server) {
108
129
  const sidecar = server.uuid === server.primaryUuid
109
130
  ? null
110
131
  : remoteDatabaseHost(subject.base, server.uuid, server.primaryUuid);
111
132
  const dbHost = sidecar ? remoteDatabaseAddress(sidecar) : null;
112
133
  const out = [];
113
134
  for (const env of source) {
114
- if (env.is_preview || env.value === undefined || env.key.startsWith('REPORTING_'))
135
+ if (env.is_preview ||
136
+ env.value === undefined ||
137
+ env.key.startsWith('REPORTING_') ||
138
+ env.key === ACTIONS_KEY_ENV) {
115
139
  continue;
140
+ }
116
141
  let value = env.value;
117
142
  if (dbHost && DATABASE_ENV.has(env.key)) {
118
143
  const swapped = withDatabaseHost(value, dbHost);
@@ -149,6 +174,8 @@ async function ensureGitApp(deps, subject, input) {
149
174
  ports_exposes: String(subject.port),
150
175
  git_branch: input.branch,
151
176
  is_preview_deployments_enabled: false,
177
+ is_auto_deploy_enabled: input.autoDeploy,
178
+ ...(subject.dockerfileLocation ? { dockerfile_location: subject.dockerfileLocation } : {}),
152
179
  ...(subject.folder === null ? {} : { base_directory: `/${subject.folder}` }),
153
180
  };
154
181
  const recorded = deps.recorded()[input.key];
@@ -188,6 +215,7 @@ async function ensureGitApp(deps, subject, input) {
188
215
  ports_exposes: isStatic ? '80' : String(subject.port),
189
216
  is_auto_deploy_enabled: input.autoDeploy,
190
217
  base_directory: subject.folder === null ? undefined : `/${subject.folder}`,
218
+ ...(subject.dockerfileLocation ? { dockerfile_location: subject.dockerfileLocation } : {}),
191
219
  });
192
220
  // Recorded before anything else is written, so a later failure never leaves an app nothing points at.
193
221
  deps.record(input.key, created.uuid);
@@ -273,12 +301,12 @@ async function ensureLoadBalancer(deps, subject, serverUuid, name, isPrimary) {
273
301
  }
274
302
  return lbId;
275
303
  }
276
- /** The stage app and its own empty database, on the primary. Holds no env from the live app. */
304
+ /** The stage app and its own empty database, on the primary. Its env comes from the allowlist, never from a copy of the live app's. */
277
305
  async function ensureStage(deps, subject, serverUuid) {
278
306
  const { coolify } = deps;
279
307
  const name = stageAppName(subject.base);
280
308
  const appId = await stageAppId(deps, subject, name, serverUuid);
281
- const envs = [{ key: 'LOG_DIR', value: deps.logDir }];
309
+ const database = [];
282
310
  if (subject.database) {
283
311
  const dbName = `${subject.base}-stage-db`;
284
312
  const pgName = dbName.replace(/-/g, '_');
@@ -302,7 +330,7 @@ async function ensureStage(deps, subject, serverUuid) {
302
330
  const db = await coolify.getDatabase(dbId);
303
331
  if (db.internal_db_url) {
304
332
  deps.secret(db.internal_db_url);
305
- envs.push({ key: 'DATABASE_URL_OWNER', value: db.internal_db_url });
333
+ database.push({ key: 'DATABASE_URL_OWNER', value: db.internal_db_url });
306
334
  const have = (await coolify.listEnvs(appId)).find((e) => e.key === 'DATABASE_URL' && !e.is_preview);
307
335
  if (!have) {
308
336
  const password = randomBytes(24).toString('base64url');
@@ -310,21 +338,144 @@ async function ensureStage(deps, subject, serverUuid) {
310
338
  const runtime = runtimeDatabaseUrl(db.internal_db_url, `${pgName}_rt`, password);
311
339
  if (runtime) {
312
340
  deps.secret(runtime);
313
- envs.push({ key: 'DATABASE_URL', value: runtime });
341
+ database.push({ key: 'DATABASE_URL', value: runtime });
314
342
  }
315
343
  }
316
344
  }
317
345
  }
318
- // Stage never holds the live app's keys or secrets: its env is the log directory and its own database.
319
- await upsertEnv(deps, appId, envs);
346
+ const kind = stageKindOf(subject);
347
+ const prodEnv = await coolify.listEnvs(deps.primaryBlueAppId);
348
+ const greenId = deps.recorded()[bgKey.app(subject.id, serverUuid, 'green')];
349
+ const greenEnv = greenId ? await coolify.listEnvs(greenId) : [];
350
+ const { env } = buildStageEnv({
351
+ kind,
352
+ appUrl: stageAppUrl(subject),
353
+ logDir: deps.logDir,
354
+ database,
355
+ prod: prodEnv,
356
+ existing: await coolify.listEnvs(appId),
357
+ mint: () => {
358
+ const value = randomBytes(32).toString('base64url');
359
+ deps.secret(value);
360
+ return value;
361
+ },
362
+ });
363
+ const stageEnv = [...env];
364
+ if (subject.reporting && deps.actionsKey) {
365
+ // Stage never shares a production secret: its key is its own, minted once and kept.
366
+ const have = (await coolify.listEnvs(appId)).find((e) => e.key === ACTIONS_KEY_ENV && !e.is_preview);
367
+ const key = have?.value || randomBytes(32).toString('base64');
368
+ deps.secret(key);
369
+ stageEnv.push({ key: ACTIONS_KEY_ENV, value: key, buildTime: true });
370
+ }
371
+ const stageSurface = {
372
+ env: stageEnv.map((e) => ({ name: e.key, class: envClass(e.key), value: e.value })),
373
+ resources: stageResources(subject),
374
+ };
375
+ const violations = assertStageIsolated({
376
+ kind,
377
+ prod: {
378
+ env: [...prodEnv, ...greenEnv]
379
+ .filter((e) => !e.is_preview && e.value !== undefined)
380
+ .map((e) => ({ name: e.key, class: envClass(e.key), value: e.value })),
381
+ resources: prodResources(subject),
382
+ internalHosts: Object.values(prodResources(subject)),
383
+ },
384
+ stage: stageSurface,
385
+ });
386
+ if (violations.length > 0) {
387
+ // Stage fails closed for itself: nothing is written to it, production's structure goes on.
388
+ await stageFailed(deps, subject, `blocked:${violations.join(',')}`);
389
+ return;
390
+ }
391
+ // Stage's credentials are tried against production before they are written; one that production accepts
392
+ // blocks stage (nothing is written to it), and what was tried is recorded by name so the copy slice sees it.
393
+ const stageValues = new Map((await coolify.listEnvs(appId))
394
+ .filter((e) => !e.is_preview && e.value !== undefined)
395
+ .map((e) => [e.key, e.value]));
396
+ for (const e of stageEnv)
397
+ stageValues.set(e.key, e.value);
398
+ const isolationHash = stageIsolationHash(stageSurface);
399
+ let probeResult;
400
+ try {
401
+ probeResult = await runStageProbes(buildStageProbes({
402
+ port: deps.stageGuards?.probes,
403
+ stage: stageValues,
404
+ database: subject.database,
405
+ prod: new Map(prodEnv
406
+ .filter((e) => !e.is_preview && e.value !== undefined)
407
+ .map((e) => [e.key, e.value])),
408
+ }));
409
+ }
410
+ catch (error) {
411
+ // A connection URL that cannot be read: stage is blocked, production goes on.
412
+ await stageFailed(deps, subject, 'blocked:database', error);
413
+ return;
414
+ }
415
+ const probes = stageProbesRecord(probeResult, isolationHash, stageValues);
416
+ if (probeResult.failed.length > 0) {
417
+ deps.record(bgKey.stageProbes(subject.id), probes.value);
418
+ await stageFailed(deps, subject, `blocked:${probeResult.failed.join(',')}`);
419
+ return;
420
+ }
421
+ if (!(await ensureStageGate(deps, subject))) {
422
+ await stageFailed(deps, subject, 'blocked:gate');
423
+ return;
424
+ }
425
+ await upsertEnv(deps, appId, stageEnv);
426
+ deps.record(bgKey.stageIsolation(subject.id), isolationHash);
427
+ deps.record(bgKey.stageProbes(subject.id), probes.value);
428
+ await deps.persist();
320
429
  const live = await coolify.getApplication(appId);
321
430
  if (live.status === undefined || !deps.isRunning(live.status)) {
322
- const built = await waitForNewDeployment(coolify, appId, noDeploy, deps.runtime, deps.sleep, `${deps.label} ${name}`, `${name} stays as it was`);
431
+ // Stage is a test copy: a failed build or health check must not fail production's structure apply.
432
+ // The next apply retries it (the plan keeps the step while the state is `unhealthy`).
433
+ let built;
434
+ try {
435
+ built = await waitForNewDeployment(coolify, appId, noDeploy, deps.runtime, deps.sleep, `${deps.label} ${name}`, `${name} stays as it was`);
436
+ }
437
+ catch (error) {
438
+ await stageFailed(deps, subject, 'unhealthy', error);
439
+ return;
440
+ }
323
441
  await recordBuiltCommit(coolify, { appId, deploymentUuid: built.deploymentUuid, name: subject.id }, (key, value) => {
324
442
  if (key.startsWith('built_commit_'))
325
443
  deps.record(bgKey.stage(subject.id), value);
326
444
  });
327
445
  }
446
+ deps.record(bgKey.stageState(subject.id), 'ok');
447
+ await deps.persist();
448
+ }
449
+ /**
450
+ * Stage fails closed for itself only: its state is recorded and the apply of production goes on. The log line is
451
+ * fixed text and the error name; the cause can echo a credential, so it is never logged.
452
+ */
453
+ async function stageFailed(deps, subject, state, error) {
454
+ const name = error === undefined ? 'refused' : error instanceof Error ? error.name : 'unknown';
455
+ console.error(`${deps.label}: stage is ${state.split(':')[0]} (${name}); production goes on`);
456
+ deps.record(bgKey.stageState(subject.id), state);
457
+ await deps.persist();
458
+ }
459
+ /**
460
+ * The Access gate over the stage host, made once. `false` when making it failed: stage is then blocked and not
461
+ * written to. No gate dependency: nothing is recorded (not run) and stage goes on.
462
+ */
463
+ async function ensureStageGate(deps, subject) {
464
+ const gate = deps.stageGuards?.gate;
465
+ if (!gate || deps.recorded()[bgKey.stageGate(subject.id)])
466
+ return true;
467
+ let id;
468
+ try {
469
+ id = (await gate.create()).id;
470
+ }
471
+ catch (error) {
472
+ // An Access error can echo the request, which names the people allowed in: only its name is logged.
473
+ console.error(`${deps.label}: making the Access gate failed (${error instanceof Error ? error.name : 'unknown'})`);
474
+ return false;
475
+ }
476
+ deps.record(bgKey.stageGate(subject.id), id);
477
+ await deps.persist();
478
+ return true;
328
479
  }
329
480
  async function stageAppId(deps, subject, name, serverUuid) {
330
481
  return ensureGitApp(deps, subject, {