From 40d41f9a32ec4f96375a658913ffc65a1d46cbda Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Sun, 26 Jul 2026 04:22:02 +0200 Subject: [PATCH] Release ForgeFlow 0.8.7 automatic server discovery --- CHANGELOG.md | 9 + SOURCE_MANIFEST.txt | 25 +- docs/RELEASE_NOTES_0.8.7.md | 22 ++ main.cjs | 15 +- package-lock.json | 4 +- package.json | 3 +- preload.cjs | 212 +++++++++-------- scripts/verify.mjs | 5 +- src/main/ipc.cjs | 37 ++- src/main/unraid-deployment-service.cjs | 301 ++++++++++++++++++++++++- src/renderer/app.js | 27 ++- src/renderer/mock-bridge.js | 14 +- tests/unraid-deployment.test.mjs | 94 ++++++++ 13 files changed, 638 insertions(+), 130 deletions(-) create mode 100644 docs/RELEASE_NOTES_0.8.7.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 06f1468..e0304c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.8.7 - 2026-07-26 + +- automatically inventories running workloads across configured Unraid servers; +- links server Git checkouts to Gitea repositories through exact normalized origins and strong container evidence; +- supports image-only discovery through OCI and ForgeFlow repository/commit labels; +- adopts uniquely matched workloads into Deployments without requiring a prior ForgeFlow release operation; +- verifies every SSH deployment refresh against the current full Gitea branch SHA; +- treats matching commits as in order only while the container is running and healthy, and rejects ambiguous matches. + ## 0.8.6 - 2026-07-26 - added contextual animated code maps to unused diff-canvas space; diff --git a/SOURCE_MANIFEST.txt b/SOURCE_MANIFEST.txt index c5c199a..649e17b 100644 --- a/SOURCE_MANIFEST.txt +++ b/SOURCE_MANIFEST.txt @@ -1,4 +1,4 @@ -ForgeFlow 0.8.6 source manifest +ForgeFlow 0.8.7 source manifest SHA-256 BYTES PATH (The manifest excludes itself, dependencies and generated release artifacts.) 755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 58 .gitignore @@ -12,7 +12,7 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263 4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png 25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png -d73523f71016ac34bcf227d0222c9008fbd9c6489e2711dd3ad1da37de796369 9665 CHANGELOG.md +feac8256bec2762533c6dd76a49fceaeafb4935528e406158598e8ba55500a6e 10277 CHANGELOG.md 21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md 30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md @@ -44,6 +44,7 @@ c2802fa5dbff392c846b82b55e84a8bfb8e1625546fd1eba39f7129318bece96 654 8c13279987672314332f648889f52338bcdcb243249f9e1c20fb09d85d7808f5 505 docs/RELEASE_NOTES_0.8.4.md b516db97a0353babc810c24a87a971d30d72a8021d809e6b833ff7ae0458f442 538 docs/RELEASE_NOTES_0.8.5.md 838d196f3fbbfeee8df375a0502107f56b6df28babca30aa09ef1c7aa5196d09 738 docs/RELEASE_NOTES_0.8.6.md +ef049adcfa204908e6dc3a059124b39ba0e2739cc54e38945ce73a57049df0d8 1185 docs/RELEASE_NOTES_0.8.7.md 2b631b9d6d973bdd70869d84886ff339da351e29e17598970b3b27915674661d 4175 docs/ROADMAP.md 1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png @@ -65,11 +66,11 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf 2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json 4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE -6765015bdf27b288a250192272750b243c3cb8d1326b752d056d1e43317b6344 12935 main.cjs +0d9f0ac837b4d3b39476d9bcbb57fb3e12d6b7084bb1f7435401e416cff1854f 12920 main.cjs 91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md -59ab2691188bfde953a30c0ad2709f529c86ea9de7351aa98f53238ba50d7771 130466 package-lock.json -8c137e3654c508000fa313429457e314c42a4b73abf6d14848e07445289515cf 3832 package.json -3d2ac366a13e9418e3ec6d13ce95b611f30f0228eb3a80ef9e7a936ce9578e24 9080 preload.cjs +5610ef8f25cc47b5296c57edb29d5e1b605de4113b4b0128fa451b9ec16bba2c 130466 package-lock.json +faae3af578a40306472494f5816855bf9422425b6daf39732fe04cbad55675ae 3869 package.json +45eea5fe4fd58cc0e96497295223e81a4f3acc3778bf546d4c7c96af03da4d26 9496 preload.cjs b31c43d9355c13b5ae4efc0f3649d8cb8d509b2bb7ebb042ff546b7820fb7de8 8411 Publish-ForgeFlow-Release.ps1 a6d32a742412b7836606be00f17be0465f1b6f55d3911f6c73a14029787ba206 14037 README.md 509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs @@ -81,7 +82,7 @@ f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 74433d8a6b24afe368197a469e2fe0c5050c239d7250b84c2f3f598c304778b0 4736 scripts/publish-binary-release.cjs 444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs 42203f9e0fd4aae517284d387f265cf1b0b180379bc253a092b5c3c5c4caef0a 2992 scripts/validate-installed-connections.cjs -cd7650a307ce862c9dfc6c756efda5b10c75d3d1f63ce54191baaf7f008123bb 12308 scripts/verify.mjs +e77edf02fc9b9afb398ae46767e634f47259e58288ce9a18bc696b028234b581 12341 scripts/verify.mjs 0079701b5acbfef07b71a9623613d1940805ccd20649d77e3f34c37e79df7655 735 scripts/write-release-checksums.mjs 619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1 dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs @@ -92,22 +93,22 @@ c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs 0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs 75f25fa8ee520b03b0bbe4c4cea439853202d318547343cda49a19c6ab868901 16984 src/main/gitea-service.cjs -79593a28b8f40f48a73028be34464f94be2e4c67a027a3f5da78a33bcadc76eb 45539 src/main/ipc.cjs +13f6521bb82aa5a3399d6839ea7563459b19969c1e5af7525e4116cf771282e9 46526 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 e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 src/main/repository-monitor.cjs 17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs b31a63bf8cb1807b3e838e2bf8a0e742738f119d13de8ca9f42e471f072217d3 8328 src/main/ssh-service.cjs -8cf5013de91737dd9345b121586ceec38a6fc8518648975d60593d81fc225c4b 67376 src/main/unraid-deployment-service.cjs +f4e221edace12e2120ec439e0f91274de110872c5bfbe968f2d7935eeb7193c4 78544 src/main/unraid-deployment-service.cjs 27b9e15dd6530bc7bfab51414430eaef169e0cc4acd027257b1c63f891af8c85 20733 src/main/update-service.cjs -ac13e1222c7adcb664ff18c1de42c1f7bd25e6d90d2f02a18e4a4a68f5683bad 196260 src/renderer/app.js +00b5c44b7d04452498288d42a2c345e0e3922c8ddfabfee9eb04654f238811d5 197015 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 e1c463d6cda9f2b9b78c468845c0a7e8688f0362be5642074a1a5f7122dfe811 762 src/renderer/index.html -96e62ef31fd20954942dbe9dd4d44ecefdf869e9fb3144fe23d88c38fac0d9bf 50922 src/renderer/mock-bridge.js +3faf63826ee693b1afcfcc632238dd71e7969641e2502ef6884b40f3855e9d6b 51166 src/renderer/mock-bridge.js e30ea37ee00f34110d0d252c79f9d8e0bb127c4db0bd1bf8b374609dea433361 72160 src/renderer/styles.css 0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs 5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs @@ -144,7 +145,7 @@ d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs 020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs 8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs -05c791ea262aef85a7c79874ed900c5d792a78778c577cb803b88a433cd6e2b6 21413 tests/unraid-deployment.test.mjs +8f44579de3af7d7d23d55572d1d97cb0690425d4d9dffc30c6bf2ec3ff9c54eb 24553 tests/unraid-deployment.test.mjs f1f0f13ac41f47c9df8ef778d7b98c09a589f61e6f76e14bc0e8943c5ede7560 14990 tests/update-service.test.mjs 9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs 7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs diff --git a/docs/RELEASE_NOTES_0.8.7.md b/docs/RELEASE_NOTES_0.8.7.md new file mode 100644 index 0000000..8fcc14c --- /dev/null +++ b/docs/RELEASE_NOTES_0.8.7.md @@ -0,0 +1,22 @@ +# ForgeFlow 0.8.7 + +ForgeFlow 0.8.7 automatically discovers applications already running on every +configured and trusted Unraid server. It inventories server-side Git checkouts, +Docker Compose metadata, bind mounts, container identity and image provenance, +then links each workload to a Gitea repository only when the evidence produces +one unambiguous match. + +Uniquely matched workloads are added to Deployments automatically, even when +they were originally deployed outside ForgeFlow. Every refresh resolves the +configured branch directly on Gitea and compares its full commit SHA with the +live server version. A deployment is reported as in order when the SHAs match, +the container is running and Docker health is not failing. + +Containers without a server-side Git checkout can also be discovered when the +image exposes standard OCI source/revision labels or ForgeFlow provenance +labels. New ForgeFlow deployments now write repository, branch and exact commit +labels so future discovery remains deterministic. + +Ambiguous or weak matches are intentionally left unlinked for manual review. +Server inventory is read-only; automatic adoption changes only ForgeFlow's local +configuration. diff --git a/main.cjs b/main.cjs index b6f7a73..8c63720 100644 --- a/main.cjs +++ b/main.cjs @@ -141,14 +141,12 @@ function createWindow() { ); mainWindow.webContents.setWindowOpenHandler(({ url }) => { if (/^https?:\/\//i.test(url)) - shell - .openExternal(url) - .catch((error) => - diagnostics?.warning("external-link.open.failed", { - url, - message: error.message, - }), - ); + shell.openExternal(url).catch((error) => + diagnostics?.warning("external-link.open.failed", { + url, + message: error.message, + }), + ); return { action: "deny" }; }); mainWindow.webContents.on("will-navigate", (event, url) => { @@ -260,6 +258,7 @@ app store, ssh, git, + gitea, diagnostics, sourcePath: app.getAppPath(), onOperationChange: reportOperationChange, diff --git a/package-lock.json b/package-lock.json index a514af6..7ed5f2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "forgeflow", - "version": "0.8.6", + "version": "0.8.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "forgeflow", - "version": "0.8.6", + "version": "0.8.7", "dependencies": { "ssh2": "1.17.0" }, diff --git a/package.json b/package.json index 55784a7..324c96c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "forgeflow", - "version": "0.8.6", + "version": "0.8.7", "private": true, "description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.", "main": "main.cjs", @@ -79,6 +79,7 @@ "docs/RELEASE_NOTES_0.8.4.md", "docs/RELEASE_NOTES_0.8.5.md", "docs/RELEASE_NOTES_0.8.6.md", + "docs/RELEASE_NOTES_0.8.7.md", "docs/ACCEPTANCE.md" ], "asarUnpack": [ diff --git a/preload.cjs b/preload.cjs index df6ea4a..a635d6f 100644 --- a/preload.cjs +++ b/preload.cjs @@ -22,98 +22,120 @@ function subscribe(channel, listener) { return () => ipcRenderer.removeListener(channel, handler); } -contextBridge.exposeInMainWorld('forgeflow', Object.freeze({ - bootstrap: () => invoke('app:bootstrap'), - selectDirectory: (payload) => invoke('dialog:select-directory', payload), - selectKeyFile: (payload) => invoke('dialog:select-key-file', payload), - selectImageFile: (payload) => invoke('dialog:select-image-file', payload), - setupPreflight: (payload) => invoke('setup:preflight', payload), - validateGitea: (payload) => invoke('setup:validate-gitea', payload), - completeSetup: (payload) => invoke('setup:complete', payload), - updateGitea: (payload) => invoke('settings:update-gitea', payload), - setWorkspaceRoots: (roots) => invoke('settings:set-roots', { roots }), - setAppearance: (appearance) => invoke('settings:set-appearance', { appearance }), - setPreferences: (preferences) => invoke('settings:set-preferences', { preferences }), - exportConfigurationBackup: (passphrase) => invoke('settings:export-backup', { passphrase }), - importConfigurationBackup: (passphrase) => invoke('settings:import-backup', { passphrase }), - listAuditEvents: (limit = 250) => invoke('audit:list', { limit }), - exportAuditLog: (format = 'json') => invoke('audit:export', { format }), - setUpdatePreferences: (updates) => invoke('updates:preferences', { updates }), - checkForUpdates: () => invoke('updates:check'), - downloadUpdate: () => invoke('updates:download'), - applyUpdate: () => invoke('updates:apply'), - saveServer: (server, password = '', passphrase = '') => invoke('server:save', { server, password, passphrase }), - deleteServer: (serverId) => invoke('server:delete', { serverId }), - testServer: (serverId) => invoke('server:test', { serverId }), - inspectServerProject: (repository, profileId) => invoke('server:inspect-project', { repository, profileId }), - discoverExistingDeployment: (repository, serverId, remoteFolder) => invoke('server:discover-existing', { repository, serverId, remoteFolder }), - refreshRepositories: () => invoke('repositories:refresh'), - discoverRepositories: (roots) => invoke('repositories:discover', { roots }), - favoriteRepository: (fullName, favorite) => invoke('repository:favorite', { fullName, favorite }), - linkRepository: (fullName, localPath) => invoke('repository:link', { fullName, localPath }), - unlinkRepository: (fullName) => invoke('repository:unlink', { fullName }), - repositoryStatus: (localPath) => invoke('repository:status', { localPath }), - repositoryDiff: (localPath, filePath, staged = false) => invoke('repository:diff', { localPath, filePath, staged }), - repositoryDiffHunks: (localPath, filePath) => invoke('repository:diff-hunks', { localPath, filePath }), - stageHunks: (localPath, filePath, hunkIndexes) => invoke('repository:stage-hunks', { localPath, filePath, hunkIndexes }), - conflictState: (localPath) => invoke('repository:conflicts', { localPath }), - resolveConflict: (localPath, filePath, resolution) => invoke('repository:resolve-conflict', { localPath, filePath, resolution }), - continueGitOperation: (localPath) => invoke('repository:continue-operation', { localPath }), - abortGitOperation: (localPath) => invoke('repository:abort-operation', { localPath }), - stageFiles: (localPath, files) => invoke('repository:stage', { localPath, files }), - unstageFiles: (localPath, files) => invoke('repository:unstage', { localPath, files }), - commit: (localPath, message, files) => invoke('repository:commit', { localPath, message, files }), - commitStaged: (localPath, message) => invoke('repository:commit-staged', { localPath, message }), - commitStagedAndPush: (localPath, message) => invoke('repository:commit-staged-push', { localPath, message }), - commitAndPush: (localPath, message, files) => invoke('repository:commit-push', { localPath, message, files }), - push: (localPath) => invoke('repository:push', { localPath }), - fetch: (localPath) => invoke('repository:fetch', { localPath }), - pull: (localPath) => invoke('repository:pull', { localPath }), - history: (localPath, limit = 20) => invoke('repository:history', { localPath, limit }), - branchProtection: (fullName, branch) => invoke('repository:branch-protection', { fullName, branch }), - pullRequests: (fullName, state = 'open') => invoke('repository:pull-requests', { fullName, state }), - createPullRequest: (fullName, title, body, base) => invoke('repository:create-pull-request', { fullName, title, body, base }), - branches: (localPath) => invoke('repository:branches', { localPath }), - checkoutBranch: (localPath, branch) => invoke('repository:checkout-branch', { localPath, branch }), - createBranch: (localPath, branch) => invoke('repository:create-branch', { localPath, branch }), - stash: (localPath, message) => invoke('repository:stash', { localPath, message }), - stashList: (localPath) => invoke('repository:stash-list', { localPath }), - popStash: (localPath, ref) => invoke('repository:stash-pop', { localPath, ref }), - indexLockInfo: (localPath) => invoke('repository:index-lock', { localPath }), - repairIndexLock: (localPath) => invoke('repository:repair-index-lock', { localPath }), - gitRecoveryStatus: (localPath) => invoke('repository:git-recovery-status', { localPath }), - repairGitLocks: (localPath, force = false) => invoke('repository:repair-git-locks', { localPath, force }), - reconcileRepository: (localPath) => invoke('repository:reconcile', { localPath }), - repairRepositorySync: (localPath, strategy) => invoke('repository:repair-sync', { localPath, strategy }), - setOrigin: (localPath, remoteUrl) => invoke('repository:set-origin', { localPath, remoteUrl }), - normalizeOrigins: () => invoke('repositories:normalize-origins'), - cloneRepository: (fullName, mode = 'default') => invoke('repository:clone', { fullName, mode }), - openPath: (localPath) => invoke('repository:open-path', { localPath }), - openEditor: (localPath, filePath = '', line = 1) => invoke('repository:open-editor', { localPath, filePath, line }), - openTerminal: (localPath) => invoke('repository:open-terminal', { localPath }), - openExternal: (url) => invoke('external:open', { url }), - saveDeploymentProfile: (fullName, profile) => invoke('deployment:save-profile', { fullName, profile }), - deploymentPreflight: (repository, profileId) => invoke('deployment:preflight', { repository, profileId }), - deleteDeploymentProfile: (fullName, profileId) => invoke('deployment:delete-profile', { fullName, profileId }), - deploy: (repository, profileId, sha, options = {}) => invoke('deployment:dispatch', { repository, profileId, sha, note: options.note || '', override: options.override === true, overrideReason: options.overrideReason || '' }), - rollback: (repository, profileId, targetSha) => invoke('deployment:rollback', { repository, profileId, targetSha }), - healthcheck: (url) => invoke('deployment:health', { url }), - refreshProfileState: (fullName, profileId) => invoke('deployment:profile-state', { fullName, profileId }), - applyDockerManMetadata: (repository, profileId) => invoke('deployment:apply-dockerman-metadata', { repository, profileId }), - reconcileDeployment: (fullName, profileId) => invoke('deployment:reconcile', { fullName, profileId }), - refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }), - getOperation: (operationId) => invoke('operations:get', { operationId }), - troubleshooterScan: (fullName = null) => invoke('troubleshooter:scan', { fullName }), - troubleshooterRepair: (issue) => invoke('troubleshooter:repair', { issue }), - troubleshooterAutoRepair: (issues) => invoke('troubleshooter:auto-repair', { issues }), - diagnosticsStatus: () => invoke('diagnostics:status'), - clearDiagnostics: () => invoke('diagnostics:clear'), - openDiagnosticsFolder: () => invoke('diagnostics:open-folder'), - exportDiagnostics: (privacyMode = 'standard') => invoke('diagnostics:export', { privacyMode }), - showDiagnosticBundle: (filePath) => invoke('diagnostics:show-bundle', { filePath }), - reportRendererEvent: (level, event, details = {}) => invoke('renderer:report', { level, event, details }), - onRepositoriesChanged: (listener) => subscribe('repositories:changed', listener), - onOperationsChanged: (listener) => subscribe('operations:changed', listener), - onUpdatesChanged: (listener) => subscribe('updates:changed', listener), - reset: () => invoke('app:reset') -})); +contextBridge.exposeInMainWorld( + 'forgeflow', + Object.freeze({ + bootstrap: () => invoke('app:bootstrap'), + selectDirectory: (payload) => invoke('dialog:select-directory', payload), + selectKeyFile: (payload) => invoke('dialog:select-key-file', payload), + selectImageFile: (payload) => invoke('dialog:select-image-file', payload), + setupPreflight: (payload) => invoke('setup:preflight', payload), + validateGitea: (payload) => invoke('setup:validate-gitea', payload), + completeSetup: (payload) => invoke('setup:complete', payload), + updateGitea: (payload) => invoke('settings:update-gitea', payload), + setWorkspaceRoots: (roots) => invoke('settings:set-roots', { roots }), + setAppearance: (appearance) => invoke('settings:set-appearance', { appearance }), + setPreferences: (preferences) => invoke('settings:set-preferences', { preferences }), + exportConfigurationBackup: (passphrase) => invoke('settings:export-backup', { passphrase }), + importConfigurationBackup: (passphrase) => invoke('settings:import-backup', { passphrase }), + listAuditEvents: (limit = 250) => invoke('audit:list', { limit }), + exportAuditLog: (format = 'json') => invoke('audit:export', { format }), + setUpdatePreferences: (updates) => invoke('updates:preferences', { updates }), + checkForUpdates: () => invoke('updates:check'), + downloadUpdate: () => invoke('updates:download'), + applyUpdate: () => invoke('updates:apply'), + saveServer: (server, password = '', passphrase = '') => invoke('server:save', { server, password, passphrase }), + deleteServer: (serverId) => invoke('server:delete', { serverId }), + testServer: (serverId) => invoke('server:test', { serverId }), + inspectServerProject: (repository, profileId) => invoke('server:inspect-project', { repository, profileId }), + discoverExistingDeployment: (repository, serverId, remoteFolder) => + invoke('server:discover-existing', { + repository, + serverId, + remoteFolder, + }), + refreshRepositories: () => invoke('repositories:refresh'), + discoverRepositories: (roots) => invoke('repositories:discover', { roots }), + favoriteRepository: (fullName, favorite) => invoke('repository:favorite', { fullName, favorite }), + linkRepository: (fullName, localPath) => invoke('repository:link', { fullName, localPath }), + unlinkRepository: (fullName) => invoke('repository:unlink', { fullName }), + repositoryStatus: (localPath) => invoke('repository:status', { localPath }), + repositoryDiff: (localPath, filePath, staged = false) => invoke('repository:diff', { localPath, filePath, staged }), + repositoryDiffHunks: (localPath, filePath) => invoke('repository:diff-hunks', { localPath, filePath }), + stageHunks: (localPath, filePath, hunkIndexes) => invoke('repository:stage-hunks', { localPath, filePath, hunkIndexes }), + conflictState: (localPath) => invoke('repository:conflicts', { localPath }), + resolveConflict: (localPath, filePath, resolution) => + invoke('repository:resolve-conflict', { + localPath, + filePath, + resolution, + }), + continueGitOperation: (localPath) => invoke('repository:continue-operation', { localPath }), + abortGitOperation: (localPath) => invoke('repository:abort-operation', { localPath }), + stageFiles: (localPath, files) => invoke('repository:stage', { localPath, files }), + unstageFiles: (localPath, files) => invoke('repository:unstage', { localPath, files }), + commit: (localPath, message, files) => invoke('repository:commit', { localPath, message, files }), + commitStaged: (localPath, message) => invoke('repository:commit-staged', { localPath, message }), + commitStagedAndPush: (localPath, message) => invoke('repository:commit-staged-push', { localPath, message }), + commitAndPush: (localPath, message, files) => invoke('repository:commit-push', { localPath, message, files }), + push: (localPath) => invoke('repository:push', { localPath }), + fetch: (localPath) => invoke('repository:fetch', { localPath }), + pull: (localPath) => invoke('repository:pull', { localPath }), + history: (localPath, limit = 20) => invoke('repository:history', { localPath, limit }), + branchProtection: (fullName, branch) => invoke('repository:branch-protection', { fullName, branch }), + pullRequests: (fullName, state = 'open') => invoke('repository:pull-requests', { fullName, state }), + createPullRequest: (fullName, title, body, base) => invoke('repository:create-pull-request', { fullName, title, body, base }), + branches: (localPath) => invoke('repository:branches', { localPath }), + checkoutBranch: (localPath, branch) => invoke('repository:checkout-branch', { localPath, branch }), + createBranch: (localPath, branch) => invoke('repository:create-branch', { localPath, branch }), + stash: (localPath, message) => invoke('repository:stash', { localPath, message }), + stashList: (localPath) => invoke('repository:stash-list', { localPath }), + popStash: (localPath, ref) => invoke('repository:stash-pop', { localPath, ref }), + indexLockInfo: (localPath) => invoke('repository:index-lock', { localPath }), + repairIndexLock: (localPath) => invoke('repository:repair-index-lock', { localPath }), + gitRecoveryStatus: (localPath) => invoke('repository:git-recovery-status', { localPath }), + repairGitLocks: (localPath, force = false) => invoke('repository:repair-git-locks', { localPath, force }), + reconcileRepository: (localPath) => invoke('repository:reconcile', { localPath }), + repairRepositorySync: (localPath, strategy) => invoke('repository:repair-sync', { localPath, strategy }), + setOrigin: (localPath, remoteUrl) => invoke('repository:set-origin', { localPath, remoteUrl }), + normalizeOrigins: () => invoke('repositories:normalize-origins'), + cloneRepository: (fullName, mode = 'default') => invoke('repository:clone', { fullName, mode }), + openPath: (localPath) => invoke('repository:open-path', { localPath }), + openEditor: (localPath, filePath = '', line = 1) => invoke('repository:open-editor', { localPath, filePath, line }), + openTerminal: (localPath) => invoke('repository:open-terminal', { localPath }), + openExternal: (url) => invoke('external:open', { url }), + saveDeploymentProfile: (fullName, profile) => invoke('deployment:save-profile', { fullName, profile }), + deploymentPreflight: (repository, profileId) => invoke('deployment:preflight', { repository, profileId }), + deleteDeploymentProfile: (fullName, profileId) => invoke('deployment:delete-profile', { fullName, profileId }), + deploy: (repository, profileId, sha, options = {}) => + invoke('deployment:dispatch', { + repository, + profileId, + sha, + note: options.note || '', + override: options.override === true, + overrideReason: options.overrideReason || '', + }), + rollback: (repository, profileId, targetSha) => invoke('deployment:rollback', { repository, profileId, targetSha }), + healthcheck: (url) => invoke('deployment:health', { url }), + refreshProfileState: (fullName, profileId) => invoke('deployment:profile-state', { fullName, profileId }), + discoverServerDeployments: () => invoke('deployment:discover-server-workloads'), + applyDockerManMetadata: (repository, profileId) => invoke('deployment:apply-dockerman-metadata', { repository, profileId }), + reconcileDeployment: (fullName, profileId) => invoke('deployment:reconcile', { fullName, profileId }), + refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }), + getOperation: (operationId) => invoke('operations:get', { operationId }), + troubleshooterScan: (fullName = null) => invoke('troubleshooter:scan', { fullName }), + troubleshooterRepair: (issue) => invoke('troubleshooter:repair', { issue }), + troubleshooterAutoRepair: (issues) => invoke('troubleshooter:auto-repair', { issues }), + diagnosticsStatus: () => invoke('diagnostics:status'), + clearDiagnostics: () => invoke('diagnostics:clear'), + openDiagnosticsFolder: () => invoke('diagnostics:open-folder'), + exportDiagnostics: (privacyMode = 'standard') => invoke('diagnostics:export', { privacyMode }), + showDiagnosticBundle: (filePath) => invoke('diagnostics:show-bundle', { filePath }), + reportRendererEvent: (level, event, details = {}) => invoke('renderer:report', { level, event, details }), + onRepositoriesChanged: (listener) => subscribe('repositories:changed', listener), + onOperationsChanged: (listener) => subscribe('operations:changed', listener), + onUpdatesChanged: (listener) => subscribe('updates:changed', listener), + reset: () => invoke('app:reset'), + }), +); diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 2c11c94..ec369e1 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -67,6 +67,7 @@ const required = [ "docs/RELEASE_NOTES_0.8.4.md", "docs/RELEASE_NOTES_0.8.5.md", "docs/RELEASE_NOTES_0.8.6.md", + "docs/RELEASE_NOTES_0.8.7.md", "docs/UPDATING.md", "docs/DIAGNOSTICS.md", "docs/DEPLOYMENT_SETUP.md", @@ -105,9 +106,9 @@ for (const file of required) await access(path.join(root, file)); const packageJson = JSON.parse( await readFile(path.join(root, "package.json"), "utf8"), ); -if (packageJson.version !== "0.8.6") +if (packageJson.version !== "0.8.7") throw new Error( - `Expected package version 0.8.6, got ${packageJson.version}.`, + `Expected package version 0.8.7, got ${packageJson.version}.`, ); const sourceManifest = await readFile( path.join(root, "SOURCE_MANIFEST.txt"), diff --git a/src/main/ipc.cjs b/src/main/ipc.cjs index 6b67999..d5e2843 100644 --- a/src/main/ipc.cjs +++ b/src/main/ipc.cjs @@ -1140,10 +1140,41 @@ function registerIpc({ }, ); register("deployment:health", ({ url }) => deployments.checkHealth(url)); - register("deployment:profile-state", ({ fullName, profileId }) => { + register("deployment:discover-server-workloads", async () => { + const repositoryList = await repositories.refresh(); + const remoteRepositories = repositoryList.filter( + (repository) => repository.owner?.login !== "local", + ); + const results = []; + for (const server of store.data.servers || []) { + try { + results.push( + await unraid.discoverServerWorkloads(server.id, remoteRepositories), + ); + } catch (error) { + results.push({ + serverId: server.id, + detected: 0, + adopted: 0, + verified: 0, + unmatched: 0, + error: error.message, + }); + } + } + return results; + }); + register("deployment:profile-state", async ({ fullName, profileId }) => { const profile = store.getDeploymentProfile(fullName, profileId); - if (profile?.provider === "ssh-unraid") - return unraid.refreshProfileState(fullName, profileId); + if (profile?.provider === "ssh-unraid") { + let giteaSha = null; + try { + const [owner, repo] = String(fullName || "").split("/"); + const branch = await gitea.getBranch(owner, repo, profile.branch); + giteaSha = branch?.commit?.id || branch?.commit?.sha || null; + } catch {} + return unraid.refreshProfileState(fullName, profileId, giteaSha); + } return deployments.refreshProfileState(fullName, profileId); }); register( diff --git a/src/main/unraid-deployment-service.cjs b/src/main/unraid-deployment-service.cjs index cb9e401..6cf0be1 100644 --- a/src/main/unraid-deployment-service.cjs +++ b/src/main/unraid-deployment-service.cjs @@ -349,11 +349,92 @@ function iconReferenceLocalPath(iconReference) { return ""; } +function decodeInventoryValue(value) { + try { + return Buffer.from(String(value || ""), "base64").toString("utf8"); + } catch { + return ""; + } +} + +function remoteIdentity(value) { + const normalized = normalizeRemoteUrl(value); + return normalized ? `${normalized.host}/${normalized.path}` : ""; +} + +function parseServerInventory(output) { + const marker = "__FORGEFLOW_INVENTORY__"; + const index = String(output || "").lastIndexOf(marker); + if (index < 0) + throw new Error( + "The server did not return a ForgeFlow workload inventory.", + ); + const checkouts = []; + const containers = []; + for (const line of String(output) + .slice(index + marker.length) + .trim() + .split(/\r?\n/)) { + const [kind, ...parts] = line.split("\t"); + if (kind === "R" && parts.length >= 4) { + checkouts.push({ + root: decodeInventoryValue(parts[0]), + remote: decodeInventoryValue(parts[1]), + liveSha: parts[2], + branch: decodeInventoryValue(parts[3]), + }); + } else if (kind === "C" && parts[0]) { + try { + const parsed = JSON.parse(decodeInventoryValue(parts[0])); + if (Array.isArray(parsed) && parsed[0]) containers.push(parsed[0]); + } catch {} + } + } + return { checkouts, containers }; +} + +function inventoryContainerMatch(checkout, repository, container) { + if (!container?.State?.Running) return 0; + const labels = container.Config?.Labels || {}; + const workingDir = String( + labels["com.docker.compose.project.working_dir"] || "", + ).replace(/\/$/, ""); + const source = remoteIdentity( + labels["org.opencontainers.image.source"] || "", + ); + const mounts = Array.isArray(container.Mounts) ? container.Mounts : []; + const root = String(checkout.root || "").replace(/\/$/, ""); + const name = String(container.Name || "").replace(/^\//, ""); + const project = String(labels["com.docker.compose.project"] || ""); + const expectedNames = new Set( + [repository.name, root.split("/").pop()].filter(Boolean).map((value) => + String(value) + .toLowerCase() + .replace(/[^a-z0-9]/g, ""), + ), + ); + const normalizedName = name.toLowerCase().replace(/[^a-z0-9]/g, ""); + const normalizedProject = project.toLowerCase().replace(/[^a-z0-9]/g, ""); + if (workingDir && workingDir === root) return 100; + if ( + mounts.some((mount) => { + const mountSource = String(mount.Source || "").replace(/\/$/, ""); + return mountSource === root || mountSource.startsWith(`${root}/`); + }) + ) + return 90; + if (source && source === remoteIdentity(checkout.remote)) return 85; + if (expectedNames.has(normalizedProject)) return 70; + if (expectedNames.has(normalizedName)) return 60; + return 0; +} + class UnraidDeploymentService { constructor({ store, ssh, git, + gitea, diagnostics, sourcePath = process.cwd(), onOperationChange = null, @@ -361,11 +442,204 @@ class UnraidDeploymentService { this.store = store; this.ssh = ssh; this.git = git; + this.gitea = gitea; this.diagnostics = diagnostics; this.sourcePath = sourcePath; this.onOperationChange = onOperationChange; } + async discoverServerWorkloads(serverId, repositories) { + const server = this.store.getServer(serverId); + if (!server) throw new Error("The deployment server no longer exists."); + const script = ` +base=${shellQuote(server.basePath)} +printf '__FORGEFLOW_INVENTORY__\\n' +if [ -d "$base" ]; then + find "$base" -mindepth 2 -maxdepth 2 -type d -name .git -print0 2>/dev/null | while IFS= read -r -d '' gitdir; do + root=\${gitdir%/.git} + remote=$(git -C "$root" remote get-url origin 2>/dev/null || true) + live=$(git -C "$root" rev-parse HEAD 2>/dev/null || true) + branch=$(git -C "$root" symbolic-ref --short HEAD 2>/dev/null || true) + [ -n "$remote" ] || continue + printf 'R\\t%s\\t%s\\t%s\\t%s\\n' "$(printf '%s' "$root" | base64 | tr -d '\\r\\n')" "$(printf '%s' "$remote" | base64 | tr -d '\\r\\n')" "$live" "$(printf '%s' "$branch" | base64 | tr -d '\\r\\n')" + done +fi +for id in $(docker ps -aq 2>/dev/null || true); do + printf 'C\\t%s\\n' "$(docker inspect "$id" 2>/dev/null | base64 | tr -d '\\r\\n')" +done +`; + const result = await this.ssh.exec(server.id, bash(script), { + timeout: 45_000, + maxOutput: 8 * 1024 * 1024, + }); + const inventory = parseServerInventory(result.stdout); + const workloads = [...inventory.checkouts]; + const knownRemotes = new Set( + workloads.map((checkout) => remoteIdentity(checkout.remote)), + ); + for (const container of inventory.containers) { + const labels = container.Config?.Labels || {}; + const remote = + labels["org.opencontainers.image.source"] || + labels["tech.itworx.forgeflow.repository"] || + ""; + const revision = + labels["org.opencontainers.image.revision"] || + labels["tech.itworx.forgeflow.commit"] || + ""; + if ( + !remoteIdentity(remote) || + knownRemotes.has(remoteIdentity(remote)) || + !/^[0-9a-f]{40}$/i.test(revision) + ) + continue; + const workingDir = String( + labels["com.docker.compose.project.working_dir"] || "", + ); + const mountedRoot = (container.Mounts || []) + .map((mount) => String(mount.Source || "")) + .find((source) => source.startsWith(`${server.basePath}/`)); + workloads.push({ + root: workingDir || mountedRoot || "", + remote, + liveSha: revision, + branch: labels["tech.itworx.forgeflow.branch"] || "", + imageMetadata: true, + }); + knownRemotes.add(remoteIdentity(remote)); + } + const remoteMap = new Map(); + for (const repository of repositories || []) { + for (const remote of [ + repository.cloneUrl, + repository.sshUrl, + repository.htmlUrl, + ]) { + const normalized = remoteIdentity(remote); + if (normalized) remoteMap.set(normalized, repository); + } + } + const summary = { + serverId, + detected: 0, + adopted: 0, + verified: 0, + unmatched: 0, + }; + for (const checkout of workloads) { + const repository = remoteMap.get(remoteIdentity(checkout.remote)); + if (!repository) { + summary.unmatched += 1; + continue; + } + if (!checkout.root) + checkout.root = path.join(server.basePath, repository.name); + const candidates = inventory.containers + .map((container) => ({ + container, + score: inventoryContainerMatch(checkout, repository, container), + })) + .filter((candidate) => candidate.score >= 60) + .sort((left, right) => right.score - left.score); + if (!candidates.length || candidates[1]?.score === candidates[0].score) { + summary.unmatched += 1; + continue; + } + summary.detected += 1; + const container = candidates[0].container; + const containerName = String(container.Name || "").replace(/^\//, ""); + const remoteFolder = checkout.root + .slice(server.basePath.length) + .replace(/^\/+/, ""); + if (!/^[A-Za-z0-9._-]+$/.test(remoteFolder)) { + summary.unmatched += 1; + continue; + } + const existing = this.store + .getDeploymentProfiles(repository.fullName) + .find( + (profile) => + profile.provider === "ssh-unraid" && + profile.serverId === server.id && + (profile.containerName === containerName || + profile.remoteFolder === remoteFolder), + ); + const labels = container.Config?.Labels || {}; + const portEntry = Object.entries( + container.NetworkSettings?.Ports || {}, + ).find( + ([, bindings]) => Array.isArray(bindings) && bindings[0]?.HostPort, + ); + const containerPort = portEntry + ? Number(String(portEntry[0]).split("/")[0]) || null + : null; + const hostPort = portEntry + ? Number(portEntry[1][0].HostPort) || null + : null; + const profile = + existing || + (await this.store.saveDeploymentProfile(repository.fullName, { + id: `auto-${crypto.createHash("sha256").update(`${server.id}:${repository.fullName}:${containerName}`).digest("hex").slice(0, 20)}`, + name: `${server.name} · ${containerName}`, + environment: "production", + provider: "ssh-unraid", + branch: checkout.branch || repository.defaultBranch || "main", + serverId: server.id, + remoteFolder, + composeFile: + String(labels["com.docker.compose.project.config_files"] || "") + .split(",")[0] + .split("/") + .pop() || "docker-compose.yml", + composeService: + String(labels["com.docker.compose.service"] || remoteFolder) + .toLowerCase() + .replace(/[^a-z0-9._-]/g, "-") || remoteFolder.toLowerCase(), + containerName, + cloneUrl: checkout.remote, + hostPort, + containerPort, + webUiUrl: labels["net.unraid.docker.webui"] || "", + adoptedFromServer: true, + serverSourceOfTruth: true, + detectedAt: new Date().toISOString(), + detectedMetadata: { + confidence: candidates[0].score, + source: "automatic-server-inventory", + }, + })); + if (!existing) summary.adopted += 1; + let giteaSha = null; + try { + const [owner, repo] = repository.fullName.split("/"); + const branch = await this.gitea.getBranch(owner, repo, profile.branch); + giteaSha = branch?.commit?.id || branch?.commit?.sha || null; + } catch {} + const dockerHealth = String(container.State?.Health?.Status || ""); + const healthy = dockerHealth + ? dockerHealth === "healthy" + : container.State?.Running === true; + await this.store.saveDeploymentState(profile.id, { + liveSha: /^[0-9a-f]{40}$/i.test(checkout.liveSha) + ? checkout.liveSha + : null, + giteaSha: /^[0-9a-f]{40}$/i.test(giteaSha || "") ? giteaSha : null, + matchesGitea: Boolean(giteaSha && checkout.liveSha === giteaSha), + healthy, + containerRunning: container.State?.Running === true, + dockerHealth: dockerHealth || null, + containerName, + remotePath: checkout.root, + provider: "ssh-unraid", + discovery: { automatic: true, confidence: candidates[0].score }, + }); + if (healthy && giteaSha && checkout.liveSha === giteaSha) + summary.verified += 1; + } + await this.diagnostics?.info("unraid.workloads.discovered", summary); + return summary; + } + async saveOperation(operation) { const saved = await this.store.addOperation(operation); this.onOperationChange?.({ operations: [saved] }); @@ -1148,7 +1422,7 @@ chmod 0644 ${shellQuote(templatePath)} ${this.iconCacheRefresh(profile, repository, iconReference)}`; } - metadataCompose(profile, repository, iconReference = "") { + metadataCompose(profile, repository, iconReference = "", deployment = {}) { const service = String(profile.composeService || repository.name || "app") .trim() @@ -1165,7 +1439,16 @@ ${this.iconCacheRefresh(profile, repository, iconReference)}`; const labels = { "net.unraid.docker.managed": "dockerman", "net.unraid.docker.shell": this.dockerManShell(profile), + "tech.itworx.forgeflow.repository": + deployment.repositoryUrl || + profile.cloneUrl || + repository.sshUrl || + repository.cloneUrl || + repository.htmlUrl || + repository.fullName, + "tech.itworx.forgeflow.branch": profile.branch || "main", }; + if (deployment.sha) labels["tech.itworx.forgeflow.commit"] = deployment.sha; const webUiLabel = this.dockerManWebUi(profile); if (webUiLabel) labels["net.unraid.docker.webui"] = webUiLabel; if (iconReference) labels["net.unraid.docker.icon"] = iconReference; @@ -1327,7 +1610,10 @@ ${this.iconCacheRefresh(profile, repository, iconReference)}`; ? this.generatedCompose(profile, repository) : ""; const iconReference = await this.prepareIcon(profile, repository, server); - const metadata = this.metadataCompose(profile, repository, iconReference); + const metadata = this.metadataCompose(profile, repository, iconReference, { + sha: targetSha, + repositoryUrl: cloneUrl, + }); const compose = this.composeInvocation(profile, repository, composeFile); const branch = String(profile.branch || "main"); const statusJson = JSON.stringify({ @@ -1513,7 +1799,11 @@ FORGEFLOW_STATUS ? ".forgeflow/compose.forgeflow.yml" : safeRelativeRemoteFile(profile.composeFile || "docker-compose.yml"); const iconReference = await this.prepareIcon(profile, repository, server); - const metadata = this.metadataCompose(profile, repository, iconReference); + const metadata = this.metadataCompose(profile, repository, iconReference, { + sha: target, + repositoryUrl: + profile.cloneUrl || repository.sshUrl || repository.cloneUrl || "", + }); const compose = this.composeInvocation(profile, repository, composeFile); const requestId = crypto.randomUUID(); const operation = await this.saveOperation({ @@ -1639,6 +1929,8 @@ if docker inspect "$container" >/dev/null 2>&1; then webui=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.webui"}}' "$container" 2>/dev/null || true) icon=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.icon"}}' "$container" 2>/dev/null || true) shell_label=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.shell"}}' "$container" 2>/dev/null || true) + [ -z "$live" ] && live=$(docker inspect -f '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$container" 2>/dev/null || true) + [ -z "$live" ] && live=$(docker inspect -f '{{index .Config.Labels "tech.itworx.forgeflow.commit"}}' "$container" 2>/dev/null || true) fi printf '__FORGEFLOW_KV__\n' printf 'liveSha=%s\n' "$live" @@ -1886,6 +2178,9 @@ module.exports = { iconReferenceLocalPath, decodeBase64Json, parseDockerManXml, + parseServerInventory, + inventoryContainerMatch, + remoteIdentity, deriveDetectedProfile, bash, }; diff --git a/src/renderer/app.js b/src/renderer/app.js index d75502b..5b1bf4f 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -159,6 +159,7 @@ const ui = { diagnosticsStatus: null, troubleshooter: null, deploymentDiscovery: null, + serverDiscovery: [], lastDiagnosticBundle: null, setupDraft: { baseUrl: "https://", @@ -415,13 +416,33 @@ async function refreshActiveOperations(showErrors = true) { } async function refreshDeploymentTruth(showErrors = false) { + let discovery = []; + try { + discovery = (await window.forgeflow.discoverServerDeployments?.()) || []; + ui.serverDiscovery = discovery; + const adopted = discovery.reduce( + (total, server) => total + Number(server.adopted || 0), + 0, + ); + if (adopted > 0) { + await refreshRepositories(false, true); + showToast( + "Server workloads discovered", + `${adopted} running deployment${adopted === 1 ? " was" : "s were"} linked to Gitea automatically.`, + "success", + ); + } + } catch (error) { + if (showErrors) + showToast("Server discovery unavailable", error.message, "error"); + } const targets = ui.repositories.flatMap((repository) => (repository.deploymentProfiles || []).map((profile) => ({ repository, profile, })), ); - if (!targets.length) return { checked: 0, failed: 0 }; + if (!targets.length) return { checked: 0, failed: 0, discovery }; const failures = []; const queue = [...targets]; @@ -454,7 +475,7 @@ async function refreshDeploymentTruth(showErrors = false) { "error", ); } - return { checked: targets.length, failed: failures.length }; + return { checked: targets.length, failed: failures.length, discovery }; } function selectRepository(id, shouldRender = true) { @@ -903,7 +924,7 @@ function renderProfileCard(repository, profile, compact = false) { repository.localStatus?.branch.head === profile.branch; const isSsh = profile.provider === "ssh-unraid"; const providerDetail = isSsh - ? `SSH / Unraid · ${profile.remoteFolder || repository.name} · ${profile.branch}` + ? `SSH / Unraid · ${profile.remoteFolder || repository.name} · ${profile.branch}${profile.adoptedFromServer ? " · automatically discovered" : ""}` : `${profile.workflowFile} · ${profile.branch}`; const rollbackConfigured = isSsh || Boolean(profile.rollbackWorkflowFile); const dockerMan = dockerManIntegration(profile); diff --git a/src/renderer/mock-bridge.js b/src/renderer/mock-bridge.js index dc7259f..a5e782b 100644 --- a/src/renderer/mock-bridge.js +++ b/src/renderer/mock-bridge.js @@ -564,7 +564,7 @@ await wait(80); snapshot(); return { - appVersion: "0.8.6-demo", + appVersion: "0.8.7-demo", platform: "win32", state: clone(state), git: { available: true, version: "git version 2.47.3" }, @@ -1421,6 +1421,18 @@ syncState(); return clone(target.state); }, + async discoverServerDeployments() { + await wait(80); + return [ + { + serverId: "unraid-primary", + detected: 2, + adopted: 0, + verified: 1, + unmatched: 0, + }, + ]; + }, async refreshOperations(operationId = null) { await wait(300); if (operationId) { diff --git a/tests/unraid-deployment.test.mjs b/tests/unraid-deployment.test.mjs index fa0e34d..1004c6b 100644 --- a/tests/unraid-deployment.test.mjs +++ b/tests/unraid-deployment.test.mjs @@ -9,6 +9,9 @@ const { parseInspection, dockerIgnoreHasPath, checksSummary, + parseServerInventory, + inventoryContainerMatch, + remoteIdentity, xmlEscape, bash, } = require("../src/main/unraid-deployment-service.cjs"); @@ -37,6 +40,97 @@ test("server inspection key-value payload is decoded safely", () => { assert.deepEqual(parsed.existingPreservePaths, ["data", "logs"]); }); +test("server workload inventory links running containers to exact Gitea checkouts", () => { + const b64 = (value) => Buffer.from(value).toString("base64"); + const inspect = JSON.stringify([ + { + Name: "/Portfolio", + State: { Running: true, Health: { Status: "healthy" } }, + Config: { + Labels: { + "com.docker.compose.project.working_dir": + "/mnt/user/appdata/Portfolio", + }, + }, + Mounts: [], + }, + ]); + const inventory = parseServerInventory( + `noise\n__FORGEFLOW_INVENTORY__\nR\t${b64("/mnt/user/appdata/Portfolio")}\t${b64("git@gitea.itworx.tech:Jens/Portfolio.git")}\t${"a".repeat(40)}\t${b64("main")}\nC\t${b64(inspect)}\n`, + ); + assert.equal(inventory.checkouts.length, 1); + assert.equal(inventory.containers.length, 1); + assert.equal( + remoteIdentity("git@gitea.itworx.tech:Jens/Portfolio.git"), + remoteIdentity("https://gitea.itworx.tech/Jens/Portfolio"), + ); + assert.equal( + inventoryContainerMatch( + inventory.checkouts[0], + { name: "Portfolio" }, + inventory.containers[0], + ), + 100, + ); +}); + +test("automatic server discovery adopts and verifies a running Gitea deployment", async () => { + const b64 = (value) => Buffer.from(value).toString("base64"); + const sha = "b".repeat(40); + const container = { + Name: "/Portfolio", + State: { Running: true, Health: { Status: "healthy" } }, + Config: { + Labels: { + "com.docker.compose.project.working_dir": "/mnt/user/appdata/Portfolio", + "com.docker.compose.service": "portfolio", + }, + }, + Mounts: [], + NetworkSettings: { Ports: { "3000/tcp": [{ HostPort: "8080" }] } }, + }; + const profiles = []; + const states = new Map(); + const service = new UnraidDeploymentService({ + store: { + getServer: () => ({ + id: "unraid", + name: "Unraid", + basePath: "/mnt/user/appdata", + }), + getDeploymentProfiles: () => profiles, + saveDeploymentProfile: async (_fullName, profile) => { + profiles.push(profile); + return profile; + }, + saveDeploymentState: async (id, state) => { + states.set(id, state); + return state; + }, + }, + ssh: { + exec: async () => ({ + stdout: `__FORGEFLOW_INVENTORY__\nR\t${b64("/mnt/user/appdata/Portfolio")}\t${b64("git@gitea.itworx.tech:Jens/Portfolio.git")}\t${sha}\t${b64("main")}\nC\t${b64(JSON.stringify([container]))}\n`, + }), + }, + gitea: { getBranch: async () => ({ commit: { id: sha } }) }, + }); + const result = await service.discoverServerWorkloads("unraid", [ + { + fullName: "Jens/Portfolio", + name: "Portfolio", + defaultBranch: "main", + cloneUrl: "https://gitea.itworx.tech/Jens/Portfolio.git", + sshUrl: "git@gitea.itworx.tech:Jens/Portfolio.git", + }, + ]); + assert.equal(result.adopted, 1); + assert.equal(result.verified, 1); + assert.equal(profiles[0].containerName, "Portfolio"); + assert.equal(profiles[0].adoptedFromServer, true); + assert.equal(states.get(profiles[0].id).matchesGitea, true); +}); + test("Docker ignore checks identify exact runtime and Git context exclusions", () => { const rules = "# build context\n.git\ndata/\nlogs/**\n!logs/keep.txt\n"; assert.equal(dockerIgnoreHasPath(rules, ".git"), true);