deploy(ai-discovery): add safe existing-service update path
This commit is contained in:
parent
7c3c8e9238
commit
2739104088
14 changed files with 427 additions and 26 deletions
|
|
@ -24,19 +24,59 @@ function safeRelative(value) {
|
|||
}
|
||||
|
||||
function validateManifest(manifest, resource) {
|
||||
if (!manifest || manifest.schema !== "guanghu.architecture-provision-request/v1") throw new Error("invalid_manifest_schema");
|
||||
if (!manifest || !["guanghu.architecture-provision-request/v1", "guanghu.existing-service-update-request/v1"].includes(manifest.schema)) throw new Error("invalid_manifest_schema");
|
||||
if (manifest.request_id !== resource.requestId || manifest.target_node !== "JD-FD-PRIMARY") throw new Error("manifest_identity_mismatch");
|
||||
if (manifest.status !== "ARCHITECTURE_PACKAGE_READY · INITIAL_PROVISION_PENDING") throw new Error("manifest_not_pending");
|
||||
if (!manifest.initial_provision || manifest.initial_provision.kind !== "new-architecture-unit") throw new Error("not_initial_architecture_unit");
|
||||
const unit = String(manifest.module && manifest.module.unit || "");
|
||||
if (!/^[A-Za-z0-9_.@-]+\.service$/.test(unit)) throw new Error("invalid_unit_name");
|
||||
if (manifest.schema === "guanghu.existing-service-update-request/v1") return validateServiceUpdateManifest(manifest, unit);
|
||||
if (manifest.status !== "ARCHITECTURE_PACKAGE_READY · INITIAL_PROVISION_PENDING") throw new Error("manifest_not_pending");
|
||||
if (!manifest.initial_provision || manifest.initial_provision.kind !== "new-architecture-unit") throw new Error("not_initial_architecture_unit");
|
||||
if (!Array.isArray(manifest.source_paths) || manifest.source_paths.length < 1 || manifest.source_paths.length > 64 || manifest.source_paths.some(item => !safeRelative(item))) throw new Error("invalid_source_paths");
|
||||
const unitMatches = manifest.source_paths.filter(item => path.basename(item) === unit);
|
||||
if (unitMatches.length !== 1) throw new Error("unit_not_uniquely_declared");
|
||||
const check = manifest.runtime_check || {};
|
||||
if (!/^http:\/\/127\.0\.0\.1:\d{2,5}\/[A-Za-z0-9._/?=&-]*$/.test(String(check.url || ""))) throw new Error("invalid_loopback_runtime_check");
|
||||
if (!check.expected || typeof check.expected !== "object" || Array.isArray(check.expected)) throw new Error("invalid_runtime_expectation");
|
||||
return { unit, unitSource: unitMatches[0], runtimeCheck: check };
|
||||
return { kind: "initial-provision", unit, unitSource: unitMatches[0], sourcePaths: manifest.source_paths, runtimeCheck: check };
|
||||
}
|
||||
|
||||
function validateServiceUpdateManifest(manifest, unit) {
|
||||
if (manifest.status !== "SERVICE_UPDATE_PACKAGE_READY · DEPLOYMENT_PENDING") throw new Error("manifest_not_pending");
|
||||
if (!manifest.service_update || manifest.service_update.kind !== "existing-systemd-service" || manifest.service_update.require_existing_unit !== true) throw new Error("not_existing_service_update");
|
||||
const installRoot = String(manifest.module && manifest.module.install_root || "");
|
||||
if (!/^\/opt\/guanghu\/[a-z0-9][a-z0-9-]{1,62}$/.test(installRoot)) throw new Error("invalid_service_install_root");
|
||||
const unitSource = String(manifest.unit_source || "");
|
||||
if (!safeRelative(unitSource) || path.basename(unitSource) !== unit) throw new Error("invalid_unit_source");
|
||||
if (!Array.isArray(manifest.files) || manifest.files.length < 1 || manifest.files.length > 64) throw new Error("invalid_update_files");
|
||||
const seenDestinations = new Set();
|
||||
for (const item of manifest.files) {
|
||||
if (!item || !safeRelative(item.source) || !safeRelative(item.destination)) throw new Error("invalid_update_file_path");
|
||||
if (!/^(?:0?644|0?755)$/.test(String(item.mode || ""))) throw new Error("invalid_update_file_mode");
|
||||
if (seenDestinations.has(item.destination)) throw new Error("duplicate_update_destination");
|
||||
seenDestinations.add(item.destination);
|
||||
}
|
||||
const requiredExisting = manifest.service_update.required_existing_files || [];
|
||||
if (!Array.isArray(requiredExisting) || requiredExisting.some(item => !safeRelative(item) || !seenDestinations.has(item))) throw new Error("invalid_required_existing_files");
|
||||
const check = manifest.runtime_check || {};
|
||||
if (!/^http:\/\/127\.0\.0\.1:\d{2,5}\/[A-Za-z0-9._/?=&-]*$/.test(String(check.url || ""))) throw new Error("invalid_loopback_runtime_check");
|
||||
if (!check.expected || typeof check.expected !== "object" || Array.isArray(check.expected)) throw new Error("invalid_runtime_expectation");
|
||||
const acceptanceChecks = manifest.acceptance_checks || [];
|
||||
if (!Array.isArray(acceptanceChecks) || acceptanceChecks.length > 16) throw new Error("invalid_acceptance_checks");
|
||||
for (const item of acceptanceChecks) {
|
||||
if (!item || !/^http:\/\/127\.0\.0\.1:\d{2,5}\/[A-Za-z0-9._/?=&-]*$/.test(String(item.url || ""))) throw new Error("invalid_acceptance_check_url");
|
||||
if (!item.expected || typeof item.expected !== "object" || Array.isArray(item.expected)) throw new Error("invalid_acceptance_expectation");
|
||||
}
|
||||
return {
|
||||
kind: "existing-service-update",
|
||||
unit,
|
||||
unitSource,
|
||||
installRoot,
|
||||
files: manifest.files,
|
||||
requiredExisting,
|
||||
sourcePaths: [unitSource, ...manifest.files.map(item => item.source)],
|
||||
runtimeCheck: check,
|
||||
acceptanceChecks,
|
||||
};
|
||||
}
|
||||
|
||||
function declaredPaths(value) {
|
||||
|
|
@ -69,6 +109,17 @@ function validateUnit(text, expectedUser = "guanghu", policy = {}) {
|
|||
return value;
|
||||
}
|
||||
|
||||
function validateUpdateUnit(text, expectedUser, installRoot) {
|
||||
const value = String(text || "");
|
||||
if (!value.includes("[Service]") || !/^NoNewPrivileges=(true|yes)$/m.test(value) || !/^ProtectSystem=strict$/m.test(value) || !/^ProtectHome=(true|yes)$/m.test(value) || !/^PrivateTmp=(true|yes)$/m.test(value)) throw new Error("unit_hardening_required");
|
||||
if (!/^[a-z_][a-z0-9_-]{0,30}$/.test(expectedUser) || expectedUser === "root" || !new RegExp(`^User=${expectedUser}$`, "m").test(value) || !new RegExp(`^Group=${expectedUser}$`, "m").test(value)) throw new Error("dedicated_service_user_required");
|
||||
if (!value.includes(`WorkingDirectory=${installRoot}`) || !value.includes(`ReadOnlyPaths=${installRoot}`)) throw new Error("service_install_root_not_confined");
|
||||
const execStart = value.match(/^ExecStart=(.+)$/m);
|
||||
if (!execStart || !execStart[1].includes(`${installRoot}/`) || /[;&|`$<>]/.test(execStart[1])) throw new Error("service_exec_start_not_confined");
|
||||
if (/^(SupplementaryGroups|AmbientCapabilities|BindPaths|BindReadOnlyPaths|RootDirectory|RootImage|DeviceAllow|EnvironmentFile|ReadWritePaths)=/m.test(value)) throw new Error("privileged_unit_directive_forbidden");
|
||||
return value;
|
||||
}
|
||||
|
||||
async function provision(request, options = {}) {
|
||||
if (!request || request.target !== "JD-FD-PRIMARY" || request.action !== "provision-approved-architecture") return { ok: false, error: "action_not_registered" };
|
||||
const resource = parseResource(request.resource);
|
||||
|
|
@ -84,10 +135,17 @@ async function provision(request, options = {}) {
|
|||
await prepareRepo(repoDir, resource.commit, run, options.repoUrl || REPO_URL);
|
||||
const manifestPath = path.join(repoDir, "deployment", "requests", `${resource.requestId}.json`);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
const checked = validateManifest(manifest, resource);
|
||||
let checked = validateManifest(manifest, resource);
|
||||
if (checked.kind === "existing-service-update") {
|
||||
if (options.installRootOverride) checked = { ...checked, installRoot: options.installRootOverride };
|
||||
return await updateExistingService({
|
||||
checked, manifest, resource, repoDir, releasesDir, unitDir, receiptsDir, run,
|
||||
getJson: options.getJson, healthAttempts: options.healthAttempts, healthDelayMs: options.healthDelayMs,
|
||||
});
|
||||
}
|
||||
const releaseRoot = path.join(releasesDir, resource.commit);
|
||||
fs.mkdirSync(releaseRoot, { recursive: true, mode: 0o755 });
|
||||
for (const relative of manifest.source_paths) copyDeclaredFile(repoDir, releaseRoot, relative);
|
||||
for (const relative of checked.sourcePaths) copyDeclaredFile(repoDir, releaseRoot, relative);
|
||||
const unitSource = path.join(releaseRoot, checked.unitSource);
|
||||
const unitText = validateUnit(fs.readFileSync(unitSource, "utf8"), String(manifest.module.run_user || ""), manifest.module).replaceAll("__RELEASE_ROOT__", releaseRoot);
|
||||
fs.mkdirSync(unitDir, { recursive: true, mode: 0o755 });
|
||||
|
|
@ -120,6 +178,98 @@ async function provision(request, options = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
async function updateExistingService(context) {
|
||||
const { checked, manifest, resource, repoDir, releasesDir, unitDir, receiptsDir, run } = context;
|
||||
const releaseRoot = path.join(releasesDir, resource.commit);
|
||||
const installedUnit = path.join(unitDir, checked.unit);
|
||||
const backupDir = path.join(receiptsDir, "backups", resource.requestId, resource.commit);
|
||||
const backups = [];
|
||||
let updateStarted = false;
|
||||
try {
|
||||
if (!fs.existsSync(installedUnit) || !fs.lstatSync(installedUnit).isFile() || fs.lstatSync(installedUnit).isSymbolicLink()) throw new Error("existing_service_unit_required");
|
||||
for (const relative of checked.requiredExisting) {
|
||||
const existing = path.join(checked.installRoot, relative);
|
||||
if (!fs.existsSync(existing) || !fs.lstatSync(existing).isFile() || fs.lstatSync(existing).isSymbolicLink()) throw new Error(`required_existing_file_missing:${relative}`);
|
||||
}
|
||||
fs.mkdirSync(releaseRoot, { recursive: true, mode: 0o755 });
|
||||
for (const relative of checked.sourcePaths) copyDeclaredFile(repoDir, releaseRoot, relative);
|
||||
const unitText = validateUpdateUnit(fs.readFileSync(path.join(releaseRoot, checked.unitSource), "utf8"), String(manifest.module.run_user || ""), checked.installRoot);
|
||||
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
|
||||
backupFile(installedUnit, path.join(backupDir, "systemd", checked.unit), backups);
|
||||
for (const item of checked.files) {
|
||||
const destination = path.join(checked.installRoot, item.destination);
|
||||
backupFile(destination, path.join(backupDir, "files", item.destination), backups);
|
||||
}
|
||||
updateStarted = true;
|
||||
fs.mkdirSync(checked.installRoot, { recursive: true, mode: 0o755 });
|
||||
for (const item of checked.files) {
|
||||
const destination = path.join(checked.installRoot, item.destination);
|
||||
if (fs.existsSync(destination) && fs.lstatSync(destination).isSymbolicLink()) throw new Error("update_destination_symlink_forbidden");
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o755 });
|
||||
writeAtomic(destination, fs.readFileSync(path.join(releaseRoot, item.source)), Number.parseInt(String(item.mode), 8));
|
||||
}
|
||||
writeAtomic(installedUnit, unitText, 0o644);
|
||||
await run("/usr/bin/systemctl", ["daemon-reload"]);
|
||||
await run("/usr/bin/systemctl", ["restart", checked.unit]);
|
||||
const runtime = await getJsonWithRetry(checked.runtimeCheck.url, context.getJson, context.healthAttempts, context.healthDelayMs);
|
||||
for (const [key, expected] of Object.entries(checked.runtimeCheck.expected)) if (runtime[key] !== expected) throw new Error(`runtime_check_failed:${key}`);
|
||||
for (const check of checked.acceptanceChecks) {
|
||||
const observed = await getJsonWithRetry(check.url, context.getJson, context.healthAttempts, context.healthDelayMs);
|
||||
for (const [key, expected] of Object.entries(check.expected)) if (observed[key] !== expected) throw new Error(`acceptance_check_failed:${key}`);
|
||||
}
|
||||
const receipt = {
|
||||
schema: "guanghu.existing-service-update-receipt/v1",
|
||||
request_id: resource.requestId,
|
||||
source_commit: resource.commit,
|
||||
target_node: "JD-FD-PRIMARY",
|
||||
unit: checked.unit,
|
||||
install_root: checked.installRoot,
|
||||
runtime_check: checked.runtimeCheck.url,
|
||||
acceptance_checks: checked.acceptanceChecks.map(item => item.url),
|
||||
backup: path.join("backups", resource.requestId, resource.commit),
|
||||
rollback: "restore-all-declared-files-and-previous-unit",
|
||||
result: "DEPLOYED_AND_VERIFIED",
|
||||
recorded_at: new Date().toISOString(),
|
||||
};
|
||||
fs.mkdirSync(receiptsDir, { recursive: true, mode: 0o700 });
|
||||
writeAtomic(path.join(receiptsDir, `${resource.requestId}.json`), `${JSON.stringify(receipt, null, 2)}\n`, 0o600);
|
||||
return { ok: true, request_id: resource.requestId, source_commit: resource.commit, unit: checked.unit, runtime: "verified" };
|
||||
} catch (error) {
|
||||
if (updateStarted) {
|
||||
try {
|
||||
restoreBackups(backups);
|
||||
await run("/usr/bin/systemctl", ["daemon-reload"]);
|
||||
await run("/usr/bin/systemctl", ["restart", checked.unit]);
|
||||
} catch { /* The original error remains authoritative; backups stay available for recovery. */ }
|
||||
}
|
||||
return { ok: false, error: String(error && error.message || "service_update_failed").slice(0, 240) };
|
||||
}
|
||||
}
|
||||
|
||||
function backupFile(source, backup, records) {
|
||||
if (!fs.existsSync(source)) {
|
||||
records.push({ source, backup, existed: false });
|
||||
return;
|
||||
}
|
||||
const stat = fs.lstatSync(source);
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("backup_source_not_regular_file");
|
||||
fs.mkdirSync(path.dirname(backup), { recursive: true, mode: 0o700 });
|
||||
fs.copyFileSync(source, backup, fs.constants.COPYFILE_EXCL);
|
||||
fs.chmodSync(backup, stat.mode & 0o777);
|
||||
records.push({ source, backup, existed: true, mode: stat.mode & 0o777 });
|
||||
}
|
||||
|
||||
function restoreBackups(records) {
|
||||
for (const record of records.slice().reverse()) {
|
||||
if (!record.existed) {
|
||||
fs.rmSync(record.source, { force: true });
|
||||
continue;
|
||||
}
|
||||
fs.mkdirSync(path.dirname(record.source), { recursive: true, mode: 0o755 });
|
||||
writeAtomic(record.source, fs.readFileSync(record.backup), record.mode);
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareRepo(repoDir, commit, run, repoUrl) {
|
||||
fs.mkdirSync(path.dirname(repoDir), { recursive: true, mode: 0o700 });
|
||||
if (!fs.existsSync(path.join(repoDir, ".git"))) await run("/usr/bin/git", ["clone", "--filter=blob:none", "--no-checkout", repoUrl, repoDir]);
|
||||
|
|
@ -197,4 +347,4 @@ if (require.main === module) {
|
|||
server.listen(SOCKET_PATH, () => { fs.chownSync(SOCKET_PATH, 0, Number(process.env.LAKE_LAMP_AUTHZ_GID || 0)); fs.chmodSync(SOCKET_PATH, 0o660); });
|
||||
}
|
||||
|
||||
module.exports = { parseResource, safeRelative, validateManifest, validateUnit, provision };
|
||||
module.exports = { parseResource, safeRelative, validateManifest, validateUnit, validateUpdateUnit, provision };
|
||||
|
|
|
|||
Loading…
Reference in a new issue