diff --git a/CHANGELOG.md b/CHANGELOG.md index d082c64..f8ce104 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.9.0 - 2026-07-27 + +- replaced Git-checkout-only discovery with a complete Unraid workload inventory, including stopped, Compose, DockerMan and standalone containers; +- added persistent manual workload linking with repository, deployment mode, Compose project, files and service identity; +- made checksum-verified exact-commit push bundles the default for new SSH/Unraid profiles, so Unraid no longer needs a Gitea key; +- separated desktop-to-Unraid authentication, Docker/Compose capabilities and optional Unraid-to-Gitea access in diagnostics; +- preserved adopted DockerMan templates and disabled aggressive recreate/orphan flags by default; +- promoted deployment state only after Compose validation and service verification, with atomic manifests, rollback restoration and live lock ownership; +- retained server-side Git and monitor-only modes for explicit use cases. + ## 0.8.9 - 2026-07-26 - added a per-repository Git Validator with a weighted assurance score and evidence-backed checks; diff --git a/README.md b/README.md index 1eda138..9d2f63b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ForgeFlow is een desktopapp voor teams die met Git, Gitea en eigen servers werken. De app toont wat lokaal gewijzigd is, wat al op Gitea staat en welke exacte commit op de server draait. Daarna begeleidt ForgeFlow je door review, commit, push, deployment en verificatie. -> Huidige release: **0.8.9** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest) +> Huidige release: **0.9.0** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest) ![ForgeFlow release-overzicht](docs/screenshots/overview.png) @@ -13,7 +13,8 @@ ForgeFlow is een desktopapp voor teams die met Git, Gitea en eigen servers werke - **Eén duidelijke actielijst:** zie meteen welke repository aandacht nodig heeft en waarom. - **Veilige Git-flow:** review wijzigingen, stage volledige bestanden of afzonderlijke hunks, commit, push en herstel conflicten zonder contextwissel. - **Deployment op een exacte commit:** ForgeFlow gebruikt volledige commit-SHA's en toont lokaal, Gitea en server naast elkaar. -- **Live serverwaarheid:** ontdekte containers worden aan repositories gekoppeld en opnieuw met Gitea vergeleken, ook wanneer een deployment buiten ForgeFlow gebeurde. +- **Volledige serverinventaris:** zie ook gestopte, DockerMan- en niet-Git-installaties, koppel twijfelgevallen handmatig en behoud hun bestaande Compose-identiteit. +- **Push-deployment zonder server-Gitea-key:** ForgeFlow verstuurt standaard een checksum-gecontroleerde bundle van de exacte lokale commit via de vertrouwde SSH-verbinding. - **Ingebouwde Git Validator:** controleer repository-identiteit, branch protection, synchronisatie-instellingen, documentatie, geheimen en grote bestanden; veilige verbeteringen kunnen gericht worden toegepast. - **Lokale controle:** configuratie en credentials blijven op het toestel en diagnostische exports worden lokaal geredigeerd. @@ -92,7 +93,9 @@ Een gelijke commit bewijst welke code draait; een geslaagde healthcheck bewijst - deploymentprofielen per repository en omgeving; - Gitea Actions en SSH/Unraid als gecontroleerde uitvoeringsroutes; -- server discovery en herkenbare koppeling van bestaande containers aan repositories; +- serverinventaris van draaiende en gestopte Docker-, Compose- en DockerMan-workloads; +- automatische koppeling op exact bewijs en een handmatige koppelwizard voor twijfelgevallen; +- push-bundle deployment als standaard, zonder Git- of Gitea-sleutel op Unraid; - verificatie op volledige SHA, runtime health en recente serverwaarheid; - preflight, live logs, deploymenthistoriek en rollback naar de vorige bekende versie. diff --git a/SOURCE_MANIFEST.txt b/SOURCE_MANIFEST.txt index 64e3131..79b42d6 100644 --- a/SOURCE_MANIFEST.txt +++ b/SOURCE_MANIFEST.txt @@ -1,4 +1,4 @@ -ForgeFlow 0.8.9 source manifest +ForgeFlow 0.9.0 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 -2038d46210c3c2ac582889f9ed5e352a9ff4f2b0bbd80d25adcc20aba7c3e09b 11200 CHANGELOG.md +e3fd7c60e47b04aab79518bfc80b747bd76b299d20e7caca570abaf10b57cdca 12075 CHANGELOG.md 21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md 30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md @@ -47,6 +47,7 @@ b516db97a0353babc810c24a87a971d30d72a8021d809e6b833ff7ae0458f442 538 ef049adcfa204908e6dc3a059124b39ba0e2739cc54e38945ce73a57049df0d8 1185 docs/RELEASE_NOTES_0.8.7.md 7eedb25e1aae3b06a04bb9b2f4843bd6af614418737e600b4bdc9161edabd76a 632 docs/RELEASE_NOTES_0.8.8.md 35dcfda990946480d6d55bd2d2e6360c336260bcd05cdb51d92e07a4e8d76945 1046 docs/RELEASE_NOTES_0.8.9.md +0f5d64a752424bb8e2f5aa8a01fa31ea3bce3db8fd70e6b3c2ea29c41dd954f5 2574 docs/RELEASE_NOTES_0.9.0.md 2b631b9d6d973bdd70869d84886ff339da351e29e17598970b3b27915674661d 4175 docs/ROADMAP.md 1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png @@ -57,7 +58,7 @@ bbdbe91679b486cc92dec4758ce1cdaf24e3277d038c57e794a04c0dee7e3a5b 84046 c8a5e80bb9fd2d442d2d23d30e6ac1528cf2330e6e19492b7c6799e2d1508b53 112868 docs/screenshots/repository-workspace.png 322624242d246d07180cc719e14c91e8fb69e123676a02e5046f4e576cca1ca1 5569 docs/SECURITY.md 32a34ec13a284d3f9ceebbc107b25a844e3db096f8cafa4e43951fc2050c9a03 13552 docs/SETUP_GUIDE.md -4dcdbd42550a4cc53fe948349b20bfe0e445d40144ef82df54b3aab8b00a8f46 5165 docs/SSH_UNRAID_DEPLOYMENT.md +6d053a73500eda1f48abd633bc8936d4397c7b46e95bc6c3a02aa698ec12f2a8 6056 docs/SSH_UNRAID_DEPLOYMENT.md b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md 0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md 4625a10ebd3c749f60b2a7bef6b1716cd05dbc44ccceba0491a1b46bc293c195 4883 docs/TEST_MATRIX.md @@ -72,11 +73,11 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE 3b16a087c73b600415394dff8b8e34e7f7519e48fde1cf443007b2e11ca77b27 13123 main.cjs 91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md -ab7c04a2df33ebe2861adc68c74d8f7b0eeb6a2d7c72410c26bc66b883d985c7 130466 package-lock.json -4256d59aa47ea13d9e305370c2602887930d6069f4db34dc91c1f738d4b79e97 3943 package.json -da0fef78350d4f924a8d960eaa2c363023ae21d76ade50fb478c70a22584c8a1 9674 preload.cjs +bf0ccaa096bb7db57eda09c74769ba5f798ccf72654bafe6d04efea6446d0418 130466 package-lock.json +2a6d2668296f9f0e3fea2ef3ce5afebf5b8e6d60fbcbf6939c827e69087dfde7 3980 package.json +6b66a5aef158de35d5cdd0f205ceeec53b6a375937a67c5e9d00e483999ff840 9898 preload.cjs b31c43d9355c13b5ae4efc0f3649d8cb8d509b2bb7ebb042ff546b7820fb7de8 8411 Publish-ForgeFlow-Release.ps1 -e51bd52dc36ee1351316020f5f60381f1e881970f78435682d8acd7c498c48c8 8455 README.md +d929f074b9c24619f0e750753b8721b212ed37cedad44565df9c52754eb71a23 8802 README.md 509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs 00d57bda5af8c8eda294b72d18b318f024a307b81b0d9205a0821f5240151e31 3814 scripts/apply-binary-update.ps1 f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 scripts/apply-source-update.ps1 @@ -86,11 +87,11 @@ f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 74433d8a6b24afe368197a469e2fe0c5050c239d7250b84c2f3f598c304778b0 4736 scripts/publish-binary-release.cjs 444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs 42203f9e0fd4aae517284d387f265cf1b0b180379bc253a092b5c3c5c4caef0a 2992 scripts/validate-installed-connections.cjs -c7942bac000c9850630dc086f260f2dbf4bf4b8b435dac3631e7fe5ca41d507f 12407 scripts/verify.mjs +1b269fa25947324120cab42612aaef806c3f72c761eacb4649edd58396a0de6f 12890 scripts/verify.mjs 0079701b5acbfef07b71a9623613d1940805ccd20649d77e3f34c37e79df7655 735 scripts/write-release-checksums.mjs 619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1 dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs -a381848a296c28f6d14093c96f722967acf9c994ffb867d54dd92bf5ada2729b 23648 src/main/config-store.cjs +e3e3839042dad88bba6971535d34ba2531dfd129d963b14ab9df887f68179cbf 27126 src/main/config-store.cjs 2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs 9d0af5074093108a5248d0dde0ff70a666748e61f1954b630886a81e8f34072c 24079 src/main/deployment-service.cjs c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs @@ -98,22 +99,23 @@ a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs 857f270a2204b743421eea619a6a88595f749e4c24c1794cb092ace7987d25dc 12553 src/main/git-validator-service.cjs 1c0a1c1b7f20734c646c874e07c89550e601951351c04770874c1ed3496f0833 17837 src/main/gitea-service.cjs -dadf2fd2e3d148456da0b735a1d9af142196b230a02346a9ca29c7605d7e0b87 47704 src/main/ipc.cjs +703547b6d8f5837801b953cb4c049e41139343da131088e0cd5bb7382c05e772 48373 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 -f4e221edace12e2120ec439e0f91274de110872c5bfbe968f2d7935eeb7193c4 78544 src/main/unraid-deployment-service.cjs +cc4d5e06119d0315e4240f0776d68aacf72f2f36907f40830fa86e7bb0876490 18250 src/main/server-inventory.cjs +afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs +51bfc677fcdaec75ac6abe1a55ff531bc081787053b49ee56d8bfffcab666aa4 99239 src/main/unraid-deployment-service.cjs 45e65564e1e8b9db487dc6dda03a752c51130189f23ec3b1260dc62e3621a925 20806 src/main/update-service.cjs -9729e1124a95899f0803e2cd5147bd6d3ae58010d659c408ddd69a31f5d1cb0e 202489 src/renderer/app.js +704306badd4a1a7080c3d2f4407c8dc1d8ec5f807dd165cd22a37e48d6022435 220714 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 -e56e81ec2f2c9743332de0e19aca75bf1aa585351a790d7b32ae1ccdb6a26b4c 54717 src/renderer/mock-bridge.js +d102db2f63d92d8b6af85fc798453dc3bd78cfbe5542cbf0af740781d059fa0f 58577 src/renderer/mock-bridge.js 607b2592c6aee37de91126a80704c1f7d8575cb2e5ff889305cd86a37e6170c4 76937 src/renderer/styles.css 0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs 5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs @@ -150,8 +152,9 @@ f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 1068 d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 tests/security-validation.test.mjs bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs 020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs +2571128f0b8e650071df17755baa09c4dfc441af0c20a7a4e9aa445b59e87d11 1654 tests/ssh-service.test.mjs 8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs -8f44579de3af7d7d23d55572d1d97cb0690425d4d9dffc30c6bf2ec3ff9c54eb 24553 tests/unraid-deployment.test.mjs +936412de29be7fbccb71a32460e222077361090deb1373fd56c904d70a0af718 37436 tests/unraid-deployment.test.mjs 11d6e6329f775617a1ce3657d0454cc97d7bcf3d9759f9a5a623c9d18e13d03e 15118 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.9.0.md b/docs/RELEASE_NOTES_0.9.0.md new file mode 100644 index 0000000..52b0742 --- /dev/null +++ b/docs/RELEASE_NOTES_0.9.0.md @@ -0,0 +1,23 @@ +# ForgeFlow 0.9.0 + +ForgeFlow 0.9.0 changes Unraid deployments from a Git-checkout-first workflow into a server-inventory-first workflow. + +## Existing installations are now visible + +Server Inventory collects a safe, selected subset of Docker, Compose and DockerMan metadata for both running and stopped containers. It recognizes Compose project names, working directories, active Compose files and services, mounts, ports, runtime state, image provenance and existing DockerMan templates. Environment values and other container secrets are not collected. + +Exact repository provenance may be linked automatically. Name similarity is never treated as proof: uncertain workloads remain visible as suggestions and can be linked through the new manual wizard. The saved link preserves the workload identity rather than depending on the disposable container ID. + +## Push bundle is the new default + +New SSH/Unraid profiles use **Push bundle**. ForgeFlow creates a tar archive from the exact local Git commit, calculates its SHA-256 digest and uploads it over the already trusted desktop-to-Unraid SSH connection. Unraid therefore does not need a Git client, Gitea host-key entry or Gitea private key for this mode. + +The server verifies the checksum and archive paths, rejects symlink payloads, preserves configured runtime paths, updates only ForgeFlow-managed files and validates the merged Compose model before starting services. The active SHA and managed-file manifest are promoted atomically only after the expected services are running. Failure restoration keeps the previous deployment truth and restores overwritten files and Compose metadata. + +**Server-side Git** remains available as an explicit mode. Its Gitea access check is now reported separately from desktop SSH and Docker/Compose capabilities. **Monitor only** links an existing workload without granting ForgeFlow permission to deploy it. + +## Safer adoption + +Adopted workloads retain their existing Compose project, Compose files and service set. ForgeFlow no longer overrides their image or container name in the metadata overlay. Existing DockerMan templates are left untouched; generated templates are managed only for explicitly generated Compose profiles. `--force-recreate` and `--remove-orphans` are opt-in rather than defaults. + +A deployment lock records the live shell process, and an old lock is removed only when it is sufficiently old and its owner no longer runs. Deployment output truncation now fails explicitly instead of allowing ForgeFlow to interpret an incomplete inventory or command result. diff --git a/docs/SSH_UNRAID_DEPLOYMENT.md b/docs/SSH_UNRAID_DEPLOYMENT.md index 96aa167..089a923 100644 --- a/docs/SSH_UNRAID_DEPLOYMENT.md +++ b/docs/SSH_UNRAID_DEPLOYMENT.md @@ -1,6 +1,20 @@ # SSH / Unraid deployment -ForgeFlow deploys an exact Gitea commit directly to an Unraid server over pinned SSH. +ForgeFlow can inventory existing Unraid workloads and deploy an exact Git commit through one of three explicit modes. + +## Deployment modes + +### Push bundle — default + +ForgeFlow creates an archive from the exact local commit and uploads it through the trusted desktop-to-Unraid SSH connection. The Unraid server needs Docker, Docker Compose, `tar` and a SHA-256 checksum tool. It does **not** need Git access or a Gitea key. + +### Server-side Git — optional + +Unraid fetches the exact commit from Gitea. This mode additionally requires Git, a trusted Gitea host key and a non-interactive Unraid-to-Gitea identity with repository access. + +### Monitor only + +ForgeFlow inventories and tracks the workload but refuses deploy and rollback operations until another mode is selected. ## Security model @@ -8,88 +22,76 @@ ForgeFlow deploys an exact Gitea commit directly to an Unraid server over pinned - Ed25519 private keys are preferred. - Passwords and key passphrases use Electron safe storage. - The first trusted connection records the SSH host-key fingerprint; later changes fail closed. -- Unraid-to-Gitea repository access is tested during every deployment preflight. +- ForgeFlow validates the selected private key before saving the server configuration. +- Desktop-to-Unraid authentication, Docker access, Compose availability and optional Unraid-to-Gitea access are reported as separate checks. +- Remote inventory collects selected labels, mounts, ports and runtime state; it does not collect container environment values. - The renderer cannot submit arbitrary shell commands. Remote scripts are assembled from validated profile fields and transported as base64-encoded Bash input. -- Tracked server-side modifications block deployment and rollback. +- Incomplete SSH output is rejected rather than interpreted. -## Profile identity +## Server Inventory and manual linking -ForgeFlow separates names that users see from names Docker requires: +**Server Inventory** includes running and stopped containers, Compose projects, DockerMan installations and standalone containers even when `/mnt/user/appdata/` is not a Git checkout. + +An exact repository remote or trusted provenance label may be linked automatically. Similar names remain suggestions. For an uncertain workload, use **Link workload** and choose: + +1. the repository; +2. Push bundle, Server-side Git or Monitor only; +3. the folder below the configured server base path. + +The resulting profile preserves the detected Compose project name, Compose files, service set, container metadata and stable workload selector. Linking itself does not recreate a container or rewrite a DockerMan template. + +## Compose identity + +An adopted installation keeps the identity reported by Docker: ```text -Visible project/container: Portfolio -Server folder: Portfolio -Internal Compose project: portfolio -Internal Compose service: portfolio -Internal image: forgeflow/portfolio:production +Visible container: omniroute +Server folder: OmniRoute +Compose project: omniroute-production +Compose files: compose.yml, compose.unraid.yml +Compose services: api, worker ``` -The internal Compose service must match the repository's service key and remain lowercase. The visible container can preserve branding and casing. +ForgeFlow adds `.forgeflow/compose.metadata.yml` as the final Compose overlay. For adopted workloads this overlay adds labels only; it does not replace the existing image or `container_name`. -## DockerMan WebUI, icon and shell +`--force-recreate` and `--remove-orphans` are disabled by default and can be enabled explicitly per profile. Multiple Compose files and services are retained and validated. -ForgeFlow writes `.forgeflow/compose.metadata.yml` and combines it with the repository or generated Compose file. The override supplies: +## DockerMan safety -```text -net.unraid.docker.managed=dockerman -net.unraid.docker.webui=http://[IP]:[PORT:]/ -net.unraid.docker.icon= -net.unraid.docker.shell=sh -``` +Existing DockerMan templates under `/boot/config/plugins/dockerMan/templates-user` are never rewritten for adopted workloads. ForgeFlow manages a persistent DockerMan template only when all of the following are true: -Icon modes: +- the profile uses ForgeFlow-generated Compose; +- **Manage DockerMan template** is enabled; +- the workload was not adopted from the server. -- **Built-in high-contrast ITWorx mark** — default; -- **Upload local PNG** — copied to `/boot/config/plugins/dockerMan/images/-icon.png`; -- **Use icon URL** — HTTP(S) PNG; -- **No custom icon**. +Metadata repair for an adopted application therefore updates the Compose metadata labels only and leaves the original Unraid template intact. -After metadata changes ForgeFlow recreates the container, writes `/boot/config/plugins/dockerMan/templates-user/my-.xml`, removes known icon caches and invalidates DockerMan's volatile `docker.json` metadata cache. The Unraid Docker page may still need one browser refresh. +## Push bundle sequence -The deployment card reports whether WebUI and icon labels were confirmed through `docker inspect`. **Repair DockerMan integration** recreates an existing healthy container with labels, a persistent DockerMan template, icon cache refresh and WebUI metadata without creating a Git commit. **Open Web UI** uses the profile URL directly from the desktop. +1. Verify the clean local working tree, selected branch, upstream state and exact commit. +2. Verify the exact commit exists on the configured remote branch. +3. Test desktop-to-Unraid SSH, Docker, Compose, `tar`, checksum tooling and deployment storage. +4. Create the release with `git archive` from the exact commit. +5. Upload a temporary `.part` file through SFTP. +6. Verify the SHA-256 digest and reject unsafe paths or symbolic links. +7. Store a retained release manifest and back up files that ForgeFlow is about to manage. +8. Preserve `.forgeflow`, `.git` and configured runtime paths such as `.env`, `data`, `config`, `logs` and application-specific folders. +9. Update only files in the old or new ForgeFlow managed-file manifests; unrelated server files remain untouched. +10. Validate the merged Compose configuration. +11. Start the configured Compose project and verify every selected service has a running container and is not unhealthy. +12. Promote `current-sha`, status and managed-file manifests atomically. +13. Run the optional desktop healthcheck and persist runtime state. -## Existing application folder +If activation fails, ForgeFlow restores the backed-up files and previous Compose metadata, restarts the previous Compose state where possible and leaves the previous active SHA untouched. -For an existing folder: +## Server-side Git sequence -```text -Server folder: Portfolio -Remote path: /mnt/user/appdata/Portfolio -Compose file: docker-compose.yml -``` +Server-side Git retains the exact-SHA flow for installations that deliberately keep a checkout on Unraid. Preflight runs `git ls-remote` from Unraid, rejects tracked server edits, verifies the configured origin and checks that the selected SHA belongs to the allowed remote branch. The active SHA is written only after Compose validation and service verification. -The project root must be a Git working tree. Untracked runtime paths such as `.env`, `appdata`, `data`, `logs`, `config` and `compose.override.yml` remain untouched by `git reset --hard`. Nested Git repositories are warnings and never deleted automatically. +## Interrupted operations and locks -Preflight inspects `.dockerignore` when a Dockerfile exists. It warns when `.git`, preserved runtime data or nested repositories would be sent into the build context. - -## New application folder - -The server clones the configured URL on the selected branch. The Unraid host therefore needs a non-interactive Gitea SSH identity. Preflight runs `git ls-remote --exit-code` from Unraid before deployment. - -Use repository Compose for real applications. Generated Compose is intended only for a simple single-service Dockerfile application with basic port mapping. - -## Deployment sequence - -1. Verify clean local tree, allowed branch, upstream and ahead/behind state. -2. Verify the exact SHA exists on the allowed remote branch. -3. Verify Unraid can read the Gitea repository. -4. Inspect the server folder and refuse tracked server changes. -5. Clone when the folder is absent. -6. Fetch the branch and verify the exact SHA is an ancestor of `origin/`. -7. Save the previous SHA and reset to the requested SHA. -8. Write generated Compose when selected. -9. Write the DockerMan metadata override and persistent template fallback. -10. Validate the merged Compose model. -11. Run `docker compose up -d --build --remove-orphans --force-recreate`. -12. Clear relevant icon caches. -13. Inspect the visible container and write `.forgeflow/status.json`. -14. Run the configured desktop healthcheck. -15. Persist the live SHA, previous SHA, health, container and DockerMan state. - -## Interrupted operation recovery - -At startup and through **Reconcile**, ForgeFlow reads the live SHA, container running state, Docker health, labels and persistent template state. When a previously running operation already reached its exact requested SHA and the container is healthy, the operation becomes `success`. Operations that remain unresolved for more than 45 minutes become `failed` rather than staying indefinitely in deployment mode. +A push deployment creates `.forgeflow/deploy.lock` with a request ID, timestamp and shell PID. A lock older than two hours is removed only when its recorded process no longer exists. Startup and **Reconcile** compare the requested SHA with server state and container health; unresolved operations eventually become failed rather than remaining active indefinitely. ## Rollback -Rollback is accepted only for the exact SHA currently recorded as `previousSha`. ForgeFlow re-verifies that commit against Gitea, refuses tracked server changes, resets the same working tree, reapplies Compose and DockerMan metadata, reruns health checks and rotates the former live SHA into the new rollback target. +Rollback is accepted only for the exact `previousSha` recorded for the profile. Push bundle mode recreates that commit archive locally and uses the same checksum, backup, Compose validation and atomic promotion flow. Server-side Git mode verifies and checks out the exact previous commit through its Git workflow. diff --git a/package-lock.json b/package-lock.json index a2fd102..0a9892b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "forgeflow", - "version": "0.8.9", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "forgeflow", - "version": "0.8.9", + "version": "0.9.0", "dependencies": { "ssh2": "1.17.0" }, diff --git a/package.json b/package.json index 272ef7c..d58947f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "forgeflow", - "version": "0.8.9", + "version": "0.9.0", "private": true, "description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.", "main": "main.cjs", @@ -82,6 +82,7 @@ "docs/RELEASE_NOTES_0.8.7.md", "docs/RELEASE_NOTES_0.8.8.md", "docs/RELEASE_NOTES_0.8.9.md", + "docs/RELEASE_NOTES_0.9.0.md", "docs/ACCEPTANCE.md" ], "asarUnpack": [ diff --git a/preload.cjs b/preload.cjs index 1a50d62..d455ce8 100644 --- a/preload.cjs +++ b/preload.cjs @@ -120,6 +120,7 @@ contextBridge.exposeInMainWorld( healthcheck: (url) => invoke('deployment:health', { url }), refreshProfileState: (fullName, profileId) => invoke('deployment:profile-state', { fullName, profileId }), discoverServerDeployments: () => invoke('deployment:discover-server-workloads'), + linkServerWorkload: (repository, serverId, workloadId, deploymentMode = 'push-bundle', remoteFolder = '') => invoke('deployment:link-server-workload', { repository, serverId, workloadId, deploymentMode, remoteFolder }), 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 }), diff --git a/scripts/verify.mjs b/scripts/verify.mjs index edd57b7..9de49e8 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -27,6 +27,7 @@ const required = [ "src/main/repository-monitor.cjs", "src/main/deployment-service.cjs", "src/main/unraid-deployment-service.cjs", + "src/main/server-inventory.cjs", "src/main/ssh-service.cjs", "src/main/update-service.cjs", "src/main/diagnostics-service.cjs", @@ -70,6 +71,7 @@ const required = [ "docs/RELEASE_NOTES_0.8.7.md", "docs/RELEASE_NOTES_0.8.8.md", "docs/RELEASE_NOTES_0.8.9.md", + "docs/RELEASE_NOTES_0.9.0.md", "docs/UPDATING.md", "docs/DIAGNOSTICS.md", "docs/DEPLOYMENT_SETUP.md", @@ -108,9 +110,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.9") +if (packageJson.version !== "0.9.0") throw new Error( - `Expected package version 0.8.9, got ${packageJson.version}.`, + `Expected package version 0.9.0, got ${packageJson.version}.`, ); const sourceManifest = await readFile( path.join(root, "SOURCE_MANIFEST.txt"), @@ -345,10 +347,21 @@ for (const channel of [ "repository:repair-sync", "deployment:apply-dockerman-metadata", "deployment:reconcile", + "deployment:link-server-workload", ]) { if (!ipc.includes(channel)) throw new Error(`IPC registration is missing: ${channel}`); } +const release090 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.0.md"), "utf8"); +for (const phrase of [ + "Push bundle", + "manual wizard", + "Monitor only", + "DockerMan templates", + "SHA-256", +]) { + if (!release090.includes(phrase)) throw new Error(`0.9.0 release notes are missing: ${phrase}`); +} const gitSource = await readFile( path.join(root, "src/main/git-service.cjs"), "utf8", @@ -379,6 +392,9 @@ for (const phrase of [ "iconCacheRefresh", "[PORT:", "Superseded by live commit", + "pushBundleScript", + "linkServerWorkload", + "deploymentMode", ]) { if (!unraidSource.includes(phrase)) throw new Error(`Unraid recovery implementation is missing: ${phrase}`); diff --git a/src/main/config-store.cjs b/src/main/config-store.cjs index 3be846c..c93fc02 100644 --- a/src/main/config-store.cjs +++ b/src/main/config-store.cjs @@ -4,10 +4,10 @@ const fs = require('node:fs/promises'); const path = require('node:path'); const crypto = require('node:crypto'); const { safeStorage } = require('electron'); -const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePaths } = require('../shared/validation.cjs'); +const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePath, assertRepositoryRelativePaths } = require('../shared/validation.cjs'); const DEFAULT_CONFIG = { - schemaVersion: 8, + schemaVersion: 9, setupComplete: false, appearance: 'dark', gitea: { baseUrl: '', user: null, encryptedToken: null }, @@ -76,7 +76,28 @@ class ConfigStore { : iconFilePath ? 'upload' : iconUrl && !/itworx\.tech\/assets\/itworx-icon\.png/i.test(iconUrl) ? 'url' : 'builtin'; const visibleName = String(profile.containerName || profile.remoteFolder || '').trim(); const internalService = String(profile.composeService || profile.remoteFolder || 'app').trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-') || 'app'; - return { ...profile, composeService: internalService, containerName: visibleName || internalService, iconMode }; + const requestedDeploymentMode = String(profile.deploymentMode || '').trim(); + const deploymentMode = ['push-bundle', 'server-git', 'monitor-only'].includes(requestedDeploymentMode) + ? requestedDeploymentMode + : 'server-git'; + const composeFiles = uniqueStrings(profile.composeFiles || [profile.composeFile || 'docker-compose.yml']); + const composeServices = uniqueStrings(profile.composeServices || [internalService]).map((value) => value.toLowerCase()); + return { + ...profile, + deploymentMode, + composeFile: composeFiles[0] || 'docker-compose.yml', + composeFiles: composeFiles.length ? composeFiles : ['docker-compose.yml'], + composeServices, + composeProject: String(profile.composeProject || '').trim(), + composeWorkingDir: String(profile.composeWorkingDir || '').trim(), + composeService: internalService, + containerName: visibleName || internalService, + iconMode, + manageDockerMan: profile.manageDockerMan === true, + forceRecreate: profile.forceRecreate === true, + removeOrphans: profile.removeOrphans === true, + workloadIdentity: profile.workloadIdentity && typeof profile.workloadIdentity === 'object' ? structuredClone(profile.workloadIdentity) : null + }; })])) : {}, deploymentStates: source.deploymentStates && typeof source.deploymentStates === 'object' ? source.deploymentStates : {}, @@ -223,10 +244,13 @@ class ConfigStore { async deleteServer(serverId) { this.data.servers = this.data.servers.filter((item) => item.id !== serverId); + const removedProfileIds = new Set(); for (const [key, profiles] of Object.entries(this.data.deploymentProfiles)) { + for (const profile of profiles) if (profile.serverId === serverId) removedProfileIds.add(profile.id); this.data.deploymentProfiles[key] = profiles.filter((profile) => profile.serverId !== serverId); if (!this.data.deploymentProfiles[key].length) delete this.data.deploymentProfiles[key]; } + for (const profileId of removedProfileIds) delete this.data.deploymentStates[profileId]; await this.save(); } @@ -357,21 +381,39 @@ class ConfigStore { inputs: {} }; if (provider === 'ssh-unraid') { - const remoteFolder = String(profile.remoteFolder || '').trim(); - if (!remoteFolder || !/^[a-zA-Z0-9._-]+$/.test(remoteFolder)) throw new Error('Remote folder must contain only letters, numbers, dots, underscores and dashes.'); + const remoteFolder = assertRepositoryRelativePath(String(profile.remoteFolder || '').trim()); + if (!remoteFolder || remoteFolder === '.' || remoteFolder.split('/').some((part) => !part || part === '.')) throw new Error('Remote folder must be a safe path relative to the configured server base path.'); const preservePaths = assertRepositoryRelativePaths(uniqueStrings(profile.preservePaths || ['.env', 'appdata', 'data', 'logs', 'config', 'compose.override.yml'])); + const composeFiles = assertRepositoryRelativePaths(uniqueStrings(profile.composeFiles || [profile.composeFile || 'docker-compose.yml'])); + if (!composeFiles.length && profile.generatedCompose !== true) throw new Error('Select at least one Compose file.'); + const composeService = (() => { + const value = String(profile.composeService || profile.composeServices?.[0] || remoteFolder.split('/').pop()).trim().toLowerCase(); + if (!/^[a-z0-9._-]+$/.test(value)) throw new Error('Compose service must be lowercase and contain only letters, numbers, dots, underscores and dashes.'); + return value; + })(); + const composeServices = uniqueStrings(profile.composeServices || [composeService]).map((value) => { + const normalized = String(value).trim().toLowerCase(); + if (!/^[a-z0-9._-]+$/.test(normalized)) throw new Error('Compose services must be lowercase and contain only letters, numbers, dots, underscores and dashes.'); + return normalized; + }); + const composeProject = String(profile.composeProject || '').trim(); + if (composeProject && !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(composeProject)) throw new Error('Compose project name contains unsupported characters.'); + const composeWorkingDir = String(profile.composeWorkingDir || '').trim(); + if (composeWorkingDir && (!composeWorkingDir.startsWith('/') || /[\r\n\0]/.test(composeWorkingDir))) throw new Error('Compose working directory must be an absolute safe Unix path.'); + const deploymentMode = ['push-bundle', 'server-git', 'monitor-only'].includes(profile.deploymentMode) ? profile.deploymentMode : 'push-bundle'; return { ...common, serverId: String(profile.serverId || '').trim(), remoteFolder, - composeFile: String(profile.composeFile || 'docker-compose.yml').trim(), - composeService: (() => { - const value = String(profile.composeService || remoteFolder).trim().toLowerCase(); - if (!/^[a-z0-9._-]+$/.test(value)) throw new Error('Compose service must be lowercase and contain only letters, numbers, dots, underscores and dashes.'); - return value; - })(), + deploymentMode, + composeFile: composeFiles[0] || 'docker-compose.yml', + composeFiles: composeFiles.length ? composeFiles : ['docker-compose.yml'], + composeProject, + composeWorkingDir, + composeService, + composeServices, containerName: (() => { - const value = String(profile.containerName || remoteFolder).trim(); + const value = String(profile.containerName || remoteFolder.split('/').pop()).trim(); if (!/^[A-Za-z0-9._-]+$/.test(value)) throw new Error('Container name must contain only letters, numbers, dots, underscores and dashes.'); return value; })(), @@ -390,6 +432,10 @@ class ConfigStore { generatedCompose: profile.generatedCompose === true, adoptedFromServer: profile.adoptedFromServer === true, serverSourceOfTruth: profile.serverSourceOfTruth === true, + manageDockerMan: profile.manageDockerMan === true, + forceRecreate: profile.forceRecreate === true, + removeOrphans: profile.removeOrphans === true, + workloadIdentity: profile.workloadIdentity && typeof profile.workloadIdentity === 'object' ? structuredClone(profile.workloadIdentity) : null, detectedAt: profile.detectedAt || null, provenance: profile.provenance && typeof profile.provenance === 'object' ? structuredClone(profile.provenance) : {}, detectedMetadata: profile.detectedMetadata && typeof profile.detectedMetadata === 'object' ? structuredClone(profile.detectedMetadata) : {}, diff --git a/src/main/ipc.cjs b/src/main/ipc.cjs index d5033ff..186d609 100644 --- a/src/main/ipc.cjs +++ b/src/main/ipc.cjs @@ -450,6 +450,7 @@ function registerIpc({ register( "server:save", async ({ server, password = "", passphrase = "" }) => { + await ssh.validateServerConfiguration(server, { password, passphrase }); const saved = await store.saveServer(server, { password, passphrase }); await diagnostics.info("server.saved", { serverId: saved.id, @@ -1175,6 +1176,17 @@ function registerIpc({ }, ); register("deployment:health", ({ url }) => deployments.checkHealth(url)); + register("deployment:link-server-workload", async ({ repository, serverId, workloadId, deploymentMode = "push-bundle", remoteFolder = "" }) => { + const current = await resolveRepository(repository); + const result = await unraid.linkServerWorkload({ + repository: current, + serverId, + workloadId, + deploymentMode, + remoteFolder, + }); + return { ...result, state: store.getPublicState() }; + }); register("deployment:discover-server-workloads", async () => { const repositoryList = await repositories.refresh(); const remoteRepositories = repositoryList.filter( @@ -1189,10 +1201,16 @@ function registerIpc({ } catch (error) { results.push({ serverId: server.id, + serverName: server.name, detected: 0, adopted: 0, verified: 0, + linked: 0, unmatched: 0, + needsReview: 0, + capabilities: {}, + warnings: [], + workloads: [], error: error.message, }); } diff --git a/src/main/server-inventory.cjs b/src/main/server-inventory.cjs new file mode 100644 index 0000000..2d1d180 --- /dev/null +++ b/src/main/server-inventory.cjs @@ -0,0 +1,403 @@ +'use strict'; + +const crypto = require('node:crypto'); +const path = require('node:path').posix; +const { normalizeRemoteUrl } = require('../shared/repository-match.cjs'); + +function decodeBase64(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 normalizedName(value) { + return String(value || '').toLowerCase().replace(/\.git$/i, '').replace(/[^a-z0-9]/g, ''); +} + +function safeJson(value, fallback) { + try { return JSON.parse(value); } + catch { return fallback; } +} + +function sanitizeLegacyContainer(container) { + const labels = container?.Config?.Labels || {}; + return { + id: container?.Id || '', + name: String(container?.Name || '').replace(/^\//, ''), + image: container?.Config?.Image || '', + imageId: container?.Image || '', + running: container?.State?.Running === true, + status: container?.State?.Status || '', + health: container?.State?.Health?.Status || null, + labels: { + 'com.docker.compose.project': labels['com.docker.compose.project'] || '', + 'com.docker.compose.project.working_dir': labels['com.docker.compose.project.working_dir'] || '', + 'com.docker.compose.project.config_files': labels['com.docker.compose.project.config_files'] || '', + 'com.docker.compose.service': labels['com.docker.compose.service'] || '', + 'org.opencontainers.image.source': labels['org.opencontainers.image.source'] || '', + 'org.opencontainers.image.revision': labels['org.opencontainers.image.revision'] || '', + 'tech.itworx.forgeflow.repository': labels['tech.itworx.forgeflow.repository'] || '', + 'tech.itworx.forgeflow.commit': labels['tech.itworx.forgeflow.commit'] || '', + 'tech.itworx.forgeflow.branch': labels['tech.itworx.forgeflow.branch'] || '', + 'net.unraid.docker.webui': labels['net.unraid.docker.webui'] || '', + 'net.unraid.docker.icon': labels['net.unraid.docker.icon'] || '', + 'net.unraid.docker.shell': labels['net.unraid.docker.shell'] || '', + 'net.unraid.docker.managed': labels['net.unraid.docker.managed'] || '', + }, + ports: container?.NetworkSettings?.Ports || {}, + mounts: Array.isArray(container?.Mounts) ? container.Mounts : [], + networks: container?.NetworkSettings?.Networks || {}, + restartPolicy: container?.HostConfig?.RestartPolicy?.Name || '', + }; +} + +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 inventory = { + capabilities: {}, + checkouts: [], + containers: [], + dockerMan: [], + warnings: [], + }; + for (const line of String(output).slice(index + marker.length).trim().split(/\r?\n/)) { + if (!line) continue; + const [kind, ...parts] = line.split('\t'); + if (kind === 'H') { + inventory.capabilities = { + docker: parts[0] === 'true', + compose: parts[1] === 'true', + git: parts[2] === 'true', + tar: parts[3] === 'true', + checksum: parts[4] === 'true', + baseWritable: parts[5] === 'true', + composeVersion: decodeBase64(parts[6]), + platform: decodeBase64(parts[7]), + }; + } else if (kind === 'R' && parts.length >= 4) { + inventory.checkouts.push({ + root: decodeBase64(parts[0]), + remote: decodeBase64(parts[1]), + liveSha: parts[2] || '', + branch: decodeBase64(parts[3]), + }); + } else if (kind === 'C' && parts[0]) { + const parsed = safeJson(decodeBase64(parts[0]), null); + if (!parsed) continue; + if (Array.isArray(parsed)) { + if (parsed[0]) inventory.containers.push(sanitizeLegacyContainer(parsed[0])); + } else if (parsed.Config || parsed.State) inventory.containers.push(sanitizeLegacyContainer(parsed)); + else inventory.containers.push({ + ...parsed, + name: String(parsed.name || '').replace(/^\//, ''), + labels: parsed.labels && typeof parsed.labels === 'object' ? parsed.labels : {}, + mounts: Array.isArray(parsed.mounts) ? parsed.mounts : [], + ports: parsed.ports && typeof parsed.ports === 'object' ? parsed.ports : {}, + networks: parsed.networks && typeof parsed.networks === 'object' ? parsed.networks : {}, + }); + } else if (kind === 'D' && parts[0]) { + inventory.dockerMan.push({ + name: decodeBase64(parts[0]), + templatePath: decodeBase64(parts[1]), + webUiUrl: decodeBase64(parts[2]), + iconUrl: decodeBase64(parts[3]), + shell: decodeBase64(parts[4]), + repository: decodeBase64(parts[5]), + network: decodeBase64(parts[6]), + }); + } else if (kind === 'W') inventory.warnings.push(decodeBase64(parts[0])); + } + return inventory; +} + +function configFilesFor(container) { + return String(container?.labels?.['com.docker.compose.project.config_files'] || '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +function containerPorts(container) { + const ports = []; + for (const [containerKey, bindings] of Object.entries(container?.ports || {})) { + const [containerPortText, protocol = 'tcp'] = containerKey.split('/'); + const containerPort = Number(containerPortText) || null; + if (Array.isArray(bindings) && bindings.length) { + for (const binding of bindings) ports.push({ + hostIp: binding?.HostIp || '', + hostPort: Number(binding?.HostPort) || null, + containerPort, + protocol, + }); + } else ports.push({ hostIp: '', hostPort: null, containerPort, protocol }); + } + return ports; +} + +function safeRelativeToBase(basePath, candidate) { + const base = String(basePath || '').replace(/\/+$/, ''); + const value = String(candidate || '').replace(/\/+$/, ''); + if (!base || !value || !value.startsWith(`${base}/`)) return ''; + const relative = value.slice(base.length + 1).replace(/^\/+|\/+$/g, ''); + if (!relative || relative.split('/').some((part) => !part || part === '.' || part === '..')) return ''; + return relative; +} + +function topLevelRelativeToBase(basePath, candidate) { + const relative = safeRelativeToBase(basePath, candidate); + return relative ? relative.split('/')[0] : ''; +} + +function workloadSelector(group) { + if (group.composeProject) return { + kind: 'compose', + composeProject: group.composeProject, + workingDir: group.workingDir || '', + configFiles: group.configFiles, + }; + const dockerMan = group.dockerMan || null; + if (dockerMan?.templatePath) return { + kind: 'dockerman-container', + templatePath: dockerMan.templatePath, + containerName: group.containers[0]?.name || '', + }; + return { kind: 'docker-container', containerName: group.containers[0]?.name || '' }; +} + +function stableWorkloadId(serverId, selector) { + return `workload-${crypto.createHash('sha256').update(`${serverId}:${JSON.stringify(selector)}`).digest('hex').slice(0, 24)}`; +} + +function profileMatchesWorkload(profile, workload) { + if (!profile || profile.provider !== 'ssh-unraid' || profile.serverId !== workload.serverId) return false; + const identity = profile.workloadIdentity || {}; + if (identity.workloadId && identity.workloadId === workload.workloadId) return true; + if (identity.selector && JSON.stringify(identity.selector) === JSON.stringify(workload.selector)) return true; + if (profile.composeProject && workload.compose?.project && profile.composeProject === workload.compose.project) { + if (!profile.composeWorkingDir || !workload.compose.workingDir || profile.composeWorkingDir === workload.compose.workingDir) return true; + } + return workload.containers.some((container) => container.name === profile.containerName); +} + +function repositoryRemoteMap(repositories) { + const map = new Map(); + for (const repository of repositories || []) { + for (const value of [repository.cloneUrl, repository.sshUrl, repository.htmlUrl, repository.preferredCloneUrl]) { + const id = remoteIdentity(value); + if (id) map.set(id, repository); + } + } + return map; +} + +function candidateRepositories(workload, repositories, checkouts) { + const candidates = new Map(); + const add = (repository, points, reason, exact = false) => { + if (!repository?.fullName) return; + const current = candidates.get(repository.fullName) || { repositoryFullName: repository.fullName, repositoryName: repository.name, score: 0, exact: false, reasons: [] }; + current.score += points; + current.exact ||= exact; + if (reason && !current.reasons.includes(reason)) current.reasons.push(reason); + candidates.set(repository.fullName, current); + }; + const remotes = repositoryRemoteMap(repositories); + const exactRemoteHints = new Set(); + for (const container of workload.containers) { + const labels = container.labels || {}; + for (const value of [labels['tech.itworx.forgeflow.repository'], labels['org.opencontainers.image.source']]) { + const id = remoteIdentity(value); + if (id) exactRemoteHints.add(id); + } + } + for (const checkout of checkouts || []) { + const root = String(checkout.root || '').replace(/\/+$/, ''); + const matchesPath = root && (root === workload.compose.workingDir || workload.containers.some((container) => (container.mounts || []).some((mount) => { + const source = String(mount?.Source || '').replace(/\/+$/, ''); + return source === root || source.startsWith(`${root}/`); + }))); + if (matchesPath) { + const id = remoteIdentity(checkout.remote); + if (id) exactRemoteHints.add(id); + } + } + for (const id of exactRemoteHints) { + const repository = remotes.get(id); + if (repository) add(repository, 100, 'Exact repository provenance from container or server checkout', true); + } + const names = new Set([ + workload.compose.project, + path.basename(workload.compose.workingDir || ''), + ...workload.containers.map((container) => container.name), + ...workload.containers.map((container) => String(container.image || '').split('/').pop()?.split(':')[0]), + ].filter(Boolean).map(normalizedName)); + for (const repository of repositories || []) { + const repoName = normalizedName(repository.name); + if (!repoName || !names.has(repoName)) continue; + add(repository, workload.compose.project && normalizedName(workload.compose.project) === repoName ? 35 : 20, 'Name similarity only; manual confirmation required'); + } + return [...candidates.values()].sort((a, b) => b.score - a.score || a.repositoryFullName.localeCompare(b.repositoryFullName)).map((candidate) => ({ + ...candidate, + confidence: candidate.exact ? 'exact' : candidate.score >= 35 ? 'strong' : 'weak', + })); +} + +function buildWorkloadInventory({ inventory, server, repositories = [], profiles = [] }) { + const dockerManByName = new Map((inventory.dockerMan || []).map((item) => [item.name, item])); + const groups = new Map(); + for (const container of inventory.containers || []) { + const labels = container.labels || {}; + const composeProject = String(labels['com.docker.compose.project'] || '').trim(); + const workingDir = String(labels['com.docker.compose.project.working_dir'] || '').replace(/\/+$/, ''); + const configFiles = configFilesFor(container); + const key = composeProject + ? `compose:${composeProject}:${workingDir}:${configFiles.join('|')}` + : `container:${container.name}`; + const group = groups.get(key) || { + composeProject, + workingDir, + configFiles, + services: [], + containers: [], + dockerMan: null, + }; + group.containers.push(container); + const service = String(labels['com.docker.compose.service'] || '').trim(); + if (service && !group.services.includes(service)) group.services.push(service); + group.dockerMan ||= dockerManByName.get(container.name) || null; + groups.set(key, group); + } + const workloads = []; + for (const group of groups.values()) { + const selector = workloadSelector(group); + const workloadId = stableWorkloadId(server.id, selector); + const primary = group.containers.find((item) => item.running) || group.containers[0]; + const ports = group.containers.flatMap(containerPorts); + const mounts = group.containers.flatMap((container) => container.mounts || []); + const remoteFolderCandidate = safeRelativeToBase(server.basePath, group.workingDir) + || mounts.map((mount) => topLevelRelativeToBase(server.basePath, mount?.Source)).find(Boolean) + || ''; + const workload = { + workloadId, + serverId: server.id, + serverName: server.name, + kind: selector.kind, + selector, + displayName: group.composeProject || primary?.name || 'Unnamed workload', + compose: { + project: group.composeProject, + workingDir: group.workingDir, + configFiles: group.configFiles, + services: group.services, + }, + containers: group.containers.map((container) => ({ + id: container.id, + name: container.name, + image: container.image, + imageId: container.imageId, + running: container.running === true, + status: container.status || '', + health: container.health || null, + service: container.labels?.['com.docker.compose.service'] || '', + ports: containerPorts(container), + mounts: (container.mounts || []).map((mount) => ({ + type: mount?.Type || '', + source: mount?.Source || '', + target: mount?.Destination || '', + readOnly: mount?.RW === false, + })), + networks: Object.keys(container.networks || {}), + restartPolicy: container.restartPolicy || '', + })), + dockerMan: group.dockerMan, + metadata: { + webUiUrl: primary?.labels?.['net.unraid.docker.webui'] || group.dockerMan?.webUiUrl || '', + iconUrl: primary?.labels?.['net.unraid.docker.icon'] || group.dockerMan?.iconUrl || '', + shell: primary?.labels?.['net.unraid.docker.shell'] || group.dockerMan?.shell || '/bin/sh', + sourceRepository: primary?.labels?.['tech.itworx.forgeflow.repository'] || primary?.labels?.['org.opencontainers.image.source'] || '', + liveRevision: primary?.labels?.['tech.itworx.forgeflow.commit'] || primary?.labels?.['org.opencontainers.image.revision'] || '', + branch: primary?.labels?.['tech.itworx.forgeflow.branch'] || '', + }, + runtime: { + running: group.containers.some((container) => container.running === true), + allRunning: group.containers.every((container) => container.running === true), + health: group.containers.some((container) => container.health === 'unhealthy') + ? 'unhealthy' + : group.containers.length && group.containers.every((container) => container.health === 'healthy') + ? 'healthy' + : 'unverified', + ports, + }, + remoteFolderCandidate, + observedAt: new Date().toISOString(), + }; + const matchingCheckout = (inventory.checkouts || []).find((checkout) => { + const root = String(checkout.root || '').replace(/\/+$/, ''); + if (!root) return false; + if (root === workload.compose.workingDir) return true; + return mounts.some((mount) => { + const source = String(mount?.Source || '').replace(/\/+$/, ''); + return source === root || source.startsWith(`${root}/`); + }); + }); + if (matchingCheckout) { + workload.metadata.sourceRepository ||= matchingCheckout.remote || ''; + workload.metadata.liveRevision ||= matchingCheckout.liveSha || ''; + workload.metadata.branch ||= matchingCheckout.branch || ''; + } + workload.candidates = candidateRepositories(workload, repositories, inventory.checkouts || []); + const linked = profiles.find((profile) => profileMatchesWorkload(profile, workload)); + if (linked) { + workload.link = { + status: 'linked', + profileId: linked.id, + repositoryFullName: linked.repositoryFullName || linked._repositoryFullName || '', + source: linked.workloadIdentity?.linkSource || (linked.adoptedFromServer ? 'automatic' : 'manual'), + }; + workload.status = 'linked'; + } else if (workload.candidates.length === 1 && workload.candidates[0].exact) workload.status = 'exact-match'; + else if (workload.candidates.length) workload.status = workload.candidates[1]?.score === workload.candidates[0]?.score ? 'ambiguous' : 'suggested'; + else workload.status = 'unmatched'; + workloads.push(workload); + } + workloads.sort((a, b) => Number(b.runtime.running) - Number(a.runtime.running) || a.displayName.localeCompare(b.displayName)); + return workloads; +} + +function inventoryContainerMatch(checkout, repository, container) { + const safe = container?.Config || container?.State ? sanitizeLegacyContainer(container) : container; + if (!safe?.running) return 0; + const labels = safe.labels || {}; + const workingDir = String(labels['com.docker.compose.project.working_dir'] || '').replace(/\/$/, ''); + const source = remoteIdentity(labels['org.opencontainers.image.source'] || labels['tech.itworx.forgeflow.repository'] || ''); + const mounts = Array.isArray(safe.mounts) ? safe.mounts : []; + const root = String(checkout.root || '').replace(/\/$/, ''); + const name = String(safe.name || '').replace(/^\//, ''); + const project = String(labels['com.docker.compose.project'] || ''); + const expectedNames = new Set([repository.name, root.split('/').pop()].filter(Boolean).map(normalizedName)); + 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(normalizedName(project))) return 70; + if (expectedNames.has(normalizedName(name))) return 60; + return 0; +} + +module.exports = { + parseServerInventory, + buildWorkloadInventory, + inventoryContainerMatch, + remoteIdentity, + stableWorkloadId, + profileMatchesWorkload, + sanitizeLegacyContainer, + safeRelativeToBase, +}; diff --git a/src/main/ssh-service.cjs b/src/main/ssh-service.cjs index 33d9336..051d2e8 100644 --- a/src/main/ssh-service.cjs +++ b/src/main/ssh-service.cjs @@ -1,11 +1,12 @@ 'use strict'; -const fs = require('node:fs/promises'); +const fs = require('node:fs'); +const fsp = require('node:fs/promises'); const crypto = require('node:crypto'); const path = require('node:path').posix; -function loadSshClient() { - try { return require('ssh2').Client; } +function loadSshModule() { + try { return require('ssh2'); } catch { const error = new Error('The ssh2 dependency is not installed. Run npm install before configuring SSH deployments.'); error.code = 'SSH2_NOT_INSTALLED'; @@ -13,6 +14,10 @@ function loadSshClient() { } } +function loadSshClient() { + return loadSshModule().Client; +} + function fingerprintKey(key) { const buffer = Buffer.isBuffer(key) ? key : Buffer.from(key); return `SHA256:${crypto.createHash('sha256').update(buffer).digest('base64').replace(/=+$/, '')}`; @@ -22,12 +27,65 @@ function shellQuote(value) { return `'${String(value ?? '').replace(/'/g, `'\\''`)}'`; } +function parseCapabilityOutput(output) { + const marker = '__FORGEFLOW_SERVER_TEST__'; + const index = String(output || '').lastIndexOf(marker); + if (index < 0) return { platform: String(output || '').trim(), docker: false, dockerReady: false, compose: false, git: false, tar: false, checksum: false }; + const fields = {}; + for (const line of String(output).slice(index + marker.length).trim().split(/\r?\n/)) { + const separator = line.indexOf('='); + if (separator > 0) fields[line.slice(0, separator)] = line.slice(separator + 1); + } + const decode = (value) => { + try { return value ? Buffer.from(value, 'base64').toString('utf8') : ''; } + catch { return ''; } + }; + return { + platform: decode(fields.platform), + docker: fields.docker === 'true', + dockerReady: fields.dockerReady === 'true', + compose: fields.compose === 'true', + composeVersion: decode(fields.composeVersion), + git: fields.git === 'true', + tar: fields.tar === 'true', + checksum: fields.checksum === 'true', + baseWritable: fields.baseWritable === 'true', + }; +} + class SshService { constructor({ store, diagnostics }) { this.store = store; this.diagnostics = diagnostics; } + async validateServerConfiguration(server, secrets = {}) { + if (server?.authType !== 'privateKey') return { valid: true, method: 'password' }; + const privateKeyPath = String(server.privateKeyPath || '').trim(); + if (!privateKeyPath) throw new Error('Select a private key file.'); + const stat = await fsp.stat(privateKeyPath).catch(() => null); + if (!stat?.isFile()) { + const error = new Error(`The SSH private key file was not found: ${privateKeyPath}`); + error.code = 'SSH_PRIVATE_KEY_NOT_FOUND'; + throw error; + } + const existing = server.id ? this.store.getServer(server.id) : null; + const sameKey = existing && String(existing.privateKeyPath || '') === privateKeyPath; + const storedPassphrase = sameKey ? this.store.getServerCredentials(existing.id).passphrase : ''; + const passphrase = Object.prototype.hasOwnProperty.call(secrets, 'passphrase') && String(secrets.passphrase || '') + ? String(secrets.passphrase) + : storedPassphrase; + const key = await fsp.readFile(privateKeyPath); + const parsed = loadSshModule().utils.parseKey(key, passphrase || undefined); + const errorResult = Array.isArray(parsed) ? parsed.find((item) => item instanceof Error) : parsed instanceof Error ? parsed : null; + if (errorResult) { + const error = new Error(`The selected file is not a usable SSH private key${passphrase ? ' with the supplied passphrase' : ''}: ${errorResult.message}`); + error.code = /encrypted|passphrase|decrypt/i.test(errorResult.message) ? 'SSH_PRIVATE_KEY_PASSPHRASE_INVALID' : 'SSH_PRIVATE_KEY_INVALID'; + throw error; + } + return { valid: true, method: 'privateKey', encrypted: Boolean(passphrase), privateKeyPath }; + } + async connectionOptions(server, { trustOnFirstUse = false } = {}) { const credentials = this.store.getServerCredentials(server.id); let observedFingerprint = null; @@ -41,12 +99,16 @@ class SshService { hostVerifier: (key) => { observedFingerprint = fingerprintKey(key); return trustOnFirstUse || Boolean(server.hostFingerprint && observedFingerprint === server.hostFingerprint); - } + }, }; - if (server.authType === 'password') { - options.password = credentials.password; - } else { - options.privateKey = await fs.readFile(server.privateKeyPath); + if (server.authType === 'password') options.password = credentials.password; + else { + try { options.privateKey = await fsp.readFile(server.privateKeyPath); } + catch (error) { + const wrapped = new Error(`Could not read SSH private key ${server.privateKeyPath}: ${error.message}`); + wrapped.code = 'SSH_PRIVATE_KEY_READ_FAILED'; + throw wrapped; + } if (credentials.passphrase) options.passphrase = credentials.passphrase; } return { options, getObservedFingerprint: () => observedFingerprint }; @@ -70,24 +132,20 @@ class SshService { client.once('ready', async () => { try { const data = await action(client, server, connection.getObservedFingerprint()); - await this.diagnostics?.debug('ssh.connection.completed', { - serverId, - host: server.host, - durationMs: Date.now() - started - }); + await this.diagnostics?.debug('ssh.connection.completed', { serverId, host: server.host, durationMs: Date.now() - started }); finish(resolve, data); } catch (error) { finish(reject, error); } }); client.once('error', async (error) => { - const wrapped = new Error(`SSH connection failed: ${error.message}`); - wrapped.code = error.code || 'SSH_CONNECTION_FAILED'; - await this.diagnostics?.warning('ssh.connection.failed', { - serverId, - host: server.host, - durationMs: Date.now() - started, - code: wrapped.code, - message: wrapped.message - }); + const observed = connection.getObservedFingerprint(); + const mismatch = Boolean(server.hostFingerprint && observed && server.hostFingerprint !== observed); + const wrapped = new Error(mismatch + ? `SSH host identity changed. Expected ${server.hostFingerprint}, but the server presented ${observed}.` + : `SSH connection failed: ${error.message}`); + wrapped.code = mismatch ? 'SSH_HOST_KEY_MISMATCH' : (error.code || 'SSH_CONNECTION_FAILED'); + wrapped.expectedFingerprint = mismatch ? server.hostFingerprint : undefined; + wrapped.observedFingerprint = mismatch ? observed : undefined; + await this.diagnostics?.warning('ssh.connection.failed', { serverId, host: server.host, durationMs: Date.now() - started, code: wrapped.code, message: wrapped.message }); finish(reject, wrapped); }); client.connect(connection.options); @@ -96,96 +154,166 @@ class SshService { execClient(client, command, { timeout = 15 * 60_000, maxOutput = 2 * 1024 * 1024 } = {}) { return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('The SSH command timed out.')), timeout); + let completed = false; + const timer = setTimeout(() => { + if (completed) return; + completed = true; + reject(new Error('The SSH command timed out.')); + }, timeout); client.exec(command, (error, stream) => { if (error) { clearTimeout(timer); + completed = true; reject(error); return; } let stdout = ''; let stderr = ''; - stream.on('data', (chunk) => { if (stdout.length < maxOutput) stdout += chunk.toString(); }); - stream.stderr.on('data', (chunk) => { if (stderr.length < maxOutput) stderr += chunk.toString(); }); + let stdoutBytes = 0; + let stderrBytes = 0; + let truncated = false; + const append = (target, chunk) => { + const text = chunk.toString(); + const bytes = Buffer.byteLength(text); + if (target === 'stdout') { + if (stdoutBytes + bytes <= maxOutput) stdout += text; + else truncated = true; + stdoutBytes += bytes; + } else { + if (stderrBytes + bytes <= maxOutput) stderr += text; + else truncated = true; + stderrBytes += bytes; + } + }; + stream.on('data', (chunk) => append('stdout', chunk)); + stream.stderr.on('data', (chunk) => append('stderr', chunk)); stream.on('close', (code, signal) => { + if (completed) return; + completed = true; clearTimeout(timer); - if (code !== 0) { + if (truncated) { + const failure = new Error(`Remote command output exceeded the ${maxOutput}-byte safety limit. ForgeFlow refused to use an incomplete result.`); + failure.code = 'SSH_OUTPUT_TRUNCATED'; + failure.stdoutBytes = stdoutBytes; + failure.stderrBytes = stderrBytes; + reject(failure); + } else if (code !== 0) { const failure = new Error(`Remote command failed with exit code ${code}: ${(stderr || stdout).trim().slice(-4000)}`); failure.code = 'SSH_COMMAND_FAILED'; failure.exitCode = code; failure.signal = signal; reject(failure); - } else resolve({ stdout, stderr, exitCode: code }); + } else resolve({ stdout, stderr, exitCode: code, truncated: false }); }); }); }); } - async uploadBuffer(serverId, remotePath, content, { mode = 0o600 } = {}) { + ensureUploadTarget(target) { + const normalized = String(target || '').replace(/\\/g, '/'); + if (!normalized.startsWith('/') || normalized.includes('\0') || normalized.split('/').includes('..')) throw new Error('Remote upload path must be an absolute safe Unix path.'); + return normalized; + } + + async withSftp(serverId, remotePath, action) { const server = this.store.getServer(serverId); if (!server?.hostFingerprint) { const error = new Error('Test and trust the SSH server fingerprint before uploading deployment assets.'); error.code = 'SSH_HOST_NOT_TRUSTED'; throw error; } - const target = String(remotePath || '').replace(/\\/g, '/'); - if (!target.startsWith('/') || target.includes('\0') || target.split('/').includes('..')) throw new Error('Remote upload path must be an absolute safe Unix path.'); - const data = Buffer.isBuffer(content) ? content : Buffer.from(content); + const target = this.ensureUploadTarget(remotePath); return this.withClient(serverId, (client) => new Promise((resolve, reject) => { client.sftp((sftpError, sftp) => { if (sftpError) { reject(sftpError); return; } - const directory = path.dirname(target); - const mkdirParts = directory.split('/').filter(Boolean); + const parts = path.dirname(target).split('/').filter(Boolean); let current = ''; - const makeNext = (index) => { - if (index >= mkdirParts.length) { - const stream = sftp.createWriteStream(target, { mode }); - stream.once('error', reject); - stream.once('close', () => resolve({ remotePath: target, size: data.length })); - stream.end(data); + const ensureNext = (index) => { + if (index >= parts.length) { + Promise.resolve(action(sftp, target)).then(resolve, reject); return; } - current += `/${mkdirParts[index]}`; - const ensureDirectory = () => { - sftp.stat(current, (statError, attributes) => { - if (!statError) { - if (typeof attributes?.isDirectory === 'function' && !attributes.isDirectory()) { - reject(new Error(`Remote upload parent exists but is not a directory: ${current}`)); - return; - } - makeNext(index + 1); - return; - } - if (![2, 'ENOENT'].includes(statError.code)) { reject(statError); return; } - sftp.mkdir(current, { mode: 0o755 }, (mkdirError) => { - if (!mkdirError) { makeNext(index + 1); return; } - sftp.stat(current, (retryError, retryAttributes) => { - if (!retryError && (typeof retryAttributes?.isDirectory !== 'function' || retryAttributes.isDirectory())) makeNext(index + 1); - else reject(mkdirError); - }); + current += `/${parts[index]}`; + sftp.stat(current, (statError, attributes) => { + if (!statError) { + if (typeof attributes?.isDirectory === 'function' && !attributes.isDirectory()) { reject(new Error(`Remote upload parent exists but is not a directory: ${current}`)); return; } + ensureNext(index + 1); + return; + } + if (![2, 'ENOENT'].includes(statError.code)) { reject(statError); return; } + sftp.mkdir(current, { mode: 0o755 }, (mkdirError) => { + if (!mkdirError) { ensureNext(index + 1); return; } + sftp.stat(current, (retryError, retryAttributes) => { + if (!retryError && (typeof retryAttributes?.isDirectory !== 'function' || retryAttributes.isDirectory())) ensureNext(index + 1); + else reject(mkdirError); }); }); - }; - ensureDirectory(); + }); }; - makeNext(0); + ensureNext(0); }); }), { trustOnFirstUse: false }); } - async uploadFile(serverId, localPath, remotePath, options = {}) { - const data = await fs.readFile(localPath); - return this.uploadBuffer(serverId, remotePath, data, options); + async uploadBuffer(serverId, remotePath, content, { mode = 0o600 } = {}) { + const data = Buffer.isBuffer(content) ? content : Buffer.from(content); + return this.withSftp(serverId, remotePath, (sftp, target) => new Promise((resolve, reject) => { + const stream = sftp.createWriteStream(target, { mode }); + stream.once('error', reject); + stream.once('close', () => resolve({ remotePath: target, size: data.length })); + stream.end(data); + })); + } + + async uploadFile(serverId, localPath, remotePath, { mode = 0o600, onProgress = null } = {}) { + const stat = await fsp.stat(localPath); + if (!stat.isFile()) throw new Error(`Local upload source is not a file: ${localPath}`); + return this.withSftp(serverId, remotePath, (sftp, target) => new Promise((resolve, reject) => { + const options = { + mode, + step: (totalTransferred, _chunk, total) => onProgress?.({ transferred: totalTransferred, total: total || stat.size }), + }; + sftp.fastPut(localPath, target, options, (error) => { + if (error) { reject(error); return; } + resolve({ remotePath: target, size: stat.size }); + }); + })); } async test(serverId, { trustOnFirstUse = true } = {}) { return this.withClient(serverId, async (client, server, fingerprint) => { - const result = await this.execClient(client, 'uname -srm && command -v git && (docker compose version || docker-compose version)', { timeout: 30_000 }); + const script = ` +platform=$(uname -srm 2>/dev/null || true) +docker=false; docker_ready=false; compose=false; compose_version=''; git=false; tar_ok=false; checksum=false; base_writable=false +command -v docker >/dev/null 2>&1 && docker=true +[ "$docker" = true ] && docker info >/dev/null 2>&1 && docker_ready=true +if [ "$docker" = true ]; then + if docker compose version >/dev/null 2>&1; then compose=true; compose_version=$(docker compose version 2>/dev/null | head -n1); elif command -v docker-compose >/dev/null 2>&1; then compose=true; compose_version=$(docker-compose version 2>/dev/null | head -n1); fi +fi +command -v git >/dev/null 2>&1 && git=true +command -v tar >/dev/null 2>&1 && tar_ok=true +(command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1) && checksum=true +base=${shellQuote(server.basePath)} +if [ -d "$base" ]; then [ -w "$base" ] && base_writable=true; else parent=$(dirname "$base"); [ -d "$parent" ] && [ -w "$parent" ] && base_writable=true; fi +printf '__FORGEFLOW_SERVER_TEST__\\n' +printf 'platform=%s\\n' "$(printf '%s' "$platform" | base64 | tr -d '\\r\\n')" +printf 'docker=%s\\n' "$docker" +printf 'dockerReady=%s\\n' "$docker_ready" +printf 'compose=%s\\n' "$compose" +printf 'composeVersion=%s\\n' "$(printf '%s' "$compose_version" | base64 | tr -d '\\r\\n')" +printf 'git=%s\\n' "$git" +printf 'tar=%s\\n' "$tar_ok" +printf 'checksum=%s\\n' "$checksum" +printf 'baseWritable=%s\\n' "$base_writable" +`; + const result = await this.execClient(client, script, { timeout: 30_000, maxOutput: 256 * 1024 }); + const capabilities = parseCapabilityOutput(result.stdout); return { connected: true, fingerprint, server: { id: server.id, name: server.name, host: server.host, basePath: server.basePath }, - output: result.stdout.trim() + capabilities, + output: [capabilities.platform, capabilities.composeVersion].filter(Boolean).join('\n'), }; }, { trustOnFirstUse }); } @@ -201,4 +329,4 @@ class SshService { } } -module.exports = { SshService, shellQuote, fingerprintKey }; +module.exports = { SshService, shellQuote, fingerprintKey, parseCapabilityOutput }; diff --git a/src/main/unraid-deployment-service.cjs b/src/main/unraid-deployment-service.cjs index 6cf0be1..511ba2a 100644 --- a/src/main/unraid-deployment-service.cjs +++ b/src/main/unraid-deployment-service.cjs @@ -1,17 +1,29 @@ "use strict"; const fs = require("node:fs/promises"); +const fileSystem = require("node:fs"); const path = require("node:path").posix; const nativePath = require("node:path"); const crypto = require("node:crypto"); +const os = require("node:os"); const { shellQuote } = require("./ssh-service.cjs"); const { assertFullCommitSha } = require("../shared/validation.cjs"); const { normalizeRemoteUrl } = require("../shared/repository-match.cjs"); +const { run } = require("./process-runner.cjs"); +const { + parseServerInventory: parseWorkloadInventory, + buildWorkloadInventory, + inventoryContainerMatch: matchInventoryContainer, + remoteIdentity: inventoryRemoteIdentity, +} = require("./server-inventory.cjs"); function safeRemoteFolder(value) { - const text = String(value || "").trim(); - if (!/^[a-zA-Z0-9._-]+$/.test(text) || text === "." || text === "..") - throw new Error("Remote folder contains unsupported characters."); + const text = String(value || "").trim().replace(/\\/g, "/").replace(/^\.\//, ""); + if ( + !text || + path.isAbsolute(text) || + text.split("/").some((part) => !part || part === "." || part === ".." || !/^[a-zA-Z0-9._-]+$/.test(part)) + ) throw new Error("Remote folder must be a safe path below the configured server base path."); return text; } @@ -30,7 +42,16 @@ function safeRelativeRemoteFile(value, fallback = "") { } function bash(command) { - const script = `set -euo pipefail\nexport GIT_TERMINAL_PROMPT=0\nexport GIT_SSH_COMMAND='ssh -o BatchMode=yes'\n${command}`; + const script = `set -euo pipefail +export GIT_TERMINAL_PROMPT=0 +export GIT_SSH_COMMAND='ssh -o BatchMode=yes' +forgeflow_compose() { + if docker compose version >/dev/null 2>&1; then docker compose "$@"; + elif command -v docker-compose >/dev/null 2>&1; then docker-compose "$@"; + else echo "Docker Compose is not available on the server." >&2; return 127; + fi +} +${command}`; const payload = Buffer.from(script, "utf8").toString("base64"); return `printf '%s' ${shellQuote(payload)} | base64 -d | bash`; } @@ -349,86 +370,6 @@ 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, @@ -448,14 +389,23 @@ class UnraidDeploymentService { 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 = ` + inventoryScript(server) { + return ` base=${shellQuote(server.basePath)} +platform=$(uname -srm 2>/dev/null || true) +docker_ok=false; compose_ok=false; git_ok=false; tar_ok=false; checksum_ok=false; base_writable=false; compose_version='' +command -v docker >/dev/null 2>&1 && docker_ok=true +if [ "$docker_ok" = true ]; then + if docker compose version >/dev/null 2>&1; then compose_ok=true; compose_version=$(docker compose version 2>/dev/null | head -n1); elif command -v docker-compose >/dev/null 2>&1; then compose_ok=true; compose_version=$(docker-compose version 2>/dev/null | head -n1); fi +fi +command -v git >/dev/null 2>&1 && git_ok=true +command -v tar >/dev/null 2>&1 && tar_ok=true +(command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1) && checksum_ok=true +if [ -d "$base" ]; then [ -w "$base" ] && base_writable=true; else parent=$(dirname "$base"); [ -d "$parent" ] && [ -w "$parent" ] && base_writable=true; fi 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 +printf 'H\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' "$docker_ok" "$compose_ok" "$git_ok" "$tar_ok" "$checksum_ok" "$base_writable" "$(printf '%s' "$compose_version" | base64 | tr -d '\\r\\n')" "$(printf '%s' "$platform" | base64 | tr -d '\\r\\n')" +if [ "$git_ok" = true ] && [ -d "$base" ]; then + find "$base" -mindepth 2 -maxdepth 7 -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) @@ -464,182 +414,233 @@ if [ -d "$base" ]; then 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')" +if [ "$docker_ok" != true ]; then + printf 'W\\t%s\\n' "$(printf '%s' 'Docker is not installed or not in PATH.' | base64 | tr -d '\\r\\n')" + exit 0 +fi +ids=$(docker ps -aq --no-trunc 2>&1) || { + printf 'W\\t%s\\n' "$(printf '%s' "Docker inventory failed: $ids" | base64 | tr -d '\\r\\n')" + exit 0 +} +for id in $ids; do + summary=$(docker inspect --format '{"id":{{json .Id}},"name":{{json .Name}},"image":{{json .Config.Image}},"imageId":{{json .Image}},"running":{{json .State.Running}},"status":{{json .State.Status}},"health":{{if .State.Health}}{{json .State.Health.Status}}{{else}}null{{end}},"labels":{"com.docker.compose.project":{{json (index .Config.Labels "com.docker.compose.project")}},"com.docker.compose.project.working_dir":{{json (index .Config.Labels "com.docker.compose.project.working_dir")}},"com.docker.compose.project.config_files":{{json (index .Config.Labels "com.docker.compose.project.config_files")}},"com.docker.compose.service":{{json (index .Config.Labels "com.docker.compose.service")}},"org.opencontainers.image.source":{{json (index .Config.Labels "org.opencontainers.image.source")}},"org.opencontainers.image.revision":{{json (index .Config.Labels "org.opencontainers.image.revision")}},"tech.itworx.forgeflow.repository":{{json (index .Config.Labels "tech.itworx.forgeflow.repository")}},"tech.itworx.forgeflow.commit":{{json (index .Config.Labels "tech.itworx.forgeflow.commit")}},"tech.itworx.forgeflow.branch":{{json (index .Config.Labels "tech.itworx.forgeflow.branch")}},"net.unraid.docker.webui":{{json (index .Config.Labels "net.unraid.docker.webui")}},"net.unraid.docker.icon":{{json (index .Config.Labels "net.unraid.docker.icon")}},"net.unraid.docker.shell":{{json (index .Config.Labels "net.unraid.docker.shell")}},"net.unraid.docker.managed":{{json (index .Config.Labels "net.unraid.docker.managed")}}},"ports":{{json .NetworkSettings.Ports}},"mounts":{{json .Mounts}},"networks":{{json .NetworkSettings.Networks}},"restartPolicy":{{json .HostConfig.RestartPolicy.Name}}}' "$id" 2>/dev/null || true) + if [ -z "$summary" ]; then + printf 'W\\t%s\\n' "$(printf '%s' "Could not inspect container $id" | base64 | tr -d '\\r\\n')" + continue + fi + printf 'C\\t%s\\n' "$(printf '%s' "$summary" | base64 | tr -d '\\r\\n')" + name=$(docker inspect --format '{{.Name}}' "$id" 2>/dev/null | sed 's#^/##' || true) + template='' + if [ -n "$name" ] && [ -d /boot/config/plugins/dockerMan/templates-user ]; then + template=$(grep -ril --fixed-strings "$name" /boot/config/plugins/dockerMan/templates-user 2>/dev/null | head -n1 || true) + fi + if [ -n "$template" ]; then + read_tag() { sed -n "s#.*<$1>\\(.*\\).*#\\1#p" "$template" | head -n1; } + printf 'D\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \ + "$(printf '%s' "$name" | base64 | tr -d '\\r\\n')" \ + "$(printf '%s' "$template" | base64 | tr -d '\\r\\n')" \ + "$(printf '%s' "$(read_tag WebUI)" | base64 | tr -d '\\r\\n')" \ + "$(printf '%s' "$(read_tag Icon)" | base64 | tr -d '\\r\\n')" \ + "$(printf '%s' "$(read_tag Shell)" | base64 | tr -d '\\r\\n')" \ + "$(printf '%s' "$(read_tag Repository)" | base64 | tr -d '\\r\\n')" \ + "$(printf '%s' "$(read_tag Network)" | base64 | tr -d '\\r\\n')" + fi 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)); + } + + allSshProfiles() { + const result = []; + for (const [repositoryFullName, profiles] of Object.entries(this.store.data?.deploymentProfiles || {})) { + for (const profile of profiles || []) { + if (profile?.provider === "ssh-unraid") result.push({ ...profile, _repositoryFullName: repositoryFullName }); + } } - 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); + return result; + } + + relativeComposeFiles(workload) { + const workingDir = String(workload.compose?.workingDir || "").replace(/\/+$/, ""); + const files = (workload.compose?.configFiles || []).map((file) => { + const value = String(file || "").trim(); + if (workingDir && value.startsWith(`${workingDir}/`)) return value.slice(workingDir.length + 1); + return value.startsWith("/") ? path.basename(value) : value; + }).filter(Boolean); + return [...new Set(files.length ? files : ["docker-compose.yml"])]; + } + + profileFromWorkload(repository, server, workload, { linkSource = "manual", deploymentMode = "push-bundle", remoteFolder = "" } = {}) { + const selectedFolder = safeRemoteFolder(remoteFolder || workload.remoteFolderCandidate || repository.name); + const composeFiles = this.relativeComposeFiles(workload); + const services = [...new Set((workload.compose?.services || []).filter(Boolean))]; + const primary = workload.containers.find((container) => container.running) || workload.containers[0] || {}; + const primaryPort = (primary.ports || []).find((item) => item.hostPort) || primary.ports?.[0] || {}; + const remotePath = path.join(server.basePath, selectedFolder); + const preservePaths = new Set([".env", "appdata", "data", "logs", "config", "compose.override.yml"]); + for (const container of workload.containers || []) { + for (const mount of container.mounts || []) { + const source = String(mount.source || ""); + if (!source.startsWith(`${remotePath}/`)) continue; + const relative = source.slice(remotePath.length + 1).split("/")[0]; + if (relative) preservePaths.add(relative); + } + } + const idPrefix = linkSource === "automatic" ? "auto" : "link"; + const profileId = `${idPrefix}-${crypto.createHash("sha256").update(`${server.id}:${repository.fullName}:${workload.workloadId}`).digest("hex").slice(0, 20)}`; + return { + id: profileId, + name: `${server.name} · ${workload.displayName}`, + environment: "production", + provider: "ssh-unraid", + branch: workload.metadata?.branch || repository.defaultBranch || "main", + serverId: server.id, + remoteFolder: selectedFolder, + deploymentMode, + composeFile: composeFiles[0], + composeFiles, + composeProject: workload.compose?.project || "", + composeWorkingDir: workload.compose?.workingDir || "", + composeService: services[0] || String(primary.service || selectedFolder.split("/").pop()).toLowerCase().replace(/[^a-z0-9._-]/g, "-") || "app", + composeServices: services.length ? services : [String(primary.service || selectedFolder.split("/").pop()).toLowerCase().replace(/[^a-z0-9._-]/g, "-") || "app"], + containerName: primary.name || selectedFolder.split("/").pop(), + cloneUrl: workload.metadata?.sourceRepository || repository.sshUrl || repository.cloneUrl || "", + alignRemote: false, + hostPort: primaryPort.hostPort || null, + containerPort: primaryPort.containerPort || null, + webUiUrl: workload.metadata?.webUiUrl || workload.dockerMan?.webUiUrl || "", + iconMode: "none", + iconUrl: "", + iconFilePath: "", + serverIconReference: workload.metadata?.iconUrl || workload.dockerMan?.iconUrl || "", + dockerShell: ["/bin/bash", "/bin/sh"].includes(workload.metadata?.shell) ? workload.metadata.shell : "/bin/sh", + preservePaths: [...preservePaths], + generatedCompose: false, + adoptedFromServer: true, + serverSourceOfTruth: true, + manageDockerMan: false, + forceRecreate: false, + removeOrphans: false, + workloadIdentity: { + workloadId: workload.workloadId, + selector: workload.selector, + linkSource, + linkedAt: new Date().toISOString(), + }, + detectedAt: new Date().toISOString(), + detectedMetadata: { + source: `${linkSource}-server-inventory`, + kind: workload.kind, + composeProject: workload.compose?.project || "", + composeFiles, + services, + image: primary.image || "", + dockerManTemplatePath: workload.dockerMan?.templatePath || "", + }, + confirmationRequired: true, + }; + } + + async saveWorkloadState(profile, workload, server) { + const candidateSha = String(workload.metadata?.liveRevision || ""); + const liveSha = /^[0-9a-f]{40,64}$/i.test(candidateSha) ? candidateSha.toLowerCase() : null; + const profileRemote = inventoryRemoteIdentity(profile.cloneUrl); + const workloadRemote = inventoryRemoteIdentity(workload.metadata?.sourceRepository); + const repositoryMatches = Boolean(liveSha && profileRemote && workloadRemote && profileRemote === workloadRemote); + const primary = workload.containers.find((container) => container.running) || workload.containers[0] || {}; + return this.store.saveDeploymentState(profile.id, { + liveSha, + healthy: workload.runtime.health === "healthy" ? true : workload.runtime.health === "unhealthy" ? false : null, + runtimeVerification: workload.runtime.health === "unverified" ? "running-unverified" : workload.runtime.health, + containerRunning: workload.runtime.running, + dockerHealth: primary.health || null, + containerName: primary.name || profile.containerName, + remotePath: path.join(server.basePath, profile.remoteFolder), + provider: "ssh-unraid", + workloadId: workload.workloadId, + composeProject: workload.compose?.project || null, + observedAt: workload.observedAt, + evidence: liveSha ? "container-provenance-label" : "runtime-only", + giteaSha: repositoryMatches ? liveSha : null, + matchesGitea: repositoryMatches, + }); + } + + async scanServerInventory(serverId, repositories, { autoLink = true } = {}) { + const server = this.store.getServer(serverId); + if (!server) throw new Error("The deployment server no longer exists."); + const result = await this.ssh.exec(server.id, bash(this.inventoryScript(server)), { + timeout: 90_000, + maxOutput: 16 * 1024 * 1024, + }); + const inventory = parseWorkloadInventory(result.stdout); + const workloads = buildWorkloadInventory({ + inventory, + server, + repositories, + profiles: this.allSshProfiles(), + }); + let adopted = 0; + if (autoLink) { + for (const workload of workloads) { + if (workload.status !== "exact-match" || workload.candidates.length !== 1 || !workload.candidates[0].exact) continue; + const repository = (repositories || []).find((item) => item.fullName === workload.candidates[0].repositoryFullName); + if (!repository) continue; + const profile = this.profileFromWorkload(repository, server, workload, { linkSource: "automatic", deploymentMode: "push-bundle" }); + const saved = await this.store.saveDeploymentProfile(repository.fullName, profile); + await this.saveWorkloadState(saved, workload, server); + workload.status = "linked"; + workload.link = { status: "linked", profileId: saved.id, repositoryFullName: repository.fullName, source: "automatic" }; + adopted += 1; } } const summary = { serverId, - detected: 0, - adopted: 0, - verified: 0, - unmatched: 0, + serverName: server.name, + detected: workloads.length, + adopted, + verified: workloads.filter((item) => item.runtime.health === "healthy" && item.link).length, + linked: workloads.filter((item) => item.status === "linked").length, + unmatched: workloads.filter((item) => !item.link).length, + needsReview: workloads.filter((item) => ["suggested", "ambiguous", "unmatched"].includes(item.status)).length, + running: workloads.filter((item) => item.runtime.running).length, + stopped: workloads.filter((item) => !item.runtime.running).length, + }; + const response = { + ...summary, + server: { id: server.id, name: server.name, host: server.host, basePath: server.basePath }, + capabilities: inventory.capabilities, + warnings: inventory.warnings, + workloads, + observedAt: new Date().toISOString(), }; - 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; + return response; } + async discoverServerWorkloads(serverId, repositories) { + return this.scanServerInventory(serverId, repositories, { autoLink: true }); + } + + async linkServerWorkload({ repository, serverId, workloadId, deploymentMode = "push-bundle", remoteFolder = "" }) { + const server = this.store.getServer(serverId); + if (!server) throw new Error("The deployment server no longer exists."); + const inventory = await this.scanServerInventory(serverId, [repository], { autoLink: false }); + const workload = inventory.workloads.find((item) => item.workloadId === workloadId); + if (!workload) throw new Error("The selected server workload no longer exists. Scan the server again."); + const existing = this.allSshProfiles().find((profile) => profile.workloadIdentity?.workloadId === workloadId && profile.serverId === serverId); + if (existing && String(existing._repositoryFullName).toLowerCase() !== String(repository.fullName).toLowerCase()) { + const error = new Error(`This workload is already linked to ${existing._repositoryFullName}. Remove or edit that link first.`); + error.code = "WORKLOAD_ALREADY_LINKED"; + throw error; + } + const profile = this.profileFromWorkload(repository, server, workload, { linkSource: "manual", deploymentMode, remoteFolder }); + const saved = await this.store.saveDeploymentProfile(repository.fullName, profile); + const state = await this.saveWorkloadState(saved, workload, server); + await this.diagnostics?.info("unraid.workload.linked", { serverId, workloadId, repository: repository.fullName, profileId: saved.id, deploymentMode }); + return { profile: saved, state, workload }; + } + + async saveOperation(operation) { const saved = await this.store.addOperation(operation); this.onOperationChange?.({ operations: [saved] }); @@ -671,89 +672,61 @@ done if (!server) throw new Error("The deployment server no longer exists."); const folder = safeRemoteFolder(remoteFolder || repository.name); const remotePath = path.join(server.basePath, folder); - if (!remotePath.startsWith(`${server.basePath}/`)) - throw new Error( - "Remote project path escapes the configured server base path.", - ); - const script = ` -root=${shellQuote(remotePath)} -test -d "$root" || { echo "Existing server folder not found: $root" >&2; exit 44; } -head=$(git -C "$root" rev-parse HEAD 2>/dev/null || true) -branch=$(git -C "$root" branch --show-current 2>/dev/null || true) -remote=$(git -C "$root" remote get-url origin 2>/dev/null || true) -compose_files=$(find "$root" -maxdepth 2 -type f \\( -name 'docker-compose.yml' -o -name 'docker-compose.yaml' -o -name 'compose.yml' -o -name 'compose.yaml' \\) -printf '%P\\n' 2>/dev/null | sort) -compose_file=$(printf '%s\\n' "$compose_files" | head -n1) -compose_json='{}' -container_json='[]' -if [ -n "$compose_file" ] && command -v docker >/dev/null 2>&1; then - compose_json=$(cd "$root" && docker compose -f "$compose_file" config --format json 2>/dev/null || printf '{}') - ids=$(cd "$root" && docker compose -f "$compose_file" ps -aq 2>/dev/null || true) - [ -n "$ids" ] && container_json=$(docker inspect $ids 2>/dev/null || printf '[]') -fi -container_name=$(printf '%s' "$container_json" | sed -n 's/.*"Name"[[:space:]]*:[[:space:]]*"\\/\\([^" ]*\\)".*/\\1/p' | head -n1) -dockerman_xml='' -if [ -n "$container_name" ] && [ -d /boot/config/plugins/dockerMan/templates-user ]; then - template=$(grep -ril "${container_name}" /boot/config/plugins/dockerMan/templates-user 2>/dev/null | head -n1 || true) - [ -n "$template" ] && dockerman_xml=$(cat "$template") -fi -printf '__FORGEFLOW_DISCOVERY__\\n' -printf 'head=%s\\n' "$head" -printf 'branch=%s\\n' "$branch" -printf 'remote=%s\\n' "$(printf '%s' "$remote" | base64 | tr -d '\\r\\n')" -printf 'composeFiles=%s\\n' "$(printf '%s\\n' "$compose_files" | base64 | tr -d '\\r\\n')" -printf 'compose=%s\\n' "$(printf '%s' "$compose_json" | base64 | tr -d '\\r\\n')" -printf 'containers=%s\\n' "$(printf '%s' "$container_json" | base64 | tr -d '\\r\\n')" -printf 'dockerManXml=%s\\n' "$(printf '%s' "$dockerman_xml" | base64 | tr -d '\\r\\n')" -`; - const result = await this.ssh.exec(server.id, bash(script), { - timeout: 90_000, - maxOutput: 8 * 1024 * 1024, - }); - const marker = "__FORGEFLOW_DISCOVERY__"; - const index = result.stdout.lastIndexOf(marker); - if (index < 0) - throw new Error("The server did not return deployment discovery data."); - const fields = {}; - for (const line of result.stdout - .slice(index + marker.length) - .trim() - .split(/\r?\n/)) { - const split = line.indexOf("="); - if (split > 0) fields[line.slice(0, split)] = line.slice(split + 1); + const inventory = await this.scanServerInventory(serverId, [repository], { autoLink: false }); + const workload = inventory.workloads.find((item) => + item.remoteFolderCandidate === folder || + item.compose?.workingDir === remotePath || + item.containers.some((container) => (container.mounts || []).some((mount) => { + const source = String(mount.source || "").replace(/\/+$/, ""); + return source === remotePath || source.startsWith(`${remotePath}/`); + })) + ); + if (!workload) { + const error = new Error(`No Docker or Compose workload could be matched to ${remotePath}. Use Server Inventory to select the running container directly.`); + error.code = "SERVER_WORKLOAD_NOT_FOUND"; + throw error; } - const payload = { - head: fields.head || null, - branch: fields.branch || null, - remote: fields.remote - ? Buffer.from(fields.remote, "base64").toString("utf8") - : "", - composeFiles: fields.composeFiles - ? Buffer.from(fields.composeFiles, "base64") - .toString("utf8") - .split(/\r?\n/) - .filter(Boolean) - : [], - compose: decodeBase64Json(fields.compose, {}), - containers: decodeBase64Json(fields.containers, []), - dockerManXml: fields.dockerManXml - ? Buffer.from(fields.dockerManXml, "base64").toString("utf8") - : "", - }; - const discovery = deriveDetectedProfile({ - repository, - server, + const profile = this.profileFromWorkload(repository, server, workload, { + linkSource: "manual", + deploymentMode: "push-bundle", remoteFolder: folder, - remotePath, - payload, }); - await this.diagnostics?.info("unraid.existing-discovered", { + const source = (value, origin, confidence = "confirmed") => ({ + value, + origin, + confidence, + detectedAt: new Date().toISOString(), + overridden: false, + }); + const provenance = { + remoteFolder: source(folder, "server-inventory"), + cloneUrl: source(profile.cloneUrl, workload.metadata?.sourceRepository ? "container-provenance" : "repository"), + branch: source(profile.branch, workload.metadata?.branch ? "container-provenance" : "repository"), + composeFile: source(profile.composeFile, "docker-compose-labels"), + composeService: source(profile.composeService, "docker-compose-labels"), + containerName: source(profile.containerName, "docker-inspect"), + hostPort: source(profile.hostPort, "docker-inspect"), + containerPort: source(profile.containerPort, "docker-inspect"), + webUiUrl: source(profile.webUiUrl, workload.dockerMan?.webUiUrl ? "unraid-dockerman" : "docker-labels"), + iconUrl: source(profile.serverIconReference, workload.dockerMan?.iconUrl ? "unraid-dockerman" : "docker-labels"), + dockerShell: source(profile.dockerShell, workload.dockerMan?.shell ? "unraid-dockerman" : "docker-labels"), + }; + return { repository: repository.fullName, - serverId, - remotePath, - containers: discovery.runtime.containers, - services: discovery.runtime.services, - }); - return discovery; + profile: { ...profile, id: undefined, provenance }, + provenance, + workload, + runtime: { + remotePath, + containerRunning: workload.runtime.running, + containers: workload.containers.length, + services: workload.compose?.services?.length || workload.containers.length, + ports: workload.runtime.ports, + mounts: workload.containers.flatMap((container) => container.mounts || []), + networks: [...new Set(workload.containers.flatMap((container) => container.networks || []))], + envNames: [], + }, + }; } async inspect({ repository, profileId }) { @@ -839,8 +812,25 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths" async preflight({ repository, profileId, sha = null }) { const { profile, server, remotePath } = this.resolve(repository, profileId); const targetSha = assertFullCommitSha(sha || repository.localStatus?.head); + const deploymentMode = profile.deploymentMode || "server-git"; const checks = []; let inspection = null; + let connectionCapabilities = null; + + if (deploymentMode === "monitor-only") checks.push({ + id: "deployment-mode", + label: "Deployment mode", + status: "fail", + detail: "This workload is linked for monitoring only. Edit the environment and select Push bundle or Server-side Git before deploying.", + }); + else checks.push({ + id: "deployment-mode", + label: "Deployment mode", + status: "pass", + detail: deploymentMode === "push-bundle" + ? "ForgeFlow uploads the exact local commit bundle; Unraid does not need a Gitea key." + : "Unraid fetches the exact commit from Gitea using its own Git credentials.", + }); if (!repository.localPath) { checks.push({ @@ -918,26 +908,24 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths" }); } - const localDeploymentFile = profile.generatedCompose - ? nativePath.join(repository.localPath, "Dockerfile") - : nativePath.join( - repository.localPath, - safeRelativeRemoteFile( - profile.composeFile || "docker-compose.yml", - ), + const localDeploymentFiles = profile.generatedCompose + ? [nativePath.join(repository.localPath, "Dockerfile")] + : this.deploymentComposeFiles(profile).map((file) => + nativePath.join(repository.localPath, safeRelativeRemoteFile(file)), ); - const localDeploymentFileExists = Boolean( - (await fs.stat(localDeploymentFile).catch(() => null))?.isFile(), - ); + const missingDeploymentFiles = []; + for (const file of localDeploymentFiles) { + if (!Boolean((await fs.stat(file).catch(() => null))?.isFile())) missingDeploymentFiles.push(file); + } checks.push({ id: "local-deployment-file", label: profile.generatedCompose ? "Dockerfile in repository" - : "Compose file in repository", - status: localDeploymentFileExists ? "pass" : "fail", - detail: localDeploymentFileExists - ? localDeploymentFile - : `${localDeploymentFile} was not found in the exact local checkout.`, + : localDeploymentFiles.length > 1 ? "Compose files in repository" : "Compose file in repository", + status: missingDeploymentFiles.length ? "fail" : "pass", + detail: missingDeploymentFiles.length + ? `Missing from the exact local checkout: ${missingDeploymentFiles.join(", ")}` + : localDeploymentFiles.join(", "), }); } catch (error) { checks.push({ @@ -950,94 +938,107 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths" } try { - const connection = await this.ssh.test(server.id, { - trustOnFirstUse: false, - }); + const connection = await this.ssh.test(server.id, { trustOnFirstUse: false }); + connectionCapabilities = connection.capabilities || {}; checks.push({ id: "ssh", - label: "SSH connection", + label: "Desktop → Unraid SSH", status: "pass", detail: `${server.username}@${server.host}:${server.port}`, }); - if (!/docker compose|docker-compose/i.test(connection.output)) { + checks.push({ + id: "docker-runtime", + label: "Docker runtime", + status: connectionCapabilities.docker && connectionCapabilities.dockerReady ? "pass" : "fail", + detail: connectionCapabilities.dockerReady + ? "Docker is reachable by the configured SSH user." + : connectionCapabilities.docker + ? "Docker is installed, but the configured SSH user cannot query the daemon." + : "Docker was not detected on the server.", + }); + checks.push({ + id: "compose-command", + label: "Docker Compose", + status: connectionCapabilities.compose ? "pass" : "fail", + detail: connectionCapabilities.composeVersion || "Docker Compose was not detected on the server.", + }); + if (deploymentMode === "push-bundle") { checks.push({ - id: "compose-command", - label: "Docker Compose", - status: "fail", - detail: "Docker Compose was not detected on the server.", + id: "bundle-tools", + label: "Bundle extraction and checksum tools", + status: connectionCapabilities.tar && connectionCapabilities.checksum ? "pass" : "fail", + detail: connectionCapabilities.tar && connectionCapabilities.checksum + ? "tar and a SHA-256 checksum tool are available." + : `tar ${connectionCapabilities.tar ? "available" : "missing"}; checksum tool ${connectionCapabilities.checksum ? "available" : "missing"}.`, }); - } else checks.push({ - id: "compose-command", - label: "Docker Compose", - status: "pass", - detail: "Docker Compose is available.", + id: "server-base-writable", + label: "Deployment storage writable", + status: connectionCapabilities.baseWritable ? "pass" : "fail", + detail: connectionCapabilities.baseWritable ? `${server.basePath} is writable.` : `${server.basePath} cannot be created or written by this SSH user.`, }); + } else { + checks.push({ + id: "server-git-command", + label: "Git on Unraid", + status: connectionCapabilities.git ? "pass" : "fail", + detail: connectionCapabilities.git ? "Git is available for server-side fetches." : "Git is required only for Server-side Git mode and was not detected.", + }); + } } catch (error) { checks.push({ id: "ssh", - label: "SSH connection", + label: "Desktop → Unraid SSH", status: "fail", detail: error.message, }); } - if (!server.hostFingerprint) - checks.push({ - id: "host-key", - label: "Server identity", - status: "fail", - detail: "Test and trust the SSH host key first.", - }); - else - checks.push({ - id: "host-key", - label: "Server identity", - status: "pass", - detail: server.hostFingerprint, - }); + if (!server.hostFingerprint) checks.push({ + id: "host-key", + label: "Server identity", + status: "fail", + detail: "Test and trust the SSH host key first.", + }); + else checks.push({ + id: "host-key", + label: "Server identity", + status: "pass", + detail: server.hostFingerprint, + }); - const cloneUrl = String( - profile.cloneUrl || - repository.sshUrl || - repository.preferredCloneUrl || - "", - ).trim(); - if (!cloneUrl) { - checks.push({ + if (deploymentMode === "server-git") { + const cloneUrl = String(profile.cloneUrl || repository.sshUrl || repository.preferredCloneUrl || "").trim(); + if (!cloneUrl) checks.push({ id: "server-git-access", label: "Unraid → Gitea access", status: "fail", detail: "No server-usable Git clone URL is configured.", }); - } else { - try { - const branchRef = `refs/heads/${String(profile.branch || "main")}`; - const probe = await this.ssh.exec( - server.id, - bash( - `git ls-remote --exit-code ${shellQuote(cloneUrl)} ${shellQuote(branchRef)}`, - ), - { timeout: 45_000, maxOutput: 256 * 1024 }, - ); - const remoteSha = - String(probe.stdout || "") - .trim() - .split(/\s+/)[0] || "reachable"; - checks.push({ - id: "server-git-access", - label: "Unraid → Gitea access", - status: "pass", - detail: `${cloneUrl} · ${String(remoteSha).slice(0, 7)}`, - }); - } catch (error) { - checks.push({ - id: "server-git-access", - label: "Unraid → Gitea access", - status: "fail", - detail: `Unraid cannot read the repository with the configured clone URL: ${error.message}`, - }); + else { + try { + const branchRef = `refs/heads/${String(profile.branch || "main")}`; + const probe = await this.ssh.exec(server.id, bash(`git ls-remote --exit-code ${shellQuote(cloneUrl)} ${shellQuote(branchRef)}`), { timeout: 45_000, maxOutput: 256 * 1024 }); + const remoteSha = String(probe.stdout || "").trim().split(/\s+/)[0] || "reachable"; + checks.push({ id: "server-git-access", label: "Unraid → Gitea access", status: "pass", detail: `${cloneUrl} · ${String(remoteSha).slice(0, 7)}` }); + } catch (error) { + const message = String(error.message || error); + const reason = /host key verification/i.test(message) + ? "Gitea host key is not trusted by Unraid." + : /permission denied|publickey|authentication/i.test(message) + ? "The Unraid Git key was rejected by Gitea." + : /not found|repository.*does not exist|access denied/i.test(message) + ? "The repository is missing or the Unraid key has no repository access." + : `Unraid cannot read the repository: ${message}`; + checks.push({ id: "server-git-access", label: "Unraid → Gitea access", status: "fail", detail: reason }); + } } - } + } else checks.push({ + id: "server-git-access", + label: "Unraid → Gitea access", + status: "pass", + detail: "Not required in Push bundle mode. The trusted desktop SSH connection transfers the release files.", + }); + try { inspection = await this.inspect({ repository, profileId }); @@ -1048,37 +1049,42 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths" status: "pass", detail: `${remotePath} will be created.`, }); - } else if (!inspection.rootGit) { + } else if (deploymentMode === "server-git" && !inspection.rootGit) { checks.push({ id: "remote-folder", label: "Remote project folder", status: "fail", - detail: `${remotePath} exists but is not a Git working tree. Adopt or migrate it before deployment.`, + detail: `${remotePath} exists but is not a Git working tree. Select Push bundle to take over an existing non-Git installation safely.`, }); } else { checks.push({ id: "remote-folder", - label: "Remote Git working tree", + label: inspection.rootGit ? "Remote project folder" : "Existing server installation", status: "pass", - detail: `${remotePath} at ${String(inspection.head || "").slice(0, 7) || "unknown"}.`, + detail: inspection.rootGit + ? `${remotePath} currently contains Git commit ${String(inspection.head || "").slice(0, 7) || "unknown"}.` + : `${remotePath} will receive managed release files while preserved and unknown runtime data remains untouched.`, }); } - if (inspection.trackedChanges.length) { + if (deploymentMode === "server-git" && inspection.trackedChanges.length) { checks.push({ id: "tracked-changes", label: "Server-side tracked changes", status: "fail", - detail: `${inspection.trackedChanges.length} tracked change(s) would be overwritten. Commit, revert or migrate them first.`, + detail: `${inspection.trackedChanges.length} tracked change(s) would be overwritten. Commit, revert or switch to Push bundle.`, }); - } else if (inspection.rootGit) + } else if (inspection.rootGit) { checks.push({ id: "tracked-changes", label: "Server-side tracked changes", - status: "pass", - detail: "No tracked server-only edits detected.", + status: inspection.trackedChanges.length ? "warning" : "pass", + detail: inspection.trackedChanges.length + ? `${inspection.trackedChanges.length} tracked server edit(s) exist. Push bundle does not reset the server Git checkout, but review these before a later Server-side Git deployment.` + : "No tracked server-only edits detected.", }); + } - if (inspection.rootGit && profile.cloneUrl && inspection.remote) { + if (deploymentMode === "server-git" && inspection.rootGit && profile.cloneUrl && inspection.remote) { const expectedRemote = normalizeRemoteUrl(profile.cloneUrl); const currentRemote = normalizeRemoteUrl(inspection.remote); const matches = Boolean( @@ -1160,19 +1166,17 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths" "Detected preserved runtime paths are excluded from the Docker build context.", }); } - const composeFile = safeRelativeRemoteFile( - profile.composeFile || "docker-compose.yml", - ); - if ( - inspection.exists && - !inspection.composeFiles.includes(composeFile) && - !profile.generatedCompose - ) { + const composeFiles = profile.generatedCompose + ? [".forgeflow/compose.forgeflow.yml"] + : (profile.composeFiles?.length ? profile.composeFiles : [profile.composeFile || "docker-compose.yml"]) + .map((value) => safeRelativeRemoteFile(value)); + const missingRemoteCompose = composeFiles.filter((composeFile) => !inspection.composeFiles.includes(composeFile)); + if (deploymentMode === "server-git" && inspection.exists && missingRemoteCompose.length && !profile.generatedCompose) { checks.push({ id: "compose-file", label: "Compose configuration", status: "fail", - detail: `${composeFile} was not found. Select an existing file or enable generated Compose.`, + detail: `${missingRemoteCompose.join(", ")} ${missingRemoteCompose.length === 1 ? "was" : "were"} not found in the server checkout.`, }); } else { checks.push({ @@ -1181,7 +1185,9 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths" status: "pass", detail: profile.generatedCompose ? "ForgeFlow will generate an isolated Compose file." - : composeFile, + : deploymentMode === "push-bundle" && missingRemoteCompose.length + ? `${composeFiles.join(", ")} will be uploaded from the exact local commit.` + : composeFiles.join(", "), }); } } catch (error) { @@ -1413,6 +1419,9 @@ ${invalidateMetadata}`; } dockerManRefreshScript(profile, repository, iconReference = "") { + if (profile.manageDockerMan !== true || profile.adoptedFromServer === true || profile.generatedCompose !== true) { + return `echo 'ForgeFlow left the existing DockerMan template unchanged.'`; + } const templatePath = this.dockerManTemplatePath(profile, repository); const template = this.dockerManTemplate(profile, repository, iconReference); return `mkdir -p /boot/config/plugins/dockerMan/templates-user @@ -1422,20 +1431,21 @@ chmod 0644 ${shellQuote(templatePath)} ${this.iconCacheRefresh(profile, repository, iconReference)}`; } + deploymentServices(profile, repository) { + const values = profile.generatedCompose + ? [profile.composeService || repository.name || "app"] + : (profile.composeServices?.length ? profile.composeServices : [profile.composeService || repository.name || "app"]); + return [...new Set(values.map((value) => String(value || "").trim().toLowerCase().replace(/[^a-z0-9._-]/g, "-")).filter(Boolean))]; + } + + deploymentComposeFiles(profile) { + if (profile.generatedCompose) return [".forgeflow/compose.forgeflow.yml"]; + const values = profile.composeFiles?.length ? profile.composeFiles : [profile.composeFile || "docker-compose.yml"]; + return [...new Set(values.map((value) => safeRelativeRemoteFile(value)))]; + } + metadataCompose(profile, repository, iconReference = "", deployment = {}) { - const service = - String(profile.composeService || repository.name || "app") - .trim() - .toLowerCase() - .replace(/[^a-z0-9._-]/g, "-") || "app"; - const containerName = - String( - profile.containerName || - profile.remoteFolder || - repository.name || - service, - ).replace(/[^A-Za-z0-9._-]/g, "-") || "app"; - const slug = this.internalSlug(profile, repository); + const services = this.deploymentServices(profile, repository); const labels = { "net.unraid.docker.managed": "dockerman", "net.unraid.docker.shell": this.dockerManShell(profile), @@ -1445,37 +1455,35 @@ ${this.iconCacheRefresh(profile, repository, iconReference)}`; repository.sshUrl || repository.cloneUrl || repository.htmlUrl || - repository.fullName, + repository.fullName || repository.name || "unknown", "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; - return ( - [ - "services:", - ` ${service}:`, - ` image: forgeflow/${slug}:${String( - profile.environment || "production", - ) - .toLowerCase() - .replace(/[^a-z0-9._-]/g, "-")}`, - ` container_name: ${containerName}`, - " labels:", - ...Object.entries(labels).map( - ([key, value]) => - ` ${JSON.stringify(key)}: ${JSON.stringify(value)}`, - ), - ].join("\n") + "\n" - ); + + const output = ["services:"]; + for (const service of services) { + output.push(` ${service}:`); + if (profile.generatedCompose) { + const containerName = String( + profile.containerName || profile.remoteFolder || repository.name || service, + ).replace(/[^A-Za-z0-9._-]/g, "-") || "app"; + output.push(` image: forgeflow/${this.internalSlug(profile, repository)}:${String(profile.environment || "production").toLowerCase().replace(/[^a-z0-9._-]/g, "-")}`); + output.push(` container_name: ${containerName}`); + } + output.push(" labels:"); + output.push(...Object.entries(labels).map(([key, value]) => ` ${JSON.stringify(key)}: ${JSON.stringify(value)}`)); + } + return `${output.join("\n")}\n`; } async prepareIcon(profile, repository, server) { const mode = profile.iconMode || (profile.iconFilePath ? "upload" : profile.iconUrl ? "url" : "builtin"); - if (mode === "none") return ""; + if (mode === "none") return profile.serverIconReference || ""; if (mode === "url") { if (!profile.iconUrl) throw new Error( @@ -1518,9 +1526,35 @@ ${this.iconCacheRefresh(profile, repository, iconReference)}`; return `file://${remoteIconPath}`; } - composeInvocation(profile, repository, composeFile) { - const slug = this.internalSlug(profile, repository); - return `docker compose -p ${shellQuote(slug)} -f ${shellQuote(composeFile)} -f '.forgeflow/compose.metadata.yml'`; + composeInvocation(profile, repository) { + const project = String(profile.composeProject || this.internalSlug(profile, repository)).trim(); + const files = [...this.deploymentComposeFiles(profile), ".forgeflow/compose.metadata.yml"]; + return `forgeflow_compose -p ${shellQuote(project)} ${files.map((file) => `-f ${shellQuote(file)}`).join(" ")}`; + } + + composeUpFlags(profile) { + return [profile.removeOrphans === true ? "--remove-orphans" : "", profile.forceRecreate === true ? "--force-recreate" : ""] + .filter(Boolean) + .join(" "); + } + + containerVerificationScript(profile, repository, compose) { + const services = this.deploymentServices(profile, repository); + if (!services.length) { + return `running_services=$(${compose} ps --status running --services 2>/dev/null | sed '/^$/d' | wc -l | tr -d ' ') +[ "$running_services" -gt 0 ] || { echo "Compose project has no running services" >&2; exit 64; }`; + } + return `while IFS= read -r service; do + [ -n "$service" ] || continue + container_id=$(${compose} ps -q "$service" | head -n1) + [ -n "$container_id" ] || { echo "Compose service $service did not create a container" >&2; exit 61; } + running=$(docker inspect -f '{{.State.Running}}' "$container_id" 2>/dev/null || echo false) + [ "$running" = true ] || { echo "Compose service $service is not running" >&2; exit 62; } + health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container_id" 2>/dev/null || true) + [ "$health" != unhealthy ] || { echo "Compose service $service is unhealthy" >&2; exit 63; } +done <<'FORGEFLOW_EXPECTED_SERVICES' +${services.join("\n")} +FORGEFLOW_EXPECTED_SERVICES`; } async checkHealth(url) { @@ -1561,22 +1595,278 @@ ${this.iconCacheRefresh(profile, repository, iconReference)}`; return last; } + hashFile(filePath) { + return new Promise((resolve, reject) => { + const hash = crypto.createHash("sha256"); + const stream = fileSystem.createReadStream(filePath); + stream.on("error", reject); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("end", () => resolve(hash.digest("hex"))); + }); + } + + async createCommitBundle(repository, sha, requestId) { + if (!repository.localPath) throw new Error("A linked local repository is required to create a push bundle."); + const bundleDirectory = nativePath.join(os.tmpdir(), "forgeflow-bundles"); + await fs.mkdir(bundleDirectory, { recursive: true }); + const archivePath = nativePath.join(bundleDirectory, `${requestId}-${sha}.tar`); + await run("git", ["-C", repository.localPath, "archive", "--format=tar", `--output=${archivePath}`, sha], { + timeout: 5 * 60_000, + maxBuffer: 4 * 1024 * 1024, + }); + const stat = await fs.stat(archivePath); + if (!stat.isFile() || stat.size <= 0) throw new Error("Git produced an empty deployment bundle."); + return { archivePath, bytes: stat.size, sha256: await this.hashFile(archivePath) }; + } + + deploymentStatusDocument({ repository, profile, targetSha, requestId, rollback = false }) { + return JSON.stringify({ + repository: repository.fullName, + environment: profile.environment, + requested_sha: targetSha, + live_sha: targetSha, + request_id: requestId, + healthy: null, + healthcheck_url_configured: Boolean(profile.healthcheckUrl), + rollback, + deployment_mode: profile.deploymentMode || "server-git", + deployed_at: new Date().toISOString(), + }); + } + + pushBundleScript({ repository, profile, remotePath, targetSha, requestId, remotePart, digest, metadata, generated, iconReference, rollback = false }) { + const compose = this.composeInvocation(profile, repository); + const flags = this.composeUpFlags(profile); + const preservePayload = Buffer.from([".forgeflow", ".git", ...(profile.preservePaths || [])].join("\n"), "utf8").toString("base64"); + const statusJson = this.deploymentStatusDocument({ repository, profile, targetSha, requestId, rollback }); + const verification = this.containerVerificationScript(profile, repository, compose); + return ` +root=${shellQuote(remotePath)} +target=${shellQuote(targetSha)} +request_id=${shellQuote(requestId)} +incoming=${shellQuote(remotePart)} +expected_digest=${shellQuote(digest)} +release_root="$root/.forgeflow/releases/$target" +release="$release_root/source" +staging="$root/.forgeflow/staging/$request_id" +backup="$root/.forgeflow/backups/$request_id" +lock="$root/.forgeflow/deploy.lock" +mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" +if [ -d "$lock" ] && find "$lock" -maxdepth 0 -mmin +120 -print -quit | grep -q .; then + lock_pid=$(cat "$lock/pid" 2>/dev/null || true) + if [ -z "$lock_pid" ] || ! kill -0 "$lock_pid" 2>/dev/null; then rm -rf "$lock"; fi +fi +mkdir "$lock" 2>/dev/null || { echo "Another ForgeFlow deployment is active for $root" >&2; exit 70; } +printf '%s\n' "$request_id" > "$lock/request-id" +printf '%s\n' "$$" > "$lock/pid" +date -u +%Y-%m-%dT%H:%M:%SZ > "$lock/started-at" +restore_needed=false +finish() { + status=$? + trap - EXIT + set +e + if [ "$status" -ne 0 ] && [ "$restore_needed" = true ]; then + if [ -f "$backup/union" ]; then + while IFS= read -r rel; do + [ -n "$rel" ] || continue + is_preserved "$rel" && continue + rm -f -- "$root/$rel" + done < "$backup/union" + fi + if [ -f "$backup/present" ]; then + while IFS= read -r rel; do + [ -n "$rel" ] || continue + mkdir -p -- "$root/$(dirname "$rel")" + cp -a -- "$backup/source/$rel" "$root/$rel" + done < "$backup/present" + fi + if [ -f "$backup/metadata.present" ]; then cp -a "$backup/compose.metadata.yml" "$root/.forgeflow/compose.metadata.yml"; else rm -f "$root/.forgeflow/compose.metadata.yml"; fi + if [ -f "$backup/generated.present" ]; then cp -a "$backup/compose.forgeflow.yml" "$root/.forgeflow/compose.forgeflow.yml"; else rm -f "$root/.forgeflow/compose.forgeflow.yml"; fi + cd "$root" 2>/dev/null && ${compose} config >/dev/null 2>&1 && ${compose} up -d --build >/dev/null 2>&1 || true + fi + rm -rf "$staging" "$lock" + exit "$status" +} +trap finish EXIT +actual_digest=$(if command -v sha256sum >/dev/null 2>&1; then sha256sum "$incoming" | awk '{print $1}'; else shasum -a 256 "$incoming" | awk '{print $1}'; fi) +[ "$actual_digest" = "$expected_digest" ] || { echo "Uploaded bundle checksum mismatch" >&2; exit 71; } +tar -tf "$incoming" > "$staging.entries" +if grep -E '(^/|(^|/)\\.\\.(/|$))' "$staging.entries" >/dev/null; then echo "Unsafe path detected in deployment bundle" >&2; exit 72; fi +rm -rf "$staging" "$release_root.pending" +mkdir -p "$staging/source" "$release_root.pending" +tar -xf "$incoming" -C "$staging/source" +if find "$staging/source" -type l -print -quit | grep -q .; then echo "Symbolic links are not accepted in push bundles" >&2; exit 73; fi +mv "$staging/source" "$release_root.pending/source" +find "$release_root.pending/source" -type f -printf '%P\n' | LC_ALL=C sort > "$release_root.pending/managed-files" +rm -rf "$release_root" +mv "$release_root.pending" "$release_root" +rm -f "$incoming" "$staging.entries" +printf '%s' ${shellQuote(preservePayload)} | base64 -d > "$staging.preserve" +is_preserved() { + rel="$1" + while IFS= read -r keep; do + [ -n "$keep" ] || continue + if [ "$rel" = "$keep" ] || [[ "$rel" == "$keep/"* ]]; then return 0; fi + done < "$staging.preserve" + return 1 +} +assert_safe_parent() { + rel="$1" + parent=$(dirname "$rel") + [ "$parent" = "." ] && return 0 + current="$root" + old_ifs=$IFS; IFS='/'; read -r -a parts <<< "$parent"; IFS=$old_ifs + for part in "\${parts[@]}"; do + current="$current/$part" + [ ! -L "$current" ] || { echo "Refusing to deploy through symlinked parent $current" >&2; exit 74; } + done +} +old_manifest="$root/.forgeflow/managed-files" +new_manifest="$release_root/managed-files" +mkdir -p "$backup/source" +: > "$backup/present" +: > "$backup/absent" +cat "$new_manifest" > "$backup/union" +[ -f "$old_manifest" ] && cat "$old_manifest" >> "$backup/union" +LC_ALL=C sort -u "$backup/union" -o "$backup/union" +while IFS= read -r rel; do + [ -n "$rel" ] || continue + is_preserved "$rel" && continue + assert_safe_parent "$rel" + if [ -e "$root/$rel" ] || [ -L "$root/$rel" ]; then + [ ! -d "$root/$rel" ] || { echo "A directory conflicts with managed file $rel" >&2; exit 75; } + mkdir -p "$backup/source/$(dirname "$rel")" + cp -a -- "$root/$rel" "$backup/source/$rel" + printf '%s\n' "$rel" >> "$backup/present" + else + printf '%s\n' "$rel" >> "$backup/absent" + fi +done < "$backup/union" +[ -f "$root/.forgeflow/compose.metadata.yml" ] && { cp -a "$root/.forgeflow/compose.metadata.yml" "$backup/compose.metadata.yml"; touch "$backup/metadata.present"; } +[ -f "$root/.forgeflow/compose.forgeflow.yml" ] && { cp -a "$root/.forgeflow/compose.forgeflow.yml" "$backup/compose.forgeflow.yml"; touch "$backup/generated.present"; } +restore_needed=true +if [ -f "$old_manifest" ]; then + while IFS= read -r rel; do + [ -n "$rel" ] || continue + is_preserved "$rel" && continue + grep -Fxq -- "$rel" "$new_manifest" || rm -f -- "$root/$rel" + done < "$old_manifest" +fi +while IFS= read -r rel; do + [ -n "$rel" ] || continue + is_preserved "$rel" && continue + assert_safe_parent "$rel" + mkdir -p -- "$root/$(dirname "$rel")" + rm -f -- "$root/$rel" + cp -a -- "$release/$rel" "$root/$rel" +done < "$new_manifest" +mkdir -p "$root/.forgeflow" +${profile.generatedCompose ? `cat > "$root/.forgeflow/compose.forgeflow.yml" <<'FORGEFLOW_COMPOSE'\n${generated}FORGEFLOW_COMPOSE` : ""} +cat > "$root/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA' +${metadata}FORGEFLOW_METADATA +cd "$root" +${compose} config >/dev/null +${compose} up -d --build ${flags} +${verification} +${this.dockerManRefreshScript(profile, repository, iconReference)} +previous=$(cat "$root/.forgeflow/current-sha" 2>/dev/null || true) +[ -n "$previous" ] || previous=$(git -C "$root" rev-parse HEAD 2>/dev/null || true) +[ -n "$previous" ] && printf '%s' "$previous" > "$root/.forgeflow/previous-sha" +cp "$new_manifest" "$root/.forgeflow/managed-files.pending" +mv "$root/.forgeflow/managed-files.pending" "$root/.forgeflow/managed-files" +printf '%s' "$target" > "$root/.forgeflow/current-sha.pending" +mv "$root/.forgeflow/current-sha.pending" "$root/.forgeflow/current-sha" +cat > "$root/.forgeflow/status.json.pending" <<'FORGEFLOW_STATUS' +${statusJson} +FORGEFLOW_STATUS +mv "$root/.forgeflow/status.json.pending" "$root/.forgeflow/status.json" +restore_needed=false +rm -rf "$backup" +echo "ForgeFlow activated push bundle $target" +`; + } + + serverGitScript({ repository, profile, remotePath, targetSha, requestId, metadata, generated, iconReference, rollback = false }) { + const cloneUrl = String(profile.cloneUrl || repository.sshUrl || repository.preferredCloneUrl || "").trim(); + const branch = String(profile.branch || "main"); + const compose = this.composeInvocation(profile, repository); + const flags = this.composeUpFlags(profile); + const verification = this.containerVerificationScript(profile, repository, compose); + const statusJson = this.deploymentStatusDocument({ repository, profile, targetSha, requestId, rollback }); + return ` +root=${shellQuote(remotePath)} +parent=$(dirname "$root") +mkdir -p "$parent" +if [ ! -d "$root" ]; then git clone --branch ${shellQuote(branch)} --single-branch ${shellQuote(cloneUrl)} "$root"; fi +test -d "$root/.git" || { echo "Existing folder is not a Git working tree" >&2; exit 32; } +${profile.alignRemote ? `git -C "$root" remote set-url origin ${shellQuote(cloneUrl)}` : ""} +changes=$(git -C "$root" status --porcelain --untracked-files=no) +test -z "$changes" || { echo "Tracked server-side changes block deployment" >&2; printf '%s\n' "$changes" >&2; exit 33; } +git -C "$root" fetch --prune origin ${shellQuote(branch)} +git -C "$root" cat-file -e ${shellQuote(`${targetSha}^{commit}`)} +git -C "$root" merge-base --is-ancestor ${shellQuote(targetSha)} ${shellQuote(`origin/${branch}`)} +previous=$(git -C "$root" rev-parse HEAD 2>/dev/null || true) +mkdir -p "$root/.forgeflow" +[ -f "$root/.forgeflow/compose.metadata.yml" ] && cp -a "$root/.forgeflow/compose.metadata.yml" "$root/.forgeflow/compose.metadata.yml.previous" || true +restore_git() { + status=$? + trap - EXIT + if [ "$status" -ne 0 ] && [ -n "$previous" ]; then + set +e + git -C "$root" reset --hard "$previous" + [ -f "$root/.forgeflow/compose.metadata.yml.previous" ] && mv "$root/.forgeflow/compose.metadata.yml.previous" "$root/.forgeflow/compose.metadata.yml" + cd "$root" && ${compose} config >/dev/null 2>&1 && ${compose} up -d --build >/dev/null 2>&1 || true + fi + exit "$status" +} +trap restore_git EXIT +git -C "$root" checkout -B ${shellQuote(branch)} ${shellQuote(`origin/${branch}`)} +git -C "$root" reset --hard ${shellQuote(targetSha)} +${profile.generatedCompose ? `cat > "$root/.forgeflow/compose.forgeflow.yml" <<'FORGEFLOW_COMPOSE'\n${generated}FORGEFLOW_COMPOSE` : ""} +cat > "$root/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA' +${metadata}FORGEFLOW_METADATA +cd "$root" +${compose} config >/dev/null +${compose} up -d --build ${flags} +${verification} +${this.dockerManRefreshScript(profile, repository, iconReference)} +[ -n "$previous" ] && printf '%s' "$previous" > "$root/.forgeflow/previous-sha" +printf '%s' ${shellQuote(targetSha)} > "$root/.forgeflow/current-sha.pending" +mv "$root/.forgeflow/current-sha.pending" "$root/.forgeflow/current-sha" +cat > "$root/.forgeflow/status.json.pending" <<'FORGEFLOW_STATUS' +${statusJson} +FORGEFLOW_STATUS +mv "$root/.forgeflow/status.json.pending" "$root/.forgeflow/status.json" +rm -f "$root/.forgeflow/compose.metadata.yml.previous" +trap - EXIT +echo "ForgeFlow activated server Git commit ${targetSha}" +`; + } + + async executePushBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference, rollback = false }) { + const bundle = await this.createCommitBundle(repository, targetSha, requestId); + const remotePart = path.join(remotePath, ".forgeflow", "incoming", `${requestId}-${targetSha}.tar.part`); + try { + await this.ssh.exec(server.id, bash(`mkdir -p ${shellQuote(path.dirname(remotePart))}`), { timeout: 30_000 }); + await this.ssh.uploadFile(server.id, bundle.archivePath, remotePart, { mode: 0o600 }); + const script = this.pushBundleScript({ repository, profile, remotePath, targetSha, requestId, remotePart, digest: bundle.sha256, metadata, generated, iconReference, rollback }); + return await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 8 * 1024 * 1024 }); + } finally { + await fs.rm(bundle.archivePath, { force: true }).catch(() => {}); + } + } + async deploy({ repository, profileId, sha }) { const targetSha = assertFullCommitSha(sha); const { profile, server, remotePath } = this.resolve(repository, profileId); - const preflight = await this.preflight({ - repository, - profileId, - sha: targetSha, - }); + const preflight = await this.preflight({ repository, profileId, sha: targetSha }); if (!preflight.summary.ready) { - const error = new Error( - `SSH deployment preflight failed: ${preflight.summary.blocking.join(", ")}`, - ); + const error = new Error(`SSH deployment preflight failed: ${preflight.summary.blocking.join(", ")}`); error.code = "SSH_DEPLOYMENT_PREFLIGHT_FAILED"; throw error; } const requestId = crypto.randomUUID(); + const mode = profile.deploymentMode || "server-git"; const operation = await this.saveOperation({ id: requestId, type: "deployment", @@ -1592,171 +1882,76 @@ ${this.iconCacheRefresh(profile, repository, iconReference)}`; status: "running", logs: [ "Preflight passed.", - "Unraid can read the Gitea repository.", + mode === "push-bundle" + ? "Creating and uploading an exact commit bundle; no Unraid → Gitea key is required." + : "Unraid will fetch the exact commit using its configured Git credentials.", `Deploying exact commit ${targetSha} in the background.`, ], }); - const cloneUrl = String( - profile.cloneUrl || - repository.sshUrl || - repository.preferredCloneUrl || - "", - ).trim(); - const composeFile = profile.generatedCompose - ? ".forgeflow/compose.forgeflow.yml" - : safeRelativeRemoteFile(profile.composeFile || "docker-compose.yml"); - const generated = profile.generatedCompose - ? this.generatedCompose(profile, repository) - : ""; + const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : ""; const iconReference = await this.prepareIcon(profile, repository, server); const metadata = this.metadataCompose(profile, repository, iconReference, { sha: targetSha, - repositoryUrl: cloneUrl, + repositoryUrl: profile.cloneUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName, }); - const compose = this.composeInvocation(profile, repository, composeFile); - const branch = String(profile.branch || "main"); - const statusJson = JSON.stringify({ - repository: repository.fullName, - environment: profile.environment, - requested_sha: targetSha, - live_sha: targetSha, - request_id: requestId, - healthy: null, - healthcheck_url_configured: Boolean(profile.healthcheckUrl), - deployed_at: new Date().toISOString(), - }); - const script = ` -root=${shellQuote(remotePath)} -parent=$(dirname "$root") -mkdir -p "$parent" -if [ ! -d "$root" ]; then - git clone --branch ${shellQuote(branch)} --single-branch ${shellQuote(cloneUrl)} "$root" -fi -test -d "$root/.git" || { echo "Existing folder is not a Git working tree" >&2; exit 32; } -${profile.alignRemote ? `git -C "$root" remote set-url origin ${shellQuote(cloneUrl)}` : ""} -changes=$(git -C "$root" status --porcelain --untracked-files=no) -test -z "$changes" || { echo "Tracked server-side changes block deployment" >&2; printf '%s\n' "$changes" >&2; exit 33; } -git -C "$root" fetch --prune origin ${shellQuote(branch)} -git -C "$root" cat-file -e ${shellQuote(`${targetSha}^{commit}`)} -git -C "$root" merge-base --is-ancestor ${shellQuote(targetSha)} ${shellQuote(`origin/${branch}`)} -previous=$(git -C "$root" rev-parse HEAD 2>/dev/null || true) -git -C "$root" checkout -B ${shellQuote(branch)} ${shellQuote(`origin/${branch}`)} -git -C "$root" reset --hard ${shellQuote(targetSha)} -mkdir -p "$root/.forgeflow" -printf '%s' "$previous" > "$root/.forgeflow/previous-sha" -printf '%s' ${shellQuote(targetSha)} > "$root/.forgeflow/current-sha" -${profile.generatedCompose ? `cat > "$root/.forgeflow/compose.forgeflow.yml" <<'FORGEFLOW_COMPOSE'\n${generated}FORGEFLOW_COMPOSE` : ""} -cat > "$root/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA' -${metadata}FORGEFLOW_METADATA -cd "$root" -${compose} config >/dev/null -${compose} up -d --build --remove-orphans --force-recreate -${this.dockerManRefreshScript(profile, repository, iconReference)} -container=${shellQuote(String(profile.containerName || profile.remoteFolder || repository.name))} -docker inspect "$container" >/dev/null -cat > "$root/.forgeflow/status.json" <<'FORGEFLOW_STATUS' -${statusJson} -FORGEFLOW_STATUS -`; + const previousState = this.store.getDeploymentState?.(profileId) || null; void (async () => { try { - const result = await this.ssh.exec(server.id, bash(script), { - timeout: 30 * 60_000, - maxOutput: 4 * 1024 * 1024, - }); + const result = mode === "push-bundle" + ? await this.executePushBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference }) + : await this.ssh.exec(server.id, bash(this.serverGitScript({ repository, profile, remotePath, targetSha, requestId, metadata, generated, iconReference })), { timeout: 30 * 60_000, maxOutput: 8 * 1024 * 1024 }); const health = await this.checkHealth(profile.healthcheckUrl); - // The remote deployment script already verifies that Docker created the expected - // container. Complete the operation before a secondary state inspection so a slow or - // failed refresh cannot leave ForgeFlow stuck in deployment mode after a successful run. - const effectiveHealthy = health.configured ? health.healthy : true; - const finalStatus = effectiveHealthy === false ? "failed" : "success"; - const finalLogs = [ - ...operation.logs, - ...result.stdout.trim().split("\n").filter(Boolean).slice(-60), - "Docker Compose deployment completed.", - health.configured - ? `Healthcheck ${health.healthy ? "passed" : "failed"}${health.status ? ` with HTTP ${health.status}` : ""}.` - : "No desktop healthcheck URL configured; the remote container inspection passed.", - ]; - await this.saveOperation({ + const finalStatus = health.healthy === false ? "failed" : "success"; + const completed = await this.saveOperation({ ...operation, status: finalStatus, - previousSha: preflight.inspection?.head || null, - health: { ...health, healthy: effectiveHealthy }, - logs: finalLogs, - error: - effectiveHealthy === false - ? "The application healthcheck did not pass after deployment." - : null, + previousSha: previousState?.liveSha || preflight.inspection?.head || null, + health, + logs: [ + ...operation.logs, + ...result.stdout.trim().split("\n").filter(Boolean).slice(-80), + "Docker Compose activation and runtime verification completed.", + health.configured + ? `Healthcheck ${health.healthy ? "passed" : "failed"}${health.status ? ` with HTTP ${health.status}` : ""}.` + : "No desktop healthcheck configured; running containers were verified and health remains unverified.", + ], + error: health.healthy === false ? "The application healthcheck did not pass after deployment." : null, }); await this.store.saveDeploymentState(profileId, { liveSha: targetSha, - previousSha: preflight.inspection?.head || null, - healthy: effectiveHealthy, + previousSha: previousState?.liveSha || preflight.inspection?.head || null, + healthy: health.configured ? health.healthy : null, + runtimeVerification: health.configured ? "desktop-healthcheck" : "running-unverified", healthStatus: health.status ?? null, healthLatencyMs: health.latencyMs ?? null, requestId, remotePath, provider: "ssh-unraid", - containerName: String( - profile.containerName || profile.remoteFolder || repository.name, - ), + deploymentMode: mode, + containerName: String(profile.containerName || profile.remoteFolder || repository.name), containerRunning: true, dockerMan: { webUi: this.dockerManWebUi(profile), icon: iconReference, shell: this.dockerManShell(profile), - templateExists: true, - configured: Boolean(this.dockerManWebUi(profile) || iconReference), + templateExists: profile.manageDockerMan === true || previousState?.dockerMan?.templateExists === true, + configured: Boolean(this.dockerManWebUi(profile) || iconReference || previousState?.dockerMan?.configured), }, - webUiUrl: - profile.webUiUrl || - (profile.hostPort - ? `http://${server.host}:${profile.hostPort}/` - : null), - }); - // Reconcile authoritative Unraid/Docker state in the background and preserve the already - // completed operation if that follow-up inspection is unavailable. - void this.refreshProfileState(repository.fullName, profileId).catch( - async (refreshError) => { - await this.diagnostics?.warning( - "unraid.deployment.post-refresh-failed", - { - requestId, - repository: repository.fullName, - serverId: server.id, - error: refreshError, - }, - ); - }, - ); - await this.diagnostics?.info("unraid.deployment.completed", { - requestId, - repository: repository.fullName, - serverId: server.id, - remotePath, - sha: targetSha, - healthy: effectiveHealthy, - healthStatus: health.status ?? null, + webUiUrl: profile.webUiUrl || (profile.hostPort ? `http://${server.host}:${profile.hostPort}/` : null), }); + void this.refreshProfileState(repository.fullName, profileId).catch(() => {}); + await this.diagnostics?.info("unraid.deployment.completed", { requestId, repository: repository.fullName, serverId: server.id, remotePath, sha: targetSha, status: completed.status }); } catch (error) { await this.saveOperation({ ...operation, status: "failed", error: error.message, - failure: { stage: "SSH / Docker deployment", message: error.message }, - logs: [...operation.logs, error.message], - }); - await this.diagnostics?.error("unraid.deployment.failed", { - requestId, - repository: repository.fullName, - serverId: server.id, - remotePath, - sha: targetSha, - error, + failure: { stage: mode === "push-bundle" ? "Bundle upload / Compose activation" : "Server Git / Compose activation", message: error.message }, + logs: [...operation.logs, error.message, "The live SHA was not promoted. Previous release evidence remains authoritative."], }); + await this.diagnostics?.error("unraid.deployment.failed", { requestId, repository: repository.fullName, serverId: server.id, remotePath, sha: targetSha, error }); } })(); @@ -1767,44 +1962,13 @@ FORGEFLOW_STATUS const target = assertFullCommitSha(targetSha); const { profile, server, remotePath } = this.resolve(repository, profileId); const deploymentState = this.store.getDeploymentState(profileId); - if ( - !deploymentState?.previousSha || - deploymentState.previousSha !== target - ) { - const error = new Error( - "Rollback is allowed only to the exact previous SHA reported by ForgeFlow for this deployment profile.", - ); + if (!deploymentState?.previousSha || deploymentState.previousSha !== target) { + const error = new Error("Rollback is allowed only to the exact previous SHA reported by ForgeFlow for this deployment profile."); error.code = "ROLLBACK_TARGET_NOT_PREVIOUS_SHA"; throw error; } - if (!repository.localPath) - throw new Error( - "A linked local repository is required for rollback verification.", - ); - await this.git.verifyCommitOnRemoteBranch( - repository.localPath, - target, - profile.branch, - ); - const inspection = await this.inspect({ repository, profileId }); - if (!inspection.rootGit) - throw new Error( - "The configured server project is not a root Git working tree.", - ); - if (inspection.trackedChanges.length) - throw new Error( - "Tracked server-side changes block rollback. Commit, revert or migrate them first.", - ); - const composeFile = profile.generatedCompose - ? ".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, { - sha: target, - repositoryUrl: - profile.cloneUrl || repository.sshUrl || repository.cloneUrl || "", - }); - const compose = this.composeInvocation(profile, repository, composeFile); + if (!repository.localPath) throw new Error("A linked local repository is required for rollback verification."); + await this.git.verifyCommitOnRemoteBranch(repository.localPath, target, profile.branch); const requestId = crypto.randomUUID(); const operation = await this.saveOperation({ id: requestId, @@ -1821,75 +1985,46 @@ FORGEFLOW_STATUS status: "running", logs: [`Rolling back to exact commit ${target}.`], }); - const statusJson = JSON.stringify({ - repository: repository.fullName, - environment: profile.environment, - requested_sha: target, - live_sha: target, - request_id: requestId, - healthy: null, - healthcheck_url_configured: Boolean(profile.healthcheckUrl), - rollback: true, - deployed_at: new Date().toISOString(), + const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : ""; + const iconReference = await this.prepareIcon(profile, repository, server); + const metadata = this.metadataCompose(profile, repository, iconReference, { + sha: target, + repositoryUrl: profile.cloneUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName, }); - const script = ` -root=${shellQuote(remotePath)} -test -d "$root/.git" -git -C "$root" fetch --prune origin ${shellQuote(profile.branch)} -git -C "$root" cat-file -e ${shellQuote(`${target}^{commit}`)} -current=$(git -C "$root" rev-parse HEAD) -git -C "$root" reset --hard ${shellQuote(target)} -cat > "$root/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA' -${metadata}FORGEFLOW_METADATA -cd "$root" -${compose} config >/dev/null -${compose} up -d --build --remove-orphans --force-recreate -${this.dockerManRefreshScript(profile, repository, iconReference)} -printf '%s' "$current" > "$root/.forgeflow/previous-sha" -printf '%s' ${shellQuote(target)} > "$root/.forgeflow/current-sha" -cat > "$root/.forgeflow/status.json" <<'FORGEFLOW_STATUS' -${statusJson} -FORGEFLOW_STATUS -`; try { - const result = await this.ssh.exec(server.id, bash(script), { - timeout: 30 * 60_000, - maxOutput: 4 * 1024 * 1024, - }); + const mode = profile.deploymentMode || "server-git"; + const result = mode === "push-bundle" + ? await this.executePushBundle({ repository, profile, server, remotePath, targetSha: target, requestId, metadata, generated, iconReference, rollback: true }) + : await this.ssh.exec(server.id, bash(this.serverGitScript({ repository, profile, remotePath, targetSha: target, requestId, metadata, generated, iconReference, rollback: true })), { timeout: 30 * 60_000, maxOutput: 8 * 1024 * 1024 }); const health = await this.checkHealth(profile.healthcheckUrl); const finalStatus = health.healthy === false ? "failed" : "rolled-back"; const completed = await this.saveOperation({ ...operation, status: finalStatus, - previousSha: deploymentState.liveSha || inspection.head || null, + previousSha: deploymentState.liveSha || null, health, - error: - health.healthy === false - ? "The application healthcheck did not pass after rollback." - : null, + error: health.healthy === false ? "The application healthcheck did not pass after rollback." : null, logs: [ ...operation.logs, - ...result.stdout.trim().split("\n").filter(Boolean).slice(-60), - "Rollback completed.", - health.configured - ? `Healthcheck ${health.healthy ? "passed" : "failed"}${health.status ? ` with HTTP ${health.status}` : ""}.` - : "No desktop healthcheck URL configured.", + ...result.stdout.trim().split("\n").filter(Boolean).slice(-80), + "Rollback activation completed.", + health.configured ? `Healthcheck ${health.healthy ? "passed" : "failed"}.` : "Runtime is running; no desktop healthcheck was configured.", ], }); await this.store.saveDeploymentState(profileId, { liveSha: target, - previousSha: deploymentState.liveSha || inspection.head || null, - healthy: health.healthy, - healthStatus: health.status, - healthLatencyMs: health.latencyMs, + previousSha: deploymentState.liveSha || null, + healthy: health.configured ? health.healthy : null, + runtimeVerification: health.configured ? "desktop-healthcheck" : "running-unverified", + healthStatus: health.status ?? null, + healthLatencyMs: health.latencyMs ?? null, requestId, remotePath, provider: "ssh-unraid", + containerRunning: true, }); if (health.healthy === false) { - const error = new Error( - "Rollback completed, but the configured healthcheck failed.", - ); + const error = new Error("Rollback completed, but the configured healthcheck failed."); error.code = "ROLLBACK_HEALTHCHECK_FAILED"; error.operationId = completed.id; throw error; @@ -1897,12 +2032,7 @@ FORGEFLOW_STATUS return completed; } catch (error) { if (error.code !== "ROLLBACK_HEALTHCHECK_FAILED") { - await this.saveOperation({ - ...operation, - status: "failed", - error: error.message, - logs: [...operation.logs, error.message], - }); + await this.saveOperation({ ...operation, status: "failed", error: error.message, logs: [...operation.logs, error.message] }); } throw error; } @@ -1969,9 +2099,16 @@ printf 'templateExists=%s\n' "$template_exists" const dockerHealthy = fields.dockerHealth ? fields.dockerHealth === "healthy" : null; - const effectiveHealthy = health.configured - ? health.healthy - : (dockerHealthy ?? (fields.containerRunning === "true" ? true : false)); + const effectiveHealthy = health.configured ? health.healthy : dockerHealthy; + const runtimeVerification = health.configured + ? "desktop-healthcheck" + : dockerHealthy === true + ? "docker-healthcheck" + : dockerHealthy === false + ? "docker-unhealthy" + : fields.containerRunning === "true" + ? "running-unverified" + : "stopped"; return this.store.saveDeploymentState(profile.id, { liveSha: /^[0-9a-f]{40}$/i.test(fields.liveSha || "") ? fields.liveSha @@ -1980,6 +2117,7 @@ printf 'templateExists=%s\n' "$template_exists" ? fields.previousSha : null, healthy: effectiveHealthy, + runtimeVerification, healthStatus: health.status, healthLatencyMs: health.latencyMs, containerName, @@ -2019,16 +2157,18 @@ printf 'templateExists=%s\n' "$template_exists" : safeRelativeRemoteFile(profile.composeFile || "docker-compose.yml"); const iconReference = await this.prepareIcon(profile, repository, server); const metadata = this.metadataCompose(profile, repository, iconReference); - const compose = this.composeInvocation(profile, repository, composeFile); + const compose = this.composeInvocation(profile, repository); + const flags = this.composeUpFlags(profile); const script = ` root=${shellQuote(remotePath)} -test -d "$root/.git" +test -d "$root" mkdir -p "$root/.forgeflow" cat > "$root/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA' ${metadata}FORGEFLOW_METADATA cd "$root" ${compose} config >/dev/null -${compose} up -d --build --remove-orphans --force-recreate +${compose} up -d --build ${flags} +${this.containerVerificationScript(profile, repository, compose)} ${this.dockerManRefreshScript(profile, repository, iconReference)} `; await this.ssh.exec(server.id, bash(script), { @@ -2178,9 +2318,9 @@ module.exports = { iconReferenceLocalPath, decodeBase64Json, parseDockerManXml, - parseServerInventory, - inventoryContainerMatch, - remoteIdentity, + parseServerInventory: parseWorkloadInventory, + inventoryContainerMatch: matchInventoryContainer, + remoteIdentity: inventoryRemoteIdentity, deriveDetectedProfile, bash, }; diff --git a/src/renderer/app.js b/src/renderer/app.js index 685d729..c5b0d4b 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -429,7 +429,7 @@ async function refreshDeploymentTruth(showErrors = false) { await refreshRepositories(false, true); showToast( "Server workloads discovered", - `${adopted} running deployment${adopted === 1 ? " was" : "s were"} linked to Gitea automatically.`, + `${adopted} workload${adopted === 1 ? " was" : "s were"} linked automatically from exact repository provenance.`, "success", ); } @@ -874,11 +874,9 @@ function environmentState(profile) { const state = profile.state || {}; if (state.healthy === false) return { label: "Unhealthy", tone: "danger" }; if (state.healthy === true) return { label: "Healthy", tone: "success" }; - if ( - profile.provider === "ssh-unraid" || - state.statusConfigured || - state.healthConfigured - ) + if (state.containerRunning === true) return { label: "Running · unverified", tone: "warning" }; + if (state.containerRunning === false) return { label: "Stopped", tone: "danger" }; + if (profile.provider === "ssh-unraid" || state.statusConfigured || state.healthConfigured) return { label: "Not checked", tone: "" }; return { label: "Status not configured", tone: "" }; } @@ -921,28 +919,41 @@ function deploymentIdentity(profile, repository) { function renderProfileCard(repository, profile, compact = false) { const state = profile.state || {}; const health = environmentState(profile); + const isSsh = profile.provider === "ssh-unraid"; + const mode = isSsh ? profile.deploymentMode || "server-git" : "gitea-actions"; const ready = + mode !== "monitor-only" && repository.readyToDeploy && repository.localStatus?.branch.head === profile.branch; - const isSsh = profile.provider === "ssh-unraid"; + const modeLabel = { + "push-bundle": "Push bundle", + "server-git": "Server-side Git", + "monitor-only": "Monitor only", + }[mode] || mode; const providerDetail = isSsh - ? `SSH / Unraid · ${profile.remoteFolder || repository.name} · ${profile.branch}${profile.adoptedFromServer ? " · automatically discovered" : ""}` + ? `SSH / Unraid · ${modeLabel} · ${profile.remoteFolder || repository.name} · ${profile.branch}${profile.adoptedFromServer ? " · server-linked" : ""}` : `${profile.workflowFile} · ${profile.branch}`; - const rollbackConfigured = isSsh || Boolean(profile.rollbackWorkflowFile); + const rollbackConfigured = (isSsh && mode !== "monitor-only") || Boolean(profile.rollbackWorkflowFile); const dockerMan = dockerManIntegration(profile); const { templateReady, webUiReady, iconReady } = dockerMan; const dockerManReady = dockerMan.ready; - const webUi = - profile.webUiUrl || state.webUiUrl || state.dockerMan?.webUi || ""; + const managesDockerMan = isSsh && profile.manageDockerMan === true; + const webUi = profile.webUiUrl || state.webUiUrl || state.dockerMan?.webUi || ""; const identity = deploymentIdentity(profile, repository); const syncLabel = state.matchesGitea ? `${icon("check")}Live = Gitea · ${shortSha(state.liveSha)}` : state.giteaSha && state.liveSha ? `Live ${shortSha(state.liveSha)} · Gitea ${shortSha(state.giteaSha)}` : ""; - return `
${escapeHtml(identity.initial)}
Container${escapeHtml(identity.name)}${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}
${syncLabel}
${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}

${escapeHtml(profile.name)}

${escapeHtml(providerDetail)}

${health.label}
${webUi ? `` : ""}${isSsh ? `` : ""}${ready ? `` : ""}${state.previousSha && rollbackConfigured ? `` : ""}
`; + const dockerManLabel = managesDockerMan + ? dockerManReady + ? templateReady + ? "Managed labels/template active" + : "Managed labels active" + : `Managed · WebUI ${webUiReady ? "ready" : "missing"} · icon ${iconReady ? "ready" : "missing"}` + : "Existing DockerMan template preserved"; + return `
${escapeHtml(identity.initial)}
Container${escapeHtml(identity.name)}${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}
${syncLabel}
${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}

${escapeHtml(profile.name)}

${escapeHtml(providerDetail)}

${health.label}
${webUi ? `` : ""}${managesDockerMan ? `` : ""}${ready ? `` : ""}${state.previousSha && rollbackConfigured ? `` : ""}
`; } - function renderRepositoryDeployments(repository) { const profiles = repository.deploymentProfiles || []; const repoOps = repositoryOperations(repository).slice(0, 10); @@ -1093,34 +1104,44 @@ function renderActionPanel(repository) { return ``; } -function renderDeployments() { - const cards = ui.repositories.flatMap((repository) => - (repository.deploymentProfiles || []).map((profile) => ({ - repository, - profile, - })), - ); - const active = operations().filter( - (operation) => !isTerminalOperation(operation.status), - ); - const missingDockerMan = cards.filter( - ({ profile }) => - profile.provider === "ssh-unraid" && - profile.state?.containerRunning && - !dockerManIntegration(profile).ready, - ); - return `
${active.length ? `
${icon("pulse")} ${active.length} deployment operation${active.length === 1 ? " is" : "s are"} still active. ForgeFlow reconciles these against the live server automatically.
` : ""}
${cards.length ? cards.map(({ repository, profile }) => renderProfileCard(repository, profile, true)).join("") : '

No deployment environments configured

Open a repository and add an environment.

'}

All operations

Newest first
${ - operations().length - ? `${operations() - .map( - (operation) => - ``, - ) - .join("")}
RepositoryActionEnvironmentCommitStatusUpdated
${escapeHtml(operation.repository)}${escapeHtml(operation.action || "deploy")}${escapeHtml(operation.environment || "—")}${escapeHtml(operation.shortSha || shortSha(operation.sha))}${escapeHtml(operation.status)}${formatDate(operation.updatedAt || operation.createdAt)}
` - : '

No operations recorded.

' - }
`; +function renderServerInventory() { + const servers = ui.serverDiscovery || []; + const workloadCount = servers.reduce((total, server) => total + (server.workloads?.length || 0), 0); + const reviewCount = servers.reduce((total, server) => total + Number(server.needsReview || 0), 0); + return `

Server inventory

Running and stopped Docker workloads, including installations without a Git checkout
${servers.length ? `
${servers.map((server) => { + const capabilities = server.capabilities || {}; + const capabilityText = [capabilities.docker ? "Docker" : "Docker missing", capabilities.compose ? "Compose" : "Compose missing", capabilities.git ? "Git" : "Git optional/missing", capabilities.tar && capabilities.checksum ? "Push ready" : "Push tools incomplete"].join(" · "); + return `

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

${server.detected || 0} workloads · ${server.running || 0} running · ${server.linked || 0} linked · ${server.needsReview || 0} review
${server.error ? "Scan failed" : escapeHtml(capabilityText)}
${server.error ? `
${icon("error")}${escapeHtml(server.error)}
` : ""}${(server.warnings || []).map((warning) => `
${icon("warning")}${escapeHtml(warning)}
`).join("")}
${(server.workloads || []).length ? server.workloads.map((workload) => { + const containers = (workload.containers || []).map((container) => container.name).join(", "); + const topCandidate = workload.candidates?.[0]; + const linked = workload.status === "linked" || Boolean(workload.link); + const statusTone = linked ? "success" : workload.status === "ambiguous" ? "danger" : workload.status === "unmatched" ? "warning" : "warning"; + const detail = workload.compose?.project + ? `Compose ${workload.compose.project} · ${(workload.compose.services || []).join(", ") || "services unknown"}` + : `Container installation · ${containers || "unnamed"}`; + const candidate = linked + ? `Linked to ${workload.link?.repositoryFullName || "repository"}` + : topCandidate + ? `${topCandidate.repositoryFullName} suggested · ${topCandidate.confidence}` + : "No repository candidate; select one manually"; + return `
${escapeHtml(workload.displayName)}${escapeHtml(detail)} · ${workload.runtime?.running ? "running" : "stopped"}${escapeHtml(candidate)}
${escapeHtml(linked ? "Linked" : workload.status || "Review")}${linked ? `` : ``}
`; + }).join("") : '

No Docker workloads were returned by this server.

'}
`; + }).join("")}
` : `

Server inventory not scanned

Scan the configured servers to detect existing DockerMan, Docker and Compose installations.

`}
${icon("shield")}Only exact repository provenance is linked automatically. Name similarity remains a manual decision. Push bundle deployments reuse the desktop → Unraid SSH connection and do not require a Gitea key on Unraid.
`; } +function renderDeployments() { + const cards = ui.repositories.flatMap((repository) => + (repository.deploymentProfiles || []).map((profile) => ({ repository, profile })), + ); + const active = operations().filter((operation) => !isTerminalOperation(operation.status)); + const missingDockerMan = cards.filter(({ profile }) => + profile.provider === "ssh-unraid" && + profile.manageDockerMan === true && + profile.state?.containerRunning && + !dockerManIntegration(profile).ready, + ); + return `
${active.length ? `
${icon("pulse")} ${active.length} deployment operation${active.length === 1 ? " is" : "s are"} still active. ForgeFlow reconciles these against the live server automatically.
` : ""}${renderServerInventory()}

Linked deployment environments

Manual links remain stable across container recreations through Compose/workload identity
${cards.length ? cards.map(({ repository, profile }) => renderProfileCard(repository, profile, true)).join("") : '

No deployment environments configured

Scan a server and link an existing workload, or open a repository and add an environment.

'}

All operations

Newest first
${operations().length ? `${operations().map((operation) => ``).join("")}
RepositoryActionEnvironmentCommitStatusUpdated
${escapeHtml(operation.repository)}${escapeHtml(operation.action || "deploy")}${escapeHtml(operation.environment || "—")}${escapeHtml(operation.shortSha || shortSha(operation.sha))}${escapeHtml(operation.status)}${formatDate(operation.updatedAt || operation.createdAt)}
` : '

No operations recorded.

'}
`; +} function renderSettings() { const state = ui.boot.state; const prefs = state.preferences || {}; @@ -1246,6 +1267,45 @@ function renderModal() { ui.repositories.find( (repo) => repo.fullName === ui.modal.repositoryFullName, ); + if (ui.modal.type === "workload-link") { + const serverResult = (ui.serverDiscovery || []).find( + (item) => item.serverId === ui.modal.serverId, + ); + const workload = serverResult?.workloads?.find( + (item) => item.workloadId === ui.modal.workloadId, + ); + if (!workload) { + return ``; + } + const availableRepositories = ui.repositories.filter((item) => item.fullName); + const suggestedRepository = + ui.modal.repositoryFullName || + workload.candidates?.[0]?.repositoryFullName || + selectedRepository()?.fullName || + availableRepositories[0]?.fullName || + ""; + const selectedLinkRepository = availableRepositories.find( + (item) => item.fullName === suggestedRepository, + ); + const remoteFolder = + ui.modal.remoteFolder || + workload.remoteFolderCandidate || + safeCloneFolderName(selectedLinkRepository); + const candidateSummary = workload.candidates?.length + ? workload.candidates + .slice(0, 4) + .map( + (candidate) => + `
${escapeHtml(candidate.repositoryFullName)}${escapeHtml(candidate.exact ? "Exact provenance" : `${candidate.score} confidence`)} · ${escapeHtml((candidate.reasons || []).join(", ") || "name similarity")}
`, + ) + .join("") + : '
Repository candidatesNo confident match; choose manually.
'; + const containerNames = (workload.containers || []) + .map((container) => container.name) + .filter(Boolean) + .join(", "); + return ``; + } if (ui.modal.type === "deployment-config") { const storedProfile = repository?.deploymentProfiles?.find( @@ -1265,30 +1325,35 @@ function renderModal() { const ssh = provider === "ssh-unraid"; const remoteFolder = existing.remoteFolder || safeCloneFolderName(repository); - return ``; } if (ui.modal.type === "deployment-preflight") { const profile = @@ -1318,7 +1383,7 @@ function renderModal() { (item) => item.id === ui.modal.serverId, ) || {}; const authType = ui.modal.authType || server.authType || "privateKey"; - return ``; + return ``; } if (ui.modal.type === "hunk-staging") { const hunks = ui.diffHunks?.hunks || []; @@ -2143,6 +2208,98 @@ app.addEventListener("click", async (event) => { "success", ); } + } else if (action === "scan-server-inventory") { + setLoading(true, "Scanning Docker, Compose and DockerMan workloads…"); + try { + await refreshDeploymentTruth(true); + const detected = (ui.serverDiscovery || []).reduce( + (total, item) => total + Number(item.detected || 0), + 0, + ); + const review = (ui.serverDiscovery || []).reduce( + (total, item) => total + Number(item.needsReview || 0), + 0, + ); + showToast( + "Server inventory updated", + `${detected} workload${detected === 1 ? "" : "s"} detected; ${review} require manual review.`, + review ? "info" : "success", + ); + } catch (error) { + showToast("Server scan failed", error.message, "error"); + } + setLoading(false); + } else if (action === "link-server-workload") { + const serverResult = (ui.serverDiscovery || []).find( + (item) => item.serverId === target.dataset.serverId, + ); + const workload = serverResult?.workloads?.find( + (item) => item.workloadId === target.dataset.workloadId, + ); + if (!workload) { + showToast( + "Workload unavailable", + "Scan the server inventory again before linking this workload.", + "error", + ); + return; + } + ui.modal = { + type: "workload-link", + serverId: target.dataset.serverId, + workloadId: target.dataset.workloadId, + repositoryFullName: + workload.candidates?.[0]?.repositoryFullName || + repository?.fullName || + ui.repositories[0]?.fullName || + "", + remoteFolder: workload.remoteFolderCandidate || "", + }; + render(); + } else if (action === "confirm-link-server-workload") { + const repositoryFullName = document + .querySelector("#workload-repository") + ?.value.trim(); + const deploymentMode = + document.querySelector("#workload-deployment-mode")?.value || + "push-bundle"; + const remoteFolder = document + .querySelector("#workload-remote-folder") + ?.value.trim(); + const linkedRepository = ui.repositories.find( + (item) => item.fullName === repositoryFullName, + ); + if (!linkedRepository) { + showToast( + "Choose a repository", + "The workload must be linked to a Gitea repository.", + "error", + ); + return; + } + setLoading(true, "Saving the permanent server workload link…"); + try { + const result = await window.forgeflow.linkServerWorkload( + linkedRepository, + target.dataset.serverId, + target.dataset.workloadId, + deploymentMode, + remoteFolder, + ); + if (result.state) ui.boot.state = result.state; + ui.modal = null; + ui.selectedProfileId = result.profile?.id || null; + await refreshRepositories(false, true); + await refreshDeploymentTruth(false); + showToast( + "Workload linked", + `${linkedRepository.fullName} now uses ${deploymentMode === "push-bundle" ? "safe bundle upload" : deploymentMode === "monitor-only" ? "monitor-only mode" : "server-side Git"}.`, + "success", + ); + } catch (error) { + showToast("Could not link workload", error.message, "error"); + } + setLoading(false); } else if (action === "configure-deployment") { ui.deploymentDiscovery = null; ui.modal = { @@ -2155,7 +2312,7 @@ app.addEventListener("click", async (event) => { render(); } else if (action === "edit-deployment-profile") { ui.deploymentDiscovery = null; - if (!repository) repository = profileRepository(target.dataset.profileId); + repository = profileRepository(target.dataset.profileId) || repository; if (repository && String(repository.id) !== String(ui.selectedRepoId)) selectRepository(repository.id, false); ui.modal = { @@ -2248,6 +2405,20 @@ app.addEventListener("click", async (event) => { showToast("Could not save profile", error.message, "error"); return; } + const composeFiles = + provider === "ssh-unraid" + ? (document.querySelector("#profile-compose-files")?.value || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean) + : []; + const composeServices = + provider === "ssh-unraid" + ? (document.querySelector("#profile-compose-services")?.value || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean) + : []; const profile = { id: target.dataset.profileId || undefined, provider, @@ -2275,18 +2446,27 @@ app.addEventListener("click", async (event) => { remoteFolder: document .querySelector("#profile-remote-folder") .value.trim(), + deploymentMode: + document.querySelector("#profile-deployment-mode")?.value || + "push-bundle", cloneUrl: document.querySelector("#profile-clone-url").value.trim(), alignRemote: document.querySelector("#profile-align-remote") .checked, generatedCompose: document.querySelector("#profile-generated-compose").value === "true", - composeFile: document - .querySelector("#profile-compose-file") - .value.trim(), - composeService: document - .querySelector("#profile-compose-service") - .value.trim(), + composeProject: + document.querySelector("#profile-compose-project")?.value.trim() || + previousProfile.composeProject || + "", + composeWorkingDir: previousProfile.composeWorkingDir || "", + composeFiles: composeFiles.length ? composeFiles : ["docker-compose.yml"], + composeFile: composeFiles[0] || "docker-compose.yml", + composeServices: composeServices.length + ? composeServices + : [safeCloneFolderName(repository).toLowerCase()], + composeService: + composeServices[0] || safeCloneFolderName(repository).toLowerCase(), containerName: document .querySelector("#profile-container-name") .value.trim(), @@ -2308,8 +2488,25 @@ app.addEventListener("click", async (event) => { .value.split(",") .map((item) => item.trim()) .filter(Boolean), - adoptedFromServer: Boolean(ui.deploymentDiscovery), - serverSourceOfTruth: Boolean(ui.deploymentDiscovery), + manageDockerMan: + document.querySelector("#profile-manage-dockerman")?.checked === + true, + forceRecreate: + document.querySelector("#profile-force-recreate")?.checked === + true, + removeOrphans: + document.querySelector("#profile-remove-orphans")?.checked === + true, + adoptedFromServer: Boolean( + ui.deploymentDiscovery || previousProfile.adoptedFromServer, + ), + serverSourceOfTruth: Boolean( + ui.deploymentDiscovery || previousProfile.serverSourceOfTruth, + ), + workloadIdentity: + ui.deploymentDiscovery?.profile?.workloadIdentity || + previousProfile.workloadIdentity || + null, detectedAt: ui.deploymentDiscovery?.profile?.detectedAt || previousProfile.detectedAt || @@ -2759,15 +2956,18 @@ app.addEventListener("click", async (event) => { } else if (action === "test-server") { setLoading( true, - "Connecting to Unraid and checking Git and Docker Compose…", + "Checking SSH identity, Docker, Compose and optional Git capabilities…", ); try { const result = await window.forgeflow.testServer(target.dataset.serverId); ui.boot.state = result.state; + const capabilities = result.capabilities || {}; + const deploymentReady = + capabilities.docker && capabilities.dockerReady && capabilities.compose; showToast( - "SSH server ready", - `${result.server.name} presented ${result.fingerprint}.`, - "success", + deploymentReady ? "SSH server ready" : "SSH connected with missing tools", + `${result.server.name} presented ${result.fingerprint}. Docker ${capabilities.dockerReady ? "ready" : "unavailable"}; Compose ${capabilities.compose ? "ready" : "missing"}; server-side Git ${capabilities.git ? "available" : "not installed (optional)"}.`, + deploymentReady ? "success" : "info", ); } catch (error) { showToast("SSH test failed", error.message, "error"); diff --git a/src/renderer/mock-bridge.js b/src/renderer/mock-bridge.js index 8f43284..dab06fd 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.9-demo", + appVersion: "0.9.0-demo", platform: "win32", state: clone(state), git: { available: true, version: "git version 2.47.3" }, @@ -1204,7 +1204,7 @@ )), ...input, id: input.id || `profile-${Date.now()}`, - provider: "gitea-actions", + provider: input.provider || existing?.provider || "gitea-actions", inputs: existing?.inputs || {}, state: existing?.state || { liveSha: null, @@ -1425,14 +1425,114 @@ await wait(80); return [ { - serverId: "unraid-primary", + serverId: "server-unraid", + serverName: "Unraid", detected: 2, adopted: 0, verified: 1, + linked: 1, unmatched: 0, + needsReview: 1, + running: 2, + stopped: 0, + capabilities: { + docker: true, + dockerReady: true, + compose: true, + git: false, + tar: true, + checksum: true, + }, + warnings: [], + workloads: [ + { + workloadId: "workload-demo-linked", + displayName: "Portfolio", + status: "linked", + runtime: { running: true, health: "healthy" }, + compose: { + project: "portfolio", + workingDir: "/mnt/user/appdata/portfolio", + configFiles: ["/mnt/user/appdata/portfolio/docker-compose.yml"], + services: ["web"], + }, + containers: [{ name: "Portfolio", running: true }], + candidates: [], + link: { + profileId: "profile-portfolio", + repositoryFullName: "jens/portfolio", + source: "manual", + }, + }, + { + workloadId: "workload-demo-review", + displayName: "OmniRoute", + status: "suggested", + runtime: { running: true, health: "unverified" }, + compose: { + project: "omniroute", + workingDir: "/mnt/user/appdata/OmniRoute", + configFiles: ["/mnt/user/appdata/OmniRoute/docker-compose.yml"], + services: ["omniroute"], + }, + containers: [{ name: "omniroute", running: true }], + remoteFolderCandidate: "OmniRoute", + candidates: repositories.slice(0, 1).map((repository) => ({ + repositoryFullName: repository.fullName, + repositoryName: repository.name, + score: 55, + exact: false, + reasons: ["container and repository names are similar"], + })), + }, + ], }, ]; }, + async linkServerWorkload(repository, serverId, workloadId, deploymentMode = "push-bundle", remoteFolder = "") { + await wait(120); + const repo = repositories.find((item) => item.fullName === repository.fullName); + if (!repo) throw new Error("Repository not found."); + const id = `profile-${workloadId}`; + const saved = { + id, + name: `Unraid · ${remoteFolder || repo.name}`, + environment: "production", + provider: "ssh-unraid", + branch: repo.defaultBranch || "main", + serverId, + remoteFolder: remoteFolder || repo.name, + deploymentMode, + composeFile: "docker-compose.yml", + composeFiles: ["docker-compose.yml"], + composeProject: String(remoteFolder || repo.name).toLowerCase(), + composeService: String(remoteFolder || repo.name).toLowerCase(), + composeServices: [String(remoteFolder || repo.name).toLowerCase()], + containerName: remoteFolder || repo.name, + preservePaths: [".env", "appdata", "data", "logs", "config"], + generatedCompose: false, + adoptedFromServer: true, + serverSourceOfTruth: true, + manageDockerMan: false, + forceRecreate: false, + removeOrphans: false, + workloadIdentity: { workloadId, linkSource: "manual", linkedAt: iso() }, + confirmationRequired: true, + state: { + liveSha: null, + healthy: null, + containerRunning: true, + runtimeVerification: "running-unverified", + checkedAt: iso(), + }, + }; + repo.deploymentProfiles = [ + ...repo.deploymentProfiles.filter((item) => item.id !== id), + saved, + ]; + syncState(); + return { profile: clone(saved), state: clone(state) }; + }, async refreshOperations(operationId = null) { await wait(300); if (operationId) { diff --git a/tests/ssh-service.test.mjs b/tests/ssh-service.test.mjs new file mode 100644 index 0000000..0ede260 --- /dev/null +++ b/tests/ssh-service.test.mjs @@ -0,0 +1,37 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { SshService, parseCapabilityOutput } = require("../src/main/ssh-service.cjs"); + +test("SSH capability parsing keeps Git optional and reports deployment prerequisites separately", () => { + const b64 = (value) => Buffer.from(value).toString("base64"); + const parsed = parseCapabilityOutput(`noise\n__FORGEFLOW_SERVER_TEST__\nplatform=${b64("Linux Unraid")}\ndocker=true\ndockerReady=true\ncompose=true\ncomposeVersion=${b64("Docker Compose version v2.40.0")}\ngit=false\ntar=true\nchecksum=true\nbaseWritable=true\n`); + assert.equal(parsed.dockerReady, true); + assert.equal(parsed.compose, true); + assert.equal(parsed.git, false); + assert.equal(parsed.tar, true); + assert.equal(parsed.checksum, true); + assert.equal(parsed.baseWritable, true); +}); + +test("SSH execution rejects truncated output instead of using an incomplete inventory", async () => { + const service = new SshService({ store: {}, diagnostics: null }); + const stream = new EventEmitter(); + stream.stderr = new EventEmitter(); + const client = { + exec(_command, callback) { + callback(null, stream); + queueMicrotask(() => { + stream.emit("data", Buffer.from("x".repeat(64))); + stream.emit("close", 0, null); + }); + }, + }; + await assert.rejects( + service.execClient(client, "inventory", { maxOutput: 16, timeout: 1_000 }), + (error) => error?.code === "SSH_OUTPUT_TRUNCATED" && /incomplete result/.test(error.message), + ); +}); diff --git a/tests/unraid-deployment.test.mjs b/tests/unraid-deployment.test.mjs index 1004c6b..96efe64 100644 --- a/tests/unraid-deployment.test.mjs +++ b/tests/unraid-deployment.test.mjs @@ -16,6 +16,7 @@ const { bash, } = require("../src/main/unraid-deployment-service.cjs"); const { fingerprintKey, shellQuote } = require("../src/main/ssh-service.cjs"); +const { buildWorkloadInventory } = require("../src/main/server-inventory.cjs"); test("Unraid remote paths cannot escape appdata project folder", () => { assert.equal(safeRemoteFolder("lumaops"), "lumaops"); @@ -131,6 +132,164 @@ test("automatic server discovery adopts and verifies a running Gitea deployment" assert.equal(states.get(profiles[0].id).matchesGitea, true); }); + + +test("server inventory includes stopped DockerMan containers without Git and keeps name matches manual", () => { + const workloads = buildWorkloadInventory({ + inventory: { + checkouts: [], + dockerMan: [ + { + name: "omniroute", + templatePath: "/boot/config/plugins/dockerMan/templates-user/my-omniroute.xml", + webUiUrl: "http://[IP]:[PORT:20128]/", + iconUrl: "", + shell: "sh", + repository: "diegosouzapw/omniroute:latest", + network: "bridge", + }, + ], + containers: [ + { + id: "container-1", + name: "omniroute", + image: "ghcr.io/diegosouzapw/omniroute:latest", + imageId: "sha256:image", + running: false, + status: "exited", + health: null, + labels: {}, + ports: { "3000/tcp": [{ HostPort: "20128", HostIp: "0.0.0.0" }] }, + mounts: [ + { + Type: "bind", + Source: "/mnt/user/appdata/OmniRoute/config", + Destination: "/app/config", + RW: true, + }, + ], + networks: { bridge: {} }, + restartPolicy: "unless-stopped", + }, + ], + warnings: [], + capabilities: { docker: true, compose: true }, + }, + server: { + id: "unraid", + name: "Unraid", + basePath: "/mnt/user/appdata", + }, + repositories: [ + { + fullName: "Jens/OmniRoute", + name: "OmniRoute", + cloneUrl: "https://gitea.itworx.tech/Jens/OmniRoute.git", + }, + ], + profiles: [], + }); + assert.equal(workloads.length, 1); + assert.equal(workloads[0].runtime.running, false); + assert.equal(workloads[0].kind, "dockerman-container"); + assert.equal(workloads[0].remoteFolderCandidate, "OmniRoute"); + assert.equal(workloads[0].status, "suggested"); + assert.equal(workloads[0].candidates[0].exact, false); + assert.match(workloads[0].candidates[0].reasons.join(" "), /manual confirmation/i); +}); + +test("server inventory groups multi-service Compose projects and preserves their identity", () => { + const baseContainer = { + image: "example/app:latest", + imageId: "sha256:image", + running: true, + status: "running", + health: null, + ports: {}, + mounts: [], + networks: { appnet: {} }, + restartPolicy: "unless-stopped", + }; + const labels = { + "com.docker.compose.project": "forgeflow", + "com.docker.compose.project.working_dir": "/mnt/user/appdata/ForgeFlow", + "com.docker.compose.project.config_files": "/mnt/user/appdata/ForgeFlow/compose.yml,/mnt/user/appdata/ForgeFlow/compose.prod.yml", + }; + const workloads = buildWorkloadInventory({ + inventory: { + checkouts: [], + dockerMan: [], + warnings: [], + capabilities: {}, + containers: [ + { + ...baseContainer, + id: "web", + name: "forgeflow-web-1", + labels: { ...labels, "com.docker.compose.service": "web" }, + }, + { + ...baseContainer, + id: "worker", + name: "forgeflow-worker-1", + labels: { ...labels, "com.docker.compose.service": "worker" }, + }, + ], + }, + server: { id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata" }, + repositories: [], + profiles: [], + }); + assert.equal(workloads.length, 1); + assert.deepEqual(workloads[0].compose.services.sort(), ["web", "worker"]); + assert.deepEqual(workloads[0].compose.configFiles, [ + "/mnt/user/appdata/ForgeFlow/compose.yml", + "/mnt/user/appdata/ForgeFlow/compose.prod.yml", + ]); + assert.equal(workloads[0].compose.project, "forgeflow"); + assert.equal(workloads[0].remoteFolderCandidate, "ForgeFlow"); +}); + + + +test("manual workload linking does not claim Gitea parity for unrelated provenance", async () => { + let savedState = null; + const service = new UnraidDeploymentService({ + store: { + saveDeploymentState: async (_id, state) => { + savedState = state; + return state; + }, + }, + ssh: {}, + git: {}, + diagnostics: null, + }); + await service.saveWorkloadState( + { + id: "profile", + containerName: "app", + remoteFolder: "app", + cloneUrl: "https://gitea.itworx.tech/Jens/Expected.git", + }, + { + workloadId: "workload", + observedAt: new Date().toISOString(), + metadata: { + sourceRepository: "https://gitea.itworx.tech/Jens/Other.git", + liveRevision: "a".repeat(40), + }, + runtime: { running: true, health: "healthy" }, + containers: [{ name: "app", running: true, health: "healthy" }], + compose: { project: "app" }, + }, + { basePath: "/mnt/user/appdata" }, + ); + assert.equal(savedState.liveSha, "a".repeat(40)); + assert.equal(savedState.matchesGitea, false); + assert.equal(savedState.giteaSha, null); +}); + 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); @@ -207,6 +366,9 @@ test("SSH helpers produce pinned fingerprints and quoted commands", () => { const decoded = Buffer.from(encoded, "base64").toString("utf8"); assert.match(decoded, /GIT_TERMINAL_PROMPT=0/); assert.match(decoded, /BatchMode=yes/); + assert.match(decoded, /forgeflow_compose\(\)/); + assert.match(decoded, /docker compose "\$@"/); + assert.match(decoded, /docker-compose "\$@"/); assert.match(decoded, /git fetch origin main/); }); @@ -428,8 +590,8 @@ test("DockerMan metadata uses dockerman labels, a template WebUI and lowercase-s "file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png", ); assert.match(metadata, / portfolio:/); - assert.match(metadata, /image: forgeflow\/portfolio:production/); - assert.match(metadata, /container_name: Portfolio/); + assert.doesNotMatch(metadata, /image: forgeflow\/portfolio:production/); + assert.doesNotMatch(metadata, /container_name: Portfolio/); assert.match(metadata, /net\.unraid\.docker\.managed.*dockerman/); assert.match( metadata, @@ -456,6 +618,8 @@ test("DockerMan integration writes a persistent template fallback and invalidate hostPort: 5150, webUiUrl: "http://192.168.10.150:5150/", dockerShell: "/bin/sh", + manageDockerMan: true, + generatedCompose: true, }; const repository = { name: "Portfolio" }; const icon = @@ -482,6 +646,32 @@ test("DockerMan integration writes a persistent template fallback and invalidate assert.equal(xmlEscape('A&B<"x">'), "A&B<"x">"); }); + + +test("adopted DockerMan templates are never rewritten", () => { + const service = new UnraidDeploymentService({ + store: {}, + ssh: {}, + git: {}, + diagnostics: null, + }); + const refresh = service.dockerManRefreshScript( + { + composeService: "omniroute", + containerName: "omniroute", + remoteFolder: "OmniRoute", + environment: "production", + manageDockerMan: true, + generatedCompose: false, + adoptedFromServer: true, + }, + { name: "OmniRoute" }, + "", + ); + assert.match(refresh, /left the existing DockerMan template unchanged/); + assert.doesNotMatch(refresh, /templates-user/); +}); + test("built-in ITWorx DockerMan icon is uploaded to persistent Unraid storage", async (t) => { const { mkdtemp, mkdir, writeFile, rm } = await import("node:fs/promises"); const os = await import("node:os"); @@ -759,3 +949,183 @@ test("a failed deployment is marked superseded when Gitea and Unraid agree on a assert.equal(operations[0].status, "cancelled"); assert.match(operations[0].error, /Superseded/); }); + +test("linked Compose deployments retain the existing project, files and service set", () => { + const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {} }); + const repository = { name: "OmniRoute", fullName: "Jens/OmniRoute" }; + const profile = { + composeProject: "omniroute-production", + composeFiles: ["compose.yml", "compose.unraid.yml"], + composeServices: ["api", "worker"], + generatedCompose: false, + }; + const invocation = service.composeInvocation(profile, repository); + assert.match(invocation, /-p 'omniroute-production'/); + assert.ok(invocation.indexOf("-f 'compose.yml'") < invocation.indexOf("-f 'compose.unraid.yml'")); + assert.ok(invocation.indexOf("-f 'compose.unraid.yml'") < invocation.indexOf("-f '.forgeflow\/compose.metadata.yml'")); + assert.deepEqual(service.deploymentServices(profile, repository), ["api", "worker"]); +}); + +test("push bundle activation validates Compose and services before promoting current SHA", () => { + const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {} }); + const repository = { name: "OmniRoute", fullName: "Jens/OmniRoute" }; + const profile = { + environment: "production", + deploymentMode: "push-bundle", + composeProject: "omniroute", + composeFiles: ["compose.yml", "compose.unraid.yml"], + composeServices: ["api", "worker"], + preservePaths: ["data", "config"], + generatedCompose: false, + adoptedFromServer: true, + manageDockerMan: false, + }; + const script = service.pushBundleScript({ + repository, + profile, + remotePath: "/mnt/user/appdata/OmniRoute", + targetSha: "a".repeat(40), + requestId: "request-1", + remotePart: "/mnt/user/appdata/.forgeflow/incoming/request-1.tar.part", + digest: "b".repeat(64), + metadata: "services:\n api:\n labels: {}\n worker:\n labels: {}\n", + generated: "", + iconReference: "", + }); + const configIndex = script.indexOf("config >/dev/null"); + const upIndex = script.indexOf("up -d --build"); + const serviceCheckIndex = script.indexOf("Compose service $service did not create a container"); + const promoteIndex = script.indexOf('current-sha.pending'); + assert.ok(configIndex >= 0 && configIndex < upIndex); + assert.ok(upIndex < serviceCheckIndex); + assert.ok(serviceCheckIndex < promoteIndex); + assert.match(script, /mmin \+120/); + assert.match(script, /grep -E '\(\^\/\|\(\^\|\/\)\\\.\\\.\(\/\|\$\)\)'/); + assert.match(script, /kill -0 "\$lock_pid"/); + assert.match(script, /is_preserved "\$rel" && continue/); + assert.doesNotMatch(script, /git clone|git -C "\$root" fetch/); + assert.match(script, /ForgeFlow left the existing DockerMan template unchanged/); +}); + +test("generated push-bundle command passes Bash syntax validation", { skip: process.platform === "win32" }, async () => { + const { spawnSync } = await import("node:child_process"); + const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {} }); + const repository = { name: "Demo", fullName: "Jens/Demo" }; + const profile = { + environment: "production", + deploymentMode: "push-bundle", + composeProject: "demo", + composeFiles: ["compose.yml"], + composeServices: ["app"], + preservePaths: ["data"], + generatedCompose: false, + adoptedFromServer: true, + manageDockerMan: false, + }; + const generated = service.pushBundleScript({ + repository, + profile, + remotePath: "/mnt/user/appdata/Demo", + targetSha: "d".repeat(40), + requestId: "syntax-test", + remotePart: "/mnt/user/appdata/Demo/.forgeflow/incoming/syntax-test.tar.part", + digest: "e".repeat(64), + metadata: "services:\n app:\n labels: {}\n", + generated: "", + iconReference: "", + }); + const wrapped = bash(generated); + const encoded = wrapped.match(/printf '%s' '([A-Za-z0-9+/=]+)'/)[1]; + const script = Buffer.from(encoded, "base64").toString("utf8"); + const result = spawnSync("bash", ["-n"], { input: script, encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr || result.stdout); +}); + +test("push bundle preflight does not require Git or Gitea credentials on Unraid", async (context) => { + const { mkdtemp, writeFile, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const localPath = await mkdtemp(join(tmpdir(), "forgeflow-push-preflight-")); + context.after(() => rm(localPath, { recursive: true, force: true })); + await writeFile(join(localPath, "compose.yml"), "services:\n app:\n image: example/app:latest\n"); + const sha = "c".repeat(40); + const profile = { + id: "production", + name: "Production", + environment: "production", + provider: "ssh-unraid", + branch: "main", + serverId: "unraid", + remoteFolder: "OmniRoute", + deploymentMode: "push-bundle", + composeFile: "compose.yml", + composeFiles: ["compose.yml"], + composeService: "app", + composeServices: ["app"], + iconMode: "none", + generatedCompose: false, + preservePaths: ["data"], + }; + let remoteGitProbeCount = 0; + const service = new UnraidDeploymentService({ + store: { + getDeploymentProfile: () => profile, + getServer: () => ({ + id: "unraid", + name: "Unraid", + host: "192.168.10.150", + port: 22, + username: "root", + basePath: "/mnt/user/appdata", + hostFingerprint: "SHA256:test", + }), + }, + git: { + status: async () => ({ + root: localPath, + head: sha, + clean: true, + counts: { changed: 0 }, + branch: { head: "main", upstream: "origin/main", ahead: 0, behind: 0 }, + }), + verifyCommitOnRemoteBranch: async () => true, + }, + ssh: { + test: async () => ({ + capabilities: { + docker: true, + dockerReady: true, + compose: true, + composeVersion: "Docker Compose version v2", + git: false, + tar: true, + checksum: true, + baseWritable: true, + }, + }), + exec: async (_serverId, command) => { + if (String(command).includes("git ls-remote")) remoteGitProbeCount += 1; + const encodedFiles = Buffer.from("").toString("base64"); + return { + stdout: `__FORGEFLOW_KV__\nexists=false\nrootGit=false\nhead=\nbranch=\nremote=\ntrackedChanges=\ncomposeFiles=${encodedFiles}\nnestedGit=\ndockerfile=false\ndockerignoreContent=\nexistingPreservePaths=\n`, + }; + }, + }, + sourcePath: new URL("..", import.meta.url).pathname, + }); + const result = await service.preflight({ + repository: { + fullName: "Jens/OmniRoute", + name: "OmniRoute", + localPath, + localStatus: { head: sha }, + }, + profileId: "production", + sha, + }); + assert.equal(remoteGitProbeCount, 0); + assert.equal(result.checks.find((item) => item.id === "server-git-access")?.status, "pass"); + assert.match(result.checks.find((item) => item.id === "server-git-access")?.detail || "", /Not required/); + assert.equal(result.checks.some((item) => item.id === "server-git-command"), false); + assert.equal(result.summary.ready, true); +});