mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
238 lines
6.1 KiB
TypeScript
238 lines
6.1 KiB
TypeScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
|
|
|
import { inspectActiveComposeImageSelection } from './compose/composeRevision';
|
|
import {
|
|
currentIdentity,
|
|
LocalDeploymentConfigurationError,
|
|
normalizeLocalDeploymentStatusCommand,
|
|
type LocalDeploymentProfile,
|
|
type LocalDeploymentServiceKind,
|
|
type LocalDeploymentStatusResult,
|
|
} from './foundation/contract';
|
|
import { validatePrivateDirectory } from './foundation/files';
|
|
import {
|
|
deploymentPaths,
|
|
type LocalDeploymentPaths,
|
|
} from './foundation/render';
|
|
|
|
const MAX_OBSERVED_FILE_BYTES = 64 * 1024;
|
|
|
|
function configurationError(message: string, cause?: unknown): never {
|
|
throw new LocalDeploymentConfigurationError(message, { cause });
|
|
}
|
|
|
|
function boundedPrivateFile(
|
|
filePath: string,
|
|
uid: number,
|
|
mode: number,
|
|
label: string,
|
|
): string {
|
|
let stat: fs.Stats;
|
|
try {
|
|
stat = fs.lstatSync(filePath);
|
|
} catch (error) {
|
|
configurationError(`${label} is unavailable`, error);
|
|
}
|
|
if (
|
|
!stat.isFile() ||
|
|
stat.isSymbolicLink() ||
|
|
stat.uid !== uid ||
|
|
(stat.mode & 0o777) !== mode ||
|
|
stat.nlink !== 1 ||
|
|
stat.size < 2 ||
|
|
stat.size > MAX_OBSERVED_FILE_BYTES
|
|
) {
|
|
configurationError(`${label} identity is invalid`);
|
|
}
|
|
return fs.readFileSync(filePath, 'utf8');
|
|
}
|
|
|
|
function applicationProfile(
|
|
paths: Readonly<LocalDeploymentPaths>,
|
|
uid: number,
|
|
): LocalDeploymentProfile {
|
|
const contents = boundedPrivateFile(
|
|
paths.applicationConfig,
|
|
uid,
|
|
0o600,
|
|
'application configuration',
|
|
);
|
|
let value: unknown;
|
|
try {
|
|
value = JSON.parse(contents);
|
|
} catch (error) {
|
|
configurationError('application configuration is invalid', error);
|
|
}
|
|
const candidate = value as {
|
|
readonly schema?: unknown;
|
|
readonly profile?: unknown;
|
|
};
|
|
if (
|
|
candidate?.schema !== 'qinglong/local-application-process@v2' ||
|
|
(candidate.profile !== 'edge' && candidate.profile !== 'standalone')
|
|
) {
|
|
configurationError('application configuration binding drifted');
|
|
}
|
|
return candidate.profile;
|
|
}
|
|
|
|
function serviceKind(
|
|
paths: Readonly<LocalDeploymentPaths>,
|
|
uid: number,
|
|
): LocalDeploymentServiceKind {
|
|
const candidates = [
|
|
{
|
|
kind: 'systemd' as const,
|
|
fileName: 'qinglong3.service',
|
|
mode: 0o600,
|
|
markers: [
|
|
'[Unit]\n',
|
|
'Description=QingLong 3.0 local automation runtime',
|
|
],
|
|
},
|
|
{
|
|
kind: 'openrc' as const,
|
|
fileName: 'qinglong3.openrc',
|
|
mode: 0o700,
|
|
markers: [
|
|
'#!/sbin/openrc-run\n',
|
|
'description="QingLong 3.0 local automation runtime"',
|
|
],
|
|
},
|
|
{
|
|
kind: 'compose' as const,
|
|
fileName: 'compose.yaml',
|
|
mode: 0o600,
|
|
markers: ['name: ql3-', '\nservices:\n qinglong3:\n'],
|
|
},
|
|
].filter((candidate) =>
|
|
fs.existsSync(path.join(paths.service, candidate.fileName)),
|
|
);
|
|
if (candidates.length !== 1) {
|
|
configurationError('service descriptor selection drifted');
|
|
}
|
|
const selected = candidates[0]!;
|
|
const contents = boundedPrivateFile(
|
|
path.join(paths.service, selected.fileName),
|
|
uid,
|
|
selected.mode,
|
|
'service descriptor',
|
|
);
|
|
if (selected.markers.some((marker) => !contents.includes(marker))) {
|
|
configurationError('service descriptor binding drifted');
|
|
}
|
|
return selected.kind;
|
|
}
|
|
|
|
function fence(
|
|
filePath: string,
|
|
uid: number,
|
|
label: string,
|
|
): 'idle' | 'in_flight' {
|
|
if (!fs.existsSync(filePath)) return 'idle';
|
|
boundedPrivateFile(filePath, uid, 0o600, label);
|
|
return 'in_flight';
|
|
}
|
|
|
|
export function inspectLocalDeploymentStatus(
|
|
input: unknown,
|
|
): Readonly<LocalDeploymentStatusResult> {
|
|
const command = normalizeLocalDeploymentStatusCommand(input);
|
|
const identity = currentIdentity();
|
|
const paths = deploymentPaths(command.options.deploymentRoot);
|
|
validatePrivateDirectory(
|
|
command.options.deploymentRoot,
|
|
identity.uid,
|
|
'deploymentRoot',
|
|
);
|
|
validatePrivateDirectory(
|
|
paths.service,
|
|
identity.uid,
|
|
'serviceDescriptorRoot',
|
|
);
|
|
const profile = applicationProfile(paths, identity.uid);
|
|
const kind = serviceKind(paths, identity.uid);
|
|
const common = {
|
|
schemaVersion: 1 as const,
|
|
operation: 'local.deployment.status' as const,
|
|
status: 'observed' as const,
|
|
observation: 'durable' as const,
|
|
profile,
|
|
applicationConfiguration: Object.freeze({
|
|
schema: 'qinglong/local-application-process@v2' as const,
|
|
state: 'present' as const,
|
|
}),
|
|
runtime: Object.freeze({ health: 'unobserved' as const }),
|
|
};
|
|
if (kind !== 'compose') {
|
|
return Object.freeze({
|
|
...common,
|
|
service: Object.freeze({
|
|
kind,
|
|
descriptor: 'present' as const,
|
|
}),
|
|
});
|
|
}
|
|
|
|
validatePrivateDirectory(
|
|
paths.composeRevisions,
|
|
identity.uid,
|
|
'composeRevisionRoot',
|
|
);
|
|
const selection = inspectActiveComposeImageSelection(
|
|
paths.composeSelection,
|
|
paths.composeRevisions,
|
|
identity.uid,
|
|
);
|
|
const fences = Object.freeze({
|
|
revision: fence(
|
|
paths.composeRevisionLock,
|
|
identity.uid,
|
|
'compose revision lock',
|
|
),
|
|
rollout: fence(
|
|
paths.composeRolloutLock,
|
|
identity.uid,
|
|
'compose rollout lock',
|
|
),
|
|
restore: fence(
|
|
paths.composeRestoreLock,
|
|
identity.uid,
|
|
'compose restore lock',
|
|
),
|
|
evidenceCollection: fence(
|
|
paths.composeEvidenceCollectionLock,
|
|
identity.uid,
|
|
'compose evidence collection lock',
|
|
),
|
|
});
|
|
const recoveryRequired = Object.values(fences).some(
|
|
(state) => state === 'in_flight',
|
|
);
|
|
return Object.freeze({
|
|
...common,
|
|
service: Object.freeze({
|
|
kind: 'compose' as const,
|
|
descriptor: 'present' as const,
|
|
generation: selection.generation,
|
|
rollbackTargetGeneration:
|
|
selection.rollbackTargetGeneration === 0
|
|
? null
|
|
: selection.rollbackTargetGeneration,
|
|
transition: recoveryRequired
|
|
? ('recovery_required' as const)
|
|
: ('stable' as const),
|
|
fences,
|
|
}),
|
|
});
|
|
}
|
|
|
|
export function inspectLocalDeploymentStatusCommandFile(
|
|
filePath: string,
|
|
): Readonly<LocalDeploymentStatusResult> {
|
|
return inspectLocalDeploymentStatus(readPrivateLocalCommandFile(filePath));
|
|
}
|