@wtfalch/foundry 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap/declare.js +8 -6
- package/dist/index.d.ts +5 -3
- package/dist/index.js +3 -2
- package/dist/plan/agora.d.ts +11 -0
- package/dist/plan/agora.js +13 -0
- package/dist/plan/chat-matrix.d.ts +3 -0
- package/dist/plan/chat-matrix.js +2 -0
- package/dist/plan/cloudflared-connector.d.ts +3 -2
- package/dist/plan/cloudflared-connector.js +2 -2
- package/dist/plan/diff.js +26 -5
- package/dist/plan/edge-access.d.ts +68 -0
- package/dist/plan/edge-access.js +123 -0
- package/dist/plan/edge-dns.d.ts +89 -0
- package/dist/plan/edge-dns.js +227 -0
- package/dist/plan/edge-ratelimits.d.ts +54 -0
- package/dist/plan/edge-ratelimits.js +79 -0
- package/dist/plan/edge-tunnels.d.ts +44 -0
- package/dist/plan/edge-tunnels.js +108 -0
- package/dist/plan/edge-workers.d.ts +31 -0
- package/dist/plan/edge-workers.js +165 -0
- package/dist/plan/edge-zones.d.ts +37 -0
- package/dist/plan/edge-zones.js +64 -0
- package/dist/plan/entry-edge-key.d.ts +10 -0
- package/dist/plan/entry-edge-key.js +38 -8
- package/dist/plan/executor.d.ts +124 -143
- package/dist/plan/executor.js +288 -89
- package/dist/plan/index.d.ts +15 -3
- package/dist/plan/index.js +8 -2
- package/dist/plan/plan-apply.js +5 -1
- package/dist/plan/platform-diff.d.ts +9 -0
- package/dist/plan/platform-diff.js +32 -8
- package/dist/plan/platform-providers.d.ts +53 -6
- package/dist/plan/platform-providers.js +87 -44
- package/dist/plan/providers-readers.d.ts +23 -1
- package/dist/plan/providers-readers.js +29 -0
- package/package.json +2 -1
package/dist/plan/executor.js
CHANGED
|
@@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
2
2
|
import { URL } from 'node:url';
|
|
3
3
|
import { CALLS_SFU_PRIVATE_URL, CALLS_SFU_PUBLIC_URL } from '../calls-sfu.js';
|
|
4
4
|
import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, sharedSecretRef, } from '../org.js';
|
|
5
|
-
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
|
|
5
|
+
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatIntrospectionAppName, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
|
|
6
6
|
import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
7
7
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
|
|
8
8
|
import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
@@ -10,8 +10,13 @@ import { CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOC
|
|
|
10
10
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
11
11
|
import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
|
|
12
12
|
import { DEFAULT_AI_LIMIT_MICROS } from './diff.js';
|
|
13
|
+
import { createEdgeAccess, } from './edge-access.js';
|
|
14
|
+
import { EDGE_DNS_WRITE_CONCURRENCY, createEdgeDns, limiter, } from './edge-dns.js';
|
|
15
|
+
import { createEdgeRateLimits, } from './edge-ratelimits.js';
|
|
16
|
+
import { createEdgeTunnels } from './edge-tunnels.js';
|
|
17
|
+
import { createEdgeZones } from './edge-zones.js';
|
|
13
18
|
import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScopeKey, entryAiExpiresScopeKey, entryAiPendingExpiresScopeKey, entryAiRetiredScopeKey, entryAiUndeliveredScopeKey, entryAiUnrevokedScopeKey, entryAiWebhookPendingScopeKey, entryAiWrittenScopeKey, parseIdList, } from './entry-ai-key.js';
|
|
14
|
-
import { EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS, entryEdgeMintedAtScopeKey, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
|
|
19
|
+
import { BOULE_EDGE_ENTRY_KEY, EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS, edgeKeyAppRecordKey, entryEdgeMintedAtScopeKey, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
|
|
15
20
|
import { FILES_AGORA_KEY_SCOPE_KEY, FILES_AGORA_UNDELIVERED_SCOPE_KEY, FILES_KEY_DAYS, FILES_KEY_RENEW_WINDOW_DAYS, FILES_ORG_OWNER_SCOPE_KEY, FILES_ROOT_EXPIRES_SCOPE_KEY, FILES_ROOT_PREVIOUS_SCOPE_KEY, FILES_ROOT_UNDELIVERED_SCOPE_KEY, FILES_ROOT_UNREVOKED_SCOPE_KEY, entryFilesAppliedScopeKey, entryFilesExpiresScopeKey, entryFilesMinterScopeKey, entryFilesPendingExpiresScopeKey, entryFilesRetiredScopeKey, entryFilesUndeliveredScopeKey, entryFilesUnrevokedScopeKey, entryFilesWrittenScopeKey, filesOwnerUserId, parseIdList as parseFilesIdList, } from './entry-files-key.js';
|
|
16
21
|
import { appHostname, coolifyDomains, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
|
|
17
22
|
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
@@ -479,6 +484,16 @@ const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
|
|
|
479
484
|
const AI_KEY_UNDELIVERED_SCOPE_KEY = 'ai_key_undelivered';
|
|
480
485
|
/** How many times `aiKey` tries `deliverSecret` before giving up. */
|
|
481
486
|
const AI_KEY_DELIVERY_ATTEMPTS = 3;
|
|
487
|
+
/**
|
|
488
|
+
* #515: the limit `aiBudget` last applied, a flat scope id (no `:`) alongside
|
|
489
|
+
* `AI_KEY_UNDELIVERED_SCOPE_KEY`'s own. AI's `updateBudget` offers no read
|
|
490
|
+
* back, so nothing else can tell `diff.ts`'s `planAiBudget` the limit is
|
|
491
|
+
* already live -- without this, every plan for an applied org proposed
|
|
492
|
+
* setting it again. `providers-readers.ts`'s `ProvidersAiReader` keeps its
|
|
493
|
+
* own copy of this literal, the same "restated, not shared" choice this
|
|
494
|
+
* codebase already makes for `cloudflare_zone`/`zitadel_org`.
|
|
495
|
+
*/
|
|
496
|
+
const AI_BUDGET_LIMIT_SCOPE_KEY = 'ai_budget_limit_micros';
|
|
482
497
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
483
498
|
/** What a failed files call says about itself: its HTTP status, never its message, which can echo a value. */
|
|
484
499
|
function filesErrorCause(error) {
|
|
@@ -640,6 +655,12 @@ export const databaseStartWait = {
|
|
|
640
655
|
// ---------------------------------------------------------------------------
|
|
641
656
|
export class FoundryStepExecutor {
|
|
642
657
|
ports;
|
|
658
|
+
edgeDns;
|
|
659
|
+
edgeTunnels;
|
|
660
|
+
edgeAccess;
|
|
661
|
+
edgeRateLimits;
|
|
662
|
+
edgeZones;
|
|
663
|
+
edgeWriteSlot;
|
|
643
664
|
constructor(ports) {
|
|
644
665
|
this.ports = ports;
|
|
645
666
|
}
|
|
@@ -686,7 +707,7 @@ export class FoundryStepExecutor {
|
|
|
686
707
|
case 'ai:organisation':
|
|
687
708
|
return this.aiOrganisation(action, org);
|
|
688
709
|
case 'ai:budget':
|
|
689
|
-
return this.aiBudget(action, org);
|
|
710
|
+
return this.aiBudget(action, org, context);
|
|
690
711
|
case 'ai:key':
|
|
691
712
|
// The org's own key is keyed `<orgId>`, an entry's `<orgId>:<entry>` (F6a).
|
|
692
713
|
return action.key.includes(':')
|
|
@@ -774,7 +795,7 @@ export class FoundryStepExecutor {
|
|
|
774
795
|
// `everyone` is Cloudflare's own shape for "match every visitor".
|
|
775
796
|
return { decision: 'bypass', include: [{ everyone: {} }] };
|
|
776
797
|
case 'service-token': {
|
|
777
|
-
const token = await this.
|
|
798
|
+
const token = await this.accessPort().findServiceTokenByName(rule.serviceTokenName);
|
|
778
799
|
if (!token) {
|
|
779
800
|
throw new Error(`cloudflare:access for ${org.slug}: no Cloudflare Access service token named '${rule.serviceTokenName}'`);
|
|
780
801
|
}
|
|
@@ -788,7 +809,7 @@ export class FoundryStepExecutor {
|
|
|
788
809
|
*/
|
|
789
810
|
async cloudflareAccessApp(org, opts) {
|
|
790
811
|
const { appName, domain, policyName, policy } = opts;
|
|
791
|
-
const cf = this.
|
|
812
|
+
const cf = this.accessPort();
|
|
792
813
|
const onDomain = (await cf.listAccessApps()).filter((a) => a.domain === domain);
|
|
793
814
|
const foreign = onDomain.find((a) => a.name !== appName);
|
|
794
815
|
if (foreign) {
|
|
@@ -812,12 +833,10 @@ export class FoundryStepExecutor {
|
|
|
812
833
|
* gate), so `org.domain` is set and that zone is the org's alone, never
|
|
813
834
|
* shared with another org the way `oikos.host` is.
|
|
814
835
|
*
|
|
815
|
-
* Rate limiting rules
|
|
816
|
-
*
|
|
817
|
-
*
|
|
818
|
-
*
|
|
819
|
-
* `rules[]` and matching `RATE_LIMIT_RULE_DESCRIPTION`, the same shape
|
|
820
|
-
* `cloudflareAccess` above finds its own Access app by name. The Free
|
|
836
|
+
* Rate limiting rules go through edge (`RateLimitPort`) -- so "find by
|
|
837
|
+
* name" here means listing the zone's rules and matching
|
|
838
|
+
* `RATE_LIMIT_RULE_DESCRIPTION`, the same shape `cloudflareAccess` above
|
|
839
|
+
* finds its own Access app by name. The Free
|
|
821
840
|
* plan allows exactly one rule per zone, so a rule already there under a
|
|
822
841
|
* different description is refused, never adopted or replaced, mirroring
|
|
823
842
|
* `cloudflareAccess`'s own refusal when a foreign Access app already
|
|
@@ -827,40 +846,33 @@ export class FoundryStepExecutor {
|
|
|
827
846
|
if (!org.domain) {
|
|
828
847
|
throw new Error(`cloudflare:ratelimit for ${org.slug}: hosting.usesDomain is set but domain is null`);
|
|
829
848
|
}
|
|
830
|
-
const cf = this.
|
|
849
|
+
const cf = this.zonePort();
|
|
831
850
|
const zone = await cf.findZoneByApex(org.domain);
|
|
832
851
|
if (!zone) {
|
|
833
852
|
throw new Error(`cloudflare:ratelimit for ${org.slug}: no Cloudflare zone found for ${org.domain} -- cloudflare:zone must provision it first`);
|
|
834
853
|
}
|
|
835
|
-
const
|
|
836
|
-
const
|
|
837
|
-
const rules = existing?.rules ?? [];
|
|
854
|
+
const rateLimits = this.rateLimitPort();
|
|
855
|
+
const rules = await rateLimits.listRateLimitRules(zone.id);
|
|
838
856
|
const own = rules.find((r) => r.description === RATE_LIMIT_RULE_DESCRIPTION);
|
|
839
|
-
if (own)
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
}
|
|
845
|
-
const updated = await cf.request('PUT', path, {
|
|
846
|
-
rules: [
|
|
847
|
-
{
|
|
848
|
-
description: RATE_LIMIT_RULE_DESCRIPTION,
|
|
849
|
-
expression: SIGN_IN_PATH_EXPRESSION,
|
|
850
|
-
action: 'block',
|
|
851
|
-
ratelimit: {
|
|
852
|
-
characteristics: ['cf.colo.id', 'ip.src'],
|
|
853
|
-
period: RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS,
|
|
854
|
-
requests_per_period: RATE_LIMIT_REQUESTS_PER_PERIOD,
|
|
855
|
-
mitigation_timeout: RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS,
|
|
856
|
-
},
|
|
857
|
-
},
|
|
858
|
-
],
|
|
859
|
-
});
|
|
860
|
-
const created = updated.rules?.[0];
|
|
861
|
-
if (!created) {
|
|
862
|
-
throw new Error(`cloudflare:ratelimit for ${org.slug}: PUT ${path} did not return the created rule`);
|
|
857
|
+
if (!own) {
|
|
858
|
+
const foreign = rules[0];
|
|
859
|
+
if (foreign) {
|
|
860
|
+
throw new Error(`cloudflare:ratelimit for ${org.slug}: zone ${org.domain} already has a rate limiting rule ('${foreign.description ?? foreign.id}') foundry did not create; the Free plan allows only one rule per zone, so foundry does not adopt or replace it`);
|
|
861
|
+
}
|
|
863
862
|
}
|
|
863
|
+
// Edge adopts a live rule with no edge row (made before edge owned rate
|
|
864
|
+
// limits) and then makes the rule once: a repeat returns it unchanged.
|
|
865
|
+
const created = await rateLimits.createRateLimitRule(zone.id, {
|
|
866
|
+
description: RATE_LIMIT_RULE_DESCRIPTION,
|
|
867
|
+
expression: SIGN_IN_PATH_EXPRESSION,
|
|
868
|
+
action: 'block',
|
|
869
|
+
ratelimit: {
|
|
870
|
+
characteristics: ['cf.colo.id', 'ip.src'],
|
|
871
|
+
period: RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS,
|
|
872
|
+
requests_per_period: RATE_LIMIT_REQUESTS_PER_PERIOD,
|
|
873
|
+
mitigation_timeout: RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS,
|
|
874
|
+
},
|
|
875
|
+
});
|
|
864
876
|
return { resourceId: created.id };
|
|
865
877
|
}
|
|
866
878
|
/**
|
|
@@ -913,7 +925,7 @@ export class FoundryStepExecutor {
|
|
|
913
925
|
if (!org.domain) {
|
|
914
926
|
throw new Error(`${at}: worker needs org.domain, and validateHosting already refuses it without one`);
|
|
915
927
|
}
|
|
916
|
-
const zone = await this.
|
|
928
|
+
const zone = await this.zonePort().findZoneByApex(org.domain);
|
|
917
929
|
if (!zone) {
|
|
918
930
|
throw new Error(`${at}: no Cloudflare zone found for ${org.domain} -- cloudflare:zone must provision it first`);
|
|
919
931
|
}
|
|
@@ -1066,7 +1078,7 @@ export class FoundryStepExecutor {
|
|
|
1066
1078
|
throw new Error(`${at}: a route is recorded but its zone is not, so foundry cannot tell which zone holds it; refuses to forget it`);
|
|
1067
1079
|
}
|
|
1068
1080
|
const { id, apex } = JSON.parse(recordedZone);
|
|
1069
|
-
const zone = await this.
|
|
1081
|
+
const zone = await this.zonePort().findZoneByApex(apex);
|
|
1070
1082
|
return zone && zone.id === id ? id : undefined;
|
|
1071
1083
|
}
|
|
1072
1084
|
/** Deletes the route an interrupted apply was creating, if `findRoute` shows it exists, points at that apply's script, and is not `keepRouteId`. */
|
|
@@ -2481,11 +2493,11 @@ export class FoundryStepExecutor {
|
|
|
2481
2493
|
scopedProjectIds.push(platformLettermintProjectId);
|
|
2482
2494
|
}
|
|
2483
2495
|
await this.ports.lettermint.restrictDomainToProjects(domain.id, scopedProjectIds);
|
|
2484
|
-
const zone = await this.
|
|
2496
|
+
const zone = await this.zonePort().findZoneByApex(mailDomain);
|
|
2485
2497
|
if (!zone) {
|
|
2486
2498
|
throw new Error(`Lettermint for ${org.slug}: no Cloudflare zone found for ${mailDomain}`);
|
|
2487
2499
|
}
|
|
2488
|
-
await this.publishLettermintDnsRecords(zone.
|
|
2500
|
+
await this.publishLettermintDnsRecords(zone.name, domain.dnsRecords);
|
|
2489
2501
|
await this.ports.lettermint.verifyDomain(domain.id);
|
|
2490
2502
|
}
|
|
2491
2503
|
/**
|
|
@@ -3079,6 +3091,74 @@ export class FoundryStepExecutor {
|
|
|
3079
3091
|
}
|
|
3080
3092
|
}
|
|
3081
3093
|
// ---- chat -----------------------------------------------------------------
|
|
3094
|
+
/**
|
|
3095
|
+
* The chat service's introspection client: an API app in the org's
|
|
3096
|
+
* messaging project (the audience Agora's token carries), so ZITADEL's
|
|
3097
|
+
* introspection accepts it. The chat service authenticates as it with
|
|
3098
|
+
* `client_secret_basic`. Minted once, the way {@link callsTokenEnvs} does:
|
|
3099
|
+
* the id is recorded under an undelivered key and persisted before the
|
|
3100
|
+
* secret leaves, the secret goes to `deliverSecret` and to the caller's env
|
|
3101
|
+
* write, never to a log, error or return beyond that. A re-run reads the id
|
|
3102
|
+
* and secret back from the service's own env and mints nothing. `changed`
|
|
3103
|
+
* is true only for a fresh mint.
|
|
3104
|
+
*/
|
|
3105
|
+
async ensureChatIntrospection(org, zitadelOrgId, projectId, serviceAppId, context) {
|
|
3106
|
+
const recorded = context.recordedIds();
|
|
3107
|
+
const done = recorded[CHAT_INTROSPECTION_SCOPE_KEY];
|
|
3108
|
+
const undelivered = recorded[CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY];
|
|
3109
|
+
if (done) {
|
|
3110
|
+
const envs = await this.ports.coolify.listEnvs(serviceAppId);
|
|
3111
|
+
const read = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
|
|
3112
|
+
const clientId = read('CHAT_INTROSPECTION_CLIENT_ID');
|
|
3113
|
+
const clientSecret = read('CHAT_INTROSPECTION_CLIENT_SECRET');
|
|
3114
|
+
if (!clientId || !clientSecret) {
|
|
3115
|
+
throw new Error(`coolify:chat for ${org.slug}: the introspection client (ZITADEL app ${done}) is recorded but its id or secret is no longer on the service; remove the recorded keys ${CHAT_INTROSPECTION_SCOPE_KEY} and ${CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY}, and delete the ZITADEL client, to mint a new one`);
|
|
3116
|
+
}
|
|
3117
|
+
return { clientId, clientSecret, appId: done, changed: false };
|
|
3118
|
+
}
|
|
3119
|
+
if (undelivered) {
|
|
3120
|
+
// A minted client with no marker: a crash or failure after the mint.
|
|
3121
|
+
// Both values on the service mean the write landed and only the marker
|
|
3122
|
+
// was lost; otherwise nothing usable was delivered, and the app is
|
|
3123
|
+
// deleted below and made again.
|
|
3124
|
+
const envs = await this.ports.coolify.listEnvs(serviceAppId);
|
|
3125
|
+
const read = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
|
|
3126
|
+
const clientId = read('CHAT_INTROSPECTION_CLIENT_ID');
|
|
3127
|
+
const clientSecret = read('CHAT_INTROSPECTION_CLIENT_SECRET');
|
|
3128
|
+
if (clientId && clientSecret) {
|
|
3129
|
+
return { clientId, clientSecret, appId: undelivered, changed: false };
|
|
3130
|
+
}
|
|
3131
|
+
}
|
|
3132
|
+
// An app by this name with no recorded secret is a crash between create
|
|
3133
|
+
// and record: nothing was delivered, so it is deleted and made again.
|
|
3134
|
+
const stale = (await this.ports.zitadel.listApps(zitadelOrgId, projectId)).apps.filter((app) => app.name === chatIntrospectionAppName(org) && app.state !== 'APP_STATE_REMOVED');
|
|
3135
|
+
const staleIds = new Set(stale.map((app) => app.id));
|
|
3136
|
+
if (undelivered)
|
|
3137
|
+
staleIds.add(undelivered);
|
|
3138
|
+
for (const id of staleIds) {
|
|
3139
|
+
try {
|
|
3140
|
+
await this.ports.zitadel.deleteApp(zitadelOrgId, projectId, id);
|
|
3141
|
+
}
|
|
3142
|
+
catch (error) {
|
|
3143
|
+
if (!isNotFoundError(error))
|
|
3144
|
+
throw error;
|
|
3145
|
+
}
|
|
3146
|
+
}
|
|
3147
|
+
const client = await this.ports.zitadel.createApiApp(zitadelOrgId, projectId, {
|
|
3148
|
+
name: chatIntrospectionAppName(org),
|
|
3149
|
+
});
|
|
3150
|
+
context.recordScopeId(CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, client.appId);
|
|
3151
|
+
await context.persistScopeIds();
|
|
3152
|
+
if (!(await this.deliverWithRetry(`zitadel:chat:${org.slug}:introspection`, client.clientSecret))) {
|
|
3153
|
+
throw new Error(`coolify:chat for ${org.slug}: the introspection client secret (ZITADEL app ${client.appId}) could not be delivered after ${JOBS_DELIVERY_ATTEMPTS} attempts`);
|
|
3154
|
+
}
|
|
3155
|
+
return {
|
|
3156
|
+
clientId: client.clientId,
|
|
3157
|
+
clientSecret: client.clientSecret,
|
|
3158
|
+
appId: client.appId,
|
|
3159
|
+
changed: true,
|
|
3160
|
+
};
|
|
3161
|
+
}
|
|
3082
3162
|
/**
|
|
3083
3163
|
* Creates the `chat.admin` role on the org's messaging project and grants
|
|
3084
3164
|
* it to each owner whose invite is recorded (`zitadel:owner:<orgId>:<email>`,
|
|
@@ -3147,6 +3227,29 @@ export class FoundryStepExecutor {
|
|
|
3147
3227
|
context.recordScopeId(markerKey, '1');
|
|
3148
3228
|
return { resourceId: serviceAppId };
|
|
3149
3229
|
}
|
|
3230
|
+
// `planChat` plans this for a live chat whose service has no
|
|
3231
|
+
// introspection client: mint it once, push only its two env vars and
|
|
3232
|
+
// redeploy. Never the re-key paths or a fresh provision.
|
|
3233
|
+
if (action.desired.refreshIntrospection === true) {
|
|
3234
|
+
const serviceAppId = recorded[CHAT_SERVICE_SCOPE_KEY];
|
|
3235
|
+
const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
|
|
3236
|
+
const projectId = recorded[ZITADEL_MESSAGING_PROJECT_SCOPE_KEY];
|
|
3237
|
+
if (!serviceAppId || !zitadelOrgId || !projectId) {
|
|
3238
|
+
throw new Error(`coolify:chat introspection for ${org.slug}: the chat service app, the ZITADEL org or the messaging project is not recorded yet`);
|
|
3239
|
+
}
|
|
3240
|
+
const hadMarker = Boolean(recorded[CHAT_INTROSPECTION_SCOPE_KEY]);
|
|
3241
|
+
const client = await this.ensureChatIntrospection(org, zitadelOrgId, projectId, serviceAppId, context);
|
|
3242
|
+
await this.ports.coolify.bulkUpsertEnvs(serviceAppId, [
|
|
3243
|
+
{ key: 'CHAT_INTROSPECTION_CLIENT_ID', value: client.clientId },
|
|
3244
|
+
{ key: 'CHAT_INTROSPECTION_CLIENT_SECRET', value: client.clientSecret },
|
|
3245
|
+
]);
|
|
3246
|
+
// Deploy first, marker last: a failed deploy leaves the marker unset,
|
|
3247
|
+
// so the next plan still carries this step and the container gets the env.
|
|
3248
|
+
if (client.changed || !hadMarker)
|
|
3249
|
+
await this.ports.coolify.deploy(serviceAppId);
|
|
3250
|
+
context.recordScopeId(CHAT_INTROSPECTION_SCOPE_KEY, client.appId);
|
|
3251
|
+
return { resourceId: serviceAppId };
|
|
3252
|
+
}
|
|
3150
3253
|
if (action.desired.usesDomain === false) {
|
|
3151
3254
|
throw new NotBuiltError(`coolify:chat reverse re-key for ${org.slug}`, 'moving its chat back onto the original server_name is not built (#7 amendment 3)');
|
|
3152
3255
|
}
|
|
@@ -3280,6 +3383,7 @@ export class FoundryStepExecutor {
|
|
|
3280
3383
|
rsaKeyPem: secrets.value.masRsaKeyPem,
|
|
3281
3384
|
ecKeyPem: secrets.value.masEcKeyPem,
|
|
3282
3385
|
}));
|
|
3386
|
+
const introspection = await this.ensureChatIntrospection(org, zitadelOrgId, project.id, service.appId, context);
|
|
3283
3387
|
await this.ports.coolify.bulkUpsertEnvs(service.appId, chatServiceEnvs({
|
|
3284
3388
|
orgSlug: org.slug,
|
|
3285
3389
|
companyName: org.displayName,
|
|
@@ -3289,6 +3393,8 @@ export class FoundryStepExecutor {
|
|
|
3289
3393
|
agoraHost,
|
|
3290
3394
|
masClientId: secrets.value.masClientId,
|
|
3291
3395
|
masClientSecret: secrets.value.masClientSecret,
|
|
3396
|
+
introspectionClientId: introspection.clientId,
|
|
3397
|
+
introspectionClientSecret: introspection.clientSecret,
|
|
3292
3398
|
}));
|
|
3293
3399
|
// Split the same way `coolifyApp`'s and `cmsSite`'s own databases are
|
|
3294
3400
|
// (#239, #456): `DATABASE_URL_OWNER` is the superuser string,
|
|
@@ -3306,11 +3412,14 @@ export class FoundryStepExecutor {
|
|
|
3306
3412
|
for (const app of [mas, synapse, service]) {
|
|
3307
3413
|
if (!app.existed ||
|
|
3308
3414
|
secrets.changed ||
|
|
3309
|
-
(app === service && serviceDbPush.changed) ||
|
|
3415
|
+
(app === service && (serviceDbPush.changed || introspection.changed)) ||
|
|
3310
3416
|
!isRunningOrStarting((await this.ports.coolify.getApplication(app.appId)).status)) {
|
|
3311
3417
|
await this.ports.coolify.deploy(app.appId);
|
|
3312
3418
|
}
|
|
3313
3419
|
}
|
|
3420
|
+
// After the write and the deploys, so a failure before them leaves the
|
|
3421
|
+
// marker unset and the next apply repeats the step.
|
|
3422
|
+
context.recordScopeId(CHAT_INTROSPECTION_SCOPE_KEY, introspection.appId);
|
|
3314
3423
|
// #456: recorded here too, not only on the `refreshDatabaseSplit` replan
|
|
3315
3424
|
// path above -- without it a fresh provision's very first apply never
|
|
3316
3425
|
// sets this marker, so `planChat` (`!recordedIds[serviceDbEnvAppliedScopeKey('chat')]`)
|
|
@@ -3837,6 +3946,87 @@ export class FoundryStepExecutor {
|
|
|
3837
3946
|
throw new Error('ai: this executor was built without an ai port (#177)');
|
|
3838
3947
|
return port;
|
|
3839
3948
|
}
|
|
3949
|
+
/**
|
|
3950
|
+
* Every DNS record call runs through edge (`edge.dns`, the platform
|
|
3951
|
+
* credential), adopting live records before it first changes them. `dns` on
|
|
3952
|
+
* the ports is a seam for a host or test that supplies its own {@link DnsPort}.
|
|
3953
|
+
*/
|
|
3954
|
+
dnsPort() {
|
|
3955
|
+
if (this.ports.dns)
|
|
3956
|
+
return this.ports.dns;
|
|
3957
|
+
if (!this.ports.edge) {
|
|
3958
|
+
throw new Error('dns: this executor was built without an EdgePort (F4.dns)');
|
|
3959
|
+
}
|
|
3960
|
+
this.edgeWriteSlot ??= limiter(EDGE_DNS_WRITE_CONCURRENCY);
|
|
3961
|
+
this.edgeDns ??= createEdgeDns(this.ports.edge.dns, { writeSlot: this.edgeWriteSlot });
|
|
3962
|
+
return this.edgeDns;
|
|
3963
|
+
}
|
|
3964
|
+
/**
|
|
3965
|
+
* Every tunnel call runs through edge (`edge.tunnels`, the platform
|
|
3966
|
+
* credential), adopting a live tunnel before it first configures, reads the
|
|
3967
|
+
* token of or deletes it. Shares edge's write slot with {@link dnsPort}.
|
|
3968
|
+
*/
|
|
3969
|
+
tunnelPort() {
|
|
3970
|
+
if (this.ports.tunnels)
|
|
3971
|
+
return this.ports.tunnels;
|
|
3972
|
+
if (!this.ports.edge) {
|
|
3973
|
+
throw new Error('tunnels: this executor was built without an EdgePort (F4.tunnels)');
|
|
3974
|
+
}
|
|
3975
|
+
this.edgeWriteSlot ??= limiter(EDGE_DNS_WRITE_CONCURRENCY);
|
|
3976
|
+
this.edgeTunnels ??= createEdgeTunnels(this.ports.edge.tunnels, {
|
|
3977
|
+
writeSlot: this.edgeWriteSlot,
|
|
3978
|
+
});
|
|
3979
|
+
return this.edgeTunnels;
|
|
3980
|
+
}
|
|
3981
|
+
/**
|
|
3982
|
+
* Every Access call runs through edge (`edge.access`, the platform
|
|
3983
|
+
* credential), adopting a live app before it first adds a policy to or
|
|
3984
|
+
* deletes it. Shares edge's write slot with {@link dnsPort}.
|
|
3985
|
+
*/
|
|
3986
|
+
accessPort() {
|
|
3987
|
+
if (this.ports.access)
|
|
3988
|
+
return this.ports.access;
|
|
3989
|
+
if (!this.ports.edge) {
|
|
3990
|
+
throw new Error('access: this executor was built without an EdgePort (F4.access)');
|
|
3991
|
+
}
|
|
3992
|
+
this.edgeWriteSlot ??= limiter(EDGE_DNS_WRITE_CONCURRENCY);
|
|
3993
|
+
this.edgeAccess ??= createEdgeAccess(this.ports.edge.access, {
|
|
3994
|
+
writeSlot: this.edgeWriteSlot,
|
|
3995
|
+
});
|
|
3996
|
+
return this.edgeAccess;
|
|
3997
|
+
}
|
|
3998
|
+
/**
|
|
3999
|
+
* Every rate limiting rule call runs through edge (`edge.ratelimits`, the
|
|
4000
|
+
* platform credential), adopting the live rule before it first creates it.
|
|
4001
|
+
* Shares edge's write slot with {@link dnsPort}.
|
|
4002
|
+
*/
|
|
4003
|
+
rateLimitPort() {
|
|
4004
|
+
if (this.ports.ratelimits)
|
|
4005
|
+
return this.ports.ratelimits;
|
|
4006
|
+
if (!this.ports.edge) {
|
|
4007
|
+
throw new Error('ratelimits: this executor was built without an EdgePort (F4.ratelimits)');
|
|
4008
|
+
}
|
|
4009
|
+
this.edgeWriteSlot ??= limiter(EDGE_DNS_WRITE_CONCURRENCY);
|
|
4010
|
+
this.edgeRateLimits ??= createEdgeRateLimits(this.ports.edge.ratelimits, {
|
|
4011
|
+
writeSlot: this.edgeWriteSlot,
|
|
4012
|
+
});
|
|
4013
|
+
return this.edgeRateLimits;
|
|
4014
|
+
}
|
|
4015
|
+
/**
|
|
4016
|
+
* Every zone lookup and create runs through edge (`edge.zones`, the
|
|
4017
|
+
* platform credential), adopting a live zone before it first creates it.
|
|
4018
|
+
* Shares edge's write slot with {@link dnsPort}.
|
|
4019
|
+
*/
|
|
4020
|
+
zonePort() {
|
|
4021
|
+
if (this.ports.zones)
|
|
4022
|
+
return this.ports.zones;
|
|
4023
|
+
if (!this.ports.edge) {
|
|
4024
|
+
throw new Error('zones: this executor was built without an EdgePort (F4.zones)');
|
|
4025
|
+
}
|
|
4026
|
+
this.edgeWriteSlot ??= limiter(EDGE_DNS_WRITE_CONCURRENCY);
|
|
4027
|
+
this.edgeZones ??= createEdgeZones(this.ports.edge.zones, { writeSlot: this.edgeWriteSlot });
|
|
4028
|
+
return this.edgeZones;
|
|
4029
|
+
}
|
|
3840
4030
|
async jobsPort(org) {
|
|
3841
4031
|
const port = this.ports.jobsScheduler ?? (await this.ports.platformOrg?.jobs());
|
|
3842
4032
|
if (!port) {
|
|
@@ -3905,7 +4095,7 @@ export class FoundryStepExecutor {
|
|
|
3905
4095
|
}
|
|
3906
4096
|
// Cleared, not deleted (#7 corrections 1): an ingress with only the
|
|
3907
4097
|
// mandatory catch-all routes nothing.
|
|
3908
|
-
await this.
|
|
4098
|
+
await this.tunnelPort().putTunnelConfig(tunnelId, {
|
|
3909
4099
|
ingress: [{ service: 'http_status:404' }],
|
|
3910
4100
|
});
|
|
3911
4101
|
// #235: park stops the connector too -- an ingress with nothing
|
|
@@ -3931,8 +4121,9 @@ export class FoundryStepExecutor {
|
|
|
3931
4121
|
// domain declared still leaves `destroy` a way to recognise the old
|
|
3932
4122
|
// id's CNAMEs as this tunnel's own rather than a foreign record.
|
|
3933
4123
|
const recordedTunnelId = context.recordedIds()[`cloudflare:tunnel:${action.key}`];
|
|
3934
|
-
const
|
|
3935
|
-
const
|
|
4124
|
+
const tunnels = this.tunnelPort();
|
|
4125
|
+
const existing = await tunnels.findTunnelByName(tunnelName);
|
|
4126
|
+
const tunnel = existing ?? (await tunnels.createTunnel(tunnelName));
|
|
3936
4127
|
const previousIds = parseTunnelPreviousIds(context.recordedIds());
|
|
3937
4128
|
if (recordedTunnelId && recordedTunnelId !== tunnel.id) {
|
|
3938
4129
|
previousIds.add(recordedTunnelId);
|
|
@@ -3959,7 +4150,7 @@ export class FoundryStepExecutor {
|
|
|
3959
4150
|
hostname,
|
|
3960
4151
|
service: COOLIFY_PROXY_ORIGIN,
|
|
3961
4152
|
}));
|
|
3962
|
-
await this.
|
|
4153
|
+
await this.tunnelPort().putTunnelConfig(tunnel.id, {
|
|
3963
4154
|
ingress: [...ingress, { service: 'http_status:404' }],
|
|
3964
4155
|
});
|
|
3965
4156
|
// #235: a connector for this org's own tunnel, the same call every
|
|
@@ -3973,7 +4164,7 @@ export class FoundryStepExecutor {
|
|
|
3973
4164
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
3974
4165
|
await ensureCloudflaredConnector({
|
|
3975
4166
|
coolify: this.ports.coolify,
|
|
3976
|
-
|
|
4167
|
+
tunnels: this.tunnelPort(),
|
|
3977
4168
|
tunnelId: tunnel.id,
|
|
3978
4169
|
appName: `${org.slug}-cloudflared`,
|
|
3979
4170
|
projectUuid,
|
|
@@ -4104,7 +4295,7 @@ export class FoundryStepExecutor {
|
|
|
4104
4295
|
* nothing of this domain's left in that set either way.
|
|
4105
4296
|
*/
|
|
4106
4297
|
async deleteStaleTunnelDnsRecords(tunnelId, previousIds, previousDomain, previousHostnames, context) {
|
|
4107
|
-
const zone = await this.
|
|
4298
|
+
const zone = await this.zonePort().findZoneByApex(previousDomain);
|
|
4108
4299
|
if (!zone)
|
|
4109
4300
|
return;
|
|
4110
4301
|
const dnsTargets = new Set([`${tunnelId}.cfargotunnel.com`]);
|
|
@@ -4112,13 +4303,13 @@ export class FoundryStepExecutor {
|
|
|
4112
4303
|
if (id !== tunnelId)
|
|
4113
4304
|
dnsTargets.add(`${id}.cfargotunnel.com`);
|
|
4114
4305
|
}
|
|
4115
|
-
const
|
|
4306
|
+
const dns = this.dnsPort();
|
|
4116
4307
|
const records = previousHostnames.length > 0
|
|
4117
|
-
? (await Promise.all(previousHostnames.map((hostname) =>
|
|
4118
|
-
: await
|
|
4308
|
+
? (await Promise.all(previousHostnames.map((hostname) => dns.findDnsRecord(zone.name, hostname, 'CNAME')))).flat()
|
|
4309
|
+
: await dns.listDnsRecords(zone.name, { type: 'CNAME' });
|
|
4119
4310
|
for (const record of records) {
|
|
4120
4311
|
if (dnsTargets.has(record.content)) {
|
|
4121
|
-
await
|
|
4312
|
+
await dns.deleteDnsRecord(zone.name, record.id);
|
|
4122
4313
|
}
|
|
4123
4314
|
}
|
|
4124
4315
|
if (previousHostnames.length > 0) {
|
|
@@ -4186,8 +4377,8 @@ export class FoundryStepExecutor {
|
|
|
4186
4377
|
* both read this set.
|
|
4187
4378
|
*/
|
|
4188
4379
|
async publishTunnelDnsRecords(org, domain, tunnelId, previousIds, previousHostnames, context) {
|
|
4189
|
-
const
|
|
4190
|
-
const zone = await
|
|
4380
|
+
const dns = this.dnsPort();
|
|
4381
|
+
const zone = await this.zonePort().findZoneByApex(domain);
|
|
4191
4382
|
if (!zone) {
|
|
4192
4383
|
throw new Error(`cloudflare:tunnel for ${org.slug}: no Cloudflare zone found for ${domain} to point its hostnames at -- cloudflare:zone must provision it first`);
|
|
4193
4384
|
}
|
|
@@ -4199,14 +4390,14 @@ export class FoundryStepExecutor {
|
|
|
4199
4390
|
const toCreate = [];
|
|
4200
4391
|
const toRepoint = [];
|
|
4201
4392
|
for (const hostname of hostnames) {
|
|
4202
|
-
const [a] = await
|
|
4203
|
-
const [aaaa] = await
|
|
4393
|
+
const [a] = await dns.findDnsRecord(zone.name, hostname, 'A');
|
|
4394
|
+
const [aaaa] = await dns.findDnsRecord(zone.name, hostname, 'AAAA');
|
|
4204
4395
|
const foreign = a ?? aaaa;
|
|
4205
4396
|
if (foreign) {
|
|
4206
4397
|
conflicts.push(`${hostname} already has a ${foreign.type} record`);
|
|
4207
4398
|
continue;
|
|
4208
4399
|
}
|
|
4209
|
-
const [cname] = await
|
|
4400
|
+
const [cname] = await dns.findDnsRecord(zone.name, hostname, 'CNAME');
|
|
4210
4401
|
if (cname && cname.content !== dnsTarget) {
|
|
4211
4402
|
if (previousTargets.has(cname.content)) {
|
|
4212
4403
|
toRepoint.push({ hostname, recordId: cname.id });
|
|
@@ -4238,7 +4429,7 @@ export class FoundryStepExecutor {
|
|
|
4238
4429
|
for (const hostname of settled)
|
|
4239
4430
|
recordTracked(hostname);
|
|
4240
4431
|
for (const hostname of toCreate) {
|
|
4241
|
-
await
|
|
4432
|
+
await dns.createDnsRecord(zone.name, {
|
|
4242
4433
|
type: 'CNAME',
|
|
4243
4434
|
name: hostname,
|
|
4244
4435
|
content: dnsTarget,
|
|
@@ -4247,7 +4438,7 @@ export class FoundryStepExecutor {
|
|
|
4247
4438
|
recordTracked(hostname);
|
|
4248
4439
|
}
|
|
4249
4440
|
for (const { hostname, recordId } of toRepoint) {
|
|
4250
|
-
await
|
|
4441
|
+
await dns.updateDnsRecord(zone.name, recordId, {
|
|
4251
4442
|
type: 'CNAME',
|
|
4252
4443
|
name: hostname,
|
|
4253
4444
|
content: dnsTarget,
|
|
@@ -4266,9 +4457,9 @@ export class FoundryStepExecutor {
|
|
|
4266
4457
|
// tracking under `domain`, so it is dropped from the shared set either
|
|
4267
4458
|
// way.
|
|
4268
4459
|
for (const hostname of droppedHostnames) {
|
|
4269
|
-
const [cname] = await
|
|
4460
|
+
const [cname] = await dns.findDnsRecord(zone.name, hostname, 'CNAME');
|
|
4270
4461
|
if (cname && (cname.content === dnsTarget || previousTargets.has(cname.content))) {
|
|
4271
|
-
await
|
|
4462
|
+
await dns.deleteDnsRecord(zone.name, cname.id);
|
|
4272
4463
|
}
|
|
4273
4464
|
tracked.delete(trackedTunnelDnsEntry(domain, hostname));
|
|
4274
4465
|
context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY, trackedTunnelDnsState(tracked));
|
|
@@ -4302,7 +4493,7 @@ export class FoundryStepExecutor {
|
|
|
4302
4493
|
*/
|
|
4303
4494
|
async cloudflareZone(action, org, context) {
|
|
4304
4495
|
const domain = action.key;
|
|
4305
|
-
const cf = this.
|
|
4496
|
+
const cf = this.zonePort();
|
|
4306
4497
|
const existing = await cf.findZoneByApex(domain);
|
|
4307
4498
|
if (existing) {
|
|
4308
4499
|
const owner = await this.ports.declaredOrgStore.findOrgIdForZone(existing.id);
|
|
@@ -4503,12 +4694,12 @@ export class FoundryStepExecutor {
|
|
|
4503
4694
|
* publishes it.
|
|
4504
4695
|
*/
|
|
4505
4696
|
async publishMailDnsRecords(org, result) {
|
|
4506
|
-
const zone = await this.
|
|
4697
|
+
const zone = await this.zonePort().findZoneByApex(result.domain);
|
|
4507
4698
|
if (!zone) {
|
|
4508
4699
|
throw new Error(`coolify:mail for ${org.slug}: no Cloudflare zone found for ${result.domain}`);
|
|
4509
4700
|
}
|
|
4510
4701
|
for (const record of result.records) {
|
|
4511
|
-
await this.upsertMailDnsRecord(zone.
|
|
4702
|
+
await this.upsertMailDnsRecord(zone.name, record);
|
|
4512
4703
|
}
|
|
4513
4704
|
}
|
|
4514
4705
|
/**
|
|
@@ -4522,7 +4713,7 @@ export class FoundryStepExecutor {
|
|
|
4522
4713
|
* a record someone proxied by hand is put back. An `MX` record carries the
|
|
4523
4714
|
* priority it was given, which Cloudflare requires.
|
|
4524
4715
|
*
|
|
4525
|
-
* Found by an exact name+type match (`
|
|
4716
|
+
* Found by an exact name+type match (`DnsPort.findDnsRecord`'s own
|
|
4526
4717
|
* doc comment) so a stale record of a different type is never taken for a
|
|
4527
4718
|
* match. A name can hold several `TXT` records (an apex often holds a site
|
|
4528
4719
|
* verification beside SPF), so a `TXT` record only replaces one that
|
|
@@ -4531,8 +4722,8 @@ export class FoundryStepExecutor {
|
|
|
4531
4722
|
* whatever it was.
|
|
4532
4723
|
*/
|
|
4533
4724
|
async upsertMailDnsRecord(zoneId, record) {
|
|
4534
|
-
const
|
|
4535
|
-
const found = await
|
|
4725
|
+
const dns = this.dnsPort();
|
|
4726
|
+
const found = await dns.findDnsRecord(zoneId, record.name, record.type);
|
|
4536
4727
|
const existing = record.type === 'TXT'
|
|
4537
4728
|
? found.find((r) => txtRecordTag(r.content) === txtRecordTag(record.value))
|
|
4538
4729
|
: found[0];
|
|
@@ -4544,10 +4735,10 @@ export class FoundryStepExecutor {
|
|
|
4544
4735
|
...(record.priority === undefined ? {} : { priority: record.priority }),
|
|
4545
4736
|
};
|
|
4546
4737
|
if (existing) {
|
|
4547
|
-
await
|
|
4738
|
+
await dns.updateDnsRecord(zoneId, existing.id, rec);
|
|
4548
4739
|
}
|
|
4549
4740
|
else {
|
|
4550
|
-
await
|
|
4741
|
+
await dns.createDnsRecord(zoneId, rec);
|
|
4551
4742
|
}
|
|
4552
4743
|
}
|
|
4553
4744
|
/**
|
|
@@ -4603,13 +4794,13 @@ export class FoundryStepExecutor {
|
|
|
4603
4794
|
* `dkim_secondary`, none of them SPF -- so that merge has no case left to
|
|
4604
4795
|
* handle here and is dropped rather than kept as dead code.
|
|
4605
4796
|
*/
|
|
4606
|
-
async publishLettermintDnsRecords(
|
|
4797
|
+
async publishLettermintDnsRecords(zone, records) {
|
|
4607
4798
|
for (const record of records) {
|
|
4608
4799
|
if (record.type === 'TXT' && record.value.toLowerCase().startsWith('v=dmarc1')) {
|
|
4609
|
-
await this.ensureLettermintDmarcRecord(this.
|
|
4800
|
+
await this.ensureLettermintDmarcRecord(this.dnsPort(), zone, record);
|
|
4610
4801
|
continue;
|
|
4611
4802
|
}
|
|
4612
|
-
await this.upsertMailDnsRecord(
|
|
4803
|
+
await this.upsertMailDnsRecord(zone, record);
|
|
4613
4804
|
}
|
|
4614
4805
|
}
|
|
4615
4806
|
/**
|
|
@@ -4617,11 +4808,11 @@ export class FoundryStepExecutor {
|
|
|
4617
4808
|
* already set (`p=quarantine`/`p=reject`) should not be relaxed back to
|
|
4618
4809
|
* Lettermint's own `p=none` default.
|
|
4619
4810
|
*/
|
|
4620
|
-
async ensureLettermintDmarcRecord(
|
|
4621
|
-
const hasDmarc = (await
|
|
4811
|
+
async ensureLettermintDmarcRecord(dns, zoneId, record) {
|
|
4812
|
+
const hasDmarc = (await dns.findDnsRecord(zoneId, record.name, 'TXT')).some((r) => txtRecordTag(r.content) === 'v=dmarc1');
|
|
4622
4813
|
if (hasDmarc)
|
|
4623
4814
|
return;
|
|
4624
|
-
await
|
|
4815
|
+
await dns.createDnsRecord(zoneId, {
|
|
4625
4816
|
type: 'TXT',
|
|
4626
4817
|
name: record.name,
|
|
4627
4818
|
content: record.value,
|
|
@@ -4748,7 +4939,7 @@ export class FoundryStepExecutor {
|
|
|
4748
4939
|
await ai.setOrganisationOffers(org.id, action.desired.offers ?? []);
|
|
4749
4940
|
return { resourceId: provisioned.organisationId };
|
|
4750
4941
|
}
|
|
4751
|
-
async aiBudget(action, org) {
|
|
4942
|
+
async aiBudget(action, org, context) {
|
|
4752
4943
|
// #484: ai/packages/sdk/src/types.ts:134-137's UpdateBudget.limitMicros
|
|
4753
4944
|
// is a required string -- there is no "uncapped" this API can express
|
|
4754
4945
|
// yet (Finding, this campaign's report) -- so a null `desired.limitMicros`
|
|
@@ -4759,6 +4950,10 @@ export class FoundryStepExecutor {
|
|
|
4759
4950
|
const limitMicros = action.desired.limitMicros ?? DEFAULT_AI_LIMIT_MICROS;
|
|
4760
4951
|
const ai = await this.aiPort();
|
|
4761
4952
|
await ai.updateBudget(org.id, { keyId: null, limitMicros });
|
|
4953
|
+
// #515: recorded only once `updateBudget` has actually succeeded, so a
|
|
4954
|
+
// crash or a thrown call before this line leaves nothing recorded and
|
|
4955
|
+
// the step replans, rather than recording a limit that was never applied.
|
|
4956
|
+
context.recordScopeId(AI_BUDGET_LIMIT_SCOPE_KEY, limitMicros);
|
|
4762
4957
|
return { resourceId: org.id };
|
|
4763
4958
|
}
|
|
4764
4959
|
/**
|
|
@@ -5018,7 +5213,7 @@ export class FoundryStepExecutor {
|
|
|
5018
5213
|
throw error;
|
|
5019
5214
|
}
|
|
5020
5215
|
}
|
|
5021
|
-
const appId = recorded[
|
|
5216
|
+
const appId = recorded[edgeKeyAppRecordKey(org, entryKey)];
|
|
5022
5217
|
if (appId) {
|
|
5023
5218
|
// A revoked key's env values are dead, not merely stale -- remove
|
|
5024
5219
|
// them so nothing reads a credential edge has already revoked. A
|
|
@@ -5052,10 +5247,14 @@ export class FoundryStepExecutor {
|
|
|
5052
5247
|
context.recordScopeId(entryEdgeRetiredScopeKey(entryKey), keyId);
|
|
5053
5248
|
return {};
|
|
5054
5249
|
}
|
|
5055
|
-
|
|
5056
|
-
|
|
5250
|
+
if (entryKey === BOULE_EDGE_ENTRY_KEY) {
|
|
5251
|
+
if (!org.domain)
|
|
5252
|
+
throw new Error(`${at}: Boule's edge key needs org.domain`);
|
|
5253
|
+
}
|
|
5254
|
+
else if (!org.hosting.entries.some((e) => e.key === entryKey && e.uses?.edge)) {
|
|
5057
5255
|
throw new Error(`${at}: the entry no longer declares uses.edge`);
|
|
5058
|
-
|
|
5256
|
+
}
|
|
5257
|
+
const appId = context.recordedIds()[edgeKeyAppRecordKey(org, entryKey)];
|
|
5059
5258
|
if (!appId)
|
|
5060
5259
|
throw new Error(`${at}: the entry's app is not recorded yet`);
|
|
5061
5260
|
if (!org.domain) {
|
|
@@ -5493,15 +5692,15 @@ export class FoundryStepExecutor {
|
|
|
5493
5692
|
}
|
|
5494
5693
|
for (const [dnsDomain, dnsHostnames] of hostnamesByDomain) {
|
|
5495
5694
|
try {
|
|
5496
|
-
const zone = await this.
|
|
5695
|
+
const zone = await this.zonePort().findZoneByApex(dnsDomain);
|
|
5497
5696
|
if (zone) {
|
|
5498
|
-
const
|
|
5697
|
+
const dns = this.dnsPort();
|
|
5499
5698
|
const records = dnsHostnames.length > 0
|
|
5500
|
-
? (await Promise.all(dnsHostnames.map((hostname) =>
|
|
5501
|
-
: await
|
|
5699
|
+
? (await Promise.all(dnsHostnames.map((hostname) => dns.findDnsRecord(zone.name, hostname, 'CNAME')))).flat()
|
|
5700
|
+
: await dns.listDnsRecords(zone.name, { type: 'CNAME' });
|
|
5502
5701
|
for (const record of records) {
|
|
5503
5702
|
if (dnsTargets.has(record.content)) {
|
|
5504
|
-
await
|
|
5703
|
+
await dns.deleteDnsRecord(zone.name, record.id);
|
|
5505
5704
|
}
|
|
5506
5705
|
}
|
|
5507
5706
|
}
|
|
@@ -5511,11 +5710,11 @@ export class FoundryStepExecutor {
|
|
|
5511
5710
|
throw error;
|
|
5512
5711
|
}
|
|
5513
5712
|
}
|
|
5514
|
-
await this.
|
|
5713
|
+
await this.tunnelPort().deleteTunnel(target.id);
|
|
5515
5714
|
return;
|
|
5516
5715
|
}
|
|
5517
5716
|
case 'cloudflare:access':
|
|
5518
|
-
await this.
|
|
5717
|
+
await this.accessPort().deleteAccessApp(target.id);
|
|
5519
5718
|
return;
|
|
5520
5719
|
case 'ai:key': {
|
|
5521
5720
|
// F6a: an entry's key (`<orgId>:<entry>`). REVOKE SEAM (ai#157): a
|