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