@wtfalch/foundry 0.77.1 → 0.78.1
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/org.d.ts +2 -2
- package/dist/plan/diff.d.ts +5 -0
- package/dist/plan/diff.js +12 -0
- package/dist/plan/executor.d.ts +8 -0
- package/dist/plan/executor.js +58 -0
- package/dist/plan/files-space-alert.d.ts +40 -0
- package/dist/plan/files-space-alert.js +90 -0
- package/dist/plan/jobs-project-settings.d.ts +33 -0
- package/dist/plan/jobs-project-settings.js +77 -0
- package/dist/plan/jobs-scheduler-http.d.ts +2 -0
- package/dist/plan/jobs-scheduler-http.js +10 -0
- package/dist/plan/plan-apply.d.ts +10 -0
- package/dist/plan/plan-apply.js +26 -0
- package/dist/plan/platform-diff.d.ts +1 -0
- package/dist/plan/platform-diff.js +8 -2
- package/dist/plan/platform-org.d.ts +3 -0
- package/dist/plan/platform-org.js +4 -0
- package/dist/plan/service-alert-mail.d.ts +36 -0
- package/dist/plan/service-alert-mail.js +105 -0
- package/dist/plan/usage-tail-worker.js +6 -2
- package/package.json +1 -1
package/dist/org.d.ts
CHANGED
|
@@ -770,8 +770,8 @@ export interface EntryMail {
|
|
|
770
770
|
bounceAlertPercent?: number;
|
|
771
771
|
}
|
|
772
772
|
/**
|
|
773
|
-
*
|
|
774
|
-
*
|
|
773
|
+
* Jobs' settings are delivered to the scheduler's project together with the company's owners.
|
|
774
|
+
* `uses.jobs: true` is every setting at its default.
|
|
775
775
|
*/
|
|
776
776
|
export interface EntryJobs {
|
|
777
777
|
/** How many times a failed job is tried again. Absent is {@link JOBS_RETRIES_DEFAULT}. */
|
package/dist/plan/diff.d.ts
CHANGED
|
@@ -92,6 +92,11 @@ export interface PlanOrgOptions {
|
|
|
92
92
|
reportingPort?: boolean;
|
|
93
93
|
/** foundry#1119: the executor's jobs scheduler port mints and revokes project keys; without it Boule's key per `uses.jobs` entry is blocked. */
|
|
94
94
|
jobsProjectKeys?: boolean;
|
|
95
|
+
/** The Files port can set and remove each live app's owner space alert. */
|
|
96
|
+
serviceAlertMail?: boolean;
|
|
97
|
+
serviceAlertMailCurrent?: Partial<Record<'files' | 'jobs', boolean | 'unreadable'>>;
|
|
98
|
+
filesSpaceAlerts?: boolean;
|
|
99
|
+
jobsProjectSettings?: boolean;
|
|
95
100
|
/**
|
|
96
101
|
* foundry#1122: ai offers a company Boule's five read permissions (`ai-boule-key.ts`). With it the organisation
|
|
97
102
|
* is offered them, the org's own ai key is minted again with them, and Boule's key is minted below it; without
|
package/dist/plan/diff.js
CHANGED
|
@@ -32,9 +32,11 @@ import { planEntryRepoKeys } from './entry-repo-key.js';
|
|
|
32
32
|
import { planEntryReportingKeys } from './entry-reporting-key.js';
|
|
33
33
|
import { BROKER_WORKSPACES_DATABASE_ENV, workspacesDatabaseRef } from './entry-workspaces.js';
|
|
34
34
|
import { planFilesBouleKey } from './files-boule-key.js';
|
|
35
|
+
import { planFilesSpaceAlerts } from './files-space-alert.js';
|
|
35
36
|
import { gateIdRecordKey, hasWildcardGate, planGates } from './gates.js';
|
|
36
37
|
import { githubSourceResourceKey, planGithubSource } from './github-source.js';
|
|
37
38
|
import { appHostname, tunnelDnsHostnames } from './hostnames.js';
|
|
39
|
+
import { planJobsProjectSettings } from './jobs-project-settings.js';
|
|
38
40
|
import { planKeysRegistrar } from './keys-registrar.js';
|
|
39
41
|
import { desiredKosmosEnv, kosmosEnvDrift } from './kosmos.js';
|
|
40
42
|
import { APP_GONE_KIND } from './live.js';
|
|
@@ -61,6 +63,7 @@ import { planRegisteredApps } from './registered-app.js';
|
|
|
61
63
|
import { planRemoteDatabase } from './remote-database.js';
|
|
62
64
|
import { planReportingBoule } from './reporting-boule.js';
|
|
63
65
|
import { REPORTING_MAIL_TOKEN_ENV, desiredReportingEnv, reportingEnvDrift, reportingWanted, } from './reporting.js';
|
|
66
|
+
import { planServiceAlertMail } from './service-alert-mail.js';
|
|
64
67
|
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';
|
|
65
68
|
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, signInLoginNames, } from './sign-in.js';
|
|
66
69
|
import { withStepSystem } from './step-system.js';
|
|
@@ -750,6 +753,10 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
|
|
|
750
753
|
// foundry#1121: Boule's files key, after Boule and after the org's own files key it is minted below.
|
|
751
754
|
const filesBouleKey = planFilesBouleKey(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
|
|
752
755
|
plannedSteps.push(...filesBouleKey.steps);
|
|
756
|
+
const filesSpaceAlerts = planFilesSpaceAlerts(org, recordedIds, platformReadiness.filesReady && !filesOrganisation.blocked, options.filesSpaceAlerts ?? false);
|
|
757
|
+
plannedSteps.push(...filesSpaceAlerts.steps);
|
|
758
|
+
const serviceAlertMail = planServiceAlertMail(org, platformReadiness, options.serviceAlertMail ?? false, options.ownMailKeyHeld ?? false, options.serviceAlertMailCurrent);
|
|
759
|
+
plannedSteps.push(...serviceAlertMail.steps);
|
|
753
760
|
// foundry#1122: Boule's ai key, after Boule and after the org's own ai key it is minted below.
|
|
754
761
|
const aiBouleKey = planAiBouleKey(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)(), aiBouleReads);
|
|
755
762
|
plannedSteps.push(...aiBouleKey.steps);
|
|
@@ -805,6 +812,8 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
|
|
|
805
812
|
// A `uses.jobs` entry registers with the scheduler after its app, whose env the step writes.
|
|
806
813
|
const entryJobs = planEntryJobs(org, recordedIds, plannedSteps, platformReadiness, schedulerUsable);
|
|
807
814
|
plannedSteps.push(...entryJobs.steps);
|
|
815
|
+
const jobsSettings = planJobsProjectSettings(org, recordedIds, plannedSteps, schedulerUsable, options.jobsProjectSettings ?? false);
|
|
816
|
+
plannedSteps.push(...jobsSettings.steps);
|
|
808
817
|
// foundry#1119: Boule's key for each entry's project, after the project and after Boule.
|
|
809
818
|
const entryJobsBouleKeys = planEntryJobsBouleKeys(org, recordedIds, plannedSteps, platformReadiness, options.jobsProjectKeys ?? false);
|
|
810
819
|
plannedSteps.push(...entryJobsBouleKeys.steps);
|
|
@@ -1086,6 +1095,8 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
|
|
|
1086
1095
|
unmanaged.push(...workspaces.unmanaged);
|
|
1087
1096
|
unmanaged.push(...filesKeys.blocked);
|
|
1088
1097
|
unmanaged.push(...filesBouleKey.blocked);
|
|
1098
|
+
unmanaged.push(...filesSpaceAlerts.blocked);
|
|
1099
|
+
unmanaged.push(...serviceAlertMail.blocked);
|
|
1089
1100
|
unmanaged.push(...entryAiKeys.blocked);
|
|
1090
1101
|
unmanaged.push(...aiBouleKey.blocked);
|
|
1091
1102
|
unmanaged.push(...edgeWorkers.blocked);
|
|
@@ -1108,6 +1119,7 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
|
|
|
1108
1119
|
unmanaged.push(...appsDbRoute.blocked);
|
|
1109
1120
|
unmanaged.push(...appsZones.blocked);
|
|
1110
1121
|
unmanaged.push(...entryJobs.blocked);
|
|
1122
|
+
unmanaged.push(...jobsSettings.blocked);
|
|
1111
1123
|
unmanaged.push(...entryJobsBouleKeys.blocked);
|
|
1112
1124
|
unmanaged.push(...authEventsBlocked(org, recordedIds));
|
|
1113
1125
|
unmanaged.push(...entryAudit.blocked);
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -13,7 +13,9 @@ import { type DnsPort, type EdgePlatformDns } from './edge-dns.js';
|
|
|
13
13
|
import { type EdgePlatformRateLimits, type RateLimitPort } from './edge-ratelimits.js';
|
|
14
14
|
import { type EdgePlatformTunnels, type TunnelPort } from './edge-tunnels.js';
|
|
15
15
|
import { type EdgePlatformZones, type ZonePort } from './edge-zones.js';
|
|
16
|
+
import { type FilesSpaceAlertPort } from './files-space-alert.js';
|
|
16
17
|
import type { GithubDeployKeysPort } from './github-deploy-keys.js';
|
|
18
|
+
import { type JobsProjectSettings } from './jobs-project-settings.js';
|
|
17
19
|
import { type KeysRegistrarPort } from './keys-registrar.js';
|
|
18
20
|
import { type LoginHostBindingsPort } from './login-host.js';
|
|
19
21
|
import { type MailCopyPort } from './mail-copy.js';
|
|
@@ -25,6 +27,7 @@ import type { RequestHeaderPort } from './platform-providers.js';
|
|
|
25
27
|
import { type RegistrarApiPort } from './registered-app.js';
|
|
26
28
|
import { type ReleaseContext, type ReleaseHost } from './release-host.js';
|
|
27
29
|
import { type ReportingPort } from './reporting.js';
|
|
30
|
+
import { type ServiceAlertMailWant } from './service-alert-mail.js';
|
|
28
31
|
import { type StageCopyHostConfig } from './stage-copy-host.js';
|
|
29
32
|
import { type ReleasePreviewMembers, type StageProbePort } from './stage-guards.js';
|
|
30
33
|
import type { WorkerAppDatabasePort } from './worker-app-database.js';
|
|
@@ -1375,6 +1378,8 @@ export interface ZitadelListAppsRawApp {
|
|
|
1375
1378
|
* an operator's `MANAGEMENT_PUBLIC_KEYS`/`MANAGEMENT_ISSUER` pins.
|
|
1376
1379
|
*/
|
|
1377
1380
|
export interface FilesOrganisationPort {
|
|
1381
|
+
setSpaceAlert?: FilesSpaceAlertPort['setSpaceAlert'];
|
|
1382
|
+
removeSpaceAlert?: FilesSpaceAlertPort['removeSpaceAlert'];
|
|
1378
1383
|
/**
|
|
1379
1384
|
* An own files host's R2 storage key, sent to that host once (the files route
|
|
1380
1385
|
* `PUT /v1/manage/r2-credentials`). Absent on a port that cannot: the own R2 key step is then
|
|
@@ -1706,6 +1711,8 @@ export interface BouleOwnersPort {
|
|
|
1706
1711
|
}>;
|
|
1707
1712
|
}
|
|
1708
1713
|
export interface JobsSchedulerPort {
|
|
1714
|
+
/** Deliver settings without re-registering or minting project credentials. */
|
|
1715
|
+
setProjectSettings?(project: string, settings: JobsProjectSettings): Promise<void>;
|
|
1709
1716
|
registerProject(input: {
|
|
1710
1717
|
name: string;
|
|
1711
1718
|
baseUrl: string;
|
|
@@ -2197,6 +2204,7 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
2197
2204
|
* timeout. `undefined` when there is nothing to read: the ref is a tag or a commit (it names one build), or the
|
|
2198
2205
|
* host supplies no read. A failed or malformed read is a fixed reason, never the cause (it can echo a credential).
|
|
2199
2206
|
*/
|
|
2207
|
+
serviceAlertMailCurrent(want: ServiceAlertMailWant, ids: Readonly<Record<string, string>>): Promise<boolean>;
|
|
2200
2208
|
ownAuthHead(gitRef: string): Promise<OwnAuthHead | undefined>;
|
|
2201
2209
|
/** Whether the held keys can store a Worker app database's CA and certificate. */
|
|
2202
2210
|
workerAppDbKeysPuttable(): boolean;
|
package/dist/plan/executor.js
CHANGED
|
@@ -57,11 +57,13 @@ import { ensureRepoKey, removeRepoKey } from './entry-repo-key-apply.js';
|
|
|
57
57
|
import { ENTRY_REPORTING_KEY_DELIVERY_ATTEMPTS, entryReportingAppliedScopeKey, entryReportingRetiredScopeKey, entryReportingUndeliveredScopeKey, stageReportingKeyName, } from './entry-reporting-key.js';
|
|
58
58
|
import { BROKER_WORKSPACES_DATABASE_ENV, ensureWorkspacesKey, heldWorkspacesDatabase, heldWorkspacesKey, workspacesDatabaseRef, workspacesKeyHash, workspacesKeyRef, } from './entry-workspaces.js';
|
|
59
59
|
import { ensureFilesBouleKey, retireFilesBouleKey } from './files-boule-key-apply.js';
|
|
60
|
+
import { applyFilesSpaceAlert, } from './files-space-alert.js';
|
|
60
61
|
import { GATE_CLAIM_NAME, gateHostnamesScopeKey, gateIdRecordKey, isBehindGate } from './gates.js';
|
|
61
62
|
import { GITHUB_SOURCE_INSTALLATION_SCOPE_KEY, GITHUB_SOURCE_TIMEOUT_MS, ensureGithubSource, githubSourceResourceKey, } from './github-source.js';
|
|
62
63
|
import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, wgetProbe, } from './health-check-settings.js';
|
|
63
64
|
import { PROXY_LABEL_TOKEN_REF } from './held-key-refs.js';
|
|
64
65
|
import { appHostname, cmsStudioHostnameFor, coolifyTunnelDomains, entryHostnames, siteHostnameFor, tunnelDnsHostnames, tunnelIngressHostnames, workspaceHost, } from './hostnames.js';
|
|
66
|
+
import { applyJobsProjectSettings, } from './jobs-project-settings.js';
|
|
65
67
|
import { KEYS_REGISTRAR_DELIVERY_ATTEMPTS, KEYS_REGISTRAR_KEY_DAYS, KEYS_REGISTRAR_KEY_ENV, KEYS_REGISTRAR_MINTED_AT_SCOPE_KEY, KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY, KEYS_REGISTRAR_SCOPE_KEY, KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY, KEYS_REGISTRAR_URL_APPLIED_SCOPE_KEY, KEYS_REGISTRAR_URL_ENV, } from './keys-registrar.js';
|
|
66
68
|
import { KOSMOS_CLIENT_ID_SCOPE_KEY, KOSMOS_DATABASE_SCOPE_KEY, KOSMOS_DEPLOYED_SCOPE_KEY, desiredKosmosEnv, kosmosEnvDrift, kosmosHostnameFor, } from './kosmos.js';
|
|
67
69
|
import { LOGIN_BASE_URI_SWEEP_SCOPE_KEY, LOGIN_BASE_URI_SWEEP_VERSION, LOGIN_BINDING_SCOPE_KEY, LOGIN_CLIENT_ORGS_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, OWN_CUSTOMERS_BASE_URI_SCOPE_KEY, clientOrganizationIds, clientOrganizationsState, customersClientOrganizations, customersEntryKeys, loginBindingState, loginHostClientIds, ownAuthApplied, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
@@ -90,6 +92,7 @@ import { STAGE_DEPLOY_ATTEMPTS, STAGE_DEPLOY_INTERVAL_MS, createReleaseHost, } f
|
|
|
90
92
|
import { TUNNEL_HTTP_INGRESS_SCOPE_KEY, applyRemoteDatabase, mergeTcpIngress, recordedTcpRoutes, } from './remote-database.js';
|
|
91
93
|
import { REPORTING_BOULE_APPLIED_SCOPE_KEY, REPORTING_BOULE_ENV_NAMES, reportingBouleMarker, } from './reporting-boule.js';
|
|
92
94
|
import { REPORTING_DATABASE_SCOPE_KEY, REPORTING_DEPLOYED_SCOPE_KEY, REPORTING_OPERATOR_KEY_ENV, desiredReportingEnv, reportingEnvDrift, reportingHostnameFor, reportingUrlFor, } from './reporting.js';
|
|
95
|
+
import { applyServiceAlertMail, serviceAlertMailCurrent, } from './service-alert-mail.js';
|
|
93
96
|
import { editSignInBindings } from './sign-in-bindings.js';
|
|
94
97
|
import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
|
|
95
98
|
import { SIGN_IN_BASE_PATH, SIGN_IN_ENV_LIVE_SCOPE_PREFIX, SIGN_IN_IN_APP_SCOPE_KEY, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_RATE_LIMIT_DESCRIPTION, SIGN_IN_RATE_LIMIT_EXPRESSION, SIGN_IN_RATE_LIMIT_SCOPE_KEY, SIGN_IN_TRUSTED_SCOPE_KEY, SIGN_IN_UPSTREAM_ENV, inAppBindingFor, inAppSignIn, inAppStateHosts, inAppStatePairs, inAppTargets, signInEnvLiveHosts, } from './sign-in.js';
|
|
@@ -1147,6 +1150,14 @@ export class FoundryStepExecutor {
|
|
|
1147
1150
|
* timeout. `undefined` when there is nothing to read: the ref is a tag or a commit (it names one build), or the
|
|
1148
1151
|
* host supplies no read. A failed or malformed read is a fixed reason, never the cause (it can echo a credential).
|
|
1149
1152
|
*/
|
|
1153
|
+
async serviceAlertMailCurrent(want, ids) {
|
|
1154
|
+
return serviceAlertMailCurrent({
|
|
1155
|
+
want,
|
|
1156
|
+
ids,
|
|
1157
|
+
coolify: this.ports.coolify,
|
|
1158
|
+
resolve: async (ref) => (await this.ports.heldKeys?.resolve(ref)) ?? null,
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1150
1161
|
async ownAuthHead(gitRef) {
|
|
1151
1162
|
const read = this.ports.ownServices?.readBranchHead?.bind(this.ports.ownServices);
|
|
1152
1163
|
if (!read || !isBranchRef(gitRef))
|
|
@@ -1441,6 +1452,53 @@ export class FoundryStepExecutor {
|
|
|
1441
1452
|
return this.filesOrganisation(action, org, context);
|
|
1442
1453
|
case 'files:boule-key':
|
|
1443
1454
|
return this.filesBouleKey(action, org, context);
|
|
1455
|
+
case 'coolify:service-alert-mail': {
|
|
1456
|
+
await applyServiceAlertMail({
|
|
1457
|
+
want: action.desired,
|
|
1458
|
+
ids: context.recordedIds(),
|
|
1459
|
+
coolify: this.ports.coolify,
|
|
1460
|
+
resolve: async (ref) => (await this.ports.heldKeys?.resolve(ref)) ?? null,
|
|
1461
|
+
secret: (value) => {
|
|
1462
|
+
this.handledSecrets.add(value);
|
|
1463
|
+
},
|
|
1464
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
1465
|
+
forget: (key) => forgetScopeId(context, key),
|
|
1466
|
+
persist: () => context.persistScopeIds(),
|
|
1467
|
+
});
|
|
1468
|
+
return {};
|
|
1469
|
+
}
|
|
1470
|
+
case 'jobs:settings': {
|
|
1471
|
+
const jobs = await this.jobsPort(org);
|
|
1472
|
+
if (!jobs.setProjectSettings)
|
|
1473
|
+
throw new Error('The scheduler cannot deliver project settings.');
|
|
1474
|
+
await applyJobsProjectSettings({
|
|
1475
|
+
org,
|
|
1476
|
+
want: action.desired,
|
|
1477
|
+
ids: context.recordedIds(),
|
|
1478
|
+
port: { setProjectSettings: jobs.setProjectSettings.bind(jobs) },
|
|
1479
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
1480
|
+
persist: () => context.persistScopeIds(),
|
|
1481
|
+
});
|
|
1482
|
+
return {};
|
|
1483
|
+
}
|
|
1484
|
+
case 'files:space-alert': {
|
|
1485
|
+
const files = await this.filesPort();
|
|
1486
|
+
if (!files.setSpaceAlert || !files.removeSpaceAlert)
|
|
1487
|
+
throw new Error('The Files service cannot set and remove space alerts.');
|
|
1488
|
+
await applyFilesSpaceAlert({
|
|
1489
|
+
org,
|
|
1490
|
+
want: action.desired,
|
|
1491
|
+
ids: context.recordedIds(),
|
|
1492
|
+
port: {
|
|
1493
|
+
setSpaceAlert: files.setSpaceAlert.bind(files),
|
|
1494
|
+
removeSpaceAlert: files.removeSpaceAlert.bind(files),
|
|
1495
|
+
},
|
|
1496
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
1497
|
+
forget: (key) => forgetScopeId(context, key),
|
|
1498
|
+
persist: () => context.persistScopeIds(),
|
|
1499
|
+
});
|
|
1500
|
+
return {};
|
|
1501
|
+
}
|
|
1444
1502
|
case 'files:key':
|
|
1445
1503
|
// The org's own key is keyed `<orgId>`, an entry's `<orgId>:<entry>` (F6b).
|
|
1446
1504
|
return action.key.includes(':')
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type DeclaredOrg } from '../org.js';
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
|
+
export interface FilesSpaceAlertInput {
|
|
4
|
+
percent?: number;
|
|
5
|
+
name: string;
|
|
6
|
+
collections: string[];
|
|
7
|
+
recipients: string[];
|
|
8
|
+
}
|
|
9
|
+
export interface FilesSpaceAlertPort {
|
|
10
|
+
setSpaceAlert(orgId: string, entryKey: string, input: FilesSpaceAlertInput): Promise<unknown>;
|
|
11
|
+
removeSpaceAlert(orgId: string, entryKey: string): Promise<unknown>;
|
|
12
|
+
}
|
|
13
|
+
export declare const filesSpaceAlertScopeKey: (key: string) => string;
|
|
14
|
+
/** Addresses are resolved only during apply. Plans and recorded ids hold their hash. */
|
|
15
|
+
export declare function filesSpaceAlertInput(org: DeclaredOrg, entryKey: string, ids: Readonly<Record<string, string>>): FilesSpaceAlertInput | null;
|
|
16
|
+
export type FilesSpaceAlertWant = {
|
|
17
|
+
entryKey: string;
|
|
18
|
+
hash: string;
|
|
19
|
+
} | {
|
|
20
|
+
entryKey: string;
|
|
21
|
+
remove: true;
|
|
22
|
+
};
|
|
23
|
+
export declare function planFilesSpaceAlerts(org: DeclaredOrg, ids: Readonly<Record<string, string>>, ready: boolean, capable: boolean): {
|
|
24
|
+
steps: DraftStep[];
|
|
25
|
+
blocked: {
|
|
26
|
+
key: string;
|
|
27
|
+
description: string;
|
|
28
|
+
}[];
|
|
29
|
+
};
|
|
30
|
+
/** Idempotent delivery; a failed or timed-out call records nothing and can be retried. */
|
|
31
|
+
export declare function applyFilesSpaceAlert(input: {
|
|
32
|
+
org: DeclaredOrg;
|
|
33
|
+
want: FilesSpaceAlertWant;
|
|
34
|
+
ids: Readonly<Record<string, string>>;
|
|
35
|
+
port: FilesSpaceAlertPort;
|
|
36
|
+
record(key: string, value: string): void;
|
|
37
|
+
forget(key: string): void;
|
|
38
|
+
persist(): Promise<void>;
|
|
39
|
+
timeoutMs?: number;
|
|
40
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isLiveEntry } from '../org.js';
|
|
3
|
+
import { withTimeout } from './call-timeout.js';
|
|
4
|
+
import { entryCollectionNames } from './entry-files-key.js';
|
|
5
|
+
const PREFIX = 'files_space_alert_';
|
|
6
|
+
export const filesSpaceAlertScopeKey = (key) => `${PREFIX}${key}`;
|
|
7
|
+
/** Addresses are resolved only during apply. Plans and recorded ids hold their hash. */
|
|
8
|
+
export function filesSpaceAlertInput(org, entryKey, ids) {
|
|
9
|
+
const entry = org.hosting.enabled
|
|
10
|
+
? org.hosting.entries.find((e) => e.key === entryKey && isLiveEntry(e) && e.uses?.files)
|
|
11
|
+
: undefined;
|
|
12
|
+
if (!entry?.uses?.files)
|
|
13
|
+
return null;
|
|
14
|
+
return {
|
|
15
|
+
name: entry.key,
|
|
16
|
+
...(entry.uses.files.spaceAlertPercent === undefined
|
|
17
|
+
? {}
|
|
18
|
+
: { percent: entry.uses.files.spaceAlertPercent }),
|
|
19
|
+
collections: [...new Set(Object.values(entryCollectionNames(entry, ids)))].sort(),
|
|
20
|
+
recipients: [...new Set(org.owners.map((owner) => owner.email.trim().toLowerCase()))].sort(),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const hashOf = (input) => createHash('sha256').update(JSON.stringify(input)).digest('hex');
|
|
24
|
+
export function planFilesSpaceAlerts(org, ids, ready, capable) {
|
|
25
|
+
const steps = [];
|
|
26
|
+
const blocked = [];
|
|
27
|
+
const keys = new Set([
|
|
28
|
+
...org.hosting.entries.filter((e) => isLiveEntry(e) && e.uses?.files).map((e) => e.key),
|
|
29
|
+
...Object.keys(ids)
|
|
30
|
+
.filter((key) => key.startsWith(PREFIX))
|
|
31
|
+
.map((key) => key.slice(PREFIX.length)),
|
|
32
|
+
]);
|
|
33
|
+
for (const entryKey of [...keys].sort()) {
|
|
34
|
+
const input = filesSpaceAlertInput(org, entryKey, ids);
|
|
35
|
+
const hash = input ? hashOf(input) : null;
|
|
36
|
+
if (hash === ids[filesSpaceAlertScopeKey(entryKey)] ||
|
|
37
|
+
(!input && !ids[filesSpaceAlertScopeKey(entryKey)]))
|
|
38
|
+
continue;
|
|
39
|
+
const key = `files:space-alert:${org.id}:${entryKey}`;
|
|
40
|
+
const reason = !ready
|
|
41
|
+
? 'the Files service is not ready'
|
|
42
|
+
: !capable
|
|
43
|
+
? 'the host cannot set and remove space alerts'
|
|
44
|
+
: input && (input.collections.length < 1 || input.collections.length > 50)
|
|
45
|
+
? 'the app needs between one and fifty collections'
|
|
46
|
+
: input && input.recipients.length > 20
|
|
47
|
+
? 'the service accepts at most twenty owners'
|
|
48
|
+
: null;
|
|
49
|
+
if (reason) {
|
|
50
|
+
blocked.push({
|
|
51
|
+
key,
|
|
52
|
+
description: `${org.slug}'s space alert for ${entryKey} is blocked: ${reason}`,
|
|
53
|
+
});
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
steps.push({
|
|
57
|
+
action: {
|
|
58
|
+
system: 'files',
|
|
59
|
+
kind: 'space-alert',
|
|
60
|
+
key: `${org.id}:${entryKey}`,
|
|
61
|
+
desired: input ? { entryKey, hash } : { entryKey, remove: true },
|
|
62
|
+
},
|
|
63
|
+
step: {
|
|
64
|
+
description: `${input ? 'set' : 'remove'} the space alert for ${org.slug}'s ${entryKey}`,
|
|
65
|
+
destroys: !input,
|
|
66
|
+
migrates: false,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return { steps, blocked };
|
|
71
|
+
}
|
|
72
|
+
/** Idempotent delivery; a failed or timed-out call records nothing and can be retried. */
|
|
73
|
+
export async function applyFilesSpaceAlert(input) {
|
|
74
|
+
const { org, want, ids, port } = input;
|
|
75
|
+
const desired = filesSpaceAlertInput(org, want.entryKey, ids);
|
|
76
|
+
const marker = filesSpaceAlertScopeKey(want.entryKey);
|
|
77
|
+
if ('remove' in want) {
|
|
78
|
+
if (desired)
|
|
79
|
+
throw new Error('The app uses Files again. Make a new plan before removing its alert.');
|
|
80
|
+
await withTimeout('Files space alert removal', input.timeoutMs ?? 30_000, () => port.removeSpaceAlert(org.id, want.entryKey));
|
|
81
|
+
input.forget(marker);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
if (!desired || hashOf(desired) !== want.hash)
|
|
85
|
+
throw new Error('The app or its owners changed. Make a new plan before setting its alert.');
|
|
86
|
+
await withTimeout('Files space alert delivery', input.timeoutMs ?? 30_000, () => port.setSpaceAlert(org.id, want.entryKey, desired));
|
|
87
|
+
input.record(marker, want.hash);
|
|
88
|
+
}
|
|
89
|
+
await input.persist();
|
|
90
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type DeclaredOrg } from '../org.js';
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
|
+
export interface JobsProjectSettings {
|
|
4
|
+
retries: number;
|
|
5
|
+
alertOwners: boolean;
|
|
6
|
+
pauseAfterFailures: number | null;
|
|
7
|
+
owners: string[];
|
|
8
|
+
}
|
|
9
|
+
export interface JobsProjectSettingsPort {
|
|
10
|
+
setProjectSettings(project: string, settings: JobsProjectSettings): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
export interface JobsProjectSettingsWant {
|
|
13
|
+
entryKey: string;
|
|
14
|
+
hash: string;
|
|
15
|
+
}
|
|
16
|
+
export declare const jobsProjectSettingsScopeKey: (key: string) => string;
|
|
17
|
+
export declare function jobsProjectSettings(org: DeclaredOrg, entryKey: string): JobsProjectSettings | null;
|
|
18
|
+
export declare function planJobsProjectSettings(org: DeclaredOrg, ids: Readonly<Record<string, string>>, planned: readonly DraftStep[], ready: boolean, capable: boolean): {
|
|
19
|
+
steps: DraftStep[];
|
|
20
|
+
blocked: {
|
|
21
|
+
key: string;
|
|
22
|
+
description: string;
|
|
23
|
+
}[];
|
|
24
|
+
};
|
|
25
|
+
export declare function applyJobsProjectSettings(input: {
|
|
26
|
+
org: DeclaredOrg;
|
|
27
|
+
want: JobsProjectSettingsWant;
|
|
28
|
+
ids: Readonly<Record<string, string>>;
|
|
29
|
+
port: JobsProjectSettingsPort;
|
|
30
|
+
record(key: string, value: string): void;
|
|
31
|
+
persist(): Promise<void>;
|
|
32
|
+
timeoutMs?: number;
|
|
33
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { JOBS_RETRIES_DEFAULT } from '../entry-rules.js';
|
|
3
|
+
import { isLiveEntry } from '../org.js';
|
|
4
|
+
import { withTimeout } from './call-timeout.js';
|
|
5
|
+
import { entryJobsAppliedScopeKey, entryJobsProjectName, entryJobsUndeliveredScopeKey, } from './entry-jobs.js';
|
|
6
|
+
export const jobsProjectSettingsScopeKey = (key) => `jobs_project_settings.${key}`;
|
|
7
|
+
export function jobsProjectSettings(org, entryKey) {
|
|
8
|
+
const entry = org.hosting.enabled
|
|
9
|
+
? org.hosting.entries.find((e) => e.key === entryKey && isLiveEntry(e) && e.uses?.jobs)
|
|
10
|
+
: undefined;
|
|
11
|
+
if (!entry?.uses?.jobs)
|
|
12
|
+
return null;
|
|
13
|
+
const settings = entry.uses.jobs === true ? {} : entry.uses.jobs;
|
|
14
|
+
return {
|
|
15
|
+
retries: settings.retries ?? JOBS_RETRIES_DEFAULT,
|
|
16
|
+
alertOwners: settings.alertOwners ?? true,
|
|
17
|
+
pauseAfterFailures: settings.pauseAfterFailures ?? null,
|
|
18
|
+
owners: [...new Set(org.owners.map((owner) => owner.email.trim().toLowerCase()))].sort(),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const hashOf = (project, settings) => createHash('sha256')
|
|
22
|
+
.update(JSON.stringify([project, settings]))
|
|
23
|
+
.digest('hex');
|
|
24
|
+
export function planJobsProjectSettings(org, ids, planned, ready, capable) {
|
|
25
|
+
const steps = [];
|
|
26
|
+
const blocked = [];
|
|
27
|
+
for (const entry of org.hosting.entries) {
|
|
28
|
+
const settings = jobsProjectSettings(org, entry.key);
|
|
29
|
+
if (!settings)
|
|
30
|
+
continue;
|
|
31
|
+
const hash = hashOf(entryJobsProjectName(org, entry.key), settings);
|
|
32
|
+
if (ids[jobsProjectSettingsScopeKey(entry.key)] === hash)
|
|
33
|
+
continue;
|
|
34
|
+
const key = `${org.id}:${entry.key}`;
|
|
35
|
+
const projectReady = !ids[entryJobsUndeliveredScopeKey(entry.key)] &&
|
|
36
|
+
(Boolean(ids[entryJobsAppliedScopeKey(entry.key)]) ||
|
|
37
|
+
planned.some((p) => p.action.system === 'jobs' && p.action.kind === 'project' && p.action.key === key));
|
|
38
|
+
const reason = !ready
|
|
39
|
+
? 'the scheduler is not ready'
|
|
40
|
+
: !capable
|
|
41
|
+
? 'the host cannot deliver project settings'
|
|
42
|
+
: !projectReady
|
|
43
|
+
? 'the project and its credentials must be provisioned first'
|
|
44
|
+
: settings.owners.length > 50
|
|
45
|
+
? 'the scheduler accepts at most fifty owners'
|
|
46
|
+
: null;
|
|
47
|
+
if (reason) {
|
|
48
|
+
blocked.push({
|
|
49
|
+
key: `jobs:settings:${key}`,
|
|
50
|
+
description: `${org.slug}'s settings for ${entry.key} are blocked: ${reason}`,
|
|
51
|
+
});
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
steps.push({
|
|
55
|
+
action: { system: 'jobs', kind: 'settings', key, desired: { entryKey: entry.key, hash } },
|
|
56
|
+
step: {
|
|
57
|
+
description: `set the job settings and owner alerts for ${org.slug}'s ${entry.key}`,
|
|
58
|
+
destroys: false,
|
|
59
|
+
migrates: false,
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return { steps, blocked };
|
|
64
|
+
}
|
|
65
|
+
export async function applyJobsProjectSettings(input) {
|
|
66
|
+
const { org, want, ids } = input;
|
|
67
|
+
const settings = jobsProjectSettings(org, want.entryKey);
|
|
68
|
+
const name = entryJobsProjectName(org, want.entryKey);
|
|
69
|
+
if (!settings || hashOf(name, settings) !== want.hash)
|
|
70
|
+
throw new Error('The app or its owners changed. Make a new plan before setting its job settings.');
|
|
71
|
+
if (!ids[entryJobsAppliedScopeKey(want.entryKey)] ||
|
|
72
|
+
ids[entryJobsUndeliveredScopeKey(want.entryKey)])
|
|
73
|
+
throw new Error('The job project and its credentials must be provisioned before setting its settings.');
|
|
74
|
+
await withTimeout('Job settings delivery', input.timeoutMs ?? 30_000, () => input.port.setProjectSettings(name, settings));
|
|
75
|
+
input.record(jobsProjectSettingsScopeKey(want.entryKey), want.hash);
|
|
76
|
+
await input.persist();
|
|
77
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { JobsSchedulerPort } from './executor.js';
|
|
2
|
+
import type { JobsProjectSettings } from './jobs-project-settings.js';
|
|
2
3
|
/**
|
|
3
4
|
* #173: the real {@link JobsSchedulerPort}, a thin client over the jobs
|
|
4
5
|
* scheduler's admin HTTP API (https://github.com/wtfalch/jobs README, "The
|
|
@@ -17,6 +18,7 @@ export declare class HttpJobsSchedulerClient implements JobsSchedulerPort {
|
|
|
17
18
|
private readonly fetchImpl;
|
|
18
19
|
private readonly baseUrl;
|
|
19
20
|
constructor(baseUrl: string, adminToken: string, fetchImpl?: typeof fetch);
|
|
21
|
+
setProjectSettings(project: string, settings: JobsProjectSettings): Promise<void>;
|
|
20
22
|
registerProject(input: {
|
|
21
23
|
name: string;
|
|
22
24
|
baseUrl: string;
|
|
@@ -22,6 +22,16 @@ export class HttpJobsSchedulerClient {
|
|
|
22
22
|
this.fetchImpl = fetchImpl;
|
|
23
23
|
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
|
24
24
|
}
|
|
25
|
+
async setProjectSettings(project, settings) {
|
|
26
|
+
const response = await this.fetchImpl(`${this.baseUrl}/admin/projects/${encodeURIComponent(project)}/settings`, {
|
|
27
|
+
method: 'PUT',
|
|
28
|
+
headers: { authorization: `Bearer ${this.adminToken}`, 'content-type': 'application/json' },
|
|
29
|
+
body: JSON.stringify(settings),
|
|
30
|
+
signal: AbortSignal.timeout(ADMIN_CALL_TIMEOUT_MS),
|
|
31
|
+
});
|
|
32
|
+
if (response.status !== 200)
|
|
33
|
+
throw new Error(`jobs scheduler: PUT /admin/projects/<name>/settings answered ${response.status}`);
|
|
34
|
+
}
|
|
25
35
|
async registerProject(input) {
|
|
26
36
|
const response = await this.fetchImpl(`${this.baseUrl}/admin/projects`, {
|
|
27
37
|
method: 'POST',
|
|
@@ -22,6 +22,7 @@ import type { OwnAuthHead } from './own-diff.js';
|
|
|
22
22
|
import { type EdgePortName, type HeldKeyResolver, type PlatformOwnerInput } from './platform-org.js';
|
|
23
23
|
import type { CallsSfuConnectionPort } from './platform-sfu.js';
|
|
24
24
|
import { type ReleaseContext, type ReleaseHost } from './release-host.js';
|
|
25
|
+
import { type ServiceAlertMailWant } from './service-alert-mail.js';
|
|
25
26
|
/** Who did it, for `@wtfalch/audit`'s "who, when, the plan, the result" (#9, #19). */
|
|
26
27
|
export interface AuditActor {
|
|
27
28
|
readonly id: string;
|
|
@@ -182,6 +183,7 @@ export interface StepExecutionContext {
|
|
|
182
183
|
* `FoundryStepExecutor`).
|
|
183
184
|
*/
|
|
184
185
|
export interface StepExecutor {
|
|
186
|
+
serviceAlertMailCurrent?(want: ServiceAlertMailWant, ids: Readonly<Record<string, string>>): Promise<boolean>;
|
|
185
187
|
execute(action: StepAction, context: StepExecutionContext): Promise<{
|
|
186
188
|
resourceId?: string;
|
|
187
189
|
} | undefined>;
|
|
@@ -626,6 +628,10 @@ export interface FoundryPlanApplyOptions {
|
|
|
626
628
|
* `uses.jobs` entry's Boule key is reported blocked.
|
|
627
629
|
*/
|
|
628
630
|
jobsProjectKeys?: boolean;
|
|
631
|
+
/** The host can deliver per-app Files space alerts. */
|
|
632
|
+
serviceAlertMail?: boolean;
|
|
633
|
+
filesSpaceAlerts?: boolean;
|
|
634
|
+
jobsProjectSettings?: boolean;
|
|
629
635
|
/**
|
|
630
636
|
* foundry#1122: true once ai offers a company Boule's five read permissions (`ai.usage:read`,
|
|
631
637
|
* `ai.runs:read`, `ai.budgets:read`, `ai.budgets:update`, `ai.offerings:read`; all `offered: false` today).
|
|
@@ -801,6 +807,9 @@ export declare class FoundryPlanApply implements PlanApply {
|
|
|
801
807
|
private readonly analyticsPort;
|
|
802
808
|
private readonly reportingPort;
|
|
803
809
|
private readonly jobsProjectKeys;
|
|
810
|
+
private readonly serviceAlertMail;
|
|
811
|
+
private readonly filesSpaceAlerts;
|
|
812
|
+
private readonly jobsProjectSettings;
|
|
804
813
|
private readonly aiBouleReads;
|
|
805
814
|
private readonly workerAppDatabase;
|
|
806
815
|
private readonly workerAppNoDeleteCheck;
|
|
@@ -1060,6 +1069,7 @@ export declare class FoundryPlanApply implements PlanApply {
|
|
|
1060
1069
|
/** #886: the head of an own org's declared auth branch, or nothing when the org is not own or the executor has no read. */
|
|
1061
1070
|
private ownAuthHeadFor;
|
|
1062
1071
|
/** An own org's Lettermint key is held. A resolver that throws counts as not held. */
|
|
1072
|
+
private readServiceAlertMail;
|
|
1063
1073
|
private ownMailKeyHeld;
|
|
1064
1074
|
/** An own org's registrar token is held (a rerun after a crash delivers it instead of blocking). */
|
|
1065
1075
|
private ownRegistrarTokenHeld;
|
package/dist/plan/plan-apply.js
CHANGED
|
@@ -41,6 +41,7 @@ import { privatePageGateKey } from './private-pages.js';
|
|
|
41
41
|
import { CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY, CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, } from './proxy-labels.js';
|
|
42
42
|
import { finishStageCopy, runSubjectRelease, } from './release-host.js';
|
|
43
43
|
import { remoteDatabaseFactsFrom, remoteDatabaseKeys } from './remote-database.js';
|
|
44
|
+
import { planServiceAlertMail } from './service-alert-mail.js';
|
|
44
45
|
import { STAGE_COPY_LOCK_MS, claimStageCopyLock, parseStageCopyLock, stageCopyLockValue, } from './stage-copy-host.js';
|
|
45
46
|
import { heldSecretId, heldSecretVersion, liveWorkerApps, workerAppTraceScopeKeys, } from './worker-app.js';
|
|
46
47
|
import { liveWorkers, workerTraceScopeKeys } from './worker.js';
|
|
@@ -194,6 +195,9 @@ export class FoundryPlanApply {
|
|
|
194
195
|
analyticsPort;
|
|
195
196
|
reportingPort;
|
|
196
197
|
jobsProjectKeys;
|
|
198
|
+
serviceAlertMail;
|
|
199
|
+
filesSpaceAlerts;
|
|
200
|
+
jobsProjectSettings;
|
|
197
201
|
aiBouleReads;
|
|
198
202
|
workerAppDatabase;
|
|
199
203
|
workerAppNoDeleteCheck;
|
|
@@ -244,6 +248,9 @@ export class FoundryPlanApply {
|
|
|
244
248
|
this.analyticsPort = options.analyticsPort ?? false;
|
|
245
249
|
this.reportingPort = options.reportingPort ?? false;
|
|
246
250
|
this.jobsProjectKeys = options.jobsProjectKeys ?? false;
|
|
251
|
+
this.filesSpaceAlerts = options.filesSpaceAlerts ?? false;
|
|
252
|
+
this.serviceAlertMail = options.serviceAlertMail ?? false;
|
|
253
|
+
this.jobsProjectSettings = options.jobsProjectSettings ?? false;
|
|
247
254
|
this.aiBouleReads = options.aiBouleReads ?? false;
|
|
248
255
|
this.workerAppDatabase = options.workerAppDatabase ?? false;
|
|
249
256
|
this.workerAppNoDeleteCheck = options.workerAppNoDeleteCheck ?? false;
|
|
@@ -1720,6 +1727,10 @@ export class FoundryPlanApply {
|
|
|
1720
1727
|
analyticsPort: this.analyticsPort,
|
|
1721
1728
|
reportingPort: this.reportingPort,
|
|
1722
1729
|
jobsProjectKeys: this.jobsProjectKeys,
|
|
1730
|
+
filesSpaceAlerts: this.filesSpaceAlerts,
|
|
1731
|
+
serviceAlertMail: this.serviceAlertMail,
|
|
1732
|
+
serviceAlertMailCurrent: await this.readServiceAlertMail(org, platformReadiness, recorded),
|
|
1733
|
+
jobsProjectSettings: this.jobsProjectSettings,
|
|
1723
1734
|
aiBouleReads: this.aiBouleReads,
|
|
1724
1735
|
workerAppDatabase: this.workerAppDatabase,
|
|
1725
1736
|
workerAppNoDeleteCheck: this.workerAppNoDeleteCheck,
|
|
@@ -1995,6 +2006,21 @@ export class FoundryPlanApply {
|
|
|
1995
2006
|
return head ? { ownAuthHead: head } : {};
|
|
1996
2007
|
}
|
|
1997
2008
|
/** An own org's Lettermint key is held. A resolver that throws counts as not held. */
|
|
2009
|
+
async readServiceAlertMail(org, readiness, ids) {
|
|
2010
|
+
const current = {};
|
|
2011
|
+
const planned = planServiceAlertMail(org, readiness, this.serviceAlertMail, await this.ownMailKeyHeld(org));
|
|
2012
|
+
for (const step of planned.steps) {
|
|
2013
|
+
const want = step.action.desired;
|
|
2014
|
+
try {
|
|
2015
|
+
current[want.service] =
|
|
2016
|
+
(await this.executor.serviceAlertMailCurrent?.(want, ids)) ?? 'unreadable';
|
|
2017
|
+
}
|
|
2018
|
+
catch {
|
|
2019
|
+
current[want.service] = 'unreadable';
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
return current;
|
|
2023
|
+
}
|
|
1998
2024
|
async ownMailKeyHeld(org) {
|
|
1999
2025
|
const held = this.platformDeps?.heldKeys;
|
|
2000
2026
|
if (!ownsServices(org) || !held)
|
|
@@ -1262,6 +1262,7 @@ export declare const PLATFORM_USAGE_TAIL_SCRIPT_NAME = "platform-usage-tail";
|
|
|
1262
1262
|
export declare const PLATFORM_USAGE_TAIL_KEY_REF = "usage-tail:worker:key";
|
|
1263
1263
|
export declare const PLATFORM_USAGE_TAIL_KEY_UNDELIVERED_SCOPE_KEY = "usage_tail_key_undelivered";
|
|
1264
1264
|
export declare const PLATFORM_USAGE_TAIL_KEY_SET_SCOPE_KEY = "usage_tail_key_set";
|
|
1265
|
+
export declare const PLATFORM_USAGE_TAIL_APPLIED_URL_SCOPE_KEY = "usage_tail_applied_url";
|
|
1265
1266
|
export declare const PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY = "usage_tail_applied_tag";
|
|
1266
1267
|
export declare const PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY = "usage_tail_applied_sha256";
|
|
1267
1268
|
export declare const PLATFORM_USAGE_TAIL_REMOVED_SCOPE_KEY = "usage_tail_removed";
|
|
@@ -1461,6 +1461,7 @@ export const PLATFORM_USAGE_TAIL_SCRIPT_NAME = 'platform-usage-tail';
|
|
|
1461
1461
|
export const PLATFORM_USAGE_TAIL_KEY_REF = 'usage-tail:worker:key';
|
|
1462
1462
|
export const PLATFORM_USAGE_TAIL_KEY_UNDELIVERED_SCOPE_KEY = 'usage_tail_key_undelivered';
|
|
1463
1463
|
export const PLATFORM_USAGE_TAIL_KEY_SET_SCOPE_KEY = 'usage_tail_key_set';
|
|
1464
|
+
export const PLATFORM_USAGE_TAIL_APPLIED_URL_SCOPE_KEY = 'usage_tail_applied_url';
|
|
1464
1465
|
export const PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY = 'usage_tail_applied_tag';
|
|
1465
1466
|
export const PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY = 'usage_tail_applied_sha256';
|
|
1466
1467
|
export const PLATFORM_USAGE_TAIL_REMOVED_SCOPE_KEY = 'usage_tail_removed';
|
|
@@ -3488,12 +3489,17 @@ function planUsageTail(platform, recordedIds, executorBlocked, edgeApplied) {
|
|
|
3488
3489
|
const applied = deployed &&
|
|
3489
3490
|
Boolean(recordedIds[PLATFORM_USAGE_TAIL_KEY_SET_SCOPE_KEY]) &&
|
|
3490
3491
|
recordedIds[PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY] === usageTail.tag &&
|
|
3491
|
-
recordedIds[PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY] === usageTail.sha256
|
|
3492
|
+
recordedIds[PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY] === usageTail.sha256 &&
|
|
3493
|
+
recordedIds[PLATFORM_USAGE_TAIL_APPLIED_URL_SCOPE_KEY] === `https://${platform.edge.hostname}`;
|
|
3492
3494
|
if (applied)
|
|
3493
3495
|
return { step: null, blocked: [] };
|
|
3494
3496
|
return {
|
|
3495
3497
|
step: {
|
|
3496
|
-
action: action({
|
|
3498
|
+
action: action({
|
|
3499
|
+
gitRef: usageTail.tag,
|
|
3500
|
+
sha256: usageTail.sha256,
|
|
3501
|
+
url: `https://${platform.edge.hostname}`,
|
|
3502
|
+
}),
|
|
3497
3503
|
step: {
|
|
3498
3504
|
description: `deploy the usage tail Worker ${PLATFORM_USAGE_TAIL_SCRIPT_NAME} from edge release ${usageTail.tag}, with its held key`,
|
|
3499
3505
|
destroys: false,
|
|
@@ -69,6 +69,9 @@ export interface OrgPlatformReadiness {
|
|
|
69
69
|
platformLettermintProjectId: string | null;
|
|
70
70
|
/** The files host (#171). Files is always on for every org (#80), so `false` always blocks rather than only mattering when a flag is set. */
|
|
71
71
|
filesReady: boolean;
|
|
72
|
+
/** Existing production service hosts, for alert sender reconciliation. */
|
|
73
|
+
filesAppId?: string;
|
|
74
|
+
jobsAppId?: string;
|
|
72
75
|
/**
|
|
73
76
|
* The files host's internal address, which an entry with `uses.files` gets
|
|
74
77
|
* as `FILES_URL`. `null` or absent until the platform has recorded it.
|
|
@@ -137,6 +137,8 @@ edgePortPresent = false) {
|
|
|
137
137
|
platformOwnerOrgId: platform.ownerOrgId || null,
|
|
138
138
|
platformLettermintProjectId: platform.mail.lettermintProjectId ?? null,
|
|
139
139
|
filesReady: filesApplied && missingKeys.files === null,
|
|
140
|
+
filesAppId: platformRecordedIds[OWN_FILES_RUNNER_SCOPE_KEY],
|
|
141
|
+
jobsAppId: platformRecordedIds[`coolify:jobs:${JOBS_SCHEDULER_KEY}`],
|
|
140
142
|
filesUrl: filesApplied
|
|
141
143
|
? (platformRecordedIds[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY] ?? null)
|
|
142
144
|
: null,
|
|
@@ -231,6 +233,8 @@ export function orgPlatformReadinessFor(org, base, orgRecordedIds, heldRefs) {
|
|
|
231
233
|
authReady,
|
|
232
234
|
authIssuer: authReady ? ownHostnames(org).issuerUrl : null,
|
|
233
235
|
filesReady: filesApplied && filesUrl !== undefined && missingKeys.files === null,
|
|
236
|
+
filesAppId: orgRecordedIds[OWN_FILES_RUNNER_SCOPE_KEY],
|
|
237
|
+
jobsAppId: orgRecordedIds[`coolify:own-jobs:${org.id}`],
|
|
234
238
|
filesUrl: filesApplied ? (filesUrl ?? null) : null,
|
|
235
239
|
jobsReady: jobsApplied && jobsUrl !== undefined && missingKeys.jobs === null,
|
|
236
240
|
...(jobsApplied && jobsUrl === undefined ? { jobsUrlMissing: true } : {}),
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type DeclaredOrg } from '../org.js';
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
|
+
import type { CoolifyPort } from './executor.js';
|
|
4
|
+
import type { OrgPlatformReadiness } from './platform-org.js';
|
|
5
|
+
export interface ServiceAlertMailWant {
|
|
6
|
+
service: 'files' | 'jobs';
|
|
7
|
+
appId: string;
|
|
8
|
+
from: string;
|
|
9
|
+
tokenRef: string;
|
|
10
|
+
}
|
|
11
|
+
/** Reconcile existing service hosts against live env, including held-token rotations. */
|
|
12
|
+
export declare function planServiceAlertMail(org: DeclaredOrg, readiness: OrgPlatformReadiness, capable: boolean, ownMailKeyHeld: boolean, current?: Partial<Record<'files' | 'jobs', boolean | 'unreadable'>>): {
|
|
13
|
+
steps: DraftStep[];
|
|
14
|
+
blocked: {
|
|
15
|
+
key: string;
|
|
16
|
+
description: string;
|
|
17
|
+
}[];
|
|
18
|
+
};
|
|
19
|
+
export declare function applyServiceAlertMail(input: {
|
|
20
|
+
want: ServiceAlertMailWant;
|
|
21
|
+
ids: Readonly<Record<string, string>>;
|
|
22
|
+
coolify: Pick<CoolifyPort, 'listEnvs' | 'bulkUpsertEnvs' | 'deployAndWait'>;
|
|
23
|
+
resolve(ref: string): Promise<string | null>;
|
|
24
|
+
secret(value: string): void;
|
|
25
|
+
record(key: string, value: string): void;
|
|
26
|
+
forget(key: string): void;
|
|
27
|
+
persist(): Promise<void>;
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
}): Promise<void>;
|
|
30
|
+
/** Compare in memory only; no token or token digest is exposed to the planner. */
|
|
31
|
+
export declare function serviceAlertMailCurrent(input: {
|
|
32
|
+
want: ServiceAlertMailWant;
|
|
33
|
+
ids: Readonly<Record<string, string>>;
|
|
34
|
+
coolify: Pick<CoolifyPort, 'listEnvs'>;
|
|
35
|
+
resolve(ref: string): Promise<string | null>;
|
|
36
|
+
}): Promise<boolean>;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { ownsServices } from '../org.js';
|
|
2
|
+
import { withTimeout } from './call-timeout.js';
|
|
3
|
+
import { ownHostnames } from './own-hostnames.js';
|
|
4
|
+
import { orgTarget, platformTarget } from './own-services.js';
|
|
5
|
+
/** Reconcile existing service hosts against live env, including held-token rotations. */
|
|
6
|
+
export function planServiceAlertMail(org, readiness, capable, ownMailKeyHeld, current = {}) {
|
|
7
|
+
const steps = [];
|
|
8
|
+
const blocked = [];
|
|
9
|
+
const own = ownsServices(org);
|
|
10
|
+
if (!own && org.id !== readiness.platformOwnerOrgId)
|
|
11
|
+
return { steps, blocked };
|
|
12
|
+
for (const service of ['files', 'jobs']) {
|
|
13
|
+
const appId = service === 'files' ? readiness.filesAppId : readiness.jobsAppId;
|
|
14
|
+
if (!appId)
|
|
15
|
+
continue; // A later reconcile follows provisioning the service host.
|
|
16
|
+
const ownerMail = service === 'jobs' && own;
|
|
17
|
+
const domain = ownerMail ? ownHostnames(org).mailDomain : readiness.platformApex;
|
|
18
|
+
const held = ownerMail ? ownMailKeyHeld : readiness.mailKeyHeld;
|
|
19
|
+
const key = `${org.id}:${service}`;
|
|
20
|
+
if (current[service] === true)
|
|
21
|
+
continue;
|
|
22
|
+
const reason = current[service] === 'unreadable'
|
|
23
|
+
? 'the current sender configuration could not be read'
|
|
24
|
+
: !capable
|
|
25
|
+
? 'the host cannot deliver service alert mail'
|
|
26
|
+
: !domain
|
|
27
|
+
? 'the sender domain is not declared'
|
|
28
|
+
: !held
|
|
29
|
+
? 'the sender credential is not held'
|
|
30
|
+
: null;
|
|
31
|
+
if (reason) {
|
|
32
|
+
blocked.push({
|
|
33
|
+
key: `coolify:service-alert-mail:${key}`,
|
|
34
|
+
description: `${org.slug}'s ${service} alert mail is blocked: ${reason}`,
|
|
35
|
+
});
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
steps.push({
|
|
39
|
+
action: {
|
|
40
|
+
system: 'coolify',
|
|
41
|
+
kind: 'service-alert-mail',
|
|
42
|
+
key,
|
|
43
|
+
desired: {
|
|
44
|
+
service,
|
|
45
|
+
appId,
|
|
46
|
+
from: `noreply@${domain}`,
|
|
47
|
+
tokenRef: ownerMail
|
|
48
|
+
? orgTarget(org).refs.brokerMailKey
|
|
49
|
+
: platformTarget().refs.brokerMailKey,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
step: {
|
|
53
|
+
description: `reconcile the sender for ${org.slug}'s ${service} alerts`,
|
|
54
|
+
destroys: false,
|
|
55
|
+
migrates: false,
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return { steps, blocked };
|
|
60
|
+
}
|
|
61
|
+
export async function applyServiceAlertMail(input) {
|
|
62
|
+
const { want, coolify } = input;
|
|
63
|
+
const call = (fn) => withTimeout('Service alert mail delivery', input.timeoutMs ?? 30_000, fn);
|
|
64
|
+
const rows = (await call(() => coolify.listEnvs(want.appId))).filter((row) => !row.is_preview);
|
|
65
|
+
// Stage copies are provisioned through stageMailEnv. Never turn a sink host into a live sender.
|
|
66
|
+
if (rows.some((row) => row.key === 'MAIL_SINK_URL' && row.value))
|
|
67
|
+
throw new Error('Service alert mail delivery refuses a stage mail sink.');
|
|
68
|
+
const token = await call(() => input.resolve(want.tokenRef));
|
|
69
|
+
if (!token)
|
|
70
|
+
throw new Error('Service alert mail delivery: the sender credential is not held.');
|
|
71
|
+
input.secret(token);
|
|
72
|
+
const envs = [
|
|
73
|
+
{ key: 'MAIL_FROM', value: want.from, literal: true },
|
|
74
|
+
{ key: 'LETTERMINT_API_TOKEN', value: token, literal: true },
|
|
75
|
+
];
|
|
76
|
+
const changed = envs.some((env) => rows.find((row) => row.key === env.key)?.value !== env.value);
|
|
77
|
+
const marker = `service_alert_mail_pending.${want.service}`;
|
|
78
|
+
if (!changed && input.ids[marker] !== want.appId)
|
|
79
|
+
return;
|
|
80
|
+
// Persist before the env write. A failed write or deployment must be retried even if env now matches.
|
|
81
|
+
input.record(marker, want.appId);
|
|
82
|
+
await input.persist();
|
|
83
|
+
if (changed)
|
|
84
|
+
await call(() => coolify.bulkUpsertEnvs(want.appId, envs));
|
|
85
|
+
const deployment = await withTimeout('Service alert mail deployment', 10 * 60_000, () => coolify.deployAndWait(want.appId));
|
|
86
|
+
if (deployment.status !== 'finished')
|
|
87
|
+
throw new Error('Service alert mail deployment did not finish successfully.');
|
|
88
|
+
input.forget(marker);
|
|
89
|
+
await input.persist();
|
|
90
|
+
}
|
|
91
|
+
/** Compare in memory only; no token or token digest is exposed to the planner. */
|
|
92
|
+
export async function serviceAlertMailCurrent(input) {
|
|
93
|
+
const { want } = input;
|
|
94
|
+
if (input.ids[`service_alert_mail_pending.${want.service}`])
|
|
95
|
+
return false;
|
|
96
|
+
return withTimeout('Service alert mail read', 30_000, async () => {
|
|
97
|
+
const rows = (await input.coolify.listEnvs(want.appId)).filter((row) => !row.is_preview);
|
|
98
|
+
if (rows.some((row) => row.key === 'MAIL_SINK_URL' && row.value))
|
|
99
|
+
throw new Error('Service alert mail read refuses a stage mail sink.');
|
|
100
|
+
const token = await input.resolve(want.tokenRef);
|
|
101
|
+
return (Boolean(token) &&
|
|
102
|
+
rows.some((row) => row.key === 'MAIL_FROM' && row.value === want.from) &&
|
|
103
|
+
rows.some((row) => row.key === 'LETTERMINT_API_TOKEN' && row.value === token));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
2
|
import { usageTailArtifactUrl } from '../platform.js';
|
|
3
|
-
import { PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY, PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY, PLATFORM_USAGE_TAIL_KEY, PLATFORM_USAGE_TAIL_KEY_REF, PLATFORM_USAGE_TAIL_KEY_SET_SCOPE_KEY, PLATFORM_USAGE_TAIL_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_USAGE_TAIL_REMOVED_SCOPE_KEY, PLATFORM_USAGE_TAIL_SCRIPT_NAME, } from './platform-diff.js';
|
|
3
|
+
import { PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY, PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY, PLATFORM_USAGE_TAIL_APPLIED_URL_SCOPE_KEY, PLATFORM_USAGE_TAIL_KEY, PLATFORM_USAGE_TAIL_KEY_REF, PLATFORM_USAGE_TAIL_KEY_SET_SCOPE_KEY, PLATFORM_USAGE_TAIL_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_USAGE_TAIL_REMOVED_SCOPE_KEY, PLATFORM_USAGE_TAIL_SCRIPT_NAME, } from './platform-diff.js';
|
|
4
4
|
/** The date Cloudflare's Workers runtime compatibility is pinned to; no Workers feature here needs a later one. */
|
|
5
5
|
const USAGE_TAIL_COMPATIBILITY_DATE = '2026-10-01';
|
|
6
6
|
/**
|
|
@@ -28,6 +28,7 @@ export async function provisionPlatformUsageTail(context, deps) {
|
|
|
28
28
|
PLATFORM_USAGE_TAIL_KEY_UNDELIVERED_SCOPE_KEY,
|
|
29
29
|
PLATFORM_USAGE_TAIL_KEY_SET_SCOPE_KEY,
|
|
30
30
|
PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY,
|
|
31
|
+
PLATFORM_USAGE_TAIL_APPLIED_URL_SCOPE_KEY,
|
|
31
32
|
PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY,
|
|
32
33
|
]) {
|
|
33
34
|
context.recordScopeId(key, '');
|
|
@@ -64,7 +65,9 @@ export async function provisionPlatformUsageTail(context, deps) {
|
|
|
64
65
|
artifactUrl: usageTailArtifactUrl(usageTail.tag),
|
|
65
66
|
artifactSha256: usageTail.sha256,
|
|
66
67
|
compatibilityDate: USAGE_TAIL_COMPATIBILITY_DATE,
|
|
67
|
-
bindings: [
|
|
68
|
+
bindings: [
|
|
69
|
+
{ type: 'plain_text', name: 'EDGE_USAGE_URL', text: `https://${platform.edge.hostname}` },
|
|
70
|
+
],
|
|
68
71
|
});
|
|
69
72
|
// The script is foundry's from here on. Recorded now, not at the end: after
|
|
70
73
|
// a later failure the next plan must repair this Worker, not take it for a
|
|
@@ -84,6 +87,7 @@ export async function provisionPlatformUsageTail(context, deps) {
|
|
|
84
87
|
// Last, after every write the applied values depend on.
|
|
85
88
|
context.recordScopeId(PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY, usageTail.tag);
|
|
86
89
|
context.recordScopeId(PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY, usageTail.sha256);
|
|
90
|
+
context.recordScopeId(PLATFORM_USAGE_TAIL_APPLIED_URL_SCOPE_KEY, `https://${platform.edge.hostname}`);
|
|
87
91
|
return { resourceId: PLATFORM_USAGE_TAIL_SCRIPT_NAME };
|
|
88
92
|
}
|
|
89
93
|
/** Rule 1: delivery is tried up to `deliverAttempts` times; the error names the ref only, never the cause. */
|