@decocms/sandbox-controller 0.7.2 → 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 +24 -2
- package/dist/index.js +106 -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,15 +3752,34 @@ 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
|
*/
|
|
3759
|
+
/**
|
|
3760
|
+
* Build this image's base snapshot now, so the first sandbox after an image
|
|
3761
|
+
* bump boots from it instead of waiting on the pull. Never throws.
|
|
3762
|
+
*/
|
|
3763
|
+
warm(): Promise<void>;
|
|
3755
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;
|
|
3756
3772
|
private snapshotExists;
|
|
3757
3773
|
private run;
|
|
3758
3774
|
ensure(id: SandboxId, opts?: EnsureOptions): Promise<Sandbox>;
|
|
3759
3775
|
private resume;
|
|
3760
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;
|
|
3761
3783
|
/** A plan that caps autoDelete lower answers 400; omitting it gets the cap. */
|
|
3762
3784
|
private createVm;
|
|
3763
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,15 +6939,30 @@ 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
|
*/
|
|
6946
|
+
/**
|
|
6947
|
+
* Build this image's base snapshot now, so the first sandbox after an image
|
|
6948
|
+
* bump boots from it instead of waiting on the pull. Never throws.
|
|
6949
|
+
*/
|
|
6950
|
+
async warm() {
|
|
6951
|
+
await this.baseSnapshot().catch(
|
|
6952
|
+
(err) => console.warn(`[${LOG_LABEL2}] base snapshot warm failed: ${errMsg2(err)}`)
|
|
6953
|
+
);
|
|
6954
|
+
}
|
|
6940
6955
|
baseSnapshot() {
|
|
6941
6956
|
const { image, sidecarImage } = this;
|
|
6942
6957
|
const slug = `studio-sandbox-${createHash3("sha256").update(`${image}
|
|
6943
|
-
${sidecarImage}
|
|
6958
|
+
${sidecarImage}
|
|
6959
|
+
running`).digest("hex").slice(0, 16)}`;
|
|
6960
|
+
if (this.knownSnapshots.has(slug)) return Promise.resolve(slug);
|
|
6944
6961
|
return this.snapshotInflight.run(slug, async () => {
|
|
6945
|
-
if (await this.snapshotExists(slug))
|
|
6962
|
+
if (await this.snapshotExists(slug)) {
|
|
6963
|
+
this.knownSnapshots.add(slug);
|
|
6964
|
+
return slug;
|
|
6965
|
+
}
|
|
6946
6966
|
console.log(`[${LOG_LABEL2}] building base snapshot ${slug} for ${image}`);
|
|
6947
6967
|
const { vm } = await this.freestyle.vms.create({
|
|
6948
6968
|
ttlSeconds: 1800,
|
|
@@ -6958,7 +6978,14 @@ ${sidecarImage}`).digest("hex").slice(0, 16)}`;
|
|
|
6958
6978
|
`docker pull -q ${shellQuote(image)} && docker pull -q ${shellQuote(sidecarImage)}`,
|
|
6959
6979
|
{ timeoutMs: IMAGE_PULL_TIMEOUT_MS }
|
|
6960
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
|
+
);
|
|
6961
6987
|
await vm.snapshot({ slug });
|
|
6988
|
+
this.knownSnapshots.add(slug);
|
|
6962
6989
|
} catch (err) {
|
|
6963
6990
|
if (!await this.snapshotExists(slug)) throw err;
|
|
6964
6991
|
} finally {
|
|
@@ -6968,6 +6995,34 @@ ${sidecarImage}`).digest("hex").slice(0, 16)}`;
|
|
|
6968
6995
|
return slug;
|
|
6969
6996
|
});
|
|
6970
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
|
+
}
|
|
6971
7026
|
async snapshotExists(slug) {
|
|
6972
7027
|
try {
|
|
6973
7028
|
await this.freestyle.vms.snapshots.get(slug);
|
|
@@ -7032,7 +7087,19 @@ ${sidecarImage}`).digest("hex").slice(0, 16)}`;
|
|
|
7032
7087
|
}
|
|
7033
7088
|
}
|
|
7034
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
|
+
};
|
|
7035
7101
|
const snapshotId = await this.baseSnapshot();
|
|
7102
|
+
step("snapshot");
|
|
7036
7103
|
const token = randomBytes2(24).toString("hex");
|
|
7037
7104
|
const { vm } = await this.createVm({
|
|
7038
7105
|
snapshotId,
|
|
@@ -7056,35 +7123,23 @@ ${sidecarImage}`).digest("hex").slice(0, 16)}`;
|
|
|
7056
7123
|
]
|
|
7057
7124
|
}
|
|
7058
7125
|
});
|
|
7126
|
+
step("vm-fork");
|
|
7059
7127
|
const daemon = { url: this.daemonUrl(handle), token };
|
|
7060
7128
|
try {
|
|
7061
|
-
|
|
7062
|
-
|
|
7063
|
-
|
|
7064
|
-
|
|
7065
|
-
|
|
7066
|
-
PROXY_PORT: String(DAEMON_PORT),
|
|
7067
|
-
ORGFS_SIDECAR_CONFIG_PATH: `${CTL_DIR}/config.json`,
|
|
7068
|
-
ORGFS_SIDECAR_STATUS_PATH: `${CTL_DIR}/status.json`
|
|
7069
|
-
};
|
|
7070
|
-
const envFlags = Object.keys(env).map((k) => `-e ${shellQuote(k)}`).join(" ");
|
|
7071
|
-
await this.run(
|
|
7072
|
-
vm,
|
|
7073
|
-
`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)}`,
|
|
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)}`,
|
|
7079
|
-
{ env, timeoutMs: IMAGE_PULL_TIMEOUT_MS }
|
|
7129
|
+
await this.claimDaemon(
|
|
7130
|
+
daemon.url,
|
|
7131
|
+
this.sentinelToken(snapshotId),
|
|
7132
|
+
token,
|
|
7133
|
+
this.configPayload(opts) ?? {}
|
|
7080
7134
|
);
|
|
7081
|
-
|
|
7082
|
-
await postConfig(daemon.url, token, this.configPayload(opts) ?? {});
|
|
7135
|
+
step("claim");
|
|
7083
7136
|
if (opts.orgFsConfigJson) {
|
|
7084
7137
|
await postOrgFsConfig(daemon.url, token, opts.orgFsConfigJson).catch(
|
|
7085
7138
|
(err) => console.warn(`[${LOG_LABEL2}] org-fs relay failed`, err)
|
|
7086
7139
|
);
|
|
7087
7140
|
}
|
|
7141
|
+
step("orgfs-config");
|
|
7142
|
+
console.log(`[${LOG_LABEL2}] create ${handle} ms=${JSON.stringify(ms)}`);
|
|
7088
7143
|
} catch (err) {
|
|
7089
7144
|
await vm.delete().catch(() => {
|
|
7090
7145
|
});
|
|
@@ -7093,6 +7148,30 @@ ${sidecarImage}`).digest("hex").slice(0, 16)}`;
|
|
|
7093
7148
|
}
|
|
7094
7149
|
this.remember(handle, { vm, daemon });
|
|
7095
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
|
+
}
|
|
7096
7175
|
/** A plan that caps autoDelete lower answers 400; omitting it gets the cap. */
|
|
7097
7176
|
async createVm(opts) {
|
|
7098
7177
|
try {
|