mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 22:17:50 +00:00
f5178889eb
* feat(recovery): capture complete authored Compose project for atomic rollback Replace the root-compose-only backup slot with staged recovery generations that record the managed inventory, exact Compose invocation, and prior image identity, and wire the same engine through deploy, update, manual rollback, and Git apply. * fix(recovery): satisfy CodeQL path barriers and update-guard mock Inline resolve+startsWith checks at generation/inventory fs sinks and stub getCurrentStackUpdateRecovery in UpdateGuardService tests. * fix(recovery): drop unused FileSystemService import in generation store test * fix(recovery): harden authored-project rollback for upgrade and restore safety Preserve legacy UUID backup rows, restore Git deploy state with files, make multi-file restore recoverable, evaluate policy on the restored target, and fail closed when Git capture cannot cover an apply. * fix(recovery): unblock Git apply unit tests and CodeQL pre-restore TOCTOU Mock recovery capture in git-source-service tests after fail-closed apply capture, and re-resolve live paths immediately before pre-restore snapshot reads. * fix(recovery): fall back to authored inventory when Git manifesto is missing First Git apply captures before promote, so a missing managed-project manifesto must not block rollback capture when the live stack already has authored files. * fix(recovery): make authored-project rollback atomic across Git state Restore the managed-project manifesto with files, keep nullable Git identity on first-apply captures, persist Git side-state in restore intents for startup reconcile, compensate legacy materialize failures, and refuse directory collisions before mutation. * fix(recovery): satisfy CodeQL path and TOCTOU barriers on manifesto restore Add inline resolve barriers for manifesto read/clear sinks and remove the access-then-read race when restoring a generation manifesto snapshot. * fix(recovery): close third-audit rollback generation blockers Fail closed on incomplete Git inventory fallbacks, execute captured Compose invocation during recovery, refuse startup and mutations while restore intents remain unresolved, propagate legacy stale-delete failures, and add Docker-level exact prior-image coverage plus regression tests. * fix(recovery): mark acquired before handoff in prior-image Docker test Match the production updateStack CAS sequence so the exact prior-image integration test does not fail handoff from the captured phase. * fix(recovery): close fourth-audit rollback safety blockers Evaluate policy against held images, use index-based pre-restore snapshots, hold the shared stack lock across Git apply, replay Mesh and empty captured invocations exactly, restore POSIX modes with fail-closed sensitive permissions, keep case-sensitive paths, and link Git auto-deploy health gates. Add regression coverage for these cases. * test(recovery): fix mocks for health-gate link and authored compose args Add linkGateOrRetain to the Git apply recovery mock, and mock authoredComposeArgs so the case-collision inventory test is not masked by a missing getComposeDir stub. * fix(recovery): close fifth-audit rollback safety blockers Share git_apply locking for webhook auto-apply, fail closed on malformed recovery service records, refuse mixed-image capture, and require exact probe counts with hold-tag eligibility checks. * fix(recovery): close sixth-audit rollback safety blockers Preserve the legacy backup slot during generation capture, encrypt sensitive pre-restore snapshots, revert files on a failed health probe without committing Git, fail closed when an absent-file revert would delete a directory, skip Compose one-offs, route manual and scheduled backup through the current generation, and persist runtime image platform identity. * fix(recovery): close seventh-audit rollback safety blockers Fleet snapshot restore and restore-all now capture a recovery generation under the stack lock before any authored file write, including on remote nodes. * fix(recovery): keep pre-deploy generations during health-gate observe Link deploy recovery generations to the observing gate so backup cannot replace them mid-observe. Distinguish missing hold tags from probe failures, refuse generation release when services metadata is corrupt, classify mixed-replica and coverage refusals, and toast the backend rollback message. * fix(recovery): wrap webhook deploy case for eslint const bindings in an unbraced switch case trip no-case-declarations. Match the pull case block.
566 lines
19 KiB
TypeScript
566 lines
19 KiB
TypeScript
/**
|
|
* Resolve the authored-project file set that an atomic rollback generation
|
|
* must capture. Git-managed stacks consume the managed-project manifest;
|
|
* authored stacks rediscover against the live stack directory.
|
|
*/
|
|
import { promises as fsPromises, readFileSync } from 'fs';
|
|
import path from 'path';
|
|
import { DatabaseService, type StackGitSource } from './DatabaseService';
|
|
import { FileSystemService } from './FileSystemService';
|
|
import { GitProjectManifestService } from './GitProjectManifestService';
|
|
import { collectManifestFilePaths } from '../helpers/manifestFilePaths';
|
|
import { isHostAbsolutePath, parseDeclaredInputs } from '../helpers/composeInputParse';
|
|
import { isValidRelativeStackPath, isValidStackName } from '../utils/validation';
|
|
import { authoredComposeEnvFileArgs, authoredComposeFileArgs } from '../utils/authoredComposeArgs';
|
|
import type {
|
|
ComposeInputEntry,
|
|
GitProjectManifest,
|
|
InputSensitivity,
|
|
} from '../types/gitProjectManifest';
|
|
import type {
|
|
ResolvedRollbackInventory,
|
|
RollbackEntryKind,
|
|
RollbackEntryProvenance,
|
|
RollbackInvocationRecord,
|
|
} from '../types/rollbackGeneration';
|
|
|
|
const ROOT_COMPOSE_FILENAMES = [
|
|
'compose.yaml',
|
|
'compose.yml',
|
|
'docker-compose.yaml',
|
|
'docker-compose.yml',
|
|
] as const;
|
|
|
|
const DOT_ENV = '.' + 'env';
|
|
|
|
const COMPOSE_INVOCATION_KINDS = new Set<RollbackEntryKind>([
|
|
'compose-root',
|
|
'implicit-override',
|
|
'explicit',
|
|
'include',
|
|
'extends',
|
|
]);
|
|
|
|
type InventoryAccum = {
|
|
relativePath: string;
|
|
dependencyKind: RollbackEntryKind;
|
|
provenance: RollbackEntryProvenance;
|
|
sensitivity: InputSensitivity;
|
|
absolutePath: string | null;
|
|
};
|
|
|
|
function posixRel(rel: string): string {
|
|
return rel.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
}
|
|
|
|
function foldedKey(rel: string): string {
|
|
return posixRel(rel).toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* Prefer exact POSIX paths as map keys so case-distinct Linux paths are kept.
|
|
* When two different paths collide under case-folding, record a refusal note
|
|
* instead of silently merging them.
|
|
*/
|
|
function upsertEntry(
|
|
map: Map<string, InventoryAccum>,
|
|
foldedOwners: Map<string, string>,
|
|
entry: InventoryAccum,
|
|
caseCollisions: string[],
|
|
): void {
|
|
const exact = posixRel(entry.relativePath);
|
|
const folded = foldedKey(exact);
|
|
const owner = foldedOwners.get(folded);
|
|
if (owner !== undefined && owner !== exact) {
|
|
caseCollisions.push(`Case-colliding managed paths "${owner}" and "${exact}"`);
|
|
return;
|
|
}
|
|
const prev = map.get(exact);
|
|
if (!prev) {
|
|
map.set(exact, { ...entry, relativePath: exact });
|
|
foldedOwners.set(folded, exact);
|
|
return;
|
|
}
|
|
if (prev.absolutePath !== null && entry.absolutePath === null) return;
|
|
map.set(exact, {
|
|
...entry,
|
|
relativePath: exact,
|
|
absolutePath: entry.absolutePath ?? prev.absolutePath,
|
|
dependencyKind: prev.dependencyKind === 'compose-root' && entry.dependencyKind !== 'compose-root'
|
|
? entry.dependencyKind
|
|
: entry.dependencyKind,
|
|
sensitivity: higherSensitivity(entry.sensitivity, prev.sensitivity),
|
|
});
|
|
}
|
|
|
|
function resolveStackRoot(fsSvc: FileSystemService, stackName: string): string {
|
|
if (!isValidStackName(stackName)) {
|
|
throw Object.assign(new Error('Invalid stack name'), { code: 'INVALID_STACK_NAME' });
|
|
}
|
|
// Canonical js/path-injection barrier: resolve + startsWith.
|
|
// CodeQL does not credit isPathWithinBase helpers at later sinks.
|
|
const base = path.resolve(fsSvc.getBaseDir());
|
|
const stackRoot = path.resolve(base, stackName);
|
|
if (!stackRoot.startsWith(base + path.sep)) {
|
|
throw Object.assign(new Error('Stack name escapes compose directory'), { code: 'INVALID_PATH' });
|
|
}
|
|
return stackRoot;
|
|
}
|
|
|
|
function resolveStackRel(
|
|
stackRoot: string,
|
|
relRaw: string,
|
|
): { relativePath: string; absolutePath: string } | null {
|
|
const relativePath = posixRel(relRaw);
|
|
if (!relativePath || !isValidRelativeStackPath(relativePath)) return null;
|
|
// Join-time containment; callers still re-check at each fs sink.
|
|
const baseResolved = path.resolve(stackRoot);
|
|
const absolutePath = path.resolve(baseResolved, relativePath);
|
|
if (!absolutePath.startsWith(baseResolved + path.sep)) return null;
|
|
return { relativePath, absolutePath };
|
|
}
|
|
|
|
async function pathExistsAsFile(stackRoot: string, relativePath: string): Promise<boolean> {
|
|
// Inline barrier at the lstat sink.
|
|
const baseResolved = path.resolve(stackRoot);
|
|
const abs = path.resolve(baseResolved, relativePath);
|
|
if (!abs.startsWith(baseResolved + path.sep)) return false;
|
|
try {
|
|
const st = await fsPromises.lstat(abs);
|
|
return st.isFile() || st.isSymbolicLink();
|
|
} catch (e) {
|
|
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
function authoredSensitivity(kind: RollbackEntryKind): InputSensitivity {
|
|
if (kind === 'secret' || kind === 'config' || kind === 'build-secret') return 'high';
|
|
if (
|
|
kind === 'env_file'
|
|
|| kind === 'include-env'
|
|
|| kind === 'interpolation-env'
|
|
|| kind === 'sync-env'
|
|
|| kind === 'project-env'
|
|
|| kind === 'label_file'
|
|
) {
|
|
return 'medium';
|
|
}
|
|
return 'low';
|
|
}
|
|
|
|
function higherSensitivity(a: InputSensitivity, b: InputSensitivity): InputSensitivity {
|
|
if (a === 'high' || b === 'high') return 'high';
|
|
if (a === 'medium' || b === 'medium') return 'medium';
|
|
return 'low';
|
|
}
|
|
|
|
function appliedDeploySpecString(
|
|
spec: { files: string[]; contextDir: string | null } | null | undefined,
|
|
): string | null {
|
|
if (!spec) return null;
|
|
return JSON.stringify(spec);
|
|
}
|
|
|
|
function refusedGitInventory(
|
|
gitSource: StackGitSource,
|
|
emptyInvocation: RollbackInvocationRecord,
|
|
coverageRefusal: string,
|
|
manifestVersion: number | null = gitSource.manifest_version,
|
|
): ResolvedRollbackInventory {
|
|
return {
|
|
entries: [],
|
|
invocation: emptyInvocation,
|
|
git: {
|
|
repoUrl: gitSource.repo_url,
|
|
branch: gitSource.branch,
|
|
commitSha: gitSource.last_applied_commit_sha || '',
|
|
manifestVersion,
|
|
},
|
|
appliedDeploySpec: appliedDeploySpecString(gitSource.applied_deploy_spec),
|
|
lastAppliedContentHash: gitSource.last_applied_content_hash,
|
|
manifestState: gitSource.manifest_state,
|
|
manifestGeneration: gitSource.manifest_generation,
|
|
exactCoverage: false,
|
|
coverageRefusal,
|
|
};
|
|
}
|
|
|
|
function isGitManifest(
|
|
value: GitProjectManifest | { corrupt: string } | null,
|
|
): value is GitProjectManifest {
|
|
return value !== null && !('corrupt' in value);
|
|
}
|
|
|
|
function sensitivityForManifestPath(
|
|
manifest: GitProjectManifest,
|
|
rel: string,
|
|
): { kind: RollbackEntryKind; sensitivity: InputSensitivity; provenance: RollbackEntryProvenance } {
|
|
const key = foldedKey(rel);
|
|
const input = manifest.inputs.find(
|
|
(i: ComposeInputEntry) => i.materializedPath !== null && foldedKey(i.materializedPath) === key,
|
|
);
|
|
if (input) {
|
|
return {
|
|
kind: input.dependencyKind,
|
|
sensitivity: input.sensitivity,
|
|
provenance: input.provenance,
|
|
};
|
|
}
|
|
return { kind: 'other', sensitivity: 'low', provenance: 'fetch' };
|
|
}
|
|
|
|
async function resolveGitInventory(
|
|
nodeId: number,
|
|
stackName: string,
|
|
stackRoot: string,
|
|
): Promise<ResolvedRollbackInventory | null> {
|
|
const gitSource = DatabaseService.getInstance().getGitSource(stackName);
|
|
if (!gitSource) return null;
|
|
|
|
const emptyInvocation: RollbackInvocationRecord = {
|
|
composeArgsPrefix: [],
|
|
projectDirectory: null,
|
|
projectName: stackName,
|
|
explicitComposeFiles: [],
|
|
meshOverrideRelativePath: null,
|
|
meshEnabled: false,
|
|
};
|
|
|
|
const read = await GitProjectManifestService.getInstance().readManifest(
|
|
stackName,
|
|
gitSource.repo_url,
|
|
gitSource.branch,
|
|
);
|
|
if (!isGitManifest(read)) {
|
|
const corrupt = Boolean(read && 'corrupt' in read);
|
|
const reason = corrupt
|
|
? `Managed-project manifest is unreadable (${(read as { corrupt: string }).corrupt}). Fix or re-link the Git source before capturing rollback coverage.`
|
|
: 'Managed-project manifest is missing. Pull or re-link the Git source before capturing rollback coverage.';
|
|
|
|
const established = Boolean(
|
|
gitSource.applied_deploy_spec
|
|
|| gitSource.last_applied_content_hash
|
|
|| gitSource.last_applied_commit_sha,
|
|
);
|
|
|
|
// Established missing/corrupt manifesto: fail closed. applied_deploy_spec
|
|
// alone cannot claim exact managed-input coverage (includes, extends, env,
|
|
// labels, configs, secrets, and build inputs are omitted).
|
|
if (established || corrupt) {
|
|
return refusedGitInventory(gitSource, emptyInvocation, reason);
|
|
}
|
|
|
|
// First apply (no applied revision yet): signal incomplete Git coverage so
|
|
// resolveRollbackInventory can merge authored disk files with this identity.
|
|
return refusedGitInventory(gitSource, emptyInvocation, reason, null);
|
|
}
|
|
|
|
const map = new Map<string, InventoryAccum>();
|
|
const foldedOwners = new Map<string, string>();
|
|
const caseCollisions: string[] = [];
|
|
for (const rel of collectManifestFilePaths(read)) {
|
|
const resolved = resolveStackRel(stackRoot, rel);
|
|
if (!resolved) continue;
|
|
const meta = sensitivityForManifestPath(read, resolved.relativePath);
|
|
const exists = await pathExistsAsFile(stackRoot, resolved.relativePath);
|
|
upsertEntry(map, foldedOwners, {
|
|
relativePath: resolved.relativePath,
|
|
dependencyKind: meta.kind,
|
|
provenance: meta.provenance,
|
|
sensitivity: meta.sensitivity,
|
|
absolutePath: exists ? resolved.absolutePath : null,
|
|
}, caseCollisions);
|
|
}
|
|
|
|
const refused = read.refusals.length > 0
|
|
|| read.counts.refused > 0
|
|
|| read.state === 'unsupported'
|
|
|| read.state === 'partial'
|
|
|| caseCollisions.length > 0;
|
|
const coverageRefusal = refused
|
|
? (caseCollisions[0]
|
|
?? read.refusals[0]?.reason
|
|
?? `Managed-project manifest state "${read.state}" does not claim exact coverage`)
|
|
: null;
|
|
|
|
let meshEnabled = false;
|
|
let meshReadFailed: string | null = null;
|
|
try {
|
|
meshEnabled = DatabaseService.getInstance().isMeshStackEnabled(nodeId, stackName);
|
|
} catch (e) {
|
|
meshReadFailed = `Could not read Mesh enablement: ${(e as Error).message}`;
|
|
}
|
|
|
|
const invocation: RollbackInvocationRecord = {
|
|
composeArgsPrefix: [...read.project.invocation],
|
|
projectDirectory: read.project.effectiveProjectDir,
|
|
projectName: read.project.projectName || stackName,
|
|
explicitComposeFiles: [...read.project.composeFiles],
|
|
meshOverrideRelativePath: null,
|
|
meshEnabled,
|
|
};
|
|
|
|
const meshRefused = meshReadFailed !== null;
|
|
return {
|
|
entries: [...map.values()].sort((a, b) => a.relativePath.localeCompare(b.relativePath)),
|
|
invocation,
|
|
git: {
|
|
repoUrl: read.repo.url,
|
|
branch: read.repo.branch,
|
|
commitSha: read.resolvedRevision.commitSha || gitSource.last_applied_commit_sha || '',
|
|
manifestVersion: read.manifestVersion,
|
|
},
|
|
appliedDeploySpec: appliedDeploySpecString(gitSource.applied_deploy_spec),
|
|
lastAppliedContentHash: gitSource.last_applied_content_hash,
|
|
manifestState: gitSource.manifest_state,
|
|
manifestGeneration: gitSource.manifest_generation,
|
|
exactCoverage: !refused && !meshRefused,
|
|
coverageRefusal: coverageRefusal ?? meshReadFailed,
|
|
};
|
|
}
|
|
|
|
async function resolveAuthoredInventory(
|
|
nodeId: number,
|
|
stackName: string,
|
|
stackRoot: string,
|
|
fsSvc: FileSystemService,
|
|
): Promise<ResolvedRollbackInventory> {
|
|
const map = new Map<string, InventoryAccum>();
|
|
const foldedOwners = new Map<string, string>();
|
|
const coverageNotes: string[] = [];
|
|
const caseCollisions: string[] = [];
|
|
|
|
const composePaths: string[] = [];
|
|
for (const name of ROOT_COMPOSE_FILENAMES) {
|
|
const resolved = resolveStackRel(stackRoot, name);
|
|
if (!resolved) continue;
|
|
if (!(await pathExistsAsFile(stackRoot, resolved.relativePath))) continue;
|
|
composePaths.push(resolved.relativePath);
|
|
upsertEntry(map, foldedOwners, {
|
|
relativePath: resolved.relativePath,
|
|
dependencyKind: 'compose-root',
|
|
provenance: 'authored',
|
|
sensitivity: 'low',
|
|
absolutePath: resolved.absolutePath,
|
|
}, caseCollisions);
|
|
}
|
|
|
|
const overrideName = await fsSvc.getOverrideFilename(stackName);
|
|
if (overrideName) {
|
|
const resolved = resolveStackRel(stackRoot, overrideName);
|
|
if (resolved && await pathExistsAsFile(stackRoot, resolved.relativePath)) {
|
|
if (!composePaths.includes(resolved.relativePath)) {
|
|
composePaths.push(resolved.relativePath);
|
|
}
|
|
upsertEntry(map, foldedOwners, {
|
|
relativePath: resolved.relativePath,
|
|
dependencyKind: 'implicit-override',
|
|
provenance: 'authored',
|
|
sensitivity: 'low',
|
|
absolutePath: resolved.absolutePath,
|
|
}, caseCollisions);
|
|
}
|
|
}
|
|
|
|
const envCandidates = new Set<string>([DOT_ENV]);
|
|
for (const f of DatabaseService.getInstance().getStackProjectEnvFiles(nodeId, stackName)) {
|
|
envCandidates.add(posixRel(f));
|
|
}
|
|
for (const envFile of envCandidates) {
|
|
const resolved = resolveStackRel(stackRoot, envFile);
|
|
if (!resolved) continue;
|
|
if (!(await pathExistsAsFile(stackRoot, resolved.relativePath))) continue;
|
|
upsertEntry(map, foldedOwners, {
|
|
relativePath: resolved.relativePath,
|
|
dependencyKind: envFile === DOT_ENV ? 'interpolation-env' : 'project-env',
|
|
provenance: 'authored',
|
|
sensitivity: authoredSensitivity(envFile === DOT_ENV ? 'interpolation-env' : 'project-env'),
|
|
absolutePath: resolved.absolutePath,
|
|
}, caseCollisions);
|
|
}
|
|
|
|
const readCallback = (repoPath: string): string | null => {
|
|
const relativePath = posixRel(repoPath);
|
|
if (!relativePath || !isValidRelativeStackPath(relativePath)) return null;
|
|
// Inline barrier at the readFileSync sink.
|
|
const baseResolved = path.resolve(stackRoot);
|
|
const abs = path.resolve(baseResolved, relativePath);
|
|
if (!abs.startsWith(baseResolved + path.sep)) return null;
|
|
try {
|
|
return readFileSync(abs, 'utf8');
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
if (composePaths.length > 0) {
|
|
const orderedContents: Array<{ path: string; content: string }> = [];
|
|
for (const rel of composePaths) {
|
|
if (!isValidRelativeStackPath(rel)) continue;
|
|
// Inline barrier at the readFile sink (same form as readCallback).
|
|
const baseResolved = path.resolve(stackRoot);
|
|
const abs = path.resolve(baseResolved, rel);
|
|
if (!abs.startsWith(baseResolved + path.sep)) continue;
|
|
try {
|
|
const content = await fsPromises.readFile(abs, 'utf8');
|
|
orderedContents.push({ path: rel, content });
|
|
} catch (e) {
|
|
coverageNotes.push(`Could not read compose file ${rel}: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
|
|
if (orderedContents.length > 0) {
|
|
const parsed = parseDeclaredInputs(orderedContents, {
|
|
projectRoot: null,
|
|
read: readCallback,
|
|
});
|
|
|
|
if (parsed.parseErrors.length > 0) {
|
|
coverageNotes.push(...parsed.parseErrors);
|
|
}
|
|
if (parsed.dynamic.length > 0) {
|
|
coverageNotes.push(
|
|
`${parsed.dynamic.length} dynamic path declaration(s) cannot be captured exactly`,
|
|
);
|
|
}
|
|
|
|
for (const input of parsed.inputs) {
|
|
const hostAbs = (input.sourcePath !== null && isHostAbsolutePath(input.sourcePath))
|
|
|| input.baseDir === 'host'
|
|
|| input.materializedPath === null;
|
|
|
|
if (hostAbs) {
|
|
if (input.kind === 'include' || input.kind === 'extends') {
|
|
coverageNotes.push(
|
|
`Host-absolute ${input.kind} path cannot be captured for exact rollback`,
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const candidate = input.materializedPath ?? input.sourcePath;
|
|
if (!candidate) continue;
|
|
const resolved = resolveStackRel(stackRoot, candidate);
|
|
if (!resolved) continue;
|
|
if (!(await pathExistsAsFile(stackRoot, resolved.relativePath))) continue;
|
|
|
|
const kind = input.kind;
|
|
upsertEntry(map, foldedOwners, {
|
|
relativePath: resolved.relativePath,
|
|
dependencyKind: kind,
|
|
provenance: 'authored',
|
|
sensitivity: authoredSensitivity(kind),
|
|
absolutePath: resolved.absolutePath,
|
|
}, caseCollisions);
|
|
}
|
|
}
|
|
}
|
|
|
|
let composeArgsPrefix: string[] = [];
|
|
try {
|
|
composeArgsPrefix = [
|
|
...authoredComposeFileArgs(stackName, nodeId),
|
|
...(await authoredComposeEnvFileArgs(stackName, nodeId)),
|
|
];
|
|
} catch (e) {
|
|
coverageNotes.push(`Could not build compose invocation args: ${(e as Error).message}`);
|
|
}
|
|
|
|
const explicitComposeFiles = [...map.values()]
|
|
.filter((e) => COMPOSE_INVOCATION_KINDS.has(e.dependencyKind))
|
|
.map((e) => e.relativePath)
|
|
.sort((a, b) => a.localeCompare(b));
|
|
|
|
if (caseCollisions.length > 0) {
|
|
coverageNotes.push(caseCollisions[0]);
|
|
}
|
|
|
|
let meshEnabled = false;
|
|
try {
|
|
meshEnabled = DatabaseService.getInstance().isMeshStackEnabled(nodeId, stackName);
|
|
} catch (e) {
|
|
coverageNotes.push(`Could not read Mesh enablement: ${(e as Error).message}`);
|
|
}
|
|
|
|
const exactCoverage = coverageNotes.length === 0 && composePaths.length > 0;
|
|
const coverageRefusal = exactCoverage
|
|
? null
|
|
: (coverageNotes[0]
|
|
?? (composePaths.length === 0
|
|
? 'No compose file found in the stack directory'
|
|
: 'Exact coverage unavailable'));
|
|
|
|
return {
|
|
entries: [...map.values()].sort((a, b) => a.relativePath.localeCompare(b.relativePath)),
|
|
invocation: {
|
|
composeArgsPrefix,
|
|
projectDirectory: null,
|
|
projectName: stackName,
|
|
explicitComposeFiles: explicitComposeFiles.length > 0 ? explicitComposeFiles : composePaths,
|
|
meshOverrideRelativePath: null,
|
|
meshEnabled,
|
|
},
|
|
git: null,
|
|
appliedDeploySpec: null,
|
|
lastAppliedContentHash: null,
|
|
manifestState: null,
|
|
manifestGeneration: null,
|
|
exactCoverage,
|
|
coverageRefusal,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Prefer an exact Git-managed inventory when available. When the manifesto is
|
|
* missing on a true first apply (no applied revision yet), merge authored disk
|
|
* discovery with the Git identity fields so capture preserves nullable Git
|
|
* state. Established missing/corrupt manifesto cases fail closed via
|
|
* resolveGitInventory and are not overwritten by authored exactCoverage.
|
|
*/
|
|
export async function resolveRollbackInventory(
|
|
nodeId: number,
|
|
stackName: string,
|
|
): Promise<ResolvedRollbackInventory> {
|
|
const fsSvc = FileSystemService.getInstance(nodeId);
|
|
const stackRoot = resolveStackRoot(fsSvc, stackName);
|
|
|
|
try {
|
|
const gitInventory = await resolveGitInventory(nodeId, stackName, stackRoot);
|
|
if (gitInventory?.exactCoverage) return gitInventory;
|
|
|
|
const authored = await resolveAuthoredInventory(nodeId, stackName, stackRoot, fsSvc);
|
|
|
|
if (gitInventory && !gitInventory.exactCoverage) {
|
|
const established = Boolean(
|
|
gitInventory.appliedDeploySpec
|
|
|| gitInventory.lastAppliedContentHash
|
|
|| gitInventory.git?.commitSha,
|
|
);
|
|
const firstApplyCorrupt = Boolean(gitInventory.coverageRefusal?.includes('unreadable'));
|
|
// Established or corrupt first-apply: fail closed. Otherwise merge Git
|
|
// identity onto authored exact coverage for a true first apply.
|
|
if (established || firstApplyCorrupt || !authored.exactCoverage) {
|
|
return gitInventory;
|
|
}
|
|
return {
|
|
...authored,
|
|
git: gitInventory.git,
|
|
appliedDeploySpec: gitInventory.appliedDeploySpec,
|
|
lastAppliedContentHash: gitInventory.lastAppliedContentHash,
|
|
manifestState: gitInventory.manifestState,
|
|
manifestGeneration: gitInventory.manifestGeneration,
|
|
};
|
|
}
|
|
|
|
if (authored.exactCoverage) return authored;
|
|
return gitInventory ?? authored;
|
|
} catch (e) {
|
|
console.error(
|
|
'[rollbackInventory] Failed to resolve inventory:',
|
|
(e as Error).message,
|
|
);
|
|
throw e;
|
|
}
|
|
}
|