@wtfalch/foundry 0.48.0 → 0.50.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 (38) hide show
  1. package/dist/bootstrap/declare.js +1 -0
  2. package/dist/index.d.ts +8 -3
  3. package/dist/index.js +5 -2
  4. package/dist/migrations/0060_hosting_repos.sql +7 -0
  5. package/dist/org.d.ts +9 -1
  6. package/dist/org.js +27 -0
  7. package/dist/plan/archon-blue-green.js +1 -0
  8. package/dist/plan/blue-green-apply.d.ts +9 -0
  9. package/dist/plan/blue-green-apply.js +86 -2
  10. package/dist/plan/blue-green.d.ts +8 -0
  11. package/dist/plan/blue-green.js +18 -1
  12. package/dist/plan/call-timeout.d.ts +6 -0
  13. package/dist/plan/call-timeout.js +27 -0
  14. package/dist/plan/diff.d.ts +2 -0
  15. package/dist/plan/diff.js +1 -0
  16. package/dist/plan/executor.d.ts +2 -0
  17. package/dist/plan/executor.js +25 -1
  18. package/dist/plan/github-release-hook.d.ts +81 -0
  19. package/dist/plan/github-release-hook.js +159 -0
  20. package/dist/plan/index.d.ts +7 -1
  21. package/dist/plan/index.js +4 -1
  22. package/dist/plan/live.d.ts +1 -1
  23. package/dist/plan/plan-apply.d.ts +2 -0
  24. package/dist/plan/plan-apply.js +8 -3
  25. package/dist/plan/platform-diff.d.ts +59 -0
  26. package/dist/plan/platform-diff.js +98 -1
  27. package/dist/plan/platform-providers.d.ts +10 -0
  28. package/dist/plan/platform-providers.js +71 -1
  29. package/dist/plan/stage-copy-port.d.ts +62 -0
  30. package/dist/plan/stage-copy-port.js +208 -0
  31. package/dist/plan/stage-copy.d.ts +140 -0
  32. package/dist/plan/stage-copy.js +538 -0
  33. package/dist/plan/stage-sink.d.ts +37 -0
  34. package/dist/plan/stage-sink.js +33 -0
  35. package/dist/store/postgres.js +13 -2
  36. package/dist/store/schema.d.ts +19 -0
  37. package/dist/store/schema.js +2 -0
  38. package/package.json +1 -1
@@ -121,6 +121,7 @@ export function declaredCompanyOrg(company, owner) {
121
121
  issuerApps: [],
122
122
  entries: [],
123
123
  gateLimit: DEFAULT_GATE_LIMIT,
124
+ repos: [],
124
125
  },
125
126
  sso: { enabled: false },
126
127
  guests: { enabled: false },
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
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
- 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';
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, normalizeHostingRepos, 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';
5
5
  export type { DeclaredPlatform, PlatformService, PlatformServiceKey, PlatformSfuService, HeartbeatsProvider, } from './platform.js';
@@ -31,13 +31,18 @@ export { COMPANY_DESTINATION_NAME_PREFIX, COMPANY_NETWORKS, DEFAULT_MAX_DESTINAT
31
31
  export { AdoptRefusedError } from './plan/index.js';
32
32
  export { DumpError, dumpNow } from './plan/index.js';
33
33
  export type { DatabaseDump } from './plan/index.js';
34
+ export { STAGE_SINK_APPLIED_SCOPE_KEY, STAGE_SINK_IMAGE_REF, stageMailEnv, stageSinkAppName, } from './plan/index.js';
35
+ export { STAGE_COPY_EXCLUDED_TABLES, StageCopyFailedError, StageCopyRefusedError, stageCopy, tableExcluded, undoStageCopy, } from './plan/index.js';
36
+ export type { StageCopyInput, StageCopyPort, StageCopyProduction, StageCopyRunRecord, UndoStageCopyInput, } from './plan/index.js';
37
+ export { StageRestoreError, StageRestoreRefusedError, createStageCopyPort, execRunner, } from './plan/index.js';
38
+ export type { Runner, StageCopyPortDeps } from './plan/index.js';
34
39
  export { InvalidMigrationConfirmationError, ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY, OrgPostgresMigrationBackupMissingError, OrgPostgresMigrationNotConfirmedError, } from './plan/index.js';
35
40
  export type { OrgPostgresMigrationConfirmation } from './plan/index.js';
36
41
  export { NetworkMoveBackupMissingError, UpgradeBackupMissingError } from './plan/index.js';
37
42
  export type { BackupGatePorts } from './plan/index.js';
38
43
  export { createEdgeAccess, createEdgeDns, edgeDnsRecordOwners, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
39
44
  export type { AccessAppSummary, AccessDecision, AccessPolicyRule, AccessPort, DnsPort, EdgePlatformAccess, EdgePlatformDns, EdgePlatformRateLimits, EdgePlatformWorkers, EdgePlatformZones, EdgeZonesOptions, ZonePort, EdgeRateLimitsOptions, RateLimitPort, RateLimitRuleInput, EdgeWorkersOptions, EdgePlatformTunnels, TunnelPort, } from './plan/index.js';
40
- export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, stepEntryKey, } from './plan/index.js';
45
+ export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, GITHUB_RELEASE_HOOK_ORG, GITHUB_RELEASE_HOOK_PATH, GITHUB_RELEASE_WEBHOOK_SECRET_ENV, PLATFORM_GITHUB_ORG_HOOKS_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, stepEntryKey, } from './plan/index.js';
41
46
  export type { HeldKeyResolver, OrgPlatformReadiness } from './plan/index.js';
42
47
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesForOwn, agoraHostValuesFromPlatform, heldOwnKeyRefs, heldPlatformKeyRefs, orgPlatformReadinessFor, orgPlatformReadinessFrom, } from './plan/index.js';
43
48
  export type { PlatformOrgClientFactories } from './plan/index.js';
@@ -66,7 +71,7 @@ export { checkMailIdentityAudience } from './plan/index.js';
66
71
  export type { MailAllowlistApp } from './plan/index.js';
67
72
  export { checkMailAppsAllowlist } from './plan/index.js';
68
73
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, createZitadelAdminMinter, createZitadelLoginPolicy, createZitadelOrgMetadata, createZitadelProjectRoles, PLATFORM_AUTH_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_RELAY_KEY, PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './plan/index.js';
69
- export type { PlatformAuthDeps, PlatformAuthSmtp, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformDnsFactory, PlatformAccessFactory, PlatformUptimeAccessFactory, PlatformUptimeAccessPort, PlatformUptimeChatDeps, PlatformUptimeDeps, PlatformWorkersFactory, PlatformWorkersPort, PlatformAccessPort, PlatformTunnelsFactory, PlatformEdgeRuntime, } from './plan/index.js';
74
+ export type { PlatformAuthDeps, PlatformAuthSmtp, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformDnsFactory, PlatformAccessFactory, PlatformUptimeAccessFactory, PlatformUptimeAccessPort, PlatformUptimeChatDeps, PlatformUptimeDeps, PlatformGithubReleaseHookDeps, PlatformWorkersFactory, PlatformWorkersPort, PlatformAccessPort, PlatformTunnelsFactory, PlatformEdgeRuntime, } from './plan/index.js';
70
75
  export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './plan/index.js';
71
76
  export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './plan/index.js';
72
77
  export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './plan/index.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- 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';
1
+ 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, normalizeHostingRepos, validateMail, validateOwners, validateServices, validateCustomers, validateSignIn, validateVersionPin, validateSite, } from './org.js';
2
2
  export { refuseOrg } from './refuse-org.js';
3
3
  export { workerAppHasBuild } from './worker-app-config.js';
4
4
  export { PLATFORM_SERVICE_KEYS, HEARTBEATS_PROVIDERS, defaultPlatformHostname, validatePlatform, } from './platform.js';
@@ -19,10 +19,13 @@ export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAudit
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
21
  export { DumpError, dumpNow } from './plan/index.js';
22
+ export { STAGE_SINK_APPLIED_SCOPE_KEY, STAGE_SINK_IMAGE_REF, stageMailEnv, stageSinkAppName, } from './plan/index.js';
23
+ export { STAGE_COPY_EXCLUDED_TABLES, StageCopyFailedError, StageCopyRefusedError, stageCopy, tableExcluded, undoStageCopy, } from './plan/index.js';
24
+ export { StageRestoreError, StageRestoreRefusedError, createStageCopyPort, execRunner, } from './plan/index.js';
22
25
  export { InvalidMigrationConfirmationError, ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY, OrgPostgresMigrationBackupMissingError, OrgPostgresMigrationNotConfirmedError, } from './plan/index.js';
23
26
  export { NetworkMoveBackupMissingError, UpgradeBackupMissingError } from './plan/index.js';
24
27
  export { createEdgeAccess, createEdgeDns, edgeDnsRecordOwners, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
25
- export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, stepEntryKey, } from './plan/index.js';
28
+ export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, GITHUB_RELEASE_HOOK_ORG, GITHUB_RELEASE_HOOK_PATH, GITHUB_RELEASE_WEBHOOK_SECRET_ENV, PLATFORM_GITHUB_ORG_HOOKS_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, stepEntryKey, } from './plan/index.js';
26
29
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesForOwn, agoraHostValuesFromPlatform, heldOwnKeyRefs, heldPlatformKeyRefs, orgPlatformReadinessFor, orgPlatformReadinessFrom, } from './plan/index.js';
27
30
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
28
31
  export { OWN_FILES_R2_SCOPE_KEY, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, OWN_SERVICES_APPLIED_SCOPE_KEY, OwnServicesBlockedError, orgTarget, } from './plan/index.js';
@@ -0,0 +1,7 @@
1
+ -- @wtfalch/foundry: the repos a company's apps may be built from (`HostingConfig.repos`), persisted.
2
+ -- Mirrors `src/store/schema.ts`'s `hostingRepos`. Additive, like 0048_hosting_gate_limit.sql: a new
3
+ -- column with a default, so a row written before it reads `[]` and an older reader ignores it. Safe
4
+ -- on live rows when the container starts.
5
+
6
+ alter table foundry_declared_orgs
7
+ add column if not exists hosting_repos jsonb not null default '[]'::jsonb;
package/dist/org.d.ts CHANGED
@@ -253,6 +253,12 @@ export interface HostingConfig {
253
253
  access?: AccessRule[];
254
254
  /** How many gates the org may declare across all its entries. Only the platform operator sets it. Default: `3`. */
255
255
  gateLimit: number;
256
+ /**
257
+ * The repos under `wtfalch` the org's apps may be built from, each `wtfalch/<name>`. Stored lowercase and
258
+ * unique ({@link normalizeHostingRepos}). Only a person who declares the company sets it; the planner
259
+ * and the diff ignore it. Default: `[]`.
260
+ */
261
+ repos: string[];
256
262
  }
257
263
  /** The two Cloudflare Access rule kinds {@link HostingConfig.access} supports (#246). */
258
264
  export type AccessRuleKind = 'bypass' | 'service-token';
@@ -283,7 +289,7 @@ export type AccessRule = {
283
289
  /** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
284
290
  export declare const APP_ENTRY_KEY = "app";
285
291
  /** 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_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';
292
+ 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' | 'hosting_repo_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
293
  /**
288
294
  * One refusal: a stable `code`, the dotted `path` into {@link DeclaredOrg} it is about
289
295
  * (`hosting.entries.terminal.uses.auth.registration`) and the sentence for people.
@@ -626,6 +632,8 @@ export interface HostingNames {
626
632
  /** Boule or Agora declares `blueGreen`: the blue/green key names are reserved for the entries too. */
627
633
  builtinBlueGreen?: boolean;
628
634
  }
635
+ /** `repos` as the store keeps them: lowercase, in the order given. Absent reads as none. */
636
+ export declare function normalizeHostingRepos(repos: readonly string[] | undefined): string[];
629
637
  /** {@link validateHosting} with the code and path of the first refusal. */
630
638
  export declare function refuseHosting(hosting: HostingConfig, names?: HostingNames): OrgRefusal | null;
631
639
  /** `blueGreen`: steps ascending, each a whole number 1..100, the last 100; a hold of 1..1440 minutes. */
package/dist/org.js CHANGED
@@ -123,8 +123,34 @@ const HOSTING_BUILD_PACKS = new Set([
123
123
  export function validateHosting(hosting, names) {
124
124
  return refuseHosting(hosting, names)?.message ?? null;
125
125
  }
126
+ /** `repos` as the store keeps them: lowercase, in the order given. Absent reads as none. */
127
+ export function normalizeHostingRepos(repos) {
128
+ return (repos ?? []).map((repo) => repo.toLowerCase());
129
+ }
130
+ /** The rules for `hosting.repos`: a list of `wtfalch/<name>` (name 1-100 of letters, digits, `.`, `_`, `-`), unique ignoring case. */
131
+ function refuseHostingRepos(repos) {
132
+ const invalid = (message) => refusal('hosting_repo_invalid', 'hosting.repos', message);
133
+ if (repos === undefined)
134
+ return null;
135
+ if (!Array.isArray(repos))
136
+ return invalid('hosting.repos must be a list');
137
+ const seen = new Set();
138
+ for (const repo of repos) {
139
+ if (typeof repo !== 'string' || !HOSTING_REPO.test(repo)) {
140
+ return invalid('hosting.repos must each be wtfalch/<name>, the name 1-100 letters, digits, dots, underscores or hyphens');
141
+ }
142
+ const lower = repo.toLowerCase();
143
+ if (seen.has(lower))
144
+ return invalid(`hosting.repos names ${lower} twice`);
145
+ seen.add(lower);
146
+ }
147
+ return null;
148
+ }
126
149
  /** {@link validateHosting} with the code and path of the first refusal. */
127
150
  export function refuseHosting(hosting, names) {
151
+ const badRepos = refuseHostingRepos(hosting.repos);
152
+ if (badRepos)
153
+ return badRepos;
128
154
  if (hosting.tunnel && !hosting.enabled) {
129
155
  return refusal('hosting_tunnel_needs_hosting', 'hosting.tunnel', 'hosting.tunnel requires hosting.enabled');
130
156
  }
@@ -381,6 +407,7 @@ const VOLUME_FORBIDDEN_ROOTS = [
381
407
  ];
382
408
  const VOLUME_MOUNT_PATH_MAX = 255;
383
409
  const REPO = /^wtfalch\/[A-Za-z0-9._-]+$/;
410
+ const HOSTING_REPO = /^wtfalch\/[A-Za-z0-9._-]{1,100}$/;
384
411
  // The env names `pushAppEnv` and `pushDatabaseUrl` set themselves.
385
412
  const FOUNDRY_ENV_NAME = /^(APP_URL|LOG_DIR|OWNER_USER_ID|AUTH_.*|DATABASE_URL.*)$/;
386
413
  // Set only on the colors of a blueGreen entry; any other entry may declare them.
@@ -59,6 +59,7 @@ export async function applyArchonBlueGreen(run) {
59
59
  logDir: run.logDir,
60
60
  isRunning: run.isRunning,
61
61
  adoptRunningBlue: true,
62
+ stageSink: { slug: 'platform' },
62
63
  actionsKey: async () => {
63
64
  const key = await mintActionsKey({
64
65
  id,
@@ -7,6 +7,8 @@ import { type StageGuardDeps } from './stage-guards.js';
7
7
  /** The Coolify calls this module makes. */
8
8
  type Coolify = Pick<CoolifyPort, 'findApplicationByName' | 'getApplication' | 'createGithubAppApplication' | 'createDockerfileApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deploy' | 'deployAndWait' | 'listStorages' | 'createPersistentStorage' | 'createFileStorage' | 'updateFileStorage' | 'requireGithubApp' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'raw'> & {
9
9
  listApplicationDeployments?: CoolifyPort['listApplicationDeployments'];
10
+ /** Needed by the stage mail sink; a host without it gets no sink, so no stage mail env. */
11
+ createDockerImageApplication?: CoolifyPort['createDockerImageApplication'];
10
12
  };
11
13
  export interface BlueGreenApplyDeps {
12
14
  coolify: Coolify;
@@ -47,6 +49,13 @@ export interface BlueGreenApplyDeps {
47
49
  * guard unrecorded ("not run", never passed), so `stageGuardsReady` stays false and the copy does not start.
48
50
  */
49
51
  stageGuards?: StageGuardDeps;
52
+ /**
53
+ * The company the stage mail sink belongs to (its name is `<slug>-stage-sink`). Absent: no sink is made and
54
+ * stage holds no mail env at all, so the copy stays refused.
55
+ */
56
+ stageSink?: {
57
+ slug: string;
58
+ };
50
59
  }
51
60
  /** What writing a balancer's config needs; {@link BlueGreenApplyDeps} and the release runner both have it. */
52
61
  export interface LbConfigDeps {
@@ -1,13 +1,18 @@
1
1
  import { randomBytes } from 'node:crypto';
2
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
+ import { withTimeout } from './call-timeout.js';
4
5
  import { ensureConnectorsOnServers } from './cloudflared-connector.js';
5
6
  import { waitForNewDeployment } from './deploy-health.js';
7
+ import { commandHealthCheckSettings, wgetProbe } from './health-check-settings.js';
6
8
  import { ENTRY_CONTAINER_LABELS } from './proxy-labels.js';
7
9
  import { remoteDatabaseAddress, remoteDatabaseHost } from './remote-database.js';
8
10
  import { buildStageEnv } from './stage-env.js';
9
11
  import { buildStageProbes, runStageProbes, stageProbesRecord, } from './stage-guards.js';
10
12
  import { assertStageIsolated, envClass, stageIsolationHash, } from './stage-isolation.js';
13
+ import { STAGE_SINK_APPLIED_SCOPE_KEY, STAGE_SINK_APP_SCOPE_KEY, STAGE_SINK_HTTP_PORT, STAGE_SINK_IMAGE_TAG, STAGE_SINK_REPOSITORY, stageMailEnv, stageSinkAppName, } from './stage-sink.js';
14
+ /** How long one Coolify call of the sink may take. */
15
+ const SINK_CALL_TIMEOUT_MS = 30_000;
11
16
  /** Env names whose value is a credential: registered before they are copied. */
12
17
  const SECRETISH = /(KEY|SECRET|TOKEN|PASSWORD|DATABASE_URL)/;
13
18
  /** The env names that name the database. */
@@ -80,8 +85,15 @@ export async function applyBlueGreenServer(deps, subject, server) {
80
85
  const lbName = lbAppName(subject.base);
81
86
  const lbId = await ensureLoadBalancer(deps, subject, server.uuid, lbName, isPrimary);
82
87
  await writeLbConfig(deps, subject, { uuid: server.uuid, isPrimary }, lbId, weights);
83
- if (isPrimary)
88
+ if (isPrimary) {
89
+ const sinkReady = await ensureStageSink(deps, subject, server.uuid);
84
90
  await ensureStage(deps, subject, server.uuid);
91
+ if (!sinkReady) {
92
+ // The planner plans this step again while the state is `blocked:`; ensureStage may have written `ok`.
93
+ deps.record(bgKey.stageState(id), 'blocked:sink');
94
+ await deps.persist();
95
+ }
96
+ }
85
97
  deps.record(bgKey.applied(id, server.uuid), structureHash(subject, { primary: isPrimary }));
86
98
  await deps.persist();
87
99
  }
@@ -301,6 +313,69 @@ async function ensureLoadBalancer(deps, subject, serverUuid, name, isPrimary) {
301
313
  }
302
314
  return lbId;
303
315
  }
316
+ /**
317
+ * One Mailpit app per company beside stage, shared by every stage of the company. It is checked against the live
318
+ * app on every run: one that is stopped or gone is deployed again. `stage_sink_applied` is recorded only after
319
+ * the sink deploys; a failure clears it, logs fixed text and the error name, and returns false. Production's
320
+ * structure goes on (stage then holds no mail env and the copy stays refused).
321
+ */
322
+ async function ensureStageSink(deps, subject, serverUuid) {
323
+ const org = deps.stageSink;
324
+ if (!org)
325
+ return true;
326
+ const { coolify } = deps;
327
+ const name = stageSinkAppName(org);
328
+ try {
329
+ let appId = deps.recorded()[STAGE_SINK_APP_SCOPE_KEY];
330
+ let created = false;
331
+ if (!appId) {
332
+ appId = (await withTimeout('stage sink: find the app', SINK_CALL_TIMEOUT_MS, () => coolify.findApplicationByName(name, { environmentIds: [deps.environment.id] })))?.uuid;
333
+ }
334
+ if (!appId) {
335
+ const createImageApp = coolify.createDockerImageApplication;
336
+ if (!createImageApp)
337
+ throw new Error('the host cannot create an image app');
338
+ appId = (await withTimeout('stage sink: create the app', SINK_CALL_TIMEOUT_MS, () => createImageApp.call(coolify, {
339
+ project_uuid: deps.projectUuid,
340
+ server_uuid: serverUuid,
341
+ environment_name: deps.environment.name,
342
+ environment_uuid: deps.environment.uuid,
343
+ docker_registry_image_name: STAGE_SINK_REPOSITORY,
344
+ docker_registry_image_tag: STAGE_SINK_IMAGE_TAG,
345
+ ports_exposes: String(STAGE_SINK_HTTP_PORT),
346
+ name,
347
+ description: `mail sink for ${subject.base}'s stage`,
348
+ instant_deploy: false,
349
+ }))).uuid;
350
+ created = true;
351
+ }
352
+ // Recorded before anything else is written, so a later failure never leaves an app nothing points at.
353
+ if (deps.recorded()[STAGE_SINK_APP_SCOPE_KEY] !== appId) {
354
+ deps.record(STAGE_SINK_APP_SCOPE_KEY, appId);
355
+ await deps.persist();
356
+ }
357
+ if (created) {
358
+ await withTimeout('stage sink: set the app up', SINK_CALL_TIMEOUT_MS, () => coolify.updateApplication(appId, {
359
+ custom_internal_name: name,
360
+ ...commandHealthCheckSettings(wgetProbe(STAGE_SINK_HTTP_PORT, '/livez')),
361
+ }));
362
+ }
363
+ await ensureDeployed(deps, appId, name);
364
+ }
365
+ catch (error) {
366
+ console.error(`${deps.label}: the stage mail sink failed (${error instanceof Error ? error.name : 'unknown'}); stage gets no mail env`);
367
+ if (deps.recorded()[STAGE_SINK_APPLIED_SCOPE_KEY]) {
368
+ deps.record(STAGE_SINK_APPLIED_SCOPE_KEY, '');
369
+ await deps.persist();
370
+ }
371
+ return false;
372
+ }
373
+ if (!deps.recorded()[STAGE_SINK_APPLIED_SCOPE_KEY]) {
374
+ deps.record(STAGE_SINK_APPLIED_SCOPE_KEY, '1');
375
+ await deps.persist();
376
+ }
377
+ return true;
378
+ }
304
379
  /** 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. */
305
380
  async function ensureStage(deps, subject, serverUuid) {
306
381
  const { coolify } = deps;
@@ -368,6 +443,12 @@ async function ensureStage(deps, subject, serverUuid) {
368
443
  deps.secret(key);
369
444
  stageEnv.push({ key: ACTIONS_KEY_ENV, value: key, buildTime: true });
370
445
  }
446
+ // The sink's mail env, only once the sink app is deployed. Before that stage holds no mail env at all.
447
+ const sinkApplied = deps.stageSink && deps.recorded()[STAGE_SINK_APPLIED_SCOPE_KEY];
448
+ const mail = deps.stageSink && sinkApplied ? stageMailEnv(deps.stageSink) : [];
449
+ const stageHas = new Map((await coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => [e.key, e.value]));
450
+ const mailChanged = mail.some((e) => stageHas.get(e.key) !== e.value);
451
+ stageEnv.push(...mail);
371
452
  const stageSurface = {
372
453
  env: stageEnv.map((e) => ({ name: e.key, class: envClass(e.key), value: e.value })),
373
454
  resources: stageResources(subject),
@@ -382,6 +463,7 @@ async function ensureStage(deps, subject, serverUuid) {
382
463
  internalHosts: Object.values(prodResources(subject)),
383
464
  },
384
465
  stage: stageSurface,
466
+ sinkMail: Object.fromEntries(mail.map((e) => [e.key, e.value])),
385
467
  });
386
468
  if (violations.length > 0) {
387
469
  // Stage fails closed for itself: nothing is written to it, production's structure goes on.
@@ -423,11 +505,13 @@ async function ensureStage(deps, subject, serverUuid) {
423
505
  return;
424
506
  }
425
507
  await upsertEnv(deps, appId, stageEnv);
508
+ if (mail.length > 0)
509
+ deps.record(bgKey.stageSink(subject.id), 'applied');
426
510
  deps.record(bgKey.stageIsolation(subject.id), isolationHash);
427
511
  deps.record(bgKey.stageProbes(subject.id), probes.value);
428
512
  await deps.persist();
429
513
  const live = await coolify.getApplication(appId);
430
- if (live.status === undefined || !deps.isRunning(live.status)) {
514
+ if (mailChanged || live.status === undefined || !deps.isRunning(live.status)) {
431
515
  // Stage is a test copy: a failed build or health check must not fail production's structure apply.
432
516
  // The next apply retries it (the plan keeps the step while the state is `unhealthy`).
433
517
  let built;
@@ -54,6 +54,12 @@ export declare const bgKey: {
54
54
  stageGate: (id: string) => string;
55
55
  /** `ok:<hash>` once every apply-time probe was rejected; `notrun:<names>` or `failed:<names>` otherwise. */
56
56
  stageProbes: (id: string) => string;
57
+ /** `applied` once the stage app holds the sink's mail env. The copy refuses without it. */
58
+ stageSink: (id: string) => string;
59
+ /** `<ISO time>|<dump execution id>|ok`, written by a finished copy of production into stage. */
60
+ stageCopy: (id: string) => string;
61
+ /** The Coolify database stage ran on before the last copy, kept for `undoStageCopy` until the next copy. */
62
+ stageKept: (id: string) => string;
57
63
  };
58
64
  /** The three env names the reporting health read uses (contract C1). */
59
65
  export declare const REPORTING_ENV: {
@@ -263,6 +269,8 @@ export interface PlanBlueGreenInput {
263
269
  appPlanned: (entryKey: string) => boolean;
264
270
  /** The platform's recorded Access provider, which the stage gate requires. Absent: no gate is made. */
265
271
  accessProviderId?: string | null;
272
+ /** The company's stage mail sink is recorded and its live app is stopped or gone (read from Coolify at plan time). */
273
+ stageSinkDown?: boolean;
266
274
  }
267
275
  /**
268
276
  * Plans the blue/green parts of an org: per usable server, one structure step (the apps, the balancer, the
@@ -8,6 +8,7 @@ import { appNameFor, appStepKey } from './platform-diff.js';
8
8
  import { remoteDatabaseAppliedFor } from './remote-database.js';
9
9
  import { buildStageEnv } from './stage-env.js';
10
10
  import { assertStageIsolated, envClass } from './stage-isolation.js';
11
+ import { STAGE_SINK_VERSION } from './stage-sink.js';
11
12
  export const COLORS = ['blue', 'green'];
12
13
  /** The name of `base`'s copy in `color`: `<base>-blue`. */
13
14
  export function colorAppName(base, color) {
@@ -62,6 +63,12 @@ export const bgKey = {
62
63
  stageGate: (id) => `bg.${id}.stagegate`,
63
64
  /** `ok:<hash>` once every apply-time probe was rejected; `notrun:<names>` or `failed:<names>` otherwise. */
64
65
  stageProbes: (id) => `bg.${id}.stageprobes`,
66
+ /** `applied` once the stage app holds the sink's mail env. The copy refuses without it. */
67
+ stageSink: (id) => `bg.${id}.stagesink`,
68
+ /** `<ISO time>|<dump execution id>|ok`, written by a finished copy of production into stage. */
69
+ stageCopy: (id) => `bg.${id}.stagecopy`,
70
+ /** The Coolify database stage ran on before the last copy, kept for `undoStageCopy` until the next copy. */
71
+ stageKept: (id) => `bg.${id}.stagekept`,
65
72
  };
66
73
  /** The three env names the reporting health read uses (contract C1). */
67
74
  export const REPORTING_ENV = {
@@ -324,6 +331,7 @@ export function structureHash(subject, input) {
324
331
  database: subject.database,
325
332
  primary: input.primary,
326
333
  lb: VERSIONS.traefik,
334
+ ...(input.primary ? { stageSink: STAGE_SINK_VERSION } : {}),
327
335
  }))
328
336
  .digest('hex')
329
337
  .slice(0, 16);
@@ -498,6 +506,12 @@ export function planBlueGreen(input) {
498
506
  key: `bg:${id}:stage`,
499
507
  });
500
508
  }
509
+ else if (stageState === 'blocked:sink' || input.stageSinkDown === true) {
510
+ unmanaged.push({
511
+ description: `${org.slug}'s ${id} app: the stage mail sink is not running; stage gets no mail env and the copy is refused`,
512
+ key: `bg:${id}:stage`,
513
+ });
514
+ }
501
515
  else if (stageState?.startsWith('blocked:')) {
502
516
  unmanaged.push({
503
517
  description: `${org.slug}'s ${id} app: stage is blocked and was not written to (${stageState.slice('blocked:'.length)})`,
@@ -516,7 +530,10 @@ export function planBlueGreen(input) {
516
530
  continue;
517
531
  }
518
532
  // A stage that was not healthy is retried by running the structure again.
519
- const stageRetry = isPrimary && (stageState === 'unhealthy' || stageState?.startsWith('blocked:') === true);
533
+ const stageRetry = isPrimary &&
534
+ (stageState === 'unhealthy' ||
535
+ stageState?.startsWith('blocked:') === true ||
536
+ input.stageSinkDown === true);
520
537
  if (recordedIds[bgKey.applied(id, server)] !== hash || stageRetry) {
521
538
  steps.push({
522
539
  kind: 'bluegreen',
@@ -0,0 +1,6 @@
1
+ /** A failed outside call: fixed text and the label, never the cause (it can echo a credential). */
2
+ export declare class CallError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ /** Runs one outside call under a timeout. Any failure becomes a {@link CallError} with fixed text. */
6
+ export declare function withTimeout<T>(label: string, timeoutMs: number, call: () => Promise<T>): Promise<T>;
@@ -0,0 +1,27 @@
1
+ /** A failed outside call: fixed text and the label, never the cause (it can echo a credential). */
2
+ export class CallError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = 'CallError';
6
+ }
7
+ }
8
+ /** Runs one outside call under a timeout. Any failure becomes a {@link CallError} with fixed text. */
9
+ export async function withTimeout(label, timeoutMs, call) {
10
+ let timer;
11
+ try {
12
+ return await Promise.race([
13
+ call(),
14
+ new Promise((_, reject) => {
15
+ timer = setTimeout(() => reject(new CallError(`${label}: no answer within ${timeoutMs / 1000} seconds`)), timeoutMs);
16
+ }),
17
+ ]);
18
+ }
19
+ catch (error) {
20
+ if (error instanceof CallError)
21
+ throw error;
22
+ throw new CallError(`${label}: the call failed`);
23
+ }
24
+ finally {
25
+ clearTimeout(timer);
26
+ }
27
+ }
@@ -136,6 +136,8 @@ export interface PlanOrgOptions {
136
136
  backupStorageUuid?: string | null;
137
137
  /** Every Coolify server the host lists, for blue/green (`plan/blue-green.ts`). Absent: the host cannot list them. */
138
138
  blueGreenServers?: readonly BlueGreenServer[];
139
+ /** The company's stage mail sink is recorded and its live app is stopped or gone. */
140
+ stageSinkDown?: boolean;
139
141
  }
140
142
  export interface PlanDetail {
141
143
  orgId: OrgId;
package/dist/plan/diff.js CHANGED
@@ -491,6 +491,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
491
491
  servers: options.blueGreenServers,
492
492
  canHoldSecrets: platformReadiness.canHoldSecrets === true,
493
493
  accessProviderId: platformReadiness.accessProviderId ?? null,
494
+ ...(options.stageSinkDown ? { stageSinkDown: true } : {}),
494
495
  appPlanned: (entryKey) => plannedSteps.some((p) => p.action.system === 'coolify' &&
495
496
  p.action.kind === 'app' &&
496
497
  p.action.key === appStepKey(org, entryKey)),
@@ -2374,6 +2374,8 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2374
2374
  * blue copy is not recorded. The health token is only read here, never minted.
2375
2375
  */
2376
2376
  releaseHost(org: DeclaredOrg, subject: BlueGreenSubject, context: ReleaseContext, renew: () => Promise<void>): ReleaseHost | null;
2377
+ /** Whether the stage mail sink app is stopped or gone, read live. A failed read throws; the caller reads that as up. */
2378
+ stageSinkDown(appId: string): Promise<boolean>;
2377
2379
  /** Every server Coolify lists with whether it is usable and reachable, for the planner. */
2378
2380
  blueGreenServers(): Promise<BlueGreenServer[]>;
2379
2381
  /**
@@ -2905,6 +2905,17 @@ export class FoundryStepExecutor {
2905
2905
  label: `coolify:release:${subject.id}`,
2906
2906
  });
2907
2907
  }
2908
+ /** Whether the stage mail sink app is stopped or gone, read live. A failed read throws; the caller reads that as up. */
2909
+ async stageSinkDown(appId) {
2910
+ try {
2911
+ return !isRunningOrStarting((await this.ports.coolify.getApplication(appId)).status);
2912
+ }
2913
+ catch (error) {
2914
+ if (isNotFoundError(error))
2915
+ return true;
2916
+ throw error;
2917
+ }
2918
+ }
2908
2919
  /** Every server Coolify lists with whether it is usable and reachable, for the planner. */
2909
2920
  async blueGreenServers() {
2910
2921
  return (await this.ports.coolify.listServers()).map((s) => ({
@@ -3014,6 +3025,7 @@ export class FoundryStepExecutor {
3014
3025
  logDir: LOG_DIR,
3015
3026
  isRunning: isRunningOrStarting,
3016
3027
  stageGuards: this.stageGuards(org, subject, recorded(), action.desired.accessProviderId),
3028
+ stageSink: { slug: org.slug },
3017
3029
  };
3018
3030
  if (action.kind === 'bluegreen-lb') {
3019
3031
  await applyBlueGreenWeights(deps, subject, { uuid: serverUuid, primaryUuid });
@@ -9324,9 +9336,21 @@ export class FoundryStepExecutor {
9324
9336
  }
9325
9337
  return;
9326
9338
  }
9327
- case 'coolify:bg-database':
9339
+ case 'coolify:bg-database': {
9340
+ // Copies of production (`<base>-stage-db-copy-*`) are never all recorded; they go with the org.
9341
+ const all = await this.ports.coolify.listDatabases();
9342
+ const name = all.find((d) => d.uuid === target.id)?.name;
9343
+ const base = name ? /^(.+)-stage-db(?:-copy-.+)?$/.exec(name)?.[1] : undefined;
9344
+ if (base) {
9345
+ for (const d of all) {
9346
+ if (d.uuid !== target.id && d.name.startsWith(`${base}-stage-db-copy-`)) {
9347
+ await this.ports.coolify.deleteDatabase(d.uuid);
9348
+ }
9349
+ }
9350
+ }
9328
9351
  await this.ports.coolify.deleteDatabase(target.id);
9329
9352
  return;
9353
+ }
9330
9354
  case 'coolify:database':
9331
9355
  await this.ports.coolify.deleteDatabase(target.id);
9332
9356
  return;
@@ -0,0 +1,81 @@
1
+ import type { CoolifyPort } from './executor.js';
2
+ /** Every GitHub call is cut off after this long. */
3
+ export declare const GITHUB_API_TIMEOUT_MS = 20000;
4
+ /** A GitHub call that failed. The text is fixed: it never holds the token, the secret or a response body. */
5
+ export declare class GithubApiError extends Error {
6
+ readonly operation: string;
7
+ readonly status: number | null;
8
+ constructor(operation: string, status: number | null);
9
+ }
10
+ /** The shape of an organisation hook foundry reads. */
11
+ export interface GithubOrgHook {
12
+ id: number;
13
+ active: boolean;
14
+ events: string[];
15
+ config: {
16
+ url?: string;
17
+ content_type?: string;
18
+ };
19
+ }
20
+ export interface GithubReleaseHookBody {
21
+ name: 'web';
22
+ active: true;
23
+ events: ['release'];
24
+ config: {
25
+ url: string;
26
+ content_type: 'json';
27
+ secret: string;
28
+ insecure_ssl: '0';
29
+ };
30
+ }
31
+ export interface GithubHooksClient {
32
+ list(org: string): Promise<GithubOrgHook[]>;
33
+ create(org: string, body: GithubReleaseHookBody): Promise<GithubOrgHook>;
34
+ update(org: string, id: number, body: GithubReleaseHookBody): Promise<GithubOrgHook>;
35
+ }
36
+ /** The GitHub REST organisation-hooks calls, over `fetchImpl` (global fetch by default) with a timeout on each. */
37
+ export declare function createGithubHooksClient(options: {
38
+ token: string;
39
+ fetchImpl?: typeof fetch;
40
+ timeoutMs?: number;
41
+ }): GithubHooksClient;
42
+ export interface PlatformGithubReleaseHookDeps {
43
+ coolify: Pick<CoolifyPort, 'listEnvs' | 'bulkUpsertEnvs'>;
44
+ heldKeys: {
45
+ resolve(ref: string): Promise<string | null>;
46
+ };
47
+ /** Global fetch by default; tests give their own. */
48
+ fetchImpl?: typeof fetch;
49
+ /** Told of the token and the secret, so the host can scrub them from logs and audit rows. */
50
+ onSecret?: (value: string) => void;
51
+ }
52
+ /**
53
+ * What the planner needs: why the step is blocked (the token is not held), or `ready` (the token is
54
+ * held) with the hash of the hook's url and Archon's live secret when Archon already holds one. With no
55
+ * secret yet the step still plans: `platform:archon` mints it earlier in the same apply.
56
+ */
57
+ export declare function githubReleaseHookPlanInputs(deps: PlatformGithubReleaseHookDeps, hostname: string, recorded: Readonly<Record<string, string>>): Promise<{
58
+ blocked?: string;
59
+ ready?: true;
60
+ hash?: string;
61
+ }>;
62
+ /**
63
+ * Makes the organisation's release webhook. Adopts the hook whose url is Archon's, else creates one;
64
+ * an adopted hook is always written again, because GitHub never returns its secret.
65
+ *
66
+ * The secret is made here when Archon's app does not hold one yet (`ensureSecret`, which keeps a live
67
+ * value), so the step can run before `platform:archon`; that step then keeps the same value.
68
+ *
69
+ * A failure halfway: a hook made and not recorded is adopted by its url on the next run; a hook
70
+ * written and not recorded is written again. The id and the hash are recorded together, after the write.
71
+ */
72
+ export declare function provisionGithubReleaseHook(context: {
73
+ platform: {
74
+ archon: {
75
+ hostname: string;
76
+ };
77
+ };
78
+ recordedIds(): Record<string, string>;
79
+ recordScopeId(key: string, id: string): void;
80
+ persist?(): Promise<void>;
81
+ }, deps: PlatformGithubReleaseHookDeps, ensureSecret: (appId: string) => Promise<string>): Promise<undefined>;