src/services/vps-operation-lock.js
Rama main · réplica de lectura del repositorio original.
import {
createVpsLockCommand, releaseVpsLockCommand, createVpsBuildStateCommand,
scopeVpsBuildCommand, cleanupVpsBuildStateCommand,
claimVpsResumeLockCommand, releaseVpsResumeClaimCommand,
} from "../domain/vps-build-state.js";
const validIdentity = value => typeof value === "string" && /^\d+:\d+$/u.test(value);
// The server shows a failure only when it is at most 240 characters and holds
// no absolute path, so these texts name the lock by its file name. The code
// lets the setup guide say where that lock lives.
const SAFE_MESSAGE_LIMIT = 240;
const LOCK_CODE = "vps_operation_lock";
const lockName = lock => lock.slice(lock.lastIndexOf("/") + 1);
const heldMessage = lock => `The operation lock ${lockName(lock)} is held or uncertain. Another setup may be running, or an earlier one was interrupted. An administrator must inspect it before retrying.`;
const recoveryMessage = lock => `The operation lock ${lockName(lock)} or its temporary build state could not be removed safely. An administrator must inspect it before retrying; Relmio will not delete uncertain state.`;
const recoveryError = lock => Object.assign(new Error(recoveryMessage(lock)), { code: LOCK_CODE });
// A failed operation keeps its own sentence. The lock notice shrinks before the
// whole text can pass the limit, and replaces it only as a last resort.
const withLockNotice = (base, lock) => [
`${base} ${recoveryMessage(lock)}`,
`${base} An administrator must inspect ${lockName(lock)} before retrying.`,
].find(text => text.length <= SAFE_MESSAGE_LIMIT) ?? recoveryMessage(lock);
// Not every caller has passed a final confirmation: the bridge's image sign-in poll and cancel
// take this lock without one. So this helper and its lock commands create and remove only the
// lock folder and its build state, and never change the shared folder itself. Build state is
// lazy: status, removal and cached retries never create it. Grok credential launches use
// Compose run, which may build a missing image and therefore needs isolation.
export async function withVpsOperationLock(remote, lock, operation, { resumeIdentity } = {}) {
const uncertainMessage = heldMessage(lock);
let acquired;
let claimIdentity;
try {
acquired = await remote.exec(resumeIdentity
? claimVpsResumeLockCommand(lock, resumeIdentity) : createVpsLockCommand(lock));
} catch (cause) {
throw Object.assign(new Error(uncertainMessage, { cause }), {
safeMessage: uncertainMessage, code: LOCK_CODE, operationLockRetained: true,
...(cause?.remoteOutcomeUnknown === true ? { remoteOutcomeUnknown: true } : {}),
...(cause?.timedOut === true ? { timedOut: true } : {}),
});
}
if (resumeIdentity) claimIdentity = acquired.stdout?.trim();
const identity = resumeIdentity ?? acquired.stdout?.trim();
if (acquired.code !== 0 || !validIdentity(identity) ||
(resumeIdentity && !validIdentity(claimIdentity))) {
throw Object.assign(new Error(uncertainMessage), {
safeMessage: uncertainMessage, code: LOCK_CODE, operationLockRetained: true,
});
}
let stateIdentity;
let stateUncertain = false;
let buildUncertain = false;
let primary;
let result;
const build = async command => {
if (!stateIdentity) {
stateUncertain = true;
const created = await remote.exec(createVpsBuildStateCommand(lock, identity));
stateIdentity = created.stdout?.trim();
if (created.code !== 0 || !validIdentity(stateIdentity)) {
stateIdentity = undefined;
throw recoveryError(lock);
}
stateUncertain = false;
}
const scoped = scopeVpsBuildCommand(lock, identity, stateIdentity, command);
buildUncertain = true;
const built = await remote.exec(scoped, { timeoutMs: 1_800_000 });
if (!Number.isInteger(built?.code)) throw Object.assign(
new Error("The companion build result could not be confirmed."), { remoteOutcomeUnknown: true });
buildUncertain = false;
if (built.code !== 0) throw Object.assign(new Error("The confirmed companion image build or launch failed. The existing n8n deployment was not changed."), { code: "vps_step_build" });
};
build.releaseState = async () => {
if (!stateIdentity || stateUncertain || buildUncertain) throw recoveryError(lock);
stateUncertain = true;
const removed = await remote.exec(cleanupVpsBuildStateCommand(lock, identity, stateIdentity));
if (removed.code !== 0) throw recoveryError(lock);
stateIdentity = undefined;
stateUncertain = false;
};
try {
result = await operation(build);
} catch (error) {
primary = error;
}
let cleanupFailed = stateUncertain || buildUncertain ||
primary?.remoteOutcomeUnknown === true || result?.remoteOutcomeUnknown === true;
try {
// A disconnected exec may leave Compose running. Never clean underneath it.
if (!cleanupFailed && stateIdentity && (await remote.exec(cleanupVpsBuildStateCommand(lock, identity, stateIdentity))).code !== 0) cleanupFailed = true;
if (!cleanupFailed && claimIdentity &&
(await remote.exec(releaseVpsResumeClaimCommand(lock, identity, claimIdentity))).code !== 0) cleanupFailed = true;
// Never release an uncertain lease, even if a foreign actor emptied it.
if (!cleanupFailed && (await remote.exec(releaseVpsLockCommand(lock, identity))).code !== 0) cleanupFailed = true;
} catch {
cleanupFailed = true;
}
if (cleanupFailed) {
const message = recoveryMessage(lock);
if (!primary && result?.credentialShownOnce === true && typeof result.clientCredential === "string") {
const partial = {
...result, deploymentMode: "partial", readiness: "unverified",
operationLockRetained: true,
finalizationFailure: { error: message, recovery: "resolve-handoff" },
};
if (result.migratedLegacy === true) partial.migrationPending = true;
if (result.replacedAccount === true) partial.replacementPending = true;
delete partial.migratedLegacy;
delete partial.replacedAccount;
return partial;
}
if (primary) {
// Keep the primary exception and recovery classification; add only fixed,
// safe cleanup text, never remote command output or credential material.
primary.message = `${primary.message} ${message}`;
primary.safeMessage = withLockNotice(primary.safeMessage ?? "The confirmed operation failed.", lock);
primary.code ??= LOCK_CODE;
primary.operationLockRetained = true;
throw primary;
}
throw Object.assign(new Error(message), { safeMessage: message, code: LOCK_CODE, operationLockRetained: true });
}
if (primary) throw primary;
return result;
}