@paperclipai/plugin-kubernetes 0.1.0 → 2026.620.0-canary.4
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/LICENSE +21 -0
- package/dist/adapter-defaults.d.ts +29 -0
- package/dist/adapter-defaults.d.ts.map +1 -0
- package/dist/adapter-defaults.js +104 -0
- package/dist/adapter-defaults.js.map +1 -0
- package/dist/adapter-registry.d.ts +64 -0
- package/dist/adapter-registry.d.ts.map +1 -0
- package/dist/adapter-registry.js +25 -0
- package/dist/adapter-registry.js.map +1 -0
- package/dist/cilium-network-policy.d.ts +8 -0
- package/dist/cilium-network-policy.d.ts.map +1 -0
- package/dist/cilium-network-policy.js +57 -0
- package/dist/cilium-network-policy.js.map +1 -0
- package/dist/image-allowlist.d.ts +18 -0
- package/dist/image-allowlist.d.ts.map +1 -0
- package/dist/image-allowlist.js +39 -0
- package/dist/image-allowlist.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/job-orchestrator.d.ts +24 -0
- package/dist/job-orchestrator.d.ts.map +1 -0
- package/dist/job-orchestrator.js +90 -0
- package/dist/job-orchestrator.js.map +1 -0
- package/dist/kube-client.d.ts +15 -0
- package/dist/kube-client.d.ts.map +1 -0
- package/dist/kube-client.js +23 -0
- package/dist/kube-client.js.map +1 -0
- package/dist/lease-lifecycle.d.ts +62 -0
- package/dist/lease-lifecycle.d.ts.map +1 -0
- package/dist/lease-lifecycle.js +155 -0
- package/dist/lease-lifecycle.js.map +1 -0
- package/dist/manifest.d.ts +4 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +108 -0
- package/dist/manifest.js.map +1 -0
- package/dist/network-policy.d.ts +18 -0
- package/dist/network-policy.d.ts.map +1 -0
- package/dist/network-policy.js +113 -0
- package/dist/network-policy.js.map +1 -0
- package/dist/plugin.d.ts +3 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +687 -0
- package/dist/plugin.js.map +1 -0
- package/dist/pod-exec.d.ts +28 -0
- package/dist/pod-exec.d.ts.map +1 -0
- package/dist/pod-exec.js +164 -0
- package/dist/pod-exec.js.map +1 -0
- package/dist/pod-spec-builder.d.ts +25 -0
- package/dist/pod-spec-builder.d.ts.map +1 -0
- package/dist/pod-spec-builder.js +82 -0
- package/dist/pod-spec-builder.js.map +1 -0
- package/dist/sandbox-cr-builder.d.ts +40 -0
- package/dist/sandbox-cr-builder.d.ts.map +1 -0
- package/dist/sandbox-cr-builder.js +123 -0
- package/dist/sandbox-cr-builder.js.map +1 -0
- package/dist/sandbox-cr-orchestrator.d.ts +62 -0
- package/dist/sandbox-cr-orchestrator.d.ts.map +1 -0
- package/dist/sandbox-cr-orchestrator.js +248 -0
- package/dist/sandbox-cr-orchestrator.js.map +1 -0
- package/dist/sandbox-orchestrator.d.ts +39 -0
- package/dist/sandbox-orchestrator.d.ts.map +1 -0
- package/dist/sandbox-orchestrator.js +2 -0
- package/dist/sandbox-orchestrator.js.map +1 -0
- package/dist/secret-manager.d.ts +14 -0
- package/dist/secret-manager.d.ts.map +1 -0
- package/dist/secret-manager.js +39 -0
- package/dist/secret-manager.js.map +1 -0
- package/dist/tenant-orchestrator.d.ts +35 -0
- package/dist/tenant-orchestrator.d.ts.map +1 -0
- package/dist/tenant-orchestrator.js +263 -0
- package/dist/tenant-orchestrator.js.map +1 -0
- package/dist/types.d.ts +264 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +65 -0
- package/dist/types.js.map +1 -0
- package/dist/upload-interceptor.d.ts +44 -0
- package/dist/upload-interceptor.d.ts.map +1 -0
- package/dist/upload-interceptor.js +114 -0
- package/dist/upload-interceptor.js.map +1 -0
- package/dist/utils.d.ts +11 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +51 -0
- package/dist/utils.js.map +1 -0
- package/dist/worker.d.ts +3 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +5 -0
- package/dist/worker.js.map +1 -0
- package/package.json +4 -12
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { definePlugin } from "@paperclipai/plugin-sdk";
|
|
3
|
+
import { kubernetesProviderConfigSchema, } from "./types.js";
|
|
4
|
+
import { createKubeConfig, makeKubeClients } from "./kube-client.js";
|
|
5
|
+
import { getAdapterDefaults, buildAdapterEnv, resolveRunAdapterType } from "./adapter-defaults.js";
|
|
6
|
+
import { resolveImage } from "./image-allowlist.js";
|
|
7
|
+
import { buildJobManifest } from "./pod-spec-builder.js";
|
|
8
|
+
import { buildSandboxCrManifest } from "./sandbox-cr-builder.js";
|
|
9
|
+
import { ensureTenant } from "./tenant-orchestrator.js";
|
|
10
|
+
import { createPerRunSecret } from "./secret-manager.js";
|
|
11
|
+
import { FastUploadInterceptor } from "./upload-interceptor.js";
|
|
12
|
+
import { jobOrchestrator, JobTimeoutError } from "./job-orchestrator.js";
|
|
13
|
+
import { sandboxCrOrchestrator, SandboxCrTimeoutError, } from "./sandbox-cr-orchestrator.js";
|
|
14
|
+
import { execInPod, wrapCommandWithEnv } from "./pod-exec.js";
|
|
15
|
+
import { checkLeaseResumable, destroyLeaseResources } from "./lease-lifecycle.js";
|
|
16
|
+
import { deriveCompanySlug, deriveNamespaceName, newRunUlidDns, paperclipLabels, } from "./utils.js";
|
|
17
|
+
// The namespace paperclip-server itself runs in. Used when building
|
|
18
|
+
// NetworkPolicy manifests so the tenant namespace allows inbound traffic
|
|
19
|
+
// from the server pod.
|
|
20
|
+
const PAPERCLIP_SERVER_NAMESPACE = "paperclip";
|
|
21
|
+
// Name of the ServiceAccount created inside each tenant namespace by ensureTenant.
|
|
22
|
+
const TENANT_SERVICE_ACCOUNT = "paperclip-tenant-sa";
|
|
23
|
+
// Resource quota defaults applied to every tenant namespace (tunable via
|
|
24
|
+
// config in a future iteration).
|
|
25
|
+
const DEFAULT_RESOURCE_QUOTA = {
|
|
26
|
+
pods: "20",
|
|
27
|
+
requestsCpu: "10",
|
|
28
|
+
requestsMemory: "20Gi",
|
|
29
|
+
limitsCpu: "20",
|
|
30
|
+
limitsMemory: "40Gi",
|
|
31
|
+
};
|
|
32
|
+
function deriveTenantNamespace(config, companyId) {
|
|
33
|
+
// TODO: future versions could thread companyName through AcquireLeaseParams
|
|
34
|
+
// to get a friendlier slug (e.g. "acme-corp") instead of the UUID-derived one.
|
|
35
|
+
const slug = config.companySlug ?? deriveCompanySlug(companyId);
|
|
36
|
+
return deriveNamespaceName(config.namespacePrefix, slug);
|
|
37
|
+
}
|
|
38
|
+
function generateBootstrapToken() {
|
|
39
|
+
// TODO: tighten once the agent runtime shim (companion images PR) lands its
|
|
40
|
+
// callback auth scheme; paperclip-server's callback auth is out of scope for
|
|
41
|
+
// this plugin. For now this per-run random token is stored in the per-run
|
|
42
|
+
// Secret and read by the runtime image entrypoint for initial registration.
|
|
43
|
+
return randomBytes(32).toString("hex");
|
|
44
|
+
}
|
|
45
|
+
// One FastUploadInterceptor instance per active lease. Scoping per lease
|
|
46
|
+
// prevents `releaseLease` from wiping in-flight upload buffers belonging to
|
|
47
|
+
// other concurrent leases — a single shared singleton would do exactly that
|
|
48
|
+
// on `reset()`. The Map is keyed by `providerLeaseId`; entries are lazily
|
|
49
|
+
// created in `onEnvironmentExecute` and removed in `onEnvironmentReleaseLease`.
|
|
50
|
+
const uploadInterceptorsByLease = new Map();
|
|
51
|
+
function getOrCreateUploadInterceptor(leaseId) {
|
|
52
|
+
let interceptor = uploadInterceptorsByLease.get(leaseId);
|
|
53
|
+
if (!interceptor) {
|
|
54
|
+
interceptor = new FastUploadInterceptor();
|
|
55
|
+
uploadInterceptorsByLease.set(leaseId, interceptor);
|
|
56
|
+
}
|
|
57
|
+
return interceptor;
|
|
58
|
+
}
|
|
59
|
+
// In-memory cache of sandbox CR names we've already observed reaching the
|
|
60
|
+
// Ready condition during the current plugin-worker lifetime. The k8s
|
|
61
|
+
// sandbox-cr lifecycle means once a Sandbox pod is Running, subsequent
|
|
62
|
+
// execs into it don't need another readiness poll — saves one
|
|
63
|
+
// `getNamespacedCustomObject` round-trip per exec, which adds up across
|
|
64
|
+
// dozens of sequential exec calls in a typical adapter workflow.
|
|
65
|
+
// On worker restart this resets, which is fine: the first exec on each
|
|
66
|
+
// lease then re-confirms readiness from scratch.
|
|
67
|
+
const readySandboxesByLease = new Set();
|
|
68
|
+
// How long onEnvironmentResumeLease waits for an existing Sandbox pod to
|
|
69
|
+
// report Ready before declaring the lease non-resumable. Deliberately short:
|
|
70
|
+
// this is a liveness check on an already-provisioned pod, not a fresh
|
|
71
|
+
// provision — if the pod isn't (almost) up, falling back to acquireLease is
|
|
72
|
+
// faster and more reliable than waiting.
|
|
73
|
+
const RESUME_READY_TIMEOUT_MS = 30_000;
|
|
74
|
+
const RESUME_READY_POLL_MS = 1_000;
|
|
75
|
+
const plugin = definePlugin({
|
|
76
|
+
async setup(ctx) {
|
|
77
|
+
ctx.logger.info("Kubernetes sandbox provider plugin ready");
|
|
78
|
+
},
|
|
79
|
+
async onHealth() {
|
|
80
|
+
return { status: "ok", message: "Kubernetes sandbox provider plugin healthy" };
|
|
81
|
+
},
|
|
82
|
+
async onEnvironmentValidateConfig(params) {
|
|
83
|
+
const parsed = kubernetesProviderConfigSchema.safeParse(params.config);
|
|
84
|
+
if (!parsed.success) {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
errors: parsed.error.issues.map((i) => i.message),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const warnings = [];
|
|
91
|
+
const cfg = parsed.data;
|
|
92
|
+
const adapterDefaults = getAdapterDefaults(cfg.adapterType, cfg.adapters);
|
|
93
|
+
const totalFqdns = [...adapterDefaults.allowFqdns, ...cfg.egressAllowFqdns];
|
|
94
|
+
if (cfg.egressMode === "standard" && totalFqdns.length > 0) {
|
|
95
|
+
if (cfg.egressAllowCidrs.length === 0) {
|
|
96
|
+
warnings.push(`egressMode=standard cannot enforce FQDN-based egress rules (Kubernetes NetworkPolicy is CIDR-only). To keep the configured FQDNs reachable (${totalFqdns.join(", ")}) without operator intervention, the plugin will allow public IPv4 egress on TCP 80/443 with private/link-local/loopback/multicast ranges excluded. This is broader than exact FQDN allow-listing — switch egressMode to "cilium" (requires Cilium CNI) for precise enforcement, or set egressAllowCidrs explicitly to override the fallback.`);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
warnings.push(`egressMode=standard cannot enforce FQDN-based egress rules. The following FQDNs are reachable only via the operator-supplied egressAllowCidrs: ${totalFqdns.join(", ")}. Switch egressMode to "cilium" (requires Cilium CNI) for exact FQDN allow-listing.`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { ok: true, normalizedConfig: cfg, warnings: warnings.length > 0 ? warnings : undefined };
|
|
103
|
+
},
|
|
104
|
+
async onEnvironmentProbe(params) {
|
|
105
|
+
const parsed = kubernetesProviderConfigSchema.safeParse(params.config);
|
|
106
|
+
if (!parsed.success) {
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
summary: "Invalid Kubernetes provider configuration.",
|
|
110
|
+
metadata: {
|
|
111
|
+
errors: parsed.error.issues.map((i) => i.message),
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const config = parsed.data;
|
|
116
|
+
const namespace = deriveTenantNamespace(config, params.companyId);
|
|
117
|
+
try {
|
|
118
|
+
const kc = createKubeConfig({
|
|
119
|
+
inCluster: config.inCluster,
|
|
120
|
+
kubeconfig: config.kubeconfig,
|
|
121
|
+
});
|
|
122
|
+
const clients = makeKubeClients(kc);
|
|
123
|
+
// Reachability check: list pods in the tenant namespace. If the namespace
|
|
124
|
+
// doesn't exist yet this will throw a 404 which we treat as "reachable
|
|
125
|
+
// but namespace not provisioned" — still a successful probe.
|
|
126
|
+
try {
|
|
127
|
+
await clients.core.listNamespacedPod({ namespace });
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
const code = err.code
|
|
131
|
+
?? err.statusCode;
|
|
132
|
+
if (code !== 404)
|
|
133
|
+
throw err;
|
|
134
|
+
// 404 means namespace doesn't exist yet — cluster is reachable.
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
ok: true,
|
|
138
|
+
summary: `Kubernetes cluster reachable. Tenant namespace: ${namespace}.`,
|
|
139
|
+
metadata: { namespace, provider: "kubernetes" },
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
summary: "Kubernetes cluster probe failed.",
|
|
146
|
+
metadata: {
|
|
147
|
+
namespace,
|
|
148
|
+
provider: "kubernetes",
|
|
149
|
+
error: err instanceof Error ? err.message : String(err),
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
async onEnvironmentAcquireLease(
|
|
155
|
+
// `adapterType` is an optional per-run hint the server may pass once the
|
|
156
|
+
// SDK lease params grow that field (companion server-integration PR). The
|
|
157
|
+
// plugin works without it: absent means "use the environment's configured
|
|
158
|
+
// default adapter", so it stays compatible with the current SDK.
|
|
159
|
+
params) {
|
|
160
|
+
const config = kubernetesProviderConfigSchema.parse(params.config);
|
|
161
|
+
const namespace = deriveTenantNamespace(config, params.companyId);
|
|
162
|
+
// The adapter for THIS run is the agent's adapter (params.adapterType) when
|
|
163
|
+
// supplied, so one environment can serve mixed harnesses; otherwise fall back
|
|
164
|
+
// to the environment's configured default adapter. getAdapterDefaults validates
|
|
165
|
+
// it is a registered adapter (throws otherwise), so a curated-out adapter fails
|
|
166
|
+
// the lease as before.
|
|
167
|
+
const effectiveAdapterType = resolveRunAdapterType(params.adapterType, config.adapterType);
|
|
168
|
+
// Emit a runtime warning if FQDNs are configured but egressMode=standard
|
|
169
|
+
// cannot enforce them. Mirrors the validateConfig warning so operators see
|
|
170
|
+
// it in paperclip-server logs even if they missed the validation step.
|
|
171
|
+
const adapterDefaultsForWarn = getAdapterDefaults(effectiveAdapterType, config.adapters);
|
|
172
|
+
const totalFqdnsForWarn = [...adapterDefaultsForWarn.allowFqdns, ...config.egressAllowFqdns];
|
|
173
|
+
if (config.egressMode === "standard" && totalFqdnsForWarn.length > 0) {
|
|
174
|
+
if (config.egressAllowCidrs.length === 0) {
|
|
175
|
+
console.warn(`[plugin-kubernetes] egressMode=standard cannot enforce FQDN-based egress rules; falling back to public-IPv4 (TCP 80/443) with private/link-local ranges excluded so the configured FQDNs (${totalFqdnsForWarn.join(", ")}) remain reachable. Switch egressMode to "cilium" for exact FQDN allow-listing.`);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
console.warn(`[plugin-kubernetes] egressMode=standard cannot enforce FQDN-based egress rules. The following FQDNs are reachable only via operator-supplied egressAllowCidrs: ${totalFqdnsForWarn.join(", ")}. Switch egressMode to "cilium" for exact FQDN allow-listing.`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const kc = createKubeConfig({
|
|
182
|
+
inCluster: config.inCluster,
|
|
183
|
+
kubeconfig: config.kubeconfig,
|
|
184
|
+
});
|
|
185
|
+
const clients = makeKubeClients(kc);
|
|
186
|
+
// Ensure the tenant namespace and all its RBAC / network policy resources
|
|
187
|
+
// exist before we try to create the Job.
|
|
188
|
+
const adapterDefaults = getAdapterDefaults(effectiveAdapterType, config.adapters);
|
|
189
|
+
await ensureTenant(clients, {
|
|
190
|
+
namespace,
|
|
191
|
+
companyId: params.companyId,
|
|
192
|
+
paperclipServerNamespace: PAPERCLIP_SERVER_NAMESPACE,
|
|
193
|
+
serviceAccountAnnotations: config.serviceAccountAnnotations,
|
|
194
|
+
egressMode: config.egressMode,
|
|
195
|
+
egressAllowFqdns: [...adapterDefaults.allowFqdns, ...config.egressAllowFqdns],
|
|
196
|
+
egressAllowCidrs: config.egressAllowCidrs,
|
|
197
|
+
resourceQuota: DEFAULT_RESOURCE_QUOTA,
|
|
198
|
+
});
|
|
199
|
+
const jobName = `pc-${newRunUlidDns()}`;
|
|
200
|
+
const secretName = `${jobName}-env`;
|
|
201
|
+
// TODO: use params.runId as stand-in for agentId in labels; future
|
|
202
|
+
// versions will have a dedicated agentId on AcquireLeaseParams.
|
|
203
|
+
const labels = paperclipLabels({
|
|
204
|
+
runId: params.runId,
|
|
205
|
+
agentId: params.runId,
|
|
206
|
+
companyId: params.companyId,
|
|
207
|
+
adapterType: effectiveAdapterType,
|
|
208
|
+
});
|
|
209
|
+
const image = resolveImage({ imageOverride: null }, adapterDefaults, { imageAllowList: config.imageAllowList, imageRegistry: config.imageRegistry });
|
|
210
|
+
// Pick the orchestrator and build the appropriate manifest based on backend.
|
|
211
|
+
const isSandboxCrBackend = config.backend === "sandbox-cr";
|
|
212
|
+
const orchestrator = isSandboxCrBackend ? sandboxCrOrchestrator : jobOrchestrator;
|
|
213
|
+
const manifest = isSandboxCrBackend
|
|
214
|
+
? buildSandboxCrManifest({
|
|
215
|
+
namespace,
|
|
216
|
+
sandboxName: jobName,
|
|
217
|
+
adapterType: effectiveAdapterType,
|
|
218
|
+
image,
|
|
219
|
+
envSecretName: secretName,
|
|
220
|
+
serviceAccountName: TENANT_SERVICE_ACCOUNT,
|
|
221
|
+
labels,
|
|
222
|
+
resources: config.defaultResources ?? {},
|
|
223
|
+
runtimeClassName: config.runtimeClassName,
|
|
224
|
+
imagePullSecrets: config.imagePullSecrets,
|
|
225
|
+
})
|
|
226
|
+
: buildJobManifest({
|
|
227
|
+
namespace,
|
|
228
|
+
jobName,
|
|
229
|
+
adapterType: effectiveAdapterType,
|
|
230
|
+
image,
|
|
231
|
+
envSecretName: secretName,
|
|
232
|
+
serviceAccountName: TENANT_SERVICE_ACCOUNT,
|
|
233
|
+
labels,
|
|
234
|
+
resources: config.defaultResources ?? {},
|
|
235
|
+
runtimeClassName: config.runtimeClassName,
|
|
236
|
+
activeDeadlineSec: config.podActivityDeadlineSec,
|
|
237
|
+
ttlSecondsAfterFinished: config.jobTtlSecondsAfterFinished,
|
|
238
|
+
imagePullSecrets: config.imagePullSecrets,
|
|
239
|
+
});
|
|
240
|
+
const { uid: ownerUid } = await orchestrator.claim(clients, namespace, manifest);
|
|
241
|
+
// defaultEnv (non-secret base, e.g. the inference base URL) is layered first;
|
|
242
|
+
// the process-env secrets named by envKeys override it.
|
|
243
|
+
const adapterEnv = buildAdapterEnv(adapterDefaults);
|
|
244
|
+
const bootstrapToken = generateBootstrapToken();
|
|
245
|
+
// Secret ownerRef: for job backend, the Job owns the Secret (cascade delete).
|
|
246
|
+
// For sandbox-cr backend, the Sandbox CR owns the Secret.
|
|
247
|
+
// NOTE: For sandbox-cr, if the Secret outlives the Sandbox due to a cluster
|
|
248
|
+
// quirk, the release() call will still clean it up via namespace GC or
|
|
249
|
+
// explicit delete in a future iteration.
|
|
250
|
+
await createPerRunSecret(clients, {
|
|
251
|
+
namespace,
|
|
252
|
+
secretName,
|
|
253
|
+
runId: params.runId,
|
|
254
|
+
ownerKind: isSandboxCrBackend ? "Sandbox" : "Job",
|
|
255
|
+
ownerApiVersion: isSandboxCrBackend ? "agents.x-k8s.io/v1alpha1" : "batch/v1",
|
|
256
|
+
ownerName: jobName,
|
|
257
|
+
ownerUid,
|
|
258
|
+
bootstrapToken,
|
|
259
|
+
adapterEnv,
|
|
260
|
+
});
|
|
261
|
+
const podName = await orchestrator.findPod(clients, namespace, jobName);
|
|
262
|
+
const leaseMetadata = {
|
|
263
|
+
namespace,
|
|
264
|
+
jobName,
|
|
265
|
+
podName,
|
|
266
|
+
secretName,
|
|
267
|
+
phase: "Pending",
|
|
268
|
+
backend: config.backend,
|
|
269
|
+
};
|
|
270
|
+
return {
|
|
271
|
+
providerLeaseId: jobName,
|
|
272
|
+
metadata: leaseMetadata,
|
|
273
|
+
};
|
|
274
|
+
},
|
|
275
|
+
async onEnvironmentResumeLease(params) {
|
|
276
|
+
const config = kubernetesProviderConfigSchema.parse(params.config);
|
|
277
|
+
const namespace = typeof params.leaseMetadata?.namespace === "string"
|
|
278
|
+
? params.leaseMetadata.namespace
|
|
279
|
+
: deriveTenantNamespace(config, params.companyId);
|
|
280
|
+
const leaseBackend = typeof params.leaseMetadata?.backend === "string"
|
|
281
|
+
? params.leaseMetadata.backend
|
|
282
|
+
: config.backend;
|
|
283
|
+
// acquireLease names the per-run Secret `${jobName}-env` and uses jobName
|
|
284
|
+
// as the providerLeaseId, so the suffix fallback reconstructs it exactly.
|
|
285
|
+
const secretName = typeof params.leaseMetadata?.secretName === "string"
|
|
286
|
+
? params.leaseMetadata.secretName
|
|
287
|
+
: `${params.providerLeaseId}-env`;
|
|
288
|
+
const kc = createKubeConfig({
|
|
289
|
+
inCluster: config.inCluster,
|
|
290
|
+
kubeconfig: config.kubeconfig,
|
|
291
|
+
});
|
|
292
|
+
const clients = makeKubeClients(kc);
|
|
293
|
+
const check = await checkLeaseResumable(clients, {
|
|
294
|
+
namespace,
|
|
295
|
+
name: params.providerLeaseId,
|
|
296
|
+
backend: leaseBackend,
|
|
297
|
+
readyTimeoutMs: RESUME_READY_TIMEOUT_MS,
|
|
298
|
+
pollMs: RESUME_READY_POLL_MS,
|
|
299
|
+
});
|
|
300
|
+
if (!check.resumable) {
|
|
301
|
+
// Kubernetes pods are NOT restartable the way Daytona sandboxes are: a
|
|
302
|
+
// stopped Daytona sandbox can be started again by ID, but a k8s pod that
|
|
303
|
+
// is gone or terminally failed can never be revived in place. Gone = not
|
|
304
|
+
// resumable, by design. Returning providerLeaseId: null tells the server
|
|
305
|
+
// the lease expired so it falls back to a fresh acquireLease.
|
|
306
|
+
return {
|
|
307
|
+
providerLeaseId: null,
|
|
308
|
+
metadata: { expired: true, reason: check.reason },
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
// A resumed lease starts with clean per-lease state: drop any stale upload
|
|
312
|
+
// interceptor buffers a previous run on this lease may have left behind.
|
|
313
|
+
uploadInterceptorsByLease.delete(params.providerLeaseId);
|
|
314
|
+
if (leaseBackend === "sandbox-cr") {
|
|
315
|
+
// We just observed the Sandbox pod Ready, so the first exec on the
|
|
316
|
+
// resumed lease can skip its readiness poll.
|
|
317
|
+
readySandboxesByLease.add(params.providerLeaseId);
|
|
318
|
+
}
|
|
319
|
+
const leaseMetadata = {
|
|
320
|
+
namespace,
|
|
321
|
+
jobName: params.providerLeaseId,
|
|
322
|
+
podName: check.podName,
|
|
323
|
+
secretName,
|
|
324
|
+
phase: check.phase,
|
|
325
|
+
backend: leaseBackend,
|
|
326
|
+
};
|
|
327
|
+
return {
|
|
328
|
+
providerLeaseId: params.providerLeaseId,
|
|
329
|
+
metadata: {
|
|
330
|
+
...leaseMetadata,
|
|
331
|
+
resumedLease: true,
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
},
|
|
335
|
+
async onEnvironmentRealizeWorkspace(params) {
|
|
336
|
+
// The agent pod already has /workspace mounted as an emptyDir at pod
|
|
337
|
+
// scheduling time (see pod-spec-builder). Nothing to provision here —
|
|
338
|
+
// we just hand back the cwd. Honor a caller-supplied remotePath if set.
|
|
339
|
+
const cwd = params.workspace.remotePath && params.workspace.remotePath.trim().length > 0
|
|
340
|
+
? params.workspace.remotePath.trim()
|
|
341
|
+
: "/workspace";
|
|
342
|
+
return {
|
|
343
|
+
cwd,
|
|
344
|
+
metadata: {
|
|
345
|
+
provider: "kubernetes",
|
|
346
|
+
remoteCwd: cwd,
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
},
|
|
350
|
+
async onEnvironmentReleaseLease(params) {
|
|
351
|
+
if (!params.providerLeaseId)
|
|
352
|
+
return;
|
|
353
|
+
const config = kubernetesProviderConfigSchema.parse(params.config);
|
|
354
|
+
const namespace = typeof params.leaseMetadata?.namespace === "string"
|
|
355
|
+
? params.leaseMetadata.namespace
|
|
356
|
+
: deriveTenantNamespace(config, params.companyId);
|
|
357
|
+
const kc = createKubeConfig({
|
|
358
|
+
inCluster: config.inCluster,
|
|
359
|
+
kubeconfig: config.kubeconfig,
|
|
360
|
+
});
|
|
361
|
+
const clients = makeKubeClients(kc);
|
|
362
|
+
const leaseBackend = typeof params.leaseMetadata?.backend === "string"
|
|
363
|
+
? params.leaseMetadata.backend
|
|
364
|
+
: config.backend;
|
|
365
|
+
const releaseOrchestrator = leaseBackend === "sandbox-cr" ? sandboxCrOrchestrator : jobOrchestrator;
|
|
366
|
+
// Drop the FastUploadInterceptor associated with THIS lease (only).
|
|
367
|
+
// Each lease has its own interceptor instance via uploadInterceptorsByLease,
|
|
368
|
+
// so unrelated concurrent leases keep their in-flight buffers intact.
|
|
369
|
+
uploadInterceptorsByLease.delete(params.providerLeaseId);
|
|
370
|
+
readySandboxesByLease.delete(params.providerLeaseId);
|
|
371
|
+
try {
|
|
372
|
+
await releaseOrchestrator.release(clients, namespace, params.providerLeaseId);
|
|
373
|
+
}
|
|
374
|
+
catch (err) {
|
|
375
|
+
// If the resource is already gone (404), that's fine.
|
|
376
|
+
const code = err.code
|
|
377
|
+
?? err.statusCode;
|
|
378
|
+
if (code !== 404)
|
|
379
|
+
throw err;
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
async onEnvironmentDestroyLease(params) {
|
|
383
|
+
if (!params.providerLeaseId)
|
|
384
|
+
return;
|
|
385
|
+
const config = kubernetesProviderConfigSchema.parse(params.config);
|
|
386
|
+
const namespace = typeof params.leaseMetadata?.namespace === "string"
|
|
387
|
+
? params.leaseMetadata.namespace
|
|
388
|
+
: deriveTenantNamespace(config, params.companyId);
|
|
389
|
+
const leaseBackend = typeof params.leaseMetadata?.backend === "string"
|
|
390
|
+
? params.leaseMetadata.backend
|
|
391
|
+
: config.backend;
|
|
392
|
+
const secretName = typeof params.leaseMetadata?.secretName === "string"
|
|
393
|
+
? params.leaseMetadata.secretName
|
|
394
|
+
: `${params.providerLeaseId}-env`;
|
|
395
|
+
const podName = typeof params.leaseMetadata?.podName === "string" &&
|
|
396
|
+
params.leaseMetadata.podName.length > 0
|
|
397
|
+
? params.leaseMetadata.podName
|
|
398
|
+
: null;
|
|
399
|
+
// Clear per-lease in-memory state up front, regardless of what the
|
|
400
|
+
// cluster says — the lease is dead either way.
|
|
401
|
+
uploadInterceptorsByLease.delete(params.providerLeaseId);
|
|
402
|
+
readySandboxesByLease.delete(params.providerLeaseId);
|
|
403
|
+
const kc = createKubeConfig({
|
|
404
|
+
inCluster: config.inCluster,
|
|
405
|
+
kubeconfig: config.kubeconfig,
|
|
406
|
+
});
|
|
407
|
+
const clients = makeKubeClients(kc);
|
|
408
|
+
// Forcibly delete everything acquireLease created (Sandbox CR / Job, pod,
|
|
409
|
+
// per-run Secret). 404s are success — destroy must be idempotent.
|
|
410
|
+
await destroyLeaseResources(clients, {
|
|
411
|
+
namespace,
|
|
412
|
+
name: params.providerLeaseId,
|
|
413
|
+
backend: leaseBackend,
|
|
414
|
+
podName,
|
|
415
|
+
secretName,
|
|
416
|
+
});
|
|
417
|
+
},
|
|
418
|
+
async onEnvironmentExecute(params) {
|
|
419
|
+
const { lease, timeoutMs } = params;
|
|
420
|
+
if (!lease.providerLeaseId) {
|
|
421
|
+
return {
|
|
422
|
+
exitCode: 1,
|
|
423
|
+
timedOut: false,
|
|
424
|
+
stdout: "",
|
|
425
|
+
stderr: "No provider lease ID available for execution.",
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
const config = kubernetesProviderConfigSchema.parse(params.config);
|
|
429
|
+
const namespace = typeof lease.metadata?.namespace === "string"
|
|
430
|
+
? lease.metadata.namespace
|
|
431
|
+
: deriveTenantNamespace(config, params.companyId);
|
|
432
|
+
// Determine which backend this lease was created with.
|
|
433
|
+
const leaseBackend = typeof lease.metadata?.backend === "string"
|
|
434
|
+
? lease.metadata.backend
|
|
435
|
+
: config.backend;
|
|
436
|
+
const kc = createKubeConfig({
|
|
437
|
+
inCluster: config.inCluster,
|
|
438
|
+
kubeconfig: config.kubeconfig,
|
|
439
|
+
});
|
|
440
|
+
const clients = makeKubeClients(kc);
|
|
441
|
+
const effectiveTimeoutMs = typeof timeoutMs === "number" && timeoutMs > 0
|
|
442
|
+
? timeoutMs
|
|
443
|
+
: config.podActivityDeadlineSec * 1000;
|
|
444
|
+
if (leaseBackend === "sandbox-cr") {
|
|
445
|
+
// ── Sandbox-CR backend ──────────────────────────────────────────────────
|
|
446
|
+
// 1. Ensure the Sandbox pod is Ready (wait only on first exec for this lease).
|
|
447
|
+
// 2. Exec the command into the running pod.
|
|
448
|
+
// 3. Return exec result directly (no log scraping needed).
|
|
449
|
+
let podName = typeof lease.metadata?.podName === "string" && lease.metadata.podName
|
|
450
|
+
? lease.metadata.podName
|
|
451
|
+
: null;
|
|
452
|
+
// Skip the readiness poll if we've already observed this Sandbox CR
|
|
453
|
+
// reaching Ready during this worker's lifetime. See readySandboxesByLease
|
|
454
|
+
// declaration for rationale.
|
|
455
|
+
const podAlreadyKnownReady = readySandboxesByLease.has(lease.providerLeaseId);
|
|
456
|
+
// The caller's timeout is a budget for the WHOLE execute call: readiness
|
|
457
|
+
// wait + exec must share it, or the first exec on a fresh lease could
|
|
458
|
+
// block for up to twice the requested timeout.
|
|
459
|
+
const executeStartedAt = Date.now();
|
|
460
|
+
if (!podAlreadyKnownReady) {
|
|
461
|
+
try {
|
|
462
|
+
await sandboxCrOrchestrator.waitForCompletion(clients, namespace, lease.providerLeaseId, { timeoutMs: effectiveTimeoutMs, pollMs: 2000 });
|
|
463
|
+
readySandboxesByLease.add(lease.providerLeaseId);
|
|
464
|
+
}
|
|
465
|
+
catch (err) {
|
|
466
|
+
if (err instanceof SandboxCrTimeoutError) {
|
|
467
|
+
return {
|
|
468
|
+
exitCode: null,
|
|
469
|
+
timedOut: true,
|
|
470
|
+
stdout: "",
|
|
471
|
+
stderr: `Sandbox pod did not become Ready within ${effectiveTimeoutMs}ms`,
|
|
472
|
+
metadata: {
|
|
473
|
+
provider: "kubernetes",
|
|
474
|
+
backend: "sandbox-cr",
|
|
475
|
+
namespace,
|
|
476
|
+
sandboxName: lease.providerLeaseId,
|
|
477
|
+
},
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
throw err;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
// Resolve pod name (may now be populated in Sandbox status).
|
|
484
|
+
if (!podName) {
|
|
485
|
+
podName = await sandboxCrOrchestrator.findPod(clients, namespace, lease.providerLeaseId);
|
|
486
|
+
}
|
|
487
|
+
if (!podName) {
|
|
488
|
+
return {
|
|
489
|
+
exitCode: 1,
|
|
490
|
+
timedOut: false,
|
|
491
|
+
stdout: "",
|
|
492
|
+
stderr: "Sandbox pod is Ready but podName could not be resolved.",
|
|
493
|
+
metadata: {
|
|
494
|
+
provider: "kubernetes",
|
|
495
|
+
backend: "sandbox-cr",
|
|
496
|
+
namespace,
|
|
497
|
+
sandboxName: lease.providerLeaseId,
|
|
498
|
+
},
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
// Build the command to exec. The adapter passes shell invocations as
|
|
502
|
+
// `command: "sh", args: ["-c", "<script>"]` — must combine both, NOT
|
|
503
|
+
// drop args. If only command is present (no args), wrap in a login shell.
|
|
504
|
+
const command = typeof params.command === "string" ? params.command.trim() : "";
|
|
505
|
+
const args = Array.isArray(params.args) ? params.args : [];
|
|
506
|
+
// Fast-upload interceptor: short-circuit the chunked-shell file transfer
|
|
507
|
+
// protocol (adapter-utils writeFile) so an N-chunk upload becomes 1 exec
|
|
508
|
+
// instead of N+2. Falls back transparently when patterns don't match.
|
|
509
|
+
// See upload-interceptor.ts.
|
|
510
|
+
const shellScript = command === "sh" && args[0] === "-c" && typeof args[1] === "string"
|
|
511
|
+
? args[1]
|
|
512
|
+
: null;
|
|
513
|
+
if (shellScript) {
|
|
514
|
+
const decision = getOrCreateUploadInterceptor(lease.providerLeaseId).decide(shellScript);
|
|
515
|
+
if (decision.action === "ack") {
|
|
516
|
+
return {
|
|
517
|
+
exitCode: 0,
|
|
518
|
+
timedOut: false,
|
|
519
|
+
stdout: "",
|
|
520
|
+
stderr: "",
|
|
521
|
+
metadata: {
|
|
522
|
+
provider: "kubernetes",
|
|
523
|
+
backend: "sandbox-cr",
|
|
524
|
+
namespace,
|
|
525
|
+
sandboxName: lease.providerLeaseId,
|
|
526
|
+
podName,
|
|
527
|
+
fastUpload: "ack",
|
|
528
|
+
},
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
if (decision.action === "flush") {
|
|
532
|
+
// Single exec: `head -c <N> | base64 -d > '<TARGET>'` with stdin =
|
|
533
|
+
// base64 ASCII. `head -c` reads EXACTLY N bytes and exits, so we
|
|
534
|
+
// don't depend on WebSocket-driven EOF detection on stdin (which is
|
|
535
|
+
// racy against the `base64 -d` exit timing in @kubernetes/client-node
|
|
536
|
+
// v0.21.0 — see pod-exec.ts). All bytes are sent through the
|
|
537
|
+
// WebSocket data channel; size is unbounded by ARG_MAX.
|
|
538
|
+
const base64Body = decision.flush.payload.toString("base64");
|
|
539
|
+
const dir = decision.flush.targetPath.substring(0, decision.flush.targetPath.lastIndexOf("/"));
|
|
540
|
+
const script = `mkdir -p '${dir}' && ` +
|
|
541
|
+
`head -c ${base64Body.length} | base64 -d > '${decision.flush.targetPath}'`;
|
|
542
|
+
// The flush shares the caller's single execute budget (same contract
|
|
543
|
+
// as the normal exec path below) and surfaces watchdog/WebSocket
|
|
544
|
+
// failures as a timed-out result instead of an uncaught throw.
|
|
545
|
+
const flushTimeoutMs = Math.max(5_000, effectiveTimeoutMs - (Date.now() - executeStartedAt));
|
|
546
|
+
let flushResult;
|
|
547
|
+
try {
|
|
548
|
+
flushResult = await execInPod(kc, namespace, podName, "agent", ["/bin/sh", "-c", script], base64Body, flushTimeoutMs);
|
|
549
|
+
}
|
|
550
|
+
catch (err) {
|
|
551
|
+
return {
|
|
552
|
+
exitCode: null,
|
|
553
|
+
timedOut: true,
|
|
554
|
+
stdout: "",
|
|
555
|
+
stderr: `fast-upload flush failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
556
|
+
metadata: {
|
|
557
|
+
provider: "kubernetes",
|
|
558
|
+
backend: "sandbox-cr",
|
|
559
|
+
namespace,
|
|
560
|
+
sandboxName: lease.providerLeaseId,
|
|
561
|
+
podName,
|
|
562
|
+
fastUpload: "flush",
|
|
563
|
+
},
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
return {
|
|
567
|
+
exitCode: flushResult.exitCode,
|
|
568
|
+
timedOut: false,
|
|
569
|
+
stdout: flushResult.stdout,
|
|
570
|
+
stderr: flushResult.stderr,
|
|
571
|
+
metadata: {
|
|
572
|
+
provider: "kubernetes",
|
|
573
|
+
backend: "sandbox-cr",
|
|
574
|
+
namespace,
|
|
575
|
+
sandboxName: lease.providerLeaseId,
|
|
576
|
+
podName,
|
|
577
|
+
fastUpload: "flush",
|
|
578
|
+
uploadedBytes: decision.flush.payload.length,
|
|
579
|
+
},
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
// decision.action === "passthrough" — fall through to normal exec
|
|
583
|
+
}
|
|
584
|
+
const baseExecCommand = command.length > 0 && args.length > 0
|
|
585
|
+
? [command, ...args]
|
|
586
|
+
: command.length > 0
|
|
587
|
+
? ["/bin/sh", "-lc", command]
|
|
588
|
+
: ["/bin/sh", "-l"];
|
|
589
|
+
// Apply the caller-provided run env (params.env) to the in-pod process. Without
|
|
590
|
+
// this the adapter's runtime env (e.g. XDG_CONFIG_HOME pointing at the shipped
|
|
591
|
+
// OpenCode config, plus helper settings like small_model/provider routing) never
|
|
592
|
+
// reaches the harness, which falls back to its in-image HOME config -> wrong or
|
|
593
|
+
// partial behaviour.
|
|
594
|
+
const execCommand = wrapCommandWithEnv(baseExecCommand, params.env);
|
|
595
|
+
// Remaining share of the caller's budget after the readiness wait (floor
|
|
596
|
+
// of 5s so an exec attempt is still made when readiness consumed most of
|
|
597
|
+
// it; the watchdog then bounds it tightly).
|
|
598
|
+
const remainingTimeoutMs = Math.max(5_000, effectiveTimeoutMs - (Date.now() - executeStartedAt));
|
|
599
|
+
let execResult;
|
|
600
|
+
try {
|
|
601
|
+
execResult = await execInPod(kc, namespace, podName, "agent", execCommand, typeof params.stdin === "string" ? params.stdin : undefined, remainingTimeoutMs);
|
|
602
|
+
}
|
|
603
|
+
catch (err) {
|
|
604
|
+
// Watchdog-fired or WebSocket-setup error. Surface as a timeout so
|
|
605
|
+
// the caller can retry instead of hanging forever.
|
|
606
|
+
return {
|
|
607
|
+
exitCode: null,
|
|
608
|
+
timedOut: true,
|
|
609
|
+
stdout: "",
|
|
610
|
+
stderr: err instanceof Error ? err.message : String(err),
|
|
611
|
+
metadata: {
|
|
612
|
+
provider: "kubernetes",
|
|
613
|
+
backend: "sandbox-cr",
|
|
614
|
+
namespace,
|
|
615
|
+
sandboxName: lease.providerLeaseId,
|
|
616
|
+
podName,
|
|
617
|
+
},
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
return {
|
|
621
|
+
exitCode: execResult.exitCode,
|
|
622
|
+
timedOut: false,
|
|
623
|
+
stdout: execResult.stdout,
|
|
624
|
+
stderr: execResult.stderr,
|
|
625
|
+
metadata: {
|
|
626
|
+
provider: "kubernetes",
|
|
627
|
+
backend: "sandbox-cr",
|
|
628
|
+
namespace,
|
|
629
|
+
sandboxName: lease.providerLeaseId,
|
|
630
|
+
podName,
|
|
631
|
+
},
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
else {
|
|
635
|
+
// ── Job backend (legacy / stable fallback) ──────────────────────────────
|
|
636
|
+
// The container entrypoint is baked into the Job spec (Tini + paperclip-agent-shim).
|
|
637
|
+
// We do NOT re-exec command/args — instead we wait for the Job to finish
|
|
638
|
+
// and collect its logs.
|
|
639
|
+
//
|
|
640
|
+
// params.command / params.args / params.stdin are intentionally ignored.
|
|
641
|
+
let status;
|
|
642
|
+
let timedOut = false;
|
|
643
|
+
try {
|
|
644
|
+
status = await jobOrchestrator.waitForCompletion(clients, namespace, lease.providerLeaseId, { timeoutMs: effectiveTimeoutMs, pollMs: 2000 });
|
|
645
|
+
}
|
|
646
|
+
catch (err) {
|
|
647
|
+
if (err instanceof JobTimeoutError) {
|
|
648
|
+
timedOut = true;
|
|
649
|
+
status = null;
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
throw err;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
// Collect logs from the pod.
|
|
656
|
+
const podName = typeof lease.metadata?.podName === "string"
|
|
657
|
+
? lease.metadata.podName
|
|
658
|
+
: await jobOrchestrator.findPod(clients, namespace, lease.providerLeaseId);
|
|
659
|
+
const stdoutChunks = [];
|
|
660
|
+
const stderrChunks = [];
|
|
661
|
+
if (podName) {
|
|
662
|
+
await jobOrchestrator.streamLogs(clients, namespace, podName, async (stream, text) => {
|
|
663
|
+
if (stream === "stdout")
|
|
664
|
+
stdoutChunks.push(text);
|
|
665
|
+
else
|
|
666
|
+
stderrChunks.push(text);
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
return {
|
|
670
|
+
exitCode: timedOut ? null : status?.phase === "Succeeded" ? 0 : 1,
|
|
671
|
+
timedOut,
|
|
672
|
+
stdout: stdoutChunks.join(""),
|
|
673
|
+
stderr: stderrChunks.join(""),
|
|
674
|
+
metadata: {
|
|
675
|
+
provider: "kubernetes",
|
|
676
|
+
backend: "job",
|
|
677
|
+
namespace,
|
|
678
|
+
jobName: lease.providerLeaseId,
|
|
679
|
+
podName: podName ?? null,
|
|
680
|
+
phase: status?.phase ?? null,
|
|
681
|
+
},
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
},
|
|
685
|
+
});
|
|
686
|
+
export default plugin;
|
|
687
|
+
//# sourceMappingURL=plugin.js.map
|