blob: b1989abd6cef0121f77c54a5c42cb1b9b7b66d17 [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 { execFile, type ChildProcess } from 'node:child_process';
import { constants } from 'node:fs';
import { access, lstat, stat } from 'node:fs/promises';
import { dirname, isAbsolute, resolve } from 'node:path';
import {
BUNDLED_HARNESS_RELAY_ROOT,
createHarnessPreparationEnvironment,
resolvePathWithinRoot,
resolveRealPathWithinRoot,
} from './harness-environment.js';
import type { HarnessFramework, HarnessOptions } from './harness-executor.js';
import { terminateProcess } from './process-termination.js';
const PREFLIGHT_TIMEOUT_MS = 10_000;
const PREFLIGHT_OUTPUT_LIMIT_BYTES = 16 * 1024;
const PYTHON_FRAMEWORK_PROBE = [
'from importlib import import_module',
'from importlib.metadata import version',
'import sys',
'actual = version(sys.argv[1])',
'if actual != sys.argv[2]:',
' raise SystemExit(f"installed {actual}, expected {sys.argv[2]}")',
'import_module(f"{sys.argv[3]}.models.trial.config")',
].join('\n');
export interface HarnessPreflightDependencies {
readonly runCommand: (
command: string,
args: readonly string[],
environment: NodeJS.ProcessEnv,
cwd: string,
signal?: AbortSignal,
) => Promise<void>;
}
interface HarnessPreflightInput {
readonly framework: HarnessFramework;
readonly options: HarnessOptions;
readonly specPath: string;
readonly subjectCredentialNames: readonly string[];
readonly signal?: AbortSignal;
}
export async function preflightHarnessInstallation(
input: HarnessPreflightInput,
dependencies: Partial<HarnessPreflightDependencies> = {},
): Promise<void> {
input.signal?.throwIfAborted();
const { framework, options, specPath } = input;
const pythonPath = pythonCommand(options.pythonPathEnv, specPath);
const trialsRoot = machinePath(options.trialsRootEnv);
if (isAbsolute(pythonPath)) {
await requirePath(pythonPath, `machine path ${options.pythonPathEnv}`, 'file');
await access(pythonPath, constants.X_OK).catch(() => {
throw new Error(`machine path ${options.pythonPathEnv} is not executable: ${pythonPath}`);
});
}
await requireUsableDirectory(trialsRoot, `machine path ${options.trialsRootEnv}`);
if (options.tasksRootEnv) {
await requirePath(
machinePath(options.tasksRootEnv),
`machine path ${options.tasksRootEnv}`,
'directory',
);
}
for (const mount of options.mounts) {
await requirePath(machinePath(mount.sourceEnv), `machine path ${mount.sourceEnv}`, 'any');
}
let networkPolicyPath: string | undefined;
if (options.egressProxy) {
const source = machinePath(options.egressProxy.composeSourceEnv);
await requirePath(source, `machine path ${options.egressProxy.composeSourceEnv}`, 'directory');
const composeCandidate = resolvePathWithinRoot(
source,
options.egressProxy.composeRelativePath,
'egress proxy compose path',
);
const networkPolicyCandidate = resolvePathWithinRoot(
source,
options.egressProxy.networkPolicyRelativePath,
'egress network policy path',
);
await requirePath(composeCandidate, 'Eval egress Compose overlay', 'file');
await requirePath(networkPolicyCandidate, 'Eval egress network policy', 'file');
[, networkPolicyPath] = await Promise.all([
resolveRealPathWithinRoot(
source,
options.egressProxy.composeRelativePath,
'egress proxy compose path',
),
resolveRealPathWithinRoot(
source,
options.egressProxy.networkPolicyRelativePath,
'egress network policy path',
),
]);
}
for (const asset of ['eval_framework.py', 'relay_agent.py', 'run_trial.py']) {
await requirePath(
resolve(BUNDLED_HARNESS_RELAY_ROOT, asset),
`bundled Eval runtime ${asset}`,
'file',
);
}
input.signal?.throwIfAborted();
const runCommand = dependencies.runCommand ?? runCheckedCommand;
const environment = createHarnessPreparationEnvironment({
subjectCredentialNames: input.subjectCredentialNames,
declared: options.preparationEnvironment,
...(options.egressProxy && networkPolicyPath
? {
egress: {
allowedHost: options.egressProxy.allowedHost,
networkPolicyPath,
},
}
: {}),
});
const workingDirectory = dirname(specPath);
const distribution = framework === 'harbor' ? 'harbor' : 'datacurve-pier';
try {
await runCommand(
pythonPath,
['-c', PYTHON_FRAMEWORK_PROBE, distribution, options.frameworkVersion, framework],
environment,
workingDirectory,
input.signal,
);
} catch (error) {
if (input.signal?.aborted) input.signal.throwIfAborted();
throw new Error(
`${framework} Python environment ${options.pythonPathEnv} is unavailable or does not provide ${distribution}@${options.frameworkVersion}: ${errorMessage(error)}`,
);
}
input.signal?.throwIfAborted();
if (options.environment.type === 'docker') {
try {
await runCommand(
'docker',
['version', '--format', '{{.Server.Version}}'],
environment,
workingDirectory,
input.signal,
);
} catch (error) {
if (input.signal?.aborted) input.signal.throwIfAborted();
throw new Error(`Docker CLI or daemon is unavailable: ${errorMessage(error)}`);
}
}
}
async function runCheckedCommand(
command: string,
args: readonly string[],
environment: NodeJS.ProcessEnv,
cwd: string,
signal?: AbortSignal,
): Promise<void> {
signal?.throwIfAborted();
await new Promise<void>((resolvePromise, rejectPromise) => {
let settled = false;
let timeout: NodeJS.Timeout | undefined;
let child: ChildProcess | undefined;
const onAbort = () => {
void terminateProcess(child, 'SIGTERM');
};
const settle = (error?: Error, stderr = '') => {
if (settled) return;
settled = true;
if (timeout) clearTimeout(timeout);
signal?.removeEventListener('abort', onAbort);
if (!error) {
resolvePromise();
return;
}
rejectPromise(new Error(stderr.trim() || error.message));
};
try {
child = execFile(
command,
[...args],
{
env: environment,
cwd,
killSignal: 'SIGTERM',
maxBuffer: PREFLIGHT_OUTPUT_LIMIT_BYTES,
encoding: 'utf8',
},
(error, _stdout, stderr) => {
settle(error ?? undefined, stderr);
},
);
} catch (error) {
settle(error instanceof Error ? error : new Error(String(error)));
return;
}
timeout = setTimeout(() => {
void terminateProcess(child, 'SIGKILL');
}, PREFLIGHT_TIMEOUT_MS);
if (signal) {
if (signal.aborted) onAbort();
else signal.addEventListener('abort', onAbort, { once: true });
}
});
}
async function requirePath(
path: string,
label: string,
expected: 'any' | 'file' | 'directory',
): Promise<void> {
let metadata;
try {
metadata = await stat(path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error(`${label} does not exist: ${path}`);
}
throw new Error(`${label} is inaccessible: ${path}: ${errorMessage(error)}`);
}
if (expected === 'file' && !metadata.isFile()) throw new Error(`${label} is not a file: ${path}`);
if (expected === 'directory' && !metadata.isDirectory()) {
throw new Error(`${label} is not a directory: ${path}`);
}
}
async function requireUsableDirectory(path: string, label: string): Promise<void> {
let candidate = path;
while (true) {
let metadata;
try {
metadata = await stat(candidate);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw new Error(`${label} is inaccessible: ${candidate}: ${errorMessage(error)}`);
}
let linkMetadata;
try {
linkMetadata = await lstat(candidate);
} catch (linkError) {
if ((linkError as NodeJS.ErrnoException).code !== 'ENOENT') {
throw new Error(`${label} is inaccessible: ${candidate}: ${errorMessage(linkError)}`);
}
}
if (linkMetadata?.isSymbolicLink()) {
throw new Error(`${label} is a dangling symbolic link: ${candidate}`);
}
if (linkMetadata) continue;
const parent = dirname(candidate);
if (parent === candidate) {
throw new Error(`${label} has no accessible parent directory: ${path}`);
}
candidate = parent;
continue;
}
if (!metadata.isDirectory()) {
const subject = candidate === path ? label : `${label} parent`;
throw new Error(`${subject} is not a directory: ${candidate}`);
}
await access(candidate, constants.W_OK | constants.X_OK).catch(() => {
const subject = candidate === path ? label : `${label} nearest existing parent`;
throw new Error(`${subject} is not writable and searchable: ${candidate}`);
});
return;
}
}
function machinePath(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`machine path ${name} is unavailable`);
return resolve(value);
}
function pythonCommand(name: string, specPath: string): string {
const value = process.env[name];
if (!value) throw new Error(`machine path ${name} is unavailable`);
if (isAbsolute(value)) return value;
return value.includes('/') || value.includes('\\') ? resolve(dirname(specPath), value) : value;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}