Release ForgeFlow 0.8.7 automatic server discovery

This commit is contained in:
NuklearRabbit
2026-07-26 04:22:02 +02:00
parent 70840839f8
commit 40d41f9a32
13 changed files with 638 additions and 130 deletions
+9
View File
@@ -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;
+13 -12
View File
@@ -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
+22
View File
@@ -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.
+7 -8
View File
@@ -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,
+2 -2
View File
@@ -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"
},
+2 -1
View File
@@ -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": [
+117 -95
View File
@@ -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'),
}),
);
+3 -2
View File
@@ -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"),
+34 -3
View File
@@ -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(
+298 -3
View File
@@ -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,
};
+24 -3
View File
@@ -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);
+13 -1
View File
@@ -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) {
+94
View File
@@ -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);