blob: 8c91c2a51274c07c92470f857cf5eeafe0db3790 [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 { connectExistingRuntimeHost, type RuntimeHostConnection } from '@maka/runtime-host/client';
import {
encodeRuntimeHostPeerMeshManagementFrame,
type RuntimeHostPeerMeshManagementAction,
type RuntimeHostPeerMeshManagementFrame,
} from '@maka/runtime-host/operator';
import {
decodePeerMeshInvitation,
RUNTIME_HOST_PROTOCOL_VERSION,
type OperationInput,
type OperationOutput,
} from '@maka/runtime-host/protocol';
import {
RuntimeHostServiceManagerError,
withRuntimeHostManagedServiceDeploymentLock,
withRuntimeHostManagedServiceLifecycleLock,
type RuntimeHostManagedServiceTarget,
} from './runtime-host-service-manager.js';
import { resolveRuntimeHostLifecycleProvider } from './runtime-host-service-management-command.js';
import { resolveRecoverableRuntimeHostManagedDeployment } from './runtime-host-lifecycle-transaction.js';
import {
assertRuntimeHostManagedOperatorConfig,
assertRuntimeHostManagedOperatorDeployment,
convergeRuntimeHostManagedOperator,
resolveRuntimeHostManagedControlRoot,
verifyRuntimeHostManagedOperator,
} from './runtime-host-managed-deployment.js';
const INPUT_MAX_BYTES = 128 * 1024;
const PROTOCOL = {
min: RUNTIME_HOST_PROTOCOL_VERSION,
max: RUNTIME_HOST_PROTOCOL_VERSION,
} as const;
export interface RuntimeHostPeerMeshManagementCliOptions {
readonly action: RuntimeHostPeerMeshManagementAction;
readonly json: boolean;
readonly framed: boolean;
readonly managedRootId: string;
readonly operatorDeploymentId: string;
readonly cliPath: string;
readonly expectedTarget: RuntimeHostManagedServiceTarget;
readonly meshId?: string | null;
readonly peerId?: string;
readonly displayName?: string | null;
}
interface RuntimeHostPeerMeshManagementCliDeps {
readonly writeStdout: (text: string) => void;
readonly writeStderr: (text: string) => void;
readonly readInvitation: () => Promise<unknown>;
readonly connect: (rootPath: string) => Promise<RuntimeHostConnection>;
}
export async function runRuntimeHostPeerMeshManagementCli(
options: RuntimeHostPeerMeshManagementCliOptions,
overrides: Partial<RuntimeHostPeerMeshManagementCliDeps> = {},
): Promise<number> {
const deps: RuntimeHostPeerMeshManagementCliDeps = {
writeStdout: (text) => process.stdout.write(text),
writeStderr: (text) => process.stderr.write(text),
readInvitation: readInvitationFromStdin,
connect: connectLocalOwner,
...overrides,
};
try {
const manualInvitation =
options.action === 'join' && !options.framed
? await readJoinInvitation(options, deps)
: undefined;
const controlRoot = resolveRuntimeHostManagedControlRoot(options.managedRootId);
const result = await withRuntimeHostManagedServiceDeploymentLock(controlRoot, () =>
withRuntimeHostManagedServiceLifecycleLock(controlRoot, async () => {
await assertRuntimeHostManagedOperatorDeployment(
options.managedRootId,
options.operatorDeploymentId,
options.cliPath,
);
const resolved = await resolveRecoverableRuntimeHostManagedDeployment(
options.managedRootId,
{
convergeOperator: convergeRuntimeHostManagedOperator,
verifyOperator: verifyRuntimeHostManagedOperator,
resolveProvider: resolveRuntimeHostLifecycleProvider,
},
{ expectedTarget: options.expectedTarget },
);
if (resolved.kind === 'absent') {
throw new RuntimeHostServiceManagerError(
'not_installed',
'The managed Runtime Host deployment is not installed',
);
}
assertRuntimeHostManagedOperatorConfig(
resolved.config,
options.operatorDeploymentId,
options.cliPath,
);
if (options.action !== 'status' && !resolved.config.listeners.directPeer?.enabled) {
throw new Error('Direct peer is not enabled for this Runtime Host');
}
const connection = await deps.connect(resolved.config.root.path);
try {
if (connection.rootId !== options.managedRootId) {
throw new Error('Runtime Host service is bound to a different State Root');
}
const invitation =
options.action === 'join'
? (manualInvitation ?? (await readJoinInvitation(options, deps)))
: undefined;
return await executePeerMeshAction(connection, options, invitation);
} finally {
await connection.close();
}
}),
);
writeResult(options, result, deps);
return 0;
} catch (error) {
writeError(options, error, deps);
return 1;
}
}
async function executePeerMeshAction(
connection: RuntimeHostConnection,
options: RuntimeHostPeerMeshManagementCliOptions,
invitation: ReturnType<typeof decodePeerMeshInvitation> | undefined,
): Promise<PeerMeshResultFrame> {
const request = async <K extends PeerMeshOperation>(
operation: K,
input: OperationInput<K>,
): Promise<OperationOutput<K>> => connection.request(operation, input);
switch (options.action) {
case 'status':
return { kind: 'result', action: 'status', result: await request('peer.mesh.query', {}) };
case 'create':
return { kind: 'result', action: 'create', result: await request('peer.mesh.create', {}) };
case 'invite':
return {
kind: 'result',
action: 'invite',
result: await request('peer.mesh.invite', {
meshId: requiredMeshId(options.meshId),
}),
};
case 'join':
return {
kind: 'result',
action: 'join',
result: await request('peer.mesh.join', {
invitation: requiredOption(invitation, 'Peer Mesh invitation'),
}),
};
case 'remove':
return {
kind: 'result',
action: 'remove',
result: await request('peer.mesh.remove', {
meshId: requiredMeshId(options.meshId),
peerId: requiredOption(options.peerId, 'Peer ID'),
}),
};
case 'leave':
return {
kind: 'result',
action: 'leave',
result: await request('peer.mesh.leave', {
meshId: requiredMeshId(options.meshId),
}),
};
case 'close':
return {
kind: 'result',
action: 'close',
result: await request('peer.mesh.close', {
meshId: requiredMeshId(options.meshId),
}),
};
case 'reconcile':
return {
kind: 'result',
action: 'reconcile',
result: await request('peer.mesh.reconcile', {}),
};
case 'transit':
return {
kind: 'result',
action: 'transit',
result: await request('peer.mesh.transit.set', {
meshId: requiredOption(options.meshId, 'Mesh ID'),
}),
};
case 'rename':
return {
kind: 'result',
action: 'rename',
result: await request('peer.mesh.display-name.set', {
displayName: requiredOption(options.displayName, 'Display name'),
}),
};
case 'rename-mesh':
return {
kind: 'result',
action: 'rename-mesh',
result: await request('peer.mesh.rename', {
meshId: requiredMeshId(options.meshId),
displayName: requiredOption(options.displayName, 'Display name'),
}),
};
}
}
function requiredOption<T>(value: T | undefined, label: string): T {
if (value === undefined) throw new Error(`${label} is required`);
return value;
}
function requiredMeshId(value: string | null | undefined): string {
if (typeof value !== 'string') throw new Error('Mesh ID is required');
return value;
}
async function readJoinInvitation(
options: RuntimeHostPeerMeshManagementCliOptions,
deps: RuntimeHostPeerMeshManagementCliDeps,
): Promise<ReturnType<typeof decodePeerMeshInvitation>> {
if (options.framed) {
deps.writeStdout(encodeRuntimeHostPeerMeshManagementFrame({ kind: 'input', action: 'join' }));
} else if (!options.json) {
deps.writeStderr('Paste the Peer Mesh invitation and press Enter:\n');
}
return decodePeerMeshInvitation(await deps.readInvitation());
}
type PeerMeshOperation = Extract<
Parameters<RuntimeHostConnection['request']>[0],
`peer.mesh.${string}`
>;
async function connectLocalOwner(rootPath: string): Promise<RuntimeHostConnection> {
const result = await connectExistingRuntimeHost({ rootPath, protocol: PROTOCOL });
if (result.kind !== 'connected') {
throw new Error(`Runtime Host service is not available (${result.kind})`);
}
return result.connection;
}
async function readInvitationFromStdin(): Promise<unknown> {
const stdin = process.stdin;
const canSetRawMode = stdin.isTTY && typeof stdin.setRawMode === 'function';
const wasRaw = stdin.isRaw;
if (canSetRawMode) stdin.setRawMode(true);
try {
let text = '';
for await (const chunk of stdin) {
text += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
if (text.includes('\u0003')) throw new Error('Peer Mesh invitation input was cancelled');
if (Buffer.byteLength(text, 'utf8') > INPUT_MAX_BYTES) {
throw new Error('Peer Mesh invitation exceeds the input size limit');
}
const carriageReturn = text.indexOf('\r');
const lineFeed = text.indexOf('\n');
const newline = [carriageReturn, lineFeed]
.filter((index) => index >= 0)
.sort((left, right) => left - right)[0];
if (newline !== undefined) {
if (
text
.slice(newline + 1)
.replace(/^\n/u, '')
.trim().length > 0
) {
throw new Error('Peer Mesh invitation input contains trailing data');
}
text = text.slice(0, newline);
break;
}
}
if (!text.trim()) throw new Error('Peer Mesh invitation input is empty');
return JSON.parse(text.trim()) as unknown;
} finally {
if (canSetRawMode) stdin.setRawMode(wasRaw);
}
}
function writeResult(
options: RuntimeHostPeerMeshManagementCliOptions,
result: PeerMeshResultFrame,
deps: RuntimeHostPeerMeshManagementCliDeps,
): void {
if (options.framed) {
deps.writeStdout(encodeRuntimeHostPeerMeshManagementFrame(result));
return;
}
const output = result.action === 'invite' ? result.result.invitation : result.result;
deps.writeStdout(
`${JSON.stringify(output, null, options.json || result.action === 'invite' ? undefined : 2)}\n`,
);
}
type PeerMeshResultFrame = Extract<RuntimeHostPeerMeshManagementFrame, { readonly kind: 'result' }>;
function writeError(
options: RuntimeHostPeerMeshManagementCliOptions,
error: unknown,
deps: RuntimeHostPeerMeshManagementCliDeps,
): void {
const message = error instanceof Error ? error.message : String(error);
if (options.framed) {
deps.writeStdout(
encodeRuntimeHostPeerMeshManagementFrame({
kind: 'error',
action: options.action,
error: { code: 'peer_mesh_management_failed', message },
}),
);
return;
}
if (options.json) {
deps.writeStdout(`${JSON.stringify({ ok: false, error: { message } })}\n`);
} else {
deps.writeStderr(`${message}\n`);
}
}