@decocms/sandbox-controller 0.15.3 → 0.15.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.
package/dist/index.d.ts CHANGED
@@ -3750,6 +3750,8 @@ interface FreestyleSandboxProviderOptions {
3750
3750
  meter?: Meter;
3751
3751
  /** Replaces the API client, for tests. */
3752
3752
  client?: Freestyle;
3753
+ /** How long a started VM's daemon gets to answer /health, for tests. */
3754
+ daemonBootTimeoutMs?: number;
3753
3755
  }
3754
3756
  declare class FreestyleSandboxProvider implements SandboxProvider {
3755
3757
  private readonly apiKey;
@@ -3761,6 +3763,7 @@ declare class FreestyleSandboxProvider implements SandboxProvider {
3761
3763
  private readonly idleTimeoutSeconds;
3762
3764
  private readonly idlePauseMs;
3763
3765
  private readonly autoDeleteSeconds;
3766
+ private readonly daemonBootTimeoutMs;
3764
3767
  private readonly domainSuffix;
3765
3768
  private readonly timeDaemonRequest;
3766
3769
  private readonly inflight;
@@ -3867,7 +3870,19 @@ declare class FreestyleSandboxProvider implements SandboxProvider {
3867
3870
  private createVm;
3868
3871
  private configPayload;
3869
3872
  private waitForDaemon;
3873
+ /**
3874
+ * Deletes the VM only once its shutdown sync is confirmed. Otherwise the
3875
+ * user's unpushed work may still be on its disk, so it is detached instead:
3876
+ * the handle is free for a new sandbox, and the VM stays recoverable by id
3877
+ * until Freestyle's auto-delete.
3878
+ */
3870
3879
  delete(handle: string): Promise<void>;
3880
+ /**
3881
+ * Stops the daemon, which publishes unsaved work to git on SIGTERM, then the
3882
+ * sidecar, which flushes its mounts. The daemon exits 0 even when that
3883
+ * publish fails, so its log decides.
3884
+ */
3885
+ private syncedOnShutdown;
3871
3886
  lastTermination(_handle: string): Promise<PodTermination | null>;
3872
3887
  /** A paused VM is alive: the next request resumes it. */
3873
3888
  alive(handle: string): Promise<boolean>;
@@ -3954,6 +3969,13 @@ declare class SandboxProviderRouter implements SandboxProvider {
3954
3969
  * reclaims a stray paused VM anyway, only later.
3955
3970
  */
3956
3971
  private readonly strays;
3972
+ /**
3973
+ * The latest ensure per handle. Placement reads what exists, so two at once
3974
+ * each found nothing and put one sandbox on each provider.
3975
+ *
3976
+ * ponytail: in-process; replicas would need the state store's lock.
3977
+ */
3978
+ private readonly ensuring;
3957
3979
  private readonly freestyleShare;
3958
3980
  constructor(providers: SandboxProviders, opts?: SandboxProviderRouterOptions);
3959
3981
  private get;
@@ -3964,6 +3986,7 @@ declare class SandboxProviderRouter implements SandboxProvider {
3964
3986
  /** Where `ensure` puts this sandbox, and why. */
3965
3987
  place(handle: string, opts: EnsureOptions): Promise<Placement>;
3966
3988
  ensure(id: SandboxId, opts?: EnsureOptions): Promise<Sandbox>;
3989
+ private ensureOne;
3967
3990
  /** Delete the sandbox a fallback left behind on `kind`, in the background. */
3968
3991
  private retire;
3969
3992
  delete(handle: string): Promise<void>;
package/dist/index.js CHANGED
@@ -6804,7 +6804,7 @@ var Freestyle = class {
6804
6804
  // ../sandbox/package.json
6805
6805
  var package_default = {
6806
6806
  name: "@decocms/sandbox",
6807
- version: "1.95.3",
6807
+ version: "1.95.5",
6808
6808
  private: true,
6809
6809
  type: "module",
6810
6810
  description: "Sandbox runtime for isolated Studio tool execution",
@@ -6926,6 +6926,7 @@ var FreestyleSandboxProvider = class {
6926
6926
  idleTimeoutSeconds;
6927
6927
  idlePauseMs;
6928
6928
  autoDeleteSeconds;
6929
+ daemonBootTimeoutMs;
6929
6930
  domainSuffix;
6930
6931
  timeDaemonRequest;
6931
6932
  inflight = new Inflight();
@@ -6966,6 +6967,7 @@ var FreestyleSandboxProvider = class {
6966
6967
  this.idleTimeoutSeconds = opts.idleTimeoutSeconds ?? 5 * 60;
6967
6968
  this.idlePauseMs = opts.idlePauseMs ?? 10 * 6e4;
6968
6969
  this.autoDeleteSeconds = opts.autoDeleteSeconds ?? 24 * 60 * 60;
6970
+ this.daemonBootTimeoutMs = opts.daemonBootTimeoutMs ?? DAEMON_BOOT_TIMEOUT_MS;
6969
6971
  this.domainSuffix = opts.domainSuffix ?? "style.dev";
6970
6972
  this.timeDaemonRequest = daemonProxyTimer(opts.meter);
6971
6973
  if (this.idlePauseMs > 0) {
@@ -7300,7 +7302,12 @@ ${head}`).digest("hex").slice(0, 16)}`;
7300
7302
  async resume(handle, found, opts) {
7301
7303
  const { state, sourceSnapshotSlugAtCreate, metadata } = await found.vm.data();
7302
7304
  if (state !== "running") await found.vm.start();
7303
- await this.waitForDaemon(found.daemon.url);
7305
+ await this.waitForDaemon(found.daemon.url).catch(async (err) => {
7306
+ console.warn(`[${LOG_LABEL2}] ${handle}: ${errMsg2(err)}; rebooting`);
7307
+ await found.vm.exec({ command: "reboot -f", timeoutMs: 1e4 }).catch(() => {
7308
+ });
7309
+ await this.waitForDaemon(found.daemon.url);
7310
+ });
7304
7311
  const sentinelSlug = metadata[BASE_KEY] ?? sourceSnapshotSlugAtCreate;
7305
7312
  if (sentinelSlug) {
7306
7313
  await this.reclaim(found, sentinelSlug, opts);
@@ -7539,26 +7546,63 @@ ${head}`).digest("hex").slice(0, 16)}`;
7539
7546
  });
7540
7547
  }
7541
7548
  async waitForDaemon(url) {
7542
- const deadline = Date.now() + DAEMON_BOOT_TIMEOUT_MS;
7549
+ const deadline = Date.now() + this.daemonBootTimeoutMs;
7543
7550
  while (Date.now() < deadline) {
7544
7551
  if (await daemonAnswers(url)) return;
7545
7552
  await delay(HEALTH_POLL_MS);
7546
7553
  }
7547
7554
  throw new Error(
7548
- `sandbox daemon at ${url} did not answer /health within ${DAEMON_BOOT_TIMEOUT_MS / 1e3}s`
7555
+ `sandbox daemon at ${url} did not answer /health within ${this.daemonBootTimeoutMs / 1e3}s`
7549
7556
  );
7550
7557
  }
7558
+ /**
7559
+ * Deletes the VM only once its shutdown sync is confirmed. Otherwise the
7560
+ * user's unpushed work may still be on its disk, so it is detached instead:
7561
+ * the handle is free for a new sandbox, and the VM stays recoverable by id
7562
+ * until Freestyle's auto-delete.
7563
+ */
7551
7564
  async delete(handle) {
7552
7565
  this.cancelRelease(handle);
7553
7566
  const found = await this.find(handle, { refresh: true });
7554
7567
  this.lookups.delete(handle);
7555
7568
  if (!found) return;
7556
- await found.vm.exec({
7557
- command: `docker stop -t 30 ${CONTAINER}; docker stop -t 10 ${SIDECAR}`,
7558
- timeoutMs: 6e4
7559
- }).catch(() => {
7569
+ if (await this.syncedOnShutdown(found.vm)) {
7570
+ await found.vm.delete();
7571
+ return;
7572
+ }
7573
+ const { rules } = await this.freestyle.tls.rules.list({
7574
+ vmId: found.vm.id
7560
7575
  });
7561
- await found.vm.delete();
7576
+ for (const rule of rules) await this.freestyle.tls.rules.delete(rule.id);
7577
+ await found.vm.update({ slug: "" });
7578
+ this.appIngress.delete(handle);
7579
+ console.warn(
7580
+ `[${LOG_LABEL2}] ${handle}: shutdown sync not confirmed; detached VM ${found.vm.id} instead of deleting it`
7581
+ );
7582
+ }
7583
+ /**
7584
+ * Stops the daemon, which publishes unsaved work to git on SIGTERM, then the
7585
+ * sidecar, which flushes its mounts. The daemon exits 0 even when that
7586
+ * publish fails, so its log decides.
7587
+ */
7588
+ async syncedOnShutdown(vm) {
7589
+ try {
7590
+ const { state } = await vm.data();
7591
+ if (state !== "running") await vm.start();
7592
+ const res = await vm.exec({
7593
+ command: [
7594
+ "since=$(date +%s)",
7595
+ `docker stop -t 30 ${CONTAINER} >/dev/null`,
7596
+ `code=$(docker inspect -f '{{.State.ExitCode}}' ${CONTAINER})`,
7597
+ `docker stop -t 10 ${SIDECAR} >/dev/null`,
7598
+ `[ "$code" = 0 ] && ! docker logs --since "$since" ${CONTAINER} 2>&1 | grep -q "shutdown publish failed"`
7599
+ ].join("; "),
7600
+ timeoutMs: 6e4
7601
+ });
7602
+ return res.statusCode === 0;
7603
+ } catch {
7604
+ return false;
7605
+ }
7562
7606
  }
7563
7607
  async lastTermination(_handle) {
7564
7608
  return null;
@@ -7861,6 +7905,13 @@ var SandboxProviderRouter = class {
7861
7905
  * reclaims a stray paused VM anyway, only later.
7862
7906
  */
7863
7907
  strays = /* @__PURE__ */ new Map();
7908
+ /**
7909
+ * The latest ensure per handle. Placement reads what exists, so two at once
7910
+ * each found nothing and put one sandbox on each provider.
7911
+ *
7912
+ * ponytail: in-process; replicas would need the state store's lock.
7913
+ */
7914
+ ensuring = /* @__PURE__ */ new Map();
7864
7915
  freestyleShare;
7865
7916
  get(kind) {
7866
7917
  const provider = this.providers[kind];
@@ -7919,8 +7970,18 @@ var SandboxProviderRouter = class {
7919
7970
  return roomy ? { kind: "kubernetes", reason: "split" } : { kind: "freestyle", reason: "capacity" };
7920
7971
  }
7921
7972
  async ensure(id2, opts = {}) {
7922
- const { provider: _requested, ...rest } = opts;
7923
7973
  const handle = computeHandle(id2);
7974
+ const run = (this.ensuring.get(handle) ?? Promise.resolve()).catch(() => {
7975
+ }).then(() => this.ensureOne(handle, id2, opts));
7976
+ this.ensuring.set(handle, run);
7977
+ try {
7978
+ return await run;
7979
+ } finally {
7980
+ if (this.ensuring.get(handle) === run) this.ensuring.delete(handle);
7981
+ }
7982
+ }
7983
+ async ensureOne(handle, id2, opts) {
7984
+ const { provider: _requested, ...rest } = opts;
7924
7985
  const placed = await this.place(handle, opts);
7925
7986
  this.remember(handle, placed.kind);
7926
7987
  if (this.strays.get(handle) === placed.kind) this.strays.delete(handle);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/sandbox-controller",
3
- "version": "0.15.3",
3
+ "version": "0.15.5",
4
4
  "description": "Runs Studio agent sandboxes on Kubernetes, and serves them to a remote Studio.",
5
5
  "repository": {
6
6
  "type": "git",