@moondeck/station 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +149 -2
- package/bin/uai-host.mjs +130 -0
- package/db/migrations/0000_host_tasks.sql +12 -0
- package/db/migrations/0001_host_ui.sql +11 -0
- package/db/migrations/0002_host_github_tokens.sql +8 -0
- package/db/migrations/0003_host_ssh_keys.sql +8 -0
- package/db/migrations/0004_host_owner_name.sql +1 -0
- package/db/migrations/0005_host_env_vars.sql +5 -0
- package/db/migrations/0006_host_project_env.sql +9 -0
- package/db/migrations/0007_host_github_token_kind.sql +7 -0
- package/db/migrations/0008_host_mcp_connections.sql +18 -0
- package/db/migrations/0009_host_agent_sessions.sql +15 -0
- package/db/migrations/0010_host_engine_accounts.sql +13 -0
- package/db/migrations/0011_codex_thread_id.sql +5 -0
- package/db/migrations/0012_agent_session_attach_key.sql +4 -0
- package/db/migrations/0013_github_credential_generations.sql +11 -0
- package/db/migrations/0014_host_inventory_event_index.sql +1 -0
- package/db/migrations/0015_host_settings.sql +9 -0
- package/db/migrations/0016_task_environment.sql +2 -0
- package/db/migrations/0017_engine_account_scope.sql +7 -0
- package/db/migrations/0018_engine_account_position.sql +3 -0
- package/db/migrations/0019_engine_account_store_ref.sql +3 -0
- package/db/migrations/0020_user_credential_store_ref.sql +6 -0
- package/db/migrations/0021_project_env_store_ref.sql +3 -0
- package/db/migrations/0022_task_project_ids.sql +5 -0
- package/db/migrations/0023_project_env_org.sql +4 -0
- package/db/migrations/0024_task_env_mode.sql +9 -0
- package/db/migrations/0025_project_env_authority.sql +147 -0
- package/db/migrations/0026_project_env_replica.sql +150 -0
- package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/0029_task_shared_files_mode.sql +5 -0
- package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
- package/db/migrations/meta/_journal.json +223 -0
- package/db/schema.ts +850 -0
- package/images/standard/Dockerfile +347 -0
- package/images/standard/README.md +167 -0
- package/images/standard/container/code-server-settings.json +63 -0
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +704 -0
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/images/standard/container/uai-task-forwarder.mjs +303 -0
- package/images/standard/tool-versions +2 -0
- package/lib/__fixtures__/skill-index/SKILL.md +10 -0
- package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
- package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
- package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
- package/lib/agent-cli.ts +1507 -0
- package/lib/agent-errors.ts +22 -0
- package/lib/agent.ts +672 -0
- package/lib/agents/attach-compatibility.ts +51 -0
- package/lib/agents/claude.ts +918 -0
- package/lib/agents/codex-token-usage.fixture.json +130 -0
- package/lib/agents/codex.ts +1392 -0
- package/lib/agents/cursor-period-usage.ts +253 -0
- package/lib/agents/cursor.ts +562 -0
- package/lib/agents/dispatch.ts +78 -0
- package/lib/agents/durable-proc.ts +334 -0
- package/lib/agents/factory.ts +57 -0
- package/lib/agents/grok.ts +381 -0
- package/lib/agents/kimi.ts +329 -0
- package/lib/agents/machine-durable.ts +604 -0
- package/lib/agents/mock.ts +133 -0
- package/lib/agents/mode.ts +64 -0
- package/lib/agents/opencode.ts +508 -0
- package/lib/agents/output-source.ts +3 -0
- package/lib/agents/proc.ts +221 -0
- package/lib/agents/rate-limit.ts +337 -0
- package/lib/agents/registry.ts +182 -0
- package/lib/agents/session-state.ts +197 -0
- package/lib/agents/skill-index-preamble.fixture.ts +26 -0
- package/lib/agents/transport.ts +741 -0
- package/lib/agents/types.ts +254 -0
- package/lib/agents/uai.ts +106 -0
- package/lib/agents/usage.ts +44 -0
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +237 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/assignment-evidence.ts +94 -0
- package/lib/attachments.ts +48 -0
- package/lib/browser-testing.ts +1537 -0
- package/lib/capture-flow.ts +332 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2587 -0
- package/lib/cloud-credential-runtime.ts +1357 -0
- package/lib/cloud-state.ts +56 -0
- package/lib/codex-auth.ts +456 -0
- package/lib/command-db.ts +310 -0
- package/lib/compose-network-recovery.ts +283 -0
- package/lib/connection-secret-redaction.ts +25 -0
- package/lib/container-runtime.ts +1453 -0
- package/lib/credential-cleanup-inventory.ts +490 -0
- package/lib/credential-consumers/engine-anchor.ts +1880 -0
- package/lib/credential-consumers/engines.ts +797 -0
- package/lib/credential-consumers/files.ts +934 -0
- package/lib/credential-consumers/github.ts +702 -0
- package/lib/credential-consumers/mcp.ts +389 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-consumers/task-secrets.ts +10 -0
- package/lib/credential-store-pickup.ts +149 -0
- package/lib/credential-store-scope.ts +112 -0
- package/lib/credential-store.ts +932 -0
- package/lib/credentials-activation-authority.ts +709 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/custom-connection-capture.ts +395 -0
- package/lib/custom-connection-launch.ts +384 -0
- package/lib/custom-connection-runtime.ts +141 -0
- package/lib/db.ts +80 -0
- package/lib/disk-pressure.ts +42 -0
- package/lib/docker-exec.ts +156 -0
- package/lib/engine-accounts.ts +1970 -0
- package/lib/engine-connect.ts +321 -0
- package/lib/engine-credential-env.ts +52 -0
- package/lib/engine-login.ts +2933 -0
- package/lib/engines.ts +1342 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +246 -0
- package/lib/env-schema.ts +891 -0
- package/lib/env.ts +195 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +530 -0
- package/lib/git-identity.ts +715 -0
- package/lib/github-git-auth.ts +207 -0
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +2439 -0
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-env.ts +424 -0
- package/lib/host-logs.ts +965 -0
- package/lib/internal-connections.ts +5 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/launch-mode.ts +25 -0
- package/lib/lease.ts +239 -0
- package/lib/legacy-host.ts +769 -0
- package/lib/legacy-runtime-retirement.ts +424 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/machine-exec.ts +238 -0
- package/lib/machine-gateway-tunnel.ts +344 -0
- package/lib/machine-keys.ts +65 -0
- package/lib/machine-project-env-loader.ts +152 -0
- package/lib/machine-provider-aws.ts +923 -0
- package/lib/machine-provider-local.ts +338 -0
- package/lib/machine-provider.ts +90 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +57 -0
- package/lib/managed-runtime.ts +4296 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-capture.ts +865 -0
- package/lib/mcp-config-lock.ts +2 -0
- package/lib/mcp-connections.ts +959 -0
- package/lib/mcp-gateway.ts +2594 -0
- package/lib/obs.ts +512 -0
- package/lib/openrouter-budget.ts +247 -0
- package/lib/orchestrator.ts +11409 -0
- package/lib/person-capture.ts +327 -0
- package/lib/preview-ports.ts +85 -0
- package/lib/preview-sidecar.ts +331 -0
- package/lib/project-env-authority.ts +1319 -0
- package/lib/project-env-feature.ts +74 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-loader.ts +242 -0
- package/lib/project-env-replica.ts +2094 -0
- package/lib/project-env-schema-capture.ts +342 -0
- package/lib/project-env-staging.ts +148 -0
- package/lib/project-env-task-policy.ts +176 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/release-feed.ts +61 -0
- package/lib/release-manifest.ts +710 -0
- package/lib/release-trust.ts +46 -0
- package/lib/repo-clone.ts +38 -0
- package/lib/routing/shadow.ts +261 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +279 -0
- package/lib/runtime-archive.ts +1216 -0
- package/lib/runtime-authority.ts +166 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +1033 -0
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/address.ts +95 -0
- package/lib/secrets-proxy/audit.ts +30 -0
- package/lib/secrets-proxy/ca-store.ts +200 -0
- package/lib/secrets-proxy/ca.ts +470 -0
- package/lib/secrets-proxy/container-steps.ts +138 -0
- package/lib/secrets-proxy/der.ts +273 -0
- package/lib/secrets-proxy/engine.ts +1633 -0
- package/lib/secrets-proxy/host.ts +93 -0
- package/lib/secrets-proxy/hostname.ts +57 -0
- package/lib/secrets-proxy/placeholders.ts +196 -0
- package/lib/secrets-proxy/policy.ts +150 -0
- package/lib/secrets-proxy/readiness.ts +148 -0
- package/lib/secrets-proxy/runtime.ts +582 -0
- package/lib/secrets-proxy/scrub.ts +559 -0
- package/lib/secrets-proxy/sidecar-view.ts +139 -0
- package/lib/secrets-proxy/task-scripts.ts +262 -0
- package/lib/secrets-proxy/task-trust.ts +605 -0
- package/lib/secrets-proxy/trust-watch.ts +80 -0
- package/lib/secrets.ts +71 -0
- package/lib/shared-files-s3.ts +976 -0
- package/lib/shared-files.ts +156 -0
- package/lib/sidecar-assign.ts +49 -0
- package/lib/sidecar-guard.ts +58 -0
- package/lib/sidecar-resume.ts +178 -0
- package/lib/sidecar.ts +69 -0
- package/lib/signin-box-credentials.ts +465 -0
- package/lib/signin-box.ts +114 -0
- package/lib/skill-index-example.fixture.ts +56 -0
- package/lib/skill-layout.ts +417 -0
- package/lib/skills.ts +489 -0
- package/lib/ssh.ts +401 -0
- package/lib/standard-image-cleanup.ts +117 -0
- package/lib/standard-image-pin.ts +207 -0
- package/lib/standard-image.ts +1697 -0
- package/lib/stopped-task-status-queue.ts +120 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-credential-cleanup.ts +752 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +91 -0
- package/lib/task-env-forwarder.ts +228 -0
- package/lib/task-env-source.ts +249 -0
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +860 -0
- package/lib/task-environment/docker.ts +1344 -0
- package/lib/task-environment/index.ts +466 -0
- package/lib/task-environment/legacy-adoption.ts +467 -0
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +1227 -0
- package/lib/task-environment/machine.ts +974 -0
- package/lib/task-environment/registry.ts +59 -0
- package/lib/task-environment/self-identity.ts +20 -0
- package/lib/task-environment/self.ts +566 -0
- package/lib/task-environment/types.ts +642 -0
- package/lib/task-environment/workspace-files.ts +685 -0
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-files-machine-retirement-outbox.ts +592 -0
- package/lib/task-files-machine-retirement.ts +116 -0
- package/lib/task-files-readiness.ts +127 -0
- package/lib/task-gateway-port.ts +22 -0
- package/lib/task-gateway-protocol.ts +190 -0
- package/lib/task-gateway-recovery.ts +47 -0
- package/lib/task-gateway-state.ts +512 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +682 -0
- package/lib/task-machine-credentials-activation.ts +95 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/task-person-credentials.ts +76 -0
- package/lib/task-project-ops-host.ts +147 -0
- package/lib/task-project-ops.ts +1062 -0
- package/lib/task-secret-staging.ts +16 -0
- package/lib/task-status.ts +46 -0
- package/lib/task-token.ts +46 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +105 -0
- package/lib/token-broker-local.ts +392 -0
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +525 -0
- package/lib/token-broker.ts +218 -0
- package/lib/token-lanes/openai-oauth.ts +123 -0
- package/lib/token-lanes/registry.ts +45 -0
- package/lib/token-lanes/types.ts +163 -0
- package/lib/token-lanes/xai-oauth.ts +165 -0
- package/lib/transcript.ts +128 -0
- package/lib/tunnel-registry.ts +220 -0
- package/lib/tunnel-runtime.ts +235 -0
- package/lib/ulid.ts +16 -0
- package/lib/user-bus-env.ts +116 -0
- package/lib/user-credentials-store.ts +435 -0
- package/lib/vnode-pressure.ts +142 -0
- package/package.json +131 -4
- package/runner/runner.mjs +442 -0
- package/scripts/agent/_common.sh +633 -0
- package/scripts/agent/env-hook.sh +626 -0
- package/scripts/agent/moondeck-cli.sh +5 -0
- package/scripts/agent/task-down.sh +291 -0
- package/scripts/agent/task-status.sh +70 -0
- package/scripts/agent/task-up.sh +3761 -0
- package/scripts/agent/uai-env-exec.sh +26 -0
- package/scripts/install/darwin.ts +1048 -0
- package/scripts/install/linux.ts +958 -0
- package/scripts/install/types.ts +78 -0
- package/scripts/install/util.ts +254 -0
- package/scripts/install/win.ts +143 -0
- package/src/activation-respawn.ts +155 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/capture-wire.ts +102 -0
- package/src/cli.ts +2583 -0
- package/src/cloud-credentials.ts +2144 -0
- package/src/cloud-origins.ts +23 -0
- package/src/credential-location.ts +248 -0
- package/src/credential-wire-validation.ts +25 -0
- package/src/custom-connections.ts +237 -0
- package/src/engine-account-paths.ts +11 -0
- package/src/engine-models.ts +39 -0
- package/src/engine-usage.ts +104 -0
- package/src/event-outbox.ts +185 -0
- package/src/index.ts +2899 -0
- package/src/load-env.ts +97 -0
- package/src/machine-tunnel-route.ts +156 -0
- package/src/main.ts +6349 -0
- package/src/never-proxied-domains.ts +31 -0
- package/src/outbound-frame.ts +31 -0
- package/src/paths.ts +81 -0
- package/src/project-env-latest.ts +130 -0
- package/src/project-env-policy.ts +396 -0
- package/src/project-env-wording.ts +53 -0
- package/src/protocol.ts +5545 -0
- package/src/runtime-bootstrap.ts +165 -0
- package/src/station-identity.ts +96 -0
- package/src/task-env-names.ts +28 -0
- package/src/task-secrets.ts +75 -0
- package/src/tunnel-dial.ts +102 -0
- package/src/ui/server.ts +852 -0
- package/src/ui/types.ts +173 -0
- package/src/workspace-path-policy.ts +41 -0
- package/ui/app.js +1102 -0
- package/ui/index.html +90 -0
- package/ui/style.css +685 -0
- package/ui/uai-favicon.svg +21 -0
- package/ui/uai-logo-black.svg +9 -0
- package/ui/uai-wheel.svg +9 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-043: per-(task, preview) node-proxy sidecar for ad-hoc preview ports.
|
|
3
|
+
*
|
|
4
|
+
* Ad-hoc preview ports aren't Docker-published, and the app container's bridge
|
|
5
|
+
* IP isn't host-routable on macOS/OrbStack (EHOSTUNREACH). So proxy through a
|
|
6
|
+
* tiny sidecar: a container on the task's compose network that PUBLISHES a
|
|
7
|
+
* `127.0.0.1` host port (reachable on every backend) and forwards to
|
|
8
|
+
* `<app-container>:<containerPort>`. The sidecar runs the task's OWN image
|
|
9
|
+
* (always local — no registry pull) under `node` with a ~5-line `net` TCP proxy;
|
|
10
|
+
* node streams give backpressure + handle WebSockets (Metro/HMR).
|
|
11
|
+
*
|
|
12
|
+
* The cache deduplicates creation, but every tunnel open re-proves the exact
|
|
13
|
+
* sidecar container and its current Docker mapping. A process-lifetime cached
|
|
14
|
+
* localhost port is unsafe across an unnoticed daemon restart: another process
|
|
15
|
+
* can inherit that port before the bridge reconnects and triggers recovery.
|
|
16
|
+
*/
|
|
17
|
+
import net from "node:net";
|
|
18
|
+
|
|
19
|
+
import { dockerCli } from "./docker-exec";
|
|
20
|
+
import { inspectTunnelContainer } from "./tunnel-runtime";
|
|
21
|
+
|
|
22
|
+
/** Port the proxy listens on inside the sidecar (published to a random host port). */
|
|
23
|
+
const INNER_PORT = 9000;
|
|
24
|
+
/**
|
|
25
|
+
* Exported for every "which container is the task's APP" scan: sidecars can
|
|
26
|
+
* legitimately wear the task's compose project/service labels (teardown
|
|
27
|
+
* finds them by project), so any container carrying THIS label must be
|
|
28
|
+
* excluded before counting app containers. Live 2026-08-18: one browser
|
|
29
|
+
* sidecar with compose labels made legacy adoption count two app containers
|
|
30
|
+
* and fail the whole runtime selection closed — every task on the host read
|
|
31
|
+
* as "still starting".
|
|
32
|
+
*/
|
|
33
|
+
export const PREVIEW_SIDECAR_TASK_LABEL = "io.moondeck.preview-sidecar.task";
|
|
34
|
+
const LABEL = PREVIEW_SIDECAR_TASK_LABEL;
|
|
35
|
+
const TARGET_LABEL = "io.moondeck.preview-sidecar.target";
|
|
36
|
+
|
|
37
|
+
/** (taskId,name,containerPort) -> published 127.0.0.1 host port of a live sidecar. */
|
|
38
|
+
const cache = new Map<string, number>();
|
|
39
|
+
/** In-flight creates, so concurrent requests for the same key share one sidecar. */
|
|
40
|
+
const inflight = new Map<string, Promise<number | null>>();
|
|
41
|
+
|
|
42
|
+
function cacheKey(taskId: string, name: string, containerPort: number): string {
|
|
43
|
+
return `${taskId} ${name} ${containerPort}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sidecarName(taskId: string, name: string): string {
|
|
47
|
+
return `uai-preview-${taskId}-${name}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function proxyScript(appContainer: string, containerPort: number): string {
|
|
51
|
+
// Bidirectional TCP pipe: 0.0.0.0:INNER_PORT <-> appContainer:containerPort.
|
|
52
|
+
return (
|
|
53
|
+
`const net=require('net');` +
|
|
54
|
+
`net.createServer(c=>{` +
|
|
55
|
+
`const u=net.connect(${containerPort},${JSON.stringify(appContainer)});` +
|
|
56
|
+
`c.on('error',()=>u.destroy());u.on('error',()=>c.destroy());` +
|
|
57
|
+
`c.pipe(u);u.pipe(c);` +
|
|
58
|
+
`}).listen(${INNER_PORT},'0.0.0.0');`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** How long to wait for a new sidecar's proxy to start accepting. */
|
|
63
|
+
const READY_TIMEOUT_MS = 10_000;
|
|
64
|
+
const READY_POLL_MS = 100;
|
|
65
|
+
/** A connection that survives this long AFTER connecting has a live upstream. */
|
|
66
|
+
const PROBE_SETTLE_MS = 150;
|
|
67
|
+
/** A handshake that hasn't completed by now isn't going to. */
|
|
68
|
+
const PROBE_CONNECT_MS = 1_000;
|
|
69
|
+
|
|
70
|
+
/** The bit of `net.Socket` the probe uses — so a test can supply its own. */
|
|
71
|
+
interface SocketLike {
|
|
72
|
+
on: (event: string, fn: () => void) => unknown;
|
|
73
|
+
destroy: () => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether the whole chain behind `hostPort` is live: the proxy is listening AND
|
|
78
|
+
* its upstream connect succeeded.
|
|
79
|
+
*
|
|
80
|
+
* A plain "did the TCP connect succeed" check proves nothing here, which is the
|
|
81
|
+
* trap this exists for. Docker publishes the port when the CONTAINER starts,
|
|
82
|
+
* not when the process inside binds — so `docker-proxy` accepts the connection
|
|
83
|
+
* either way and then closes it, and an HTTP client reports that as
|
|
84
|
+
* `socket hang up`. The sidecar's proxy behaves identically when it is up but
|
|
85
|
+
* its own upstream connect fails (`u.on('error',()=>c.destroy())`).
|
|
86
|
+
*
|
|
87
|
+
* So the signal is the connection SURVIVING briefly, not opening. Both failure
|
|
88
|
+
* modes close it immediately; a healthy pipe just sits there waiting for a
|
|
89
|
+
* request, which is the one thing neither of them does.
|
|
90
|
+
*/
|
|
91
|
+
function probeSidecar(
|
|
92
|
+
hostPort: number,
|
|
93
|
+
/** Seam for tests: a socket that never connects can't be produced locally. */
|
|
94
|
+
connect: (port: number) => SocketLike = (port) =>
|
|
95
|
+
net.connect(port, "127.0.0.1"),
|
|
96
|
+
): Promise<boolean> {
|
|
97
|
+
return new Promise((resolve) => {
|
|
98
|
+
const socket = connect(hostPort);
|
|
99
|
+
let settled = false;
|
|
100
|
+
let settleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
101
|
+
const done = (ok: boolean): void => {
|
|
102
|
+
if (settled) return;
|
|
103
|
+
settled = true;
|
|
104
|
+
clearTimeout(settleTimer);
|
|
105
|
+
clearTimeout(connectTimer);
|
|
106
|
+
socket.destroy();
|
|
107
|
+
resolve(ok);
|
|
108
|
+
};
|
|
109
|
+
// The settle window starts at `connect`, NOT at the call. Starting it here
|
|
110
|
+
// would count a connection still stuck in the handshake as healthy — the
|
|
111
|
+
// one state that is neither of the two we can distinguish, and the one a
|
|
112
|
+
// wedged host is most likely to be in.
|
|
113
|
+
//
|
|
114
|
+
// The connect deadline is disarmed at the same moment, or the two overlap:
|
|
115
|
+
// a connection established at 900ms would be failed at 1000ms, 50ms into a
|
|
116
|
+
// 150ms settle window it was on course to pass. The deadlines are
|
|
117
|
+
// sequential — first "did it connect", then "did it stay" — so only one may
|
|
118
|
+
// be running.
|
|
119
|
+
socket.on("connect", () => {
|
|
120
|
+
clearTimeout(connectTimer);
|
|
121
|
+
settleTimer = setTimeout(() => done(true), PROBE_SETTLE_MS);
|
|
122
|
+
});
|
|
123
|
+
const connectTimer = setTimeout(() => done(false), PROBE_CONNECT_MS);
|
|
124
|
+
socket.on("error", () => done(false));
|
|
125
|
+
socket.on("close", () => done(false));
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Test seam: the probe with an injectable socket. Not used in production. */
|
|
130
|
+
export const probeForTest = probeSidecar;
|
|
131
|
+
|
|
132
|
+
/** Poll `probeSidecar` until the chain answers, or give up. */
|
|
133
|
+
async function waitUntilReady(hostPort: number): Promise<boolean> {
|
|
134
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
135
|
+
for (;;) {
|
|
136
|
+
if (await probeSidecar(hostPort)) return true;
|
|
137
|
+
if (Date.now() >= deadline) return false;
|
|
138
|
+
await new Promise((r) => setTimeout(r, READY_POLL_MS));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function imageOf(appContainer: string): Promise<string | null> {
|
|
143
|
+
const r = await dockerCli(
|
|
144
|
+
["inspect", "-f", "{{.Config.Image}}", appContainer],
|
|
145
|
+
{ timeoutMs: 5_000 },
|
|
146
|
+
);
|
|
147
|
+
return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Live Docker proof -> the published 127.0.0.1 host port and labels, or null. */
|
|
151
|
+
async function publishedSidecar(
|
|
152
|
+
name: string,
|
|
153
|
+
): Promise<{ port: number; labels: Record<string, string> } | null> {
|
|
154
|
+
const proof = await inspectTunnelContainer(name, INNER_PORT);
|
|
155
|
+
return proof.kind === "running" && proof.published?.host === "127.0.0.1"
|
|
156
|
+
? { port: proof.published.port, labels: proof.labels }
|
|
157
|
+
: null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function publishedPort(name: string): Promise<number | null> {
|
|
161
|
+
return (await publishedSidecar(name))?.port ?? null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function createSidecar(args: {
|
|
165
|
+
taskId: string;
|
|
166
|
+
composeProject: string;
|
|
167
|
+
name: string;
|
|
168
|
+
containerPort: number;
|
|
169
|
+
}): Promise<number | null> {
|
|
170
|
+
const name = sidecarName(args.taskId, args.name);
|
|
171
|
+
const appContainer = `${args.composeProject}-app-1`;
|
|
172
|
+
const target = `${appContainer}:${args.containerPort}`;
|
|
173
|
+
|
|
174
|
+
// Reuse a still-running sidecar from a previous host process (--rm keeps it up
|
|
175
|
+
// across a host restart; the in-memory cache was cleared). `docker port` is an
|
|
176
|
+
// exact-name lookup, so no name-filter regex pitfalls.
|
|
177
|
+
//
|
|
178
|
+
// PROBED, not just found. A sidecar outlives the app container it proxies to,
|
|
179
|
+
// so one left over from a previous container generation is still running and
|
|
180
|
+
// still publishing its port while its route is dead. Reused blind, that
|
|
181
|
+
// sidecar serves `socket hang up` forever: the recovery path invalidates the
|
|
182
|
+
// CACHE on a failed connect, then lands right back here and returns the same
|
|
183
|
+
// corpse. Nothing could break the cycle short of removing it by hand.
|
|
184
|
+
const existing = await publishedSidecar(name);
|
|
185
|
+
if (existing !== null) {
|
|
186
|
+
// The Docker name is stable per (task, preview name), while a task-scoped
|
|
187
|
+
// preview may later point that name at another container port. The
|
|
188
|
+
// process-local cache key carries the port, but it vanishes on host
|
|
189
|
+
// restart; without a durable target label a healthy old proxy would be
|
|
190
|
+
// reused and silently route the authenticated request to the wrong port.
|
|
191
|
+
// Legacy unlabeled sidecars rebuild once.
|
|
192
|
+
if (
|
|
193
|
+
existing.labels[TARGET_LABEL] === target &&
|
|
194
|
+
(await probeSidecar(existing.port))
|
|
195
|
+
) {
|
|
196
|
+
return existing.port;
|
|
197
|
+
}
|
|
198
|
+
await dockerCli(["rm", "-f", name], { timeoutMs: 10_000 });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const image = await imageOf(appContainer);
|
|
202
|
+
if (!image) return null;
|
|
203
|
+
|
|
204
|
+
await dockerCli(["rm", "-f", name], { timeoutMs: 10_000 });
|
|
205
|
+
const run = await dockerCli(
|
|
206
|
+
[
|
|
207
|
+
"run", "-d", "--rm",
|
|
208
|
+
"--name", name,
|
|
209
|
+
"--network", `${args.composeProject}_default`,
|
|
210
|
+
"--label", `${LABEL}=${args.taskId}`,
|
|
211
|
+
"--label", `${TARGET_LABEL}=${target}`,
|
|
212
|
+
"-p", `127.0.0.1::${INNER_PORT}`,
|
|
213
|
+
"--entrypoint", "node",
|
|
214
|
+
image,
|
|
215
|
+
"-e", proxyScript(appContainer, args.containerPort),
|
|
216
|
+
],
|
|
217
|
+
{ timeoutMs: 20_000 },
|
|
218
|
+
);
|
|
219
|
+
if (run.status !== 0) return null;
|
|
220
|
+
|
|
221
|
+
const hostPort = await publishedPort(name);
|
|
222
|
+
if (hostPort === null) {
|
|
223
|
+
await dockerCli(["rm", "-f", name], { timeoutMs: 10_000 });
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Do not hand back a port that isn't answering yet. `docker run -d` returns
|
|
228
|
+
// when the CONTAINER starts and the mapping exists from that instant, but the
|
|
229
|
+
// node proxy inside has not called `listen` — so every request arriving in
|
|
230
|
+
// that window is accepted by docker-proxy and dropped.
|
|
231
|
+
//
|
|
232
|
+
// A browser makes that window very expensive. It fetches the document, then
|
|
233
|
+
// fires every sub-resource at once: one page load put ~20 requests into a
|
|
234
|
+
// ~3s gap and lost all of them, which renders as an unstyled page with broken
|
|
235
|
+
// images rather than as anything resembling "not ready yet".
|
|
236
|
+
if (!(await waitUntilReady(hostPort))) {
|
|
237
|
+
await dockerCli(["rm", "-f", name], { timeoutMs: 10_000 });
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
return hostPort;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Ensure a sidecar is proxying `<app>:<containerPort>` for (taskId, name);
|
|
245
|
+
* returns the `127.0.0.1` host port it publishes, or null on failure. Cache-only
|
|
246
|
+
* on the hot path; a cache miss creates the sidecar (deduped across concurrent
|
|
247
|
+
* callers).
|
|
248
|
+
*/
|
|
249
|
+
export async function ensurePreviewSidecar(args: {
|
|
250
|
+
taskId: string;
|
|
251
|
+
composeProject: string;
|
|
252
|
+
name: string;
|
|
253
|
+
containerPort: number;
|
|
254
|
+
}): Promise<number | null> {
|
|
255
|
+
const key = cacheKey(args.taskId, args.name, args.containerPort);
|
|
256
|
+
const cached = cache.get(key);
|
|
257
|
+
if (cached !== undefined) {
|
|
258
|
+
const current = await publishedSidecar(sidecarName(args.taskId, args.name));
|
|
259
|
+
const expectedTarget =
|
|
260
|
+
`${args.composeProject}-app-1:${args.containerPort}`;
|
|
261
|
+
const app =
|
|
262
|
+
current?.port === cached &&
|
|
263
|
+
current.labels[TARGET_LABEL] === expectedTarget
|
|
264
|
+
? await inspectTunnelContainer(
|
|
265
|
+
`${args.composeProject}-app-1`,
|
|
266
|
+
args.containerPort,
|
|
267
|
+
)
|
|
268
|
+
: null;
|
|
269
|
+
// The sidecar's PID is the proxy itself, and it resolves the app by stable
|
|
270
|
+
// Compose DNS name for each connection. Proving both exact containers are
|
|
271
|
+
// running avoids the old 150ms socket-settle penalty on every browser asset
|
|
272
|
+
// while still rejecting a localhost port inherited after daemon restart.
|
|
273
|
+
if (
|
|
274
|
+
current?.port === cached &&
|
|
275
|
+
current.labels[TARGET_LABEL] === expectedTarget &&
|
|
276
|
+
app?.kind === "running"
|
|
277
|
+
) {
|
|
278
|
+
return cached;
|
|
279
|
+
}
|
|
280
|
+
cache.delete(key);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
let pending = inflight.get(key);
|
|
284
|
+
if (!pending) {
|
|
285
|
+
pending = createSidecar(args)
|
|
286
|
+
.then((port) => {
|
|
287
|
+
if (port !== null) cache.set(key, port);
|
|
288
|
+
return port;
|
|
289
|
+
})
|
|
290
|
+
.finally(() => inflight.delete(key));
|
|
291
|
+
inflight.set(key, pending);
|
|
292
|
+
}
|
|
293
|
+
return pending;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Drop the cached port for a sidecar whose route just failed. No-op for
|
|
298
|
+
* published-declared / container-IP targets, which aren't in this cache.
|
|
299
|
+
*
|
|
300
|
+
* CACHE-ONLY, deliberately. This fires from the host's `upstream.on("error")`,
|
|
301
|
+
* which cannot tell a dead sidecar from a client that hit stop mid-download or
|
|
302
|
+
* a request that tripped the tunnel's ack deadline — both destroy the socket
|
|
303
|
+
* with ECONNRESET. Removing the container here would let one abandoned asset
|
|
304
|
+
* kill a sidecar that is serving twenty other requests fine, which is a worse
|
|
305
|
+
* failure than the one being recovered from.
|
|
306
|
+
*
|
|
307
|
+
* Forgetting the port is enough now: the next `createSidecar` finds the
|
|
308
|
+
* container by name and PROBES it, so a genuinely dead one is removed there,
|
|
309
|
+
* where the evidence is direct rather than inferred. That is the escape hatch
|
|
310
|
+
* this used to lack — invalidation alone kept landing back on the same corpse.
|
|
311
|
+
*/
|
|
312
|
+
export function invalidatePreviewSidecar(hostPort: number): void {
|
|
313
|
+
for (const [key, port] of cache) {
|
|
314
|
+
if (port === hostPort) cache.delete(key);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Stop + remove all preview sidecars for a task (called on task-down). */
|
|
319
|
+
export async function stopPreviewSidecars(taskId: string): Promise<void> {
|
|
320
|
+
for (const key of [...cache.keys()]) {
|
|
321
|
+
if (key.startsWith(`${taskId} `)) cache.delete(key);
|
|
322
|
+
}
|
|
323
|
+
const r = await dockerCli(
|
|
324
|
+
["ps", "-aq", "--filter", `label=${LABEL}=${taskId}`],
|
|
325
|
+
{ timeoutMs: 5_000 },
|
|
326
|
+
);
|
|
327
|
+
if (r.status !== 0) return;
|
|
328
|
+
for (const id of r.stdout.split("\n").map((s) => s.trim()).filter(Boolean)) {
|
|
329
|
+
await dockerCli(["rm", "-f", id], { timeoutMs: 10_000 });
|
|
330
|
+
}
|
|
331
|
+
}
|