openship 0.6.0 → 0.6.5

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 (63) hide show
  1. package/README.md +7 -2
  2. package/dist/{bare-FSUAM5V5.js → bare-XQIPDO4R.js} +10 -9
  3. package/dist/{chunk-PSZSKEPA.js → chunk-3ZAGC5NT.js} +64 -17
  4. package/dist/{chunk-6ODUBKUB.js → chunk-5OUWIPOB.js} +69 -7
  5. package/dist/chunk-6HOA5JT7.js +124 -0
  6. package/dist/{chunk-TWJCLD4W.js → chunk-6JELW75A.js} +4 -4
  7. package/dist/{chunk-GUQ3QCJ7.js → chunk-7334NSJL.js} +399 -369
  8. package/dist/chunk-7S4USQYF.js +1753 -0
  9. package/dist/{chunk-3LDWWY7U.js → chunk-A7EWM2KA.js} +41 -7
  10. package/dist/chunk-ELGRMZOW.js +25 -0
  11. package/dist/chunk-EZPPTWJQ.js +292 -0
  12. package/dist/{chunk-IXM5245F.js → chunk-GRTXMUHA.js} +553 -538
  13. package/dist/chunk-HPXP6LXZ.js +428 -0
  14. package/dist/{chunk-OTLSJYAC.js → chunk-LEYSL46B.js} +2135 -85
  15. package/dist/{chunk-EMXI4UCU.js → chunk-LFCSC4IU.js} +29 -10
  16. package/dist/{chunk-JIMUYEKN.js → chunk-MSZUCNPV.js} +1 -1
  17. package/dist/{chunk-OGY63DGD.js → chunk-PZRRL5PC.js} +71 -28
  18. package/dist/{chunk-VE2YOK4I.js → chunk-QLX7NBEA.js} +1218 -588
  19. package/dist/chunk-RFWJE7ZN.js +68 -0
  20. package/dist/{chunk-VDDMADOF.js → chunk-TM2IQPYU.js} +16 -2
  21. package/dist/{chunk-3FYIXVX5.js → chunk-VDESJSG6.js} +451 -106
  22. package/dist/{chunk-B3CE2OMR.js → chunk-VHMOQL5Y.js} +276 -50
  23. package/dist/{chunk-QEH4QUBX.js → chunk-VLQ3UIVO.js} +393 -53
  24. package/dist/{chunk-VZ2ZHPS2.js → chunk-WE7BXW6L.js} +3 -9
  25. package/dist/chunk-WFRXYWK2.js +444 -0
  26. package/dist/chunk-XRNRT37W.js +316 -0
  27. package/dist/{chunk-EP2TNALB.js → chunk-XVOBL7BC.js} +31 -8
  28. package/dist/cloud-3DODOWTR.js +36 -0
  29. package/dist/{detect-ALF4GXRX.js → detect-P4CIQBRH.js} +3 -3
  30. package/dist/{docker-QECQJ5P4.js → docker-3K2NEBD6.js} +15 -8
  31. package/dist/{docker-edge-executor-62N64MGS.js → docker-edge-executor-YZZ6P7IC.js} +5 -5
  32. package/dist/{edge-import-CQWHN2Q3.js → edge-import-MCJNVTZ2.js} +26 -23
  33. package/dist/edge-preflight-ES6FH4JK.js +42 -0
  34. package/dist/{ensure-container-edge-MJVXJQG5.js → ensure-container-edge-YPITNH45.js} +9 -8
  35. package/dist/{executor-JELARVKC.js → executor-DZGX55K2.js} +12 -5
  36. package/dist/host-channel-preflight-MUXE3KZU.js +49 -0
  37. package/dist/index.js +3668 -4063
  38. package/dist/{nginx-AXGNWRKZ.js → nginx-NSAHWGJV.js} +11 -5
  39. package/dist/node-bootstrap.js +241 -0
  40. package/dist/node-entry.js +52 -1
  41. package/dist/{openresty-lua-IE4P5YQX.js → openresty-lua-DR4D3EZ6.js} +6 -2
  42. package/dist/privilege-YTVI4SKF.js +19 -0
  43. package/dist/server/engine/samples/fail2ban/jail.d/sshd.local +4 -1
  44. package/dist/server/index.js +21501 -13137
  45. package/dist/server/lua/geo_country.lua +4 -3
  46. package/dist/server/lua/pipe_stream.lua +52 -39
  47. package/dist/server/lua/site_logger.lua +22 -24
  48. package/dist/server/migrations/0098_instance_product_mode.sql +12 -0
  49. package/dist/server/migrations/0099_mail_webmail_project.sql +24 -0
  50. package/dist/server/migrations/0100_server_ssh_private_key.sql +11 -0
  51. package/dist/server/migrations/0101_mail_resume_step.sql +12 -0
  52. package/dist/server/migrations/0102_project_workload_axes.sql +19 -0
  53. package/dist/server/migrations/0103_dns_credential.sql +41 -0
  54. package/dist/server/migrations/0104_instance_host_control.sql +15 -0
  55. package/dist/server/migrations/meta/_journal.json +49 -0
  56. package/dist/{setup-FA4PW4NU.js → setup-WUUZURHS.js} +10 -9
  57. package/dist/up-XEO3SR6Z.js +58 -0
  58. package/package.json +4 -2
  59. package/dist/chunk-IW2UKBVF.js +0 -144
  60. package/dist/chunk-ZCFVHA25.js +0 -89
  61. package/dist/cloud-E5M6VMCG.js +0 -31
  62. package/dist/server/lua/pipe_log.lua +0 -77
  63. /package/dist/{dist-es-JGBPABJI.js → dist-es-ZP5QDSJ3.js} +0 -0
@@ -908,6 +908,49 @@ function appVolumeTargets(volumes, appDir = APP_DIR) {
908
908
  return out;
909
909
  }
910
910
 
911
+ // ../../packages/core/src/compose-namespace.ts
912
+ var CONTAINER_REF = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
913
+ var SERVICE_REF = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
914
+ function parseComposeNamespace(raw, field) {
915
+ if (raw === void 0 || raw === null) return void 0;
916
+ if (typeof raw !== "string") {
917
+ return { ok: false, reason: `${field} must be a string, got ${typeof raw}.` };
918
+ }
919
+ const value = raw.trim();
920
+ if (!value) return void 0;
921
+ if (value === "host") {
922
+ return {
923
+ ok: false,
924
+ reason: `${field}: host is not supported. A host-namespace container bypasses the edge (its ports bind host interfaces directly) and can reach every loopback service on the machine, including Openship's own API. Share a sibling's namespace instead (${field}: service:<name>), or run this workload outside the stack.`
925
+ };
926
+ }
927
+ if (field === "network_mode") {
928
+ if (value === "none") return { ok: true, ref: { kind: "none" }, value };
929
+ if (value === "bridge" || value === "default") return void 0;
930
+ }
931
+ const serviceRef = value.startsWith("service:") ? value.slice("service:".length) : void 0;
932
+ if (serviceRef !== void 0) {
933
+ if (!SERVICE_REF.test(serviceRef)) {
934
+ return { ok: false, reason: `${field}: "${value}" is not a valid service reference.` };
935
+ }
936
+ return { ok: true, ref: { kind: "service", service: serviceRef }, value };
937
+ }
938
+ const containerRef = value.startsWith("container:") ? value.slice("container:".length) : void 0;
939
+ if (containerRef !== void 0) {
940
+ if (!CONTAINER_REF.test(containerRef)) {
941
+ return { ok: false, reason: `${field}: "${value}" is not a valid container reference.` };
942
+ }
943
+ return { ok: true, ref: { kind: "container", container: containerRef }, value };
944
+ }
945
+ return {
946
+ ok: false,
947
+ reason: `${field}: "${value}" is not supported. Openship accepts ` + (field === "network_mode" ? `"none", ` : "") + `"service:<name>" (a service in this stack) and "container:<id>".`
948
+ };
949
+ }
950
+ function ownsNetworkEndpoint(resolvedNetworkMode) {
951
+ return !resolvedNetworkMode;
952
+ }
953
+
911
954
  // ../../packages/core/src/constants.ts
912
955
  var DEFAULT_IMAGE_REGISTRY = "ghcr.io/oblien";
913
956
  var PLANS = {
@@ -1228,6 +1271,27 @@ function withTimeout(promise2, ms, message) {
1228
1271
  );
1229
1272
  });
1230
1273
  }
1274
+ function wrapText(text, width = 88) {
1275
+ const out = [];
1276
+ for (const paragraph of text.split("\n")) {
1277
+ const words = paragraph.split(/\s+/).filter(Boolean);
1278
+ if (words.length === 0) {
1279
+ out.push("");
1280
+ continue;
1281
+ }
1282
+ let line = "";
1283
+ for (const word of words) {
1284
+ if (line && line.length + 1 + word.length > width) {
1285
+ out.push(line);
1286
+ line = word;
1287
+ } else {
1288
+ line = line ? `${line} ${word}` : word;
1289
+ }
1290
+ }
1291
+ if (line) out.push(line);
1292
+ }
1293
+ return out;
1294
+ }
1231
1295
 
1232
1296
  // ../../packages/core/src/errors.ts
1233
1297
  var AppError = class extends Error {
@@ -1265,6 +1329,7 @@ var OPENSHIP_PRODUCTION_MODES = [
1265
1329
  "static",
1266
1330
  "standalone"
1267
1331
  ];
1332
+ var OPENSHIP_WORKLOADS = ["web", "worker", "static"];
1268
1333
  var OPENSHIP_DOMAIN_TYPES = ["free", "custom"];
1269
1334
  var OPENSHIP_RESTARTS = [
1270
1335
  "no",
@@ -1296,6 +1361,7 @@ var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
1296
1361
  "volumes",
1297
1362
  "runtime",
1298
1363
  "productionMode",
1364
+ "workload",
1299
1365
  "port",
1300
1366
  "env",
1301
1367
  "domains",
@@ -1642,6 +1708,7 @@ function parseOpenshipConfig(raw) {
1642
1708
  volumes: ctx.strArray(raw.volumes, "volumes"),
1643
1709
  runtime: ctx.enumOf(raw.runtime, "runtime", OPENSHIP_RUNTIMES),
1644
1710
  productionMode: ctx.enumOf(raw.productionMode, "productionMode", OPENSHIP_PRODUCTION_MODES),
1711
+ workload: ctx.enumOf(raw.workload, "workload", OPENSHIP_WORKLOADS),
1645
1712
  port: ctx.int(raw.port, "port", 1, 65535),
1646
1713
  env: parseEnv(ctx, raw.env, "env"),
1647
1714
  domains: parseDomains(ctx, raw.domains, "domains"),
@@ -2255,12 +2322,345 @@ function parseProxyValue(spec, raw) {
2255
2322
  return coerceProxyValue(spec, normalized.toLowerCase());
2256
2323
  }
2257
2324
 
2325
+ // ../../packages/core/src/answer.ts
2326
+ function answered(value) {
2327
+ return { supported: true, value };
2328
+ }
2329
+ function refused(reason) {
2330
+ return { supported: false, reason };
2331
+ }
2332
+
2333
+ // ../../packages/core/src/host-firewall.ts
2334
+ var ADDRESS = /^[0-9A-Fa-f.:]+(?:\/\d{1,3})?$/;
2335
+ function invalidScope(scope) {
2336
+ if (!Number.isInteger(scope.port) || scope.port < 1 || scope.port > 65535) {
2337
+ return `${JSON.stringify(scope.port)} is not a port number, so no firewall rule was built for it.`;
2338
+ }
2339
+ const bad = scope.cidrs.find((cidr) => !ADDRESS.test(cidr));
2340
+ if (bad !== void 0) {
2341
+ return `${JSON.stringify(bad)} is not an address or CIDR, so no firewall rule was built for it.`;
2342
+ }
2343
+ return null;
2344
+ }
2345
+ function ipFamily(cidr) {
2346
+ return cidr.includes(":") ? "ipv6" : "ipv4";
2347
+ }
2348
+ function nftSource(cidr) {
2349
+ return `${cidr.includes(":") ? "ip6" : "ip"} saddr ${cidr}`;
2350
+ }
2351
+ var ALLOW = {
2352
+ ufw: ({ cidrs, port, proto }) => cidrs.length === 0 ? [`ufw allow ${port}/${proto}`] : cidrs.map((c) => `ufw allow from ${c} to any port ${port} proto ${proto}`),
2353
+ firewalld: ({ cidrs, port, proto }) => [
2354
+ ...cidrs.length === 0 ? [`firewall-cmd --permanent --add-port=${port}/${proto}`] : cidrs.map(
2355
+ (c) => `firewall-cmd --permanent --add-rich-rule='rule family="${ipFamily(c)}" source address="${c}" port port="${port}" protocol="${proto}" accept'`
2356
+ ),
2357
+ "firewall-cmd --reload"
2358
+ ],
2359
+ // Adding a rule to an existing chain only. The detector reports `nftables` exactly when
2360
+ // `table inet filter` is present, so the chain is there; if it's named something else
2361
+ // (an iptables-nft translation layer, say) this fails loudly rather than half-applying.
2362
+ nftables: ({ cidrs, port, proto }) => cidrs.length === 0 ? [`nft add rule inet filter input ${proto} dport ${port} accept`] : cidrs.map(
2363
+ (c) => `nft add rule inet filter input ${nftSource(c)} ${proto} dport ${port} accept`
2364
+ ),
2365
+ iptables: ({ cidrs, port, proto }) => cidrs.length === 0 ? [`iptables -I INPUT -p ${proto} --dport ${port} -j ACCEPT`] : cidrs.map((c) => `iptables -I INPUT -p ${proto} -s ${c} --dport ${port} -j ACCEPT`)
2366
+ };
2367
+ var REVOKE = {
2368
+ ufw: ({ cidrs, port, proto }) => answered(
2369
+ cidrs.length === 0 ? [`ufw delete allow ${port}/${proto}`] : cidrs.map((c) => `ufw delete allow from ${c} to any port ${port} proto ${proto}`)
2370
+ ),
2371
+ firewalld: ({ cidrs, port, proto }) => answered([
2372
+ ...cidrs.length === 0 ? [`firewall-cmd --permanent --remove-port=${port}/${proto}`] : cidrs.map(
2373
+ (c) => `firewall-cmd --permanent --remove-rich-rule='rule family="${ipFamily(c)}" source address="${c}" port port="${port}" protocol="${proto}" accept'`
2374
+ ),
2375
+ "firewall-cmd --reload"
2376
+ ]),
2377
+ nftables: () => refused(
2378
+ "An nftables rule is deleted by handle, and the handle only exists in the live ruleset \u2014 there is no command that removes a rule by its contents. Read it with `nft -a list chain inet filter input`, then `nft delete rule inet filter input handle <n>`."
2379
+ ),
2380
+ iptables: ({ cidrs, port, proto }) => answered(
2381
+ cidrs.length === 0 ? [`iptables -D INPUT -p ${proto} --dport ${port} -j ACCEPT`] : cidrs.map((c) => `iptables -D INPUT -p ${proto} -s ${c} --dport ${port} -j ACCEPT`)
2382
+ )
2383
+ };
2384
+ function firewallManager(firewall) {
2385
+ switch (firewall) {
2386
+ case "ufw":
2387
+ case "firewalld":
2388
+ case "nftables":
2389
+ case "iptables":
2390
+ return answered(firewall);
2391
+ case "none":
2392
+ return refused(
2393
+ "No firewall is active on this host, so there is no rule to add \u2014 traffic reaches it already. Check a cloud security group / network ACL if it doesn't."
2394
+ );
2395
+ case "unknown":
2396
+ return refused(
2397
+ "Openship could not determine which firewall this host runs, so it will not guess at a rule \u2014 applying an iptables rule where firewalld is managing the chains looks like it worked and is gone after a reload."
2398
+ );
2399
+ }
2400
+ }
2401
+ var PERSISTS = {
2402
+ // Both keep their own on-disk config and reload it at boot.
2403
+ ufw: true,
2404
+ firewalld: true,
2405
+ // The rule lives in the running ruleset only; something else has to save it.
2406
+ nftables: false,
2407
+ iptables: false,
2408
+ // Nothing was added, so there is nothing to survive anything.
2409
+ none: false,
2410
+ unknown: false
2411
+ };
2412
+ function firewallPersists(firewall) {
2413
+ return PERSISTS[firewall];
2414
+ }
2415
+ function firewallAllowSteps(manager, scope) {
2416
+ const invalid = invalidScope(scope);
2417
+ return invalid ? refused(invalid) : answered(ALLOW[manager](scope));
2418
+ }
2419
+ function firewallRevokeSteps(manager, scope) {
2420
+ const invalid = invalidScope(scope);
2421
+ return invalid ? refused(invalid) : REVOKE[manager](scope);
2422
+ }
2423
+
2424
+ // ../../packages/core/src/host-channel.ts
2425
+ var HOST_CHANNEL_UNAFFECTED = "Ordinary deploys to this box still work \u2014 they go through the Docker socket.";
2426
+ var HOST_CHANNEL_BLOCKED = [
2427
+ "the host terminal, host system info and host port scans",
2428
+ "taking over :80/:443 from a proxy Openship didn't start",
2429
+ "installing or updating the mail engine",
2430
+ "deploying a catalog app that needs a generated config file on the host (Supabase's kong.yml, say)"
2431
+ ];
2432
+ var HOST_CHANNEL_SYMPTOM = `This box also reads "Offline" in the dashboard's server list while the channel is down.`;
2433
+ var HOST_CHANNEL_PROVISION_COMMAND = "openship up";
2434
+ var HOST_CHANNEL_RECHECK_COMMAND = "openship doctor";
2435
+ var HOST_CHANNEL_RECHECK = `Re-check it any time with \`${HOST_CHANNEL_RECHECK_COMMAND}\`.`;
2436
+ var HOST_CHANNEL_NOT_PROVISIONED = (
2437
+ // Two sentences rather than one clause chain, for the reason HOST_CHANNEL_OPT_OUT
2438
+ // gives: every surface wraps this to a terminal width, and both commands have to
2439
+ // survive the break. Starting the second sentence with its command keeps it whole.
2440
+ `Re-run \`${HOST_CHANNEL_PROVISION_COMMAND}\` to provision the host channel. \`${HOST_CHANNEL_RECHECK_COMMAND}\` reports whether it worked.`
2441
+ );
2442
+ var HOST_CHANNEL_UNPROVISIONED = "Openship is running in a container with no host channel \u2014 OPENSHIP_HOST_SSH_HOST is unset, so there is no address to reach the host at and nothing has been dialed.";
2443
+ var HOST_CHANNEL_OPT_OUT = (
2444
+ // The command sits early in the sentence on purpose: every surface wraps this to a
2445
+ // terminal width, and a line break through the middle of a command is a command an
2446
+ // operator can't copy.
2447
+ "Not a fix, a choice: `openship up --no-host-control` turns the channel off and takes its key back, for a box where the api container should never run host commands \u2014 the items above stay unavailable."
2448
+ );
2449
+ var SSHD_ENABLE_HINT_UNKNOWN = "sudo systemctl enable --now ssh # RHEL-family hosts name the unit 'sshd'";
2450
+ function explainHostChannelCause(cause, ctx) {
2451
+ const at = ctx.target;
2452
+ const suffix = ctx.detail ? ` (${ctx.detail})` : "";
2453
+ switch (cause) {
2454
+ case "timeout":
2455
+ return {
2456
+ headline: `${at} \u2014 no response, not even a refusal${suffix}`,
2457
+ body: "That is what a dropped packet looks like. This address is host-local, so the connection traverses the host's filter/INPUT chain, where a default-deny firewall applies. Published container ports are forwarded and never reach that chain, which is why the rest of the stack looks healthy.",
2458
+ firewallShaped: true
2459
+ };
2460
+ case "refused":
2461
+ return {
2462
+ headline: `${at} \u2014 connection refused${suffix}`,
2463
+ // The command sits on its own line (wrapText honours the break) so no terminal
2464
+ // width can fold it in half and hand the operator something unpasteable.
2465
+ body: `The packet arrived and nothing accepted it: either sshd isn't listening on an address the containers can reach, or something is rejecting rather than dropping it. A firewall rule alone won't fix a refusal. Check that ListenAddress in /etc/ssh/sshd_config isn't pinned to 127.0.0.1, then:
2466
+ ${ctx.sshdEnableHint ?? SSHD_ENABLE_HINT_UNKNOWN}`,
2467
+ firewallShaped: false
2468
+ };
2469
+ case "unresolved":
2470
+ return {
2471
+ headline: `"${ctx.host ?? at}" didn't resolve inside the Openship container${suffix}`,
2472
+ body: "The host alias needs `extra_hosts: host.docker.internal:host-gateway` and Docker 20.10+; under rootless Docker the host gateway isn't provided at all. Re-run `openship up` to reprovision the channel.",
2473
+ firewallShaped: false
2474
+ };
2475
+ case "no_route":
2476
+ return {
2477
+ headline: `${at} \u2014 no route from the Openship container${suffix}`,
2478
+ body: "The address the channel was provisioned with isn't reachable on this box's networks. Re-run `openship up` to reprovision it, or point OPENSHIP_HOST_SSH_HOST at an address the container can reach.",
2479
+ firewallShaped: false
2480
+ };
2481
+ case "error":
2482
+ return {
2483
+ headline: `${at} \u2014 ${ctx.detail ?? "the connection failed"}`,
2484
+ body: "",
2485
+ firewallShaped: false
2486
+ };
2487
+ }
2488
+ }
2489
+ function summarizeHostChannelCause(cause, ctx) {
2490
+ const at = ctx.target;
2491
+ switch (cause) {
2492
+ case "timeout":
2493
+ return `${at} dropped the connection`;
2494
+ case "refused":
2495
+ return `${at} refused \u2014 sshd isn't listening where containers can reach it`;
2496
+ case "unresolved":
2497
+ return `${ctx.host ?? at} didn't resolve in the container \u2014 needs Docker 20.10+ host-gateway`;
2498
+ case "no_route":
2499
+ return `no route to ${at} from the container`;
2500
+ case "error":
2501
+ return `${at} \u2014 ${ctx.detail ?? "the connection failed"}`;
2502
+ }
2503
+ }
2504
+ var DOCKER_DEFAULT_POOL_CIDR = "172.16.0.0/12";
2505
+ var PERSIST_IPTABLES = {
2506
+ debian: "persist it with `sudo apt-get install iptables-persistent`, which saves the current rules as it installs",
2507
+ rhel: "persist it with `sudo dnf install iptables-services && sudo service iptables save`",
2508
+ alpine: "persist it with `sudo rc-update add iptables && sudo /etc/init.d/iptables save`",
2509
+ suse: "persist it with whatever this distro restores iptables from at boot",
2510
+ arch: "persist it with whatever this distro restores iptables from at boot",
2511
+ unknown: "persist it with iptables-persistent (Debian/Ubuntu) or iptables-services (RHEL family)"
2512
+ };
2513
+ function note(text) {
2514
+ return `# (${text})`;
2515
+ }
2516
+ function pasteable(steps) {
2517
+ return steps.supported ? steps.value.map((step) => `sudo ${step}`).join("\n") : steps.reason.split("\n").map((line) => `# ${line}`).join("\n");
2518
+ }
2519
+ function hostChannelRuleScope(cidrs, port) {
2520
+ return { cidrs: cidrs.length > 0 ? cidrs : [DOCKER_DEFAULT_POOL_CIDR], port, proto: "tcp" };
2521
+ }
2522
+ function everyForm(render, managers) {
2523
+ return managers.flatMap((manager) => [`# ${manager}:`, render(manager)]).join("\n");
2524
+ }
2525
+ var OFFER_ORDER = ["ufw", "firewalld", "nftables", "iptables"];
2526
+ function hostFirewallRule(firewall, cidrs, port, family = "unknown") {
2527
+ const scope = hostChannelRuleScope(cidrs, port);
2528
+ const allow = (manager) => pasteable(firewallAllowSteps(manager, scope));
2529
+ const unpersisted = (manager, caveat) => `${allow(manager)}
2530
+ ${note(caveat)}`;
2531
+ const ruleFor = (manager) => {
2532
+ switch (manager) {
2533
+ case "ufw":
2534
+ case "firewalld":
2535
+ return allow(manager);
2536
+ case "nftables":
2537
+ return unpersisted(
2538
+ "nftables",
2539
+ "an `nft add rule` is gone at the next reboot \u2014 write it into /etc/nftables.conf (or your distro's ruleset file) to keep it"
2540
+ );
2541
+ case "iptables":
2542
+ return unpersisted(
2543
+ "iptables",
2544
+ `a raw iptables rule does not survive a reboot \u2014 ${PERSIST_IPTABLES[family]}`
2545
+ );
2546
+ }
2547
+ };
2548
+ switch (firewall) {
2549
+ case "ufw":
2550
+ case "firewalld":
2551
+ case "nftables":
2552
+ case "iptables":
2553
+ return ruleFor(firewall);
2554
+ // Two different observations, one rule: `iptables` means raw rules are already the
2555
+ // only thing enforcing here, `none` means nothing is. Either way iptables is what
2556
+ // this host speaks, and either way the rule is lost at reboot.
2557
+ case "none":
2558
+ return ruleFor("iptables");
2559
+ case "unknown":
2560
+ return everyForm(ruleFor, OFFER_ORDER);
2561
+ }
2562
+ }
2563
+ function hostFirewallRuleRemoval(firewall, cidrs, port) {
2564
+ const scope = hostChannelRuleScope(cidrs, port);
2565
+ const revoke = (manager) => pasteable(firewallRevokeSteps(manager, scope));
2566
+ switch (firewall) {
2567
+ case "ufw":
2568
+ case "firewalld":
2569
+ case "nftables":
2570
+ return revoke(firewall);
2571
+ case "iptables":
2572
+ case "none":
2573
+ return revoke("iptables");
2574
+ case "unknown":
2575
+ return everyForm(revoke, OFFER_ORDER);
2576
+ }
2577
+ }
2578
+
2579
+ // ../../packages/core/src/compose-spec.ts
2580
+ var asIs = (s) => s;
2581
+ function composeMountToSpec(mount, interpolate = asIs) {
2582
+ const source = mount.source ?? mount.name;
2583
+ const target = mount.target;
2584
+ const bindOpts = mount.bind;
2585
+ const volumeOpts = mount.volume;
2586
+ const selinux = typeof bindOpts?.selinux === "string" ? bindOpts.selinux : void 0;
2587
+ const mode = mount.read_only === true ? ":ro" : volumeOpts?.nocopy === true ? ":nocopy" : selinux === "z" || selinux === "Z" ? `:${selinux}` : "";
2588
+ if (source && target) return `${source}:${target}${mode}`;
2589
+ if (target) return String(target);
2590
+ return void 0;
2591
+ }
2592
+ function composeMountIssues(mount) {
2593
+ const issues = [];
2594
+ const target = typeof mount.target === "string" ? mount.target : "<unnamed>";
2595
+ const type = typeof mount.type === "string" ? mount.type : void 0;
2596
+ if (type === "tmpfs") {
2597
+ return [
2598
+ {
2599
+ field: "volumes[].type=tmpfs",
2600
+ reason: `the tmpfs mount at ${target} is not modeled. Openship mounts volumes and host paths only, so this would be created as a persistent volume on disk rather than in memory \u2014 remove it or make it a named volume deliberately.`,
2601
+ blocking: true
2602
+ }
2603
+ ];
2604
+ }
2605
+ if (type === "npipe" || type === "cluster" || type === "image") {
2606
+ return [
2607
+ {
2608
+ field: `volumes[].type=${type}`,
2609
+ reason: `the ${type} mount at ${target} is not modeled.`,
2610
+ blocking: true
2611
+ }
2612
+ ];
2613
+ }
2614
+ const volumeOpts = mount.volume;
2615
+ if (typeof volumeOpts?.subpath === "string" && volumeOpts.subpath) {
2616
+ issues.push({
2617
+ field: "volumes[].volume.subpath",
2618
+ reason: `volume.subpath on the mount at ${target} is not modeled. Ignoring it would mount the ENTIRE volume there instead of the one subdirectory, exposing data the file scoped out.`,
2619
+ blocking: true
2620
+ });
2621
+ }
2622
+ if (mount.read_only === true && !mount.source && !mount.name) {
2623
+ issues.push({
2624
+ field: "volumes[].read_only",
2625
+ reason: `read_only on the anonymous volume at ${target} is not modeled \u2014 a volume with no source cannot be mounted read-only. Give it a named source to make it read-only.`
2626
+ });
2627
+ }
2628
+ const bindOpts = mount.bind;
2629
+ if (typeof bindOpts?.propagation === "string" && bindOpts.propagation) {
2630
+ issues.push({
2631
+ field: "volumes[].bind.propagation",
2632
+ reason: `bind.propagation on the mount at ${target} is not modeled.`
2633
+ });
2634
+ }
2635
+ if (bindOpts?.create_host_path === false) {
2636
+ issues.push({
2637
+ field: "volumes[].bind.create_host_path",
2638
+ reason: `create_host_path: false on the mount at ${target} is not modeled \u2014 Docker will create the host path if it is missing.`
2639
+ });
2640
+ }
2641
+ return issues;
2642
+ }
2643
+ function composePortToSpec(port, interpolate = asIs) {
2644
+ const target = port.target ?? port.container_port;
2645
+ if (target === void 0 || target === null || target === "") return void 0;
2646
+ const published = port.published ?? port.host_port;
2647
+ const proto = typeof port.protocol === "string" ? port.protocol.toLowerCase() : void 0;
2648
+ const suffix = proto && proto !== "tcp" ? `/${proto}` : "";
2649
+ const hostIp = typeof port.host_ip === "string" ? interpolate(port.host_ip) : void 0;
2650
+ const hasPublished = published !== void 0 && published !== null && published !== "" && published !== 0 && published !== "0";
2651
+ const hostPart = hasPublished ? hostIp ? `${hostIp}:${published}:` : `${published}:` : hostIp ? `${hostIp}::` : "";
2652
+ return `${hostPart}${target}${suffix}`;
2653
+ }
2654
+
2258
2655
  // ../../packages/core/src/object-storage.ts
2259
2656
  function isAwsS3Endpoint(endpoint2) {
2260
2657
  return !endpoint2 || endpoint2.includes(".amazonaws.com");
2261
2658
  }
2262
2659
 
2263
2660
  // ../../packages/core/src/shell-split.ts
2661
+ function shellQuote(value) {
2662
+ return `'${value.replace(/'/g, "'\\''")}'`;
2663
+ }
2264
2664
  function shellSplitWords(value) {
2265
2665
  const words = [];
2266
2666
  let current = "";
@@ -3168,6 +3568,74 @@ var LANGUAGE_MANIFEST_FILES = Array.from(
3168
3568
  new Set(LANGUAGE_DETECTORS.flatMap((d) => d.manifestFiles.map((f) => f.toLowerCase())))
3169
3569
  );
3170
3570
 
3571
+ // ../../packages/core/src/mail-server/relay-providers.ts
3572
+ var RELAY_PROVIDERS = [
3573
+ {
3574
+ id: "ses",
3575
+ label: "Amazon SES",
3576
+ hostTemplate: "email-smtp.{region}.amazonaws.com",
3577
+ regional: true,
3578
+ defaultPort: 587,
3579
+ spfInclude: "include:amazonses.com",
3580
+ providerDkim: true,
3581
+ mailFrom: {
3582
+ mxTemplate: "feedback-smtp.{region}.amazonaws.com",
3583
+ spf: "v=spf1 include:amazonses.com ~all"
3584
+ }
3585
+ },
3586
+ {
3587
+ id: "sendgrid",
3588
+ label: "SendGrid",
3589
+ hostTemplate: "smtp.sendgrid.net",
3590
+ defaultPort: 587,
3591
+ spfInclude: "include:sendgrid.net",
3592
+ username: "apikey",
3593
+ providerDkim: true
3594
+ },
3595
+ {
3596
+ id: "mailgun",
3597
+ label: "Mailgun",
3598
+ hostTemplate: "smtp.mailgun.org",
3599
+ defaultPort: 587,
3600
+ spfInclude: "include:mailgun.org",
3601
+ providerDkim: true
3602
+ },
3603
+ {
3604
+ id: "postmark",
3605
+ label: "Postmark",
3606
+ hostTemplate: "smtp.postmarkapp.com",
3607
+ defaultPort: 587,
3608
+ spfInclude: "include:spf.mtasv.net",
3609
+ providerDkim: true
3610
+ },
3611
+ {
3612
+ id: "resend",
3613
+ // Resend rides SES, but the verified-domain records it hands out are
3614
+ // per-account — don't assume the SES include.
3615
+ label: "Resend",
3616
+ hostTemplate: "smtp.resend.com",
3617
+ defaultPort: 587,
3618
+ username: "resend",
3619
+ providerDkim: true
3620
+ },
3621
+ {
3622
+ id: "oracle",
3623
+ // OCI Email Delivery's SPF include is region-scoped
3624
+ // (rp / eu.rp / ap.rp .oracleemaildelivery.com) — the operator pastes theirs.
3625
+ label: "Oracle Cloud",
3626
+ hostTemplate: "smtp.email.{region}.oci.oraclecloud.com",
3627
+ regional: true,
3628
+ defaultPort: 587,
3629
+ providerDkim: true
3630
+ },
3631
+ {
3632
+ id: "custom",
3633
+ label: "Custom SMTP",
3634
+ defaultPort: 587
3635
+ }
3636
+ ];
3637
+ var RELAY_PROVIDER_IDS = RELAY_PROVIDERS.map((p) => p.id);
3638
+
3171
3639
  // ../../packages/core/src/apps/catalog.json
3172
3640
  var catalog_default = {
3173
3641
  version: 1,
@@ -3177,9 +3645,9 @@ var catalog_default = {
3177
3645
  verified: true,
3178
3646
  id: "mail",
3179
3647
  name: "Openship Mail",
3180
- description: "A complete self-hosted mail server \u2014 SMTP/IMAP + webmail on your own domain.",
3648
+ description: "Your own mail on your own domain \u2014 SMTP/IMAP, mailboxes and webmail on one server. Or keep the mailboxes you already have and just run the webmail.",
3181
3649
  kind: "flow",
3182
- logo: "mail-webmail",
3650
+ logo: "mail",
3183
3651
  category: "mail",
3184
3652
  tags: [
3185
3653
  "mail",
@@ -5071,18 +5539,1051 @@ COMMIT;
5071
5539
  AUTH_TYPE: "LOGIN_FORM",
5072
5540
  SPRING_SECURITY_USER_NAME: "admin"
5073
5541
  },
5074
- secretEnv: [
5075
- "SPRING_SECURITY_USER_PASSWORD"
5076
- ],
5542
+ secretEnv: [
5543
+ "SPRING_SECURITY_USER_PASSWORD"
5544
+ ],
5545
+ restart: "unless-stopped"
5546
+ }
5547
+ ],
5548
+ configFields: [
5549
+ {
5550
+ key: "SPRING_SECURITY_USER_PASSWORD",
5551
+ service: "kafka-ui",
5552
+ label: "Kafka UI password",
5553
+ help: "Auto-generated. The password to sign in to the Kafka UI web console (username: admin).",
5554
+ generate: "secret",
5555
+ secret: true
5556
+ }
5557
+ ],
5558
+ management: {
5559
+ kind: "schema"
5560
+ },
5561
+ connection: {
5562
+ title: "Connect to Kafka",
5563
+ description: "Open the Kafka UI to browse topics and messages, or point a client at the broker from another service in this project.",
5564
+ outputs: [
5565
+ {
5566
+ id: "ui",
5567
+ label: "Kafka UI",
5568
+ source: "publicUrl:kafka-ui",
5569
+ kind: "url",
5570
+ help: "Sign in with the username + password below."
5571
+ },
5572
+ {
5573
+ id: "uiUser",
5574
+ label: "UI username",
5575
+ source: "env:kafka-ui:SPRING_SECURITY_USER_NAME"
5576
+ },
5577
+ {
5578
+ id: "uiPassword",
5579
+ label: "UI password",
5580
+ source: "env:kafka-ui:SPRING_SECURITY_USER_PASSWORD",
5581
+ secret: true
5582
+ },
5583
+ {
5584
+ id: "broker",
5585
+ label: "Bootstrap server (internal)",
5586
+ source: "template:kafka:9092",
5587
+ help: "Reachable as kafka:9092 from other services in this project \u2014 connect an app to it."
5588
+ }
5589
+ ]
5590
+ },
5591
+ endpoints: [
5592
+ {
5593
+ service: "kafka-ui",
5594
+ port: 8080,
5595
+ label: "Kafka UI",
5596
+ kind: "http"
5597
+ }
5598
+ ]
5599
+ },
5600
+ {
5601
+ available: true,
5602
+ verified: true,
5603
+ id: "qdrant",
5604
+ name: "Qdrant",
5605
+ description: "High-performance vector database for AI search, RAG, and embeddings \u2014 with the built-in Qdrant Web UI for browsing collections and running queries.",
5606
+ kind: "template",
5607
+ logo: "qdrant",
5608
+ category: "backend",
5609
+ tags: [
5610
+ "database",
5611
+ "vector",
5612
+ "ai",
5613
+ "search",
5614
+ "rag",
5615
+ "embeddings"
5616
+ ],
5617
+ framework: "docker-compose",
5618
+ services: [
5619
+ {
5620
+ name: "qdrant",
5621
+ image: "qdrant/qdrant:v1.18.3",
5622
+ ports: [
5623
+ "6333:6333"
5624
+ ],
5625
+ exposedPort: 6333,
5626
+ exposed: true,
5627
+ routes: [
5628
+ {
5629
+ port: 6333
5630
+ }
5631
+ ],
5632
+ secretEnv: [
5633
+ "QDRANT__SERVICE__API_KEY"
5634
+ ],
5635
+ volumes: [
5636
+ "qdrant_storage:/qdrant/storage"
5637
+ ],
5638
+ restart: "unless-stopped"
5639
+ }
5640
+ ],
5641
+ configFields: [
5642
+ {
5643
+ key: "QDRANT__SERVICE__API_KEY",
5644
+ service: "qdrant",
5645
+ label: "API key",
5646
+ help: "Auto-generated. Required as the `api-key` header on every request, and to open the Web UI.",
5647
+ generate: "secret",
5648
+ secret: true
5649
+ }
5650
+ ],
5651
+ management: {
5652
+ kind: "schema"
5653
+ },
5654
+ connection: {
5655
+ title: "Connect to Qdrant",
5656
+ description: "Open the Qdrant Web UI in your browser (it lives under /dashboard), or point a client at the REST API. Every request needs the API key below.",
5657
+ outputs: [
5658
+ {
5659
+ id: "ui",
5660
+ label: "Qdrant Web UI",
5661
+ source: "publicUrl:qdrant",
5662
+ kind: "url",
5663
+ help: "The dashboard is at this URL under /dashboard. Paste the API key when it asks."
5664
+ },
5665
+ {
5666
+ id: "restUrl",
5667
+ label: "REST API",
5668
+ source: "publicUrl:qdrant"
5669
+ },
5670
+ {
5671
+ id: "apiKey",
5672
+ label: "API key",
5673
+ source: "env:qdrant:QDRANT__SERVICE__API_KEY",
5674
+ secret: true,
5675
+ help: "Send it as the `api-key` header on every request."
5676
+ }
5677
+ ]
5678
+ }
5679
+ },
5680
+ {
5681
+ available: true,
5682
+ verified: true,
5683
+ id: "meilisearch",
5684
+ name: "Meilisearch",
5685
+ description: "Lightning-fast, typo-tolerant search engine. Point any Meilisearch client at the URL below with the master key \u2014 index your documents and search in milliseconds.",
5686
+ kind: "template",
5687
+ logo: "meilisearch",
5688
+ category: "backend",
5689
+ tags: [
5690
+ "search",
5691
+ "full-text",
5692
+ "index",
5693
+ "typo-tolerant"
5694
+ ],
5695
+ framework: "docker-compose",
5696
+ services: [
5697
+ {
5698
+ name: "meilisearch",
5699
+ image: "getmeili/meilisearch:v1.12",
5700
+ ports: [
5701
+ "7700:7700"
5702
+ ],
5703
+ exposedPort: 7700,
5704
+ exposed: true,
5705
+ routes: [
5706
+ {
5707
+ port: 7700
5708
+ }
5709
+ ],
5710
+ environment: {
5711
+ MEILI_ENV: "production",
5712
+ MEILI_NO_ANALYTICS: "true"
5713
+ },
5714
+ secretEnv: [
5715
+ "MEILI_MASTER_KEY"
5716
+ ],
5717
+ volumes: [
5718
+ "meili_data:/meili_data"
5719
+ ],
5720
+ healthcheck: {
5721
+ test: [
5722
+ "CMD-SHELL",
5723
+ "wget -q -O - http://127.0.0.1:7700/health 2>&1 | grep -q available"
5724
+ ],
5725
+ interval: "10s",
5726
+ timeout: "5s",
5727
+ retries: 5,
5728
+ startPeriod: "10s"
5729
+ },
5730
+ restart: "unless-stopped"
5731
+ }
5732
+ ],
5733
+ configFields: [
5734
+ {
5735
+ key: "MEILI_MASTER_KEY",
5736
+ service: "meilisearch",
5737
+ label: "Master key",
5738
+ help: "Auto-generated. Send it as the `Authorization: Bearer` header on every request. Use it to mint scoped API keys.",
5739
+ generate: "secret",
5740
+ secret: true
5741
+ }
5742
+ ],
5743
+ management: {
5744
+ kind: "schema"
5745
+ },
5746
+ connection: {
5747
+ title: "Connect to Meilisearch",
5748
+ description: "Point a Meilisearch client at the URL with the master key. Every request needs the key as a Bearer token.",
5749
+ guide: {
5750
+ intro: "Your project gets a Meilisearch endpoint plus its master key.",
5751
+ useHint: "Read `process.env.MEILISEARCH_URL` and `process.env.MEILISEARCH_KEY` in your code \u2014 set on your next deploy.",
5752
+ defaultMode: "internal"
5753
+ },
5754
+ outputs: [
5755
+ {
5756
+ id: "url",
5757
+ label: "API URL",
5758
+ source: "publicUrl:meilisearch",
5759
+ sourceLabel: "Public",
5760
+ variants: [
5761
+ {
5762
+ id: "internal",
5763
+ label: "Internal",
5764
+ source: "template:http://meilisearch:7700"
5765
+ }
5766
+ ],
5767
+ envKey: "MEILISEARCH_URL",
5768
+ recommended: true,
5769
+ help: "The Meilisearch HTTP endpoint. Switch to Internal for apps on the same project network."
5770
+ },
5771
+ {
5772
+ id: "masterKey",
5773
+ label: "Master key",
5774
+ source: "env:meilisearch:MEILI_MASTER_KEY",
5775
+ secret: true,
5776
+ envKey: "MEILISEARCH_KEY",
5777
+ recommended: true,
5778
+ help: "Send as the `Authorization: Bearer <key>` header. Full-access \u2014 mint scoped keys for clients."
5779
+ }
5780
+ ]
5781
+ },
5782
+ provides: [
5783
+ {
5784
+ id: "meilisearch",
5785
+ outputRefs: [
5786
+ "url",
5787
+ "masterKey"
5788
+ ],
5789
+ category: "search"
5790
+ }
5791
+ ],
5792
+ endpoints: [
5793
+ {
5794
+ service: "meilisearch",
5795
+ port: 7700,
5796
+ label: "Meilisearch",
5797
+ kind: "http"
5798
+ }
5799
+ ]
5800
+ },
5801
+ {
5802
+ available: true,
5803
+ verified: false,
5804
+ hosting: "experimental",
5805
+ minResources: {
5806
+ memoryMb: 8192
5807
+ },
5808
+ id: "neon",
5809
+ name: "Neon",
5810
+ description: "Self-hosted Neon \u2014 the serverless-Postgres storage engine (pageserver, 3 safekeepers, storage broker) on S3-backed object storage, with a Neon compute node built from source. EXPERIMENTAL and heavy: ~9 containers, needs roughly 8 GB RAM, and is a test-grade topology, not a production HA cluster. Not for production data.",
5811
+ repository: "https://github.com/neondatabase/neon",
5812
+ kind: "template",
5813
+ logo: "neon",
5814
+ category: "database",
5815
+ tags: [
5816
+ "database",
5817
+ "postgres",
5818
+ "postgresql",
5819
+ "serverless",
5820
+ "sql"
5821
+ ],
5822
+ framework: "docker-compose",
5823
+ services: [
5824
+ {
5825
+ name: "storage_broker",
5826
+ image: "ghcr.io/neondatabase/neon:latest",
5827
+ command: "storage_broker --listen-addr=0.0.0.0:50051",
5828
+ restart: "unless-stopped"
5829
+ },
5830
+ {
5831
+ name: "minio",
5832
+ image: "minio/minio:RELEASE.2025-04-22T22-12-26Z",
5833
+ command: "server /data --address :9000 --console-address :9001",
5834
+ volumes: [
5835
+ "neon_minio_data:/data"
5836
+ ],
5837
+ restart: "unless-stopped"
5838
+ },
5839
+ {
5840
+ name: "pageserver",
5841
+ image: "ghcr.io/neondatabase/neon:latest",
5842
+ dependsOn: [
5843
+ "storage_broker",
5844
+ "minio"
5845
+ ],
5846
+ environment: {
5847
+ AWS_ACCESS_KEY_ID: "{{config:MINIO_ROOT_USER}}",
5848
+ AWS_SECRET_ACCESS_KEY: "{{config:MINIO_ROOT_PASSWORD}}"
5849
+ },
5850
+ secretEnv: [
5851
+ "AWS_SECRET_ACCESS_KEY"
5852
+ ],
5853
+ restart: "unless-stopped"
5854
+ },
5855
+ {
5856
+ name: "safekeeper1",
5857
+ image: "ghcr.io/neondatabase/neon:latest",
5858
+ command: "safekeeper --listen-pg=safekeeper1:5454 --listen-http=0.0.0.0:7676 --id=1 --broker-endpoint=http://storage_broker:50051 -D /data --remote-storage={endpoint='http://minio:9000',bucket_name='neon',bucket_region='eu-north-1',prefix_in_bucket='/safekeeper/'}",
5859
+ dependsOn: [
5860
+ "storage_broker",
5861
+ "minio"
5862
+ ],
5863
+ environment: {
5864
+ AWS_ACCESS_KEY_ID: "{{config:MINIO_ROOT_USER}}",
5865
+ AWS_SECRET_ACCESS_KEY: "{{config:MINIO_ROOT_PASSWORD}}"
5866
+ },
5867
+ secretEnv: [
5868
+ "AWS_SECRET_ACCESS_KEY"
5869
+ ],
5870
+ restart: "unless-stopped"
5871
+ },
5872
+ {
5873
+ name: "safekeeper2",
5874
+ image: "ghcr.io/neondatabase/neon:latest",
5875
+ command: "safekeeper --listen-pg=safekeeper2:5454 --listen-http=0.0.0.0:7676 --id=2 --broker-endpoint=http://storage_broker:50051 -D /data --remote-storage={endpoint='http://minio:9000',bucket_name='neon',bucket_region='eu-north-1',prefix_in_bucket='/safekeeper/'}",
5876
+ dependsOn: [
5877
+ "storage_broker",
5878
+ "minio"
5879
+ ],
5880
+ environment: {
5881
+ AWS_ACCESS_KEY_ID: "{{config:MINIO_ROOT_USER}}",
5882
+ AWS_SECRET_ACCESS_KEY: "{{config:MINIO_ROOT_PASSWORD}}"
5883
+ },
5884
+ secretEnv: [
5885
+ "AWS_SECRET_ACCESS_KEY"
5886
+ ],
5887
+ restart: "unless-stopped"
5888
+ },
5889
+ {
5890
+ name: "safekeeper3",
5891
+ image: "ghcr.io/neondatabase/neon:latest",
5892
+ command: "safekeeper --listen-pg=safekeeper3:5454 --listen-http=0.0.0.0:7676 --id=3 --broker-endpoint=http://storage_broker:50051 -D /data --remote-storage={endpoint='http://minio:9000',bucket_name='neon',bucket_region='eu-north-1',prefix_in_bucket='/safekeeper/'}",
5893
+ dependsOn: [
5894
+ "storage_broker",
5895
+ "minio"
5896
+ ],
5897
+ environment: {
5898
+ AWS_ACCESS_KEY_ID: "{{config:MINIO_ROOT_USER}}",
5899
+ AWS_SECRET_ACCESS_KEY: "{{config:MINIO_ROOT_PASSWORD}}"
5900
+ },
5901
+ secretEnv: [
5902
+ "AWS_SECRET_ACCESS_KEY"
5903
+ ],
5904
+ restart: "unless-stopped"
5905
+ },
5906
+ {
5907
+ name: "compute",
5908
+ build: {
5909
+ dockerfile: `FROM ghcr.io/neondatabase/compute-node-v16:latest
5910
+
5911
+ USER root
5912
+ RUN echo 'Acquire::Retries "5";' > /etc/apt/apt.conf.d/80-retries && \\
5913
+ apt-get update && \\
5914
+ apt-get install -y curl jq netcat-openbsd && \\
5915
+ rm -rf /var/lib/apt/lists/*
5916
+
5917
+ COPY compute/compute.sh /shell/compute.sh
5918
+ RUN chmod +x /shell/compute.sh
5919
+
5920
+ USER postgres
5921
+ ENTRYPOINT ["/shell/compute.sh"]
5922
+ `,
5923
+ files: [
5924
+ {
5925
+ path: "compute.sh",
5926
+ content: '#!/usr/bin/env bash\nset -eux\n\n# Generate a random tenant or timeline ID\n#\n# Takes a variable name as argument. The result is stored in that variable.\ngenerate_id() {\n local -n resvar=${1}\n printf -v resvar \'%08x%08x%08x%08x\' ${SRANDOM} ${SRANDOM} ${SRANDOM} ${SRANDOM}\n}\n\nPG_VERSION=${PG_VERSION:-16}\n\nreadonly CONFIG_FILE_ORG=/var/db/postgres/configs/config.json\nreadonly CONFIG_FILE=/tmp/config.json\n\necho "Waiting pageserver become ready."\nwhile ! nc -z pageserver 6400; do\n sleep 1\ndone\necho "Page server is ready."\n\ncp "${CONFIG_FILE_ORG}" "${CONFIG_FILE}"\n\nif [[ -n "${TENANT_ID:-}" && -n "${TIMELINE_ID:-}" ]]; then\n tenant_id=${TENANT_ID}\n timeline_id=${TIMELINE_ID}\nelse\n echo "Check if a tenant present"\n PARAMS=(\n -X GET\n -H "Content-Type: application/json"\n "http://pageserver:9898/v1/tenant"\n )\n tenant_id=$(curl "${PARAMS[@]}" | jq -r .[0].id)\n if [[ -z "${tenant_id}" || "${tenant_id}" = null ]]; then\n echo "Create a tenant"\n generate_id tenant_id\n PARAMS=(\n -X PUT\n -H "Content-Type: application/json"\n -d "{\\"mode\\": \\"AttachedSingle\\", \\"generation\\": 1, \\"tenant_conf\\": {}}"\n "http://pageserver:9898/v1/tenant/${tenant_id}/location_config"\n )\n result=$(curl "${PARAMS[@]}")\n printf \'%s\\n\' "${result}" | jq .\n fi\n\n echo "Check if a timeline present"\n PARAMS=(\n -X GET\n -H "Content-Type: application/json"\n "http://pageserver:9898/v1/tenant/${tenant_id}/timeline"\n )\n timeline_id=$(curl "${PARAMS[@]}" | jq -r .[0].timeline_id)\n if [[ -z "${timeline_id}" || "${timeline_id}" = null ]]; then\n generate_id timeline_id\n PARAMS=(\n -sbf\n -X POST\n -H "Content-Type: application/json"\n -d "{\\"new_timeline_id\\": \\"${timeline_id}\\", \\"pg_version\\": ${PG_VERSION}}"\n "http://pageserver:9898/v1/tenant/${tenant_id}/timeline/"\n )\n result=$(curl "${PARAMS[@]}")\n printf \'%s\\n\' "${result}" | jq .\n fi\nfi\n\necho "Overwrite tenant id and timeline id in spec file"\nsed -i "s|TENANT_ID|${tenant_id}|" ${CONFIG_FILE}\nsed -i "s|TIMELINE_ID|${timeline_id}|" ${CONFIG_FILE}\n\ncat ${CONFIG_FILE}\n\necho "Start compute node"\n/usr/local/bin/compute_ctl --pgdata /var/db/postgres/compute \\\n -C "postgresql://cloud_admin@localhost:55433/postgres" \\\n -b /usr/local/bin/postgres \\\n --compute-id "compute-${RANDOM}" \\\n --config "${CONFIG_FILE}"\n'
5927
+ }
5928
+ ]
5929
+ },
5930
+ dependsOn: [
5931
+ "pageserver",
5932
+ "safekeeper1",
5933
+ "safekeeper2",
5934
+ "safekeeper3"
5935
+ ],
5936
+ environment: {
5937
+ PG_VERSION: "16"
5938
+ },
5939
+ ports: [
5940
+ "55433:55433"
5941
+ ],
5942
+ volumes: [
5943
+ "neon_compute_data:/var/db/postgres/compute"
5944
+ ],
5945
+ healthcheck: {
5946
+ test: [
5947
+ "CMD-SHELL",
5948
+ "pg_isready -h 127.0.0.1 -p 55433 -U cloud_admin || exit 1"
5949
+ ],
5950
+ interval: "10s",
5951
+ timeout: "5s",
5952
+ retries: 10,
5953
+ startPeriod: "90s"
5954
+ },
5955
+ restart: "unless-stopped"
5956
+ }
5957
+ ],
5958
+ configFields: [
5959
+ {
5960
+ key: "MINIO_ROOT_USER",
5961
+ service: "minio",
5962
+ label: "Object-storage access key",
5963
+ help: "The S3 access key the Neon storage layer uses against its bundled MinIO.",
5964
+ type: "text",
5965
+ default: "neon",
5966
+ required: true
5967
+ },
5968
+ {
5969
+ key: "MINIO_ROOT_PASSWORD",
5970
+ service: "minio",
5971
+ label: "Object-storage secret key",
5972
+ help: "Auto-generated. The S3 secret key shared by the pageserver and safekeepers.",
5973
+ generate: "secret",
5974
+ secret: true
5975
+ }
5976
+ ],
5977
+ management: {
5978
+ kind: "schema"
5979
+ },
5980
+ prepare: [
5981
+ {
5982
+ service: "minio",
5983
+ command: 'mc alias set local http://127.0.0.1:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" > /dev/null && mc mb --ignore-existing --region eu-north-1 local/neon > /dev/null && printf %s neon',
5984
+ capture: "bucket",
5985
+ phase: "post-ready",
5986
+ readiness: {
5987
+ test: 'mc alias set local http://127.0.0.1:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"',
5988
+ interval: 1e3,
5989
+ retries: 30
5990
+ },
5991
+ once: true
5992
+ }
5993
+ ],
5994
+ connection: {
5995
+ title: "Connect to Neon",
5996
+ description: "Point a Postgres driver at the compute node. The compute serves the Postgres wire protocol on port 55433 as user cloud_admin.",
5997
+ guide: {
5998
+ intro: "Your project gets a Postgres connection served by the Neon compute node.",
5999
+ useHint: "Read process.env.DATABASE_URL in your code \u2014 it's set the next time your project deploys.",
6000
+ defaultMode: "internal"
6001
+ },
6002
+ outputs: [
6003
+ {
6004
+ id: "dbUrl",
6005
+ label: "Database URL",
6006
+ source: "template:postgresql://cloud_admin:cloud_admin@{{host}}:55433/postgres",
6007
+ sourceLabel: "Public",
6008
+ variants: [
6009
+ {
6010
+ id: "internal",
6011
+ label: "Internal",
6012
+ source: "template:postgresql://cloud_admin:cloud_admin@compute:55433/postgres"
6013
+ }
6014
+ ],
6015
+ secret: true,
6016
+ envKey: "DATABASE_URL",
6017
+ recommended: true,
6018
+ help: "Postgres connection (cloud_admin). Published on port 55433. Switch to Internal for apps on the same project network."
6019
+ }
6020
+ ]
6021
+ },
6022
+ endpoints: [
6023
+ {
6024
+ service: "compute",
6025
+ port: 55433,
6026
+ label: "Postgres",
6027
+ kind: "tcp"
6028
+ }
6029
+ ],
6030
+ files: [
6031
+ {
6032
+ service: "pageserver",
6033
+ path: "/data/.neon/pageserver.toml",
6034
+ content: `broker_endpoint='http://storage_broker:50051'
6035
+ pg_distrib_dir='/usr/local/'
6036
+ listen_pg_addr='0.0.0.0:6400'
6037
+ listen_http_addr='0.0.0.0:9898'
6038
+ remote_storage={ endpoint='http://minio:9000', bucket_name='neon', bucket_region='eu-north-1', prefix_in_bucket='/pageserver' }
6039
+ control_plane_api='http://0.0.0.0:6666'
6040
+ control_plane_emergency_mode=true
6041
+ virtual_file_io_mode="buffered"
6042
+ `
6043
+ },
6044
+ {
6045
+ service: "pageserver",
6046
+ path: "/data/.neon/identity.toml",
6047
+ content: "id=1234\n"
6048
+ },
6049
+ {
6050
+ service: "compute",
6051
+ path: "/var/db/postgres/configs/config.json",
6052
+ content: '{\n "spec": {\n "format_version": 1.0,\n\n "timestamp": "2022-10-12T18:00:00.000Z",\n "operation_uuid": "0f657b36-4b0f-4a2d-9c2e-1dcd615e7d8c",\n "suspend_timeout_seconds": -1,\n\n "cluster": {\n "cluster_id": "docker_compose",\n "name": "docker_compose_test",\n "state": "restarted",\n "roles": [\n {\n "name": "cloud_admin",\n "encrypted_password": "b093c0d3b281ba6da1eacc608620abd8",\n "options": null\n }\n ],\n "databases": [\n ],\n "settings": [\n {\n "name": "fsync",\n "value": "off",\n "vartype": "bool"\n },\n {\n "name": "wal_level",\n "value": "logical",\n "vartype": "enum"\n },\n {\n "name": "wal_log_hints",\n "value": "on",\n "vartype": "bool"\n },\n {\n "name": "log_connections",\n "value": "on",\n "vartype": "bool"\n },\n {\n "name": "port",\n "value": "55433",\n "vartype": "integer"\n },\n {\n "name": "shared_buffers",\n "value": "1MB",\n "vartype": "string"\n },\n {\n "name": "max_connections",\n "value": "100",\n "vartype": "integer"\n },\n {\n "name": "listen_addresses",\n "value": "0.0.0.0",\n "vartype": "string"\n },\n {\n "name": "max_wal_senders",\n "value": "10",\n "vartype": "integer"\n },\n {\n "name": "max_replication_slots",\n "value": "10",\n "vartype": "integer"\n },\n {\n "name": "wal_sender_timeout",\n "value": "5s",\n "vartype": "string"\n },\n {\n "name": "wal_keep_size",\n "value": "0",\n "vartype": "integer"\n },\n {\n "name": "password_encryption",\n "value": "md5",\n "vartype": "enum"\n },\n {\n "name": "restart_after_crash",\n "value": "off",\n "vartype": "bool"\n },\n {\n "name": "synchronous_standby_names",\n "value": "walproposer",\n "vartype": "string"\n },\n {\n "name": "shared_preload_libraries",\n "value": "neon,pg_cron,timescaledb,pg_stat_statements",\n "vartype": "string"\n },\n {\n "name": "neon.safekeepers",\n "value": "safekeeper1:5454,safekeeper2:5454,safekeeper3:5454",\n "vartype": "string"\n },\n {\n "name": "neon.timeline_id",\n "value": "TIMELINE_ID",\n "vartype": "string"\n },\n {\n "name": "neon.tenant_id",\n "value": "TENANT_ID",\n "vartype": "string"\n },\n {\n "name": "neon.pageserver_connstring",\n "value": "host=pageserver port=6400",\n "vartype": "string"\n },\n {\n "name": "max_replication_write_lag",\n "value": "500MB",\n "vartype": "string"\n },\n {\n "name": "max_replication_flush_lag",\n "value": "10GB",\n "vartype": "string"\n },\n {\n "name": "cron.database",\n "value": "postgres",\n "vartype": "string"\n }\n ]\n },\n\n "delta_operations": [\n ]\n },\n "compute_ctl_config": {\n "jwks": {\n "keys": [\n {\n "use": "sig",\n "key_ops": [\n "verify"\n ],\n "alg": "EdDSA",\n "kid": "ZGIxMzAzOGY0YWQwODk2ODU1MTk1NzMxMDFkYmUyOWU2NzZkOWNjNjMyMGRkZGJjOWY0MjdjYWVmNzE1MjUyOAo=",\n "kty": "OKP",\n "crv": "Ed25519",\n "x": "MGQ4ZDFhOTdmNTM0NmUwMDc3ZmJkN2Q0MWE0ZmI3M2NhNWE3YjFjOTNkM2IyYzRkZTQzOGM3MjBkZTk3N2E5ZAo="\n }\n ]\n }\n }\n}\n'
6053
+ }
6054
+ ],
6055
+ provides: [
6056
+ {
6057
+ id: "postgres",
6058
+ outputRefs: [
6059
+ "dbUrl"
6060
+ ],
6061
+ category: "database"
6062
+ }
6063
+ ]
6064
+ },
6065
+ {
6066
+ available: true,
6067
+ verified: false,
6068
+ hosting: "experimental",
6069
+ minResources: {
6070
+ memoryMb: 8192,
6071
+ cpuCores: 4
6072
+ },
6073
+ id: "posthog",
6074
+ name: "PostHog",
6075
+ description: "Self-hosted PostHog \u2014 product analytics, session replay, and feature flags. EXPERIMENTAL and heavy: the hobby stack runs the PostHog app plus Postgres, Redis, ClickHouse, Zookeeper, a Kafka-compatible broker (Redpanda), and MinIO (~9 containers) and wants roughly 4 vCPU / 8\u201316 GB RAM. Not for production.",
6076
+ repository: "https://github.com/PostHog/posthog",
6077
+ kind: "template",
6078
+ logo: "posthog",
6079
+ category: "analytics",
6080
+ tags: [
6081
+ "analytics",
6082
+ "product-analytics",
6083
+ "events",
6084
+ "session-replay",
6085
+ "feature-flags"
6086
+ ],
6087
+ framework: "docker-compose",
6088
+ services: [
6089
+ {
6090
+ name: "db",
6091
+ image: "postgres:15.12-alpine",
6092
+ environment: {
6093
+ POSTGRES_USER: "posthog",
6094
+ POSTGRES_DB: "posthog"
6095
+ },
6096
+ secretEnv: [
6097
+ "POSTGRES_PASSWORD"
6098
+ ],
6099
+ volumes: [
6100
+ "posthog_pgdata:/var/lib/postgresql/data"
6101
+ ],
6102
+ healthcheck: {
6103
+ test: [
6104
+ "CMD-SHELL",
6105
+ "pg_isready -U posthog"
6106
+ ],
6107
+ interval: "5s",
6108
+ timeout: "5s",
6109
+ retries: 10,
6110
+ startPeriod: "10s"
6111
+ },
6112
+ restart: "unless-stopped"
6113
+ },
6114
+ {
6115
+ name: "redis",
6116
+ image: "redis:7.2-alpine",
6117
+ command: "redis-server --maxmemory-policy allkeys-lru --maxmemory 200mb",
6118
+ volumes: [
6119
+ "posthog_redis:/data"
6120
+ ],
6121
+ healthcheck: {
6122
+ test: [
6123
+ "CMD",
6124
+ "redis-cli",
6125
+ "ping"
6126
+ ],
6127
+ interval: "5s",
6128
+ timeout: "5s",
6129
+ retries: 10
6130
+ },
6131
+ restart: "unless-stopped"
6132
+ },
6133
+ {
6134
+ name: "zookeeper",
6135
+ image: "zookeeper:3.7.0",
6136
+ environment: {
6137
+ ZOO_AUTOPURGE_PURGEINTERVAL: "1",
6138
+ ZOO_AUTOPURGE_SNAPRETAINCOUNT: "3"
6139
+ },
6140
+ volumes: [
6141
+ "posthog_zk_data:/data",
6142
+ "posthog_zk_datalog:/datalog"
6143
+ ],
6144
+ restart: "unless-stopped"
6145
+ },
6146
+ {
6147
+ name: "kafka",
6148
+ image: "docker.io/redpandadata/redpanda:v25.1.9",
6149
+ command: "redpanda start --kafka-addr internal://0.0.0.0:9092 --advertise-kafka-addr internal://kafka:9092 --mode dev-container --smp 1 --memory 1G",
6150
+ volumes: [
6151
+ "posthog_kafka:/var/lib/redpanda/data"
6152
+ ],
6153
+ restart: "unless-stopped"
6154
+ },
6155
+ {
6156
+ name: "clickhouse",
6157
+ image: "clickhouse/clickhouse-server:24.12",
6158
+ dependsOn: [
6159
+ "kafka",
6160
+ "zookeeper"
6161
+ ],
6162
+ environment: {
6163
+ CLICKHOUSE_SKIP_USER_SETUP: "1"
6164
+ },
6165
+ volumes: [
6166
+ "posthog_clickhouse:/var/lib/clickhouse"
6167
+ ],
6168
+ restart: "unless-stopped"
6169
+ },
6170
+ {
6171
+ name: "objectstorage",
6172
+ image: "minio/minio:RELEASE.2025-04-22T22-12-26Z",
6173
+ command: "server /data --console-address :9001",
6174
+ secretEnv: [
6175
+ "MINIO_ROOT_PASSWORD"
6176
+ ],
6177
+ volumes: [
6178
+ "posthog_objectstorage:/data"
6179
+ ],
6180
+ restart: "unless-stopped"
6181
+ },
6182
+ {
6183
+ name: "web",
6184
+ image: "posthog/posthog:latest",
6185
+ command: "./bin/docker-server",
6186
+ dependsOn: [
6187
+ "db",
6188
+ "redis",
6189
+ "clickhouse",
6190
+ "kafka",
6191
+ "objectstorage"
6192
+ ],
6193
+ exposedPort: 8e3,
6194
+ exposed: true,
6195
+ routes: [
6196
+ {
6197
+ port: 8e3
6198
+ }
6199
+ ],
6200
+ environment: {
6201
+ DATABASE_URL: "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
6202
+ CLICKHOUSE_HOST: "clickhouse",
6203
+ CLICKHOUSE_DATABASE: "posthog",
6204
+ CLICKHOUSE_SECURE: "false",
6205
+ CLICKHOUSE_VERIFY: "false",
6206
+ KAFKA_HOSTS: "kafka:9092",
6207
+ KAFKA_URL: "kafka://kafka:9092",
6208
+ REDIS_URL: "redis://redis:6379/",
6209
+ SECRET_KEY: "{{config:POSTHOG_SECRET}}",
6210
+ OBJECT_STORAGE_ENABLED: "True",
6211
+ OBJECT_STORAGE_ENDPOINT: "http://objectstorage:9000",
6212
+ OBJECT_STORAGE_ACCESS_KEY_ID: "{{config:MINIO_ROOT_USER}}",
6213
+ OBJECT_STORAGE_SECRET_ACCESS_KEY: "{{config:MINIO_ROOT_PASSWORD}}",
6214
+ OBJECT_STORAGE_BUCKET: "posthog",
6215
+ SITE_URL: "{{publicUrl:web}}",
6216
+ DISABLE_SECURE_SSL_REDIRECT: "true",
6217
+ IS_BEHIND_PROXY: "true",
6218
+ TRUST_ALL_PROXIES: "true"
6219
+ },
6220
+ secretEnv: [
6221
+ "DATABASE_URL",
6222
+ "SECRET_KEY",
6223
+ "OBJECT_STORAGE_SECRET_ACCESS_KEY"
6224
+ ],
6225
+ restart: "unless-stopped"
6226
+ },
6227
+ {
6228
+ name: "worker",
6229
+ image: "posthog/posthog:latest",
6230
+ command: "./bin/docker-worker-celery --with-scheduler",
6231
+ dependsOn: [
6232
+ "db",
6233
+ "redis",
6234
+ "clickhouse",
6235
+ "kafka",
6236
+ "objectstorage",
6237
+ "web"
6238
+ ],
6239
+ environment: {
6240
+ DATABASE_URL: "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
6241
+ CLICKHOUSE_HOST: "clickhouse",
6242
+ CLICKHOUSE_DATABASE: "posthog",
6243
+ CLICKHOUSE_SECURE: "false",
6244
+ CLICKHOUSE_VERIFY: "false",
6245
+ KAFKA_HOSTS: "kafka:9092",
6246
+ KAFKA_URL: "kafka://kafka:9092",
6247
+ REDIS_URL: "redis://redis:6379/",
6248
+ SECRET_KEY: "{{config:POSTHOG_SECRET}}",
6249
+ OBJECT_STORAGE_ENABLED: "True",
6250
+ OBJECT_STORAGE_ENDPOINT: "http://objectstorage:9000",
6251
+ OBJECT_STORAGE_ACCESS_KEY_ID: "{{config:MINIO_ROOT_USER}}",
6252
+ OBJECT_STORAGE_SECRET_ACCESS_KEY: "{{config:MINIO_ROOT_PASSWORD}}",
6253
+ OBJECT_STORAGE_BUCKET: "posthog",
6254
+ SITE_URL: "{{publicUrl:web}}"
6255
+ },
6256
+ secretEnv: [
6257
+ "DATABASE_URL",
6258
+ "SECRET_KEY",
6259
+ "OBJECT_STORAGE_SECRET_ACCESS_KEY"
6260
+ ],
6261
+ restart: "unless-stopped"
6262
+ },
6263
+ {
6264
+ name: "plugins",
6265
+ image: "posthog/posthog:latest",
6266
+ command: "./bin/plugin-server --no-restart-loop",
6267
+ dependsOn: [
6268
+ "db",
6269
+ "redis",
6270
+ "clickhouse",
6271
+ "kafka",
6272
+ "objectstorage",
6273
+ "web"
6274
+ ],
6275
+ environment: {
6276
+ DATABASE_URL: "postgres://posthog:{{config:POSTGRES_PASSWORD}}@db:5432/posthog",
6277
+ CLICKHOUSE_HOST: "clickhouse",
6278
+ CLICKHOUSE_DATABASE: "posthog",
6279
+ CLICKHOUSE_SECURE: "false",
6280
+ CLICKHOUSE_VERIFY: "false",
6281
+ KAFKA_HOSTS: "kafka:9092",
6282
+ KAFKA_URL: "kafka://kafka:9092",
6283
+ REDIS_URL: "redis://redis:6379/",
6284
+ SECRET_KEY: "{{config:POSTHOG_SECRET}}",
6285
+ OBJECT_STORAGE_ENABLED: "True",
6286
+ OBJECT_STORAGE_ENDPOINT: "http://objectstorage:9000",
6287
+ OBJECT_STORAGE_ACCESS_KEY_ID: "{{config:MINIO_ROOT_USER}}",
6288
+ OBJECT_STORAGE_SECRET_ACCESS_KEY: "{{config:MINIO_ROOT_PASSWORD}}",
6289
+ OBJECT_STORAGE_BUCKET: "posthog"
6290
+ },
6291
+ secretEnv: [
6292
+ "DATABASE_URL",
6293
+ "SECRET_KEY",
6294
+ "OBJECT_STORAGE_SECRET_ACCESS_KEY"
6295
+ ],
6296
+ restart: "unless-stopped"
6297
+ }
6298
+ ],
6299
+ configFields: [
6300
+ {
6301
+ key: "POSTGRES_PASSWORD",
6302
+ service: "db",
6303
+ label: "Database password",
6304
+ help: "Auto-generated. The Postgres password PostHog uses.",
6305
+ generate: "secret",
6306
+ secret: true
6307
+ },
6308
+ {
6309
+ key: "POSTHOG_SECRET",
6310
+ service: "web",
6311
+ label: "Django secret key",
6312
+ help: "Auto-generated. Signs sessions and cookies (SECRET_KEY).",
6313
+ generate: "secret",
6314
+ secret: true
6315
+ },
6316
+ {
6317
+ key: "MINIO_ROOT_USER",
6318
+ service: "objectstorage",
6319
+ label: "Object-storage access key",
6320
+ help: "The S3 access key PostHog uses against its bundled MinIO (session recordings).",
6321
+ type: "text",
6322
+ default: "posthog",
6323
+ required: true
6324
+ },
6325
+ {
6326
+ key: "MINIO_ROOT_PASSWORD",
6327
+ service: "objectstorage",
6328
+ label: "Object-storage secret key",
6329
+ help: "Auto-generated. The S3 secret key for the bundled MinIO.",
6330
+ generate: "secret",
6331
+ secret: true
6332
+ }
6333
+ ],
6334
+ management: {
6335
+ kind: "schema"
6336
+ },
6337
+ prepare: [
6338
+ {
6339
+ service: "objectstorage",
6340
+ command: 'mc alias set local http://127.0.0.1:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" > /dev/null && mc mb --ignore-existing local/posthog > /dev/null && printf %s posthog',
6341
+ capture: "bucket",
6342
+ phase: "post-ready",
6343
+ readiness: {
6344
+ test: 'mc alias set local http://127.0.0.1:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"',
6345
+ interval: 1e3,
6346
+ retries: 30
6347
+ },
6348
+ once: true
6349
+ }
6350
+ ],
6351
+ connection: {
6352
+ title: "Open PostHog",
6353
+ description: "Open the PostHog UI and create your admin account on first load.",
6354
+ guide: {
6355
+ intro: "PostHog runs its own analytics UI \u2014 create the admin account when you first open it.",
6356
+ defaultMode: "public"
6357
+ },
6358
+ outputs: [
6359
+ {
6360
+ id: "ui",
6361
+ label: "PostHog",
6362
+ source: "publicUrl:web",
6363
+ kind: "url",
6364
+ help: "PostHog has no default login \u2014 the first visitor creates the admin account."
6365
+ }
6366
+ ],
6367
+ firstLogin: {
6368
+ note: "PostHog ships no default credentials \u2014 open the URL above and sign up to create the first (admin) account."
6369
+ }
6370
+ },
6371
+ endpoints: [
6372
+ {
6373
+ service: "web",
6374
+ port: 8e3,
6375
+ label: "PostHog",
6376
+ kind: "http"
6377
+ }
6378
+ ]
6379
+ },
6380
+ {
6381
+ available: true,
6382
+ verified: true,
6383
+ id: "redis",
6384
+ name: "Valkey (Redis)",
6385
+ description: "In-memory data store for caching, sessions, rate limits, and queues \u2014 Valkey, the open-source Redis fork. Drop-in Redis-compatible: point any redis client at it.",
6386
+ kind: "template",
6387
+ logo: "valkey",
6388
+ category: "database",
6389
+ tags: [
6390
+ "cache",
6391
+ "redis",
6392
+ "valkey",
6393
+ "key-value",
6394
+ "queue"
6395
+ ],
6396
+ framework: "docker-compose",
6397
+ services: [
6398
+ {
6399
+ name: "valkey",
6400
+ image: "valkey/valkey:8.1-alpine",
6401
+ command: "valkey-server /etc/valkey/valkey.conf",
6402
+ ports: [
6403
+ "6379:6379"
6404
+ ],
6405
+ volumes: [
6406
+ "valkey_data:/data"
6407
+ ],
6408
+ healthcheck: {
6409
+ test: [
6410
+ "CMD-SHELL",
6411
+ 'valkey-cli --no-auth-warning -a "$VALKEY_PASSWORD" ping | grep -q PONG'
6412
+ ],
6413
+ interval: "10s",
6414
+ timeout: "5s",
6415
+ retries: 5,
6416
+ startPeriod: "10s"
6417
+ },
6418
+ restart: "unless-stopped"
6419
+ }
6420
+ ],
6421
+ configFields: [
6422
+ {
6423
+ key: "VALKEY_PASSWORD",
6424
+ service: "valkey",
6425
+ label: "Password",
6426
+ help: "Auto-generated. Required by every client (Valkey's `requirepass` auth).",
6427
+ generate: "secret",
6428
+ secret: true
6429
+ }
6430
+ ],
6431
+ files: [
6432
+ {
6433
+ service: "valkey",
6434
+ path: "/etc/valkey/valkey.conf",
6435
+ content: "requirepass {{config:VALKEY_PASSWORD}}\nappendonly yes\nappendfsync everysec\nsave 300 100\n"
6436
+ }
6437
+ ],
6438
+ management: {
6439
+ kind: "schema"
6440
+ },
6441
+ connection: {
6442
+ title: "Connect to Valkey",
6443
+ description: "A Redis-compatible endpoint. Point any redis client at the URL below \u2014 it already carries the password.",
6444
+ guide: {
6445
+ intro: "Your project gets a Redis-compatible cache, ready to use.",
6446
+ useHint: "Read `process.env.REDIS_URL` in your code \u2014 it's set the next time your project deploys.",
6447
+ defaultMode: "internal"
6448
+ },
6449
+ outputs: [
6450
+ {
6451
+ id: "url",
6452
+ label: "Connection URL",
6453
+ source: "template:redis://:{{env:valkey:VALKEY_PASSWORD}}@{{host}}:6379",
6454
+ sourceLabel: "Public",
6455
+ variants: [
6456
+ {
6457
+ id: "internal",
6458
+ label: "Internal",
6459
+ source: "template:redis://:{{env:valkey:VALKEY_PASSWORD}}@valkey:6379"
6460
+ }
6461
+ ],
6462
+ secret: true,
6463
+ envKey: "REDIS_URL",
6464
+ recommended: true,
6465
+ help: "Redis-protocol URL with the password embedded. Published on port 6379. Switch to Internal for apps on the same project network."
6466
+ },
6467
+ {
6468
+ id: "password",
6469
+ label: "Password",
6470
+ source: "env:valkey:VALKEY_PASSWORD",
6471
+ secret: true,
6472
+ envKey: "REDIS_PASSWORD",
6473
+ width: "half"
6474
+ }
6475
+ ]
6476
+ },
6477
+ provides: [
6478
+ {
6479
+ id: "redis",
6480
+ outputRefs: [
6481
+ "url"
6482
+ ],
6483
+ category: "database"
6484
+ }
6485
+ ],
6486
+ endpoints: [
6487
+ {
6488
+ service: "valkey",
6489
+ port: 6379,
6490
+ label: "Redis / Valkey",
6491
+ kind: "tcp"
6492
+ }
6493
+ ]
6494
+ },
6495
+ {
6496
+ available: true,
6497
+ verified: true,
6498
+ id: "umami",
6499
+ name: "Umami",
6500
+ description: "Privacy-friendly, cookie-free web analytics \u2014 a self-hosted alternative to Google Analytics. Add one script tag to your site and see traffic in a clean dashboard you own.",
6501
+ kind: "template",
6502
+ logo: "umami",
6503
+ category: "analytics",
6504
+ tags: [
6505
+ "analytics",
6506
+ "web-analytics",
6507
+ "privacy",
6508
+ "statistics"
6509
+ ],
6510
+ framework: "docker-compose",
6511
+ services: [
6512
+ {
6513
+ name: "umami",
6514
+ image: "ghcr.io/umami-software/umami:postgresql-v2.19.0",
6515
+ exposedPort: 3e3,
6516
+ exposed: true,
6517
+ ports: [
6518
+ "3000:3000"
6519
+ ],
6520
+ routes: [
6521
+ {
6522
+ port: 3e3
6523
+ }
6524
+ ],
6525
+ dependsOn: [
6526
+ "umami-db"
6527
+ ],
6528
+ environment: {
6529
+ DATABASE_TYPE: "postgresql",
6530
+ DATABASE_URL: "postgresql://umami:{{config:POSTGRES_PASSWORD}}@umami-db:5432/umami"
6531
+ },
6532
+ secretEnv: [
6533
+ "DATABASE_URL"
6534
+ ],
6535
+ healthcheck: {
6536
+ test: [
6537
+ "CMD-SHELL",
6538
+ "wget -q -O - http://127.0.0.1:3000/api/heartbeat 2>&1 | grep -q ok"
6539
+ ],
6540
+ interval: "10s",
6541
+ timeout: "5s",
6542
+ retries: 10,
6543
+ startPeriod: "30s"
6544
+ },
6545
+ restart: "unless-stopped"
6546
+ },
6547
+ {
6548
+ name: "umami-db",
6549
+ image: "postgres:16-alpine",
6550
+ environment: {
6551
+ POSTGRES_DB: "umami",
6552
+ POSTGRES_USER: "umami"
6553
+ },
6554
+ secretEnv: [
6555
+ "POSTGRES_PASSWORD"
6556
+ ],
6557
+ volumes: [
6558
+ "umami_db:/var/lib/postgresql/data"
6559
+ ],
6560
+ healthcheck: {
6561
+ test: [
6562
+ "CMD-SHELL",
6563
+ "pg_isready -U umami -d umami"
6564
+ ],
6565
+ interval: "10s",
6566
+ timeout: "5s",
6567
+ retries: 5,
6568
+ startPeriod: "10s"
6569
+ },
5077
6570
  restart: "unless-stopped"
5078
6571
  }
5079
6572
  ],
5080
6573
  configFields: [
5081
6574
  {
5082
- key: "SPRING_SECURITY_USER_PASSWORD",
5083
- service: "kafka-ui",
5084
- label: "Kafka UI password",
5085
- help: "Auto-generated. The password to sign in to the Kafka UI web console (username: admin).",
6575
+ key: "POSTGRES_PASSWORD",
6576
+ service: "umami-db",
6577
+ label: "Database password",
6578
+ help: "Auto-generated. The password for Umami's Postgres database.",
6579
+ generate: "secret",
6580
+ secret: true
6581
+ },
6582
+ {
6583
+ key: "APP_SECRET",
6584
+ service: "umami",
6585
+ label: "App secret",
6586
+ help: "Auto-generated. Signs Umami's session tokens \u2014 keep it stable.",
5086
6587
  generate: "secret",
5087
6588
  secret: true
5088
6589
  }
@@ -5091,123 +6592,205 @@ COMMIT;
5091
6592
  kind: "schema"
5092
6593
  },
5093
6594
  connection: {
5094
- title: "Connect to Kafka",
5095
- description: "Open the Kafka UI to browse topics and messages, or point a client at the broker from another service in this project.",
6595
+ title: "Connect to Umami",
6596
+ description: "Open the Umami dashboard and sign in, then add your website to get its tracking script.",
6597
+ guide: {
6598
+ intro: "Your project gets a self-hosted Umami analytics dashboard.",
6599
+ useHint: "Sign in, add your website under Settings \u2192 Websites, and paste the tracking snippet into your site's <head>."
6600
+ },
6601
+ firstLogin: {
6602
+ username: "admin",
6603
+ password: "umami",
6604
+ note: "Change this password immediately from Settings \u2192 Profile after your first sign-in."
6605
+ },
5096
6606
  outputs: [
5097
6607
  {
5098
- id: "ui",
5099
- label: "Kafka UI",
5100
- source: "publicUrl:kafka-ui",
6608
+ id: "dashboard",
6609
+ label: "Dashboard",
6610
+ source: "publicUrl:umami",
5101
6611
  kind: "url",
6612
+ envKey: "UMAMI_URL",
6613
+ recommended: true,
5102
6614
  help: "Sign in with the username + password below."
5103
- },
5104
- {
5105
- id: "uiUser",
5106
- label: "UI username",
5107
- source: "env:kafka-ui:SPRING_SECURITY_USER_NAME"
5108
- },
5109
- {
5110
- id: "uiPassword",
5111
- label: "UI password",
5112
- source: "env:kafka-ui:SPRING_SECURITY_USER_PASSWORD",
5113
- secret: true
5114
- },
5115
- {
5116
- id: "broker",
5117
- label: "Bootstrap server (internal)",
5118
- source: "template:kafka:9092",
5119
- help: "Reachable as kafka:9092 from other services in this project \u2014 connect an app to it."
5120
6615
  }
5121
6616
  ]
5122
6617
  },
5123
6618
  endpoints: [
5124
6619
  {
5125
- service: "kafka-ui",
5126
- port: 8080,
5127
- label: "Kafka UI",
6620
+ service: "umami",
6621
+ port: 3e3,
6622
+ label: "Dashboard",
5128
6623
  kind: "http"
6624
+ },
6625
+ {
6626
+ service: "umami-db",
6627
+ port: 5432,
6628
+ label: "Database",
6629
+ kind: "tcp"
5129
6630
  }
5130
6631
  ]
5131
6632
  },
5132
6633
  {
5133
6634
  available: true,
5134
6635
  verified: true,
5135
- id: "qdrant",
5136
- name: "Qdrant",
5137
- description: "High-performance vector database for AI search, RAG, and embeddings \u2014 with the built-in Qdrant Web UI for browsing collections and running queries.",
6636
+ unlisted: true,
6637
+ id: "webmail",
6638
+ name: "Openship Webmail",
6639
+ description: "A fast, self-hosted webmail client. Point it at any IMAP/SMTP mailbox \u2014 an Openship Mail server, Gmail, Fastmail, or your company's \u2014 and read, search and send from a clean UI on your own domain. Nothing is stored outside your server.",
5138
6640
  kind: "template",
5139
- logo: "qdrant",
5140
- category: "backend",
6641
+ logo: "webmail",
6642
+ category: "mail",
5141
6643
  tags: [
5142
- "database",
5143
- "vector",
5144
- "ai",
5145
- "search",
5146
- "rag",
5147
- "embeddings"
6644
+ "mail",
6645
+ "webmail",
6646
+ "email",
6647
+ "imap",
6648
+ "smtp"
5148
6649
  ],
5149
6650
  framework: "docker-compose",
6651
+ repository: "https://github.com/Mail-0/Zero",
5150
6652
  services: [
5151
6653
  {
5152
- name: "qdrant",
5153
- image: "qdrant/qdrant:v1.18.3",
5154
- ports: [
5155
- "6333:6333"
5156
- ],
5157
- exposedPort: 6333,
6654
+ name: "webmail",
6655
+ image: "ghcr.io/oblien/openship-webmail:latest",
6656
+ exposedPort: 4080,
5158
6657
  exposed: true,
5159
- routes: [
5160
- {
5161
- port: 6333
5162
- }
5163
- ],
5164
- secretEnv: [
5165
- "QDRANT__SERVICE__API_KEY"
5166
- ],
5167
6658
  volumes: [
5168
- "qdrant_storage:/qdrant/storage"
6659
+ "webmail_data:/data"
5169
6660
  ],
6661
+ environment: {
6662
+ TRUSTED_ORIGINS: "{{publicUrl:webmail}}"
6663
+ },
5170
6664
  restart: "unless-stopped"
5171
6665
  }
5172
6666
  ],
5173
6667
  configFields: [
5174
6668
  {
5175
- key: "QDRANT__SERVICE__API_KEY",
5176
- service: "qdrant",
5177
- label: "API key",
5178
- help: "Auto-generated. Required as the `api-key` header on every request, and to open the Web UI.",
6669
+ key: "SESSION_ENCRYPTION_KEY",
6670
+ service: "webmail",
6671
+ label: "Session encryption key",
6672
+ help: "Auto-generated (32 random bytes, hex). Encrypts each signed-in user's IMAP password at rest. Changing it signs everyone out; they just sign in again.",
6673
+ generate: "secret",
6674
+ secret: true
6675
+ },
6676
+ {
6677
+ key: "BRANDING_ADMIN_TOKEN",
6678
+ service: "webmail",
6679
+ label: "Branding admin token",
6680
+ help: "Auto-generated. Lets Openship write this webmail's white-label login page (logo, name, colors) over its admin API. Nothing else accepts it.",
5179
6681
  generate: "secret",
5180
6682
  secret: true
5181
6683
  }
5182
6684
  ],
6685
+ settings: [
6686
+ {
6687
+ id: "backend",
6688
+ label: "Mail backend",
6689
+ description: "Which IMAP/SMTP server this webmail talks to. Leave the hosts blank and each address resolves to mail.<its own domain> at sign-in \u2014 the convention Openship Mail and iRedMail install. Set them to pin every mailbox to one server. Users can still override host and port on the sign-in screen.",
6690
+ fields: [
6691
+ {
6692
+ key: "DEFAULT_IMAP_HOST",
6693
+ service: "webmail",
6694
+ label: "IMAP host",
6695
+ help: "Where mail is read from. Blank \u2192 mail.<domain of the address signing in>.",
6696
+ type: "text",
6697
+ placeholder: "mail.example.com",
6698
+ installStep: true,
6699
+ requiresRedeploy: true
6700
+ },
6701
+ {
6702
+ key: "DEFAULT_SMTP_HOST",
6703
+ service: "webmail",
6704
+ label: "SMTP host",
6705
+ help: "Where mail is sent through. Blank \u2192 mail.<domain of the address signing in>.",
6706
+ type: "text",
6707
+ placeholder: "mail.example.com",
6708
+ installStep: true,
6709
+ requiresRedeploy: true
6710
+ },
6711
+ {
6712
+ key: "DEFAULT_IMAP_PORT",
6713
+ service: "webmail",
6714
+ label: "IMAP port",
6715
+ help: "Blank \u2192 993 (IMAPS). TLS is always required.",
6716
+ type: "number",
6717
+ integer: true,
6718
+ min: 1,
6719
+ max: 65535,
6720
+ placeholder: "993",
6721
+ advanced: true,
6722
+ installStep: true,
6723
+ requiresRedeploy: true
6724
+ },
6725
+ {
6726
+ key: "DEFAULT_SMTP_PORT",
6727
+ service: "webmail",
6728
+ label: "SMTP port",
6729
+ help: "Blank \u2192 587 (submission, STARTTLS). Use 465 for implicit TLS \u2014 the port alone selects the mode, and TLS is required either way.",
6730
+ type: "number",
6731
+ integer: true,
6732
+ min: 1,
6733
+ max: 65535,
6734
+ placeholder: "587",
6735
+ advanced: true,
6736
+ installStep: true,
6737
+ requiresRedeploy: true
6738
+ }
6739
+ ]
6740
+ },
6741
+ {
6742
+ id: "access",
6743
+ label: "Access",
6744
+ description: "Where this webmail is reached from. Filled in from the hostname you route it on \u2014 override it only when something in front of it changes the origin the browser sees.",
6745
+ fields: [
6746
+ {
6747
+ key: "TRUSTED_ORIGINS",
6748
+ service: "webmail",
6749
+ label: "Trusted origins",
6750
+ help: "Comma-separated origins allowed to sign in \u2014 scheme + host, no path. Defaults to this app's own public URL. Set it when the app is served through a proxy Openship doesn't route, e.g. an Openship Mail server fronting mail.<domain> for a cloud deploy.",
6751
+ type: "text",
6752
+ placeholder: "https://mail.example.com",
6753
+ advanced: true,
6754
+ requiresRedeploy: true
6755
+ }
6756
+ ]
6757
+ }
6758
+ ],
5183
6759
  management: {
5184
6760
  kind: "schema"
5185
6761
  },
5186
6762
  connection: {
5187
- title: "Connect to Qdrant",
5188
- description: "Open the Qdrant Web UI in your browser (it lives under /dashboard), or point a client at the REST API. Every request needs the API key below.",
6763
+ title: "Sign in to Webmail",
6764
+ description: "Open the webmail URL and sign in with a full email address and its mailbox password. There is no separate account to create \u2014 the mailbox on your IMAP server is the account.",
6765
+ guide: {
6766
+ intro: "Your project runs its own webmail client. It holds no mail: every message is read live from IMAP, and only encrypted session credentials and per-user settings live on disk.",
6767
+ useHint: "Webmail is domain-only by design: sessions use Secure cookies, so sign-in never completes over plain http, and the container publishes nothing to the host \u2014 the edge reaches it on loopback. Point a hostname at it and TLS is issued automatically.",
6768
+ defaultMode: "public"
6769
+ },
5189
6770
  outputs: [
5190
6771
  {
5191
- id: "ui",
5192
- label: "Qdrant Web UI",
5193
- source: "publicUrl:qdrant",
6772
+ id: "webmail",
6773
+ label: "Webmail",
6774
+ source: "publicUrl:webmail",
5194
6775
  kind: "url",
5195
- help: "The dashboard is at this URL under /dashboard. Paste the API key when it asks."
5196
- },
5197
- {
5198
- id: "restUrl",
5199
- label: "REST API",
5200
- source: "publicUrl:qdrant"
5201
- },
5202
- {
5203
- id: "apiKey",
5204
- label: "API key",
5205
- source: "env:qdrant:QDRANT__SERVICE__API_KEY",
5206
- secret: true,
5207
- help: "Send it as the `api-key` header on every request."
6776
+ envKey: "WEBMAIL_URL",
6777
+ recommended: true,
6778
+ help: "Sign in with an email address on your mail server and its mailbox password."
5208
6779
  }
5209
6780
  ]
5210
- }
6781
+ },
6782
+ endpoints: [
6783
+ {
6784
+ service: "webmail",
6785
+ port: 4080,
6786
+ label: "Webmail",
6787
+ kind: "http",
6788
+ defaultMode: "domain",
6789
+ allowedModes: [
6790
+ "domain"
6791
+ ]
6792
+ }
6793
+ ]
5211
6794
  }
5212
6795
  ]
5213
6796
  };
@@ -19749,9 +21332,19 @@ function compareSemver(a, b) {
19749
21332
  }
19750
21333
 
19751
21334
  // ../../packages/core/src/apps/schema.ts
21335
+ var serviceBuild = external_exports.object({
21336
+ /** Full Dockerfile contents (inline). COPY sources are `<service-name>/<path>`. */
21337
+ dockerfile: external_exports.string(),
21338
+ /** Extra build-context files (COPY targets, scripts); `path` is relative to
21339
+ * this service's subdir — COPY it as `<service-name>/<path>`. */
21340
+ files: external_exports.array(external_exports.object({ path: external_exports.string(), content: external_exports.string() })).optional()
21341
+ });
19752
21342
  var serviceSpec = external_exports.object({
19753
21343
  name: external_exports.string(),
19754
- image: external_exports.string(),
21344
+ /** Prebuilt image to pull. Exactly one of `image`/`build` must be set per service. */
21345
+ image: external_exports.string().optional(),
21346
+ /** Inline build context (see `serviceBuild`) — mutually exclusive with `image`. */
21347
+ build: serviceBuild.optional(),
19755
21348
  ports: external_exports.array(external_exports.string()).optional(),
19756
21349
  exposedPort: external_exports.number().optional(),
19757
21350
  routes: external_exports.array(external_exports.object({ port: external_exports.number(), slugSuffix: external_exports.string().optional() })).optional(),
@@ -19931,7 +21524,24 @@ var appTemplateSchema = external_exports.object({
19931
21524
  provides: external_exports.array(provides).optional(),
19932
21525
  requires: external_exports.array(requires).optional(),
19933
21526
  available: external_exports.boolean().optional(),
21527
+ // What the app needs from the machine, matched against the host's real capacity
21528
+ // before it installs (see `fitsCapacity`). Only what a host can be PROBED for —
21529
+ // RAM and vCPU — because a requirement we can't verify is a refusal based on a
21530
+ // guess. Absent ⇒ no preflight, which is right for almost every app.
21531
+ minResources: external_exports.object({
21532
+ memoryMb: external_exports.number().positive().optional(),
21533
+ cpuCores: external_exports.number().positive().optional()
21534
+ }).optional(),
19934
21535
  verified: external_exports.boolean().optional(),
21536
+ // Hidden from the browsable catalog while staying fully installable — for an app
21537
+ // reached through another app's wizard rather than the grid. NOT `available:
21538
+ // false`, which is a refusal ("app-not-available"), not a listing choice.
21539
+ unlisted: external_exports.boolean().optional(),
21540
+ // How the app is hosted, for an honest catalog badge + wizard notice. Absent ⇒
21541
+ // "self-hosted" (the default: runs on the user's own server). "experimental" =
21542
+ // self-host that runs but isn't production-grade (heavy/unsupported). Purely
21543
+ // presentational; additive. Every catalog app is something Openship runs.
21544
+ hosting: external_exports.enum(["self-hosted", "experimental"]).optional(),
19935
21545
  // Versioning / compat gate (see parseAppTemplate).
19936
21546
  schemaVersion: external_exports.number().optional(),
19937
21547
  minEngine: external_exports.string().optional(),
@@ -19975,6 +21585,17 @@ var appTemplateSchema = external_exports.object({
19975
21585
  if (data.flowHref !== void 0 && !data.flowHref.startsWith("/")) {
19976
21586
  ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["flowHref"], message: "flowHref must be an internal route starting with /" });
19977
21587
  }
21588
+ (data.services ?? []).forEach((s, i) => {
21589
+ const hasImage = typeof s.image === "string" && s.image.length > 0;
21590
+ const hasBuild = !!s.build;
21591
+ if (hasImage === hasBuild) {
21592
+ ctx.addIssue({
21593
+ code: external_exports.ZodIssueCode.custom,
21594
+ path: ["services", i],
21595
+ message: `service "${s.name}" must set exactly one of image|build`
21596
+ });
21597
+ }
21598
+ });
19978
21599
  const outputIds = new Set((data.connection?.outputs ?? []).map((o) => o.id));
19979
21600
  (data.provides ?? []).forEach((p, i) => {
19980
21601
  p.outputRefs.forEach((ref, j) => {
@@ -20065,6 +21686,13 @@ var AUDIT_EVENTS = {
20065
21686
  tone: "warning",
20066
21687
  description: "A deployment was stopped before it finished."
20067
21688
  },
21689
+ "deployment.no_changes": {
21690
+ category: "deployments",
21691
+ action: "found no changes to deploy for",
21692
+ label: "No changes to deploy",
21693
+ tone: "info",
21694
+ description: "A redeploy found every service already up to date, so the current release stayed live."
21695
+ },
20068
21696
  "deployment:write": {
20069
21697
  category: "deployments",
20070
21698
  action: "started a deploy for",
@@ -20182,6 +21810,13 @@ var AUDIT_EVENTS = {
20182
21810
  tone: "success",
20183
21811
  description: "An outage ended and the container is healthy again."
20184
21812
  },
21813
+ "service.exec": {
21814
+ category: "apps",
21815
+ action: "ran a command in",
21816
+ label: "Command executed in container",
21817
+ tone: "warning",
21818
+ description: "A shell command was run inside a service's container. The command is recorded; its output is not, because output is unbounded and may contain secrets the command read."
21819
+ },
20185
21820
  /* ---------------- Domains & SSL ---------------- */
20186
21821
  "domain.added": {
20187
21822
  category: "domains",
@@ -20209,12 +21844,30 @@ var AUDIT_EVENTS = {
20209
21844
  tone: "warning",
20210
21845
  description: "DNS records for this domain don't point here yet."
20211
21846
  },
21847
+ // The name the AUDIT row carries — `verify` and `verifyStream` both write it,
21848
+ // while the notification namespace uses `verification_failed` above. Both are
21849
+ // catalogued rather than renamed: the event type is a stored, queryable column,
21850
+ // so a rename would orphan every row already written on an operator's instance.
21851
+ "domain.verify_failed": {
21852
+ category: "domains",
21853
+ action: "failed to verify the domain",
21854
+ label: "Domain verification failed",
21855
+ tone: "warning",
21856
+ description: "DNS records for this domain don't point here yet."
21857
+ },
20212
21858
  "domain.set_primary": {
20213
21859
  category: "domains",
20214
21860
  action: "made primary the domain",
20215
21861
  label: "Primary domain changed",
20216
21862
  description: "The domain other addresses now redirect to."
20217
21863
  },
21864
+ "domain.dns_provisioned": {
21865
+ category: "domains",
21866
+ action: "provisioned DNS for",
21867
+ label: "DNS records provisioned",
21868
+ tone: "info",
21869
+ description: "Openship wrote this domain's records at the connected DNS provider. The counts of applied and failed records are in the details."
21870
+ },
20218
21871
  "domain.cert_uploaded": {
20219
21872
  category: "domains",
20220
21873
  action: "uploaded a certificate for",
@@ -20228,6 +21881,20 @@ var AUDIT_EVENTS = {
20228
21881
  tone: "danger",
20229
21882
  description: "Automatic renewal failed \u2014 the certificate will expire unless this is fixed."
20230
21883
  },
21884
+ "dns_credential.connected": {
21885
+ category: "domains",
21886
+ action: "connected the DNS provider",
21887
+ label: "DNS provider connected",
21888
+ tone: "info",
21889
+ description: "An API token that lets Openship write this organization's DNS records was stored. The token itself is never recorded."
21890
+ },
21891
+ "dns_credential.disconnected": {
21892
+ category: "domains",
21893
+ action: "disconnected the DNS provider",
21894
+ label: "DNS provider disconnected",
21895
+ tone: "warning",
21896
+ description: "Openship can no longer write DNS records for domains in that provider's zones; records already written are left in place."
21897
+ },
20231
21898
  "domain:write": {
20232
21899
  category: "domains",
20233
21900
  action: "changed the domain",
@@ -20258,6 +21925,13 @@ var AUDIT_EVENTS = {
20258
21925
  label: "Server removed",
20259
21926
  tone: "danger"
20260
21927
  },
21928
+ "server.exec": {
21929
+ category: "servers",
21930
+ action: "ran a command on the server",
21931
+ label: "Command executed",
21932
+ tone: "warning",
21933
+ description: "A shell command was run on the host. The command is recorded; its output is not, because output is unbounded and may contain secrets the command read."
21934
+ },
20261
21935
  "server.unreachable": {
20262
21936
  category: "servers",
20263
21937
  action: "reported unreachable",
@@ -20423,6 +22097,20 @@ var AUDIT_EVENTS = {
20423
22097
  tone: "warning",
20424
22098
  description: "An existing grant was overwritten with a different scope."
20425
22099
  },
22100
+ "mcp.authorized": {
22101
+ category: "members",
22102
+ action: "authorized the MCP client",
22103
+ label: "MCP client authorized",
22104
+ tone: "info",
22105
+ description: "An AI agent was connected and given a scope to act within."
22106
+ },
22107
+ "mcp.scope_changed": {
22108
+ category: "members",
22109
+ action: "changed the access of the MCP client",
22110
+ label: "MCP access changed",
22111
+ tone: "warning",
22112
+ description: "A connected agent's scope was edited. It takes effect on the agent's next request \u2014 no reconnect."
22113
+ },
20426
22114
  "grant.materialized": {
20427
22115
  category: "members",
20428
22116
  action: "received their pending permissions,",
@@ -20700,6 +22388,18 @@ var AUDIT_EVENTS = {
20700
22388
  label: "Backup failed",
20701
22389
  tone: "danger"
20702
22390
  },
22391
+ "backup_restore.completed": {
22392
+ category: "system",
22393
+ action: "restored",
22394
+ label: "Restore completed",
22395
+ tone: "success"
22396
+ },
22397
+ "backup_restore.failed": {
22398
+ category: "system",
22399
+ action: "failed to restore",
22400
+ label: "Restore failed",
22401
+ tone: "danger"
22402
+ },
20703
22403
  "backup.webhook.fired": {
20704
22404
  category: "system",
20705
22405
  action: "triggered a backup of",
@@ -20750,6 +22450,316 @@ var EVENT_TYPES_BY_CATEGORY = (() => {
20750
22450
  return map2;
20751
22451
  })();
20752
22452
 
22453
+ // ../../packages/core/src/access-grants.ts
22454
+ var ORG_SINGLETON_RESOURCE_TYPES = [
22455
+ "billing",
22456
+ "audit",
22457
+ "analytics",
22458
+ "github",
22459
+ "permissions",
22460
+ "settings",
22461
+ "job",
22462
+ "cloud",
22463
+ "terminal",
22464
+ "notifications",
22465
+ "updates"
22466
+ ];
22467
+ var ORG_SINGLETON_SET = new Set(ORG_SINGLETON_RESOURCE_TYPES);
22468
+ function isOrgSingletonResourceType(type) {
22469
+ return ORG_SINGLETON_SET.has(type);
22470
+ }
22471
+ var GRANTABLE_RESOURCE_TYPES = [
22472
+ "project",
22473
+ // Authorized by canUseGitHubRepo, NOT by checkPermission — see github-access.ts.
22474
+ "github_installation",
22475
+ "github_repository",
22476
+ "server",
22477
+ "mail_server",
22478
+ "backup_destination",
22479
+ // Platform features, granted at resourceId "*".
22480
+ "job",
22481
+ "notifications",
22482
+ "analytics",
22483
+ "updates",
22484
+ "settings",
22485
+ "cloud",
22486
+ "audit",
22487
+ "billing"
22488
+ ];
22489
+ var GRANTABLE_SET = new Set(GRANTABLE_RESOURCE_TYPES);
22490
+ var RESOURCE_GRANT_TYPES = GRANTABLE_RESOURCE_TYPES.filter((t) => !isOrgSingletonResourceType(t));
22491
+ var PLATFORM_GRANT_TYPES = GRANTABLE_RESOURCE_TYPES.filter((t) => isOrgSingletonResourceType(t));
22492
+ var PRIMARY_GRANT_TYPES = [
22493
+ "project",
22494
+ "github_installation",
22495
+ "github_repository"
22496
+ ];
22497
+ var PRIMARY_SET = new Set(PRIMARY_GRANT_TYPES);
22498
+ var INFRASTRUCTURE_GRANT_TYPES = RESOURCE_GRANT_TYPES.filter((t) => !PRIMARY_SET.has(t));
22499
+ var SENSITIVE_GRANT_TYPES = ["billing", "audit"];
22500
+ var SENSITIVE_SET = new Set(SENSITIVE_GRANT_TYPES);
22501
+ var SELF_HOSTED_ONLY_GRANT_TYPES = [
22502
+ "server",
22503
+ "mail_server",
22504
+ // The jobs router is `localOnly`, so on the hosted control plane every job route
22505
+ // 404s regardless of the grant.
22506
+ "job"
22507
+ ];
22508
+ var CLOUD_ONLY_GRANT_TYPES = ["billing"];
22509
+ var SELF_HOSTED_ONLY_SET = new Set(SELF_HOSTED_ONLY_GRANT_TYPES);
22510
+ var CLOUD_ONLY_SET = new Set(CLOUD_ONLY_GRANT_TYPES);
22511
+
22512
+ // ../../packages/core/src/host-profile.ts
22513
+ var FAMILY_BY_DISTRO = {
22514
+ ubuntu: "debian",
22515
+ debian: "debian",
22516
+ rhel: "rhel",
22517
+ centos: "rhel",
22518
+ fedora: "rhel",
22519
+ rocky: "rhel",
22520
+ almalinux: "rhel",
22521
+ oracle: "rhel",
22522
+ amzn: "rhel",
22523
+ alpine: "alpine",
22524
+ opensuse: "suse",
22525
+ arch: "arch"
22526
+ };
22527
+ var DISTRO_BY_ID = {
22528
+ ubuntu: "ubuntu",
22529
+ debian: "debian",
22530
+ rhel: "rhel",
22531
+ "rhel server": "rhel",
22532
+ redhat: "rhel",
22533
+ centos: "centos",
22534
+ fedora: "fedora",
22535
+ rocky: "rocky",
22536
+ almalinux: "almalinux",
22537
+ alma: "almalinux",
22538
+ ol: "oracle",
22539
+ // Oracle Linux. The ID that looks like nothing.
22540
+ oracle: "oracle",
22541
+ amzn: "amzn",
22542
+ alpine: "alpine",
22543
+ opensuse: "opensuse",
22544
+ "opensuse-leap": "opensuse",
22545
+ "opensuse-tumbleweed": "opensuse",
22546
+ sles: "opensuse",
22547
+ arch: "arch",
22548
+ archarm: "arch"
22549
+ };
22550
+ var FAMILY_BY_ID_LIKE = {
22551
+ debian: "debian",
22552
+ ubuntu: "debian",
22553
+ rhel: "rhel",
22554
+ fedora: "rhel",
22555
+ centos: "rhel",
22556
+ "rhel-fedora": "rhel",
22557
+ alpine: "alpine",
22558
+ suse: "suse",
22559
+ opensuse: "suse",
22560
+ arch: "arch"
22561
+ };
22562
+ function parseOsRelease(text) {
22563
+ const out = {};
22564
+ for (const rawLine of text.split("\n")) {
22565
+ const line = rawLine.trim();
22566
+ if (!line || line.startsWith("#")) continue;
22567
+ const eq = line.indexOf("=");
22568
+ if (eq <= 0) continue;
22569
+ const key = line.slice(0, eq).trim().toUpperCase();
22570
+ let value = line.slice(eq + 1).trim();
22571
+ if (value.length >= 2 && (value.startsWith('"') || value.startsWith("'"))) {
22572
+ const quote = value[0];
22573
+ if (value.endsWith(quote)) value = value.slice(1, -1);
22574
+ }
22575
+ out[key] = value;
22576
+ }
22577
+ return out;
22578
+ }
22579
+ function classifyDistro(id, idLike) {
22580
+ const normalizedId = id?.trim().toLowerCase() ?? "";
22581
+ const distro = DISTRO_BY_ID[normalizedId] ?? "unknown";
22582
+ if (distro !== "unknown") return { distro, family: FAMILY_BY_DISTRO[distro] };
22583
+ for (const token of (idLike ?? "").trim().toLowerCase().split(/\s+/)) {
22584
+ const family = token ? FAMILY_BY_ID_LIKE[token] : void 0;
22585
+ if (family) return { distro: "unknown", family };
22586
+ }
22587
+ return { distro: "unknown", family: "unknown" };
22588
+ }
22589
+ function parseSystemOs(uname) {
22590
+ const value = uname?.trim().toLowerCase();
22591
+ if (value === "linux") return "linux";
22592
+ if (value === "darwin") return "darwin";
22593
+ return "unknown";
22594
+ }
22595
+ function parseSystemArch(uname) {
22596
+ const value = uname?.trim().toLowerCase();
22597
+ if (!value) return "unknown";
22598
+ if (["x86_64", "amd64", "x64"].includes(value)) return "amd64";
22599
+ if (["aarch64", "arm64", "armv8l"].includes(value)) return "arm64";
22600
+ return "unknown";
22601
+ }
22602
+ var SUPPORTED = { supported: true, unsupportedReason: null };
22603
+ function refuse(reason) {
22604
+ return { supported: false, unsupportedReason: reason };
22605
+ }
22606
+ function assessHostSupport(facts) {
22607
+ if (facts.probeError) {
22608
+ return refuse(
22609
+ `Openship could not measure this host: the probe never finished, so nothing below is a fact about the box. A loaded machine, a dropped SSH channel or a shell that cannot run a script all look like this. Openship's own diagnostic: ${JSON.stringify(facts.probeError)}`
22610
+ );
22611
+ }
22612
+ if (facts.forcedCommand) {
22613
+ return refuse(
22614
+ `This host answered Openship's probe with something other than the probe's output, so nothing about it could be measured. An sshd forced command (\`ForceCommand\`, or \`command=\` in authorized_keys) is the usual cause \u2014 AWS AMIs ship one on root's key and expect you to connect as ec2-user instead. It said: ${JSON.stringify(facts.forcedCommand)}`
22615
+ );
22616
+ }
22617
+ if (facts.os === "unknown") {
22618
+ const observed = facts.osRaw?.trim();
22619
+ return refuse(
22620
+ observed ? `Openship drives Linux hosts (and macOS locally); this one reports \`uname -s\` as ${JSON.stringify(observed)}, which it has no provisioning path for.` : "Could not determine this host's operating system (`uname -s` returned nothing usable)."
22621
+ );
22622
+ }
22623
+ if (facts.arch === "unknown") {
22624
+ const observed = facts.archRaw?.trim();
22625
+ return refuse(
22626
+ observed ? `Openship has no build for this host's CPU architecture (\`uname -m\` reported ${JSON.stringify(observed)}), and it will not guess \u2014 every release artifact it downloads is architecture-specific. x86_64/amd64 and aarch64/arm64 are supported.` : "Could not determine this host's CPU architecture (`uname -m` returned nothing usable), and Openship will not guess \u2014 the release artifacts it downloads are architecture-specific."
22627
+ );
22628
+ }
22629
+ if (facts.os === "darwin") return SUPPORTED;
22630
+ switch (facts.distroFamily) {
22631
+ case "debian":
22632
+ case "rhel":
22633
+ case "alpine":
22634
+ break;
22635
+ case "suse":
22636
+ return refuse(
22637
+ `Openship doesn't drive openSUSE/SLES hosts${idSuffix(facts.distroId)} \u2014 it has no zypper provisioning path. Debian/Ubuntu, the RHEL family (including Amazon Linux) and Alpine are supported.`
22638
+ );
22639
+ case "arch":
22640
+ return refuse(
22641
+ `Openship doesn't drive Arch hosts${idSuffix(facts.distroId)} \u2014 it has no pacman provisioning path. Debian/Ubuntu, the RHEL family (including Amazon Linux) and Alpine are supported.`
22642
+ );
22643
+ case "unknown":
22644
+ if (facts.packageManager === "none") {
22645
+ return refuse(
22646
+ `Unrecognized Linux distribution${idSuffix(facts.distroId)} with no usable package manager. Openship could not match it to a supported family from its os-release ID or ID_LIKE, and found none of apt-get, dnf, yum or apk. Debian/Ubuntu, the RHEL family (including Amazon Linux) and Alpine are supported.`
22647
+ );
22648
+ }
22649
+ return SUPPORTED;
22650
+ default: {
22651
+ const unwired = facts.distroFamily;
22652
+ return refuse(
22653
+ `Openship has no provisioning path for this host's Linux family (${JSON.stringify(String(unwired))})${idSuffix(facts.distroId)}. Debian/Ubuntu, the RHEL family (including Amazon Linux) and Alpine are supported.`
22654
+ );
22655
+ }
22656
+ }
22657
+ if (facts.packageManager === "none") {
22658
+ return refuse(
22659
+ `No usable package manager found on this host (expected one of apt-get, dnf, yum or apk for a ${facts.distroFamily}-family system).`
22660
+ );
22661
+ }
22662
+ return SUPPORTED;
22663
+ }
22664
+ function idSuffix(distroId) {
22665
+ return distroId ? ` (os-release ID=${JSON.stringify(distroId)})` : "";
22666
+ }
22667
+
22668
+ // ../../packages/adapters/src/system/errors.ts
22669
+ var SSH_AUTH_ERROR_PATTERNS = [
22670
+ "All configured authentication methods failed"
22671
+ ];
22672
+ var RETRYABLE_CONNECTION_ERROR_PATTERNS = [
22673
+ "ECONNRESET",
22674
+ "ECONNREFUSED",
22675
+ "ETIMEDOUT",
22676
+ "EHOSTUNREACH",
22677
+ "ENETUNREACH",
22678
+ "Timed out",
22679
+ "Not connected",
22680
+ "Connection lost",
22681
+ "read ECONNRESET",
22682
+ "Handshake failed",
22683
+ "keepalive timeout",
22684
+ "SSH connection closed before ready",
22685
+ // ssh2 emits "Channel open failure: open failed" / "Channel open failure: …"
22686
+ // when the SSH SERVER refuses a new channel - typically because the cached
22687
+ // connection has gone half-dead (peer-side LOGOUT, network blip,
22688
+ // remote sshd MaxSessions). The inner SshExecutor retries `exec`/
22689
+ // `streamExec` on this, but `pipeLocal`, `transferIn`, and any other
22690
+ // channel-bearing op bubble it up. Listing it here lets `withExecutor`
22691
+ // drop the dead connection and retry transparently - fixes the
22692
+ // "first redeploy fails, second click works" pattern.
22693
+ "Channel open failure",
22694
+ "open failed"
22695
+ // NOT "socket hang up": it is what an HTTP client reports for ANY far end that vanished
22696
+ // mid-request, including a permission-denied Docker socket and a wedged channel, neither
22697
+ // of which a fresh connection fixes. A match here makes `withExecutor` replay its whole
22698
+ // callback — and those callbacks install mail, create mailboxes and ensure the edge, so a
22699
+ // non-retryable cause becomes a repeated mutation. `core/connectivity.ts` labels it
22700
+ // without retrying, `api/lib/remote-state.ts` still maps it to a 503, and the bridge's
22701
+ // 502 carries the real reason — so leaving it out here costs no diagnosis.
22702
+ ];
22703
+ var SshDisconnectedError = class extends Error {
22704
+ constructor(message = "SSH connection lost") {
22705
+ super(message);
22706
+ this.name = "SshDisconnectedError";
22707
+ }
22708
+ };
22709
+ function isSshDisconnectedError(err) {
22710
+ return err instanceof SshDisconnectedError || err instanceof Error && err.name === "SshDisconnectedError";
22711
+ }
22712
+ function isSshAuthError(err) {
22713
+ if (!(err instanceof Error)) return false;
22714
+ return SSH_AUTH_ERROR_PATTERNS.some((pattern) => err.message.includes(pattern));
22715
+ }
22716
+ var HostChannelUnavailableError = class extends Error {
22717
+ code;
22718
+ constructor(code, message) {
22719
+ super(message);
22720
+ this.name = "HostChannelUnavailableError";
22721
+ this.code = code;
22722
+ }
22723
+ };
22724
+ function isRetryableRemoteConnectionError(err) {
22725
+ if (isSshDisconnectedError(err)) return true;
22726
+ if (!(err instanceof Error)) return false;
22727
+ return RETRYABLE_CONNECTION_ERROR_PATTERNS.some(
22728
+ (pattern) => err.message.includes(pattern)
22729
+ );
22730
+ }
22731
+ function isRemoteConnectionError(err) {
22732
+ return isSshAuthError(err) || isRetryableRemoteConnectionError(err);
22733
+ }
22734
+ var MAX_COMMAND_CHARS = 120;
22735
+ function commandForError(command) {
22736
+ const oneLine = command.trim().replace(/\s+/g, " ");
22737
+ return oneLine.length > MAX_COMMAND_CHARS ? `${oneLine.slice(0, MAX_COMMAND_CHARS)}\u2026 (${command.length} chars)` : oneLine;
22738
+ }
22739
+ function isRuntimeNotFoundError(err) {
22740
+ if (!err || typeof err !== "object") return false;
22741
+ const e = err;
22742
+ if (e.statusCode === 404) return true;
22743
+ return typeof e.message === "string" && /no such (container|image|volume|network)/i.test(e.message);
22744
+ }
22745
+
22746
+ // ../../packages/adapters/src/system/debug.ts
22747
+ function isTruthy(value) {
22748
+ if (!value) return false;
22749
+ return ["1", "true", "yes", "on", "debug"].includes(value.toLowerCase());
22750
+ }
22751
+ function isSystemDebugEnabled() {
22752
+ return isTruthy(process.env.SYSTEM_DEBUG_LOGS);
22753
+ }
22754
+ function systemDebug(scope, message) {
22755
+ if (!isSystemDebugEnabled()) return;
22756
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
22757
+ console.log(`[system-debug] ${timestamp} [${scope}] ${message}`);
22758
+ }
22759
+ function formatDuration(startMs) {
22760
+ return `${Date.now() - startMs}ms`;
22761
+ }
22762
+
20753
22763
  export {
20754
22764
  LANGUAGES,
20755
22765
  STACKS,
@@ -20758,8 +22768,14 @@ export {
20758
22768
  PACKAGE_ROOT_ONLY_EXCLUDES,
20759
22769
  packageManagerEnsureCommand,
20760
22770
  appVolumeTargets,
22771
+ parseComposeNamespace,
22772
+ ownsNetworkEndpoint,
22773
+ composeMountToSpec,
22774
+ composeMountIssues,
22775
+ composePortToSpec,
20761
22776
  isAwsS3Endpoint,
20762
22777
  DEFAULT_IMAGE_REGISTRY,
22778
+ shellQuote,
20763
22779
  shellSplitWords,
20764
22780
  commandToArgv,
20765
22781
  buildEdgeImageRef,
@@ -20769,6 +22785,7 @@ export {
20769
22785
  isValidEmail,
20770
22786
  formatBytes,
20771
22787
  withTimeout,
22788
+ wrapText,
20772
22789
  AppError,
20773
22790
  DeployError,
20774
22791
  safeErrorMessage,
@@ -20787,5 +22804,38 @@ export {
20787
22804
  PROXY_DIRECTIVES,
20788
22805
  sanitizeProxySettings,
20789
22806
  resolveProxyDirectives,
20790
- parseProxyValue
22807
+ parseProxyValue,
22808
+ answered,
22809
+ refused,
22810
+ FAMILY_BY_DISTRO,
22811
+ parseOsRelease,
22812
+ classifyDistro,
22813
+ parseSystemOs,
22814
+ parseSystemArch,
22815
+ assessHostSupport,
22816
+ firewallManager,
22817
+ firewallPersists,
22818
+ firewallAllowSteps,
22819
+ HOST_CHANNEL_UNAFFECTED,
22820
+ HOST_CHANNEL_BLOCKED,
22821
+ HOST_CHANNEL_SYMPTOM,
22822
+ HOST_CHANNEL_PROVISION_COMMAND,
22823
+ HOST_CHANNEL_RECHECK,
22824
+ HOST_CHANNEL_NOT_PROVISIONED,
22825
+ HOST_CHANNEL_UNPROVISIONED,
22826
+ HOST_CHANNEL_OPT_OUT,
22827
+ explainHostChannelCause,
22828
+ summarizeHostChannelCause,
22829
+ hostChannelRuleScope,
22830
+ hostFirewallRule,
22831
+ hostFirewallRuleRemoval,
22832
+ SshDisconnectedError,
22833
+ isSshAuthError,
22834
+ HostChannelUnavailableError,
22835
+ isRetryableRemoteConnectionError,
22836
+ isRemoteConnectionError,
22837
+ commandForError,
22838
+ isRuntimeNotFoundError,
22839
+ systemDebug,
22840
+ formatDuration
20791
22841
  };