@decocms/sandbox-controller 0.15.4 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,16 +6804,17 @@ var Freestyle = class {
6804
6804
  // ../sandbox/package.json
6805
6805
  var package_default = {
6806
6806
  name: "@decocms/sandbox",
6807
- version: "1.95.4",
6807
+ version: "1.96.0",
6808
6808
  private: true,
6809
6809
  type: "module",
6810
6810
  description: "Sandbox runtime for isolated Studio tool execution",
6811
6811
  scripts: {
6812
6812
  check: "tsc --noEmit",
6813
6813
  test: "bun test",
6814
- build: "bun run build:typegen && bun run build:harness-runner",
6814
+ build: "bun run build:typegen && bun run build:harness-runner && bun run build:studio-cli",
6815
6815
  "build:typegen": "mkdir -p dist && bun run --cwd=../typegen build && bun --cwd=../typegen pm pack --filename $PWD/dist/typegen.tgz --quiet",
6816
- "build:harness-runner": "mkdir -p dist && bun --cwd=../harness-runner pm pack --filename $PWD/dist/harness-runner.tgz --quiet"
6816
+ "build:harness-runner": "mkdir -p dist && bun --cwd=../harness-runner pm pack --filename $PWD/dist/harness-runner.tgz --quiet",
6817
+ "build:studio-cli": "bun scripts/build-studio-cli.ts dist/decocms"
6817
6818
  },
6818
6819
  exports: {
6819
6820
  "./shared": "./shared.ts",
@@ -6926,6 +6927,7 @@ var FreestyleSandboxProvider = class {
6926
6927
  idleTimeoutSeconds;
6927
6928
  idlePauseMs;
6928
6929
  autoDeleteSeconds;
6930
+ daemonBootTimeoutMs;
6929
6931
  domainSuffix;
6930
6932
  timeDaemonRequest;
6931
6933
  inflight = new Inflight();
@@ -6966,6 +6968,7 @@ var FreestyleSandboxProvider = class {
6966
6968
  this.idleTimeoutSeconds = opts.idleTimeoutSeconds ?? 5 * 60;
6967
6969
  this.idlePauseMs = opts.idlePauseMs ?? 10 * 6e4;
6968
6970
  this.autoDeleteSeconds = opts.autoDeleteSeconds ?? 24 * 60 * 60;
6971
+ this.daemonBootTimeoutMs = opts.daemonBootTimeoutMs ?? DAEMON_BOOT_TIMEOUT_MS;
6969
6972
  this.domainSuffix = opts.domainSuffix ?? "style.dev";
6970
6973
  this.timeDaemonRequest = daemonProxyTimer(opts.meter);
6971
6974
  if (this.idlePauseMs > 0) {
@@ -7300,7 +7303,12 @@ ${head}`).digest("hex").slice(0, 16)}`;
7300
7303
  async resume(handle, found, opts) {
7301
7304
  const { state, sourceSnapshotSlugAtCreate, metadata } = await found.vm.data();
7302
7305
  if (state !== "running") await found.vm.start();
7303
- await this.waitForDaemon(found.daemon.url);
7306
+ await this.waitForDaemon(found.daemon.url).catch(async (err) => {
7307
+ console.warn(`[${LOG_LABEL2}] ${handle}: ${errMsg2(err)}; rebooting`);
7308
+ await found.vm.exec({ command: "reboot -f", timeoutMs: 1e4 }).catch(() => {
7309
+ });
7310
+ await this.waitForDaemon(found.daemon.url);
7311
+ });
7304
7312
  const sentinelSlug = metadata[BASE_KEY] ?? sourceSnapshotSlugAtCreate;
7305
7313
  if (sentinelSlug) {
7306
7314
  await this.reclaim(found, sentinelSlug, opts);
@@ -7539,26 +7547,63 @@ ${head}`).digest("hex").slice(0, 16)}`;
7539
7547
  });
7540
7548
  }
7541
7549
  async waitForDaemon(url) {
7542
- const deadline = Date.now() + DAEMON_BOOT_TIMEOUT_MS;
7550
+ const deadline = Date.now() + this.daemonBootTimeoutMs;
7543
7551
  while (Date.now() < deadline) {
7544
7552
  if (await daemonAnswers(url)) return;
7545
7553
  await delay(HEALTH_POLL_MS);
7546
7554
  }
7547
7555
  throw new Error(
7548
- `sandbox daemon at ${url} did not answer /health within ${DAEMON_BOOT_TIMEOUT_MS / 1e3}s`
7556
+ `sandbox daemon at ${url} did not answer /health within ${this.daemonBootTimeoutMs / 1e3}s`
7549
7557
  );
7550
7558
  }
7559
+ /**
7560
+ * Deletes the VM only once its shutdown sync is confirmed. Otherwise the
7561
+ * user's unpushed work may still be on its disk, so it is detached instead:
7562
+ * the handle is free for a new sandbox, and the VM stays recoverable by id
7563
+ * until Freestyle's auto-delete.
7564
+ */
7551
7565
  async delete(handle) {
7552
7566
  this.cancelRelease(handle);
7553
7567
  const found = await this.find(handle, { refresh: true });
7554
7568
  this.lookups.delete(handle);
7555
7569
  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(() => {
7570
+ if (await this.syncedOnShutdown(found.vm)) {
7571
+ await found.vm.delete();
7572
+ return;
7573
+ }
7574
+ const { rules } = await this.freestyle.tls.rules.list({
7575
+ vmId: found.vm.id
7560
7576
  });
7561
- await found.vm.delete();
7577
+ for (const rule of rules) await this.freestyle.tls.rules.delete(rule.id);
7578
+ await found.vm.update({ slug: "" });
7579
+ this.appIngress.delete(handle);
7580
+ console.warn(
7581
+ `[${LOG_LABEL2}] ${handle}: shutdown sync not confirmed; detached VM ${found.vm.id} instead of deleting it`
7582
+ );
7583
+ }
7584
+ /**
7585
+ * Stops the daemon, which publishes unsaved work to git on SIGTERM, then the
7586
+ * sidecar, which flushes its mounts. The daemon exits 0 even when that
7587
+ * publish fails, so its log decides.
7588
+ */
7589
+ async syncedOnShutdown(vm) {
7590
+ try {
7591
+ const { state } = await vm.data();
7592
+ if (state !== "running") await vm.start();
7593
+ const res = await vm.exec({
7594
+ command: [
7595
+ "since=$(date +%s)",
7596
+ `docker stop -t 30 ${CONTAINER} >/dev/null`,
7597
+ `code=$(docker inspect -f '{{.State.ExitCode}}' ${CONTAINER})`,
7598
+ `docker stop -t 10 ${SIDECAR} >/dev/null`,
7599
+ `[ "$code" = 0 ] && ! docker logs --since "$since" ${CONTAINER} 2>&1 | grep -q "shutdown publish failed"`
7600
+ ].join("; "),
7601
+ timeoutMs: 6e4
7602
+ });
7603
+ return res.statusCode === 0;
7604
+ } catch {
7605
+ return false;
7606
+ }
7562
7607
  }
7563
7608
  async lastTermination(_handle) {
7564
7609
  return null;
@@ -7861,6 +7906,13 @@ var SandboxProviderRouter = class {
7861
7906
  * reclaims a stray paused VM anyway, only later.
7862
7907
  */
7863
7908
  strays = /* @__PURE__ */ new Map();
7909
+ /**
7910
+ * The latest ensure per handle. Placement reads what exists, so two at once
7911
+ * each found nothing and put one sandbox on each provider.
7912
+ *
7913
+ * ponytail: in-process; replicas would need the state store's lock.
7914
+ */
7915
+ ensuring = /* @__PURE__ */ new Map();
7864
7916
  freestyleShare;
7865
7917
  get(kind) {
7866
7918
  const provider = this.providers[kind];
@@ -7919,8 +7971,18 @@ var SandboxProviderRouter = class {
7919
7971
  return roomy ? { kind: "kubernetes", reason: "split" } : { kind: "freestyle", reason: "capacity" };
7920
7972
  }
7921
7973
  async ensure(id2, opts = {}) {
7922
- const { provider: _requested, ...rest } = opts;
7923
7974
  const handle = computeHandle(id2);
7975
+ const run = (this.ensuring.get(handle) ?? Promise.resolve()).catch(() => {
7976
+ }).then(() => this.ensureOne(handle, id2, opts));
7977
+ this.ensuring.set(handle, run);
7978
+ try {
7979
+ return await run;
7980
+ } finally {
7981
+ if (this.ensuring.get(handle) === run) this.ensuring.delete(handle);
7982
+ }
7983
+ }
7984
+ async ensureOne(handle, id2, opts) {
7985
+ const { provider: _requested, ...rest } = opts;
7924
7986
  const placed = await this.place(handle, opts);
7925
7987
  this.remember(handle, placed.kind);
7926
7988
  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.4",
3
+ "version": "0.16.0",
4
4
  "description": "Runs Studio agent sandboxes on Kubernetes, and serves them to a remote Studio.",
5
5
  "repository": {
6
6
  "type": "git",