@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.
- package/dist/bootstrap/declare.js +1 -0
- package/dist/index.d.ts +8 -3
- package/dist/index.js +5 -2
- package/dist/migrations/0060_hosting_repos.sql +7 -0
- package/dist/org.d.ts +9 -1
- package/dist/org.js +27 -0
- package/dist/plan/archon-blue-green.js +1 -0
- package/dist/plan/blue-green-apply.d.ts +9 -0
- package/dist/plan/blue-green-apply.js +86 -2
- package/dist/plan/blue-green.d.ts +8 -0
- package/dist/plan/blue-green.js +18 -1
- package/dist/plan/call-timeout.d.ts +6 -0
- package/dist/plan/call-timeout.js +27 -0
- package/dist/plan/diff.d.ts +2 -0
- package/dist/plan/diff.js +1 -0
- package/dist/plan/executor.d.ts +2 -0
- package/dist/plan/executor.js +25 -1
- package/dist/plan/github-release-hook.d.ts +81 -0
- package/dist/plan/github-release-hook.js +159 -0
- package/dist/plan/index.d.ts +7 -1
- package/dist/plan/index.js +4 -1
- package/dist/plan/live.d.ts +1 -1
- package/dist/plan/plan-apply.d.ts +2 -0
- package/dist/plan/plan-apply.js +8 -3
- package/dist/plan/platform-diff.d.ts +59 -0
- package/dist/plan/platform-diff.js +98 -1
- package/dist/plan/platform-providers.d.ts +10 -0
- package/dist/plan/platform-providers.js +71 -1
- package/dist/plan/stage-copy-port.d.ts +62 -0
- package/dist/plan/stage-copy-port.js +208 -0
- package/dist/plan/stage-copy.d.ts +140 -0
- package/dist/plan/stage-copy.js +538 -0
- package/dist/plan/stage-sink.d.ts +37 -0
- package/dist/plan/stage-sink.js +33 -0
- package/dist/store/postgres.js +13 -2
- package/dist/store/schema.d.ts +19 -0
- package/dist/store/schema.js +2 -0
- package/package.json +1 -1
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.
|
|
@@ -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
|
package/dist/plan/blue-green.js
CHANGED
|
@@ -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 &&
|
|
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
|
+
}
|
package/dist/plan/diff.d.ts
CHANGED
|
@@ -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)),
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/plan/executor.js
CHANGED
|
@@ -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>;
|