@decocms/sandbox-controller 0.14.0 → 0.15.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
@@ -81,6 +81,8 @@ interface Workload {
81
81
  devPort?: number;
82
82
  /** Subdirectory inside the repo where the package manager manifest lives (e.g. `apps/web`). */
83
83
  packageManagerPath?: string;
84
+ /** Other monorepo apps to serve beside it, by path. Absent keeps what the sandbox has. */
85
+ apps?: string[];
84
86
  }
85
87
  /**
86
88
  * What a sandbox is for. AgentSandboxProvider may size, pool, and place the two
@@ -1054,6 +1056,11 @@ declare class AgentSandboxProvider {
1054
1056
  * mounts are additive, a relay failure must not fail provisioning/recovery.
1055
1057
  */
1056
1058
  private relayOrgFsConfig;
1059
+ /**
1060
+ * Bring a resumed/adopted sandbox's extra apps and their preview hosts in
1061
+ * line with the caller's. Best-effort, like the credential refresh.
1062
+ */
1063
+ private syncExtraApps;
1057
1064
  /**
1058
1065
  * Forward a freshly-minted clone credential to a resumed/adopted sandbox's
1059
1066
  * daemon so authenticated git survives the ~1h expiry of the token baked in
@@ -3766,6 +3773,8 @@ declare class FreestyleSandboxProvider implements SandboxProvider {
3766
3773
  private sweeping;
3767
3774
  /** Org + repo → the newest repo snapshot known to exist, and the head it was built for. */
3768
3775
  private readonly repoSnapshots;
3776
+ /** Handle → the app domains its VM was last given, so a resume skips the API. */
3777
+ private readonly appIngress;
3769
3778
  /** Org + repo with a build running, so a push mid-build waits for it. */
3770
3779
  private readonly repoSnapshotBuilds;
3771
3780
  /** When a repo's last build failed; a build takes up to 30 min, so not on every ensure. */
@@ -3824,6 +3833,13 @@ declare class FreestyleSandboxProvider implements SandboxProvider {
3824
3833
  private run;
3825
3834
  ensure(id: SandboxId, opts?: EnsureOptions): Promise<Sandbox>;
3826
3835
  private resume;
3836
+ /** Public hosts of a sandbox's extra apps; the daemon routes them by Host. */
3837
+ private appDomains;
3838
+ /**
3839
+ * Gives each extra app of a running VM its ingress, and takes it from apps
3840
+ * no longer configured.
3841
+ */
3842
+ private syncAppIngress;
3827
3843
  /**
3828
3844
  * The bearer this VM was claimed with lives in daemon memory. A cold boot
3829
3845
  * (Freestyle restarts a guest it finds wedged) brings the daemon back on the
package/dist/index.js CHANGED
@@ -3,6 +3,14 @@ import "./chunk-QGM4M3NI.js";
3
3
  // index.ts
4
4
  import { KubeConfig } from "@kubernetes/client-node";
5
5
 
6
+ // ../shared/src/app-preview-host.ts
7
+ function appSlug(path) {
8
+ return path.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
9
+ }
10
+ function appHostLabel(handle, path) {
11
+ return `${handle}--${appSlug(path)}`;
12
+ }
13
+
6
14
  // ../sandbox/server/provider/agent-sandbox/runner.ts
7
15
  import { createHash as createHash2, randomBytes, randomUUID } from "crypto";
8
16
  import * as net from "net";
@@ -957,7 +965,8 @@ function buildConfigPayload(args) {
957
965
  const application = packageManager ? {
958
966
  packageManager,
959
967
  runtime: args.runtime,
960
- ...args.port !== void 0 ? { port: args.port } : {}
968
+ ...args.port !== void 0 ? { port: args.port } : {},
969
+ ...args.apps ? { apps: args.apps.map((path) => ({ path })) } : {}
961
970
  } : void 0;
962
971
  const orgId = tenant?.orgId;
963
972
  if (!git && !application && !operator && args.cloneOnly === void 0 && !orgId) {
@@ -979,6 +988,9 @@ function buildConfigPayload(args) {
979
988
  ...application ? { application } : {}
980
989
  };
981
990
  }
991
+ function extraAppsPatch(apps) {
992
+ return { application: { apps: apps.map((path) => ({ path })) } };
993
+ }
982
994
  function deriveRepoLabel(cloneUrl) {
983
995
  try {
984
996
  const u = new URL(cloneUrl);
@@ -2477,6 +2489,7 @@ var AgentSandboxProvider = class {
2477
2489
  const rec = await this.rehydrate(id2, handle, persisted);
2478
2490
  if (rec) {
2479
2491
  await this.refreshDaemonGitCredential(rec, opts);
2492
+ await this.syncExtraApps(rec, opts);
2480
2493
  await this.relayOrgFsConfig(
2481
2494
  rec.daemonUrl,
2482
2495
  rec.token,
@@ -2553,6 +2566,7 @@ var AgentSandboxProvider = class {
2553
2566
  });
2554
2567
  if (adopted) {
2555
2568
  await this.refreshDaemonGitCredential(adopted, opts);
2569
+ await this.syncExtraApps(adopted, opts);
2556
2570
  await this.relayOrgFsConfig(
2557
2571
  adopted.daemonUrl,
2558
2572
  adopted.token,
@@ -2794,7 +2808,8 @@ var AgentSandboxProvider = class {
2794
2808
  await this.ensureHttpRouteForHandle(
2795
2809
  handle,
2796
2810
  adoptedSandboxName,
2797
- opts.tenant ?? null
2811
+ opts.tenant ?? null,
2812
+ opts.workload?.apps
2798
2813
  );
2799
2814
  } catch (err) {
2800
2815
  await deleteSandboxClaim(this.kubeConfig, this.namespace, handle).catch(
@@ -2875,6 +2890,7 @@ var AgentSandboxProvider = class {
2875
2890
  repo: opts?.repo ?? null,
2876
2891
  extraRepos: opts?.extraRepos ?? [],
2877
2892
  port: opts?.workload?.devPort ?? DEFAULT_DEV_PORT,
2893
+ apps: opts?.workload?.apps,
2878
2894
  tenant: opts?.tenant ?? void 0,
2879
2895
  // Sent on every ensure that carries opts, so a warm-pool pod inheriting a
2880
2896
  // previous claim's clone-only config gets it cleared on a normal one.
@@ -2896,6 +2912,27 @@ var AgentSandboxProvider = class {
2896
2912
  (err) => console.warn("[org-fs] sidecar config relay failed", err)
2897
2913
  );
2898
2914
  }
2915
+ /**
2916
+ * Bring a resumed/adopted sandbox's extra apps and their preview hosts in
2917
+ * line with the caller's. Best-effort, like the credential refresh.
2918
+ */
2919
+ async syncExtraApps(rec, opts) {
2920
+ const apps = opts.workload?.apps;
2921
+ if (!apps) return;
2922
+ try {
2923
+ await this.ensureHttpRouteForHandle(
2924
+ rec.handle,
2925
+ rec.adoptedSandboxName,
2926
+ rec.tenant,
2927
+ apps
2928
+ );
2929
+ await postConfig(rec.daemonUrl, rec.token, extraAppsPatch(apps));
2930
+ } catch (err) {
2931
+ console.warn(
2932
+ `[${LOG_LABEL}] extra apps sync failed for ${rec.handle}: ${err instanceof Error ? err.message : String(err)}`
2933
+ );
2934
+ }
2935
+ }
2899
2936
  /**
2900
2937
  * Forward a freshly-minted clone credential to a resumed/adopted sandbox's
2901
2938
  * daemon so authenticated git survives the ~1h expiry of the token baked in
@@ -3353,14 +3390,18 @@ var AgentSandboxProvider = class {
3353
3390
  * `ensureServicePortForAdoptedSandbox` does — we want traffic to land on
3354
3391
  * the actually-bound pool pod, not the operator's cold-path orphan.
3355
3392
  */
3356
- async ensureHttpRouteForHandle(handle, adoptedSandboxName, tenant) {
3393
+ async ensureHttpRouteForHandle(handle, adoptedSandboxName, tenant, apps = []) {
3357
3394
  if (!this.previewGateway || !this.previewUrlPattern) return;
3358
- const hostname = previewHostnameForHandle(this.previewUrlPattern, handle);
3395
+ const pattern = this.previewUrlPattern;
3396
+ const hostname = previewHostnameForHandle(pattern, handle);
3359
3397
  if (!hostname) {
3360
3398
  throw new SandboxError(
3361
- `Unable to derive preview hostname for ${handle} from pattern: ${this.previewUrlPattern}`
3399
+ `Unable to derive preview hostname for ${handle} from pattern: ${pattern}`
3362
3400
  );
3363
3401
  }
3402
+ const appHostnames = apps.flatMap(
3403
+ (path) => previewHostnameForHandle(pattern, appHostLabel(handle, path)) ?? []
3404
+ );
3364
3405
  const route = {
3365
3406
  apiVersion: `${HTTPROUTE_CONSTANTS.API_GROUP}/${HTTPROUTE_CONSTANTS.API_VERSION}`,
3366
3407
  kind: "HTTPRoute",
@@ -3384,7 +3425,7 @@ var AgentSandboxProvider = class {
3384
3425
  namespace: this.previewGateway.namespace
3385
3426
  }
3386
3427
  ],
3387
- hostnames: [hostname],
3428
+ hostnames: [hostname, ...appHostnames],
3388
3429
  rules: [
3389
3430
  {
3390
3431
  backendRefs: [
@@ -6763,7 +6804,7 @@ var Freestyle = class {
6763
6804
  // ../sandbox/package.json
6764
6805
  var package_default = {
6765
6806
  name: "@decocms/sandbox",
6766
- version: "1.94.0",
6807
+ version: "1.95.0",
6767
6808
  private: true,
6768
6809
  type: "module",
6769
6810
  description: "Sandbox runtime for isolated Studio tool execution",
@@ -6897,6 +6938,8 @@ var FreestyleSandboxProvider = class {
6897
6938
  sweeping = false;
6898
6939
  /** Org + repo → the newest repo snapshot known to exist, and the head it was built for. */
6899
6940
  repoSnapshots = /* @__PURE__ */ new Map();
6941
+ /** Handle → the app domains its VM was last given, so a resume skips the API. */
6942
+ appIngress = /* @__PURE__ */ new Map();
6900
6943
  /** Org + repo with a build running, so a push mid-build waits for it. */
6901
6944
  repoSnapshotBuilds = /* @__PURE__ */ new Set();
6902
6945
  /** When a repo's last build failed; a build takes up to 30 min, so not on every ensure. */
@@ -7241,7 +7284,7 @@ ${head}`).digest("hex").slice(0, 16)}`;
7241
7284
  const existing = await this.find(handle, { refresh: true });
7242
7285
  let warmPoolAdopted = false;
7243
7286
  if (existing) {
7244
- await this.resume(existing, opts);
7287
+ await this.resume(handle, existing, opts);
7245
7288
  } else {
7246
7289
  warmPoolAdopted = await this.create(handle, opts);
7247
7290
  }
@@ -7254,7 +7297,7 @@ ${head}`).digest("hex").slice(0, 16)}`;
7254
7297
  };
7255
7298
  });
7256
7299
  }
7257
- async resume(found, opts) {
7300
+ async resume(handle, found, opts) {
7258
7301
  const { state, sourceSnapshotSlugAtCreate, metadata } = await found.vm.data();
7259
7302
  if (state !== "running") await found.vm.start();
7260
7303
  await this.waitForDaemon(found.daemon.url);
@@ -7277,6 +7320,52 @@ ${head}`).digest("hex").slice(0, 16)}`;
7277
7320
  )
7278
7321
  );
7279
7322
  }
7323
+ const apps = opts.workload?.apps;
7324
+ if (apps) {
7325
+ await this.syncAppIngress(handle, found.vm, apps).catch(
7326
+ (err) => console.warn(`[${LOG_LABEL2}] app ingress sync failed: ${errMsg2(err)}`)
7327
+ );
7328
+ await postConfig(
7329
+ found.daemon.url,
7330
+ found.daemon.token,
7331
+ extraAppsPatch(apps)
7332
+ ).catch(
7333
+ (err) => console.warn(`[${LOG_LABEL2}] apps update failed: ${errMsg2(err)}`)
7334
+ );
7335
+ }
7336
+ }
7337
+ /** Public hosts of a sandbox's extra apps; the daemon routes them by Host. */
7338
+ appDomains(handle, apps) {
7339
+ return apps.map(
7340
+ (path) => `${appHostLabel(handle, path)}.${this.domainSuffix}`
7341
+ );
7342
+ }
7343
+ /**
7344
+ * Gives each extra app of a running VM its ingress, and takes it from apps
7345
+ * no longer configured.
7346
+ */
7347
+ async syncAppIngress(handle, vm, apps) {
7348
+ const wanted = new Set(this.appDomains(handle, apps));
7349
+ const key = [...wanted].sort().join(",");
7350
+ if (this.appIngress.get(handle) === key) return;
7351
+ const appPrefix = `${handle}--`;
7352
+ const { rules } = await this.freestyle.tls.rules.list({ vmId: vm.id });
7353
+ const have = /* @__PURE__ */ new Set();
7354
+ for (const rule of rules) {
7355
+ if (!rule.domain.startsWith(appPrefix)) continue;
7356
+ if (wanted.has(rule.domain)) have.add(rule.domain);
7357
+ else await this.freestyle.tls.rules.delete(rule.id);
7358
+ }
7359
+ for (const domain of wanted) {
7360
+ if (have.has(domain)) continue;
7361
+ await this.freestyle.tls.rules.create({
7362
+ action: "allow",
7363
+ domain,
7364
+ source: { public: true },
7365
+ destination: { vmId: vm.id, port: DAEMON_PORT }
7366
+ });
7367
+ }
7368
+ this.appIngress.set(handle, key);
7280
7369
  }
7281
7370
  /**
7282
7371
  * The bearer this VM was claimed with lives in daemon memory. A cold boot
@@ -7346,13 +7435,14 @@ ${head}`).digest("hex").slice(0, 16)}`;
7346
7435
  },
7347
7436
  tls: {
7348
7437
  rules: [
7349
- {
7350
- action: "allow",
7351
- domain: `${handle}.${this.domainSuffix}`,
7352
- source: { public: true },
7353
- destination: { port: DAEMON_PORT }
7354
- }
7355
- ]
7438
+ `${handle}.${this.domainSuffix}`,
7439
+ ...this.appDomains(handle, opts.workload?.apps ?? [])
7440
+ ].map((domain) => ({
7441
+ action: "allow",
7442
+ domain,
7443
+ source: { public: true },
7444
+ destination: { port: DAEMON_PORT }
7445
+ }))
7356
7446
  }
7357
7447
  });
7358
7448
  step("vm-fork");
@@ -7444,7 +7534,8 @@ ${head}`).digest("hex").slice(0, 16)}`;
7444
7534
  extraRepos: opts.extraRepos ?? [],
7445
7535
  port: opts.workload?.devPort ?? DEFAULT_DEV_PORT2,
7446
7536
  tenant: opts.tenant,
7447
- cloneOnly: opts.cloneOnly === true
7537
+ cloneOnly: opts.cloneOnly === true,
7538
+ apps: opts.workload?.apps
7448
7539
  });
7449
7540
  }
7450
7541
  async waitForDaemon(url) {
@@ -8005,7 +8096,8 @@ var ensureOptionsSchema = z4.object({
8005
8096
  runtime: z4.enum(["node", "bun", "deno"]),
8006
8097
  packageManager: z4.enum(["npm", "pnpm", "yarn", "bun", "deno"]),
8007
8098
  devPort: z4.number().int().optional(),
8008
- packageManagerPath: z4.string().optional()
8099
+ packageManagerPath: z4.string().optional(),
8100
+ apps: z4.array(z4.string().min(1).max(256)).max(8).optional()
8009
8101
  }).optional(),
8010
8102
  cloneOnly: z4.boolean().optional(),
8011
8103
  repoSnapshot: z4.boolean().optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/sandbox-controller",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Runs Studio agent sandboxes on Kubernetes, and serves them to a remote Studio.",
5
5
  "repository": {
6
6
  "type": "git",