feat: normalize deployment inventory evidence
This commit is contained in:
@@ -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] || {}),
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 } };
|
||||
@@ -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] };
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user