@wtfalch/foundry 0.32.0 → 0.34.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 (74) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +18 -1
  2. package/dist/bootstrap/bootstrap.js +167 -28
  3. package/dist/bootstrap/declare.d.ts +5 -1
  4. package/dist/bootstrap/declare.js +3 -1
  5. package/dist/bootstrap/index.d.ts +1 -1
  6. package/dist/bootstrap/versions.d.ts +4 -0
  7. package/dist/calls-sfu.d.ts +10 -0
  8. package/dist/calls-sfu.js +41 -0
  9. package/dist/index.d.ts +18 -8
  10. package/dist/index.js +8 -3
  11. package/dist/migrations/0051_org_services.sql +13 -0
  12. package/dist/migrations/0052_platform_mail_relay.sql +8 -0
  13. package/dist/org.d.ts +24 -2
  14. package/dist/org.js +42 -0
  15. package/dist/plan/boule.d.ts +24 -1
  16. package/dist/plan/boule.js +29 -1
  17. package/dist/plan/calls-matrix.d.ts +9 -0
  18. package/dist/plan/calls-matrix.js +17 -0
  19. package/dist/plan/cloudflared-connector.d.ts +7 -1
  20. package/dist/plan/cloudflared-connector.js +14 -1
  21. package/dist/plan/diff.d.ts +32 -1
  22. package/dist/plan/diff.js +383 -26
  23. package/dist/plan/executor.d.ts +177 -7
  24. package/dist/plan/executor.js +738 -51
  25. package/dist/plan/health-check-settings.d.ts +28 -0
  26. package/dist/plan/health-check-settings.js +36 -0
  27. package/dist/plan/health-gaps.d.ts +33 -0
  28. package/dist/plan/health-gaps.js +89 -0
  29. package/dist/plan/health-targets.js +1 -1
  30. package/dist/plan/held-key-refs.d.ts +10 -0
  31. package/dist/plan/held-key-refs.js +10 -0
  32. package/dist/plan/index.d.ts +16 -6
  33. package/dist/plan/index.js +8 -3
  34. package/dist/plan/live.d.ts +5 -2
  35. package/dist/plan/live.js +15 -2
  36. package/dist/plan/locked-hostnames.d.ts +5 -2
  37. package/dist/plan/locked-hostnames.js +9 -1
  38. package/dist/plan/login-host.d.ts +62 -2
  39. package/dist/plan/login-host.js +104 -1
  40. package/dist/plan/mail-relay.d.ts +120 -0
  41. package/dist/plan/mail-relay.js +323 -0
  42. package/dist/plan/org-names.d.ts +24 -0
  43. package/dist/plan/org-names.js +52 -0
  44. package/dist/plan/own-diff.d.ts +169 -0
  45. package/dist/plan/own-diff.js +432 -0
  46. package/dist/plan/own-dns.d.ts +6 -0
  47. package/dist/plan/own-dns.js +15 -0
  48. package/dist/plan/own-limits.d.ts +22 -0
  49. package/dist/plan/own-limits.js +101 -0
  50. package/dist/plan/own-services.d.ts +193 -0
  51. package/dist/plan/own-services.js +167 -0
  52. package/dist/plan/own-sfu.d.ts +69 -0
  53. package/dist/plan/own-sfu.js +255 -0
  54. package/dist/plan/plan-apply.d.ts +101 -7
  55. package/dist/plan/plan-apply.js +216 -12
  56. package/dist/plan/platform-diff.d.ts +61 -12
  57. package/dist/plan/platform-diff.js +140 -34
  58. package/dist/plan/platform-org.d.ts +36 -0
  59. package/dist/plan/platform-org.js +96 -2
  60. package/dist/plan/platform-providers.d.ts +407 -12
  61. package/dist/plan/platform-providers.js +947 -422
  62. package/dist/plan/sign-in-org.d.ts +99 -0
  63. package/dist/plan/sign-in-org.js +151 -0
  64. package/dist/plan/zitadel-login-policy.js +1 -1
  65. package/dist/plan/zitadel-system-api.d.ts +22 -1
  66. package/dist/plan/zitadel-system-api.js +49 -8
  67. package/dist/plan.d.ts +18 -0
  68. package/dist/platform.d.ts +8 -0
  69. package/dist/slug.js +1 -0
  70. package/dist/store/postgres.js +51 -2
  71. package/dist/store/schema.d.ts +51 -0
  72. package/dist/store/schema.js +5 -0
  73. package/package.json +1 -1
  74. package/versions.json +3 -1
@@ -0,0 +1,120 @@
1
+ import type { DeclaredPlatform } from '../platform.js';
2
+ import type { DeployHealthRuntime } from './deploy-health.js';
3
+ import type { DraftStep } from './diff.js';
4
+ import type { CoolifyPort } from './executor.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 declare const PLATFORM_MAIL_RELAY_KEY = "platform:mail-relay";
16
+ /** Fingerprint of the sorted routes and platform domains the last successful apply put live. */
17
+ export declare const PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY = "mail_relay_routes_applied";
18
+ /** The base image tag the last successful apply built the relay from. */
19
+ export declare const PLATFORM_MAIL_RELAY_BASE_SCOPE_KEY = "mail_relay_base_applied";
20
+ /** The org-applied-state key the company mail step records its server's SMTP address under (`<internal name>:25`). */
21
+ export declare const OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY = "mail_smtp_internal";
22
+ export declare const MAIL_RELAY_APP_NAME = "platform-mail-relay";
23
+ /** One recipient domain and the mail server that receives it: `target` is `<internal name>:25`. */
24
+ export interface MailRoute {
25
+ domain: string;
26
+ target: string;
27
+ }
28
+ /** Lower-cases and checks a recipient domain; `null` for anything that is not a plain DNS name. */
29
+ export declare function normalizeMailDomain(domain: string): string | null;
30
+ /** Lower-cases and checks `<host>:25`; `null` for whitespace, metacharacters, another port or no host. */
31
+ export declare function normalizeMailTarget(target: string): string | null;
32
+ /**
33
+ * The routes, checked and sorted by domain. `null` when any entry is invalid or
34
+ * one domain has two targets (a message could not know where to go).
35
+ */
36
+ export declare function normalizeMailRoutes(routes: readonly MailRoute[]): MailRoute[] | null;
37
+ /** The platform's own mail domains, checked, de-duplicated, sorted; `null` when any is invalid. */
38
+ export declare function normalizeMailDomains(domains: readonly string[]): string[] | null;
39
+ /**
40
+ * What the relay was last built to do, as one value. Order of the inputs does
41
+ * not matter; a changed target, a new route or a new platform domain does.
42
+ * Never empty, so a platform with no company route still records one.
43
+ */
44
+ export declare function mailRelayFingerprint(routes: readonly MailRoute[], platformDomains: readonly string[]): string;
45
+ /**
46
+ * The relay's container start script. It renders Postfix's config from env
47
+ * only and refuses to start on a value that is not a plain domain or host, so
48
+ * nothing from env reaches a config line unchecked. The relay accepts mail for
49
+ * the listed domains and nothing else (`reject_unauth_destination`), so it
50
+ * cannot be used to send to a third party.
51
+ *
52
+ * Env: `MAIL_RELAY_HOSTNAME`, `MAIL_RELAY_DEFAULT_TARGET` (the platform
53
+ * Stalwart, `<name>:25`), `MAIL_RELAY_ROUTES` (`domain=host:25`, comma
54
+ * separated), `MAIL_RELAY_PLATFORM_DOMAINS` (comma separated, delivered to the
55
+ * default target). `MAIL_RELAY_RENDER_ONLY=1` writes the files and stops, and
56
+ * `MAIL_RELAY_CONF_DIR` moves them; both are for tests.
57
+ */
58
+ export declare const MAIL_RELAY_ENTRYPOINT = "#!/bin/sh\nset -eu\nset -f\nconf=\"${MAIL_RELAY_CONF_DIR:-/etc/postfix}\"\nfail() { echo \"mail relay: $1\" >&2; exit 1; }\nok_name() {\n case \"$1\" in\n ''|*[!a-z0-9.-]*|.*|*.|*..*|-*|*.-*|*-.*) return 1 ;;\n esac\n return 0\n}\nok_domain() { ok_name \"$1\" && case \"$1\" in *.*) return 0 ;; esac; return 1; }\nok_target() {\n case \"$1\" in *:25) ;; *) return 1 ;; esac\n ok_name \"${1%:25}\"\n}\nok_target \"${MAIL_RELAY_DEFAULT_TARGET:-}\" || fail \"MAIL_RELAY_DEFAULT_TARGET is not <host>:25\"\nok_name \"${MAIL_RELAY_HOSTNAME:-}\" || fail \"MAIL_RELAY_HOSTNAME is not a host name\"\n: > \"$conf/relay_domains\"\n: > \"$conf/transport\"\nIFS=','\nfor entry in ${MAIL_RELAY_ROUTES:-}; do\n d=\"${entry%%=*}\"\n t=\"${entry#*=}\"\n ok_domain \"$d\" || fail \"a route domain is not a plain domain\"\n ok_target \"$t\" || fail \"a route target is not <host>:25\"\n printf '%s OK\\n' \"$d\" >> \"$conf/relay_domains\"\n printf '%s smtp:[%s]:25\\n' \"$d\" \"${t%:25}\" >> \"$conf/transport\"\ndone\nfor d in ${MAIL_RELAY_PLATFORM_DOMAINS:-}; do\n ok_domain \"$d\" || fail \"a platform domain is not a plain domain\"\n printf '%s OK\\n' \"$d\" >> \"$conf/relay_domains\"\n printf '%s smtp:[%s]:25\\n' \"$d\" \"${MAIL_RELAY_DEFAULT_TARGET%:25}\" >> \"$conf/transport\"\ndone\nunset IFS\ncat > \"$conf/main.cf\" <<CONF\nmyhostname = $MAIL_RELAY_HOSTNAME\nmyorigin = \\$myhostname\ninet_interfaces = all\ninet_protocols = ipv4\nmydestination =\nmynetworks = 127.0.0.0/8\nlocal_recipient_maps =\nalias_maps =\nalias_database =\nrelay_domains = texthash:$conf/relay_domains\ntransport_maps = texthash:$conf/transport\nrelay_recipient_maps =\nsmtpd_relay_restrictions = reject_unauth_destination\nsmtpd_recipient_restrictions = reject_unauth_destination\ndisable_vrfy_command = yes\nmaillog_file = /dev/stdout\nCONF\n[ \"${MAIL_RELAY_RENDER_ONLY:-}\" = \"1\" ] && exit 0\nexec postfix start-fg\n";
59
+ /** The inline Dockerfile Coolify builds. The start script travels base64-encoded so no quoting can alter it. */
60
+ export declare function mailRelayDockerfile(): string;
61
+ /** The env the step writes; the only input the container reads. */
62
+ export declare function mailRelayEnv(input: {
63
+ hostname: string;
64
+ defaultTarget: string;
65
+ routes: readonly MailRoute[];
66
+ platformDomains: readonly string[];
67
+ }): Array<{
68
+ key: string;
69
+ value: string;
70
+ literal: boolean;
71
+ }>;
72
+ export declare const MAIL_RELAY_BLOCKED: {
73
+ readonly mailNotEnabled: "the platform's mail relay is blocked, not planned: the platform's mail server is not enabled";
74
+ readonly mailNotReady: "the platform's mail relay is blocked, not planned: the platform's mail server is neither applied nor planned";
75
+ readonly executorMissing: "the platform's mail relay is blocked, not planned: the host built the platform executor without its mail dependencies";
76
+ readonly invalidRoutes: "the platform's mail relay is blocked, not planned: a mail route or platform mail domain is not valid (a domain must be a plain DNS name, a target must be <host>:25, one domain needs one target)";
77
+ };
78
+ /**
79
+ * Plans the relay: nothing unless `mail.relay` is true. Planned when it was
80
+ * never applied, when the fingerprint of its routes differs from the recorded
81
+ * one, or when the base image pin changed. Blocked (reported, not planned) while
82
+ * the platform mail server is not applied, the executor cannot run it, or an
83
+ * input is invalid.
84
+ */
85
+ export declare function planMailRelay(platform: DeclaredPlatform, recordedIds: Readonly<Record<string, string>>, input: {
86
+ routes: readonly MailRoute[];
87
+ platformDomains: readonly string[];
88
+ executorBlocked: string | undefined;
89
+ mailReady: boolean;
90
+ }): {
91
+ step: DraftStep | null;
92
+ blocked: string | null;
93
+ };
94
+ export interface MailRelayDeps {
95
+ coolify: Pick<CoolifyPort, 'findApplicationByName' | 'getApplication' | 'findOrCreateEnvironment' | 'createDockerfileApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deployAndWait' | 'raw'>;
96
+ runtime?: DeployHealthRuntime;
97
+ }
98
+ /**
99
+ * Makes the relay true: finds or creates the app, sets its internal name and
100
+ * host port 25, writes env, and deploys when it was just created, its env or
101
+ * base changed, or it is not running (decided from the live app). The old
102
+ * container is stopped first, because two containers cannot both bind port 25.
103
+ * The routes fingerprint is recorded last, so a run that fails halfway leaves
104
+ * the step planned and the next apply repairs it. No secret exists here.
105
+ */
106
+ export declare function provisionMailRelay(action: {
107
+ desired: Record<string, unknown>;
108
+ }, context: {
109
+ recordedIds(): Readonly<Record<string, string>>;
110
+ recordScopeId(key: string, id: string): void;
111
+ persist?(): Promise<void>;
112
+ }, deps: MailRelayDeps, input: {
113
+ projectUuid: string;
114
+ serverUuid: string;
115
+ hostname: string;
116
+ defaultTarget: string;
117
+ appScopeKey: string;
118
+ }): Promise<{
119
+ resourceId: string;
120
+ }>;
@@ -0,0 +1,323 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { VERSIONS } 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 and platform domains the last successful apply put live. */
17
+ export const PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY = 'mail_relay_routes_applied';
18
+ /** The base image tag the last successful apply built the relay from. */
19
+ export const PLATFORM_MAIL_RELAY_BASE_SCOPE_KEY = 'mail_relay_base_applied';
20
+ /** The org-applied-state key the company mail step records its server's SMTP address under (`<internal name>:25`). */
21
+ export const OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY = 'mail_smtp_internal';
22
+ /** `executor.ts`'s `isRunningOrStarting`, restated: importing it would make this module and the planner import each other. */
23
+ function isRunningOrStarting(status) {
24
+ if (!status)
25
+ return true;
26
+ return !status.startsWith('exited') && !status.startsWith('stopped');
27
+ }
28
+ export const MAIL_RELAY_APP_NAME = 'platform-mail-relay';
29
+ const LABEL = '[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?';
30
+ const DOMAIN_PATTERN = new RegExp(`^(${LABEL}\\.)+[a-z]{2,63}$`);
31
+ const HOST_PATTERN = new RegExp(`^${LABEL}(\\.${LABEL})*$`);
32
+ /** Lower-cases and checks a recipient domain; `null` for anything that is not a plain DNS name. */
33
+ export function normalizeMailDomain(domain) {
34
+ const lower = domain.toLowerCase();
35
+ return lower.length <= 253 && DOMAIN_PATTERN.test(lower) ? lower : null;
36
+ }
37
+ /** Lower-cases and checks `<host>:25`; `null` for whitespace, metacharacters, another port or no host. */
38
+ export function normalizeMailTarget(target) {
39
+ const lower = target.toLowerCase();
40
+ if (!lower.endsWith(':25'))
41
+ return null;
42
+ const host = lower.slice(0, -3);
43
+ return host.length <= 253 && HOST_PATTERN.test(host) ? lower : null;
44
+ }
45
+ /**
46
+ * The routes, checked and sorted by domain. `null` when any entry is invalid or
47
+ * one domain has two targets (a message could not know where to go).
48
+ */
49
+ export function normalizeMailRoutes(routes) {
50
+ const byDomain = new Map();
51
+ for (const route of routes) {
52
+ const domain = normalizeMailDomain(route.domain);
53
+ const target = normalizeMailTarget(route.target);
54
+ if (!domain || !target)
55
+ return null;
56
+ const existing = byDomain.get(domain);
57
+ if (existing !== undefined && existing !== target)
58
+ return null;
59
+ byDomain.set(domain, target);
60
+ }
61
+ return [...byDomain.entries()]
62
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
63
+ .map(([domain, target]) => ({ domain, target }));
64
+ }
65
+ /** The platform's own mail domains, checked, de-duplicated, sorted; `null` when any is invalid. */
66
+ export function normalizeMailDomains(domains) {
67
+ const out = new Set();
68
+ for (const domain of domains) {
69
+ const checked = normalizeMailDomain(domain);
70
+ if (!checked)
71
+ return null;
72
+ out.add(checked);
73
+ }
74
+ return [...out].sort();
75
+ }
76
+ /**
77
+ * What the relay was last built to do, as one value. Order of the inputs does
78
+ * not matter; a changed target, a new route or a new platform domain does.
79
+ * Never empty, so a platform with no company route still records one.
80
+ */
81
+ export function mailRelayFingerprint(routes, platformDomains) {
82
+ const sortedRoutes = normalizeMailRoutes(routes) ?? [];
83
+ const sortedDomains = normalizeMailDomains(platformDomains) ?? [];
84
+ return createHash('sha256')
85
+ .update(JSON.stringify([sortedRoutes.map((r) => [r.domain, r.target]), sortedDomains]))
86
+ .digest('hex');
87
+ }
88
+ /**
89
+ * The relay's container start script. It renders Postfix's config from env
90
+ * only and refuses to start on a value that is not a plain domain or host, so
91
+ * nothing from env reaches a config line unchecked. The relay accepts mail for
92
+ * the listed domains and nothing else (`reject_unauth_destination`), so it
93
+ * cannot be used to send to a third party.
94
+ *
95
+ * Env: `MAIL_RELAY_HOSTNAME`, `MAIL_RELAY_DEFAULT_TARGET` (the platform
96
+ * Stalwart, `<name>:25`), `MAIL_RELAY_ROUTES` (`domain=host:25`, comma
97
+ * separated), `MAIL_RELAY_PLATFORM_DOMAINS` (comma separated, delivered to the
98
+ * default target). `MAIL_RELAY_RENDER_ONLY=1` writes the files and stops, and
99
+ * `MAIL_RELAY_CONF_DIR` moves them; both are for tests.
100
+ */
101
+ export const MAIL_RELAY_ENTRYPOINT = `#!/bin/sh
102
+ set -eu
103
+ set -f
104
+ conf="\${MAIL_RELAY_CONF_DIR:-/etc/postfix}"
105
+ fail() { echo "mail relay: $1" >&2; exit 1; }
106
+ ok_name() {
107
+ case "$1" in
108
+ ''|*[!a-z0-9.-]*|.*|*.|*..*|-*|*.-*|*-.*) return 1 ;;
109
+ esac
110
+ return 0
111
+ }
112
+ ok_domain() { ok_name "$1" && case "$1" in *.*) return 0 ;; esac; return 1; }
113
+ ok_target() {
114
+ case "$1" in *:25) ;; *) return 1 ;; esac
115
+ ok_name "\${1%:25}"
116
+ }
117
+ ok_target "\${MAIL_RELAY_DEFAULT_TARGET:-}" || fail "MAIL_RELAY_DEFAULT_TARGET is not <host>:25"
118
+ ok_name "\${MAIL_RELAY_HOSTNAME:-}" || fail "MAIL_RELAY_HOSTNAME is not a host name"
119
+ : > "$conf/relay_domains"
120
+ : > "$conf/transport"
121
+ IFS=','
122
+ for entry in \${MAIL_RELAY_ROUTES:-}; do
123
+ d="\${entry%%=*}"
124
+ t="\${entry#*=}"
125
+ ok_domain "$d" || fail "a route domain is not a plain domain"
126
+ ok_target "$t" || fail "a route target is not <host>:25"
127
+ printf '%s OK\\n' "$d" >> "$conf/relay_domains"
128
+ printf '%s smtp:[%s]:25\\n' "$d" "\${t%:25}" >> "$conf/transport"
129
+ done
130
+ for d in \${MAIL_RELAY_PLATFORM_DOMAINS:-}; do
131
+ ok_domain "$d" || fail "a platform domain is not a plain domain"
132
+ printf '%s OK\\n' "$d" >> "$conf/relay_domains"
133
+ printf '%s smtp:[%s]:25\\n' "$d" "\${MAIL_RELAY_DEFAULT_TARGET%:25}" >> "$conf/transport"
134
+ done
135
+ unset IFS
136
+ cat > "$conf/main.cf" <<CONF
137
+ myhostname = $MAIL_RELAY_HOSTNAME
138
+ myorigin = \\$myhostname
139
+ inet_interfaces = all
140
+ inet_protocols = ipv4
141
+ mydestination =
142
+ mynetworks = 127.0.0.0/8
143
+ local_recipient_maps =
144
+ alias_maps =
145
+ alias_database =
146
+ relay_domains = texthash:$conf/relay_domains
147
+ transport_maps = texthash:$conf/transport
148
+ relay_recipient_maps =
149
+ smtpd_relay_restrictions = reject_unauth_destination
150
+ smtpd_recipient_restrictions = reject_unauth_destination
151
+ disable_vrfy_command = yes
152
+ maillog_file = /dev/stdout
153
+ CONF
154
+ [ "\${MAIL_RELAY_RENDER_ONLY:-}" = "1" ] && exit 0
155
+ exec postfix start-fg
156
+ `;
157
+ /** The inline Dockerfile Coolify builds. The start script travels base64-encoded so no quoting can alter it. */
158
+ export function mailRelayDockerfile() {
159
+ const script = Buffer.from(MAIL_RELAY_ENTRYPOINT, 'utf8').toString('base64');
160
+ return [
161
+ `FROM alpine:${VERSIONS.alpine}`,
162
+ `RUN apk add --no-cache postfix && echo ${script} | base64 -d > /entrypoint.sh && chmod +x /entrypoint.sh`,
163
+ 'EXPOSE 25',
164
+ 'ENTRYPOINT ["/entrypoint.sh"]',
165
+ '',
166
+ ].join('\n');
167
+ }
168
+ /** The env the step writes; the only input the container reads. */
169
+ export function mailRelayEnv(input) {
170
+ return [
171
+ { key: 'MAIL_RELAY_HOSTNAME', value: input.hostname },
172
+ { key: 'MAIL_RELAY_DEFAULT_TARGET', value: input.defaultTarget },
173
+ {
174
+ key: 'MAIL_RELAY_ROUTES',
175
+ value: input.routes.map((r) => `${r.domain}=${r.target}`).join(','),
176
+ },
177
+ { key: 'MAIL_RELAY_PLATFORM_DOMAINS', value: input.platformDomains.join(',') },
178
+ ].map((e) => ({ ...e, literal: true }));
179
+ }
180
+ export const MAIL_RELAY_BLOCKED = {
181
+ mailNotEnabled: "the platform's mail relay is blocked, not planned: the platform's mail server is not enabled",
182
+ mailNotReady: "the platform's mail relay is blocked, not planned: the platform's mail server is neither applied nor planned",
183
+ executorMissing: "the platform's mail relay is blocked, not planned: the host built the platform executor without its mail dependencies",
184
+ invalidRoutes: "the platform's mail relay is blocked, not planned: a mail route or platform mail domain is not valid (a domain must be a plain DNS name, a target must be <host>:25, one domain needs one target)",
185
+ };
186
+ /**
187
+ * Plans the relay: nothing unless `mail.relay` is true. Planned when it was
188
+ * never applied, when the fingerprint of its routes differs from the recorded
189
+ * one, or when the base image pin changed. Blocked (reported, not planned) while
190
+ * the platform mail server is not applied, the executor cannot run it, or an
191
+ * input is invalid.
192
+ */
193
+ export function planMailRelay(platform, recordedIds, input) {
194
+ if (platform.mail.relay !== true)
195
+ return { step: null, blocked: null };
196
+ if (!platform.mail.enabled)
197
+ return { step: null, blocked: MAIL_RELAY_BLOCKED.mailNotEnabled };
198
+ if (input.executorBlocked)
199
+ return { step: null, blocked: input.executorBlocked };
200
+ if (!input.mailReady)
201
+ return { step: null, blocked: MAIL_RELAY_BLOCKED.mailNotReady };
202
+ const routes = normalizeMailRoutes(input.routes);
203
+ const platformDomains = normalizeMailDomains(input.platformDomains);
204
+ if (!routes || !platformDomains)
205
+ return { step: null, blocked: MAIL_RELAY_BLOCKED.invalidRoutes };
206
+ const fingerprint = mailRelayFingerprint(routes, platformDomains);
207
+ const applied = Boolean(recordedIds[`coolify:app:${PLATFORM_MAIL_RELAY_KEY}`]);
208
+ if (applied &&
209
+ recordedIds[PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY] === fingerprint &&
210
+ recordedIds[PLATFORM_MAIL_RELAY_BASE_SCOPE_KEY] === VERSIONS.alpine) {
211
+ return { step: null, blocked: null };
212
+ }
213
+ return {
214
+ step: {
215
+ action: {
216
+ system: 'coolify',
217
+ kind: 'app',
218
+ key: PLATFORM_MAIL_RELAY_KEY,
219
+ desired: { routes, platformDomains, fingerprint },
220
+ },
221
+ step: {
222
+ description: applied
223
+ ? `redeploy the shared mail relay with ${routes.length} company route${routes.length === 1 ? '' : 's'}`
224
+ : `provision the shared mail relay on port 25 with ${routes.length} company route${routes.length === 1 ? '' : 's'}`,
225
+ destroys: false,
226
+ migrates: false,
227
+ },
228
+ },
229
+ blocked: null,
230
+ };
231
+ }
232
+ /**
233
+ * Makes the relay true: finds or creates the app, sets its internal name and
234
+ * host port 25, writes env, and deploys when it was just created, its env or
235
+ * base changed, or it is not running (decided from the live app). The old
236
+ * container is stopped first, because two containers cannot both bind port 25.
237
+ * The routes fingerprint is recorded last, so a run that fails halfway leaves
238
+ * the step planned and the next apply repairs it. No secret exists here.
239
+ */
240
+ export async function provisionMailRelay(action, context, deps, input) {
241
+ const routes = normalizeMailRoutes((action.desired.routes ?? []));
242
+ const platformDomains = normalizeMailDomains((action.desired.platformDomains ?? []));
243
+ const defaultTarget = normalizeMailTarget(input.defaultTarget);
244
+ if (!routes || !platformDomains || !defaultTarget) {
245
+ throw new Error('platform:mail-relay: a mail route, platform domain or default target is invalid');
246
+ }
247
+ const runtime = deps.runtime ?? {};
248
+ const sleep = runtime.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
249
+ const recorded = context.recordedIds();
250
+ const record = async (key, id) => {
251
+ context.recordScopeId(key, id);
252
+ await context.persist?.();
253
+ };
254
+ const { environment } = await deps.coolify.findOrCreateEnvironment(input.projectUuid, 'production');
255
+ const existing = await deps.coolify.findApplicationByName(MAIL_RELAY_APP_NAME, {
256
+ environmentIds: [environment.id],
257
+ });
258
+ let appId;
259
+ let running = false;
260
+ const justCreated = !existing;
261
+ if (existing) {
262
+ appId = existing.uuid;
263
+ running = isRunningOrStarting((await deps.coolify.getApplication(appId)).status);
264
+ }
265
+ else {
266
+ appId = (await deps.coolify.createDockerfileApplication({
267
+ project_uuid: input.projectUuid,
268
+ server_uuid: input.serverUuid,
269
+ environment_name: environment.name,
270
+ environment_uuid: environment.uuid,
271
+ dockerfile: mailRelayDockerfile(),
272
+ name: MAIL_RELAY_APP_NAME,
273
+ description: 'Postfix relay that owns host port 25 and routes by recipient domain',
274
+ instant_deploy: false,
275
+ })).uuid;
276
+ }
277
+ await record(input.appScopeKey, appId);
278
+ const baseChanged = !justCreated && recorded[PLATFORM_MAIL_RELAY_BASE_SCOPE_KEY] !== VERSIONS.alpine;
279
+ await deps.coolify.updateApplication(appId, {
280
+ custom_internal_name: MAIL_RELAY_APP_NAME,
281
+ ports_exposes: '25',
282
+ ports_mappings: '25:25',
283
+ // Postfix has no HTTP: a connect to port 25 on the loopback is the check.
284
+ ...commandHealthCheckSettings(tcpProbe('25')),
285
+ ...(baseChanged
286
+ ? { dockerfile: Buffer.from(mailRelayDockerfile(), 'utf8').toString('base64') }
287
+ : {}),
288
+ });
289
+ const envs = mailRelayEnv({
290
+ hostname: input.hostname,
291
+ defaultTarget,
292
+ routes,
293
+ platformDomains,
294
+ });
295
+ const live = new Map((await deps.coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => [e.key, e.value]));
296
+ const envsChanged = envs.some((e) => live.get(e.key) !== e.value);
297
+ await deps.coolify.bulkUpsertEnvs(appId, envs);
298
+ if (justCreated || !running || envsChanged || baseChanged) {
299
+ if (!justCreated && running) {
300
+ await deps.coolify.raw('POST', `/applications/${appId}/stop`);
301
+ await waitForStopped(deps.coolify, appId, runtime, sleep);
302
+ }
303
+ await waitForDeploymentFinished(deps.coolify, appId, runtime, 'platform:mail-relay', 'the relay keeps its previous routes');
304
+ }
305
+ await record(PLATFORM_MAIL_RELAY_BASE_SCOPE_KEY, VERSIONS.alpine);
306
+ await record(PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY, mailRelayFingerprint(routes, platformDomains));
307
+ return { resourceId: appId };
308
+ }
309
+ async function waitForStopped(coolify, appId, runtime, sleep) {
310
+ const now = runtime.now ?? Date.now;
311
+ const timeout = runtime.readyTimeoutMs ?? 300_000;
312
+ const interval = runtime.readyIntervalMs ?? 5_000;
313
+ const deadline = now() + timeout;
314
+ for (;;) {
315
+ const { status } = await coolify.getApplication(appId);
316
+ if (!status || !isRunningOrStarting(status))
317
+ return;
318
+ if (now() >= deadline) {
319
+ throw new Error(`platform:mail-relay: the relay app ${appId} did not stop within ${Math.round(timeout / 1000)}s; apply again`);
320
+ }
321
+ await sleep(interval);
322
+ }
323
+ }
@@ -0,0 +1,24 @@
1
+ import { type DeclaredOrg } from '../org.js';
2
+ /** Which side of an org a derived name belongs to: its own services or its hosting entries. */
3
+ export type OrgNameSource = 'own' | 'hosting';
4
+ /**
5
+ * Every container name, network alias, database, tunnel and bucket name that
6
+ * foundry derives for an org: its own-service names when it owns services,
7
+ * and its hosting-entry and database names.
8
+ */
9
+ export declare function orgDerivedNames(org: Pick<DeclaredOrg, 'slug' | 'services' | 'hosting'>): {
10
+ name: string;
11
+ source: OrgNameSource;
12
+ }[];
13
+ /** Every name the platform's shared services take. */
14
+ export declare function platformDerivedNames(): string[];
15
+ export interface NameClash {
16
+ name: string;
17
+ /** The slug of the other org, or `platform`. */
18
+ other: string;
19
+ /** Which side of `org` holds the clashing name. */
20
+ source: OrgNameSource;
21
+ }
22
+ /** 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[];
24
+ export declare function nameClashMessage(slug: string, clash: NameClash): string;
@@ -0,0 +1,52 @@
1
+ import { ownsServices } from '../org.js';
2
+ import { orgTarget, platformTarget } from './own-services.js';
3
+ import { appNameFor } from './platform-diff.js';
4
+ /**
5
+ * Every container name, network alias, database, tunnel and bucket name that
6
+ * foundry derives for an org: its own-service names when it owns services,
7
+ * and its hosting-entry and database names.
8
+ */
9
+ export function orgDerivedNames(org) {
10
+ const names = [];
11
+ if (ownsServices(org)) {
12
+ const target = orgTarget(org);
13
+ for (const name of [...Object.values(target.names), ...Object.values(target.internal)]) {
14
+ names.push({ name, source: 'own' });
15
+ }
16
+ }
17
+ for (const entry of org.hosting.entries) {
18
+ names.push({ name: appNameFor(org, entry), source: 'hosting' });
19
+ }
20
+ if (org.hosting.entries.some((entry) => entry.database)) {
21
+ names.push({ name: `${org.slug}-db`, source: 'hosting' });
22
+ }
23
+ return names;
24
+ }
25
+ /** Every name the platform's shared services take. */
26
+ export function platformDerivedNames() {
27
+ const target = platformTarget();
28
+ return [...Object.values(target.names), ...Object.values(target.internal)];
29
+ }
30
+ /** Every name `org` derives that another org or the platform also derives. */
31
+ export function findNameClashes(org, others) {
32
+ const mine = orgDerivedNames(org);
33
+ const platform = new Set(platformDerivedNames());
34
+ const clashes = [];
35
+ for (const { name, source } of mine) {
36
+ if (platform.has(name))
37
+ clashes.push({ name, other: 'platform', source });
38
+ }
39
+ for (const other of others) {
40
+ if (other.slug === org.slug)
41
+ continue;
42
+ const theirs = new Set(orgDerivedNames(other).map((n) => n.name));
43
+ for (const { name, source } of mine) {
44
+ if (theirs.has(name))
45
+ clashes.push({ name, other: other.slug, source });
46
+ }
47
+ }
48
+ return clashes;
49
+ }
50
+ export function nameClashMessage(slug, clash) {
51
+ return `name '${clash.name}' is taken by both '${slug}' and '${clash.other}'`;
52
+ }