Files
orca/src/shared/runtime-environment-sidecar.ts
T

265 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 {
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; it outlives the link like a learned runtimeId would.
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 {
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
return matches(entry.binding) || matches(entry.pendingBinding)
}
/**
* 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)
if (!entry || !isCurrentEntry(environment, entry)) {
return base
}
const basePairingRevision = environment.pairingRevision ?? environment.createdAt
const access = entry.sshAccess
if (
entry.runtimeId !== undefined &&
environment.runtimeId !== null &&
environment.runtimeId !== entry.runtimeId
) {
return base
}
const pairingRevision = Math.max(
basePairingRevision,
entry.pairingRevisionFloor ?? basePairingRevision
)
const { endpoint, ...link } = access ?? {}
const overlaid = KnownRuntimeEnvironmentSchema.safeParse({
...environment,
...(pairingRevision !== basePairingRevision ? { pairingRevision } : {}),
...(entry.runtimeId !== undefined
? { runtimeId: environment.runtimeId ?? entry.runtimeId }
: {}),
...(access && endpoint
? {
connectionDependency: 'ssh-tunnel',
sshAccess: link,
endpoints: [...environment.endpoints.filter((e) => e.id !== endpoint.id), endpoint],
preferredEndpointId: endpoint.id
}
: {}),
...(entry.pendingSshAccessOperation
? { pendingSshAccessOperation: entry.pendingSshAccessOperation }
: {}),
...(entry.orcadDeployment ? { orcadDeployment: entry.orcadDeployment } : {}),
...(entry.orcadMigratedAt ? { orcadMigratedAt: entry.orcadMigratedAt } : {}),
...(entry.reconciliation ? { reconciliation: entry.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 && isCurrentEntry(environment, entry) ? entry : undefined
}