@decocms/sandbox-controller 0.8.0 → 0.8.1

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
@@ -3730,6 +3730,7 @@ interface FreestyleSandboxProviderOptions {
3730
3730
  client?: Freestyle;
3731
3731
  }
3732
3732
  declare class FreestyleSandboxProvider implements SandboxProvider {
3733
+ private readonly apiKey;
3733
3734
  private readonly freestyle;
3734
3735
  private readonly lookupClient;
3735
3736
  private readonly image;
@@ -3740,6 +3741,8 @@ declare class FreestyleSandboxProvider implements SandboxProvider {
3740
3741
  private readonly timeDaemonRequest;
3741
3742
  private readonly inflight;
3742
3743
  private readonly snapshotInflight;
3744
+ /** Snapshots are immutable once built, so one positive lookup is enough. */
3745
+ private readonly knownSnapshots;
3743
3746
  private readonly lookups;
3744
3747
  private readonly releaseTimers;
3745
3748
  constructor(opts: FreestyleSandboxProviderOptions);
@@ -3749,8 +3752,9 @@ declare class FreestyleSandboxProvider implements SandboxProvider {
3749
3752
  private find;
3750
3753
  private remember;
3751
3754
  /**
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.
3755
+ * A snapshot of a VM with both containers RUNNING and the daemon healthy but
3756
+ * unclaimed, so a fork resumes a live daemon (Freestyle snapshots memory) and
3757
+ * is configured with one request instead of booted. One per image pair.
3754
3758
  */
3755
3759
  /**
3756
3760
  * Build this image's base snapshot now, so the first sandbox after an image
@@ -3758,11 +3762,24 @@ declare class FreestyleSandboxProvider implements SandboxProvider {
3758
3762
  */
3759
3763
  warm(): Promise<void>;
3760
3764
  private baseSnapshot;
3765
+ /**
3766
+ * The bearer the snapshot's daemon boots with. Every fork shares it until its
3767
+ * first config rotates it, so it is derived (any replica can compute it) and
3768
+ * never stored.
3769
+ */
3770
+ private sentinelToken;
3771
+ private startContainers;
3761
3772
  private snapshotExists;
3762
3773
  private run;
3763
3774
  ensure(id: SandboxId, opts?: EnsureOptions): Promise<Sandbox>;
3764
3775
  private resume;
3765
3776
  private create;
3777
+ /**
3778
+ * The fork's first config: swap the snapshot's sentinel bearer for this
3779
+ * sandbox's own, retried until the new domain routes. A 401 means an earlier
3780
+ * attempt already rotated (its response was lost), so finish with the new one.
3781
+ */
3782
+ private claimDaemon;
3766
3783
  /** A plan that caps autoDelete lower answers 400; omitting it gets the cap. */
3767
3784
  private createVm;
3768
3785
  private configPayload;
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.1",
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;
@@ -6861,6 +6862,7 @@ var LOOKUP_MAX = 5e3;
6861
6862
  var WATCH_TIMEOUT_MS = 5 * 6e4;
6862
6863
  var LOOKUP_TIMEOUT_MS = 3e3;
6863
6864
  var FreestyleSandboxProvider = class {
6865
+ apiKey;
6864
6866
  freestyle;
6865
6867
  lookupClient;
6866
6868
  image;
@@ -6871,9 +6873,12 @@ var FreestyleSandboxProvider = class {
6871
6873
  timeDaemonRequest;
6872
6874
  inflight = new Inflight();
6873
6875
  snapshotInflight = new Inflight();
6876
+ /** Snapshots are immutable once built, so one positive lookup is enough. */
6877
+ knownSnapshots = /* @__PURE__ */ new Set();
6874
6878
  lookups = /* @__PURE__ */ new Map();
6875
6879
  releaseTimers = /* @__PURE__ */ new Map();
6876
6880
  constructor(opts) {
6881
+ this.apiKey = opts.apiKey;
6877
6882
  this.freestyle = opts.client ?? new Freestyle({ apiKey: opts.apiKey });
6878
6883
  this.lookupClient = opts.client ?? new Freestyle({
6879
6884
  apiKey: opts.apiKey,
@@ -6934,8 +6939,9 @@ var FreestyleSandboxProvider = class {
6934
6939
  }
6935
6940
  // ---- Base snapshot --------------------------------------------------------
6936
6941
  /**
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.
6942
+ * A snapshot of a VM with both containers RUNNING and the daemon healthy but
6943
+ * unclaimed, so a fork resumes a live daemon (Freestyle snapshots memory) and
6944
+ * is configured with one request instead of booted. One per image pair.
6939
6945
  */
6940
6946
  /**
6941
6947
  * Build this image's base snapshot now, so the first sandbox after an image
@@ -6949,9 +6955,14 @@ var FreestyleSandboxProvider = class {
6949
6955
  baseSnapshot() {
6950
6956
  const { image, sidecarImage } = this;
6951
6957
  const slug = `studio-sandbox-${createHash3("sha256").update(`${image}
6952
- ${sidecarImage}`).digest("hex").slice(0, 16)}`;
6958
+ ${sidecarImage}
6959
+ running`).digest("hex").slice(0, 16)}`;
6960
+ if (this.knownSnapshots.has(slug)) return Promise.resolve(slug);
6953
6961
  return this.snapshotInflight.run(slug, async () => {
6954
- if (await this.snapshotExists(slug)) return slug;
6962
+ if (await this.snapshotExists(slug)) {
6963
+ this.knownSnapshots.add(slug);
6964
+ return slug;
6965
+ }
6955
6966
  console.log(`[${LOG_LABEL2}] building base snapshot ${slug} for ${image}`);
6956
6967
  const { vm } = await this.freestyle.vms.create({
6957
6968
  ttlSeconds: 1800,
@@ -6967,7 +6978,14 @@ ${sidecarImage}`).digest("hex").slice(0, 16)}`;
6967
6978
  `docker pull -q ${shellQuote(image)} && docker pull -q ${shellQuote(sidecarImage)}`,
6968
6979
  { timeoutMs: IMAGE_PULL_TIMEOUT_MS }
6969
6980
  );
6981
+ await this.startContainers(vm, this.sentinelToken(slug));
6982
+ await this.run(
6983
+ vm,
6984
+ `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`,
6985
+ { timeoutMs: DAEMON_BOOT_TIMEOUT_MS + 1e4 }
6986
+ );
6970
6987
  await vm.snapshot({ slug });
6988
+ this.knownSnapshots.add(slug);
6971
6989
  } catch (err) {
6972
6990
  if (!await this.snapshotExists(slug)) throw err;
6973
6991
  } finally {
@@ -6977,6 +6995,34 @@ ${sidecarImage}`).digest("hex").slice(0, 16)}`;
6977
6995
  return slug;
6978
6996
  });
6979
6997
  }
6998
+ /**
6999
+ * The bearer the snapshot's daemon boots with. Every fork shares it until its
7000
+ * first config rotates it, so it is derived (any replica can compute it) and
7001
+ * never stored.
7002
+ */
7003
+ sentinelToken(slug) {
7004
+ return createHmac("sha256", this.apiKey).update(`daemon-sentinel:${slug}`).digest("hex").slice(0, 48);
7005
+ }
7006
+ async startContainers(vm, token) {
7007
+ const env = {
7008
+ DAEMON_TOKEN: token,
7009
+ APP_ROOT: WORKDIR,
7010
+ PROXY_PORT: String(DAEMON_PORT),
7011
+ ORGFS_SIDECAR_CONFIG_PATH: `${CTL_DIR}/config.json`,
7012
+ ORGFS_SIDECAR_STATUS_PATH: `${CTL_DIR}/status.json`
7013
+ };
7014
+ const envFlags = Object.keys(env).map((k) => `-e ${shellQuote(k)}`).join(" ");
7015
+ await this.run(
7016
+ vm,
7017
+ `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)}`,
7018
+ { timeoutMs: IMAGE_PULL_TIMEOUT_MS }
7019
+ );
7020
+ await this.run(
7021
+ vm,
7022
+ `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)}`,
7023
+ { env, timeoutMs: IMAGE_PULL_TIMEOUT_MS }
7024
+ );
7025
+ }
6980
7026
  async snapshotExists(slug) {
6981
7027
  try {
6982
7028
  await this.freestyle.vms.snapshots.get(slug);
@@ -7041,7 +7087,19 @@ ${sidecarImage}`).digest("hex").slice(0, 16)}`;
7041
7087
  }
7042
7088
  }
7043
7089
  async create(handle, opts) {
7090
+ if (opts.env && Object.keys(opts.env).length > 0) {
7091
+ throw new Error(
7092
+ `${LOG_LABEL2}: per-sandbox container env is not supported`
7093
+ );
7094
+ }
7095
+ const ms = {};
7096
+ let last = Date.now();
7097
+ const step = (name) => {
7098
+ ms[name] = Date.now() - last;
7099
+ last = Date.now();
7100
+ };
7044
7101
  const snapshotId = await this.baseSnapshot();
7102
+ step("snapshot");
7045
7103
  const token = randomBytes2(24).toString("hex");
7046
7104
  const { vm } = await this.createVm({
7047
7105
  snapshotId,
@@ -7065,35 +7123,23 @@ ${sidecarImage}`).digest("hex").slice(0, 16)}`;
7065
7123
  ]
7066
7124
  }
7067
7125
  });
7126
+ step("vm-fork");
7068
7127
  const daemon = { url: this.daemonUrl(handle), token };
7069
7128
  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 }
7129
+ await this.claimDaemon(
7130
+ daemon.url,
7131
+ this.sentinelToken(snapshotId),
7132
+ token,
7133
+ this.configPayload(opts) ?? {}
7089
7134
  );
7090
- await this.waitForDaemon(daemon.url);
7091
- await postConfig(daemon.url, token, this.configPayload(opts) ?? {});
7135
+ step("claim");
7092
7136
  if (opts.orgFsConfigJson) {
7093
7137
  await postOrgFsConfig(daemon.url, token, opts.orgFsConfigJson).catch(
7094
7138
  (err) => console.warn(`[${LOG_LABEL2}] org-fs relay failed`, err)
7095
7139
  );
7096
7140
  }
7141
+ step("orgfs-config");
7142
+ console.log(`[${LOG_LABEL2}] create ${handle} ms=${JSON.stringify(ms)}`);
7097
7143
  } catch (err) {
7098
7144
  await vm.delete().catch(() => {
7099
7145
  });
@@ -7102,6 +7148,30 @@ ${sidecarImage}`).digest("hex").slice(0, 16)}`;
7102
7148
  }
7103
7149
  this.remember(handle, { vm, daemon });
7104
7150
  }
7151
+ /**
7152
+ * The fork's first config: swap the snapshot's sentinel bearer for this
7153
+ * sandbox's own, retried until the new domain routes. A 401 means an earlier
7154
+ * attempt already rotated (its response was lost), so finish with the new one.
7155
+ */
7156
+ async claimDaemon(url, sentinel, token, payload) {
7157
+ const deadline = Date.now() + DAEMON_BOOT_TIMEOUT_MS;
7158
+ for (; ; ) {
7159
+ try {
7160
+ await postConfig(url, sentinel, payload, { rotateToken: token });
7161
+ return;
7162
+ } catch (err) {
7163
+ if (err instanceof ConfigRequestError && err.status === 401) {
7164
+ await postConfig(url, token, payload);
7165
+ return;
7166
+ }
7167
+ if (err instanceof ConfigRequestError && (err.status === 400 || err.status === 409)) {
7168
+ throw err;
7169
+ }
7170
+ if (Date.now() > deadline) throw err;
7171
+ await delay(CLAIM_RETRY_MS);
7172
+ }
7173
+ }
7174
+ }
7105
7175
  /** A plan that caps autoDelete lower answers 400; omitting it gets the cap. */
7106
7176
  async createVm(opts) {
7107
7177
  try {
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.1",
4
4
  "description": "Runs Studio agent sandboxes on Kubernetes, and serves them to a remote Studio.",
5
5
  "repository": {
6
6
  "type": "git",