@wtfalch/foundry 0.34.0 → 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.
Files changed (88) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +1 -1
  2. package/dist/bootstrap/bootstrap.js +49 -11
  3. package/dist/bootstrap/declare.d.ts +12 -5
  4. package/dist/bootstrap/declare.js +33 -18
  5. package/dist/bootstrap/versions.d.ts +20 -0
  6. package/dist/bootstrap/versions.js +46 -0
  7. package/dist/calls-sfu.js +3 -3
  8. package/dist/index.d.ts +9 -4
  9. package/dist/index.js +5 -2
  10. package/dist/migrations/0053_heartbeats_provider.sql +9 -0
  11. package/dist/migrations/0053_org_version_pin.sql +6 -0
  12. package/dist/org.d.ts +60 -14
  13. package/dist/org.js +118 -28
  14. package/dist/plan/backup-gate.d.ts +93 -0
  15. package/dist/plan/backup-gate.js +261 -0
  16. package/dist/plan/broker-entries.d.ts +9 -1
  17. package/dist/plan/broker-entries.js +20 -3
  18. package/dist/plan/build-record.d.ts +56 -0
  19. package/dist/plan/build-record.js +109 -0
  20. package/dist/plan/calls-matrix.js +3 -3
  21. package/dist/plan/cloudflared-connector.js +2 -2
  22. package/dist/plan/company-backups.d.ts +31 -0
  23. package/dist/plan/company-backups.js +101 -0
  24. package/dist/plan/deploy-health.d.ts +12 -2
  25. package/dist/plan/deploy-health.js +3 -1
  26. package/dist/plan/destinations.d.ts +73 -0
  27. package/dist/plan/destinations.js +217 -0
  28. package/dist/plan/diff.d.ts +17 -8
  29. package/dist/plan/diff.js +131 -131
  30. package/dist/plan/edge-access.d.ts +15 -0
  31. package/dist/plan/edge-access.js +21 -2
  32. package/dist/plan/executor.d.ts +166 -7
  33. package/dist/plan/executor.js +547 -106
  34. package/dist/plan/health-targets.d.ts +1 -3
  35. package/dist/plan/health-targets.js +36 -36
  36. package/dist/plan/held-key-refs.d.ts +5 -0
  37. package/dist/plan/held-key-refs.js +5 -0
  38. package/dist/plan/hostnames.d.ts +10 -0
  39. package/dist/plan/hostnames.js +14 -0
  40. package/dist/plan/index.d.ts +5 -0
  41. package/dist/plan/index.js +3 -0
  42. package/dist/plan/live.d.ts +6 -0
  43. package/dist/plan/live.js +3 -2
  44. package/dist/plan/locked-hostnames.d.ts +2 -1
  45. package/dist/plan/locked-hostnames.js +16 -3
  46. package/dist/plan/login-host.d.ts +9 -20
  47. package/dist/plan/login-host.js +10 -25
  48. package/dist/plan/mail-relay.d.ts +48 -29
  49. package/dist/plan/mail-relay.js +145 -68
  50. package/dist/plan/network-move.d.ts +20 -0
  51. package/dist/plan/network-move.js +41 -0
  52. package/dist/plan/oikos-ingress.d.ts +111 -0
  53. package/dist/plan/oikos-ingress.js +231 -0
  54. package/dist/plan/org-names.d.ts +8 -2
  55. package/dist/plan/org-names.js +57 -1
  56. package/dist/plan/own-diff.d.ts +9 -8
  57. package/dist/plan/own-diff.js +42 -34
  58. package/dist/plan/own-hostnames.d.ts +27 -0
  59. package/dist/plan/own-hostnames.js +24 -0
  60. package/dist/plan/own-limits.d.ts +198 -7
  61. package/dist/plan/own-limits.js +163 -32
  62. package/dist/plan/own-services.js +3 -1
  63. package/dist/plan/own-sfu.js +3 -2
  64. package/dist/plan/plan-apply.d.ts +47 -7
  65. package/dist/plan/plan-apply.js +184 -38
  66. package/dist/plan/platform-diff.d.ts +133 -17
  67. package/dist/plan/platform-diff.js +231 -68
  68. package/dist/plan/platform-org.d.ts +79 -10
  69. package/dist/plan/platform-org.js +93 -13
  70. package/dist/plan/platform-providers.d.ts +118 -73
  71. package/dist/plan/platform-providers.js +770 -231
  72. package/dist/plan/proxy-labels.d.ts +57 -11
  73. package/dist/plan/proxy-labels.js +94 -14
  74. package/dist/plan/run-options.d.ts +19 -0
  75. package/dist/plan/run-options.js +46 -0
  76. package/dist/plan/zitadel-login-policy.d.ts +1 -1
  77. package/dist/plan/zitadel-login-policy.js +76 -8
  78. package/dist/platform.d.ts +32 -0
  79. package/dist/platform.js +16 -0
  80. package/dist/provision.d.ts +2 -0
  81. package/dist/provision.js +3 -1
  82. package/dist/slug.d.ts +3 -1
  83. package/dist/slug.js +25 -3
  84. package/dist/store/postgres.js +43 -1
  85. package/dist/store/schema.d.ts +36 -0
  86. package/dist/store/schema.js +4 -0
  87. package/package.json +3 -3
  88. package/versions.digests.json +11 -0
@@ -13,35 +13,52 @@ import type { CoolifyPort } from './executor.js';
13
13
  * Everything it knows reaches the container as env the step writes.
14
14
  */
15
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. */
16
+ /** Fingerprint of the sorted routes the last successful apply put live. */
17
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`). */
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 declare 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 declare const MAIL_RELAY_SPOOL_DIR = "/var/spool/postfix";
26
+ export declare 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
+ */
21
33
  export declare 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 declare 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 declare const OWN_MAIL_SMTP_PORT_BASE = 25000;
38
+ export declare const OWN_MAIL_SMTP_PORT_MAX = 25999;
39
+ /** Whether `port` is one of the ports a company's mail server may publish. */
40
+ export declare function isOwnMailSmtpPort(port: unknown): port is number;
22
41
  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`. */
42
+ /** One recipient domain and the mail server that receives it: `target` is `<host>:<port>`. */
24
43
  export interface MailRoute {
25
44
  domain: string;
26
45
  target: string;
27
46
  }
28
47
  /** Lower-cases and checks a recipient domain; `null` for anything that is not a plain DNS name. */
29
48
  export declare function normalizeMailDomain(domain: string): string | null;
30
- /** Lower-cases and checks `<host>:25`; `null` for whitespace, metacharacters, another port or no host. */
49
+ /** Lower-cases and checks `<host>:<port>`; `null` for whitespace, metacharacters, a port outside 1..65535 or no host. */
31
50
  export declare function normalizeMailTarget(target: string): string | null;
32
51
  /**
33
52
  * The routes, checked and sorted by domain. `null` when any entry is invalid or
34
53
  * one domain has two targets (a message could not know where to go).
35
54
  */
36
55
  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
56
  /**
40
57
  * 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.
58
+ * not matter; a changed target or a new route does.
42
59
  * Never empty, so a platform with no company route still records one.
43
60
  */
44
- export declare function mailRelayFingerprint(routes: readonly MailRoute[], platformDomains: readonly string[]): string;
61
+ export declare function mailRelayFingerprint(routes: readonly MailRoute[]): string;
45
62
  /**
46
63
  * The relay's container start script. It renders Postfix's config from env
47
64
  * only and refuses to start on a value that is not a plain domain or host, so
@@ -49,31 +66,30 @@ export declare function mailRelayFingerprint(routes: readonly MailRoute[], platf
49
66
  * the listed domains and nothing else (`reject_unauth_destination`), so it
50
67
  * cannot be used to send to a third party.
51
68
  *
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
69
+ * Mail for a domain no company routes is refused: there is no platform mail
70
+ * server to fall back on, so no default target exists.
71
+ *
72
+ * Env: `MAIL_RELAY_HOSTNAME`, `MAIL_RELAY_ROUTES` (`domain=host:port`, comma
73
+ * separated). `MAIL_RELAY_RENDER_ONLY=1` writes the files and stops, and
56
74
  * `MAIL_RELAY_CONF_DIR` moves them; both are for tests.
57
75
  */
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";
76
+ 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 h=\"${1%:*}\"\n p=\"${1##*:}\"\n [ \"$h\" != \"$1\" ] || return 1\n case \"$p\" in ''|0*|*[!0-9]*) return 1 ;; esac\n [ \"${#p}\" -le 5 ] || return 1\n [ \"$p\" -le 65535 ] || return 1\n ok_name \"$h\"\n}\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>:<port>\"\n printf '%s OK\\n' \"$d\" >> \"$conf/relay_domains\"\n printf '%s smtp:[%s]:%s\\n' \"$d\" \"${t%:*}\" \"${t##*:}\" >> \"$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\nparent_domain_matches_subdomains =\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
77
  /** The inline Dockerfile Coolify builds. The start script travels base64-encoded so no quoting can alter it. */
60
78
  export declare function mailRelayDockerfile(): string;
79
+ /** What a rebuild depends on: the Dockerfile and the spool mount. Routes, domains and hostname are not in it. */
80
+ export declare function mailRelayBuildFingerprint(): string;
61
81
  /** The env the step writes; the only input the container reads. */
62
82
  export declare function mailRelayEnv(input: {
63
83
  hostname: string;
64
- defaultTarget: string;
65
84
  routes: readonly MailRoute[];
66
- platformDomains: readonly string[];
67
85
  }): Array<{
68
86
  key: string;
69
87
  value: string;
70
88
  literal: boolean;
71
89
  }>;
72
90
  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
91
  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)";
92
+ readonly 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)";
77
93
  };
78
94
  /**
79
95
  * Plans the relay: nothing unless `mail.relay` is true. Planned when it was
@@ -84,24 +100,28 @@ export declare const MAIL_RELAY_BLOCKED: {
84
100
  */
85
101
  export declare function planMailRelay(platform: DeclaredPlatform, recordedIds: Readonly<Record<string, string>>, input: {
86
102
  routes: readonly MailRoute[];
87
- platformDomains: readonly string[];
88
103
  executorBlocked: string | undefined;
89
- mailReady: boolean;
90
104
  }): {
91
105
  step: DraftStep | null;
92
106
  blocked: string | null;
93
107
  };
94
108
  export interface MailRelayDeps {
95
- coolify: Pick<CoolifyPort, 'findApplicationByName' | 'getApplication' | 'findOrCreateEnvironment' | 'createDockerfileApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deployAndWait' | 'raw'>;
109
+ coolify: Pick<CoolifyPort, 'findApplicationByName' | 'getApplication' | 'findOrCreateEnvironment' | 'createDockerfileApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'listStorages' | 'createPersistentStorage' | 'restart' | 'getDeployment' | 'deployAndWait' | 'raw'>;
96
110
  runtime?: DeployHealthRuntime;
97
111
  }
98
112
  /**
99
113
  * 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.
114
+ * host port 25, mounts the queue volume once, writes env, and then does the
115
+ * least that makes it true (decided from the live app):
116
+ * - a rebuild (stop, deploy) when the app was just created, the Dockerfile or
117
+ * volume changed, or the volume was just added. The old container is stopped
118
+ * first, because two containers cannot both bind port 25;
119
+ * - a deploy when the container is not running;
120
+ * - a restart of the same container when only the routes, domains or hostname
121
+ * changed. Postfix reads its routes at start, nothing else is needed.
122
+ * The two fingerprints are recorded last, so a run that fails halfway (env
123
+ * written, restart failed) leaves the step planned and the next apply
124
+ * finishes it. No secret exists here.
105
125
  */
106
126
  export declare function provisionMailRelay(action: {
107
127
  desired: Record<string, unknown>;
@@ -113,7 +133,6 @@ export declare function provisionMailRelay(action: {
113
133
  projectUuid: string;
114
134
  serverUuid: string;
115
135
  hostname: string;
116
- defaultTarget: string;
117
136
  appScopeKey: string;
118
137
  }): Promise<{
119
138
  resourceId: string;
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { VERSIONS } from '../bootstrap/versions.js';
2
+ import { pinnedRef } from '../bootstrap/versions.js';
3
3
  import { waitForDeploymentFinished } from './deploy-health.js';
4
4
  import { commandHealthCheckSettings, tcpProbe } from './health-check-settings.js';
5
5
  /**
@@ -13,12 +13,36 @@ import { commandHealthCheckSettings, tcpProbe } from './health-check-settings.js
13
13
  * Everything it knows reaches the container as env the step writes.
14
14
  */
15
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. */
16
+ /** Fingerprint of the sorted routes the last successful apply put live. */
17
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`). */
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
+ */
21
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
+ }
22
46
  /** `executor.ts`'s `isRunningOrStarting`, restated: importing it would make this module and the planner import each other. */
23
47
  function isRunningOrStarting(status) {
24
48
  if (!status)
@@ -34,12 +58,16 @@ export function normalizeMailDomain(domain) {
34
58
  const lower = domain.toLowerCase();
35
59
  return lower.length <= 253 && DOMAIN_PATTERN.test(lower) ? lower : null;
36
60
  }
37
- /** Lower-cases and checks `<host>:25`; `null` for whitespace, metacharacters, another port or no host. */
61
+ /** Lower-cases and checks `<host>:<port>`; `null` for whitespace, metacharacters, a port outside 1..65535 or no host. */
38
62
  export function normalizeMailTarget(target) {
39
63
  const lower = target.toLowerCase();
40
- if (!lower.endsWith(':25'))
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)
41
70
  return null;
42
- const host = lower.slice(0, -3);
43
71
  return host.length <= 253 && HOST_PATTERN.test(host) ? lower : null;
44
72
  }
45
73
  /**
@@ -62,27 +90,15 @@ export function normalizeMailRoutes(routes) {
62
90
  .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
63
91
  .map(([domain, target]) => ({ domain, target }));
64
92
  }
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
93
  /**
77
94
  * 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.
95
+ * not matter; a changed target or a new route does.
79
96
  * Never empty, so a platform with no company route still records one.
80
97
  */
81
- export function mailRelayFingerprint(routes, platformDomains) {
98
+ export function mailRelayFingerprint(routes) {
82
99
  const sortedRoutes = normalizeMailRoutes(routes) ?? [];
83
- const sortedDomains = normalizeMailDomains(platformDomains) ?? [];
84
100
  return createHash('sha256')
85
- .update(JSON.stringify([sortedRoutes.map((r) => [r.domain, r.target]), sortedDomains]))
101
+ .update(JSON.stringify(sortedRoutes.map((r) => [r.domain, r.target])))
86
102
  .digest('hex');
87
103
  }
88
104
  /**
@@ -92,10 +108,11 @@ export function mailRelayFingerprint(routes, platformDomains) {
92
108
  * the listed domains and nothing else (`reject_unauth_destination`), so it
93
109
  * cannot be used to send to a third party.
94
110
  *
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
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
99
116
  * `MAIL_RELAY_CONF_DIR` moves them; both are for tests.
100
117
  */
101
118
  export const MAIL_RELAY_ENTRYPOINT = `#!/bin/sh
@@ -111,10 +128,14 @@ ok_name() {
111
128
  }
112
129
  ok_domain() { ok_name "$1" && case "$1" in *.*) return 0 ;; esac; return 1; }
113
130
  ok_target() {
114
- case "$1" in *:25) ;; *) return 1 ;; esac
115
- ok_name "\${1%:25}"
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"
116
138
  }
117
- ok_target "\${MAIL_RELAY_DEFAULT_TARGET:-}" || fail "MAIL_RELAY_DEFAULT_TARGET is not <host>:25"
118
139
  ok_name "\${MAIL_RELAY_HOSTNAME:-}" || fail "MAIL_RELAY_HOSTNAME is not a host name"
119
140
  : > "$conf/relay_domains"
120
141
  : > "$conf/transport"
@@ -123,14 +144,9 @@ for entry in \${MAIL_RELAY_ROUTES:-}; do
123
144
  d="\${entry%%=*}"
124
145
  t="\${entry#*=}"
125
146
  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"
147
+ ok_target "$t" || fail "a route target is not <host>:<port>"
132
148
  printf '%s OK\\n' "$d" >> "$conf/relay_domains"
133
- printf '%s smtp:[%s]:25\\n' "$d" "\${MAIL_RELAY_DEFAULT_TARGET%:25}" >> "$conf/transport"
149
+ printf '%s smtp:[%s]:%s\\n' "$d" "\${t%:*}" "\${t##*:}" >> "$conf/transport"
134
150
  done
135
151
  unset IFS
136
152
  cat > "$conf/main.cf" <<CONF
@@ -144,6 +160,7 @@ local_recipient_maps =
144
160
  alias_maps =
145
161
  alias_database =
146
162
  relay_domains = texthash:$conf/relay_domains
163
+ parent_domain_matches_subdomains =
147
164
  transport_maps = texthash:$conf/transport
148
165
  relay_recipient_maps =
149
166
  smtpd_relay_restrictions = reject_unauth_destination
@@ -158,30 +175,32 @@ exec postfix start-fg
158
175
  export function mailRelayDockerfile() {
159
176
  const script = Buffer.from(MAIL_RELAY_ENTRYPOINT, 'utf8').toString('base64');
160
177
  return [
161
- `FROM alpine:${VERSIONS.alpine}`,
178
+ `FROM alpine:${pinnedRef('alpine')}`,
162
179
  `RUN apk add --no-cache postfix && echo ${script} | base64 -d > /entrypoint.sh && chmod +x /entrypoint.sh`,
163
180
  'EXPOSE 25',
164
181
  'ENTRYPOINT ["/entrypoint.sh"]',
165
182
  '',
166
183
  ].join('\n');
167
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
+ }
168
191
  /** The env the step writes; the only input the container reads. */
169
192
  export function mailRelayEnv(input) {
170
193
  return [
171
194
  { key: 'MAIL_RELAY_HOSTNAME', value: input.hostname },
172
- { key: 'MAIL_RELAY_DEFAULT_TARGET', value: input.defaultTarget },
173
195
  {
174
196
  key: 'MAIL_RELAY_ROUTES',
175
197
  value: input.routes.map((r) => `${r.domain}=${r.target}`).join(','),
176
198
  },
177
- { key: 'MAIL_RELAY_PLATFORM_DOMAINS', value: input.platformDomains.join(',') },
178
199
  ].map((e) => ({ ...e, literal: true }));
179
200
  }
180
201
  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
202
  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)",
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)",
185
204
  };
186
205
  /**
187
206
  * Plans the relay: nothing unless `mail.relay` is true. Planned when it was
@@ -193,21 +212,16 @@ export const MAIL_RELAY_BLOCKED = {
193
212
  export function planMailRelay(platform, recordedIds, input) {
194
213
  if (platform.mail.relay !== true)
195
214
  return { step: null, blocked: null };
196
- if (!platform.mail.enabled)
197
- return { step: null, blocked: MAIL_RELAY_BLOCKED.mailNotEnabled };
198
215
  if (input.executorBlocked)
199
216
  return { step: null, blocked: input.executorBlocked };
200
- if (!input.mailReady)
201
- return { step: null, blocked: MAIL_RELAY_BLOCKED.mailNotReady };
202
217
  const routes = normalizeMailRoutes(input.routes);
203
- const platformDomains = normalizeMailDomains(input.platformDomains);
204
- if (!routes || !platformDomains)
218
+ if (!routes)
205
219
  return { step: null, blocked: MAIL_RELAY_BLOCKED.invalidRoutes };
206
- const fingerprint = mailRelayFingerprint(routes, platformDomains);
220
+ const fingerprint = mailRelayFingerprint(routes);
207
221
  const applied = Boolean(recordedIds[`coolify:app:${PLATFORM_MAIL_RELAY_KEY}`]);
208
222
  if (applied &&
209
223
  recordedIds[PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY] === fingerprint &&
210
- recordedIds[PLATFORM_MAIL_RELAY_BASE_SCOPE_KEY] === VERSIONS.alpine) {
224
+ recordedIds[PLATFORM_MAIL_RELAY_BUILD_SCOPE_KEY] === mailRelayBuildFingerprint()) {
211
225
  return { step: null, blocked: null };
212
226
  }
213
227
  return {
@@ -216,7 +230,7 @@ export function planMailRelay(platform, recordedIds, input) {
216
230
  system: 'coolify',
217
231
  kind: 'app',
218
232
  key: PLATFORM_MAIL_RELAY_KEY,
219
- desired: { routes, platformDomains, fingerprint },
233
+ desired: { routes, fingerprint },
220
234
  },
221
235
  step: {
222
236
  description: applied
@@ -231,18 +245,22 @@ export function planMailRelay(platform, recordedIds, input) {
231
245
  }
232
246
  /**
233
247
  * 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.
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.
239
259
  */
240
260
  export async function provisionMailRelay(action, context, deps, input) {
241
261
  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');
262
+ if (!routes) {
263
+ throw new Error('platform:mail-relay: a mail route is invalid');
246
264
  }
247
265
  const runtime = deps.runtime ?? {};
248
266
  const sleep = runtime.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
@@ -275,7 +293,18 @@ export async function provisionMailRelay(action, context, deps, input) {
275
293
  })).uuid;
276
294
  }
277
295
  await record(input.appScopeKey, appId);
278
- const baseChanged = !justCreated && recorded[PLATFORM_MAIL_RELAY_BASE_SCOPE_KEY] !== VERSIONS.alpine;
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);
279
308
  await deps.coolify.updateApplication(appId, {
280
309
  custom_internal_name: MAIL_RELAY_APP_NAME,
281
310
  ports_exposes: '25',
@@ -288,22 +317,32 @@ export async function provisionMailRelay(action, context, deps, input) {
288
317
  });
289
318
  const envs = mailRelayEnv({
290
319
  hostname: input.hostname,
291
- defaultTarget,
292
320
  routes,
293
- platformDomains,
294
321
  });
295
322
  const live = new Map((await deps.coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => [e.key, e.value]));
296
323
  const envsChanged = envs.some((e) => live.get(e.key) !== e.value);
297
324
  await deps.coolify.bulkUpsertEnvs(appId, envs);
298
- if (justCreated || !running || envsChanged || baseChanged) {
299
- if (!justCreated && running) {
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) {
300
330
  await deps.coolify.raw('POST', `/applications/${appId}/stop`);
301
331
  await waitForStopped(deps.coolify, appId, runtime, sleep);
302
332
  }
303
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);
304
337
  }
305
- await record(PLATFORM_MAIL_RELAY_BASE_SCOPE_KEY, VERSIONS.alpine);
306
- await record(PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY, mailRelayFingerprint(routes, platformDomains));
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);
307
346
  return { resourceId: appId };
308
347
  }
309
348
  async function waitForStopped(coolify, appId, runtime, sleep) {
@@ -321,3 +360,41 @@ async function waitForStopped(coolify, appId, runtime, sleep) {
321
360
  await sleep(interval);
322
361
  }
323
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
+ }