@wtfalch/foundry 0.34.0 → 0.35.2
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/bootstrap.d.ts +1 -1
- package/dist/bootstrap/bootstrap.js +49 -11
- package/dist/bootstrap/declare.d.ts +12 -5
- package/dist/bootstrap/declare.js +33 -18
- package/dist/bootstrap/versions.d.ts +20 -0
- package/dist/bootstrap/versions.js +46 -0
- package/dist/calls-sfu.js +3 -3
- package/dist/index.d.ts +9 -4
- package/dist/index.js +5 -2
- package/dist/migrations/0053_heartbeats_provider.sql +9 -0
- package/dist/migrations/0053_org_version_pin.sql +6 -0
- package/dist/org.d.ts +60 -14
- package/dist/org.js +118 -28
- package/dist/plan/backup-gate.d.ts +93 -0
- package/dist/plan/backup-gate.js +261 -0
- package/dist/plan/broker-entries.d.ts +9 -1
- package/dist/plan/broker-entries.js +20 -3
- package/dist/plan/build-record.d.ts +56 -0
- package/dist/plan/build-record.js +109 -0
- package/dist/plan/calls-matrix.js +3 -3
- package/dist/plan/cloudflared-connector.js +2 -2
- package/dist/plan/company-backups.d.ts +31 -0
- package/dist/plan/company-backups.js +101 -0
- package/dist/plan/deploy-health.d.ts +12 -2
- package/dist/plan/deploy-health.js +3 -1
- package/dist/plan/destinations.d.ts +73 -0
- package/dist/plan/destinations.js +217 -0
- package/dist/plan/diff.d.ts +17 -8
- package/dist/plan/diff.js +131 -131
- package/dist/plan/edge-access.d.ts +15 -0
- package/dist/plan/edge-access.js +21 -2
- package/dist/plan/executor.d.ts +166 -7
- package/dist/plan/executor.js +547 -106
- package/dist/plan/health-targets.d.ts +1 -3
- package/dist/plan/health-targets.js +36 -36
- package/dist/plan/held-key-refs.d.ts +5 -0
- package/dist/plan/held-key-refs.js +5 -0
- package/dist/plan/hostnames.d.ts +10 -0
- package/dist/plan/hostnames.js +14 -0
- package/dist/plan/index.d.ts +5 -0
- package/dist/plan/index.js +3 -0
- package/dist/plan/live.d.ts +6 -0
- package/dist/plan/live.js +3 -2
- package/dist/plan/locked-hostnames.d.ts +2 -1
- package/dist/plan/locked-hostnames.js +16 -3
- package/dist/plan/login-host.d.ts +9 -20
- package/dist/plan/login-host.js +10 -25
- package/dist/plan/mail-relay.d.ts +48 -29
- package/dist/plan/mail-relay.js +145 -68
- package/dist/plan/network-move.d.ts +20 -0
- package/dist/plan/network-move.js +41 -0
- package/dist/plan/oikos-ingress.d.ts +111 -0
- package/dist/plan/oikos-ingress.js +231 -0
- package/dist/plan/org-names.d.ts +8 -2
- package/dist/plan/org-names.js +57 -1
- package/dist/plan/own-diff.d.ts +9 -8
- package/dist/plan/own-diff.js +42 -34
- package/dist/plan/own-hostnames.d.ts +27 -0
- package/dist/plan/own-hostnames.js +24 -0
- package/dist/plan/own-limits.d.ts +198 -7
- package/dist/plan/own-limits.js +163 -32
- package/dist/plan/own-services.js +3 -1
- package/dist/plan/own-sfu.js +3 -2
- package/dist/plan/plan-apply.d.ts +47 -7
- package/dist/plan/plan-apply.js +184 -38
- package/dist/plan/platform-diff.d.ts +133 -17
- package/dist/plan/platform-diff.js +231 -68
- package/dist/plan/platform-org.d.ts +79 -10
- package/dist/plan/platform-org.js +93 -13
- package/dist/plan/platform-providers.d.ts +118 -73
- package/dist/plan/platform-providers.js +799 -241
- package/dist/plan/proxy-labels.d.ts +57 -11
- package/dist/plan/proxy-labels.js +94 -14
- package/dist/plan/run-options.d.ts +19 -0
- package/dist/plan/run-options.js +46 -0
- package/dist/plan/zitadel-login-policy.d.ts +1 -1
- package/dist/plan/zitadel-login-policy.js +76 -8
- package/dist/platform.d.ts +32 -0
- package/dist/platform.js +16 -0
- package/dist/provision.d.ts +2 -0
- package/dist/provision.js +3 -1
- package/dist/slug.d.ts +3 -1
- package/dist/slug.js +25 -3
- package/dist/store/postgres.js +43 -1
- package/dist/store/schema.d.ts +36 -0
- package/dist/store/schema.js +4 -0
- package/package.json +3 -3
- package/versions.digests.json +11 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { DeclaredPlatform } from '../platform.js';
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
|
+
import type { DnsPort } from './edge-dns.js';
|
|
4
|
+
import type { RateLimitPort, RateLimitRuleInput } from './edge-ratelimits.js';
|
|
5
|
+
import type { TunnelIngress, TunnelPort } from './edge-tunnels.js';
|
|
6
|
+
import type { ZonePort } from './edge-zones.js';
|
|
7
|
+
import type { CoolifyPort } from './executor.js';
|
|
8
|
+
import type { PlatformStepExecutionContext } from './plan-apply.js';
|
|
9
|
+
/**
|
|
10
|
+
* The shared `oikos.host` ingress: one Cloudflare tunnel, its connector app on
|
|
11
|
+
* Coolify, one proxied wildcard record `*.oikos.host` and one zone-wide rate
|
|
12
|
+
* limiting rule for the sign-in paths. Every company without a domain of its
|
|
13
|
+
* own is reached through it, so a fresh platform needs it before such a company
|
|
14
|
+
* can be planned. It is the platform step that `scripts/shared-ingress.mjs`
|
|
15
|
+
* used to be run by hand for. The script keeps its own copy of the rate limit
|
|
16
|
+
* constants (it runs outside this package) and a test here keeps them equal.
|
|
17
|
+
*
|
|
18
|
+
* The three names below are found by name, and the tunnel's whole ingress is
|
|
19
|
+
* replaced. They differ from the script's (`oikos-shared`) on purpose: a hand-made
|
|
20
|
+
* project or tunnel of that name may carry other traffic, and this step must only
|
|
21
|
+
* ever find its own.
|
|
22
|
+
*
|
|
23
|
+
* One `coolify:app` step keyed {@link PLATFORM_OIKOS_INGRESS_KEY}. Its applied
|
|
24
|
+
* id (the connector app) is recorded by the runner only when the whole step
|
|
25
|
+
* returns, so a run that fails halfway is planned again and finds what it made
|
|
26
|
+
* by name.
|
|
27
|
+
*/
|
|
28
|
+
export declare const PLATFORM_OIKOS_INGRESS_KEY = "platform:oikos-ingress";
|
|
29
|
+
export declare const OIKOS_INGRESS_ZONE = "oikos.host";
|
|
30
|
+
export declare const OIKOS_INGRESS_TUNNEL_NAME = "platform-oikos-ingress";
|
|
31
|
+
export declare const OIKOS_INGRESS_WILDCARD = "*.oikos.host";
|
|
32
|
+
export declare const OIKOS_INGRESS_CONNECTOR_APP_NAME = "platform-oikos-ingress-cloudflared";
|
|
33
|
+
export declare const OIKOS_INGRESS_PROJECT = "platform-oikos-ingress";
|
|
34
|
+
export declare const OIKOS_INGRESS_ENVIRONMENT = "production";
|
|
35
|
+
/** Bookkeeping of what the last run finished; none of these is the applied marker. */
|
|
36
|
+
export declare const OIKOS_INGRESS_PROJECT_SCOPE_KEY = "oikos_ingress_project";
|
|
37
|
+
export declare const OIKOS_INGRESS_TUNNEL_SCOPE_KEY = "oikos_ingress_tunnel";
|
|
38
|
+
export declare const OIKOS_INGRESS_CONNECTOR_APP_SCOPE_KEY = "oikos_ingress_connector_app";
|
|
39
|
+
export declare const OIKOS_INGRESS_DNS_SCOPE_KEY = "oikos_ingress_dns";
|
|
40
|
+
export declare const OIKOS_INGRESS_RATELIMIT_SCOPE_KEY = "oikos_ingress_ratelimit";
|
|
41
|
+
/** True once the step has finished: the readiness a company without a domain waits for. */
|
|
42
|
+
export declare function oikosIngressApplied(recordedIds: Readonly<Record<string, string>>): boolean;
|
|
43
|
+
/** Fixed texts. None carries a provider's error. */
|
|
44
|
+
export declare const OIKOS_INGRESS_BLOCKED: {
|
|
45
|
+
readonly edgeNotApplied: "the shared oikos.host ingress is blocked, not planned: every DNS record and tunnel is written through edge, and edge is not applied yet";
|
|
46
|
+
readonly zoneMissing: "the shared oikos.host ingress is blocked, not planned: the oikos.host zone is not on the Cloudflare account the platform's edge reaches; add the zone to that account";
|
|
47
|
+
readonly zoneUnchecked: "the shared oikos.host ingress is blocked, not planned: the oikos.host zone could not be looked up through edge just now; apply again";
|
|
48
|
+
readonly executorMissing: "the shared oikos.host ingress is blocked, not planned: the host built the platform executor without its oikos ingress dependencies";
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* The step, or the reason it cannot be planned. Nothing when the platform has
|
|
52
|
+
* no edge (it could not write a record) or the step is already applied.
|
|
53
|
+
* `edgeApplied` is whether edge has recorded its address.
|
|
54
|
+
*/
|
|
55
|
+
export declare function planOikosIngress(platform: DeclaredPlatform, recordedIds: Readonly<Record<string, string>>, input: {
|
|
56
|
+
edgeApplied: boolean;
|
|
57
|
+
executorBlocked?: string;
|
|
58
|
+
}): {
|
|
59
|
+
step: DraftStep | null;
|
|
60
|
+
blocked: string | null;
|
|
61
|
+
};
|
|
62
|
+
/** The tunnel's ingress: the wildcard to Coolify's proxy, then the mandatory catch-all. */
|
|
63
|
+
export declare function oikosIngressTunnelIngress(): TunnelIngress[];
|
|
64
|
+
/** What the wildcard record needs: nothing, a create, or an update. */
|
|
65
|
+
export declare function planWildcardRecord(live: {
|
|
66
|
+
content: string;
|
|
67
|
+
proxied?: boolean;
|
|
68
|
+
} | undefined, tunnelId: string): 'exists' | 'create' | 'update';
|
|
69
|
+
export declare const OIKOS_INGRESS_RATE_LIMIT_DESCRIPTION = "foundry: rate limit sign-in paths (oikos.host, shared)";
|
|
70
|
+
/** The Free plan allows one rule per zone, with only "Path" in the expression, a 10 second period and IP counting. */
|
|
71
|
+
export declare const OIKOS_INGRESS_RATE_LIMIT_PERIOD_SECONDS = 10;
|
|
72
|
+
export declare const OIKOS_INGRESS_RATE_LIMIT_REQUESTS = 10;
|
|
73
|
+
export declare const OIKOS_INGRESS_SIGN_IN_EXPRESSION = "starts_with(http.request.uri.path, \"/auth/\")";
|
|
74
|
+
export declare function oikosIngressRateLimitRule(): RateLimitRuleInput;
|
|
75
|
+
/** Ours is there, nothing is there, or a rule that is not ours holds the zone's one Free-plan slot. */
|
|
76
|
+
export declare function planOikosRateLimit(rules: ReadonlyArray<{
|
|
77
|
+
id: string;
|
|
78
|
+
description: string | null;
|
|
79
|
+
}>): {
|
|
80
|
+
status: 'exists';
|
|
81
|
+
ruleId: string;
|
|
82
|
+
} | {
|
|
83
|
+
status: 'create' | 'conflict';
|
|
84
|
+
ruleId: null;
|
|
85
|
+
};
|
|
86
|
+
export interface OikosIngressPorts {
|
|
87
|
+
coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'findApplicationByName' | 'getApplication' | 'createDockerfileApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deploy'>;
|
|
88
|
+
tunnels: Pick<TunnelPort, 'findTunnelByName' | 'createTunnel' | 'putTunnelConfig' | 'getTunnelToken'>;
|
|
89
|
+
dns: Pick<DnsPort, 'findDnsRecord' | 'createDnsRecord' | 'updateDnsRecord'>;
|
|
90
|
+
ratelimits: RateLimitPort;
|
|
91
|
+
zones: Pick<ZonePort, 'findZoneByApex'>;
|
|
92
|
+
resolveServer: () => Promise<{
|
|
93
|
+
uuid: string;
|
|
94
|
+
}>;
|
|
95
|
+
/** One outside call's limit in milliseconds; 60 s by default. The connector step gets five times it. */
|
|
96
|
+
callTimeoutMs?: number;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Why the step cannot be planned for want of its zone, or `null` when the zone
|
|
100
|
+
* is on the account edge reaches. The texts are fixed; a lookup error is never carried.
|
|
101
|
+
*/
|
|
102
|
+
export declare function oikosIngressZoneBlocked(zones: Pick<ZonePort, 'findZoneByApex'>, callTimeoutMs?: number): Promise<string | null>;
|
|
103
|
+
/**
|
|
104
|
+
* Makes the ingress, in this order: tunnel, its ingress, the connector app, the
|
|
105
|
+
* wildcard record, the rate limit rule. Each is found by name or content before
|
|
106
|
+
* it is made, so a second run makes nothing twice. Returns the connector app id,
|
|
107
|
+
* which the runner records as the step's applied marker only on success.
|
|
108
|
+
*/
|
|
109
|
+
export declare function provisionOikosIngress(context: Pick<PlatformStepExecutionContext, 'recordedIds' | 'recordScopeId' | 'persist'>, ports: OikosIngressPorts): Promise<{
|
|
110
|
+
resourceId: string;
|
|
111
|
+
}>;
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
2
|
+
import { COOLIFY_PROXY_ORIGIN } from './executor.js';
|
|
3
|
+
/**
|
|
4
|
+
* The shared `oikos.host` ingress: one Cloudflare tunnel, its connector app on
|
|
5
|
+
* Coolify, one proxied wildcard record `*.oikos.host` and one zone-wide rate
|
|
6
|
+
* limiting rule for the sign-in paths. Every company without a domain of its
|
|
7
|
+
* own is reached through it, so a fresh platform needs it before such a company
|
|
8
|
+
* can be planned. It is the platform step that `scripts/shared-ingress.mjs`
|
|
9
|
+
* used to be run by hand for. The script keeps its own copy of the rate limit
|
|
10
|
+
* constants (it runs outside this package) and a test here keeps them equal.
|
|
11
|
+
*
|
|
12
|
+
* The three names below are found by name, and the tunnel's whole ingress is
|
|
13
|
+
* replaced. They differ from the script's (`oikos-shared`) on purpose: a hand-made
|
|
14
|
+
* project or tunnel of that name may carry other traffic, and this step must only
|
|
15
|
+
* ever find its own.
|
|
16
|
+
*
|
|
17
|
+
* One `coolify:app` step keyed {@link PLATFORM_OIKOS_INGRESS_KEY}. Its applied
|
|
18
|
+
* id (the connector app) is recorded by the runner only when the whole step
|
|
19
|
+
* returns, so a run that fails halfway is planned again and finds what it made
|
|
20
|
+
* by name.
|
|
21
|
+
*/
|
|
22
|
+
export const PLATFORM_OIKOS_INGRESS_KEY = 'platform:oikos-ingress';
|
|
23
|
+
export const OIKOS_INGRESS_ZONE = 'oikos.host';
|
|
24
|
+
export const OIKOS_INGRESS_TUNNEL_NAME = 'platform-oikos-ingress';
|
|
25
|
+
export const OIKOS_INGRESS_WILDCARD = `*.${OIKOS_INGRESS_ZONE}`;
|
|
26
|
+
export const OIKOS_INGRESS_CONNECTOR_APP_NAME = 'platform-oikos-ingress-cloudflared';
|
|
27
|
+
export const OIKOS_INGRESS_PROJECT = 'platform-oikos-ingress';
|
|
28
|
+
export const OIKOS_INGRESS_ENVIRONMENT = 'production';
|
|
29
|
+
/** Bookkeeping of what the last run finished; none of these is the applied marker. */
|
|
30
|
+
export const OIKOS_INGRESS_PROJECT_SCOPE_KEY = 'oikos_ingress_project';
|
|
31
|
+
export const OIKOS_INGRESS_TUNNEL_SCOPE_KEY = 'oikos_ingress_tunnel';
|
|
32
|
+
export const OIKOS_INGRESS_CONNECTOR_APP_SCOPE_KEY = 'oikos_ingress_connector_app';
|
|
33
|
+
export const OIKOS_INGRESS_DNS_SCOPE_KEY = 'oikos_ingress_dns';
|
|
34
|
+
export const OIKOS_INGRESS_RATELIMIT_SCOPE_KEY = 'oikos_ingress_ratelimit';
|
|
35
|
+
/** The key the runner records the step's result under (`system:kind:key`). */
|
|
36
|
+
const APPLIED_KEY = `coolify:app:${PLATFORM_OIKOS_INGRESS_KEY}`;
|
|
37
|
+
/** True once the step has finished: the readiness a company without a domain waits for. */
|
|
38
|
+
export function oikosIngressApplied(recordedIds) {
|
|
39
|
+
return Boolean(recordedIds[APPLIED_KEY]);
|
|
40
|
+
}
|
|
41
|
+
/** Fixed texts. None carries a provider's error. */
|
|
42
|
+
export const OIKOS_INGRESS_BLOCKED = {
|
|
43
|
+
edgeNotApplied: 'the shared oikos.host ingress is blocked, not planned: every DNS record and tunnel is written through edge, and edge is not applied yet',
|
|
44
|
+
zoneMissing: `the shared oikos.host ingress is blocked, not planned: the ${OIKOS_INGRESS_ZONE} zone is not on the Cloudflare account the platform's edge reaches; add the zone to that account`,
|
|
45
|
+
zoneUnchecked: `the shared oikos.host ingress is blocked, not planned: the ${OIKOS_INGRESS_ZONE} zone could not be looked up through edge just now; apply again`,
|
|
46
|
+
executorMissing: 'the shared oikos.host ingress is blocked, not planned: the host built the platform executor without its oikos ingress dependencies',
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* The step, or the reason it cannot be planned. Nothing when the platform has
|
|
50
|
+
* no edge (it could not write a record) or the step is already applied.
|
|
51
|
+
* `edgeApplied` is whether edge has recorded its address.
|
|
52
|
+
*/
|
|
53
|
+
export function planOikosIngress(platform, recordedIds, input) {
|
|
54
|
+
if (!platform.edge.enabled)
|
|
55
|
+
return { step: null, blocked: null };
|
|
56
|
+
if (oikosIngressApplied(recordedIds))
|
|
57
|
+
return { step: null, blocked: null };
|
|
58
|
+
if (!input.edgeApplied)
|
|
59
|
+
return { step: null, blocked: OIKOS_INGRESS_BLOCKED.edgeNotApplied };
|
|
60
|
+
if (input.executorBlocked)
|
|
61
|
+
return { step: null, blocked: input.executorBlocked };
|
|
62
|
+
return {
|
|
63
|
+
step: {
|
|
64
|
+
action: { system: 'coolify', kind: 'app', key: PLATFORM_OIKOS_INGRESS_KEY, desired: {} },
|
|
65
|
+
step: {
|
|
66
|
+
description: `set up the shared ${OIKOS_INGRESS_ZONE} ingress: its tunnel and connector, the ${OIKOS_INGRESS_WILDCARD} record and the sign-in rate limit`,
|
|
67
|
+
destroys: false,
|
|
68
|
+
migrates: false,
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
blocked: null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
// ---- the decisions of scripts/shared-ingress.mjs, as pure functions --------
|
|
75
|
+
/** The tunnel's ingress: the wildcard to Coolify's proxy, then the mandatory catch-all. */
|
|
76
|
+
export function oikosIngressTunnelIngress() {
|
|
77
|
+
return [
|
|
78
|
+
{ hostname: OIKOS_INGRESS_WILDCARD, service: COOLIFY_PROXY_ORIGIN },
|
|
79
|
+
{ service: 'http_status:404' },
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
/** What the wildcard record needs: nothing, a create, or an update. */
|
|
83
|
+
export function planWildcardRecord(live, tunnelId) {
|
|
84
|
+
if (!live)
|
|
85
|
+
return 'create';
|
|
86
|
+
return live.content === `${tunnelId}.cfargotunnel.com` && live.proxied === true
|
|
87
|
+
? 'exists'
|
|
88
|
+
: 'update';
|
|
89
|
+
}
|
|
90
|
+
export const OIKOS_INGRESS_RATE_LIMIT_DESCRIPTION = 'foundry: rate limit sign-in paths (oikos.host, shared)';
|
|
91
|
+
/** The Free plan allows one rule per zone, with only "Path" in the expression, a 10 second period and IP counting. */
|
|
92
|
+
export const OIKOS_INGRESS_RATE_LIMIT_PERIOD_SECONDS = 10;
|
|
93
|
+
export const OIKOS_INGRESS_RATE_LIMIT_REQUESTS = 10;
|
|
94
|
+
export const OIKOS_INGRESS_SIGN_IN_EXPRESSION = 'starts_with(http.request.uri.path, "/auth/")';
|
|
95
|
+
export function oikosIngressRateLimitRule() {
|
|
96
|
+
return {
|
|
97
|
+
description: OIKOS_INGRESS_RATE_LIMIT_DESCRIPTION,
|
|
98
|
+
expression: OIKOS_INGRESS_SIGN_IN_EXPRESSION,
|
|
99
|
+
action: 'block',
|
|
100
|
+
ratelimit: {
|
|
101
|
+
characteristics: ['cf.colo.id', 'ip.src'],
|
|
102
|
+
period: OIKOS_INGRESS_RATE_LIMIT_PERIOD_SECONDS,
|
|
103
|
+
requests_per_period: OIKOS_INGRESS_RATE_LIMIT_REQUESTS,
|
|
104
|
+
mitigation_timeout: OIKOS_INGRESS_RATE_LIMIT_PERIOD_SECONDS,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/** Ours is there, nothing is there, or a rule that is not ours holds the zone's one Free-plan slot. */
|
|
109
|
+
export function planOikosRateLimit(rules) {
|
|
110
|
+
const own = rules.find((r) => r.description === OIKOS_INGRESS_RATE_LIMIT_DESCRIPTION);
|
|
111
|
+
if (own)
|
|
112
|
+
return { status: 'exists', ruleId: own.id };
|
|
113
|
+
return { status: rules.length > 0 ? 'conflict' : 'create', ruleId: null };
|
|
114
|
+
}
|
|
115
|
+
const DEFAULT_CALL_TIMEOUT_MS = 60_000;
|
|
116
|
+
class CallTimeout extends Error {
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* One outside call, with a limit. A failure is reported by name only: a provider
|
|
120
|
+
* error can echo a token or a request body, so it is never carried on.
|
|
121
|
+
*/
|
|
122
|
+
async function call(label, ms, work) {
|
|
123
|
+
let timer;
|
|
124
|
+
try {
|
|
125
|
+
return await Promise.race([
|
|
126
|
+
work(),
|
|
127
|
+
new Promise((_, reject) => {
|
|
128
|
+
timer = setTimeout(() => reject(new CallTimeout()), ms);
|
|
129
|
+
}),
|
|
130
|
+
]);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
const how = error instanceof CallTimeout ? 'timed out' : 'failed';
|
|
134
|
+
throw new Error(`platform:oikos-ingress: ${label} ${how}`);
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Why the step cannot be planned for want of its zone, or `null` when the zone
|
|
142
|
+
* is on the account edge reaches. The texts are fixed; a lookup error is never carried.
|
|
143
|
+
*/
|
|
144
|
+
export async function oikosIngressZoneBlocked(zones, callTimeoutMs = DEFAULT_CALL_TIMEOUT_MS) {
|
|
145
|
+
try {
|
|
146
|
+
const zone = await call('zone lookup', callTimeoutMs, () => zones.findZoneByApex(OIKOS_INGRESS_ZONE));
|
|
147
|
+
return zone && zone.name === OIKOS_INGRESS_ZONE ? null : OIKOS_INGRESS_BLOCKED.zoneMissing;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// The error is dropped on purpose: it may carry provider detail, and the fixed text says enough.
|
|
151
|
+
return OIKOS_INGRESS_BLOCKED.zoneUnchecked;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Makes the ingress, in this order: tunnel, its ingress, the connector app, the
|
|
156
|
+
* wildcard record, the rate limit rule. Each is found by name or content before
|
|
157
|
+
* it is made, so a second run makes nothing twice. Returns the connector app id,
|
|
158
|
+
* which the runner records as the step's applied marker only on success.
|
|
159
|
+
*/
|
|
160
|
+
export async function provisionOikosIngress(context, ports) {
|
|
161
|
+
const ms = ports.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
|
|
162
|
+
const record = async (key, id) => {
|
|
163
|
+
context.recordScopeId(key, id);
|
|
164
|
+
await context.persist?.();
|
|
165
|
+
};
|
|
166
|
+
const recorded = context.recordedIds();
|
|
167
|
+
const zone = await call('zone lookup', ms, () => ports.zones.findZoneByApex(OIKOS_INGRESS_ZONE));
|
|
168
|
+
if (!zone || zone.name !== OIKOS_INGRESS_ZONE) {
|
|
169
|
+
throw new Error(`platform:oikos-ingress: ${OIKOS_INGRESS_BLOCKED.zoneMissing}`);
|
|
170
|
+
}
|
|
171
|
+
let projectUuid = recorded[OIKOS_INGRESS_PROJECT_SCOPE_KEY];
|
|
172
|
+
if (!projectUuid) {
|
|
173
|
+
const found = await call('project lookup', ms, () => ports.coolify.findProjectByName(OIKOS_INGRESS_PROJECT));
|
|
174
|
+
projectUuid = found
|
|
175
|
+
? found.uuid
|
|
176
|
+
: (await call('project create', ms, () => ports.coolify.createProject(OIKOS_INGRESS_PROJECT)))
|
|
177
|
+
.uuid;
|
|
178
|
+
await record(OIKOS_INGRESS_PROJECT_SCOPE_KEY, projectUuid);
|
|
179
|
+
}
|
|
180
|
+
const projectId = projectUuid;
|
|
181
|
+
const { environment } = await call('environment', ms, () => ports.coolify.findOrCreateEnvironment(projectId, OIKOS_INGRESS_ENVIRONMENT));
|
|
182
|
+
const found = await call('tunnel lookup', ms, () => ports.tunnels.findTunnelByName(OIKOS_INGRESS_TUNNEL_NAME));
|
|
183
|
+
const tunnel = found ??
|
|
184
|
+
(await call('tunnel create', ms, () => ports.tunnels.createTunnel(OIKOS_INGRESS_TUNNEL_NAME)));
|
|
185
|
+
await record(OIKOS_INGRESS_TUNNEL_SCOPE_KEY, tunnel.id);
|
|
186
|
+
await call('tunnel ingress', ms, () => ports.tunnels.putTunnelConfig(tunnel.id, { ingress: oikosIngressTunnelIngress() }));
|
|
187
|
+
const connector = await call('connector app', ms * 5, () => ensureCloudflaredConnector({
|
|
188
|
+
coolify: ports.coolify,
|
|
189
|
+
tunnels: ports.tunnels,
|
|
190
|
+
tunnelId: tunnel.id,
|
|
191
|
+
appName: OIKOS_INGRESS_CONNECTOR_APP_NAME,
|
|
192
|
+
projectUuid: projectId,
|
|
193
|
+
environment,
|
|
194
|
+
resolveServer: ports.resolveServer,
|
|
195
|
+
recordedConnectorAppId: recorded[OIKOS_INGRESS_CONNECTOR_APP_SCOPE_KEY] ?? null,
|
|
196
|
+
recordAppId: (appId) => record(OIKOS_INGRESS_CONNECTOR_APP_SCOPE_KEY, appId),
|
|
197
|
+
}));
|
|
198
|
+
const [live] = await call('wildcard lookup', ms, () => ports.dns.findDnsRecord(OIKOS_INGRESS_ZONE, OIKOS_INGRESS_WILDCARD, 'CNAME'));
|
|
199
|
+
const wanted = {
|
|
200
|
+
type: 'CNAME',
|
|
201
|
+
name: OIKOS_INGRESS_WILDCARD,
|
|
202
|
+
content: `${tunnel.id}.cfargotunnel.com`,
|
|
203
|
+
proxied: true,
|
|
204
|
+
};
|
|
205
|
+
const dnsPlan = planWildcardRecord(live, tunnel.id);
|
|
206
|
+
if (dnsPlan === 'create') {
|
|
207
|
+
const made = await call('wildcard create', ms, () => ports.dns.createDnsRecord(OIKOS_INGRESS_ZONE, wanted));
|
|
208
|
+
await record(OIKOS_INGRESS_DNS_SCOPE_KEY, made.id);
|
|
209
|
+
}
|
|
210
|
+
else if (dnsPlan === 'update' && live) {
|
|
211
|
+
const id = live.id;
|
|
212
|
+
await call('wildcard update', ms, () => ports.dns.updateDnsRecord(OIKOS_INGRESS_ZONE, id, wanted));
|
|
213
|
+
await record(OIKOS_INGRESS_DNS_SCOPE_KEY, id);
|
|
214
|
+
}
|
|
215
|
+
else if (live) {
|
|
216
|
+
await record(OIKOS_INGRESS_DNS_SCOPE_KEY, live.id);
|
|
217
|
+
}
|
|
218
|
+
const rules = await call('rate limit lookup', ms, () => ports.ratelimits.listRateLimitRules(zone.id));
|
|
219
|
+
const limit = planOikosRateLimit(rules);
|
|
220
|
+
if (limit.status === 'conflict') {
|
|
221
|
+
throw new Error(`platform:oikos-ingress: the ${OIKOS_INGRESS_ZONE} zone already has a rate limiting rule foundry did not make, and the Free plan allows one; it is not adopted or replaced`);
|
|
222
|
+
}
|
|
223
|
+
if (limit.status === 'create') {
|
|
224
|
+
const made = await call('rate limit create', ms, () => ports.ratelimits.createRateLimitRule(zone.id, oikosIngressRateLimitRule()));
|
|
225
|
+
await record(OIKOS_INGRESS_RATELIMIT_SCOPE_KEY, made.id);
|
|
226
|
+
}
|
|
227
|
+
else if (limit.ruleId) {
|
|
228
|
+
await record(OIKOS_INGRESS_RATELIMIT_SCOPE_KEY, limit.ruleId);
|
|
229
|
+
}
|
|
230
|
+
return { resourceId: connector.appId };
|
|
231
|
+
}
|
package/dist/plan/org-names.d.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { type DeclaredOrg } from '../org.js';
|
|
2
2
|
/** Which side of an org a derived name belongs to: its own services or its hosting entries. */
|
|
3
3
|
export type OrgNameSource = 'own' | 'hosting';
|
|
4
|
+
/**
|
|
5
|
+
* The names foundry gives the services every company can run beside its own
|
|
6
|
+
* hosting entries: Agora, Boule, the tunnel and its connector, and chat,
|
|
7
|
+
* calls and the cms site while they are on.
|
|
8
|
+
*/
|
|
9
|
+
export declare function companyServiceNames(org: Pick<DeclaredOrg, 'slug' | 'chat' | 'calls' | 'site'>): string[];
|
|
4
10
|
/**
|
|
5
11
|
* Every container name, network alias, database, tunnel and bucket name that
|
|
6
12
|
* foundry derives for an org: its own-service names when it owns services,
|
|
7
13
|
* and its hosting-entry and database names.
|
|
8
14
|
*/
|
|
9
|
-
export declare function orgDerivedNames(org: Pick<DeclaredOrg, 'slug' | 'services' | 'hosting'>): {
|
|
15
|
+
export declare function orgDerivedNames(org: Pick<DeclaredOrg, 'slug' | 'services' | 'hosting' | 'chat' | 'calls' | 'site'>): {
|
|
10
16
|
name: string;
|
|
11
17
|
source: OrgNameSource;
|
|
12
18
|
}[];
|
|
@@ -20,5 +26,5 @@ export interface NameClash {
|
|
|
20
26
|
source: OrgNameSource;
|
|
21
27
|
}
|
|
22
28
|
/** Every name `org` derives that another org or the platform also derives. */
|
|
23
|
-
export declare function findNameClashes(org: Pick<DeclaredOrg, 'slug' | 'services' | 'hosting'>, others: readonly Pick<DeclaredOrg, 'slug' | 'services' | 'hosting'>[]): NameClash[];
|
|
29
|
+
export declare function findNameClashes(org: Pick<DeclaredOrg, 'slug' | 'services' | 'hosting' | 'chat' | 'calls' | 'site'>, others: readonly Pick<DeclaredOrg, 'slug' | 'services' | 'hosting' | 'chat' | 'calls' | 'site'>[]): NameClash[];
|
|
24
30
|
export declare function nameClashMessage(slug: string, clash: NameClash): string;
|
package/dist/plan/org-names.js
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
import { ownsServices } from '../org.js';
|
|
2
|
+
import { chatInternalNames, chatRekeyedInternalNames } from './chat-matrix.js';
|
|
2
3
|
import { orgTarget, platformTarget } from './own-services.js';
|
|
3
4
|
import { appNameFor } from './platform-diff.js';
|
|
5
|
+
/**
|
|
6
|
+
* The names foundry gives the services every company can run beside its own
|
|
7
|
+
* hosting entries: Agora, Boule, the tunnel and its connector, and chat,
|
|
8
|
+
* calls and the cms site while they are on.
|
|
9
|
+
*/
|
|
10
|
+
export function companyServiceNames(org) {
|
|
11
|
+
const s = org.slug;
|
|
12
|
+
const names = [`${s}-agora`, `${s}-boule`, `${s}-boule-db`, `${s}-tunnel`, `${s}-cloudflared`];
|
|
13
|
+
if (org.chat?.enabled) {
|
|
14
|
+
names.push(...Object.values(chatInternalNames(s)), ...Object.values(chatRekeyedInternalNames(s)));
|
|
15
|
+
}
|
|
16
|
+
if (org.calls?.enabled) {
|
|
17
|
+
names.push(`${s}-calls`, `${s}-calls-synapse`, `${s}-calls-mas`, `${s}-calls-synapse-db`, `${s}-calls-mas-db`);
|
|
18
|
+
}
|
|
19
|
+
if (org.site?.enabled)
|
|
20
|
+
names.push(`${s}-site`, `${s}-cms`, `${s}-cms-db`);
|
|
21
|
+
return names;
|
|
22
|
+
}
|
|
4
23
|
/**
|
|
5
24
|
* Every container name, network alias, database, tunnel and bucket name that
|
|
6
25
|
* foundry derives for an org: its own-service names when it owns services,
|
|
@@ -8,6 +27,8 @@ import { appNameFor } from './platform-diff.js';
|
|
|
8
27
|
*/
|
|
9
28
|
export function orgDerivedNames(org) {
|
|
10
29
|
const names = [];
|
|
30
|
+
for (const name of companyServiceNames(org))
|
|
31
|
+
names.push({ name, source: 'own' });
|
|
11
32
|
if (ownsServices(org)) {
|
|
12
33
|
const target = orgTarget(org);
|
|
13
34
|
for (const name of [...Object.values(target.names), ...Object.values(target.internal)]) {
|
|
@@ -22,10 +43,35 @@ export function orgDerivedNames(org) {
|
|
|
22
43
|
}
|
|
23
44
|
return names;
|
|
24
45
|
}
|
|
46
|
+
/** Names the platform's own steps give apps, tunnels, connectors, databases and projects outside `platformTarget()`. */
|
|
47
|
+
const PLATFORM_STEP_NAMES = [
|
|
48
|
+
'platform',
|
|
49
|
+
'platform-archon',
|
|
50
|
+
'platform-archon-db',
|
|
51
|
+
'platform-archon-tunnel',
|
|
52
|
+
'platform-archon-cloudflared',
|
|
53
|
+
'platform-hc',
|
|
54
|
+
'platform-hc-db',
|
|
55
|
+
'platform-hc-tunnel',
|
|
56
|
+
'platform-hc-cloudflared',
|
|
57
|
+
'platform-edge',
|
|
58
|
+
'platform-edge-db',
|
|
59
|
+
'platform-edge-tunnel',
|
|
60
|
+
'platform-edge-cloudflared',
|
|
61
|
+
'platform-ai',
|
|
62
|
+
'platform-ai-migrate',
|
|
63
|
+
'platform-ai-worker',
|
|
64
|
+
'platform-backups',
|
|
65
|
+
'platform-oikos-ingress',
|
|
66
|
+
];
|
|
25
67
|
/** Every name the platform's shared services take. */
|
|
26
68
|
export function platformDerivedNames() {
|
|
27
69
|
const target = platformTarget();
|
|
28
|
-
return [
|
|
70
|
+
return [
|
|
71
|
+
...Object.values(target.names),
|
|
72
|
+
...Object.values(target.internal),
|
|
73
|
+
...PLATFORM_STEP_NAMES,
|
|
74
|
+
];
|
|
29
75
|
}
|
|
30
76
|
/** Every name `org` derives that another org or the platform also derives. */
|
|
31
77
|
export function findNameClashes(org, others) {
|
|
@@ -36,6 +82,13 @@ export function findNameClashes(org, others) {
|
|
|
36
82
|
if (platform.has(name))
|
|
37
83
|
clashes.push({ name, other: 'platform', source });
|
|
38
84
|
}
|
|
85
|
+
// A hosting entry may not take a name foundry runs a service under for the same company.
|
|
86
|
+
const ownServices = new Set(companyServiceNames(org));
|
|
87
|
+
for (const { name, source } of mine) {
|
|
88
|
+
if (source === 'hosting' && ownServices.has(name)) {
|
|
89
|
+
clashes.push({ name, other: org.slug, source });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
39
92
|
for (const other of others) {
|
|
40
93
|
if (other.slug === org.slug)
|
|
41
94
|
continue;
|
|
@@ -48,5 +101,8 @@ export function findNameClashes(org, others) {
|
|
|
48
101
|
return clashes;
|
|
49
102
|
}
|
|
50
103
|
export function nameClashMessage(slug, clash) {
|
|
104
|
+
if (clash.other === slug) {
|
|
105
|
+
return `name '${clash.name}' is taken by both a hosting entry and a service foundry runs for '${slug}'`;
|
|
106
|
+
}
|
|
51
107
|
return `name '${clash.name}' is taken by both '${slug}' and '${clash.other}'`;
|
|
52
108
|
}
|
package/dist/plan/own-diff.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type DeclaredOrg } from '../org.js';
|
|
2
2
|
import type { DraftStep } from './diff.js';
|
|
3
|
+
import { OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY } from './login-host.js';
|
|
3
4
|
import { type OrgPlatformReadiness } from './platform-org.js';
|
|
4
5
|
/** `unmanaged` key of the one line `planOwnJobs` reports. */
|
|
5
6
|
export declare const OWN_JOBS_BLOCKED_KEY = "own-jobs";
|
|
@@ -7,7 +8,6 @@ export interface PlanOwnJobsOptions {
|
|
|
7
8
|
org: DeclaredOrg;
|
|
8
9
|
recordedIds: Readonly<Record<string, string>>;
|
|
9
10
|
readiness: OrgPlatformReadiness;
|
|
10
|
-
isPlatformOrg: boolean;
|
|
11
11
|
/** The executor was built with an `ownServices` port. */
|
|
12
12
|
ownServices: boolean;
|
|
13
13
|
/** The admin token's held-key ref holds a value, so an undelivered marker is resumed, not blocked. */
|
|
@@ -26,21 +26,21 @@ export declare function planOwnJobs(options: PlanOwnJobsOptions): {
|
|
|
26
26
|
};
|
|
27
27
|
/** `unmanaged` key of the one line `planOwnAuth` reports. */
|
|
28
28
|
export declare const OWN_AUTH_BLOCKED_KEY = "own-auth";
|
|
29
|
-
|
|
30
|
-
export declare const OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY = "own_auth_applied_hostname";
|
|
29
|
+
export { OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY };
|
|
31
30
|
/** The auth git ref an own org's sign-in last deployed, so a changed declared ref plans the step again. */
|
|
32
31
|
export declare const OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY = "own_auth_applied_git_ref";
|
|
33
32
|
export interface PlanOwnAuthOptions {
|
|
34
33
|
org: DeclaredOrg;
|
|
35
34
|
recordedIds: Readonly<Record<string, string>>;
|
|
36
35
|
readiness: OrgPlatformReadiness;
|
|
37
|
-
isPlatformOrg: boolean;
|
|
38
36
|
/** The executor was built with an `ownServices` port. */
|
|
39
37
|
ownServices: boolean;
|
|
40
38
|
/** The org's Cloudflare zone as the live read shows it, or null when none exists yet. */
|
|
41
39
|
zone: {
|
|
42
40
|
active: boolean;
|
|
43
41
|
} | null;
|
|
42
|
+
/** The platform's edge has the `oikos.host` zone: the zone gate of a company without a domain. */
|
|
43
|
+
oikosZoneReady?: boolean;
|
|
44
44
|
/** The org's own Lettermint key (`orgTarget(org).refs.brokerMailKey`) is held. */
|
|
45
45
|
mailKeyHeld: boolean;
|
|
46
46
|
/** The host can mint that key itself (a Lettermint port and a held-key reader): the step plans without it. */
|
|
@@ -65,13 +65,17 @@ export interface PlanOwnMailOptions {
|
|
|
65
65
|
org: DeclaredOrg;
|
|
66
66
|
recordedIds: Readonly<Record<string, string>>;
|
|
67
67
|
readiness: OrgPlatformReadiness;
|
|
68
|
-
isPlatformOrg: boolean;
|
|
69
68
|
/** The executor was built with an `ownServices` port. */
|
|
70
69
|
ownServices: boolean;
|
|
71
70
|
/** `platform.mail.relay` is declared and the relay recorded a route list as applied. */
|
|
72
71
|
mailRelayApplied: boolean;
|
|
73
72
|
/** The org's zone is live and active. */
|
|
74
73
|
zoneActive: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* The host port the company's Stalwart publishes for the relay (`plan-apply.ts` gives the one the
|
|
76
|
+
* company already has, else the lowest free). Absent: the first port of the range.
|
|
77
|
+
*/
|
|
78
|
+
smtpPort?: number;
|
|
75
79
|
}
|
|
76
80
|
/**
|
|
77
81
|
* An own org (`services.own`) with mail on gets its own Stalwart in its own
|
|
@@ -92,7 +96,6 @@ export declare const OWN_SFU_FIREWALL_KEY = "own-sfu:firewall";
|
|
|
92
96
|
export interface PlanOwnSfuOptions {
|
|
93
97
|
org: DeclaredOrg;
|
|
94
98
|
recordedIds: Readonly<Record<string, string>>;
|
|
95
|
-
isPlatformOrg: boolean;
|
|
96
99
|
/** The executor was built with an `ownServices` port. */
|
|
97
100
|
ownServices: boolean;
|
|
98
101
|
/** The org's zone is live and active: none is planned and none is waiting. */
|
|
@@ -122,7 +125,6 @@ export interface PlanOwnFilesOptions {
|
|
|
122
125
|
org: DeclaredOrg;
|
|
123
126
|
recordedIds: Readonly<Record<string, string>>;
|
|
124
127
|
readiness: OrgPlatformReadiness;
|
|
125
|
-
isPlatformOrg: boolean;
|
|
126
128
|
/** The executor was built with an `ownServices` port. */
|
|
127
129
|
ownServices: boolean;
|
|
128
130
|
/** What the host's own files dependencies supply: valid keys worker settings, and a bucket port. */
|
|
@@ -149,7 +151,6 @@ export declare const OWN_FILES_R2_BLOCKED_KEY = "own-files-r2";
|
|
|
149
151
|
export interface PlanOwnFilesR2Options {
|
|
150
152
|
org: DeclaredOrg;
|
|
151
153
|
recordedIds: Readonly<Record<string, string>>;
|
|
152
|
-
isPlatformOrg: boolean;
|
|
153
154
|
/** The executor was built with an `ownServices` port. */
|
|
154
155
|
ownServices: boolean;
|
|
155
156
|
/** Which ports the step needs the host supplied: the R2 key port, and the files-organisation `storeR2Credentials`. */
|