@stacksjs/ts-cloud 0.7.7 → 0.7.10

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.
@@ -26,7 +26,7 @@ import {
26
26
  resolveSiteFramework,
27
27
  resolveSiteKind,
28
28
  resolveUiSource
29
- } from "./chunk-xzn0ntr0.js";
29
+ } from "./chunk-7pym4v5b.js";
30
30
  import {
31
31
  artifactKey,
32
32
  composeServerlessAppTemplate,
@@ -11016,7 +11016,7 @@ async function rollbackComputeSite(ctx, options) {
11016
11016
  const paths = releasePaths(appBase, options.to || "unused");
11017
11017
  const commands = [
11018
11018
  "set -uo pipefail",
11019
- ...buildRollbackScript(paths, options.to ? { to: options.to } : {}),
11019
+ ...buildRollbackScript(paths, { ...options.to ? { to: options.to } : {}, unitBase: `${ctx.slug}-${options.siteName}` }),
11020
11020
  `(cd ${PANTRY_PROJECT_DIR} && pantry restart php-fpm) 2>/dev/null || true`,
11021
11021
  `(cd ${paths.current} && eval "$(cd ${PANTRY_PROJECT_DIR} && pantry env 2>/dev/null)" && php artisan queue:restart) 2>/dev/null || true`
11022
11022
  ];
@@ -3496,15 +3496,11 @@ function buildActivateRelease(paths) {
3496
3496
  ];
3497
3497
  }
3498
3498
  function buildRollbackScript(paths, options = {}) {
3499
- if (options.to) {
3500
- const target = `${paths.releases}/${options.to}`;
3501
- return [
3502
- `[ -d ${target} ] || { echo "rollback target ${target} not found" >&2; exit 1; }`,
3503
- `ln -sfn ${target} ${paths.current}.tmp`,
3504
- `mv -Tf ${paths.current}.tmp ${paths.current}`
3505
- ];
3506
- }
3507
- return [
3499
+ const flip = options.to ? [
3500
+ `[ -d ${paths.releases}/${options.to} ] || { echo "rollback target ${paths.releases}/${options.to} not found" >&2; exit 1; }`,
3501
+ `ln -sfn ${paths.releases}/${options.to} ${paths.current}.tmp`,
3502
+ `mv -Tf ${paths.current}.tmp ${paths.current}`
3503
+ ] : [
3508
3504
  `TS_CLOUD_CURRENT=$(readlink -f ${paths.current} 2>/dev/null || true)`,
3509
3505
  `TS_CLOUD_PREV=$(ls -1dt ${paths.releases}/*/ 2>/dev/null | sed 's#/$##' | while read -r r; do ` + '[ "$(readlink -f "$r")" != "$TS_CLOUD_CURRENT" ] && { echo "$r"; break; }; done)',
3510
3506
  '[ -n "$TS_CLOUD_PREV" ] || { echo "no previous release to roll back to" >&2; exit 1; }',
@@ -3512,6 +3508,14 @@ function buildRollbackScript(paths, options = {}) {
3512
3508
  `mv -Tf ${paths.current}.tmp ${paths.current}`,
3513
3509
  'echo "rolled back to $TS_CLOUD_PREV"'
3514
3510
  ];
3511
+ if (!options.unitBase)
3512
+ return flip;
3513
+ const unitBase = options.unitBase;
3514
+ return [
3515
+ ...flip,
3516
+ `TS_CLOUD_RB_ID=$(basename "$(readlink -f ${paths.current})")`,
3517
+ `if [ -f /etc/systemd/system/${unitBase}@.service ]; then ` + `systemctl start "${unitBase}@\${TS_CLOUD_RB_ID}.service"; sleep 2; ` + `systemctl is-active --quiet "${unitBase}@\${TS_CLOUD_RB_ID}.service" || { echo "rolled-back release failed to start" >&2; exit 1; }; ` + `systemctl enable "${unitBase}@\${TS_CLOUD_RB_ID}.service" 2>/dev/null || true; ` + `systemctl list-units --plain --no-legend --type=service "${unitBase}@*.service" 2>/dev/null | awk '{print $1}' | grep -v "^${unitBase}@\${TS_CLOUD_RB_ID}.service$" | while read -r TS_CLOUD_U; do systemctl stop "$TS_CLOUD_U" 2>/dev/null || true; systemctl disable "$TS_CLOUD_U" 2>/dev/null || true; done; ` + `elif [ -f /etc/systemd/system/${unitBase}.service ]; then systemctl restart ${unitBase}.service; fi`
3518
+ ];
3515
3519
  }
3516
3520
  function buildPruneReleases(paths, keep = DEFAULT_KEEP_RELEASES) {
3517
3521
  const n = Math.max(1, keep);
@@ -3559,15 +3563,19 @@ function buildSiteDeployScript(options) {
3559
3563
  envEntries,
3560
3564
  port,
3561
3565
  keepReleases = DEFAULT_KEEP_RELEASES,
3562
- preStartCommands = []
3566
+ preStartCommands = [],
3567
+ healthCheckPath,
3568
+ healthGateSeconds = 5
3563
3569
  } = options;
3570
+ const zeroDowntime = options.zeroDowntime ?? port != null;
3564
3571
  const base = options.appDir ?? `/var/www/${siteName}`;
3565
3572
  const paths = releasePaths(base, releaseId);
3566
- const serviceName = `${slug}-${siteName}.service`;
3573
+ const unitBase = `${slug}-${siteName}`;
3574
+ const serviceName = `${unitBase}.service`;
3567
3575
  const sharedPaths = [".env"];
3568
3576
  const envFile = formatEnvFile(envEntries);
3569
3577
  const preStart = preStartCommands.length > 0 ? [`cd ${paths.release}`, ...preStartCommands] : [];
3570
- return [
3578
+ const stageRelease = [
3571
3579
  "set -euo pipefail",
3572
3580
  ...artifactFetch,
3573
3581
  ...buildEnsureReleaseLayout(paths, sharedPaths),
@@ -3579,7 +3587,47 @@ function buildSiteDeployScript(options) {
3579
3587
  "TS_CLOUD_ENV_EOF",
3580
3588
  `chmod 600 ${paths.shared}/.env`,
3581
3589
  ...buildLinkSharedPaths(paths, sharedPaths),
3582
- ...preStart,
3590
+ ...preStart
3591
+ ];
3592
+ if (zeroDowntime && port != null) {
3593
+ const instance = `${unitBase}@${releaseId}.service`;
3594
+ const gatePath = healthCheckPath ? healthCheckPath.startsWith("/") ? healthCheckPath : `/${healthCheckPath}` : null;
3595
+ const failGate = `{ echo "[ts-cloud] release ${releaseId} failed its health gate — previous release keeps serving" >&2; journalctl -u ${instance} -n 50 --no-pager >&2 || true; systemctl stop ${instance} 2>/dev/null || true; exit 1; }`;
3596
+ return [
3597
+ ...stageRelease,
3598
+ `cat > /etc/systemd/system/${unitBase}@.service <<'TS_CLOUD_UNIT_EOF'`,
3599
+ "[Unit]",
3600
+ `Description=${siteName} release %i (managed by ts-cloud)`,
3601
+ "After=network.target",
3602
+ "",
3603
+ "[Service]",
3604
+ "Type=simple",
3605
+ `WorkingDirectory=${paths.releases}/%i`,
3606
+ `ExecStart=${execStart}`,
3607
+ "Restart=always",
3608
+ "RestartSec=5",
3609
+ `EnvironmentFile=${paths.releases}/%i/.env`,
3610
+ `Environment=PORT=${port}`,
3611
+ "",
3612
+ "[Install]",
3613
+ "WantedBy=multi-user.target",
3614
+ "TS_CLOUD_UNIT_EOF",
3615
+ "systemctl daemon-reload",
3616
+ `TS_CLOUD_OLD_UNITS=$(systemctl list-units --plain --no-legend --type=service "${unitBase}@*.service" 2>/dev/null | awk '{print $1}' | grep -v "^${instance}$" || true)`,
3617
+ `if [ -f /etc/systemd/system/${serviceName} ] && systemctl is-active --quiet ${serviceName}; then echo "[ts-cloud] retiring pre-zero-downtime unit ${serviceName} (one-time restart cutover)"; systemctl stop ${serviceName}; fi`,
3618
+ `systemctl start ${instance}`,
3619
+ `for TS_CLOUD_I in $(seq 1 ${Math.max(1, healthGateSeconds)}); do sleep 1; systemctl is-active --quiet ${instance} || ${failGate}; done`,
3620
+ ...gatePath ? [`curl -sf -o /dev/null --max-time 10 "http://127.0.0.1:${port}${gatePath}" || ${failGate}`] : [],
3621
+ ...buildActivateRelease(paths),
3622
+ `systemctl enable ${instance} 2>/dev/null || true`,
3623
+ `for TS_CLOUD_U in \${TS_CLOUD_OLD_UNITS}; do systemctl stop "$TS_CLOUD_U" 2>/dev/null || true; systemctl disable "$TS_CLOUD_U" 2>/dev/null || true; done`,
3624
+ `systemctl list-unit-files --plain --no-legend "${unitBase}@*.service" 2>/dev/null | awk '{print $1}' | grep -v "^${instance}$" | while read -r TS_CLOUD_U; do systemctl disable "$TS_CLOUD_U" 2>/dev/null || true; done`,
3625
+ `if [ -f /etc/systemd/system/${serviceName} ]; then systemctl disable ${serviceName} 2>/dev/null || true; rm -f /etc/systemd/system/${serviceName}; systemctl daemon-reload; fi`,
3626
+ ...buildPruneReleases(paths, keepReleases)
3627
+ ];
3628
+ }
3629
+ return [
3630
+ ...stageRelease,
3583
3631
  `cat > /etc/systemd/system/${serviceName} <<'TS_CLOUD_UNIT_EOF'`,
3584
3632
  "[Unit]",
3585
3633
  `Description=${siteName} (managed by ts-cloud)`,
@@ -4367,7 +4415,9 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
4367
4415
  execStart: resolveExecStart(site.start, runtime),
4368
4416
  envEntries: site.env || {},
4369
4417
  port: site.port,
4370
- preStartCommands: site.preStart
4418
+ preStartCommands: site.preStart,
4419
+ zeroDowntime: site.zeroDowntime !== false,
4420
+ healthCheckPath: site.healthCheck?.path
4371
4421
  });
4372
4422
  const compute = config.infrastructure?.compute;
4373
4423
  const wantsNginxStatic = kind === "server-static" && compute?.webServer !== "rpx" && !!site.domain;
@@ -12,7 +12,7 @@ import {
12
12
  sanitizeCloudConfig,
13
13
  setMaintenance,
14
14
  startLocalDashboardServer
15
- } from "../chunk-x07dz3sd.js";
15
+ } from "../chunk-6va72rhh.js";
16
16
  import {
17
17
  buildAndPushServerlessImage
18
18
  } from "../chunk-0wxyppza.js";
@@ -29,7 +29,7 @@ import {
29
29
  resolveSiteKind,
30
30
  resolveUiSource,
31
31
  validateDeploymentConfig
32
- } from "../chunk-xzn0ntr0.js";
32
+ } from "../chunk-7pym4v5b.js";
33
33
  import"../chunk-c6rgvg1j.js";
34
34
  import"../chunk-93hjhs78.js";
35
35
  import {
@@ -48,7 +48,7 @@ import {
48
48
  waitForCloudInit,
49
49
  waitForSsh,
50
50
  wrapCloudInitUserData
51
- } from "../chunk-xzn0ntr0.js";
51
+ } from "../chunk-7pym4v5b.js";
52
52
  import"../chunk-c6rgvg1j.js";
53
53
  import"../chunk-93hjhs78.js";
54
54
  import"../chunk-qpj3edwz.js";
@@ -25,13 +25,47 @@ export interface BuildSiteDeployScriptOptions {
25
25
  * `node_modules`.
26
26
  */
27
27
  preStartCommands?: string[];
28
+ /**
29
+ * True zero-downtime cutover for ported sites: the new release runs as its
30
+ * own systemd instance (`<slug>-<site>@<releaseId>`) that binds the same
31
+ * port via SO_REUSEPORT while the old instance still serves, must pass a
32
+ * health gate, and only then is the old instance stopped. A release that
33
+ * crashes on boot fails the deploy with the old release still serving.
34
+ *
35
+ * Requires the app to bind with `reusePort` (Stacks' server does in
36
+ * production). Defaults to true when `port` is set; portless sites
37
+ * (queue workers, schedulers) always use the stop/start flow because two
38
+ * overlapping instances would double-process their work.
39
+ */
40
+ zeroDowntime?: boolean;
41
+ /**
42
+ * HTTP path polled on `127.0.0.1:<port>` as part of the health gate (e.g.
43
+ * `/health`). Optional — without it the gate is "the instance stays
44
+ * active for {@link BuildSiteDeployScriptOptions.healthGateSeconds}".
45
+ */
46
+ healthCheckPath?: string;
47
+ /**
48
+ * Seconds the new instance must stay active (and, with
49
+ * {@link BuildSiteDeployScriptOptions.healthCheckPath}, respond 2xx/3xx)
50
+ * before the old instance is stopped.
51
+ * @default 5
52
+ */
53
+ healthGateSeconds?: number;
28
54
  }
29
55
  /**
30
56
  * Build the remote shell commands that install/refresh a server-app site on a
31
- * compute target with a **zero-downtime atomic release** (Envoyer-style): unpack
32
- * into `releases/<id>`, link the shared `.env`, build, then atomically repoint
33
- * `current` and restart the systemd service (which runs from `current`). Old
34
- * releases are pruned for rollback.
57
+ * compute target with an atomic release (Envoyer-style): unpack into
58
+ * `releases/<id>`, link the shared `.env`, build, then cut over.
59
+ *
60
+ * The cutover has two modes:
61
+ * - **zero-downtime** (default for ported sites): the new release starts as a
62
+ * templated systemd instance that shares the port via SO_REUSEPORT with the
63
+ * still-running old instance, must pass a health gate, and only then does
64
+ * the old instance stop — no dropped connections, and a crash-on-boot
65
+ * release fails the deploy with the old one still serving.
66
+ * - **restart** (portless sites, or `zeroDowntime: false`): the classic flip
67
+ * `current` + `systemctl restart` — correct for workers/schedulers where two
68
+ * overlapping instances would double-process work.
35
69
  */
36
70
  export declare function buildSiteDeployScript(options: BuildSiteDeployScriptOptions): string[];
37
71
  export interface BuildStaticSiteDeployScriptOptions {
@@ -58,11 +58,19 @@ export declare function buildActivateRelease(paths: ReleasePaths): string[];
58
58
  * `to` set, points `current` at `releases/<to>`; otherwise picks the most recent
59
59
  * release that isn't the one `current` resolves to. Atomic (temp symlink + `mv
60
60
  * -T`), and a no-op-safe guard fails loudly if the target is missing rather than
61
- * leaving `current` dangling. The caller appends the engine reload
62
- * (php-fpm/queues) — see {@link import('./laravel-deploy')}.
61
+ * leaving `current` dangling.
62
+ *
63
+ * With `unitBase` set (e.g. `myapp-api`), the script also swaps the running
64
+ * systemd release instance for sites deployed zero-downtime style (templated
65
+ * `<unitBase>@<releaseId>` units pinned to their release dirs): it starts the
66
+ * instance for the rolled-back release — overlapping on the SO_REUSEPORT port —
67
+ * then stops the newer one, so even the rollback itself is zero-downtime. Sites
68
+ * on the legacy single unit just get a restart. The caller appends any engine
69
+ * reload (php-fpm/queues) — see {@link import('./laravel-deploy')}.
63
70
  */
64
71
  export declare function buildRollbackScript(paths: ReleasePaths, options?: {
65
72
  to?: string;
73
+ unitBase?: string;
66
74
  }): string[];
67
75
  /**
68
76
  * Remove all but the newest `keep` releases (by mtime). `current` always points
package/dist/index.js CHANGED
@@ -55,7 +55,7 @@ import {
55
55
  sanitizeCloudConfig,
56
56
  setMaintenance,
57
57
  startLocalDashboardServer
58
- } from "./chunk-x07dz3sd.js";
58
+ } from "./chunk-6va72rhh.js";
59
59
  import {
60
60
  buildAndPushServerlessImage
61
61
  } from "./chunk-0wxyppza.js";
@@ -105,7 +105,7 @@ import {
105
105
  waitForCloudInit,
106
106
  waitForSsh,
107
107
  wrapCloudInitUserData
108
- } from "./chunk-xzn0ntr0.js";
108
+ } from "./chunk-7pym4v5b.js";
109
109
  import {
110
110
  ABTestManager,
111
111
  AI,