From 5d837fca555a222b2ccc6b770a46a8d2f551ca75 Mon Sep 17 00:00:00 2001 From: Chance <71226606+Chanceium@users.noreply.github.com> Date: Fri, 7 Nov 2025 20:16:50 +0000 Subject: [PATCH 1/9] chore(logging): concise pretty logs, add env template entries (PORT, API_KEY, LOG_FORMAT) --- .env.template | 15 ++ package-lock.json | 495 +++++++++++++++++++++++++++++++++++++ src/__tests__/app.test.ts | 18 +- src/api/stacks.ts | 22 +- src/api/webhooks.ts | 30 ++- src/app.ts | 3 +- src/dev.ts | 5 +- src/env.ts | 21 +- src/index.ts | 42 +++- src/plugins/auth-plugin.ts | 17 +- src/utils/logger.ts | 118 +++++++++ src/utils/portainer.ts | 3 +- src/utils/with-logging.ts | 56 +++++ 13 files changed, 792 insertions(+), 53 deletions(-) create mode 100644 package-lock.json create mode 100644 src/utils/logger.ts create mode 100644 src/utils/with-logging.ts diff --git a/.env.template b/.env.template index add8251..f16bb35 100644 --- a/.env.template +++ b/.env.template @@ -1,3 +1,18 @@ PORTAINER_API_URL=https://portainer.example.com/api PORTAINER_USERNAME=admin PORTAINER_PASSWORD=password + +# Optional runtime configuration +# Port to listen on (default 3000) +PORT=3000 + +# Protect endpoints with an API key (optional) +# If set, requests must include the X-API-Key header +API_KEY= + +# Logging format: 'pretty' (colored human output) or 'json' (single-line JSON) +# Default: pretty in development, json in production +LOG_FORMAT=pretty + +# Set NODE_ENV=production in production environments +# NODE_ENV=production diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..15794bc --- /dev/null +++ b/package-lock.json @@ -0,0 +1,495 @@ +{ + "name": "portainer-stack-webhooks", + "version": "0.2.5", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "portainer-stack-webhooks", + "version": "0.2.5", + "dependencies": { + "@aklinker1/zero-factory": "^1.1.4", + "@aklinker1/zeta": "^1.1.1", + "zod": "^4.1.12" + }, + "devDependencies": { + "@aklinker1/check": "^2.1.2", + "@types/bun": "^1.3.1", + "bun-types": "latest", + "oxlint": "^1.15.0", + "prettier": "^3.6.2", + "typescript": "^5.9.3" + } + }, + "node_modules/@aklinker1/check": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@aklinker1/check/-/check-2.1.2.tgz", + "integrity": "sha512-mOWpH57s6379L/rOSmK21G2Dq/xznUjGVGs+VIrAC3LuRCej5urNuCwOeFUNYy9P2D44KL2dqfZZmi42LsDNVA==", + "dev": true, + "dependencies": { + "ci-info": "*" + }, + "bin": { + "check": "bin/check.mjs" + } + }, + "node_modules/@aklinker1/zero-factory": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@aklinker1/zero-factory/-/zero-factory-1.1.4.tgz", + "integrity": "sha512-V6oNq73snF9hDZJSgvAGZcGT7106IBZOzs20r301iia6R4SlhUa9Vhz5+cD3MPv+7pfHEcXPSqzUNKq5Bc8UXw==" + }, + "node_modules/@aklinker1/zeta": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@aklinker1/zeta/-/zeta-1.1.2.tgz", + "integrity": "sha512-xI+zWr1FhN2fUb/eNPNQGFXuq2JHWeUrRyaz+47Jbs+yY2Uc7u6PCe2OjQQKd3tmlmU6BS/qnL/DHG0vBzRrew==", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "openapi-types": "^12.1.3", + "rou3": "^0.7.1" + } + }, + "node_modules/@oxlint/darwin-arm64": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@oxlint/darwin-arm64/-/darwin-arm64-1.26.0.tgz", + "integrity": "sha512-kTmm1opqyn7iZopWHO3Ml4D/44pA5eknZBepgxCnTaPrW8XgCEUI85Q5AvOOvoNve8NziTYb8ax+CyuGJIgn/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint/darwin-x64": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@oxlint/darwin-x64/-/darwin-x64-1.26.0.tgz", + "integrity": "sha512-/hMfZ9j7ZzVPRmMm02PHNc6MIMk0QYv5VowZJRIp40YLqLPvFfGNGZBj8e1fDVgZMFEGWDQK3yrt1uBKxXAK4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint/linux-arm64-gnu": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@oxlint/linux-arm64-gnu/-/linux-arm64-gnu-1.26.0.tgz", + "integrity": "sha512-iv4wdrwdCa8bhJxOpKlvfxqTs0LgW5tKBUMvH9B13zREHm1xT9JRZ8cQbbKiyC6LNdggwu5S6TSvODgAu7/DlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint/linux-arm64-musl": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@oxlint/linux-arm64-musl/-/linux-arm64-musl-1.26.0.tgz", + "integrity": "sha512-a3gTbnN1JzedxqYeGTkg38BAs/r3Krd2DPNs/MF7nnHthT3RzkPUk47isMePLuNc4e/Weljn7m2m/Onx22tiNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint/linux-x64-gnu": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@oxlint/linux-x64-gnu/-/linux-x64-gnu-1.26.0.tgz", + "integrity": "sha512-cCAyqyuKpFImjlgiBuuwSF+aDBW2h19/aCmHMTMSp6KXwhoQK7/Xx7/EhZKP5wiQJzVUYq5fXr0D8WmpLGsjRg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint/linux-x64-musl": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@oxlint/linux-x64-musl/-/linux-x64-musl-1.26.0.tgz", + "integrity": "sha512-8VOJ4vQo0G1tNdaghxrWKjKZGg73tv+FoMDrtNYuUesqBHZN68FkYCsgPwEsacLhCmtoZrkF3ePDWDuWEpDyAg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint/win32-arm64": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@oxlint/win32-arm64/-/win32-arm64-1.26.0.tgz", + "integrity": "sha512-N8KUtzP6gfEHKvaIBZCS9g8wRfqV5v55a/B8iJjIEhtMehcEM+UX+aYRsQ4dy5oBCrK3FEp4Yy/jHgb0moLm3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxlint/win32-x64": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@oxlint/win32-x64/-/win32-x64-1.26.0.tgz", + "integrity": "sha512-7tCyG0laduNQ45vzB9blVEGq/6DOvh7AFmiUAana8mTp0zIKQQmwJ21RqhazH0Rk7O6lL7JYzKcu+zaJHGpRLA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==" + }, + "node_modules/@types/bun": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.1.tgz", + "integrity": "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ==", + "dev": true, + "dependencies": { + "bun-types": "1.3.1" + } + }, + "node_modules/@types/node": { + "version": "24.10.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", + "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", + "dev": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", + "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", + "dev": true, + "peer": true, + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/bun-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.1.tgz", + "integrity": "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw==", + "dev": true, + "dependencies": { + "@types/node": "*" + }, + "peerDependencies": { + "@types/react": "^19" + } + }, + "node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "engines": { + "node": ">=8" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "peer": true + }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==" + }, + "node_modules/oxlint": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.26.0.tgz", + "integrity": "sha512-KRpL+SMi07JQyggv5ldIF+wt2pnrKm8NLW0B+8bK+0HZsLmH9/qGA+qMWie5Vf7lnlMBllJmsuzHaKFEGY3rIA==", + "dev": true, + "bin": { + "oxc_language_server": "bin/oxc_language_server", + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/darwin-arm64": "1.26.0", + "@oxlint/darwin-x64": "1.26.0", + "@oxlint/linux-arm64-gnu": "1.26.0", + "@oxlint/linux-arm64-musl": "1.26.0", + "@oxlint/linux-x64-gnu": "1.26.0", + "@oxlint/linux-x64-musl": "1.26.0", + "@oxlint/win32-arm64": "1.26.0", + "@oxlint/win32-x64": "1.26.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.4.0" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + } + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/rou3": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.10.tgz", + "integrity": "sha512-aoFj6f7MJZ5muJ+Of79nrhs9N3oLGqi2VEMe94Zbkjb6Wupha46EuoYgpWSOZlXww3bbd8ojgXTAA2mzimX5Ww==" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true + }, + "node_modules/zod": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + }, + "dependencies": { + "@aklinker1/check": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@aklinker1/check/-/check-2.1.2.tgz", + "integrity": "sha512-mOWpH57s6379L/rOSmK21G2Dq/xznUjGVGs+VIrAC3LuRCej5urNuCwOeFUNYy9P2D44KL2dqfZZmi42LsDNVA==", + "dev": true, + "requires": { + "ci-info": "*" + } + }, + "@aklinker1/zero-factory": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@aklinker1/zero-factory/-/zero-factory-1.1.4.tgz", + "integrity": "sha512-V6oNq73snF9hDZJSgvAGZcGT7106IBZOzs20r301iia6R4SlhUa9Vhz5+cD3MPv+7pfHEcXPSqzUNKq5Bc8UXw==" + }, + "@aklinker1/zeta": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@aklinker1/zeta/-/zeta-1.1.2.tgz", + "integrity": "sha512-xI+zWr1FhN2fUb/eNPNQGFXuq2JHWeUrRyaz+47Jbs+yY2Uc7u6PCe2OjQQKd3tmlmU6BS/qnL/DHG0vBzRrew==", + "requires": { + "@standard-schema/spec": "^1.0.0", + "openapi-types": "^12.1.3", + "rou3": "^0.7.1" + } + }, + "@oxlint/darwin-arm64": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@oxlint/darwin-arm64/-/darwin-arm64-1.26.0.tgz", + "integrity": "sha512-kTmm1opqyn7iZopWHO3Ml4D/44pA5eknZBepgxCnTaPrW8XgCEUI85Q5AvOOvoNve8NziTYb8ax+CyuGJIgn/Q==", + "dev": true, + "optional": true + }, + "@oxlint/darwin-x64": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@oxlint/darwin-x64/-/darwin-x64-1.26.0.tgz", + "integrity": "sha512-/hMfZ9j7ZzVPRmMm02PHNc6MIMk0QYv5VowZJRIp40YLqLPvFfGNGZBj8e1fDVgZMFEGWDQK3yrt1uBKxXAK4Q==", + "dev": true, + "optional": true + }, + "@oxlint/linux-arm64-gnu": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@oxlint/linux-arm64-gnu/-/linux-arm64-gnu-1.26.0.tgz", + "integrity": "sha512-iv4wdrwdCa8bhJxOpKlvfxqTs0LgW5tKBUMvH9B13zREHm1xT9JRZ8cQbbKiyC6LNdggwu5S6TSvODgAu7/DlA==", + "dev": true, + "optional": true + }, + "@oxlint/linux-arm64-musl": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@oxlint/linux-arm64-musl/-/linux-arm64-musl-1.26.0.tgz", + "integrity": "sha512-a3gTbnN1JzedxqYeGTkg38BAs/r3Krd2DPNs/MF7nnHthT3RzkPUk47isMePLuNc4e/Weljn7m2m/Onx22tiNg==", + "dev": true, + "optional": true + }, + "@oxlint/linux-x64-gnu": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@oxlint/linux-x64-gnu/-/linux-x64-gnu-1.26.0.tgz", + "integrity": "sha512-cCAyqyuKpFImjlgiBuuwSF+aDBW2h19/aCmHMTMSp6KXwhoQK7/Xx7/EhZKP5wiQJzVUYq5fXr0D8WmpLGsjRg==", + "dev": true, + "optional": true + }, + "@oxlint/linux-x64-musl": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@oxlint/linux-x64-musl/-/linux-x64-musl-1.26.0.tgz", + "integrity": "sha512-8VOJ4vQo0G1tNdaghxrWKjKZGg73tv+FoMDrtNYuUesqBHZN68FkYCsgPwEsacLhCmtoZrkF3ePDWDuWEpDyAg==", + "dev": true, + "optional": true + }, + "@oxlint/win32-arm64": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@oxlint/win32-arm64/-/win32-arm64-1.26.0.tgz", + "integrity": "sha512-N8KUtzP6gfEHKvaIBZCS9g8wRfqV5v55a/B8iJjIEhtMehcEM+UX+aYRsQ4dy5oBCrK3FEp4Yy/jHgb0moLm3Q==", + "dev": true, + "optional": true + }, + "@oxlint/win32-x64": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@oxlint/win32-x64/-/win32-x64-1.26.0.tgz", + "integrity": "sha512-7tCyG0laduNQ45vzB9blVEGq/6DOvh7AFmiUAana8mTp0zIKQQmwJ21RqhazH0Rk7O6lL7JYzKcu+zaJHGpRLA==", + "dev": true, + "optional": true + }, + "@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==" + }, + "@types/bun": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.1.tgz", + "integrity": "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ==", + "dev": true, + "requires": { + "bun-types": "1.3.1" + } + }, + "@types/node": { + "version": "24.10.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", + "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", + "dev": true, + "requires": { + "undici-types": "~7.16.0" + } + }, + "@types/react": { + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", + "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", + "dev": true, + "peer": true, + "requires": { + "csstype": "^3.0.2" + } + }, + "bun-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.1.tgz", + "integrity": "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true + }, + "csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "peer": true + }, + "openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==" + }, + "oxlint": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.26.0.tgz", + "integrity": "sha512-KRpL+SMi07JQyggv5ldIF+wt2pnrKm8NLW0B+8bK+0HZsLmH9/qGA+qMWie5Vf7lnlMBllJmsuzHaKFEGY3rIA==", + "dev": true, + "requires": { + "@oxlint/darwin-arm64": "1.26.0", + "@oxlint/darwin-x64": "1.26.0", + "@oxlint/linux-arm64-gnu": "1.26.0", + "@oxlint/linux-arm64-musl": "1.26.0", + "@oxlint/linux-x64-gnu": "1.26.0", + "@oxlint/linux-x64-musl": "1.26.0", + "@oxlint/win32-arm64": "1.26.0", + "@oxlint/win32-x64": "1.26.0" + } + }, + "prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true + }, + "rou3": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.10.tgz", + "integrity": "sha512-aoFj6f7MJZ5muJ+Of79nrhs9N3oLGqi2VEMe94Zbkjb6Wupha46EuoYgpWSOZlXww3bbd8ojgXTAA2mzimX5Ww==" + }, + "typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true + }, + "undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true + }, + "zod": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==" + } + } +} diff --git a/src/__tests__/app.test.ts b/src/__tests__/app.test.ts index 60c475f..448de0a 100644 --- a/src/__tests__/app.test.ts +++ b/src/__tests__/app.test.ts @@ -70,12 +70,15 @@ describe("App Integration Tests", async () => { { id: stacks[1]!.Id, name: stacks[1]!.Name }, ]; - const sendRequest = async (apiKey: string | null = API_KEY) => - fetch( + const sendRequest = async (apiKey: string | null = API_KEY) => { + const headers: Record = { "X-Forwarded-For": "203.0.113.5" }; + if (apiKey) headers["X-API-Key"] = apiKey; + return fetch( new Request(`http://localhost/api/stacks`, { - headers: apiKey ? { "X-API-Key": apiKey } : undefined, + headers, }), ); + }; const expectSuccess = async (response: Response) => { expect(response.status).toBe(HttpStatus.Ok); @@ -132,13 +135,16 @@ describe("App Integration Tests", async () => { const endpointId = stack.EndpointId; const stackFileContent = ""; - const sendRequest = async (apiKey: string | null = API_KEY) => - fetch( + const sendRequest = async (apiKey: string | null = API_KEY) => { + const headers: Record = { "X-Forwarded-For": "203.0.113.5" }; + if (apiKey) headers["X-API-Key"] = apiKey; + return fetch( new Request(`http://localhost/api/webhook/stacks/${stackId}`, { method: "POST", - headers: apiKey ? { "X-API-Key": apiKey } : undefined, + headers, }), ); + }; const expectSuccess = (response: Response) => { expect(response.status).toBe(HttpStatus.Accepted); diff --git a/src/api/stacks.ts b/src/api/stacks.ts index c78b290..5c64c48 100644 --- a/src/api/stacks.ts +++ b/src/api/stacks.ts @@ -2,6 +2,19 @@ import { createApp } from "@aklinker1/zeta"; import { ListStacksOutput } from "../models"; import { authPlugin } from "../plugins/auth-plugin"; import { ctxPlugin } from "../plugins/ctx-plugin"; +import { withLogging } from "../utils/with-logging"; + +const listStacksHandler: any = withLogging( + "GET /api/stacks", + async ({ portainer }: { portainer: any }) => { + const stacks = await portainer.listStacks(); + + return stacks.map((stack: any) => ({ + id: stack.Id, + name: stack.Name, + })); + }, +); export const stacksApp = createApp() .use(authPlugin) @@ -13,12 +26,5 @@ export const stacksApp = createApp() summary: "List Stacks", responses: ListStacksOutput, }, - async ({ portainer }) => { - const stacks = await portainer.listStacks(); - - return stacks.map((stack) => ({ - id: stack.Id, - name: stack.Name, - })); - }, + listStacksHandler, ); diff --git a/src/api/webhooks.ts b/src/api/webhooks.ts index 17550f8..376e3e7 100644 --- a/src/api/webhooks.ts +++ b/src/api/webhooks.ts @@ -2,6 +2,7 @@ import { HttpStatus, createApp } from "@aklinker1/zeta"; import { UpdateStackWebhookInput, UpdateStackWebhookOutput } from "../models"; import { authPlugin } from "../plugins/auth-plugin"; import { ctxPlugin } from "../plugins/ctx-plugin"; +import { withLogging } from "../utils/with-logging"; export const webhooksApp = createApp() .use(authPlugin) @@ -16,18 +17,21 @@ export const webhooksApp = createApp() [HttpStatus.Accepted]: UpdateStackWebhookOutput, }, }, - async ({ params, portainer, status }) => { - const [stack, stackFile] = await Promise.all([ - portainer.getStack(params.stackId), - portainer.getStackFile(params.stackId), - ]); - await portainer.updateStack(params.stackId, { - prune: false, - pullImage: true, - endpointId: stack.EndpointId, - stackFileContent: stackFile.StackFileContent, - }); + withLogging( + "POST /api/webhook/stacks/:stackId", + async ({ params, portainer, status }: any) => { + const [stack, stackFile] = await Promise.all([ + portainer.getStack(params.stackId), + portainer.getStackFile(params.stackId), + ]); + await portainer.updateStack(params.stackId, { + prune: false, + pullImage: true, + endpointId: stack.EndpointId, + stackFileContent: stackFile.StackFileContent, + }); - return status(HttpStatus.Accepted, undefined); - }, + return status(HttpStatus.Accepted, undefined); + }, + ) as any, ); diff --git a/src/app.ts b/src/app.ts index 6bd060e..8c74d18 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,4 +1,5 @@ import { createApp } from "@aklinker1/zeta"; +import { logger } from "./utils/logger"; import { version } from "../package.json"; import { zodSchemaAdapter } from "@aklinker1/zeta/adapters/zod-schema-adapter"; import { healthApp } from "./api/health"; @@ -14,7 +15,7 @@ export const app = createApp({ }, }, }) - .onGlobalError(({ error }) => console.error(error)) + .onGlobalError(({ error }) => logger.error("global.error", { error: String(error) })) .use(healthApp) .use(stacksApp) .use(webhooksApp); diff --git a/src/dev.ts b/src/dev.ts index 1ac960c..721a631 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -1,10 +1,9 @@ import { bold, cyan, dim, violet } from "./colors"; import { env } from "./env"; import "./index"; +import { logger } from "./utils/logger"; const res = await fetch(`http://localhost:${env.port}/openapi.json`); const json = await res.json(); Bun.write("openapi.json", JSON.stringify(json, null, 2)); -console.log( - `${cyan(bold("ℹ"))} ${dim("[dev]")} OpenAPI spec written to ${violet("openapi.json")}`, -); +logger.info("dev.openapi_written", { message: "OpenAPI spec written to openapi.json" }); diff --git a/src/env.ts b/src/env.ts index 564f670..4c60288 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,11 +1,12 @@ import { violet, bold, cyan, red, yellow } from "./colors"; +import { logger } from "./utils/logger"; function requireEnv(key: string): string { const value = process.env[key]; if (!value) { - console.log( - `${red(bold("⚠ Fatal"))}: The ${violet(key)} env var is required`, - ); + logger.error("env.missing", { message: `The ${key} env var is required` }); + // keep the old visual warning too for very early startup clarity + console.log(`${red(bold("⚠ Fatal"))}: The ${violet(key)} env var is required`); process.exit(1); } return value; @@ -23,13 +24,9 @@ export const env = { export function logEnvWarnings() { if (env.apiKey) { - console.log( - `${cyan(bold("ℹ"))} ${violet("API_KEY")} set - endpoints are protected`, - ); + logger.info("env.api_key", { message: "API_KEY set - endpoints are protected" }); } else { - console.log( - `${cyan(bold("ℹ"))} ${violet("API_KEY")} not set - endpoints are not protected`, - ); + logger.info("env.api_key", { message: "API_KEY not set - endpoints are not protected" }); } maybeLogDeprecated("BASE_URL", "PORTAINER_API_URL"); @@ -39,8 +36,8 @@ export function logEnvWarnings() { function maybeLogDeprecated(key: string, replacement: string): void { if (process.env[key]) { - console.log( - `${yellow(bold("⚠"))} ${violet(key)} is deprecated, use ${violet(replacement)} instead`, - ); + logger.warn("env.deprecated", { + message: `${key} is deprecated, use ${replacement} instead`, + }); } } diff --git a/src/index.ts b/src/index.ts index 5f04dad..fa6d38e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,44 @@ -import { bold, cyan, dim, violet } from "./colors"; import { version } from "./version"; import { app } from "./app"; import { env, logEnvWarnings } from "./env"; +import { logger } from "./utils/logger"; -console.log(`${bold(cyan("Portainer Stack Webhooks"))} ${dim("v" + version)}`); +logger.info("startup", { message: "Portainer Stack Webhooks starting", version }); app.listen(env.port, () => { logEnvWarnings(); - console.log( - `${cyan("ℹ")} Server started ${dim("→")} ${violet( - "http://localhost:" + env.port, - )}`, - ); + logger.info("server.started", { url: `http://localhost:${env.port}` }); }); + +// Global process handlers for consistent logging +process.on("uncaughtException", (err: unknown) => { + try { + logger.error("uncaughtException", { error: String(err) }); + } catch { + // If logger itself fails, fallback to console + console.error("uncaughtException", err); + } + // allow orchestrator to restart + process.exit(1); +}); + +process.on("unhandledRejection", (reason: unknown) => { + try { + logger.error("unhandledRejection", { reason: String(reason) }); + } catch { + console.error("unhandledRejection", reason); + } + // give process a chance to exit in a known state + process.exit(1); +}); + +// Graceful-ish shutdown on signals +const handleSignal = (sig: string) => { + logger.info("process.signal", { signal: sig }); + // best-effort: exit cleanly + process.exit(0); +}; + +process.on("SIGINT", () => handleSignal("SIGINT")); +process.on("SIGTERM", () => handleSignal("SIGTERM")); diff --git a/src/plugins/auth-plugin.ts b/src/plugins/auth-plugin.ts index 16adc70..3a33d36 100644 --- a/src/plugins/auth-plugin.ts +++ b/src/plugins/auth-plugin.ts @@ -1,5 +1,6 @@ import { createApp, UnauthorizedHttpError } from "@aklinker1/zeta"; import { env } from "../env"; +import { logger } from "../utils/logger"; export const authPlugin = createApp() .onTransform(({ request }) => { @@ -7,9 +8,21 @@ export const authPlugin = createApp() if (!env.apiKey) return; const apiKey = request.headers.get("x-api-key"); - if (!apiKey) + if (!apiKey) { + logger.warn("auth.missing_api_key", { + method: request.method, + path: new URL(request.url).pathname, + client: + request.headers.get("x-forwarded-for") ?? request.headers.get("x-real-ip"), + }); throw new UnauthorizedHttpError("X-API-Key header is required"); - if (apiKey !== env.apiKey) + } + if (apiKey !== env.apiKey) { + logger.warn("auth.invalid_api_key", { + method: request.method, + path: new URL(request.url).pathname, + }); throw new UnauthorizedHttpError("Invalid API key"); + } }) .export(); diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..8d2e9a5 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,118 @@ +import { bold, dim, red, yellow, green, cyan, violet, blue } from "../colors"; + +type Meta = Record | undefined; + +const isProd = process.env.NODE_ENV === "production"; +const format = (process.env.LOG_FORMAT || (isProd ? "json" : "pretty")).toLowerCase(); + +function safeString(v: unknown) { + if (v === undefined) return ""; + if (v === null) return "null"; + if (typeof v === "string") return v; + try { + return JSON.stringify(v); + } catch { + return String(v); + } +} + +function prettyAction(message?: string, meta?: Meta) { + const m = message ?? ""; + // Prefer explicit route if provided + if (meta && typeof meta.route === "string") return String(meta.route); + + // if message looks like `auth.missing_api_key` -> AUTH + if (String(m).includes(".")) return String(m).split(".")[0].toUpperCase(); + return String(m).toUpperCase(); +} + +export const createLogger = (service = "portainer-stack-webhook") => { + const logJson = (level: string, message: string, meta?: Meta) => { + const entry: Record = { + time: new Date().toISOString(), + service, + level, + message, + }; + if (meta) Object.assign(entry, meta); + console.log(JSON.stringify(entry)); + }; + + const logPretty = (level: string, message: string, meta?: Meta) => { + const t = new Date().toISOString(); + const lvl = level === "error" ? red(level.toUpperCase()) : level === "warn" ? yellow(level.toUpperCase()) : green(level.toUpperCase()); + const action = prettyAction(message, meta); + + // method color + const method = meta?.method ? String(meta.method).toUpperCase() : undefined; + const methodColored = method + ? method === "GET" + ? blue(method) + : method === "POST" + ? violet(method) + : method === "PUT" + ? yellow(method) + : method === "DELETE" + ? red(method) + : cyan(method) + : undefined; + + // path (prefer meta.path, fall back to meta.route) + const pathRaw = meta?.path ?? meta?.route; + const path = pathRaw ? cyan(String(pathRaw)) : undefined; + + // status coloring helper (only color numeric statuses) + const colorStatus = (s: unknown) => { + const n = Number(s); + if (Number.isInteger(n)) { + if (n >= 500) return red(String(n)); + if (n >= 400) return yellow(String(n)); + if (n >= 300) return cyan(String(n)); + return green(String(n)); + } + return undefined; + }; + + // Build display pieces: show only method, concrete path, and client (user requested minimal output) + const pieces: string[] = []; + if (methodColored) pieces.push(methodColored); + if (path) pieces.push(path); + // only show client IP for compact logs + const clientPart = meta?.client ? `client=${cyan(String(meta.client))}` : undefined; + + const metaStr = clientPart ? dim(clientPart) : ""; + + // If we have method/path pieces, show concise request-style line. + if (pieces.length > 0) { + const line = `${dim(t)} ${lvl} ${pieces.join(" ")}${metaStr ? " - " + metaStr : ""}`; + console.log(line); + return; + } + + // Otherwise (non-request logs like global errors), fall back to an action label and show important meta (error/reason) + const actionLabel = cyan(bold(prettyAction(message, meta))); + // pick useful meta keys to show (error, reason, message) + const usefulKeys = ["error", "reason", "message"]; + const useful = meta + ? Object.entries(meta) + .filter(([k]) => usefulKeys.includes(k)) + .map(([k, v]) => `${violet(k)}=${safeString(v)}`) + .join(" ") + : ""; + + const fallbackMeta = useful ? dim(useful) : ""; + const line = `${dim(t)} ${lvl} ${actionLabel}${fallbackMeta ? " - " + fallbackMeta : ""}`; + console.log(line); + }; + + const choose = format === "json" ? logJson : logPretty; + + return { + info: (message: string, meta?: Meta) => choose("info", message, meta), + warn: (message: string, meta?: Meta) => choose("warn", message, meta), + error: (message: string, meta?: Meta) => choose("error", message, meta), + debug: (message: string, meta?: Meta) => choose("debug", message, meta), + } as const; +}; + +export const logger = createLogger(); diff --git a/src/utils/portainer.ts b/src/utils/portainer.ts index 721396f..1cb057f 100644 --- a/src/utils/portainer.ts +++ b/src/utils/portainer.ts @@ -2,6 +2,7 @@ import { InternalServerErrorHttpError } from "@aklinker1/zeta"; import { env } from "../env"; import { createTtlValue } from "./ttl-value"; import { bold, cyan } from "../colors"; +import { logger } from "./logger"; export interface PortainerApi { listStacks: () => Promise; @@ -25,7 +26,7 @@ export function createPortainerApi(): PortainerApi { const jwt = createTtlValue(60 * 60e3); // 1 hour (experimentally, it seems portainer JWTs last 8 hours) const login = async (): Promise => { - console.log(`${cyan(bold("ℹ"))} Fetching new JWT for portainer...`); + logger.info("portainer.jwt.fetch", { message: "Fetching new JWT for portainer..." }); const res = await fetch(`${apiUrl}/auth`, { body: JSON.stringify({ username, password }), diff --git a/src/utils/with-logging.ts b/src/utils/with-logging.ts new file mode 100644 index 0000000..d7d67bf --- /dev/null +++ b/src/utils/with-logging.ts @@ -0,0 +1,56 @@ +import { logger } from "./logger"; + +export const withLogging = + (name: string, handler: (ctx: any) => Promise) => + async (ctx: any) => { + const method = ctx?.method ?? ctx?.request?.method ?? "UNKNOWN"; + const path = ctx?.path ?? (ctx?.request?.url ? new URL(ctx.request.url).pathname : ""); + + // try to extract client IP from common headers + const headers = ctx?.request?.headers; + const rawXff = headers ? (headers.get?.("x-forwarded-for") || headers.get?.("x-forwarded") || headers.get?.("forwarded-for")) : null; + // prefer the first IP in X-Forwarded-For (original client) + let clientIP: string | null = null; + if (rawXff) { + clientIP = String(rawXff).split(",")[0]?.trim() ?? null; + } else if (headers && (headers.get?.("x-real-ip") || headers.get?.("x-client-ip") || headers.get?.("cf-connecting-ip"))) { + clientIP = headers.get("x-real-ip") || headers.get("x-client-ip") || headers.get("cf-connecting-ip") || null; + } else if (ctx?.request?.conn?.remoteAddress) { + clientIP = ctx.request.conn.remoteAddress as string; + } + + const metaBase = { + route: name, + method, + path, + client: clientIP ?? null, + xff: rawXff, + } as Record; + + logger.info("request.start", metaBase); + + const start = Date.now(); + try { + const result = await handler(ctx); + const duration = Date.now() - start; + // Try to glean numeric status from common places; avoid logging function objects + const rawStatus = ctx?.response?.status ?? ctx?.status; + let status: number | undefined = undefined; + if (typeof rawStatus === "number") status = rawStatus; + else if (typeof rawStatus === "string" && !Number.isNaN(Number(rawStatus))) status = Number(rawStatus); + + const endMeta = { ...metaBase, duration } as Record; + if (status !== undefined) endMeta.status = status; + logger.info("request.end", endMeta); + return result; + } catch (err: any) { + const duration = Date.now() - start; + logger.error("request.error", { + ...metaBase, + error: err?.message ?? String(err), + status: typeof err?.status === "number" ? err.status : undefined, + duration, + }); + throw err; + } + }; From aa60adc5084e45e7acb037ae41f06ad8e282f364 Mon Sep 17 00:00:00 2001 From: Chance <71226606+Chanceium@users.noreply.github.com> Date: Fri, 7 Nov 2025 20:29:12 +0000 Subject: [PATCH 2/9] chore: fix TypeScript errors --- src/__tests__/app.test.ts | 8 ++++++-- src/api/stacks.ts | 21 +++++++++---------- src/app.ts | 4 +++- src/dev.ts | 5 +++-- src/env.ts | 14 +++++++++---- src/index.ts | 5 ++++- src/plugins/auth-plugin.ts | 3 ++- src/utils/logger.ts | 41 +++++++++++++++++++++++++------------- src/utils/portainer.ts | 5 +++-- src/utils/with-logging.ts | 28 +++++++++++++++++++++----- tsconfig.json | 2 +- 11 files changed, 91 insertions(+), 45 deletions(-) diff --git a/src/__tests__/app.test.ts b/src/__tests__/app.test.ts index 448de0a..c3c4314 100644 --- a/src/__tests__/app.test.ts +++ b/src/__tests__/app.test.ts @@ -71,7 +71,9 @@ describe("App Integration Tests", async () => { ]; const sendRequest = async (apiKey: string | null = API_KEY) => { - const headers: Record = { "X-Forwarded-For": "203.0.113.5" }; + const headers: Record = { + "X-Forwarded-For": "203.0.113.5", + }; if (apiKey) headers["X-API-Key"] = apiKey; return fetch( new Request(`http://localhost/api/stacks`, { @@ -136,7 +138,9 @@ describe("App Integration Tests", async () => { const stackFileContent = ""; const sendRequest = async (apiKey: string | null = API_KEY) => { - const headers: Record = { "X-Forwarded-For": "203.0.113.5" }; + const headers: Record = { + "X-Forwarded-For": "203.0.113.5", + }; if (apiKey) headers["X-API-Key"] = apiKey; return fetch( new Request(`http://localhost/api/webhook/stacks/${stackId}`, { diff --git a/src/api/stacks.ts b/src/api/stacks.ts index 5c64c48..df65ce3 100644 --- a/src/api/stacks.ts +++ b/src/api/stacks.ts @@ -16,15 +16,12 @@ const listStacksHandler: any = withLogging( }, ); -export const stacksApp = createApp() - .use(authPlugin) - .use(ctxPlugin) - .get( - "/api/stacks", - { - operationId: "listStacks", - summary: "List Stacks", - responses: ListStacksOutput, - }, - listStacksHandler, - ); +export const stacksApp = createApp().use(authPlugin).use(ctxPlugin).get( + "/api/stacks", + { + operationId: "listStacks", + summary: "List Stacks", + responses: ListStacksOutput, + }, + listStacksHandler, +); diff --git a/src/app.ts b/src/app.ts index 8c74d18..304e20a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -15,7 +15,9 @@ export const app = createApp({ }, }, }) - .onGlobalError(({ error }) => logger.error("global.error", { error: String(error) })) + .onGlobalError(({ error }) => + logger.error("global.error", { error: String(error) }), + ) .use(healthApp) .use(stacksApp) .use(webhooksApp); diff --git a/src/dev.ts b/src/dev.ts index 721a631..5e59d79 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -1,4 +1,3 @@ -import { bold, cyan, dim, violet } from "./colors"; import { env } from "./env"; import "./index"; import { logger } from "./utils/logger"; @@ -6,4 +5,6 @@ import { logger } from "./utils/logger"; const res = await fetch(`http://localhost:${env.port}/openapi.json`); const json = await res.json(); Bun.write("openapi.json", JSON.stringify(json, null, 2)); -logger.info("dev.openapi_written", { message: "OpenAPI spec written to openapi.json" }); +logger.info("dev.openapi_written", { + message: "OpenAPI spec written to openapi.json", +}); diff --git a/src/env.ts b/src/env.ts index 4c60288..69b0f1f 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,4 +1,4 @@ -import { violet, bold, cyan, red, yellow } from "./colors"; +import { violet, bold, red } from "./colors"; import { logger } from "./utils/logger"; function requireEnv(key: string): string { @@ -6,7 +6,9 @@ function requireEnv(key: string): string { if (!value) { logger.error("env.missing", { message: `The ${key} env var is required` }); // keep the old visual warning too for very early startup clarity - console.log(`${red(bold("⚠ Fatal"))}: The ${violet(key)} env var is required`); + console.log( + `${red(bold("⚠ Fatal"))}: The ${violet(key)} env var is required`, + ); process.exit(1); } return value; @@ -24,9 +26,13 @@ export const env = { export function logEnvWarnings() { if (env.apiKey) { - logger.info("env.api_key", { message: "API_KEY set - endpoints are protected" }); + logger.info("env.api_key", { + message: "API_KEY set - endpoints are protected", + }); } else { - logger.info("env.api_key", { message: "API_KEY not set - endpoints are not protected" }); + logger.info("env.api_key", { + message: "API_KEY not set - endpoints are not protected", + }); } maybeLogDeprecated("BASE_URL", "PORTAINER_API_URL"); diff --git a/src/index.ts b/src/index.ts index fa6d38e..8a767f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,10 @@ import { app } from "./app"; import { env, logEnvWarnings } from "./env"; import { logger } from "./utils/logger"; -logger.info("startup", { message: "Portainer Stack Webhooks starting", version }); +logger.info("startup", { + message: "Portainer Stack Webhooks starting", + version, +}); app.listen(env.port, () => { logEnvWarnings(); diff --git a/src/plugins/auth-plugin.ts b/src/plugins/auth-plugin.ts index 3a33d36..1a22619 100644 --- a/src/plugins/auth-plugin.ts +++ b/src/plugins/auth-plugin.ts @@ -13,7 +13,8 @@ export const authPlugin = createApp() method: request.method, path: new URL(request.url).pathname, client: - request.headers.get("x-forwarded-for") ?? request.headers.get("x-real-ip"), + request.headers.get("x-forwarded-for") ?? + request.headers.get("x-real-ip"), }); throw new UnauthorizedHttpError("X-API-Key header is required"); } diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 8d2e9a5..8f0cca5 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -3,7 +3,9 @@ import { bold, dim, red, yellow, green, cyan, violet, blue } from "../colors"; type Meta = Record | undefined; const isProd = process.env.NODE_ENV === "production"; -const format = (process.env.LOG_FORMAT || (isProd ? "json" : "pretty")).toLowerCase(); +const format = ( + process.env.LOG_FORMAT || (isProd ? "json" : "pretty") +).toLowerCase(); function safeString(v: unknown) { if (v === undefined) return ""; @@ -19,10 +21,14 @@ function safeString(v: unknown) { function prettyAction(message?: string, meta?: Meta) { const m = message ?? ""; // Prefer explicit route if provided - if (meta && typeof meta.route === "string") return String(meta.route); + if (meta && typeof meta["route"] === "string") + return String(meta["route"] as string); // if message looks like `auth.missing_api_key` -> AUTH - if (String(m).includes(".")) return String(m).split(".")[0].toUpperCase(); + if (String(m).includes(".")) { + const part = String(m).split(".")[0] ?? ""; + return part.toUpperCase(); + } return String(m).toUpperCase(); } @@ -40,8 +46,13 @@ export const createLogger = (service = "portainer-stack-webhook") => { const logPretty = (level: string, message: string, meta?: Meta) => { const t = new Date().toISOString(); - const lvl = level === "error" ? red(level.toUpperCase()) : level === "warn" ? yellow(level.toUpperCase()) : green(level.toUpperCase()); - const action = prettyAction(message, meta); + const lvl = + level === "error" + ? red(level.toUpperCase()) + : level === "warn" + ? yellow(level.toUpperCase()) + : green(level.toUpperCase()); + // derived action (kept as a function call below when needed) // method color const method = meta?.method ? String(meta.method).toUpperCase() : undefined; @@ -49,20 +60,20 @@ export const createLogger = (service = "portainer-stack-webhook") => { ? method === "GET" ? blue(method) : method === "POST" - ? violet(method) - : method === "PUT" - ? yellow(method) - : method === "DELETE" - ? red(method) - : cyan(method) + ? violet(method) + : method === "PUT" + ? yellow(method) + : method === "DELETE" + ? red(method) + : cyan(method) : undefined; // path (prefer meta.path, fall back to meta.route) - const pathRaw = meta?.path ?? meta?.route; + const pathRaw = meta?.["path"] ?? meta?.["route"]; const path = pathRaw ? cyan(String(pathRaw)) : undefined; // status coloring helper (only color numeric statuses) - const colorStatus = (s: unknown) => { + const _colorStatus = (s: unknown) => { const n = Number(s); if (Number.isInteger(n)) { if (n >= 500) return red(String(n)); @@ -78,7 +89,9 @@ export const createLogger = (service = "portainer-stack-webhook") => { if (methodColored) pieces.push(methodColored); if (path) pieces.push(path); // only show client IP for compact logs - const clientPart = meta?.client ? `client=${cyan(String(meta.client))}` : undefined; + const clientPart = meta?.["client"] + ? `client=${cyan(String(meta["client"]))}` + : undefined; const metaStr = clientPart ? dim(clientPart) : ""; diff --git a/src/utils/portainer.ts b/src/utils/portainer.ts index 1cb057f..70548da 100644 --- a/src/utils/portainer.ts +++ b/src/utils/portainer.ts @@ -1,7 +1,6 @@ import { InternalServerErrorHttpError } from "@aklinker1/zeta"; import { env } from "../env"; import { createTtlValue } from "./ttl-value"; -import { bold, cyan } from "../colors"; import { logger } from "./logger"; export interface PortainerApi { @@ -26,7 +25,9 @@ export function createPortainerApi(): PortainerApi { const jwt = createTtlValue(60 * 60e3); // 1 hour (experimentally, it seems portainer JWTs last 8 hours) const login = async (): Promise => { - logger.info("portainer.jwt.fetch", { message: "Fetching new JWT for portainer..." }); + logger.info("portainer.jwt.fetch", { + message: "Fetching new JWT for portainer...", + }); const res = await fetch(`${apiUrl}/auth`, { body: JSON.stringify({ username, password }), diff --git a/src/utils/with-logging.ts b/src/utils/with-logging.ts index d7d67bf..4841c8e 100644 --- a/src/utils/with-logging.ts +++ b/src/utils/with-logging.ts @@ -4,17 +4,31 @@ export const withLogging = (name: string, handler: (ctx: any) => Promise) => async (ctx: any) => { const method = ctx?.method ?? ctx?.request?.method ?? "UNKNOWN"; - const path = ctx?.path ?? (ctx?.request?.url ? new URL(ctx.request.url).pathname : ""); + const path = + ctx?.path ?? (ctx?.request?.url ? new URL(ctx.request.url).pathname : ""); // try to extract client IP from common headers const headers = ctx?.request?.headers; - const rawXff = headers ? (headers.get?.("x-forwarded-for") || headers.get?.("x-forwarded") || headers.get?.("forwarded-for")) : null; + const rawXff = headers + ? headers.get?.("x-forwarded-for") || + headers.get?.("x-forwarded") || + headers.get?.("forwarded-for") + : null; // prefer the first IP in X-Forwarded-For (original client) let clientIP: string | null = null; if (rawXff) { clientIP = String(rawXff).split(",")[0]?.trim() ?? null; - } else if (headers && (headers.get?.("x-real-ip") || headers.get?.("x-client-ip") || headers.get?.("cf-connecting-ip"))) { - clientIP = headers.get("x-real-ip") || headers.get("x-client-ip") || headers.get("cf-connecting-ip") || null; + } else if ( + headers && + (headers.get?.("x-real-ip") || + headers.get?.("x-client-ip") || + headers.get?.("cf-connecting-ip")) + ) { + clientIP = + headers.get("x-real-ip") || + headers.get("x-client-ip") || + headers.get("cf-connecting-ip") || + null; } else if (ctx?.request?.conn?.remoteAddress) { clientIP = ctx.request.conn.remoteAddress as string; } @@ -37,7 +51,11 @@ export const withLogging = const rawStatus = ctx?.response?.status ?? ctx?.status; let status: number | undefined = undefined; if (typeof rawStatus === "number") status = rawStatus; - else if (typeof rawStatus === "string" && !Number.isNaN(Number(rawStatus))) status = Number(rawStatus); + else if ( + typeof rawStatus === "string" && + !Number.isNaN(Number(rawStatus)) + ) + status = Number(rawStatus); const endMeta = { ...metaBase, duration } as Record; if (status !== undefined) endMeta.status = status; diff --git a/tsconfig.json b/tsconfig.json index 8ae4110..82c4edb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { // Environment setup & latest features - "lib": ["ESNext"], + "lib": ["ESNext", "DOM"], "target": "ESNext", "module": "Preserve", "moduleDetection": "force", From bd12d54e84f34d771b7358195e91044e5c316f40 Mon Sep 17 00:00:00 2001 From: Chance Date: Wed, 17 Dec 2025 14:07:41 -0500 Subject: [PATCH 3/9] feat: add name based stack webhook --- README.md | 60 +++++++++++++--- openapi.json | 37 +++++++++- src/__tests__/app.test.ts | 139 +++++++++++++++++++++++++++++++++++++- src/api/webhooks.ts | 67 +++++++++++++----- src/models.ts | 19 +++++- 5 files changed, 287 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 2a3b9db..8d80277 100644 --- a/README.md +++ b/README.md @@ -20,19 +20,60 @@ services: API_KEY: your-api-key # Optional, set to a any string to require authentication ``` -To tell Portainer to pull the latest images and update the stack, make a simple POST request: +For available endpoints, see the API Reference below or open `/scalar`. You can also copy-paste [`./openapi.json`](./openapi.json) into [Scalar Editor](https://editor.scalar.com/). + +## API Reference + +- Base URL: `http://localhost:3000` +- Authentication: if `API_KEY` is set, add header `X-API-Key: ` to every request. + +### Health + +Check server status, uptime, and version. + +```sh +# no auth +curl http://localhost:3000/api/health + +# with auth +curl -H "X-API-Key: " http://localhost:3000/api/health +``` + +### List stacks + +Returns `id` and `name` for each stack (useful for selecting `stackId` or `stackName`). + +```sh +# no auth +curl http://localhost:3000/api/stacks + +# with auth +curl -H "X-API-Key: " http://localhost:3000/api/stacks +``` + +### Update stack by ID + +Pull latest images and redeploy a stack by numeric ID. ```sh -# No authentication -curl -X POST http://localhost:3000/api/webhook/stacks/:stackId +# no auth +curl -X POST http://localhost:3000/api/webhook/stacks/id/:stackId -# With an API key -curl -X POST -H "X-API-Key: " http://localhost:3000/api/webhook/stacks/:stackId +# with auth +curl -X POST -H "X-API-Key: " http://localhost:3000/api/webhook/stacks/id/:stackId ``` -You can get the `stackId` from the `GET /api/stacks` endpoint. +### Update stack by name -For other available APIs, see `/scalar` or copy-paste [`./openapi.json`](./openapi.json) into [Scalar Editor](https://editor.scalar.com/) +Pull latest images and redeploy a stack by name. + +```sh +# no auth +curl -X POST http://localhost:3000/api/webhook/stacks/name/:stackName + +# with auth +curl -X POST -H "X-API-Key: " http://localhost:3000/api/webhook/stacks/name/:stackName +``` ## Contributing @@ -54,10 +95,7 @@ To run: ```sh bun dev ``` -3. Send a request to test it out - ```sh - curl -X POST http://localhost:3000/api/webhook/stacks/123 - ``` +3. Send a request to test it out (see API Reference above for routes) To run tests: diff --git a/openapi.json b/openapi.json index 9c7b989..e620e44 100644 --- a/openapi.json +++ b/openapi.json @@ -43,10 +43,10 @@ } } }, - "/api/webhook/stacks/{stackId}": { + "/api/webhook/stacks/id/{stackId}": { "post": { - "operationId": "updateStackWebhook", - "summary": "Update Stack Webhook", + "operationId": "updateStackWebhookById", + "summary": "Update Stack Webhook by ID", "parameters": [ { "name": "stackId", @@ -75,6 +75,37 @@ } } } + }, + "/api/webhook/stacks/name/{stackName}": { + "post": { + "operationId": "updateStackWebhookByName", + "summary": "Update Stack Webhook by Name", + "parameters": [ + { + "name": "stackName", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } } }, "components": { diff --git a/src/__tests__/app.test.ts b/src/__tests__/app.test.ts index c3c4314..4ba081f 100644 --- a/src/__tests__/app.test.ts +++ b/src/__tests__/app.test.ts @@ -131,7 +131,7 @@ describe("App Integration Tests", async () => { }); }); - describe("POST /api/webhook/stacks/{id}", () => { + describe("POST /api/webhook/stacks/id/{id}", () => { const stack = portainerStackFactory(); const stackId = stack.Id; const endpointId = stack.EndpointId; @@ -143,7 +143,7 @@ describe("App Integration Tests", async () => { }; if (apiKey) headers["X-API-Key"] = apiKey; return fetch( - new Request(`http://localhost/api/webhook/stacks/${stackId}`, { + new Request(`http://localhost/api/webhook/stacks/id/${stackId}`, { method: "POST", headers, }), @@ -250,4 +250,139 @@ describe("App Integration Tests", async () => { expectSuccess(response); }); }); + + describe("POST /api/webhook/stacks/name/{name}", () => { + const stack = portainerStackFactory(); + const stackName = stack.Name; + const stackId = stack.Id; + const endpointId = stack.EndpointId; + const stackFileContent = ""; + + const sendRequest = async (apiKey: string | null = API_KEY) => { + const headers: Record = { + "X-Forwarded-For": "203.0.113.5", + }; + if (apiKey) headers["X-API-Key"] = apiKey; + return fetch( + new Request(`http://localhost/api/webhook/stacks/name/${stackName}`, { + method: "POST", + headers, + }), + ); + }; + + const expectSuccess = (response: Response) => { + expect(response.status).toBe(HttpStatus.Accepted); + expect(portainer.updateStack).toBeCalledTimes(1); + expect(portainer.updateStack).toBeCalledWith(stackId, { + endpointId, + stackFileContent, + prune: false, + pullImage: true, + }); + }; + + beforeEach(() => { + portainer.listStacks.mockResolvedValue([stack]); + portainer.getStack.mockResolvedValue(stack); + portainer.getStackFile.mockResolvedValue({ + StackFileContent: stackFileContent, + }); + portainer.updateStack.mockResolvedValue(); + }); + + it("should fail if no stack exists with the provided name", async () => { + portainer.listStacks.mockResolvedValue([]); + + const response = await sendRequest(); + + await expectErrorResponse( + response, + new Error(`Stack not found: ${stackName}`), + ); + expect(portainer.getStack).not.toBeCalled(); + expect(portainer.updateStack).not.toBeCalled(); + }); + + describe("when getStack throws an error", () => { + const err = Error("Not Found"); + + beforeEach(() => { + portainer.getStack.mockRejectedValue(err); + }); + + it("should fail", async () => { + const response = await sendRequest(); + + await expectErrorResponse(response, err); + }); + }); + + describe("when getStackFile throws an error", () => { + const err = Error("Not Found"); + + beforeEach(() => { + portainer.getStackFile.mockRejectedValue(err); + }); + + it("should fail", async () => { + const response = await sendRequest(); + + await expectErrorResponse(response, err); + }); + }); + + describe("when updateStack throws an error", () => { + const err = Error("Some error"); + + beforeEach(() => { + portainer.updateStack.mockRejectedValue(err); + }); + + it("should fail", async () => { + const response = await sendRequest(); + + await expectErrorResponse(response, err); + }); + }); + + describe("when env.apiKey is set", () => { + describe("when no API key is provided", () => { + it("should fail", async () => { + const response = await sendRequest(null); + + await expectUnauthorizedResponse( + response, + "X-API-Key header is required", + ); + }); + }); + + describe("when an invalid API key is provided", () => { + it("should fail", async () => { + const response = await sendRequest("not" + API_KEY); + + await expectUnauthorizedResponse(response, "Invalid API key"); + }); + }); + }); + + describe("when env.apiKey is not set", () => { + beforeEach(() => { + env.apiKey = undefined; + }); + + it("should update the stack, pulling the latest images", async () => { + const response = await sendRequest(null); + + expectSuccess(response); + }); + }); + + it("should update the stack, pulling the latest images", async () => { + const response = await sendRequest(); + + expectSuccess(response); + }); + }); }); diff --git a/src/api/webhooks.ts b/src/api/webhooks.ts index 376e3e7..bd332c5 100644 --- a/src/api/webhooks.ts +++ b/src/api/webhooks.ts @@ -1,36 +1,71 @@ import { HttpStatus, createApp } from "@aklinker1/zeta"; -import { UpdateStackWebhookInput, UpdateStackWebhookOutput } from "../models"; +import { + UpdateStackWebhookByIdInput, + UpdateStackWebhookByNameInput, + UpdateStackWebhookOutput, +} from "../models"; import { authPlugin } from "../plugins/auth-plugin"; import { ctxPlugin } from "../plugins/ctx-plugin"; import { withLogging } from "../utils/with-logging"; +const updateStackById = async ( + stackId: number, + portainer: any, +): Promise => { + const [stack, stackFile] = await Promise.all([ + portainer.getStack(stackId), + portainer.getStackFile(stackId), + ]); + await portainer.updateStack(stackId, { + prune: false, + pullImage: true, + endpointId: stack.EndpointId, + stackFileContent: stackFile.StackFileContent, + }); +}; + export const webhooksApp = createApp() .use(authPlugin) .use(ctxPlugin) .post( - "/api/webhook/stacks/:stackId", + "/api/webhook/stacks/id/:stackId", + { + operationId: "updateStackWebhookById", + summary: "Update Stack Webhook by ID", + params: UpdateStackWebhookByIdInput, + responses: { + [HttpStatus.Accepted]: UpdateStackWebhookOutput, + }, + }, + withLogging( + "POST /api/webhook/stacks/id/:stackId", + async ({ params, portainer, status }: any) => { + await updateStackById(params.stackId, portainer); + return status(HttpStatus.Accepted, undefined); + }, + ) as any, + ) + .post( + "/api/webhook/stacks/name/:stackName", { - operationId: "updateStackWebhook", - summary: "Update Stack Webhook", - params: UpdateStackWebhookInput, + operationId: "updateStackWebhookByName", + summary: "Update Stack Webhook by Name", + params: UpdateStackWebhookByNameInput, responses: { [HttpStatus.Accepted]: UpdateStackWebhookOutput, }, }, withLogging( - "POST /api/webhook/stacks/:stackId", + "POST /api/webhook/stacks/name/:stackName", async ({ params, portainer, status }: any) => { - const [stack, stackFile] = await Promise.all([ - portainer.getStack(params.stackId), - portainer.getStackFile(params.stackId), - ]); - await portainer.updateStack(params.stackId, { - prune: false, - pullImage: true, - endpointId: stack.EndpointId, - stackFileContent: stackFile.StackFileContent, - }); + const stacks = await portainer.listStacks(); + const stackSummary = stacks.find( + (s: any) => s.Name === params.stackName, + ); + if (!stackSummary) + throw new Error(`Stack not found: ${params.stackName}`); + await updateStackById(stackSummary.Id, portainer); return status(HttpStatus.Accepted, undefined); }, ) as any, diff --git a/src/models.ts b/src/models.ts index 2d42c29..ef3d108 100644 --- a/src/models.ts +++ b/src/models.ts @@ -28,14 +28,27 @@ export const GetHealthOutput = z }); export type GetHealthOutput = z.infer; -export const UpdateStackWebhookInput = z +export const UpdateStackWebhookByIdInput = z .object({ stackId: z.coerce.number().int().min(0), }) .meta({ - ref: "UpdateStackWebhookInput", + ref: "UpdateStackWebhookByIdInput", }); -export type UpdateStackWebhookInput = z.infer; +export type UpdateStackWebhookByIdInput = z.infer< + typeof UpdateStackWebhookByIdInput +>; + +export const UpdateStackWebhookByNameInput = z + .object({ + stackName: z.string().trim().min(1), + }) + .meta({ + ref: "UpdateStackWebhookByNameInput", + }); +export type UpdateStackWebhookByNameInput = z.infer< + typeof UpdateStackWebhookByNameInput +>; export const UpdateStackWebhookOutput = NoResponse.meta({ responseDescription: "Stack update submitted", From f8cefec9e6d486251778b8b8e4c40e6fbd483f76 Mon Sep 17 00:00:00 2001 From: Chance Date: Mon, 22 Dec 2025 16:59:43 -0500 Subject: [PATCH 4/9] feat: update authentication method to support access token and legacy credentials chore: format code for improved readability in env.ts --- .env.template | 7 ++++-- README.md | 6 +++-- src/env.ts | 53 +++++++++++++++++++++++++++++++++--------- src/utils/portainer.ts | 25 +++++++++++++++----- 4 files changed, 70 insertions(+), 21 deletions(-) diff --git a/.env.template b/.env.template index f16bb35..ec49976 100644 --- a/.env.template +++ b/.env.template @@ -1,6 +1,9 @@ PORTAINER_API_URL=https://portainer.example.com/api -PORTAINER_USERNAME=admin -PORTAINER_PASSWORD=password +PORTAINER_ACCESS_TOKEN=your-access-token + +# Legacy credentials (only if no access token is provided) +# PORTAINER_USERNAME=admin +# PORTAINER_PASSWORD=password # Optional runtime configuration # Port to listen on (default 3000) diff --git a/README.md b/README.md index 8d80277..a70bdaf 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,10 @@ services: - 3000:3000 environment: PORTAINER_API_URL: https://portainer.example.com/api # Required, full URL including /api - PORTAINER_USERNAME: your-username # Required, username to login with - PORTAINER_PASSWORD: your-password # Required, password to login with + PORTAINER_ACCESS_TOKEN: your-access-token # Preferred, Portainer access token + # Legacy (falls back to username/password if no token is provided): + # PORTAINER_USERNAME: your-username # Username to login with + # PORTAINER_PASSWORD: your-password # Password to login with PORT: 3000 # Optional, default 3000 API_KEY: your-api-key # Optional, set to a any string to require authentication ``` diff --git a/src/env.ts b/src/env.ts index 69b0f1f..2a7ede0 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,25 +1,40 @@ import { violet, bold, red } from "./colors"; import { logger } from "./utils/logger"; +function fatalEnv(message: string): never { + logger.error("env.missing", { message }); + // keep the old visual warning too for very early startup clarity + console.log(`${red(bold("⚠ Fatal"))}: ${message}`); + process.exit(1); +} + function requireEnv(key: string): string { const value = process.env[key]; - if (!value) { - logger.error("env.missing", { message: `The ${key} env var is required` }); - // keep the old visual warning too for very early startup clarity - console.log( - `${red(bold("⚠ Fatal"))}: The ${violet(key)} env var is required`, - ); - process.exit(1); - } + if (!value) fatalEnv(`The ${violet(key)} env var is required`); return value; } +const portainerApiUrl = + process.env.BASE_URL || requireEnv("PORTAINER_API_URL"); +const portainerAccessToken = process.env.PORTAINER_ACCESS_TOKEN; +const portainerUsername = + process.env.USERNAME || process.env.PORTAINER_USERNAME; +const portainerPassword = + process.env.PASSWORD || process.env.PORTAINER_PASSWORD; + + if (!portainerAccessToken && (!portainerUsername || !portainerPassword)) { + fatalEnv( + "Set PORTAINER_ACCESS_TOKEN or both PORTAINER_USERNAME and PORTAINER_PASSWORD", + ); + } + export const env = { port: Number(process.env.PORT || 3000), portainer: { - apiUrl: process.env.BASE_URL || requireEnv("PORTAINER_API_URL"), - username: process.env.USERNAME || requireEnv("PORTAINER_USERNAME"), - password: process.env.PASSWORD || requireEnv("PORTAINER_PASSWORD"), + apiUrl: portainerApiUrl, + accessToken: portainerAccessToken, + username: portainerUsername, + password: portainerPassword, }, apiKey: process.env.API_KEY || undefined, }; @@ -35,6 +50,22 @@ export function logEnvWarnings() { }); } + if (env.portainer.accessToken) { + logger.info("env.portainer_auth", { + message: "Using PORTAINER_ACCESS_TOKEN; username/password not required", + }); + if (env.portainer.username || env.portainer.password) { + logger.warn("env.portainer_auth", { + message: + "PORTAINER_USERNAME or PORTAINER_PASSWORD set but ignored because PORTAINER_ACCESS_TOKEN is configured", + }); + } + } else { + logger.info("env.portainer_auth", { + message: "Using PORTAINER_USERNAME and PORTAINER_PASSWORD", + }); + } + maybeLogDeprecated("BASE_URL", "PORTAINER_API_URL"); maybeLogDeprecated("USERNAME", "PORTAINER_USERNAME"); maybeLogDeprecated("PASSWORD", "PORTAINER_PASSWORD"); diff --git a/src/utils/portainer.ts b/src/utils/portainer.ts index 70548da..62cce8f 100644 --- a/src/utils/portainer.ts +++ b/src/utils/portainer.ts @@ -11,7 +11,7 @@ export interface PortainerApi { } export function createPortainerApi(): PortainerApi { - const { apiUrl, username, password } = env.portainer; + const { apiUrl, accessToken, username, password } = env.portainer; const checkResponse = async (response: Response, expectedStatus = 200) => { if (response.status !== expectedStatus) @@ -25,6 +25,12 @@ export function createPortainerApi(): PortainerApi { const jwt = createTtlValue(60 * 60e3); // 1 hour (experimentally, it seems portainer JWTs last 8 hours) const login = async (): Promise => { + if (!username || !password) { + throw new InternalServerErrorHttpError( + "Portainer username/password are not configured", + ); + } + logger.info("portainer.jwt.fetch", { message: "Fetching new JWT for portainer...", }); @@ -45,13 +51,20 @@ export function createPortainerApi(): PortainerApi { return json; }; - const getAuthHeaders = async () => { - const value = jwt.getValue() || (await login().then((res) => res.jwt)); - return { - Authorization: `Bearer ${value}`, - }; + const getToken = async (): Promise => { + if (accessToken) return accessToken; + + const value = jwt.getValue(); + if (value) return value; + + const { jwt: freshJwt } = await login(); + return freshJwt; }; + const getAuthHeaders = async () => ({ + Authorization: `Bearer ${await getToken()}`, + }); + const listStacks: PortainerApi["listStacks"] = async () => { const res = await fetch(`${apiUrl}/stacks`, { headers: await getAuthHeaders(), From a1b033a3a0ddf85e4c14b4b3abcb2f7d928395ca Mon Sep 17 00:00:00 2001 From: Chance Date: Mon, 22 Dec 2025 17:09:25 -0500 Subject: [PATCH 5/9] chore: format code for consistency in env.ts --- src/env.ts | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/env.ts b/src/env.ts index 2a7ede0..a204550 100644 --- a/src/env.ts +++ b/src/env.ts @@ -8,25 +8,30 @@ function fatalEnv(message: string): never { process.exit(1); } +function readEnv(key: string): string | undefined { + const raw = process.env[key]; + if (!raw) return undefined; + const value = raw.trim(); + return value === "" ? undefined : value; +} + function requireEnv(key: string): string { - const value = process.env[key]; + const value = readEnv(key); if (!value) fatalEnv(`The ${violet(key)} env var is required`); return value; } -const portainerApiUrl = - process.env.BASE_URL || requireEnv("PORTAINER_API_URL"); -const portainerAccessToken = process.env.PORTAINER_ACCESS_TOKEN; -const portainerUsername = - process.env.USERNAME || process.env.PORTAINER_USERNAME; -const portainerPassword = - process.env.PASSWORD || process.env.PORTAINER_PASSWORD; +const baseUrl = readEnv("BASE_URL"); +const portainerApiUrl = baseUrl || requireEnv("PORTAINER_API_URL"); +const portainerAccessToken = readEnv("PORTAINER_ACCESS_TOKEN"); +const portainerUsername = readEnv("USERNAME") || readEnv("PORTAINER_USERNAME"); +const portainerPassword = readEnv("PASSWORD") || readEnv("PORTAINER_PASSWORD"); - if (!portainerAccessToken && (!portainerUsername || !portainerPassword)) { - fatalEnv( - "Set PORTAINER_ACCESS_TOKEN or both PORTAINER_USERNAME and PORTAINER_PASSWORD", - ); - } +if (!portainerAccessToken && (!portainerUsername || !portainerPassword)) { + fatalEnv( + "Set PORTAINER_ACCESS_TOKEN or both PORTAINER_USERNAME and PORTAINER_PASSWORD", + ); +} export const env = { port: Number(process.env.PORT || 3000), From 7c587818cbb63149df14b0e98061f1524b7a02ba Mon Sep 17 00:00:00 2001 From: Chance Date: Mon, 22 Dec 2025 17:33:37 -0500 Subject: [PATCH 6/9] feat: add endpointId to stack responses and update related API functionality --- README.md | 8 ++-- openapi.json | 20 ++++++++- src/__tests__/app.test.ts | 91 ++++++++++++++++++++++++++++++++++----- src/api/stacks.ts | 1 + src/api/webhooks.ts | 38 +++++++++++++--- src/models.ts | 12 ++++++ 6 files changed, 150 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index a70bdaf..c3dd9e2 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ curl -H "X-API-Key: " http://localhost:3000/api/health ### List stacks -Returns `id` and `name` for each stack (useful for selecting `stackId` or `stackName`). +Returns `id`, `name`, and `endpointId` for each stack (useful for selecting `stackId` or `stackName` and disambiguating identical stack names across environments). ```sh # no auth @@ -71,10 +71,12 @@ Pull latest images and redeploy a stack by name. ```sh # no auth -curl -X POST http://localhost:3000/api/webhook/stacks/name/:stackName +curl -X POST "http://localhost:3000/api/webhook/stacks/name/:stackName?endpointId=:endpointId" # with auth -curl -X POST -H "X-API-Key: " http://localhost:3000/api/webhook/stacks/name/:stackName +curl -X POST -H "X-API-Key: " "http://localhost:3000/api/webhook/stacks/name/:stackName?endpointId=:endpointId" + +> `endpointId` is optional unless multiple stacks share the same name across environments; provide it to target the correct stack. ``` ## Contributing diff --git a/openapi.json b/openapi.json index e620e44..4bc42b9 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Portainer Stack Webhook", - "version": "0.2.3" + "version": "0.2.5" }, "paths": { "/api/health": { @@ -88,6 +88,16 @@ "type": "string" }, "required": true + }, + { + "name": "endpointId", + "in": "query", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "required": false } ], "responses": { @@ -174,11 +184,17 @@ }, "name": { "type": "string" + }, + "endpointId": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 } }, "required": [ "id", - "name" + "name", + "endpointId" ], "additionalProperties": false }, diff --git a/src/__tests__/app.test.ts b/src/__tests__/app.test.ts index 4ba081f..b1fc16a 100644 --- a/src/__tests__/app.test.ts +++ b/src/__tests__/app.test.ts @@ -66,8 +66,16 @@ describe("App Integration Tests", async () => { portainerStackFactory(), ]; const expected: ListStacksOutput = [ - { id: stacks[0]!.Id, name: stacks[0]!.Name }, - { id: stacks[1]!.Id, name: stacks[1]!.Name }, + { + id: stacks[0]!.Id, + name: stacks[0]!.Name, + endpointId: stacks[0]!.EndpointId, + }, + { + id: stacks[1]!.Id, + name: stacks[1]!.Name, + endpointId: stacks[1]!.EndpointId, + }, ]; const sendRequest = async (apiKey: string | null = API_KEY) => { @@ -150,11 +158,14 @@ describe("App Integration Tests", async () => { ); }; - const expectSuccess = (response: Response) => { + const expectSuccess = ( + response: Response, + expectedStack: PortainerStack = stack, + ) => { expect(response.status).toBe(HttpStatus.Accepted); expect(portainer.updateStack).toBeCalledTimes(1); - expect(portainer.updateStack).toBeCalledWith(stackId, { - endpointId, + expect(portainer.updateStack).toBeCalledWith(expectedStack.Id, { + endpointId: expectedStack.EndpointId, stackFileContent, prune: false, pullImage: true, @@ -258,24 +269,35 @@ describe("App Integration Tests", async () => { const endpointId = stack.EndpointId; const stackFileContent = ""; - const sendRequest = async (apiKey: string | null = API_KEY) => { + const sendRequest = async ( + apiKey: string | null = API_KEY, + endpointId?: number, + ) => { const headers: Record = { "X-Forwarded-For": "203.0.113.5", }; if (apiKey) headers["X-API-Key"] = apiKey; + const url = new URL( + `http://localhost/api/webhook/stacks/name/${stackName}`, + ); + if (endpointId !== undefined) + url.searchParams.set("endpointId", String(endpointId)); return fetch( - new Request(`http://localhost/api/webhook/stacks/name/${stackName}`, { + new Request(url, { method: "POST", headers, }), ); }; - const expectSuccess = (response: Response) => { + const expectSuccess = ( + response: Response, + expectedStack: PortainerStack = stack, + ) => { expect(response.status).toBe(HttpStatus.Accepted); expect(portainer.updateStack).toBeCalledTimes(1); - expect(portainer.updateStack).toBeCalledWith(stackId, { - endpointId, + expect(portainer.updateStack).toBeCalledWith(expectedStack.Id, { + endpointId: expectedStack.EndpointId, stackFileContent, prune: false, pullImage: true, @@ -304,6 +326,38 @@ describe("App Integration Tests", async () => { expect(portainer.updateStack).not.toBeCalled(); }); + it("should fail when multiple stacks share the name but no endpoint is provided", async () => { + const otherStack = portainerStackFactory({ + Name: stackName, + EndpointId: stack.EndpointId + 1, + }); + portainer.listStacks.mockResolvedValue([stack, otherStack]); + + const response = await sendRequest(); + + await expectErrorResponse( + response, + new Error( + `Multiple stacks found with name ${stackName}; provide endpointId or use stack ID`, + ), + ); + expect(portainer.getStack).not.toBeCalled(); + expect(portainer.updateStack).not.toBeCalled(); + }); + + it("should fail when the endpointId does not match a stack with the provided name", async () => { + portainer.listStacks.mockResolvedValue([stack]); + + const response = await sendRequest(API_KEY, stack.EndpointId + 100); + + await expectErrorResponse( + response, + new Error(`Stack not found: ${stackName} on endpoint ${stack.EndpointId + 100}`), + ); + expect(portainer.getStack).not.toBeCalled(); + expect(portainer.updateStack).not.toBeCalled(); + }); + describe("when getStack throws an error", () => { const err = Error("Not Found"); @@ -384,5 +438,22 @@ describe("App Integration Tests", async () => { expectSuccess(response); }); + + it("should update the stack matching the provided endpointId when names collide", async () => { + const targetStack = portainerStackFactory({ + Name: stackName, + EndpointId: stack.EndpointId + 10, + }); + const otherStack = portainerStackFactory({ + Name: stackName, + EndpointId: stack.EndpointId + 20, + }); + portainer.listStacks.mockResolvedValue([otherStack, targetStack]); + portainer.getStack.mockResolvedValue(targetStack); + + const response = await sendRequest(API_KEY, targetStack.EndpointId); + + expectSuccess(response, targetStack); + }); }); }); diff --git a/src/api/stacks.ts b/src/api/stacks.ts index df65ce3..e9db3d4 100644 --- a/src/api/stacks.ts +++ b/src/api/stacks.ts @@ -12,6 +12,7 @@ const listStacksHandler: any = withLogging( return stacks.map((stack: any) => ({ id: stack.Id, name: stack.Name, + endpointId: stack.EndpointId, })); }, ); diff --git a/src/api/webhooks.ts b/src/api/webhooks.ts index bd332c5..4b76686 100644 --- a/src/api/webhooks.ts +++ b/src/api/webhooks.ts @@ -2,6 +2,7 @@ import { HttpStatus, createApp } from "@aklinker1/zeta"; import { UpdateStackWebhookByIdInput, UpdateStackWebhookByNameInput, + UpdateStackWebhookByNameQuery, UpdateStackWebhookOutput, } from "../models"; import { authPlugin } from "../plugins/auth-plugin"; @@ -24,6 +25,32 @@ const updateStackById = async ( }); }; +const findStackByName = ( + stacks: any[], + stackName: string, + endpointId?: number, +) => { + const matches = stacks.filter((s: any) => s.Name === stackName); + if (matches.length === 0) + throw new Error(`Stack not found: ${stackName}`); + + if (endpointId !== undefined) { + const match = matches.find((s: any) => s.EndpointId === endpointId); + if (!match) + throw new Error( + `Stack not found: ${stackName} on endpoint ${endpointId}`, + ); + return match; + } + + if (matches.length > 1) + throw new Error( + `Multiple stacks found with name ${stackName}; provide endpointId or use stack ID`, + ); + + return matches[0]; +}; + export const webhooksApp = createApp() .use(authPlugin) .use(ctxPlugin) @@ -51,19 +78,20 @@ export const webhooksApp = createApp() operationId: "updateStackWebhookByName", summary: "Update Stack Webhook by Name", params: UpdateStackWebhookByNameInput, + query: UpdateStackWebhookByNameQuery, responses: { [HttpStatus.Accepted]: UpdateStackWebhookOutput, }, }, withLogging( "POST /api/webhook/stacks/name/:stackName", - async ({ params, portainer, status }: any) => { + async ({ params, query, portainer, status }: any) => { const stacks = await portainer.listStacks(); - const stackSummary = stacks.find( - (s: any) => s.Name === params.stackName, + const stackSummary = findStackByName( + stacks, + params.stackName, + query?.endpointId, ); - if (!stackSummary) - throw new Error(`Stack not found: ${params.stackName}`); await updateStackById(stackSummary.Id, portainer); return status(HttpStatus.Accepted, undefined); diff --git a/src/models.ts b/src/models.ts index ef3d108..add4200 100644 --- a/src/models.ts +++ b/src/models.ts @@ -5,6 +5,7 @@ export const Stack = z .object({ id: z.int(), name: z.string(), + endpointId: z.int(), }) .meta({ ref: "Stack", @@ -50,6 +51,17 @@ export type UpdateStackWebhookByNameInput = z.infer< typeof UpdateStackWebhookByNameInput >; +export const UpdateStackWebhookByNameQuery = z + .object({ + endpointId: z.coerce.number().int().min(0).optional(), + }) + .meta({ + ref: "UpdateStackWebhookByNameQuery", + }); +export type UpdateStackWebhookByNameQuery = z.infer< + typeof UpdateStackWebhookByNameQuery +>; + export const UpdateStackWebhookOutput = NoResponse.meta({ responseDescription: "Stack update submitted", }); From 13b180a523f9a26ad6bc0b85f6a6ec6ee7343ee3 Mon Sep 17 00:00:00 2001 From: Chance Date: Mon, 22 Dec 2025 17:48:04 -0500 Subject: [PATCH 7/9] feat: enhance error handling and logging in API calls and tests --- src/__tests__/app.test.ts | 7 +++-- src/api/webhooks.ts | 3 +-- src/plugins/auth-plugin.ts | 23 ++++++++++++++--- src/utils/portainer.ts | 53 ++++++++++++++++++++++++++++---------- 4 files changed, 63 insertions(+), 23 deletions(-) diff --git a/src/__tests__/app.test.ts b/src/__tests__/app.test.ts index b1fc16a..3402bd0 100644 --- a/src/__tests__/app.test.ts +++ b/src/__tests__/app.test.ts @@ -142,7 +142,6 @@ describe("App Integration Tests", async () => { describe("POST /api/webhook/stacks/id/{id}", () => { const stack = portainerStackFactory(); const stackId = stack.Id; - const endpointId = stack.EndpointId; const stackFileContent = ""; const sendRequest = async (apiKey: string | null = API_KEY) => { @@ -265,8 +264,6 @@ describe("App Integration Tests", async () => { describe("POST /api/webhook/stacks/name/{name}", () => { const stack = portainerStackFactory(); const stackName = stack.Name; - const stackId = stack.Id; - const endpointId = stack.EndpointId; const stackFileContent = ""; const sendRequest = async ( @@ -352,7 +349,9 @@ describe("App Integration Tests", async () => { await expectErrorResponse( response, - new Error(`Stack not found: ${stackName} on endpoint ${stack.EndpointId + 100}`), + new Error( + `Stack not found: ${stackName} on endpoint ${stack.EndpointId + 100}`, + ), ); expect(portainer.getStack).not.toBeCalled(); expect(portainer.updateStack).not.toBeCalled(); diff --git a/src/api/webhooks.ts b/src/api/webhooks.ts index 4b76686..6bc04a9 100644 --- a/src/api/webhooks.ts +++ b/src/api/webhooks.ts @@ -31,8 +31,7 @@ const findStackByName = ( endpointId?: number, ) => { const matches = stacks.filter((s: any) => s.Name === stackName); - if (matches.length === 0) - throw new Error(`Stack not found: ${stackName}`); + if (matches.length === 0) throw new Error(`Stack not found: ${stackName}`); if (endpointId !== undefined) { const match = matches.find((s: any) => s.EndpointId === endpointId); diff --git a/src/plugins/auth-plugin.ts b/src/plugins/auth-plugin.ts index 1a22619..b04dcc6 100644 --- a/src/plugins/auth-plugin.ts +++ b/src/plugins/auth-plugin.ts @@ -7,23 +7,38 @@ export const authPlugin = createApp() // Allow unauthenticated requests if no API key is provided if (!env.apiKey) return; + const method = request.method; + const path = new URL(request.url).pathname; const apiKey = request.headers.get("x-api-key"); if (!apiKey) { logger.warn("auth.missing_api_key", { - method: request.method, - path: new URL(request.url).pathname, + method, + path, client: request.headers.get("x-forwarded-for") ?? request.headers.get("x-real-ip"), + expectedApiKey: env.apiKey, }); throw new UnauthorizedHttpError("X-API-Key header is required"); } if (apiKey !== env.apiKey) { logger.warn("auth.invalid_api_key", { - method: request.method, - path: new URL(request.url).pathname, + method, + path, + providedApiKey: apiKey, + expectedApiKey: env.apiKey, }); throw new UnauthorizedHttpError("Invalid API key"); } + + logger.info("auth.api_key.accepted", { + method, + path, + providedApiKey: apiKey, + expectedApiKey: env.apiKey, + client: + request.headers.get("x-forwarded-for") ?? + request.headers.get("x-real-ip"), + }); }) .export(); diff --git a/src/utils/portainer.ts b/src/utils/portainer.ts index 62cce8f..500926f 100644 --- a/src/utils/portainer.ts +++ b/src/utils/portainer.ts @@ -13,12 +13,33 @@ export interface PortainerApi { export function createPortainerApi(): PortainerApi { const { apiUrl, accessToken, username, password } = env.portainer; - const checkResponse = async (response: Response, expectedStatus = 200) => { + const fetchPortainer = async (url: string, init?: RequestInit) => { + try { + return await fetch(url, init); + } catch (err) { + logger.error("portainer.fetch_failed", { + url, + method: init?.method ?? "GET", + error: String(err), + }); + throw new InternalServerErrorHttpError( + `Unable to connect to Portainer at ${url}`, + { cause: err }, + ); + } + }; + + const checkResponse = async ( + response: Response, + expectedStatus = 200, + url?: string, + ) => { if (response.status !== expectedStatus) throw new PortainerApiError( expectedStatus, response, await response.text(), + url, ); }; @@ -35,7 +56,8 @@ export function createPortainerApi(): PortainerApi { message: "Fetching new JWT for portainer...", }); - const res = await fetch(`${apiUrl}/auth`, { + const url = `${apiUrl}/auth`; + const res = await fetchPortainer(url, { body: JSON.stringify({ username, password }), method: "POST", headers: { @@ -43,7 +65,7 @@ export function createPortainerApi(): PortainerApi { }, }); - await checkResponse(res); + await checkResponse(res, 200, url); const json = (await res.json()) as PortainerLoginResponse; jwt.setValue(json.jwt); @@ -66,29 +88,32 @@ export function createPortainerApi(): PortainerApi { }); const listStacks: PortainerApi["listStacks"] = async () => { - const res = await fetch(`${apiUrl}/stacks`, { + const url = `${apiUrl}/stacks`; + const res = await fetchPortainer(url, { headers: await getAuthHeaders(), }); - await checkResponse(res); + await checkResponse(res, 200, url); return (await res.json()) as any; }; const getStack: PortainerApi["getStack"] = async (id) => { - const res = await fetch(`${apiUrl}/stacks/${id}`, { + const url = `${apiUrl}/stacks/${id}`; + const res = await fetchPortainer(url, { headers: await getAuthHeaders(), }); - await checkResponse(res); + await checkResponse(res, 200, url); return (await res.json()) as any; }; const getStackFile: PortainerApi["getStackFile"] = async (id) => { - const res = await fetch(`${apiUrl}/stacks/${id}/file`, { + const url = `${apiUrl}/stacks/${id}/file`; + const res = await fetchPortainer(url, { headers: await getAuthHeaders(), }); - await checkResponse(res); + await checkResponse(res, 200, url); return (await res.json()) as any; }; @@ -99,7 +124,7 @@ export function createPortainerApi(): PortainerApi { const updateUrl = new URL(`${apiUrl}/stacks/${id}`); updateUrl.searchParams.set("endpointId", String(options.endpointId)); - const res = await fetch(updateUrl.href, { + const res = await fetchPortainer(updateUrl.href, { method: "PUT", body: JSON.stringify({ prune: options.prune, @@ -112,7 +137,7 @@ export function createPortainerApi(): PortainerApi { }, }); - await checkResponse(res); + await checkResponse(res, 200, updateUrl.href); }; return { @@ -149,11 +174,13 @@ export class PortainerApiError extends InternalServerErrorHttpError { expectedStatus: number, response: Response, readonly text: string, + readonly url?: string, options?: ErrorOptions, ) { super( - `Request to Portainer API failed. Expected status ${expectedStatus}, received ${response.status}`, - { response, text }, + `Request to Portainer API failed. Expected status ${expectedStatus}, received ${response.status}` + + (url ? ` for ${url}` : ""), + { response, text, url }, options, ); } From b8894e24f52c5439cfd31974d232aec54cab58ff Mon Sep 17 00:00:00 2001 From: Chance Date: Mon, 22 Dec 2025 18:03:08 -0500 Subject: [PATCH 8/9] feat: update authentication headers to support access token and API key --- src/utils/portainer.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/utils/portainer.ts b/src/utils/portainer.ts index 500926f..4f8aa75 100644 --- a/src/utils/portainer.ts +++ b/src/utils/portainer.ts @@ -84,7 +84,9 @@ export function createPortainerApi(): PortainerApi { }; const getAuthHeaders = async () => ({ - Authorization: `Bearer ${await getToken()}`, + ...(accessToken?.startsWith("ptr_") + ? { "X-API-Key": accessToken } + : { Authorization: `Bearer ${await getToken()}` }), }); const listStacks: PortainerApi["listStacks"] = async () => { From 4b5454c71c7277f828e51c1ddf61bdfc343426e2 Mon Sep 17 00:00:00 2001 From: Chance Date: Mon, 22 Dec 2025 18:06:58 -0500 Subject: [PATCH 9/9] feat: add error logging for stack update failures in webhooks --- src/api/webhooks.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/api/webhooks.ts b/src/api/webhooks.ts index 6bc04a9..e0bdedd 100644 --- a/src/api/webhooks.ts +++ b/src/api/webhooks.ts @@ -7,6 +7,7 @@ import { } from "../models"; import { authPlugin } from "../plugins/auth-plugin"; import { ctxPlugin } from "../plugins/ctx-plugin"; +import { logger } from "../utils/logger"; import { withLogging } from "../utils/with-logging"; const updateStackById = async ( @@ -66,7 +67,13 @@ export const webhooksApp = createApp() withLogging( "POST /api/webhook/stacks/id/:stackId", async ({ params, portainer, status }: any) => { - await updateStackById(params.stackId, portainer); + void updateStackById(params.stackId, portainer).catch((error) => { + logger.error("webhook.update_failed", { + route: "POST /api/webhook/stacks/id/:stackId", + stackId: params.stackId, + error: String(error), + }); + }); return status(HttpStatus.Accepted, undefined); }, ) as any, @@ -92,7 +99,15 @@ export const webhooksApp = createApp() query?.endpointId, ); - await updateStackById(stackSummary.Id, portainer); + void updateStackById(stackSummary.Id, portainer).catch((error) => { + logger.error("webhook.update_failed", { + route: "POST /api/webhook/stacks/name/:stackName", + stackId: stackSummary.Id, + stackName: params.stackName, + endpointId: stackSummary.EndpointId, + error: String(error), + }); + }); return status(HttpStatus.Accepted, undefined); }, ) as any,