mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 00:47:52 +00:00
feat(git-sources): link stacks to Git repositories with diff-and-apply workflow (#600)
* feat(git-sources): link stacks to Git repositories with diff-and-apply workflow
Add Git Sources so any stack can point at an HTTPS Git repository, branch, and
compose file path. Pulls fetch + validate the incoming commit, store a
diffable pending snapshot, and apply writes only after explicit confirmation
(or automatically, per the configured apply mode). Sibling .env sync is
optional. Works on the Community tier.
Apply modes:
- Review only: mark pending, wait for manual apply in the diff dialog
- Auto-write: write compose + env to disk, do not redeploy
- Auto-deploy: write files and run docker compose up -d
Webhook integration: webhooks can target the new "git-pull" action to trigger
a sync from CI. Per-source debounce prevents runaway pipelines from hammering
the repository host. Tokens are encrypted at rest and never returned to the
frontend.
Docs and tests included. Screenshots and Playwright E2E flows to follow.
* fix(git-sources): drop unnecessary useMemo on commit sha slice
React Compiler's lint rule rejected the manual dependency list because the
inferred dep ('pull') was less specific than the written one ('pull?.commitSha').
The computation is a cheap 7-char slice, so drop the useMemo entirely rather
than fight the rule.
* test(git-sources): add Playwright E2E flows and drop orphan source rows on stack delete
- E2E coverage: non-HTTPS URL rejected client-side, unreachable repo surfaces
a toast error on save, and configure+remove walks the AlertDialog confirm path.
- Deleting a stack now also drops its linked Git source row so a future stack
with the same name starts clean rather than inheriting a stale config.
This commit is contained in:
Generated
+364
-1
@@ -32,6 +32,7 @@
|
||||
"helmet": "^8.1.0",
|
||||
"http-proxy": "^1.18.1",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"isomorphic-git": "^1.37.5",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"ldapts": "^8.1.7",
|
||||
"node-pty": "^1.1.0",
|
||||
@@ -2624,6 +2625,18 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"event-target-shim": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.5"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
@@ -2769,12 +2782,33 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/async-lock": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz",
|
||||
"integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/available-typed-arrays": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||
"integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"possible-typed-array-names": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
|
||||
@@ -2989,6 +3023,24 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||
"integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"get-intrinsic": "^1.3.0",
|
||||
"set-function-length": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
@@ -3068,6 +3120,12 @@
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/clean-git-ref": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/clean-git-ref/-/clean-git-ref-2.0.1.tgz",
|
||||
"integrity": "sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
@@ -3326,6 +3384,18 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/create-require": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
@@ -3426,6 +3496,23 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/define-data-property": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -3474,6 +3561,12 @@
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/diff3": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/diff3/-/diff3-0.0.3.tgz",
|
||||
"integrity": "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/docker-modem": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.7.tgz",
|
||||
@@ -3848,12 +3941,30 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
@@ -4122,6 +4233,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
"integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-callable": "^1.2.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
@@ -4312,6 +4438,18 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/has-property-descriptors": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
@@ -4451,7 +4589,6 @@
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
@@ -4526,6 +4663,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-callable": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
|
||||
"integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -4580,6 +4729,27 @@
|
||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-typed-array": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
|
||||
"integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"which-typed-array": "^1.1.16"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
||||
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
@@ -4587,6 +4757,71 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/isomorphic-git": {
|
||||
"version": "1.37.5",
|
||||
"resolved": "https://registry.npmjs.org/isomorphic-git/-/isomorphic-git-1.37.5.tgz",
|
||||
"integrity": "sha512-wek54c5uFvd3WsxewLWt6h0GXKWQh0P8rRXns9bN1rHNjcgCb3+0lmyAsP594NeTtQFeCJQVS9b0kjbkD1l5qg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"async-lock": "^1.4.1",
|
||||
"clean-git-ref": "^2.0.1",
|
||||
"crc-32": "^1.2.0",
|
||||
"diff3": "0.0.3",
|
||||
"ignore": "^5.1.4",
|
||||
"minimisted": "^2.0.0",
|
||||
"pako": "^1.0.10",
|
||||
"pify": "^4.0.1",
|
||||
"readable-stream": "^4.0.0",
|
||||
"sha.js": "^2.4.12",
|
||||
"simple-get": "^4.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"isogit": "cli.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/isomorphic-git/node_modules/buffer": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/isomorphic-git/node_modules/readable-stream": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"abort-controller": "^3.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"events": "^3.3.0",
|
||||
"process": "^0.11.10",
|
||||
"string_decoder": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
|
||||
@@ -5198,6 +5433,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minimisted": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimisted/-/minimisted-2.0.1.tgz",
|
||||
"integrity": "sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
@@ -5479,6 +5723,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -5559,6 +5809,24 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pify": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
|
||||
"integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
"integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
@@ -5625,6 +5893,15 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process": {
|
||||
"version": "0.11.10",
|
||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.5.4",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
|
||||
@@ -5936,12 +6213,49 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.1.4",
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-intrinsic": "^1.2.4",
|
||||
"gopd": "^1.0.1",
|
||||
"has-property-descriptors": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sha.js": {
|
||||
"version": "2.4.12",
|
||||
"resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz",
|
||||
"integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==",
|
||||
"license": "(MIT AND BSD-3-Clause)",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.4",
|
||||
"safe-buffer": "^5.2.1",
|
||||
"to-buffer": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"sha.js": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -6398,6 +6712,20 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/to-buffer": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz",
|
||||
"integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"isarray": "^2.0.5",
|
||||
"safe-buffer": "^5.2.1",
|
||||
"typed-array-buffer": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -6537,6 +6865,20 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typed-array-buffer": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
|
||||
"integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.3",
|
||||
"es-errors": "^1.3.0",
|
||||
"is-typed-array": "^1.1.14"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
|
||||
@@ -6852,6 +7194,27 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-typed-array": {
|
||||
"version": "1.1.20",
|
||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
|
||||
"integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"available-typed-arrays": "^1.0.7",
|
||||
"call-bind": "^1.0.8",
|
||||
"call-bound": "^1.0.4",
|
||||
"for-each": "^0.3.5",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-tostringtag": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"helmet": "^8.1.0",
|
||||
"http-proxy": "^1.18.1",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"isomorphic-git": "^1.37.5",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"ldapts": "^8.1.7",
|
||||
"node-pty": "^1.1.0",
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* Unit tests for GitSourceService.
|
||||
*
|
||||
* Covers:
|
||||
* - hashContent determinism and env separation
|
||||
* - validateCompose YAML pre-check (empty / non-object / syntax error)
|
||||
* - Token round-trip via upsert: encryption, has_token projection, undefined/null/empty/non-empty semantics
|
||||
* - Apply-matrix rejection (auto_deploy requires auto_apply)
|
||||
* - Error code mapping from isomorphic-git failures (REPO_NOT_FOUND, AUTH_FAILED, BRANCH_NOT_FOUND, NETWORK_TIMEOUT)
|
||||
* - Credential scrubbing in surfaced error messages
|
||||
* - Pending state lifecycle (setPending -> apply clears -> dismissPending clears)
|
||||
* - Webhook debounce enforcement
|
||||
* - Per-stack mutex serialization ordering
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
|
||||
// ── Hoisted mocks ──────────────────────────────────────────────────────
|
||||
|
||||
const { mockGitClone, mockGitLog } = vi.hoisted(() => ({
|
||||
mockGitClone: vi.fn(),
|
||||
mockGitLog: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('isomorphic-git', () => {
|
||||
const api = { clone: mockGitClone, log: mockGitLog };
|
||||
return { default: api, clone: mockGitClone, log: mockGitLog };
|
||||
});
|
||||
|
||||
vi.mock('isomorphic-git/http/node', () => ({ default: {} }));
|
||||
|
||||
let tmpDir: string;
|
||||
let GitSourceService: typeof import('../services/GitSourceService').GitSourceService;
|
||||
let GitSourceError: typeof import('../services/GitSourceService').GitSourceError;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ GitSourceService, GitSourceError } = await import('../services/GitSourceService'));
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockGitClone.mockReset();
|
||||
mockGitLog.mockReset();
|
||||
|
||||
// Wipe persisted git sources between tests
|
||||
const db = DatabaseService.getInstance();
|
||||
for (const s of db.getGitSources()) db.deleteGitSource(s.stack_name);
|
||||
});
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Stub out isomorphic-git so that `clone` writes a minimal compose file into
|
||||
* the caller's temp dir and `log` returns a deterministic commit sha. Returns
|
||||
* the sha so tests can compare.
|
||||
*/
|
||||
function mockSuccessfulClone(options: {
|
||||
compose?: string;
|
||||
env?: string | null;
|
||||
composePath?: string;
|
||||
envPath?: string | null;
|
||||
sha?: string;
|
||||
} = {}) {
|
||||
const {
|
||||
compose = 'services:\n web:\n image: nginx\n',
|
||||
env = null,
|
||||
composePath = 'compose.yaml',
|
||||
envPath = null,
|
||||
sha = 'abc1234567890abc1234567890abc1234567890a',
|
||||
} = options;
|
||||
|
||||
mockGitClone.mockImplementation(async (args: { dir: string }) => {
|
||||
const { promises: fsp } = await import('fs');
|
||||
const path = await import('path');
|
||||
await fsp.writeFile(path.join(args.dir, composePath), compose, 'utf-8');
|
||||
if (env !== null && envPath) {
|
||||
await fsp.writeFile(path.join(args.dir, envPath), env, 'utf-8');
|
||||
}
|
||||
});
|
||||
mockGitLog.mockResolvedValue([{ oid: sha }]);
|
||||
return sha;
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GitSourceService.hashContent', () => {
|
||||
it('produces stable hashes for identical inputs', () => {
|
||||
const svc = GitSourceService.getInstance();
|
||||
const a = svc.hashContent('services:\n web: nginx\n', 'FOO=bar');
|
||||
const b = svc.hashContent('services:\n web: nginx\n', 'FOO=bar');
|
||||
expect(a).toBe(b);
|
||||
expect(a).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it('distinguishes env=null from env=""', () => {
|
||||
const svc = GitSourceService.getInstance();
|
||||
const nullHash = svc.hashContent('x: 1', null);
|
||||
const emptyHash = svc.hashContent('x: 1', '');
|
||||
// Both hash-empty-string after null-coalesce, so they should match by design.
|
||||
expect(nullHash).toBe(emptyHash);
|
||||
});
|
||||
|
||||
it('changes when compose content changes', () => {
|
||||
const svc = GitSourceService.getInstance();
|
||||
const a = svc.hashContent('x: 1', null);
|
||||
const b = svc.hashContent('x: 2', null);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('changes when env content changes', () => {
|
||||
const svc = GitSourceService.getInstance();
|
||||
const a = svc.hashContent('x: 1', 'A=1');
|
||||
const b = svc.hashContent('x: 1', 'A=2');
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('does not confuse compose|env boundary (uses NUL separator)', () => {
|
||||
const svc = GitSourceService.getInstance();
|
||||
// If the separator were absent, "ab" + "cd" would equal "abc" + "d".
|
||||
const a = svc.hashContent('ab', 'cd');
|
||||
const b = svc.hashContent('abc', 'd');
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitSourceService.validateCompose (YAML pre-check)', () => {
|
||||
const svc = () => GitSourceService.getInstance();
|
||||
|
||||
it('rejects empty content', async () => {
|
||||
const r = await svc().validateCompose('', null);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(/empty/i);
|
||||
});
|
||||
|
||||
it('rejects a YAML array at the root', async () => {
|
||||
const r = await svc().validateCompose('- one\n- two\n', null);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(/mapping/i);
|
||||
});
|
||||
|
||||
it('rejects a YAML scalar at the root', async () => {
|
||||
const r = await svc().validateCompose('42', null);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(/mapping/i);
|
||||
});
|
||||
|
||||
it('rejects malformed YAML syntax', async () => {
|
||||
const r = await svc().validateCompose('services:\n web:\n image: "unterminated\n', null);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(/YAML parse error/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitSourceService.upsert (encryption + reachability)', () => {
|
||||
it('stores an encrypted token and exposes has_token without leaking the value', async () => {
|
||||
mockSuccessfulClone();
|
||||
const svc = GitSourceService.getInstance();
|
||||
const created = await svc.upsert({
|
||||
stackName: 'enc-stack',
|
||||
repoUrl: 'https://github.com/example/repo.git',
|
||||
branch: 'main',
|
||||
composePath: 'compose.yaml',
|
||||
syncEnv: false,
|
||||
envPath: null,
|
||||
authType: 'token',
|
||||
token: 'ghp_secret_token_value',
|
||||
autoApplyOnWebhook: false,
|
||||
autoDeployOnApply: false,
|
||||
});
|
||||
expect(created.has_token).toBe(true);
|
||||
// Public projection should not contain the raw token
|
||||
const serialized = JSON.stringify(created);
|
||||
expect(serialized).not.toContain('ghp_secret_token_value');
|
||||
|
||||
// DB row holds an encrypted blob distinct from the plaintext
|
||||
const row = DatabaseService.getInstance().getGitSource('enc-stack');
|
||||
expect(row?.encrypted_token).toBeTruthy();
|
||||
expect(row?.encrypted_token).not.toBe('ghp_secret_token_value');
|
||||
});
|
||||
|
||||
it('preserves an existing token when update omits token (undefined)', async () => {
|
||||
mockSuccessfulClone();
|
||||
const svc = GitSourceService.getInstance();
|
||||
await svc.upsert({
|
||||
stackName: 'keep-stack',
|
||||
repoUrl: 'https://github.com/example/repo.git',
|
||||
branch: 'main',
|
||||
composePath: 'compose.yaml',
|
||||
syncEnv: false,
|
||||
envPath: null,
|
||||
authType: 'token',
|
||||
token: 'initial-token',
|
||||
autoApplyOnWebhook: false,
|
||||
autoDeployOnApply: false,
|
||||
});
|
||||
const originalEnc = DatabaseService.getInstance().getGitSource('keep-stack')?.encrypted_token;
|
||||
|
||||
await svc.upsert({
|
||||
stackName: 'keep-stack',
|
||||
repoUrl: 'https://github.com/example/repo.git',
|
||||
branch: 'main',
|
||||
composePath: 'compose.yaml',
|
||||
syncEnv: false,
|
||||
envPath: null,
|
||||
authType: 'token',
|
||||
// token omitted on purpose
|
||||
autoApplyOnWebhook: false,
|
||||
autoDeployOnApply: false,
|
||||
});
|
||||
const after = DatabaseService.getInstance().getGitSource('keep-stack')?.encrypted_token;
|
||||
expect(after).toBe(originalEnc);
|
||||
});
|
||||
|
||||
it('clears the token when authType switches to "none"', async () => {
|
||||
mockSuccessfulClone();
|
||||
const svc = GitSourceService.getInstance();
|
||||
await svc.upsert({
|
||||
stackName: 'clear-stack',
|
||||
repoUrl: 'https://github.com/example/repo.git',
|
||||
branch: 'main',
|
||||
composePath: 'compose.yaml',
|
||||
syncEnv: false,
|
||||
envPath: null,
|
||||
authType: 'token',
|
||||
token: 'will-be-cleared',
|
||||
autoApplyOnWebhook: false,
|
||||
autoDeployOnApply: false,
|
||||
});
|
||||
|
||||
await svc.upsert({
|
||||
stackName: 'clear-stack',
|
||||
repoUrl: 'https://github.com/example/repo.git',
|
||||
branch: 'main',
|
||||
composePath: 'compose.yaml',
|
||||
syncEnv: false,
|
||||
envPath: null,
|
||||
authType: 'none',
|
||||
autoApplyOnWebhook: false,
|
||||
autoDeployOnApply: false,
|
||||
});
|
||||
const row = DatabaseService.getInstance().getGitSource('clear-stack');
|
||||
expect(row?.encrypted_token).toBeNull();
|
||||
expect(row?.auth_type).toBe('none');
|
||||
});
|
||||
|
||||
it('rejects auto_deploy_on_apply without auto_apply_on_webhook', async () => {
|
||||
const svc = GitSourceService.getInstance();
|
||||
await expect(svc.upsert({
|
||||
stackName: 'bad-matrix',
|
||||
repoUrl: 'https://github.com/example/repo.git',
|
||||
branch: 'main',
|
||||
composePath: 'compose.yaml',
|
||||
syncEnv: false,
|
||||
envPath: null,
|
||||
authType: 'none',
|
||||
autoApplyOnWebhook: false,
|
||||
autoDeployOnApply: true,
|
||||
})).rejects.toBeInstanceOf(GitSourceError);
|
||||
|
||||
// Dry-run clone must not have been attempted for the invalid matrix
|
||||
expect(mockGitClone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not persist when dry-run fetch fails', async () => {
|
||||
mockGitClone.mockRejectedValueOnce(Object.assign(new Error('404 not found'), { code: 'NotFoundError' }));
|
||||
const svc = GitSourceService.getInstance();
|
||||
await expect(svc.upsert({
|
||||
stackName: 'unreachable',
|
||||
repoUrl: 'https://github.com/example/nope.git',
|
||||
branch: 'main',
|
||||
composePath: 'compose.yaml',
|
||||
syncEnv: false,
|
||||
envPath: null,
|
||||
authType: 'none',
|
||||
autoApplyOnWebhook: false,
|
||||
autoDeployOnApply: false,
|
||||
})).rejects.toMatchObject({ code: 'REPO_NOT_FOUND' });
|
||||
|
||||
expect(DatabaseService.getInstance().getGitSource('unreachable')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitSourceService error mapping', () => {
|
||||
const svc = () => GitSourceService.getInstance();
|
||||
const fetchParams = {
|
||||
repoUrl: 'https://github.com/example/repo.git',
|
||||
branch: 'main',
|
||||
composePath: 'compose.yaml',
|
||||
};
|
||||
|
||||
it('maps 401/auth errors to AUTH_FAILED', async () => {
|
||||
mockGitClone.mockRejectedValueOnce(Object.assign(new Error('HTTP 401 Unauthorized'), { code: 'HttpError' }));
|
||||
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({ code: 'AUTH_FAILED' });
|
||||
});
|
||||
|
||||
it('maps 404/not-found errors to REPO_NOT_FOUND', async () => {
|
||||
mockGitClone.mockRejectedValueOnce(Object.assign(new Error('Repository not found'), { code: 'NotFoundError' }));
|
||||
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({ code: 'REPO_NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('maps resolve-ref errors to BRANCH_NOT_FOUND', async () => {
|
||||
// Message phrased to miss the REPO_NOT_FOUND regex ("could not resolve")
|
||||
// so the BRANCH_NOT_FOUND branch is exercised.
|
||||
mockGitClone.mockRejectedValueOnce(Object.assign(new Error('unknown ref nonexistent'), { code: 'ResolveRefError' }));
|
||||
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({ code: 'BRANCH_NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('maps timeout errors to NETWORK_TIMEOUT', async () => {
|
||||
mockGitClone.mockRejectedValueOnce(new Error('ETIMEDOUT connecting to host'));
|
||||
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({ code: 'NETWORK_TIMEOUT' });
|
||||
});
|
||||
|
||||
it('surfaces FILE_NOT_FOUND when the compose path is missing from the clone', async () => {
|
||||
mockGitClone.mockImplementation(async () => { /* clone empty repo */ });
|
||||
mockGitLog.mockResolvedValue([{ oid: 'deadbeef' }]);
|
||||
await expect(svc().fetchFromGit({
|
||||
...fetchParams,
|
||||
composePath: 'missing/compose.yaml',
|
||||
})).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('scrubs inline credentials from surfaced error messages', async () => {
|
||||
mockGitClone.mockRejectedValueOnce(new Error('Failed: https://user:supersecret@github.com/example/repo.git 500'));
|
||||
try {
|
||||
await svc().fetchFromGit(fetchParams);
|
||||
expect.fail('should have thrown');
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
expect(err.message).not.toContain('supersecret');
|
||||
expect(err.message).toContain('***');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitSourceService pending lifecycle', () => {
|
||||
it('dismissPending clears pending columns', async () => {
|
||||
mockSuccessfulClone();
|
||||
const svc = GitSourceService.getInstance();
|
||||
await svc.upsert({
|
||||
stackName: 'pending-stack',
|
||||
repoUrl: 'https://github.com/example/repo.git',
|
||||
branch: 'main',
|
||||
composePath: 'compose.yaml',
|
||||
syncEnv: false,
|
||||
envPath: null,
|
||||
authType: 'none',
|
||||
autoApplyOnWebhook: false,
|
||||
autoDeployOnApply: false,
|
||||
});
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
db.setGitSourcePending('pending-stack', 'sha-xxx', 'services: {}', null);
|
||||
expect(db.getGitSource('pending-stack')?.pending_commit_sha).toBe('sha-xxx');
|
||||
|
||||
svc.dismissPending('pending-stack');
|
||||
expect(db.getGitSource('pending-stack')?.pending_commit_sha).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitSourceService.handleWebhookPull debounce', () => {
|
||||
it('returns skipped when invoked within the debounce window', async () => {
|
||||
mockSuccessfulClone();
|
||||
const svc = GitSourceService.getInstance();
|
||||
await svc.upsert({
|
||||
stackName: 'debounce-stack',
|
||||
repoUrl: 'https://github.com/example/repo.git',
|
||||
branch: 'main',
|
||||
composePath: 'compose.yaml',
|
||||
syncEnv: false,
|
||||
envPath: null,
|
||||
authType: 'none',
|
||||
autoApplyOnWebhook: false,
|
||||
autoDeployOnApply: false,
|
||||
});
|
||||
|
||||
// Stamp a recent debounce timestamp directly
|
||||
DatabaseService.getInstance().touchGitSourceDebounce('debounce-stack');
|
||||
|
||||
const result = await svc.handleWebhookPull('debounce-stack');
|
||||
expect(result.status).toBe('skipped');
|
||||
expect(result.message).toMatch(/rate limited/i);
|
||||
});
|
||||
|
||||
it('returns error when stack has no Git source configured', async () => {
|
||||
const svc = GitSourceService.getInstance();
|
||||
const result = await svc.handleWebhookPull('does-not-exist');
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.message).toMatch(/no git source/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitSourceService per-stack mutex', () => {
|
||||
it('serializes concurrent apply calls on the same stack', async () => {
|
||||
mockSuccessfulClone();
|
||||
const svc = GitSourceService.getInstance() as unknown as {
|
||||
withStackLock<T>(name: string, fn: () => Promise<T>): Promise<T>;
|
||||
};
|
||||
|
||||
const order: string[] = [];
|
||||
const makeJob = (label: string, delayMs: number) => async () => {
|
||||
order.push(`start:${label}`);
|
||||
await new Promise(r => setTimeout(r, delayMs));
|
||||
order.push(`end:${label}`);
|
||||
return label;
|
||||
};
|
||||
|
||||
const [a, b, c] = await Promise.all([
|
||||
svc.withStackLock('serialized', makeJob('A', 30)),
|
||||
svc.withStackLock('serialized', makeJob('B', 10)),
|
||||
svc.withStackLock('serialized', makeJob('C', 5)),
|
||||
]);
|
||||
|
||||
expect([a, b, c]).toEqual(['A', 'B', 'C']);
|
||||
// Each job must fully complete before the next one starts.
|
||||
expect(order).toEqual([
|
||||
'start:A', 'end:A',
|
||||
'start:B', 'end:B',
|
||||
'start:C', 'end:C',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not block work on a different stack', async () => {
|
||||
const svc = GitSourceService.getInstance() as unknown as {
|
||||
withStackLock<T>(name: string, fn: () => Promise<T>): Promise<T>;
|
||||
};
|
||||
|
||||
const order: string[] = [];
|
||||
const slow = svc.withStackLock('alpha', async () => {
|
||||
order.push('alpha:start');
|
||||
await new Promise(r => setTimeout(r, 40));
|
||||
order.push('alpha:end');
|
||||
});
|
||||
const fast = svc.withStackLock('beta', async () => {
|
||||
order.push('beta:start');
|
||||
order.push('beta:end');
|
||||
});
|
||||
|
||||
await Promise.all([slow, fast]);
|
||||
// beta should have started and finished before alpha finished
|
||||
expect(order.indexOf('beta:end')).toBeLessThan(order.indexOf('alpha:end'));
|
||||
});
|
||||
});
|
||||
+197
-2
@@ -35,6 +35,7 @@ import { SchedulerService } from './services/SchedulerService';
|
||||
import { RegistryService } from './services/RegistryService';
|
||||
import { CacheService } from './services/CacheService';
|
||||
import { CAPABILITIES, getSenchoVersion, isValidVersion, fetchRemoteMeta, getActiveCapabilities, type RemoteMeta } from './services/CapabilityRegistry';
|
||||
import { GitSourceService, GitSourceError, sweepStaleTempDirs as sweepStaleGitTempDirs, type GitSourceErrorCode } from './services/GitSourceService';
|
||||
|
||||
// ── Hot-path cache TTLs ────────────────────────────────────────────────
|
||||
// Short TTLs collapse concurrent polling pressure across browser tabs and
|
||||
@@ -2254,11 +2255,15 @@ app.post('/api/webhooks', authMiddleware, async (req: Request, res: Response): P
|
||||
res.status(400).json({ error: 'name, stack_name, and action are required' });
|
||||
return;
|
||||
}
|
||||
const validActions = ['deploy', 'restart', 'stop', 'start', 'pull'];
|
||||
const validActions = ['deploy', 'restart', 'stop', 'start', 'pull', 'git-pull'];
|
||||
if (!validActions.includes(action)) {
|
||||
res.status(400).json({ error: `action must be one of: ${validActions.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
if (action === 'git-pull' && !GitSourceService.getInstance().get(stack_name)) {
|
||||
res.status(400).json({ error: 'Configure a Git source for this stack before creating a git-pull webhook' });
|
||||
return;
|
||||
}
|
||||
|
||||
const svc = WebhookService.getInstance();
|
||||
const secret = svc.generateSecret();
|
||||
@@ -2283,11 +2288,18 @@ app.put('/api/webhooks/:id', authMiddleware, async (req: Request, res: Response)
|
||||
if (!webhook) { res.status(404).json({ error: 'Webhook not found' }); return; }
|
||||
|
||||
const { name, stack_name, action, enabled } = req.body;
|
||||
const validActions = ['deploy', 'restart', 'stop', 'start', 'pull'];
|
||||
const validActions = ['deploy', 'restart', 'stop', 'start', 'pull', 'git-pull'];
|
||||
if (action && !validActions.includes(action)) {
|
||||
res.status(400).json({ error: `action must be one of: ${validActions.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
if (action === 'git-pull') {
|
||||
const targetStack = stack_name || webhook.stack_name;
|
||||
if (!GitSourceService.getInstance().get(targetStack)) {
|
||||
res.status(400).json({ error: 'Configure a Git source for this stack before enabling a git-pull webhook' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
DatabaseService.getInstance().updateWebhook(id, { name, stack_name, action, enabled });
|
||||
res.json({ success: true });
|
||||
@@ -3681,6 +3693,182 @@ app.put('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Git sources ────────────────────────────────────────────────────────
|
||||
// Map GitSourceError codes to HTTP statuses so the UI can tell apart things
|
||||
// a user can fix (bad token, missing file) from transient failures.
|
||||
function gitSourceStatus(code: GitSourceErrorCode): number {
|
||||
switch (code) {
|
||||
case 'AUTH_FAILED': return 401;
|
||||
case 'REPO_NOT_FOUND':
|
||||
case 'BRANCH_NOT_FOUND':
|
||||
case 'FILE_NOT_FOUND':
|
||||
return 404;
|
||||
case 'NETWORK_TIMEOUT': return 504;
|
||||
default: return 400;
|
||||
}
|
||||
}
|
||||
|
||||
function sendGitSourceError(res: Response, err: unknown): void {
|
||||
if (err instanceof GitSourceError) {
|
||||
res.status(gitSourceStatus(err.code)).json({ error: err.message, code: err.code });
|
||||
return;
|
||||
}
|
||||
console.error('[GitSource] Unexpected error:', err);
|
||||
res.status(500).json({ error: 'Git source operation failed' });
|
||||
}
|
||||
|
||||
app.get('/api/git-sources', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const sources = GitSourceService.getInstance().list();
|
||||
res.json(sources);
|
||||
} catch (error) {
|
||||
sendGitSourceError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/stacks/:stackName/git-source', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
const source = GitSourceService.getInstance().get(stackName);
|
||||
if (!source) return res.status(404).json({ error: 'No Git source configured for this stack' });
|
||||
res.json(source);
|
||||
} catch (error) {
|
||||
sendGitSourceError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/stacks/:stackName/git-source', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
const {
|
||||
repo_url,
|
||||
branch,
|
||||
compose_path,
|
||||
sync_env,
|
||||
env_path,
|
||||
auth_type,
|
||||
token,
|
||||
auto_apply_on_webhook,
|
||||
auto_deploy_on_apply,
|
||||
} = req.body ?? {};
|
||||
|
||||
if (typeof repo_url !== 'string' || !repo_url.trim()) {
|
||||
return res.status(400).json({ error: 'repo_url is required' });
|
||||
}
|
||||
if (typeof branch !== 'string' || !branch.trim()) {
|
||||
return res.status(400).json({ error: 'branch is required' });
|
||||
}
|
||||
if (typeof compose_path !== 'string' || !compose_path.trim()) {
|
||||
return res.status(400).json({ error: 'compose_path is required' });
|
||||
}
|
||||
if (auth_type !== 'none' && auth_type !== 'token') {
|
||||
return res.status(400).json({ error: 'auth_type must be "none" or "token"' });
|
||||
}
|
||||
if (!/^https?:\/\//i.test(repo_url)) {
|
||||
return res.status(400).json({ error: 'Only HTTPS repository URLs are supported' });
|
||||
}
|
||||
|
||||
const syncEnv = Boolean(sync_env);
|
||||
const resolvedEnvPath = syncEnv
|
||||
? (typeof env_path === 'string' && env_path.trim()
|
||||
? env_path
|
||||
: path.posix.join(path.posix.dirname(compose_path.replace(/\\/g, '/')) || '.', '.env'))
|
||||
: null;
|
||||
|
||||
const source = await GitSourceService.getInstance().upsert({
|
||||
stackName,
|
||||
repoUrl: repo_url.trim(),
|
||||
branch: branch.trim(),
|
||||
composePath: compose_path.trim(),
|
||||
syncEnv,
|
||||
envPath: resolvedEnvPath,
|
||||
authType: auth_type,
|
||||
token: typeof token === 'string' ? token : undefined,
|
||||
autoApplyOnWebhook: Boolean(auto_apply_on_webhook),
|
||||
autoDeployOnApply: Boolean(auto_deploy_on_apply),
|
||||
});
|
||||
|
||||
console.log(`[GitSource] Configured git source for ${stackName}`);
|
||||
res.json(source);
|
||||
} catch (error) {
|
||||
sendGitSourceError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/stacks/:stackName/git-source', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
GitSourceService.getInstance().delete(stackName);
|
||||
console.log(`[GitSource] Removed git source for ${stackName}`);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
sendGitSourceError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/stacks/:stackName/git-source/pull', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
const result = await GitSourceService.getInstance().pull(stackName);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
sendGitSourceError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/stacks/:stackName/git-source/apply', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
const { commitSha, deploy } = req.body ?? {};
|
||||
if (typeof commitSha !== 'string' || !commitSha.trim()) {
|
||||
return res.status(400).json({ error: 'commitSha is required' });
|
||||
}
|
||||
const result = await GitSourceService.getInstance().apply(
|
||||
stackName,
|
||||
commitSha.trim(),
|
||||
{ deploy: typeof deploy === 'boolean' ? deploy : undefined }
|
||||
);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[GitSource] Applied commit ${commitSha.trim().slice(0, 7)} to ${stackName}${result.deployed ? ' (deployed)' : ''}`);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
sendGitSourceError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/stacks/:stackName/git-source/dismiss-pending', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
GitSourceService.getInstance().dismissPending(stackName);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
sendGitSourceError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/stacks', async (req: Request, res: Response) => {
|
||||
if (!requirePermission(req, res, 'stack:create')) return;
|
||||
try {
|
||||
@@ -3726,6 +3914,8 @@ app.delete('/api/stacks/:stackName', async (req: Request, res: Response) => {
|
||||
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
|
||||
// Clean up any scoped role assignments referencing this stack
|
||||
DatabaseService.getInstance().deleteRoleAssignmentsByResource('stack', stackName);
|
||||
// Remove any linked Git source so it does not resurface on a future stack with the same name
|
||||
DatabaseService.getInstance().deleteGitSource(stackName);
|
||||
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Stacks] Stack deleted: ${stackName}`);
|
||||
@@ -6476,6 +6666,11 @@ async function startServer() {
|
||||
// Start Scheduled Operations Service
|
||||
SchedulerService.getInstance().start();
|
||||
|
||||
// Sweep any leftover git-source temp clones from a crashed prior run
|
||||
sweepStaleGitTempDirs().catch((err) => {
|
||||
console.warn('[GitSource] Temp dir sweep failed:', (err as Error).message);
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
|
||||
@@ -45,17 +45,44 @@ export interface Label {
|
||||
color: string;
|
||||
}
|
||||
|
||||
export type WebhookAction = 'deploy' | 'restart' | 'stop' | 'start' | 'pull' | 'git-pull';
|
||||
|
||||
export interface Webhook {
|
||||
id?: number;
|
||||
name: string;
|
||||
stack_name: string;
|
||||
action: 'deploy' | 'restart' | 'stop' | 'start' | 'pull';
|
||||
action: WebhookAction;
|
||||
secret: string;
|
||||
enabled: boolean;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export type GitSourceAuthType = 'none' | 'token';
|
||||
|
||||
export interface StackGitSource {
|
||||
id?: number;
|
||||
stack_name: string;
|
||||
repo_url: string;
|
||||
branch: string;
|
||||
compose_path: string;
|
||||
sync_env: boolean;
|
||||
env_path: string | null;
|
||||
auth_type: GitSourceAuthType;
|
||||
encrypted_token: string | null;
|
||||
auto_apply_on_webhook: boolean;
|
||||
auto_deploy_on_apply: boolean;
|
||||
last_applied_commit_sha: string | null;
|
||||
last_applied_content_hash: string | null;
|
||||
pending_commit_sha: string | null;
|
||||
pending_compose_content: string | null;
|
||||
pending_env_content: string | null;
|
||||
pending_fetched_at: number | null;
|
||||
last_debounce_at: number | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface WebhookExecution {
|
||||
id?: number;
|
||||
webhook_id: number;
|
||||
@@ -459,6 +486,29 @@ export class DatabaseService {
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_label_assignments_stack
|
||||
ON stack_label_assignments(stack_name, node_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stack_git_sources (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
stack_name TEXT NOT NULL UNIQUE,
|
||||
repo_url TEXT NOT NULL,
|
||||
branch TEXT NOT NULL,
|
||||
compose_path TEXT NOT NULL,
|
||||
sync_env INTEGER NOT NULL DEFAULT 0,
|
||||
env_path TEXT,
|
||||
auth_type TEXT NOT NULL DEFAULT 'none',
|
||||
encrypted_token TEXT,
|
||||
auto_apply_on_webhook INTEGER NOT NULL DEFAULT 0,
|
||||
auto_deploy_on_apply INTEGER NOT NULL DEFAULT 0,
|
||||
last_applied_commit_sha TEXT,
|
||||
last_applied_content_hash TEXT,
|
||||
pending_commit_sha TEXT,
|
||||
pending_compose_content TEXT,
|
||||
pending_env_content TEXT,
|
||||
pending_fetched_at INTEGER,
|
||||
last_debounce_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Apply migrations safely (ignore if columns already exist)
|
||||
@@ -1467,6 +1517,127 @@ export class DatabaseService {
|
||||
this.db.prepare('DELETE FROM registries WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
// --- Stack Git Sources ---
|
||||
|
||||
private parseGitSource(row: Record<string, unknown> | undefined): StackGitSource | undefined {
|
||||
if (!row) return undefined;
|
||||
return {
|
||||
id: row.id as number,
|
||||
stack_name: row.stack_name as string,
|
||||
repo_url: row.repo_url as string,
|
||||
branch: row.branch as string,
|
||||
compose_path: row.compose_path as string,
|
||||
sync_env: Number(row.sync_env) === 1,
|
||||
env_path: (row.env_path as string | null) ?? null,
|
||||
auth_type: row.auth_type as GitSourceAuthType,
|
||||
encrypted_token: (row.encrypted_token as string | null) ?? null,
|
||||
auto_apply_on_webhook: Number(row.auto_apply_on_webhook) === 1,
|
||||
auto_deploy_on_apply: Number(row.auto_deploy_on_apply) === 1,
|
||||
last_applied_commit_sha: (row.last_applied_commit_sha as string | null) ?? null,
|
||||
last_applied_content_hash: (row.last_applied_content_hash as string | null) ?? null,
|
||||
pending_commit_sha: (row.pending_commit_sha as string | null) ?? null,
|
||||
pending_compose_content: (row.pending_compose_content as string | null) ?? null,
|
||||
pending_env_content: (row.pending_env_content as string | null) ?? null,
|
||||
pending_fetched_at: (row.pending_fetched_at as number | null) ?? null,
|
||||
last_debounce_at: (row.last_debounce_at as number | null) ?? null,
|
||||
created_at: row.created_at as number,
|
||||
updated_at: row.updated_at as number,
|
||||
};
|
||||
}
|
||||
|
||||
public getGitSource(stackName: string): StackGitSource | undefined {
|
||||
const row = this.db.prepare('SELECT * FROM stack_git_sources WHERE stack_name = ?').get(stackName) as Record<string, unknown> | undefined;
|
||||
return this.parseGitSource(row);
|
||||
}
|
||||
|
||||
public getGitSources(): StackGitSource[] {
|
||||
const rows = this.db.prepare('SELECT * FROM stack_git_sources ORDER BY stack_name ASC').all() as Record<string, unknown>[];
|
||||
return rows.map(r => this.parseGitSource(r)!);
|
||||
}
|
||||
|
||||
public upsertGitSource(source: Omit<StackGitSource, 'id' | 'created_at' | 'updated_at'>): number {
|
||||
const now = Date.now();
|
||||
const existing = this.getGitSource(source.stack_name);
|
||||
if (existing) {
|
||||
this.db.prepare(
|
||||
`UPDATE stack_git_sources SET
|
||||
repo_url = ?, branch = ?, compose_path = ?, sync_env = ?, env_path = ?,
|
||||
auth_type = ?, encrypted_token = ?,
|
||||
auto_apply_on_webhook = ?, auto_deploy_on_apply = ?,
|
||||
updated_at = ?
|
||||
WHERE stack_name = ?`
|
||||
).run(
|
||||
source.repo_url, source.branch, source.compose_path,
|
||||
source.sync_env ? 1 : 0, source.env_path,
|
||||
source.auth_type, source.encrypted_token,
|
||||
source.auto_apply_on_webhook ? 1 : 0, source.auto_deploy_on_apply ? 1 : 0,
|
||||
now, source.stack_name
|
||||
);
|
||||
return existing.id!;
|
||||
}
|
||||
const result = this.db.prepare(
|
||||
`INSERT INTO stack_git_sources
|
||||
(stack_name, repo_url, branch, compose_path, sync_env, env_path,
|
||||
auth_type, encrypted_token, auto_apply_on_webhook, auto_deploy_on_apply,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
source.stack_name, source.repo_url, source.branch, source.compose_path,
|
||||
source.sync_env ? 1 : 0, source.env_path,
|
||||
source.auth_type, source.encrypted_token,
|
||||
source.auto_apply_on_webhook ? 1 : 0, source.auto_deploy_on_apply ? 1 : 0,
|
||||
now, now
|
||||
);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
public deleteGitSource(stackName: string): void {
|
||||
this.db.prepare('DELETE FROM stack_git_sources WHERE stack_name = ?').run(stackName);
|
||||
}
|
||||
|
||||
public setGitSourcePending(stackName: string, commitSha: string, composeContent: string, envContent: string | null): void {
|
||||
this.db.prepare(
|
||||
`UPDATE stack_git_sources SET
|
||||
pending_commit_sha = ?,
|
||||
pending_compose_content = ?,
|
||||
pending_env_content = ?,
|
||||
pending_fetched_at = ?,
|
||||
updated_at = ?
|
||||
WHERE stack_name = ?`
|
||||
).run(commitSha, composeContent, envContent, Date.now(), Date.now(), stackName);
|
||||
}
|
||||
|
||||
public clearGitSourcePending(stackName: string): void {
|
||||
this.db.prepare(
|
||||
`UPDATE stack_git_sources SET
|
||||
pending_commit_sha = NULL,
|
||||
pending_compose_content = NULL,
|
||||
pending_env_content = NULL,
|
||||
pending_fetched_at = NULL,
|
||||
updated_at = ?
|
||||
WHERE stack_name = ?`
|
||||
).run(Date.now(), stackName);
|
||||
}
|
||||
|
||||
public markGitSourceApplied(stackName: string, commitSha: string, contentHash: string): void {
|
||||
this.db.prepare(
|
||||
`UPDATE stack_git_sources SET
|
||||
last_applied_commit_sha = ?,
|
||||
last_applied_content_hash = ?,
|
||||
pending_commit_sha = NULL,
|
||||
pending_compose_content = NULL,
|
||||
pending_env_content = NULL,
|
||||
pending_fetched_at = NULL,
|
||||
updated_at = ?
|
||||
WHERE stack_name = ?`
|
||||
).run(commitSha, contentHash, Date.now(), stackName);
|
||||
}
|
||||
|
||||
public touchGitSourceDebounce(stackName: string): void {
|
||||
this.db.prepare('UPDATE stack_git_sources SET last_debounce_at = ? WHERE stack_name = ?')
|
||||
.run(Date.now(), stackName);
|
||||
}
|
||||
|
||||
// --- Scheduled Tasks ---
|
||||
|
||||
public getScheduledTasks(): ScheduledTask[] {
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import { spawn } from 'child_process';
|
||||
import crypto from 'crypto';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import git from 'isomorphic-git';
|
||||
import gitHttp from 'isomorphic-git/http/node';
|
||||
import YAML from 'yaml';
|
||||
import { CryptoService } from './CryptoService';
|
||||
import { DatabaseService, type StackGitSource, type GitSourceAuthType } from './DatabaseService';
|
||||
import { FileSystemService } from './FileSystemService';
|
||||
import { ComposeService } from './ComposeService';
|
||||
|
||||
/**
|
||||
* GitSourceService - fetch compose files from a Git repository and apply
|
||||
* them to local stacks. Tokens are encrypted via CryptoService. Shallow
|
||||
* single-branch clones land in a per-fetch temp dir and are cleaned up
|
||||
* in a `finally` block. A startup sweep removes any leftover temp dirs
|
||||
* older than 1 hour in case a previous process crashed.
|
||||
*/
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type GitSourceErrorCode =
|
||||
| 'REPO_NOT_FOUND'
|
||||
| 'AUTH_FAILED'
|
||||
| 'BRANCH_NOT_FOUND'
|
||||
| 'FILE_NOT_FOUND'
|
||||
| 'NETWORK_TIMEOUT'
|
||||
| 'GIT_ERROR';
|
||||
|
||||
export class GitSourceError extends Error {
|
||||
constructor(public code: GitSourceErrorCode, message: string) {
|
||||
super(message);
|
||||
this.name = 'GitSourceError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface FetchParams {
|
||||
repoUrl: string;
|
||||
branch: string;
|
||||
composePath: string;
|
||||
envPath?: string | null;
|
||||
token?: string | null;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface FetchResult {
|
||||
composeContent: string;
|
||||
envContent: string | null;
|
||||
commitSha: string;
|
||||
}
|
||||
|
||||
export interface UpsertInput {
|
||||
stackName: string;
|
||||
repoUrl: string;
|
||||
branch: string;
|
||||
composePath: string;
|
||||
syncEnv: boolean;
|
||||
envPath: string | null;
|
||||
authType: GitSourceAuthType;
|
||||
token?: string | null; // undefined = keep existing, '' = clear, non-empty = replace
|
||||
autoApplyOnWebhook: boolean;
|
||||
autoDeployOnApply: boolean;
|
||||
}
|
||||
|
||||
export interface PullResult {
|
||||
commitSha: string;
|
||||
incomingCompose: string;
|
||||
incomingEnv: string | null;
|
||||
currentCompose: string;
|
||||
currentEnv: string | null;
|
||||
validation: { ok: boolean; error?: string };
|
||||
hasLocalChanges: boolean;
|
||||
}
|
||||
|
||||
export interface PublicGitSource {
|
||||
id: number;
|
||||
stack_name: string;
|
||||
repo_url: string;
|
||||
branch: string;
|
||||
compose_path: string;
|
||||
sync_env: boolean;
|
||||
env_path: string | null;
|
||||
auth_type: GitSourceAuthType;
|
||||
has_token: boolean;
|
||||
auto_apply_on_webhook: boolean;
|
||||
auto_deploy_on_apply: boolean;
|
||||
last_applied_commit_sha: string | null;
|
||||
pending_commit_sha: string | null;
|
||||
pending_fetched_at: number | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const TEMP_DIR_PREFIX = 'sencho-git-';
|
||||
const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
|
||||
const TEMP_DIR_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
|
||||
const WEBHOOK_DEBOUNCE_MS = 10_000;
|
||||
|
||||
// ─── Credential scrubbing ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Remove any inline credentials and Authorization headers from an error
|
||||
* message before it lands in a log or an API response. isomorphic-git
|
||||
* tends to include the fetch URL in thrown errors; if a PAT ever leaks
|
||||
* into that URL (we try to avoid it via `onAuth`, but be defensive),
|
||||
* strip it here.
|
||||
*/
|
||||
function scrubCredentials(message: string): string {
|
||||
return message
|
||||
.replace(/https?:\/\/[^/\s:@]+:[^/\s@]+@/gi, 'https://***:***@')
|
||||
.replace(/(authorization[:=]\s*)[^\s,;]+/gi, '$1***')
|
||||
.replace(/(token[:=]\s*)[^\s,;]+/gi, '$1***')
|
||||
.replace(/(password[:=]\s*)[^\s,;]+/gi, '$1***');
|
||||
}
|
||||
|
||||
// ─── Temp dir helpers ────────────────────────────────────────────────────────
|
||||
|
||||
async function createTempDir(): Promise<string> {
|
||||
const prefix = path.join(os.tmpdir(), TEMP_DIR_PREFIX);
|
||||
return fsPromises.mkdtemp(prefix);
|
||||
}
|
||||
|
||||
async function removeTempDir(dir: string): Promise<void> {
|
||||
try {
|
||||
await fsPromises.rm(dir, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.warn('[GitSourceService] Failed to remove temp dir:', (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep any leftover sencho-git-* temp dirs older than 1 hour. Runs once at
|
||||
* service boot to clean up after a crashed process.
|
||||
*/
|
||||
export async function sweepStaleTempDirs(): Promise<void> {
|
||||
const tmp = os.tmpdir();
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fsPromises.readdir(tmp);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const cutoff = Date.now() - TEMP_DIR_MAX_AGE_MS;
|
||||
for (const entry of entries) {
|
||||
if (!entry.startsWith(TEMP_DIR_PREFIX)) continue;
|
||||
const full = path.join(tmp, entry);
|
||||
try {
|
||||
const stat = await fsPromises.stat(full);
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
await fsPromises.rm(full, { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Service ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export class GitSourceService {
|
||||
private static instance: GitSourceService;
|
||||
private crypto: CryptoService;
|
||||
/** Per-stack serialization for the apply path. */
|
||||
private stackLocks = new Map<string, Promise<unknown>>();
|
||||
|
||||
private constructor() {
|
||||
this.crypto = CryptoService.getInstance();
|
||||
}
|
||||
|
||||
public static getInstance(): GitSourceService {
|
||||
if (!GitSourceService.instance) {
|
||||
GitSourceService.instance = new GitSourceService();
|
||||
}
|
||||
return GitSourceService.instance;
|
||||
}
|
||||
|
||||
// ─── Public projections ──────────────────────────────────────────────────
|
||||
|
||||
private toPublic(src: StackGitSource): PublicGitSource {
|
||||
return {
|
||||
id: src.id!,
|
||||
stack_name: src.stack_name,
|
||||
repo_url: src.repo_url,
|
||||
branch: src.branch,
|
||||
compose_path: src.compose_path,
|
||||
sync_env: src.sync_env,
|
||||
env_path: src.env_path,
|
||||
auth_type: src.auth_type,
|
||||
has_token: !!src.encrypted_token,
|
||||
auto_apply_on_webhook: src.auto_apply_on_webhook,
|
||||
auto_deploy_on_apply: src.auto_deploy_on_apply,
|
||||
last_applied_commit_sha: src.last_applied_commit_sha,
|
||||
pending_commit_sha: src.pending_commit_sha,
|
||||
pending_fetched_at: src.pending_fetched_at,
|
||||
created_at: src.created_at,
|
||||
updated_at: src.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
public get(stackName: string): PublicGitSource | undefined {
|
||||
const row = DatabaseService.getInstance().getGitSource(stackName);
|
||||
return row ? this.toPublic(row) : undefined;
|
||||
}
|
||||
|
||||
public list(): PublicGitSource[] {
|
||||
return DatabaseService.getInstance().getGitSources().map(s => this.toPublic(s));
|
||||
}
|
||||
|
||||
// ─── CRUD ────────────────────────────────────────────────────────────────
|
||||
|
||||
public async upsert(input: UpsertInput): Promise<PublicGitSource> {
|
||||
const db = DatabaseService.getInstance();
|
||||
const existing = db.getGitSource(input.stackName);
|
||||
|
||||
// Determine the stored token.
|
||||
let encryptedToken: string | null;
|
||||
if (input.authType === 'none') {
|
||||
encryptedToken = null;
|
||||
} else if (input.token === undefined) {
|
||||
// Keep existing
|
||||
encryptedToken = existing?.encrypted_token ?? null;
|
||||
} else if (input.token === null || input.token === '') {
|
||||
encryptedToken = null;
|
||||
} else {
|
||||
encryptedToken = this.crypto.encrypt(input.token);
|
||||
}
|
||||
|
||||
// Apply-matrix sanity: auto_deploy requires auto_apply.
|
||||
if (input.autoDeployOnApply && !input.autoApplyOnWebhook) {
|
||||
throw new GitSourceError('GIT_ERROR', 'Auto-deploy requires auto-apply-on-webhook to be enabled.');
|
||||
}
|
||||
|
||||
// Dry-run reachability check before persisting.
|
||||
const token = encryptedToken ? this.crypto.decrypt(encryptedToken) : null;
|
||||
await this.fetchFromGit({
|
||||
repoUrl: input.repoUrl,
|
||||
branch: input.branch,
|
||||
composePath: input.composePath,
|
||||
envPath: input.syncEnv ? input.envPath : null,
|
||||
token,
|
||||
});
|
||||
|
||||
db.upsertGitSource({
|
||||
stack_name: input.stackName,
|
||||
repo_url: input.repoUrl,
|
||||
branch: input.branch,
|
||||
compose_path: input.composePath,
|
||||
sync_env: input.syncEnv,
|
||||
env_path: input.syncEnv ? input.envPath : null,
|
||||
auth_type: input.authType,
|
||||
encrypted_token: encryptedToken,
|
||||
auto_apply_on_webhook: input.autoApplyOnWebhook,
|
||||
auto_deploy_on_apply: input.autoDeployOnApply,
|
||||
last_applied_commit_sha: existing?.last_applied_commit_sha ?? null,
|
||||
last_applied_content_hash: existing?.last_applied_content_hash ?? null,
|
||||
pending_commit_sha: existing?.pending_commit_sha ?? null,
|
||||
pending_compose_content: existing?.pending_compose_content ?? null,
|
||||
pending_env_content: existing?.pending_env_content ?? null,
|
||||
pending_fetched_at: existing?.pending_fetched_at ?? null,
|
||||
last_debounce_at: existing?.last_debounce_at ?? null,
|
||||
});
|
||||
|
||||
return this.get(input.stackName)!;
|
||||
}
|
||||
|
||||
public delete(stackName: string): void {
|
||||
DatabaseService.getInstance().deleteGitSource(stackName);
|
||||
}
|
||||
|
||||
// ─── Fetch ───────────────────────────────────────────────────────────────
|
||||
|
||||
public async fetchFromGit(params: FetchParams): Promise<FetchResult> {
|
||||
const { repoUrl, branch, composePath, envPath, token } = params;
|
||||
const timeoutMs = params.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
||||
const dir = await createTempDir();
|
||||
|
||||
// isomorphic-git's onAuth callback hands credentials to the HTTP
|
||||
// layer without them touching the URL string, which keeps tokens
|
||||
// out of any error messages generated during the clone.
|
||||
const onAuth = token
|
||||
? () => ({ username: 'x-access-token', password: token })
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
// isomorphic-git does not natively accept an AbortSignal, so we
|
||||
// wrap the clone in a Promise.race against a timeout rejection.
|
||||
// The clone will keep running in the background until the socket
|
||||
// resolves, but we will not block the caller indefinitely.
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(Object.assign(new Error('Clone timed out'), { code: 'ETIMEDOUT' })),
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
try {
|
||||
await Promise.race([
|
||||
git.clone({
|
||||
fs: { promises: fsPromises },
|
||||
http: gitHttp,
|
||||
dir,
|
||||
url: repoUrl,
|
||||
ref: branch,
|
||||
singleBranch: true,
|
||||
depth: 1,
|
||||
noTags: true,
|
||||
onAuth,
|
||||
}),
|
||||
timeout,
|
||||
]);
|
||||
} catch (e) {
|
||||
throw this.mapGitError(e as Error);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
|
||||
const log = await git.log({ fs: { promises: fsPromises }, dir, ref: branch, depth: 1 });
|
||||
if (!log.length) {
|
||||
throw new GitSourceError('GIT_ERROR', 'Repository has no commits on the requested branch.');
|
||||
}
|
||||
const commitSha = log[0].oid;
|
||||
|
||||
const composeAbs = path.resolve(dir, composePath);
|
||||
if (!composeAbs.startsWith(path.resolve(dir))) {
|
||||
throw new GitSourceError('FILE_NOT_FOUND', 'Compose path resolves outside the repository.');
|
||||
}
|
||||
let composeContent: string;
|
||||
try {
|
||||
composeContent = await fsPromises.readFile(composeAbs, 'utf-8');
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
throw new GitSourceError('FILE_NOT_FOUND', `File not found in repository: ${composePath}`);
|
||||
}
|
||||
throw new GitSourceError('GIT_ERROR', scrubCredentials((e as Error).message));
|
||||
}
|
||||
|
||||
let envContent: string | null = null;
|
||||
if (envPath) {
|
||||
const envAbs = path.resolve(dir, envPath);
|
||||
if (!envAbs.startsWith(path.resolve(dir))) {
|
||||
throw new GitSourceError('FILE_NOT_FOUND', 'Env path resolves outside the repository.');
|
||||
}
|
||||
try {
|
||||
envContent = await fsPromises.readFile(envAbs, 'utf-8');
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
// A missing sibling .env is legitimate (repo may not carry one
|
||||
// in the requested directory). Return null so the caller can
|
||||
// decide whether to warn.
|
||||
envContent = null;
|
||||
} else {
|
||||
throw new GitSourceError('GIT_ERROR', scrubCredentials((e as Error).message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { composeContent, envContent, commitSha };
|
||||
} finally {
|
||||
await removeTempDir(dir);
|
||||
}
|
||||
}
|
||||
|
||||
private mapGitError(err: Error): GitSourceError {
|
||||
const raw = scrubCredentials(err.message || String(err));
|
||||
const code = (err as Error & { code?: string }).code;
|
||||
|
||||
// isomorphic-git error codes
|
||||
if (code === 'HttpError' || /401|403|authentication/i.test(raw)) {
|
||||
return new GitSourceError('AUTH_FAILED', 'Repository authentication failed. Check your token.');
|
||||
}
|
||||
if (code === 'NotFoundError' || /404|not found|could not resolve/i.test(raw)) {
|
||||
return new GitSourceError('REPO_NOT_FOUND', 'Repository not found or not accessible.');
|
||||
}
|
||||
if (code === 'ResolveRefError' || /resolve ref|unknown ref|couldn't find remote ref|reference not found/i.test(raw)) {
|
||||
return new GitSourceError('BRANCH_NOT_FOUND', 'Branch not found in the repository.');
|
||||
}
|
||||
if (code === 'ECONNABORTED' || /timeout|timed out|ETIMEDOUT|ENOTFOUND|ECONNREFUSED/i.test(raw)) {
|
||||
return new GitSourceError('NETWORK_TIMEOUT', 'Network timeout or host unreachable.');
|
||||
}
|
||||
return new GitSourceError('GIT_ERROR', raw);
|
||||
}
|
||||
|
||||
// ─── Validation ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate a compose file by (a) parsing YAML and (b) handing the content
|
||||
* to `docker compose config --quiet` in a throwaway temp dir. This is the
|
||||
* same validator Compose runs at deploy time, so it catches interpolation
|
||||
* errors, invalid `include:` references, etc., which a shallow schema
|
||||
* check would miss.
|
||||
*/
|
||||
public async validateCompose(composeContent: string, envContent: string | null): Promise<{ ok: boolean; error?: string }> {
|
||||
// Cheap syntax pre-check
|
||||
try {
|
||||
const parsed = YAML.parse(composeContent);
|
||||
if (parsed === null || parsed === undefined) {
|
||||
return { ok: false, error: 'Compose file is empty.' };
|
||||
}
|
||||
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return { ok: false, error: 'Compose file must be a YAML mapping.' };
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, error: `YAML parse error: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
// Semantic check via `docker compose config`
|
||||
const dir = await createTempDir();
|
||||
try {
|
||||
const composeFile = path.join(dir, 'compose.yaml');
|
||||
await fsPromises.writeFile(composeFile, composeContent, 'utf-8');
|
||||
const args = ['compose', '-f', composeFile];
|
||||
if (envContent !== null) {
|
||||
const envFile = path.join(dir, '.env');
|
||||
await fsPromises.writeFile(envFile, envContent, 'utf-8');
|
||||
args.push('--env-file', envFile);
|
||||
}
|
||||
args.push('config', '--quiet');
|
||||
const result = await this.runDockerCompose(args, dir, 10_000);
|
||||
if (result.code === 0) return { ok: true };
|
||||
return { ok: false, error: result.stderr.trim() || `docker compose exited with code ${result.code}` };
|
||||
} finally {
|
||||
await removeTempDir(dir);
|
||||
}
|
||||
}
|
||||
|
||||
private runDockerCompose(args: string[], cwd: string, timeoutMs: number): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn('docker', args, { cwd });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const timer = setTimeout(() => {
|
||||
try { child.kill('SIGKILL'); } catch { /* best effort */ }
|
||||
resolve({ code: -1, stdout, stderr: stderr + '\nValidation timed out.' });
|
||||
}, timeoutMs);
|
||||
child.stdout.on('data', d => { stdout += d.toString(); });
|
||||
child.stderr.on('data', d => { stderr += d.toString(); });
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code: code ?? -1, stdout, stderr });
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code: -1, stdout, stderr: stderr + '\n' + err.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Hashing + diff ──────────────────────────────────────────────────────
|
||||
|
||||
public hashContent(compose: string, env: string | null): string {
|
||||
return crypto.createHash('sha256')
|
||||
.update(compose)
|
||||
.update('\x00')
|
||||
.update(env ?? '')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
private async readDiskContent(stackName: string, syncEnv: boolean): Promise<{ compose: string; env: string | null }> {
|
||||
const fsSvc = FileSystemService.getInstance();
|
||||
let compose: string;
|
||||
try {
|
||||
compose = await fsSvc.getStackContent(stackName);
|
||||
} catch {
|
||||
compose = '';
|
||||
}
|
||||
let env: string | null = null;
|
||||
if (syncEnv) {
|
||||
try {
|
||||
env = await fsSvc.getEnvContent(stackName);
|
||||
} catch {
|
||||
env = null;
|
||||
}
|
||||
}
|
||||
return { compose, env };
|
||||
}
|
||||
|
||||
// ─── Pull / apply ────────────────────────────────────────────────────────
|
||||
|
||||
public async pull(stackName: string): Promise<PullResult> {
|
||||
const db = DatabaseService.getInstance();
|
||||
const src = db.getGitSource(stackName);
|
||||
if (!src) throw new GitSourceError('GIT_ERROR', 'No Git source configured for this stack.');
|
||||
|
||||
const token = src.encrypted_token ? this.crypto.decrypt(src.encrypted_token) : null;
|
||||
const fetched = await this.fetchFromGit({
|
||||
repoUrl: src.repo_url,
|
||||
branch: src.branch,
|
||||
composePath: src.compose_path,
|
||||
envPath: src.sync_env ? src.env_path : null,
|
||||
token,
|
||||
});
|
||||
|
||||
const validation = await this.validateCompose(fetched.composeContent, fetched.envContent);
|
||||
const disk = await this.readDiskContent(stackName, src.sync_env);
|
||||
const currentHash = this.hashContent(disk.compose, disk.env);
|
||||
const hasLocalChanges = src.last_applied_content_hash !== null
|
||||
&& src.last_applied_content_hash !== currentHash;
|
||||
|
||||
// Store pending so a subsequent apply doesn't re-fetch. Compose files
|
||||
// routinely contain secrets inlined as env interpolations or passwords,
|
||||
// so encrypt the pending buffers at rest.
|
||||
db.setGitSourcePending(
|
||||
stackName,
|
||||
fetched.commitSha,
|
||||
this.crypto.encrypt(fetched.composeContent),
|
||||
fetched.envContent !== null ? this.crypto.encrypt(fetched.envContent) : null,
|
||||
);
|
||||
|
||||
return {
|
||||
commitSha: fetched.commitSha,
|
||||
incomingCompose: fetched.composeContent,
|
||||
incomingEnv: fetched.envContent,
|
||||
currentCompose: disk.compose,
|
||||
currentEnv: disk.env,
|
||||
validation,
|
||||
hasLocalChanges,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a pending pull. Idempotent under the per-stack mutex: if two
|
||||
* clients hit /apply concurrently, the second one sees cleared pending
|
||||
* columns and gets a clean error rather than double-writing.
|
||||
*/
|
||||
public async apply(stackName: string, commitSha: string, opts: { deploy?: boolean } = {}): Promise<{ applied: boolean; deployed: boolean }> {
|
||||
return this.withStackLock(stackName, async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const src = db.getGitSource(stackName);
|
||||
if (!src) throw new GitSourceError('GIT_ERROR', 'No Git source configured for this stack.');
|
||||
|
||||
if (!src.pending_commit_sha || !src.pending_compose_content) {
|
||||
throw new GitSourceError('GIT_ERROR', 'No pending pull to apply. Fetch the source again.');
|
||||
}
|
||||
if (src.pending_commit_sha !== commitSha) {
|
||||
throw new GitSourceError('GIT_ERROR', 'Pending commit has changed since this pull was fetched. Please review the latest diff.');
|
||||
}
|
||||
|
||||
// Pending buffers are stored encrypted; decrypt is a no-op for any
|
||||
// legacy plaintext rows (isEncrypted check inside CryptoService).
|
||||
const composeContent = this.crypto.decrypt(src.pending_compose_content);
|
||||
const envContent = src.pending_env_content !== null
|
||||
? this.crypto.decrypt(src.pending_env_content)
|
||||
: null;
|
||||
|
||||
// Re-validate before writing.
|
||||
const validation = await this.validateCompose(composeContent, envContent);
|
||||
if (!validation.ok) {
|
||||
throw new GitSourceError('GIT_ERROR', `Compose validation failed: ${validation.error}`);
|
||||
}
|
||||
|
||||
const fsSvc = FileSystemService.getInstance();
|
||||
await fsSvc.saveStackContent(stackName, composeContent);
|
||||
if (src.sync_env && envContent !== null) {
|
||||
await fsSvc.saveEnvContent(stackName, envContent);
|
||||
}
|
||||
|
||||
const hash = this.hashContent(composeContent, envContent);
|
||||
db.markGitSourceApplied(stackName, commitSha, hash);
|
||||
|
||||
const shouldDeploy = opts.deploy ?? src.auto_deploy_on_apply;
|
||||
if (shouldDeploy) {
|
||||
try {
|
||||
await ComposeService.getInstance().deployStack(stackName);
|
||||
return { applied: true, deployed: true };
|
||||
} catch (e) {
|
||||
console.error(`[GitSourceService] Auto-deploy failed for ${stackName}:`, (e as Error).message);
|
||||
throw new GitSourceError('GIT_ERROR', `Applied file but deploy failed: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
return { applied: true, deployed: false };
|
||||
});
|
||||
}
|
||||
|
||||
public dismissPending(stackName: string): void {
|
||||
DatabaseService.getInstance().clearGitSourcePending(stackName);
|
||||
}
|
||||
|
||||
// ─── Webhook-triggered pull ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Invoked by the webhook dispatcher. Returns a short status string to
|
||||
* record in webhook_executions. Enforces the per-source debounce.
|
||||
*/
|
||||
public async handleWebhookPull(stackName: string): Promise<{ status: 'success' | 'skipped' | 'error'; message: string }> {
|
||||
const db = DatabaseService.getInstance();
|
||||
const src = db.getGitSource(stackName);
|
||||
if (!src) {
|
||||
return { status: 'error', message: 'No Git source configured for this stack.' };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (src.last_debounce_at !== null && (now - src.last_debounce_at) < WEBHOOK_DEBOUNCE_MS) {
|
||||
return { status: 'skipped', message: 'Rate limited (debounced).' };
|
||||
}
|
||||
|
||||
try {
|
||||
const pullResult = await this.pull(stackName);
|
||||
// Only burn the debounce window once the fetch actually produced
|
||||
// something. A transient network failure should be retriable
|
||||
// immediately rather than locked out for the debounce interval.
|
||||
db.touchGitSourceDebounce(stackName);
|
||||
if (!pullResult.validation.ok) {
|
||||
return { status: 'error', message: `Validation failed: ${pullResult.validation.error}` };
|
||||
}
|
||||
|
||||
if (!src.auto_apply_on_webhook) {
|
||||
return { status: 'success', message: `Pending update ready at ${pullResult.commitSha.slice(0, 7)}.` };
|
||||
}
|
||||
|
||||
const applied = await this.apply(stackName, pullResult.commitSha, { deploy: src.auto_deploy_on_apply });
|
||||
const suffix = applied.deployed ? ' and deployed' : '';
|
||||
return { status: 'success', message: `Applied commit ${pullResult.commitSha.slice(0, 7)}${suffix}.` };
|
||||
} catch (e) {
|
||||
const msg = e instanceof GitSourceError ? `${e.code}: ${e.message}` : (e as Error).message;
|
||||
return { status: 'error', message: scrubCredentials(msg) };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Concurrency ─────────────────────────────────────────────────────────
|
||||
|
||||
private async withStackLock<T>(stackName: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = this.stackLocks.get(stackName) ?? Promise.resolve();
|
||||
const next = prev.catch(() => { /* swallow previous errors */ }).then(fn);
|
||||
this.stackLocks.set(stackName, next);
|
||||
try {
|
||||
return await next;
|
||||
} finally {
|
||||
// Only clear if the current chain tip is still our promise; otherwise a
|
||||
// later caller has already queued behind us.
|
||||
if (this.stackLocks.get(stackName) === next) {
|
||||
this.stackLocks.delete(stackName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import crypto from 'crypto';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
import { ComposeService } from './ComposeService';
|
||||
import { FileSystemService } from './FileSystemService';
|
||||
import { GitSourceService } from './GitSourceService';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
|
||||
export class WebhookService {
|
||||
@@ -76,6 +77,32 @@ export class WebhookService {
|
||||
case 'pull':
|
||||
await compose.updateStack(webhook.stack_name, undefined, atomic);
|
||||
break;
|
||||
case 'git-pull': {
|
||||
const result = await GitSourceService.getInstance().handleWebhookPull(webhook.stack_name);
|
||||
const duration_ms = Date.now() - startTime;
|
||||
if (result.status === 'error') {
|
||||
db.addWebhookExecution({
|
||||
webhook_id: webhookId,
|
||||
action,
|
||||
status: 'failure',
|
||||
trigger_source: triggerSource,
|
||||
duration_ms,
|
||||
error: result.message,
|
||||
executed_at: Date.now(),
|
||||
});
|
||||
return { success: false, error: result.message, duration_ms };
|
||||
}
|
||||
db.addWebhookExecution({
|
||||
webhook_id: webhookId,
|
||||
action,
|
||||
status: result.status === 'skipped' ? 'failure' : 'success',
|
||||
trigger_source: triggerSource,
|
||||
duration_ms,
|
||||
error: result.status === 'skipped' ? result.message : null,
|
||||
executed_at: Date.now(),
|
||||
});
|
||||
return { success: result.status === 'success', error: result.status === 'skipped' ? result.message : undefined, duration_ms };
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown action: ${action}`);
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
"features/alerts-notifications",
|
||||
"features/notification-routing",
|
||||
"features/webhooks",
|
||||
"features/git-sources",
|
||||
"features/rbac",
|
||||
"features/atomic-deployments",
|
||||
"features/fleet-backups",
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
---
|
||||
title: Git Sources
|
||||
description: Link a stack to a Git repository and keep compose.yaml in sync via manual pulls or webhook triggers.
|
||||
---
|
||||
|
||||
Git Sources turn any stack into a GitOps target. Point Sencho at a repository, branch, and `compose.yaml` path; pull updates on demand or from CI; and review a diff before applying changes to disk. Optional sibling `.env` sync keeps configuration consistent too.
|
||||
|
||||
Git Sources are available to all Sencho users on the Community tier.
|
||||
|
||||
## How it works
|
||||
|
||||
1. Open a stack's editor and click **Git Source**.
|
||||
2. Fill in the repository URL, branch, and compose file path. Add a token if the repo is private.
|
||||
3. Click **Pull now** to fetch the latest compose content. Sencho shows a side-by-side diff.
|
||||
4. Click **Apply** to write the incoming content to disk. Optionally deploy immediately.
|
||||
|
||||
Writes land in the stack's existing directory using the same storage Sencho uses for the in-browser editor. Existing history, alerts, and metrics are unaffected.
|
||||
|
||||
## Configure a source
|
||||
|
||||
<Frame>
|
||||
<img src="/images/git-sources/panel.png" alt="Git Source panel with repository URL, branch, compose path, and apply mode" />
|
||||
</Frame>
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Repository URL** | `https://github.com/your-org/your-repo.git` (HTTPS only) |
|
||||
| **Branch** | Branch to track (e.g. `main`) |
|
||||
| **Compose file path** | Path within the repo (e.g. `deploy/compose.yaml`) |
|
||||
| **Also sync sibling `.env`** | When enabled, also pulls `.env` from the same directory as the compose file |
|
||||
| **Auth** | `None` for public repos, `Personal Access Token` for private repos |
|
||||
| **Apply behavior** | See the three modes below |
|
||||
|
||||
Saving runs a reachability check against the repository. If the URL is wrong, the token is invalid, the branch does not exist, or the file is missing, Sencho surfaces the error inline and nothing is persisted.
|
||||
|
||||
### Apply behavior modes
|
||||
|
||||
| Mode | What happens when a webhook fires |
|
||||
|------|-----------------------------------|
|
||||
| **Review only** | Sencho fetches + validates the incoming commit and marks the stack as having a pending update. You review the diff and apply manually. |
|
||||
| **Auto-write** | Sencho writes the new compose + env to disk automatically but does not redeploy. Use this when another process handles rollout. |
|
||||
| **Auto-deploy** | Sencho writes the files and immediately runs `docker compose up -d` so the stack picks up the new configuration. |
|
||||
|
||||
You can always override on the spot: when you click **Apply** in the diff dialog, a **Deploy after apply** checkbox lets you deploy regardless of the configured mode.
|
||||
|
||||
## Pulling and reviewing changes
|
||||
|
||||
Click **Pull now** on the Git Source panel to fetch the latest commit on the configured branch.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/git-sources/diff-dialog.png" alt="Diff dialog showing a side-by-side comparison between the on-disk compose.yaml and the incoming commit" />
|
||||
</Frame>
|
||||
|
||||
The diff dialog shows:
|
||||
|
||||
- The commit sha being compared (short form, next to the stack name)
|
||||
- A side-by-side compare of the on-disk compose file and the incoming version
|
||||
- A `.env` tab when the source is configured to sync `.env`
|
||||
- A **validation** banner when the incoming compose fails `docker compose config` (you cannot apply an invalid file)
|
||||
- A **local edits detected** banner when the on-disk content differs from the last applied commit. Applying in this state overwrites those edits. The Apply button becomes a confirmation prompt.
|
||||
|
||||
### Pending updates
|
||||
|
||||
When a webhook fires in **Review only** mode, the stack gets a pending update badge in the sidebar and a dot on the **Git Source** button in the editor. Clicking either opens the diff dialog with the incoming content already loaded; there's no second network round-trip to apply.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/git-sources/sidebar-badge.png" alt="Sidebar stack entry with a small branded dot indicating a pending Git source update" />
|
||||
</Frame>
|
||||
|
||||
Click **Dismiss** on the Git Source panel to discard a pending update without applying.
|
||||
|
||||
## Trigger from CI with a webhook
|
||||
|
||||
Git sources integrate with Sencho's existing webhook system. Create a webhook targeting the stack with the **Git source sync** action.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/git-sources/webhook-action.png" alt="Webhook creation form with Git source sync selected in the action dropdown" />
|
||||
</Frame>
|
||||
|
||||
The webhook's behavior on trigger depends on the source's apply mode:
|
||||
|
||||
- **Review only**: fetch + validate + diff, mark pending.
|
||||
- **Auto-write**: fetch, validate, write to disk.
|
||||
- **Auto-deploy**: fetch, validate, write, deploy.
|
||||
|
||||
The Git source sync action is only selectable on webhooks whose target stack already has a Git source configured. Webhook triggers for a single source are debounced so a runaway pipeline cannot overwhelm Sencho (or your repository host's rate limits); the dashboard records the skipped trigger in the webhook's execution history.
|
||||
|
||||
### GitHub Actions example
|
||||
|
||||
```yaml
|
||||
- name: Sync compose via Sencho
|
||||
run: |
|
||||
BODY='{}'
|
||||
SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "${{ secrets.SENCHO_WEBHOOK_SECRET }}" | cut -d' ' -f2)
|
||||
curl -X POST "${{ secrets.SENCHO_URL }}/api/webhooks/${{ secrets.SENCHO_WEBHOOK_ID }}/trigger" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Webhook-Signature: sha256=$SIGNATURE" \
|
||||
-d "$BODY"
|
||||
```
|
||||
|
||||
See the [Webhooks](/features/webhooks) page for the full signing protocol.
|
||||
|
||||
## Private repositories
|
||||
|
||||
For private repositories, use a Personal Access Token scoped to read access on the target repo:
|
||||
|
||||
- **GitHub**: a fine-grained PAT with **Contents: Read** permission on the repo, or a classic PAT with the `repo` scope.
|
||||
- **GitLab**: a project or group access token with the `read_repository` scope.
|
||||
- **Bitbucket**: an app password with **Repositories: Read**.
|
||||
|
||||
Paste the token into the **Token** field and save. Sencho stores it encrypted at rest and never returns it in API responses or UI. When editing the source later, the token field shows a masked placeholder; leave it blank to keep the stored value, or type a new token to replace it. Switching the auth type to **None** clears the stored token.
|
||||
|
||||
## Local edits vs Git
|
||||
|
||||
Sencho tracks a hash of the compose + env contents at the moment of the last apply. When you pull, it compares that hash against the current on-disk content.
|
||||
|
||||
- Matching hash: applying overwrites content that Sencho itself last wrote.
|
||||
- Differing hash: someone edited the files outside Git. The diff dialog shows a warning, and Apply requires confirmation.
|
||||
|
||||
The in-browser editor and the Git Source panel both write to the same files, so you can always fall back to editing locally. The next pull will just flag the divergence rather than silently clobbering your edits.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Repository not found or not accessible">
|
||||
Verify the URL is reachable from the Sencho host and ends with `.git`. For private repos, confirm the token is present and has read access. If you rotated the token, open the panel and paste the new value.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Authentication failed">
|
||||
Your token is missing, expired, or lacks read access to the repository. Generate a new token and replace the value in the **Token** field.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Branch not found">
|
||||
The branch name is case-sensitive and must exist on the remote. Confirm the branch with `git ls-remote <url>` from a shell that has access.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="File not found">
|
||||
The compose path is relative to the repository root and must point at the file, not its parent directory. If the file was moved, update the path on the panel and save.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Compose validation failed">
|
||||
Sencho runs `docker compose config` against the incoming content before letting you apply. The error banner shows the exact message. Common causes: unresolved `${VAR}` interpolation (fix by enabling sibling `.env` sync and committing the file), invalid `include:` paths, or schema issues introduced by a recent compose change.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Local edits detected">
|
||||
The on-disk files diverge from the last applied Git commit. Either apply anyway to overwrite the local edits (the confirmation prompt makes this explicit), or discard local work with a redeploy from the stack editor, or commit your local changes back to the repo so the diff becomes clean.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Webhook skipped (rate limited)">
|
||||
Sencho debounces rapid-fire triggers per source. Wait a few seconds and retry, or consolidate multiple CI triggers into a single call at the end of your pipeline.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Network timeout">
|
||||
The clone did not finish in time. Check that the Sencho host can reach the repository host (proxies, firewalls, DNS) and try again.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
<Note>
|
||||
Git Sources currently use HTTPS only. SSH URLs and SSH keys are not supported.
|
||||
</Note>
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Git Sources E2E - configure, save, pull, remove.
|
||||
*
|
||||
* These tests use a throwaway stack that is created via the browser's
|
||||
* authenticated fetch (so cookies are carried) and cleaned up in afterAll.
|
||||
* Pull tests use an unreachable URL on purpose so the suite does not depend
|
||||
* on real network egress or a specific upstream repo being available.
|
||||
*/
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { loginAs } from './helpers';
|
||||
|
||||
const TEST_STACK = 'e2e-git-source-stack';
|
||||
|
||||
async function createTestStackViaApi(page: Page) {
|
||||
return page.evaluate(async (name) => {
|
||||
const res = await fetch(`/api/stacks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ stackName: name }),
|
||||
});
|
||||
return res.status;
|
||||
}, TEST_STACK);
|
||||
}
|
||||
|
||||
async function deleteTestStackViaApi(page: Page) {
|
||||
await page.evaluate(async (name) => {
|
||||
// Drop any orphaned git-source row first (safe even if the stack is already gone).
|
||||
await fetch(`/api/stacks/${name}/git-source`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
|
||||
await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
|
||||
}, TEST_STACK);
|
||||
}
|
||||
|
||||
async function openGitSourcePanel(page: Page) {
|
||||
await page.getByText(TEST_STACK).first().click();
|
||||
const gitBtn = page.getByRole('button', { name: /Git Source/i });
|
||||
await expect(gitBtn).toBeVisible({ timeout: 10_000 });
|
||||
await gitBtn.click();
|
||||
await expect(page.getByRole('dialog').getByText('Git Source', { exact: false })).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
test.describe('Git Sources', () => {
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const page = await browser.newPage();
|
||||
await loginAs(page);
|
||||
await deleteTestStackViaApi(page);
|
||||
await createTestStackViaApi(page);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
const page = await browser.newPage();
|
||||
await loginAs(page);
|
||||
await deleteTestStackViaApi(page);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page);
|
||||
await expect(page.getByRole('button', { name: 'Create Stack' })).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator('[data-stacks-loaded="true"]')).toBeAttached({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('rejects non-HTTPS repository URLs client-side', async ({ page }) => {
|
||||
await openGitSourcePanel(page);
|
||||
|
||||
await page.locator('#git-source-repo').fill('git@github.com:org/repo.git');
|
||||
await page.locator('#git-source-branch').fill('main');
|
||||
await page.locator('#git-source-path').fill('compose.yaml');
|
||||
|
||||
await page.getByRole('dialog').getByRole('button', { name: /^Save$/ }).click();
|
||||
|
||||
await expect(page.getByText(/Only HTTPS repository URLs are supported/i)).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test('surfaces reachability error on save with unreachable repo', async ({ page }) => {
|
||||
await openGitSourcePanel(page);
|
||||
|
||||
// Use a URL that resolves but returns 404 for the git protocol so the dry-run
|
||||
// fetch fails with a clean error. reserved-TLDs like .invalid trigger DNS failure
|
||||
// which maps to NETWORK_TIMEOUT or REPO_NOT_FOUND.
|
||||
await page.locator('#git-source-repo').fill('https://git.invalid.example/nope/nope.git');
|
||||
await page.locator('#git-source-branch').fill('main');
|
||||
await page.locator('#git-source-path').fill('compose.yaml');
|
||||
|
||||
await page.getByRole('dialog').getByRole('button', { name: /^Save$/ }).click();
|
||||
|
||||
// Any of the mapped error messages is acceptable; the key is that nothing
|
||||
// persisted silently and the user sees a toast.
|
||||
await expect(
|
||||
page.getByText(/not found|unreachable|network|timeout|authentication failed/i).first(),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('configure, view pending-empty state, and remove via AlertDialog', async ({ page }) => {
|
||||
// Seed a git source directly via API so we can exercise the remove-confirm
|
||||
// flow without depending on a reachable upstream.
|
||||
const putStatus = await page.evaluate(async (name) => {
|
||||
const res = await fetch(`/api/stacks/${name}/git-source`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
repo_url: 'https://github.com/docker/awesome-compose.git',
|
||||
branch: 'master',
|
||||
compose_path: 'nginx-golang/compose.yaml',
|
||||
sync_env: false,
|
||||
auth_type: 'none',
|
||||
auto_apply_on_webhook: false,
|
||||
auto_deploy_on_apply: false,
|
||||
}),
|
||||
});
|
||||
return res.status;
|
||||
}, TEST_STACK);
|
||||
|
||||
// Either the dry-run succeeded (2xx) or the network blocked it (4xx/5xx).
|
||||
// If it failed, skip the rest of the remove flow to keep the suite robust.
|
||||
if (putStatus >= 400) {
|
||||
test.skip(true, `Upstream dry-run returned ${putStatus}; skipping remove path`);
|
||||
return;
|
||||
}
|
||||
|
||||
await openGitSourcePanel(page);
|
||||
|
||||
// Source should render with the saved repo URL.
|
||||
await expect(page.locator('#git-source-repo')).toHaveValue(/awesome-compose/);
|
||||
|
||||
// Click Remove → AlertDialog appears → confirm → source cleared.
|
||||
await page.getByRole('dialog').getByRole('button', { name: /Remove/i }).click();
|
||||
await expect(page.getByRole('alertdialog')).toBeVisible({ timeout: 5_000 });
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: /^Remove$/ }).click();
|
||||
|
||||
// After removal, the "Remove" button is gone from the panel footer.
|
||||
await expect(page.getByRole('dialog').getByRole('button', { name: /^Remove$/ })).not.toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ import { Highlight, HighlightItem } from './animate-ui/primitives/effects/highli
|
||||
import { CursorProvider, Cursor, CursorContainer, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown } from 'lucide-react';
|
||||
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown, GitBranch } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { LabelPill, LabelDot } from './LabelPill';
|
||||
import { type Label as StackLabel } from './label-types';
|
||||
@@ -44,6 +44,7 @@ import { Sheet, SheetContent, SheetTrigger } from './ui/sheet';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SettingsModal } from './SettingsModal';
|
||||
import { StackAlertSheet } from './StackAlertSheet';
|
||||
import { GitSourcePanel } from './stack/GitSourcePanel';
|
||||
import { AppStoreView } from './AppStoreView';
|
||||
import { LogViewer } from './LogViewer';
|
||||
import { GlobalObservabilityView } from './GlobalObservabilityView';
|
||||
@@ -123,6 +124,8 @@ export default function EditorLayout() {
|
||||
// the stale-closure bug that occurs when reading containerStats directly.
|
||||
const rawBytesRef = useRef<Record<string, { lastRx: number; lastTx: number }>>({});
|
||||
const [activeTab, setActiveTab] = useState<'compose' | 'env'>('compose');
|
||||
const [gitSourceOpen, setGitSourceOpen] = useState(false);
|
||||
const [gitSourcePendingMap, setGitSourcePendingMap] = useState<Record<string, boolean>>({});
|
||||
const monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
|
||||
const pendingStackLoadRef = useRef<string | null>(null);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
@@ -415,6 +418,26 @@ export default function EditorLayout() {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Populate the per-stack "pending git source update" map. Runs on mount and
|
||||
* whenever a git-source change is signalled by the panel. Backend failure
|
||||
* leaves the map empty, which is the correct fallback (no badges shown).
|
||||
*/
|
||||
const refreshGitSourcePending = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/git-sources');
|
||||
if (!res.ok) return;
|
||||
const sources: Array<{ stack_name: string; pending_commit_sha: string | null }> = await res.json();
|
||||
const map: Record<string, boolean> = {};
|
||||
for (const s of sources) {
|
||||
if (s.pending_commit_sha) map[s.stack_name] = true;
|
||||
}
|
||||
setGitSourcePendingMap(map);
|
||||
} catch {
|
||||
// Non-critical; leave prior state.
|
||||
}
|
||||
};
|
||||
|
||||
const handleScanStacks = async () => {
|
||||
if (isScanning) return;
|
||||
setIsScanning(true);
|
||||
@@ -616,6 +639,7 @@ export default function EditorLayout() {
|
||||
|
||||
refreshStacks();
|
||||
fetchImageUpdates();
|
||||
refreshGitSourcePending();
|
||||
|
||||
// Poll for image update results every 5 minutes so background checks are picked up
|
||||
const imageUpdateInterval = setInterval(fetchImageUpdates, 5 * 60 * 1000);
|
||||
@@ -1566,6 +1590,27 @@ export default function EditorLayout() {
|
||||
</CursorProvider>
|
||||
)}
|
||||
|
||||
{gitSourcePendingMap[file] && (
|
||||
<CursorProvider>
|
||||
<CursorContainer className="inline-flex items-center shrink-0">
|
||||
<GitBranch className="w-3 h-3 text-brand" strokeWidth={1.5} />
|
||||
</CursorContainer>
|
||||
<Cursor>
|
||||
<div className="h-2 w-2 rounded-full bg-brand" />
|
||||
</Cursor>
|
||||
<CursorFollow
|
||||
side="bottom"
|
||||
sideOffset={4}
|
||||
align="center"
|
||||
transition={{ stiffness: 400, damping: 40, bounce: 0 }}
|
||||
>
|
||||
<div className="rounded-md border border-card-border bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] px-2.5 py-1.5 shadow-md">
|
||||
<span className="font-mono text-xs tabular-nums text-stat-value">Git source update pending</span>
|
||||
</div>
|
||||
</CursorFollow>
|
||||
</CursorProvider>
|
||||
)}
|
||||
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -2179,7 +2224,19 @@ export default function EditorLayout() {
|
||||
)}
|
||||
</div>
|
||||
{can('stack:edit', 'stack', stackName) && (
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-lg relative"
|
||||
onClick={() => setGitSourceOpen(true)}
|
||||
>
|
||||
<GitBranch className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
Git Source
|
||||
{gitSourcePendingMap[stackName] && (
|
||||
<span className="absolute -top-1 -right-1 w-2.5 h-2.5 rounded-full bg-brand animate-pulse" />
|
||||
)}
|
||||
</Button>
|
||||
{!isEditing ? (
|
||||
<Button size="sm" variant="default" className="rounded-lg" onClick={enterEditMode}>
|
||||
<Pencil className="w-4 h-4 mr-2" />
|
||||
@@ -2425,6 +2482,18 @@ export default function EditorLayout() {
|
||||
onClose={() => setAlertSheetOpen(false)}
|
||||
stackName={alertSheetStack}
|
||||
/>
|
||||
|
||||
{/* Git Source Panel */}
|
||||
{stackName && (
|
||||
<GitSourcePanel
|
||||
open={gitSourceOpen}
|
||||
onOpenChange={setGitSourceOpen}
|
||||
stackName={stackName}
|
||||
canEdit={can('stack:edit', 'stack', stackName)}
|
||||
isDarkMode={isDarkMode}
|
||||
onSourceChanged={refreshGitSourcePending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,6 +191,7 @@ export function WebhooksSection({ isPaid }: { isPaid: boolean }) {
|
||||
<SelectItem value="stop">Stop</SelectItem>
|
||||
<SelectItem value="start">Start</SelectItem>
|
||||
<SelectItem value="pull">Pull & Update</SelectItem>
|
||||
<SelectItem value="git-pull">Git source sync</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useState } from 'react';
|
||||
import { DiffEditor } from '@monaco-editor/react';
|
||||
import { AlertTriangle, GitBranch, Loader2 } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { springs } from '@/lib/motion';
|
||||
|
||||
export interface PullResult {
|
||||
commitSha: string;
|
||||
incomingCompose: string;
|
||||
incomingEnv: string | null;
|
||||
currentCompose: string;
|
||||
currentEnv: string | null;
|
||||
validation: { ok: boolean; error?: string };
|
||||
hasLocalChanges: boolean;
|
||||
}
|
||||
|
||||
interface GitSourceDiffDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
stackName: string;
|
||||
pull: PullResult | null;
|
||||
syncEnv: boolean;
|
||||
autoDeployDefault: boolean;
|
||||
isDarkMode: boolean;
|
||||
applying: boolean;
|
||||
onApply: (commitSha: string, deploy: boolean) => Promise<void>;
|
||||
onDismiss: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function GitSourceDiffDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
stackName,
|
||||
pull,
|
||||
syncEnv,
|
||||
autoDeployDefault,
|
||||
isDarkMode,
|
||||
applying,
|
||||
onApply,
|
||||
onDismiss,
|
||||
}: GitSourceDiffDialogProps) {
|
||||
const [diffTab, setDiffTab] = useState<'compose' | 'env'>('compose');
|
||||
const [deployAfter, setDeployAfter] = useState<boolean>(autoDeployDefault);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
const envAvailable = syncEnv && pull?.incomingEnv !== null;
|
||||
const effectiveTab = envAvailable ? diffTab : 'compose';
|
||||
|
||||
if (!pull) return null;
|
||||
|
||||
const shortSha = pull.commitSha.slice(0, 7);
|
||||
|
||||
const apply = async () => {
|
||||
await onApply(pull.commitSha, deployAfter);
|
||||
};
|
||||
|
||||
const handleApplyClick = () => {
|
||||
if (pull.hasLocalChanges) {
|
||||
setConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
apply();
|
||||
};
|
||||
|
||||
const currentValue = effectiveTab === 'compose' ? pull.currentCompose : (pull.currentEnv ?? '');
|
||||
const incomingValue = effectiveTab === 'compose' ? pull.incomingCompose : (pull.incomingEnv ?? '');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-5xl w-[95vw] p-0 gap-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b border-glass-border">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitBranch className="w-4 h-4" strokeWidth={1.5} />
|
||||
<span>Review update for</span>
|
||||
<span className="font-mono tabular-nums">{stackName}</span>
|
||||
<span className="font-mono tabular-nums text-xs text-stat-subtitle">@{shortSha}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Review the diff between the current on-disk stack files and the incoming Git commit.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pt-4 space-y-3">
|
||||
{!pull.validation.ok && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" strokeWidth={1.5} />
|
||||
<div>
|
||||
<p className="font-medium">Incoming compose failed validation</p>
|
||||
<pre className="font-mono text-[11px] whitespace-pre-wrap mt-1">{pull.validation.error}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{pull.hasLocalChanges && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" strokeWidth={1.5} />
|
||||
<div>
|
||||
<p className="font-medium">Local edits detected on disk</p>
|
||||
<p className="mt-0.5">Applying will overwrite changes that differ from the last applied commit.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{envAvailable && (
|
||||
<Tabs value={diffTab} onValueChange={(v) => setDiffTab(v as 'compose' | 'env')}>
|
||||
<TabsList>
|
||||
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
|
||||
<TabsHighlightItem value="compose">
|
||||
<TabsTrigger value="compose">compose.yaml</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
<TabsHighlightItem value="env">
|
||||
<TabsTrigger value="env">.env</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
</TabsHighlight>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 pb-4 pt-3">
|
||||
<div className="h-[55vh] border border-glass-border rounded-md overflow-hidden">
|
||||
<DiffEditor
|
||||
height="100%"
|
||||
language={effectiveTab === 'compose' ? 'yaml' : 'ini'}
|
||||
theme={isDarkMode ? 'vs-dark' : 'vs'}
|
||||
original={currentValue}
|
||||
modified={incomingValue}
|
||||
options={{
|
||||
readOnly: true,
|
||||
renderSideBySide: true,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
fontFamily: "'Geist Mono', monospace",
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-6 py-4 border-t border-glass-border flex flex-row items-center justify-between sm:justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="git-source-deploy-after"
|
||||
checked={deployAfter}
|
||||
onCheckedChange={(checked) => setDeployAfter(checked === true)}
|
||||
disabled={applying || !pull.validation.ok}
|
||||
/>
|
||||
<Label htmlFor="git-source-deploy-after" className="text-xs cursor-pointer">
|
||||
Deploy after apply
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onDismiss()}
|
||||
disabled={applying}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleApplyClick}
|
||||
disabled={applying || !pull.validation.ok}
|
||||
>
|
||||
{applying ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />
|
||||
Applying...
|
||||
</>
|
||||
) : (
|
||||
'Apply'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Overwrite local edits?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The on-disk stack files differ from the last applied commit. Applying this pull will replace them with the incoming content.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={applying}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
setConfirmOpen(false);
|
||||
await apply();
|
||||
}}
|
||||
disabled={applying}
|
||||
>
|
||||
Overwrite and apply
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { GitBranch, Loader2, Trash2, RefreshCw, Save, AlertCircle } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { GitSourceDiffDialog, type PullResult } from './GitSourceDiffDialog';
|
||||
|
||||
export interface GitSource {
|
||||
id: number;
|
||||
stack_name: string;
|
||||
repo_url: string;
|
||||
branch: string;
|
||||
compose_path: string;
|
||||
sync_env: boolean;
|
||||
env_path: string | null;
|
||||
auth_type: 'none' | 'token';
|
||||
has_token: boolean;
|
||||
auto_apply_on_webhook: boolean;
|
||||
auto_deploy_on_apply: boolean;
|
||||
last_applied_commit_sha: string | null;
|
||||
pending_commit_sha: string | null;
|
||||
pending_fetched_at: number | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
type ApplyMode = 'review' | 'auto-write' | 'auto-deploy';
|
||||
|
||||
interface GitSourcePanelProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
stackName: string;
|
||||
canEdit: boolean;
|
||||
isDarkMode: boolean;
|
||||
/** Called after any change that may affect the sidebar pending-badge. */
|
||||
onSourceChanged?: () => void;
|
||||
}
|
||||
|
||||
function deriveApplyMode(source: GitSource | null, pendingMode: ApplyMode | null): ApplyMode {
|
||||
if (pendingMode) return pendingMode;
|
||||
if (!source) return 'review';
|
||||
if (!source.auto_apply_on_webhook) return 'review';
|
||||
return source.auto_deploy_on_apply ? 'auto-deploy' : 'auto-write';
|
||||
}
|
||||
|
||||
export function GitSourcePanel({
|
||||
open,
|
||||
onOpenChange,
|
||||
stackName,
|
||||
canEdit,
|
||||
isDarkMode,
|
||||
onSourceChanged,
|
||||
}: GitSourcePanelProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [pulling, setPulling] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [source, setSource] = useState<GitSource | null>(null);
|
||||
|
||||
const [repoUrl, setRepoUrl] = useState('');
|
||||
const [branch, setBranch] = useState('main');
|
||||
const [composePath, setComposePath] = useState('compose.yaml');
|
||||
const [syncEnv, setSyncEnv] = useState(false);
|
||||
const [authType, setAuthType] = useState<'none' | 'token'>('none');
|
||||
const [token, setToken] = useState('');
|
||||
const [applyModeOverride, setApplyModeOverride] = useState<ApplyMode | null>(null);
|
||||
|
||||
const [pull, setPull] = useState<PullResult | null>(null);
|
||||
const [diffOpen, setDiffOpen] = useState(false);
|
||||
const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false);
|
||||
|
||||
const applyMode = deriveApplyMode(source, applyModeOverride);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source`);
|
||||
if (res.ok) {
|
||||
const data: GitSource = await res.json();
|
||||
setSource(data);
|
||||
setRepoUrl(data.repo_url);
|
||||
setBranch(data.branch);
|
||||
setComposePath(data.compose_path);
|
||||
setSyncEnv(data.sync_env);
|
||||
setAuthType(data.auth_type);
|
||||
setToken('');
|
||||
setApplyModeOverride(null);
|
||||
} else if (res.status === 404) {
|
||||
setSource(null);
|
||||
setRepoUrl('');
|
||||
setBranch('main');
|
||||
setComposePath('compose.yaml');
|
||||
setSyncEnv(false);
|
||||
setAuthType('none');
|
||||
setToken('');
|
||||
setApplyModeOverride(null);
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || 'Failed to load Git source.');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error((e as Error)?.message || 'Network error.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stackName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
void load();
|
||||
}
|
||||
}, [open, load]);
|
||||
|
||||
const save = async () => {
|
||||
if (!repoUrl.trim() || !branch.trim() || !composePath.trim()) {
|
||||
toast.error('Repository URL, branch, and compose path are required.');
|
||||
return;
|
||||
}
|
||||
if (!/^https?:\/\//i.test(repoUrl.trim())) {
|
||||
toast.error('Only HTTPS repository URLs are supported.');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
const loadingId = toast.loading('Verifying repository access...');
|
||||
try {
|
||||
const autoApply = applyMode !== 'review';
|
||||
const autoDeploy = applyMode === 'auto-deploy';
|
||||
const body: Record<string, unknown> = {
|
||||
repo_url: repoUrl.trim(),
|
||||
branch: branch.trim(),
|
||||
compose_path: composePath.trim(),
|
||||
sync_env: syncEnv,
|
||||
auth_type: authType,
|
||||
auto_apply_on_webhook: autoApply,
|
||||
auto_deploy_on_apply: autoDeploy,
|
||||
};
|
||||
if (authType === 'token' && token !== '') {
|
||||
body.token = token;
|
||||
}
|
||||
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data: GitSource = await res.json();
|
||||
setSource(data);
|
||||
setToken('');
|
||||
setApplyModeOverride(null);
|
||||
toast.success('Git source saved.');
|
||||
onSourceChanged?.();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || 'Failed to save Git source.');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error((e as Error)?.message || 'Network error.');
|
||||
} finally {
|
||||
toast.dismiss(loadingId);
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!source) return;
|
||||
setRemoveConfirmOpen(false);
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success('Git source removed.');
|
||||
setSource(null);
|
||||
onSourceChanged?.();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || 'Failed to remove Git source.');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error((e as Error)?.message || 'Network error.');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pullNow = async () => {
|
||||
if (!source) return;
|
||||
setPulling(true);
|
||||
const loadingId = toast.loading('Fetching from Git...');
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source/pull`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (res.ok) {
|
||||
const data: PullResult = await res.json();
|
||||
setPull(data);
|
||||
setDiffOpen(true);
|
||||
onSourceChanged?.();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || 'Pull failed.');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error((e as Error)?.message || 'Network error.');
|
||||
} finally {
|
||||
toast.dismiss(loadingId);
|
||||
setPulling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyPull = async (commitSha: string, deploy: boolean) => {
|
||||
setApplying(true);
|
||||
const loadingId = toast.loading(deploy ? 'Applying and deploying...' : 'Applying changes...');
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source/apply`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ commitSha, deploy }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data: { applied: boolean; deployed: boolean } = await res.json();
|
||||
toast.success(data.deployed ? 'Applied and deployed.' : 'Applied successfully.');
|
||||
setDiffOpen(false);
|
||||
setPull(null);
|
||||
await load();
|
||||
onSourceChanged?.();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || 'Apply failed.');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error((e as Error)?.message || 'Network error.');
|
||||
} finally {
|
||||
toast.dismiss(loadingId);
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const dismissPending = async () => {
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source/dismiss-pending`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (res.ok) {
|
||||
setDiffOpen(false);
|
||||
setPull(null);
|
||||
await load();
|
||||
onSourceChanged?.();
|
||||
toast.success('Pending update dismissed.');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error((e as Error)?.message || 'Network error.');
|
||||
}
|
||||
};
|
||||
|
||||
const radioOption = (mode: ApplyMode, title: string, description: string) => (
|
||||
<button
|
||||
type="button"
|
||||
key={mode}
|
||||
onClick={() => canEdit && setApplyModeOverride(mode)}
|
||||
disabled={!canEdit}
|
||||
className={cn(
|
||||
'w-full text-left rounded-md border px-3 py-2 transition-colors',
|
||||
applyMode === mode
|
||||
? 'border-brand/60 bg-brand/5'
|
||||
: 'border-glass-border hover:border-card-border-hover',
|
||||
!canEdit && 'cursor-not-allowed opacity-60',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className={cn(
|
||||
'w-3.5 h-3.5 rounded-full border mt-0.5 shrink-0 transition-colors',
|
||||
applyMode === mode ? 'border-brand bg-brand' : 'border-stat-subtitle',
|
||||
)} />
|
||||
<div>
|
||||
<p className="text-xs font-medium">{title}</p>
|
||||
<p className="text-[11px] text-stat-subtitle mt-0.5">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-xl w-[95vw] p-0 gap-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b border-glass-border">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitBranch className="w-4 h-4" strokeWidth={1.5} />
|
||||
Git Source
|
||||
<span className="font-mono tabular-nums text-xs text-stat-subtitle">{stackName}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Link this stack to a Git repository so compose updates can be pulled on demand or via webhook.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[70vh]">
|
||||
<div className="px-6 py-5 space-y-5">
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{source?.pending_commit_sha && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-brand/30 bg-brand/5 px-3 py-2 text-xs">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5 text-brand" strokeWidth={1.5} />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">Pending update</p>
|
||||
<p className="text-stat-subtitle mt-0.5">
|
||||
Commit <span className="font-mono tabular-nums">{source.pending_commit_sha.slice(0, 7)}</span> is ready to review.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7"
|
||||
onClick={() => pullNow()}
|
||||
disabled={pulling}
|
||||
>
|
||||
Review
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="git-source-repo">Repository URL</Label>
|
||||
<Input
|
||||
id="git-source-repo"
|
||||
placeholder="https://github.com/org/repo.git"
|
||||
value={repoUrl}
|
||||
onChange={(e) => setRepoUrl(e.target.value)}
|
||||
disabled={!canEdit || saving}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="git-source-branch">Branch</Label>
|
||||
<Input
|
||||
id="git-source-branch"
|
||||
placeholder="main"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
disabled={!canEdit || saving}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="git-source-path">Compose file path</Label>
|
||||
<Input
|
||||
id="git-source-path"
|
||||
placeholder="compose.yaml"
|
||||
value={composePath}
|
||||
onChange={(e) => setComposePath(e.target.value)}
|
||||
disabled={!canEdit || saving}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="git-source-sync-env"
|
||||
checked={syncEnv}
|
||||
onCheckedChange={(c) => setSyncEnv(c === true)}
|
||||
disabled={!canEdit || saving}
|
||||
/>
|
||||
<Label htmlFor="git-source-sync-env" className="text-xs cursor-pointer">
|
||||
Also sync sibling <span className="font-mono">.env</span> file
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Authentication</Label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canEdit && setAuthType('none')}
|
||||
disabled={!canEdit || saving}
|
||||
className={cn(
|
||||
'flex-1 rounded-md border px-3 py-1.5 text-xs transition-colors',
|
||||
authType === 'none'
|
||||
? 'border-brand/60 bg-brand/5'
|
||||
: 'border-glass-border hover:border-card-border-hover',
|
||||
)}
|
||||
>
|
||||
Public (no auth)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canEdit && setAuthType('token')}
|
||||
disabled={!canEdit || saving}
|
||||
className={cn(
|
||||
'flex-1 rounded-md border px-3 py-1.5 text-xs transition-colors',
|
||||
authType === 'token'
|
||||
? 'border-brand/60 bg-brand/5'
|
||||
: 'border-glass-border hover:border-card-border-hover',
|
||||
)}
|
||||
>
|
||||
Personal Access Token
|
||||
</button>
|
||||
</div>
|
||||
{authType === 'token' && (
|
||||
<div className="space-y-1.5">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={source?.has_token ? '•••••••• (leave blank to keep current)' : 'ghp_xxx... or glpat-xxx...'}
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
disabled={!canEdit || saving}
|
||||
className="font-mono text-xs"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-[11px] text-stat-subtitle">
|
||||
Token is encrypted at rest and never returned from the API.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Apply behavior</Label>
|
||||
<div className="space-y-1.5">
|
||||
{radioOption('review', 'Review only', 'Webhook fetches and flags a pending diff. You apply manually.')}
|
||||
{radioOption('auto-write', 'Auto-write files', 'Webhook writes to disk. You deploy manually.')}
|
||||
{radioOption('auto-deploy', 'Auto-deploy', 'Webhook writes and deploys in one step.')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{source && (
|
||||
<div className="rounded-md border border-glass-border bg-muted/30 px-3 py-2 text-[11px] text-stat-subtitle space-y-0.5">
|
||||
<div className="flex justify-between gap-2">
|
||||
<span>Last applied commit</span>
|
||||
<span className="font-mono tabular-nums">
|
||||
{source.last_applied_commit_sha ? source.last_applied_commit_sha.slice(0, 7) : 'never'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<span>Updated</span>
|
||||
<span className="font-mono tabular-nums">
|
||||
{new Date(source.updated_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="px-6 py-4 border-t border-glass-border flex items-center justify-between gap-2">
|
||||
<div>
|
||||
{source && canEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setRemoveConfirmOpen(true)}
|
||||
disabled={deleting || saving}
|
||||
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{source && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={pullNow}
|
||||
disabled={pulling || saving}
|
||||
>
|
||||
{pulling ? (
|
||||
<><Loader2 className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />Pulling</>
|
||||
) : (
|
||||
<><RefreshCw className="w-4 h-4 mr-1.5" strokeWidth={1.5} />Pull now</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button size="sm" onClick={save} disabled={saving}>
|
||||
{saving ? (
|
||||
<><Loader2 className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />Saving</>
|
||||
) : (
|
||||
<><Save className="w-4 h-4 mr-1.5" strokeWidth={1.5} />{source ? 'Update' : 'Save'}</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<GitSourceDiffDialog
|
||||
open={diffOpen}
|
||||
onOpenChange={setDiffOpen}
|
||||
stackName={stackName}
|
||||
pull={pull}
|
||||
syncEnv={syncEnv}
|
||||
autoDeployDefault={applyMode === 'auto-deploy'}
|
||||
isDarkMode={isDarkMode}
|
||||
applying={applying}
|
||||
onApply={applyPull}
|
||||
onDismiss={dismissPending}
|
||||
/>
|
||||
|
||||
<AlertDialog open={removeConfirmOpen} onOpenChange={setRemoveConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove Git source?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The stack files on disk will be left in place. You can reconfigure the source later at any time.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={remove} disabled={deleting}>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user