@decocms/sandbox-controller 0.8.0 → 0.8.2

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
@@ -3717,6 +3717,13 @@ interface FreestyleSandboxProviderOptions {
3717
3717
  orgFsSidecarImage?: string;
3718
3718
  /** Pause a VM after this long without network activity. Default 5 min. */
3719
3719
  idleTimeoutSeconds?: number;
3720
+ /**
3721
+ * Also pause a running VM once its daemon has gone this long unused. The
3722
+ * idle timeout counts any traffic, so a dev server holding an outbound
3723
+ * connection open keeps the VM awake without it. Checked every minute;
3724
+ * 0 turns it off. Default 10 min.
3725
+ */
3726
+ idlePauseMs?: number;
3720
3727
  /**
3721
3728
  * Delete a VM after this long without running. Default 1 day. A plan
3722
3729
  * that caps it lower gets its cap.
@@ -3730,18 +3737,24 @@ interface FreestyleSandboxProviderOptions {
3730
3737
  client?: Freestyle;
3731
3738
  }
3732
3739
  declare class FreestyleSandboxProvider implements SandboxProvider {
3740
+ private readonly apiKey;
3733
3741
  private readonly freestyle;
3734
3742
  private readonly lookupClient;
3735
3743
  private readonly image;
3736
3744
  private readonly sidecarImage;
3737
3745
  private readonly idleTimeoutSeconds;
3746
+ private readonly idlePauseMs;
3738
3747
  private readonly autoDeleteSeconds;
3739
3748
  private readonly domainSuffix;
3740
3749
  private readonly timeDaemonRequest;
3741
3750
  private readonly inflight;
3742
3751
  private readonly snapshotInflight;
3752
+ /** Snapshots are immutable once built, so one positive lookup is enough. */
3753
+ private readonly knownSnapshots;
3743
3754
  private readonly lookups;
3744
3755
  private readonly releaseTimers;
3756
+ private readonly idleSweep;
3757
+ private sweeping;
3745
3758
  constructor(opts: FreestyleSandboxProviderOptions);
3746
3759
  /** Whether this handle is a Freestyle sandbox. Cached; see `MISS_TTL_MS`. */
3747
3760
  owns(handle: string): Promise<boolean>;
@@ -3749,8 +3762,9 @@ declare class FreestyleSandboxProvider implements SandboxProvider {
3749
3762
  private find;
3750
3763
  private remember;
3751
3764
  /**
3752
- * A snapshot of a VM with both images already pulled, so a sandbox boots in
3753
- * seconds instead of pulling ~2GB. One per image pair; built on first use.
3765
+ * A snapshot of a VM with both containers RUNNING and the daemon healthy but
3766
+ * unclaimed, so a fork resumes a live daemon (Freestyle snapshots memory) and
3767
+ * is configured with one request instead of booted. One per image pair.
3754
3768
  */
3755
3769
  /**
3756
3770
  * Build this image's base snapshot now, so the first sandbox after an image
@@ -3758,11 +3772,24 @@ declare class FreestyleSandboxProvider implements SandboxProvider {
3758
3772
  */
3759
3773
  warm(): Promise<void>;
3760
3774
  private baseSnapshot;
3775
+ /**
3776
+ * The bearer the snapshot's daemon boots with. Every fork shares it until its
3777
+ * first config rotates it, so it is derived (any replica can compute it) and
3778
+ * never stored.
3779
+ */
3780
+ private sentinelToken;
3781
+ private startContainers;
3761
3782
  private snapshotExists;
3762
3783
  private run;
3763
3784
  ensure(id: SandboxId, opts?: EnsureOptions): Promise<Sandbox>;
3764
3785
  private resume;
3765
3786
  private create;
3787
+ /**
3788
+ * The fork's first config: swap the snapshot's sentinel bearer for this
3789
+ * sandbox's own, retried until the new domain routes. A 401 means an earlier
3790
+ * attempt already rotated (its response was lost), so finish with the new one.
3791
+ */
3792
+ private claimDaemon;
3766
3793
  /** A plan that caps autoDelete lower answers 400; omitting it gets the cap. */
3767
3794
  private createVm;
3768
3795
  private configPayload;
@@ -3792,6 +3819,12 @@ declare class FreestyleSandboxProvider implements SandboxProvider {
3792
3819
  releaseAfter(handle: string, graceMs: number): Promise<void>;
3793
3820
  /** The VM's idle timeout already counts every request as activity. */
3794
3821
  renewTtl(handle: string): Promise<void>;
3822
+ /**
3823
+ * Pause every running VM of ours whose daemon reports `idlePauseMs` without
3824
+ * use. The daemon's clock counts requests and streaming runs, not the dev
3825
+ * server's own traffic. Returns the handles paused; never throws.
3826
+ */
3827
+ pauseIdle(): Promise<string[]>;
3795
3828
  private cancelRelease;
3796
3829
  close(): void;
3797
3830
  }
package/dist/index.js CHANGED
@@ -4475,7 +4475,7 @@ function tenantPoolReader(store, cluster, { refreshMs = DEFAULT_TENANT_POOL_REFR
4475
4475
  }
4476
4476
 
4477
4477
  // ../sandbox/server/provider/freestyle.ts
4478
- import { createHash as createHash3, randomBytes as randomBytes2 } from "crypto";
4478
+ import { createHash as createHash3, createHmac, randomBytes as randomBytes2 } from "crypto";
4479
4479
 
4480
4480
  // ../../node_modules/.bun/freestyle@0.2.16/node_modules/freestyle/dist/errors.js
4481
4481
  var FreestyleApiError = class _FreestyleApiError extends Error {
@@ -6759,7 +6759,7 @@ var Freestyle = class {
6759
6759
  // ../sandbox/package.json
6760
6760
  var package_default = {
6761
6761
  name: "@decocms/sandbox",
6762
- version: "1.83.0",
6762
+ version: "1.85.2",
6763
6763
  private: true,
6764
6764
  type: "module",
6765
6765
  description: "Sandbox runtime for isolated Studio tool execution",
@@ -6853,6 +6853,7 @@ var ORG_KEY = "studio-org-id";
6853
6853
  var USER_KEY = "studio-user-id";
6854
6854
  var DAEMON_BOOT_TIMEOUT_MS = 12e4;
6855
6855
  var HEALTH_POLL_MS = 500;
6856
+ var CLAIM_RETRY_MS = 100;
6856
6857
  var HEALTH_PROBE_TIMEOUT_MS2 = 5e3;
6857
6858
  var IMAGE_PULL_TIMEOUT_MS = 3e5;
6858
6859
  var LOOKUP_TTL_MS = 5 * 6e4;
@@ -6860,20 +6861,29 @@ var MISS_TTL_MS = 1e4;
6860
6861
  var LOOKUP_MAX = 5e3;
6861
6862
  var WATCH_TIMEOUT_MS = 5 * 6e4;
6862
6863
  var LOOKUP_TIMEOUT_MS = 3e3;
6864
+ var IDLE_SWEEP_MS = 6e4;
6865
+ var IDLE_SWEEP_PAGE = 100;
6863
6866
  var FreestyleSandboxProvider = class {
6867
+ apiKey;
6864
6868
  freestyle;
6865
6869
  lookupClient;
6866
6870
  image;
6867
6871
  sidecarImage;
6868
6872
  idleTimeoutSeconds;
6873
+ idlePauseMs;
6869
6874
  autoDeleteSeconds;
6870
6875
  domainSuffix;
6871
6876
  timeDaemonRequest;
6872
6877
  inflight = new Inflight();
6873
6878
  snapshotInflight = new Inflight();
6879
+ /** Snapshots are immutable once built, so one positive lookup is enough. */
6880
+ knownSnapshots = /* @__PURE__ */ new Set();
6874
6881
  lookups = /* @__PURE__ */ new Map();
6875
6882
  releaseTimers = /* @__PURE__ */ new Map();
6883
+ idleSweep = null;
6884
+ sweeping = false;
6876
6885
  constructor(opts) {
6886
+ this.apiKey = opts.apiKey;
6877
6887
  this.freestyle = opts.client ?? new Freestyle({ apiKey: opts.apiKey });
6878
6888
  this.lookupClient = opts.client ?? new Freestyle({
6879
6889
  apiKey: opts.apiKey,
@@ -6891,9 +6901,14 @@ var FreestyleSandboxProvider = class {
6891
6901
  this.image = opts.image ?? `ghcr.io/decocms/studio/studio-sandbox-go:${package_default.version}`;
6892
6902
  this.sidecarImage = opts.orgFsSidecarImage ?? DEFAULT_SIDECAR_IMAGE;
6893
6903
  this.idleTimeoutSeconds = opts.idleTimeoutSeconds ?? 5 * 60;
6904
+ this.idlePauseMs = opts.idlePauseMs ?? 10 * 6e4;
6894
6905
  this.autoDeleteSeconds = opts.autoDeleteSeconds ?? 24 * 60 * 60;
6895
6906
  this.domainSuffix = opts.domainSuffix ?? "style.dev";
6896
6907
  this.timeDaemonRequest = daemonProxyTimer(opts.meter);
6908
+ if (this.idlePauseMs > 0) {
6909
+ this.idleSweep = setInterval(() => void this.pauseIdle(), IDLE_SWEEP_MS);
6910
+ this.idleSweep.unref?.();
6911
+ }
6897
6912
  }
6898
6913
  /** Whether this handle is a Freestyle sandbox. Cached; see `MISS_TTL_MS`. */
6899
6914
  async owns(handle) {
@@ -6934,8 +6949,9 @@ var FreestyleSandboxProvider = class {
6934
6949
  }
6935
6950
  // ---- Base snapshot --------------------------------------------------------
6936
6951
  /**
6937
- * A snapshot of a VM with both images already pulled, so a sandbox boots in
6938
- * seconds instead of pulling ~2GB. One per image pair; built on first use.
6952
+ * A snapshot of a VM with both containers RUNNING and the daemon healthy but
6953
+ * unclaimed, so a fork resumes a live daemon (Freestyle snapshots memory) and
6954
+ * is configured with one request instead of booted. One per image pair.
6939
6955
  */
6940
6956
  /**
6941
6957
  * Build this image's base snapshot now, so the first sandbox after an image
@@ -6949,9 +6965,14 @@ var FreestyleSandboxProvider = class {
6949
6965
  baseSnapshot() {
6950
6966
  const { image, sidecarImage } = this;
6951
6967
  const slug = `studio-sandbox-${createHash3("sha256").update(`${image}
6952
- ${sidecarImage}`).digest("hex").slice(0, 16)}`;
6968
+ ${sidecarImage}
6969
+ running`).digest("hex").slice(0, 16)}`;
6970
+ if (this.knownSnapshots.has(slug)) return Promise.resolve(slug);
6953
6971
  return this.snapshotInflight.run(slug, async () => {
6954
- if (await this.snapshotExists(slug)) return slug;
6972
+ if (await this.snapshotExists(slug)) {
6973
+ this.knownSnapshots.add(slug);
6974
+ return slug;
6975
+ }
6955
6976
  console.log(`[${LOG_LABEL2}] building base snapshot ${slug} for ${image}`);
6956
6977
  const { vm } = await this.freestyle.vms.create({
6957
6978
  ttlSeconds: 1800,
@@ -6967,7 +6988,14 @@ ${sidecarImage}`).digest("hex").slice(0, 16)}`;
6967
6988
  `docker pull -q ${shellQuote(image)} && docker pull -q ${shellQuote(sidecarImage)}`,
6968
6989
  { timeoutMs: IMAGE_PULL_TIMEOUT_MS }
6969
6990
  );
6991
+ await this.startContainers(vm, this.sentinelToken(slug));
6992
+ await this.run(
6993
+ vm,
6994
+ `for i in $(seq 1 240); do curl -sf -o /dev/null http://127.0.0.1:${DAEMON_PORT}/health && exit 0; sleep 0.5; done; exit 1`,
6995
+ { timeoutMs: DAEMON_BOOT_TIMEOUT_MS + 1e4 }
6996
+ );
6970
6997
  await vm.snapshot({ slug });
6998
+ this.knownSnapshots.add(slug);
6971
6999
  } catch (err) {
6972
7000
  if (!await this.snapshotExists(slug)) throw err;
6973
7001
  } finally {
@@ -6977,6 +7005,34 @@ ${sidecarImage}`).digest("hex").slice(0, 16)}`;
6977
7005
  return slug;
6978
7006
  });
6979
7007
  }
7008
+ /**
7009
+ * The bearer the snapshot's daemon boots with. Every fork shares it until its
7010
+ * first config rotates it, so it is derived (any replica can compute it) and
7011
+ * never stored.
7012
+ */
7013
+ sentinelToken(slug) {
7014
+ return createHmac("sha256", this.apiKey).update(`daemon-sentinel:${slug}`).digest("hex").slice(0, 48);
7015
+ }
7016
+ async startContainers(vm, token) {
7017
+ const env = {
7018
+ DAEMON_TOKEN: token,
7019
+ APP_ROOT: WORKDIR,
7020
+ PROXY_PORT: String(DAEMON_PORT),
7021
+ ORGFS_SIDECAR_CONFIG_PATH: `${CTL_DIR}/config.json`,
7022
+ ORGFS_SIDECAR_STATUS_PATH: `${CTL_DIR}/status.json`
7023
+ };
7024
+ const envFlags = Object.keys(env).map((k) => `-e ${shellQuote(k)}`).join(" ");
7025
+ await this.run(
7026
+ vm,
7027
+ `sudo sh -c ${shellQuote(HOST_SETUP)} && docker run -d --name ${SIDECAR} --restart=always --privileged --device /dev/fuse -e APP_ROOT=${WORKDIR} -e ORGFS_FEED_IDLE_MS=60000 -v ${HOST_ORG_DIR}:${WORKDIR}/org:rshared -v ${HOST_CTL_DIR}:${CTL_DIR} ${shellQuote(this.sidecarImage)}`,
7028
+ { timeoutMs: IMAGE_PULL_TIMEOUT_MS }
7029
+ );
7030
+ await this.run(
7031
+ vm,
7032
+ `docker run -d --name ${CONTAINER} --restart=always -p ${DAEMON_PORT}:${DAEMON_PORT} -v ${HOST_ORG_DIR}:${WORKDIR}/org:rslave -v ${HOST_CTL_DIR}:${CTL_DIR} ${envFlags} ${shellQuote(this.image)}`,
7033
+ { env, timeoutMs: IMAGE_PULL_TIMEOUT_MS }
7034
+ );
7035
+ }
6980
7036
  async snapshotExists(slug) {
6981
7037
  try {
6982
7038
  await this.freestyle.vms.snapshots.get(slug);
@@ -7041,7 +7097,19 @@ ${sidecarImage}`).digest("hex").slice(0, 16)}`;
7041
7097
  }
7042
7098
  }
7043
7099
  async create(handle, opts) {
7100
+ if (opts.env && Object.keys(opts.env).length > 0) {
7101
+ throw new Error(
7102
+ `${LOG_LABEL2}: per-sandbox container env is not supported`
7103
+ );
7104
+ }
7105
+ const ms = {};
7106
+ let last = Date.now();
7107
+ const step = (name) => {
7108
+ ms[name] = Date.now() - last;
7109
+ last = Date.now();
7110
+ };
7044
7111
  const snapshotId = await this.baseSnapshot();
7112
+ step("snapshot");
7045
7113
  const token = randomBytes2(24).toString("hex");
7046
7114
  const { vm } = await this.createVm({
7047
7115
  snapshotId,
@@ -7065,35 +7133,23 @@ ${sidecarImage}`).digest("hex").slice(0, 16)}`;
7065
7133
  ]
7066
7134
  }
7067
7135
  });
7136
+ step("vm-fork");
7068
7137
  const daemon = { url: this.daemonUrl(handle), token };
7069
7138
  try {
7070
- const env = {
7071
- ...opts.env,
7072
- DAEMON_TOKEN: token,
7073
- DAEMON_BOOT_ID: crypto.randomUUID(),
7074
- APP_ROOT: WORKDIR,
7075
- PROXY_PORT: String(DAEMON_PORT),
7076
- ORGFS_SIDECAR_CONFIG_PATH: `${CTL_DIR}/config.json`,
7077
- ORGFS_SIDECAR_STATUS_PATH: `${CTL_DIR}/status.json`
7078
- };
7079
- const envFlags = Object.keys(env).map((k) => `-e ${shellQuote(k)}`).join(" ");
7080
- await this.run(
7081
- vm,
7082
- `sudo sh -c ${shellQuote(HOST_SETUP)} && docker run -d --name ${SIDECAR} --restart=always --privileged --device /dev/fuse -e APP_ROOT=${WORKDIR} -e ORGFS_FEED_IDLE_MS=60000 -v ${HOST_ORG_DIR}:${WORKDIR}/org:rshared -v ${HOST_CTL_DIR}:${CTL_DIR} ${shellQuote(this.sidecarImage)}`,
7083
- { timeoutMs: IMAGE_PULL_TIMEOUT_MS }
7084
- );
7085
- await this.run(
7086
- vm,
7087
- `docker run -d --name ${CONTAINER} --restart=always -p ${DAEMON_PORT}:${DAEMON_PORT} -v ${HOST_ORG_DIR}:${WORKDIR}/org:rslave -v ${HOST_CTL_DIR}:${CTL_DIR} ${envFlags} ${shellQuote(this.image)}`,
7088
- { env, timeoutMs: IMAGE_PULL_TIMEOUT_MS }
7139
+ await this.claimDaemon(
7140
+ daemon.url,
7141
+ this.sentinelToken(snapshotId),
7142
+ token,
7143
+ this.configPayload(opts) ?? {}
7089
7144
  );
7090
- await this.waitForDaemon(daemon.url);
7091
- await postConfig(daemon.url, token, this.configPayload(opts) ?? {});
7145
+ step("claim");
7092
7146
  if (opts.orgFsConfigJson) {
7093
7147
  await postOrgFsConfig(daemon.url, token, opts.orgFsConfigJson).catch(
7094
7148
  (err) => console.warn(`[${LOG_LABEL2}] org-fs relay failed`, err)
7095
7149
  );
7096
7150
  }
7151
+ step("orgfs-config");
7152
+ console.log(`[${LOG_LABEL2}] create ${handle} ms=${JSON.stringify(ms)}`);
7097
7153
  } catch (err) {
7098
7154
  await vm.delete().catch(() => {
7099
7155
  });
@@ -7102,6 +7158,30 @@ ${sidecarImage}`).digest("hex").slice(0, 16)}`;
7102
7158
  }
7103
7159
  this.remember(handle, { vm, daemon });
7104
7160
  }
7161
+ /**
7162
+ * The fork's first config: swap the snapshot's sentinel bearer for this
7163
+ * sandbox's own, retried until the new domain routes. A 401 means an earlier
7164
+ * attempt already rotated (its response was lost), so finish with the new one.
7165
+ */
7166
+ async claimDaemon(url, sentinel, token, payload) {
7167
+ const deadline = Date.now() + DAEMON_BOOT_TIMEOUT_MS;
7168
+ for (; ; ) {
7169
+ try {
7170
+ await postConfig(url, sentinel, payload, { rotateToken: token });
7171
+ return;
7172
+ } catch (err) {
7173
+ if (err instanceof ConfigRequestError && err.status === 401) {
7174
+ await postConfig(url, token, payload);
7175
+ return;
7176
+ }
7177
+ if (err instanceof ConfigRequestError && (err.status === 400 || err.status === 409)) {
7178
+ throw err;
7179
+ }
7180
+ if (Date.now() > deadline) throw err;
7181
+ await delay(CLAIM_RETRY_MS);
7182
+ }
7183
+ }
7184
+ }
7105
7185
  /** A plan that caps autoDelete lower answers 400; omitting it gets the cap. */
7106
7186
  async createVm(opts) {
7107
7187
  try {
@@ -7259,12 +7339,64 @@ ${sidecarImage}`).digest("hex").slice(0, 16)}`;
7259
7339
  async renewTtl(handle) {
7260
7340
  this.cancelRelease(handle);
7261
7341
  }
7342
+ /**
7343
+ * Pause every running VM of ours whose daemon reports `idlePauseMs` without
7344
+ * use. The daemon's clock counts requests and streaming runs, not the dev
7345
+ * server's own traffic. Returns the handles paused; never throws.
7346
+ */
7347
+ async pauseIdle() {
7348
+ if (this.sweeping) return [];
7349
+ this.sweeping = true;
7350
+ try {
7351
+ const running = [];
7352
+ for (let offset = 0; ; offset += IDLE_SWEEP_PAGE) {
7353
+ const { vms } = await this.freestyle.vms.list({
7354
+ state: "running",
7355
+ limit: IDLE_SWEEP_PAGE,
7356
+ offset
7357
+ });
7358
+ running.push(...vms);
7359
+ if (vms.length < IDLE_SWEEP_PAGE) break;
7360
+ }
7361
+ const paused = await Promise.all(
7362
+ running.map(async (data) => {
7363
+ const handle = data.slug;
7364
+ if (!handle || !data.metadata[TOKEN_KEY]) return null;
7365
+ if (Date.now() - Date.parse(data.createdAt) < this.idlePauseMs) {
7366
+ return null;
7367
+ }
7368
+ const idleMs = await daemonIdleMs(this.daemonUrl(handle));
7369
+ if (idleMs === null || idleMs < this.idlePauseMs) return null;
7370
+ try {
7371
+ await this.freestyle.vms.ref(data.id).pause();
7372
+ } catch (err) {
7373
+ console.warn(
7374
+ `[${LOG_LABEL2}] pause ${handle} failed: ${errMsg2(err)}`
7375
+ );
7376
+ return null;
7377
+ }
7378
+ this.cancelRelease(handle);
7379
+ console.log(
7380
+ `[${LOG_LABEL2}] paused ${handle}, daemon idle ${Math.round(idleMs / 6e4)}m`
7381
+ );
7382
+ return handle;
7383
+ })
7384
+ );
7385
+ return paused.filter((h) => h !== null);
7386
+ } catch (err) {
7387
+ console.warn(`[${LOG_LABEL2}] idle sweep failed: ${errMsg2(err)}`);
7388
+ return [];
7389
+ } finally {
7390
+ this.sweeping = false;
7391
+ }
7392
+ }
7262
7393
  cancelRelease(handle) {
7263
7394
  const timer = this.releaseTimers.get(handle);
7264
7395
  if (timer) clearTimeout(timer);
7265
7396
  this.releaseTimers.delete(handle);
7266
7397
  }
7267
7398
  close() {
7399
+ if (this.idleSweep) clearInterval(this.idleSweep);
7268
7400
  for (const timer of this.releaseTimers.values()) clearTimeout(timer);
7269
7401
  this.releaseTimers.clear();
7270
7402
  this.lookups.clear();
@@ -7281,6 +7413,21 @@ async function daemonAnswers(url) {
7281
7413
  return false;
7282
7414
  }
7283
7415
  }
7416
+ async function daemonIdleMs(url) {
7417
+ try {
7418
+ const res = await fetch(`${url}/_sandbox/idle`, {
7419
+ signal: AbortSignal.timeout(HEALTH_PROBE_TIMEOUT_MS2)
7420
+ });
7421
+ if (!res.ok) {
7422
+ await res.body?.cancel();
7423
+ return null;
7424
+ }
7425
+ const body = await res.json();
7426
+ return typeof body === "object" && body !== null && "idleMs" in body && typeof body.idleMs === "number" ? body.idleMs : null;
7427
+ } catch {
7428
+ return null;
7429
+ }
7430
+ }
7284
7431
  function shellQuote(s) {
7285
7432
  return `'${s.replaceAll("'", `'\\''`)}'`;
7286
7433
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/sandbox-controller",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Runs Studio agent sandboxes on Kubernetes, and serves them to a remote Studio.",
5
5
  "repository": {
6
6
  "type": "git",