@wtfalch/foundry 0.42.1 → 0.44.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/versions.d.ts +2 -0
- package/dist/index.d.ts +8 -4
- package/dist/index.js +4 -2
- package/dist/migrations/0055_release_requests.sql +20 -0
- package/dist/migrations/0056_platform_archon_blue_green.sql +7 -0
- package/dist/migrations/0057_org_customers.sql +29 -0
- package/dist/org.d.ts +55 -0
- package/dist/org.js +90 -27
- package/dist/plan/archon-blue-green.d.ts +54 -0
- package/dist/plan/archon-blue-green.js +72 -0
- package/dist/plan/blue-green-apply.d.ts +82 -0
- package/dist/plan/blue-green-apply.js +360 -0
- package/dist/plan/blue-green.d.ts +223 -0
- package/dist/plan/blue-green.js +444 -0
- package/dist/plan/boule.d.ts +43 -2
- package/dist/plan/boule.js +48 -1
- package/dist/plan/broker-entries.d.ts +28 -0
- package/dist/plan/broker-entries.js +46 -1
- package/dist/plan/cloudflared-connector.d.ts +26 -0
- package/dist/plan/cloudflared-connector.js +37 -0
- package/dist/plan/diff.d.ts +20 -0
- package/dist/plan/diff.js +207 -20
- package/dist/plan/executor.d.ts +134 -2
- package/dist/plan/executor.js +517 -25
- package/dist/plan/hostnames.d.ts +7 -0
- package/dist/plan/hostnames.js +17 -2
- package/dist/plan/locked-hostnames.d.ts +11 -0
- package/dist/plan/locked-hostnames.js +13 -0
- package/dist/plan/mcp.d.ts +57 -0
- package/dist/plan/mcp.js +131 -0
- package/dist/plan/org-names.d.ts +2 -2
- package/dist/plan/org-names.js +16 -1
- package/dist/plan/plan-apply.d.ts +77 -0
- package/dist/plan/plan-apply.js +267 -5
- package/dist/plan/platform-diff.d.ts +9 -0
- package/dist/plan/platform-diff.js +24 -1
- package/dist/plan/platform-providers.d.ts +15 -1
- package/dist/plan/platform-providers.js +90 -5
- package/dist/plan/proxy-labels.d.ts +2 -2
- package/dist/plan/proxy-labels.js +14 -4
- package/dist/plan/release-host.d.ts +59 -0
- package/dist/plan/release-host.js +239 -0
- package/dist/plan/remote-database.d.ts +124 -0
- package/dist/plan/remote-database.js +373 -0
- package/dist/plan/worker-app-database.d.ts +148 -0
- package/dist/plan/worker-app-database.js +424 -0
- package/dist/plan/worker-app.d.ts +320 -0
- package/dist/plan/worker-app.js +792 -0
- package/dist/plan/zitadel-machine-user.d.ts +13 -1
- package/dist/plan/zitadel-machine-user.js +80 -0
- package/dist/platform.d.ts +11 -1
- package/dist/platform.js +4 -1
- package/dist/release/decide.d.ts +95 -0
- package/dist/release/decide.js +242 -0
- package/dist/release/health.d.ts +48 -0
- package/dist/release/health.js +61 -0
- package/dist/release/run.d.ts +40 -0
- package/dist/release/run.js +56 -0
- package/dist/release/state.d.ts +64 -0
- package/dist/release/state.js +126 -0
- package/dist/release/types.d.ts +54 -0
- package/dist/release/types.js +4 -0
- package/dist/requested-changes.d.ts +12 -3
- package/dist/requested-changes.js +22 -0
- package/dist/store/postgres.d.ts +8 -0
- package/dist/store/postgres.js +67 -8
- package/dist/store/schema.d.ts +188 -0
- package/dist/store/schema.js +15 -0
- package/dist/worker-app-config.d.ts +151 -0
- package/dist/worker-app-config.js +438 -0
- package/package.json +1 -1
- package/versions.digests.json +2 -1
- package/versions.json +2 -1
|
@@ -18,6 +18,8 @@ export interface PinnedVersions {
|
|
|
18
18
|
busybox: string;
|
|
19
19
|
/** The sign-in database's image (#746). Its major version is the data directory's format: a bump past 16 needs a dump and restore, not an image change. */
|
|
20
20
|
postgres: string;
|
|
21
|
+
/** The load balancer of a blue/green app (`plan/blue-green.ts`): Traefik with a file provider. */
|
|
22
|
+
traefik: string;
|
|
21
23
|
}
|
|
22
24
|
export declare function loadVersions(): PinnedVersions;
|
|
23
25
|
export declare const VERSIONS: PinnedVersions;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, EntryGate, EntryVolume, HostingEntryUses, EntryAuth, EntryFiles, EntryAi, EnvValue, EnvHeldSecret, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, 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, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateSignIn, validateVersionPin, validateSite, } from './org.js';
|
|
1
|
+
export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, WorkerAppConfig, EntryGate, EntryVolume, HostingEntryUses, EntryAuth, EntryFiles, EntryAi, EnvValue, EnvHeldSecret, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, 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, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateCustomers, validateSignIn, validateVersionPin, validateSite, } from './org.js';
|
|
3
3
|
export type { DeclaredPlatform, PlatformService, PlatformServiceKey, PlatformSfuService, HeartbeatsProvider, } from './platform.js';
|
|
4
4
|
export { PLATFORM_SERVICE_KEYS, HEARTBEATS_PROVIDERS, defaultPlatformHostname, validatePlatform, } from './platform.js';
|
|
5
5
|
import { type PinnedVersions } from './bootstrap/versions.js';
|
|
6
6
|
export type { PinnedVersions };
|
|
7
7
|
export declare const PINNED_VERSIONS: Readonly<PinnedVersions>;
|
|
8
|
-
export type { RequestableField, CallsChange, StorageLimitChange, AiLimitChange, DomainChange, MailTierChange, RequestedChangeValue, ChangeClassification, RequestedChangeState, RequestedChange, } from './requested-changes.js';
|
|
8
|
+
export type { RequestableField, CallsChange, CustomersChange, StorageLimitChange, AiLimitChange, DomainChange, MailTierChange, RequestedChangeValue, ChangeClassification, RequestedChangeState, RequestedChange, } from './requested-changes.js';
|
|
9
9
|
export { REQUESTABLE_FIELDS, classifyChange, RequestedChangeError } from './requested-changes.js';
|
|
10
10
|
export type { SlugRejectionReason, SlugRejection } from './slug.js';
|
|
11
11
|
export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
|
|
@@ -16,8 +16,12 @@ export { StaleLeaseError } from './store.js';
|
|
|
16
16
|
export type { PlanApply, DestroyProgress } from './apply.js';
|
|
17
17
|
export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
|
|
18
18
|
export { createPostgresAuditLog } from './store/audit.js';
|
|
19
|
+
export type { Color, ReleaseAction, ReleaseSignal, ReleaseState, WindowState, } from './release/types.js';
|
|
20
|
+
export type { ReleaseStore } from './release/state.js';
|
|
21
|
+
export { readReleaseState, requestReleaseAction } from './release/state.js';
|
|
22
|
+
export { createPostgresReleaseStore } from './store/postgres.js';
|
|
19
23
|
export type { FoundryStoreDb } from './store/postgres.js';
|
|
20
|
-
export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, type AuditEventRow, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, type AppliedVersionRow, type NewAppliedVersionRow, type DeclaredPlatformRow, type NewDeclaredPlatformRow, type RequestedChangeRow, type NewRequestedChangeRow, } from './store/schema.js';
|
|
24
|
+
export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryReleaseRequests, foundryRequestedChanges, type AuditEventRow, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, type AppliedVersionRow, type NewAppliedVersionRow, type DeclaredPlatformRow, type NewDeclaredPlatformRow, type RequestedChangeRow, type NewRequestedChangeRow, } from './store/schema.js';
|
|
21
25
|
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';
|
|
22
26
|
export { COMPANY_DESTINATION_NAME_PREFIX, COMPANY_NETWORKS, DEFAULT_MAX_DESTINATIONS, DestinationCapacityError, PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, placed, placedOnPlatform, validateNetworkName, } from './plan/index.js';
|
|
23
27
|
export { AdoptRefusedError } 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, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, 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, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateCustomers, validateSignIn, validateVersionPin, validateSite, } from './org.js';
|
|
2
2
|
export { PLATFORM_SERVICE_KEYS, HEARTBEATS_PROVIDERS, defaultPlatformHostname, validatePlatform, } from './platform.js';
|
|
3
3
|
// archon#273: the pinned platform service versions the planner deploys, so a
|
|
4
4
|
// host can show declared against running. Readonly: it is the planner's own
|
|
@@ -10,7 +10,9 @@ export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
|
|
|
10
10
|
export { StaleLeaseError } from './store.js';
|
|
11
11
|
export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
|
|
12
12
|
export { createPostgresAuditLog } from './store/audit.js';
|
|
13
|
-
export {
|
|
13
|
+
export { readReleaseState, requestReleaseAction } from './release/state.js';
|
|
14
|
+
export { createPostgresReleaseStore } from './store/postgres.js';
|
|
15
|
+
export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryReleaseRequests, foundryRequestedChanges, } from './store/schema.js';
|
|
14
16
|
export { COMPANY_DESTINATION_NAME_PREFIX, COMPANY_NETWORKS, DEFAULT_MAX_DESTINATIONS, DestinationCapacityError, PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, placed, placedOnPlatform, validateNetworkName, } from './plan/index.js';
|
|
15
17
|
export { AdoptRefusedError } from './plan/index.js';
|
|
16
18
|
export { NetworkMoveBackupMissingError, UpgradeBackupMissingError } from './plan/index.js';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
-- @wtfalch/foundry: an operator's release action (promote, roll back, clear a
|
|
2
|
+
-- held digest, decide at the cap) waiting for the release runner. The runner
|
|
3
|
+
-- marks a row consumed in one statement, so each request is applied once.
|
|
4
|
+
-- Additive: a new table, no existing row touched. Copies 0036_applied_versions
|
|
5
|
+
-- (an appended table with no foreign key, because `org_id` may be `platform`).
|
|
6
|
+
-- Mirrors src/store/schema.ts's `foundryReleaseRequests`.
|
|
7
|
+
|
|
8
|
+
create table if not exists foundry_release_requests (
|
|
9
|
+
id bigserial primary key,
|
|
10
|
+
org_id text not null,
|
|
11
|
+
entry_key text not null,
|
|
12
|
+
action jsonb not null,
|
|
13
|
+
actor text not null,
|
|
14
|
+
created_at timestamptz not null default now(),
|
|
15
|
+
consumed_at timestamptz
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
create index if not exists foundry_release_requests_open_idx
|
|
19
|
+
on foundry_release_requests (org_id, entry_key, id)
|
|
20
|
+
where consumed_at is null;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- @wtfalch/foundry: `archon_blue_green` is `platform.archon.blueGreen` as JSON ({ steps?, stepHoldMinutes? });
|
|
2
|
+
-- null (the default) keeps platform-archon on one app. Copies 0052_platform_mail_relay.sql: one
|
|
3
|
+
-- nullable column added with `if not exists`, so it is safe to run at container start on live rows.
|
|
4
|
+
-- Mirrors src/store/schema.ts's `foundryDeclaredPlatform` table addition.
|
|
5
|
+
|
|
6
|
+
alter table foundry_declared_platform
|
|
7
|
+
add column if not exists archon_blue_green jsonb;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
-- @wtfalch/foundry: a company's customers switch (C3). `customers_enabled` gives the company a
|
|
2
|
+
-- `<slug> customers` sign-in organisation and a read-only directory credential for its Boule;
|
|
3
|
+
-- `customers_sign_up` lets people register themselves in it. Both default off.
|
|
4
|
+
-- Mirrors src/store/schema.ts's `declaredOrgs`.
|
|
5
|
+
|
|
6
|
+
alter table foundry_declared_orgs
|
|
7
|
+
add column if not exists customers_enabled boolean not null default false,
|
|
8
|
+
add column if not exists customers_sign_up boolean not null default false;
|
|
9
|
+
|
|
10
|
+
-- A company that already signs a hosting entry's users in as customers keeps working. Until now
|
|
11
|
+
-- foundry forced self-registration on for every customers organisation, so those companies carry
|
|
12
|
+
-- both switches on: the live state does not change. A company declared after this migration
|
|
13
|
+
-- starts with sign-up off.
|
|
14
|
+
update foundry_declared_orgs
|
|
15
|
+
set customers_enabled = true,
|
|
16
|
+
customers_sign_up = true
|
|
17
|
+
where jsonb_typeof(hosting_entries) = 'array'
|
|
18
|
+
and exists (
|
|
19
|
+
select 1
|
|
20
|
+
from jsonb_array_elements(hosting_entries) as entry
|
|
21
|
+
where entry -> 'uses' -> 'auth' ->> 'users' = 'customers'
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
-- Requested changes may now name `customers`.
|
|
25
|
+
alter table foundry_requested_changes
|
|
26
|
+
drop constraint if exists foundry_requested_changes_field_check;
|
|
27
|
+
alter table foundry_requested_changes
|
|
28
|
+
add constraint foundry_requested_changes_field_check
|
|
29
|
+
check (field in ('calls', 'customers', 'storageLimit', 'aiLimit', 'domain', 'mailTier'));
|
package/dist/org.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type WorkerAppConfig } from './worker-app-config.js';
|
|
2
|
+
export type { WorkerAppConfig } from './worker-app-config.js';
|
|
1
3
|
/**
|
|
2
4
|
* Opaque, minted by foundry when an org is created. Keys the declared org,
|
|
3
5
|
* the applied state, every estate-wide service's id map, and the chat
|
|
@@ -355,6 +357,23 @@ export interface HostingEntry {
|
|
|
355
357
|
* is the org's own domain.
|
|
356
358
|
*/
|
|
357
359
|
worker?: WorkerConfig;
|
|
360
|
+
/**
|
|
361
|
+
* A Cloudflare Worker with Durable Objects and Containers, hosted by foundry
|
|
362
|
+
* as an option any org can declare (ADR 0002). Exclusive with `worker` and a
|
|
363
|
+
* Coolify `repo`; `validateEntryWorkerApp` states the rest.
|
|
364
|
+
*/
|
|
365
|
+
workerApp?: WorkerAppConfig;
|
|
366
|
+
/**
|
|
367
|
+
* Blue/green releases (docs/adr/0001-blue-green-releases.md). Presence turns it on: foundry runs the app as a
|
|
368
|
+
* blue and a green copy behind a load balancer on every server it manages, and the tunnel routes to the balancer.
|
|
369
|
+
*/
|
|
370
|
+
blueGreen?: BlueGreenConfig;
|
|
371
|
+
}
|
|
372
|
+
/** The traffic steps (percent to the new color) and the hold at each step. Both default in `plan/blue-green.ts`. */
|
|
373
|
+
export interface BlueGreenConfig {
|
|
374
|
+
/** Ascending, each 1..100, ending at 100. */
|
|
375
|
+
steps?: number[];
|
|
376
|
+
stepHoldMinutes?: number;
|
|
358
377
|
}
|
|
359
378
|
/** One named volume on a hosting entry's app. */
|
|
360
379
|
export interface EntryVolume {
|
|
@@ -430,7 +449,19 @@ export interface EntryAuth {
|
|
|
430
449
|
users: 'staff' | 'customers';
|
|
431
450
|
registration: 'open' | 'closed';
|
|
432
451
|
replacesBrokerEntry?: string;
|
|
452
|
+
/**
|
|
453
|
+
* The entry is an OAuth resource server for MCP clients (auth ADR 0002): the
|
|
454
|
+
* broker is the authorization server, ZITADEL signs the person in. `path` is
|
|
455
|
+
* the MCP endpoint on the entry's host; the resource is
|
|
456
|
+
* `https://<the entry's host><path>`, derived and never declared. Needs
|
|
457
|
+
* `users: 'customers'` and `registration: 'open'`.
|
|
458
|
+
*/
|
|
459
|
+
mcp?: {
|
|
460
|
+
path: string;
|
|
461
|
+
};
|
|
433
462
|
}
|
|
463
|
+
/** The MCP endpoint path: absolute, plain characters, no dot segments, no query. */
|
|
464
|
+
export declare const MCP_PATH: RegExp;
|
|
434
465
|
/**
|
|
435
466
|
* AI for one entry: a key below the org's own ai key, the `AI_*` env and,
|
|
436
467
|
* with `webhookPath`, the ai webhook. `offerings` is what the app may call,
|
|
@@ -551,7 +582,11 @@ export declare const heldEnvRef: (orgId: string, entryKey: string, name: string)
|
|
|
551
582
|
export declare function validateHosting(hosting: HostingConfig, names?: {
|
|
552
583
|
domain: string | null;
|
|
553
584
|
locked: ReadonlySet<string>;
|
|
585
|
+
slug?: string;
|
|
586
|
+
siteOwnsApex?: boolean;
|
|
554
587
|
}): string | null;
|
|
588
|
+
/** `blueGreen`: steps ascending, each a whole number 1..100, the last 100; a hold of 1..1440 minutes. */
|
|
589
|
+
export declare function validateBlueGreen(value: unknown, at: string): string | null;
|
|
555
590
|
export declare function validateDomain(domain: string): string | null;
|
|
556
591
|
/**
|
|
557
592
|
* A broker entry name foundry writes itself: Archon's, and any company's Boule.
|
|
@@ -651,6 +686,24 @@ export interface SignInConfig {
|
|
|
651
686
|
* own sign-in stack gets the second login app.
|
|
652
687
|
*/
|
|
653
688
|
export declare function validateSignIn(org: Pick<DeclaredOrg, 'signIn' | 'domain' | 'issuer' | 'services'>): string | null;
|
|
689
|
+
/**
|
|
690
|
+
* A company's customers: people outside the company who sign in to its apps (C3, campaign
|
|
691
|
+
* 2026-10-15). `enabled` gives the company a `<slug> customers` ZITADEL organisation and a
|
|
692
|
+
* read-only directory credential for its Boule. `signUp` lets people register themselves in
|
|
693
|
+
* that organisation; it needs `enabled` and is off by default. Both are requestable (`customers`).
|
|
694
|
+
*/
|
|
695
|
+
export interface CustomersConfig {
|
|
696
|
+
enabled: boolean;
|
|
697
|
+
signUp: boolean;
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* `null` when `customers` is valid: both switches are booleans, sign-up needs customers on, and
|
|
701
|
+
* an entry with `registration: 'open'` needs both, because open registration signs people up into
|
|
702
|
+
* the customers organisation.
|
|
703
|
+
*/
|
|
704
|
+
export declare function validateCustomers(org: Pick<DeclaredOrg, 'customers'> & {
|
|
705
|
+
hosting: Pick<HostingConfig, 'entries'>;
|
|
706
|
+
}): string | null;
|
|
654
707
|
/** The only reader of `services.own`. Every company runs its own services, the platform's owner company too; a company without a domain runs them on oikos.host. */
|
|
655
708
|
export declare function ownsServices(org: Pick<DeclaredOrg, 'services'>): boolean;
|
|
656
709
|
/** The pure part of the `services` rules; that no two orgs share an `sfuSlot` is the store's check. */
|
|
@@ -933,6 +986,8 @@ export interface DeclaredOrg {
|
|
|
933
986
|
services?: ServicesConfig;
|
|
934
987
|
/** Absent means `{ inApp: false }`. Optional so every existing literal and fixture compiles unchanged. */
|
|
935
988
|
signIn?: SignInConfig;
|
|
989
|
+
/** Absent means `{ enabled: false, signUp: false }`. Optional so every existing literal and fixture compiles unchanged. */
|
|
990
|
+
customers?: CustomersConfig;
|
|
936
991
|
/** Parts of this company held on their own version. Absent or null follows the platform. Ignored for the owner company. */
|
|
937
992
|
versionPin?: VersionPin | null;
|
|
938
993
|
sso: SsoConfig;
|
package/dist/org.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { floatingRefBlocked, floatingTagBlocked } from './bootstrap/versions.js';
|
|
2
|
+
import { entryHasDatabase, validateArtifact, validateEntryWorkerApp, } from './worker-app-config.js';
|
|
2
3
|
/** {@link MailConfig.mailApps} when the org declares none of its own (#34, decided 2026-09-30). */
|
|
3
4
|
export const DEFAULT_MAIL_APPS = ['agora', 'webmail'];
|
|
4
5
|
/** {@link MailConfig.noReplyLocalPart}'s own default, restated wherever it is read rather than imported (this file's `LOG_DIR`-style convention, named in `plan/executor.ts`'s own doc comments). */
|
|
@@ -10,6 +11,8 @@ export const DEFAULT_GATE_LIMIT = 3;
|
|
|
10
11
|
export const APP_ENTRY_KEY = 'app';
|
|
11
12
|
/** The most volumes one entry may declare. */
|
|
12
13
|
export const MAX_ENTRY_VOLUMES = 8;
|
|
14
|
+
/** The MCP endpoint path: absolute, plain characters, no dot segments, no query. */
|
|
15
|
+
export const MCP_PATH = /^\/[A-Za-z0-9_~-]+(\/[A-Za-z0-9_~-]+)*$|^\/$/;
|
|
13
16
|
/** What an entry's ai key may hold: the most the org's own key holds. */
|
|
14
17
|
export const AI_ENTRY_PERMISSIONS = ['ai.offerings:invoke', 'ai.runs:create'];
|
|
15
18
|
/** The env names `uses.ai` writes; `env` may not name them. */
|
|
@@ -159,6 +162,9 @@ export function validateHosting(hosting, names) {
|
|
|
159
162
|
const invalidWorker = validateEntryWorker(entry, hosting, names?.domain);
|
|
160
163
|
if (invalidWorker)
|
|
161
164
|
return invalidWorker;
|
|
165
|
+
const invalidWorkerApp = validateEntryWorkerApp(entry, hosting, names);
|
|
166
|
+
if (invalidWorkerApp)
|
|
167
|
+
return invalidWorkerApp;
|
|
162
168
|
const invalidEdgeUse = validateEntryEdge(entry, hosting, names?.domain);
|
|
163
169
|
if (invalidEdgeUse)
|
|
164
170
|
return invalidEdgeUse;
|
|
@@ -183,7 +189,7 @@ export function validateHosting(hosting, names) {
|
|
|
183
189
|
const invalidNames = validateEntryHostnames(hosting, names);
|
|
184
190
|
if (invalidNames)
|
|
185
191
|
return invalidNames;
|
|
186
|
-
if (hosting.entries.filter(
|
|
192
|
+
if (hosting.entries.filter(entryHasDatabase).length > 1) {
|
|
187
193
|
return 'at most one hosting entry may set database: the one runtime database role cannot serve two apps';
|
|
188
194
|
}
|
|
189
195
|
const invalidGates = validateGates(hosting, names?.domain, names?.locked);
|
|
@@ -308,7 +314,10 @@ function validateEntryHostnames(hosting, names) {
|
|
|
308
314
|
return `${at} hostnames need hosting.usesDomain: without it only the '${APP_ENTRY_KEY}' entry is served, at its own address`;
|
|
309
315
|
}
|
|
310
316
|
const reads = hosting.usesDomain && names?.domain;
|
|
311
|
-
if (reads &&
|
|
317
|
+
if (reads &&
|
|
318
|
+
entry.key === APP_ENTRY_KEY &&
|
|
319
|
+
!entry.workerApp &&
|
|
320
|
+
!entry.hostnames.some((n) => n !== '*')) {
|
|
312
321
|
return `${at} needs a hostname ('@' or a label) while hosting.usesDomain is on: '*' alone names nothing, and the app would fall back to the bare domain`;
|
|
313
322
|
}
|
|
314
323
|
for (const name of entry.hostnames) {
|
|
@@ -351,6 +360,8 @@ const VOLUME_MOUNT_PATH_MAX = 255;
|
|
|
351
360
|
const REPO = /^wtfalch\/[A-Za-z0-9._-]+$/;
|
|
352
361
|
// The env names `pushAppEnv` and `pushDatabaseUrl` set themselves.
|
|
353
362
|
const FOUNDRY_ENV_NAME = /^(APP_URL|LOG_DIR|OWNER_USER_ID|AUTH_.*|DATABASE_URL.*)$/;
|
|
363
|
+
// Set only on the colors of a blueGreen entry; any other entry may declare them.
|
|
364
|
+
const BLUE_GREEN_ENV_NAME = /^REPORTING_(COLOR|RELEASE|HEALTH_TOKEN)$/;
|
|
354
365
|
const SECRET_ENV_NAME = /_(KEY|SECRET|TOKEN|PASSWORD)$/;
|
|
355
366
|
const HELD_SECRETS = "declare it as { from: 'heldSecret' }, not a plain value";
|
|
356
367
|
/** The rules for one {@link HostingEntry}; `null` when it is valid. */
|
|
@@ -392,11 +403,19 @@ function validateHostingEntry(entry, enabled) {
|
|
|
392
403
|
const invalidVolumes = validateEntryVolumes(entry, at);
|
|
393
404
|
if (invalidVolumes)
|
|
394
405
|
return invalidVolumes;
|
|
406
|
+
const invalidBlueGreen = validateBlueGreen(entry.blueGreen, at);
|
|
407
|
+
if (invalidBlueGreen)
|
|
408
|
+
return invalidBlueGreen;
|
|
409
|
+
if (entry.blueGreen !== undefined &&
|
|
410
|
+
entry.key !== APP_ENTRY_KEY &&
|
|
411
|
+
!entry.hostnames.some((name) => name !== '*')) {
|
|
412
|
+
return `${at} blueGreen needs a public hostname: the release check reaches each color through it`;
|
|
413
|
+
}
|
|
395
414
|
for (const [name, value] of Object.entries(entry.env)) {
|
|
396
415
|
if (!name) {
|
|
397
416
|
return `${at} env needs a non-empty env var name`;
|
|
398
417
|
}
|
|
399
|
-
if (FOUNDRY_ENV_NAME.test(name)) {
|
|
418
|
+
if (FOUNDRY_ENV_NAME.test(name) || (entry.blueGreen && BLUE_GREEN_ENV_NAME.test(name))) {
|
|
400
419
|
return `${at} env '${name}' is set by foundry and cannot be declared`;
|
|
401
420
|
}
|
|
402
421
|
if (typeof value !== 'string') {
|
|
@@ -424,6 +443,33 @@ function validateHostingEntry(entry, enabled) {
|
|
|
424
443
|
}
|
|
425
444
|
return null;
|
|
426
445
|
}
|
|
446
|
+
/** `blueGreen`: steps ascending, each a whole number 1..100, the last 100; a hold of 1..1440 minutes. */
|
|
447
|
+
export function validateBlueGreen(value, at) {
|
|
448
|
+
if (value === undefined)
|
|
449
|
+
return null;
|
|
450
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
451
|
+
return `${at} blueGreen must be an object`;
|
|
452
|
+
}
|
|
453
|
+
const { steps, stepHoldMinutes } = value;
|
|
454
|
+
if (steps !== undefined) {
|
|
455
|
+
if (!Array.isArray(steps) || steps.length === 0 || steps.length > 20) {
|
|
456
|
+
return `${at} blueGreen steps must be a list of 1 to 20 percentages`;
|
|
457
|
+
}
|
|
458
|
+
if (!steps.every((n) => Number.isInteger(n) && n >= 1 && n <= 100)) {
|
|
459
|
+
return `${at} blueGreen steps must be whole numbers from 1 to 100`;
|
|
460
|
+
}
|
|
461
|
+
if (!steps.every((n, i) => i === 0 || n > steps[i - 1])) {
|
|
462
|
+
return `${at} blueGreen steps must be ascending`;
|
|
463
|
+
}
|
|
464
|
+
if (steps[steps.length - 1] !== 100)
|
|
465
|
+
return `${at} blueGreen steps must end at 100`;
|
|
466
|
+
}
|
|
467
|
+
if (stepHoldMinutes !== undefined &&
|
|
468
|
+
!(Number.isInteger(stepHoldMinutes) && stepHoldMinutes >= 1 && stepHoldMinutes <= 1440)) {
|
|
469
|
+
return `${at} blueGreen stepHoldMinutes must be a whole number from 1 to 1440`;
|
|
470
|
+
}
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
427
473
|
/** `volumes`: a bounded list of named volumes at distinct absolute paths, none on the log volume's path or name,
|
|
428
474
|
* on `/app` or under a system path. */
|
|
429
475
|
function validateEntryVolumes(entry, at) {
|
|
@@ -501,6 +547,16 @@ function validateEntryAuth(entry, hosting, domain) {
|
|
|
501
547
|
if (!hasExactHostname(entry, hosting, domain)) {
|
|
502
548
|
return `${at} needs an exact hostname to sign in at`;
|
|
503
549
|
}
|
|
550
|
+
if (auth.mcp !== undefined) {
|
|
551
|
+
if (auth.users !== 'customers' || auth.registration !== 'open') {
|
|
552
|
+
return `${at} mcp needs users 'customers' and registration 'open': an MCP client signs the customer in, and only customers register themselves`;
|
|
553
|
+
}
|
|
554
|
+
if (typeof auth.mcp?.path !== 'string' ||
|
|
555
|
+
!MCP_PATH.test(auth.mcp.path) ||
|
|
556
|
+
auth.mcp.path.length > 200) {
|
|
557
|
+
return `${at} mcp.path must be an absolute path of letters, digits, '_', '~' and '-' segments, such as '/mcp'`;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
504
560
|
if (entry.key === 'agora' ||
|
|
505
561
|
entry.key === 'boule' ||
|
|
506
562
|
entry.key === 'cms' ||
|
|
@@ -511,6 +567,8 @@ function validateEntryAuth(entry, hosting, domain) {
|
|
|
511
567
|
}
|
|
512
568
|
/** With no domain the hostnames are not read (`entryHostnames`). */
|
|
513
569
|
function hasExactHostname(entry, hosting, domain) {
|
|
570
|
+
if (entry.workerApp)
|
|
571
|
+
return true; // Its host is workerApp.host, or <slug>[-<key>].oikos.host.
|
|
514
572
|
return hosting.usesDomain && domain !== null
|
|
515
573
|
? entry.hostnames.some((name) => name !== '*')
|
|
516
574
|
: entry.key === APP_ENTRY_KEY;
|
|
@@ -601,7 +659,6 @@ function workerRouteZone(route) {
|
|
|
601
659
|
return host.startsWith('*.') ? host.slice(2) : host;
|
|
602
660
|
}
|
|
603
661
|
const WORKER_ROUTE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+\/.*$/;
|
|
604
|
-
const WORKER_SHA256 = /^[0-9a-f]{64}$/;
|
|
605
662
|
/**
|
|
606
663
|
* Restated, not shared, from `plan/worker.ts`'s `WORKER_ORG_HASH_LENGTH`/
|
|
607
664
|
* `WORKER_NAME_MAX_LENGTH` -- importing them here would import that
|
|
@@ -635,29 +692,9 @@ function validateEntryWorker(entry, hosting, domain) {
|
|
|
635
692
|
if (entry.key.length > WORKER_NAME_MAX_LENGTH - WORKER_NAME_FIXED_LENGTH) {
|
|
636
693
|
return `${at} entry key is too long: the deployed Worker's name (an 8-character org hash, the key, and "-edge") must fit Cloudflare's ${WORKER_NAME_MAX_LENGTH}-character limit`;
|
|
637
694
|
}
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
}
|
|
642
|
-
catch {
|
|
643
|
-
return `${at} artifactUrl must be an absolute URL, not a bare path or relative file`;
|
|
644
|
-
}
|
|
645
|
-
if (artifactUrl.protocol !== 'https:') {
|
|
646
|
-
return `${at} artifactUrl must be an https:// URL`;
|
|
647
|
-
}
|
|
648
|
-
if (artifactUrl.username || artifactUrl.password) {
|
|
649
|
-
return `${at} artifactUrl must not carry userinfo (a signed or credentialed URL ends up stored verbatim in recorded scope ids)`;
|
|
650
|
-
}
|
|
651
|
-
if (artifactUrl.search) {
|
|
652
|
-
return `${at} artifactUrl must not carry a query string (a signed or credentialed URL ends up stored verbatim in recorded scope ids)`;
|
|
653
|
-
}
|
|
654
|
-
// `URL.hash` is '' for a bare trailing `#`, so the raw string is checked.
|
|
655
|
-
if (worker.artifactUrl.includes('#')) {
|
|
656
|
-
return `${at} artifactUrl must not carry a fragment (refused for the same reason as a query string)`;
|
|
657
|
-
}
|
|
658
|
-
if (!WORKER_SHA256.test(worker.artifactSha256)) {
|
|
659
|
-
return `${at} artifactSha256 must be 64 lowercase hex characters`;
|
|
660
|
-
}
|
|
695
|
+
const invalidArtifact = validateArtifact(at, worker.artifactUrl, worker.artifactSha256, entry.repo);
|
|
696
|
+
if (invalidArtifact)
|
|
697
|
+
return invalidArtifact;
|
|
661
698
|
if (!WORKER_ROUTE.test(worker.route)) {
|
|
662
699
|
return `${at} route must be a hostname (an optional leading '*.' label, then at least two more labels) and a path, joined by '/'`;
|
|
663
700
|
}
|
|
@@ -918,6 +955,32 @@ export function validateSignIn(org) {
|
|
|
918
955
|
}
|
|
919
956
|
return null;
|
|
920
957
|
}
|
|
958
|
+
/**
|
|
959
|
+
* `null` when `customers` is valid: both switches are booleans, sign-up needs customers on, and
|
|
960
|
+
* an entry with `registration: 'open'` needs both, because open registration signs people up into
|
|
961
|
+
* the customers organisation.
|
|
962
|
+
*/
|
|
963
|
+
export function validateCustomers(org) {
|
|
964
|
+
const customers = org.customers;
|
|
965
|
+
if (customers !== undefined) {
|
|
966
|
+
const value = customers;
|
|
967
|
+
if (typeof customers !== 'object' ||
|
|
968
|
+
customers === null ||
|
|
969
|
+
Array.isArray(customers) ||
|
|
970
|
+
typeof value?.enabled !== 'boolean' ||
|
|
971
|
+
typeof value?.signUp !== 'boolean' ||
|
|
972
|
+
Object.keys(customers).some((key) => key !== 'enabled' && key !== 'signUp')) {
|
|
973
|
+
return 'customers must be { enabled: boolean, signUp: boolean }';
|
|
974
|
+
}
|
|
975
|
+
if (value.signUp && !value.enabled)
|
|
976
|
+
return 'customers.signUp needs customers.enabled';
|
|
977
|
+
}
|
|
978
|
+
const open = org.hosting.entries.find((entry) => entry.uses?.auth?.registration === 'open');
|
|
979
|
+
if (open && !(org.customers?.enabled && org.customers.signUp)) {
|
|
980
|
+
return `hosting.entries '${open.key}' uses.auth registration 'open' needs customers.enabled and customers.signUp`;
|
|
981
|
+
}
|
|
982
|
+
return null;
|
|
983
|
+
}
|
|
921
984
|
/** The only reader of `services.own`. Every company runs its own services, the platform's owner company too; a company without a domain runs them on oikos.host. */
|
|
922
985
|
export function ownsServices(org) {
|
|
923
986
|
return org.services?.own === true;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type BlueGreenApplyDeps } from './blue-green-apply.js';
|
|
2
|
+
import { type BlueGreenSubject } from './blue-green.js';
|
|
3
|
+
import type { DeployHealthRuntime } from './deploy-health.js';
|
|
4
|
+
import type { PlatformTunnelPort } from './platform-providers.js';
|
|
5
|
+
export interface ArchonBlueGreenRun {
|
|
6
|
+
subject: BlueGreenSubject;
|
|
7
|
+
/** The owner company's id: the health token's held ref is `sharedSecretRef(ownerOrgId, ...)` like an entry's. */
|
|
8
|
+
ownerOrgId: string;
|
|
9
|
+
desired: {
|
|
10
|
+
primary: string;
|
|
11
|
+
};
|
|
12
|
+
recorded(): Readonly<Record<string, string>>;
|
|
13
|
+
record(key: string, value: string): void;
|
|
14
|
+
persist(): Promise<void>;
|
|
15
|
+
coolify: BlueGreenApplyDeps['coolify'] & {
|
|
16
|
+
listServers(): Promise<Array<{
|
|
17
|
+
uuid: string;
|
|
18
|
+
is_usable: boolean;
|
|
19
|
+
is_reachable: boolean;
|
|
20
|
+
}>>;
|
|
21
|
+
findOrCreateEnvironment(projectUuid: string, name: string): Promise<{
|
|
22
|
+
environment: {
|
|
23
|
+
id: number;
|
|
24
|
+
name: string;
|
|
25
|
+
uuid: string;
|
|
26
|
+
};
|
|
27
|
+
}>;
|
|
28
|
+
};
|
|
29
|
+
heldKeys: {
|
|
30
|
+
resolve(ref: string): Promise<string | null>;
|
|
31
|
+
put(ref: string, value: string): Promise<void>;
|
|
32
|
+
};
|
|
33
|
+
/** Registers a value for redaction before it is written anywhere. */
|
|
34
|
+
secret: (value: string) => void;
|
|
35
|
+
/** Archon's Coolify project, its blue app and its tunnel, all recorded by the archon step. */
|
|
36
|
+
projectUuid: string;
|
|
37
|
+
blueAppId: string;
|
|
38
|
+
tunnelId: string;
|
|
39
|
+
hostname: string;
|
|
40
|
+
tunnels: Pick<PlatformTunnelPort, 'putTunnelConfig'>;
|
|
41
|
+
runtime: DeployHealthRuntime;
|
|
42
|
+
sleep: (ms: number) => Promise<void>;
|
|
43
|
+
logDir: string;
|
|
44
|
+
isRunning: (status: string | undefined) => boolean;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* platform-archon on the blue/green path (`platform.archon.blueGreen`): the archon app keeps its name and is
|
|
48
|
+
* blue; green, the balancer and stage are new apps beside it on the primary server. The tunnel moves to the
|
|
49
|
+
* balancer last, only after every part of the structure is applied and running, so a first apply never has the
|
|
50
|
+
* tunnel point at a balancer that does not answer. A failure halfway leaves the apps already made, recorded by
|
|
51
|
+
* id; the structure is recorded as applied only after its last part, so the next apply repeats from there and
|
|
52
|
+
* the tunnel stays on the app until then.
|
|
53
|
+
*/
|
|
54
|
+
export declare function applyArchonBlueGreen(run: ArchonBlueGreenRun): Promise<void>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { sharedSecretRef } from '../org.js';
|
|
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';
|
|
5
|
+
/**
|
|
6
|
+
* platform-archon on the blue/green path (`platform.archon.blueGreen`): the archon app keeps its name and is
|
|
7
|
+
* blue; green, the balancer and stage are new apps beside it on the primary server. The tunnel moves to the
|
|
8
|
+
* balancer last, only after every part of the structure is applied and running, so a first apply never has the
|
|
9
|
+
* tunnel point at a balancer that does not answer. A failure halfway leaves the apps already made, recorded by
|
|
10
|
+
* id; the structure is recorded as applied only after its last part, so the next apply repeats from there and
|
|
11
|
+
* the tunnel stays on the app until then.
|
|
12
|
+
*/
|
|
13
|
+
export async function applyArchonBlueGreen(run) {
|
|
14
|
+
const { subject } = run;
|
|
15
|
+
const id = subject.id;
|
|
16
|
+
const label = `coolify:archon-bluegreen:${id}`;
|
|
17
|
+
const { up } = splitServers((await run.coolify.listServers()).map((s) => ({
|
|
18
|
+
uuid: s.uuid,
|
|
19
|
+
usable: s.is_usable,
|
|
20
|
+
reachable: s.is_reachable,
|
|
21
|
+
})));
|
|
22
|
+
const primary = run.recorded()[PRIMARY_SERVER_SCOPE_KEY] ?? up[0];
|
|
23
|
+
if (!primary || !up.includes(primary) || primary !== run.desired.primary) {
|
|
24
|
+
throw new Error(`${label}: the plan's primary server is not the usable recorded one`);
|
|
25
|
+
}
|
|
26
|
+
if (!run.recorded()[PRIMARY_SERVER_SCOPE_KEY]) {
|
|
27
|
+
run.record(PRIMARY_SERVER_SCOPE_KEY, primary);
|
|
28
|
+
await run.persist();
|
|
29
|
+
}
|
|
30
|
+
if (!run.recorded()[bgKey.weights(id)]) {
|
|
31
|
+
run.record(bgKey.weights(id), DEFAULT_WEIGHTS);
|
|
32
|
+
await run.persist();
|
|
33
|
+
}
|
|
34
|
+
const { environment } = await run.coolify.findOrCreateEnvironment(run.projectUuid, 'production');
|
|
35
|
+
const ref = sharedSecretRef(run.ownerOrgId, healthTokenName(id));
|
|
36
|
+
const deps = {
|
|
37
|
+
coolify: run.coolify,
|
|
38
|
+
recorded: run.recorded,
|
|
39
|
+
record: run.record,
|
|
40
|
+
persist: run.persist,
|
|
41
|
+
projectUuid: run.projectUuid,
|
|
42
|
+
environment,
|
|
43
|
+
healthToken: async () => {
|
|
44
|
+
const held = await run.heldKeys.resolve(ref);
|
|
45
|
+
if (held) {
|
|
46
|
+
run.secret(held);
|
|
47
|
+
return held;
|
|
48
|
+
}
|
|
49
|
+
const minted = randomBytes(32).toString('base64url');
|
|
50
|
+
run.secret(minted);
|
|
51
|
+
await run.heldKeys.put(ref, minted);
|
|
52
|
+
return minted;
|
|
53
|
+
},
|
|
54
|
+
secret: run.secret,
|
|
55
|
+
runtime: run.runtime,
|
|
56
|
+
sleep: run.sleep,
|
|
57
|
+
label,
|
|
58
|
+
primaryBlueAppId: run.blueAppId,
|
|
59
|
+
logDir: run.logDir,
|
|
60
|
+
isRunning: run.isRunning,
|
|
61
|
+
};
|
|
62
|
+
await applyBlueGreenServer(deps, subject, { uuid: primary, primaryUuid: primary });
|
|
63
|
+
// The structure is applied and every part ran: now, and only now, the tunnel moves.
|
|
64
|
+
const origin = lbOrigin(subject);
|
|
65
|
+
if (run.recorded()[ARCHON_BG_ORIGIN_SCOPE_KEY] !== origin) {
|
|
66
|
+
await run.tunnels.putTunnelConfig(run.tunnelId, {
|
|
67
|
+
ingress: [{ hostname: run.hostname, service: origin }, { service: 'http_status:404' }],
|
|
68
|
+
});
|
|
69
|
+
run.record(ARCHON_BG_ORIGIN_SCOPE_KEY, origin);
|
|
70
|
+
await run.persist();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { type BlueGreenSubject } from './blue-green.js';
|
|
2
|
+
import { ensureConnectorsOnServers } from './cloudflared-connector.js';
|
|
3
|
+
import { type DeployHealthRuntime } from './deploy-health.js';
|
|
4
|
+
import type { TunnelPort } from './edge-tunnels.js';
|
|
5
|
+
import type { CoolifyPort } from './executor.js';
|
|
6
|
+
/** The Coolify calls this module makes. */
|
|
7
|
+
type Coolify = Pick<CoolifyPort, 'findApplicationByName' | 'getApplication' | 'createGithubAppApplication' | 'createDockerfileApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deploy' | 'deployAndWait' | 'listStorages' | 'createPersistentStorage' | 'createFileStorage' | 'updateFileStorage' | 'requireGithubApp' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'raw'> & {
|
|
8
|
+
listApplicationDeployments?: CoolifyPort['listApplicationDeployments'];
|
|
9
|
+
};
|
|
10
|
+
export interface BlueGreenApplyDeps {
|
|
11
|
+
coolify: Coolify;
|
|
12
|
+
/** The ids recorded so far, and how to record one. */
|
|
13
|
+
recorded: () => Readonly<Record<string, string>>;
|
|
14
|
+
record: (key: string, value: string) => void;
|
|
15
|
+
persist: () => Promise<void>;
|
|
16
|
+
projectUuid: string;
|
|
17
|
+
environment: {
|
|
18
|
+
id: number;
|
|
19
|
+
name: string;
|
|
20
|
+
uuid: string;
|
|
21
|
+
};
|
|
22
|
+
/** The entry's one health token: held once, handed to the env writes below and nowhere else. */
|
|
23
|
+
healthToken: () => Promise<string>;
|
|
24
|
+
/** Registers a value for redaction before it is written anywhere. */
|
|
25
|
+
secret: (value: string) => void;
|
|
26
|
+
runtime: DeployHealthRuntime;
|
|
27
|
+
sleep: (ms: number) => Promise<void>;
|
|
28
|
+
/** The step's name, for errors. */
|
|
29
|
+
label: string;
|
|
30
|
+
/** The app id of the blue copy `coolify:app` made on the primary. */
|
|
31
|
+
primaryBlueAppId: string;
|
|
32
|
+
logDir: string;
|
|
33
|
+
isRunning: (status: string | undefined) => boolean;
|
|
34
|
+
}
|
|
35
|
+
/** What writing a balancer's config needs; {@link BlueGreenApplyDeps} and the release runner both have it. */
|
|
36
|
+
export interface LbConfigDeps {
|
|
37
|
+
coolify: Pick<Coolify, 'listStorages' | 'createFileStorage' | 'updateFileStorage'>;
|
|
38
|
+
recorded: () => Readonly<Record<string, string>>;
|
|
39
|
+
record: (key: string, value: string) => void;
|
|
40
|
+
persist: () => Promise<void>;
|
|
41
|
+
label: string;
|
|
42
|
+
}
|
|
43
|
+
/** Replaces the host in a connection URL; `null` when it is not one. Never throws a value. */
|
|
44
|
+
export declare function withDatabaseHost(url: string, host: string): string | null;
|
|
45
|
+
/** `postgres://user:password@host/db` for a stage runtime role: the password is minted by the caller. */
|
|
46
|
+
export declare function runtimeDatabaseUrl(ownerUrl: string, role: string, password: string): string | null;
|
|
47
|
+
/**
|
|
48
|
+
* Applies one server's blue/green structure for `subject`: green (and blue, off the primary) beside the
|
|
49
|
+
* primary's blue, the balancer with the recorded weights, and on the primary the stage app. Every color
|
|
50
|
+
* gets the primary blue's env (the database host rewritten off the primary) and its three reporting
|
|
51
|
+
* values. A failure halfway leaves the apps already made, recorded by id; the next run finds them and
|
|
52
|
+
* does the rest. Nothing is recorded as applied until every part is done.
|
|
53
|
+
*/
|
|
54
|
+
export declare function applyBlueGreenServer(deps: BlueGreenApplyDeps, subject: BlueGreenSubject, server: {
|
|
55
|
+
uuid: string;
|
|
56
|
+
primaryUuid: string;
|
|
57
|
+
}): Promise<void>;
|
|
58
|
+
/** Writes the balancer's config with the recorded weights and records them for this server. */
|
|
59
|
+
export declare function applyBlueGreenWeights(deps: LbConfigDeps, subject: BlueGreenSubject, server: {
|
|
60
|
+
uuid: string;
|
|
61
|
+
primaryUuid: string;
|
|
62
|
+
}): Promise<void>;
|
|
63
|
+
/** A connector for the org's tunnel on each given server; the primary's is the existing one. */
|
|
64
|
+
export declare function applyBlueGreenConnectors(input: {
|
|
65
|
+
coolify: Parameters<typeof ensureConnectorsOnServers>[0]['coolify'];
|
|
66
|
+
tunnels: Pick<TunnelPort, 'getTunnelToken'>;
|
|
67
|
+
tunnelId: string;
|
|
68
|
+
baseAppName: string;
|
|
69
|
+
baseScopeKey: string;
|
|
70
|
+
primaryUuid: string;
|
|
71
|
+
serverUuids: readonly string[];
|
|
72
|
+
projectUuid: string;
|
|
73
|
+
environment: {
|
|
74
|
+
id: number;
|
|
75
|
+
name: string;
|
|
76
|
+
uuid: string;
|
|
77
|
+
};
|
|
78
|
+
recorded: () => Readonly<Record<string, string>>;
|
|
79
|
+
record: (key: string, value: string) => void;
|
|
80
|
+
persist: () => Promise<void>;
|
|
81
|
+
}): Promise<void>;
|
|
82
|
+
export {};
|