@wardby/cli 0.5.3 → 0.6.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/.env.example +33 -0
- package/README.md +25 -14
- package/dist/claude-coding-worker/context.d.ts +22 -0
- package/dist/claude-coding-worker/context.js +92 -0
- package/dist/claude-coding-worker/driver.d.ts +7 -1
- package/dist/claude-coding-worker/driver.js +38 -5
- package/dist/claude-coding-worker/sdk.js +13 -4
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +5 -2
- package/dist/cli-native-mode.d.ts +9 -0
- package/dist/cli-native-mode.js +20 -0
- package/dist/cli.d.ts +3 -1
- package/dist/cli.js +141 -5
- package/dist/coding/claude-context.d.ts +45 -0
- package/dist/coding/claude-context.js +434 -0
- package/dist/coding/profile.d.ts +12 -0
- package/dist/coding/profile.js +6 -0
- package/dist/coding/protocol.d.ts +104 -0
- package/dist/coding/protocol.js +87 -3
- package/dist/coding-worker/artifact.d.ts +8 -0
- package/dist/coding-worker/artifact.js +2 -2
- package/dist/coding-worker/driver.js +2 -0
- package/dist/coding-worker/sdk.d.ts +6 -0
- package/dist/coding-worker/sdk.js +1 -0
- package/dist/coding-worker/skills.d.ts +23 -0
- package/dist/coding-worker/skills.js +117 -0
- package/dist/coding-worker/types.d.ts +2 -0
- package/dist/config/github-allowed-accounts.d.ts +15 -0
- package/dist/config/github-allowed-accounts.js +36 -0
- package/dist/config/low-power.d.ts +12 -0
- package/dist/config/low-power.js +18 -0
- package/dist/config/mobile.d.ts +12 -0
- package/dist/config/mobile.js +18 -0
- package/dist/config/providers.d.ts +53 -0
- package/dist/config/providers.js +92 -6
- package/dist/core/db.d.ts +16 -6
- package/dist/core/db.js +12 -10
- package/dist/core/dispatch.d.ts +11 -0
- package/dist/core/dispatch.js +36 -1
- package/dist/core/engine-native.js +9 -5
- package/dist/core/host-events.d.ts +49 -3
- package/dist/core/host-events.js +289 -15
- package/dist/core/issue-bridge.js +42 -2
- package/dist/core/issue-events.js +12 -1
- package/dist/core/issue-status.js +14 -3
- package/dist/core/merge-order-check.d.ts +129 -0
- package/dist/core/merge-order-check.js +336 -0
- package/dist/core/notification-dispatcher.d.ts +70 -0
- package/dist/core/notification-dispatcher.js +368 -0
- package/dist/core/notification-templates.d.ts +25 -0
- package/dist/core/notification-templates.js +105 -0
- package/dist/core/notifications.d.ts +10 -0
- package/dist/core/notifications.js +38 -0
- package/dist/core/pg-auth.d.ts +22 -0
- package/dist/core/pg-auth.js +152 -0
- package/dist/core/power-composition.d.ts +16 -0
- package/dist/core/power-composition.js +37 -0
- package/dist/core/power-idle.d.ts +14 -0
- package/dist/core/power-idle.js +30 -0
- package/dist/core/power.d.ts +71 -0
- package/dist/core/power.js +202 -0
- package/dist/core/reconciler.d.ts +4 -2
- package/dist/core/reconciler.js +29 -5
- package/dist/core/related-pull-requests.d.ts +48 -4
- package/dist/core/related-pull-requests.js +113 -11
- package/dist/core/repo-access.d.ts +15 -0
- package/dist/core/repo-access.js +66 -0
- package/dist/core/review-fix.d.ts +37 -0
- package/dist/core/review-fix.js +207 -6
- package/dist/core/review-host-tools.d.ts +20 -0
- package/dist/core/review-host-tools.js +58 -2
- package/dist/core/runner.d.ts +336 -3
- package/dist/core/runner.js +894 -317
- package/dist/core/secret-broker-config.d.ts +103 -0
- package/dist/core/secret-broker-config.js +112 -0
- package/dist/core/secrets.d.ts +39 -4
- package/dist/core/secrets.js +148 -40
- package/dist/core/workflow-event-recorder.d.ts +14 -0
- package/dist/core/workflow-event-recorder.js +120 -0
- package/dist/core/workflow-events.d.ts +82 -0
- package/dist/core/workflow-events.js +44 -0
- package/dist/core/workflow-run-events.d.ts +16 -0
- package/dist/core/workflow-run-events.js +40 -0
- package/dist/generated/prisma/browser.d.ts +58 -0
- package/dist/generated/prisma/client.d.ts +58 -0
- package/dist/generated/prisma/commonInputTypes.d.ts +150 -0
- package/dist/generated/prisma/enums.d.ts +27 -0
- package/dist/generated/prisma/enums.js +23 -0
- package/dist/generated/prisma/internal/class.d.ts +88 -0
- package/dist/generated/prisma/internal/class.js +4 -4
- package/dist/generated/prisma/internal/prismaNamespace.d.ts +772 -1
- package/dist/generated/prisma/internal/prismaNamespace.js +135 -4
- package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +139 -0
- package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +135 -4
- package/dist/generated/prisma/models/Agent.d.ts +360 -1
- package/dist/generated/prisma/models/CodingAgentProfile.d.ts +73 -1
- package/dist/generated/prisma/models/CodingRun.d.ts +205 -1
- package/dist/generated/prisma/models/DeferredReview.d.ts +78 -1
- package/dist/generated/prisma/models/NativeGatewayCall.d.ts +1675 -0
- package/dist/generated/prisma/models/NativeGatewayCall.js +1 -0
- package/dist/generated/prisma/models/NativeGatewaySession.d.ts +1517 -0
- package/dist/generated/prisma/models/NativeGatewaySession.js +1 -0
- package/dist/generated/prisma/models/NativeWarmWorker.d.ts +1296 -0
- package/dist/generated/prisma/models/NativeWarmWorker.js +1 -0
- package/dist/generated/prisma/models/NotificationChannel.d.ts +1543 -0
- package/dist/generated/prisma/models/NotificationChannel.js +1 -0
- package/dist/generated/prisma/models/NotificationDelivery.d.ts +1502 -0
- package/dist/generated/prisma/models/NotificationDelivery.js +1 -0
- package/dist/generated/prisma/models/NotificationThread.d.ts +1090 -0
- package/dist/generated/prisma/models/NotificationThread.js +1 -0
- package/dist/generated/prisma/models/Run.d.ts +826 -1
- package/dist/generated/prisma/models/RunHostCheck.d.ts +36 -1
- package/dist/generated/prisma/models/Secret.d.ts +37 -1
- package/dist/generated/prisma/models/SecretBrokerChange.d.ts +1101 -0
- package/dist/generated/prisma/models/SecretBrokerChange.js +1 -0
- package/dist/generated/prisma/models/WorkflowEvent.d.ts +1422 -0
- package/dist/generated/prisma/models/WorkflowEvent.js +1 -0
- package/dist/generated/prisma/models.d.ts +8 -0
- package/dist/help/build.js +4 -2
- package/dist/help/catalog.d.ts +1 -1
- package/dist/help/catalog.js +31 -6
- package/dist/help-index.json +1363 -176
- package/dist/import/create.js +17 -0
- package/dist/import/neutral-schema.d.ts +6 -2
- package/dist/import/neutral-schema.js +2 -0
- package/dist/mcp/auth/access.d.ts +1 -0
- package/dist/mcp/auth/repo-authorization.js +14 -0
- package/dist/mcp/auth/resource-server.d.ts +2 -1
- package/dist/mcp/auth/resource-server.js +7 -2
- package/dist/mcp/auth/self-hosted/browser.d.ts +1 -1
- package/dist/mcp/auth/self-hosted/browser.js +19 -6
- package/dist/mcp/context.d.ts +6 -0
- package/dist/mcp/host-events/github-ingress.d.ts +23 -1
- package/dist/mcp/host-events/github-ingress.js +68 -0
- package/dist/mcp/index.d.ts +13 -0
- package/dist/mcp/index.js +59 -10
- package/dist/mcp/tools/agents.js +82 -11
- package/dist/mcp/tools/notification-channels.d.ts +2 -0
- package/dist/mcp/tools/notification-channels.js +293 -0
- package/dist/mcp/tools/power.d.ts +2 -0
- package/dist/mcp/tools/power.js +77 -0
- package/dist/mcp/tools/runs.d.ts +0 -6
- package/dist/mcp/tools/runs.js +7 -2
- package/dist/mcp/tools/secret-broker-compat.d.ts +12 -0
- package/dist/mcp/tools/secret-broker-compat.js +23 -0
- package/dist/mcp/tools/secret-elicitation-form.d.ts +22 -2
- package/dist/mcp/tools/secret-elicitation-form.js +200 -10
- package/dist/mcp/tools/secret-elicitation-server.js +8 -1
- package/dist/mcp/tools/secret-elicitation.d.ts +38 -3
- package/dist/mcp/tools/secret-elicitation.js +110 -12
- package/dist/mcp/tools/secrets.js +184 -7
- package/dist/mcp/tools/tools.d.ts +1 -1
- package/dist/mcp/tools/tools.js +22 -2
- package/dist/native-worker/composition.d.ts +23 -0
- package/dist/native-worker/composition.js +77 -0
- package/dist/native-worker/docker-isolation.d.ts +65 -0
- package/dist/native-worker/docker-isolation.js +183 -0
- package/dist/native-worker/docker-launcher.d.ts +74 -0
- package/dist/native-worker/docker-launcher.js +237 -0
- package/dist/native-worker/gateway-server.d.ts +39 -0
- package/dist/native-worker/gateway-server.js +315 -0
- package/dist/native-worker/gateway.d.ts +44 -0
- package/dist/native-worker/gateway.js +197 -0
- package/dist/native-worker/http-server.d.ts +22 -0
- package/dist/native-worker/http-server.js +109 -0
- package/dist/native-worker/http-transport.d.ts +17 -0
- package/dist/native-worker/http-transport.js +88 -0
- package/dist/native-worker/kubernetes-isolation.d.ts +90 -0
- package/dist/native-worker/kubernetes-isolation.js +218 -0
- package/dist/native-worker/kubernetes-launcher.d.ts +102 -0
- package/dist/native-worker/kubernetes-launcher.js +335 -0
- package/dist/native-worker/launch.d.ts +45 -0
- package/dist/native-worker/launch.js +44 -0
- package/dist/native-worker/ledger.d.ts +109 -0
- package/dist/native-worker/ledger.js +192 -0
- package/dist/native-worker/loopback.d.ts +12 -0
- package/dist/native-worker/loopback.js +49 -0
- package/dist/native-worker/main.d.ts +7 -0
- package/dist/native-worker/main.js +74 -0
- package/dist/native-worker/protocol.d.ts +744 -0
- package/dist/native-worker/protocol.js +200 -0
- package/dist/native-worker/sandbox-executor.d.ts +68 -0
- package/dist/native-worker/sandbox-executor.js +210 -0
- package/dist/native-worker/stdio.d.ts +29 -0
- package/dist/native-worker/stdio.js +188 -0
- package/dist/native-worker/warm-delivery.d.ts +16 -0
- package/dist/native-worker/warm-delivery.js +33 -0
- package/dist/native-worker/warm-pool-ledger.d.ts +39 -0
- package/dist/native-worker/warm-pool-ledger.js +73 -0
- package/dist/native-worker/warm-pool.d.ts +107 -0
- package/dist/native-worker/warm-pool.js +283 -0
- package/dist/native-worker/worker.d.ts +17 -0
- package/dist/native-worker/worker.js +115 -0
- package/dist/providers/auth/authorization-server.d.ts +3 -0
- package/dist/providers/auth/index.js +2 -0
- package/dist/providers/auth/self-hosted.d.ts +13 -1
- package/dist/providers/auth/self-hosted.js +54 -4
- package/dist/providers/chat/fake.d.ts +46 -0
- package/dist/providers/chat/fake.js +39 -0
- package/dist/providers/chat/index.d.ts +4 -0
- package/dist/providers/chat/index.js +8 -0
- package/dist/providers/chat/slack.d.ts +37 -0
- package/dist/providers/chat/slack.js +105 -0
- package/dist/providers/chat/types.d.ts +42 -0
- package/dist/providers/chat/types.js +19 -0
- package/dist/providers/engine/types.d.ts +19 -0
- package/dist/providers/engine/types.js +22 -0
- package/dist/providers/executor/composition.d.ts +24 -1
- package/dist/providers/executor/composition.js +39 -6
- package/dist/providers/executor/container.d.ts +24 -1
- package/dist/providers/executor/container.js +101 -11
- package/dist/providers/executor/dbos.d.ts +42 -0
- package/dist/providers/executor/dbos.js +118 -12
- package/dist/providers/executor/deferred.d.ts +56 -0
- package/dist/providers/executor/deferred.js +119 -0
- package/dist/providers/executor/routing.d.ts +37 -10
- package/dist/providers/executor/routing.js +73 -32
- package/dist/providers/executor/types.d.ts +12 -0
- package/dist/providers/jobs/docker.js +3 -3
- package/dist/providers/jobs/fake-kubernetes-api.d.ts +21 -2
- package/dist/providers/jobs/fake-kubernetes-api.js +42 -1
- package/dist/providers/jobs/kubernetes-api.d.ts +17 -1
- package/dist/providers/jobs/kubernetes-api.js +7 -0
- package/dist/providers/jobs/kubernetes-client.d.ts +10 -1
- package/dist/providers/jobs/kubernetes-client.js +52 -3
- package/dist/providers/jobs/kubernetes-dry-run-fixture.d.ts +6 -1
- package/dist/providers/jobs/kubernetes-dry-run-fixture.js +6 -3
- package/dist/providers/jobs/kubernetes-isolation.d.ts +3 -1
- package/dist/providers/jobs/kubernetes-isolation.js +6 -0
- package/dist/providers/jobs/kubernetes-platform.d.ts +4 -1
- package/dist/providers/jobs/kubernetes-platform.js +38 -1
- package/dist/providers/jobs/kubernetes-preflight.d.ts +2 -1
- package/dist/providers/jobs/kubernetes-preflight.js +4 -2
- package/dist/providers/jobs/kubernetes.d.ts +36 -1
- package/dist/providers/jobs/kubernetes.js +74 -4
- package/dist/providers/jobs/metadata-probes.d.ts +11 -0
- package/dist/providers/jobs/metadata-probes.js +13 -0
- package/dist/providers/jobs/types.d.ts +9 -0
- package/dist/providers/review-host/github.d.ts +11 -1
- package/dist/providers/review-host/github.js +50 -0
- package/dist/providers/review-host/types.d.ts +23 -0
- package/dist/providers/vcs/github.d.ts +32 -6
- package/dist/providers/vcs/github.js +74 -16
- package/dist/quickstart/coding-seed.d.ts +10 -3
- package/dist/quickstart/coding-seed.js +74 -3
- package/dist/quickstart/images.d.ts +2 -0
- package/dist/quickstart/images.js +7 -1
- package/dist/quickstart/reviewer-prompt.d.ts +1 -1
- package/dist/quickstart/reviewer-prompt.js +1 -1
- package/dist/quickstart-images.json +1 -1
- package/dist/sandbox/brokered-fetch.d.ts +23 -0
- package/dist/sandbox/brokered-fetch.js +37 -0
- package/dist/sandbox/host-functions.d.ts +34 -0
- package/dist/sandbox/host-functions.js +144 -80
- package/dist/sandbox/prelude.js +7 -1
- package/dist/sandbox/run-in-sandbox.d.ts +19 -0
- package/dist/sandbox/run-in-sandbox.js +28 -10
- package/dist/sandbox/safe-fetch.d.ts +2 -0
- package/dist/sandbox/safe-fetch.js +3 -1
- package/dist/sandbox/secret-broker-sigv4.d.ts +2 -0
- package/dist/sandbox/secret-broker-sigv4.js +49 -0
- package/dist/sandbox/secret-broker.d.ts +34 -0
- package/dist/sandbox/secret-broker.js +162 -0
- package/dist/sandbox/user-tool.d.ts +14 -0
- package/dist/sandbox/user-tool.js +32 -0
- package/dist/serve.js +18 -0
- package/dist/viewer/api-schema.d.ts +210 -14
- package/dist/viewer/api-schema.js +22 -0
- package/dist/viewer/build-schemas.js +4 -1
- package/dist/viewer/event-bus.d.ts +6 -0
- package/dist/viewer/event-bus.js +16 -7
- package/dist/viewer/graph.d.ts +5 -2
- package/dist/viewer/graph.js +7 -2
- package/dist/viewer/http.d.ts +0 -1
- package/dist/viewer/http.js +2 -2
- package/dist/viewer/infra.js +25 -2
- package/dist/viewer/limits.d.ts +4 -0
- package/dist/viewer/limits.js +4 -0
- package/dist/viewer/openapi.d.ts +15 -0
- package/dist/viewer/openapi.js +172 -0
- package/docs/README.md +7 -0
- package/docs/agent-recipes.md +57 -8
- package/docs/architecture-runtime.md +16 -1
- package/docs/code-review-agents.md +139 -5
- package/docs/coding-agent-setup.md +113 -5
- package/docs/coding-packages.md +4 -2
- package/docs/coding-services.md +3 -1
- package/docs/coding-worker-byo-images.md +198 -6
- package/docs/coding-worker-isolation.md +68 -14
- package/docs/getting-started-gke.md +225 -9
- package/docs/getting-started-identity-provider.md +21 -10
- package/docs/getting-started.md +5 -4
- package/docs/jira-agents.md +5 -1
- package/docs/native-sandbox.md +635 -0
- package/docs/release-verification.md +9 -0
- package/docs/security-deployment.md +113 -12
- package/docs/slack-notifications.md +252 -0
- package/docs/tool-secrets.md +252 -0
- package/docs/viewer-api.md +46 -5
- package/docs/viewer-api.openapi.yml +1404 -0
- package/help/admin-viewer.md +6 -2
- package/help/agent-recipes.md +27 -0
- package/help/architecture-agent.md +1 -1
- package/help/brokered-secrets.md +79 -0
- package/help/build-claude-code-image.md +131 -0
- package/help/build-worker-image.md +3 -6
- package/help/code-review-agents.md +68 -6
- package/help/deploy-gke.md +8 -1
- package/help/deployment-targets.md +25 -14
- package/help/errors/coding-provider-not-configured.md +2 -2
- package/help/errors/database-auth-invalid.md +28 -0
- package/help/errors/github-account-not-allowed.md +25 -0
- package/help/errors/low-power-disabled.md +29 -0
- package/help/errors/low-power-scale-forbidden.md +35 -0
- package/help/errors/low-power-wake-timeout.md +30 -0
- package/help/errors/mobile-disabled.md +31 -0
- package/help/errors/native-sandbox-capacity.md +22 -0
- package/help/errors/native-sandbox-deadline-exceeded.md +19 -0
- package/help/errors/native-sandbox-docker-failed.md +20 -0
- package/help/errors/native-sandbox-gateway-unavailable.md +21 -0
- package/help/errors/native-sandbox-image-not-pinned.md +19 -0
- package/help/errors/native-sandbox-isolation-mismatch.md +20 -0
- package/help/errors/native-sandbox-network-unenforced.md +22 -0
- package/help/errors/native-sandbox-requires-catalog.md +31 -0
- package/help/errors/native-sandbox-unavailable.md +19 -0
- package/help/errors/native-sandbox-warm-delivery-failed.md +21 -0
- package/help/errors/native-sandbox-worker-exited.md +20 -0
- package/help/errors/native-sandbox-worker-lost.md +20 -0
- package/help/errors/native-sandbox-worker-unready.md +22 -0
- package/help/errors/secret-broker-body-invalid.md +26 -0
- package/help/errors/secret-broker-changed.md +20 -0
- package/help/errors/secret-broker-config-invalid.md +30 -0
- package/help/errors/secret-broker-conflict.md +27 -0
- package/help/errors/secret-broker-destination-denied.md +28 -0
- package/help/errors/secret-broker-value-invalid.md +28 -0
- package/help/errors/secret-brokered.md +24 -0
- package/help/errors/secret-not-brokered.md +25 -0
- package/help/errors/slack-auth-failed.md +48 -0
- package/help/errors/slack-channel-unreachable.md +39 -0
- package/help/errors/slack-not-configured.md +33 -0
- package/help/getting-started.md +4 -5
- package/help/github.md +18 -1
- package/help/identity-and-access.md +5 -1
- package/help/local-repositories.md +2 -3
- package/help/low-power-mode.md +125 -0
- package/help/merge-order-check.md +117 -0
- package/help/mobile-app.md +54 -0
- package/help/native-sandbox.md +136 -0
- package/help/observability.md +8 -0
- package/help/related-pull-requests.md +70 -9
- package/help/repo-instructions-and-skills.md +91 -0
- package/help/review-fix-rounds.md +10 -0
- package/help/slack-notifications.md +62 -0
- package/help/troubleshooting/coding-workers.md +21 -0
- package/package.json +11 -4
- package/prisma/migrations/20261009000003_native_execution_mode/migration.sql +12 -0
- package/prisma/migrations/20261009000004_native_gateway_ledger/migration.sql +65 -0
- package/prisma/migrations/20261009000005_native_gateway_call_child_agent/migration.sql +5 -0
- package/prisma/migrations/20261009000006_run_deferred_start_stop/migration.sql +14 -0
- package/prisma/migrations/20261009000007_native_gateway_network_ready/migration.sql +5 -0
- package/prisma/migrations/20261010000001_workflow_notifications/migration.sql +99 -0
- package/prisma/migrations/20261011000001_native_gateway_session_status_index/migration.sql +2 -0
- package/prisma/migrations/20261012000001_native_warm_worker/migration.sql +32 -0
- package/prisma/migrations/20261013000001_coding_run_merge_order/migration.sql +2 -0
- package/prisma/migrations/20261013000001_secret_broker/migration.sql +17 -0
- package/prisma/migrations/20261013000002_deferred_review_request/migration.sql +4 -0
- package/prisma/migrations/20261014000000_repo_skills/migration.sql +15 -0
- package/prisma/migrations/20261015000001_coding_run_merge_order_index/migration.sql +2 -0
- package/prisma/migrations/20261015000002_no_change_rereview/migration.sql +2 -0
- package/prisma/schema.prisma +362 -91
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kubernetes objects for one sandbox-mode native run (docs/native-sandbox.md), as pure builders —
|
|
3
|
+
* the launcher creates exactly these and attests what the API server stored against them. One pod
|
|
4
|
+
* per run, with no service-account token, a read-only non-root container holding no credentials,
|
|
5
|
+
* its WorkerInput (capability included) mounted read-only from a per-run Secret, and a
|
|
6
|
+
* NetworkPolicy whose only egress is the native gateway's port. Names carry a hash of the run id,
|
|
7
|
+
* never the id itself.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { isRepositoryDigest } from "../providers/jobs/docker-isolation.js";
|
|
11
|
+
import { CLOUD_METADATA_PROBES } from "../providers/jobs/metadata-probes.js";
|
|
12
|
+
import { conformResources, GVISOR_RUNTIME_CLASS, platformProfile, } from "../providers/jobs/kubernetes-platform.js";
|
|
13
|
+
import { NATIVE_GATEWAY_DENY_PORT, NATIVE_GATEWAY_PORT, NATIVE_WORKER_TMP_MB, NATIVE_WORKER_UID, nativeWarmWorkerName, } from "./docker-isolation.js";
|
|
14
|
+
export { nativeWarmWorkerName };
|
|
15
|
+
import { WARM_INPUT_FILE } from "./warm-delivery.js";
|
|
16
|
+
export const NATIVE_RUN_COMPONENT_LABEL = { "wardby.io/component": "native-run" };
|
|
17
|
+
export const NATIVE_GATEWAY_POD_LABEL = { "app.kubernetes.io/name": "wardby-native-gateway" };
|
|
18
|
+
const MANAGED_BY_LABEL = { "app.kubernetes.io/managed-by": "wardby" };
|
|
19
|
+
export const NATIVE_RUN_SHA_LABEL = "wardby.io/run-sha256";
|
|
20
|
+
/** The label selector listing every native run pod (the janitor's listing). */
|
|
21
|
+
export const NATIVE_RUN_SELECTOR = "wardby.io/component=native-run";
|
|
22
|
+
export const NATIVE_WORKER_CONTAINER = "worker";
|
|
23
|
+
export const NATIVE_INPUT_MOUNT = "/run/wardby/input";
|
|
24
|
+
export const NATIVE_INPUT_KEY = "input.json";
|
|
25
|
+
export const NATIVE_INPUT_FILE = `${NATIVE_INPUT_MOUNT}/${NATIVE_INPUT_KEY}`;
|
|
26
|
+
/** Room for node's own writes besides /tmp; the root filesystem is read-only. */
|
|
27
|
+
const EPHEMERAL_STORAGE_MI = 128;
|
|
28
|
+
export function nativeKubernetesNames(runId) {
|
|
29
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,199}$/.test(runId))
|
|
30
|
+
throw new Error("native_sandbox_invalid_run_id");
|
|
31
|
+
const digest = createHash("sha256").update(runId).digest("hex");
|
|
32
|
+
const base = `wardby-native-${digest.slice(0, 20)}`;
|
|
33
|
+
return { runSha: digest.slice(0, 40), pod: base, policy: base, secret: `${base}-input` };
|
|
34
|
+
}
|
|
35
|
+
export function nativeRunLabels(runId) {
|
|
36
|
+
return {
|
|
37
|
+
...MANAGED_BY_LABEL,
|
|
38
|
+
...NATIVE_RUN_COMPONENT_LABEL,
|
|
39
|
+
[NATIVE_RUN_SHA_LABEL]: nativeKubernetesNames(runId).runSha,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export function buildNativeInputSecret(input, namespace) {
|
|
43
|
+
const names = nativeKubernetesNames(input.runId);
|
|
44
|
+
return {
|
|
45
|
+
apiVersion: "v1",
|
|
46
|
+
kind: "Secret",
|
|
47
|
+
metadata: { name: names.secret, namespace, labels: nativeRunLabels(input.runId) },
|
|
48
|
+
type: "Opaque",
|
|
49
|
+
immutable: true,
|
|
50
|
+
stringData: { [NATIVE_INPUT_KEY]: JSON.stringify(input) },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export const NATIVE_SANDBOX_PLATFORM_ERROR = "native_sandbox_platform_unsupported";
|
|
54
|
+
export function buildNativeRunPod(options) {
|
|
55
|
+
const names = nativeKubernetesNames(options.runId);
|
|
56
|
+
return buildNativePod(options, {
|
|
57
|
+
name: names.pod,
|
|
58
|
+
labels: nativeRunLabels(options.runId),
|
|
59
|
+
env: [{ name: "NATIVE_WORKER_INPUT_FILE", value: NATIVE_INPUT_FILE }],
|
|
60
|
+
input: { secretName: names.secret },
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/** The labels of every warm pool pod: still a native-run pod (the gateway admits it), never a run's. */
|
|
64
|
+
export const NATIVE_WARM_POOL_LABEL = { "wardby.io/pool": "warm" };
|
|
65
|
+
export const NATIVE_WARM_TOKEN_LABEL = "wardby.io/warm-worker";
|
|
66
|
+
/** The label selector listing every warm pool pod. */
|
|
67
|
+
export const NATIVE_WARM_SELECTOR = "wardby.io/component=native-run,wardby.io/pool=warm";
|
|
68
|
+
export function nativeWarmLabels(token) {
|
|
69
|
+
return {
|
|
70
|
+
...MANAGED_BY_LABEL,
|
|
71
|
+
...NATIVE_RUN_COMPONENT_LABEL,
|
|
72
|
+
...NATIVE_WARM_POOL_LABEL,
|
|
73
|
+
[NATIVE_WARM_TOKEN_LABEL]: token,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** A warm pool pod (native sandbox phase 6): no input yet; it waits for one delivered by exec. */
|
|
77
|
+
export function buildNativeWarmPod(options) {
|
|
78
|
+
return buildNativePod(options, {
|
|
79
|
+
name: nativeWarmWorkerName(options.token),
|
|
80
|
+
labels: nativeWarmLabels(options.token),
|
|
81
|
+
env: [
|
|
82
|
+
{ name: "NATIVE_WORKER_INPUT_FILE", value: WARM_INPUT_FILE },
|
|
83
|
+
{ name: "NATIVE_WORKER_INPUT_WAIT_MS", value: String(Math.floor(options.waitMs)) },
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function buildNativePod(options, identity) {
|
|
88
|
+
const { namespace, image, limits } = options;
|
|
89
|
+
if (!isRepositoryDigest(image)) {
|
|
90
|
+
throw new Error("native_sandbox_image_not_pinned: a cluster pulls by registry digest (repo@sha256:...).");
|
|
91
|
+
}
|
|
92
|
+
const profile = platformProfile(options.platform ?? "generic");
|
|
93
|
+
if (profile.requiresGvisor && options.runtimeClassName !== GVISOR_RUNTIME_CLASS) {
|
|
94
|
+
throw new Error(`${NATIVE_SANDBOX_PLATFORM_ERROR}: platform ${profile.name} runs native workers only under the ${GVISOR_RUNTIME_CLASS} RuntimeClass.`);
|
|
95
|
+
}
|
|
96
|
+
// Already legal on the platform, so admission rewrites nothing (attestation compares resources).
|
|
97
|
+
// Native pods always declare ephemeral storage, which a generic profile would otherwise drop.
|
|
98
|
+
const conformed = conformResources(profile, {
|
|
99
|
+
cpuMillicores: Math.round(limits.cpus * 1000),
|
|
100
|
+
memoryMib: limits.memoryMb,
|
|
101
|
+
ephemeralStorageMib: EPHEMERAL_STORAGE_MI,
|
|
102
|
+
});
|
|
103
|
+
const resources = { ...conformed.requests, "ephemeral-storage": `${EPHEMERAL_STORAGE_MI}Mi` };
|
|
104
|
+
return {
|
|
105
|
+
apiVersion: "v1",
|
|
106
|
+
kind: "Pod",
|
|
107
|
+
metadata: { name: identity.name, namespace, labels: identity.labels },
|
|
108
|
+
spec: {
|
|
109
|
+
restartPolicy: "Never",
|
|
110
|
+
activeDeadlineSeconds: Math.max(1, Math.floor(options.activeDeadlineSeconds)),
|
|
111
|
+
automountServiceAccountToken: false,
|
|
112
|
+
enableServiceLinks: false,
|
|
113
|
+
hostNetwork: false,
|
|
114
|
+
hostPID: false,
|
|
115
|
+
hostIPC: false,
|
|
116
|
+
terminationGracePeriodSeconds: 5,
|
|
117
|
+
...(options.runtimeClassName ? { runtimeClassName: options.runtimeClassName } : {}),
|
|
118
|
+
...(options.priorityClassName ? { priorityClassName: options.priorityClassName } : {}),
|
|
119
|
+
securityContext: {
|
|
120
|
+
runAsNonRoot: true,
|
|
121
|
+
runAsUser: NATIVE_WORKER_UID,
|
|
122
|
+
runAsGroup: NATIVE_WORKER_UID,
|
|
123
|
+
// Secret files mount root-owned: group ownership lets the non-root worker read its input.
|
|
124
|
+
fsGroup: NATIVE_WORKER_UID,
|
|
125
|
+
seccompProfile: { type: "RuntimeDefault" },
|
|
126
|
+
},
|
|
127
|
+
containers: [
|
|
128
|
+
{
|
|
129
|
+
name: NATIVE_WORKER_CONTAINER,
|
|
130
|
+
image,
|
|
131
|
+
imagePullPolicy: "IfNotPresent",
|
|
132
|
+
env: identity.env,
|
|
133
|
+
resources: { requests: { ...resources }, limits: { ...resources } },
|
|
134
|
+
securityContext: {
|
|
135
|
+
allowPrivilegeEscalation: false,
|
|
136
|
+
privileged: false,
|
|
137
|
+
readOnlyRootFilesystem: true,
|
|
138
|
+
runAsNonRoot: true,
|
|
139
|
+
capabilities: { drop: ["ALL"] },
|
|
140
|
+
},
|
|
141
|
+
volumeMounts: [
|
|
142
|
+
...(identity.input ? [{ name: "input", mountPath: NATIVE_INPUT_MOUNT, readOnly: true }] : []),
|
|
143
|
+
{ name: "tmp", mountPath: "/tmp" },
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
volumes: [
|
|
148
|
+
...(identity.input
|
|
149
|
+
? [{ name: "input", secret: { secretName: identity.input.secretName, defaultMode: 0o440 } }]
|
|
150
|
+
: []),
|
|
151
|
+
{ name: "tmp", emptyDir: { sizeLimit: `${NATIVE_WORKER_TMP_MB}Mi` } },
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/** Ingress: none. Egress: the native gateway's pods, on the gateway port only. */
|
|
157
|
+
export function buildNativeRunNetworkPolicy(runId, namespace) {
|
|
158
|
+
return buildNativePolicy(nativeKubernetesNames(runId).policy, namespace, nativeRunLabels(runId));
|
|
159
|
+
}
|
|
160
|
+
/** A warm pool pod's policy: the same egress, selecting the pod by its token. */
|
|
161
|
+
export function buildNativeWarmNetworkPolicy(token, namespace) {
|
|
162
|
+
return buildNativePolicy(nativeWarmWorkerName(token), namespace, nativeWarmLabels(token));
|
|
163
|
+
}
|
|
164
|
+
function buildNativePolicy(name, namespace, labels) {
|
|
165
|
+
return {
|
|
166
|
+
apiVersion: "networking.k8s.io/v1",
|
|
167
|
+
kind: "NetworkPolicy",
|
|
168
|
+
metadata: { name, namespace, labels },
|
|
169
|
+
spec: {
|
|
170
|
+
podSelector: { matchLabels: labels },
|
|
171
|
+
policyTypes: ["Ingress", "Egress"],
|
|
172
|
+
ingress: [],
|
|
173
|
+
egress: [
|
|
174
|
+
{
|
|
175
|
+
to: [{ podSelector: { matchLabels: { ...NATIVE_GATEWAY_POD_LABEL } } }],
|
|
176
|
+
ports: [{ protocol: "TCP", port: NATIVE_GATEWAY_PORT }],
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/** Exit codes of the in-pod enforcement probe. */
|
|
183
|
+
export const NATIVE_PROBE = { proven: 0, gatewayUnreachable: 3, denyReachable: 4, outsideReachable: 5 };
|
|
184
|
+
/** Addresses a worker must not reach: the internet, and the cloud metadata endpoints (node and workload credentials). */
|
|
185
|
+
export const NATIVE_PROBE_OUTSIDE = [
|
|
186
|
+
{ host: "1.1.1.1", port: 443 },
|
|
187
|
+
...CLOUD_METADATA_PROBES,
|
|
188
|
+
];
|
|
189
|
+
/**
|
|
190
|
+
* Run inside the worker container (exec): proves the pod's egress is the gateway port and nothing
|
|
191
|
+
* else — the gateway answers, while its deny port (same address, another port) and every outside
|
|
192
|
+
* address do not. A connection that is refused or times out both count as unreachable.
|
|
193
|
+
*/
|
|
194
|
+
export function nativeEnforcementProbe(gatewayHost, outside = NATIVE_PROBE_OUTSIDE) {
|
|
195
|
+
const script = [
|
|
196
|
+
'const net = require("node:net");',
|
|
197
|
+
"const reach = (host, port) => new Promise((done) => {",
|
|
198
|
+
" const s = net.connect({ host, port, timeout: 3000 });",
|
|
199
|
+
" s.once('connect', () => { s.destroy(); done(true); });",
|
|
200
|
+
" s.once('error', () => done(false));",
|
|
201
|
+
" s.once('timeout', () => { s.destroy(); done(false); });",
|
|
202
|
+
"});",
|
|
203
|
+
"(async () => {",
|
|
204
|
+
` if (!(await reach(${JSON.stringify(gatewayHost)}, ${NATIVE_GATEWAY_PORT}))) process.exit(${NATIVE_PROBE.gatewayUnreachable});`,
|
|
205
|
+
// The deny port and every outside address run concurrently (each dropped connect waits the full
|
|
206
|
+
// timeout, so serial probing would eat the exec budget); the verdicts are still read in a fixed
|
|
207
|
+
// order, so when several fail the exit code is deterministic: deny port first, then outside.
|
|
208
|
+
` const deny = reach(${JSON.stringify(gatewayHost)}, ${NATIVE_GATEWAY_DENY_PORT});`,
|
|
209
|
+
" const outside = Promise.all([",
|
|
210
|
+
...outside.map((o) => ` reach(${JSON.stringify(o.host)}, ${o.port}),`),
|
|
211
|
+
" ]);",
|
|
212
|
+
` if (await deny) process.exit(${NATIVE_PROBE.denyReachable});`,
|
|
213
|
+
` if ((await outside).some(Boolean)) process.exit(${NATIVE_PROBE.outsideReachable});`,
|
|
214
|
+
` process.exit(${NATIVE_PROBE.proven});`,
|
|
215
|
+
"})();",
|
|
216
|
+
].join("\n");
|
|
217
|
+
return ["node", "-e", script];
|
|
218
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launches, watches, stops, and removes native sandbox workers as Kubernetes pods
|
|
3
|
+
* (docs/native-sandbox.md), from the builders in kubernetes-isolation.ts. Like the Docker
|
|
4
|
+
* launcher it keeps no state of its own: names derive from the run id, state comes from the pod,
|
|
5
|
+
* and sessions live in Postgres, so any replica can act on any run.
|
|
6
|
+
*
|
|
7
|
+
* A pod's NetworkPolicy takes effect a few seconds after the pod starts (the CNI programs it), so
|
|
8
|
+
* the session is created not ready (`networkReadyAtLaunch = false`): the gateway refuses the
|
|
9
|
+
* worker until this launcher has read back the pod and policy it created, then proven from inside
|
|
10
|
+
* the pod that the gateway port answers while the gateway's deny port and an outside address do
|
|
11
|
+
* not — and only then marks the session ready.
|
|
12
|
+
*/
|
|
13
|
+
import { type Power } from "../core/power.js";
|
|
14
|
+
import { type KubernetesApi } from "../providers/jobs/kubernetes-api.js";
|
|
15
|
+
import type { KubernetesPlatform } from "../providers/jobs/kubernetes-platform.js";
|
|
16
|
+
import { type NativeWorkerLimits } from "./docker-isolation.js";
|
|
17
|
+
import type { NativeWorkerState } from "./docker-launcher.js";
|
|
18
|
+
import type { WorkerHandle } from "./launch.js";
|
|
19
|
+
import type { WorkerInput } from "./protocol.js";
|
|
20
|
+
import type { ManagedWorkerLauncher } from "./sandbox-executor.js";
|
|
21
|
+
import { type WarmWorkerLauncher } from "./warm-pool.js";
|
|
22
|
+
export declare const NATIVE_SANDBOX_NETWORK_UNENFORCED = "native_sandbox_network_unenforced";
|
|
23
|
+
export declare const NATIVE_SANDBOX_ISOLATION_MISMATCH = "native_sandbox_isolation_mismatch";
|
|
24
|
+
export declare const NATIVE_SANDBOX_CAPACITY = "native_sandbox_capacity";
|
|
25
|
+
/** The API server's refusal of a pod the namespace's ResourceQuota has no room for (403 "exceeded quota"). */
|
|
26
|
+
export declare function isQuotaRejection(err: unknown): boolean;
|
|
27
|
+
export interface KubernetesNativeWorkerLauncherOptions {
|
|
28
|
+
api: KubernetesApi;
|
|
29
|
+
namespace: string;
|
|
30
|
+
image: string;
|
|
31
|
+
limits: NativeWorkerLimits;
|
|
32
|
+
/** The native gateway's Service, in the same namespace (NetworkPolicy selects its pods there). */
|
|
33
|
+
gatewayService: string;
|
|
34
|
+
runtimeClassName?: string;
|
|
35
|
+
/** KUBERNETES_PLATFORM: the admission rules native pods are conformed to. */
|
|
36
|
+
platform?: KubernetesPlatform;
|
|
37
|
+
/** KUBERNETES_RUN_PRIORITY_CLASS. */
|
|
38
|
+
priorityClassName?: string;
|
|
39
|
+
/** NATIVE_GATEWAY_URL: dial this instead of the Service's ClusterIP (its host is also what the probe checks). */
|
|
40
|
+
gatewayUrl?: string;
|
|
41
|
+
/** Called once a pod's isolation is proven; the executor marks the run's session ready. */
|
|
42
|
+
onNetworkProven: (runId: string) => Promise<void>;
|
|
43
|
+
/** The pod's hard lifetime (its session's deadline). */
|
|
44
|
+
deadlineSeconds: number;
|
|
45
|
+
readyTimeoutMs?: number;
|
|
46
|
+
enforcementTimeoutMs?: number;
|
|
47
|
+
pollMs?: number;
|
|
48
|
+
sleep?: (ms: number) => Promise<void>;
|
|
49
|
+
/** Low-power mode: wakes the (possibly scaled-to-zero) native gateway before a launch. Default `DISABLED_POWER`. */
|
|
50
|
+
power?: Power;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* An object as the API server would store it, for comparison: keys sorted, and missing, undefined,
|
|
54
|
+
* and empty arrays alike (it drops an empty `ingress: []`, which `policyTypes` already makes deny-all).
|
|
55
|
+
*/
|
|
56
|
+
export declare function canonical(value: unknown): unknown;
|
|
57
|
+
export declare class KubernetesNativeWorkerLauncher implements ManagedWorkerLauncher, WarmWorkerLauncher {
|
|
58
|
+
private readonly options;
|
|
59
|
+
readonly networkReadyAtLaunch = false;
|
|
60
|
+
private gatewayHost;
|
|
61
|
+
private readonly power;
|
|
62
|
+
constructor(options: KubernetesNativeWorkerLauncherOptions);
|
|
63
|
+
private get api();
|
|
64
|
+
private get ns();
|
|
65
|
+
private sleep;
|
|
66
|
+
/** The gateway Service's ClusterIP: workers dial an address, so they need no DNS egress. */
|
|
67
|
+
private resolveGatewayHost;
|
|
68
|
+
resolveGatewayUrl(): Promise<string>;
|
|
69
|
+
private createIfMissing;
|
|
70
|
+
launch(input: WorkerInput): Promise<WorkerHandle>;
|
|
71
|
+
/**
|
|
72
|
+
* Policy first, so the pod never exists without it; then the pod; then attest and prove it. A
|
|
73
|
+
* quota rejection becomes native_sandbox_capacity. Any failure removes what was made.
|
|
74
|
+
*/
|
|
75
|
+
private createIsolated;
|
|
76
|
+
/** What the API server stored must still be what was built: an admission change voids the run. */
|
|
77
|
+
private attest;
|
|
78
|
+
private matches;
|
|
79
|
+
/** Waits for the pod to run, then proves its egress from inside it (bounded). */
|
|
80
|
+
private proveIsolation;
|
|
81
|
+
handle(runId: string): WorkerHandle;
|
|
82
|
+
private handleByName;
|
|
83
|
+
inspect(runId: string): Promise<NativeWorkerState>;
|
|
84
|
+
private inspectByName;
|
|
85
|
+
kill(runId: string): Promise<void>;
|
|
86
|
+
remove(runId: string): Promise<void>;
|
|
87
|
+
listWorkers(): Promise<{
|
|
88
|
+
name: string;
|
|
89
|
+
runHash: string;
|
|
90
|
+
}[]>;
|
|
91
|
+
removeByWorkerName(name: string): Promise<void>;
|
|
92
|
+
private warmPod;
|
|
93
|
+
warmSpecHash(waitMs: number): string;
|
|
94
|
+
startWarm(token: string, waitMs: number): Promise<void>;
|
|
95
|
+
reattestWarm(token: string, waitMs: number): Promise<boolean>;
|
|
96
|
+
deliver(token: string, input: WorkerInput): Promise<void>;
|
|
97
|
+
warmHandle(token: string): WorkerHandle;
|
|
98
|
+
inspectWarm(token: string): Promise<NativeWorkerState>;
|
|
99
|
+
killWarm(token: string): Promise<void>;
|
|
100
|
+
removeWarm(token: string): Promise<void>;
|
|
101
|
+
listWarm(): Promise<string[]>;
|
|
102
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launches, watches, stops, and removes native sandbox workers as Kubernetes pods
|
|
3
|
+
* (docs/native-sandbox.md), from the builders in kubernetes-isolation.ts. Like the Docker
|
|
4
|
+
* launcher it keeps no state of its own: names derive from the run id, state comes from the pod,
|
|
5
|
+
* and sessions live in Postgres, so any replica can act on any run.
|
|
6
|
+
*
|
|
7
|
+
* A pod's NetworkPolicy takes effect a few seconds after the pod starts (the CNI programs it), so
|
|
8
|
+
* the session is created not ready (`networkReadyAtLaunch = false`): the gateway refuses the
|
|
9
|
+
* worker until this launcher has read back the pod and policy it created, then proven from inside
|
|
10
|
+
* the pod that the gateway port answers while the gateway's deny port and an outside address do
|
|
11
|
+
* not — and only then marks the session ready.
|
|
12
|
+
*/
|
|
13
|
+
import { logger } from "../core/logger.js";
|
|
14
|
+
import { DISABLED_POWER, wakeTimeoutMs } from "../core/power.js";
|
|
15
|
+
import { KubernetesAlreadyExistsError } from "../providers/jobs/kubernetes-api.js";
|
|
16
|
+
import { canonicalResources } from "../providers/jobs/kubernetes-isolation.js";
|
|
17
|
+
import { NATIVE_GATEWAY_PORT } from "./docker-isolation.js";
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import { Readable } from "node:stream";
|
|
20
|
+
import { buildNativeInputSecret, buildNativeRunNetworkPolicy, buildNativeRunPod, buildNativeWarmNetworkPolicy, buildNativeWarmPod, NATIVE_PROBE, NATIVE_RUN_SELECTOR, NATIVE_RUN_SHA_LABEL, NATIVE_WARM_SELECTOR, NATIVE_WARM_TOKEN_LABEL, NATIVE_WORKER_CONTAINER, nativeEnforcementProbe, nativeKubernetesNames, nativeWarmWorkerName, } from "./kubernetes-isolation.js";
|
|
21
|
+
import { warmDeliveryCommand } from "./warm-delivery.js";
|
|
22
|
+
import { NATIVE_SANDBOX_WARM_DELIVERY_FAILED } from "./warm-pool.js";
|
|
23
|
+
const k8sLog = logger.child({ module: "native-kubernetes-launcher" });
|
|
24
|
+
export const NATIVE_SANDBOX_NETWORK_UNENFORCED = "native_sandbox_network_unenforced";
|
|
25
|
+
export const NATIVE_SANDBOX_ISOLATION_MISMATCH = "native_sandbox_isolation_mismatch";
|
|
26
|
+
export const NATIVE_SANDBOX_CAPACITY = "native_sandbox_capacity";
|
|
27
|
+
/** The API server's refusal of a pod the namespace's ResourceQuota has no room for (403 "exceeded quota"). */
|
|
28
|
+
export function isQuotaRejection(err) {
|
|
29
|
+
if (typeof err !== "object" || err === null)
|
|
30
|
+
return false;
|
|
31
|
+
const { code, body, message } = err;
|
|
32
|
+
const text = `${typeof body === "string" ? body : JSON.stringify(body ?? "")} ${typeof message === "string" ? message : ""}`;
|
|
33
|
+
return (code === 403 || code === undefined) && /exceeded quota/i.test(text);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* An object as the API server would store it, for comparison: keys sorted, and missing, undefined,
|
|
37
|
+
* and empty arrays alike (it drops an empty `ingress: []`, which `policyTypes` already makes deny-all).
|
|
38
|
+
*/
|
|
39
|
+
export function canonical(value) {
|
|
40
|
+
if (Array.isArray(value))
|
|
41
|
+
return value.length === 0 ? undefined : value.map(canonical);
|
|
42
|
+
if (value === null || typeof value !== "object")
|
|
43
|
+
return value;
|
|
44
|
+
const out = {};
|
|
45
|
+
for (const key of Object.keys(value).sort()) {
|
|
46
|
+
const item = canonical(value[key]);
|
|
47
|
+
if (item !== undefined)
|
|
48
|
+
out[key] = item;
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
export class KubernetesNativeWorkerLauncher {
|
|
53
|
+
options;
|
|
54
|
+
networkReadyAtLaunch = false;
|
|
55
|
+
gatewayHost;
|
|
56
|
+
power;
|
|
57
|
+
constructor(options) {
|
|
58
|
+
this.options = options;
|
|
59
|
+
this.power = options.power ?? DISABLED_POWER;
|
|
60
|
+
}
|
|
61
|
+
get api() {
|
|
62
|
+
return this.options.api;
|
|
63
|
+
}
|
|
64
|
+
get ns() {
|
|
65
|
+
return this.options.namespace;
|
|
66
|
+
}
|
|
67
|
+
sleep(ms) {
|
|
68
|
+
return (this.options.sleep ?? ((t) => new Promise((resolve) => setTimeout(resolve, t))))(ms);
|
|
69
|
+
}
|
|
70
|
+
/** The gateway Service's ClusterIP: workers dial an address, so they need no DNS egress. */
|
|
71
|
+
resolveGatewayHost() {
|
|
72
|
+
if (this.options.gatewayUrl)
|
|
73
|
+
return Promise.resolve(new URL(this.options.gatewayUrl).hostname);
|
|
74
|
+
this.gatewayHost ??= (async () => {
|
|
75
|
+
const service = await this.api.readService(this.ns, this.options.gatewayService);
|
|
76
|
+
const ip = service?.spec?.clusterIP;
|
|
77
|
+
if (!ip || ip === "None") {
|
|
78
|
+
this.gatewayHost = undefined;
|
|
79
|
+
throw new Error(`native_sandbox_gateway_unavailable: Service "${this.options.gatewayService}" in "${this.ns}" has no ClusterIP.`);
|
|
80
|
+
}
|
|
81
|
+
return ip;
|
|
82
|
+
})();
|
|
83
|
+
return this.gatewayHost;
|
|
84
|
+
}
|
|
85
|
+
async resolveGatewayUrl() {
|
|
86
|
+
if (this.options.gatewayUrl)
|
|
87
|
+
return this.options.gatewayUrl;
|
|
88
|
+
return `http://${await this.resolveGatewayHost()}:${NATIVE_GATEWAY_PORT}/native-gateway/v1/call`;
|
|
89
|
+
}
|
|
90
|
+
async createIfMissing(create) {
|
|
91
|
+
try {
|
|
92
|
+
await create();
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
if (!(err instanceof KubernetesAlreadyExistsError))
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async launch(input) {
|
|
100
|
+
const runId = input.runId;
|
|
101
|
+
// Low-power mode: the gateway may be scaled to zero; wake it before anything is created. Guarded
|
|
102
|
+
// so mode off adds no await.
|
|
103
|
+
if (this.power.enabled) {
|
|
104
|
+
await this.power.ensureAwake("gateway", {
|
|
105
|
+
timeoutMs: wakeTimeoutMs(this.options.readyTimeoutMs ?? 120_000),
|
|
106
|
+
runId,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const names = nativeKubernetesNames(runId);
|
|
110
|
+
if ((await this.inspect(runId)).state === "missing") {
|
|
111
|
+
const pod = buildNativeRunPod({
|
|
112
|
+
runId,
|
|
113
|
+
namespace: this.ns,
|
|
114
|
+
image: this.options.image,
|
|
115
|
+
limits: this.options.limits,
|
|
116
|
+
activeDeadlineSeconds: this.options.deadlineSeconds,
|
|
117
|
+
runtimeClassName: this.options.runtimeClassName,
|
|
118
|
+
platform: this.options.platform,
|
|
119
|
+
priorityClassName: this.options.priorityClassName,
|
|
120
|
+
});
|
|
121
|
+
const policy = buildNativeRunNetworkPolicy(runId, this.ns);
|
|
122
|
+
await this.createIsolated(pod, policy, () => this.remove(runId), {
|
|
123
|
+
// The Secret before the pod that mounts it.
|
|
124
|
+
beforePod: () => this.createIfMissing(() => this.api.createSecret(this.ns, buildNativeInputSecret(input, this.ns))),
|
|
125
|
+
});
|
|
126
|
+
await this.options.onNetworkProven(runId);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
k8sLog.info({ pod: names.pod }, "native worker pod already exists: attaching, not relaunching");
|
|
130
|
+
}
|
|
131
|
+
return this.handle(runId);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Policy first, so the pod never exists without it; then the pod; then attest and prove it. A
|
|
135
|
+
* quota rejection becomes native_sandbox_capacity. Any failure removes what was made.
|
|
136
|
+
*/
|
|
137
|
+
async createIsolated(pod, policy, cleanup, hooks = {}) {
|
|
138
|
+
await this.createIfMissing(() => this.api.createNetworkPolicy(this.ns, policy));
|
|
139
|
+
await hooks.beforePod?.();
|
|
140
|
+
try {
|
|
141
|
+
await this.createIfMissing(() => this.api.createPod(this.ns, pod));
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
if (!isQuotaRejection(err))
|
|
145
|
+
throw err;
|
|
146
|
+
await cleanup().catch(() => { });
|
|
147
|
+
throw new Error(`${NATIVE_SANDBOX_CAPACITY}: the namespace's ResourceQuota has no room for another native worker pod.`, { cause: err });
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
await this.attest(pod, policy);
|
|
151
|
+
await this.proveIsolation(pod.metadata.name);
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
await cleanup().catch(() => { });
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** What the API server stored must still be what was built: an admission change voids the run. */
|
|
159
|
+
async attest(built, builtPolicy) {
|
|
160
|
+
if (!(await this.matches(built, builtPolicy))) {
|
|
161
|
+
throw new Error(`${NATIVE_SANDBOX_ISOLATION_MISMATCH}: the worker pod or its NetworkPolicy as stored differs from what was built.`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async matches(built, builtPolicy) {
|
|
165
|
+
const [pod, policy] = await Promise.all([
|
|
166
|
+
this.api.readPod(this.ns, built.metadata.name),
|
|
167
|
+
this.api.readNetworkPolicy(this.ns, builtPolicy.metadata.name),
|
|
168
|
+
]);
|
|
169
|
+
const stored = pod?.spec;
|
|
170
|
+
const want = built.spec;
|
|
171
|
+
const same = (a, b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
|
|
172
|
+
const ok = !!stored &&
|
|
173
|
+
!!policy &&
|
|
174
|
+
same(policy.spec, builtPolicy.spec) &&
|
|
175
|
+
stored.automountServiceAccountToken === false &&
|
|
176
|
+
stored.hostNetwork !== true &&
|
|
177
|
+
stored.runtimeClassName === want.runtimeClassName &&
|
|
178
|
+
stored.priorityClassName === want.priorityClassName &&
|
|
179
|
+
stored.containers.length === 1 &&
|
|
180
|
+
stored.containers[0].image === want.containers[0].image &&
|
|
181
|
+
same(stored.containers[0].securityContext, want.containers[0].securityContext) &&
|
|
182
|
+
same(stored.containers[0].env, want.containers[0].env) &&
|
|
183
|
+
same(canonicalResources(stored.containers[0].resources), canonicalResources(want.containers[0].resources)) &&
|
|
184
|
+
(stored.initContainers ?? []).length === 0;
|
|
185
|
+
return ok;
|
|
186
|
+
}
|
|
187
|
+
/** Waits for the pod to run, then proves its egress from inside it (bounded). */
|
|
188
|
+
async proveIsolation(podName) {
|
|
189
|
+
const pollMs = this.options.pollMs ?? 1_000;
|
|
190
|
+
const readyBy = Date.now() + (this.options.readyTimeoutMs ?? 120_000);
|
|
191
|
+
for (;;) {
|
|
192
|
+
const pod = await this.api.readPod(this.ns, podName);
|
|
193
|
+
if (!pod)
|
|
194
|
+
throw new Error("native_sandbox_worker_lost: the worker pod disappeared before it was ready.");
|
|
195
|
+
const phase = pod.status?.phase ?? "Pending";
|
|
196
|
+
if (phase === "Running")
|
|
197
|
+
break;
|
|
198
|
+
if (phase === "Succeeded" || phase === "Failed") {
|
|
199
|
+
throw new Error(`native_sandbox_worker_exited: the worker pod ended before it was ready (${phase}).`);
|
|
200
|
+
}
|
|
201
|
+
if (Date.now() >= readyBy)
|
|
202
|
+
throw new Error("native_sandbox_worker_unready: the worker pod did not start in time.");
|
|
203
|
+
await this.sleep(pollMs);
|
|
204
|
+
}
|
|
205
|
+
const command = nativeEnforcementProbe(await this.resolveGatewayHost());
|
|
206
|
+
const provenBy = Date.now() + (this.options.enforcementTimeoutMs ?? 30_000);
|
|
207
|
+
let last;
|
|
208
|
+
for (;;) {
|
|
209
|
+
last = await this.api
|
|
210
|
+
.exec(this.ns, podName, NATIVE_WORKER_CONTAINER, command, { timeoutMs: 20_000 })
|
|
211
|
+
.catch(() => NATIVE_PROBE.gatewayUnreachable);
|
|
212
|
+
if (last === NATIVE_PROBE.proven)
|
|
213
|
+
return;
|
|
214
|
+
if (Date.now() >= provenBy)
|
|
215
|
+
break;
|
|
216
|
+
await this.sleep(pollMs);
|
|
217
|
+
}
|
|
218
|
+
const why = last === NATIVE_PROBE.denyReachable
|
|
219
|
+
? "the gateway's deny port was reachable"
|
|
220
|
+
: last === NATIVE_PROBE.outsideReachable
|
|
221
|
+
? "an outside address was reachable"
|
|
222
|
+
: "the gateway was unreachable";
|
|
223
|
+
throw new Error(`${NATIVE_SANDBOX_NETWORK_UNENFORCED}: the worker pod's isolation could not be proven (${why}).`);
|
|
224
|
+
}
|
|
225
|
+
handle(runId) {
|
|
226
|
+
return this.handleByName(nativeKubernetesNames(runId).pod);
|
|
227
|
+
}
|
|
228
|
+
handleByName(podName) {
|
|
229
|
+
const exited = (async () => {
|
|
230
|
+
for (;;) {
|
|
231
|
+
const state = await this.inspectByName(podName);
|
|
232
|
+
if (state.state === "exited")
|
|
233
|
+
return state.exitCode;
|
|
234
|
+
if (state.state === "missing")
|
|
235
|
+
return null;
|
|
236
|
+
await this.sleep(this.options.pollMs ?? 1_000);
|
|
237
|
+
}
|
|
238
|
+
})();
|
|
239
|
+
return { exited, kill: () => void this.api.deletePod(this.ns, podName, 0) };
|
|
240
|
+
}
|
|
241
|
+
async inspect(runId) {
|
|
242
|
+
return this.inspectByName(nativeKubernetesNames(runId).pod);
|
|
243
|
+
}
|
|
244
|
+
async inspectByName(podName) {
|
|
245
|
+
const pod = await this.api.readPod(this.ns, podName);
|
|
246
|
+
if (!pod)
|
|
247
|
+
return { state: "missing" };
|
|
248
|
+
const phase = pod.status?.phase;
|
|
249
|
+
if (phase === "Succeeded" || phase === "Failed") {
|
|
250
|
+
const terminated = pod.status?.containerStatuses?.find((c) => c.name === NATIVE_WORKER_CONTAINER)?.state
|
|
251
|
+
?.terminated;
|
|
252
|
+
return { state: "exited", exitCode: terminated?.exitCode ?? 137 };
|
|
253
|
+
}
|
|
254
|
+
return { state: "running" };
|
|
255
|
+
}
|
|
256
|
+
async kill(runId) {
|
|
257
|
+
await this.api.deletePod(this.ns, nativeKubernetesNames(runId).pod, 0);
|
|
258
|
+
}
|
|
259
|
+
async remove(runId) {
|
|
260
|
+
await this.removeByWorkerName(nativeKubernetesNames(runId).pod);
|
|
261
|
+
}
|
|
262
|
+
async listWorkers() {
|
|
263
|
+
const pods = await this.api.listPods(this.ns, NATIVE_RUN_SELECTOR);
|
|
264
|
+
return pods
|
|
265
|
+
.map((pod) => ({ name: pod.metadata?.name ?? "", runHash: pod.metadata?.labels?.[NATIVE_RUN_SHA_LABEL] ?? "" }))
|
|
266
|
+
.filter((worker) => worker.name && worker.runHash);
|
|
267
|
+
}
|
|
268
|
+
async removeByWorkerName(name) {
|
|
269
|
+
if (!/^wardby-native-[0-9a-f]{20}$/.test(name))
|
|
270
|
+
return;
|
|
271
|
+
await this.api.deletePod(this.ns, name, 0);
|
|
272
|
+
await this.api.deleteSecret(this.ns, `${name}-input`);
|
|
273
|
+
await this.api.deleteNetworkPolicy(this.ns, name);
|
|
274
|
+
}
|
|
275
|
+
// --- Warm pool workers (native sandbox phase 6) ---
|
|
276
|
+
warmPod(token, waitMs) {
|
|
277
|
+
return buildNativeWarmPod({
|
|
278
|
+
token,
|
|
279
|
+
waitMs,
|
|
280
|
+
namespace: this.ns,
|
|
281
|
+
image: this.options.image,
|
|
282
|
+
limits: this.options.limits,
|
|
283
|
+
// A claimed worker keeps the run's whole deadline however long it waited.
|
|
284
|
+
activeDeadlineSeconds: Math.ceil(waitMs / 1000) + this.options.deadlineSeconds,
|
|
285
|
+
runtimeClassName: this.options.runtimeClassName,
|
|
286
|
+
platform: this.options.platform,
|
|
287
|
+
priorityClassName: this.options.priorityClassName,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
warmSpecHash(waitMs) {
|
|
291
|
+
const { image, limits, runtimeClassName, platform, priorityClassName, gatewayService, gatewayUrl } = this.options;
|
|
292
|
+
const spec = { launcher: "kubernetes", ns: this.ns, image, limits, runtimeClassName, platform, priorityClassName };
|
|
293
|
+
return createHash("sha256")
|
|
294
|
+
.update(JSON.stringify({ ...spec, gatewayService, gatewayUrl, waitMs }))
|
|
295
|
+
.digest("hex");
|
|
296
|
+
}
|
|
297
|
+
async startWarm(token, waitMs) {
|
|
298
|
+
await this.createIsolated(this.warmPod(token, waitMs), buildNativeWarmNetworkPolicy(token, this.ns), () => this.removeWarm(token));
|
|
299
|
+
}
|
|
300
|
+
async reattestWarm(token, waitMs) {
|
|
301
|
+
const pod = await this.api.readPod(this.ns, nativeWarmWorkerName(token));
|
|
302
|
+
if (pod?.status?.phase !== "Running")
|
|
303
|
+
return false;
|
|
304
|
+
return this.matches(this.warmPod(token, waitMs), buildNativeWarmNetworkPolicy(token, this.ns));
|
|
305
|
+
}
|
|
306
|
+
async deliver(token, input) {
|
|
307
|
+
const code = await this.api.exec(this.ns, nativeWarmWorkerName(token), NATIVE_WORKER_CONTAINER, warmDeliveryCommand(), {
|
|
308
|
+
timeoutMs: 20_000,
|
|
309
|
+
stdin: Readable.from([`${JSON.stringify(input)}\n`]),
|
|
310
|
+
});
|
|
311
|
+
if (code !== 0) {
|
|
312
|
+
throw new Error(`${NATIVE_SANDBOX_WARM_DELIVERY_FAILED}: delivering the run's input exited ${code}.`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
warmHandle(token) {
|
|
316
|
+
return this.handleByName(nativeWarmWorkerName(token));
|
|
317
|
+
}
|
|
318
|
+
inspectWarm(token) {
|
|
319
|
+
return this.inspectByName(nativeWarmWorkerName(token));
|
|
320
|
+
}
|
|
321
|
+
async killWarm(token) {
|
|
322
|
+
await this.api.deletePod(this.ns, nativeWarmWorkerName(token), 0);
|
|
323
|
+
}
|
|
324
|
+
async removeWarm(token) {
|
|
325
|
+
const name = nativeWarmWorkerName(token);
|
|
326
|
+
await this.api.deletePod(this.ns, name, 0);
|
|
327
|
+
await this.api.deleteNetworkPolicy(this.ns, name);
|
|
328
|
+
}
|
|
329
|
+
async listWarm() {
|
|
330
|
+
const pods = await this.api.listPods(this.ns, NATIVE_WARM_SELECTOR);
|
|
331
|
+
return pods
|
|
332
|
+
.map((pod) => pod.metadata?.labels?.[NATIVE_WARM_TOKEN_LABEL] ?? "")
|
|
333
|
+
.filter((token) => /^[0-9a-f]{20}$/.test(token));
|
|
334
|
+
}
|
|
335
|
+
}
|