@sema-agent/server 7.125.1 → 7.127.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/MIGRATION.md +229 -2
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/USAGE.md +346 -145
- package/deploy/sema-up/README.md +1 -1
- package/deploy/sema-up/chart/templates/networkpolicy-sandbox.yaml +57 -0
- package/deploy/sema-up/chart/templates/sandbox-rbac.yaml +32 -1
- package/deploy/sema-up/chart/templates/server-v4pro.yaml +3 -2
- package/deploy/sema-up/chart/templates/server.yaml +2 -1
- package/deploy/sema-up/chart/values.yaml +22 -0
- package/deploy/sema-up/cluster-up.sh +10 -10
- package/deploy/sema-up/kube-up.sh +20 -13
- package/deploy/sema-up/lib/dataroot.sh +1 -1
- package/deploy/sema-up/lib/envf.sh +5 -5
- package/deploy/sema-up/lib/flagshape.sh +5 -5
- package/deploy/sema-up/lib/lanes.py +1 -1
- package/deploy/sema-up/lib/node-addrs.sh +42 -0
- package/deploy/sema-up/lib/parse-machines.py +2 -2
- package/deploy/sema-up/lib/shell-access.sh +1 -1
- package/deploy/sema-up/preflight.sh +4 -4
- package/deploy/sema-up/sema-up.sh +33 -33
- package/deploy/sema-up/smoke.sh +3 -3
- package/deploy/sema-up/stack/compose.yaml +1 -1
- package/deploy/sema-up/stack/sema-stack-cluster.yaml +26 -3
- package/dist/approval-card.d.ts +20 -4
- package/dist/approval-content-kind.d.ts +38 -22
- package/dist/approval-content-kind.js +20 -4
- package/dist/audit.js +3 -0
- package/dist/bench/s1/arms.d.ts +9 -37
- package/dist/bench/s1/arms.js +4 -61
- package/dist/bench/s1/live-deps.d.ts +4 -10
- package/dist/bench/s1/live-deps.js +1 -222
- package/dist/bench/s1/oracle.d.ts +1 -1
- package/dist/bench/s1/reviewer.d.ts +1 -1
- package/dist/bench/s1/row.d.ts +5 -7
- package/dist/bench/s1/row.js +0 -2
- package/dist/bench/s1/run-firm.d.ts +2 -2
- package/dist/bench/s1/run-firm.js +1 -4
- package/dist/bench/s1/runner-ctx.d.ts +17 -29
- package/dist/bench/s1/runner-ctx.js +3 -7
- package/dist/bench/s1/tasks.d.ts +9 -46
- package/dist/bench/s1/tasks.js +4 -63
- package/dist/boot/execution-env.d.ts +38 -26
- package/dist/boot/execution-env.js +87 -41
- package/dist/boot/leader.d.ts +2 -1
- package/dist/boot/memory-boundary.d.ts +1 -1
- package/dist/boot/memory-consolidation.d.ts +37 -3
- package/dist/boot/memory-consolidation.js +18 -2
- package/dist/boot/parked-revive-gate.d.ts +0 -3
- package/dist/boot/parked-revive-gate.js +2 -2
- package/dist/boot/resolve-spec.d.ts +0 -2
- package/dist/boot/resolve-spec.js +12 -17
- package/dist/boot/retention-lane.d.ts +12 -1
- package/dist/boot/retention-lane.js +2 -0
- package/dist/boot/runner-deps.d.ts +7 -6
- package/dist/boot/runner-deps.js +5 -16
- package/dist/boot/session-faces.d.ts +7 -2
- package/dist/boot/session-faces.js +37 -1
- package/dist/boot/stage-03-store-backend.d.ts +1 -0
- package/dist/boot/stage-03-store-backend.js +2 -2
- package/dist/boot/stage-04-stores.d.ts +1 -0
- package/dist/boot/stage-05-execution-env.d.ts +2 -2
- package/dist/boot/stage-05-execution-env.js +7 -4
- package/dist/boot/stage-06-runners.d.ts +2 -3
- package/dist/boot/stage-06-runners.js +13 -9
- package/dist/boot/stage-07-capability-layer.d.ts +2 -3
- package/dist/boot/stage-07-capability-layer.js +3 -4
- package/dist/boot/stage-08-reapers.d.ts +2 -3
- package/dist/boot/stage-08-reapers.js +2 -2
- package/dist/boot/stage-09-leader.d.ts +2 -3
- package/dist/boot/stage-09-leader.js +2 -1
- package/dist/boot/stage-10-http-server.d.ts +2 -3
- package/dist/boot/stage-10-http-server.js +2 -2
- package/dist/boot/stores.d.ts +1 -0
- package/dist/boot/stores.js +4 -3
- package/dist/boot/web-admin.d.ts +5 -0
- package/dist/boot/web-admin.js +28 -14
- package/dist/brain.d.ts +2 -2
- package/dist/brain.js +1 -1
- package/dist/capabilities/builtin-tools.d.ts +2 -1
- package/dist/capabilities/composite-members.d.ts +1 -1
- package/dist/capabilities/composite-members.js +1 -1
- package/dist/capabilities/hands-lane.d.ts +9 -6
- package/dist/capabilities/hands-lane.js +2 -2
- package/dist/capabilities/memory-notice.d.ts +27 -22
- package/dist/capabilities/memory-notice.js +15 -17
- package/dist/capabilities/scenarios.d.ts +9 -14
- package/dist/capabilities/scenarios.js +5 -50
- package/dist/capability-facts.d.ts +22 -11
- package/dist/capability-facts.js +13 -6
- package/dist/config-catalog.js +14 -5
- package/dist/config-provider.d.ts +5 -1
- package/dist/config-provider.js +23 -4
- package/dist/config-types.d.ts +19 -2
- package/dist/config.js +41 -6
- package/dist/deployment-governance.d.ts +19 -21
- package/dist/deployment-governance.js +7 -8
- package/dist/env-name-allowlist-knobs.d.ts +5 -5
- package/dist/env-name-allowlist-knobs.js +1 -0
- package/dist/execution-lane-caps.d.ts +12 -0
- package/dist/execution-lane-caps.js +18 -4
- package/dist/fleet/subagent-tail-bus.js +15 -5
- package/dist/hooks/hook-runner.d.ts +84 -30
- package/dist/hooks/hook-runner.js +74 -41
- package/dist/http/admission.js +17 -10
- package/dist/http/durable-run-leg.d.ts +5 -4
- package/dist/http/durable-run-leg.js +2 -4
- package/dist/http/idempotency.d.ts +28 -6
- package/dist/http/idempotency.js +10 -2
- package/dist/http/principal-gate.d.ts +14 -0
- package/dist/http/principal-gate.js +7 -0
- package/dist/http/request-guards.d.ts +2 -1
- package/dist/http/request-guards.js +14 -14
- package/dist/http/resume-legs.d.ts +2 -2
- package/dist/http/resume-legs.js +20 -10
- package/dist/http/route-ctx.d.ts +15 -7
- package/dist/http/routes/a2a-serve.js +11 -7
- package/dist/http/routes/approvals-assistant.js +18 -3
- package/dist/http/routes/background.d.ts +1 -1
- package/dist/http/routes/background.js +1 -1
- package/dist/http/routes/capabilities.js +10 -2
- package/dist/http/routes/mcp-reconnect.js +1 -1
- package/dist/http/routes/runs.d.ts +4 -3
- package/dist/http/routes/runs.js +35 -14
- package/dist/http/routes/session-sync.js +14 -11
- package/dist/http/routes/sessions.js +9 -9
- package/dist/http/routes/side-query.js +6 -19
- package/dist/http/routes/tasks.js +29 -19
- package/dist/http/routes/workflows.js +8 -0
- package/dist/http/server.d.ts +4 -0
- package/dist/http/server.js +3 -5
- package/dist/http/submit-replay.d.ts +15 -8
- package/dist/http/submit-replay.js +6 -0
- package/dist/http/wire-types.d.ts +7 -9
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal-run-env.d.ts +30 -0
- package/dist/internal-run-env.js +11 -0
- package/dist/leader/wire.js +3 -2
- package/dist/lsp/manager.js +1 -1
- package/dist/memory-scope.d.ts +19 -7
- package/dist/memory-scope.js +25 -8
- package/dist/observability/fail-open.d.ts +31 -3
- package/dist/observability/fail-open.js +31 -3
- package/dist/observability/metrics.d.ts +0 -1
- package/dist/observability/metrics.js +1 -4
- package/dist/observability/run-terminal.js +72 -4
- package/dist/permission-rule-vocab.d.ts +3 -2
- package/dist/permission-rule-vocab.js +1 -1
- package/dist/plan-cache-probe.d.ts +3 -2
- package/dist/plugins/approval-ask-store-sql.d.ts +1 -1
- package/dist/plugins/caching-session-store.d.ts +42 -22
- package/dist/plugins/caching-session-store.js +19 -9
- package/dist/plugins/checkpoint-store-sql.d.ts +42 -28
- package/dist/plugins/checkpoint-store-sql.js +57 -11
- package/dist/plugins/file-run-store.d.ts +25 -21
- package/dist/plugins/file-run-store.js +70 -34
- package/dist/plugins/k8s-exec-protocol.js +3 -0
- package/dist/plugins/local-checkpoint-store.d.ts +8 -5
- package/dist/plugins/local-checkpoint-store.js +5 -15
- package/dist/plugins/local-session-store.d.ts +66 -7
- package/dist/plugins/local-session-store.js +168 -101
- package/dist/plugins/mailbox-recipient-lifecycle.d.ts +1 -1
- package/dist/plugins/memory-run-store.d.ts +19 -3
- package/dist/plugins/memory-run-store.js +38 -3
- package/dist/plugins/one-time-migrations.d.ts +165 -39
- package/dist/plugins/one-time-migrations.js +51 -144
- package/dist/plugins/pg-pool.js +11 -1
- package/dist/plugins/pg-session-storage.d.ts +2 -0
- package/dist/plugins/pg-session-storage.js +9 -5
- package/dist/plugins/remote-env-adb.d.ts +3 -0
- package/dist/plugins/remote-env-adb.js +1 -0
- package/dist/plugins/remote-env-device.d.ts +3 -0
- package/dist/plugins/remote-env-device.js +1 -0
- package/dist/plugins/remote-env-e2b.d.ts +4 -0
- package/dist/plugins/remote-env-e2b.js +2 -1
- package/dist/plugins/remote-env-host.d.ts +23 -1
- package/dist/plugins/remote-env-host.js +20 -2
- package/dist/plugins/remote-env-k8s.d.ts +264 -41
- package/dist/plugins/remote-env-k8s.js +551 -82
- package/dist/plugins/remote-env-local-docker.d.ts +4 -0
- package/dist/plugins/remote-env-local-docker.js +2 -1
- package/dist/plugins/remote-env-ssh.d.ts +3 -0
- package/dist/plugins/remote-env-ssh.js +1 -0
- package/dist/plugins/retention-store-sql.d.ts +4 -1
- package/dist/plugins/retention-store-sql.js +11 -9
- package/dist/plugins/run-store-sql.d.ts +44 -6
- package/dist/plugins/run-store-sql.js +92 -27
- package/dist/plugins/session-capture-record-store-sql.d.ts +2 -3
- package/dist/plugins/session-store.d.ts +4 -3
- package/dist/plugins/session-store.js +1 -1
- package/dist/plugins/sql-driver.d.ts +2 -1
- package/dist/plugins/store-backend.d.ts +12 -5
- package/dist/plugins/store-backend.js +13 -4
- package/dist/plugins/store-contracts.d.ts +20 -3
- package/dist/plugins/store-open-wait.js +3 -0
- package/dist/plugins/tidb-pool.js +15 -3
- package/dist/plugins/tidb-session-storage.js +3 -2
- package/dist/plugins/tidb-session-store.d.ts +2 -0
- package/dist/plugins/tidb-session-store.js +6 -2
- package/dist/request-key-closure.d.ts +32 -7
- package/dist/request-key-closure.js +25 -9
- package/dist/router/route-orchestration.d.ts +3 -11
- package/dist/router/route-orchestration.js +0 -4
- package/dist/rule-entry-list.js +0 -4
- package/dist/rules-consent.js +3 -0
- package/dist/run-acceptance.d.ts +51 -0
- package/dist/run-acceptance.js +27 -0
- package/dist/run-local.d.ts +5 -4
- package/dist/run-local.js +23 -17
- package/dist/runs.d.ts +7 -11
- package/dist/runs.js +6 -16
- package/dist/sandbox-egress-policy.d.ts +61 -0
- package/dist/sandbox-egress-policy.js +235 -0
- package/dist/security.d.ts +7 -0
- package/dist/security.js +5 -0
- package/dist/server-secret-env.d.ts +10 -4
- package/dist/server-secret-env.js +4 -1
- package/dist/session-error-wire.d.ts +2 -2
- package/dist/session-incarnation.d.ts +46 -2
- package/dist/session-incarnation.js +14 -3
- package/dist/session-sync-kernel.d.ts +10 -3
- package/dist/session-titler.d.ts +5 -3
- package/dist/spec-fields.d.ts +24 -8
- package/dist/spec-fields.js +21 -2
- package/dist/task-workflow.d.ts +6 -4
- package/dist/task-workflow.js +3 -2
- package/dist/tool-approval.d.ts +78 -6
- package/dist/tool-approval.js +7 -3
- package/dist/trace/core-keyset-guard.d.ts +6 -5
- package/dist/trace/engine-notice-wire.d.ts +26 -1
- package/dist/trace/engine-notice-wire.js +7 -2
- package/dist/trace/ledger-events.d.ts +9 -3
- package/dist/trace/ledger-sink.d.ts +7 -0
- package/dist/trace/ledger-sink.js +14 -4
- package/dist/trace/project.d.ts +57 -11
- package/dist/trace/project.js +54 -8
- package/dist/trace/self-minted-wire.d.ts +94 -2
- package/dist/trace/self-minted-wire.js +28 -0
- package/dist/trace/tool-output-media.d.ts +26 -0
- package/dist/trace/tool-output-media.js +57 -0
- package/dist/trace/wire-projection-faces.d.ts +7 -1
- package/dist/trace/wire-projection-faces.js +2 -1
- package/dist/web-admin-client/apply-effective.d.ts +13 -7
- package/dist/web-admin-client/apply-effective.js +11 -26
- package/dist/web-admin-client/apply-ledger.d.ts +2 -2
- package/dist/web-admin-client/candidate-gate.d.ts +39 -4
- package/dist/web-admin-client/candidate-gate.js +9 -2
- package/dist/web-admin-client/facade.d.ts +2 -2
- package/dist/web-admin-client/http-client.d.ts +3 -1
- package/dist/web-admin-client/http-client.js +4 -3
- package/dist/web-admin-client/read-warnings.d.ts +33 -5
- package/dist/web-admin-client/read-warnings.js +41 -6
- package/dist/web-admin-client/types.d.ts +11 -5
- package/dist/workspace-cache.d.ts +45 -0
- package/dist/workspace-cache.js +30 -0
- package/dist/workspace-space-commit.d.ts +116 -0
- package/dist/workspace-space-commit.js +117 -0
- package/dist/workspace-space.d.ts +75 -9
- package/dist/workspace-space.js +52 -1
- package/package.json +4 -4
- package/dist/boot/session-incarnation-seat.d.ts +0 -41
- package/dist/boot/session-incarnation-seat.js +0 -69
- package/dist/capabilities/code-review-council.d.ts +0 -49
- package/dist/capabilities/code-review-council.js +0 -111
- package/dist/capabilities/collab-wire.d.ts +0 -36
- package/dist/capabilities/collab-wire.js +0 -2
- package/dist/capabilities/collab-workflows.d.ts +0 -47
- package/dist/capabilities/collab-workflows.js +0 -90
- package/dist/capabilities/prompts/code-review.d.ts +0 -25
- package/dist/capabilities/prompts/code-review.js +0 -39
- package/dist/capabilities/prompts/team.d.ts +0 -10
- package/dist/capabilities/prompts/team.js +0 -6
- package/dist/capabilities/team.d.ts +0 -113
- package/dist/capabilities/team.js +0 -70
|
@@ -6,7 +6,12 @@ import { StringDecoder } from "node:string_decoder";
|
|
|
6
6
|
import WebSocket from "ws";
|
|
7
7
|
import { buildCwdEnvCommand, createCwdGuardedCall, shellQuote, kindFromMode, numEnvOr } from "./remote-shell.js";
|
|
8
8
|
import { presignS3Url, presignS3ListUrl } from "./s3-presign.js";
|
|
9
|
+
import { K8S_WORKSPACE_MOUNT_DEFAULT, WORKSPACE_SNAPSHOT_TIMEOUT_MS_DEFAULT, mintSnapshotId, snapshotNameOwnedBy } from "../workspace-space.js";
|
|
10
|
+
import { spaceCommitAlive } from "../workspace-space-commit.js";
|
|
9
11
|
import { incarnationLogPrefix, redactIncarnations } from "../session-incarnation.js";
|
|
12
|
+
import { WORKSPACE_CACHE_SPACE_LABEL, workspaceCacheClaimOf, workspaceCacheEnv } from "../workspace-cache.js";
|
|
13
|
+
import { recordFailOpen } from "../observability/fail-open.js";
|
|
14
|
+
import { SANDBOX_IPV6_DEFAULT, sandboxEgressGaps, sandboxPodSysctls } from "../sandbox-egress-policy.js";
|
|
10
15
|
import { FileError, ExecutionError, RemoteExecutionError, withRetry, RollingTailBuffer, markTruncated, } from "@sema-agent/core";
|
|
11
16
|
import { fileErrorFromExec, classifyFsStderr } from "./remote-env-file-error.js";
|
|
12
17
|
import { createPosixShellFs } from "./posix-shell-fs.js";
|
|
@@ -32,21 +37,84 @@ const EXEC_SUBPROTOCOLS = ["v5.channel.k8s.io", "v4.channel.k8s.io"];
|
|
|
32
37
|
const WRITE_CHUNK_BYTES = 64 * 1024;
|
|
33
38
|
import { applyPodSpecPatch } from "./k8s-exec-protocol.js";
|
|
34
39
|
export { applyPodSpecPatch } from "./k8s-exec-protocol.js";
|
|
35
|
-
|
|
40
|
+
const CACHE_VOLUME = "ws-cache";
|
|
41
|
+
function occupantKeyOf(occupant) {
|
|
42
|
+
if (occupant === undefined)
|
|
43
|
+
return { state: "unavailable", reason: "incarnation_unknown" };
|
|
44
|
+
if ("incarnation" in occupant)
|
|
45
|
+
return occupant;
|
|
46
|
+
if ("unread" in occupant)
|
|
47
|
+
return { state: "unavailable", reason: occupant.unread };
|
|
48
|
+
return { state: "unreadable", code: occupant.fault };
|
|
49
|
+
}
|
|
50
|
+
function cacheLaneOf(policy, occupant) {
|
|
51
|
+
if (policy === undefined)
|
|
52
|
+
return { state: "off" };
|
|
53
|
+
const key = occupantKeyOf(occupant);
|
|
54
|
+
if (!("incarnation" in key))
|
|
55
|
+
return key;
|
|
56
|
+
return { state: "open", cache: { mount: policy.mount, size: policy.size, claim: workspaceCacheClaimOf(key.incarnation), spaceKey: key.incarnation } };
|
|
57
|
+
}
|
|
58
|
+
function spaceLaneOf(policy, occupant) {
|
|
36
59
|
if (policy === undefined)
|
|
37
60
|
return { state: "off" };
|
|
38
61
|
switch (policy.persist) {
|
|
39
62
|
case "off":
|
|
40
63
|
return { state: "off" };
|
|
41
|
-
case "space":
|
|
42
|
-
|
|
64
|
+
case "space": {
|
|
65
|
+
const key = occupantKeyOf(occupant);
|
|
66
|
+
return "incarnation" in key ? { state: "open", space: { incarnation: key.incarnation, keep: policy.keep, fence: key.fence } } : key;
|
|
67
|
+
}
|
|
43
68
|
default: {
|
|
44
69
|
const unreachable = policy.persist;
|
|
45
70
|
throw new Error(`unhandled WORKSPACE_PERSIST word: ${String(unreachable)}`);
|
|
46
71
|
}
|
|
47
72
|
}
|
|
48
73
|
}
|
|
74
|
+
function spaceReadFailed(what, detail, cause) {
|
|
75
|
+
return { ok: false, error: new RemoteExecutionError("connect_failed", `workspace space ${what} read failed${detail}`, cause instanceof Error ? cause : undefined) };
|
|
76
|
+
}
|
|
77
|
+
function restoreFailureIsTransient(cls) {
|
|
78
|
+
switch (cls) {
|
|
79
|
+
case "timeout":
|
|
80
|
+
case "transport":
|
|
81
|
+
case "store_busy":
|
|
82
|
+
return true;
|
|
83
|
+
case "download":
|
|
84
|
+
case "untar":
|
|
85
|
+
return false;
|
|
86
|
+
default: {
|
|
87
|
+
const unreachable = cls;
|
|
88
|
+
throw new Error(`unhandled restore failure class: ${String(unreachable)}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const RESTORE_MAX_ATTEMPTS = { park: 1, space: 2 };
|
|
93
|
+
const RESTORE_DOWNLOAD_EXIT = 22;
|
|
94
|
+
const RESTORE_TRANSFER_EXIT = 75;
|
|
95
|
+
const RESTORE_STORE_BUSY_STATUSES = [408, 429, 500, 502, 503, 504];
|
|
96
|
+
const RESTORE_STORE_BUSY_EXIT = 69;
|
|
97
|
+
function restoreClassOfExit(exitCode) {
|
|
98
|
+
switch (exitCode) {
|
|
99
|
+
case RESTORE_STORE_BUSY_EXIT:
|
|
100
|
+
return "store_busy";
|
|
101
|
+
case RESTORE_DOWNLOAD_EXIT:
|
|
102
|
+
return "download";
|
|
103
|
+
case RESTORE_TRANSFER_EXIT:
|
|
104
|
+
return "transport";
|
|
105
|
+
default:
|
|
106
|
+
return "untar";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
49
109
|
const SPACE_COMMIT_POLL_MS = 1000;
|
|
110
|
+
class FenceCallAborted extends Error {
|
|
111
|
+
constructor() {
|
|
112
|
+
super("aborted");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function dataTimeoutMsOf(config) {
|
|
116
|
+
return config.dataTimeoutMs ?? WORKSPACE_SNAPSHOT_TIMEOUT_MS_DEFAULT;
|
|
117
|
+
}
|
|
50
118
|
import { execSocketTlsOptions, exitCodeFromStatus } from "./k8s-exec-protocol.js";
|
|
51
119
|
export { execSocketTlsOptions } from "./k8s-exec-protocol.js";
|
|
52
120
|
export function errorMessageOf(e) {
|
|
@@ -56,6 +124,8 @@ export function errorMessageOf(e) {
|
|
|
56
124
|
return typeof m === "string" && m ? m : String(e);
|
|
57
125
|
}
|
|
58
126
|
export { exitCodeFromStatus } from "./k8s-exec-protocol.js";
|
|
127
|
+
export const K8S_SANDBOX_NAMESPACE_DEFAULT = "sandboxes";
|
|
128
|
+
const SANDBOX_POD_LABELS = { app: "sema-sandbox", "managed-by": "sema-server" };
|
|
59
129
|
export class RemoteK8sExecutionEnv {
|
|
60
130
|
capabilities;
|
|
61
131
|
cwd;
|
|
@@ -65,11 +135,17 @@ export class RemoteK8sExecutionEnv {
|
|
|
65
135
|
handleSnapshotId;
|
|
66
136
|
inFlightExecs = 0;
|
|
67
137
|
connectPromise;
|
|
138
|
+
restoreRefusal;
|
|
68
139
|
homeDir;
|
|
140
|
+
cdPath = "unknown";
|
|
69
141
|
cfg;
|
|
70
142
|
deps;
|
|
143
|
+
occupantIncarnation;
|
|
71
144
|
spaceLane;
|
|
72
145
|
spaceUnavailableSaid = false;
|
|
146
|
+
unreadableOccupantSaid = false;
|
|
147
|
+
cacheLane;
|
|
148
|
+
cacheUnavailableSaid = false;
|
|
73
149
|
logger;
|
|
74
150
|
onWorkspaceSnapshot;
|
|
75
151
|
constructor(config, deps) {
|
|
@@ -78,27 +154,32 @@ export class RemoteK8sExecutionEnv {
|
|
|
78
154
|
token: config.token,
|
|
79
155
|
caCert: config.caCert,
|
|
80
156
|
insecureTls: config.insecureTls ?? false,
|
|
81
|
-
namespace: config.namespace ??
|
|
157
|
+
namespace: config.namespace ?? K8S_SANDBOX_NAMESPACE_DEFAULT,
|
|
82
158
|
image: config.image,
|
|
83
159
|
runtimeClass: config.runtimeClass ?? "kata-qemu",
|
|
84
|
-
mountPath: config.mountPath ??
|
|
160
|
+
mountPath: config.mountPath ?? K8S_WORKSPACE_MOUNT_DEFAULT,
|
|
85
161
|
timeoutMs: config.timeoutMs ?? 30 * 60_000,
|
|
86
162
|
readyTimeoutMs: config.readyTimeoutMs ?? 120_000,
|
|
87
163
|
rpcTimeoutMs: config.rpcTimeoutMs ?? 30_000,
|
|
88
164
|
livenessMs: config.livenessMs ?? 120_000,
|
|
89
|
-
dataTimeoutMs: config
|
|
165
|
+
dataTimeoutMs: dataTimeoutMsOf(config),
|
|
90
166
|
cpu: config.cpu ?? "1",
|
|
91
167
|
memory: config.memory ?? "2Gi",
|
|
168
|
+
ipv6: config.ipv6 ?? SANDBOX_IPV6_DEFAULT,
|
|
92
169
|
retry: { maxAttempts: config.retry?.maxAttempts ?? 3, backoffMs: config.retry?.backoffMs ?? 1000 },
|
|
93
170
|
...(config.s3Snapshot ? { s3Snapshot: config.s3Snapshot } : {}),
|
|
94
171
|
...(config.sessionId ? { sessionId: config.sessionId } : {}),
|
|
95
172
|
...(config.podSpecPatch ? { podSpecPatch: config.podSpecPatch } : {}),
|
|
96
173
|
...(config.podEnv ? { podEnv: config.podEnv } : {}),
|
|
174
|
+
...(config.snapshotExclude && config.snapshotExclude.length > 0 ? { snapshotExclude: config.snapshotExclude } : {}),
|
|
97
175
|
};
|
|
98
176
|
this.cwd = this.cfg.mountPath;
|
|
99
177
|
this.homeDir = config.homeDir;
|
|
100
178
|
this.deps = deps ?? {};
|
|
101
|
-
|
|
179
|
+
const occupant = occupantKeyOf(config.spaceOccupant);
|
|
180
|
+
this.occupantIncarnation = "incarnation" in occupant ? occupant.incarnation : undefined;
|
|
181
|
+
this.spaceLane = spaceLaneOf(config.s3Snapshot?.space, config.spaceOccupant);
|
|
182
|
+
this.cacheLane = cacheLaneOf(config.cache, config.spaceOccupant);
|
|
102
183
|
this.logger = config.logger;
|
|
103
184
|
this.onWorkspaceSnapshot = config.onWorkspaceSnapshot;
|
|
104
185
|
this.capabilities = { isolation: true, suspendable: !!this.cfg.s3Snapshot, canonicalPathAuthoritative: true };
|
|
@@ -206,18 +287,23 @@ export class RemoteK8sExecutionEnv {
|
|
|
206
287
|
case "off":
|
|
207
288
|
return this.provision(signal);
|
|
208
289
|
case "unavailable":
|
|
209
|
-
this.sayUnavailable();
|
|
290
|
+
this.sayUnavailable(lane.reason);
|
|
291
|
+
return this.provision(signal);
|
|
292
|
+
case "unreadable":
|
|
210
293
|
return this.provision(signal);
|
|
211
294
|
case "open": {
|
|
295
|
+
if (this.restoreRefusal)
|
|
296
|
+
return this.restoreRefusal;
|
|
212
297
|
const settled = await this.awaitSpaceCommit(lane.space, signal);
|
|
213
298
|
if (!settled.ok)
|
|
214
299
|
return settled;
|
|
215
300
|
const latest = await this.readLatest(lane.space, signal);
|
|
216
301
|
if (!latest.ok)
|
|
217
302
|
return latest;
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
303
|
+
const c = latest.value === undefined ? await this.provision(signal) : await this.restoreSnapshot({ lane: "space", space: lane.space }, latest.value, signal);
|
|
304
|
+
if (c.ok)
|
|
305
|
+
this.armSpace();
|
|
306
|
+
return c;
|
|
221
307
|
}
|
|
222
308
|
default: {
|
|
223
309
|
const unreachable = lane;
|
|
@@ -225,27 +311,76 @@ export class RemoteK8sExecutionEnv {
|
|
|
225
311
|
}
|
|
226
312
|
}
|
|
227
313
|
}
|
|
228
|
-
|
|
314
|
+
armSpace() {
|
|
315
|
+
if (this.spaceLane.state === "open")
|
|
316
|
+
this.spaceLane.space.fence.arm(this.cfg.dataTimeoutMs);
|
|
317
|
+
}
|
|
318
|
+
async endSpace(space) {
|
|
319
|
+
await this.fenceCall(() => space.fence.end(), "delete").catch(() => undefined);
|
|
320
|
+
}
|
|
321
|
+
fenceCall(op, what, signal) {
|
|
322
|
+
return new Promise((resolve, reject) => {
|
|
323
|
+
if (signal?.aborted)
|
|
324
|
+
return reject(new FenceCallAborted());
|
|
325
|
+
const onAbort = () => {
|
|
326
|
+
clearTimeout(timer);
|
|
327
|
+
reject(new FenceCallAborted());
|
|
328
|
+
};
|
|
329
|
+
const timer = setTimeout(() => {
|
|
330
|
+
signal?.removeEventListener("abort", onAbort);
|
|
331
|
+
reject(new Error(`workspace space commit state ${what} did not answer within ${this.cfg.rpcTimeoutMs}ms`));
|
|
332
|
+
}, this.cfg.rpcTimeoutMs);
|
|
333
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
334
|
+
op().then((v) => {
|
|
335
|
+
clearTimeout(timer);
|
|
336
|
+
signal?.removeEventListener("abort", onAbort);
|
|
337
|
+
resolve(v);
|
|
338
|
+
}, (e) => {
|
|
339
|
+
clearTimeout(timer);
|
|
340
|
+
signal?.removeEventListener("abort", onAbort);
|
|
341
|
+
reject(e);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
sayUnavailable(reason) {
|
|
229
346
|
if (this.spaceUnavailableSaid)
|
|
230
347
|
return;
|
|
231
348
|
this.spaceUnavailableSaid = true;
|
|
232
349
|
this.logger?.warn("workspace_space_unavailable", {
|
|
233
|
-
reason
|
|
350
|
+
reason,
|
|
234
351
|
sessionId: this.cfg.sessionId,
|
|
235
|
-
effect: "WORKSPACE_PERSIST=space, but
|
|
352
|
+
effect: "WORKSPACE_PERSIST=space, but the session store has no occupant (incarnation) for this run's session — its /workspace is neither restored nor saved (it is never keyed by the session id instead)",
|
|
236
353
|
});
|
|
237
354
|
}
|
|
355
|
+
refuseUnreadableOccupant(code) {
|
|
356
|
+
const ways = [...(this.spaceLane.state === "unreadable" ? ["WORKSPACE_PERSIST=off"] : []), ...(this.cacheLane.state === "unreadable" ? ["WORKSPACE_CACHE=off"] : [])].join(" and ");
|
|
357
|
+
if (!this.unreadableOccupantSaid) {
|
|
358
|
+
this.unreadableOccupantSaid = true;
|
|
359
|
+
this.logger?.warn("workspace_space_unreadable_occupant", {
|
|
360
|
+
sessionId: this.cfg.sessionId,
|
|
361
|
+
code,
|
|
362
|
+
effect: `the session store did not answer for this run's session occupant (incarnation) — this run is refused rather than started on a workspace that is neither restored nor saved; ${ways} runs without it`,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
return spaceReadFailed("occupant", ` (${code}) — refusing to start this run on a workspace that could be neither restored nor saved; retry the run once the session store answers, or set ${ways}`);
|
|
366
|
+
}
|
|
238
367
|
spaceLine(event, space, fields) {
|
|
239
368
|
const safe = Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, typeof v === "string" ? redactIncarnations(v) : v]));
|
|
240
369
|
this.logger?.warn(event, { space: incarnationLogPrefix(space.incarnation), ...safe });
|
|
241
370
|
}
|
|
242
371
|
provision(signal) {
|
|
372
|
+
const space = this.spaceLane;
|
|
373
|
+
const cache = this.cacheLane;
|
|
374
|
+
if (space.state === "unreadable")
|
|
375
|
+
return Promise.resolve(this.refuseUnreadableOccupant(space.code));
|
|
376
|
+
if (cache.state === "unreadable")
|
|
377
|
+
return Promise.resolve(this.refuseUnreadableOccupant(cache.code));
|
|
243
378
|
const policy = {
|
|
244
379
|
retryableCodes: ["connect_failed", "timeout", "transport_lost"],
|
|
245
380
|
maxAttempts: this.cfg.retry.maxAttempts,
|
|
246
381
|
backoffMs: (failed) => this.cfg.retry.backoffMs * failed,
|
|
247
382
|
};
|
|
248
|
-
const once = this.deps.connectOnce ?? ((s) => this.connectOnce(s));
|
|
383
|
+
const once = this.deps.connectOnce ?? ((s) => this.connectOnce(cache, s));
|
|
249
384
|
return withRetry(() => once(signal), policy, { signal });
|
|
250
385
|
}
|
|
251
386
|
async ensureConnected() {
|
|
@@ -261,21 +396,133 @@ export class RemoteK8sExecutionEnv {
|
|
|
261
396
|
}
|
|
262
397
|
return ok(undefined);
|
|
263
398
|
}
|
|
264
|
-
async connectOnce(signal) {
|
|
399
|
+
async connectOnce(cacheLane, signal) {
|
|
265
400
|
if (signal?.aborted)
|
|
266
401
|
return { ok: false, error: new RemoteExecutionError("aborted", "connect aborted") };
|
|
267
402
|
if (this.destroyed)
|
|
268
403
|
return { ok: false, error: new RemoteExecutionError("connect_failed", "env already destroyed") };
|
|
404
|
+
const cache = await this.attachCache(cacheLane, signal);
|
|
405
|
+
const first = await this.createPod(signal, cache);
|
|
406
|
+
if (!("cacheMiss" in first))
|
|
407
|
+
return first;
|
|
408
|
+
this.cacheUnavailable(first.cacheMiss, first.detail, cache);
|
|
409
|
+
const second = await this.createPod(signal, undefined);
|
|
410
|
+
return "cacheMiss" in second ? { ok: false, error: new RemoteExecutionError("connect_failed", "unreachable: a pod without the cache volume was given up for it") } : second;
|
|
411
|
+
}
|
|
412
|
+
async attachCache(lane, signal) {
|
|
413
|
+
switch (lane.state) {
|
|
414
|
+
case "off":
|
|
415
|
+
return undefined;
|
|
416
|
+
case "unavailable":
|
|
417
|
+
this.cacheUnavailable(lane.reason, "the session store has no occupant (incarnation) for this run's session — no space key to find its cache volume by (never keyed by the session id instead)");
|
|
418
|
+
return undefined;
|
|
419
|
+
case "open": {
|
|
420
|
+
const ensured = await this.ensureCacheClaim(lane.cache, signal);
|
|
421
|
+
if (!ensured.ok) {
|
|
422
|
+
this.cacheUnavailable("claim_failed", ensured.detail, lane.cache);
|
|
423
|
+
return undefined;
|
|
424
|
+
}
|
|
425
|
+
return { ...lane.cache, fresh: ensured.fresh, bindDeadline: Date.now() + this.cfg.dataTimeoutMs };
|
|
426
|
+
}
|
|
427
|
+
default: {
|
|
428
|
+
const unreachable = lane;
|
|
429
|
+
throw new Error(`unhandled cache lane state: ${String(unreachable.state)}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
async ensureCacheClaim(cache, signal) {
|
|
434
|
+
const base = `/api/v1/namespaces/${this.cfg.namespace}/persistentvolumeclaims`;
|
|
435
|
+
try {
|
|
436
|
+
const got = await this.request("GET", `${base}/${cache.claim.name}`, undefined, { signal });
|
|
437
|
+
if (got.status === 200)
|
|
438
|
+
return { ok: true, fresh: false };
|
|
439
|
+
if (got.status !== 404)
|
|
440
|
+
return { ok: false, detail: `claim read answered HTTP ${got.status}: ${JSON.stringify(got.json).slice(0, 200)}` };
|
|
441
|
+
const claim = {
|
|
442
|
+
apiVersion: "v1",
|
|
443
|
+
kind: "PersistentVolumeClaim",
|
|
444
|
+
metadata: { name: cache.claim.name, labels: { [WORKSPACE_CACHE_SPACE_LABEL]: cache.claim.label, "managed-by": "sema-server" } },
|
|
445
|
+
spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: cache.size } } },
|
|
446
|
+
};
|
|
447
|
+
const made = await this.request("POST", base, claim, { signal });
|
|
448
|
+
if (made.status === 200 || made.status === 201)
|
|
449
|
+
return { ok: true, fresh: true };
|
|
450
|
+
if (made.status === 409)
|
|
451
|
+
return { ok: true, fresh: false };
|
|
452
|
+
return { ok: false, detail: `claim create answered HTTP ${made.status}: ${JSON.stringify(made.json).slice(0, 200)}` };
|
|
453
|
+
}
|
|
454
|
+
catch (e) {
|
|
455
|
+
return { ok: false, detail: `claim request failed: ${errorMessageOf(e)}` };
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
async cacheClaimBound(cache, signal) {
|
|
459
|
+
const got = await this.request("GET", `/api/v1/namespaces/${this.cfg.namespace}/persistentvolumeclaims/${cache.claim.name}`, undefined, { signal }).catch(() => undefined);
|
|
460
|
+
return got?.status === 200 && got.json?.status?.phase === "Bound";
|
|
461
|
+
}
|
|
462
|
+
async reclaimSpaceCache(spaceKey, deadline) {
|
|
463
|
+
if (deadline.aborted)
|
|
464
|
+
return { ok: false, reason: "budget", detail: this.reclaimDeadlinePassed("the claim delete-by-label was not sent") };
|
|
465
|
+
const { label } = workspaceCacheClaimOf(spaceKey);
|
|
466
|
+
const selector = encodeURIComponent(`${WORKSPACE_CACHE_SPACE_LABEL}=${label}`);
|
|
467
|
+
try {
|
|
468
|
+
const r = await this.request("DELETE", `/api/v1/namespaces/${this.cfg.namespace}/persistentvolumeclaims?labelSelector=${selector}`, undefined, { timeoutMs: this.cfg.rpcTimeoutMs, signal: deadline });
|
|
469
|
+
if (r.status < 200 || r.status >= 300)
|
|
470
|
+
return { ok: false, reason: "delete_failed", detail: `claim delete-by-label answered HTTP ${r.status}: ${JSON.stringify(r.json).slice(0, 200)}` };
|
|
471
|
+
const items = r.json?.items;
|
|
472
|
+
return { ok: true, deleted: Array.isArray(items) ? items.length : 0 };
|
|
473
|
+
}
|
|
474
|
+
catch (e) {
|
|
475
|
+
if (deadline.aborted)
|
|
476
|
+
return { ok: false, reason: "budget", detail: this.reclaimDeadlinePassed("the claim delete-by-label was cut in flight") };
|
|
477
|
+
return { ok: false, reason: "delete_failed", detail: `claim delete-by-label failed: ${errorMessageOf(e)}` };
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
async egressPolicyGaps() {
|
|
481
|
+
const read = async (apiPath) => {
|
|
482
|
+
try {
|
|
483
|
+
const r = await this.request("GET", apiPath);
|
|
484
|
+
return r.status >= 200 && r.status < 300 ? { ok: true, json: r.json } : { ok: false, detail: `HTTP ${r.status}` };
|
|
485
|
+
}
|
|
486
|
+
catch (e) {
|
|
487
|
+
return { ok: false, detail: errorMessageOf(e) };
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
const nodes = await read("/api/v1/nodes");
|
|
491
|
+
const policies = await read(`/apis/networking.k8s.io/v1/namespaces/${this.cfg.namespace}/networkpolicies`);
|
|
492
|
+
const probe = { metadata: { labels: { ...SANDBOX_POD_LABELS } }, spec: {} };
|
|
493
|
+
if (this.cfg.podSpecPatch?.labels)
|
|
494
|
+
applyPodSpecPatch(probe, { labels: this.cfg.podSpecPatch.labels });
|
|
495
|
+
return sandboxEgressGaps({ nodes, policies, podLabels: probe.metadata.labels, ipv6: this.cfg.ipv6 });
|
|
496
|
+
}
|
|
497
|
+
reclaimDeadlinePassed(what) {
|
|
498
|
+
return `reclaim deadline (${this.cfg.dataTimeoutMs} ms, the snapshot time bound, one per reclaim call) passed — ${what}`;
|
|
499
|
+
}
|
|
500
|
+
cacheUnavailable(reason, detail, cache) {
|
|
501
|
+
if (this.cacheUnavailableSaid)
|
|
502
|
+
return;
|
|
503
|
+
this.cacheUnavailableSaid = true;
|
|
504
|
+
recordFailOpen("server.workspace-cache.unavailable", `reason=${reason}`);
|
|
505
|
+
this.logger?.warn("workspace_cache_unavailable", {
|
|
506
|
+
...(cache ? { space: incarnationLogPrefix(cache.spaceKey), claim: cache.claim.name } : { sessionId: this.cfg.sessionId }),
|
|
507
|
+
reason,
|
|
508
|
+
error: redactIncarnations(detail),
|
|
509
|
+
effect: "this run starts without the build-cache volume and without the cache env (tools use the image's own cache directories; builds re-download)",
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
async createPod(signal, cache) {
|
|
269
513
|
const ns = this.cfg.namespace;
|
|
514
|
+
const podEnv = { ...(cache ? workspaceCacheEnv(cache.mount) : {}), ...(this.cfg.podEnv ?? {}) };
|
|
515
|
+
const sysctls = sandboxPodSysctls(this.cfg.ipv6);
|
|
270
516
|
const pod = {
|
|
271
517
|
apiVersion: "v1",
|
|
272
518
|
kind: "Pod",
|
|
273
519
|
metadata: {
|
|
274
520
|
generateName: "sbx-",
|
|
275
|
-
labels: {
|
|
521
|
+
labels: { ...SANDBOX_POD_LABELS },
|
|
276
522
|
},
|
|
277
523
|
spec: {
|
|
278
524
|
...(this.cfg.runtimeClass ? { runtimeClassName: this.cfg.runtimeClass } : {}),
|
|
525
|
+
...(sysctls ? { securityContext: { sysctls: sysctls.map((x) => ({ ...x })) } } : {}),
|
|
279
526
|
restartPolicy: "Never",
|
|
280
527
|
automountServiceAccountToken: false,
|
|
281
528
|
activeDeadlineSeconds: Math.ceil(this.cfg.timeoutMs / 1000),
|
|
@@ -287,12 +534,12 @@ export class RemoteK8sExecutionEnv {
|
|
|
287
534
|
command: this.cfg.podEnv?.SEMA_PKG_SOURCE
|
|
288
535
|
? ["sh", "-c", "[ -f /opt/sema/pkg-source.sh ] && sh /opt/sema/pkg-source.sh || true; sleep infinity"]
|
|
289
536
|
: ["sh", "-c", "sleep infinity"],
|
|
290
|
-
...(
|
|
291
|
-
|
|
292
|
-
: {}),
|
|
537
|
+
...(Object.keys(podEnv).length > 0 ? { env: Object.entries(podEnv).map(([name, value]) => ({ name, value: String(value) })) } : {}),
|
|
538
|
+
...(cache ? { volumeMounts: [{ name: CACHE_VOLUME, mountPath: cache.mount }] } : {}),
|
|
293
539
|
resources: { limits: { cpu: this.cfg.cpu, memory: this.cfg.memory } },
|
|
294
540
|
},
|
|
295
541
|
],
|
|
542
|
+
...(cache ? { volumes: [{ name: CACHE_VOLUME, persistentVolumeClaim: { claimName: cache.claim.name } }] } : {}),
|
|
296
543
|
},
|
|
297
544
|
};
|
|
298
545
|
if (this.cfg.podSpecPatch)
|
|
@@ -344,6 +591,17 @@ export class RemoteK8sExecutionEnv {
|
|
|
344
591
|
return { ok: false, error: new RemoteExecutionError("connect_failed", `image pull failed for ${this.cfg.image}: ${waiting.message ?? waiting.reason}`) };
|
|
345
592
|
}
|
|
346
593
|
if (Date.now() > deadline) {
|
|
594
|
+
if (cache) {
|
|
595
|
+
const bound = await this.cacheClaimBound(cache, signal);
|
|
596
|
+
if (!bound && Date.now() < cache.bindDeadline) {
|
|
597
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
await this.deletePod(name).catch(() => { });
|
|
601
|
+
return bound
|
|
602
|
+
? { cacheMiss: "claim_unmountable", detail: `the pod carrying the bound claim ${cache.claim.name} was not Running within ${this.cfg.readyTimeoutMs}ms (phase=${phase ?? "?"}${waiting?.reason ? `, ${waiting.reason}` : ""})` }
|
|
603
|
+
: { cacheMiss: "claim_unbound", detail: `the claim ${cache.claim.name} was not bound within ${this.cfg.dataTimeoutMs}ms (no default StorageClass, or its provisioner is down)` };
|
|
604
|
+
}
|
|
347
605
|
await this.deletePod(name).catch(() => { });
|
|
348
606
|
return { ok: false, error: new RemoteExecutionError("timeout", `pod not Running within ${this.cfg.readyTimeoutMs}ms (phase=${phase ?? "?"})`) };
|
|
349
607
|
}
|
|
@@ -362,6 +620,8 @@ export class RemoteK8sExecutionEnv {
|
|
|
362
620
|
this.podIP = undefined;
|
|
363
621
|
return { ok: false, error: new RemoteExecutionError("connect_failed", `workspace mkdir failed: ${mk.ok ? mk.value.stderr : mk.error.message}`) };
|
|
364
622
|
}
|
|
623
|
+
if (cache?.fresh)
|
|
624
|
+
this.logger?.warn("workspace_cache_fresh", { space: incarnationLogPrefix(cache.spaceKey), claim: cache.claim.name, size: cache.size });
|
|
365
625
|
return ok(this.workspaceHandle());
|
|
366
626
|
}
|
|
367
627
|
presignSnapshot(req) {
|
|
@@ -369,8 +629,14 @@ export class RemoteK8sExecutionEnv {
|
|
|
369
629
|
const prefix = s3.keyPrefix ?? "kata";
|
|
370
630
|
const [label, partition] = req.lane === "space" ? ["spaceIncarnation", req.space.incarnation] : ["sessionId", this.cfg.sessionId ?? "_"];
|
|
371
631
|
const snapshotId = "snapshotId" in req ? req.snapshotId : undefined;
|
|
632
|
+
const objectName = "object" in req ? req.object : undefined;
|
|
372
633
|
const safe = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
373
|
-
const segments = [
|
|
634
|
+
const segments = [
|
|
635
|
+
[label, partition],
|
|
636
|
+
...(snapshotId !== undefined ? [["snapshotId", snapshotId]] : []),
|
|
637
|
+
...(objectName !== undefined ? [["object", objectName]] : []),
|
|
638
|
+
["keyPrefix", prefix],
|
|
639
|
+
];
|
|
374
640
|
for (const [label, seg] of segments) {
|
|
375
641
|
if (!safe.test(seg))
|
|
376
642
|
return { ok: false, error: new RemoteExecutionError("connect_failed", `unsafe snapshot key segment (${label}): refusing to build a traversing object key`) };
|
|
@@ -383,12 +649,86 @@ export class RemoteK8sExecutionEnv {
|
|
|
383
649
|
...(s3.region ? { region: s3.region } : {}),
|
|
384
650
|
expiresSec: Math.min(s3.presignTtlSec ?? 600, 3600),
|
|
385
651
|
};
|
|
386
|
-
const base = req
|
|
652
|
+
const base = this.snapshotBase(req);
|
|
387
653
|
if (req.method === "LIST")
|
|
388
654
|
return { ok: true, value: presignS3ListUrl({ ...common, prefix: base, maxKeys: 1000 }) };
|
|
389
|
-
const object = "pointer" in req ? req.pointer : `${req.snapshotId}.tar.gz`;
|
|
655
|
+
const object = "pointer" in req ? req.pointer : "object" in req ? req.object : `${req.snapshotId}.tar.gz`;
|
|
390
656
|
return { ok: true, value: presignS3Url({ ...common, key: `${base}${object}`, method: req.method }) };
|
|
391
657
|
}
|
|
658
|
+
snapshotBase(req) {
|
|
659
|
+
const prefix = this.cfg.s3Snapshot.keyPrefix ?? "kata";
|
|
660
|
+
return req.lane === "space" ? `${prefix}/${req.space.incarnation}/space/` : `${prefix}/${this.cfg.sessionId ?? "_"}/`;
|
|
661
|
+
}
|
|
662
|
+
async reclaimSpaceSnapshots(spaceKey, deadline) {
|
|
663
|
+
if (!this.cfg.s3Snapshot)
|
|
664
|
+
return [];
|
|
665
|
+
const failures = [];
|
|
666
|
+
const deadlinePassed = (lane) => [
|
|
667
|
+
...failures,
|
|
668
|
+
{ lane, stage: "budget", detail: this.reclaimDeadlinePassed("stopped; the objects not yet deleted stay for the bucket's lifecycle rule") },
|
|
669
|
+
];
|
|
670
|
+
const targets = [{ lane: "space", req: { lane: "space", space: { incarnation: spaceKey }, method: "LIST" } }];
|
|
671
|
+
if (this.cfg.sessionId !== undefined)
|
|
672
|
+
targets.push({ lane: "park", req: { lane: "park", method: "LIST" } });
|
|
673
|
+
for (const { lane, req } of targets) {
|
|
674
|
+
if (deadline.aborted)
|
|
675
|
+
return deadlinePassed(lane);
|
|
676
|
+
const list = this.presignSnapshot(req);
|
|
677
|
+
if (!list.ok)
|
|
678
|
+
continue;
|
|
679
|
+
let xml;
|
|
680
|
+
try {
|
|
681
|
+
const res = await this.storeFetch(list.value, { method: "GET", signal: deadline });
|
|
682
|
+
xml = await res.text();
|
|
683
|
+
if (!res.ok) {
|
|
684
|
+
failures.push({ lane, stage: "list", detail: `list HTTP ${res.status}` });
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
catch (e) {
|
|
689
|
+
if (deadline.aborted)
|
|
690
|
+
return deadlinePassed(lane);
|
|
691
|
+
failures.push({ lane, stage: "list", detail: `list: ${this.redactUrl(errorMessageOf(e), list.value)}` });
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
if (/<IsTruncated>true<\/IsTruncated>/.test(xml))
|
|
695
|
+
failures.push({ lane, stage: "list", detail: "listing truncated (more than 1000 objects in this partition) — deleting the objects seen; the rest stay" });
|
|
696
|
+
const base = this.snapshotBase(req);
|
|
697
|
+
const names = [...xml.matchAll(/<Contents>([\s\S]*?)<\/Contents>/g)]
|
|
698
|
+
.map((m) => /<Key>([^<]*)<\/Key>/.exec(m[1])?.[1] ?? "")
|
|
699
|
+
.filter((key) => key.startsWith(base) && !key.slice(base.length).includes("/"))
|
|
700
|
+
.map((key) => key.slice(base.length))
|
|
701
|
+
.filter((name) => lane === "space" || snapshotNameOwnedBy(name, spaceKey));
|
|
702
|
+
const del = [];
|
|
703
|
+
let outOfTime = false;
|
|
704
|
+
for (const name of names) {
|
|
705
|
+
if (deadline.aborted) {
|
|
706
|
+
outOfTime = true;
|
|
707
|
+
break;
|
|
708
|
+
}
|
|
709
|
+
const target = req.lane === "space" ? this.presignSnapshot({ lane: "space", space: req.space, method: "DELETE", object: name }) : this.presignSnapshot({ lane: "park", method: "DELETE", object: name });
|
|
710
|
+
if (!target.ok)
|
|
711
|
+
continue;
|
|
712
|
+
try {
|
|
713
|
+
const res = await this.storeFetch(target.value, { method: "DELETE", signal: deadline });
|
|
714
|
+
if (!res.ok && res.status !== 404)
|
|
715
|
+
del.push(`${name}: HTTP ${res.status}`);
|
|
716
|
+
}
|
|
717
|
+
catch (e) {
|
|
718
|
+
if (deadline.aborted) {
|
|
719
|
+
outOfTime = true;
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
del.push(`${name}: ${this.redactUrl(errorMessageOf(e), target.value)}`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
if (del.length > 0)
|
|
726
|
+
failures.push({ lane, stage: "delete", detail: `${del.length} of ${names.length} objects not deleted — first: ${del[0]}` });
|
|
727
|
+
if (outOfTime)
|
|
728
|
+
return deadlinePassed(lane);
|
|
729
|
+
}
|
|
730
|
+
return failures;
|
|
731
|
+
}
|
|
392
732
|
storeFetch(url, init) {
|
|
393
733
|
const bound = AbortSignal.timeout(Math.max(1, Math.min(this.cfg.rpcTimeoutMs, init.timeoutMs ?? this.cfg.rpcTimeoutMs)));
|
|
394
734
|
const signal = init.signal ? AbortSignal.any([init.signal, bound]) : bound;
|
|
@@ -398,7 +738,7 @@ export class RemoteK8sExecutionEnv {
|
|
|
398
738
|
const url = this.presignSnapshot({ lane: "space", space, method: "GET", pointer });
|
|
399
739
|
if (!url.ok)
|
|
400
740
|
return url;
|
|
401
|
-
const what =
|
|
741
|
+
const what = "pointer";
|
|
402
742
|
let res;
|
|
403
743
|
let body;
|
|
404
744
|
try {
|
|
@@ -406,12 +746,12 @@ export class RemoteK8sExecutionEnv {
|
|
|
406
746
|
body = await res.text();
|
|
407
747
|
}
|
|
408
748
|
catch (e) {
|
|
409
|
-
return
|
|
749
|
+
return spaceReadFailed(what, `: ${this.redactUrl(errorMessageOf(e), url.value)}`, e);
|
|
410
750
|
}
|
|
411
751
|
if (res.status === 404)
|
|
412
752
|
return ok(undefined);
|
|
413
753
|
if (!res.ok)
|
|
414
|
-
return
|
|
754
|
+
return spaceReadFailed(what, ` (HTTP ${res.status})`);
|
|
415
755
|
return ok(body.trim());
|
|
416
756
|
}
|
|
417
757
|
async readLatest(space, signal) {
|
|
@@ -423,7 +763,7 @@ export class RemoteK8sExecutionEnv {
|
|
|
423
763
|
const url = this.presignSnapshot({ lane: "space", space, method, pointer });
|
|
424
764
|
if (!url.ok)
|
|
425
765
|
return url;
|
|
426
|
-
const what =
|
|
766
|
+
const what = "pointer";
|
|
427
767
|
try {
|
|
428
768
|
const res = await this.storeFetch(url.value, { method, ...(body === null ? {} : { body }), ...(timeoutMs !== undefined ? { timeoutMs } : {}) });
|
|
429
769
|
if (!res.ok && !(method === "DELETE" && res.status === 404))
|
|
@@ -436,17 +776,30 @@ export class RemoteK8sExecutionEnv {
|
|
|
436
776
|
}
|
|
437
777
|
async awaitSpaceCommit(space, signal) {
|
|
438
778
|
for (;;) {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
779
|
+
let row;
|
|
780
|
+
try {
|
|
781
|
+
row = await this.fenceCall(() => space.fence.read(), "read", signal);
|
|
782
|
+
}
|
|
783
|
+
catch (e) {
|
|
784
|
+
if (e instanceof FenceCallAborted)
|
|
785
|
+
return { ok: false, error: new RemoteExecutionError("aborted", "connect aborted while waiting for the previous run's workspace commit") };
|
|
786
|
+
return spaceReadFailed("commit state", `: ${redactIncarnations(errorMessageOf(e))} — refusing to guess whether a commit is in flight`, e);
|
|
787
|
+
}
|
|
788
|
+
if (row === undefined)
|
|
443
789
|
return ok(undefined);
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
790
|
+
if (!spaceCommitAlive(row)) {
|
|
791
|
+
let gone;
|
|
792
|
+
try {
|
|
793
|
+
gone = await this.fenceCall(() => space.fence.abandon(row.taskId), "delete", signal);
|
|
794
|
+
}
|
|
795
|
+
catch (e) {
|
|
796
|
+
if (e instanceof FenceCallAborted)
|
|
797
|
+
return { ok: false, error: new RemoteExecutionError("aborted", "connect aborted while waiting for the previous run's workspace commit") };
|
|
798
|
+
return { ok: false, error: new RemoteExecutionError("connect_failed", `workspace space commit state write failed: ${redactIncarnations(errorMessageOf(e))} — refusing to guess whether the previous commit is gone`, e instanceof Error ? e : undefined) };
|
|
799
|
+
}
|
|
800
|
+
if (!gone)
|
|
801
|
+
continue;
|
|
802
|
+
this.spaceLine("workspace_commit_abandoned", space, { remainingMs: row.remainingMs, state: row.state });
|
|
450
803
|
return ok(undefined);
|
|
451
804
|
}
|
|
452
805
|
await new Promise((resolveWait) => {
|
|
@@ -503,12 +856,15 @@ export class RemoteK8sExecutionEnv {
|
|
|
503
856
|
async suspendVM(_options) {
|
|
504
857
|
if (!this.cfg.s3Snapshot)
|
|
505
858
|
return unsupported("suspendVM");
|
|
506
|
-
|
|
859
|
+
const r = await this.uploadSnapshot({ lane: "park" });
|
|
860
|
+
if (r.ok && this.spaceLane.state === "open")
|
|
861
|
+
await this.endSpace(this.spaceLane.space);
|
|
862
|
+
return r;
|
|
507
863
|
}
|
|
508
864
|
async uploadSnapshot(target) {
|
|
509
865
|
if (this.inFlightExecs > 0)
|
|
510
866
|
return { ok: false, error: new RemoteExecutionError("command_in_flight", "cannot suspend while a command is in flight (would snapshot a torn workspace)") };
|
|
511
|
-
const snapshotId =
|
|
867
|
+
const snapshotId = mintSnapshotId(this.occupantIncarnation);
|
|
512
868
|
if (!this.podName)
|
|
513
869
|
return ok(snapshotId);
|
|
514
870
|
const presigned = this.presignSnapshot({ ...target, method: "PUT", snapshotId });
|
|
@@ -516,7 +872,8 @@ export class RemoteK8sExecutionEnv {
|
|
|
516
872
|
return presigned;
|
|
517
873
|
const url = presigned.value;
|
|
518
874
|
const tmp = "/tmp/ws-snap.tgz";
|
|
519
|
-
const
|
|
875
|
+
const excludes = (this.cfg.snapshotExclude ?? []).map((item) => ` --exclude=${shellQuote(`./${item}`)}`).join("");
|
|
876
|
+
const cmd = `tar czf ${tmp}${excludes} -C ${shellQuote(this.cfg.mountPath)} . && curl -fsS -X PUT -T ${tmp} ${shellQuote(url)}; ` +
|
|
520
877
|
`rc=$?; rm -f ${tmp}; exit $rc`;
|
|
521
878
|
const r = await this.runExec(cmd, { timeoutMs: this.cfg.dataTimeoutMs, livenessMs: 0 });
|
|
522
879
|
if (!r.ok)
|
|
@@ -532,40 +889,94 @@ export class RemoteK8sExecutionEnv {
|
|
|
532
889
|
async resumeVM(snapshotId, options) {
|
|
533
890
|
if (!this.cfg.s3Snapshot)
|
|
534
891
|
return unsupported("resumeVM");
|
|
535
|
-
const r = await this.restoreSnapshot({ lane: "park" }, snapshotId,
|
|
536
|
-
if (r.ok)
|
|
537
|
-
|
|
538
|
-
|
|
892
|
+
const r = await this.restoreSnapshot({ lane: "park" }, snapshotId, options?.abortSignal);
|
|
893
|
+
if (!r.ok)
|
|
894
|
+
return r;
|
|
895
|
+
this.handleSnapshotId = snapshotId;
|
|
896
|
+
this.armSpace();
|
|
897
|
+
return ok(this.workspaceHandle());
|
|
539
898
|
}
|
|
540
|
-
async restoreSnapshot(target, snapshotId,
|
|
899
|
+
async restoreSnapshot(target, snapshotId, abortSignal) {
|
|
541
900
|
const presigned = this.presignSnapshot({ ...target, method: "GET", snapshotId });
|
|
542
901
|
if (!presigned.ok)
|
|
543
902
|
return presigned;
|
|
544
|
-
const c = await this.provision(opts.abortSignal);
|
|
545
|
-
if (!c.ok)
|
|
546
|
-
return c;
|
|
547
903
|
const url = presigned.value;
|
|
548
|
-
const cmd = `code=$(curl -s -o /tmp/ws.tgz -w '%{http_code}' ${shellQuote(url)}); ` +
|
|
904
|
+
const cmd = `code=$(curl -s -o /tmp/ws.tgz -w '%{http_code}' ${shellQuote(url)}); rc=$?; ` +
|
|
905
|
+
`if [ "$rc" != "0" ]; then echo "snapshot GET got no whole answer (curl exit $rc, HTTP $code)" >&2; exit ${RESTORE_TRANSFER_EXIT}; fi; ` +
|
|
549
906
|
`if [ "$code" = "404" ]; then echo EMPTY_SNAPSHOT; exit 0; fi; ` +
|
|
550
|
-
`
|
|
907
|
+
`case "$code" in ${RESTORE_STORE_BUSY_STATUSES.join("|")}) echo "snapshot GET HTTP $code" >&2; exit ${RESTORE_STORE_BUSY_EXIT};; esac; ` +
|
|
908
|
+
`if [ "$code" != "200" ]; then echo "snapshot GET HTTP $code" >&2; exit ${RESTORE_DOWNLOAD_EXIT}; fi; ` +
|
|
551
909
|
`tar xzf /tmp/ws.tgz -C ${shellQuote(this.cfg.mountPath)} && rm -f /tmp/ws.tgz`;
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
910
|
+
for (let attempt = 1;; attempt++) {
|
|
911
|
+
const c = await this.provision(abortSignal);
|
|
912
|
+
if (!c.ok)
|
|
913
|
+
return c;
|
|
914
|
+
const r = await this.runExec(cmd, { timeoutMs: this.cfg.dataTimeoutMs, livenessMs: 0 });
|
|
915
|
+
const failure = !r.ok
|
|
916
|
+
? { cls: r.error.code === "timeout" ? "timeout" : "transport", detail: `workspace snapshot restore failed: ${this.redactUrl(r.error.message, url)}`, cause: r.error }
|
|
917
|
+
: r.value.exitCode !== 0
|
|
918
|
+
? { cls: restoreClassOfExit(r.value.exitCode), detail: `workspace snapshot restore exited ${r.value.exitCode}: ${this.redactUrl(r.value.stderr, url).slice(0, 300)}` }
|
|
559
919
|
: undefined;
|
|
560
|
-
|
|
920
|
+
if (!failure) {
|
|
921
|
+
if (target.lane === "space" && r.ok && r.value.stdout.includes("EMPTY_SNAPSHOT"))
|
|
922
|
+
await this.dropDanglingLatest(target.space, snapshotId);
|
|
923
|
+
return ok(this.workspaceHandle());
|
|
924
|
+
}
|
|
561
925
|
const name = this.podName;
|
|
562
926
|
this.podName = undefined;
|
|
563
927
|
this.podIP = undefined;
|
|
564
928
|
if (name)
|
|
565
929
|
await this.deletePod(name).catch(() => { });
|
|
566
|
-
|
|
930
|
+
if (attempt < RESTORE_MAX_ATTEMPTS[target.lane] && restoreFailureIsTransient(failure.cls)) {
|
|
931
|
+
if (await this.restorePause(abortSignal))
|
|
932
|
+
continue;
|
|
933
|
+
return { ok: false, error: new RemoteExecutionError("aborted", "connect aborted while retrying the workspace restore") };
|
|
934
|
+
}
|
|
935
|
+
switch (target.lane) {
|
|
936
|
+
case "park":
|
|
937
|
+
return { ok: false, error: new RemoteExecutionError("connect_failed", failure.detail, failure.cause) };
|
|
938
|
+
case "space": {
|
|
939
|
+
const tries = attempt === 1 ? "1 attempt" : `${attempt} attempts`;
|
|
940
|
+
this.spaceLine("workspace_restore_failed", target.space, { snapshotId, class: failure.cls, attempts: attempt, boundMs: this.cfg.dataTimeoutMs, error: failure.detail });
|
|
941
|
+
this.restoreRefusal = {
|
|
942
|
+
ok: false,
|
|
943
|
+
error: new RemoteExecutionError("connect_failed", `${failure.detail} — the workspace's latest snapshot (${snapshotId}) is in the store but did not come back (${failure.cls}, ${tries}, bound ${this.cfg.dataTimeoutMs}ms each), ` +
|
|
944
|
+
"so this run does not start on an empty /workspace. Ways out: raise WORKSPACE_SNAPSHOT_TIMEOUT_MS if the archive is large, " +
|
|
945
|
+
"or set WORKSPACE_PERSIST=off for one run (it starts from an empty /workspace and deletes no snapshot)", failure.cause),
|
|
946
|
+
};
|
|
947
|
+
return this.restoreRefusal;
|
|
948
|
+
}
|
|
949
|
+
default: {
|
|
950
|
+
const unreachable = target;
|
|
951
|
+
throw new Error(`unhandled snapshot lane: ${String(unreachable.lane)}`);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
567
954
|
}
|
|
568
|
-
|
|
955
|
+
}
|
|
956
|
+
restorePause(signal) {
|
|
957
|
+
return new Promise((resolve) => {
|
|
958
|
+
if (signal?.aborted)
|
|
959
|
+
return resolve(false);
|
|
960
|
+
const onAbort = () => {
|
|
961
|
+
clearTimeout(timer);
|
|
962
|
+
resolve(false);
|
|
963
|
+
};
|
|
964
|
+
const timer = setTimeout(() => {
|
|
965
|
+
signal?.removeEventListener("abort", onAbort);
|
|
966
|
+
resolve(true);
|
|
967
|
+
}, this.cfg.retry.backoffMs);
|
|
968
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
async dropDanglingLatest(space, snapshotId) {
|
|
972
|
+
const prefix = this.cfg.s3Snapshot?.keyPrefix ?? "kata";
|
|
973
|
+
const dropped = await this.writePointer(space, "latest", null);
|
|
974
|
+
this.spaceLine("workspace_latest_dangling", space, {
|
|
975
|
+
key: `${prefix}/${space.incarnation}/space/${snapshotId}.tar.gz`,
|
|
976
|
+
deleted: dropped.ok,
|
|
977
|
+
...(dropped.ok ? {} : { error: dropped.error.message }),
|
|
978
|
+
effect: "the space's latest snapshot is gone (removed by a bucket lifecycle rule or by hand) — this run starts from an empty /workspace; its run-end snapshot becomes the new latest",
|
|
979
|
+
});
|
|
569
980
|
}
|
|
570
981
|
async postResumeInit() {
|
|
571
982
|
if (!this.cfg.s3Snapshot)
|
|
@@ -590,20 +1001,23 @@ export class RemoteK8sExecutionEnv {
|
|
|
590
1001
|
await this.disposeBackgroundShells().catch(() => { });
|
|
591
1002
|
this.destroyed = true;
|
|
592
1003
|
const lane = this.spaceLane;
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
1004
|
+
switch (lane.state) {
|
|
1005
|
+
case "open":
|
|
1006
|
+
if (this.podName)
|
|
596
1007
|
await this.persistWorkspace(lane.space);
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
1008
|
+
else
|
|
1009
|
+
await this.endSpace(lane.space);
|
|
1010
|
+
break;
|
|
1011
|
+
case "unavailable":
|
|
1012
|
+
if (this.podName)
|
|
1013
|
+
this.sayUnavailable(lane.reason);
|
|
1014
|
+
break;
|
|
1015
|
+
case "off":
|
|
1016
|
+
case "unreadable":
|
|
1017
|
+
break;
|
|
1018
|
+
default: {
|
|
1019
|
+
const unreachable = lane;
|
|
1020
|
+
void unreachable;
|
|
607
1021
|
}
|
|
608
1022
|
}
|
|
609
1023
|
const name = this.podName;
|
|
@@ -620,10 +1034,18 @@ export class RemoteK8sExecutionEnv {
|
|
|
620
1034
|
this.spaceLine("workspace_snapshot_failed", space, { stage, code: error.code, error: error.message });
|
|
621
1035
|
this.onWorkspaceSnapshot?.("failed");
|
|
622
1036
|
};
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
1037
|
+
let claimed;
|
|
1038
|
+
try {
|
|
1039
|
+
claimed = await this.fenceCall(() => space.fence.begin(), "write");
|
|
1040
|
+
}
|
|
1041
|
+
catch (e) {
|
|
1042
|
+
await this.endSpace(space);
|
|
1043
|
+
return failed("fence", new RemoteExecutionError("connect_failed", `workspace space commit state write failed: ${redactIncarnations(errorMessageOf(e))}`));
|
|
1044
|
+
}
|
|
1045
|
+
if (!claimed) {
|
|
1046
|
+
await this.endSpace(space);
|
|
1047
|
+
return failed("fence", new RemoteExecutionError("timeout", "the commit fence is lost (this run's commit row is past its deadline, or another run's live commit holds the space) — not saving (a next run may already have restored the previous snapshot)"));
|
|
1048
|
+
}
|
|
627
1049
|
try {
|
|
628
1050
|
let snap;
|
|
629
1051
|
try {
|
|
@@ -634,9 +1056,16 @@ export class RemoteK8sExecutionEnv {
|
|
|
634
1056
|
}
|
|
635
1057
|
if (!snap.ok)
|
|
636
1058
|
return failed("upload", snap.error);
|
|
637
|
-
|
|
1059
|
+
let deadline;
|
|
1060
|
+
try {
|
|
1061
|
+
deadline = await this.fenceCall(() => space.fence.publish(), "write");
|
|
1062
|
+
}
|
|
1063
|
+
catch (e) {
|
|
1064
|
+
return failed("fence", new RemoteExecutionError("connect_failed", `workspace space commit state write failed: ${redactIncarnations(errorMessageOf(e))}`));
|
|
1065
|
+
}
|
|
1066
|
+
const remaining = deadline === undefined ? 0 : deadline - Date.now();
|
|
638
1067
|
if (remaining <= 0)
|
|
639
|
-
return failed("
|
|
1068
|
+
return failed("fence", new RemoteExecutionError("timeout", "the commit lost its fence before publishing (its row is past its deadline, or another run judged it abandoned and took the space) — not publishing latest (a next run may already have restored the previous one)"));
|
|
640
1069
|
const moved = await this.writePointer(space, "latest", snap.value, remaining);
|
|
641
1070
|
if (!moved.ok)
|
|
642
1071
|
return failed("pointer", moved.error);
|
|
@@ -644,9 +1073,7 @@ export class RemoteK8sExecutionEnv {
|
|
|
644
1073
|
await this.pruneSpace(space, snap.value).catch((e) => this.spaceLine("workspace_snapshot_prune_failed", space, { detail: errorMessageOf(e) }));
|
|
645
1074
|
}
|
|
646
1075
|
finally {
|
|
647
|
-
|
|
648
|
-
if (!cleared.ok)
|
|
649
|
-
this.spaceLine("workspace_commit_marker_stuck", space, { error: cleared.error.message });
|
|
1076
|
+
await this.endSpace(space);
|
|
650
1077
|
}
|
|
651
1078
|
}
|
|
652
1079
|
execUrl(command) {
|
|
@@ -1208,6 +1635,48 @@ export class RemoteK8sExecutionEnv {
|
|
|
1208
1635
|
return dir ? this.resolve(dir) : undefined;
|
|
1209
1636
|
}
|
|
1210
1637
|
}
|
|
1638
|
+
export function createSpaceReclaimer(config, deps) {
|
|
1639
|
+
return {
|
|
1640
|
+
async reclaim(spaces) {
|
|
1641
|
+
const deadline = AbortSignal.timeout(dataTimeoutMsOf(config));
|
|
1642
|
+
for (const { sessionId, spaceKey } of spaces) {
|
|
1643
|
+
if (spaceKey === "")
|
|
1644
|
+
continue;
|
|
1645
|
+
const env = new RemoteK8sExecutionEnv({ ...config, sessionId }, deps);
|
|
1646
|
+
if (config.cache !== undefined) {
|
|
1647
|
+
const r = await env.reclaimSpaceCache(spaceKey, deadline).catch((e) => ({ ok: false, reason: "delete_failed", detail: errorMessageOf(e) }));
|
|
1648
|
+
if (!r.ok) {
|
|
1649
|
+
recordFailOpen("server.workspace-cache.reclaim-failed", `reason=${r.reason} label=${workspaceCacheClaimOf(spaceKey).label}`);
|
|
1650
|
+
config.logger?.warn("workspace_cache_reclaim_failed", {
|
|
1651
|
+
reason: r.reason,
|
|
1652
|
+
space: incarnationLogPrefix(spaceKey),
|
|
1653
|
+
label: workspaceCacheClaimOf(spaceKey).label,
|
|
1654
|
+
error: redactIncarnations(r.detail),
|
|
1655
|
+
effect: `the ended space's cache volume stays — delete it by label ${WORKSPACE_CACHE_SPACE_LABEL}=<label> (the deletion it belongs to went ahead)`,
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
if (config.s3Snapshot !== undefined) {
|
|
1660
|
+
const failures = await env.reclaimSpaceSnapshots(spaceKey, deadline).catch((e) => [{ lane: "space", stage: "delete", detail: errorMessageOf(e) }]);
|
|
1661
|
+
for (const f of failures) {
|
|
1662
|
+
recordFailOpen("server.workspace-space.reclaim-failed", `lane=${f.lane} stage=${f.stage}`);
|
|
1663
|
+
config.logger?.warn("workspace_space_reclaim_failed", {
|
|
1664
|
+
space: incarnationLogPrefix(spaceKey),
|
|
1665
|
+
sessionId,
|
|
1666
|
+
lane: f.lane,
|
|
1667
|
+
stage: f.stage,
|
|
1668
|
+
error: redactIncarnations(f.detail),
|
|
1669
|
+
effect: `the ended space's ${f.lane === "space" ? "workspace snapshots" : "park snapshots"} may stay in the bucket (unreadable through the API; the bucket's lifecycle rule is the fallback) — the deletion it belongs to went ahead`,
|
|
1670
|
+
});
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
},
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
export async function checkSandboxEgressPolicy(config, deps) {
|
|
1678
|
+
return new RemoteK8sExecutionEnv(config, deps).egressPolicyGaps();
|
|
1679
|
+
}
|
|
1211
1680
|
export function k8sExecutionEnvFactory(config, deps) {
|
|
1212
1681
|
return (ctx) => new RemoteK8sExecutionEnv({ ...config, sessionId: ctx.sessionId }, deps);
|
|
1213
1682
|
}
|