Files
orca/src/shared/runtime-environment-sidecar.ts
T
NeilandSahilZ0810 0c27fd5f91 fix(ssh): move a managed tunnel off an unbindable saved local port (#33271)
* fix(ssh): move a managed tunnel off an unbindable saved local port

When the saved local port of a managed SSH server's tunnel can no longer
be bound (EADDRINUSE from another listener, or EACCES from a Windows
excluded port range), pick a free port, save it with the pairing and the
deployment link together, and build the tunnel again. The pairing, server
identity, remote port and projects are unchanged.

A move interrupted between its sidecar and store writes reads the moved
port from the stored endpoint, so the deployment link is never dropped.

Fixes #33182

Co-authored-by: SahilZ0810 <sahil@zamp.ai>

* test(e2e): drop squatter connections so teardown never waits on them

* fix(ssh): move the tunnel port before pairing repair and retire old transports

Move outside buildWithPairingRepair so a refusal on the moved port is
repaired against the revision the move saved, and retire transports and
status owners still dialing the old port, as a repair does.

* fix(ssh): refresh maintenance pairings on the current tunnel port

Update, rollback and restart reread the deployment's local port when they
save the new pairing, since the census before them may have moved it.

* fix(ssh): let concurrent tunnel builds follow a port another caller moved

---------

Co-authored-by: SahilZ0810 <sahil@zamp.ai>
2026-10-24 15:03:39 -07:00

279 lines
11 KiB
TypeScript

import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { z } from 'zod'
import { writeSecureJsonFileWithinLimit } from './bounded-secure-json-file'
import { readNodeFileSyncWithinLimit } from './node-bounded-file-reader'
import { RuntimeEnvironmentReconciliationRecordSchema } from './runtime-environment-reconciliation-record'
import { readEnvironmentStoreRecords } from './runtime-environment-store-records'
import {
getPreferredLoopbackRuntimePort,
KnownRuntimeEnvironmentSchema,
RuntimeAccessEndpointSchema,
RuntimeSshAccessLinkSchema,
RuntimeSshAccessOperationSchema,
RuntimeSshTunnelLinkSchema,
type KnownRuntimeEnvironment,
type PersistedRuntimeEnvironment
} from './runtime-environments'
import { hardenExistingSecureFile } from './secure-file'
// Why a sidecar: shipped builds parse orca-environments.json with key-stripping schemas and rewrite
// it on routine use, so T5 state stored there would vanish across a downgrade. They never touch this.
const SIDECAR_FILE = 'orca-environment-sidecar.json'
const MAX_SIDECAR_FILE_BYTES = 1024 * 1024
const SidecarBindingSchema = z.object({
createdAt: z.number().finite(),
pairingRevision: z.number().finite(),
preferredEndpointId: z.string().min(1)
})
const SidecarSshAccessSchema = RuntimeSshAccessLinkSchema.extend({
endpoint: RuntimeAccessEndpointSchema
})
const SidecarEntrySchema = z.object({
binding: SidecarBindingSchema,
// The store state a two-file rebind is moving to; the entry stays current through a crash between them.
pendingBinding: SidecarBindingSchema.optional(),
// The runtime a link verified, used only until the store learns one. orcad mints a new id at
// every start, so a different stored id is a restart, not a different server (#32957).
runtimeId: z.string().min(1).optional(),
sshAccess: SidecarSshAccessSchema.optional(),
pendingSshAccessOperation: RuntimeSshAccessOperationSchema.optional(),
// The managed SSH deployment's tunnel, without a `kind` so older builds read it unchanged.
// WSL deployments live in wsl-host-deployment-store instead.
orcadDeployment: RuntimeSshTunnelLinkSchema.optional(),
// A rollback to a snapshot older than this would discard the migrated catalog.
orcadMigratedAt: z.string().datetime().optional(),
// Keeps the overlaid pairing revision monotonic after the access that raised it is removed.
pairingRevisionFloor: z.number().finite().optional(),
reconciliation: RuntimeEnvironmentReconciliationRecordSchema.optional()
})
const SidecarSchema = z.object({
version: z.literal(1),
entries: z.record(z.string().min(1), SidecarEntrySchema)
})
// Entries stay opaque so one bad or newer-format entry cannot hide every server.
const SidecarEnvelopeSchema = z.object({
version: z.literal(1),
entries: z.record(z.string(), z.unknown())
})
export type RuntimeEnvironmentSidecarEntry = z.infer<typeof SidecarEntrySchema>
type RuntimeEnvironmentSidecar = z.infer<typeof SidecarSchema> & {
/** Entries this build cannot read, kept verbatim; their environments must not be used. */
unreadable: Record<string, unknown>
}
export class RuntimeEnvironmentSidecarInvalidError extends Error {
constructor(path: string, environmentId?: string) {
super(
environmentId === undefined
? `Could not read Orca environment links at ${path}; the file is invalid.`
: `Could not read the Orca environment link for ${environmentId} at ${path}; it may need a newer version of Orca.`
)
this.name = 'RuntimeEnvironmentSidecarInvalidError'
}
}
export function getRuntimeEnvironmentSidecarPath(userDataPath: string): string {
return join(userDataPath, SIDECAR_FILE)
}
/** The persisted state a sidecar entry was written against; any change makes the entry stale. */
export function runtimeEnvironmentSidecarBinding(environment: PersistedRuntimeEnvironment) {
return {
createdAt: environment.createdAt,
pairingRevision: environment.pairingRevision ?? environment.createdAt,
preferredEndpointId: environment.preferredEndpointId
}
}
export function readRuntimeEnvironmentSidecar(userDataPath: string): RuntimeEnvironmentSidecar {
const path = getRuntimeEnvironmentSidecarPath(userDataPath)
if (!existsSync(path)) {
return { version: 1, entries: {}, unreadable: {} }
}
let raw: Record<string, unknown>
try {
hardenExistingSecureFile(path)
raw = SidecarEnvelopeSchema.parse(
JSON.parse(readNodeFileSyncWithinLimit(path, MAX_SIDECAR_FILE_BYTES).buffer.toString('utf8'))
).entries
} catch {
throw new RuntimeEnvironmentSidecarInvalidError(path)
}
const entries: RuntimeEnvironmentSidecar['entries'] = {}
const unreadable: RuntimeEnvironmentSidecar['unreadable'] = {}
for (const [id, value] of Object.entries(raw)) {
const entry = SidecarEntrySchema.safeParse(value)
// defineProperty: a JSON "__proto__" key must stay data, not replace the prototype.
Object.defineProperty(id.length > 0 && entry.success ? entries : unreadable, id, {
value: entry.success ? entry.data : value,
enumerable: true,
writable: true,
configurable: true
})
}
return { version: 1, entries, unreadable }
}
function isCurrentEntry(
environment: PersistedRuntimeEnvironment,
entry: RuntimeEnvironmentSidecarEntry
): boolean {
return currentEntry(environment, entry) !== undefined
}
/** The entry as of `environment`, or none when it is bound to a different store state. */
function currentEntry(
environment: PersistedRuntimeEnvironment,
entry: RuntimeEnvironmentSidecarEntry
): RuntimeEnvironmentSidecarEntry | undefined {
const binding = runtimeEnvironmentSidecarBinding(environment)
const matches = (candidate: z.infer<typeof SidecarBindingSchema> | undefined): boolean =>
candidate?.createdAt === binding.createdAt &&
candidate.pairingRevision === binding.pairingRevision &&
candidate.preferredEndpointId === binding.preferredEndpointId
if (matches(entry.binding)) {
return entry
}
if (!matches(entry.pendingBinding)) {
return undefined
}
// A rebind stopped after its store write: the stored endpoint already names the new tunnel port.
const localPort = getPreferredLoopbackRuntimePort(environment)
return entry.orcadDeployment && localPort !== null
? { ...entry, orcadDeployment: { ...entry.orcadDeployment, localPort } }
: entry
}
/**
* The environment callers see. An entry bound to a different pairing, or one whose overlay would be
* inconsistent, is stale and ignored rather than trusted.
*/
export function overlayRuntimeEnvironmentSidecar(
environment: PersistedRuntimeEnvironment,
entry: RuntimeEnvironmentSidecarEntry | undefined
): KnownRuntimeEnvironment {
const base = KnownRuntimeEnvironmentSchema.parse(environment)
const current = entry && currentEntry(environment, entry)
if (!current) {
return base
}
const basePairingRevision = environment.pairingRevision ?? environment.createdAt
const access = current.sshAccess
const pairingRevision = Math.max(
basePairingRevision,
current.pairingRevisionFloor ?? basePairingRevision
)
const { endpoint, ...link } = access ?? {}
const overlaid = KnownRuntimeEnvironmentSchema.safeParse({
...environment,
...(pairingRevision !== basePairingRevision ? { pairingRevision } : {}),
...(current.runtimeId !== undefined
? { runtimeId: environment.runtimeId ?? current.runtimeId }
: {}),
...(access && endpoint
? {
connectionDependency: 'ssh-tunnel',
sshAccess: link,
endpoints: [...environment.endpoints.filter((e) => e.id !== endpoint.id), endpoint],
preferredEndpointId: endpoint.id
}
: {}),
...(current.pendingSshAccessOperation
? { pendingSshAccessOperation: current.pendingSshAccessOperation }
: {}),
...(current.orcadDeployment ? { orcadDeployment: current.orcadDeployment } : {}),
...(current.orcadMigratedAt ? { orcadMigratedAt: current.orcadMigratedAt } : {}),
...(current.reconciliation ? { reconciliation: current.reconciliation } : {})
})
return overlaid.success ? overlaid.data : base
}
/**
* Replaces one environment's sidecar entry and drops entries whose environment no longer exists.
* `pendingEnvironment` also binds the entry to the store state about to be written.
*/
export function writeRuntimeEnvironmentSidecarEntry(
userDataPath: string,
environments: readonly PersistedRuntimeEnvironment[],
environment: PersistedRuntimeEnvironment,
entry: Omit<RuntimeEnvironmentSidecarEntry, 'binding'> | null,
pendingEnvironment?: PersistedRuntimeEnvironment
): void {
const current = readRuntimeEnvironmentSidecar(userDataPath)
// Never replace what we cannot read: it may hold this server's SSH pin or deployment link.
if (Object.hasOwn(current.unreadable, environment.id)) {
throw new RuntimeEnvironmentSidecarInvalidError(
getRuntimeEnvironmentSidecarPath(userDataPath),
environment.id
)
}
const live = new Map(environments.map((candidate) => [candidate.id, candidate]))
// Servers whose main record this build cannot read still own their entries.
const foreignOwners = new Set(
readEnvironmentStoreRecords(userDataPath).unreadable.map((record) => record.id)
)
const entries: Record<string, RuntimeEnvironmentSidecarEntry> = {}
for (const [id, existing] of Object.entries(current.entries)) {
const owner = live.get(id)
if (
id !== environment.id &&
((owner && isCurrentEntry(owner, existing)) || (!owner && foreignOwners.has(id)))
) {
entries[id] = existing
}
}
const basePairingRevision = environment.pairingRevision ?? environment.createdAt
const hasState =
entry !== null &&
((entry.runtimeId !== undefined && environment.runtimeId === null) ||
entry.sshAccess !== undefined ||
entry.pendingSshAccessOperation !== undefined ||
entry.orcadDeployment !== undefined ||
entry.orcadMigratedAt !== undefined ||
entry.reconciliation !== undefined ||
(entry.pairingRevisionFloor ?? basePairingRevision) > basePairingRevision)
if (entry && hasState) {
const { pendingBinding: _staleTarget, ...state } = entry
entries[environment.id] = SidecarEntrySchema.parse({
...state,
binding: runtimeEnvironmentSidecarBinding(environment),
...(pendingEnvironment
? { pendingBinding: runtimeEnvironmentSidecarBinding(pendingEnvironment) }
: {})
})
}
writeSecureJsonFileWithinLimit(
getRuntimeEnvironmentSidecarPath(userDataPath),
{
version: 1,
// Unreadable entries are never pruned: this build cannot tell whether they are stale.
entries: { ...SidecarSchema.parse({ version: 1, entries }).entries, ...current.unreadable }
},
MAX_SIDECAR_FILE_BYTES,
{ durable: true }
)
}
/** The current sidecar entry for an environment, or none when absent or stale. Throws when unreadable. */
export function readCurrentRuntimeEnvironmentSidecarEntry(
userDataPath: string,
environment: PersistedRuntimeEnvironment
): RuntimeEnvironmentSidecarEntry | undefined {
const sidecar = readRuntimeEnvironmentSidecar(userDataPath)
if (Object.hasOwn(sidecar.unreadable, environment.id)) {
throw new RuntimeEnvironmentSidecarInvalidError(
getRuntimeEnvironmentSidecarPath(userDataPath),
environment.id
)
}
const entry = sidecar.entries[environment.id]
return entry && currentEntry(environment, entry)
}