@wtfalch/foundry 0.19.1 → 0.21.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.d.ts +13 -0
- package/dist/bootstrap/declare.js +31 -7
- package/dist/bootstrap/versions.d.ts +1 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +4 -2
- package/dist/migrations/0046_platform_hc.sql +19 -0
- package/dist/plan/cloudflared-connector.d.ts +3 -2
- package/dist/plan/cloudflared-connector.js +2 -2
- package/dist/plan/deploy-health.js +5 -1
- package/dist/plan/diff.js +27 -4
- 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 +146 -143
- package/dist/plan/executor.js +370 -115
- package/dist/plan/health-check-settings.d.ts +47 -0
- package/dist/plan/health-check-settings.js +47 -0
- package/dist/plan/health-targets.d.ts +86 -0
- package/dist/plan/health-targets.js +344 -0
- package/dist/plan/index.d.ts +17 -3
- package/dist/plan/index.js +9 -2
- package/dist/plan/live.d.ts +12 -1
- package/dist/plan/plan-apply.d.ts +4 -0
- package/dist/plan/plan-apply.js +5 -1
- package/dist/plan/platform-diff.d.ts +127 -0
- package/dist/plan/platform-diff.js +304 -8
- package/dist/plan/platform-providers.d.ts +202 -7
- package/dist/plan/platform-providers.js +545 -79
- package/dist/plan/providers-readers.d.ts +23 -1
- package/dist/plan/providers-readers.js +61 -1
- package/dist/plan/rollout.d.ts +9 -8
- package/dist/platform.d.ts +14 -1
- package/dist/platform.js +1 -0
- package/dist/store/postgres.js +8 -0
- package/dist/store/schema.d.ts +51 -0
- package/dist/store/schema.js +4 -0
- package/package.json +2 -1
- package/versions.json +2 -1
|
@@ -21,6 +21,19 @@ export declare const DEFAULT_GIT_REF = "main";
|
|
|
21
21
|
* file's loader follows.
|
|
22
22
|
*/
|
|
23
23
|
export declare const DEFAULT_MAIL_IMAGE_TAG: string;
|
|
24
|
+
/**
|
|
25
|
+
* #540: the Healthchecks image tag the platform's `hc` step runs.
|
|
26
|
+
* `platform.hc.gitRef` is an image tag, the same overload `mail.gitRef`
|
|
27
|
+
* carries. #540 shipped this floating at `'latest'`, with no
|
|
28
|
+
* `versions.json` pin and no `floatingTagBlocked` guard at all -- a real
|
|
29
|
+
* reviewed gap, closed round 2 the same way Stalwart's own tag is pinned:
|
|
30
|
+
* `versions.json`'s `hc` entry, checked against `healthchecks/healthchecks`'s
|
|
31
|
+
* real GitHub releases and Docker Hub tags (2026-10, latest stable `v4.4`)
|
|
32
|
+
* rather than guessed, and `hcImageTagBlocked` (`platform-providers.ts`)
|
|
33
|
+
* refuses a floating one the way `mailImageTagBlocked` already does for
|
|
34
|
+
* Stalwart.
|
|
35
|
+
*/
|
|
36
|
+
export declare const DEFAULT_HC_IMAGE_TAG: string;
|
|
24
37
|
export interface BootstrapCompany {
|
|
25
38
|
name: string;
|
|
26
39
|
slug: string;
|
|
@@ -23,12 +23,28 @@ export const DEFAULT_GIT_REF = 'main';
|
|
|
23
23
|
*/
|
|
24
24
|
export const DEFAULT_MAIL_IMAGE_TAG = VERSIONS.stalwart;
|
|
25
25
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
26
|
+
* #540: the Healthchecks image tag the platform's `hc` step runs.
|
|
27
|
+
* `platform.hc.gitRef` is an image tag, the same overload `mail.gitRef`
|
|
28
|
+
* carries. #540 shipped this floating at `'latest'`, with no
|
|
29
|
+
* `versions.json` pin and no `floatingTagBlocked` guard at all -- a real
|
|
30
|
+
* reviewed gap, closed round 2 the same way Stalwart's own tag is pinned:
|
|
31
|
+
* `versions.json`'s `hc` entry, checked against `healthchecks/healthchecks`'s
|
|
32
|
+
* real GitHub releases and Docker Hub tags (2026-10, latest stable `v4.4`)
|
|
33
|
+
* rather than guessed, and `hcImageTagBlocked` (`platform-providers.ts`)
|
|
34
|
+
* refuses a floating one the way `mailImageTagBlocked` already does for
|
|
35
|
+
* Stalwart.
|
|
36
|
+
*/
|
|
37
|
+
export const DEFAULT_HC_IMAGE_TAG = VERSIONS.hc;
|
|
38
|
+
/**
|
|
39
|
+
* Every platform service except `sfu` is enabled, like the other steps: a
|
|
40
|
+
* service whose dependencies the host did not supply is reported blocked,
|
|
41
|
+
* not planned. `sfu` stays off because it needs a held SSH key and host
|
|
42
|
+
* fingerprint, a separate human step (issue 170). `edge` is on because every
|
|
43
|
+
* platform DNS record is written through it (F4.dns); its own Cloudflare API
|
|
44
|
+
* token is a held key (`cloudflare:edge:apiToken`), and until a human holds
|
|
45
|
+
* it the planner reports edge blocked, and auth, mail and archon with it.
|
|
46
|
+
* `hc` (#540) is on too: it needs no held key of its own, only edge's DNS,
|
|
47
|
+
* the same as mail and archon.
|
|
32
48
|
*/
|
|
33
49
|
const SERVICES_ENABLED_BY_DEFAULT = new Set([
|
|
34
50
|
'auth',
|
|
@@ -38,6 +54,8 @@ const SERVICES_ENABLED_BY_DEFAULT = new Set([
|
|
|
38
54
|
'jobs',
|
|
39
55
|
'integrations',
|
|
40
56
|
'archon',
|
|
57
|
+
'edge',
|
|
58
|
+
'hc',
|
|
41
59
|
]);
|
|
42
60
|
/**
|
|
43
61
|
* The owner as `OwnerIdentity`, trimmed. Throws a plain error naming the
|
|
@@ -113,7 +131,13 @@ export function declaredPlatformFor(apex, serverIpv4) {
|
|
|
113
131
|
services[key] = {
|
|
114
132
|
enabled,
|
|
115
133
|
hostname: defaultPlatformHostname(key, apex),
|
|
116
|
-
gitRef: !enabled
|
|
134
|
+
gitRef: !enabled
|
|
135
|
+
? ''
|
|
136
|
+
: key === 'mail'
|
|
137
|
+
? DEFAULT_MAIL_IMAGE_TAG
|
|
138
|
+
: key === 'hc'
|
|
139
|
+
? DEFAULT_HC_IMAGE_TAG
|
|
140
|
+
: DEFAULT_GIT_REF,
|
|
117
141
|
};
|
|
118
142
|
}
|
|
119
143
|
return {
|
package/dist/index.d.ts
CHANGED
|
@@ -20,7 +20,9 @@ export type { FoundryStoreDb } from './store/postgres.js';
|
|
|
20
20
|
export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, type AuditEventRow, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, type AppliedVersionRow, type NewAppliedVersionRow, type DeclaredPlatformRow, type NewDeclaredPlatformRow, type RequestedChangeRow, type NewRequestedChangeRow, } from './store/schema.js';
|
|
21
21
|
export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, PlatformCloudflareReader, PlatformCoolifyReader, PlatformLiveReaders, StepAction, PlannedStep, StepSystem, PlanDetail, PlatformPlanDetail, AdoptCandidate, AdoptInput, AdoptPlan, AdoptSnapshot, AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan/index.js';
|
|
22
22
|
export { AdoptRefusedError } from './plan/index.js';
|
|
23
|
-
export {
|
|
23
|
+
export { createEdgeAccess, createEdgeDns, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
|
|
24
|
+
export type { AccessAppSummary, AccessDecision, AccessPolicyRule, AccessPort, DnsPort, EdgePlatformAccess, EdgePlatformDns, EdgePlatformRateLimits, EdgePlatformWorkers, EdgePlatformZones, EdgeZonesOptions, ZonePort, EdgeRateLimitsOptions, RateLimitPort, RateLimitRuleInput, EdgeWorkersOptions, EdgePlatformTunnels, TunnelPort, } from './plan/index.js';
|
|
25
|
+
export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
|
|
24
26
|
export type { HeldKeyResolver, OrgPlatformReadiness } from './plan/index.js';
|
|
25
27
|
export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
|
|
26
28
|
export type { PlatformOrgClientFactories } from './plan/index.js';
|
|
@@ -28,8 +30,10 @@ export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
|
|
|
28
30
|
export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, PlatformOrgPortSource, AgoraHostPort, AgoraHostValues, LoginHostBinding, LoginHostBindingsPort, } from './plan/index.js';
|
|
29
31
|
export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
|
|
30
32
|
export { HttpJobsSchedulerClient } from './plan/index.js';
|
|
33
|
+
export type { HealthTarget, HealthTargetDepth, HealthTargetExpect } from './plan/index.js';
|
|
34
|
+
export { healthTargets, platformHealthTargets } from './plan/index.js';
|
|
31
35
|
export type { ScopeResolver, RecordedIds } from './plan/index.js';
|
|
32
|
-
export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
|
|
36
|
+
export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
|
|
33
37
|
export type { ZitadelListAppsRawApp } from './plan/index.js';
|
|
34
38
|
export { adaptZitadelListApps } from './plan/index.js';
|
|
35
39
|
export type { MailIdentityApp, MailIdentityAudienceCheck } from './plan/index.js';
|
|
@@ -37,7 +41,7 @@ export { checkMailIdentityAudience } from './plan/index.js';
|
|
|
37
41
|
export type { MailAllowlistApp } from './plan/index.js';
|
|
38
42
|
export { checkMailAppsAllowlist } from './plan/index.js';
|
|
39
43
|
export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, createZitadelAdminMinter, createZitadelProjectRoles, PLATFORM_AUTH_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './plan/index.js';
|
|
40
|
-
export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformEdgeRuntime, } from './plan/index.js';
|
|
44
|
+
export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformDnsFactory, PlatformAccessFactory, PlatformAccessPort, PlatformTunnelsFactory, PlatformEdgeRuntime, } from './plan/index.js';
|
|
41
45
|
export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './plan/index.js';
|
|
42
46
|
export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './plan/index.js';
|
|
43
47
|
export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './plan/index.js';
|
package/dist/index.js
CHANGED
|
@@ -12,12 +12,14 @@ export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, cre
|
|
|
12
12
|
export { createPostgresAuditLog } from './store/audit.js';
|
|
13
13
|
export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, } from './store/schema.js';
|
|
14
14
|
export { AdoptRefusedError } from './plan/index.js';
|
|
15
|
-
export {
|
|
15
|
+
export { createEdgeAccess, createEdgeDns, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
|
|
16
|
+
export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
|
|
16
17
|
export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
|
|
17
18
|
export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
|
|
18
19
|
export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
|
|
19
20
|
export { HttpJobsSchedulerClient } from './plan/index.js';
|
|
20
|
-
export {
|
|
21
|
+
export { healthTargets, platformHealthTargets } from './plan/index.js';
|
|
22
|
+
export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
|
|
21
23
|
export { adaptZitadelListApps } from './plan/index.js';
|
|
22
24
|
export { checkMailIdentityAudience } from './plan/index.js';
|
|
23
25
|
export { checkMailAppsAllowlist } from './plan/index.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- @wtfalch/foundry: #540 -- the platform's own heartbeat server (self-hosted
|
|
2
|
+
-- Healthchecks), added to the declared platform the same way every other
|
|
3
|
+
-- shared service here was (#165, #317). Mirrors `src/store/schema.ts`'s
|
|
4
|
+
-- `foundryDeclaredPlatform` table additions. Backfills `hc_hostname` the
|
|
5
|
+
-- same way `0043_platform_names.sql` backfilled `names_hostname`: the
|
|
6
|
+
-- column must not be empty (`src/platform.ts`'s `validatePlatform`), and
|
|
7
|
+
-- the table already carries its one live row by the time this runs.
|
|
8
|
+
|
|
9
|
+
alter table foundry_declared_platform
|
|
10
|
+
add column if not exists hc_enabled boolean not null default false,
|
|
11
|
+
add column if not exists hc_hostname text,
|
|
12
|
+
add column if not exists hc_git_ref text not null default '';
|
|
13
|
+
|
|
14
|
+
update foundry_declared_platform
|
|
15
|
+
set hc_hostname = 'hc.' || apex
|
|
16
|
+
where hc_hostname is null;
|
|
17
|
+
|
|
18
|
+
alter table foundry_declared_platform
|
|
19
|
+
alter column hc_hostname set not null;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { TunnelPort } from './edge-tunnels.js';
|
|
2
|
+
import type { CoolifyPort } from './executor.js';
|
|
2
3
|
/**
|
|
3
4
|
* #197: the pinned `cloudflared` image, the same release tag as
|
|
4
5
|
* `scripts/shared-ingress.mjs`'s and `calls-sfu.ts`'s own
|
|
@@ -24,7 +25,7 @@ export declare function cloudflaredDockerfile(): string;
|
|
|
24
25
|
*/
|
|
25
26
|
export interface EnsureCloudflaredConnectorInput {
|
|
26
27
|
coolify: Pick<CoolifyPort, 'findApplicationByName' | 'getApplication' | 'createDockerfileApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deploy'>;
|
|
27
|
-
|
|
28
|
+
tunnels: Pick<TunnelPort, 'getTunnelToken'>;
|
|
28
29
|
/** The Cloudflare tunnel this connector dials into (already created and configured by the caller). */
|
|
29
30
|
tunnelId: string;
|
|
30
31
|
/** Fixed name, found before created -- so a second apply reuses this app rather than making a second one. */
|
|
@@ -32,7 +32,7 @@ export function cloudflaredDockerfile() {
|
|
|
32
32
|
* with no cause, because a Coolify error can echo the request body.
|
|
33
33
|
*/
|
|
34
34
|
export async function ensureCloudflaredConnector(input) {
|
|
35
|
-
const { coolify,
|
|
35
|
+
const { coolify, tunnels } = input;
|
|
36
36
|
const liveStatus = async (uuid) => {
|
|
37
37
|
try {
|
|
38
38
|
return { found: true, status: (await coolify.getApplication(uuid)).status };
|
|
@@ -82,7 +82,7 @@ export async function ensureCloudflaredConnector(input) {
|
|
|
82
82
|
}
|
|
83
83
|
if (appId !== input.recordedConnectorAppId)
|
|
84
84
|
await input.recordAppId(appId);
|
|
85
|
-
const token = await
|
|
85
|
+
const token = await tunnels.getTunnelToken(input.tunnelId);
|
|
86
86
|
const tokenChanged = justCreated || (await tunnelTokenDiffers(coolify, appId, token));
|
|
87
87
|
if (tokenChanged) {
|
|
88
88
|
try {
|
|
@@ -62,6 +62,8 @@
|
|
|
62
62
|
*/
|
|
63
63
|
const DEFAULT_READY_TIMEOUT_MS = 300_000;
|
|
64
64
|
const DEFAULT_READY_INTERVAL_MS = 5_000;
|
|
65
|
+
/** One health request's own limit: a connection that accepts and never answers counts as not healthy yet, instead of blocking the whole wait. */
|
|
66
|
+
const HEALTH_REQUEST_TIMEOUT_MS = 5_000;
|
|
65
67
|
/** The one terminal `CoolifyDeploymentStatus` that means the new version landed. */
|
|
66
68
|
const DEPLOYMENT_SUCCEEDED = 'finished';
|
|
67
69
|
/**
|
|
@@ -135,7 +137,9 @@ export async function waitForNewDeployment(coolify, appId, health, runtime, slee
|
|
|
135
137
|
}
|
|
136
138
|
async function matchesHealth(fetchImpl, health) {
|
|
137
139
|
try {
|
|
138
|
-
const response = await fetchImpl(health.url
|
|
140
|
+
const response = await fetchImpl(health.url, {
|
|
141
|
+
signal: AbortSignal.timeout(HEALTH_REQUEST_TIMEOUT_MS),
|
|
142
|
+
});
|
|
139
143
|
if (!response.ok)
|
|
140
144
|
return false;
|
|
141
145
|
if (health.kind === 'ready')
|
package/dist/plan/diff.js
CHANGED
|
@@ -214,10 +214,6 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
214
214
|
const edgeReservedLabels = planEdgeReservedLabels(org, recordedIds, platformReadiness, options.platformHostnames ?? []);
|
|
215
215
|
if (edgeReservedLabels.step)
|
|
216
216
|
plannedSteps.push(edgeReservedLabels.step);
|
|
217
|
-
// After the apps -- a edge key needs the entry's app recorded, the same
|
|
218
|
-
// dependency `planEntryAiKeys`/`planFilesKeys` have.
|
|
219
|
-
const entryEdgeKeys = planEntryEdgeKeys(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
|
|
220
|
-
plannedSteps.push(...entryEdgeKeys.steps);
|
|
221
217
|
const tunnel = planTunnel(org, byKey, claimed, recordedIds);
|
|
222
218
|
// #238: a tunnel that is being provisioned waits for an active zone; parking
|
|
223
219
|
// one never does.
|
|
@@ -264,6 +260,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
264
260
|
const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null);
|
|
265
261
|
if (boule.step)
|
|
266
262
|
plannedSteps.push(boule.step);
|
|
263
|
+
// After the apps and Boule -- an edge key needs its app recorded or planned,
|
|
264
|
+
// the same dependency `planEntryAiKeys`/`planFilesKeys` have.
|
|
265
|
+
const entryEdgeKeys = planEntryEdgeKeys(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
|
|
266
|
+
plannedSteps.push(...entryEdgeKeys.steps);
|
|
267
267
|
// Boule's own broker entry, after its app: the entry needs the AUTH_APP_KEY
|
|
268
268
|
// that step puts in the app's env.
|
|
269
269
|
const bouleBroker = planBouleBrokerEntry(org, recordedIds, plannedSteps, options.broker ?? false);
|
|
@@ -1955,6 +1955,29 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
|
|
|
1955
1955
|
blocked,
|
|
1956
1956
|
};
|
|
1957
1957
|
}
|
|
1958
|
+
// #543: a human turning the health check off in the Coolify UI (or an app
|
|
1959
|
+
// applied before this patch existed) is noticed the same way `hasLogVolume`
|
|
1960
|
+
// just above is -- `coolifyApp` already sends the quartet on every run, so
|
|
1961
|
+
// replanning this one step (the same action, nothing extra) is the whole
|
|
1962
|
+
// repair. A reader that did not check (`healthCheckEnabled` absent) reads
|
|
1963
|
+
// as compliant, like `hasLogVolume`.
|
|
1964
|
+
// Only for an entry `coolifyApp` would set the check on (a static entry,
|
|
1965
|
+
// or one with a declared port): otherwise the repair could never land and
|
|
1966
|
+
// every apply would replan it.
|
|
1967
|
+
if (live.config.healthCheckEnabled === false &&
|
|
1968
|
+
(entry.buildPack === 'static' || entry.port !== null)) {
|
|
1969
|
+
return {
|
|
1970
|
+
step: {
|
|
1971
|
+
action,
|
|
1972
|
+
step: {
|
|
1973
|
+
description: `turn Coolify's health check back on for ${org.slug}'s ${label} (#543)`,
|
|
1974
|
+
destroys: false,
|
|
1975
|
+
migrates: false,
|
|
1976
|
+
},
|
|
1977
|
+
},
|
|
1978
|
+
blocked,
|
|
1979
|
+
};
|
|
1980
|
+
}
|
|
1958
1981
|
// #239 (rule 4), from live state: an app with a database but no
|
|
1959
1982
|
// `DATABASE_URL_OWNER` env still has the pre-split superuser URL, or never
|
|
1960
1983
|
// got the push. Names only, never values. A reader that did not list env
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { AccessDecision, createEdgeClient } from '@wtfalch/edge';
|
|
2
|
+
import { type WriteSlot } from './edge-dns.js';
|
|
3
|
+
/** The edge SDK's platform Access client: `createEdgeClient({ ... }).platform.access`. */
|
|
4
|
+
export type EdgePlatformAccess = ReturnType<typeof createEdgeClient>['platform']['access'];
|
|
5
|
+
export type { AccessDecision };
|
|
6
|
+
/** One Access rule in Cloudflare's own shape: `{ email: { email } }`, `{ everyone: {} }`, `{ service_token: { token_id } }`. */
|
|
7
|
+
export type AccessPolicyRule = Record<string, unknown>;
|
|
8
|
+
export interface AccessAppSummary {
|
|
9
|
+
/** Cloudflare's app id: what every edge Access call takes. */
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
domain: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Foundry's Cloudflare Access calls. Every Access call in foundry goes through
|
|
16
|
+
* this; the one implementation is {@link createEdgeAccess}, which speaks to the
|
|
17
|
+
* edge service with the platform credential.
|
|
18
|
+
*/
|
|
19
|
+
export interface AccessPort {
|
|
20
|
+
/** Every live Access app foundry may use: the platform's own, and ones nobody adopted yet. */
|
|
21
|
+
listAccessApps(): Promise<AccessAppSummary[]>;
|
|
22
|
+
createAccessApp(app: {
|
|
23
|
+
name: string;
|
|
24
|
+
domain: string;
|
|
25
|
+
type: 'self_hosted';
|
|
26
|
+
}): Promise<{
|
|
27
|
+
id: string;
|
|
28
|
+
}>;
|
|
29
|
+
listAccessPolicies(appId: string): Promise<Array<{
|
|
30
|
+
id: string;
|
|
31
|
+
name: string;
|
|
32
|
+
include?: AccessPolicyRule[];
|
|
33
|
+
}>>;
|
|
34
|
+
createAccessPolicy(appId: string, policy: {
|
|
35
|
+
name: string;
|
|
36
|
+
decision: AccessDecision;
|
|
37
|
+
include: AccessPolicyRule[];
|
|
38
|
+
}): Promise<{
|
|
39
|
+
id: string;
|
|
40
|
+
}>;
|
|
41
|
+
/**
|
|
42
|
+
* A service token's id by exact name -- the token itself is a live
|
|
43
|
+
* Cloudflare resource foundry never mints and whose secret it never reads.
|
|
44
|
+
* `undefined` when no token has that name; throws when more than one does.
|
|
45
|
+
*/
|
|
46
|
+
findServiceTokenByName(name: string): Promise<{
|
|
47
|
+
id: string;
|
|
48
|
+
} | undefined>;
|
|
49
|
+
deleteAccessApp(appId: string): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Edge answers a policy, delete or create call for a live Access app with no
|
|
53
|
+
* edge row `not_found` or 409 `exists` ("create or adopt it first"). So the
|
|
54
|
+
* adapter adopts an app the moment it is about to be used: an app it saw in a
|
|
55
|
+
* list (or that a create refused with `exists`) whose owner edge reported as
|
|
56
|
+
* `null` is remembered, and adopted just before the first call on it. An app
|
|
57
|
+
* foundry recorded in an earlier apply and now names by id only is looked up
|
|
58
|
+
* on `not_found`, adopted if unowned, and the call retried once, so a destroy
|
|
59
|
+
* deletes it instead of finding "nothing there". Adopt is idempotent and the
|
|
60
|
+
* list reports the owner afterwards, so nothing is recorded in the plan. An
|
|
61
|
+
* app with an owner is never adopted or changed. An adopt that edge skips
|
|
62
|
+
* (`taken`, `unsupported_type`) is an error. A `rate_limited` answer is
|
|
63
|
+
* retried with edge's hint, up to 5 tries.
|
|
64
|
+
*/
|
|
65
|
+
export declare function createEdgeAccess(client: EdgePlatformAccess, options?: {
|
|
66
|
+
sleep?: (ms: number) => Promise<void>;
|
|
67
|
+
writeSlot?: WriteSlot;
|
|
68
|
+
}): AccessPort;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { EdgeApiError } from '@wtfalch/edge';
|
|
2
|
+
import { EDGE_DNS_WRITE_CONCURRENCY, limiter, rateLimitRetrier, } from './edge-dns.js';
|
|
3
|
+
const isNotFound = (error) => error instanceof EdgeApiError && error.code === 'not_found';
|
|
4
|
+
/** Edge's 409 for a live app on the domain with no edge row. */
|
|
5
|
+
const isExists = (error) => error instanceof EdgeApiError && error.code === 'conflict' && error.reason === 'exists';
|
|
6
|
+
/**
|
|
7
|
+
* Edge answers a policy, delete or create call for a live Access app with no
|
|
8
|
+
* edge row `not_found` or 409 `exists` ("create or adopt it first"). So the
|
|
9
|
+
* adapter adopts an app the moment it is about to be used: an app it saw in a
|
|
10
|
+
* list (or that a create refused with `exists`) whose owner edge reported as
|
|
11
|
+
* `null` is remembered, and adopted just before the first call on it. An app
|
|
12
|
+
* foundry recorded in an earlier apply and now names by id only is looked up
|
|
13
|
+
* on `not_found`, adopted if unowned, and the call retried once, so a destroy
|
|
14
|
+
* deletes it instead of finding "nothing there". Adopt is idempotent and the
|
|
15
|
+
* list reports the owner afterwards, so nothing is recorded in the plan. An
|
|
16
|
+
* app with an owner is never adopted or changed. An adopt that edge skips
|
|
17
|
+
* (`taken`, `unsupported_type`) is an error. A `rate_limited` answer is
|
|
18
|
+
* retried with edge's hint, up to 5 tries.
|
|
19
|
+
*/
|
|
20
|
+
export function createEdgeAccess(client, options = {}) {
|
|
21
|
+
const retrying = rateLimitRetrier(options.sleep);
|
|
22
|
+
const access = {
|
|
23
|
+
list: (filter) => retrying(() => client.list(filter)),
|
|
24
|
+
create: (input) => retrying(() => client.create(input)),
|
|
25
|
+
remove: (id) => retrying(() => client.remove(id)),
|
|
26
|
+
listPolicies: (appId) => retrying(() => client.listPolicies(appId)),
|
|
27
|
+
createPolicy: (appId, input) => retrying(() => client.createPolicy(appId, input)),
|
|
28
|
+
findServiceToken: (name) => retrying(() => client.findServiceToken(name)),
|
|
29
|
+
adopt: (input) => retrying(() => client.adopt(input)),
|
|
30
|
+
};
|
|
31
|
+
const writeSlot = options.writeSlot ?? limiter(EDGE_DNS_WRITE_CONCURRENCY);
|
|
32
|
+
/** app id -> a seen live app with no edge row yet. */
|
|
33
|
+
const unadopted = new Map();
|
|
34
|
+
const remember = (views) => {
|
|
35
|
+
for (const view of views) {
|
|
36
|
+
if (view.owner === null)
|
|
37
|
+
unadopted.set(view.id, view);
|
|
38
|
+
else
|
|
39
|
+
unadopted.delete(view.id);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const adopt = async (view) => {
|
|
43
|
+
const result = await writeSlot(() => access.adopt({ apps: [{ name: view.name, domain: view.domain, id: view.id }] }));
|
|
44
|
+
const skipped = result.skipped[0];
|
|
45
|
+
if (skipped) {
|
|
46
|
+
throw new Error(`edge access: ${view.name} (${view.domain}) cannot be adopted (${skipped.reason}); remove or fix it by hand`);
|
|
47
|
+
}
|
|
48
|
+
if (result.notFound.length > 0) {
|
|
49
|
+
throw new EdgeApiError(404, 'not_found', `edge access: ${view.name} is no longer live`);
|
|
50
|
+
}
|
|
51
|
+
unadopted.delete(view.id);
|
|
52
|
+
};
|
|
53
|
+
/** Runs a call on an app; adopts it first when seen unowned, and once more on `not_found`. */
|
|
54
|
+
const onApp = async (appId, call) => {
|
|
55
|
+
const seen = unadopted.get(appId);
|
|
56
|
+
if (seen)
|
|
57
|
+
await adopt(seen);
|
|
58
|
+
try {
|
|
59
|
+
return await call();
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (!isNotFound(error))
|
|
63
|
+
throw error;
|
|
64
|
+
const live = (await access.list()).apps;
|
|
65
|
+
remember(live);
|
|
66
|
+
const view = live.find((a) => a.id === appId);
|
|
67
|
+
if (!view || view.owner !== null)
|
|
68
|
+
throw error;
|
|
69
|
+
await adopt(view);
|
|
70
|
+
return call();
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const listAccessApps = async () => {
|
|
74
|
+
const { apps } = await access.list();
|
|
75
|
+
remember(apps);
|
|
76
|
+
return apps.map((a) => ({ id: a.id, name: a.name, domain: a.domain }));
|
|
77
|
+
};
|
|
78
|
+
return {
|
|
79
|
+
listAccessApps,
|
|
80
|
+
async createAccessApp(app) {
|
|
81
|
+
try {
|
|
82
|
+
const created = await writeSlot(() => access.create({ name: app.name, domain: app.domain, type: app.type }));
|
|
83
|
+
return { id: created.id };
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
// A live app holds the domain with no edge row. The one of this name
|
|
87
|
+
// and domain is foundry's own from before edge owned Access: use it,
|
|
88
|
+
// adopting it before its first policy call. Any other app is refused.
|
|
89
|
+
if (!isExists(error))
|
|
90
|
+
throw error;
|
|
91
|
+
const { apps } = await access.list({ domain: app.domain });
|
|
92
|
+
remember(apps);
|
|
93
|
+
const same = apps.find((a) => a.name === app.name && a.owner === null);
|
|
94
|
+
if (!same)
|
|
95
|
+
throw error;
|
|
96
|
+
return { id: same.id };
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
async listAccessPolicies(appId) {
|
|
100
|
+
const { policies } = await onApp(appId, () => access.listPolicies(appId));
|
|
101
|
+
return policies.map((p) => ({ id: p.id, name: p.name, include: p.include }));
|
|
102
|
+
},
|
|
103
|
+
async createAccessPolicy(appId, policy) {
|
|
104
|
+
const created = await onApp(appId, () => writeSlot(() => access.createPolicy(appId, policy)));
|
|
105
|
+
return { id: created.id };
|
|
106
|
+
},
|
|
107
|
+
async findServiceTokenByName(name) {
|
|
108
|
+
try {
|
|
109
|
+
const { token } = await access.findServiceToken(name);
|
|
110
|
+
return token ? { id: token.id } : undefined;
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error instanceof EdgeApiError && error.code === 'conflict') {
|
|
114
|
+
throw new Error(`edge access: more than one Cloudflare Access service token is named '${name}'; rename or remove the extras`);
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
async deleteAccessApp(appId) {
|
|
120
|
+
await onApp(appId, () => writeSlot(() => access.remove(appId)));
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { createEdgeClient } from '@wtfalch/edge';
|
|
2
|
+
/** The edge SDK's platform DNS client: `createEdgeClient({ ... }).platform.dns`. */
|
|
3
|
+
export type EdgePlatformDns = ReturnType<typeof createEdgeClient>['platform']['dns'];
|
|
4
|
+
export interface DnsRecordSummary {
|
|
5
|
+
id: string;
|
|
6
|
+
type: string;
|
|
7
|
+
content: string;
|
|
8
|
+
name: string;
|
|
9
|
+
}
|
|
10
|
+
export interface DnsRecordInput {
|
|
11
|
+
type: string;
|
|
12
|
+
name: string;
|
|
13
|
+
content: string;
|
|
14
|
+
proxied?: boolean;
|
|
15
|
+
ttl?: number;
|
|
16
|
+
/** Required for an `MX` record. */
|
|
17
|
+
priority?: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Foundry's DNS calls. Every record call in foundry goes through this; the one
|
|
21
|
+
* implementation is {@link createEdgeDns}, which speaks to the edge service
|
|
22
|
+
* with the platform credential. `zone` is the zone's own domain (what
|
|
23
|
+
* `ZonePort.findZoneByApex` returns as `name`), not a Cloudflare zone
|
|
24
|
+
* id: edge addresses a zone by its domain.
|
|
25
|
+
*/
|
|
26
|
+
export interface DnsPort {
|
|
27
|
+
/** An exact name + type match, so a stale record of a different type is never mistaken for a match. */
|
|
28
|
+
findDnsRecord(zone: string, name: string, type?: string): Promise<DnsRecordSummary[]>;
|
|
29
|
+
/**
|
|
30
|
+
* Every record of a type in the zone, for a domain recorded before
|
|
31
|
+
* `CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY` existed and so with no
|
|
32
|
+
* hostname list to look up instead.
|
|
33
|
+
*/
|
|
34
|
+
listDnsRecords(zone: string, opts?: {
|
|
35
|
+
type?: string;
|
|
36
|
+
name?: string;
|
|
37
|
+
}): Promise<DnsRecordSummary[]>;
|
|
38
|
+
createDnsRecord(zone: string, rec: DnsRecordInput): Promise<{
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
content: string;
|
|
42
|
+
}>;
|
|
43
|
+
updateDnsRecord(zone: string, recordId: string, rec: DnsRecordInput): Promise<{
|
|
44
|
+
id: string;
|
|
45
|
+
name: string;
|
|
46
|
+
content: string;
|
|
47
|
+
}>;
|
|
48
|
+
deleteDnsRecord(zone: string, recordId: string): Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
/** Edge answers its platform writes from one shared slot of 4 (company-less writes). */
|
|
51
|
+
export declare const EDGE_DNS_WRITE_CONCURRENCY = 4;
|
|
52
|
+
/**
|
|
53
|
+
* Edge lists a TXT record raw, quoted (`"v=spf1 ..."`, long values in several
|
|
54
|
+
* quoted chunks), but returns it unquoted from a create or update. Everything
|
|
55
|
+
* foundry compares sees the unquoted form.
|
|
56
|
+
*/
|
|
57
|
+
export declare function unquoteTxt(content: string): string;
|
|
58
|
+
export type WriteSlot = <T>(task: () => Promise<T>) => Promise<T>;
|
|
59
|
+
export declare function limiter(max: number): WriteSlot;
|
|
60
|
+
/**
|
|
61
|
+
* Edge's company-less platform calls share one per-org slot of 4 and answer a
|
|
62
|
+
* full slot with `rate_limited` and a retry hint, without queueing. The SDK
|
|
63
|
+
* does not retry, so every call here waits the hint out, up to this many tries.
|
|
64
|
+
*/
|
|
65
|
+
export declare const EDGE_DNS_RATE_LIMIT_ATTEMPTS = 5;
|
|
66
|
+
/**
|
|
67
|
+
* Retries a call edge answered `rate_limited`, waiting out its hint (capped at
|
|
68
|
+
* 30 s), up to {@link EDGE_DNS_RATE_LIMIT_ATTEMPTS} tries. Shared by every
|
|
69
|
+
* edge adapter, since all platform calls draw on the one slot.
|
|
70
|
+
*/
|
|
71
|
+
export declare function rateLimitRetrier(sleepOption?: (ms: number) => Promise<void>): <T>(task: () => Promise<T>) => Promise<T>;
|
|
72
|
+
/**
|
|
73
|
+
* Edge answers an update or delete of a live record with no edge row
|
|
74
|
+
* `not_found` ("adopt it first"). So the adapter adopts a record the moment it
|
|
75
|
+
* is about to be changed or removed: every record it has returned that the
|
|
76
|
+
* list reported with `owner: null` is remembered, and adopted just before the
|
|
77
|
+
* first write that names it. Adopt is idempotent and the list reports the
|
|
78
|
+
* owner once adopted, so nothing is recorded in the plan and a later apply
|
|
79
|
+
* adopts nothing. A record foundry only reads (a foreign record it refuses to
|
|
80
|
+
* overwrite) is never adopted. An `unsupported_type`, `taken` or `organisation`
|
|
81
|
+
* skip is an error; an `unclaimed` record is left alone. A company's own record
|
|
82
|
+
* (edge answers `forbidden`) is never changed: the error names zone, name and
|
|
83
|
+
* type. A `rate_limited` answer is retried with edge's hint, up to 5 tries.
|
|
84
|
+
*/
|
|
85
|
+
export declare function createEdgeDns(client: EdgePlatformDns, options?: {
|
|
86
|
+
sleep?: (ms: number) => Promise<void>;
|
|
87
|
+
/** Share one slot of {@link EDGE_DNS_WRITE_CONCURRENCY} with the tunnel adapter: edge counts both. */
|
|
88
|
+
writeSlot?: WriteSlot;
|
|
89
|
+
}): DnsPort;
|