@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.
Files changed (49) hide show
  1. package/dist/bootstrap/declare.d.ts +13 -0
  2. package/dist/bootstrap/declare.js +31 -7
  3. package/dist/bootstrap/versions.d.ts +1 -0
  4. package/dist/index.d.ts +7 -3
  5. package/dist/index.js +4 -2
  6. package/dist/migrations/0046_platform_hc.sql +19 -0
  7. package/dist/plan/cloudflared-connector.d.ts +3 -2
  8. package/dist/plan/cloudflared-connector.js +2 -2
  9. package/dist/plan/deploy-health.js +5 -1
  10. package/dist/plan/diff.js +27 -4
  11. package/dist/plan/edge-access.d.ts +68 -0
  12. package/dist/plan/edge-access.js +123 -0
  13. package/dist/plan/edge-dns.d.ts +89 -0
  14. package/dist/plan/edge-dns.js +227 -0
  15. package/dist/plan/edge-ratelimits.d.ts +54 -0
  16. package/dist/plan/edge-ratelimits.js +79 -0
  17. package/dist/plan/edge-tunnels.d.ts +44 -0
  18. package/dist/plan/edge-tunnels.js +108 -0
  19. package/dist/plan/edge-workers.d.ts +31 -0
  20. package/dist/plan/edge-workers.js +165 -0
  21. package/dist/plan/edge-zones.d.ts +37 -0
  22. package/dist/plan/edge-zones.js +64 -0
  23. package/dist/plan/entry-edge-key.d.ts +10 -0
  24. package/dist/plan/entry-edge-key.js +38 -8
  25. package/dist/plan/executor.d.ts +146 -143
  26. package/dist/plan/executor.js +370 -115
  27. package/dist/plan/health-check-settings.d.ts +47 -0
  28. package/dist/plan/health-check-settings.js +47 -0
  29. package/dist/plan/health-targets.d.ts +86 -0
  30. package/dist/plan/health-targets.js +344 -0
  31. package/dist/plan/index.d.ts +17 -3
  32. package/dist/plan/index.js +9 -2
  33. package/dist/plan/live.d.ts +12 -1
  34. package/dist/plan/plan-apply.d.ts +4 -0
  35. package/dist/plan/plan-apply.js +5 -1
  36. package/dist/plan/platform-diff.d.ts +127 -0
  37. package/dist/plan/platform-diff.js +304 -8
  38. package/dist/plan/platform-providers.d.ts +202 -7
  39. package/dist/plan/platform-providers.js +545 -79
  40. package/dist/plan/providers-readers.d.ts +23 -1
  41. package/dist/plan/providers-readers.js +61 -1
  42. package/dist/plan/rollout.d.ts +9 -8
  43. package/dist/platform.d.ts +14 -1
  44. package/dist/platform.js +1 -0
  45. package/dist/store/postgres.js +8 -0
  46. package/dist/store/schema.d.ts +51 -0
  47. package/dist/store/schema.js +4 -0
  48. package/package.json +2 -1
  49. 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
- * Every platform service except `sfu` and `edge` is enabled, like the other
27
- * steps: a service whose dependencies the host did not supply is reported
28
- * blocked, not planned. `sfu` stays off because it needs a held SSH key and
29
- * host fingerprint, a separate human step (issue 170). `edge` stays off
30
- * because the edge service (N1) does not exist yet -- a human deploys it
31
- * and sets its hostname before enabling it (#317's own "Human steps").
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 ? '' : key === 'mail' ? DEFAULT_MAIL_IMAGE_TAG : DEFAULT_GIT_REF,
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 {
@@ -12,6 +12,7 @@ export interface PinnedVersions {
12
12
  livekit: string;
13
13
  cloudflared: string;
14
14
  stalwart: string;
15
+ hc: string;
15
16
  }
16
17
  export declare function loadVersions(): PinnedVersions;
17
18
  export declare const VERSIONS: PinnedVersions;
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 { 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_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
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 { 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_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
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 { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
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 { CloudflarePort, CoolifyPort } from './executor.js';
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
- cloudflare: Pick<CloudflarePort, 'getTunnelToken'>;
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, cloudflare } = input;
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 cloudflare.getTunnelToken(input.tunnelId);
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;