diff --git a/SOURCE_MANIFEST.txt b/SOURCE_MANIFEST.txt index 151f864..5adb1a8 100644 --- a/SOURCE_MANIFEST.txt +++ b/SOURCE_MANIFEST.txt @@ -15,9 +15,11 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263 5f4aca19a35cbcaffa1a6993ce96b7d66052ec2b286022f2af74594e8a310568 15712 CHANGELOG.md 21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md +72e846f591c47a0291e7466e58e052d3d5afcf551c4e6c848632ac3c552a1244 3043 docs/CURRENT_STATE.md 30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md eb42f979666e05d51c587e4223282914926a2b9b1ade9f3fb75525019ce7f738 4616 docs/DIAGNOSTICS.md a0cd06a96f23a94e118feb012be0fa1ac51345951cb2ba8e67fb8c889c4c342a 5007 docs/LUMAOPS_SERVER_AUDIT.md +648dd6bca6b45668fb86eb3e1f6c5898dd8da0291b990f1bb5105cfd79421343 1301 docs/MUTATION_MODEL.md f79908fb3dad98c38030c6e6be7c79a1999e0478ed9c2496923891954438daa1 4581 docs/RELEASE_AUDIT_0.6.0.md 979a0b8e129979be6b265e8571d0a3c1e9ddd4ddb6b0bf55ae748d3478e51854 2296 docs/RELEASE_NOTES_0.10.0.md a0c00ff76acd1682bb5e0e8dcf6589c9480da436c9c6d30780a1ed58b4dad94f 1770 docs/RELEASE_NOTES_0.2.0.md @@ -80,8 +82,8 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 3b16a087c73b600415394dff8b8e34e7f7519e48fde1cf443007b2e11ca77b27 13123 main.cjs 91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md efe2f75ec8bfdbad8e2ee68d0f2c4b412ba460c6b841163d7853f56d5b7b63ea 130468 package-lock.json -605514833fe95a59cbfd5cee6a509ce8a0228a8b79c1fcc5bdb4e8fea92084b7 4364 package.json -e95be3f3736f2c1ef249fdc9a083fffa46c07a0b885979878a552a4846995aa6 10160 preload.cjs +f4ac31dd2266494085409ad5fc8bc440aa871296f7c64042495b6b14dec49084 4427 package.json +3fb463572c62c94bec2d0675c73f8258e02d52cd9632b9fc99d4f24d137caf4e 10392 preload.cjs abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1 688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1 @@ -96,11 +98,11 @@ f6f89e893195b9c8ef0ff01e256005b9b3cd7d4a278722979a5e0e616c86a89f 1733 403a64db5069595a83006a4e293d7e5ceeaefcfb74e820ed3e899864a0f182d2 6066 scripts/publish-binary-release.cjs 444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs 4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs -43e84c3cced1e23ba5b070d87051a235cd7e2d8c02e2e5ddc1e5e0ff2afabace 16487 scripts/verify.mjs +20cc93c86efaf4d23851225e031639b093cafb23dabb083e1804cade17651e82 16542 scripts/verify.mjs 0079701b5acbfef07b71a9623613d1940805ccd20649d77e3f34c37e79df7655 735 scripts/write-release-checksums.mjs 619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1 dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs -c9dee05857b6eb9475dace885579291b9ce6830023ab54acafaa146d15962a7a 27754 src/main/config-store.cjs +80e0afcfa77dbc617c425cd603e5c165ea425b5f47046538657bc1dc51d7b1e7 28670 src/main/config-store.cjs 2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs 9d0af5074093108a5248d0dde0ff70a666748e61f1954b630886a81e8f34072c 24079 src/main/deployment-service.cjs c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs @@ -108,7 +110,7 @@ a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs 3ce45837099ac7bddc024974bd839575b4b765a7df9055e7d45ef889dc85bf7f 15623 src/main/git-validator-service.cjs d85d5b1abb35e8bd7f1273a697914eeaf96567d3d4ce3f55f364765d35ea4ad9 19900 src/main/gitea-service.cjs -8adeebd08aeafaf79ed8fc7bcd2c1e110d3dc17c14596fb623b9d2c7255fcd11 49662 src/main/ipc.cjs +127678735714595472c4a62188404284c6ae9cab43412b178c71836c9f34e767 50922 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 @@ -116,15 +118,15 @@ e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs 52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs -75f4135bc068d6c615fc317cfda81f33a4387c4ff44fbb9799a055f302cc154d 137170 src/main/unraid-deployment-service.cjs +d389e195eb48c5b1b801163c9bcf15e289cf312324aaaa8355b487834503a0fe 141995 src/main/unraid-deployment-service.cjs b654a9e45044ad32c61fabe4a6d897288615ec83739b53e3241ff881e32f56bd 21677 src/main/update-service.cjs -07822cdaf34745678b82b7eb0f20fedb16bdbcd6ab2b9b774ae0cf9c6069032d 234703 src/renderer/app.js +ea82d232b6a31218aeea3a807e4d490341474aa72d5d9c08db99cc3355fdbeb6 239190 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 -b63786e7e4f70eac8b65d29484c0040aec530a410da5f554574d8a056cd90003 60621 src/renderer/mock-bridge.js +81a15afced5b8782346d13a6ccbf3ba576a284d0e756eec6c704735026ee4b4c 61728 src/renderer/mock-bridge.js 34ee56ed08dcd1c2295985b9bb419b981598a2699ee8e65394d86f4177f4797b 77647 src/renderer/styles.css 0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs 5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs @@ -140,7 +142,7 @@ f8853dce6fdf360d5df2fbe2b6df3e5687630c807fee5ba8436679b34ec737ea 2436 f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009 tests/acceptance.test.mjs a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs 454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 4318 tests/clone-target.test.mjs -a984ddd5a29a4ccf55d78ba71202390e0bf1f0925a6a96f03ee74edbbf3bb2f7 1505 tests/configuration-backup.test.mjs +f1463326aee79842d265687ae628189ce54e92544600f2bd14073780287cfb14 2502 tests/configuration-backup.test.mjs 1dc6477bd07de78be189e6e8195ec339eb9d75820c4dbd5b073b8520ee21f6b5 1938 tests/deployment-policy.test.mjs bf68c4dc91a2604235c6a7848088bcd9566fbeaa089b86ec1e0a4fcfc54ca9d2 7677 tests/deployment-status.test.mjs fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800 tests/diagnostics.test.mjs @@ -163,7 +165,7 @@ bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs 2571128f0b8e650071df17755baa09c4dfc441af0c20a7a4e9aa445b59e87d11 1654 tests/ssh-service.test.mjs 8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs -5634e82a3a3c782cd6d7cd4df42b3102c22b81c5566fb072d7cd30de65c1f395 40003 tests/unraid-deployment.test.mjs +80e951961f015417160f8ba40b2e5a2fd85177b025cbae66ae482b962e58e8c4 40498 tests/unraid-deployment.test.mjs 4abe7b2fc113c486f35f15c2d629c5b4f24589eada718c4ea58f93551c77d5eb 17777 tests/update-service.test.mjs 9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs 7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs diff --git a/docs/CURRENT_STATE.md b/docs/CURRENT_STATE.md new file mode 100644 index 0000000..1022af2 --- /dev/null +++ b/docs/CURRENT_STATE.md @@ -0,0 +1,54 @@ +# ForgeFlow current state + +## Baseline + +- Baseline version: **0.10.0** +- Baseline commit: `56efd1a00c2e76251a0b2e7a7a424d94200ae33c` +- Baseline branch: `main` +- Desktop runtime: Electron 43 with Node.js 22+ required by the source project +- Primary supported packaged updater: Windows installer and portable executable + +The baseline was recorded before the 1.0 professionalization programme. It is the comparison point for functional, deployment and renderer regressions. + +## Known baseline evidence + +- `npm run check`: 155 tests, 152 passed, 3 environment-dependent Bash checks skipped, 0 failed. +- OS-backed secure storage, encrypted Gitea token, Gitea API, repository and Actions access were available. +- Unraid exposed Docker, Compose, Git, tar and SHA-256 tooling. +- The server inventory contained active repository workloads plus a large number of historical or unrelated definitions that require backend classification. +- Windows release artifacts were checksum-protected but not Authenticode-signed. + +No secret values, passwords, private keys or tokens are stored in this document. + +## Operation classes + +| Class | Default | Examples | +| --- | --- | --- | +| Read-only inspection | Allowed without confirmation | repository refresh, server inventory, deploy-key probe, preflight, audit export | +| Reconciliation | Preview required | adopt an exact workload, refresh a profile from server truth | +| Configuration mutation | Explicit action and audit record | save profile, rotate deploy key, change server settings | +| Deployment | Fresh preflight and confirmation | server pull, Direct Copy, Gitea Actions dispatch | +| Destructive maintenance | Recovery evidence and explicit confirmation | unlink, prune, key revocation, rollback | + +Discovery and audit never belong to a mutating class. Ambiguous evidence cannot be promoted automatically. + +## Recovery model + +ForgeFlow writes its configuration atomically. Explicit server reconciliation additionally creates a private recovery snapshot before changing profiles or deployment state. Encrypted user-created `.ffbackup` files remain the portable restore mechanism; recovery snapshots are local operational safeguards and can contain OS-encrypted credential material. + +## Issue priorities + +- **P0:** active data loss, credential disclosure, arbitrary execution or uncontrolled production mutation. +- **P1:** release-blocking incorrect deployment, unsafe implicit mutation, broken recovery or material security gap. +- **P2:** important functional, accessibility, performance or maintainability defect with a safe workaround. +- **P3:** polish, documentation or low-risk improvement. + +## 1.0 constraints + +- No force-push or implicit repository history rewrite. +- No automatic deployment deletion. +- No desktop Gitea token on a server. +- Read-only repository-scoped deploy keys for server pull. +- SSH host-key changes fail closed. +- Live commit, remote commit and runtime health remain separate evidence. +- Production signing must fail closed when the required external certificate is absent. diff --git a/docs/MUTATION_MODEL.md b/docs/MUTATION_MODEL.md new file mode 100644 index 0000000..dd9b541 --- /dev/null +++ b/docs/MUTATION_MODEL.md @@ -0,0 +1,26 @@ +# Mutation and reconciliation model + +ForgeFlow separates observation from state changes at the API boundary. + +## Discovery + +`scanServerInventory()` and `discoverServerWorkloads()` collect Docker, Compose, DockerMan and Git evidence. They may write diagnostic logs, but they do not save, update or delete deployment profiles and do not change containers. + +## Reconciliation planning + +`planServerInventoryReconciliation()` returns a content-addressed plan containing: + +- exact links that may be added; +- existing profiles whose observed metadata may be refreshed; +- stale profiles that require review; +- ambiguous workloads that block automatic application. + +The plan identifier changes whenever its proposed scope changes. + +## Reconciliation application + +`reconcileServerInventory()` requires the exact reviewed plan identifier. It rescans the server and refuses a stale plan. Before writing configuration it creates a private recovery snapshot. Stale profiles are reported but never removed automatically. + +## Direct mutations + +Manual linking, unlinking, deploy-key rotation, deployment and rollback remain separate explicit commands. Each must append an audit event with repository, profile, operation identifier and result. Destructive commands need a dedicated confirmation flow and recovery path. diff --git a/package.json b/package.json index ed27732..0ecbd29 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,9 @@ "docs/RELEASE_NOTES_0.9.3.md", "docs/RELEASE_NOTES_0.9.4.md", "docs/RELEASE_NOTES_0.9.5.md", - "docs/RELEASE_NOTES_0.10.0.md" + "docs/RELEASE_NOTES_0.10.0.md", + "docs/CURRENT_STATE.md", + "docs/MUTATION_MODEL.md" ], "asarUnpack": [ "scripts/apply-binary-update.ps1" diff --git a/preload.cjs b/preload.cjs index 781f1af..42fdf1b 100644 --- a/preload.cjs +++ b/preload.cjs @@ -121,6 +121,8 @@ contextBridge.exposeInMainWorld( healthcheck: (url) => invoke('deployment:health', { url }), refreshProfileState: (fullName, profileId) => invoke('deployment:profile-state', { fullName, profileId }), discoverServerDeployments: () => invoke('deployment:discover-server-workloads'), + planServerReconciliation: (serverId) => invoke('deployment:plan-server-reconciliation', { serverId }), + applyServerReconciliation: (serverId, planId) => invoke('deployment:apply-server-reconciliation', { serverId, 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 }), applyDockerManMetadata: (repository, profileId) => invoke('deployment:apply-dockerman-metadata', { repository, profileId }), diff --git a/scripts/verify.mjs b/scripts/verify.mjs index bcb6c68..0d03a68 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -57,6 +57,8 @@ const required = [ "scripts/apply-source-update.ps1", "scripts/apply-binary-update.ps1", "docs/ARCHITECTURE.md", + "docs/CURRENT_STATE.md", + "docs/MUTATION_MODEL.md", "docs/SECURITY.md", "docs/ROADMAP.md", "docs/SETUP_GUIDE.md", diff --git a/src/main/config-store.cjs b/src/main/config-store.cjs index 1f2e448..cad854a 100644 --- a/src/main/config-store.cjs +++ b/src/main/config-store.cjs @@ -142,6 +142,19 @@ class ConfigStore { return this.saveQueue; } + async createRecoverySnapshot(reason = 'configuration-change') { + await this.saveQueue.catch(() => {}); + const safeReason = String(reason || 'configuration-change').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'configuration-change'; + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const snapshotDirectory = path.join(path.dirname(this.filePath), 'snapshots'); + const snapshotPath = path.join(snapshotDirectory, `${timestamp}-${safeReason}.json`); + await fs.mkdir(snapshotDirectory, { recursive: true }); + await fs.writeFile(snapshotPath, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600, flag: 'wx' }); + try { await fs.chmod(snapshotDirectory, 0o700); } catch {} + try { await fs.chmod(snapshotPath, 0o600); } catch {} + return { filePath: snapshotPath, reason: safeReason, createdAt: new Date().toISOString() }; + } + setToken(token, { preserveExisting = false } = {}) { const value = String(token || '').trim(); if (!value && preserveExisting && this.getToken()) return { persistent: Boolean(this.data.gitea.encryptedToken), preserved: true }; diff --git a/src/main/ipc.cjs b/src/main/ipc.cjs index 3cdde1d..33b61a9 100644 --- a/src/main/ipc.cjs +++ b/src/main/ipc.cjs @@ -1244,6 +1244,31 @@ function registerIpc({ } return results; }); + register("deployment:plan-server-reconciliation", async ({ serverId }) => { + const repositoryList = await repositories.refresh(); + const remoteRepositories = repositoryList.filter((repository) => repository.owner?.login !== "local"); + const result = await unraid.planServerInventoryReconciliation(serverId, remoteRepositories, { autoLink: true }); + await audit.append("deployment.server-reconciliation-planned", { + serverId, + planId: result.plan.id, + summary: result.plan.summary, + }); + return result; + }); + register("deployment:apply-server-reconciliation", async ({ serverId, planId }) => { + const repositoryList = await repositories.refresh(); + const remoteRepositories = repositoryList.filter((repository) => repository.owner?.login !== "local"); + const result = await unraid.reconcileServerInventory(serverId, remoteRepositories, { autoLink: true, expectedPlanId: planId }); + await audit.append("deployment.server-reconciliation-applied", { + serverId, + planId, + adopted: result.adopted, + refreshed: result.refreshed, + retired: result.retired, + recoverySnapshot: result.recoverySnapshot?.filePath || null, + }); + return { ...result, 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 95c1388..6fd76a4 100644 --- a/src/main/unraid-deployment-service.cjs +++ b/src/main/unraid-deployment-service.cjs @@ -1128,7 +1128,7 @@ $extra" }); } - async scanServerInventory(serverId, repositories, { autoLink = true } = {}) { + async collectServerInventory(serverId, repositories) { const server = this.store.getServer(serverId); if (!server) throw new Error("The deployment server no longer exists."); const result = await this.ssh.exec(server.id, bash(this.inventoryScript(server)), { @@ -1162,9 +1162,123 @@ $extra" workload.link = null; workload.status = workload.candidates?.length ? "suggested" : "unmatched"; } + return { server, inventory, workloads }; + } + + inventoryResponse(server, inventory, workloads, changes = {}) { + const summary = { + serverId: server.id, + serverName: server.name, + detected: workloads.length, + adopted: Number(changes.adopted || 0), + refreshed: Number(changes.refreshed || 0), + retired: Number(changes.retired || 0), + staleProfiles: Array.isArray(changes.staleProfiles) ? changes.staleProfiles : [], + 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, + running: workloads.filter((item) => item.runtime.running).length, + stopped: workloads.filter((item) => !item.runtime.running).length, + }; + return { + ...summary, + server: { id: server.id, name: server.name, host: server.host, basePath: server.basePath }, + capabilities: inventory.capabilities, + warnings: inventory.warnings, + workloads, + observedAt: new Date().toISOString(), + }; + } + + async scanServerInventory(serverId, repositories) { + const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories); + const response = this.inventoryResponse(server, inventory, workloads); + await this.diagnostics?.info("unraid.workloads.scanned", { + serverId, + detected: response.detected, + linked: response.linked, + needsReview: response.needsReview, + readOnly: true, + }); + return response; + } + + 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 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 (workload.link?.profileId && workload.link?.repositoryFullName) { + updates.push({ + workloadId: workload.workloadId, + profileId: workload.link.profileId, + repositoryFullName: workload.link.repositoryFullName, + impact: "Refresh detected Compose identity and observed deployment state", + }); + continue; + } + const candidate = workload.candidates?.[0]; + const unique = workload.candidates?.length === 1; + const exact = unique && (candidate?.exact === true || (candidate?.identityExact === true && candidate.score >= 70)); + if (autoLink && exact && workload.runtime?.running && !linkedRepositories.has(String(candidate.repositoryFullName).toLowerCase())) { + additions.push({ + workloadId: workload.workloadId, + repositoryFullName: candidate.repositoryFullName, + evidence: candidate.exact ? "exact-provenance" : "exact-runtime-identity", + impact: "Create a server-pull deployment profile; no container changes", + }); + } else if (["suggested", "ambiguous"].includes(workload.status) || (workload.runtime?.running && workload.candidates?.length)) { + conflicts.push({ + workloadId: workload.workloadId, + displayName: workload.displayName, + status: workload.status, + candidates: (workload.candidates || []).slice(0, 5).map((item) => ({ repositoryFullName: item.repositoryFullName, score: item.score, exact: item.exact === true })), + }); + } + } + const stale = profiles.filter((profile) => + String(profile.workloadIdentity?.linkSource || "").startsWith("automatic") + && profile.workloadIdentity?.workloadId + && !activeWorkloadIds.has(profile.workloadIdentity.workloadId), + ).map((profile) => ({ + profileId: profile.id, + repositoryFullName: profile._repositoryFullName, + reason: "workload-missing", + impact: "Review only; ForgeFlow will not remove this profile automatically", + })); + const payload = { serverId: server.id, additions, updates, stale, conflicts }; + return { + id: crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex"), + createdAt: new Date().toISOString(), + ...payload, + summary: { additions: additions.length, updates: updates.length, stale: stale.length, conflicts: conflicts.length }, + }; + } + + async planServerInventoryReconciliation(serverId, repositories, options = {}) { + const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories); + const plan = this.reconciliationPlan(server, workloads, repositories, options); + return { inventory: this.inventoryResponse(server, inventory, workloads), plan }; + } + + async reconcileServerInventory(serverId, repositories, { autoLink = true, expectedPlanId = "" } = {}) { + const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories); + const plan = this.reconciliationPlan(server, workloads, repositories, { autoLink }); + if (!expectedPlanId || expectedPlanId !== plan.id) { + const error = new Error(expectedPlanId ? "The server inventory changed after the reconciliation preview. Review a fresh plan before applying it." : "Apply reconciliation only with an explicitly reviewed plan ID."); + error.code = expectedPlanId ? "RECONCILIATION_PLAN_STALE" : "RECONCILIATION_PLAN_REQUIRED"; + error.plan = plan; + throw error; + } + const recoverySnapshot = await this.store.createRecoverySnapshot?.(`server-reconciliation-${serverId}`) || null; let adopted = 0; let refreshed = 0; let retired = 0; + 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)); @@ -1174,10 +1288,6 @@ $extra" && profile.workloadIdentity?.workloadId && !activeWorkloadIds.has(profile.workloadIdentity.workloadId), ); - for (const profile of staleAutomaticProfiles) { - await this.store.deleteDeploymentProfile(profile._repositoryFullName, profile.id); - retired += 1; - } const runningRepositoryLinks = new Set(workloads .filter((workload) => workload.runtime?.running && workload.link?.repositoryFullName) .map((workload) => String(workload.link.repositoryFullName).toLowerCase())); @@ -1188,10 +1298,11 @@ $extra" .filter((workload) => !workload.runtime?.running && workload.link?.profileId && !runningProfileIds.has(workload.link.profileId) && runningRepositoryLinks.has(String(workload.link.repositoryFullName).toLowerCase())) .map((workload) => this.allSshProfiles().find((profile) => profile.id === workload.link.profileId && String(profile._repositoryFullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase())) .filter((profile) => profile && String(profile.workloadIdentity?.linkSource || "").startsWith("automatic")); - for (const profile of shadowedAutomaticProfiles) { - await this.store.deleteDeploymentProfile(profile._repositoryFullName, profile.id); - retired += 1; - } + staleProfiles = [...new Map([...staleAutomaticProfiles, ...shadowedAutomaticProfiles].map((profile) => [profile.id, { + profileId: profile.id, + repositoryFullName: profile._repositoryFullName, + reason: staleAutomaticProfiles.includes(profile) ? "workload-missing" : "shadowed-by-running-workload", + }])).values()]; } for (const workload of workloads) { if (workload.status !== "linked" || !workload.link?.profileId || !workload.link?.repositoryFullName) continue; @@ -1214,21 +1325,16 @@ $extra" const uniqueCandidate = workload.candidates.length === 1; if (candidate && alreadyLinkedRepositories.has(String(candidate.repositoryFullName).toLowerCase())) continue; const exactMatch = uniqueCandidate && candidate?.exact === true; - const strongComposeMatch = uniqueCandidate - && candidate?.confidence === "strong" - && candidate.score >= 90 - && Boolean(workload.compose?.workingDir) - && Boolean(workload.compose?.configFiles?.length); const exactRuntimeIdentity = uniqueCandidate && candidate?.identityExact === true && candidate.score >= 70 && workload.runtime?.running === true && Boolean(workload.remoteFolderCandidate) && !alreadyLinkedRepositories.has(String(candidate.repositoryFullName).toLowerCase()); - if (!exactMatch && !strongComposeMatch && !exactRuntimeIdentity) continue; + if (!exactMatch && !exactRuntimeIdentity) continue; const repository = (repositories || []).find((item) => item.fullName === candidate.repositoryFullName); if (!repository) continue; - const linkSource = exactMatch ? "automatic" : exactRuntimeIdentity ? "automatic-runtime-identity" : "automatic-compose"; + const linkSource = exactMatch ? "automatic" : "automatic-runtime-identity"; const profile = this.profileFromWorkload(repository, server, workload, { linkSource, deploymentMode: "server-git" }); const saved = await this.store.saveDeploymentProfile(repository.fullName, profile); await this.saveWorkloadState(saved, workload, server); @@ -1238,34 +1344,20 @@ $extra" adopted += 1; } } - const summary = { + const response = this.inventoryResponse(server, inventory, workloads, { adopted, refreshed, retired, staleProfiles }); + response.recoverySnapshot = recoverySnapshot; + await this.diagnostics?.info("unraid.workloads.reconciled", { serverId, - serverName: server.name, - detected: workloads.length, + detected: response.detected, adopted, refreshed, retired, - 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, - running: workloads.filter((item) => item.runtime.running).length, - stopped: workloads.filter((item) => !item.runtime.running).length, - }; - const response = { - ...summary, - server: { id: server.id, name: server.name, host: server.host, basePath: server.basePath }, - capabilities: inventory.capabilities, - warnings: inventory.warnings, - workloads, - observedAt: new Date().toISOString(), - }; - await this.diagnostics?.info("unraid.workloads.discovered", summary); + }); return response; } async discoverServerWorkloads(serverId, repositories) { - return this.scanServerInventory(serverId, repositories, { autoLink: true }); + return this.scanServerInventory(serverId, repositories); } async linkServerWorkload({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) { @@ -1274,7 +1366,7 @@ $extra" : "server-git"; const server = this.store.getServer(serverId); if (!server) throw new Error("The deployment server no longer exists."); - const inventory = await this.scanServerInventory(serverId, [repository], { autoLink: false }); + const inventory = await this.scanServerInventory(serverId, [repository]); const workload = inventory.workloads.find((item) => item.workloadId === workloadId); if (!workload) throw new Error("The selected server workload no longer exists. Scan the server again."); const existing = this.allSshProfiles().find((profile) => profile.workloadIdentity?.workloadId === workloadId && profile.serverId === serverId); diff --git a/src/renderer/app.js b/src/renderer/app.js index 98b4af2..d7cd558 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -1215,7 +1215,7 @@ function renderServerInventory() { return `
${escapeHtml(workload.displayName)}${escapeHtml(detail)} · ${workload.runtime?.running ? "running" : "stopped"}${escapeHtml(candidate)}${workload.metadata?.composeDefinitionError ? `Compose file found; validation warning: ${escapeHtml(workload.metadata.composeDefinitionError)}` : ""}
${escapeHtml(linked ? "Linked" : workload.status || "Review")}${linked ? `` : linkButton}
`; }).join("") : `

${server.error ? "No inventory could be read until the SSH connection works." : "Docker returned no containers, Compose projects or DockerMan templates."}

`; - return `

${escapeHtml(server.serverName || server.server?.name || server.serverId)}

${server.running || 0} running · ${server.linked || 0} repository links · ${visibleWorkloads.filter((workload) => !workload.link).length} to review${hiddenCount ? ` · ${hiddenCount} unrelated/system workloads hidden` : ""}
${server.error ? "Scan failed" : escapeHtml(capabilityText)}
${errorBlock}${warnings}
${workloads}
`; + return `

${escapeHtml(server.serverName || server.server?.name || server.serverId)}

${server.running || 0} running · ${server.linked || 0} repository links · ${visibleWorkloads.filter((workload) => !workload.link).length} to review${hiddenCount ? ` · ${hiddenCount} unrelated/system workloads hidden` : ""}
${server.error ? "Scan failed" : escapeHtml(capabilityText)}${server.error ? "" : ``}
${errorBlock}${warnings}
${workloads}
`; }).join(""); const empty = configuredServers.length ? `

Server inventory has not completed

ForgeFlow will query Docker directly. A failed connection is shown explicitly instead of being reported as zero deployments.

` @@ -1367,6 +1367,17 @@ function renderModal() { const retryText = ui.modal.retry?.type === "deploy" ? "Save password & redeploy" : "Save password & rescan"; return ``; } + if (ui.modal.type === "server-reconciliation-plan") { + const plan = ui.modal.result?.plan || {}; + const summary = plan.summary || {}; + const rows = [ + ...(plan.additions || []).map((item) => ({ tone: "success", title: `Link ${item.repositoryFullName}`, detail: `${item.evidence} · ${item.impact}` })), + ...(plan.updates || []).map((item) => ({ tone: "", title: `Refresh ${item.repositoryFullName}`, detail: item.impact })), + ...(plan.stale || []).map((item) => ({ tone: "warning", title: `Review stale link ${item.repositoryFullName}`, detail: `${item.reason} · no automatic removal` })), + ...(plan.conflicts || []).map((item) => ({ tone: "danger", title: `Manual review: ${item.displayName}`, detail: `${item.status} · ${(item.candidates || []).map((candidate) => candidate.repositoryFullName).join(", ") || "no unique repository"}` })), + ]; + return ``; + } if (ui.modal.type === "workload-link") { const serverResult = (ui.serverDiscovery || []).find( (item) => item.serverId === ui.modal.serverId, @@ -2361,6 +2372,29 @@ app.addEventListener("click", async (event) => { showToast("Server scan failed", error.message, "error"); } setLoading(false); + } else if (action === "plan-server-reconciliation") { + setLoading(true, "Building a read-only reconciliation preview…"); + try { + const result = await window.forgeflow.planServerReconciliation(target.dataset.serverId); + ui.modal = { type: "server-reconciliation-plan", result }; + render(); + } catch (error) { + showToast("Could not build reconciliation plan", error.message, "error"); + } + setLoading(false); + } else if (action === "apply-server-reconciliation") { + setLoading(true, "Applying the reviewed configuration plan…"); + try { + const result = await window.forgeflow.applyServerReconciliation(target.dataset.serverId, target.dataset.planId); + if (result.state) ui.boot.state = result.state; + ui.modal = null; + await refreshRepositories(false, true); + await refreshDeploymentTruth(false); + showToast("Reconciliation applied", `${result.adopted || 0} link(s) added and ${result.refreshed || 0} profile(s) refreshed. No containers were changed.`, "success"); + } catch (error) { + showToast("Reconciliation was not applied", error.message, "error"); + } + setLoading(false); } else if (action === "quick-link-server-workload") { const serverResult = (ui.serverDiscovery || []).find( (item) => item.serverId === target.dataset.serverId, diff --git a/src/renderer/mock-bridge.js b/src/renderer/mock-bridge.js index bfab083..1ee0f0c 100644 --- a/src/renderer/mock-bridge.js +++ b/src/renderer/mock-bridge.js @@ -1511,6 +1511,27 @@ }, ]; }, + async planServerReconciliation(serverId) { + await wait(90); + const id = "a".repeat(64); + return { + inventory: (await this.discoverServerDeployments()).find((item) => item.serverId === serverId), + plan: { + id, + serverId, + summary: { additions: 0, updates: 1, stale: 0, conflicts: 1 }, + additions: [], + updates: [{ workloadId: "workload-demo-linked", profileId: "profile-portfolio", repositoryFullName: "jens/portfolio", impact: "Refresh detected Compose identity and observed deployment state" }], + stale: [], + conflicts: [{ workloadId: "workload-demo-review", displayName: "OmniRoute", status: "suggested", candidates: [{ repositoryFullName: repositories[0].fullName, score: 55, exact: false }] }], + }, + }; + }, + async applyServerReconciliation(serverId, planId) { + await wait(120); + 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 linkServerWorkload(repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "") { await wait(120); const repo = repositories.find((item) => item.fullName === repository.fullName); diff --git a/tests/configuration-backup.test.mjs b/tests/configuration-backup.test.mjs index 0784ccd..912a89a 100644 --- a/tests/configuration-backup.test.mjs +++ b/tests/configuration-backup.test.mjs @@ -1,8 +1,13 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import backupModule from '../src/main/configuration-backup.cjs'; +import configModule from '../src/main/config-store.cjs'; const { sanitizeConfiguration, createEncryptedBackup, readEncryptedBackup } = backupModule; +const { ConfigStore } = configModule; test('configuration backups exclude credentials and operation history', () => { const clean = sanitizeConfiguration({ @@ -25,3 +30,16 @@ test('configuration backups round-trip with authenticated encryption', () => { assert.equal(restored.configuration.gitea.encryptedToken, null); assert.throws(() => readEncryptedBackup(serialized, 'incorrect passphrase'), /could not be decrypted/i); }); + +test('recovery snapshots preserve the exact in-memory configuration before a mutation', async (context) => { + const directory = await mkdtemp(path.join(tmpdir(), 'forgeflow-config-snapshot-')); + context.after(() => rm(directory, { recursive: true, force: true })); + const store = new ConfigStore(directory); + store.data.workspaceRoots = ['C:/Projects']; + store.data.deploymentProfiles = { 'jens/example': [{ id: 'production', provider: 'gitea-actions' }] }; + await store.save(); + const before = `${JSON.stringify(store.data, null, 2)}\n`; + const snapshot = await store.createRecoverySnapshot('server reconciliation / production'); + assert.equal(await readFile(snapshot.filePath, 'utf8'), before); + assert.equal(snapshot.reason, 'server-reconciliation-production'); +}); diff --git a/tests/unraid-deployment.test.mjs b/tests/unraid-deployment.test.mjs index 69dcee0..88a7b39 100644 --- a/tests/unraid-deployment.test.mjs +++ b/tests/unraid-deployment.test.mjs @@ -114,7 +114,7 @@ test("server workload inventory links running containers to exact Gitea checkout ); }); -test("automatic server discovery adopts and verifies a running Gitea deployment", async () => { +test("server discovery is read-only and explicit reconciliation adopts a verified deployment", async () => { const b64 = (value) => Buffer.from(value).toString("base64"); const sha = "b".repeat(40); const container = { @@ -155,7 +155,7 @@ test("automatic server discovery adopts and verifies a running Gitea deployment" }, gitea: { getBranch: async () => ({ commit: { id: sha } }) }, }); - const result = await service.discoverServerWorkloads("unraid", [ + const repositories = [ { fullName: "Jens/Portfolio", name: "Portfolio", @@ -163,7 +163,16 @@ test("automatic server discovery adopts and verifies a running Gitea deployment" cloneUrl: "https://gitea.itworx.tech/Jens/Portfolio.git", sshUrl: "git@gitea.itworx.tech:Jens/Portfolio.git", }, - ]); + ]; + const discovery = await service.discoverServerWorkloads("unraid", repositories); + assert.equal(discovery.adopted, 0); + assert.equal(discovery.verified, 0); + assert.equal(profiles.length, 0); + assert.equal(states.size, 0); + + const preview = await service.planServerInventoryReconciliation("unraid", repositories, { autoLink: true }); + assert.equal(preview.plan.summary.additions, 1); + const result = await service.reconcileServerInventory("unraid", repositories, { autoLink: true, expectedPlanId: preview.plan.id }); assert.equal(result.adopted, 1); assert.equal(result.verified, 1); assert.equal(profiles[0].containerName, "Portfolio");