blob: f06c28f4e867d4c10aef5bdce248727cf824e669 [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 { homedir } from 'node:os';
import type { RuntimeHostServiceErrorCode } from '@maka/runtime-host/operator';
import {
isProductReleaseVersion,
isSha512PackageIntegrity,
type RuntimeHostNpmDeploymentIdentity,
} from '@maka/runtime-host/operator/update-package-evidence';
import type { RuntimeHostUpdateSelector } from './runtime-host-cli.js';
const PACKAGE_NAME = 'maka-agent';
const NPM_REGISTRY = 'https://registry.npmjs.org/';
const COMPATIBILITY_FIELD = 'maka.managedRuntimeHostUpdateCompatibility';
const REGISTRY_TIMEOUT_MS = 30_000;
const REGISTRY_OUTPUT_MAX_BYTES = 64 * 1024;
export interface RuntimeHostUpdateCandidate extends RuntimeHostNpmDeploymentIdentity {
readonly compatibility?: number;
}
export class RuntimeHostUpdateDiscoveryError extends Error {
constructor(
readonly code: Extract<
RuntimeHostServiceErrorCode,
'target_unavailable' | 'registry_unavailable' | 'invalid_registry_metadata'
>,
message: string,
options?: ErrorOptions,
) {
super(message, options);
this.name = 'RuntimeHostUpdateDiscoveryError';
}
}
interface NpmViewResult {
readonly exitCode: number;
readonly stdout: string;
}
export async function resolveRuntimeHostRegistryUpdateCandidate(
selector: RuntimeHostUpdateSelector,
run: (args: readonly string[]) => Promise<NpmViewResult> = runNpmView,
): Promise<RuntimeHostUpdateCandidate> {
const target = selector.kind === 'channel' ? selector.channel : selector.version;
const result = await run([
'view',
`${PACKAGE_NAME}@${target}`,
'version',
'dist.integrity',
COMPATIBILITY_FIELD,
'--json',
'--registry',
NPM_REGISTRY,
]);
if (result.exitCode !== 0) {
const failure = parseJson(result.stdout);
const code = isRecord(failure) && isRecord(failure.error) ? failure.error.code : undefined;
throw new RuntimeHostUpdateDiscoveryError(
code === 'E404' ? 'target_unavailable' : 'registry_unavailable',
code === 'E404'
? `No Maka package is published for ${target}`
: 'The Maka package registry is unavailable',
);
}
let metadata: unknown;
try {
metadata = JSON.parse(result.stdout);
} catch (error) {
throw new RuntimeHostUpdateDiscoveryError(
'invalid_registry_metadata',
'The npm registry returned invalid Maka package metadata',
{ cause: error },
);
}
if (!isRecord(metadata)) return invalidMetadata();
const version = metadata.version;
const integrity = metadata['dist.integrity'];
if (
typeof version !== 'string' ||
!isProductReleaseVersion(version) ||
(selector.kind === 'exact' && version !== selector.version) ||
typeof integrity !== 'string' ||
!isSha512PackageIntegrity(integrity)
) {
return invalidMetadata();
}
const compatibility = positiveInteger(metadata[COMPATIBILITY_FIELD]);
return {
kind: 'npm_registry',
version,
integrity,
...(compatibility === undefined ? {} : { compatibility }),
};
}
function runNpmView(args: readonly string[]): Promise<NpmViewResult> {
return new Promise((resolve, reject) => {
const child = spawn('npm', args, {
cwd: homedir(),
stdio: ['ignore', 'pipe', 'ignore'],
timeout: REGISTRY_TIMEOUT_MS,
killSignal: 'SIGKILL',
});
let stdout = '';
let bytes = 0;
child.stdout.setEncoding('utf8');
child.stdout.on('data', (chunk: string) => {
bytes += Buffer.byteLength(chunk, 'utf8');
if (bytes > REGISTRY_OUTPUT_MAX_BYTES) child.kill('SIGKILL');
else stdout += chunk;
});
child.once('error', reject);
child.once('close', (exitCode) => {
if (bytes > REGISTRY_OUTPUT_MAX_BYTES) {
reject(
new RuntimeHostUpdateDiscoveryError(
'invalid_registry_metadata',
'The npm registry returned oversized Maka package metadata',
),
);
return;
}
resolve({ exitCode: exitCode ?? 1, stdout });
});
});
}
function invalidMetadata(): never {
throw new RuntimeHostUpdateDiscoveryError(
'invalid_registry_metadata',
'The npm registry returned incomplete Maka package metadata',
);
}
function positiveInteger(value: unknown): number | undefined {
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined;
}
function parseJson(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return undefined;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}