@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
|
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { isFoundryOwnedBrokerEntryName, } from '../org.js';
|
|
3
3
|
import { entryClientIdScopeKey } from './agora.js';
|
|
4
4
|
import { entryHostnames } from './hostnames.js';
|
|
5
|
+
import { loginHostnameFor } from './login-host.js';
|
|
5
6
|
import { PLATFORM_ARCHON_ORG_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, parseBrokerDeployRecord, } from './platform-diff.js';
|
|
6
7
|
import { archonOrgConflicts, brokerDeployNeeded, brokerDeployRecordValue, missingBrokerInputs, validateBrokerAppEntry, } from './platform-providers.js';
|
|
7
8
|
/**
|
|
@@ -28,6 +29,31 @@ export function brokerEntryAppliedScopeKey(entryKey) {
|
|
|
28
29
|
export function brokerEntryWarningsScopeKey(entryKey) {
|
|
29
30
|
return `broker_entry_warnings.${entryKey}`;
|
|
30
31
|
}
|
|
32
|
+
/** The ZITADEL project id that is an MCP entry's token audience, recorded by `zitadel:mcp-project`. */
|
|
33
|
+
export const entryMcpProjectScopeKey = (entryKey) => `mcp_project_id.${entryKey}`;
|
|
34
|
+
/** The marker for the entry's app having JWT access tokens and the MCP callback (`zitadel:mcp-access`). */
|
|
35
|
+
export const entryMcpAccessAppliedScopeKey = (entryKey) => `mcp_access_applied.${entryKey}`;
|
|
36
|
+
/** The instance access-token lifetime the MCP flow wants, in seconds (auth ADR 0002, 4). */
|
|
37
|
+
export const MCP_ACCESS_TOKEN_LIFETIME_SECONDS = 3600;
|
|
38
|
+
/** Records the lifetime foundry last set; the value is {@link MCP_ACCESS_TOKEN_LIFETIME_SECONDS}. */
|
|
39
|
+
export const ACCESS_TOKEN_LIFETIME_APPLIED_SCOPE_KEY = 'access_token_lifetime_applied';
|
|
40
|
+
/** The broker's callback on the org's login host. It joins the entry app's redirect URIs. */
|
|
41
|
+
export const mcpCallbackUri = (org) => `https://${loginHostnameFor(org)}/mcp-oauth/callback`;
|
|
42
|
+
/**
|
|
43
|
+
* What an entry's MCP resource is, from the one recorded project id. The
|
|
44
|
+
* broker entry (`wantedBrokerEntry`) and the app's env (`entryAuthEnv`) both
|
|
45
|
+
* read it here, so the audience the broker checks and the audience the app
|
|
46
|
+
* compares are the same value. `null` without `mcp`, or until the project
|
|
47
|
+
* exists.
|
|
48
|
+
*/
|
|
49
|
+
export function entryMcp(org, entry, recorded) {
|
|
50
|
+
const mcp = entry.uses?.auth?.mcp;
|
|
51
|
+
const projectId = recorded[entryMcpProjectScopeKey(entry.key)];
|
|
52
|
+
const host = entryHostnames(org, entry)[0];
|
|
53
|
+
if (!mcp || !projectId || !host)
|
|
54
|
+
return null;
|
|
55
|
+
return { resource: `https://${host}${mcp.path}`, projectId, loginHost: loginHostnameFor(org) };
|
|
56
|
+
}
|
|
31
57
|
/** What the app's env says about who signs in, as recorded so far. Missing values are left out. */
|
|
32
58
|
export function entryAuthEnv(org, entry, recorded, issuer) {
|
|
33
59
|
const auth = entry.uses?.auth;
|
|
@@ -39,6 +65,15 @@ export function entryAuthEnv(org, entry, recorded, issuer) {
|
|
|
39
65
|
AUTH_CLIENT_ID: recorded[entryClientIdScopeKey(entry.key)],
|
|
40
66
|
AUTH_ISSUER: issuer ?? undefined,
|
|
41
67
|
};
|
|
68
|
+
const mcp = entryMcp(org, entry, recorded);
|
|
69
|
+
const mcpValues = mcp
|
|
70
|
+
? {
|
|
71
|
+
MCP_AUDIENCE: mcp.projectId,
|
|
72
|
+
MCP_RESOURCE: mcp.resource,
|
|
73
|
+
MCP_ISSUER: `https://${mcp.loginHost}`,
|
|
74
|
+
}
|
|
75
|
+
: {};
|
|
76
|
+
Object.assign(values, mcpValues);
|
|
42
77
|
return Object.fromEntries(Object.entries(values).filter(([, value]) => Boolean(value)));
|
|
43
78
|
}
|
|
44
79
|
/** The marker `pushAppEnv` records for an entry's sign-in env; the planner compares against it. */
|
|
@@ -60,7 +95,11 @@ export function wantedBrokerEntry(org, entry, recorded) {
|
|
|
60
95
|
missing.push({ what: 'its sign-in client id', producedBy: ['zitadel', 'app'] });
|
|
61
96
|
if (!mailDomain)
|
|
62
97
|
missing.push({ what: 'the org mail domain', producedBy: ['coolify', 'mail'] });
|
|
63
|
-
|
|
98
|
+
const mcp = entryMcp(org, entry, recorded);
|
|
99
|
+
if (auth.mcp && !mcp) {
|
|
100
|
+
missing.push({ what: 'its MCP audience project id', producedBy: ['zitadel', 'mcp-project'] });
|
|
101
|
+
}
|
|
102
|
+
if (!organizationId || !clientId || !mailDomain || (auth.mcp && !mcp))
|
|
64
103
|
return { missing };
|
|
65
104
|
return {
|
|
66
105
|
want: {
|
|
@@ -71,6 +110,7 @@ export function wantedBrokerEntry(org, entry, recorded) {
|
|
|
71
110
|
from: `${org.mail.noReplyLocalPart || 'noreply'}@${mailDomain}`,
|
|
72
111
|
registration: auth.registration === 'open',
|
|
73
112
|
replaces: auth.replacesBrokerEntry ?? null,
|
|
113
|
+
...(mcp ? { mcp } : {}),
|
|
74
114
|
},
|
|
75
115
|
};
|
|
76
116
|
}
|
|
@@ -182,6 +222,11 @@ export async function writeBrokerEntry(broker, input) {
|
|
|
182
222
|
from: want.from,
|
|
183
223
|
registration: want.registration,
|
|
184
224
|
};
|
|
225
|
+
// The previous write's `mcp` goes when the entry no longer declares it.
|
|
226
|
+
if (want.mcp)
|
|
227
|
+
entry.mcp = want.mcp;
|
|
228
|
+
else
|
|
229
|
+
entry.mcp = undefined;
|
|
185
230
|
try {
|
|
186
231
|
validateBrokerAppEntry(entry, brokerAppId, 0);
|
|
187
232
|
}
|
|
@@ -76,3 +76,29 @@ export declare function ensureCloudflaredConnector(input: EnsureCloudflaredConne
|
|
|
76
76
|
appId: string;
|
|
77
77
|
deployed: boolean;
|
|
78
78
|
}>;
|
|
79
|
+
/** The scope key of a non-primary server's connector: the tunnel's own key plus the server uuid. */
|
|
80
|
+
export declare function serverConnectorScopeKey(baseScopeKey: string, serverUuid: string): string;
|
|
81
|
+
/** A non-primary server's connector app name: the tunnel's own name plus the first eight characters of the uuid. */
|
|
82
|
+
export declare function serverConnectorAppName(baseAppName: string, serverUuid: string): string;
|
|
83
|
+
export interface EnsureConnectorsOnServersInput extends Omit<EnsureCloudflaredConnectorInput, 'resolveServer' | 'recordedConnectorAppId' | 'recordAppId' | 'appName'> {
|
|
84
|
+
/** The primary's connector keeps its name and scope key, so a record made before servers were many is still its record. */
|
|
85
|
+
baseAppName: string;
|
|
86
|
+
baseScopeKey: string;
|
|
87
|
+
primaryUuid: string;
|
|
88
|
+
serverUuids: readonly string[];
|
|
89
|
+
recorded: Readonly<Record<string, string>>;
|
|
90
|
+
/** Records the app id as soon as it exists; the caller persists it. */
|
|
91
|
+
record: (key: string, appId: string) => Promise<void>;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* One connector app for the tunnel on each server, all with the tunnel's one token. Each server's connector
|
|
95
|
+
* reaches that server's own containers, so a request lands on a server with the app on it. The primary's
|
|
96
|
+
* connector is the existing one (found by its recorded id, never created again); every other server gets its
|
|
97
|
+
* own name and scope key. A failure on one server stops the loop there: the ones before it are recorded,
|
|
98
|
+
* and the next run repeats from the failed one.
|
|
99
|
+
*/
|
|
100
|
+
export declare function ensureConnectorsOnServers(input: EnsureConnectorsOnServersInput): Promise<Array<{
|
|
101
|
+
serverUuid: string;
|
|
102
|
+
appId: string;
|
|
103
|
+
deployed: boolean;
|
|
104
|
+
}>>;
|
|
@@ -119,3 +119,40 @@ async function tunnelTokenDiffers(coolify, appId, token) {
|
|
|
119
119
|
const live = (await coolify.listEnvs(appId)).find((e) => e.key === 'TUNNEL_TOKEN' && !e.is_preview);
|
|
120
120
|
return !live || live.value !== token;
|
|
121
121
|
}
|
|
122
|
+
/** The scope key of a non-primary server's connector: the tunnel's own key plus the server uuid. */
|
|
123
|
+
export function serverConnectorScopeKey(baseScopeKey, serverUuid) {
|
|
124
|
+
return `${baseScopeKey}.${serverUuid}`;
|
|
125
|
+
}
|
|
126
|
+
/** A non-primary server's connector app name: the tunnel's own name plus the first eight characters of the uuid. */
|
|
127
|
+
export function serverConnectorAppName(baseAppName, serverUuid) {
|
|
128
|
+
return `${baseAppName}-${serverUuid.slice(0, 8)}`;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* One connector app for the tunnel on each server, all with the tunnel's one token. Each server's connector
|
|
132
|
+
* reaches that server's own containers, so a request lands on a server with the app on it. The primary's
|
|
133
|
+
* connector is the existing one (found by its recorded id, never created again); every other server gets its
|
|
134
|
+
* own name and scope key. A failure on one server stops the loop there: the ones before it are recorded,
|
|
135
|
+
* and the next run repeats from the failed one.
|
|
136
|
+
*/
|
|
137
|
+
export async function ensureConnectorsOnServers(input) {
|
|
138
|
+
const results = [];
|
|
139
|
+
for (const serverUuid of input.serverUuids) {
|
|
140
|
+
const primary = serverUuid === input.primaryUuid;
|
|
141
|
+
const scopeKey = primary
|
|
142
|
+
? input.baseScopeKey
|
|
143
|
+
: serverConnectorScopeKey(input.baseScopeKey, serverUuid);
|
|
144
|
+
const { appId, deployed } = await ensureCloudflaredConnector({
|
|
145
|
+
coolify: input.coolify,
|
|
146
|
+
tunnels: input.tunnels,
|
|
147
|
+
tunnelId: input.tunnelId,
|
|
148
|
+
projectUuid: input.projectUuid,
|
|
149
|
+
environment: input.environment,
|
|
150
|
+
appName: primary ? input.baseAppName : serverConnectorAppName(input.baseAppName, serverUuid),
|
|
151
|
+
resolveServer: async () => ({ uuid: serverUuid }),
|
|
152
|
+
recordedConnectorAppId: input.recorded[scopeKey] ?? null,
|
|
153
|
+
recordAppId: (appId) => input.record(scopeKey, appId),
|
|
154
|
+
});
|
|
155
|
+
results.push({ serverUuid, appId, deployed });
|
|
156
|
+
}
|
|
157
|
+
return results;
|
|
158
|
+
}
|
package/dist/plan/diff.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { DeclaredOrg, OrgId, OrgServiceSchema } from '../org.js';
|
|
2
2
|
import type { Plan, PlanStep, UnmanagedItem, VersionChangeItem } from '../plan.js';
|
|
3
3
|
import { type AgoraHostReading } from './agora.js';
|
|
4
|
+
import { type BlueGreenServer } from './blue-green.js';
|
|
4
5
|
import type { LiveResource } from './live.js';
|
|
5
6
|
import { type OrgPlatformReadiness } from './platform-org.js';
|
|
7
|
+
import { type RemoteDatabaseFacts } from './remote-database.js';
|
|
6
8
|
/**
|
|
7
9
|
* What a {@link StepExecutor} (see `plan-apply.ts`) needs to actually make a
|
|
8
10
|
* step true, alongside the public {@link PlanStep} an operator reads.
|
|
@@ -41,6 +43,8 @@ export interface DraftStep {
|
|
|
41
43
|
* New ones go here, not in new positional arguments (rule 7).
|
|
42
44
|
*/
|
|
43
45
|
export interface PlanOrgOptions {
|
|
46
|
+
/** What the blue/green database route needs (`plan/remote-database.ts`). Absent: nothing is planned. */
|
|
47
|
+
remoteDatabase?: RemoteDatabaseFacts;
|
|
44
48
|
/** The platform's edge has the `oikos.host` zone: a company without a domain waits on it for its own services. */
|
|
45
49
|
oikosZoneReady?: boolean;
|
|
46
50
|
/** #267: the executor was built with a `loginHostBindings` port. */
|
|
@@ -49,6 +53,8 @@ export interface PlanOrgOptions {
|
|
|
49
53
|
loginHostServesDiscovery?: boolean;
|
|
50
54
|
/** The executor's ZITADEL client has `updateOidcAppIdTokenUserinfoAssertion`; without it `planIdTokenUserinfo` plans nothing. */
|
|
51
55
|
zitadelIdTokenUserinfo?: boolean;
|
|
56
|
+
/** The executor's ZITADEL client has `ensureOidcAppMcpAccess` and `ensureAccessTokenLifetime`; without them `planMcp` is blocked. */
|
|
57
|
+
zitadelMcp?: boolean;
|
|
52
58
|
/** F5: the executor was built with a `broker` port. */
|
|
53
59
|
broker?: boolean;
|
|
54
60
|
/** #161: the slugs of the other declared orgs, so a Boule whose hostname another org's slug would take is blocked. */
|
|
@@ -59,6 +65,14 @@ export interface PlanOrgOptions {
|
|
|
59
65
|
archonUrl?: string | null;
|
|
60
66
|
/** #318: the executor was built with a `WorkersPort`, read by `planWorkers`. */
|
|
61
67
|
edgeWorkers?: boolean;
|
|
68
|
+
/** ADR 0002: the executor was built with a `WorkerAppPort`, read by `planWorkerApps`. */
|
|
69
|
+
workerApps?: boolean;
|
|
70
|
+
/** ADR 0002: the keys service address, when the executor has a keys port. */
|
|
71
|
+
workerAppKeysUrl?: string;
|
|
72
|
+
/** C2: true when the executor was built with a `WorkerAppDatabasePort`; a `workerApp.database` is blocked without it. */
|
|
73
|
+
workerAppDatabase?: boolean;
|
|
74
|
+
/** ADR 0002: the platform's Cloudflare account id; a container image must live in its registry. */
|
|
75
|
+
workerAppRegistryAccountId?: string;
|
|
62
76
|
/** The executor was built with an `ownerInviteMail` port; without it each owner's invite is blocked. */
|
|
63
77
|
ownerInviteMail?: boolean;
|
|
64
78
|
/** The platform's own service hostnames: `edgeReservedLabels` reserves them under the org's domain, and a wildcard Worker route (`planWorkers`) keeps them out. */
|
|
@@ -69,6 +83,10 @@ export interface PlanOrgOptions {
|
|
|
69
83
|
ownMailKeyHeld?: boolean;
|
|
70
84
|
/** The executor can mint an own org's Lettermint key (`FoundryStepExecutor.ownMailKeyMintable`). */
|
|
71
85
|
ownMailKeyMintable?: boolean;
|
|
86
|
+
/** C3: the executor can mint a customers directory credential (`ZitadelPort.mintOrgDirectoryReader`). */
|
|
87
|
+
customersDirectory?: boolean;
|
|
88
|
+
/** C3: the executor can mint Boule's Archon credential (`FoundryStepExecutorPorts.mintBouleArchonCredential`). */
|
|
89
|
+
bouleArchonCredential?: boolean;
|
|
72
90
|
/** The executor can store the sign-in database's CA and certificate (`FoundryStepExecutor.ownSigninDbKeysPuttable`). Absent: true. */
|
|
73
91
|
ownSigninDbKeysPuttable?: boolean;
|
|
74
92
|
/** The host supplies the backup bucket the sign-in database dumps into (`FoundryStepExecutor.ownSigninDbBackupConfigured`). Absent: true. */
|
|
@@ -108,6 +126,8 @@ export interface PlanOrgOptions {
|
|
|
108
126
|
* but the storage is not registered, so each database is reported blocked.
|
|
109
127
|
*/
|
|
110
128
|
backupStorageUuid?: string | null;
|
|
129
|
+
/** Every Coolify server the host lists, for blue/green (`plan/blue-green.ts`). Absent: the host cannot list them. */
|
|
130
|
+
blueGreenServers?: readonly BlueGreenServer[];
|
|
111
131
|
}
|
|
112
132
|
export interface PlanDetail {
|
|
113
133
|
orgId: OrgId;
|
package/dist/plan/diff.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
2
2
|
import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, heldEnvRef, ownerMailboxRecorded, ownerMailboxes, ownsServices, } from '../org.js';
|
|
3
|
+
import { entryHasDatabase } from '../worker-app-config.js';
|
|
3
4
|
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, chatCallsApplied, chatHasUpstreams, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
|
|
4
|
-
import {
|
|
5
|
+
import { planBlueGreen } from './blue-green.js';
|
|
6
|
+
import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_MAIL_TOKEN_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, customersOrgIdOf, desiredBouleEnv, staleCustomersEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
5
7
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
|
|
6
8
|
import { chatInternalNames, chatUpstreams } from './chat-matrix.js';
|
|
7
9
|
import { planCompanyBackups } from './company-backups.js';
|
|
@@ -15,6 +17,7 @@ import { lockedHostnames } from './locked-hostnames.js';
|
|
|
15
17
|
import { LOGIN_BINDING_SCOPE_KEY, agoraIssuerHostFor, companyLoginTunnelHost, inAppApps, loginBindingState, loginHostClientIds, loginHostServesAgora, loginHostnameFor, ownAuthApplied, pendingRemovalHosts, } from './login-host.js';
|
|
16
18
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
|
|
17
19
|
import { mailStoreSwitchChange } from './mail-store.js';
|
|
20
|
+
import { planMcp } from './mcp.js';
|
|
18
21
|
import { hasAdoptedResources, networkMoveNeeded, planNetworkMove } from './network-move.js';
|
|
19
22
|
import { OWN_AUTH_BLOCKED_KEY, OWN_FILES_BLOCKED_KEY, OWN_FILES_R2_BLOCKED_KEY, OWN_JOBS_BLOCKED_KEY, OWN_MAIL_BLOCKED_KEY, OWN_SFU_BLOCKED_KEY, OWN_SFU_FIREWALL_KEY, OWN_SIGNIN_DB_MOVE_BLOCKED_KEY, planOwnAuth, planOwnFiles, planOwnFilesR2, planOwnJobs, planOwnMail, planOwnSfu, planSigninDbMove, } from './own-diff.js';
|
|
20
23
|
import { ownDnsHostnames } from './own-dns.js';
|
|
@@ -23,9 +26,11 @@ import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_
|
|
|
23
26
|
import { DEFAULT_ORG_PLATFORM_READINESS, } from './platform-org.js';
|
|
24
27
|
import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
|
|
25
28
|
import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryRoutes, entryRoutesMarker, } from './proxy-labels.js';
|
|
29
|
+
import { planRemoteDatabase } from './remote-database.js';
|
|
26
30
|
import { ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY, ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, signInDomainBlockedText, signInOrgDomain, } from './sign-in-org.js';
|
|
27
31
|
import { SIGN_IN_BASE_PATH, SIGN_IN_IMAGE_TAG_SUFFIX, SIGN_IN_IN_APP_SCOPE_KEY, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_LOGIN_INTERNAL_NAME, SIGN_IN_RATE_LIMIT_SCOPE_KEY, SIGN_IN_TRUSTED_SCOPE_KEY, inAppSignIn, inAppStatePairs, inAppTargets, } from './sign-in.js';
|
|
28
32
|
import { withStepSystem } from './step-system.js';
|
|
33
|
+
import { brokerKeyDelivered, planWorkerApps } from './worker-app.js';
|
|
29
34
|
import { planWorkers } from './worker.js';
|
|
30
35
|
/** The `Plan` (#19's public contract) a `PlanDetail` carries. */
|
|
31
36
|
export function toPlan(detail) {
|
|
@@ -406,6 +411,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
406
411
|
if (guarded)
|
|
407
412
|
plannedSteps.push(guarded);
|
|
408
413
|
}
|
|
414
|
+
// Auth ADR 0002: the MCP project and the JWT app, after the app they change
|
|
415
|
+
// and before the broker entry and the Worker that read the project id.
|
|
416
|
+
const mcp = planMcp(org, recordedIds, plannedSteps, options.zitadelMcp ?? false);
|
|
417
|
+
plannedSteps.push(...mcp.steps);
|
|
409
418
|
// The Access gate before the database, app, tunnel and zone (#84): on a
|
|
410
419
|
// first apply the hostname is gated before anything answers on it.
|
|
411
420
|
// An Access gate that cannot be planned must not leave the hostname it
|
|
@@ -456,6 +465,18 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
456
465
|
const edgeReservedLabels = planEdgeReservedLabels(org, recordedIds, platformReadiness, options.platformHostnames ?? []);
|
|
457
466
|
if (edgeReservedLabels.step)
|
|
458
467
|
plannedSteps.push(edgeReservedLabels.step);
|
|
468
|
+
// Blue/green (`blue-green.ts`): the apps and balancers before the tunnel routes to them, a second
|
|
469
|
+
// server's connector after the tunnel exists.
|
|
470
|
+
const blueGreen = planBlueGreen({
|
|
471
|
+
org,
|
|
472
|
+
recordedIds,
|
|
473
|
+
servers: options.blueGreenServers,
|
|
474
|
+
canHoldSecrets: platformReadiness.canHoldSecrets === true,
|
|
475
|
+
appPlanned: (entryKey) => plannedSteps.some((p) => p.action.system === 'coolify' &&
|
|
476
|
+
p.action.kind === 'app' &&
|
|
477
|
+
p.action.key === appStepKey(org, entryKey)),
|
|
478
|
+
});
|
|
479
|
+
plannedSteps.push(...blueGreen.steps.filter((x) => x.phase === 'before').map(blueGreenDraft()));
|
|
459
480
|
const tunnel = planTunnel(org, byKey, claimed, recordedIds, platformReadiness.platformApex);
|
|
460
481
|
// #238: a tunnel that is being provisioned waits for an active zone; parking
|
|
461
482
|
// one never does.
|
|
@@ -482,6 +503,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
482
503
|
else if (tunnel) {
|
|
483
504
|
plannedSteps.push(tunnel);
|
|
484
505
|
}
|
|
506
|
+
plannedSteps.push(...blueGreen.steps.filter((x) => x.phase === 'after').map(blueGreenDraft()));
|
|
485
507
|
// Right after the zone (#43): `cloudflareRateLimit` resolves the zone by
|
|
486
508
|
// apex domain, so the zone step above must already have run (or the zone
|
|
487
509
|
// must already exist live) by the time this one's action executes.
|
|
@@ -519,7 +541,11 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
519
541
|
const bouleGuarded = guardApp(bouleClient);
|
|
520
542
|
if (bouleGuarded)
|
|
521
543
|
plannedSteps.push(bouleGuarded);
|
|
522
|
-
const
|
|
544
|
+
const bouleCredentials = planBouleCredentials(org, recordedIds, {
|
|
545
|
+
archon: options.bouleArchonCredential === true,
|
|
546
|
+
directory: options.customersDirectory === true,
|
|
547
|
+
}, platformReadiness.canHoldSecrets === true, (options.now ?? Date.now)());
|
|
548
|
+
const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null, platformReadiness, bouleCredentials.wants);
|
|
523
549
|
if (boule.step)
|
|
524
550
|
plannedSteps.push(boule.step);
|
|
525
551
|
// After the apps and Boule -- an edge key needs its app recorded or planned,
|
|
@@ -549,6 +575,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
549
575
|
(jobsAvailable && !ownsServices(org)) || platformReadiness.jobsReady, platformReadiness.missingKeys.jobs, platformReadiness.noKeyReader, platformReadiness.jobsUrlMissing ?? false);
|
|
550
576
|
if (jobsRegistration.step)
|
|
551
577
|
plannedSteps.push(jobsRegistration.step);
|
|
578
|
+
const remoteDatabase = planRemoteDatabase(org, options.remoteDatabase, recordedIds);
|
|
579
|
+
if (remoteDatabase.step)
|
|
580
|
+
plannedSteps.push(remoteDatabase.step);
|
|
552
581
|
// F8 (#318), after Agora and Boule, not right after the zone: a route
|
|
553
582
|
// already bound by a hand-made entry, another entry's route, a taken
|
|
554
583
|
// script name, or no Workers token all throw at apply, and `runSteps`
|
|
@@ -574,6 +603,60 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
574
603
|
plannedSteps.push(step);
|
|
575
604
|
}
|
|
576
605
|
}
|
|
606
|
+
// ADR 0002: Worker apps, after the Workers for the same reason: a route, record
|
|
607
|
+
// or script name that is not ours throws at apply and `runSteps` stops there.
|
|
608
|
+
const workerApps = planWorkerApps(org, recordedIds, {
|
|
609
|
+
available: options.workerApps ?? false,
|
|
610
|
+
...(options.workerAppKeysUrl !== undefined ? { keysUrl: options.workerAppKeysUrl } : {}),
|
|
611
|
+
...(options.workerAppRegistryAccountId !== undefined
|
|
612
|
+
? { registryAccountId: options.workerAppRegistryAccountId }
|
|
613
|
+
: {}),
|
|
614
|
+
canHoldSecrets: platformReadiness.canHoldSecrets === true,
|
|
615
|
+
issuer: authIssuer,
|
|
616
|
+
...(options.workerAppDatabase
|
|
617
|
+
? {
|
|
618
|
+
database: {
|
|
619
|
+
databaseReady: Boolean(recordedIds[`coolify:database:${org.id}`]) ||
|
|
620
|
+
plannedSteps.some((s) => s.action.system === 'coolify' && s.action.kind === 'database'),
|
|
621
|
+
tunnelReady: Boolean(recordedIds[`cloudflare:tunnel:${org.id}`]) ||
|
|
622
|
+
plannedSteps.some((s) => s.action.system === 'cloudflare' && s.action.kind === 'tunnel'),
|
|
623
|
+
accessReady: edgeNeeded(platformReadiness, ['access', 'tunnels', 'dns', 'zones']) === null,
|
|
624
|
+
},
|
|
625
|
+
}
|
|
626
|
+
: {}),
|
|
627
|
+
});
|
|
628
|
+
for (const step of workerApps.steps) {
|
|
629
|
+
const stepEntry = step.action.key.slice(org.id.length + 1);
|
|
630
|
+
const ownZone = org.hosting.usesDomain && Boolean(org.domain);
|
|
631
|
+
const apply = step.action.desired.mode === 'apply';
|
|
632
|
+
// Retire never looks a zone up; a deploy needs the org's zone, or the shared oikos.host zone.
|
|
633
|
+
const hasDatabase = Boolean(org.hosting.entries.find((e) => e.key === stepEntry)?.workerApp?.database);
|
|
634
|
+
const reason = !apply
|
|
635
|
+
? null
|
|
636
|
+
: hasDatabase
|
|
637
|
+
? edgeNeeded(platformReadiness, ['zones', 'dns', 'access', 'tunnels'])
|
|
638
|
+
: ownZone
|
|
639
|
+
? edgeNeeded(platformReadiness, ['zones', 'dns'])
|
|
640
|
+
: edgeNeeded(platformReadiness, ['zones']);
|
|
641
|
+
if (reason) {
|
|
642
|
+
dnsBlocked.push(edgeBlockedItem(org, `${stepEntry} Worker app`, step.action.key, reason));
|
|
643
|
+
}
|
|
644
|
+
else if (apply && ownZone && zoneWaiting) {
|
|
645
|
+
zoneBlocked.push({
|
|
646
|
+
description: `${org.slug}'s ${stepEntry} Worker app is blocked, not planned: the zone for ${org.domain} is not active yet`,
|
|
647
|
+
key: step.action.key,
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
else if (apply && !ownZone && !(options.oikosZoneReady ?? false)) {
|
|
651
|
+
zoneBlocked.push({
|
|
652
|
+
description: `${org.slug}'s ${stepEntry} Worker app is blocked, not planned: the shared oikos.host zone is not ready yet`,
|
|
653
|
+
key: step.action.key,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
else {
|
|
657
|
+
plannedSteps.push(step);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
577
660
|
// Last: a repair that fails must not stop any step after it (`runSteps` stops at the first failure).
|
|
578
661
|
const idTokenUserinfo = planIdTokenUserinfo(org, live, recordedIds, plannedSteps, platformReadiness.authReady, options.zitadelIdTokenUserinfo ?? false);
|
|
579
662
|
if (idTokenUserinfo)
|
|
@@ -608,6 +691,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
608
691
|
unmanaged.push({ description: agora.issuerNote, key: 'coolify:agora' });
|
|
609
692
|
if (boule.blocked)
|
|
610
693
|
unmanaged.push({ description: boule.blocked, key: 'coolify:boule' });
|
|
694
|
+
for (const description of bouleCredentials.blocked) {
|
|
695
|
+
unmanaged.push({ description, key: 'coolify:boule:credentials' });
|
|
696
|
+
}
|
|
611
697
|
if (boule.mailBlocked) {
|
|
612
698
|
unmanaged.push({ description: boule.mailBlocked, key: 'coolify:boule:mail' });
|
|
613
699
|
}
|
|
@@ -615,9 +701,12 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
615
701
|
// orphan a second live key.
|
|
616
702
|
if (aiKey.blocked)
|
|
617
703
|
unmanaged.push({ description: aiKey.blocked, key: 'ai:key' });
|
|
704
|
+
unmanaged.push(...blueGreen.unmanaged);
|
|
618
705
|
unmanaged.push(...filesKeys.blocked);
|
|
619
706
|
unmanaged.push(...entryAiKeys.blocked);
|
|
620
707
|
unmanaged.push(...edgeWorkers.blocked);
|
|
708
|
+
unmanaged.push(...workerApps.blocked);
|
|
709
|
+
unmanaged.push(...mcp.blocked);
|
|
621
710
|
unmanaged.push(...entryEdgeKeys.blocked);
|
|
622
711
|
unmanaged.push(...edgeReservedLabels.blocked);
|
|
623
712
|
if (ownAuth.blocked)
|
|
@@ -644,6 +733,8 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
644
733
|
if (jobsRegistration.blocked) {
|
|
645
734
|
unmanaged.push({ description: jobsRegistration.blocked, key: 'jobs:registration' });
|
|
646
735
|
}
|
|
736
|
+
if (remoteDatabase.blocked)
|
|
737
|
+
unmanaged.push(remoteDatabase.blocked);
|
|
647
738
|
// #177: an org step whose own shared platform service is off or not yet
|
|
648
739
|
// applied is blocked here, not planned -- the same rule, now driven by
|
|
649
740
|
// `platformReadiness` instead of a host-supplied port. Each is reported at
|
|
@@ -740,6 +831,13 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
740
831
|
orgPostgres,
|
|
741
832
|
};
|
|
742
833
|
}
|
|
834
|
+
/** A blue/green plan step as the draft step `computePlanDetail` collects. */
|
|
835
|
+
function blueGreenDraft() {
|
|
836
|
+
return (step) => ({
|
|
837
|
+
action: { system: 'coolify', kind: step.kind, key: step.key, desired: step.desired },
|
|
838
|
+
step: { description: step.description, destroys: false, migrates: false },
|
|
839
|
+
});
|
|
840
|
+
}
|
|
743
841
|
/** Steps that run against the org's own ZITADEL instance (its projects, apps and broker entries). */
|
|
744
842
|
const NEEDS_OWN_ZITADEL = new Set([
|
|
745
843
|
'zitadel:issuer',
|
|
@@ -1274,7 +1372,7 @@ function orgServiceSchemaEnabled(org, schema) {
|
|
|
1274
1372
|
if (schema.schema.startsWith('calls_'))
|
|
1275
1373
|
return org.calls.enabled;
|
|
1276
1374
|
if (schema.schema === 'app')
|
|
1277
|
-
return org.hosting.entries.some(
|
|
1375
|
+
return org.hosting.entries.some(entryHasDatabase);
|
|
1278
1376
|
return true;
|
|
1279
1377
|
}
|
|
1280
1378
|
/**
|
|
@@ -2567,6 +2665,45 @@ function planBouleClient(org, byKey, claimed) {
|
|
|
2567
2665
|
},
|
|
2568
2666
|
};
|
|
2569
2667
|
}
|
|
2668
|
+
/**
|
|
2669
|
+
* C3: which credentials the org's Boule is given, and why one is not. Archon's credential goes to every
|
|
2670
|
+
* Boule the host can mint it for; the customers directory credential only while `customers.enabled`. Each
|
|
2671
|
+
* needs a held-key store that can put. A credential minted and never held (platform rule 1) is never minted
|
|
2672
|
+
* again: it is reported blocked.
|
|
2673
|
+
*/
|
|
2674
|
+
function planBouleCredentials(org, recordedIds, host, canHoldSecrets, nowMs) {
|
|
2675
|
+
const blocked = [];
|
|
2676
|
+
const at = `${org.slug}'s Boule`;
|
|
2677
|
+
let archonCredential = false;
|
|
2678
|
+
if (host.archon) {
|
|
2679
|
+
if (!canHoldSecrets) {
|
|
2680
|
+
blocked.push(`${at} Archon credential is blocked, not planned: it needs a held-key store that can put, and the host has none`);
|
|
2681
|
+
}
|
|
2682
|
+
else {
|
|
2683
|
+
archonCredential = true;
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
let customersDirectory = false;
|
|
2687
|
+
if (org.customers?.enabled) {
|
|
2688
|
+
if (!host.directory) {
|
|
2689
|
+
blocked.push(`${at} customers directory credential is blocked, not planned: the host supplies no ZitadelPort.mintOrgDirectoryReader`);
|
|
2690
|
+
}
|
|
2691
|
+
else if (!canHoldSecrets) {
|
|
2692
|
+
blocked.push(`${at} customers directory credential is blocked, not planned: it needs a held-key store that can put, and the host has none`);
|
|
2693
|
+
}
|
|
2694
|
+
else {
|
|
2695
|
+
customersDirectory = true;
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
return {
|
|
2699
|
+
wants: {
|
|
2700
|
+
archonCredential,
|
|
2701
|
+
customersDirectory,
|
|
2702
|
+
archonRenewDue: archonCredential && archonCredentialRenewDue(recordedIds, nowMs),
|
|
2703
|
+
},
|
|
2704
|
+
blocked,
|
|
2705
|
+
};
|
|
2706
|
+
}
|
|
2570
2707
|
/**
|
|
2571
2708
|
* #161 (Boule part): the org's own Boule, a per-org singleton keyed by the org
|
|
2572
2709
|
* id under its own `boule` kind, a separate Coolify application from the org's
|
|
@@ -2580,7 +2717,7 @@ function planBouleClient(org, byKey, claimed) {
|
|
|
2580
2717
|
* address (the one Agora signs in at); without it the Boule is blocked, not
|
|
2581
2718
|
* planned, so every other step still applies.
|
|
2582
2719
|
*/
|
|
2583
|
-
function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonUrl, readiness) {
|
|
2720
|
+
function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonUrl, readiness, credentials) {
|
|
2584
2721
|
const key = resourceKey('coolify', 'boule', org.id);
|
|
2585
2722
|
const live = byKey.get(key);
|
|
2586
2723
|
if (live)
|
|
@@ -2650,6 +2787,8 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
|
|
|
2650
2787
|
mailBlocked: mailBlocked !== null,
|
|
2651
2788
|
// Only the platform's own org's Boule gets the peer password for Archon.
|
|
2652
2789
|
platformOwner,
|
|
2790
|
+
archonCredential: credentials.archonCredential === true,
|
|
2791
|
+
customersDirectory: credentials.customersDirectory === true,
|
|
2653
2792
|
},
|
|
2654
2793
|
};
|
|
2655
2794
|
if (!live) {
|
|
@@ -2663,8 +2802,22 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
|
|
|
2663
2802
|
};
|
|
2664
2803
|
}
|
|
2665
2804
|
const liveEnv = (live.config.env ?? {});
|
|
2666
|
-
const { env } = desiredBouleEnv(org, recordedIds, issuer, archonUrl, mailOn);
|
|
2805
|
+
const { env } = desiredBouleEnv(org, recordedIds, issuer, archonUrl, mailOn, credentials);
|
|
2667
2806
|
const drift = bouleEnvDrift(env, liveEnv);
|
|
2807
|
+
// Secrets are compared by presence only. A switched-off customers org takes its env away.
|
|
2808
|
+
if (credentials.archonCredential &&
|
|
2809
|
+
(!liveEnv[BOULE_ARCHON_CREDENTIAL_ENV] || credentials.archonRenewDue)) {
|
|
2810
|
+
drift.push(BOULE_ARCHON_CREDENTIAL_ENV);
|
|
2811
|
+
}
|
|
2812
|
+
if (credentials.customersDirectory &&
|
|
2813
|
+
customersOrgIdOf(org, recordedIds) &&
|
|
2814
|
+
!liveEnv[BOULE_CUSTOMERS_CREDENTIAL_ENV]) {
|
|
2815
|
+
drift.push(BOULE_CUSTOMERS_CREDENTIAL_ENV);
|
|
2816
|
+
}
|
|
2817
|
+
for (const key of staleCustomersEnv(org, liveEnv)) {
|
|
2818
|
+
if (!drift.includes(key))
|
|
2819
|
+
drift.push(key);
|
|
2820
|
+
}
|
|
2668
2821
|
// The token is compared, never planned with its value.
|
|
2669
2822
|
if (mailOn &&
|
|
2670
2823
|
bouleMailTokenDrift(liveEnv[BOULE_MAIL_TOKEN_ENV], live.config.appMailToken)) {
|
|
@@ -3155,6 +3308,9 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
|
|
|
3155
3308
|
if (!resource || (typeof status === 'string' && /^(exited|stopped)/.test(status)))
|
|
3156
3309
|
continue;
|
|
3157
3310
|
}
|
|
3311
|
+
// A Worker app has no Coolify app: `planWorkerApps` plans it.
|
|
3312
|
+
if (org.hosting.entries.some((e) => e.key === key && e.workerApp))
|
|
3313
|
+
continue;
|
|
3158
3314
|
const app = planApp(org, key, byKey, claimed, recordedIds, plannedSteps, authIssuer, readiness);
|
|
3159
3315
|
// The executor pushes the issuer the plan read, the way Agora gets its host values.
|
|
3160
3316
|
// The same availability the plan's marker used: the executor records its
|
|
@@ -3294,7 +3450,7 @@ function planJobsRegistration(org, recordedIds, jobsAvailable, missingKeyRef = n
|
|
|
3294
3450
|
* {@link planApp}.
|
|
3295
3451
|
*/
|
|
3296
3452
|
function planDatabase(org, byKey, claimed) {
|
|
3297
|
-
return planHostingToggle('coolify', 'database', org.hosting.entries.some(
|
|
3453
|
+
return planHostingToggle('coolify', 'database', org.hosting.entries.some(entryHasDatabase), org, byKey, claimed, () => `provision a database for ${org.slug}'s app`, () => `park ${org.slug}'s database (data is retained, not deleted -- #7 corrections 1)`);
|
|
3298
3454
|
}
|
|
3299
3455
|
function sortedNames(names) {
|
|
3300
3456
|
return names.filter(Boolean).sort().join(',');
|
|
@@ -3336,7 +3492,7 @@ function planTunnel(org, byKey, claimed, recordedIds, platformApex) {
|
|
|
3336
3492
|
(org.calls.enabled && org.calls.usesDomain && Boolean(org.domain)) ||
|
|
3337
3493
|
Boolean(org.domain) ||
|
|
3338
3494
|
// A company without a domain still gets a tunnel of its own for an entry at `<slug>.oikos.host`.
|
|
3339
|
-
entryRoutes(org).length > 0;
|
|
3495
|
+
entryRoutes(org, recordedIds).length > 0;
|
|
3340
3496
|
const toggled = planHostingToggle('cloudflare', 'tunnel', desired, org, byKey, claimed, () => `provision a Cloudflare tunnel for ${org.slug}'s app`, () => `park ${org.slug}'s Cloudflare tunnel (configuration is retained, not deleted -- #7 corrections 1)`, org.id,
|
|
3341
3497
|
// Cloudflare's status, read against what is declared: a park cannot change
|
|
3342
3498
|
// it, so judging on/off by status alone would never let the plan empty.
|
|
@@ -3393,7 +3549,8 @@ function planTunnel(org, byKey, claimed, recordedIds, platformApex) {
|
|
|
3393
3549
|
// An entry's hostname goes straight to its container, so a changed port, key or name, or an ingress
|
|
3394
3550
|
// written before entries were routed that way, needs the ingress written again. Nothing recorded
|
|
3395
3551
|
// equals no routes.
|
|
3396
|
-
const routesStale = (recordedIds[CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY] ?? '[]') !==
|
|
3552
|
+
const routesStale = (recordedIds[CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY] ?? '[]') !==
|
|
3553
|
+
entryRoutesMarker(org, recordedIds);
|
|
3397
3554
|
if (!connectorMissing && !dnsStale && !routesStale)
|
|
3398
3555
|
return null;
|
|
3399
3556
|
return {
|
|
@@ -3606,6 +3763,15 @@ function planBrokerEntries(org, recordedIds, plannedSteps, brokerWired) {
|
|
|
3606
3763
|
});
|
|
3607
3764
|
continue;
|
|
3608
3765
|
}
|
|
3766
|
+
// A Worker app has no Coolify app to hold the broker key: its generated AUTH_APP_KEY
|
|
3767
|
+
// is delivered to the Worker first and read back from the held-key store.
|
|
3768
|
+
if (entry.workerApp && !brokerKeyDelivered(recordedIds, entry.key)) {
|
|
3769
|
+
blocked.push({
|
|
3770
|
+
description: `${at} is blocked, not planned: the Worker app's AUTH_APP_KEY is not delivered yet`,
|
|
3771
|
+
key,
|
|
3772
|
+
});
|
|
3773
|
+
continue;
|
|
3774
|
+
}
|
|
3609
3775
|
const wanted = wantedBrokerEntry(org, entry, recordedIds);
|
|
3610
3776
|
if ('want' in wanted) {
|
|
3611
3777
|
if (recordedIds[brokerEntryAppliedScopeKey(entry.key)] === JSON.stringify(wanted.want)) {
|
|
@@ -3714,23 +3880,44 @@ function planBouleBrokerEntry(org, recordedIds, plannedSteps, brokerWired) {
|
|
|
3714
3880
|
* listed as unmanaged.
|
|
3715
3881
|
*/
|
|
3716
3882
|
function planCustomers(org, live, claimed, recordedIds) {
|
|
3717
|
-
|
|
3718
|
-
|
|
3883
|
+
const signUp = org.customers?.signUp === true;
|
|
3884
|
+
const wanted = org.customers?.enabled === true ||
|
|
3885
|
+
org.hosting.entries.some((entry) => entry.uses?.auth?.users === 'customers');
|
|
3719
3886
|
const customersOrgId = recordedIds[resourceKey('zitadel', 'customers', org.id)];
|
|
3720
|
-
if (customersOrgId) {
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3887
|
+
if (!customersOrgId) {
|
|
3888
|
+
if (!wanted)
|
|
3889
|
+
return null;
|
|
3890
|
+
return {
|
|
3891
|
+
action: { system: 'zitadel', kind: 'customers', key: org.id, desired: { signUp } },
|
|
3892
|
+
step: {
|
|
3893
|
+
description: `provision a ZITADEL org for ${org.slug}'s customers, with self-registration ${signUp ? 'on' : 'off'}`,
|
|
3894
|
+
destroys: false,
|
|
3895
|
+
migrates: false,
|
|
3896
|
+
},
|
|
3897
|
+
};
|
|
3898
|
+
}
|
|
3899
|
+
for (const r of live) {
|
|
3900
|
+
if (r.system === 'zitadel' &&
|
|
3901
|
+
r.kind === 'project_grant' &&
|
|
3902
|
+
r.config.grantedOrgId === customersOrgId) {
|
|
3903
|
+
claimed.add(resourceKey(r.system, r.kind, r.key));
|
|
3727
3904
|
}
|
|
3728
|
-
return null;
|
|
3729
3905
|
}
|
|
3906
|
+
// Before the sign-up switch existed foundry forced self-registration on, so a customers org with no
|
|
3907
|
+
// marker is on. Switching customers off closes it: the declaration says no one signs themselves up.
|
|
3908
|
+
const applied = recordedIds[CUSTOMERS_SIGN_UP_SCOPE_KEY] || '1';
|
|
3909
|
+
const wantedSignUp = wanted && signUp ? '1' : '0';
|
|
3910
|
+
if (applied === wantedSignUp)
|
|
3911
|
+
return null;
|
|
3730
3912
|
return {
|
|
3731
|
-
action: {
|
|
3913
|
+
action: {
|
|
3914
|
+
system: 'zitadel',
|
|
3915
|
+
kind: 'customers',
|
|
3916
|
+
key: org.id,
|
|
3917
|
+
desired: { signUp: wantedSignUp === '1' },
|
|
3918
|
+
},
|
|
3732
3919
|
step: {
|
|
3733
|
-
description: `
|
|
3920
|
+
description: `turn self-registration ${wantedSignUp === '1' ? 'on' : 'off'} for ${org.slug}'s customers`,
|
|
3734
3921
|
destroys: false,
|
|
3735
3922
|
migrates: false,
|
|
3736
3923
|
},
|