@decocms/sandbox-controller 0.6.0 → 0.7.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.
Files changed (3) hide show
  1. package/dist/index.d.ts +2421 -5
  2. package/dist/index.js +191 -29
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -842,6 +842,16 @@ function refSlugSource(projectRef) {
842
842
  }
843
843
 
844
844
  // ../sandbox/server/provider/types.ts
845
+ var SANDBOX_PLACEMENT_REASONS = [
846
+ "existing",
847
+ "requested",
848
+ "split",
849
+ "capacity",
850
+ "fallback",
851
+ "image",
852
+ "warm-pool",
853
+ "freestyle-unavailable"
854
+ ];
845
855
  function sandboxIdKey(id2) {
846
856
  return `${id2.userId}:${id2.projectRef}`;
847
857
  }
@@ -6749,7 +6759,7 @@ var Freestyle = class {
6749
6759
  // ../sandbox/package.json
6750
6760
  var package_default = {
6751
6761
  name: "@decocms/sandbox",
6752
- version: "1.79.0",
6762
+ version: "1.80.0",
6753
6763
  private: true,
6754
6764
  type: "module",
6755
6765
  description: "Sandbox runtime for isolated Studio tool execution",
@@ -6827,6 +6837,17 @@ var DAEMON_PORT = 9e3;
6827
6837
  var WORKDIR = "/app";
6828
6838
  var DEFAULT_DEV_PORT2 = 3e3;
6829
6839
  var CONTAINER = "sandbox";
6840
+ var SIDECAR = "orgfs";
6841
+ var DEFAULT_SIDECAR_IMAGE = "ghcr.io/decocms/studio/orgfs-sidecar:0.1.0";
6842
+ var HOST_ORG_DIR = "/srv/sandbox/org";
6843
+ var HOST_CTL_DIR = "/srv/sandbox/orgfs-ctl";
6844
+ var CTL_DIR = "/run/orgfs";
6845
+ var HOST_SETUP = [
6846
+ `mkdir -p ${HOST_ORG_DIR} ${HOST_CTL_DIR}`,
6847
+ `grep -q ' ${HOST_ORG_DIR} ' /etc/fstab || printf '%s\\n' '${HOST_ORG_DIR} ${HOST_ORG_DIR} none bind,rshared 0 0' 'tmpfs ${HOST_CTL_DIR} tmpfs uid=1000,gid=1000,mode=0755,size=1m 0 0' >> /etc/fstab`,
6848
+ `mountpoint -q ${HOST_ORG_DIR} || mount ${HOST_ORG_DIR}`,
6849
+ `mountpoint -q ${HOST_CTL_DIR} || mount ${HOST_CTL_DIR}`
6850
+ ].join(" && ");
6830
6851
  var TOKEN_KEY = "studio-daemon-token";
6831
6852
  var ORG_KEY = "studio-org-id";
6832
6853
  var USER_KEY = "studio-user-id";
@@ -6838,10 +6859,14 @@ var LOOKUP_TTL_MS = 5 * 6e4;
6838
6859
  var MISS_TTL_MS = 1e4;
6839
6860
  var LOOKUP_MAX = 5e3;
6840
6861
  var WATCH_TIMEOUT_MS = 5 * 6e4;
6862
+ var LOOKUP_TIMEOUT_MS = 3e3;
6841
6863
  var FreestyleSandboxProvider = class {
6842
6864
  freestyle;
6865
+ lookupClient;
6843
6866
  image;
6867
+ sidecarImage;
6844
6868
  idleTimeoutSeconds;
6869
+ autoDeleteSeconds;
6845
6870
  domainSuffix;
6846
6871
  timeDaemonRequest;
6847
6872
  inflight = new Inflight();
@@ -6849,9 +6874,24 @@ var FreestyleSandboxProvider = class {
6849
6874
  lookups = /* @__PURE__ */ new Map();
6850
6875
  releaseTimers = /* @__PURE__ */ new Map();
6851
6876
  constructor(opts) {
6852
- this.freestyle = new Freestyle({ apiKey: opts.apiKey });
6877
+ this.freestyle = opts.client ?? new Freestyle({ apiKey: opts.apiKey });
6878
+ this.lookupClient = opts.client ?? new Freestyle({
6879
+ apiKey: opts.apiKey,
6880
+ fetch: Object.assign(
6881
+ (input, init) => fetch(input, {
6882
+ ...init,
6883
+ signal: init?.signal ? AbortSignal.any([
6884
+ init.signal,
6885
+ AbortSignal.timeout(LOOKUP_TIMEOUT_MS)
6886
+ ]) : AbortSignal.timeout(LOOKUP_TIMEOUT_MS)
6887
+ }),
6888
+ { preconnect: fetch.preconnect }
6889
+ )
6890
+ });
6853
6891
  this.image = opts.image ?? `ghcr.io/decocms/studio/studio-sandbox-go:${package_default.version}`;
6892
+ this.sidecarImage = opts.orgFsSidecarImage ?? DEFAULT_SIDECAR_IMAGE;
6854
6893
  this.idleTimeoutSeconds = opts.idleTimeoutSeconds ?? 15 * 60;
6894
+ this.autoDeleteSeconds = opts.autoDeleteSeconds ?? 3 * 24 * 60 * 60;
6855
6895
  this.domainSuffix = opts.domainSuffix ?? "style.dev";
6856
6896
  this.timeDaemonRequest = daemonProxyTimer(opts.meter);
6857
6897
  }
@@ -6870,7 +6910,7 @@ var FreestyleSandboxProvider = class {
6870
6910
  }
6871
6911
  let found = null;
6872
6912
  try {
6873
- const data = await this.freestyle.vms.get(handle);
6913
+ const data = await this.lookupClient.vms.get(handle);
6874
6914
  const token = data.metadata[TOKEN_KEY];
6875
6915
  if (token) {
6876
6916
  found = {
@@ -6894,12 +6934,13 @@ var FreestyleSandboxProvider = class {
6894
6934
  }
6895
6935
  // ---- Base snapshot --------------------------------------------------------
6896
6936
  /**
6897
- * A snapshot of a VM with the image already pulled, so a sandbox boots in
6898
- * seconds instead of pulling ~2GB. One per image; built on first use.
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.
6899
6939
  */
6900
6940
  baseSnapshot() {
6901
- const image = this.image;
6902
- const slug = `studio-sandbox-${createHash3("sha256").update(image).digest("hex").slice(0, 16)}`;
6941
+ const { image, sidecarImage } = this;
6942
+ const slug = `studio-sandbox-${createHash3("sha256").update(`${image}
6943
+ ${sidecarImage}`).digest("hex").slice(0, 16)}`;
6903
6944
  return this.snapshotInflight.run(slug, async () => {
6904
6945
  if (await this.snapshotExists(slug)) return slug;
6905
6946
  console.log(`[${LOG_LABEL2}] building base snapshot ${slug} for ${image}`);
@@ -6912,9 +6953,11 @@ var FreestyleSandboxProvider = class {
6912
6953
  }
6913
6954
  });
6914
6955
  try {
6915
- await this.run(vm, `docker pull -q ${shellQuote(image)}`, {
6916
- timeoutMs: IMAGE_PULL_TIMEOUT_MS
6917
- });
6956
+ await this.run(
6957
+ vm,
6958
+ `docker pull -q ${shellQuote(image)} && docker pull -q ${shellQuote(sidecarImage)}`,
6959
+ { timeoutMs: IMAGE_PULL_TIMEOUT_MS }
6960
+ );
6918
6961
  await vm.snapshot({ slug });
6919
6962
  } catch (err) {
6920
6963
  if (!await this.snapshotExists(slug)) throw err;
@@ -6972,6 +7015,13 @@ var FreestyleSandboxProvider = class {
6972
7015
  const { state } = await found.vm.data();
6973
7016
  if (state !== "running") await found.vm.start();
6974
7017
  await this.waitForDaemon(found.daemon.url);
7018
+ if (opts.orgFsConfigJson) {
7019
+ await postOrgFsConfig(
7020
+ found.daemon.url,
7021
+ found.daemon.token,
7022
+ opts.orgFsConfigJson
7023
+ ).catch((err) => console.warn(`[${LOG_LABEL2}] org-fs relay failed`, err));
7024
+ }
6975
7025
  const patch = gitCredentialRefreshPatch(opts);
6976
7026
  if (patch) {
6977
7027
  await postConfig(found.daemon.url, found.daemon.token, patch).catch(
@@ -6984,7 +7034,7 @@ var FreestyleSandboxProvider = class {
6984
7034
  async create(handle, opts) {
6985
7035
  const snapshotId = await this.baseSnapshot();
6986
7036
  const token = randomBytes2(24).toString("hex");
6987
- const { vm } = await this.freestyle.vms.create({
7037
+ const { vm } = await this.createVm({
6988
7038
  snapshotId,
6989
7039
  slug: handle,
6990
7040
  idleTimeoutSeconds: this.idleTimeoutSeconds,
@@ -7013,12 +7063,19 @@ var FreestyleSandboxProvider = class {
7013
7063
  DAEMON_TOKEN: token,
7014
7064
  DAEMON_BOOT_ID: crypto.randomUUID(),
7015
7065
  APP_ROOT: WORKDIR,
7016
- PROXY_PORT: String(DAEMON_PORT)
7066
+ PROXY_PORT: String(DAEMON_PORT),
7067
+ ORGFS_SIDECAR_CONFIG_PATH: `${CTL_DIR}/config.json`,
7068
+ ORGFS_SIDECAR_STATUS_PATH: `${CTL_DIR}/status.json`
7017
7069
  };
7018
7070
  const envFlags = Object.keys(env).map((k) => `-e ${shellQuote(k)}`).join(" ");
7019
7071
  await this.run(
7020
7072
  vm,
7021
- `docker run -d --name ${CONTAINER} --restart=always -p ${DAEMON_PORT}:${DAEMON_PORT} ${envFlags} ${shellQuote(this.image)}`,
7073
+ `sudo sh -c ${shellQuote(HOST_SETUP)} && docker run -d --name ${SIDECAR} --restart=always --privileged --device /dev/fuse -e APP_ROOT=${WORKDIR} -v ${HOST_ORG_DIR}:${WORKDIR}/org:rshared -v ${HOST_CTL_DIR}:${CTL_DIR} ${shellQuote(this.sidecarImage)}`,
7074
+ { timeoutMs: IMAGE_PULL_TIMEOUT_MS }
7075
+ );
7076
+ await this.run(
7077
+ vm,
7078
+ `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)}`,
7022
7079
  { env, timeoutMs: IMAGE_PULL_TIMEOUT_MS }
7023
7080
  );
7024
7081
  await this.waitForDaemon(daemon.url);
@@ -7036,6 +7093,21 @@ var FreestyleSandboxProvider = class {
7036
7093
  }
7037
7094
  this.remember(handle, { vm, daemon });
7038
7095
  }
7096
+ /** A plan that caps autoDelete lower answers 400; omitting it gets the cap. */
7097
+ async createVm(opts) {
7098
+ try {
7099
+ return await this.freestyle.vms.create({
7100
+ ...opts,
7101
+ autoDeleteSeconds: this.autoDeleteSeconds
7102
+ });
7103
+ } catch (err) {
7104
+ if (!(err instanceof FreestyleApiError && err.status === 400)) throw err;
7105
+ console.warn(
7106
+ `[${LOG_LABEL2}] autoDeleteSeconds=${this.autoDeleteSeconds} refused (${err.message}); creating with the plan's cap`
7107
+ );
7108
+ return this.freestyle.vms.create(opts);
7109
+ }
7110
+ }
7039
7111
  configPayload(opts) {
7040
7112
  return buildConfigPayload({
7041
7113
  runtime: opts.workload?.runtime ?? "node",
@@ -7065,7 +7137,10 @@ var FreestyleSandboxProvider = class {
7065
7137
  const found = await this.find(handle, { refresh: true });
7066
7138
  this.lookups.delete(handle);
7067
7139
  if (!found) return;
7068
- await found.vm.exec({ command: `docker stop -t 30 ${CONTAINER}`, timeoutMs: 45e3 }).catch(() => {
7140
+ await found.vm.exec({
7141
+ command: `docker stop -t 30 ${CONTAINER}; docker stop -t 10 ${SIDECAR}`,
7142
+ timeoutMs: 6e4
7143
+ }).catch(() => {
7069
7144
  });
7070
7145
  await found.vm.delete();
7071
7146
  }
@@ -7228,6 +7303,10 @@ function handleShare(handle) {
7228
7303
  return Number.parseInt(hex, 16) / 2 ** 32;
7229
7304
  }
7230
7305
  var PICKED_MAX = 5e3;
7306
+ var other = (kind) => kind === "kubernetes" ? "freestyle" : "kubernetes";
7307
+ function repeatsElsewhere(err) {
7308
+ return err instanceof ConfigRequestError;
7309
+ }
7231
7310
  var SandboxProviderRouter = class {
7232
7311
  constructor(providers, opts = {}) {
7233
7312
  this.providers = providers;
@@ -7238,6 +7317,14 @@ var SandboxProviderRouter = class {
7238
7317
  }
7239
7318
  providers;
7240
7319
  picked = /* @__PURE__ */ new Map();
7320
+ /**
7321
+ * Handles whose sandbox on this provider outlived a fallback because its
7322
+ * delete failed. Retried when `place` next sees the handle.
7323
+ *
7324
+ * ponytail: in-process, lost on restart; Freestyle's autoDeleteSeconds
7325
+ * reclaims a stray paused VM anyway, only later.
7326
+ */
7327
+ strays = /* @__PURE__ */ new Map();
7241
7328
  freestyleShare;
7242
7329
  get(kind) {
7243
7330
  const provider = this.providers[kind];
@@ -7260,12 +7347,13 @@ var SandboxProviderRouter = class {
7260
7347
  if (!kubernetes) return "freestyle";
7261
7348
  const picked = this.picked.get(handle);
7262
7349
  if (picked) return picked;
7263
- return await freestyle.owns(handle) ? "freestyle" : "kubernetes";
7350
+ const owned = await freestyle.owns(handle).catch(() => false);
7351
+ return owned ? "freestyle" : "kubernetes";
7264
7352
  }
7265
7353
  async owner(handle) {
7266
7354
  return this.get(await this.providerOf(handle));
7267
7355
  }
7268
- /** Where `ensure` puts this sandbox. */
7356
+ /** Where `ensure` puts this sandbox, and why. */
7269
7357
  async place(handle, opts) {
7270
7358
  const { kubernetes, freestyle } = this.providers;
7271
7359
  const image = opts.sandboxImage ?? "default";
@@ -7273,30 +7361,98 @@ var SandboxProviderRouter = class {
7273
7361
  if (!kubernetes) {
7274
7362
  throw new Error(`sandbox image ${image} needs the kubernetes provider`);
7275
7363
  }
7276
- return "kubernetes";
7364
+ return { kind: "kubernetes", reason: freestyle ? "image" : void 0 };
7365
+ }
7366
+ if (!kubernetes) return { kind: "freestyle" };
7367
+ if (!freestyle) return { kind: "kubernetes" };
7368
+ const stray = this.strays.get(handle);
7369
+ if (stray) this.retire(stray, handle);
7370
+ if (stray !== "kubernetes" && await kubernetes.alive(handle).catch(() => false)) {
7371
+ return { kind: "kubernetes", reason: "existing" };
7372
+ }
7373
+ const owned = stray === "freestyle" ? false : await freestyle.owns(handle).catch(() => null);
7374
+ if (owned) return { kind: "freestyle", reason: "existing" };
7375
+ if (owned === null) {
7376
+ return { kind: "kubernetes", reason: "freestyle-unavailable" };
7377
+ }
7378
+ if (opts.provider) return { kind: opts.provider, reason: "requested" };
7379
+ if (this.hasWarmPool(opts, image)) {
7380
+ return { kind: "kubernetes", reason: "warm-pool" };
7381
+ }
7382
+ if (handleShare(handle) < this.freestyleShare) {
7383
+ return { kind: "freestyle", reason: "split" };
7277
7384
  }
7278
- if (!kubernetes) return "freestyle";
7279
- if (!freestyle) return "kubernetes";
7280
- if (await freestyle.owns(handle)) return "freestyle";
7281
- if (await kubernetes.alive(handle).catch(() => false)) return "kubernetes";
7282
- if (opts.provider) return opts.provider;
7283
- if (handleShare(handle) < this.freestyleShare) return "freestyle";
7284
7385
  const roomy = await kubernetes.hasSchedulableCapacity().catch(() => false);
7285
- return roomy ? "kubernetes" : "freestyle";
7386
+ return roomy ? { kind: "kubernetes", reason: "split" } : { kind: "freestyle", reason: "capacity" };
7387
+ }
7388
+ /** The org has pods already cloned and running for this image. */
7389
+ hasWarmPool(opts, image) {
7390
+ const tenant = claimTenantKey(opts);
7391
+ if (!tenant) return false;
7392
+ const pools = this.providers.kubernetes?.listTenantPools?.() ?? [];
7393
+ return pools.some((p) => p.tenant === tenant && p.image === image);
7286
7394
  }
7287
7395
  async ensure(id2, opts = {}) {
7288
7396
  const { provider: _requested, ...rest } = opts;
7289
7397
  const handle = computeHandle(id2);
7290
- const kind = await this.place(handle, opts);
7291
- this.remember(handle, kind);
7398
+ const placed = await this.place(handle, opts);
7399
+ this.remember(handle, placed.kind);
7400
+ if (this.strays.get(handle) === placed.kind) this.strays.delete(handle);
7401
+ let failure;
7402
+ try {
7403
+ const sandbox2 = await this.get(placed.kind).ensure(id2, rest);
7404
+ return placed.reason ? { ...sandbox2, placement: { reason: placed.reason } } : sandbox2;
7405
+ } catch (err) {
7406
+ failure = tagSandboxProvider(err, placed.kind);
7407
+ }
7408
+ const fallback = other(placed.kind);
7409
+ if (!placed.reason || placed.reason === "image" || repeatsElsewhere(failure)) {
7410
+ throw failure;
7411
+ }
7412
+ console.warn(
7413
+ `[SandboxProviderRouter] ensure ${handle} failed on ${placed.kind}, trying ${fallback}:`,
7414
+ failure instanceof Error ? failure.message : String(failure)
7415
+ );
7416
+ this.remember(handle, fallback);
7417
+ let sandbox;
7292
7418
  try {
7293
- return await this.get(kind).ensure(id2, rest);
7419
+ sandbox = await this.get(fallback).ensure(id2, rest);
7294
7420
  } catch (err) {
7295
- throw tagSandboxProvider(err, kind);
7421
+ this.picked.delete(handle);
7422
+ throw tagSandboxProvider(err, fallback);
7296
7423
  }
7424
+ if (placed.reason === "existing") this.retire(placed.kind, handle);
7425
+ return {
7426
+ ...sandbox,
7427
+ placement: { reason: "fallback", fallbackFrom: placed.kind }
7428
+ };
7429
+ }
7430
+ /** Delete the sandbox a fallback left behind on `kind`, in the background. */
7431
+ retire(kind, handle) {
7432
+ this.strays.set(handle, kind);
7433
+ void retry(
7434
+ async () => {
7435
+ if (this.strays.get(handle) === kind)
7436
+ await this.get(kind).delete(handle);
7437
+ },
7438
+ { maxAttempts: 3 }
7439
+ ).then(() => {
7440
+ if (this.strays.get(handle) === kind) this.strays.delete(handle);
7441
+ }).catch(
7442
+ (err) => console.warn(
7443
+ `[SandboxProviderRouter] delete of ${kind} sandbox ${handle} after fallback failed:`,
7444
+ err instanceof Error ? err.message : String(err)
7445
+ )
7446
+ );
7297
7447
  }
7298
7448
  async delete(handle) {
7299
- await (await this.owner(handle)).delete(handle);
7449
+ const stray = this.strays.get(handle);
7450
+ this.strays.delete(handle);
7451
+ await Promise.all([
7452
+ (await this.owner(handle)).delete(handle),
7453
+ stray && this.get(stray).delete(handle).catch(() => {
7454
+ })
7455
+ ]);
7300
7456
  this.picked.delete(handle);
7301
7457
  }
7302
7458
  async lastTermination(handle) {
@@ -7356,6 +7512,7 @@ var SandboxProviderRouter = class {
7356
7512
  this.providers.kubernetes?.close();
7357
7513
  this.providers.freestyle?.close();
7358
7514
  this.picked.clear();
7515
+ this.strays.clear();
7359
7516
  }
7360
7517
  };
7361
7518
 
@@ -7440,6 +7597,11 @@ var ensureOutputSchema = z4.object({
7440
7597
  previewUrl: z4.string().nullable(),
7441
7598
  warmPoolAdopted: z4.boolean(),
7442
7599
  provider: providerKindSchema.default("kubernetes"),
7600
+ // A reason this build doesn't know drops the label, not the ensure.
7601
+ placement: z4.object({
7602
+ reason: z4.enum(SANDBOX_PLACEMENT_REASONS),
7603
+ fallbackFrom: providerKindSchema.optional()
7604
+ }).optional().catch(void 0),
7443
7605
  daemon: daemonSchema
7444
7606
  });
7445
7607
  var handleInputSchema = z4.object({ handle: z4.string().min(1) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/sandbox-controller",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Runs Studio agent sandboxes on Kubernetes, and serves them to a remote Studio.",
5
5
  "repository": {
6
6
  "type": "git",