feat: normalize deployment inventory evidence
This commit is contained in:
+12
-8
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
+46
-6
@@ -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
|
||||
? `<button class="button primary" data-action="quick-link-server-workload" data-server-id="${attr(server.serverId)}" data-workload-id="${attr(workload.workloadId)}" data-repository="${attr(topCandidate.repositoryFullName)}">${icon("link")}Link to ${escapeHtml(topCandidate.repositoryName || topCandidate.repositoryFullName)}</button>`
|
||||
: `<button class="button primary" data-action="link-server-workload" data-server-id="${attr(server.serverId)}" data-workload-id="${attr(workload.workloadId)}">${icon("link")}Review & link</button>`;
|
||||
return `<div class="tool-row"><div><strong>${escapeHtml(workload.displayName)}</strong><span>${escapeHtml(detail)} · ${workload.runtime?.running ? "running" : "stopped"}</span><span>${escapeHtml(candidate)}</span>${workload.metadata?.composeDefinitionError ? `<span class="text-warning">Compose file found; validation warning: ${escapeHtml(workload.metadata.composeDefinitionError)}</span>` : ""}</div><div class="stack horizontal compact"><span class="status-pill ${statusTone}">${escapeHtml(linked ? "Linked" : workload.status || "Review")}</span>${linked ? `<button class="button ghost" data-action="edit-deployment-profile" data-profile-id="${attr(workload.link?.profileId || "")}">Open link</button>` : linkButton}</div></div>`;
|
||||
const evidenceNote = workload.reviewDecisionStale ? "Saved decision is stale because server evidence changed" : workload.classification?.reason || "Awaiting review";
|
||||
return `<div class="tool-row"><div><strong>${escapeHtml(workload.displayName)}</strong><span>${escapeHtml(detail)} · ${workload.runtime?.running ? "running" : "stopped"}</span><span>${escapeHtml(candidate)}</span><span class="${workload.reviewDecisionStale ? "text-warning" : "meta"}">${escapeHtml(evidenceNote)}</span>${workload.metadata?.composeDefinitionError ? `<span class="text-warning">Compose file found; validation warning: ${escapeHtml(workload.metadata.composeDefinitionError)}</span>` : ""}</div><div class="stack horizontal compact"><span class="status-pill ${statusTone}">${escapeHtml(workload.reviewDecisionStale ? "Decision stale" : linked ? "Linked" : classification)}</span>${linked ? `<button class="button ghost" data-action="edit-deployment-profile" data-profile-id="${attr(workload.link?.profileId || "")}">Open link</button>` : linkButton}</div></div>`;
|
||||
}).join("")
|
||||
: `<div class="empty-state compact"><p>${server.error ? "No inventory could be read until the SSH connection works." : "Docker returned no containers, Compose projects or DockerMan templates."}</p></div>`;
|
||||
return `<section class="panel server-inventory-panel"><div class="panel-header"><div><h3>${escapeHtml(server.serverName || server.server?.name || server.serverId)}</h3><span class="meta">${server.running || 0} running · ${server.linked || 0} repository links · ${visibleWorkloads.filter((workload) => !workload.link).length} to review${hiddenCount ? ` · ${hiddenCount} unrelated/system workloads hidden` : ""}</span></div><div class="stack horizontal compact"><span class="status-pill ${server.error ? "danger" : capabilities.docker && capabilities.compose ? "success" : "warning"}">${server.error ? "Scan failed" : escapeHtml(capabilityText)}</span>${server.error ? "" : `<button class="button" data-action="plan-server-reconciliation" data-server-id="${attr(server.serverId)}">${icon("shield")}Review reconciliation</button>`}</div></div><div class="panel-body">${errorBlock}${warnings}<div class="tool-list">${workloads}</div></div></section>`;
|
||||
@@ -1467,6 +1471,10 @@ function renderModal() {
|
||||
<div class="field full"><label>Healthcheck URL (optional)</label><input id="profile-healthcheck" class="input" value="${attr(existing.healthcheckUrl || "")}" placeholder="https://app.example.com/health" /></div>`
|
||||
}<label class="check-field full"><input id="profile-confirmation" type="checkbox" ${existing.confirmationRequired !== false ? "checked" : ""}/><span>Require an explicit confirmation before deployment</span></label></div><div class="notice" style="margin-top:13px">${icon("shield")}${ssh ? "Server pull fetches the exact selected Gitea commit with a repository-scoped read-only key, validates Compose and services, then promotes atomically with rollback protection." : "ForgeFlow sends only controlled workflow inputs: environment, exact SHA and a unique request ID."}</div></div><footer class="modal-footer">${existing.id ? `<button class="button danger" data-action="delete-deployment-profile" data-profile-id="${attr(existing.id)}">Delete</button>` : ""}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-deployment-profile" data-profile-id="${attr(existing.id || "")}" ${ssh && !servers.length ? "disabled" : ""}>Save environment</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "inventory-review-plan") {
|
||||
const plan = ui.inventoryReviewPlan;
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true" aria-labelledby="inventory-review-title"><header class="modal-header"><h2 id="inventory-review-title">Review inventory decision</h2><button class="icon-button" data-action="close-modal" aria-label="Close inventory review">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero">${icon("shield")}<div><strong>${escapeHtml(plan?.action || "Review")}</strong><span>${escapeHtml(plan?.workloadId || "")} · ${escapeHtml(plan?.classification || "unclassified")}</span></div></div><div class="confirm-grid"><span>Evidence hash</span><strong class="mono">${escapeHtml(plan?.evidenceHash || "")}</strong><span>Configuration change</span><strong>${escapeHtml(plan?.configurationChanges?.join("; ") || "None")}</strong><span>Containers</span><strong>${plan?.containersUnaffected ? "Unaffected" : "Review required"}</strong><span>Recovery</span><strong>${escapeHtml(plan?.recovery || "")}</strong><span>Reason</span><strong>${escapeHtml(plan?.reason || "Not supplied")}</strong></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="apply-inventory-review">Apply reviewed decision</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "deploy-key-lifecycle") {
|
||||
const lifecycle = ui.deployKeyLifecycle;
|
||||
const inventory = lifecycle?.inventory;
|
||||
@@ -1503,7 +1511,7 @@ function renderModal() {
|
||||
(item) => item.id === ui.modal.serverId,
|
||||
) || {};
|
||||
const authType = ui.modal.authType || server.authType || "password";
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${server.id ? "Edit" : "Add"} SSH / Unraid server</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="form-grid"><div class="field"><label>Name</label><input id="server-name" class="input" value="${attr(server.name || "Unraid")}"/></div><div class="field"><label>Host or IP</label><input id="server-host" class="input" value="${attr(server.host || "")}" placeholder="192.168.1.10"/></div><div class="field"><label>SSH port</label><input id="server-port" class="input" type="number" min="1" max="65535" value="${attr(server.port || 22)}"/></div><div class="field"><label>Username</label><input id="server-username" class="input" value="${attr(server.username || "root")}"/></div><div class="field"><label>Authentication</label><select id="server-auth-type" class="select"><option value="privateKey" ${authType === "privateKey" ? "selected" : ""}>Private key · optional</option><option value="password" ${authType === "password" ? "selected" : ""}>Password · no key</option></select></div><div class="field"><label>Appdata base path</label><input id="server-base-path" class="input" value="${attr(server.basePath || "/mnt/user/appdata")}"/></div>${authType === "privateKey" ? `<div class="field full"><label>Private key file</label><div class="input-action"><input id="server-private-key" class="input" value="${attr(server.privateKeyPath || "")}" placeholder="C:\\Users\\Jens\\.ssh\\id_ed25519"/><button class="button" data-action="select-private-key">Browse</button></div></div><div class="field full"><label>Private key passphrase</label><input id="server-passphrase" class="input" type="password" placeholder="${server.hasPassphrase ? "Leave empty to keep stored passphrase" : "Only when the key is encrypted"}"/></div>` : `<div class="field full"><label>SSH password</label><input id="server-password" class="input" type="password" placeholder="${server.hasPassword ? "Leave empty to keep stored password" : "Password"}"/></div>`}<div class="field full"><label>Trusted host fingerprint</label><input id="server-fingerprint" class="input mono" value="${attr(server.hostFingerprint || "")}" readonly placeholder="Filled automatically after Test & trust"/></div></div><div class="notice warning" style="margin-top:12px">${icon("key")}This login secures the desktop → Unraid connection. Server pull separately creates one read-only deploy key per repository and pins the Gitea SSH host key. No reusable Gitea token is stored on Unraid.</div></div><footer class="modal-footer">${server.id ? `<button class="button danger" data-action="delete-server" data-server-id="${attr(server.id)}">Delete</button>` : ""}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-server" data-server-id="${attr(server.id || "")}">Save server</button></footer></section></div>`;
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${server.id ? "Edit" : "Add"} SSH / Unraid server</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="form-grid"><div class="field"><label>Name</label><input id="server-name" class="input" value="${attr(server.name || "Unraid")}"/></div><div class="field"><label>Host or IP</label><input id="server-host" class="input" value="${attr(server.host || "")}" placeholder="192.168.1.10"/></div><div class="field"><label>SSH port</label><input id="server-port" class="input" type="number" min="1" max="65535" value="${attr(server.port || 22)}"/></div><div class="field"><label>Username</label><input id="server-username" class="input" value="${attr(server.username || "root")}"/></div><div class="field"><label>Authentication</label><select id="server-auth-type" class="select"><option value="privateKey" ${authType === "privateKey" ? "selected" : ""}>Private key · optional</option><option value="password" ${authType === "password" ? "selected" : ""}>Password · no key</option></select></div><div class="field"><label>Appdata base path</label><input id="server-base-path" class="input" value="${attr(server.basePath || "/mnt/user/appdata")}"/></div><div class="field full"><label>Inventory scan roots</label><textarea id="server-scan-roots" class="input" rows="3" placeholder="One absolute server path per line">${escapeHtml((server.scanRoots || [server.basePath || "/mnt/user/appdata"]).join("\n"))}</textarea><small>ForgeFlow scans only these roots and never changes containers during discovery.</small></div><div class="field full"><label>Excluded folder names</label><input id="server-scan-excludes" class="input" value="${attr((server.scanExcludes || ["backups", "archives", "releases", "staging", "testdata"]).join(", "))}"/><small>Comma-separated directory names or safe wildcard patterns.</small></div>${authType === "privateKey" ? `<div class="field full"><label>Private key file</label><div class="input-action"><input id="server-private-key" class="input" value="${attr(server.privateKeyPath || "")}" placeholder="C:\\Users\\Jens\\.ssh\\id_ed25519"/><button class="button" data-action="select-private-key">Browse</button></div></div><div class="field full"><label>Private key passphrase</label><input id="server-passphrase" class="input" type="password" placeholder="${server.hasPassphrase ? "Leave empty to keep stored passphrase" : "Only when the key is encrypted"}"/></div>` : `<div class="field full"><label>SSH password</label><input id="server-password" class="input" type="password" placeholder="${server.hasPassword ? "Leave empty to keep stored password" : "Password"}"/></div>`}<div class="field full"><label>Trusted host fingerprint</label><input id="server-fingerprint" class="input mono" value="${attr(server.hostFingerprint || "")}" readonly placeholder="Filled automatically after Test & trust"/></div></div><div class="notice warning" style="margin-top:12px">${icon("key")}This login secures the desktop → Unraid connection. Server pull separately creates one read-only deploy key per repository and pins the Gitea SSH host key. No reusable Gitea token is stored on Unraid.</div></div><footer class="modal-footer">${server.id ? `<button class="button danger" data-action="delete-server" data-server-id="${attr(server.id)}">Delete</button>` : ""}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-server" data-server-id="${attr(server.id || "")}">Save server</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "hunk-staging") {
|
||||
const hunks = ui.diffHunks?.hunks || [];
|
||||
@@ -1608,6 +1616,14 @@ function enhanceRenderedUi() {
|
||||
`<div class="field full"><h3>Deployment policy</h3></div><label class="check-field"><input id="profile-policy-frozen" type="checkbox" ${policy.frozen ? "checked" : ""}/><span>Freeze deployments</span></label><label class="check-field"><input id="profile-policy-note" type="checkbox" ${policy.requireNote ? "checked" : ""}/><span>Require release note</span></label><div class="field full"><label>Freeze reason</label><input id="profile-policy-freeze-reason" class="input" value="${attr(policy.freezeReason || "")}"/></div><div class="field full"><label>Maintenance windows</label><input id="profile-policy-windows" class="input" value="${attr((policy.maintenanceWindows || []).map((window) => `${window.days.join(",")}:${window.start}-${window.end}`).join(" | "))}" placeholder="1,2,3,4,5:09:00-17:00"/><small>Day 0 is Sunday. Separate windows with |.</small></div>`,
|
||||
);
|
||||
}
|
||||
if (ui.modal?.type === "workload-link") {
|
||||
const workload = (ui.serverDiscovery || []).find((server) => server.serverId === ui.modal.serverId)?.workloads?.find((item) => item.workloadId === ui.modal.workloadId);
|
||||
const type = workload?.classification?.type || "ambiguous";
|
||||
const recommended = type === "duplicate" ? "select-authoritative" : type === "stale-link" ? "archive-link" : type === "historical-compose" ? "mark-historical" : type === "orphan-container" ? "monitor-only" : "manual-link";
|
||||
const actions = [["manual-link", "Confirm selected repository match"], ["select-authoritative", "Select as authoritative instance"], ["mark-historical", "Mark historical definition"], ["archive-link", "Archive stale link"], ["monitor-only", "Keep for monitoring only"], ["manual-exclude", "Exclude this workload"], ["ignore", "Ignore with reason"]];
|
||||
const options = actions.map(([value, label]) => `<option value="${value}" ${value === recommended ? "selected" : ""}>${escapeHtml(label)}${value === recommended ? " · recommended" : ""}</option>`).join("");
|
||||
document.querySelector(".modal-body")?.insertAdjacentHTML("beforeend", `<section class="settings-group" style="margin-top:14px"><h3>Classify without touching containers</h3><div class="notice" style="margin-bottom:10px">${icon("info")}<div><strong>${escapeHtml(type)}</strong><p>${escapeHtml(workload?.classification?.reason || "ForgeFlow needs an explicit decision for this workload.")}</p></div></div><div class="form-grid"><div class="field"><label for="inventory-review-action">Review decision</label><select id="inventory-review-action" class="select">${options}</select></div><div class="field"><label for="inventory-review-reason">Reason</label><input id="inventory-review-reason" class="input" placeholder="Why is this the correct classification?"/></div></div><button class="button" style="margin-top:10px" data-action="preview-inventory-review" data-server-id="${attr(ui.modal.serverId)}" data-workload-id="${attr(ui.modal.workloadId)}">${icon("shield")}Preview classification impact</button><p class="meta">The decision is tied to current evidence and becomes stale automatically when server truth changes.</p></section>`);
|
||||
}
|
||||
if (ui.modal?.type === "deploy-confirm") {
|
||||
const profile = repository?.deploymentProfiles?.find(
|
||||
(item) => item.id === ui.modal.profileId,
|
||||
@@ -2451,6 +2467,28 @@ app.addEventListener("click", async (event) => {
|
||||
showToast("Could not link deployment", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (action === "preview-inventory-review") {
|
||||
const reviewAction = document.querySelector("#inventory-review-action")?.value || "ignore";
|
||||
const reason = document.querySelector("#inventory-review-reason")?.value.trim() || "";
|
||||
const serverId = target.dataset.serverId;
|
||||
const workloadId = target.dataset.workloadId;
|
||||
try {
|
||||
ui.inventoryReviewPlan = await window.forgeflow.planInventoryReview(serverId, workloadId, reviewAction, reason, document.querySelector("#workload-repository")?.value || null);
|
||||
ui.modal = { type: "inventory-review-plan" };
|
||||
render();
|
||||
} catch (error) { showToast("Review preview unavailable", error.message, "error"); }
|
||||
} else if (action === "apply-inventory-review") {
|
||||
const plan = ui.inventoryReviewPlan;
|
||||
if (!plan?.id) return;
|
||||
setLoading(true, "Saving the evidence-bound inventory decision…");
|
||||
try {
|
||||
const result = await window.forgeflow.applyInventoryReview(plan.serverId, plan.workloadId, plan.action, plan.reason, plan.repositoryFullName, plan.id);
|
||||
if (result.state) ui.boot.state = result.state;
|
||||
ui.serverDiscovery = (ui.serverDiscovery || []).map((item) => item.serverId === plan.serverId ? result.inventory : item);
|
||||
ui.inventoryReviewPlan = null; ui.modal = null; render();
|
||||
showToast("Inventory decision saved", "Containers and Compose runtime were not changed.", "success");
|
||||
} catch (error) { showToast("Inventory review failed safely", error.message, "error"); }
|
||||
finally { setLoading(false); }
|
||||
} else if (action === "link-server-workload") {
|
||||
const serverResult = (ui.serverDiscovery || []).find(
|
||||
(item) => item.serverId === target.dataset.serverId,
|
||||
@@ -3302,6 +3340,8 @@ app.addEventListener("click", async (event) => {
|
||||
username: document.querySelector("#server-username").value.trim(),
|
||||
authType,
|
||||
basePath: document.querySelector("#server-base-path").value.trim(),
|
||||
scanRoots: document.querySelector("#server-scan-roots").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean),
|
||||
scanExcludes: document.querySelector("#server-scan-excludes").value.split(",").map((value) => value.trim()).filter(Boolean),
|
||||
privateKeyPath:
|
||||
document.querySelector("#server-private-key")?.value.trim() || "",
|
||||
hostFingerprint: document
|
||||
|
||||
@@ -1532,6 +1532,17 @@
|
||||
if (serverId !== "server-unraid" || planId !== "a".repeat(64)) throw new Error("The reconciliation plan is stale.");
|
||||
return { adopted: 0, refreshed: 1, retired: 0, state: clone(state) };
|
||||
},
|
||||
async planInventoryReview(serverId, workloadId, action, reason = "", repositoryFullName = null) {
|
||||
if (["ignore", "manual-exclude", "exclude-scan-root"].includes(action) && reason.length < 5) throw new Error("A meaningful review reason is required.");
|
||||
return { id: "b".repeat(64), serverId, workloadId, action, reason, repositoryFullName, evidenceHash: "c".repeat(64), classification: "ambiguous", containersUnaffected: true, configurationChanges: [`Persist review decision ${action}`], recovery: "Remove the decision or rescan after evidence changes." };
|
||||
},
|
||||
async applyInventoryReview(serverId, workloadId, action, reason, repositoryFullName, planId) {
|
||||
if (planId !== "b".repeat(64)) throw new Error("The inventory review plan is stale.");
|
||||
const inventory = (await this.discoverServerDeployments()).find((item) => item.serverId === serverId);
|
||||
const workload = inventory.workloads.find((item) => item.workloadId === workloadId);
|
||||
if (workload) workload.reviewDecision = { action, reason, repositoryFullName, evidenceHash: "c".repeat(64) };
|
||||
return { decision: workload?.reviewDecision, inventory, state: clone(state) };
|
||||
},
|
||||
async linkServerWorkload(repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "") {
|
||||
await wait(120);
|
||||
const repo = repositories.find((item) => item.fullName === repository.fullName);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { classifyInventory } = require("../src/main/inventory-classifier.cjs");
|
||||
const { deploymentIdentity, deploymentEvidenceHash, deploymentAuthorityKey } = require("../src/main/deployment-identity.cjs");
|
||||
const { InventoryReviewService } = require("../src/main/inventory-review-service.cjs");
|
||||
|
||||
function workload(id, options = {}) {
|
||||
return {
|
||||
workloadId: id, serverId: options.serverId || "unraid", displayName: options.name || id,
|
||||
status: options.status || (options.link ? "linked" : "suggested"), link: options.link || null,
|
||||
compose: { project: options.project || id, workingDir: options.root || `/mnt/user/appdata/${id}`, configFiles: options.files || [`/mnt/user/appdata/${id}/compose.yml`], services: ["app"] },
|
||||
containers: options.noContainers ? [] : [{ id: `container-${id}`, name: id, running: options.running !== false }],
|
||||
runtime: { running: options.running !== false, health: options.health || "healthy" },
|
||||
metadata: { sourceRepository: options.remote ?? "git@gitea.test:Jens/Portfolio.git", liveRevision: options.sha || "a".repeat(40), branch: options.branch || "main" },
|
||||
candidates: options.candidates || [{ repositoryFullName: "Jens/Portfolio", score: 100, exact: true }],
|
||||
remoteFolderCandidate: id,
|
||||
};
|
||||
}
|
||||
|
||||
test("deployment identity is canonical across SSH and HTTPS remotes", () => {
|
||||
const ssh = deploymentIdentity({ workload: workload("one") });
|
||||
const https = deploymentIdentity({ workload: workload("two", { remote: "https://gitea.test/Jens/Portfolio.git" }) });
|
||||
assert.equal(ssh.repository, https.repository);
|
||||
assert.equal(deploymentAuthorityKey(ssh), deploymentAuthorityKey({ ...https, serverId: ssh.serverId, environment: ssh.environment }));
|
||||
});
|
||||
|
||||
test("running duplicate is authoritative and historical folder is never linked", () => {
|
||||
const active = workload("portfolio-current", { link: { profileId: "profile", repositoryFullName: "Jens/Portfolio" } });
|
||||
const historical = workload("portfolio-old", { running: false, root: "/mnt/user/appdata/portfolio/releases/old", link: { profileId: "profile-old", repositoryFullName: "Jens/Portfolio" } });
|
||||
const result = classifyInventory([historical, active], [{ id: "profile", environment: "production" }, { id: "profile-old", environment: "production" }]);
|
||||
assert.equal(result.find((item) => item.workloadId === "portfolio-current").authoritative, true);
|
||||
assert.equal(result.find((item) => item.workloadId === "portfolio-old").classification.type, "duplicate");
|
||||
assert.equal(result.find((item) => item.workloadId === "portfolio-old").link, null);
|
||||
});
|
||||
|
||||
for (const [label, item, expected] of [
|
||||
["backup Compose folder", workload("backup", { root: "/mnt/user/appdata/portfolio-backup", running: false }), "backup"],
|
||||
["release directory", workload("release", { root: "/mnt/user/appdata/portfolio/releases/a1", running: false }), "release-folder"],
|
||||
["staging workload", workload("stage", { root: "/mnt/user/appdata/portfolio-staging" }), "staging"],
|
||||
["stopped legitimate app", workload("stopped", { running: false }), "stopped-application"],
|
||||
["Compose without container", workload("historical", { noContainers: true, running: false }), "historical-compose"],
|
||||
["container without repository", workload("orphan", { remote: "", candidates: [], status: "unmatched" }), "orphan-container"],
|
||||
["system container", workload("infra", { name: "watchtower", remote: "", candidates: [] }), "system-container"],
|
||||
["ambiguous exact matches", workload("ambiguous", { status: "ambiguous", candidates: [{ repositoryFullName: "Jens/A", score: 100, exact: true }, { repositoryFullName: "Jens/B", score: 100, exact: true }] }), "ambiguous"],
|
||||
["profile whose server workload disappeared", { ...workload("stale", { running: false, noContainers: true, link: { profileId: "profile-stale", repositoryFullName: "Jens/Portfolio" } }), metadata: { sourceRepository: "git@gitea.test:Jens/Portfolio.git", branch: "main", staleLink: true } }, "stale-link"],
|
||||
]) test(`inventory classifies ${label}`, () => {
|
||||
assert.equal(classifyInventory([item])[0].classification.type, expected);
|
||||
});
|
||||
|
||||
test("multi-instance environments remain separate authority groups", () => {
|
||||
const a = workload("instance-a", { link: { profileId: "a", repositoryFullName: "Jens/Portfolio" } });
|
||||
const b = workload("instance-b", { link: { profileId: "b", repositoryFullName: "Jens/Portfolio" } });
|
||||
const result = classifyInventory([a, b], [{ id: "a", environment: "production" }, { id: "b", environment: "staging" }]);
|
||||
assert.equal(result.filter((item) => item.classification.type === "duplicate").length, 0);
|
||||
});
|
||||
|
||||
test("stored review decision becomes stale when remote evidence changes", () => {
|
||||
const original = classifyInventory([workload("review")])[0];
|
||||
const decision = { workloadId: original.workloadId, evidenceHash: original.evidenceHash, action: "ignore", reason: "Known external workload" };
|
||||
const unchanged = classifyInventory([workload("review")], [], [decision])[0];
|
||||
const changed = classifyInventory([workload("review", { remote: "git@gitea.test:Jens/Renamed.git" })], [], [decision])[0];
|
||||
assert.equal(unchanged.reviewDecision.action, "ignore");
|
||||
assert.equal(changed.reviewDecision, null);
|
||||
assert.equal(changed.reviewDecisionStale, true);
|
||||
});
|
||||
|
||||
test("review decisions drive classification and explicit authority", () => {
|
||||
const primary = classifyInventory([workload("primary")])[0];
|
||||
const secondary = classifyInventory([workload("secondary")])[0];
|
||||
const decisions = [
|
||||
{ workloadId: primary.workloadId, evidenceHash: primary.evidenceHash, action: "mark-historical", reason: "Retained rollback definition" },
|
||||
{ workloadId: secondary.workloadId, evidenceHash: secondary.evidenceHash, action: "select-authoritative", reason: "Verified production instance" },
|
||||
];
|
||||
const result = classifyInventory([workload("primary"), workload("secondary")], [], decisions);
|
||||
assert.equal(result.find((item) => item.workloadId === "primary").classification.type, "duplicate");
|
||||
assert.equal(result.find((item) => item.workloadId === "secondary").authoritative, true);
|
||||
});
|
||||
|
||||
test("ignore and monitor-only decisions stay evidence-bound", () => {
|
||||
const ignored = classifyInventory([workload("ignored")])[0];
|
||||
const monitoredSource = workload("monitored", { remote: "git@gitea.test:Jens/Monitored.git", candidates: [{ repositoryFullName: "Jens/Monitored", score: 100, exact: true }] });
|
||||
const monitored = classifyInventory([monitoredSource])[0];
|
||||
const result = classifyInventory([workload("ignored"), monitoredSource], [], [
|
||||
{ workloadId: ignored.workloadId, evidenceHash: ignored.evidenceHash, action: "ignore", reason: "Managed by another platform" },
|
||||
{ workloadId: monitored.workloadId, evidenceHash: monitored.evidenceHash, action: "monitor-only", reason: "Visibility without deployment ownership" },
|
||||
]);
|
||||
assert.equal(result.find((item) => item.workloadId === "ignored").classification.type, "manually-excluded");
|
||||
assert.equal(result.find((item) => item.workloadId === "monitored").classification.type, "monitor-only");
|
||||
});
|
||||
|
||||
test("review service requires reason, exact plan and recovery snapshot", async () => {
|
||||
const decisions = [];
|
||||
const store = { getInventoryReviewDecisions: () => decisions, createRecoverySnapshot: async () => ({ filePath: "snapshot.json" }), saveInventoryReviewDecision: async (_server, decision) => { decisions.push(decision); return decision; } };
|
||||
const service = new InventoryReviewService({ store });
|
||||
const item = classifyInventory([workload("review")])[0];
|
||||
assert.throws(() => service.preview({ serverId: "unraid", workload: item, action: "ignore", reason: "no" }), (error) => error.code === "INVENTORY_REVIEW_REASON_REQUIRED");
|
||||
const plan = service.preview({ serverId: "unraid", workload: item, action: "ignore", reason: "Managed outside ForgeFlow" });
|
||||
await assert.rejects(service.apply({ plan }), (error) => error.code === "INVENTORY_REVIEW_PLAN_REQUIRED");
|
||||
const result = await service.apply({ plan, expectedPlanId: plan.id });
|
||||
assert.equal(result.snapshot.filePath, "snapshot.json");
|
||||
assert.equal(decisions[0].evidenceHash, item.evidenceHash);
|
||||
});
|
||||
|
||||
test("large inventory classification is deterministic and bounded", () => {
|
||||
const input = Array.from({ length: 1200 }, (_, index) => workload(`app-${index}`, { remote: `git@gitea.test:Jens/App-${index}.git`, candidates: [{ repositoryFullName: `Jens/App-${index}`, score: 100, exact: true }] }));
|
||||
const started = Date.now();
|
||||
const result = classifyInventory(input);
|
||||
assert.equal(result.length, 1200);
|
||||
assert.ok(Date.now() - started < 2000);
|
||||
assert.equal(new Set(result.map((item) => item.evidenceHash)).size, 1200);
|
||||
});
|
||||
|
||||
test("evidence hash changes for runtime, Compose and candidate changes", () => {
|
||||
const identity = deploymentIdentity({ workload: workload("hash") });
|
||||
const one = deploymentEvidenceHash(identity, { running: true, files: ["compose.yml"] });
|
||||
const two = deploymentEvidenceHash(identity, { running: false, files: ["compose.yml"] });
|
||||
assert.notEqual(one, two);
|
||||
});
|
||||
@@ -1064,6 +1064,22 @@ test("linked Compose deployments retain the existing project, files and service
|
||||
assert.deepEqual(service.deploymentServices(profile, repository), ["api", "worker"]);
|
||||
});
|
||||
|
||||
test("inventory scan uses only configured roots and reports partial find failures", () => {
|
||||
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {} });
|
||||
const script = service.inventoryScript({
|
||||
basePath: "/mnt/user/appdata",
|
||||
scanRoots: ["/mnt/user/appdata", "/mnt/cache/custom apps"],
|
||||
scanExcludes: ["archive-*", "scratch"],
|
||||
});
|
||||
assert.match(script, /add_scan_root '\/mnt\/user\/appdata'/);
|
||||
assert.match(script, /add_scan_root '\/mnt\/cache\/custom apps'/);
|
||||
assert.match(script, /-name 'archive-\*'/);
|
||||
assert.match(script, /-name 'scratch'/);
|
||||
assert.match(script, /Inventory scan partially failed/);
|
||||
assert.match(script, /2>"\$scan_error" \|\| true/);
|
||||
assert.doesNotMatch(script, /add_scan_root \/mnt\/cache\/appdata/);
|
||||
});
|
||||
|
||||
test("push bundle activation validates Compose and services before promoting current SHA", () => {
|
||||
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {} });
|
||||
const repository = { name: "OmniRoute", fullName: "Jens/OmniRoute" };
|
||||
|
||||
Reference in New Issue
Block a user