From cfd5a8bbe28d9eafe886c4f60c56ae9438d6c9d1 Mon Sep 17 00:00:00 2001 From: zheng861 Date: Sun, 26 Oct 2025 10:47:30 -0400 Subject: [PATCH 01/38] Create postgres chart --- .prettierignore | 2 + .../src/lib/cluster/resources/extension.ts | 29 ++++++++ templates/extensions/ha-postgres/Chart.yaml | 5 ++ .../extensions/ha-postgres/description.txt | 1 + .../ha-postgres/templates/_helpers.tpl | 12 ++++ .../ha-postgres/templates/endpoints.yaml | 7 ++ .../ha-postgres/templates/role.yaml | 14 ++++ .../ha-postgres/templates/rolebinding.yaml | 11 +++ .../ha-postgres/templates/secret.yaml | 11 +++ .../ha-postgres/templates/service-config.yaml | 8 +++ .../ha-postgres/templates/service-read.yaml | 16 +++++ .../ha-postgres/templates/service-write.yaml | 12 ++++ .../ha-postgres/templates/serviceaccount.yaml | 4 ++ .../ha-postgres/templates/statefulset.yaml | 72 +++++++++++++++++++ templates/extensions/ha-postgres/values.yaml | 14 ++++ 15 files changed, 218 insertions(+) create mode 100644 backend/src/lib/cluster/resources/extension.ts create mode 100644 templates/extensions/ha-postgres/Chart.yaml create mode 100644 templates/extensions/ha-postgres/description.txt create mode 100644 templates/extensions/ha-postgres/templates/_helpers.tpl create mode 100644 templates/extensions/ha-postgres/templates/endpoints.yaml create mode 100644 templates/extensions/ha-postgres/templates/role.yaml create mode 100644 templates/extensions/ha-postgres/templates/rolebinding.yaml create mode 100644 templates/extensions/ha-postgres/templates/secret.yaml create mode 100644 templates/extensions/ha-postgres/templates/service-config.yaml create mode 100644 templates/extensions/ha-postgres/templates/service-read.yaml create mode 100644 templates/extensions/ha-postgres/templates/service-write.yaml create mode 100644 templates/extensions/ha-postgres/templates/serviceaccount.yaml create mode 100644 templates/extensions/ha-postgres/templates/statefulset.yaml create mode 100644 templates/extensions/ha-postgres/values.yaml diff --git a/.prettierignore b/.prettierignore index ba93199d..d2530ae3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,6 @@ # Ignore artifacts: charts/**/*.yaml !charts/**/values.yaml +templates/extensions/**/*.yaml +!templates/extensions/**/values.yaml # ^ Prettier doesn't know how to interpret Go templates so it thinks we're writing invalid YAML \ No newline at end of file diff --git a/backend/src/lib/cluster/resources/extension.ts b/backend/src/lib/cluster/resources/extension.ts new file mode 100644 index 00000000..e7de4436 --- /dev/null +++ b/backend/src/lib/cluster/resources/extension.ts @@ -0,0 +1,29 @@ +import { spawn } from "child_process"; +import fs from "fs"; + +const extensions = new Set(fs.readdirSync("/templates/extensions")); + +const runHelm = ({ chartPath, namespace, kv, release }) => { + const args = [ + "upgrade", + "--install", + release, + chartPath, + "--namespace", + namespace, + "--set", + kv.join(","), + ]; + return new Promise((resolve, reject) => { + const p = spawn("helm", args, { stdio: ["ignore", "pipe", "pipe"] }); + let out = "", + err = ""; + p.stdout.on("data", (d) => (out += d)); + p.stderr.on("data", (d) => (err += d)); + p.on("close", (code) => + code === 0 + ? resolve({ out }) + : reject(new Error(err || `helm exit ${code}`)), + ); + }); +}; diff --git a/templates/extensions/ha-postgres/Chart.yaml b/templates/extensions/ha-postgres/Chart.yaml new file mode 100644 index 00000000..9a679823 --- /dev/null +++ b/templates/extensions/ha-postgres/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: spilo +description: Minimal Spilo/Patroni HA Postgres +type: application +version: 0.1.0 diff --git a/templates/extensions/ha-postgres/description.txt b/templates/extensions/ha-postgres/description.txt new file mode 100644 index 00000000..d2db6291 --- /dev/null +++ b/templates/extensions/ha-postgres/description.txt @@ -0,0 +1 @@ +A minimal chart for a high-availability PostgreSQL deployment using Spilo. User must have permission to create Roles and RoleBindings. \ No newline at end of file diff --git a/templates/extensions/ha-postgres/templates/_helpers.tpl b/templates/extensions/ha-postgres/templates/_helpers.tpl new file mode 100644 index 00000000..24cdd87a --- /dev/null +++ b/templates/extensions/ha-postgres/templates/_helpers.tpl @@ -0,0 +1,12 @@ +{{- define "spilo.name" -}} +{{- .Chart.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "spilo.fullname" -}} +{{- printf "%s" .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "spilo.labels" -}} +application: spilo +spilo-cluster: {{ include "spilo.fullname" . }} +{{- end -}} \ No newline at end of file diff --git a/templates/extensions/ha-postgres/templates/endpoints.yaml b/templates/extensions/ha-postgres/templates/endpoints.yaml new file mode 100644 index 00000000..befbf9d6 --- /dev/null +++ b/templates/extensions/ha-postgres/templates/endpoints.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Endpoints +metadata: + name: {{ include "spilo.fullname" . }} + labels: + {{- include "spilo.labels" . | nindent 4 }} +subsets: [] \ No newline at end of file diff --git a/templates/extensions/ha-postgres/templates/role.yaml b/templates/extensions/ha-postgres/templates/role.yaml new file mode 100644 index 00000000..385a774c --- /dev/null +++ b/templates/extensions/ha-postgres/templates/role.yaml @@ -0,0 +1,14 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: operator +rules: +- apiGroups: [""] + resources: ["configmaps"] + verbs: ["create","get","list","patch","update","watch","delete"] +- apiGroups: [""] + resources: ["endpoints"] + verbs: ["create","get","list","watch","patch","update","delete"] +- apiGroups: [""] + resources: ["pods"] + verbs: ["get","list","watch","patch","update"] \ No newline at end of file diff --git a/templates/extensions/ha-postgres/templates/rolebinding.yaml b/templates/extensions/ha-postgres/templates/rolebinding.yaml new file mode 100644 index 00000000..fa62bcae --- /dev/null +++ b/templates/extensions/ha-postgres/templates/rolebinding.yaml @@ -0,0 +1,11 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: operator +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: operator +subjects: +- kind: ServiceAccount + name: operator \ No newline at end of file diff --git a/templates/extensions/ha-postgres/templates/secret.yaml b/templates/extensions/ha-postgres/templates/secret.yaml new file mode 100644 index 00000000..5977d14c --- /dev/null +++ b/templates/extensions/ha-postgres/templates/secret.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "spilo.fullname" . }} + labels: + {{- include "spilo.labels" . | nindent 4 }} +type: Opaque +data: + superuser-password: {{ .Values.passwords.superuser | b64enc | quote }} + replication-password: {{ .Values.passwords.replication | b64enc | quote }} + admin-password: {{ .Values.passwords.admin | b64enc | quote }} diff --git a/templates/extensions/ha-postgres/templates/service-config.yaml b/templates/extensions/ha-postgres/templates/service-config.yaml new file mode 100644 index 00000000..ce5fbeaa --- /dev/null +++ b/templates/extensions/ha-postgres/templates/service-config.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "spilo.fullname" . }}-config + labels: + {{- include "spilo.labels" . | nindent 4 }} +spec: + clusterIP: None diff --git a/templates/extensions/ha-postgres/templates/service-read.yaml b/templates/extensions/ha-postgres/templates/service-read.yaml new file mode 100644 index 00000000..638c6fc7 --- /dev/null +++ b/templates/extensions/ha-postgres/templates/service-read.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "spilo.fullname" . }}-repl + labels: + {{- include "spilo.labels" . | nindent 4 }} + role: replica +spec: + type: ClusterIP + selector: + {{- include "spilo.labels" . | nindent 4 }} + role: replica + ports: + - name: postgres + port: 5432 + targetPort: 5432 \ No newline at end of file diff --git a/templates/extensions/ha-postgres/templates/service-write.yaml b/templates/extensions/ha-postgres/templates/service-write.yaml new file mode 100644 index 00000000..9102a856 --- /dev/null +++ b/templates/extensions/ha-postgres/templates/service-write.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "spilo.fullname" . }} + labels: + {{- include "spilo.labels" . | nindent 4 }} +spec: + type: ClusterIP + ports: + - name: postgresql + port: 5432 + targetPort: 5432 \ No newline at end of file diff --git a/templates/extensions/ha-postgres/templates/serviceaccount.yaml b/templates/extensions/ha-postgres/templates/serviceaccount.yaml new file mode 100644 index 00000000..5f5e99bd --- /dev/null +++ b/templates/extensions/ha-postgres/templates/serviceaccount.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: operator \ No newline at end of file diff --git a/templates/extensions/ha-postgres/templates/statefulset.yaml b/templates/extensions/ha-postgres/templates/statefulset.yaml new file mode 100644 index 00000000..fe59cda5 --- /dev/null +++ b/templates/extensions/ha-postgres/templates/statefulset.yaml @@ -0,0 +1,72 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "spilo.fullname" . }} + labels: + {{- include "spilo.labels" . | nindent 4 }} +spec: + selector: + matchLabels: + {{- include "spilo.labels" . | nindent 6 }} + replicas: {{ .Values.replicas }} + serviceName: {{ include "spilo.fullname" . }} + template: + metadata: + labels: + {{- include "spilo.labels" . | nindent 8 }} + spec: + serviceAccountName: operator + containers: + - name: spilo + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - { containerPort: 8008, protocol: TCP } + - { containerPort: 5432, protocol: TCP } + volumeMounts: + - name: pgdata + mountPath: /home/postgres/pgdata + env: + - name: DCS_ENABLE_KUBERNETES_API + value: 'true' + - name: KUBERNETES_SCOPE_LABEL + value: spilo-cluster + - name: KUBERNETES_ROLE_LABEL + value: role + - name: KUBERNETES_LEADER_LABEL_VALUE + value: master + - name: KUBERNETES_STANDBY_LEADER_LABEL_VALUE + value: master + - name: SPILO_CONFIGURATION + value: | + bootstrap: + initdb: + - auth-host: md5 + - auth-local: trust + - name: POD_IP + valueFrom: { fieldRef: { apiVersion: v1, fieldPath: status.podIP } } + - name: POD_NAMESPACE + valueFrom: { fieldRef: { apiVersion: v1, fieldPath: metadata.namespace } } + - name: PGPASSWORD_SUPERUSER + valueFrom: { secretKeyRef: { name: {{ include "spilo.fullname" . }}, key: superuser-password } } + - name: PGUSER_ADMIN + value: superadmin + - name: PGPASSWORD_ADMIN + valueFrom: { secretKeyRef: { name: {{ include "spilo.fullname" . }}, key: admin-password } } + - name: PGPASSWORD_STANDBY + valueFrom: { secretKeyRef: { name: {{ include "spilo.fullname" . }}, key: replication-password } } + - name: SCOPE + value: {{ include "spilo.fullname" . }} + - name: PGROOT + value: /home/postgres/pgdata/pgroot + volumeClaimTemplates: + - metadata: + name: pgdata + labels: + {{- include "spilo.labels" . | nindent 8 }} + spec: + accessModes: ["ReadWriteOnce"] + storageClassName: {{ .Values.storage.className | quote }} + resources: + requests: + storage: {{ .Values.storage.size | quote }} diff --git a/templates/extensions/ha-postgres/values.yaml b/templates/extensions/ha-postgres/values.yaml new file mode 100644 index 00000000..be36de0d --- /dev/null +++ b/templates/extensions/ha-postgres/values.yaml @@ -0,0 +1,14 @@ +passwords: + superuser: "change-me" + replication: "change-me" + admin: "change-me" + +storage: + className: "standard" + size: "5Gi" + +image: + repository: registry.opensource.zalan.do/acid/spilo-11 + tag: "1.5-p5" + +replicas: 3 From cac41e704b2ed53712f4cfaf6ab72675d982adb1 Mon Sep 17 00:00:00 2001 From: zheng861 Date: Mon, 8 Dec 2025 19:07:54 -0500 Subject: [PATCH 02/38] Install from chart URL --- backend/prisma/types.ts | 1 - backend/src/lib/cluster/rancher.ts | 16 +++-- .../src/lib/cluster/resources/extension.ts | 22 ++++-- templates/README.md | 12 ---- templates/extensions/ha-postgres/Chart.yaml | 5 -- .../extensions/ha-postgres/description.txt | 1 - .../ha-postgres/templates/_helpers.tpl | 12 ---- .../ha-postgres/templates/endpoints.yaml | 7 -- .../ha-postgres/templates/role.yaml | 14 ---- .../ha-postgres/templates/rolebinding.yaml | 11 --- .../ha-postgres/templates/secret.yaml | 11 --- .../ha-postgres/templates/service-config.yaml | 8 --- .../ha-postgres/templates/service-read.yaml | 16 ----- .../ha-postgres/templates/service-write.yaml | 12 ---- .../ha-postgres/templates/serviceaccount.yaml | 4 -- .../ha-postgres/templates/statefulset.yaml | 72 ------------------- templates/extensions/ha-postgres/values.yaml | 14 ---- templates/templates.json | 23 ------ 18 files changed, 27 insertions(+), 234 deletions(-) delete mode 100644 templates/README.md delete mode 100644 templates/extensions/ha-postgres/Chart.yaml delete mode 100644 templates/extensions/ha-postgres/description.txt delete mode 100644 templates/extensions/ha-postgres/templates/_helpers.tpl delete mode 100644 templates/extensions/ha-postgres/templates/endpoints.yaml delete mode 100644 templates/extensions/ha-postgres/templates/role.yaml delete mode 100644 templates/extensions/ha-postgres/templates/rolebinding.yaml delete mode 100644 templates/extensions/ha-postgres/templates/secret.yaml delete mode 100644 templates/extensions/ha-postgres/templates/service-config.yaml delete mode 100644 templates/extensions/ha-postgres/templates/service-read.yaml delete mode 100644 templates/extensions/ha-postgres/templates/service-write.yaml delete mode 100644 templates/extensions/ha-postgres/templates/serviceaccount.yaml delete mode 100644 templates/extensions/ha-postgres/templates/statefulset.yaml delete mode 100644 templates/extensions/ha-postgres/values.yaml delete mode 100644 templates/templates.json diff --git a/backend/prisma/types.ts b/backend/prisma/types.ts index 3e748ad8..ddc0c68b 100644 --- a/backend/prisma/types.ts +++ b/backend/prisma/types.ts @@ -28,7 +28,6 @@ declare global { }; type AppFlags = { enableCD: boolean; - isPreviewing: boolean; }; } type ExtendedDeploymentConfig = Prisma.Result< diff --git a/backend/src/lib/cluster/rancher.ts b/backend/src/lib/cluster/rancher.ts index 5fb7f8bd..402ffece 100644 --- a/backend/src/lib/cluster/rancher.ts +++ b/backend/src/lib/cluster/rancher.ts @@ -1,7 +1,7 @@ import { KubeConfig } from "@kubernetes/client-node"; -import { getClientForClusterUsername } from "./kubernetes.ts"; -import { env } from "../env.ts"; import { getOrCreate } from "../cache.ts"; +import { env } from "../env.ts"; +import { getClientForClusterUsername } from "./kubernetes.ts"; const kc = new KubeConfig(); kc.loadFromDefault(); @@ -20,12 +20,16 @@ const fetchRancherResource = async (endpoint: string) => { return fetch(`${API_BASE_URL}/${endpoint}`, { headers }) .then((res) => res.text()) .then((res) => JSON.parse(res)) - .then((res) => (res.type === "error" ? new Error(res.message) : res)); + .then((res) => { + if (res.type === "error") { + throw res; + } + return res; + }); }; const getProjectById = async (id: string) => { const project = await fetchRancherResource(`projects/${id}`); - return { id: project.id, name: project.name, @@ -37,7 +41,9 @@ const fetchUserProjects = async (rancherId: string) => { const bindings = await fetchRancherResource( `projectRoleTemplateBindings?userId=${rancherId}`, ).then((res) => res.data); - const projectIds = bindings.map((binding: any) => binding.projectId); + const projectIds = bindings + ? bindings.map((binding: any) => binding.projectId) + : []; projectIds.push(SANDBOX_ID); const uniqueProjectIds = [...new Set(projectIds)] as string[]; diff --git a/backend/src/lib/cluster/resources/extension.ts b/backend/src/lib/cluster/resources/extension.ts index e7de4436..3010621b 100644 --- a/backend/src/lib/cluster/resources/extension.ts +++ b/backend/src/lib/cluster/resources/extension.ts @@ -1,19 +1,29 @@ import { spawn } from "child_process"; -import fs from "fs"; -const extensions = new Set(fs.readdirSync("/templates/extensions")); - -const runHelm = ({ chartPath, namespace, kv, release }) => { +export const runHelm = ({ + chartURL, + namespace, + values, + release, +}: { + chartURL: string; + namespace: string; + values: { [key: string]: string }; + release: string; +}) => { + const kvPairs = Object.keys(values).map((key, value) => `${key}=${value}`); const args = [ "upgrade", "--install", release, - chartPath, + chartURL, "--namespace", namespace, + "--create-namespace", "--set", - kv.join(","), + kvPairs.join(","), ]; + return new Promise((resolve, reject) => { const p = spawn("helm", args, { stdio: ["ignore", "pipe", "pipe"] }); let out = "", diff --git a/templates/README.md b/templates/README.md deleted file mode 100644 index 3f7da91d..00000000 --- a/templates/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# Templates - -A collection of templates for deploying applications with [AnvilOps](https://anvilops.rcac.purdue.edu). - -## Usage - -Select a template and follow the instructions provided on its main page. - -## Templates - -- [anvilops-demo](https://github.rcac.purdue.edu/swans150/anvilops-demo): Astro project that showcases the features of AnvilOps. -- [AnvilOps RShiny Template](https://github.rcac.purdue.edu/RCAC-Staff/AnvilOps-RShiny-Template): Template for deploying R Shiny applications. diff --git a/templates/extensions/ha-postgres/Chart.yaml b/templates/extensions/ha-postgres/Chart.yaml deleted file mode 100644 index 9a679823..00000000 --- a/templates/extensions/ha-postgres/Chart.yaml +++ /dev/null @@ -1,5 +0,0 @@ -apiVersion: v1 -name: spilo -description: Minimal Spilo/Patroni HA Postgres -type: application -version: 0.1.0 diff --git a/templates/extensions/ha-postgres/description.txt b/templates/extensions/ha-postgres/description.txt deleted file mode 100644 index d2db6291..00000000 --- a/templates/extensions/ha-postgres/description.txt +++ /dev/null @@ -1 +0,0 @@ -A minimal chart for a high-availability PostgreSQL deployment using Spilo. User must have permission to create Roles and RoleBindings. \ No newline at end of file diff --git a/templates/extensions/ha-postgres/templates/_helpers.tpl b/templates/extensions/ha-postgres/templates/_helpers.tpl deleted file mode 100644 index 24cdd87a..00000000 --- a/templates/extensions/ha-postgres/templates/_helpers.tpl +++ /dev/null @@ -1,12 +0,0 @@ -{{- define "spilo.name" -}} -{{- .Chart.Name | trunc 63 | trimSuffix "-" -}} -{{- end -}} - -{{- define "spilo.fullname" -}} -{{- printf "%s" .Release.Name | trunc 63 | trimSuffix "-" -}} -{{- end -}} - -{{- define "spilo.labels" -}} -application: spilo -spilo-cluster: {{ include "spilo.fullname" . }} -{{- end -}} \ No newline at end of file diff --git a/templates/extensions/ha-postgres/templates/endpoints.yaml b/templates/extensions/ha-postgres/templates/endpoints.yaml deleted file mode 100644 index befbf9d6..00000000 --- a/templates/extensions/ha-postgres/templates/endpoints.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: v1 -kind: Endpoints -metadata: - name: {{ include "spilo.fullname" . }} - labels: - {{- include "spilo.labels" . | nindent 4 }} -subsets: [] \ No newline at end of file diff --git a/templates/extensions/ha-postgres/templates/role.yaml b/templates/extensions/ha-postgres/templates/role.yaml deleted file mode 100644 index 385a774c..00000000 --- a/templates/extensions/ha-postgres/templates/role.yaml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: operator -rules: -- apiGroups: [""] - resources: ["configmaps"] - verbs: ["create","get","list","patch","update","watch","delete"] -- apiGroups: [""] - resources: ["endpoints"] - verbs: ["create","get","list","watch","patch","update","delete"] -- apiGroups: [""] - resources: ["pods"] - verbs: ["get","list","watch","patch","update"] \ No newline at end of file diff --git a/templates/extensions/ha-postgres/templates/rolebinding.yaml b/templates/extensions/ha-postgres/templates/rolebinding.yaml deleted file mode 100644 index fa62bcae..00000000 --- a/templates/extensions/ha-postgres/templates/rolebinding.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: operator -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: operator -subjects: -- kind: ServiceAccount - name: operator \ No newline at end of file diff --git a/templates/extensions/ha-postgres/templates/secret.yaml b/templates/extensions/ha-postgres/templates/secret.yaml deleted file mode 100644 index 5977d14c..00000000 --- a/templates/extensions/ha-postgres/templates/secret.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: {{ include "spilo.fullname" . }} - labels: - {{- include "spilo.labels" . | nindent 4 }} -type: Opaque -data: - superuser-password: {{ .Values.passwords.superuser | b64enc | quote }} - replication-password: {{ .Values.passwords.replication | b64enc | quote }} - admin-password: {{ .Values.passwords.admin | b64enc | quote }} diff --git a/templates/extensions/ha-postgres/templates/service-config.yaml b/templates/extensions/ha-postgres/templates/service-config.yaml deleted file mode 100644 index ce5fbeaa..00000000 --- a/templates/extensions/ha-postgres/templates/service-config.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: {{ include "spilo.fullname" . }}-config - labels: - {{- include "spilo.labels" . | nindent 4 }} -spec: - clusterIP: None diff --git a/templates/extensions/ha-postgres/templates/service-read.yaml b/templates/extensions/ha-postgres/templates/service-read.yaml deleted file mode 100644 index 638c6fc7..00000000 --- a/templates/extensions/ha-postgres/templates/service-read.yaml +++ /dev/null @@ -1,16 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: {{ include "spilo.fullname" . }}-repl - labels: - {{- include "spilo.labels" . | nindent 4 }} - role: replica -spec: - type: ClusterIP - selector: - {{- include "spilo.labels" . | nindent 4 }} - role: replica - ports: - - name: postgres - port: 5432 - targetPort: 5432 \ No newline at end of file diff --git a/templates/extensions/ha-postgres/templates/service-write.yaml b/templates/extensions/ha-postgres/templates/service-write.yaml deleted file mode 100644 index 9102a856..00000000 --- a/templates/extensions/ha-postgres/templates/service-write.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: {{ include "spilo.fullname" . }} - labels: - {{- include "spilo.labels" . | nindent 4 }} -spec: - type: ClusterIP - ports: - - name: postgresql - port: 5432 - targetPort: 5432 \ No newline at end of file diff --git a/templates/extensions/ha-postgres/templates/serviceaccount.yaml b/templates/extensions/ha-postgres/templates/serviceaccount.yaml deleted file mode 100644 index 5f5e99bd..00000000 --- a/templates/extensions/ha-postgres/templates/serviceaccount.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: ServiceAccount -metadata: - name: operator \ No newline at end of file diff --git a/templates/extensions/ha-postgres/templates/statefulset.yaml b/templates/extensions/ha-postgres/templates/statefulset.yaml deleted file mode 100644 index fe59cda5..00000000 --- a/templates/extensions/ha-postgres/templates/statefulset.yaml +++ /dev/null @@ -1,72 +0,0 @@ -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: {{ include "spilo.fullname" . }} - labels: - {{- include "spilo.labels" . | nindent 4 }} -spec: - selector: - matchLabels: - {{- include "spilo.labels" . | nindent 6 }} - replicas: {{ .Values.replicas }} - serviceName: {{ include "spilo.fullname" . }} - template: - metadata: - labels: - {{- include "spilo.labels" . | nindent 8 }} - spec: - serviceAccountName: operator - containers: - - name: spilo - image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - imagePullPolicy: {{ .Values.image.pullPolicy }} - ports: - - { containerPort: 8008, protocol: TCP } - - { containerPort: 5432, protocol: TCP } - volumeMounts: - - name: pgdata - mountPath: /home/postgres/pgdata - env: - - name: DCS_ENABLE_KUBERNETES_API - value: 'true' - - name: KUBERNETES_SCOPE_LABEL - value: spilo-cluster - - name: KUBERNETES_ROLE_LABEL - value: role - - name: KUBERNETES_LEADER_LABEL_VALUE - value: master - - name: KUBERNETES_STANDBY_LEADER_LABEL_VALUE - value: master - - name: SPILO_CONFIGURATION - value: | - bootstrap: - initdb: - - auth-host: md5 - - auth-local: trust - - name: POD_IP - valueFrom: { fieldRef: { apiVersion: v1, fieldPath: status.podIP } } - - name: POD_NAMESPACE - valueFrom: { fieldRef: { apiVersion: v1, fieldPath: metadata.namespace } } - - name: PGPASSWORD_SUPERUSER - valueFrom: { secretKeyRef: { name: {{ include "spilo.fullname" . }}, key: superuser-password } } - - name: PGUSER_ADMIN - value: superadmin - - name: PGPASSWORD_ADMIN - valueFrom: { secretKeyRef: { name: {{ include "spilo.fullname" . }}, key: admin-password } } - - name: PGPASSWORD_STANDBY - valueFrom: { secretKeyRef: { name: {{ include "spilo.fullname" . }}, key: replication-password } } - - name: SCOPE - value: {{ include "spilo.fullname" . }} - - name: PGROOT - value: /home/postgres/pgdata/pgroot - volumeClaimTemplates: - - metadata: - name: pgdata - labels: - {{- include "spilo.labels" . | nindent 8 }} - spec: - accessModes: ["ReadWriteOnce"] - storageClassName: {{ .Values.storage.className | quote }} - resources: - requests: - storage: {{ .Values.storage.size | quote }} diff --git a/templates/extensions/ha-postgres/values.yaml b/templates/extensions/ha-postgres/values.yaml deleted file mode 100644 index be36de0d..00000000 --- a/templates/extensions/ha-postgres/values.yaml +++ /dev/null @@ -1,14 +0,0 @@ -passwords: - superuser: "change-me" - replication: "change-me" - admin: "change-me" - -storage: - className: "standard" - size: "5Gi" - -image: - repository: registry.opensource.zalan.do/acid/spilo-11 - tag: "1.5-p5" - -replicas: 3 diff --git a/templates/templates.json b/templates/templates.json deleted file mode 100644 index 3fd3a5bc..00000000 --- a/templates/templates.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "anvilops-demo": { - "displayName": "AnvilOps Demo", - "url": "https://github.rcac.purdue.edu/swans150/anvilops-demo", - "description": "Astro project that showcases the features of AnvilOps.", - "port": "80", - "builder": "railpack", - "subdomain": "anvilops-demo", - "env": [], - "mounts": [] - }, - "AnvilOps-RShiny-Template": { - "displayName": "AnvilOps RShiny Template", - "url": "https://github.rcac.purdue.edu/RCAC-Staff/AnvilOps-RShiny-Template", - "description": "Template for deploying R Shiny applications.", - "port": "3838", - "builder": "dockerfile", - "dockerfilePath": "Dockerfile", - "subdomain": "anvilops-rshiny", - "env": [], - "mounts": [] - } -} From a0092314ac9ee4d92da75421d5256e9e16d6facc Mon Sep 17 00:00:00 2001 From: zheng861 Date: Tue, 9 Dec 2025 09:12:55 -0500 Subject: [PATCH 03/38] Move spilo chart to charts --- charts/spilo/Chart.yaml | 7 +++ charts/spilo/templates/_helpers.tpl | 12 ++++ charts/spilo/templates/endpoints.yaml | 7 +++ charts/spilo/templates/role.yaml | 14 +++++ charts/spilo/templates/rolebinding.yaml | 11 ++++ charts/spilo/templates/secret.yaml | 11 ++++ charts/spilo/templates/service-config.yaml | 8 +++ charts/spilo/templates/service-read.yaml | 16 +++++ charts/spilo/templates/service-write.yaml | 12 ++++ charts/spilo/templates/serviceaccount.yaml | 4 ++ charts/spilo/templates/statefulset.yaml | 72 ++++++++++++++++++++++ charts/spilo/values.yaml | 14 +++++ templates/README.md | 12 ++++ templates/templates.json | 23 +++++++ 14 files changed, 223 insertions(+) create mode 100644 charts/spilo/Chart.yaml create mode 100644 charts/spilo/templates/_helpers.tpl create mode 100644 charts/spilo/templates/endpoints.yaml create mode 100644 charts/spilo/templates/role.yaml create mode 100644 charts/spilo/templates/rolebinding.yaml create mode 100644 charts/spilo/templates/secret.yaml create mode 100644 charts/spilo/templates/service-config.yaml create mode 100644 charts/spilo/templates/service-read.yaml create mode 100644 charts/spilo/templates/service-write.yaml create mode 100644 charts/spilo/templates/serviceaccount.yaml create mode 100644 charts/spilo/templates/statefulset.yaml create mode 100644 charts/spilo/values.yaml create mode 100644 templates/README.md create mode 100644 templates/templates.json diff --git a/charts/spilo/Chart.yaml b/charts/spilo/Chart.yaml new file mode 100644 index 00000000..7e513f7e --- /dev/null +++ b/charts/spilo/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +name: spilo +description: A minimal Spilo/Patroni HA Postgres deployment. +type: application +version: 0.1.0 +annotations: + note: Requires permission to create Roles and RoleBindings. Rancher Project Owners have this permission. \ No newline at end of file diff --git a/charts/spilo/templates/_helpers.tpl b/charts/spilo/templates/_helpers.tpl new file mode 100644 index 00000000..24cdd87a --- /dev/null +++ b/charts/spilo/templates/_helpers.tpl @@ -0,0 +1,12 @@ +{{- define "spilo.name" -}} +{{- .Chart.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "spilo.fullname" -}} +{{- printf "%s" .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "spilo.labels" -}} +application: spilo +spilo-cluster: {{ include "spilo.fullname" . }} +{{- end -}} \ No newline at end of file diff --git a/charts/spilo/templates/endpoints.yaml b/charts/spilo/templates/endpoints.yaml new file mode 100644 index 00000000..befbf9d6 --- /dev/null +++ b/charts/spilo/templates/endpoints.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Endpoints +metadata: + name: {{ include "spilo.fullname" . }} + labels: + {{- include "spilo.labels" . | nindent 4 }} +subsets: [] \ No newline at end of file diff --git a/charts/spilo/templates/role.yaml b/charts/spilo/templates/role.yaml new file mode 100644 index 00000000..385a774c --- /dev/null +++ b/charts/spilo/templates/role.yaml @@ -0,0 +1,14 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: operator +rules: +- apiGroups: [""] + resources: ["configmaps"] + verbs: ["create","get","list","patch","update","watch","delete"] +- apiGroups: [""] + resources: ["endpoints"] + verbs: ["create","get","list","watch","patch","update","delete"] +- apiGroups: [""] + resources: ["pods"] + verbs: ["get","list","watch","patch","update"] \ No newline at end of file diff --git a/charts/spilo/templates/rolebinding.yaml b/charts/spilo/templates/rolebinding.yaml new file mode 100644 index 00000000..fa62bcae --- /dev/null +++ b/charts/spilo/templates/rolebinding.yaml @@ -0,0 +1,11 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: operator +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: operator +subjects: +- kind: ServiceAccount + name: operator \ No newline at end of file diff --git a/charts/spilo/templates/secret.yaml b/charts/spilo/templates/secret.yaml new file mode 100644 index 00000000..5977d14c --- /dev/null +++ b/charts/spilo/templates/secret.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "spilo.fullname" . }} + labels: + {{- include "spilo.labels" . | nindent 4 }} +type: Opaque +data: + superuser-password: {{ .Values.passwords.superuser | b64enc | quote }} + replication-password: {{ .Values.passwords.replication | b64enc | quote }} + admin-password: {{ .Values.passwords.admin | b64enc | quote }} diff --git a/charts/spilo/templates/service-config.yaml b/charts/spilo/templates/service-config.yaml new file mode 100644 index 00000000..ce5fbeaa --- /dev/null +++ b/charts/spilo/templates/service-config.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "spilo.fullname" . }}-config + labels: + {{- include "spilo.labels" . | nindent 4 }} +spec: + clusterIP: None diff --git a/charts/spilo/templates/service-read.yaml b/charts/spilo/templates/service-read.yaml new file mode 100644 index 00000000..638c6fc7 --- /dev/null +++ b/charts/spilo/templates/service-read.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "spilo.fullname" . }}-repl + labels: + {{- include "spilo.labels" . | nindent 4 }} + role: replica +spec: + type: ClusterIP + selector: + {{- include "spilo.labels" . | nindent 4 }} + role: replica + ports: + - name: postgres + port: 5432 + targetPort: 5432 \ No newline at end of file diff --git a/charts/spilo/templates/service-write.yaml b/charts/spilo/templates/service-write.yaml new file mode 100644 index 00000000..9102a856 --- /dev/null +++ b/charts/spilo/templates/service-write.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "spilo.fullname" . }} + labels: + {{- include "spilo.labels" . | nindent 4 }} +spec: + type: ClusterIP + ports: + - name: postgresql + port: 5432 + targetPort: 5432 \ No newline at end of file diff --git a/charts/spilo/templates/serviceaccount.yaml b/charts/spilo/templates/serviceaccount.yaml new file mode 100644 index 00000000..5f5e99bd --- /dev/null +++ b/charts/spilo/templates/serviceaccount.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: operator \ No newline at end of file diff --git a/charts/spilo/templates/statefulset.yaml b/charts/spilo/templates/statefulset.yaml new file mode 100644 index 00000000..fe59cda5 --- /dev/null +++ b/charts/spilo/templates/statefulset.yaml @@ -0,0 +1,72 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "spilo.fullname" . }} + labels: + {{- include "spilo.labels" . | nindent 4 }} +spec: + selector: + matchLabels: + {{- include "spilo.labels" . | nindent 6 }} + replicas: {{ .Values.replicas }} + serviceName: {{ include "spilo.fullname" . }} + template: + metadata: + labels: + {{- include "spilo.labels" . | nindent 8 }} + spec: + serviceAccountName: operator + containers: + - name: spilo + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - { containerPort: 8008, protocol: TCP } + - { containerPort: 5432, protocol: TCP } + volumeMounts: + - name: pgdata + mountPath: /home/postgres/pgdata + env: + - name: DCS_ENABLE_KUBERNETES_API + value: 'true' + - name: KUBERNETES_SCOPE_LABEL + value: spilo-cluster + - name: KUBERNETES_ROLE_LABEL + value: role + - name: KUBERNETES_LEADER_LABEL_VALUE + value: master + - name: KUBERNETES_STANDBY_LEADER_LABEL_VALUE + value: master + - name: SPILO_CONFIGURATION + value: | + bootstrap: + initdb: + - auth-host: md5 + - auth-local: trust + - name: POD_IP + valueFrom: { fieldRef: { apiVersion: v1, fieldPath: status.podIP } } + - name: POD_NAMESPACE + valueFrom: { fieldRef: { apiVersion: v1, fieldPath: metadata.namespace } } + - name: PGPASSWORD_SUPERUSER + valueFrom: { secretKeyRef: { name: {{ include "spilo.fullname" . }}, key: superuser-password } } + - name: PGUSER_ADMIN + value: superadmin + - name: PGPASSWORD_ADMIN + valueFrom: { secretKeyRef: { name: {{ include "spilo.fullname" . }}, key: admin-password } } + - name: PGPASSWORD_STANDBY + valueFrom: { secretKeyRef: { name: {{ include "spilo.fullname" . }}, key: replication-password } } + - name: SCOPE + value: {{ include "spilo.fullname" . }} + - name: PGROOT + value: /home/postgres/pgdata/pgroot + volumeClaimTemplates: + - metadata: + name: pgdata + labels: + {{- include "spilo.labels" . | nindent 8 }} + spec: + accessModes: ["ReadWriteOnce"] + storageClassName: {{ .Values.storage.className | quote }} + resources: + requests: + storage: {{ .Values.storage.size | quote }} diff --git a/charts/spilo/values.yaml b/charts/spilo/values.yaml new file mode 100644 index 00000000..be36de0d --- /dev/null +++ b/charts/spilo/values.yaml @@ -0,0 +1,14 @@ +passwords: + superuser: "change-me" + replication: "change-me" + admin: "change-me" + +storage: + className: "standard" + size: "5Gi" + +image: + repository: registry.opensource.zalan.do/acid/spilo-11 + tag: "1.5-p5" + +replicas: 3 diff --git a/templates/README.md b/templates/README.md new file mode 100644 index 00000000..3f7da91d --- /dev/null +++ b/templates/README.md @@ -0,0 +1,12 @@ +# Templates + +A collection of templates for deploying applications with [AnvilOps](https://anvilops.rcac.purdue.edu). + +## Usage + +Select a template and follow the instructions provided on its main page. + +## Templates + +- [anvilops-demo](https://github.rcac.purdue.edu/swans150/anvilops-demo): Astro project that showcases the features of AnvilOps. +- [AnvilOps RShiny Template](https://github.rcac.purdue.edu/RCAC-Staff/AnvilOps-RShiny-Template): Template for deploying R Shiny applications. diff --git a/templates/templates.json b/templates/templates.json new file mode 100644 index 00000000..3fd3a5bc --- /dev/null +++ b/templates/templates.json @@ -0,0 +1,23 @@ +{ + "anvilops-demo": { + "displayName": "AnvilOps Demo", + "url": "https://github.rcac.purdue.edu/swans150/anvilops-demo", + "description": "Astro project that showcases the features of AnvilOps.", + "port": "80", + "builder": "railpack", + "subdomain": "anvilops-demo", + "env": [], + "mounts": [] + }, + "AnvilOps-RShiny-Template": { + "displayName": "AnvilOps RShiny Template", + "url": "https://github.rcac.purdue.edu/RCAC-Staff/AnvilOps-RShiny-Template", + "description": "Template for deploying R Shiny applications.", + "port": "3838", + "builder": "dockerfile", + "dockerfilePath": "Dockerfile", + "subdomain": "anvilops-rshiny", + "env": [], + "mounts": [] + } +} From f2289b6138299c793814712eb8484a60dcca22e1 Mon Sep 17 00:00:00 2001 From: zheng861 Date: Sat, 20 Dec 2025 11:48:35 -0700 Subject: [PATCH 04/38] Write API handler for listing custom charts --- backend/.env.example | 2 + backend/package-lock.json | 722 +----------------- backend/package.json | 3 +- backend/src/handlers/index.ts | 2 + backend/src/handlers/listCharts.ts | 23 + .../src/lib/cluster/resources/extension.ts | 39 - backend/src/lib/env.ts | 8 + backend/src/lib/helm.ts | 87 +++ backend/src/lib/registry.ts | 24 +- charts/spilo/Chart.yaml | 39 +- charts/spilo/values.schema.json | 48 ++ charts/spilo/values.yaml | 9 +- openapi/openapi.yaml | 58 +- 13 files changed, 295 insertions(+), 769 deletions(-) create mode 100644 backend/src/handlers/listCharts.ts delete mode 100644 backend/src/lib/cluster/resources/extension.ts create mode 100644 backend/src/lib/helm.ts create mode 100644 charts/spilo/values.schema.json diff --git a/backend/.env.example b/backend/.env.example index 29b7d26f..ff4006da 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -41,6 +41,8 @@ DELETE_REPO_PASSWORD= CURRENT_NAMESPACE=anvilops-dev +CHART_PROJECT_NAME=anvilops-chart +REGISTRY_API_URL=https://registry.anvil.rcac.purdue.edu/api/v2.0 REGISTRY_HOSTNAME=registry.anvil.rcac.purdue.edu STORAGE_CLASS_NAME=standard diff --git a/backend/package-lock.json b/backend/package-lock.json index c156119e..ebf90acc 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -26,7 +26,8 @@ "openapi-backend": "^5.15.0", "openid-client": "^6.8.1", "patch-package": "^8.0.1", - "pg": "^8.16.3" + "pg": "^8.16.3", + "yaml": "^2.8.2" }, "devDependencies": { "@types/connect-pg-simple": "^7.0.3", @@ -41,8 +42,6 @@ }, "node_modules/@apidevtools/json-schema-ref-parser": { "version": "11.9.3", - "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", - "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", "license": "MIT", "dependencies": { "@jsdevtools/ono": "^7.1.3", @@ -58,8 +57,6 @@ }, "node_modules/@chevrotain/cst-dts-gen": { "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz", - "integrity": "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -70,8 +67,6 @@ }, "node_modules/@chevrotain/gast": { "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-10.5.0.tgz", - "integrity": "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -81,29 +76,21 @@ }, "node_modules/@chevrotain/types": { "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-10.5.0.tgz", - "integrity": "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@chevrotain/utils": { "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-10.5.0.tgz", - "integrity": "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@electric-sql/pglite": { "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.2.tgz", - "integrity": "sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@electric-sql/pglite-socket": { "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.0.6.tgz", - "integrity": "sha512-6RjmgzphIHIBA4NrMGJsjNWK4pu+bCWJlEWlwcxFTVY3WT86dFpKwbZaGWZV6C5Rd7sCk1Z0CI76QEfukLAUXw==", "devOptional": true, "license": "Apache-2.0", "bin": { @@ -115,8 +102,6 @@ }, "node_modules/@electric-sql/pglite-tools": { "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.2.7.tgz", - "integrity": "sha512-9dAccClqxx4cZB+Ar9B+FZ5WgxDc/Xvl9DPrTWv+dYTf0YNubLzi4wHHRGRGhrJv15XwnyKcGOZAP1VXSneSUg==", "devOptional": true, "license": "Apache-2.0", "peerDependencies": { @@ -125,8 +110,6 @@ }, "node_modules/@hono/node-server": { "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.6.tgz", - "integrity": "sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==", "devOptional": true, "license": "MIT", "engines": { @@ -138,14 +121,10 @@ }, "node_modules/@jsdevtools/ono": { "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", - "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", "license": "MIT" }, "node_modules/@jsep-plugin/assignment": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", - "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==", "license": "MIT", "engines": { "node": ">= 10.16.0" @@ -156,8 +135,6 @@ }, "node_modules/@jsep-plugin/regex": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz", - "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==", "license": "MIT", "engines": { "node": ">= 10.16.0" @@ -168,8 +145,6 @@ }, "node_modules/@kubernetes/client-node": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-1.4.0.tgz", - "integrity": "sha512-Zge3YvF7DJi264dU1b3wb/GmzR99JhUpqTvp+VGHfwZT+g7EOOYNScDJNZwXy9cszyIGPIs0VHr+kk8e95qqrA==", "license": "Apache-2.0", "dependencies": { "@types/js-yaml": "^4.0.1", @@ -192,8 +167,6 @@ }, "node_modules/@mrleebo/prisma-ast": { "version": "0.12.1", - "resolved": "https://registry.npmjs.org/@mrleebo/prisma-ast/-/prisma-ast-0.12.1.tgz", - "integrity": "sha512-JwqeCQ1U3fvccttHZq7Tk0m/TMC6WcFAQZdukypW3AzlJYKYTGNVd1ANU2GuhKnv4UQuOFj3oAl0LLG/gxFN1w==", "devOptional": true, "license": "MIT", "dependencies": { @@ -206,8 +179,6 @@ }, "node_modules/@octokit/app": { "version": "16.1.2", - "resolved": "https://registry.npmjs.org/@octokit/app/-/app-16.1.2.tgz", - "integrity": "sha512-8j7sEpUYVj18dxvh0KWj6W/l6uAiVRBl1JBDVRqH1VHKAO/G5eRVl4yEoYACjakWers1DjUkcCHyJNQK47JqyQ==", "license": "MIT", "dependencies": { "@octokit/auth-app": "^8.1.2", @@ -224,8 +195,6 @@ }, "node_modules/@octokit/auth-app": { "version": "8.1.2", - "resolved": "https://registry.npmjs.org/@octokit/auth-app/-/auth-app-8.1.2.tgz", - "integrity": "sha512-db8VO0PqXxfzI6GdjtgEFHY9tzqUql5xMFXYA12juq8TeTgPAuiiP3zid4h50lwlIP457p5+56PnJOgd2GGBuw==", "license": "MIT", "dependencies": { "@octokit/auth-oauth-app": "^9.0.3", @@ -243,8 +212,6 @@ }, "node_modules/@octokit/auth-oauth-app": { "version": "9.0.3", - "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-app/-/auth-oauth-app-9.0.3.tgz", - "integrity": "sha512-+yoFQquaF8OxJSxTb7rnytBIC2ZLbLqA/yb71I4ZXT9+Slw4TziV9j/kyGhUFRRTF2+7WlnIWsePZCWHs+OGjg==", "license": "MIT", "dependencies": { "@octokit/auth-oauth-device": "^8.0.3", @@ -259,8 +226,6 @@ }, "node_modules/@octokit/auth-oauth-device": { "version": "8.0.3", - "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-device/-/auth-oauth-device-8.0.3.tgz", - "integrity": "sha512-zh2W0mKKMh/VWZhSqlaCzY7qFyrgd9oTWmTmHaXnHNeQRCZr/CXy2jCgHo4e4dJVTiuxP5dLa0YM5p5QVhJHbw==", "license": "MIT", "dependencies": { "@octokit/oauth-methods": "^6.0.2", @@ -274,8 +239,6 @@ }, "node_modules/@octokit/auth-oauth-user": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-user/-/auth-oauth-user-6.0.2.tgz", - "integrity": "sha512-qLoPPc6E6GJoz3XeDG/pnDhJpTkODTGG4kY0/Py154i/I003O9NazkrwJwRuzgCalhzyIeWQ+6MDvkUmKXjg/A==", "license": "MIT", "dependencies": { "@octokit/auth-oauth-device": "^8.0.3", @@ -290,8 +253,6 @@ }, "node_modules/@octokit/auth-token": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", - "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", "license": "MIT", "engines": { "node": ">= 20" @@ -299,8 +260,6 @@ }, "node_modules/@octokit/auth-unauthenticated": { "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@octokit/auth-unauthenticated/-/auth-unauthenticated-7.0.3.tgz", - "integrity": "sha512-8Jb1mtUdmBHL7lGmop9mU9ArMRUTRhg8vp0T1VtZ4yd9vEm3zcLwmjQkhNEduKawOOORie61xhtYIhTDN+ZQ3g==", "license": "MIT", "dependencies": { "@octokit/request-error": "^7.0.2", @@ -312,8 +271,6 @@ }, "node_modules/@octokit/core": { "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", - "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "license": "MIT", "dependencies": { "@octokit/auth-token": "^6.0.0", @@ -330,8 +287,6 @@ }, "node_modules/@octokit/endpoint": { "version": "11.0.2", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.2.tgz", - "integrity": "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==", "license": "MIT", "dependencies": { "@octokit/types": "^16.0.0", @@ -343,8 +298,6 @@ }, "node_modules/@octokit/graphql": { "version": "9.0.3", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", - "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", "license": "MIT", "dependencies": { "@octokit/request": "^10.0.6", @@ -357,8 +310,6 @@ }, "node_modules/@octokit/oauth-app": { "version": "8.0.3", - "resolved": "https://registry.npmjs.org/@octokit/oauth-app/-/oauth-app-8.0.3.tgz", - "integrity": "sha512-jnAjvTsPepyUaMu9e69hYBuozEPgYqP4Z3UnpmvoIzHDpf8EXDGvTY1l1jK0RsZ194oRd+k6Hm13oRU8EoDFwg==", "license": "MIT", "dependencies": { "@octokit/auth-oauth-app": "^9.0.2", @@ -376,8 +327,6 @@ }, "node_modules/@octokit/oauth-authorization-url": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@octokit/oauth-authorization-url/-/oauth-authorization-url-8.0.0.tgz", - "integrity": "sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ==", "license": "MIT", "engines": { "node": ">= 20" @@ -385,8 +334,6 @@ }, "node_modules/@octokit/oauth-methods": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@octokit/oauth-methods/-/oauth-methods-6.0.2.tgz", - "integrity": "sha512-HiNOO3MqLxlt5Da5bZbLV8Zarnphi4y9XehrbaFMkcoJ+FL7sMxH/UlUsCVxpddVu4qvNDrBdaTVE2o4ITK8ng==", "license": "MIT", "dependencies": { "@octokit/oauth-authorization-url": "^8.0.0", @@ -400,20 +347,14 @@ }, "node_modules/@octokit/openapi-types": { "version": "27.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", - "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", "license": "MIT" }, "node_modules/@octokit/openapi-webhooks-types": { "version": "12.0.3", - "resolved": "https://registry.npmjs.org/@octokit/openapi-webhooks-types/-/openapi-webhooks-types-12.0.3.tgz", - "integrity": "sha512-90MF5LVHjBedwoHyJsgmaFhEN1uzXyBDRLEBe7jlTYx/fEhPAk3P3DAJsfZwC54m8hAIryosJOL+UuZHB3K3yA==", "license": "MIT" }, "node_modules/@octokit/plugin-paginate-graphql": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-graphql/-/plugin-paginate-graphql-6.0.0.tgz", - "integrity": "sha512-crfpnIoFiBtRkvPqOyLOsw12XsveYuY2ieP6uYDosoUegBJpSVxGwut9sxUgFFcll3VTOTqpUf8yGd8x1OmAkQ==", "license": "MIT", "engines": { "node": ">= 20" @@ -424,8 +365,6 @@ }, "node_modules/@octokit/plugin-paginate-rest": { "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", - "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", "license": "MIT", "dependencies": { "@octokit/types": "^16.0.0" @@ -439,8 +378,6 @@ }, "node_modules/@octokit/plugin-rest-endpoint-methods": { "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", - "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", "license": "MIT", "dependencies": { "@octokit/types": "^16.0.0" @@ -454,8 +391,6 @@ }, "node_modules/@octokit/plugin-retry": { "version": "8.0.3", - "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-8.0.3.tgz", - "integrity": "sha512-vKGx1i3MC0za53IzYBSBXcrhmd+daQDzuZfYDd52X5S0M2otf3kVZTVP8bLA3EkU0lTvd1WEC2OlNNa4G+dohA==", "license": "MIT", "dependencies": { "@octokit/request-error": "^7.0.2", @@ -471,8 +406,6 @@ }, "node_modules/@octokit/plugin-throttling": { "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-11.0.3.tgz", - "integrity": "sha512-34eE0RkFCKycLl2D2kq7W+LovheM/ex3AwZCYN8udpi6bxsyjZidb2McXs69hZhLmJlDqTSP8cH+jSRpiaijBg==", "license": "MIT", "dependencies": { "@octokit/types": "^16.0.0", @@ -487,8 +420,6 @@ }, "node_modules/@octokit/request": { "version": "10.0.7", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.7.tgz", - "integrity": "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==", "license": "MIT", "dependencies": { "@octokit/endpoint": "^11.0.2", @@ -503,8 +434,6 @@ }, "node_modules/@octokit/request-error": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", - "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", "license": "MIT", "dependencies": { "@octokit/types": "^16.0.0" @@ -515,8 +444,6 @@ }, "node_modules/@octokit/types": { "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", - "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", "license": "MIT", "dependencies": { "@octokit/openapi-types": "^27.0.0" @@ -524,8 +451,6 @@ }, "node_modules/@octokit/webhooks": { "version": "14.1.3", - "resolved": "https://registry.npmjs.org/@octokit/webhooks/-/webhooks-14.1.3.tgz", - "integrity": "sha512-gcK4FNaROM9NjA0mvyfXl0KPusk7a1BeA8ITlYEZVQCXF5gcETTd4yhAU0Kjzd8mXwYHppzJBWgdBVpIR9wUcQ==", "license": "MIT", "dependencies": { "@octokit/openapi-webhooks-types": "12.0.3", @@ -538,8 +463,6 @@ }, "node_modules/@octokit/webhooks-methods": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@octokit/webhooks-methods/-/webhooks-methods-6.0.0.tgz", - "integrity": "sha512-MFlzzoDJVw/GcbfzVC1RLR36QqkTLUf79vLVO3D+xn7r0QgxnFoLZgtrzxiQErAjFUOdH6fas2KeQJ1yr/qaXQ==", "license": "MIT", "engines": { "node": ">= 20" @@ -547,8 +470,6 @@ }, "node_modules/@prisma/adapter-pg": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.0.1.tgz", - "integrity": "sha512-01GpPPhLMoDMF4ipgfZz0L87fla/TV/PBQcmHy+9vV1ml6gUoqF8dUIRNI5Yf2YKpOwzQg9sn8C7dYD1Yio9Ug==", "license": "Apache-2.0", "dependencies": { "@prisma/driver-adapter-utils": "7.0.1", @@ -558,8 +479,6 @@ }, "node_modules/@prisma/adapter-pg/node_modules/postgres-array": { "version": "3.0.4", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", - "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", "license": "MIT", "engines": { "node": ">=12" @@ -567,8 +486,6 @@ }, "node_modules/@prisma/client": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.1.0.tgz", - "integrity": "sha512-qf7GPYHmS/xybNiSOpzv9wBo+UwqfL2PeyX+08v+KVHDI0AlSCQIh5bBySkH3alu06NX9wy98JEnckhMHoMFfA==", "license": "Apache-2.0", "dependencies": { "@prisma/client-runtime-utils": "7.1.0" @@ -591,14 +508,10 @@ }, "node_modules/@prisma/client-runtime-utils": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.1.0.tgz", - "integrity": "sha512-39xmeBrNTN40FzF34aJMjfX1PowVCqoT3UKUWBBSP3aXV05NRqGBC3x2wCDs96ti6ZgdiVzqnRDHtbzU8X+lPQ==", "license": "Apache-2.0" }, "node_modules/@prisma/config": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.1.0.tgz", - "integrity": "sha512-Uz+I43Wn1RYNHtuYtOhOnUcNMWp2Pd3GUDDKs37xlHptCGpzEG3MRR9L+8Y2ISMsMI24z/Ni+ww6OB/OO8M0sQ==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -610,14 +523,10 @@ }, "node_modules/@prisma/debug": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.0.1.tgz", - "integrity": "sha512-5+25XokVeAK2Z2C9W457AFw7Hk032Q3QI3G58KYKXPlpgxy+9FvV1+S1jqfJ2d4Nmq9LP/uACrM6OVhpJMSr8w==", "license": "Apache-2.0" }, "node_modules/@prisma/dev": { "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.15.0.tgz", - "integrity": "sha512-KhWaipnFlS/fWEs6I6Oqjcy2S08vKGmxJ5LexqUl/3Ve0EgLUsZwdKF0MvqPM5F5ttw8GtfZarjM5y7VLwv9Ow==", "devOptional": true, "license": "ISC", "dependencies": { @@ -642,15 +551,11 @@ }, "node_modules/@prisma/dmmf": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@prisma/dmmf/-/dmmf-7.0.1.tgz", - "integrity": "sha512-8f04R3226L/tvC0jMuTRF9ArbYU/AWdAClkw7XCcSrN1Jml/zWt+43OOwAi5K/7EpASRi+IaaIdrmT+hop0a5g==", "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/driver-adapter-utils": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.0.1.tgz", - "integrity": "sha512-sBbxm/yysHLLF2iMAB+qcX/nn3WFgsiC4DQNz0uM6BwGSIs8lIvgo0u8nR9nxe5gvFgKiIH8f4z2fgOEMeXc8w==", "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.0.1" @@ -658,8 +563,6 @@ }, "node_modules/@prisma/engines": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.1.0.tgz", - "integrity": "sha512-KQlraOybdHAzVv45KWKJzpR9mJLkib7/TyApQpqrsL7FUHfgjIcy8jrVGt3iNfG6/GDDl+LNlJ84JSQwIfdzxA==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -672,22 +575,16 @@ }, "node_modules/@prisma/engines-version": { "version": "7.1.0-6.ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.0-6.ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba.tgz", - "integrity": "sha512-qZUevUh+yPhGT28rDQnV8V2kLnFjirzhVD67elRPIJHRsUV/mkII10HSrJrhK/U2GYgAxXR2VEREtq7AsfS8qw==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines/node_modules/@prisma/debug": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.1.0.tgz", - "integrity": "sha512-pPAckG6etgAsEBusmZiFwM9bldLSNkn++YuC4jCTJACdK5hLOVnOzX7eSL2FgaU6Gomd6wIw21snUX2dYroMZQ==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.1.0.tgz", - "integrity": "sha512-lq8hMdjKiZftuT5SssYB3EtQj8+YjL24/ZTLflQqzFquArKxBcyp6Xrblto+4lzIKJqnpOjfMiBjMvl7YuD7+Q==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -696,8 +593,6 @@ }, "node_modules/@prisma/fetch-engine": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.1.0.tgz", - "integrity": "sha512-GZYF5Q8kweXWGfn87hTu17kw7x1DgnehgKoE4Zg1BmHYF3y1Uu0QRY/qtSE4veH3g+LW8f9HKqA0tARG66bxxQ==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -708,15 +603,11 @@ }, "node_modules/@prisma/fetch-engine/node_modules/@prisma/debug": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.1.0.tgz", - "integrity": "sha512-pPAckG6etgAsEBusmZiFwM9bldLSNkn++YuC4jCTJACdK5hLOVnOzX7eSL2FgaU6Gomd6wIw21snUX2dYroMZQ==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.1.0.tgz", - "integrity": "sha512-lq8hMdjKiZftuT5SssYB3EtQj8+YjL24/ZTLflQqzFquArKxBcyp6Xrblto+4lzIKJqnpOjfMiBjMvl7YuD7+Q==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -725,15 +616,11 @@ }, "node_modules/@prisma/generator": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@prisma/generator/-/generator-7.0.1.tgz", - "integrity": "sha512-d/rPH4p2hdZKg1kfWBAL+SLDGGbxl8I74dZv5+Fm/mpH0lWXRwQrtvpxrPaKbnJnCkyddUS+4SOl9WV2iBMH7w==", "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/generator-helper": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@prisma/generator-helper/-/generator-helper-7.0.1.tgz", - "integrity": "sha512-cCwQFw6Sfm74mKwq8haxCyOOgpRKJ7iFRnr71srT/+onz9oCAKzRBDcDAmnAOLiPvz/RW7qy3RVw3upovwt7lg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -744,8 +631,6 @@ }, "node_modules/@prisma/get-platform": { "version": "6.8.2", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.8.2.tgz", - "integrity": "sha512-vXSxyUgX3vm1Q70QwzwkjeYfRryIvKno1SXbIqwSptKwqKzskINnDUcx85oX+ys6ooN2ATGSD0xN2UTfg6Zcow==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -754,22 +639,16 @@ }, "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { "version": "6.8.2", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.8.2.tgz", - "integrity": "sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/query-plan-executor": { "version": "6.18.0", - "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-6.18.0.tgz", - "integrity": "sha512-jZ8cfzFgL0jReE1R10gT8JLHtQxjWYLiQ//wHmVYZ2rVkFHoh0DT8IXsxcKcFlfKN7ak7k6j0XMNn2xVNyr5cA==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/studio-core": { "version": "0.8.2", - "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.8.2.tgz", - "integrity": "sha512-/iAEWEUpTja+7gVMu1LtR2pPlvDmveAwMHdTWbDeGlT7yiv0ZTCPpmeAGdq/Y9aJ9Zj1cEGBXGRbmmNPj022PQ==", "devOptional": true, "license": "UNLICENSED", "peerDependencies": { @@ -780,21 +659,15 @@ }, "node_modules/@standard-schema/spec": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", "devOptional": true, "license": "MIT" }, "node_modules/@types/aws-lambda": { "version": "8.10.159", - "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.159.tgz", - "integrity": "sha512-SAP22WSGNN12OQ8PlCzGzRCZ7QDCwI85dQZbmpz7+mAk+L7j+wI7qnvmdKh+o7A5LaOp6QnOZ2NJphAZQTTHQg==", "license": "MIT" }, "node_modules/@types/body-parser": { "version": "1.19.5", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", - "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", "dev": true, "license": "MIT", "dependencies": { @@ -804,8 +677,6 @@ }, "node_modules/@types/connect": { "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "dev": true, "license": "MIT", "dependencies": { @@ -814,8 +685,6 @@ }, "node_modules/@types/connect-pg-simple": { "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@types/connect-pg-simple/-/connect-pg-simple-7.0.3.tgz", - "integrity": "sha512-NGCy9WBlW2bw+J/QlLnFZ9WjoGs6tMo3LAut6mY4kK+XHzue//lpNVpAvYRpIwM969vBRAM2Re0izUvV6kt+NA==", "dev": true, "license": "MIT", "dependencies": { @@ -826,8 +695,6 @@ }, "node_modules/@types/cookie-parser": { "version": "1.4.10", - "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", - "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -836,8 +703,6 @@ }, "node_modules/@types/express": { "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", - "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", "dev": true, "license": "MIT", "dependencies": { @@ -848,8 +713,6 @@ }, "node_modules/@types/express-serve-static-core": { "version": "5.0.6", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz", - "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", "dev": true, "license": "MIT", "dependencies": { @@ -861,8 +724,6 @@ }, "node_modules/@types/express-session": { "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.2.tgz", - "integrity": "sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==", "dev": true, "license": "MIT", "dependencies": { @@ -871,34 +732,24 @@ }, "node_modules/@types/http-errors": { "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", "dev": true, "license": "MIT" }, "node_modules/@types/js-yaml": { "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", - "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", "license": "MIT" }, "node_modules/@types/json-schema": { "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, "node_modules/@types/mime": { "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "dev": true, "license": "MIT" }, "node_modules/@types/morgan": { "version": "1.9.10", - "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.10.tgz", - "integrity": "sha512-sS4A1zheMvsADRVfT0lYbJ4S9lmsey8Zo2F7cnbYjWHP67Q0AwMYuuzLlkIM2N8gAbb9cubhIVFwcIN2XyYCkA==", "dev": true, "license": "MIT", "dependencies": { @@ -907,8 +758,6 @@ }, "node_modules/@types/node": { "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -916,8 +765,6 @@ }, "node_modules/@types/node-fetch": { "version": "2.6.13", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", "license": "MIT", "dependencies": { "@types/node": "*", @@ -926,8 +773,6 @@ }, "node_modules/@types/pg": { "version": "8.15.4", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.4.tgz", - "integrity": "sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg==", "dev": true, "license": "MIT", "dependencies": { @@ -938,22 +783,16 @@ }, "node_modules/@types/qs": { "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "19.2.7", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", - "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", "peer": true, @@ -963,8 +802,6 @@ }, "node_modules/@types/send": { "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", - "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", "dev": true, "license": "MIT", "dependencies": { @@ -974,8 +811,6 @@ }, "node_modules/@types/serve-static": { "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", - "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", "dev": true, "license": "MIT", "dependencies": { @@ -986,8 +821,6 @@ }, "node_modules/@types/stream-buffers": { "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/stream-buffers/-/stream-buffers-3.0.7.tgz", - "integrity": "sha512-azOCy05sXVXrO+qklf0c/B07H/oHaIuDDAiHPVwlk3A9Ek+ksHyTeMajLZl3r76FxpPpxem//4Te61G1iW3Giw==", "license": "MIT", "dependencies": { "@types/node": "*" @@ -995,14 +828,10 @@ }, "node_modules/@yarnpkg/lockfile": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", - "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", "license": "BSD-2-Clause" }, "node_modules/accepts": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", "dependencies": { "mime-types": "^3.0.0", @@ -1014,8 +843,6 @@ }, "node_modules/agent-base": { "version": "7.1.3", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", - "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", "license": "MIT", "engines": { "node": ">= 14" @@ -1023,8 +850,6 @@ }, "node_modules/ajv": { "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1039,8 +864,6 @@ }, "node_modules/ajv-formats": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -1056,8 +879,6 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -1071,20 +892,14 @@ }, "node_modules/argparse": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, "node_modules/asynckit": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, "node_modules/aws-ssl-profiles": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", - "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", "devOptional": true, "license": "MIT", "engines": { @@ -1093,21 +908,15 @@ }, "node_modules/b4a": { "version": "1.6.7", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", - "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", "license": "Apache-2.0" }, "node_modules/bare-events": { "version": "2.5.4", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", - "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", "license": "Apache-2.0", "optional": true }, "node_modules/bare-fs": { "version": "4.1.5", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.5.tgz", - "integrity": "sha512-1zccWBMypln0jEE05LzZt+V/8y8AQsQQqxtklqaIyg5nu6OAYFhZxPXinJTSG+kU5qyNmeLgcn9AW7eHiCHVLA==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -1129,8 +938,6 @@ }, "node_modules/bare-os": { "version": "3.6.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz", - "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==", "license": "Apache-2.0", "optional": true, "engines": { @@ -1139,8 +946,6 @@ }, "node_modules/bare-path": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -1149,8 +954,6 @@ }, "node_modules/bare-stream": { "version": "2.6.5", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz", - "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -1171,8 +974,6 @@ }, "node_modules/basic-auth": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", - "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", "license": "MIT", "dependencies": { "safe-buffer": "5.1.2" @@ -1183,26 +984,18 @@ }, "node_modules/basic-auth/node_modules/safe-buffer": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, "node_modules/bath-es5": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/bath-es5/-/bath-es5-3.0.3.tgz", - "integrity": "sha512-PdCioDToH3t84lP40kUFCKWCOCH389Dl1kbC8FGoqOwamxsmqxxnJSXdkTOsPoNHXjem4+sJ+bbNoQm5zeCqxg==", "license": "MIT" }, "node_modules/before-after-hook": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", - "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", "license": "Apache-2.0" }, "node_modules/body-parser": { "version": "2.2.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", - "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", @@ -1225,14 +1018,10 @@ }, "node_modules/bottleneck": { "version": "2.19.5", - "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", - "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==", "license": "MIT" }, "node_modules/braces": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -1243,8 +1032,6 @@ }, "node_modules/bytes": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -1252,8 +1039,6 @@ }, "node_modules/c12": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", - "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -1281,8 +1066,6 @@ }, "node_modules/call-bind": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", @@ -1299,8 +1082,6 @@ }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -1312,8 +1093,6 @@ }, "node_modules/call-bound": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -1328,8 +1107,6 @@ }, "node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -1344,8 +1121,6 @@ }, "node_modules/chalk/node_modules/has-flag": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "license": "MIT", "engines": { "node": ">=8" @@ -1353,8 +1128,6 @@ }, "node_modules/chalk/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -1365,8 +1138,6 @@ }, "node_modules/chevrotain": { "version": "10.5.0", - "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz", - "integrity": "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -1380,8 +1151,6 @@ }, "node_modules/chokidar": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -1396,8 +1165,6 @@ }, "node_modules/ci-info": { "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", "funding": [ { "type": "github", @@ -1411,8 +1178,6 @@ }, "node_modules/citty": { "version": "0.1.6", - "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", - "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -1421,8 +1186,6 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -1433,14 +1196,10 @@ }, "node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, "node_modules/combined-stream": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -1451,15 +1210,11 @@ }, "node_modules/confbox": { "version": "0.2.2", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", - "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", "devOptional": true, "license": "MIT" }, "node_modules/connect-pg-simple": { "version": "10.0.0", - "resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz", - "integrity": "sha512-pBGVazlqiMrackzCr0eKhn4LO5trJXsOX0nQoey9wCOayh80MYtThCbq8eoLsjpiWgiok/h+1/uti9/2/Una8A==", "license": "MIT", "dependencies": { "pg": "^8.12.0" @@ -1470,8 +1225,6 @@ }, "node_modules/consola": { "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", "devOptional": true, "license": "MIT", "engines": { @@ -1480,8 +1233,6 @@ }, "node_modules/content-disposition": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", "license": "MIT", "dependencies": { "safe-buffer": "5.2.1" @@ -1492,8 +1243,6 @@ }, "node_modules/content-type": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -1501,8 +1250,6 @@ }, "node_modules/cookie": { "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -1510,8 +1257,6 @@ }, "node_modules/cookie-parser": { "version": "1.4.7", - "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", - "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", "license": "MIT", "dependencies": { "cookie": "0.7.2", @@ -1523,14 +1268,10 @@ }, "node_modules/cookie-parser/node_modules/cookie-signature": { "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, "node_modules/cookie-signature": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", "license": "MIT", "engines": { "node": ">=6.6.0" @@ -1538,8 +1279,6 @@ }, "node_modules/cross-spawn": { "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1552,16 +1291,12 @@ }, "node_modules/csstype": { "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "devOptional": true, "license": "MIT", "peer": true }, "node_modules/debug": { "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1577,8 +1312,6 @@ }, "node_modules/deepmerge-ts": { "version": "7.1.5", - "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", - "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", "devOptional": true, "license": "BSD-3-Clause", "engines": { @@ -1587,8 +1320,6 @@ }, "node_modules/define-data-property": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -1604,15 +1335,11 @@ }, "node_modules/defu": { "version": "6.1.4", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", "devOptional": true, "license": "MIT" }, "node_modules/delayed-stream": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "license": "MIT", "engines": { "node": ">=0.4.0" @@ -1620,8 +1347,6 @@ }, "node_modules/denque": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", "devOptional": true, "license": "Apache-2.0", "engines": { @@ -1630,8 +1355,6 @@ }, "node_modules/depd": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -1639,21 +1362,15 @@ }, "node_modules/dereference-json-schema": { "version": "0.2.1", - "resolved": "https://registry.npmjs.org/dereference-json-schema/-/dereference-json-schema-0.2.1.tgz", - "integrity": "sha512-uzJsrg225owJyRQ8FNTPHIuBOdSzIZlHhss9u6W8mp7jJldHqGuLv9cULagP/E26QVJDnjtG8U7Dw139mM1ydA==", "license": "MIT" }, "node_modules/destr": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", - "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", "devOptional": true, "license": "MIT" }, "node_modules/dotenv": { "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "devOptional": true, "license": "BSD-2-Clause", "engines": { @@ -1665,8 +1382,6 @@ }, "node_modules/dunder-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -1679,14 +1394,10 @@ }, "node_modules/ee-first": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, "node_modules/effect": { "version": "3.18.4", - "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", - "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -1696,8 +1407,6 @@ }, "node_modules/empathic": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", - "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", "devOptional": true, "license": "MIT", "engines": { @@ -1706,8 +1415,6 @@ }, "node_modules/encodeurl": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -1715,8 +1422,6 @@ }, "node_modules/end-of-stream": { "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -1724,8 +1429,6 @@ }, "node_modules/es-define-property": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -1733,8 +1436,6 @@ }, "node_modules/es-errors": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -1742,8 +1443,6 @@ }, "node_modules/es-object-atoms": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -1754,8 +1453,6 @@ }, "node_modules/es-set-tostringtag": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -1769,14 +1466,10 @@ }, "node_modules/escape-html": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, "node_modules/etag": { "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -1784,8 +1477,6 @@ }, "node_modules/express": { "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", "dependencies": { "accepts": "^2.0.0", @@ -1826,8 +1517,6 @@ }, "node_modules/express-rate-limit": { "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", "license": "MIT", "dependencies": { "ip-address": "10.0.1" @@ -1844,8 +1533,6 @@ }, "node_modules/express-rate-limit/node_modules/ip-address": { "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", "license": "MIT", "engines": { "node": ">= 12" @@ -1853,8 +1540,6 @@ }, "node_modules/express-session": { "version": "1.18.2", - "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz", - "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==", "license": "MIT", "dependencies": { "cookie": "0.7.2", @@ -1872,14 +1557,10 @@ }, "node_modules/express-session/node_modules/cookie-signature": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, "node_modules/express-session/node_modules/debug": { "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "dependencies": { "ms": "2.0.0" @@ -1887,21 +1568,15 @@ }, "node_modules/express-session/node_modules/ms": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, "node_modules/exsolve": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", - "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "devOptional": true, "license": "MIT" }, "node_modules/fast-check": { "version": "3.23.2", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", - "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", "devOptional": true, "funding": [ { @@ -1923,8 +1598,6 @@ }, "node_modules/fast-content-type-parse": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", - "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", "funding": [ { "type": "github", @@ -1939,20 +1612,14 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, "node_modules/fast-fifo": { "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", "license": "MIT" }, "node_modules/fast-uri": { "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", "funding": [ { "type": "github", @@ -1967,8 +1634,6 @@ }, "node_modules/fill-range": { "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -1979,8 +1644,6 @@ }, "node_modules/finalhandler": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -1996,8 +1659,6 @@ }, "node_modules/find-yarn-workspace-root": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", - "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", "license": "Apache-2.0", "dependencies": { "micromatch": "^4.0.2" @@ -2005,8 +1666,6 @@ }, "node_modules/foreground-child": { "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "devOptional": true, "license": "ISC", "dependencies": { @@ -2022,8 +1681,6 @@ }, "node_modules/form-data": { "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -2038,8 +1695,6 @@ }, "node_modules/form-data/node_modules/mime-db": { "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -2047,8 +1702,6 @@ }, "node_modules/form-data/node_modules/mime-types": { "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -2059,8 +1712,6 @@ }, "node_modules/forwarded": { "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -2068,8 +1719,6 @@ }, "node_modules/fresh": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -2077,8 +1726,6 @@ }, "node_modules/fs-extra": { "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -2091,8 +1738,6 @@ }, "node_modules/function-bind": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -2100,8 +1745,6 @@ }, "node_modules/generate-function": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", - "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2110,8 +1753,6 @@ }, "node_modules/get-intrinsic": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -2134,15 +1775,11 @@ }, "node_modules/get-port-please": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.1.2.tgz", - "integrity": "sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ==", "devOptional": true, "license": "MIT" }, "node_modules/get-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -2154,8 +1791,6 @@ }, "node_modules/giget": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", - "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2172,8 +1807,6 @@ }, "node_modules/gopd": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -2184,21 +1817,15 @@ }, "node_modules/graceful-fs": { "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, "node_modules/grammex": { "version": "3.1.12", - "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", - "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", "devOptional": true, "license": "MIT" }, "node_modules/has-property-descriptors": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -2209,8 +1836,6 @@ }, "node_modules/has-symbols": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -2221,8 +1846,6 @@ }, "node_modules/has-tostringtag": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -2236,8 +1859,6 @@ }, "node_modules/hasown": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -2248,8 +1869,6 @@ }, "node_modules/hono": { "version": "4.10.6", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.10.6.tgz", - "integrity": "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==", "devOptional": true, "license": "MIT", "engines": { @@ -2258,8 +1877,6 @@ }, "node_modules/hpagent": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", - "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==", "license": "MIT", "engines": { "node": ">=14" @@ -2267,8 +1884,6 @@ }, "node_modules/http-errors": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", "dependencies": { "depd": "~2.0.0", @@ -2287,15 +1902,11 @@ }, "node_modules/http-status-codes": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", - "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", "devOptional": true, "license": "MIT" }, "node_modules/iconv-lite": { "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -2310,14 +1921,10 @@ }, "node_modules/inherits": { "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, "node_modules/ip-address": { "version": "9.0.5", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", - "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", "license": "MIT", "dependencies": { "jsbn": "1.1.0", @@ -2329,8 +1936,6 @@ }, "node_modules/ipaddr.js": { "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "license": "MIT", "engines": { "node": ">= 0.10" @@ -2338,8 +1943,6 @@ }, "node_modules/is-docker": { "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", "license": "MIT", "bin": { "is-docker": "cli.js" @@ -2353,8 +1956,6 @@ }, "node_modules/is-number": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "license": "MIT", "engines": { "node": ">=0.12.0" @@ -2362,21 +1963,15 @@ }, "node_modules/is-promise": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, "node_modules/is-property": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", - "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", "devOptional": true, "license": "MIT" }, "node_modules/is-wsl": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "license": "MIT", "dependencies": { "is-docker": "^2.0.0" @@ -2387,20 +1982,14 @@ }, "node_modules/isarray": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, "node_modules/isomorphic-ws": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", - "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", "license": "MIT", "peerDependencies": { "ws": "*" @@ -2408,8 +1997,6 @@ }, "node_modules/jiti": { "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "devOptional": true, "license": "MIT", "bin": { @@ -2418,8 +2005,6 @@ }, "node_modules/jose": { "version": "6.1.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.2.tgz", - "integrity": "sha512-MpcPtHLE5EmztuFIqB0vzHAWJPpmN1E6L4oo+kze56LIs3MyXIj9ZHMDxqOvkP38gBR7K1v3jqd4WU2+nrfONQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -2427,8 +2012,6 @@ }, "node_modules/js-yaml": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -2439,14 +2022,10 @@ }, "node_modules/jsbn": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", "license": "MIT" }, "node_modules/jsep": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", - "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", "license": "MIT", "engines": { "node": ">= 10.16.0" @@ -2454,14 +2033,10 @@ }, "node_modules/json-schema-traverse": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/json-stable-stringify": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", - "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -2479,8 +2054,6 @@ }, "node_modules/jsonfile": { "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -2491,8 +2064,6 @@ }, "node_modules/jsonify": { "version": "0.0.1", - "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", - "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", "license": "Public Domain", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -2500,8 +2071,6 @@ }, "node_modules/jsonpath-plus": { "version": "10.3.0", - "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.3.0.tgz", - "integrity": "sha512-8TNmfeTCk2Le33A3vRRwtuworG/L5RrgMvdjhKZxvyShO+mBu2fP50OWUjRLNtvw344DdDarFh9buFAZs5ujeA==", "license": "MIT", "dependencies": { "@jsep-plugin/assignment": "^1.3.0", @@ -2518,8 +2087,6 @@ }, "node_modules/klaw-sync": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", - "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", "license": "MIT", "dependencies": { "graceful-fs": "^4.1.11" @@ -2527,8 +2094,6 @@ }, "node_modules/lilconfig": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", - "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", "devOptional": true, "license": "MIT", "engines": { @@ -2537,27 +2102,19 @@ }, "node_modules/lodash": { "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "license": "MIT" }, "node_modules/long": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/lru-cache": { "version": "11.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", - "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", "license": "ISC", "engines": { "node": "20 || >=22" @@ -2565,8 +2122,6 @@ }, "node_modules/lru.min": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.3.tgz", - "integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==", "devOptional": true, "license": "MIT", "engines": { @@ -2581,8 +2136,6 @@ }, "node_modules/math-intrinsics": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -2590,8 +2143,6 @@ }, "node_modules/media-typer": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -2599,8 +2150,6 @@ }, "node_modules/merge-descriptors": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", "engines": { "node": ">=18" @@ -2611,8 +2160,6 @@ }, "node_modules/micromatch": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -2624,8 +2171,6 @@ }, "node_modules/mime-db": { "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -2633,8 +2178,6 @@ }, "node_modules/mime-types": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -2645,8 +2188,6 @@ }, "node_modules/minimist": { "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -2654,8 +2195,6 @@ }, "node_modules/mock-json-schema": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/mock-json-schema/-/mock-json-schema-1.1.1.tgz", - "integrity": "sha512-YV23vlsLP1EEOy0EviUvZTluXjLR+rhMzeayP2rcDiezj3RW01MhOSQkbQskdtg0K2fnGas5LKbSXgNjAOSX4A==", "license": "MIT", "dependencies": { "lodash": "^4.17.21" @@ -2663,8 +2202,6 @@ }, "node_modules/morgan": { "version": "1.10.1", - "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", - "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", "license": "MIT", "dependencies": { "basic-auth": "~2.0.1", @@ -2679,8 +2216,6 @@ }, "node_modules/morgan/node_modules/debug": { "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "dependencies": { "ms": "2.0.0" @@ -2688,14 +2223,10 @@ }, "node_modules/morgan/node_modules/ms": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, "node_modules/morgan/node_modules/on-finished": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", "license": "MIT", "dependencies": { "ee-first": "1.1.1" @@ -2706,14 +2237,10 @@ }, "node_modules/ms": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, "node_modules/mysql2": { "version": "3.15.3", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", - "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2733,8 +2260,6 @@ }, "node_modules/named-placeholders": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", - "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2746,8 +2271,6 @@ }, "node_modules/named-placeholders/node_modules/lru-cache": { "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", "devOptional": true, "license": "ISC", "engines": { @@ -2756,8 +2279,6 @@ }, "node_modules/negotiator": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -2765,8 +2286,6 @@ }, "node_modules/node-fetch": { "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", "dependencies": { "whatwg-url": "^5.0.0" @@ -2785,15 +2304,11 @@ }, "node_modules/node-fetch-native": { "version": "1.6.7", - "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", - "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", "devOptional": true, "license": "MIT" }, "node_modules/nypm": { "version": "0.6.2", - "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", - "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2812,8 +2327,6 @@ }, "node_modules/oauth4webapi": { "version": "3.8.3", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.3.tgz", - "integrity": "sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -2821,8 +2334,6 @@ }, "node_modules/object-inspect": { "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -2833,8 +2344,6 @@ }, "node_modules/object-keys": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -2842,8 +2351,6 @@ }, "node_modules/octokit": { "version": "5.0.5", - "resolved": "https://registry.npmjs.org/octokit/-/octokit-5.0.5.tgz", - "integrity": "sha512-4+/OFSqOjoyULo7eN7EA97DE0Xydj/PW5aIckxqQIoFjFwqXKuFCvXUJObyJfBF9Khu4RL/jlDRI9FPaMGfPnw==", "license": "MIT", "dependencies": { "@octokit/app": "^16.1.2", @@ -2864,15 +2371,11 @@ }, "node_modules/ohash": { "version": "2.0.11", - "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", - "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", "devOptional": true, "license": "MIT" }, "node_modules/on-finished": { "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "license": "MIT", "dependencies": { "ee-first": "1.1.1" @@ -2883,8 +2386,6 @@ }, "node_modules/on-headers": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -2892,8 +2393,6 @@ }, "node_modules/once": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "license": "ISC", "dependencies": { "wrappy": "1" @@ -2901,8 +2400,6 @@ }, "node_modules/open": { "version": "7.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", - "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", "license": "MIT", "dependencies": { "is-docker": "^2.0.0", @@ -2917,8 +2414,6 @@ }, "node_modules/openapi-backend": { "version": "5.15.0", - "resolved": "https://registry.npmjs.org/openapi-backend/-/openapi-backend-5.15.0.tgz", - "integrity": "sha512-yox0nCv511YWUeBNCdKY6xmUB92yEN+N9rHO4BHA5GOAZaNtY+zzuftAdfEwIbCsCcvZJ9ysENCguqBg+hLlWw==", "license": "MIT", "dependencies": { "@apidevtools/json-schema-ref-parser": "^11.1.0", @@ -2941,8 +2436,6 @@ }, "node_modules/openapi-backend/node_modules/cookie": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", "license": "MIT", "engines": { "node": ">=18" @@ -2950,8 +2443,6 @@ }, "node_modules/openapi-schema-validator": { "version": "12.1.3", - "resolved": "https://registry.npmjs.org/openapi-schema-validator/-/openapi-schema-validator-12.1.3.tgz", - "integrity": "sha512-xTHOmxU/VQGUgo7Cm0jhwbklOKobXby+/237EG967+3TQEYJztMgX9Q5UE2taZKwyKPUq0j11dngpGjUuxz1hQ==", "license": "MIT", "dependencies": { "ajv": "^8.1.0", @@ -2962,14 +2453,10 @@ }, "node_modules/openapi-types": { "version": "12.1.3", - "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", - "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", "license": "MIT" }, "node_modules/openid-client": { "version": "6.8.1", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.1.tgz", - "integrity": "sha512-VoYT6enBo6Vj2j3Q5Ec0AezS+9YGzQo1f5Xc42lreMGlfP4ljiXPKVDvCADh+XHCV/bqPu/wWSiCVXbJKvrODw==", "license": "MIT", "dependencies": { "jose": "^6.1.0", @@ -2981,8 +2468,6 @@ }, "node_modules/parseurl": { "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -2990,8 +2475,6 @@ }, "node_modules/patch-package": { "version": "8.0.1", - "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", - "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", "license": "MIT", "dependencies": { "@yarnpkg/lockfile": "^1.1.0", @@ -3019,8 +2502,6 @@ }, "node_modules/path-key": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "license": "MIT", "engines": { "node": ">=8" @@ -3028,8 +2509,6 @@ }, "node_modules/path-to-regexp": { "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", "license": "MIT", "engines": { "node": ">=16" @@ -3037,22 +2516,16 @@ }, "node_modules/pathe": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "devOptional": true, "license": "MIT" }, "node_modules/perfect-debounce": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", "devOptional": true, "license": "MIT" }, "node_modules/pg": { "version": "8.16.3", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", - "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", "dependencies": { "pg-connection-string": "^2.9.1", @@ -3078,21 +2551,15 @@ }, "node_modules/pg-cloudflare": { "version": "1.2.7", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", - "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", "license": "MIT", "optional": true }, "node_modules/pg-connection-string": { "version": "2.9.1", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", - "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", "license": "MIT" }, "node_modules/pg-int8": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", "license": "ISC", "engines": { "node": ">=4.0.0" @@ -3100,8 +2567,6 @@ }, "node_modules/pg-pool": { "version": "3.10.1", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", - "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", "license": "MIT", "peerDependencies": { "pg": ">=8.0" @@ -3109,14 +2574,10 @@ }, "node_modules/pg-protocol": { "version": "1.10.3", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", - "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", "license": "MIT" }, "node_modules/pg-types": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", "license": "MIT", "dependencies": { "pg-int8": "1.0.1", @@ -3131,8 +2592,6 @@ }, "node_modules/pgpass": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", "license": "MIT", "dependencies": { "split2": "^4.1.0" @@ -3140,8 +2599,6 @@ }, "node_modules/picomatch": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -3152,8 +2609,6 @@ }, "node_modules/pkg-types": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", - "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3164,8 +2619,6 @@ }, "node_modules/postgres": { "version": "3.4.7", - "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", - "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", "devOptional": true, "license": "Unlicense", "engines": { @@ -3178,8 +2631,6 @@ }, "node_modules/postgres-array": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", "license": "MIT", "engines": { "node": ">=4" @@ -3187,8 +2638,6 @@ }, "node_modules/postgres-bytea": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", - "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3196,8 +2645,6 @@ }, "node_modules/postgres-date": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3205,8 +2652,6 @@ }, "node_modules/postgres-interval": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", "license": "MIT", "dependencies": { "xtend": "^4.0.0" @@ -3217,8 +2662,6 @@ }, "node_modules/prisma": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.1.0.tgz", - "integrity": "sha512-dy/3urE4JjhdiW5b09pGjVhGI7kPESK2VlCDrCqeYK5m5SslAtG5FCGnZWP7E8Sdg+Ow1wV2mhJH5RTFL5gEsw==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -3251,8 +2694,6 @@ }, "node_modules/prisma-json-types-generator": { "version": "4.0.0-beta.1", - "resolved": "https://registry.npmjs.org/prisma-json-types-generator/-/prisma-json-types-generator-4.0.0-beta.1.tgz", - "integrity": "sha512-JUpTlZ6QGWRyU5+Iz4zAfRYalK4Z744VRvDG4Gb3pW4R1bi5NRRVtOT2N+CirOEm0Nj0zU3zu90r/0z6+gwR6g==", "dev": true, "license": "MIT", "dependencies": { @@ -3278,8 +2719,6 @@ }, "node_modules/proper-lockfile": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", - "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3290,15 +2729,11 @@ }, "node_modules/proper-lockfile/node_modules/signal-exit": { "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "devOptional": true, "license": "ISC" }, "node_modules/proxy-addr": { "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -3310,8 +2745,6 @@ }, "node_modules/pump": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", - "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -3320,8 +2753,6 @@ }, "node_modules/pure-rand": { "version": "6.1.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", - "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", "devOptional": true, "funding": [ { @@ -3337,8 +2768,6 @@ }, "node_modules/qs": { "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -3352,8 +2781,6 @@ }, "node_modules/random-bytes": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", - "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -3361,8 +2788,6 @@ }, "node_modules/range-parser": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -3370,8 +2795,6 @@ }, "node_modules/raw-body": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -3385,8 +2808,6 @@ }, "node_modules/rc9": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", - "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3396,8 +2817,6 @@ }, "node_modules/react": { "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", - "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "devOptional": true, "license": "MIT", "peer": true, @@ -3407,8 +2826,6 @@ }, "node_modules/react-dom": { "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", - "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "devOptional": true, "license": "MIT", "peer": true, @@ -3421,8 +2838,6 @@ }, "node_modules/readdirp": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "devOptional": true, "license": "MIT", "engines": { @@ -3435,15 +2850,11 @@ }, "node_modules/regexp-to-ast": { "version": "0.5.0", - "resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz", - "integrity": "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==", "devOptional": true, "license": "MIT" }, "node_modules/remeda": { "version": "2.21.3", - "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.21.3.tgz", - "integrity": "sha512-XXrZdLA10oEOQhLLzEJEiFFSKi21REGAkHdImIb4rt/XXy8ORGXh5HCcpUOsElfPNDb+X6TA/+wkh+p2KffYmg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3452,8 +2863,6 @@ }, "node_modules/require-from-string": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3461,8 +2870,6 @@ }, "node_modules/retry": { "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", "devOptional": true, "license": "MIT", "engines": { @@ -3471,14 +2878,10 @@ }, "node_modules/rfc4648": { "version": "1.5.4", - "resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.4.tgz", - "integrity": "sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==", "license": "MIT" }, "node_modules/router": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -3493,8 +2896,6 @@ }, "node_modules/safe-buffer": { "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "funding": [ { "type": "github", @@ -3513,22 +2914,16 @@ }, "node_modules/safer-buffer": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, "node_modules/scheduler": { "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "devOptional": true, "license": "MIT", "peer": true }, "node_modules/semver": { "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -3539,8 +2934,6 @@ }, "node_modules/send": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", "license": "MIT", "dependencies": { "debug": "^4.3.5", @@ -3561,14 +2954,10 @@ }, "node_modules/seq-queue": { "version": "0.0.5", - "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", - "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", "devOptional": true }, "node_modules/serve-static": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", "license": "MIT", "dependencies": { "encodeurl": "^2.0.0", @@ -3582,8 +2971,6 @@ }, "node_modules/set-function-length": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -3599,14 +2986,10 @@ }, "node_modules/setprototypeof": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, "node_modules/shebang-command": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -3617,8 +3000,6 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "license": "MIT", "engines": { "node": ">=8" @@ -3626,8 +3007,6 @@ }, "node_modules/side-channel": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3645,8 +3024,6 @@ }, "node_modules/side-channel-list": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3661,8 +3038,6 @@ }, "node_modules/side-channel-map": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -3679,8 +3054,6 @@ }, "node_modules/side-channel-weakmap": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -3698,8 +3071,6 @@ }, "node_modules/signal-exit": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "devOptional": true, "license": "ISC", "engines": { @@ -3711,8 +3082,6 @@ }, "node_modules/slash": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", - "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", "license": "MIT", "engines": { "node": ">=6" @@ -3720,8 +3089,6 @@ }, "node_modules/smart-buffer": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "license": "MIT", "engines": { "node": ">= 6.0.0", @@ -3730,8 +3097,6 @@ }, "node_modules/socks": { "version": "2.8.4", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", - "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", "license": "MIT", "dependencies": { "ip-address": "^9.0.5", @@ -3744,8 +3109,6 @@ }, "node_modules/socks-proxy-agent": { "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -3758,8 +3121,6 @@ }, "node_modules/split2": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "license": "ISC", "engines": { "node": ">= 10.x" @@ -3767,14 +3128,10 @@ }, "node_modules/sprintf-js": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "license": "BSD-3-Clause" }, "node_modules/sqlstring": { "version": "2.3.3", - "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", - "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", "devOptional": true, "license": "MIT", "engines": { @@ -3783,8 +3140,6 @@ }, "node_modules/statuses": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -3792,15 +3147,11 @@ }, "node_modules/std-env": { "version": "3.9.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", - "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", "devOptional": true, "license": "MIT" }, "node_modules/stream-buffers": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-3.0.3.tgz", - "integrity": "sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==", "license": "Unlicense", "engines": { "node": ">= 0.10.0" @@ -3808,8 +3159,6 @@ }, "node_modules/streamx": { "version": "2.22.1", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz", - "integrity": "sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==", "license": "MIT", "dependencies": { "fast-fifo": "^1.3.2", @@ -3821,8 +3170,6 @@ }, "node_modules/tar-fs": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", - "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", "license": "MIT", "dependencies": { "pump": "^3.0.0", @@ -3835,8 +3182,6 @@ }, "node_modules/tar-stream": { "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", "license": "MIT", "dependencies": { "b4a": "^1.6.4", @@ -3846,8 +3191,6 @@ }, "node_modules/text-decoder": { "version": "1.2.3", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", - "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.4" @@ -3855,8 +3198,6 @@ }, "node_modules/tinyexec": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", "devOptional": true, "license": "MIT", "engines": { @@ -3865,8 +3206,6 @@ }, "node_modules/tmp": { "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", "license": "MIT", "engines": { "node": ">=14.14" @@ -3874,8 +3213,6 @@ }, "node_modules/to-regex-range": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -3886,8 +3223,6 @@ }, "node_modules/toad-cache": { "version": "3.7.0", - "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", - "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", "license": "MIT", "engines": { "node": ">=12" @@ -3895,8 +3230,6 @@ }, "node_modules/toidentifier": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "license": "MIT", "engines": { "node": ">=0.6" @@ -3904,14 +3237,10 @@ }, "node_modules/tr46": { "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, "node_modules/try": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/try/-/try-1.0.1.tgz", - "integrity": "sha512-3S6RIoraErJFhkNmlNyojU9YmaEs6M2qvAyy7lSb6PgSbiX5hOgyeLVsgwJ74lCSMLxjCggtgiaOJ4BtCV7LNA==", "dev": true, "license": "MIT", "funding": { @@ -3920,15 +3249,11 @@ }, "node_modules/tslib": { "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, "license": "0BSD" }, "node_modules/type-fest": { "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "devOptional": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -3940,8 +3265,6 @@ }, "node_modules/type-is": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "license": "MIT", "dependencies": { "content-type": "^1.0.5", @@ -3954,8 +3277,6 @@ }, "node_modules/typescript": { "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", "bin": { @@ -3968,8 +3289,6 @@ }, "node_modules/uid-safe": { "version": "2.1.5", - "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", - "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", "license": "MIT", "dependencies": { "random-bytes": "~1.0.0" @@ -3980,26 +3299,18 @@ }, "node_modules/undici-types": { "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, "node_modules/universal-github-app-jwt": { "version": "2.2.2", - "resolved": "https://registry.npmjs.org/universal-github-app-jwt/-/universal-github-app-jwt-2.2.2.tgz", - "integrity": "sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw==", "license": "MIT" }, "node_modules/universal-user-agent": { "version": "7.0.3", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", - "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", "license": "ISC" }, "node_modules/universalify": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "license": "MIT", "engines": { "node": ">= 10.0.0" @@ -4007,8 +3318,6 @@ }, "node_modules/unpipe": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -4016,8 +3325,6 @@ }, "node_modules/valibot": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", - "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", "devOptional": true, "license": "MIT", "peerDependencies": { @@ -4031,8 +3338,6 @@ }, "node_modules/vary": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -4040,14 +3345,10 @@ }, "node_modules/webidl-conversions": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, "node_modules/whatwg-url": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "license": "MIT", "dependencies": { "tr46": "~0.0.3", @@ -4056,8 +3357,6 @@ }, "node_modules/which": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -4071,14 +3370,10 @@ }, "node_modules/wrappy": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, "node_modules/ws": { "version": "8.18.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", - "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -4098,29 +3393,28 @@ }, "node_modules/xtend": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", "license": "MIT", "engines": { "node": ">=0.4" } }, "node_modules/yaml": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", - "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/zeptomatch": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.0.2.tgz", - "integrity": "sha512-H33jtSKf8Ijtb5BW6wua3G5DhnFjbFML36eFu+VdOoVY4HD9e7ggjqdM6639B+L87rjnR6Y+XeRzBXZdy52B/g==", "devOptional": true, "license": "MIT", "dependencies": { diff --git a/backend/package.json b/backend/package.json index e1419487..e9fe5b1a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -29,7 +29,8 @@ "openapi-backend": "^5.15.0", "openid-client": "^6.8.1", "patch-package": "^8.0.1", - "pg": "^8.16.3" + "pg": "^8.16.3", + "yaml": "^2.8.2" }, "devDependencies": { "@types/connect-pg-simple": "^7.0.3", diff --git a/backend/src/handlers/index.ts b/backend/src/handlers/index.ts index 40dc96a0..928c137c 100644 --- a/backend/src/handlers/index.ts +++ b/backend/src/handlers/index.ts @@ -31,6 +31,7 @@ import { importGitRepo, importGitRepoCreateState } from "./importGitRepo.ts"; import { ingestLogs } from "./ingestLogs.ts"; import { inviteUser } from "./inviteUser.ts"; import { isSubdomainAvailable } from "./isSubdomainAvailable.ts"; +import { listCharts } from "./listCharts.ts"; import { listDeployments } from "./listDeployments.ts"; import { listOrgGroups } from "./listOrgGroups.ts"; import { listOrgRepos } from "./listOrgRepos.ts"; @@ -81,6 +82,7 @@ export const handlers = { ingestLogs, inviteUser, isSubdomainAvailable, + listCharts, listDeployments, listOrgGroups, listOrgRepos, diff --git a/backend/src/handlers/listCharts.ts b/backend/src/handlers/listCharts.ts new file mode 100644 index 00000000..060ddad0 --- /dev/null +++ b/backend/src/handlers/listCharts.ts @@ -0,0 +1,23 @@ +import { env } from "../lib/env.ts"; +import { getChart } from "../lib/helm.ts"; +import { getRepositoriesByProject } from "../lib/registry.ts"; +import { json, type HandlerMap } from "../types.ts"; + +export const listCharts: HandlerMap["listCharts"] = async (ctx, req, res) => { + const repos = await getRepositoriesByProject(env.CHART_PROJECT_NAME); + const charts = await Promise.all( + repos.map(async (repo) => { + const url = `oci://${env.REGISTRY_HOSTNAME}/${repo.name}`; + const chart = await getChart(url); + return { + name: chart.name, + note: chart.annotations["anvilops-note"], + url, + urlType: "oci", + version: chart.version, + valueSpec: JSON.parse(chart.annotations["anvilops-values"] ?? ""), + }; + }), + ); + return json(200, res, charts); +}; diff --git a/backend/src/lib/cluster/resources/extension.ts b/backend/src/lib/cluster/resources/extension.ts deleted file mode 100644 index 3010621b..00000000 --- a/backend/src/lib/cluster/resources/extension.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { spawn } from "child_process"; - -export const runHelm = ({ - chartURL, - namespace, - values, - release, -}: { - chartURL: string; - namespace: string; - values: { [key: string]: string }; - release: string; -}) => { - const kvPairs = Object.keys(values).map((key, value) => `${key}=${value}`); - const args = [ - "upgrade", - "--install", - release, - chartURL, - "--namespace", - namespace, - "--create-namespace", - "--set", - kvPairs.join(","), - ]; - - return new Promise((resolve, reject) => { - const p = spawn("helm", args, { stdio: ["ignore", "pipe", "pipe"] }); - let out = "", - err = ""; - p.stdout.on("data", (d) => (out += d)); - p.stderr.on("data", (d) => (err += d)); - p.on("close", (code) => - code === 0 - ? resolve({ out }) - : reject(new Error(err || `helm exit ${code}`)), - ); - }); -}; diff --git a/backend/src/lib/env.ts b/backend/src/lib/env.ts index 24ecab0e..ba93042b 100644 --- a/backend/src/lib/env.ts +++ b/backend/src/lib/env.ts @@ -149,6 +149,14 @@ const variables = { * The Kubernetes namespace that all AnvilOps jobs should run in, e.g. anvilops-dev */ CURRENT_NAMESPACE: { required: true }, + /** + * The name of the project in which custom AnvilOps charts are stored. + */ + CHART_PROJECT_NAME: { required: false }, + /** + * The base URL of the registry's API, e.g. https://registry.anvil.rcac.purdue.edu/api/v2.0 + */ + REGISTRY_API_URL: { required: true }, /** * The hostname for the image registry, e.g. registry.anvil.rcac.purdue.edu */ diff --git a/backend/src/lib/helm.ts b/backend/src/lib/helm.ts new file mode 100644 index 00000000..3488ddc8 --- /dev/null +++ b/backend/src/lib/helm.ts @@ -0,0 +1,87 @@ +import { spawn } from "child_process"; +import { parse as yamlParse } from "yaml"; + +type Dependency = { + name: string; + version: string; + repository?: string; + condition?: string; + tags?: string[]; + "import-values"?: string; + alias?: string; +}; + +type Chart = { + apiVersion: string; + name: string; + version: string; + kubeVersion?: string; + description?: string; + type?: string; + keywords?: string[]; + home?: string; + sources?: string[]; + dependencies?: Dependency[]; + maintainers?: { name: string; email: string; url: string }[]; + icon?: string; + appVersion?: string; + deprecated?: boolean; + annotations?: Record; +}; + +const runHelm = (args: string[]) => { + return new Promise((resolve, reject) => { + const p = spawn("helm", args, { stdio: ["ignore", "pipe", "pipe"] }); + let out = "", + err = ""; + p.stdout.on("data", (d) => (out += d)); + p.stderr.on("data", (d) => (err += d)); + p.on("close", (code) => + code === 0 + ? resolve({ out }) + : reject(new Error(err || `helm exit ${code}`)), + ); + }); +}; + +export const getChart = async ( + url: string, + version?: string, +): Promise => { + const args = ["show", "chart"]; + if (version) { + args.push("version", version); + } + args.push(url); + + const result = (await runHelm(args)) as string; + const chart = (await yamlParse(result)) as Chart; + return chart; +}; + +export const upgrade = ({ + chartURL, + namespace, + values, + release, +}: { + chartURL: string; + namespace: string; + values: { [key: string]: string }; + release: string; +}) => { + const kvPairs = Object.keys(values).map((key, value) => `${key}=${value}`); + const args = [ + "upgrade", + "--install", + release, + chartURL, + "--namespace", + namespace, + "--create-namespace", + "--set", + kvPairs.join(","), + ]; + + return runHelm(args); +}; diff --git a/backend/src/lib/registry.ts b/backend/src/lib/registry.ts index 53161a53..a3946cc6 100644 --- a/backend/src/lib/registry.ts +++ b/backend/src/lib/registry.ts @@ -11,7 +11,7 @@ export async function deleteRepo(name: string) { } await fetch( - `${host}/api/v2.0/projects/${env.HARBOR_PROJECT_NAME}/repositories/${name}`, + `${env.REGISTRY_API_URL}/projects/${env.HARBOR_PROJECT_NAME}/repositories/${name}`, { method: "DELETE", headers, @@ -23,3 +23,25 @@ export async function deleteRepo(name: string) { } }); } + +type HarborRepository = { + artifact_count: number; + creation_time: string; + id: number; + name: string; + project_id: number; + pull_count: number; + update_time: string; +}; + +export async function getRepositoriesByProject(projectName: string) { + return fetch(`${env.REGISTRY_API_URL}/projects/${projectName}/repositories`) + .then((res) => res.text()) + .then((res) => JSON.parse(res)) + .then((res) => { + if ("errors" in res) { + throw res; + } + return res as HarborRepository[]; + }); +} diff --git a/charts/spilo/Chart.yaml b/charts/spilo/Chart.yaml index 7e513f7e..61a3235f 100644 --- a/charts/spilo/Chart.yaml +++ b/charts/spilo/Chart.yaml @@ -2,6 +2,41 @@ apiVersion: v1 name: spilo description: A minimal Spilo/Patroni HA Postgres deployment. type: application -version: 0.1.0 +version: 0.1.1 annotations: - note: Requires permission to create Roles and RoleBindings. Rancher Project Owners have this permission. \ No newline at end of file + anvilops-note: | + Requires permission to create Roles and RoleBindings. Rancher Project Owners have this permission. + The usernames of the superuser, admin, and replication user respectively are 'postgres', 'admin', and 'standby'. + anvilops-values: | + { + "passwords": [ + { + "name": "superuser", + "displayName": "Superuser Password", + "random": true + }, + { + "name": "replication", + "displayName": "Replication Password", + "random": true + }, + { + "name": "admin", + "displayName": "Admin Password", + "random": "true" + } + ], + "storage": [ + { + "name": "className", + "displayName": "Storage Class", + "default": "standard" + }, + { + "name": "size", + "displayName": "Size", + "type": "number", + "unit": "Gi" + } + ] + } \ No newline at end of file diff --git a/charts/spilo/values.schema.json b/charts/spilo/values.schema.json new file mode 100644 index 00000000..dd9f94dc --- /dev/null +++ b/charts/spilo/values.schema.json @@ -0,0 +1,48 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "required": ["passwords", "storage"], + "properties": { + "passwords": { + "type": "object", + "required": ["superuser", "replication", "admin"], + "properties": { + "superuser": { + "type": "string" + }, + "replication": { + "type": "string" + }, + "admin": { + "type": "string" + } + } + }, + "storage": { + "type": "object", + "required": ["className", "size"], + "properties": { + "className": { + "type": "string" + }, + "sizeInGi": { + "type": "number" + } + } + }, + "image": { + "type": "object", + "required": ["repository", "tag"], + "properties": { + "repository": { + "type": "string" + }, + "tag": { + "type": "string" + } + } + }, + "replicas": { + "type": "number" + } + } +} diff --git a/charts/spilo/values.yaml b/charts/spilo/values.yaml index be36de0d..997fe7d0 100644 --- a/charts/spilo/values.yaml +++ b/charts/spilo/values.yaml @@ -1,11 +1,6 @@ -passwords: - superuser: "change-me" - replication: "change-me" - admin: "change-me" +passwords: {} -storage: - className: "standard" - size: "5Gi" +storage: {} image: repository: registry.opensource.zalan.do/acid/spilo-11 diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 6ce6b53d..9d46341f 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -1828,6 +1828,35 @@ paths: - subdomain - env - mounts + + /templates/charts: + description: List custom AnvilOps helm charts. + get: + operationId: listCharts + responses: + "200": + description: Success + content: + application/json: + schema: + type: array + items: + type: object + properties: + name: + type: string + note: + type: string + url: + type: string + urlType: + type: string + version: + type: string + valueSpec: + type: object + additionalProperties: true + required: [name, note, url, urlType, version] components: schemas: UserOrg: @@ -2131,11 +2160,13 @@ components: - config DeploymentConfig: type: object - allOf: - - oneOf: - - $ref: "#/components/schemas/GitDeploymentOptions" - - $ref: "#/components/schemas/ImageDeploymentOptions" - - $ref: "#/components/schemas/KnownDeploymentOptions" + oneOf: + - allOf: + - oneOf: + - $ref: "#/components/schemas/GitDeploymentOptions" + - $ref: "#/components/schemas/ImageDeploymentOptions" + - $ref: "#/components/schemas/KnownDeploymentOptions" + - $ref: "#/components/schemas/HelmDeploymentOptions" KnownDeploymentOptions: type: object properties: @@ -2229,6 +2260,23 @@ components: type: string # Deployment options required: [source, imageTag] + HelmDeploymentOptions: + properties: + source: + type: string + enum: [helm] + url: + type: string + urlType: + type: string + enum: [absolute, oci, repository] + version: + type: string + values: + type: object + additionalProperties: true + required: [source, url, urlType] + Mount: type: object properties: From afc4709c63cc835266b83609fd9a98da0dc746de Mon Sep 17 00:00:00 2001 From: zheng861 Date: Sun, 21 Dec 2025 18:37:39 -0700 Subject: [PATCH 05/38] Add Helm config model --- backend/prisma/schema.prisma | 42 ++++++++-- backend/prisma/types.ts | 4 - backend/src/db/models.ts | 15 +++- backend/src/db/repo/app.ts | 20 +++-- backend/src/db/repo/deployment.ts | 24 ++---- openapi/openapi.yaml | 131 +++++++++++++----------------- 6 files changed, 127 insertions(+), 109 deletions(-) diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 177f99cb..62687d10 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -138,6 +138,11 @@ enum ImageBuilder { railpack } +enum AppType { + WORKLOAD + HELM +} + enum DeploymentSource { GIT IMAGE @@ -199,12 +204,25 @@ model Deployment { } model DeploymentConfig { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) + deployment Deployment? + appType AppType + workloadConfig WorkloadConfig? @relation(fields: [workloadConfigId], references: [id], onDelete: Cascade) + workloadConfigId Int? @unique + + helmConfig HelmConfig? @relation(fields: [helmConfigId], references: [id], onDelete: Cascade) + helmConfigId Int? @unique + app App? +} + +model WorkloadConfig { + id Int @id @default(autoincrement()) // Deployment options /// [EnvVar[]] - env Json @default("[]") - envKey String @default("") - deployment Deployment? + env Json @default("[]") + envKey String @default("") + deploymentConfig DeploymentConfig? + // Build options source DeploymentSource // > Git deployment source @@ -234,9 +252,21 @@ model DeploymentConfig { limits Json ///[Resources] requests Json +} + +enum HelmUrlType { + OCI + REPOSITORY + ABSOLUTE +} - // Reverse relation field for when this DeploymentConfig is an App's template config - app App? +model HelmConfig { + id Int @id @default(autoincrement()) + deploymentConfig DeploymentConfig? + url String + version String + urlType HelmUrlType + values Json? } model Log { diff --git a/backend/prisma/types.ts b/backend/prisma/types.ts index 1405810f..5a063008 100644 --- a/backend/prisma/types.ts +++ b/backend/prisma/types.ts @@ -16,9 +16,5 @@ declare global { }; type VolumeMount = { path: string; amountInMiB: number }; - - type AppFlags = { - enableCD: boolean; - }; } } diff --git a/backend/src/db/models.ts b/backend/src/db/models.ts index 1d945255..ae0572c3 100644 --- a/backend/src/db/models.ts +++ b/backend/src/db/models.ts @@ -1,6 +1,7 @@ import type { DeploymentSource, DeploymentStatus, + HelmUrlType, ImageBuilder, PermissionLevel, WebhookEvent, @@ -97,7 +98,7 @@ export interface DeploymentWithSourceInfo extends Omit { source?: DeploymentSource; } -export interface DeploymentConfig { +export interface WorkloadConfig { id: number; displayEnv: PrismaJson.EnvVar[]; getEnv(): PrismaJson.EnvVar[]; @@ -121,13 +122,21 @@ export interface DeploymentConfig { mounts: PrismaJson.VolumeMount[]; } -export type DeploymentConfigCreate = Omit< - DeploymentConfig, +export type WorkloadConfigCreate = Omit< + WorkloadConfig, "id" | "displayEnv" | "getEnv" > & { env: PrismaJson.EnvVar[]; }; +export type HelmConfig = { + id: number; + url: string; + version: string; + urlType: HelmUrlType; + values?: any; +}; + export interface Log { id: number; type: "BUILD" | "RUNTIME"; diff --git a/backend/src/db/repo/app.ts b/backend/src/db/repo/app.ts index 3b0017c5..5f2406a8 100644 --- a/backend/src/db/repo/app.ts +++ b/backend/src/db/repo/app.ts @@ -60,11 +60,14 @@ export class AppRepo { return await this.client.app.findMany({ where: { config: { - source: DeploymentSource.GIT, - repositoryId: repoId, - event, - eventId, - branch, + appType: "WORKLOAD", + workloadConfig: { + source: DeploymentSource.GIT, + repositoryId: repoId, + event, + eventId, + branch, + }, }, org: { githubInstallationId: { not: null } }, enableCD: true, @@ -75,7 +78,12 @@ export class AppRepo { async isSubdomainInUse(subdomain: string): Promise { return ( (await this.client.app.count({ - where: { config: { subdomain: subdomain } }, + where: { + config: { + appType: "WORKLOAD", + workloadConfig: { subdomain }, + }, + }, })) > 0 ); } diff --git a/backend/src/db/repo/deployment.ts b/backend/src/db/repo/deployment.ts index 78fc3223..15a2074b 100644 --- a/backend/src/db/repo/deployment.ts +++ b/backend/src/db/repo/deployment.ts @@ -10,13 +10,7 @@ import { } from "../../generated/prisma/models/DeploymentConfig.ts"; import { decryptEnv, encryptEnv, generateKey } from "../crypto.ts"; import type { PrismaClientType } from "../index.ts"; -import type { - Deployment, - DeploymentConfig, - DeploymentConfigCreate, - DeploymentWithSourceInfo, - Log, -} from "../models.ts"; +import type { Deployment, DeploymentWithSourceInfo, Log } from "../models.ts"; export class DeploymentRepo { private client: PrismaClientType; @@ -298,10 +292,9 @@ export class DeploymentRepo { include: { config: { select: { - source: true, - commitHash: true, - imageTag: true, - repositoryId: true, + appType: true, + workloadConfig: true, + helmConfig: true, }, }, }, @@ -313,10 +306,11 @@ export class DeploymentRepo { return deployments.map((deployment) => ({ ...deployment, config: undefined, - source: deployment.config.source, - commitHash: deployment.config.commitHash, - imageTag: deployment.config.imageTag, - repositoryId: deployment.config.repositoryId, + appType: deployment.config.appType, + source: deployment.config.workloadConfig.source, + commitHash: deployment.config.workloadConfig.commitHash, + imageTag: deployment.config.workloadConfig.imageTag, + repositoryId: deployment.config.workloadConfig.repositoryId, })); } } diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 9d46341f..1e985711 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -2161,16 +2161,22 @@ components: DeploymentConfig: type: object oneOf: - - allOf: - - oneOf: - - $ref: "#/components/schemas/GitDeploymentOptions" - - $ref: "#/components/schemas/ImageDeploymentOptions" - - $ref: "#/components/schemas/KnownDeploymentOptions" - - $ref: "#/components/schemas/HelmDeploymentOptions" + - $ref: "#/components/schemas/WorkloadConfigOptions" + - $ref: "#/components/schemas/HelmConfigOptions" + WorkloadConfigOptions: + type: object + allOf: + - oneOf: + - $ref: "#/components/schemas/GitDeploymentOptions" + - $ref: "#/components/schemas/ImageDeploymentOptions" + - $ref: "#/components/schemas/KnownDeploymentOptions" KnownDeploymentOptions: type: object properties: # Deployment options + appType: + type: string + enum: [workload] port: type: integer format: int64 @@ -2260,9 +2266,9 @@ components: type: string # Deployment options required: [source, imageTag] - HelmDeploymentOptions: + HelmConfigOptions: properties: - source: + appType: type: string enum: [helm] url: @@ -2407,73 +2413,48 @@ components: enum: [BUILD, RUNTIME] NewApp: type: object - allOf: - - type: object - properties: - name: - type: string - # Source options - orgId: - type: integer - format: int64 - projectId: - type: string - subdomain: - type: string - format: hostname - port: - type: integer - format: int64 - env: - $ref: "#/components/schemas/Envs" - mounts: - type: array - items: - $ref: "#/components/schemas/Mount" - cpuCores: - type: number - memoryInMiB: - type: integer - appGroup: - oneOf: - - type: object - properties: - type: - type: string - enum: [standalone] - required: [type] - - type: object - properties: - type: - type: string - enum: [create-new] - name: - type: string - required: [type, name] - - type: object - properties: - type: - type: string - enum: [add-to] - id: - type: integer - format: int64 - required: [type, id] - createIngress: - type: boolean - required: - - name - - orgId - - port - - env - - mounts - - appGroup - - cpuCores - - memoryInMiB - - createIngress - - oneOf: - - $ref: "#/components/schemas/GitDeploymentOptions" - - $ref: "#/components/schemas/ImageDeploymentOptions" + properties: + name: + type: string + orgId: + type: integer + format: int64 + projectId: + type: string + appGroup: + oneOf: + - type: object + properties: + type: + type: string + enum: [standalone] + required: [type] + - type: object + properties: + type: + type: string + enum: [create-new] + name: + type: string + required: [type, name] + - type: object + properties: + type: + type: string + enum: [add-to] + id: + type: integer + format: int64 + required: [type, id] + createIngress: + type: boolean + config: + $ref: "#/components/schemas/DeploymentConfig" + required: + - name + - orgId + - createIngress + - config NewAppWithoutGroupInfo: type: object allOf: From c2d329d8e9edfd0e4e28b2a2e5fafe6b3371aabb Mon Sep 17 00:00:00 2001 From: zheng861 Date: Sun, 21 Dec 2025 23:42:32 -0700 Subject: [PATCH 06/38] Start writing testable deployment controller to handle various app types --- backend/src/db/models.ts | 14 +++ backend/src/db/repo/app.ts | 24 +++- backend/src/db/repo/deployment.ts | 57 ++++++--- backend/src/domain/deployment.ts | 112 ++++++++++++++++++ backend/src/domain/deploymentConfig.ts | 156 +++++++++++++++++++++++++ backend/src/domain/index.ts | 8 ++ backend/src/domain/types.ts | 7 ++ backend/src/handlers/createApp.ts | 127 ++++---------------- backend/src/lib/validate.ts | 138 +--------------------- openapi/openapi.yaml | 3 + 10 files changed, 387 insertions(+), 259 deletions(-) create mode 100644 backend/src/domain/deployment.ts create mode 100644 backend/src/domain/deploymentConfig.ts create mode 100644 backend/src/domain/index.ts create mode 100644 backend/src/domain/types.ts diff --git a/backend/src/db/models.ts b/backend/src/db/models.ts index ae0572c3..2626086c 100644 --- a/backend/src/db/models.ts +++ b/backend/src/db/models.ts @@ -129,6 +129,18 @@ export type WorkloadConfigCreate = Omit< env: PrismaJson.EnvVar[]; }; +export type GitConfigCreate = WorkloadConfigCreate & { + source: "GIT"; + repositoryId: number; + branch: string; + event: WebhookEvent; + eventId?: number; + commitHash: string; + builder: ImageBuilder; + rootDir?: string; + dockerfilePath?: string; +}; + export type HelmConfig = { id: number; url: string; @@ -137,6 +149,8 @@ export type HelmConfig = { values?: any; }; +export type HelmConfigCreate = Omit; + export interface Log { id: number; type: "BUILD" | "RUNTIME"; diff --git a/backend/src/db/repo/app.ts b/backend/src/db/repo/app.ts index 5f2406a8..e7781405 100644 --- a/backend/src/db/repo/app.ts +++ b/backend/src/db/repo/app.ts @@ -15,7 +15,8 @@ import type { App, AppCreate, Deployment, - DeploymentConfig, + HelmConfig, + WorkloadConfig, } from "../models.ts"; import { DeploymentRepo } from "./deployment.ts"; @@ -217,13 +218,28 @@ export class AppRepo { return app.config.deployment; } - async getDeploymentConfig(appId: number): Promise { + async getDeploymentConfig( + appId: number, + ): Promise { const app = await this.client.app.findUnique({ where: { id: appId }, - include: { config: true }, + include: { + config: { + include: { + workloadConfig: true, + helmConfig: true, + }, + }, + }, }); - return DeploymentRepo.preprocessDeploymentConfig(app.config); + if (app.config.appType === "WORKLOAD") { + return DeploymentRepo.preprocessDeploymentConfig( + app.config.workloadConfig, + ); + } else { + return app.config.helmConfig; + } } async setConfig(appId: number, configId: number) { diff --git a/backend/src/db/repo/deployment.ts b/backend/src/db/repo/deployment.ts index 15a2074b..6c383154 100644 --- a/backend/src/db/repo/deployment.ts +++ b/backend/src/db/repo/deployment.ts @@ -5,12 +5,19 @@ import type { PermissionLevel, } from "../../generated/prisma/enums.ts"; import { - type DeploymentConfigCreateInput, - type DeploymentConfigModel as PrismaDeploymentConfig, -} from "../../generated/prisma/models/DeploymentConfig.ts"; + WorkloadConfigCreateInput, + type WorkloadConfigModel as PrismaWorkloadConfig, +} from "../../generated/prisma/models.ts"; import { decryptEnv, encryptEnv, generateKey } from "../crypto.ts"; import type { PrismaClientType } from "../index.ts"; -import type { Deployment, DeploymentWithSourceInfo, Log } from "../models.ts"; +import type { + Deployment, + DeploymentWithSourceInfo, + HelmConfig, + Log, + WorkloadConfig, + WorkloadConfigCreate, +} from "../models.ts"; export class DeploymentRepo { private client: PrismaClientType; @@ -73,7 +80,7 @@ export class DeploymentRepo { status, }: { appId: number; - config: DeploymentConfigCreate; + config: WorkloadConfigCreate; commitMessage: string | null; workflowRunId?: number; status?: DeploymentStatus; @@ -81,7 +88,14 @@ export class DeploymentRepo { return await this.client.deployment.create({ data: { app: { connect: { id: appId } }, - config: { create: DeploymentRepo.encryptEnv(config) }, + config: { + create: { + appType: "WORKLOAD", + workloadConfig: { + create: DeploymentRepo.encryptEnv(config), + }, + }, + }, commitMessage, workflowRunId, secret: randomBytes(32).toString("hex"), @@ -141,27 +155,40 @@ export class DeploymentRepo { }); } - async getConfig(deploymentId: number): Promise { + async getConfig(deploymentId: number): Promise { const deployment = await this.client.deployment.findUnique({ where: { id: deploymentId }, - select: { config: true }, + select: { + config: { + include: { + workloadConfig: true, + helmConfig: true, + }, + }, + }, }); - return DeploymentRepo.preprocessDeploymentConfig(deployment.config); + if (deployment.config.appType === "WORKLOAD") { + return DeploymentRepo.preprocessDeploymentConfig( + deployment.config.workloadConfig, + ); + } + + return deployment.config.helmConfig; } private static encryptEnv( - config: DeploymentConfigCreate, - ): DeploymentConfigCreateInput { - const copy = structuredClone(config) as DeploymentConfigCreateInput; + config: WorkloadConfigCreate, + ): WorkloadConfigCreateInput { + const copy = structuredClone(config) as WorkloadConfigCreateInput; copy.envKey = generateKey(); copy.env = encryptEnv(copy.env, copy.envKey); return copy; } static preprocessDeploymentConfig( - config: PrismaDeploymentConfig, - ): DeploymentConfig { + config: PrismaWorkloadConfig, + ): WorkloadConfig { if (config === null) { return null; } @@ -236,7 +263,7 @@ export class DeploymentRepo { } async unlinkRepositoryFromAllDeployments(repoId: number) { - await this.client.deploymentConfig.updateMany({ + await this.client.workloadConfig.updateMany({ where: { repositoryId: repoId }, data: { repositoryId: null, branch: null, source: "IMAGE" }, }); diff --git a/backend/src/domain/deployment.ts b/backend/src/domain/deployment.ts new file mode 100644 index 00000000..2bc16670 --- /dev/null +++ b/backend/src/domain/deployment.ts @@ -0,0 +1,112 @@ +import { Octokit } from "octokit"; +import { GitConfigCreate } from "../db/models.ts"; +import { components } from "../generated/openapi.ts"; +import { getOctokit, getRepoById } from "../lib/octokit.ts"; +import { type DeploymentConfigValidator } from "./deploymentConfig.ts"; +import { deploymentConfigValidator } from "./index.ts"; +import { GitWorkloadConfig } from "./types.ts"; + +export class DeploymentController { + private readonly validator: DeploymentConfigValidator; + private readonly getOctokitFn: typeof getOctokit; + private readonly getRepoByIdFn: typeof getRepoById; + + constructor( + validator: DeploymentConfigValidator, + getOctokitFn?: typeof getOctokit, + getRepoByIdFn?: typeof getRepoById, + ) { + this.validator = validator; + this.getOctokitFn = getOctokitFn ?? getOctokit; + this.getRepoByIdFn = getRepoByIdFn ?? getRepoById; + } + + async prepareDeploymentMetadata( + config: components["schemas"]["DeploymentConfig"], + orgId: number, + ) { + let commitHash = "unknown", + commitMessage = "Initial deployment"; + + switch (config.source) { + case "git": { + let octokit: Octokit, repo: Awaited>; + + try { + octokit = await this.getOctokitFn(orgId); + repo = await this.getRepoByIdFn(octokit, config.repositoryId); + } catch (err) { + if (err.status === 404) { + throw new Error("Invalid repository id"); + } + + console.error(err); + throw new Error("Failed to look up GitHub repository"); + } + + await this.validator.validateGitConfig(config, octokit, repo); + + const latestCommit = ( + await octokit.rest.repos.listCommits({ + per_page: 1, + owner: repo.owner.login, + repo: repo.name, + }) + ).data[0]; + + commitHash = latestCommit.sha; + commitMessage = latestCommit.commit.message; + + return { + config: this.createGitConfig(config, commitHash, repo.id), + commitMessage, + }; + } + case "image": { + deploymentConfigValidator.validateImageConfig(config); + return { + config: this.createCommonWorkloadConfig(config), + commitMessage, + }; + } + case "helm": { + return { config, commitMessage }; + } + } + } + + createCommonWorkloadConfig( + config: components["schemas"]["WorkloadConfigOptions"], + ) { + return { + collectLogs: config.collectLogs, + createIngress: config.createIngress, + subdomain: config.subdomain, + env: config.env, + requests: config.requests, + limits: config.limits, + replicas: config.replicas, + port: config.port, + mounts: config.mounts, + commitHash: "unknown", + imageTag: config.imageTag, + }; + } + + async createGitConfig( + config: GitWorkloadConfig, + commitHash: string, + repositoryId: number, + ): Promise { + return { + ...this.createCommonWorkloadConfig(config), + source: "GIT", + repositoryId, + branch: config.branch, + event: config.event, + commitHash, + builder: config.builder, + imageTag: undefined, + } satisfies GitConfigCreate; + } +} diff --git a/backend/src/domain/deploymentConfig.ts b/backend/src/domain/deploymentConfig.ts new file mode 100644 index 00000000..72e82639 --- /dev/null +++ b/backend/src/domain/deploymentConfig.ts @@ -0,0 +1,156 @@ +import { Octokit } from "octokit"; +import { AppRepo } from "../db/repo/app.ts"; +import { components } from "../generated/openapi.ts"; +import { MAX_SUBDOMAIN_LEN } from "../lib/cluster/resources.ts"; +import { getImageConfig } from "../lib/cluster/resources/logs.ts"; +import { getRepoById } from "../lib/octokit.ts"; +import { GitWorkloadConfig, ImageWorkloadConfig } from "./types.ts"; + +export class DeploymentConfigValidator { + private appRepo: AppRepo; + constructor(appRepo: AppRepo) { + this.appRepo = appRepo; + } + + async validateCommonWorkloadConfig( + config: components["schemas"]["WorkloadConfigOptions"], + ) { + if (config.subdomain) { + await this.validateSubdomain(config.subdomain); + } + + if (config.port < 0 || config.port > 65535) { + throw new Error("Invalid port number: must be between 0 and 65535"); + } + + this.validateEnv(config.env); + + this.validateMounts(config.mounts); + } + + async validateGitConfig( + config: GitWorkloadConfig, + octokit: Octokit, + repo: Awaited>, + ) { + const { rootDir, builder, dockerfilePath, event, eventId } = config; + if (rootDir.startsWith("/") || rootDir.includes(`"`)) { + throw new Error("Invalid root directory"); + } + if (builder === "dockerfile") { + if (!dockerfilePath) { + throw new Error("Dockerfile path is required"); + } + if (dockerfilePath.startsWith("/") || dockerfilePath.includes(`"`)) { + throw new Error("Invalid Dockerfile path"); + } + } + + if (event === "workflow_run" && eventId === undefined) { + throw new Error("Workflow ID is required"); + } + + if (config.event === "workflow_run" && config.eventId) { + try { + const workflows = await ( + octokit.request({ + method: "GET", + url: `/repositories/${repo.id}/actions/workflows`, + }) as ReturnType + ).then((res) => res.data.workflows); + if (!workflows.some((workflow) => workflow.id === config.eventId)) { + throw new Error("Workflow not found"); + } + } catch (err) { + throw new Error("Failed to look up GitHub workflow"); + } + } + } + + async validateImageConfig(config: ImageWorkloadConfig) { + if (!config.imageTag) { + throw new Error("Image tag is required"); + } + + await this.validateImageReference(config.imageTag); + } + + private validateMounts( + mounts: components["schemas"]["KnownDeploymentOptions"]["mounts"], + ) { + const pathSet = new Set(); + for (const mount of mounts) { + if (!mount.path.startsWith("/")) { + throw new Error( + `Invalid mount path ${mount.path}: must start with '/'`, + ); + } + + if (pathSet.has(mount.path)) { + throw new Error(`Invalid mounts: paths are not unique`); + } + pathSet.add(mount.path); + } + } + + private validateEnv(env: PrismaJson.EnvVar[]) { + if (env?.some((it) => !it.name || it.name.length === 0)) { + return { + valid: false, + message: "Some environment variable(s) are empty", + }; + } + + if (env?.some((it) => it.name.startsWith("_PRIVATE_ANVILOPS_"))) { + // Environment variables with this prefix are used in the log shipper - see log-shipper/main.go + return { + valid: false, + message: + 'Environment variable(s) use reserved prefix "_PRIVATE_ANVILOPS_"', + }; + } + + const envNames = new Set(); + + for (let envVar of env) { + if (envNames.has(envVar.name)) { + return { + valid: false, + message: "Duplicate environment variable " + envVar.name, + }; + } + envNames.add(envVar.name); + } + } + + private async validateImageReference(reference: string) { + try { + // Look up the image in its registry to make sure it exists + await getImageConfig(reference); + } catch (e) { + console.error(e); + throw new Error("Image could not be found in its registry."); + } + } + + private async validateSubdomain(subdomain: string) { + if (subdomain.length > MAX_SUBDOMAIN_LEN || !this.isRFC1123(subdomain)) { + throw new Error( + "Subdomain must contain only lowercase alphanumeric characters or '-', " + + "start and end with an alphanumeric character, " + + `and contain at most ${MAX_SUBDOMAIN_LEN} characters`, + ); + } + + if (await this.appRepo.isSubdomainInUse(subdomain)) { + throw new Error("Subdomain is in use"); + } + } + + private isRFC1123(value: string) { + return ( + value.length <= 63 && + value.match(/[a-zA-Z0-9]([-a-z0-9]*[a-z0-9])?$/) !== null + ); + } +} diff --git a/backend/src/domain/index.ts b/backend/src/domain/index.ts new file mode 100644 index 00000000..a701c75f --- /dev/null +++ b/backend/src/domain/index.ts @@ -0,0 +1,8 @@ +import { db } from "../db/index.ts"; +import { DeploymentController } from "./deployment.ts"; +import { DeploymentConfigValidator } from "./deploymentConfig.ts"; + +export const deploymentConfigValidator = new DeploymentConfigValidator(db.app); +export const deploymentController = new DeploymentController( + deploymentConfigValidator, +); diff --git a/backend/src/domain/types.ts b/backend/src/domain/types.ts new file mode 100644 index 00000000..3f356d76 --- /dev/null +++ b/backend/src/domain/types.ts @@ -0,0 +1,7 @@ +import { components } from "../generated/openapi.ts"; + +export type GitWorkloadConfig = + components["schemas"]["WorkloadConfigOptions"] & { source: "git" }; + +export type ImageWorkloadConfig = + components["schemas"]["WorkloadConfigOptions"] & { source: "image" }; diff --git a/backend/src/handlers/createApp.ts b/backend/src/handlers/createApp.ts index 1f5078bb..55b3fa70 100644 --- a/backend/src/handlers/createApp.ts +++ b/backend/src/handlers/createApp.ts @@ -1,17 +1,15 @@ 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 type { App } from "../db/models.ts"; +import { + deploymentConfigValidator, + deploymentController, +} from "../domain/index.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"; +import { validateAppGroup, validateAppName } from "../lib/validate.ts"; import { json, type HandlerMap } from "../types.ts"; import { buildAndDeploy } from "./githubWebhook.ts"; import { type AuthenticatedRequest } from "./index.ts"; @@ -32,10 +30,11 @@ export const createApp: HandlerMap["createApp"] = async ( } try { - await validateDeploymentConfig({ - ...appData, - collectLogs: true, - }); + if (appData.config.appType === "workload") { + await deploymentConfigValidator.validateCommonWorkloadConfig( + appData.config, + ); + } validateAppGroup(appData.appGroup); validateAppName(appData.name); } catch (e) { @@ -59,98 +58,14 @@ export const createApp: HandlerMap["createApp"] = async ( 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.", - }); - } - - 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); - return json(500, res, { - code: 500, - message: "Failed to look up GitHub repository.", - }); - } - - 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; + if (appData.config.source === "git" && !organization.githubInstallationId) { + return json(403, res, { + code: 403, + message: "The AnvilOps GitHub App is not installed in this organization.", + }); } 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": @@ -172,7 +87,7 @@ export const createApp: HandlerMap["createApp"] = async ( break; } - let namespace = appData.subdomain; + let namespace = appData.config.subdomain; if (await namespaceInUse(getNamespace(namespace))) { namespace += "-" + Math.floor(Math.random() * 10_000); } @@ -202,13 +117,19 @@ export const createApp: HandlerMap["createApp"] = async ( return json(500, res, { code: 500, message: "Unable to create app." }); } + const { config, commitMessage } = + await deploymentController.prepareDeploymentMetadata( + appData.config, + appData.orgId, + ); + try { await buildAndDeploy({ org: organization, app, imageRepo: app.imageRepo, commitMessage: commitMessage, - config: deploymentConfig, + config, createCheckRun: false, }); } catch (e) { diff --git a/backend/src/lib/validate.ts b/backend/src/lib/validate.ts index cb703559..23f60f21 100644 --- a/backend/src/lib/validate.ts +++ b/backend/src/lib/validate.ts @@ -1,67 +1,5 @@ import type { components } from "../generated/openapi.ts"; -import { namespaceInUse } from "./cluster/kubernetes.ts"; -import { - getNamespace, - MAX_GROUPNAME_LEN, - MAX_STS_NAME_LEN, - MAX_SUBDOMAIN_LEN, -} from "./cluster/resources.ts"; -import { getImageConfig } from "./cluster/resources/logs.ts"; - -export async function validateDeploymentConfig( - data: ( - | components["schemas"]["GitDeploymentOptions"] - | components["schemas"]["ImageDeploymentOptions"] - ) & - Omit< - components["schemas"]["KnownDeploymentOptions"], - "replicas" | "postStart" | "preStop" | "requests" | "limits" - >, -) { - const { source, env, mounts, port } = data; - if (source === "git") { - const { builder, dockerfilePath, rootDir, event, eventId } = data; - if (rootDir.startsWith("/") || rootDir.includes(`"`)) { - throw new Error("Invalid root directory"); - } - if (builder === "dockerfile") { - if (!dockerfilePath) { - throw new Error("Dockerfile path is required"); - } - if (dockerfilePath.startsWith("/") || dockerfilePath.includes(`"`)) { - throw new Error("Invalid Dockerfile path"); - } - } - - if (event === "workflow_run" && eventId === undefined) { - throw new Error("Workflow ID is required"); - } - } else if (source === "image") { - if (!data.imageTag) { - throw new Error("Image tag is required"); - } - } else { - throw new Error( - "Invalid deployment source type: expected `git` or `image`.", - ); - } - - if (port < 0 || port > 65535) { - throw new Error("Invalid port number: must be between 0 and 65535"); - } - - validateEnv(env); - - validateMounts(mounts); - - if (data.source === "image" && data.collectLogs) { - await validateImageReference(data.imageTag); - } - - if (data.subdomain) { - await validateSubdomain(data.subdomain); - } -} +import { MAX_GROUPNAME_LEN, MAX_STS_NAME_LEN } from "./cluster/resources.ts"; export const validateAppGroup = ( appGroup: components["schemas"]["NewApp"]["appGroup"], @@ -79,76 +17,6 @@ export const validateAppGroup = ( } return { valid: true }; }; - -const validateMounts = ( - mounts: components["schemas"]["KnownDeploymentOptions"]["mounts"], -) => { - const pathSet = new Set(); - for (const mount of mounts) { - if (!mount.path.startsWith("/")) { - throw new Error(`Invalid mount path ${mount.path}: must start with '/'`); - } - - if (pathSet.has(mount.path)) { - throw new Error(`Invalid mounts: paths are not unique`); - } - pathSet.add(mount.path); - } -}; - -export const validateEnv = (env: PrismaJson.EnvVar[]) => { - if (env?.some((it) => !it.name || it.name.length === 0)) { - return { valid: false, message: "Some environment variable(s) are empty" }; - } - - if (env?.some((it) => it.name.startsWith("_PRIVATE_ANVILOPS_"))) { - // Environment variables with this prefix are used in the log shipper - see log-shipper/main.go - return { - valid: false, - message: - 'Environment variable(s) use reserved prefix "_PRIVATE_ANVILOPS_"', - }; - } - - const envNames = new Set(); - - for (let envVar of env) { - if (envNames.has(envVar.name)) { - return { - valid: false, - message: "Duplicate environment variable " + envVar.name, - }; - } - envNames.add(envVar.name); - } -}; - -export const validateSubdomain = async (subdomain: string) => { - if (subdomain.length > MAX_SUBDOMAIN_LEN || !isRFC1123(subdomain)) { - throw new Error( - "Subdomain must contain only lowercase alphanumeric characters or '-', " + - "start and end with an alphanumeric character, " + - `and contain at most ${MAX_SUBDOMAIN_LEN} characters`, - ); - } - - if (await namespaceInUse(getNamespace(subdomain))) { - throw new Error("Subdomain is unavailable"); - } - - return { valid: true }; -}; - -export const validateImageReference = async (reference: string) => { - try { - // Look up the image in its registry to make sure it exists - await getImageConfig(reference); - } catch (e) { - console.error(e); - throw new Error("Image could not be found in its registry."); - } -}; - export const validateAppName = (name: string) => { if (name.length > MAX_STS_NAME_LEN || !isRFC1123(name)) { throw new Error( @@ -158,7 +26,3 @@ export const validateAppName = (name: string) => { ); } }; - -export const isRFC1123 = (value: string) => - value.length <= 63 && - value.match(/[a-zA-Z0-9]([-a-z0-9]*[a-z0-9])?$/) !== null; diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 1e985711..49f09ee3 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -2271,6 +2271,9 @@ components: appType: type: string enum: [helm] + source: + type: string + enum: [helm] url: type: string urlType: From 2e65b5baddf04ec1fdc0fca13f4507f6609c4285 Mon Sep 17 00:00:00 2001 From: zheng861 Date: Mon, 22 Dec 2025 15:21:56 -0700 Subject: [PATCH 07/38] Write database migration and refactor createApp/createAppGroup to deploy Helm apps --- .../migration.sql | 69 ++++++ backend/prisma/schema.prisma | 5 +- backend/src/db/models.ts | 1 + backend/src/db/repo/app.ts | 2 +- backend/src/db/repo/deployment.ts | 34 ++- backend/src/domain/app.ts | 105 ++++++++ backend/src/domain/deployment.ts | 20 +- backend/src/domain/deploymentConfig.ts | 10 +- backend/src/domain/index.ts | 2 + backend/src/handlers/createApp.ts | 116 +++------ backend/src/handlers/createAppGroup.ts | 233 ++++-------------- backend/src/handlers/githubWebhook.ts | 53 +++- backend/src/lib/cluster/resources.ts | 5 +- backend/src/lib/helm.ts | 22 +- backend/src/lib/validate.ts | 31 +-- openapi/openapi.yaml | 38 +-- 16 files changed, 390 insertions(+), 356 deletions(-) create mode 100644 backend/prisma/migrations/20251222211930_separate_workload_and_helm_configs/migration.sql create mode 100644 backend/src/domain/app.ts diff --git a/backend/prisma/migrations/20251222211930_separate_workload_and_helm_configs/migration.sql b/backend/prisma/migrations/20251222211930_separate_workload_and_helm_configs/migration.sql new file mode 100644 index 00000000..f2334f88 --- /dev/null +++ b/backend/prisma/migrations/20251222211930_separate_workload_and_helm_configs/migration.sql @@ -0,0 +1,69 @@ +-- CreateEnum +CREATE TYPE "AppType" AS ENUM ('WORKLOAD', 'HELM'); + +-- CreateEnum +CREATE TYPE "HelmUrlType" AS ENUM ('oci', 'absolute'); + +-- AlterTable +ALTER TABLE "DeploymentConfig" +RENAME TO "WorkloadConfig"; + +ALTER INDEX "DeploymentConfig_pkey" RENAME TO "WorkloadConfig_pkey"; + +ALTER SEQUENCE "DeploymentConfig_id_seq" +RENAME TO "WorkloadConfig_id_seq"; + +CREATE TABLE "DeploymentConfig" ( + "id" SERIAL NOT NULL, + "appType" "AppType" NOT NULL, + "helmConfigId" INTEGER, + "workloadConfigId" INTEGER, + + CONSTRAINT "DeploymentConfig_pkey" PRIMARY KEY ("id") +); + +-- Fill with existing WorkloadConfigs +INSERT INTO "DeploymentConfig" ("id", "appType", "workloadConfigId") +SELECT id, 'WORKLOAD', id FROM "WorkloadConfig"; + +-- Adjust sequence to start at highest existing id value +SELECT setval( + '"DeploymentConfig_id_seq"', + (SELECT COALESCE(MAX(id), 0) FROM "DeploymentConfig") +); + +-- Rename indexes +ALTER TABLE "Deployment" DROP CONSTRAINT "Deployment_configId_fkey"; +ALTER TABLE "Deployment" + ADD CONSTRAINT "Deployment_configId_fkey" + FOREIGN KEY ("configId") REFERENCES "DeploymentConfig"(id) + ON UPDATE CASCADE ON DELETE SET NULL; + +ALTER TABLE "App" DROP CONSTRAINT "App_configId_fkey"; +ALTER TABLE "App" + ADD CONSTRAINT "App_configId_fkey" + FOREIGN KEY ("configId") references "DeploymentConfig"(id) + ON UPDATE CASCADE ON DELETE SET NULL; + +-- CreateTable +CREATE TABLE "HelmConfig" ( + "id" SERIAL NOT NULL, + "url" TEXT NOT NULL, + "version" TEXT NOT NULL, + "urlType" "HelmUrlType" NOT NULL, + "values" JSONB, + + CONSTRAINT "HelmConfig_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "DeploymentConfig_workloadConfigId_key" ON "DeploymentConfig"("workloadConfigId"); + +-- CreateIndex +CREATE UNIQUE INDEX "DeploymentConfig_helmConfigId_key" ON "DeploymentConfig"("helmConfigId"); + +-- AddForeignKey +ALTER TABLE "DeploymentConfig" ADD CONSTRAINT "DeploymentConfig_workloadConfigId_fkey" FOREIGN KEY ("workloadConfigId") REFERENCES "WorkloadConfig"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentConfig" ADD CONSTRAINT "DeploymentConfig_helmConfigId_fkey" FOREIGN KEY ("helmConfigId") REFERENCES "HelmConfig"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 62687d10..2ff79829 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -255,9 +255,8 @@ model WorkloadConfig { } enum HelmUrlType { - OCI - REPOSITORY - ABSOLUTE + oci + absolute } model HelmConfig { diff --git a/backend/src/db/models.ts b/backend/src/db/models.ts index 2626086c..55afe150 100644 --- a/backend/src/db/models.ts +++ b/backend/src/db/models.ts @@ -143,6 +143,7 @@ export type GitConfigCreate = WorkloadConfigCreate & { export type HelmConfig = { id: number; + source: "HELM"; url: string; version: string; urlType: HelmUrlType; diff --git a/backend/src/db/repo/app.ts b/backend/src/db/repo/app.ts index e7781405..3bd52734 100644 --- a/backend/src/db/repo/app.ts +++ b/backend/src/db/repo/app.ts @@ -238,7 +238,7 @@ export class AppRepo { app.config.workloadConfig, ); } else { - return app.config.helmConfig; + return { ...app.config.helmConfig, source: "HELM" }; } } diff --git a/backend/src/db/repo/deployment.ts b/backend/src/db/repo/deployment.ts index 6c383154..86a36206 100644 --- a/backend/src/db/repo/deployment.ts +++ b/backend/src/db/repo/deployment.ts @@ -1,5 +1,6 @@ import { randomBytes } from "node:crypto"; import type { + AppType, DeploymentStatus, LogType, PermissionLevel, @@ -14,6 +15,7 @@ import type { Deployment, DeploymentWithSourceInfo, HelmConfig, + HelmConfigCreate, Log, WorkloadConfig, WorkloadConfigCreate, @@ -74,13 +76,15 @@ export class DeploymentRepo { async create({ appId, + appType, config, commitMessage, workflowRunId, status, }: { appId: number; - config: WorkloadConfigCreate; + appType: AppType; + config: WorkloadConfigCreate | HelmConfigCreate; commitMessage: string | null; workflowRunId?: number; status?: DeploymentStatus; @@ -90,10 +94,20 @@ export class DeploymentRepo { app: { connect: { id: appId } }, config: { create: { - appType: "WORKLOAD", - workloadConfig: { - create: DeploymentRepo.encryptEnv(config), - }, + appType: appType, + ...(appType === "WORKLOAD" + ? { + workloadConfig: { + create: DeploymentRepo.encryptEnv( + config as WorkloadConfigCreate, + ), + }, + } + : { + helmConfig: { + create: config as HelmConfigCreate, + }, + }), }, }, commitMessage, @@ -174,7 +188,7 @@ export class DeploymentRepo { ); } - return deployment.config.helmConfig; + return { ...deployment.config.helmConfig, source: "HELM" }; } private static encryptEnv( @@ -334,10 +348,10 @@ export class DeploymentRepo { ...deployment, config: undefined, appType: deployment.config.appType, - source: deployment.config.workloadConfig.source, - commitHash: deployment.config.workloadConfig.commitHash, - imageTag: deployment.config.workloadConfig.imageTag, - repositoryId: deployment.config.workloadConfig.repositoryId, + source: deployment.config.workloadConfig?.source, + commitHash: deployment.config.workloadConfig?.commitHash, + imageTag: deployment.config.workloadConfig?.imageTag, + repositoryId: deployment.config.workloadConfig?.repositoryId, })); } } diff --git a/backend/src/domain/app.ts b/backend/src/domain/app.ts new file mode 100644 index 00000000..4277d5c0 --- /dev/null +++ b/backend/src/domain/app.ts @@ -0,0 +1,105 @@ +import { Organization, User } from "../db/models.ts"; +import { components } from "../generated/openapi.ts"; +import { namespaceInUse } from "../lib/cluster/kubernetes.ts"; +import { canManageProject, isRancherManaged } from "../lib/cluster/rancher.ts"; +import { + MAX_GROUPNAME_LEN, + MAX_NAMESPACE_LEN, + MAX_STS_NAME_LEN, +} from "../lib/cluster/resources.ts"; +import { isRFC1123 } from "../lib/validate.ts"; +import { DeploymentConfigValidator } from "./deploymentConfig.ts"; + +interface NewApp { + name: string; + projectId?: string; + createIngress: boolean; + namespace: string; + config: components["schemas"]["DeploymentConfig"]; +} + +export class AppValidationError extends Error {} + +export class AppValidator { + private configValidator: DeploymentConfigValidator; + constructor(configValidator: DeploymentConfigValidator) { + this.configValidator = configValidator; + } + + async validateApps( + organization: Organization, + user: User, + ...apps: NewApp[] + ) { + const appValidationErrors = ( + await Promise.all(apps.map((app) => this.validateNewApp(app, user))) + ).filter(Boolean); + if (appValidationErrors.length != 0) { + throw new AppValidationError(JSON.stringify(appValidationErrors)); + } + + if ( + apps.some( + (app) => + app.config.source === "git" && !organization.githubInstallationId, + ) + ) { + throw new AppValidationError( + "The AnvilOps GitHub App is not installed in this organization.", + ); + } + } + + private async validateNewApp(app: NewApp, user: { clusterUsername: string }) { + if (isRancherManaged()) { + if (!app.projectId) { + throw new AppValidationError("Project ID is required"); + } + + if (!(await canManageProject(user.clusterUsername, app.projectId))) { + throw new AppValidationError("Project not found"); + } + } + + if (app.config.appType === "workload") { + await this.configValidator.validateCommonWorkloadConfig(app.config); + } + + if ( + !( + 0 < app.namespace.length && app.namespace.length <= MAX_NAMESPACE_LEN + ) || + !isRFC1123(app.namespace) + ) { + throw new AppValidationError( + "Namespace must contain only lowercase alphanumeric characters or '-', " + + "start with an alphabetic character and end with an alphanumeric character, " + + `and contain at most ${MAX_NAMESPACE_LEN} characters`, + ); + } + + if (await namespaceInUse(app.namespace)) { + throw new AppValidationError("Namespace is in use"); + } + this.validateAppName(app.name); + } + + validateAppGroupName(name: string) { + if ( + !(0 < name.length && name.length <= MAX_GROUPNAME_LEN) || + !isRFC1123(name) + ) { + throw new AppValidationError("Invalid app group name"); + } + } + + private validateAppName(name: string) { + if (name.length > MAX_STS_NAME_LEN || !isRFC1123(name)) { + throw new AppValidationError( + "App name must contain only lowercase alphanumeric characters or '-', " + + "start and end with an alphanumeric character, " + + `and contain at most ${MAX_STS_NAME_LEN} characters`, + ); + } + } +} diff --git a/backend/src/domain/deployment.ts b/backend/src/domain/deployment.ts index 2bc16670..58537e97 100644 --- a/backend/src/domain/deployment.ts +++ b/backend/src/domain/deployment.ts @@ -1,5 +1,9 @@ import { Octokit } from "octokit"; -import { GitConfigCreate } from "../db/models.ts"; +import { + GitConfigCreate, + HelmConfigCreate, + WorkloadConfigCreate, +} from "../db/models.ts"; import { components } from "../generated/openapi.ts"; import { getOctokit, getRepoById } from "../lib/octokit.ts"; import { type DeploymentConfigValidator } from "./deploymentConfig.ts"; @@ -24,7 +28,10 @@ export class DeploymentController { async prepareDeploymentMetadata( config: components["schemas"]["DeploymentConfig"], orgId: number, - ) { + ): Promise<{ + config: GitConfigCreate | HelmConfigCreate | WorkloadConfigCreate; + commitMessage: string; + }> { let commitHash = "unknown", commitMessage = "Initial deployment"; @@ -58,19 +65,22 @@ export class DeploymentController { commitMessage = latestCommit.commit.message; return { - config: this.createGitConfig(config, commitHash, repo.id), + config: await this.createGitConfig(config, commitHash, repo.id), commitMessage, }; } case "image": { deploymentConfigValidator.validateImageConfig(config); return { - config: this.createCommonWorkloadConfig(config), + config: { + ...this.createCommonWorkloadConfig(config), + source: "IMAGE", + }, commitMessage, }; } case "helm": { - return { config, commitMessage }; + return { config: { ...config, source: "HELM" }, commitMessage }; } } } diff --git a/backend/src/domain/deploymentConfig.ts b/backend/src/domain/deploymentConfig.ts index 72e82639..b124474e 100644 --- a/backend/src/domain/deploymentConfig.ts +++ b/backend/src/domain/deploymentConfig.ts @@ -4,6 +4,7 @@ import { components } from "../generated/openapi.ts"; import { MAX_SUBDOMAIN_LEN } from "../lib/cluster/resources.ts"; import { getImageConfig } from "../lib/cluster/resources/logs.ts"; import { getRepoById } from "../lib/octokit.ts"; +import { isRFC1123 } from "../lib/validate.ts"; import { GitWorkloadConfig, ImageWorkloadConfig } from "./types.ts"; export class DeploymentConfigValidator { @@ -134,7 +135,7 @@ export class DeploymentConfigValidator { } private async validateSubdomain(subdomain: string) { - if (subdomain.length > MAX_SUBDOMAIN_LEN || !this.isRFC1123(subdomain)) { + if (subdomain.length > MAX_SUBDOMAIN_LEN || !isRFC1123(subdomain)) { throw new Error( "Subdomain must contain only lowercase alphanumeric characters or '-', " + "start and end with an alphanumeric character, " + @@ -146,11 +147,4 @@ export class DeploymentConfigValidator { throw new Error("Subdomain is in use"); } } - - private isRFC1123(value: string) { - return ( - value.length <= 63 && - value.match(/[a-zA-Z0-9]([-a-z0-9]*[a-z0-9])?$/) !== null - ); - } } diff --git a/backend/src/domain/index.ts b/backend/src/domain/index.ts index a701c75f..b601a40e 100644 --- a/backend/src/domain/index.ts +++ b/backend/src/domain/index.ts @@ -1,8 +1,10 @@ import { db } from "../db/index.ts"; +import { AppValidator } from "./app.ts"; import { DeploymentController } from "./deployment.ts"; import { DeploymentConfigValidator } from "./deploymentConfig.ts"; export const deploymentConfigValidator = new DeploymentConfigValidator(db.app); +export const appValidator = new AppValidator(deploymentConfigValidator); export const deploymentController = new DeploymentController( deploymentConfigValidator, ); diff --git a/backend/src/handlers/createApp.ts b/backend/src/handlers/createApp.ts index 55b3fa70..1b40b68a 100644 --- a/backend/src/handlers/createApp.ts +++ b/backend/src/handlers/createApp.ts @@ -1,15 +1,8 @@ import { randomBytes } from "node:crypto"; import { db } from "../db/index.ts"; -import type { App } from "../db/models.ts"; -import { - deploymentConfigValidator, - deploymentController, -} from "../domain/index.ts"; +import { App } from "../db/models.ts"; +import { appValidator, deploymentController } from "../domain/index.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 { validateAppGroup, validateAppName } from "../lib/validate.ts"; import { json, type HandlerMap } from "../types.ts"; import { buildAndDeploy } from "./githubWebhook.ts"; import { type AuthenticatedRequest } from "./index.ts"; @@ -29,77 +22,53 @@ export const createApp: HandlerMap["createApp"] = async ( return json(400, res, { code: 400, message: "Organization not found" }); } - try { - if (appData.config.appType === "workload") { - await deploymentConfigValidator.validateCommonWorkloadConfig( - appData.config, - ); - } - validateAppGroup(appData.appGroup); - validateAppName(appData.name); - } catch (e) { - return json(400, res, { - code: 400, - message: e.message, - }); - } + let appGroupId: number; - let clusterUsername: string; - if (isRancherManaged()) { - if (!appData.projectId) { - return json(400, res, { code: 400, message: "Project ID is required" }); + if (appData.appGroup.type === "add-to") { + appGroupId = appData.appGroup.id; + if (!(await db.appGroup.getById(appGroupId))) { + return json(400, res, { code: 400, message: "App group not found" }); } - - 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" }); + } else { + let groupName = + appData.appGroup.type === "create-new" + ? appData.appGroup.name + : `${appData.name}-${randomBytes(4).toString("hex")}`; + try { + appValidator.validateAppGroupName(groupName); + } catch (e) { + return json(400, res, { code: 400, message: e.message }); } - - clusterUsername = username; + appGroupId = await db.appGroup.create( + appData.orgId, + groupName, + appData.appGroup.type === "standalone", + ); } - if (appData.config.source === "git" && !organization.githubInstallationId) { - return json(403, res, { - code: 403, - message: "The AnvilOps GitHub App is not installed in this organization.", - }); + const user = await db.user.getById(req.user.id); + let metadata: Awaited< + ReturnType + >; + try { + await appValidator.validateApps(organization, user, appData); + metadata = await deploymentController.prepareDeploymentMetadata( + appData.config, + organization.id, + ); + } catch (e) { + return json(400, res, { code: 400, message: e.message }); } let app: App; - 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.config.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, + orgId: organization.id, + appGroupId, + name: app.name, + namespace: app.namespace, + clusterUsername: user?.clusterUsername, + projectId: app.projectId, }); } catch (err) { if (err instanceof PrismaClientKnownRequestError && err.code === "P2002") { @@ -113,15 +82,10 @@ export const createApp: HandlerMap["createApp"] = async ( message, }); } - console.error(err); return json(500, res, { code: 500, message: "Unable to create app." }); } - const { config, commitMessage } = - await deploymentController.prepareDeploymentMetadata( - appData.config, - appData.orgId, - ); + const { config, commitMessage } = metadata; try { await buildAndDeploy({ diff --git a/backend/src/handlers/createAppGroup.ts b/backend/src/handlers/createAppGroup.ts index 828fc4d4..a6f50d83 100644 --- a/backend/src/handlers/createAppGroup.ts +++ b/backend/src/handlers/createAppGroup.ts @@ -1,16 +1,6 @@ -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"; +import type { App } from "../db/models.ts"; +import { appValidator, deploymentController } from "../domain/index.ts"; import { json, type HandlerMap } from "../types.ts"; import { buildAndDeploy } from "./githubWebhook.ts"; import type { AuthenticatedRequest } from "./index.ts"; @@ -29,140 +19,44 @@ export const createAppGroup: HandlerMap["createAppGroup"] = async ( return json(400, res, { code: 400, message: "Organization not found" }); } + const user = await db.user.getById(req.user.id); + let metadata: Awaited< + ReturnType + >[]; try { - validateAppGroup({ type: "create-new", name: data.name }); + appValidator.validateAppGroupName(data.name); + appValidator.validateApps(organization, user, ...data.apps); + metadata = await Promise.all( + data.apps.map((app) => + deploymentController.prepareDeploymentMetadata( + app.config, + organization.id, + ), + ), + ); } 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) { - return json(400, res, { - code: 400, - message: `Project ${result.project} not found`, - }); - } - } - } - - 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); - return json(500, res, { - code: 500, - message: `Failed to look up repository for app ${app.name}`, - }); - } - - 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 appGroupId = await db.appGroup.create( + organization.id, + data.name, + false, ); - - const apps: App[] = []; + let apps: App[]; try { - for (const app of appConfigs) { - apps.push(await db.app.create(app)); - } + apps = await Promise.all( + apps.map((app) => + db.app.create({ + orgId: organization.id, + appGroupId, + name: app.name, + namespace: app.namespace, + clusterUsername: user?.clusterUsername, + projectId: app.projectId, + }), + ), + ); } catch (err) { if (err instanceof ConflictError && err.message === "subdomain") { return json(409, res, { @@ -174,64 +68,21 @@ export const createAppGroup: HandlerMap["createAppGroup"] = async ( } } + const appsAndMetadata = apps.map((app, idx) => ({ + app, + metadata: metadata[idx], + })); try { await Promise.all( - apps.map((app, idx) => + appsAndMetadata.map((pair) => (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, - }), - }; - + const { app, metadata } = pair; await buildAndDeploy({ org: organization, - app: app, + app, imageRepo: app.imageRepo, - commitMessage: commitMessage, - config: deploymentConfig, + commitMessage: metadata.commitMessage, + config: metadata.config, createCheckRun: false, }); })(), diff --git a/backend/src/handlers/githubWebhook.ts b/backend/src/handlers/githubWebhook.ts index 8957d055..d2caf0c9 100644 --- a/backend/src/handlers/githubWebhook.ts +++ b/backend/src/handlers/githubWebhook.ts @@ -4,9 +4,10 @@ import { db, NotFoundError } from "../db/index.ts"; import type { App, Deployment, - DeploymentConfig, - DeploymentConfigCreate, + GitConfigCreate, + HelmConfigCreate, Organization, + WorkloadConfigCreate, } from "../db/models.ts"; import type { components } from "../generated/openapi.ts"; import { @@ -27,6 +28,7 @@ import { import { shouldImpersonate } from "../lib/cluster/rancher.ts"; import { createAppConfigsFromDeployment } from "../lib/cluster/resources.ts"; import { env } from "../lib/env.ts"; +import { upgrade } from "../lib/helm.ts"; import { getInstallationAccessToken, getOctokit, @@ -174,7 +176,7 @@ type BuildAndDeployOptions = { app: App; imageRepo: string; commitMessage: string; - config: DeploymentConfigCreate; + config: WorkloadConfigCreate | GitConfigCreate | HelmConfigCreate; } & ( | { createCheckRun: true; octokit: Octokit; owner: string; repo: string } | { createCheckRun: false } @@ -188,6 +190,20 @@ export async function buildAndDeploy({ config: configIn, ...opts }: BuildAndDeployOptions) { + if (configIn.source === "HELM") { + configIn = configIn as HelmConfigCreate; + const deployment = await db.deployment.create({ + appId: app.id, + commitMessage, + appType: "HELM", + config: configIn, + }); + await deployFromHelm(app, deployment, configIn); + return; + } + + configIn = configIn as WorkloadConfigCreate; + const imageTag = configIn.source === DeploymentSource.IMAGE ? (configIn.imageTag as ImageTag) @@ -197,6 +213,7 @@ export async function buildAndDeploy({ db.deployment.create({ appId: app.id, commitMessage, + appType: "WORKLOAD", config: { ...configIn, imageTag }, }), db.appGroup.getById(app.appGroupId), @@ -251,6 +268,36 @@ export async function buildAndDeploy({ } } +async function deployFromHelm( + app: App, + deployment: Deployment, + config: HelmConfigCreate, +) { + log(deployment.id, "BUILD", "Deploying directly from Helm chart..."); + try { + await upgrade({ + urlType: config.urlType, + chartURL: config.url, + version: config.version, + namespace: app.namespace, + release: app.name, + values: config.values, + }); + } 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, diff --git a/backend/src/lib/cluster/resources.ts b/backend/src/lib/cluster/resources.ts index c50f2851..40567214 100644 --- a/backend/src/lib/cluster/resources.ts +++ b/backend/src/lib/cluster/resources.ts @@ -22,8 +22,11 @@ import { const NAMESPACE_PREFIX = "anvilops-"; +// Subdomain must pass RFC 1123 +export const MAX_SUBDOMAIN_LEN = 63; + // Namespace must pass RFC 1123 (and service must pass RFC 1035) -export const MAX_SUBDOMAIN_LEN = 63 - NAMESPACE_PREFIX.length; +export const MAX_NAMESPACE_LEN = 63 - NAMESPACE_PREFIX.length; // app.kubernetes.io/part-of label must pass RFC 1123 // `-{groupId}-{organizationId}` is appended to group name to create the label value diff --git a/backend/src/lib/helm.ts b/backend/src/lib/helm.ts index 3488ddc8..bca88d09 100644 --- a/backend/src/lib/helm.ts +++ b/backend/src/lib/helm.ts @@ -1,5 +1,6 @@ import { spawn } from "child_process"; import { parse as yamlParse } from "yaml"; +import { HelmUrlType } from "../generated/prisma/enums.ts"; type Dependency = { name: string; @@ -60,28 +61,43 @@ export const getChart = async ( }; export const upgrade = ({ + urlType, chartURL, + version, namespace, values, release, }: { + urlType: HelmUrlType; chartURL: string; + version: string; namespace: string; values: { [key: string]: string }; release: string; }) => { const kvPairs = Object.keys(values).map((key, value) => `${key}=${value}`); - const args = [ + let args = [ "upgrade", "--install", - release, - chartURL, "--namespace", namespace, "--create-namespace", "--set", kvPairs.join(","), ]; + switch (urlType) { + // example: helm install mynginx https://example.com/charts/nginx-1.2.3.tgz + case "absolute": { + args.push(release, chartURL); + break; + } + + // example: helm install mynginx --version 1.2.3 oci://example.com/charts/nginx + case "oci": { + args.push(release, "--version", version, chartURL); + break; + } + } return runHelm(args); }; diff --git a/backend/src/lib/validate.ts b/backend/src/lib/validate.ts index 23f60f21..797c4091 100644 --- a/backend/src/lib/validate.ts +++ b/backend/src/lib/validate.ts @@ -1,28 +1,3 @@ -import type { components } from "../generated/openapi.ts"; -import { MAX_GROUPNAME_LEN, MAX_STS_NAME_LEN } from "./cluster/resources.ts"; - -export const validateAppGroup = ( - appGroup: components["schemas"]["NewApp"]["appGroup"], -) => { - if (appGroup.type === "create-new") { - if ( - appGroup.name.length > MAX_GROUPNAME_LEN || - appGroup.name.match(/^[a-zA-Z0-9][ a-zA-Z0-9-_\.]*$/) === null - ) { - return { - valid: false, - message: "Invalid group name", - }; - } - } - return { valid: true }; -}; -export const validateAppName = (name: string) => { - if (name.length > MAX_STS_NAME_LEN || !isRFC1123(name)) { - throw new Error( - "App name must contain only lowercase alphanumeric characters or '-', " + - "start and end with an alphanumeric character, " + - `and contain at most ${MAX_STS_NAME_LEN} characters`, - ); - } -}; +export const isRFC1123 = (value: string) => + value.length <= 63 && + value.match(/[a-zA-Z0-9]([-a-z0-9]*[a-z0-9])?$/) !== null; diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 49f09ee3..99f05784 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -2278,13 +2278,13 @@ components: type: string urlType: type: string - enum: [absolute, oci, repository] + enum: [absolute, oci] version: type: string values: type: object additionalProperties: true - required: [source, url, urlType] + required: [source, url, urlType, version] Mount: type: object @@ -2451,12 +2451,15 @@ components: required: [type, id] createIngress: type: boolean + namespace: + type: string config: $ref: "#/components/schemas/DeploymentConfig" required: - name - orgId - createIngress + - namespace - config NewAppWithoutGroupInfo: type: object @@ -2466,39 +2469,20 @@ components: name: type: string # Source options - orgId: - type: integer - format: int64 projectId: type: string - subdomain: + namespace: type: string - format: hostname - port: - type: integer - format: int64 - env: - $ref: "#/components/schemas/Envs" - mounts: - type: array - items: - $ref: "#/components/schemas/Mount" - cpuCores: - type: number - memoryInMiB: - type: integer createIngress: type: boolean + config: + $ref: "#/components/schemas/DeploymentConfig" required: - name - - orgId - - subdomain - - port - - env - - mounts - - cpuCores - - memoryInMiB + - projectId + - namespace - createIngress + - config - oneOf: - $ref: "#/components/schemas/GitDeploymentOptions" - $ref: "#/components/schemas/ImageDeploymentOptions" From 8945122477c87f76c47cee9ec95d364a1f285354 Mon Sep 17 00:00:00 2001 From: zheng861 Date: Tue, 23 Dec 2025 16:39:51 -0700 Subject: [PATCH 08/38] Update backend with helm configs --- backend/prisma/schema.prisma | 4 +- backend/src/db/models.ts | 16 +- backend/src/db/repo/app.ts | 45 ++++- backend/src/db/repo/deployment.ts | 28 ++- backend/src/domain/app.ts | 60 ++++--- backend/src/domain/deployment.ts | 36 +++- backend/src/domain/deploymentConfig.ts | 53 ++++++ backend/src/domain/index.ts | 4 +- backend/src/handlers/createApp.ts | 30 ++-- backend/src/handlers/createAppGroup.ts | 6 +- backend/src/handlers/deleteApp.ts | 2 +- backend/src/handlers/files.ts | 7 + backend/src/handlers/getAppByID.ts | 36 +--- backend/src/handlers/getAppLogs.ts | 9 +- backend/src/handlers/getDeployment.ts | 27 +-- backend/src/handlers/getOrgByID.ts | 22 +-- backend/src/handlers/githubWebhook.ts | 21 ++- backend/src/handlers/updateApp.ts | 162 ++++++++++-------- backend/src/handlers/updateDeployment.ts | 8 +- backend/src/handlers/webhook/push.ts | 26 +-- backend/src/handlers/webhook/workflow_run.ts | 33 +--- backend/src/lib/builder.ts | 13 +- backend/src/lib/cluster/resources.ts | 4 +- .../src/lib/cluster/resources/statefulset.ts | 4 +- openapi/openapi.yaml | 19 +- 25 files changed, 381 insertions(+), 294 deletions(-) diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 2ff79829..5e33395d 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -139,8 +139,8 @@ enum ImageBuilder { } enum AppType { - WORKLOAD - HELM + workload + helm } enum DeploymentSource { diff --git a/backend/src/db/models.ts b/backend/src/db/models.ts index 55afe150..e5b5bf65 100644 --- a/backend/src/db/models.ts +++ b/backend/src/db/models.ts @@ -102,6 +102,7 @@ export interface WorkloadConfig { id: number; displayEnv: PrismaJson.EnvVar[]; getEnv(): PrismaJson.EnvVar[]; + appType: "workload"; source: DeploymentSource; repositoryId?: number; branch?: string; @@ -114,7 +115,7 @@ export interface WorkloadConfig { imageTag?: string; collectLogs: boolean; createIngress: boolean; - subdomain: string | undefined; + subdomain?: string; requests: PrismaJson.Resources; limits: PrismaJson.Resources; replicas: number; @@ -129,6 +130,18 @@ export type WorkloadConfigCreate = Omit< env: PrismaJson.EnvVar[]; }; +export type GitConfig = WorkloadConfig & { + source: "GIT"; + repositoryId: number; + branch: string; + event: WebhookEvent; + eventId?: number; + commitHash: string; + builder: ImageBuilder; + rootDir?: string; + dockerfilePath?: string; +}; + export type GitConfigCreate = WorkloadConfigCreate & { source: "GIT"; repositoryId: number; @@ -143,6 +156,7 @@ export type GitConfigCreate = WorkloadConfigCreate & { export type HelmConfig = { id: number; + appType: "helm"; source: "HELM"; url: string; version: string; diff --git a/backend/src/db/repo/app.ts b/backend/src/db/repo/app.ts index 3bd52734..7f03c21d 100644 --- a/backend/src/db/repo/app.ts +++ b/backend/src/db/repo/app.ts @@ -61,7 +61,7 @@ export class AppRepo { return await this.client.app.findMany({ where: { config: { - appType: "WORKLOAD", + appType: "workload", workloadConfig: { source: DeploymentSource.GIT, repositoryId: repoId, @@ -81,7 +81,7 @@ export class AppRepo { (await this.client.app.count({ where: { config: { - appType: "WORKLOAD", + appType: "workload", workloadConfig: { subdomain }, }, }, @@ -233,12 +233,14 @@ export class AppRepo { }, }); - if (app.config.appType === "WORKLOAD") { - return DeploymentRepo.preprocessDeploymentConfig( - app.config.workloadConfig, - ); + if (app.config.appType === "workload") { + return DeploymentRepo.preprocessWorkloadConfig(app.config.workloadConfig); } else { - return { ...app.config.helmConfig, source: "HELM" }; + return { + ...app.config.helmConfig, + source: "HELM", + appType: app.config.appType, + }; } } @@ -256,7 +258,7 @@ export class AppRepo { } async getDeploymentsWithStatus(appId: number, statuses: DeploymentStatus[]) { - return await this.client.deployment.findMany({ + const deployments = await this.client.deployment.findMany({ where: { appId: appId, status: { @@ -264,9 +266,34 @@ export class AppRepo { }, }, include: { - config: true, + config: { + include: { + workloadConfig: true, + helmConfig: true, + }, + }, }, }); + + return deployments.map((deployment) => { + if (deployment.config.workloadConfig) { + return { + ...deployment, + config: DeploymentRepo.preprocessWorkloadConfig( + deployment.config.workloadConfig, + ) satisfies WorkloadConfig, + }; + } else { + return { + ...deployment, + config: { + ...deployment.config.helmConfig, + source: "HELM", + appType: "helm", + } satisfies HelmConfig, + }; + } + }); } async setGroup(appId: number, appGroupId: number) { diff --git a/backend/src/db/repo/deployment.ts b/backend/src/db/repo/deployment.ts index 86a36206..eff62967 100644 --- a/backend/src/db/repo/deployment.ts +++ b/backend/src/db/repo/deployment.ts @@ -95,7 +95,7 @@ export class DeploymentRepo { config: { create: { appType: appType, - ...(appType === "WORKLOAD" + ...(appType === "workload" ? { workloadConfig: { create: DeploymentRepo.encryptEnv( @@ -182,13 +182,17 @@ export class DeploymentRepo { }, }); - if (deployment.config.appType === "WORKLOAD") { - return DeploymentRepo.preprocessDeploymentConfig( + if (deployment.config.appType === "workload") { + return DeploymentRepo.preprocessWorkloadConfig( deployment.config.workloadConfig, ); } - return { ...deployment.config.helmConfig, source: "HELM" }; + return { + ...deployment.config.helmConfig, + source: "HELM", + appType: deployment.config.appType, + }; } private static encryptEnv( @@ -200,7 +204,7 @@ export class DeploymentRepo { return copy; } - static preprocessDeploymentConfig( + static preprocessWorkloadConfig( config: PrismaWorkloadConfig, ): WorkloadConfig { if (config === null) { @@ -216,6 +220,7 @@ export class DeploymentRepo { return { ...config, + appType: "workload", getEnv() { return decrypted; }, @@ -225,6 +230,19 @@ export class DeploymentRepo { }; } + static cloneWorkloadConfig(config: WorkloadConfig): WorkloadConfigCreate { + if (config === null) { + return null; + } + const newConfig = structuredClone(config); + const env = config.getEnv(); + delete newConfig.displayEnv; + delete newConfig.getEnv; + delete newConfig.id; + + return { ...newConfig, env }; + } + async checkLogIngestSecret(deploymentId: number, logIngestSecret: string) { const count = await this.client.app.count({ where: { diff --git a/backend/src/domain/app.ts b/backend/src/domain/app.ts index 4277d5c0..44f68acd 100644 --- a/backend/src/domain/app.ts +++ b/backend/src/domain/app.ts @@ -10,11 +10,10 @@ import { import { isRFC1123 } from "../lib/validate.ts"; import { DeploymentConfigValidator } from "./deploymentConfig.ts"; -interface NewApp { - name: string; +interface App { + name?: string; projectId?: string; - createIngress: boolean; - namespace: string; + namespace?: string; config: components["schemas"]["DeploymentConfig"]; } @@ -26,13 +25,18 @@ export class AppValidator { this.configValidator = configValidator; } - async validateApps( - organization: Organization, - user: User, - ...apps: NewApp[] - ) { + async validateApps(organization: Organization, user: User, ...apps: App[]) { const appValidationErrors = ( - await Promise.all(apps.map((app) => this.validateNewApp(app, user))) + await Promise.all( + apps.map(async (app) => { + try { + await this.validateNewApp(app, user); + return null; + } catch (e) { + return e.message; + } + }), + ) ).filter(Boolean); if (appValidationErrors.length != 0) { throw new AppValidationError(JSON.stringify(appValidationErrors)); @@ -50,7 +54,7 @@ export class AppValidator { } } - private async validateNewApp(app: NewApp, user: { clusterUsername: string }) { + private async validateNewApp(app: App, user: { clusterUsername: string }) { if (isRancherManaged()) { if (!app.projectId) { throw new AppValidationError("Project ID is required"); @@ -65,23 +69,27 @@ export class AppValidator { await this.configValidator.validateCommonWorkloadConfig(app.config); } - if ( - !( - 0 < app.namespace.length && app.namespace.length <= MAX_NAMESPACE_LEN - ) || - !isRFC1123(app.namespace) - ) { - throw new AppValidationError( - "Namespace must contain only lowercase alphanumeric characters or '-', " + - "start with an alphabetic character and end with an alphanumeric character, " + - `and contain at most ${MAX_NAMESPACE_LEN} characters`, - ); - } + if (app.namespace) { + if ( + !( + 0 < app.namespace.length && app.namespace.length <= MAX_NAMESPACE_LEN + ) || + !isRFC1123(app.namespace) + ) { + throw new AppValidationError( + "Namespace must contain only lowercase alphanumeric characters or '-', " + + "start with an alphabetic character and end with an alphanumeric character, " + + `and contain at most ${MAX_NAMESPACE_LEN} characters`, + ); + } - if (await namespaceInUse(app.namespace)) { - throw new AppValidationError("Namespace is in use"); + if (await namespaceInUse(app.namespace)) { + throw new AppValidationError("Namespace is in use"); + } + } + if (app.name) { + this.validateAppName(app.name); } - this.validateAppName(app.name); } validateAppGroupName(name: string) { diff --git a/backend/src/domain/deployment.ts b/backend/src/domain/deployment.ts index 58537e97..d4ab53f2 100644 --- a/backend/src/domain/deployment.ts +++ b/backend/src/domain/deployment.ts @@ -10,7 +10,7 @@ import { type DeploymentConfigValidator } from "./deploymentConfig.ts"; import { deploymentConfigValidator } from "./index.ts"; import { GitWorkloadConfig } from "./types.ts"; -export class DeploymentController { +export class DeploymentService { private readonly validator: DeploymentConfigValidator; private readonly getOctokitFn: typeof getOctokit; private readonly getRepoByIdFn: typeof getRepoById; @@ -53,16 +53,26 @@ export class DeploymentController { await this.validator.validateGitConfig(config, octokit, repo); - const latestCommit = ( - await octokit.rest.repos.listCommits({ - per_page: 1, + if (config.commitHash) { + commitHash = config.commitHash; + const commit = await octokit.rest.git.getCommit({ owner: repo.owner.login, repo: repo.name, - }) - ).data[0]; + commit_sha: commitHash, + }); + commitMessage = commit.data.message; + } else { + const latestCommit = ( + await octokit.rest.repos.listCommits({ + per_page: 1, + owner: repo.owner.login, + repo: repo.name, + }) + ).data[0]; - commitHash = latestCommit.sha; - commitMessage = latestCommit.commit.message; + commitHash = latestCommit.sha; + commitMessage = latestCommit.commit.message; + } return { config: await this.createGitConfig(config, commitHash, repo.id), @@ -75,12 +85,16 @@ export class DeploymentController { config: { ...this.createCommonWorkloadConfig(config), source: "IMAGE", + appType: "workload", }, commitMessage, }; } case "helm": { - return { config: { ...config, source: "HELM" }, commitMessage }; + return { + config: { ...config, source: "HELM", appType: "helm" }, + commitMessage, + }; } } } @@ -89,6 +103,7 @@ export class DeploymentController { config: components["schemas"]["WorkloadConfigOptions"], ) { return { + appType: "workload" as const, collectLogs: config.collectLogs, createIngress: config.createIngress, subdomain: config.subdomain, @@ -114,8 +129,11 @@ export class DeploymentController { repositoryId, branch: config.branch, event: config.event, + eventId: config.eventId, commitHash, builder: config.builder, + dockerfilePath: config.dockerfilePath, + rootDir: config.rootDir, imageTag: undefined, } satisfies GitConfigCreate; } diff --git a/backend/src/domain/deploymentConfig.ts b/backend/src/domain/deploymentConfig.ts index b124474e..a73fcdd0 100644 --- a/backend/src/domain/deploymentConfig.ts +++ b/backend/src/domain/deploymentConfig.ts @@ -1,8 +1,10 @@ import { Octokit } from "octokit"; +import { HelmConfig, WorkloadConfig } from "../db/models.ts"; import { AppRepo } from "../db/repo/app.ts"; import { components } from "../generated/openapi.ts"; import { MAX_SUBDOMAIN_LEN } from "../lib/cluster/resources.ts"; import { getImageConfig } from "../lib/cluster/resources/logs.ts"; +import { generateVolumeName } from "../lib/cluster/resources/statefulset.ts"; import { getRepoById } from "../lib/octokit.ts"; import { isRFC1123 } from "../lib/validate.ts"; import { GitWorkloadConfig, ImageWorkloadConfig } from "./types.ts"; @@ -13,6 +15,57 @@ export class DeploymentConfigValidator { this.appRepo = appRepo; } + // Produces a DeploymentConfig object to be returned from the API, as described in the OpenAPI spec. + formatDeploymentConfig( + config: WorkloadConfig | HelmConfig, + ): components["schemas"]["DeploymentConfig"] { + if (config.appType === "workload") { + return this.formatWorkloadConfig(config); + } else { + return { + ...config, + source: "helm", + }; + } + } + + private formatWorkloadConfig( + config: WorkloadConfig, + ): components["schemas"]["WorkloadConfigOptions"] { + return { + appType: "workload", + createIngress: config.createIngress, + subdomain: config.createIngress ? config.subdomain : undefined, + collectLogs: config.collectLogs, + port: config.port, + env: config.displayEnv, + replicas: config.replicas, + requests: config.requests, + limits: config.limits, + mounts: config.mounts.map((mount) => ({ + amountInMiB: mount.amountInMiB, + path: mount.path, + volumeClaimName: generateVolumeName(mount.path), + })), + ...(config.source === "GIT" + ? { + source: "git" as const, + branch: config.branch, + dockerfilePath: config.dockerfilePath, + rootDir: config.rootDir, + builder: config.builder, + repositoryId: config.repositoryId, + event: config.event, + eventId: config.eventId, + commitHash: config.commitHash, + } + : { + source: "image" as const, + imageTag: config.imageTag, + }), + }; + } + async validateCommonWorkloadConfig( config: components["schemas"]["WorkloadConfigOptions"], ) { diff --git a/backend/src/domain/index.ts b/backend/src/domain/index.ts index b601a40e..583e8df7 100644 --- a/backend/src/domain/index.ts +++ b/backend/src/domain/index.ts @@ -1,10 +1,10 @@ import { db } from "../db/index.ts"; import { AppValidator } from "./app.ts"; -import { DeploymentController } from "./deployment.ts"; +import { DeploymentService } from "./deployment.ts"; import { DeploymentConfigValidator } from "./deploymentConfig.ts"; export const deploymentConfigValidator = new DeploymentConfigValidator(db.app); export const appValidator = new AppValidator(deploymentConfigValidator); -export const deploymentController = new DeploymentController( +export const deploymentService = new DeploymentService( deploymentConfigValidator, ); diff --git a/backend/src/handlers/createApp.ts b/backend/src/handlers/createApp.ts index 1b40b68a..dfdd8764 100644 --- a/backend/src/handlers/createApp.ts +++ b/backend/src/handlers/createApp.ts @@ -1,7 +1,7 @@ import { randomBytes } from "node:crypto"; import { db } from "../db/index.ts"; import { App } from "../db/models.ts"; -import { appValidator, deploymentController } from "../domain/index.ts"; +import { appValidator, deploymentService } from "../domain/index.ts"; import { PrismaClientKnownRequestError } from "../generated/prisma/internal/prismaNamespace.ts"; import { json, type HandlerMap } from "../types.ts"; import { buildAndDeploy } from "./githubWebhook.ts"; @@ -22,6 +22,20 @@ export const createApp: HandlerMap["createApp"] = async ( return json(400, res, { code: 400, message: "Organization not found" }); } + const user = await db.user.getById(req.user.id); + let metadata: Awaited< + ReturnType + >; + try { + await appValidator.validateApps(organization, user, appData); + metadata = await deploymentService.prepareDeploymentMetadata( + appData.config, + organization.id, + ); + } catch (e) { + return json(400, res, { code: 400, message: e.message }); + } + let appGroupId: number; if (appData.appGroup.type === "add-to") { @@ -46,20 +60,6 @@ export const createApp: HandlerMap["createApp"] = async ( ); } - const user = await db.user.getById(req.user.id); - let metadata: Awaited< - ReturnType - >; - try { - await appValidator.validateApps(organization, user, appData); - metadata = await deploymentController.prepareDeploymentMetadata( - appData.config, - organization.id, - ); - } catch (e) { - return json(400, res, { code: 400, message: e.message }); - } - let app: App; try { app = await db.app.create({ diff --git a/backend/src/handlers/createAppGroup.ts b/backend/src/handlers/createAppGroup.ts index a6f50d83..71cd61c2 100644 --- a/backend/src/handlers/createAppGroup.ts +++ b/backend/src/handlers/createAppGroup.ts @@ -1,6 +1,6 @@ import { ConflictError, db } from "../db/index.ts"; import type { App } from "../db/models.ts"; -import { appValidator, deploymentController } from "../domain/index.ts"; +import { appValidator, deploymentService } from "../domain/index.ts"; import { json, type HandlerMap } from "../types.ts"; import { buildAndDeploy } from "./githubWebhook.ts"; import type { AuthenticatedRequest } from "./index.ts"; @@ -21,14 +21,14 @@ export const createAppGroup: HandlerMap["createAppGroup"] = async ( const user = await db.user.getById(req.user.id); let metadata: Awaited< - ReturnType + ReturnType >[]; try { appValidator.validateAppGroupName(data.name); appValidator.validateApps(organization, user, ...data.apps); metadata = await Promise.all( data.apps.map((app) => - deploymentController.prepareDeploymentMetadata( + deploymentService.prepareDeploymentMetadata( app.config, organization.id, ), diff --git a/backend/src/handlers/deleteApp.ts b/backend/src/handlers/deleteApp.ts index 2681ef70..aa2f3af3 100644 --- a/backend/src/handlers/deleteApp.ts +++ b/backend/src/handlers/deleteApp.ts @@ -44,7 +44,7 @@ export const deleteApp: HandlerMap["deleteApp"] = async ( } catch (err) { console.error("Failed to delete namespace:", err); } - } else if (config.collectLogs) { + } else if (config.appType === "workload" && config.collectLogs) { // If the log shipper was enabled, redeploy without it config.collectLogs = false; // <-- Disable log shipping diff --git a/backend/src/handlers/files.ts b/backend/src/handlers/files.ts index deb3041b..c0c66b8f 100644 --- a/backend/src/handlers/files.ts +++ b/backend/src/handlers/files.ts @@ -91,6 +91,13 @@ async function forward( const config = await db.app.getDeploymentConfig(appId); + if (config.appType !== "workload") { + return json(400, res, { + code: 400, + message: "File browsing is supported only for Git and image deployments", + }); + } + if ( !config.mounts.some((mount) => volumeClaimName.startsWith(generateVolumeName(mount.path) + "-"), diff --git a/backend/src/handlers/getAppByID.ts b/backend/src/handlers/getAppByID.ts index 9a6028dd..1472c1d2 100644 --- a/backend/src/handlers/getAppByID.ts +++ b/backend/src/handlers/getAppByID.ts @@ -1,7 +1,7 @@ import { db } from "../db/index.ts"; +import { deploymentConfigValidator } from "../domain/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 { json, type HandlerMap } from "../types.ts"; import type { AuthenticatedRequest } from "./index.ts"; @@ -69,39 +69,7 @@ export const getAppByID: HandlerMap["getAppByID"] = async ( 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, - }), - }, + config: deploymentConfigValidator.formatDeploymentConfig(currentConfig), appGroup: { standalone: appGroup.isMono, name: !appGroup.isMono ? appGroup.name : undefined, diff --git a/backend/src/handlers/getAppLogs.ts b/backend/src/handlers/getAppLogs.ts index e173fa07..e212eb62 100644 --- a/backend/src/handlers/getAppLogs.ts +++ b/backend/src/handlers/getAppLogs.ts @@ -21,6 +21,14 @@ export const getAppLogs: HandlerMap["getAppLogs"] = async ( return json(404, res, { code: 404, message: "App not found." }); } + const config = await db.app.getDeploymentConfig(app.id); + if (config.appType != "workload") { + return json(400, res, { + code: 400, + message: "Log browsing is supported only for Git and image deployments", + }); + } + res.set({ "Cache-Control": "no-cache", "Content-Type": "text/event-stream", @@ -59,7 +67,6 @@ export const getAppLogs: HandlerMap["getAppLogs"] = async ( } // 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") { diff --git a/backend/src/handlers/getDeployment.ts b/backend/src/handlers/getDeployment.ts index 68bd9a45..247f6088 100644 --- a/backend/src/handlers/getDeployment.ts +++ b/backend/src/handlers/getDeployment.ts @@ -1,5 +1,6 @@ import type { V1Pod } from "@kubernetes/client-node"; import { db } from "../db/index.ts"; +import { deploymentConfigValidator } from "../domain/index.ts"; import { getClientsForRequest } from "../lib/cluster/kubernetes.ts"; import { getNamespace } from "../lib/cluster/resources.ts"; import { getOctokit, getRepoById } from "../lib/octokit.ts"; @@ -87,7 +88,7 @@ export const getDeployment: HandlerMap["getDeployment"] = async ( return json(200, res, { repositoryURL, - commitHash: config.commitHash, + commitHash: config.source === "GIT" ? config.commitHash : "unknown", commitMessage: deployment.commitMessage, createdAt: deployment.createdAt.toISOString(), updatedAt: deployment.updatedAt.toISOString(), @@ -100,28 +101,6 @@ export const getDeployment: HandlerMap["getDeployment"] = async ( 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, - }, + config: deploymentConfigValidator.formatDeploymentConfig(config), }); }; diff --git a/backend/src/handlers/getOrgByID.ts b/backend/src/handlers/getOrgByID.ts index 2f67946f..2d7505a7 100644 --- a/backend/src/handlers/getOrgByID.ts +++ b/backend/src/handlers/getOrgByID.ts @@ -68,16 +68,18 @@ export const getOrgByID: HandlerMap["getOrgByID"] = async ( displayName: app.displayName, status: selectedDeployment?.status, source: config.source, - imageTag: config.imageTag, - repositoryURL: repoURL, - branch: config.branch, - commitHash: config.commitHash, - link: - selectedDeployment?.status === "COMPLETE" && - env.APP_DOMAIN && - config.createIngress - ? `${appDomain.protocol}//${config.subdomain}.${appDomain.host}` - : undefined, + ...(config.appType === "workload" && { + imageTag: config.imageTag, + repositoryURL: repoURL, + branch: config.branch, + commitHash: config.commitHash, + link: + selectedDeployment?.status === "COMPLETE" && + env.APP_DOMAIN && + config.createIngress + ? `${appDomain.protocol}//${config.subdomain}.${appDomain.host}` + : undefined, + }), }; }), ); diff --git a/backend/src/handlers/githubWebhook.ts b/backend/src/handlers/githubWebhook.ts index d2caf0c9..7922492d 100644 --- a/backend/src/handlers/githubWebhook.ts +++ b/backend/src/handlers/githubWebhook.ts @@ -4,6 +4,7 @@ import { db, NotFoundError } from "../db/index.ts"; import type { App, Deployment, + GitConfig, GitConfigCreate, HelmConfigCreate, Organization, @@ -195,9 +196,10 @@ export async function buildAndDeploy({ const deployment = await db.deployment.create({ appId: app.id, commitMessage, - appType: "HELM", + appType: "helm", config: configIn, }); + await cancelAllOtherDeployments(org, app, deployment.id, true); await deployFromHelm(app, deployment, configIn); return; } @@ -213,7 +215,7 @@ export async function buildAndDeploy({ db.deployment.create({ appId: app.id, commitMessage, - appType: "WORKLOAD", + appType: "workload", config: { ...configIn, imageTag }, }), db.appGroup.getById(app.appGroupId), @@ -231,7 +233,7 @@ export async function buildAndDeploy({ await cancelAllOtherDeployments(org, app, deployment.id, true); if (config.source === "GIT") { - buildAndDeployFromRepo(org, app, deployment, config, opts); + buildAndDeployFromRepo(org, app, deployment, config as GitConfig, 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 @@ -268,7 +270,7 @@ export async function buildAndDeploy({ } } -async function deployFromHelm( +export async function deployFromHelm( app: App, deployment: Deployment, config: HelmConfigCreate, @@ -302,7 +304,7 @@ export async function buildAndDeployFromRepo( org: Organization, app: App, deployment: Deployment, - config: DeploymentConfig, + config: GitConfig, opts: | { createCheckRun: true; octokit: Octokit; owner: string; repo: string } | { createCheckRun: false }, @@ -393,13 +395,17 @@ export async function createPendingWorkflowDeployment({ config, workflowRunId, ...opts -}: BuildAndDeployOptions & { workflowRunId: number }) { +}: BuildAndDeployOptions & { + workflowRunId: number; + config: WorkloadConfigCreate; +}) { 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({ + appType: "workload", appId: app.id, commitMessage, workflowRunId, @@ -462,7 +468,8 @@ export async function cancelAllOtherDeployments( if (!octokit) { octokit = await getOctokit(org.githubInstallationId); } - const repo = await getRepoById(octokit, deployment.config.repositoryId); + const config = deployment.config as GitConfig; + const repo = await getRepoById(octokit, config.repositoryId); await octokit.rest.checks.update({ check_run_id: deployment.checkRunId, owner: repo.owner.login, diff --git a/backend/src/handlers/updateApp.ts b/backend/src/handlers/updateApp.ts index 66c68540..374f7f09 100644 --- a/backend/src/handlers/updateApp.ts +++ b/backend/src/handlers/updateApp.ts @@ -1,18 +1,27 @@ import { randomBytes } from "node:crypto"; import { db, NotFoundError } from "../db/index.ts"; -import type { DeploymentConfigCreate } from "../db/models.ts"; +import { + Deployment, + HelmConfig, + HelmConfigCreate, + WorkloadConfig, + WorkloadConfigCreate, +} from "../db/models.ts"; +import { + appValidator, + deploymentConfigValidator, + deploymentService, +} from "../domain/index.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 { type HandlerMap, json } from "../types.ts"; import { buildAndDeploy, cancelAllOtherDeployments, + deployFromHelm, log, } from "./githubWebhook.ts"; import { type AuthenticatedRequest } from "./index.ts"; @@ -35,11 +44,22 @@ export const updateApp: HandlerMap["updateApp"] = async ( return json(404, res, { code: 404, message: "App not found" }); } + const organization = await db.org.getById(originalApp.orgId); + const user = await db.user.getById(req.user.id); + let metadata: Awaited< + ReturnType + >; try { - await validateDeploymentConfig(appData.config); - if (appData.appGroup) { - validateAppGroup(appData.appGroup); + if (appData.config.appType === "workload") { + await deploymentConfigValidator.validateCommonWorkloadConfig( + appData.config, + ); } + await appValidator.validateApps(organization, user, appData); + metadata = await deploymentService.prepareDeploymentMetadata( + appData.config, + organization.id, + ); } catch (e) { return json(400, res, { code: 400, @@ -47,13 +67,6 @@ export const updateApp: HandlerMap["updateApp"] = async ( }); } - 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") { @@ -73,6 +86,11 @@ export const updateApp: HandlerMap["updateApp"] = async ( appData.appGroup.type === "standalone" ? `${appData.name}-${randomBytes(4).toString("hex")}` : appData.appGroup.name; + try { + appValidator.validateAppGroupName(name); + } catch (e) { + return json(400, res, { code: 400, message: e.message }); + } const newGroupId = await db.appGroup.create( originalApp.orgId, @@ -112,68 +130,19 @@ export const updateApp: HandlerMap["updateApp"] = async ( 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, - }), - }; + const { config: updatedConfig, commitMessage } = metadata; // ---------------- 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 (shouldBuildOnUpdate(currentConfig, updatedConfig, currentDeployment)) { // 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, + commitMessage, config: updatedConfig, createCheckRun: false, }); @@ -186,22 +155,38 @@ export const updateApp: HandlerMap["updateApp"] = async ( message: "Failed to create a deployment for your app.", }); } + } else if (updatedConfig.appType === "helm") { + const deployment = await db.deployment.create({ + appId: app.id, + commitMessage, + appType: "helm", + config: updatedConfig, + }); + await cancelAllOtherDeployments(org, app, deployment.id, true); + await deployFromHelm(app, deployment, updatedConfig); + return json(200, res, {}); } else { // ---------------- Redeploy the app with the new configuration ---------------- + // To reach this block, the update must be: + // (1) from a Git deployment to a similar Git deployment, in which case the current imageTag is reused + // (2) from any deployment type to an image deployment, in which case the updatedConfig will have an imageTag 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, + updatedConfig.imageTag ?? (currentConfig as WorkloadConfig).imageTag, }, status: "DEPLOYING", + appType: "workload", appId: originalApp.id, commitMessage: currentDeployment.commitMessage, }); - const config = await db.deployment.getConfig(deployment.id); + const config = (await db.deployment.getConfig( + deployment.id, + )) as WorkloadConfig; try { const { namespace, configs, postCreate } = @@ -243,6 +228,47 @@ export const updateApp: HandlerMap["updateApp"] = async ( return json(200, res, {}); }; +const shouldBuildOnUpdate = ( + oldConfig: WorkloadConfig | HelmConfig, + newConfig: WorkloadConfigCreate | HelmConfigCreate, + currentDeployment: Deployment, +) => { + // Only Git apps need to be built + if (newConfig.source !== "GIT") { + return false; + } + + // Either this app has not been built in the past, or it has not been built successfully + if ( + oldConfig.source !== "GIT" || + !oldConfig.imageTag || + currentDeployment.status === "ERROR" + ) { + return true; + } + + // The code has changed + if ( + newConfig.branch !== oldConfig.branch || + newConfig.repositoryId != oldConfig.repositoryId || + newConfig.commitHash != oldConfig.commitHash + ) { + return true; + } + + // Build options have changed + if ( + newConfig.builder != oldConfig.builder || + newConfig.rootDir != oldConfig.rootDir || + (newConfig.builder === "dockerfile" && + newConfig.dockerfilePath != oldConfig.dockerfilePath) + ) { + return true; + } + + return false; +}; + // Patch the null(hidden) values of env vars sent from client with the sensitive plaintext export const withSensitiveEnv = ( lastPlaintextEnv: PrismaJson.EnvVar[], diff --git a/backend/src/handlers/updateDeployment.ts b/backend/src/handlers/updateDeployment.ts index 68add6b7..8cc430d1 100644 --- a/backend/src/handlers/updateDeployment.ts +++ b/backend/src/handlers/updateDeployment.ts @@ -30,6 +30,11 @@ export const updateDeployment: HandlerMap["updateDeployment"] = async ( return json(404, res, { code: 404, message: "Deployment not found." }); } + const config = await db.deployment.getConfig(deployment.id); + if (config.source !== "GIT") { + return json(400, res, { code: 400, message: "Cannot update deployment" }); + } + await db.deployment.setStatus( deployment.id, status as "BUILDING" | "DEPLOYING" | "ERROR", @@ -42,9 +47,8 @@ export const updateDeployment: HandlerMap["updateDeployment"] = async ( ); const app = await db.app.getById(deployment.appId); - const [appGroup, config, org] = await Promise.all([ + const [appGroup, org] = await Promise.all([ db.appGroup.getById(app.appGroupId), - db.deployment.getConfig(deployment.id), db.org.getById(app.orgId), ]); diff --git a/backend/src/handlers/webhook/push.ts b/backend/src/handlers/webhook/push.ts index c67effac..14f59e20 100644 --- a/backend/src/handlers/webhook/push.ts +++ b/backend/src/handlers/webhook/push.ts @@ -1,4 +1,6 @@ import { db } from "../../db/index.ts"; +import { GitConfig } from "../../db/models.ts"; +import { DeploymentRepo } from "../../db/repo/deployment.ts"; import type { components } from "../../generated/openapi.ts"; import { getOctokit } from "../../lib/octokit.ts"; import { json, type HandlerMap } from "../../types.ts"; @@ -37,7 +39,7 @@ export const handlePush: HandlerMap["githubWebhook"] = async ( for (const app of apps) { const org = await db.org.getById(app.orgId); - const config = await db.app.getDeploymentConfig(app.id); + const config = (await db.app.getDeploymentConfig(app.id)) as GitConfig; const octokit = await getOctokit(org.githubInstallationId); await buildAndDeploy({ @@ -45,27 +47,7 @@ export const handlePush: HandlerMap["githubWebhook"] = async ( 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, - }, + config: DeploymentRepo.cloneWorkloadConfig(config), createCheckRun: true, octokit, owner: payload.repository.owner.login, diff --git a/backend/src/handlers/webhook/workflow_run.ts b/backend/src/handlers/webhook/workflow_run.ts index 64e49e03..4e5c7de2 100644 --- a/backend/src/handlers/webhook/workflow_run.ts +++ b/backend/src/handlers/webhook/workflow_run.ts @@ -1,4 +1,6 @@ import { db } from "../../db/index.ts"; +import { GitConfig, WorkloadConfig } from "../../db/models.ts"; +import { DeploymentRepo } from "../../db/repo/deployment.ts"; import type { components } from "../../generated/openapi.ts"; import { getOctokit } from "../../lib/octokit.ts"; import { json, type HandlerMap } from "../../types.ts"; @@ -43,7 +45,9 @@ export const handleWorkflowRun: HandlerMap["githubWebhook"] = async ( 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 config = (await db.app.getDeploymentConfig( + app.id, + )) as WorkloadConfig; const octokit = await getOctokit(org.githubInstallationId); try { await createPendingWorkflowDeployment({ @@ -51,28 +55,7 @@ export const handleWorkflowRun: HandlerMap["githubWebhook"] = async ( 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, - }, + config: DeploymentRepo.cloneWorkloadConfig(config), workflowRunId: payload.workflow_run.id, createCheckRun: true, octokit, @@ -90,7 +73,9 @@ export const handleWorkflowRun: HandlerMap["githubWebhook"] = async ( app.id, payload.workflow_run.id, ); - const config = await db.deployment.getConfig(deployment.id); + const config = (await db.deployment.getConfig( + deployment.id, + )) as GitConfig; if (!deployment || deployment.status !== "PENDING") { // If the app was deleted, nothing to do diff --git a/backend/src/lib/builder.ts b/backend/src/lib/builder.ts index ff118b9d..f4edb927 100644 --- a/backend/src/lib/builder.ts +++ b/backend/src/lib/builder.ts @@ -5,12 +5,7 @@ import { } from "@kubernetes/client-node"; import { createHash, randomBytes } from "node:crypto"; import { db } from "../db/index.ts"; -import type { - App, - Deployment, - DeploymentConfig, - Organization, -} from "../db/models.ts"; +import type { App, Deployment, GitConfig, Organization } from "../db/models.ts"; import { generateCloneURLWithCredentials } from "../handlers/githubWebhook.ts"; import { svcK8s } from "./cluster/kubernetes.ts"; import { wrapWithLogExporter } from "./cluster/resources/logs.ts"; @@ -26,7 +21,7 @@ async function createJobFromDeployment( org: Organization, app: App, deployment: Deployment, - config: DeploymentConfig, + config: GitConfig, ) { const octokit = await getOctokit(org.githubInstallationId); const repo = await getRepoById(octokit, config.repositoryId); @@ -299,7 +294,7 @@ export async function createBuildJob( ...params: Parameters ) { const deployment = params[2] satisfies Deployment; - const config = params[3] satisfies DeploymentConfig; + const config = params[3] satisfies GitConfig; if (!["dockerfile", "railpack"].includes(config.builder)) { throw new Error( @@ -356,7 +351,7 @@ export async function dequeueBuildJob(): Promise { const app = await db.app.getById(deployment.appId); const org = await db.org.getById(app.orgId); - const config = await db.deployment.getConfig(deployment.id); + const config = (await db.deployment.getConfig(deployment.id)) as GitConfig; console.log( `Starting build job for deployment ${deployment.id} of app ${deployment.appId}`, diff --git a/backend/src/lib/cluster/resources.ts b/backend/src/lib/cluster/resources.ts index 40567214..e4222bb8 100644 --- a/backend/src/lib/cluster/resources.ts +++ b/backend/src/lib/cluster/resources.ts @@ -9,8 +9,8 @@ import type { App, AppGroup, Deployment, - DeploymentConfig, Organization, + WorkloadConfig, } from "../../db/models.ts"; import { getOctokit } from "../octokit.ts"; import { createIngressConfig } from "./resources/ingress.ts"; @@ -147,7 +147,7 @@ export const createAppConfigsFromDeployment = async ( app: App, appGroup: AppGroup, deployment: Deployment, - conf: DeploymentConfig, + conf: WorkloadConfig, ) => { const namespaceName = getNamespace(app.namespace); diff --git a/backend/src/lib/cluster/resources/statefulset.ts b/backend/src/lib/cluster/resources/statefulset.ts index 7da6fbcf..d2bfe264 100644 --- a/backend/src/lib/cluster/resources/statefulset.ts +++ b/backend/src/lib/cluster/resources/statefulset.ts @@ -1,7 +1,7 @@ import type { V1EnvVar, V1StatefulSet } from "@kubernetes/client-node"; import crypto from "node:crypto"; import type { Octokit } from "octokit"; -import type { App, Deployment, DeploymentConfig } from "../../../db/models.ts"; +import type { App, Deployment, WorkloadConfig } from "../../../db/models.ts"; import { env } from "../../env.ts"; import { getRepoById } from "../../octokit.ts"; import type { K8sObject } from "../resources.ts"; @@ -28,7 +28,7 @@ interface DeploymentParams { export const generateAutomaticEnvVars = async ( octokit: Octokit | null, deployment: Deployment, - config: DeploymentConfig, + config: WorkloadConfig, app: App, ): Promise<{ name: string; value: string }[]> => { const appDomain = URL.parse(env.APP_DOMAIN); diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 99f05784..f5b2e1be 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -2031,7 +2031,7 @@ components: - CANCELLED source: type: string - enum: [GIT, IMAGE] + enum: [GIT, IMAGE, HELM] imageTag: type: string nullable: true @@ -2205,6 +2205,7 @@ components: additionalProperties: type: string required: + - appType - port - replicas - env @@ -2246,16 +2247,7 @@ components: enum: [dockerfile, railpack] imageTag: type: string - required: - [ - source, - dockerfilePath, - rootDir, - repositoryId, - event, - eventId, - builder, - ] + required: [source, rootDir, repositoryId, event, eventId, builder] ImageDeploymentOptions: properties: source: @@ -2449,8 +2441,6 @@ components: type: integer format: int64 required: [type, id] - createIngress: - type: boolean namespace: type: string config: @@ -2473,15 +2463,12 @@ components: type: string namespace: type: string - createIngress: - type: boolean config: $ref: "#/components/schemas/DeploymentConfig" required: - name - projectId - namespace - - createIngress - config - oneOf: - $ref: "#/components/schemas/GitDeploymentOptions" From bea8f843fce51e8e0320bad223849e4d6ed0e146 Mon Sep 17 00:00:00 2001 From: zheng861 Date: Tue, 23 Dec 2025 16:40:12 -0700 Subject: [PATCH 09/38] Write migration for splitting deployment configs by app type --- .../migration.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/prisma/migrations/20251222211930_separate_workload_and_helm_configs/migration.sql b/backend/prisma/migrations/20251222211930_separate_workload_and_helm_configs/migration.sql index f2334f88..86656a19 100644 --- a/backend/prisma/migrations/20251222211930_separate_workload_and_helm_configs/migration.sql +++ b/backend/prisma/migrations/20251222211930_separate_workload_and_helm_configs/migration.sql @@ -1,5 +1,5 @@ -- CreateEnum -CREATE TYPE "AppType" AS ENUM ('WORKLOAD', 'HELM'); +CREATE TYPE "AppType" AS ENUM ('workload', 'helm'); -- CreateEnum CREATE TYPE "HelmUrlType" AS ENUM ('oci', 'absolute'); From 359b13abb7782047d0a66494c633c503a4c997b8 Mon Sep 17 00:00:00 2001 From: zheng861 Date: Wed, 24 Dec 2025 20:26:54 -0700 Subject: [PATCH 10/38] Merge branch 'main' into helm --- .../copy-railpack-frontend-image.yml | 13 +- backend/src/db/models.ts | 9 + backend/src/db/repo/appGroup.ts | 29 +- backend/src/db/repo/invitation.ts | 4 +- backend/src/domain/app.ts | 113 ---- backend/src/handlers/acceptInvitation.ts | 9 +- backend/src/handlers/claimOrg.ts | 36 +- backend/src/handlers/createApp.ts | 122 +--- backend/src/handlers/createAppGroup.ts | 121 +--- backend/src/handlers/createOrg.ts | 6 +- backend/src/handlers/deleteApp.ts | 89 +-- backend/src/handlers/deleteAppPod.ts | 35 +- backend/src/handlers/deleteOrgByID.ts | 59 +- backend/src/handlers/files.ts | 70 +- backend/src/handlers/getAppByID.ts | 83 +-- backend/src/handlers/getAppLogs.ts | 176 +---- backend/src/handlers/getAppStatus.ts | 240 +------ backend/src/handlers/getDeployment.ts | 109 +-- backend/src/handlers/getInstallation.ts | 49 +- backend/src/handlers/getOrgByID.ts | 119 +--- backend/src/handlers/getSettings.ts | 35 +- backend/src/handlers/getTemplates.ts | 11 +- backend/src/handlers/getUser.ts | 42 +- backend/src/handlers/githubAppInstall.ts | 80 +-- backend/src/handlers/githubInstallCallback.ts | 85 +-- backend/src/handlers/githubOAuthCallback.ts | 120 ++-- backend/src/handlers/githubWebhook.ts | 497 +------------- backend/src/handlers/importGitRepo.ts | 131 ++-- backend/src/handlers/index.ts | 165 ++--- backend/src/handlers/ingestLogs.ts | 63 +- backend/src/handlers/inviteUser.ts | 54 +- backend/src/handlers/isSubdomainAvailable.ts | 34 +- backend/src/handlers/listCharts.ts | 28 +- backend/src/handlers/listDeployments.ts | 106 +-- backend/src/handlers/listOrgGroups.ts | 29 +- backend/src/handlers/listOrgRepos.ts | 40 +- backend/src/handlers/listRepoBranches.ts | 47 +- backend/src/handlers/listRepoWorkflows.ts | 51 +- backend/src/handlers/removeUserFromOrg.ts | 29 +- backend/src/handlers/revokeInvitation.ts | 15 +- backend/src/handlers/setAppCD.ts | 30 +- backend/src/handlers/updateApp.ts | 288 +------- backend/src/handlers/updateDeployment.ts | 127 +--- backend/src/handlers/webhook/push.ts | 59 -- backend/src/handlers/webhook/workflow_run.ts | 124 ---- backend/src/lib/builder.ts | 7 +- backend/src/lib/cluster/resources.ts | 4 + backend/src/lib/import.ts | 8 +- backend/src/lib/octokit.ts | 17 + backend/src/service/acceptInvitation.ts | 17 + backend/src/service/claimOrg.ts | 26 + backend/src/service/common/errors.ts | 78 +++ backend/src/service/createApp.ts | 95 +++ backend/src/service/createAppGroup.ts | 87 +++ backend/src/service/createOrg.ts | 9 + backend/src/service/deleteApp.ts | 80 +++ backend/src/service/deleteAppPod.ts | 26 + backend/src/service/deleteOrgByID.ts | 21 + backend/src/service/files.ts | 49 ++ backend/src/service/getAppByID.ts | 75 +++ backend/src/service/getAppLogs.ts | 133 ++++ backend/src/service/getAppStatus.ts | 232 +++++++ backend/src/service/getDeployment.ts | 96 +++ backend/src/service/getInstallation.ts | 36 + backend/src/service/getOrgByID.ts | 104 +++ backend/src/service/getSettings.ts | 37 ++ backend/src/service/getTemplates.ts | 15 + backend/src/service/getUser.ts | 40 ++ backend/src/service/githubAppInstall.ts | 41 ++ backend/src/service/githubInstallCallback.ts | 65 ++ backend/src/service/githubOAuthCallback.ts | 87 +++ backend/src/service/githubWebhook.ts | 627 ++++++++++++++++++ backend/src/service/helper/app.ts | 168 +++++ .../{domain => service/helper}/deployment.ts | 10 +- .../helper}/deploymentConfig.ts | 16 +- .../src/{domain => service/helper}/index.ts | 10 +- .../src/{domain => service/helper}/types.ts | 2 +- backend/src/service/importGitRepo.ts | 113 ++++ backend/src/service/ingestLogs.ts | 46 ++ backend/src/service/inviteUser.ts | 34 + backend/src/service/isSubdomainAvailable.ts | 14 + backend/src/service/listCharts.ts | 21 + backend/src/service/listDeployments.ts | 91 +++ backend/src/service/listOrgGroups.ts | 18 + backend/src/service/listOrgRepos.ts | 29 + backend/src/service/listRepoBranches.ts | 46 ++ backend/src/service/listRepoWorkflows.ts | 49 ++ backend/src/service/removeUserFromOrg.ts | 26 + backend/src/service/revokeInvitation.ts | 17 + backend/src/service/setAppCD.ts | 18 + backend/src/service/updateApp.ts | 272 ++++++++ backend/src/service/updateDeployment.ts | 116 ++++ builders/railpack/Dockerfile | 51 +- frontend/src/components/ImportRepoDialog.tsx | 10 +- frontend/src/pages/ImportRepoView.tsx | 7 - log-shipper/main.go | 14 +- openapi/openapi.yaml | 22 +- 97 files changed, 4032 insertions(+), 3081 deletions(-) delete mode 100644 backend/src/domain/app.ts delete mode 100644 backend/src/handlers/webhook/push.ts delete mode 100644 backend/src/handlers/webhook/workflow_run.ts create mode 100644 backend/src/service/acceptInvitation.ts create mode 100644 backend/src/service/claimOrg.ts create mode 100644 backend/src/service/common/errors.ts create mode 100644 backend/src/service/createApp.ts create mode 100644 backend/src/service/createAppGroup.ts create mode 100644 backend/src/service/createOrg.ts create mode 100644 backend/src/service/deleteApp.ts create mode 100644 backend/src/service/deleteAppPod.ts create mode 100644 backend/src/service/deleteOrgByID.ts create mode 100644 backend/src/service/files.ts create mode 100644 backend/src/service/getAppByID.ts create mode 100644 backend/src/service/getAppLogs.ts create mode 100644 backend/src/service/getAppStatus.ts create mode 100644 backend/src/service/getDeployment.ts create mode 100644 backend/src/service/getInstallation.ts create mode 100644 backend/src/service/getOrgByID.ts create mode 100644 backend/src/service/getSettings.ts create mode 100644 backend/src/service/getTemplates.ts create mode 100644 backend/src/service/getUser.ts create mode 100644 backend/src/service/githubAppInstall.ts create mode 100644 backend/src/service/githubInstallCallback.ts create mode 100644 backend/src/service/githubOAuthCallback.ts create mode 100644 backend/src/service/githubWebhook.ts create mode 100644 backend/src/service/helper/app.ts rename backend/src/{domain => service/helper}/deployment.ts (94%) rename backend/src/{domain => service/helper}/deploymentConfig.ts (92%) rename backend/src/{domain => service/helper}/index.ts (62%) rename backend/src/{domain => service/helper}/types.ts (78%) create mode 100644 backend/src/service/importGitRepo.ts create mode 100644 backend/src/service/ingestLogs.ts create mode 100644 backend/src/service/inviteUser.ts create mode 100644 backend/src/service/isSubdomainAvailable.ts create mode 100644 backend/src/service/listCharts.ts create mode 100644 backend/src/service/listDeployments.ts create mode 100644 backend/src/service/listOrgGroups.ts create mode 100644 backend/src/service/listOrgRepos.ts create mode 100644 backend/src/service/listRepoBranches.ts create mode 100644 backend/src/service/listRepoWorkflows.ts create mode 100644 backend/src/service/removeUserFromOrg.ts create mode 100644 backend/src/service/revokeInvitation.ts create mode 100644 backend/src/service/setAppCD.ts create mode 100644 backend/src/service/updateApp.ts create mode 100644 backend/src/service/updateDeployment.ts diff --git a/.github/workflows/copy-railpack-frontend-image.yml b/.github/workflows/copy-railpack-frontend-image.yml index ac200cea..2eaf77d6 100644 --- a/.github/workflows/copy-railpack-frontend-image.yml +++ b/.github/workflows/copy-railpack-frontend-image.yml @@ -2,18 +2,27 @@ on: workflow_dispatch: push: branches: [main] - paths: [.github/workflows/copy-railpack-frontend-image.yml] + paths: + - .github/workflows/copy-railpack-frontend-image.yml + - builders/railpack/Dockerfile jobs: push_to_registry: runs-on: ubuntu-latest name: Copy railpack-frontend image to private registry steps: + - name: Checkout files + uses: actions/checkout@v6 + with: + sparse-checkout: | + builders/railpack/ + - name: Log in to container registry run: docker login -u '${{ secrets.DOCKER_USERNAME }}' -p '${{ secrets.DOCKER_PASSWORD }}' registry.anvil.rcac.purdue.edu - name: Pull, tag, and push the image run: | - TAG=$(docker pull -q ghcr.io/railwayapp/railpack-frontend:v0.15.1) + VERSION=$(cat builders/railpack/Dockerfile | grep "RAILPACK_VERSION=" | cut -d= -f 2) + TAG=$(docker pull -q ghcr.io/railwayapp/railpack-frontend:v$VERSION) NEW_TAG=registry.anvil.rcac.purdue.edu/anvilops/railpack-frontend:latest docker tag $TAG $NEW_TAG docker push $NEW_TAG diff --git a/backend/src/db/models.ts b/backend/src/db/models.ts index e5b5bf65..11fa8c8a 100644 --- a/backend/src/db/models.ts +++ b/backend/src/db/models.ts @@ -1,6 +1,7 @@ import type { DeploymentSource, DeploymentStatus, + GitHubOAuthAction, HelmUrlType, ImageBuilder, PermissionLevel, @@ -195,3 +196,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/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/domain/app.ts b/backend/src/domain/app.ts deleted file mode 100644 index 44f68acd..00000000 --- a/backend/src/domain/app.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Organization, User } from "../db/models.ts"; -import { components } from "../generated/openapi.ts"; -import { namespaceInUse } from "../lib/cluster/kubernetes.ts"; -import { canManageProject, isRancherManaged } from "../lib/cluster/rancher.ts"; -import { - MAX_GROUPNAME_LEN, - MAX_NAMESPACE_LEN, - MAX_STS_NAME_LEN, -} from "../lib/cluster/resources.ts"; -import { isRFC1123 } from "../lib/validate.ts"; -import { DeploymentConfigValidator } from "./deploymentConfig.ts"; - -interface App { - name?: string; - projectId?: string; - namespace?: string; - config: components["schemas"]["DeploymentConfig"]; -} - -export class AppValidationError extends Error {} - -export class AppValidator { - private configValidator: DeploymentConfigValidator; - constructor(configValidator: DeploymentConfigValidator) { - this.configValidator = configValidator; - } - - async validateApps(organization: Organization, user: User, ...apps: App[]) { - const appValidationErrors = ( - await Promise.all( - apps.map(async (app) => { - try { - await this.validateNewApp(app, user); - return null; - } catch (e) { - return e.message; - } - }), - ) - ).filter(Boolean); - if (appValidationErrors.length != 0) { - throw new AppValidationError(JSON.stringify(appValidationErrors)); - } - - if ( - apps.some( - (app) => - app.config.source === "git" && !organization.githubInstallationId, - ) - ) { - throw new AppValidationError( - "The AnvilOps GitHub App is not installed in this organization.", - ); - } - } - - private async validateNewApp(app: App, user: { clusterUsername: string }) { - if (isRancherManaged()) { - if (!app.projectId) { - throw new AppValidationError("Project ID is required"); - } - - if (!(await canManageProject(user.clusterUsername, app.projectId))) { - throw new AppValidationError("Project not found"); - } - } - - if (app.config.appType === "workload") { - await this.configValidator.validateCommonWorkloadConfig(app.config); - } - - if (app.namespace) { - if ( - !( - 0 < app.namespace.length && app.namespace.length <= MAX_NAMESPACE_LEN - ) || - !isRFC1123(app.namespace) - ) { - throw new AppValidationError( - "Namespace must contain only lowercase alphanumeric characters or '-', " + - "start with an alphabetic character and end with an alphanumeric character, " + - `and contain at most ${MAX_NAMESPACE_LEN} characters`, - ); - } - - if (await namespaceInUse(app.namespace)) { - throw new AppValidationError("Namespace is in use"); - } - } - if (app.name) { - this.validateAppName(app.name); - } - } - - validateAppGroupName(name: string) { - if ( - !(0 < name.length && name.length <= MAX_GROUPNAME_LEN) || - !isRFC1123(name) - ) { - throw new AppValidationError("Invalid app group name"); - } - } - - private validateAppName(name: string) { - if (name.length > MAX_STS_NAME_LEN || !isRFC1123(name)) { - throw new AppValidationError( - "App name must contain only lowercase alphanumeric characters or '-', " + - "start and end with an alphanumeric character, " + - `and contain at most ${MAX_STS_NAME_LEN} characters`, - ); - } - } -} diff --git a/backend/src/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 dfdd8764..ffd7cc9c 100644 --- a/backend/src/handlers/createApp.ts +++ b/backend/src/handlers/createApp.ts @@ -1,108 +1,40 @@ -import { randomBytes } from "node:crypto"; -import { db } from "../db/index.ts"; -import { App } from "../db/models.ts"; -import { appValidator, deploymentService } from "../domain/index.ts"; -import { PrismaClientKnownRequestError } from "../generated/prisma/internal/prismaNamespace.ts"; +import { + DeploymentError, + OrgNotFoundError, + ValidationError, +} from "../service/common/errors.ts"; +import { createApp } 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" }); - } - - const user = await db.user.getById(req.user.id); - let metadata: Awaited< - ReturnType - >; try { - await appValidator.validateApps(organization, user, appData); - metadata = await deploymentService.prepareDeploymentMetadata( - appData.config, - organization.id, - ); + const appId = await createApp(ctx.request.requestBody, req.user.id); + return json(200, res, { id: appId }); } catch (e) { - return json(400, res, { code: 400, message: e.message }); - } - - let appGroupId: number; - - if (appData.appGroup.type === "add-to") { - appGroupId = appData.appGroup.id; - if (!(await db.appGroup.getById(appGroupId))) { - return json(400, res, { code: 400, message: "App group not found" }); - } - } else { - let groupName = - appData.appGroup.type === "create-new" - ? appData.appGroup.name - : `${appData.name}-${randomBytes(4).toString("hex")}`; - try { - appValidator.validateAppGroupName(groupName); - } catch (e) { - return json(400, res, { code: 400, message: e.message }); - } - appGroupId = await db.appGroup.create( - appData.orgId, - groupName, - appData.appGroup.type === "standalone", - ); - } - - let app: App; - try { - app = await db.app.create({ - orgId: organization.id, - appGroupId, - name: app.name, - namespace: app.namespace, - clusterUsername: user?.clusterUsername, - projectId: app.projectId, - }); - } 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, + 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, + }); + } 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 create a deployment for your app.", + }); + } else { + console.error(e); + return json(500, res, { + code: 500, + message: "There was a problem creating your app.", }); } - return json(500, res, { code: 500, message: "Unable to create app." }); - } - - const { config, commitMessage } = metadata; - - try { - await buildAndDeploy({ - org: organization, - app, - imageRepo: app.imageRepo, - commitMessage: commitMessage, - config, - 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 71cd61c2..7d6a3e8a 100644 --- a/backend/src/handlers/createAppGroup.ts +++ b/backend/src/handlers/createAppGroup.ts @@ -1,100 +1,51 @@ -import { ConflictError, db } from "../db/index.ts"; -import type { App } from "../db/models.ts"; -import { appValidator, deploymentService } from "../domain/index.ts"; +import { + 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" }); - } - - const user = await db.user.getById(req.user.id); - let metadata: Awaited< - ReturnType - >[]; try { - appValidator.validateAppGroupName(data.name); - appValidator.validateApps(organization, user, ...data.apps); - metadata = await Promise.all( - data.apps.map((app) => - deploymentService.prepareDeploymentMetadata( - app.config, - organization.id, - ), - ), - ); + await createAppGroup(req.user.id, data.orgId, data.name, data.apps); } catch (e) { - return json(400, res, { code: 400, message: e.message }); - } - - const appGroupId = await db.appGroup.create( - organization.id, - data.name, - false, - ); - let apps: App[]; - try { - apps = await Promise.all( - apps.map((app) => - db.app.create({ - orgId: organization.id, - appGroupId, - name: app.name, - namespace: app.namespace, - clusterUsername: user?.clusterUsername, - projectId: app.projectId, - }), - ), - ); - } catch (err) { - if (err instanceof ConflictError && err.message === "subdomain") { - return json(409, res, { - code: 409, - message: "Subdomain must be unique.", + 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: ex.message, + }); + } 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 create a deployment for ${e.appName}.`, + }); + } else { + console.error(ex); + return json(500, res, { + code: 500, + message: `There was a problem creating ${e.appName}.`, + }); + } + } 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; } - - const appsAndMetadata = apps.map((app, idx) => ({ - app, - metadata: metadata[idx], - })); - try { - await Promise.all( - appsAndMetadata.map((pair) => - (async () => { - const { app, metadata } = pair; - await buildAndDeploy({ - org: organization, - app, - imageRepo: app.imageRepo, - commitMessage: metadata.commitMessage, - config: metadata.config, - 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 aa2f3af3..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.appType === "workload" && 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 c0c66b8f..ed1bc53f 100644 --- a/backend/src/handlers/files.ts +++ b/backend/src/handlers/files.ts @@ -1,13 +1,15 @@ -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, + ValidationError, +} 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 +24,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 +39,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 +62,7 @@ export const writeAppFile: HandlerMap["writeAppFile"] = async ( ); }; -export const deleteAppFile: HandlerMap["deleteAppFile"] = async ( +export const deleteAppFileHandler: HandlerMap["deleteAppFile"] = async ( ctx, req: AuthenticatedRequest, res, @@ -81,39 +83,29 @@ 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.appType !== "workload") { - return json(400, res, { - code: 400, - message: "File browsing is supported only for Git and image deployments", - }); - } - - 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, {}); + } else if (e instanceof ValidationError) { + return json(400, res, { code: 400, res: e.message }); + } else { + 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 1472c1d2..ac066b17 100644 --- a/backend/src/handlers/getAppByID.ts +++ b/backend/src/handlers/getAppByID.ts @@ -1,81 +1,20 @@ -import { db } from "../db/index.ts"; -import { deploymentConfigValidator } from "../domain/index.ts"; -import { getClientsForRequest } from "../lib/cluster/kubernetes.ts"; -import { getNamespace } from "../lib/cluster/resources.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: deploymentConfigValidator.formatDeploymentConfig(currentConfig), - 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 e212eb62..dc93ebe3 100644 --- a/backend/src/handlers/getAppLogs.ts +++ b/backend/src/handlers/getAppLogs.ts @@ -1,165 +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." }); - } - - const config = await db.app.getDeploymentConfig(app.id); - if (config.appType != "workload") { - return json(400, res, { - code: 400, - message: "Log browsing is supported only for Git and image deployments", - }); - } - - 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 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 247f6088..37612d15 100644 --- a/backend/src/handlers/getDeployment.ts +++ b/backend/src/handlers/getDeployment.ts @@ -1,106 +1,23 @@ -import type { V1Pod } from "@kubernetes/client-node"; -import { db } from "../db/index.ts"; -import { deploymentConfigValidator } from "../domain/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.source === "GIT" ? config.commitHash : "unknown", - 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: deploymentConfigValidator.formatDeploymentConfig(config), - }); }; 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 2d7505a7..9f2796b1 100644 --- a/backend/src/handlers/getOrgByID.ts +++ b/backend/src/handlers/getOrgByID.ts @@ -1,114 +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, - ...(config.appType === "workload" && { - imageTag: config.imageTag, - repositoryURL: repoURL, - branch: config.branch, - commitHash: config.commitHash, - link: - selectedDeployment?.status === "COMPLETE" && - env.APP_DOMAIN && - config.createIngress - ? `${appDomain.protocol}//${config.subdomain}.${appDomain.host}` - : undefined, - }), - }; - }), - ); - - 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 7922492d..cd250c2b 100644 --- a/backend/src/handlers/githubWebhook.ts +++ b/backend/src/handlers/githubWebhook.ts @@ -1,51 +1,19 @@ import { Webhooks } from "@octokit/webhooks"; -import type { Octokit } from "octokit"; -import { db, NotFoundError } from "../db/index.ts"; -import type { - App, - Deployment, - GitConfig, - GitConfigCreate, - HelmConfigCreate, - Organization, - WorkloadConfigCreate, -} 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 { upgrade } from "../lib/helm.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; @@ -62,448 +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: WorkloadConfigCreate | GitConfigCreate | HelmConfigCreate; -} & ( - | { createCheckRun: true; octokit: Octokit; owner: string; repo: string } - | { createCheckRun: false } -); - -export async function buildAndDeploy({ - org, - app, - imageRepo, - commitMessage, - config: configIn, - ...opts -}: BuildAndDeployOptions) { - if (configIn.source === "HELM") { - configIn = configIn as HelmConfigCreate; - const deployment = await db.deployment.create({ - appId: app.id, - commitMessage, - appType: "helm", - config: configIn, - }); - await cancelAllOtherDeployments(org, app, deployment.id, true); - await deployFromHelm(app, deployment, configIn); - return; - } - - configIn = configIn as WorkloadConfigCreate; - - 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, - appType: "workload", - 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 as GitConfig, 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 deployFromHelm( - app: App, - deployment: Deployment, - config: HelmConfigCreate, -) { - log(deployment.id, "BUILD", "Deploying directly from Helm chart..."); try { - await upgrade({ - urlType: config.urlType, - chartURL: config.url, - version: config.version, - namespace: app.namespace, - release: app.name, - values: config.values, - }); + await processGitHubWebhookPayload(requestType, action, JSON.parse(data)); + return json(200, res, {}); } 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: GitConfig, - 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; - config: WorkloadConfigCreate; -}) { - 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({ - appType: "workload", - 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 config = deployment.config as GitConfig; - const repo = await getRepoById(octokit, 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 928c137c..ea58f5f8 100644 --- a/backend/src/handlers/index.ts +++ b/backend/src/handlers/index.ts @@ -1,48 +1,51 @@ 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 { listCharts } from "./listCharts.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 { listChartsHandler } from "./listCharts.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: { @@ -53,46 +56,46 @@ 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, - listCharts, - 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, + listCharts: listChartsHandler, + 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/listCharts.ts b/backend/src/handlers/listCharts.ts index 060ddad0..c66c4241 100644 --- a/backend/src/handlers/listCharts.ts +++ b/backend/src/handlers/listCharts.ts @@ -1,23 +1,9 @@ -import { env } from "../lib/env.ts"; -import { getChart } from "../lib/helm.ts"; -import { getRepositoriesByProject } from "../lib/registry.ts"; +import { listCharts } from "../service/listCharts.ts"; import { json, type HandlerMap } from "../types.ts"; - -export const listCharts: HandlerMap["listCharts"] = async (ctx, req, res) => { - const repos = await getRepositoriesByProject(env.CHART_PROJECT_NAME); - const charts = await Promise.all( - repos.map(async (repo) => { - const url = `oci://${env.REGISTRY_HOSTNAME}/${repo.name}`; - const chart = await getChart(url); - return { - name: chart.name, - note: chart.annotations["anvilops-note"], - url, - urlType: "oci", - version: chart.version, - valueSpec: JSON.parse(chart.annotations["anvilops-values"] ?? ""), - }; - }), - ); - return json(200, res, charts); +export const listChartsHandler: HandlerMap["listCharts"] = async ( + ctx, + req, + res, +) => { + return json(200, res, await listCharts()); }; 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 374f7f09..a8b21b94 100644 --- a/backend/src/handlers/updateApp.ts +++ b/backend/src/handlers/updateApp.ts @@ -1,294 +1,32 @@ -import { randomBytes } from "node:crypto"; -import { db, NotFoundError } from "../db/index.ts"; import { - Deployment, - HelmConfig, - HelmConfigCreate, - WorkloadConfig, - WorkloadConfigCreate, -} from "../db/models.ts"; -import { - appValidator, - deploymentConfigValidator, - deploymentService, -} from "../domain/index.ts"; -import { - createOrUpdateApp, - getClientsForRequest, -} from "../lib/cluster/kubernetes.ts"; -import { createAppConfigsFromDeployment } from "../lib/cluster/resources.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, - deployFromHelm, - 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" }); - } - - const organization = await db.org.getById(originalApp.orgId); - const user = await db.user.getById(req.user.id); - let metadata: Awaited< - ReturnType - >; try { - if (appData.config.appType === "workload") { - await deploymentConfigValidator.validateCommonWorkloadConfig( - appData.config, - ); - } - await appValidator.validateApps(organization, user, appData); - metadata = await deploymentService.prepareDeploymentMetadata( - appData.config, - organization.id, - ); + 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, - }); - } - - // ---------------- 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; - try { - appValidator.validateAppGroupName(name); - } catch (e) { + 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 }); - } - - 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 { config: updatedConfig, commitMessage } = metadata; - - // ---------------- Rebuild if necessary ---------------- - - if (shouldBuildOnUpdate(currentConfig, updatedConfig, currentDeployment)) { - // 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. - try { - await buildAndDeploy({ - app: originalApp, - org: org, - imageRepo: originalApp.imageRepo, - commitMessage, - 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); + } else if (e instanceof DeploymentError) { return json(500, res, { code: 500, message: "Failed to create a deployment for your app.", }); } - } else if (updatedConfig.appType === "helm") { - const deployment = await db.deployment.create({ - appId: app.id, - commitMessage, - appType: "helm", - config: updatedConfig, - }); - await cancelAllOtherDeployments(org, app, deployment.id, true); - await deployFromHelm(app, deployment, updatedConfig); - return json(200, res, {}); - } else { - // ---------------- Redeploy the app with the new configuration ---------------- - // To reach this block, the update must be: - // (1) from a Git deployment to a similar Git deployment, in which case the current imageTag is reused - // (2) from any deployment type to an image deployment, in which case the updatedConfig will have an imageTag - 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 as WorkloadConfig).imageTag, - }, - status: "DEPLOYING", - appType: "workload", - appId: originalApp.id, - commitMessage: currentDeployment.commitMessage, - }); - - const config = (await db.deployment.getConfig( - deployment.id, - )) as WorkloadConfig; - - 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, {}); -}; - -const shouldBuildOnUpdate = ( - oldConfig: WorkloadConfig | HelmConfig, - newConfig: WorkloadConfigCreate | HelmConfigCreate, - currentDeployment: Deployment, -) => { - // Only Git apps need to be built - if (newConfig.source !== "GIT") { - return false; - } - - // Either this app has not been built in the past, or it has not been built successfully - if ( - oldConfig.source !== "GIT" || - !oldConfig.imageTag || - currentDeployment.status === "ERROR" - ) { - return true; - } - - // The code has changed - if ( - newConfig.branch !== oldConfig.branch || - newConfig.repositoryId != oldConfig.repositoryId || - newConfig.commitHash != oldConfig.commitHash - ) { - return true; - } - - // Build options have changed - if ( - newConfig.builder != oldConfig.builder || - newConfig.rootDir != oldConfig.rootDir || - (newConfig.builder === "dockerfile" && - newConfig.dockerfilePath != oldConfig.dockerfilePath) - ) { - return true; - } - - return false; -}; - -// Patch the null(hidden) values of env vars sent from client with the sensitive plaintext -export const withSensitiveEnv = ( - 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 8cc430d1..480f3289 100644 --- a/backend/src/handlers/updateDeployment.ts +++ b/backend/src/handlers/updateDeployment.ts @@ -1,124 +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." }); - } - - const config = await db.deployment.getConfig(deployment.id); - if (config.source !== "GIT") { - return json(400, res, { code: 400, message: "Cannot update deployment" }); - } - - 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, org] = await Promise.all([ - db.appGroup.getById(app.appGroupId), - 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); + 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; } - - 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", - ); - } - } - - 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 14f59e20..00000000 --- a/backend/src/handlers/webhook/push.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { db } from "../../db/index.ts"; -import { GitConfig } from "../../db/models.ts"; -import { DeploymentRepo } from "../../db/repo/deployment.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)) as GitConfig; - const octokit = await getOctokit(org.githubInstallationId); - - await buildAndDeploy({ - org: org, - app: app, - imageRepo: app.imageRepo, - commitMessage: payload.head_commit.message, - config: DeploymentRepo.cloneWorkloadConfig(config), - 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 4e5c7de2..00000000 --- a/backend/src/handlers/webhook/workflow_run.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { db } from "../../db/index.ts"; -import { GitConfig, WorkloadConfig } from "../../db/models.ts"; -import { DeploymentRepo } from "../../db/repo/deployment.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, - )) as WorkloadConfig; - const octokit = await getOctokit(org.githubInstallationId); - try { - await createPendingWorkflowDeployment({ - org: org, - app: app, - imageRepo: app.imageRepo, - commitMessage: payload.workflow_run.head_commit.message, - config: DeploymentRepo.cloneWorkloadConfig(config), - 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, - )) as GitConfig; - - 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/lib/builder.ts b/backend/src/lib/builder.ts index f4edb927..998bd2d7 100644 --- a/backend/src/lib/builder.ts +++ b/backend/src/lib/builder.ts @@ -6,12 +6,15 @@ import { import { createHash, randomBytes } from "node:crypto"; import { db } from "../db/index.ts"; import type { App, Deployment, GitConfig, 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}`; diff --git a/backend/src/lib/cluster/resources.ts b/backend/src/lib/cluster/resources.ts index e4222bb8..4d061300 100644 --- a/backend/src/lib/cluster/resources.ts +++ b/backend/src/lib/cluster/resources.ts @@ -5,6 +5,7 @@ import type { V1Namespace, V1Secret, } from "@kubernetes/client-node"; +import { randomBytes } from "node:crypto"; import type { App, AppGroup, @@ -36,6 +37,9 @@ export const MAX_GROUPNAME_LEN = 50; // The names of its pods, which are `{statefulset name}-{pod #}` also must pass RFC 1123 export const MAX_STS_NAME_LEN = 60; +export const getRandomTag = (): string => randomBytes(4).toString("hex"); +export const RANDOM_TAG_LEN = 8; + export const getNamespace = (subdomain: string) => NAMESPACE_PREFIX + subdomain; export interface K8sObject { 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..97d5a8aa 100644 --- a/backend/src/lib/octokit.ts +++ b/backend/src/lib/octokit.ts @@ -89,3 +89,20 @@ 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(); +} 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..6fa2a333 --- /dev/null +++ b/backend/src/service/createApp.ts @@ -0,0 +1,95 @@ +import { ConflictError, db } from "../db/index.ts"; +import type { App } from "../db/models.ts"; +import type { components } from "../generated/openapi.ts"; +import { + getRandomTag, + MAX_GROUPNAME_LEN, + RANDOM_TAG_LEN, +} from "../lib/cluster/resources.ts"; +import { + DeploymentError, + OrgNotFoundError, + ValidationError, +} from "./common/errors.ts"; +import { buildAndDeploy } from "./githubWebhook.ts"; +import { appService } from "./helper/index.ts"; + +export type NewApp = components["schemas"]["NewApp"]; + +export async function createApp(appData: NewApp, userId: number) { + const [organization, user] = await Promise.all([ + this.orgRepo.getById(appData.orgId, { requireUser: { id: userId } }), + this.userRepo.getById(userId), + ]); + + if (!organization) { + throw new OrgNotFoundError(null); + } + + let app: App; + let appGroupId: number; + + switch (appData.appGroup.type) { + case "add-to": { + const group = await db.appGroup.getById(appData.appGroup.id); + if (!group) { + throw new ValidationError("Invalid app group"); + } + appGroupId = appData.appGroup.id; + break; + } + + case "create-new": { + appService.validateAppGroupName(appData.appGroup.name); + appGroupId = await db.appGroup.create( + appData.orgId, + appData.appGroup.name, + false, + ); + } + + case "standalone": { + // In this case, group name is constructed from the app name + // App name was previously validated. If it passed RFC1123, then + // a substring plus random tag will also pass, so no re-validation + let groupName = `${appData.name.substring(0, MAX_GROUPNAME_LEN - RANDOM_TAG_LEN - 1)}-${getRandomTag()}`; + appGroupId = await db.appGroup.create(appData.orgId, groupName, true); + break; + } + } + + const { config, commitMessage } = ( + await appService.prepareMetadataForApps(organization, user, appData) + )[0]; + + try { + app = await db.app.create({ + orgId: appData.orgId, + appGroupId: appGroupId, + name: appData.name, + clusterUsername: user.clusterUsername, + projectId: appData.projectId, + namespace: appData.namespace, + }); + } catch (err) { + // In between validation and creating the app, the namespace was taken by another app + if (err instanceof ConflictError) { + throw new ValidationError(err.message + " is unavailable"); + } + } + + try { + await buildAndDeploy({ + org: organization, + app, + imageRepo: app.imageRepo, + commitMessage, + config, + 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..4d0be3af --- /dev/null +++ b/backend/src/service/createAppGroup.ts @@ -0,0 +1,87 @@ +import { ConflictError, db } from "../db/index.ts"; +import { App } from "../db/models.ts"; +import type { components } from "../generated/openapi.ts"; +import { + DeploymentError, + OrgNotFoundError, + ValidationError, +} from "../service/common/errors.ts"; +import { type NewApp } from "../service/createApp.ts"; +import { buildAndDeploy } from "./githubWebhook.ts"; +import { appService } from "./helper/index.ts"; + +export type NewAppWithoutGroup = + components["schemas"]["NewAppWithoutGroupInfo"]; + +export async function createAppGroup( + userId: number, + orgId: number, + groupName: string, + appData: NewAppWithoutGroup[], +) { + // validate all apps before creating any + appService.validateAppGroupName(groupName); + const groupId = await db.appGroup.create(orgId, groupName, false); + + const appsWithGroups = appData.map( + (app) => + ({ + ...app, + orgId: orgId, + appGroup: { type: "add-to", id: groupId }, + }) satisfies NewApp, + ); + + const [organization, user] = await Promise.all([ + db.org.getById(orgId, { requireUser: { id: userId } }), + db.user.getById(userId), + ]); + + if (!organization) { + throw new OrgNotFoundError(null); + } + + const validationResults = await appService.prepareMetadataForApps( + organization, + user, + ...appData, + ); + + const appsWithMetadata = appsWithGroups.map((app, idx) => ({ + appData: app, + metadata: validationResults[idx], + })); + + for (const { appData, metadata } of appsWithMetadata) { + const { config, commitMessage } = metadata; + let app: App; + try { + app = await db.app.create({ + orgId: appData.orgId, + appGroupId: groupId, + name: appData.name, + clusterUsername: user.clusterUsername, + projectId: appData.projectId, + namespace: appData.namespace, + }); + } catch (err) { + // In between validation and creating the app, the namespace was taken by another app + if (err instanceof ConflictError) { + throw new ValidationError(err.message + " is unavailable"); + } + } + + try { + await buildAndDeploy({ + org: organization, + app, + imageRepo: app.imageRepo, + commitMessage, + config, + createCheckRun: false, + }); + } catch (err) { + throw new DeploymentError(err); + } + } +} 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..05404b7b --- /dev/null +++ b/backend/src/service/deleteApp.ts @@ -0,0 +1,80 @@ +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); + const config = await db.deployment.getConfig(lastDeployment.id); + + if (!keepNamespace) { + try { + const { KubernetesObjectApi: api } = await getClientsForRequest( + userId, + projectId, + ["KubernetesObjectApi"], + ); + await deleteNamespace(api, getNamespace(namespace)); + } catch (err) { + console.error("Failed to delete namespace:", err); + } + } else if (config.appType === "workload" && 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..6f332011 --- /dev/null +++ b/backend/src/service/files.ts @@ -0,0 +1,49 @@ +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, + ValidationError, +} 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.appType !== "workload") { + throw new ValidationError( + "File browsing is supported only for Git and image deployments", + ); + } + + if ( + !config.mounts.some((mount) => + volumeClaimName.startsWith(generateVolumeName(mount.path) + "-"), + ) + ) { + // 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..1ad8a8e4 --- /dev/null +++ b/backend/src/service/getAppByID.ts @@ -0,0 +1,75 @@ +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 { AppNotFoundError } from "./common/errors.ts"; +import { deploymentConfigValidator } from "./helper/index.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: deploymentConfigValidator.formatDeploymentConfig(currentConfig), + appGroup: { + standalone: appGroup.isMono, + name: !appGroup.isMono ? appGroup.name : undefined, + 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..72a19a3f --- /dev/null +++ b/backend/src/service/getAppLogs.ts @@ -0,0 +1,133 @@ +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, ValidationError } 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); + + if (config.appType != "workload") { + throw new ValidationError( + "Log browsing is supported only for Git and image deployments", + ); + } + + 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..5c37845c --- /dev/null +++ b/backend/src/service/getDeployment.ts @@ -0,0 +1,96 @@ +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"; +import { deploymentConfigValidator } from "./helper/index.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.source === "GIT" ? config.commitHash : "unknown", + 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: deploymentConfigValidator.formatDeploymentConfig(config), + }; +} 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..1f5d6309 --- /dev/null +++ b/backend/src/service/getOrgByID.ts @@ -0,0 +1,104 @@ +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, + ...(config.appType === "workload" && { + imageTag: config.imageTag, + repositoryURL: repoURL, + branch: config.branch, + commitHash: config.commitHash, + link: + selectedDeployment?.status === "COMPLETE" && + env.APP_DOMAIN && + config.createIngress + ? `${appDomain.protocol}//${config.subdomain}.${appDomain.host}` + : undefined, + }), + }; + }), + ); + + 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..45322724 --- /dev/null +++ b/backend/src/service/githubWebhook.ts @@ -0,0 +1,627 @@ +import type { Octokit } from "octokit"; +import { db, NotFoundError } from "../db/index.ts"; +import type { + App, + Deployment, + GitConfig, + GitConfigCreate, + HelmConfigCreate, + Organization, + WorkloadConfigCreate, +} from "../db/models.ts"; +import { DeploymentRepo } from "../db/repo/deployment.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 { upgrade } from "../lib/helm.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)) as GitConfig; + const octokit = await getOctokit(org.githubInstallationId); + + await buildAndDeploy({ + org: org, + app: app, + imageRepo: app.imageRepo, + commitMessage: payload.head_commit.message, + config: DeploymentRepo.cloneWorkloadConfig(config), + 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)) as GitConfig; + const octokit = await getOctokit(org.githubInstallationId); + try { + await createPendingWorkflowDeployment({ + org: org, + app: app, + imageRepo: app.imageRepo, + commitMessage: payload.workflow_run.head_commit.message, + config: DeploymentRepo.cloneWorkloadConfig(config), + 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, + )) as GitConfig; + + 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: WorkloadConfigCreate | GitConfigCreate | HelmConfigCreate; +} & ( + | { createCheckRun: true; octokit: Octokit; owner: string; repo: string } + | { createCheckRun: false } +); + +export async function buildAndDeploy({ + org, + app, + imageRepo, + commitMessage, + config: configIn, + ...opts +}: BuildAndDeployOptions) { + if (configIn.source === "HELM") { + configIn = configIn as HelmConfigCreate; + const deployment = await db.deployment.create({ + appId: app.id, + commitMessage, + appType: "helm", + config: configIn, + }); + await cancelAllOtherDeployments(org, app, deployment.id, true); + await deployFromHelm(app, deployment, configIn); + return; + } + + configIn = configIn as WorkloadConfigCreate; + + 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, + appType: "workload", + 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 as GitConfig, 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 deployFromHelm( + app: App, + deployment: Deployment, + config: HelmConfigCreate, +) { + log(deployment.id, "BUILD", "Deploying directly from Helm chart..."); + try { + await upgrade({ + urlType: config.urlType, + chartURL: config.url, + version: config.version, + namespace: app.namespace, + release: app.name, + values: config.values, + }); + } 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: GitConfig, + 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; + config: WorkloadConfigCreate; +}) { + 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, + appType: "workload", + 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 config = deployment.config as GitConfig; + + const repo = await getRepoById(octokit, 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/helper/app.ts b/backend/src/service/helper/app.ts new file mode 100644 index 00000000..5a8ec081 --- /dev/null +++ b/backend/src/service/helper/app.ts @@ -0,0 +1,168 @@ +import { Organization, User } from "../../db/models.ts"; +import { components } from "../../generated/openapi.ts"; +import { namespaceInUse } from "../../lib/cluster/kubernetes.ts"; +import { + canManageProject, + isRancherManaged, +} from "../../lib/cluster/rancher.ts"; +import { + MAX_GROUPNAME_LEN, + MAX_NAMESPACE_LEN, + MAX_STS_NAME_LEN, +} from "../../lib/cluster/resources.ts"; +import { isRFC1123 } from "../../lib/validate.ts"; +import { ValidationError } from "../../service/common/errors.ts"; +import { DeploymentService } from "./deployment.ts"; +import { DeploymentConfigValidator } from "./deploymentConfig.ts"; + +export interface App { + name?: string; + projectId?: string; + namespace?: string; + config: components["schemas"]["DeploymentConfig"]; +} + +export class AppService { + private configValidator: DeploymentConfigValidator; + private deploymentService: DeploymentService; + constructor( + configValidator: DeploymentConfigValidator, + deploymentService: DeploymentService, + ) { + this.configValidator = configValidator; + this.deploymentService = deploymentService; + } + + /** + * @throws ValidationError, OrgNotFoundError + */ + async prepareMetadataForApps( + organization: Organization, + user: User, + ...apps: App[] + ) { + const appValidationErrors = ( + await Promise.all( + apps.map(async (app) => { + try { + await this.validateNewApp(app, user); + return null; + } catch (e) { + return e.message; + } + }), + ) + ).filter(Boolean); + if (appValidationErrors.length != 0) { + throw new ValidationError(JSON.stringify(appValidationErrors)); + } + + if ( + apps.some( + (app) => + app.config.source === "git" && !organization.githubInstallationId, + ) + ) { + throw new ValidationError( + "The AnvilOps GitHub App is not installed in this organization.", + ); + } + + const metadata: ( + | Awaited< + ReturnType + > + | Error + )[] = await Promise.all( + apps.map((app) => { + try { + return this.deploymentService.prepareDeploymentMetadata( + app.config, + organization.id, + ); + } catch (e) { + return e; + } + }), + ); + + const errors = metadata.filter((res) => res instanceof ValidationError); + if (errors.length > 0) { + throw new ValidationError(errors.map((err) => err.message).join(",")); + } + + return metadata as Awaited< + ReturnType + >[]; + } + + /** + * @throws ValidationError + */ + private async validateNewApp(app: App, user: { clusterUsername: string }) { + if (isRancherManaged()) { + if (!app.projectId) { + throw new ValidationError("Project ID is required"); + } + + if (!(await canManageProject(user.clusterUsername, app.projectId))) { + throw new ValidationError("Project not found"); + } + } + + if (app.config.appType === "workload") { + await this.configValidator.validateCommonWorkloadConfig(app.config); + } + + if (app.namespace) { + if ( + !( + 0 < app.namespace.length && app.namespace.length <= MAX_NAMESPACE_LEN + ) || + !isRFC1123(app.namespace) + ) { + throw new ValidationError( + "Namespace must contain only lowercase alphanumeric characters or '-', " + + "start with an alphabetic character and end with an alphanumeric character, " + + `and contain at most ${MAX_NAMESPACE_LEN} characters`, + ); + } + + if (await namespaceInUse(app.namespace)) { + throw new ValidationError("namespace is unavailable"); + } + } + if (app.name) { + this.validateAppName(app.name); + } + } + + /** + * @throws ValidationError + */ + validateAppGroupName(name: string) { + if ( + !(0 < name.length && name.length <= MAX_GROUPNAME_LEN) || + !isRFC1123(name) + ) { + throw new ValidationError( + "App group name must contain only lowercase alphanumeric characters or '-', " + + "start with an alphabetic character and end with an alphanumeric character, " + + `and contain at most ${MAX_GROUPNAME_LEN} characters`, + ); + } + } + + /** + * @throws ValidationError + */ + private validateAppName(name: string) { + if (name.length > MAX_STS_NAME_LEN || !isRFC1123(name)) { + throw new ValidationError( + "App name must contain only lowercase alphanumeric characters or '-', " + + "start and end with an alphanumeric character, " + + `and contain at most ${MAX_STS_NAME_LEN} characters`, + ); + } + } +} diff --git a/backend/src/domain/deployment.ts b/backend/src/service/helper/deployment.ts similarity index 94% rename from backend/src/domain/deployment.ts rename to backend/src/service/helper/deployment.ts index d4ab53f2..12fdcb95 100644 --- a/backend/src/domain/deployment.ts +++ b/backend/src/service/helper/deployment.ts @@ -3,9 +3,9 @@ import { GitConfigCreate, HelmConfigCreate, WorkloadConfigCreate, -} from "../db/models.ts"; -import { components } from "../generated/openapi.ts"; -import { getOctokit, getRepoById } from "../lib/octokit.ts"; +} from "../../db/models.ts"; +import { components } from "../../generated/openapi.ts"; +import { getOctokit, getRepoById } from "../../lib/octokit.ts"; import { type DeploymentConfigValidator } from "./deploymentConfig.ts"; import { deploymentConfigValidator } from "./index.ts"; import { GitWorkloadConfig } from "./types.ts"; @@ -99,7 +99,7 @@ export class DeploymentService { } } - createCommonWorkloadConfig( + private createCommonWorkloadConfig( config: components["schemas"]["WorkloadConfigOptions"], ) { return { @@ -118,7 +118,7 @@ export class DeploymentService { }; } - async createGitConfig( + private async createGitConfig( config: GitWorkloadConfig, commitHash: string, repositoryId: number, diff --git a/backend/src/domain/deploymentConfig.ts b/backend/src/service/helper/deploymentConfig.ts similarity index 92% rename from backend/src/domain/deploymentConfig.ts rename to backend/src/service/helper/deploymentConfig.ts index a73fcdd0..d00b1e48 100644 --- a/backend/src/domain/deploymentConfig.ts +++ b/backend/src/service/helper/deploymentConfig.ts @@ -1,12 +1,12 @@ import { Octokit } from "octokit"; -import { HelmConfig, WorkloadConfig } from "../db/models.ts"; -import { AppRepo } from "../db/repo/app.ts"; -import { components } from "../generated/openapi.ts"; -import { MAX_SUBDOMAIN_LEN } from "../lib/cluster/resources.ts"; -import { getImageConfig } from "../lib/cluster/resources/logs.ts"; -import { generateVolumeName } from "../lib/cluster/resources/statefulset.ts"; -import { getRepoById } from "../lib/octokit.ts"; -import { isRFC1123 } from "../lib/validate.ts"; +import { HelmConfig, WorkloadConfig } from "../../db/models.ts"; +import { AppRepo } from "../../db/repo/app.ts"; +import { components } from "../../generated/openapi.ts"; +import { MAX_SUBDOMAIN_LEN } from "../../lib/cluster/resources.ts"; +import { getImageConfig } from "../../lib/cluster/resources/logs.ts"; +import { generateVolumeName } from "../../lib/cluster/resources/statefulset.ts"; +import { getRepoById } from "../../lib/octokit.ts"; +import { isRFC1123 } from "../../lib/validate.ts"; import { GitWorkloadConfig, ImageWorkloadConfig } from "./types.ts"; export class DeploymentConfigValidator { diff --git a/backend/src/domain/index.ts b/backend/src/service/helper/index.ts similarity index 62% rename from backend/src/domain/index.ts rename to backend/src/service/helper/index.ts index 583e8df7..59e1e00c 100644 --- a/backend/src/domain/index.ts +++ b/backend/src/service/helper/index.ts @@ -1,10 +1,14 @@ -import { db } from "../db/index.ts"; -import { AppValidator } from "./app.ts"; +import { db } from "../../db/index.ts"; +import { AppService } from "./app.ts"; import { DeploymentService } from "./deployment.ts"; import { DeploymentConfigValidator } from "./deploymentConfig.ts"; export const deploymentConfigValidator = new DeploymentConfigValidator(db.app); -export const appValidator = new AppValidator(deploymentConfigValidator); export const deploymentService = new DeploymentService( deploymentConfigValidator, ); + +export const appService = new AppService( + deploymentConfigValidator, + deploymentService, +); diff --git a/backend/src/domain/types.ts b/backend/src/service/helper/types.ts similarity index 78% rename from backend/src/domain/types.ts rename to backend/src/service/helper/types.ts index 3f356d76..82c600fb 100644 --- a/backend/src/domain/types.ts +++ b/backend/src/service/helper/types.ts @@ -1,4 +1,4 @@ -import { components } from "../generated/openapi.ts"; +import { components } from "../../generated/openapi.ts"; export type GitWorkloadConfig = components["schemas"]["WorkloadConfigOptions"] & { source: "git" }; 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/listCharts.ts b/backend/src/service/listCharts.ts new file mode 100644 index 00000000..f100029f --- /dev/null +++ b/backend/src/service/listCharts.ts @@ -0,0 +1,21 @@ +import { env } from "../lib/env.ts"; +import { getChart } from "../lib/helm.ts"; +import { getRepositoriesByProject } from "../lib/registry.ts"; + +export async function listCharts() { + const repos = await getRepositoriesByProject(env.CHART_PROJECT_NAME); + return await Promise.all( + repos.map(async (repo) => { + const url = `oci://${env.REGISTRY_HOSTNAME}/${repo.name}`; + const chart = await getChart(url); + return { + name: chart.name, + note: chart.annotations["anvilops-note"], + url, + urlType: "oci", + version: chart.version, + valueSpec: JSON.parse(chart.annotations["anvilops-values"] ?? ""), + }; + }), + ); +} 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..32be0cbb --- /dev/null +++ b/backend/src/service/updateApp.ts @@ -0,0 +1,272 @@ +import { db } from "../db/index.ts"; +import { + Deployment, + HelmConfig, + HelmConfigCreate, + WorkloadConfig, + WorkloadConfigCreate, +} from "../db/models.ts"; +import type { components } from "../generated/openapi.ts"; +import { + createOrUpdateApp, + getClientsForRequest, +} from "../lib/cluster/kubernetes.ts"; +import { + createAppConfigsFromDeployment, + getRandomTag, + MAX_GROUPNAME_LEN, + RANDOM_TAG_LEN, +} from "../lib/cluster/resources.ts"; +import { + buildAndDeploy, + cancelAllOtherDeployments, + deployFromHelm, + log, +} from "../service/githubWebhook.ts"; +import { + AppNotFoundError, + DeploymentError, + ValidationError, +} from "./common/errors.ts"; +import { appService } from "./helper/index.ts"; + +export type AppUpdate = components["schemas"]["AppUpdate"]; + +export async function updateApp( + appId: number, + userId: number, + appData: AppUpdate, +) { + const originalApp = await db.app.getById(appId, { + requireUser: { id: userId }, + }); + + if (!originalApp) { + throw new AppNotFoundError(); + } + + const [organization, user] = await Promise.all([ + this.orgRepo.getById(originalApp.orgId, { requireUser: { id: userId } }), + this.userRepo.getById(userId), + ]); + + // performs validation + const { config: updatedConfig, commitMessage } = ( + await appService.prepareMetadataForApps(organization, user, appData) + )[0]; + + // ---------------- App group updates ---------------- + switch (appData.appGroup.type) { + case "add-to": { + const group = await db.appGroup.getById(appData.appGroup.id); + if (!group) { + throw new ValidationError("Invalid app group"); + } + db.app.setGroup(originalApp.id, appData.appGroup.id); + break; + } + + case "create-new": { + appService.validateAppGroupName(appData.appGroup.name); + const appGroupId = await db.appGroup.create( + originalApp.orgId, + appData.appGroup.name, + false, + ); + db.app.setGroup(originalApp.id, appGroupId); + break; + } + + case "standalone": { + // In this case, group name is constructed from the app name + // App name was previously validated. If it passed RFC1123, then + // a substring plus random tag will also pass, so no re-validation + let groupName = `${appData.name.substring(0, MAX_GROUPNAME_LEN - RANDOM_TAG_LEN - 1)}-${getRandomTag()}`; + const appGroupId = await db.appGroup.create( + originalApp.orgId, + groupName, + true, + ); + db.app.setGroup(originalApp.id, appGroupId); + break; + } + } + + // ---------------- 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); + } + + const app = await db.app.getById(originalApp.id); + const [appGroup, currentConfig, currentDeployment] = await Promise.all([ + db.appGroup.getById(app.appGroupId), + db.app.getDeploymentConfig(app.id), + db.app.getCurrentDeployment(app.id), + ]); + // ---------------- Rebuild if necessary ---------------- + + if (shouldBuildOnUpdate(currentConfig, updatedConfig, currentDeployment)) { + // 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. + try { + await buildAndDeploy({ + app, + org: organization, + imageRepo: app.imageRepo, + commitMessage, + 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 if (updatedConfig.appType === "helm") { + const deployment = await db.deployment.create({ + appId: app.id, + commitMessage, + appType: "helm", + config: updatedConfig, + }); + await cancelAllOtherDeployments(organization, app, deployment.id, true); + await deployFromHelm(app, deployment, updatedConfig); + } else { + // ---------------- Redeploy the app with the new configuration ---------------- + // To reach this block, the update must be: + // (1) from a Git deployment to a similar Git deployment, in which case the current imageTag is reused + // (2) from any deployment type to an image deployment, in which case the updatedConfig will have an imageTag + + const deployment = await db.deployment.create({ + status: "DEPLOYING", + appType: "workload", + appId: originalApp.id, + commitMessage, + 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 as WorkloadConfig).imageTag, + }, + }); + + const config = (await db.deployment.getConfig( + deployment.id, + )) as WorkloadConfig; + + try { + const { namespace, configs, postCreate } = + await createAppConfigsFromDeployment( + organization, + originalApp, + appGroup, + deployment, + config, + ); + + const { KubernetesObjectApi: api } = await getClientsForRequest( + userId, + app.projectId, + ["KubernetesObjectApi"], + ); + await createOrUpdateApp(api, app.name, namespace, configs, postCreate); + + await Promise.all([ + cancelAllOtherDeployments(organization, 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", + ); + } + } +} + +const shouldBuildOnUpdate = ( + oldConfig: WorkloadConfig | HelmConfig, + newConfig: WorkloadConfigCreate | HelmConfigCreate, + currentDeployment: Deployment, +) => { + // Only Git apps need to be built + if (newConfig.source !== "GIT") { + return false; + } + + // Either this app has not been built in the past, or it has not been built successfully + if ( + oldConfig.source !== "GIT" || + !oldConfig.imageTag || + currentDeployment.status === "ERROR" + ) { + return true; + } + + // The code has changed + if ( + newConfig.branch !== oldConfig.branch || + newConfig.repositoryId != oldConfig.repositoryId || + newConfig.commitHash != oldConfig.commitHash + ) { + return true; + } + + // Build options have changed + if ( + newConfig.builder != oldConfig.builder || + newConfig.rootDir != oldConfig.rootDir || + (newConfig.builder === "dockerfile" && + newConfig.dockerfilePath != oldConfig.dockerfilePath) + ) { + return true; + } + + return false; +}; + +// Patch the null(hidden) values of env vars sent from client with the sensitive plaintext +export const withSensitiveEnv = ( + 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..13bf72aa --- /dev/null +++ b/backend/src/service/updateDeployment.ts @@ -0,0 +1,116 @@ +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(); + } + + const config = await db.deployment.getConfig(deployment.id); + if (config.source !== "GIT") { + throw new ValidationError("Cannot update deployment"); + } + + 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, org] = await Promise.all([ + db.appGroup.getById(app.appGroupId), + 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/builders/railpack/Dockerfile b/builders/railpack/Dockerfile index ffd7e29a..6c15b992 100644 --- a/builders/railpack/Dockerfile +++ b/builders/railpack/Dockerfile @@ -1,21 +1,4 @@ -# When changing the Railpack version, make sure the Mise version is updated as well -# Current Mise version: https://github.com/railwayapp/railpack/blob/main/core/mise/install.go#L20 -ARG RAILPACK_VERSION=0.15.1 -ARG MISE_VERSION=2025.11.8 - -FROM ghcr.io/jdx/mise:${MISE_VERSION} AS mise - -FROM ubuntu:24.04 AS railpack - -# Install the Railpack CLI -# https://railpack.com/installation - -RUN apt-get update && apt-get install -y --no-install-recommends wget=1.21.4-1ubuntu4.1 ca-certificates=20240203 && rm -rf /var/lib/apt/lists/* - -ARG RAILPACK_VERSION -RUN wget --progress=dot:giga https://github.com/railwayapp/railpack/releases/download/v${RAILPACK_VERSION}/railpack-v${RAILPACK_VERSION}-x86_64-unknown-linux-musl.tar.gz && \ - tar -xzvf railpack-v${RAILPACK_VERSION}-x86_64-unknown-linux-musl.tar.gz && \ - mv ./railpack /usr/local/bin/railpack +ARG RAILPACK_VERSION=0.15.4 FROM ubuntu:24.04 @@ -26,23 +9,33 @@ RUN apt-get update && \ apt-get update && \ # ^ Get the latest version of Git from their PPA (we need 2.49.0 or later because we use the `git clone --revision` flag) apt-get install -y --no-install-recommends git=1:2.52.0-0ppa1~ubuntu24.04.1 && \ + apt-get remove -y software-properties-common && \ + apt-get autoremove -y && \ rm -rf /var/lib/apt/lists/* -RUN groupadd -g 65532 -r appuser && \ - useradd -u 65532 -m -g appuser appuser - -COPY --chown=appuser:appuser --from=moby/buildkit:rootless /usr/bin/buildctl /usr/bin/buildctl - -ARG MISE_VERSION -COPY --chown=appuser:appuser --chmod=500 --from=mise /usr/local/bin/mise /usr/bin/mise/mise-${MISE_VERSION} - -COPY --chown=appuser:appuser --chmod=500 --from=railpack /usr/local/bin/railpack /usr/bin/railpack - +ARG RAILPACK_VERSION +RUN \ + # Install Railpack + wget --progress=dot:giga "https://github.com/railwayapp/railpack/releases/download/v${RAILPACK_VERSION}/railpack-v${RAILPACK_VERSION}-x86_64-unknown-linux-musl.tar.gz" && \ + tar -xz "railpack" -f "railpack-v${RAILPACK_VERSION}-x86_64-unknown-linux-musl.tar.gz" && \ + rm "railpack-v${RAILPACK_VERSION}-x86_64-unknown-linux-musl.tar.gz" && \ + mv "./railpack" "/usr/bin/railpack" && \ + # Install Mise + MISE_VERSION=$(wget --quiet -O- "https://raw.githubusercontent.com/railwayapp/railpack/refs/tags/v${RAILPACK_VERSION}/core/mise/version.txt") && \ + mkdir /usr/bin/mise && \ + wget --progress=dot:giga -O "/usr/bin/mise/mise-$MISE_VERSION" "https://github.com/jdx/mise/releases/download/v$MISE_VERSION/mise-v$MISE_VERSION-linux-x64" && \ + # Create container user & update file permissions + groupadd -g 65532 -r appuser && \ + useradd -u 65532 -m -g appuser appuser && \ + chmod 500 "/usr/bin/railpack" "/usr/bin/mise/mise-$MISE_VERSION" && \ + chown appuser:appuser "/usr/bin/railpack" "/usr/bin/mise/mise-$MISE_VERSION" + +COPY --chown=appuser:appuser --chmod=500 --from=moby/buildkit:rootless /usr/bin/buildctl /usr/bin/buildctl COPY --chown=appuser:appuser --chmod=500 pre-stop.sh /var/run COPY --chown=appuser:appuser --chmod=500 docker-entrypoint.sh /var/run WORKDIR /home/appuser +USER appuser:appuser -USER appuser ENTRYPOINT ["/bin/sh"] CMD ["-c", "/var/run/docker-entrypoint.sh"] 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 - - - ); -} diff --git a/frontend/src/components/PagedView.tsx b/frontend/src/components/PagedView.tsx deleted file mode 100644 index 83825c76..00000000 --- a/frontend/src/components/PagedView.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Button } from "./ui/button"; -import { useState } from "react"; - -export const PagedView = ({ - children, - submitButton, -}: { - children: React.ReactNode[]; - submitButton: React.ReactNode; -}) => { - const [idx, setIdx] = useState(0); - return ( -
- {children.map((child, i) => { - if (i === idx) { - return child; - } - return
{child}
; - })} -
- {idx > 0 && ( - - )} - {idx < children.length - 1 ? ( - - ) : ( -
{submitButton}
- )} -
-
- ); -}; diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx deleted file mode 100644 index bb08f977..00000000 --- a/frontend/src/components/ProtectedRoute.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Route, Navigate } from "react-router-dom"; -import { UserContext } from "./UserProvider"; -import React from "react"; - -export default function ProtectedRoute({ - path, - element, -}: React.ComponentProps) { - const { user } = React.useContext(UserContext); - return ( - } /> - ); -} diff --git a/frontend/src/pages/create-app/AppConfigFormFields.tsx b/frontend/src/components/config/AppConfigFormFields.tsx similarity index 98% rename from frontend/src/pages/create-app/AppConfigFormFields.tsx rename to frontend/src/components/config/AppConfigFormFields.tsx index 7027d3dd..3f3de9a4 100644 --- a/frontend/src/pages/create-app/AppConfigFormFields.tsx +++ b/frontend/src/components/config/AppConfigFormFields.tsx @@ -1,6 +1,9 @@ import { useAppConfig } from "@/components/AppConfigProvider"; -import { EnvVarGrid } from "@/components/EnvVarGrid"; -import { MountsGrid, type Mounts } from "@/components/MountsGrid"; +import { EnvVarGrid } from "@/components/config/workload/EnvVarGrid"; +import { + MountsGrid, + type Mounts, +} from "@/components/config/workload/MountsGrid"; import { Accordion, AccordionContent, @@ -42,8 +45,8 @@ import { X, } from "lucide-react"; import { useContext, useMemo, useState, type Dispatch } from "react"; -import { GitHubIcon, SubdomainStatus } from "./CreateAppView"; -import { GitDeploymentFields } from "./GitDeploymentFields"; +import { GitHubIcon, SubdomainStatus } from "@/pages/create-app/CreateAppView"; +import { GitDeploymentFields } from "@/components/config/workload/git/GitDeploymentFields"; export type AppInfoFormData = { name?: string; diff --git a/frontend/src/components/EnvVarGrid.tsx b/frontend/src/components/config/workload/EnvVarGrid.tsx similarity index 96% rename from frontend/src/components/EnvVarGrid.tsx rename to frontend/src/components/config/workload/EnvVarGrid.tsx index 88932880..cd349fdc 100644 --- a/frontend/src/components/EnvVarGrid.tsx +++ b/frontend/src/components/config/workload/EnvVarGrid.tsx @@ -1,9 +1,9 @@ import { Trash2 } from "lucide-react"; import { Fragment, useEffect, useState, type Dispatch } from "react"; -import HelpTooltip from "./HelpTooltip"; -import { Button } from "./ui/button"; -import { Checkbox } from "./ui/checkbox"; -import { Input } from "./ui/input"; +import HelpTooltip from "@/components/HelpTooltip"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; type EnvVars = { name: string; value: string | null; isSensitive: boolean }[]; diff --git a/frontend/src/components/MountsGrid.tsx b/frontend/src/components/config/workload/MountsGrid.tsx similarity index 93% rename from frontend/src/components/MountsGrid.tsx rename to frontend/src/components/config/workload/MountsGrid.tsx index 7a647d4a..7e53db56 100644 --- a/frontend/src/components/MountsGrid.tsx +++ b/frontend/src/components/config/workload/MountsGrid.tsx @@ -1,9 +1,9 @@ import { TooltipTrigger } from "@radix-ui/react-tooltip"; import { Trash2 } from "lucide-react"; import { Fragment, useEffect, type Dispatch } from "react"; -import { Button } from "./ui/button"; -import { Input } from "./ui/input"; -import { Tooltip, TooltipContent } from "./ui/tooltip"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Tooltip, TooltipContent } from "@/components/ui/tooltip"; export type Mounts = { path: string; amountInMiB: number }[]; diff --git a/frontend/src/pages/create-app/GitDeploymentFields.tsx b/frontend/src/components/config/workload/git/GitDeploymentFields.tsx similarity index 99% rename from frontend/src/pages/create-app/GitDeploymentFields.tsx rename to frontend/src/components/config/workload/git/GitDeploymentFields.tsx index 575df816..3c9f3c2b 100644 --- a/frontend/src/pages/create-app/GitDeploymentFields.tsx +++ b/frontend/src/components/config/workload/git/GitDeploymentFields.tsx @@ -24,7 +24,7 @@ import { Hammer, } from "lucide-react"; import { useContext, useEffect, useState } from "react"; -import type { AppInfoFormData } from "./AppConfigFormFields"; +import type { AppInfoFormData } from "@/components/config/AppConfigFormFields"; export const GitDeploymentFields = ({ orgId, diff --git a/frontend/src/components/ImportRepoDialog.tsx b/frontend/src/components/config/workload/git/ImportRepoDialog.tsx similarity index 95% rename from frontend/src/components/ImportRepoDialog.tsx rename to frontend/src/components/config/workload/git/ImportRepoDialog.tsx index eb7e7ebb..3c38756d 100644 --- a/frontend/src/components/ImportRepoDialog.tsx +++ b/frontend/src/components/config/workload/git/ImportRepoDialog.tsx @@ -1,14 +1,19 @@ import { api } from "@/lib/api"; -import type { AppInfoFormData } from "@/pages/create-app/AppConfigFormFields"; +import type { AppInfoFormData } from "@/components/config/AppConfigFormFields"; import { FormContext } from "@/pages/create-app/CreateAppView"; import { Info, Library, Loader, X } from "lucide-react"; import { useContext, useState, type Dispatch } from "react"; import { toast } from "sonner"; -import { Button } from "./ui/button"; -import { Checkbox } from "./ui/checkbox"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"; -import { Input } from "./ui/input"; -import { Label } from "./ui/label"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { Select, SelectContent, @@ -17,7 +22,7 @@ import { SelectLabel, SelectTrigger, SelectValue, -} from "./ui/select"; +} from "@/components/ui/select"; export const ImportRepoDialog = ({ orgId, diff --git a/frontend/src/pages/app/overview/AppConfigDiff.tsx b/frontend/src/components/diff/AppConfigDiff.tsx similarity index 98% rename from frontend/src/pages/app/overview/AppConfigDiff.tsx rename to frontend/src/components/diff/AppConfigDiff.tsx index df9fc011..9d70014e 100644 --- a/frontend/src/pages/app/overview/AppConfigDiff.tsx +++ b/frontend/src/components/diff/AppConfigDiff.tsx @@ -28,10 +28,10 @@ import { Terminal, } from "lucide-react"; import { useContext } from "react"; -import { GitHubIcon } from "../../create-app/CreateAppView"; -import { DiffInput } from "./DiffInput"; -import { EnvsWithDiffs } from "./EnvsWithDiffs"; -import { GitConfigDiff } from "./GitConfigDiff"; +import { GitHubIcon } from "@/pages/create-app/CreateAppView"; +import { DiffInput } from "@/components/diff/DiffInput"; +import { EnvsWithDiffs } from "@/components/diff/workload/EnvsWithDiffs"; +import { GitConfigDiff } from "@/components/diff/workload/git/GitConfigDiff"; export type DeploymentConfigFormData = { port: string; diff --git a/frontend/src/pages/app/overview/DiffInput.tsx b/frontend/src/components/diff/DiffInput.tsx similarity index 100% rename from frontend/src/pages/app/overview/DiffInput.tsx rename to frontend/src/components/diff/DiffInput.tsx diff --git a/frontend/src/pages/app/overview/EnvsWithDiffs.tsx b/frontend/src/components/diff/workload/EnvsWithDiffs.tsx similarity index 100% rename from frontend/src/pages/app/overview/EnvsWithDiffs.tsx rename to frontend/src/components/diff/workload/EnvsWithDiffs.tsx diff --git a/frontend/src/pages/app/overview/GitConfigDiff.tsx b/frontend/src/components/diff/workload/git/GitConfigDiff.tsx similarity index 99% rename from frontend/src/pages/app/overview/GitConfigDiff.tsx rename to frontend/src/components/diff/workload/git/GitConfigDiff.tsx index 15fabda2..348fee92 100644 --- a/frontend/src/pages/app/overview/GitConfigDiff.tsx +++ b/frontend/src/components/diff/workload/git/GitConfigDiff.tsx @@ -22,8 +22,8 @@ import { Hammer, } from "lucide-react"; import { useContext } from "react"; -import { type DeploymentConfigFormData } from "./AppConfigDiff"; -import { DiffInput } from "./DiffInput"; +import { type DeploymentConfigFormData } from "@/components/diff/AppConfigDiff"; +import { DiffInput } from "@/components/diff/DiffInput"; export const GitConfigDiff = ({ orgId, diff --git a/frontend/src/pages/app/ConfigTab.tsx b/frontend/src/pages/app/ConfigTab.tsx index e0ac087a..b3d34382 100644 --- a/frontend/src/pages/app/ConfigTab.tsx +++ b/frontend/src/pages/app/ConfigTab.tsx @@ -6,7 +6,7 @@ import type { components } from "@/generated/openapi"; import { api } from "@/lib/api"; import AppConfigFormFields, { type AppInfoFormData, -} from "@/pages/create-app/AppConfigFormFields"; +} from "@/components/config/AppConfigFormFields"; import type { RefetchOptions } from "@tanstack/react-query"; import { Loader, Save, Scale3D, TextCursorInput } from "lucide-react"; import { useContext, useState, type Dispatch } from "react"; diff --git a/frontend/src/pages/app/overview/RedeployModal.tsx b/frontend/src/pages/app/overview/RedeployModal.tsx index 11d8e71b..92998581 100644 --- a/frontend/src/pages/app/overview/RedeployModal.tsx +++ b/frontend/src/pages/app/overview/RedeployModal.tsx @@ -20,7 +20,10 @@ import { Container, GitCommit, Loader, Rocket } from "lucide-react"; import { useContext, useEffect, useRef, useState, type Dispatch } from "react"; import { toast } from "sonner"; import type { App } from "../AppView"; -import { AppConfigDiff, type DeploymentConfigFormData } from "./AppConfigDiff"; +import { + AppConfigDiff, + type DeploymentConfigFormData, +} from "@/components/diff/AppConfigDiff"; const defaultRedeployState = { radioValue: undefined, diff --git a/frontend/src/pages/create-app/CreateAppGroupView.tsx b/frontend/src/pages/create-app/CreateAppGroupView.tsx index dd75197d..d86c9296 100644 --- a/frontend/src/pages/create-app/CreateAppGroupView.tsx +++ b/frontend/src/pages/create-app/CreateAppGroupView.tsx @@ -27,7 +27,7 @@ import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import AppConfigFormFields, { type AppInfoFormData, -} from "./AppConfigFormFields"; +} from "@/components/config/AppConfigFormFields"; import { FormContext, getCleanedAppName } from "./CreateAppView"; export default function CreateAppGroupView() { diff --git a/frontend/src/pages/create-app/CreateAppView.tsx b/frontend/src/pages/create-app/CreateAppView.tsx index d5d9fc18..9e7e0370 100644 --- a/frontend/src/pages/create-app/CreateAppView.tsx +++ b/frontend/src/pages/create-app/CreateAppView.tsx @@ -17,7 +17,7 @@ import { createContext, useContext, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import AppConfigFormFields, { type AppInfoFormData, -} from "./AppConfigFormFields"; +} from "@/components/config/AppConfigFormFields"; export default function CreateAppView() { const { user } = useContext(UserContext); From b15cd63739c6610c87136896dfb48f651e5fd819 Mon Sep 17 00:00:00 2001 From: zheng861 Date: Fri, 2 Jan 2026 13:46:29 -0700 Subject: [PATCH 16/38] Refactor frontend for helm and workload configs - Write types for the form states of configs of different sources - Add helper functions for form state and config generation - Move source or app type-specific code into separate components - Separate DiffInput into DiffInput and DiffSelect for speed --- .../components/config/AppConfigFormFields.tsx | 721 ++---------------- .../components/config/GroupConfigFields.tsx | 164 ++++ .../src/components/config/ProjectConfig.tsx | 75 ++ .../config/helm/HelmConfigFields.tsx | 14 + .../workload/CommonWorkloadConfigFields.tsx | 370 +++++++++ .../components/config/workload/EnvVarGrid.tsx | 58 +- .../components/config/workload/MountsGrid.tsx | 26 +- ...tFields.tsx => EnabledGitConfigFields.tsx} | 114 ++- .../config/workload/git/GitConfigFields.tsx | 59 ++ .../config/workload/git/ImportRepoDialog.tsx | 21 +- .../workload/image/ImageConfigFields.tsx | 42 + .../src/components/diff/AppConfigDiff.tsx | 438 ++--------- frontend/src/components/diff/DiffInput.tsx | 55 +- frontend/src/components/diff/DiffSelect.tsx | 48 ++ .../components/diff/helm/HelmConfigDiff.tsx | 16 + .../workload/CommonWorkloadConfigDiff.tsx | 244 ++++++ .../diff/workload/EnvsWithDiffs.tsx | 59 +- .../diff/workload/SubdomainDiff.tsx | 205 +++++ .../diff/workload/git/GitConfigDiff.tsx | 428 ++++++----- .../diff/workload/image/ImageConfigDiff.tsx | 48 ++ frontend/src/lib/api.ts | 40 +- frontend/src/lib/form.ts | 320 ++++++++ frontend/src/lib/form.types.ts | 55 ++ frontend/src/lib/utils.ts | 11 + frontend/src/pages/app/AppView.tsx | 12 +- frontend/src/pages/app/ConfigTab.tsx | 194 ++--- frontend/src/pages/app/FilesTab.tsx | 9 + frontend/src/pages/app/OverviewTab.tsx | 53 +- .../src/pages/app/overview/RedeployModal.tsx | 188 ++--- .../pages/create-app/CreateAppGroupView.tsx | 190 ++--- .../src/pages/create-app/CreateAppView.tsx | 142 +--- 31 files changed, 2490 insertions(+), 1929 deletions(-) create mode 100644 frontend/src/components/config/GroupConfigFields.tsx create mode 100644 frontend/src/components/config/ProjectConfig.tsx create mode 100644 frontend/src/components/config/helm/HelmConfigFields.tsx create mode 100644 frontend/src/components/config/workload/CommonWorkloadConfigFields.tsx rename frontend/src/components/config/workload/git/{GitDeploymentFields.tsx => EnabledGitConfigFields.tsx} (84%) create mode 100644 frontend/src/components/config/workload/git/GitConfigFields.tsx create mode 100644 frontend/src/components/config/workload/image/ImageConfigFields.tsx create mode 100644 frontend/src/components/diff/DiffSelect.tsx create mode 100644 frontend/src/components/diff/helm/HelmConfigDiff.tsx create mode 100644 frontend/src/components/diff/workload/CommonWorkloadConfigDiff.tsx create mode 100644 frontend/src/components/diff/workload/SubdomainDiff.tsx create mode 100644 frontend/src/components/diff/workload/image/ImageConfigDiff.tsx create mode 100644 frontend/src/lib/form.ts create mode 100644 frontend/src/lib/form.types.ts diff --git a/frontend/src/components/config/AppConfigFormFields.tsx b/frontend/src/components/config/AppConfigFormFields.tsx index 3f3de9a4..077f6ea8 100644 --- a/frontend/src/components/config/AppConfigFormFields.tsx +++ b/frontend/src/components/config/AppConfigFormFields.tsx @@ -1,615 +1,60 @@ +import type { components } from "@/generated/openapi"; +import type { CommonFormFields, GroupFormFields } from "@/lib/form.types"; +import { Cable } from "lucide-react"; +import { useContext } from "react"; import { useAppConfig } from "@/components/AppConfigProvider"; -import { EnvVarGrid } from "@/components/config/workload/EnvVarGrid"; -import { - MountsGrid, - type Mounts, -} from "@/components/config/workload/MountsGrid"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion"; -import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Input } from "@/components/ui/input"; +import { UserContext } from "@/components/UserProvider"; +import { GitConfigFields } from "./workload/git/GitConfigFields"; +import { HelmConfigFields } from "./helm/HelmConfigFields"; +import { ImageConfigFields } from "./workload/image/ImageConfigFields"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectGroup, SelectItem, - SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { UserContext } from "@/components/UserProvider"; -import type { components } from "@/generated/openapi"; -import { api } from "@/lib/api"; -import { useDebouncedValue } from "@/lib/utils"; +import { ProjectConfig } from "./ProjectConfig"; +import { CommonWorkloadConfigFields } from "./workload/CommonWorkloadConfigFields"; import { - Cable, - Code2, - Cog, - Component, - Cpu, - Database, - Fence, - Info, - Link, - Loader, - Logs, - MemoryStick, - Server, - Tag, - X, -} from "lucide-react"; -import { useContext, useMemo, useState, type Dispatch } from "react"; -import { GitHubIcon, SubdomainStatus } from "@/pages/create-app/CreateAppView"; -import { GitDeploymentFields } from "@/components/config/workload/git/GitDeploymentFields"; - -export type AppInfoFormData = { - name?: string; - port?: string; - subdomain: string; - createIngress: boolean; - dockerfilePath?: string; - groupOption?: string; - groupId?: number; - projectId?: string; - env: Env; - mounts: Mounts; - orgId?: number; - repositoryId?: number; - event?: "push" | "workflow_run"; - eventId?: string; - repoName?: string; - imageTag?: string; - branch?: string; - rootDir?: string; - source: "git" | "image"; - builder: "dockerfile" | "railpack"; - collectLogs: boolean; - cpuCores: number; - memoryInMiB: number; -}; + makeFunctionalWorkloadSetter, + makeHelmSetter, + makeImageSetter, +} from "@/lib/form"; -type Env = { name: string; value: string | null; isSensitive: boolean }[]; - -const AppConfigFormFields = ({ +export const AppConfigFormFields = ({ + groupState, state, setState, - isExistingApp, - hideGroupSelect, - defaults, - disabled = false, + disabled, + originalConfig, }: { - state: AppInfoFormData; - setState: Dispatch>; - isExistingApp?: boolean; - hideGroupSelect?: boolean; - defaults?: { - config?: components["schemas"]["DeploymentConfig"]; - }; + groupState: GroupFormFields; + state: CommonFormFields; + setState: (updater: (prev: CommonFormFields) => CommonFormFields) => void; disabled?: boolean; + originalConfig?: components["schemas"]["DeploymentConfig"]; }) => { - const { - groupOption, - groupId, - projectId, - source, - env, - mounts, - orgId, - subdomain, - createIngress, - } = state; + const appConfig = useAppConfig(); const { user } = useContext(UserContext); - const selectedOrg = - orgId !== undefined ? user?.orgs?.find((it) => it.id === orgId) : undefined; - - const { data: groups, isPending: groupsLoading } = !hideGroupSelect - ? api.useQuery( - "get", - "/org/{orgId}/groups", - { params: { path: { orgId: orgId! } } }, - { - enabled: orgId !== undefined, - }, - ) - : { data: null, isPending: false }; + groupState.orgId !== undefined + ? user?.orgs?.find((it) => it.id === groupState.orgId) + : undefined; - const MAX_SUBDOMAIN_LENGTH = 54; - const subdomainIsValid = - subdomain.length < MAX_SUBDOMAIN_LENGTH && - subdomain.match(/^[a-z0-9](?:[a-z0-9\-]*[a-z0-9])?$/) !== null; - const debouncedSub = useDebouncedValue(subdomain); - const { data: subStatus, isPending: subLoading } = api.useQuery( - "get", - "/app/subdomain", - { - params: { - query: { - subdomain: debouncedSub, - }, - }, - }, - { enabled: subdomain == debouncedSub && subdomainIsValid }, - ); + const imageSetter = makeImageSetter(setState); - const [groupName, setGroupName] = useState(""); - const isGroupNameValid = useMemo(() => { - const MAX_GROUP_LENGTH = 56; - return ( - groupName.length <= MAX_GROUP_LENGTH && - groupName.match(/^[a-zA-Z0-9][ a-zA-Z0-9-_\.]*$/) - ); - }, [groupName]); - - const appConfig = useAppConfig(); - const appDomain = URL.parse(appConfig?.appDomain ?? ""); + const helmSetter = makeHelmSetter(setState); - const DeploymentOptions = ( - <> -

Deployment Options

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

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

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

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

- )} -

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

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

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

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

Grouping Options

-
-
-
- - - * - -
-

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

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

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

-
- -
+ )}

Source Options

@@ -628,9 +73,13 @@ const AppConfigFormFields = ({
- {source === "git" ? ( - selectedOrg?.githubConnected ? ( - + )} + {state.source === "image" && ( + + )} + {state.source === "helm" && ( + + )} + {state.appType === "workload" && + (state.source !== "git" || selectedOrg?.githubConnected) && ( + - ) : selectedOrg?.permissionLevel === "OWNER" ? ( -
-

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

-

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

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

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

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

Grouping Options

+
+
+ + + * + +
+

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

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

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

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

Deployment Options

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

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

*/} + + ))} +
+ )} +
+
+ + + * + +
+ { + setState({ port: e.currentTarget.value }); + }} + /> +
+
+
+ + + * + +
+
+ + + * + +
+ { + setState({ cpuCores: e.currentTarget.value }); + }} + /> +
+ { + setState({ memoryInMiB: e.currentTarget.value }); + }} + /> + MiB +
+
+ + + + + + + { + setState((prev) => ({ ...prev, env: updater(prev.env) })); + }} + fixedSensitiveNames={fixedSensitiveNames} + disabled={disabled ?? false} + /> + + + {appConfig.storageEnabled && ( + + + + + + {!!isExistingApp && ( +

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

+ )} +

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

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

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

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

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

+

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

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

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

+ ); + } + } + + return ( + + ); +}; diff --git a/frontend/src/components/config/workload/git/ImportRepoDialog.tsx b/frontend/src/components/config/workload/git/ImportRepoDialog.tsx index 3c38756d..6c98f734 100644 --- a/frontend/src/components/config/workload/git/ImportRepoDialog.tsx +++ b/frontend/src/components/config/workload/git/ImportRepoDialog.tsx @@ -1,5 +1,4 @@ import { api } from "@/lib/api"; -import type { AppInfoFormData } from "@/components/config/AppConfigFormFields"; import { FormContext } from "@/pages/create-app/CreateAppView"; import { Info, Library, Loader, X } from "lucide-react"; import { useContext, useState, type Dispatch } from "react"; @@ -23,22 +22,36 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import type { CommonFormFields } from "@/lib/form.types"; export const ImportRepoDialog = ({ orgId, open, setOpen, refresh, - setRepo, setState, }: { orgId: number; open: boolean; setOpen: Dispatch; refresh: () => Promise; - setRepo: (id: number, name: string) => void; - setState: Dispatch>; + setState: (updater: (prev: CommonFormFields) => CommonFormFields) => void; }) => { + const setRepo = (id: number, name: string) => { + console.log("setRepo", id, name); + setState((s) => ({ + ...s, + workload: { + ...s.workload, + git: { + ...s.workload.git, + repositoryId: id, + repoName: name, + }, + }, + })); + }; + const { data: installation } = api.useQuery( "get", "/org/{orgId}/installation", diff --git a/frontend/src/components/config/workload/image/ImageConfigFields.tsx b/frontend/src/components/config/workload/image/ImageConfigFields.tsx new file mode 100644 index 00000000..e3e32b8d --- /dev/null +++ b/frontend/src/components/config/workload/image/ImageConfigFields.tsx @@ -0,0 +1,42 @@ +import { Tag } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { ImageFormFields } from "@/lib/form.types"; + +export const ImageConfigFields = ({ + imageState, + setImageState, + disabled, +}: { + imageState: ImageFormFields; + setImageState: (update: Partial) => void; + disabled?: boolean; +}) => { + const { imageTag } = imageState; + return ( +
+
+ + + * + +
+ { + setImageState({ imageTag: e.currentTarget.value }); + }} + name="imageTag" + id="imageTag" + placeholder="nginx:latest" + className="w-full" + /> +
+ ); +}; diff --git a/frontend/src/components/diff/AppConfigDiff.tsx b/frontend/src/components/diff/AppConfigDiff.tsx index 9d70014e..7d051b87 100644 --- a/frontend/src/components/diff/AppConfigDiff.tsx +++ b/frontend/src/components/diff/AppConfigDiff.tsx @@ -1,77 +1,34 @@ -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion"; -import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { UserContext } from "@/components/UserProvider"; -import type { components } from "@/generated/openapi"; -import { - Cable, - Code2, - Cog, - Cpu, - MemoryStick, - Scale3D, - Server, - Tag, - Terminal, -} from "lucide-react"; +import { Label } from "@/components/ui/label"; +import { SelectContent, SelectGroup, SelectItem } from "@/components/ui/select"; +import { Cable } from "lucide-react"; import { useContext } from "react"; -import { GitHubIcon } from "@/pages/create-app/CreateAppView"; -import { DiffInput } from "@/components/diff/DiffInput"; -import { EnvsWithDiffs } from "@/components/diff/workload/EnvsWithDiffs"; -import { GitConfigDiff } from "@/components/diff/workload/git/GitConfigDiff"; - -export type DeploymentConfigFormData = { - port: string; - replicas: string; - dockerfilePath?: string; - env: Env; - repositoryId?: number; - event?: "push" | "workflow_run"; - eventId?: string; - commitHash?: string; - imageTag?: string; - branch?: string; - rootDir?: string; - source: "git" | "image"; - builder?: "dockerfile" | "railpack"; - createIngress: boolean; - collectLogs: boolean; - cpuCores: string; - memoryInMiB: number; -}; - -type Env = { name: string; value: string | null; isSensitive: boolean }[]; +import { GitConfigDiff } from "./workload/git/GitConfigDiff"; +import type { CommonFormFields } from "@/lib/form.types"; +import { + getFormStateFromApp, + makeFunctionalWorkloadSetter, + makeGitSetter, + makeHelmSetter, + makeImageSetter, +} from "@/lib/form"; +import type { App } from "../../pages/app/AppView"; +import { DiffSelect } from "./DiffSelect"; +import { HelmConfigDiff } from "./helm/HelmConfigDiff"; +import { ImageConfigDiff } from "./workload/image/ImageConfigDiff"; +import { CommonWorkloadConfigDiff } from "./workload/CommonWorkloadConfigDiff"; export const AppConfigDiff = ({ orgId, base, state, setState, - defaults, disabled = false, }: { orgId: number; - base: DeploymentConfigFormData; - state: DeploymentConfigFormData; - setState: ( - callback: (state: DeploymentConfigFormData) => DeploymentConfigFormData, - ) => void; - defaults?: { - config?: components["schemas"]["DeploymentConfig"]; - }; + base: App; + state: Omit; + setState: (callback: (state: CommonFormFields) => CommonFormFields) => void; disabled?: boolean; }) => { const { user } = useContext(UserContext); @@ -80,8 +37,12 @@ export const AppConfigDiff = ({ ? user?.orgs?.find((it) => it.id === orgId) : undefined; - const showDeploymentOptions = - state.source !== "git" || selectedOrg?.githubConnected; + const baseFormState = getFormStateFromApp(base); + + const setImageState = makeImageSetter(setState); + const setGitState = makeGitSetter(setState); + const setHelmState = makeHelmSetter(setState); + const setWorkloadState = makeFunctionalWorkloadSetter(setState); return (
@@ -100,316 +61,65 @@ export const AppConfigDiff = ({
- setState((prev) => ({ ...prev, - source: source as "git" | "image", + source: source as "helm" | "git" | "image", })) } - select={(props) => ( - - )} - /> + leftPlaceholder="Select deployment source" + rightPlaceholder="Select deployment source" + > + + + Git Repository + OCI Image + + +
- {state.source === "git" ? ( - selectedOrg?.githubConnected ? ( - - ) : selectedOrg?.permissionLevel === "OWNER" ? ( -
-

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

-

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

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

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

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

Deployment Options

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

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

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

Deployment Options

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

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

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

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

+

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

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

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

+ ); + } + } - const selectedOrg = - orgId !== undefined ? user?.orgs?.find((it) => it.id === orgId) : undefined; + const baseGitState = base.source === "git" ? base.workload.git : null; + const orgId = selectedOrg.id; const { data: repos, isPending: reposLoading } = api.useQuery( "get", "/org/{orgId}/repos", - { params: { path: { orgId: orgId! } } }, - { - enabled: - orgId !== undefined && - state.source === "git" && - selectedOrg?.githubConnected, - }, + { params: { path: { orgId: selectedOrg.id } } }, ); const { data: branches, isPending: branchesLoading } = api.useQuery( @@ -64,12 +86,12 @@ export const GitConfigDiff = ({ params: { path: { orgId: orgId!, - repoId: state.repositoryId!, + repoId: gitState.repositoryId!, }, }, }, { - enabled: !!orgId && !!state.repositoryId && state.source === "git", + enabled: gitState.repositoryId !== undefined, }, ); @@ -80,29 +102,47 @@ export const GitConfigDiff = ({ params: { path: { orgId: orgId!, - repoId: state.repositoryId!, + repoId: gitState.repositoryId!, + }, + }, + }, + { + enabled: + gitState.repositoryId !== undefined && + gitState.event === "workflow_run", + }, + ); + + const { data: baseWorkflows } = api.useQuery( + "get", + "/org/{orgId}/repos/{repoId}/workflows", + { + params: { + path: { + orgId: orgId!, + repoId: baseGitState?.repositoryId!, }, }, }, { enabled: - orgId !== undefined && - state.repositoryId !== undefined && - state.source === "git" && - state.event === "workflow_run", + baseGitState?.repositoryId !== undefined && + baseGitState?.event === "workflow_run", + refetchInterval: false, }, ); + const baseWorkflowName = baseWorkflows?.workflows?.find( + (workflow) => workflow.id === baseGitState?.eventId, + )?.name; + return ( <>
- { - setState((prev) => ({ - ...prev, + left={baseGitState?.repositoryId?.toString()} + setRight={(repo) => + setGitState({ repositoryId: typeof repo === "string" ? parseInt(repo) : repo, repoName: repos?.find((r) => r?.id === parseInt(repo))?.name, - })); - }} - right={state.repositoryId?.toString() ?? ""} - select={(props) => ( - - )} - /> + branch: undefined, + eventId: undefined, + }) + } + right={gitState.repositoryId?.toString() ?? ""} + rightPlaceholder="Select a repository" + disabled={disabled || reposLoading} + > + + + {repos !== undefined + ? Object.entries( + Object.groupBy(repos, (repo) => repo.owner!), + ).map(([owner, repos]) => ( + + {owner} + {repos?.map((repo) => ( + + {repo.owner}/{repo.name} + + ))} + + )) + : null} + + +
@@ -168,7 +199,7 @@ export const GitConfigDiff = ({ htmlFor="selectBranch" className={cn( "pb-1", - (state.repositoryId === undefined || branchesLoading) && + (gitState.repositoryId === undefined || branchesLoading) && "opacity-50", )} > @@ -183,50 +214,44 @@ export const GitConfigDiff = ({
- { - setState((prev) => ({ ...prev, branch })); - }} - select={(props) => ( - - )} - /> + left={baseGitState?.branch} + right={gitState.branch ?? ""} + setRight={(branch) => setGitState({ branch })} + rightPlaceholder={ + branchesLoading && gitState.repositoryId + ? "Loading..." + : "Select a branch" + } + leftContent={ + + + + {baseGitState?.branch} + + + + } + > + + + {gitState.repositoryId !== undefined && + branches?.branches?.map((branch) => { + return ( + + {branch} + + ); + })} + + +
@@ -243,49 +268,34 @@ export const GitConfigDiff = ({
- { - setState((prev) => ({ - ...prev, - event: event as "push" | "workflow_run", - })); - }} - select={(props) => ( - - )} - /> + left={baseGitState?.event} + right={gitState.event ?? ""} + setRight={(event) => + setGitState({ event: event as "push" | "workflow_run" }) + } + > + + Push + + Successful workflow run + + +
- {state.event === "workflow_run" && ( + {gitState.event === "workflow_run" && (
- { - setState((prev) => ({ ...prev, eventId })); - }} - select={(props) => ( - - )} - /> + left={baseGitState?.eventId?.toString() ?? ""} + right={gitState.eventId?.toString() ?? ""} + setRight={(eventId) => + setGitState({ eventId: parseInt(eventId) }) + } + rightPlaceholder={ + workflowsLoading || workflows!.workflows!.length > 0 + ? "Select a workflow" + : "No workflows available" + } + leftContent={ + + + + {baseWorkflowName} + + + + } + > + + + {gitState.repositoryId !== undefined && + workflows?.workflows?.map((workflow) => { + return ( + + {workflow.name} + + ); + })} + + +
)} @@ -368,11 +377,9 @@ export const GitConfigDiff = ({
{ - setState((state) => ({ ...state, rootDir })); - }} + left={baseGitState?.rootDir} + right={gitState.rootDir} + setRight={(rootDir) => setGitState({ rootDir })} name="rootDir" id="rootDir" placeholder="./" @@ -401,12 +408,9 @@ export const GitConfigDiff = ({ disabled={disabled} name="builder" id="builder" - value={state.builder} + value={gitState.builder} onValueChange={(newValue) => - setState((prev) => ({ - ...prev, - builder: newValue as "dockerfile" | "railpack", - })) + setGitState({ builder: newValue as "dockerfile" | "railpack" }) } required > @@ -414,8 +418,9 @@ export const GitConfigDiff = ({ htmlFor="builder-dockerfile" className={cn( "flex items-center gap-2 border border-input rounded-lg p-4 focus-within:border-ring focus-within:ring-ring/50 outline-none focus-within:ring-[3px] transition-colors", - base.source === "git" && base.builder !== state.builder - ? base.builder === "dockerfile" + base.source === "git" && + baseGitState?.builder !== gitState.builder + ? baseGitState?.builder === "dockerfile" ? "bg-red-100 hover:bg-red-200" : "bg-green-50" : "bg-inherit hover:bg-gray-50 has-checked:bg-gray-50", @@ -431,8 +436,9 @@ export const GitConfigDiff = ({ htmlFor="builder-railpack" className={cn( "flex items-center gap-2 border border-input rounded-lg p-4 focus-within:border-ring focus-within:ring-ring/50 outline-none focus-within:ring-[3px] transition-colors", - base.source === "git" && base.builder !== state.builder - ? base.builder === "railpack" + base.source === "git" && + baseGitState?.builder !== gitState.builder + ? baseGitState?.builder === "railpack" ? "bg-red-100 hover:bg-red-200" : "bg-green-50" : "bg-inherit hover:bg-gray-50 has-checked:bg-gray-50", @@ -446,7 +452,7 @@ export const GitConfigDiff = ({
- {state.builder === "dockerfile" ? ( + {gitState.builder === "dockerfile" ? (
- + + setState((s) => ({ ...s, displayName: e.target.value })) + } + />
@@ -198,15 +131,26 @@ export const ConfigTab = ({ placeholder="1" type="number" required - defaultValue={app.config.replicas} + value={state.workload?.replicas ?? "1"} + onChange={(e) => { + const value = e.target.value; + setState((s) => ({ + ...s, + workload: { ...s.workload, replicas: value }, + })); + }} />
+ + {appConfig?.isRancherManaged && ( + + )} {enableSaveButton && ( diff --git a/frontend/src/pages/app/FilesTab.tsx b/frontend/src/pages/app/FilesTab.tsx index a9875ec7..4cdfe2b3 100644 --- a/frontend/src/pages/app/FilesTab.tsx +++ b/frontend/src/pages/app/FilesTab.tsx @@ -16,6 +16,7 @@ import { SelectValue, } from "@/components/ui/select"; import { api } from "@/lib/api"; +import { isWorkloadConfig } from "@/lib/utils"; import { ArrowUp, CloudUpload, @@ -52,6 +53,14 @@ function dirname(path: string) { } export const FilesTab = ({ app }: { app: App }) => { + if (!isWorkloadConfig(app.config)) { + return ( +
+

File browser is not available for Helm-based apps.

+
+ ); + } + const [replica, setReplica] = useState("0"); const [volume, setVolume] = useState( app.config.mounts?.[0]?.volumeClaimName, diff --git a/frontend/src/pages/app/OverviewTab.tsx b/frontend/src/pages/app/OverviewTab.tsx index bb2098b7..04b5e7cd 100644 --- a/frontend/src/pages/app/OverviewTab.tsx +++ b/frontend/src/pages/app/OverviewTab.tsx @@ -7,7 +7,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { api } from "@/lib/api"; -import { cn } from "@/lib/utils"; +import { cn, isWorkloadConfig } from "@/lib/utils"; import { GitHubIcon } from "@/pages/create-app/CreateAppView"; import { CheckCheck, @@ -26,10 +26,8 @@ import { } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; -import { toast } from "sonner"; import { Status, type App, type DeploymentStatus } from "./AppView"; import { RedeployModal } from "./overview/RedeployModal"; - export const format = new Intl.DateTimeFormat(undefined, { dateStyle: "short", timeStyle: "medium", @@ -189,29 +187,31 @@ export const OverviewTab = ({

{app.config.imageTag}

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

- - Public address -

-

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

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

+ + Public address +

+

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

+ + )}

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

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

AnvilOps will combine this version of your application @@ -367,43 +323,14 @@ export const RedeployModal = ({ <> DeploymentConfigFormData, - ) => { + setState={(updater) => setRedeployState((rs) => ({ ...rs, - configState: updateConfig(rs.configState), - })); - }} - defaults={{ config: pastDeployment?.config }} + configState: updater(rs.configState), + })) + } /> {(redeployState.configState.source !== "git" || selectedOrg?.githubConnected) && ( @@ -411,6 +338,7 @@ export const RedeployModal = ({ className="mt-4 float-right" type="button" onClick={() => { + console.log("?"); if (form.current!.checkValidity()) { setRedeployState((rs) => ({ ...rs, diff --git a/frontend/src/pages/create-app/CreateAppGroupView.tsx b/frontend/src/pages/create-app/CreateAppGroupView.tsx index d86c9296..8b5f89ff 100644 --- a/frontend/src/pages/create-app/CreateAppGroupView.tsx +++ b/frontend/src/pages/create-app/CreateAppGroupView.tsx @@ -1,4 +1,3 @@ -import { useAppConfig } from "@/components/AppConfigProvider"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -12,139 +11,78 @@ import { } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { UserContext } from "@/components/UserProvider"; -import type { components } from "@/generated/openapi"; import { api } from "@/lib/api"; import { Globe, Loader, Plus, Rocket, X } from "lucide-react"; -import { - Fragment, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { Fragment, useContext, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; -import AppConfigFormFields, { - type AppInfoFormData, -} from "@/components/config/AppConfigFormFields"; -import { FormContext, getCleanedAppName } from "./CreateAppView"; +import { FormContext } from "./CreateAppView"; +import type { CommonFormFields, GroupFormFields } from "@/lib/form.types"; +import { + createDefaultCommonFormFields, + createNewAppWithoutGroup, + getAppName, +} from "@/lib/form"; +import { AppConfigFormFields } from "@/components/config/AppConfigFormFields"; +type GroupCreate = { type: "create-new"; name: string }; export default function CreateAppGroupView() { const { user } = useContext(UserContext); const { mutateAsync: createAppGroup, isPending: createPending } = api.useMutation("post", "/app/group"); - const [orgId, setOrgId] = useState(user?.orgs?.[0]?.id); + const [groupState, setGroupState] = useState({ + orgId: user?.orgs?.[0]?.id, + groupOption: { type: "create-new", name: "" }, + }); - const defaultState = { - collectLogs: true, - env: [], - mounts: [], - source: "git" as "git", - builder: "railpack" as "railpack", - event: "push" as "push", - subdomain: "", - createIngress: true, - rootDir: "./", - dockerfilePath: "Dockerfile", - cpuCores: 1, - memoryInMiB: 1024, - } satisfies AppInfoFormData; + const { + orgId, + groupOption: { name: groupName }, + } = groupState as { orgId?: string; groupOption: GroupCreate }; - const [appStates, setAppStates] = useState([ - { ...defaultState }, + const [appStates, setAppStates] = useState([ + createDefaultCommonFormFields(), ]); - const [tab, setTab] = useState("0"); const navigate = useNavigate(); const shouldShowDeploy = useMemo(() => { return ( orgId === undefined || - user?.orgs.some((org) => org.id === orgId && org.githubConnected) + user?.orgs.some( + (org) => org.id === parseInt(orgId) && org.githubConnected, + ) ); - }, [user, orgId]); + }, [user, groupState.orgId]); const scrollRef = useRef(null); - - useEffect(() => { - setAppStates((appStates) => - appStates.map((state) => ({ ...state, orgId })), - ); - }, [orgId]); - - const [groupName, setGroupName] = useState(""); - const isGroupNameValid = useMemo(() => { + const showGroupNameError = useMemo(() => { const MAX_GROUP_LENGTH = 56; return ( - groupName.length <= MAX_GROUP_LENGTH && - groupName.match(/^[a-zA-Z0-9][ a-zA-Z0-9-_\.]*$/) + groupName.length > 0 && + (groupName.length > MAX_GROUP_LENGTH || + !groupName.match(/^[a-zA-Z0-9][ a-zA-Z0-9-_\.]*$/)) ); }, [groupName]); - const config = useAppConfig(); - return (

{ e.preventDefault(); - const formData = new FormData(e.currentTarget); - try { - const apps = appStates.map( - (appState): components["schemas"]["NewAppWithoutGroupInfo"] => { - const appName = getAppName(appState); - let subdomain = appState.subdomain; - if ( - (!subdomain && !config.appDomain) || - !appState.createIngress - ) { - subdomain = - appName.replaceAll(/[^a-zA-Z0-9-_]/g, "_") + - "-" + - Math.floor(Math.random() * 10_000); - } - - return { - orgId: orgId!, - projectId: appState.projectId, - name: appName, - subdomain, - createIngress: appState.createIngress, - port: parseInt(appState.port!), - env: appState.env.filter((ev) => ev.name.length > 0), - mounts: appState.mounts.filter((m) => m.path.length > 0), - cpuCores: appState.cpuCores, - memoryInMiB: appState.memoryInMiB, - ...(appState.source === "git" - ? { - source: "git", - repositoryId: appState.repositoryId!, - branch: appState.branch!, - event: appState.event!, - eventId: appState.eventId - ? parseInt(appState.eventId) - : null, - dockerfilePath: appState.dockerfilePath!, - rootDir: appState.rootDir!, - builder: appState.builder!, - } - : { - source: "image", - imageTag: appState.imageTag!, - }), - }; - }, - ); + // const formData = new FormData(e.currentTarget); + // TODO: client-side validation on every app state + const finalAppStates = appStates as Required[]; + try { await createAppGroup({ body: { - name: formData.get("groupName")!.toString(), - orgId: orgId!, - apps, + name: groupName, + orgId: parseInt(orgId!), + apps: finalAppStates.map(createNewAppWithoutGroup), }, }); @@ -171,7 +109,9 @@ export default function CreateAppGroupView() {
- setFormState((prev) => ({ ...prev, orgId: parseInt(orgId!) })) + setGroupState((prev) => ({ ...prev, orgId: parseInt(orgId) })) } - value={formState.orgId?.toString()} + value={groupState.orgId?.toString()} name="org" > @@ -179,8 +112,13 @@ export default function CreateAppView() { + - + {shouldShowDeploy ? (