diff --git a/.github/workflows/build-publish-anvilops.yml b/.github/workflows/build-publish-anvilops.yml index 1ffa8002..99a72e14 100644 --- a/.github/workflows/build-publish-anvilops.yml +++ b/.github/workflows/build-publish-anvilops.yml @@ -39,12 +39,3 @@ jobs: - name: Log out of container registry (Geddes) if: always() run: docker logout geddes-registry.anvil.rcac.purdue.edu - - - name: Commit changes - run: | - git config --global user.name "github-actions[bot]" - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config pull.rebase true - git add infra/anvil/values.yaml infra/geddes/values.yaml - git commit -m "Update image to ${{ github.ref_name }}-${{ github.run_number }}-${{ github.sha }}" || echo "No changes to commit" - git push || git pull && git push diff --git a/.github/workflows/build-publish-helm-deployer.yml b/.github/workflows/build-publish-helm-deployer.yml new file mode 100644 index 00000000..6a0f4558 --- /dev/null +++ b/.github/workflows/build-publish-helm-deployer.yml @@ -0,0 +1,29 @@ +name: Build and Publish Dockerfile Builder Docker image + +on: + push: + branches: [main] + paths: + - "builders/helm/**" + - ".github/workflows/build-publish-helm-deployer.yml" + workflow_dispatch: + +jobs: + push_to_registry: + name: Push Helm Deployer Docker image to Harbor + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out the repo + uses: actions/checkout@v6 + + - name: Log in to container registry + run: docker login -u '${{ secrets.DOCKER_USERNAME }}' -p '${{ secrets.DOCKER_PASSWORD }}' registry.anvil.rcac.purdue.edu + + - name: Build and push Helm Deployer Docker image + run: docker build --push -t registry.anvil.rcac.purdue.edu/anvilops/helm-deployer:${{ github.ref_name }}-${{ github.run_number }}-${{ github.sha }}${{ github.event_name == 'push' && ' -t registry.anvil.rcac.purdue.edu/anvilops/helm-deployer:latest' || '' }} --cache-from=type=registry,ref=registry.anvil.rcac.purdue.edu/anvilops/helm-deployer:latest --cache-to=type=inline ./builders/helm + + - name: Log out of container registry + if: always() + run: docker logout registry.anvil.rcac.purdue.edu diff --git a/.github/workflows/build-publish-staging-anvilops.yml b/.github/workflows/build-publish-staging-anvilops.yml new file mode 100644 index 00000000..aa1d16da --- /dev/null +++ b/.github/workflows/build-publish-staging-anvilops.yml @@ -0,0 +1,24 @@ +name: Build and Publish Staging Docker image + +on: + workflow_dispatch: + +jobs: + push_to_registry: + name: Push AnvilOps Docker image to Harbor (Staging) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out the repo + uses: actions/checkout@v6 + + - name: Log in to container registry + run: docker login -u '${{ secrets.DOCKER_USERNAME_STAGING }}' -p '${{ secrets.DOCKER_PASSWORD_STAGING }}' registry.anvil.rcac.purdue.edu + + - name: Build and push AnvilOps Docker image + run: docker build --push -t registry.anvil.rcac.purdue.edu/anvilops-staging/anvilops:${{ github.run_number }}-${{ github.sha }} . + + - name: Log out of container registry + if: always() + run: docker logout registry.anvil.rcac.purdue.edu diff --git a/.prettierignore b/.prettierignore index ba93199d..d2530ae3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,6 @@ # Ignore artifacts: charts/**/*.yaml !charts/**/values.yaml +templates/extensions/**/*.yaml +!templates/extensions/**/values.yaml # ^ Prettier doesn't know how to interpret Go templates so it thinks we're writing invalid YAML \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example index 29b7d26f..ff4006da 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -41,6 +41,8 @@ DELETE_REPO_PASSWORD= CURRENT_NAMESPACE=anvilops-dev +CHART_PROJECT_NAME=anvilops-chart +REGISTRY_API_URL=https://registry.anvil.rcac.purdue.edu/api/v2.0 REGISTRY_HOSTNAME=registry.anvil.rcac.purdue.edu STORAGE_CLASS_NAME=standard diff --git a/backend/package-lock.json b/backend/package-lock.json index c156119e..ebf90acc 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -26,7 +26,8 @@ "openapi-backend": "^5.15.0", "openid-client": "^6.8.1", "patch-package": "^8.0.1", - "pg": "^8.16.3" + "pg": "^8.16.3", + "yaml": "^2.8.2" }, "devDependencies": { "@types/connect-pg-simple": "^7.0.3", @@ -41,8 +42,6 @@ }, "node_modules/@apidevtools/json-schema-ref-parser": { "version": "11.9.3", - "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", - "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", "license": "MIT", "dependencies": { "@jsdevtools/ono": "^7.1.3", @@ -58,8 +57,6 @@ }, "node_modules/@chevrotain/cst-dts-gen": { "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz", - "integrity": "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -70,8 +67,6 @@ }, "node_modules/@chevrotain/gast": { "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-10.5.0.tgz", - "integrity": "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -81,29 +76,21 @@ }, "node_modules/@chevrotain/types": { "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-10.5.0.tgz", - "integrity": "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@chevrotain/utils": { "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-10.5.0.tgz", - "integrity": "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@electric-sql/pglite": { "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.2.tgz", - "integrity": "sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@electric-sql/pglite-socket": { "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.0.6.tgz", - "integrity": "sha512-6RjmgzphIHIBA4NrMGJsjNWK4pu+bCWJlEWlwcxFTVY3WT86dFpKwbZaGWZV6C5Rd7sCk1Z0CI76QEfukLAUXw==", "devOptional": true, "license": "Apache-2.0", "bin": { @@ -115,8 +102,6 @@ }, "node_modules/@electric-sql/pglite-tools": { "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.2.7.tgz", - "integrity": "sha512-9dAccClqxx4cZB+Ar9B+FZ5WgxDc/Xvl9DPrTWv+dYTf0YNubLzi4wHHRGRGhrJv15XwnyKcGOZAP1VXSneSUg==", "devOptional": true, "license": "Apache-2.0", "peerDependencies": { @@ -125,8 +110,6 @@ }, "node_modules/@hono/node-server": { "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.6.tgz", - "integrity": "sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==", "devOptional": true, "license": "MIT", "engines": { @@ -138,14 +121,10 @@ }, "node_modules/@jsdevtools/ono": { "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", - "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", "license": "MIT" }, "node_modules/@jsep-plugin/assignment": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", - "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==", "license": "MIT", "engines": { "node": ">= 10.16.0" @@ -156,8 +135,6 @@ }, "node_modules/@jsep-plugin/regex": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz", - "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==", "license": "MIT", "engines": { "node": ">= 10.16.0" @@ -168,8 +145,6 @@ }, "node_modules/@kubernetes/client-node": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-1.4.0.tgz", - "integrity": "sha512-Zge3YvF7DJi264dU1b3wb/GmzR99JhUpqTvp+VGHfwZT+g7EOOYNScDJNZwXy9cszyIGPIs0VHr+kk8e95qqrA==", "license": "Apache-2.0", "dependencies": { "@types/js-yaml": "^4.0.1", @@ -192,8 +167,6 @@ }, "node_modules/@mrleebo/prisma-ast": { "version": "0.12.1", - "resolved": "https://registry.npmjs.org/@mrleebo/prisma-ast/-/prisma-ast-0.12.1.tgz", - "integrity": "sha512-JwqeCQ1U3fvccttHZq7Tk0m/TMC6WcFAQZdukypW3AzlJYKYTGNVd1ANU2GuhKnv4UQuOFj3oAl0LLG/gxFN1w==", "devOptional": true, "license": "MIT", "dependencies": { @@ -206,8 +179,6 @@ }, "node_modules/@octokit/app": { "version": "16.1.2", - "resolved": "https://registry.npmjs.org/@octokit/app/-/app-16.1.2.tgz", - "integrity": "sha512-8j7sEpUYVj18dxvh0KWj6W/l6uAiVRBl1JBDVRqH1VHKAO/G5eRVl4yEoYACjakWers1DjUkcCHyJNQK47JqyQ==", "license": "MIT", "dependencies": { "@octokit/auth-app": "^8.1.2", @@ -224,8 +195,6 @@ }, "node_modules/@octokit/auth-app": { "version": "8.1.2", - "resolved": "https://registry.npmjs.org/@octokit/auth-app/-/auth-app-8.1.2.tgz", - "integrity": "sha512-db8VO0PqXxfzI6GdjtgEFHY9tzqUql5xMFXYA12juq8TeTgPAuiiP3zid4h50lwlIP457p5+56PnJOgd2GGBuw==", "license": "MIT", "dependencies": { "@octokit/auth-oauth-app": "^9.0.3", @@ -243,8 +212,6 @@ }, "node_modules/@octokit/auth-oauth-app": { "version": "9.0.3", - "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-app/-/auth-oauth-app-9.0.3.tgz", - "integrity": "sha512-+yoFQquaF8OxJSxTb7rnytBIC2ZLbLqA/yb71I4ZXT9+Slw4TziV9j/kyGhUFRRTF2+7WlnIWsePZCWHs+OGjg==", "license": "MIT", "dependencies": { "@octokit/auth-oauth-device": "^8.0.3", @@ -259,8 +226,6 @@ }, "node_modules/@octokit/auth-oauth-device": { "version": "8.0.3", - "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-device/-/auth-oauth-device-8.0.3.tgz", - "integrity": "sha512-zh2W0mKKMh/VWZhSqlaCzY7qFyrgd9oTWmTmHaXnHNeQRCZr/CXy2jCgHo4e4dJVTiuxP5dLa0YM5p5QVhJHbw==", "license": "MIT", "dependencies": { "@octokit/oauth-methods": "^6.0.2", @@ -274,8 +239,6 @@ }, "node_modules/@octokit/auth-oauth-user": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-user/-/auth-oauth-user-6.0.2.tgz", - "integrity": "sha512-qLoPPc6E6GJoz3XeDG/pnDhJpTkODTGG4kY0/Py154i/I003O9NazkrwJwRuzgCalhzyIeWQ+6MDvkUmKXjg/A==", "license": "MIT", "dependencies": { "@octokit/auth-oauth-device": "^8.0.3", @@ -290,8 +253,6 @@ }, "node_modules/@octokit/auth-token": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", - "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", "license": "MIT", "engines": { "node": ">= 20" @@ -299,8 +260,6 @@ }, "node_modules/@octokit/auth-unauthenticated": { "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@octokit/auth-unauthenticated/-/auth-unauthenticated-7.0.3.tgz", - "integrity": "sha512-8Jb1mtUdmBHL7lGmop9mU9ArMRUTRhg8vp0T1VtZ4yd9vEm3zcLwmjQkhNEduKawOOORie61xhtYIhTDN+ZQ3g==", "license": "MIT", "dependencies": { "@octokit/request-error": "^7.0.2", @@ -312,8 +271,6 @@ }, "node_modules/@octokit/core": { "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", - "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "license": "MIT", "dependencies": { "@octokit/auth-token": "^6.0.0", @@ -330,8 +287,6 @@ }, "node_modules/@octokit/endpoint": { "version": "11.0.2", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.2.tgz", - "integrity": "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==", "license": "MIT", "dependencies": { "@octokit/types": "^16.0.0", @@ -343,8 +298,6 @@ }, "node_modules/@octokit/graphql": { "version": "9.0.3", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", - "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", "license": "MIT", "dependencies": { "@octokit/request": "^10.0.6", @@ -357,8 +310,6 @@ }, "node_modules/@octokit/oauth-app": { "version": "8.0.3", - "resolved": "https://registry.npmjs.org/@octokit/oauth-app/-/oauth-app-8.0.3.tgz", - "integrity": "sha512-jnAjvTsPepyUaMu9e69hYBuozEPgYqP4Z3UnpmvoIzHDpf8EXDGvTY1l1jK0RsZ194oRd+k6Hm13oRU8EoDFwg==", "license": "MIT", "dependencies": { "@octokit/auth-oauth-app": "^9.0.2", @@ -376,8 +327,6 @@ }, "node_modules/@octokit/oauth-authorization-url": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@octokit/oauth-authorization-url/-/oauth-authorization-url-8.0.0.tgz", - "integrity": "sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ==", "license": "MIT", "engines": { "node": ">= 20" @@ -385,8 +334,6 @@ }, "node_modules/@octokit/oauth-methods": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@octokit/oauth-methods/-/oauth-methods-6.0.2.tgz", - "integrity": "sha512-HiNOO3MqLxlt5Da5bZbLV8Zarnphi4y9XehrbaFMkcoJ+FL7sMxH/UlUsCVxpddVu4qvNDrBdaTVE2o4ITK8ng==", "license": "MIT", "dependencies": { "@octokit/oauth-authorization-url": "^8.0.0", @@ -400,20 +347,14 @@ }, "node_modules/@octokit/openapi-types": { "version": "27.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", - "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", "license": "MIT" }, "node_modules/@octokit/openapi-webhooks-types": { "version": "12.0.3", - "resolved": "https://registry.npmjs.org/@octokit/openapi-webhooks-types/-/openapi-webhooks-types-12.0.3.tgz", - "integrity": "sha512-90MF5LVHjBedwoHyJsgmaFhEN1uzXyBDRLEBe7jlTYx/fEhPAk3P3DAJsfZwC54m8hAIryosJOL+UuZHB3K3yA==", "license": "MIT" }, "node_modules/@octokit/plugin-paginate-graphql": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-graphql/-/plugin-paginate-graphql-6.0.0.tgz", - "integrity": "sha512-crfpnIoFiBtRkvPqOyLOsw12XsveYuY2ieP6uYDosoUegBJpSVxGwut9sxUgFFcll3VTOTqpUf8yGd8x1OmAkQ==", "license": "MIT", "engines": { "node": ">= 20" @@ -424,8 +365,6 @@ }, "node_modules/@octokit/plugin-paginate-rest": { "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", - "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", "license": "MIT", "dependencies": { "@octokit/types": "^16.0.0" @@ -439,8 +378,6 @@ }, "node_modules/@octokit/plugin-rest-endpoint-methods": { "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", - "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", "license": "MIT", "dependencies": { "@octokit/types": "^16.0.0" @@ -454,8 +391,6 @@ }, "node_modules/@octokit/plugin-retry": { "version": "8.0.3", - "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-8.0.3.tgz", - "integrity": "sha512-vKGx1i3MC0za53IzYBSBXcrhmd+daQDzuZfYDd52X5S0M2otf3kVZTVP8bLA3EkU0lTvd1WEC2OlNNa4G+dohA==", "license": "MIT", "dependencies": { "@octokit/request-error": "^7.0.2", @@ -471,8 +406,6 @@ }, "node_modules/@octokit/plugin-throttling": { "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-11.0.3.tgz", - "integrity": "sha512-34eE0RkFCKycLl2D2kq7W+LovheM/ex3AwZCYN8udpi6bxsyjZidb2McXs69hZhLmJlDqTSP8cH+jSRpiaijBg==", "license": "MIT", "dependencies": { "@octokit/types": "^16.0.0", @@ -487,8 +420,6 @@ }, "node_modules/@octokit/request": { "version": "10.0.7", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.7.tgz", - "integrity": "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==", "license": "MIT", "dependencies": { "@octokit/endpoint": "^11.0.2", @@ -503,8 +434,6 @@ }, "node_modules/@octokit/request-error": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", - "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", "license": "MIT", "dependencies": { "@octokit/types": "^16.0.0" @@ -515,8 +444,6 @@ }, "node_modules/@octokit/types": { "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", - "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", "license": "MIT", "dependencies": { "@octokit/openapi-types": "^27.0.0" @@ -524,8 +451,6 @@ }, "node_modules/@octokit/webhooks": { "version": "14.1.3", - "resolved": "https://registry.npmjs.org/@octokit/webhooks/-/webhooks-14.1.3.tgz", - "integrity": "sha512-gcK4FNaROM9NjA0mvyfXl0KPusk7a1BeA8ITlYEZVQCXF5gcETTd4yhAU0Kjzd8mXwYHppzJBWgdBVpIR9wUcQ==", "license": "MIT", "dependencies": { "@octokit/openapi-webhooks-types": "12.0.3", @@ -538,8 +463,6 @@ }, "node_modules/@octokit/webhooks-methods": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@octokit/webhooks-methods/-/webhooks-methods-6.0.0.tgz", - "integrity": "sha512-MFlzzoDJVw/GcbfzVC1RLR36QqkTLUf79vLVO3D+xn7r0QgxnFoLZgtrzxiQErAjFUOdH6fas2KeQJ1yr/qaXQ==", "license": "MIT", "engines": { "node": ">= 20" @@ -547,8 +470,6 @@ }, "node_modules/@prisma/adapter-pg": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.0.1.tgz", - "integrity": "sha512-01GpPPhLMoDMF4ipgfZz0L87fla/TV/PBQcmHy+9vV1ml6gUoqF8dUIRNI5Yf2YKpOwzQg9sn8C7dYD1Yio9Ug==", "license": "Apache-2.0", "dependencies": { "@prisma/driver-adapter-utils": "7.0.1", @@ -558,8 +479,6 @@ }, "node_modules/@prisma/adapter-pg/node_modules/postgres-array": { "version": "3.0.4", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", - "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", "license": "MIT", "engines": { "node": ">=12" @@ -567,8 +486,6 @@ }, "node_modules/@prisma/client": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.1.0.tgz", - "integrity": "sha512-qf7GPYHmS/xybNiSOpzv9wBo+UwqfL2PeyX+08v+KVHDI0AlSCQIh5bBySkH3alu06NX9wy98JEnckhMHoMFfA==", "license": "Apache-2.0", "dependencies": { "@prisma/client-runtime-utils": "7.1.0" @@ -591,14 +508,10 @@ }, "node_modules/@prisma/client-runtime-utils": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.1.0.tgz", - "integrity": "sha512-39xmeBrNTN40FzF34aJMjfX1PowVCqoT3UKUWBBSP3aXV05NRqGBC3x2wCDs96ti6ZgdiVzqnRDHtbzU8X+lPQ==", "license": "Apache-2.0" }, "node_modules/@prisma/config": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.1.0.tgz", - "integrity": "sha512-Uz+I43Wn1RYNHtuYtOhOnUcNMWp2Pd3GUDDKs37xlHptCGpzEG3MRR9L+8Y2ISMsMI24z/Ni+ww6OB/OO8M0sQ==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -610,14 +523,10 @@ }, "node_modules/@prisma/debug": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.0.1.tgz", - "integrity": "sha512-5+25XokVeAK2Z2C9W457AFw7Hk032Q3QI3G58KYKXPlpgxy+9FvV1+S1jqfJ2d4Nmq9LP/uACrM6OVhpJMSr8w==", "license": "Apache-2.0" }, "node_modules/@prisma/dev": { "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.15.0.tgz", - "integrity": "sha512-KhWaipnFlS/fWEs6I6Oqjcy2S08vKGmxJ5LexqUl/3Ve0EgLUsZwdKF0MvqPM5F5ttw8GtfZarjM5y7VLwv9Ow==", "devOptional": true, "license": "ISC", "dependencies": { @@ -642,15 +551,11 @@ }, "node_modules/@prisma/dmmf": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@prisma/dmmf/-/dmmf-7.0.1.tgz", - "integrity": "sha512-8f04R3226L/tvC0jMuTRF9ArbYU/AWdAClkw7XCcSrN1Jml/zWt+43OOwAi5K/7EpASRi+IaaIdrmT+hop0a5g==", "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/driver-adapter-utils": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.0.1.tgz", - "integrity": "sha512-sBbxm/yysHLLF2iMAB+qcX/nn3WFgsiC4DQNz0uM6BwGSIs8lIvgo0u8nR9nxe5gvFgKiIH8f4z2fgOEMeXc8w==", "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.0.1" @@ -658,8 +563,6 @@ }, "node_modules/@prisma/engines": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.1.0.tgz", - "integrity": "sha512-KQlraOybdHAzVv45KWKJzpR9mJLkib7/TyApQpqrsL7FUHfgjIcy8jrVGt3iNfG6/GDDl+LNlJ84JSQwIfdzxA==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -672,22 +575,16 @@ }, "node_modules/@prisma/engines-version": { "version": "7.1.0-6.ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.0-6.ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba.tgz", - "integrity": "sha512-qZUevUh+yPhGT28rDQnV8V2kLnFjirzhVD67elRPIJHRsUV/mkII10HSrJrhK/U2GYgAxXR2VEREtq7AsfS8qw==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines/node_modules/@prisma/debug": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.1.0.tgz", - "integrity": "sha512-pPAckG6etgAsEBusmZiFwM9bldLSNkn++YuC4jCTJACdK5hLOVnOzX7eSL2FgaU6Gomd6wIw21snUX2dYroMZQ==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.1.0.tgz", - "integrity": "sha512-lq8hMdjKiZftuT5SssYB3EtQj8+YjL24/ZTLflQqzFquArKxBcyp6Xrblto+4lzIKJqnpOjfMiBjMvl7YuD7+Q==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -696,8 +593,6 @@ }, "node_modules/@prisma/fetch-engine": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.1.0.tgz", - "integrity": "sha512-GZYF5Q8kweXWGfn87hTu17kw7x1DgnehgKoE4Zg1BmHYF3y1Uu0QRY/qtSE4veH3g+LW8f9HKqA0tARG66bxxQ==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -708,15 +603,11 @@ }, "node_modules/@prisma/fetch-engine/node_modules/@prisma/debug": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.1.0.tgz", - "integrity": "sha512-pPAckG6etgAsEBusmZiFwM9bldLSNkn++YuC4jCTJACdK5hLOVnOzX7eSL2FgaU6Gomd6wIw21snUX2dYroMZQ==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.1.0.tgz", - "integrity": "sha512-lq8hMdjKiZftuT5SssYB3EtQj8+YjL24/ZTLflQqzFquArKxBcyp6Xrblto+4lzIKJqnpOjfMiBjMvl7YuD7+Q==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -725,15 +616,11 @@ }, "node_modules/@prisma/generator": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@prisma/generator/-/generator-7.0.1.tgz", - "integrity": "sha512-d/rPH4p2hdZKg1kfWBAL+SLDGGbxl8I74dZv5+Fm/mpH0lWXRwQrtvpxrPaKbnJnCkyddUS+4SOl9WV2iBMH7w==", "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/generator-helper": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@prisma/generator-helper/-/generator-helper-7.0.1.tgz", - "integrity": "sha512-cCwQFw6Sfm74mKwq8haxCyOOgpRKJ7iFRnr71srT/+onz9oCAKzRBDcDAmnAOLiPvz/RW7qy3RVw3upovwt7lg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -744,8 +631,6 @@ }, "node_modules/@prisma/get-platform": { "version": "6.8.2", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.8.2.tgz", - "integrity": "sha512-vXSxyUgX3vm1Q70QwzwkjeYfRryIvKno1SXbIqwSptKwqKzskINnDUcx85oX+ys6ooN2ATGSD0xN2UTfg6Zcow==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -754,22 +639,16 @@ }, "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { "version": "6.8.2", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.8.2.tgz", - "integrity": "sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/query-plan-executor": { "version": "6.18.0", - "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-6.18.0.tgz", - "integrity": "sha512-jZ8cfzFgL0jReE1R10gT8JLHtQxjWYLiQ//wHmVYZ2rVkFHoh0DT8IXsxcKcFlfKN7ak7k6j0XMNn2xVNyr5cA==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/studio-core": { "version": "0.8.2", - "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.8.2.tgz", - "integrity": "sha512-/iAEWEUpTja+7gVMu1LtR2pPlvDmveAwMHdTWbDeGlT7yiv0ZTCPpmeAGdq/Y9aJ9Zj1cEGBXGRbmmNPj022PQ==", "devOptional": true, "license": "UNLICENSED", "peerDependencies": { @@ -780,21 +659,15 @@ }, "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==", "devOptional": true, "license": "MIT" }, "node_modules/@types/aws-lambda": { "version": "8.10.159", - "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.159.tgz", - "integrity": "sha512-SAP22WSGNN12OQ8PlCzGzRCZ7QDCwI85dQZbmpz7+mAk+L7j+wI7qnvmdKh+o7A5LaOp6QnOZ2NJphAZQTTHQg==", "license": "MIT" }, "node_modules/@types/body-parser": { "version": "1.19.5", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", - "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", "dev": true, "license": "MIT", "dependencies": { @@ -804,8 +677,6 @@ }, "node_modules/@types/connect": { "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "dev": true, "license": "MIT", "dependencies": { @@ -814,8 +685,6 @@ }, "node_modules/@types/connect-pg-simple": { "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@types/connect-pg-simple/-/connect-pg-simple-7.0.3.tgz", - "integrity": "sha512-NGCy9WBlW2bw+J/QlLnFZ9WjoGs6tMo3LAut6mY4kK+XHzue//lpNVpAvYRpIwM969vBRAM2Re0izUvV6kt+NA==", "dev": true, "license": "MIT", "dependencies": { @@ -826,8 +695,6 @@ }, "node_modules/@types/cookie-parser": { "version": "1.4.10", - "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", - "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -836,8 +703,6 @@ }, "node_modules/@types/express": { "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", - "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", "dev": true, "license": "MIT", "dependencies": { @@ -848,8 +713,6 @@ }, "node_modules/@types/express-serve-static-core": { "version": "5.0.6", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz", - "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", "dev": true, "license": "MIT", "dependencies": { @@ -861,8 +724,6 @@ }, "node_modules/@types/express-session": { "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.2.tgz", - "integrity": "sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==", "dev": true, "license": "MIT", "dependencies": { @@ -871,34 +732,24 @@ }, "node_modules/@types/http-errors": { "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", "dev": true, "license": "MIT" }, "node_modules/@types/js-yaml": { "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", - "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", "license": "MIT" }, "node_modules/@types/json-schema": { "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, "node_modules/@types/mime": { "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "dev": true, "license": "MIT" }, "node_modules/@types/morgan": { "version": "1.9.10", - "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.10.tgz", - "integrity": "sha512-sS4A1zheMvsADRVfT0lYbJ4S9lmsey8Zo2F7cnbYjWHP67Q0AwMYuuzLlkIM2N8gAbb9cubhIVFwcIN2XyYCkA==", "dev": true, "license": "MIT", "dependencies": { @@ -907,8 +758,6 @@ }, "node_modules/@types/node": { "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -916,8 +765,6 @@ }, "node_modules/@types/node-fetch": { "version": "2.6.13", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", "license": "MIT", "dependencies": { "@types/node": "*", @@ -926,8 +773,6 @@ }, "node_modules/@types/pg": { "version": "8.15.4", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.4.tgz", - "integrity": "sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg==", "dev": true, "license": "MIT", "dependencies": { @@ -938,22 +783,16 @@ }, "node_modules/@types/qs": { "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "19.2.7", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", - "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", "peer": true, @@ -963,8 +802,6 @@ }, "node_modules/@types/send": { "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", - "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", "dev": true, "license": "MIT", "dependencies": { @@ -974,8 +811,6 @@ }, "node_modules/@types/serve-static": { "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", - "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", "dev": true, "license": "MIT", "dependencies": { @@ -986,8 +821,6 @@ }, "node_modules/@types/stream-buffers": { "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/stream-buffers/-/stream-buffers-3.0.7.tgz", - "integrity": "sha512-azOCy05sXVXrO+qklf0c/B07H/oHaIuDDAiHPVwlk3A9Ek+ksHyTeMajLZl3r76FxpPpxem//4Te61G1iW3Giw==", "license": "MIT", "dependencies": { "@types/node": "*" @@ -995,14 +828,10 @@ }, "node_modules/@yarnpkg/lockfile": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", - "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", "license": "BSD-2-Clause" }, "node_modules/accepts": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", "dependencies": { "mime-types": "^3.0.0", @@ -1014,8 +843,6 @@ }, "node_modules/agent-base": { "version": "7.1.3", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", - "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", "license": "MIT", "engines": { "node": ">= 14" @@ -1023,8 +850,6 @@ }, "node_modules/ajv": { "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1039,8 +864,6 @@ }, "node_modules/ajv-formats": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -1056,8 +879,6 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -1071,20 +892,14 @@ }, "node_modules/argparse": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, "node_modules/asynckit": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, "node_modules/aws-ssl-profiles": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", - "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", "devOptional": true, "license": "MIT", "engines": { @@ -1093,21 +908,15 @@ }, "node_modules/b4a": { "version": "1.6.7", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", - "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", "license": "Apache-2.0" }, "node_modules/bare-events": { "version": "2.5.4", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", - "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", "license": "Apache-2.0", "optional": true }, "node_modules/bare-fs": { "version": "4.1.5", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.5.tgz", - "integrity": "sha512-1zccWBMypln0jEE05LzZt+V/8y8AQsQQqxtklqaIyg5nu6OAYFhZxPXinJTSG+kU5qyNmeLgcn9AW7eHiCHVLA==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -1129,8 +938,6 @@ }, "node_modules/bare-os": { "version": "3.6.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz", - "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==", "license": "Apache-2.0", "optional": true, "engines": { @@ -1139,8 +946,6 @@ }, "node_modules/bare-path": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -1149,8 +954,6 @@ }, "node_modules/bare-stream": { "version": "2.6.5", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz", - "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -1171,8 +974,6 @@ }, "node_modules/basic-auth": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", - "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", "license": "MIT", "dependencies": { "safe-buffer": "5.1.2" @@ -1183,26 +984,18 @@ }, "node_modules/basic-auth/node_modules/safe-buffer": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, "node_modules/bath-es5": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/bath-es5/-/bath-es5-3.0.3.tgz", - "integrity": "sha512-PdCioDToH3t84lP40kUFCKWCOCH389Dl1kbC8FGoqOwamxsmqxxnJSXdkTOsPoNHXjem4+sJ+bbNoQm5zeCqxg==", "license": "MIT" }, "node_modules/before-after-hook": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", - "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", "license": "Apache-2.0" }, "node_modules/body-parser": { "version": "2.2.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", - "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", @@ -1225,14 +1018,10 @@ }, "node_modules/bottleneck": { "version": "2.19.5", - "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", - "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==", "license": "MIT" }, "node_modules/braces": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -1243,8 +1032,6 @@ }, "node_modules/bytes": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -1252,8 +1039,6 @@ }, "node_modules/c12": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", - "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -1281,8 +1066,6 @@ }, "node_modules/call-bind": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", @@ -1299,8 +1082,6 @@ }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -1312,8 +1093,6 @@ }, "node_modules/call-bound": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -1328,8 +1107,6 @@ }, "node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -1344,8 +1121,6 @@ }, "node_modules/chalk/node_modules/has-flag": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "license": "MIT", "engines": { "node": ">=8" @@ -1353,8 +1128,6 @@ }, "node_modules/chalk/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -1365,8 +1138,6 @@ }, "node_modules/chevrotain": { "version": "10.5.0", - "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz", - "integrity": "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -1380,8 +1151,6 @@ }, "node_modules/chokidar": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -1396,8 +1165,6 @@ }, "node_modules/ci-info": { "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", "funding": [ { "type": "github", @@ -1411,8 +1178,6 @@ }, "node_modules/citty": { "version": "0.1.6", - "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", - "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -1421,8 +1186,6 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -1433,14 +1196,10 @@ }, "node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, "node_modules/combined-stream": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -1451,15 +1210,11 @@ }, "node_modules/confbox": { "version": "0.2.2", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", - "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", "devOptional": true, "license": "MIT" }, "node_modules/connect-pg-simple": { "version": "10.0.0", - "resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz", - "integrity": "sha512-pBGVazlqiMrackzCr0eKhn4LO5trJXsOX0nQoey9wCOayh80MYtThCbq8eoLsjpiWgiok/h+1/uti9/2/Una8A==", "license": "MIT", "dependencies": { "pg": "^8.12.0" @@ -1470,8 +1225,6 @@ }, "node_modules/consola": { "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", "devOptional": true, "license": "MIT", "engines": { @@ -1480,8 +1233,6 @@ }, "node_modules/content-disposition": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", "license": "MIT", "dependencies": { "safe-buffer": "5.2.1" @@ -1492,8 +1243,6 @@ }, "node_modules/content-type": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -1501,8 +1250,6 @@ }, "node_modules/cookie": { "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -1510,8 +1257,6 @@ }, "node_modules/cookie-parser": { "version": "1.4.7", - "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", - "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", "license": "MIT", "dependencies": { "cookie": "0.7.2", @@ -1523,14 +1268,10 @@ }, "node_modules/cookie-parser/node_modules/cookie-signature": { "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, "node_modules/cookie-signature": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", "license": "MIT", "engines": { "node": ">=6.6.0" @@ -1538,8 +1279,6 @@ }, "node_modules/cross-spawn": { "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1552,16 +1291,12 @@ }, "node_modules/csstype": { "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "devOptional": true, "license": "MIT", "peer": true }, "node_modules/debug": { "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1577,8 +1312,6 @@ }, "node_modules/deepmerge-ts": { "version": "7.1.5", - "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", - "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", "devOptional": true, "license": "BSD-3-Clause", "engines": { @@ -1587,8 +1320,6 @@ }, "node_modules/define-data-property": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -1604,15 +1335,11 @@ }, "node_modules/defu": { "version": "6.1.4", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", "devOptional": true, "license": "MIT" }, "node_modules/delayed-stream": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "license": "MIT", "engines": { "node": ">=0.4.0" @@ -1620,8 +1347,6 @@ }, "node_modules/denque": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", "devOptional": true, "license": "Apache-2.0", "engines": { @@ -1630,8 +1355,6 @@ }, "node_modules/depd": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -1639,21 +1362,15 @@ }, "node_modules/dereference-json-schema": { "version": "0.2.1", - "resolved": "https://registry.npmjs.org/dereference-json-schema/-/dereference-json-schema-0.2.1.tgz", - "integrity": "sha512-uzJsrg225owJyRQ8FNTPHIuBOdSzIZlHhss9u6W8mp7jJldHqGuLv9cULagP/E26QVJDnjtG8U7Dw139mM1ydA==", "license": "MIT" }, "node_modules/destr": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", - "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", "devOptional": true, "license": "MIT" }, "node_modules/dotenv": { "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "devOptional": true, "license": "BSD-2-Clause", "engines": { @@ -1665,8 +1382,6 @@ }, "node_modules/dunder-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -1679,14 +1394,10 @@ }, "node_modules/ee-first": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, "node_modules/effect": { "version": "3.18.4", - "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", - "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -1696,8 +1407,6 @@ }, "node_modules/empathic": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", - "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", "devOptional": true, "license": "MIT", "engines": { @@ -1706,8 +1415,6 @@ }, "node_modules/encodeurl": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -1715,8 +1422,6 @@ }, "node_modules/end-of-stream": { "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -1724,8 +1429,6 @@ }, "node_modules/es-define-property": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -1733,8 +1436,6 @@ }, "node_modules/es-errors": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -1742,8 +1443,6 @@ }, "node_modules/es-object-atoms": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -1754,8 +1453,6 @@ }, "node_modules/es-set-tostringtag": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -1769,14 +1466,10 @@ }, "node_modules/escape-html": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, "node_modules/etag": { "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -1784,8 +1477,6 @@ }, "node_modules/express": { "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", "dependencies": { "accepts": "^2.0.0", @@ -1826,8 +1517,6 @@ }, "node_modules/express-rate-limit": { "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", "license": "MIT", "dependencies": { "ip-address": "10.0.1" @@ -1844,8 +1533,6 @@ }, "node_modules/express-rate-limit/node_modules/ip-address": { "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", "license": "MIT", "engines": { "node": ">= 12" @@ -1853,8 +1540,6 @@ }, "node_modules/express-session": { "version": "1.18.2", - "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz", - "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==", "license": "MIT", "dependencies": { "cookie": "0.7.2", @@ -1872,14 +1557,10 @@ }, "node_modules/express-session/node_modules/cookie-signature": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, "node_modules/express-session/node_modules/debug": { "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "dependencies": { "ms": "2.0.0" @@ -1887,21 +1568,15 @@ }, "node_modules/express-session/node_modules/ms": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, "node_modules/exsolve": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", - "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "devOptional": true, "license": "MIT" }, "node_modules/fast-check": { "version": "3.23.2", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", - "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", "devOptional": true, "funding": [ { @@ -1923,8 +1598,6 @@ }, "node_modules/fast-content-type-parse": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", - "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", "funding": [ { "type": "github", @@ -1939,20 +1612,14 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, "node_modules/fast-fifo": { "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", "license": "MIT" }, "node_modules/fast-uri": { "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", "funding": [ { "type": "github", @@ -1967,8 +1634,6 @@ }, "node_modules/fill-range": { "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -1979,8 +1644,6 @@ }, "node_modules/finalhandler": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -1996,8 +1659,6 @@ }, "node_modules/find-yarn-workspace-root": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", - "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", "license": "Apache-2.0", "dependencies": { "micromatch": "^4.0.2" @@ -2005,8 +1666,6 @@ }, "node_modules/foreground-child": { "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "devOptional": true, "license": "ISC", "dependencies": { @@ -2022,8 +1681,6 @@ }, "node_modules/form-data": { "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -2038,8 +1695,6 @@ }, "node_modules/form-data/node_modules/mime-db": { "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -2047,8 +1702,6 @@ }, "node_modules/form-data/node_modules/mime-types": { "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -2059,8 +1712,6 @@ }, "node_modules/forwarded": { "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -2068,8 +1719,6 @@ }, "node_modules/fresh": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -2077,8 +1726,6 @@ }, "node_modules/fs-extra": { "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -2091,8 +1738,6 @@ }, "node_modules/function-bind": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -2100,8 +1745,6 @@ }, "node_modules/generate-function": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", - "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2110,8 +1753,6 @@ }, "node_modules/get-intrinsic": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -2134,15 +1775,11 @@ }, "node_modules/get-port-please": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.1.2.tgz", - "integrity": "sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ==", "devOptional": true, "license": "MIT" }, "node_modules/get-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -2154,8 +1791,6 @@ }, "node_modules/giget": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", - "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2172,8 +1807,6 @@ }, "node_modules/gopd": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -2184,21 +1817,15 @@ }, "node_modules/graceful-fs": { "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, "node_modules/grammex": { "version": "3.1.12", - "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", - "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", "devOptional": true, "license": "MIT" }, "node_modules/has-property-descriptors": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -2209,8 +1836,6 @@ }, "node_modules/has-symbols": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -2221,8 +1846,6 @@ }, "node_modules/has-tostringtag": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -2236,8 +1859,6 @@ }, "node_modules/hasown": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -2248,8 +1869,6 @@ }, "node_modules/hono": { "version": "4.10.6", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.10.6.tgz", - "integrity": "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==", "devOptional": true, "license": "MIT", "engines": { @@ -2258,8 +1877,6 @@ }, "node_modules/hpagent": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", - "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==", "license": "MIT", "engines": { "node": ">=14" @@ -2267,8 +1884,6 @@ }, "node_modules/http-errors": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", "dependencies": { "depd": "~2.0.0", @@ -2287,15 +1902,11 @@ }, "node_modules/http-status-codes": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", - "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", "devOptional": true, "license": "MIT" }, "node_modules/iconv-lite": { "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -2310,14 +1921,10 @@ }, "node_modules/inherits": { "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, "node_modules/ip-address": { "version": "9.0.5", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", - "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", "license": "MIT", "dependencies": { "jsbn": "1.1.0", @@ -2329,8 +1936,6 @@ }, "node_modules/ipaddr.js": { "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "license": "MIT", "engines": { "node": ">= 0.10" @@ -2338,8 +1943,6 @@ }, "node_modules/is-docker": { "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", "license": "MIT", "bin": { "is-docker": "cli.js" @@ -2353,8 +1956,6 @@ }, "node_modules/is-number": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "license": "MIT", "engines": { "node": ">=0.12.0" @@ -2362,21 +1963,15 @@ }, "node_modules/is-promise": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, "node_modules/is-property": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", - "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", "devOptional": true, "license": "MIT" }, "node_modules/is-wsl": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "license": "MIT", "dependencies": { "is-docker": "^2.0.0" @@ -2387,20 +1982,14 @@ }, "node_modules/isarray": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, "node_modules/isomorphic-ws": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", - "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", "license": "MIT", "peerDependencies": { "ws": "*" @@ -2408,8 +1997,6 @@ }, "node_modules/jiti": { "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "devOptional": true, "license": "MIT", "bin": { @@ -2418,8 +2005,6 @@ }, "node_modules/jose": { "version": "6.1.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.2.tgz", - "integrity": "sha512-MpcPtHLE5EmztuFIqB0vzHAWJPpmN1E6L4oo+kze56LIs3MyXIj9ZHMDxqOvkP38gBR7K1v3jqd4WU2+nrfONQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -2427,8 +2012,6 @@ }, "node_modules/js-yaml": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -2439,14 +2022,10 @@ }, "node_modules/jsbn": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", "license": "MIT" }, "node_modules/jsep": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", - "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", "license": "MIT", "engines": { "node": ">= 10.16.0" @@ -2454,14 +2033,10 @@ }, "node_modules/json-schema-traverse": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/json-stable-stringify": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", - "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -2479,8 +2054,6 @@ }, "node_modules/jsonfile": { "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -2491,8 +2064,6 @@ }, "node_modules/jsonify": { "version": "0.0.1", - "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", - "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", "license": "Public Domain", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -2500,8 +2071,6 @@ }, "node_modules/jsonpath-plus": { "version": "10.3.0", - "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.3.0.tgz", - "integrity": "sha512-8TNmfeTCk2Le33A3vRRwtuworG/L5RrgMvdjhKZxvyShO+mBu2fP50OWUjRLNtvw344DdDarFh9buFAZs5ujeA==", "license": "MIT", "dependencies": { "@jsep-plugin/assignment": "^1.3.0", @@ -2518,8 +2087,6 @@ }, "node_modules/klaw-sync": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", - "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", "license": "MIT", "dependencies": { "graceful-fs": "^4.1.11" @@ -2527,8 +2094,6 @@ }, "node_modules/lilconfig": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", - "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", "devOptional": true, "license": "MIT", "engines": { @@ -2537,27 +2102,19 @@ }, "node_modules/lodash": { "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "license": "MIT" }, "node_modules/long": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/lru-cache": { "version": "11.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", - "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", "license": "ISC", "engines": { "node": "20 || >=22" @@ -2565,8 +2122,6 @@ }, "node_modules/lru.min": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.3.tgz", - "integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==", "devOptional": true, "license": "MIT", "engines": { @@ -2581,8 +2136,6 @@ }, "node_modules/math-intrinsics": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -2590,8 +2143,6 @@ }, "node_modules/media-typer": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -2599,8 +2150,6 @@ }, "node_modules/merge-descriptors": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", "engines": { "node": ">=18" @@ -2611,8 +2160,6 @@ }, "node_modules/micromatch": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -2624,8 +2171,6 @@ }, "node_modules/mime-db": { "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -2633,8 +2178,6 @@ }, "node_modules/mime-types": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -2645,8 +2188,6 @@ }, "node_modules/minimist": { "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -2654,8 +2195,6 @@ }, "node_modules/mock-json-schema": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/mock-json-schema/-/mock-json-schema-1.1.1.tgz", - "integrity": "sha512-YV23vlsLP1EEOy0EviUvZTluXjLR+rhMzeayP2rcDiezj3RW01MhOSQkbQskdtg0K2fnGas5LKbSXgNjAOSX4A==", "license": "MIT", "dependencies": { "lodash": "^4.17.21" @@ -2663,8 +2202,6 @@ }, "node_modules/morgan": { "version": "1.10.1", - "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", - "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", "license": "MIT", "dependencies": { "basic-auth": "~2.0.1", @@ -2679,8 +2216,6 @@ }, "node_modules/morgan/node_modules/debug": { "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "dependencies": { "ms": "2.0.0" @@ -2688,14 +2223,10 @@ }, "node_modules/morgan/node_modules/ms": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, "node_modules/morgan/node_modules/on-finished": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", "license": "MIT", "dependencies": { "ee-first": "1.1.1" @@ -2706,14 +2237,10 @@ }, "node_modules/ms": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, "node_modules/mysql2": { "version": "3.15.3", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", - "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2733,8 +2260,6 @@ }, "node_modules/named-placeholders": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", - "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2746,8 +2271,6 @@ }, "node_modules/named-placeholders/node_modules/lru-cache": { "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", "devOptional": true, "license": "ISC", "engines": { @@ -2756,8 +2279,6 @@ }, "node_modules/negotiator": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -2765,8 +2286,6 @@ }, "node_modules/node-fetch": { "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", "dependencies": { "whatwg-url": "^5.0.0" @@ -2785,15 +2304,11 @@ }, "node_modules/node-fetch-native": { "version": "1.6.7", - "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", - "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", "devOptional": true, "license": "MIT" }, "node_modules/nypm": { "version": "0.6.2", - "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", - "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2812,8 +2327,6 @@ }, "node_modules/oauth4webapi": { "version": "3.8.3", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.3.tgz", - "integrity": "sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -2821,8 +2334,6 @@ }, "node_modules/object-inspect": { "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -2833,8 +2344,6 @@ }, "node_modules/object-keys": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -2842,8 +2351,6 @@ }, "node_modules/octokit": { "version": "5.0.5", - "resolved": "https://registry.npmjs.org/octokit/-/octokit-5.0.5.tgz", - "integrity": "sha512-4+/OFSqOjoyULo7eN7EA97DE0Xydj/PW5aIckxqQIoFjFwqXKuFCvXUJObyJfBF9Khu4RL/jlDRI9FPaMGfPnw==", "license": "MIT", "dependencies": { "@octokit/app": "^16.1.2", @@ -2864,15 +2371,11 @@ }, "node_modules/ohash": { "version": "2.0.11", - "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", - "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", "devOptional": true, "license": "MIT" }, "node_modules/on-finished": { "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "license": "MIT", "dependencies": { "ee-first": "1.1.1" @@ -2883,8 +2386,6 @@ }, "node_modules/on-headers": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -2892,8 +2393,6 @@ }, "node_modules/once": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "license": "ISC", "dependencies": { "wrappy": "1" @@ -2901,8 +2400,6 @@ }, "node_modules/open": { "version": "7.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", - "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", "license": "MIT", "dependencies": { "is-docker": "^2.0.0", @@ -2917,8 +2414,6 @@ }, "node_modules/openapi-backend": { "version": "5.15.0", - "resolved": "https://registry.npmjs.org/openapi-backend/-/openapi-backend-5.15.0.tgz", - "integrity": "sha512-yox0nCv511YWUeBNCdKY6xmUB92yEN+N9rHO4BHA5GOAZaNtY+zzuftAdfEwIbCsCcvZJ9ysENCguqBg+hLlWw==", "license": "MIT", "dependencies": { "@apidevtools/json-schema-ref-parser": "^11.1.0", @@ -2941,8 +2436,6 @@ }, "node_modules/openapi-backend/node_modules/cookie": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", "license": "MIT", "engines": { "node": ">=18" @@ -2950,8 +2443,6 @@ }, "node_modules/openapi-schema-validator": { "version": "12.1.3", - "resolved": "https://registry.npmjs.org/openapi-schema-validator/-/openapi-schema-validator-12.1.3.tgz", - "integrity": "sha512-xTHOmxU/VQGUgo7Cm0jhwbklOKobXby+/237EG967+3TQEYJztMgX9Q5UE2taZKwyKPUq0j11dngpGjUuxz1hQ==", "license": "MIT", "dependencies": { "ajv": "^8.1.0", @@ -2962,14 +2453,10 @@ }, "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==", "license": "MIT" }, "node_modules/openid-client": { "version": "6.8.1", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.1.tgz", - "integrity": "sha512-VoYT6enBo6Vj2j3Q5Ec0AezS+9YGzQo1f5Xc42lreMGlfP4ljiXPKVDvCADh+XHCV/bqPu/wWSiCVXbJKvrODw==", "license": "MIT", "dependencies": { "jose": "^6.1.0", @@ -2981,8 +2468,6 @@ }, "node_modules/parseurl": { "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -2990,8 +2475,6 @@ }, "node_modules/patch-package": { "version": "8.0.1", - "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", - "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", "license": "MIT", "dependencies": { "@yarnpkg/lockfile": "^1.1.0", @@ -3019,8 +2502,6 @@ }, "node_modules/path-key": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "license": "MIT", "engines": { "node": ">=8" @@ -3028,8 +2509,6 @@ }, "node_modules/path-to-regexp": { "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", "license": "MIT", "engines": { "node": ">=16" @@ -3037,22 +2516,16 @@ }, "node_modules/pathe": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "devOptional": true, "license": "MIT" }, "node_modules/perfect-debounce": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", "devOptional": true, "license": "MIT" }, "node_modules/pg": { "version": "8.16.3", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", - "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", "dependencies": { "pg-connection-string": "^2.9.1", @@ -3078,21 +2551,15 @@ }, "node_modules/pg-cloudflare": { "version": "1.2.7", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", - "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", "license": "MIT", "optional": true }, "node_modules/pg-connection-string": { "version": "2.9.1", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", - "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", "license": "MIT" }, "node_modules/pg-int8": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", "license": "ISC", "engines": { "node": ">=4.0.0" @@ -3100,8 +2567,6 @@ }, "node_modules/pg-pool": { "version": "3.10.1", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", - "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", "license": "MIT", "peerDependencies": { "pg": ">=8.0" @@ -3109,14 +2574,10 @@ }, "node_modules/pg-protocol": { "version": "1.10.3", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", - "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", "license": "MIT" }, "node_modules/pg-types": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", "license": "MIT", "dependencies": { "pg-int8": "1.0.1", @@ -3131,8 +2592,6 @@ }, "node_modules/pgpass": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", "license": "MIT", "dependencies": { "split2": "^4.1.0" @@ -3140,8 +2599,6 @@ }, "node_modules/picomatch": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -3152,8 +2609,6 @@ }, "node_modules/pkg-types": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", - "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3164,8 +2619,6 @@ }, "node_modules/postgres": { "version": "3.4.7", - "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", - "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", "devOptional": true, "license": "Unlicense", "engines": { @@ -3178,8 +2631,6 @@ }, "node_modules/postgres-array": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", "license": "MIT", "engines": { "node": ">=4" @@ -3187,8 +2638,6 @@ }, "node_modules/postgres-bytea": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", - "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3196,8 +2645,6 @@ }, "node_modules/postgres-date": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3205,8 +2652,6 @@ }, "node_modules/postgres-interval": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", "license": "MIT", "dependencies": { "xtend": "^4.0.0" @@ -3217,8 +2662,6 @@ }, "node_modules/prisma": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.1.0.tgz", - "integrity": "sha512-dy/3urE4JjhdiW5b09pGjVhGI7kPESK2VlCDrCqeYK5m5SslAtG5FCGnZWP7E8Sdg+Ow1wV2mhJH5RTFL5gEsw==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -3251,8 +2694,6 @@ }, "node_modules/prisma-json-types-generator": { "version": "4.0.0-beta.1", - "resolved": "https://registry.npmjs.org/prisma-json-types-generator/-/prisma-json-types-generator-4.0.0-beta.1.tgz", - "integrity": "sha512-JUpTlZ6QGWRyU5+Iz4zAfRYalK4Z744VRvDG4Gb3pW4R1bi5NRRVtOT2N+CirOEm0Nj0zU3zu90r/0z6+gwR6g==", "dev": true, "license": "MIT", "dependencies": { @@ -3278,8 +2719,6 @@ }, "node_modules/proper-lockfile": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", - "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3290,15 +2729,11 @@ }, "node_modules/proper-lockfile/node_modules/signal-exit": { "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "devOptional": true, "license": "ISC" }, "node_modules/proxy-addr": { "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -3310,8 +2745,6 @@ }, "node_modules/pump": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", - "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -3320,8 +2753,6 @@ }, "node_modules/pure-rand": { "version": "6.1.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", - "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", "devOptional": true, "funding": [ { @@ -3337,8 +2768,6 @@ }, "node_modules/qs": { "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -3352,8 +2781,6 @@ }, "node_modules/random-bytes": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", - "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -3361,8 +2788,6 @@ }, "node_modules/range-parser": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -3370,8 +2795,6 @@ }, "node_modules/raw-body": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -3385,8 +2808,6 @@ }, "node_modules/rc9": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", - "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3396,8 +2817,6 @@ }, "node_modules/react": { "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", - "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "devOptional": true, "license": "MIT", "peer": true, @@ -3407,8 +2826,6 @@ }, "node_modules/react-dom": { "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", - "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "devOptional": true, "license": "MIT", "peer": true, @@ -3421,8 +2838,6 @@ }, "node_modules/readdirp": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "devOptional": true, "license": "MIT", "engines": { @@ -3435,15 +2850,11 @@ }, "node_modules/regexp-to-ast": { "version": "0.5.0", - "resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz", - "integrity": "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==", "devOptional": true, "license": "MIT" }, "node_modules/remeda": { "version": "2.21.3", - "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.21.3.tgz", - "integrity": "sha512-XXrZdLA10oEOQhLLzEJEiFFSKi21REGAkHdImIb4rt/XXy8ORGXh5HCcpUOsElfPNDb+X6TA/+wkh+p2KffYmg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3452,8 +2863,6 @@ }, "node_modules/require-from-string": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3461,8 +2870,6 @@ }, "node_modules/retry": { "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", "devOptional": true, "license": "MIT", "engines": { @@ -3471,14 +2878,10 @@ }, "node_modules/rfc4648": { "version": "1.5.4", - "resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.4.tgz", - "integrity": "sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==", "license": "MIT" }, "node_modules/router": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -3493,8 +2896,6 @@ }, "node_modules/safe-buffer": { "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "funding": [ { "type": "github", @@ -3513,22 +2914,16 @@ }, "node_modules/safer-buffer": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, "node_modules/scheduler": { "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "devOptional": true, "license": "MIT", "peer": true }, "node_modules/semver": { "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -3539,8 +2934,6 @@ }, "node_modules/send": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", "license": "MIT", "dependencies": { "debug": "^4.3.5", @@ -3561,14 +2954,10 @@ }, "node_modules/seq-queue": { "version": "0.0.5", - "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", - "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", "devOptional": true }, "node_modules/serve-static": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", "license": "MIT", "dependencies": { "encodeurl": "^2.0.0", @@ -3582,8 +2971,6 @@ }, "node_modules/set-function-length": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -3599,14 +2986,10 @@ }, "node_modules/setprototypeof": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, "node_modules/shebang-command": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -3617,8 +3000,6 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "license": "MIT", "engines": { "node": ">=8" @@ -3626,8 +3007,6 @@ }, "node_modules/side-channel": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3645,8 +3024,6 @@ }, "node_modules/side-channel-list": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3661,8 +3038,6 @@ }, "node_modules/side-channel-map": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -3679,8 +3054,6 @@ }, "node_modules/side-channel-weakmap": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -3698,8 +3071,6 @@ }, "node_modules/signal-exit": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "devOptional": true, "license": "ISC", "engines": { @@ -3711,8 +3082,6 @@ }, "node_modules/slash": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", - "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", "license": "MIT", "engines": { "node": ">=6" @@ -3720,8 +3089,6 @@ }, "node_modules/smart-buffer": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "license": "MIT", "engines": { "node": ">= 6.0.0", @@ -3730,8 +3097,6 @@ }, "node_modules/socks": { "version": "2.8.4", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", - "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", "license": "MIT", "dependencies": { "ip-address": "^9.0.5", @@ -3744,8 +3109,6 @@ }, "node_modules/socks-proxy-agent": { "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -3758,8 +3121,6 @@ }, "node_modules/split2": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "license": "ISC", "engines": { "node": ">= 10.x" @@ -3767,14 +3128,10 @@ }, "node_modules/sprintf-js": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "license": "BSD-3-Clause" }, "node_modules/sqlstring": { "version": "2.3.3", - "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", - "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", "devOptional": true, "license": "MIT", "engines": { @@ -3783,8 +3140,6 @@ }, "node_modules/statuses": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -3792,15 +3147,11 @@ }, "node_modules/std-env": { "version": "3.9.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", - "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", "devOptional": true, "license": "MIT" }, "node_modules/stream-buffers": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-3.0.3.tgz", - "integrity": "sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==", "license": "Unlicense", "engines": { "node": ">= 0.10.0" @@ -3808,8 +3159,6 @@ }, "node_modules/streamx": { "version": "2.22.1", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz", - "integrity": "sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==", "license": "MIT", "dependencies": { "fast-fifo": "^1.3.2", @@ -3821,8 +3170,6 @@ }, "node_modules/tar-fs": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", - "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", "license": "MIT", "dependencies": { "pump": "^3.0.0", @@ -3835,8 +3182,6 @@ }, "node_modules/tar-stream": { "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", "license": "MIT", "dependencies": { "b4a": "^1.6.4", @@ -3846,8 +3191,6 @@ }, "node_modules/text-decoder": { "version": "1.2.3", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", - "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.4" @@ -3855,8 +3198,6 @@ }, "node_modules/tinyexec": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", "devOptional": true, "license": "MIT", "engines": { @@ -3865,8 +3206,6 @@ }, "node_modules/tmp": { "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", "license": "MIT", "engines": { "node": ">=14.14" @@ -3874,8 +3213,6 @@ }, "node_modules/to-regex-range": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -3886,8 +3223,6 @@ }, "node_modules/toad-cache": { "version": "3.7.0", - "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", - "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", "license": "MIT", "engines": { "node": ">=12" @@ -3895,8 +3230,6 @@ }, "node_modules/toidentifier": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "license": "MIT", "engines": { "node": ">=0.6" @@ -3904,14 +3237,10 @@ }, "node_modules/tr46": { "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, "node_modules/try": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/try/-/try-1.0.1.tgz", - "integrity": "sha512-3S6RIoraErJFhkNmlNyojU9YmaEs6M2qvAyy7lSb6PgSbiX5hOgyeLVsgwJ74lCSMLxjCggtgiaOJ4BtCV7LNA==", "dev": true, "license": "MIT", "funding": { @@ -3920,15 +3249,11 @@ }, "node_modules/tslib": { "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" }, "node_modules/type-fest": { "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "devOptional": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -3940,8 +3265,6 @@ }, "node_modules/type-is": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "license": "MIT", "dependencies": { "content-type": "^1.0.5", @@ -3954,8 +3277,6 @@ }, "node_modules/typescript": { "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", "bin": { @@ -3968,8 +3289,6 @@ }, "node_modules/uid-safe": { "version": "2.1.5", - "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", - "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", "license": "MIT", "dependencies": { "random-bytes": "~1.0.0" @@ -3980,26 +3299,18 @@ }, "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==", "license": "MIT" }, "node_modules/universal-github-app-jwt": { "version": "2.2.2", - "resolved": "https://registry.npmjs.org/universal-github-app-jwt/-/universal-github-app-jwt-2.2.2.tgz", - "integrity": "sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw==", "license": "MIT" }, "node_modules/universal-user-agent": { "version": "7.0.3", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", - "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", "license": "ISC" }, "node_modules/universalify": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "license": "MIT", "engines": { "node": ">= 10.0.0" @@ -4007,8 +3318,6 @@ }, "node_modules/unpipe": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -4016,8 +3325,6 @@ }, "node_modules/valibot": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", - "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", "devOptional": true, "license": "MIT", "peerDependencies": { @@ -4031,8 +3338,6 @@ }, "node_modules/vary": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -4040,14 +3345,10 @@ }, "node_modules/webidl-conversions": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, "node_modules/whatwg-url": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "license": "MIT", "dependencies": { "tr46": "~0.0.3", @@ -4056,8 +3357,6 @@ }, "node_modules/which": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -4071,14 +3370,10 @@ }, "node_modules/wrappy": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, "node_modules/ws": { "version": "8.18.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", - "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -4098,29 +3393,28 @@ }, "node_modules/xtend": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", "license": "MIT", "engines": { "node": ">=0.4" } }, "node_modules/yaml": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", - "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/zeptomatch": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.0.2.tgz", - "integrity": "sha512-H33jtSKf8Ijtb5BW6wua3G5DhnFjbFML36eFu+VdOoVY4HD9e7ggjqdM6639B+L87rjnR6Y+XeRzBXZdy52B/g==", "devOptional": true, "license": "MIT", "dependencies": { diff --git a/backend/package.json b/backend/package.json index e1419487..e9fe5b1a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -29,7 +29,8 @@ "openapi-backend": "^5.15.0", "openid-client": "^6.8.1", "patch-package": "^8.0.1", - "pg": "^8.16.3" + "pg": "^8.16.3", + "yaml": "^2.8.2" }, "devDependencies": { "@types/connect-pg-simple": "^7.0.3", diff --git a/backend/prisma/Dockerfile b/backend/prisma/Dockerfile index 265ce57f..6aeb38aa 100644 --- a/backend/prisma/Dockerfile +++ b/backend/prisma/Dockerfile @@ -28,7 +28,8 @@ USER 65532 # https://github.com/krallin/tini ENV TINI_VERSION=v0.19.0 -ADD --chown=65532:65532 --chmod=500 https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini +ARG TARGETARCH +ADD --chown=65532:65532 --chmod=500 https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-${TARGETARCH} /tini ENTRYPOINT ["/tini", "--", "/nodejs/bin/node", "/app/node_modules/.bin/prisma"] COPY --chown=65532:65532 --from=build /app /app diff --git a/backend/prisma/migrations/20251222211930_separate_workload_and_helm_configs/migration.sql b/backend/prisma/migrations/20251222211930_separate_workload_and_helm_configs/migration.sql new file mode 100644 index 00000000..de78d3f1 --- /dev/null +++ b/backend/prisma/migrations/20251222211930_separate_workload_and_helm_configs/migration.sql @@ -0,0 +1,75 @@ +-- CreateEnum +CREATE TYPE "AppType" AS ENUM ('workload', 'helm'); + +-- CreateEnum +CREATE TYPE "HelmUrlType" AS ENUM ('oci', 'absolute'); + +-- AlterTable +ALTER TABLE "DeploymentConfig" +RENAME TO "WorkloadConfig"; + +ALTER INDEX "DeploymentConfig_pkey" RENAME TO "WorkloadConfig_pkey"; + +ALTER SEQUENCE "DeploymentConfig_id_seq" +RENAME TO "WorkloadConfig_id_seq"; + +CREATE TABLE "DeploymentConfig" ( + "id" SERIAL NOT NULL, + "appType" "AppType" NOT NULL, + + CONSTRAINT "DeploymentConfig_pkey" PRIMARY KEY ("id") +); + +-- Fill with existing WorkloadConfigs +INSERT INTO "DeploymentConfig" ("id", "appType") +SELECT id, 'workload' FROM "WorkloadConfig"; + +-- Adjust sequence to start at highest existing id value +SELECT setval( + '"DeploymentConfig_id_seq"', + (SELECT COALESCE(MAX(id), 1) FROM "DeploymentConfig") +); + +-- Add deploymentConfigId to WorkloadConfig +ALTER TABLE "WorkloadConfig" +ADD COLUMN "deploymentConfigId" INTEGER; + +UPDATE "WorkloadConfig" +SET "deploymentConfigId" = id; + +ALTER TABLE "WorkloadConfig" +ALTER COLUMN "deploymentConfigId" SET NOT NULL; + +CREATE UNIQUE INDEX "WorkloadConfig_deploymentConfigId_key" ON "WorkloadConfig"("deploymentConfigId"); + +ALTER TABLE "WorkloadConfig" + ADD CONSTRAINT "WorkloadConfig_deploymentConfigId_fkey" + FOREIGN KEY ("deploymentConfigId") REFERENCES "DeploymentConfig"(id) + ON UPDATE CASCADE ON DELETE CASCADE; + +-- Alter foreign key constraints +ALTER TABLE "Deployment" DROP CONSTRAINT "Deployment_configId_fkey"; +ALTER TABLE "Deployment" + ADD CONSTRAINT "Deployment_configId_fkey" + FOREIGN KEY ("configId") REFERENCES "DeploymentConfig"(id) + ON UPDATE CASCADE ON DELETE CASCADE; + +ALTER TABLE "App" DROP CONSTRAINT "App_configId_fkey"; +ALTER TABLE "App" + ADD CONSTRAINT "App_configId_fkey" + FOREIGN KEY ("configId") references "DeploymentConfig"(id) + ON UPDATE CASCADE ON DELETE SET NULL; + +-- CreateTable +CREATE TABLE "HelmConfig" ( + "id" SERIAL NOT NULL, + "url" TEXT NOT NULL, + "version" TEXT NOT NULL, + "urlType" "HelmUrlType" NOT NULL, + "values" JSONB, + "deploymentConfigId" INTEGER UNIQUE NOT NULL, + CONSTRAINT "HelmConfig_pkey" PRIMARY KEY ("id"), + CONSTRAINT "HelmConfig_deploymentConfigId_fkey" + FOREIGN KEY ("deploymentConfigId") REFERENCES "DeploymentConfig"(id) + ON UPDATE CASCADE ON DELETE CASCADE +); \ No newline at end of file diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 177f99cb..8d89e6de 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -138,6 +138,11 @@ enum ImageBuilder { railpack } +enum AppType { + workload + helm +} + enum DeploymentSource { GIT IMAGE @@ -199,12 +204,24 @@ model Deployment { } model DeploymentConfig { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) + deployment Deployment? + appType AppType + workloadConfig WorkloadConfig? + + helmConfig HelmConfig? + app App? +} + +model WorkloadConfig { + id Int @id @default(autoincrement()) // Deployment options /// [EnvVar[]] - env Json @default("[]") - envKey String @default("") - deployment Deployment? + env Json @default("[]") + envKey String @default("") + deploymentConfig DeploymentConfig @relation(fields: [deploymentConfigId], references: [id], onDelete: Cascade) + deploymentConfigId Int @unique + // Build options source DeploymentSource // > Git deployment source @@ -234,9 +251,21 @@ model DeploymentConfig { limits Json ///[Resources] requests Json +} + +enum HelmUrlType { + oci + absolute +} - // Reverse relation field for when this DeploymentConfig is an App's template config - app App? +model HelmConfig { + id Int @id @default(autoincrement()) + deploymentConfig DeploymentConfig @relation(fields: [deploymentConfigId], references: [id], onDelete: Cascade) + deploymentConfigId Int @unique + url String + version String + urlType HelmUrlType + values Json? } model Log { diff --git a/backend/prisma/types.ts b/backend/prisma/types.ts index 11726454..5a063008 100644 --- a/backend/prisma/types.ts +++ b/backend/prisma/types.ts @@ -16,10 +16,5 @@ declare global { }; type VolumeMount = { path: string; amountInMiB: number }; - - type AppFlags = { - enableCD: boolean; - isPreviewing: boolean; - }; } } diff --git a/backend/src/db/models.ts b/backend/src/db/models.ts index e57ceb06..25f017b5 100644 --- a/backend/src/db/models.ts +++ b/backend/src/db/models.ts @@ -2,6 +2,7 @@ import type { DeploymentSource, DeploymentStatus, GitHubOAuthAction, + HelmUrlType, ImageBuilder, PermissionLevel, WebhookEvent, @@ -98,10 +99,10 @@ export interface DeploymentWithSourceInfo extends Omit { source?: DeploymentSource; } -export interface DeploymentConfig { - id: number; +export interface WorkloadConfig { displayEnv: PrismaJson.EnvVar[]; getEnv(): PrismaJson.EnvVar[]; + appType: "workload"; source: DeploymentSource; repositoryId?: number; branch?: string; @@ -114,21 +115,81 @@ export interface DeploymentConfig { imageTag?: string; collectLogs: boolean; createIngress: boolean; - subdomain: string | undefined; + subdomain?: string; requests: PrismaJson.Resources; limits: PrismaJson.Resources; replicas: number; port: number; mounts: PrismaJson.VolumeMount[]; + + /** + * Returns this instance casted to `GitConfig` if `source` == `GIT`. + * @throws {Error} if this workload is not deployed from a Git repo + */ + asGitConfig(): GitConfig; } -export type DeploymentConfigCreate = Omit< - DeploymentConfig, - "id" | "displayEnv" | "getEnv" +export type DeploymentConfig = (WorkloadConfig | HelmConfig) & { + /** + * Returns this instance casted to `WorkloadConfig` if `appType` == `workload`. + * @throws {Error} if this deployment is not a workload + */ + asWorkloadConfig(): WorkloadConfig; + + /** + * Returns this instance casted to `HelmConfig` if `appType` == `helm`. + * @throws {Error} if this deployment is not a Helm chart + */ + asHelmConfig(): HelmConfig; + + /** + * A shortcut for `asWorkloadConfig().asGitConfig()` + */ + asGitConfig(): GitConfig; +}; + +export type WorkloadConfigCreate = Omit< + WorkloadConfig, + "id" | "displayEnv" | "getEnv" | "asGitConfig" > & { env: PrismaJson.EnvVar[]; }; +export type GitConfig = WorkloadConfig & { + source: "GIT"; + repositoryId: number; + branch: string; + event: WebhookEvent; + eventId?: number; + commitHash: string; + builder: ImageBuilder; + rootDir?: string; + dockerfilePath?: string; +}; + +export type GitConfigCreate = WorkloadConfigCreate & { + source: "GIT"; + repositoryId: number; + branch: string; + event: WebhookEvent; + eventId?: number; + commitHash: string; + builder: ImageBuilder; + rootDir?: string; + dockerfilePath?: string; +}; + +export type HelmConfig = { + appType: "helm"; + source: "HELM"; + url: string; + version: string; + urlType: HelmUrlType; + values?: any; +}; + +export type HelmConfigCreate = Omit; + export interface Log { id: number; type: "BUILD" | "RUNTIME"; diff --git a/backend/src/db/repo/app.ts b/backend/src/db/repo/app.ts index 3b0017c5..2241dffd 100644 --- a/backend/src/db/repo/app.ts +++ b/backend/src/db/repo/app.ts @@ -60,11 +60,14 @@ export class AppRepo { return await this.client.app.findMany({ where: { config: { - source: DeploymentSource.GIT, - repositoryId: repoId, - event, - eventId, - branch, + appType: "workload", + workloadConfig: { + source: DeploymentSource.GIT, + repositoryId: repoId, + event, + eventId, + branch, + }, }, org: { githubInstallationId: { not: null } }, enableCD: true, @@ -72,12 +75,15 @@ export class AppRepo { }); } - async isSubdomainInUse(subdomain: string): Promise { - return ( - (await this.client.app.count({ - where: { config: { subdomain: subdomain } }, - })) > 0 - ); + async getAppBySubdomain(subdomain: string): Promise { + return await this.client.app.findFirst({ + where: { + config: { + appType: "workload", + workloadConfig: { subdomain }, + }, + }, + }); } async listForOrg(orgId: number): Promise { @@ -118,10 +124,10 @@ export class AppRepo { err.code === "P2002" ) { // P2002 is "Unique Constraint Failed" - https://www.prisma.io/docs/orm/reference/error-reference#p2002 - throw new ConflictError( - err.meta?.target as string /* column name */, - err, - ); + const target = Array.isArray(err.meta?.target) + ? err.meta.target.join(", ") + : (err.meta?.target as string); + throw new ConflictError(target); } } @@ -212,10 +218,17 @@ export class AppRepo { async getDeploymentConfig(appId: number): Promise { const app = await this.client.app.findUnique({ where: { id: appId }, - include: { config: true }, + include: { + config: { + include: { + workloadConfig: { omit: { id: true, deploymentConfigId: true } }, + helmConfig: { omit: { id: true, deploymentConfigId: true } }, + }, + }, + }, }); - return DeploymentRepo.preprocessDeploymentConfig(app.config); + return DeploymentRepo.preprocessConfig(app.config); } async setConfig(appId: number, configId: number) { @@ -232,7 +245,7 @@ export class AppRepo { } async getDeploymentsWithStatus(appId: number, statuses: DeploymentStatus[]) { - return await this.client.deployment.findMany({ + const deployments = await this.client.deployment.findMany({ where: { appId: appId, status: { @@ -240,9 +253,19 @@ export class AppRepo { }, }, include: { - config: true, + config: { + include: { + workloadConfig: { omit: { id: true } }, + helmConfig: { omit: { id: true } }, + }, + }, }, }); + + return deployments.map((deployment) => ({ + ...deployment, + config: DeploymentRepo.preprocessConfig(deployment.config), + })); } async setGroup(appId: number, appGroupId: number) { diff --git a/backend/src/db/repo/appGroup.ts b/backend/src/db/repo/appGroup.ts index 12fe299d..19976e9c 100644 --- a/backend/src/db/repo/appGroup.ts +++ b/backend/src/db/repo/appGroup.ts @@ -30,6 +30,15 @@ export class AppGroupRepo { } } + async delete(appGroupId: number) { + if ( + (await this.client.app.count({ where: { appGroupId: appGroupId } })) > 0 + ) { + throw new Error("App group is not empty"); + } + await this.client.appGroup.delete({ where: { id: appGroupId } }); + } + async getById(appGroupId: number): Promise { return await this.client.appGroup.findUnique({ where: { id: appGroupId }, diff --git a/backend/src/db/repo/deployment.ts b/backend/src/db/repo/deployment.ts index 78fc3223..6405dea1 100644 --- a/backend/src/db/repo/deployment.ts +++ b/backend/src/db/repo/deployment.ts @@ -1,23 +1,31 @@ import { randomBytes } from "node:crypto"; import type { + AppType, DeploymentStatus, LogType, PermissionLevel, } from "../../generated/prisma/enums.ts"; -import { - type DeploymentConfigCreateInput, - type DeploymentConfigModel as PrismaDeploymentConfig, -} from "../../generated/prisma/models/DeploymentConfig.ts"; +import type { + HelmConfigModel as PrismaHelmConfig, + WorkloadConfigModel as PrismaWorkloadConfig, + WorkloadConfigCreateInput, +} from "../../generated/prisma/models.ts"; import { decryptEnv, encryptEnv, generateKey } from "../crypto.ts"; import type { PrismaClientType } from "../index.ts"; import type { Deployment, DeploymentConfig, - DeploymentConfigCreate, DeploymentWithSourceInfo, + GitConfig, + HelmConfig, + HelmConfigCreate, Log, + WorkloadConfig, + WorkloadConfigCreate, } from "../models.ts"; +type PrismaWorkloadConfigCreate = Omit; +type PrismaHelmConfigCreate = Omit; export class DeploymentRepo { private client: PrismaClientType; private publish: (topic: string, payload: any) => Promise; @@ -79,15 +87,38 @@ export class DeploymentRepo { status, }: { appId: number; - config: DeploymentConfigCreate; + config: WorkloadConfigCreate | HelmConfigCreate; commitMessage: string | null; workflowRunId?: number; status?: DeploymentStatus; }): Promise { + const configClone = structuredClone(config); + const appType = configClone.appType; + if (appType === "workload") { + delete configClone.appType; + } else if (appType === "helm") { + delete configClone.appType; + delete configClone.source; + } return await this.client.deployment.create({ data: { app: { connect: { id: appId } }, - config: { create: DeploymentRepo.encryptEnv(config) }, + config: { + create: { + appType: appType, + ...(appType === "workload" + ? { + workloadConfig: { + create: DeploymentRepo.encryptEnv(configClone), + }, + } + : { + helmConfig: { + create: configClone, + }, + }), + }, + }, commitMessage, workflowRunId, secret: randomBytes(32).toString("hex"), @@ -150,24 +181,77 @@ export class DeploymentRepo { async getConfig(deploymentId: number): Promise { const deployment = await this.client.deployment.findUnique({ where: { id: deploymentId }, - select: { config: true }, + select: { + config: { + include: { + workloadConfig: { omit: { id: true, deploymentConfigId: true } }, + helmConfig: { omit: { id: true, deploymentConfigId: true } }, + }, + }, + }, }); - return DeploymentRepo.preprocessDeploymentConfig(deployment.config); + return DeploymentRepo.preprocessConfig(deployment.config); } private static encryptEnv( - config: DeploymentConfigCreate, - ): DeploymentConfigCreateInput { - const copy = structuredClone(config) as DeploymentConfigCreateInput; + config: PrismaWorkloadConfigCreate, + ): WorkloadConfigCreateInput { + const copy = structuredClone(config) as WorkloadConfigCreateInput; copy.envKey = generateKey(); copy.env = encryptEnv(copy.env, copy.envKey); return copy; } - static preprocessDeploymentConfig( - config: PrismaDeploymentConfig, - ): DeploymentConfig { + static preprocessConfig(config: { + appType: AppType; + workloadConfig?: Omit; + helmConfig?: Omit; + }): DeploymentConfig { + if (config === null) { + return null; + } + + let obj: WorkloadConfig | HelmConfig; + if (config.appType === "workload") { + obj = DeploymentRepo.preprocessWorkloadConfig(config.workloadConfig!); + } else if (config.appType === "helm") { + obj = { + ...config.helmConfig, + source: "HELM", + appType: "helm", + } satisfies HelmConfig; + } else { + return null; + } + + const wrapped = { + ...obj, + asWorkloadConfig() { + if (obj.appType === "workload") { + return obj as WorkloadConfig; + } else { + throw new Error("DeploymentConfig is not a WorkloadConfig"); + } + }, + asHelmConfig() { + if (obj.appType === "helm") { + return obj as HelmConfig; + } else { + throw new Error("DeploymentConfig is not a HelmConfig"); + } + }, + asGitConfig() { + return wrapped.asWorkloadConfig().asGitConfig(); + }, + } satisfies DeploymentConfig; + + return wrapped; + } + + private static preprocessWorkloadConfig( + config: Omit, + ): WorkloadConfig { if (config === null) { return null; } @@ -179,15 +263,35 @@ export class DeploymentRepo { const decrypted = decryptEnv(env, key); - return { + const obj = { ...config, + appType: "workload", getEnv() { return decrypted; }, displayEnv: decrypted.map((envVar) => envVar.isSensitive ? { ...envVar, value: null } : envVar, ), - }; + asGitConfig() { + if (config.source === "GIT") { + return obj as GitConfig; + } else { + throw new Error("Workload is not deployed from Git"); + } + }, + } satisfies WorkloadConfig; + + return obj; + } + + static cloneWorkloadConfig(config: WorkloadConfig): WorkloadConfigCreate { + if (config === null) { + return null; + } + const { getEnv, displayEnv, asGitConfig, ...clonable } = config; + const newConfig = structuredClone(clonable); + const env = config.getEnv(); + return { ...newConfig, env }; } async checkLogIngestSecret(deploymentId: number, logIngestSecret: string) { @@ -242,7 +346,7 @@ export class DeploymentRepo { } async unlinkRepositoryFromAllDeployments(repoId: number) { - await this.client.deploymentConfig.updateMany({ + await this.client.workloadConfig.updateMany({ where: { repositoryId: repoId }, data: { repositoryId: null, branch: null, source: "IMAGE" }, }); @@ -298,10 +402,9 @@ export class DeploymentRepo { include: { config: { select: { - source: true, - commitHash: true, - imageTag: true, - repositoryId: true, + appType: true, + workloadConfig: true, + helmConfig: true, }, }, }, @@ -313,10 +416,11 @@ export class DeploymentRepo { return deployments.map((deployment) => ({ ...deployment, config: undefined, - source: deployment.config.source, - commitHash: deployment.config.commitHash, - imageTag: deployment.config.imageTag, - repositoryId: deployment.config.repositoryId, + appType: deployment.config.appType, + source: deployment.config.workloadConfig?.source, + commitHash: deployment.config.workloadConfig?.commitHash, + imageTag: deployment.config.workloadConfig?.imageTag, + repositoryId: deployment.config.workloadConfig?.repositoryId, })); } } diff --git a/backend/src/handlers/createApp.ts b/backend/src/handlers/createApp.ts index 27127753..ffd7cc9c 100644 --- a/backend/src/handlers/createApp.ts +++ b/backend/src/handlers/createApp.ts @@ -3,7 +3,7 @@ import { OrgNotFoundError, ValidationError, } from "../service/common/errors.ts"; -import { createApp, validateAppConfig } from "../service/createApp.ts"; +import { createApp } from "../service/createApp.ts"; import { json, type HandlerMap } from "../types.ts"; import { type AuthenticatedRequest } from "./index.ts"; @@ -13,10 +13,7 @@ export const createAppHandler: HandlerMap["createApp"] = async ( res, ) => { try { - const appId = await createApp( - ctx.request.requestBody, - await validateAppConfig(req.user.id, ctx.request.requestBody), - ); + const appId = await createApp(ctx.request.requestBody, req.user.id); return json(200, res, { id: appId }); } catch (e) { if (e instanceof OrgNotFoundError) { diff --git a/backend/src/handlers/createAppGroup.ts b/backend/src/handlers/createAppGroup.ts index 7d6a3e8a..a8df7e92 100644 --- a/backend/src/handlers/createAppGroup.ts +++ b/backend/src/handlers/createAppGroup.ts @@ -17,6 +17,7 @@ export const createAppGroupHandler: HandlerMap["createAppGroup"] = async ( try { await createAppGroup(req.user.id, data.orgId, data.name, data.apps); + return json(200, res, {}); } catch (e) { if (e instanceof AppCreateError) { const ex = e.cause!; diff --git a/backend/src/handlers/files.ts b/backend/src/handlers/files.ts index 7161b33d..ed1bc53f 100644 --- a/backend/src/handlers/files.ts +++ b/backend/src/handlers/files.ts @@ -3,6 +3,7 @@ import { Readable } from "node:stream"; import { AppNotFoundError, IllegalPVCAccessError, + ValidationError, } from "../service/common/errors.ts"; import { forwardToFileBrowser } from "../service/files.ts"; import { json, type HandlerMap } from "../types.ts"; @@ -98,8 +99,11 @@ async function forward( return json(404, res, {}); } else if (e instanceof IllegalPVCAccessError) { return json(403, res, {}); + } else if (e instanceof ValidationError) { + return json(400, res, { code: 400, res: e.message }); + } else { + throw e; } - throw e; } if (response.status === 404) { diff --git a/backend/src/handlers/index.ts b/backend/src/handlers/index.ts index 2a445ccc..ea58f5f8 100644 --- a/backend/src/handlers/index.ts +++ b/backend/src/handlers/index.ts @@ -34,6 +34,7 @@ import { import { ingestLogsHandler } from "./ingestLogs.ts"; import { inviteUserHandler } from "./inviteUser.ts"; import { isSubdomainAvailableHandler } from "./isSubdomainAvailable.ts"; +import { listChartsHandler } from "./listCharts.ts"; import { listDeploymentsHandler } from "./listDeployments.ts"; import { listOrgGroupsHandler } from "./listOrgGroups.ts"; import { listOrgReposHandler } from "./listOrgRepos.ts"; @@ -84,6 +85,7 @@ export const handlers = { ingestLogs: ingestLogsHandler, inviteUser: inviteUserHandler, isSubdomainAvailable: isSubdomainAvailableHandler, + listCharts: listChartsHandler, listDeployments: listDeploymentsHandler, listOrgGroups: listOrgGroupsHandler, listOrgRepos: listOrgReposHandler, diff --git a/backend/src/handlers/listCharts.ts b/backend/src/handlers/listCharts.ts new file mode 100644 index 00000000..56835b90 --- /dev/null +++ b/backend/src/handlers/listCharts.ts @@ -0,0 +1,24 @@ +import { ValidationError } from "../service/common/errors.ts"; +import { listCharts } from "../service/listCharts.ts"; +import { json, type HandlerMap } from "../types.ts"; +export const listChartsHandler: HandlerMap["listCharts"] = async ( + ctx, + req, + res, +) => { + try { + return json(200, res, await listCharts()); + } catch (e) { + if (e instanceof ValidationError) { + return json(400, res, { + code: 400, + message: e.message, + }); + } + console.error(e); + return json(500, res, { + code: 500, + message: "Something went wrong.", + }); + } +}; diff --git a/backend/src/handlers/updateDeployment.ts b/backend/src/handlers/updateDeployment.ts index 480f3289..a7c943f3 100644 --- a/backend/src/handlers/updateDeployment.ts +++ b/backend/src/handlers/updateDeployment.ts @@ -15,6 +15,7 @@ export const updateDeploymentHandler: HandlerMap["updateDeployment"] = async ( await updateDeployment(secret, status); return json(200, res, undefined); } catch (e) { + console.error(e); if (e instanceof ValidationError) { return json(404, res, { code: 400, message: e.message }); } else if (e instanceof DeploymentNotFoundError) { diff --git a/backend/src/lib/builder.ts b/backend/src/lib/builder.ts index d0d77127..a8915688 100644 --- a/backend/src/lib/builder.ts +++ b/backend/src/lib/builder.ts @@ -5,12 +5,7 @@ import { } from "@kubernetes/client-node"; import { createHash, randomBytes } from "node:crypto"; import { db } from "../db/index.ts"; -import type { - App, - Deployment, - DeploymentConfig, - Organization, -} from "../db/models.ts"; +import type { App, Deployment, GitConfig, Organization } from "../db/models.ts"; import { svcK8s } from "./cluster/kubernetes.ts"; import { wrapWithLogExporter } from "./cluster/resources/logs.ts"; import { generateAutomaticEnvVars } from "./cluster/resources/statefulset.ts"; @@ -29,7 +24,7 @@ async function createJobFromDeployment( org: Organization, app: App, deployment: Deployment, - config: DeploymentConfig, + config: GitConfig, ) { const octokit = await getOctokit(org.githubInstallationId); const repo = await getRepoById(octokit, config.repositoryId); @@ -302,7 +297,7 @@ export async function createBuildJob( ...params: Parameters ) { const deployment = params[2] satisfies Deployment; - const config = params[3] satisfies DeploymentConfig; + const config = params[3] satisfies GitConfig; if (!["dockerfile", "railpack"].includes(config.builder)) { throw new Error( @@ -343,7 +338,10 @@ async function countActiveBuildJobs() { return jobs.items.filter((job) => job.status?.active).length; } -/** @returns The UID of the created build job, or null if the queue is full */ +/** + * @returns The UID of the created build job, or null if the queue is full + * @throws {Error} if the config is not a GitConfig + */ export async function dequeueBuildJob(): Promise { if ((await countActiveBuildJobs()) >= MAX_JOBS) { return null; @@ -359,7 +357,7 @@ export async function dequeueBuildJob(): Promise { const app = await db.app.getById(deployment.appId); const org = await db.org.getById(app.orgId); - const config = await db.deployment.getConfig(deployment.id); + const config = (await db.deployment.getConfig(deployment.id)).asGitConfig(); console.log( `Starting build job for deployment ${deployment.id} of app ${deployment.appId}`, diff --git a/backend/src/lib/cluster/kubernetes.ts b/backend/src/lib/cluster/kubernetes.ts index 7cf5c1d7..6fefbcda 100644 --- a/backend/src/lib/cluster/kubernetes.ts +++ b/backend/src/lib/cluster/kubernetes.ts @@ -12,10 +12,10 @@ import { Watch, type V1Namespace, } from "@kubernetes/client-node"; +import { db } from "../../db/index.ts"; import { env } from "../env.ts"; import { shouldImpersonate } from "./rancher.ts"; import type { K8sObject } from "./resources.ts"; -import { db } from "../../db/index.ts"; const kc = new KubeConfig(); kc.loadFromDefault(); @@ -103,7 +103,10 @@ export const namespaceInUse = async (namespace: string) => { }); }; -const resourceExists = async (api: KubernetesObjectApi, data: K8sObject) => { +export const resourceExists = async ( + api: KubernetesObjectApi, + data: K8sObject, +) => { try { await api.read(data); return true; @@ -121,7 +124,7 @@ const resourceExists = async (api: KubernetesObjectApi, data: K8sObject) => { const REQUIRED_LABELS = env["RANCHER_API_BASE"] ? ["field.cattle.io/projectId", "lifecycle.cattle.io/create.namespace-auth"] : []; -const ensureNamespace = async ( +export const ensureNamespace = async ( api: KubernetesObjectApi, namespace: V1Namespace & K8sObject, ) => { @@ -149,8 +152,18 @@ export const deleteNamespace = async ( api: KubernetesObjectApi, name: string, ) => { - await api.delete({ apiVersion: "v1", kind: "Namespace", metadata: { name } }); - console.log(`Namespace ${name} deleted`); + try { + await api.delete({ + apiVersion: "v1", + kind: "Namespace", + metadata: { name }, + }); + } catch (err) { + if (err instanceof ApiException && (err.code === 404 || err.code === 403)) { + return; + } + throw err; + } }; export const createOrUpdateApp = async ( diff --git a/backend/src/lib/cluster/rancher.ts b/backend/src/lib/cluster/rancher.ts index 9f16437d..5a09f6c9 100644 --- a/backend/src/lib/cluster/rancher.ts +++ b/backend/src/lib/cluster/rancher.ts @@ -20,12 +20,16 @@ const fetchRancherResource = async (endpoint: string) => { return fetch(`${API_BASE_URL}/${endpoint}`, { headers }) .then((res) => res.text()) .then((res) => JSON.parse(res)) - .then((res) => (res.type === "error" ? new Error(res.message) : res)); + .then((res) => { + if (res.type === "error") { + throw res; + } + return res; + }); }; const getProjectById = async (id: string) => { const project = await fetchRancherResource(`projects/${id}`); - return { id: project.id, name: project.name, @@ -37,7 +41,9 @@ const fetchUserProjects = async (rancherId: string) => { const bindings = await fetchRancherResource( `projectRoleTemplateBindings?userId=${rancherId}`, ).then((res) => res.data); - const projectIds = bindings.map((binding: any) => binding.projectId); + const projectIds = bindings + ? bindings.map((binding: any) => binding.projectId) + : []; projectIds.push(SANDBOX_ID); const uniqueProjectIds = [...new Set(projectIds)] as string[]; diff --git a/backend/src/lib/cluster/resources.ts b/backend/src/lib/cluster/resources.ts index c50f2851..4d061300 100644 --- a/backend/src/lib/cluster/resources.ts +++ b/backend/src/lib/cluster/resources.ts @@ -5,12 +5,13 @@ import type { V1Namespace, V1Secret, } from "@kubernetes/client-node"; +import { randomBytes } from "node:crypto"; import type { App, AppGroup, Deployment, - DeploymentConfig, Organization, + WorkloadConfig, } from "../../db/models.ts"; import { getOctokit } from "../octokit.ts"; import { createIngressConfig } from "./resources/ingress.ts"; @@ -22,8 +23,11 @@ import { const NAMESPACE_PREFIX = "anvilops-"; +// Subdomain must pass RFC 1123 +export const MAX_SUBDOMAIN_LEN = 63; + // Namespace must pass RFC 1123 (and service must pass RFC 1035) -export const MAX_SUBDOMAIN_LEN = 63 - NAMESPACE_PREFIX.length; +export const MAX_NAMESPACE_LEN = 63 - NAMESPACE_PREFIX.length; // app.kubernetes.io/part-of label must pass RFC 1123 // `-{groupId}-{organizationId}` is appended to group name to create the label value @@ -33,6 +37,9 @@ export const MAX_GROUPNAME_LEN = 50; // The names of its pods, which are `{statefulset name}-{pod #}` also must pass RFC 1123 export const MAX_STS_NAME_LEN = 60; +export const getRandomTag = (): string => randomBytes(4).toString("hex"); +export const RANDOM_TAG_LEN = 8; + export const getNamespace = (subdomain: string) => NAMESPACE_PREFIX + subdomain; export interface K8sObject { @@ -144,7 +151,7 @@ export const createAppConfigsFromDeployment = async ( app: App, appGroup: AppGroup, deployment: Deployment, - conf: DeploymentConfig, + conf: WorkloadConfig, ) => { const namespaceName = getNamespace(app.namespace); diff --git a/backend/src/lib/cluster/resources/statefulset.ts b/backend/src/lib/cluster/resources/statefulset.ts index 7da6fbcf..d2bfe264 100644 --- a/backend/src/lib/cluster/resources/statefulset.ts +++ b/backend/src/lib/cluster/resources/statefulset.ts @@ -1,7 +1,7 @@ import type { V1EnvVar, V1StatefulSet } from "@kubernetes/client-node"; import crypto from "node:crypto"; import type { Octokit } from "octokit"; -import type { App, Deployment, DeploymentConfig } from "../../../db/models.ts"; +import type { App, Deployment, WorkloadConfig } from "../../../db/models.ts"; import { env } from "../../env.ts"; import { getRepoById } from "../../octokit.ts"; import type { K8sObject } from "../resources.ts"; @@ -28,7 +28,7 @@ interface DeploymentParams { export const generateAutomaticEnvVars = async ( octokit: Octokit | null, deployment: Deployment, - config: DeploymentConfig, + config: WorkloadConfig, app: App, ): Promise<{ name: string; value: string }[]> => { const appDomain = URL.parse(env.APP_DOMAIN); diff --git a/backend/src/lib/env.ts b/backend/src/lib/env.ts index 24ecab0e..15e20d7c 100644 --- a/backend/src/lib/env.ts +++ b/backend/src/lib/env.ts @@ -149,6 +149,14 @@ const variables = { * The Kubernetes namespace that all AnvilOps jobs should run in, e.g. anvilops-dev */ CURRENT_NAMESPACE: { required: true }, + /** + * The name of the project in which custom AnvilOps charts are stored. + */ + CHART_PROJECT_NAME: { required: false, defaultValue: "anvilops-chart" }, + /** + * Whether to allow Helm deployments + */ + ALLOW_HELM_DEPLOYMENTS: { required: false }, /** * The hostname for the image registry, e.g. registry.anvil.rcac.purdue.edu */ @@ -180,6 +188,14 @@ const variables = { defaultValue: "registry.anvil.rcac.purdue.edu/anvilops/railpack-builder:latest", }, + /** + * The image for a job that creates or updates a Helm deployment + */ + HELM_DEPLOYER_IMAGE: { + required: false, + defaultValue: + "registry.anvil.rcac.purdue.edu/anvilops/helm-deployer:latest", + }, /** * The image that copies the log shipper binary to a destination path, used in an initContainer to start collecting logs from users' apps (see backend/src/lib/cluster/resources/logs.ts for more details) */ @@ -224,12 +240,13 @@ const variables = { export const env = {} as Record; +const notFound: string[] = []; for (const [key, _params] of Object.entries(variables)) { const params = _params as EnvVarDefinition; const value = process.env[key]; if (value === undefined) { if (params.required === true) { - throw new Error("Environment variable " + key + " not found."); + notFound.push(key); } else if (params.defaultValue !== undefined) { env[key] = params.defaultValue; } @@ -238,6 +255,12 @@ for (const [key, _params] of Object.entries(variables)) { } } +if (notFound.length > 0) { + throw new Error( + "Environment variable(s) " + notFound.join(", ") + " not found.", + ); +} + // Either DATABASE_URL or the separate variables must be specified if ( !env["DATABASE_URL"] && diff --git a/backend/src/lib/helm.ts b/backend/src/lib/helm.ts new file mode 100644 index 00000000..5a1568aa --- /dev/null +++ b/backend/src/lib/helm.ts @@ -0,0 +1,267 @@ +import { V1Pod } from "@kubernetes/client-node"; +import { randomBytes } from "node:crypto"; +import type { App, Deployment, HelmConfig } from "../db/models.ts"; +import { + ensureNamespace, + getClientForClusterUsername, + resourceExists, + svcK8s, +} from "./cluster/kubernetes.ts"; +import { shouldImpersonate } from "./cluster/rancher.ts"; +import { createNamespaceConfig, getNamespace } from "./cluster/resources.ts"; +import { wrapWithLogExporter } from "./cluster/resources/logs.ts"; +import { env } from "./env.ts"; + +type Chart = { + name: string; + version: string; + description?: string; + note?: string; + values: Record; +}; + +type ChartTagList = { + name: string; + tags: string[]; +}; + +export const getChartToken = async () => { + return fetch( + `${env.REGISTRY_PROTOCOL}://${env.REGISTRY_HOSTNAME}/v2/service/token?service=harbor-registry&scope=repository:${env.CHART_PROJECT_NAME}/charts:pull`, + ) + .then((res) => { + if (!res.ok) { + console.error(res); + throw new Error(res.statusText); + } + return res; + }) + .then((res) => res.text()) + .then((res) => JSON.parse(res)) + .then((res) => { + return res.token; + }); +}; + +const getChart = async ( + repository: string, + version: string, + token: string, +): Promise => { + return fetch( + `${env.REGISTRY_PROTOCOL}://${env.REGISTRY_HOSTNAME}/v2/${repository}/manifests/${version}`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.oci.image.manifest.v1+json", + }, + }, + ) + .then((res) => { + if (!res.ok) { + throw new Error(res.statusText); + } + return res; + }) + .then((res) => res.text()) + .then((res) => JSON.parse(res)) + .then((res) => { + const annotations = res.annotations; + if ("anvilops-values" in annotations) { + return { + name: annotations["org.opencontainers.image.title"], + version: annotations["org.opencontainers.image.version"], + description: annotations["org.opencontainers.image.description"], + note: annotations["anvilops-note"], + values: JSON.parse(annotations["anvilops-values"]), + }; + } else { + return null; + } + }); +}; + +export const getLatestChart = async ( + repository: string, + token: string, +): Promise => { + const chartTagList = await fetch( + `${env.REGISTRY_PROTOCOL}://${env.REGISTRY_HOSTNAME}/v2/${repository}/tags/list`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ) + .then((res) => { + if (!res.ok) { + throw new Error(res.statusText); + } + return res; + }) + .then((res) => res.json() as Promise); + + return await getChart( + chartTagList.name, + chartTagList.tags[chartTagList.tags.length - 1], + token, + ); +}; + +export const upgrade = async ( + app: App, + deployment: Deployment, + config: HelmConfig, +) => { + const namespaceName = getNamespace(app.namespace); + + // Create namespace through Kubernetes API to ensure required Rancher annotations + const api = getClientForClusterUsername( + app.clusterUsername, + "KubernetesObjectApi", + shouldImpersonate(app.projectId), + ); + const namespace = createNamespaceConfig(namespaceName, app.projectId); + if (!(await resourceExists(api, namespace))) { + try { + await ensureNamespace(api, namespace); + } catch (err) { + throw new Error( + `Failed to create namespace ${namespaceName}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + const args = ["upgrade", "--install", "--namespace", namespaceName]; + + const { urlType, url, version, values } = config; + const release = app.name; + + for (const [key, value] of Object.entries(values)) { + args.push("--set-json", `${key}=${JSON.stringify(value)}`); + } + switch (urlType) { + // example: helm install mynginx https://example.com/charts/nginx-1.2.3.tgz + case "absolute": { + args.push(release, url); + break; + } + + // example: helm install mynginx --version 1.2.3 oci://example.com/charts/nginx + case "oci": { + args.push(release, "--version", version, url); + break; + } + } + + const podTemplate: V1Pod = { + metadata: { + labels: { + "anvilops.rcac.purdue.edu/app-id": app.id.toString(), + "anvilops.rcac.purdue.edu/deployment-id": deployment.id.toString(), + }, + }, + spec: { + automountServiceAccountToken: false, + containers: [ + { + env: [ + { name: "DEPLOYMENT_API_SECRET", value: deployment.secret }, + { + name: "DEPLOYMENT_API_URL", + value: `${env.CLUSTER_INTERNAL_BASE_URL}/api`, + }, + { + name: "KUBECONFIG", + value: "/opt/creds/kubeconfig", + }, + { + name: "HELM_KUBEASUSER", + value: shouldImpersonate(app.projectId) + ? app.clusterUsername + : "", + }, + { + name: "HELM_ARGS", + value: `${args.join(" ")}`, + }, + ], + name: "helm", + image: env.HELM_DEPLOYER_IMAGE, + volumeMounts: [ + { + name: "kubeconfig", + mountPath: "/opt/creds", + readOnly: true, + }, + ], + resources: { + limits: { + cpu: "500m", + memory: "500Mi", + }, + requests: { + cpu: "250m", + memory: "128Mi", + }, + }, + securityContext: { + capabilities: { + drop: ["ALL"], + }, + runAsNonRoot: true, + runAsUser: 65532, + runAsGroup: 65532, + allowPrivilegeEscalation: false, + }, + }, + ], + volumes: [ + { + name: "kubeconfig", + secret: { + secretName: "kube-auth", + items: [ + { + key: "kubeconfig", + path: "kubeconfig", + }, + ], + }, + }, + ], + restartPolicy: "Never", + }, + }; + + const label = randomBytes(4).toString("hex"); + const jobName = `helm-upgrade-${release}-${label}`; + try { + await svcK8s["BatchV1Api"].createNamespacedJob({ + namespace: env.CURRENT_NAMESPACE, + body: { + metadata: { + name: jobName, + labels: { + "anvilops.rcac.purdue.edu/app-id": app.id.toString(), + "anvilops.rcac.purdue.edu/deployment-id": deployment.id.toString(), + }, + }, + spec: { + ttlSecondsAfterFinished: 5 * 60, + backoffLimit: 1, + activeDeadlineSeconds: 5 * 60, + template: await wrapWithLogExporter( + "build", + app.logIngestSecret, + deployment.id, + podTemplate, + ), + }, + }, + }); + } catch (e) { + console.error(e); + throw e; + } +}; diff --git a/backend/src/lib/registry.ts b/backend/src/lib/registry.ts index 53161a53..7b5f76d7 100644 --- a/backend/src/lib/registry.ts +++ b/backend/src/lib/registry.ts @@ -11,7 +11,7 @@ export async function deleteRepo(name: string) { } await fetch( - `${host}/api/v2.0/projects/${env.HARBOR_PROJECT_NAME}/repositories/${name}`, + `${env.REGISTRY_PROTOCOL}://${env.REGISTRY_HOSTNAME}/api/v2.0/projects/${env.HARBOR_PROJECT_NAME}/repositories/${name}`, { method: "DELETE", headers, @@ -23,3 +23,31 @@ export async function deleteRepo(name: string) { } }); } + +type HarborRepository = { + artifact_count: number; + creation_time: string; + id: number; + name: string; + project_id: number; + pull_count: number; + update_time: string; +}; + +export async function getRepositoriesByProject(projectName: string) { + return fetch( + `${env.REGISTRY_PROTOCOL}://${env.REGISTRY_HOSTNAME}/api/v2.0/projects/${projectName}/repositories`, + ) + .then((res) => { + if (!res.ok) { + console.error(res); + throw new Error(res.statusText); + } + return res; + }) + .then((res) => res.text()) + .then((res) => JSON.parse(res)) + .then((res) => { + return res as HarborRepository[]; + }); +} diff --git a/backend/src/lib/validate.ts b/backend/src/lib/validate.ts index cb703559..797c4091 100644 --- a/backend/src/lib/validate.ts +++ b/backend/src/lib/validate.ts @@ -1,164 +1,3 @@ -import type { components } from "../generated/openapi.ts"; -import { namespaceInUse } from "./cluster/kubernetes.ts"; -import { - getNamespace, - MAX_GROUPNAME_LEN, - MAX_STS_NAME_LEN, - MAX_SUBDOMAIN_LEN, -} from "./cluster/resources.ts"; -import { getImageConfig } from "./cluster/resources/logs.ts"; - -export async function validateDeploymentConfig( - data: ( - | components["schemas"]["GitDeploymentOptions"] - | components["schemas"]["ImageDeploymentOptions"] - ) & - Omit< - components["schemas"]["KnownDeploymentOptions"], - "replicas" | "postStart" | "preStop" | "requests" | "limits" - >, -) { - const { source, env, mounts, port } = data; - if (source === "git") { - const { builder, dockerfilePath, rootDir, event, eventId } = data; - if (rootDir.startsWith("/") || rootDir.includes(`"`)) { - throw new Error("Invalid root directory"); - } - if (builder === "dockerfile") { - if (!dockerfilePath) { - throw new Error("Dockerfile path is required"); - } - if (dockerfilePath.startsWith("/") || dockerfilePath.includes(`"`)) { - throw new Error("Invalid Dockerfile path"); - } - } - - if (event === "workflow_run" && eventId === undefined) { - throw new Error("Workflow ID is required"); - } - } else if (source === "image") { - if (!data.imageTag) { - throw new Error("Image tag is required"); - } - } else { - throw new Error( - "Invalid deployment source type: expected `git` or `image`.", - ); - } - - if (port < 0 || port > 65535) { - throw new Error("Invalid port number: must be between 0 and 65535"); - } - - validateEnv(env); - - validateMounts(mounts); - - if (data.source === "image" && data.collectLogs) { - await validateImageReference(data.imageTag); - } - - if (data.subdomain) { - await validateSubdomain(data.subdomain); - } -} - -export const validateAppGroup = ( - appGroup: components["schemas"]["NewApp"]["appGroup"], -) => { - if (appGroup.type === "create-new") { - if ( - appGroup.name.length > MAX_GROUPNAME_LEN || - appGroup.name.match(/^[a-zA-Z0-9][ a-zA-Z0-9-_\.]*$/) === null - ) { - return { - valid: false, - message: "Invalid group name", - }; - } - } - return { valid: true }; -}; - -const validateMounts = ( - mounts: components["schemas"]["KnownDeploymentOptions"]["mounts"], -) => { - const pathSet = new Set(); - for (const mount of mounts) { - if (!mount.path.startsWith("/")) { - throw new Error(`Invalid mount path ${mount.path}: must start with '/'`); - } - - if (pathSet.has(mount.path)) { - throw new Error(`Invalid mounts: paths are not unique`); - } - pathSet.add(mount.path); - } -}; - -export const validateEnv = (env: PrismaJson.EnvVar[]) => { - if (env?.some((it) => !it.name || it.name.length === 0)) { - return { valid: false, message: "Some environment variable(s) are empty" }; - } - - if (env?.some((it) => it.name.startsWith("_PRIVATE_ANVILOPS_"))) { - // Environment variables with this prefix are used in the log shipper - see log-shipper/main.go - return { - valid: false, - message: - 'Environment variable(s) use reserved prefix "_PRIVATE_ANVILOPS_"', - }; - } - - const envNames = new Set(); - - for (let envVar of env) { - if (envNames.has(envVar.name)) { - return { - valid: false, - message: "Duplicate environment variable " + envVar.name, - }; - } - envNames.add(envVar.name); - } -}; - -export const validateSubdomain = async (subdomain: string) => { - if (subdomain.length > MAX_SUBDOMAIN_LEN || !isRFC1123(subdomain)) { - throw new Error( - "Subdomain must contain only lowercase alphanumeric characters or '-', " + - "start and end with an alphanumeric character, " + - `and contain at most ${MAX_SUBDOMAIN_LEN} characters`, - ); - } - - if (await namespaceInUse(getNamespace(subdomain))) { - throw new Error("Subdomain is unavailable"); - } - - return { valid: true }; -}; - -export const validateImageReference = async (reference: string) => { - try { - // Look up the image in its registry to make sure it exists - await getImageConfig(reference); - } catch (e) { - console.error(e); - throw new Error("Image could not be found in its registry."); - } -}; - -export const validateAppName = (name: string) => { - if (name.length > MAX_STS_NAME_LEN || !isRFC1123(name)) { - throw new Error( - "App name must contain only lowercase alphanumeric characters or '-', " + - "start and end with an alphanumeric character, " + - `and contain at most ${MAX_STS_NAME_LEN} characters`, - ); - } -}; - export const isRFC1123 = (value: string) => value.length <= 63 && value.match(/[a-zA-Z0-9]([-a-z0-9]*[a-z0-9])?$/) !== null; diff --git a/backend/src/service/createApp.ts b/backend/src/service/createApp.ts index c85fb7ce..a4c76e44 100644 --- a/backend/src/service/createApp.ts +++ b/backend/src/service/createApp.ts @@ -1,176 +1,70 @@ -import { randomBytes } from "node:crypto"; -import { type Octokit } from "octokit"; import { ConflictError, db } from "../db/index.ts"; -import type { App, DeploymentConfigCreate } from "../db/models.ts"; +import type { App } from "../db/models.ts"; import type { components } from "../generated/openapi.ts"; -import { namespaceInUse } from "../lib/cluster/kubernetes.ts"; -import { canManageProject, isRancherManaged } from "../lib/cluster/rancher.ts"; -import { getNamespace } from "../lib/cluster/resources.ts"; -import { getOctokit, getRepoById } from "../lib/octokit.ts"; import { - validateAppGroup, - validateAppName, - validateDeploymentConfig, - validateSubdomain, -} from "../lib/validate.ts"; + MAX_GROUPNAME_LEN, + RANDOM_TAG_LEN, + getRandomTag, +} from "../lib/cluster/resources.ts"; +import { OrgNotFoundError, ValidationError } from "./common/errors.ts"; import { - DeploymentError, - OrgNotFoundError, - ValidationError, -} from "./common/errors.ts"; -import { buildAndDeploy } from "./githubWebhook.ts"; + appService, + deploymentConfigService, + deploymentService, +} from "./helper/index.ts"; export type NewApp = components["schemas"]["NewApp"]; -export async function validateAppConfig(ownerUserId: number, appData: NewApp) { - const organization = await db.org.getById(appData.orgId, { - requireUser: { id: ownerUserId }, - }); +export async function createApp(appData: NewApp, userId: number) { + const [organization, user] = await Promise.all([ + db.org.getById(appData.orgId, { requireUser: { id: userId } }), + db.user.getById(userId), + ]); if (!organization) { throw new OrgNotFoundError(null); } - try { - await validateDeploymentConfig({ ...appData, collectLogs: true }); - validateAppGroup(appData.appGroup); - const subdomainRes = validateSubdomain(appData.subdomain); - validateAppName(appData.name); - await subdomainRes; - } catch (e) { - throw new ValidationError(e.message, e); - } - - let clusterUsername: string; - if (isRancherManaged()) { - if (!appData.projectId) { - throw new ValidationError("Project ID is required"); - } - - let { clusterUsername: username } = await db.user.getById(ownerUserId); - if (!(await canManageProject(username, appData.projectId))) { - throw new ValidationError("Project not found"); - } - - clusterUsername = username; - } - - let commitSha = "unknown", - commitMessage = "Initial deployment"; - - if (appData.source === "git") { - if (!organization.githubInstallationId) { - throw new ValidationError( - "The AnvilOps GitHub App is not installed in this organization.", - ); - } - - let octokit: Octokit, repo: Awaited>; + let app: App; - try { - octokit = await getOctokit(organization.githubInstallationId); - repo = await getRepoById(octokit, appData.repositoryId); - } catch (err) { - if (err.status === 404) { - throw new ValidationError("Invalid repository ID"); - } + let { config, commitMessage } = ( + await appService.prepareMetadataForApps(organization, user, { + type: "create", + ...appData, + }) + )[0]; - throw new Error("Failed to look up GitHub repository", err); - } + let appGroupId: number; - if (appData.event === "workflow_run" && appData.eventId) { - try { - const workflows = await ( - octokit.request({ - method: "GET", - url: `/repositories/${repo.id}/actions/workflows`, - }) as ReturnType - ).then((res) => res.data.workflows); - if (!workflows.some((workflow) => workflow.id === appData.eventId)) { - throw new ValidationError("Workflow not found"); - } - } catch (err) { - throw new Error("Failed to look up GitHub workflows", err); + switch (appData.appGroup.type) { + case "add-to": { + const group = await db.appGroup.getById(appData.appGroup.id); + if (!group) { + throw new ValidationError("Invalid app group"); } + appGroupId = appData.appGroup.id; + break; } - const latestCommit = ( - await octokit.rest.repos.listCommits({ - per_page: 1, - owner: repo.owner.login, - repo: repo.name, - }) - ).data[0]; - - commitSha = latestCommit.sha; - commitMessage = latestCommit.commit.message; - } - - return { clusterUsername, organization, commitSha, commitMessage }; -} - -export async function createApp( - appData: NewApp, - validationResult: Awaited>, -) { - const { clusterUsername, organization, commitSha, commitMessage } = - validationResult; - - let app: App; - - const cpu = Math.round(appData.cpuCores * 1000) + "m", - memory = appData.memoryInMiB + "Mi"; - const deploymentConfig: DeploymentConfigCreate = { - collectLogs: true, - createIngress: appData.createIngress, - subdomain: appData.subdomain, - env: appData.env, - requests: { cpu, memory }, - limits: { cpu, memory }, - replicas: 1, - port: appData.port, - mounts: appData.mounts, - ...(appData.source === "git" - ? { - source: "GIT", - repositoryId: appData.repositoryId, - event: appData.event, - eventId: appData.eventId, - branch: appData.branch, - commitHash: commitSha, - builder: appData.builder, - dockerfilePath: appData.dockerfilePath, - rootDir: appData.rootDir, - } - : { - source: "IMAGE", - imageTag: appData.imageTag, - }), - }; - let appGroupId: number; - switch (appData.appGroup.type) { - case "standalone": - appGroupId = await db.appGroup.create( - appData.orgId, - `${appData.name}-${randomBytes(4).toString("hex")}`, - true, - ); - break; - case "create-new": + case "create-new": { + appService.validateAppGroupName(appData.appGroup.name); appGroupId = await db.appGroup.create( appData.orgId, appData.appGroup.name, false, ); + } + + case "standalone": { + let groupName = `${appData.name.substring(0, MAX_GROUPNAME_LEN - RANDOM_TAG_LEN - 1)}-${getRandomTag()}`; + appService.validateAppGroupName(groupName); + appGroupId = await db.appGroup.create(appData.orgId, groupName, true); break; - default: - appGroupId = appData.appGroup.id; - break; - } + } - let namespace = appData.subdomain; - if (await namespaceInUse(getNamespace(namespace))) { - namespace += "-" + Math.floor(Math.random() * 10_000); + default: { + appData.appGroup satisfies never; // Make sure switch is exhaustive + } } try { @@ -178,30 +72,25 @@ export async function createApp( orgId: appData.orgId, appGroupId: appGroupId, name: appData.name, - clusterUsername: clusterUsername, + clusterUsername: user.clusterUsername, projectId: appData.projectId, - namespace: namespace, + namespace: appData.namespace, }); - } catch (err) { - if (err instanceof ConflictError) { - throw new ValidationError( - "App group name conflicts with an existing app group.", - ); - } - } - try { - await buildAndDeploy({ - org: organization, - app, - imageRepo: app.imageRepo, - commitMessage: commitMessage, - config: deploymentConfig, - createCheckRun: false, - }); + config = deploymentConfigService.populateImageTag(config, app); } catch (err) { - throw new DeploymentError(err); + // In between validation and creating the app, the namespace was taken by another app + if (err instanceof ConflictError && err.message === "namespace") { + throw new ValidationError("Namespace is unavailable"); + } + throw err; } + await deploymentService.create({ + org: organization, + app, + commitMessage, + config, + }); return app.id; } diff --git a/backend/src/service/createAppGroup.ts b/backend/src/service/createAppGroup.ts index 60d8fa42..3ac0b368 100644 --- a/backend/src/service/createAppGroup.ts +++ b/backend/src/service/createAppGroup.ts @@ -1,12 +1,13 @@ import { ConflictError, db } from "../db/index.ts"; +import type { App } from "../db/models.ts"; import type { components } from "../generated/openapi.ts"; -import { validateAppGroup } from "../lib/validate.ts"; -import { AppCreateError, ValidationError } from "../service/common/errors.ts"; +import { OrgNotFoundError, ValidationError } from "../service/common/errors.ts"; +import { type NewApp } from "../service/createApp.ts"; import { - createApp, - validateAppConfig, - type NewApp, -} from "../service/createApp.ts"; + appService, + deploymentConfigService, + deploymentService, +} from "./helper/index.ts"; export type NewAppWithoutGroup = components["schemas"]["NewAppWithoutGroupInfo"]; @@ -17,49 +18,79 @@ export async function createAppGroup( groupName: string, appData: NewAppWithoutGroup[], ) { - const validationResult = validateAppGroup({ - type: "create-new", - name: groupName, - }); - if (!validationResult.valid) { - throw new ValidationError(validationResult.message); - } - - let groupId: number; - try { - groupId = await db.appGroup.create(orgId, groupName, false); - } catch (e) { - if (e instanceof ConflictError) { - throw new ValidationError( - "An app group already exists with the same name.", - ); - } - throw e; - } - - const appsWithGroups = appData.map( + appService.validateAppGroupName(groupName); + const apps = appData.map( (app) => ({ ...app, - appGroup: { type: "add-to", id: groupId }, - }) satisfies NewApp, + orgId: orgId, + }) satisfies Omit, ); - const validationResults = await Promise.all( - appsWithGroups.map(async (app) => { - try { - return await validateAppConfig(userId, app); - } catch (e) { - throw new AppCreateError(app.name, e); - } - }), + const [organization, user] = await Promise.all([ + db.org.getById(orgId, { requireUser: { id: userId } }), + db.user.getById(userId), + ]); + + if (!organization) { + throw new OrgNotFoundError(null); + } + + // validate all apps before creating any + const validationResults = await appService.prepareMetadataForApps( + organization, + user, + ...appData.map((app) => ({ + type: "create" as const, + ...app, + })), ); - for (let i = 0; i < appsWithGroups.length; i++) { + const appsWithMetadata = apps.map((app, idx) => ({ + appData: app, + metadata: validationResults[idx], + })); + + const groupId = await db.appGroup.create(orgId, groupName, false); + // let groupId: number; + // try { + // groupId = await db.appGroup.create(orgId, groupName, false); + // } catch (e) { + // if (e instanceof ConflictError) { + // throw new ValidationError( + // "An app group already exists with the same name.", + // ); + // } + // throw e; + // } + + for (const { appData, metadata } of appsWithMetadata) { + let { config, commitMessage } = metadata; + let app: App; try { - await createApp(appsWithGroups[i], validationResults[i]); - } catch (e) { - throw new AppCreateError(appsWithGroups[i].name, e); + app = await db.app.create({ + orgId: appData.orgId, + appGroupId: groupId, + name: appData.name, + clusterUsername: user.clusterUsername, + projectId: appData.projectId, + namespace: appData.namespace, + }); + config = deploymentConfigService.populateImageTag(config, app); + } catch (err) { + // In between validation and creating the app, the namespace was taken by another app + if (err instanceof ConflictError && err.message === "namespace") { + throw new ValidationError("Namespace is unavailable"); + } + + throw err; } + + await deploymentService.create({ + org: organization, + app, + commitMessage, + config, + }); } } diff --git a/backend/src/service/deleteApp.ts b/backend/src/service/deleteApp.ts index 6ba5a271..ec60765b 100644 --- a/backend/src/service/deleteApp.ts +++ b/backend/src/service/deleteApp.ts @@ -31,17 +31,13 @@ export async function deleteApp( const config = await db.deployment.getConfig(lastDeployment.id); if (!keepNamespace) { - try { - const { KubernetesObjectApi: api } = await getClientsForRequest( - userId, - projectId, - ["KubernetesObjectApi"], - ); - await deleteNamespace(api, getNamespace(namespace)); - } catch (err) { - console.error("Failed to delete namespace:", err); - } - } else if (config.collectLogs) { + const { KubernetesObjectApi: api } = await getClientsForRequest( + userId, + projectId, + ["KubernetesObjectApi"], + ); + await deleteNamespace(api, getNamespace(namespace)); + } else if (config.appType === "workload" && config.collectLogs) { // If the log shipper was enabled, redeploy without it config.collectLogs = false; // <-- Disable log shipping diff --git a/backend/src/service/files.ts b/backend/src/service/files.ts index d7bdb3c4..6f332011 100644 --- a/backend/src/service/files.ts +++ b/backend/src/service/files.ts @@ -2,7 +2,11 @@ import { db } from "../db/index.ts"; import { getNamespace } from "../lib/cluster/resources.ts"; import { generateVolumeName } from "../lib/cluster/resources/statefulset.ts"; import { forwardRequest } from "../lib/fileBrowser.ts"; -import { AppNotFoundError, IllegalPVCAccessError } from "./common/errors.ts"; +import { + AppNotFoundError, + IllegalPVCAccessError, + ValidationError, +} from "./common/errors.ts"; export async function forwardToFileBrowser( userId: number, @@ -19,6 +23,12 @@ export async function forwardToFileBrowser( const config = await db.app.getDeploymentConfig(appId); + if (config.appType !== "workload") { + throw new ValidationError( + "File browsing is supported only for Git and image deployments", + ); + } + if ( !config.mounts.some((mount) => volumeClaimName.startsWith(generateVolumeName(mount.path) + "-"), diff --git a/backend/src/service/getAppByID.ts b/backend/src/service/getAppByID.ts index 304a3fdf..c08cb819 100644 --- a/backend/src/service/getAppByID.ts +++ b/backend/src/service/getAppByID.ts @@ -1,9 +1,9 @@ import { db } from "../db/index.ts"; import { getClientsForRequest } from "../lib/cluster/kubernetes.ts"; import { getNamespace } from "../lib/cluster/resources.ts"; -import { generateVolumeName } from "../lib/cluster/resources/statefulset.ts"; import { getOctokit, getRepoById } from "../lib/octokit.ts"; import { AppNotFoundError } from "./common/errors.ts"; +import { deploymentConfigService } from "./helper/index.ts"; export async function getAppByID(appId: number, userId: number) { const [app, recentDeployment, deploymentCount] = await Promise.all([ @@ -63,39 +63,7 @@ export async function getAppByID(appId: number, userId: number) { repositoryURL: repoURL, cdEnabled: app.enableCD, namespace: app.namespace, - config: { - createIngress: currentConfig.createIngress, - subdomain: currentConfig.createIngress - ? currentConfig.subdomain - : undefined, - collectLogs: currentConfig.collectLogs, - port: currentConfig.port, - env: currentConfig.displayEnv, - replicas: currentConfig.replicas, - requests: currentConfig.requests, - limits: currentConfig.limits, - mounts: currentConfig.mounts.map((mount) => ({ - amountInMiB: mount.amountInMiB, - path: mount.path, - volumeClaimName: generateVolumeName(mount.path), - })), - ...(currentConfig.source === "GIT" - ? { - source: "git" as const, - branch: currentConfig.branch, - dockerfilePath: currentConfig.dockerfilePath, - rootDir: currentConfig.rootDir, - builder: currentConfig.builder, - repositoryId: currentConfig.repositoryId, - event: currentConfig.event, - eventId: currentConfig.eventId, - commitHash: currentConfig.commitHash, - } - : { - source: "image" as const, - imageTag: currentConfig.imageTag, - }), - }, + config: deploymentConfigService.formatDeploymentConfig(currentConfig), appGroup: { standalone: appGroup.isMono, name: !appGroup.isMono ? appGroup.name : undefined, diff --git a/backend/src/service/getAppLogs.ts b/backend/src/service/getAppLogs.ts index 4bc049c9..82d1e4c7 100644 --- a/backend/src/service/getAppLogs.ts +++ b/backend/src/service/getAppLogs.ts @@ -5,7 +5,7 @@ import type { components } from "../generated/openapi.ts"; import type { LogType } from "../generated/prisma/enums.ts"; import { getClientsForRequest } from "../lib/cluster/kubernetes.ts"; import { getNamespace } from "../lib/cluster/resources.ts"; -import { AppNotFoundError } from "./common/errors.ts"; +import { AppNotFoundError, ValidationError } from "./common/errors.ts"; export async function getAppLogs( appId: number, @@ -32,7 +32,8 @@ export async function getAppLogs( // If the user has enabled collectLogs, we can pull them from our DB. If not, pull them from Kubernetes directly. const config = await db.app.getDeploymentConfig(app.id); - const collectLogs = config?.collectLogs; + + const collectLogs = config.appType === "workload" && config.collectLogs; if (collectLogs || type === "BUILD") { const fetchNewLogs = async () => { @@ -68,6 +69,12 @@ export async function getAppLogs( // Send all previous logs now await fetchNewLogs(); } else { + if (config.appType === "helm") { + throw new ValidationError( + "Application log browsing is not supported for Helm deployments", + ); + } + const { CoreV1Api: core, Log: log } = await getClientsForRequest( userId, app.projectId, diff --git a/backend/src/service/getDeployment.ts b/backend/src/service/getDeployment.ts index 038dc513..0f32632e 100644 --- a/backend/src/service/getDeployment.ts +++ b/backend/src/service/getDeployment.ts @@ -1,9 +1,10 @@ -import type { V1Pod } from "@kubernetes/client-node"; +import type { V1Pod, V1PodList } from "@kubernetes/client-node"; import { db } from "../db/index.ts"; import { getClientsForRequest } from "../lib/cluster/kubernetes.ts"; import { getNamespace } from "../lib/cluster/resources.ts"; import { getOctokit, getRepoById } from "../lib/octokit.ts"; import { DeploymentNotFoundError } from "./common/errors.ts"; +import { deploymentConfigService } from "./helper/index.ts"; export async function getDeployment(deploymentId: number, userId: number) { const deployment = await db.deployment.getById(deploymentId, { @@ -24,26 +25,22 @@ export async function getDeployment(deploymentId: number, userId: number) { const { CoreV1Api: api } = await getClientsForRequest(userId, app.projectId, [ "CoreV1Api", ]); - const [repositoryURL, pods] = await Promise.all([ - (async () => { - if (config.source === "GIT") { - const octokit = await getOctokit(org.githubInstallationId); - const repo = await getRepoById(octokit, config.repositoryId); - return repo.html_url; - } - return undefined; - })(), - api + let repositoryURL: string | null = null; + let pods: V1PodList | null = null; + if (config.source === "GIT") { + const octokit = await getOctokit(org.githubInstallationId); + const repo = await getRepoById(octokit, config.repositoryId); + repositoryURL = repo.html_url; + } + if (config.appType === "workload") { + pods = await api .listNamespacedPod({ namespace: getNamespace(app.namespace), labelSelector: `anvilops.rcac.purdue.edu/deployment-id=${deployment.id}`, }) - .catch( - // Namespace may not be ready yet - () => ({ apiVersion: "v1", items: [] as V1Pod[] }), - ), - ]); + .catch(() => ({ apiVersion: "v1", items: [] as V1Pod[] })); + } let scheduled = 0, ready = 0, @@ -71,47 +68,49 @@ export async function getDeployment(deploymentId: number, userId: number) { } const status = - deployment.status === "COMPLETE" && scheduled + ready + failed === 0 + deployment.status === "COMPLETE" && + config.appType === "workload" && + scheduled + ready + failed === 0 ? ("STOPPED" as const) : deployment.status; + let title: string; + switch (config.source) { + case "GIT": + title = deployment.commitMessage; + break; + case "IMAGE": + title = config.imageTag; + break; + case "HELM": + title = config.url; + break; + default: + title = "Unknown"; + break; + } + + const podStatus = + config.appType === "workload" + ? { + scheduled, + ready, + total: pods.items.length, + failed, + } + : null; + return { repositoryURL, - commitHash: config.commitHash, - commitMessage: deployment.commitMessage, + title, + commitHash: config.source === "GIT" ? config.commitHash : null, + commitMessage: config.source === "GIT" ? deployment.commitMessage : null, createdAt: deployment.createdAt.toISOString(), updatedAt: deployment.updatedAt.toISOString(), id: deployment.id, appId: deployment.appId, - status: status, - podStatus: { - scheduled, - ready, - total: pods.items.length, - failed, - }, - config: { - branch: config.branch, - imageTag: config.imageTag, - mounts: config.mounts.map((mount) => ({ - path: mount.path, - amountInMiB: mount.amountInMiB, - })), - source: config.source === "GIT" ? ("git" as const) : ("image" as const), - repositoryId: config.repositoryId, - event: config.event, - eventId: config.eventId, - commitHash: config.commitHash, - builder: config.builder, - dockerfilePath: config.dockerfilePath, - env: config.displayEnv, - port: config.port, - replicas: config.replicas, - rootDir: config.rootDir, - collectLogs: config.collectLogs, - requests: config.requests, - limits: config.limits, - createIngress: config.createIngress, - }, + status, + podStatus, + config: deploymentConfigService.formatDeploymentConfig(config), }; } diff --git a/backend/src/service/getOrgByID.ts b/backend/src/service/getOrgByID.ts index 8948414d..1f5d6309 100644 --- a/backend/src/service/getOrgByID.ts +++ b/backend/src/service/getOrgByID.ts @@ -58,16 +58,18 @@ export async function getOrgByID(orgId: number, userId: number) { displayName: app.displayName, status: selectedDeployment?.status, source: config.source, - imageTag: config.imageTag, - repositoryURL: repoURL, - branch: config.branch, - commitHash: config.commitHash, - link: - selectedDeployment?.status === "COMPLETE" && - env.APP_DOMAIN && - config.createIngress - ? `${appDomain.protocol}//${config.subdomain}.${appDomain.host}` - : undefined, + ...(config.appType === "workload" && { + imageTag: config.imageTag, + repositoryURL: repoURL, + branch: config.branch, + commitHash: config.commitHash, + link: + selectedDeployment?.status === "COMPLETE" && + env.APP_DOMAIN && + config.createIngress + ? `${appDomain.protocol}//${config.subdomain}.${appDomain.host}` + : undefined, + }), }; }), ); diff --git a/backend/src/service/getSettings.ts b/backend/src/service/getSettings.ts index e1a8a81e..2636a572 100644 --- a/backend/src/service/getSettings.ts +++ b/backend/src/service/getSettings.ts @@ -33,5 +33,6 @@ export async function getSettings() { faq: clusterConfig?.faq, storageEnabled: env.STORAGE_CLASS_NAME !== undefined, isRancherManaged: isRancherManaged(), + allowHelmDeployments: env.ALLOW_HELM_DEPLOYMENTS === "true", }; } diff --git a/backend/src/service/githubWebhook.ts b/backend/src/service/githubWebhook.ts index 6c04bcae..6d9142d3 100644 --- a/backend/src/service/githubWebhook.ts +++ b/backend/src/service/githubWebhook.ts @@ -1,38 +1,15 @@ -import type { Octokit } from "octokit"; import { db, NotFoundError } from "../db/index.ts"; -import type { - App, - Deployment, - DeploymentConfig, - DeploymentConfigCreate, - Organization, -} from "../db/models.ts"; import type { components } from "../generated/openapi.ts"; -import { - DeploymentSource, - DeploymentStatus, - type LogStream, - type LogType, -} from "../generated/prisma/enums.ts"; -import { - cancelBuildJobsForApp, - createBuildJob, - type ImageTag, -} from "../lib/builder.ts"; -import { - createOrUpdateApp, - getClientForClusterUsername, -} from "../lib/cluster/kubernetes.ts"; -import { shouldImpersonate } from "../lib/cluster/rancher.ts"; -import { createAppConfigsFromDeployment } from "../lib/cluster/resources.ts"; +import { type LogStream, type LogType } from "../generated/prisma/enums.ts"; import { env } from "../lib/env.ts"; -import { getOctokit, getRepoById } from "../lib/octokit.ts"; +import { getOctokit } from "../lib/octokit.ts"; import { AppNotFoundError, UnknownWebhookRequestTypeError, UserNotFoundError, ValidationError, } from "./common/errors.ts"; +import { deploymentConfigService, deploymentService } from "./helper/index.ts"; export async function processGitHubWebhookPayload( event: string, @@ -143,6 +120,11 @@ async function handleInstallationDeleted( await db.org.unlinkInstallationFromAllOrgs(payload.installation.id); } +/** + * + * @throws {Error} if the current config of an app is not a GitConfig + * @throws {AppNotFoundError} if no apps redeploy on push to this branch + */ async function handlePush(payload: components["schemas"]["webhook-push"]) { const repoId = payload.repository?.id; if (!repoId) { @@ -166,43 +148,32 @@ async function handlePush(payload: components["schemas"]["webhook-push"]) { for (const app of apps) { const org = await db.org.getById(app.orgId); - const config = await db.app.getDeploymentConfig(app.id); - const octokit = await getOctokit(org.githubInstallationId); - - await buildAndDeploy({ - org: org, - app: app, - imageRepo: app.imageRepo, + const oldConfig = (await db.app.getDeploymentConfig(app.id)).asGitConfig(); + const config = deploymentConfigService.populateNewCommit( + oldConfig, + app, + payload.head_commit.id, + ); + await deploymentService.create({ + org, + app, commitMessage: payload.head_commit.message, - config: { - // Reuse the config from the previous deployment - port: config.port, - replicas: config.replicas, - requests: config.requests, - limits: config.limits, - mounts: config.mounts, - createIngress: config.createIngress, - subdomain: config.subdomain, - collectLogs: config.collectLogs, - source: "GIT", - event: config.event, - env: config.getEnv(), - repositoryId: config.repositoryId, - branch: config.branch, - commitHash: payload.head_commit.id, - builder: config.builder, - rootDir: config.rootDir, - dockerfilePath: config.dockerfilePath, - imageTag: config.imageTag, + config, + git: { + checkRun: { + pending: false, + owner: payload.repository.owner.login, + repo: payload.repository.name, + }, }, - createCheckRun: true, - octokit, - owner: payload.repository.owner.login, - repo: payload.repository.name, }); } } +/** + * @throws {Error} if the current config of an app is not a GitConfig + * @throws {AppNotFoundError} if no apps are linked to this branch and workflow + */ async function handleWorkflowRun( payload: components["schemas"]["webhook-workflow-run"], ) { @@ -230,45 +201,28 @@ async function handleWorkflowRun( if (payload.action === "requested") { for (const app of apps) { const org = await db.org.getById(app.orgId); - const config = await db.app.getDeploymentConfig(app.id); - const octokit = await getOctokit(org.githubInstallationId); - try { - await createPendingWorkflowDeployment({ - org: org, - app: app, - imageRepo: app.imageRepo, - commitMessage: payload.workflow_run.head_commit.message, - config: { - // Reuse the config from the previous deployment - port: config.port, - replicas: config.replicas, - requests: config.requests, - limits: config.limits, - mounts: config.mounts, - createIngress: config.createIngress, - subdomain: config.subdomain, - collectLogs: config.collectLogs, - source: "GIT", - env: config.getEnv(), - repositoryId: config.repositoryId, - branch: config.branch, - commitHash: payload.workflow_run.head_commit.id, - builder: config.builder, - rootDir: config.rootDir, - dockerfilePath: config.dockerfilePath, - imageTag: config.imageTag, - event: config.event, - eventId: config.eventId, + const oldConfig = ( + await db.app.getDeploymentConfig(app.id) + ).asGitConfig(); + const config = deploymentConfigService.populateNewCommit( + oldConfig, + app, + payload.workflow_run.head_commit.id, + ); + await deploymentService.create({ + org, + app, + commitMessage: payload.workflow_run.head_commit.message, + workflowRunId: payload.workflow_run.id, + config, + git: { + checkRun: { + pending: true, + owner: payload.repository.owner.login, + repo: payload.repository.name, }, - workflowRunId: payload.workflow_run.id, - createCheckRun: true, - octokit, - owner: payload.repository.owner.login, - repo: payload.repository.name, - }); - } catch (e) { - console.error(e); - } + }, + }); } } else if (payload.action === "completed") { for (const app of apps) { @@ -277,7 +231,6 @@ async function handleWorkflowRun( app.id, payload.workflow_run.id, ); - const config = await db.deployment.getConfig(deployment.id); if (!deployment || deployment.status !== "PENDING") { // If the app was deleted, nothing to do @@ -313,276 +266,21 @@ async function handleWorkflowRun( continue; } - const octokit = await getOctokit(org.githubInstallationId); - await buildAndDeployFromRepo(org, app, deployment, config, { - createCheckRun: true, - octokit, - owner: payload.repository.owner.login, - repo: payload.repository.name, - }); - } - } -} - -type BuildAndDeployOptions = { - org: Organization; - app: App; - imageRepo: string; - commitMessage: string; - config: DeploymentConfigCreate; -} & ( - | { createCheckRun: true; octokit: Octokit; owner: string; repo: string } - | { createCheckRun: false } -); - -export async function buildAndDeploy({ - org, - app, - imageRepo, - commitMessage, - config: configIn, - ...opts -}: BuildAndDeployOptions) { - const imageTag = - configIn.source === DeploymentSource.IMAGE - ? (configIn.imageTag as ImageTag) - : (`${env.REGISTRY_HOSTNAME}/${env.HARBOR_PROJECT_NAME}/${imageRepo}:${configIn.commitHash}` as const); - - const [deployment, appGroup] = await Promise.all([ - db.deployment.create({ - appId: app.id, - commitMessage, - config: { ...configIn, imageTag }, - }), - db.appGroup.getById(app.appGroupId), - ]); - - const config = await db.deployment.getConfig(deployment.id); - - if (!app.configId) { - // Only set the app's config reference if we are creating the app. - // If updating, first wait for the build to complete successfully - // and set this in updateDeployment. - await db.app.setConfig(app.id, deployment.configId); - } - - await cancelAllOtherDeployments(org, app, deployment.id, true); - - if (config.source === "GIT") { - buildAndDeployFromRepo(org, app, deployment, config, opts); - } else if (config.source === "IMAGE") { - log(deployment.id, "BUILD", "Deploying directly from OCI image..."); - // If we're creating a deployment directly from an existing image tag, just deploy it now - try { - const { namespace, configs, postCreate } = - await createAppConfigsFromDeployment( - org, - app, - appGroup, - deployment, - config, - ); - const api = getClientForClusterUsername( - app.clusterUsername, - "KubernetesObjectApi", - shouldImpersonate(app.projectId), - ); - await createOrUpdateApp(api, app.name, namespace, configs, postCreate); - log(deployment.id, "BUILD", "Deployment succeeded"); - await db.deployment.setStatus(deployment.id, DeploymentStatus.COMPLETE); - } catch (e) { - console.error( - `Failed to create Kubernetes resources for deployment ${deployment.id}`, - e, - ); - await db.deployment.setStatus(deployment.id, DeploymentStatus.ERROR); - log( - deployment.id, - "BUILD", - `Failed to apply Kubernetes resources: ${JSON.stringify(e?.body ?? e)}`, - "stderr", - ); - } - } -} - -export async function buildAndDeployFromRepo( - org: Organization, - app: App, - deployment: Deployment, - config: DeploymentConfig, - opts: - | { createCheckRun: true; octokit: Octokit; owner: string; repo: string } - | { createCheckRun: false }, -) { - let checkRun: - | Awaited> - | Awaited> - | undefined; - - if (opts.createCheckRun) { - try { - if (deployment.checkRunId) { - // We are finishing a deployment that was pending earlier - checkRun = await opts.octokit.rest.checks.update({ - check_run_id: deployment.checkRunId, - status: "in_progress", - owner: opts.owner, - repo: opts.repo, - }); - log( - deployment.id, - "BUILD", - "Updated GitHub check run to In Progress at " + - checkRun.data.html_url, - ); - } else { - // Create a check on their commit that says the build is "in progress" - checkRun = await opts.octokit.rest.checks.create({ - head_sha: config.commitHash, - name: "AnvilOps", - status: "in_progress", - details_url: `${env.BASE_URL}/app/${deployment.appId}/deployment/${deployment.id}`, - owner: opts.owner, - repo: opts.repo, - }); - log( - deployment.id, - "BUILD", - "Created GitHub check run with status In Progress at " + - checkRun.data.html_url, - ); - } - } catch (e) { - console.error("Failed to modify check run: ", e); - } - } - - let jobId: string | undefined; - try { - jobId = await createBuildJob(org, app, deployment, config); - log(deployment.id, "BUILD", "Created build job with ID " + jobId); - } catch (e) { - log( - deployment.id, - "BUILD", - "Error creating build job: " + JSON.stringify(e), - "stderr", - ); - await db.deployment.setStatus(deployment.id, "ERROR"); - if (opts.createCheckRun && checkRun.data.id) { - // If a check run was created, make sure it's marked as failed - try { - await opts.octokit.rest.checks.update({ - check_run_id: checkRun.data.id, - owner: opts.owner, - repo: opts.repo, - status: "completed", - conclusion: "failure", - }); - log( - deployment.id, - "BUILD", - "Updated GitHub check run to Completed with conclusion Failure", - ); - } catch {} - } - throw new Error("Failed to create build job", { cause: e }); - } - - await db.deployment.setCheckRunId(deployment.id, checkRun?.data?.id); -} - -export async function createPendingWorkflowDeployment({ - org, - app, - imageRepo, - commitMessage, - config, - workflowRunId, - ...opts -}: BuildAndDeployOptions & { workflowRunId: number }) { - const imageTag = - config.source === DeploymentSource.IMAGE - ? (config.imageTag as ImageTag) - : (`${env.REGISTRY_HOSTNAME}/${env.HARBOR_PROJECT_NAME}/${imageRepo}:${config.commitHash}` as const); - - const deployment = await db.deployment.create({ - appId: app.id, - commitMessage, - workflowRunId, - config: { - ...config, - imageTag, - }, - }); - - await cancelAllOtherDeployments(org, app, deployment.id, false); - - let checkRun: - | Awaited> - | undefined; - if (opts.createCheckRun) { - try { - checkRun = await opts.octokit.rest.checks.create({ - head_sha: config.commitHash, - name: "AnvilOps", - status: "queued", - details_url: `${env.BASE_URL}/app/${deployment.appId}/deployment/${deployment.id}`, - owner: opts.owner, - repo: opts.repo, - }); - log( - deployment.id, - "BUILD", - "Created GitHub check run with status Queued at " + - checkRun.data.html_url, - ); - } catch (e) { - console.error("Failed to modify check run: ", e); - } - } - if (checkRun) { - await db.deployment.setCheckRunId(deployment.id, checkRun.data.id); - } -} - -export async function cancelAllOtherDeployments( - org: Organization, - app: App, - deploymentId: number, - cancelComplete = false, -) { - await cancelBuildJobsForApp(app.id); - - const statuses = Object.keys(DeploymentStatus) as DeploymentStatus[]; - const deployments = await db.app.getDeploymentsWithStatus( - app.id, - cancelComplete - ? statuses.filter((it) => it != "ERROR") - : statuses.filter((it) => it != "ERROR" && it != "COMPLETE"), - ); - - let octokit: Octokit; - for (const deployment of deployments) { - if (deployment.id !== deploymentId && !!deployment.checkRunId) { - // Should have a check run that is either queued or in_progress - if (!octokit) { - octokit = await getOctokit(org.githubInstallationId); - } - const repo = await getRepoById(octokit, deployment.config.repositoryId); - await octokit.rest.checks.update({ - check_run_id: deployment.checkRunId, - owner: repo.owner.login, - repo: repo.name, - status: "completed", - conclusion: "cancelled", + const config = ( + await db.deployment.getConfig(deployment.id) + ).asGitConfig(); + + await deploymentService.completeGitDeployment({ + org, + app, + deployment, + config, + checkRunOpts: { + type: "update", + owner: payload.repository.owner.login, + repo: payload.repository.name, + }, }); - log( - deployment.id, - "BUILD", - "Updated GitHub check run to Completed with conclusion Cancelled", - ); } } } diff --git a/backend/src/service/helper/app.ts b/backend/src/service/helper/app.ts new file mode 100644 index 00000000..68890a89 --- /dev/null +++ b/backend/src/service/helper/app.ts @@ -0,0 +1,198 @@ +import type { Organization, User } from "../../db/models.ts"; +import type { components } from "../../generated/openapi.ts"; +import { namespaceInUse } from "../../lib/cluster/kubernetes.ts"; +import { + canManageProject, + isRancherManaged, +} from "../../lib/cluster/rancher.ts"; +import { + MAX_GROUPNAME_LEN, + MAX_NAMESPACE_LEN, + MAX_STS_NAME_LEN, +} from "../../lib/cluster/resources.ts"; +import { env } from "../../lib/env.ts"; +import { isRFC1123 } from "../../lib/validate.ts"; +import { ValidationError } from "../../service/common/errors.ts"; +import { DeploymentConfigService } from "./deploymentConfig.ts"; +interface CreateAppInput { + type: "create"; + name: string; + namespace: string; + projectId?: string; + config: components["schemas"]["DeploymentConfig"]; +} + +interface UpdateAppInput { + type: "update"; + existingAppId: number; + projectId?: string; + config: components["schemas"]["DeploymentConfig"]; +} + +export type AppInput = CreateAppInput | UpdateAppInput; + +export class AppService { + private configService: DeploymentConfigService; + constructor(configService: DeploymentConfigService) { + this.configService = configService; + } + + /** + * Validates and prepares deployment config and commit message for app creation or update. + * @throws ValidationError, OrgNotFoundError + */ + async prepareMetadataForApps( + organization: Organization, + user: User, + ...apps: AppInput[] + ) { + const appValidationErrors = ( + await Promise.all( + apps.map(async (app) => { + try { + await this.validateApp(app, user); + return null; + } catch (e) { + return e.message; + } + }), + ) + ).filter(Boolean); + if (appValidationErrors.length != 0) { + throw new ValidationError(appValidationErrors.join(",")); + } + + if ( + apps.some( + (app) => + app.config.source === "git" && !organization.githubInstallationId, + ) + ) { + throw new ValidationError( + "The AnvilOps GitHub App is not installed in this organization.", + ); + } + + const metadata = await Promise.allSettled( + apps.map( + async (app) => + await this.configService.prepareDeploymentMetadata( + app.config, + organization, + ), + ), + ); + + const errors = metadata.filter((res) => res.status === "rejected"); + if (errors.length > 0) { + throw new ValidationError( + errors.map((err) => (err.reason as Error)?.message).join(","), + ); + } + + type MetadataReturn = Awaited< + ReturnType + >; + + return metadata.map( + (app) => (app as PromiseFulfilledResult).value, + ); + } + + /** + * Validates an app input for create or update. + * @throws ValidationError + */ + private async validateApp(app: AppInput, user: { clusterUsername: string }) { + // Common validation for both create and update + await this.validateCommon(app, user); + + // Type-specific validation + if (app.type === "create") { + await this.validateCreate(app); + } + } + + /** + * Validation steps common between app creates and updates. + * @throws ValidationError + */ + private async validateCommon( + app: AppInput, + user: { clusterUsername: string }, + ) { + if (isRancherManaged()) { + if (!app.projectId) { + throw new ValidationError("Project ID is required"); + } + + if (!(await canManageProject(user.clusterUsername, app.projectId))) { + throw new ValidationError("Project not found"); + } + } + + if (app.config.appType === "workload") { + await this.configService.validateCommonWorkloadConfig( + app.config, + app.type === "update" ? app.existingAppId : undefined, + ); + } else if (app.config.appType === "helm") { + if (!env.ALLOW_HELM_DEPLOYMENTS) { + throw new ValidationError("Helm deployments are disabled"); + } + } + } + + /** + * Validation steps specific to app creation. + * @throws ValidationError + */ + private async validateCreate(app: CreateAppInput) { + if ( + app.namespace.length == 0 || + app.namespace.length > MAX_NAMESPACE_LEN || + !isRFC1123(app.namespace) + ) { + throw new ValidationError( + "Namespace must contain only lowercase alphanumeric characters or '-', " + + "start with an alphabetic character and end with an alphanumeric character, " + + `and contain at most ${MAX_NAMESPACE_LEN} characters`, + ); + } + + if (await namespaceInUse(app.namespace)) { + throw new ValidationError("namespace is unavailable"); + } + + this.validateAppName(app.name); + } + + /** + * @throws ValidationError + */ + validateAppGroupName(name: string) { + if ( + !(0 < name.length && name.length <= MAX_GROUPNAME_LEN) || + !isRFC1123(name) + ) { + throw new ValidationError( + "App group name must contain only lowercase alphanumeric characters or '-', " + + "start with an alphabetic character and end with an alphanumeric character, " + + `and contain at most ${MAX_GROUPNAME_LEN} characters`, + ); + } + } + + /** + * @throws ValidationError + */ + private validateAppName(name: string) { + if (name.length > MAX_STS_NAME_LEN || !isRFC1123(name)) { + throw new ValidationError( + "App name must contain only lowercase alphanumeric characters or '-', " + + "start and end with an alphanumeric character, " + + `and contain at most ${MAX_STS_NAME_LEN} characters`, + ); + } + } +} diff --git a/backend/src/service/helper/deployment.ts b/backend/src/service/helper/deployment.ts new file mode 100644 index 00000000..ddc5b564 --- /dev/null +++ b/backend/src/service/helper/deployment.ts @@ -0,0 +1,560 @@ +import { Octokit } from "octokit"; +import type { + App, + AppGroup, + Deployment, + GitConfig, + GitConfigCreate, + HelmConfig, + HelmConfigCreate, + Organization, + WorkloadConfig, + WorkloadConfigCreate, +} from "../../db/models.ts"; +import { AppRepo } from "../../db/repo/app.ts"; +import { AppGroupRepo } from "../../db/repo/appGroup.ts"; +import { DeploymentRepo } from "../../db/repo/deployment.ts"; +import { DeploymentStatus } from "../../generated/prisma/enums.ts"; +import { cancelBuildJobsForApp, createBuildJob } from "../../lib/builder.ts"; +import { + createOrUpdateApp, + getClientForClusterUsername, +} from "../../lib/cluster/kubernetes.ts"; +import { shouldImpersonate } from "../../lib/cluster/rancher.ts"; +import { createAppConfigsFromDeployment } from "../../lib/cluster/resources.ts"; +import { env } from "../../lib/env.ts"; +import { upgrade } from "../../lib/helm.ts"; +import { getOctokit, getRepoById } from "../../lib/octokit.ts"; +import { DeploymentError } from "../common/errors.ts"; +import { log } from "../githubWebhook.ts"; + +type GitOptions = + | { skipBuild: boolean; checkRun?: undefined } + | { + skipBuild?: false; + checkRun: { pending: boolean; owner: string; repo: string }; + }; + +export class DeploymentService { + private appRepo: AppRepo; + private appGroupRepo: AppGroupRepo; + private deploymentRepo: DeploymentRepo; + private getOctokitFn: typeof getOctokit; + private getRepoByIdFn: typeof getRepoById; + constructor( + appRepo: AppRepo, + appGroupRepo: AppGroupRepo, + deploymentRepo: DeploymentRepo, + getOctokitFn?: typeof getOctokit, + getRepoByIdFn?: typeof getRepoById, + ) { + this.appRepo = appRepo; + this.appGroupRepo = appGroupRepo; + this.deploymentRepo = deploymentRepo; + this.getOctokitFn = getOctokitFn ?? getOctokit; + this.getRepoByIdFn = getRepoByIdFn ?? getRepoById; + } + + /** + * Creates a Deployment object and triggers the deployment process. + * @throws DeploymentError + */ + async create({ + org, + app, + commitMessage, + workflowRunId, + config: configIn, + git, + }: { + org: Organization; + app: App; + commitMessage: string; + workflowRunId?: number; + config: WorkloadConfigCreate | GitConfigCreate | HelmConfigCreate; + git?: GitOptions; + }) { + const deployment = await this.deploymentRepo.create({ + appId: app.id, + commitMessage, + workflowRunId, + config: configIn, + ...(git?.checkRun?.pending && { status: "PENDING" }), + }); + const config = await this.deploymentRepo.getConfig(deployment.id); + + if (!app.configId) { + await this.appRepo.setConfig(app.id, deployment.configId); + } + + switch (config.source) { + case "HELM": { + await this.deployHelm(org, app, deployment, config.asHelmConfig()); + break; + } + + case "GIT": { + await this.handleGitDeployment({ + org, + app, + deployment, + config: config.asGitConfig(), + opts: git, + }); + break; + } + + case "IMAGE": { + const appGroup = await this.appGroupRepo.getById(app.appGroupId); + await this.deployWorkloadWithoutBuild({ + org, + app, + appGroup, + deployment, + config, + }); + break; + } + + default: { + config satisfies never; // Make sure switch is exhaustive + } + } + } + + /** + * Proceeds with a Git deployment from an existing Deployment and GitConfig. + * - If opts.skipBuild is true, immediately deploy the app. + * - If opts.checkRun is present, deploy in response to a webhook. When opts.pending is true, create a pending check run and wait for other workflows to complete. When opts.pending is false, start the build. + * - Otherwise, build and deploy as if a new app has just been created. + * + * @throws DeploymentError + */ + private async handleGitDeployment({ + org, + app, + deployment, + config, + opts, + }: { + org: Organization; + app: App; + deployment: Deployment; + config: GitConfig; + opts?: GitOptions; + }) { + if (opts?.checkRun) { + // Webhook event deployment + const { pending, owner, repo } = opts.checkRun; + if (pending) { + // AnvilOps is waiting for another CI workflow to finish before deploying the app. Create a "Pending" check run for now. + // When the other workflow completes, this method will be called again with `pending` set to `false`. + this.createPendingCheckRun({ + org, + app, + deployment, + config, + checkRunOpts: { owner, repo }, + }); + } else { + await this.completeGitDeployment({ + org, + app, + deployment, + config, + checkRunOpts: { + type: "create", + owner, + repo, + }, + }); + } + } else if (opts?.skipBuild) { + // Minor config update + const appGroup = await this.appGroupRepo.getById(app.appGroupId); + await this.deployWorkloadWithoutBuild({ + org, + app, + appGroup, + deployment, + config, + }); + } else { + // Regular app creation + await this.completeGitDeployment({ org, app, deployment, config }); + } + } + + /** + * Creates a pending check run for a Git deployment, + * to be updated when an associated workflow run completes. + */ + private async createPendingCheckRun({ + org, + app, + deployment, + config, + checkRunOpts, + }: { + org: Organization; + app: App; + deployment: Deployment; + config: GitConfig; + checkRunOpts: { + owner: string; + repo: string; + }; + }) { + try { + const checkRun = await this.handleCheckRun({ + octokit: await this.getOctokitFn(org.githubInstallationId), + deployment, + config, + checkRun: { + type: "create", + opts: { + owner: checkRunOpts.owner, + repo: checkRunOpts.repo, + status: "queued", + }, + }, + }); + log( + deployment.id, + "BUILD", + "Created GitHub check run with status Queued at " + + checkRun.data.html_url, + ); + await this.deploymentRepo.setCheckRunId( + deployment.id, + checkRun?.data?.id, + ); + await this.cancelAllOtherDeployments(org, app, deployment.id, false); + } catch (e) { + console.error("Failed to set check run: ", e); + } + } + + /** + * Builds and deploys from an existing Deployment and GitConfig. + * @throws DeploymentError + */ + async completeGitDeployment({ + org, + app, + deployment, + config, + checkRunOpts, + }: { + org: Organization; + app: App; + deployment: Deployment; + config: GitConfig; + checkRunOpts?: { + type: "create" | "update"; + owner: string; + repo: string; + status?: "in_progress" | "completed" | "queued"; + }; + }) { + await this.cancelAllOtherDeployments(org, app, deployment.id, true); + + let jobId: string | undefined; + let octokit: Octokit; + let checkRun: + | Awaited> + | Awaited>; + if (checkRunOpts) { + octokit = await this.getOctokitFn(org.githubInstallationId); + const { owner, repo, status } = checkRunOpts; + try { + switch (checkRunOpts.type) { + case "create": { + checkRun = await this.handleCheckRun({ + octokit, + deployment, + config, + checkRun: { + type: "create", + opts: { owner, repo, status: status ?? "in_progress" }, + }, + }); + log( + deployment.id, + "BUILD", + "Created GitHub check run with status In Progress at " + + checkRun.data.html_url, + ); + break; + } + + case "update": { + checkRun = await this.handleCheckRun({ + octokit, + deployment, + config, + checkRun: { + type: "update", + opts: { + owner, + repo, + status: status ?? "in_progress", + check_run_id: deployment.checkRunId, + }, + }, + }); + log( + deployment.id, + "BUILD", + "Updated GitHub check run to In Progress at " + + checkRun.data.html_url, + ); + break; + } + } + } catch (e) { + console.error("Failed to set check run: ", e); + } + } + + try { + jobId = await createBuildJob(org, app, deployment, config); + log(deployment.id, "BUILD", "Created build job with ID " + jobId); + } catch (e) { + log( + deployment.id, + "BUILD", + "Error creating build job: " + JSON.stringify(e), + "stderr", + ); + await this.deploymentRepo.setStatus(deployment.id, "ERROR"); + if (checkRunOpts && checkRun?.data?.id) { + // If a check run was created, make sure it's marked as failed + try { + await this.handleCheckRun({ + octokit, + deployment, + config, + checkRun: { + type: "update", + opts: { + check_run_id: checkRun.data.id, + owner: checkRunOpts.owner, + repo: checkRunOpts.repo, + status: "completed", + conclusion: "failure", + }, + }, + }); + log( + deployment.id, + "BUILD", + "Updated GitHub check run to Completed with conclusion Failure", + ); + } catch {} + } + throw new DeploymentError(e); + } + + if (checkRun?.data?.id) { + await this.deploymentRepo.setCheckRunId(deployment.id, checkRun.data.id); + } + } + + /** + * Immediately deploys a workload. The image tag must be set on the config object. + * @throws DeploymentError + */ + private async deployWorkloadWithoutBuild({ + org, + app, + appGroup, + deployment, + config, + }: { + org: Organization; + app: App; + appGroup: AppGroup; + deployment: Deployment; + config: WorkloadConfig; + }) { + await this.cancelAllOtherDeployments(org, app, deployment.id, true); + await this.deploymentRepo.setStatus( + deployment.id, + DeploymentStatus.DEPLOYING, + ); + log(deployment.id, "BUILD", "Deploying directly from OCI image..."); + // If we're creating a deployment directly from an existing image tag, just deploy it now + try { + const { namespace, configs, postCreate } = + await createAppConfigsFromDeployment( + org, + app, + appGroup, + deployment, + config, + ); + const api = getClientForClusterUsername( + app.clusterUsername, + "KubernetesObjectApi", + shouldImpersonate(app.projectId), + ); + await createOrUpdateApp(api, app.name, namespace, configs, postCreate); + log(deployment.id, "BUILD", "Deployment succeeded"); + await this.deploymentRepo.setStatus( + deployment.id, + DeploymentStatus.COMPLETE, + ); + await this.appRepo.setConfig(app.id, deployment.configId); + } catch (e) { + await this.deploymentRepo.setStatus( + deployment.id, + DeploymentStatus.ERROR, + ); + log( + deployment.id, + "BUILD", + `Failed to apply Kubernetes resources: ${JSON.stringify(e?.body ?? e)}`, + "stderr", + ); + throw new DeploymentError(e); + } + } + + /** + * Deploys a helm chart. + * @throws DeploymentError + */ + private async deployHelm( + org: Organization, + app: App, + deployment: Deployment, + config: HelmConfig, + ) { + await this.cancelAllOtherDeployments(org, app, deployment.id, true); + log(deployment.id, "BUILD", "Deploying directly from Helm chart..."); + try { + await upgrade(app, deployment, config); + await this.appRepo.setConfig(app.id, deployment.configId); + } catch (e) { + await this.deploymentRepo.setStatus( + deployment.id, + DeploymentStatus.ERROR, + ); + log( + deployment.id, + "BUILD", + `Failed to create Helm deployment job: ${JSON.stringify(e?.body ?? e)}`, + "stderr", + ); + throw new DeploymentError(e); + } + } + + private async handleCheckRun({ + octokit, + deployment, + config, + checkRun, + }: { + octokit: Octokit; + deployment: Omit; + config: GitConfig; + checkRun: + | { + type: "create"; + opts: { + owner: string; + repo: string; + status: "in_progress" | "completed" | "queued"; + }; + } + | { + type: "update"; + opts: { + owner: string; + repo: string; + check_run_id: number; + status: "in_progress" | "completed" | "queued"; + conclusion?: "cancelled" | "failure" | "success"; + }; + }; + }) { + switch (checkRun.type) { + case "create": { + return await octokit.rest.checks.create({ + ...checkRun.opts, + head_sha: config.commitHash, + name: "AnvilOps", + details_url: `${env.BASE_URL}/app/${deployment.appId}/deployment/${deployment.id}`, + }); + break; + } + case "update": { + return await octokit.rest.checks.update(checkRun.opts); + break; + } + } + } + + /** + * @throws {Error} if a deployment has a checkRunId but its config is not a GitConfig + */ + async cancelAllOtherDeployments( + org: Organization, + app: App, + deploymentId: number, + cancelComplete = false, + ) { + await cancelBuildJobsForApp(app.id); + + const statuses = Object.keys(DeploymentStatus) as DeploymentStatus[]; + const deployments = await this.appRepo.getDeploymentsWithStatus( + app.id, + cancelComplete + ? statuses.filter((it) => it != "ERROR") + : statuses.filter((it) => it != "ERROR" && it != "COMPLETE"), + ); + + let octokit: Octokit; + for (const deployment of deployments) { + if (deployment.id === deploymentId) { + continue; + } + if (!!deployment.checkRunId) { + // Should have a check run that is either queued or in_progress + if (!octokit) { + octokit = await this.getOctokitFn(org.githubInstallationId); + } + const config = deployment.config.asGitConfig(); + + const repo = await this.getRepoByIdFn(octokit, config.repositoryId); + try { + await this.handleCheckRun({ + octokit, + deployment, + config, + checkRun: { + type: "update", + opts: { + check_run_id: deployment.checkRunId, + owner: repo.owner.login, + repo: repo.name, + status: "completed", + conclusion: "cancelled", + }, + }, + }); + log( + deployment.id, + "BUILD", + "Updated GitHub check run to Completed with conclusion Cancelled", + ); + } catch (e) {} + } + if (deployment.status != "COMPLETE") { + await this.deploymentRepo.setStatus(deployment.id, "CANCELLED"); + } + } + } +} diff --git a/backend/src/service/helper/deploymentConfig.ts b/backend/src/service/helper/deploymentConfig.ts new file mode 100644 index 00000000..f5d4a215 --- /dev/null +++ b/backend/src/service/helper/deploymentConfig.ts @@ -0,0 +1,374 @@ +import { Octokit } from "octokit"; +import type { + App, + DeploymentConfig, + GitConfig, + GitConfigCreate, + HelmConfigCreate, + Organization, + WorkloadConfig, + WorkloadConfigCreate, +} from "../../db/models.ts"; +import { AppRepo } from "../../db/repo/app.ts"; +import { DeploymentRepo } from "../../db/repo/deployment.ts"; +import type { components } from "../../generated/openapi.ts"; +import { MAX_SUBDOMAIN_LEN } from "../../lib/cluster/resources.ts"; +import { getImageConfig } from "../../lib/cluster/resources/logs.ts"; +import { generateVolumeName } from "../../lib/cluster/resources/statefulset.ts"; +import { env } from "../../lib/env.ts"; +import { getOctokit, getRepoById } from "../../lib/octokit.ts"; +import { isRFC1123 } from "../../lib/validate.ts"; +import { ValidationError } from "../common/errors.ts"; + +type GitWorkloadConfig = components["schemas"]["WorkloadConfigOptions"] & { + source: "git"; +}; + +type ImageWorkloadConfig = components["schemas"]["WorkloadConfigOptions"] & { + source: "image"; +}; + +export class DeploymentConfigService { + private appRepo: AppRepo; + private getOctokitFn: typeof getOctokit; + private getRepoByIdFn: typeof getRepoById; + constructor( + appRepo: AppRepo, + getOctokitFn = getOctokit, + getRepoByIdFn = getRepoById, + ) { + this.appRepo = appRepo; + this.getOctokitFn = getOctokitFn; + this.getRepoByIdFn = getRepoByIdFn; + } + + async prepareDeploymentMetadata( + config: components["schemas"]["DeploymentConfig"], + organization: Pick, + ): Promise<{ + config: GitConfigCreate | HelmConfigCreate | WorkloadConfigCreate; + commitMessage: string | null; + }> { + switch (config.source) { + case "git": { + let octokit: Octokit, repo: Awaited>; + + try { + octokit = await this.getOctokitFn(organization.githubInstallationId); + repo = await this.getRepoByIdFn(octokit, config.repositoryId); + } catch (err) { + if (err.status === 404) { + throw new ValidationError("Invalid repository id"); + } + + console.error(err); + throw new Error("Failed to look up GitHub repository"); + } + + await this.validateGitConfig(config, octokit, repo); + + let commitHash: string; + let commitMessage: string; + if (config.commitHash) { + commitHash = config.commitHash; + const commit = await octokit.rest.git.getCommit({ + owner: repo.owner.login, + repo: repo.name, + commit_sha: commitHash, + }); + commitMessage = commit.data.message; + } else { + const latestCommit = ( + await octokit.rest.repos.listCommits({ + per_page: 1, + owner: repo.owner.login, + repo: repo.name, + sha: config.branch, + }) + ).data[0]; + + commitHash = latestCommit.sha; + commitMessage = latestCommit.commit.message; + } + + return { + config: this.createGitConfig(config, commitHash, repo.id), + commitMessage, + }; + } + case "image": { + await this.validateImageConfig(config); + return { + config: { + ...this.createCommonWorkloadConfig(config), + source: "IMAGE", + appType: "workload", + }, + commitMessage: null, + }; + } + case "helm": { + return { + config: { ...config, source: "HELM", appType: "helm" }, + commitMessage: null, + }; + } + default: { + config satisfies never; // Make sure switch is exhaustive + throw new ValidationError("Invalid deployment config type"); + } + } + } + + /** + * @returns If source is GIT, a `ConfigCreate` object with the image tag where + * the built image will be pushed, the original config otherwise + */ + populateImageTag( + config: GitConfigCreate | HelmConfigCreate | WorkloadConfigCreate, + app: App, + ) { + if (config.source === "GIT") { + return { + ...config, + imageTag: `${env.REGISTRY_HOSTNAME}/${env.HARBOR_PROJECT_NAME}/${app.imageRepo}:${config.commitHash}`, + } satisfies WorkloadConfigCreate; + } + + return config; + } + + populateNewCommit(config: GitConfig, app: App, commitHash: string) { + return this.populateImageTag( + { + ...DeploymentRepo.cloneWorkloadConfig(config), + commitHash, + }, + app, + ); + } + + private createCommonWorkloadConfig( + config: components["schemas"]["WorkloadConfigOptions"], + ) { + return { + appType: "workload" as const, + collectLogs: config.collectLogs, + createIngress: config.createIngress, + subdomain: config.subdomain, + env: config.env, + requests: config.requests, + limits: config.limits, + replicas: config.replicas, + port: config.port, + mounts: config.mounts, + commitHash: "unknown", + imageTag: config.imageTag, + }; + } + + private createGitConfig( + config: GitWorkloadConfig, + commitHash: string, + repositoryId: number, + ): GitConfigCreate { + return { + ...this.createCommonWorkloadConfig(config), + source: "GIT", + repositoryId, + branch: config.branch, + event: config.event, + eventId: config.eventId, + commitHash, + builder: config.builder, + dockerfilePath: config.dockerfilePath, + rootDir: config.rootDir, + imageTag: undefined, + } satisfies GitConfigCreate; + } + + /** + * Produces a `DeploymentConfig` object to be returned from the API, as described in the OpenAPI spec. + */ + formatDeploymentConfig( + config: DeploymentConfig, + ): components["schemas"]["DeploymentConfig"] { + if (config.appType === "workload") { + return this.formatWorkloadConfig(config); + } else { + return { + ...config, + source: "helm", + }; + } + } + + private formatWorkloadConfig( + config: WorkloadConfig, + ): components["schemas"]["WorkloadConfigOptions"] { + return { + appType: "workload", + createIngress: config.createIngress, + subdomain: config.createIngress ? config.subdomain : undefined, + collectLogs: config.collectLogs, + port: config.port, + env: config.displayEnv, + replicas: config.replicas, + requests: config.requests, + limits: config.limits, + mounts: config.mounts.map((mount) => ({ + amountInMiB: mount.amountInMiB, + path: mount.path, + volumeClaimName: generateVolumeName(mount.path), + })), + ...(config.source === "GIT" + ? { + source: "git" as const, + branch: config.branch, + dockerfilePath: config.dockerfilePath, + rootDir: config.rootDir, + builder: config.builder, + repositoryId: config.repositoryId, + event: config.event, + eventId: config.eventId, + commitHash: config.commitHash, + } + : { + source: "image" as const, + imageTag: config.imageTag, + }), + }; + } + + async validateCommonWorkloadConfig( + config: components["schemas"]["WorkloadConfigOptions"], + existingAppId?: number, + ) { + if (config.subdomain) { + await this.validateSubdomain(config.subdomain, existingAppId); + } + + if (config.port < 0 || config.port > 65535) { + throw new ValidationError( + "Invalid port number: must be between 0 and 65535", + ); + } + + this.validateEnv(config.env); + + this.validateMounts(config.mounts); + } + + async validateGitConfig( + config: GitWorkloadConfig, + octokit: Octokit, + repo: Awaited>, + ) { + const { rootDir, builder, dockerfilePath, event, eventId } = config; + if (rootDir.startsWith("/") || rootDir.includes(`"`)) { + throw new ValidationError("Invalid root directory"); + } + if (builder === "dockerfile") { + if (!dockerfilePath) { + throw new ValidationError("Dockerfile path is required"); + } + if (dockerfilePath.startsWith("/") || dockerfilePath.includes(`"`)) { + throw new ValidationError("Invalid Dockerfile path"); + } + } + + if (event === "workflow_run" && eventId === undefined) { + throw new ValidationError("Workflow ID is required"); + } + + if (config.event === "workflow_run" && config.eventId) { + try { + const workflows = await ( + octokit.request({ + method: "GET", + url: `/repositories/${repo.id}/actions/workflows`, + }) as ReturnType + ).then((res) => res.data.workflows); + if (!workflows.some((workflow) => workflow.id === config.eventId)) { + throw new ValidationError("Workflow not found"); + } + } catch (err) { + throw new ValidationError("Failed to look up GitHub workflow"); + } + } + } + + async validateImageConfig(config: ImageWorkloadConfig) { + if (!config.imageTag) { + throw new ValidationError("Image tag is required"); + } + + await this.validateImageReference(config.imageTag); + } + + private validateMounts( + mounts: components["schemas"]["KnownDeploymentOptions"]["mounts"], + ) { + const pathSet = new Set(); + for (const mount of mounts) { + if (!mount.path.startsWith("/")) { + throw new ValidationError( + `Invalid mount path ${mount.path}: must start with '/'`, + ); + } + + if (pathSet.has(mount.path)) { + throw new ValidationError(`Invalid mounts: paths are not unique`); + } + pathSet.add(mount.path); + } + } + + private validateEnv(env: PrismaJson.EnvVar[]) { + if (env?.some((it) => !it.name || it.name.length === 0)) { + throw new ValidationError("Some environment variable(s) are empty"); + } + + if (env?.some((it) => it.name.startsWith("_PRIVATE_ANVILOPS_"))) { + // Environment variables with this prefix are used in the log shipper - see log-shipper/main.go + throw new ValidationError( + 'Environment variable(s) use reserved prefix "_PRIVATE_ANVILOPS_"', + ); + } + + const envNames = new Set(); + + for (let envVar of env) { + if (envNames.has(envVar.name)) { + throw new ValidationError( + "Duplicate environment variable " + envVar.name, + ); + } + envNames.add(envVar.name); + } + } + + private async validateImageReference(reference: string) { + try { + // Look up the image in its registry to make sure it exists + await getImageConfig(reference); + } catch (e) { + throw new ValidationError("Image could not be found in its registry."); + } + } + + private async validateSubdomain(subdomain: string, existingAppId?: number) { + if (subdomain.length > MAX_SUBDOMAIN_LEN || !isRFC1123(subdomain)) { + throw new ValidationError( + "Subdomain must contain only lowercase alphanumeric characters or '-', " + + "start and end with an alphanumeric character, " + + `and contain at most ${MAX_SUBDOMAIN_LEN} characters`, + ); + } + + const appWithSubdomain = await this.appRepo.getAppBySubdomain(subdomain); + if (appWithSubdomain && appWithSubdomain.id !== existingAppId) { + throw new ValidationError("Subdomain is in use"); + } + } +} diff --git a/backend/src/service/helper/index.ts b/backend/src/service/helper/index.ts new file mode 100644 index 00000000..29065b92 --- /dev/null +++ b/backend/src/service/helper/index.ts @@ -0,0 +1,17 @@ +import { db } from "../../db/index.ts"; +import { getOctokit, getRepoById } from "../../lib/octokit.ts"; +import { AppService } from "./app.ts"; +import { DeploymentService } from "./deployment.ts"; +import { DeploymentConfigService } from "./deploymentConfig.ts"; + +export const deploymentConfigService = new DeploymentConfigService(db.app); + +export const appService = new AppService(deploymentConfigService); + +export const deploymentService = new DeploymentService( + db.app, + db.appGroup, + db.deployment, + getOctokit, + getRepoById, +); diff --git a/backend/src/service/isSubdomainAvailable.ts b/backend/src/service/isSubdomainAvailable.ts index 6877b8da..58074ee2 100644 --- a/backend/src/service/isSubdomainAvailable.ts +++ b/backend/src/service/isSubdomainAvailable.ts @@ -9,6 +9,6 @@ export async function isSubdomainAvailable(subdomain: string) { throw new ValidationError("Invalid subdomain."); } - const subdomainUsedByApp = await db.app.isSubdomainInUse(subdomain); - return !subdomainUsedByApp; + const appUsingSubdomain = await db.app.getAppBySubdomain(subdomain); + return appUsingSubdomain === null; } diff --git a/backend/src/service/listCharts.ts b/backend/src/service/listCharts.ts new file mode 100644 index 00000000..2adac627 --- /dev/null +++ b/backend/src/service/listCharts.ts @@ -0,0 +1,38 @@ +import { getOrCreate } from "../lib/cache.ts"; +import { env } from "../lib/env.ts"; +import { getChartToken, getLatestChart } from "../lib/helm.ts"; +import { getRepositoriesByProject } from "../lib/registry.ts"; +import { ValidationError } from "./common/errors.ts"; + +export async function listCharts() { + if (!env.ALLOW_HELM_DEPLOYMENTS) { + throw new ValidationError("Helm deployments are disabled"); + } + return JSON.parse( + await getOrCreate("charts", 60 * 60, async () => + JSON.stringify(await listChartsFromRegistry()), + ), + ); +} + +const listChartsFromRegistry = async () => { + const [repos, token] = await Promise.all([ + getRepositoriesByProject(env.CHART_PROJECT_NAME), + getChartToken(), + ]); + + const charts = await Promise.all( + repos.map(async (repo) => { + return await getLatestChart(repo.name, token); + }), + ); + + return charts.filter(Boolean).map((chart) => ({ + name: chart.name, + note: chart.note, + url: `oci://${env.REGISTRY_HOSTNAME}/${env.CHART_PROJECT_NAME}/${chart.name}`, + urlType: "oci", + version: chart.version, + valueSpec: chart.values, + })); +}; diff --git a/backend/src/service/listOrgGroups.ts b/backend/src/service/listOrgGroups.ts index cb9c5e54..3fe69174 100644 --- a/backend/src/service/listOrgGroups.ts +++ b/backend/src/service/listOrgGroups.ts @@ -14,5 +14,6 @@ export async function listOrgGroups(orgId: number, userId: number) { return appGroups.map((group) => ({ id: group.id, name: group.name, + isMono: group.isMono, })); } diff --git a/backend/src/service/updateApp.ts b/backend/src/service/updateApp.ts index 08727917..890c2543 100644 --- a/backend/src/service/updateApp.ts +++ b/backend/src/service/updateApp.ts @@ -1,25 +1,26 @@ -import { randomBytes } from "node:crypto"; -import { db, NotFoundError } from "../db/index.ts"; -import type { DeploymentConfigCreate } from "../db/models.ts"; +import { db } from "../db/index.ts"; +import type { + Deployment, + DeploymentConfig, + HelmConfigCreate, + WorkloadConfigCreate, +} from "../db/models.ts"; import type { components } from "../generated/openapi.ts"; import { - createOrUpdateApp, - getClientsForRequest, -} from "../lib/cluster/kubernetes.ts"; -import { canManageProject } from "../lib/cluster/rancher.ts"; -import { createAppConfigsFromDeployment } from "../lib/cluster/resources.ts"; -import { getOctokit, getRepoById } from "../lib/octokit.ts"; -import { validateAppGroup, validateDeploymentConfig } from "../lib/validate.ts"; -import { - buildAndDeploy, - cancelAllOtherDeployments, - log, -} from "../service/githubWebhook.ts"; + MAX_GROUPNAME_LEN, + RANDOM_TAG_LEN, + getRandomTag, +} from "../lib/cluster/resources.ts"; import { AppNotFoundError, DeploymentError, ValidationError, } from "./common/errors.ts"; +import { + appService, + deploymentConfigService, + deploymentService, +} from "./helper/index.ts"; export type AppUpdate = components["schemas"]["AppUpdate"]; @@ -28,8 +29,6 @@ export async function updateApp( userId: number, appData: AppUpdate, ) { - // ---------------- Input validation ---------------- - const originalApp = await db.app.getById(appId, { requireUser: { id: userId }, }); @@ -38,56 +37,66 @@ export async function updateApp( throw new AppNotFoundError(); } - try { - await validateDeploymentConfig(appData.config); - if (appData.appGroup) { - validateAppGroup(appData.appGroup); - } - } catch (e) { - throw new ValidationError(e.message, { cause: e }); - } + const [organization, user] = await Promise.all([ + db.org.getById(originalApp.orgId, { requireUser: { id: userId } }), + db.user.getById(userId), + ]); - if (appData.projectId) { - const user = await db.user.getById(userId); - if (!(await canManageProject(user.clusterUsername, appData.projectId))) { - throw new ValidationError("Project not found"); - } - } + // performs validation + let { config: updatedConfig, commitMessage } = ( + await appService.prepareMetadataForApps(organization, user, { + type: "update", + existingAppId: originalApp.id, + ...appData, + }) + )[0]; // ---------------- App group updates ---------------- - - if (appData.appGroup?.type === "add-to") { - // Add the app to an existing group - if (appData.appGroup.id !== originalApp.appGroupId) { - try { - await db.app.setGroup(originalApp.id, appData.appGroup.id); - } catch (err) { - if (err instanceof NotFoundError) { - throw new ValidationError("App group not found"); - } + switch (appData.appGroup?.type) { + case "add-to": { + if (appData.appGroup.id === originalApp.appGroupId) { + break; + } + const group = await db.appGroup.getById(appData.appGroup.id); + if (!group) { + throw new ValidationError("Invalid app group"); } + await db.app.setGroup(originalApp.id, appData.appGroup.id); + break; } - } else if (appData.appGroup) { - // Create a new group - const name = - appData.appGroup.type === "standalone" - ? `${appData.name}-${randomBytes(4).toString("hex")}` - : appData.appGroup.name; - const newGroupId = await db.appGroup.create( - originalApp.orgId, - name, - appData.appGroup.type === "standalone", - ); + case "create-new": { + appService.validateAppGroupName(appData.appGroup.name); + const appGroupId = await db.appGroup.create( + originalApp.orgId, + appData.appGroup.name, + false, + ); + await db.app.setGroup(originalApp.id, appGroupId); + break; + } - await db.app.setGroup(originalApp.id, newGroupId); + case "standalone": { + if (appData.appGroup.type === "standalone") { + break; + } + let groupName = `${originalApp.name.substring(0, MAX_GROUPNAME_LEN - RANDOM_TAG_LEN - 1)}-${getRandomTag()}`; + appService.validateAppGroupName(groupName); + const appGroupId = await db.appGroup.create( + originalApp.orgId, + groupName, + true, + ); + await db.app.setGroup(originalApp.id, appGroupId); + break; + } } // ---------------- App model updates ---------------- const updates = {} as Record; - if (appData.name !== undefined) { - updates.displayName = appData.name; + if (appData.displayName !== undefined) { + updates.displayName = appData.displayName; } if (appData.projectId !== undefined) { @@ -102,143 +111,88 @@ export async function updateApp( await db.app.update(originalApp.id, updates); } - // ---------------- Create updated deployment configuration ---------------- - const app = await db.app.getById(originalApp.id); - const [appGroup, org, currentConfig, currentDeployment] = await Promise.all([ - db.appGroup.getById(app.appGroupId), - db.org.getById(app.orgId), + const [currentConfig, currentDeployment] = await Promise.all([ db.app.getDeploymentConfig(app.id), db.app.getCurrentDeployment(app.id), ]); - const updatedConfig: DeploymentConfigCreate = { - // Null values for unchanged sensitive vars need to be replaced with their true values - env: withSensitiveEnv(currentConfig.getEnv(), appData.config.env), - createIngress: appData.config.createIngress, - subdomain: appData.config.subdomain, - collectLogs: appData.config.collectLogs, - replicas: appData.config.replicas, - port: appData.config.port, - mounts: appData.config.mounts, - requests: appData.config.requests, - limits: appData.config.limits, - ...(appData.config.source === "git" - ? { - source: "GIT", - branch: appData.config.branch, - repositoryId: appData.config.repositoryId, - commitHash: appData.config.commitHash ?? currentConfig.commitHash, - builder: appData.config.builder, - rootDir: appData.config.rootDir, - dockerfilePath: appData.config.dockerfilePath, - event: appData.config.event, - eventId: appData.config.eventId, - } - : { - source: "IMAGE", - imageTag: appData.config.imageTag, - }), - }; - - // ---------------- Rebuild if necessary ---------------- + // Adds an image tag to Git configs + updatedConfig = deploymentConfigService.populateImageTag(updatedConfig, app); if ( - updatedConfig.source === "GIT" && - (!currentConfig.imageTag || - currentDeployment.status === "ERROR" || - updatedConfig.branch !== currentConfig.branch || - updatedConfig.repositoryId !== currentConfig.repositoryId || - updatedConfig.builder !== currentConfig.builder || - (updatedConfig.builder === "dockerfile" && - updatedConfig.dockerfilePath !== currentConfig.dockerfilePath) || - updatedConfig.rootDir !== currentConfig.rootDir || - updatedConfig.commitHash !== currentConfig.commitHash) + updatedConfig.appType === "workload" && + currentConfig.appType === "workload" ) { - // If source is git, start a new build if the app was not successfully built in the past, - // or if branches or repositories or any build settings were changed. - const octokit = await getOctokit(org.githubInstallationId); - const repo = await getRepoById(octokit, updatedConfig.repositoryId); - try { - const latestCommit = ( - await octokit.rest.repos.listCommits({ - per_page: 1, - owner: repo.owner.login, - repo: repo.name, - sha: updatedConfig.branch, - }) - ).data[0]; - - await buildAndDeploy({ - app: originalApp, - org: org, - imageRepo: originalApp.imageRepo, - commitMessage: latestCommit.commit.message, - config: updatedConfig, - createCheckRun: false, - }); + updatedConfig.env = withSensitiveEnv( + currentConfig.getEnv(), + updatedConfig.env, + ); + } - // When the new image is built and deployed successfully, it will become the imageTag of the app's template deployment config so that future redeploys use it. - } catch (err) { - throw new DeploymentError(err); - } - } else { - // ---------------- Redeploy the app with the new configuration ---------------- - const deployment = await db.deployment.create({ - config: { - ...updatedConfig, - imageTag: - // In situations where a rebuild isn't required (given when we get to this point), we need to use the previous image tag. - // Use the one that the user specified or the most recent successful one. - updatedConfig.imageTag ?? currentConfig.imageTag, + try { + await deploymentService.create({ + org: organization, + app, + commitMessage, + config: updatedConfig, + git: { + skipBuild: !shouldBuildOnUpdate( + currentConfig, + updatedConfig, + currentDeployment, + ), }, - status: "DEPLOYING", - appId: originalApp.id, - commitMessage: currentDeployment.commitMessage, }); + // When the new image is built and deployed successfully, it will become the imageTag of the app's template deployment config so that future redeploys use it. + } catch (err) { + throw new DeploymentError(err); + } +} - const config = await db.deployment.getConfig(deployment.id); +const shouldBuildOnUpdate = ( + oldConfig: DeploymentConfig, + newConfig: WorkloadConfigCreate | HelmConfigCreate, + currentDeployment: Deployment, +) => { + // Only Git apps need to be built + if (newConfig.source !== "GIT") { + return false; + } - try { - const { namespace, configs, postCreate } = - await createAppConfigsFromDeployment( - org, - app, - appGroup, - deployment, - config, - ); + // Either this app has not been built in the past, or it has not been built successfully + if ( + oldConfig.source !== "GIT" || + !oldConfig.imageTag || + currentDeployment.status === "ERROR" + ) { + return true; + } - const { KubernetesObjectApi: api } = await getClientsForRequest( - userId, - app.projectId, - ["KubernetesObjectApi"], - ); - await createOrUpdateApp(api, app.name, namespace, configs, postCreate); + // The code has changed + if ( + newConfig.branch !== oldConfig.branch || + newConfig.repositoryId != oldConfig.repositoryId || + newConfig.commitHash != oldConfig.commitHash + ) { + return true; + } - await Promise.all([ - cancelAllOtherDeployments(org, app, deployment.id, true), - db.deployment.setStatus(deployment.id, "COMPLETE"), - db.app.setConfig(appId, deployment.configId), - ]); - } catch (err) { - console.error( - `Failed to update Kubernetes resources for deployment ${deployment.id}`, - err, - ); - await db.deployment.setStatus(deployment.id, "ERROR"); - await log( - deployment.id, - "BUILD", - `Failed to update Kubernetes resources: ${JSON.stringify(err?.body ?? err)}`, - "stderr", - ); - } + // Build options have changed + if ( + newConfig.builder != oldConfig.builder || + newConfig.rootDir != oldConfig.rootDir || + (newConfig.builder === "dockerfile" && + newConfig.dockerfilePath != oldConfig.dockerfilePath) + ) { + return true; } -} + + return false; +}; // Patch the null(hidden) values of env vars sent from client with the sensitive plaintext -export const withSensitiveEnv = ( +const withSensitiveEnv = ( lastPlaintextEnv: PrismaJson.EnvVar[], envVars: { name: string; diff --git a/backend/src/service/updateDeployment.ts b/backend/src/service/updateDeployment.ts index 6025644b..069e40af 100644 --- a/backend/src/service/updateDeployment.ts +++ b/backend/src/service/updateDeployment.ts @@ -14,19 +14,38 @@ export async function updateDeployment(secret: string, newStatus: string) { if (!secret) { throw new ValidationError("No deployment secret provided."); } - - if (!["BUILDING", "DEPLOYING", "ERROR"].some((it) => newStatus === it)) { - throw new ValidationError("Invalid status."); - } const deployment = await db.deployment.getFromSecret(secret); if (!deployment) { throw new DeploymentNotFoundError(); } + const config = await db.deployment.getConfig(deployment.id); + if (config.source === "IMAGE") { + throw new ValidationError("Cannot update deployment"); + } + + switch (config.source) { + case "GIT": { + if (!["BUILDING", "DEPLOYING", "ERROR"].some((it) => newStatus === it)) { + throw new ValidationError("Invalid status."); + } + break; + } + case "HELM": { + if (!["DEPLOYING", "COMPLETE", "ERROR"].some((it) => newStatus === it)) { + throw new ValidationError("Invalid status."); + } + break; + } + default: { + throw new ValidationError("Invalid source."); + } + } + await db.deployment.setStatus( deployment.id, - newStatus as "BUILDING" | "DEPLOYING" | "ERROR", + newStatus as "BUILDING" | "DEPLOYING" | "COMPLETE" | "ERROR", ); log( @@ -35,10 +54,13 @@ export async function updateDeployment(secret: string, newStatus: string) { "Deployment status has been updated to " + newStatus, ); + if (config.source != "GIT") { + return; + } + const app = await db.app.getById(deployment.appId); - const [appGroup, config, org] = await Promise.all([ + const [appGroup, org] = await Promise.all([ db.appGroup.getById(app.appGroupId), - db.deployment.getConfig(deployment.id), db.org.getById(app.orgId), ]); @@ -94,10 +116,10 @@ export async function updateDeployment(secret: string, newStatus: string) { await Promise.all([ db.deployment.setStatus(deployment.id, "COMPLETE"), // The update was successful. Update App with the reference to the latest successful config. - db.app.setConfig(app.id, config.id), + db.app.setConfig(app.id, deployment.configId), ]); - dequeueBuildJob(); // TODO - error handling for this line + await dequeueBuildJob(); } catch (err) { console.error(err); await db.deployment.setStatus(deployment.id, "ERROR"); diff --git a/builders/dockerfile/Dockerfile b/builders/dockerfile/Dockerfile index 41386cea..a19a7e25 100644 --- a/builders/dockerfile/Dockerfile +++ b/builders/dockerfile/Dockerfile @@ -6,7 +6,7 @@ RUN apt-get update && \ add-apt-repository ppa:git-core/ppa && \ apt-get update && \ # ^ Get the latest version of Git from their PPA (we need 2.49.0 or later because we use the `git clone --revision` flag) - apt-get install -y --no-install-recommends git=1:2.52.0-0ppa1~ubuntu24.04.1 && \ + apt-get install -y --no-install-recommends git=1:2.52.0-0ppa1~ubuntu24.04.3 && \ rm -rf /var/lib/apt/lists/* RUN groupadd -g 65532 -r appuser && \ diff --git a/builders/helm/Dockerfile b/builders/helm/Dockerfile new file mode 100644 index 00000000..f6aa46da --- /dev/null +++ b/builders/helm/Dockerfile @@ -0,0 +1,10 @@ +FROM alpine/helm:3.19.0 + +RUN addgroup -g 65532 appuser && \ + adduser -u 65532 -G appuser -D appuser + +COPY --chown=appuser:appuser --chmod=500 pre-stop.sh /var/run +COPY --chown=appuser:appuser --chmod=500 docker-entrypoint.sh /var/run + +ENTRYPOINT ["/bin/sh"] +CMD ["-c", "/var/run/docker-entrypoint.sh"] \ No newline at end of file diff --git a/builders/helm/docker-entrypoint.sh b/builders/helm/docker-entrypoint.sh new file mode 100644 index 00000000..4a508c77 --- /dev/null +++ b/builders/helm/docker-entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +set -eo pipefail + +set_status() { + wget -q --header="Content-Type: application/json" --post-data "{\"secret\":\"$DEPLOYMENT_API_SECRET\",\"status\":\"$1\"}" -O- "$DEPLOYMENT_API_URL/deployment/update" +} + +run_job() { + helm $HELM_ARGS +} + +set_status "DEPLOYING" + +if run_job ; then + set_status "COMPLETE" +else + set_status "ERROR" + exit 1 +fi \ No newline at end of file diff --git a/builders/helm/pre-stop.sh b/builders/helm/pre-stop.sh new file mode 100644 index 00000000..f47a5321 --- /dev/null +++ b/builders/helm/pre-stop.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +# This script is executed when the pod is forcefully terminated + +set_status() { + wget -q --header="Content-Type: application/json" --post-data "{\"secret\":\"$DEPLOYMENT_API_SECRET\",\"status\":\"$1\"}" -O- "$DEPLOYMENT_API_URL/deployment/update" +} + +set_status "ERROR" diff --git a/builders/railpack/Dockerfile b/builders/railpack/Dockerfile index 6c15b992..f415ddb7 100644 --- a/builders/railpack/Dockerfile +++ b/builders/railpack/Dockerfile @@ -8,7 +8,7 @@ RUN apt-get update && \ add-apt-repository ppa:git-core/ppa && \ apt-get update && \ # ^ Get the latest version of Git from their PPA (we need 2.49.0 or later because we use the `git clone --revision` flag) - apt-get install -y --no-install-recommends git=1:2.52.0-0ppa1~ubuntu24.04.1 && \ + apt-get install -y --no-install-recommends git=1:2.52.0-0ppa1~ubuntu24.04.3 && \ apt-get remove -y software-properties-common && \ apt-get autoremove -y && \ rm -rf /var/lib/apt/lists/* diff --git a/charts/anvilops/Chart.yaml b/charts/anvilops/Chart.yaml index 482bc2f3..196d2d69 100644 --- a/charts/anvilops/Chart.yaml +++ b/charts/anvilops/Chart.yaml @@ -15,7 +15,7 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.1.1 +version: 0.1.2 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to diff --git a/charts/anvilops/templates/anvilops/anvilops-deployment.yaml b/charts/anvilops/templates/anvilops/anvilops-deployment.yaml index e75af78d..5b19b055 100644 --- a/charts/anvilops/templates/anvilops/anvilops-deployment.yaml +++ b/charts/anvilops/templates/anvilops/anvilops-deployment.yaml @@ -41,7 +41,6 @@ spec: {{- end }} image: {{ .Values.anvilops.image | quote }} imagePullPolicy: {{ .Values.anvilops.imagePullPolicy }} - args: ["./src/index.ts"] ports: - name: http containerPort: 3000 @@ -219,6 +218,10 @@ spec: value: {{ .Values.anvilops.env.appDomain }} - name: HARBOR_PROJECT_NAME value: {{ .Values.anvilops.env.harborProjectName }} + - name: CHART_PROJECT_NAME + value: {{ .Values.anvilops.env.harborChartRepoName }} + - name: ALLOW_HELM_DEPLOYMENTS + value: "{{ .Values.anvilops.env.allowHelmDeployments }}" - name: BUILDKITD_ADDRESS value: {{ .Values.buildkitd.address }} - name: FILE_BROWSER_IMAGE @@ -227,27 +230,14 @@ spec: value: {{ .Values.anvilops.env.dockerfileBuilderImage }} - name: RAILPACK_BUILDER_IMAGE value: {{ .Values.anvilops.env.railpackBuilderImage }} + - name: HELM_DEPLOYER_IMAGE + value: {{ .Values.anvilops.env.helmDeployerImage }} - name: LOG_SHIPPER_IMAGE value: {{ .Values.anvilops.env.logShipperImage }} {{- with .Values.anvilops.env.registryProtocol }} - name: REGISTRY_PROTOCOL value: {{ . }} {{- end }} - securityContext: - capabilities: - drop: [ALL] - runAsNonRoot: true - runAsUser: 65532 - runAsGroup: 65532 - readOnlyRootFilesystem: true - allowPrivilegeEscalation: false - resources: - requests: - cpu: 512m - memory: 512Mi - limits: - cpu: 1000m - memory: 1Gi volumes: - name: cluster-config configMap: diff --git a/charts/anvilops/values.yaml b/charts/anvilops/values.yaml index 0fb2f8c0..fb349462 100644 --- a/charts/anvilops/values.yaml +++ b/charts/anvilops/values.yaml @@ -12,12 +12,15 @@ anvilops: baseURL: http://anvilops.minikube.local appDomain: http://minikube.local harborProjectName: anvilops + harborChartRepoName: anvilops-chart allowedIdps: https://idp.purdue.edu/idp/shibboleth loginType: shibboleth fileBrowserImage: registry.anvil.rcac.purdue.edu/anvilops/file-browser:latest dockerfileBuilderImage: registry.anvil.rcac.purdue.edu/anvilops/dockerfile-builder:latest railpackBuilderImage: registry.anvil.rcac.purdue.edu/anvilops/railpack-builder:latest + helmDeployerImage: registry.anvil.rcac.purdue.edu/anvilops/helm-deployer:latest logShipperImage: registry.anvil.rcac.purdue.edu/anvilops/log-shipper:latest + allowHelmDeployments: false # This is for the secrets for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ imagePullSecrets: [] @@ -27,9 +30,13 @@ anvilops: fullnameOverride: "" securityContext: - runAsUser: 1001 - runAsGroup: 1001 + capabilities: + drop: [ALL] runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false # This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ serviceAccount: @@ -66,7 +73,7 @@ anvilops: resources: requests: - cpu: 500m + cpu: 512m memory: 512Mi limits: cpu: 1000m diff --git a/charts/spilo/Chart.yaml b/charts/spilo/Chart.yaml new file mode 100644 index 00000000..18456d7d --- /dev/null +++ b/charts/spilo/Chart.yaml @@ -0,0 +1,42 @@ +apiVersion: v1 +name: spilo +description: A minimal Spilo/Patroni HA Postgres deployment, adapted from https://github.com/zalando/spilo/kubernetes. +type: application +version: 0.1.1 +annotations: + anvilops-note: | + Requires permission to create Roles and RoleBindings. Rancher Project Owners have this permission. + The usernames of the superuser, admin, and replication user respectively are 'postgres', 'admin', and 'standby'. + anvilops-values: | + { + "passwords": [ + { + "name": "superuser", + "displayName": "Superuser Password", + "random": true + }, + { + "name": "replication", + "displayName": "Replication Password", + "random": true + }, + { + "name": "admin", + "displayName": "Admin Password", + "random": "true" + } + ], + "storage": [ + { + "name": "className", + "displayName": "Storage Class", + "default": "standard" + }, + { + "name": "size", + "displayName": "Size", + "type": "number", + "unit": "Gi" + } + ] + } \ No newline at end of file diff --git a/charts/spilo/templates/_helpers.tpl b/charts/spilo/templates/_helpers.tpl new file mode 100644 index 00000000..24cdd87a --- /dev/null +++ b/charts/spilo/templates/_helpers.tpl @@ -0,0 +1,12 @@ +{{- define "spilo.name" -}} +{{- .Chart.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "spilo.fullname" -}} +{{- printf "%s" .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "spilo.labels" -}} +application: spilo +spilo-cluster: {{ include "spilo.fullname" . }} +{{- end -}} \ No newline at end of file diff --git a/charts/spilo/templates/endpoints.yaml b/charts/spilo/templates/endpoints.yaml new file mode 100644 index 00000000..befbf9d6 --- /dev/null +++ b/charts/spilo/templates/endpoints.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Endpoints +metadata: + name: {{ include "spilo.fullname" . }} + labels: + {{- include "spilo.labels" . | nindent 4 }} +subsets: [] \ No newline at end of file diff --git a/charts/spilo/templates/role.yaml b/charts/spilo/templates/role.yaml new file mode 100644 index 00000000..385a774c --- /dev/null +++ b/charts/spilo/templates/role.yaml @@ -0,0 +1,14 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: operator +rules: +- apiGroups: [""] + resources: ["configmaps"] + verbs: ["create","get","list","patch","update","watch","delete"] +- apiGroups: [""] + resources: ["endpoints"] + verbs: ["create","get","list","watch","patch","update","delete"] +- apiGroups: [""] + resources: ["pods"] + verbs: ["get","list","watch","patch","update"] \ No newline at end of file diff --git a/charts/spilo/templates/rolebinding.yaml b/charts/spilo/templates/rolebinding.yaml new file mode 100644 index 00000000..fa62bcae --- /dev/null +++ b/charts/spilo/templates/rolebinding.yaml @@ -0,0 +1,11 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: operator +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: operator +subjects: +- kind: ServiceAccount + name: operator \ No newline at end of file diff --git a/charts/spilo/templates/secret.yaml b/charts/spilo/templates/secret.yaml new file mode 100644 index 00000000..5977d14c --- /dev/null +++ b/charts/spilo/templates/secret.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "spilo.fullname" . }} + labels: + {{- include "spilo.labels" . | nindent 4 }} +type: Opaque +data: + superuser-password: {{ .Values.passwords.superuser | b64enc | quote }} + replication-password: {{ .Values.passwords.replication | b64enc | quote }} + admin-password: {{ .Values.passwords.admin | b64enc | quote }} diff --git a/charts/spilo/templates/service-config.yaml b/charts/spilo/templates/service-config.yaml new file mode 100644 index 00000000..ce5fbeaa --- /dev/null +++ b/charts/spilo/templates/service-config.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "spilo.fullname" . }}-config + labels: + {{- include "spilo.labels" . | nindent 4 }} +spec: + clusterIP: None diff --git a/charts/spilo/templates/service-read.yaml b/charts/spilo/templates/service-read.yaml new file mode 100644 index 00000000..638c6fc7 --- /dev/null +++ b/charts/spilo/templates/service-read.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "spilo.fullname" . }}-repl + labels: + {{- include "spilo.labels" . | nindent 4 }} + role: replica +spec: + type: ClusterIP + selector: + {{- include "spilo.labels" . | nindent 4 }} + role: replica + ports: + - name: postgres + port: 5432 + targetPort: 5432 \ No newline at end of file diff --git a/charts/spilo/templates/service-write.yaml b/charts/spilo/templates/service-write.yaml new file mode 100644 index 00000000..9102a856 --- /dev/null +++ b/charts/spilo/templates/service-write.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "spilo.fullname" . }} + labels: + {{- include "spilo.labels" . | nindent 4 }} +spec: + type: ClusterIP + ports: + - name: postgresql + port: 5432 + targetPort: 5432 \ No newline at end of file diff --git a/charts/spilo/templates/serviceaccount.yaml b/charts/spilo/templates/serviceaccount.yaml new file mode 100644 index 00000000..5f5e99bd --- /dev/null +++ b/charts/spilo/templates/serviceaccount.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: operator \ No newline at end of file diff --git a/charts/spilo/templates/statefulset.yaml b/charts/spilo/templates/statefulset.yaml new file mode 100644 index 00000000..fe59cda5 --- /dev/null +++ b/charts/spilo/templates/statefulset.yaml @@ -0,0 +1,72 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "spilo.fullname" . }} + labels: + {{- include "spilo.labels" . | nindent 4 }} +spec: + selector: + matchLabels: + {{- include "spilo.labels" . | nindent 6 }} + replicas: {{ .Values.replicas }} + serviceName: {{ include "spilo.fullname" . }} + template: + metadata: + labels: + {{- include "spilo.labels" . | nindent 8 }} + spec: + serviceAccountName: operator + containers: + - name: spilo + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - { containerPort: 8008, protocol: TCP } + - { containerPort: 5432, protocol: TCP } + volumeMounts: + - name: pgdata + mountPath: /home/postgres/pgdata + env: + - name: DCS_ENABLE_KUBERNETES_API + value: 'true' + - name: KUBERNETES_SCOPE_LABEL + value: spilo-cluster + - name: KUBERNETES_ROLE_LABEL + value: role + - name: KUBERNETES_LEADER_LABEL_VALUE + value: master + - name: KUBERNETES_STANDBY_LEADER_LABEL_VALUE + value: master + - name: SPILO_CONFIGURATION + value: | + bootstrap: + initdb: + - auth-host: md5 + - auth-local: trust + - name: POD_IP + valueFrom: { fieldRef: { apiVersion: v1, fieldPath: status.podIP } } + - name: POD_NAMESPACE + valueFrom: { fieldRef: { apiVersion: v1, fieldPath: metadata.namespace } } + - name: PGPASSWORD_SUPERUSER + valueFrom: { secretKeyRef: { name: {{ include "spilo.fullname" . }}, key: superuser-password } } + - name: PGUSER_ADMIN + value: superadmin + - name: PGPASSWORD_ADMIN + valueFrom: { secretKeyRef: { name: {{ include "spilo.fullname" . }}, key: admin-password } } + - name: PGPASSWORD_STANDBY + valueFrom: { secretKeyRef: { name: {{ include "spilo.fullname" . }}, key: replication-password } } + - name: SCOPE + value: {{ include "spilo.fullname" . }} + - name: PGROOT + value: /home/postgres/pgdata/pgroot + volumeClaimTemplates: + - metadata: + name: pgdata + labels: + {{- include "spilo.labels" . | nindent 8 }} + spec: + accessModes: ["ReadWriteOnce"] + storageClassName: {{ .Values.storage.className | quote }} + resources: + requests: + storage: {{ .Values.storage.size | quote }} diff --git a/charts/spilo/values.schema.json b/charts/spilo/values.schema.json new file mode 100644 index 00000000..dd9f94dc --- /dev/null +++ b/charts/spilo/values.schema.json @@ -0,0 +1,48 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "required": ["passwords", "storage"], + "properties": { + "passwords": { + "type": "object", + "required": ["superuser", "replication", "admin"], + "properties": { + "superuser": { + "type": "string" + }, + "replication": { + "type": "string" + }, + "admin": { + "type": "string" + } + } + }, + "storage": { + "type": "object", + "required": ["className", "size"], + "properties": { + "className": { + "type": "string" + }, + "sizeInGi": { + "type": "number" + } + } + }, + "image": { + "type": "object", + "required": ["repository", "tag"], + "properties": { + "repository": { + "type": "string" + }, + "tag": { + "type": "string" + } + } + }, + "replicas": { + "type": "number" + } + } +} diff --git a/charts/spilo/values.yaml b/charts/spilo/values.yaml new file mode 100644 index 00000000..997fe7d0 --- /dev/null +++ b/charts/spilo/values.yaml @@ -0,0 +1,9 @@ +passwords: {} + +storage: {} + +image: + repository: registry.opensource.zalan.do/acid/spilo-11 + tag: "1.5-p5" + +replicas: 3 diff --git a/frontend/src/components/ConfigVar.tsx b/frontend/src/components/ConfigVar.tsx deleted file mode 100644 index d1a74726..00000000 --- a/frontend/src/components/ConfigVar.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Input } from "@/components/ui/input"; - -export default function ConfigVar({ - name, - value, - namePlaceholder, - valuePlaceholder, - secret = false, -}: { - name?: string; - value?: string; - namePlaceholder?: string; - valuePlaceholder?: string; - secret?: boolean; -}) { - return ( -
- - -
- ); -} diff --git a/frontend/src/components/PagedView.tsx b/frontend/src/components/PagedView.tsx deleted file mode 100644 index 83825c76..00000000 --- a/frontend/src/components/PagedView.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Button } from "./ui/button"; -import { useState } from "react"; - -export const PagedView = ({ - children, - submitButton, -}: { - children: React.ReactNode[]; - submitButton: React.ReactNode; -}) => { - const [idx, setIdx] = useState(0); - return ( -
- {children.map((child, i) => { - if (i === idx) { - return child; - } - return
{child}
; - })} -
- {idx > 0 && ( - - )} - {idx < children.length - 1 ? ( - - ) : ( -
{submitButton}
- )} -
-
- ); -}; diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx deleted file mode 100644 index bb08f977..00000000 --- a/frontend/src/components/ProtectedRoute.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Route, Navigate } from "react-router-dom"; -import { UserContext } from "./UserProvider"; -import React from "react"; - -export default function ProtectedRoute({ - path, - element, -}: React.ComponentProps) { - const { user } = React.useContext(UserContext); - return ( - } /> - ); -} diff --git a/frontend/src/components/config/AppConfigFormFields.tsx b/frontend/src/components/config/AppConfigFormFields.tsx new file mode 100644 index 00000000..414f27ac --- /dev/null +++ b/frontend/src/components/config/AppConfigFormFields.tsx @@ -0,0 +1,131 @@ +import { useAppConfig } from "@/components/AppConfigProvider"; +import { UserContext } from "@/components/UserProvider"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { components } from "@/generated/openapi"; +import { + makeFunctionalWorkloadSetter, + makeHelmSetter, + makeImageSetter, +} from "@/lib/form"; +import type { CommonFormFields, GroupFormFields } from "@/lib/form.types"; +import { Cable } from "lucide-react"; +import { useContext } from "react"; +import { ProjectConfig } from "./ProjectConfig"; +import { HelmConfigFields } from "./helm/HelmConfigFields"; +import { CommonWorkloadConfigFields } from "./workload/CommonWorkloadConfigFields"; +import { GitConfigFields } from "./workload/git/GitConfigFields"; +import { ImageConfigFields } from "./workload/image/ImageConfigFields"; + +export const AppConfigFormFields = ({ + groupState, + state, + setState, + disabled, + originalConfig, +}: { + groupState: GroupFormFields; + state: CommonFormFields; + setState: (updater: (prev: CommonFormFields) => CommonFormFields) => void; + disabled?: boolean; + originalConfig?: components["schemas"]["DeploymentConfig"]; +}) => { + const appConfig = useAppConfig(); + + const { user } = useContext(UserContext); + const selectedOrg = + groupState.orgId !== undefined + ? user?.orgs?.find((it) => it.id === groupState.orgId) + : undefined; + + const imageSetter = makeImageSetter(setState); + + const helmSetter = makeHelmSetter(setState); + + const commonWorkloadSetter = makeFunctionalWorkloadSetter(setState); + + return ( + <> + {appConfig.isRancherManaged && ( + + )} +

Source Options

+
+
+ + + * + +
+ +
+ {state.source === "git" && selectedOrg && ( + + )} + {state.source === "image" && ( + + )} + {state.source === "helm" && ( + + )} + {state.appType === "workload" && + (state.source !== "git" || selectedOrg?.githubConnected) && ( + + )} + + ); +}; diff --git a/frontend/src/components/config/GroupConfigFields.tsx b/frontend/src/components/config/GroupConfigFields.tsx new file mode 100644 index 00000000..49054e99 --- /dev/null +++ b/frontend/src/components/config/GroupConfigFields.tsx @@ -0,0 +1,164 @@ +import { Label } from "@/components/ui/label"; +import { Component, X } from "lucide-react"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { GroupFormFields } from "@/lib/form.types"; +import { useMemo, type Dispatch, type SetStateAction } from "react"; +import { api } from "@/lib/api"; +import { Input } from "@/components/ui/input"; + +export const GroupConfigFields = ({ + state, + setState, + disabled, +}: { + state: GroupFormFields; + setState: Dispatch>; + disabled?: boolean; +}) => { + const { orgId, groupOption } = state; + const { data: groups, isPending: groupsLoading } = api.useQuery( + "get", + "/org/{orgId}/groups", + { params: { path: { orgId: orgId! } } }, + { + enabled: orgId !== undefined, + }, + ); + + const multiGroups = groups?.filter((group) => !group.isMono); + const groupName = + groupOption?.type === "create-new" ? groupOption.name : undefined; + + const shouldDisplayGroupNameError = useMemo(() => { + const MAX_GROUP_LENGTH = 56; + if (!groupName) return true; + return ( + groupName.length > MAX_GROUP_LENGTH || + !groupName.match(/^[a-zA-Z0-9][ a-zA-Z0-9-_\.]*$/) + ); + }, [groupName]); + + return ( + <> +

Grouping Options

+
+
+ + + * + +
+

+ Applications can be created as standalone apps, or as part of a group + of related microservices. +

+ + + {groupOption?.type === "create-new" && ( + <> +
+ + + * + +
+ + setState({ + ...state, + groupOption: { ...groupOption, name: e.currentTarget.value }, + }) + } + autoComplete="off" + /> + {groupName && shouldDisplayGroupNameError && ( +
+ +
    +
  • A group name must have 56 or fewer characters.
  • +
  • + A group name must contain only alphanumeric characters, + dashes, underscores, dots, and spaces. +
  • +
  • + A group name must start with an alphanumeric character. +
  • +
+
+ )} + + )} +
+ + ); +}; diff --git a/frontend/src/components/config/ProjectConfig.tsx b/frontend/src/components/config/ProjectConfig.tsx new file mode 100644 index 00000000..dd968f13 --- /dev/null +++ b/frontend/src/components/config/ProjectConfig.tsx @@ -0,0 +1,75 @@ +import { UserContext } from "@/components/UserProvider"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { CommonFormFields } from "@/lib/form.types"; +import { Fence } from "lucide-react"; +import { useContext } from "react"; + +export const ProjectConfig = ({ + state, + setState, + disabled, +}: { + state: CommonFormFields; + setState: (updater: (prev: CommonFormFields) => CommonFormFields) => void; + disabled?: boolean; +}) => { + const { user } = useContext(UserContext); + + return ( +
+
+
+ + + * + +
+

+ In clusters managed by Rancher, resources are organized into projects + for administration. +

+
+ +
+ ); +}; diff --git a/frontend/src/components/config/helm/HelmConfigFields.tsx b/frontend/src/components/config/helm/HelmConfigFields.tsx new file mode 100644 index 00000000..cc392387 --- /dev/null +++ b/frontend/src/components/config/helm/HelmConfigFields.tsx @@ -0,0 +1,14 @@ +import type { HelmFormFields } from "@/lib/form.types"; + +//@ts-ignore +export const HelmConfigFields = ({ + state, + setState, + disabled, +}: { + state: HelmFormFields; + setState: (update: HelmFormFields) => void; + disabled?: boolean; +}) => { + return
; +}; diff --git a/frontend/src/components/config/workload/CommonWorkloadConfigFields.tsx b/frontend/src/components/config/workload/CommonWorkloadConfigFields.tsx new file mode 100644 index 00000000..694c4b3c --- /dev/null +++ b/frontend/src/components/config/workload/CommonWorkloadConfigFields.tsx @@ -0,0 +1,359 @@ +import { useAppConfig } from "@/components/AppConfigProvider"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { components } from "@/generated/openapi"; +import { api } from "@/lib/api"; +import { MAX_SUBDOMAIN_LENGTH } from "@/lib/form"; +import type { WorkloadFormFields, WorkloadUpdate } from "@/lib/form.types"; +import { useDebouncedValue } from "@/lib/utils"; +import { FormContext, SubdomainStatus } from "@/pages/create-app/CreateAppView"; +import { + Code2, + Cog, + Cpu, + Database, + Link, + Loader, + Logs, + MemoryStick, + Server, + X, +} from "lucide-react"; +import { useContext } from "react"; +import { EnvVarGrid } from "./EnvVarGrid"; +import { MountsGrid } from "./MountsGrid"; + +export const CommonWorkloadConfigFields = ({ + state, + setState, + disabled, + originalConfig, +}: { + state: WorkloadFormFields; + setState: (update: WorkloadUpdate) => void; + disabled?: boolean; + originalConfig?: components["schemas"]["DeploymentConfig"]; +}) => { + const appConfig = useAppConfig(); + const appDomain = URL.parse(appConfig?.appDomain ?? ""); + const { + port, + env, + mounts, + subdomain, + createIngress, + cpuCores, + memoryInMiB, + collectLogs, + } = state; + + const showSubdomainError = + !!subdomain && + (subdomain.length > MAX_SUBDOMAIN_LENGTH || + subdomain.match(/^[a-z0-9](?:[a-z0-9\-]*[a-z0-9])?$/) === null); + + const context = useContext(FormContext); + const isExistingApp = context === "UpdateApp" && !!originalConfig; + + const originalSubdomain = + isExistingApp && originalConfig?.appType === "workload" + ? originalConfig.subdomain + : undefined; + const debouncedSub = useDebouncedValue(subdomain); + + const enableSubdomainCheck = + !!subdomain && + subdomain === debouncedSub && + subdomain !== originalSubdomain && + !showSubdomainError; + + const { data: subStatus, isPending: subLoading } = api.useQuery( + "get", + "/app/subdomain", + { + params: { + query: { + subdomain: debouncedSub ?? "", + }, + }, + }, + { enabled: enableSubdomainCheck }, + ); + + const fixedSensitiveNames = + originalConfig?.appType === "workload" + ? new Set( + originalConfig.env + .filter((env) => env.isSensitive) + .map((env) => env.name), + ) + : new Set(); + + return ( + <> +

Deployment Options

+ {appDomain !== null && ( +
+
+ + {createIngress && ( + + * + + )} +
+ +
+ + {appDomain?.protocol}// + + { + const subdomain = e.currentTarget.value + .toLowerCase() + .replace(/[^a-z0-9-]/, "-"); + setState({ subdomain }); + }} + autoComplete="off" + /> + + .{appDomain?.host} + +
+ {subdomain && showSubdomainError && ( +
+ +
    +
  • A subdomain must have 54 or fewer characters.
  • +
  • + A subdomain must only contain lowercase alphanumeric + characters or dashes(-). +
  • +
  • + A subdomain must start and end with an alphanumeric character. +
  • +
+
+ )} + {subdomain && + !showSubdomainError && + subdomain !== originalSubdomain && + (subdomain !== debouncedSub || subLoading ? ( + + Checking subdomain... + + ) : ( + <> + + + ))} +
+ )} +
+
+ + + * + +
+ { + setState({ port: e.currentTarget.value }); + }} + /> +
+
+
+ + + * + +
+
+ + + * + +
+ { + setState({ cpuCores: e.currentTarget.value }); + }} + /> +
+ { + setState({ memoryInMiB: e.currentTarget.value }); + }} + /> + MiB +
+
+ + + + + + + { + setState((prev) => ({ ...prev, env: updater(prev.env) })); + }} + fixedSensitiveNames={fixedSensitiveNames} + disabled={disabled ?? false} + /> + + + {appConfig.storageEnabled && ( + + + + + + {!!isExistingApp && ( +

+ Volume mounts cannot be edited after an app has been created. +

+ )} +

+ Preserve files contained at these paths across app restarts. All + other files will be discarded. Every replica will get its own + separate volume. +

+ { + setState((prev) => ({ + ...prev, + mounts: updater(prev.mounts), + })); + }} + /> +
+
+ )} + {isExistingApp && ( + + + + + +
+
+ +

+ When this setting is disabled, you will only be able to view + logs from the most recent, alive pod from your app's most + recent deployment. +

+
+ { + setState({ + collectLogs: checked === true, + }); + }} + /> + +
+
+
+
+
+ )} +
+ + ); +}; diff --git a/frontend/src/components/EnvVarGrid.tsx b/frontend/src/components/config/workload/EnvVarGrid.tsx similarity index 69% rename from frontend/src/components/EnvVarGrid.tsx rename to frontend/src/components/config/workload/EnvVarGrid.tsx index 88932880..5902cf5e 100644 --- a/frontend/src/components/EnvVarGrid.tsx +++ b/frontend/src/components/config/workload/EnvVarGrid.tsx @@ -1,9 +1,9 @@ import { Trash2 } from "lucide-react"; -import { Fragment, useEffect, useState, type Dispatch } from "react"; -import HelpTooltip from "./HelpTooltip"; -import { Button } from "./ui/button"; -import { Checkbox } from "./ui/checkbox"; -import { Input } from "./ui/input"; +import { Fragment, useEffect, useState } from "react"; +import HelpTooltip from "@/components/HelpTooltip"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; type EnvVars = { name: string; value: string | null; isSensitive: boolean }[]; @@ -15,7 +15,7 @@ export const EnvVarGrid = ({ disabled = false, }: { value: EnvVars; - setValue: Dispatch>; + setValue: (updater: (envVars: EnvVars) => EnvVars) => void; fixedSensitiveNames: Set; disabled: boolean; }) => { @@ -75,17 +75,22 @@ export const EnvVarGrid = ({ } value={name} onChange={(e) => { - const newList = structuredClone(envVars); - newList[index].name = e.currentTarget.value; - const duplicates = getDuplicates(newList); - if (duplicates.length != 0) { - setError( - `Duplicate environment variable(s): ${duplicates.join(", ")}`, - ); - } else { - setError(""); - } - setEnvironmentVariables(newList); + const value = e.currentTarget.value; + setEnvironmentVariables((prev) => { + const newList = prev.toSpliced(index, 1, { + ...prev[index], + name: value, + }); + const duplicates = getDuplicates(newList); + if (duplicates.length != 0) { + setError( + `Duplicate environment variable(s): ${duplicates.join(", ")}`, + ); + } else { + setError(""); + } + return newList; + }); }} /> = @@ -96,9 +101,14 @@ export const EnvVarGrid = ({ className="w-full" value={value ?? ""} onChange={(e) => { - const newList = structuredClone(envVars); - newList[index].value = e.currentTarget.value; - setEnvironmentVariables(newList); + const value = e.currentTarget.value; + setEnvironmentVariables((prev) => { + const newList = prev.toSpliced(index, 1, { + ...prev[index], + value: value, + }); + return newList; + }); }} autoComplete="off" autoCorrect="off" @@ -111,10 +121,12 @@ export const EnvVarGrid = ({ disabled={disabled || isFixedSensitive} checked={isSensitive} onCheckedChange={(checked) => { - const newList = structuredClone(envVars); - newList[index].isSensitive = - checked === "indeterminate" ? false : checked; - setEnvironmentVariables(newList); + setEnvironmentVariables((prev) => + prev.toSpliced(index, 1, { + ...prev[index], + isSensitive: checked === true, + }), + ); }} /> @@ -122,9 +134,9 @@ export const EnvVarGrid = ({ disabled={disabled} variant="secondary" type="button" - onClick={() => { - setEnvironmentVariables(envVars.filter((_, i) => i !== index)); - }} + onClick={() => + setEnvironmentVariables((prev) => prev.toSpliced(index, 1)) + } > diff --git a/frontend/src/components/MountsGrid.tsx b/frontend/src/components/config/workload/MountsGrid.tsx similarity index 71% rename from frontend/src/components/MountsGrid.tsx rename to frontend/src/components/config/workload/MountsGrid.tsx index 7a647d4a..aeacbf72 100644 --- a/frontend/src/components/MountsGrid.tsx +++ b/frontend/src/components/config/workload/MountsGrid.tsx @@ -1,9 +1,9 @@ import { TooltipTrigger } from "@radix-ui/react-tooltip"; import { Trash2 } from "lucide-react"; -import { Fragment, useEffect, type Dispatch } from "react"; -import { Button } from "./ui/button"; -import { Input } from "./ui/input"; -import { Tooltip, TooltipContent } from "./ui/tooltip"; +import { Fragment, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Tooltip, TooltipContent } from "@/components/ui/tooltip"; export type Mounts = { path: string; amountInMiB: number }[]; @@ -14,7 +14,7 @@ export const MountsGrid = ({ }: { readonly?: boolean; value: Mounts; - setValue: Dispatch>; + setValue: (updater: (mounts: Mounts) => Mounts) => void; }) => { useEffect(() => { for (let i in mounts) { @@ -41,9 +41,13 @@ export const MountsGrid = ({ className="w-full" value={path} onChange={(e) => { - const newList = structuredClone(mounts); - newList[index].path = e.currentTarget.value; - setMounts(newList); + const value = e.currentTarget.value; + setMounts((prev) => + prev.toSpliced(index, 1, { + ...prev[index], + path: value, + }), + ); }} /> : @@ -56,9 +60,13 @@ export const MountsGrid = ({ min="1" max="10240" onChange={(e) => { - const newList = structuredClone(mounts); - newList[index].amountInMiB = e.currentTarget.valueAsNumber; - setMounts(newList); + const value = e.currentTarget.valueAsNumber; + setMounts((prev) => + prev.toSpliced(index, 1, { + ...prev[index], + amountInMiB: value, + }), + ); }} /> @@ -72,7 +80,7 @@ export const MountsGrid = ({ variant="secondary" type="button" onClick={() => { - setMounts(mounts.filter((_, i) => i !== index)); + setMounts((mounts) => mounts.toSpliced(index, 1)); }} > diff --git a/frontend/src/pages/create-app/GitDeploymentFields.tsx b/frontend/src/components/config/workload/git/EnabledGitConfigFields.tsx similarity index 85% rename from frontend/src/pages/create-app/GitDeploymentFields.tsx rename to frontend/src/components/config/workload/git/EnabledGitConfigFields.tsx index 575df816..6cdf36f5 100644 --- a/frontend/src/pages/create-app/GitDeploymentFields.tsx +++ b/frontend/src/components/config/workload/git/EnabledGitConfigFields.tsx @@ -1,4 +1,4 @@ -import { ImportRepoDialog } from "@/components/ImportRepoDialog"; +import { ImportRepoDialog } from "./ImportRepoDialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; @@ -11,7 +11,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { UserContext } from "@/components/UserProvider"; import { api } from "@/lib/api"; import clsx from "clsx"; import { @@ -23,37 +22,42 @@ import { GitBranch, Hammer, } from "lucide-react"; -import { useContext, useEffect, useState } from "react"; -import type { AppInfoFormData } from "./AppConfigFormFields"; +import { useEffect, useState } from "react"; +import type { CommonFormFields, GitFormFields } from "@/lib/form.types"; -export const GitDeploymentFields = ({ +export const EnabledGitConfigFields = ({ orgId, - state, + gitState, setState, - disabled = false, + disabled, }: { orgId?: number; - state: Pick< - AppInfoFormData, - | "builder" - | "repositoryId" - | "event" - | "eventId" - | "source" - | "branch" - | "rootDir" - | "dockerfilePath" - >; - setState: React.Dispatch>; - disabled: boolean; + gitState: GitFormFields; + setState: (updater: (prev: CommonFormFields) => CommonFormFields) => void; + disabled?: boolean; }) => { - const { builder, repositoryId, event, eventId, source } = state; - - const { user } = useContext(UserContext); - - const selectedOrg = - orgId !== undefined ? user?.orgs?.find((it) => it.id === orgId) : undefined; + const setGitState = (update: Partial) => { + setState((s) => ({ + ...s, + workload: { + ...s.workload, + git: { + ...s.workload.git, + ...update, + }, + }, + })); + }; + const { + builder, + repositoryId, + event, + eventId, + rootDir, + dockerfilePath, + branch, + } = gitState; const { data: repos, isPending: reposLoading, @@ -63,8 +67,7 @@ export const GitDeploymentFields = ({ "/org/{orgId}/repos", { params: { path: { orgId: orgId! } } }, { - enabled: - orgId !== undefined && source === "git" && selectedOrg?.githubConnected, + enabled: orgId !== undefined, }, ); @@ -80,8 +83,7 @@ export const GitDeploymentFields = ({ }, }, { - enabled: - orgId !== undefined && repositoryId !== undefined && source === "git", + enabled: orgId !== undefined && repositoryId !== undefined, }, ); @@ -100,36 +102,27 @@ export const GitDeploymentFields = ({ enabled: orgId !== undefined && repositoryId !== undefined && - source === "git" && event === "workflow_run", }, ); useEffect(() => { - setState((prev) => ({ - ...prev, - branch: branches?.default ?? branches?.branches?.[0], - })); + if (!branch) { + setGitState({ branch: branches?.default ?? branches?.branches?.[0] }); + } }, [branches]); const [importDialogShown, setImportDialogShown] = useState(false); return ( <> - {selectedOrg?.id && ( + {orgId !== undefined && ( { await refreshRepos(); }} - setRepo={(repositoryId, repoName) => - setState((prev) => ({ - ...prev, - repositoryId, - repoName, - })) - } setState={setState} /> )} @@ -161,11 +154,10 @@ export const GitDeploymentFields = ({ if (repo === "$import-repo") { setImportDialogShown(true); } else if (repo) { - setState((prev) => ({ - ...prev, + setGitState({ repositoryId: typeof repo === "string" ? parseInt(repo) : repo, repoName: repos?.find((r) => r?.id === parseInt(repo))?.name, - })); + }); } }} value={repositoryId?.toString() ?? ""} @@ -228,9 +220,9 @@ export const GitDeploymentFields = ({ required name="branch" disabled={disabled || repositoryId === undefined || branchesLoading} - value={state.branch ?? ""} + value={branch ?? ""} onValueChange={(branch) => { - setState((prev) => ({ ...prev, branch })); + setGitState({ branch }); }} > @@ -273,12 +265,9 @@ export const GitDeploymentFields = ({ required disabled={disabled} name="branch" - value={state.event ?? ""} + value={event ?? ""} onValueChange={(event) => { - setState((prev) => ({ - ...prev, - event: event as "push" | "workflow_run", - })); + setGitState({ event: event as "push" | "workflow_run" }); }} > @@ -324,9 +313,9 @@ export const GitDeploymentFields = ({ branchesLoading || workflows?.workflows?.length === 0 } - value={eventId ?? ""} + value={eventId?.toString() ?? ""} onValueChange={(eventId) => { - setState((prev) => ({ ...prev, eventId })); + setGitState({ eventId: parseInt(eventId) }); }} > @@ -371,10 +360,10 @@ export const GitDeploymentFields = ({ { const rootDir = e.currentTarget.value; - setState((state) => ({ ...state, rootDir })); + setGitState({ rootDir }); }} name="rootDir" id="rootDir" @@ -406,10 +395,7 @@ export const GitDeploymentFields = ({ id="builder" value={builder} onValueChange={(newValue) => - setState((prev) => ({ - ...prev, - builder: newValue as "dockerfile" | "railpack", - })) + setGitState({ builder: newValue as "dockerfile" | "railpack" }) } required > @@ -451,10 +437,10 @@ export const GitDeploymentFields = ({ name="dockerfilePath" id="dockerfilePath" placeholder="Dockerfile" - value={state.dockerfilePath} + value={dockerfilePath ?? ""} onChange={(e) => { const dockerfilePath = e.currentTarget.value; - setState((state) => ({ ...state, dockerfilePath })); + setGitState({ dockerfilePath }); }} className="w-full" autoComplete="off" diff --git a/frontend/src/components/config/workload/git/GitConfigFields.tsx b/frontend/src/components/config/workload/git/GitConfigFields.tsx new file mode 100644 index 00000000..a48a865c --- /dev/null +++ b/frontend/src/components/config/workload/git/GitConfigFields.tsx @@ -0,0 +1,59 @@ +import { EnabledGitConfigFields } from "./EnabledGitConfigFields"; +import type { components } from "@/generated/openapi"; +import type { CommonFormFields, GitFormFields } from "@/lib/form.types"; +import { GitHubIcon } from "@/pages/create-app/CreateAppView"; +import { Button } from "@/components/ui/button"; + +export const GitConfigFields = ({ + selectedOrg, + gitState, + setState, + disabled, +}: { + selectedOrg: components["schemas"]["UserOrg"]; + gitState: GitFormFields; + setState: (updater: (prev: CommonFormFields) => CommonFormFields) => void; + disabled?: boolean; +}) => { + if (!selectedOrg?.githubConnected) { + if (selectedOrg?.permissionLevel === "OWNER") { + return ( +
+

+ {selectedOrg?.name} has not been connected to + GitHub. +

+

+ AnvilOps integrates with GitHub to deploy your app as soon as you + push to your repository. +

+ + + +
+ ); + } else { + return ( +

+ {selectedOrg?.name} has not been connected to GitHub. + Ask the owner of your organization to install the AnvilOps GitHub App. +

+ ); + } + } + + return ( + + ); +}; diff --git a/frontend/src/components/ImportRepoDialog.tsx b/frontend/src/components/config/workload/git/ImportRepoDialog.tsx similarity index 92% rename from frontend/src/components/ImportRepoDialog.tsx rename to frontend/src/components/config/workload/git/ImportRepoDialog.tsx index eb7e7ebb..d8145852 100644 --- a/frontend/src/components/ImportRepoDialog.tsx +++ b/frontend/src/components/config/workload/git/ImportRepoDialog.tsx @@ -1,14 +1,13 @@ -import { api } from "@/lib/api"; -import type { AppInfoFormData } from "@/pages/create-app/AppConfigFormFields"; -import { FormContext } from "@/pages/create-app/CreateAppView"; -import { Info, Library, Loader, X } from "lucide-react"; -import { useContext, useState, type Dispatch } from "react"; -import { toast } from "sonner"; -import { Button } from "./ui/button"; -import { Checkbox } from "./ui/checkbox"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"; -import { Input } from "./ui/input"; -import { Label } from "./ui/label"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { Select, SelectContent, @@ -17,23 +16,41 @@ import { SelectLabel, SelectTrigger, SelectValue, -} from "./ui/select"; +} from "@/components/ui/select"; +import { api } from "@/lib/api"; +import type { CommonFormFields } from "@/lib/form.types"; +import { FormContext } from "@/pages/create-app/CreateAppView"; +import { Info, Library, Loader, X } from "lucide-react"; +import { useContext, useState, type Dispatch } from "react"; +import { toast } from "sonner"; export const ImportRepoDialog = ({ orgId, open, setOpen, refresh, - setRepo, setState, }: { orgId: number; open: boolean; setOpen: Dispatch; refresh: () => Promise; - setRepo: (id: number, name: string) => void; - setState: Dispatch>; + setState: (updater: (prev: CommonFormFields) => CommonFormFields) => void; }) => { + const setRepo = (id: number, name: string) => { + setState((s) => ({ + ...s, + workload: { + ...s.workload, + git: { + ...s.workload.git, + repositoryId: id, + repoName: name, + }, + }, + })); + }; + const { data: installation } = api.useQuery( "get", "/org/{orgId}/installation", diff --git a/frontend/src/components/config/workload/image/ImageConfigFields.tsx b/frontend/src/components/config/workload/image/ImageConfigFields.tsx new file mode 100644 index 00000000..81791c75 --- /dev/null +++ b/frontend/src/components/config/workload/image/ImageConfigFields.tsx @@ -0,0 +1,43 @@ +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { ImageFormFields } from "@/lib/form.types"; +import { Tag } from "lucide-react"; + +export const ImageConfigFields = ({ + imageState, + setImageState, + disabled, +}: { + imageState: ImageFormFields; + setImageState: (update: Partial) => void; + disabled?: boolean; +}) => { + const { imageTag } = imageState; + return ( +
+
+ + + * + +
+ { + setImageState({ imageTag: e.currentTarget.value }); + }} + name="imageTag" + id="imageTag" + placeholder="nginx:latest" + className="w-full" + /> +
+ ); +}; diff --git a/frontend/src/components/diff/AppConfigDiff.tsx b/frontend/src/components/diff/AppConfigDiff.tsx new file mode 100644 index 00000000..fc2db06b --- /dev/null +++ b/frontend/src/components/diff/AppConfigDiff.tsx @@ -0,0 +1,126 @@ +import { UserContext } from "@/components/UserProvider"; +import { Label } from "@/components/ui/label"; +import { SelectContent, SelectGroup, SelectItem } from "@/components/ui/select"; +import { + getFormStateFromApp, + makeFunctionalWorkloadSetter, + makeGitSetter, + makeHelmSetter, + makeImageSetter, +} from "@/lib/form"; +import type { CommonFormFields } from "@/lib/form.types"; +import { Cable } from "lucide-react"; +import { useContext } from "react"; +import type { App } from "../../pages/app/AppView"; +import { DiffSelect } from "./DiffSelect"; +import { HelmConfigDiff } from "./helm/HelmConfigDiff"; +import { CommonWorkloadConfigDiff } from "./workload/CommonWorkloadConfigDiff"; +import { GitConfigDiff } from "./workload/git/GitConfigDiff"; +import { ImageConfigDiff } from "./workload/image/ImageConfigDiff"; + +export const AppConfigDiff = ({ + orgId, + base, + state, + setState, + disabled = false, +}: { + orgId: number; + base: App; + state: Omit; + setState: (callback: (state: CommonFormFields) => CommonFormFields) => void; + disabled?: boolean; +}) => { + const { user } = useContext(UserContext); + // const appConfig = useAppConfig(); + const selectedOrg = orgId + ? user?.orgs?.find((it) => it.id === orgId) + : undefined; + + const baseFormState = getFormStateFromApp(base); + + const setImageState = makeImageSetter(setState); + const setGitState = makeGitSetter(setState); + const setHelmState = makeHelmSetter(setState); + const setWorkloadState = makeFunctionalWorkloadSetter(setState); + + return ( +
+

Source Options

+
+
+ + + * + +
+
+ + setState((prev) => ({ + ...prev, + source: source as "helm" | "git" | "image", + })) + } + leftPlaceholder="Select deployment source" + rightPlaceholder="Select deployment source" + > + + + Git Repository + OCI Image + {/* {appConfig.allowHelmDeployments && Helm Chart} */} + + + +
+
+ + {state.source === "git" && ( + + )} + {state.source === "image" && ( + + )} + + {state.source === "helm" && ( + + )} + {state.appType === "workload" && + (state.source !== "git" || selectedOrg?.githubConnected) && ( + + )} +
+ ); +}; diff --git a/frontend/src/components/diff/DiffInput.tsx b/frontend/src/components/diff/DiffInput.tsx new file mode 100644 index 00000000..57ca666b --- /dev/null +++ b/frontend/src/components/diff/DiffInput.tsx @@ -0,0 +1,43 @@ +import type { ComponentProps } from "react"; +import { Input } from "@/components/ui/input"; +import { MoveRight } from "lucide-react"; + +export const DiffInput = ({ + left, + right, + setRight, + leftPlaceholder = "(N/A)", + disabled = false, + id, + ...inputProps +}: Omit, "value" | "onChange"> & { + left: string | undefined; + right: string | undefined; + setRight: (value: string) => void; + leftPlaceholder?: string; + id?: string; +}) => { + const isDifferent = (!!left || !!right) && (left ?? "") !== (right ?? ""); + + return ( +
+ + + setRight(e.currentTarget.value)} + disabled={disabled} + className={isDifferent ? "bg-green-50" : ""} + /> +
+ ); +}; diff --git a/frontend/src/components/diff/DiffSelect.tsx b/frontend/src/components/diff/DiffSelect.tsx new file mode 100644 index 00000000..b75d57cc --- /dev/null +++ b/frontend/src/components/diff/DiffSelect.tsx @@ -0,0 +1,48 @@ +import type { ComponentProps } from "react"; +import { + Select, + SelectContent, + SelectTrigger, + SelectValue, +} from "../ui/select"; +import { MoveRight } from "lucide-react"; + +export const DiffSelect = ({ + left, + right, + setRight, + leftPlaceholder = "(N/A)", + rightPlaceholder, + children: selectContent, + leftContent, + id, + ...props +}: ComponentProps & { + left?: string; + right?: string; + setRight: (value: string) => void; + leftPlaceholder?: string; + rightPlaceholder?: string; + children: React.ReactElement>; + leftContent?: React.ReactElement>; + id?: string; +}) => { + const isDifferent = (!!left || !!right) && (left ?? "") !== (right ?? ""); + return ( +
+ + + +
+ ); +}; diff --git a/frontend/src/components/diff/helm/HelmConfigDiff.tsx b/frontend/src/components/diff/helm/HelmConfigDiff.tsx new file mode 100644 index 00000000..2bf4d01c --- /dev/null +++ b/frontend/src/components/diff/helm/HelmConfigDiff.tsx @@ -0,0 +1,16 @@ +import type { CommonFormFields, HelmFormFields } from "@/lib/form.types"; + +//@ts-ignore +export const HelmConfigDiff = ({ + base, + helmState, + setHelmState, + disabled, +}: { + base: CommonFormFields; + helmState: HelmFormFields; + setHelmState: (state: Partial) => void; + disabled: boolean; +}) => { + return
; +}; diff --git a/frontend/src/components/diff/workload/CommonWorkloadConfigDiff.tsx b/frontend/src/components/diff/workload/CommonWorkloadConfigDiff.tsx new file mode 100644 index 00000000..7a9a5d4e --- /dev/null +++ b/frontend/src/components/diff/workload/CommonWorkloadConfigDiff.tsx @@ -0,0 +1,244 @@ +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Label } from "@/components/ui/label"; +import { + Code2, + Cog, + Cpu, + MemoryStick, + Scale3D, + Server, + Terminal, +} from "lucide-react"; +import { DiffInput } from "../DiffInput"; +import type { + CommonFormFields, + WorkloadFormFields, + WorkloadUpdate, +} from "@/lib/form.types"; +import { EnvsWithDiffs } from "@/components/diff/workload/EnvsWithDiffs"; +import { SelectContent, SelectGroup, SelectItem } from "@/components/ui/select"; +import { useAppConfig } from "@/components/AppConfigProvider"; +import { SubdomainDiff } from "./SubdomainDiff"; +import { DiffSelect } from "../DiffSelect"; + +export const CommonWorkloadConfigDiff = ({ + base, + workloadState, + setWorkloadState, + disabled, +}: { + base: CommonFormFields; + workloadState: WorkloadFormFields; + setWorkloadState: (update: WorkloadUpdate) => void; + disabled: boolean; +}) => { + const appConfig = useAppConfig(); + const baseWorkloadState = base.appType === "workload" ? base.workload : null; + + const fixedSensitiveNames = new Set( + baseWorkloadState?.env + .filter((env) => env.isSensitive) + .map((env) => env.name) ?? [], + ); + + return ( + <> +

Deployment Options

+ {appConfig.appDomain && ( + + )} +
+
+ + + * + +
+
+ { + setWorkloadState({ port }); + }} + /> +
+
+
+
+ + + * + +
+
+ { + setWorkloadState({ replicas }); + }} + /> +
+
+
+
+ + + * + +
+
+ { + setWorkloadState({ cpuCores }); + }} + /> +
+
+
+
+ + + * + +
+
+ { + setWorkloadState({ memoryInMiB }); + }} + /> +
+
+ + + + + + + + setWorkloadState((prev) => ({ + ...prev, + env: updater(prev.env), + })) + } + fixedSensitiveNames={fixedSensitiveNames} + /> + + + + + + + +
+
+ +

+ When this setting is disabled, you will only be able to view + logs from the most recent, alive pod from your app's most + recent deployment. +

+
+
+ { + setWorkloadState({ collectLogs: collectLogs === "true" }); + }} + defaultValue="true" + > + + + Enabled + Disabled + + + +
+
+
+
+
+ + ); +}; diff --git a/frontend/src/pages/app/overview/EnvsWithDiffs.tsx b/frontend/src/components/diff/workload/EnvsWithDiffs.tsx similarity index 78% rename from frontend/src/pages/app/overview/EnvsWithDiffs.tsx rename to frontend/src/components/diff/workload/EnvsWithDiffs.tsx index 75f143e5..429e51ef 100644 --- a/frontend/src/pages/app/overview/EnvsWithDiffs.tsx +++ b/frontend/src/components/diff/workload/EnvsWithDiffs.tsx @@ -3,11 +3,10 @@ import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Trash2 } from "lucide-react"; -import { Fragment, useEffect, useState, type Dispatch } from "react"; +import { Fragment, useEffect, useState } from "react"; type EnvVars = { name: string; value: string | null; isSensitive: boolean }[]; -// TODO: show error message on duplicate env names export const EnvsWithDiffs = ({ base, value: envVars, @@ -17,7 +16,7 @@ export const EnvsWithDiffs = ({ }: { base: EnvVars; value: EnvVars; - setValue: Dispatch>; + setValue: (updater: (envVars: EnvVars) => EnvVars) => void; fixedSensitiveNames: Set; disabled?: boolean; }) => { @@ -94,17 +93,22 @@ export const EnvsWithDiffs = ({ } value={name} onChange={(e) => { - const newList = structuredClone(envVars); - newList[index].name = e.currentTarget.value; - const duplicates = getDuplicates(newList); - if (duplicates.length != 0) { - setError( - `Duplicate environment variable(s): ${duplicates.join(", ")}`, - ); - } else { - setError(""); - } - setEnvironmentVariables(newList); + const value = e.currentTarget.value; + setEnvironmentVariables((prev) => { + const newList = prev.toSpliced(index, 1, { + ...prev[index], + name: value, + }); + const duplicates = getDuplicates(newList); + if (duplicates.length != 0) { + setError( + `Duplicate environment variable(s): ${duplicates.join(", ")}`, + ); + } else { + setError(""); + } + return newList; + }); }} /> = @@ -115,9 +119,14 @@ export const EnvsWithDiffs = ({ className="w-full" value={value ?? ""} onChange={(e) => { - const newList = structuredClone(envVars); - newList[index].value = e.currentTarget.value; - setEnvironmentVariables(newList); + const value = e.currentTarget.value; + setEnvironmentVariables((prev) => { + const newList = prev.toSpliced(index, 1, { + ...prev[index], + value: value, + }); + return newList; + }); }} autoComplete="off" autoCorrect="off" @@ -130,10 +139,14 @@ export const EnvsWithDiffs = ({ disabled={disabled || isFixedSensitive} checked={isSensitive} onCheckedChange={(checked) => { - const newList = structuredClone(envVars); - newList[index].isSensitive = - checked === "indeterminate" ? false : checked; - setEnvironmentVariables(newList); + setEnvironmentVariables((prev) => { + const newList = prev.toSpliced(index, 1, { + ...prev[index], + isSensitive: + checked === "indeterminate" ? false : checked, + }); + return newList; + }); }} /> @@ -142,7 +155,9 @@ export const EnvsWithDiffs = ({ variant="secondary" type="button" onClick={() => { - setEnvironmentVariables(envVars.filter((_, i) => i !== index)); + setEnvironmentVariables((prev) => + prev.filter((_, i) => i !== index), + ); }} > diff --git a/frontend/src/components/diff/workload/SubdomainDiff.tsx b/frontend/src/components/diff/workload/SubdomainDiff.tsx new file mode 100644 index 00000000..45af50c8 --- /dev/null +++ b/frontend/src/components/diff/workload/SubdomainDiff.tsx @@ -0,0 +1,205 @@ +import type { + CommonFormFields, + WorkloadFormFields, + WorkloadUpdate, +} from "@/lib/form.types"; +import { SelectContent, SelectGroup, SelectItem } from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { useAppConfig } from "@/components/AppConfigProvider"; +import { Label } from "@/components/ui/label"; +import { Link, Loader, MoveRight, X } from "lucide-react"; +import { MAX_SUBDOMAIN_LENGTH } from "@/lib/form"; +import { cn, useDebouncedValue } from "@/lib/utils"; +import { api } from "@/lib/api"; +import { DiffSelect } from "../DiffSelect"; +import type { ComponentProps } from "react"; +import { SubdomainStatus } from "@/pages/create-app/CreateAppView"; + +export const SubdomainDiff = ({ + base, + workloadState, + setWorkloadState, + disabled, +}: { + base: CommonFormFields; + workloadState: WorkloadFormFields; + setWorkloadState: (update: WorkloadUpdate) => void; + disabled: boolean; +}) => { + const baseWorkloadState = base.appType === "workload" ? base.workload : null; + + const { createIngress, subdomain } = workloadState; + return ( + <> + { + setWorkloadState({ createIngress: createIngress === "true" }); + }} + name="createIngress" + id="createIngress" + > + + + Expose app + Do not expose app + + + +
+
+ + {createIngress && ( + + * + + )} +
+
+ { + subdomain = subdomain.toLowerCase().replace(/[^a-z0-9-]/, "-"); + setWorkloadState({ subdomain }); + }} + placeholder="my-app" + pattern="[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?" + autoComplete="off" + /> + + ); +}; + +const SubdomainDiffInput = ({ + left, + right, + setRight, + required, + disabled, + leftPlaceholder = "(N/A)", + id, + ...inputProps +}: ComponentProps & { + left: string | undefined; + right: string | undefined; + setRight: (value: string) => void; + leftPlaceholder?: string; + id: string; +}) => { + const appConfig = useAppConfig(); + const appDomain = URL.parse(appConfig?.appDomain ?? ""); + const showSubdomainError = + !!right && + right !== left && + (right.length > MAX_SUBDOMAIN_LENGTH || + right.match(/^[a-z0-9](?:[a-z0-9\-]*[a-z0-9])?$/) === null); + + const debouncedSub = useDebouncedValue(right); + const enableSubdomainCheck = + !!right && right === debouncedSub && right !== left && !showSubdomainError; + + const { data: subStatus, isPending: subLoading } = api.useQuery( + "get", + "/app/subdomain", + { + params: { + query: { + subdomain: debouncedSub ?? "", + }, + }, + }, + { enabled: enableSubdomainCheck }, + ); + + const isDifferent = (!!left || !!right) && (left ?? "") !== (right ?? ""); + return ( +
+ {left ? ( +
+ + {appDomain?.protocol}// + + + + .{appDomain?.host} + +
+ ) : ( + + )} + +
+ + {appDomain?.protocol}// + + { + const subdomain = e.currentTarget.value + .toLowerCase() + .replace(/[^a-z0-9-]/, "-"); + setRight(subdomain); + }} + /> + + .{appDomain?.host} + +
+
+ {showSubdomainError && ( +
+ +
    +
  • A subdomain must have 54 or fewer characters.
  • +
  • + A subdomain must only contain lowercase alphanumeric characters + or dashes(-). +
  • +
  • + A subdomain must start and end with an alphanumeric character. +
  • +
+
+ )} + {right && + !showSubdomainError && + right !== left && + (right !== debouncedSub || subLoading ? ( + + Checking subdomain... + + ) : ( + + ))} +
+
+ ); +}; diff --git a/frontend/src/components/diff/workload/git/GitConfigDiff.tsx b/frontend/src/components/diff/workload/git/GitConfigDiff.tsx new file mode 100644 index 00000000..47dafd95 --- /dev/null +++ b/frontend/src/components/diff/workload/git/GitConfigDiff.tsx @@ -0,0 +1,486 @@ +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, +} from "@/components/ui/select"; +import { api } from "@/lib/api"; +import { cn } from "@/lib/utils"; +import { + BookMarked, + ClipboardCheck, + CloudUpload, + Container, + FolderRoot, + GitBranch, + Hammer, +} from "lucide-react"; +import { DiffInput } from "@/components/diff/DiffInput"; +import type { components } from "@/generated/openapi"; +import type { CommonFormFields, GitFormFields } from "@/lib/form.types"; +import { Button } from "@/components/ui/button"; +import { GitHubIcon } from "@/pages/create-app/CreateAppView"; +import { DiffSelect } from "../../DiffSelect"; + +export const GitConfigDiff = ({ + selectedOrg, + base, + gitState, + setGitState, + disabled = false, +}: { + selectedOrg?: components["schemas"]["UserOrg"]; + base: CommonFormFields; + gitState: GitFormFields; + setGitState: (state: Partial) => void; + disabled?: boolean; +}) => { + if (!selectedOrg?.githubConnected) { + if (selectedOrg?.permissionLevel === "OWNER") { + return ( +
+

+ {selectedOrg?.name} has not been connected to + GitHub. +

+

+ AnvilOps integrates with GitHub to deploy your app as soon as you + push to your repository. +

+ + + +
+ ); + } else { + return ( +

+ {selectedOrg?.name} has not been connected to GitHub. + Ask the owner of your organization to install the AnvilOps GitHub App. +

+ ); + } + } + + const baseGitState = base.source === "git" ? base.workload.git : null; + + const orgId = selectedOrg.id; + const { data: repos, isPending: reposLoading } = api.useQuery( + "get", + "/org/{orgId}/repos", + { params: { path: { orgId: selectedOrg.id } } }, + ); + + const { data: branches, isPending: branchesLoading } = api.useQuery( + "get", + "/org/{orgId}/repos/{repoId}/branches", + { + params: { + path: { + orgId: orgId!, + repoId: gitState.repositoryId!, + }, + }, + }, + { + enabled: gitState.repositoryId !== undefined, + }, + ); + + const { data: workflows, isPending: workflowsLoading } = api.useQuery( + "get", + "/org/{orgId}/repos/{repoId}/workflows", + { + params: { + path: { + orgId: orgId!, + repoId: gitState.repositoryId!, + }, + }, + }, + { + enabled: + gitState.repositoryId !== undefined && + gitState.event === "workflow_run", + }, + ); + + const { data: baseWorkflows } = api.useQuery( + "get", + "/org/{orgId}/repos/{repoId}/workflows", + { + params: { + path: { + orgId: orgId!, + repoId: baseGitState?.repositoryId!, + }, + }, + }, + { + enabled: + baseGitState?.repositoryId !== undefined && + baseGitState?.event === "workflow_run", + refetchInterval: false, + }, + ); + + const baseWorkflowName = baseWorkflows?.workflows?.find( + (workflow) => workflow.id === baseGitState?.eventId, + )?.name; + + return ( + <> +
+
+ + + * + +
+
+ + setGitState({ + repositoryId: typeof repo === "string" ? parseInt(repo) : repo, + repoName: repos?.find((r) => r?.id === parseInt(repo))?.name, + branch: undefined, + eventId: undefined, + }) + } + right={gitState.repositoryId?.toString() ?? ""} + rightPlaceholder="Select a repository" + disabled={disabled || reposLoading} + > + + + {repos !== undefined + ? Object.entries( + Object.groupBy(repos, (repo) => repo.owner!), + ).map(([owner, repos]) => ( + + {owner} + {repos?.map((repo) => ( + + {repo.owner}/{repo.name} + + ))} + + )) + : null} + + + +
+
+
+
+ + + * + +
+
+ setGitState({ branch })} + rightPlaceholder={ + branchesLoading && gitState.repositoryId + ? "Loading..." + : "Select a branch" + } + leftContent={ + + + + {baseGitState?.branch} + + + + } + > + + + {gitState.repositoryId !== undefined && + branches?.branches?.map((branch) => { + return ( + + {branch} + + ); + })} + + + +
+
+
+
+ + + * + +
+
+ + setGitState({ event: event as "push" | "workflow_run" }) + } + > + + Push + + Successful workflow run + + + +
+
+ {gitState.event === "workflow_run" && ( +
+
+ + + * + +
+
+ + setGitState({ eventId: parseInt(eventId) }) + } + rightPlaceholder={ + workflowsLoading || workflows!.workflows!.length > 0 + ? "Select a workflow" + : "No workflows available" + } + leftContent={ + + + + {baseWorkflowName} + + + + } + > + + + {gitState.repositoryId !== undefined && + workflows?.workflows?.map((workflow) => { + return ( + + {workflow.name} + + ); + })} + + + +
+
+ )} +

Build Options

+
+
+ + + * + +
+
+ setGitState({ rootDir })} + name="rootDir" + id="rootDir" + placeholder="./" + pattern="^\.\/.*$" + autoComplete="off" + required + /> +
+

+ Root directory must start with ./ +

+
+
+
+ + + * + +
+ + setGitState({ builder: newValue as "dockerfile" | "railpack" }) + } + required + > + + + +
+ {gitState.builder === "dockerfile" ? ( +
+ +
+ setGitState({ dockerfilePath })} + autoComplete="off" + required + /> +
+

+ Relative to the root directory. +

+
+ ) : null} + + ); +}; diff --git a/frontend/src/components/diff/workload/image/ImageConfigDiff.tsx b/frontend/src/components/diff/workload/image/ImageConfigDiff.tsx new file mode 100644 index 00000000..afd16adf --- /dev/null +++ b/frontend/src/components/diff/workload/image/ImageConfigDiff.tsx @@ -0,0 +1,48 @@ +import type { CommonFormFields, ImageFormFields } from "@/lib/form.types"; +import { Tag } from "lucide-react"; +import { Label } from "@/components/ui/label"; +import { DiffInput } from "@/components/diff/DiffInput"; + +export const ImageConfigDiff = ({ + base, + imageState, + setImageState, + disabled, +}: { + base: CommonFormFields; + imageState: ImageFormFields; + setImageState: (state: Partial) => void; + disabled: boolean; +}) => { + const baseImageState = base.source === "image" ? base.workload.image : null; + + return ( +
+
+ + + * + +
+
+ { + setImageState({ imageTag }); + }} + name="imageTag" + id="imageTag" + placeholder="nginx:latest" + required + /> +
+
+ ); +}; diff --git a/frontend/src/lib/form.ts b/frontend/src/lib/form.ts new file mode 100644 index 00000000..e07fe725 --- /dev/null +++ b/frontend/src/lib/form.ts @@ -0,0 +1,320 @@ +import type { components } from "@/generated/openapi"; +import type { App } from "@/pages/app/AppView"; +import type { + CommonFormFields, + GitFormFields, + GroupFormFields, + HelmFormFields, + ImageFormFields, + WorkloadFormFields, + WorkloadUpdate, +} from "./form.types"; + +export const MAX_SUBDOMAIN_LENGTH = 54; + +const createDefaultGitState = ( + git?: Partial, +): GitFormFields => ({ + builder: "railpack", + dockerfilePath: "./Dockerfile", + rootDir: "./", + event: "push", + repoName: "", + ...(git ?? {}), +}); + +const getDefaultImageState = () => ({ imageTag: "" }); +const createDefaultWorkloadState = ( + git?: Partial, +): WorkloadFormFields => ({ + port: "", + replicas: "1", + env: [], + mounts: [], + subdomain: "", + createIngress: true, + collectLogs: true, + cpuCores: "1", + memoryInMiB: "1024", + git: createDefaultGitState(git), + image: getDefaultImageState(), +}); + +export const createDefaultCommonFormFields = ( + git?: Partial, +): CommonFormFields => ({ + appType: "workload", + source: "git", + projectId: null, + workload: createDefaultWorkloadState(git), + helm: { + urlType: "oci", + }, +}); + +export const createDeploymentConfig = ( + formFields: Required, +): components["schemas"]["DeploymentConfig"] => { + if (formFields.appType === "workload") { + const workloadConfig = formFields.workload as Required; + const cpu = Math.round(parseFloat(workloadConfig.cpuCores) * 1000) + "m"; + const memory = workloadConfig.memoryInMiB + "Mi"; + + const workloadOptions: components["schemas"]["KnownDeploymentOptions"] = { + appType: "workload", + port: parseInt(workloadConfig.port), + replicas: parseInt(workloadConfig.replicas), + env: workloadConfig.env.filter((env) => env.name.length > 0), + mounts: workloadConfig.mounts.filter((mount) => mount.path.length > 0), + createIngress: workloadConfig.createIngress, + subdomain: workloadConfig.createIngress ? workloadConfig.subdomain : null, + collectLogs: workloadConfig.collectLogs, + limits: { cpu, memory }, + requests: { cpu, memory }, + }; + switch (formFields.source) { + case "git": + return { + ...workloadOptions, + ...createGitDeploymentOptions( + workloadConfig.git as Required, + ), + }; + + case "image": + return { + ...workloadOptions, + ...createImageDeploymentOptions( + workloadConfig.image as Required, + ), + }; + } + } else { + const helmConfig = formFields.helm as Required; + return { + ...helmConfig, + source: "helm", + appType: "helm", + }; + } + + throw new Error("Invalid app type"); +}; + +const generateNamespace = (appState: Required): string => { + if (appState.appType === "workload" && appState.workload.createIngress) { + return appState.workload.subdomain as string; + } + return ( + getAppName(appState).replaceAll(/[^a-zA-Z0-9-_]/g, "_") + + "-" + + Math.floor(Math.random() * 10_000) + ); +}; + +export const createNewAppWithoutGroup = ( + appState: Required, +): components["schemas"]["NewAppWithoutGroupInfo"] => { + return { + name: getAppName(appState), + namespace: generateNamespace(appState), + projectId: appState.projectId ?? undefined, + config: createDeploymentConfig(appState), + }; +}; + +const createGitDeploymentOptions = ( + gitFields: Required, +): components["schemas"]["GitDeploymentOptions"] => { + return { + source: "git", + repositoryId: gitFields.repositoryId!, + branch: gitFields.branch, + rootDir: gitFields.rootDir, + ...(gitFields.event === "push" + ? { + event: "push", + eventId: null, + } + : { + event: "workflow_run", + eventId: gitFields.eventId, + }), + ...(gitFields.builder === "dockerfile" + ? { + builder: "dockerfile", + dockerfilePath: gitFields.dockerfilePath, + } + : { + builder: "railpack", + }), + }; +}; + +const createImageDeploymentOptions = ( + imageFields: Required, +): components["schemas"]["ImageDeploymentOptions"] => { + return { + source: "image", + imageTag: imageFields.imageTag, + }; +}; + +const getCleanedAppName = (name: string) => + name.length > 0 + ? name + .toLowerCase() + .substring(0, 60) + .replace(/[^a-z0-9-]/g, "") + : "New App"; + +export const getAppName = ({ + source, + workload, + helm, +}: Pick): string => { + switch (source) { + case "git": { + const gitConfig = workload.git as Required; + return getCleanedAppName(gitConfig.repoName); + } + case "image": { + const imageConfig = workload.image as Required; + const image = imageConfig.imageTag.split("/"); + const imageName = image[image.length - 1].split(":")[0]; + return getCleanedAppName(imageName); + } + case "helm": + return getCleanedAppName(helm.url!); + default: + throw new Error("Invalid source"); + } +}; + +export const getGroupStateFromApp = (app: App): GroupFormFields => { + return { + orgId: app.orgId, + groupOption: { + type: app.appGroup.standalone ? "standalone" : "add-to", + id: app.appGroup.id, + }, + }; +}; + +export const getFormStateFromApp = ( + app: Pick, +): CommonFormFields => { + return { + displayName: app.displayName, + projectId: app.projectId ?? null, + appType: app.config.appType, + source: app.config.source, + workload: + app.config.appType === "workload" + ? getWorkloadFormFieldsFromAppConfig(app.config) + : createDefaultWorkloadState(), + helm: + app.config.appType === "helm" + ? getHelmFormFieldsFromAppConfig(app.config) + : { + urlType: "oci", + }, + }; +}; + +const getCpuCores = (cpu: string) => { + return (parseFloat(cpu.replace("m", "")) / 1000).toString(); +}; + +const getWorkloadFormFieldsFromAppConfig = ( + config: components["schemas"]["DeploymentConfig"] & { appType: "workload" }, +): WorkloadFormFields => { + return { + port: config.port.toString(), + replicas: config.replicas.toString(), + env: config.env, + mounts: config.mounts, + subdomain: config.subdomain ?? "", + createIngress: config.createIngress, + collectLogs: config.collectLogs, + cpuCores: config.requests?.cpu ? getCpuCores(config.requests?.cpu) : "1", + memoryInMiB: config.requests?.memory?.replace("Mi", "") ?? "1024", + git: + config.source === "git" + ? { + builder: config.builder, + dockerfilePath: config.dockerfilePath ?? "./Dockerfile", + rootDir: config.rootDir, + event: config.event, + eventId: config.eventId, + repositoryId: config.repositoryId, + branch: config.branch, + repoName: "", + } + : createDefaultGitState(), + image: + config.source === "image" + ? { + imageTag: config.imageTag, + } + : getDefaultImageState(), + }; +}; + +const getHelmFormFieldsFromAppConfig = ( + config: components["schemas"]["DeploymentConfig"] & { appType: "helm" }, +): HelmFormFields => { + return { + url: config.url, + urlType: config.urlType, + version: config.version, + values: config.values, + }; +}; + +export const makeImageSetter = ( + setState: (updater: (prev: CommonFormFields) => CommonFormFields) => void, +) => { + return (update: Partial) => { + setState((prev) => ({ + ...prev, + workload: { + ...prev.workload, + image: { ...prev.workload.image, ...update }, + }, + })); + }; +}; + +export const makeGitSetter = ( + setState: (updater: (prev: CommonFormFields) => CommonFormFields) => void, +) => { + return (update: Partial) => { + setState((prev) => ({ + ...prev, + workload: { ...prev.workload, git: { ...prev.workload.git, ...update } }, + })); + }; +}; + +export const makeHelmSetter = ( + setState: (updater: (prev: CommonFormFields) => CommonFormFields) => void, +) => { + return (update: Partial) => { + setState((prev) => ({ ...prev, helm: { ...prev.helm, ...update } })); + }; +}; + +export const makeFunctionalWorkloadSetter = ( + setState: (updater: (prev: CommonFormFields) => CommonFormFields) => void, +) => { + return (update: WorkloadUpdate) => { + setState((s) => ({ + ...s, + workload: { + ...s.workload, + ...(typeof update === "function" ? update(s.workload) : update), + }, + })); + }; +}; diff --git a/frontend/src/lib/form.types.ts b/frontend/src/lib/form.types.ts new file mode 100644 index 00000000..796e0d16 --- /dev/null +++ b/frontend/src/lib/form.types.ts @@ -0,0 +1,55 @@ +import type { components } from "@/generated/openapi"; + +export type GroupFormFields = { + orgId?: number; + groupOption: components["schemas"]["NewApp"]["appGroup"]; +}; + +export type GitFormFields = { + dockerfilePath: string; + rootDir: string; + repositoryId?: number; + repoName: string; + event: "push" | "workflow_run"; + eventId?: number | null; + branch?: string; + builder: "dockerfile" | "railpack"; +}; + +export type ImageFormFields = { + imageTag: string; +}; + +export type WorkloadFormFields = { + port?: string; + replicas?: string; + env: components["schemas"]["Envs"]; + mounts: components["schemas"]["Mount"][]; + subdomain?: string | null; + createIngress: boolean; + collectLogs: boolean; + cpuCores: string; + memoryInMiB: string; + + git: GitFormFields; + image: ImageFormFields; +}; + +export type WorkloadUpdate = + | Partial> + | (( + prev: WorkloadFormFields, + ) => Partial>); + +export type HelmFormFields = Partial< + Omit +>; + +export type CommonFormFields = { + displayName?: string; + projectId: string | null; + appType: "workload" | "helm"; + source: "git" | "image" | "helm"; + workload: WorkloadFormFields; + helm: HelmFormFields; +}; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index a6bd0205..745a619e 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1,3 +1,4 @@ +import type { components } from "@/generated/openapi"; import { clsx, type ClassValue } from "clsx"; import React from "react"; import { twMerge } from "tailwind-merge"; @@ -19,3 +20,13 @@ export function useDebouncedValue(value: T, delay = 300) { }, [value, delay]); return debounceValue; } + +/** + * Type guard to check if a DeploymentConfig is a WorkloadConfigOptions + * (i.e., not a HelmConfigOptions) + */ +export function isWorkloadConfig( + config: components["schemas"]["DeploymentConfig"], +): config is components["schemas"]["WorkloadConfigOptions"] { + return config.source !== "helm"; +} diff --git a/frontend/src/pages/DeploymentView.tsx b/frontend/src/pages/DeploymentView.tsx index 24ee47cd..d417d989 100644 --- a/frontend/src/pages/DeploymentView.tsx +++ b/frontend/src/pages/DeploymentView.tsx @@ -29,20 +29,14 @@ export const DeploymentView = () => { }, }, ); + console.log(deployment); const format = new Intl.DateTimeFormat(undefined, { dateStyle: "short", timeStyle: "short", }); - let commitMessage = deployment.commitMessage.split("\n"); - if (deployment.commitMessage.trim().length === 0) { - commitMessage = ["Untitled deployment"]; - } - const [title, description] = [ - commitMessage.shift(), - commitMessage.join("\n").trim(), - ]; + const title = deployment?.title?.trim() ?? "Untitled deployment"; return (
@@ -56,11 +50,6 @@ export const DeploymentView = () => {

{title}

- {description.trim().length > 0 && ( -

- {description} -

- )}
{deployment.config.source === "git" && deployment.commitHash ? ( diff --git a/frontend/src/pages/app/AppView.tsx b/frontend/src/pages/app/AppView.tsx index 52dda78a..5b9296fc 100644 --- a/frontend/src/pages/app/AppView.tsx +++ b/frontend/src/pages/app/AppView.tsx @@ -3,7 +3,7 @@ import { DeploymentStatus } from "@/components/DeploymentStatus"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import type { components, paths } from "@/generated/openapi"; import { api } from "@/lib/api"; -import { cn } from "@/lib/utils"; +import { cn, isWorkloadConfig } from "@/lib/utils"; import { useQueryClient } from "@tanstack/react-query"; import { useParams, useSearchParams } from "react-router-dom"; import { ConfigTab } from "./ConfigTab"; @@ -79,13 +79,15 @@ export default function AppView() { )}
- + {app.config.appType === "workload" && ( + + )} Overview - {!!app.activeDeployment && ( + {isWorkloadConfig(app.config) && !!app.activeDeployment && ( Status @@ -95,10 +97,12 @@ export default function AppView() { {settings.storageEnabled && !!app.activeDeployment && ( <> - - Logs - - {app.config.mounts.length > 0 && ( + {isWorkloadConfig(app.config) && ( + + Logs + + )} + {isWorkloadConfig(app.config) && app.config.mounts.length > 0 && ( Files diff --git a/frontend/src/pages/app/ConfigTab.tsx b/frontend/src/pages/app/ConfigTab.tsx index e0ac087a..d4e60dcd 100644 --- a/frontend/src/pages/app/ConfigTab.tsx +++ b/frontend/src/pages/app/ConfigTab.tsx @@ -1,17 +1,21 @@ import HelpTooltip from "@/components/HelpTooltip"; +import { UserContext } from "@/components/UserProvider"; +import { AppConfigFormFields } from "@/components/config/AppConfigFormFields"; +import { GroupConfigFields } from "@/components/config/GroupConfigFields"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { UserContext } from "@/components/UserProvider"; -import type { components } from "@/generated/openapi"; import { api } from "@/lib/api"; -import AppConfigFormFields, { - type AppInfoFormData, -} from "@/pages/create-app/AppConfigFormFields"; +import { + createDeploymentConfig, + getFormStateFromApp, + getGroupStateFromApp, +} from "@/lib/form"; +import type { CommonFormFields, GroupFormFields } from "@/lib/form.types"; +import { isWorkloadConfig } from "@/lib/utils"; import type { RefetchOptions } from "@tanstack/react-query"; import { Loader, Save, Scale3D, TextCursorInput } from "lucide-react"; import { useContext, useState, type Dispatch } from "react"; -import { toast } from "sonner"; -import { Input } from "../../components/ui/input"; import { FormContext } from "../create-app/CreateAppView"; import type { App } from "./AppView"; @@ -26,40 +30,20 @@ export const ConfigTab = ({ setTab: Dispatch; refetch: (options: RefetchOptions | undefined) => Promise; }) => { - const [formState, setFormState] = useState({ - port: app.config.port.toString(), - env: app.config.env, - mounts: app.config.mounts.map((mount) => ({ - // (remove volumeClaimName because it's not stored in the app's deployment config) - amountInMiB: mount.amountInMiB, - path: mount.path, - })), - collectLogs: app.config.collectLogs, - subdomain: app.config.subdomain ?? "", - createIngress: app.config.createIngress, - orgId: app.orgId, - groupOption: app.appGroup.standalone ? "standalone" : "add-to", - groupId: app.appGroup.id, - projectId: app.projectId, - source: app.config.source, - cpuCores: parseInt(app.config.limits?.cpu ?? "1000m") / 1000, // parseInt ignores the "m" which means millicore - we need to divide by 1000 to get the number of full cores - memoryInMiB: parseInt(app.config.limits?.memory ?? "1024"), // parseInt ignores the "Mi" which means mebibyte - ...(app.config.source === "git" - ? { - repositoryId: app.config.repositoryId, - branch: app.config.branch, - event: app.config.event, - eventId: app.config.eventId?.toString() ?? undefined, - rootDir: app.config.rootDir ?? undefined, - dockerfilePath: app.config.dockerfilePath ?? undefined, - builder: app.config.builder, - } - : { - dockerfilePath: "Dockerfile", - builder: "railpack", - }), - imageTag: app.config.imageTag, - }); + if (!isWorkloadConfig(app.config)) { + return ( +
+

Configuration editing is not available for Helm-based apps.

+
+ ); + } + + const [state, setState] = useState( + getFormStateFromApp(app), + ); + const [groupState, setGroupState] = useState( + getGroupStateFromApp(app), + ); const { mutateAsync: updateApp, isPending: updatePending } = api.useMutation( "put", @@ -69,90 +53,23 @@ export const ConfigTab = ({ const { user } = useContext(UserContext); const enableSaveButton = - formState.source !== "git" || + state.source !== "git" || user?.orgs?.find((it) => it.id === app.orgId)?.githubConnected; return (
{ e.preventDefault(); - - const formData = new FormData(e.currentTarget); - let appGroup: components["schemas"]["AppUpdate"]["appGroup"]; - switch (formState.groupOption) { - case "standalone": - appGroup = { - type: "standalone", - }; - break; - case "create-new": - appGroup = { - type: "create-new", - name: formData.get("groupName")!.toString(), - }; - break; - default: - appGroup = { type: "add-to", id: formState.groupId! }; - break; - } - - const resources = { - cpu: Math.round(formState.cpuCores * 1000) + "m", - memory: formState.memoryInMiB + "Mi", - }; - + const finalAppState = state as Required; await updateApp({ params: { path: { appId: app.id } }, body: { - name: formData.get("name")!.toString(), - appGroup, - projectId: formState.projectId, - config: { - port: parseInt(formData.get("portNumber")!.toString()), - env: formState.env.filter((it) => it.name.length > 0), - mounts: formState.mounts.filter((it) => it.path.length > 0), - createIngress: formState.createIngress, - subdomain: formState.createIngress - ? formState.subdomain - : undefined, - collectLogs: formState.collectLogs, - replicas: parseInt(formData.get("replicas")!.toString()), - requests: resources, - limits: resources, - ...(formState.source === "git" - ? { - source: "git", - repositoryId: formState.repositoryId!, - branch: formState.branch!, - rootDir: formState.rootDir!, - ...(formState.builder === "dockerfile" - ? { - builder: formState.builder, - dockerfilePath: formState.dockerfilePath!, - } - : { - builder: formState.builder, - dockerfilePath: null, - }), - ...(formState.event === "push" - ? { - event: "push", - eventId: null, - } - : { - event: formState.event!, - eventId: parseInt(formState.eventId!), - }), - } - : { - source: "image", - imageTag: formState.imageTag!, - }), - }, + displayName: state.displayName, + appGroup: groupState.groupOption, + projectId: state.projectId ?? undefined, + config: createDeploymentConfig(finalAppState), }, }); - - toast.success("App updated successfully!"); if (tab === "configuration") { setTab("overview"); } @@ -172,7 +89,14 @@ export const ConfigTab = ({ * - + + setState((s) => ({ ...s, displayName: e.target.value })) + } + />
@@ -198,15 +122,23 @@ export const ConfigTab = ({ placeholder="1" type="number" required - defaultValue={app.config.replicas} + value={state.workload?.replicas ?? "1"} + onChange={(e) => { + const value = e.target.value; + setState((s) => ({ + ...s, + workload: { ...s.workload, replicas: value }, + })); + }} />
+ {enableSaveButton && ( diff --git a/frontend/src/pages/app/FilesTab.tsx b/frontend/src/pages/app/FilesTab.tsx index a9875ec7..20b72f8d 100644 --- a/frontend/src/pages/app/FilesTab.tsx +++ b/frontend/src/pages/app/FilesTab.tsx @@ -15,6 +15,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import type { components } from "@/generated/openapi"; import { api } from "@/lib/api"; import { ArrowUp, @@ -33,7 +34,7 @@ import { Trash, UploadCloud, } from "lucide-react"; -import { lazy, Suspense, useEffect, useState, type ReactNode } from "react"; +import { Suspense, lazy, useEffect, useState, type ReactNode } from "react"; import { toast } from "sonner"; import type { App } from "./AppView"; @@ -52,9 +53,11 @@ function dirname(path: string) { } export const FilesTab = ({ app }: { app: App }) => { + const config = app.config as components["schemas"]["WorkloadConfigOptions"]; + const [replica, setReplica] = useState("0"); const [volume, setVolume] = useState( - app.config.mounts?.[0]?.volumeClaimName, + config.mounts?.[0]?.volumeClaimName, ); const [pathInput, setPathInput] = useState("/"); @@ -146,7 +149,7 @@ export const FilesTab = ({ app }: { app: App }) => { - {Array({ length: app.config.replicas }).map((_, index) => ( + {Array({ length: config.replicas }).map((_, index) => ( {app.name + "-" + index.toString()} @@ -159,7 +162,7 @@ export const FilesTab = ({ app }: { app: App }) => { - {app.config.mounts.map((mount) => ( + {config.mounts.map((mount) => ( {mount.path} diff --git a/frontend/src/pages/app/OverviewTab.tsx b/frontend/src/pages/app/OverviewTab.tsx index bb2098b7..29a92735 100644 --- a/frontend/src/pages/app/OverviewTab.tsx +++ b/frontend/src/pages/app/OverviewTab.tsx @@ -7,7 +7,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { api } from "@/lib/api"; -import { cn } from "@/lib/utils"; +import { cn, isWorkloadConfig } from "@/lib/utils"; import { GitHubIcon } from "@/pages/create-app/CreateAppView"; import { CheckCheck, @@ -26,10 +26,8 @@ import { } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; -import { toast } from "sonner"; import { Status, type App, type DeploymentStatus } from "./AppView"; import { RedeployModal } from "./overview/RedeployModal"; - export const format = new Intl.DateTimeFormat(undefined, { dateStyle: "short", timeStyle: "medium", @@ -189,46 +187,52 @@ export const OverviewTab = ({

{app.config.imageTag}

) : null} - {appDomain !== null && app.config.createIngress && ( + {appDomain !== null && + isWorkloadConfig(app.config) && + app.config.createIngress && ( + <> +

+ + Public address +

+

+ { + const temp = new URL(appDomain); + temp.hostname = app.config.subdomain + "." + temp.hostname; + return temp.toString(); + })()} + className="underline flex gap-1 items-center" + target="_blank" + rel="noopener noreferrer" + > + {app.config.subdomain}.{appDomain?.hostname} + + +

+ + )} + {isWorkloadConfig(app.config) && ( <>

- - Public address + + Internal address + + Other workloads within the cluster can communicate with your + application using this address.
+ Use this address when possible for improved speed and + compatibility with non-HTTP protocols. +
+ End users cannot use this address, as it's only valid within the + cluster. +

- { - const temp = new URL(appDomain); - temp.hostname = app.config.subdomain + "." + temp.hostname; - return temp.toString(); - })()} - className="underline flex gap-1 items-center" - target="_blank" - rel="noopener noreferrer" - > - {app.config.subdomain}.{appDomain?.hostname} - - + anvilops-{app.namespace}.anvilops-{app.namespace} + .svc.cluster.local

)} -

- - Internal address - - Other workloads within the cluster can communicate with your - application using this address.
- Use this address when possible for improved speed and compatibility - with non-HTTP protocols. -
- End users cannot use this address, as it's only valid within the - cluster. -
-

-

- anvilops-{app.namespace}.anvilops-{app.namespace} - .svc.cluster.local -

Recent Deployments

@@ -427,7 +431,6 @@ const ToggleCDForm = ({ body: { enable: !app.cdEnabled }, }); - toast.success("Updated app successfully."); refetchApp(); }} > diff --git a/frontend/src/pages/app/overview/AppConfigDiff.tsx b/frontend/src/pages/app/overview/AppConfigDiff.tsx deleted file mode 100644 index df9fc011..00000000 --- a/frontend/src/pages/app/overview/AppConfigDiff.tsx +++ /dev/null @@ -1,415 +0,0 @@ -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion"; -import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { UserContext } from "@/components/UserProvider"; -import type { components } from "@/generated/openapi"; -import { - Cable, - Code2, - Cog, - Cpu, - MemoryStick, - Scale3D, - Server, - Tag, - Terminal, -} from "lucide-react"; -import { useContext } from "react"; -import { GitHubIcon } from "../../create-app/CreateAppView"; -import { DiffInput } from "./DiffInput"; -import { EnvsWithDiffs } from "./EnvsWithDiffs"; -import { GitConfigDiff } from "./GitConfigDiff"; - -export type DeploymentConfigFormData = { - port: string; - replicas: string; - dockerfilePath?: string; - env: Env; - repositoryId?: number; - event?: "push" | "workflow_run"; - eventId?: string; - commitHash?: string; - imageTag?: string; - branch?: string; - rootDir?: string; - source: "git" | "image"; - builder?: "dockerfile" | "railpack"; - createIngress: boolean; - collectLogs: boolean; - cpuCores: string; - memoryInMiB: number; -}; - -type Env = { name: string; value: string | null; isSensitive: boolean }[]; - -export const AppConfigDiff = ({ - orgId, - base, - state, - setState, - defaults, - disabled = false, -}: { - orgId: number; - base: DeploymentConfigFormData; - state: DeploymentConfigFormData; - setState: ( - callback: (state: DeploymentConfigFormData) => DeploymentConfigFormData, - ) => void; - defaults?: { - config?: components["schemas"]["DeploymentConfig"]; - }; - disabled?: boolean; -}) => { - const { user } = useContext(UserContext); - - const selectedOrg = orgId - ? user?.orgs?.find((it) => it.id === orgId) - : undefined; - - const showDeploymentOptions = - state.source !== "git" || selectedOrg?.githubConnected; - - return ( -
-

Source Options

-
-
- - - * - -
-
- - setState((prev) => ({ - ...prev, - source: source as "git" | "image", - })) - } - select={(props) => ( - - )} - /> -
-
- {state.source === "git" ? ( - selectedOrg?.githubConnected ? ( - - ) : selectedOrg?.permissionLevel === "OWNER" ? ( -
-

- {selectedOrg?.name} has not been connected to - GitHub. -

-

- AnvilOps integrates with GitHub to deploy your app as soon as you - push to your repository. -

- - - -
- ) : ( - <> -

- {selectedOrg?.name} has not been connected to - GitHub. Ask the owner of your organization to install the AnvilOps - GitHub App. -

- - ) - ) : state.source === "image" ? ( - <> -
-
- - - * - -
-
- { - setState((state) => ({ ...state, imageTag })); - }} - name="imageTag" - id="imageTag" - placeholder="nginx:latest" - required - /> -
-
- - ) : null} - - {showDeploymentOptions && ( - <> -

Deployment Options

-
-
- - - * - -
-
- { - setState((state) => ({ ...state, port })); - }} - /> -
-
-
-
- - - * - -
-
- { - setState((s) => ({ ...s, replicas })); - }} - /> -
-
-
-
- - - * - -
-
- { - setState((state) => ({ ...state, cpuCores })); - }} - /> -
-
-
-
- - - * - -
-
- { - setState((state) => ({ - ...state, - memoryInMiB: parseInt(memoryInMiB), - })); - }} - /> -
-
- - - - - - - { - setState((prev) => { - return { - ...prev, - env: typeof env === "function" ? env(prev.env) : env, - }; - }); - }} - fixedSensitiveNames={ - defaults?.config - ? new Set( - defaults.config.env - .filter((env) => env.isSensitive) - .map((env) => env.name), - ) - : new Set() - } - /> - - - - - - - -
-
- -

- When this setting is disabled, you will only be able to - view logs from the most recent, alive pod from your app's - most recent deployment. -

-
-
- { - setState((state) => ({ - ...state, - collectLogs: collectLogs === "true", - })); - }} - select={(props) => ( - - )} - /> -
-
-
-
-
- - )} -
- ); -}; diff --git a/frontend/src/pages/app/overview/DiffInput.tsx b/frontend/src/pages/app/overview/DiffInput.tsx deleted file mode 100644 index 5820d618..00000000 --- a/frontend/src/pages/app/overview/DiffInput.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { - memo, - type ComponentProps, - type ComponentType, - type DetailedHTMLProps, - type Dispatch, - type InputHTMLAttributes, -} from "react"; -import type * as SelectPrimitive from "@radix-ui/react-select"; -import { Input } from "@/components/ui/input"; -import { MoveRight } from "lucide-react"; -import { cn } from "@/lib/utils"; - -export const DiffInput = ({ - left, - right, - setRight, - select, - leftPlaceholder = "(None)", - ...props -}: Omit< - DetailedHTMLProps, HTMLInputElement>, - "value" | "onChange" -> & { - left: string | undefined; - right: string | undefined; - setRight: Dispatch; - select?: ComponentType< - { side?: "before" | "after"; placeholder?: string } & Pick< - ComponentProps, - "value" | "onValueChange" - > - >; - leftPlaceholder?: string; -}) => { - const Component = select ? memo(select) : Input; - const isDifferent = (!!left || !!right) && (left ?? "") !== (right ?? ""); - - return ( -
- - - setRight(e.currentTarget.value)} - {...(select !== undefined - ? { side: "after", onValueChange: (e: string) => setRight(e) } - : {})} - className={cn(props.className, isDifferent && "bg-green-50")} - /> -
- ); -}; diff --git a/frontend/src/pages/app/overview/GitConfigDiff.tsx b/frontend/src/pages/app/overview/GitConfigDiff.tsx deleted file mode 100644 index 15fabda2..00000000 --- a/frontend/src/pages/app/overview/GitConfigDiff.tsx +++ /dev/null @@ -1,482 +0,0 @@ -import { Label } from "@/components/ui/label"; -import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { UserContext } from "@/components/UserProvider"; -import { api } from "@/lib/api"; -import { cn } from "@/lib/utils"; -import { - BookMarked, - ClipboardCheck, - CloudUpload, - Container, - FolderRoot, - GitBranch, - Hammer, -} from "lucide-react"; -import { useContext } from "react"; -import { type DeploymentConfigFormData } from "./AppConfigDiff"; -import { DiffInput } from "./DiffInput"; - -export const GitConfigDiff = ({ - orgId, - base, - state, - setState, - disabled = false, -}: { - orgId: number; - base: DeploymentConfigFormData; - state: DeploymentConfigFormData; - setState: ( - callback: (s: DeploymentConfigFormData) => DeploymentConfigFormData, - ) => void; - disabled?: boolean; -}) => { - const { user } = useContext(UserContext); - - const selectedOrg = - orgId !== undefined ? user?.orgs?.find((it) => it.id === orgId) : undefined; - - const { data: repos, isPending: reposLoading } = api.useQuery( - "get", - "/org/{orgId}/repos", - { params: { path: { orgId: orgId! } } }, - { - enabled: - orgId !== undefined && - state.source === "git" && - selectedOrg?.githubConnected, - }, - ); - - const { data: branches, isPending: branchesLoading } = api.useQuery( - "get", - "/org/{orgId}/repos/{repoId}/branches", - { - params: { - path: { - orgId: orgId!, - repoId: state.repositoryId!, - }, - }, - }, - { - enabled: !!orgId && !!state.repositoryId && state.source === "git", - }, - ); - - const { data: workflows, isPending: workflowsLoading } = api.useQuery( - "get", - "/org/{orgId}/repos/{repoId}/workflows", - { - params: { - path: { - orgId: orgId!, - repoId: state.repositoryId!, - }, - }, - }, - { - enabled: - orgId !== undefined && - state.repositoryId !== undefined && - state.source === "git" && - state.event === "workflow_run", - }, - ); - - return ( - <> -
-
- - - * - -
-
- { - setState((prev) => ({ - ...prev, - repositoryId: typeof repo === "string" ? parseInt(repo) : repo, - repoName: repos?.find((r) => r?.id === parseInt(repo))?.name, - })); - }} - right={state.repositoryId?.toString() ?? ""} - select={(props) => ( - - )} - /> -
-
-
-
- - - * - -
-
- { - setState((prev) => ({ ...prev, branch })); - }} - select={(props) => ( - - )} - /> -
-
-
-
- - - * - -
-
- { - setState((prev) => ({ - ...prev, - event: event as "push" | "workflow_run", - })); - }} - select={(props) => ( - - )} - /> -
-
- {state.event === "workflow_run" && ( -
-
- - - * - -
-
- { - setState((prev) => ({ ...prev, eventId })); - }} - select={(props) => ( - - )} - /> -
-
- )} -

Build Options

-
-
- - - * - -
-
- { - setState((state) => ({ ...state, rootDir })); - }} - name="rootDir" - id="rootDir" - placeholder="./" - pattern="^\.\/.*$" - autoComplete="off" - required - /> -
-

- Root directory must start with ./ -

-
-
-
- - - * - -
- - setState((prev) => ({ - ...prev, - builder: newValue as "dockerfile" | "railpack", - })) - } - required - > - - - -
- {state.builder === "dockerfile" ? ( -
- -
- { - setState((state) => ({ ...state, dockerfilePath })); - }} - autoComplete="off" - required - /> -
-

- Relative to the root directory. -

-
- ) : null} - - ); -}; diff --git a/frontend/src/pages/app/overview/RedeployModal.tsx b/frontend/src/pages/app/overview/RedeployModal.tsx index 11d8e71b..eb7204b9 100644 --- a/frontend/src/pages/app/overview/RedeployModal.tsx +++ b/frontend/src/pages/app/overview/RedeployModal.tsx @@ -1,3 +1,4 @@ +import { UserContext } from "@/components/UserProvider"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -13,34 +14,26 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { UserContext } from "@/components/UserProvider"; import { api } from "@/lib/api"; -import { cn } from "@/lib/utils"; +import { + createDefaultCommonFormFields, + createDeploymentConfig, + getFormStateFromApp, +} from "@/lib/form"; +import type { CommonFormFields } from "@/lib/form.types"; +import { cn, isWorkloadConfig } from "@/lib/utils"; import { Container, GitCommit, Loader, Rocket } from "lucide-react"; import { useContext, useEffect, useRef, useState, type Dispatch } from "react"; -import { toast } from "sonner"; +import { AppConfigDiff } from "../../../components/diff/AppConfigDiff"; import type { App } from "../AppView"; -import { AppConfigDiff, type DeploymentConfigFormData } from "./AppConfigDiff"; -const defaultRedeployState = { +const getDefaultRedeployState = () => ({ radioValue: undefined, configOpen: false, - configState: { - replicas: "", - env: [], - source: "git" as const, - builder: "dockerfile" as const, - port: "", - cpuCores: "1", - memoryInMiB: 1024, - createIngress: true, - collectLogs: true, - } satisfies DeploymentConfigFormData, + configState: createDefaultCommonFormFields(), enableCD: true, idx: 0, -}; - -Object.freeze(defaultRedeployState); +}); export const RedeployModal = ({ isOpen, @@ -63,16 +56,16 @@ export const RedeployModal = ({ const [redeployState, setRedeployState] = useState<{ radioValue: "useBuild" | "useConfig" | undefined; configOpen: boolean; - configState: DeploymentConfigFormData; + configState: CommonFormFields; enableCD: boolean; idx: number; - }>(defaultRedeployState); + }>(getDefaultRedeployState()); - const resourceConfig = { - cpu: - Math.round(parseFloat(redeployState.configState.cpuCores) * 1000) + "m", - memory: redeployState.configState.memoryInMiB + "Mi", - }; + // const resourceConfig = { + // cpu: + // Math.round(parseFloat(redeployState.configState.cpuCores) * 1000) + "m", + // memory: redeployState.configState.memoryInMiB + "Mi", + // }; const { data: pastDeployment, isPending: pastDeploymentLoading } = api.useQuery( @@ -84,40 +77,15 @@ export const RedeployModal = ({ const setRadioValue = (value: string) => { if (pastDeployment === undefined) return; // Should never happen; sanity check to satisfy type checker - // Populate the new deployment config based on the previous deployment setRedeployState((rs) => ({ ...rs, radioValue: value as "useBuild" | "useConfig", - configState: { - orgId: app.orgId, - port: pastDeployment.config.port.toString(), - replicas: pastDeployment.config.replicas.toString(), - env: pastDeployment.config.env, - cpuCores: ( - parseInt(pastDeployment.config.limits?.cpu ?? "1000m") / 1000 - ).toString(), // convert millicores ("m") to cores, - memoryInMiB: parseInt(pastDeployment.config.limits?.memory ?? "1024Mi"), - createIngress: pastDeployment.config.createIngress, - collectLogs: pastDeployment.config.collectLogs, - ...(pastDeployment.config.source === "git" - ? { - source: "git", - builder: pastDeployment.config.builder, - event: pastDeployment.config.event, - eventId: pastDeployment.config.eventId?.toString() ?? undefined, - commitHash: - value === "useBuild" ? pastDeployment.commitHash : undefined, - dockerfilePath: pastDeployment.config.dockerfilePath ?? undefined, - rootDir: pastDeployment.config.rootDir ?? undefined, - repositoryId: pastDeployment.config.repositoryId, - branch: pastDeployment.config.branch, - } - : { - source: "image", - imageTag: pastDeployment.config.imageTag, - }), - }, + configState: getFormStateFromApp({ + displayName: app.displayName, + projectId: app.projectId, + config: value === "useConfig" ? pastDeployment.config : app.config, + }), })); }; @@ -134,7 +102,7 @@ export const RedeployModal = ({ useEffect(() => { // Clear inputs when closing the dialog if (!isOpen) { - setRedeployState(defaultRedeployState); + setRedeployState(getDefaultRedeployState()); } }, [isOpen]); @@ -176,42 +144,33 @@ export const RedeployModal = ({ className="space-y-1" onSubmit={async (e) => { e.preventDefault(); - const config = redeployState.configState; - const res = { - replicas: parseInt(config.replicas), - port: parseInt(config.port), - env: config.env.filter((env) => env.name.length > 0), - mounts: app.config.mounts, - limits: resourceConfig, - requests: resourceConfig, - createIngress: config.createIngress === true, - collectLogs: config.collectLogs === true, - ...(config.source === "git" - ? { - source: "git" as const, - repositoryId: config.repositoryId!, - rootDir: config.rootDir!, - branch: config.branch, - event: config.event!, - eventId: config.eventId ? parseInt(config.eventId) : null, - commitHash: config.commitHash, - builder: config.builder!, - dockerfilePath: config.dockerfilePath! ?? "", - } - : { - source: "image" as const, - imageTag: config.imageTag!, - }), - }; + const finalConfigState = + redeployState.configState as Required; + const config = createDeploymentConfig(finalConfigState); + if (redeployState.radioValue === "useConfig") { + await updateApp({ + params: { path: { appId: app.id } }, + body: { + enableCD: redeployState.enableCD, + projectId: app.projectId, + config, + }, + }); + } else { + await updateApp({ + params: { path: { appId: app.id } }, + body: { + enableCD: redeployState.enableCD, + config: { + ...config, + ...(pastDeployment.config.source === "git" && { + commitHash: pastDeployment.commitHash ?? undefined, + }), + }, + }, + }); + } - await updateApp({ - params: { path: { appId: app.id } }, - body: { - enableCD: redeployState.enableCD, - config: res, - }, - }); - toast.success("App updated successfully!"); onSubmitted(); setOpen(false); }} @@ -255,7 +214,8 @@ export const RedeployModal = ({ {pastDeployment.commitMessage} - ) : ( + ) : isWorkloadConfig(pastDeployment.config) && + pastDeployment.config.source === "image" ? (

@@ -269,7 +229,7 @@ export const RedeployModal = ({ {pastDeployment.config.imageTag} - )} + ) : null}

AnvilOps will combine this version of your application @@ -364,43 +324,14 @@ export const RedeployModal = ({ <> DeploymentConfigFormData, - ) => { + setState={(updater) => setRedeployState((rs) => ({ ...rs, - configState: updateConfig(rs.configState), - })); - }} - defaults={{ config: pastDeployment?.config }} + configState: updater(rs.configState), + })) + } /> {(redeployState.configState.source !== "git" || selectedOrg?.githubConnected) && ( diff --git a/frontend/src/pages/create-app/AppConfigFormFields.tsx b/frontend/src/pages/create-app/AppConfigFormFields.tsx deleted file mode 100644 index 7027d3dd..00000000 --- a/frontend/src/pages/create-app/AppConfigFormFields.tsx +++ /dev/null @@ -1,717 +0,0 @@ -import { useAppConfig } from "@/components/AppConfigProvider"; -import { EnvVarGrid } from "@/components/EnvVarGrid"; -import { MountsGrid, type Mounts } from "@/components/MountsGrid"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion"; -import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { UserContext } from "@/components/UserProvider"; -import type { components } from "@/generated/openapi"; -import { api } from "@/lib/api"; -import { useDebouncedValue } from "@/lib/utils"; -import { - Cable, - Code2, - Cog, - Component, - Cpu, - Database, - Fence, - Info, - Link, - Loader, - Logs, - MemoryStick, - Server, - Tag, - X, -} from "lucide-react"; -import { useContext, useMemo, useState, type Dispatch } from "react"; -import { GitHubIcon, SubdomainStatus } from "./CreateAppView"; -import { GitDeploymentFields } from "./GitDeploymentFields"; - -export type AppInfoFormData = { - name?: string; - port?: string; - subdomain: string; - createIngress: boolean; - dockerfilePath?: string; - groupOption?: string; - groupId?: number; - projectId?: string; - env: Env; - mounts: Mounts; - orgId?: number; - repositoryId?: number; - event?: "push" | "workflow_run"; - eventId?: string; - repoName?: string; - imageTag?: string; - branch?: string; - rootDir?: string; - source: "git" | "image"; - builder: "dockerfile" | "railpack"; - collectLogs: boolean; - cpuCores: number; - memoryInMiB: number; -}; - -type Env = { name: string; value: string | null; isSensitive: boolean }[]; - -const AppConfigFormFields = ({ - state, - setState, - isExistingApp, - hideGroupSelect, - defaults, - disabled = false, -}: { - state: AppInfoFormData; - setState: Dispatch>; - isExistingApp?: boolean; - hideGroupSelect?: boolean; - defaults?: { - config?: components["schemas"]["DeploymentConfig"]; - }; - disabled?: boolean; -}) => { - const { - groupOption, - groupId, - projectId, - source, - env, - mounts, - orgId, - subdomain, - createIngress, - } = state; - - const { user } = useContext(UserContext); - - const selectedOrg = - orgId !== undefined ? user?.orgs?.find((it) => it.id === orgId) : undefined; - - const { data: groups, isPending: groupsLoading } = !hideGroupSelect - ? api.useQuery( - "get", - "/org/{orgId}/groups", - { params: { path: { orgId: orgId! } } }, - { - enabled: orgId !== undefined, - }, - ) - : { data: null, isPending: false }; - - const MAX_SUBDOMAIN_LENGTH = 54; - const subdomainIsValid = - subdomain.length < MAX_SUBDOMAIN_LENGTH && - subdomain.match(/^[a-z0-9](?:[a-z0-9\-]*[a-z0-9])?$/) !== null; - const debouncedSub = useDebouncedValue(subdomain); - const { data: subStatus, isPending: subLoading } = api.useQuery( - "get", - "/app/subdomain", - { - params: { - query: { - subdomain: debouncedSub, - }, - }, - }, - { enabled: subdomain == debouncedSub && subdomainIsValid }, - ); - - const [groupName, setGroupName] = useState(""); - const isGroupNameValid = useMemo(() => { - const MAX_GROUP_LENGTH = 56; - return ( - groupName.length <= MAX_GROUP_LENGTH && - groupName.match(/^[a-zA-Z0-9][ a-zA-Z0-9-_\.]*$/) - ); - }, [groupName]); - - const appConfig = useAppConfig(); - const appDomain = URL.parse(appConfig?.appDomain ?? ""); - - const DeploymentOptions = ( - <> -

Deployment Options

- - {appDomain !== null && ( -
-
- - {createIngress && ( - - * - - )} -
- -
- - {appDomain?.protocol}// - - { - const subdomain = e.currentTarget.value - .toLowerCase() - .replace(/[^a-z0-9-]/, "-"); - setState((state) => ({ - ...state, - subdomain, - })); - }} - autoComplete="off" - /> - - .{appDomain?.host} - -
- {subdomain && !subdomainIsValid ? ( -
- -
    -
  • A subdomain must have 54 or fewer characters.
  • -
  • - A subdomain must only contain lowercase alphanumeric - characters or dashes(-). -
  • -
  • - A subdomain must start and end with an alphanumeric character. -
  • -
-
- ) : null} - {subdomain && - subdomainIsValid && - subdomain !== defaults?.config?.subdomain ? ( - subdomain !== debouncedSub || subLoading ? ( - - Checking subdomain... - - ) : ( - <> - -

- - - Your application will be reachable at{" "} - - anvilops-{subdomain}.anvilops-{subdomain} - .svc.cluster.local - {" "} - from within the cluster. - -

- - ) - ) : null} -
- )} -
-
- - - * - -
- { - const port = e.currentTarget.value; - setState((state) => ({ ...state, port })); - }} - /> -
-
-
- - - * - -
-
- - - * - -
- { - const cpuCores = e.currentTarget.valueAsNumber; - setState((state) => ({ ...state, cpuCores })); - }} - /> -
- { - const memoryInMiB = e.currentTarget.valueAsNumber; - setState((state) => ({ ...state, memoryInMiB })); - }} - /> - MiB -
-
- - - - - - - { - setState((prev) => { - return { - ...prev, - env: typeof env === "function" ? env(prev.env) : env, - }; - }); - }} - fixedSensitiveNames={ - defaults?.config - ? new Set( - defaults.config.env - .filter((env) => env.isSensitive) - .map((env) => env.name), - ) - : new Set() - } - disabled={disabled} - /> - - - {appConfig.storageEnabled && ( - - - - - - {!!isExistingApp && ( -

- Volume mounts cannot be edited after an app has been created. -

- )} -

- Preserve files contained at these paths across app restarts. All - other files will be discarded. Every replica will get its own - separate volume. -

- - setState((prev) => ({ - ...prev, - mounts: - typeof mounts === "function" - ? mounts(prev.mounts) - : mounts, - })) - } - /> -
-
- )} - {isExistingApp && ( - - - - - -
-
- -

- When this setting is disabled, you will only be able to view - logs from the most recent, alive pod from your app's most - recent deployment. -

-
- { - setState((state) => ({ - ...state, - collectLogs: checked === true, - })); - }} - /> - -
-
-
-
-
- )} -
- - ); - - return ( - <> - {!hideGroupSelect && ( - <> -

Grouping Options

-
-
-
- - - * - -
-

- Applications can be created as standalone apps, or as part of a - group of related microservices. -

-
- - - {groupOption === "create-new" && ( - <> -
- - - * - -
- setGroupName(e.currentTarget.value)} - autoComplete="off" - /> - {groupName && !isGroupNameValid && ( -
- -
    -
  • A group name must have 56 or fewer characters.
  • -
  • - A group name must contain only alphanumeric characters, - dashes, underscores, dots, and spaces. -
  • -
  • - A group name must start with an alphanumeric character. -
  • -
-
- )} - - )} -
- - )} - {appConfig.isRancherManaged && ( -
-
-
- - - * - -
-

- In clusters managed by Rancher, resources are organized into - projects for administration. -

-
- -
- )} -

Source Options

-
-
- - - * - -
- -
- {source === "git" ? ( - selectedOrg?.githubConnected ? ( - - ) : selectedOrg?.permissionLevel === "OWNER" ? ( -
-

- {selectedOrg?.name} has not been connected to - GitHub. -

-

- AnvilOps integrates with GitHub to deploy your app as soon as you - push to your repository. -

- - - -
- ) : ( - <> -

- {selectedOrg?.name} has not been connected to - GitHub. Ask the owner of your organization to install the AnvilOps - GitHub App. -

- - ) - ) : source === "image" ? ( - <> -
-
- - - * - -
- { - const imageTag = e.currentTarget.value; - setState((state) => ({ ...state, imageTag })); - }} - name="imageTag" - id="imageTag" - placeholder="nginx:latest" - className="w-full" - /> -
- - ) : null} - - {(source !== "git" || selectedOrg?.githubConnected) && DeploymentOptions} - - ); -}; - -export default AppConfigFormFields; diff --git a/frontend/src/pages/create-app/CreateAppGroupView.tsx b/frontend/src/pages/create-app/CreateAppGroupView.tsx index dd75197d..8b5f89ff 100644 --- a/frontend/src/pages/create-app/CreateAppGroupView.tsx +++ b/frontend/src/pages/create-app/CreateAppGroupView.tsx @@ -1,4 +1,3 @@ -import { useAppConfig } from "@/components/AppConfigProvider"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -12,139 +11,78 @@ import { } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { UserContext } from "@/components/UserProvider"; -import type { components } from "@/generated/openapi"; import { api } from "@/lib/api"; import { Globe, Loader, Plus, Rocket, X } from "lucide-react"; -import { - Fragment, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { Fragment, useContext, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; -import AppConfigFormFields, { - type AppInfoFormData, -} from "./AppConfigFormFields"; -import { FormContext, getCleanedAppName } from "./CreateAppView"; +import { FormContext } from "./CreateAppView"; +import type { CommonFormFields, GroupFormFields } from "@/lib/form.types"; +import { + createDefaultCommonFormFields, + createNewAppWithoutGroup, + getAppName, +} from "@/lib/form"; +import { AppConfigFormFields } from "@/components/config/AppConfigFormFields"; +type GroupCreate = { type: "create-new"; name: string }; export default function CreateAppGroupView() { const { user } = useContext(UserContext); const { mutateAsync: createAppGroup, isPending: createPending } = api.useMutation("post", "/app/group"); - const [orgId, setOrgId] = useState(user?.orgs?.[0]?.id); + const [groupState, setGroupState] = useState({ + orgId: user?.orgs?.[0]?.id, + groupOption: { type: "create-new", name: "" }, + }); - const defaultState = { - collectLogs: true, - env: [], - mounts: [], - source: "git" as "git", - builder: "railpack" as "railpack", - event: "push" as "push", - subdomain: "", - createIngress: true, - rootDir: "./", - dockerfilePath: "Dockerfile", - cpuCores: 1, - memoryInMiB: 1024, - } satisfies AppInfoFormData; + const { + orgId, + groupOption: { name: groupName }, + } = groupState as { orgId?: string; groupOption: GroupCreate }; - const [appStates, setAppStates] = useState([ - { ...defaultState }, + const [appStates, setAppStates] = useState([ + createDefaultCommonFormFields(), ]); - const [tab, setTab] = useState("0"); const navigate = useNavigate(); const shouldShowDeploy = useMemo(() => { return ( orgId === undefined || - user?.orgs.some((org) => org.id === orgId && org.githubConnected) + user?.orgs.some( + (org) => org.id === parseInt(orgId) && org.githubConnected, + ) ); - }, [user, orgId]); + }, [user, groupState.orgId]); const scrollRef = useRef(null); - - useEffect(() => { - setAppStates((appStates) => - appStates.map((state) => ({ ...state, orgId })), - ); - }, [orgId]); - - const [groupName, setGroupName] = useState(""); - const isGroupNameValid = useMemo(() => { + const showGroupNameError = useMemo(() => { const MAX_GROUP_LENGTH = 56; return ( - groupName.length <= MAX_GROUP_LENGTH && - groupName.match(/^[a-zA-Z0-9][ a-zA-Z0-9-_\.]*$/) + groupName.length > 0 && + (groupName.length > MAX_GROUP_LENGTH || + !groupName.match(/^[a-zA-Z0-9][ a-zA-Z0-9-_\.]*$/)) ); }, [groupName]); - const config = useAppConfig(); - return (
{ e.preventDefault(); - const formData = new FormData(e.currentTarget); - try { - const apps = appStates.map( - (appState): components["schemas"]["NewAppWithoutGroupInfo"] => { - const appName = getAppName(appState); - let subdomain = appState.subdomain; - if ( - (!subdomain && !config.appDomain) || - !appState.createIngress - ) { - subdomain = - appName.replaceAll(/[^a-zA-Z0-9-_]/g, "_") + - "-" + - Math.floor(Math.random() * 10_000); - } - - return { - orgId: orgId!, - projectId: appState.projectId, - name: appName, - subdomain, - createIngress: appState.createIngress, - port: parseInt(appState.port!), - env: appState.env.filter((ev) => ev.name.length > 0), - mounts: appState.mounts.filter((m) => m.path.length > 0), - cpuCores: appState.cpuCores, - memoryInMiB: appState.memoryInMiB, - ...(appState.source === "git" - ? { - source: "git", - repositoryId: appState.repositoryId!, - branch: appState.branch!, - event: appState.event!, - eventId: appState.eventId - ? parseInt(appState.eventId) - : null, - dockerfilePath: appState.dockerfilePath!, - rootDir: appState.rootDir!, - builder: appState.builder!, - } - : { - source: "image", - imageTag: appState.imageTag!, - }), - }; - }, - ); + // const formData = new FormData(e.currentTarget); + // TODO: client-side validation on every app state + const finalAppStates = appStates as Required[]; + try { await createAppGroup({ body: { - name: formData.get("groupName")!.toString(), - orgId: orgId!, - apps, + name: groupName, + orgId: parseInt(orgId!), + apps: finalAppStates.map(createNewAppWithoutGroup), }, }); @@ -171,7 +109,9 @@ export default function CreateAppGroupView() {
- setFormState((prev) => ({ ...prev, orgId: parseInt(orgId!) })) + setGroupState((prev) => ({ ...prev, orgId: parseInt(orgId) })) } - value={formState.orgId?.toString()} + value={groupState.orgId?.toString()} name="org" > @@ -179,8 +112,13 @@ export default function CreateAppView() { + - + {shouldShowDeploy ? (