diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c47c04..b1039fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## 0.10.0 - 2026-07-28 + +- Added safe Gitea Server pull with repository-scoped read-only deploy keys and pinned SSH host fingerprints. +- Automatically discovers and links unique running repository workloads while filtering system containers and stale release folders. +- Reconciles live server SHA, Gitea SHA and runtime health, including deployments changed outside ForgeFlow. +- Preserves adopted Compose and DockerMan identity and avoids duplicate repository links. +- Expanded Git Validator with editor, line-ending, dependency-lock and Gitea Actions checks plus reviewable fixes. +- Refined the dark deployment workspace with clearer workload identity, focused inventory and motion-safe illustration. +- Added live connection/deployment audit scripts and kept automatic cleanup of older packaged artifacts. + +## 0.9.5 - 2026-07-28 + +- Added a mandatory remote write-access preflight before every Direct copy deployment and again immediately before upload. +- The preflight reports the exact failing path, SSH user, owner, group and mode instead of allowing a partial copy or Docker activation. +- Added an in-app **Check / fix write access** action on every SSH / Unraid deployment and directly beside failed permission checks. +- The repair is restricted to the linked project source and `.forgeflow` state, assigns the Unraid `users` group where available, preserves executable bits and excludes configured runtime data. +- Explicit permission repair now also normalizes SMB/manual-copy access even when the SSH account is root and could already write. +- Direct copy is fail-closed: candidate Compose configuration and images are validated before live files change; no implicit `down`, `--remove-orphans` or `--force-recreate` is used. +- A failed activation restores the prior source and image tags, attempts to restore the previous runtime and retains rollback evidence for diagnosis. +- Removed orphan deletion from the legacy server deployment helper as well. + +## 0.9.4 - 2026-07-28 + +- Fixed imported deployments failing with `service has neither an image nor a build context` because a stale labels-only metadata overlay introduced a phantom service. +- Existing workloads now activate strictly from their real Compose files; ForgeFlow metadata is stored outside the active Compose model. +- Redeploy always uses `--force-recreate` and verifies every service returned by `docker compose config --services`. +- A deployment is rejected when the container ID did not change or when the previous hinted container remains running beside a duplicate workload. +- Deployment SHA is promoted only after recreation and runtime checks complete. + +## 0.9.3 - 2026-07-28 + +- removed every server-to-repository authentication check and all server-side Git deployment branches from SSH/Unraid deploy and rollback; +- forcibly migrated legacy SSH/Unraid profiles to direct local bundle copy, except explicit monitor-only profiles; +- discovered Compose YAML definitions directly from configured Unraid appdata roots even when Docker inspection fails; +- merged YAML definitions with runtime containers, stopped containers and DockerMan templates; +- automatically linked unique high-confidence Compose folder/project matches and added one-click linking for remaining strong matches with all fields prefilled; +- made inventory failure handling compatible with strict Bash execution, pruned large runtime folders during YAML discovery and normalized user-share/cache/disk appdata paths. + ## 0.9.0 - 2026-07-27 - replaced Git-checkout-only discovery with a complete Unraid workload inventory, including stopped, Compose, DockerMan and standalone containers; diff --git a/README.md b/README.md index abab930..68fa0fb 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.9.0** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest) +> Huidige release: **0.10.0** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest) ![ForgeFlow release-overzicht](docs/screenshots/overview.png) @@ -14,7 +14,7 @@ ForgeFlow is een desktopapp voor teams die met Git, Gitea en eigen servers werke - **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. - **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. +- **Veilige server-pull:** Unraid haalt de exacte commit uit Gitea met een unieke, repository-scoped read-only deploy key en een vastgepinde SSH-hostsleutel. - **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. @@ -95,7 +95,8 @@ Een gelijke commit bewijst welke code draait; een geslaagde healthcheck bewijst - Gitea Actions en SSH/Unraid als gecontroleerde uitvoeringsroutes; - 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; +- server-pull als aanbevolen route, met een afzonderlijke read-only deploy key per repository; +- directe checksum-gecontroleerde copy als alternatief zonder servertoegang tot Gitea; - 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 631f4b1..151f864 100644 --- a/SOURCE_MANIFEST.txt +++ b/SOURCE_MANIFEST.txt @@ -1,4 +1,4 @@ -ForgeFlow 0.9.1 source manifest +ForgeFlow 0.10.0 source manifest SHA-256 BYTES PATH (The manifest excludes itself, dependencies and generated release artifacts.) 755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 58 .gitignore @@ -12,13 +12,14 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263 4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png 25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png -65a78f6aeb0a3a4e3ef00e33913438b0585259fb3103f43baf4401d5c88c85a8 12252 CHANGELOG.md +5f4aca19a35cbcaffa1a6993ce96b7d66052ec2b286022f2af74594e8a310568 15712 CHANGELOG.md 21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md 30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md eb42f979666e05d51c587e4223282914926a2b9b1ade9f3fb75525019ce7f738 4616 docs/DIAGNOSTICS.md a0cd06a96f23a94e118feb012be0fa1ac51345951cb2ba8e67fb8c889c4c342a 5007 docs/LUMAOPS_SERVER_AUDIT.md f79908fb3dad98c38030c6e6be7c79a1999e0478ed9c2496923891954438daa1 4581 docs/RELEASE_AUDIT_0.6.0.md +979a0b8e129979be6b265e8571d0a3c1e9ddd4ddb6b0bf55ae748d3478e51854 2296 docs/RELEASE_NOTES_0.10.0.md a0c00ff76acd1682bb5e0e8dcf6589c9480da436c9c6d30780a1ed58b4dad94f 1770 docs/RELEASE_NOTES_0.2.0.md 5773ead01aa4c522c556295553787482d01b1f5242f053b2c61f120c4de4fa76 5963 docs/RELEASE_NOTES_0.3.0.md d46de73cf6c4cd5c2ba3f455a7a2af2e0d64ee9d94a97fd1a0bfb44e35c1624a 1093 docs/RELEASE_NOTES_0.3.1.md @@ -49,24 +50,28 @@ ef049adcfa204908e6dc3a059124b39ba0e2739cc54e38945ce73a57049df0d8 1185 35dcfda990946480d6d55bd2d2e6360c336260bcd05cdb51d92e07a4e8d76945 1046 docs/RELEASE_NOTES_0.8.9.md 29f7b11fef1e4960ef874f643b2206ca73a310b0b75402bcd3764a01e59db2e0 3114 docs/RELEASE_NOTES_0.9.0.md ed40e08bac8792f95970bc05e49bce3cc9e288a08d11565a1bd156d787360a3b 720 docs/RELEASE_NOTES_0.9.1.md +01bf2cc72593b10010b667ae017b9eb62f4658a9711bbf24c81f5f95d93fe8c8 827 docs/RELEASE_NOTES_0.9.2.md +25169225d73d22b9d884ab3b5c1625f03fd44e53c7a7a4c4067775e80482c9f8 2182 docs/RELEASE_NOTES_0.9.3.md +720506842e0aeb30c9fc635f86a52a5545556f092e678cf37f08436243244c3d 933 docs/RELEASE_NOTES_0.9.4.md +dd90c81a375f97dfb7fa8f7808db03b19d7e7dafe3818a93537397f57eaae829 2109 docs/RELEASE_NOTES_0.9.5.md 2b631b9d6d973bdd70869d84886ff339da351e29e17598970b3b27915674661d 4175 docs/ROADMAP.md 1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png 070e6700bdae8c628c907ba181bbf0dde0bbbbb4208f7a875503f933ff1b882e 118819 docs/screenshots/deployment-success.png -495c55c37a42e7f8ab302d838ed73c232190d33e4e04eea3e3fa4ad1fe795dbc 105224 docs/screenshots/deployments.png +581375ee0727911f85b0441f09734c6215ea8dd6cfaba4a7555599df0edb24f1 333382 docs/screenshots/deployments.png 87546583580e8591b1306f997d27445725b0bf5a5a79a839af3a727964e65bc2 103901 docs/screenshots/git-validator.png bbdbe91679b486cc92dec4758ce1cdaf24e3277d038c57e794a04c0dee7e3a5b 84046 docs/screenshots/overview.png c8a5e80bb9fd2d442d2d23d30e6ac1528cf2330e6e19492b7c6799e2d1508b53 112868 docs/screenshots/repository-workspace.png 322624242d246d07180cc719e14c91e8fb69e123676a02e5046f4e576cca1ca1 5569 docs/SECURITY.md 32a34ec13a284d3f9ceebbc107b25a844e3db096f8cafa4e43951fc2050c9a03 13552 docs/SETUP_GUIDE.md -6d053a73500eda1f48abd633bc8936d4397c7b46e95bc6c3a02aa698ec12f2a8 6056 docs/SSH_UNRAID_DEPLOYMENT.md +2fd71e9bcaeb4cb10c3fa2496b7e52fedf70c5b7f871cd587e22dc060c399079 4421 docs/SSH_UNRAID_DEPLOYMENT.md b6a178215dab054006aae4944b8ffcbe7f6100691c30f08e221e3a2dbff4cd42 2147 docs/STATUS_ENDPOINT.md 0adfeabb98168a7fc0b02bae8d4af436d3c59459012fb05b2216e02265190128 3139 docs/STITCH_REVIEW.md 4625a10ebd3c749f60b2a7bef6b1716cd05dbc44ccceba0491a1b46bc293c195 4883 docs/TEST_MATRIX.md dbbd9fa96988e7543e98c85da864adaadd3057815f18d20a3b3ccb5c540a169d 4558 docs/UPDATING.md c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 examples/gitea-actions/deploy.yml 4c792cc9fd57ed36da291300c252a6ef75b08a249cf6f2561e23c4c22522138a 1477 examples/gitea-actions/rollback.yml -1d2cde1bef4882f56006823d2806f6105882fa098a665a303150fdf18ada2004 5705 examples/server/forgeflow-deploy +577f3fa2131a3baa84549a6523f5816ef9da94f5bac6bc274d4588b6e7ab6594 5688 examples/server/forgeflow-deploy 4fe3eee5c2d8705964c24b8c4dd909883a05e7d6eb85629c84b0d64473e0a92b 258 examples/server/forgeflow-runner.sudoers 0423fe2cc7f43fe793986a3f62a395668897cdf07348756aa7742a8cd40ac51c 569 examples/server/forgeflow-targets.conf 106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf @@ -74,58 +79,59 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE 3b16a087c73b600415394dff8b8e34e7f7519e48fde1cf443007b2e11ca77b27 13123 main.cjs 91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md -7672d16f79afe9273c56ee6e3a579145c20c1f744acb25ead8605df836c5bf34 130466 package-lock.json -0ea7d7a6457ae5e2ca9b90f78a38ad9487957bd92b3449904851d7274f108ac3 4100 package.json -6b66a5aef158de35d5cdd0f205ceeec53b6a375937a67c5e9d00e483999ff840 9898 preload.cjs +efe2f75ec8bfdbad8e2ee68d0f2c4b412ba460c6b841163d7853f56d5b7b63ea 130468 package-lock.json +605514833fe95a59cbfd5cee6a509ce8a0228a8b79c1fcc5bdb4e8fea92084b7 4364 package.json +e95be3f3736f2c1ef249fdc9a083fffa46c07a0b885979878a552a4846995aa6 10160 preload.cjs abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1 688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1 -d4b3a2f1174fe056e89e05de9c6478e42cf6758c7279f69f6c2d4bf0aed839ca 9063 README.md +794bbe1937077788f34c64398fd73dc9a3c43d095084aa32673f3e968b115de2 9150 README.md 509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs 00d57bda5af8c8eda294b72d18b318f024a307b81b0d9205a0821f5240151e31 3814 scripts/apply-binary-update.ps1 f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 scripts/apply-source-update.ps1 +33a040b12fc5deab05f9df5f71986d0bb71b91f15a8cd25e9ab3cf7ca9a26766 4212 scripts/audit-installed-deployments.cjs 6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs -5e9a2a819522f6a32bbd9d3303263d5e5eaec95898ea2cd5776b221168008d75 1727 scripts/generate-source-manifest.mjs +f6f89e893195b9c8ef0ff01e256005b9b3cd7d4a278722979a5e0e616c86a89f 1733 scripts/generate-source-manifest.mjs 842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs 403a64db5069595a83006a4e293d7e5ceeaefcfb74e820ed3e899864a0f182d2 6066 scripts/publish-binary-release.cjs 444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs -42203f9e0fd4aae517284d387f265cf1b0b180379bc253a092b5c3c5c4caef0a 2992 scripts/validate-installed-connections.cjs -b9a013440b75db306dee754a8711ae8458c5271d9ddd921589c26f8d97105970 13542 scripts/verify.mjs +4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs +43e84c3cced1e23ba5b070d87051a235cd7e2d8c02e2e5ddc1e5e0ff2afabace 16487 scripts/verify.mjs 0079701b5acbfef07b71a9623613d1940805ccd20649d77e3f34c37e79df7655 735 scripts/write-release-checksums.mjs 619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1 dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs -e3e3839042dad88bba6971535d34ba2531dfd129d963b14ab9df887f68179cbf 27126 src/main/config-store.cjs +c9dee05857b6eb9475dace885579291b9ce6830023ab54acafaa146d15962a7a 27754 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 a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs 0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs -857f270a2204b743421eea619a6a88595f749e4c24c1794cb092ace7987d25dc 12553 src/main/git-validator-service.cjs -4a58f02cd93e40d5e7880206b279dda75aa1f729f82733e19c554aaaddc1ff35 18436 src/main/gitea-service.cjs -703547b6d8f5837801b953cb4c049e41139343da131088e0cd5bb7382c05e772 48373 src/main/ipc.cjs +3ce45837099ac7bddc024974bd839575b4b765a7df9055e7d45ef889dc85bf7f 15623 src/main/git-validator-service.cjs +d85d5b1abb35e8bd7f1273a697914eeaf96567d3d4ce3f55f364765d35ea4ad9 19900 src/main/gitea-service.cjs +8adeebd08aeafaf79ed8fc7bcd2c1e110d3dc17c14596fb623b9d2c7255fcd11 49662 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 -cc4d5e06119d0315e4240f0776d68aacf72f2f36907f40830fa86e7bb0876490 18250 src/main/server-inventory.cjs +52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs -51bfc677fcdaec75ac6abe1a55ff531bc081787053b49ee56d8bfffcab666aa4 99239 src/main/unraid-deployment-service.cjs +75f4135bc068d6c615fc317cfda81f33a4387c4ff44fbb9799a055f302cc154d 137170 src/main/unraid-deployment-service.cjs b654a9e45044ad32c61fabe4a6d897288615ec83739b53e3241ff881e32f56bd 21677 src/main/update-service.cjs -704306badd4a1a7080c3d2f4407c8dc1d8ec5f807dd165cd22a37e48d6022435 220714 src/renderer/app.js +07822cdaf34745678b82b7eb0f20fedb16bdbcd6ab2b9b774ae0cf9c6069032d 234703 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 -d102db2f63d92d8b6af85fc798453dc3bd78cfbe5542cbf0af740781d059fa0f 58577 src/renderer/mock-bridge.js -607b2592c6aee37de91126a80704c1f7d8575cb2e5ff889305cd86a37e6170c4 76937 src/renderer/styles.css +fb7ed47f9aac50d9259d7d3c3bb2010c7bfdd2fe8e8e47ca2744bb22f0057d54 830 src/renderer/index.html +b63786e7e4f70eac8b65d29484c0040aec530a410da5f554574d8a056cd90003 60621 src/renderer/mock-bridge.js +34ee56ed08dcd1c2295985b9bb419b981598a2699ee8e65394d86f4177f4797b 77647 src/renderer/styles.css 0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs 5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs 029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs 2778ebcbdf60fdc1cb0749f15565e0e1bd66f3a0d31eb70ae7942a7511a3de75 1295 src/shared/repository-match.cjs c7e120ea53c5ef3c01b8cce71afe913f34bb461bb73aa3ade24656e09f99f338 1152 src/shared/semver.cjs -ede2c95bb045c0005a3931709a0116d9fbcb3faa5f609848a0066c6ba382ca0b 2906 src/shared/shell-verification.cjs +8791d3813e6cf285ee6aa49f76e75fc1f3af76fd98c76bcb3c92ee18e9cb699f 2889 src/shared/shell-verification.cjs 2daa98fd421598bfe5fc9757c9b6f4d82c31d1bfece15829928473581d5d2639 1210 src/shared/tool-invocation.cjs 114f01be8bd54c91b90af82d8e1604e24cc0c5f8e64e63c40cf3f4042623a98e 5402 src/shared/validation.cjs 13b731c38863b1007b0312fd9d89562401b7cce875c952f52429bde74f77a8af 3096 src/shared/zip-writer.cjs @@ -141,14 +147,14 @@ fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800 dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762 tests/external-tools.test.mjs e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 tests/git-integration.test.mjs 5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db 1239 tests/git-status.test.mjs -c98cbe50a783e2a1cfecf9052f558aabe656add6a463899532dd59d743b720b2 3645 tests/git-validator.test.mjs +73d00729696e5067ba33dd6d43b018d89ce7fdd561a60ab66648d3283fb54d21 5370 tests/git-validator.test.mjs 681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs -8ffadbf02ebbaead210cce636e69b943e0891524b92090534c7e7139a8c202b7 7718 tests/gitea-actions.test.mjs +771eeb4fa5443d581991cedb4107d7c414ce1b7d2e14bac7cf719ec8ba131647 8969 tests/gitea-actions.test.mjs 48bca4711e7c193d19c78a0cb45ea1c83179b3c23640195f66058268e8a11b52 1520 tests/ipc-contract.test.mjs caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs 96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 3718 tests/preflight.test.mjs -9b2534196f2f6f61fd1c8c5dc44634375bb65ad3000096904d6b10c07d221b13 9158 tests/renderer-workflow.test.mjs +15e05220fd282895b02ea52ceaa38327a61297af8db1484e6d9964e8b03a8fea 9156 tests/renderer-workflow.test.mjs 2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 1068 tests/repository-monitor.test.mjs 75b5b83836c75675bb9a48fe4363fcb8a24fc425e6af6f822d7955c6f3c79eac 2265 tests/repository-service.test.mjs @@ -157,7 +163,7 @@ bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs 2571128f0b8e650071df17755baa09c4dfc441af0c20a7a4e9aa445b59e87d11 1654 tests/ssh-service.test.mjs 8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs -936412de29be7fbccb71a32460e222077361090deb1373fd56c904d70a0af718 37436 tests/unraid-deployment.test.mjs +5634e82a3a3c782cd6d7cd4df42b3102c22b81c5566fb072d7cd30de65c1f395 40003 tests/unraid-deployment.test.mjs 4abe7b2fc113c486f35f15c2d629c5b4f24589eada718c4ea58f93551c77d5eb 17777 tests/update-service.test.mjs 9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs 7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs diff --git a/docs/RELEASE_NOTES_0.10.0.md b/docs/RELEASE_NOTES_0.10.0.md new file mode 100644 index 0000000..c685660 --- /dev/null +++ b/docs/RELEASE_NOTES_0.10.0.md @@ -0,0 +1,32 @@ +# ForgeFlow 0.10.0 + +ForgeFlow 0.10.0 makes existing Unraid workloads substantially easier and safer to adopt, verify and deploy. + +## Deployment discovery and verification + +- Automatic discovery links unique running server workloads to their matching Gitea repositories while excluding unrelated infrastructure and stale release folders. +- Deployment cards preserve the last verified live commit and compare it with the current Gitea branch, even when a container was updated outside ForgeFlow. +- Existing Compose project names, files, services, remote folders and DockerMan metadata are adopted from server truth instead of guessed or overwritten. + +## Safe Server pull + +- Server pull is now the recommended deployment route and fetches the exact requested commit from Gitea. +- Each repository receives its own repository-scoped read-only deploy key; ForgeFlow never installs the desktop Gitea token on Unraid. +- Gitea SSH host fingerprints are pinned and checked before trust is changed and before every pull. +- Fetch, archive, checksum, Compose validation, activation and rollback remain exact-commit and transactional. +- Direct copy remains available when server-side Git access is undesirable, and monitoring-only links cannot deploy accidentally. + +## Git hygiene + +- Git Validator now also checks `.gitattributes`, `.editorconfig`, dependency lockfiles and a Gitea Actions workflow. +- Safe repairs create reviewable files without committing or pushing them automatically. +- Existing identity, upstream, synchronization, branch-protection, documentation, secret-path and oversized-file checks remain available in one scored view. + +## Interface and reliability + +- The deployment workspace now emphasizes repository, container, environment and live-versus-Gitea evidence, with a focused animated project illustration and clearer server inventory. +- Large unrelated server inventories are summarized instead of producing dozens of indistinguishable cards. +- Connection validation consistently opens the same encrypted Electron profile as the installed app. +- Obsolete ForgeFlow artifacts are removed from `dist` after every successful packaged build. + +No container was restarted or replaced during automatic discovery. Deployments still require a successful preflight and an explicit user action. diff --git a/docs/RELEASE_NOTES_0.9.2.md b/docs/RELEASE_NOTES_0.9.2.md new file mode 100644 index 0000000..c5395f0 --- /dev/null +++ b/docs/RELEASE_NOTES_0.9.2.md @@ -0,0 +1,10 @@ +# ForgeFlow 0.9.2 + +## Emergency deployment and discovery repair + +- Existing deployment profiles without an explicit mode migrate to **Push bundle**, never Server-side Git. +- Push bundle deploys the clean, committed local HEAD directly over SSH/SFTP and never requires a Gitea key, upstream, remote sync, or Git on Unraid. +- Desktop-to-Unraid authentication can use the server password; a failed key opens a password recovery flow instead of blocking deployment. +- Server inventory scans `docker ps -a`, complete Docker inspect data, Compose projects, and every DockerMan user template, including stopped and template-only workloads. +- Inventory connection failures are shown as failures rather than misleading zero counts. +- Existing Compose and DockerMan workloads can be linked manually without restarting or modifying them. diff --git a/docs/RELEASE_NOTES_0.9.3.md b/docs/RELEASE_NOTES_0.9.3.md new file mode 100644 index 0000000..2337c93 --- /dev/null +++ b/docs/RELEASE_NOTES_0.9.3.md @@ -0,0 +1,25 @@ +# ForgeFlow 0.9.3 + +This release removes server-side repository authentication from every SSH/Unraid deployment path and makes server Compose files the primary discovery source. + +## Direct deployment only + +- Every existing SSH/Unraid deployment profile is migrated to **Direct copy**, except profiles explicitly marked **Monitor only**. +- Deployment and rollback archive the exact committed local HEAD, upload it over the already configured desktop-to-Unraid connection and run Docker Compose on Unraid. +- Preflight contains no Unraid-to-repository access probe, no remote `git ls-remote`, no repository-key validation and no Git requirement on Unraid. +- Password authentication for the desktop-to-Unraid connection remains supported and is independent of repository access. + +## Compose-file inventory and automatic linking + +- Server Inventory scans Compose YAML files such as `compose.yml`, `compose.yaml`, `docker-compose.yml`, `docker-compose.yaml`, overrides and stack YAML files below the configured appdata roots. +- YAML discovery still runs when Docker inspection fails or Docker is unavailable, so a container-query problem no longer produces a false empty inventory. +- ForgeFlow reads the Compose project name, file set, service names and image names from the server. +- A unique high-confidence repository match based on both Compose folder and project identity is linked automatically. +- Remaining strong matches use a one-click link action with server folder, Compose project, Compose files, services, container identity and preservation paths already filled in. +- Runtime containers, stopped containers and DockerMan templates are merged with the YAML definition when available. `/mnt/user/appdata`, `/mnt/cache/appdata` and disk-backed appdata paths are treated as the same logical deployment location. + +## Reliability + +- The inventory shell script is safe under `set -euo pipefail`; Docker or Compose command failures are captured as warnings instead of aborting the scan. +- Large runtime, cache, log and database folders are pruned while searching for Compose files. +- A failed inventory operation remains visible as an error and is never presented as zero workloads. diff --git a/docs/RELEASE_NOTES_0.9.4.md b/docs/RELEASE_NOTES_0.9.4.md new file mode 100644 index 0000000..650b3a6 --- /dev/null +++ b/docs/RELEASE_NOTES_0.9.4.md @@ -0,0 +1,7 @@ +# ForgeFlow 0.9.4 + +ForgeFlow now treats the real Compose files discovered on Unraid as the only activation source for adopted workloads. A generated labels fragment is no longer merged into an imported project, so stale service hints such as `geointel` cannot create a phantom service without an image or build context. + +Direct copy redeployments always use `--force-recreate`. The runtime service list comes from `docker compose config --services`, not from manually stored service names. Before activation ForgeFlow records the existing container ID for every service; after activation it verifies that each service is running, not unhealthy, and uses a different container ID. + +ForgeFlow also rejects the deployment when Compose starts a duplicate workload while leaving the previous container running. The new SHA is written only after these checks pass. Existing DockerMan templates and the real Compose files remain untouched. diff --git a/docs/RELEASE_NOTES_0.9.5.md b/docs/RELEASE_NOTES_0.9.5.md new file mode 100644 index 0000000..db9309b --- /dev/null +++ b/docs/RELEASE_NOTES_0.9.5.md @@ -0,0 +1,11 @@ +# ForgeFlow 0.9.5 + +ForgeFlow 0.9.5 makes Direct copy deployments fail closed and adds an in-app repair for Unraid write permissions. + +Before a bundle is created or uploaded, ForgeFlow probes the linked project folder, `.forgeflow` upload/state folders and every active Compose file using the configured SSH identity. A failed check names the exact path, user, owner, group and mode. Deployment stops before file transfer and before any Docker or Compose command changes the runtime. The same write-access check runs again immediately before upload to prevent a stale preflight result. + +Every SSH / Unraid deployment card now includes **Check / fix write access**. The same action appears beside a blocking preflight result. It normalizes the linked source tree and ForgeFlow state folders to safe shared access, uses the Unraid `users` group where available and preserves existing executable bits. Configured runtime locations such as `.env`, `appdata`, `data`, `config`, `logs`, mounted data paths and common generated dependency folders are excluded. It never runs Docker, stops a container, removes a container or uses `chmod 777`. The action also runs when the SSH account is root so manual SMB/file-copy access can be repaired, not only ForgeFlow's own write access. + +Direct copy now validates candidate Compose configuration and builds candidate images before replacing live source files. It never implicitly executes `docker compose down`, `--remove-orphans` or `--force-recreate`. Existing container IDs, image IDs and source files are captured first. When activation fails, ForgeFlow restores the prior source, retags the previous images, attempts to restore the previous runtime and retains the backup evidence. A release is only promoted after the exact linked Compose services are running and verified. + +This release does not claim live validation against a specific private Unraid server. The automated suite validates generated Bash syntax, permission-report parsing, scoped repair commands, no server-to-Gitea authentication, no destructive Compose flags and transactional deployment ordering. diff --git a/docs/SSH_UNRAID_DEPLOYMENT.md b/docs/SSH_UNRAID_DEPLOYMENT.md index 089a923..2a1dd82 100644 --- a/docs/SSH_UNRAID_DEPLOYMENT.md +++ b/docs/SSH_UNRAID_DEPLOYMENT.md @@ -1,97 +1,73 @@ # SSH / Unraid deployment -ForgeFlow can inventory existing Unraid workloads and deploy an exact Git commit through one of three explicit modes. +ForgeFlow uses one deployment flow for Unraid: it copies the exact committed local project from the desktop to the server and activates the Compose definition found for that deployment. ## Deployment modes -### Push bundle — default +### Direct copy — 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. +ForgeFlow creates an archive from the exact local commit and uploads it through the configured desktop-to-Unraid connection. Unraid needs Docker, Docker Compose, `tar` and a SHA-256 checksum tool. Unraid does not clone, fetch or authenticate to a repository. ### Monitor only -ForgeFlow inventories and tracks the workload but refuses deploy and rollback operations until another mode is selected. +ForgeFlow inventories and tracks the workload but refuses deploy and rollback operations until **Direct copy** is selected. -## Security model +All older SSH/Unraid profiles are migrated to Direct copy unless they were explicitly Monitor only. -- Credentials are entered only in the local ForgeFlow desktop application. -- 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. -- 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. -- Incomplete SSH output is rejected rather than interpreted. +## Server Inventory and automatic linking -## Server Inventory and manual linking +Server Inventory reads the server itself instead of relying on ForgeFlow history. The default scan root is `/mnt/user/appdata`, together with the configured server base path and the cache-backed appdata path when present. It combines: -**Server Inventory** includes running and stopped containers, Compose projects, DockerMan installations and standalone containers even when `/mnt/user/appdata/` is not a Git checkout. +- running and stopped containers from `docker ps -a` and Docker Inspect; +- active and stopped Compose projects; +- DockerMan templates; +- Compose YAML files below the configured appdata roots, including standard override files. -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: +YAML discovery continues even when Docker inspection fails. For each Compose definition ForgeFlow reads the working directory, project name, file set, services and images. It then compares those values with the linked local repositories. -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. +A unique high-confidence match based on both the Compose folder and project identity is linked automatically. Other strong matches show a one-click **Link to repository** action. The server folder, Compose project, Compose files, service list, visible container identity, ports and preservation paths are already filled in; linking does not recreate the container. ## Compose identity -An adopted installation keeps the identity reported by Docker: +An adopted installation retains the identity detected on the server: ```text -Visible container: omniroute -Server folder: OmniRoute -Compose project: omniroute-production -Compose files: compose.yml, compose.unraid.yml -Compose services: api, worker +Visible container: geointel +Server folder: GeoIntel +Compose project: geointel +Compose files: compose.yml, compose.override.yml +Compose services: web, worker ``` -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`. +ForgeFlow adds `.forgeflow/compose.metadata.yml` as the final Compose overlay. For adopted workloads this overlay adds safe labels only; it does not replace the existing image, volumes, ports, networks or `container_name`. -`--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. +`--force-recreate` and `--remove-orphans` remain disabled by default. Existing DockerMan templates are not rewritten. -## DockerMan safety +## Direct-copy sequence -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: +1. Verify the selected local branch, clean working tree and exact committed HEAD. +2. Test the desktop-to-Unraid connection, Docker, Compose, `tar`, checksum tooling and deployment storage. +3. Create the release locally with `git archive`. +4. Upload a temporary `.part` file through SFTP. +5. Verify SHA-256 and reject unsafe archive paths or symbolic links. +6. Preserve `.forgeflow`, `.git` and configured runtime paths such as `.env`, `data`, `config`, `logs` and application-specific folders. +7. Update only files covered by the managed release manifests; unrelated server files remain untouched. +8. Validate the detected merged Compose configuration. +9. Activate the retained Compose project and verify every selected service is running and not unhealthy. +10. Promote the active SHA and manifests only after activation succeeds. +11. Run the optional desktop health check and persist runtime state. -- the profile uses ForgeFlow-generated Compose; -- **Manage DockerMan template** is enabled; -- the workload was not adopted from the server. +If activation fails, ForgeFlow restores the previous managed files and Compose metadata and leaves the previous active SHA authoritative. -Metadata repair for an adopted application therefore updates the Compose metadata labels only and leaves the original Unraid template intact. +## Authentication model -## Push bundle sequence - -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. - -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. - -## Server-side Git sequence - -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. - -## Interrupted operations and locks - -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. +- The only remote authentication used for Direct copy is the configured desktop-to-Unraid connection. +- That connection may use an Unraid password or a private key. +- Passwords and private-key passphrases use Electron safe storage. +- The first trusted connection records the SSH host-key fingerprint; later changes fail closed. +- 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. ## Rollback -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. +Rollback is allowed only to the exact `previousSha` recorded for the profile. ForgeFlow recreates that commit archive locally and uses the same upload, checksum, backup, Compose validation and atomic promotion flow. diff --git a/docs/screenshots/deployments.png b/docs/screenshots/deployments.png index fc3aa7a..82bf6ed 100644 Binary files a/docs/screenshots/deployments.png and b/docs/screenshots/deployments.png differ diff --git a/examples/server/forgeflow-deploy b/examples/server/forgeflow-deploy index 008e617..707fa5d 100644 --- a/examples/server/forgeflow-deploy +++ b/examples/server/forgeflow-deploy @@ -126,7 +126,7 @@ git -C "$APP_DIR" merge-base --is-ancestor "$SHA" "origin/$BRANCH" || { write_status "deploying" "$current_sha" "$previous_sha" 0 git -C "$APP_DIR" reset --hard "$SHA" -docker compose -f "$COMPOSE_FILE" up -d --build --remove-orphans +docker compose -f "$COMPOSE_FILE" up -d --build for attempt in $(seq 1 30); do if curl --fail --silent --show-error --max-time 5 "$HEALTHCHECK_URL" >/dev/null; then diff --git a/package-lock.json b/package-lock.json index ef6a5bf..f7595d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "forgeflow", - "version": "0.9.1", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "forgeflow", - "version": "0.9.1", + "version": "0.10.0", "dependencies": { "ssh2": "1.17.0" }, diff --git a/package.json b/package.json index 419c52b..ed27732 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "forgeflow", - "version": "0.9.1", + "version": "0.10.0", "private": true, "description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.", "main": "main.cjs", @@ -17,6 +17,7 @@ "doctor": "node scripts/doctor.mjs", "acceptance": "node scripts/acceptance.mjs", "connections:check": "electron scripts/validate-installed-connections.cjs", + "deployments:audit": "electron scripts/audit-installed-deployments.cjs", "release:binary": "electron scripts/publish-binary-release.cjs", "manifest": "node scripts/generate-source-manifest.mjs", "check": "npm run verify && npm test" @@ -86,7 +87,12 @@ "docs/RELEASE_NOTES_0.8.9.md", "docs/RELEASE_NOTES_0.9.0.md", "docs/RELEASE_NOTES_0.9.1.md", - "docs/ACCEPTANCE.md" + "docs/ACCEPTANCE.md", + "docs/RELEASE_NOTES_0.9.2.md", + "docs/RELEASE_NOTES_0.9.3.md", + "docs/RELEASE_NOTES_0.9.4.md", + "docs/RELEASE_NOTES_0.9.5.md", + "docs/RELEASE_NOTES_0.10.0.md" ], "asarUnpack": [ "scripts/apply-binary-update.ps1" diff --git a/preload.cjs b/preload.cjs index d455ce8..781f1af 100644 --- a/preload.cjs +++ b/preload.cjs @@ -106,6 +106,7 @@ contextBridge.exposeInMainWorld( openExternal: (url) => invoke('external:open', { url }), saveDeploymentProfile: (fullName, profile) => invoke('deployment:save-profile', { fullName, profile }), deploymentPreflight: (repository, profileId) => invoke('deployment:preflight', { repository, profileId }), + repairDeploymentWriteAccess: (repository, profileId) => invoke('deployment:repair-write-access', { repository, profileId }), deleteDeploymentProfile: (fullName, profileId) => invoke('deployment:delete-profile', { fullName, profileId }), deploy: (repository, profileId, sha, options = {}) => invoke('deployment:dispatch', { @@ -120,7 +121,8 @@ contextBridge.exposeInMainWorld( healthcheck: (url) => invoke('deployment:health', { url }), refreshProfileState: (fullName, profileId) => invoke('deployment:profile-state', { fullName, profileId }), discoverServerDeployments: () => invoke('deployment:discover-server-workloads'), - linkServerWorkload: (repository, serverId, workloadId, deploymentMode = 'push-bundle', remoteFolder = '') => invoke('deployment:link-server-workload', { repository, serverId, workloadId, deploymentMode, remoteFolder }), + linkServerWorkload: (repository, serverId, workloadId, deploymentMode = 'server-git', remoteFolder = '') => invoke('deployment:link-server-workload', { repository, serverId, workloadId, deploymentMode, remoteFolder }), + configureServerGitAccess: (repository, profileId) => invoke('deployment:configure-server-git-access', { repository, profileId }), applyDockerManMetadata: (repository, profileId) => invoke('deployment:apply-dockerman-metadata', { repository, profileId }), reconcileDeployment: (fullName, profileId) => invoke('deployment:reconcile', { fullName, profileId }), refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }), diff --git a/scripts/audit-installed-deployments.cjs b/scripts/audit-installed-deployments.cjs new file mode 100644 index 0000000..aeab802 --- /dev/null +++ b/scripts/audit-installed-deployments.cjs @@ -0,0 +1,81 @@ +"use strict"; + +const path = require("node:path"); +const { app } = require("electron"); +const { ConfigStore } = require("../src/main/config-store.cjs"); +const { GitService } = require("../src/main/git-service.cjs"); +const { GiteaService } = require("../src/main/gitea-service.cjs"); +const { RepositoryService } = require("../src/main/repository-service.cjs"); +const { SshService } = require("../src/main/ssh-service.cjs"); +const { UnraidDeploymentService } = require("../src/main/unraid-deployment-service.cjs"); + +const userDataPath = process.env.FORGEFLOW_USER_DATA + ? path.resolve(process.env.FORGEFLOW_USER_DATA) + : path.join(app.getPath("appData"), "forgeflow"); +app.setPath("userData", userDataPath); + +app.whenReady().then(async () => { + try { + const reconcile = process.argv.includes("--reconcile"); + const configureAccess = process.argv.includes("--configure-access"); + const repositoryFilter = new Set(String(process.argv.find((value) => value.startsWith("--repository=")) || "") + .slice("--repository=".length).toLowerCase().split(",").map((value) => value.trim()).filter(Boolean)); + const store = new ConfigStore(userDataPath); + await store.load(); + const git = new GitService(); + const gitea = new GiteaService(store); + const repositories = await new RepositoryService(store, git, gitea).refresh(); + const ssh = new SshService({ store }); + const deployments = new UnraidDeploymentService({ store, ssh, git, gitea, sourcePath: path.resolve(__dirname, "..") }); + const reports = []; + for (const server of store.data.servers || []) { + const report = await deployments.scanServerInventory(server.id, repositories, { autoLink: reconcile }); + const access = []; + if (configureAccess) { + const refreshedRepositories = await new RepositoryService(store, git, gitea).refresh(); + const seenProfiles = new Set(); + for (const workload of report.workloads.filter((item) => item.runtime?.running && item.link?.profileId && item.link?.repositoryFullName)) { + if (seenProfiles.has(workload.link.profileId)) continue; + seenProfiles.add(workload.link.profileId); + const repository = refreshedRepositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase()); + if (!repository) continue; + if (repositoryFilter.size && !repositoryFilter.has(String(repository.fullName).toLowerCase())) continue; + try { + const configured = await deployments.configureServerGitAccess({ repository, profileId: workload.link.profileId }); + access.push({ repository: repository.fullName, ready: true, created: configured.created, remoteSha: configured.remoteSha }); + await new Promise((resolve) => setTimeout(resolve, 1500)); + } catch (error) { + access.push({ repository: repository.fullName, ready: false, error: error.message }); + } + } + } + reports.push({ + server: server.name, + capabilities: report.capabilities, + warnings: (report.warnings || []).map((warning) => String(warning).slice(0, 300)), + summary: { + detected: report.detected, + running: report.running, + linked: report.linked, + needsReview: report.needsReview, + }, + access, + workloads: report.workloads.filter((workload) => workload.link || (workload.runtime?.running && workload.status !== "unmatched")).map((workload) => ({ + name: workload.displayName, + running: workload.runtime?.running === true, + health: workload.runtime?.health || "unknown", + repository: workload.link?.repositoryFullName || workload.suggestedRepository?.fullName || null, + confidence: workload.matchConfidence || workload.status, + folder: workload.remoteFolderCandidate || null, + containers: (workload.containers || []).map((container) => container.name), + })), + }); + } + console.log(JSON.stringify(reports, null, 2)); + } catch (error) { + console.error(error?.stack || error?.message || String(error)); + process.exitCode = 1; + } finally { + app.quit(); + } +}); diff --git a/scripts/generate-source-manifest.mjs b/scripts/generate-source-manifest.mjs index a5f29d1..2ac436f 100644 --- a/scripts/generate-source-manifest.mjs +++ b/scripts/generate-source-manifest.mjs @@ -4,12 +4,12 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const excludedDirectories = new Set(['.git', 'dist', 'node_modules']); +const excludedDirectories = new Set(['.git', 'dist', 'node_modules', 'ForgeFlow-runtime-win-x64']); const excludedFiles = new Set(['SOURCE_MANIFEST.txt']); async function collect(directory, output = []) { for (const entry of await readdir(directory, { withFileTypes: true })) { - if (entry.isDirectory() && excludedDirectories.has(entry.name)) continue; + if (excludedDirectories.has(entry.name)) continue; const absolute = path.join(directory, entry.name); if (entry.isDirectory()) await collect(absolute, output); else if (!excludedFiles.has(entry.name)) output.push(absolute); diff --git a/scripts/validate-installed-connections.cjs b/scripts/validate-installed-connections.cjs index f054e7d..4589224 100644 --- a/scripts/validate-installed-connections.cjs +++ b/scripts/validate-installed-connections.cjs @@ -4,9 +4,12 @@ const fs = require("node:fs/promises"); const path = require("node:path"); const { app, safeStorage } = require("electron"); -const configuredUserData = process.env.FORGEFLOW_USER_DATA; -if (configuredUserData) - app.setPath("userData", path.resolve(configuredUserData)); +const configuredUserData = process.env.FORGEFLOW_USER_DATA + ? path.resolve(process.env.FORGEFLOW_USER_DATA) + : path.join(app.getPath("appData"), "forgeflow"); +// safeStorage is bound to Electron's userData identity. Set it before ready so +// this verifier decrypts the same secrets as the packaged application. +app.setPath("userData", configuredUserData); function result(name, ok, detail) { console.log( @@ -18,9 +21,7 @@ function result(name, ok, detail) { app.whenReady().then(async () => { let passed = true; try { - const userDataPath = configuredUserData - ? path.resolve(configuredUserData) - : path.join(app.getPath("appData"), "forgeflow"); + const userDataPath = configuredUserData; const configPath = path.join(userDataPath, "forgeflow-config.json"); const config = JSON.parse(await fs.readFile(configPath, "utf8")); const baseUrl = String(config.gitea?.baseUrl || "").replace(/\/+$/, ""); diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 4007897..bcb6c68 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -73,6 +73,11 @@ const required = [ "docs/RELEASE_NOTES_0.8.9.md", "docs/RELEASE_NOTES_0.9.0.md", "docs/RELEASE_NOTES_0.9.1.md", + "docs/RELEASE_NOTES_0.9.2.md", + "docs/RELEASE_NOTES_0.9.3.md", + "docs/RELEASE_NOTES_0.9.4.md", + "docs/RELEASE_NOTES_0.9.5.md", + "docs/RELEASE_NOTES_0.10.0.md", "docs/UPDATING.md", "docs/DIAGNOSTICS.md", "docs/DEPLOYMENT_SETUP.md", @@ -111,9 +116,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.9.1") +if (packageJson.version !== "0.10.0") throw new Error( - `Expected package version 0.9.1, got ${packageJson.version}.`, + `Expected package version 0.10.0, got ${packageJson.version}.`, ); const sourceManifest = await readFile( path.join(root, "SOURCE_MANIFEST.txt"), @@ -373,6 +378,76 @@ for (const phrase of [ ]) { if (!release091.includes(phrase)) throw new Error(`0.9.1 release notes are missing: ${phrase}`); } +const release092 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.2.md"), "utf8"); +for (const phrase of [ + "Push bundle", + "server password", + "docker ps -a", + "DockerMan", + "zero counts", +]) { + if (!release092.includes(phrase)) throw new Error(`0.9.2 release notes are missing: ${phrase}`); +} +const release093 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.3.md"), "utf8"); +for (const phrase of [ + "Direct copy", + "Compose YAML", + "linked automatically", + "one-click", + "no remote `git ls-remote`", +]) { + if (!release093.includes(phrase)) throw new Error(`0.9.3 release notes are missing: ${phrase}`); +} +const release094 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.4.md"), "utf8"); +for (const phrase of [ + "real Compose files", + "stale service hints", + "force-recreate", + "container ID", + "previous container", +]) { + if (!release094.includes(phrase)) throw new Error(`0.9.4 release notes are missing: ${phrase}`); +} +const release095 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.5.md"), "utf8"); +for (const phrase of [ + "Check / fix write access", + "exact path, user, owner, group and mode", + "preserves existing executable bits", + "never implicitly executes `docker compose down`", + "retains the backup evidence", +]) { + if (!release095.includes(phrase)) throw new Error(`0.9.5 release notes are missing: ${phrase}`); +} +const release0100 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.0.md"), "utf8"); +for (const phrase of [ + "Server pull", + "read-only deploy key", + "automatic discovery", + "Git Validator", + "SSH host fingerprint", +]) { + if (!release0100.includes(phrase)) throw new Error(`0.10.0 release notes are missing: ${phrase}`); +} +const configSource = await readFile(path.join(root, "src/main/config-store.cjs"), "utf8"); +for (const mode of ["server-git", "push-bundle", "monitor-only"]) { + if (!configSource.includes(mode)) throw new Error(`Deployment configuration is missing mode: ${mode}`); +} +const unraidDirectSource = await readFile(path.join(root, "src/main/unraid-deployment-service.cjs"), "utf8"); +for (const requiredPhrase of [ + "executePushBundle", + "executeServerGitBundle", + "configureServerGitAccess", + "server-git-access", + "git ls-remote --exit-code", + "repository-scoped read-only deploy key", +]) { + if (!unraidDirectSource.includes(requiredPhrase)) throw new Error(`Deployment source is missing: ${requiredPhrase}`); +} +const serverInventorySource = await readFile(path.join(root, "src/main/server-inventory.cjs"), "utf8"); +for (const requiredPhrase of ["server-compose-file", "composeDefinitions", "remoteFolderCandidate"]) { + if (!serverInventorySource.includes(requiredPhrase)) throw new Error(`Server inventory source is missing: ${requiredPhrase}`); +} + const giteaUpdateSource = await readFile(path.join(root, "src/main/gitea-service.cjs"), "utf8"); for (const phrase of [ "browser_download_url", diff --git a/src/main/config-store.cjs b/src/main/config-store.cjs index c93fc02..1f2e448 100644 --- a/src/main/config-store.cjs +++ b/src/main/config-store.cjs @@ -7,7 +7,7 @@ const { safeStorage } = require('electron'); const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePath, assertRepositoryRelativePaths } = require('../shared/validation.cjs'); const DEFAULT_CONFIG = { - schemaVersion: 9, + schemaVersion: 11, setupComplete: false, appearance: 'dark', gitea: { baseUrl: '', user: null, encryptedToken: null }, @@ -79,7 +79,7 @@ class ConfigStore { const requestedDeploymentMode = String(profile.deploymentMode || '').trim(); const deploymentMode = ['push-bundle', 'server-git', 'monitor-only'].includes(requestedDeploymentMode) ? requestedDeploymentMode - : 'server-git'; + : 'push-bundle'; const composeFiles = uniqueStrings(profile.composeFiles || [profile.composeFile || 'docker-compose.yml']); const composeServices = uniqueStrings(profile.composeServices || [internalService]).map((value) => value.toLowerCase()); return { @@ -198,7 +198,7 @@ class ConfigStore { const port = Math.min(Math.max(Number(source.port || existing?.port || 22), 1), 65535); const username = String(source.username || existing?.username || '').trim(); if (!username || /[\s@]/.test(username)) throw new Error('Enter a valid SSH username.'); - const authType = ['password', 'privateKey'].includes(source.authType) ? source.authType : (existing?.authType || 'privateKey'); + const authType = ['password', 'privateKey'].includes(source.authType) ? source.authType : (existing?.authType || 'password'); const basePath = String(source.basePath || existing?.basePath || '/mnt/user/appdata').trim().replace(/\/+$/, ''); if (!basePath.startsWith('/') || /[\r\n\0]/.test(basePath)) throw new Error('The server base path must be an absolute Unix path.'); const privateKeyPath = String(source.privateKeyPath || existing?.privateKeyPath || '').trim(); @@ -400,7 +400,9 @@ class ConfigStore { 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'; + const deploymentMode = ['push-bundle', 'server-git', 'monitor-only'].includes(profile.deploymentMode) + ? profile.deploymentMode + : 'push-bundle'; return { ...common, serverId: String(profile.serverId || '').trim(), @@ -436,6 +438,13 @@ class ConfigStore { forceRecreate: profile.forceRecreate === true, removeOrphans: profile.removeOrphans === true, workloadIdentity: profile.workloadIdentity && typeof profile.workloadIdentity === 'object' ? structuredClone(profile.workloadIdentity) : null, + serverGitAccess: profile.serverGitAccess && typeof profile.serverGitAccess === 'object' ? { + configured: profile.serverGitAccess.configured === true, + deployKeyId: Number.isFinite(Number(profile.serverGitAccess.deployKeyId)) ? Number(profile.serverGitAccess.deployKeyId) : null, + keyFingerprint: String(profile.serverGitAccess.keyFingerprint || '').trim().slice(0, 200) || null, + hostFingerprint: String(profile.serverGitAccess.hostFingerprint || '').trim().slice(0, 200) || null, + configuredAt: profile.serverGitAccess.configuredAt || null + } : 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/git-validator-service.cjs b/src/main/git-validator-service.cjs index 4d1442e..0dfc920 100644 --- a/src/main/git-validator-service.cjs +++ b/src/main/git-validator-service.cjs @@ -24,6 +24,32 @@ coverage/ Thumbs.db `; +const RECOMMENDED_GITATTRIBUTES = `* text=auto eol=lf +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.zip binary +`; + +const RECOMMENDED_EDITORCONFIG = `root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.{bat,cmd,ps1}] +end_of_line = crlf +`; + function sameRemote(left, right) { const a = normalizeRemoteUrl(left); const b = normalizeRemoteUrl(right); @@ -275,6 +301,36 @@ class GitValidatorService { }, ), ); + for (const [id, title, filename, action] of [ + ["gitattributes", ".gitattributes normalizes text and binary files", ".gitattributes", "add-gitattributes"], + ["editorconfig", ".editorconfig keeps editors consistent", ".editorconfig", "add-editorconfig"], + ]) { + const present = lowerFiles.includes(filename); + checks.push(result(id, "Repository hygiene", title, present ? "pass" : "warning", + present ? `${filename} is versioned.` : `No tracked ${filename} was found.`, { + weight: 5, + fixAction: present ? null : action, + safe: false, + confirmation: `Create a recommended ${filename} in the working tree for review?`, + })); + } + + const packageManagers = [ + { manifests: ["package.json"], locks: ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"] }, + { manifests: ["pyproject.toml", "requirements.in", "pipfile"], locks: ["uv.lock", "poetry.lock", "requirements.txt", "pipfile.lock"] }, + { manifests: ["composer.json"], locks: ["composer.lock"] }, + { manifests: ["gemfile"], locks: ["gemfile.lock"] }, + ]; + const lockCheck = packageManagers.find((entry) => entry.manifests.some((name) => lowerFiles.includes(name))); + if (lockCheck) { + const lockfile = lockCheck.locks.find((name) => lowerFiles.includes(name)); + checks.push(result("dependency-lock", "Supply chain", "Dependencies are reproducibly locked", lockfile ? "pass" : "warning", + lockfile ? `${lockfile} is versioned.` : "A dependency manifest exists without a recognized lockfile.", { weight: 9 })); + } + + const hasCi = lowerFiles.some((file) => /^\.gitea\/workflows\/[^/]+\.ya?ml$/.test(file)); + checks.push(result("continuous-integration", "Gitea governance", "Automated checks run on Gitea", hasCi ? "pass" : "warning", + hasCi ? "At least one Gitea Actions workflow is versioned." : "No .gitea/workflows YAML file was found.", { weight: 8 })); const sensitive = tracked.filter(isSensitiveTrackedPath); checks.push( @@ -397,6 +453,16 @@ class GitValidatorService { }); return { created: ".gitignore" }; } + if (["add-gitattributes", "add-editorconfig"].includes(check.fixAction)) { + const definition = check.fixAction === "add-gitattributes" + ? { name: ".gitattributes", content: RECOMMENDED_GITATTRIBUTES } + : { name: ".editorconfig", content: RECOMMENDED_EDITORCONFIG }; + const target = path.join(root, definition.name); + if (await fs.stat(target).catch(() => null)) + throw new Error(`${definition.name} already exists; rescan before repairing.`); + await fs.writeFile(target, definition.content, { encoding: "utf8", flag: "wx" }); + return { created: definition.name }; + } if (check.fixAction === "protect-default-branch") { return this.gitea.createBranchProtection( repository.owner.login, @@ -411,6 +477,8 @@ class GitValidatorService { module.exports = { GitValidatorService, RECOMMENDED_GITIGNORE, + RECOMMENDED_GITATTRIBUTES, + RECOMMENDED_EDITORCONFIG, sameRemote, isSensitiveTrackedPath, }; diff --git a/src/main/gitea-service.cjs b/src/main/gitea-service.cjs index a020d20..022c552 100644 --- a/src/main/gitea-service.cjs +++ b/src/main/gitea-service.cjs @@ -226,6 +226,44 @@ class GiteaService { ).data; } + async listDeployKeys(owner, repo) { + const result = await this.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys?limit=100`, + ); + return Array.isArray(result.data) ? result.data : []; + } + + async ensureReadOnlyDeployKey({ owner, repo, title, publicKey }) { + const key = String(publicKey || "").trim(); + if (!/^ssh-(ed25519|rsa)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/.test(key)) + throw new Error("The server did not return a valid SSH public key."); + const keys = await this.listDeployKeys(owner, repo); + const keyMaterial = key.split(/\s+/).slice(0, 2).join(" "); + const existing = keys.find((item) => + String(item?.key || "").trim().split(/\s+/).slice(0, 2).join(" ") === keyMaterial, + ); + if (existing) { + if (existing.read_only !== true) { + const error = new Error("The matching Gitea deploy key has write access. Revoke it before ForgeFlow configures a read-only server key."); + error.code = "DEPLOY_KEY_NOT_READ_ONLY"; + throw error; + } + return { ...existing, created: false }; + } + const result = await this.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys`, + { + method: "POST", + body: { + title: String(title || "ForgeFlow server deploy key").trim().slice(0, 255), + key, + read_only: true, + }, + }, + ); + return { ...result.data, created: true }; + } + async listPullRequests({ owner, repo, state = "open", limit = 30 } = {}) { const query = new URLSearchParams({ state, diff --git a/src/main/ipc.cjs b/src/main/ipc.cjs index 186d609..3cdde1d 100644 --- a/src/main/ipc.cjs +++ b/src/main/ipc.cjs @@ -859,6 +859,8 @@ function registerIpc({ "align-origin", "configure-local-safety", "add-gitignore", + "add-gitattributes", + "add-editorconfig", "protect-default-branch", ]); if (!allowed.has(check?.fixAction)) @@ -1121,6 +1123,20 @@ function registerIpc({ return unraid.preflight({ repository: current, profileId }); return preflight.runDeployment({ repository: current, profileId }); }); + register("deployment:repair-write-access", async ({ repository, profileId }) => { + const current = await resolveRepository(repository); + const profile = store.getDeploymentProfile(current.fullName, profileId); + if (profile?.provider !== "ssh-unraid") + throw new Error("Write-access repair is available only for SSH / Unraid deployment profiles."); + const result = await unraid.repairWriteAccess({ repository: current, profileId }); + await audit.append("deployment.write-access.repaired", { + repository: current.fullName, + profileId, + changed: result.changed, + remotePath: result.after?.remotePath || result.before?.remotePath || null, + }); + return result; + }); register( "deployment:dispatch", async ({ @@ -1176,7 +1192,7 @@ function registerIpc({ }, ); register("deployment:health", ({ url }) => deployments.checkHealth(url)); - register("deployment:link-server-workload", async ({ repository, serverId, workloadId, deploymentMode = "push-bundle", remoteFolder = "" }) => { + register("deployment:link-server-workload", async ({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) => { const current = await resolveRepository(repository); const result = await unraid.linkServerWorkload({ repository: current, @@ -1187,6 +1203,17 @@ function registerIpc({ }); return { ...result, state: store.getPublicState() }; }); + register("deployment:configure-server-git-access", async ({ repository, profileId }) => { + const current = await resolveRepository(repository); + const result = await unraid.configureServerGitAccess({ repository: current, profileId }); + await audit.append("deployment.server-git-access-configured", { + repository: current.fullName, + profileId, + keyFingerprint: result.keyFingerprint, + hostFingerprint: result.hostFingerprint, + }); + return { ...result, state: store.getPublicState() }; + }); register("deployment:discover-server-workloads", async () => { const repositoryList = await repositories.refresh(); const remoteRepositories = repositoryList.filter( diff --git a/src/main/server-inventory.cjs b/src/main/server-inventory.cjs index 2d1d180..1eb6d9a 100644 --- a/src/main/server-inventory.cjs +++ b/src/main/server-inventory.cjs @@ -64,6 +64,8 @@ function parseServerInventory(output) { checkouts: [], containers: [], dockerMan: [], + composeProjects: [], + composeDefinitions: [], warnings: [], }; for (const line of String(output).slice(index + marker.length).trim().split(/\r?\n/)) { @@ -91,7 +93,7 @@ function parseServerInventory(output) { const parsed = safeJson(decodeBase64(parts[0]), null); if (!parsed) continue; if (Array.isArray(parsed)) { - if (parsed[0]) inventory.containers.push(sanitizeLegacyContainer(parsed[0])); + for (const item of parsed) if (item) inventory.containers.push(sanitizeLegacyContainer(item)); } else if (parsed.Config || parsed.State) inventory.containers.push(sanitizeLegacyContainer(parsed)); else inventory.containers.push({ ...parsed, @@ -111,6 +113,32 @@ function parseServerInventory(output) { repository: decodeBase64(parts[5]), network: decodeBase64(parts[6]), }); + } else if (kind === 'P' && parts[0]) { + const parsed = safeJson(decodeBase64(parts[0]), []); + const projects = Array.isArray(parsed) ? parsed : parsed ? [parsed] : []; + for (const project of projects) { + const name = String(project?.Name || project?.name || '').trim(); + if (!name) continue; + const rawFiles = project?.ConfigFiles || project?.configFiles || project?.config_files || []; + const configFiles = (Array.isArray(rawFiles) ? rawFiles : String(rawFiles || '').split(',')) + .map((item) => String(item || '').trim()) + .filter(Boolean); + inventory.composeProjects.push({ + name, + status: String(project?.Status || project?.status || ''), + configFiles, + }); + } + } else if (kind === 'Y' && parts[0]) { + inventory.composeDefinitions.push({ + workingDir: decodeBase64(parts[0]).replace(/\/+$/, ''), + configFiles: decodeBase64(parts[1]).split(/\r?\n/).map((item) => item.trim()).filter(Boolean), + projectName: decodeBase64(parts[2]).trim(), + services: decodeBase64(parts[3]).split(/\r?\n/).map((item) => item.trim()).filter(Boolean), + images: decodeBase64(parts[4]).split(/\r?\n/).map((item) => item.trim()).filter(Boolean), + valid: parts[5] === 'true', + error: decodeBase64(parts[6]).trim(), + }); } else if (kind === 'W') inventory.warnings.push(decodeBase64(parts[0])); } return inventory; @@ -154,6 +182,44 @@ function topLevelRelativeToBase(basePath, candidate) { return relative ? relative.split('/')[0] : ''; } +function canonicalServerAppdataPath(basePath, candidate) { + const value = String(candidate || '').replace(/\\/g, '/').replace(/\/+$/, ''); + if (!value) return ''; + const bases = [...new Set([ + String(basePath || '').replace(/\/+$/, ''), + '/mnt/user/appdata', + '/mnt/cache/appdata', + ].filter(Boolean))]; + for (const base of bases) { + const relative = safeRelativeToBase(base, value); + if (relative) return `${String(basePath || base).replace(/\/+$/, '')}/${relative}`; + if (value === base) return String(basePath || base).replace(/\/+$/, ''); + } + const diskMatch = value.match(/^\/mnt\/disk\d+\/appdata\/(.+)$/i); + if (diskMatch) return `${String(basePath || '/mnt/user/appdata').replace(/\/+$/, '')}/${diskMatch[1]}`; + return value; +} + +function isDeploymentBackupPath(value) { + const segments = String(value || '').replace(/\\/g, '/').split('/').filter(Boolean); + return segments.some((segment) => + /^source-pre-[0-9a-f]{7,64}$/i.test(segment) + || /^forgeflow-(backup|staging|rollback)(?:[-_.].*)?$/i.test(segment) + || ['.forgeflow', 'releases', 'backups', 'staging', 'incoming', '_audit_quarantine', 'devrunbook-validation'].includes(segment.toLowerCase()), + ); +} + +function deploymentRootCandidate(relativePath) { + const segments = String(relativePath || '').replace(/\\/g, '/').split('/').filter(Boolean); + const forgeFlowIndex = segments.indexOf('.forgeflow'); + if (forgeFlowIndex > 0) return segments.slice(0, forgeFlowIndex).join('/'); + const releasesIndex = segments.indexOf('releases'); + if (releasesIndex > 0 && segments.length > releasesIndex + 1) return segments.slice(0, releasesIndex).join('/'); + const backupIndex = segments.findIndex((segment) => /^source-pre-[0-9a-f]{7,64}$/i.test(segment)); + if (backupIndex > 0) return segments.slice(0, backupIndex).join('/'); + return segments.join('/'); +} + function workloadSelector(group) { if (group.composeProject) return { kind: 'compose', @@ -182,6 +248,7 @@ function profileMatchesWorkload(profile, workload) { if (profile.composeProject && workload.compose?.project && profile.composeProject === workload.compose.project) { if (!profile.composeWorkingDir || !workload.compose.workingDir || profile.composeWorkingDir === workload.compose.workingDir) return true; } + if (profile.remoteFolder && workload.remoteFolderCandidate && profile.remoteFolder === workload.remoteFolderCandidate) return true; return workload.containers.some((container) => container.name === profile.containerName); } @@ -198,11 +265,12 @@ function repositoryRemoteMap(repositories) { function candidateRepositories(workload, repositories, checkouts) { const candidates = new Map(); - const add = (repository, points, reason, exact = false) => { + const add = (repository, points, reason, exact = false, identityExact = false) => { if (!repository?.fullName) return; - const current = candidates.get(repository.fullName) || { repositoryFullName: repository.fullName, repositoryName: repository.name, score: 0, exact: false, reasons: [] }; + const current = candidates.get(repository.fullName) || { repositoryFullName: repository.fullName, repositoryName: repository.name, score: 0, exact: false, identityExact: false, reasons: [] }; current.score += points; current.exact ||= exact; + current.identityExact ||= identityExact; if (reason && !current.reasons.includes(reason)) current.reasons.push(reason); candidates.set(repository.fullName, current); }; @@ -230,31 +298,42 @@ function candidateRepositories(workload, repositories, checkouts) { 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), + const composeProjectName = normalizedName(workload.compose.project); + const composeFolderName = normalizedName(path.basename(workload.compose.workingDir || '')); + const deploymentFolderName = normalizedName(String(workload.remoteFolderCandidate || '').split('/')[0]); + const serviceNames = new Set((workload.compose.services || []).map(normalizedName).filter(Boolean)); + const containerNames = new Set(workload.containers.map((container) => normalizedName(container.name)).filter(Boolean)); + const imageNames = new Set([ ...workload.containers.map((container) => String(container.image || '').split('/').pop()?.split(':')[0]), - ].filter(Boolean).map(normalizedName)); + ...(workload.metadata?.images || []).map((image) => String(image || '').split('/').pop()?.split(':')[0]), + ].map(normalizedName).filter(Boolean)); 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'); + if (!repoName) continue; + if (composeProjectName && composeProjectName === repoName) add(repository, 55, 'Compose project name matches repository', false, true); + if (deploymentFolderName && deploymentFolderName === repoName) add(repository, 70, 'Top-level appdata folder exactly matches repository', false, true); + if (composeFolderName && composeFolderName === repoName) add(repository, 50, 'Compose file folder matches repository'); + if (serviceNames.has(repoName)) add(repository, 25, 'Compose service name matches repository'); + if (containerNames.has(repoName)) add(repository, 70, 'Container name exactly matches repository', false, true); + if (imageNames.has(repoName)) add(repository, 20, 'Container image name matches repository'); } return [...candidates.values()].sort((a, b) => b.score - a.score || a.repositoryFullName.localeCompare(b.repositoryFullName)).map((candidate) => ({ ...candidate, + reasons: candidate.exact + ? candidate.reasons + : [...candidate.reasons, 'Manual confirmation is reduced to one click; Compose identity and paths are prefilled from the server.'], 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 dockerManByName = new Map((inventory.dockerMan || []).map((item) => [String(item.name || '').toLowerCase(), 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 workingDir = canonicalServerAppdataPath(server.basePath, labels['com.docker.compose.project.working_dir']); + const configFiles = [...new Set(configFilesFor(container).map((file) => canonicalServerAppdataPath(server.basePath, file)))]; const key = composeProject ? `compose:${composeProject}:${workingDir}:${configFiles.join('|')}` : `container:${container.name}`; @@ -263,15 +342,103 @@ function buildWorkloadInventory({ inventory, server, repositories = [], profiles workingDir, configFiles, services: [], + images: [], 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; + group.dockerMan ||= dockerManByName.get(String(container.name || '').toLowerCase()) || null; groups.set(key, group); } + for (const project of inventory.composeProjects || []) { + const configFiles = [...new Set((project.configFiles || []).filter(Boolean).map((file) => canonicalServerAppdataPath(server.basePath, file)))]; + const workingDir = configFiles.length ? canonicalServerAppdataPath(server.basePath, path.dirname(configFiles[0])) : ''; + const key = `compose:${project.name}:${workingDir}:${configFiles.join('|')}`; + if (groups.has(key)) continue; + const existingByProject = [...groups.values()].find((group) => group.composeProject === project.name); + if (existingByProject) { + if (!existingByProject.configFiles.length && configFiles.length) existingByProject.configFiles = configFiles; + if (!existingByProject.workingDir && workingDir) existingByProject.workingDir = workingDir; + continue; + } + groups.set(key, { + composeProject: project.name, + workingDir, + configFiles, + services: [], + images: [], + containers: [], + dockerMan: dockerManByName.get(String(project.name || '').toLowerCase()) || null, + composeStatus: project.status || '', + }); + } + for (const definition of inventory.composeDefinitions || []) { + const configFiles = [...new Set((definition.configFiles || []).filter(Boolean).map((file) => canonicalServerAppdataPath(server.basePath, file)))]; + const workingDir = canonicalServerAppdataPath(server.basePath, definition.workingDir || (configFiles[0] ? path.dirname(configFiles[0]) : '')); + if (isDeploymentBackupPath(workingDir) || configFiles.some(isDeploymentBackupPath)) continue; + const projectName = String(definition.projectName || path.basename(workingDir || '')).trim(); + const existing = [...groups.values()].find((group) => { + if (workingDir && group.workingDir && group.workingDir === workingDir) return true; + if (configFiles.length && (group.configFiles || []).some((file) => configFiles.includes(file))) return true; + return Boolean(projectName && group.composeProject === projectName && (!workingDir || !group.workingDir)); + }); + if (existing) { + existing.composeProject ||= projectName; + existing.workingDir ||= workingDir; + existing.configFiles = [...new Set([...(existing.configFiles || []), ...configFiles])]; + existing.services = [...new Set([...(existing.services || []), ...(definition.services || [])])]; + existing.images = [...new Set([...(existing.images || []), ...(definition.images || [])])]; + existing.composeDefinitionValid = definition.valid; + existing.composeDefinitionError = definition.error || ''; + existing.composeSource = 'server-compose-file'; + continue; + } + const key = `compose-file:${projectName}:${workingDir}:${configFiles.join('|')}`; + groups.set(key, { + composeProject: projectName, + workingDir, + configFiles, + services: [...new Set(definition.services || [])], + images: [...new Set(definition.images || [])], + containers: [], + dockerMan: dockerManByName.get(projectName.toLowerCase()) || null, + composeStatus: '', + composeDefinitionValid: definition.valid, + composeDefinitionError: definition.error || '', + composeSource: 'server-compose-file', + }); + } + const containerNames = new Set((inventory.containers || []).map((container) => String(container.name || '').toLowerCase())); + for (const dockerMan of inventory.dockerMan || []) { + const normalized = String(dockerMan.name || '').toLowerCase(); + if (!normalized || containerNames.has(normalized)) continue; + const key = `container:${dockerMan.name}`; + if (groups.has(key)) continue; + groups.set(key, { + composeProject: '', + workingDir: '', + configFiles: [], + services: [], + images: dockerMan.repository ? [dockerMan.repository] : [], + containers: [{ + id: '', + name: dockerMan.name, + image: dockerMan.repository || '', + imageId: '', + running: false, + status: 'template-only', + health: null, + labels: {}, + ports: {}, + mounts: [], + networks: dockerMan.network ? { [dockerMan.network]: {} } : {}, + restartPolicy: '', + }], + dockerMan, + }); + } const workloads = []; for (const group of groups.values()) { const selector = workloadSelector(group); @@ -279,8 +446,8 @@ function buildWorkloadInventory({ inventory, server, repositories = [], profiles 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 remoteFolderCandidate = deploymentRootCandidate(safeRelativeToBase(server.basePath, canonicalServerAppdataPath(server.basePath, group.workingDir))) + || mounts.map((mount) => topLevelRelativeToBase(server.basePath, canonicalServerAppdataPath(server.basePath, mount?.Source))).find(Boolean) || ''; const workload = { workloadId, @@ -288,7 +455,7 @@ function buildWorkloadInventory({ inventory, server, repositories = [], profiles serverName: server.name, kind: selector.kind, selector, - displayName: group.composeProject || primary?.name || 'Unnamed workload', + displayName: group.composeProject || primary?.name || group.dockerMan?.name || 'Unnamed workload', compose: { project: group.composeProject, workingDir: group.workingDir, @@ -322,10 +489,15 @@ function buildWorkloadInventory({ inventory, server, repositories = [], profiles 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'] || '', + composeStatus: group.composeStatus || '', + images: [...new Set(group.images || [])], + composeSource: group.composeSource || (group.configFiles?.length ? 'docker-compose-runtime' : ''), + composeDefinitionValid: group.composeDefinitionValid !== false, + composeDefinitionError: group.composeDefinitionError || '', }, runtime: { running: group.containers.some((container) => container.running === true), - allRunning: group.containers.every((container) => container.running === true), + allRunning: group.containers.length > 0 && 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') @@ -400,4 +572,6 @@ module.exports = { profileMatchesWorkload, sanitizeLegacyContainer, safeRelativeToBase, + canonicalServerAppdataPath, + deploymentRootCandidate, }; diff --git a/src/main/unraid-deployment-service.cjs b/src/main/unraid-deployment-service.cjs index 511ba2a..95c1388 100644 --- a/src/main/unraid-deployment-service.cjs +++ b/src/main/unraid-deployment-service.cjs @@ -8,7 +8,6 @@ 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, @@ -186,6 +185,64 @@ function parseDockerManXml(xml) { }; } +function parsePermissionInspection(text) { + const marker = "__FORGEFLOW_PERMISSIONS__"; + const index = String(text || "").lastIndexOf(marker); + if (index < 0) + throw new Error("The server permission check did not return a ForgeFlow marker."); + const decode = (value) => { + try { + return value ? Buffer.from(value, "base64").toString("utf8") : ""; + } catch { + return ""; + } + }; + const result = { + identity: { user: "", uid: null, gid: null, groups: [], hasAcl: false, canElevate: false }, + targets: [], + }; + for (const line of String(text) + .slice(index + marker.length) + .trim() + .split(/\r?\n/)) { + const parts = line.split("\t"); + if (parts[0] === "I") { + result.identity = { + user: decode(parts[1]), + uid: Number(parts[2]), + gid: Number(parts[3]), + groups: decode(parts[4]).split(/\s+/).filter(Boolean), + hasAcl: parts[5] === "true", + canElevate: parts[6] === "true", + }; + } else if (parts[0] === "P") { + result.targets.push({ + id: decode(parts[1]), + label: decode(parts[2]), + path: decode(parts[3]), + kind: parts[4] || "directory", + required: parts[5] === "true", + exists: parts[6] === "true", + readable: parts[7] === "true", + writable: parts[8] === "true", + parentWritable: parts[9] === "true", + effectiveWritable: parts[10] === "true", + owner: decode(parts[11]), + group: decode(parts[12]), + mode: parts[13] || "", + nearestWritableAncestor: decode(parts[14]), + detail: decode(parts[15]), + }); + } + } + result.blocking = result.targets.filter( + (target) => target.required && !target.effectiveWritable, + ); + result.ready = result.blocking.length === 0; + result.repairable = result.blocking.some((target) => target.id !== "server-base"); + return result; +} + function deriveDetectedProfile({ repository, server, @@ -389,14 +446,392 @@ class UnraidDeploymentService { this.onOperationChange = onOperationChange; } + serverGitRemote(repository, profile) { + const candidates = [repository.sshUrl, profile.cloneUrl, repository.preferredCloneUrl] + .map((value) => String(value || "").trim()) + .filter(Boolean); + const value = candidates.find((candidate) => /^ssh:\/\//i.test(candidate) || /^[^@\s]+@[^:\s]+:.+/.test(candidate)); + if (!value) { + const error = new Error("Server pull requires the repository SSH clone URL from Gitea."); + error.code = "SERVER_GIT_SSH_URL_REQUIRED"; + throw error; + } + return value; + } + + serverGitHost(repository, profile) { + const remote = this.serverGitRemote(repository, profile); + if (/^ssh:\/\//i.test(remote)) { + const parsed = new URL(remote); + return { host: parsed.hostname, port: Number(parsed.port || 22) }; + } + const match = remote.match(/^[^@\s]+@([^:\s]+):/); + if (!match) throw new Error("Could not determine the Gitea SSH host from the clone URL."); + return { host: match[1], port: 22 }; + } + + serverGitCredentialPaths(repository, server) { + const repositoryId = crypto.createHash("sha256").update(String(repository.fullName).toLowerCase()).digest("hex").slice(0, 24); + const directory = path.join(server.basePath, ".forgeflow", "git-credentials", repositoryId); + return { + directory, + privateKey: path.join(directory, "deploy-key"), + publicKey: path.join(directory, "deploy-key.pub"), + knownHosts: path.join(directory, "known_hosts"), + }; + } + + serverGitEnvironment(repository, profile, server) { + const credentials = this.serverGitCredentialPaths(repository, server); + return `GIT_SSH_COMMAND=${shellQuote(`ssh -i ${credentials.privateKey} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${credentials.knownHosts}`)}`; + } + + async configureServerGitAccess({ repository, profileId }) { + const { profile, server } = this.resolve(repository, profileId); + const remote = this.serverGitRemote(repository, profile); + const { host, port } = this.serverGitHost(repository, profile); + const credentials = this.serverGitCredentialPaths(repository, server); + const trustedHostFingerprint = String(profile.serverGitAccess?.hostFingerprint || "").trim(); + const marker = "__FORGEFLOW_DEPLOY_KEY__"; + const setupScript = ` +command -v git >/dev/null 2>&1 || { echo "Git is not installed on the server" >&2; exit 41; } +command -v ssh-keygen >/dev/null 2>&1 || { echo "ssh-keygen is not installed on the server" >&2; exit 42; } +command -v ssh-keyscan >/dev/null 2>&1 || { echo "ssh-keyscan is not installed on the server" >&2; exit 43; } +credential_dir=${shellQuote(credentials.directory)} +private_key=${shellQuote(credentials.privateKey)} +public_key=${shellQuote(credentials.publicKey)} +known_hosts=${shellQuote(credentials.knownHosts)} +expected_host_fingerprint=${shellQuote(trustedHostFingerprint)} +mkdir -p "$credential_dir" +chmod 700 "$credential_dir" +if [ ! -s "$private_key" ] || [ ! -s "$public_key" ]; then + rm -f "$private_key" "$public_key" + ssh-keygen -q -t ed25519 -N '' -C ${shellQuote(`forgeflow:${repository.fullName}`)} -f "$private_key" +fi +chmod 600 "$private_key" +chmod 644 "$public_key" +scan_tmp="$known_hosts.$$.tmp" +scan_ok=false +for attempt in 1 2 3; do + ssh-keyscan -T 10 -H -p ${Number(port)} ${shellQuote(host)} > "$scan_tmp" 2>/dev/null || true + if [ -s "$scan_tmp" ]; then scan_ok=true; break; fi + sleep $((attempt * 2)) +done +[ "$scan_ok" = true ] || { rm -f "$scan_tmp"; echo "Gitea SSH host did not return a host key after three attempts" >&2; exit 44; } +scanned_host_fingerprint="$(ssh-keygen -lf "$scan_tmp" -E sha256 2>/dev/null | awk '{print $2}' | sort -u | paste -sd, -)" +if [ -n "$expected_host_fingerprint" ] && [ "$scanned_host_fingerprint" != "$expected_host_fingerprint" ]; then + rm -f "$scan_tmp" + echo "The Gitea SSH host key changed. Verify the Gitea server before replacing trust." >&2 + exit 46 +fi +mv "$scan_tmp" "$known_hosts" +chmod 600 "$known_hosts" +printf '%s\n' ${shellQuote(marker)} +printf 'publicKey=%s\n' "$(base64 < "$public_key" | tr -d '\\r\\n')" +printf 'fingerprint=%s\n' "$(ssh-keygen -lf "$public_key" -E sha256 | awk '{print $2}')" +printf 'hostFingerprint=%s\n' "$scanned_host_fingerprint" +`; + const setup = await this.ssh.exec(server.id, bash(setupScript), { timeout: 60_000, maxOutput: 256 * 1024 }); + const output = String(setup.stdout || ""); + const markerIndex = output.lastIndexOf(marker); + if (markerIndex < 0) throw new Error("The server did not return the generated deploy key."); + const fields = Object.fromEntries(output.slice(markerIndex + marker.length).trim().split(/\r?\n/).map((line) => { + const separator = line.indexOf("="); + return separator > 0 ? [line.slice(0, separator), line.slice(separator + 1)] : [line, ""]; + })); + if (trustedHostFingerprint && fields.hostFingerprint && trustedHostFingerprint !== fields.hostFingerprint) { + const error = new Error("The Gitea SSH host key changed. Server pull was not reconfigured. Verify the Gitea server before replacing trust."); + error.code = "GITEA_SSH_HOST_KEY_MISMATCH"; + throw error; + } + const publicKey = Buffer.from(fields.publicKey || "", "base64").toString("utf8").trim(); + const [owner, repo] = String(repository.fullName || "").split("/"); + if (!owner || !repo) throw new Error("A full Gitea repository name is required to configure server pull."); + const deployKey = await this.gitea.ensureReadOnlyDeployKey({ + owner, + repo, + title: `ForgeFlow · ${server.name} · read-only`, + publicKey, + }); + const probeCommand = `${this.serverGitEnvironment(repository, profile, server)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)}`; + const probe = await this.ssh.exec( + server.id, + bash(`probe_error='' +for attempt in 1 2 3; do + if probe_output=$(${probeCommand} 2>&1); then printf '%s\n' "$probe_output"; exit 0; fi + probe_error=$probe_output + sleep $((attempt * 2)) +done +printf '%s\n' "$probe_error" >&2 +exit 45`), + { timeout: 45_000, maxOutput: 256 * 1024 }, + ); + const remoteSha = String(probe.stdout || "").trim().split(/\s+/)[0] || null; + const updated = await this.store.saveDeploymentProfile(repository.fullName, { + ...profile, + deploymentMode: "server-git", + cloneUrl: remote, + serverGitAccess: { + configured: true, + deployKeyId: deployKey.id || null, + keyFingerprint: fields.fingerprint || null, + hostFingerprint: fields.hostFingerprint || null, + configuredAt: new Date().toISOString(), + }, + }); + return { + profile: updated, + created: deployKey.created === true, + remoteSha, + keyFingerprint: fields.fingerprint || null, + hostFingerprint: fields.hostFingerprint || null, + }; + } + + async probeServerGitAccess({ repository, profile, server }) { + try { + const remote = this.serverGitRemote(repository, profile); + const credentials = this.serverGitCredentialPaths(repository, server); + const trustedHostFingerprint = String(profile.serverGitAccess?.hostFingerprint || "").trim(); + const command = `[ -s ${shellQuote(credentials.privateKey)} ] && [ -s ${shellQuote(credentials.knownHosts)} ] && actual_host_fingerprint="$(ssh-keygen -lf ${shellQuote(credentials.knownHosts)} -E sha256 2>/dev/null | awk '{print $2}' | sort -u | paste -sd, -)" && { [ -z ${shellQuote(trustedHostFingerprint)} ] || [ "$actual_host_fingerprint" = ${shellQuote(trustedHostFingerprint)} ]; } && ${this.serverGitEnvironment(repository, profile, server)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)}`; + const result = await this.ssh.exec(server.id, bash(command), { timeout: 45_000, maxOutput: 256 * 1024 }); + return { ready: true, remoteSha: String(result.stdout || "").trim().split(/\s+/)[0] || null }; + } catch (error) { + return { ready: false, error: error.message }; + } + } + + permissionTargets(profile, server, remotePath) { + const targets = [ + { + id: "server-base", + label: "Configured deployment base", + path: server.basePath, + kind: "directory", + required: false, + }, + { + id: "project-root", + label: "Project folder", + path: remotePath, + kind: "directory", + required: true, + }, + { + id: "forgeflow-state", + label: "ForgeFlow upload and rollback storage", + path: path.join(remotePath, ".forgeflow"), + kind: "directory", + required: true, + }, + { + id: "forgeflow-incoming", + label: "ForgeFlow incoming upload folder", + path: path.join(remotePath, ".forgeflow", "incoming"), + kind: "directory", + required: true, + }, + ]; + if (!profile.generatedCompose) { + for (const file of this.deploymentComposeFiles(profile)) { + targets.push({ + id: `compose:${file}`, + label: `Compose file ${file}`, + path: path.join(remotePath, file), + kind: "file", + required: true, + }); + } + } + const unique = new Map(); + for (const target of targets) unique.set(`${target.kind}:${target.path}`, target); + return [...unique.values()]; + } + + permissionInspectionScript(profile, server, remotePath) { + const targetCalls = this.permissionTargets(profile, server, remotePath) + .map( + (target) => + `probe ${shellQuote(target.id)} ${shellQuote(target.label)} ${shellQuote(target.path)} ${shellQuote(target.kind)} ${target.required ? "true" : "false"}`, + ) + .join("\n"); + return ` +encode() { printf '%s' "$1" | base64 | tr -d '\\r\\n'; } +can_elevate=false +[ "$(id -u)" = 0 ] && can_elevate=true +if [ "$can_elevate" != true ] && command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then can_elevate=true; fi +has_acl=false +command -v setfacl >/dev/null 2>&1 && has_acl=true +printf '__FORGEFLOW_PERMISSIONS__\\n' +printf 'I\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \ + "$(encode "$(id -un 2>/dev/null || echo unknown)")" \ + "$(id -u 2>/dev/null || echo -1)" \ + "$(id -g 2>/dev/null || echo -1)" \ + "$(encode "$(id -Gn 2>/dev/null || true)")" \ + "$has_acl" "$can_elevate" +probe() { + target_id=$1 + label=$2 + target=$3 + kind=$4 + required=$5 + exists=false; readable=false; writable=false; parent_writable=false; effective=false + owner=''; group=''; mode=''; detail=''; nearest='' + if [ -e "$target" ] || [ -L "$target" ]; then + exists=true + [ -r "$target" ] && readable=true + [ -w "$target" ] && writable=true + owner=$(stat -c '%U' "$target" 2>/dev/null || true) + group=$(stat -c '%G' "$target" 2>/dev/null || true) + mode=$(stat -c '%a' "$target" 2>/dev/null || true) + fi + parent=$(dirname "$target") + ancestor=$parent + while [ ! -d "$ancestor" ] && [ "$ancestor" != / ]; do ancestor=$(dirname "$ancestor"); done + nearest=$ancestor + marker="$ancestor/.forgeflow-write-test-$$-\${RANDOM:-0}" + if [ -d "$ancestor" ] && (umask 077; : > "$marker") 2>/dev/null; then + rm -f -- "$marker" >/dev/null 2>&1 || true + parent_writable=true + fi + if [ "$kind" = directory ]; then + if [ -d "$target" ]; then + marker="$target/.forgeflow-write-test-$$-\${RANDOM:-0}" + if (umask 077; : > "$marker") 2>/dev/null; then + rm -f -- "$marker" >/dev/null 2>&1 || true + effective=true + fi + elif [ "$parent_writable" = true ]; then + effective=true + fi + else + if [ "$exists" = true ] && [ ! -f "$target" ]; then + detail='Path exists but is not a regular file.' + elif [ "$exists" = true ] && [ "$readable" = true ] && { [ "$writable" = true ] || [ "$parent_writable" = true ]; }; then + effective=true + elif [ "$exists" = false ] && [ "$parent_writable" = true ]; then + effective=true + detail='File is absent but can be created by the deployment user.' + fi + fi + if [ -z "$detail" ]; then + if [ "$effective" = true ]; then detail='Read/write probe passed.' + else detail="No safe create/replace access for $(id -un 2>/dev/null || echo 'the SSH user')."; fi + fi + printf 'P\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \ + "$(encode "$target_id")" "$(encode "$label")" "$(encode "$target")" "$kind" "$required" \ + "$exists" "$readable" "$writable" "$parent_writable" "$effective" \ + "$(encode "$owner")" "$(encode "$group")" "$mode" "$(encode "$nearest")" "$(encode "$detail")" +} +${targetCalls} +`; + } + + async inspectWriteAccess({ repository, profileId }) { + const { profile, server, remotePath } = this.resolve(repository, profileId); + const result = await this.ssh.exec( + server.id, + bash(this.permissionInspectionScript(profile, server, remotePath)), + { timeout: 45_000, maxOutput: 2 * 1024 * 1024 }, + ); + const report = parsePermissionInspection(result.stdout); + report.serverId = server.id; + report.remotePath = remotePath; + return report; + } + + permissionRepairScript(profile, server, remotePath) { + const preserve = [ + ".git", + "node_modules", + ".venv", + "venv", + "__pycache__", + ...(profile.preservePaths || []), + ] + .map((value) => safeRelativeRemoteFile(value)) + .filter(Boolean); + const pruneExpression = preserve.length + ? preserve + .map((value) => `-path ${shellQuote(path.join(remotePath, value))} -o -path ${shellQuote(path.join(remotePath, value, "*"))}`) + .join(" -o ") + : "-false"; + const composePaths = this.deploymentComposeFiles(profile) + .map((file) => shellQuote(path.join(remotePath, file))) + .join(" "); + return ` +root=${shellQuote(remotePath)} +base=${shellQuote(server.basePath)} +case "$root" in "$base"|"$base"/*) ;; *) echo "Refusing permission repair outside configured base path: $root" >&2; exit 81 ;; esac +run_privileged() { + if [ "$(id -u)" = 0 ]; then "$@"; + elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then sudo -n "$@"; + else "$@"; + fi +} +mkdir_cmd=mkdir +if ! mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null; then + run_privileged mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" +fi +share_group=$(getent group users >/dev/null 2>&1 && echo users || id -gn) +if command -v setfacl >/dev/null 2>&1; then + run_privileged setfacl -m "u:$(id -un):rwx,g:$share_group:rwx,m:rwx" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true + run_privileged setfacl -d -m "u:$(id -un):rwx,g:$share_group:rwx,m:rwx" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true +fi +run_privileged chgrp "$share_group" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true +run_privileged chmod 2775 "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" +if [ -d "$root" ]; then + while IFS= read -r -d '' entry; do + case "$entry" in + "$root/.forgeflow"|"$root/.forgeflow"/*) continue ;; + esac + run_privileged chgrp "$share_group" "$entry" 2>/dev/null || true + if [ -d "$entry" ]; then run_privileged chmod u+rwx,g+rwx,g+s "$entry"; else run_privileged chmod u+rw,g+rw "$entry"; fi + done < <(find "$root" -mindepth 1 \\( ${pruneExpression} \\) -prune -o -print0) +fi +for compose_file in ${composePaths || ""}; do + [ -e "$compose_file" ] || continue + run_privileged chgrp "$share_group" "$compose_file" 2>/dev/null || true + run_privileged chmod u+rw,g+rw "$compose_file" +done +echo "ForgeFlow repaired project write access for $(id -un) and group $share_group without changing preserved runtime paths." +`; + } + + async repairWriteAccess({ repository, profileId }) { + const { profile, server, remotePath } = this.resolve(repository, profileId); + const before = await this.inspectWriteAccess({ repository, profileId }); + await this.ssh.exec(server.id, bash(this.permissionRepairScript(profile, server, remotePath)), { + timeout: 5 * 60_000, + maxOutput: 4 * 1024 * 1024, + }); + const after = await this.inspectWriteAccess({ repository, profileId }); + if (!after.ready) { + const error = new Error( + `Write-access repair did not make every required path writable: ${after.blocking.map((item) => item.path).join(", ")}`, + ); + error.code = "WRITE_ACCESS_REPAIR_INCOMPLETE"; + error.permissionReport = after; + throw error; + } + await this.diagnostics?.info("unraid.write-access.repaired", { + repository: repository.fullName, + profileId, + serverId: server.id, + remotePath, + user: after.identity.user, + }); + return { changed: true, normalized: true, before, after }; + } + 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='' +docker_ok=false; compose_ok=false; compose_v2=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 + if docker compose version >/dev/null 2>&1; then compose_ok=true; compose_v2=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 @@ -404,48 +839,131 @@ command -v tar >/dev/null 2>&1 && tar_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' 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) - branch=$(git -C "$root" symbolic-ref --short HEAD 2>/dev/null || true) - [ -n "$remote" ] || continue - printf 'R\\t%s\\t%s\\t%s\\t%s\\n' "$(printf '%s' "$root" | base64 | tr -d '\\r\\n')" "$(printf '%s' "$remote" | base64 | tr -d '\\r\\n')" "$live" "$(printf '%s' "$branch" | base64 | tr -d '\\r\\n')" +ids='' +if [ "$docker_ok" != true ]; then + printf 'W\\t%s\\n' "$(printf '%s' 'Docker is not installed or not in PATH. Compose files and DockerMan templates will still be scanned.' | base64 | tr -d '\\r\\n')" +else + if ! ids=$(docker ps -aq --no-trunc 2>&1); then + printf 'W\\t%s\\n' "$(printf '%s' "Docker inventory failed: $ids. Compose files and DockerMan templates will still be scanned." | head -c 2000 | base64 | tr -d '\\r\\n')" + ids='' + fi +fi +if [ -n "$ids" ]; then + disappeared=0 + while IFS= read -r container_id; do + [ -n "$container_id" ] || continue + if inspect=$(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":{{json .Config.Labels}},"ports":{{json .NetworkSettings.Ports}},"mounts":{{json .Mounts}},"networks":{{json .NetworkSettings.Networks}},"restartPolicy":{{json .HostConfig.RestartPolicy.Name}}}' "$container_id" 2>/dev/null); then + printf 'C\\t%s\\n' "$(printf '%s' "$inspect" | base64 | tr -d '\\r\\n')" + else + disappeared=$((disappeared + 1)) + fi + done <<< "$ids" + if [ "$disappeared" -gt 0 ]; then + printf 'W\\t%s\\n' "$(printf '%s' "$disappeared stale container reference(s) disappeared during inventory; current containers were still processed." | base64 | tr -d '\\r\\n')" + fi +fi +templates_dir=/boot/config/plugins/dockerMan/templates-user +if [ -d "$templates_dir" ]; then + find "$templates_dir" -maxdepth 1 -type f -name '*.xml' -print0 2>/dev/null | while IFS= read -r -d '' template; do + read_tag() { sed -n "s#.*<$1>\\(.*\\).*#\\1#p" "$template" | head -n1; } + name=$(read_tag Name) + [ -n "$name" ] || continue + 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')" done fi -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 +if [ "$compose_ok" = true ]; then + compose_projects=$(docker compose ls --all --format json 2>/dev/null || docker-compose ls --all --format json 2>/dev/null || true) + if [ -n "$compose_projects" ]; then + printf 'P\\t%s\\n' "$(printf '%s' "$compose_projects" | base64 | tr -d '\\r\\n')" + fi + 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 + + scan_roots=() + add_scan_root() { + candidate=$1 + [ -d "$candidate" ] || return 0 + for existing in "\${scan_roots[@]}"; do [ "$existing" = "$candidate" ] && return 0; done + scan_roots+=("$candidate") + } + add_scan_root "$base" + add_scan_root /mnt/user/appdata + add_scan_root /mnt/cache/appdata + + for root in "\${scan_roots[@]}"; do + find "$root" -mindepth 2 -maxdepth 4 \\( -type d \\( -name .git -o -name node_modules -o -name .forgeflow -o -name releases -o -name backups -o -name staging -o -name incoming -o -name '_audit_quarantine' -o -name 'devrunbook-validation' -o -name 'source-pre-*' -o -name cache -o -name caches -o -name logs -o -name database -o -name databases \\) -prune \\) -o \\( -type f \\( -name '*compose*.yml' -o -name '*compose*.yaml' -o -name 'stack.yml' -o -name 'stack.yaml' \\) -print0 \\) 2>/dev/null | + while IFS= read -r -d '' primary; do + dir=$(dirname "$primary") + filename=$(basename "$primary") + case "$filename" in + compose.override.yml|compose.override.yaml|docker-compose.override.yml|docker-compose.override.yaml) continue ;; + compose.yml) ;; + compose.yaml) [ -f "$dir/compose.yml" ] && continue ;; + docker-compose.yml) { [ -f "$dir/compose.yml" ] || [ -f "$dir/compose.yaml" ]; } && continue ;; + docker-compose.yaml) { [ -f "$dir/compose.yml" ] || [ -f "$dir/compose.yaml" ] || [ -f "$dir/docker-compose.yml" ]; } && continue ;; + *) { [ -f "$dir/compose.yml" ] || [ -f "$dir/compose.yaml" ] || [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/docker-compose.yaml" ]; } && continue ;; + esac + ( + set -- -f "$primary" + files_text=$primary + for extra in "$dir/compose.override.yml" "$dir/compose.override.yaml" "$dir/docker-compose.override.yml" "$dir/docker-compose.override.yaml"; do + [ -f "$extra" ] || continue + set -- "$@" -f "$extra" + files_text="$files_text +$extra" + done + project_name=$(sed -n 's/^name:[[:space:]]*//p' "$primary" 2>/dev/null | head -n1 | cut -d'#' -f1 | tr -d '"' | tr -d "'" | xargs 2>/dev/null || true) + [ -n "$project_name" ] || project_name=$(basename "$dir") + valid=false; services=''; images=''; compose_error='' + if [ "$compose_ok" != true ]; then + compose_error='Docker Compose is unavailable; file metadata was still detected.' + elif [ "$compose_v2" = true ]; then + if services=$(cd "$dir" && docker compose "$@" config --services 2>&1); then + valid=true + images=$(cd "$dir" && docker compose "$@" config --images 2>/dev/null || true) + else compose_error=$services; services=''; fi + else + if services=$(cd "$dir" && docker-compose "$@" config --services 2>&1); then + valid=true + images=$(cd "$dir" && docker-compose "$@" config --images 2>/dev/null || true) + else compose_error=$services; services=''; fi + fi + if [ -z "$services" ]; then + services=$(awk ' + /^[[:space:]]*services:[[:space:]]*($|#)/ { in_services=1; next } + in_services && /^[^[:space:]]/ { exit } + in_services && /^ [A-Za-z0-9._-]+:[[:space:]]*($|#)/ { + line=$0; sub(/^[[:space:]]*/, "", line); sub(/:.*/, "", line); print line + } + ' "$primary" 2>/dev/null || true) + fi + if [ -z "$images" ]; then + images=$(awk ' + /^[[:space:]]*services:[[:space:]]*($|#)/ { in_services=1; next } + in_services && /^[^[:space:]]/ { exit } + in_services && /^[[:space:]]+image:[[:space:]]*/ { + line=$0; sub(/^[[:space:]]*image:[[:space:]]*/, "", line); sub(/[[:space:]]+#.*/, "", line); gsub(/"/, "", line); print line + } + ' "$primary" 2>/dev/null || true) + fi + printf 'Y\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \\ + "$(printf '%s' "$dir" | base64 | tr -d '\\r\\n')" \\ + "$(printf '%s' "$files_text" | base64 | tr -d '\\r\\n')" \\ + "$(printf '%s' "$project_name" | base64 | tr -d '\\r\\n')" \\ + "$(printf '%s' "$services" | base64 | tr -d '\\r\\n')" \\ + "$(printf '%s' "$images" | base64 | tr -d '\\r\\n')" \\ + "$valid" \\ + "$(printf '%s' "$compose_error" | head -c 2000 | base64 | tr -d '\\r\\n')" + ) + done + done `; } @@ -469,10 +987,15 @@ done return [...new Set(files.length ? files : ["docker-compose.yml"])]; } - profileFromWorkload(repository, server, workload, { linkSource = "manual", deploymentMode = "push-bundle", remoteFolder = "" } = {}) { + profileFromWorkload(repository, server, workload, { linkSource = "manual", deploymentMode = "server-git", remoteFolder = "" } = {}) { + const effectiveDeploymentMode = ["push-bundle", "server-git", "monitor-only"].includes(deploymentMode) + ? deploymentMode + : "server-git"; const selectedFolder = safeRemoteFolder(remoteFolder || workload.remoteFolderCandidate || repository.name); const composeFiles = this.relativeComposeFiles(workload); - const services = [...new Set((workload.compose?.services || []).filter(Boolean))]; + const services = [...new Set((workload.compose?.services || []) + .map((service) => String(service || "").trim().toLowerCase().replace(/[^a-z0-9._-]/g, "-")) + .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); @@ -485,7 +1008,7 @@ done if (relative) preservePaths.add(relative); } } - const idPrefix = linkSource === "automatic" ? "auto" : "link"; + const idPrefix = String(linkSource).startsWith("automatic") ? "auto" : "link"; const profileId = `${idPrefix}-${crypto.createHash("sha256").update(`${server.id}:${repository.fullName}:${workload.workloadId}`).digest("hex").slice(0, 20)}`; return { id: profileId, @@ -495,7 +1018,7 @@ done branch: workload.metadata?.branch || repository.defaultBranch || "main", serverId: server.id, remoteFolder: selectedFolder, - deploymentMode, + deploymentMode: effectiveDeploymentMode, composeFile: composeFiles[0], composeFiles, composeProject: workload.compose?.project || "", @@ -536,16 +1059,55 @@ done image: primary.image || "", dockerManTemplatePath: workload.dockerMan?.templatePath || "", }, - confirmationRequired: true, + confirmationRequired: !String(linkSource).startsWith("automatic"), + }; + } + + refreshedProfileFromWorkload(repository, server, workload, existingProfile) { + const detected = this.profileFromWorkload(repository, server, workload, { + linkSource: existingProfile.workloadIdentity?.linkSource || "automatic-compose", + deploymentMode: ["push-bundle", "server-git", "monitor-only"].includes(existingProfile.deploymentMode) + ? existingProfile.deploymentMode + : "push-bundle", + remoteFolder: workload.remoteFolderCandidate || existingProfile.remoteFolder, + }); + return { + ...existingProfile, + deploymentMode: detected.deploymentMode, + remoteFolder: detected.remoteFolder, + composeFile: detected.composeFile, + composeFiles: detected.composeFiles, + composeProject: detected.composeProject, + composeWorkingDir: detected.composeWorkingDir, + composeService: detected.composeService, + composeServices: detected.composeServices, + containerName: detected.containerName || existingProfile.containerName, + hostPort: detected.hostPort || existingProfile.hostPort || null, + containerPort: detected.containerPort || existingProfile.containerPort || null, + webUiUrl: detected.webUiUrl || existingProfile.webUiUrl || "", + serverIconReference: detected.serverIconReference || existingProfile.serverIconReference || "", + dockerShell: detected.dockerShell || existingProfile.dockerShell || "/bin/sh", + preservePaths: [...new Set([...(existingProfile.preservePaths || []), ...(detected.preservePaths || [])])], + generatedCompose: false, + adoptedFromServer: true, + serverSourceOfTruth: true, + manageDockerMan: false, + forceRecreate: false, + removeOrphans: false, + workloadIdentity: detected.workloadIdentity, + detectedAt: detected.detectedAt, + detectedMetadata: detected.detectedMetadata, }; } 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 previousState = this.store.getDeploymentState?.(profile.id) || {}; + const observedLiveSha = /^[0-9a-f]{40,64}$/i.test(candidateSha) ? candidateSha.toLowerCase() : null; + const liveSha = observedLiveSha || previousState.liveSha || null; const profileRemote = inventoryRemoteIdentity(profile.cloneUrl); const workloadRemote = inventoryRemoteIdentity(workload.metadata?.sourceRepository); - const repositoryMatches = Boolean(liveSha && profileRemote && workloadRemote && profileRemote === workloadRemote); + const repositoryMatches = Boolean(observedLiveSha && profileRemote && workloadRemote && profileRemote === workloadRemote); const primary = workload.containers.find((container) => container.running) || workload.containers[0] || {}; return this.store.saveDeploymentState(profile.id, { liveSha, @@ -560,8 +1122,9 @@ done composeProject: workload.compose?.project || null, observedAt: workload.observedAt, evidence: liveSha ? "container-provenance-label" : "runtime-only", - giteaSha: repositoryMatches ? liveSha : null, - matchesGitea: repositoryMatches, + giteaSha: repositoryMatches ? observedLiveSha : previousState.giteaSha || null, + matchesGitea: repositoryMatches ? true : previousState.matchesGitea === true && previousState.liveSha === liveSha, + previousSha: previousState.previousSha || null, }); } @@ -569,8 +1132,8 @@ done 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, + timeout: 180_000, + maxOutput: 64 * 1024 * 1024, }); const inventory = parseWorkloadInventory(result.stdout); const workloads = buildWorkloadInventory({ @@ -579,17 +1142,99 @@ done repositories, profiles: this.allSshProfiles(), }); + // A profile can be evidenced by both a running container and an old Compose + // definition. Keep the running workload authoritative so one repository never + // appears as multiple linked deployment cards. + const authoritativeLinkByRepository = new Map(); + for (const workload of workloads) { + const repositoryName = String(workload.link?.repositoryFullName || "").toLowerCase(); + if (!repositoryName) continue; + const rank = (workload.runtime?.running ? 100 : 0) + + (workload.containers?.length ? 10 : 0) + + (workload.compose?.workingDir ? 1 : 0); + const current = authoritativeLinkByRepository.get(repositoryName); + if (!current || rank > current.rank) authoritativeLinkByRepository.set(repositoryName, { workloadId: workload.workloadId, rank }); + } + for (const workload of workloads) { + const repositoryName = String(workload.link?.repositoryFullName || "").toLowerCase(); + const authoritativeWorkloadId = authoritativeLinkByRepository.get(repositoryName)?.workloadId; + if (!repositoryName || !authoritativeWorkloadId || workload.workloadId === authoritativeWorkloadId) continue; + workload.link = null; + workload.status = workload.candidates?.length ? "suggested" : "unmatched"; + } let adopted = 0; + let refreshed = 0; + let retired = 0; + const inventoryStable = (inventory.warnings || []).every((warning) => /stale container reference\(s\) disappeared during inventory/i.test(warning)); + if (inventoryStable && workloads.length) { + const activeWorkloadIds = new Set(workloads.map((item) => item.workloadId)); + const staleAutomaticProfiles = this.allSshProfiles().filter((profile) => + profile.serverId === serverId + && String(profile.workloadIdentity?.linkSource || "").startsWith("automatic") + && profile.workloadIdentity?.workloadId + && !activeWorkloadIds.has(profile.workloadIdentity.workloadId), + ); + for (const profile of staleAutomaticProfiles) { + await this.store.deleteDeploymentProfile(profile._repositoryFullName, profile.id); + retired += 1; + } + const runningRepositoryLinks = new Set(workloads + .filter((workload) => workload.runtime?.running && workload.link?.repositoryFullName) + .map((workload) => String(workload.link.repositoryFullName).toLowerCase())); + const runningProfileIds = new Set(workloads + .filter((workload) => workload.runtime?.running && workload.link?.profileId) + .map((workload) => workload.link.profileId)); + const shadowedAutomaticProfiles = workloads + .filter((workload) => !workload.runtime?.running && workload.link?.profileId && !runningProfileIds.has(workload.link.profileId) && runningRepositoryLinks.has(String(workload.link.repositoryFullName).toLowerCase())) + .map((workload) => this.allSshProfiles().find((profile) => profile.id === workload.link.profileId && String(profile._repositoryFullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase())) + .filter((profile) => profile && String(profile.workloadIdentity?.linkSource || "").startsWith("automatic")); + for (const profile of shadowedAutomaticProfiles) { + await this.store.deleteDeploymentProfile(profile._repositoryFullName, profile.id); + retired += 1; + } + } + for (const workload of workloads) { + if (workload.status !== "linked" || !workload.link?.profileId || !workload.link?.repositoryFullName) continue; + const repository = (repositories || []).find((item) => item.fullName === workload.link.repositoryFullName); + const existingProfile = this.store.getDeploymentProfile?.(workload.link.repositoryFullName, workload.link.profileId) + || this.allSshProfiles().find((item) => item.id === workload.link.profileId && item._repositoryFullName === workload.link.repositoryFullName); + if (!repository || !existingProfile) continue; + const updated = this.refreshedProfileFromWorkload(repository, server, workload, existingProfile); + const saved = await this.store.saveDeploymentProfile(repository.fullName, updated); + await this.saveWorkloadState(saved, workload, server); + refreshed += 1; + } if (autoLink) { + const alreadyLinkedRepositories = new Set(workloads + .filter((item) => item.runtime?.running && item.link?.repositoryFullName) + .map((item) => String(item.link.repositoryFullName).toLowerCase())); 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 (workload.status === "linked") continue; + const candidate = workload.candidates[0]; + const uniqueCandidate = workload.candidates.length === 1; + if (candidate && alreadyLinkedRepositories.has(String(candidate.repositoryFullName).toLowerCase())) continue; + const exactMatch = uniqueCandidate && candidate?.exact === true; + const strongComposeMatch = uniqueCandidate + && candidate?.confidence === "strong" + && candidate.score >= 90 + && Boolean(workload.compose?.workingDir) + && Boolean(workload.compose?.configFiles?.length); + const exactRuntimeIdentity = uniqueCandidate + && candidate?.identityExact === true + && candidate.score >= 70 + && workload.runtime?.running === true + && Boolean(workload.remoteFolderCandidate) + && !alreadyLinkedRepositories.has(String(candidate.repositoryFullName).toLowerCase()); + if (!exactMatch && !strongComposeMatch && !exactRuntimeIdentity) continue; + const repository = (repositories || []).find((item) => item.fullName === candidate.repositoryFullName); if (!repository) continue; - const profile = this.profileFromWorkload(repository, server, workload, { linkSource: "automatic", deploymentMode: "push-bundle" }); + const linkSource = exactMatch ? "automatic" : exactRuntimeIdentity ? "automatic-runtime-identity" : "automatic-compose"; + const profile = this.profileFromWorkload(repository, server, workload, { linkSource, deploymentMode: "server-git" }); const saved = await this.store.saveDeploymentProfile(repository.fullName, profile); await this.saveWorkloadState(saved, workload, server); workload.status = "linked"; - workload.link = { status: "linked", profileId: saved.id, repositoryFullName: repository.fullName, source: "automatic" }; + workload.link = { status: "linked", profileId: saved.id, repositoryFullName: repository.fullName, source: linkSource }; + alreadyLinkedRepositories.add(String(repository.fullName).toLowerCase()); adopted += 1; } } @@ -598,6 +1243,8 @@ done serverName: server.name, detected: workloads.length, adopted, + refreshed, + retired, verified: workloads.filter((item) => item.runtime.health === "healthy" && item.link).length, linked: workloads.filter((item) => item.status === "linked").length, unmatched: workloads.filter((item) => !item.link).length, @@ -621,7 +1268,10 @@ done return this.scanServerInventory(serverId, repositories, { autoLink: true }); } - async linkServerWorkload({ repository, serverId, workloadId, deploymentMode = "push-bundle", remoteFolder = "" }) { + async linkServerWorkload({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) { + const effectiveDeploymentMode = ["push-bundle", "server-git", "monitor-only"].includes(deploymentMode) + ? deploymentMode + : "server-git"; const server = this.store.getServer(serverId); if (!server) throw new Error("The deployment server no longer exists."); const inventory = await this.scanServerInventory(serverId, [repository], { autoLink: false }); @@ -633,10 +1283,10 @@ done error.code = "WORKLOAD_ALREADY_LINKED"; throw error; } - const profile = this.profileFromWorkload(repository, server, workload, { linkSource: "manual", deploymentMode, remoteFolder }); + const profile = this.profileFromWorkload(repository, server, workload, { linkSource: "manual", deploymentMode: effectiveDeploymentMode, 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 }); + await this.diagnostics?.info("unraid.workload.linked", { serverId, workloadId, repository: repository.fullName, profileId: saved.id, deploymentMode: effectiveDeploymentMode }); return { profile: saved, state, workload }; } @@ -664,7 +1314,13 @@ done throw new Error( "Remote project path escapes the configured server base path.", ); - return { profile, server, remoteFolder, remotePath }; + const effectiveProfile = { + ...profile, + deploymentMode: ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode) + ? profile.deploymentMode + : "push-bundle", + }; + return { profile: effectiveProfile, server, remoteFolder, remotePath }; } async discoverExisting({ repository, serverId, remoteFolder = "" }) { @@ -688,7 +1344,7 @@ done } const profile = this.profileFromWorkload(repository, server, workload, { linkSource: "manual", - deploymentMode: "push-bundle", + deploymentMode: "server-git", remoteFolder: folder, }); const source = (value, origin, confidence = "confirmed") => ({ @@ -750,7 +1406,7 @@ if [ -d "$root" ]; then tracked_changes=$(git -C "$root" status --porcelain --untracked-files=no 2>/dev/null | head -n 25 | base64 | tr -d '\\r\\n' || true) fi 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' -o -name 'compose.forgeflow.yml' \\) -printf '%P\\n' 2>/dev/null | sort | base64 | tr -d '\\r\\n' || true) - nested_git=$(find "$root" -mindepth 2 -maxdepth 5 -type d -name .git -printf '%h\\n' 2>/dev/null | sed "s#^$root/##" | sort | base64 | tr -d '\\r\\n' || true) + nested_git=$(find "$root" -mindepth 2 -maxdepth 4 -type d -name .git -printf '%h\\n' 2>/dev/null | sed "s#^$root/##" | sort | base64 | tr -d '\\r\\n' || true) [ -f "$root/Dockerfile" ] && dockerfile=true [ -f "$root/.dockerignore" ] && dockerignore_content=$(base64 < "$root/.dockerignore" | tr -d '\\r\\n' || true) existing_preserve_paths=$({ ${preserveProbe || ":"}; } | sort -u | base64 | tr -d '\\r\\n' || true) @@ -811,33 +1467,44 @@ 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 deploymentMode = ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode) + ? profile.deploymentMode + : "push-bundle"; + let requestedSha = sha || repository.localStatus?.head; + if (deploymentMode === "server-git" && !sha) { + const [owner, repo] = String(repository.fullName || "").split("/"); + const branch = await this.gitea.getBranch(owner, repo, profile.branch); + requestedSha = branch?.commit?.id || branch?.commit?.sha || null; + } + const targetSha = assertFullCommitSha(requestedSha); const checks = []; let inspection = null; let connectionCapabilities = null; + let permissions = 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.", + detail: "This workload is linked for monitoring only. Select Server pull or Direct copy 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.", + detail: deploymentMode === "server-git" + ? "Unraid fetches the exact Gitea commit with a repository-scoped read-only deploy key." + : "ForgeFlow copies the exact committed local project directly to Unraid and runs Docker Compose there.", }); if (!repository.localPath) { checks.push({ id: "local-repository", label: "Local repository", - status: "fail", - detail: "Link or clone the repository locally before deploying.", + status: deploymentMode === "server-git" ? "pass" : "fail", + detail: deploymentMode === "server-git" + ? "Not required: the exact commit is fetched from Gitea by the server." + : "Link or clone the repository locally before using Direct copy.", }); } else { try { @@ -851,73 +1518,37 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths" checks.push({ id: "local-branch", label: "Allowed branch", - status: localStatus.branch.head === profile.branch ? "pass" : "fail", + status: localStatus.branch.head === profile.branch ? "pass" : deploymentMode === "server-git" ? "warning" : "fail", detail: `Current: ${localStatus.branch.head || "detached"}; required: ${profile.branch}.`, }); checks.push({ id: "local-clean", label: "Clean local working tree", - status: localStatus.clean ? "pass" : "fail", + status: localStatus.clean ? "pass" : deploymentMode === "server-git" ? "warning" : "fail", detail: localStatus.clean ? "No uncommitted changes." : `${localStatus.counts.changed} changed file(s) remain.`, }); checks.push({ - id: "local-upstream", - label: "Published upstream", - status: localStatus.branch.upstream ? "pass" : "fail", - detail: - localStatus.branch.upstream || "No upstream branch is configured.", + id: "deployment-source", + label: deploymentMode === "server-git" ? "Gitea deployment source" : "Direct deployment source", + status: "pass", + detail: deploymentMode === "server-git" + ? "Local files are not uploaded; the exact requested commit is fetched from Gitea." + : "The exact committed local HEAD is archived and copied directly to Unraid. No server-side repository access is involved.", }); - checks.push({ - id: "local-sync", - label: "Local and Gitea synchronized", - status: - !localStatus.branch.ahead && !localStatus.branch.behind - ? "pass" - : "fail", - detail: `${localStatus.branch.ahead || 0} ahead, ${localStatus.branch.behind || 0} behind.`, - }); - checks.push({ - id: "local-target-sha", - label: "Selected deployment commit", - status: localStatus.head === targetSha ? "pass" : "fail", - detail: - localStatus.head === targetSha - ? targetSha - : `Local HEAD is ${localStatus.head || "unknown"}, but deployment requested ${targetSha}.`, - }); - try { - await this.git.verifyCommitOnRemoteBranch( - repository.localPath, - targetSha, - profile.branch, - ); - checks.push({ - id: "remote-target-sha", - label: "Exact commit on Gitea branch", - status: "pass", - detail: `${targetSha.slice(0, 7)} exists on origin/${profile.branch}.`, - }); - } catch (error) { - checks.push({ - id: "remote-target-sha", - label: "Exact commit on Gitea branch", - status: "fail", - detail: error.message, - }); - } - const localDeploymentFiles = profile.generatedCompose + + const localDeploymentFiles = deploymentMode === "push-bundle" && profile.generatedCompose ? [nativePath.join(repository.localPath, "Dockerfile")] - : this.deploymentComposeFiles(profile).map((file) => + : deploymentMode === "push-bundle" ? this.deploymentComposeFiles(profile).map((file) => nativePath.join(repository.localPath, safeRelativeRemoteFile(file)), - ); + ) : []; const missingDeploymentFiles = []; for (const file of localDeploymentFiles) { if (!Boolean((await fs.stat(file).catch(() => null))?.isFile())) missingDeploymentFiles.push(file); } - checks.push({ + if (deploymentMode === "push-bundle") checks.push({ id: "local-deployment-file", label: profile.generatedCompose ? "Dockerfile in repository" @@ -962,29 +1593,22 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths" status: connectionCapabilities.compose ? "pass" : "fail", detail: connectionCapabilities.composeVersion || "Docker Compose was not detected on the server.", }); - if (deploymentMode === "push-bundle") { - checks.push({ - id: "bundle-tools", - label: "Bundle extraction and checksum tools", - status: connectionCapabilities.tar && connectionCapabilities.checksum ? "pass" : "fail", - detail: connectionCapabilities.tar && connectionCapabilities.checksum + checks.push({ + id: "bundle-tools", + label: deploymentMode === "server-git" ? "Server pull tools" : "Direct copy tools", + status: connectionCapabilities.tar && connectionCapabilities.checksum && (deploymentMode !== "server-git" || connectionCapabilities.git) ? "pass" : "fail", + detail: deploymentMode === "server-git" + ? `Git ${connectionCapabilities.git ? "available" : "missing"}; tar ${connectionCapabilities.tar ? "available" : "missing"}; checksum ${connectionCapabilities.checksum ? "available" : "missing"}.` + : connectionCapabilities.tar && connectionCapabilities.checksum ? "tar and a SHA-256 checksum tool are available." : `tar ${connectionCapabilities.tar ? "available" : "missing"}; checksum tool ${connectionCapabilities.checksum ? "available" : "missing"}.`, - }); - checks.push({ - 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.", - }); - } + }); + checks.push({ + 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.`, + }); } catch (error) { checks.push({ id: "ssh", @@ -1007,38 +1631,61 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths" }); if (deploymentMode === "server-git") { - const cloneUrl = String(profile.cloneUrl || repository.sshUrl || repository.preferredCloneUrl || "").trim(); - if (!cloneUrl) checks.push({ + const access = await this.probeServerGitAccess({ repository, profile, server }); + checks.push({ id: "server-git-access", - label: "Unraid → Gitea access", - status: "fail", - detail: "No server-usable Git clone URL is configured.", + label: "Unraid → Gitea read access", + status: access.ready ? "pass" : "fail", + detail: access.ready + ? `Read-only deploy key verified${access.remoteSha ? ` at ${access.remoteSha.slice(0, 7)}` : ""}.` + : access.error, + repairAction: access.ready ? null : "configure-server-git-access", + repairLabel: "Configure read-only deploy key", }); - 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", + id: "transfer-path", + label: "Desktop → Unraid transfer", status: "pass", - detail: "Not required in Push bundle mode. The trusted desktop SSH connection transfers the release files.", + detail: "Files are copied over the configured server connection. No Gitea credential is required on Unraid.", }); + try { + permissions = await this.inspectWriteAccess({ repository, profileId }); + const blockingPaths = permissions.blocking.map((target) => target.path); + checks.push({ + id: "project-write-access", + label: "Project write access", + status: permissions.ready ? "pass" : "fail", + detail: permissions.ready + ? `${permissions.identity.user} can create and atomically replace deployment files in ${remotePath}.` + : `No safe write access for ${permissions.identity.user}: ${blockingPaths.join(", ")}`, + help: permissions.ready + ? "ForgeFlow rechecks these paths immediately before every upload and Compose activation." + : "Use Fix write access to repair only the linked project source and ForgeFlow state folders. Preserved runtime data is excluded.", + repairAction: permissions.ready ? null : "repair-deployment-write-access", + repairLabel: "Fix write access", + }); + for (const target of permissions.targets.filter( + (item) => item.required && !item.effectiveWritable, + )) { + checks.push({ + id: `write-path:${target.id}`, + label: target.label, + status: "fail", + detail: `${target.path} · owner ${target.owner || "unknown"}:${target.group || "unknown"} · mode ${target.mode || "unknown"}. ${target.detail}`, + repairAction: "repair-deployment-write-access", + repairLabel: "Fix write access", + }); + } + } catch (error) { + checks.push({ + id: "project-write-access", + label: "Project write access", + status: "fail", + detail: error.message, + }); + } + try { inspection = await this.inspect({ repository, profileId }); @@ -1049,13 +1696,6 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths" status: "pass", detail: `${remotePath} will be created.`, }); - } 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. Select Push bundle to take over an existing non-Git installation safely.`, - }); } else { checks.push({ id: "remote-folder", @@ -1066,56 +1706,17 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths" : `${remotePath} will receive managed release files while preserved and unknown runtime data remains untouched.`, }); } - 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 switch to Push bundle.`, - }); - } else if (inspection.rootGit) { + if (inspection.rootGit) { checks.push({ id: "tracked-changes", label: "Server-side tracked changes", 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.` + ? `${inspection.trackedChanges.length} tracked server edit(s) exist. Direct copy preserves unknown runtime data and does not depend on the server Git checkout.` : "No tracked server-only edits detected.", }); } - if (deploymentMode === "server-git" && inspection.rootGit && profile.cloneUrl && inspection.remote) { - const expectedRemote = normalizeRemoteUrl(profile.cloneUrl); - const currentRemote = normalizeRemoteUrl(inspection.remote); - const matches = Boolean( - expectedRemote && - currentRemote && - expectedRemote.host === currentRemote.host && - expectedRemote.path === currentRemote.path, - ); - if (!matches && profile.alignRemote) { - checks.push({ - id: "origin-url", - label: "Server Git origin", - status: "warning", - detail: `Origin will be aligned from ${inspection.remote} to the configured clone URL before fetch.`, - }); - } else if (!matches) { - checks.push({ - id: "origin-url", - label: "Server Git origin", - status: "fail", - detail: `Current origin ${inspection.remote} does not match the configured clone URL. Enable controlled origin alignment or correct the profile.`, - }); - } else { - checks.push({ - id: "origin-url", - label: "Server Git origin", - status: "pass", - detail: inspection.remote, - }); - } - } if (inspection.nestedGit.length) { checks.push({ id: "nested-git", @@ -1171,25 +1772,16 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths" : (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: `${missingRemoteCompose.join(", ")} ${missingRemoteCompose.length === 1 ? "was" : "were"} not found in the server checkout.`, - }); - } else { - checks.push({ - id: "compose-file", - label: "Compose configuration", - status: "pass", - detail: profile.generatedCompose - ? "ForgeFlow will generate an isolated Compose file." - : deploymentMode === "push-bundle" && missingRemoteCompose.length - ? `${composeFiles.join(", ")} will be uploaded from the exact local commit.` - : composeFiles.join(", "), - }); - } + checks.push({ + id: "compose-file", + label: "Compose configuration", + status: "pass", + detail: profile.generatedCompose + ? "ForgeFlow will generate an isolated Compose file." + : missingRemoteCompose.length + ? `${composeFiles.join(", ")} will be uploaded from the exact local commit.` + : composeFiles.join(", "), + }); } catch (error) { checks.push({ id: "inspection", @@ -1274,6 +1866,7 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths" server: { id: server.id, name: server.name, host: server.host }, remotePath, inspection, + permissions, checks, summary: checksSummary(checks), }; @@ -1441,7 +2034,9 @@ ${this.iconCacheRefresh(profile, repository, iconReference)}`; 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)))]; + return [...new Set(values + .map((value) => safeRelativeRemoteFile(value)) + .filter((value) => value !== ".forgeflow/compose.metadata.yml" && value !== ".forgeflow/compose.forgeflow.yml"))]; } metadataCompose(profile, repository, iconReference = "", deployment = {}) { @@ -1528,33 +2123,52 @@ ${this.iconCacheRefresh(profile, repository, iconReference)}`; composeInvocation(profile, repository) { const project = String(profile.composeProject || this.internalSlug(profile, repository)).trim(); - const files = [...this.deploymentComposeFiles(profile), ".forgeflow/compose.metadata.yml"]; + const files = [...this.deploymentComposeFiles(profile)]; + // A labels-only Compose fragment is valid only when every service key also + // exists in the base definition. Imported profiles can contain stale service + // hints, so adopted workloads must activate from their real server Compose + // files only. ForgeFlow tracks the deployed SHA in .forgeflow/status.json. + if (profile.generatedCompose) files.push(".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(" "); + // ForgeFlow never adds destructive recreation or orphan-removal flags. + // Compose may replace a service when its built image or configuration changed, + // but unrelated containers are never deleted by ForgeFlow. + void profile; + return ""; } - 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 + containerVerificationScript(profile, repository, compose, { requireRecreated = false } = {}) { + const recreationCheck = requireRecreated + ? ` before_id=$(awk -F '\t' -v wanted="$service" '$1 == wanted { print $2; exit }' "$before_containers" 2>/dev/null || true) + if [ -n "$before_id" ] && [ "$before_id" = "$container_id" ]; then + echo "Compose reported success but service $service still uses the previous container $container_id" >&2 + exit 65 + fi` + : ` before_id=""`; + return `actual_services=$(${compose} config --services 2>/dev/null | sed '/^$/d') +[ -n "$actual_services" ] || { echo "Compose project defines no services" >&2; exit 60; } +printf '%s\n' "$actual_services" | while IFS= read -r service; do [ -n "$service" ] || continue - container_id=$(${compose} ps -q "$service" | head -n1) + attempt=0; container_id=''; running=false; health='' + while [ "$attempt" -lt 30 ]; do + container_id=$(${compose} ps -q "$service" | head -n1) + if [ -n "$container_id" ]; then + running=$(docker inspect -f '{{.State.Running}}' "$container_id" 2>/dev/null || echo false) + health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container_id" 2>/dev/null || true) + if [ "$running" = true ] && [ "$health" != unhealthy ] && [ "$health" != starting ]; then break; fi + fi + attempt=$((attempt + 1)); sleep 2 + done [ -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`; + [ "$running" = true ] || { echo "Compose service $service is not running after 60 seconds" >&2; exit 62; } + [ "$health" != unhealthy ] && [ "$health" != starting ] || { echo "Compose service $service did not become healthy" >&2; exit 63; } +${recreationCheck} + image_id=$(docker inspect -f '{{.Image}}' "$container_id" 2>/dev/null || true) + printf 'ForgeFlow verified service %s: container=%s previous=%s image=%s\n' "$service" "$container_id" "\${before_id:-none}" "\${image_id:-unknown}" +done`; } async checkHealth(url) { @@ -1629,19 +2243,45 @@ FORGEFLOW_EXPECTED_SERVICES`; healthy: null, healthcheck_url_configured: Boolean(profile.healthcheckUrl), rollback, - deployment_mode: profile.deploymentMode || "server-git", + deployment_mode: profile.deploymentMode || "push-bundle", 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); + const project = String( + profile.composeProject || this.internalSlug(profile, repository), + ).trim(); + const candidateFiles = [...this.deploymentComposeFiles(profile)]; + if (profile.generatedCompose) candidateFiles.push(".forgeflow/compose.metadata.yml"); + const candidateCompose = `forgeflow_compose -p ${shellQuote(project)} ${candidateFiles + .map((file) => `-f "$release"/${shellQuote(file)}`) + .join(" ")}`; + 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, + { requireRecreated: true }, + ); + const containerHint = String( + profile.containerName || profile.remoteFolder || repository.name || "", + ).trim(); return ` root=${shellQuote(remotePath)} +expected_project=${shellQuote(project)} +tracked_container_hint=${shellQuote(containerHint)} target=${shellQuote(targetSha)} request_id=${shellQuote(requestId)} incoming=${shellQuote(remotePart)} @@ -1661,28 +2301,78 @@ 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 +activation_started=false +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 +} +restore_files() { + if [ -f "$backup/present" ]; then + while IFS= read -r rel; do + [ -n "$rel" ] || continue + mkdir -p -- "$root/$(dirname "$rel")" + temp="$root/$rel.forgeflow-restore-$request_id" + cp -a -- "$backup/source/$rel" "$temp" && mv -f -- "$temp" "$root/$rel" + done < "$backup/present" + fi + if [ -f "$backup/absent" ]; then + while IFS= read -r rel; do + [ -n "$rel" ] || continue + case "$rel" in .forgeflow/*) continue ;; esac + [ -e "$root/$rel" ] || [ -L "$root/$rel" ] || continue + rm -f -- "$root/$rel" + done < "$backup/absent" + fi + if [ -f "$backup/generated.present" ]; then + cp -a "$backup/compose.forgeflow.yml" "$root/.forgeflow/compose.forgeflow.yml" + elif [ -f "$backup/generated.created" ]; then + rm -f "$root/.forgeflow/compose.forgeflow.yml" + fi + if [ -f "$backup/metadata.present" ]; then + cp -a "$backup/compose.metadata.yml" "$root/.forgeflow/compose.metadata.yml" + elif [ -f "$backup/metadata.created" ]; then + rm -f "$root/.forgeflow/compose.metadata.yml" + fi +} +restore_images() { + [ -f "$backup/containers.before" ] || return 0 + while IFS=$'\t' read -r service container_id image_id image_ref_b64; do + [ -n "$image_id" ] || continue + docker image inspect "$image_id" >/dev/null 2>&1 || continue + image_ref=$(printf '%s' "$image_ref_b64" | base64 -d 2>/dev/null || true) + case "$image_ref" in ''|sha256:*|*@sha256:*) continue ;; esac + docker image tag "$image_id" "$image_ref" >/dev/null 2>&1 || true + done < "$backup/containers.before" +} +restore_runtime() { + [ "$activation_started" = true ] || return 0 + restore_images + if cd "$root" 2>/dev/null && ${compose} config >/dev/null 2>&1; then + ${compose} up -d --no-build >/dev/null 2>&1 || return 1 + old_services=$(${compose} config --services 2>/dev/null | sed '/^$/d') + printf '%s\n' "$old_services" | while IFS= read -r service; do + [ -n "$service" ] || continue + old_id=$(${compose} ps -q "$service" | head -n1) + [ -n "$old_id" ] || exit 1 + [ "$(docker inspect -f '{{.State.Running}}' "$old_id" 2>/dev/null || echo false)" = true ] || exit 1 + done + fi +} 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" + restore_files + if ! restore_runtime; then + echo "CRITICAL: source files were restored, but the previous Compose runtime could not be restarted automatically. Backup: $backup" >&2 + else + echo "ForgeFlow restored the previous source and runtime after the failed activation." >&2 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" @@ -1702,72 +2392,129 @@ 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" +for runtime_config in .env compose.override.yml compose.override.yaml docker-compose.override.yml docker-compose.override.yaml; do + if [ -f "$root/$runtime_config" ] && [ ! -e "$release/$runtime_config" ]; then + mkdir -p "$release/$(dirname "$runtime_config")" + cp -a "$root/$runtime_config" "$release/$runtime_config" + fi +done +${profile.generatedCompose ? `mkdir -p "$release/.forgeflow" +cat > "$release/.forgeflow/compose.forgeflow.yml" <<'FORGEFLOW_COMPOSE' +${generated}FORGEFLOW_COMPOSE +cat > "$release/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA' +${metadata}FORGEFLOW_METADATA` : ""} +cd "$release" +${candidateCompose} config >/dev/null +candidate_services=$(${candidateCompose} config --services 2>/dev/null | sed '/^$/d') +[ -n "$candidate_services" ] || { echo "Candidate Compose project defines no services" >&2; exit 60; } 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" +: > "$backup/containers.before" +had_existing_compose=false +if cd "$root" 2>/dev/null && ${compose} config >/dev/null 2>&1; then + had_existing_compose=true + old_services=$(${compose} config --services 2>/dev/null | sed '/^$/d') + printf '%s\n' "$old_services" | while IFS= read -r service; do + [ -n "$service" ] || continue + container_id=$(${compose} ps -q "$service" | head -n1) + image_id=''; image_ref='' + if [ -n "$container_id" ] && docker inspect "$container_id" >/dev/null 2>&1; then + image_id=$(docker inspect -f '{{.Image}}' "$container_id" 2>/dev/null || true) + image_ref=$(docker inspect -f '{{.Config.Image}}' "$container_id" 2>/dev/null || true) + fi + printf '%s\t%s\t%s\t%s\n' "$service" "$container_id" "$image_id" "$(printf '%s' "$image_ref" | base64 | tr -d '\r\n')" + done >> "$backup/containers.before" +fi +if [ -n "$tracked_container_hint" ] && docker inspect "$tracked_container_hint" >/dev/null 2>&1; then + hint_project=$(docker inspect -f '{{index .Config.Labels "com.docker.compose.project"}}' "$tracked_container_hint" 2>/dev/null || true) + if [ -n "$hint_project" ] && [ "$hint_project" != "$expected_project" ]; then + echo "Refusing activation: container $tracked_container_hint belongs to Compose project $hint_project, not $expected_project" >&2 + exit 67 + fi +fi +# Build all candidate images before any running container is touched. +cd "$release" +${candidateCompose} build +new_manifest="$release_root/managed-files" 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; } + parent=$(dirname "$rel") + current="$root" + if [ "$parent" != . ]; then + 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 + fi + [ ! -L "$root/$rel" ] || { echo "Refusing to replace symlinked managed path $rel" >&2; exit 74; } + if [ -d "$root/$rel" ]; then echo "A directory conflicts with managed file $rel" >&2; exit 75; fi + if [ -e "$root/$rel" ]; then 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" +done < "$new_manifest" [ -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" + temp="$root/$rel.forgeflow-new-$request_id" + cp -a -- "$release/$rel" "$temp" + mv -f -- "$temp" "$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' +${profile.generatedCompose ? `if [ ! -f "$backup/generated.present" ]; then touch "$backup/generated.created"; fi +if [ ! -f "$backup/metadata.present" ]; then touch "$backup/metadata.created"; fi +cat > "$root/.forgeflow/compose.forgeflow.yml.pending" <<'FORGEFLOW_COMPOSE' +${generated}FORGEFLOW_COMPOSE +mv "$root/.forgeflow/compose.forgeflow.yml.pending" "$root/.forgeflow/compose.forgeflow.yml" +cat > "$root/.forgeflow/compose.metadata.yml.pending" <<'FORGEFLOW_METADATA' ${metadata}FORGEFLOW_METADATA +mv "$root/.forgeflow/compose.metadata.yml.pending" "$root/.forgeflow/compose.metadata.yml"` : `cat > "$root/.forgeflow/deployment-metadata.json.pending" <<'FORGEFLOW_METADATA_JSON' +${JSON.stringify({ repository: repository.fullName, environment: profile.environment, commit: targetSha, requestId })} +FORGEFLOW_METADATA_JSON +mv "$root/.forgeflow/deployment-metadata.json.pending" "$root/.forgeflow/deployment-metadata.json"`} +share_group=$(getent group users >/dev/null 2>&1 && echo users || id -gn) +chgrp "$share_group" "$root" "$root/.forgeflow" 2>/dev/null || true +chmod g+rwx "$root" "$root/.forgeflow" 2>/dev/null || true +chmod g+s "$root" "$root/.forgeflow" 2>/dev/null || true +while IFS= read -r rel; do + [ -n "$rel" ] || continue + is_preserved "$rel" && continue + chgrp "$share_group" "$root/$rel" 2>/dev/null || true + chmod u+rw,g+rw "$root/$rel" 2>/dev/null || true + parent="$root/$(dirname "$rel")" + chgrp "$share_group" "$parent" 2>/dev/null || true + chmod g+rwx,g+s "$parent" 2>/dev/null || true +done < "$new_manifest" cd "$root" ${compose} config >/dev/null -${compose} up -d --build ${flags} +actual_services=$(${compose} config --services 2>/dev/null | sed '/^$/d') +[ -n "$actual_services" ] || { echo "Compose project defines no services" >&2; exit 60; } +if [ "$(printf '%s\n' "$candidate_services" | LC_ALL=C sort)" != "$(printf '%s\n' "$actual_services" | LC_ALL=C sort)" ]; then + echo "Refusing activation because candidate and server Compose service sets differ" >&2 + exit 68 +fi +before_containers="$backup/containers.before" +hint_before_id='' +if [ -n "$tracked_container_hint" ] && docker inspect "$tracked_container_hint" >/dev/null 2>&1; then + hint_before_id=$(docker inspect -f '{{.Id}}' "$tracked_container_hint" 2>/dev/null || true) +fi +activation_started=true +${compose} up -d --no-build ${verification} +if [ -n "$hint_before_id" ] && docker inspect "$hint_before_id" >/dev/null 2>&1; then + old_hint_running=$(docker inspect -f '{{.State.Running}}' "$hint_before_id" 2>/dev/null || echo false) + [ "$old_hint_running" != true ] || { echo "Compose left the previous container $tracked_container_hint ($hint_before_id) running" >&2; exit 66; } +fi ${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) @@ -1781,69 +2528,25 @@ ${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}" +printf '%s\n' "successful" > "$backup/result" +date -u +%Y-%m-%dT%H:%M:%SZ > "$backup/completed-at" +echo "ForgeFlow safely activated push bundle $target; rollback evidence retained at $backup" `; } async executePushBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference, rollback = false }) { + const permissionReport = await this.inspectWriteAccess({ + repository, + profileId: profile.id, + }); + if (!permissionReport.ready) { + const error = new Error( + `Deployment stopped before upload because write access is missing for: ${permissionReport.blocking.map((item) => item.path).join(", ")}`, + ); + error.code = "REMOTE_WRITE_ACCESS_REQUIRED"; + error.permissionReport = permissionReport; + throw error; + } const bundle = await this.createCommitBundle(repository, targetSha, requestId); const remotePart = path.join(remotePath, ".forgeflow", "incoming", `${requestId}-${targetSha}.tar.part`); try { @@ -1856,6 +2559,59 @@ echo "ForgeFlow activated server Git commit ${targetSha}" } } + async createServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId }) { + const remote = this.serverGitRemote(repository, profile); + const repositoryId = crypto.createHash("sha256").update(String(repository.fullName).toLowerCase()).digest("hex").slice(0, 24); + const cache = path.join(server.basePath, ".forgeflow", "git-cache", `${repositoryId}.git`); + const remotePart = path.join(remotePath, ".forgeflow", "incoming", `${requestId}-${targetSha}.tar.part`); + const marker = "__FORGEFLOW_SERVER_ARCHIVE__"; + const script = ` +cache=${shellQuote(cache)} +incoming=${shellQuote(remotePart)} +remote=${shellQuote(remote)} +branch=${shellQuote(profile.branch)} +target=${shellQuote(targetSha)} +mkdir -p "$(dirname "$cache")" "$(dirname "$incoming")" +if [ ! -d "$cache" ]; then git init --bare "$cache" >/dev/null; fi +if git --git-dir="$cache" remote get-url origin >/dev/null 2>&1; then + git --git-dir="$cache" remote set-url origin "$remote" +else + git --git-dir="$cache" remote add origin "$remote" +fi +${this.serverGitEnvironment(repository, profile, server)} git --git-dir="$cache" fetch --force --prune origin "+refs/heads/$branch:refs/remotes/origin/$branch" +git --git-dir="$cache" cat-file -e "$target^{commit}" +git --git-dir="$cache" merge-base --is-ancestor "$target" "refs/remotes/origin/$branch" +archive_tmp="$incoming.$$.tmp" +git --git-dir="$cache" archive --format=tar --output="$archive_tmp" "$target" +[ -s "$archive_tmp" ] || { rm -f "$archive_tmp"; echo "Gitea produced an empty deployment archive" >&2; exit 45; } +mv "$archive_tmp" "$incoming" +digest=$(if command -v sha256sum >/dev/null 2>&1; then sha256sum "$incoming" | awk '{print $1}'; else shasum -a 256 "$incoming" | awk '{print $1}'; fi) +printf '%s\n' ${shellQuote(marker)} +printf 'digest=%s\n' "$digest" +`; + const result = await this.ssh.exec(server.id, bash(script), { timeout: 5 * 60_000, maxOutput: 512 * 1024 }); + const output = String(result.stdout || ""); + const markerIndex = output.lastIndexOf(marker); + const digest = markerIndex >= 0 + ? String(output.slice(markerIndex + marker.length).match(/(?:^|\n)digest=([0-9a-f]{64})(?:\n|$)/i)?.[1] || "").toLowerCase() + : ""; + if (!digest) throw new Error("The server did not return a valid checksum for the Gitea archive."); + return { remotePart, digest }; + } + + async executeServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference, rollback = false }) { + const permissionReport = await this.inspectWriteAccess({ repository, profileId: profile.id }); + if (!permissionReport.ready) { + const error = new Error(`Deployment stopped before the Gitea fetch because write access is missing for: ${permissionReport.blocking.map((item) => item.path).join(", ")}`); + error.code = "REMOTE_WRITE_ACCESS_REQUIRED"; + error.permissionReport = permissionReport; + throw error; + } + const bundle = await this.createServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId }); + const script = this.pushBundleScript({ repository, profile, remotePath, targetSha, requestId, remotePart: bundle.remotePart, digest: bundle.digest, metadata, generated, iconReference, rollback }); + return this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 8 * 1024 * 1024 }); + } + async deploy({ repository, profileId, sha }) { const targetSha = assertFullCommitSha(sha); const { profile, server, remotePath } = this.resolve(repository, profileId); @@ -1866,7 +2622,9 @@ echo "ForgeFlow activated server Git commit ${targetSha}" throw error; } const requestId = crypto.randomUUID(); - const mode = profile.deploymentMode || "server-git"; + const mode = ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode) + ? profile.deploymentMode + : "push-bundle"; const operation = await this.saveOperation({ id: requestId, type: "deployment", @@ -1883,8 +2641,10 @@ echo "ForgeFlow activated server Git commit ${targetSha}" logs: [ "Preflight passed.", 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.", + ? "Creating and uploading the exact committed local project directly to Unraid." + : mode === "server-git" + ? "Fetching the exact commit from Gitea with a repository-scoped read-only deploy key." + : "This workload is monitor-only and cannot be deployed.", `Deploying exact commit ${targetSha} in the background.`, ], }); @@ -1899,9 +2659,10 @@ echo "ForgeFlow activated server Git commit ${targetSha}" void (async () => { try { - 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 }); + if (mode === "monitor-only") throw new Error("This workload is monitor-only. Select Server pull or Direct copy before deploying."); + const result = mode === "server-git" + ? await this.executeServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference }) + : await this.executePushBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference }); const health = await this.checkHealth(profile.healthcheckUrl); const finalStatus = health.healthy === false ? "failed" : "success"; const completed = await this.saveOperation({ @@ -1948,7 +2709,7 @@ echo "ForgeFlow activated server Git commit ${targetSha}" ...operation, status: "failed", error: error.message, - failure: { stage: mode === "push-bundle" ? "Bundle upload / Compose activation" : "Server Git / Compose activation", message: error.message }, + failure: { stage: mode === "server-git" ? "Gitea server pull / Compose activation" : "Direct copy / 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 }); @@ -1967,8 +2728,11 @@ echo "ForgeFlow activated server Git commit ${targetSha}" 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 rollbackMode = ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode) + ? profile.deploymentMode + : "push-bundle"; + if (rollbackMode === "push-bundle" && !repository.localPath) + throw new Error("A linked local repository is required for Direct copy rollback verification."); const requestId = crypto.randomUUID(); const operation = await this.saveOperation({ id: requestId, @@ -1992,10 +2756,11 @@ echo "ForgeFlow activated server Git commit ${targetSha}" repositoryUrl: profile.cloneUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName, }); try { - 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 mode = rollbackMode; + if (mode === "monitor-only") throw new Error("This workload is monitor-only. Select Server pull or Direct copy before rolling back."); + const result = mode === "server-git" + ? await this.executeServerGitBundle({ repository, profile, server, remotePath, targetSha: target, requestId, metadata, generated, iconReference, rollback: true }) + : await this.executePushBundle({ repository, profile, server, remotePath, targetSha: target, requestId, metadata, generated, iconReference, rollback: true }); const health = await this.checkHealth(profile.healthcheckUrl); const finalStatus = health.healthy === false ? "failed" : "rolled-back"; const completed = await this.saveOperation({ @@ -2152,6 +2917,12 @@ printf 'templateExists=%s\n' "$template_exists" async applyDockerManMetadata({ repository, profileId }) { const { profile, server, remotePath } = this.resolve(repository, profileId); + if (profile.generatedCompose !== true) { + // Existing Compose files remain authoritative. Applying a generated + // labels-only service fragment can create a phantom service when a stale + // profile hint no longer matches the real Compose service keys. + return this.refreshProfileState(repository.fullName, profileId); + } const composeFile = profile.generatedCompose ? ".forgeflow/compose.forgeflow.yml" : safeRelativeRemoteFile(profile.composeFile || "docker-compose.yml"); @@ -2318,6 +3089,7 @@ module.exports = { iconReferenceLocalPath, decodeBase64Json, parseDockerManXml, + parsePermissionInspection, parseServerInventory: parseWorkloadInventory, inventoryContainerMatch: matchInventoryContainer, remoteIdentity: inventoryRemoteIdentity, diff --git a/src/renderer/app.js b/src/renderer/app.js index c5b0d4b..98b4af2 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -207,6 +207,51 @@ function selectedProfile(repository = selectedRepository()) { repository.deploymentProfiles[0] ); } + +function canDirectPushDeploy(repository, profile = selectedProfile(repository)) { + const status = repository?.localStatus; + const mode = deploymentMode(profile); + return Boolean( + repository?.localPath + && status?.head + && !status?.counts?.changed + && !status?.counts?.conflicts + && profile + && mode === "push-bundle" + && profile.branch === status.branch?.head, + ); +} + +function deploymentMode(profile) { + if (profile?.provider !== "ssh-unraid") return "gitea-actions"; + return ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode) + ? profile.deploymentMode + : "push-bundle"; +} + +function deploymentTargetSha(repository, profile = selectedProfile(repository)) { + return deploymentMode(profile) === "server-git" + ? profile?.state?.giteaSha || null + : repository?.localStatus?.head || null; +} + +function canServerGitDeploy(repository, profile = selectedProfile(repository)) { + const target = deploymentTargetSha(repository, profile); + return Boolean( + profile + && deploymentMode(profile) === "server-git" + && target + && profile.branch + && !(profile.state?.liveSha === target && profile.state?.healthy !== false), + ); +} + +function canDeploy(repository, profile = selectedProfile(repository)) { + const mode = deploymentMode(profile); + if (mode === "server-git") return canServerGitDeploy(repository, profile); + if (mode === "push-bundle") return canDirectPushDeploy(repository, profile); + return profile?.provider === "gitea-actions" && repository?.readyToDeploy; +} function operations() { return ui.boot?.state?.operations || []; } @@ -234,6 +279,13 @@ function showToast(title, message, type = "info") { setTimeout(() => toast.remove(), 5600); } +function isSshCredentialError(error) { + const code = String(error?.code || ""); + const message = String(error?.message || ""); + return ["SSH_PRIVATE_KEY_READ_FAILED", "SSH_PRIVATE_KEY_NOT_FOUND", "SSH_CONNECTION_FAILED"].includes(code) + || /private key|publickey|authentication methods failed|permission denied|authentication failed/i.test(message); +} + function setLoading(loading, message = "") { ui.loading = loading; ui.loadingMessage = message; @@ -547,14 +599,13 @@ function repositoryAction(repository) { return { kind: "error", title: "Local repository unavailable", - detail: - repository.attentionReason || "The linked folder could not be read.", + detail: repository.attentionReason || "The linked folder could not be read.", }; if (status.counts.conflicts) return { kind: "conflict", title: "Resolve merge conflicts", - detail: `${status.counts.conflicts} conflicted file${status.counts.conflicts === 1 ? "" : "s"} block synchronization.`, + detail: `${status.counts.conflicts} conflicted file${status.counts.conflicts === 1 ? "" : "s"} block deployment.`, }; if (status.counts.changed) return { @@ -562,11 +613,30 @@ function repositoryAction(repository) { title: "Commit local changes", detail: `${status.counts.changed} changed file${status.counts.changed === 1 ? "" : "s"} detected.`, }; + if (!repository.deploymentProfiles?.length) + return { + kind: "configure", + title: "Configure deployment", + detail: "Connect an Unraid server or a Gitea Actions workflow before deploying.", + }; + const profile = selectedProfile(repository); + if (profile?.branch !== status.branch.head) + return { + kind: "branch-profile", + title: "No deployment for this branch", + detail: `The selected profile accepts ${profile.branch}; you are on ${status.branch.head}.`, + }; + if (canDirectPushDeploy(repository, profile)) + return { + kind: "deploy", + title: "Ready for direct redeploy", + detail: `ForgeFlow will copy committed HEAD ${status.shortHead} directly to ${profile.environment} over the configured desktop → Unraid connection.`, + }; if (status.branch.behind && status.branch.ahead) return { kind: "diverged", title: "Branches have diverged", - detail: `Local is ${status.branch.ahead} ahead and ${status.branch.behind} behind. ForgeFlow can create a safety branch and repair this from Git tools.`, + detail: `Local is ${status.branch.ahead} ahead and ${status.branch.behind} behind.`, }; if (status.branch.behind) return { @@ -580,19 +650,6 @@ function repositoryAction(repository) { title: "Push local commits", detail: `${status.branch.ahead} commit${status.branch.ahead === 1 ? "" : "s"} ready to push.`, }; - if (!repository.deploymentProfiles?.length) - return { - kind: "configure", - title: "Configure deployment", - detail: "Connect a predefined Gitea Actions workflow before deploying.", - }; - const profile = selectedProfile(repository); - if (profile?.branch !== status.branch.head) - return { - kind: "branch-profile", - title: "No deployment for this branch", - detail: `The selected profile accepts ${profile.branch}; you are on ${status.branch.head}.`, - }; if (repository.readyToDeploy) return { kind: "deploy", @@ -920,14 +977,12 @@ 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 mode = deploymentMode(profile); + const targetSha = deploymentTargetSha(repository, profile); + const ready = canDeploy(repository, profile); const modeLabel = { - "push-bundle": "Push bundle", - "server-git": "Server-side Git", + "push-bundle": "Direct copy", + "server-git": "Server pull from Gitea", "monitor-only": "Monitor only", }[mode] || mode; const providerDetail = isSsh @@ -940,11 +995,17 @@ function renderProfileCard(repository, profile, compact = false) { 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)}` - : ""; + const syncLabel = isSsh + ? state.matchesGitea + ? `${icon("check")}Live = Gitea · ${shortSha(state.liveSha)}` + : state.liveSha && state.giteaSha + ? `Live ${shortSha(state.liveSha)} · Gitea ${shortSha(state.giteaSha)}` + : state.liveSha ? `${icon("check")}Live · ${shortSha(state.liveSha)}` : "" + : state.matchesGitea + ? `${icon("check")}Live = Gitea · ${shortSha(state.liveSha)}` + : state.giteaSha && state.liveSha + ? `Live ${shortSha(state.liveSha)} · Gitea ${shortSha(state.giteaSha)}` + : ""; const dockerManLabel = managesDockerMan ? dockerManReady ? templateReady @@ -952,7 +1013,13 @@ function renderProfileCard(repository, profile, compact = false) { : "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 ? `` : ""}
`; + const sourceLabel = isSsh + ? mode === "server-git" ? `Gitea ${state.giteaSha ? shortSha(state.giteaSha) : "refresh required"}` : "Committed local HEAD" + : state.giteaSha ? shortSha(state.giteaSha) : "Refresh to compare"; + const serverAccessAction = isSsh && mode === "server-git" + ? `` + : ""; + 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}
${isSsh ? `` : ""}${serverAccessAction}${webUi ? `` : ""}${managesDockerMan ? `` : ""}${ready ? `` : ""}${state.previousSha && rollbackConfigured ? `` : ""}
`; } function renderRepositoryDeployments(repository) { const profiles = repository.deploymentProfiles || []; @@ -1106,27 +1173,54 @@ function renderActionPanel(repository) { 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 configuredServers = ui.boot?.state?.servers || []; + const visibleForServer = (server) => (server.workloads || []).filter((workload) => + workload.link || (workload.runtime?.running && workload.status !== "unmatched"), + ); + const reviewCount = servers.reduce((total, server) => total + visibleForServer(server).filter((workload) => !workload.link).length, 0); + const serverCards = 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.
`; + const capabilityText = [ + capabilities.docker ? "Docker" : "Docker missing", + capabilities.compose ? "Compose" : "Compose missing", + capabilities.git ? "Git available" : "Git optional", + capabilities.tar && capabilities.checksum ? "Push ready" : "Push tools incomplete", + ].join(" · "); + const errorBlock = server.error + ? `
${icon("error")}
Server scan failed

${escapeHtml(server.error)}

` + : ""; + const warnings = (server.warnings || []).map((warning) => `
${icon("warning")}${escapeHtml(warning)}
`).join(""); + const visibleWorkloads = visibleForServer(server); + const hiddenCount = Math.max(0, (server.workloads || []).length - visibleWorkloads.length); + const workloads = visibleWorkloads.length + ? visibleWorkloads.map((workload) => { + const containers = (workload.containers || []).map((container) => container.name).filter(Boolean).join(", "); + const topCandidate = workload.candidates?.[0]; + const linked = workload.status === "linked" || Boolean(workload.link); + const statusTone = linked ? "success" : workload.status === "ambiguous" ? "danger" : "warning"; + const detail = workload.compose?.project + ? `Compose ${workload.compose.project} · ${(workload.compose.services || []).join(", ") || "services unknown"}` + : workload.dockerMan?.templatePath + ? `DockerMan ${workload.dockerMan.name || workload.displayName} · ${containers || "template only"}` + : `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"; + const canQuickLink = !linked && topCandidate && ["exact", "strong"].includes(topCandidate.confidence) && Boolean(workload.remoteFolderCandidate); + const linkButton = canQuickLink + ? `` + : ``; + return `
${escapeHtml(workload.displayName)}${escapeHtml(detail)} · ${workload.runtime?.running ? "running" : "stopped"}${escapeHtml(candidate)}${workload.metadata?.composeDefinitionError ? `Compose file found; validation warning: ${escapeHtml(workload.metadata.composeDefinitionError)}` : ""}
${escapeHtml(linked ? "Linked" : workload.status || "Review")}${linked ? `` : linkButton}
`; + }).join("") + : `

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

`; + return `

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

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

Server inventory has not completed

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

` + : `

No Unraid server configured

Add the server with password authentication and ForgeFlow can copy and deploy projects directly.

`; + return `

Server inventory

Live Docker, Compose and DockerMan discovery, linked to Gitea
${servers.length ? `
${serverCards}
` : empty}
${icon("shield")}Server pull fetches an exact Gitea commit through a repository-scoped read-only deploy key, validates Compose and only then promotes the release. Direct copy remains an explicit fallback.
`; } function renderDeployments() { @@ -1140,7 +1234,7 @@ function renderDeployments() { 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.

'}
`; + 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

Stable Compose identity, live container health and exact Gitea commit parity
${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; @@ -1177,7 +1271,7 @@ function renderPreflightChecks( ) { if (!report?.checks?.length) return `

${escapeHtml(emptyMessage)}

`; - return `
${report.checks.map((item) => `
${item.status === "pass" ? icon("check") : item.status === "fail" ? icon("error") : icon("warning")}
${escapeHtml(item.label)}${escapeHtml(item.detail)}${item.help ? `${escapeHtml(item.help)}` : ""}
${escapeHtml(item.status)}
`).join("")}
`; + return `
${report.checks.map((item) => `
${item.status === "pass" ? icon("check") : item.status === "fail" ? icon("error") : icon("warning")}
${escapeHtml(item.label)}${escapeHtml(item.detail)}${item.help ? `${escapeHtml(item.help)}` : ""}${item.repairAction ? `` : ""}
${escapeHtml(item.status)}
`).join("")}
`; } function renderDiagnostics() { @@ -1267,6 +1361,12 @@ function renderModal() { ui.repositories.find( (repo) => repo.fullName === ui.modal.repositoryFullName, ); + if (ui.modal.type === "server-password") { + const server = (ui.boot?.state?.servers || []).find((item) => item.id === ui.modal.serverId); + if (!server) return ``; + const retryText = ui.modal.retry?.type === "deploy" ? "Save password & redeploy" : "Save password & rescan"; + return ``; + } if (ui.modal.type === "workload-link") { const serverResult = (ui.serverDiscovery || []).find( (item) => item.serverId === ui.modal.serverId, @@ -1304,7 +1404,7 @@ function renderModal() { .map((container) => container.name) .filter(Boolean) .join(", "); - return ``; + return ``; } if (ui.modal.type === "deployment-config") { const storedProfile = @@ -1330,13 +1430,11 @@ function renderModal() { ? `
-
Push bundle uses the desktop → Unraid key and does not require Gitea credentials on Unraid.
-
- +
Server pull uses an automatically managed repository-scoped read-only deploy key. Direct copy remains available as a fallback and never requires Gitea credentials on Unraid.
Kept stable to update the existing containers instead of creating duplicates.
-
Comma-separated, in the same order used by the existing deployment. ForgeFlow adds its metadata overlay last.
-
Compose service (internal) keys, comma-separated. All listed services must be running after deployment.
Used as an inventory hint; adopted Compose identity remains authoritative.
+
Comma-separated, in the same order used by the existing deployment. These real server Compose files remain authoritative; ForgeFlow does not inject a synthetic service overlay.
+
Discovery hints only. At deployment time ForgeFlow reads the actual service keys from docker compose config and verifies every active service.
Used as an inventory hint; adopted Compose identity remains authoritative.
@@ -1345,15 +1443,15 @@ function renderModal() {
Push bundle never replaces these paths and only removes files previously managed by ForgeFlow.
- - + + ` : `
` - }
${icon("shield")}${ssh ? "Push bundle is the safe default: ForgeFlow archives the exact local commit, uploads it over pinned SSH, validates Compose, verifies every selected service and only then promotes the live release. Server-side Git remains optional." : "ForgeFlow sends only controlled workflow inputs: environment, exact SHA and a unique request ID."}
`; + }
${icon("shield")}${ssh ? "Server pull fetches the exact selected Gitea commit with a repository-scoped read-only key, validates Compose and services, then promotes atomically with rollback protection." : "ForgeFlow sends only controlled workflow inputs: environment, exact SHA and a unique request ID."}
`; } if (ui.modal.type === "deployment-preflight") { const profile = @@ -1368,7 +1466,8 @@ function renderModal() { repository?.deploymentProfiles?.find( (item) => item.id === ui.modal.profileId, ) || selectedProfile(repository); - return ``; + const targetSha = deploymentTargetSha(repository, profile); + return ``; } if (ui.modal.type === "rollback-confirm") { const profile = repository?.deploymentProfiles?.find( @@ -1382,8 +1481,8 @@ function renderModal() { (ui.boot.state.servers || []).find( (item) => item.id === ui.modal.serverId, ) || {}; - const authType = ui.modal.authType || server.authType || "privateKey"; - return ``; + const authType = ui.modal.authType || server.authType || "password"; + return ``; } if (ui.modal.type === "hunk-staging") { const hunks = ui.diffHunks?.hunks || []; @@ -1455,11 +1554,11 @@ function paletteCommands() { { id: "deploy-selected", label: "Deploy selected repository", - detail: repository?.readyToDeploy - ? `${repository.name} ${repository.localStatus.shortHead}` + detail: canDeploy(repository) + ? `${repository.name} ${shortSha(deploymentTargetSha(repository))}` : "Not ready", icon: "rocket", - enabled: Boolean(repository?.readyToDeploy), + enabled: canDeploy(repository), }, ]; } @@ -1577,6 +1676,11 @@ async function executeDeployment(profileId) { repository?.deploymentProfiles?.find((item) => item.id === profileId) || selectedProfile(repository); if (!repository || !profile) return; + const targetSha = deploymentTargetSha(repository, profile); + if (!targetSha) { + showToast("Refresh required", "Refresh Gitea and server truth before deploying this environment.", "error"); + return; + } const deploymentOptions = { note: document.querySelector("#deployment-note")?.value.trim() || "", override: document.querySelector("#deployment-override")?.checked === true, @@ -1594,19 +1698,33 @@ async function executeDeployment(profileId) { ui.activeDeployment = await window.forgeflow.deploy( repository, profile.id, - repository.localStatus.head, + targetSha, deploymentOptions, ); updateOperationInState(ui.activeDeployment); ui.currentView = "deployment-run"; showToast( "Deployment started", - `${repository.name} ${repository.localStatus.shortHead} → ${profile.environment}`, + `${repository.name} ${shortSha(targetSha)} → ${profile.environment}`, "success", ); startOperationPolling(); } catch (error) { - showToast("Deployment failed to start", error.message, "error"); + if (profile.provider === "ssh-unraid" && isSshCredentialError(error)) { + ui.modal = { + type: "server-password", + serverId: profile.serverId, + retry: { + type: "deploy", + repositoryFullName: repository.fullName, + profileId: profile.id, + }, + }; + showToast("SSH key rejected", "Enter the Unraid server password once; ForgeFlow will retry the direct desktop → Unraid connection.", "error"); + render(); + } else { + showToast("Deployment failed to start", error.message, "error"); + } } setLoading(false); } @@ -1742,6 +1860,11 @@ app.addEventListener("click", async (event) => { ui.currentView = target.dataset.view; ui.modal = null; render(); + if (ui.currentView === "deployments" && (ui.boot?.state?.servers || []).length && !(ui.serverDiscovery || []).length) { + setLoading(true, "Reading Docker, Compose and DockerMan inventory from Unraid…"); + await refreshDeploymentTruth(true); + setLoading(false); + } } else if (action === "select-repo") selectRepository(target.dataset.id); else if (action === "refresh") { await refreshRepositories(true); @@ -2220,15 +2343,60 @@ app.addEventListener("click", async (event) => { (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", - ); + const failures = (ui.serverDiscovery || []).filter((item) => item.error); + if (failures.length) { + showToast( + "Server scan failed", + failures.map((item) => `${item.serverName || item.serverId}: ${item.error}`).join(" · "), + "error", + ); + } else { + 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 === "quick-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, + ); + const linkedRepository = ui.repositories.find( + (item) => item.fullName === target.dataset.repository, + ); + if (!workload || !linkedRepository || !workload.remoteFolderCandidate) { + showToast("Automatic link unavailable", "Scan the server again and use Review & link.", "error"); + return; + } + setLoading(true, `Linking ${workload.displayName} to ${linkedRepository.fullName}…`); + try { + const result = await window.forgeflow.linkServerWorkload( + linkedRepository, + target.dataset.serverId, + target.dataset.workloadId, + "server-git", + workload.remoteFolderCandidate, + ); + if (result.state) ui.boot.state = result.state; + ui.selectedProfileId = result.profile?.id || null; + await refreshRepositories(false, true); + await refreshDeploymentTruth(false); + showToast( + "Deployment linked", + `${linkedRepository.fullName} is linked to ${workload.compose?.workingDir || workload.remoteFolderCandidate}. Compose values were read from the server.`, + "success", + ); + } catch (error) { + showToast("Could not link deployment", error.message, "error"); + } + setLoading(false); } else if (action === "link-server-workload") { const serverResult = (ui.serverDiscovery || []).find( (item) => item.serverId === target.dataset.serverId, @@ -2260,9 +2428,7 @@ app.addEventListener("click", async (event) => { const repositoryFullName = document .querySelector("#workload-repository") ?.value.trim(); - const deploymentMode = - document.querySelector("#workload-deployment-mode")?.value || - "push-bundle"; + const deploymentMode = document.querySelector("#workload-deployment-mode")?.value || "server-git"; const remoteFolder = document .querySelector("#workload-remote-folder") ?.value.trim(); @@ -2272,7 +2438,7 @@ app.addEventListener("click", async (event) => { if (!linkedRepository) { showToast( "Choose a repository", - "The workload must be linked to a Gitea repository.", + "The workload must be linked to a ForgeFlow project.", "error", ); return; @@ -2293,7 +2459,7 @@ app.addEventListener("click", async (event) => { 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"}.`, + `${linkedRepository.fullName} now uses direct desktop-to-Unraid copy and the Compose configuration detected on the server.`, "success", ); } catch (error) { @@ -2446,12 +2612,11 @@ 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, + deploymentMode: ["server-git", "push-bundle", "monitor-only"].includes( + document.querySelector("#profile-deployment-mode")?.value, + ) ? document.querySelector("#profile-deployment-mode").value : "server-git", + cloneUrl: previousProfile.cloneUrl || "", + alignRemote: false, generatedCompose: document.querySelector("#profile-generated-compose").value === "true", @@ -2491,12 +2656,8 @@ app.addEventListener("click", async (event) => { manageDockerMan: document.querySelector("#profile-manage-dockerman")?.checked === true, - forceRecreate: - document.querySelector("#profile-force-recreate")?.checked === - true, - removeOrphans: - document.querySelector("#profile-remove-orphans")?.checked === - true, + forceRecreate: false, + removeOrphans: false, adoptedFromServer: Boolean( ui.deploymentDiscovery || previousProfile.adoptedFromServer, ), @@ -2578,6 +2739,57 @@ app.addEventListener("click", async (event) => { } else if (action === "run-deployment-preflight") { if (!repository) repository = profileRepository(target.dataset.profileId); await runDeploymentPreflight(repository, target.dataset.profileId); + } else if (action === "configure-server-git-access") { + const profileId = target.dataset.profileId || ui.selectedProfileId; + if (!repository) repository = profileRepository(profileId); + if (!repository || !profileId) return; + const approved = confirm( + `Configure read-only Gitea access for ${repository.fullName}?\n\nForgeFlow creates a dedicated SSH deploy key on the selected server, adds only its public key to this Gitea repository and pins the observed Gitea SSH host key. The private key never leaves the server.`, + ); + if (!approved) return; + setLoading(true, "Configuring repository-scoped Gitea access…"); + try { + const result = await window.forgeflow.configureServerGitAccess(repository, profileId); + if (result.state) ui.boot.state = result.state; + await refreshRepositories(false, true); + await refreshDeploymentTruth(false); + showToast( + "Server pull ready", + `Read-only Gitea access verified at ${shortSha(result.remoteSha)}.`, + "success", + ); + await runDeploymentPreflight(repository, profileId, { showModal: true }); + } catch (error) { + showToast("Could not configure Gitea access", error.message, "error"); + } finally { + setLoading(false); + } + } else if (action === "repair-deployment-write-access") { + const profileId = target.dataset.profileId || ui.selectedProfileId; + if (!repository) repository = profileRepository(profileId); + if (!repository || !profileId) return; + const profile = repository.deploymentProfiles?.find((item) => item.id === profileId); + const approved = confirm( + `Repair write access for ${repository.fullName} on ${profile?.name || profile?.environment || "the linked Unraid deployment"}?\n\nForgeFlow will only adjust the linked project source tree and its .forgeflow state folders. Preserved runtime paths such as appdata, data, config and logs are excluded. No container will be stopped, removed or recreated.`, + ); + if (!approved) return; + setLoading(true, "Repairing scoped Unraid write access…"); + try { + const result = await window.forgeflow.repairDeploymentWriteAccess( + repository, + profileId, + ); + showToast( + "Write access normalized", + "Project source and ForgeFlow upload folders now use safe shared write permissions. Preserved runtime data was not changed.", + "success", + ); + await runDeploymentPreflight(repository, profileId, { showModal: true }); + } catch (error) { + showToast("Write-access repair failed", error.message, "error"); + } finally { + setLoading(false); + } } else if (action === "deploy-profile") { if (repository && String(repository.id) !== String(ui.selectedRepoId)) selectRepository(repository.id, false); @@ -2892,11 +3104,49 @@ app.addEventListener("click", async (event) => { showToast("Could not launch update", error.message, "error"); setLoading(false); } + } else if (action === "use-server-password") { + ui.modal = { + type: "server-password", + serverId: target.dataset.serverId, + retry: { type: target.dataset.retry || "scan" }, + }; + render(); + } else if (action === "confirm-server-password") { + const server = (ui.boot?.state?.servers || []).find((item) => item.id === target.dataset.serverId); + const password = document.querySelector("#quick-server-password")?.value || ""; + if (!server || !password) { + showToast("Password required", "Enter the Unraid SSH password.", "error"); + return; + } + const retry = ui.modal?.retry || { type: "scan" }; + setLoading(true, "Switching the server connection to password authentication…"); + try { + const saved = await window.forgeflow.saveServer( + { ...server, authType: "password", privateKeyPath: "" }, + password, + "", + ); + ui.boot.state = saved.state; + const tested = await window.forgeflow.testServer(server.id); + ui.boot.state = tested.state; + ui.modal = null; + showToast("Server password saved", "ForgeFlow will no longer use an SSH key for this server.", "success"); + if (retry.type === "deploy") { + const retryRepository = ui.repositories.find((item) => item.fullName === retry.repositoryFullName); + if (retryRepository) ui.selectedRepoId = retryRepository.id; + await executeDeployment(retry.profileId); + } else { + await refreshDeploymentTruth(true); + } + } catch (error) { + showToast("Server authentication failed", error.message, "error"); + } + setLoading(false); } else if (action === "open-add-server") { ui.modal = { type: "server-config", serverId: null, - authType: "privateKey", + authType: "password", }; render(); } else if (action === "edit-server") { @@ -2906,7 +3156,7 @@ app.addEventListener("click", async (event) => { ui.modal = { type: "server-config", serverId: target.dataset.serverId, - authType: server?.authType || "privateKey", + authType: server?.authType || "password", }; render(); } else if (action === "select-private-key") { @@ -2966,7 +3216,7 @@ app.addEventListener("click", async (event) => { capabilities.docker && capabilities.dockerReady && capabilities.compose; showToast( 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)"}.`, + `${result.server.name} presented ${result.fingerprint}. Docker ${capabilities.dockerReady ? "ready" : "unavailable"}; Compose ${capabilities.compose ? "ready" : "missing"}.`, deploymentReady ? "success" : "info", ); } catch (error) { @@ -3499,7 +3749,7 @@ Force repair after you have closed all Git tools for this repository?`) await window.forgeflow.openPath(repository.localPath); else if (command === "git-tools" && repository) await loadGitTools(repository); - else if (command === "deploy-selected" && repository?.readyToDeploy) { + else if (command === "deploy-selected" && canDeploy(repository)) { const profile = selectedProfile(repository); if (profile) await runDeploymentPreflight(repository, profile.id); } diff --git a/src/renderer/index.html b/src/renderer/index.html index 6e27fdd..4f876c6 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -5,6 +5,7 @@ ForgeFlow + diff --git a/src/renderer/mock-bridge.js b/src/renderer/mock-bridge.js index dab06fd..bfab083 100644 --- a/src/renderer/mock-bridge.js +++ b/src/renderer/mock-bridge.js @@ -112,6 +112,24 @@ }, }); + const sshProfile = (id, name, environment, options = {}) => ({ + id, name, environment, provider: "ssh-unraid", branch: options.branch || "main", + serverId: "demo-unraid", remoteFolder: options.remoteFolder || name, + deploymentMode: "server-git", composeFiles: ["compose.yml"], + composeProject: options.composeProject || String(options.remoteFolder || name).toLowerCase(), + composeServices: options.composeServices || [String(options.remoteFolder || name).toLowerCase()], + containerName: options.containerName || options.remoteFolder || name, + generatedCompose: false, adoptedFromServer: true, serverSourceOfTruth: true, + confirmationRequired: true, + serverGitAccess: { configured: options.accessConfigured !== false, keyFingerprint: "SHA256:demo", hostFingerprint: "SHA256:gitea", configuredAt: iso(-3600000) }, + state: { + liveSha: options.liveSha || null, giteaSha: options.giteaSha || options.liveSha || null, + previousSha: options.previousSha || null, healthy: options.healthy ?? true, + containerRunning: true, runtimeVerification: "verified", matchesGitea: options.matchesGitea ?? true, + checkedAt: iso(-120000), dockerMan: { templateExists: true, webUi: true, icon: true }, + }, + }); + const now = iso(); const defaultPreferences = { autoRefresh: true, @@ -360,10 +378,14 @@ }), linkState: "linked", deploymentProfiles: [ - profile("profile-portfolio", "Production", "production", { + sshProfile("profile-portfolio", "Production", "production", { + remoteFolder: "Portfolio", + containerName: "Portfolio", liveSha: "4c20dd11bb6147fc8b6633d2b08500c93402a719", + giteaSha: "a7f2e1c1bb6147fc8b6633d2b08500c93402a719", previousSha: "31adfe11bb6147fc8b6633d2b08500c93402a719", healthy: false, + matchesGitea: false, }), ], }, @@ -564,7 +586,7 @@ await wait(80); snapshot(); return { - appVersion: "0.9.0-demo", + appVersion: "0.10.0-demo", platform: "win32", state: clone(state), git: { available: true, version: "git version 2.47.3" }, @@ -1489,7 +1511,7 @@ }, ]; }, - async linkServerWorkload(repository, serverId, workloadId, deploymentMode = "push-bundle", remoteFolder = "") { + async linkServerWorkload(repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "") { await wait(120); const repo = repositories.find((item) => item.fullName === repository.fullName); if (!repo) throw new Error("Repository not found."); @@ -1533,6 +1555,15 @@ syncState(); return { profile: clone(saved), state: clone(state) }; }, + async configureServerGitAccess(repository, profileId) { + const repo = repositories.find((item) => item.fullName === repository.fullName); + const target = repo?.deploymentProfiles.find((item) => item.id === profileId); + if (!target) throw new Error("Deployment profile not found."); + target.deploymentMode = "server-git"; + target.serverGitAccess = { configured: true, keyFingerprint: "SHA256:demo", hostFingerprint: "SHA256:gitea", configuredAt: iso() }; + syncState(); + return { profile: clone(target), created: true, remoteSha: target.state?.giteaSha || repo.localStatus?.head }; + }, async refreshOperations(operationId = null) { await wait(300); if (operationId) { diff --git a/src/renderer/styles.css b/src/renderer/styles.css index bef654c..5e20659 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -2612,6 +2612,27 @@ kbd { color: var(--text-faint); font: 10px var(--font-mono); } +.server-inventory-panel { + position: relative; + overflow: hidden; + border-color: color-mix(in srgb, var(--primary) 22%, var(--line)); + background: + radial-gradient(circle at 94% 0%, color-mix(in srgb, var(--primary) 10%, transparent), transparent 34%), + var(--surface-1); +} +.server-inventory-panel::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: linear-gradient(180deg, var(--primary), var(--success)); +} +.server-inventory-panel .tool-row { + transition: background 150ms ease, transform 150ms ease; +} +.server-inventory-panel .tool-row:hover { + transform: translateX(2px); +} .deploy-card-header h3 { margin: 4px 0 3px; font-size: 14px; @@ -2915,6 +2936,10 @@ kbd { .preflight-row small { color: var(--text-muted); } +.preflight-row .compact-button { + width: fit-content; + margin-top: 9px; +} .preflight-state { width: 25px; height: 25px; diff --git a/src/shared/shell-verification.cjs b/src/shared/shell-verification.cjs index 72ba98c..b9e8b0c 100644 --- a/src/shared/shell-verification.cjs +++ b/src/shared/shell-verification.cjs @@ -70,7 +70,7 @@ function validateShellScriptStructure(scriptText) { 'flock -n 9', 'git -C "$APP_DIR" fetch', 'git -C "$APP_DIR" reset --hard "$SHA"', - 'docker compose -f "$COMPOSE_FILE" up -d --build --remove-orphans', + 'docker compose -f "$COMPOSE_FILE" up -d --build', 'write_status "healthy"', 'write_status "unhealthy"' ]) { diff --git a/tests/git-validator.test.mjs b/tests/git-validator.test.mjs index 24fc9e8..1763ea8 100644 --- a/tests/git-validator.test.mjs +++ b/tests/git-validator.test.mjs @@ -99,3 +99,26 @@ test("Git Validator recognizes remote aliases and secret-shaped tracked paths", assert.equal(isSensitiveTrackedPath("config/private.pem"), true); assert.equal(isSensitiveTrackedPath(".env.example"), false); }); + +test("Git Validator reports reproducibility, CI and editor hygiene and creates reviewable defaults", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "forgeflow-hygiene-")); + t.after(() => rm(root, { recursive: true, force: true })); + await git(["init", "-b", "main"], root); + await git(["config", "user.name", "ForgeFlow Test"], root); + await git(["config", "user.email", "forgeflow@example.invalid"], root); + await git(["remote", "add", "origin", "https://gitea.example.test/jens/app.git"], root); + await writeFile(path.join(root, "package.json"), '{"name":"app"}\n', "utf8"); + await writeFile(path.join(root, "README.md"), "# App\n", "utf8"); + await writeFile(path.join(root, ".gitignore"), ".env\n", "utf8"); + await git(["add", "."], root); + await git(["commit", "-m", "Initial"], root); + const validator = new GitValidatorService({ git: new GitService(), gitea: { getBranchProtection: async () => ({ protected: true, enableForcePush: false }) } }); + const repository = { fullName: "jens/app", name: "app", owner: { login: "jens" }, defaultBranch: "main", localPath: root, cloneUrl: "https://gitea.example.test/jens/app.git" }; + const report = await validator.scan(repository); + assert.equal(report.checks.find((check) => check.id === "dependency-lock").status, "warning"); + assert.equal(report.checks.find((check) => check.id === "continuous-integration").status, "warning"); + for (const action of ["add-gitattributes", "add-editorconfig"]) + await validator.repair(repository, { fixAction: action }); + assert.match(await readFile(path.join(root, ".gitattributes"), "utf8"), /text=auto/); + assert.match(await readFile(path.join(root, ".editorconfig"), "utf8"), /root = true/); +}); diff --git a/tests/gitea-actions.test.mjs b/tests/gitea-actions.test.mjs index e05e414..f1be095 100644 --- a/tests/gitea-actions.test.mjs +++ b/tests/gitea-actions.test.mjs @@ -170,3 +170,27 @@ test('creates conservative default branch protection rules', async () => { assert.equal(request.options.body.enable_force_push, false); assert.equal(request.options.body.rule_name, 'main'); }); + +test('creates repository-scoped read-only deploy keys and reuses only safe matches', async () => { + const service = new GiteaService(makeStore()); + const publicKey = `ssh-ed25519 ${Buffer.from('public-key-material').toString('base64')} forgeflow:test`; + const requests = []; + service.request = async (pathname, options = {}) => { + requests.push({ pathname, options }); + if (!options.method) return { data: [] }; + return { data: { id: 41, key: publicKey, read_only: true } }; + }; + const created = await service.ensureReadOnlyDeployKey({ owner: 'jens', repo: 'app', title: 'ForgeFlow', publicKey }); + assert.equal(created.created, true); + assert.equal(requests[1].options.body.read_only, true); + + service.request = async () => ({ data: [{ id: 41, key: publicKey, read_only: true }] }); + const reused = await service.ensureReadOnlyDeployKey({ owner: 'jens', repo: 'app', title: 'ForgeFlow', publicKey }); + assert.equal(reused.created, false); + + service.request = async () => ({ data: [{ id: 41, key: publicKey, read_only: false }] }); + await assert.rejects( + () => service.ensureReadOnlyDeployKey({ owner: 'jens', repo: 'app', title: 'ForgeFlow', publicKey }), + (error) => error.code === 'DEPLOY_KEY_NOT_READ_ONLY', + ); +}); diff --git a/tests/renderer-workflow.test.mjs b/tests/renderer-workflow.test.mjs index 2511728..5d4c875 100644 --- a/tests/renderer-workflow.test.mjs +++ b/tests/renderer-workflow.test.mjs @@ -108,7 +108,7 @@ test("SSH deployments are polled in the background and Portfolio casing is prese assert.match(renderer, /function startOperationPolling\(\)/); assert.match(renderer, /startOperationPolling\(\);/); assert.match(renderer, /Visible container name/); - assert.match(renderer, /Compose service \(internal\)/); + assert.match(renderer, /Compose services to verify/); }); test("deployment profiles expose built-in/uploaded DockerMan icons and automatic metadata repair", async () => { diff --git a/tests/unraid-deployment.test.mjs b/tests/unraid-deployment.test.mjs index 96efe64..69dcee0 100644 --- a/tests/unraid-deployment.test.mjs +++ b/tests/unraid-deployment.test.mjs @@ -16,7 +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"); +const { buildWorkloadInventory, deploymentRootCandidate } = require("../src/main/server-inventory.cjs"); test("Unraid remote paths cannot escape appdata project folder", () => { assert.equal(safeRemoteFolder("lumaops"), "lumaops"); @@ -28,6 +28,12 @@ test("Unraid remote paths cannot escape appdata project folder", () => { assert.throws(() => safeRelativeRemoteFile("../../etc/passwd")); }); +test("server release directories resolve to the stable deployment root", () => { + assert.equal(deploymentRootCandidate("infrabinder/releases/f8b0dd8"), "infrabinder"); + assert.equal(deploymentRootCandidate("portfolio/.forgeflow/releases/abc/source"), "portfolio"); + assert.equal(deploymentRootCandidate("ludarium/source/deploy"), "ludarium/source/deploy"); +}); + test("server inspection key-value payload is decoded safely", () => { const b64 = (value) => Buffer.from(value).toString("base64"); const parsed = parseInspection( @@ -41,6 +47,39 @@ test("server inspection key-value payload is decoded safely", () => { assert.deepEqual(parsed.existingPreservePaths, ["data", "logs"]); }); +test("server pull provisions a pinned repository-scoped key and records access metadata", async () => { + const publicKey = `ssh-ed25519 ${Buffer.from("server-public-key").toString("base64")} forgeflow:test`; + const profile = { + id: "profile-1", provider: "ssh-unraid", serverId: "unraid", remoteFolder: "portfolio", + branch: "main", deploymentMode: "server-git", composeFiles: ["compose.yml"], composeServices: ["portfolio"], + }; + let saved = null; + let deployKeyRequest = null; + let calls = 0; + const service = new UnraidDeploymentService({ + store: { + getDeploymentProfile: () => profile, + getServer: () => ({ id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata" }), + saveDeploymentProfile: async (_fullName, value) => { saved = value; return value; }, + }, + ssh: { exec: async () => { + calls += 1; + if (calls === 1) return { stdout: `__FORGEFLOW_DEPLOY_KEY__\npublicKey=${Buffer.from(publicKey).toString("base64")}\nfingerprint=SHA256:key\nhostFingerprint=SHA256:host\n` }; + return { stdout: `${"a".repeat(40)}\trefs/heads/main\n` }; + } }, + gitea: { ensureReadOnlyDeployKey: async (request) => { deployKeyRequest = request; return { id: 17, created: true }; } }, + }); + const result = await service.configureServerGitAccess({ + repository: { fullName: "Jens/Portfolio", sshUrl: "git@gitea.example.test:Jens/Portfolio.git" }, + profileId: profile.id, + }); + assert.equal(deployKeyRequest.owner, "Jens"); + assert.equal(deployKeyRequest.repo, "Portfolio"); + assert.equal(saved.deploymentMode, "server-git"); + assert.equal(saved.serverGitAccess.hostFingerprint, "SHA256:host"); + assert.equal(result.remoteSha, "a".repeat(40)); +}); + test("server workload inventory links running containers to exact Gitea checkouts", () => { const b64 = (value) => Buffer.from(value).toString("base64"); const inspect = JSON.stringify([ @@ -458,6 +497,7 @@ test("successful SSH rollback records the formerly live SHA as the new rollback status: null, latencyMs: null, }); + service.executePushBundle = async () => ({ stdout: "rollback activated", stderr: "", exitCode: 0 }); const result = await service.rollback({ repository: { fullName: "Jens/LumaOps", @@ -539,6 +579,7 @@ test("SSH deployment dispatch returns a running operation while the remote build status: null, latencyMs: null, }); + service.executePushBundle = async () => new Promise((resolve) => { resolveRemote = resolve; }); const operation = await service.deploy({ repository: { fullName: "Jens/Portfolio", name: "Portfolio" }, @@ -566,7 +607,7 @@ test("Unraid preflight verifies repository access before a deployment can start" ); assert.match(source, /server-git-access/); assert.match(source, /git ls-remote --exit-code/); - assert.match(source, /Unraid → Gitea access/); + assert.match(source, /Unraid → Gitea read access/); }); test("DockerMan metadata uses dockerman labels, a template WebUI and lowercase-safe service/image names", () => { @@ -767,7 +808,7 @@ test("DockerMan metadata repair refreshes known Unraid icon caches after contain assert.match(source, /dynamix\.docker\.manager\/images/); assert.match(source, /-icon\.png/); assert.match(source, /cp \${shellQuote\(localIconPath\)}/); - assert.match(source, /--force-recreate/); + assert.match(source, /never adds destructive recreation or orphan-removal flags/); }); test("stuck deployment is cleared as superseded when a different healthy commit is already live", async () => { @@ -962,7 +1003,7 @@ test("linked Compose deployments retain the existing project, files and service 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.doesNotMatch(invocation, /compose\.metadata\.yml/); assert.deepEqual(service.deploymentServices(profile, repository), ["api", "worker"]); }); @@ -993,10 +1034,12 @@ test("push bundle activation validates Compose and services before promoting cur iconReference: "", }); const configIndex = script.indexOf("config >/dev/null"); - const upIndex = script.indexOf("up -d --build"); + const buildIndex = script.indexOf("build"); + const upIndex = script.indexOf("up -d --no-build", buildIndex); 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(configIndex >= 0 && configIndex < buildIndex); + assert.ok(buildIndex < upIndex); assert.ok(upIndex < serviceCheckIndex); assert.ok(serviceCheckIndex < promoteIndex); assert.match(script, /mmin \+120/); @@ -1113,6 +1156,7 @@ test("push bundle preflight does not require Git or Gitea credentials on Unraid" }, sourcePath: new URL("..", import.meta.url).pathname, }); + service.inspectWriteAccess = async () => ({ ready: true, blocking: [], targets: [], identity: { user: "root" } }); const result = await service.preflight({ repository: { fullName: "Jens/OmniRoute", @@ -1124,8 +1168,8 @@ test("push bundle preflight does not require Git or Gitea credentials on Unraid" 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.find((item) => item.id === "transfer-path")?.status, "pass"); + assert.match(result.checks.find((item) => item.id === "transfer-path")?.detail || "", /No Gitea credential/); assert.equal(result.checks.some((item) => item.id === "server-git-command"), false); - assert.equal(result.summary.ready, true); + assert.equal(result.summary.ready, true, JSON.stringify(result.checks.filter((item) => item.status === "fail"))); });