blob: a502f0d97919ba605180345299b8490bfcf3ac58 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { spawn } from 'node:child_process';
import type { RuntimeHostLocalStagedDeployment } from './runtime-host-local-handoff.js';
import type { RuntimeHostUpdateCandidate } from './runtime-host-registry-update.js';
const TARGET_ACTIVATOR_SETTLEMENT_TIMEOUT_MS = 15_000;
export interface RuntimeHostTargetActivationInput {
readonly rootPath: string;
readonly rootId: string;
readonly staged: RuntimeHostLocalStagedDeployment;
readonly ownerInstallationId: string;
readonly target: RuntimeHostUpdateCandidate;
readonly takeoverHostEpoch?: string;
readonly inheritableAuthorityLeaseFd: number;
}
export interface RuntimeHostTargetActivation {
readonly kind: 'ready' | 'active_work' | 'operator_required';
/** Asks the short-lived child to adjudicate the durable owner record. */
settle(): Promise<void>;
}
export class RuntimeHostTargetActivationError extends Error {
constructor(
readonly code: 'recovery_required',
message: string,
options?: ErrorOptions,
) {
super(message, options);
this.name = 'RuntimeHostTargetActivationError';
}
}
/**
* Launches the exact staged target through its own release and keeps the
* existing owner-authority lease in the activator until durable commit.
*/
export function launchRuntimeHostTargetActivator(
input: RuntimeHostTargetActivationInput,
options: { readonly settlementTimeoutMs?: number } = {},
): Promise<RuntimeHostTargetActivation> {
const args = [
input.staged.cliPath,
'runtime-host',
'local-update-activate',
'--root',
input.rootPath,
'--expected-root-id',
input.rootId,
'--generation',
input.staged.launchGeneration,
'--candidate-entrypoint',
input.staged.candidateEntrypoint,
'--await-coordinator-commit',
'true',
'--expected-owner-installation-id',
input.ownerInstallationId,
'--target-version',
input.target.version,
'--target-integrity',
input.target.integrity,
...(input.takeoverHostEpoch ? ['--takeover-host-epoch', input.takeoverHostEpoch] : []),
];
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, args, {
// fd 3 is the coordinator channel; fd 4 is the inherited authority
// lease. The activator, not the long-lived target, owns that lease.
stdio: ['inherit', 'inherit', 'inherit', 'ipc', input.inheritableAuthorityLeaseFd],
windowsHide: false,
});
let ready = false;
let settled = false;
const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
(resolveClosed) => {
child.once('close', (code, signal) => resolveClosed({ code, signal }));
},
);
const closeError = async (): Promise<never> => {
const { code, signal } = await closed;
if (signal) throw new Error(`Maka target activator exited on ${signal}`);
if (code === 3) throw new Error('The activated Runtime Host still owns active work');
if (code === 4) throw new Error('The observed Runtime Host requires its operator');
throw new Error('The exact Maka target could not be activated');
};
const settle = async (): Promise<void> => {
if (settled) return;
settled = true;
let settlementTimedOut = false;
// This parent owns the activator process and therefore the hard bound.
// Killing and then awaiting close releases its inherited lease even when
// an individual authority read in the child never settles.
const settlementTimer = setTimeout(() => {
settlementTimedOut = true;
child.kill('SIGKILL');
}, options.settlementTimeoutMs ?? TARGET_ACTIVATOR_SETTLEMENT_TIMEOUT_MS);
settlementTimer.unref();
if (child.connected) {
try {
child.send({ kind: 'settle' });
} catch {
// The close result below remains the only settlement evidence.
}
}
try {
const exited = await closed;
if (settlementTimedOut) {
throw new RuntimeHostTargetActivationError(
'recovery_required',
'The local Runtime Host update requires recovery because its target activator exceeded the durable settlement deadline',
);
}
if (!exited.signal && exited.code === 0) return;
throw new RuntimeHostTargetActivationError(
'recovery_required',
exited.signal
? `The local Runtime Host update requires recovery because its target activator exited on ${exited.signal}`
: 'The local Runtime Host update requires recovery because its target activator could not confirm durable ownership',
);
} finally {
clearTimeout(settlementTimer);
}
};
child.once('error', reject);
child.on('message', (message: unknown) => {
if (ready || !isTargetActivatorReadyMessage(message)) return;
ready = true;
resolve({ kind: 'ready', settle });
});
void closed.then(({ code, signal }) => {
if (ready) return;
if (signal) {
reject(new Error(`Maka target activator exited on ${signal}`));
} else if (code === 3) {
resolve({ kind: 'active_work', settle: async () => undefined });
} else if (code === 4) {
resolve({ kind: 'operator_required', settle: async () => undefined });
} else {
void closeError().catch(reject);
}
});
});
}
function isTargetActivatorReadyMessage(value: unknown): value is { readonly kind: 'ready' } {
return (
typeof value === 'object' && value !== null && (value as { kind?: unknown }).kind === 'ready'
);
}