diff --git a/README.md b/README.md index 738349f7..845320ac 100644 --- a/README.md +++ b/README.md @@ -123,14 +123,15 @@ To install AnvilOps on a Kubernetes cluster for production use, follow the insta AnvilOps is a collection of many subprojects which build into a few container images. -| Image | Subproject(s) | Deployed... | -| ----------------------------- | ------------------------------------------------- | -------------------- | -| `anvilops/anvilops` | backend, frontend, openapi, swagger-ui, templates | Once | -| `anvilops/app-proxy` | infra/sandbox/proxy | Once | -| `anvilops/log-shipper` | log-shipper | Once per application | -| `anvilops/dockerfile-builder` | builders/dockerfile | Once per build | -| `anvilops/railpack-builder` | builders/railpack | Once per build | -| `anvilops/file-browser` | filebrowser | On demand | +| Image | Subproject(s) | Deployed... | +| ----------------------------- | ------------------------------------------------- | -------------------------- | +| `anvilops/anvilops` | backend, frontend, openapi, swagger-ui, templates | Once | +| `anvilops/migrate-db` | backend | Once per Helm Chart update | +| `anvilops/app-proxy` | infra/sandbox/proxy | Once | +| `anvilops/log-shipper` | log-shipper | Once per application | +| `anvilops/dockerfile-builder` | builders/dockerfile | Once per build | +| `anvilops/railpack-builder` | builders/railpack | Once per build | +| `anvilops/file-browser` | filebrowser | On demand | Every subproject has a `README.md` file with more information about its purpose and how to use it. @@ -143,7 +144,6 @@ Every subproject has a `README.md` file with more information about its purpose | `docs` | AnvilOps end user documentation | Astro, HTML | | `filebrowser` | A container image that powers the persistent volume file browser | Node.js, TypeScript | | `frontend` | The AnvilOps web dashboard and landing page | React, Vite, TypeScript, TailwindCSS | -| `infra` | FluxCD configurations for Purdue's AnvilOps deployments | YAML | | `log-shipper` | Sends logs from users' apps to the AnvilOps backend | Go | | `openapi` | OpenAPI spec | YAML | | `swagger-ui` | Auto-generated API docs | | diff --git a/backend/package-lock.json b/backend/package-lock.json index c156119e..fb1d36a9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -36,7 +36,8 @@ "@types/morgan": "^1.9.10", "prisma": "^7.1.0", "prisma-json-types-generator": "^4.0.0-beta.1", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.0.15" } }, "node_modules/@apidevtools/json-schema-ref-parser": { @@ -123,6 +124,448 @@ "@electric-sql/pglite": "0.3.2" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@hono/node-server": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.6.tgz", @@ -136,6 +579,13 @@ "hono": "^4" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@jsdevtools/ono": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", @@ -778,6 +1228,314 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@standard-schema/spec": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", @@ -802,6 +1560,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -834,6 +1603,20 @@ "@types/express": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/express": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", @@ -993,6 +1776,117 @@ "@types/node": "*" } }, + "node_modules/@vitest/expect": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.15.tgz", + "integrity": "sha512-Gfyva9/GxPAWXIWjyGDli9O+waHDC0Q0jaLdFP1qPAUUfo1FEXPXUfUkp3eZA0sSq340vPycSyOlYUeM15Ft1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.15", + "@vitest/utils": "4.0.15", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.15.tgz", + "integrity": "sha512-CZ28GLfOEIFkvCFngN8Sfx5h+Se0zN+h4B7yOsPVCcgtiO7t5jt9xQh2E1UkFep+eb9fjyMfuC5gBypwb07fvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.15", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.15.tgz", + "integrity": "sha512-SWdqR8vEv83WtZcrfLNqlqeQXlQLh2iilO1Wk1gv4eiHKjEzvgHb2OVc3mIPyhZE6F+CtfYjNlDJwP5MN6Km7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.15.tgz", + "integrity": "sha512-+A+yMY8dGixUhHmNdPUxOh0la6uVzun86vAbuMT3hIDxMrAOmn5ILBHm8ajrqHE0t8R9T1dGnde1A5DTnmi3qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.15", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.15.tgz", + "integrity": "sha512-A7Ob8EdFZJIBjLjeO0DZF4lqR6U7Ydi5/5LIZ0xcI+23lYlsYJAfGn8PrIWTYdZQRNnSRlzhg0zyGu37mVdy5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.15", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.15.tgz", + "integrity": "sha512-+EIjOJmnY6mIfdXtE/bnozKEvTC4Uczg19yeZ2vtCz5Yyb0QQ31QWVQ8hswJ3Ysx/K2EqaNsVanjr//2+P3FHw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.15.tgz", + "integrity": "sha512-HXjPW2w5dxhTD0dLwtYHDnelK3j8sR8cWIaLxr22evTyY6q8pRCjZSmhRWVjBaOVXChQd6AwMzi9pucorXCPZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.15", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@yarnpkg/lockfile": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", @@ -1075,6 +1969,16 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1326,6 +2230,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chai": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", + "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1740,6 +2654,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -1764,7 +2685,49 @@ "hasown": "^2.0.2" }, "engines": { - "node": ">= 0.4" + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, "node_modules/escape-html": { @@ -1773,6 +2736,16 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1782,6 +2755,16 @@ "node": ">= 0.6" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", @@ -2089,6 +3072,21 @@ "node": ">=12" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -2579,6 +3577,16 @@ "url": "https://github.com/sponsors/wellwelwel" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2754,6 +3762,25 @@ "node": ">=12" } }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -2840,6 +3867,17 @@ "node": ">= 0.4" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/octokit": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/octokit/-/octokit-5.0.5.tgz", @@ -3138,6 +4176,13 @@ "split2": "^4.1.0" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -3162,6 +4207,35 @@ "pathe": "^2.0.3" } }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/postgres": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", @@ -3475,6 +4549,48 @@ "integrity": "sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==", "license": "MIT" }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -3696,6 +4812,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -3756,6 +4879,16 @@ "node": ">= 14" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -3781,6 +4914,13 @@ "node": ">= 0.6" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -3853,6 +4993,13 @@ "b4a": "^1.6.4" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -3863,6 +5010,64 @@ "node": ">=18" } }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -4038,6 +5243,210 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "7.2.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz", + "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.15.tgz", + "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.15", + "@vitest/mocker": "4.0.15", + "@vitest/pretty-format": "4.0.15", + "@vitest/runner": "4.0.15", + "@vitest/snapshot": "4.0.15", + "@vitest/spy": "4.0.15", + "@vitest/utils": "4.0.15", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.15", + "@vitest/browser-preview": "4.0.15", + "@vitest/browser-webdriverio": "4.0.15", + "@vitest/ui": "4.0.15", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -4069,6 +5478,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index e1419487..522a4a54 100644 --- a/backend/package.json +++ b/backend/package.json @@ -39,6 +39,7 @@ "@types/morgan": "^1.9.10", "prisma": "^7.1.0", "prisma-json-types-generator": "^4.0.0-beta.1", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.0.15" } } diff --git a/backend/src/db/models.ts b/backend/src/db/models.ts index 1d945255..e57ceb06 100644 --- a/backend/src/db/models.ts +++ b/backend/src/db/models.ts @@ -1,6 +1,7 @@ import type { DeploymentSource, DeploymentStatus, + GitHubOAuthAction, ImageBuilder, PermissionLevel, WebhookEvent, @@ -157,3 +158,11 @@ export interface RepoImportState { userId: number; orgId: number; } + +export interface GitHubOAuthState { + id: number; + random: string; + userId: number; + orgId: number; + action: GitHubOAuthAction; +} diff --git a/backend/src/db/repo/app.ts b/backend/src/db/repo/app.ts index 3b0017c5..2e1b05c5 100644 --- a/backend/src/db/repo/app.ts +++ b/backend/src/db/repo/app.ts @@ -123,6 +123,7 @@ export class AppRepo { err, ); } + throw err; } // Use the new app's ID to generate the imageRepo field diff --git a/backend/src/db/repo/appGroup.ts b/backend/src/db/repo/appGroup.ts index 044542fa..12fe299d 100644 --- a/backend/src/db/repo/appGroup.ts +++ b/backend/src/db/repo/appGroup.ts @@ -1,4 +1,5 @@ -import type { PrismaClientType } from "../index.ts"; +import { PrismaClientKnownRequestError } from "../../generated/prisma/internal/prismaNamespace.ts"; +import { ConflictError, type PrismaClientType } from "../index.ts"; import type { AppGroup } from "../models.ts"; export class AppGroupRepo { @@ -9,16 +10,24 @@ export class AppGroupRepo { } async create(orgId: number, name: string, isMono: boolean) { - const group = await this.client.appGroup.create({ - data: { - orgId: orgId, - name: name, - isMono: isMono, - }, - select: { id: true }, - }); + try { + const group = await this.client.appGroup.create({ + data: { + orgId: orgId, + name: name, + isMono: isMono, + }, + select: { id: true }, + }); - return group.id; + return group.id; + } catch (e) { + if (e instanceof PrismaClientKnownRequestError && e.code === "P2002") { + // P2002 is "Unique Constraint Failed" - https://www.prisma.io/docs/orm/reference/error-reference#p2002 + throw new ConflictError("name", e); + } + throw e; + } } async getById(appGroupId: number): Promise { diff --git a/backend/src/db/repo/invitation.ts b/backend/src/db/repo/invitation.ts index 11024e39..672a53e7 100644 --- a/backend/src/db/repo/invitation.ts +++ b/backend/src/db/repo/invitation.ts @@ -91,11 +91,11 @@ export class InvitationRepo { if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") { // https://www.prisma.io/docs/orm/reference/error-reference#p2025 // "An operation failed because it depends on one or more records that were required but not found." - throw new NotFoundError("organization"); + throw new NotFoundError("organization", e); } if (e instanceof PrismaClientKnownRequestError && e.code === "P2002") { // Unique constraint failed - throw new ConflictError("user"); + throw new ConflictError("user", e); } throw e; } diff --git a/backend/src/db/repo/user.ts b/backend/src/db/repo/user.ts index 0d4cdeec..cb8ed092 100644 --- a/backend/src/db/repo/user.ts +++ b/backend/src/db/repo/user.ts @@ -150,4 +150,8 @@ export class UserRepo { }, }); } + + async deleteById(userId: number) { + await this.client.user.delete({ where: { id: userId } }); + } } diff --git a/backend/src/handlers/acceptInvitation.ts b/backend/src/handlers/acceptInvitation.ts index 3aa2e4e4..3cb2be6c 100644 --- a/backend/src/handlers/acceptInvitation.ts +++ b/backend/src/handlers/acceptInvitation.ts @@ -1,20 +1,21 @@ -import { db, NotFoundError } from "../db/index.ts"; +import { acceptInvitation } from "../service/acceptInvitation.ts"; +import { InvitationNotFoundError } from "../service/common/errors.ts"; import { json, type HandlerMap } from "../types.ts"; import type { AuthenticatedRequest } from "./index.ts"; -export const acceptInvitation: HandlerMap["acceptInvitation"] = async ( +export const acceptInvitationHandler: HandlerMap["acceptInvitation"] = async ( ctx, req: AuthenticatedRequest, res, ) => { try { - await db.invitation.accept( + await acceptInvitation( ctx.request.params.invId, ctx.request.params.orgId, req.user.id, ); } catch (e: any) { - if (e instanceof NotFoundError) { + if (e instanceof InvitationNotFoundError) { return json(404, res, { code: 404, message: "Invitation not found." }); } throw e; diff --git a/backend/src/handlers/claimOrg.ts b/backend/src/handlers/claimOrg.ts index 71c3ccc0..4e0ea357 100644 --- a/backend/src/handlers/claimOrg.ts +++ b/backend/src/handlers/claimOrg.ts @@ -1,8 +1,10 @@ -import { db, NotFoundError } from "../db/index.ts"; +import { InstallationNotFoundError } from "../lib/octokit.ts"; +import { claimOrg } from "../service/claimOrg.ts"; +import { OrgNotFoundError } from "../service/common/errors.ts"; import { json, type HandlerMap } from "../types.ts"; import type { AuthenticatedRequest } from "./index.ts"; -export const claimOrg: HandlerMap["claimOrg"] = async ( +export const claimOrgHandler: HandlerMap["claimOrg"] = async ( ctx, req: AuthenticatedRequest, res, @@ -11,27 +13,19 @@ export const claimOrg: HandlerMap["claimOrg"] = async ( ctx.request.requestBody.unclaimedInstallationId; const orgId = ctx.request.params.orgId; try { - await db.org.claimInstallation( - orgId, - unassignedInstallationId, - req.user.id, - ); + await claimOrg(orgId, unassignedInstallationId, req.user.id); } catch (e) { - if (e instanceof NotFoundError) { - switch (e.message) { - case "installation": - return json(404, res, { - code: 404, - message: "Installation does not exist.", - }); - case "organization": - return json(404, res, { - code: 404, - message: "Organization does not exist.", - }); - } + if (e instanceof InstallationNotFoundError) { + return json(404, res, { + code: 404, + message: "Installation does not exist.", + }); + } else if (e instanceof OrgNotFoundError) { + return json(404, res, { + code: 404, + message: "Organization does not exist.", + }); } - throw e; } return json(200, res, {}); diff --git a/backend/src/handlers/createApp.ts b/backend/src/handlers/createApp.ts index 1f5078bb..27127753 100644 --- a/backend/src/handlers/createApp.ts +++ b/backend/src/handlers/createApp.ts @@ -1,223 +1,43 @@ -import { randomBytes } from "node:crypto"; -import { type Octokit } from "octokit"; -import { db } from "../db/index.ts"; -import type { App, DeploymentConfigCreate } from "../db/models.ts"; -import { PrismaClientKnownRequestError } from "../generated/prisma/internal/prismaNamespace.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, -} from "../lib/validate.ts"; + DeploymentError, + OrgNotFoundError, + ValidationError, +} from "../service/common/errors.ts"; +import { createApp, validateAppConfig } from "../service/createApp.ts"; import { json, type HandlerMap } from "../types.ts"; -import { buildAndDeploy } from "./githubWebhook.ts"; import { type AuthenticatedRequest } from "./index.ts"; -export const createApp: HandlerMap["createApp"] = async ( +export const createAppHandler: HandlerMap["createApp"] = async ( ctx, req: AuthenticatedRequest, res, ) => { - const appData = ctx.request.requestBody; - - const organization = await db.org.getById(appData.orgId, { - requireUser: { id: req.user.id }, - }); - - if (!organization) { - return json(400, res, { code: 400, message: "Organization not found" }); - } - try { - await validateDeploymentConfig({ - ...appData, - collectLogs: true, - }); - validateAppGroup(appData.appGroup); - validateAppName(appData.name); + const appId = await createApp( + ctx.request.requestBody, + await validateAppConfig(req.user.id, ctx.request.requestBody), + ); + return json(200, res, { id: appId }); } catch (e) { - return json(400, res, { - code: 400, - message: e.message, - }); - } - - let clusterUsername: string; - if (isRancherManaged()) { - if (!appData.projectId) { - return json(400, res, { code: 400, message: "Project ID is required" }); - } - - let { clusterUsername: username } = await db.user.getById(req.user.id); - if (!(await canManageProject(username, appData.projectId))) { - return json(400, res, { code: 400, message: "Project not found" }); - } - - clusterUsername = username; - } - - let commitSha = "unknown", - commitMessage = "Initial deployment"; - - if (appData.source === "git") { - if (!organization.githubInstallationId) { - return json(403, res, { - code: 403, - message: - "The AnvilOps GitHub App is not installed in this organization.", + if (e instanceof OrgNotFoundError) { + return json(400, res, { code: 400, message: "Organization not found" }); + } else if (e instanceof ValidationError) { + return json(400, res, { + code: 400, + message: e.message, }); - } - - let octokit: Octokit, repo: Awaited>; - - try { - octokit = await getOctokit(organization.githubInstallationId); - repo = await getRepoById(octokit, appData.repositoryId); - } catch (err) { - if (err.status === 404) { - return json(400, res, { code: 400, message: "Invalid repository id" }); - } - - console.error(err); + } else if (e instanceof DeploymentError) { + // The app was created, but a Deployment couldn't be created return json(500, res, { code: 500, - message: "Failed to look up GitHub repository.", + message: "Failed to create a deployment for your app.", }); - } - - 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)) { - return json(400, res, { code: 400, message: "Workflow not found" }); - } - } catch (err) { - console.error(err); - return json(500, res, { - code: 500, - message: "Failed to look up GitHub workflows.", - }); - } - } - - 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; - } - - 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": - appGroupId = await db.appGroup.create( - appData.orgId, - appData.appGroup.name, - false, - ); - break; - default: - appGroupId = appData.appGroup.id; - break; - } - - let namespace = appData.subdomain; - if (await namespaceInUse(getNamespace(namespace))) { - namespace += "-" + Math.floor(Math.random() * 10_000); - } - - try { - app = await db.app.create({ - orgId: appData.orgId, - appGroupId: appGroupId, - name: appData.name, - clusterUsername: clusterUsername, - projectId: appData.projectId, - namespace, - }); - } catch (err) { - if (err instanceof PrismaClientKnownRequestError && err.code === "P2002") { - // P2002 is "Unique Constraint Failed" - https://www.prisma.io/docs/orm/reference/error-reference#p2002 - const message = - err.meta?.target === "subdomain" - ? "Subdomain must be unique." - : "App group already exists in organization."; - return json(409, res, { - code: 409, - message, + } else { + console.error(e); + return json(500, res, { + code: 500, + message: "There was a problem creating your app.", }); } - console.error(err); - return json(500, res, { code: 500, message: "Unable to create app." }); } - - try { - await buildAndDeploy({ - org: organization, - app, - imageRepo: app.imageRepo, - commitMessage: commitMessage, - config: deploymentConfig, - createCheckRun: false, - }); - } catch (e) { - console.error(e); - return json(500, res, { - code: 500, - message: "Failed to create a deployment for your app.", - }); - } - - return json(200, res, { id: app.id }); }; diff --git a/backend/src/handlers/createAppGroup.ts b/backend/src/handlers/createAppGroup.ts index 828fc4d4..7d6a3e8a 100644 --- a/backend/src/handlers/createAppGroup.ts +++ b/backend/src/handlers/createAppGroup.ts @@ -1,249 +1,51 @@ -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 { 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, -} from "../lib/validate.ts"; + AppCreateError, + DeploymentError, + OrgNotFoundError, + ValidationError, +} from "../service/common/errors.ts"; +import { createAppGroup } from "../service/createAppGroup.ts"; import { json, type HandlerMap } from "../types.ts"; -import { buildAndDeploy } from "./githubWebhook.ts"; import type { AuthenticatedRequest } from "./index.ts"; -export const createAppGroup: HandlerMap["createAppGroup"] = async ( +export const createAppGroupHandler: HandlerMap["createAppGroup"] = async ( ctx, req: AuthenticatedRequest, res, ) => { const data = ctx.request.requestBody; - const organization = await db.org.getById(data.orgId, { - requireUser: { id: req.user.id }, - }); - if (!organization) { - return json(400, res, { code: 400, message: "Organization not found" }); - } - try { - validateAppGroup({ type: "create-new", name: data.name }); + await createAppGroup(req.user.id, data.orgId, data.name, data.apps); } catch (e) { - return json(400, res, { code: 400, message: e.message }); - } - const appValidationErrors = ( - await Promise.all( - data.apps.map(async (app) => { - try { - await validateDeploymentConfig({ - ...app, - collectLogs: true, - }); - validateAppName(app.name); - return null; - } catch (e) { - return e; - } - }), - ) - ).filter(Boolean); - - if (appValidationErrors.length > 0) { - return json(400, res, { - code: 400, - message: JSON.stringify(appValidationErrors), - }); - } - - const { clusterUsername } = await db.user.getById(req.user.id); - - if (isRancherManaged()) { - const permissionResults = await Promise.all( - data.apps.map(async (app) => ({ - project: app.projectId, - canManage: await canManageProject(clusterUsername, app.projectId), - })), - ); - - for (const result of permissionResults) { - if (!result.canManage) { + if (e instanceof AppCreateError) { + const ex = e.cause!; + if (ex instanceof OrgNotFoundError) { + return json(400, res, { code: 400, message: "Organization not found" }); + } else if (ex instanceof ValidationError) { return json(400, res, { code: 400, - message: `Project ${result.project} not found`, + message: ex.message, }); - } - } - } - - let octokit: Octokit; - if (data.apps.some((app) => app.source === "git")) { - if (!organization.githubInstallationId) { - return json(403, res, { - code: 403, - message: - "The AnvilOps GitHub App is not installed in this organization.", - }); - } else { - octokit = await getOctokit(organization.githubInstallationId); - } - - for (const app of data.apps) { - if (app.source !== "git") continue; - - try { - await getRepoById(octokit, app.repositoryId); - } catch (err) { - if (err.status === 404) { - return json(400, res, { - code: 400, - message: `Invalid repository id ${app.repositoryId} for app ${app.name}`, - }); - } - - console.error(err); + } else if (ex instanceof DeploymentError) { + // The app was created, but a Deployment couldn't be created return json(500, res, { code: 500, - message: `Failed to look up repository for app ${app.name}`, + message: `Failed to create a deployment for ${e.appName}.`, + }); + } else { + console.error(ex); + return json(500, res, { + code: 500, + message: `There was a problem creating ${e.appName}.`, }); } - - if (app.event === "workflow_run") { - try { - const workflows = await octokit - .request({ - method: "GET", - url: `/repositories/${app.repositoryId}/actions/workflows`, - }) - .then((res) => res.data.workflows); - if (!workflows.some((workflow) => workflow.id == app.eventId)) { - return json(400, res, { - code: 400, - message: `Invalid workflow id ${app.eventId} for app ${app.name}`, - }); - } - } catch (err) { - console.error(err); - return json(500, res, { - code: 500, - message: `Failed to look up workflow for app ${app.name}`, - }); - } - } - } - } - - const appGroupId = await db.appGroup.create(data.orgId, data.name, false); - - const appConfigs = await Promise.all( - data.apps.map(async (app) => { - let namespace = app.subdomain; - if (await namespaceInUse(getNamespace(namespace))) { - namespace += "-" + Math.floor(Math.random() * 10_000); - } - - return { - name: app.name, - displayName: app.name, - namespace, - orgId: app.orgId, - // This cluster username will be used to automatically update the app after a build job or webhook payload - clusterUsername, - projectId: app.projectId, - appGroupId, - logIngestSecret: randomBytes(48).toString("hex"), - }; - }), - ); - - const apps: App[] = []; - try { - for (const app of appConfigs) { - apps.push(await db.app.create(app)); - } - } catch (err) { - if (err instanceof ConflictError && err.message === "subdomain") { - return json(409, res, { - code: 409, - message: "Subdomain must be unique.", + } else if (e instanceof ValidationError) { + return json(400, res, { + code: 400, + message: e.message, }); - } else { - return json(500, res, { code: 500, message: "Unable to create app." }); } + throw e; } - - try { - await Promise.all( - apps.map((app, idx) => - (async () => { - let commitSha = "unknown", - commitMessage = "Initial deployment"; - - const configParams = data.apps[idx]; - const cpu = Math.round(configParams.cpuCores * 1000) + "m", - memory = configParams.memoryInMiB + "Mi"; - if (configParams.source === "git") { - const repo = await getRepoById(octokit, configParams.repositoryId); - 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; - } - - const deploymentConfig: DeploymentConfigCreate = { - collectLogs: true, - createIngress: configParams.createIngress, - subdomain: configParams.subdomain, - env: configParams.env, - requests: { cpu, memory }, - limits: { cpu, memory }, - replicas: 1, - port: configParams.port, - mounts: configParams.mounts, - ...(configParams.source === "git" - ? { - source: "GIT", - repositoryId: configParams.repositoryId, - event: configParams.event, - eventId: configParams.eventId, - branch: configParams.branch, - commitHash: commitSha, - builder: configParams.builder, - dockerfilePath: configParams.dockerfilePath, - rootDir: configParams.rootDir, - } - : { - source: "IMAGE", - imageTag: configParams.imageTag, - }), - }; - - await buildAndDeploy({ - org: organization, - app: app, - imageRepo: app.imageRepo, - commitMessage: commitMessage, - config: deploymentConfig, - createCheckRun: false, - }); - })(), - ), - ); - } catch (err) { - console.error(err); - return json(500, res, { - code: 500, - message: "Failed to create deployments for your apps.", - }); - } - - return json(200, res, {}); }; diff --git a/backend/src/handlers/createOrg.ts b/backend/src/handlers/createOrg.ts index b317f71d..662b0f29 100644 --- a/backend/src/handlers/createOrg.ts +++ b/backend/src/handlers/createOrg.ts @@ -1,14 +1,14 @@ -import { db } from "../db/index.ts"; +import { createOrg } from "../service/createOrg.ts"; import { json, type HandlerMap } from "../types.ts"; import type { AuthenticatedRequest } from "./index.ts"; -export const createOrg: HandlerMap["createOrg"] = async ( +export const createOrgHandler: HandlerMap["createOrg"] = async ( ctx, req: AuthenticatedRequest, res, ) => { const orgName = ctx.request.requestBody.name; - const result = await db.org.create(orgName, req.user.id); + const result = await createOrg(orgName, req.user.id); return json(200, res, { id: result.id, diff --git a/backend/src/handlers/deleteApp.ts b/backend/src/handlers/deleteApp.ts index 2681ef70..a0914e76 100644 --- a/backend/src/handlers/deleteApp.ts +++ b/backend/src/handlers/deleteApp.ts @@ -1,88 +1,23 @@ -import { db } from "../db/index.ts"; -import { - createOrUpdateApp, - deleteNamespace, - getClientsForRequest, -} from "../lib/cluster/kubernetes.ts"; -import { - createAppConfigsFromDeployment, - getNamespace, -} from "../lib/cluster/resources.ts"; -import { deleteRepo } from "../lib/registry.ts"; +import { AppNotFoundError } from "../service/common/errors.ts"; +import { deleteApp } from "../service/deleteApp.ts"; import { json, type HandlerMap } from "../types.ts"; import { type AuthenticatedRequest } from "./index.ts"; -export const deleteApp: HandlerMap["deleteApp"] = async ( +export const deleteAppHandler: HandlerMap["deleteApp"] = async ( ctx, req: AuthenticatedRequest, res, ) => { const appId = ctx.request.params.appId; - - const app = await db.app.getById(appId); - - // Check permission - const org = await db.org.getById(app.orgId, { - requireUser: { id: req.user.id, permissionLevel: "OWNER" }, - }); - if (!org) { - return json(404, res, { code: 404, message: "App not found" }); - } - - const { namespace, projectId, imageRepo } = app; - const lastDeployment = await db.app.getMostRecentDeployment(appId); - const config = await db.deployment.getConfig(lastDeployment.id); - - if (!ctx.request.requestBody.keepNamespace) { - try { - const { KubernetesObjectApi: api } = await getClientsForRequest( - req.user.id, - projectId, - ["KubernetesObjectApi"], - ); - await deleteNamespace(api, getNamespace(namespace)); - } catch (err) { - console.error("Failed to delete namespace:", err); - } - } else if (config.collectLogs) { - // If the log shipper was enabled, redeploy without it - config.collectLogs = false; // <-- Disable log shipping - - const app = await db.app.getById(lastDeployment.appId); - const [org, appGroup] = await Promise.all([ - db.org.getById(app.orgId), - db.appGroup.getById(app.appGroupId), - ]); - - const { namespace, configs, postCreate } = - await createAppConfigsFromDeployment( - org, - app, - appGroup, - lastDeployment, - config, - ); - - const { KubernetesObjectApi: api } = await getClientsForRequest( - req.user.id, - app.projectId, - ["KubernetesObjectApi"], - ); - await createOrUpdateApp(api, app.name, namespace, configs, postCreate); - } - - try { - if (imageRepo) await deleteRepo(imageRepo); - } catch (err) { - console.error("Couldn't delete image repository:", err); - } - try { - await db.app.delete(appId); - } catch (err) { - console.error(err); - return json(500, res, { code: 500, message: "Failed to delete app" }); + await deleteApp(appId, req.user.id, ctx.request.requestBody.keepNamespace); + return json(200, res, {}); + } catch (e) { + if (e instanceof AppNotFoundError) { + return json(404, res, { code: 404, message: "App not found" }); + } else { + console.error("Error deleting app: ", appId, e); + return json(500, res, { code: 500, message: "Failed to delete app" }); + } } - - return json(200, res, {}); }; diff --git a/backend/src/handlers/deleteAppPod.ts b/backend/src/handlers/deleteAppPod.ts index f05ce10b..1b54c056 100644 --- a/backend/src/handlers/deleteAppPod.ts +++ b/backend/src/handlers/deleteAppPod.ts @@ -1,31 +1,24 @@ -import { db } from "../db/index.ts"; -import { getClientsForRequest } from "../lib/cluster/kubernetes.ts"; -import { getNamespace } from "../lib/cluster/resources.ts"; +import { AppNotFoundError } from "../service/common/errors.ts"; +import { deleteAppPod } from "../service/deleteAppPod.ts"; import { json, type HandlerMap } from "../types.ts"; import type { AuthenticatedRequest } from "./index.ts"; -export const deleteAppPod: HandlerMap["deleteAppPod"] = async ( +export const deleteAppPodHandler: HandlerMap["deleteAppPod"] = async ( ctx, req: AuthenticatedRequest, res, ) => { - const app = await db.app.getById(ctx.request.params.appId, { - requireUser: { id: req.user.id }, - }); - if (!app) { - return json(404, res, { code: 404, message: "App not found." }); + try { + await deleteAppPod( + ctx.request.params.appId, + ctx.request.params.podName, + req.user.id, + ); + } catch (e) { + if (e instanceof AppNotFoundError) { + return json(404, res, { code: 404, message: "App not found." }); + } + throw e; } - - const { CoreV1Api: api } = await getClientsForRequest( - req.user.id, - app.projectId, - ["CoreV1Api"], - ); - - await api.deleteNamespacedPod({ - namespace: getNamespace(app.namespace), - name: ctx.request.params.podName, - }); - return json(204, res, {}); }; diff --git a/backend/src/handlers/deleteOrgByID.ts b/backend/src/handlers/deleteOrgByID.ts index 0d86a285..ce416bc2 100644 --- a/backend/src/handlers/deleteOrgByID.ts +++ b/backend/src/handlers/deleteOrgByID.ts @@ -1,61 +1,22 @@ -import { db } from "../db/index.ts"; -import { - deleteNamespace, - getClientForClusterUsername, - svcK8s, -} from "../lib/cluster/kubernetes.ts"; -import { getNamespace } from "../lib/cluster/resources.ts"; -import { env } from "../lib/env.ts"; +import { OrgNotFoundError } from "../service/common/errors.ts"; +import { deleteOrgByID } from "../service/deleteOrgByID.ts"; import { json, type HandlerMap } from "../types.ts"; import type { AuthenticatedRequest } from "./index.ts"; -export const deleteOrgByID: HandlerMap["deleteOrgByID"] = async ( +export const deleteOrgByIDHandler: HandlerMap["deleteOrgByID"] = async ( ctx, req: AuthenticatedRequest, res, ) => { - const orgId = ctx.request.params.orgId; - const org = await db.org.getById(orgId, { - requireUser: { id: req.user.id, permissionLevel: "OWNER" }, - }); - - if (!org) { - return json(404, res, { code: 404, message: "Organization not found." }); - } - - const user = await db.user.getById(req.user.id); - - const userApi = getClientForClusterUsername( - user.clusterUsername, - "KubernetesObjectApi", - true, - ); - - const apps = await db.app.listForOrg(orgId); - - for (let app of apps) { - const deployments = await db.app.getDeploymentsWithStatus(app.id, [ - "DEPLOYING", - "COMPLETE", - "ERROR", - ]); - - if (deployments.length > 0) { - try { - const api = - app.projectId === env.SANDBOX_ID - ? svcK8s["KubernetesObjectApi"] - : userApi; - await deleteNamespace(api, getNamespace(app.namespace)); - } catch (err) { - console.error(err); - } + try { + await deleteOrgByID(ctx.request.params.orgId, req.user.id); + } catch (e) { + if (e instanceof OrgNotFoundError) { + return json(404, res, { code: 404, message: "Organization not found." }); + } else { + throw e; } - - await db.app.delete(app.id); } - await db.org.delete(orgId); - return json(200, res, {}); }; diff --git a/backend/src/handlers/files.ts b/backend/src/handlers/files.ts index deb3041b..7161b33d 100644 --- a/backend/src/handlers/files.ts +++ b/backend/src/handlers/files.ts @@ -1,13 +1,14 @@ -import type { Response } from "express"; +import type { Response as ExpressResponse } from "express"; import { Readable } from "node:stream"; -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 "../service/common/errors.ts"; +import { forwardToFileBrowser } from "../service/files.ts"; import { json, type HandlerMap } from "../types.ts"; import type { AuthenticatedRequest } from "./index.ts"; -export const getAppFile: HandlerMap["getAppFile"] = async ( +export const getAppFileHandler: HandlerMap["getAppFile"] = async ( ctx, req: AuthenticatedRequest, res, @@ -22,7 +23,7 @@ export const getAppFile: HandlerMap["getAppFile"] = async ( ); }; -export const downloadAppFile: HandlerMap["downloadAppFile"] = async ( +export const downloadAppFileHandler: HandlerMap["downloadAppFile"] = async ( ctx, req: AuthenticatedRequest, res, @@ -37,7 +38,7 @@ export const downloadAppFile: HandlerMap["downloadAppFile"] = async ( ); }; -export const writeAppFile: HandlerMap["writeAppFile"] = async ( +export const writeAppFileHandler: HandlerMap["writeAppFile"] = async ( ctx, req: AuthenticatedRequest, res, @@ -60,7 +61,7 @@ export const writeAppFile: HandlerMap["writeAppFile"] = async ( ); }; -export const deleteAppFile: HandlerMap["deleteAppFile"] = async ( +export const deleteAppFileHandler: HandlerMap["deleteAppFile"] = async ( ctx, req: AuthenticatedRequest, res, @@ -81,32 +82,26 @@ async function forward( volumeClaimName: string, path: string, requestInit: RequestInit, - res: Response, + res: ExpressResponse, ) { - const app = await db.app.getById(appId, { requireUser: { id: userId } }); - - if (!app) { - return json(404, res, {}); - } - - const config = await db.app.getDeploymentConfig(appId); - - if ( - !config.mounts.some((mount) => - volumeClaimName.startsWith(generateVolumeName(mount.path) + "-"), - ) - ) { - // This persistent volume doesn't belong to the application - return json(400, res, {}); + let response: Response; + try { + response = await forwardToFileBrowser( + userId, + appId, + volumeClaimName, + path, + requestInit, + ); + } catch (e) { + if (e instanceof AppNotFoundError) { + return json(404, res, {}); + } else if (e instanceof IllegalPVCAccessError) { + return json(403, res, {}); + } + throw e; } - const response = await forwardRequest( - getNamespace(app.namespace), - volumeClaimName, - path, - requestInit, - ); - if (response.status === 404) { return json(404, res, {}); } else if (response.status === 500) { diff --git a/backend/src/handlers/getAppByID.ts b/backend/src/handlers/getAppByID.ts index 9a6028dd..ac066b17 100644 --- a/backend/src/handlers/getAppByID.ts +++ b/backend/src/handlers/getAppByID.ts @@ -1,113 +1,20 @@ -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 "../service/common/errors.ts"; +import { getAppByID } from "../service/getAppByID.ts"; import { json, type HandlerMap } from "../types.ts"; import type { AuthenticatedRequest } from "./index.ts"; -export const getAppByID: HandlerMap["getAppByID"] = async ( +export const getAppByIDHandler: HandlerMap["getAppByID"] = async ( ctx, req: AuthenticatedRequest, res, ) => { - const appId = ctx.request.params.appId; - - const [app, recentDeployment, deploymentCount] = await Promise.all([ - db.app.getById(appId, { requireUser: { id: req.user.id } }), - db.app.getMostRecentDeployment(appId), - db.app.getDeploymentCount(appId), - ]); - - if (!app) return json(404, res, { code: 404, message: "App not found." }); - - // Fetch the current StatefulSet to read its labels - const getK8sDeployment = async () => { - try { - const { AppsV1Api: api } = await getClientsForRequest( - req.user.id, - app.projectId, - ["AppsV1Api"], - ); - return await api.readNamespacedStatefulSet({ - namespace: getNamespace(app.namespace), - name: app.name, - }); - } catch {} - }; - - const [org, appGroup, currentConfig, activeDeployment] = await Promise.all([ - db.org.getById(app.orgId), - db.appGroup.getById(app.appGroupId), - db.deployment.getConfig(recentDeployment.id), - (await getK8sDeployment())?.spec?.template?.metadata?.labels?.[ - "anvilops.rcac.purdue.edu/deployment-id" - ], - ]); - - // Fetch repository info if this app is deployed from a Git repository - const { repoId, repoURL } = await (async () => { - if (currentConfig.source === "GIT" && org.githubInstallationId) { - const octokit = await getOctokit(org.githubInstallationId); - const repo = await getRepoById(octokit, currentConfig.repositoryId); - return { repoId: repo.id, repoURL: repo.html_url }; - } else { - return { repoId: undefined, repoURL: undefined }; + try { + const info = await getAppByID(ctx.request.params.appId, req.user.id); + return json(200, res, info); + } catch (e) { + if (e instanceof AppNotFoundError) { + return json(404, res, { code: 404, message: "App not found." }); } - })(); - - // TODO: Separate this into several API calls - return json(200, res, { - id: app.id, - orgId: app.orgId, - projectId: app.projectId, - name: app.name, - displayName: app.displayName, - createdAt: app.createdAt.toISOString(), - updatedAt: app.updatedAt.toISOString(), - repositoryId: repoId, - 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", - 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", - imageTag: currentConfig.imageTag, - }), - }, - appGroup: { - standalone: appGroup.isMono, - name: !appGroup.isMono ? appGroup.name : undefined, - id: app.appGroupId, - }, - activeDeployment: activeDeployment ? parseInt(activeDeployment) : undefined, - deploymentCount, - }); + throw e; + } }; diff --git a/backend/src/handlers/getAppLogs.ts b/backend/src/handlers/getAppLogs.ts index e173fa07..dc93ebe3 100644 --- a/backend/src/handlers/getAppLogs.ts +++ b/backend/src/handlers/getAppLogs.ts @@ -1,158 +1,59 @@ -import type { V1PodList } from "@kubernetes/client-node"; import { once } from "node:events"; -import stream from "node:stream"; -import { db } from "../db/index.ts"; import type { components } from "../generated/openapi.ts"; -import { getClientsForRequest } from "../lib/cluster/kubernetes.ts"; -import { getNamespace } from "../lib/cluster/resources.ts"; +import { AppNotFoundError } from "../service/common/errors.ts"; +import { getAppLogs } from "../service/getAppLogs.ts"; import { json, type HandlerMap } from "../types.ts"; import type { AuthenticatedRequest } from "./index.ts"; -export const getAppLogs: HandlerMap["getAppLogs"] = async ( +export const getAppLogsHandler: HandlerMap["getAppLogs"] = async ( ctx, req: AuthenticatedRequest, res, ) => { - const app = await db.app.getById(ctx.request.params.appId, { - requireUser: { id: req.user.id }, - }); + try { + const abortController = new AbortController(); + req.on("close", () => abortController.abort()); - if (app === null) { - return json(404, res, { code: 404, message: "App not found." }); - } - - res.set({ - "Cache-Control": "no-cache", - "Content-Type": "text/event-stream", - Connection: "keep-alive", - }); - res.flushHeaders(); - - const sendLog = async (log: components["schemas"]["LogLine"]) => { - const readyForMoreContent = res.write( - `event: log\nid: ${log.id}\ndata: ${JSON.stringify(log)}\n\n`, - ); - if (!readyForMoreContent) { - await once(res, "drain"); - } - }; + const sendLog = async (log: components["schemas"]["LogLine"]) => { + const readyForMoreContent = res.write( + `event: log\nid: ${log.id}\ndata: ${JSON.stringify(log)}\n\n`, + ); + if (!readyForMoreContent) { + await once(res, "drain"); + } + }; - // Pull logs from Postgres and send them to the client as they come in - if (typeof ctx.request.params.deploymentId !== "number") { - // Extra sanity check due to potential SQL injection below in `subscribe`; should never happen because of openapi-backend's request validation and additional sanitization in `subscribe()` - return json(400, res, { - code: 400, - message: "Deployment ID must be number.", + res.set({ + "Cache-Control": "no-cache", + "Content-Type": "text/event-stream", + Connection: "keep-alive", }); - } - - let lastLogId = -1; + res.flushHeaders(); - { // The Last-Event-Id header allows SSE streams to resume after being disconnected: https://html.spec.whatwg.org/multipage/server-sent-events.html#the-last-event-id-header - const lastEventId = req.headers["last-event-id"]; - if (lastEventId) { + let lastLogId = -1; + const lastEventIdHeader = req.headers["last-event-id"]; + if (lastEventIdHeader) { try { - lastLogId = parseInt(lastEventId.toString()); + lastLogId = parseInt(lastEventIdHeader.toString()); } catch {} } - } - - // 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; - if (collectLogs || ctx.request.query.type === "BUILD") { - const fetchNewLogs = async () => { - const newLogs = await db.deployment.getLogs( - ctx.request.params.deploymentId, - lastLogId, - ctx.request.query.type, - 500, - ); - if (newLogs.length > 0) { - lastLogId = newLogs[0].id; - } - for (const log of newLogs) { - await sendLog({ - id: log.id, - type: log.type, - stream: log.stream, - log: log.content as string, - pod: log.podName, - time: log.timestamp.toISOString(), - }); - } - }; - - // When new logs come in, send them to the client - const unsubscribe = await db.subscribe( - `deployment_${ctx.request.params.deploymentId}_logs`, - fetchNewLogs, - ); - - req.on("close", async () => { - await unsubscribe(); - }); - - // Send all previous logs now - await fetchNewLogs(); - } else { - const { CoreV1Api: core, Log: log } = await getClientsForRequest( + await getAppLogs( + ctx.request.params.appId, + ctx.request.params.deploymentId, req.user.id, - app.projectId, - ["CoreV1Api", "Log"], + ctx.request.query.type, + lastLogId, + abortController, + sendLog, ); - let pods: V1PodList; - try { - pods = await core.listNamespacedPod({ - namespace: getNamespace(app.namespace), - labelSelector: `anvilops.rcac.purdue.edu/deployment-id=${ctx.request.params.deploymentId}`, - }); - } catch (err) { - // Namespace may not be ready yet - pods = { apiVersion: "v1", items: [] }; - } - let podIndex = 0; - for (const pod of pods.items) { - podIndex++; - const podName = pod.metadata.name; - const logStream = new stream.PassThrough(); - const abortController = await log.log( - getNamespace(app.namespace), - podName, - pod.spec.containers[0].name, - logStream, - { follow: true, tailLines: 500, timestamps: true }, - ); - let i = 0; - let current = ""; - logStream.on("data", async (chunk: Buffer) => { - const str = chunk.toString(); - current += str; - if (str.endsWith("\n") || str.endsWith("\r")) { - const lines = current.split("\n"); - current = ""; - for (const line of lines) { - if (line.trim().length === 0) continue; - const [date, ...text] = line.split(" "); - await sendLog({ - type: "RUNTIME", - log: text.join(" "), - stream: "stdout", - pod: podName, - time: date, - id: podIndex * 100_000_000 + i, - }); - i++; - } - } - }); - - req.on("close", () => abortController.abort()); + res.write("event: pastLogsSent\ndata:\n\n"); // Let the browser know that all previous logs have been sent. If none were received, then there are no logs for this deployment so far. + } catch (e) { + if (e instanceof AppNotFoundError) { + return json(404, res, { code: 404, message: "App not found." }); } + throw e; } - - res.write("event: pastLogsSent\ndata:\n\n"); // Let the browser know that all previous logs have been sent. If none were received, then there are no logs for this deployment so far. }; diff --git a/backend/src/handlers/getAppStatus.ts b/backend/src/handlers/getAppStatus.ts index a30c42fe..bae9aef8 100644 --- a/backend/src/handlers/getAppStatus.ts +++ b/backend/src/handlers/getAppStatus.ts @@ -1,86 +1,21 @@ -import { - AbortError, - Watch, - type CoreV1EventList, - type KubernetesListObject, - type KubernetesObject, - type V1PodCondition, - type V1PodList, - type V1StatefulSet, -} from "@kubernetes/client-node"; import { once } from "node:events"; -import { db } from "../db/index.ts"; -import { getClientsForRequest } from "../lib/cluster/kubernetes.ts"; -import { getNamespace } from "../lib/cluster/resources.ts"; +import { AppNotFoundError } from "../service/common/errors.ts"; +import { getAppStatus, type StatusUpdate } from "../service/getAppStatus.ts"; import { json, type HandlerMap } from "../types.ts"; import type { AuthenticatedRequest } from "./index.ts"; -export const getAppStatus: HandlerMap["getAppStatus"] = async ( +export const getAppStatusHandler: HandlerMap["getAppStatus"] = async ( ctx, req: AuthenticatedRequest, res, ) => { - const app = await db.app.getById(ctx.request.params.appId, { - requireUser: { id: req.user.id }, - }); - - if (!app) { - return json(404, res, { code: 404, message: "App not found." }); - } - - res.set({ - "Cache-Control": "no-cache", - "Content-Type": "text/event-stream", - Connection: "keep-alive", - }); - res.flushHeaders(); + const abortController = new AbortController(); - let pods: V1PodList; - let statefulSet: V1StatefulSet; - let events: CoreV1EventList; + abortController.signal.addEventListener("abort", () => res.end()); + req.on("close", () => abortController.abort()); let lastStatus: string; - const update = async () => { - if (!pods || !events || !statefulSet) return; - const newStatus = { - pods: pods.items.map((pod) => ({ - id: pod.metadata?.uid, - name: pod.metadata?.name, - createdAt: pod.metadata?.creationTimestamp, - startedAt: pod.status?.startTime, - deploymentId: parseInt( - pod.metadata.labels["anvilops.rcac.purdue.edu/deployment-id"], - ), - node: pod.spec?.nodeName, - podScheduled: - getCondition(pod?.status?.conditions, "PodScheduled")?.status === - "True", - podReady: - getCondition(pod?.status?.conditions, "Ready")?.status === "True", - image: pod.status?.containerStatuses?.[0]?.image, - containerReady: pod.status?.containerStatuses?.[0]?.ready, - containerState: pod.status?.containerStatuses?.[0]?.state, - lastState: pod.status?.containerStatuses?.[0].lastState, - ip: pod.status.podIP, - })), - events: events.items.map((event) => ({ - reason: event.reason, - message: event.message, - count: event.count, - firstTimestamp: event.firstTimestamp.toISOString(), - lastTimestamp: event.lastTimestamp.toISOString(), - })), - statefulSet: { - readyReplicas: statefulSet.status.readyReplicas, - updatedReplicas: statefulSet.status.currentReplicas, - replicas: statefulSet.status.replicas, - generation: statefulSet.metadata.generation, - observedGeneration: statefulSet.status.observedGeneration, - currentRevision: statefulSet.status.currentRevision, - updateRevision: statefulSet.status.updateRevision, - }, - }; - + const update = async (newStatus: StatusUpdate) => { const str = JSON.stringify(newStatus); if (str !== lastStatus) { lastStatus = str; @@ -91,155 +26,24 @@ export const getAppStatus: HandlerMap["getAppStatus"] = async ( } }; - const ns = getNamespace(app.namespace); - - const close = (err: any) => { - if (!(err instanceof AbortError) && !(err.cause instanceof AbortError)) { - console.error("Kubernetes watch failed: ", err); - } - res.end(); - }; + res.set({ + "Cache-Control": "no-cache", + "Content-Type": "text/event-stream", + Connection: "keep-alive", + }); + res.flushHeaders(); try { - const { - CoreV1Api: core, - AppsV1Api: apps, - Watch: watch, - } = await getClientsForRequest(req.user.id, app.projectId, [ - "CoreV1Api", - "AppsV1Api", - "Watch", - ]); - const podWatcher = await watchList( - watch, - `/api/v1/namespaces/${ns}/pods`, - async () => - await core.listNamespacedPod({ - namespace: ns, - labelSelector: "anvilops.rcac.purdue.edu/deployment-id", - }), - { labelSelector: "anvilops.rcac.purdue.edu/deployment-id" }, - async (newValue) => { - pods = newValue; - await update(); - }, - close, + await getAppStatus( + ctx.request.params.appId, + req.user.id, + abortController, + update, ); - - const statefulSetWatcher = await watchList( - watch, - `/apis/apps/v1/namespaces/${ns}/statefulsets`, - async () => - await apps.listNamespacedStatefulSet({ - namespace: ns, - }), - {}, - async (newValue) => { - statefulSet = newValue.items.find( - (it) => it.metadata.name === app.name, - ); - await update(); - }, - close, - ); - - const fieldSelector = `involvedObject.kind=StatefulSet,involvedObject.name=${app.name},type=Warning`; - - const eventsWatcher = await watchList( - watch, - `/api/v1/namespaces/${ns}/events`, - async () => - await core.listNamespacedEvent({ - namespace: ns, - fieldSelector, - limit: 15, - }), - { fieldSelector, limit: 15 }, - async (newValue) => { - events = newValue; - await update(); - }, - close, - ); - - req.on("close", () => { - podWatcher.abort(); - eventsWatcher.abort(); - statefulSetWatcher.abort(); - }); } catch (e) { - close(e); + if (e instanceof AppNotFoundError) { + return json(404, res, { code: 404, message: "App not found." }); + } + throw e; } - - await update(); }; - -function getCondition(conditions: V1PodCondition[], condition: string) { - return conditions?.find((it) => it.type === condition); -} - -async function watchList>( - watch: Watch, - path: string, - getInitialValue: () => Promise, - queryParams: Record, - callback: (newValue: T) => void, - stop: (err: any) => void, -) { - let list: T; - try { - list = await getInitialValue(); - callback(list); - queryParams["resourceVersion"] = list.metadata.resourceVersion; - } catch (e) { - stop(new Error("Failed to fetch initial value for " + path, { cause: e })); - return; - } - - return await watch.watch( - path, - queryParams, - (phase, object: KubernetesObject, watch) => { - switch (phase) { - case "ADDED": { - list.items.push(object); - break; - } - case "MODIFIED": { - const index = list.items.findIndex( - (item) => item.metadata.uid === object.metadata.uid, - ); - if (index === -1) { - // Modified an item that we don't know about. Try adding it to the list. - list.items.push(object); - } else { - list.items[index] = object; - } - break; - } - case "DELETED": { - const index = list.items.findIndex( - (item) => item.metadata.uid === object.metadata.uid, - ); - if (index === -1) { - // Deleted an item that we don't know about - return; - } else { - list.items.splice(index, 1); - } - break; - } - } - try { - callback(structuredClone(list)); - } catch (e) { - stop( - new Error("Failed to invoke update callback for " + path, { - cause: e, - }), - ); - } - }, - (err) => stop(new Error("Failed to watch " + path, { cause: err })), - ); -} diff --git a/backend/src/handlers/getDeployment.ts b/backend/src/handlers/getDeployment.ts index 68bd9a45..37612d15 100644 --- a/backend/src/handlers/getDeployment.ts +++ b/backend/src/handlers/getDeployment.ts @@ -1,127 +1,23 @@ -import type { V1Pod } 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 "../service/common/errors.ts"; +import { getDeployment } from "../service/getDeployment.ts"; import { json, type HandlerMap } from "../types.ts"; import type { AuthenticatedRequest } from "./index.ts"; -export const getDeployment: HandlerMap["getDeployment"] = async ( +export const getDeploymentHandler: HandlerMap["getDeployment"] = async ( ctx, req: AuthenticatedRequest, res, ) => { - const deployment = await db.deployment.getById( - ctx.request.params.deploymentId, - { - requireUser: { id: req.user.id }, - }, - ); - - if (!deployment) { - return json(404, res, { code: 404, message: "Deployment not found." }); - } - - const [config, app] = await Promise.all([ - db.deployment.getConfig(deployment.id), - db.app.getById(deployment.appId), - ]); - - const org = await db.org.getById(app.orgId); - - const { CoreV1Api: api } = await getClientsForRequest( - req.user.id, - 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 - .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[] }), - ), - ]); - - let scheduled = 0, - ready = 0, - failed = 0; - - for (const pod of pods?.items ?? []) { - if ( - pod?.status?.conditions?.find((it) => it.type === "PodScheduled") - ?.status === "True" - ) { - scheduled++; - } - if ( - pod?.status?.conditions?.find((it) => it.type === "Ready")?.status === - "True" - ) { - ready++; - } - if ( - pod?.status?.phase === "Failed" || - pod?.status?.containerStatuses?.[0]?.state?.terminated - ) { - failed++; + try { + const deployment = await getDeployment( + ctx.request.params.deploymentId, + req.user.id, + ); + return json(200, res, deployment); + } catch (e) { + if (e instanceof DeploymentNotFoundError) { + return json(404, res, { code: 404, message: "Deployment not found." }); } + throw e; } - - const status = - deployment.status === "COMPLETE" && scheduled + ready + failed === 0 - ? "STOPPED" - : deployment.status; - - return json(200, res, { - repositoryURL, - commitHash: config.commitHash, - commitMessage: deployment.commitMessage, - 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" : "image", - 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, - }, - }); }; diff --git a/backend/src/handlers/getInstallation.ts b/backend/src/handlers/getInstallation.ts index f5dac30d..19ab29e6 100644 --- a/backend/src/handlers/getInstallation.ts +++ b/backend/src/handlers/getInstallation.ts @@ -1,38 +1,29 @@ -import { db } from "../db/index.ts"; -import { getOctokit } from "../lib/octokit.ts"; +import { InstallationNotFoundError } from "../lib/octokit.ts"; +import { OrgNotFoundError } from "../service/common/errors.ts"; +import { getInstallation } from "../service/getInstallation.ts"; import { json, type HandlerMap } from "../types.ts"; import type { AuthenticatedRequest } from "./index.ts"; -export const getInstallation: HandlerMap["getInstallation"] = async ( +export const getInstallationHandler: HandlerMap["getInstallation"] = async ( ctx, req: AuthenticatedRequest, res, ) => { - const org = await db.org.getById(ctx.request.params.orgId, { - requireUser: { id: req.user.id }, - }); - - if (!org) { - return json(404, res, { code: 404, message: "Organization not found." }); - } - - if (!org.githubInstallationId) { - return json(404, res, { code: 404, message: "GitHub app not installed." }); + try { + const installation = await getInstallation( + ctx.request.params.orgId, + req.user.id, + ); + return json(200, res, installation); + } catch (e) { + if (e instanceof OrgNotFoundError) { + return json(404, res, { code: 404, message: "Organization not found." }); + } else if (e instanceof InstallationNotFoundError) { + return json(404, res, { + code: 404, + message: "GitHub app not installed.", + }); + } + throw e; } - - const octokit = await getOctokit(org.githubInstallationId); - const installation = await octokit.rest.apps.getInstallation({ - installation_id: org.githubInstallationId, - }); - - return json(200, res, { - hasAllRepoAccess: installation.data.repository_selection === "all", - targetId: installation.data.target_id, - targetType: installation.data.target_type as "User" | "Organization", - targetName: - // `slug` is present when `account` is an Organization, and `login` is present when it's a User - "slug" in installation.data.account - ? installation.data.account.slug - : installation.data.account.login, - }); }; diff --git a/backend/src/handlers/getOrgByID.ts b/backend/src/handlers/getOrgByID.ts index 2f67946f..9f2796b1 100644 --- a/backend/src/handlers/getOrgByID.ts +++ b/backend/src/handlers/getOrgByID.ts @@ -1,112 +1,23 @@ -import type { Octokit } from "octokit"; -import { db } from "../db/index.ts"; -import type { components } from "../generated/openapi.ts"; -import { env } from "../lib/env.ts"; -import { getOctokit, getRepoById } from "../lib/octokit.ts"; +import { OrgNotFoundError } from "../service/common/errors.ts"; +import { getOrgByID } from "../service/getOrgByID.ts"; import { json, type HandlerMap } from "../types.ts"; import type { AuthenticatedRequest } from "./index.ts"; -export const getOrgByID: HandlerMap["getOrgByID"] = async ( +export const getOrgByIDHandler: HandlerMap["getOrgByID"] = async ( ctx, req: AuthenticatedRequest, res, ) => { - const orgId: number = ctx.request.params.orgId; - - const org = await db.org.getById(orgId, { requireUser: { id: req.user.id } }); - - if (!org) { - return json(404, res, { - code: 404, - message: "Organization not found.", - }); + try { + const org = await getOrgByID(ctx.request.params.orgId, req.user.id); + return json(200, res, org); + } catch (e) { + if (e instanceof OrgNotFoundError) { + return json(404, res, { + code: 404, + message: "Organization not found.", + }); + } + throw e; } - - const [apps, appGroups, outgoingInvitations, users] = await Promise.all([ - db.app.listForOrg(org.id), - db.appGroup.listForOrg(org.id), - db.invitation.listOutgoingForOrg(org.id), - db.org.listUsers(org.id), - ]); - - let octokit: Promise; - - if (org.githubInstallationId) { - octokit = getOctokit(org.githubInstallationId); - } - - const hydratedApps = await Promise.all( - apps.map(async (app) => { - const [config, selectedDeployment] = await Promise.all([ - db.app.getDeploymentConfig(app.id), - db.app.getMostRecentDeployment(app.id), - ]); - - if (!config) { - return null; - } - - let repoURL: string; - if (config.source === "GIT" && org.githubInstallationId) { - try { - const repo = await getRepoById(await octokit, config.repositoryId); - repoURL = repo.html_url; - } catch (error: any) { - if (error?.status === 404) { - // The repo couldn't be found. Either it doesn't exist or the installation doesn't have permission to see it. - return; - } - throw error; // Rethrow all other kinds of errors - } - } - - const appDomain = URL.parse(env.APP_DOMAIN); - - return { - id: app.id, - groupId: app.appGroupId, - 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, - }; - }), - ); - - const appGroupRes: components["schemas"]["Org"]["appGroups"] = appGroups.map( - (group) => { - return { - ...group, - apps: hydratedApps.filter((app) => app?.groupId === group.id), - }; - }, - ); - - return json(200, res, { - id: org.id, - name: org.name, - members: users.map((membership) => ({ - id: membership.user.id, - name: membership.user.name, - email: membership.user.email, - permissionLevel: membership.permissionLevel, - })), - githubInstallationId: org.githubInstallationId, - appGroups: appGroupRes, - outgoingInvitations: outgoingInvitations.map((inv) => ({ - id: inv.id, - inviter: { name: inv.inviter.name }, - invitee: { name: inv.invitee.name }, - org: { id: inv.orgId, name: inv.org.name }, - })), - }); }; diff --git a/backend/src/handlers/getSettings.ts b/backend/src/handlers/getSettings.ts index 154fb44b..31d63eb3 100644 --- a/backend/src/handlers/getSettings.ts +++ b/backend/src/handlers/getSettings.ts @@ -1,31 +1,10 @@ -import fs from "fs"; -import { isRancherManaged } from "../lib/cluster/rancher.ts"; -import { env } from "../lib/env.ts"; +import { getSettings } from "../service/getSettings.ts"; import { json, type HandlerMap } from "../types.ts"; -type ClusterConfig = { - name?: string; - faq?: { - question?: string; - answer?: string; - link?: string; - }; -}; -let clusterConfig: null | ClusterConfig = null; -const configPath = - env["NODE_ENV"] === "development" - ? "./cluster.local.json" - : env.CLUSTER_CONFIG_PATH; -if (configPath) { - clusterConfig = JSON.parse(fs.readFileSync(configPath).toString()); -} - -export const getSettings: HandlerMap["getSettings"] = (ctx, req, res) => { - return json(200, res, { - appDomain: !!env.INGRESS_CLASS_NAME ? env.APP_DOMAIN : undefined, - clusterName: clusterConfig?.name, - faq: clusterConfig?.faq, - storageEnabled: env.STORAGE_CLASS_NAME !== undefined, - isRancherManaged: isRancherManaged(), - }); +export const getSettingsHandler: HandlerMap["getSettings"] = async ( + ctx, + req, + res, +) => { + return json(200, res, await getSettings()); }; diff --git a/backend/src/handlers/getTemplates.ts b/backend/src/handlers/getTemplates.ts index a1cd71b8..0b1f3714 100644 --- a/backend/src/handlers/getTemplates.ts +++ b/backend/src/handlers/getTemplates.ts @@ -1,15 +1,10 @@ -import fs from "node:fs"; +import { getTemplates } from "../service/getTemplates.ts"; import { json, type HandlerMap } from "../types.ts"; -export const getTemplates: HandlerMap["getTemplates"] = async ( +export const getTemplatesHandler: HandlerMap["getTemplates"] = async ( ctx, req, res, ) => { - const path = - process.env.NODE_ENV === "development" - ? "../templates/templates.json" - : "./templates.json"; - const data = JSON.parse(fs.readFileSync(path, "utf8")); - return json(200, res, data); + return json(200, res, await getTemplates()); }; diff --git a/backend/src/handlers/getUser.ts b/backend/src/handlers/getUser.ts index cfa9caf7..2d575221 100644 --- a/backend/src/handlers/getUser.ts +++ b/backend/src/handlers/getUser.ts @@ -1,46 +1,12 @@ -import { db } from "../db/index.ts"; -import { - getProjectsForUser, - isRancherManaged, -} from "../lib/cluster/rancher.ts"; +import { getUser } from "../service/getUser.ts"; import { json, type HandlerMap } from "../types.ts"; import type { AuthenticatedRequest } from "./index.ts"; -export const getUser: HandlerMap["getUser"] = async ( +export const getUserHandler: HandlerMap["getUser"] = async ( ctx, req: AuthenticatedRequest, res, ) => { - const [user, orgs, unassignedInstallations, receivedInvitations] = - await Promise.all([ - db.user.getById(req.user.id), - db.user.getOrgs(req.user.id), - db.user.getUnassignedInstallations(req.user.id), - db.invitation.listReceived(req.user.id), - ]); - - const projects = - user?.clusterUsername && isRancherManaged() - ? await getProjectsForUser(user.clusterUsername) - : undefined; - - return json(200, res, { - id: user.id, - email: user.email, - name: user.name, - orgs: orgs.map((item) => ({ - id: item.organization.id, - name: item.organization.name, - permissionLevel: item.permissionLevel, - githubConnected: item.organization.githubInstallationId !== null, - })), - projects, - unassignedInstallations: unassignedInstallations, - receivedInvitations: receivedInvitations.map((inv) => ({ - id: inv.id, - inviter: { name: inv.inviter.name }, - invitee: { name: inv.invitee.name }, - org: { id: inv.orgId, name: inv.org.name }, - })), - }); + const user = await getUser(req.user.id); + return json(200, res, user); }; diff --git a/backend/src/handlers/githubAppInstall.ts b/backend/src/handlers/githubAppInstall.ts index 63b793a3..6bc21bbf 100644 --- a/backend/src/handlers/githubAppInstall.ts +++ b/backend/src/handlers/githubAppInstall.ts @@ -1,11 +1,9 @@ -import { randomBytes } from "node:crypto"; -import { db } from "../db/index.ts"; -import type { GitHubOAuthState } from "../generated/prisma/client.ts"; -import { - PermissionLevel, - type GitHubOAuthAction, -} from "../generated/prisma/enums.ts"; import { env } from "../lib/env.ts"; +import { + OrgAlreadyLinkedError, + OrgNotFoundError, +} from "../service/common/errors.ts"; +import { createGitHubAppInstallState } from "../service/githubAppInstall.ts"; import { json, redirect, type HandlerMap } from "../types.ts"; import { githubConnectError } from "./githubOAuthCallback.ts"; import type { AuthenticatedRequest } from "./index.ts"; @@ -25,55 +23,35 @@ import type { AuthenticatedRequest } from "./index.ts"; * * This endpoint handles step 1 of the process. */ -export const githubAppInstall: HandlerMap["githubAppInstall"] = async ( +export const githubAppInstallHandler: HandlerMap["githubAppInstall"] = async ( ctx, req: AuthenticatedRequest, res, ) => { - const orgId = ctx.request.params.orgId; - - const org = await db.org.getById(orgId, { - requireUser: { id: req.user.id, permissionLevel: PermissionLevel.OWNER }, - }); - - if (org.githubInstallationId) { - return json(400, res, { - code: 400, - message: "This organization is already linked to GitHub.", - }); - } - - if (org === null) { - return json(404, res, { code: 404, message: "Organization not found." }); - } - - let state: string; try { - state = await createState("CREATE_INSTALLATION", req.user.id, orgId); + const newState = await createGitHubAppInstallState( + ctx.request.params.orgId, + req.user.id, + ); + + return redirect( + 302, + res, + `${env.GITHUB_BASE_URL}/github-apps/${env.GITHUB_APP_NAME}/installations/new?state=${newState}`, + ); + + // When GitHub redirects back, we handle it in githubInstallCallback.ts } catch (e) { - console.error("Error creating state", e); - return githubConnectError(res, "STATE_FAIL"); + if (e instanceof OrgAlreadyLinkedError) { + json(400, res, { + code: 400, + message: "This organization is already linked to GitHub.", + }); + } else if (e instanceof OrgNotFoundError) { + return json(404, res, { code: 404, message: "Organization not found." }); + } else { + console.error("Error creating GitHub OAuth state:", e); + return githubConnectError(res, "STATE_FAIL"); + } } - - return redirect( - 302, - res, - `${env.GITHUB_BASE_URL}/github-apps/${env.GITHUB_APP_NAME}/installations/new?state=${state}`, - ); - - // When GitHub redirects back, we handle it in githubInstallCallback.ts }; - -export async function createState( - action: GitHubOAuthAction, - userId: number, - orgId: number, -) { - const random = randomBytes(64).toString("base64url"); - await db.user.setOAuthState(orgId, userId, action, random); - return random; -} - -export async function verifyState(random: string): Promise { - return await db.user.getAndDeleteOAuthState(random); -} diff --git a/backend/src/handlers/githubInstallCallback.ts b/backend/src/handlers/githubInstallCallback.ts index 8c2b080f..275286a0 100644 --- a/backend/src/handlers/githubInstallCallback.ts +++ b/backend/src/handlers/githubInstallCallback.ts @@ -1,7 +1,11 @@ -import { db } from "../db/index.ts"; import { env } from "../lib/env.ts"; +import { + GitHubOAuthAccountMismatchError, + GitHubOAuthStateMismatchError, + ValidationError, +} from "../service/common/errors.ts"; +import { createGitHubAuthorizationState } from "../service/githubInstallCallback.ts"; import { json, redirect, type HandlerMap } from "../types.ts"; -import { createState, verifyState } from "./githubAppInstall.ts"; import { githubConnectError } from "./githubOAuthCallback.ts"; import type { AuthenticatedRequest } from "./index.ts"; @@ -9,75 +13,36 @@ import type { AuthenticatedRequest } from "./index.ts"; * This endpoint is called after the user installs the GitHub App on their user account or organization. * The URL of this endpoint should be used as the GitHub App's "Setup URL". * - * We (1-2) validate the `state`, (3) save the installation ID in a temporary location, and then (4-5) redirect back to GitHub to authorize. + * We validate the `state`, save the installation ID in a temporary location, and then redirect back to GitHub to authorize. * After that, we will use the authorization to verify that the user has access to the installation ID that they provided, and then * the installation ID can be linked to the organization and the user access token can be discarded. */ -export const githubInstallCallback: HandlerMap["githubInstallCallback"] = +export const githubInstallCallbackHandler: HandlerMap["githubInstallCallback"] = async (ctx, req: AuthenticatedRequest, res) => { - const state = ctx.request.query.state; - const installationId = ctx.request.query.installation_id; - - if ( - !installationId && - (ctx.request.query.setup_action === "install" || - ctx.request.query.setup_action === "update") - ) { - return json(400, res, { code: 400, message: "Missing installation ID." }); - } - - // 1) Verify the `state` - let userId: number, orgId: number; try { - const parsed = await verifyState(state); - userId = parsed.userId; - orgId = parsed.orgId; - - if (parsed.action !== "CREATE_INSTALLATION") { - return githubConnectError(res, "STATE_FAIL"); - } - } catch (e) { - return githubConnectError(res, "STATE_FAIL"); - } - - // 1.5) Make sure the app was actually installed - if (ctx.request.query.setup_action === "request") { - // The user sent a request to an admin to approve their installation. - // We have to bail early here because we don't have the installation ID yet. It will come in through a webhook when the request is approved. - // Next, we'll get the user's GitHub user ID and save it for later so that we can associate the new installation with them. - const newState = await createState( - "GET_UID_FOR_LATER_INSTALLATION", - userId, - orgId, + const newState = await createGitHubAuthorizationState( + ctx.request.query.state, + ctx.request.query.installation_id, + ctx.request.query.setup_action, + req.user.id, ); + + // Redirect back to GitHub to get a user access token return redirect( 302, res, `${env.GITHUB_BASE_URL}/login/oauth/authorize?client_id=${env.GITHUB_CLIENT_ID}&state=${newState}`, ); - } - // 2) Verify the user ID hasn't changed - if (userId !== req.user.id) { - return githubConnectError(res, "DIFF_ACCOUNT"); + // When GitHub redirects back, we handle it in githubOAuthCallback.ts + } catch (e) { + if (e instanceof ValidationError) { + return json(400, res, { code: 400, message: e.message }); + } else if (e instanceof GitHubOAuthAccountMismatchError) { + return githubConnectError(res, "DIFF_ACCOUNT"); + } else if (e instanceof GitHubOAuthStateMismatchError) { + return githubConnectError(res, "STATE_FAIL"); + } + throw e; } - - // 3) Save the installation ID temporarily - await db.org.setTemporaryInstallationId(orgId, userId, installationId); - - // 4) Generate a new `state` - const newState = await createState( - "VERIFY_INSTALLATION_ACCESS", - userId, - orgId, - ); - - // 5) Redirect back to GitHub to get a user access token - return redirect( - 302, - res, - `${env.GITHUB_BASE_URL}/login/oauth/authorize?client_id=${env.GITHUB_CLIENT_ID}&state=${newState}`, - ); - - // When GitHub redirects back, we handle it in githubOAuthCallback.ts }; diff --git a/backend/src/handlers/githubOAuthCallback.ts b/backend/src/handlers/githubOAuthCallback.ts index 78905e67..caa79c1a 100644 --- a/backend/src/handlers/githubOAuthCallback.ts +++ b/backend/src/handlers/githubOAuthCallback.ts @@ -1,13 +1,15 @@ import type { Response } from "express"; -import { db } from "../db/index.ts"; +import { InstallationNotFoundError } from "../lib/octokit.ts"; import { - PermissionLevel, - type GitHubOAuthAction, -} from "../generated/prisma/enums.ts"; -import { getUserOctokit } from "../lib/octokit.ts"; + GitHubInstallationForbiddenError, + GitHubOAuthAccountMismatchError, + GitHubOAuthStateMismatchError, + OrgNotFoundError, +} from "../service/common/errors.ts"; +import { processGitHubOAuthResponse } from "../service/githubOAuthCallback.ts"; import { redirect, type HandlerMap } from "../types.ts"; -import { verifyState } from "./githubAppInstall.ts"; import type { AuthenticatedRequest } from "./index.ts"; + /** * This endpoint is called after the user signs in with GitHub. * @@ -16,6 +18,38 @@ import type { AuthenticatedRequest } from "./index.ts"; * * In this handler, we perform that verification and then redirect back to the frontend. */ +export const githubOAuthCallbackHandler: HandlerMap["githubOAuthCallback"] = + async (ctx, req: AuthenticatedRequest, res) => { + try { + const result = await processGitHubOAuthResponse( + ctx.request.query.state, + ctx.request.query.code, + req.user.id, + ); + + if (result === "done") { + return redirect(302, res, "/dashboard"); + } else if (result === "approval-needed") { + return redirect(302, res, "/github-approval-pending"); + } else { + throw new Error("Unexpected GitHub OAuth result: " + result); + } + } catch (e) { + if (e instanceof GitHubOAuthStateMismatchError) { + return githubConnectError(res, "STATE_FAIL"); + } else if (e instanceof GitHubOAuthAccountMismatchError) { + return githubConnectError(res, "DIFF_ACCOUNT"); + } else if (e instanceof OrgNotFoundError) { + return githubConnectError(res, "ORG_FAIL"); + } else if (e instanceof InstallationNotFoundError) { + // Thrown when newInstallationId doesn't exist on the organization in cases where it should + return githubConnectError(res, "STATE_FAIL"); + } else if (e instanceof GitHubInstallationForbiddenError) { + return githubConnectError(res, "INSTALLATION_FAIL"); + } + throw e; + } + }; export const githubConnectError = ( res: Response, @@ -29,77 +63,3 @@ export const githubConnectError = ( ) => { return redirect(302, res, `/error?type=github_app&code=${code}`); }; - -export const githubOAuthCallback: HandlerMap["githubOAuthCallback"] = async ( - ctx, - req: AuthenticatedRequest, - res, -) => { - const state = ctx.request.query.state; - const code = ctx.request.query.code; - - // 1) Verify the `state` and extract the user and org IDs - let action: GitHubOAuthAction, userId: number, orgId: number; - try { - const parsed = await verifyState(state); - action = parsed.action; - userId = parsed.userId; - orgId = parsed.orgId; - } catch (e) { - return githubConnectError(res, "STATE_FAIL"); - } - - // 2) Verify that the user ID hasn't changed - if (userId !== req.user.id) { - return githubConnectError(res, "DIFF_ACCOUNT"); - } - - // 3) Verify that the user has access to the installation - if (action === "VERIFY_INSTALLATION_ACCESS") { - const octokit = getUserOctokit(code); - - const org = await db.org.getById(orgId, { - requireUser: { id: userId, permissionLevel: PermissionLevel.OWNER }, - }); - - if (!org) { - return githubConnectError(res, "ORG_FAIL"); - } - - if (!org?.newInstallationId) { - return githubConnectError(res, ""); - } - - const installations = ( - await octokit.rest.apps.listInstallationsForAuthenticatedUser() - ).data.installations; - let found = false; - for (const install of installations) { - if (install.id === org.newInstallationId) { - found = true; - break; - } - } - - if (!found) { - // The user doesn't have access to the new installation - return githubConnectError(res, "INSTALLATION_FAIL"); - } - - // Update the organization's installation ID - await db.org.setInstallationId(orgId, org.newInstallationId); - - // We're finally done! Redirect the user back to the frontend. - return redirect(302, res, "/dashboard"); - } else if (state === "GET_UID_FOR_LATER_INSTALLATION") { - const octokit = getUserOctokit(code); - const user = await octokit.rest.users.getAuthenticated(); - - await db.user.setGitHubUserId(userId, user.data.id); - - // Redirect the user to a page that says the app approval is pending and that they can link the installation to an organization when the request is approved. - return redirect(302, res, "/github-approval-pending"); - } else { - return githubConnectError(res, "STATE_FAIL"); - } -}; diff --git a/backend/src/handlers/githubWebhook.ts b/backend/src/handlers/githubWebhook.ts index 8957d055..cd250c2b 100644 --- a/backend/src/handlers/githubWebhook.ts +++ b/backend/src/handlers/githubWebhook.ts @@ -1,48 +1,19 @@ import { Webhooks } from "@octokit/webhooks"; -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 { env } from "../lib/env.ts"; import { - getInstallationAccessToken, - getOctokit, - getRepoById, -} from "../lib/octokit.ts"; + AppNotFoundError, + UnknownWebhookRequestTypeError, + ValidationError, +} from "../service/common/errors.ts"; +import { processGitHubWebhookPayload } from "../service/githubWebhook.ts"; import { json, type HandlerMap } from "../types.ts"; -import { handlePush } from "./webhook/push.ts"; -import { handleWorkflowRun } from "./webhook/workflow_run.ts"; const webhooks = new Webhooks({ secret: env.GITHUB_WEBHOOK_SECRET }); -export const githubWebhook: HandlerMap["githubWebhook"] = async ( +export const githubWebhookHandler: HandlerMap["githubWebhook"] = async ( ctx, req, res, - next, ) => { const signature = ctx.request.headers["x-hub-signature-256"]; const data = req.body as string; @@ -59,397 +30,19 @@ export const githubWebhook: HandlerMap["githubWebhook"] = async ( const requestType = ctx.request.headers["x-github-event"]; const action = ctx.request.requestBody["action"]; - switch (requestType) { - case "repository": { - switch (action) { - case "transferred": { - const payload = ctx.request - .requestBody as components["schemas"]["webhook-repository-transferred"]; - // TODO - break; - } - case "deleted": { - const payload = ctx.request - .requestBody as components["schemas"]["webhook-repository-deleted"]; - // Unlink the repository from all of its associated apps - // Every deployment from that repository will now be listed as directly from the produced container image - await db.deployment.unlinkRepositoryFromAllDeployments( - payload.repository.id, - ); - return json(200, res, {}); - } - default: { - return json(422, res, {}); - } - } - break; - } - case "installation": { - switch (action) { - case "created": { - const payload = ctx.request - .requestBody as components["schemas"]["webhook-installation-created"]; - // This webhook is sent when the GitHub App is installed or a request to install the GitHub App is approved. Here, we care about the latter. - if (!payload.requester) { - // Since this installation has no requester, it was created without going to an organization admin for approval. That means it's already been linked to an AnvilOps organization in src/handlers/githubOAuthCallback.ts. - // TODO: Verify that the requester field is what I think it is. GitHub doesn't provide any description of it in their API docs. - return json(200, res, {}); - } - - if (payload.installation.app_id.toString() !== env.GITHUB_APP_ID) { - // Sanity check - return json(422, res, { message: "Unknown app ID" }); - } - - // Find the person who requested the app installation and add a record linked to their account that allows them to link the installation to an organization of their choosing - try { - await db.user.createUnassignedInstallation( - payload.requester.id, - payload.installation.id, - payload.installation["login"] ?? - payload.installation.account.name, - payload.installation.html_url, - ); - } catch (e) { - if (e instanceof NotFoundError && e.message === "user") { - return json(200, res, { - message: - "No AnvilOps user found that matches the installation request's sender", - }); - } else { - throw e; - } - } - - return json(200, res, { - message: "Unassigned installation created successfully", - }); - } - case "deleted": { - const payload = ctx.request - .requestBody as components["schemas"]["webhook-installation-deleted"]; - // Unlink the GitHub App installation from the organization - await db.org.unlinkInstallationFromAllOrgs(payload.installation.id); - return json(200, res, {}); - } - default: { - return json(422, res, {}); - } - } - break; - } - case "push": { - return await handlePush(ctx, req, res, next); - } - case "workflow_run": { - return await handleWorkflowRun(ctx, req, res, next); - } - default: { - return json(422, res, {}); - } - } - - return json(200, res, {}); -}; - -export async function generateCloneURLWithCredentials( - octokit: Octokit, - originalURL: string, -) { - const url = URL.parse(originalURL); - - if (url.host !== URL.parse(env.GITHUB_BASE_URL).host) { - // If the target is on a different GitHub instance, don't add credentials! - return originalURL; - } - - const token = await getInstallationAccessToken(octokit); - url.username = "x-access-token"; - url.password = token; - return url.toString(); -} - -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); + await processGitHubWebhookPayload(requestType, action, JSON.parse(data)); + return json(200, res, {}); } 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", - }); - log( - deployment.id, - "BUILD", - "Updated GitHub check run to Completed with conclusion Cancelled", - ); + if (e instanceof ValidationError) { + return json(400, res, { code: 400, message: e.message }); + } else if (e instanceof AppNotFoundError) { + // GitHub sent a webhook about a repository, but it's not linked to any apps - nothing to do here + return json(200, res, {}); + } else if (e instanceof UnknownWebhookRequestTypeError) { + // GitHub sent a webhook payload that we don't care about + return json(422, res, {}); } + throw e; } -} - -export async function log( - deploymentId: number, - type: LogType, - content: string, - stream: LogStream = "stdout", -) { - try { - await db.deployment.insertLogs([ - { - deploymentId, - content, - type, - stream, - podName: undefined, - timestamp: new Date(), - }, - ]); - } catch { - // Don't let errors bubble up and disrupt the deployment process - } -} +}; diff --git a/backend/src/handlers/importGitRepo.ts b/backend/src/handlers/importGitRepo.ts index eec69114..f91628a1 100644 --- a/backend/src/handlers/importGitRepo.ts +++ b/backend/src/handlers/importGitRepo.ts @@ -1,110 +1,59 @@ -import type { Request, Response } from "express"; -import { db } from "../db/index.ts"; import { env } from "../lib/env.ts"; -import { getLocalRepo, importRepo } from "../lib/import.ts"; -import { getOctokit } from "../lib/octokit.ts"; +import { OrgNotFoundError } from "../service/common/errors.ts"; +import { + createRepoImportState, + importGitRepo, +} from "../service/importGitRepo.ts"; import { json, type HandlerMap } from "../types.ts"; import type { AuthenticatedRequest } from "./index.ts"; -export const importGitRepoCreateState: HandlerMap["importGitRepoCreateState"] = +export const importGitRepoCreateStateHandler: HandlerMap["importGitRepoCreateState"] = async (ctx, req: AuthenticatedRequest, res) => { - const { sourceURL, destIsOrg, destOwner, destRepo, makePrivate } = - ctx.request.requestBody; - - const org = await db.org.getById(ctx.request.params.orgId, { - requireUser: { id: req.user.id, permissionLevel: "OWNER" }, - }); - - if (!org) { - return json(404, res, { code: 404, message: "Organization not found." }); - } - - if (!org.githubInstallationId) { - return json(403, res, { - code: 403, - message: "Organization has not installed the GitHub App", - }); - } - - const stateId = await db.repoImportState.create( - req.user.id, - org.id, - destIsOrg, - destOwner, - destRepo, - makePrivate, - sourceURL, - ); - - const octokit = await getOctokit(org.githubInstallationId); - const isLocalRepo = !!(await getLocalRepo(octokit, URL.parse(sourceURL))); - - if (destIsOrg || isLocalRepo) { - // We can create the repo now - // Fall into the importGitRepo handler directly - return await importRepoHandler(stateId, undefined, req.user.id, req, res); - } else { - // We need a user access token - const redirectURL = `${req.protocol}://${req.host}/import-repo`; - return json(200, res, { - url: `${env.GITHUB_BASE_URL}/login/oauth/authorize?client_id=${env.GITHUB_CLIENT_ID}&state=${stateId}&redirect_uri=${encodeURIComponent(redirectURL)}`, - }); + try { + const result = await createRepoImportState( + ctx.request.params.orgId, + req.user.id, + ctx.request.requestBody, + ); + + if (result.codeNeeded === true) { + // We need a user access token + const redirectURL = `${req.protocol}://${req.host}/import-repo`; + return json(200, res, { + url: `${env.GITHUB_BASE_URL}/login/oauth/authorize?client_id=${env.GITHUB_CLIENT_ID}&state=${result.oauthState}&redirect_uri=${encodeURIComponent(redirectURL)}`, + }); + } else { + // The repo was created immediately & we don't need to redirect to GitHub for authorization + return json(201, res, { orgId: result.orgId, repoId: result.repoId }); + } + } catch (e) { + if (e instanceof OrgNotFoundError) { + return json(404, res, { + code: 404, + message: "Organization not found.", + }); + } } }; -export const importGitRepo: HandlerMap["importGitRepo"] = async ( +export const importGitRepoHandler: HandlerMap["importGitRepo"] = async ( ctx, req: AuthenticatedRequest, res, ) => { - return await importRepoHandler( + const result = await importGitRepo( ctx.request.requestBody.state, ctx.request.requestBody.code, req.user.id, - req, - res, - ); -}; - -async function importRepoHandler( - stateId: string, - code: string | undefined, - userId: number, - req: Request, - res: Response, -) { - const state = await db.repoImportState.get(stateId, userId); - - if (!state) { - return json(404, res, {}); - } - - const org = await db.org.getById(state.orgId); - - const repoId = await importRepo( - org.githubInstallationId, - URL.parse(state.srcRepoURL), - state.destIsOrg, - state.destRepoOwner, - state.destRepoName, - state.makePrivate, - code, ); - if (repoId === "code needed") { - // There was a problem creating the repo directly from a template and we didn't provide an OAuth code to authorize the user. - // We need to start over. - const redirectURL = `${req.protocol}://${req.host}/import-repo`; - return json(200, res, { - url: `${env.GITHUB_BASE_URL}/login/oauth/authorize?client_id=${env.GITHUB_CLIENT_ID}&state=${state.id}&redirect_uri=${encodeURIComponent(redirectURL)}`, + if (result.codeNeeded === true) { + // Should never happen since we're providing a GitHub authorization code to importGitRepo + return json(500, res, { + code: 500, + message: "GitHub authorization state mismatch", }); + } else { + return json(201, res, { orgId: result.orgId, repoId: result.repoId }); } - - await db.repoImportState.delete(state.id); - - // The repository was created successfully. If repoId is null, then - // we're not 100% sure that it was created, but no errors were thrown. - // It's probably just a big repository that will be created soon. - - return json(201, res, { orgId: state.orgId, repoId }); -} +}; diff --git a/backend/src/handlers/index.ts b/backend/src/handlers/index.ts index 40dc96a0..2a445ccc 100644 --- a/backend/src/handlers/index.ts +++ b/backend/src/handlers/index.ts @@ -1,47 +1,50 @@ import { type Request as ExpressRequest } from "express"; import { type HandlerMap } from "../types.ts"; -import { acceptInvitation } from "./acceptInvitation.ts"; -import { claimOrg } from "./claimOrg.ts"; -import { createApp } from "./createApp.ts"; -import { createAppGroup } from "./createAppGroup.ts"; -import { createOrg } from "./createOrg.ts"; -import { deleteApp } from "./deleteApp.ts"; -import { deleteAppPod } from "./deleteAppPod.ts"; -import { deleteOrgByID } from "./deleteOrgByID.ts"; +import { acceptInvitationHandler } from "./acceptInvitation.ts"; +import { claimOrgHandler } from "./claimOrg.ts"; +import { createAppHandler } from "./createApp.ts"; +import { createAppGroupHandler } from "./createAppGroup.ts"; +import { createOrgHandler } from "./createOrg.ts"; +import { deleteAppHandler } from "./deleteApp.ts"; +import { deleteAppPodHandler } from "./deleteAppPod.ts"; +import { deleteOrgByIDHandler } from "./deleteOrgByID.ts"; import { - deleteAppFile, - downloadAppFile, - getAppFile, - writeAppFile, + deleteAppFileHandler, + downloadAppFileHandler, + getAppFileHandler, + writeAppFileHandler, } from "./files.ts"; -import { getAppByID } from "./getAppByID.ts"; -import { getAppLogs } from "./getAppLogs.ts"; -import { getAppStatus } from "./getAppStatus.ts"; -import { getDeployment } from "./getDeployment.ts"; -import { getInstallation } from "./getInstallation.ts"; -import { getOrgByID } from "./getOrgByID.ts"; -import { getSettings } from "./getSettings.ts"; -import { getTemplates } from "./getTemplates.ts"; -import { getUser } from "./getUser.ts"; -import { githubAppInstall } from "./githubAppInstall.ts"; -import { githubInstallCallback } from "./githubInstallCallback.ts"; -import { githubOAuthCallback } from "./githubOAuthCallback.ts"; -import { githubWebhook } from "./githubWebhook.ts"; -import { importGitRepo, importGitRepoCreateState } from "./importGitRepo.ts"; -import { ingestLogs } from "./ingestLogs.ts"; -import { inviteUser } from "./inviteUser.ts"; -import { isSubdomainAvailable } from "./isSubdomainAvailable.ts"; -import { listDeployments } from "./listDeployments.ts"; -import { listOrgGroups } from "./listOrgGroups.ts"; -import { listOrgRepos } from "./listOrgRepos.ts"; -import { listRepoBranches } from "./listRepoBranches.ts"; -import { listRepoWorkflows } from "./listRepoWorkflows.ts"; +import { getAppByIDHandler } from "./getAppByID.ts"; +import { getAppLogsHandler } from "./getAppLogs.ts"; +import { getAppStatusHandler } from "./getAppStatus.ts"; +import { getDeploymentHandler } from "./getDeployment.ts"; +import { getInstallationHandler } from "./getInstallation.ts"; +import { getOrgByIDHandler } from "./getOrgByID.ts"; +import { getSettingsHandler } from "./getSettings.ts"; +import { getTemplatesHandler } from "./getTemplates.ts"; +import { getUserHandler } from "./getUser.ts"; +import { githubAppInstallHandler } from "./githubAppInstall.ts"; +import { githubInstallCallbackHandler } from "./githubInstallCallback.ts"; +import { githubOAuthCallbackHandler } from "./githubOAuthCallback.ts"; +import { githubWebhookHandler } from "./githubWebhook.ts"; +import { + importGitRepoCreateStateHandler, + importGitRepoHandler, +} from "./importGitRepo.ts"; +import { ingestLogsHandler } from "./ingestLogs.ts"; +import { inviteUserHandler } from "./inviteUser.ts"; +import { isSubdomainAvailableHandler } from "./isSubdomainAvailable.ts"; +import { listDeploymentsHandler } from "./listDeployments.ts"; +import { listOrgGroupsHandler } from "./listOrgGroups.ts"; +import { listOrgReposHandler } from "./listOrgRepos.ts"; +import { listRepoBranchesHandler } from "./listRepoBranches.ts"; +import { listRepoWorkflowsHandler } from "./listRepoWorkflows.ts"; import { livenessProbe } from "./liveness.ts"; -import { removeUserFromOrg } from "./removeUserFromOrg.ts"; -import { revokeInvitation } from "./revokeInvitation.ts"; -import { setAppCD } from "./setAppCD.ts"; -import { updateApp } from "./updateApp.ts"; -import { updateDeployment } from "./updateDeployment.ts"; +import { removeUserFromOrgHandler } from "./removeUserFromOrg.ts"; +import { revokeInvitationHandler } from "./revokeInvitation.ts"; +import { setAppCDHandler } from "./setAppCD.ts"; +import { updateAppHandler } from "./updateApp.ts"; +import { updateDeploymentHandler } from "./updateDeployment.ts"; export type AuthenticatedRequest = ExpressRequest & { user: { @@ -52,45 +55,45 @@ export type AuthenticatedRequest = ExpressRequest & { }; export const handlers = { - acceptInvitation, - claimOrg, - createApp, - createAppGroup, - createOrg, - deleteApp, - deleteAppFile, - deleteAppPod, - deleteOrgByID, - downloadAppFile, - getAppByID, - getAppFile, - getAppLogs, - getAppStatus, - getDeployment, - getInstallation, - getOrgByID, - getSettings, - getTemplates, - getUser, - githubAppInstall, - githubInstallCallback, - githubOAuthCallback, - githubWebhook, - importGitRepo, - importGitRepoCreateState, - ingestLogs, - inviteUser, - isSubdomainAvailable, - listDeployments, - listOrgGroups, - listOrgRepos, - listRepoBranches, - listRepoWorkflows, + acceptInvitation: acceptInvitationHandler, + claimOrg: claimOrgHandler, + createApp: createAppHandler, + createAppGroup: createAppGroupHandler, + createOrg: createOrgHandler, + deleteApp: deleteAppHandler, + deleteAppFile: deleteAppFileHandler, + deleteAppPod: deleteAppPodHandler, + deleteOrgByID: deleteOrgByIDHandler, + downloadAppFile: downloadAppFileHandler, + getAppByID: getAppByIDHandler, + getAppFile: getAppFileHandler, + getAppLogs: getAppLogsHandler, + getAppStatus: getAppStatusHandler, + getDeployment: getDeploymentHandler, + getInstallation: getInstallationHandler, + getOrgByID: getOrgByIDHandler, + getSettings: getSettingsHandler, + getTemplates: getTemplatesHandler, + getUser: getUserHandler, + githubAppInstall: githubAppInstallHandler, + githubInstallCallback: githubInstallCallbackHandler, + githubOAuthCallback: githubOAuthCallbackHandler, + githubWebhook: githubWebhookHandler, + importGitRepo: importGitRepoHandler, + importGitRepoCreateState: importGitRepoCreateStateHandler, + ingestLogs: ingestLogsHandler, + inviteUser: inviteUserHandler, + isSubdomainAvailable: isSubdomainAvailableHandler, + listDeployments: listDeploymentsHandler, + listOrgGroups: listOrgGroupsHandler, + listOrgRepos: listOrgReposHandler, + listRepoBranches: listRepoBranchesHandler, + listRepoWorkflows: listRepoWorkflowsHandler, livenessProbe, - removeUserFromOrg, - revokeInvitation, - setAppCD, - updateApp, - updateDeployment, - writeAppFile, + removeUserFromOrg: removeUserFromOrgHandler, + revokeInvitation: revokeInvitationHandler, + setAppCD: setAppCDHandler, + updateApp: updateAppHandler, + updateDeployment: updateDeploymentHandler, + writeAppFile: writeAppFileHandler, } as const satisfies HandlerMap; diff --git a/backend/src/handlers/ingestLogs.ts b/backend/src/handlers/ingestLogs.ts index 3580c86d..15bf2a58 100644 --- a/backend/src/handlers/ingestLogs.ts +++ b/backend/src/handlers/ingestLogs.ts @@ -1,9 +1,16 @@ -import { db } from "../db/index.ts"; import type { LogType } from "../generated/prisma/enums.ts"; -import type { LogUncheckedCreateInput } from "../generated/prisma/models.ts"; +import { + DeploymentNotFoundError, + ValidationError, +} from "../service/common/errors.ts"; +import { ingestLogs } from "../service/ingestLogs.ts"; import { json, type HandlerMap } from "../types.ts"; -export const ingestLogs: HandlerMap["ingestLogs"] = async (ctx, req, res) => { +export const ingestLogsHandler: HandlerMap["ingestLogs"] = async ( + ctx, + req, + res, +) => { const authHeader = ctx.request.headers["authorization"]?.split(" "); if (authHeader[0] !== "Bearer") { return json(400, res, { @@ -12,42 +19,28 @@ export const ingestLogs: HandlerMap["ingestLogs"] = async (ctx, req, res) => { }); } - // Authorize the request const token = authHeader[1]; - const result = await db.deployment.checkLogIngestSecret( - ctx.request.requestBody.deploymentId!, - token, - ); - if (!result) { - return json(403, res, {}); - } - - // Append the logs to the DB - const logType: LogType = ({ build: "BUILD", runtime: "RUNTIME" } as const)[ ctx.request.requestBody.type ]; - if (logType === undefined) { - // Should never happen - return json(400, res, { code: 400, message: "Missing log type." }); + try { + await ingestLogs( + ctx.request.requestBody.deploymentId, + token, + ctx.request.requestBody.hostname, + logType, + ctx.request.requestBody.lines, + ); + return json(200, res, {}); + } catch (e) { + if (e instanceof DeploymentNotFoundError) { + // No deployment matches the ID and secret + return json(403, res, {}); + } else if (e instanceof ValidationError) { + // This request is invalid + return json(400, res, { code: 400, message: "Invalid log type" }); + } + throw e; } - - const logLines = ctx.request.requestBody.lines - .map((line, i) => { - return { - content: line.content, - deploymentId: ctx.request.requestBody.deploymentId, - type: logType, - timestamp: new Date(line.timestamp), - index: i, - podName: ctx.request.requestBody.hostname, - stream: line.stream, - } satisfies LogUncheckedCreateInput; - }) - .filter((it) => it !== null); - - await db.deployment.insertLogs(logLines); - - return json(200, res, {}); }; diff --git a/backend/src/handlers/inviteUser.ts b/backend/src/handlers/inviteUser.ts index 2c59e055..a85181e2 100644 --- a/backend/src/handlers/inviteUser.ts +++ b/backend/src/handlers/inviteUser.ts @@ -1,40 +1,40 @@ -import { ConflictError, db, NotFoundError } from "../db/index.ts"; +import { ConflictError } from "../db/index.ts"; +import { + OrgNotFoundError, + UserNotFoundError, + ValidationError, +} from "../service/common/errors.ts"; +import { inviteUser } from "../service/inviteUser.ts"; import { json, type HandlerMap } from "../types.ts"; import type { AuthenticatedRequest } from "./index.ts"; -export const inviteUser: HandlerMap["inviteUser"] = async ( +export const inviteUserHandler: HandlerMap["inviteUser"] = async ( ctx, req: AuthenticatedRequest, res, ) => { - const otherUser = await db.user.getByEmail(ctx.request.requestBody.email); - - if (otherUser === null) { - return json(404, res, { - code: 404, - message: - "No user was found with that email address. Make sure it is spelled correctly.", - }); - } - - if (otherUser.id === req.user.id) { - return json(400, res, { - code: 400, - message: "You cannot send an invitation to yourself.", - }); - } - try { - await db.invitation.send( - ctx.request.params.orgId, + await inviteUser( req.user.id, - otherUser.id, + ctx.request.params.orgId, + ctx.request.requestBody.email, ); - } catch (e: any) { - if (e instanceof NotFoundError && e.message === "organization") { + return json(201, res, {}); + } catch (e) { + if (e instanceof UserNotFoundError) { + return json(404, res, { + code: 404, + message: + "No user was found with that email address. Make sure it is spelled correctly.", + }); + } else if (e instanceof ValidationError) { + return json(400, res, { + code: 400, + message: e.message, + }); + } else if (e instanceof OrgNotFoundError) { return json(404, res, { code: 404, message: "Organization not found." }); - } - if (e instanceof ConflictError && e.message === "user") { + } else if (e instanceof ConflictError) { return json(400, res, { code: 400, message: "That user has already been invited to this organization.", @@ -42,6 +42,4 @@ export const inviteUser: HandlerMap["inviteUser"] = async ( } throw e; } - - return json(201, res, {}); }; diff --git a/backend/src/handlers/isSubdomainAvailable.ts b/backend/src/handlers/isSubdomainAvailable.ts index 21161299..2ddb8455 100644 --- a/backend/src/handlers/isSubdomainAvailable.ts +++ b/backend/src/handlers/isSubdomainAvailable.ts @@ -1,22 +1,18 @@ -import { db } from "../db/index.ts"; +import { ValidationError } from "../service/common/errors.ts"; +import { isSubdomainAvailable } from "../service/isSubdomainAvailable.ts"; import { json, type HandlerMap } from "../types.ts"; import type { AuthenticatedRequest } from "./index.ts"; -export const isSubdomainAvailable: HandlerMap["isSubdomainAvailable"] = async ( - ctx, - req: AuthenticatedRequest, - res, -) => { - const subdomain = ctx.request.query.subdomain; - - if ( - subdomain.length > 54 || - subdomain.match(/^[a-z0-9](?:[a-z0-9\-]*[a-z0-9])?$/) === null - ) { - return json(400, res, { code: 400, message: "Invalid subdomain." }); - } - - const subdomainUsedByApp = await db.app.isSubdomainInUse(subdomain); - - return json(200, res, { available: !subdomainUsedByApp }); -}; +export const isSubdomainAvailableHandler: HandlerMap["isSubdomainAvailable"] = + async (ctx, req: AuthenticatedRequest, res) => { + const subdomain = ctx.request.query.subdomain; + try { + const available = await isSubdomainAvailable(subdomain); + return json(200, res, { available }); + } catch (e) { + if (e instanceof ValidationError) { + return json(400, res, { code: 400, message: e.message }); + } + throw e; + } + }; diff --git a/backend/src/handlers/listDeployments.ts b/backend/src/handlers/listDeployments.ts index 5af5a503..62954bf9 100644 --- a/backend/src/handlers/listDeployments.ts +++ b/backend/src/handlers/listDeployments.ts @@ -1,101 +1,29 @@ -import type { Octokit } from "octokit"; -import { db } from "../db/index.ts"; -import type { DeploymentWithSourceInfo } from "../db/models.ts"; -import type { components } from "../generated/openapi.ts"; -import { getOctokit, getRepoById } from "../lib/octokit.ts"; +import { AppNotFoundError, ValidationError } from "../service/common/errors.ts"; +import { listDeployments } from "../service/listDeployments.ts"; import { json, type HandlerMap } from "../types.ts"; import type { AuthenticatedRequest } from "./index.ts"; -export const listDeployments: HandlerMap["listDeployments"] = async ( +export const listDeploymentsHandler: HandlerMap["listDeployments"] = async ( ctx, req: AuthenticatedRequest, res, ) => { const page = ctx.request.query.page ?? 0; const pageLength = ctx.request.query.length ?? 25; - - if ( - page < 0 || - pageLength <= 0 || - !Number.isInteger(page) || - !Number.isInteger(pageLength) - ) { - return json(400, res, { - code: 400, - message: "Invalid page or page length.", - }); - } - - const app = await db.app.getById(ctx.request.params.appId, { - requireUser: { id: req.user.id }, - }); - - if (!app) { - return json(404, res, {}); - } - - const org = await db.org.getById(app.orgId); - - const deployments = await db.deployment.listForApp(app.id, page, pageLength); - - const distinctRepoIDs = [ - ...new Set(deployments.map((it) => it.repositoryId).filter(Boolean)), - ]; - let octokit: Octokit; - if (distinctRepoIDs.length > 0 && org.githubInstallationId) { - octokit = await getOctokit(org.githubInstallationId); - } - const repos = await Promise.all( - distinctRepoIDs.map(async (id) => { - if (id) { - try { - return octokit ? await getRepoById(octokit, id) : null; - } catch (error) { - if (error?.status === 404) { - // The repo couldn't be found. Either it doesn't exist or the installation doesn't have permission to see it. - return undefined; - } - throw error; // Rethrow any other kind of error - } - } - return undefined; - }), - ); - - const modifiedDeployments = deployments as Array< - Omit & { - status: components["schemas"]["AppSummary"]["status"]; - } - >; - - let sawSuccess = false; - for (const deployment of modifiedDeployments) { - if (deployment.status === "COMPLETE") { - if (!sawSuccess) { - sawSuccess = true; - } else { - deployment.status = "STOPPED"; - } + try { + const deployments = await listDeployments( + ctx.request.params.appId, + req.user.id, + page, + pageLength, + ); + return json(200, res, deployments); + } catch (e) { + if (e instanceof AppNotFoundError) { + return json(404, res, {}); + } else if (e instanceof ValidationError) { + return json(400, res, { code: 400, message: e.message }); } + throw e; } - - return json( - 200, - res, - modifiedDeployments.map((deployment) => { - return { - id: deployment.id, - appId: deployment.appId, - repositoryURL: - repos[distinctRepoIDs.indexOf(deployment.repositoryId)]?.html_url, - commitHash: deployment.commitHash, - commitMessage: deployment.commitMessage, - status: deployment.status, - createdAt: deployment.createdAt.toISOString(), - updatedAt: deployment.updatedAt.toISOString(), - source: deployment.source, - imageTag: deployment.imageTag, - }; - }), - ); }; diff --git a/backend/src/handlers/listOrgGroups.ts b/backend/src/handlers/listOrgGroups.ts index e4255edc..c2ba5e40 100644 --- a/backend/src/handlers/listOrgGroups.ts +++ b/backend/src/handlers/listOrgGroups.ts @@ -1,29 +1,22 @@ -import { db } from "../db/index.ts"; +import { OrgNotFoundError } from "../service/common/errors.ts"; +import { listOrgGroups } from "../service/listOrgGroups.ts"; import { json, type HandlerMap } from "../types.ts"; import type { AuthenticatedRequest } from "./index.ts"; -export const listOrgGroups: HandlerMap["listOrgGroups"] = async ( +export const listOrgGroupsHandler: HandlerMap["listOrgGroups"] = async ( ctx, req: AuthenticatedRequest, res, ) => { const orgId = ctx.request.params.orgId; - const [org, appGroups] = await Promise.all([ - db.org.getById(orgId, { requireUser: { id: req.user.id } }), - db.appGroup.listForOrg(orgId), - ]); - - if (org === null) { - return json(404, res, { code: 404, message: "Organization not found." }); + try { + const groups = await listOrgGroups(orgId, req.user.id); + return json(200, res, groups); + } catch (e) { + if (e instanceof OrgNotFoundError) { + return json(404, res, { code: 404, message: "Organization not found." }); + } + throw e; } - - return json( - 200, - res, - appGroups.map((group) => ({ - id: group.id, - name: group.name, - })), - ); }; diff --git a/backend/src/handlers/listOrgRepos.ts b/backend/src/handlers/listOrgRepos.ts index ca7b0f22..508f2beb 100644 --- a/backend/src/handlers/listOrgRepos.ts +++ b/backend/src/handlers/listOrgRepos.ts @@ -1,33 +1,25 @@ -import { db } from "../db/index.ts"; -import { getOctokit } from "../lib/octokit.ts"; +import { + InstallationNotFoundError, + OrgNotFoundError, +} from "../service/common/errors.ts"; +import { listOrgRepos } from "../service/listOrgRepos.ts"; import { json, type HandlerMap } from "../types.ts"; import type { AuthenticatedRequest } from "./index.ts"; -export const listOrgRepos: HandlerMap["listOrgRepos"] = async ( +export const listOrgReposHandler: HandlerMap["listOrgRepos"] = async ( ctx, req: AuthenticatedRequest, res, ) => { - const org = await db.org.getById(ctx.request.params.orgId, { - requireUser: { id: req.user.id }, - }); - - if (!org) { - return json(404, res, { code: 404, message: "Organization not found." }); - } - - if (org.githubInstallationId === null) { - return json(403, res, { code: 403, message: "GitHub not connected" }); + try { + const data = await listOrgRepos(ctx.request.params.orgId, req.user.id); + return json(200, res, data); + } catch (e) { + if (e instanceof OrgNotFoundError) { + return json(404, res, { code: 404, message: "Organization not found." }); + } else if (e instanceof InstallationNotFoundError) { + return json(403, res, { code: 403, message: "GitHub not connected" }); + } + throw e; } - - const octokit = await getOctokit(org.githubInstallationId); - const repos = await octokit.rest.apps.listReposAccessibleToInstallation(); - - const data = repos.data.repositories?.map((repo) => ({ - id: repo.id, - owner: repo.owner.login, - name: repo.name, - })); - - return json(200, res, data); }; diff --git a/backend/src/handlers/listRepoBranches.ts b/backend/src/handlers/listRepoBranches.ts index ac2b90b5..f445f8a7 100644 --- a/backend/src/handlers/listRepoBranches.ts +++ b/backend/src/handlers/listRepoBranches.ts @@ -1,43 +1,32 @@ -import { RequestError } from "octokit"; -import { db } from "../db/index.ts"; -import { getOctokit, getRepoById } from "../lib/octokit.ts"; +import { + InstallationNotFoundError, + OrgNotFoundError, + RepositoryNotFoundError, +} from "../service/common/errors.ts"; +import { listRepoBranches } from "../service/listRepoBranches.ts"; import { json, type HandlerMap } from "../types.ts"; import type { AuthenticatedRequest } from "./index.ts"; -export const listRepoBranches: HandlerMap["listRepoBranches"] = async ( +export const listRepoBranchesHandler: HandlerMap["listRepoBranches"] = async ( ctx, req: AuthenticatedRequest, res, ) => { - const org = await db.org.getById(ctx.request.params.orgId, { - requireUser: { id: req.user.id }, - }); - - if (!org) { - return json(404, res, { code: 404, message: "Organization not found" }); - } - - if (org.githubInstallationId === null) { - return json(403, res, { code: 403, message: "GitHub not connected" }); - } - try { - const octokit = await getOctokit(org.githubInstallationId); - const repo = await getRepoById(octokit, ctx.request.params.repoId); - const branches = await octokit.rest.repos.listBranches({ - owner: repo.owner.login, - repo: repo.name, - }); - - return json(200, res, { - default: repo.default_branch, - branches: branches.data.map((branch) => branch.name), - }); + const branches = await listRepoBranches( + ctx.request.params.orgId, + req.user.id, + ctx.request.params.repoId, + ); + return json(200, res, branches); } catch (e) { - if (e instanceof RequestError && e.status == 404) { + if (e instanceof OrgNotFoundError) { + return json(404, res, { code: 404, message: "Organization not found" }); + } else if (e instanceof InstallationNotFoundError) { + return json(403, res, { code: 403, message: "GitHub not connected" }); + } else if (e instanceof RepositoryNotFoundError) { return json(404, res, { code: 404, message: "Repository not found" }); } - throw e; } }; diff --git a/backend/src/handlers/listRepoWorkflows.ts b/backend/src/handlers/listRepoWorkflows.ts index 1d9adbdc..0d54f0dd 100644 --- a/backend/src/handlers/listRepoWorkflows.ts +++ b/backend/src/handlers/listRepoWorkflows.ts @@ -1,47 +1,32 @@ -import { RequestError } from "octokit"; -import { db } from "../db/index.ts"; -import { getOctokit } from "../lib/octokit.ts"; +import { + InstallationNotFoundError, + OrgNotFoundError, + RepositoryNotFoundError, +} from "../service/common/errors.ts"; +import { listRepoWorkflows } from "../service/listRepoWorkflows.ts"; import { json, type HandlerMap } from "../types.ts"; import type { AuthenticatedRequest } from "./index.ts"; -export const listRepoWorkflows: HandlerMap["listRepoWorkflows"] = async ( +export const listRepoWorkflowsHandler: HandlerMap["listRepoWorkflows"] = async ( ctx, req: AuthenticatedRequest, res, ) => { - const org = await db.org.getById(ctx.request.params.orgId, { - requireUser: { id: req.user.id }, - }); - - if (!org) { - return json(404, res, { code: 404, message: "Organization not found" }); - } - - if (org.githubInstallationId == null) { - return json(403, res, { code: 403, message: "GitHub not connected" }); - } try { - const octokit = await getOctokit(org.githubInstallationId); - const workflows = (await octokit - .request({ - method: "GET", - url: `/repositories/${ctx.request.params.repoId}/actions/workflows`, - }) - .then((res) => res.data.workflows)) as Awaited< - ReturnType - >["data"][]; - return json(200, res, { - workflows: workflows.map((workflow) => ({ - id: workflow.id, - name: workflow.name, - path: workflow.path, - })), - }); + const workflows = await listRepoWorkflows( + ctx.request.params.orgId, + req.user.id, + ctx.request.params.repoId, + ); + return json(200, res, { workflows }); } catch (e) { - if (e instanceof RequestError && e.status === 404) { + if (e instanceof OrgNotFoundError) { + return json(404, res, { code: 404, message: "Organization not found" }); + } else if (e instanceof InstallationNotFoundError) { + return json(403, res, { code: 403, message: "GitHub not connected" }); + } else if (e instanceof RepositoryNotFoundError) { return json(404, res, { code: 404, message: "Repository not found" }); } - throw e; } }; diff --git a/backend/src/handlers/removeUserFromOrg.ts b/backend/src/handlers/removeUserFromOrg.ts index b5b538dd..4d4da70b 100644 --- a/backend/src/handlers/removeUserFromOrg.ts +++ b/backend/src/handlers/removeUserFromOrg.ts @@ -1,32 +1,29 @@ -import { db, NotFoundError } from "../db/index.ts"; +import { + OrgNotFoundError, + UserNotFoundError, +} from "../service/common/errors.ts"; +import { removeUserFromOrg } from "../service/removeUserFromOrg.ts"; import { json, type HandlerMap } from "../types.ts"; import type { AuthenticatedRequest } from "./index.ts"; -export const removeUserFromOrg: HandlerMap["removeUserFromOrg"] = async ( +export const removeUserFromOrgHandler: HandlerMap["removeUserFromOrg"] = async ( ctx, req: AuthenticatedRequest, res, ) => { - const org = await db.org.getById(ctx.request.params.orgId, { - requireUser: { id: req.user.id, permissionLevel: "OWNER" }, - }); - - if (!org) { - return json(403, res, {}); - } - try { - await db.org.removeMember( + await removeUserFromOrg( ctx.request.params.orgId, + req.user.id, ctx.request.params.userId, ); - } catch (e: any) { - if (e instanceof NotFoundError) { + return json(204, res, {}); + } catch (e) { + if (e instanceof OrgNotFoundError) { + return json(403, res, {}); + } else if (e instanceof UserNotFoundError) { return json(404, res, { code: 404, message: "Not found." }); } - throw e; } - - return json(204, res, {}); }; diff --git a/backend/src/handlers/revokeInvitation.ts b/backend/src/handlers/revokeInvitation.ts index 44e97d90..a28d5de6 100644 --- a/backend/src/handlers/revokeInvitation.ts +++ b/backend/src/handlers/revokeInvitation.ts @@ -1,24 +1,25 @@ -import { db, NotFoundError } from "../db/index.ts"; +import { InvitationNotFoundError } from "../service/common/errors.ts"; +import { revokeInvitation } from "../service/revokeInvitation.ts"; import { json, type HandlerMap } from "../types.ts"; import type { AuthenticatedRequest } from "./index.ts"; -export const revokeInvitation: HandlerMap["revokeInvitation"] = async ( +export const revokeInvitationHandler: HandlerMap["revokeInvitation"] = async ( ctx, req: AuthenticatedRequest, res, ) => { try { - await db.invitation.revoke( + await revokeInvitation( ctx.request.params.orgId, - ctx.request.params.invId, req.user.id, + ctx.request.params.invId, ); + + return json(204, res, {}); } catch (e) { - if (e instanceof NotFoundError) { + if (e instanceof InvitationNotFoundError) { return json(404, res, { code: 404, message: "Invitation not found." }); } throw e; } - - return json(204, res, {}); }; diff --git a/backend/src/handlers/setAppCD.ts b/backend/src/handlers/setAppCD.ts index 7d195dc0..c0ca99be 100644 --- a/backend/src/handlers/setAppCD.ts +++ b/backend/src/handlers/setAppCD.ts @@ -1,24 +1,24 @@ -import { db } from "../db/index.ts"; +import { AppNotFoundError } from "../service/common/errors.ts"; +import { setAppCD } from "../service/setAppCD.ts"; import { json, type HandlerMap } from "../types.ts"; import type { AuthenticatedRequest } from "./index.ts"; -export const setAppCD: HandlerMap["setAppCD"] = async ( +export const setAppCDHandler: HandlerMap["setAppCD"] = async ( ctx, req: AuthenticatedRequest, res, ) => { - const app = await db.app.getById(ctx.request.params.appId, { - requireUser: { id: req.user.id }, - }); - - if (!app) { - return json(404, res, { code: 404, message: "App not found." }); + try { + await setAppCD( + ctx.request.params.appId, + req.user.id, + ctx.request.requestBody.enable, + ); + return json(200, res, {}); + } catch (e) { + if (e instanceof AppNotFoundError) { + return json(404, res, { code: 404, message: "App not found." }); + } + throw e; } - - await db.app.setEnableCD( - ctx.request.params.appId, - ctx.request.requestBody.enable, - ); - - return json(200, res, {}); }; diff --git a/backend/src/handlers/updateApp.ts b/backend/src/handlers/updateApp.ts index 66c68540..a8b21b94 100644 --- a/backend/src/handlers/updateApp.ts +++ b/backend/src/handlers/updateApp.ts @@ -1,268 +1,32 @@ -import { randomBytes } from "node:crypto"; -import { db, NotFoundError } from "../db/index.ts"; -import type { DeploymentConfigCreate } from "../db/models.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"; + AppNotFoundError, + DeploymentError, + ValidationError, +} from "../service/common/errors.ts"; +import { updateApp } from "../service/updateApp.ts"; import { type HandlerMap, json } from "../types.ts"; -import { - buildAndDeploy, - cancelAllOtherDeployments, - log, -} from "./githubWebhook.ts"; import { type AuthenticatedRequest } from "./index.ts"; -export const updateApp: HandlerMap["updateApp"] = async ( +export const updateAppHandler: HandlerMap["updateApp"] = async ( ctx, req: AuthenticatedRequest, res, ) => { const appData = ctx.request.requestBody; - const appConfig = appData.config; - - // ---------------- Input validation ---------------- - - const originalApp = await db.app.getById(ctx.request.params.appId, { - requireUser: { id: req.user.id }, - }); - - if (!originalApp) { - return json(404, res, { code: 404, message: "App not found" }); - } - try { - await validateDeploymentConfig(appData.config); - if (appData.appGroup) { - validateAppGroup(appData.appGroup); - } + await updateApp(ctx.request.params.appId, req.user.id, appData); + return json(200, res, {}); } catch (e) { - return json(400, res, { - code: 400, - message: e.message, - }); - } - - if (appData.projectId) { - const user = await db.user.getById(req.user.id); - if (!(await canManageProject(user.clusterUsername, appData.projectId))) { - return json(404, res, { code: 404, message: "Project not found" }); - } - } - - // ---------------- 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) { - return json(404, res, { code: 404, message: "App group not found" }); - } - } - } - } 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", - ); - - await db.app.setGroup(originalApp.id, newGroupId); - } - - // ---------------- App model updates ---------------- - - const updates = {} as Record; - if (appData.name !== undefined) { - updates.displayName = appData.name; - } - - if (appData.projectId !== undefined) { - updates.projectId = appData.projectId; - } - - if (appData.enableCD !== undefined) { - updates.enableCD = appData.enableCD; - } - - if (Object.keys(updates).length > 0) { - 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), - 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(), appConfig.env), - createIngress: appConfig.createIngress, - subdomain: appConfig.subdomain, - collectLogs: appConfig.collectLogs, - replicas: appConfig.replicas, - port: appConfig.port, - mounts: appConfig.mounts, - requests: appConfig.requests, - limits: appConfig.limits, - ...(appConfig.source === "git" - ? { - source: "GIT", - branch: appConfig.branch, - repositoryId: appConfig.repositoryId, - commitHash: appConfig.commitHash ?? currentConfig.commitHash, - builder: appConfig.builder, - rootDir: appConfig.rootDir, - dockerfilePath: appConfig.dockerfilePath, - event: appConfig.event, - eventId: appConfig.eventId, - } - : { - source: "IMAGE", - imageTag: appConfig.imageTag, - }), - }; - - // ---------------- Rebuild if necessary ---------------- - - 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) - ) { - // 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, - }); - - // 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) { - console.error(err); + if (e instanceof AppNotFoundError) { + return json(404, res, { code: 404, message: "App not found" }); + } else if (e instanceof ValidationError) { + return json(400, res, { code: 400, message: e.message }); + } else if (e instanceof DeploymentError) { return json(500, res, { code: 500, message: "Failed to create a deployment for your app.", }); } - } 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, - }, - status: "DEPLOYING", - appId: originalApp.id, - commitMessage: currentDeployment.commitMessage, - }); - - const config = await db.deployment.getConfig(deployment.id); - - try { - const { namespace, configs, postCreate } = - await createAppConfigsFromDeployment( - org, - app, - appGroup, - deployment, - config, - ); - - const { KubernetesObjectApi: api } = await getClientsForRequest( - req.user.id, - app.projectId, - ["KubernetesObjectApi"], - ); - await createOrUpdateApp(api, app.name, namespace, configs, postCreate); - - await Promise.all([ - cancelAllOtherDeployments(org, app, deployment.id, true), - db.deployment.setStatus(deployment.id, "COMPLETE"), - db.app.setConfig(ctx.request.params.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", - ); - return json(200, res, {}); - } + throw e; } - return json(200, res, {}); -}; - -// Patch the null(hidden) values of env vars sent from client with the sensitive plaintext -export const withSensitiveEnv = ( - lastPlaintextEnv: PrismaJson.EnvVar[], - envVars: { - name: string; - value: string | null; - isSensitive: boolean; - }[], -) => { - const lastEnvMap = - lastPlaintextEnv?.reduce((map, env) => { - return Object.assign(map, { [env.name]: env.value }); - }, {}) ?? {}; - return envVars.map((env) => - env.value === null - ? { - name: env.name, - value: lastEnvMap[env.name], - isSensitive: env.isSensitive, - } - : env, - ); }; diff --git a/backend/src/handlers/updateDeployment.ts b/backend/src/handlers/updateDeployment.ts index 68add6b7..480f3289 100644 --- a/backend/src/handlers/updateDeployment.ts +++ b/backend/src/handlers/updateDeployment.ts @@ -1,120 +1,25 @@ -import { db } from "../db/index.ts"; -import { dequeueBuildJob } 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 { getOctokit, getRepoById } from "../lib/octokit.ts"; + DeploymentNotFoundError, + ValidationError, +} from "../service/common/errors.ts"; +import { updateDeployment } from "../service/updateDeployment.ts"; import { json, type HandlerMap } from "../types.ts"; -import { log } from "./githubWebhook.ts"; -export const updateDeployment: HandlerMap["updateDeployment"] = async ( +export const updateDeploymentHandler: HandlerMap["updateDeployment"] = async ( ctx, req, res, ) => { const { secret, status } = ctx.request.requestBody; - - if (!secret) { - return json(401, res, {}); - } - - if (!["BUILDING", "DEPLOYING", "ERROR"].some((it) => status === it)) { - return json(400, res, { code: 400, message: "Invalid status." }); - } - const deployment = await db.deployment.getFromSecret(secret); - - if (!deployment) { - return json(404, res, { code: 404, message: "Deployment not found." }); - } - - await db.deployment.setStatus( - deployment.id, - status as "BUILDING" | "DEPLOYING" | "ERROR", - ); - - log( - deployment.id, - "BUILD", - "Deployment status has been updated to " + status, - ); - - const app = await db.app.getById(deployment.appId); - const [appGroup, config, org] = await Promise.all([ - db.appGroup.getById(app.appGroupId), - db.deployment.getConfig(deployment.id), - db.org.getById(app.orgId), - ]); - - if ( - (status === "DEPLOYING" || status === "ERROR") && - deployment.checkRunId !== null - ) { - try { - // The build completed. Update the check run with the result of the build (success or failure). - const octokit = await getOctokit(org.githubInstallationId); - - // Get the repo's name and owner from its ID, just in case the name or owner changed in the middle of the deployment - const repo = await getRepoById(octokit, config.repositoryId); - - await octokit.rest.checks.update({ - check_run_id: deployment.checkRunId, - status: "completed", - conclusion: status === "DEPLOYING" ? "success" : "failure", - owner: repo.owner.login, - repo: repo.name, - }); - log( - deployment.id, - "BUILD", - "Updated GitHub check run to Completed with conclusion " + - (status === "DEPLOYING" ? "Success" : "Failure"), - ); - } catch (e) { - console.error("Failed to update check run: ", e); - } - } - - if (status === "DEPLOYING") { - const { namespace, configs, postCreate } = - await createAppConfigsFromDeployment( - org, - app, - appGroup, - deployment, - config, - ); - - try { - const api = getClientForClusterUsername( - app.clusterUsername, - "KubernetesObjectApi", - shouldImpersonate(app.projectId), - ); - - await createOrUpdateApp(api, app.name, namespace, configs, postCreate); - log(deployment.id, "BUILD", "Deployment succeeded"); - - 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), - ]); - - dequeueBuildJob(); // TODO - error handling for this line - } catch (err) { - console.error(err); - await db.deployment.setStatus(deployment.id, "ERROR"); - await log( - deployment.id, - "BUILD", - `Failed to apply Kubernetes resources: ${JSON.stringify(err?.body ?? err)}`, - "stderr", - ); + try { + await updateDeployment(secret, status); + return json(200, res, undefined); + } catch (e) { + if (e instanceof ValidationError) { + return json(404, res, { code: 400, message: e.message }); + } else if (e instanceof DeploymentNotFoundError) { + return json(404, res, { code: 404, message: "Deployment not found." }); } + throw e; } - - return json(200, res, undefined); }; diff --git a/backend/src/handlers/webhook/push.ts b/backend/src/handlers/webhook/push.ts deleted file mode 100644 index c67effac..00000000 --- a/backend/src/handlers/webhook/push.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { db } from "../../db/index.ts"; -import type { components } from "../../generated/openapi.ts"; -import { getOctokit } from "../../lib/octokit.ts"; -import { json, type HandlerMap } from "../../types.ts"; -import { buildAndDeploy } from "../githubWebhook.ts"; - -export const handlePush: HandlerMap["githubWebhook"] = async ( - ctx, - req, - res, -) => { - const payload = ctx.request - .requestBody as components["schemas"]["webhook-push"]; - - const repoId = payload.repository?.id; - if (!repoId) { - return json(400, res, { - code: 400, - message: "Repository ID not specified", - }); - } - - const updatedBranch = payload.ref.match(/^refs\/heads\/(?.+)/).groups - .branch; - - // Look up the connected app and create a deployment job - const apps = await db.app.listFromConnectedRepo( - repoId, - "push", - updatedBranch, - undefined, - ); - - if (apps.length === 0) { - return json(200, res, { message: "No matching apps found" }); - } - - 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, - 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, - }, - createCheckRun: true, - octokit, - owner: payload.repository.owner.login, - repo: payload.repository.name, - }); - } - - return json(200, res, {}); -}; diff --git a/backend/src/handlers/webhook/workflow_run.ts b/backend/src/handlers/webhook/workflow_run.ts deleted file mode 100644 index 64e49e03..00000000 --- a/backend/src/handlers/webhook/workflow_run.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { db } from "../../db/index.ts"; -import type { components } from "../../generated/openapi.ts"; -import { getOctokit } from "../../lib/octokit.ts"; -import { json, type HandlerMap } from "../../types.ts"; -import { - buildAndDeployFromRepo, - createPendingWorkflowDeployment, - log, -} from "../githubWebhook.ts"; - -export const handleWorkflowRun: HandlerMap["githubWebhook"] = async ( - ctx, - req, - res, -) => { - const payload = ctx.request - .requestBody as components["schemas"]["webhook-workflow-run"]; - - const repoId = payload.repository?.id; - if (!repoId) { - return json(400, res, { - code: 400, - message: "Repository ID not specified", - }); - } - - if (payload.action === "in_progress") { - return json(200, res, {}); - } - - // Look up the connected apps - const apps = await db.app.listFromConnectedRepo( - repoId, - "workflow_run", - payload.workflow_run.head_branch, - payload.workflow.id, - ); - - if (apps.length === 0) { - return json(200, res, { message: "No matching apps found" }); - } - - 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, - }, - 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) { - const org = await db.org.getById(app.orgId); - const deployment = await db.deployment.getFromWorkflowRunId( - 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 - // If the deployment was canceled, its check run will be updated to canceled - continue; - } - if (payload.workflow_run.conclusion !== "success") { - // No need to build for unsuccessful workflow run - log( - deployment.id, - "BUILD", - "Workflow run did not complete successfully", - ); - if (!deployment.checkRunId) { - continue; - } - const octokit = await getOctokit(org.githubInstallationId); - try { - await octokit.rest.checks.update({ - check_run_id: deployment.checkRunId, - owner: payload.repository.owner.login, - repo: payload.repository.name, - status: "completed", - conclusion: "cancelled", - }); - log( - deployment.id, - "BUILD", - "Updated GitHub check run to Completed with conclusion Cancelled", - ); - await db.deployment.setStatus(deployment.id, "CANCELLED"); - } catch (e) {} - 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, - }); - } - } - return json(200, res, {}); -}; diff --git a/backend/src/index.ts b/backend/src/index.ts index f49b6b85..88ac0bb4 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -113,7 +113,7 @@ if (existsSync(publicDir) && statSync(publicDir).isDirectory()) { }); } -app.listen(port, (err) => { +export const server = app.listen(port, (err) => { if (err !== undefined) { console.error(err); } else { diff --git a/backend/src/lib/builder.ts b/backend/src/lib/builder.ts index ff118b9d..814d34df 100644 --- a/backend/src/lib/builder.ts +++ b/backend/src/lib/builder.ts @@ -11,12 +11,15 @@ import type { DeploymentConfig, Organization, } from "../db/models.ts"; -import { generateCloneURLWithCredentials } from "../handlers/githubWebhook.ts"; import { svcK8s } from "./cluster/kubernetes.ts"; import { wrapWithLogExporter } from "./cluster/resources/logs.ts"; import { generateAutomaticEnvVars } from "./cluster/resources/statefulset.ts"; import { env } from "./env.ts"; -import { getOctokit, getRepoById } from "./octokit.ts"; +import { + generateCloneURLWithCredentials, + getOctokit, + getRepoById, +} from "./octokit.ts"; export type ImageTag = `${string}/${string}/${string}:${string}`; @@ -36,7 +39,7 @@ async function createJobFromDeployment( ); const label = randomBytes(4).toString("hex"); - const secretName = `anvilops-temp-build-secrets-${app.id}-${deployment.id}`; + const secretName = `anvilops-temp-build-secrets-${app.id}-${deployment.id}-${label}`; const jobName = `build-image-${app.imageRepo}-${label}`; const envVars = config.getEnv(); diff --git a/backend/src/lib/import.ts b/backend/src/lib/import.ts index 205a264a..e4bea692 100644 --- a/backend/src/lib/import.ts +++ b/backend/src/lib/import.ts @@ -1,11 +1,13 @@ import crypto from "node:crypto"; import { setTimeout } from "node:timers/promises"; import type { Octokit } from "octokit"; -import { generateCloneURLWithCredentials } from "../handlers/githubWebhook.ts"; import { svcK8s } from "./cluster/kubernetes.ts"; - import { env } from "./env.ts"; -import { getOctokit, getUserOctokit } from "./octokit.ts"; +import { + generateCloneURLWithCredentials, + getOctokit, + getUserOctokit, +} from "./octokit.ts"; export async function getLocalRepo(octokit: Octokit, url: URL) { if (url.host === new URL(env.GITHUB_BASE_URL).host) { diff --git a/backend/src/lib/octokit.ts b/backend/src/lib/octokit.ts index cf0d2986..e09d10ae 100644 --- a/backend/src/lib/octokit.ts +++ b/backend/src/lib/octokit.ts @@ -89,3 +89,34 @@ export async function getRepoById(octokit: Octokit, repoId: number) { ), ) as Repo; } + +export async function generateCloneURLWithCredentials( + octokit: Octokit, + originalURL: string, +) { + const url = URL.parse(originalURL); + + if (url.host !== URL.parse(env.GITHUB_BASE_URL).host) { + // If the target is on a different GitHub instance, don't add credentials! + return originalURL; + } + + const token = await getInstallationAccessToken(octokit); + url.username = "x-access-token"; + url.password = token; + return url.toString(); +} + +export async function getLatestCommit( + octokit: Octokit, + owner: string, + repo: string, +) { + return ( + await octokit.rest.repos.listCommits({ + per_page: 1, + owner, + repo, + }) + ).data[0]; +} diff --git a/backend/src/lib/validate.ts b/backend/src/lib/validate.ts index cb703559..d3936526 100644 --- a/backend/src/lib/validate.ts +++ b/backend/src/lib/validate.ts @@ -1,4 +1,5 @@ import type { components } from "../generated/openapi.ts"; +import { ValidationError } from "../service/common/errors.ts"; import { namespaceInUse } from "./cluster/kubernetes.ts"; import { getNamespace, @@ -50,7 +51,10 @@ export async function validateDeploymentConfig( throw new Error("Invalid port number: must be between 0 and 65535"); } - validateEnv(env); + const envResult = validateEnv(env); + if (!envResult.valid) { + throw new ValidationError(envResult.message); + } validateMounts(mounts); @@ -121,6 +125,8 @@ export const validateEnv = (env: PrismaJson.EnvVar[]) => { } envNames.add(envVar.name); } + + return { valid: true }; }; export const validateSubdomain = async (subdomain: string) => { diff --git a/backend/src/service/acceptInvitation.ts b/backend/src/service/acceptInvitation.ts new file mode 100644 index 00000000..f90b0dfe --- /dev/null +++ b/backend/src/service/acceptInvitation.ts @@ -0,0 +1,17 @@ +import { db, NotFoundError } from "../db/index.ts"; +import { InvitationNotFoundError } from "./common/errors.ts"; + +export async function acceptInvitation( + invitationId: number, + orgId: number, + inviteeId: number, +) { + try { + await db.invitation.accept(invitationId, orgId, inviteeId); + } catch (e: any) { + if (e instanceof NotFoundError) { + throw new InvitationNotFoundError(e); + } + throw e; + } +} diff --git a/backend/src/service/claimOrg.ts b/backend/src/service/claimOrg.ts new file mode 100644 index 00000000..38d10d15 --- /dev/null +++ b/backend/src/service/claimOrg.ts @@ -0,0 +1,26 @@ +import { db, NotFoundError } from "../db/index.ts"; +import { + InstallationNotFoundError, + OrgNotFoundError, +} from "./common/errors.ts"; + +export async function claimOrg( + orgId: number, + unassignedInstallationId: number, + userId: number, +) { + try { + await db.org.claimInstallation(orgId, unassignedInstallationId, userId); + } catch (e) { + if (e instanceof NotFoundError) { + switch (e.message) { + case "installation": + throw new InstallationNotFoundError(e); + case "organization": + throw new OrgNotFoundError(e); + } + } + + throw e; + } +} diff --git a/backend/src/service/common/errors.ts b/backend/src/service/common/errors.ts new file mode 100644 index 00000000..2e03a071 --- /dev/null +++ b/backend/src/service/common/errors.ts @@ -0,0 +1,78 @@ +export class UserNotFoundError extends Error {} +export class AppNotFoundError extends Error {} +export class RepositoryNotFoundError extends Error {} + +export class InstallationNotFoundError extends Error { + constructor(cause: Error) { + super(undefined, { cause: cause }); + } +} + +export class OrgNotFoundError extends Error { + constructor(cause: Error) { + super(undefined, { cause: cause }); + } +} + +export class InvitationNotFoundError extends Error { + constructor(cause: Error) { + super(undefined, { cause: cause }); + } +} + +export class DeploymentNotFoundError extends Error {} + +export class ValidationError extends Error {} + +export class DeploymentError extends Error { + constructor(cause: Error) { + super(undefined, { cause: cause }); + } +} + +export class AppCreateError extends Error { + appName: string; + + constructor(appName: string, cause: Error) { + super(appName, cause); + this.appName = appName; + } +} + +/** + * Thrown when trying to use the file browser to mount a PVC + * that doesn't belong to the requested application. + */ +export class IllegalPVCAccessError extends Error {} + +/** + * Thrown when an organization is already linked to GitHub + * and a user tries to install the GitHub App again. + */ +export class OrgAlreadyLinkedError extends Error {} + +export class GitHubOAuthStateCreationError extends Error {} + +/** + * Thrown when the account used to install the GitHub App + * differs from the one authenticated in the follow-up request. + */ +export class GitHubOAuthAccountMismatchError extends Error {} + +/** + * Thrown when there's something wrong or unexpected with the + * given OAuth `state` parameter. + */ +export class GitHubOAuthStateMismatchError extends Error {} + +/** + * Thrown when a user tries to link an AnvilOps organization with + * a GitHub App installation that they didn't create. + */ +export class GitHubInstallationForbiddenError extends Error {} + +/** + * Thrown when a webhook payload doesn't match any of the expected + * actions or events. Should trigger a "Bad request" (4xx) HTTP error. + */ +export class UnknownWebhookRequestTypeError extends Error {} diff --git a/backend/src/service/createApp.ts b/backend/src/service/createApp.ts new file mode 100644 index 00000000..0e0d8301 --- /dev/null +++ b/backend/src/service/createApp.ts @@ -0,0 +1,203 @@ +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 { 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 { getLatestCommit, getOctokit, getRepoById } from "../lib/octokit.ts"; +import { + validateAppGroup, + validateAppName, + validateDeploymentConfig, +} from "../lib/validate.ts"; +import { + DeploymentError, + OrgNotFoundError, + ValidationError, +} from "./common/errors.ts"; +import { buildAndDeploy } from "./githubWebhook.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 }, + }); + + if (!organization) { + throw new OrgNotFoundError(null); + } + + try { + await validateDeploymentConfig({ ...appData, collectLogs: true }); + validateAppGroup(appData.appGroup); + validateAppName(appData.name); + } catch (e) { + throw new ValidationError(e.message, { cause: 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>; + + try { + octokit = await getOctokit(organization.githubInstallationId); + repo = await getRepoById(octokit, appData.repositoryId); + } catch (err) { + if (err.status === 404) { + throw new ValidationError("Invalid repository ID", { cause: err }); + } + + throw new Error("Failed to look up GitHub repository", { cause: err }); + } + + 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", { cause: err }); + } + } + + const latestCommit = await getLatestCommit( + octokit, + repo.owner.login, + repo.name, + ); + + 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": + appGroupId = await db.appGroup.create( + appData.orgId, + appData.appGroup.name, + false, + ); + break; + default: + appGroupId = appData.appGroup.id; + break; + } + + let namespace = appData.subdomain; + if (await namespaceInUse(getNamespace(namespace))) { + namespace += "-" + Math.floor(Math.random() * 10_000); + } + + try { + app = await db.app.create({ + orgId: appData.orgId, + appGroupId: appGroupId, + name: appData.name, + clusterUsername: clusterUsername, + projectId: appData.projectId, + namespace: namespace, + }); + } catch (err) { + if (err instanceof ConflictError) { + throw new ValidationError( + "App group name conflicts with an existing app group.", + ); + } + throw err; + } + + try { + await buildAndDeploy({ + org: organization, + app, + imageRepo: app.imageRepo, + commitMessage: commitMessage, + config: deploymentConfig, + createCheckRun: false, + }); + } catch (err) { + throw new DeploymentError(err); + } + + return app.id; +} diff --git a/backend/src/service/createAppGroup.ts b/backend/src/service/createAppGroup.ts new file mode 100644 index 00000000..60d8fa42 --- /dev/null +++ b/backend/src/service/createAppGroup.ts @@ -0,0 +1,65 @@ +import { ConflictError, db } from "../db/index.ts"; +import type { components } from "../generated/openapi.ts"; +import { validateAppGroup } from "../lib/validate.ts"; +import { AppCreateError, ValidationError } from "../service/common/errors.ts"; +import { + createApp, + validateAppConfig, + type NewApp, +} from "../service/createApp.ts"; + +export type NewAppWithoutGroup = + components["schemas"]["NewAppWithoutGroupInfo"]; + +export async function createAppGroup( + userId: number, + orgId: number, + 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( + (app) => + ({ + ...app, + appGroup: { type: "add-to", id: groupId }, + }) satisfies NewApp, + ); + + const validationResults = await Promise.all( + appsWithGroups.map(async (app) => { + try { + return await validateAppConfig(userId, app); + } catch (e) { + throw new AppCreateError(app.name, e); + } + }), + ); + + for (let i = 0; i < appsWithGroups.length; i++) { + try { + await createApp(appsWithGroups[i], validationResults[i]); + } catch (e) { + throw new AppCreateError(appsWithGroups[i].name, e); + } + } +} diff --git a/backend/src/service/createOrg.ts b/backend/src/service/createOrg.ts new file mode 100644 index 00000000..36d45466 --- /dev/null +++ b/backend/src/service/createOrg.ts @@ -0,0 +1,9 @@ +import { db } from "../db/index.ts"; +import type { Organization } from "../db/models.ts"; + +export async function createOrg( + name: string, + firstUserId: number, +): Promise { + return await db.org.create(name, firstUserId); +} diff --git a/backend/src/service/deleteApp.ts b/backend/src/service/deleteApp.ts new file mode 100644 index 00000000..58781e86 --- /dev/null +++ b/backend/src/service/deleteApp.ts @@ -0,0 +1,78 @@ +import { db } from "../db/index.ts"; +import { + createOrUpdateApp, + deleteNamespace, + getClientsForRequest, +} from "../lib/cluster/kubernetes.ts"; +import { + createAppConfigsFromDeployment, + getNamespace, +} from "../lib/cluster/resources.ts"; +import { deleteRepo } from "../lib/registry.ts"; +import { AppNotFoundError } from "./common/errors.ts"; + +export async function deleteApp( + appId: number, + userId: number, + keepNamespace: boolean, +) { + const app = await db.app.getById(appId); + + // Check permission + const org = await db.org.getById(app.orgId, { + requireUser: { id: userId, permissionLevel: "OWNER" }, + }); + if (!org) { + throw new AppNotFoundError(); + } + + const { namespace, projectId, imageRepo } = app; + const lastDeployment = await db.app.getMostRecentDeployment(appId); + if (!keepNamespace) { + const { KubernetesObjectApi: api } = await getClientsForRequest( + userId, + projectId, + ["KubernetesObjectApi"], + ); + await deleteNamespace(api, getNamespace(namespace)); + } else if (lastDeployment) { + const config = await db.deployment.getConfig(lastDeployment.id); + + if (config.collectLogs) { + // If the log shipper was enabled, redeploy without it + config.collectLogs = false; // <-- Disable log shipping + + const app = await db.app.getById(lastDeployment.appId); + const [org, appGroup] = await Promise.all([ + db.org.getById(app.orgId), + db.appGroup.getById(app.appGroupId), + ]); + + const { namespace, configs, postCreate } = + await createAppConfigsFromDeployment( + org, + app, + appGroup, + lastDeployment, + config, + ); + + const { KubernetesObjectApi: api } = await getClientsForRequest( + userId, + app.projectId, + ["KubernetesObjectApi"], + ); + await createOrUpdateApp(api, app.name, namespace, configs, postCreate); + } + + // TODO: redeploy without AnvilOps-specified labels + } + + try { + if (imageRepo) await deleteRepo(imageRepo); + } catch (err) { + console.error("Couldn't delete image repository:", err); + } + + await db.app.delete(appId); +} diff --git a/backend/src/service/deleteAppPod.ts b/backend/src/service/deleteAppPod.ts new file mode 100644 index 00000000..d6547d2d --- /dev/null +++ b/backend/src/service/deleteAppPod.ts @@ -0,0 +1,26 @@ +import { db } from "../db/index.ts"; +import { getClientsForRequest } from "../lib/cluster/kubernetes.ts"; +import { getNamespace } from "../lib/cluster/resources.ts"; +import { AppNotFoundError } from "./common/errors.ts"; + +export async function deleteAppPod( + appId: number, + podName: string, + userId: number, +) { + const app = await db.app.getById(appId, { + requireUser: { id: userId }, + }); + if (!app) { + throw new AppNotFoundError(); + } + + const { CoreV1Api: api } = await getClientsForRequest(userId, app.projectId, [ + "CoreV1Api", + ]); + + await api.deleteNamespacedPod({ + namespace: getNamespace(app.namespace), + name: podName, + }); +} diff --git a/backend/src/service/deleteOrgByID.ts b/backend/src/service/deleteOrgByID.ts new file mode 100644 index 00000000..27c62517 --- /dev/null +++ b/backend/src/service/deleteOrgByID.ts @@ -0,0 +1,21 @@ +import { db } from "../db/index.ts"; +import { OrgNotFoundError } from "./common/errors.ts"; +import { deleteApp } from "./deleteApp.ts"; + +export async function deleteOrgByID(orgId: number, userId: number) { + const org = await db.org.getById(orgId, { + requireUser: { id: userId, permissionLevel: "OWNER" }, + }); + + if (!org) { + throw new OrgNotFoundError(null); + } + + const apps = await db.app.listForOrg(orgId); + + await Promise.all( + apps.map(async (app) => await deleteApp(app.id, userId, false)), + ); + + await db.org.delete(orgId); +} diff --git a/backend/src/service/files.ts b/backend/src/service/files.ts new file mode 100644 index 00000000..d7bdb3c4 --- /dev/null +++ b/backend/src/service/files.ts @@ -0,0 +1,39 @@ +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"; + +export async function forwardToFileBrowser( + userId: number, + appId: number, + volumeClaimName: string, + path: string, + requestInit: RequestInit, +) { + const app = await db.app.getById(appId, { requireUser: { id: userId } }); + + if (!app) { + throw new AppNotFoundError(); + } + + const config = await db.app.getDeploymentConfig(appId); + + if ( + !config.mounts.some((mount) => + volumeClaimName.startsWith(generateVolumeName(mount.path) + "-"), + ) + ) { + // This persistent volume doesn't belong to the application + throw new IllegalPVCAccessError(); + } + + const response = await forwardRequest( + getNamespace(app.namespace), + volumeClaimName, + path, + requestInit, + ); + + return response; +} diff --git a/backend/src/service/getAppByID.ts b/backend/src/service/getAppByID.ts new file mode 100644 index 00000000..dc5338f3 --- /dev/null +++ b/backend/src/service/getAppByID.ts @@ -0,0 +1,107 @@ +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"; + +export async function getAppByID(appId: number, userId: number) { + const [app, recentDeployment, deploymentCount] = await Promise.all([ + db.app.getById(appId, { requireUser: { id: userId } }), + db.app.getMostRecentDeployment(appId), + db.app.getDeploymentCount(appId), + ]); + + if (!app) { + throw new AppNotFoundError(); + } + + // Fetch the current StatefulSet to read its labels + const getK8sDeployment = async () => { + try { + const { AppsV1Api: api } = await getClientsForRequest( + userId, + app.projectId, + ["AppsV1Api"], + ); + return await api.readNamespacedStatefulSet({ + namespace: getNamespace(app.namespace), + name: app.name, + }); + } catch {} + }; + + const [org, appGroup, currentConfig, activeDeployment] = await Promise.all([ + db.org.getById(app.orgId), + db.appGroup.getById(app.appGroupId), + db.deployment.getConfig(recentDeployment.id), + (await getK8sDeployment())?.spec?.template?.metadata?.labels?.[ + "anvilops.rcac.purdue.edu/deployment-id" + ], + ]); + + // Fetch repository info if this app is deployed from a Git repository + const { repoId, repoURL } = await (async () => { + if (currentConfig.source === "GIT" && org.githubInstallationId) { + const octokit = await getOctokit(org.githubInstallationId); + const repo = await getRepoById(octokit, currentConfig.repositoryId); + return { repoId: repo.id, repoURL: repo.html_url }; + } else { + return { repoId: undefined, repoURL: undefined }; + } + })(); + + return { + id: app.id, + orgId: app.orgId, + projectId: app.projectId, + name: app.name, + displayName: app.displayName, + createdAt: app.createdAt.toISOString(), + updatedAt: app.updatedAt.toISOString(), + repositoryId: repoId, + 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, + }), + }, + appGroup: { + standalone: appGroup.isMono, + name: appGroup.name, + id: app.appGroupId, + }, + activeDeployment: activeDeployment ? parseInt(activeDeployment) : undefined, + deploymentCount, + }; +} diff --git a/backend/src/service/getAppLogs.ts b/backend/src/service/getAppLogs.ts new file mode 100644 index 00000000..4bc049c9 --- /dev/null +++ b/backend/src/service/getAppLogs.ts @@ -0,0 +1,126 @@ +import type { V1PodList } from "@kubernetes/client-node"; +import stream from "node:stream"; +import { db } from "../db/index.ts"; +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"; + +export async function getAppLogs( + appId: number, + deploymentId: number, + userId: number, + type: LogType, + lastLogId: number, + abortController: AbortController, + callback: (log: components["schemas"]["LogLine"]) => Promise, +) { + const app = await db.app.getById(appId, { + requireUser: { id: userId }, + }); + + if (app === null) { + throw new AppNotFoundError(); + } + + // Pull logs from Postgres and send them to the client as they come in + if (typeof deploymentId !== "number") { + // Extra sanity check due to potential SQL injection below in `subscribe`; should never happen because of openapi-backend's request validation and additional sanitization in `subscribe()` + throw new Error("deploymentId must be a number."); + } + + // 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; + + if (collectLogs || type === "BUILD") { + const fetchNewLogs = async () => { + const newLogs = await db.deployment.getLogs( + deploymentId, + lastLogId, + type, + 500, + ); + if (newLogs.length > 0) { + lastLogId = newLogs[0].id; + } + for (const log of newLogs) { + await callback({ + id: log.id, + type: log.type, + stream: log.stream, + log: log.content as string, + pod: log.podName, + time: log.timestamp.toISOString(), + }); + } + }; + + // When new logs come in, send them to the client + const unsubscribe = await db.subscribe( + `deployment_${deploymentId}_logs`, + fetchNewLogs, + ); + + abortController.signal.addEventListener("abort", unsubscribe); + + // Send all previous logs now + await fetchNewLogs(); + } else { + const { CoreV1Api: core, Log: log } = await getClientsForRequest( + userId, + app.projectId, + ["CoreV1Api", "Log"], + ); + let pods: V1PodList; + try { + pods = await core.listNamespacedPod({ + namespace: getNamespace(app.namespace), + labelSelector: `anvilops.rcac.purdue.edu/deployment-id=${deploymentId}`, + }); + } catch (err) { + // Namespace may not be ready yet + pods = { apiVersion: "v1", items: [] }; + } + + for (let podIndex = 0; podIndex < pods.items.length; podIndex++) { + const pod = pods.items[podIndex]; + const podName = pod.metadata.name; + const logStream = new stream.PassThrough(); + const logAbortController = await log.log( + getNamespace(app.namespace), + podName, + pod.spec.containers[0].name, + logStream, + { follow: true, tailLines: 500, timestamps: true }, + ); + abortController.signal.addEventListener("abort", () => + logAbortController.abort(), + ); + let i = 0; + let current = ""; + logStream.on("data", async (chunk: Buffer) => { + const str = chunk.toString(); + current += str; + if (str.endsWith("\n") || str.endsWith("\r")) { + const lines = current.split("\n"); + current = ""; + for (const line of lines) { + if (line.trim().length === 0) continue; + const [date, ...text] = line.split(" "); + await callback({ + type: "RUNTIME", + log: text.join(" "), + stream: "stdout", + pod: podName, + time: date, + id: podIndex * 100_000_000 + i, + }); + i++; + } + } + }); + } + } +} diff --git a/backend/src/service/getAppStatus.ts b/backend/src/service/getAppStatus.ts new file mode 100644 index 00000000..d9d35e88 --- /dev/null +++ b/backend/src/service/getAppStatus.ts @@ -0,0 +1,232 @@ +import { + AbortError, + type CoreV1EventList, + type KubernetesListObject, + type KubernetesObject, + type V1PodCondition, + type V1PodList, + type V1StatefulSet, + type Watch, +} 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 { AppNotFoundError } from "./common/errors.ts"; + +export type StatusUpdate = {}; + +export async function getAppStatus( + appId: number, + userId: number, + abortController: AbortController, + callback: (status: StatusUpdate) => Promise, +) { + const app = await db.app.getById(appId, { + requireUser: { id: userId }, + }); + + if (!app) { + throw new AppNotFoundError(); + } + + let pods: V1PodList; + let statefulSet: V1StatefulSet; + let events: CoreV1EventList; + + const update = async () => { + if (!pods || !events || !statefulSet) return; + const newStatus = { + pods: pods.items.map((pod) => ({ + id: pod.metadata?.uid, + name: pod.metadata?.name, + createdAt: pod.metadata?.creationTimestamp, + startedAt: pod.status?.startTime, + deploymentId: parseInt( + pod.metadata.labels["anvilops.rcac.purdue.edu/deployment-id"], + ), + node: pod.spec?.nodeName, + podScheduled: + getCondition(pod?.status?.conditions, "PodScheduled")?.status === + "True", + podReady: + getCondition(pod?.status?.conditions, "Ready")?.status === "True", + image: pod.status?.containerStatuses?.[0]?.image, + containerReady: pod.status?.containerStatuses?.[0]?.ready, + containerState: pod.status?.containerStatuses?.[0]?.state, + lastState: pod.status?.containerStatuses?.[0].lastState, + ip: pod.status.podIP, + })), + events: events.items.map((event) => ({ + reason: event.reason, + message: event.message, + count: event.count, + firstTimestamp: event.firstTimestamp.toISOString(), + lastTimestamp: event.lastTimestamp.toISOString(), + })), + statefulSet: { + readyReplicas: statefulSet.status.readyReplicas, + updatedReplicas: statefulSet.status.currentReplicas, + replicas: statefulSet.status.replicas, + generation: statefulSet.metadata.generation, + observedGeneration: statefulSet.status.observedGeneration, + currentRevision: statefulSet.status.currentRevision, + updateRevision: statefulSet.status.updateRevision, + }, + }; + + await callback(newStatus); + }; + + const ns = getNamespace(app.namespace); + + const close = (err: any) => { + if (!(err instanceof AbortError) && !(err.cause instanceof AbortError)) { + console.error("Kubernetes watch failed: ", err); + } + abortController.abort(); + }; + + try { + const { + CoreV1Api: core, + AppsV1Api: apps, + Watch: watch, + } = await getClientsForRequest(userId, app.projectId, [ + "CoreV1Api", + "AppsV1Api", + "Watch", + ]); + const podWatcher = await watchList( + watch, + `/api/v1/namespaces/${ns}/pods`, + async () => + await core.listNamespacedPod({ + namespace: ns, + labelSelector: "anvilops.rcac.purdue.edu/deployment-id", + }), + { labelSelector: "anvilops.rcac.purdue.edu/deployment-id" }, + async (newValue) => { + pods = newValue; + await update(); + }, + close, + ); + abortController.signal.addEventListener("abort", () => podWatcher.abort()); + + const statefulSetWatcher = await watchList( + watch, + `/apis/apps/v1/namespaces/${ns}/statefulsets`, + async () => + await apps.listNamespacedStatefulSet({ + namespace: ns, + }), + {}, + async (newValue) => { + statefulSet = newValue.items.find( + (it) => it.metadata.name === app.name, + ); + await update(); + }, + close, + ); + abortController.signal.addEventListener("abort", () => + statefulSetWatcher.abort(), + ); + + const fieldSelector = `involvedObject.kind=StatefulSet,involvedObject.name=${app.name},type=Warning`; + + const eventsWatcher = await watchList( + watch, + `/api/v1/namespaces/${ns}/events`, + async () => + await core.listNamespacedEvent({ + namespace: ns, + fieldSelector, + limit: 15, + }), + { fieldSelector, limit: 15 }, + async (newValue) => { + events = newValue; + await update(); + }, + close, + ); + abortController.signal.addEventListener("abort", () => + eventsWatcher.abort(), + ); + } catch (e) { + close(e); + } + + await update(); +} + +function getCondition(conditions: V1PodCondition[], condition: string) { + return conditions?.find((it) => it.type === condition); +} + +async function watchList>( + watch: Watch, + path: string, + getInitialValue: () => Promise, + queryParams: Record, + callback: (newValue: T) => void, + stop: (err: any) => void, +) { + let list: T; + try { + list = await getInitialValue(); + callback(list); + queryParams["resourceVersion"] = list.metadata.resourceVersion; + } catch (e) { + stop(new Error("Failed to fetch initial value for " + path, { cause: e })); + return; + } + + return await watch.watch( + path, + queryParams, + (phase, object: KubernetesObject, watch) => { + switch (phase) { + case "ADDED": { + list.items.push(object); + break; + } + case "MODIFIED": { + const index = list.items.findIndex( + (item) => item.metadata.uid === object.metadata.uid, + ); + if (index === -1) { + // Modified an item that we don't know about. Try adding it to the list. + list.items.push(object); + } else { + list.items[index] = object; + } + break; + } + case "DELETED": { + const index = list.items.findIndex( + (item) => item.metadata.uid === object.metadata.uid, + ); + if (index === -1) { + // Deleted an item that we don't know about + return; + } else { + list.items.splice(index, 1); + } + break; + } + } + try { + callback(structuredClone(list)); + } catch (e) { + stop( + new Error("Failed to invoke update callback for " + path, { + cause: e, + }), + ); + } + }, + (err) => stop(new Error("Failed to watch " + path, { cause: err })), + ); +} diff --git a/backend/src/service/getDeployment.ts b/backend/src/service/getDeployment.ts new file mode 100644 index 00000000..038dc513 --- /dev/null +++ b/backend/src/service/getDeployment.ts @@ -0,0 +1,117 @@ +import type { V1Pod } 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"; + +export async function getDeployment(deploymentId: number, userId: number) { + const deployment = await db.deployment.getById(deploymentId, { + requireUser: { id: userId }, + }); + + if (!deployment) { + throw new DeploymentNotFoundError(); + } + + const [config, app] = await Promise.all([ + db.deployment.getConfig(deployment.id), + db.app.getById(deployment.appId), + ]); + + const org = await db.org.getById(app.orgId); + + 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 + .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[] }), + ), + ]); + + let scheduled = 0, + ready = 0, + failed = 0; + + for (const pod of pods?.items ?? []) { + if ( + pod?.status?.conditions?.find((it) => it.type === "PodScheduled") + ?.status === "True" + ) { + scheduled++; + } + if ( + pod?.status?.conditions?.find((it) => it.type === "Ready")?.status === + "True" + ) { + ready++; + } + if ( + pod?.status?.phase === "Failed" || + pod?.status?.containerStatuses?.[0]?.state?.terminated + ) { + failed++; + } + } + + const status = + deployment.status === "COMPLETE" && scheduled + ready + failed === 0 + ? ("STOPPED" as const) + : deployment.status; + + return { + repositoryURL, + commitHash: config.commitHash, + commitMessage: deployment.commitMessage, + 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, + }, + }; +} diff --git a/backend/src/service/getInstallation.ts b/backend/src/service/getInstallation.ts new file mode 100644 index 00000000..05719502 --- /dev/null +++ b/backend/src/service/getInstallation.ts @@ -0,0 +1,36 @@ +import { db } from "../db/index.ts"; +import { getOctokit } from "../lib/octokit.ts"; +import { + InstallationNotFoundError, + OrgNotFoundError, +} from "./common/errors.ts"; + +export async function getInstallation(orgId: number, userId: number) { + const org = await db.org.getById(orgId, { + requireUser: { id: userId }, + }); + + if (!org) { + throw new OrgNotFoundError(null); + } + + if (!org.githubInstallationId) { + throw new InstallationNotFoundError(null); + } + + const octokit = await getOctokit(org.githubInstallationId); + const installation = await octokit.rest.apps.getInstallation({ + installation_id: org.githubInstallationId, + }); + + return { + hasAllRepoAccess: installation.data.repository_selection === "all", + targetId: installation.data.target_id, + targetType: installation.data.target_type as "User" | "Organization", + targetName: + // `slug` is present when `account` is an Organization, and `login` is present when it's a User + "slug" in installation.data.account + ? installation.data.account.slug + : installation.data.account.login, + }; +} diff --git a/backend/src/service/getOrgByID.ts b/backend/src/service/getOrgByID.ts new file mode 100644 index 00000000..8948414d --- /dev/null +++ b/backend/src/service/getOrgByID.ts @@ -0,0 +1,102 @@ +import type { Octokit } from "octokit"; +import { db } from "../db/index.ts"; +import type { components } from "../generated/openapi.ts"; +import { env } from "../lib/env.ts"; +import { getOctokit, getRepoById } from "../lib/octokit.ts"; +import { OrgNotFoundError } from "./common/errors.ts"; + +export async function getOrgByID(orgId: number, userId: number) { + const org = await db.org.getById(orgId, { requireUser: { id: userId } }); + + if (!org) { + throw new OrgNotFoundError(null); + } + + const [apps, appGroups, outgoingInvitations, users] = await Promise.all([ + db.app.listForOrg(org.id), + db.appGroup.listForOrg(org.id), + db.invitation.listOutgoingForOrg(org.id), + db.org.listUsers(org.id), + ]); + + let octokit: Promise; + + if (org.githubInstallationId) { + octokit = getOctokit(org.githubInstallationId); + } + + const hydratedApps = await Promise.all( + apps.map(async (app) => { + const [config, selectedDeployment] = await Promise.all([ + db.app.getDeploymentConfig(app.id), + db.app.getMostRecentDeployment(app.id), + ]); + + if (!config) { + return null; + } + + let repoURL: string; + if (config.source === "GIT" && org.githubInstallationId) { + try { + const repo = await getRepoById(await octokit, config.repositoryId); + repoURL = repo.html_url; + } catch (error: any) { + if (error?.status === 404) { + // The repo couldn't be found. Either it doesn't exist or the installation doesn't have permission to see it. + return; + } + throw error; // Rethrow all other kinds of errors + } + } + + const appDomain = URL.parse(env.APP_DOMAIN); + + return { + id: app.id, + groupId: app.appGroupId, + 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, + }; + }), + ); + + const appGroupRes: components["schemas"]["Org"]["appGroups"] = appGroups.map( + (group) => { + return { + ...group, + apps: hydratedApps.filter((app) => app?.groupId === group.id), + }; + }, + ); + + return { + id: org.id, + name: org.name, + members: users.map((membership) => ({ + id: membership.user.id, + name: membership.user.name, + email: membership.user.email, + permissionLevel: membership.permissionLevel, + })), + githubInstallationId: org.githubInstallationId, + appGroups: appGroupRes, + outgoingInvitations: outgoingInvitations.map((inv) => ({ + id: inv.id, + inviter: { name: inv.inviter.name }, + invitee: { name: inv.invitee.name }, + org: { id: inv.orgId, name: inv.org.name }, + })), + }; +} diff --git a/backend/src/service/getSettings.ts b/backend/src/service/getSettings.ts new file mode 100644 index 00000000..e1a8a81e --- /dev/null +++ b/backend/src/service/getSettings.ts @@ -0,0 +1,37 @@ +import { readFile } from "node:fs/promises"; +import { isRancherManaged } from "../lib/cluster/rancher.ts"; +import { env } from "../lib/env.ts"; + +type ClusterConfig = { + name?: string; + faq?: { + question?: string; + answer?: string; + link?: string; + }; +}; + +let clusterConfigPromise: Promise | null = null; + +const configPath = + env["NODE_ENV"] === "development" + ? "./cluster.local.json" + : env.CLUSTER_CONFIG_PATH; + +if (configPath) { + clusterConfigPromise = readFile(configPath).then((file) => + JSON.parse(file.toString()), + ); +} + +export async function getSettings() { + const clusterConfig = await clusterConfigPromise; + + return { + appDomain: !!env.INGRESS_CLASS_NAME ? env.APP_DOMAIN : undefined, + clusterName: clusterConfig?.name, + faq: clusterConfig?.faq, + storageEnabled: env.STORAGE_CLASS_NAME !== undefined, + isRancherManaged: isRancherManaged(), + }; +} diff --git a/backend/src/service/getTemplates.ts b/backend/src/service/getTemplates.ts new file mode 100644 index 00000000..25c19e24 --- /dev/null +++ b/backend/src/service/getTemplates.ts @@ -0,0 +1,15 @@ +import { readFile } from "node:fs/promises"; +import { env } from "../lib/env.ts"; + +const path = + env.NODE_ENV === "development" + ? "../templates/templates.json" + : "./templates.json"; + +const templatesPromise = readFile(path, "utf8").then((file) => + JSON.parse(file.toString()), +); + +export async function getTemplates() { + return await templatesPromise; +} diff --git a/backend/src/service/getUser.ts b/backend/src/service/getUser.ts new file mode 100644 index 00000000..b3f65684 --- /dev/null +++ b/backend/src/service/getUser.ts @@ -0,0 +1,40 @@ +import { db } from "../db/index.ts"; +import { + getProjectsForUser, + isRancherManaged, +} from "../lib/cluster/rancher.ts"; + +export async function getUser(userId: number) { + const [user, orgs, unassignedInstallations, receivedInvitations] = + await Promise.all([ + db.user.getById(userId), + db.user.getOrgs(userId), + db.user.getUnassignedInstallations(userId), + db.invitation.listReceived(userId), + ]); + + const projects = + user?.clusterUsername && isRancherManaged() + ? await getProjectsForUser(user.clusterUsername) + : undefined; + + return { + id: user.id, + email: user.email, + name: user.name, + orgs: orgs.map((item) => ({ + id: item.organization.id, + name: item.organization.name, + permissionLevel: item.permissionLevel, + githubConnected: item.organization.githubInstallationId !== null, + })), + projects, + unassignedInstallations: unassignedInstallations, + receivedInvitations: receivedInvitations.map((inv) => ({ + id: inv.id, + inviter: { name: inv.inviter.name }, + invitee: { name: inv.invitee.name }, + org: { id: inv.orgId, name: inv.org.name }, + })), + }; +} diff --git a/backend/src/service/githubAppInstall.ts b/backend/src/service/githubAppInstall.ts new file mode 100644 index 00000000..0bf69a67 --- /dev/null +++ b/backend/src/service/githubAppInstall.ts @@ -0,0 +1,41 @@ +import { randomBytes } from "node:crypto"; +import { db } from "../db/index.ts"; +import type { GitHubOAuthState } from "../db/models.ts"; +import { + PermissionLevel, + type GitHubOAuthAction, +} from "../generated/prisma/enums.ts"; +import { OrgAlreadyLinkedError, OrgNotFoundError } from "./common/errors.ts"; + +export async function createGitHubAppInstallState( + orgId: number, + userId: number, +) { + const org = await db.org.getById(orgId, { + requireUser: { id: userId, permissionLevel: PermissionLevel.OWNER }, + }); + + if (org.githubInstallationId) { + throw new OrgAlreadyLinkedError(); + } + + if (org === null) { + throw new OrgNotFoundError(null); + } + + return await createState("CREATE_INSTALLATION", userId, orgId); +} + +export async function createState( + action: GitHubOAuthAction, + userId: number, + orgId: number, +) { + const random = randomBytes(64).toString("base64url"); + await db.user.setOAuthState(orgId, userId, action, random); + return random; +} + +export async function verifyState(random: string): Promise { + return await db.user.getAndDeleteOAuthState(random); +} diff --git a/backend/src/service/githubInstallCallback.ts b/backend/src/service/githubInstallCallback.ts new file mode 100644 index 00000000..5de1f4b2 --- /dev/null +++ b/backend/src/service/githubInstallCallback.ts @@ -0,0 +1,65 @@ +import { db } from "../db/index.ts"; +import { + GitHubOAuthAccountMismatchError, + GitHubOAuthStateMismatchError, + ValidationError, +} from "./common/errors.ts"; +import { createState, verifyState } from "./githubAppInstall.ts"; + +export async function createGitHubAuthorizationState( + state: string, + installationId: number, + setupAction: "request" | "install" | "update", + userId: number, +) { + if ( + !installationId && + (setupAction === "install" || setupAction === "update") + ) { + throw new ValidationError("Missing installation ID."); + } + + // Verify the `state` + let stateUserId: number, orgId: number; + try { + const parsed = await verifyState(state); + stateUserId = parsed.userId; + orgId = parsed.orgId; + + if (parsed.action !== "CREATE_INSTALLATION") { + throw new GitHubOAuthStateMismatchError(); + } + } catch (e) { + throw new GitHubOAuthStateMismatchError(null, { cause: e }); + } + + // Make sure the app was actually installed + if (setupAction === "request") { + // The user sent a request to an admin to approve their installation. + // We have to bail early here because we don't have the installation ID yet. It will come in through a webhook when the request is approved. + // Next, we'll get the user's GitHub user ID and save it for later so that we can associate the new installation with them. + const newState = await createState( + "GET_UID_FOR_LATER_INSTALLATION", + stateUserId, + orgId, + ); + return newState; + } + + // Verify the user ID hasn't changed + if (stateUserId !== userId) { + throw new GitHubOAuthAccountMismatchError(); + } + + // Save the installation ID temporarily + await db.org.setTemporaryInstallationId(orgId, stateUserId, installationId); + + // Generate a new `state` + const newState = await createState( + "VERIFY_INSTALLATION_ACCESS", + stateUserId, + orgId, + ); + + return newState; +} diff --git a/backend/src/service/githubOAuthCallback.ts b/backend/src/service/githubOAuthCallback.ts new file mode 100644 index 00000000..bcc06e8c --- /dev/null +++ b/backend/src/service/githubOAuthCallback.ts @@ -0,0 +1,87 @@ +import { db } from "../db/index.ts"; +import { + PermissionLevel, + type GitHubOAuthAction, +} from "../generated/prisma/enums.ts"; +import { getUserOctokit } from "../lib/octokit.ts"; +import { + GitHubInstallationForbiddenError, + GitHubOAuthAccountMismatchError, + GitHubOAuthStateMismatchError, + InstallationNotFoundError, + OrgNotFoundError, +} from "./common/errors.ts"; +import { verifyState } from "./githubAppInstall.ts"; + +type GitHubOAuthResponseResult = "done" | "approval-needed"; + +export async function processGitHubOAuthResponse( + state: string, + code: string, + reqUserId: number, +): Promise { + // Verify the `state` and extract the user and org IDs + let action: GitHubOAuthAction, userId: number, orgId: number; + try { + const parsed = await verifyState(state); + action = parsed.action; + userId = parsed.userId; + orgId = parsed.orgId; + } catch (e) { + throw new GitHubOAuthStateMismatchError(); + } + + // Verify that the user ID hasn't changed + if (userId !== reqUserId) { + throw new GitHubOAuthAccountMismatchError(); + } + + // Verify that the user has access to the installation + if (action === "VERIFY_INSTALLATION_ACCESS") { + const octokit = getUserOctokit(code); + + const org = await db.org.getById(orgId, { + requireUser: { id: userId, permissionLevel: PermissionLevel.OWNER }, + }); + + if (!org) { + throw new OrgNotFoundError(null); + } + + if (!org?.newInstallationId) { + throw new InstallationNotFoundError(null); + } + + const installations = ( + await octokit.rest.apps.listInstallationsForAuthenticatedUser() + ).data.installations; + let found = false; + for (const install of installations) { + if (install.id === org.newInstallationId) { + found = true; + break; + } + } + + if (!found) { + // The user doesn't have access to the new installation + throw new GitHubInstallationForbiddenError(); + } + + // Update the organization's installation ID + await db.org.setInstallationId(orgId, org.newInstallationId); + + // We're finally done! Redirect the user back to the frontend. + return "done"; + } else if (state === "GET_UID_FOR_LATER_INSTALLATION") { + const octokit = getUserOctokit(code); + const user = await octokit.rest.users.getAuthenticated(); + + await db.user.setGitHubUserId(userId, user.data.id); + + // Redirect the user to a page that says the app approval is pending and that they can link the installation to an organization when the request is approved. + return "approval-needed"; + } else { + throw new GitHubOAuthStateMismatchError(); + } +} diff --git a/backend/src/service/githubWebhook.ts b/backend/src/service/githubWebhook.ts new file mode 100644 index 00000000..6c04bcae --- /dev/null +++ b/backend/src/service/githubWebhook.ts @@ -0,0 +1,610 @@ +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 { env } from "../lib/env.ts"; +import { getOctokit, getRepoById } from "../lib/octokit.ts"; +import { + AppNotFoundError, + UnknownWebhookRequestTypeError, + UserNotFoundError, + ValidationError, +} from "./common/errors.ts"; + +export async function processGitHubWebhookPayload( + event: string, + action: string, + requestBody: any, +) { + switch (event) { + case "repository": { + switch (action) { + case "transferred": { + return await handleRepositoryTransferred( + requestBody as components["schemas"]["webhook-repository-transferred"], + ); + } + case "deleted": { + return await handleRepositoryDeleted( + requestBody as components["schemas"]["webhook-repository-deleted"], + ); + } + default: { + throw new UnknownWebhookRequestTypeError(); + } + } + } + case "installation": { + switch (action) { + case "created": { + return await handleInstallationCreated( + requestBody as components["schemas"]["webhook-installation-created"], + ); + } + case "deleted": { + return await handleInstallationDeleted( + requestBody as components["schemas"]["webhook-installation-deleted"], + ); + } + default: { + throw new UnknownWebhookRequestTypeError(); + } + } + } + case "push": { + return await handlePush( + requestBody as components["schemas"]["webhook-push"], + ); + } + case "workflow_run": { + return await handleWorkflowRun( + requestBody as components["schemas"]["webhook-workflow-run"], + ); + } + default: { + throw new UnknownWebhookRequestTypeError(); + } + } +} + +async function handleRepositoryTransferred( + payload: components["schemas"]["webhook-repository-transferred"], +) { + // TODO Verify that the AnvilOps organization(s) linked to this repo still have access to it +} + +async function handleRepositoryDeleted( + payload: components["schemas"]["webhook-repository-deleted"], +) { + // Unlink the repository from all of its associated apps + // Every deployment from that repository will now be listed as directly from the produced container image + await db.deployment.unlinkRepositoryFromAllDeployments(payload.repository.id); +} + +async function handleInstallationCreated( + payload: components["schemas"]["webhook-installation-created"], +) { + // This webhook is sent when the GitHub App is installed or a request to install the GitHub App is approved. Here, we care about the latter. + if (!payload.requester) { + // Since this installation has no requester, it was created without going to an organization admin for approval. That means it's already been linked to an AnvilOps organization in src/handlers/githubOAuthCallback.ts. + // TODO: Verify that the requester field is what I think it is. GitHub doesn't provide any description of it in their API docs. + return; + } + + if (payload.installation.app_id.toString() !== env.GITHUB_APP_ID) { + // Sanity check + throw new ValidationError("Invalid GitHub app ID"); + } + + // Find the person who requested the app installation and add a record linked to their account that allows them to link the installation to an organization of their choosing + try { + await db.user.createUnassignedInstallation( + payload.requester.id, + payload.installation.id, + payload.installation["login"] ?? payload.installation.account.name, + payload.installation.html_url, + ); + } catch (e) { + if (e instanceof NotFoundError && e.message === "user") { + throw new UserNotFoundError(null, { cause: e }); + } else { + throw e; + } + } +} + +async function handleInstallationDeleted( + payload: components["schemas"]["webhook-installation-deleted"], +) { + // Unlink the GitHub App installation from the organization + await db.org.unlinkInstallationFromAllOrgs(payload.installation.id); +} + +async function handlePush(payload: components["schemas"]["webhook-push"]) { + const repoId = payload.repository?.id; + if (!repoId) { + throw new ValidationError("Repository ID not specified"); + } + + const updatedBranch = payload.ref.match(/^refs\/heads\/(?.+)/).groups + .branch; + + // Look up the connected app and create a deployment job + const apps = await db.app.listFromConnectedRepo( + repoId, + "push", + updatedBranch, + undefined, + ); + + if (apps.length === 0) { + throw new AppNotFoundError(); + } + + 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, + 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, + }, + createCheckRun: true, + octokit, + owner: payload.repository.owner.login, + repo: payload.repository.name, + }); + } +} + +async function handleWorkflowRun( + payload: components["schemas"]["webhook-workflow-run"], +) { + const repoId = payload.repository?.id; + if (!repoId) { + throw new ValidationError("Repository ID not specified"); + } + + if (payload.action === "in_progress") { + return; + } + + // Look up the connected apps + const apps = await db.app.listFromConnectedRepo( + repoId, + "workflow_run", + payload.workflow_run.head_branch, + payload.workflow.id, + ); + + if (apps.length === 0) { + throw new AppNotFoundError(); + } + + 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, + }, + 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) { + const org = await db.org.getById(app.orgId); + const deployment = await db.deployment.getFromWorkflowRunId( + 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 + // If the deployment was canceled, its check run will be updated to canceled + continue; + } + if (payload.workflow_run.conclusion !== "success") { + // No need to build for unsuccessful workflow run + log( + deployment.id, + "BUILD", + "Workflow run did not complete successfully", + ); + if (!deployment.checkRunId) { + continue; + } + const octokit = await getOctokit(org.githubInstallationId); + try { + await octokit.rest.checks.update({ + check_run_id: deployment.checkRunId, + owner: payload.repository.owner.login, + repo: payload.repository.name, + status: "completed", + conclusion: "cancelled", + }); + log( + deployment.id, + "BUILD", + "Updated GitHub check run to Completed with conclusion Cancelled", + ); + await db.deployment.setStatus(deployment.id, "CANCELLED"); + } catch (e) {} + 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", + }); + log( + deployment.id, + "BUILD", + "Updated GitHub check run to Completed with conclusion Cancelled", + ); + } + } +} + +export async function log( + deploymentId: number, + type: LogType, + content: string, + stream: LogStream = "stdout", +) { + try { + await db.deployment.insertLogs([ + { + deploymentId, + content, + type, + stream, + podName: undefined, + timestamp: new Date(), + }, + ]); + } catch { + // Don't let errors bubble up and disrupt the deployment process + } +} diff --git a/backend/src/service/importGitRepo.ts b/backend/src/service/importGitRepo.ts new file mode 100644 index 00000000..7f89268a --- /dev/null +++ b/backend/src/service/importGitRepo.ts @@ -0,0 +1,113 @@ +import { db, NotFoundError } from "../db/index.ts"; +import { getLocalRepo, importRepo } from "../lib/import.ts"; +import { getOctokit } from "../lib/octokit.ts"; +import { + InstallationNotFoundError, + OrgNotFoundError, +} from "./common/errors.ts"; + +export async function createRepoImportState( + orgId: number, + userId: number, + { + sourceURL, + destOwner, + destIsOrg, + destRepo, + makePrivate, + }: { + sourceURL: string; + destOwner: string; + destIsOrg: boolean; + destRepo: string; + makePrivate: boolean; + }, +): Promise< + | { codeNeeded: true; oauthState: string } + | { codeNeeded: false; orgId: number; repoId: number } +> { + const org = await db.org.getById(orgId, { + requireUser: { id: userId, permissionLevel: "OWNER" }, + }); + + if (!org) { + throw new OrgNotFoundError(null); + } + + if (!org.githubInstallationId) { + throw new InstallationNotFoundError(null); + } + + const stateId = await db.repoImportState.create( + userId, + org.id, + destIsOrg, + destOwner, + destRepo, + makePrivate, + sourceURL, + ); + + const octokit = await getOctokit(org.githubInstallationId); + const isLocalRepo = !!(await getLocalRepo(octokit, URL.parse(sourceURL))); + + if (destIsOrg || isLocalRepo) { + // We can create the repo now + // Fall into the importGitRepo handler directly + return await importGitRepo(stateId, undefined, userId); + } else { + // We need a user access token + return { + codeNeeded: true as const, + oauthState: stateId, + }; + } +} + +export async function importGitRepo( + stateId: string, + code: string | undefined, + userId: number, +): Promise< + | { codeNeeded: true; oauthState: string } + | { codeNeeded: false; orgId: number; repoId: number } +> { + const state = await db.repoImportState.get(stateId, userId); + + if (!state) { + throw new NotFoundError("repoImportState"); + } + + const org = await db.org.getById(state.orgId); + + const repoId = await importRepo( + org.githubInstallationId, + URL.parse(state.srcRepoURL), + state.destIsOrg, + state.destRepoOwner, + state.destRepoName, + state.makePrivate, + code, + ); + + if (repoId === "code needed") { + // There was a problem creating the repo directly from a template and we didn't provide an OAuth code to authorize the user. + // We need to start over. + return { + codeNeeded: true, + oauthState: state.id, + }; + } + + await db.repoImportState.delete(state.id); + + // The repository was created successfully. If repoId is null, then + // we're not 100% sure that it was created, but no errors were thrown. + // It's probably just a big repository that will be created soon. + + return { + codeNeeded: false, + orgId: state.orgId, + repoId, + }; +} diff --git a/backend/src/service/ingestLogs.ts b/backend/src/service/ingestLogs.ts new file mode 100644 index 00000000..b8819004 --- /dev/null +++ b/backend/src/service/ingestLogs.ts @@ -0,0 +1,46 @@ +import { db } from "../db/index.ts"; +import type { LogType } from "../generated/prisma/enums.ts"; +import type { LogUncheckedCreateInput } from "../generated/prisma/models.ts"; +import { DeploymentNotFoundError, ValidationError } from "./common/errors.ts"; + +type LogLineInput = { + content: string; + stream: "stdout" | "stderr"; + timestamp: number; +}; + +export async function ingestLogs( + deploymentId: number, + token: string, + podName: string, + logType: LogType, + lines: LogLineInput[], +) { + // Authorize the request + const result = await db.deployment.checkLogIngestSecret(deploymentId, token); + if (!result) { + throw new DeploymentNotFoundError(); + } + + // Append the logs to the DB + if (!logType) { + // Should never happen + throw new ValidationError("Missing log type."); + } + + const logLines = lines + .map((line, i) => { + return { + content: line.content, + deploymentId: deploymentId, + type: logType, + timestamp: new Date(line.timestamp), + index: i, + podName: podName, + stream: line.stream, + } satisfies LogUncheckedCreateInput; + }) + .filter((it) => it !== null); + + await db.deployment.insertLogs(logLines); +} diff --git a/backend/src/service/inviteUser.ts b/backend/src/service/inviteUser.ts new file mode 100644 index 00000000..f66e775b --- /dev/null +++ b/backend/src/service/inviteUser.ts @@ -0,0 +1,34 @@ +import { ConflictError, db, NotFoundError } from "../db/index.ts"; +import { + OrgNotFoundError, + UserNotFoundError, + ValidationError, +} from "./common/errors.ts"; + +export async function inviteUser( + inviterId: number, + orgId: number, + inviteeEmail: string, +) { + const otherUser = await db.user.getByEmail(inviteeEmail); + + if (otherUser === null) { + throw new UserNotFoundError(); + } + + if (otherUser.id === inviterId) { + throw new ValidationError("You cannot send an invitation to yourself."); + } + + try { + await db.invitation.send(orgId, inviterId, otherUser.id); + } catch (e: any) { + if (e instanceof NotFoundError && e.message === "organization") { + throw new OrgNotFoundError(null); + } + if (e instanceof ConflictError && e.message === "user") { + throw new ConflictError("user"); + } + throw e; + } +} diff --git a/backend/src/service/isSubdomainAvailable.ts b/backend/src/service/isSubdomainAvailable.ts new file mode 100644 index 00000000..6877b8da --- /dev/null +++ b/backend/src/service/isSubdomainAvailable.ts @@ -0,0 +1,14 @@ +import { db } from "../db/index.ts"; +import { ValidationError } from "./common/errors.ts"; + +export async function isSubdomainAvailable(subdomain: string) { + if ( + subdomain.length > 54 || + subdomain.match(/^[a-z0-9](?:[a-z0-9\-]*[a-z0-9])?$/) === null + ) { + throw new ValidationError("Invalid subdomain."); + } + + const subdomainUsedByApp = await db.app.isSubdomainInUse(subdomain); + return !subdomainUsedByApp; +} diff --git a/backend/src/service/listDeployments.ts b/backend/src/service/listDeployments.ts new file mode 100644 index 00000000..a04c1c0a --- /dev/null +++ b/backend/src/service/listDeployments.ts @@ -0,0 +1,91 @@ +import type { Octokit } from "octokit"; +import { db } from "../db/index.ts"; +import type { DeploymentWithSourceInfo } from "../db/models.ts"; +import type { components } from "../generated/openapi.ts"; +import { getOctokit, getRepoById } from "../lib/octokit.ts"; +import { AppNotFoundError, ValidationError } from "./common/errors.ts"; + +export async function listDeployments( + appId: number, + userId: number, + page: number, + pageLength: number, +) { + if ( + page < 0 || + pageLength <= 0 || + !Number.isInteger(page) || + !Number.isInteger(pageLength) + ) { + throw new ValidationError("Invalid page or page length."); + } + + const app = await db.app.getById(appId, { + requireUser: { id: userId }, + }); + + if (!app) { + throw new AppNotFoundError(); + } + + const org = await db.org.getById(app.orgId); + + const deployments = await db.deployment.listForApp(app.id, page, pageLength); + + const distinctRepoIDs = [ + ...new Set(deployments.map((it) => it.repositoryId).filter(Boolean)), + ]; + let octokit: Octokit; + if (distinctRepoIDs.length > 0 && org.githubInstallationId) { + octokit = await getOctokit(org.githubInstallationId); + } + const repos = await Promise.all( + distinctRepoIDs.map(async (id) => { + if (id) { + try { + return octokit ? await getRepoById(octokit, id) : null; + } catch (error) { + if (error?.status === 404) { + // The repo couldn't be found. Either it doesn't exist or the installation doesn't have permission to see it. + return undefined; + } + throw error; // Rethrow any other kind of error + } + } + return undefined; + }), + ); + + const modifiedDeployments = deployments as Array< + Omit & { + status: components["schemas"]["AppSummary"]["status"]; + } + >; + + let sawSuccess = false; + for (const deployment of modifiedDeployments) { + if (deployment.status === "COMPLETE") { + if (!sawSuccess) { + sawSuccess = true; + } else { + deployment.status = "STOPPED"; + } + } + } + + return modifiedDeployments.map((deployment) => { + return { + id: deployment.id, + appId: deployment.appId, + repositoryURL: + repos[distinctRepoIDs.indexOf(deployment.repositoryId)]?.html_url, + commitHash: deployment.commitHash, + commitMessage: deployment.commitMessage, + status: deployment.status, + createdAt: deployment.createdAt.toISOString(), + updatedAt: deployment.updatedAt.toISOString(), + source: deployment.source, + imageTag: deployment.imageTag, + }; + }); +} diff --git a/backend/src/service/listOrgGroups.ts b/backend/src/service/listOrgGroups.ts new file mode 100644 index 00000000..cb9c5e54 --- /dev/null +++ b/backend/src/service/listOrgGroups.ts @@ -0,0 +1,18 @@ +import { db } from "../db/index.ts"; +import { OrgNotFoundError } from "./common/errors.ts"; + +export async function listOrgGroups(orgId: number, userId: number) { + const [org, appGroups] = await Promise.all([ + db.org.getById(orgId, { requireUser: { id: userId } }), + db.appGroup.listForOrg(orgId), + ]); + + if (org === null) { + throw new OrgNotFoundError(null); + } + + return appGroups.map((group) => ({ + id: group.id, + name: group.name, + })); +} diff --git a/backend/src/service/listOrgRepos.ts b/backend/src/service/listOrgRepos.ts new file mode 100644 index 00000000..8372b4aa --- /dev/null +++ b/backend/src/service/listOrgRepos.ts @@ -0,0 +1,29 @@ +import { db } from "../db/index.ts"; +import { getOctokit } from "../lib/octokit.ts"; +import { + InstallationNotFoundError, + OrgNotFoundError, +} from "./common/errors.ts"; + +export async function listOrgRepos(orgId: number, userId: number) { + const org = await db.org.getById(orgId, { + requireUser: { id: userId }, + }); + + if (!org) { + throw new OrgNotFoundError(null); + } + + if (org.githubInstallationId === null) { + throw new InstallationNotFoundError(null); + } + + const octokit = await getOctokit(org.githubInstallationId); + const repos = await octokit.rest.apps.listReposAccessibleToInstallation(); + + return repos.data.repositories?.map((repo) => ({ + id: repo.id, + owner: repo.owner.login, + name: repo.name, + })); +} diff --git a/backend/src/service/listRepoBranches.ts b/backend/src/service/listRepoBranches.ts new file mode 100644 index 00000000..59c7ac09 --- /dev/null +++ b/backend/src/service/listRepoBranches.ts @@ -0,0 +1,46 @@ +import { RequestError } from "octokit"; +import { db } from "../db/index.ts"; +import { getOctokit, getRepoById } from "../lib/octokit.ts"; +import { + InstallationNotFoundError, + OrgNotFoundError, + RepositoryNotFoundError, +} from "./common/errors.ts"; + +export async function listRepoBranches( + orgId: number, + userId: number, + repoId: number, +) { + const org = await db.org.getById(orgId, { + requireUser: { id: userId }, + }); + + if (!org) { + throw new OrgNotFoundError(null); + } + + if (org.githubInstallationId === null) { + throw new InstallationNotFoundError(null); + } + + try { + const octokit = await getOctokit(org.githubInstallationId); + const repo = await getRepoById(octokit, repoId); + const branches = await octokit.rest.repos.listBranches({ + owner: repo.owner.login, + repo: repo.name, + }); + + return { + default: repo.default_branch, + branches: branches.data.map((branch) => branch.name), + }; + } catch (e) { + if (e instanceof RequestError && e.status == 404) { + throw new RepositoryNotFoundError(); + } + + throw e; + } +} diff --git a/backend/src/service/listRepoWorkflows.ts b/backend/src/service/listRepoWorkflows.ts new file mode 100644 index 00000000..1388de0c --- /dev/null +++ b/backend/src/service/listRepoWorkflows.ts @@ -0,0 +1,49 @@ +import { RequestError } from "octokit"; +import { db } from "../db/index.ts"; +import { getOctokit } from "../lib/octokit.ts"; +import { + InstallationNotFoundError, + OrgNotFoundError, + RepositoryNotFoundError, +} from "./common/errors.ts"; + +export async function listRepoWorkflows( + orgId: number, + userId: number, + repoId: number, +) { + const org = await db.org.getById(orgId, { + requireUser: { id: userId }, + }); + + if (!org) { + throw new OrgNotFoundError(null); + } + + if (org.githubInstallationId == null) { + throw new InstallationNotFoundError(null); + } + + try { + const octokit = await getOctokit(org.githubInstallationId); + const workflows = (await octokit + .request({ + method: "GET", + url: `/repositories/${repoId}/actions/workflows`, + }) + .then((res) => res.data.workflows)) as Awaited< + ReturnType + >["data"][]; + return workflows.map((workflow) => ({ + id: workflow.id, + name: workflow.name, + path: workflow.path, + })); + } catch (e) { + if (e instanceof RequestError && e.status === 404) { + throw new RepositoryNotFoundError(); + } + + throw e; + } +} diff --git a/backend/src/service/removeUserFromOrg.ts b/backend/src/service/removeUserFromOrg.ts new file mode 100644 index 00000000..0e2d1ed0 --- /dev/null +++ b/backend/src/service/removeUserFromOrg.ts @@ -0,0 +1,26 @@ +import { db, NotFoundError } from "../db/index.ts"; +import { OrgNotFoundError, UserNotFoundError } from "./common/errors.ts"; + +export async function removeUserFromOrg( + orgId: number, + actorId: number, + userId: number, +) { + const org = await db.org.getById(orgId, { + requireUser: { id: actorId, permissionLevel: "OWNER" }, + }); + + if (!org) { + throw new OrgNotFoundError(null); + } + + try { + await db.org.removeMember(orgId, userId); + } catch (e) { + if (e instanceof NotFoundError) { + throw new UserNotFoundError(); + } + + throw e; + } +} diff --git a/backend/src/service/revokeInvitation.ts b/backend/src/service/revokeInvitation.ts new file mode 100644 index 00000000..cb6d0937 --- /dev/null +++ b/backend/src/service/revokeInvitation.ts @@ -0,0 +1,17 @@ +import { db, NotFoundError } from "../db/index.ts"; +import { InvitationNotFoundError } from "./common/errors.ts"; + +export async function revokeInvitation( + orgId: number, + userId: number, + invitationId: number, +) { + try { + await db.invitation.revoke(orgId, invitationId, userId); + } catch (e) { + if (e instanceof NotFoundError) { + throw new InvitationNotFoundError(e); + } + throw e; + } +} diff --git a/backend/src/service/setAppCD.ts b/backend/src/service/setAppCD.ts new file mode 100644 index 00000000..7250fa94 --- /dev/null +++ b/backend/src/service/setAppCD.ts @@ -0,0 +1,18 @@ +import { db } from "../db/index.ts"; +import { AppNotFoundError } from "./common/errors.ts"; + +export async function setAppCD( + appId: number, + userId: number, + cdEnabled: boolean, +) { + const app = await db.app.getById(appId, { + requireUser: { id: userId }, + }); + + if (!app) { + throw new AppNotFoundError(); + } + + await db.app.setEnableCD(appId, cdEnabled); +} diff --git a/backend/src/service/updateApp.ts b/backend/src/service/updateApp.ts new file mode 100644 index 00000000..08727917 --- /dev/null +++ b/backend/src/service/updateApp.ts @@ -0,0 +1,262 @@ +import { randomBytes } from "node:crypto"; +import { db, NotFoundError } from "../db/index.ts"; +import type { DeploymentConfigCreate } 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"; +import { + AppNotFoundError, + DeploymentError, + ValidationError, +} from "./common/errors.ts"; + +export type AppUpdate = components["schemas"]["AppUpdate"]; + +export async function updateApp( + appId: number, + userId: number, + appData: AppUpdate, +) { + // ---------------- Input validation ---------------- + + const originalApp = await db.app.getById(appId, { + requireUser: { id: userId }, + }); + + if (!originalApp) { + throw new AppNotFoundError(); + } + + try { + await validateDeploymentConfig(appData.config); + if (appData.appGroup) { + validateAppGroup(appData.appGroup); + } + } catch (e) { + throw new ValidationError(e.message, { cause: e }); + } + + if (appData.projectId) { + const user = await db.user.getById(userId); + if (!(await canManageProject(user.clusterUsername, appData.projectId))) { + throw new ValidationError("Project not found"); + } + } + + // ---------------- 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"); + } + } + } + } 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", + ); + + await db.app.setGroup(originalApp.id, newGroupId); + } + + // ---------------- App model updates ---------------- + + const updates = {} as Record; + if (appData.name !== undefined) { + updates.displayName = appData.name; + } + + if (appData.projectId !== undefined) { + updates.projectId = appData.projectId; + } + + if (appData.enableCD !== undefined) { + updates.enableCD = appData.enableCD; + } + + if (Object.keys(updates).length > 0) { + 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), + 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 ---------------- + + 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) + ) { + // 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, + }); + + // 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, + }, + status: "DEPLOYING", + appId: originalApp.id, + commitMessage: currentDeployment.commitMessage, + }); + + const config = await db.deployment.getConfig(deployment.id); + + try { + const { namespace, configs, postCreate } = + await createAppConfigsFromDeployment( + org, + app, + appGroup, + deployment, + config, + ); + + const { KubernetesObjectApi: api } = await getClientsForRequest( + userId, + app.projectId, + ["KubernetesObjectApi"], + ); + await createOrUpdateApp(api, app.name, namespace, configs, postCreate); + + 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", + ); + } + } +} + +// Patch the null(hidden) values of env vars sent from client with the sensitive plaintext +export const withSensitiveEnv = ( + lastPlaintextEnv: PrismaJson.EnvVar[], + envVars: { + name: string; + value: string | null; + isSensitive: boolean; + }[], +) => { + const lastEnvMap = + lastPlaintextEnv?.reduce((map, env) => { + return Object.assign(map, { [env.name]: env.value }); + }, {}) ?? {}; + return envVars.map((env) => + env.value === null + ? { + name: env.name, + value: lastEnvMap[env.name], + isSensitive: env.isSensitive, + } + : env, + ); +}; diff --git a/backend/src/service/updateDeployment.ts b/backend/src/service/updateDeployment.ts new file mode 100644 index 00000000..6025644b --- /dev/null +++ b/backend/src/service/updateDeployment.ts @@ -0,0 +1,112 @@ +import { db } from "../db/index.ts"; +import { dequeueBuildJob } 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 { getOctokit, getRepoById } from "../lib/octokit.ts"; +import { DeploymentNotFoundError, ValidationError } from "./common/errors.ts"; +import { log } from "./githubWebhook.ts"; + +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(); + } + + await db.deployment.setStatus( + deployment.id, + newStatus as "BUILDING" | "DEPLOYING" | "ERROR", + ); + + log( + deployment.id, + "BUILD", + "Deployment status has been updated to " + newStatus, + ); + + const app = await db.app.getById(deployment.appId); + const [appGroup, config, org] = await Promise.all([ + db.appGroup.getById(app.appGroupId), + db.deployment.getConfig(deployment.id), + db.org.getById(app.orgId), + ]); + + if ( + (newStatus === "DEPLOYING" || newStatus === "ERROR") && + deployment.checkRunId !== null + ) { + try { + // The build completed. Update the check run with the result of the build (success or failure). + const octokit = await getOctokit(org.githubInstallationId); + + // Get the repo's name and owner from its ID, just in case the name or owner changed in the middle of the deployment + const repo = await getRepoById(octokit, config.repositoryId); + + await octokit.rest.checks.update({ + check_run_id: deployment.checkRunId, + status: "completed", + conclusion: newStatus === "DEPLOYING" ? "success" : "failure", + owner: repo.owner.login, + repo: repo.name, + }); + log( + deployment.id, + "BUILD", + "Updated GitHub check run to Completed with conclusion " + + (newStatus === "DEPLOYING" ? "Success" : "Failure"), + ); + } catch (e) { + console.error("Failed to update check run: ", e); + } + } + + if (newStatus === "DEPLOYING") { + const { namespace, configs, postCreate } = + await createAppConfigsFromDeployment( + org, + app, + appGroup, + deployment, + config, + ); + + try { + const api = getClientForClusterUsername( + app.clusterUsername, + "KubernetesObjectApi", + shouldImpersonate(app.projectId), + ); + + await createOrUpdateApp(api, app.name, namespace, configs, postCreate); + log(deployment.id, "BUILD", "Deployment succeeded"); + + 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), + ]); + + dequeueBuildJob(); // TODO - error handling for this line + } catch (err) { + console.error(err); + await db.deployment.setStatus(deployment.id, "ERROR"); + await log( + deployment.id, + "BUILD", + `Failed to apply Kubernetes resources: ${JSON.stringify(err?.body ?? err)}`, + "stderr", + ); + } + } +} diff --git a/backend/test/fixtures/user.ts b/backend/test/fixtures/user.ts new file mode 100644 index 00000000..98ec472f --- /dev/null +++ b/backend/test/fixtures/user.ts @@ -0,0 +1,25 @@ +import { randomUUID } from "node:crypto"; +import { db } from "../../src/db/index.ts"; + +let userCounter = 0; +let nsCounter = 0; + +const prefix = new Date().getTime().toString() + "-"; + +export async function getTestUser() { + const user = await db.user.getByEmail("user@anvilops.local"); + if (!user) { + const name = `${prefix}user-${userCounter++}`; + return await db.user.createUserWithPersonalOrg( + `${name}@anvilops.local`, + name, + randomUUID(), + null, + ); + } + return user; +} + +export function getTestNamespace() { + return `${prefix}ns-${nsCounter++}`; +} diff --git a/backend/test/globalSetup.ts b/backend/test/globalSetup.ts new file mode 100644 index 00000000..953ac992 --- /dev/null +++ b/backend/test/globalSetup.ts @@ -0,0 +1,29 @@ +import { spawn } from "node:child_process"; +import type { TestProject } from "vitest/node"; +import { server } from "../src/index.ts"; // Start up the AnvilOps server + +export async function setup(project: TestProject) { + project.onTestsRerun(async () => { + await resetDatabase(); + }); + await resetDatabase(); +} + +async function resetDatabase() { + await new Promise((resolve, reject) => { + const subprocess = spawn("npx", ["prisma", "migrate", "reset", "--force"], { + stdio: "inherit", + }); + subprocess.on("exit", (code) => { + if (code === 0) { + resolve(); + } else { + reject(); + } + }); + }); +} + +export function teardown(project: TestProject) { + server.close(); +} diff --git a/backend/test/lib/auth.test.ts b/backend/test/lib/auth.test.ts new file mode 100644 index 00000000..a805f26d --- /dev/null +++ b/backend/test/lib/auth.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "vitest"; +import { db } from "../../src/db/index.ts"; +import { getUser } from "../../src/service/getUser.ts"; + +test("createUser", async (c) => { + const newUser = await db.user.createUserWithPersonalOrg( + "create-user@anvilops.local", + "full name", + "cilogonUserId", + "clusterUsername", + ); + const user = await getUser(newUser.id); + + expect(user.orgs).toEqual([ + { + id: 1, + name: "full name's Apps", + permissionLevel: "OWNER", + githubConnected: false, + }, + ]); + + expect(user.email).toEqual("create-user@anvilops.local"); + expect(user.name).toBe("full name"); +}); diff --git a/backend/test/service/createApp.test.ts b/backend/test/service/createApp.test.ts new file mode 100644 index 00000000..70c03e51 --- /dev/null +++ b/backend/test/service/createApp.test.ts @@ -0,0 +1,221 @@ +import { + ApiException, + AppsV1Api, + BatchV1Api, + KubeConfig, + type ApiConstructor, + type KubernetesObject, +} from "@kubernetes/client-node"; +import { setTimeout } from "node:timers/promises"; +import { describe, expect, test, vi } from "vitest"; +import { db } from "../../src/db/index.ts"; +import type { User } from "../../src/db/models.ts"; +import { DeploymentStatus } from "../../src/generated/prisma/enums.ts"; +import { getNamespace } from "../../src/lib/cluster/resources.ts"; +import { + createApp, + validateAppConfig, + type NewApp, +} from "../../src/service/createApp.ts"; +import { deleteOrgByID } from "../../src/service/deleteOrgByID.ts"; +import { getAppByID } from "../../src/service/getAppByID.ts"; +import { getTestNamespace, getTestUser } from "../fixtures/user.ts"; + +const kc = new KubeConfig(); +kc.loadFromDefault(); + +async function waitForCreate( + clientType: ApiConstructor, + check: (client: C) => Promise, +): Promise { + let last404Error: ApiException; + const client = kc.makeApiClient(clientType); + for (let i = 0; i < 20; i++) { + try { + const response = await check(client); + if ( + response.kind?.endsWith("List") && + "items" in response && + Array.isArray(response.items) && + response.items.length === 0 + ) { + // For list requests, treat an empty list as a "not found" + throw new ApiException(404, "No items found in response", response, {}); + } + return response; + } catch (e) { + if (e instanceof ApiException && e.code === 404) { + // Not found; continue waiting + last404Error = e; + await setTimeout(500); + } else { + throw e; + } + } + } + + throw new Error("Timed out waiting for resource", { cause: last404Error }); +} + +async function waitForStatusCode(url: string, code: number) { + let lastError: Error; + for (let i = 0; i < 20; i++) { + try { + const response = await fetch(url); + if (response.status === code) { + return response; + } + } catch (e) { + lastError = e; + } + await setTimeout(500); + } + throw new Error("Timed out waiting for HTTP status", { cause: lastError }); +} + +describe("createApp", async (c) => { + let user: User, orgId: number; + + c.beforeEach(async () => { + user = await getTestUser(); + orgId = (await db.user.getOrgs(user.id))[0].organization.id; + }); + + c.afterEach(async () => { + const orgs = await db.user.getOrgs(user.id); + for (const entry of orgs) { + await deleteOrgByID(entry.organization.id, user.id); + } + await db.user.deleteById(user.id); + }); + + const create = async (config: NewApp) => + createApp(config, await validateAppConfig(user.id, config)); + + test("from existing Docker image", async (c) => { + const config = { + appGroup: { type: "standalone" }, + source: "image", + imageTag: process.env.TEST_ANVILOPS_SAMPLE_IMAGE, + cpuCores: 1, + memoryInMiB: 512, + createIngress: true, + env: [], + mounts: [], + name: "test-app", + orgId, + port: 8080, + subdomain: getTestNamespace(), + } satisfies NewApp; + + const appId = await create(config); + const app = await getAppByID(appId, user.id); + const ns = getNamespace(app.namespace); + + const sts = await waitForCreate(AppsV1Api, (c) => + c.readNamespacedStatefulSet({ + namespace: ns, + name: app.name, + }), + ); + + const deployment = await db.app.getMostRecentDeployment(app.id); + + expect(deployment.status).toEqual(DeploymentStatus.COMPLETE); + + expect(sts.spec.template.metadata.labels).toEqual({ + "anvilops.rcac.purdue.edu/app-group-id": app.appGroup.id.toString(), + "anvilops.rcac.purdue.edu/app-id": app.id.toString(), + "anvilops.rcac.purdue.edu/deployment-id": deployment.id.toString(), + "app.kubernetes.io/managed-by": "anvilops", + "app.kubernetes.io/name": app.name, + "app.kubernetes.io/part-of": `${app.appGroup.name}-${app.appGroup.id}-${app.orgId}`, + app: app.name, + }); + + expect(sts.spec.template.spec.containers[0].image).toEqual(config.imageTag); + + const clusterInternalURL = `http://${ns}.${ns}.svc.cluster.local`; + + const response = await waitForStatusCode(clusterInternalURL, 200); + expect(await response.text()).toEqual("Hello, world!\n"); + }, /* timeout = */ 60_000); + + test("from Dockerfile", async () => { + vi.mock(import("../../src/lib/octokit.ts"), () => ({ + getOctokit: () => Promise.resolve(undefined), + getRepoById: () => + Promise.resolve({ + id: -1, + owner: { login: "anvilops-user" }, + name: "sample", + } as any), + getLatestCommit: () => + Promise.resolve({ + sha: "main", // Normally this is a commit hash, but Git will accept any ref when cloning the repo + commit: { message: "Initial commit" }, + } as any), + generateCloneURLWithCredentials: () => + Promise.resolve( + "http://test-file-server.default.svc.cluster.local/git/sample.git", + ), + })); + // Pretend that the GitHub App is installed + await db.org.setInstallationId(orgId, -1); + + const config = { + appGroup: { type: "standalone" }, + cpuCores: 1, + memoryInMiB: 512, + createIngress: true, + env: [], + mounts: [], + name: "test-app", + orgId, + port: 8080, + subdomain: getTestNamespace(), + // Git-specific options + source: "git", + repositoryId: -1, + rootDir: "./", + event: "push", + eventId: undefined, + // Dockerfile-specific options + builder: "dockerfile", + dockerfilePath: "./Dockerfile", + } satisfies NewApp; + + const appId = await create(config); + const app = await getAppByID(appId, user.id); + const ns = getNamespace(app.namespace); + let deployment = await db.app.getMostRecentDeployment(app.id); + + const buildJob = await waitForCreate(BatchV1Api, (c) => + c.listNamespacedJob({ + namespace: "default", + labelSelector: `anvilops.rcac.purdue.edu/deployment-id=${deployment.id}`, + }), + ); + + console.log(buildJob); + + const sts = await waitForCreate(AppsV1Api, (c) => + c.readNamespacedStatefulSet({ + namespace: ns, + name: app.name, + }), + ); + + deployment = await db.app.getMostRecentDeployment(app.id); // Get the deployment's new status + expect(deployment.status).toEqual(DeploymentStatus.COMPLETE); + + const clusterInternalURL = `http://${ns}.${ns}.svc.cluster.local`; + + const response = await waitForStatusCode(clusterInternalURL, 200); + expect(await response.text()).toEqual("Hello, world!\n"); + }, /* timeout = */ 120_000); + + // test("from Railpack", () => { + // assert.fail("Not implemented yet"); + // }); +}); diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts new file mode 100644 index 00000000..1628ff99 --- /dev/null +++ b/backend/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globalSetup: ["test/globalSetup.ts"], + }, +}); diff --git a/charts/anvilops/templates/anvilops/anvilops-deployment.yaml b/charts/anvilops/templates/anvilops/anvilops-deployment.yaml index e75af78d..bd59524a 100644 --- a/charts/anvilops/templates/anvilops/anvilops-deployment.yaml +++ b/charts/anvilops/templates/anvilops/anvilops-deployment.yaml @@ -38,6 +38,15 @@ spec: {{- with .Values.anvilops.securityContext }} securityContext: {{- toYaml . | nindent 12 }} + {{- else -}} + securityContext: + capabilities: + drop: [ALL] + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false {{- end }} image: {{ .Values.anvilops.image | quote }} imagePullPolicy: {{ .Values.anvilops.imagePullPolicy }} @@ -233,14 +242,6 @@ spec: - name: REGISTRY_PROTOCOL value: {{ . }} {{- end }} - securityContext: - capabilities: - drop: [ALL] - runAsNonRoot: true - runAsUser: 65532 - runAsGroup: 65532 - readOnlyRootFilesystem: true - allowPrivilegeEscalation: false resources: requests: cpu: 512m diff --git a/charts/anvilops/values.yaml b/charts/anvilops/values.yaml index 0fb2f8c0..6095fc48 100644 --- a/charts/anvilops/values.yaml +++ b/charts/anvilops/values.yaml @@ -26,10 +26,10 @@ anvilops: nameOverride: "" fullnameOverride: "" - securityContext: - runAsUser: 1001 - runAsGroup: 1001 - runAsNonRoot: true + #securityContext: + # runAsUser: 1001 + # runAsGroup: 1001 + # runAsNonRoot: true # This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ serviceAccount: diff --git a/frontend/src/components/ImportRepoDialog.tsx b/frontend/src/components/ImportRepoDialog.tsx index faf1967d..eb7e7ebb 100644 --- a/frontend/src/components/ImportRepoDialog.tsx +++ b/frontend/src/components/ImportRepoDialog.tsx @@ -1,6 +1,9 @@ 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"; @@ -15,9 +18,6 @@ import { SelectTrigger, SelectValue, } from "./ui/select"; -import type { AppInfoFormData } from "@/pages/create-app/AppConfigFormFields"; -import { toast } from "sonner"; -import { FormContext } from "@/pages/create-app/CreateAppView"; export const ImportRepoDialog = ({ orgId, @@ -108,11 +108,11 @@ export const ImportRepoDialog = ({ }, params: { path: { orgId } }, }); - if (result.url) { + if ("url" in result) { window.location.href = result.url; } else if ("repoId" in result) { // We were able to create the repo immediately without creating a popup for GitHub authorization - const repoId = result.repoId as number; + const repoId = result.repoId; await refresh(); // Set the repo after the