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 rerenders with the updated list of repositories
setTimeout(() => setRepo(repoId, repoState.name));
diff --git a/frontend/src/pages/ImportRepoView.tsx b/frontend/src/pages/ImportRepoView.tsx
index 9b79a78a..9241656d 100644
--- a/frontend/src/pages/ImportRepoView.tsx
+++ b/frontend/src/pages/ImportRepoView.tsx
@@ -23,13 +23,6 @@ export const ImportRepoView = () => {
},
});
- if (response.url) {
- // If `url` is specified in the response, then we need to authorize with GitHub.
- // Redirect there and it'll redirect back to this page (with a `code` and `state`) when done.
- window.location.href = response.url;
- return;
- }
-
toast.success("Repository imported!");
navigate(`/create-app?org=${response.orgId}&repo=${response.repoId}`);
} catch (e) {
diff --git a/log-shipper/main.go b/log-shipper/main.go
index 6f30a4f7..52d67f17 100644
--- a/log-shipper/main.go
+++ b/log-shipper/main.go
@@ -291,12 +291,14 @@ func send(lines []LogLine, env *EnvVars) {
errorLogger.Printf("Error uploading logs: %v %v\n", res.StatusCode, string(body))
}
- for _, line := range lines {
- if line.attempts <= MAX_UPLOAD_ATTEMPTS {
- line.attempts++
- select {
- case uploadQueue <- line:
- default: // Same as above
+ if res.StatusCode >= 500 { // Only retry internal server errors
+ for _, line := range lines {
+ if line.attempts <= MAX_UPLOAD_ATTEMPTS {
+ line.attempts++
+ select {
+ case uploadQueue <- line:
+ default: // Same as above
+ }
}
}
}
diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml
index f5b2e1be..1710fcf2 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -656,7 +656,7 @@ paths:
responses:
"200":
- description: Success
+ description: GitHub Authorization Required
content:
application/json:
schema:
@@ -666,8 +666,17 @@ paths:
type: string
format: uri
required: [url]
- "308":
- description: Redirect to Import Repo endpoint (copying the Git repository immediately without needing permission grant from user)
+ "201":
+ description: Success (Repo created)
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ repoId:
+ type: number
+ format: int64
+ required: [repoId]
"400":
description: Validation failed
content:
@@ -716,17 +725,12 @@ paths:
type: object
properties:
orgId:
- description: AnvilOps organization ID that has access to the imported repository. Present if the repo was created successfully
type: number
format: int64
repoId:
- description: Present if the repo was created successfully
type: number
format: int64
- url:
- description: Present if creating from a template failed and the user needs to authorize
- type: string
- format: uri
+ required: [orgId, repoId]
"400":
description: Validation failed
content:
From fb022de52858d19fc6ce8963e3e2040a3062a114 Mon Sep 17 00:00:00 2001
From: zheng861
Date: Fri, 26 Dec 2025 16:31:18 -0700
Subject: [PATCH 11/38] Move deployment process logic to DeploymentService
---
backend/src/lib/cluster/kubernetes.ts | 2 +-
backend/src/service/createApp.ts | 34 +-
backend/src/service/createAppGroup.ts | 34 +-
backend/src/service/getAppByID.ts | 4 +-
backend/src/service/getDeployment.ts | 4 +-
backend/src/service/githubWebhook.ts | 419 ++-----------
backend/src/service/helper/app.ts | 24 +-
backend/src/service/helper/deployment.ts | 585 +++++++++++++++---
.../src/service/helper/deploymentConfig.ts | 207 ++++++-
backend/src/service/helper/index.ts | 19 +-
backend/src/service/updateApp.ts | 121 +---
11 files changed, 777 insertions(+), 676 deletions(-)
diff --git a/backend/src/lib/cluster/kubernetes.ts b/backend/src/lib/cluster/kubernetes.ts
index 7cf5c1d7..8e78291f 100644
--- a/backend/src/lib/cluster/kubernetes.ts
+++ b/backend/src/lib/cluster/kubernetes.ts
@@ -12,10 +12,10 @@ import {
Watch,
type V1Namespace,
} from "@kubernetes/client-node";
+import { db } from "../../db/index.ts";
import { env } from "../env.ts";
import { shouldImpersonate } from "./rancher.ts";
import type { K8sObject } from "./resources.ts";
-import { db } from "../../db/index.ts";
const kc = new KubeConfig();
kc.loadFromDefault();
diff --git a/backend/src/service/createApp.ts b/backend/src/service/createApp.ts
index 6fa2a333..d6f9c7aa 100644
--- a/backend/src/service/createApp.ts
+++ b/backend/src/service/createApp.ts
@@ -6,13 +6,12 @@ import {
MAX_GROUPNAME_LEN,
RANDOM_TAG_LEN,
} from "../lib/cluster/resources.ts";
+import { OrgNotFoundError, ValidationError } from "./common/errors.ts";
import {
- DeploymentError,
- OrgNotFoundError,
- ValidationError,
-} from "./common/errors.ts";
-import { buildAndDeploy } from "./githubWebhook.ts";
-import { appService } from "./helper/index.ts";
+ appService,
+ deploymentConfigService,
+ deploymentService,
+} from "./helper/index.ts";
export type NewApp = components["schemas"]["NewApp"];
@@ -58,7 +57,7 @@ export async function createApp(appData: NewApp, userId: number) {
}
}
- const { config, commitMessage } = (
+ let { config, commitMessage } = (
await appService.prepareMetadataForApps(organization, user, appData)
)[0];
@@ -71,6 +70,8 @@ export async function createApp(appData: NewApp, userId: number) {
projectId: appData.projectId,
namespace: appData.namespace,
});
+
+ config = deploymentConfigService.updateConfigWithApp(config, app);
} catch (err) {
// In between validation and creating the app, the namespace was taken by another app
if (err instanceof ConflictError) {
@@ -78,18 +79,11 @@ export async function createApp(appData: NewApp, userId: number) {
}
}
- try {
- await buildAndDeploy({
- org: organization,
- app,
- imageRepo: app.imageRepo,
- commitMessage,
- config,
- createCheckRun: false,
- });
- } catch (err) {
- throw new DeploymentError(err);
- }
-
+ await deploymentService.create({
+ org: organization,
+ app,
+ commitMessage,
+ config,
+ });
return app.id;
}
diff --git a/backend/src/service/createAppGroup.ts b/backend/src/service/createAppGroup.ts
index 4d0be3af..a771e217 100644
--- a/backend/src/service/createAppGroup.ts
+++ b/backend/src/service/createAppGroup.ts
@@ -1,14 +1,13 @@
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 { 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";
+import {
+ appService,
+ deploymentConfigService,
+ deploymentService,
+} from "./helper/index.ts";
export type NewAppWithoutGroup =
components["schemas"]["NewAppWithoutGroupInfo"];
@@ -53,7 +52,7 @@ export async function createAppGroup(
}));
for (const { appData, metadata } of appsWithMetadata) {
- const { config, commitMessage } = metadata;
+ let { config, commitMessage } = metadata;
let app: App;
try {
app = await db.app.create({
@@ -64,6 +63,7 @@ export async function createAppGroup(
projectId: appData.projectId,
namespace: appData.namespace,
});
+ config = deploymentConfigService.updateConfigWithApp(config, app);
} catch (err) {
// In between validation and creating the app, the namespace was taken by another app
if (err instanceof ConflictError) {
@@ -71,17 +71,11 @@ export async function createAppGroup(
}
}
- try {
- await buildAndDeploy({
- org: organization,
- app,
- imageRepo: app.imageRepo,
- commitMessage,
- config,
- createCheckRun: false,
- });
- } catch (err) {
- throw new DeploymentError(err);
- }
+ await deploymentService.create({
+ org: organization,
+ app,
+ commitMessage,
+ config,
+ });
}
}
diff --git a/backend/src/service/getAppByID.ts b/backend/src/service/getAppByID.ts
index 1ad8a8e4..c08cb819 100644
--- a/backend/src/service/getAppByID.ts
+++ b/backend/src/service/getAppByID.ts
@@ -3,7 +3,7 @@ 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";
+import { deploymentConfigService } from "./helper/index.ts";
export async function getAppByID(appId: number, userId: number) {
const [app, recentDeployment, deploymentCount] = await Promise.all([
@@ -63,7 +63,7 @@ export async function getAppByID(appId: number, userId: number) {
repositoryURL: repoURL,
cdEnabled: app.enableCD,
namespace: app.namespace,
- config: deploymentConfigValidator.formatDeploymentConfig(currentConfig),
+ config: deploymentConfigService.formatDeploymentConfig(currentConfig),
appGroup: {
standalone: appGroup.isMono,
name: !appGroup.isMono ? appGroup.name : undefined,
diff --git a/backend/src/service/getDeployment.ts b/backend/src/service/getDeployment.ts
index 5c37845c..5dd6ffba 100644
--- a/backend/src/service/getDeployment.ts
+++ b/backend/src/service/getDeployment.ts
@@ -4,7 +4,7 @@ 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";
+import { deploymentConfigService } from "./helper/index.ts";
export async function getDeployment(deploymentId: number, userId: number) {
const deployment = await db.deployment.getById(deploymentId, {
@@ -91,6 +91,6 @@ export async function getDeployment(deploymentId: number, userId: number) {
total: pods.items.length,
failed,
},
- config: deploymentConfigValidator.formatDeploymentConfig(config),
+ config: deploymentConfigService.formatDeploymentConfig(config),
};
}
diff --git a/backend/src/service/githubWebhook.ts b/backend/src/service/githubWebhook.ts
index 45322724..664fca38 100644
--- a/backend/src/service/githubWebhook.ts
+++ b/backend/src/service/githubWebhook.ts
@@ -1,42 +1,17 @@
-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 { GitConfig } 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 { type LogStream, type LogType } from "../generated/prisma/enums.ts";
import { env } from "../lib/env.ts";
-import { upgrade } from "../lib/helm.ts";
-import { getOctokit, getRepoById } from "../lib/octokit.ts";
+import { getOctokit } from "../lib/octokit.ts";
import {
AppNotFoundError,
UnknownWebhookRequestTypeError,
UserNotFoundError,
ValidationError,
} from "./common/errors.ts";
+import { deploymentService } from "./helper/index.ts";
export async function processGitHubWebhookPayload(
event: string,
@@ -170,19 +145,19 @@ async function handlePush(payload: components["schemas"]["webhook-push"]) {
for (const app of apps) {
const org = await db.org.getById(app.orgId);
- const config = (await db.app.getDeploymentConfig(app.id)) as GitConfig;
- const octokit = await getOctokit(org.githubInstallationId);
-
- await buildAndDeploy({
- org: org,
- app: app,
- imageRepo: app.imageRepo,
+ const oldConfig = (await db.app.getDeploymentConfig(app.id)) as GitConfig;
+ await deploymentService.create({
+ org,
+ app,
commitMessage: payload.head_commit.message,
- config: DeploymentRepo.cloneWorkloadConfig(config),
- createCheckRun: true,
- octokit,
- owner: payload.repository.owner.login,
- repo: payload.repository.name,
+ config: DeploymentRepo.cloneWorkloadConfig(oldConfig),
+ git: {
+ checkRun: {
+ pending: false,
+ owner: payload.repository.owner.login,
+ repo: payload.repository.name,
+ },
+ },
});
}
}
@@ -215,23 +190,20 @@ async function handleWorkflowRun(
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);
- }
+ await deploymentService.create({
+ org,
+ app,
+ commitMessage: payload.workflow_run.head_commit.message,
+ workflowRunId: payload.workflow_run.id,
+ config: DeploymentRepo.cloneWorkloadConfig(config),
+ git: {
+ checkRun: {
+ pending: true,
+ owner: payload.repository.owner.login,
+ repo: payload.repository.name,
+ },
+ },
+ });
}
} else if (payload.action === "completed") {
for (const app of apps) {
@@ -278,328 +250,17 @@ async function handleWorkflowRun(
continue;
}
- const octokit = await getOctokit(org.githubInstallationId);
- await buildAndDeployFromRepo(org, app, deployment, config, {
- createCheckRun: true,
- octokit,
- owner: payload.repository.owner.login,
- repo: payload.repository.name,
- });
- }
- }
-}
-
-type BuildAndDeployOptions = {
- org: Organization;
- app: App;
- imageRepo: string;
- commitMessage: string;
- config: 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",
+ await deploymentService.completeGitDeployment({
+ org,
+ app,
+ deployment,
+ config,
+ checkRunOpts: {
+ type: "update",
+ owner: payload.repository.owner.login,
+ repo: payload.repository.name,
+ },
});
- log(
- deployment.id,
- "BUILD",
- "Updated GitHub check run to Completed with conclusion Cancelled",
- );
}
}
}
diff --git a/backend/src/service/helper/app.ts b/backend/src/service/helper/app.ts
index 5a8ec081..159f01f1 100644
--- a/backend/src/service/helper/app.ts
+++ b/backend/src/service/helper/app.ts
@@ -12,8 +12,7 @@ import {
} 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";
+import { DeploymentConfigService } from "./deploymentConfig.ts";
export interface App {
name?: string;
@@ -23,14 +22,9 @@ export interface App {
}
export class AppService {
- private configValidator: DeploymentConfigValidator;
- private deploymentService: DeploymentService;
- constructor(
- configValidator: DeploymentConfigValidator,
- deploymentService: DeploymentService,
- ) {
- this.configValidator = configValidator;
- this.deploymentService = deploymentService;
+ private configService: DeploymentConfigService;
+ constructor(configService: DeploymentConfigService) {
+ this.configService = configService;
}
/**
@@ -69,14 +63,12 @@ export class AppService {
}
const metadata: (
- | Awaited<
- ReturnType
- >
+ | Awaited>
| Error
)[] = await Promise.all(
apps.map((app) => {
try {
- return this.deploymentService.prepareDeploymentMetadata(
+ return this.configService.prepareDeploymentMetadata(
app.config,
organization.id,
);
@@ -92,7 +84,7 @@ export class AppService {
}
return metadata as Awaited<
- ReturnType
+ ReturnType
>[];
}
@@ -111,7 +103,7 @@ export class AppService {
}
if (app.config.appType === "workload") {
- await this.configValidator.validateCommonWorkloadConfig(app.config);
+ await this.configService.validateCommonWorkloadConfig(app.config);
}
if (app.namespace) {
diff --git a/backend/src/service/helper/deployment.ts b/backend/src/service/helper/deployment.ts
index 12fdcb95..f04497f8 100644
--- a/backend/src/service/helper/deployment.ts
+++ b/backend/src/service/helper/deployment.ts
@@ -1,140 +1,521 @@
import { Octokit } from "octokit";
import {
+ App,
+ AppGroup,
+ Deployment,
+ GitConfig,
GitConfigCreate,
+ HelmConfig,
HelmConfigCreate,
+ Organization,
+ WorkloadConfig,
WorkloadConfigCreate,
} from "../../db/models.ts";
-import { components } from "../../generated/openapi.ts";
+import { AppRepo } from "../../db/repo/app.ts";
+import { AppGroupRepo } from "../../db/repo/appGroup.ts";
+import { DeploymentRepo } from "../../db/repo/deployment.ts";
+import { DeploymentStatus } from "../../generated/prisma/enums.ts";
+import { cancelBuildJobsForApp, createBuildJob } from "../../lib/builder.ts";
+import {
+ createOrUpdateApp,
+ getClientForClusterUsername,
+} from "../../lib/cluster/kubernetes.ts";
+import { shouldImpersonate } from "../../lib/cluster/rancher.ts";
+import { createAppConfigsFromDeployment } from "../../lib/cluster/resources.ts";
+import { env } from "../../lib/env.ts";
+import { upgrade } from "../../lib/helm.ts";
import { getOctokit, getRepoById } from "../../lib/octokit.ts";
-import { type DeploymentConfigValidator } from "./deploymentConfig.ts";
-import { deploymentConfigValidator } from "./index.ts";
-import { GitWorkloadConfig } from "./types.ts";
+import { DeploymentError } from "../common/errors.ts";
+import { log } from "../githubWebhook.ts";
-export class DeploymentService {
- private readonly validator: DeploymentConfigValidator;
- private readonly getOctokitFn: typeof getOctokit;
- private readonly getRepoByIdFn: typeof getRepoById;
+type GitOptions =
+ | { skipBuild: boolean; checkRun?: undefined }
+ | {
+ skipBuild?: false;
+ checkRun: { pending: boolean; owner: string; repo: string };
+ };
+export class DeploymentService {
+ private appRepo: AppRepo;
+ private appGroupRepo: AppGroupRepo;
+ private deploymentRepo: DeploymentRepo;
+ private getOctokitFn: typeof getOctokit;
+ private getRepoByIdFn: typeof getRepoById;
constructor(
- validator: DeploymentConfigValidator,
+ appRepo: AppRepo,
+ appGroupRepo: AppGroupRepo,
+ deploymentRepo: DeploymentRepo,
getOctokitFn?: typeof getOctokit,
getRepoByIdFn?: typeof getRepoById,
) {
- this.validator = validator;
+ this.appRepo = appRepo;
+ this.appGroupRepo = appGroupRepo;
+ this.deploymentRepo = deploymentRepo;
this.getOctokitFn = getOctokitFn ?? getOctokit;
this.getRepoByIdFn = getRepoByIdFn ?? getRepoById;
}
- async prepareDeploymentMetadata(
- config: components["schemas"]["DeploymentConfig"],
- orgId: number,
- ): Promise<{
- config: GitConfigCreate | HelmConfigCreate | WorkloadConfigCreate;
+ /**
+ *
+ * @throws DeploymentError
+ * Creates a Deployment object and triggers the deployment process.
+ */
+ async create({
+ org,
+ app,
+ commitMessage,
+ workflowRunId,
+ config: configIn,
+ git,
+ }: {
+ org: Organization;
+ app: App;
commitMessage: string;
- }> {
- let commitHash = "unknown",
- commitMessage = "Initial deployment";
+ workflowRunId?: number;
+ config: WorkloadConfigCreate | GitConfigCreate | HelmConfigCreate;
+ git?: GitOptions;
+ }) {
+ const deployment = await this.deploymentRepo.create({
+ appId: app.id,
+ commitMessage,
+ workflowRunId,
+ appType: configIn.appType,
+ config: configIn,
+ });
+ const config = await this.deploymentRepo.getConfig(deployment.id);
switch (config.source) {
- case "git": {
- let octokit: Octokit, repo: Awaited>;
+ case "HELM": {
+ await this.deployHelm(org, app, deployment, config as HelmConfig);
+ break;
+ }
- 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");
- }
+ case "GIT": {
+ await this.deployGit({
+ org,
+ app,
+ deployment,
+ config: config as GitConfig,
+ opts: git,
+ });
+ break;
+ }
+
+ case "IMAGE": {
+ const appGroup = await this.appGroupRepo.getById(app.appGroupId);
+ await this.deployWorkloadWithoutBuild({
+ org,
+ app,
+ appGroup,
+ deployment,
+ config,
+ });
+ break;
+ }
+ }
+ }
- console.error(err);
- throw new Error("Failed to look up GitHub repository");
+ /**
+ *
+ * @throws DeploymentError
+ * Proceeds with a Git deployment from an existing Deployment and GitConfig.
+ * If the skipBuild flag is set, immediately deploy the app.
+ * If the pending flag is set, add a pending check run.
+ * Otherwise, build and deploy.
+ */
+ private async deployGit({
+ org,
+ app,
+ deployment,
+ config,
+ opts,
+ }: {
+ org: Organization;
+ app: App;
+ deployment: Deployment;
+ config: GitConfig;
+ opts: GitOptions;
+ }) {
+ if (opts.checkRun) {
+ // Webhook event deployment
+ const { pending, owner, repo } = opts.checkRun;
+ if (pending) {
+ try {
+ const checkRun = await this.handleCheckRun({
+ octokit: await this.getOctokitFn(org.githubInstallationId),
+ deployment,
+ config,
+ checkRun: {
+ type: "create",
+ opts: {
+ owner: opts.checkRun.owner,
+ repo: opts.checkRun.repo,
+ status: "queued",
+ },
+ },
+ });
+ log(
+ deployment.id,
+ "BUILD",
+ "Created GitHub check run with status Queued at " +
+ checkRun.data.html_url,
+ );
+ await this.deploymentRepo.setCheckRunId(
+ deployment.id,
+ checkRun?.data?.id,
+ );
+ await this.cancelAllOtherDeployments(org, app, deployment.id, false);
+ } catch (e) {
+ console.error("Failed to set check run: ", e);
}
+ } else {
+ await this.completeGitDeployment({
+ org,
+ app,
+ deployment,
+ config,
+ checkRunOpts: {
+ type: "create",
+ owner,
+ repo,
+ },
+ });
+ }
+ } else if (opts.skipBuild) {
+ // Minor config update
+ const appGroup = await this.appGroupRepo.getById(app.appGroupId);
+ await this.deployWorkloadWithoutBuild({
+ org,
+ app,
+ appGroup,
+ deployment,
+ config,
+ });
+ } else {
+ // Regular app creation
+ await this.completeGitDeployment({ org, app, deployment, config });
+ }
+ }
- await this.validator.validateGitConfig(config, octokit, repo);
+ /**
+ *
+ * @throws DeploymentError
+ * Builds and deploys from an existing Deployment and GitConfig.
+ */
+ async completeGitDeployment({
+ org,
+ app,
+ deployment,
+ config,
+ checkRunOpts,
+ }: {
+ org: Organization;
+ app: App;
+ deployment: Deployment;
+ config: GitConfig;
+ checkRunOpts?: {
+ type: "create" | "update";
+ owner: string;
+ repo: string;
+ status?: "in_progress" | "completed" | "queued";
+ };
+ }) {
+ await this.cancelAllOtherDeployments(org, app, deployment.id, true);
- if (config.commitHash) {
- commitHash = config.commitHash;
- const commit = await octokit.rest.git.getCommit({
- owner: repo.owner.login,
- repo: repo.name,
- commit_sha: commitHash,
- });
- commitMessage = commit.data.message;
- } else {
- const latestCommit = (
- await octokit.rest.repos.listCommits({
- per_page: 1,
- owner: repo.owner.login,
- repo: repo.name,
- })
- ).data[0];
-
- commitHash = latestCommit.sha;
- commitMessage = latestCommit.commit.message;
+ let jobId: string | undefined;
+ let octokit: Octokit;
+ let checkRun:
+ | Awaited>
+ | Awaited>;
+ if (checkRunOpts) {
+ octokit = await this.getOctokitFn(org.githubInstallationId);
+ const { owner, repo, status } = checkRunOpts;
+ try {
+ switch (checkRunOpts.type) {
+ case "create": {
+ checkRun = await this.handleCheckRun({
+ octokit,
+ deployment,
+ config,
+ checkRun: {
+ type: "create",
+ opts: { owner, repo, status: status ?? "in_progress" },
+ },
+ });
+ log(
+ deployment.id,
+ "BUILD",
+ "Created GitHub check run with status In Progress at " +
+ checkRun.data.html_url,
+ );
+ break;
+ }
+
+ case "update": {
+ checkRun = await this.handleCheckRun({
+ octokit,
+ deployment,
+ config,
+ checkRun: {
+ type: "update",
+ opts: {
+ owner,
+ repo,
+ status: status ?? "in_progress",
+ check_run_id: deployment.checkRunId,
+ },
+ },
+ });
+ log(
+ deployment.id,
+ "BUILD",
+ "Updated GitHub check run to In Progress at " +
+ checkRun.data.html_url,
+ );
+ break;
+ }
}
+ } catch (e) {
+ console.error("Failed to set check run: ", e);
+ }
+ }
- return {
- config: await this.createGitConfig(config, commitHash, repo.id),
- commitMessage,
- };
+ try {
+ jobId = await createBuildJob(org, app, deployment, config);
+ log(deployment.id, "BUILD", "Created build job with ID " + jobId);
+ } catch (e) {
+ log(
+ deployment.id,
+ "BUILD",
+ "Error creating build job: " + JSON.stringify(e),
+ "stderr",
+ );
+ await this.deploymentRepo.setStatus(deployment.id, "ERROR");
+ if (checkRunOpts && checkRun?.data?.id) {
+ // If a check run was created, make sure it's marked as failed
+ try {
+ await this.handleCheckRun({
+ octokit,
+ deployment,
+ config,
+ checkRun: {
+ type: "update",
+ opts: {
+ check_run_id: checkRun.data.id,
+ owner: checkRunOpts.owner,
+ repo: checkRunOpts.repo,
+ status: "completed",
+ conclusion: "failure",
+ },
+ },
+ });
+ log(
+ deployment.id,
+ "BUILD",
+ "Updated GitHub check run to Completed with conclusion Failure",
+ );
+ } catch {}
}
- case "image": {
- deploymentConfigValidator.validateImageConfig(config);
- return {
- config: {
- ...this.createCommonWorkloadConfig(config),
- source: "IMAGE",
- appType: "workload",
- },
- commitMessage,
+ throw new DeploymentError(e);
+ }
+
+ if (checkRun?.data?.id) {
+ await this.deploymentRepo.setCheckRunId(deployment.id, checkRun.data.id);
+ }
+ }
+
+ /**
+ *
+ * @throws DeploymentError
+ * Immediately deploys a workload. The image tag must be set on the config object.
+ */
+ private async deployWorkloadWithoutBuild({
+ org,
+ app,
+ appGroup,
+ deployment,
+ config,
+ }: {
+ org: Organization;
+ app: App;
+ appGroup: AppGroup;
+ deployment: Deployment;
+ config: WorkloadConfig;
+ }) {
+ await this.cancelAllOtherDeployments(org, app, deployment.id, true);
+ await this.deploymentRepo.setStatus(
+ deployment.id,
+ DeploymentStatus.DEPLOYING,
+ );
+ log(deployment.id, "BUILD", "Deploying directly from OCI image...");
+ // If we're creating a deployment directly from an existing image tag, just deploy it now
+ try {
+ const { namespace, configs, postCreate } =
+ await createAppConfigsFromDeployment(
+ org,
+ app,
+ appGroup,
+ deployment,
+ config,
+ );
+ const api = getClientForClusterUsername(
+ app.clusterUsername,
+ "KubernetesObjectApi",
+ shouldImpersonate(app.projectId),
+ );
+ await createOrUpdateApp(api, app.name, namespace, configs, postCreate);
+ log(deployment.id, "BUILD", "Deployment succeeded");
+ await this.deploymentRepo.setStatus(
+ deployment.id,
+ DeploymentStatus.COMPLETE,
+ );
+ } catch (e) {
+ await this.deploymentRepo.setStatus(
+ deployment.id,
+ DeploymentStatus.ERROR,
+ );
+ log(
+ deployment.id,
+ "BUILD",
+ `Failed to apply Kubernetes resources: ${JSON.stringify(e?.body ?? e)}`,
+ "stderr",
+ );
+ throw new DeploymentError(e);
+ }
+ }
+
+ /**
+ *
+ * @throws DeploymentError
+ * Deploys a helm chart.
+ */
+ private async deployHelm(
+ org: Organization,
+ app: App,
+ deployment: Deployment,
+ config: HelmConfig,
+ ) {
+ await this.cancelAllOtherDeployments(org, app, deployment.id, true);
+ log(deployment.id, "BUILD", "Deploying directly from Helm chart...");
+ try {
+ await upgrade({
+ urlType: config.urlType,
+ chartURL: config.url,
+ version: config.version,
+ namespace: app.namespace,
+ release: app.name,
+ values: config.values,
+ });
+ } catch (e) {
+ await this.deploymentRepo.setStatus(
+ deployment.id,
+ DeploymentStatus.ERROR,
+ );
+ log(
+ deployment.id,
+ "BUILD",
+ `Failed to apply Kubernetes resources: ${JSON.stringify(e?.body ?? e)}`,
+ "stderr",
+ );
+ throw new DeploymentError(e);
+ }
+ }
+
+ private async handleCheckRun({
+ octokit,
+ deployment,
+ config,
+ checkRun,
+ }: {
+ octokit: Octokit;
+ deployment: Omit;
+ config: GitConfig;
+ checkRun:
+ | {
+ type: "create";
+ opts: {
+ owner: string;
+ repo: string;
+ status: "in_progress" | "completed" | "queued";
+ };
+ }
+ | {
+ type: "update";
+ opts: {
+ owner: string;
+ repo: string;
+ check_run_id: number;
+ status: "in_progress" | "completed" | "queued";
+ conclusion?: "cancelled" | "failure" | "success";
+ };
};
+ }) {
+ switch (checkRun.type) {
+ case "create": {
+ return await octokit.rest.checks.create({
+ ...checkRun.opts,
+ head_sha: config.commitHash,
+ name: "AnvilOps",
+ details_url: `${env.BASE_URL}/app/${deployment.appId}/deployment/${deployment.id}`,
+ });
+ break;
}
- case "helm": {
- return {
- config: { ...config, source: "HELM", appType: "helm" },
- commitMessage,
- };
+ case "update": {
+ return await octokit.rest.checks.update(checkRun.opts);
+ break;
}
}
}
- private createCommonWorkloadConfig(
- config: components["schemas"]["WorkloadConfigOptions"],
+ async cancelAllOtherDeployments(
+ org: Organization,
+ app: App,
+ deploymentId: number,
+ cancelComplete = false,
) {
- return {
- appType: "workload" as const,
- collectLogs: config.collectLogs,
- createIngress: config.createIngress,
- subdomain: config.subdomain,
- env: config.env,
- requests: config.requests,
- limits: config.limits,
- replicas: config.replicas,
- port: config.port,
- mounts: config.mounts,
- commitHash: "unknown",
- imageTag: config.imageTag,
- };
- }
+ await cancelBuildJobsForApp(app.id);
+
+ const statuses = Object.keys(DeploymentStatus) as DeploymentStatus[];
+ const deployments = await this.appRepo.getDeploymentsWithStatus(
+ app.id,
+ cancelComplete
+ ? statuses.filter((it) => it != "ERROR")
+ : statuses.filter((it) => it != "ERROR" && it != "COMPLETE"),
+ );
- private async createGitConfig(
- config: GitWorkloadConfig,
- commitHash: string,
- repositoryId: number,
- ): Promise {
- return {
- ...this.createCommonWorkloadConfig(config),
- source: "GIT",
- repositoryId,
- branch: config.branch,
- event: config.event,
- eventId: config.eventId,
- commitHash,
- builder: config.builder,
- dockerfilePath: config.dockerfilePath,
- rootDir: config.rootDir,
- imageTag: undefined,
- } satisfies GitConfigCreate;
+ 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 this.getOctokitFn(org.githubInstallationId);
+ }
+ const config = deployment.config as GitConfig;
+
+ const repo = await this.getRepoByIdFn(octokit, config.repositoryId);
+ try {
+ await this.handleCheckRun({
+ octokit,
+ deployment,
+ config,
+ checkRun: {
+ type: "update",
+ opts: {
+ check_run_id: deployment.checkRunId,
+ owner: repo.owner.login,
+ repo: repo.name,
+ status: "completed",
+ conclusion: "cancelled",
+ },
+ },
+ });
+ log(
+ deployment.id,
+ "BUILD",
+ "Updated GitHub check run to Completed with conclusion Cancelled",
+ );
+ } catch (e) {}
+ }
+ }
}
}
diff --git a/backend/src/service/helper/deploymentConfig.ts b/backend/src/service/helper/deploymentConfig.ts
index d00b1e48..09903a9a 100644
--- a/backend/src/service/helper/deploymentConfig.ts
+++ b/backend/src/service/helper/deploymentConfig.ts
@@ -1,18 +1,169 @@
import { Octokit } from "octokit";
-import { HelmConfig, WorkloadConfig } from "../../db/models.ts";
+import {
+ App,
+ GitConfigCreate,
+ HelmConfig,
+ HelmConfigCreate,
+ WorkloadConfig,
+ WorkloadConfigCreate,
+} 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 { env } from "../../lib/env.ts";
+import { getOctokit, getRepoById } from "../../lib/octokit.ts";
import { isRFC1123 } from "../../lib/validate.ts";
+import { ValidationError } from "../common/errors.ts";
import { GitWorkloadConfig, ImageWorkloadConfig } from "./types.ts";
-export class DeploymentConfigValidator {
+export class DeploymentConfigService {
private appRepo: AppRepo;
- constructor(appRepo: AppRepo) {
+ private getOctokitFn: typeof getOctokit;
+ private getRepoByIdFn: typeof getRepoById;
+ constructor(
+ appRepo: AppRepo,
+ getOctokitFn = getOctokit,
+ getRepoByIdFn = getRepoById,
+ ) {
this.appRepo = appRepo;
+ this.getOctokitFn = getOctokitFn;
+ this.getRepoByIdFn = getRepoByIdFn;
+ }
+
+ async prepareDeploymentMetadata(
+ config: components["schemas"]["DeploymentConfig"],
+ orgId: number,
+ ): Promise<{
+ config: GitConfigCreate | HelmConfigCreate | WorkloadConfigCreate;
+ commitMessage: string;
+ }> {
+ 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.validateGitConfig(config, octokit, repo);
+
+ if (config.commitHash) {
+ commitHash = config.commitHash;
+ const commit = await octokit.rest.git.getCommit({
+ owner: repo.owner.login,
+ repo: repo.name,
+ commit_sha: commitHash,
+ });
+ commitMessage = commit.data.message;
+ } else {
+ const latestCommit = (
+ await octokit.rest.repos.listCommits({
+ per_page: 1,
+ owner: repo.owner.login,
+ repo: repo.name,
+ })
+ ).data[0];
+
+ commitHash = latestCommit.sha;
+ commitMessage = latestCommit.commit.message;
+ }
+
+ return {
+ config: await this.createGitConfig(config, commitHash, repo.id),
+ commitMessage,
+ };
+ }
+ case "image": {
+ this.validateImageConfig(config);
+ return {
+ config: {
+ ...this.createCommonWorkloadConfig(config),
+ source: "IMAGE",
+ appType: "workload",
+ },
+ commitMessage,
+ };
+ }
+ case "helm": {
+ return {
+ config: { ...config, source: "HELM", appType: "helm" },
+ commitMessage,
+ };
+ }
+ }
+ }
+
+ /**
+ *
+ * @param config
+ * @param app
+ * @returns If source is GIT, a -ConfigCreate object with the image tag where the
+ * built image will be pushed, the original config otherwise
+ */
+ updateConfigWithApp(
+ config: GitConfigCreate | HelmConfigCreate | WorkloadConfigCreate,
+ app: App,
+ ) {
+ if (config.source === "GIT") {
+ return {
+ ...config,
+ imageTag: `${env.REGISTRY_HOSTNAME}/${env.HARBOR_PROJECT_NAME}/${app.imageRepo}:${config.commitHash}`,
+ };
+ }
+
+ return config;
+ }
+
+ private createCommonWorkloadConfig(
+ config: components["schemas"]["WorkloadConfigOptions"],
+ ) {
+ return {
+ appType: "workload" as const,
+ collectLogs: config.collectLogs,
+ createIngress: config.createIngress,
+ subdomain: config.subdomain,
+ env: config.env,
+ requests: config.requests,
+ limits: config.limits,
+ replicas: config.replicas,
+ port: config.port,
+ mounts: config.mounts,
+ commitHash: "unknown",
+ imageTag: config.imageTag,
+ };
+ }
+
+ private async createGitConfig(
+ config: GitWorkloadConfig,
+ commitHash: string,
+ repositoryId: number,
+ ): Promise {
+ return {
+ ...this.createCommonWorkloadConfig(config),
+ source: "GIT",
+ repositoryId,
+ branch: config.branch,
+ event: config.event,
+ eventId: config.eventId,
+ commitHash,
+ builder: config.builder,
+ dockerfilePath: config.dockerfilePath,
+ rootDir: config.rootDir,
+ imageTag: undefined,
+ } satisfies GitConfigCreate;
}
// Produces a DeploymentConfig object to be returned from the API, as described in the OpenAPI spec.
@@ -74,7 +225,9 @@ export class DeploymentConfigValidator {
}
if (config.port < 0 || config.port > 65535) {
- throw new Error("Invalid port number: must be between 0 and 65535");
+ throw new ValidationError(
+ "Invalid port number: must be between 0 and 65535",
+ );
}
this.validateEnv(config.env);
@@ -89,19 +242,19 @@ export class DeploymentConfigValidator {
) {
const { rootDir, builder, dockerfilePath, event, eventId } = config;
if (rootDir.startsWith("/") || rootDir.includes(`"`)) {
- throw new Error("Invalid root directory");
+ throw new ValidationError("Invalid root directory");
}
if (builder === "dockerfile") {
if (!dockerfilePath) {
- throw new Error("Dockerfile path is required");
+ throw new ValidationError("Dockerfile path is required");
}
if (dockerfilePath.startsWith("/") || dockerfilePath.includes(`"`)) {
- throw new Error("Invalid Dockerfile path");
+ throw new ValidationError("Invalid Dockerfile path");
}
}
if (event === "workflow_run" && eventId === undefined) {
- throw new Error("Workflow ID is required");
+ throw new ValidationError("Workflow ID is required");
}
if (config.event === "workflow_run" && config.eventId) {
@@ -113,17 +266,17 @@ export class DeploymentConfigValidator {
}) as ReturnType
).then((res) => res.data.workflows);
if (!workflows.some((workflow) => workflow.id === config.eventId)) {
- throw new Error("Workflow not found");
+ throw new ValidationError("Workflow not found");
}
} catch (err) {
- throw new Error("Failed to look up GitHub workflow");
+ throw new ValidationError("Failed to look up GitHub workflow");
}
}
}
async validateImageConfig(config: ImageWorkloadConfig) {
if (!config.imageTag) {
- throw new Error("Image tag is required");
+ throw new ValidationError("Image tag is required");
}
await this.validateImageReference(config.imageTag);
@@ -135,13 +288,13 @@ export class DeploymentConfigValidator {
const pathSet = new Set();
for (const mount of mounts) {
if (!mount.path.startsWith("/")) {
- throw new Error(
+ throw new ValidationError(
`Invalid mount path ${mount.path}: must start with '/'`,
);
}
if (pathSet.has(mount.path)) {
- throw new Error(`Invalid mounts: paths are not unique`);
+ throw new ValidationError(`Invalid mounts: paths are not unique`);
}
pathSet.add(mount.path);
}
@@ -149,29 +302,23 @@ export class DeploymentConfigValidator {
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",
- };
+ throw new ValidationError("Some environment variable(s) are empty");
}
if (env?.some((it) => it.name.startsWith("_PRIVATE_ANVILOPS_"))) {
// Environment variables with this prefix are used in the log shipper - see log-shipper/main.go
- return {
- valid: false,
- message:
- 'Environment variable(s) use reserved prefix "_PRIVATE_ANVILOPS_"',
- };
+ throw new ValidationError(
+ 'Environment variable(s) use reserved prefix "_PRIVATE_ANVILOPS_"',
+ );
}
const envNames = new Set();
for (let envVar of env) {
if (envNames.has(envVar.name)) {
- return {
- valid: false,
- message: "Duplicate environment variable " + envVar.name,
- };
+ throw new ValidationError(
+ "Duplicate environment variable " + envVar.name,
+ );
}
envNames.add(envVar.name);
}
@@ -183,13 +330,13 @@ export class DeploymentConfigValidator {
await getImageConfig(reference);
} catch (e) {
console.error(e);
- throw new Error("Image could not be found in its registry.");
+ throw new ValidationError("Image could not be found in its registry.");
}
}
private async validateSubdomain(subdomain: string) {
if (subdomain.length > MAX_SUBDOMAIN_LEN || !isRFC1123(subdomain)) {
- throw new Error(
+ throw new ValidationError(
"Subdomain must contain only lowercase alphanumeric characters or '-', " +
"start and end with an alphanumeric character, " +
`and contain at most ${MAX_SUBDOMAIN_LEN} characters`,
@@ -197,7 +344,7 @@ export class DeploymentConfigValidator {
}
if (await this.appRepo.isSubdomainInUse(subdomain)) {
- throw new Error("Subdomain is in use");
+ throw new ValidationError("Subdomain is in use");
}
}
}
diff --git a/backend/src/service/helper/index.ts b/backend/src/service/helper/index.ts
index 59e1e00c..29065b92 100644
--- a/backend/src/service/helper/index.ts
+++ b/backend/src/service/helper/index.ts
@@ -1,14 +1,17 @@
import { db } from "../../db/index.ts";
+import { getOctokit, getRepoById } from "../../lib/octokit.ts";
import { AppService } from "./app.ts";
import { DeploymentService } from "./deployment.ts";
-import { DeploymentConfigValidator } from "./deploymentConfig.ts";
+import { DeploymentConfigService } from "./deploymentConfig.ts";
-export const deploymentConfigValidator = new DeploymentConfigValidator(db.app);
-export const deploymentService = new DeploymentService(
- deploymentConfigValidator,
-);
+export const deploymentConfigService = new DeploymentConfigService(db.app);
-export const appService = new AppService(
- deploymentConfigValidator,
- deploymentService,
+export const appService = new AppService(deploymentConfigService);
+
+export const deploymentService = new DeploymentService(
+ db.app,
+ db.appGroup,
+ db.deployment,
+ getOctokit,
+ getRepoById,
);
diff --git a/backend/src/service/updateApp.ts b/backend/src/service/updateApp.ts
index 32be0cbb..cc76e0d5 100644
--- a/backend/src/service/updateApp.ts
+++ b/backend/src/service/updateApp.ts
@@ -8,27 +8,20 @@ import {
} 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";
+import {
+ appService,
+ deploymentConfigService,
+ deploymentService,
+} from "./helper/index.ts";
export type AppUpdate = components["schemas"]["AppUpdate"];
@@ -51,7 +44,7 @@ export async function updateApp(
]);
// performs validation
- const { config: updatedConfig, commitMessage } = (
+ let { config: updatedConfig, commitMessage } = (
await appService.prepareMetadataForApps(organization, user, appData)
)[0];
@@ -112,97 +105,33 @@ export async function updateApp(
}
const app = await db.app.getById(originalApp.id);
- const [appGroup, currentConfig, currentDeployment] = await Promise.all([
- db.appGroup.getById(app.appGroupId),
+ const [currentConfig, currentDeployment] = await Promise.all([
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,
+ // Adds an image tag to Git configs
+ updatedConfig = deploymentConfigService.updateConfigWithApp(
+ updatedConfig,
+ originalApp,
+ );
+ try {
+ await deploymentService.create({
+ org: organization,
+ app,
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,
+ git: {
+ skipBuild: !shouldBuildOnUpdate(
+ currentConfig,
+ updatedConfig,
+ currentDeployment,
+ ),
},
});
-
- 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",
- );
- }
+ // 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);
}
}
From d092487cd24e61e74f7d80b2344e67fce42a3287 Mon Sep 17 00:00:00 2001
From: zheng861
Date: Wed, 31 Dec 2025 15:21:55 -0700
Subject: [PATCH 12/38] Backend fixes - Add TARGETARCH buildarg to migration
job Dockerfile for local testing - Fix workload migration to use correct
appType parameter and sequence starting value - Omit id from Workload and
Helm configs to avoid confusion - List all missing env vars - Fix incorrect
image registry URL - Ignore not found error when deleting a namespace - Fix
various API handlers - Fix async error handling during app validation - Do
not await deployment process after validation and creating a Deployment -
Update git version
---
backend/prisma/Dockerfile | 3 +-
.../migration.sql | 6 +--
backend/src/db/models.ts | 2 -
backend/src/db/repo/app.ts | 14 ++++--
backend/src/db/repo/deployment.ts | 26 +++++++----
backend/src/handlers/createAppGroup.ts | 1 +
backend/src/lib/cluster/kubernetes.ts | 14 +++++-
backend/src/lib/env.ts | 9 +++-
backend/src/lib/registry.ts | 6 ++-
backend/src/service/createApp.ts | 4 +-
backend/src/service/createAppGroup.ts | 2 +-
backend/src/service/deleteApp.ts | 16 +++----
backend/src/service/helper/app.ts | 14 +++---
backend/src/service/helper/deployment.ts | 18 +++++---
.../src/service/helper/deploymentConfig.ts | 16 +++----
backend/src/service/helper/types.ts | 2 +-
backend/src/service/listOrgGroups.ts | 1 +
backend/src/service/updateApp.ts | 6 +--
backend/src/service/updateDeployment.ts | 2 +-
builders/dockerfile/Dockerfile | 2 +-
builders/railpack/Dockerfile | 2 +-
.../anvilops/anvilops-deployment.yaml | 2 +
openapi/openapi.yaml | 44 +++++++++----------
tilt/Tiltfile | 8 +++-
24 files changed, 130 insertions(+), 90 deletions(-)
diff --git a/backend/prisma/Dockerfile b/backend/prisma/Dockerfile
index 265ce57f..69c2230b 100644
--- a/backend/prisma/Dockerfile
+++ b/backend/prisma/Dockerfile
@@ -28,7 +28,8 @@ USER 65532
# https://github.com/krallin/tini
ENV TINI_VERSION=v0.19.0
-ADD --chown=65532:65532 --chmod=500 https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
+ARG TARGETARCH=amd64
+ADD --chown=65532:65532 --chmod=500 https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-${TARGETARCH} /tini
ENTRYPOINT ["/tini", "--", "/nodejs/bin/node", "/app/node_modules/.bin/prisma"]
COPY --chown=65532:65532 --from=build /app /app
diff --git a/backend/prisma/migrations/20251222211930_separate_workload_and_helm_configs/migration.sql b/backend/prisma/migrations/20251222211930_separate_workload_and_helm_configs/migration.sql
index 86656a19..7abf17bb 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
@@ -24,12 +24,12 @@ CREATE TABLE "DeploymentConfig" (
-- Fill with existing WorkloadConfigs
INSERT INTO "DeploymentConfig" ("id", "appType", "workloadConfigId")
-SELECT id, 'WORKLOAD', id FROM "WorkloadConfig";
+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")
+ (SELECT COALESCE(MAX(id), 1) FROM "DeploymentConfig")
);
-- Rename indexes
@@ -37,7 +37,7 @@ 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;
+ ON UPDATE CASCADE ON DELETE CASCADE;
ALTER TABLE "App" DROP CONSTRAINT "App_configId_fkey";
ALTER TABLE "App"
diff --git a/backend/src/db/models.ts b/backend/src/db/models.ts
index 11fa8c8a..059baef1 100644
--- a/backend/src/db/models.ts
+++ b/backend/src/db/models.ts
@@ -100,7 +100,6 @@ export interface DeploymentWithSourceInfo extends Omit {
}
export interface WorkloadConfig {
- id: number;
displayEnv: PrismaJson.EnvVar[];
getEnv(): PrismaJson.EnvVar[];
appType: "workload";
@@ -156,7 +155,6 @@ export type GitConfigCreate = WorkloadConfigCreate & {
};
export type HelmConfig = {
- id: number;
appType: "helm";
source: "HELM";
url: string;
diff --git a/backend/src/db/repo/app.ts b/backend/src/db/repo/app.ts
index 7f03c21d..62afe85a 100644
--- a/backend/src/db/repo/app.ts
+++ b/backend/src/db/repo/app.ts
@@ -226,21 +226,27 @@ export class AppRepo {
include: {
config: {
include: {
- workloadConfig: true,
- helmConfig: true,
+ workloadConfig: {
+ omit: { id: true },
+ },
+ helmConfig: {
+ omit: { id: true },
+ },
},
},
},
});
- if (app.config.appType === "workload") {
+ if (app.config?.appType === "workload") {
return DeploymentRepo.preprocessWorkloadConfig(app.config.workloadConfig);
- } else {
+ } else if (app.config?.appType === "helm") {
return {
...app.config.helmConfig,
source: "HELM",
appType: app.config.appType,
};
+ } else {
+ return null;
}
}
diff --git a/backend/src/db/repo/deployment.ts b/backend/src/db/repo/deployment.ts
index eff62967..464f322b 100644
--- a/backend/src/db/repo/deployment.ts
+++ b/backend/src/db/repo/deployment.ts
@@ -5,9 +5,9 @@ import type {
LogType,
PermissionLevel,
} from "../../generated/prisma/enums.ts";
-import {
+import type {
WorkloadConfigCreateInput,
- type WorkloadConfigModel as PrismaWorkloadConfig,
+ WorkloadConfigModel as PrismaWorkloadConfig,
} from "../../generated/prisma/models.ts";
import { decryptEnv, encryptEnv, generateKey } from "../crypto.ts";
import type { PrismaClientType } from "../index.ts";
@@ -21,6 +21,8 @@ import type {
WorkloadConfigCreate,
} from "../models.ts";
+type PrismaWorkloadConfigCreate = Omit;
+type PrismaHelmConfigCreate = Omit;
export class DeploymentRepo {
private client: PrismaClientType;
private publish: (topic: string, payload: any) => Promise;
@@ -89,6 +91,13 @@ export class DeploymentRepo {
workflowRunId?: number;
status?: DeploymentStatus;
}): Promise {
+ const configClone = structuredClone(config);
+ if (appType === "workload") {
+ delete configClone.appType;
+ } else if (appType === "helm") {
+ delete configClone.appType;
+ delete configClone.source;
+ }
return await this.client.deployment.create({
data: {
app: { connect: { id: appId } },
@@ -99,13 +108,13 @@ export class DeploymentRepo {
? {
workloadConfig: {
create: DeploymentRepo.encryptEnv(
- config as WorkloadConfigCreate,
+ configClone as PrismaWorkloadConfigCreate,
),
},
}
: {
helmConfig: {
- create: config as HelmConfigCreate,
+ create: configClone as PrismaHelmConfigCreate,
},
}),
},
@@ -175,8 +184,8 @@ export class DeploymentRepo {
select: {
config: {
include: {
- workloadConfig: true,
- helmConfig: true,
+ workloadConfig: { omit: { id: true } },
+ helmConfig: { omit: { id: true } },
},
},
},
@@ -196,7 +205,7 @@ export class DeploymentRepo {
}
private static encryptEnv(
- config: WorkloadConfigCreate,
+ config: PrismaWorkloadConfigCreate,
): WorkloadConfigCreateInput {
const copy = structuredClone(config) as WorkloadConfigCreateInput;
copy.envKey = generateKey();
@@ -205,7 +214,7 @@ export class DeploymentRepo {
}
static preprocessWorkloadConfig(
- config: PrismaWorkloadConfig,
+ config: Omit,
): WorkloadConfig {
if (config === null) {
return null;
@@ -238,7 +247,6 @@ export class DeploymentRepo {
const env = config.getEnv();
delete newConfig.displayEnv;
delete newConfig.getEnv;
- delete newConfig.id;
return { ...newConfig, env };
}
diff --git a/backend/src/handlers/createAppGroup.ts b/backend/src/handlers/createAppGroup.ts
index 7d6a3e8a..a8df7e92 100644
--- a/backend/src/handlers/createAppGroup.ts
+++ b/backend/src/handlers/createAppGroup.ts
@@ -17,6 +17,7 @@ export const createAppGroupHandler: HandlerMap["createAppGroup"] = async (
try {
await createAppGroup(req.user.id, data.orgId, data.name, data.apps);
+ return json(200, res, {});
} catch (e) {
if (e instanceof AppCreateError) {
const ex = e.cause!;
diff --git a/backend/src/lib/cluster/kubernetes.ts b/backend/src/lib/cluster/kubernetes.ts
index 8e78291f..1096a3eb 100644
--- a/backend/src/lib/cluster/kubernetes.ts
+++ b/backend/src/lib/cluster/kubernetes.ts
@@ -149,8 +149,18 @@ export const deleteNamespace = async (
api: KubernetesObjectApi,
name: string,
) => {
- await api.delete({ apiVersion: "v1", kind: "Namespace", metadata: { name } });
- console.log(`Namespace ${name} deleted`);
+ try {
+ await api.delete({
+ apiVersion: "v1",
+ kind: "Namespace",
+ metadata: { name },
+ });
+ } catch (err) {
+ if (err instanceof ApiException && err.code === 404) {
+ return;
+ }
+ throw err;
+ }
};
export const createOrUpdateApp = async (
diff --git a/backend/src/lib/env.ts b/backend/src/lib/env.ts
index ba93042b..3cf57d1b 100644
--- a/backend/src/lib/env.ts
+++ b/backend/src/lib/env.ts
@@ -232,12 +232,13 @@ const variables = {
export const env = {} as Record;
+const notFound: string[] = [];
for (const [key, _params] of Object.entries(variables)) {
const params = _params as EnvVarDefinition;
const value = process.env[key];
if (value === undefined) {
if (params.required === true) {
- throw new Error("Environment variable " + key + " not found.");
+ notFound.push(key);
} else if (params.defaultValue !== undefined) {
env[key] = params.defaultValue;
}
@@ -246,6 +247,12 @@ for (const [key, _params] of Object.entries(variables)) {
}
}
+if (notFound.length > 0) {
+ throw new Error(
+ "Environment variable(s) " + notFound.join(", ") + " not found.",
+ );
+}
+
// Either DATABASE_URL or the separate variables must be specified
if (
!env["DATABASE_URL"] &&
diff --git a/backend/src/lib/registry.ts b/backend/src/lib/registry.ts
index a3946cc6..d246b23b 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(
- `${env.REGISTRY_API_URL}/projects/${env.HARBOR_PROJECT_NAME}/repositories/${name}`,
+ `${env.REGISTRY_PROTOCOL}://${env.REGISTRY_HOSTNAME}/projects/${env.HARBOR_PROJECT_NAME}/repositories/${name}`,
{
method: "DELETE",
headers,
@@ -35,7 +35,9 @@ type HarborRepository = {
};
export async function getRepositoriesByProject(projectName: string) {
- return fetch(`${env.REGISTRY_API_URL}/projects/${projectName}/repositories`)
+ return fetch(
+ `${env.REGISTRY_PROTOCOL}://${env.REGISTRY_API_URL}/projects/${projectName}/repositories`,
+ )
.then((res) => res.text())
.then((res) => JSON.parse(res))
.then((res) => {
diff --git a/backend/src/service/createApp.ts b/backend/src/service/createApp.ts
index d6f9c7aa..33012c69 100644
--- a/backend/src/service/createApp.ts
+++ b/backend/src/service/createApp.ts
@@ -17,8 +17,8 @@ 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),
+ db.org.getById(appData.orgId, { requireUser: { id: userId } }),
+ db.user.getById(userId),
]);
if (!organization) {
diff --git a/backend/src/service/createAppGroup.ts b/backend/src/service/createAppGroup.ts
index a771e217..ab1f0920 100644
--- a/backend/src/service/createAppGroup.ts
+++ b/backend/src/service/createAppGroup.ts
@@ -1,5 +1,5 @@
import { ConflictError, db } from "../db/index.ts";
-import { App } from "../db/models.ts";
+import type { App } from "../db/models.ts";
import type { components } from "../generated/openapi.ts";
import { OrgNotFoundError, ValidationError } from "../service/common/errors.ts";
import { type NewApp } from "../service/createApp.ts";
diff --git a/backend/src/service/deleteApp.ts b/backend/src/service/deleteApp.ts
index 05404b7b..ec60765b 100644
--- a/backend/src/service/deleteApp.ts
+++ b/backend/src/service/deleteApp.ts
@@ -31,16 +31,12 @@ export async function deleteApp(
const config = await db.deployment.getConfig(lastDeployment.id);
if (!keepNamespace) {
- try {
- const { KubernetesObjectApi: api } = await getClientsForRequest(
- userId,
- projectId,
- ["KubernetesObjectApi"],
- );
- await deleteNamespace(api, getNamespace(namespace));
- } catch (err) {
- console.error("Failed to delete namespace:", err);
- }
+ const { KubernetesObjectApi: api } = await getClientsForRequest(
+ userId,
+ projectId,
+ ["KubernetesObjectApi"],
+ );
+ await deleteNamespace(api, getNamespace(namespace));
} else if (config.appType === "workload" && config.collectLogs) {
// If the log shipper was enabled, redeploy without it
config.collectLogs = false; // <-- Disable log shipping
diff --git a/backend/src/service/helper/app.ts b/backend/src/service/helper/app.ts
index 159f01f1..47683d26 100644
--- a/backend/src/service/helper/app.ts
+++ b/backend/src/service/helper/app.ts
@@ -1,5 +1,5 @@
-import { Organization, User } from "../../db/models.ts";
-import { components } from "../../generated/openapi.ts";
+import type { Organization, User } from "../../db/models.ts";
+import type { components } from "../../generated/openapi.ts";
import { namespaceInUse } from "../../lib/cluster/kubernetes.ts";
import {
canManageProject,
@@ -48,7 +48,7 @@ export class AppService {
)
).filter(Boolean);
if (appValidationErrors.length != 0) {
- throw new ValidationError(JSON.stringify(appValidationErrors));
+ throw new ValidationError(appValidationErrors.join(","));
}
if (
@@ -66,11 +66,11 @@ export class AppService {
| Awaited>
| Error
)[] = await Promise.all(
- apps.map((app) => {
+ apps.map(async (app) => {
try {
- return this.configService.prepareDeploymentMetadata(
+ return await this.configService.prepareDeploymentMetadata(
app.config,
- organization.id,
+ organization,
);
} catch (e) {
return e;
@@ -78,7 +78,7 @@ export class AppService {
}),
);
- const errors = metadata.filter((res) => res instanceof ValidationError);
+ const errors = metadata.filter((res) => res instanceof Error) as Error[];
if (errors.length > 0) {
throw new ValidationError(errors.map((err) => err.message).join(","));
}
diff --git a/backend/src/service/helper/deployment.ts b/backend/src/service/helper/deployment.ts
index f04497f8..24efe33a 100644
--- a/backend/src/service/helper/deployment.ts
+++ b/backend/src/service/helper/deployment.ts
@@ -1,5 +1,5 @@
import { Octokit } from "octokit";
-import {
+import type {
App,
AppGroup,
Deployment,
@@ -84,14 +84,18 @@ export class DeploymentService {
});
const config = await this.deploymentRepo.getConfig(deployment.id);
+ if (!app.configId) {
+ await this.appRepo.setConfig(app.id, deployment.configId);
+ }
+
switch (config.source) {
case "HELM": {
- await this.deployHelm(org, app, deployment, config as HelmConfig);
+ this.deployHelm(org, app, deployment, config as HelmConfig);
break;
}
case "GIT": {
- await this.deployGit({
+ this.deployGit({
org,
app,
deployment,
@@ -103,7 +107,7 @@ export class DeploymentService {
case "IMAGE": {
const appGroup = await this.appGroupRepo.getById(app.appGroupId);
- await this.deployWorkloadWithoutBuild({
+ this.deployWorkloadWithoutBuild({
org,
app,
appGroup,
@@ -134,9 +138,9 @@ export class DeploymentService {
app: App;
deployment: Deployment;
config: GitConfig;
- opts: GitOptions;
+ opts?: GitOptions;
}) {
- if (opts.checkRun) {
+ if (opts?.checkRun) {
// Webhook event deployment
const { pending, owner, repo } = opts.checkRun;
if (pending) {
@@ -181,7 +185,7 @@ export class DeploymentService {
},
});
}
- } else if (opts.skipBuild) {
+ } else if (opts?.skipBuild) {
// Minor config update
const appGroup = await this.appGroupRepo.getById(app.appGroupId);
await this.deployWorkloadWithoutBuild({
diff --git a/backend/src/service/helper/deploymentConfig.ts b/backend/src/service/helper/deploymentConfig.ts
index 09903a9a..b3d9b46d 100644
--- a/backend/src/service/helper/deploymentConfig.ts
+++ b/backend/src/service/helper/deploymentConfig.ts
@@ -1,14 +1,15 @@
import { Octokit } from "octokit";
-import {
+import type {
App,
GitConfigCreate,
HelmConfig,
HelmConfigCreate,
+ Organization,
WorkloadConfig,
WorkloadConfigCreate,
} from "../../db/models.ts";
import { AppRepo } from "../../db/repo/app.ts";
-import { components } from "../../generated/openapi.ts";
+import type { components } from "../../generated/openapi.ts";
import { MAX_SUBDOMAIN_LEN } from "../../lib/cluster/resources.ts";
import { getImageConfig } from "../../lib/cluster/resources/logs.ts";
import { generateVolumeName } from "../../lib/cluster/resources/statefulset.ts";
@@ -16,7 +17,7 @@ import { env } from "../../lib/env.ts";
import { getOctokit, getRepoById } from "../../lib/octokit.ts";
import { isRFC1123 } from "../../lib/validate.ts";
import { ValidationError } from "../common/errors.ts";
-import { GitWorkloadConfig, ImageWorkloadConfig } from "./types.ts";
+import type { GitWorkloadConfig, ImageWorkloadConfig } from "./types.ts";
export class DeploymentConfigService {
private appRepo: AppRepo;
@@ -34,7 +35,7 @@ export class DeploymentConfigService {
async prepareDeploymentMetadata(
config: components["schemas"]["DeploymentConfig"],
- orgId: number,
+ organization: Pick,
): Promise<{
config: GitConfigCreate | HelmConfigCreate | WorkloadConfigCreate;
commitMessage: string;
@@ -47,11 +48,11 @@ export class DeploymentConfigService {
let octokit: Octokit, repo: Awaited>;
try {
- octokit = await this.getOctokitFn(orgId);
+ octokit = await this.getOctokitFn(organization.githubInstallationId);
repo = await this.getRepoByIdFn(octokit, config.repositoryId);
} catch (err) {
if (err.status === 404) {
- throw new Error("Invalid repository id");
+ throw new ValidationError("Invalid repository id");
}
console.error(err);
@@ -87,7 +88,7 @@ export class DeploymentConfigService {
};
}
case "image": {
- this.validateImageConfig(config);
+ await this.validateImageConfig(config);
return {
config: {
...this.createCommonWorkloadConfig(config),
@@ -329,7 +330,6 @@ export class DeploymentConfigService {
// Look up the image in its registry to make sure it exists
await getImageConfig(reference);
} catch (e) {
- console.error(e);
throw new ValidationError("Image could not be found in its registry.");
}
}
diff --git a/backend/src/service/helper/types.ts b/backend/src/service/helper/types.ts
index 82c600fb..c1f27d57 100644
--- a/backend/src/service/helper/types.ts
+++ b/backend/src/service/helper/types.ts
@@ -1,4 +1,4 @@
-import { components } from "../../generated/openapi.ts";
+import type { components } from "../../generated/openapi.ts";
export type GitWorkloadConfig =
components["schemas"]["WorkloadConfigOptions"] & { source: "git" };
diff --git a/backend/src/service/listOrgGroups.ts b/backend/src/service/listOrgGroups.ts
index cb9c5e54..3fe69174 100644
--- a/backend/src/service/listOrgGroups.ts
+++ b/backend/src/service/listOrgGroups.ts
@@ -14,5 +14,6 @@ export async function listOrgGroups(orgId: number, userId: number) {
return appGroups.map((group) => ({
id: group.id,
name: group.name,
+ isMono: group.isMono,
}));
}
diff --git a/backend/src/service/updateApp.ts b/backend/src/service/updateApp.ts
index cc76e0d5..cfaee652 100644
--- a/backend/src/service/updateApp.ts
+++ b/backend/src/service/updateApp.ts
@@ -1,5 +1,5 @@
import { db } from "../db/index.ts";
-import {
+import type {
Deployment,
HelmConfig,
HelmConfigCreate,
@@ -39,8 +39,8 @@ export async function updateApp(
}
const [organization, user] = await Promise.all([
- this.orgRepo.getById(originalApp.orgId, { requireUser: { id: userId } }),
- this.userRepo.getById(userId),
+ db.org.getById(originalApp.orgId, { requireUser: { id: userId } }),
+ db.user.getById(userId),
]);
// performs validation
diff --git a/backend/src/service/updateDeployment.ts b/backend/src/service/updateDeployment.ts
index 13bf72aa..83486570 100644
--- a/backend/src/service/updateDeployment.ts
+++ b/backend/src/service/updateDeployment.ts
@@ -98,7 +98,7 @@ export async function updateDeployment(secret: string, newStatus: string) {
await Promise.all([
db.deployment.setStatus(deployment.id, "COMPLETE"),
// The update was successful. Update App with the reference to the latest successful config.
- db.app.setConfig(app.id, config.id),
+ db.app.setConfig(app.id, deployment.configId),
]);
dequeueBuildJob(); // TODO - error handling for this line
diff --git a/builders/dockerfile/Dockerfile b/builders/dockerfile/Dockerfile
index 41386cea..a19a7e25 100644
--- a/builders/dockerfile/Dockerfile
+++ b/builders/dockerfile/Dockerfile
@@ -6,7 +6,7 @@ RUN apt-get update && \
add-apt-repository ppa:git-core/ppa && \
apt-get update && \
# ^ Get the latest version of Git from their PPA (we need 2.49.0 or later because we use the `git clone --revision` flag)
- apt-get install -y --no-install-recommends git=1:2.52.0-0ppa1~ubuntu24.04.1 && \
+ apt-get install -y --no-install-recommends git=1:2.52.0-0ppa1~ubuntu24.04.3 && \
rm -rf /var/lib/apt/lists/*
RUN groupadd -g 65532 -r appuser && \
diff --git a/builders/railpack/Dockerfile b/builders/railpack/Dockerfile
index 6c15b992..f415ddb7 100644
--- a/builders/railpack/Dockerfile
+++ b/builders/railpack/Dockerfile
@@ -8,7 +8,7 @@ RUN apt-get update && \
add-apt-repository ppa:git-core/ppa && \
apt-get update && \
# ^ Get the latest version of Git from their PPA (we need 2.49.0 or later because we use the `git clone --revision` flag)
- apt-get install -y --no-install-recommends git=1:2.52.0-0ppa1~ubuntu24.04.1 && \
+ apt-get install -y --no-install-recommends git=1:2.52.0-0ppa1~ubuntu24.04.3 && \
apt-get remove -y software-properties-common && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
diff --git a/charts/anvilops/templates/anvilops/anvilops-deployment.yaml b/charts/anvilops/templates/anvilops/anvilops-deployment.yaml
index e75af78d..35d59319 100644
--- a/charts/anvilops/templates/anvilops/anvilops-deployment.yaml
+++ b/charts/anvilops/templates/anvilops/anvilops-deployment.yaml
@@ -203,6 +203,8 @@ spec:
value: {{ .Release.Namespace }}
- name: REGISTRY_HOSTNAME
value: {{ .Values.harbor.expose.ingress.hosts.core }}
+ - name: REGISTRY_API_URL
+ value: {{ .Values.harbor.expose.ingress.hosts.core }}/api/v2.0
- name: CLUSTER_INTERNAL_BASE_URL
value: "http://{{ include "anvilops.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local"
- name: INGRESS_CLASS_NAME
diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml
index 1710fcf2..94743254 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -1002,7 +1002,9 @@ paths:
format: int64
name:
type: string
- required: [id, name]
+ isMono:
+ type: boolean
+ required: [id, name, isMono]
"400":
description: Validation failed
content:
@@ -2195,6 +2197,7 @@ components:
$ref: "#/components/schemas/Mount"
subdomain:
type: string
+ nullable: true
createIngress:
type: boolean
collectLogs:
@@ -2218,6 +2221,7 @@ components:
- requests
- collectLogs
- createIngress
+ - subdomain
GitDeploymentOptions:
allOf:
- type: object
@@ -2280,7 +2284,7 @@ components:
values:
type: object
additionalProperties: true
- required: [source, url, urlType, version]
+ required: [appType, source, url, urlType, version]
Mount:
type: object
@@ -2452,31 +2456,25 @@ components:
required:
- name
- orgId
- - createIngress
- namespace
- config
+ - appGroup
NewAppWithoutGroupInfo:
type: object
- allOf:
- - type: object
- properties:
- name:
- type: string
- # Source options
- projectId:
- type: string
- namespace:
- type: string
- config:
- $ref: "#/components/schemas/DeploymentConfig"
- required:
- - name
- - projectId
- - namespace
- - config
- - oneOf:
- - $ref: "#/components/schemas/GitDeploymentOptions"
- - $ref: "#/components/schemas/ImageDeploymentOptions"
+ properties:
+ name:
+ type: string
+ # Source options
+ projectId:
+ type: string
+ namespace:
+ type: string
+ config:
+ $ref: "#/components/schemas/DeploymentConfig"
+ required:
+ - name
+ - namespace
+ - config
Envs:
type: array
items:
diff --git a/tilt/Tiltfile b/tilt/Tiltfile
index e2798775..aa4251cb 100644
--- a/tilt/Tiltfile
+++ b/tilt/Tiltfile
@@ -5,6 +5,12 @@ load('ext://deployment', 'deployment_create')
load('ext://helm_remote', 'helm_remote')
dotenv(fn='../backend/.env')
+arch = str(local('uname -m')).strip()
+if arch == "arm64" or arch == "aarch64":
+ platform = "arm64"
+else:
+ platform = "amd64"
+
# Should the frontend and backend be deployed separately?
# If enabled, changes will apply much faster at the expense of a development environment that looks less like production.
separate_frontend_and_backend = True
@@ -37,7 +43,7 @@ else:
"anvilops/anvilops", "../",
only=["frontend", "backend", "swagger-ui", "templates", "openapi"]
)
-docker_build("anvilops/migrate-db", "../backend", dockerfile="../backend/prisma/Dockerfile", only=["package.json", "package-lock.json", "prisma", "prisma.config.ts"])
+docker_build("anvilops/migrate-db", "../backend", dockerfile="../backend/prisma/Dockerfile", only=["package.json", "package-lock.json", "prisma", "prisma.config.ts"], build_args={'TARGETARCH': platform})
docker_build("anvilops/file-browser", "../filebrowser", match_in_env_vars=True)
docker_build("anvilops/dockerfile-builder", "../builders/dockerfile", match_in_env_vars=True)
docker_build("anvilops/railpack-builder", "../builders/railpack", match_in_env_vars=True)
From 2f20e984b9b4dd9293ef845808902399f5f173b4 Mon Sep 17 00:00:00 2001
From: zheng861
Date: Wed, 31 Dec 2025 18:36:12 -0700
Subject: [PATCH 13/38] Fix update and deployment bugs
---
backend/src/db/repo/app.ts | 18 +++++------
backend/src/service/helper/app.ts | 12 ++++---
backend/src/service/helper/deployment.ts | 6 ++++
.../src/service/helper/deploymentConfig.ts | 9 ++++--
backend/src/service/isSubdomainAvailable.ts | 4 +--
backend/src/service/updateApp.ts | 32 +++++++++++++++----
6 files changed, 56 insertions(+), 25 deletions(-)
diff --git a/backend/src/db/repo/app.ts b/backend/src/db/repo/app.ts
index 62afe85a..e18ae6fe 100644
--- a/backend/src/db/repo/app.ts
+++ b/backend/src/db/repo/app.ts
@@ -76,17 +76,15 @@ export class AppRepo {
});
}
- async isSubdomainInUse(subdomain: string): Promise {
- return (
- (await this.client.app.count({
- where: {
- config: {
- appType: "workload",
- workloadConfig: { subdomain },
- },
+ async getAppBySubdomain(subdomain: string): Promise {
+ return this.client.app.findFirst({
+ where: {
+ config: {
+ appType: "workload",
+ workloadConfig: { subdomain },
},
- })) > 0
- );
+ },
+ });
}
async listForOrg(orgId: number): Promise {
diff --git a/backend/src/service/helper/app.ts b/backend/src/service/helper/app.ts
index 47683d26..a3670112 100644
--- a/backend/src/service/helper/app.ts
+++ b/backend/src/service/helper/app.ts
@@ -14,7 +14,8 @@ import { isRFC1123 } from "../../lib/validate.ts";
import { ValidationError } from "../../service/common/errors.ts";
import { DeploymentConfigService } from "./deploymentConfig.ts";
-export interface App {
+interface App {
+ existingAppId?: number;
name?: string;
projectId?: string;
namespace?: string;
@@ -39,7 +40,7 @@ export class AppService {
await Promise.all(
apps.map(async (app) => {
try {
- await this.validateNewApp(app, user);
+ await this.validateApp(app, user);
return null;
} catch (e) {
return e.message;
@@ -91,7 +92,7 @@ export class AppService {
/**
* @throws ValidationError
*/
- private async validateNewApp(app: App, user: { clusterUsername: string }) {
+ private async validateApp(app: App, user: { clusterUsername: string }) {
if (isRancherManaged()) {
if (!app.projectId) {
throw new ValidationError("Project ID is required");
@@ -103,7 +104,10 @@ export class AppService {
}
if (app.config.appType === "workload") {
- await this.configService.validateCommonWorkloadConfig(app.config);
+ await this.configService.validateCommonWorkloadConfig(
+ app.config,
+ app.existingAppId,
+ );
}
if (app.namespace) {
diff --git a/backend/src/service/helper/deployment.ts b/backend/src/service/helper/deployment.ts
index 24efe33a..0bb390dc 100644
--- a/backend/src/service/helper/deployment.ts
+++ b/backend/src/service/helper/deployment.ts
@@ -373,6 +373,7 @@ export class DeploymentService {
deployment.id,
DeploymentStatus.COMPLETE,
);
+ await this.appRepo.setConfig(app.id, deployment.configId);
} catch (e) {
await this.deploymentRepo.setStatus(
deployment.id,
@@ -410,6 +411,11 @@ export class DeploymentService {
release: app.name,
values: config.values,
});
+ await this.deploymentRepo.setStatus(
+ deployment.id,
+ DeploymentStatus.COMPLETE,
+ );
+ await this.appRepo.setConfig(app.id, deployment.configId);
} catch (e) {
await this.deploymentRepo.setStatus(
deployment.id,
diff --git a/backend/src/service/helper/deploymentConfig.ts b/backend/src/service/helper/deploymentConfig.ts
index b3d9b46d..ef83fab1 100644
--- a/backend/src/service/helper/deploymentConfig.ts
+++ b/backend/src/service/helper/deploymentConfig.ts
@@ -75,6 +75,7 @@ export class DeploymentConfigService {
per_page: 1,
owner: repo.owner.login,
repo: repo.name,
+ sha: config.branch,
})
).data[0];
@@ -220,9 +221,10 @@ export class DeploymentConfigService {
async validateCommonWorkloadConfig(
config: components["schemas"]["WorkloadConfigOptions"],
+ existingAppId?: number,
) {
if (config.subdomain) {
- await this.validateSubdomain(config.subdomain);
+ await this.validateSubdomain(config.subdomain, existingAppId);
}
if (config.port < 0 || config.port > 65535) {
@@ -334,7 +336,7 @@ export class DeploymentConfigService {
}
}
- private async validateSubdomain(subdomain: string) {
+ private async validateSubdomain(subdomain: string, existingAppId?: number) {
if (subdomain.length > MAX_SUBDOMAIN_LEN || !isRFC1123(subdomain)) {
throw new ValidationError(
"Subdomain must contain only lowercase alphanumeric characters or '-', " +
@@ -343,7 +345,8 @@ export class DeploymentConfigService {
);
}
- if (await this.appRepo.isSubdomainInUse(subdomain)) {
+ const appWithSubdomain = await this.appRepo.getAppBySubdomain(subdomain);
+ if (appWithSubdomain && appWithSubdomain.id !== existingAppId) {
throw new ValidationError("Subdomain is in use");
}
}
diff --git a/backend/src/service/isSubdomainAvailable.ts b/backend/src/service/isSubdomainAvailable.ts
index 6877b8da..1c89a6d3 100644
--- a/backend/src/service/isSubdomainAvailable.ts
+++ b/backend/src/service/isSubdomainAvailable.ts
@@ -9,6 +9,6 @@ export async function isSubdomainAvailable(subdomain: string) {
throw new ValidationError("Invalid subdomain.");
}
- const subdomainUsedByApp = await db.app.isSubdomainInUse(subdomain);
- return !subdomainUsedByApp;
+ const subdomainUsedByApp = await db.app.getAppBySubdomain(subdomain);
+ return subdomainUsedByApp === null;
}
diff --git a/backend/src/service/updateApp.ts b/backend/src/service/updateApp.ts
index cfaee652..bb8cc288 100644
--- a/backend/src/service/updateApp.ts
+++ b/backend/src/service/updateApp.ts
@@ -45,17 +45,23 @@ export async function updateApp(
// performs validation
let { config: updatedConfig, commitMessage } = (
- await appService.prepareMetadataForApps(organization, user, appData)
+ await appService.prepareMetadataForApps(organization, user, {
+ existingAppId: originalApp.id,
+ ...appData,
+ })
)[0];
// ---------------- App group updates ----------------
switch (appData.appGroup.type) {
case "add-to": {
+ if (appData.appGroup.id === originalApp.appGroupId) {
+ break;
+ }
const group = await db.appGroup.getById(appData.appGroup.id);
if (!group) {
throw new ValidationError("Invalid app group");
}
- db.app.setGroup(originalApp.id, appData.appGroup.id);
+ await db.app.setGroup(originalApp.id, appData.appGroup.id);
break;
}
@@ -66,11 +72,14 @@ export async function updateApp(
appData.appGroup.name,
false,
);
- db.app.setGroup(originalApp.id, appGroupId);
+ await db.app.setGroup(originalApp.id, appGroupId);
break;
}
case "standalone": {
+ if (appData.appGroup.type === "standalone") {
+ break;
+ }
// 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
@@ -80,7 +89,7 @@ export async function updateApp(
groupName,
true,
);
- db.app.setGroup(originalApp.id, appGroupId);
+ await db.app.setGroup(originalApp.id, appGroupId);
break;
}
}
@@ -113,8 +122,19 @@ export async function updateApp(
// Adds an image tag to Git configs
updatedConfig = deploymentConfigService.updateConfigWithApp(
updatedConfig,
- originalApp,
+ app,
);
+
+ if (
+ updatedConfig.appType === "workload" &&
+ currentConfig.appType === "workload"
+ ) {
+ updatedConfig.env = withSensitiveEnv(
+ currentConfig.getEnv(),
+ updatedConfig.env,
+ );
+ }
+
try {
await deploymentService.create({
org: organization,
@@ -177,7 +197,7 @@ const shouldBuildOnUpdate = (
};
// Patch the null(hidden) values of env vars sent from client with the sensitive plaintext
-export const withSensitiveEnv = (
+const withSensitiveEnv = (
lastPlaintextEnv: PrismaJson.EnvVar[],
envVars: {
name: string;
From 0b527be3f1cd6c1499789decba19a4bebd726f10 Mon Sep 17 00:00:00 2001
From: zheng861
Date: Fri, 2 Jan 2026 12:15:10 -0700
Subject: [PATCH 14/38] Fix updateApp, listChart, and helm upgrade
---
Dockerfile | 1 +
backend/src/handlers/listCharts.ts | 10 ++++++-
backend/src/lib/helm.ts | 16 +++++-----
backend/src/lib/registry.ts | 10 +++++--
backend/src/service/listCharts.ts | 29 ++++++++++++-------
backend/src/service/updateApp.ts | 2 +-
.../anvilops/anvilops-deployment.yaml | 2 ++
charts/anvilops/values.yaml | 1 +
openapi/openapi.yaml | 10 +++++++
tilt/anvilops-backend.Dockerfile | 1 +
10 files changed, 59 insertions(+), 23 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 6ca2036f..90a8a8e8 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -84,6 +84,7 @@ ENTRYPOINT ["/tini", "--", "/nodejs/bin/node", "--experimental-strip-types"]
CMD ["/app/src/index.ts"]
WORKDIR /app
+COPY --chown=65532:65532 --from=alpine/helm:3.19.0 /usr/bin/helm /usr/local/bin/helm
COPY --chown=65532:65532 --from=regclient/regctl:v0.11.1-alpine /usr/local/bin/regctl /usr/local/bin/regctl
COPY --chown=65532:65532 --from=swagger_build /app/dist ./public/openapi
COPY --chown=65532:65532 --from=frontend_build /app/dist ./public
diff --git a/backend/src/handlers/listCharts.ts b/backend/src/handlers/listCharts.ts
index c66c4241..939fb8ef 100644
--- a/backend/src/handlers/listCharts.ts
+++ b/backend/src/handlers/listCharts.ts
@@ -5,5 +5,13 @@ export const listChartsHandler: HandlerMap["listCharts"] = async (
req,
res,
) => {
- return json(200, res, await listCharts());
+ try {
+ return json(200, res, await listCharts());
+ } catch (e) {
+ console.error(e);
+ return json(500, res, {
+ code: 500,
+ message: "Something went wrong.",
+ });
+ }
};
diff --git a/backend/src/lib/helm.ts b/backend/src/lib/helm.ts
index bca88d09..3fb137a3 100644
--- a/backend/src/lib/helm.ts
+++ b/backend/src/lib/helm.ts
@@ -38,9 +38,7 @@ const runHelm = (args: string[]) => {
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}`)),
+ code === 0 ? resolve(out) : reject(new Error(err || `helm exit ${code}`)),
);
});
};
@@ -56,6 +54,7 @@ export const getChart = async (
args.push(url);
const result = (await runHelm(args)) as string;
+ console.log("result", result);
const chart = (await yamlParse(result)) as Chart;
return chart;
};
@@ -72,19 +71,20 @@ export const upgrade = ({
chartURL: string;
version: string;
namespace: string;
- values: { [key: string]: string };
+ values: Record;
release: string;
}) => {
- const kvPairs = Object.keys(values).map((key, value) => `${key}=${value}`);
- let args = [
+ const args = [
"upgrade",
"--install",
"--namespace",
namespace,
"--create-namespace",
- "--set",
- kvPairs.join(","),
];
+
+ for (const [key, value] of Object.entries(values)) {
+ args.push("--set-json", `${key}=${JSON.stringify(value)}`);
+ }
switch (urlType) {
// example: helm install mynginx https://example.com/charts/nginx-1.2.3.tgz
case "absolute": {
diff --git a/backend/src/lib/registry.ts b/backend/src/lib/registry.ts
index d246b23b..3c70bbe8 100644
--- a/backend/src/lib/registry.ts
+++ b/backend/src/lib/registry.ts
@@ -38,12 +38,16 @@ export async function getRepositoriesByProject(projectName: string) {
return fetch(
`${env.REGISTRY_PROTOCOL}://${env.REGISTRY_API_URL}/projects/${projectName}/repositories`,
)
+ .then((res) => {
+ if (!res.ok) {
+ console.error(res);
+ throw new Error(res.statusText);
+ }
+ return res;
+ })
.then((res) => res.text())
.then((res) => JSON.parse(res))
.then((res) => {
- if ("errors" in res) {
- throw res;
- }
return res as HarborRepository[];
});
}
diff --git a/backend/src/service/listCharts.ts b/backend/src/service/listCharts.ts
index f100029f..73233442 100644
--- a/backend/src/service/listCharts.ts
+++ b/backend/src/service/listCharts.ts
@@ -4,18 +4,27 @@ import { getRepositoriesByProject } from "../lib/registry.ts";
export async function listCharts() {
const repos = await getRepositoriesByProject(env.CHART_PROJECT_NAME);
- return await Promise.all(
+ 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 await getChart(url);
}),
);
+
+ if (charts.some((chart) => chart === null)) {
+ throw new Error("Failed to get charts");
+ }
+
+ return charts
+ .filter(
+ (chart) => chart?.annotations && "anvilops-values" in chart?.annotations,
+ )
+ .map((chart) => ({
+ name: chart.name,
+ note: chart.annotations["anvilops-note"],
+ url: `oci://${env.REGISTRY_HOSTNAME}/${chart.name}`,
+ urlType: "oci",
+ version: chart.version,
+ valueSpec: JSON.parse(chart.annotations["anvilops-values"] ?? ""),
+ }));
}
diff --git a/backend/src/service/updateApp.ts b/backend/src/service/updateApp.ts
index bb8cc288..55818ed5 100644
--- a/backend/src/service/updateApp.ts
+++ b/backend/src/service/updateApp.ts
@@ -52,7 +52,7 @@ export async function updateApp(
)[0];
// ---------------- App group updates ----------------
- switch (appData.appGroup.type) {
+ switch (appData.appGroup?.type) {
case "add-to": {
if (appData.appGroup.id === originalApp.appGroupId) {
break;
diff --git a/charts/anvilops/templates/anvilops/anvilops-deployment.yaml b/charts/anvilops/templates/anvilops/anvilops-deployment.yaml
index 35d59319..249fd30f 100644
--- a/charts/anvilops/templates/anvilops/anvilops-deployment.yaml
+++ b/charts/anvilops/templates/anvilops/anvilops-deployment.yaml
@@ -221,6 +221,8 @@ spec:
value: {{ .Values.anvilops.env.appDomain }}
- name: HARBOR_PROJECT_NAME
value: {{ .Values.anvilops.env.harborProjectName }}
+ - name: CHART_PROJECT_NAME
+ value: {{ .Values.anvilops.env.harborChartRepoName }}
- name: BUILDKITD_ADDRESS
value: {{ .Values.buildkitd.address }}
- name: FILE_BROWSER_IMAGE
diff --git a/charts/anvilops/values.yaml b/charts/anvilops/values.yaml
index 0fb2f8c0..98374d84 100644
--- a/charts/anvilops/values.yaml
+++ b/charts/anvilops/values.yaml
@@ -12,6 +12,7 @@ anvilops:
baseURL: http://anvilops.minikube.local
appDomain: http://minikube.local
harborProjectName: anvilops
+ harborChartRepoName: anvilops-chart
allowedIdps: https://idp.purdue.edu/idp/shibboleth
loginType: shibboleth
fileBrowserImage: registry.anvil.rcac.purdue.edu/anvilops/file-browser:latest
diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml
index 94743254..2e5b94e5 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -1682,6 +1682,10 @@ paths:
description: Unexpected authentication type (expected Basic) or log type (expected build or runtime)
"500":
description: Internal server error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ApiError"
/app/{appId}/deployments/{deploymentId}:
parameters:
@@ -1863,6 +1867,12 @@ paths:
type: object
additionalProperties: true
required: [name, note, url, urlType, version]
+ "500":
+ description: Internal server error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ApiError"
components:
schemas:
UserOrg:
diff --git a/tilt/anvilops-backend.Dockerfile b/tilt/anvilops-backend.Dockerfile
index e0f65683..a36327af 100644
--- a/tilt/anvilops-backend.Dockerfile
+++ b/tilt/anvilops-backend.Dockerfile
@@ -35,6 +35,7 @@ RUN npm run prisma:generate
# Run the backend
FROM base AS backend_run
+RUN apk add helm=3.19.0-r2
ENTRYPOINT ["/usr/local/bin/node", "--experimental-strip-types"]
CMD ["./src/index.ts"]
From 5d59f0a74c7a2890ea9fa762c7daa8237a0d10c3 Mon Sep 17 00:00:00 2001
From: zheng861
Date: Fri, 2 Jan 2026 12:50:24 -0700
Subject: [PATCH 15/38] Organize config and diff components by app source
---
frontend/src/components/ConfigVar.tsx | 32 --------------
frontend/src/components/PagedView.tsx | 44 -------------------
frontend/src/components/ProtectedRoute.tsx | 13 ------
.../config}/AppConfigFormFields.tsx | 11 +++--
.../{ => config/workload}/EnvVarGrid.tsx | 8 ++--
.../{ => config/workload}/MountsGrid.tsx | 6 +--
.../workload/git}/GitDeploymentFields.tsx | 2 +-
.../workload/git}/ImportRepoDialog.tsx | 19 +++++---
.../diff}/AppConfigDiff.tsx | 8 ++--
.../diff}/DiffInput.tsx | 0
.../diff/workload}/EnvsWithDiffs.tsx | 0
.../diff/workload/git}/GitConfigDiff.tsx | 4 +-
frontend/src/pages/app/ConfigTab.tsx | 2 +-
.../src/pages/app/overview/RedeployModal.tsx | 5 ++-
.../pages/create-app/CreateAppGroupView.tsx | 2 +-
.../src/pages/create-app/CreateAppView.tsx | 2 +-
16 files changed, 40 insertions(+), 118 deletions(-)
delete mode 100644 frontend/src/components/ConfigVar.tsx
delete mode 100644 frontend/src/components/PagedView.tsx
delete mode 100644 frontend/src/components/ProtectedRoute.tsx
rename frontend/src/{pages/create-app => components/config}/AppConfigFormFields.tsx (98%)
rename frontend/src/components/{ => config/workload}/EnvVarGrid.tsx (96%)
rename frontend/src/components/{ => config/workload}/MountsGrid.tsx (93%)
rename frontend/src/{pages/create-app => components/config/workload/git}/GitDeploymentFields.tsx (99%)
rename frontend/src/components/{ => config/workload/git}/ImportRepoDialog.tsx (95%)
rename frontend/src/{pages/app/overview => components/diff}/AppConfigDiff.tsx (98%)
rename frontend/src/{pages/app/overview => components/diff}/DiffInput.tsx (100%)
rename frontend/src/{pages/app/overview => components/diff/workload}/EnvsWithDiffs.tsx (100%)
rename frontend/src/{pages/app/overview => components/diff/workload/git}/GitConfigDiff.tsx (99%)
diff --git a/frontend/src/components/ConfigVar.tsx b/frontend/src/components/ConfigVar.tsx
deleted file mode 100644
index d1a74726..00000000
--- a/frontend/src/components/ConfigVar.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { Input } from "@/components/ui/input";
-
-export default function ConfigVar({
- name,
- value,
- namePlaceholder,
- valuePlaceholder,
- secret = false,
-}: {
- name?: string;
- value?: string;
- namePlaceholder?: string;
- valuePlaceholder?: string;
- secret?: boolean;
-}) {
- return (
-
-
-
-
- );
-}
diff --git a/frontend/src/components/PagedView.tsx b/frontend/src/components/PagedView.tsx
deleted file mode 100644
index 83825c76..00000000
--- a/frontend/src/components/PagedView.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { Button } from "./ui/button";
-import { useState } from "react";
-
-export const PagedView = ({
- children,
- submitButton,
-}: {
- children: React.ReactNode[];
- submitButton: React.ReactNode;
-}) => {
- const [idx, setIdx] = useState(0);
- return (
-
- {children.map((child, i) => {
- if (i === idx) {
- return child;
- }
- return
{child}
;
- })}
-
- {idx > 0 && (
-
setIdx((idx) => idx - 1)}
- >
- Back
-
- )}
- {idx < children.length - 1 ? (
-
setIdx((idx) => idx + 1)}
- className="float-right"
- >
- Next
-
- ) : (
-
{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 && (
-
-
-
- Public URL
-
- {createIngress && (
-
- *
-
- )}
-
-
- {
- if (checked) {
- setState((prev) => ({
- ...prev,
- createIngress: !!checked,
- subdomain: "",
- }));
- } else {
- setState((prev) => ({ ...prev, createIngress: checked }));
- }
- }}
- />
- Make my app public
-
-
-
- {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}
-
- )}
-
-
-
- Port Number
-
-
- *
-
-
-
{
- const port = e.currentTarget.value;
- setState((state) => ({ ...state, port }));
- }}
- />
-
-
-
-
- CPU Cores
-
-
- *
-
-
-
-
- Memory
-
-
- *
-
-
-
{
- const cpuCores = e.currentTarget.valueAsNumber;
- setState((state) => ({ ...state, cpuCores }));
- }}
- />
-
- {
- const memoryInMiB = e.currentTarget.valueAsNumber;
- setState((state) => ({ ...state, memoryInMiB }));
- }}
- />
- MiB
-
-
-
-
-
-
- Environment Variables
-
-
-
- {
- 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 && (
-
-
-
- Volume Mounts
-
-
-
- {!!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 && (
-
-
-
- Advanced
-
-
-
-
-
-
- Keep Historical Logs
-
-
- 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,
- }));
- }}
- />
-
- Record application logs as they're produced
-
-
-
-
-
-
- )}
-
- >
- );
+ const commonWorkloadSetter = makeFunctionalWorkloadSetter(setState);
return (
<>
- {!hideGroupSelect && (
- <>
- Grouping Options
-
-
-
-
-
- Group
-
-
- *
-
-
-
- Applications can be created as standalone apps, or as part of a
- group of related microservices.
-
-
-
{
- const groupId = parseInt(groupOption);
- if (isNaN(groupId)) {
- setState((prev) => ({
- ...prev,
- groupOption: groupOption,
- groupId: undefined,
- }));
- } else {
- setState((prev) => ({
- ...prev,
- groupOption: "add-to",
- groupId,
- }));
- }
- }}
- value={
- groupOption === "add-to" ? groupId?.toString() : groupOption
- }
- name="group"
- >
-
-
-
-
-
- Standalone app
- Create new group
- {groups && groups.length > 0 && (
- <>
-
- Add to existing group
-
- {groups?.map((group) => (
-
- {group.name}
-
- ))}
- >
- )}
-
-
-
-
- {groupOption === "create-new" && (
- <>
-
-
- Group Name
-
-
- *
-
-
-
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 && (
-
-
-
-
-
- Project
-
-
- *
-
-
-
- In clusters managed by Rancher, resources are organized into
- projects for administration.
-
-
-
- setState((prev) => ({ ...prev, projectId }))
- }
- >
-
-
-
-
-
- {user?.projects?.map((project) => (
-
-
- {project.name}{" "}
-
- {project.description}
-
-
-
- ))}
-
-
-
-
+
)}
Source Options
@@ -628,9 +73,13 @@ const AppConfigFormFields = ({
- setState((prev) => ({ ...prev, source: source as "git" | "image" }))
+ setState((prev) => ({
+ ...prev,
+ source: source as "git" | "image" | "helm",
+ appType: source === "helm" ? "helm" : "workload",
+ }))
}
name="source"
>
@@ -641,80 +90,42 @@ const AppConfigFormFields = ({
Git Repository
OCI Image
+ {/* Helm Chart */}
- {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.
-
-
-
-
- Install GitHub App
-
-
-
- ) : (
- <>
-
- {selectedOrg?.name} has not been connected to
- GitHub. Ask the owner of your organization to install the AnvilOps
- GitHub App.
-
- >
- )
- ) : source === "image" ? (
- <>
-
-
-
- Image tag
-
-
- *
-
-
-
{
- 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
+
+
+
+
+ Group
+
+
+ *
+
+
+
+ Applications can be created as standalone apps, or as part of a group
+ of related microservices.
+
+
{
+ if (option === "create-new") {
+ setState({
+ ...state,
+ groupOption: { type: "create-new", name: "" },
+ });
+ } else if (option === "standalone") {
+ setState({ ...state, groupOption: { type: "standalone" } });
+ } else {
+ setState({
+ ...state,
+ groupOption: { type: "add-to", id: parseInt(option) },
+ });
+ }
+ }}
+ value={
+ groupOption?.type === "add-to"
+ ? groupOption.id.toString()
+ : groupOption?.type
+ }
+ name="group"
+ >
+
+
+
+
+
+ Standalone app
+ Create new group
+ {multiGroups && multiGroups.length > 0 && (
+ <>
+
+ Add to existing group
+
+ {multiGroups?.map((group) => (
+
+ {group.name}
+
+ ))}
+ >
+ )}
+
+
+
+
+ {groupOption?.type === "create-new" && (
+ <>
+
+
+ Group Name
+
+
+ *
+
+
+
+ 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 (
+
+
+
+
+
+ Project
+
+
+ *
+
+
+
+ In clusters managed by Rancher, resources are organized into projects
+ for administration.
+
+
+
+ setState((prev) => ({ ...prev, projectId }))
+ }
+ >
+
+
+
+
+
+ {user?.projects?.map((project) => (
+
+
+ {project.name}{" "}
+
+ {project.description}
+
+
+
+ ))}
+
+
+
+
+ );
+};
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 && (
+
+
+
+ Public URL
+
+ {createIngress && (
+
+ *
+
+ )}
+
+
+ {
+ if (checked) {
+ setState({
+ createIngress: !!checked,
+ });
+ } else {
+ setState({ createIngress: checked });
+ }
+ }}
+ />
+ Make my app public
+
+
+
+ {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.
+
+
*/}
+ >
+ ))}
+
+ )}
+
+
+
+ Port Number
+
+
+ *
+
+
+
{
+ setState({ port: e.currentTarget.value });
+ }}
+ />
+
+
+
+
+ CPU Cores
+
+
+ *
+
+
+
+
+ Memory
+
+
+ *
+
+
+
{
+ setState({ cpuCores: e.currentTarget.value });
+ }}
+ />
+
+ {
+ setState({ memoryInMiB: e.currentTarget.value });
+ }}
+ />
+ MiB
+
+
+
+
+
+
+ Environment Variables
+
+
+
+ {
+ setState((prev) => ({ ...prev, env: updater(prev.env) }));
+ }}
+ fixedSensitiveNames={fixedSensitiveNames}
+ disabled={disabled ?? false}
+ />
+
+
+ {appConfig.storageEnabled && (
+
+
+
+ Volume Mounts
+
+
+
+ {!!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 && (
+
+
+
+ Advanced
+
+
+
+
+
+
+ Keep Historical Logs
+
+
+ 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,
+ });
+ }}
+ />
+
+ Record application logs as they're produced
+
+
+
+
+
+
+ )}
+
+ >
+ );
+};
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.
+
+
+
+
+ Install GitHub App
+
+
+
+ );
+ } 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 (
+
+
+
+ Image tag
+
+
+ *
+
+
+
{
+ 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) => (
-
-
-
-
-
-
- Git Repository
- OCI Image
-
-
-
- )}
- />
+ 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.
-
-
-
-
- Install GitHub App
-
-
-
- ) : (
- <>
-
- {selectedOrg?.name} has not been connected to
- GitHub. Ask the owner of your organization to install the AnvilOps
- GitHub App.
-
- >
- )
- ) : state.source === "image" ? (
- <>
-
-
-
- Image tag
-
-
- *
-
-
-
- {
- setState((state) => ({ ...state, imageTag }));
- }}
- name="imageTag"
- id="imageTag"
- placeholder="nginx:latest"
- required
- />
-
-
- >
- ) : null}
- {showDeploymentOptions && (
- <>
- Deployment Options
-
-
-
- Port Number
-
-
- *
-
-
-
- {
- setState((state) => ({ ...state, port }));
- }}
- />
-
-
-
-
-
- Replicas
-
-
- *
-
-
-
- {
- setState((s) => ({ ...s, replicas }));
- }}
- />
-
-
-
-
-
- CPU Cores
-
-
- *
-
-
-
- {
- setState((state) => ({ ...state, cpuCores }));
- }}
- />
-
-
-
-
-
- Memory (MiB)
-
-
- *
-
-
-
- {
- setState((state) => ({
- ...state,
- memoryInMiB: parseInt(memoryInMiB),
- }));
- }}
- />
-
-
-
-
-
-
- Environment Variables
-
-
-
- {
- 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()
- }
- />
-
-
-
-
-
- Advanced
-
-
-
-
-
-
- Keep Historical
- Logs
-
-
- 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) => (
-
-
-
-
-
-
- Enabled
- Disabled
-
-
-
- )}
- />
-
-
-
-
-
- >
+ {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 (
+
+
+
+
+
+ {leftContent ?? selectContent}
+
+
+
+
+
+
+ {selectContent}
+
+
+ );
+};
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 && (
+
+ )}
+
+
+
+ Port Number
+
+
+ *
+
+
+
+ {
+ setWorkloadState({ port });
+ }}
+ />
+
+
+
+
+
+ Replicas
+
+
+ *
+
+
+
+ {
+ setWorkloadState({ replicas });
+ }}
+ />
+
+
+
+
+
+ CPU Cores
+
+
+ *
+
+
+
+ {
+ setWorkloadState({ cpuCores });
+ }}
+ />
+
+
+
+
+
+ Memory (MiB)
+
+
+ *
+
+
+
+ {
+ setWorkloadState({ memoryInMiB });
+ }}
+ />
+
+
+
+
+
+
+ Environment Variables
+
+
+
+
+ setWorkloadState((prev) => ({
+ ...prev,
+ env: updater(prev.env),
+ }))
+ }
+ fixedSensitiveNames={fixedSensitiveNames}
+ />
+
+
+
+
+
+ Advanced
+
+
+
+
+
+
+ Keep Historical Logs
+
+
+ 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
+
+
+
+
+
+
+ Public URL
+
+ {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.
+
+
+
+
+ Install GitHub App
+
+
+
+ );
+ } 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 (
<>
Repository
@@ -115,51 +155,42 @@ export const GitConfigDiff = ({
- {
- 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) => (
-
-
-
-
-
-
- {orgId !== undefined && !!repos
- ? Object.entries(
- Object.groupBy(repos, (repo) => repo.owner!),
- ).map(([owner, repos]) => (
-
- {owner}
- {repos?.map((repo) => (
-
- {repo.owner}/{repo.name}
-
- ))}
-
- ))
- : null}
-
-
-
- )}
- />
+ 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) => (
-
-
- {props.side === "before" ? (
- (base.branch ?? "(None)")
- ) : (
-
- )}
-
-
-
- {state.repositoryId !== undefined &&
- branches?.branches?.map((branch) => {
- return (
-
- {branch}
-
- );
- })}
-
-
-
- )}
- />
+ 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) => (
-
-
-
-
-
- Push
-
- Successful workflow run
-
-
-
- )}
- />
+ 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" && (
@@ -300,55 +310,54 @@ export const GitConfigDiff = ({
- {
- setState((prev) => ({ ...prev, eventId }));
- }}
- select={(props) => (
-
-
- 0
- ? "Select a workflow"
- : "No workflows available"
- }
- />
-
-
-
- {state.repositoryId !== undefined &&
- workflows?.workflows?.map((workflow) => {
- return (
-
- {workflow.name}
-
- );
- })}
-
-
-
- )}
- />
+ 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" ? (
Dockerfile Path
@@ -463,11 +469,9 @@ export const GitConfigDiff = ({
name="dockerfilePath"
id="dockerfilePath"
placeholder="Dockerfile"
- left={base.dockerfilePath}
- right={state.dockerfilePath}
- setRight={(dockerfilePath) => {
- setState((state) => ({ ...state, dockerfilePath }));
- }}
+ left={baseGitState?.dockerfilePath}
+ right={gitState.dockerfilePath}
+ setRight={(dockerfilePath) => setGitState({ dockerfilePath })}
autoComplete="off"
required
/>
diff --git a/frontend/src/components/diff/workload/image/ImageConfigDiff.tsx b/frontend/src/components/diff/workload/image/ImageConfigDiff.tsx
new file mode 100644
index 00000000..afd16adf
--- /dev/null
+++ b/frontend/src/components/diff/workload/image/ImageConfigDiff.tsx
@@ -0,0 +1,48 @@
+import type { CommonFormFields, ImageFormFields } from "@/lib/form.types";
+import { Tag } from "lucide-react";
+import { Label } from "@/components/ui/label";
+import { DiffInput } from "@/components/diff/DiffInput";
+
+export const ImageConfigDiff = ({
+ base,
+ imageState,
+ setImageState,
+ disabled,
+}: {
+ base: CommonFormFields;
+ imageState: ImageFormFields;
+ setImageState: (state: Partial) => void;
+ disabled: boolean;
+}) => {
+ const baseImageState = base.source === "image" ? base.workload.image : null;
+
+ return (
+
+
+
+ Image tag
+
+
+ *
+
+
+
+ {
+ setImageState({ imageTag });
+ }}
+ name="imageTag"
+ id="imageTag"
+ placeholder="nginx:latest"
+ required
+ />
+
+
+ );
+};
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 82d399af..d800a87e 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -4,8 +4,7 @@ import {
QueryClient,
type DefaultError,
type Mutation,
- type MutationFunctionContext,
- type QueryOptions,
+ type Query,
} from "@tanstack/react-query";
import createFetchClient from "openapi-fetch";
import createClient from "openapi-react-query";
@@ -23,16 +22,9 @@ export const api = createClient(fetchClient);
*/
const ALLOWED_UNAUTHENTICATED = ["/", "/error"];
-const onError = (
+const onQueryError = (
error: DefaultError,
- ...args:
- | [QueryOptions]
- | [
- unknown,
- unknown,
- Mutation,
- MutationFunctionContext,
- ]
+ query: Query,
) => {
if (
("code" in error && error?.code === 401) ||
@@ -43,7 +35,7 @@ const onError = (
return;
}
}
- if (args.length === 1 && args[0].queryHash === '["get","/user/me",{}]') {
+ if (query.queryHash === '["get","/user/me",{}]') {
// Don't show the error toast for the initial /user/me request
return;
}
@@ -52,7 +44,27 @@ const onError = (
);
};
+const onMutationError = (
+ error: DefaultError,
+ _variables: unknown,
+ _context: unknown,
+ _mutation: Mutation,
+) => {
+ if (
+ ("code" in error && error?.code === 401) ||
+ error?.message === "Unauthorized"
+ ) {
+ if (!ALLOWED_UNAUTHENTICATED.includes(window.location.pathname)) {
+ window.location.href = "/api/login";
+ return;
+ }
+ }
+ toast.error(
+ `Something went wrong: ${error.message ?? JSON.stringify(error)}`,
+ );
+};
+
export const queryClient = new QueryClient({
- queryCache: new QueryCache({ onError }),
- mutationCache: new MutationCache({ onError }),
+ queryCache: new QueryCache({ onError: onQueryError }),
+ mutationCache: new MutationCache({ onError: onMutationError }),
});
diff --git a/frontend/src/lib/form.ts b/frontend/src/lib/form.ts
new file mode 100644
index 00000000..65e65461
--- /dev/null
+++ b/frontend/src/lib/form.ts
@@ -0,0 +1,320 @@
+import type { components } from "@/generated/openapi";
+import type {
+ CommonFormFields,
+ GitFormFields,
+ GroupFormFields,
+ HelmFormFields,
+ ImageFormFields,
+ WorkloadFormFields,
+ WorkloadUpdate,
+} from "./form.types";
+import type { App } from "@/pages/app/AppView";
+
+export const MAX_SUBDOMAIN_LENGTH = 54;
+
+const createDefaultGitState = (
+ git?: Partial,
+): GitFormFields => ({
+ builder: "railpack",
+ dockerfilePath: "./Dockerfile",
+ rootDir: "./",
+ event: "push",
+ repoName: "",
+ ...(git ?? {}),
+});
+
+const getDefaultImageState = () => ({ imageTag: "" });
+const createDefaultWorkloadState = (
+ git?: Partial,
+): WorkloadFormFields => ({
+ port: "",
+ replicas: "1",
+ env: [],
+ mounts: [],
+ subdomain: "",
+ createIngress: true,
+ collectLogs: true,
+ cpuCores: "1",
+ memoryInMiB: "1024",
+ git: createDefaultGitState(git),
+ image: getDefaultImageState(),
+});
+
+export const createDefaultCommonFormFields = (
+ git?: Partial,
+): CommonFormFields => ({
+ appType: "workload",
+ source: "git",
+ projectId: null,
+ workload: createDefaultWorkloadState(git),
+ helm: {
+ urlType: "oci",
+ },
+});
+
+export const createDeploymentConfig = (
+ formFields: Required,
+): components["schemas"]["DeploymentConfig"] => {
+ console.log("formFields", formFields);
+ if (formFields.appType === "workload") {
+ const workloadConfig = formFields.workload as Required;
+ const cpu = Math.round(parseFloat(workloadConfig.cpuCores) * 1000) + "m";
+ const memory = workloadConfig.memoryInMiB + "Mi";
+
+ const workloadOptions: components["schemas"]["KnownDeploymentOptions"] = {
+ appType: "workload",
+ port: parseInt(workloadConfig.port),
+ replicas: parseInt(workloadConfig.replicas),
+ env: workloadConfig.env.filter((env) => env.name.length > 0),
+ mounts: workloadConfig.mounts.filter((mount) => mount.path.length > 0),
+ createIngress: workloadConfig.createIngress,
+ subdomain: workloadConfig.createIngress ? workloadConfig.subdomain : null,
+ collectLogs: workloadConfig.collectLogs,
+ limits: { cpu, memory },
+ requests: { cpu, memory },
+ };
+ switch (formFields.source) {
+ case "git":
+ return {
+ ...workloadOptions,
+ ...createGitDeploymentOptions(
+ workloadConfig.git as Required,
+ ),
+ };
+
+ case "image":
+ return {
+ ...workloadOptions,
+ ...createImageDeploymentOptions(
+ workloadConfig.image as Required,
+ ),
+ };
+ }
+ } else {
+ const helmConfig = formFields.helm as Required;
+ return {
+ ...helmConfig,
+ source: "helm",
+ appType: "helm",
+ };
+ }
+
+ throw new Error("Invalid app type");
+};
+
+const generateNamespace = (appState: Required): string => {
+ if (appState.appType === "workload" && appState.workload.createIngress) {
+ return appState.workload.subdomain as string;
+ }
+ return (
+ getAppName(appState).replaceAll(/[^a-zA-Z0-9-_]/g, "_") +
+ "-" +
+ Math.floor(Math.random() * 10_000)
+ );
+};
+
+export const createNewAppWithoutGroup = (
+ appState: Required,
+): components["schemas"]["NewAppWithoutGroupInfo"] => {
+ return {
+ name: getAppName(appState),
+ namespace: generateNamespace(appState),
+ config: createDeploymentConfig(appState),
+ };
+};
+
+const createGitDeploymentOptions = (
+ gitFields: Required,
+): components["schemas"]["GitDeploymentOptions"] => {
+ return {
+ source: "git",
+ repositoryId: gitFields.repositoryId!,
+ branch: gitFields.branch,
+ rootDir: gitFields.rootDir,
+ ...(gitFields.event === "push"
+ ? {
+ event: "push",
+ eventId: null,
+ }
+ : {
+ event: "workflow_run",
+ eventId: gitFields.eventId,
+ }),
+ ...(gitFields.builder === "dockerfile"
+ ? {
+ builder: "dockerfile",
+ dockerfilePath: gitFields.dockerfilePath,
+ }
+ : {
+ builder: "railpack",
+ }),
+ };
+};
+
+const createImageDeploymentOptions = (
+ imageFields: Required,
+): components["schemas"]["ImageDeploymentOptions"] => {
+ return {
+ source: "image",
+ imageTag: imageFields.imageTag,
+ };
+};
+
+const getCleanedAppName = (name: string) =>
+ name.length > 0
+ ? name
+ .toLowerCase()
+ .substring(0, 60)
+ .replace(/[^a-z0-9-]/g, "")
+ : "New App";
+
+export const getAppName = ({
+ source,
+ workload,
+ helm,
+}: Pick): string => {
+ switch (source) {
+ case "git": {
+ const gitConfig = workload.git as Required;
+ return getCleanedAppName(gitConfig.repoName);
+ }
+ case "image": {
+ const imageConfig = workload.image as Required;
+ const image = imageConfig.imageTag.split("/");
+ const imageName = image[image.length - 1].split(":")[0];
+ return getCleanedAppName(imageName);
+ }
+ case "helm":
+ return getCleanedAppName(helm.url!);
+ default:
+ throw new Error("Invalid source");
+ }
+};
+
+export const getGroupStateFromApp = (app: App): GroupFormFields => {
+ return {
+ orgId: app.orgId,
+ groupOption: {
+ type: app.appGroup.standalone ? "standalone" : "add-to",
+ id: app.appGroup.id,
+ },
+ };
+};
+
+export const getFormStateFromApp = (
+ app: Pick,
+): CommonFormFields => {
+ return {
+ displayName: app.displayName,
+ projectId: app.projectId ?? null,
+ appType: app.config.appType,
+ source: app.config.source,
+ workload:
+ app.config.appType === "workload"
+ ? getWorkloadFormFieldsFromAppConfig(app.config)
+ : createDefaultWorkloadState(),
+ helm:
+ app.config.appType === "helm"
+ ? getHelmFormFieldsFromAppConfig(app.config)
+ : {
+ urlType: "oci",
+ },
+ };
+};
+
+const getCpuCores = (cpu: string) => {
+ return (parseFloat(cpu.replace("m", "")) / 1000).toString();
+};
+
+const getWorkloadFormFieldsFromAppConfig = (
+ config: components["schemas"]["DeploymentConfig"] & { appType: "workload" },
+): WorkloadFormFields => {
+ return {
+ port: config.port.toString(),
+ replicas: config.replicas.toString(),
+ env: config.env,
+ mounts: config.mounts,
+ subdomain: config.subdomain ?? "",
+ createIngress: config.createIngress,
+ collectLogs: config.collectLogs,
+ cpuCores: config.requests?.cpu ? getCpuCores(config.requests?.cpu) : "1",
+ memoryInMiB: config.requests?.memory?.replace("Mi", "") ?? "1024",
+ git:
+ config.source === "git"
+ ? {
+ builder: config.builder,
+ dockerfilePath: config.dockerfilePath ?? "./Dockerfile",
+ rootDir: config.rootDir,
+ event: config.event,
+ eventId: config.eventId,
+ repositoryId: config.repositoryId,
+ branch: config.branch,
+ repoName: "",
+ }
+ : createDefaultGitState(),
+ image:
+ config.source === "image"
+ ? {
+ imageTag: config.imageTag,
+ }
+ : getDefaultImageState(),
+ };
+};
+
+const getHelmFormFieldsFromAppConfig = (
+ config: components["schemas"]["DeploymentConfig"] & { appType: "helm" },
+): HelmFormFields => {
+ return {
+ url: config.url,
+ urlType: config.urlType,
+ version: config.version,
+ values: config.values,
+ };
+};
+
+export const makeImageSetter = (
+ setState: (updater: (prev: CommonFormFields) => CommonFormFields) => void,
+) => {
+ return (update: Partial) => {
+ setState((prev) => ({
+ ...prev,
+ workload: {
+ ...prev.workload,
+ image: { ...prev.workload.image, ...update },
+ },
+ }));
+ };
+};
+
+export const makeGitSetter = (
+ setState: (updater: (prev: CommonFormFields) => CommonFormFields) => void,
+) => {
+ return (update: Partial) => {
+ setState((prev) => ({
+ ...prev,
+ workload: { ...prev.workload, git: { ...prev.workload.git, ...update } },
+ }));
+ };
+};
+
+export const makeHelmSetter = (
+ setState: (updater: (prev: CommonFormFields) => CommonFormFields) => void,
+) => {
+ return (update: Partial) => {
+ setState((prev) => ({ ...prev, helm: { ...prev.helm, ...update } }));
+ };
+};
+
+export const makeFunctionalWorkloadSetter = (
+ setState: (updater: (prev: CommonFormFields) => CommonFormFields) => void,
+) => {
+ return (update: WorkloadUpdate) => {
+ setState((s) => ({
+ ...s,
+ workload: {
+ ...s.workload,
+ ...(typeof update === "function" ? update(s.workload) : update),
+ },
+ }));
+ };
+};
diff --git a/frontend/src/lib/form.types.ts b/frontend/src/lib/form.types.ts
new file mode 100644
index 00000000..796e0d16
--- /dev/null
+++ b/frontend/src/lib/form.types.ts
@@ -0,0 +1,55 @@
+import type { components } from "@/generated/openapi";
+
+export type GroupFormFields = {
+ orgId?: number;
+ groupOption: components["schemas"]["NewApp"]["appGroup"];
+};
+
+export type GitFormFields = {
+ dockerfilePath: string;
+ rootDir: string;
+ repositoryId?: number;
+ repoName: string;
+ event: "push" | "workflow_run";
+ eventId?: number | null;
+ branch?: string;
+ builder: "dockerfile" | "railpack";
+};
+
+export type ImageFormFields = {
+ imageTag: string;
+};
+
+export type WorkloadFormFields = {
+ port?: string;
+ replicas?: string;
+ env: components["schemas"]["Envs"];
+ mounts: components["schemas"]["Mount"][];
+ subdomain?: string | null;
+ createIngress: boolean;
+ collectLogs: boolean;
+ cpuCores: string;
+ memoryInMiB: string;
+
+ git: GitFormFields;
+ image: ImageFormFields;
+};
+
+export type WorkloadUpdate =
+ | Partial>
+ | ((
+ prev: WorkloadFormFields,
+ ) => Partial>);
+
+export type HelmFormFields = Partial<
+ Omit
+>;
+
+export type CommonFormFields = {
+ displayName?: string;
+ projectId: string | null;
+ appType: "workload" | "helm";
+ source: "git" | "image" | "helm";
+ workload: WorkloadFormFields;
+ helm: HelmFormFields;
+};
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts
index a6bd0205..745a619e 100644
--- a/frontend/src/lib/utils.ts
+++ b/frontend/src/lib/utils.ts
@@ -1,3 +1,4 @@
+import type { components } from "@/generated/openapi";
import { clsx, type ClassValue } from "clsx";
import React from "react";
import { twMerge } from "tailwind-merge";
@@ -19,3 +20,13 @@ export function useDebouncedValue(value: T, delay = 300) {
}, [value, delay]);
return debounceValue;
}
+
+/**
+ * Type guard to check if a DeploymentConfig is a WorkloadConfigOptions
+ * (i.e., not a HelmConfigOptions)
+ */
+export function isWorkloadConfig(
+ config: components["schemas"]["DeploymentConfig"],
+): config is components["schemas"]["WorkloadConfigOptions"] {
+ return config.source !== "helm";
+}
diff --git a/frontend/src/pages/app/AppView.tsx b/frontend/src/pages/app/AppView.tsx
index 52dda78a..4c72d990 100644
--- a/frontend/src/pages/app/AppView.tsx
+++ b/frontend/src/pages/app/AppView.tsx
@@ -3,7 +3,7 @@ import { DeploymentStatus } from "@/components/DeploymentStatus";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { components, paths } from "@/generated/openapi";
import { api } from "@/lib/api";
-import { cn } from "@/lib/utils";
+import { cn, isWorkloadConfig } from "@/lib/utils";
import { useQueryClient } from "@tanstack/react-query";
import { useParams, useSearchParams } from "react-router-dom";
import { ConfigTab } from "./ConfigTab";
@@ -95,10 +95,12 @@ export default function AppView() {
{settings.storageEnabled && !!app.activeDeployment && (
<>
-
- Logs
-
- {app.config.mounts.length > 0 && (
+ {isWorkloadConfig(app.config) && (
+
+ Logs
+
+ )}
+ {isWorkloadConfig(app.config) && app.config.mounts.length > 0 && (
Files
diff --git a/frontend/src/pages/app/ConfigTab.tsx b/frontend/src/pages/app/ConfigTab.tsx
index b3d34382..88b93c85 100644
--- a/frontend/src/pages/app/ConfigTab.tsx
+++ b/frontend/src/pages/app/ConfigTab.tsx
@@ -1,19 +1,25 @@
-import HelpTooltip from "@/components/HelpTooltip";
-import { Button } from "@/components/ui/button";
-import { Label } from "@/components/ui/label";
-import { UserContext } from "@/components/UserProvider";
-import type { components } from "@/generated/openapi";
-import { api } from "@/lib/api";
-import AppConfigFormFields, {
- type AppInfoFormData,
-} from "@/components/config/AppConfigFormFields";
+import type { CommonFormFields, GroupFormFields } from "@/lib/form.types";
+import { isWorkloadConfig } from "@/lib/utils";
import type { RefetchOptions } from "@tanstack/react-query";
-import { Loader, Save, Scale3D, TextCursorInput } from "lucide-react";
import { useContext, useState, type Dispatch } from "react";
-import { toast } from "sonner";
-import { Input } from "../../components/ui/input";
-import { FormContext } from "../create-app/CreateAppView";
import type { App } from "./AppView";
+import {
+ createDeploymentConfig,
+ getFormStateFromApp,
+ getGroupStateFromApp,
+} from "@/lib/form";
+import { api } from "@/lib/api";
+import { UserContext } from "@/components/UserProvider";
+import { Label } from "@/components/ui/label";
+import { Loader, Save, Scale3D, TextCursorInput } from "lucide-react";
+import { Input } from "@/components/ui/input";
+import HelpTooltip from "@/components/HelpTooltip";
+import { FormContext } from "../create-app/CreateAppView";
+import { Button } from "@/components/ui/button";
+import { AppConfigFormFields } from "@/components/config/AppConfigFormFields";
+import { GroupConfigFields } from "@/components/config/GroupConfigFields";
+import { ProjectConfig } from "@/components/config/ProjectConfig";
+import { useAppConfig } from "@/components/AppConfigProvider";
export const ConfigTab = ({
app,
@@ -26,40 +32,21 @@ export const ConfigTab = ({
setTab: Dispatch;
refetch: (options: RefetchOptions | undefined) => Promise;
}) => {
- const [formState, setFormState] = useState({
- port: app.config.port.toString(),
- env: app.config.env,
- mounts: app.config.mounts.map((mount) => ({
- // (remove volumeClaimName because it's not stored in the app's deployment config)
- amountInMiB: mount.amountInMiB,
- path: mount.path,
- })),
- collectLogs: app.config.collectLogs,
- subdomain: app.config.subdomain ?? "",
- createIngress: app.config.createIngress,
- orgId: app.orgId,
- groupOption: app.appGroup.standalone ? "standalone" : "add-to",
- groupId: app.appGroup.id,
- projectId: app.projectId,
- source: app.config.source,
- cpuCores: parseInt(app.config.limits?.cpu ?? "1000m") / 1000, // parseInt ignores the "m" which means millicore - we need to divide by 1000 to get the number of full cores
- memoryInMiB: parseInt(app.config.limits?.memory ?? "1024"), // parseInt ignores the "Mi" which means mebibyte
- ...(app.config.source === "git"
- ? {
- repositoryId: app.config.repositoryId,
- branch: app.config.branch,
- event: app.config.event,
- eventId: app.config.eventId?.toString() ?? undefined,
- rootDir: app.config.rootDir ?? undefined,
- dockerfilePath: app.config.dockerfilePath ?? undefined,
- builder: app.config.builder,
- }
- : {
- dockerfilePath: "Dockerfile",
- builder: "railpack",
- }),
- imageTag: app.config.imageTag,
- });
+ if (!isWorkloadConfig(app.config)) {
+ return (
+
+
Configuration editing is not available for Helm-based apps.
+
+ );
+ }
+
+ const appConfig = useAppConfig();
+ const [state, setState] = useState(
+ getFormStateFromApp(app),
+ );
+ const [groupState, setGroupState] = useState(
+ getGroupStateFromApp(app),
+ );
const { mutateAsync: updateApp, isPending: updatePending } = api.useMutation(
"put",
@@ -69,90 +56,29 @@ export const ConfigTab = ({
const { user } = useContext(UserContext);
const enableSaveButton =
- formState.source !== "git" ||
+ state.source !== "git" ||
user?.orgs?.find((it) => it.id === app.orgId)?.githubConnected;
return (
-
+
+ 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 (
setOrgId(parseInt(orgId!))}
+ onValueChange={(orgId) =>
+ setGroupState((prev) => ({ ...prev, orgId: parseInt(orgId) }))
+ }
value={orgId?.toString()}
name="org"
>
@@ -206,10 +146,16 @@ export default function CreateAppGroupView() {
placeholder="Group name"
name="groupName"
value={groupName}
- onChange={(e) => setGroupName(e.currentTarget.value)}
+ onChange={(e) => {
+ const value = e.currentTarget.value;
+ setGroupState((prev) => ({
+ ...prev,
+ groupOption: { type: "create-new", name: value },
+ }));
+ }}
autoComplete="off"
/>
- {groupName && !isGroupNameValid && (
+ {showGroupNameError && (
@@ -258,10 +204,9 @@ export default function CreateAppGroupView() {
variant="ghost"
type="button"
onClick={() => {
- setAppStates((appStates) => [
- ...appStates,
- { ...defaultState, orgId },
- ]);
+ setAppStates((appStates) =>
+ appStates.concat(createDefaultCommonFormFields()),
+ );
}}
disabled={orgId === undefined}
>
@@ -278,23 +223,15 @@ export default function CreateAppGroupView() {
className="space-y-8"
>
{
- if (typeof stateAction === "function") {
- setAppStates((appStates) =>
- appStates.map((app, i) =>
- i === idx ? stateAction(app) : app,
- ),
- );
- } else {
- setAppStates((appStates) =>
- appStates.map((app, i) =>
- i === idx ? stateAction : app,
- ),
- );
- }
+ setState={(updater) => {
+ setAppStates((appStates) =>
+ appStates.map((appState, i) =>
+ i === idx ? updater(appState) : appState,
+ ),
+ );
}}
- hideGroupSelect
/>
))}
@@ -318,18 +255,3 @@ export default function CreateAppGroupView() {
);
}
-
-const getAppName = (state: AppInfoFormData) => {
- let appName = "Untitled";
- if (state.source === "git") {
- if (state.repoName) {
- appName = getCleanedAppName(state.repoName);
- }
- } else if (state.source === "image") {
- if (state.imageTag) {
- const tag = state.imageTag!.toString().split("/");
- appName = getCleanedAppName(tag[tag.length - 1].split(":")[0]);
- }
- }
- return appName;
-};
diff --git a/frontend/src/pages/create-app/CreateAppView.tsx b/frontend/src/pages/create-app/CreateAppView.tsx
index 9e7e0370..818accc2 100644
--- a/frontend/src/pages/create-app/CreateAppView.tsx
+++ b/frontend/src/pages/create-app/CreateAppView.tsx
@@ -1,4 +1,4 @@
-import { useAppConfig } from "@/components/AppConfigProvider";
+import { GroupConfigFields } from "@/components/config/GroupConfigFields";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
@@ -10,14 +10,16 @@ import {
SelectValue,
} from "@/components/ui/select";
import { UserContext } from "@/components/UserProvider";
-import type { components } from "@/generated/openapi";
import { api } from "@/lib/api";
+import {
+ createDefaultCommonFormFields,
+ createNewAppWithoutGroup,
+} from "@/lib/form";
+import type { CommonFormFields, GroupFormFields } from "@/lib/form.types";
import { Check, Globe, Loader, Rocket, X } from "lucide-react";
import { createContext, useContext, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
-import AppConfigFormFields, {
- type AppInfoFormData,
-} from "@/components/config/AppConfigFormFields";
+import { AppConfigFormFields } from "@/components/config/AppConfigFormFields";
export default function CreateAppView() {
const { user } = useContext(UserContext);
@@ -29,36 +31,29 @@ export default function CreateAppView() {
const [search] = useSearchParams();
- const [formState, setFormState] = useState({
- collectLogs: true,
- groupOption: "standalone",
- env: [],
- mounts: [],
+ const [groupState, setGroupState] = useState({
orgId: search.has("org")
- ? parseInt(search.get("org")!.toString())
+ ? parseInt(search.get("org")!)
: user?.orgs?.[0]?.id,
- repositoryId: search.has("repo")
- ? parseInt(search.get("repo")!.toString())
- : undefined,
- source: "git",
- event: "push",
- builder: "railpack",
- dockerfilePath: "Dockerfile",
- rootDir: "./",
- subdomain: "",
- createIngress: true,
- cpuCores: 1,
- memoryInMiB: 1024,
+ groupOption: { type: "standalone" },
});
+ const [appState, setAppState] = useState(
+ createDefaultCommonFormFields({
+ repositoryId: search.has("repo")
+ ? parseInt(search.get("repo")!.toString())
+ : undefined,
+ }),
+ );
+
const navigate = useNavigate();
const shouldShowDeploy =
- formState.orgId === undefined ||
- formState.source !== "git" ||
- user?.orgs.some((org) => org.id === formState.orgId && org.githubConnected);
-
- const config = useAppConfig();
+ groupState.orgId === undefined ||
+ appState.source !== "git" ||
+ user?.orgs.some(
+ (org) => org.id === groupState.orgId && org.githubConnected,
+ );
return (
@@ -66,79 +61,17 @@ export default function CreateAppView() {
className="flex flex-col gap-6 w-full my-10"
onSubmit={async (e) => {
e.preventDefault();
- const formData = new FormData(e.currentTarget);
- let appName = "untitled";
- if (formState.source === "git") {
- // Make RFC1123 compliant
- appName = getCleanedAppName(formState.repoName!);
- } else if (formState.source === "image") {
- const tag = formState.imageTag!.split("/");
- appName = getCleanedAppName(tag[tag.length - 1].split(":")[0]);
- }
- try {
- let appGroup: components["schemas"]["NewApp"]["appGroup"];
- switch (formState.groupOption) {
- case "standalone":
- appGroup = { type: "standalone" };
- break;
- case "create-new":
- appGroup = {
- type: "create-new",
- name: formData.get("groupName")!.toString(),
- };
- break;
- default:
- appGroup = { type: "add-to", id: formState.groupId! };
- break;
- }
-
- let subdomain = formState.subdomain;
-
- if (
- (!formState.subdomain && config.appDomain === undefined) ||
- !formState.createIngress
- ) {
- // Generate a subdomain value to be used as the namespace name
- // This should only happen if the APP_DOMAIN environment variable is missing and no publicly-available domain is known to expose users' apps on subdomains. In that case, we hide the subdomain field because it's irrelevant.
- subdomain =
- appName.replaceAll(/[^a-zA-Z0-9-_]/g, "_") +
- "-" +
- Math.floor(Math.random() * 10_000);
- }
+ const finalGroupState = groupState as Required;
+ const finalAppState = appState as Required;
+ try {
const result = await createApp({
body: {
- orgId: formState.orgId!,
- projectId: formState.projectId,
- name: appName,
- createIngress: formState.createIngress,
- subdomain,
- port: parseInt(formState.port!),
- env: formState.env.filter((ev) => ev.name.length > 0),
- mounts: formState.mounts.filter((m) => m.path.length > 0),
- cpuCores: formState.cpuCores,
- memoryInMiB: formState.memoryInMiB,
- appGroup,
- ...(formState.source === "git"
- ? {
- source: "git",
- repositoryId: formState.repositoryId!,
- dockerfilePath: formState.dockerfilePath!,
- rootDir: formState.rootDir!,
- branch: formState.branch!,
- builder: formState.builder!,
- event: formState.event!,
- eventId: formState.eventId
- ? parseInt(formState.eventId)
- : null,
- }
- : {
- source: "image",
- imageTag: formState.imageTag!,
- }),
+ orgId: finalGroupState.orgId,
+ appGroup: finalGroupState.groupOption,
+ ...createNewAppWithoutGroup(finalAppState),
},
});
-
navigate(`/app/${result.id}`);
} catch (err) {}
}}
@@ -160,9 +93,9 @@ export default function CreateAppView() {
- 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 ? (
@@ -201,12 +139,6 @@ export default function CreateAppView() {
);
}
-export const getCleanedAppName = (name: string) =>
- name
- .toLowerCase()
- .substring(0, 60)
- .replace(/[^a-z0-9-]/g, "");
-
export const GitHubIcon = ({ className }: { className?: string }) => (
Date: Mon, 5 Jan 2026 19:07:58 -0700
Subject: [PATCH 17/38] Update trigger and image repository
---
.github/workflows/build-publish-staging-anvilops.yml | 9 +--------
1 file changed, 1 insertion(+), 8 deletions(-)
diff --git a/.github/workflows/build-publish-staging-anvilops.yml b/.github/workflows/build-publish-staging-anvilops.yml
index 36f626c7..8878149a 100644
--- a/.github/workflows/build-publish-staging-anvilops.yml
+++ b/.github/workflows/build-publish-staging-anvilops.yml
@@ -1,13 +1,6 @@
name: Build and Publish AnvilOps Docker image
on:
- push:
- branches: [dev]
- paths-ignore:
- - "infra/**"
- - "charts/**"
- - "builders/**"
- - "docs/**" # The docs are hosted on AnvilOps!
workflow_dispatch:
jobs:
@@ -24,7 +17,7 @@ jobs:
run: docker login -u '${{ secrets.DOCKER_USERNAME }}' -p '${{ secrets.DOCKER_PASSWORD }}' registry.anvil.rcac.purdue.edu
- name: Build and push AnvilOps Docker image
- run: docker build --push -t registry.anvil.rcac.purdue.edu/anvilops-dev/anvilops:${{ github.ref_name }}-${{ github.run_number }}-${{ github.sha }}${{ github.event_name == 'push' && ' -t registry.anvil.rcac.purdue.edu/anvilops-dev/anvilops:latest' || '' }} --cache-from=type=registry,ref=registry.anvil.rcac.purdue.edu/anvilops-dev/anvilops:latest --cache-to=type=inline .
+ run: docker build --push -t registry.anvil.rcac.purdue.edu/anvilops-staging/anvilops:${{ github.run_number }}-${{ github.sha }}${{ github.event_name == 'push' && ' -t registry.anvil.rcac.purdue.edu/anvilops-staging/anvilops:latest' || '' }} --cache-from=type=registry,ref=registry.anvil.rcac.purdue.edu/anvilops-staging/anvilops:latest --cache-to=type=inline .
- name: Log out of container registry
if: always()
From 6c88859d2446d2865995e9bb13e97608cc94f168 Mon Sep 17 00:00:00 2001
From: FluxCapacitor2 <31071265+FluxCapacitor2@users.noreply.github.com>
Date: Tue, 6 Jan 2026 00:23:34 -0500
Subject: [PATCH 18/38] Let Docker populate the TARGETARCH argument
automatically
Tested on an x86_64 system (TARGETARCH=amd64). Works with `docker build` and from Tilt. Should work on 64-bit ARM systems; won't work on 32-bit ARM since Tini's binary naming convention doesn't line up.
Source: https://github.com/BretFisher/multi-platform-docker-build
---
backend/prisma/Dockerfile | 2 +-
tilt/Tiltfile | 8 +-------
2 files changed, 2 insertions(+), 8 deletions(-)
diff --git a/backend/prisma/Dockerfile b/backend/prisma/Dockerfile
index 69c2230b..6aeb38aa 100644
--- a/backend/prisma/Dockerfile
+++ b/backend/prisma/Dockerfile
@@ -28,7 +28,7 @@ USER 65532
# https://github.com/krallin/tini
ENV TINI_VERSION=v0.19.0
-ARG TARGETARCH=amd64
+ARG TARGETARCH
ADD --chown=65532:65532 --chmod=500 https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-${TARGETARCH} /tini
ENTRYPOINT ["/tini", "--", "/nodejs/bin/node", "/app/node_modules/.bin/prisma"]
diff --git a/tilt/Tiltfile b/tilt/Tiltfile
index aa4251cb..e2798775 100644
--- a/tilt/Tiltfile
+++ b/tilt/Tiltfile
@@ -5,12 +5,6 @@ load('ext://deployment', 'deployment_create')
load('ext://helm_remote', 'helm_remote')
dotenv(fn='../backend/.env')
-arch = str(local('uname -m')).strip()
-if arch == "arm64" or arch == "aarch64":
- platform = "arm64"
-else:
- platform = "amd64"
-
# Should the frontend and backend be deployed separately?
# If enabled, changes will apply much faster at the expense of a development environment that looks less like production.
separate_frontend_and_backend = True
@@ -43,7 +37,7 @@ else:
"anvilops/anvilops", "../",
only=["frontend", "backend", "swagger-ui", "templates", "openapi"]
)
-docker_build("anvilops/migrate-db", "../backend", dockerfile="../backend/prisma/Dockerfile", only=["package.json", "package-lock.json", "prisma", "prisma.config.ts"], build_args={'TARGETARCH': platform})
+docker_build("anvilops/migrate-db", "../backend", dockerfile="../backend/prisma/Dockerfile", only=["package.json", "package-lock.json", "prisma", "prisma.config.ts"])
docker_build("anvilops/file-browser", "../filebrowser", match_in_env_vars=True)
docker_build("anvilops/dockerfile-builder", "../builders/dockerfile", match_in_env_vars=True)
docker_build("anvilops/railpack-builder", "../builders/railpack", match_in_env_vars=True)
From adc6f4fa96fa6b4b285da1d4e2b07d5973237789 Mon Sep 17 00:00:00 2001
From: FluxCapacitor2 <31071265+FluxCapacitor2@users.noreply.github.com>
Date: Tue, 6 Jan 2026 00:36:01 -0500
Subject: [PATCH 19/38] Add generic rethrow in createApp when calling
db.app.create after ConflictError handler
---
backend/src/service/createApp.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/backend/src/service/createApp.ts b/backend/src/service/createApp.ts
index 33012c69..ebf2c0b0 100644
--- a/backend/src/service/createApp.ts
+++ b/backend/src/service/createApp.ts
@@ -77,6 +77,7 @@ export async function createApp(appData: NewApp, userId: number) {
if (err instanceof ConflictError) {
throw new ValidationError(err.message + " is unavailable");
}
+ throw err;
}
await deploymentService.create({
From 687269142a72c68a27edc9e25200961150aa915c Mon Sep 17 00:00:00 2001
From: FluxCapacitor2 <31071265+FluxCapacitor2@users.noreply.github.com>
Date: Tue, 6 Jan 2026 01:03:55 -0500
Subject: [PATCH 20/38] Add explicit `await`
Not sure if this changes semantics but it matches the convention of the other functions in the file. Maybe try/catch behavior is different?
---
backend/src/db/repo/app.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/backend/src/db/repo/app.ts b/backend/src/db/repo/app.ts
index e18ae6fe..706a3ad7 100644
--- a/backend/src/db/repo/app.ts
+++ b/backend/src/db/repo/app.ts
@@ -77,7 +77,7 @@ export class AppRepo {
}
async getAppBySubdomain(subdomain: string): Promise {
- return this.client.app.findFirst({
+ return await this.client.app.findFirst({
where: {
config: {
appType: "workload",
From 8b45bc0faa15a5147ac63f416ab0b71c6f359840 Mon Sep 17 00:00:00 2001
From: FluxCapacitor2 <31071265+FluxCapacitor2@users.noreply.github.com>
Date: Tue, 6 Jan 2026 01:22:54 -0500
Subject: [PATCH 21/38] Reduce duplicate in preprocessing deployment configs
received from database
---
backend/src/db/repo/app.ts | 47 ++++++-------------------------
backend/src/db/repo/deployment.ts | 35 ++++++++++++++---------
2 files changed, 31 insertions(+), 51 deletions(-)
diff --git a/backend/src/db/repo/app.ts b/backend/src/db/repo/app.ts
index 706a3ad7..2c836b06 100644
--- a/backend/src/db/repo/app.ts
+++ b/backend/src/db/repo/app.ts
@@ -224,28 +224,14 @@ export class AppRepo {
include: {
config: {
include: {
- workloadConfig: {
- omit: { id: true },
- },
- helmConfig: {
- omit: { id: true },
- },
+ workloadConfig: { omit: { id: true } },
+ helmConfig: { omit: { id: true } },
},
},
},
});
- if (app.config?.appType === "workload") {
- return DeploymentRepo.preprocessWorkloadConfig(app.config.workloadConfig);
- } else if (app.config?.appType === "helm") {
- return {
- ...app.config.helmConfig,
- source: "HELM",
- appType: app.config.appType,
- };
- } else {
- return null;
- }
+ return DeploymentRepo.preprocessConfig(app.config);
}
async setConfig(appId: number, configId: number) {
@@ -272,32 +258,17 @@ export class AppRepo {
include: {
config: {
include: {
- workloadConfig: true,
- helmConfig: true,
+ workloadConfig: { omit: { id: true } },
+ helmConfig: { omit: { id: 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,
- };
- }
- });
+ return deployments.map((deployment) => ({
+ ...deployment,
+ config: DeploymentRepo.preprocessConfig(deployment.config),
+ }));
}
async setGroup(appId: number, appGroupId: number) {
diff --git a/backend/src/db/repo/deployment.ts b/backend/src/db/repo/deployment.ts
index 464f322b..f21cce8a 100644
--- a/backend/src/db/repo/deployment.ts
+++ b/backend/src/db/repo/deployment.ts
@@ -6,8 +6,9 @@ import type {
PermissionLevel,
} from "../../generated/prisma/enums.ts";
import type {
- WorkloadConfigCreateInput,
+ HelmConfigModel as PrismaHelmConfig,
WorkloadConfigModel as PrismaWorkloadConfig,
+ WorkloadConfigCreateInput,
} from "../../generated/prisma/models.ts";
import { decryptEnv, encryptEnv, generateKey } from "../crypto.ts";
import type { PrismaClientType } from "../index.ts";
@@ -191,17 +192,7 @@ export class DeploymentRepo {
},
});
- if (deployment.config.appType === "workload") {
- return DeploymentRepo.preprocessWorkloadConfig(
- deployment.config.workloadConfig,
- );
- }
-
- return {
- ...deployment.config.helmConfig,
- source: "HELM",
- appType: deployment.config.appType,
- };
+ return DeploymentRepo.preprocessConfig(deployment.config);
}
private static encryptEnv(
@@ -213,7 +204,25 @@ export class DeploymentRepo {
return copy;
}
- static preprocessWorkloadConfig(
+ static preprocessConfig(config: {
+ appType: AppType;
+ workloadConfig?: Omit;
+ helmConfig?: Omit;
+ }): WorkloadConfig | HelmConfig {
+ if (config.appType === "workload") {
+ return DeploymentRepo.preprocessWorkloadConfig(config.workloadConfig!);
+ } else if (config.appType === "helm") {
+ return {
+ ...config.helmConfig,
+ source: "HELM",
+ appType: "helm",
+ } satisfies HelmConfig;
+ } else {
+ return null;
+ }
+ }
+
+ private static preprocessWorkloadConfig(
config: Omit,
): WorkloadConfig {
if (config === null) {
From de20bbe2306e30e3478d4cdeac6137e314ca622f Mon Sep 17 00:00:00 2001
From: FluxCapacitor2 <31071265+FluxCapacitor2@users.noreply.github.com>
Date: Tue, 6 Jan 2026 01:58:33 -0500
Subject: [PATCH 22/38] Add cast methods that enforce correct config type
---
backend/src/db/models.ts | 27 +++++++++-
backend/src/db/repo/app.ts | 7 +--
backend/src/db/repo/deployment.ts | 51 ++++++++++++++++---
backend/src/lib/builder.ts | 2 +-
backend/src/service/githubWebhook.ts | 4 +-
backend/src/service/helper/deployment.ts | 6 +--
.../src/service/helper/deploymentConfig.ts | 3 +-
backend/src/service/updateApp.ts | 5 +-
8 files changed, 83 insertions(+), 22 deletions(-)
diff --git a/backend/src/db/models.ts b/backend/src/db/models.ts
index 059baef1..25f017b5 100644
--- a/backend/src/db/models.ts
+++ b/backend/src/db/models.ts
@@ -121,11 +121,36 @@ export interface WorkloadConfig {
replicas: number;
port: number;
mounts: PrismaJson.VolumeMount[];
+
+ /**
+ * Returns this instance casted to `GitConfig` if `source` == `GIT`.
+ * @throws {Error} if this workload is not deployed from a Git repo
+ */
+ asGitConfig(): GitConfig;
}
+export type DeploymentConfig = (WorkloadConfig | HelmConfig) & {
+ /**
+ * Returns this instance casted to `WorkloadConfig` if `appType` == `workload`.
+ * @throws {Error} if this deployment is not a workload
+ */
+ asWorkloadConfig(): WorkloadConfig;
+
+ /**
+ * Returns this instance casted to `HelmConfig` if `appType` == `helm`.
+ * @throws {Error} if this deployment is not a Helm chart
+ */
+ asHelmConfig(): HelmConfig;
+
+ /**
+ * A shortcut for `asWorkloadConfig().asGitConfig()`
+ */
+ asGitConfig(): GitConfig;
+};
+
export type WorkloadConfigCreate = Omit<
WorkloadConfig,
- "id" | "displayEnv" | "getEnv"
+ "id" | "displayEnv" | "getEnv" | "asGitConfig"
> & {
env: PrismaJson.EnvVar[];
};
diff --git a/backend/src/db/repo/app.ts b/backend/src/db/repo/app.ts
index 2c836b06..345f30f2 100644
--- a/backend/src/db/repo/app.ts
+++ b/backend/src/db/repo/app.ts
@@ -15,8 +15,7 @@ import type {
App,
AppCreate,
Deployment,
- HelmConfig,
- WorkloadConfig,
+ DeploymentConfig,
} from "../models.ts";
import { DeploymentRepo } from "./deployment.ts";
@@ -216,9 +215,7 @@ 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: {
diff --git a/backend/src/db/repo/deployment.ts b/backend/src/db/repo/deployment.ts
index f21cce8a..cc61d897 100644
--- a/backend/src/db/repo/deployment.ts
+++ b/backend/src/db/repo/deployment.ts
@@ -14,7 +14,9 @@ import { decryptEnv, encryptEnv, generateKey } from "../crypto.ts";
import type { PrismaClientType } from "../index.ts";
import type {
Deployment,
+ DeploymentConfig,
DeploymentWithSourceInfo,
+ GitConfig,
HelmConfig,
HelmConfigCreate,
Log,
@@ -179,7 +181,7 @@ export class DeploymentRepo {
});
}
- async getConfig(deploymentId: number): Promise {
+ async getConfig(deploymentId: number): Promise {
const deployment = await this.client.deployment.findUnique({
where: { id: deploymentId },
select: {
@@ -208,11 +210,16 @@ export class DeploymentRepo {
appType: AppType;
workloadConfig?: Omit;
helmConfig?: Omit;
- }): WorkloadConfig | HelmConfig {
+ }): DeploymentConfig {
+ if (config === null) {
+ return null;
+ }
+
+ let obj: WorkloadConfig | HelmConfig;
if (config.appType === "workload") {
- return DeploymentRepo.preprocessWorkloadConfig(config.workloadConfig!);
+ obj = DeploymentRepo.preprocessWorkloadConfig(config.workloadConfig!);
} else if (config.appType === "helm") {
- return {
+ obj = {
...config.helmConfig,
source: "HELM",
appType: "helm",
@@ -220,6 +227,29 @@ export class DeploymentRepo {
} else {
return null;
}
+
+ const wrapped = {
+ ...obj,
+ asWorkloadConfig() {
+ if (obj.appType === "workload") {
+ return obj as WorkloadConfig;
+ } else {
+ throw new Error("DeploymentConfig is not a WorkloadConfig");
+ }
+ },
+ asHelmConfig() {
+ if (obj.appType === "helm") {
+ return obj as HelmConfig;
+ } else {
+ throw new Error("DeploymentConfig is not a HelmConfig");
+ }
+ },
+ asGitConfig() {
+ return wrapped.asWorkloadConfig().asGitConfig();
+ },
+ } satisfies DeploymentConfig;
+
+ return wrapped;
}
private static preprocessWorkloadConfig(
@@ -236,7 +266,7 @@ export class DeploymentRepo {
const decrypted = decryptEnv(env, key);
- return {
+ const obj = {
...config,
appType: "workload",
getEnv() {
@@ -245,7 +275,16 @@ export class DeploymentRepo {
displayEnv: decrypted.map((envVar) =>
envVar.isSensitive ? { ...envVar, value: null } : envVar,
),
- };
+ asGitConfig() {
+ if (config.source === "GIT") {
+ return obj as GitConfig;
+ } else {
+ throw new Error("Workload is not deployed from Git");
+ }
+ },
+ } satisfies WorkloadConfig;
+
+ return obj;
}
static cloneWorkloadConfig(config: WorkloadConfig): WorkloadConfigCreate {
diff --git a/backend/src/lib/builder.ts b/backend/src/lib/builder.ts
index 998bd2d7..990d1953 100644
--- a/backend/src/lib/builder.ts
+++ b/backend/src/lib/builder.ts
@@ -354,7 +354,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)) as GitConfig;
+ const config = (await db.deployment.getConfig(deployment.id)).asGitConfig();
console.log(
`Starting build job for deployment ${deployment.id} of app ${deployment.appId}`,
diff --git a/backend/src/service/githubWebhook.ts b/backend/src/service/githubWebhook.ts
index 664fca38..23da2e24 100644
--- a/backend/src/service/githubWebhook.ts
+++ b/backend/src/service/githubWebhook.ts
@@ -145,7 +145,7 @@ async function handlePush(payload: components["schemas"]["webhook-push"]) {
for (const app of apps) {
const org = await db.org.getById(app.orgId);
- const oldConfig = (await db.app.getDeploymentConfig(app.id)) as GitConfig;
+ const oldConfig = (await db.app.getDeploymentConfig(app.id)).asGitConfig();
await deploymentService.create({
org,
app,
@@ -189,7 +189,7 @@ async function handleWorkflowRun(
if (payload.action === "requested") {
for (const app of apps) {
const org = await db.org.getById(app.orgId);
- const config = (await db.app.getDeploymentConfig(app.id)) as GitConfig;
+ const config = (await db.app.getDeploymentConfig(app.id)).asGitConfig();
await deploymentService.create({
org,
app,
diff --git a/backend/src/service/helper/deployment.ts b/backend/src/service/helper/deployment.ts
index 0bb390dc..d30ad5b7 100644
--- a/backend/src/service/helper/deployment.ts
+++ b/backend/src/service/helper/deployment.ts
@@ -90,7 +90,7 @@ export class DeploymentService {
switch (config.source) {
case "HELM": {
- this.deployHelm(org, app, deployment, config as HelmConfig);
+ this.deployHelm(org, app, deployment, config.asHelmConfig());
break;
}
@@ -99,7 +99,7 @@ export class DeploymentService {
org,
app,
deployment,
- config: config as GitConfig,
+ config: config.asGitConfig(),
opts: git,
});
break;
@@ -500,7 +500,7 @@ export class DeploymentService {
if (!octokit) {
octokit = await this.getOctokitFn(org.githubInstallationId);
}
- const config = deployment.config as GitConfig;
+ const config = deployment.config.asGitConfig();
const repo = await this.getRepoByIdFn(octokit, config.repositoryId);
try {
diff --git a/backend/src/service/helper/deploymentConfig.ts b/backend/src/service/helper/deploymentConfig.ts
index ef83fab1..bd240805 100644
--- a/backend/src/service/helper/deploymentConfig.ts
+++ b/backend/src/service/helper/deploymentConfig.ts
@@ -1,6 +1,7 @@
import { Octokit } from "octokit";
import type {
App,
+ DeploymentConfig,
GitConfigCreate,
HelmConfig,
HelmConfigCreate,
@@ -170,7 +171,7 @@ export class DeploymentConfigService {
// Produces a DeploymentConfig object to be returned from the API, as described in the OpenAPI spec.
formatDeploymentConfig(
- config: WorkloadConfig | HelmConfig,
+ config: DeploymentConfig,
): components["schemas"]["DeploymentConfig"] {
if (config.appType === "workload") {
return this.formatWorkloadConfig(config);
diff --git a/backend/src/service/updateApp.ts b/backend/src/service/updateApp.ts
index 55818ed5..53543fa2 100644
--- a/backend/src/service/updateApp.ts
+++ b/backend/src/service/updateApp.ts
@@ -1,9 +1,8 @@
import { db } from "../db/index.ts";
import type {
Deployment,
- HelmConfig,
+ DeploymentConfig,
HelmConfigCreate,
- WorkloadConfig,
WorkloadConfigCreate,
} from "../db/models.ts";
import type { components } from "../generated/openapi.ts";
@@ -156,7 +155,7 @@ export async function updateApp(
}
const shouldBuildOnUpdate = (
- oldConfig: WorkloadConfig | HelmConfig,
+ oldConfig: DeploymentConfig,
newConfig: WorkloadConfigCreate | HelmConfigCreate,
currentDeployment: Deployment,
) => {
From fd41baa38266616ccd5c43ff3dfd805b34eafa3c Mon Sep 17 00:00:00 2001
From: FluxCapacitor2 <31071265+FluxCapacitor2@users.noreply.github.com>
Date: Tue, 6 Jan 2026 02:18:13 -0500
Subject: [PATCH 23/38] Use Promise.allSettled instead of manual try/catch +
returning value or error
---
backend/src/service/helper/app.ts | 32 +++++++++++++++----------------
1 file changed, 16 insertions(+), 16 deletions(-)
diff --git a/backend/src/service/helper/app.ts b/backend/src/service/helper/app.ts
index a3670112..8c7a3329 100644
--- a/backend/src/service/helper/app.ts
+++ b/backend/src/service/helper/app.ts
@@ -63,30 +63,30 @@ export class AppService {
);
}
- const metadata: (
- | Awaited>
- | Error
- )[] = await Promise.all(
- apps.map(async (app) => {
- try {
- return await this.configService.prepareDeploymentMetadata(
+ const metadata = await Promise.allSettled(
+ apps.map(
+ async (app) =>
+ await this.configService.prepareDeploymentMetadata(
app.config,
organization,
- );
- } catch (e) {
- return e;
- }
- }),
+ ),
+ ),
);
- const errors = metadata.filter((res) => res instanceof Error) as Error[];
+ const errors = metadata.filter((res) => res.status === "rejected");
if (errors.length > 0) {
- throw new ValidationError(errors.map((err) => err.message).join(","));
+ throw new ValidationError(
+ errors.map((err) => (err.reason as Error)?.message).join(","),
+ );
}
- return metadata as Awaited<
+ type MetadataReturn = Awaited<
ReturnType
- >[];
+ >;
+
+ return metadata.map(
+ (app) => (app as PromiseFulfilledResult).value,
+ );
}
/**
From ec207db68386c72474b82623e8cb45446bcf2883 Mon Sep 17 00:00:00 2001
From: FluxCapacitor2 <31071265+FluxCapacitor2@users.noreply.github.com>
Date: Tue, 6 Jan 2026 03:57:16 -0500
Subject: [PATCH 24/38] Small misc. changes
---
backend/src/service/createApp.ts | 6 ++-
backend/src/service/createAppGroup.ts | 2 +-
backend/src/service/helper/deployment.ts | 26 ++++++------
.../src/service/helper/deploymentConfig.ts | 35 ++++++++++------
backend/src/service/helper/types.ts | 7 ----
backend/src/service/isSubdomainAvailable.ts | 4 +-
backend/src/service/updateApp.ts | 5 +--
frontend/src/lib/api.ts | 40 +++++++------------
tilt/anvilops-backend.Dockerfile | 2 +-
9 files changed, 60 insertions(+), 67 deletions(-)
delete mode 100644 backend/src/service/helper/types.ts
diff --git a/backend/src/service/createApp.ts b/backend/src/service/createApp.ts
index ebf2c0b0..08f9afd2 100644
--- a/backend/src/service/createApp.ts
+++ b/backend/src/service/createApp.ts
@@ -55,6 +55,10 @@ export async function createApp(appData: NewApp, userId: number) {
appGroupId = await db.appGroup.create(appData.orgId, groupName, true);
break;
}
+
+ default: {
+ appData.appGroup satisfies never; // Make sure switch is exhaustive
+ }
}
let { config, commitMessage } = (
@@ -71,7 +75,7 @@ export async function createApp(appData: NewApp, userId: number) {
namespace: appData.namespace,
});
- config = deploymentConfigService.updateConfigWithApp(config, app);
+ config = deploymentConfigService.populateImageTag(config, app);
} catch (err) {
// In between validation and creating the app, the namespace was taken by another app
if (err instanceof ConflictError) {
diff --git a/backend/src/service/createAppGroup.ts b/backend/src/service/createAppGroup.ts
index ab1f0920..34762c87 100644
--- a/backend/src/service/createAppGroup.ts
+++ b/backend/src/service/createAppGroup.ts
@@ -63,7 +63,7 @@ export async function createAppGroup(
projectId: appData.projectId,
namespace: appData.namespace,
});
- config = deploymentConfigService.updateConfigWithApp(config, app);
+ config = deploymentConfigService.populateImageTag(config, app);
} catch (err) {
// In between validation and creating the app, the namespace was taken by another app
if (err instanceof ConflictError) {
diff --git a/backend/src/service/helper/deployment.ts b/backend/src/service/helper/deployment.ts
index d30ad5b7..0713ea33 100644
--- a/backend/src/service/helper/deployment.ts
+++ b/backend/src/service/helper/deployment.ts
@@ -56,9 +56,8 @@ export class DeploymentService {
}
/**
- *
- * @throws DeploymentError
* Creates a Deployment object and triggers the deployment process.
+ * @throws DeploymentError
*/
async create({
org,
@@ -116,16 +115,20 @@ export class DeploymentService {
});
break;
}
+
+ default: {
+ config satisfies never; // Make sure switch is exhaustive
+ }
}
}
/**
+ * Proceeds with a Git deployment from an existing Deployment and GitConfig.
+ * - If opts.skipBuild is true, immediately deploy the app.
+ * - If opts.checkRun is present, deploy in response to a webhook. When opts.pending is true, create a pending check run and wait for other workflows to complete. When opts.pending is false, start the build.
+ * - Otherwise, build and deploy as if a new app has just been created.
*
* @throws DeploymentError
- * Proceeds with a Git deployment from an existing Deployment and GitConfig.
- * If the skipBuild flag is set, immediately deploy the app.
- * If the pending flag is set, add a pending check run.
- * Otherwise, build and deploy.
*/
private async deployGit({
org,
@@ -144,6 +147,8 @@ export class DeploymentService {
// Webhook event deployment
const { pending, owner, repo } = opts.checkRun;
if (pending) {
+ // AnvilOps is waiting for another CI workflow to finish before deploying the app. Create a "Pending" check run for now.
+ // When the other workflow completes, this method will be called again with `pending` set to `false`.
try {
const checkRun = await this.handleCheckRun({
octokit: await this.getOctokitFn(org.githubInstallationId),
@@ -202,9 +207,8 @@ export class DeploymentService {
}
/**
- *
- * @throws DeploymentError
* Builds and deploys from an existing Deployment and GitConfig.
+ * @throws DeploymentError
*/
async completeGitDeployment({
org,
@@ -329,9 +333,8 @@ export class DeploymentService {
}
/**
- *
- * @throws DeploymentError
* Immediately deploys a workload. The image tag must be set on the config object.
+ * @throws DeploymentError
*/
private async deployWorkloadWithoutBuild({
org,
@@ -390,9 +393,8 @@ export class DeploymentService {
}
/**
- *
- * @throws DeploymentError
* Deploys a helm chart.
+ * @throws DeploymentError
*/
private async deployHelm(
org: Organization,
diff --git a/backend/src/service/helper/deploymentConfig.ts b/backend/src/service/helper/deploymentConfig.ts
index bd240805..7e5ba9e1 100644
--- a/backend/src/service/helper/deploymentConfig.ts
+++ b/backend/src/service/helper/deploymentConfig.ts
@@ -3,7 +3,6 @@ import type {
App,
DeploymentConfig,
GitConfigCreate,
- HelmConfig,
HelmConfigCreate,
Organization,
WorkloadConfig,
@@ -18,7 +17,14 @@ import { env } from "../../lib/env.ts";
import { getOctokit, getRepoById } from "../../lib/octokit.ts";
import { isRFC1123 } from "../../lib/validate.ts";
import { ValidationError } from "../common/errors.ts";
-import type { GitWorkloadConfig, ImageWorkloadConfig } from "./types.ts";
+
+type GitWorkloadConfig = components["schemas"]["WorkloadConfigOptions"] & {
+ source: "git";
+};
+
+type ImageWorkloadConfig = components["schemas"]["WorkloadConfigOptions"] & {
+ source: "image";
+};
export class DeploymentConfigService {
private appRepo: AppRepo;
@@ -85,7 +91,7 @@ export class DeploymentConfigService {
}
return {
- config: await this.createGitConfig(config, commitHash, repo.id),
+ config: this.createGitConfig(config, commitHash, repo.id),
commitMessage,
};
}
@@ -106,17 +112,18 @@ export class DeploymentConfigService {
commitMessage,
};
}
+ default: {
+ config satisfies never; // Make sure switch is exhaustive
+ throw new ValidationError("Invalid deployment config type");
+ }
}
}
/**
- *
- * @param config
- * @param app
- * @returns If source is GIT, a -ConfigCreate object with the image tag where the
- * built image will be pushed, the original config otherwise
+ * @returns If source is GIT, a `ConfigCreate` object with the image tag where
+ * the built image will be pushed, the original config otherwise
*/
- updateConfigWithApp(
+ populateImageTag(
config: GitConfigCreate | HelmConfigCreate | WorkloadConfigCreate,
app: App,
) {
@@ -124,7 +131,7 @@ export class DeploymentConfigService {
return {
...config,
imageTag: `${env.REGISTRY_HOSTNAME}/${env.HARBOR_PROJECT_NAME}/${app.imageRepo}:${config.commitHash}`,
- };
+ } satisfies WorkloadConfigCreate;
}
return config;
@@ -149,11 +156,11 @@ export class DeploymentConfigService {
};
}
- private async createGitConfig(
+ private createGitConfig(
config: GitWorkloadConfig,
commitHash: string,
repositoryId: number,
- ): Promise {
+ ): GitConfigCreate {
return {
...this.createCommonWorkloadConfig(config),
source: "GIT",
@@ -169,7 +176,9 @@ export class DeploymentConfigService {
} satisfies GitConfigCreate;
}
- // Produces a DeploymentConfig object to be returned from the API, as described in the OpenAPI spec.
+ /**
+ * Produces a `DeploymentConfig` object to be returned from the API, as described in the OpenAPI spec.
+ */
formatDeploymentConfig(
config: DeploymentConfig,
): components["schemas"]["DeploymentConfig"] {
diff --git a/backend/src/service/helper/types.ts b/backend/src/service/helper/types.ts
deleted file mode 100644
index c1f27d57..00000000
--- a/backend/src/service/helper/types.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import type { 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/service/isSubdomainAvailable.ts b/backend/src/service/isSubdomainAvailable.ts
index 1c89a6d3..58074ee2 100644
--- a/backend/src/service/isSubdomainAvailable.ts
+++ b/backend/src/service/isSubdomainAvailable.ts
@@ -9,6 +9,6 @@ export async function isSubdomainAvailable(subdomain: string) {
throw new ValidationError("Invalid subdomain.");
}
- const subdomainUsedByApp = await db.app.getAppBySubdomain(subdomain);
- return subdomainUsedByApp === null;
+ const appUsingSubdomain = await db.app.getAppBySubdomain(subdomain);
+ return appUsingSubdomain === null;
}
diff --git a/backend/src/service/updateApp.ts b/backend/src/service/updateApp.ts
index 53543fa2..e2462766 100644
--- a/backend/src/service/updateApp.ts
+++ b/backend/src/service/updateApp.ts
@@ -119,10 +119,7 @@ export async function updateApp(
]);
// Adds an image tag to Git configs
- updatedConfig = deploymentConfigService.updateConfigWithApp(
- updatedConfig,
- app,
- );
+ updatedConfig = deploymentConfigService.populateImageTag(updatedConfig, app);
if (
updatedConfig.appType === "workload" &&
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index d800a87e..82d399af 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -4,7 +4,8 @@ import {
QueryClient,
type DefaultError,
type Mutation,
- type Query,
+ type MutationFunctionContext,
+ type QueryOptions,
} from "@tanstack/react-query";
import createFetchClient from "openapi-fetch";
import createClient from "openapi-react-query";
@@ -22,9 +23,16 @@ export const api = createClient(fetchClient);
*/
const ALLOWED_UNAUTHENTICATED = ["/", "/error"];
-const onQueryError = (
+const onError = (
error: DefaultError,
- query: Query,
+ ...args:
+ | [QueryOptions]
+ | [
+ unknown,
+ unknown,
+ Mutation,
+ MutationFunctionContext,
+ ]
) => {
if (
("code" in error && error?.code === 401) ||
@@ -35,7 +43,7 @@ const onQueryError = (
return;
}
}
- if (query.queryHash === '["get","/user/me",{}]') {
+ if (args.length === 1 && args[0].queryHash === '["get","/user/me",{}]') {
// Don't show the error toast for the initial /user/me request
return;
}
@@ -44,27 +52,7 @@ const onQueryError = (
);
};
-const onMutationError = (
- error: DefaultError,
- _variables: unknown,
- _context: unknown,
- _mutation: Mutation,
-) => {
- if (
- ("code" in error && error?.code === 401) ||
- error?.message === "Unauthorized"
- ) {
- if (!ALLOWED_UNAUTHENTICATED.includes(window.location.pathname)) {
- window.location.href = "/api/login";
- return;
- }
- }
- toast.error(
- `Something went wrong: ${error.message ?? JSON.stringify(error)}`,
- );
-};
-
export const queryClient = new QueryClient({
- queryCache: new QueryCache({ onError: onQueryError }),
- mutationCache: new MutationCache({ onError: onMutationError }),
+ queryCache: new QueryCache({ onError }),
+ mutationCache: new MutationCache({ onError }),
});
diff --git a/tilt/anvilops-backend.Dockerfile b/tilt/anvilops-backend.Dockerfile
index a36327af..d51698f9 100644
--- a/tilt/anvilops-backend.Dockerfile
+++ b/tilt/anvilops-backend.Dockerfile
@@ -35,7 +35,7 @@ RUN npm run prisma:generate
# Run the backend
FROM base AS backend_run
-RUN apk add helm=3.19.0-r2
+RUN apk add --no-cache helm=3.19.0-r2
ENTRYPOINT ["/usr/local/bin/node", "--experimental-strip-types"]
CMD ["./src/index.ts"]
From 895361ed792db6f94a47b91bf99608a77fccfbe2 Mon Sep 17 00:00:00 2001
From: zheng861
Date: Sun, 11 Jan 2026 19:43:37 -0500
Subject: [PATCH 25/38] Misc. fixes - Move @relation to WorkloadConfig and
HelmConfig for effective cascade deletion - Remove REGISTRY_API_URL
environment variable - Add create and update types to prepareMetadataForApps
- Return a deployment title in getDeployment - Return non-applicable fields
as null from getDeployment - Cache the result of listCharts - Create or
update helm apps using a Job - Hide non-applicable tabs and fields for helm
apps
---
.../workflows/build-publish-helm-deployer.yml | 29 ++++
.../migration.sql | 46 +++---
backend/prisma/schema.prisma | 38 ++---
backend/src/db/repo/deployment.ts | 9 +-
backend/src/handlers/updateDeployment.ts | 1 +
backend/src/lib/builder.ts | 5 +-
backend/src/lib/env.ts | 12 +-
backend/src/lib/helm.ts | 150 +++++++++++++++---
backend/src/lib/registry.ts | 2 +-
backend/src/service/createApp.ts | 15 +-
backend/src/service/createAppGroup.ts | 25 ++-
backend/src/service/getAppLogs.ts | 14 +-
backend/src/service/getDeployment.ts | 72 ++++++---
backend/src/service/githubWebhook.ts | 16 +-
backend/src/service/helper/app.ts | 81 +++++++---
backend/src/service/helper/deployment.ts | 112 +++++++------
.../src/service/helper/deploymentConfig.ts | 11 +-
backend/src/service/listCharts.ts | 11 +-
backend/src/service/updateApp.ts | 13 +-
backend/src/service/updateDeployment.ts | 32 +++-
builders/helm/Dockerfile | 10 ++
builders/helm/docker-entrypoint.sh | 20 +++
builders/helm/pre-stop.sh | 9 ++
.../anvilops/anvilops-deployment.yaml | 4 +-
charts/anvilops/values.yaml | 1 +
.../workload/CommonWorkloadConfigFields.tsx | 17 +-
.../config/workload/git/ImportRepoDialog.tsx | 1 -
.../workload/image/ImageConfigFields.tsx | 3 +-
frontend/src/lib/form.ts | 3 +-
frontend/src/pages/DeploymentView.tsx | 15 +-
frontend/src/pages/app/AppView.tsx | 6 +-
frontend/src/pages/app/ConfigTab.tsx | 35 ++--
frontend/src/pages/app/FilesTab.tsx | 18 +--
frontend/src/pages/app/OverviewTab.tsx | 38 +++--
.../src/pages/app/overview/RedeployModal.tsx | 15 +-
openapi/openapi.yaml | 9 +-
tilt/Tiltfile | 1 +
tilt/local-values.yaml | 1 +
38 files changed, 594 insertions(+), 306 deletions(-)
create mode 100644 .github/workflows/build-publish-helm-deployer.yml
create mode 100644 builders/helm/Dockerfile
create mode 100644 builders/helm/docker-entrypoint.sh
create mode 100644 builders/helm/pre-stop.sh
diff --git a/.github/workflows/build-publish-helm-deployer.yml b/.github/workflows/build-publish-helm-deployer.yml
new file mode 100644
index 00000000..6a0f4558
--- /dev/null
+++ b/.github/workflows/build-publish-helm-deployer.yml
@@ -0,0 +1,29 @@
+name: Build and Publish Dockerfile Builder Docker image
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - "builders/helm/**"
+ - ".github/workflows/build-publish-helm-deployer.yml"
+ workflow_dispatch:
+
+jobs:
+ push_to_registry:
+ name: Push Helm Deployer Docker image to Harbor
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - name: Check out the repo
+ uses: actions/checkout@v6
+
+ - name: Log in to container registry
+ run: docker login -u '${{ secrets.DOCKER_USERNAME }}' -p '${{ secrets.DOCKER_PASSWORD }}' registry.anvil.rcac.purdue.edu
+
+ - name: Build and push Helm Deployer Docker image
+ run: docker build --push -t registry.anvil.rcac.purdue.edu/anvilops/helm-deployer:${{ github.ref_name }}-${{ github.run_number }}-${{ github.sha }}${{ github.event_name == 'push' && ' -t registry.anvil.rcac.purdue.edu/anvilops/helm-deployer:latest' || '' }} --cache-from=type=registry,ref=registry.anvil.rcac.purdue.edu/anvilops/helm-deployer:latest --cache-to=type=inline ./builders/helm
+
+ - name: Log out of container registry
+ if: always()
+ run: docker logout registry.anvil.rcac.purdue.edu
diff --git a/backend/prisma/migrations/20251222211930_separate_workload_and_helm_configs/migration.sql b/backend/prisma/migrations/20251222211930_separate_workload_and_helm_configs/migration.sql
index 7abf17bb..de78d3f1 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
@@ -16,15 +16,13 @@ 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";
+INSERT INTO "DeploymentConfig" ("id", "appType")
+SELECT id, 'workload' FROM "WorkloadConfig";
-- Adjust sequence to start at highest existing id value
SELECT setval(
@@ -32,7 +30,24 @@ SELECT setval(
(SELECT COALESCE(MAX(id), 1) FROM "DeploymentConfig")
);
--- Rename indexes
+-- Add deploymentConfigId to WorkloadConfig
+ALTER TABLE "WorkloadConfig"
+ADD COLUMN "deploymentConfigId" INTEGER;
+
+UPDATE "WorkloadConfig"
+SET "deploymentConfigId" = id;
+
+ALTER TABLE "WorkloadConfig"
+ALTER COLUMN "deploymentConfigId" SET NOT NULL;
+
+CREATE UNIQUE INDEX "WorkloadConfig_deploymentConfigId_key" ON "WorkloadConfig"("deploymentConfigId");
+
+ALTER TABLE "WorkloadConfig"
+ ADD CONSTRAINT "WorkloadConfig_deploymentConfigId_fkey"
+ FOREIGN KEY ("deploymentConfigId") REFERENCES "DeploymentConfig"(id)
+ ON UPDATE CASCADE ON DELETE CASCADE;
+
+-- Alter foreign key constraints
ALTER TABLE "Deployment" DROP CONSTRAINT "Deployment_configId_fkey";
ALTER TABLE "Deployment"
ADD CONSTRAINT "Deployment_configId_fkey"
@@ -52,18 +67,9 @@ CREATE TABLE "HelmConfig" (
"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;
+ "deploymentConfigId" INTEGER UNIQUE NOT NULL,
+ CONSTRAINT "HelmConfig_pkey" PRIMARY KEY ("id"),
+ CONSTRAINT "HelmConfig_deploymentConfigId_fkey"
+ FOREIGN KEY ("deploymentConfigId") REFERENCES "DeploymentConfig"(id)
+ ON UPDATE CASCADE ON DELETE CASCADE
+);
\ No newline at end of file
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index 5e33395d..8d89e6de 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -204,24 +204,23 @@ model Deployment {
}
model DeploymentConfig {
- 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?
+ id Int @id @default(autoincrement())
+ deployment Deployment?
+ appType AppType
+ workloadConfig WorkloadConfig?
+
+ helmConfig HelmConfig?
+ app App?
}
model WorkloadConfig {
- id Int @id @default(autoincrement())
+ id Int @id @default(autoincrement())
// Deployment options
/// [EnvVar[]]
- env Json @default("[]")
- envKey String @default("")
- deploymentConfig DeploymentConfig?
+ env Json @default("[]")
+ envKey String @default("")
+ deploymentConfig DeploymentConfig @relation(fields: [deploymentConfigId], references: [id], onDelete: Cascade)
+ deploymentConfigId Int @unique
// Build options
source DeploymentSource
@@ -260,12 +259,13 @@ enum HelmUrlType {
}
model HelmConfig {
- id Int @id @default(autoincrement())
- deploymentConfig DeploymentConfig?
- url String
- version String
- urlType HelmUrlType
- values Json?
+ id Int @id @default(autoincrement())
+ deploymentConfig DeploymentConfig @relation(fields: [deploymentConfigId], references: [id], onDelete: Cascade)
+ deploymentConfigId Int @unique
+ url String
+ version String
+ urlType HelmUrlType
+ values Json?
}
model Log {
diff --git a/backend/src/db/repo/deployment.ts b/backend/src/db/repo/deployment.ts
index cc61d897..20b76676 100644
--- a/backend/src/db/repo/deployment.ts
+++ b/backend/src/db/repo/deployment.ts
@@ -81,20 +81,19 @@ export class DeploymentRepo {
async create({
appId,
- appType,
config,
commitMessage,
workflowRunId,
status,
}: {
appId: number;
- appType: AppType;
config: WorkloadConfigCreate | HelmConfigCreate;
commitMessage: string | null;
workflowRunId?: number;
status?: DeploymentStatus;
}): Promise {
const configClone = structuredClone(config);
+ const appType = configClone.appType;
if (appType === "workload") {
delete configClone.appType;
} else if (appType === "helm") {
@@ -110,14 +109,12 @@ export class DeploymentRepo {
...(appType === "workload"
? {
workloadConfig: {
- create: DeploymentRepo.encryptEnv(
- configClone as PrismaWorkloadConfigCreate,
- ),
+ create: DeploymentRepo.encryptEnv(configClone),
},
}
: {
helmConfig: {
- create: configClone as PrismaHelmConfigCreate,
+ create: configClone,
},
}),
},
diff --git a/backend/src/handlers/updateDeployment.ts b/backend/src/handlers/updateDeployment.ts
index 480f3289..a7c943f3 100644
--- a/backend/src/handlers/updateDeployment.ts
+++ b/backend/src/handlers/updateDeployment.ts
@@ -15,6 +15,7 @@ export const updateDeploymentHandler: HandlerMap["updateDeployment"] = async (
await updateDeployment(secret, status);
return json(200, res, undefined);
} catch (e) {
+ console.error(e);
if (e instanceof ValidationError) {
return json(404, res, { code: 400, message: e.message });
} else if (e instanceof DeploymentNotFoundError) {
diff --git a/backend/src/lib/builder.ts b/backend/src/lib/builder.ts
index 990d1953..a8915688 100644
--- a/backend/src/lib/builder.ts
+++ b/backend/src/lib/builder.ts
@@ -338,7 +338,10 @@ async function countActiveBuildJobs() {
return jobs.items.filter((job) => job.status?.active).length;
}
-/** @returns The UID of the created build job, or null if the queue is full */
+/**
+ * @returns The UID of the created build job, or null if the queue is full
+ * @throws {Error} if the config is not a GitConfig
+ */
export async function dequeueBuildJob(): Promise {
if ((await countActiveBuildJobs()) >= MAX_JOBS) {
return null;
diff --git a/backend/src/lib/env.ts b/backend/src/lib/env.ts
index 3cf57d1b..cf58e49c 100644
--- a/backend/src/lib/env.ts
+++ b/backend/src/lib/env.ts
@@ -153,10 +153,6 @@ const variables = {
* 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
*/
@@ -188,6 +184,14 @@ const variables = {
defaultValue:
"registry.anvil.rcac.purdue.edu/anvilops/railpack-builder:latest",
},
+ /**
+ * The image for a job that creates or updates a Helm deployment
+ */
+ HELM_DEPLOYER_IMAGE: {
+ required: false,
+ defaultValue:
+ "registry.anvil.rcac.purdue.edu/anvilops/helm-deployer:latest",
+ },
/**
* The image that copies the log shipper binary to a destination path, used in an initContainer to start collecting logs from users' apps (see backend/src/lib/cluster/resources/logs.ts for more details)
*/
diff --git a/backend/src/lib/helm.ts b/backend/src/lib/helm.ts
index 3fb137a3..88cd5447 100644
--- a/backend/src/lib/helm.ts
+++ b/backend/src/lib/helm.ts
@@ -1,6 +1,13 @@
+import { V1Pod } from "@kubernetes/client-node";
import { spawn } from "child_process";
+import { randomBytes } from "node:crypto";
import { parse as yamlParse } from "yaml";
-import { HelmUrlType } from "../generated/prisma/enums.ts";
+import type { App, Deployment, HelmConfig } from "../db/models.ts";
+import { svcK8s } from "./cluster/kubernetes.ts";
+import { shouldImpersonate } from "./cluster/rancher.ts";
+import { getNamespace } from "./cluster/resources.ts";
+import { wrapWithLogExporter } from "./cluster/resources/logs.ts";
+import { env } from "./env.ts";
type Dependency = {
name: string;
@@ -54,50 +61,151 @@ export const getChart = async (
args.push(url);
const result = (await runHelm(args)) as string;
- console.log("result", result);
const chart = (await yamlParse(result)) as Chart;
return chart;
};
-export const upgrade = ({
- urlType,
- chartURL,
- version,
- namespace,
- values,
- release,
-}: {
- urlType: HelmUrlType;
- chartURL: string;
- version: string;
- namespace: string;
- values: Record;
- release: string;
-}) => {
+export const upgrade = async (
+ app: App,
+ deployment: Deployment,
+ config: HelmConfig,
+) => {
const args = [
"upgrade",
"--install",
"--namespace",
- namespace,
+ getNamespace(app.namespace),
"--create-namespace",
];
+ const { urlType, url, version, values } = config;
+ const release = app.name;
+
for (const [key, value] of Object.entries(values)) {
args.push("--set-json", `${key}=${JSON.stringify(value)}`);
}
switch (urlType) {
// example: helm install mynginx https://example.com/charts/nginx-1.2.3.tgz
case "absolute": {
- args.push(release, chartURL);
+ args.push(release, url);
break;
}
// example: helm install mynginx --version 1.2.3 oci://example.com/charts/nginx
case "oci": {
- args.push(release, "--version", version, chartURL);
+ args.push(release, "--version", version, url);
break;
}
}
- return runHelm(args);
+ const podTemplate: V1Pod = {
+ metadata: {
+ labels: {
+ "anvilops.rcac.purdue.edu/app-id": app.id.toString(),
+ "anvilops.rcac.purdue.edu/deployment-id": deployment.id.toString(),
+ },
+ },
+ spec: {
+ automountServiceAccountToken: false,
+ containers: [
+ {
+ env: [
+ { name: "DEPLOYMENT_API_SECRET", value: deployment.secret },
+ {
+ name: "DEPLOYMENT_API_URL",
+ value: `${env.CLUSTER_INTERNAL_BASE_URL}/api`,
+ },
+ {
+ name: "KUBECONFIG",
+ value: "/opt/creds/kubeconfig",
+ },
+ {
+ name: "HELM_KUBEASUSER",
+ value: shouldImpersonate(app.projectId)
+ ? app.clusterUsername
+ : "",
+ },
+ {
+ name: "HELM_ARGS",
+ value: `${args.join(" ")}`,
+ },
+ ],
+ name: "helm",
+ image: env.HELM_DEPLOYER_IMAGE,
+ volumeMounts: [
+ {
+ name: "kubeconfig",
+ mountPath: "/opt/creds",
+ readOnly: true,
+ },
+ ],
+ resources: {
+ limits: {
+ cpu: "500m",
+ memory: "500Mi",
+ },
+ requests: {
+ cpu: "250m",
+ memory: "128Mi",
+ },
+ },
+ securityContext: {
+ capabilities: {
+ drop: ["ALL"],
+ },
+ runAsNonRoot: true,
+ runAsUser: 65532,
+ runAsGroup: 65532,
+ allowPrivilegeEscalation: false,
+ },
+ },
+ ],
+ volumes: [
+ {
+ name: "kubeconfig",
+ secret: {
+ secretName: "kube-auth",
+ items: [
+ {
+ key: "kubeconfig",
+ path: "kubeconfig",
+ },
+ ],
+ },
+ },
+ ],
+ restartPolicy: "Never",
+ },
+ };
+
+ const label = randomBytes(4).toString("hex");
+ const jobName = `helm-upgrade-${release}-${label}`;
+ try {
+ await svcK8s["BatchV1Api"].createNamespacedJob({
+ namespace: env.CURRENT_NAMESPACE,
+ body: {
+ metadata: {
+ name: jobName,
+ labels: {
+ "anvilops.rcac.purdue.edu/app-id": app.id.toString(),
+ "anvilops.rcac.purdue.edu/deployment-id": deployment.id.toString(),
+ },
+ },
+ spec: {
+ ttlSecondsAfterFinished: 5 * 60,
+ backoffLimit: 1,
+ activeDeadlineSeconds: 5 * 60,
+ template: await wrapWithLogExporter(
+ "build",
+ app.logIngestSecret,
+ deployment.id,
+ podTemplate,
+ ),
+ },
+ },
+ });
+ } catch (e) {
+ console.error(e);
+ throw e;
+ }
};
diff --git a/backend/src/lib/registry.ts b/backend/src/lib/registry.ts
index 3c70bbe8..d6014ffc 100644
--- a/backend/src/lib/registry.ts
+++ b/backend/src/lib/registry.ts
@@ -36,7 +36,7 @@ type HarborRepository = {
export async function getRepositoriesByProject(projectName: string) {
return fetch(
- `${env.REGISTRY_PROTOCOL}://${env.REGISTRY_API_URL}/projects/${projectName}/repositories`,
+ `${env.REGISTRY_PROTOCOL}://${env.REGISTRY_HOSTNAME}/api/v2.0/projects/${projectName}/repositories`,
)
.then((res) => {
if (!res.ok) {
diff --git a/backend/src/service/createApp.ts b/backend/src/service/createApp.ts
index 08f9afd2..9ff18872 100644
--- a/backend/src/service/createApp.ts
+++ b/backend/src/service/createApp.ts
@@ -2,9 +2,9 @@ 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,
+ getRandomTag,
} from "../lib/cluster/resources.ts";
import { OrgNotFoundError, ValidationError } from "./common/errors.ts";
import {
@@ -48,10 +48,8 @@ export async function createApp(appData: NewApp, userId: number) {
}
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()}`;
+ appService.validateAppGroupName(groupName);
appGroupId = await db.appGroup.create(appData.orgId, groupName, true);
break;
}
@@ -62,7 +60,10 @@ export async function createApp(appData: NewApp, userId: number) {
}
let { config, commitMessage } = (
- await appService.prepareMetadataForApps(organization, user, appData)
+ await appService.prepareMetadataForApps(organization, user, {
+ type: "create",
+ ...appData,
+ })
)[0];
try {
@@ -78,8 +79,8 @@ export async function createApp(appData: NewApp, userId: number) {
config = deploymentConfigService.populateImageTag(config, app);
} catch (err) {
// In between validation and creating the app, the namespace was taken by another app
- if (err instanceof ConflictError) {
- throw new ValidationError(err.message + " is unavailable");
+ if (err instanceof ConflictError && err.message === "namespace") {
+ throw new ValidationError("Namespace is unavailable");
}
throw err;
}
diff --git a/backend/src/service/createAppGroup.ts b/backend/src/service/createAppGroup.ts
index 34762c87..5ffadc3b 100644
--- a/backend/src/service/createAppGroup.ts
+++ b/backend/src/service/createAppGroup.ts
@@ -18,10 +18,19 @@ export async function createAppGroup(
groupName: string,
appData: NewAppWithoutGroup[],
) {
- // validate all apps before creating any
appService.validateAppGroupName(groupName);
const groupId = await db.appGroup.create(orgId, groupName, false);
-
+ // let groupId: number;
+ // try {
+ // groupId = await db.appGroup.create(orgId, groupName, false);
+ // } catch (e) {
+ // if (e instanceof ConflictError) {
+ // throw new ValidationError(
+ // "An app group already exists with the same name.",
+ // );
+ // }
+ // throw e;
+ // }
const appsWithGroups = appData.map(
(app) =>
({
@@ -40,10 +49,14 @@ export async function createAppGroup(
throw new OrgNotFoundError(null);
}
+ // validate all apps before creating any
const validationResults = await appService.prepareMetadataForApps(
organization,
user,
- ...appData,
+ ...appData.map((app) => ({
+ type: "create" as const,
+ ...app,
+ })),
);
const appsWithMetadata = appsWithGroups.map((app, idx) => ({
@@ -66,9 +79,11 @@ export async function createAppGroup(
config = deploymentConfigService.populateImageTag(config, app);
} catch (err) {
// In between validation and creating the app, the namespace was taken by another app
- if (err instanceof ConflictError) {
- throw new ValidationError(err.message + " is unavailable");
+ if (err instanceof ConflictError && err.message === "namespace") {
+ throw new ValidationError("Namespace is unavailable");
}
+
+ throw err;
}
await deploymentService.create({
diff --git a/backend/src/service/getAppLogs.ts b/backend/src/service/getAppLogs.ts
index 72a19a3f..82d1e4c7 100644
--- a/backend/src/service/getAppLogs.ts
+++ b/backend/src/service/getAppLogs.ts
@@ -33,13 +33,7 @@ export async function getAppLogs(
// If the user has enabled collectLogs, we can pull them from our DB. If not, pull them from Kubernetes directly.
const config = await db.app.getDeploymentConfig(app.id);
- if (config.appType != "workload") {
- throw new ValidationError(
- "Log browsing is supported only for Git and image deployments",
- );
- }
-
- const collectLogs = config.collectLogs;
+ const collectLogs = config.appType === "workload" && config.collectLogs;
if (collectLogs || type === "BUILD") {
const fetchNewLogs = async () => {
@@ -75,6 +69,12 @@ export async function getAppLogs(
// Send all previous logs now
await fetchNewLogs();
} else {
+ if (config.appType === "helm") {
+ throw new ValidationError(
+ "Application log browsing is not supported for Helm deployments",
+ );
+ }
+
const { CoreV1Api: core, Log: log } = await getClientsForRequest(
userId,
app.projectId,
diff --git a/backend/src/service/getDeployment.ts b/backend/src/service/getDeployment.ts
index 5dd6ffba..0f32632e 100644
--- a/backend/src/service/getDeployment.ts
+++ b/backend/src/service/getDeployment.ts
@@ -1,4 +1,4 @@
-import type { V1Pod } from "@kubernetes/client-node";
+import type { V1Pod, V1PodList } from "@kubernetes/client-node";
import { db } from "../db/index.ts";
import { getClientsForRequest } from "../lib/cluster/kubernetes.ts";
import { getNamespace } from "../lib/cluster/resources.ts";
@@ -25,26 +25,22 @@ export async function getDeployment(deploymentId: number, userId: number) {
const { CoreV1Api: api } = await getClientsForRequest(userId, app.projectId, [
"CoreV1Api",
]);
- const [repositoryURL, pods] = await Promise.all([
- (async () => {
- if (config.source === "GIT") {
- const octokit = await getOctokit(org.githubInstallationId);
- const repo = await getRepoById(octokit, config.repositoryId);
- return repo.html_url;
- }
- return undefined;
- })(),
- api
+ let repositoryURL: string | null = null;
+ let pods: V1PodList | null = null;
+ if (config.source === "GIT") {
+ const octokit = await getOctokit(org.githubInstallationId);
+ const repo = await getRepoById(octokit, config.repositoryId);
+ repositoryURL = repo.html_url;
+ }
+ if (config.appType === "workload") {
+ pods = await api
.listNamespacedPod({
namespace: getNamespace(app.namespace),
labelSelector: `anvilops.rcac.purdue.edu/deployment-id=${deployment.id}`,
})
- .catch(
- // Namespace may not be ready yet
- () => ({ apiVersion: "v1", items: [] as V1Pod[] }),
- ),
- ]);
+ .catch(() => ({ apiVersion: "v1", items: [] as V1Pod[] }));
+ }
let scheduled = 0,
ready = 0,
@@ -72,25 +68,49 @@ export async function getDeployment(deploymentId: number, userId: number) {
}
const status =
- deployment.status === "COMPLETE" && scheduled + ready + failed === 0
+ deployment.status === "COMPLETE" &&
+ config.appType === "workload" &&
+ scheduled + ready + failed === 0
? ("STOPPED" as const)
: deployment.status;
+ let title: string;
+ switch (config.source) {
+ case "GIT":
+ title = deployment.commitMessage;
+ break;
+ case "IMAGE":
+ title = config.imageTag;
+ break;
+ case "HELM":
+ title = config.url;
+ break;
+ default:
+ title = "Unknown";
+ break;
+ }
+
+ const podStatus =
+ config.appType === "workload"
+ ? {
+ scheduled,
+ ready,
+ total: pods.items.length,
+ failed,
+ }
+ : null;
+
return {
repositoryURL,
- commitHash: config.source === "GIT" ? config.commitHash : "unknown",
- commitMessage: deployment.commitMessage,
+ title,
+ commitHash: config.source === "GIT" ? config.commitHash : null,
+ commitMessage: config.source === "GIT" ? deployment.commitMessage : null,
createdAt: deployment.createdAt.toISOString(),
updatedAt: deployment.updatedAt.toISOString(),
id: deployment.id,
appId: deployment.appId,
- status: status,
- podStatus: {
- scheduled,
- ready,
- total: pods.items.length,
- failed,
- },
+ status,
+ podStatus,
config: deploymentConfigService.formatDeploymentConfig(config),
};
}
diff --git a/backend/src/service/githubWebhook.ts b/backend/src/service/githubWebhook.ts
index 23da2e24..37d7c105 100644
--- a/backend/src/service/githubWebhook.ts
+++ b/backend/src/service/githubWebhook.ts
@@ -1,5 +1,4 @@
import { db, NotFoundError } from "../db/index.ts";
-import type { GitConfig } from "../db/models.ts";
import { DeploymentRepo } from "../db/repo/deployment.ts";
import type { components } from "../generated/openapi.ts";
import { type LogStream, type LogType } from "../generated/prisma/enums.ts";
@@ -122,6 +121,11 @@ async function handleInstallationDeleted(
await db.org.unlinkInstallationFromAllOrgs(payload.installation.id);
}
+/**
+ *
+ * @throws {Error} if the current config of an app is not a GitConfig
+ * @throws {AppNotFoundError} if no apps redeploy on push to this branch
+ */
async function handlePush(payload: components["schemas"]["webhook-push"]) {
const repoId = payload.repository?.id;
if (!repoId) {
@@ -162,6 +166,10 @@ async function handlePush(payload: components["schemas"]["webhook-push"]) {
}
}
+/**
+ * @throws {Error} if the current config of an app is not a GitConfig
+ * @throws {AppNotFoundError} if no apps are linked to this branch and workflow
+ */
async function handleWorkflowRun(
payload: components["schemas"]["webhook-workflow-run"],
) {
@@ -212,9 +220,9 @@ async function handleWorkflowRun(
app.id,
payload.workflow_run.id,
);
- const config = (await db.deployment.getConfig(
- deployment.id,
- )) as GitConfig;
+ const config = (
+ await db.deployment.getConfig(deployment.id)
+ ).asGitConfig();
if (!deployment || deployment.status !== "PENDING") {
// If the app was deleted, nothing to do
diff --git a/backend/src/service/helper/app.ts b/backend/src/service/helper/app.ts
index 8c7a3329..97b38633 100644
--- a/backend/src/service/helper/app.ts
+++ b/backend/src/service/helper/app.ts
@@ -13,15 +13,23 @@ import {
import { isRFC1123 } from "../../lib/validate.ts";
import { ValidationError } from "../../service/common/errors.ts";
import { DeploymentConfigService } from "./deploymentConfig.ts";
+interface CreateAppInput {
+ type: "create";
+ name: string;
+ namespace: string;
+ projectId?: string;
+ config: components["schemas"]["DeploymentConfig"];
+}
-interface App {
- existingAppId?: number;
- name?: string;
+interface UpdateAppInput {
+ type: "update";
+ existingAppId: number;
projectId?: string;
- namespace?: string;
config: components["schemas"]["DeploymentConfig"];
}
+export type AppInput = CreateAppInput | UpdateAppInput;
+
export class AppService {
private configService: DeploymentConfigService;
constructor(configService: DeploymentConfigService) {
@@ -29,12 +37,13 @@ export class AppService {
}
/**
+ * Validates and prepares deployment config and commit message for app creation or update.
* @throws ValidationError, OrgNotFoundError
*/
async prepareMetadataForApps(
organization: Organization,
user: User,
- ...apps: App[]
+ ...apps: AppInput[]
) {
const appValidationErrors = (
await Promise.all(
@@ -90,9 +99,27 @@ export class AppService {
}
/**
+ * Validates an app input for create or update.
* @throws ValidationError
*/
- private async validateApp(app: App, user: { clusterUsername: string }) {
+ private async validateApp(app: AppInput, user: { clusterUsername: string }) {
+ // Common validation for both create and update
+ await this.validateCommon(app, user);
+
+ // Type-specific validation
+ if (app.type === "create") {
+ await this.validateCreate(app);
+ }
+ }
+
+ /**
+ * Validation steps common between app creates and updates.
+ * @throws ValidationError
+ */
+ private async validateCommon(
+ app: AppInput,
+ user: { clusterUsername: string },
+ ) {
if (isRancherManaged()) {
if (!app.projectId) {
throw new ValidationError("Project ID is required");
@@ -106,31 +133,33 @@ export class AppService {
if (app.config.appType === "workload") {
await this.configService.validateCommonWorkloadConfig(
app.config,
- app.existingAppId,
+ app.type === "update" ? app.existingAppId : undefined,
);
}
+ }
- 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");
- }
+ /**
+ * Validation steps specific to app creation.
+ * @throws ValidationError
+ */
+ private async validateCreate(app: CreateAppInput) {
+ if (
+ app.namespace.length == 0 ||
+ app.namespace.length > MAX_NAMESPACE_LEN ||
+ !isRFC1123(app.namespace)
+ ) {
+ throw new ValidationError(
+ "Namespace must contain only lowercase alphanumeric characters or '-', " +
+ "start with an alphabetic character and end with an alphanumeric character, " +
+ `and contain at most ${MAX_NAMESPACE_LEN} characters`,
+ );
}
- if (app.name) {
- this.validateAppName(app.name);
+
+ if (await namespaceInUse(app.namespace)) {
+ throw new ValidationError("namespace is unavailable");
}
+
+ this.validateAppName(app.name);
}
/**
diff --git a/backend/src/service/helper/deployment.ts b/backend/src/service/helper/deployment.ts
index 0713ea33..bc5fe351 100644
--- a/backend/src/service/helper/deployment.ts
+++ b/backend/src/service/helper/deployment.ts
@@ -78,7 +78,6 @@ export class DeploymentService {
appId: app.id,
commitMessage,
workflowRunId,
- appType: configIn.appType,
config: configIn,
});
const config = await this.deploymentRepo.getConfig(deployment.id);
@@ -89,12 +88,12 @@ export class DeploymentService {
switch (config.source) {
case "HELM": {
- this.deployHelm(org, app, deployment, config.asHelmConfig());
+ await this.deployHelm(org, app, deployment, config.asHelmConfig());
break;
}
case "GIT": {
- this.deployGit({
+ await this.handleGitDeployment({
org,
app,
deployment,
@@ -106,7 +105,7 @@ export class DeploymentService {
case "IMAGE": {
const appGroup = await this.appGroupRepo.getById(app.appGroupId);
- this.deployWorkloadWithoutBuild({
+ await this.deployWorkloadWithoutBuild({
org,
app,
appGroup,
@@ -130,7 +129,7 @@ export class DeploymentService {
*
* @throws DeploymentError
*/
- private async deployGit({
+ private async handleGitDeployment({
org,
app,
deployment,
@@ -149,34 +148,13 @@ export class DeploymentService {
if (pending) {
// AnvilOps is waiting for another CI workflow to finish before deploying the app. Create a "Pending" check run for now.
// When the other workflow completes, this method will be called again with `pending` set to `false`.
- try {
- const checkRun = await this.handleCheckRun({
- octokit: await this.getOctokitFn(org.githubInstallationId),
- deployment,
- config,
- checkRun: {
- type: "create",
- opts: {
- owner: opts.checkRun.owner,
- repo: opts.checkRun.repo,
- status: "queued",
- },
- },
- });
- log(
- deployment.id,
- "BUILD",
- "Created GitHub check run with status Queued at " +
- checkRun.data.html_url,
- );
- await this.deploymentRepo.setCheckRunId(
- deployment.id,
- checkRun?.data?.id,
- );
- await this.cancelAllOtherDeployments(org, app, deployment.id, false);
- } catch (e) {
- console.error("Failed to set check run: ", e);
- }
+ this.createPendingCheckRun({
+ org,
+ app,
+ deployment,
+ config,
+ checkRunOpts: { owner, repo },
+ });
} else {
await this.completeGitDeployment({
org,
@@ -206,6 +184,56 @@ export class DeploymentService {
}
}
+ /**
+ * Creates a pending check run for a Git deployment,
+ * to be updated when an associated workflow run completes.
+ */
+ private async createPendingCheckRun({
+ org,
+ app,
+ deployment,
+ config,
+ checkRunOpts,
+ }: {
+ org: Organization;
+ app: App;
+ deployment: Deployment;
+ config: GitConfig;
+ checkRunOpts: {
+ owner: string;
+ repo: string;
+ };
+ }) {
+ try {
+ const checkRun = await this.handleCheckRun({
+ octokit: await this.getOctokitFn(org.githubInstallationId),
+ deployment,
+ config,
+ checkRun: {
+ type: "create",
+ opts: {
+ owner: checkRunOpts.owner,
+ repo: checkRunOpts.repo,
+ status: "queued",
+ },
+ },
+ });
+ log(
+ deployment.id,
+ "BUILD",
+ "Created GitHub check run with status Queued at " +
+ checkRun.data.html_url,
+ );
+ await this.deploymentRepo.setCheckRunId(
+ deployment.id,
+ checkRun?.data?.id,
+ );
+ await this.cancelAllOtherDeployments(org, app, deployment.id, false);
+ } catch (e) {
+ console.error("Failed to set check run: ", e);
+ }
+ }
+
/**
* Builds and deploys from an existing Deployment and GitConfig.
* @throws DeploymentError
@@ -405,18 +433,7 @@ export class DeploymentService {
await this.cancelAllOtherDeployments(org, app, deployment.id, true);
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 this.deploymentRepo.setStatus(
- deployment.id,
- DeploymentStatus.COMPLETE,
- );
+ await upgrade(app, deployment, config);
await this.appRepo.setConfig(app.id, deployment.configId);
} catch (e) {
await this.deploymentRepo.setStatus(
@@ -426,7 +443,7 @@ export class DeploymentService {
log(
deployment.id,
"BUILD",
- `Failed to apply Kubernetes resources: ${JSON.stringify(e?.body ?? e)}`,
+ `Failed to create Helm deployment job: ${JSON.stringify(e?.body ?? e)}`,
"stderr",
);
throw new DeploymentError(e);
@@ -479,6 +496,9 @@ export class DeploymentService {
}
}
+ /**
+ * @throws {Error} if a deployment has a checkRunId but its config is not a GitConfig
+ */
async cancelAllOtherDeployments(
org: Organization,
app: App,
diff --git a/backend/src/service/helper/deploymentConfig.ts b/backend/src/service/helper/deploymentConfig.ts
index 7e5ba9e1..b48f71ca 100644
--- a/backend/src/service/helper/deploymentConfig.ts
+++ b/backend/src/service/helper/deploymentConfig.ts
@@ -45,11 +45,8 @@ export class DeploymentConfigService {
organization: Pick,
): Promise<{
config: GitConfigCreate | HelmConfigCreate | WorkloadConfigCreate;
- commitMessage: string;
+ commitMessage: string | null;
}> {
- let commitHash = "unknown",
- commitMessage = "Initial deployment";
-
switch (config.source) {
case "git": {
let octokit: Octokit, repo: Awaited>;
@@ -68,6 +65,8 @@ export class DeploymentConfigService {
await this.validateGitConfig(config, octokit, repo);
+ let commitHash: string;
+ let commitMessage: string;
if (config.commitHash) {
commitHash = config.commitHash;
const commit = await octokit.rest.git.getCommit({
@@ -103,13 +102,13 @@ export class DeploymentConfigService {
source: "IMAGE",
appType: "workload",
},
- commitMessage,
+ commitMessage: null,
};
}
case "helm": {
return {
config: { ...config, source: "HELM", appType: "helm" },
- commitMessage,
+ commitMessage: null,
};
}
default: {
diff --git a/backend/src/service/listCharts.ts b/backend/src/service/listCharts.ts
index 73233442..3b907da2 100644
--- a/backend/src/service/listCharts.ts
+++ b/backend/src/service/listCharts.ts
@@ -1,8 +1,17 @@
+import { getOrCreate } from "../lib/cache.ts";
import { env } from "../lib/env.ts";
import { getChart } from "../lib/helm.ts";
import { getRepositoriesByProject } from "../lib/registry.ts";
export async function listCharts() {
+ return JSON.parse(
+ await getOrCreate("charts", 60 * 60, async () =>
+ JSON.stringify(await listChartsFromRegistry()),
+ ),
+ );
+}
+
+const listChartsFromRegistry = async () => {
const repos = await getRepositoriesByProject(env.CHART_PROJECT_NAME);
const charts = await Promise.all(
repos.map(async (repo) => {
@@ -27,4 +36,4 @@ export async function listCharts() {
version: chart.version,
valueSpec: JSON.parse(chart.annotations["anvilops-values"] ?? ""),
}));
-}
+};
diff --git a/backend/src/service/updateApp.ts b/backend/src/service/updateApp.ts
index e2462766..890c2543 100644
--- a/backend/src/service/updateApp.ts
+++ b/backend/src/service/updateApp.ts
@@ -7,9 +7,9 @@ import type {
} from "../db/models.ts";
import type { components } from "../generated/openapi.ts";
import {
- getRandomTag,
MAX_GROUPNAME_LEN,
RANDOM_TAG_LEN,
+ getRandomTag,
} from "../lib/cluster/resources.ts";
import {
AppNotFoundError,
@@ -45,6 +45,7 @@ export async function updateApp(
// performs validation
let { config: updatedConfig, commitMessage } = (
await appService.prepareMetadataForApps(organization, user, {
+ type: "update",
existingAppId: originalApp.id,
...appData,
})
@@ -79,10 +80,8 @@ export async function updateApp(
if (appData.appGroup.type === "standalone") {
break;
}
- // 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()}`;
+ let groupName = `${originalApp.name.substring(0, MAX_GROUPNAME_LEN - RANDOM_TAG_LEN - 1)}-${getRandomTag()}`;
+ appService.validateAppGroupName(groupName);
const appGroupId = await db.appGroup.create(
originalApp.orgId,
groupName,
@@ -96,8 +95,8 @@ export async function updateApp(
// ---------------- App model updates ----------------
const updates = {} as Record;
- if (appData.name !== undefined) {
- updates.displayName = appData.name;
+ if (appData.displayName !== undefined) {
+ updates.displayName = appData.displayName;
}
if (appData.projectId !== undefined) {
diff --git a/backend/src/service/updateDeployment.ts b/backend/src/service/updateDeployment.ts
index 83486570..069e40af 100644
--- a/backend/src/service/updateDeployment.ts
+++ b/backend/src/service/updateDeployment.ts
@@ -14,10 +14,6 @@ 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) {
@@ -25,13 +21,31 @@ export async function updateDeployment(secret: string, newStatus: string) {
}
const config = await db.deployment.getConfig(deployment.id);
- if (config.source !== "GIT") {
+ if (config.source === "IMAGE") {
throw new ValidationError("Cannot update deployment");
}
+ switch (config.source) {
+ case "GIT": {
+ if (!["BUILDING", "DEPLOYING", "ERROR"].some((it) => newStatus === it)) {
+ throw new ValidationError("Invalid status.");
+ }
+ break;
+ }
+ case "HELM": {
+ if (!["DEPLOYING", "COMPLETE", "ERROR"].some((it) => newStatus === it)) {
+ throw new ValidationError("Invalid status.");
+ }
+ break;
+ }
+ default: {
+ throw new ValidationError("Invalid source.");
+ }
+ }
+
await db.deployment.setStatus(
deployment.id,
- newStatus as "BUILDING" | "DEPLOYING" | "ERROR",
+ newStatus as "BUILDING" | "DEPLOYING" | "COMPLETE" | "ERROR",
);
log(
@@ -40,6 +54,10 @@ export async function updateDeployment(secret: string, newStatus: string) {
"Deployment status has been updated to " + newStatus,
);
+ if (config.source != "GIT") {
+ return;
+ }
+
const app = await db.app.getById(deployment.appId);
const [appGroup, org] = await Promise.all([
db.appGroup.getById(app.appGroupId),
@@ -101,7 +119,7 @@ export async function updateDeployment(secret: string, newStatus: string) {
db.app.setConfig(app.id, deployment.configId),
]);
- dequeueBuildJob(); // TODO - error handling for this line
+ await dequeueBuildJob();
} catch (err) {
console.error(err);
await db.deployment.setStatus(deployment.id, "ERROR");
diff --git a/builders/helm/Dockerfile b/builders/helm/Dockerfile
new file mode 100644
index 00000000..f6aa46da
--- /dev/null
+++ b/builders/helm/Dockerfile
@@ -0,0 +1,10 @@
+FROM alpine/helm:3.19.0
+
+RUN addgroup -g 65532 appuser && \
+ adduser -u 65532 -G appuser -D appuser
+
+COPY --chown=appuser:appuser --chmod=500 pre-stop.sh /var/run
+COPY --chown=appuser:appuser --chmod=500 docker-entrypoint.sh /var/run
+
+ENTRYPOINT ["/bin/sh"]
+CMD ["-c", "/var/run/docker-entrypoint.sh"]
\ No newline at end of file
diff --git a/builders/helm/docker-entrypoint.sh b/builders/helm/docker-entrypoint.sh
new file mode 100644
index 00000000..4a508c77
--- /dev/null
+++ b/builders/helm/docker-entrypoint.sh
@@ -0,0 +1,20 @@
+#!/bin/sh
+
+set -eo pipefail
+
+set_status() {
+ wget -q --header="Content-Type: application/json" --post-data "{\"secret\":\"$DEPLOYMENT_API_SECRET\",\"status\":\"$1\"}" -O- "$DEPLOYMENT_API_URL/deployment/update"
+}
+
+run_job() {
+ helm $HELM_ARGS
+}
+
+set_status "DEPLOYING"
+
+if run_job ; then
+ set_status "COMPLETE"
+else
+ set_status "ERROR"
+ exit 1
+fi
\ No newline at end of file
diff --git a/builders/helm/pre-stop.sh b/builders/helm/pre-stop.sh
new file mode 100644
index 00000000..f47a5321
--- /dev/null
+++ b/builders/helm/pre-stop.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+
+# This script is executed when the pod is forcefully terminated
+
+set_status() {
+ wget -q --header="Content-Type: application/json" --post-data "{\"secret\":\"$DEPLOYMENT_API_SECRET\",\"status\":\"$1\"}" -O- "$DEPLOYMENT_API_URL/deployment/update"
+}
+
+set_status "ERROR"
diff --git a/charts/anvilops/templates/anvilops/anvilops-deployment.yaml b/charts/anvilops/templates/anvilops/anvilops-deployment.yaml
index 249fd30f..1bd6750f 100644
--- a/charts/anvilops/templates/anvilops/anvilops-deployment.yaml
+++ b/charts/anvilops/templates/anvilops/anvilops-deployment.yaml
@@ -203,8 +203,6 @@ spec:
value: {{ .Release.Namespace }}
- name: REGISTRY_HOSTNAME
value: {{ .Values.harbor.expose.ingress.hosts.core }}
- - name: REGISTRY_API_URL
- value: {{ .Values.harbor.expose.ingress.hosts.core }}/api/v2.0
- name: CLUSTER_INTERNAL_BASE_URL
value: "http://{{ include "anvilops.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local"
- name: INGRESS_CLASS_NAME
@@ -231,6 +229,8 @@ spec:
value: {{ .Values.anvilops.env.dockerfileBuilderImage }}
- name: RAILPACK_BUILDER_IMAGE
value: {{ .Values.anvilops.env.railpackBuilderImage }}
+ - name: HELM_DEPLOYER_IMAGE
+ value: {{ .Values.anvilops.env.helmDeployerImage }}
- name: LOG_SHIPPER_IMAGE
value: {{ .Values.anvilops.env.logShipperImage }}
{{- with .Values.anvilops.env.registryProtocol }}
diff --git a/charts/anvilops/values.yaml b/charts/anvilops/values.yaml
index 98374d84..c0246967 100644
--- a/charts/anvilops/values.yaml
+++ b/charts/anvilops/values.yaml
@@ -18,6 +18,7 @@ anvilops:
fileBrowserImage: registry.anvil.rcac.purdue.edu/anvilops/file-browser:latest
dockerfileBuilderImage: registry.anvil.rcac.purdue.edu/anvilops/dockerfile-builder:latest
railpackBuilderImage: registry.anvil.rcac.purdue.edu/anvilops/railpack-builder:latest
+ helmDeployerImage: registry.anvil.rcac.purdue.edu/anvilops/helm-deployer:latest
logShipperImage: registry.anvil.rcac.purdue.edu/anvilops/log-shipper:latest
# This is for the secrets for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/
diff --git a/frontend/src/components/config/workload/CommonWorkloadConfigFields.tsx b/frontend/src/components/config/workload/CommonWorkloadConfigFields.tsx
index 3b1be59e..694c4b3c 100644
--- a/frontend/src/components/config/workload/CommonWorkloadConfigFields.tsx
+++ b/frontend/src/components/config/workload/CommonWorkloadConfigFields.tsx
@@ -1,6 +1,4 @@
import { useAppConfig } from "@/components/AppConfigProvider";
-import { EnvVarGrid } from "./EnvVarGrid";
-import { MountsGrid } from "./MountsGrid";
import {
Accordion,
AccordionContent,
@@ -12,6 +10,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { components } from "@/generated/openapi";
import { api } from "@/lib/api";
+import { MAX_SUBDOMAIN_LENGTH } from "@/lib/form";
import type { WorkloadFormFields, WorkloadUpdate } from "@/lib/form.types";
import { useDebouncedValue } from "@/lib/utils";
import { FormContext, SubdomainStatus } from "@/pages/create-app/CreateAppView";
@@ -28,7 +27,8 @@ import {
X,
} from "lucide-react";
import { useContext } from "react";
-import { MAX_SUBDOMAIN_LENGTH } from "@/lib/form";
+import { EnvVarGrid } from "./EnvVarGrid";
+import { MountsGrid } from "./MountsGrid";
export const CommonWorkloadConfigFields = ({
state,
@@ -179,17 +179,6 @@ export const CommonWorkloadConfigFields = ({
) : (
<>
- {/*
-
-
- Your application will be reachable at{" "}
-
- anvilops-{subdomain}.anvilops-{subdomain}
- .svc.cluster.local
- {" "}
- from within the cluster.
-
-
*/}
>
))}
diff --git a/frontend/src/components/config/workload/git/ImportRepoDialog.tsx b/frontend/src/components/config/workload/git/ImportRepoDialog.tsx
index 4d994247..d8145852 100644
--- a/frontend/src/components/config/workload/git/ImportRepoDialog.tsx
+++ b/frontend/src/components/config/workload/git/ImportRepoDialog.tsx
@@ -38,7 +38,6 @@ export const ImportRepoDialog = ({
setState: (updater: (prev: CommonFormFields) => CommonFormFields) => void;
}) => {
const setRepo = (id: number, name: string) => {
- console.log("setRepo", id, name);
setState((s) => ({
...s,
workload: {
diff --git a/frontend/src/components/config/workload/image/ImageConfigFields.tsx b/frontend/src/components/config/workload/image/ImageConfigFields.tsx
index e3e32b8d..81791c75 100644
--- a/frontend/src/components/config/workload/image/ImageConfigFields.tsx
+++ b/frontend/src/components/config/workload/image/ImageConfigFields.tsx
@@ -1,7 +1,7 @@
-import { Tag } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ImageFormFields } from "@/lib/form.types";
+import { Tag } from "lucide-react";
export const ImageConfigFields = ({
imageState,
@@ -27,6 +27,7 @@ export const ImageConfigFields = ({
{
diff --git a/frontend/src/lib/form.ts b/frontend/src/lib/form.ts
index 65e65461..6c4b10b2 100644
--- a/frontend/src/lib/form.ts
+++ b/frontend/src/lib/form.ts
@@ -1,4 +1,5 @@
import type { components } from "@/generated/openapi";
+import type { App } from "@/pages/app/AppView";
import type {
CommonFormFields,
GitFormFields,
@@ -8,7 +9,6 @@ import type {
WorkloadFormFields,
WorkloadUpdate,
} from "./form.types";
-import type { App } from "@/pages/app/AppView";
export const MAX_SUBDOMAIN_LENGTH = 54;
@@ -55,7 +55,6 @@ export const createDefaultCommonFormFields = (
export const createDeploymentConfig = (
formFields: Required,
): components["schemas"]["DeploymentConfig"] => {
- console.log("formFields", formFields);
if (formFields.appType === "workload") {
const workloadConfig = formFields.workload as Required;
const cpu = Math.round(parseFloat(workloadConfig.cpuCores) * 1000) + "m";
diff --git a/frontend/src/pages/DeploymentView.tsx b/frontend/src/pages/DeploymentView.tsx
index 24ee47cd..d417d989 100644
--- a/frontend/src/pages/DeploymentView.tsx
+++ b/frontend/src/pages/DeploymentView.tsx
@@ -29,20 +29,14 @@ export const DeploymentView = () => {
},
},
);
+ console.log(deployment);
const format = new Intl.DateTimeFormat(undefined, {
dateStyle: "short",
timeStyle: "short",
});
- let commitMessage = deployment.commitMessage.split("\n");
- if (deployment.commitMessage.trim().length === 0) {
- commitMessage = ["Untitled deployment"];
- }
- const [title, description] = [
- commitMessage.shift(),
- commitMessage.join("\n").trim(),
- ];
+ const title = deployment?.title?.trim() ?? "Untitled deployment";
return (
@@ -56,11 +50,6 @@ export const DeploymentView = () => {
{title}
- {description.trim().length > 0 && (
-
- {description}
-
- )}
{deployment.config.source === "git" && deployment.commitHash ? (
diff --git a/frontend/src/pages/app/AppView.tsx b/frontend/src/pages/app/AppView.tsx
index 4c72d990..5b9296fc 100644
--- a/frontend/src/pages/app/AppView.tsx
+++ b/frontend/src/pages/app/AppView.tsx
@@ -79,13 +79,15 @@ export default function AppView() {
)}
-
+ {app.config.appType === "workload" && (
+
+ )}
Overview
- {!!app.activeDeployment && (
+ {isWorkloadConfig(app.config) && !!app.activeDeployment && (
Status
diff --git a/frontend/src/pages/app/ConfigTab.tsx b/frontend/src/pages/app/ConfigTab.tsx
index 88b93c85..f4b067d7 100644
--- a/frontend/src/pages/app/ConfigTab.tsx
+++ b/frontend/src/pages/app/ConfigTab.tsx
@@ -1,25 +1,25 @@
-import type { CommonFormFields, GroupFormFields } from "@/lib/form.types";
-import { isWorkloadConfig } from "@/lib/utils";
-import type { RefetchOptions } from "@tanstack/react-query";
-import { useContext, useState, type Dispatch } from "react";
-import type { App } from "./AppView";
+import { useAppConfig } from "@/components/AppConfigProvider";
+import HelpTooltip from "@/components/HelpTooltip";
+import { UserContext } from "@/components/UserProvider";
+import { AppConfigFormFields } from "@/components/config/AppConfigFormFields";
+import { GroupConfigFields } from "@/components/config/GroupConfigFields";
+import { ProjectConfig } from "@/components/config/ProjectConfig";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { api } from "@/lib/api";
import {
createDeploymentConfig,
getFormStateFromApp,
getGroupStateFromApp,
} from "@/lib/form";
-import { api } from "@/lib/api";
-import { UserContext } from "@/components/UserProvider";
-import { Label } from "@/components/ui/label";
+import type { CommonFormFields, GroupFormFields } from "@/lib/form.types";
+import { isWorkloadConfig } from "@/lib/utils";
+import type { RefetchOptions } from "@tanstack/react-query";
import { Loader, Save, Scale3D, TextCursorInput } from "lucide-react";
-import { Input } from "@/components/ui/input";
-import HelpTooltip from "@/components/HelpTooltip";
+import { useContext, useState, type Dispatch } from "react";
import { FormContext } from "../create-app/CreateAppView";
-import { Button } from "@/components/ui/button";
-import { AppConfigFormFields } from "@/components/config/AppConfigFormFields";
-import { GroupConfigFields } from "@/components/config/GroupConfigFields";
-import { ProjectConfig } from "@/components/config/ProjectConfig";
-import { useAppConfig } from "@/components/AppConfigProvider";
+import type { App } from "./AppView";
export const ConfigTab = ({
app,
@@ -67,10 +67,7 @@ export const ConfigTab = ({
await updateApp({
params: { path: { appId: app.id } },
body: {
- name:
- state.displayName !== app.displayName
- ? state.displayName
- : undefined,
+ displayName: state.displayName,
appGroup: groupState.groupOption,
projectId:
state.projectId && state.projectId !== app.projectId
diff --git a/frontend/src/pages/app/FilesTab.tsx b/frontend/src/pages/app/FilesTab.tsx
index 4cdfe2b3..20b72f8d 100644
--- a/frontend/src/pages/app/FilesTab.tsx
+++ b/frontend/src/pages/app/FilesTab.tsx
@@ -15,8 +15,8 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
+import type { components } from "@/generated/openapi";
import { api } from "@/lib/api";
-import { isWorkloadConfig } from "@/lib/utils";
import {
ArrowUp,
CloudUpload,
@@ -34,7 +34,7 @@ import {
Trash,
UploadCloud,
} from "lucide-react";
-import { lazy, Suspense, useEffect, useState, type ReactNode } from "react";
+import { Suspense, lazy, useEffect, useState, type ReactNode } from "react";
import { toast } from "sonner";
import type { App } from "./AppView";
@@ -53,17 +53,11 @@ 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 config = app.config as components["schemas"]["WorkloadConfigOptions"];
const [replica, setReplica] = useState("0");
const [volume, setVolume] = useState(
- app.config.mounts?.[0]?.volumeClaimName,
+ config.mounts?.[0]?.volumeClaimName,
);
const [pathInput, setPathInput] = useState("/");
@@ -155,7 +149,7 @@ export const FilesTab = ({ app }: { app: App }) => {
- {Array({ length: app.config.replicas }).map((_, index) => (
+ {Array({ length: config.replicas }).map((_, index) => (
{app.name + "-" + index.toString()}
@@ -168,7 +162,7 @@ export const FilesTab = ({ app }: { app: App }) => {
- {app.config.mounts.map((mount) => (
+ {config.mounts.map((mount) => (
{mount.path}
diff --git a/frontend/src/pages/app/OverviewTab.tsx b/frontend/src/pages/app/OverviewTab.tsx
index 04b5e7cd..29a92735 100644
--- a/frontend/src/pages/app/OverviewTab.tsx
+++ b/frontend/src/pages/app/OverviewTab.tsx
@@ -212,23 +212,27 @@ export const OverviewTab = ({
>
)}
-
-
- Internal address
-
- Other workloads within the cluster can communicate with your
- application using this address.
- Use this address when possible for improved speed and compatibility
- with non-HTTP protocols.
-
- End users cannot use this address, as it's only valid within the
- cluster.
-
-
-
- anvilops-{app.namespace}.anvilops-{app.namespace}
- .svc.cluster.local
-
+ {isWorkloadConfig(app.config) && (
+ <>
+
+
+ Internal address
+
+ Other workloads within the cluster can communicate with your
+ application using this address.
+ Use this address when possible for improved speed and
+ compatibility with non-HTTP protocols.
+
+ End users cannot use this address, as it's only valid within the
+ cluster.
+
+
+
+ anvilops-{app.namespace}.anvilops-{app.namespace}
+ .svc.cluster.local
+
+ >
+ )}
Recent Deployments
diff --git a/frontend/src/pages/app/overview/RedeployModal.tsx b/frontend/src/pages/app/overview/RedeployModal.tsx
index 5b65c217..695bed99 100644
--- a/frontend/src/pages/app/overview/RedeployModal.tsx
+++ b/frontend/src/pages/app/overview/RedeployModal.tsx
@@ -15,17 +15,17 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/lib/api";
-import { cn, isWorkloadConfig } from "@/lib/utils";
-import { Container, GitCommit, Loader, Rocket } from "lucide-react";
-import { useContext, useEffect, useRef, useState, type Dispatch } from "react";
-import type { App } from "../AppView";
-import { AppConfigDiff } from "../../../components/diff/AppConfigDiff";
-import type { CommonFormFields } from "@/lib/form.types";
import {
createDefaultCommonFormFields,
createDeploymentConfig,
getFormStateFromApp,
} from "@/lib/form";
+import type { CommonFormFields } from "@/lib/form.types";
+import { cn, isWorkloadConfig } from "@/lib/utils";
+import { Container, GitCommit, Loader, Rocket } from "lucide-react";
+import { useContext, useEffect, useRef, useState, type Dispatch } from "react";
+import { AppConfigDiff } from "../../../components/diff/AppConfigDiff";
+import type { App } from "../AppView";
const getDefaultRedeployState = () => ({
radioValue: undefined,
@@ -163,7 +163,7 @@ export const RedeployModal = ({
config: {
...config,
...(pastDeployment.config.source === "git" && {
- commitHash: pastDeployment.commitHash,
+ commitHash: pastDeployment.commitHash ?? undefined,
}),
},
},
@@ -338,7 +338,6 @@ export const RedeployModal = ({
className="mt-4 float-right"
type="button"
onClick={() => {
- console.log("?");
if (form.current!.checkValidity()) {
setRedeployState((rs) => ({
...rs,
diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml
index 2e5b94e5..b1742752 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -2139,7 +2139,7 @@ components:
AppUpdate:
type: object
properties:
- name:
+ displayName:
type: string
appGroup:
oneOf:
@@ -2367,10 +2367,16 @@ components:
format: int64
repositoryURL:
type: string
+ nullable: true
commitHash:
type: string
+ nullable: true
commitMessage:
type: string
+ nullable: true
+ title:
+ type: string
+ nullable: true
createdAt:
type: string
format: date-time
@@ -2396,6 +2402,7 @@ components:
required:
- id
- appId
+ - title
- commitHash
- commitMessage
- createdAt
diff --git a/tilt/Tiltfile b/tilt/Tiltfile
index e2798775..acce5b37 100644
--- a/tilt/Tiltfile
+++ b/tilt/Tiltfile
@@ -41,6 +41,7 @@ docker_build("anvilops/migrate-db", "../backend", dockerfile="../backend/prisma/
docker_build("anvilops/file-browser", "../filebrowser", match_in_env_vars=True)
docker_build("anvilops/dockerfile-builder", "../builders/dockerfile", match_in_env_vars=True)
docker_build("anvilops/railpack-builder", "../builders/railpack", match_in_env_vars=True)
+docker_build("anvilops/helm-deployer", "../builders/helm", match_in_env_vars=True)
docker_build("anvilops/log-shipper", "../log-shipper", match_in_env_vars=True)
# Read the current kubeconfig, set the server to kubernetes.default.svc (a hostname accessible from within the cluster), and make it available to AnvilOps via a K8s secret
diff --git a/tilt/local-values.yaml b/tilt/local-values.yaml
index f037e8df..704d6cc2 100644
--- a/tilt/local-values.yaml
+++ b/tilt/local-values.yaml
@@ -14,6 +14,7 @@ anvilops:
fileBrowserImage: anvilops/file-browser
dockerfileBuilderImage: anvilops/dockerfile-builder
railpackBuilderImage: anvilops/railpack-builder
+ helmDeployerImage: anvilops/helm-deployer
logShipperImage: anvilops/log-shipper
registryProtocol: http
cluster:
From ac27491f7deca730639643c5bfa4fb785f0fbfe0 Mon Sep 17 00:00:00 2001
From: zheng861
Date: Sun, 11 Jan 2026 20:46:51 -0500
Subject: [PATCH 26/38] Enable helm deployments from values.yaml
---
backend/src/handlers/listCharts.ts | 7 +++++++
backend/src/lib/env.ts | 6 +++++-
backend/src/service/getSettings.ts | 1 +
backend/src/service/helper/app.ts | 5 +++++
backend/src/service/listCharts.ts | 4 ++++
.../anvilops/anvilops-deployment.yaml | 2 ++
charts/anvilops/values.yaml | 1 +
.../components/config/AppConfigFormFields.tsx | 20 +++++++++----------
.../src/components/diff/AppConfigDiff.tsx | 14 +++++++------
openapi/openapi.yaml | 8 ++++++++
tilt/local-values.yaml | 1 +
11 files changed, 52 insertions(+), 17 deletions(-)
diff --git a/backend/src/handlers/listCharts.ts b/backend/src/handlers/listCharts.ts
index 939fb8ef..56835b90 100644
--- a/backend/src/handlers/listCharts.ts
+++ b/backend/src/handlers/listCharts.ts
@@ -1,3 +1,4 @@
+import { ValidationError } from "../service/common/errors.ts";
import { listCharts } from "../service/listCharts.ts";
import { json, type HandlerMap } from "../types.ts";
export const listChartsHandler: HandlerMap["listCharts"] = async (
@@ -8,6 +9,12 @@ export const listChartsHandler: HandlerMap["listCharts"] = async (
try {
return json(200, res, await listCharts());
} catch (e) {
+ if (e instanceof ValidationError) {
+ return json(400, res, {
+ code: 400,
+ message: e.message,
+ });
+ }
console.error(e);
return json(500, res, {
code: 500,
diff --git a/backend/src/lib/env.ts b/backend/src/lib/env.ts
index cf58e49c..15e20d7c 100644
--- a/backend/src/lib/env.ts
+++ b/backend/src/lib/env.ts
@@ -152,7 +152,11 @@ const variables = {
/**
* The name of the project in which custom AnvilOps charts are stored.
*/
- CHART_PROJECT_NAME: { required: false },
+ CHART_PROJECT_NAME: { required: false, defaultValue: "anvilops-chart" },
+ /**
+ * Whether to allow Helm deployments
+ */
+ ALLOW_HELM_DEPLOYMENTS: { required: false },
/**
* The hostname for the image registry, e.g. registry.anvil.rcac.purdue.edu
*/
diff --git a/backend/src/service/getSettings.ts b/backend/src/service/getSettings.ts
index e1a8a81e..2636a572 100644
--- a/backend/src/service/getSettings.ts
+++ b/backend/src/service/getSettings.ts
@@ -33,5 +33,6 @@ export async function getSettings() {
faq: clusterConfig?.faq,
storageEnabled: env.STORAGE_CLASS_NAME !== undefined,
isRancherManaged: isRancherManaged(),
+ allowHelmDeployments: env.ALLOW_HELM_DEPLOYMENTS === "true",
};
}
diff --git a/backend/src/service/helper/app.ts b/backend/src/service/helper/app.ts
index 97b38633..68890a89 100644
--- a/backend/src/service/helper/app.ts
+++ b/backend/src/service/helper/app.ts
@@ -10,6 +10,7 @@ import {
MAX_NAMESPACE_LEN,
MAX_STS_NAME_LEN,
} from "../../lib/cluster/resources.ts";
+import { env } from "../../lib/env.ts";
import { isRFC1123 } from "../../lib/validate.ts";
import { ValidationError } from "../../service/common/errors.ts";
import { DeploymentConfigService } from "./deploymentConfig.ts";
@@ -135,6 +136,10 @@ export class AppService {
app.config,
app.type === "update" ? app.existingAppId : undefined,
);
+ } else if (app.config.appType === "helm") {
+ if (!env.ALLOW_HELM_DEPLOYMENTS) {
+ throw new ValidationError("Helm deployments are disabled");
+ }
}
}
diff --git a/backend/src/service/listCharts.ts b/backend/src/service/listCharts.ts
index 3b907da2..fc96fe93 100644
--- a/backend/src/service/listCharts.ts
+++ b/backend/src/service/listCharts.ts
@@ -2,8 +2,12 @@ import { getOrCreate } from "../lib/cache.ts";
import { env } from "../lib/env.ts";
import { getChart } from "../lib/helm.ts";
import { getRepositoriesByProject } from "../lib/registry.ts";
+import { ValidationError } from "./common/errors.ts";
export async function listCharts() {
+ if (!env.ALLOW_HELM_DEPLOYMENTS) {
+ throw new ValidationError("Helm deployments are disabled");
+ }
return JSON.parse(
await getOrCreate("charts", 60 * 60, async () =>
JSON.stringify(await listChartsFromRegistry()),
diff --git a/charts/anvilops/templates/anvilops/anvilops-deployment.yaml b/charts/anvilops/templates/anvilops/anvilops-deployment.yaml
index 1bd6750f..ee567447 100644
--- a/charts/anvilops/templates/anvilops/anvilops-deployment.yaml
+++ b/charts/anvilops/templates/anvilops/anvilops-deployment.yaml
@@ -221,6 +221,8 @@ spec:
value: {{ .Values.anvilops.env.harborProjectName }}
- name: CHART_PROJECT_NAME
value: {{ .Values.anvilops.env.harborChartRepoName }}
+ - name: ALLOW_HELM_DEPLOYMENTS
+ value: "{{ .Values.anvilops.env.allowHelmDeployments }}"
- name: BUILDKITD_ADDRESS
value: {{ .Values.buildkitd.address }}
- name: FILE_BROWSER_IMAGE
diff --git a/charts/anvilops/values.yaml b/charts/anvilops/values.yaml
index c0246967..30dd1caa 100644
--- a/charts/anvilops/values.yaml
+++ b/charts/anvilops/values.yaml
@@ -20,6 +20,7 @@ anvilops:
railpackBuilderImage: registry.anvil.rcac.purdue.edu/anvilops/railpack-builder:latest
helmDeployerImage: registry.anvil.rcac.purdue.edu/anvilops/helm-deployer:latest
logShipperImage: registry.anvil.rcac.purdue.edu/anvilops/log-shipper:latest
+ allowHelmDeployments: false
# This is for the secrets for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/
imagePullSecrets: []
diff --git a/frontend/src/components/config/AppConfigFormFields.tsx b/frontend/src/components/config/AppConfigFormFields.tsx
index 077f6ea8..414f27ac 100644
--- a/frontend/src/components/config/AppConfigFormFields.tsx
+++ b/frontend/src/components/config/AppConfigFormFields.tsx
@@ -1,12 +1,5 @@
-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 { 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,
@@ -16,13 +9,20 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-import { ProjectConfig } from "./ProjectConfig";
-import { CommonWorkloadConfigFields } from "./workload/CommonWorkloadConfigFields";
+import type { components } from "@/generated/openapi";
import {
makeFunctionalWorkloadSetter,
makeHelmSetter,
makeImageSetter,
} from "@/lib/form";
+import type { CommonFormFields, GroupFormFields } from "@/lib/form.types";
+import { Cable } from "lucide-react";
+import { useContext } from "react";
+import { ProjectConfig } from "./ProjectConfig";
+import { HelmConfigFields } from "./helm/HelmConfigFields";
+import { CommonWorkloadConfigFields } from "./workload/CommonWorkloadConfigFields";
+import { GitConfigFields } from "./workload/git/GitConfigFields";
+import { ImageConfigFields } from "./workload/image/ImageConfigFields";
export const AppConfigFormFields = ({
groupState,
@@ -90,7 +90,7 @@ export const AppConfigFormFields = ({
Git Repository
OCI Image
- {/* Helm Chart */}
+ {/* appConfig.allowHelmDeployments && Helm Chart */}
diff --git a/frontend/src/components/diff/AppConfigDiff.tsx b/frontend/src/components/diff/AppConfigDiff.tsx
index 7d051b87..4f7ea9de 100644
--- a/frontend/src/components/diff/AppConfigDiff.tsx
+++ b/frontend/src/components/diff/AppConfigDiff.tsx
@@ -1,10 +1,6 @@
import { UserContext } from "@/components/UserProvider";
import { Label } from "@/components/ui/label";
import { SelectContent, SelectGroup, SelectItem } from "@/components/ui/select";
-import { Cable } from "lucide-react";
-import { useContext } from "react";
-import { GitConfigDiff } from "./workload/git/GitConfigDiff";
-import type { CommonFormFields } from "@/lib/form.types";
import {
getFormStateFromApp,
makeFunctionalWorkloadSetter,
@@ -12,11 +8,16 @@ import {
makeHelmSetter,
makeImageSetter,
} from "@/lib/form";
+import type { CommonFormFields } from "@/lib/form.types";
+import { Cable } from "lucide-react";
+import { useContext } from "react";
import type { App } from "../../pages/app/AppView";
+import { useAppConfig } from "../AppConfigProvider";
import { DiffSelect } from "./DiffSelect";
import { HelmConfigDiff } from "./helm/HelmConfigDiff";
-import { ImageConfigDiff } from "./workload/image/ImageConfigDiff";
import { CommonWorkloadConfigDiff } from "./workload/CommonWorkloadConfigDiff";
+import { GitConfigDiff } from "./workload/git/GitConfigDiff";
+import { ImageConfigDiff } from "./workload/image/ImageConfigDiff";
export const AppConfigDiff = ({
orgId,
@@ -32,7 +33,7 @@ export const AppConfigDiff = ({
disabled?: boolean;
}) => {
const { user } = useContext(UserContext);
-
+ const appConfig = useAppConfig();
const selectedOrg = orgId
? user?.orgs?.find((it) => it.id === orgId)
: undefined;
@@ -79,6 +80,7 @@ export const AppConfigDiff = ({
Git Repository
OCI Image
+ {/* {appConfig.allowHelmDeployments && Helm Chart } */}
diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml
index b1742752..ad87c8e4 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -50,6 +50,8 @@ paths:
type: boolean
isRancherManaged:
type: boolean
+ allowHelmDeployments:
+ type: boolean
/login:
get:
tags:
@@ -1867,6 +1869,12 @@ paths:
type: object
additionalProperties: true
required: [name, note, url, urlType, version]
+ "400":
+ description: Validation failed
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ApiError"
"500":
description: Internal server error
content:
diff --git a/tilt/local-values.yaml b/tilt/local-values.yaml
index 704d6cc2..63bd7e69 100644
--- a/tilt/local-values.yaml
+++ b/tilt/local-values.yaml
@@ -16,6 +16,7 @@ anvilops:
railpackBuilderImage: anvilops/railpack-builder
helmDeployerImage: anvilops/helm-deployer
logShipperImage: anvilops/log-shipper
+ allowHelmDeployments: true
registryProtocol: http
cluster:
name: "Local"
From e6922c1fd5980987a8d722c0849e2aeb701b31e2 Mon Sep 17 00:00:00 2001
From: zheng861
Date: Sun, 11 Jan 2026 22:14:44 -0500
Subject: [PATCH 27/38] Directly query OCI repository for chart info
---
.../build-publish-staging-anvilops.yml | 24 ----
Dockerfile | 1 -
backend/src/lib/helm.ts | 128 ++++++++++++------
backend/src/service/listCharts.ts | 35 ++---
.../src/components/diff/AppConfigDiff.tsx | 3 +-
5 files changed, 99 insertions(+), 92 deletions(-)
delete mode 100644 .github/workflows/build-publish-staging-anvilops.yml
diff --git a/.github/workflows/build-publish-staging-anvilops.yml b/.github/workflows/build-publish-staging-anvilops.yml
deleted file mode 100644
index 8878149a..00000000
--- a/.github/workflows/build-publish-staging-anvilops.yml
+++ /dev/null
@@ -1,24 +0,0 @@
-name: Build and Publish AnvilOps Docker image
-
-on:
- workflow_dispatch:
-
-jobs:
- push_to_registry:
- name: Push AnvilOps Docker image to Harbor (Development)
- runs-on: ubuntu-latest
- permissions:
- contents: read
- steps:
- - name: Check out the repo
- uses: actions/checkout@v6
-
- - name: Log in to container registry
- run: docker login -u '${{ secrets.DOCKER_USERNAME }}' -p '${{ secrets.DOCKER_PASSWORD }}' registry.anvil.rcac.purdue.edu
-
- - name: Build and push AnvilOps Docker image
- run: docker build --push -t registry.anvil.rcac.purdue.edu/anvilops-staging/anvilops:${{ github.run_number }}-${{ github.sha }}${{ github.event_name == 'push' && ' -t registry.anvil.rcac.purdue.edu/anvilops-staging/anvilops:latest' || '' }} --cache-from=type=registry,ref=registry.anvil.rcac.purdue.edu/anvilops-staging/anvilops:latest --cache-to=type=inline .
-
- - name: Log out of container registry
- if: always()
- run: docker logout registry.anvil.rcac.purdue.edu
diff --git a/Dockerfile b/Dockerfile
index 90a8a8e8..6ca2036f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -84,7 +84,6 @@ ENTRYPOINT ["/tini", "--", "/nodejs/bin/node", "--experimental-strip-types"]
CMD ["/app/src/index.ts"]
WORKDIR /app
-COPY --chown=65532:65532 --from=alpine/helm:3.19.0 /usr/bin/helm /usr/local/bin/helm
COPY --chown=65532:65532 --from=regclient/regctl:v0.11.1-alpine /usr/local/bin/regctl /usr/local/bin/regctl
COPY --chown=65532:65532 --from=swagger_build /app/dist ./public/openapi
COPY --chown=65532:65532 --from=frontend_build /app/dist ./public
diff --git a/backend/src/lib/helm.ts b/backend/src/lib/helm.ts
index 88cd5447..6e6378d1 100644
--- a/backend/src/lib/helm.ts
+++ b/backend/src/lib/helm.ts
@@ -1,7 +1,5 @@
import { V1Pod } from "@kubernetes/client-node";
-import { spawn } from "child_process";
import { randomBytes } from "node:crypto";
-import { parse as yamlParse } from "yaml";
import type { App, Deployment, HelmConfig } from "../db/models.ts";
import { svcK8s } from "./cluster/kubernetes.ts";
import { shouldImpersonate } from "./cluster/rancher.ts";
@@ -9,60 +7,100 @@ import { getNamespace } from "./cluster/resources.ts";
import { wrapWithLogExporter } from "./cluster/resources/logs.ts";
import { env } from "./env.ts";
-type Dependency = {
+type Chart = {
name: string;
version: string;
- repository?: string;
- condition?: string;
- tags?: string[];
- "import-values"?: string;
- alias?: string;
+ description?: string;
+ note?: string;
+ values: Record;
};
-type Chart = {
- apiVersion: string;
+type ChartTagList = {
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;
+ tags: string[];
};
-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 getChartToken = async () => {
+ return fetch(
+ `${env.REGISTRY_PROTOCOL}://${env.REGISTRY_HOSTNAME}/v2/service/token?service=harbor-registry&scope=repository:${env.CHART_PROJECT_NAME}/charts:pull`,
+ )
+ .then((res) => {
+ if (!res.ok) {
+ console.error(res);
+ throw new Error(res.statusText);
+ }
+ return res;
+ })
+ .then((res) => res.text())
+ .then((res) => JSON.parse(res))
+ .then((res) => {
+ return res.token;
+ });
};
-export const getChart = async (
- url: string,
- version?: string,
+const getChart = async (
+ repository: string,
+ version: string,
+ token: string,
): Promise => {
- const args = ["show", "chart"];
- if (version) {
- args.push("version", version);
- }
- args.push(url);
+ return fetch(
+ `${env.REGISTRY_PROTOCOL}://${env.REGISTRY_HOSTNAME}/v2/${repository}/manifests/${version}`,
+ {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ Accept: "application/vnd.oci.image.manifest.v1+json",
+ },
+ },
+ )
+ .then((res) => {
+ if (!res.ok) {
+ throw new Error(res.statusText);
+ }
+ return res;
+ })
+ .then((res) => res.text())
+ .then((res) => JSON.parse(res))
+ .then((res) => {
+ const annotations = res.annotations;
+ if ("anvilops-values" in annotations) {
+ return {
+ name: annotations["org.opencontainers.image.title"],
+ version: annotations["org.opencontainers.image.version"],
+ description: annotations["org.opencontainers.image.description"],
+ note: annotations["anvilops-note"],
+ values: JSON.parse(annotations["anvilops-values"]),
+ };
+ } else {
+ return null;
+ }
+ });
+};
+
+export const getLatestChart = async (
+ repository: string,
+ token: string,
+): Promise => {
+ const chartTagList = await fetch(
+ `${env.REGISTRY_PROTOCOL}://${env.REGISTRY_HOSTNAME}/v2/${repository}/tags/list`,
+ {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ },
+ )
+ .then((res) => {
+ if (!res.ok) {
+ throw new Error(res.statusText);
+ }
+ return res;
+ })
+ .then((res) => res.json() as Promise);
- const result = (await runHelm(args)) as string;
- const chart = (await yamlParse(result)) as Chart;
- return chart;
+ return await getChart(
+ chartTagList.name,
+ chartTagList.tags[chartTagList.tags.length - 1],
+ token,
+ );
};
export const upgrade = async (
diff --git a/backend/src/service/listCharts.ts b/backend/src/service/listCharts.ts
index fc96fe93..2adac627 100644
--- a/backend/src/service/listCharts.ts
+++ b/backend/src/service/listCharts.ts
@@ -1,6 +1,6 @@
import { getOrCreate } from "../lib/cache.ts";
import { env } from "../lib/env.ts";
-import { getChart } from "../lib/helm.ts";
+import { getChartToken, getLatestChart } from "../lib/helm.ts";
import { getRepositoriesByProject } from "../lib/registry.ts";
import { ValidationError } from "./common/errors.ts";
@@ -16,28 +16,23 @@ export async function listCharts() {
}
const listChartsFromRegistry = async () => {
- const repos = await getRepositoriesByProject(env.CHART_PROJECT_NAME);
+ const [repos, token] = await Promise.all([
+ getRepositoriesByProject(env.CHART_PROJECT_NAME),
+ getChartToken(),
+ ]);
+
const charts = await Promise.all(
repos.map(async (repo) => {
- const url = `oci://${env.REGISTRY_HOSTNAME}/${repo.name}`;
- return await getChart(url);
+ return await getLatestChart(repo.name, token);
}),
);
- if (charts.some((chart) => chart === null)) {
- throw new Error("Failed to get charts");
- }
-
- return charts
- .filter(
- (chart) => chart?.annotations && "anvilops-values" in chart?.annotations,
- )
- .map((chart) => ({
- name: chart.name,
- note: chart.annotations["anvilops-note"],
- url: `oci://${env.REGISTRY_HOSTNAME}/${chart.name}`,
- urlType: "oci",
- version: chart.version,
- valueSpec: JSON.parse(chart.annotations["anvilops-values"] ?? ""),
- }));
+ return charts.filter(Boolean).map((chart) => ({
+ name: chart.name,
+ note: chart.note,
+ url: `oci://${env.REGISTRY_HOSTNAME}/${env.CHART_PROJECT_NAME}/${chart.name}`,
+ urlType: "oci",
+ version: chart.version,
+ valueSpec: chart.values,
+ }));
};
diff --git a/frontend/src/components/diff/AppConfigDiff.tsx b/frontend/src/components/diff/AppConfigDiff.tsx
index 4f7ea9de..fc2db06b 100644
--- a/frontend/src/components/diff/AppConfigDiff.tsx
+++ b/frontend/src/components/diff/AppConfigDiff.tsx
@@ -12,7 +12,6 @@ import type { CommonFormFields } from "@/lib/form.types";
import { Cable } from "lucide-react";
import { useContext } from "react";
import type { App } from "../../pages/app/AppView";
-import { useAppConfig } from "../AppConfigProvider";
import { DiffSelect } from "./DiffSelect";
import { HelmConfigDiff } from "./helm/HelmConfigDiff";
import { CommonWorkloadConfigDiff } from "./workload/CommonWorkloadConfigDiff";
@@ -33,7 +32,7 @@ export const AppConfigDiff = ({
disabled?: boolean;
}) => {
const { user } = useContext(UserContext);
- const appConfig = useAppConfig();
+ // const appConfig = useAppConfig();
const selectedOrg = orgId
? user?.orgs?.find((it) => it.id === orgId)
: undefined;
From 75431b79a2a01d908812affbb3ccac450c4ebd3c Mon Sep 17 00:00:00 2001
From: zheng861
Date: Sun, 11 Jan 2026 22:22:58 -0500
Subject: [PATCH 28/38] Restore staging build-push workflow
---
.../build-publish-staging-anvilops.yml | 24 +++++++++++++++++++
1 file changed, 24 insertions(+)
create mode 100644 .github/workflows/build-publish-staging-anvilops.yml
diff --git a/.github/workflows/build-publish-staging-anvilops.yml b/.github/workflows/build-publish-staging-anvilops.yml
new file mode 100644
index 00000000..0431e964
--- /dev/null
+++ b/.github/workflows/build-publish-staging-anvilops.yml
@@ -0,0 +1,24 @@
+name: Build and Publish AnvilOps Docker image
+
+on:
+ workflow_dispatch:
+
+jobs:
+ push_to_registry:
+ name: Push AnvilOps Docker image to Harbor (Development)
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - name: Check out the repo
+ uses: actions/checkout@v6
+
+ - name: Log in to container registry
+ run: docker login -u '${{ secrets.DOCKER_USERNAME }}' -p '${{ secrets.DOCKER_PASSWORD }}' registry.anvil.rcac.purdue.edu
+
+ - name: Build and push AnvilOps Docker image
+ run: docker build --push -t registry.anvil.rcac.purdue.edu/anvilops-staging/anvilops:${{ github.run_number }}-${{ github.sha }} .
+
+ - name: Log out of container registry
+ if: always()
+ run: docker logout registry.anvil.rcac.purdue.edu
From 5893510dd6e4345944a876234878e6e36ed19c6f Mon Sep 17 00:00:00 2001
From: Emma Zheng
Date: Sun, 11 Jan 2026 22:30:07 -0500
Subject: [PATCH 29/38] Update credentials
---
.github/workflows/build-publish-staging-anvilops.yml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/build-publish-staging-anvilops.yml b/.github/workflows/build-publish-staging-anvilops.yml
index 0431e964..aa1d16da 100644
--- a/.github/workflows/build-publish-staging-anvilops.yml
+++ b/.github/workflows/build-publish-staging-anvilops.yml
@@ -1,11 +1,11 @@
-name: Build and Publish AnvilOps Docker image
+name: Build and Publish Staging Docker image
on:
workflow_dispatch:
jobs:
push_to_registry:
- name: Push AnvilOps Docker image to Harbor (Development)
+ name: Push AnvilOps Docker image to Harbor (Staging)
runs-on: ubuntu-latest
permissions:
contents: read
@@ -14,7 +14,7 @@ jobs:
uses: actions/checkout@v6
- name: Log in to container registry
- run: docker login -u '${{ secrets.DOCKER_USERNAME }}' -p '${{ secrets.DOCKER_PASSWORD }}' registry.anvil.rcac.purdue.edu
+ run: docker login -u '${{ secrets.DOCKER_USERNAME_STAGING }}' -p '${{ secrets.DOCKER_PASSWORD_STAGING }}' registry.anvil.rcac.purdue.edu
- name: Build and push AnvilOps Docker image
run: docker build --push -t registry.anvil.rcac.purdue.edu/anvilops-staging/anvilops:${{ github.run_number }}-${{ github.sha }} .
From 4590499bd747b88167cf88618ee4001ff7c925f7 Mon Sep 17 00:00:00 2001
From: zheng861
Date: Mon, 12 Jan 2026 16:20:08 -0500
Subject: [PATCH 30/38] Pass projectId in API call
---
frontend/src/components/config/ProjectConfig.tsx | 12 ++++++------
frontend/src/lib/form.ts | 1 +
frontend/src/pages/app/ConfigTab.tsx | 11 +----------
3 files changed, 8 insertions(+), 16 deletions(-)
diff --git a/frontend/src/components/config/ProjectConfig.tsx b/frontend/src/components/config/ProjectConfig.tsx
index f5735ede..dd968f13 100644
--- a/frontend/src/components/config/ProjectConfig.tsx
+++ b/frontend/src/components/config/ProjectConfig.tsx
@@ -1,16 +1,16 @@
-import type { CommonFormFields } from "@/lib/form.types";
+import { UserContext } from "@/components/UserProvider";
+import { Label } from "@/components/ui/label";
import {
Select,
- SelectTrigger,
- SelectValue,
SelectContent,
SelectGroup,
SelectItem,
+ SelectTrigger,
+ SelectValue,
} from "@/components/ui/select";
-import { Label } from "@/components/ui/label";
+import type { CommonFormFields } from "@/lib/form.types";
import { Fence } from "lucide-react";
import { useContext } from "react";
-import { UserContext } from "@/components/UserProvider";
export const ProjectConfig = ({
state,
@@ -58,7 +58,7 @@ export const ProjectConfig = ({
{user?.projects?.map((project) => (
-
+
{project.name}{" "}
diff --git a/frontend/src/lib/form.ts b/frontend/src/lib/form.ts
index 6c4b10b2..e07fe725 100644
--- a/frontend/src/lib/form.ts
+++ b/frontend/src/lib/form.ts
@@ -118,6 +118,7 @@ export const createNewAppWithoutGroup = (
return {
name: getAppName(appState),
namespace: generateNamespace(appState),
+ projectId: appState.projectId ?? undefined,
config: createDeploymentConfig(appState),
};
};
diff --git a/frontend/src/pages/app/ConfigTab.tsx b/frontend/src/pages/app/ConfigTab.tsx
index f4b067d7..d4e60dcd 100644
--- a/frontend/src/pages/app/ConfigTab.tsx
+++ b/frontend/src/pages/app/ConfigTab.tsx
@@ -1,9 +1,7 @@
-import { useAppConfig } from "@/components/AppConfigProvider";
import HelpTooltip from "@/components/HelpTooltip";
import { UserContext } from "@/components/UserProvider";
import { AppConfigFormFields } from "@/components/config/AppConfigFormFields";
import { GroupConfigFields } from "@/components/config/GroupConfigFields";
-import { ProjectConfig } from "@/components/config/ProjectConfig";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -40,7 +38,6 @@ export const ConfigTab = ({
);
}
- const appConfig = useAppConfig();
const [state, setState] = useState(
getFormStateFromApp(app),
);
@@ -69,10 +66,7 @@ export const ConfigTab = ({
body: {
displayName: state.displayName,
appGroup: groupState.groupOption,
- projectId:
- state.projectId && state.projectId !== app.projectId
- ? state.projectId
- : undefined,
+ projectId: state.projectId ?? undefined,
config: createDeploymentConfig(finalAppState),
},
});
@@ -139,9 +133,6 @@ export const ConfigTab = ({
/>
- {appConfig?.isRancherManaged && (
-
- )}
Date: Mon, 12 Jan 2026 17:02:58 -0500
Subject: [PATCH 31/38] Bug fixes Avoid cloning functions with structuredClone
Omit deploymentConfigId from configs Create app group after validating apps,
avoiding empty app groups when apps are invalid
---
backend/src/db/repo/app.ts | 4 ++--
backend/src/db/repo/appGroup.ts | 9 ++++++++
backend/src/db/repo/deployment.ts | 16 ++++++--------
backend/src/service/createApp.ts | 15 +++++++------
backend/src/service/createAppGroup.ts | 32 +++++++++++++--------------
5 files changed, 42 insertions(+), 34 deletions(-)
diff --git a/backend/src/db/repo/app.ts b/backend/src/db/repo/app.ts
index 345f30f2..d59fef63 100644
--- a/backend/src/db/repo/app.ts
+++ b/backend/src/db/repo/app.ts
@@ -221,8 +221,8 @@ export class AppRepo {
include: {
config: {
include: {
- workloadConfig: { omit: { id: true } },
- helmConfig: { omit: { id: true } },
+ workloadConfig: { omit: { id: true, deploymentConfigId: true } },
+ helmConfig: { omit: { id: true, deploymentConfigId: true } },
},
},
},
diff --git a/backend/src/db/repo/appGroup.ts b/backend/src/db/repo/appGroup.ts
index 12fe299d..19976e9c 100644
--- a/backend/src/db/repo/appGroup.ts
+++ b/backend/src/db/repo/appGroup.ts
@@ -30,6 +30,15 @@ export class AppGroupRepo {
}
}
+ async delete(appGroupId: number) {
+ if (
+ (await this.client.app.count({ where: { appGroupId: appGroupId } })) > 0
+ ) {
+ throw new Error("App group is not empty");
+ }
+ await this.client.appGroup.delete({ where: { id: appGroupId } });
+ }
+
async getById(appGroupId: number): Promise {
return await this.client.appGroup.findUnique({
where: { id: appGroupId },
diff --git a/backend/src/db/repo/deployment.ts b/backend/src/db/repo/deployment.ts
index 20b76676..6405dea1 100644
--- a/backend/src/db/repo/deployment.ts
+++ b/backend/src/db/repo/deployment.ts
@@ -184,8 +184,8 @@ export class DeploymentRepo {
select: {
config: {
include: {
- workloadConfig: { omit: { id: true } },
- helmConfig: { omit: { id: true } },
+ workloadConfig: { omit: { id: true, deploymentConfigId: true } },
+ helmConfig: { omit: { id: true, deploymentConfigId: true } },
},
},
},
@@ -205,8 +205,8 @@ export class DeploymentRepo {
static preprocessConfig(config: {
appType: AppType;
- workloadConfig?: Omit;
- helmConfig?: Omit;
+ workloadConfig?: Omit;
+ helmConfig?: Omit;
}): DeploymentConfig {
if (config === null) {
return null;
@@ -250,7 +250,7 @@ export class DeploymentRepo {
}
private static preprocessWorkloadConfig(
- config: Omit,
+ config: Omit,
): WorkloadConfig {
if (config === null) {
return null;
@@ -288,11 +288,9 @@ export class DeploymentRepo {
if (config === null) {
return null;
}
- const newConfig = structuredClone(config);
+ const { getEnv, displayEnv, asGitConfig, ...clonable } = config;
+ const newConfig = structuredClone(clonable);
const env = config.getEnv();
- delete newConfig.displayEnv;
- delete newConfig.getEnv;
-
return { ...newConfig, env };
}
diff --git a/backend/src/service/createApp.ts b/backend/src/service/createApp.ts
index 9ff18872..a4c76e44 100644
--- a/backend/src/service/createApp.ts
+++ b/backend/src/service/createApp.ts
@@ -26,6 +26,14 @@ export async function createApp(appData: NewApp, userId: number) {
}
let app: App;
+
+ let { config, commitMessage } = (
+ await appService.prepareMetadataForApps(organization, user, {
+ type: "create",
+ ...appData,
+ })
+ )[0];
+
let appGroupId: number;
switch (appData.appGroup.type) {
@@ -59,13 +67,6 @@ export async function createApp(appData: NewApp, userId: number) {
}
}
- let { config, commitMessage } = (
- await appService.prepareMetadataForApps(organization, user, {
- type: "create",
- ...appData,
- })
- )[0];
-
try {
app = await db.app.create({
orgId: appData.orgId,
diff --git a/backend/src/service/createAppGroup.ts b/backend/src/service/createAppGroup.ts
index 5ffadc3b..3ac0b368 100644
--- a/backend/src/service/createAppGroup.ts
+++ b/backend/src/service/createAppGroup.ts
@@ -19,25 +19,12 @@ export async function createAppGroup(
appData: NewAppWithoutGroup[],
) {
appService.validateAppGroupName(groupName);
- const groupId = await db.appGroup.create(orgId, groupName, false);
- // let groupId: number;
- // try {
- // groupId = await db.appGroup.create(orgId, groupName, false);
- // } catch (e) {
- // if (e instanceof ConflictError) {
- // throw new ValidationError(
- // "An app group already exists with the same name.",
- // );
- // }
- // throw e;
- // }
- const appsWithGroups = appData.map(
+ const apps = appData.map(
(app) =>
({
...app,
orgId: orgId,
- appGroup: { type: "add-to", id: groupId },
- }) satisfies NewApp,
+ }) satisfies Omit,
);
const [organization, user] = await Promise.all([
@@ -59,11 +46,24 @@ export async function createAppGroup(
})),
);
- const appsWithMetadata = appsWithGroups.map((app, idx) => ({
+ const appsWithMetadata = apps.map((app, idx) => ({
appData: app,
metadata: validationResults[idx],
}));
+ const groupId = await db.appGroup.create(orgId, groupName, false);
+ // let groupId: number;
+ // try {
+ // groupId = await db.appGroup.create(orgId, groupName, false);
+ // } catch (e) {
+ // if (e instanceof ConflictError) {
+ // throw new ValidationError(
+ // "An app group already exists with the same name.",
+ // );
+ // }
+ // throw e;
+ // }
+
for (const { appData, metadata } of appsWithMetadata) {
let { config, commitMessage } = metadata;
let app: App;
From 8e5d15ce7b7d4f5adf480c5042c7abd474bafa1b Mon Sep 17 00:00:00 2001
From: zheng861
Date: Tue, 13 Jan 2026 17:09:15 -0500
Subject: [PATCH 32/38] Update commit hash and image tag on webhook event
---
backend/src/service/githubWebhook.ts | 21 ++++++++++++++-----
.../src/service/helper/deploymentConfig.ts | 12 +++++++++++
2 files changed, 28 insertions(+), 5 deletions(-)
diff --git a/backend/src/service/githubWebhook.ts b/backend/src/service/githubWebhook.ts
index 37d7c105..92d02c74 100644
--- a/backend/src/service/githubWebhook.ts
+++ b/backend/src/service/githubWebhook.ts
@@ -1,5 +1,4 @@
import { db, NotFoundError } from "../db/index.ts";
-import { DeploymentRepo } from "../db/repo/deployment.ts";
import type { components } from "../generated/openapi.ts";
import { type LogStream, type LogType } from "../generated/prisma/enums.ts";
import { env } from "../lib/env.ts";
@@ -10,7 +9,7 @@ import {
UserNotFoundError,
ValidationError,
} from "./common/errors.ts";
-import { deploymentService } from "./helper/index.ts";
+import { deploymentConfigService, deploymentService } from "./helper/index.ts";
export async function processGitHubWebhookPayload(
event: string,
@@ -150,11 +149,16 @@ async function handlePush(payload: components["schemas"]["webhook-push"]) {
for (const app of apps) {
const org = await db.org.getById(app.orgId);
const oldConfig = (await db.app.getDeploymentConfig(app.id)).asGitConfig();
+ const config = deploymentConfigService.populateNewCommit(
+ oldConfig,
+ app,
+ payload.head_commit.id,
+ );
await deploymentService.create({
org,
app,
commitMessage: payload.head_commit.message,
- config: DeploymentRepo.cloneWorkloadConfig(oldConfig),
+ config,
git: {
checkRun: {
pending: false,
@@ -197,13 +201,20 @@ async function handleWorkflowRun(
if (payload.action === "requested") {
for (const app of apps) {
const org = await db.org.getById(app.orgId);
- const config = (await db.app.getDeploymentConfig(app.id)).asGitConfig();
+ const oldConfig = (
+ await db.app.getDeploymentConfig(app.id)
+ ).asGitConfig();
+ const config = deploymentConfigService.populateNewCommit(
+ oldConfig,
+ app,
+ payload.workflow_run.head_commit.id,
+ );
await deploymentService.create({
org,
app,
commitMessage: payload.workflow_run.head_commit.message,
workflowRunId: payload.workflow_run.id,
- config: DeploymentRepo.cloneWorkloadConfig(config),
+ config,
git: {
checkRun: {
pending: true,
diff --git a/backend/src/service/helper/deploymentConfig.ts b/backend/src/service/helper/deploymentConfig.ts
index b48f71ca..f5d4a215 100644
--- a/backend/src/service/helper/deploymentConfig.ts
+++ b/backend/src/service/helper/deploymentConfig.ts
@@ -2,6 +2,7 @@ import { Octokit } from "octokit";
import type {
App,
DeploymentConfig,
+ GitConfig,
GitConfigCreate,
HelmConfigCreate,
Organization,
@@ -9,6 +10,7 @@ import type {
WorkloadConfigCreate,
} from "../../db/models.ts";
import { AppRepo } from "../../db/repo/app.ts";
+import { DeploymentRepo } from "../../db/repo/deployment.ts";
import type { components } from "../../generated/openapi.ts";
import { MAX_SUBDOMAIN_LEN } from "../../lib/cluster/resources.ts";
import { getImageConfig } from "../../lib/cluster/resources/logs.ts";
@@ -136,6 +138,16 @@ export class DeploymentConfigService {
return config;
}
+ populateNewCommit(config: GitConfig, app: App, commitHash: string) {
+ return this.populateImageTag(
+ {
+ ...DeploymentRepo.cloneWorkloadConfig(config),
+ commitHash,
+ },
+ app,
+ );
+ }
+
private createCommonWorkloadConfig(
config: components["schemas"]["WorkloadConfigOptions"],
) {
From 4678fe493319224be05edd1e7d456e7139431e48 Mon Sep 17 00:00:00 2001
From: zheng861
Date: Tue, 13 Jan 2026 19:56:54 -0500
Subject: [PATCH 33/38] Fix bugs in workflow run handling
---
backend/src/service/githubWebhook.ts | 7 ++++---
openapi/ghes-3.16.yaml | 1 +
2 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/backend/src/service/githubWebhook.ts b/backend/src/service/githubWebhook.ts
index 92d02c74..6d9142d3 100644
--- a/backend/src/service/githubWebhook.ts
+++ b/backend/src/service/githubWebhook.ts
@@ -231,9 +231,6 @@ async function handleWorkflowRun(
app.id,
payload.workflow_run.id,
);
- const config = (
- await db.deployment.getConfig(deployment.id)
- ).asGitConfig();
if (!deployment || deployment.status !== "PENDING") {
// If the app was deleted, nothing to do
@@ -269,6 +266,10 @@ async function handleWorkflowRun(
continue;
}
+ const config = (
+ await db.deployment.getConfig(deployment.id)
+ ).asGitConfig();
+
await deploymentService.completeGitDeployment({
org,
app,
diff --git a/openapi/ghes-3.16.yaml b/openapi/ghes-3.16.yaml
index fb18f089..871d2281 100644
--- a/openapi/ghes-3.16.yaml
+++ b/openapi/ghes-3.16.yaml
@@ -2894,6 +2894,7 @@ components:
nullable: true
type: string
enum:
+ - null
- action_required
- cancelled
- failure
From c48dbf90074eb2597dd3eddebdd7c89628ebf82f Mon Sep 17 00:00:00 2001
From: zheng861
Date: Tue, 13 Jan 2026 20:45:48 -0500
Subject: [PATCH 34/38] Fix status bugs and missing projectId
---
backend/src/service/helper/deployment.ts | 9 ++++++++-
frontend/src/pages/app/overview/RedeployModal.tsx | 1 +
2 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/backend/src/service/helper/deployment.ts b/backend/src/service/helper/deployment.ts
index bc5fe351..ddc5b564 100644
--- a/backend/src/service/helper/deployment.ts
+++ b/backend/src/service/helper/deployment.ts
@@ -79,6 +79,7 @@ export class DeploymentService {
commitMessage,
workflowRunId,
config: configIn,
+ ...(git?.checkRun?.pending && { status: "PENDING" }),
});
const config = await this.deploymentRepo.getConfig(deployment.id);
@@ -517,7 +518,10 @@ export class DeploymentService {
let octokit: Octokit;
for (const deployment of deployments) {
- if (deployment.id !== deploymentId && !!deployment.checkRunId) {
+ if (deployment.id === deploymentId) {
+ continue;
+ }
+ if (!!deployment.checkRunId) {
// Should have a check run that is either queued or in_progress
if (!octokit) {
octokit = await this.getOctokitFn(org.githubInstallationId);
@@ -548,6 +552,9 @@ export class DeploymentService {
);
} catch (e) {}
}
+ if (deployment.status != "COMPLETE") {
+ await this.deploymentRepo.setStatus(deployment.id, "CANCELLED");
+ }
}
}
}
diff --git a/frontend/src/pages/app/overview/RedeployModal.tsx b/frontend/src/pages/app/overview/RedeployModal.tsx
index 695bed99..eb7204b9 100644
--- a/frontend/src/pages/app/overview/RedeployModal.tsx
+++ b/frontend/src/pages/app/overview/RedeployModal.tsx
@@ -152,6 +152,7 @@ export const RedeployModal = ({
params: { path: { appId: app.id } },
body: {
enableCD: redeployState.enableCD,
+ projectId: app.projectId,
config,
},
});
From caabacdcc5c0f36690cd6e9ca0f50d25f939aaaa Mon Sep 17 00:00:00 2001
From: zheng861
Date: Wed, 14 Jan 2026 12:09:26 -0500
Subject: [PATCH 35/38] Create namespace before running helm job Otherwise, the
job fails because the service user doesn't have permission to read inside a
nonexistent namespace. This also adds the Rancher annotations.
---
backend/src/lib/cluster/kubernetes.ts | 7 ++++--
backend/src/lib/helm.ts | 36 ++++++++++++++++++++-------
2 files changed, 32 insertions(+), 11 deletions(-)
diff --git a/backend/src/lib/cluster/kubernetes.ts b/backend/src/lib/cluster/kubernetes.ts
index 1096a3eb..a5cbdaad 100644
--- a/backend/src/lib/cluster/kubernetes.ts
+++ b/backend/src/lib/cluster/kubernetes.ts
@@ -103,7 +103,10 @@ export const namespaceInUse = async (namespace: string) => {
});
};
-const resourceExists = async (api: KubernetesObjectApi, data: K8sObject) => {
+export const resourceExists = async (
+ api: KubernetesObjectApi,
+ data: K8sObject,
+) => {
try {
await api.read(data);
return true;
@@ -121,7 +124,7 @@ const resourceExists = async (api: KubernetesObjectApi, data: K8sObject) => {
const REQUIRED_LABELS = env["RANCHER_API_BASE"]
? ["field.cattle.io/projectId", "lifecycle.cattle.io/create.namespace-auth"]
: [];
-const ensureNamespace = async (
+export const ensureNamespace = async (
api: KubernetesObjectApi,
namespace: V1Namespace & K8sObject,
) => {
diff --git a/backend/src/lib/helm.ts b/backend/src/lib/helm.ts
index 6e6378d1..5a1568aa 100644
--- a/backend/src/lib/helm.ts
+++ b/backend/src/lib/helm.ts
@@ -1,9 +1,14 @@
import { V1Pod } from "@kubernetes/client-node";
import { randomBytes } from "node:crypto";
import type { App, Deployment, HelmConfig } from "../db/models.ts";
-import { svcK8s } from "./cluster/kubernetes.ts";
+import {
+ ensureNamespace,
+ getClientForClusterUsername,
+ resourceExists,
+ svcK8s,
+} from "./cluster/kubernetes.ts";
import { shouldImpersonate } from "./cluster/rancher.ts";
-import { getNamespace } from "./cluster/resources.ts";
+import { createNamespaceConfig, getNamespace } from "./cluster/resources.ts";
import { wrapWithLogExporter } from "./cluster/resources/logs.ts";
import { env } from "./env.ts";
@@ -108,13 +113,26 @@ export const upgrade = async (
deployment: Deployment,
config: HelmConfig,
) => {
- const args = [
- "upgrade",
- "--install",
- "--namespace",
- getNamespace(app.namespace),
- "--create-namespace",
- ];
+ const namespaceName = getNamespace(app.namespace);
+
+ // Create namespace through Kubernetes API to ensure required Rancher annotations
+ const api = getClientForClusterUsername(
+ app.clusterUsername,
+ "KubernetesObjectApi",
+ shouldImpersonate(app.projectId),
+ );
+ const namespace = createNamespaceConfig(namespaceName, app.projectId);
+ if (!(await resourceExists(api, namespace))) {
+ try {
+ await ensureNamespace(api, namespace);
+ } catch (err) {
+ throw new Error(
+ `Failed to create namespace ${namespaceName}: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+ }
+
+ const args = ["upgrade", "--install", "--namespace", namespaceName];
const { urlType, url, version, values } = config;
const release = app.name;
From bd93fac7ecd47ca1a55027c6f26f91cb78008b9c Mon Sep 17 00:00:00 2001
From: zheng861
Date: Wed, 14 Jan 2026 12:47:58 -0500
Subject: [PATCH 36/38] Bug fixes in deletion and conflicts
---
backend/src/db/repo/app.ts | 8 ++++----
backend/src/lib/cluster/kubernetes.ts | 2 +-
backend/src/lib/registry.ts | 2 +-
3 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/backend/src/db/repo/app.ts b/backend/src/db/repo/app.ts
index d59fef63..2241dffd 100644
--- a/backend/src/db/repo/app.ts
+++ b/backend/src/db/repo/app.ts
@@ -124,10 +124,10 @@ export class AppRepo {
err.code === "P2002"
) {
// P2002 is "Unique Constraint Failed" - https://www.prisma.io/docs/orm/reference/error-reference#p2002
- throw new ConflictError(
- err.meta?.target as string /* column name */,
- err,
- );
+ const target = Array.isArray(err.meta?.target)
+ ? err.meta.target.join(", ")
+ : (err.meta?.target as string);
+ throw new ConflictError(target);
}
}
diff --git a/backend/src/lib/cluster/kubernetes.ts b/backend/src/lib/cluster/kubernetes.ts
index a5cbdaad..6fefbcda 100644
--- a/backend/src/lib/cluster/kubernetes.ts
+++ b/backend/src/lib/cluster/kubernetes.ts
@@ -159,7 +159,7 @@ export const deleteNamespace = async (
metadata: { name },
});
} catch (err) {
- if (err instanceof ApiException && err.code === 404) {
+ if (err instanceof ApiException && (err.code === 404 || err.code === 403)) {
return;
}
throw err;
diff --git a/backend/src/lib/registry.ts b/backend/src/lib/registry.ts
index d6014ffc..7b5f76d7 100644
--- a/backend/src/lib/registry.ts
+++ b/backend/src/lib/registry.ts
@@ -11,7 +11,7 @@ export async function deleteRepo(name: string) {
}
await fetch(
- `${env.REGISTRY_PROTOCOL}://${env.REGISTRY_HOSTNAME}/projects/${env.HARBOR_PROJECT_NAME}/repositories/${name}`,
+ `${env.REGISTRY_PROTOCOL}://${env.REGISTRY_HOSTNAME}/api/v2.0/projects/${env.HARBOR_PROJECT_NAME}/repositories/${name}`,
{
method: "DELETE",
headers,
From c53dfac42500a5c4304cf8d4364aa7086ddc95f7 Mon Sep 17 00:00:00 2001
From: zheng861
Date: Wed, 14 Jan 2026 16:09:13 -0500
Subject: [PATCH 37/38] Update charts Move hardcoded securityContext and
resources to values.yaml - Including the same key twice(once hardcoded and
once conditionally from values) resulted in yaml unmarshal errors
---
charts/anvilops/Chart.yaml | 2 +-
.../templates/anvilops/anvilops-deployment.yaml | 16 ----------------
charts/anvilops/values.yaml | 10 +++++++---
charts/spilo/Chart.yaml | 2 +-
4 files changed, 9 insertions(+), 21 deletions(-)
diff --git a/charts/anvilops/Chart.yaml b/charts/anvilops/Chart.yaml
index 482bc2f3..196d2d69 100644
--- a/charts/anvilops/Chart.yaml
+++ b/charts/anvilops/Chart.yaml
@@ -15,7 +15,7 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
-version: 0.1.1
+version: 0.1.2
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
diff --git a/charts/anvilops/templates/anvilops/anvilops-deployment.yaml b/charts/anvilops/templates/anvilops/anvilops-deployment.yaml
index ee567447..5b19b055 100644
--- a/charts/anvilops/templates/anvilops/anvilops-deployment.yaml
+++ b/charts/anvilops/templates/anvilops/anvilops-deployment.yaml
@@ -41,7 +41,6 @@ spec:
{{- end }}
image: {{ .Values.anvilops.image | quote }}
imagePullPolicy: {{ .Values.anvilops.imagePullPolicy }}
- args: ["./src/index.ts"]
ports:
- name: http
containerPort: 3000
@@ -239,21 +238,6 @@ spec:
- name: REGISTRY_PROTOCOL
value: {{ . }}
{{- end }}
- securityContext:
- capabilities:
- drop: [ALL]
- runAsNonRoot: true
- runAsUser: 65532
- runAsGroup: 65532
- readOnlyRootFilesystem: true
- allowPrivilegeEscalation: false
- resources:
- requests:
- cpu: 512m
- memory: 512Mi
- limits:
- cpu: 1000m
- memory: 1Gi
volumes:
- name: cluster-config
configMap:
diff --git a/charts/anvilops/values.yaml b/charts/anvilops/values.yaml
index 30dd1caa..fb349462 100644
--- a/charts/anvilops/values.yaml
+++ b/charts/anvilops/values.yaml
@@ -30,9 +30,13 @@ anvilops:
fullnameOverride: ""
securityContext:
- runAsUser: 1001
- runAsGroup: 1001
+ capabilities:
+ drop: [ALL]
runAsNonRoot: true
+ runAsUser: 65532
+ runAsGroup: 65532
+ readOnlyRootFilesystem: true
+ allowPrivilegeEscalation: false
# This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/
serviceAccount:
@@ -69,7 +73,7 @@ anvilops:
resources:
requests:
- cpu: 500m
+ cpu: 512m
memory: 512Mi
limits:
cpu: 1000m
diff --git a/charts/spilo/Chart.yaml b/charts/spilo/Chart.yaml
index 61a3235f..18456d7d 100644
--- a/charts/spilo/Chart.yaml
+++ b/charts/spilo/Chart.yaml
@@ -1,6 +1,6 @@
apiVersion: v1
name: spilo
-description: A minimal Spilo/Patroni HA Postgres deployment.
+description: A minimal Spilo/Patroni HA Postgres deployment, adapted from https://github.com/zalando/spilo/kubernetes.
type: application
version: 0.1.1
annotations:
From a84cd5ae23ba554ce0f0c550de61e57ecf8a3be8 Mon Sep 17 00:00:00 2001
From: Emma Zheng
Date: Thu, 15 Jan 2026 14:04:16 -0500
Subject: [PATCH 38/38] Remove commit step from workflow
---
.github/workflows/build-publish-anvilops.yml | 9 ---------
1 file changed, 9 deletions(-)
diff --git a/.github/workflows/build-publish-anvilops.yml b/.github/workflows/build-publish-anvilops.yml
index 1ffa8002..99a72e14 100644
--- a/.github/workflows/build-publish-anvilops.yml
+++ b/.github/workflows/build-publish-anvilops.yml
@@ -39,12 +39,3 @@ jobs:
- name: Log out of container registry (Geddes)
if: always()
run: docker logout geddes-registry.anvil.rcac.purdue.edu
-
- - name: Commit changes
- run: |
- git config --global user.name "github-actions[bot]"
- git config --global user.email "github-actions[bot]@users.noreply.github.com"
- git config pull.rebase true
- git add infra/anvil/values.yaml infra/geddes/values.yaml
- git commit -m "Update image to ${{ github.ref_name }}-${{ github.run_number }}-${{ github.sha }}" || echo "No changes to commit"
- git push || git pull && git push