This commit is contained in:
@@ -5,10 +5,13 @@ const { deploymentIdentity, deploymentEvidenceHash, deploymentAuthorityKey } = r
|
||||
const BACKUP = /(?:^|[\\/._-])(backup|bak|archive|snapshot|old|previous)(?:[\\/._-]|$)/i;
|
||||
const RELEASE = /(?:^|[\\/])(releases?|versions?)(?:[\\/]|$)/i;
|
||||
const STAGING = /(?:^|[\\/._-])(staging|stage|test|qa|preview)(?:[\\/._-]|$)/i;
|
||||
const TEMPORARY = /(?:^|[\\/._-])(candidate|rollback|ephemeral)(?:[\\/._-]|$)|^GITEA-ACTIONS-TASK-/i;
|
||||
const SYSTEM = /^(?:traefik|nginx-proxy-manager|watchtower|portainer|dockerman|unraid-|cloudflared|redis|postgres|mariadb|mysql)(?:$|[-_.])/i;
|
||||
|
||||
function baseClassification(workload) {
|
||||
const location = `${workload.compose?.workingDir || ""} ${(workload.compose?.configFiles || []).join(" ")}`;
|
||||
const sourceRepository = String(workload.metadata?.sourceRepository || "").trim();
|
||||
const hasGitProvenance = /^(?:git@|ssh:\/\/|https?:\/\/)/i.test(sourceRepository);
|
||||
const decision = workload.reviewDecision;
|
||||
if (["manual-exclude", "exclude-scan-root", "ignore"].includes(decision?.action)) return { type: "manually-excluded", reason: decision.reason || "Persisted manual exclusion", decisionAction: decision.action };
|
||||
if (["mark-historical", "archive-link"].includes(decision?.action)) return { type: "historical-compose", reason: decision.reason || "Reviewed as historical", decisionAction: decision.action };
|
||||
@@ -17,12 +20,13 @@ function baseClassification(workload) {
|
||||
if (BACKUP.test(location)) return { type: "backup", reason: "Path matches backup/archive evidence" };
|
||||
if (RELEASE.test(location)) return { type: "release-folder", reason: "Path is below a release/version directory" };
|
||||
if (STAGING.test(location)) return { type: "staging", reason: "Path or project identifies a staging/test workload" };
|
||||
if (TEMPORARY.test(`${workload.displayName || ""} ${location}`)) return { type: "temporary-runtime", reason: "Runtime identity marks a candidate, rollback or CI workload" };
|
||||
if (SYSTEM.test(workload.displayName || "") && !workload.metadata?.sourceRepository) return { type: "system-container", reason: "Known infrastructure identity without repository provenance" };
|
||||
if (workload.link && workload.runtime?.running) return { type: "active-application", reason: "Linked deployment with running container evidence" };
|
||||
if (workload.link && !workload.runtime?.running) return { type: "stopped-application", reason: "Linked deployment without a running container" };
|
||||
if (!workload.containers?.length && workload.compose?.configFiles?.length) return { type: "historical-compose", reason: "Compose definition exists without container runtime" };
|
||||
if (workload.status === "ambiguous") return { type: "ambiguous", reason: "Multiple candidates have equivalent evidence" };
|
||||
if (!workload.metadata?.sourceRepository && !workload.candidates?.length) return { type: "orphan-container", reason: "Runtime has no repository provenance or candidate" };
|
||||
if (!workload.candidates?.length) return { type: "external-container", reason: hasGitProvenance ? "Repository provenance does not match an accessible configured Gitea repository" : "Runtime has no Git repository provenance and remains monitoring-only" };
|
||||
if (!workload.runtime?.running && workload.candidates?.length) return { type: "stopped-application", reason: "Stopped runtime has repository evidence" };
|
||||
return { type: workload.runtime?.running ? "active-application" : "ambiguous", reason: workload.runtime?.running ? "Running application evidence" : "Insufficient authoritative evidence" };
|
||||
}
|
||||
@@ -46,8 +50,12 @@ function classifyInventory(workloads, profiles = [], decisions = []) {
|
||||
}
|
||||
workload.evidenceHash = hash;
|
||||
workload.classification = baseClassification(workload);
|
||||
if (workload.link && ["backup", "release-folder", "staging", "temporary-runtime", "historical-compose", "system-container", "external-container", "manually-excluded"].includes(workload.classification.type)) {
|
||||
workload.shadowedLink = workload.link;
|
||||
workload.link = null;
|
||||
}
|
||||
const key = deploymentAuthorityKey(identity);
|
||||
if (identity.repository) {
|
||||
if (identity.repository && (workload.link || workload.candidates?.length) && !["backup", "release-folder", "staging", "temporary-runtime", "historical-compose", "system-container", "external-container", "manually-excluded"].includes(workload.classification.type)) {
|
||||
const group = authorities.get(key) || [];
|
||||
group.push(workload);
|
||||
authorities.set(key, group);
|
||||
@@ -55,17 +63,21 @@ function classifyInventory(workloads, profiles = [], decisions = []) {
|
||||
return workload;
|
||||
});
|
||||
for (const group of authorities.values()) {
|
||||
if (group.length < 2) continue;
|
||||
if (group.length < 2) {
|
||||
group[0].authoritative = true;
|
||||
continue;
|
||||
}
|
||||
const ranked = [...group].sort((a, b) => Number(b.reviewDecision?.action === "select-authoritative") - Number(a.reviewDecision?.action === "select-authoritative") || Number(b.runtime?.running) - Number(a.runtime?.running) || Number(Boolean(b.link)) - Number(Boolean(a.link)) || Number(Boolean(b.metadata?.liveRevision)) - Number(Boolean(a.metadata?.liveRevision)));
|
||||
ranked[0].authoritative = true;
|
||||
for (const duplicate of ranked.slice(1)) {
|
||||
duplicate.authoritative = false;
|
||||
duplicate.classification = { type: "duplicate", reason: `Conflicts with authoritative workload ${ranked[0].workloadId}`, authoritativeWorkloadId: ranked[0].workloadId };
|
||||
duplicate.status = "duplicate";
|
||||
duplicate.shadowedLink = duplicate.link;
|
||||
duplicate.link = null;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = { classifyInventory, classifyWorkload: baseClassification, inventoryPathPatterns: { BACKUP, RELEASE, STAGING, SYSTEM } };
|
||||
module.exports = { classifyInventory, classifyWorkload: baseClassification, inventoryPathPatterns: { BACKUP, RELEASE, STAGING, TEMPORARY, SYSTEM } };
|
||||
|
||||
@@ -128,21 +128,19 @@ class ProductionAcceptanceHarness {
|
||||
const binary = Buffer.from(options.binary || "MZ-forgeflow-acceptance-binary");
|
||||
const name = `ForgeFlow-Portable-${version}-win-x64.exe`;
|
||||
const checksum = crypto.createHash("sha256").update(binary).digest("hex");
|
||||
const manifest = { version, draft: options.draft === true, assets: options.missingAsset ? [] : [{ name, sha256: options.badChecksum ? "0".repeat(64) : checksum, signer: options.signer || "CN=ForgeFlow Test", timestamped: options.timestamped !== false }], provenance: { commitSha: options.commitSha || this.initialSha }, sbom: { bomFormat: "CycloneDX" } };
|
||||
const manifest = { version, draft: options.draft === true, assets: options.missingAsset ? [] : [{ name, sha256: options.badChecksum ? "0".repeat(64) : checksum }], provenance: { commitSha: options.commitSha || this.initialSha }, sbom: { bomFormat: "CycloneDX" } };
|
||||
await fs.writeFile(path.join(this.paths.releases, `${version}.json`), JSON.stringify(manifest, null, 2));
|
||||
if (!options.missingAsset) await fs.writeFile(path.join(this.paths.releases, name), binary);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async verifyRelease(version, expectedSigner = "CN=ForgeFlow Test") {
|
||||
async verifyRelease(version) {
|
||||
const manifest = JSON.parse(await fs.readFile(path.join(this.paths.releases, `${version}.json`), "utf8"));
|
||||
if (manifest.draft) throw new Error("Incomplete draft release rejected.");
|
||||
const asset = manifest.assets[0];
|
||||
if (!asset) throw new Error("Required release asset is missing.");
|
||||
const binary = await fs.readFile(path.join(this.paths.releases, asset.name));
|
||||
if (crypto.createHash("sha256").update(binary).digest("hex") !== asset.sha256) throw new Error("Release checksum mismatch.");
|
||||
if (asset.signer !== expectedSigner) throw new Error("Release signer mismatch.");
|
||||
if (!asset.timestamped) throw new Error("Release signature timestamp is missing.");
|
||||
if (!manifest.provenance?.commitSha || manifest.sbom?.bomFormat !== "CycloneDX") throw new Error("Release provenance or SBOM is missing.");
|
||||
return { verified: true, version, asset: asset.name };
|
||||
}
|
||||
|
||||
@@ -361,9 +361,16 @@ function createUnraidInventoryMethods({
|
||||
verified: workloads.filter((item) => item.runtime.health === "healthy" && item.link).length,
|
||||
linked: workloads.filter((item) => item.status === "linked").length,
|
||||
unmatched: workloads.filter((item) => !item.link).length,
|
||||
needsReview: workloads.filter((item) => !item.reviewDecision && (["suggested", "ambiguous", "unmatched", "duplicate", "stale"].includes(item.status) || ["orphan-container", "historical-compose", "stale-link"].includes(item.classification?.type))).length,
|
||||
needsReview: workloads.filter((item) => {
|
||||
if (item.reviewDecision) return false;
|
||||
const type = item.classification?.type;
|
||||
if (type === "duplicate") return item.runtime?.running === true;
|
||||
if (type === "stale-link") return true;
|
||||
if (["system-container", "external-container", "temporary-runtime", "backup", "release-folder", "historical-compose", "manually-excluded"].includes(type)) return false;
|
||||
return item.runtime?.running && ["suggested", "ambiguous", "unmatched"].includes(item.status);
|
||||
}).length,
|
||||
duplicates: workloads.filter((item) => item.classification?.type === "duplicate").length,
|
||||
excluded: workloads.filter((item) => ["system-container", "backup", "release-folder", "historical-compose", "manually-excluded"].includes(item.classification?.type)).length,
|
||||
excluded: workloads.filter((item) => ["system-container", "external-container", "temporary-runtime", "backup", "release-folder", "historical-compose", "manually-excluded"].includes(item.classification?.type)).length,
|
||||
running: workloads.filter((item) => item.runtime.running).length,
|
||||
stopped: workloads.filter((item) => !item.runtime.running).length,
|
||||
};
|
||||
@@ -398,8 +405,8 @@ function createUnraidInventoryMethods({
|
||||
const updates = [];
|
||||
const conflicts = [];
|
||||
for (const workload of workloads) {
|
||||
if (["duplicate", "backup", "release-folder", "historical-compose", "system-container", "manually-excluded", "stale-link"].includes(workload.classification?.type)) {
|
||||
if (!workload.reviewDecision && ["duplicate", "historical-compose", "stale-link"].includes(workload.classification?.type)) conflicts.push({ workloadId: workload.workloadId, displayName: workload.displayName, status: workload.classification.type, reason: workload.classification.reason, candidates: (workload.candidates || []).slice(0, 5).map((item) => ({ repositoryFullName: item.repositoryFullName, score: item.score, exact: item.exact === true })) });
|
||||
if (["duplicate", "backup", "release-folder", "historical-compose", "system-container", "external-container", "temporary-runtime", "manually-excluded", "stale-link"].includes(workload.classification?.type)) {
|
||||
if (!workload.reviewDecision && (["historical-compose", "stale-link"].includes(workload.classification?.type) || (workload.classification?.type === "duplicate" && workload.runtime?.running))) conflicts.push({ workloadId: workload.workloadId, displayName: workload.displayName, status: workload.classification.type, reason: workload.classification.reason, candidates: (workload.candidates || []).slice(0, 5).map((item) => ({ repositoryFullName: item.repositoryFullName, score: item.score, exact: item.exact === true })) });
|
||||
continue;
|
||||
}
|
||||
if (workload.link?.profileId && workload.link?.repositoryFullName) {
|
||||
@@ -485,8 +492,8 @@ function createUnraidInventoryMethods({
|
||||
.filter((workload) => workload.runtime?.running && workload.link?.profileId)
|
||||
.map((workload) => workload.link.profileId));
|
||||
const shadowedAutomaticProfiles = workloads
|
||||
.filter((workload) => !workload.runtime?.running && workload.link?.profileId && !runningProfileIds.has(workload.link.profileId) && runningRepositoryLinks.has(String(workload.link.repositoryFullName).toLowerCase()))
|
||||
.map((workload) => this.allSshProfiles().find((profile) => profile.id === workload.link.profileId && String(profile._repositoryFullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase()))
|
||||
.filter((workload) => !workload.runtime?.running && workload.shadowedLink?.profileId && !runningProfileIds.has(workload.shadowedLink.profileId) && runningRepositoryLinks.has(String(workload.shadowedLink.repositoryFullName).toLowerCase()))
|
||||
.map((workload) => this.allSshProfiles().find((profile) => profile.id === workload.shadowedLink.profileId && String(profile._repositoryFullName).toLowerCase() === String(workload.shadowedLink.repositoryFullName).toLowerCase()))
|
||||
.filter((profile) => profile && String(profile.workloadIdentity?.linkSource || "").startsWith("automatic"));
|
||||
staleProfiles = [...new Map([...staleAutomaticProfiles, ...shadowedAutomaticProfiles].map((profile) => [profile.id, {
|
||||
profileId: profile.id,
|
||||
@@ -496,7 +503,7 @@ function createUnraidInventoryMethods({
|
||||
}
|
||||
for (const workload of workloads) {
|
||||
if (workload.status !== "linked" || !workload.link?.profileId || !workload.link?.repositoryFullName) continue;
|
||||
const repository = (repositories || []).find((item) => item.fullName === workload.link.repositoryFullName);
|
||||
const repository = (repositories || []).find((item) => String(item.fullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase());
|
||||
const existingProfile = this.store.getDeploymentProfile?.(workload.link.repositoryFullName, workload.link.profileId)
|
||||
|| this.allSshProfiles().find((item) => item.id === workload.link.profileId && item._repositoryFullName === workload.link.repositoryFullName);
|
||||
if (!repository || !existingProfile) continue;
|
||||
@@ -511,7 +518,7 @@ function createUnraidInventoryMethods({
|
||||
.map((item) => String(item.link.repositoryFullName).toLowerCase()));
|
||||
for (const workload of workloads) {
|
||||
if (workload.status === "linked") continue;
|
||||
if (["duplicate", "backup", "release-folder", "historical-compose", "system-container", "manually-excluded"].includes(workload.classification?.type)) continue;
|
||||
if (["duplicate", "backup", "release-folder", "historical-compose", "system-container", "external-container", "temporary-runtime", "manually-excluded"].includes(workload.classification?.type)) continue;
|
||||
const candidate = workload.candidates[0];
|
||||
const uniqueCandidate = workload.candidates.length === 1;
|
||||
if (candidate && alreadyLinkedRepositories.has(String(candidate.repositoryFullName).toLowerCase())) continue;
|
||||
@@ -523,7 +530,7 @@ function createUnraidInventoryMethods({
|
||||
&& Boolean(workload.remoteFolderCandidate)
|
||||
&& !alreadyLinkedRepositories.has(String(candidate.repositoryFullName).toLowerCase());
|
||||
if (!exactMatch && !exactRuntimeIdentity) continue;
|
||||
const repository = (repositories || []).find((item) => item.fullName === candidate.repositoryFullName);
|
||||
const repository = (repositories || []).find((item) => String(item.fullName).toLowerCase() === String(candidate.repositoryFullName).toLowerCase());
|
||||
if (!repository) continue;
|
||||
const linkSource = exactMatch ? "automatic" : "automatic-runtime-identity";
|
||||
const profile = this.profileFromWorkload(repository, server, workload, { linkSource, deploymentMode: "server-git" });
|
||||
@@ -535,6 +542,10 @@ function createUnraidInventoryMethods({
|
||||
adopted += 1;
|
||||
}
|
||||
}
|
||||
for (const stale of staleProfiles) {
|
||||
await this.store.deleteDeploymentProfile(stale.repositoryFullName, stale.profileId);
|
||||
retired += 1;
|
||||
}
|
||||
const response = this.inventoryResponse(server, inventory, workloads, { adopted, refreshed, retired, staleProfiles });
|
||||
response.recoverySnapshot = recoverySnapshot;
|
||||
await this.diagnostics?.info("unraid.workloads.reconciled", {
|
||||
|
||||
Reference in New Issue
Block a user