feat: normalize deployment inventory evidence

This commit is contained in:
NuklearRabbit
2026-07-29 17:39:22 +02:00
parent 6b93391a9b
commit 5d3731a853
13 changed files with 433 additions and 45 deletions
+28 -1
View File
@@ -7,7 +7,7 @@ const { safeStorage } = require('electron');
const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePath, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
const DEFAULT_CONFIG = {
schemaVersion: 11,
schemaVersion: 12,
setupComplete: false,
appearance: 'dark',
gitea: { baseUrl: '', user: null, encryptedToken: null },
@@ -15,6 +15,7 @@ const DEFAULT_CONFIG = {
repositoryMappings: {},
deploymentProfiles: {},
deploymentStates: {},
inventoryReviewDecisions: {},
favorites: [],
updates: {
owner: 'Jens',
@@ -65,6 +66,7 @@ class ConfigStore {
gitea: { ...DEFAULT_CONFIG.gitea, ...(source.gitea || {}) },
workspaceRoots: uniqueStrings(source.workspaceRoots),
repositoryMappings: source.repositoryMappings && typeof source.repositoryMappings === 'object' ? source.repositoryMappings : {},
inventoryReviewDecisions: source.inventoryReviewDecisions && typeof source.inventoryReviewDecisions === 'object' ? structuredClone(source.inventoryReviewDecisions) : {},
deploymentProfiles: source.deploymentProfiles && typeof source.deploymentProfiles === 'object'
? Object.fromEntries(Object.entries(source.deploymentProfiles).map(([key, profiles]) => [key, (Array.isArray(profiles) ? profiles : []).map((profile) => {
if (!profile || typeof profile !== 'object' || profile.provider !== 'ssh-unraid') return profile;
@@ -216,6 +218,8 @@ class ConfigStore {
if (!basePath.startsWith('/') || /[\r\n\0]/.test(basePath)) throw new Error('The server base path must be an absolute Unix path.');
const privateKeyPath = String(source.privateKeyPath || existing?.privateKeyPath || '').trim();
const hostFingerprint = String(source.hostFingerprint || existing?.hostFingerprint || '').trim();
const scanRoots = uniqueStrings(source.scanRoots || existing?.scanRoots || [basePath]).map((value) => value.replace(/\/+$/, '')).filter((value) => value.startsWith('/') && !/[\r\n\0]/.test(value));
const scanExcludes = uniqueStrings(source.scanExcludes || existing?.scanExcludes || ['backups', 'archives', 'releases', 'staging', 'testdata']).filter((value) => /^[a-zA-Z0-9._*-]+$/.test(value));
return {
id: source.id || existing?.id || crypto.randomUUID(),
name,
@@ -224,6 +228,8 @@ class ConfigStore {
username,
authType,
basePath,
scanRoots: scanRoots.length ? scanRoots : [basePath],
scanExcludes,
privateKeyPath,
hostFingerprint,
encryptedPassword: existing?.encryptedPassword || null,
@@ -503,6 +509,27 @@ class ConfigStore {
return this.getDeploymentProfiles(fullName).find((item) => item.id === profileId) || null;
}
getInventoryReviewDecisions(serverId) {
return structuredClone(this.data.inventoryReviewDecisions[String(serverId || '')] || []);
}
async saveInventoryReviewDecision(serverId, decision) {
const key = String(serverId || '');
if (!key || !decision?.workloadId || !/^[0-9a-f]{64}$/i.test(String(decision.evidenceHash || ''))) throw new Error('A server, workload and evidence hash are required for an inventory review decision.');
const decisions = this.getInventoryReviewDecisions(key).filter((item) => item.workloadId !== decision.workloadId);
decisions.push(structuredClone(decision));
this.data.inventoryReviewDecisions[key] = decisions;
await this.save();
return structuredClone(decision);
}
async deleteInventoryReviewDecision(serverId, workloadId) {
const key = String(serverId || '');
this.data.inventoryReviewDecisions[key] = this.getInventoryReviewDecisions(key).filter((item) => item.workloadId !== workloadId);
await this.save();
return this.getInventoryReviewDecisions(key);
}
async saveDeploymentState(profileId, state) {
this.data.deploymentStates[profileId] = {
...(this.data.deploymentStates[profileId] || {}),
+35
View File
@@ -0,0 +1,35 @@
"use strict";
const crypto = require("node:crypto");
const { normalizeRemoteUrl } = require("../shared/repository-match.cjs");
function canonicalRemote(value) {
const normalized = normalizeRemoteUrl(value);
return normalized ? `${normalized.host}/${normalized.path}`.toLowerCase() : "";
}
function deploymentIdentity({ workload, profile = null, repository = null }) {
const remote = canonicalRemote(workload?.metadata?.sourceRepository || repository?.sshUrl || repository?.cloneUrl || profile?.cloneUrl);
return {
repository: remote || String(workload?.link?.repositoryFullName || repository?.fullName || profile?._repositoryFullName || "").toLowerCase(),
branch: String(workload?.metadata?.branch || profile?.branch || repository?.defaultBranch || "").toLowerCase(),
serverId: String(workload?.serverId || profile?.serverId || ""),
environment: String(profile?.environment || "production").toLowerCase(),
composeProject: String(workload?.compose?.project || profile?.composeProject || "").toLowerCase(),
deploymentRoot: String(workload?.compose?.workingDir || profile?.composeWorkingDir || workload?.remoteFolderCandidate || profile?.remoteFolder || "").replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(),
containers: (workload?.containers || []).map((item) => String(item.id || item.name || "").toLowerCase()).sort(),
liveSha: String(workload?.metadata?.liveRevision || "").toLowerCase(),
profileId: String(profile?.id || workload?.link?.profileId || ""),
};
}
function evidenceHash(identity, evidence = {}) {
const stable = (value) => Array.isArray(value) ? value.map(stable) : value && typeof value === "object" ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])])) : value;
return crypto.createHash("sha256").update(JSON.stringify(stable({ identity, evidence }))).digest("hex");
}
function authorityKey(identity) {
return [identity.repository, identity.serverId, identity.environment].join("|");
}
module.exports = { canonicalDeploymentRemote: canonicalRemote, deploymentIdentity, deploymentEvidenceHash: evidenceHash, deploymentAuthorityKey: authorityKey };
+71
View File
@@ -0,0 +1,71 @@
"use strict";
const { deploymentIdentity, deploymentEvidenceHash, deploymentAuthorityKey } = require("./deployment-identity.cjs");
const BACKUP = /(?:^|[\\/._-])(backup|bak|archive|snapshot|old|previous)(?:[\\/._-]|$)/i;
const RELEASE = /(?:^|[\\/])(releases?|versions?)(?:[\\/]|$)/i;
const STAGING = /(?:^|[\\/._-])(staging|stage|test|qa|preview)(?:[\\/._-]|$)/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 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 };
if (decision?.action === "monitor-only") return { type: "monitor-only", reason: decision.reason || "Reviewed for monitoring only", decisionAction: decision.action };
if (workload.metadata?.staleLink) return { type: "stale-link", reason: "The linked deployment profile has no matching server 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 (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.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" };
}
function classifyInventory(workloads, profiles = [], decisions = []) {
const profileById = new Map(profiles.map((profile) => [profile.id, profile]));
const decisionByWorkload = new Map(decisions.map((decision) => [decision.workloadId, decision]));
const authorities = new Map();
const result = workloads.map((source) => {
const workload = structuredClone(source);
const profile = profileById.get(workload.link?.profileId) || null;
const identity = deploymentIdentity({ workload, profile });
const evidence = { candidates: (workload.candidates || []).map((item) => ({ repository: item.repositoryFullName, score: item.score, exact: item.exact === true })), running: workload.runtime?.running === true, health: workload.runtime?.health || null, configFiles: workload.compose?.configFiles || [] };
const hash = deploymentEvidenceHash(identity, evidence);
const stored = decisionByWorkload.get(workload.workloadId);
workload.reviewDecision = stored?.evidenceHash === hash ? stored : null;
workload.reviewDecisionStale = Boolean(stored && stored.evidenceHash !== hash);
workload.identity = identity;
if (workload.reviewDecision?.action === "manual-link" && workload.reviewDecision.repositoryFullName) {
workload.identity.repository = String(workload.reviewDecision.repositoryFullName).toLowerCase();
}
workload.evidenceHash = hash;
workload.classification = baseClassification(workload);
const key = deploymentAuthorityKey(identity);
if (identity.repository) {
const group = authorities.get(key) || [];
group.push(workload);
authorities.set(key, group);
}
return workload;
});
for (const group of authorities.values()) {
if (group.length < 2) 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.link = null;
}
}
return result;
}
module.exports = { classifyInventory, classifyWorkload: baseClassification, inventoryPathPatterns: { BACKUP, RELEASE, STAGING, SYSTEM } };
+31
View File
@@ -0,0 +1,31 @@
"use strict";
const crypto = require("node:crypto");
const ACTIONS = new Set(["keep-link", "select-authoritative", "mark-historical", "archive-link", "monitor-only", "exclude-scan-root", "manual-link", "ignore", "manual-exclude"]);
class InventoryReviewService {
constructor({ store, audit = null }) { this.store = store; this.audit = audit; }
list(serverId) { return this.store.getInventoryReviewDecisions(serverId); }
preview({ serverId, workload, action, reason = "", repositoryFullName = null }) {
if (!ACTIONS.has(action)) throw Object.assign(new Error("Unsupported inventory review action."), { code: "INVENTORY_REVIEW_ACTION_INVALID" });
if (["ignore", "manual-exclude", "exclude-scan-root"].includes(action) && String(reason).trim().length < 5) throw Object.assign(new Error("A meaningful review reason is required."), { code: "INVENTORY_REVIEW_REASON_REQUIRED" });
if (action === "manual-link" && !repositoryFullName) throw Object.assign(new Error("Select the repository to link."), { code: "INVENTORY_REVIEW_REPOSITORY_REQUIRED" });
const linkedProfile = workload.link?.profileId && workload.link?.repositoryFullName ? { profileId: workload.link.profileId, repositoryFullName: workload.link.repositoryFullName } : null;
const configurationChanges = [`Persist review decision ${action} for workload ${workload.workloadId}`];
if (action === "archive-link" && linkedProfile) configurationChanges.push(`Archive deployment profile ${linkedProfile.profileId}`);
if (action === "manual-link") configurationChanges.push(`Remember ${repositoryFullName} as the reviewed repository match; use Save environment to create the deployment profile`);
const mutation = { serverId, workloadId: workload.workloadId, evidenceHash: workload.evidenceHash, action, reason: String(reason).trim(), repositoryFullName, linkedProfile, classification: workload.classification?.type || workload.status, containersUnaffected: true, configurationChanges, recovery: "Restore the configuration snapshot or rescan after evidence changes." };
return { ...mutation, id: crypto.createHash("sha256").update(JSON.stringify(mutation)).digest("hex") };
}
async apply({ plan, expectedPlanId }) {
if (!expectedPlanId || plan.id !== expectedPlanId) throw Object.assign(new Error("Inventory review requires the exact preview plan."), { code: expectedPlanId ? "INVENTORY_REVIEW_PLAN_STALE" : "INVENTORY_REVIEW_PLAN_REQUIRED" });
const snapshot = await this.store.createRecoverySnapshot?.(`inventory-review:${plan.serverId}:${plan.workloadId}`);
if (plan.action === "archive-link" && plan.linkedProfile) await this.store.deleteDeploymentProfile(plan.linkedProfile.repositoryFullName, plan.linkedProfile.profileId);
const decision = await this.store.saveInventoryReviewDecision(plan.serverId, { workloadId: plan.workloadId, evidenceHash: plan.evidenceHash, action: plan.action, reason: plan.reason, repositoryFullName: plan.repositoryFullName || null, classification: plan.classification, decidedAt: new Date().toISOString() });
await this.audit?.append?.("deployment.inventory-review-applied", { serverId: plan.serverId, workloadId: plan.workloadId, action: plan.action, evidenceHash: plan.evidenceHash, snapshot: snapshot?.filePath || null });
return { decision, snapshot };
}
}
module.exports = { InventoryReviewService, INVENTORY_REVIEW_ACTIONS: [...ACTIONS] };
+17
View File
@@ -85,6 +85,7 @@ function registerIpc({
deployments,
unraid,
deployKeys,
inventoryReviews,
ssh,
updates,
preflight,
@@ -1309,6 +1310,22 @@ function registerIpc({
});
return { ...result, state: store.getPublicState() };
});
register("deployment:plan-inventory-review", async ({ serverId, workloadId, action, reason = "", repositoryFullName = null }) => {
const repositoryList = await repositories.refresh();
const inventory = await unraid.scanServerInventory(serverId, repositoryList.filter((item) => item.owner?.login !== "local"));
const workload = inventory.workloads.find((item) => item.workloadId === workloadId);
if (!workload) throw Object.assign(new Error("The workload changed or disappeared. Rescan before reviewing it."), { code: "INVENTORY_REVIEW_WORKLOAD_STALE" });
return inventoryReviews.preview({ serverId, workload, action, reason, repositoryFullName });
});
register("deployment:apply-inventory-review", async ({ serverId, workloadId, action, reason = "", repositoryFullName = null, planId }) => {
const repositoryList = await repositories.refresh();
const inventory = await unraid.scanServerInventory(serverId, repositoryList.filter((item) => item.owner?.login !== "local"));
const workload = inventory.workloads.find((item) => item.workloadId === workloadId);
if (!workload) throw Object.assign(new Error("The workload changed or disappeared. Rescan before applying the review."), { code: "INVENTORY_REVIEW_WORKLOAD_STALE" });
const plan = inventoryReviews.preview({ serverId, workload, action, reason, repositoryFullName });
const result = await inventoryReviews.apply({ plan, expectedPlanId: planId });
return { ...result, inventory: await unraid.scanServerInventory(serverId, repositoryList.filter((item) => item.owner?.login !== "local")), state: store.getPublicState() };
});
register("deployment:profile-state", async ({ fullName, profileId }) => {
const profile = store.getDeploymentProfile(fullName, profileId);
if (profile?.provider === "ssh-unraid") {
+40 -30
View File
@@ -15,6 +15,7 @@ const {
inventoryContainerMatch: matchInventoryContainer,
remoteIdentity: inventoryRemoteIdentity,
} = require("./server-inventory.cjs");
const { classifyInventory } = require("./inventory-classifier.cjs");
function safeRemoteFolder(value) {
const text = String(value || "").trim().replace(/\\/g, "/").replace(/^\.\//, "");
@@ -886,6 +887,8 @@ echo "ForgeFlow repaired project write access for $(id -un) and group $share_gro
}
inventoryScript(server) {
const configuredRoots = [...new Set([server.basePath, ...(server.scanRoots || [])])].map((root) => ` add_scan_root ${shellQuote(root)}`).join("\n");
const configuredExcludes = (server.scanExcludes || []).map((name) => ` -o -name ${shellQuote(name)}`).join("");
return `
base=${shellQuote(server.basePath)}
platform=$(uname -srm 2>/dev/null || true)
@@ -954,12 +957,10 @@ fi
for existing in "\${scan_roots[@]}"; do [ "$existing" = "$candidate" ] && return 0; done
scan_roots+=("$candidate")
}
add_scan_root "$base"
add_scan_root /mnt/user/appdata
add_scan_root /mnt/cache/appdata
${configuredRoots}
for root in "\${scan_roots[@]}"; do
find "$root" -mindepth 2 -maxdepth 4 \\( -type d \\( -name .git -o -name node_modules -o -name .forgeflow -o -name releases -o -name backups -o -name staging -o -name incoming -o -name '_audit_quarantine' -o -name 'devrunbook-validation' -o -name 'source-pre-*' -o -name cache -o -name caches -o -name logs -o -name database -o -name databases \\) -prune \\) -o \\( -type f \\( -name '*compose*.yml' -o -name '*compose*.yaml' -o -name 'stack.yml' -o -name 'stack.yaml' \\) -print0 \\) 2>/dev/null |
scan_error=$(mktemp)
while IFS= read -r -d '' primary; do
dir=$(dirname "$primary")
filename=$(basename "$primary")
@@ -1023,7 +1024,12 @@ $extra"
"$valid" \\
"$(printf '%s' "$compose_error" | head -c 2000 | base64 | tr -d '\\r\\n')"
)
done
done < <(find "$root" -mindepth 2 -maxdepth 4 \\( -type d \\( -name .git -o -name node_modules -o -name .forgeflow -o -name releases -o -name backups -o -name staging -o -name incoming -o -name '_audit_quarantine' -o -name 'devrunbook-validation' -o -name 'source-pre-*' -o -name cache -o -name caches -o -name logs -o -name database -o -name databases${configuredExcludes} \\) -prune \\) -o \\( -type f \\( -name '*compose*.yml' -o -name '*compose*.yaml' -o -name 'stack.yml' -o -name 'stack.yaml' \\) -print0 \\) 2>"$scan_error" || true)
if [ -s "$scan_error" ]; then
scan_message=$(printf 'Inventory scan partially failed for %s: %s' "$root" "$(head -n 1 "$scan_error")")
printf 'W\\t%s\\n' "$(printf '%s' "$scan_message" | base64 | tr -d '\\r\\n')"
fi
rm -f "$scan_error"
done
`;
}
@@ -1197,32 +1203,29 @@ $extra"
maxOutput: 64 * 1024 * 1024,
});
const inventory = parseWorkloadInventory(result.stdout);
const workloads = buildWorkloadInventory({
const profiles = this.allSshProfiles();
const detectedWorkloads = buildWorkloadInventory({
inventory,
server,
repositories,
profiles: this.allSshProfiles(),
profiles,
});
// A profile can be evidenced by both a running container and an old Compose
// definition. Keep the running workload authoritative so one repository never
// appears as multiple linked deployment cards.
const authoritativeLinkByRepository = new Map();
for (const workload of workloads) {
const repositoryName = String(workload.link?.repositoryFullName || "").toLowerCase();
if (!repositoryName) continue;
const rank = (workload.runtime?.running ? 100 : 0)
+ (workload.containers?.length ? 10 : 0)
+ (workload.compose?.workingDir ? 1 : 0);
const current = authoritativeLinkByRepository.get(repositoryName);
if (!current || rank > current.rank) authoritativeLinkByRepository.set(repositoryName, { workloadId: workload.workloadId, rank });
}
for (const workload of workloads) {
const repositoryName = String(workload.link?.repositoryFullName || "").toLowerCase();
const authoritativeWorkloadId = authoritativeLinkByRepository.get(repositoryName)?.workloadId;
if (!repositoryName || !authoritativeWorkloadId || workload.workloadId === authoritativeWorkloadId) continue;
workload.link = null;
workload.status = workload.candidates?.length ? "suggested" : "unmatched";
}
const detectedIds = new Set(detectedWorkloads.map((item) => item.workloadId));
const staleLinks = profiles.filter((profile) => profile.serverId === serverId && profile.workloadIdentity?.workloadId && !detectedIds.has(profile.workloadIdentity.workloadId)).map((profile) => ({
workloadId: profile.workloadIdentity.workloadId,
serverId,
displayName: profile.name || profile.remoteFolder || profile._repositoryFullName,
status: "stale",
link: { profileId: profile.id, repositoryFullName: profile._repositoryFullName },
compose: { project: profile.composeProject || "", workingDir: profile.composeWorkingDir || path.join(server.basePath, profile.remoteFolder || ""), configFiles: profile.composeFiles || [profile.composeFile].filter(Boolean), services: profile.composeServices || [profile.composeService].filter(Boolean) },
containers: [],
runtime: { running: false, health: "missing" },
metadata: { sourceRepository: profile.cloneUrl || "", liveRevision: "", branch: profile.branch || "", staleLink: true },
candidates: [{ repositoryFullName: profile._repositoryFullName, repositoryName: profile._repositoryFullName.split("/").pop(), score: 100, exact: true, reasons: ["persisted deployment profile"] }],
remoteFolderCandidate: profile.remoteFolder || "",
observedAt: new Date().toISOString(),
}));
const workloads = classifyInventory([...detectedWorkloads, ...staleLinks], profiles, this.store.getInventoryReviewDecisions?.(serverId) || []);
return { server, inventory, workloads };
}
@@ -1238,7 +1241,9 @@ $extra"
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) => ["suggested", "ambiguous", "unmatched"].includes(item.status)).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,
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,
running: workloads.filter((item) => item.runtime.running).length,
stopped: workloads.filter((item) => !item.runtime.running).length,
};
@@ -1267,12 +1272,16 @@ $extra"
reconciliationPlan(server, workloads, repositories, { autoLink = true } = {}) {
const profiles = this.allSshProfiles().filter((profile) => profile.serverId === server.id);
const activeWorkloadIds = new Set(workloads.map((item) => item.workloadId));
const activeWorkloadIds = new Set(workloads.filter((item) => item.classification?.type !== "stale-link").map((item) => item.workloadId));
const linkedRepositories = new Set(workloads.filter((item) => item.link?.repositoryFullName).map((item) => String(item.link.repositoryFullName).toLowerCase()));
const additions = [];
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 })) });
continue;
}
if (workload.link?.profileId && workload.link?.repositoryFullName) {
updates.push({
workloadId: workload.workloadId,
@@ -1342,7 +1351,7 @@ $extra"
let staleProfiles = [];
const inventoryStable = (inventory.warnings || []).every((warning) => /stale container reference\(s\) disappeared during inventory/i.test(warning));
if (inventoryStable && workloads.length) {
const activeWorkloadIds = new Set(workloads.map((item) => item.workloadId));
const activeWorkloadIds = new Set(workloads.filter((item) => item.classification?.type !== "stale-link").map((item) => item.workloadId));
const staleAutomaticProfiles = this.allSshProfiles().filter((profile) =>
profile.serverId === serverId
&& String(profile.workloadIdentity?.linkSource || "").startsWith("automatic")
@@ -1382,6 +1391,7 @@ $extra"
.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;
const candidate = workload.candidates[0];
const uniqueCandidate = workload.candidates.length === 1;
if (candidate && alreadyLinkedRepositories.has(String(candidate.repositoryFullName).toLowerCase())) continue;