diff --git a/CHANGELOG.md b/CHANGELOG.md index fd60d7a3..edd15464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +* **registries:** add private registry credential management (Team Pro). Store encrypted credentials for Docker Hub, GHCR, AWS ECR, and self-hosted registries. Credentials are automatically injected during deploy/pull via temporary DOCKER_CONFIG. ECR short-lived tokens are refreshed on every operation. Image update checks now authenticate against private registries. + ### Changed * **ui:** redesign top bar into three-zone layout — node pill (left), animated navigation group (center), utilities (right). Replaces flat row of individual buttons with a cohesive tab-style navigation using animated sliding highlight. Includes responsive behavior: icon+text at xl, icons-only at md, sheet drawer on mobile. diff --git a/backend/package-lock.json b/backend/package-lock.json index 2ddaa86e..62d8c7e3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { + "@aws-sdk/client-ecr": "^3.1019.0", "@types/cors": "^2.8.19", "@types/dockerode": "^4.0.1", "@types/express": "^5.0.6", @@ -55,6 +56,612 @@ "vitest": "^4.1.0" } }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-ecr": { + "version": "3.1019.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ecr/-/client-ecr-3.1019.0.tgz", + "integrity": "sha512-lt/BsmFm2JUcqvgK5ifEZxVmOGmzFl5eGtS4QzhSf7zsN1u55qU3K7iTP5nMfR+eQO02I/72x5ZbexSCQumK/A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.25", + "@aws-sdk/credential-provider-node": "^3.972.27", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.9", + "@aws-sdk/middleware-user-agent": "^3.972.26", + "@aws-sdk/region-config-resolver": "^3.972.10", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.12", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.12", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.27", + "@smithy/middleware-retry": "^4.4.44", + "@smithy/middleware-serde": "^4.2.15", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.43", + "@smithy/util-defaults-mode-node": "^4.2.47", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.2.13", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.973.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.25.tgz", + "integrity": "sha512-TNrx7eq6nKNOO62HWPqoBqPLXEkW6nLZQGwjL6lq1jZtigWYbK1NbCnT7mKDzbLMHZfuOECUt3n6CzxjUW9HWQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/xml-builder": "^3.972.16", + "@smithy/core": "^3.23.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.23.tgz", + "integrity": "sha512-EamaclJcCEaPHp6wiVknNMM2RlsPMjAHSsYSFLNENBM8Wz92QPc6cOn3dif6vPDQt0Oo4IEghDy3NMDCzY/IvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.25", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.25.tgz", + "integrity": "sha512-qPymamdPcLp6ugoVocG1y5r69ScNiRzb0hogX25/ij+Wz7c7WnsgjLTaz7+eB5BfRxeyUwuw5hgULMuwOGOpcw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.25", + "@aws-sdk/types": "^3.973.6", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "@smithy/util-stream": "^4.5.20", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.26.tgz", + "integrity": "sha512-xKxEAMuP6GYx2y5GET+d3aGEroax3AgGfwBE65EQAUe090lzyJ/RzxPX9s8v7Z6qAk0XwfQl+LrmH05X7YvTeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.25", + "@aws-sdk/credential-provider-env": "^3.972.23", + "@aws-sdk/credential-provider-http": "^3.972.25", + "@aws-sdk/credential-provider-login": "^3.972.26", + "@aws-sdk/credential-provider-process": "^3.972.23", + "@aws-sdk/credential-provider-sso": "^3.972.26", + "@aws-sdk/credential-provider-web-identity": "^3.972.26", + "@aws-sdk/nested-clients": "^3.996.16", + "@aws-sdk/types": "^3.973.6", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.26.tgz", + "integrity": "sha512-EFcM8RM3TUxnZOfMJo++3PnyxFu1fL/huzmn3Vh+8IWRgqZawUD3cRwwOr+/4bE9DpyHaLOWFAjY0lfK5X9ZkQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.25", + "@aws-sdk/nested-clients": "^3.996.16", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.27.tgz", + "integrity": "sha512-jXpxSolfFnPVj6GCTtx3xIdWNoDR7hYC/0SbetGZxOC9UnNmipHeX1k6spVstf7eWJrMhXNQEgXC0pD1r5tXIg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.23", + "@aws-sdk/credential-provider-http": "^3.972.25", + "@aws-sdk/credential-provider-ini": "^3.972.26", + "@aws-sdk/credential-provider-process": "^3.972.23", + "@aws-sdk/credential-provider-sso": "^3.972.26", + "@aws-sdk/credential-provider-web-identity": "^3.972.26", + "@aws-sdk/types": "^3.973.6", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.23.tgz", + "integrity": "sha512-IL/TFW59++b7MpHserjUblGrdP5UXy5Ekqqx1XQkERXBFJcZr74I7VaSrQT5dxdRMU16xGK4L0RQ5fQG1pMgnA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.25", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.26.tgz", + "integrity": "sha512-c6ghvRb6gTlMznWhGxn/bpVCcp0HRaz4DobGVD9kI4vwHq186nU2xN/S7QGkm0lo0H2jQU8+dgpUFLxfTcwCOg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.25", + "@aws-sdk/nested-clients": "^3.996.16", + "@aws-sdk/token-providers": "3.1019.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.26.tgz", + "integrity": "sha512-cXcS3+XD3iwhoXkM44AmxjmbcKueoLCINr1e+IceMmCySda5ysNIfiGBGe9qn5EMiQ9Jd7pP0AGFtcd6OV3Lvg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.25", + "@aws-sdk/nested-clients": "^3.996.16", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.8.tgz", + "integrity": "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.8.tgz", + "integrity": "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.9.tgz", + "integrity": "sha512-/Wt5+CT8dpTFQxEJ9iGy/UGrXr7p2wlIOEHvIr/YcHYByzoLjrqkYqXdJjd9UIgWjv7eqV2HnFJen93UTuwfTQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.26.tgz", + "integrity": "sha512-AilFIh4rI/2hKyyGN6XrB0yN96W2o7e7wyrPWCM6QjZM1mcC/pVkW3IWWRvuBWMpVP8Fg+rMpbzeLQ6dTM4gig==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.25", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@smithy/core": "^3.23.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-retry": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.996.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.16.tgz", + "integrity": "sha512-L7Qzoj/qQU1cL5GnYLQP5LbI+wlLCLoINvcykR3htKcQ4tzrPf2DOs72x933BM7oArYj1SKrkb2lGlsJHIic3g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.25", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.9", + "@aws-sdk/middleware-user-agent": "^3.972.26", + "@aws-sdk/region-config-resolver": "^3.972.10", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.12", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.12", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.27", + "@smithy/middleware-retry": "^4.4.44", + "@smithy/middleware-serde": "^4.2.15", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.43", + "@smithy/util-defaults-mode-node": "^4.2.47", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.10.tgz", + "integrity": "sha512-1dq9ToC6e070QvnVhhbAs3bb5r6cQ10gTVc6cyRV5uvQe7P138TV2uG2i6+Yok4bAkVAcx5AqkTEBUvWEtBlsQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/config-resolver": "^4.4.13", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1019.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1019.0.tgz", + "integrity": "sha512-OF+2RfRmUKyjzrRWlDcyju3RBsuqcrYDQ8TwrJg8efcOotMzuZN4U9mpVTIdATpmEc4lWNZBMSjPzrGm6JPnAQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.25", + "@aws-sdk/nested-clients": "^3.996.16", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.6.tgz", + "integrity": "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.5.tgz", + "integrity": "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-endpoints": "^3.3.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.8.tgz", + "integrity": "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.12.tgz", + "integrity": "sha512-8phW0TS8ntENJgDcFewYT/Q8dOmarpvSxEjATu2GUBAutiHr++oEGCiBUwxslCMNvwW2cAPZNT53S/ym8zm/gg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "^3.972.26", + "@aws-sdk/types": "^3.973.6", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.16.tgz", + "integrity": "sha512-iu2pyvaqmeatIJLURLqx9D+4jKAdTH20ntzB6BFwjyN7V960r4jK32mx0Zf7YbtOYAbmbtQfDNuL60ONinyw7A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "fast-xml-parser": "5.5.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@balena/dockerignore": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz", @@ -751,6 +1358,601 @@ "dev": true, "license": "MIT" }, + "node_modules/@smithy/abort-controller": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.12.tgz", + "integrity": "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.13", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.13.tgz", + "integrity": "sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.23.12", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.12.tgz", + "integrity": "sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.20", + "@smithy/util-utf8": "^4.2.2", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.12.tgz", + "integrity": "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.15", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.15.tgz", + "integrity": "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.12", + "@smithy/querystring-builder": "^4.2.12", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.12.tgz", + "integrity": "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.12.tgz", + "integrity": "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", + "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.12.tgz", + "integrity": "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.27", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.27.tgz", + "integrity": "sha512-T3TFfUgXQlpcg+UdzcAISdZpj4Z+XECZ/cefgA6wLBd6V4lRi0svN2hBouN/be9dXQ31X4sLWz3fAQDf+nt6BA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.12", + "@smithy/middleware-serde": "^4.2.15", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-middleware": "^4.2.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.44", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.44.tgz", + "integrity": "sha512-Y1Rav7m5CFRPQyM4CI0koD/bXjyjJu3EQxZZhtLGD88WIrBrQ7kqXM96ncd6rYnojwOo/u9MXu57JrEvu/nLrA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/service-error-classification": "^4.2.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.15", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.15.tgz", + "integrity": "sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.12.tgz", + "integrity": "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.12", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.12.tgz", + "integrity": "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.0.tgz", + "integrity": "sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/querystring-builder": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.12.tgz", + "integrity": "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.12.tgz", + "integrity": "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.12.tgz", + "integrity": "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-uri-escape": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.12.tgz", + "integrity": "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.12.tgz", + "integrity": "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.7.tgz", + "integrity": "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.12.tgz", + "integrity": "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-uri-escape": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.12.7", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.7.tgz", + "integrity": "sha512-q3gqnwml60G44FECaEEsdQMplYhDMZYCtYhMCzadCnRnnHIobZJjegmdoUo6ieLQlPUzvrMdIJUpx6DoPmzANQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.12", + "@smithy/middleware-endpoint": "^4.4.27", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-stream": "^4.5.20", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz", + "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.12.tgz", + "integrity": "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", + "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", + "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", + "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", + "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", + "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.43", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.43.tgz", + "integrity": "sha512-Qd/0wCKMaXxev/z00TvNzGCH2jlKKKxXP1aDxB6oKwSQthe3Og2dMhSayGCnsma1bK/kQX1+X7SMP99t6FgiiQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.47", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.47.tgz", + "integrity": "sha512-qSRbYp1EQ7th+sPFuVcVO05AE0QH635hycdEXlpzIahqHHf2Fyd/Zl+8v0XYMJ3cgDVPa0lkMefU7oNUjAP+DQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.13", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.3.tgz", + "integrity": "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", + "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.12.tgz", + "integrity": "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.12.tgz", + "integrity": "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.20", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.20.tgz", + "integrity": "sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", + "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.13.tgz", + "integrity": "sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", + "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1568,6 +2770,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", @@ -2575,6 +3783,41 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.8", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", + "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -4111,6 +5354,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", + "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -4803,6 +6061,18 @@ "node": ">=0.10.0" } }, + "node_modules/strnum": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", + "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/superagent": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", @@ -5073,9 +6343,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/tunnel-agent": { "version": "0.6.0", diff --git a/backend/package.json b/backend/package.json index b23dd205..8a1a612e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -33,6 +33,7 @@ "vitest": "^4.1.0" }, "dependencies": { + "@aws-sdk/client-ecr": "^3.1019.0", "@types/cors": "^2.8.19", "@types/dockerode": "^4.0.1", "@types/express": "^5.0.6", diff --git a/backend/src/index.ts b/backend/src/index.ts index 0ca8d8e7..35628d9c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -29,6 +29,7 @@ import { LicenseService } from './services/LicenseService'; import { WebhookService } from './services/WebhookService'; import { SSOService } from './services/SSOService'; import { SchedulerService } from './services/SchedulerService'; +import { RegistryService } from './services/RegistryService'; import { CronExpressionParser } from 'cron-parser'; import { isValidStackName, isValidRemoteUrl } from './utils/validation'; import YAML from 'yaml'; @@ -691,6 +692,9 @@ const AUDIT_ROUTE_SUMMARIES: Record = { 'PUT /scheduled-tasks': 'Updated scheduled task', 'DELETE /scheduled-tasks': 'Deleted scheduled task', 'PATCH /scheduled-tasks': 'Toggled scheduled task', + 'POST /registries': 'Created registry credential', + 'PUT /registries': 'Updated registry credential', + 'DELETE /registries': 'Deleted registry credential', }; function getAuditSummary(method: string, apiPath: string): string { @@ -3664,6 +3668,126 @@ app.get('/api/scheduled-tasks/:id/runs', (req: Request, res: Response): void => } }); +// --- Private Registry Routes (Team Pro, admin-only, local-only) --- + +const VALID_REGISTRY_TYPES = ['dockerhub', 'ghcr', 'ecr', 'custom'] as const; + +app.get('/api/registries', (req: Request, res: Response): void => { + if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; } + if (!requireAdmin(req, res)) return; + if (!requireTeamPro(req, res)) return; + try { + res.json(RegistryService.getInstance().getAll()); + } catch (error) { + console.error('[Registries] List error:', error); + res.status(500).json({ error: 'Failed to fetch registries' }); + } +}); + +app.post('/api/registries', (req: Request, res: Response): void => { + if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; } + if (!requireAdmin(req, res)) return; + if (!requireTeamPro(req, res)) return; + try { + const { name, url, type, username, secret, aws_region } = req.body; + + if (!name || typeof name !== 'string' || name.length > 100) { + res.status(400).json({ error: 'Name is required (max 100 characters).' }); return; + } + if (!url || typeof url !== 'string' || url.length > 500) { + res.status(400).json({ error: 'URL is required (max 500 characters).' }); return; + } + if (!type || !VALID_REGISTRY_TYPES.includes(type)) { + res.status(400).json({ error: `Type must be one of: ${VALID_REGISTRY_TYPES.join(', ')}` }); return; + } + if (!username || typeof username !== 'string') { + res.status(400).json({ error: 'Username is required.' }); return; + } + if (!secret || typeof secret !== 'string') { + res.status(400).json({ error: 'Secret/token is required.' }); return; + } + if (type === 'ecr' && (!aws_region || typeof aws_region !== 'string')) { + res.status(400).json({ error: 'AWS region is required for ECR registries.' }); return; + } + + const id = RegistryService.getInstance().create({ name, url, type, username, secret, aws_region: aws_region ?? null }); + res.status(201).json({ id }); + } catch (error) { + console.error('[Registries] Create error:', error); + res.status(500).json({ error: 'Failed to create registry' }); + } +}); + +app.put('/api/registries/:id', (req: Request, res: Response): void => { + if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; } + if (!requireAdmin(req, res)) return; + if (!requireTeamPro(req, res)) return; + try { + const id = parseInt(req.params.id as string, 10); + if (isNaN(id)) { res.status(400).json({ error: 'Invalid registry ID' }); return; } + + const existing = RegistryService.getInstance().getById(id); + if (!existing) { res.status(404).json({ error: 'Registry not found' }); return; } + + const { name, url, type, username, secret, aws_region } = req.body; + + if (name !== undefined && (typeof name !== 'string' || name.length > 100)) { + res.status(400).json({ error: 'Name must be a string (max 100 characters).' }); return; + } + if (url !== undefined && (typeof url !== 'string' || url.length > 500)) { + res.status(400).json({ error: 'URL must be a string (max 500 characters).' }); return; + } + if (type !== undefined && !VALID_REGISTRY_TYPES.includes(type)) { + res.status(400).json({ error: `Type must be one of: ${VALID_REGISTRY_TYPES.join(', ')}` }); return; + } + const effectiveType = type ?? existing.type; + if (effectiveType === 'ecr' && aws_region !== undefined && (typeof aws_region !== 'string' || !aws_region)) { + res.status(400).json({ error: 'AWS region is required for ECR registries.' }); return; + } + + RegistryService.getInstance().update(id, { name, url, type, username, secret, aws_region }); + res.json({ success: true }); + } catch (error) { + console.error('[Registries] Update error:', error); + res.status(500).json({ error: 'Failed to update registry' }); + } +}); + +app.delete('/api/registries/:id', (req: Request, res: Response): void => { + if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; } + if (!requireAdmin(req, res)) return; + if (!requireTeamPro(req, res)) return; + try { + const id = parseInt(req.params.id as string, 10); + if (isNaN(id)) { res.status(400).json({ error: 'Invalid registry ID' }); return; } + + const existing = RegistryService.getInstance().getById(id); + if (!existing) { res.status(404).json({ error: 'Registry not found' }); return; } + + RegistryService.getInstance().delete(id); + res.json({ success: true }); + } catch (error) { + console.error('[Registries] Delete error:', error); + res.status(500).json({ error: 'Failed to delete registry' }); + } +}); + +app.post('/api/registries/:id/test', async (req: Request, res: Response): Promise => { + if (req.apiTokenScope) { res.status(403).json({ error: 'API tokens cannot manage registry credentials.', code: 'SCOPE_DENIED' }); return; } + if (!requireAdmin(req, res)) return; + if (!requireTeamPro(req, res)) return; + try { + const id = parseInt(req.params.id as string, 10); + if (isNaN(id)) { res.status(400).json({ error: 'Invalid registry ID' }); return; } + + const result = await RegistryService.getInstance().testConnection(id); + res.json(result); + } catch (error) { + console.error('[Registries] Test error:', error); + res.status(500).json({ error: 'Failed to test registry connection' }); + } +}); + // --- System Maintenance Routes (The System Janitor) --- app.get('/api/system/orphans', async (req: Request, res: Response) => { diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index ea3223d4..0f3bc2a8 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -1,10 +1,14 @@ import { spawn } from 'child_process'; +import fs from 'fs'; +import os from 'os'; import path from 'path'; import WebSocket from 'ws'; import DockerController from './DockerController'; +import { DatabaseService } from './DatabaseService'; import { FileSystemService } from './FileSystemService'; import { LogFormatter } from './LogFormatter'; import { NodeRegistry } from './NodeRegistry'; +import { RegistryService } from './RegistryService'; /** * ComposeService - local docker compose CLI execution. @@ -30,12 +34,13 @@ export class ComposeService { args: string[], cwd: string, ws?: WebSocket, - throwOnError = true + throwOnError = true, + env?: Record ): Promise { return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd, - env: { + env: env ?? { ...process.env, PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' } @@ -73,6 +78,31 @@ export class ComposeService { }); } + private async withRegistryAuth(fn: (env: Record) => Promise): Promise { + const registries = DatabaseService.getInstance().getRegistries(); + if (registries.length === 0) { + return fn({ + ...process.env, + PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + }); + } + + const dockerConfig = await RegistryService.getInstance().resolveDockerConfig(); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-docker-')); + const configPath = path.join(tmpDir, 'config.json'); + + try { + fs.writeFileSync(configPath, JSON.stringify(dockerConfig), { mode: 0o600 }); + return await fn({ + ...process.env, + DOCKER_CONFIG: tmpDir, + PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + }); + } finally { + try { fs.unlinkSync(configPath); fs.rmdirSync(tmpDir); } catch { /* best-effort cleanup */ } + } + } + async runCommand(stackName: string, action: 'down' | 'start' | 'stop' | 'restart', ws?: WebSocket): Promise { const stackDir = path.join(this.baseDir, stackName); await this.execute('docker', ['compose', action], stackDir, ws); @@ -107,7 +137,9 @@ export class ComposeService { console.warn(`Failed to clean up legacy containers for ${stackName}:`, e); } - await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws); + await this.withRegistryAuth(async (env) => { + await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws, true, env); + }); // Post-Deploy Health Probe await new Promise(resolve => setTimeout(resolve, 3000)); @@ -138,7 +170,9 @@ export class ComposeService { try { const fsSvc = FileSystemService.getInstance(this.nodeId); await fsSvc.restoreStackFiles(stackName); - await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws); + await this.withRegistryAuth(async (env) => { + await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws, true, env); + }); sendOutput('=== Rolled back successfully ===\n'); } catch (rollbackError) { console.error(`Rollback failed for ${stackName}:`, rollbackError); @@ -289,11 +323,13 @@ export class ComposeService { console.warn(`Failed to clean up legacy containers for ${stackName}:`, e); } - sendOutput('=== Pulling latest images ===\n'); - await this.execute('docker', ['compose', 'pull'], stackDir, ws); + await this.withRegistryAuth(async (env) => { + sendOutput('=== Pulling latest images ===\n'); + await this.execute('docker', ['compose', 'pull'], stackDir, ws, true, env); - sendOutput('=== Recreating containers ===\n'); - await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws); + sendOutput('=== Recreating containers ===\n'); + await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws, true, env); + }); // Post-Update Health Probe await new Promise(resolve => setTimeout(resolve, 3000)); @@ -326,7 +362,9 @@ export class ComposeService { try { const fsSvc = FileSystemService.getInstance(this.nodeId); await fsSvc.restoreStackFiles(stackName); - await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws); + await this.withRegistryAuth(async (env) => { + await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws, true, env); + }); sendOutput('=== Rolled back successfully ===\n'); } catch (rollbackError) { console.error(`Rollback failed for ${stackName}:`, rollbackError); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 0d858681..036fcbf9 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -167,6 +167,20 @@ export interface ScheduledTaskRun { triggered_by: 'scheduler' | 'manual'; } +export type RegistryType = 'dockerhub' | 'ghcr' | 'ecr' | 'custom'; + +export interface Registry { + id: number; + name: string; + url: string; + type: RegistryType; + username: string; + secret: string; + aws_region: string | null; + created_at: number; + updated_at: number; +} + export class DatabaseService { private static instance: DatabaseService; private db: Database.Database; @@ -186,6 +200,7 @@ export class DatabaseService { this.migrateAdminToUsersTable(); this.migrateEncryptNodeTokens(); this.migrateSSOColumns(); + this.migrateRegistries(); } public static getInstance(): DatabaseService { @@ -498,6 +513,22 @@ export class DatabaseService { `); } + private migrateRegistries(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS registries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + url TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'custom', + username TEXT NOT NULL DEFAULT '', + secret TEXT NOT NULL DEFAULT '', + aws_region TEXT DEFAULT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + `); + } + // --- Agents --- public getAgents(): Agent[] { @@ -1083,6 +1114,45 @@ export class DatabaseService { this.db.prepare('UPDATE api_tokens SET last_used_at = ? WHERE id = ?').run(Date.now(), id); } + // --- Registries --- + + public getRegistries(): Registry[] { + return this.db.prepare('SELECT * FROM registries ORDER BY name ASC').all() as Registry[]; + } + + public getRegistry(id: number): Registry | undefined { + return this.db.prepare('SELECT * FROM registries WHERE id = ?').get(id) as Registry | undefined; + } + + public addRegistry(reg: Omit): number { + const result = this.db.prepare( + 'INSERT INTO registries (name, url, type, username, secret, aws_region, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + ).run(reg.name, reg.url, reg.type, reg.username, reg.secret, reg.aws_region, reg.created_at, reg.updated_at); + return result.lastInsertRowid as number; + } + + public updateRegistry(id: number, updates: Partial>): void { + const fields: string[] = []; + const values: unknown[] = []; + + if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); } + if (updates.url !== undefined) { fields.push('url = ?'); values.push(updates.url); } + if (updates.type !== undefined) { fields.push('type = ?'); values.push(updates.type); } + if (updates.username !== undefined) { fields.push('username = ?'); values.push(updates.username); } + if (updates.secret !== undefined) { fields.push('secret = ?'); values.push(updates.secret); } + if (updates.aws_region !== undefined) { fields.push('aws_region = ?'); values.push(updates.aws_region); } + if (updates.updated_at !== undefined) { fields.push('updated_at = ?'); values.push(updates.updated_at); } + + if (fields.length === 0) return; + + values.push(id); + this.db.prepare(`UPDATE registries SET ${fields.join(', ')} WHERE id = ?`).run(...values); + } + + public deleteRegistry(id: number): void { + this.db.prepare('DELETE FROM registries WHERE id = ?').run(id); + } + // --- Scheduled Tasks --- public getScheduledTasks(): ScheduledTask[] { diff --git a/backend/src/services/ImageUpdateService.ts b/backend/src/services/ImageUpdateService.ts index 8c663f74..da93b7b1 100644 --- a/backend/src/services/ImageUpdateService.ts +++ b/backend/src/services/ImageUpdateService.ts @@ -2,6 +2,7 @@ import https from 'https'; import http from 'http'; import DockerController from './DockerController'; import { DatabaseService } from './DatabaseService'; +import { RegistryService } from './RegistryService'; // ─── Image ref parsing ──────────────────────────────────────────────────────── @@ -73,15 +74,24 @@ function httpGet(url: string, headers: Record = {}, timeoutMs = // ─── Registry auth ──────────────────────────────────────────────────────────── -async function getAuthToken(registry: string, repo: string): Promise { +async function getAuthToken( + registry: string, + repo: string, + credentials?: { username: string; password: string } | null +): Promise { try { + const basicHeaders: Record = {}; + if (credentials) { + basicHeaders['Authorization'] = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`; + } + let tokenUrl: string; if (registry === 'registry-1.docker.io') { tokenUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull`; } else { // Ping /v2/ to get the WWW-Authenticate challenge - const ping = await httpGet(`https://${registry}/v2/`); + const ping = await httpGet(`https://${registry}/v2/`, basicHeaders); const wwwAuth = ping.headers['www-authenticate'] as string | undefined; if (!wwwAuth) return null; @@ -96,7 +106,7 @@ async function getAuthToken(registry: string, repo: string): Promise { +async function getRemoteDigest( + registry: string, + repo: string, + tag: string, + credentials?: { username: string; password: string } | null +): Promise { try { - const token = await getAuthToken(registry, repo); + const token = await getAuthToken(registry, repo, credentials); const headers: Record = { Accept: MANIFEST_ACCEPT }; if (token) headers['Authorization'] = `Bearer ${token}`; @@ -259,6 +274,9 @@ export class ImageUpdateService { const parsed = parseImageRef(imageRef); if (!parsed) return false; + // Look up stored credentials for this registry + const credentials = await RegistryService.getInstance().getAuthForRegistry(parsed.registry); + // Get local digest from RepoDigests let localDigest: string | null = null; try { @@ -281,7 +299,7 @@ export class ImageUpdateService { if (!localDigest) return false; // Locally built or never pulled with a digest - const remoteDigest = await getRemoteDigest(parsed.registry, parsed.repo, parsed.tag); + const remoteDigest = await getRemoteDigest(parsed.registry, parsed.repo, parsed.tag, credentials); if (!remoteDigest) return false; // Registry unreachable - no false positives const hasUpdate = localDigest !== remoteDigest; diff --git a/backend/src/services/RegistryService.ts b/backend/src/services/RegistryService.ts new file mode 100644 index 00000000..d8445c1e --- /dev/null +++ b/backend/src/services/RegistryService.ts @@ -0,0 +1,263 @@ +import https from 'https'; +import http from 'http'; +import { CryptoService } from './CryptoService'; +import { DatabaseService, type Registry, type RegistryType } from './DatabaseService'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface RegistryCreateInput { + name: string; + url: string; + type: RegistryType; + username: string; + secret: string; + aws_region?: string | null; +} + +export interface RegistryUpdateInput { + name?: string; + url?: string; + type?: RegistryType; + username?: string; + secret?: string; + aws_region?: string | null; +} + +interface DockerConfigJson { + auths: Record; +} + +interface HttpResult { + statusCode: number; + headers: Record; + body: string; +} + +// ─── HTTP helper ───────────────────────────────────────────────────────────── + +function httpGet(url: string, headers: Record = {}, timeoutMs = 10000): Promise { + return new Promise((resolve, reject) => { + const lib = url.startsWith('https:') ? https : http; + const req = lib.get(url, { headers }, (res) => { + let body = ''; + res.on('data', (chunk: Buffer) => { body += chunk.toString(); }); + res.on('end', () => resolve({ + statusCode: res.statusCode ?? 0, + headers: res.headers as Record, + body, + })); + }); + req.on('error', reject); + req.setTimeout(timeoutMs, () => req.destroy(new Error('Request timed out'))); + }); +} + +// ─── Service ───────────────────────────────────────────────────────────────── + +export class RegistryService { + private static instance: RegistryService; + private crypto: CryptoService; + + private constructor() { + this.crypto = CryptoService.getInstance(); + } + + public static getInstance(): RegistryService { + if (!RegistryService.instance) { + RegistryService.instance = new RegistryService(); + } + return RegistryService.instance; + } + + // ─── CRUD ──────────────────────────────────────────────────────────────── + + public getAll(): (Omit & { has_secret: boolean })[] { + const db = DatabaseService.getInstance(); + return db.getRegistries().map(r => { + const { secret, ...rest } = r; + return { ...rest, has_secret: !!secret }; + }); + } + + public getById(id: number): (Omit & { has_secret: boolean }) | undefined { + const db = DatabaseService.getInstance(); + const r = db.getRegistry(id); + if (!r) return undefined; + const { secret, ...rest } = r; + return { ...rest, has_secret: !!secret }; + } + + public create(input: RegistryCreateInput): number { + const db = DatabaseService.getInstance(); + const now = Date.now(); + return db.addRegistry({ + name: input.name, + url: input.url, + type: input.type, + username: input.username, + secret: this.crypto.encrypt(input.secret), + aws_region: input.aws_region ?? null, + created_at: now, + updated_at: now, + }); + } + + public update(id: number, input: RegistryUpdateInput): void { + const db = DatabaseService.getInstance(); + const existing = db.getRegistry(id); + if (!existing) throw new Error('Registry not found'); + + const updates: Partial> = { + updated_at: Date.now(), + }; + + if (input.name !== undefined) updates.name = input.name; + if (input.url !== undefined) updates.url = input.url; + if (input.type !== undefined) updates.type = input.type; + if (input.username !== undefined) updates.username = input.username; + if (input.secret !== undefined && input.secret !== '') { + updates.secret = this.crypto.encrypt(input.secret); + } + if (input.aws_region !== undefined) updates.aws_region = input.aws_region; + + db.updateRegistry(id, updates); + } + + public delete(id: number): void { + DatabaseService.getInstance().deleteRegistry(id); + } + + // ─── Test connectivity ─────────────────────────────────────────────────── + + public async testConnection(id: number): Promise<{ success: boolean; error?: string }> { + const db = DatabaseService.getInstance(); + const reg = db.getRegistry(id); + if (!reg) return { success: false, error: 'Registry not found' }; + + try { + const username = reg.username; + const password = this.crypto.decrypt(reg.secret); + + if (reg.type === 'ecr') { + await this.getEcrToken(username, password, reg.aws_region!); + return { success: true }; + } + + // Standard registry: attempt /v2/ ping with Basic auth + const registryUrl = this.normalizeRegistryUrl(reg.url); + const basicAuth = Buffer.from(`${username}:${password}`).toString('base64'); + const res = await httpGet(`${registryUrl}/v2/`, { Authorization: `Basic ${basicAuth}` }); + + if (res.statusCode === 200 || res.statusCode === 401) { + // 401 with valid challenge means registry is reachable + // Try token-based auth if we got 401 + if (res.statusCode === 401) { + const wwwAuth = res.headers['www-authenticate'] as string | undefined; + if (!wwwAuth) return { success: false, error: 'Registry returned 401 without auth challenge' }; + + const realmMatch = wwwAuth.match(/realm="([^"]+)"/); + if (!realmMatch) return { success: false, error: 'Could not parse auth challenge' }; + + const serviceMatch = wwwAuth.match(/service="([^"]+)"/); + const params = new URLSearchParams(); + if (serviceMatch) params.set('service', serviceMatch[1]); + const tokenUrl = `${realmMatch[1]}?${params.toString()}`; + + const tokenRes = await httpGet(tokenUrl, { Authorization: `Basic ${basicAuth}` }); + if (tokenRes.statusCode !== 200) { + return { success: false, error: `Authentication failed (${tokenRes.statusCode})` }; + } + } + return { success: true }; + } + + return { success: false, error: `Registry returned HTTP ${res.statusCode}` }; + } catch (e) { + return { success: false, error: (e as Error).message }; + } + } + + // ─── Docker config resolution (for ComposeService) ─────────────────────── + + public async resolveDockerConfig(): Promise { + const db = DatabaseService.getInstance(); + const registries = db.getRegistries(); + const auths: Record = {}; + + for (const reg of registries) { + try { + const decryptedSecret = this.crypto.decrypt(reg.secret); + let username = reg.username; + let password = decryptedSecret; + + if (reg.type === 'ecr') { + const ecrCreds = await this.getEcrToken(reg.username, decryptedSecret, reg.aws_region!); + username = ecrCreds.username; + password = ecrCreds.password; + } + + const auth = Buffer.from(`${username}:${password}`).toString('base64'); + auths[reg.url] = { auth }; + } catch (e) { + console.error(`[RegistryService] Failed to resolve credentials for ${reg.name}:`, e); + } + } + + return { auths }; + } + + // ─── Registry auth for ImageUpdateService ──────────────────────────────── + + public async getAuthForRegistry(registryHost: string): Promise<{ username: string; password: string } | null> { + const db = DatabaseService.getInstance(); + const registries = db.getRegistries(); + + // Match by URL containing the registry host + const match = registries.find(r => { + const normalizedUrl = r.url.replace(/^https?:\/\//, '').replace(/\/$/, ''); + return normalizedUrl === registryHost || normalizedUrl.includes(registryHost) || registryHost.includes(normalizedUrl); + }); + + if (!match) return null; + + try { + const decryptedSecret = this.crypto.decrypt(match.secret); + + if (match.type === 'ecr') { + return await this.getEcrToken(match.username, decryptedSecret, match.aws_region!); + } + + return { username: match.username, password: decryptedSecret }; + } catch (e) { + console.error(`[RegistryService] Failed to resolve auth for ${registryHost}:`, e); + return null; + } + } + + // ─── ECR token fetch ───────────────────────────────────────────────────── + + private async getEcrToken(accessKeyId: string, secretAccessKey: string, region: string): Promise<{ username: string; password: string }> { + const { ECRClient, GetAuthorizationTokenCommand } = await import('@aws-sdk/client-ecr'); + const client = new ECRClient({ + region, + credentials: { accessKeyId, secretAccessKey }, + }); + const response = await client.send(new GetAuthorizationTokenCommand({})); + const authData = response.authorizationData?.[0]; + if (!authData?.authorizationToken) throw new Error('ECR returned no authorization token'); + + const decoded = Buffer.from(authData.authorizationToken, 'base64').toString(); + const [username, ...passwordParts] = decoded.split(':'); + return { username, password: passwordParts.join(':') }; + } + + // ─── Helpers ───────────────────────────────────────────────────────────── + + private normalizeRegistryUrl(url: string): string { + // Ensure URL has a protocol + if (!url.startsWith('http://') && !url.startsWith('https://')) { + url = `https://${url}`; + } + return url.replace(/\/$/, ''); + } +} diff --git a/docs/docs.json b/docs/docs.json index 6dc99053..9ef96bfb 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -98,6 +98,7 @@ "features/fleet-backups", "features/audit-log", "features/api-tokens", + "features/private-registries", "features/scheduled-operations", "features/sso", "features/licensing" diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index 73cbbaf8..995985d7 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -67,6 +67,10 @@ Pro users get automatic backup and rollback on every deployment. Before applying Create point-in-time snapshots of every compose file and environment file across all nodes. Snapshots are stored centrally and can be browsed by node and stack. Restore individual stacks from any snapshot with optional one-click redeploy - even to remote nodes. [Learn more →](/features/fleet-backups) +## Private registries + +Store credentials for private Docker registries - Docker Hub organizations, GHCR, AWS ECR, and self-hosted registries. Sencho injects them automatically during deploy and pull operations. ECR short-lived tokens are refreshed on every operation. Team Pro only. [Learn more →](/features/private-registries) + ## Audit log Track every mutating action across your Sencho instance with a searchable audit trail. See who deployed, stopped, deleted, or changed settings - with timestamps, user attribution, and node context. Team Pro only. [Learn more →](/features/audit-log) diff --git a/docs/features/private-registries.mdx b/docs/features/private-registries.mdx new file mode 100644 index 00000000..23585737 --- /dev/null +++ b/docs/features/private-registries.mdx @@ -0,0 +1,114 @@ +--- +title: Private Registries +description: Store credentials for private Docker registries so Sencho can automatically authenticate during deploy, pull, and image update checks. +--- + + + Private Registry Management requires a Sencho **Team Pro** license. Personal Pro and Community Edition do not include this feature. + + +Sencho can store credentials for your private Docker registries and inject them automatically whenever it runs `docker compose pull` or `docker compose up`. This means your stacks can reference private images without needing to manually `docker login` on the host. + +## Supported registry types + +| Type | Description | Credentials | +|------|-------------|-------------| +| **Docker Hub** | Private Docker Hub organizations and repositories | Username + access token | +| **GHCR** | GitHub Container Registry (`ghcr.io`) | GitHub username + personal access token (PAT) | +| **AWS ECR** | Amazon Elastic Container Registry | AWS Access Key ID + Secret Access Key | +| **Custom** | Any self-hosted Docker V2 registry | Username + password or token | + +## Adding a registry + +1. Open **Settings Hub** and navigate to the **Registries** tab (visible to Team Pro admins only). +2. Click **Add Registry**. +3. Select the registry type. The URL field auto-fills with the standard endpoint for Docker Hub and GHCR. +4. Enter a descriptive name, the registry URL, and your credentials. +5. For **ECR** registries, also provide the AWS region (e.g., `us-east-1`). +6. Click **Add**. + + + Private Registries management view in Settings Hub showing a configured Docker Hub registry + + + + Add Registry form with type selector, credentials, and URL fields + + +## Testing connectivity + +After adding a registry, click the checkmark icon on the registry card to test the connection. Sencho will attempt to authenticate against the registry's `/v2/` endpoint and report success or failure. + +For ECR registries, the test verifies that the AWS credentials can successfully obtain an authorization token. + +## How credentials are applied + +### Deploy and pull operations + +When you deploy or update a stack, Sencho: + +1. Resolves credentials for all configured registries. +2. For ECR registries, fetches a fresh authorization token from AWS (ECR tokens are short-lived, lasting 12 hours). +3. Writes a temporary Docker config file with all registry auth entries. +4. Sets the `DOCKER_CONFIG` environment variable so `docker compose` uses the temporary config. +5. Runs the compose operation (pull and/or up). +6. Cleans up the temporary config file immediately after. + +This approach ensures credentials are never persisted on disk beyond the duration of the operation. + +### Image update checks + +Sencho's background image update checker also uses stored registry credentials. When checking for newer image versions, it passes your credentials to the registry's authentication endpoint so it can compare local and remote digests for private images. + +## ECR setup + +AWS ECR uses short-lived authentication tokens (valid for 12 hours) derived from your IAM credentials. Sencho handles this automatically: + +1. Store your **AWS Access Key ID** and **Secret Access Key** as the username and secret. +2. Specify the **AWS Region** where your ECR registry lives. +3. On every deploy or pull, Sencho calls the AWS `GetAuthorizationToken` API to obtain a fresh token. + + + Use an IAM user or role with only the `ecr:GetAuthorizationToken` and `ecr:BatchGetImage` permissions. Avoid using root account credentials. + + +### IAM policy example + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "ecr:GetAuthorizationToken", + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchCheckLayerAvailability" + ], + "Resource": "*" + } + ] +} +``` + +## Registry URL reference + +| Registry | URL to use | +|----------|-----------| +| Docker Hub | `https://index.docker.io/v1/` | +| GHCR | `ghcr.io` | +| AWS ECR | `{account_id}.dkr.ecr.{region}.amazonaws.com` | +| Self-hosted | Your registry hostname, e.g. `registry.example.com` | + +## Security + +- **Encrypted storage** - Registry secrets are encrypted at rest using AES-256-GCM, the same encryption used for remote node tokens and SSO secrets. +- **No persistent Docker login** - Credentials are written to a temporary file for the duration of each operation and immediately deleted afterward. +- **Secrets never exposed** - The API never returns decrypted secrets. The UI shows only whether a secret is stored. +- **Audit trail** - Registry create, update, and delete operations are recorded in the [Audit Log](/features/audit-log). +- **Admin-only access** - Only admin users with a Team Pro license can manage registry credentials. + +## Multi-node behavior + +Registry credentials are stored per Sencho instance. When managing remote nodes, each node runs its own Sencho instance with its own registry credentials. Configure private registries on each node that needs access to private images. diff --git a/docs/images/private-registries/registries-add-form.png b/docs/images/private-registries/registries-add-form.png new file mode 100644 index 00000000..2dc214c9 Binary files /dev/null and b/docs/images/private-registries/registries-add-form.png differ diff --git a/docs/images/private-registries/registries-overview.png b/docs/images/private-registries/registries-overview.png new file mode 100644 index 00000000..e3ac4cec Binary files /dev/null and b/docs/images/private-registries/registries-overview.png differ diff --git a/docs/images/private-registries/registries-with-entry.png b/docs/images/private-registries/registries-with-entry.png new file mode 100644 index 00000000..bc73510c Binary files /dev/null and b/docs/images/private-registries/registries-with-entry.png differ diff --git a/frontend/src/components/RegistriesSection.tsx b/frontend/src/components/RegistriesSection.tsx new file mode 100644 index 00000000..ccd16b86 --- /dev/null +++ b/frontend/src/components/RegistriesSection.tsx @@ -0,0 +1,384 @@ +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'; +import { toast } from 'sonner'; +import { apiFetch } from '@/lib/api'; +import { TeamProGate } from './TeamProGate'; +import { TierBadge } from './TierBadge'; +import { Database, Plus, Trash2, Pencil, RefreshCw, CheckCircle, XCircle, Clock } from 'lucide-react'; + +type RegistryType = 'dockerhub' | 'ghcr' | 'ecr' | 'custom'; + +interface RegistryItem { + id: number; + name: string; + url: string; + type: RegistryType; + username: string; + has_secret: boolean; + aws_region: string | null; + created_at: number; + updated_at: number; +} + +const TYPE_LABELS: Record = { + dockerhub: 'Docker Hub', + ghcr: 'GitHub (GHCR)', + ecr: 'AWS ECR', + custom: 'Custom', +}; + +const TYPE_BADGE_VARIANT: Record = { + dockerhub: 'default', + ghcr: 'secondary', + ecr: 'secondary', + custom: 'outline', +}; + +const TYPE_URL_DEFAULTS: Record = { + dockerhub: 'https://index.docker.io/v1/', + ghcr: 'ghcr.io', + ecr: '', + custom: '', +}; + +const TYPE_USERNAME_HINT: Record = { + dockerhub: 'Docker Hub username', + ghcr: 'GitHub username', + ecr: 'AWS Access Key ID', + custom: 'Username', +}; + +const TYPE_SECRET_HINT: Record = { + dockerhub: 'Access token or password', + ghcr: 'Personal access token (PAT)', + ecr: 'AWS Secret Access Key', + custom: 'Password or token', +}; + +function formatDate(ts: number): string { + return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); +} + +export function RegistriesSection() { + const [registries, setRegistries] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [showForm, setShowForm] = useState(false); + const [editingId, setEditingId] = useState(null); + const [testingId, setTestingId] = useState(null); + + const [formName, setFormName] = useState(''); + const [formUrl, setFormUrl] = useState(''); + const [formType, setFormType] = useState('dockerhub'); + const [formUsername, setFormUsername] = useState(''); + const [formSecret, setFormSecret] = useState(''); + const [formAwsRegion, setFormAwsRegion] = useState(''); + + const fetchRegistries = async () => { + try { + const res = await apiFetch('/registries', { localOnly: true }); + if (res.ok) { + setRegistries(await res.json()); + } + } catch { + toast.error('Failed to load registries.'); + } finally { + setLoading(false); + } + }; + + // eslint-disable-next-line react-hooks/set-state-in-effect + useEffect(() => { fetchRegistries(); }, []); + + const resetForm = () => { + setFormName(''); + setFormUrl(''); + setFormType('dockerhub'); + setFormUsername(''); + setFormSecret(''); + setFormAwsRegion(''); + setEditingId(null); + setShowForm(false); + }; + + const handleTypeChange = (type: RegistryType) => { + setFormType(type); + if (!editingId) { + setFormUrl(TYPE_URL_DEFAULTS[type]); + } + }; + + const startEdit = (reg: RegistryItem) => { + setEditingId(reg.id); + setFormName(reg.name); + setFormUrl(reg.url); + setFormType(reg.type); + setFormUsername(reg.username); + setFormSecret(''); + setFormAwsRegion(reg.aws_region ?? ''); + setShowForm(true); + }; + + const handleSave = async () => { + if (!formName.trim()) { toast.error('Name is required.'); return; } + if (!formUrl.trim()) { toast.error('URL is required.'); return; } + if (!formUsername.trim()) { toast.error('Username is required.'); return; } + if (!editingId && !formSecret.trim()) { toast.error('Secret/token is required.'); return; } + if (formType === 'ecr' && !formAwsRegion.trim()) { toast.error('AWS region is required for ECR.'); return; } + + setSaving(true); + try { + const body: Record = { + name: formName.trim(), + url: formUrl.trim(), + type: formType, + username: formUsername.trim(), + aws_region: formType === 'ecr' ? formAwsRegion.trim() : null, + }; + if (formSecret.trim()) body.secret = formSecret.trim(); + + const url = editingId ? `/registries/${editingId}` : '/registries'; + const method = editingId ? 'PUT' : 'POST'; + + const res = await apiFetch(url, { + method, + localOnly: true, + body: JSON.stringify(body), + }); + + if (res.ok) { + toast.success(editingId ? 'Registry updated.' : 'Registry added.'); + resetForm(); + fetchRegistries(); + } else { + const err = await res.json().catch(() => ({})); + toast.error(err?.error || err?.message || 'Failed to save registry.'); + } + } catch (e: unknown) { + toast.error((e as Error)?.message || 'Network error.'); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (id: number) => { + try { + const res = await apiFetch(`/registries/${id}`, { method: 'DELETE', localOnly: true }); + if (res.ok) { + toast.success('Registry deleted.'); + fetchRegistries(); + } else { + const err = await res.json().catch(() => ({})); + toast.error(err?.error || err?.message || 'Failed to delete registry.'); + } + } catch { + toast.error('Network error.'); + } + }; + + const handleTest = async (id: number) => { + setTestingId(id); + try { + const res = await apiFetch(`/registries/${id}/test`, { method: 'POST', localOnly: true }); + if (res.ok) { + const data = await res.json(); + if (data.success) { + toast.success('Connection successful!'); + } else { + toast.error(data.error || 'Connection failed.'); + } + } else { + const err = await res.json().catch(() => ({})); + toast.error(err?.error || err?.message || 'Test failed.'); + } + } catch { + toast.error('Network error.'); + } finally { + setTestingId(null); + } + }; + + return ( + +
+
+
+

+ Private Registries +

+

+ Store credentials for private Docker registries. Sencho injects them automatically during deploy and pull operations. +

+
+ +
+ + {/* Create / Edit form */} + {showForm && ( +
+
+ + +
+
+ + setFormName(e.target.value)} + maxLength={100} + /> +
+
+ + setFormUrl(e.target.value)} + maxLength={500} + /> +
+
+
+ + setFormUsername(e.target.value)} + /> +
+
+ + setFormSecret(e.target.value)} + /> +
+
+ {formType === 'ecr' && ( +
+ + setFormAwsRegion(e.target.value)} + /> +
+ )} +
+ + +
+
+ )} + + {/* Loading state */} + {loading && ( +
+ + +
+ )} + + {/* Empty state */} + {!loading && registries.length === 0 && !showForm && ( +
+ +

No private registries configured.

+

Add one to pull images from Docker Hub orgs, GHCR, ECR, or self-hosted registries.

+
+ )} + + {/* Registry list */} + {!loading && registries.map(reg => ( +
+
+
+ + {reg.name} + + {TYPE_LABELS[reg.type]} + +
+
+ + + + + + + + + Delete registry? + + Deleting {reg.name} will remove its stored credentials. Stacks using images from this registry will fail to pull until credentials are re-added. + + + + Cancel + handleDelete(reg.id)} className="bg-destructive text-destructive-foreground hover:bg-destructive/90"> + Delete + + + + +
+
+
+ {reg.url} + {reg.username} + + {reg.has_secret ? ( + <> Secret stored + ) : ( + <> No secret + )} + + {reg.aws_region && Region: {reg.aws_region}} + + + {formatDate(reg.created_at)} + +
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 9f3aa6c7..f74cff0c 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -29,6 +29,7 @@ import { TierBadge } from './TierBadge'; import { ProGate } from './ProGate'; import { SSOSection } from './SSOSection'; import { ApiTokensSection } from './ApiTokensSection'; +import { RegistriesSection } from './RegistriesSection'; interface Agent { type: 'discord' | 'slack' | 'webhook'; @@ -50,7 +51,7 @@ interface PatchableSettings { log_retention_days?: string; } -type SectionId = 'account' | 'license' | 'users' | 'sso' | 'api-tokens' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'support' | 'about'; +type SectionId = 'account' | 'license' | 'users' | 'sso' | 'api-tokens' | 'registries' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'support' | 'about'; interface WebhookItem { id: number; @@ -658,7 +659,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { // When switching to a remote node, reset to a node-scoped section if on a global-only one useEffect(() => { - if (isRemote && (activeSection === 'account' || activeSection === 'license' || activeSection === 'users' || activeSection === 'sso' || activeSection === 'api-tokens' || activeSection === 'notifications' || activeSection === 'webhooks' || activeSection === 'nodes' || activeSection === 'appstore')) { + if (isRemote && (activeSection === 'account' || activeSection === 'license' || activeSection === 'users' || activeSection === 'sso' || activeSection === 'api-tokens' || activeSection === 'registries' || activeSection === 'notifications' || activeSection === 'webhooks' || activeSection === 'nodes' || activeSection === 'appstore')) { setActiveSection('system'); } }, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps @@ -1005,6 +1006,9 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { {!isRemote && isAdmin && isPro && license?.variant === 'team' && ( } label="API Tokens" /> )} + {!isRemote && isAdmin && isPro && license?.variant === 'team' && ( + } label="Registries" /> + )} } @@ -1468,6 +1472,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { )} + {activeSection === 'registries' && ( + + )} + {activeSection === 'developer' && (