@wtfalch/foundry 0.33.1 → 0.35.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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 +24 -0
- package/dist/bootstrap/versions.js +46 -0
- package/dist/calls-sfu.d.ts +10 -0
- package/dist/calls-sfu.js +44 -3
- package/dist/index.d.ts +21 -8
- package/dist/index.js +11 -4
- package/dist/migrations/0051_org_services.sql +13 -0
- package/dist/migrations/0052_platform_mail_relay.sql +8 -0
- package/dist/migrations/0053_heartbeats_provider.sql +9 -0
- package/dist/migrations/0053_org_version_pin.sql +6 -0
- package/dist/org.d.ts +76 -12
- package/dist/org.js +136 -20
- 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.d.ts +9 -0
- package/dist/plan/calls-matrix.js +18 -1
- package/dist/plan/cloudflared-connector.d.ts +7 -1
- package/dist/plan/cloudflared-connector.js +15 -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 +46 -8
- package/dist/plan/diff.js +367 -126
- package/dist/plan/edge-access.d.ts +15 -0
- package/dist/plan/edge-access.js +21 -2
- package/dist/plan/executor.d.ts +276 -5
- package/dist/plan/executor.js +1111 -109
- package/dist/plan/health-check-settings.d.ts +28 -0
- package/dist/plan/health-check-settings.js +36 -0
- package/dist/plan/health-gaps.d.ts +33 -0
- package/dist/plan/health-gaps.js +89 -0
- 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 +15 -0
- package/dist/plan/held-key-refs.js +15 -0
- package/dist/plan/hostnames.d.ts +10 -0
- package/dist/plan/hostnames.js +14 -0
- package/dist/plan/index.d.ts +19 -6
- package/dist/plan/index.js +10 -3
- package/dist/plan/live.d.ts +11 -2
- package/dist/plan/live.js +16 -2
- package/dist/plan/locked-hostnames.d.ts +7 -3
- package/dist/plan/locked-hostnames.js +25 -4
- package/dist/plan/login-host.d.ts +61 -12
- package/dist/plan/login-host.js +99 -11
- package/dist/plan/mail-relay.d.ts +139 -0
- package/dist/plan/mail-relay.js +400 -0
- 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 +30 -0
- package/dist/plan/org-names.js +108 -0
- package/dist/plan/own-diff.d.ts +170 -0
- package/dist/plan/own-diff.js +440 -0
- package/dist/plan/own-dns.d.ts +6 -0
- package/dist/plan/own-dns.js +15 -0
- package/dist/plan/own-hostnames.d.ts +27 -0
- package/dist/plan/own-hostnames.js +24 -0
- package/dist/plan/own-limits.d.ts +213 -0
- package/dist/plan/own-limits.js +232 -0
- package/dist/plan/own-services.d.ts +193 -0
- package/dist/plan/own-services.js +169 -0
- package/dist/plan/own-sfu.d.ts +69 -0
- package/dist/plan/own-sfu.js +256 -0
- package/dist/plan/plan-apply.d.ts +121 -10
- package/dist/plan/plan-apply.js +357 -18
- package/dist/plan/platform-diff.d.ts +155 -11
- package/dist/plan/platform-diff.js +271 -66
- package/dist/plan/platform-org.d.ts +107 -4
- package/dist/plan/platform-org.js +175 -3
- package/dist/plan/platform-providers.d.ts +474 -81
- package/dist/plan/platform-providers.js +1407 -560
- 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/sign-in-org.d.ts +1 -1
- package/dist/plan/sign-in-org.js +20 -2
- package/dist/plan/zitadel-login-policy.d.ts +1 -1
- package/dist/plan/zitadel-login-policy.js +77 -9
- package/dist/plan/zitadel-system-api.d.ts +22 -1
- package/dist/plan/zitadel-system-api.js +49 -8
- package/dist/platform.d.ts +40 -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 +26 -3
- package/dist/store/postgres.js +93 -2
- package/dist/store/schema.d.ts +87 -0
- package/dist/store/schema.js +9 -0
- package/package.json +3 -3
- package/versions.digests.json +11 -0
- package/versions.json +3 -1
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { pinnedRef } from '../bootstrap/versions.js';
|
|
3
|
+
import { waitForDeploymentFinished } from './deploy-health.js';
|
|
4
|
+
import { commandHealthCheckSettings, tcpProbe } from './health-check-settings.js';
|
|
5
|
+
/**
|
|
6
|
+
* The shared mail relay: one Postfix container that owns host port 25 and
|
|
7
|
+
* hands each message to the mail server that serves its recipient's domain,
|
|
8
|
+
* on the Docker network. One server address has one inbound mail port, so a
|
|
9
|
+
* company's own mail server cannot bind it; this container does, for all.
|
|
10
|
+
*
|
|
11
|
+
* It is a platform step (`coolify:app` keyed {@link PLATFORM_MAIL_RELAY_KEY}),
|
|
12
|
+
* planned only when `platform.mail.relay` is true. It mints no secret.
|
|
13
|
+
* Everything it knows reaches the container as env the step writes.
|
|
14
|
+
*/
|
|
15
|
+
export const PLATFORM_MAIL_RELAY_KEY = 'platform:mail-relay';
|
|
16
|
+
/** Fingerprint of the sorted routes the last successful apply put live. */
|
|
17
|
+
export const PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY = 'mail_relay_routes_applied';
|
|
18
|
+
/**
|
|
19
|
+
* Fingerprint of what forces an image rebuild (the Dockerfile text, which holds
|
|
20
|
+
* the base image pin) and the spool volume the last successful apply put live.
|
|
21
|
+
* A routes change leaves it alone, so it restarts the container and rebuilds nothing.
|
|
22
|
+
*/
|
|
23
|
+
export const PLATFORM_MAIL_RELAY_BUILD_SCOPE_KEY = 'mail_relay_build_applied';
|
|
24
|
+
/** Postfix's queue. On a volume, so mail not yet handed on survives a restart and a rebuild. */
|
|
25
|
+
export const MAIL_RELAY_SPOOL_DIR = '/var/spool/postfix';
|
|
26
|
+
export const MAIL_RELAY_SPOOL_VOLUME = 'platform-mail-relay-spool';
|
|
27
|
+
/**
|
|
28
|
+
* The org-applied-state key the company mail step records its server's SMTP address under: `<server address>:<port>`,
|
|
29
|
+
* the port the company's Stalwart publishes on the server for the relay ({@link OWN_MAIL_SMTP_PORT_SCOPE_KEY}).
|
|
30
|
+
* The relay is on the platform's network and a company's mail server on the company's own, so the relay
|
|
31
|
+
* reaches it through the server, not by its container name.
|
|
32
|
+
*/
|
|
33
|
+
export const OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY = 'mail_smtp_internal';
|
|
34
|
+
/** The host port a company's Stalwart publishes (mapped to its port 25) for the relay. */
|
|
35
|
+
export const OWN_MAIL_SMTP_PORT_SCOPE_KEY = 'mail_smtp_port';
|
|
36
|
+
/** The host ports companies' mail servers take, one each: the lowest free one in this range. */
|
|
37
|
+
export const OWN_MAIL_SMTP_PORT_BASE = 25000;
|
|
38
|
+
export const OWN_MAIL_SMTP_PORT_MAX = 25999;
|
|
39
|
+
/** Whether `port` is one of the ports a company's mail server may publish. */
|
|
40
|
+
export function isOwnMailSmtpPort(port) {
|
|
41
|
+
return (typeof port === 'number' &&
|
|
42
|
+
Number.isInteger(port) &&
|
|
43
|
+
port >= OWN_MAIL_SMTP_PORT_BASE &&
|
|
44
|
+
port <= OWN_MAIL_SMTP_PORT_MAX);
|
|
45
|
+
}
|
|
46
|
+
/** `executor.ts`'s `isRunningOrStarting`, restated: importing it would make this module and the planner import each other. */
|
|
47
|
+
function isRunningOrStarting(status) {
|
|
48
|
+
if (!status)
|
|
49
|
+
return true;
|
|
50
|
+
return !status.startsWith('exited') && !status.startsWith('stopped');
|
|
51
|
+
}
|
|
52
|
+
export const MAIL_RELAY_APP_NAME = 'platform-mail-relay';
|
|
53
|
+
const LABEL = '[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?';
|
|
54
|
+
const DOMAIN_PATTERN = new RegExp(`^(${LABEL}\\.)+[a-z]{2,63}$`);
|
|
55
|
+
const HOST_PATTERN = new RegExp(`^${LABEL}(\\.${LABEL})*$`);
|
|
56
|
+
/** Lower-cases and checks a recipient domain; `null` for anything that is not a plain DNS name. */
|
|
57
|
+
export function normalizeMailDomain(domain) {
|
|
58
|
+
const lower = domain.toLowerCase();
|
|
59
|
+
return lower.length <= 253 && DOMAIN_PATTERN.test(lower) ? lower : null;
|
|
60
|
+
}
|
|
61
|
+
/** Lower-cases and checks `<host>:<port>`; `null` for whitespace, metacharacters, a port outside 1..65535 or no host. */
|
|
62
|
+
export function normalizeMailTarget(target) {
|
|
63
|
+
const lower = target.toLowerCase();
|
|
64
|
+
const colon = lower.lastIndexOf(':');
|
|
65
|
+
if (colon < 1)
|
|
66
|
+
return null;
|
|
67
|
+
const host = lower.slice(0, colon);
|
|
68
|
+
const port = lower.slice(colon + 1);
|
|
69
|
+
if (!/^[1-9][0-9]{0,4}$/.test(port) || Number(port) > 65535)
|
|
70
|
+
return null;
|
|
71
|
+
return host.length <= 253 && HOST_PATTERN.test(host) ? lower : null;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The routes, checked and sorted by domain. `null` when any entry is invalid or
|
|
75
|
+
* one domain has two targets (a message could not know where to go).
|
|
76
|
+
*/
|
|
77
|
+
export function normalizeMailRoutes(routes) {
|
|
78
|
+
const byDomain = new Map();
|
|
79
|
+
for (const route of routes) {
|
|
80
|
+
const domain = normalizeMailDomain(route.domain);
|
|
81
|
+
const target = normalizeMailTarget(route.target);
|
|
82
|
+
if (!domain || !target)
|
|
83
|
+
return null;
|
|
84
|
+
const existing = byDomain.get(domain);
|
|
85
|
+
if (existing !== undefined && existing !== target)
|
|
86
|
+
return null;
|
|
87
|
+
byDomain.set(domain, target);
|
|
88
|
+
}
|
|
89
|
+
return [...byDomain.entries()]
|
|
90
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
91
|
+
.map(([domain, target]) => ({ domain, target }));
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* What the relay was last built to do, as one value. Order of the inputs does
|
|
95
|
+
* not matter; a changed target or a new route does.
|
|
96
|
+
* Never empty, so a platform with no company route still records one.
|
|
97
|
+
*/
|
|
98
|
+
export function mailRelayFingerprint(routes) {
|
|
99
|
+
const sortedRoutes = normalizeMailRoutes(routes) ?? [];
|
|
100
|
+
return createHash('sha256')
|
|
101
|
+
.update(JSON.stringify(sortedRoutes.map((r) => [r.domain, r.target])))
|
|
102
|
+
.digest('hex');
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The relay's container start script. It renders Postfix's config from env
|
|
106
|
+
* only and refuses to start on a value that is not a plain domain or host, so
|
|
107
|
+
* nothing from env reaches a config line unchecked. The relay accepts mail for
|
|
108
|
+
* the listed domains and nothing else (`reject_unauth_destination`), so it
|
|
109
|
+
* cannot be used to send to a third party.
|
|
110
|
+
*
|
|
111
|
+
* Mail for a domain no company routes is refused: there is no platform mail
|
|
112
|
+
* server to fall back on, so no default target exists.
|
|
113
|
+
*
|
|
114
|
+
* Env: `MAIL_RELAY_HOSTNAME`, `MAIL_RELAY_ROUTES` (`domain=host:port`, comma
|
|
115
|
+
* separated). `MAIL_RELAY_RENDER_ONLY=1` writes the files and stops, and
|
|
116
|
+
* `MAIL_RELAY_CONF_DIR` moves them; both are for tests.
|
|
117
|
+
*/
|
|
118
|
+
export const MAIL_RELAY_ENTRYPOINT = `#!/bin/sh
|
|
119
|
+
set -eu
|
|
120
|
+
set -f
|
|
121
|
+
conf="\${MAIL_RELAY_CONF_DIR:-/etc/postfix}"
|
|
122
|
+
fail() { echo "mail relay: $1" >&2; exit 1; }
|
|
123
|
+
ok_name() {
|
|
124
|
+
case "$1" in
|
|
125
|
+
''|*[!a-z0-9.-]*|.*|*.|*..*|-*|*.-*|*-.*) return 1 ;;
|
|
126
|
+
esac
|
|
127
|
+
return 0
|
|
128
|
+
}
|
|
129
|
+
ok_domain() { ok_name "$1" && case "$1" in *.*) return 0 ;; esac; return 1; }
|
|
130
|
+
ok_target() {
|
|
131
|
+
h="\${1%:*}"
|
|
132
|
+
p="\${1##*:}"
|
|
133
|
+
[ "$h" != "$1" ] || return 1
|
|
134
|
+
case "$p" in ''|0*|*[!0-9]*) return 1 ;; esac
|
|
135
|
+
[ "\${#p}" -le 5 ] || return 1
|
|
136
|
+
[ "$p" -le 65535 ] || return 1
|
|
137
|
+
ok_name "$h"
|
|
138
|
+
}
|
|
139
|
+
ok_name "\${MAIL_RELAY_HOSTNAME:-}" || fail "MAIL_RELAY_HOSTNAME is not a host name"
|
|
140
|
+
: > "$conf/relay_domains"
|
|
141
|
+
: > "$conf/transport"
|
|
142
|
+
IFS=','
|
|
143
|
+
for entry in \${MAIL_RELAY_ROUTES:-}; do
|
|
144
|
+
d="\${entry%%=*}"
|
|
145
|
+
t="\${entry#*=}"
|
|
146
|
+
ok_domain "$d" || fail "a route domain is not a plain domain"
|
|
147
|
+
ok_target "$t" || fail "a route target is not <host>:<port>"
|
|
148
|
+
printf '%s OK\\n' "$d" >> "$conf/relay_domains"
|
|
149
|
+
printf '%s smtp:[%s]:%s\\n' "$d" "\${t%:*}" "\${t##*:}" >> "$conf/transport"
|
|
150
|
+
done
|
|
151
|
+
unset IFS
|
|
152
|
+
cat > "$conf/main.cf" <<CONF
|
|
153
|
+
myhostname = $MAIL_RELAY_HOSTNAME
|
|
154
|
+
myorigin = \\$myhostname
|
|
155
|
+
inet_interfaces = all
|
|
156
|
+
inet_protocols = ipv4
|
|
157
|
+
mydestination =
|
|
158
|
+
mynetworks = 127.0.0.0/8
|
|
159
|
+
local_recipient_maps =
|
|
160
|
+
alias_maps =
|
|
161
|
+
alias_database =
|
|
162
|
+
relay_domains = texthash:$conf/relay_domains
|
|
163
|
+
parent_domain_matches_subdomains =
|
|
164
|
+
transport_maps = texthash:$conf/transport
|
|
165
|
+
relay_recipient_maps =
|
|
166
|
+
smtpd_relay_restrictions = reject_unauth_destination
|
|
167
|
+
smtpd_recipient_restrictions = reject_unauth_destination
|
|
168
|
+
disable_vrfy_command = yes
|
|
169
|
+
maillog_file = /dev/stdout
|
|
170
|
+
CONF
|
|
171
|
+
[ "\${MAIL_RELAY_RENDER_ONLY:-}" = "1" ] && exit 0
|
|
172
|
+
exec postfix start-fg
|
|
173
|
+
`;
|
|
174
|
+
/** The inline Dockerfile Coolify builds. The start script travels base64-encoded so no quoting can alter it. */
|
|
175
|
+
export function mailRelayDockerfile() {
|
|
176
|
+
const script = Buffer.from(MAIL_RELAY_ENTRYPOINT, 'utf8').toString('base64');
|
|
177
|
+
return [
|
|
178
|
+
`FROM alpine:${pinnedRef('alpine')}`,
|
|
179
|
+
`RUN apk add --no-cache postfix && echo ${script} | base64 -d > /entrypoint.sh && chmod +x /entrypoint.sh`,
|
|
180
|
+
'EXPOSE 25',
|
|
181
|
+
'ENTRYPOINT ["/entrypoint.sh"]',
|
|
182
|
+
'',
|
|
183
|
+
].join('\n');
|
|
184
|
+
}
|
|
185
|
+
/** What a rebuild depends on: the Dockerfile and the spool mount. Routes, domains and hostname are not in it. */
|
|
186
|
+
export function mailRelayBuildFingerprint() {
|
|
187
|
+
return createHash('sha256')
|
|
188
|
+
.update(JSON.stringify([mailRelayDockerfile(), MAIL_RELAY_SPOOL_DIR]))
|
|
189
|
+
.digest('hex');
|
|
190
|
+
}
|
|
191
|
+
/** The env the step writes; the only input the container reads. */
|
|
192
|
+
export function mailRelayEnv(input) {
|
|
193
|
+
return [
|
|
194
|
+
{ key: 'MAIL_RELAY_HOSTNAME', value: input.hostname },
|
|
195
|
+
{
|
|
196
|
+
key: 'MAIL_RELAY_ROUTES',
|
|
197
|
+
value: input.routes.map((r) => `${r.domain}=${r.target}`).join(','),
|
|
198
|
+
},
|
|
199
|
+
].map((e) => ({ ...e, literal: true }));
|
|
200
|
+
}
|
|
201
|
+
export const MAIL_RELAY_BLOCKED = {
|
|
202
|
+
executorMissing: "the platform's mail relay is blocked, not planned: the host built the platform executor without its mail dependencies",
|
|
203
|
+
invalidRoutes: "the platform's mail relay is blocked, not planned: a mail route is not valid (a domain must be a plain DNS name, a target must be <host>:25, one domain needs one target)",
|
|
204
|
+
};
|
|
205
|
+
/**
|
|
206
|
+
* Plans the relay: nothing unless `mail.relay` is true. Planned when it was
|
|
207
|
+
* never applied, when the fingerprint of its routes differs from the recorded
|
|
208
|
+
* one, or when the base image pin changed. Blocked (reported, not planned) while
|
|
209
|
+
* the platform mail server is not applied, the executor cannot run it, or an
|
|
210
|
+
* input is invalid.
|
|
211
|
+
*/
|
|
212
|
+
export function planMailRelay(platform, recordedIds, input) {
|
|
213
|
+
if (platform.mail.relay !== true)
|
|
214
|
+
return { step: null, blocked: null };
|
|
215
|
+
if (input.executorBlocked)
|
|
216
|
+
return { step: null, blocked: input.executorBlocked };
|
|
217
|
+
const routes = normalizeMailRoutes(input.routes);
|
|
218
|
+
if (!routes)
|
|
219
|
+
return { step: null, blocked: MAIL_RELAY_BLOCKED.invalidRoutes };
|
|
220
|
+
const fingerprint = mailRelayFingerprint(routes);
|
|
221
|
+
const applied = Boolean(recordedIds[`coolify:app:${PLATFORM_MAIL_RELAY_KEY}`]);
|
|
222
|
+
if (applied &&
|
|
223
|
+
recordedIds[PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY] === fingerprint &&
|
|
224
|
+
recordedIds[PLATFORM_MAIL_RELAY_BUILD_SCOPE_KEY] === mailRelayBuildFingerprint()) {
|
|
225
|
+
return { step: null, blocked: null };
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
step: {
|
|
229
|
+
action: {
|
|
230
|
+
system: 'coolify',
|
|
231
|
+
kind: 'app',
|
|
232
|
+
key: PLATFORM_MAIL_RELAY_KEY,
|
|
233
|
+
desired: { routes, fingerprint },
|
|
234
|
+
},
|
|
235
|
+
step: {
|
|
236
|
+
description: applied
|
|
237
|
+
? `redeploy the shared mail relay with ${routes.length} company route${routes.length === 1 ? '' : 's'}`
|
|
238
|
+
: `provision the shared mail relay on port 25 with ${routes.length} company route${routes.length === 1 ? '' : 's'}`,
|
|
239
|
+
destroys: false,
|
|
240
|
+
migrates: false,
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
blocked: null,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Makes the relay true: finds or creates the app, sets its internal name and
|
|
248
|
+
* host port 25, mounts the queue volume once, writes env, and then does the
|
|
249
|
+
* least that makes it true (decided from the live app):
|
|
250
|
+
* - a rebuild (stop, deploy) when the app was just created, the Dockerfile or
|
|
251
|
+
* volume changed, or the volume was just added. The old container is stopped
|
|
252
|
+
* first, because two containers cannot both bind port 25;
|
|
253
|
+
* - a deploy when the container is not running;
|
|
254
|
+
* - a restart of the same container when only the routes, domains or hostname
|
|
255
|
+
* changed. Postfix reads its routes at start, nothing else is needed.
|
|
256
|
+
* The two fingerprints are recorded last, so a run that fails halfway (env
|
|
257
|
+
* written, restart failed) leaves the step planned and the next apply
|
|
258
|
+
* finishes it. No secret exists here.
|
|
259
|
+
*/
|
|
260
|
+
export async function provisionMailRelay(action, context, deps, input) {
|
|
261
|
+
const routes = normalizeMailRoutes((action.desired.routes ?? []));
|
|
262
|
+
if (!routes) {
|
|
263
|
+
throw new Error('platform:mail-relay: a mail route is invalid');
|
|
264
|
+
}
|
|
265
|
+
const runtime = deps.runtime ?? {};
|
|
266
|
+
const sleep = runtime.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
267
|
+
const recorded = context.recordedIds();
|
|
268
|
+
const record = async (key, id) => {
|
|
269
|
+
context.recordScopeId(key, id);
|
|
270
|
+
await context.persist?.();
|
|
271
|
+
};
|
|
272
|
+
const { environment } = await deps.coolify.findOrCreateEnvironment(input.projectUuid, 'production');
|
|
273
|
+
const existing = await deps.coolify.findApplicationByName(MAIL_RELAY_APP_NAME, {
|
|
274
|
+
environmentIds: [environment.id],
|
|
275
|
+
});
|
|
276
|
+
let appId;
|
|
277
|
+
let running = false;
|
|
278
|
+
const justCreated = !existing;
|
|
279
|
+
if (existing) {
|
|
280
|
+
appId = existing.uuid;
|
|
281
|
+
running = isRunningOrStarting((await deps.coolify.getApplication(appId)).status);
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
appId = (await deps.coolify.createDockerfileApplication({
|
|
285
|
+
project_uuid: input.projectUuid,
|
|
286
|
+
server_uuid: input.serverUuid,
|
|
287
|
+
environment_name: environment.name,
|
|
288
|
+
environment_uuid: environment.uuid,
|
|
289
|
+
dockerfile: mailRelayDockerfile(),
|
|
290
|
+
name: MAIL_RELAY_APP_NAME,
|
|
291
|
+
description: 'Postfix relay that owns host port 25 and routes by recipient domain',
|
|
292
|
+
instant_deploy: false,
|
|
293
|
+
})).uuid;
|
|
294
|
+
}
|
|
295
|
+
await record(input.appScopeKey, appId);
|
|
296
|
+
const buildFingerprint = mailRelayBuildFingerprint();
|
|
297
|
+
const routesFingerprint = mailRelayFingerprint(routes);
|
|
298
|
+
const spoolMounted = (await deps.coolify.listStorages(appId)).persistent_storages?.some((s) => s.mount_path === MAIL_RELAY_SPOOL_DIR);
|
|
299
|
+
if (!spoolMounted) {
|
|
300
|
+
await deps.coolify.createPersistentStorage(appId, {
|
|
301
|
+
name: MAIL_RELAY_SPOOL_VOLUME,
|
|
302
|
+
mount_path: MAIL_RELAY_SPOOL_DIR,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
// A volume added to an app that already exists binds only at a new container, so it is a rebuild too.
|
|
306
|
+
const baseChanged = !justCreated &&
|
|
307
|
+
(recorded[PLATFORM_MAIL_RELAY_BUILD_SCOPE_KEY] !== buildFingerprint || !spoolMounted);
|
|
308
|
+
await deps.coolify.updateApplication(appId, {
|
|
309
|
+
custom_internal_name: MAIL_RELAY_APP_NAME,
|
|
310
|
+
ports_exposes: '25',
|
|
311
|
+
ports_mappings: '25:25',
|
|
312
|
+
// Postfix has no HTTP: a connect to port 25 on the loopback is the check.
|
|
313
|
+
...commandHealthCheckSettings(tcpProbe('25')),
|
|
314
|
+
...(baseChanged
|
|
315
|
+
? { dockerfile: Buffer.from(mailRelayDockerfile(), 'utf8').toString('base64') }
|
|
316
|
+
: {}),
|
|
317
|
+
});
|
|
318
|
+
const envs = mailRelayEnv({
|
|
319
|
+
hostname: input.hostname,
|
|
320
|
+
routes,
|
|
321
|
+
});
|
|
322
|
+
const live = new Map((await deps.coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => [e.key, e.value]));
|
|
323
|
+
const envsChanged = envs.some((e) => live.get(e.key) !== e.value);
|
|
324
|
+
await deps.coolify.bulkUpsertEnvs(appId, envs);
|
|
325
|
+
// The recorded routes fingerprint, not only the env diff: env written by a run whose restart failed
|
|
326
|
+
// reads as unchanged on the next apply, and the routes would never go live.
|
|
327
|
+
const routesChanged = envsChanged || recorded[PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY] !== routesFingerprint;
|
|
328
|
+
const rebuild = async (stopFirst) => {
|
|
329
|
+
if (stopFirst) {
|
|
330
|
+
await deps.coolify.raw('POST', `/applications/${appId}/stop`);
|
|
331
|
+
await waitForStopped(deps.coolify, appId, runtime, sleep);
|
|
332
|
+
}
|
|
333
|
+
await waitForDeploymentFinished(deps.coolify, appId, runtime, 'platform:mail-relay', 'the relay keeps its previous routes');
|
|
334
|
+
};
|
|
335
|
+
if (justCreated || !running || baseChanged) {
|
|
336
|
+
await rebuild(!justCreated && running);
|
|
337
|
+
}
|
|
338
|
+
else if (routesChanged) {
|
|
339
|
+
// A restart that names no deployment cannot be told from the old container still being healthy.
|
|
340
|
+
const followed = await restartAndWaitHealthy(deps.coolify, appId, runtime, sleep);
|
|
341
|
+
if (!followed)
|
|
342
|
+
await rebuild(true);
|
|
343
|
+
}
|
|
344
|
+
await record(PLATFORM_MAIL_RELAY_BUILD_SCOPE_KEY, buildFingerprint);
|
|
345
|
+
await record(PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY, routesFingerprint);
|
|
346
|
+
return { resourceId: appId };
|
|
347
|
+
}
|
|
348
|
+
async function waitForStopped(coolify, appId, runtime, sleep) {
|
|
349
|
+
const now = runtime.now ?? Date.now;
|
|
350
|
+
const timeout = runtime.readyTimeoutMs ?? 300_000;
|
|
351
|
+
const interval = runtime.readyIntervalMs ?? 5_000;
|
|
352
|
+
const deadline = now() + timeout;
|
|
353
|
+
for (;;) {
|
|
354
|
+
const { status } = await coolify.getApplication(appId);
|
|
355
|
+
if (!status || !isRunningOrStarting(status))
|
|
356
|
+
return;
|
|
357
|
+
if (now() >= deadline) {
|
|
358
|
+
throw new Error(`platform:mail-relay: the relay app ${appId} did not stop within ${Math.round(timeout / 1000)}s; apply again`);
|
|
359
|
+
}
|
|
360
|
+
await sleep(interval);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Restarts the same container (no stop, no image build) and returns once it is
|
|
365
|
+
* healthy again. Coolify queues a restart and answers before the container
|
|
366
|
+
* moves, so the old container's healthy status alone proves nothing: the
|
|
367
|
+
* answer must name a deployment and that record must finish first. Returns
|
|
368
|
+
* false, having waited for nothing, when the answer names none.
|
|
369
|
+
*/
|
|
370
|
+
async function restartAndWaitHealthy(coolify, appId, runtime, sleep) {
|
|
371
|
+
const now = runtime.now ?? Date.now;
|
|
372
|
+
const timeout = runtime.readyTimeoutMs ?? 300_000;
|
|
373
|
+
const interval = runtime.readyIntervalMs ?? 5_000;
|
|
374
|
+
const deadline = now() + timeout;
|
|
375
|
+
const late = (what) => new Error(`platform:mail-relay: the relay app ${appId} did not ${what} within ${Math.round(timeout / 1000)}s after its restart; the relay keeps its previous routes. Apply again`);
|
|
376
|
+
const answer = (await coolify.restart(appId));
|
|
377
|
+
const deploymentUuid = typeof answer?.deployment_uuid === 'string' ? answer.deployment_uuid : undefined;
|
|
378
|
+
// No deployment to follow: health would be the old container's, so the caller rebuilds instead.
|
|
379
|
+
if (!deploymentUuid)
|
|
380
|
+
return false;
|
|
381
|
+
for (;;) {
|
|
382
|
+
const { status } = await coolify.getDeployment(deploymentUuid);
|
|
383
|
+
if (status === 'finished')
|
|
384
|
+
break;
|
|
385
|
+
if (status === 'failed' || status.startsWith('cancelled')) {
|
|
386
|
+
throw new Error(`platform:mail-relay: the restart (${deploymentUuid}) of ${appId} did not finish (status: ${status}); the relay keeps its previous routes. Apply again`);
|
|
387
|
+
}
|
|
388
|
+
if (now() >= deadline)
|
|
389
|
+
throw late('finish restarting');
|
|
390
|
+
await sleep(interval);
|
|
391
|
+
}
|
|
392
|
+
for (;;) {
|
|
393
|
+
const { status } = await coolify.getApplication(appId);
|
|
394
|
+
if (status?.includes('healthy') && !status.includes('unhealthy'))
|
|
395
|
+
return true;
|
|
396
|
+
if (now() >= deadline)
|
|
397
|
+
throw late('become healthy');
|
|
398
|
+
await sleep(interval);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { DeclaredOrg } from '../org.js';
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
|
+
/**
|
|
4
|
+
* Recorded when a company's Coolify resources are known to sit on its own network (`co-<slug>`): by the
|
|
5
|
+
* first run for a company that has none yet, and by the move step when it ends. The value is the network name.
|
|
6
|
+
*/
|
|
7
|
+
export declare const NETWORK_APPLIED_SCOPE_KEY = "network_applied";
|
|
8
|
+
/** A company with a Coolify project but no network marker was built on the shared network. */
|
|
9
|
+
export declare function networkMoveNeeded(recordedIds: Readonly<Record<string, string>>): boolean;
|
|
10
|
+
/** Whether the company holds a resource foundry adopted: it did not create it and never deletes it. */
|
|
11
|
+
export declare function hasAdoptedResources(recordedIds: Readonly<Record<string, string>>): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* The one step a company built on the shared network plans, instead of all its others. Coolify cannot
|
|
14
|
+
* move a resource between networks in production, so the move deletes the company's apps and databases and
|
|
15
|
+
* the next apply builds them again on `co-<slug>`. It destroys and migrates, so apply asks for the typed
|
|
16
|
+
* company name, and it needs a fresh dump of every database first (`requireFreshBackupsForMove`). A company
|
|
17
|
+
* that adopted resources plans no move: foundry does not delete what it did not create, so those stay on
|
|
18
|
+
* the shared network (`diff.ts` reports it).
|
|
19
|
+
*/
|
|
20
|
+
export declare function planNetworkMove(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>): DraftStep | null;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recorded when a company's Coolify resources are known to sit on its own network (`co-<slug>`): by the
|
|
3
|
+
* first run for a company that has none yet, and by the move step when it ends. The value is the network name.
|
|
4
|
+
*/
|
|
5
|
+
export const NETWORK_APPLIED_SCOPE_KEY = 'network_applied';
|
|
6
|
+
/** The company's Coolify project: where `ensureCoolifyProject` records it. */
|
|
7
|
+
const COOLIFY_PROJECT_SCOPE_KEY = 'coolify_project';
|
|
8
|
+
/** A company with a Coolify project but no network marker was built on the shared network. */
|
|
9
|
+
export function networkMoveNeeded(recordedIds) {
|
|
10
|
+
return Boolean(recordedIds[COOLIFY_PROJECT_SCOPE_KEY]) && !recordedIds[NETWORK_APPLIED_SCOPE_KEY];
|
|
11
|
+
}
|
|
12
|
+
/** Whether the company holds a resource foundry adopted: it did not create it and never deletes it. */
|
|
13
|
+
export function hasAdoptedResources(recordedIds) {
|
|
14
|
+
return Object.keys(recordedIds).some((key) => key.startsWith('adopted.'));
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The one step a company built on the shared network plans, instead of all its others. Coolify cannot
|
|
18
|
+
* move a resource between networks in production, so the move deletes the company's apps and databases and
|
|
19
|
+
* the next apply builds them again on `co-<slug>`. It destroys and migrates, so apply asks for the typed
|
|
20
|
+
* company name, and it needs a fresh dump of every database first (`requireFreshBackupsForMove`). A company
|
|
21
|
+
* that adopted resources plans no move: foundry does not delete what it did not create, so those stay on
|
|
22
|
+
* the shared network (`diff.ts` reports it).
|
|
23
|
+
*/
|
|
24
|
+
export function planNetworkMove(org, recordedIds) {
|
|
25
|
+
if (!networkMoveNeeded(recordedIds) || hasAdoptedResources(recordedIds))
|
|
26
|
+
return null;
|
|
27
|
+
const network = `co-${org.slug}`;
|
|
28
|
+
return {
|
|
29
|
+
action: {
|
|
30
|
+
system: 'coolify',
|
|
31
|
+
kind: 'network',
|
|
32
|
+
key: org.id,
|
|
33
|
+
desired: { network },
|
|
34
|
+
},
|
|
35
|
+
step: {
|
|
36
|
+
description: `move ${org.slug} onto its own Docker network ${network}: delete its apps and databases in Coolify after a fresh backup, then apply again to build them there (data is restored by hand)`,
|
|
37
|
+
destroys: true,
|
|
38
|
+
migrates: true,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -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
|
+
}>;
|