@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 +19 -2
- package/dist/index.js +97 -27
- package/package.json +1 -1
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
|
|
3753
|
-
*
|
|
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.
|
|
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
|
|
6938
|
-
*
|
|
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}
|
|
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))
|
|
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
|
-
|
|
7071
|
-
|
|
7072
|
-
|
|
7073
|
-
|
|
7074
|
-
|
|
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
|
-
|
|
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 {
|