@decocms/sandbox-controller 0.8.0 → 0.8.2
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 +35 -2
- package/dist/index.js +174 -27
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -3717,6 +3717,13 @@ interface FreestyleSandboxProviderOptions {
|
|
|
3717
3717
|
orgFsSidecarImage?: string;
|
|
3718
3718
|
/** Pause a VM after this long without network activity. Default 5 min. */
|
|
3719
3719
|
idleTimeoutSeconds?: number;
|
|
3720
|
+
/**
|
|
3721
|
+
* Also pause a running VM once its daemon has gone this long unused. The
|
|
3722
|
+
* idle timeout counts any traffic, so a dev server holding an outbound
|
|
3723
|
+
* connection open keeps the VM awake without it. Checked every minute;
|
|
3724
|
+
* 0 turns it off. Default 10 min.
|
|
3725
|
+
*/
|
|
3726
|
+
idlePauseMs?: number;
|
|
3720
3727
|
/**
|
|
3721
3728
|
* Delete a VM after this long without running. Default 1 day. A plan
|
|
3722
3729
|
* that caps it lower gets its cap.
|
|
@@ -3730,18 +3737,24 @@ interface FreestyleSandboxProviderOptions {
|
|
|
3730
3737
|
client?: Freestyle;
|
|
3731
3738
|
}
|
|
3732
3739
|
declare class FreestyleSandboxProvider implements SandboxProvider {
|
|
3740
|
+
private readonly apiKey;
|
|
3733
3741
|
private readonly freestyle;
|
|
3734
3742
|
private readonly lookupClient;
|
|
3735
3743
|
private readonly image;
|
|
3736
3744
|
private readonly sidecarImage;
|
|
3737
3745
|
private readonly idleTimeoutSeconds;
|
|
3746
|
+
private readonly idlePauseMs;
|
|
3738
3747
|
private readonly autoDeleteSeconds;
|
|
3739
3748
|
private readonly domainSuffix;
|
|
3740
3749
|
private readonly timeDaemonRequest;
|
|
3741
3750
|
private readonly inflight;
|
|
3742
3751
|
private readonly snapshotInflight;
|
|
3752
|
+
/** Snapshots are immutable once built, so one positive lookup is enough. */
|
|
3753
|
+
private readonly knownSnapshots;
|
|
3743
3754
|
private readonly lookups;
|
|
3744
3755
|
private readonly releaseTimers;
|
|
3756
|
+
private readonly idleSweep;
|
|
3757
|
+
private sweeping;
|
|
3745
3758
|
constructor(opts: FreestyleSandboxProviderOptions);
|
|
3746
3759
|
/** Whether this handle is a Freestyle sandbox. Cached; see `MISS_TTL_MS`. */
|
|
3747
3760
|
owns(handle: string): Promise<boolean>;
|
|
@@ -3749,8 +3762,9 @@ declare class FreestyleSandboxProvider implements SandboxProvider {
|
|
|
3749
3762
|
private find;
|
|
3750
3763
|
private remember;
|
|
3751
3764
|
/**
|
|
3752
|
-
* A snapshot of a VM with both
|
|
3753
|
-
*
|
|
3765
|
+
* A snapshot of a VM with both containers RUNNING and the daemon healthy but
|
|
3766
|
+
* unclaimed, so a fork resumes a live daemon (Freestyle snapshots memory) and
|
|
3767
|
+
* is configured with one request instead of booted. One per image pair.
|
|
3754
3768
|
*/
|
|
3755
3769
|
/**
|
|
3756
3770
|
* Build this image's base snapshot now, so the first sandbox after an image
|
|
@@ -3758,11 +3772,24 @@ declare class FreestyleSandboxProvider implements SandboxProvider {
|
|
|
3758
3772
|
*/
|
|
3759
3773
|
warm(): Promise<void>;
|
|
3760
3774
|
private baseSnapshot;
|
|
3775
|
+
/**
|
|
3776
|
+
* The bearer the snapshot's daemon boots with. Every fork shares it until its
|
|
3777
|
+
* first config rotates it, so it is derived (any replica can compute it) and
|
|
3778
|
+
* never stored.
|
|
3779
|
+
*/
|
|
3780
|
+
private sentinelToken;
|
|
3781
|
+
private startContainers;
|
|
3761
3782
|
private snapshotExists;
|
|
3762
3783
|
private run;
|
|
3763
3784
|
ensure(id: SandboxId, opts?: EnsureOptions): Promise<Sandbox>;
|
|
3764
3785
|
private resume;
|
|
3765
3786
|
private create;
|
|
3787
|
+
/**
|
|
3788
|
+
* The fork's first config: swap the snapshot's sentinel bearer for this
|
|
3789
|
+
* sandbox's own, retried until the new domain routes. A 401 means an earlier
|
|
3790
|
+
* attempt already rotated (its response was lost), so finish with the new one.
|
|
3791
|
+
*/
|
|
3792
|
+
private claimDaemon;
|
|
3766
3793
|
/** A plan that caps autoDelete lower answers 400; omitting it gets the cap. */
|
|
3767
3794
|
private createVm;
|
|
3768
3795
|
private configPayload;
|
|
@@ -3792,6 +3819,12 @@ declare class FreestyleSandboxProvider implements SandboxProvider {
|
|
|
3792
3819
|
releaseAfter(handle: string, graceMs: number): Promise<void>;
|
|
3793
3820
|
/** The VM's idle timeout already counts every request as activity. */
|
|
3794
3821
|
renewTtl(handle: string): Promise<void>;
|
|
3822
|
+
/**
|
|
3823
|
+
* Pause every running VM of ours whose daemon reports `idlePauseMs` without
|
|
3824
|
+
* use. The daemon's clock counts requests and streaming runs, not the dev
|
|
3825
|
+
* server's own traffic. Returns the handles paused; never throws.
|
|
3826
|
+
*/
|
|
3827
|
+
pauseIdle(): Promise<string[]>;
|
|
3795
3828
|
private cancelRelease;
|
|
3796
3829
|
close(): void;
|
|
3797
3830
|
}
|
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.2",
|
|
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;
|
|
@@ -6860,20 +6861,29 @@ var MISS_TTL_MS = 1e4;
|
|
|
6860
6861
|
var LOOKUP_MAX = 5e3;
|
|
6861
6862
|
var WATCH_TIMEOUT_MS = 5 * 6e4;
|
|
6862
6863
|
var LOOKUP_TIMEOUT_MS = 3e3;
|
|
6864
|
+
var IDLE_SWEEP_MS = 6e4;
|
|
6865
|
+
var IDLE_SWEEP_PAGE = 100;
|
|
6863
6866
|
var FreestyleSandboxProvider = class {
|
|
6867
|
+
apiKey;
|
|
6864
6868
|
freestyle;
|
|
6865
6869
|
lookupClient;
|
|
6866
6870
|
image;
|
|
6867
6871
|
sidecarImage;
|
|
6868
6872
|
idleTimeoutSeconds;
|
|
6873
|
+
idlePauseMs;
|
|
6869
6874
|
autoDeleteSeconds;
|
|
6870
6875
|
domainSuffix;
|
|
6871
6876
|
timeDaemonRequest;
|
|
6872
6877
|
inflight = new Inflight();
|
|
6873
6878
|
snapshotInflight = new Inflight();
|
|
6879
|
+
/** Snapshots are immutable once built, so one positive lookup is enough. */
|
|
6880
|
+
knownSnapshots = /* @__PURE__ */ new Set();
|
|
6874
6881
|
lookups = /* @__PURE__ */ new Map();
|
|
6875
6882
|
releaseTimers = /* @__PURE__ */ new Map();
|
|
6883
|
+
idleSweep = null;
|
|
6884
|
+
sweeping = false;
|
|
6876
6885
|
constructor(opts) {
|
|
6886
|
+
this.apiKey = opts.apiKey;
|
|
6877
6887
|
this.freestyle = opts.client ?? new Freestyle({ apiKey: opts.apiKey });
|
|
6878
6888
|
this.lookupClient = opts.client ?? new Freestyle({
|
|
6879
6889
|
apiKey: opts.apiKey,
|
|
@@ -6891,9 +6901,14 @@ var FreestyleSandboxProvider = class {
|
|
|
6891
6901
|
this.image = opts.image ?? `ghcr.io/decocms/studio/studio-sandbox-go:${package_default.version}`;
|
|
6892
6902
|
this.sidecarImage = opts.orgFsSidecarImage ?? DEFAULT_SIDECAR_IMAGE;
|
|
6893
6903
|
this.idleTimeoutSeconds = opts.idleTimeoutSeconds ?? 5 * 60;
|
|
6904
|
+
this.idlePauseMs = opts.idlePauseMs ?? 10 * 6e4;
|
|
6894
6905
|
this.autoDeleteSeconds = opts.autoDeleteSeconds ?? 24 * 60 * 60;
|
|
6895
6906
|
this.domainSuffix = opts.domainSuffix ?? "style.dev";
|
|
6896
6907
|
this.timeDaemonRequest = daemonProxyTimer(opts.meter);
|
|
6908
|
+
if (this.idlePauseMs > 0) {
|
|
6909
|
+
this.idleSweep = setInterval(() => void this.pauseIdle(), IDLE_SWEEP_MS);
|
|
6910
|
+
this.idleSweep.unref?.();
|
|
6911
|
+
}
|
|
6897
6912
|
}
|
|
6898
6913
|
/** Whether this handle is a Freestyle sandbox. Cached; see `MISS_TTL_MS`. */
|
|
6899
6914
|
async owns(handle) {
|
|
@@ -6934,8 +6949,9 @@ var FreestyleSandboxProvider = class {
|
|
|
6934
6949
|
}
|
|
6935
6950
|
// ---- Base snapshot --------------------------------------------------------
|
|
6936
6951
|
/**
|
|
6937
|
-
* A snapshot of a VM with both
|
|
6938
|
-
*
|
|
6952
|
+
* A snapshot of a VM with both containers RUNNING and the daemon healthy but
|
|
6953
|
+
* unclaimed, so a fork resumes a live daemon (Freestyle snapshots memory) and
|
|
6954
|
+
* is configured with one request instead of booted. One per image pair.
|
|
6939
6955
|
*/
|
|
6940
6956
|
/**
|
|
6941
6957
|
* Build this image's base snapshot now, so the first sandbox after an image
|
|
@@ -6949,9 +6965,14 @@ var FreestyleSandboxProvider = class {
|
|
|
6949
6965
|
baseSnapshot() {
|
|
6950
6966
|
const { image, sidecarImage } = this;
|
|
6951
6967
|
const slug = `studio-sandbox-${createHash3("sha256").update(`${image}
|
|
6952
|
-
${sidecarImage}
|
|
6968
|
+
${sidecarImage}
|
|
6969
|
+
running`).digest("hex").slice(0, 16)}`;
|
|
6970
|
+
if (this.knownSnapshots.has(slug)) return Promise.resolve(slug);
|
|
6953
6971
|
return this.snapshotInflight.run(slug, async () => {
|
|
6954
|
-
if (await this.snapshotExists(slug))
|
|
6972
|
+
if (await this.snapshotExists(slug)) {
|
|
6973
|
+
this.knownSnapshots.add(slug);
|
|
6974
|
+
return slug;
|
|
6975
|
+
}
|
|
6955
6976
|
console.log(`[${LOG_LABEL2}] building base snapshot ${slug} for ${image}`);
|
|
6956
6977
|
const { vm } = await this.freestyle.vms.create({
|
|
6957
6978
|
ttlSeconds: 1800,
|
|
@@ -6967,7 +6988,14 @@ ${sidecarImage}`).digest("hex").slice(0, 16)}`;
|
|
|
6967
6988
|
`docker pull -q ${shellQuote(image)} && docker pull -q ${shellQuote(sidecarImage)}`,
|
|
6968
6989
|
{ timeoutMs: IMAGE_PULL_TIMEOUT_MS }
|
|
6969
6990
|
);
|
|
6991
|
+
await this.startContainers(vm, this.sentinelToken(slug));
|
|
6992
|
+
await this.run(
|
|
6993
|
+
vm,
|
|
6994
|
+
`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`,
|
|
6995
|
+
{ timeoutMs: DAEMON_BOOT_TIMEOUT_MS + 1e4 }
|
|
6996
|
+
);
|
|
6970
6997
|
await vm.snapshot({ slug });
|
|
6998
|
+
this.knownSnapshots.add(slug);
|
|
6971
6999
|
} catch (err) {
|
|
6972
7000
|
if (!await this.snapshotExists(slug)) throw err;
|
|
6973
7001
|
} finally {
|
|
@@ -6977,6 +7005,34 @@ ${sidecarImage}`).digest("hex").slice(0, 16)}`;
|
|
|
6977
7005
|
return slug;
|
|
6978
7006
|
});
|
|
6979
7007
|
}
|
|
7008
|
+
/**
|
|
7009
|
+
* The bearer the snapshot's daemon boots with. Every fork shares it until its
|
|
7010
|
+
* first config rotates it, so it is derived (any replica can compute it) and
|
|
7011
|
+
* never stored.
|
|
7012
|
+
*/
|
|
7013
|
+
sentinelToken(slug) {
|
|
7014
|
+
return createHmac("sha256", this.apiKey).update(`daemon-sentinel:${slug}`).digest("hex").slice(0, 48);
|
|
7015
|
+
}
|
|
7016
|
+
async startContainers(vm, token) {
|
|
7017
|
+
const env = {
|
|
7018
|
+
DAEMON_TOKEN: token,
|
|
7019
|
+
APP_ROOT: WORKDIR,
|
|
7020
|
+
PROXY_PORT: String(DAEMON_PORT),
|
|
7021
|
+
ORGFS_SIDECAR_CONFIG_PATH: `${CTL_DIR}/config.json`,
|
|
7022
|
+
ORGFS_SIDECAR_STATUS_PATH: `${CTL_DIR}/status.json`
|
|
7023
|
+
};
|
|
7024
|
+
const envFlags = Object.keys(env).map((k) => `-e ${shellQuote(k)}`).join(" ");
|
|
7025
|
+
await this.run(
|
|
7026
|
+
vm,
|
|
7027
|
+
`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)}`,
|
|
7028
|
+
{ timeoutMs: IMAGE_PULL_TIMEOUT_MS }
|
|
7029
|
+
);
|
|
7030
|
+
await this.run(
|
|
7031
|
+
vm,
|
|
7032
|
+
`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)}`,
|
|
7033
|
+
{ env, timeoutMs: IMAGE_PULL_TIMEOUT_MS }
|
|
7034
|
+
);
|
|
7035
|
+
}
|
|
6980
7036
|
async snapshotExists(slug) {
|
|
6981
7037
|
try {
|
|
6982
7038
|
await this.freestyle.vms.snapshots.get(slug);
|
|
@@ -7041,7 +7097,19 @@ ${sidecarImage}`).digest("hex").slice(0, 16)}`;
|
|
|
7041
7097
|
}
|
|
7042
7098
|
}
|
|
7043
7099
|
async create(handle, opts) {
|
|
7100
|
+
if (opts.env && Object.keys(opts.env).length > 0) {
|
|
7101
|
+
throw new Error(
|
|
7102
|
+
`${LOG_LABEL2}: per-sandbox container env is not supported`
|
|
7103
|
+
);
|
|
7104
|
+
}
|
|
7105
|
+
const ms = {};
|
|
7106
|
+
let last = Date.now();
|
|
7107
|
+
const step = (name) => {
|
|
7108
|
+
ms[name] = Date.now() - last;
|
|
7109
|
+
last = Date.now();
|
|
7110
|
+
};
|
|
7044
7111
|
const snapshotId = await this.baseSnapshot();
|
|
7112
|
+
step("snapshot");
|
|
7045
7113
|
const token = randomBytes2(24).toString("hex");
|
|
7046
7114
|
const { vm } = await this.createVm({
|
|
7047
7115
|
snapshotId,
|
|
@@ -7065,35 +7133,23 @@ ${sidecarImage}`).digest("hex").slice(0, 16)}`;
|
|
|
7065
7133
|
]
|
|
7066
7134
|
}
|
|
7067
7135
|
});
|
|
7136
|
+
step("vm-fork");
|
|
7068
7137
|
const daemon = { url: this.daemonUrl(handle), token };
|
|
7069
7138
|
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 }
|
|
7139
|
+
await this.claimDaemon(
|
|
7140
|
+
daemon.url,
|
|
7141
|
+
this.sentinelToken(snapshotId),
|
|
7142
|
+
token,
|
|
7143
|
+
this.configPayload(opts) ?? {}
|
|
7089
7144
|
);
|
|
7090
|
-
|
|
7091
|
-
await postConfig(daemon.url, token, this.configPayload(opts) ?? {});
|
|
7145
|
+
step("claim");
|
|
7092
7146
|
if (opts.orgFsConfigJson) {
|
|
7093
7147
|
await postOrgFsConfig(daemon.url, token, opts.orgFsConfigJson).catch(
|
|
7094
7148
|
(err) => console.warn(`[${LOG_LABEL2}] org-fs relay failed`, err)
|
|
7095
7149
|
);
|
|
7096
7150
|
}
|
|
7151
|
+
step("orgfs-config");
|
|
7152
|
+
console.log(`[${LOG_LABEL2}] create ${handle} ms=${JSON.stringify(ms)}`);
|
|
7097
7153
|
} catch (err) {
|
|
7098
7154
|
await vm.delete().catch(() => {
|
|
7099
7155
|
});
|
|
@@ -7102,6 +7158,30 @@ ${sidecarImage}`).digest("hex").slice(0, 16)}`;
|
|
|
7102
7158
|
}
|
|
7103
7159
|
this.remember(handle, { vm, daemon });
|
|
7104
7160
|
}
|
|
7161
|
+
/**
|
|
7162
|
+
* The fork's first config: swap the snapshot's sentinel bearer for this
|
|
7163
|
+
* sandbox's own, retried until the new domain routes. A 401 means an earlier
|
|
7164
|
+
* attempt already rotated (its response was lost), so finish with the new one.
|
|
7165
|
+
*/
|
|
7166
|
+
async claimDaemon(url, sentinel, token, payload) {
|
|
7167
|
+
const deadline = Date.now() + DAEMON_BOOT_TIMEOUT_MS;
|
|
7168
|
+
for (; ; ) {
|
|
7169
|
+
try {
|
|
7170
|
+
await postConfig(url, sentinel, payload, { rotateToken: token });
|
|
7171
|
+
return;
|
|
7172
|
+
} catch (err) {
|
|
7173
|
+
if (err instanceof ConfigRequestError && err.status === 401) {
|
|
7174
|
+
await postConfig(url, token, payload);
|
|
7175
|
+
return;
|
|
7176
|
+
}
|
|
7177
|
+
if (err instanceof ConfigRequestError && (err.status === 400 || err.status === 409)) {
|
|
7178
|
+
throw err;
|
|
7179
|
+
}
|
|
7180
|
+
if (Date.now() > deadline) throw err;
|
|
7181
|
+
await delay(CLAIM_RETRY_MS);
|
|
7182
|
+
}
|
|
7183
|
+
}
|
|
7184
|
+
}
|
|
7105
7185
|
/** A plan that caps autoDelete lower answers 400; omitting it gets the cap. */
|
|
7106
7186
|
async createVm(opts) {
|
|
7107
7187
|
try {
|
|
@@ -7259,12 +7339,64 @@ ${sidecarImage}`).digest("hex").slice(0, 16)}`;
|
|
|
7259
7339
|
async renewTtl(handle) {
|
|
7260
7340
|
this.cancelRelease(handle);
|
|
7261
7341
|
}
|
|
7342
|
+
/**
|
|
7343
|
+
* Pause every running VM of ours whose daemon reports `idlePauseMs` without
|
|
7344
|
+
* use. The daemon's clock counts requests and streaming runs, not the dev
|
|
7345
|
+
* server's own traffic. Returns the handles paused; never throws.
|
|
7346
|
+
*/
|
|
7347
|
+
async pauseIdle() {
|
|
7348
|
+
if (this.sweeping) return [];
|
|
7349
|
+
this.sweeping = true;
|
|
7350
|
+
try {
|
|
7351
|
+
const running = [];
|
|
7352
|
+
for (let offset = 0; ; offset += IDLE_SWEEP_PAGE) {
|
|
7353
|
+
const { vms } = await this.freestyle.vms.list({
|
|
7354
|
+
state: "running",
|
|
7355
|
+
limit: IDLE_SWEEP_PAGE,
|
|
7356
|
+
offset
|
|
7357
|
+
});
|
|
7358
|
+
running.push(...vms);
|
|
7359
|
+
if (vms.length < IDLE_SWEEP_PAGE) break;
|
|
7360
|
+
}
|
|
7361
|
+
const paused = await Promise.all(
|
|
7362
|
+
running.map(async (data) => {
|
|
7363
|
+
const handle = data.slug;
|
|
7364
|
+
if (!handle || !data.metadata[TOKEN_KEY]) return null;
|
|
7365
|
+
if (Date.now() - Date.parse(data.createdAt) < this.idlePauseMs) {
|
|
7366
|
+
return null;
|
|
7367
|
+
}
|
|
7368
|
+
const idleMs = await daemonIdleMs(this.daemonUrl(handle));
|
|
7369
|
+
if (idleMs === null || idleMs < this.idlePauseMs) return null;
|
|
7370
|
+
try {
|
|
7371
|
+
await this.freestyle.vms.ref(data.id).pause();
|
|
7372
|
+
} catch (err) {
|
|
7373
|
+
console.warn(
|
|
7374
|
+
`[${LOG_LABEL2}] pause ${handle} failed: ${errMsg2(err)}`
|
|
7375
|
+
);
|
|
7376
|
+
return null;
|
|
7377
|
+
}
|
|
7378
|
+
this.cancelRelease(handle);
|
|
7379
|
+
console.log(
|
|
7380
|
+
`[${LOG_LABEL2}] paused ${handle}, daemon idle ${Math.round(idleMs / 6e4)}m`
|
|
7381
|
+
);
|
|
7382
|
+
return handle;
|
|
7383
|
+
})
|
|
7384
|
+
);
|
|
7385
|
+
return paused.filter((h) => h !== null);
|
|
7386
|
+
} catch (err) {
|
|
7387
|
+
console.warn(`[${LOG_LABEL2}] idle sweep failed: ${errMsg2(err)}`);
|
|
7388
|
+
return [];
|
|
7389
|
+
} finally {
|
|
7390
|
+
this.sweeping = false;
|
|
7391
|
+
}
|
|
7392
|
+
}
|
|
7262
7393
|
cancelRelease(handle) {
|
|
7263
7394
|
const timer = this.releaseTimers.get(handle);
|
|
7264
7395
|
if (timer) clearTimeout(timer);
|
|
7265
7396
|
this.releaseTimers.delete(handle);
|
|
7266
7397
|
}
|
|
7267
7398
|
close() {
|
|
7399
|
+
if (this.idleSweep) clearInterval(this.idleSweep);
|
|
7268
7400
|
for (const timer of this.releaseTimers.values()) clearTimeout(timer);
|
|
7269
7401
|
this.releaseTimers.clear();
|
|
7270
7402
|
this.lookups.clear();
|
|
@@ -7281,6 +7413,21 @@ async function daemonAnswers(url) {
|
|
|
7281
7413
|
return false;
|
|
7282
7414
|
}
|
|
7283
7415
|
}
|
|
7416
|
+
async function daemonIdleMs(url) {
|
|
7417
|
+
try {
|
|
7418
|
+
const res = await fetch(`${url}/_sandbox/idle`, {
|
|
7419
|
+
signal: AbortSignal.timeout(HEALTH_PROBE_TIMEOUT_MS2)
|
|
7420
|
+
});
|
|
7421
|
+
if (!res.ok) {
|
|
7422
|
+
await res.body?.cancel();
|
|
7423
|
+
return null;
|
|
7424
|
+
}
|
|
7425
|
+
const body = await res.json();
|
|
7426
|
+
return typeof body === "object" && body !== null && "idleMs" in body && typeof body.idleMs === "number" ? body.idleMs : null;
|
|
7427
|
+
} catch {
|
|
7428
|
+
return null;
|
|
7429
|
+
}
|
|
7430
|
+
}
|
|
7284
7431
|
function shellQuote(s) {
|
|
7285
7432
|
return `'${s.replaceAll("'", `'\\''`)}'`;
|
|
7286
7433
|
}
|