diff --git a/SOURCE_MANIFEST.txt b/SOURCE_MANIFEST.txt index ff981e1..4ac375a 100644 --- a/SOURCE_MANIFEST.txt +++ b/SOURCE_MANIFEST.txt @@ -86,11 +86,11 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf 2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json 4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE -ca5cebd83df4db0c5b77ded9914eef09e4d027b920e524d631fece8ecfcba6d9 13468 main.cjs +910e179eb2743989725e7fe18efd291ff098b4335aae4b9eb530b579efa6fd3f 13654 main.cjs 91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md f3e2a262e251eb165f342dc3c184625ec10d56e536b5a987199b9944f463eee9 181069 package-lock.json 48e25abda43f5463c5c4631f5a29f7e9b2e744383a23489b4e54dedf38c74940 4817 package.json -d1924fa08d1f33762bb965faca8dfb71d7c9de23927a4e33be663de18945d849 11316 preload.cjs +86876cd502f51ad65e9c9280b189a80579df99ba65c81daf338048693d6ec78f 11732 preload.cjs abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1 688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1 @@ -110,16 +110,19 @@ d2dd98055e50f11b4e1484531e43fb5ac7f876bea4b9cf5bca2cb0a15022b60a 1913 0b9f03ba3c67ff7cdb2916a902ad8ce25e81a7c90b210e4ae52d2ad029efabf3 2353 scripts/write-release-checksums.mjs 619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1 dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs -80e0afcfa77dbc617c425cd603e5c165ea425b5f47046538657bc1dc51d7b1e7 28670 src/main/config-store.cjs +565787043e9825859301c203a269ca801e0c2c417f37417f0dde17238433d712 30410 src/main/config-store.cjs 2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs 86e9fc2eda66b4b563f6c4bbb87d3e8514340d484fb503b73137e63b6b05c3c9 14597 src/main/deploy-key-lifecycle-service.cjs +7cbfe51973d6607203cb197652ed7f296a3f6b6b644df876957117866a47d802 2159 src/main/deployment-identity.cjs 9d0af5074093108a5248d0dde0ff70a666748e61f1954b630886a81e8f34072c 24079 src/main/deployment-service.cjs c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs 0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs 3ce45837099ac7bddc024974bd839575b4b765a7df9055e7d45ef889dc85bf7f 15623 src/main/git-validator-service.cjs 2faaef0eeec1e473db82b94243674e3812ef5869358b0ec6abf8d645a8794623 20768 src/main/gitea-service.cjs -6b95d18288aa3becacaaaa21ee7f827af87e977755c0e9d68132441fa72f38cc 53023 src/main/ipc.cjs +8c3c7b9bf0893276f5ab520efba17fc143c64377128d754411cd911180025c4a 5743 src/main/inventory-classifier.cjs +dafdb09133d2b6ec2161a3f0b09354551e54fc606c8107976fca37405643be91 3404 src/main/inventory-review-service.cjs +92c1afbfeaa02dec1dc6fd646f6f633d774cbf733635e40b74957809a3796b70 54676 src/main/ipc.cjs 62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs 958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs 3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs @@ -128,15 +131,15 @@ e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs 2ede80cd1565a7f2c282cc58d35dc0889d58d7465346bc723026b9c8be4df0ac 9501 src/main/unraid-deploy-key-host.cjs -b90285d4bece4705554b2dae72f3db61a614b87f57b28a03b1e94d55f1ad4ad0 149903 src/main/unraid-deployment-service.cjs +4f418cfd745ad5e02ac4c83d9f85047a64084ccdbba9d7a3b3f22a7e306686e3 152137 src/main/unraid-deployment-service.cjs b654a9e45044ad32c61fabe4a6d897288615ec83739b53e3241ff881e32f56bd 21677 src/main/update-service.cjs -71cc25ec300737a8a0072602ae3c484ab2e670ef0c88d26b0777717de119512d 247577 src/renderer/app.js +fe4deba8107baf9869689b9c9c12b0be3f3c6735abcc5e779498d000deb35931 254738 src/renderer/app.js 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png 094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png fb7ed47f9aac50d9259d7d3c3bb2010c7bfdd2fe8e8e47ca2744bb22f0057d54 830 src/renderer/index.html -5c6e8e0080043fc320c689c59c9611fa23b895b2fab7783866e8357dd3d1b1fa 65633 src/renderer/mock-bridge.js +b7f0b3ab5c42e008ce436a61c30d6226ceb6c821c21e7dd7b0c12f37e121757d 66811 src/renderer/mock-bridge.js 45692591428575b518678a6b548c25d3de95f568541e5648b0f06f42c48b5bbf 77872 src/renderer/styles.css 0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs 5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs @@ -163,6 +166,7 @@ e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 73d00729696e5067ba33dd6d43b018d89ce7fdd561a60ab66648d3283fb54d21 5370 tests/git-validator.test.mjs 681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs 771eeb4fa5443d581991cedb4107d7c414ce1b7d2e14bac7cf719ec8ba131647 8969 tests/gitea-actions.test.mjs +8f260f35aaf162999ddcd0f851a4f215222d9de0880d602b8322facdaa4c2cb0 9190 tests/inventory-classifier.test.mjs 48bca4711e7c193d19c78a0cb45ea1c83179b3c23640195f66058268e8a11b52 1520 tests/ipc-contract.test.mjs caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs 96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs @@ -176,7 +180,7 @@ bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs 2571128f0b8e650071df17755baa09c4dfc441af0c20a7a4e9aa445b59e87d11 1654 tests/ssh-service.test.mjs 8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs -dde71af691e7e6f1b6a24c1dd5f436d3473ff94db31f987b9bbceeea5661121a 43396 tests/unraid-deployment.test.mjs +da10db992c0c34d2c4c955ac81806ba6d3dee9be936b70adb550adaef5dbc462 44170 tests/unraid-deployment.test.mjs 861bad3f118c89bd17acf4373170c208c6e29c89af1d40fb2cf010f587a5016f 19008 tests/update-service.test.mjs 9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs 7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs diff --git a/main.cjs b/main.cjs index 150951f..e76d425 100644 --- a/main.cjs +++ b/main.cjs @@ -27,6 +27,7 @@ const { const { AuditService } = require("./src/main/audit-service.cjs"); const { DeployKeyLifecycleService } = require("./src/main/deploy-key-lifecycle-service.cjs"); const { UnraidDeployKeyHost } = require("./src/main/unraid-deploy-key-host.cjs"); +const { InventoryReviewService } = require("./src/main/inventory-review-service.cjs"); const { GitValidatorService } = require("./src/main/git-validator-service.cjs"); const { ExternalToolsService, @@ -272,6 +273,7 @@ app keyHost: new UnraidDeployKeyHost({ ssh }), audit, }); + const inventoryReviews = new InventoryReviewService({ store, audit }); const updates = new UpdateService({ store, gitea, @@ -314,6 +316,7 @@ app deployments, unraid, deployKeys, + inventoryReviews, ssh, updates, preflight, diff --git a/preload.cjs b/preload.cjs index 3ffd95b..848e9ff 100644 --- a/preload.cjs +++ b/preload.cjs @@ -123,6 +123,8 @@ contextBridge.exposeInMainWorld( discoverServerDeployments: () => invoke('deployment:discover-server-workloads'), planServerReconciliation: (serverId) => invoke('deployment:plan-server-reconciliation', { serverId }), applyServerReconciliation: (serverId, planId) => invoke('deployment:apply-server-reconciliation', { serverId, planId }), + planInventoryReview: (serverId, workloadId, action, reason = '', repositoryFullName = null) => invoke('deployment:plan-inventory-review', { serverId, workloadId, action, reason, repositoryFullName }), + applyInventoryReview: (serverId, workloadId, action, reason, repositoryFullName, planId) => invoke('deployment:apply-inventory-review', { serverId, workloadId, action, reason, repositoryFullName, planId }), linkServerWorkload: (repository, serverId, workloadId, deploymentMode = 'server-git', remoteFolder = '') => invoke('deployment:link-server-workload', { repository, serverId, workloadId, deploymentMode, remoteFolder }), configureServerGitAccess: (repository, profileId) => invoke('deployment:configure-server-git-access', { repository, profileId }), verifyServerGitProfile: (repository, profileId) => invoke('deployment:verify-server-git-profile', { repository, profileId }), diff --git a/src/main/config-store.cjs b/src/main/config-store.cjs index cad854a..d255ce5 100644 --- a/src/main/config-store.cjs +++ b/src/main/config-store.cjs @@ -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] || {}), diff --git a/src/main/deployment-identity.cjs b/src/main/deployment-identity.cjs new file mode 100644 index 0000000..3686bd5 --- /dev/null +++ b/src/main/deployment-identity.cjs @@ -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 }; diff --git a/src/main/inventory-classifier.cjs b/src/main/inventory-classifier.cjs new file mode 100644 index 0000000..391ad9b --- /dev/null +++ b/src/main/inventory-classifier.cjs @@ -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 } }; diff --git a/src/main/inventory-review-service.cjs b/src/main/inventory-review-service.cjs new file mode 100644 index 0000000..72d1abe --- /dev/null +++ b/src/main/inventory-review-service.cjs @@ -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] }; diff --git a/src/main/ipc.cjs b/src/main/ipc.cjs index d1592e4..435fad3 100644 --- a/src/main/ipc.cjs +++ b/src/main/ipc.cjs @@ -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") { diff --git a/src/main/unraid-deployment-service.cjs b/src/main/unraid-deployment-service.cjs index 7fe81ea..170eaf5 100644 --- a/src/main/unraid-deployment-service.cjs +++ b/src/main/unraid-deployment-service.cjs @@ -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; diff --git a/src/renderer/app.js b/src/renderer/app.js index 1926e17..8914be1 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -158,6 +158,7 @@ const ui = { deploymentPreflight: null, serverGitVerifications: {}, deployKeyLifecycle: null, + inventoryReviewPlan: null, diagnosticsStatus: null, troubleshooter: null, deploymentDiscovery: null, @@ -1177,10 +1178,11 @@ function renderActionPanel(repository) { function renderServerInventory() { const servers = ui.serverDiscovery || []; const configuredServers = ui.boot?.state?.servers || []; + const hiddenClassifications = new Set(["backup", "release-folder", "system-container", "manually-excluded"]); const visibleForServer = (server) => (server.workloads || []).filter((workload) => - workload.link || (workload.runtime?.running && workload.status !== "unmatched"), + workload.reviewDecisionStale || workload.classification?.type === "duplicate" || (!hiddenClassifications.has(workload.classification?.type) && (workload.link || workload.runtime?.running || ["ambiguous", "orphan-container", "stopped-application", "historical-compose", "stale-link", "monitor-only"].includes(workload.classification?.type))), ); - const reviewCount = servers.reduce((total, server) => total + visibleForServer(server).filter((workload) => !workload.link).length, 0); + const reviewCount = servers.reduce((total, server) => total + visibleForServer(server).filter((workload) => !workload.link || workload.classification?.type === "stale-link" || workload.reviewDecisionStale).length, 0); const serverCards = servers.map((server) => { const capabilities = server.capabilities || {}; const capabilityText = [ @@ -1199,8 +1201,9 @@ function renderServerInventory() { ? visibleWorkloads.map((workload) => { const containers = (workload.containers || []).map((container) => container.name).filter(Boolean).join(", "); const topCandidate = workload.candidates?.[0]; - const linked = workload.status === "linked" || Boolean(workload.link); - const statusTone = linked ? "success" : workload.status === "ambiguous" ? "danger" : "warning"; + const linked = (workload.status === "linked" || Boolean(workload.link)) && workload.classification?.type !== "stale-link"; + const classification = workload.classification?.type || workload.status || "review"; + const statusTone = linked && !workload.reviewDecisionStale ? "success" : ["ambiguous", "duplicate", "orphan-container"].includes(classification) || workload.reviewDecisionStale ? "danger" : "warning"; const detail = workload.compose?.project ? `Compose ${workload.compose.project} · ${(workload.compose.services || []).join(", ") || "services unknown"}` : workload.dockerMan?.templatePath @@ -1215,7 +1218,8 @@ function renderServerInventory() { const linkButton = canQuickLink ? `` : ``; - return `
${server.error ? "No inventory could be read until the SSH connection works." : "Docker returned no containers, Compose projects or DockerMan templates."}
${escapeHtml(workload?.classification?.reason || "ForgeFlow needs an explicit decision for this workload.")}