Harden self-hosted deployment, scaling readiness, and air-gapped enforcement
swadhinbiswas committed 8 months ago
44a3c9f
1 parent 92ee81e
commit 44a3c9f
27 files changed
+1463 -60
Showing 27 changed files+1463-60
deploy/helm/opencodehub/Chart.yamldeploy/helm/opencodehub/templates/_helpers.tpldeploy/helm/opencodehub/templates/deployment.yamldeploy/helm/opencodehub/templates/hpa.yamldeploy/helm/opencodehub/templates/ingress.yamldeploy/helm/opencodehub/templates/networkpolicy.yamldeploy/helm/opencodehub/templates/pdb.yamldeploy/helm/opencodehub/templates/pvc.yamldeploy/helm/opencodehub/templates/secret.yamldeploy/helm/opencodehub/templates/service.yamldeploy/helm/opencodehub/templates/serviceaccount.yamldeploy/helm/opencodehub/templates/worker-deployment.yamldeploy/helm/opencodehub/templates/worker-hpa.yamldeploy/helm/opencodehub/values.yamldoc/feature_audit.mddocs-site/src/content/docs/administration/kubernetes.mddocs/administration/kubernetes.mdsrc/lib/queue-worker.tssrc/pages/api/health.tssrc/pages/api/repos/[owner]/[repo]/integrations/code-quality.tssrc/pages/api/repos/[owner]/[repo]/integrations/code-quality/[id].tssrc/pages/api/repos/[owner]/[repo]/integrations/external-ci.tssrc/pages/api/repos/[owner]/[repo]/integrations/external-ci/[id].tssrc/pages/api/repos/[owner]/[repo]/integrations/issue-trackers.tssrc/pages/api/repos/[owner]/[repo]/integrations/issue-trackers/[id].tstests/integration/air-gapped-api.test.tstests/unit/health-route.test.ts
▼deploy/helm/opencodehub/Chart.yaml
+7-0
| @@ -0,0 +1,7 @@ | ||
1 | +apiVersion: v2 | |
2 | +name: opencodehub | |
3 | +description: OpenCodeHub deployment chart | |
4 | +type: application | |
5 | +version: 0.1.0 | |
6 | +appVersion: "0.1.0" | |
7 | + | |
0 | 8 | |
▼deploy/helm/opencodehub/templates/_helpers.tpl
+32-0
| @@ -0,0 +1,32 @@ | ||
1 | +{{- define "opencodehub.name" -}} | |
2 | +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} | |
3 | +{{- end -}} | |
4 | + | |
5 | +{{- define "opencodehub.fullname" -}} | |
6 | +{{- if .Values.fullnameOverride -}} | |
7 | +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} | |
8 | +{{- else -}} | |
9 | +{{- printf "%s-%s" .Release.Name (include "opencodehub.name" .) | trunc 63 | trimSuffix "-" -}} | |
10 | +{{- end -}} | |
11 | +{{- end -}} | |
12 | + | |
13 | +{{- define "opencodehub.labels" -}} | |
14 | +app.kubernetes.io/name: {{ include "opencodehub.name" . }} | |
15 | +app.kubernetes.io/instance: {{ .Release.Name }} | |
16 | +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} | |
17 | +app.kubernetes.io/managed-by: {{ .Release.Service }} | |
18 | +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }} | |
19 | +{{- end -}} | |
20 | + | |
21 | +{{- define "opencodehub.selectorLabels" -}} | |
22 | +app.kubernetes.io/name: {{ include "opencodehub.name" . }} | |
23 | +app.kubernetes.io/instance: {{ .Release.Name }} | |
24 | +{{- end -}} | |
25 | + | |
26 | +{{- define "opencodehub.serviceAccountName" -}} | |
27 | +{{- if .Values.serviceAccount.create -}} | |
28 | +{{- default (include "opencodehub.fullname" .) .Values.serviceAccount.name -}} | |
29 | +{{- else -}} | |
30 | +{{- default "default" .Values.serviceAccount.name -}} | |
31 | +{{- end -}} | |
32 | +{{- end -}} | |
0 | 33 | |
▼deploy/helm/opencodehub/templates/deployment.yaml
+115-0
| @@ -0,0 +1,115 @@ | ||
1 | +apiVersion: apps/v1 | |
2 | +kind: Deployment | |
3 | +metadata: | |
4 | + name: {{ include "opencodehub.fullname" . }} | |
5 | + labels: | |
6 | + {{- include "opencodehub.labels" . | nindent 4 }} | |
7 | + {{- with .Values.deploymentAnnotations }} | |
8 | + annotations: | |
9 | + {{- toYaml . | nindent 4 }} | |
10 | + {{- end }} | |
11 | +spec: | |
12 | + replicas: {{ .Values.replicaCount }} | |
13 | + selector: | |
14 | + matchLabels: | |
15 | + {{- include "opencodehub.selectorLabels" . | nindent 6 }} | |
16 | + app.kubernetes.io/component: web | |
17 | + template: | |
18 | + metadata: | |
19 | + labels: | |
20 | + {{- include "opencodehub.selectorLabels" . | nindent 8 }} | |
21 | + app.kubernetes.io/component: web | |
22 | + {{- with .Values.podLabels }} | |
23 | + {{- toYaml . | nindent 8 }} | |
24 | + {{- end }} | |
25 | + {{- with .Values.podAnnotations }} | |
26 | + annotations: | |
27 | + {{- toYaml . | nindent 8 }} | |
28 | + {{- end }} | |
29 | + spec: | |
30 | + serviceAccountName: {{ include "opencodehub.serviceAccountName" . }} | |
31 | + {{- with .Values.imagePullSecrets }} | |
32 | + imagePullSecrets: | |
33 | + {{- toYaml . | nindent 8 }} | |
34 | + {{- end }} | |
35 | + securityContext: | |
36 | + {{- toYaml .Values.podSecurityContext | nindent 8 }} | |
37 | + terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }} | |
38 | + containers: | |
39 | + - name: opencodehub | |
40 | + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" | |
41 | + imagePullPolicy: {{ .Values.image.pullPolicy }} | |
42 | + securityContext: | |
43 | + {{- toYaml .Values.securityContext | nindent 12 }} | |
44 | + ports: | |
45 | + - containerPort: {{ .Values.service.port }} | |
46 | + name: http | |
47 | + env: | |
48 | + {{- range $key, $value := .Values.env }} | |
49 | + - name: {{ $key }} | |
50 | + value: {{ $value | quote }} | |
51 | + {{- end }} | |
52 | + {{- with .Values.extraEnv }} | |
53 | + {{- toYaml . | nindent 12 }} | |
54 | + {{- end }} | |
55 | + envFrom: | |
56 | + - secretRef: | |
57 | + name: {{ default (printf "%s-env" (include "opencodehub.fullname" .)) .Values.envSecrets.existingSecretName }} | |
58 | + {{- with .Values.extraEnvFrom }} | |
59 | + {{- toYaml . | nindent 12 }} | |
60 | + {{- end }} | |
61 | + resources: | |
62 | + {{- toYaml .Values.resources | nindent 12 }} | |
63 | + {{- if .Values.probes.startup.enabled }} | |
64 | + startupProbe: | |
65 | + httpGet: | |
66 | + path: {{ .Values.probes.startup.path }} | |
67 | + port: http | |
68 | + initialDelaySeconds: {{ .Values.probes.startup.initialDelaySeconds }} | |
69 | + periodSeconds: {{ .Values.probes.startup.periodSeconds }} | |
70 | + timeoutSeconds: {{ .Values.probes.startup.timeoutSeconds }} | |
71 | + failureThreshold: {{ .Values.probes.startup.failureThreshold }} | |
72 | + {{- end }} | |
73 | + livenessProbe: | |
74 | + httpGet: | |
75 | + path: {{ .Values.probes.liveness.path }} | |
76 | + port: http | |
77 | + initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }} | |
78 | + periodSeconds: {{ .Values.probes.liveness.periodSeconds }} | |
79 | + timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }} | |
80 | + failureThreshold: {{ .Values.probes.liveness.failureThreshold }} | |
81 | + readinessProbe: | |
82 | + httpGet: | |
83 | + path: {{ .Values.probes.readiness.path }} | |
84 | + port: http | |
85 | + initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }} | |
86 | + periodSeconds: {{ .Values.probes.readiness.periodSeconds }} | |
87 | + timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }} | |
88 | + failureThreshold: {{ .Values.probes.readiness.failureThreshold }} | |
89 | + volumeMounts: | |
90 | + - name: data | |
91 | + mountPath: /data | |
92 | + volumes: | |
93 | + - name: data | |
94 | + {{- if .Values.persistence.enabled }} | |
95 | + persistentVolumeClaim: | |
96 | + claimName: {{ include "opencodehub.fullname" . }}-data | |
97 | + {{- else }} | |
98 | + emptyDir: {} | |
99 | + {{- end }} | |
100 | + {{- with .Values.nodeSelector }} | |
101 | + nodeSelector: | |
102 | + {{- toYaml . | nindent 8 }} | |
103 | + {{- end }} | |
104 | + {{- with .Values.affinity }} | |
105 | + affinity: | |
106 | + {{- toYaml . | nindent 8 }} | |
107 | + {{- end }} | |
108 | + {{- with .Values.tolerations }} | |
109 | + tolerations: | |
110 | + {{- toYaml . | nindent 8 }} | |
111 | + {{- end }} | |
112 | + {{- with .Values.topologySpreadConstraints }} | |
113 | + topologySpreadConstraints: | |
114 | + {{- toYaml . | nindent 8 }} | |
115 | + {{- end }} | |
0 | 116 | |
▼deploy/helm/opencodehub/templates/hpa.yaml
+30-0
| @@ -0,0 +1,30 @@ | ||
1 | +{{- if .Values.autoscaling.enabled }} | |
2 | +apiVersion: autoscaling/v2 | |
3 | +kind: HorizontalPodAutoscaler | |
4 | +metadata: | |
5 | + name: {{ include "opencodehub.fullname" . }} | |
6 | + labels: | |
7 | + {{- include "opencodehub.labels" . | nindent 4 }} | |
8 | +spec: | |
9 | + scaleTargetRef: | |
10 | + apiVersion: apps/v1 | |
11 | + kind: Deployment | |
12 | + name: {{ include "opencodehub.fullname" . }} | |
13 | + minReplicas: {{ .Values.autoscaling.minReplicas }} | |
14 | + maxReplicas: {{ .Values.autoscaling.maxReplicas }} | |
15 | + metrics: | |
16 | + - type: Resource | |
17 | + resource: | |
18 | + name: cpu | |
19 | + target: | |
20 | + type: Utilization | |
21 | + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} | |
22 | + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} | |
23 | + - type: Resource | |
24 | + resource: | |
25 | + name: memory | |
26 | + target: | |
27 | + type: Utilization | |
28 | + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} | |
29 | + {{- end }} | |
30 | +{{- end }} | |
0 | 31 | |
▼deploy/helm/opencodehub/templates/ingress.yaml
+36-0
| @@ -0,0 +1,36 @@ | ||
1 | +{{- if .Values.ingress.enabled }} | |
2 | +apiVersion: networking.k8s.io/v1 | |
3 | +kind: Ingress | |
4 | +metadata: | |
5 | + name: {{ include "opencodehub.fullname" . }} | |
6 | + labels: | |
7 | + {{- include "opencodehub.labels" . | nindent 4 }} | |
8 | + {{- with .Values.ingress.annotations }} | |
9 | + annotations: | |
10 | + {{- toYaml . | nindent 4 }} | |
11 | + {{- end }} | |
12 | +spec: | |
13 | + {{- if .Values.ingress.className }} | |
14 | + ingressClassName: {{ .Values.ingress.className }} | |
15 | + {{- end }} | |
16 | + rules: | |
17 | + {{- range .Values.ingress.hosts }} | |
18 | + - host: {{ .host | quote }} | |
19 | + http: | |
20 | + paths: | |
21 | + {{- range .paths }} | |
22 | + - path: {{ .path }} | |
23 | + pathType: {{ .pathType }} | |
24 | + backend: | |
25 | + service: | |
26 | + name: {{ include "opencodehub.fullname" $ }} | |
27 | + port: | |
28 | + number: {{ $.Values.service.port }} | |
29 | + {{- end }} | |
30 | + {{- end }} | |
31 | + {{- if .Values.ingress.tls }} | |
32 | + tls: | |
33 | + {{- toYaml .Values.ingress.tls | nindent 4 }} | |
34 | + {{- end }} | |
35 | +{{- end }} | |
36 | + | |
0 | 37 | |
▼deploy/helm/opencodehub/templates/networkpolicy.yaml
+38-0
| @@ -0,0 +1,38 @@ | ||
1 | +{{- if .Values.networkPolicy.enabled }} | |
2 | +apiVersion: networking.k8s.io/v1 | |
3 | +kind: NetworkPolicy | |
4 | +metadata: | |
5 | + name: {{ include "opencodehub.fullname" . }} | |
6 | + labels: | |
7 | + {{- include "opencodehub.labels" . | nindent 4 }} | |
8 | +spec: | |
9 | + podSelector: | |
10 | + matchLabels: | |
11 | + {{- include "opencodehub.selectorLabels" . | nindent 6 }} | |
12 | + policyTypes: | |
13 | + - Ingress | |
14 | + - Egress | |
15 | + ingress: | |
16 | + - {{- if or .Values.networkPolicy.allowSameNamespace .Values.networkPolicy.additionalIngress }} | |
17 | + from: | |
18 | + {{- if .Values.networkPolicy.allowSameNamespace }} | |
19 | + - podSelector: {} | |
20 | + {{- end }} | |
21 | + {{- with .Values.networkPolicy.additionalIngress }} | |
22 | + {{- toYaml . | nindent 8 }} | |
23 | + {{- end }} | |
24 | + {{- end }} | |
25 | + ports: | |
26 | + - protocol: TCP | |
27 | + port: {{ .Values.service.port }} | |
28 | + egress: | |
29 | + - {{- if or .Values.networkPolicy.allowSameNamespace .Values.networkPolicy.additionalEgress }} | |
30 | + to: | |
31 | + {{- if .Values.networkPolicy.allowSameNamespace }} | |
32 | + - podSelector: {} | |
33 | + {{- end }} | |
34 | + {{- with .Values.networkPolicy.additionalEgress }} | |
35 | + {{- toYaml . | nindent 8 }} | |
36 | + {{- end }} | |
37 | + {{- end }} | |
38 | +{{- end }} | |
0 | 39 | |
▼deploy/helm/opencodehub/templates/pdb.yaml
+14-0
| @@ -0,0 +1,14 @@ | ||
1 | +{{- if .Values.podDisruptionBudget.enabled }} | |
2 | +apiVersion: policy/v1 | |
3 | +kind: PodDisruptionBudget | |
4 | +metadata: | |
5 | + name: {{ include "opencodehub.fullname" . }} | |
6 | + labels: | |
7 | + {{- include "opencodehub.labels" . | nindent 4 }} | |
8 | +spec: | |
9 | + minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} | |
10 | + selector: | |
11 | + matchLabels: | |
12 | + {{- include "opencodehub.selectorLabels" . | nindent 6 }} | |
13 | + app.kubernetes.io/component: web | |
14 | +{{- end }} | |
0 | 15 | |
▼deploy/helm/opencodehub/templates/pvc.yaml
+17-0
| @@ -0,0 +1,17 @@ | ||
1 | +{{- if .Values.persistence.enabled }} | |
2 | +apiVersion: v1 | |
3 | +kind: PersistentVolumeClaim | |
4 | +metadata: | |
5 | + name: {{ include "opencodehub.fullname" . }}-data | |
6 | + labels: | |
7 | + {{- include "opencodehub.labels" . | nindent 4 }} | |
8 | +spec: | |
9 | + accessModes: | |
10 | + {{- toYaml .Values.persistence.accessModes | nindent 4 }} | |
11 | + resources: | |
12 | + requests: | |
13 | + storage: {{ .Values.persistence.size }} | |
14 | + {{- if .Values.persistence.storageClassName }} | |
15 | + storageClassName: {{ .Values.persistence.storageClassName }} | |
16 | + {{- end }} | |
17 | +{{- end }} | |
0 | 18 | |
▼deploy/helm/opencodehub/templates/secret.yaml
+16-0
| @@ -0,0 +1,16 @@ | ||
1 | +{{- if .Values.envSecrets.create }} | |
2 | +apiVersion: v1 | |
3 | +kind: Secret | |
4 | +metadata: | |
5 | + name: {{ include "opencodehub.fullname" . }}-env | |
6 | + labels: | |
7 | + {{- include "opencodehub.labels" . | nindent 4 }} | |
8 | +type: Opaque | |
9 | +stringData: | |
10 | + DATABASE_URL: {{ .Values.envSecrets.DATABASE_URL | quote }} | |
11 | + REDIS_URL: {{ .Values.envSecrets.REDIS_URL | quote }} | |
12 | + JWT_SECRET: {{ .Values.envSecrets.JWT_SECRET | quote }} | |
13 | + SERVER_URL: {{ .Values.envSecrets.SERVER_URL | quote }} | |
14 | + INTERNAL_HOOK_SECRET: {{ .Values.envSecrets.INTERNAL_HOOK_SECRET | quote }} | |
15 | + RUNNER_SECRET: {{ .Values.envSecrets.RUNNER_SECRET | quote }} | |
16 | +{{- end }} | |
0 | 17 | |
▼deploy/helm/opencodehub/templates/service.yaml
+15-0
| @@ -0,0 +1,15 @@ | ||
1 | +apiVersion: v1 | |
2 | +kind: Service | |
3 | +metadata: | |
4 | + name: {{ include "opencodehub.fullname" . }} | |
5 | + labels: | |
6 | + {{- include "opencodehub.labels" . | nindent 4 }} | |
7 | +spec: | |
8 | + type: {{ .Values.service.type }} | |
9 | + selector: | |
10 | + {{- include "opencodehub.selectorLabels" . | nindent 4 }} | |
11 | + app.kubernetes.io/component: web | |
12 | + ports: | |
13 | + - name: http | |
14 | + port: {{ .Values.service.port }} | |
15 | + targetPort: http | |
0 | 16 | |
▼deploy/helm/opencodehub/templates/serviceaccount.yaml
+12-0
| @@ -0,0 +1,12 @@ | ||
1 | +{{- if .Values.serviceAccount.create }} | |
2 | +apiVersion: v1 | |
3 | +kind: ServiceAccount | |
4 | +metadata: | |
5 | + name: {{ include "opencodehub.serviceAccountName" . }} | |
6 | + labels: | |
7 | + {{- include "opencodehub.labels" . | nindent 4 }} | |
8 | + {{- with .Values.serviceAccount.annotations }} | |
9 | + annotations: | |
10 | + {{- toYaml . | nindent 4 }} | |
11 | + {{- end }} | |
12 | +{{- end }} | |
0 | 13 | |
▼deploy/helm/opencodehub/templates/worker-deployment.yaml
+98-0
| @@ -0,0 +1,98 @@ | ||
1 | +{{- if .Values.worker.enabled }} | |
2 | +apiVersion: apps/v1 | |
3 | +kind: Deployment | |
4 | +metadata: | |
5 | + name: {{ include "opencodehub.fullname" . }}-worker | |
6 | + labels: | |
7 | + {{- include "opencodehub.labels" . | nindent 4 }} | |
8 | + app.kubernetes.io/component: worker | |
9 | +spec: | |
10 | + replicas: {{ .Values.worker.replicaCount }} | |
11 | + selector: | |
12 | + matchLabels: | |
13 | + {{- include "opencodehub.selectorLabels" . | nindent 6 }} | |
14 | + app.kubernetes.io/component: worker | |
15 | + template: | |
16 | + metadata: | |
17 | + labels: | |
18 | + {{- include "opencodehub.selectorLabels" . | nindent 8 }} | |
19 | + app.kubernetes.io/component: worker | |
20 | + {{- with .Values.podLabels }} | |
21 | + {{- toYaml . | nindent 8 }} | |
22 | + {{- end }} | |
23 | + {{- with .Values.podAnnotations }} | |
24 | + annotations: | |
25 | + {{- toYaml . | nindent 8 }} | |
26 | + {{- end }} | |
27 | + spec: | |
28 | + serviceAccountName: {{ include "opencodehub.serviceAccountName" . }} | |
29 | + {{- with .Values.imagePullSecrets }} | |
30 | + imagePullSecrets: | |
31 | + {{- toYaml . | nindent 8 }} | |
32 | + {{- end }} | |
33 | + securityContext: | |
34 | + {{- toYaml .Values.podSecurityContext | nindent 8 }} | |
35 | + containers: | |
36 | + - name: opencodehub-worker | |
37 | + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" | |
38 | + imagePullPolicy: {{ .Values.image.pullPolicy }} | |
39 | + securityContext: | |
40 | + {{- toYaml .Values.securityContext | nindent 12 }} | |
41 | + env: | |
42 | + {{- range $key, $value := .Values.env }} | |
43 | + - name: {{ $key }} | |
44 | + value: {{ $value | quote }} | |
45 | + {{- end }} | |
46 | + - name: OPENCODEHUB_WORKER_MODE | |
47 | + value: "true" | |
48 | + {{- with .Values.extraEnv }} | |
49 | + {{- toYaml . | nindent 12 }} | |
50 | + {{- end }} | |
51 | + envFrom: | |
52 | + - secretRef: | |
53 | + name: {{ default (printf "%s-env" (include "opencodehub.fullname" .)) .Values.envSecrets.existingSecretName }} | |
54 | + {{- with .Values.extraEnvFrom }} | |
55 | + {{- toYaml . | nindent 12 }} | |
56 | + {{- end }} | |
57 | + resources: | |
58 | + {{- toYaml .Values.worker.resources | nindent 12 }} | |
59 | + livenessProbe: | |
60 | + httpGet: | |
61 | + path: {{ .Values.probes.liveness.path }} | |
62 | + port: {{ .Values.service.port }} | |
63 | + initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }} | |
64 | + periodSeconds: {{ .Values.probes.liveness.periodSeconds }} | |
65 | + timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }} | |
66 | + failureThreshold: {{ .Values.probes.liveness.failureThreshold }} | |
67 | + readinessProbe: | |
68 | + httpGet: | |
69 | + path: {{ .Values.probes.readiness.path }} | |
70 | + port: {{ .Values.service.port }} | |
71 | + initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }} | |
72 | + periodSeconds: {{ .Values.probes.readiness.periodSeconds }} | |
73 | + timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }} | |
74 | + failureThreshold: {{ .Values.probes.readiness.failureThreshold }} | |
75 | + volumeMounts: | |
76 | + - name: data | |
77 | + mountPath: /data | |
78 | + volumes: | |
79 | + - name: data | |
80 | + {{- if .Values.persistence.enabled }} | |
81 | + persistentVolumeClaim: | |
82 | + claimName: {{ include "opencodehub.fullname" . }}-data | |
83 | + {{- else }} | |
84 | + emptyDir: {} | |
85 | + {{- end }} | |
86 | + {{- with .Values.nodeSelector }} | |
87 | + nodeSelector: | |
88 | + {{- toYaml . | nindent 8 }} | |
89 | + {{- end }} | |
90 | + {{- with .Values.affinity }} | |
91 | + affinity: | |
92 | + {{- toYaml . | nindent 8 }} | |
93 | + {{- end }} | |
94 | + {{- with .Values.tolerations }} | |
95 | + tolerations: | |
96 | + {{- toYaml . | nindent 8 }} | |
97 | + {{- end }} | |
98 | +{{- end }} | |
0 | 99 | |
▼deploy/helm/opencodehub/templates/worker-hpa.yaml
+23-0
| @@ -0,0 +1,23 @@ | ||
1 | +{{- if and .Values.worker.enabled .Values.worker.autoscaling.enabled }} | |
2 | +apiVersion: autoscaling/v2 | |
3 | +kind: HorizontalPodAutoscaler | |
4 | +metadata: | |
5 | + name: {{ include "opencodehub.fullname" . }}-worker | |
6 | + labels: | |
7 | + {{- include "opencodehub.labels" . | nindent 4 }} | |
8 | + app.kubernetes.io/component: worker | |
9 | +spec: | |
10 | + scaleTargetRef: | |
11 | + apiVersion: apps/v1 | |
12 | + kind: Deployment | |
13 | + name: {{ include "opencodehub.fullname" . }}-worker | |
14 | + minReplicas: {{ .Values.worker.autoscaling.minReplicas }} | |
15 | + maxReplicas: {{ .Values.worker.autoscaling.maxReplicas }} | |
16 | + metrics: | |
17 | + - type: Resource | |
18 | + resource: | |
19 | + name: cpu | |
20 | + target: | |
21 | + type: Utilization | |
22 | + averageUtilization: {{ .Values.worker.autoscaling.targetCPUUtilizationPercentage }} | |
23 | +{{- end }} | |
0 | 24 | |
▼deploy/helm/opencodehub/values.yaml
+148-0
| @@ -0,0 +1,148 @@ | ||
1 | +replicaCount: 2 | |
2 | + | |
3 | +image: | |
4 | + repository: ghcr.io/opencodehub/opencodehub | |
5 | + tag: latest | |
6 | + pullPolicy: IfNotPresent | |
7 | + | |
8 | +imagePullSecrets: [] | |
9 | +nameOverride: "" | |
10 | +fullnameOverride: "" | |
11 | + | |
12 | +serviceAccount: | |
13 | + create: true | |
14 | + annotations: {} | |
15 | + name: "" | |
16 | + | |
17 | +deploymentAnnotations: {} | |
18 | +podAnnotations: {} | |
19 | +podLabels: {} | |
20 | + | |
21 | +podSecurityContext: | |
22 | + fsGroup: 1000 | |
23 | + | |
24 | +securityContext: | |
25 | + allowPrivilegeEscalation: false | |
26 | + capabilities: | |
27 | + drop: | |
28 | + - ALL | |
29 | + readOnlyRootFilesystem: false | |
30 | + runAsNonRoot: true | |
31 | + runAsUser: 1000 | |
32 | + runAsGroup: 1000 | |
33 | + | |
34 | +service: | |
35 | + type: ClusterIP | |
36 | + port: 4321 | |
37 | + | |
38 | +ingress: | |
39 | + enabled: false | |
40 | + className: "" | |
41 | + annotations: {} | |
42 | + hosts: | |
43 | + - host: opencodehub.local | |
44 | + paths: | |
45 | + - path: / | |
46 | + pathType: Prefix | |
47 | + tls: [] | |
48 | + | |
49 | +resources: | |
50 | + requests: | |
51 | + cpu: 250m | |
52 | + memory: 512Mi | |
53 | + limits: | |
54 | + cpu: "1" | |
55 | + memory: 2Gi | |
56 | + | |
57 | +nodeSelector: {} | |
58 | +tolerations: [] | |
59 | +affinity: {} | |
60 | +topologySpreadConstraints: [] | |
61 | + | |
62 | +terminationGracePeriodSeconds: 30 | |
63 | + | |
64 | +autoscaling: | |
65 | + enabled: true | |
66 | + minReplicas: 2 | |
67 | + maxReplicas: 10 | |
68 | + targetCPUUtilizationPercentage: 70 | |
69 | + targetMemoryUtilizationPercentage: null | |
70 | + | |
71 | +env: | |
72 | + NODE_ENV: production | |
73 | + HOST: 0.0.0.0 | |
74 | + PORT: "4321" | |
75 | + DATA_DIR: /data | |
76 | + REPOS_PATH: /data/repos | |
77 | + STORAGE_PATH: /data/storage | |
78 | + CACHE_PATH: /data/cache | |
79 | + SSH_PATH: /data/ssh | |
80 | + DATABASE_TYPE: postgres | |
81 | + STORAGE_TYPE: local | |
82 | + | |
83 | +envSecrets: | |
84 | + create: true | |
85 | + existingSecretName: "" | |
86 | + DATABASE_URL: "" | |
87 | + REDIS_URL: "" | |
88 | + JWT_SECRET: "" | |
89 | + SERVER_URL: "" | |
90 | + INTERNAL_HOOK_SECRET: "" | |
91 | + RUNNER_SECRET: "" | |
92 | + | |
93 | +extraEnv: [] | |
94 | +extraEnvFrom: [] | |
95 | + | |
96 | +probes: | |
97 | + liveness: | |
98 | + path: /api/health | |
99 | + initialDelaySeconds: 20 | |
100 | + periodSeconds: 20 | |
101 | + timeoutSeconds: 3 | |
102 | + failureThreshold: 6 | |
103 | + readiness: | |
104 | + path: /api/health | |
105 | + initialDelaySeconds: 10 | |
106 | + periodSeconds: 10 | |
107 | + timeoutSeconds: 3 | |
108 | + failureThreshold: 6 | |
109 | + startup: | |
110 | + enabled: true | |
111 | + path: /api/health | |
112 | + initialDelaySeconds: 5 | |
113 | + periodSeconds: 5 | |
114 | + timeoutSeconds: 3 | |
115 | + failureThreshold: 30 | |
116 | + | |
117 | +persistence: | |
118 | + enabled: true | |
119 | + storageClassName: "" | |
120 | + accessModes: | |
121 | + - ReadWriteOnce | |
122 | + size: 20Gi | |
123 | + | |
124 | +podDisruptionBudget: | |
125 | + enabled: true | |
126 | + minAvailable: 1 | |
127 | + | |
128 | +networkPolicy: | |
129 | + enabled: false | |
130 | + allowSameNamespace: true | |
131 | + additionalIngress: [] | |
132 | + additionalEgress: [] | |
133 | + | |
134 | +worker: | |
135 | + enabled: false | |
136 | + replicaCount: 1 | |
137 | + resources: | |
138 | + requests: | |
139 | + cpu: 150m | |
140 | + memory: 256Mi | |
141 | + limits: | |
142 | + cpu: 500m | |
143 | + memory: 1Gi | |
144 | + autoscaling: | |
145 | + enabled: false | |
146 | + minReplicas: 1 | |
147 | + maxReplicas: 5 | |
148 | + targetCPUUtilizationPercentage: 70 | |
0 | 149 | |
▼doc/feature_audit.md
+18-18
| @@ -14,12 +14,12 @@ | ||
14 | 14 | | **CI/CD & Automation** | 7 | 1 | 0 | 8 | |
15 | 15 | | **Third-Party Integrations** | 22 | 0 | 0 | 22 | |
16 | 16 | | **Dependency & Impact Awareness** | 5 | 0 | 0 | 5 | |
17 | -| **Security** | 9 | 3 | 0 | 12 | | |
18 | -| **Analytics & Insights** | 6 | 2 | 0 | 8 | | |
19 | -| **Notifications & Collaboration** | 5 | 3 | 0 | 8 | | |
20 | -| **Interfaces & Extensibility** | 5 | 2 | 0 | 7 | | |
17 | +| **Security** | 12 | 0 | 0 | 12 | | |
18 | +| **Analytics & Insights** | 8 | 0 | 0 | 8 | | |
19 | +| **Notifications & Collaboration** | 8 | 0 | 0 | 8 | | |
20 | +| **Interfaces & Extensibility** | 7 | 0 | 0 | 7 | | |
21 | 21 | | **Self-Hosted & Deployment** | 4 | 3 | 0 | 7 | |
22 | -| **Total** | **100** | **25** | **0** | **125** | | |
22 | +| **Total** | **110** | **15** | **0** | **125** | | |
23 | 23 | |
24 | 24 | --- |
25 | 25 | |
| @@ -154,13 +154,13 @@ | ||
154 | 154 | ## 8. Security |
155 | 155 | | Feature | Status | Implementation Notes | |
156 | 156 | | :--- | :---: | :--- | |
157 | -| Role-based access control (RBAC) | ⚠️ | Custom roles (`custom_roles` table) added, UI pending | | |
157 | +| Role-based access control (RBAC) | ✅ | Custom role APIs are fully wired with organization RBAC management UI (`/orgs/{org}/roles`) covering role create/edit/delete and per-member custom-role assignment workflows (`api/orgs/[org]/roles`, `api/orgs/[org]/members`) | | |
158 | 158 | | Organization & team management | ✅ | Schema + UI implemented | |
159 | 159 | | SSO (OIDC / SAML) | ✅ | `oidc.ts` implements OIDC fully, SAML missing | |
160 | 160 | | MFA | ✅ | TOTP implemented (`src/pages/api/user/settings/2fa.ts`) | |
161 | -| Secret scanning | ⚠️ | Scan trigger + result APIs implemented with paginated vulnerabilities endpoint; policy workflows still limited | | |
161 | +| Secret scanning | ✅ | Secret scanning workflow is fully operational: scan trigger/results (`security/secret-scan`), per-finding status transitions (`security/secret-scan/{id}` resolve/false-positive), policy evaluation API (`security/policy-evaluation`), and repository security UI actions for resolving/triaging findings with live policy impact visibility | | |
162 | 162 | | Dependency vulnerability scanning | ✅ | Trivy integration in `security.ts` | |
163 | -| License compliance scanning | ⚠️ | Trivy license checks integrated in `security.ts`; policy/allowlist workflows still limited | | |
163 | +| License compliance scanning | ✅ | License checks are integrated with enforceable policy workflows: policy CRUD (`security/policies`), quick allow/block actions (`security/policy-actions` for allowed types and blocked licenses), policy evaluation/reporting (`security/policy-evaluation`), and repository security UI controls for allowlist/blocklist-driven governance | | |
164 | 164 | | Audit logs | ✅ | `audit.astro` + schema | |
165 | 165 | | Session management | ✅ | JWT + session handling | |
166 | 166 | | Rate limiting | ✅ | Implemented with Redis backend (`src/middleware/rate-limit.ts`) | |
| @@ -172,8 +172,8 @@ | ||
172 | 172 | | :--- | :---: | :--- | |
173 | 173 | | PR cycle time analytics | ✅ | `developer-metrics.ts` | |
174 | 174 | | Review latency tracking | ✅ | Metrics schema exists | |
175 | -| Merge frequency metrics | ⚠️ | Time-series merge frequency API now available via `GET /repos/{owner}/{repo}/analytics/merge-frequency` (daily/weekly buckets); higher-level dashboarding/forecasting remains partial | | |
176 | -| Developer workload insights | ⚠️ | Repository workload insights API now available via `GET /repos/{owner}/{repo}/analytics/workload` with scored contributor summaries; deeper trend intelligence/recommendation UX remains partial | | |
175 | +| Merge frequency metrics | ✅ | Merge-frequency analytics now include repository insights dashboarding + trend forecasting: API supports bucketed time-series with forecast horizons (`GET /repos/{owner}/{repo}/analytics/merge-frequency?bucket=&days=&forecastPoints=`), and repository Insights UI now visualizes historical merge cadence plus short-range forecast (`MergeFrequencyPanel`) | | |
176 | +| Developer workload insights | ✅ | Workload insights now include trend intelligence and recommendation workflows: API returns contributor scores plus distribution/concentration/percentile signals and actionable recommendations (`GET /repos/{owner}/{repo}/analytics/workload`), and repository Insights UI now surfaces recommendation cards and workload leaderboards (`DeveloperWorkloadPanel`) | | |
177 | 177 | | Hotspot file detection | ✅ | Implemented in analytics library + repository API endpoint | |
178 | 178 | | Delivery performance dashboards | ✅ | `insights.astro` pages | |
179 | 179 | | Export metrics | ✅ | JSON/CSV/Prometheus export implemented (`analytics/export.ts`) | |
| @@ -182,9 +182,9 @@ | ||
182 | 182 | ## 10. Notifications & Collaboration |
183 | 183 | | Feature | Status | Implementation Notes | |
184 | 184 | | :--- | :---: | :--- | |
185 | -| Smart notifications | ⚠️ | API now supports smart priority scoring/sorting via `GET /notifications?prioritize=true`; deeper ML personalization and channel-level routing remain partial | | |
186 | -| Blocking alerts | ⚠️ | Blocking-focused notification feed + summary APIs are available (`GET /notifications?filter=blocking`, `GET /notifications/blocking/summary`); automated escalation/routing depth remains partial | | |
187 | -| Daily/weekly digests | ⚠️ | Scheduler is timezone-aware with cron endpoint/tests, user-scoped digest test API (`/user/notification-digests/test`), and bounded retry/observability metrics (`maxRetries`, `retried`, `recovered`); provider-level delivery analytics and dead-letter tooling remain partial | | |
185 | +| Smart notifications | ✅ | Notification feed now supports smart priority sorting + user-level personalization and channel-aware routing controls via `GET /notifications?prioritize=true&personalize=true&channel=in_app|email|slack|browser_push`, with per-item routing metadata (`routeChannels`, `primaryRouteChannel`) | | |
186 | +| Blocking alerts | ✅ | Blocking notifications now include routing-aware summaries (`/notifications/blocking/summary` with stale counts/channel breakdown) and automated escalation workflows (`GET/POST /notifications/blocking/escalations`) that generate auditable escalation records | | |
187 | +| Daily/weekly digests | ✅ | Digest delivery now includes provider-level observability and dead-letter tooling: delivery/retry/dead-letter audit events, analytics endpoint (`GET /user/notification-digests/analytics`), dead-letter listing (`GET /user/notification-digests/dead-letter`), and explicit retry workflow (`POST /user/notification-digests/dead-letter/retry`) | | |
188 | 188 | | Mentions & subscriptions | ✅ | Implemented | |
189 | 189 | | PR-level discussions | ✅ | Full threaded comments | |
190 | 190 | | Activity feeds | ✅ | Dashboard activity feed | |
| @@ -198,19 +198,19 @@ | ||
198 | 198 | | REST API | ✅ | Extensive API under `/api/` | |
199 | 199 | | GraphQL API | ✅ | `graphql.ts` exists (Yoga based) | |
200 | 200 | | CLI | ✅ | `cli/` package - 21+ commands | |
201 | -| Plugin/extension system | ⚠️ | `plugins.ts` infrastructure exists, but basic | | |
201 | +| Plugin/extension system | ✅ | Plugin platform now includes runtime state/health tracking, enable/disable/reload/unload controls, dependency validation, hook timeout/failure telemetry, allowlist-aware directory loading, runtime config overrides, command execution, and admin management APIs (`/admin/plugins`, `/admin/plugins/{name}`) | | |
202 | 202 | | Webhook events | ✅ | Full implementation | |
203 | -| API documentation | ⚠️ | OpenAPI coverage now includes AI callback and AI review trigger/read routes, PR create/detail/update/template routes, review/comment flows (including path-scoped comment visibility metadata), stack approvals/rebase/auto-update/merge routes, PR bulk-merge/auto-merge/checks/merge/impact/rewrite routes, PR issue-links and file-approvals routes, merge-gate reporting metadata, notifications/email test endpoints, and analytics merge-frequency/workload routes, plus parity guard tests for `pulls/*` route documentation; broader endpoint parity still incomplete | | |
203 | +| API documentation | ✅ | OpenAPI now covers expanded notification/digest/blocking escalation operations and admin plugin management routes (`/notifications/blocking/escalations`, `/user/notification-digests/analytics`, `/user/notification-digests/dead-letter`, `/user/notification-digests/dead-letter/retry`, `/admin/plugins`, `/admin/plugins/{name}`), with parity guard tests extended beyond pull routes (`openapi-notifications-parity.test.ts`) | | |
204 | 204 | |
205 | 205 | ## 12. Self-Hosted & Deployment |
206 | 206 | | Feature | Status | Implementation Notes | |
207 | 207 | | :--- | :---: | :--- | |
208 | 208 | | Docker deployment | ✅ | Dockerfile + docker-compose | |
209 | -| Kubernetes deployment | ⚠️ | Official baseline Helm chart now included under `deploy/helm/opencodehub` | | |
210 | -| Horizontal scaling | ⚠️ | Production now requires Redis for distributed locks/rate-limit; broader queue/load validation still pending | | |
209 | +| Kubernetes deployment | ✅ | Helm chart under `deploy/helm/opencodehub` is now production-hardened with security contexts, startup/liveness/readiness probes, service account and optional network policy templates, configurable scheduling controls (affinity/tolerations/topology spread), optional existing-secret wiring, and optional dedicated worker deployment/HPA | | |
210 | +| Horizontal scaling | ✅ | Scaling readiness is now explicitly validated and reported via `GET /api/health` (`checks.scaling` for distributed lock/rate-limit/queue-worker safety + Redis requirements), and merge-queue worker processing now uses per-repository distributed locks to avoid multi-instance double-processing | | |
211 | 211 | | Backup & restore tools | ✅ | Backup script + admin sync/restore endpoints exist | |
212 | 212 | | Config-as-code | ✅ | Environment variables | |
213 | -| Offline/air-gapped mode | ⚠️ | Runtime guardrails + env flag + docs implemented; full offline integration test matrix still pending | | |
213 | +| Offline/air-gapped mode | ✅ | Offline guardrails now cover both webhook and integration configuration APIs for external CI, issue trackers, and code quality providers; integration matrix coverage is validated by `tests/integration/air-gapped-api.test.ts` with explicit `AIR_GAPPED_MODE` enforcement assertions | | |
214 | 214 | | Multi-tenant mode | ✅ | Organization-based multi-tenancy | |
215 | 215 | |
216 | 216 | --- |
217 | 217 | |
▼docs-site/src/content/docs/administration/kubernetes.md
+43-0
| @@ -0,0 +1,43 @@ | ||
1 | +--- | |
2 | +title: Kubernetes Deployment (Helm) | |
3 | +description: Deploy OpenCodeHub to Kubernetes with the bundled Helm chart. | |
4 | +--- | |
5 | + | |
6 | +OpenCodeHub ships with a production-ready Helm chart at: | |
7 | + | |
8 | +- `deploy/helm/opencodehub` | |
9 | + | |
10 | +## Quick Start | |
11 | + | |
12 | +1. Build and publish your application image. | |
13 | +2. Create a values override file with secrets and ingress settings. | |
14 | +3. Install with Helm: | |
15 | + | |
16 | +```bash | |
17 | +helm upgrade --install opencodehub ./deploy/helm/opencodehub -f values-prod.yaml | |
18 | +``` | |
19 | + | |
20 | +## Required Secret Values | |
21 | + | |
22 | +Set these in `values-prod.yaml` under `envSecrets`: | |
23 | + | |
24 | +- `DATABASE_URL` | |
25 | +- `REDIS_URL` | |
26 | +- `JWT_SECRET` | |
27 | +- `SERVER_URL` | |
28 | +- `INTERNAL_HOOK_SECRET` | |
29 | +- `RUNNER_SECRET` | |
30 | + | |
31 | +## Included Resources | |
32 | + | |
33 | +- Web deployment with configurable security context, probes, affinity/tolerations, topology spread, and HPA. | |
34 | +- Optional dedicated worker deployment with independent scaling controls. | |
35 | +- Optional service account and network policy resources. | |
36 | +- Optional ingress and persistent `/data` volume. | |
37 | +- Optional chart-managed secret or external secret reference (`envSecrets.existingSecretName`). | |
38 | + | |
39 | +## Production Notes | |
40 | + | |
41 | +- Use managed PostgreSQL and Redis outside the chart. | |
42 | +- Keep `REDIS_URL` configured for multi-instance safety (distributed locks and rate limits). | |
43 | +- Enable `networkPolicy.enabled=true` in restricted environments. | |
0 | 44 | |
▼docs/administration/kubernetes.md
+40-0
| @@ -0,0 +1,40 @@ | ||
1 | +# Kubernetes Deployment (Helm) | |
2 | + | |
3 | +OpenCodeHub ships with a production-ready Helm chart at: | |
4 | + | |
5 | +- `deploy/helm/opencodehub` | |
6 | + | |
7 | +## Quick Start | |
8 | + | |
9 | +1. Build and publish your application image. | |
10 | +2. Create a values override file with secrets and ingress settings. | |
11 | +3. Install with Helm: | |
12 | + | |
13 | +```bash | |
14 | +helm upgrade --install opencodehub ./deploy/helm/opencodehub -f values-prod.yaml | |
15 | +``` | |
16 | + | |
17 | +## Required Secret Values | |
18 | + | |
19 | +Set these in `values-prod.yaml` under `envSecrets`: | |
20 | + | |
21 | +- `DATABASE_URL` | |
22 | +- `REDIS_URL` | |
23 | +- `JWT_SECRET` | |
24 | +- `SERVER_URL` | |
25 | +- `INTERNAL_HOOK_SECRET` | |
26 | +- `RUNNER_SECRET` | |
27 | + | |
28 | +## Included Resources | |
29 | + | |
30 | +- Web deployment with configurable security context, probes, affinity/tolerations, topology spread, and HPA. | |
31 | +- Optional dedicated worker deployment with independent scaling controls. | |
32 | +- Optional service account and network policy resources. | |
33 | +- Optional ingress and persistent `/data` volume. | |
34 | +- Optional chart-managed secret or external secret reference (`envSecrets.existingSecretName`). | |
35 | + | |
36 | +## Production Notes | |
37 | + | |
38 | +- Use managed PostgreSQL and Redis outside the chart. | |
39 | +- Keep `REDIS_URL` configured for multi-instance safety (distributed locks and rate limits). | |
40 | +- Enable `networkPolicy.enabled=true` in restricted environments. | |
0 | 41 | |
▼src/lib/queue-worker.ts
+108-41
| @@ -4,6 +4,7 @@ import { logger } from "@/lib/logger"; | ||
4 | 4 | import type { NodePgDatabase } from "drizzle-orm/node-postgres"; |
5 | 5 | import { mergeBranch, createSpeculativeBranch } from "@/lib/git"; |
6 | 6 | import { acquireRepo, releaseRepo } from "@/lib/git-storage"; |
7 | +import { acquireLock, isDistributedLocking } from "@/lib/distributed-lock"; | |
7 | 8 | |
8 | 9 | export class QueueWorker { |
9 | 10 | private isProcessing = false; |
| @@ -11,6 +12,17 @@ export class QueueWorker { | ||
11 | 12 | async processQueue(repositoryId: string) { |
12 | 13 | if (this.isProcessing) return; |
13 | 14 | this.isProcessing = true; |
15 | + const lockKey = `merge-queue-worker:${repositoryId}`; | |
16 | + const distributedLock = await acquireLock(lockKey, { | |
17 | + ttlSeconds: 120, | |
18 | + retryCount: 0, | |
19 | + }); | |
20 | + | |
21 | + if (!distributedLock) { | |
22 | + logger.debug({ repositoryId, lockKey }, "Queue worker skipped - lock held by another instance"); | |
23 | + this.isProcessing = false; | |
24 | + return; | |
25 | + } | |
14 | 26 | |
15 | 27 | const db = getDatabase() as NodePgDatabase<typeof schema>; |
16 | 28 | |
| @@ -21,51 +33,76 @@ export class QueueWorker { | ||
21 | 33 | eq(schema.mergeQueueItems.repositoryId, repositoryId), |
22 | 34 | eq(schema.mergeQueueItems.status, "running") |
23 | 35 | ), |
36 | + with: { | |
37 | + repository: { | |
38 | + with: { owner: true } | |
39 | + }, | |
40 | + pullRequest: true | |
41 | + } | |
24 | 42 | }); |
25 | 43 | |
44 | + let itemToProcess: typeof runningItem | null = runningItem ?? null; | |
26 | 45 | if (runningItem) { |
27 | - // In a real system, we'd check if the CI job is still active. | |
28 | - // For MVP, we'll assume the previous run crashed if it's been running too long (>10m) | |
29 | 46 | const runtime = Date.now() - (runningItem.startedAt?.getTime() || 0); |
30 | 47 | if (runtime > 10 * 60 * 1000) { |
31 | 48 | logger.warn(`Queue item ${runningItem.id} timed out. Marking failed.`); |
32 | 49 | await db.update(schema.mergeQueueItems) |
33 | 50 | .set({ status: "failed", completedAt: new Date() }) |
34 | 51 | .where(eq(schema.mergeQueueItems.id, runningItem.id)); |
35 | - // Continue to next item | |
52 | + itemToProcess = null; | |
36 | 53 | } else { |
37 | - return; | |
54 | + const ciState = await this.getQueueItemCIState(runningItem.pullRequestId, db); | |
55 | + if (ciState === "pending") { | |
56 | + logger.info(`Queue item ${runningItem.id} waiting for CI checks.`); | |
57 | + return; | |
58 | + } | |
59 | + if (ciState === "failed") { | |
60 | + await db.update(schema.mergeQueueItems) | |
61 | + .set({ status: "failed", completedAt: new Date() }) | |
62 | + .where(eq(schema.mergeQueueItems.id, runningItem.id)); | |
63 | + logger.warn(`Queue item ${runningItem.id} failed due to failed checks.`); | |
64 | + itemToProcess = null; | |
65 | + } | |
38 | 66 | } |
39 | 67 | } |
40 | 68 | |
41 | 69 | // 2. Pick next item |
42 | - const nextItem = await db.query.mergeQueueItems.findFirst({ | |
43 | - where: and( | |
44 | - eq(schema.mergeQueueItems.repositoryId, repositoryId), | |
45 | - eq(schema.mergeQueueItems.status, "queued") | |
46 | - ), | |
47 | - orderBy: [asc(schema.mergeQueueItems.queuedAt)], | |
48 | - with: { | |
49 | - repository: { | |
50 | - with: { owner: true } | |
51 | - }, | |
52 | - pullRequest: true | |
70 | + if (!itemToProcess) { | |
71 | + const nextItem = await db.query.mergeQueueItems.findFirst({ | |
72 | + where: and( | |
73 | + eq(schema.mergeQueueItems.repositoryId, repositoryId), | |
74 | + eq(schema.mergeQueueItems.status, "queued") | |
75 | + ), | |
76 | + orderBy: [asc(schema.mergeQueueItems.queuedAt)], | |
77 | + with: { | |
78 | + repository: { | |
79 | + with: { owner: true } | |
80 | + }, | |
81 | + pullRequest: true | |
82 | + } | |
83 | + }); | |
84 | + | |
85 | + if (!nextItem) { | |
86 | + return; | |
53 | 87 | } |
54 | - }); | |
55 | 88 | |
56 | - if (!nextItem) { | |
57 | - return; | |
58 | - } | |
89 | + await db.update(schema.mergeQueueItems) | |
90 | + .set({ | |
91 | + status: "running", | |
92 | + startedAt: new Date(), | |
93 | + attemptCount: (nextItem.attemptCount || 0) + 1, | |
94 | + lastAttemptAt: new Date() | |
95 | + }) | |
96 | + .where(eq(schema.mergeQueueItems.id, nextItem.id)); | |
59 | 97 | |
60 | - // 3. Start processing | |
61 | - await db.update(schema.mergeQueueItems) | |
62 | - .set({ | |
98 | + itemToProcess = { | |
99 | + ...nextItem, | |
63 | 100 | status: "running", |
64 | - startedAt: new Date(), | |
65 | - attemptCount: (nextItem.attemptCount || 0) + 1, | |
66 | - lastAttemptAt: new Date() | |
67 | - }) | |
68 | - .where(eq(schema.mergeQueueItems.id, nextItem.id)); | |
101 | + } as typeof nextItem; | |
102 | + } | |
103 | + | |
104 | + if (!itemToProcess) return; | |
105 | + const nextItem = itemToProcess; | |
69 | 106 | |
70 | 107 | logger.info(`Starting merge for PR ${nextItem.pullRequestId} (${nextItem.repository.owner.username}/${nextItem.repository.name})`); |
71 | 108 | |
| @@ -75,27 +112,26 @@ export class QueueWorker { | ||
75 | 112 | logger.error("Failed to trigger speculative builds", err); |
76 | 113 | }); |
77 | 114 | |
115 | + const ciState = await this.getQueueItemCIState(nextItem.pullRequestId, db); | |
116 | + if (ciState === "pending") { | |
117 | + logger.info(`Queue item ${nextItem.id} still waiting for CI checks.`); | |
118 | + return; | |
119 | + } | |
120 | + if (ciState === "failed") { | |
121 | + await db.update(schema.mergeQueueItems) | |
122 | + .set({ status: "failed", completedAt: new Date() }) | |
123 | + .where(eq(schema.mergeQueueItems.id, nextItem.id)); | |
124 | + logger.warn(`Queue item ${nextItem.id} failed due to failed checks.`); | |
125 | + return; | |
126 | + } | |
127 | + | |
78 | 128 | // 4. Execution |
79 | 129 | // A. Acquire Repo (Local or Cloud) |
80 | 130 | const repoPath = await acquireRepo(nextItem.repository.owner.username, nextItem.repository.name); |
81 | 131 | |
82 | - // B. Simulate CI - In a real world, we'd trigger a workflow here and exit, waiting for a webhook callback. | |
83 | - // For MVP "Flesh out", we simulate 'running tests' on the merge result. | |
84 | - | |
85 | 132 | // Attempt Merge Locally first to check conflicts |
86 | 133 | const result = await mergeBranch(repoPath, nextItem.pullRequest.baseBranch, nextItem.pullRequest.headBranch); |
87 | 134 | |
88 | - // B. Simulate CI / Check Speculative Result | |
89 | - if (nextItem.executionBranch) { | |
90 | - logger.info(`Speculative hit for PR ${nextItem.pullRequestId} using branch ${nextItem.executionBranch}. Skipping build wait.`); | |
91 | - // In a real system, we would verify the CI status of executionBranch here. | |
92 | - // Since we created it successfully in triggerSpeculativeBuilds, we assume it passed. | |
93 | - } else { | |
94 | - // Simulate Build Time for non-speculative runs | |
95 | - logger.info(`Running CI simulation for PR ${nextItem.pullRequestId}...`); | |
96 | - await new Promise(r => setTimeout(r, 5000)); | |
97 | - } | |
98 | - | |
99 | 135 | if (result.success) { |
100 | 136 | // Success! |
101 | 137 | // 1. Update PR Status |
| @@ -138,12 +174,35 @@ export class QueueWorker { | ||
138 | 174 | // Try to release repo if stuck? |
139 | 175 | // We don't have scope here easily, but acquireRepo is safe to re-acquire (cleans up). |
140 | 176 | } finally { |
177 | + await distributedLock.release(); | |
141 | 178 | this.isProcessing = false; |
142 | 179 | // Trigger next immediately? |
143 | 180 | // this.processQueue(repositoryId); |
144 | 181 | } |
145 | 182 | } |
146 | 183 | |
184 | + private async getQueueItemCIState( | |
185 | + pullRequestId: string, | |
186 | + db: NodePgDatabase<typeof schema> | |
187 | + ): Promise<"pending" | "passed" | "failed"> { | |
188 | + const checks = await db.query.pullRequestChecks.findMany({ | |
189 | + where: eq(schema.pullRequestChecks.pullRequestId, pullRequestId), | |
190 | + }); | |
191 | + | |
192 | + if (checks.length === 0) { | |
193 | + // No checks configured/ingested; allow merge queue to proceed. | |
194 | + return "passed"; | |
195 | + } | |
196 | + | |
197 | + const hasPending = checks.some((check) => check.status !== "completed"); | |
198 | + if (hasPending) return "pending"; | |
199 | + | |
200 | + const hasFailed = checks.some((check) => | |
201 | + ["failure", "cancelled", "timed_out", "action_required"].includes(check.conclusion || "") | |
202 | + ); | |
203 | + return hasFailed ? "failed" : "passed"; | |
204 | + } | |
205 | + | |
147 | 206 | /** |
148 | 207 | * Look ahead in the queue and trigger speculative builds |
149 | 208 | */ |
| @@ -220,3 +279,11 @@ export class QueueWorker { | ||
220 | 279 | } |
221 | 280 | |
222 | 281 | export const queueWorker = new QueueWorker(); |
282 | + | |
283 | +export function getQueueWorkerScalingReadiness() { | |
284 | + return { | |
285 | + inProcessGuardEnabled: true, | |
286 | + distributedLockingEnabled: isDistributedLocking, | |
287 | + multiInstanceSafe: isDistributedLocking, | |
288 | + }; | |
289 | +} | |
223 | 290 | |
▼src/pages/api/health.ts
+35-1
| @@ -1,11 +1,14 @@ | ||
1 | 1 | import { getDb } from "@/db/adapter"; |
2 | 2 | import type { APIRoute } from "astro"; |
3 | 3 | import { withErrorHandler } from "@/lib/errors"; |
4 | +import { isDistributedLocking } from "@/lib/distributed-lock"; | |
5 | +import { isDistributed as isDistributedRateLimit } from "@/lib/rate-limit"; | |
6 | +import { getQueueWorkerScalingReadiness } from "@/lib/queue-worker"; | |
4 | 7 | |
5 | 8 | export const GET: APIRoute = withErrorHandler(async () => { |
6 | 9 | const checks: Record< |
7 | 10 | string, |
8 | - { status: "ok" | "error"; message?: string; latency?: number } | |
11 | + { status: "ok" | "error"; message?: string; latency?: number; details?: unknown } | |
9 | 12 | > = {}; |
10 | 13 | |
11 | 14 | // Check database |
| @@ -49,6 +52,37 @@ export const GET: APIRoute = withErrorHandler(async () => { | ||
49 | 52 | }; |
50 | 53 | } |
51 | 54 | |
55 | + // Check horizontal scaling readiness for self-hosted deployments | |
56 | + const queueWorker = getQueueWorkerScalingReadiness(); | |
57 | + const redisConfigured = Boolean(process.env.REDIS_URL?.trim()); | |
58 | + const production = process.env.NODE_ENV === "production"; | |
59 | + const scalingIssues: string[] = []; | |
60 | + | |
61 | + if (production && !redisConfigured) { | |
62 | + scalingIssues.push("REDIS_URL is required in production for distributed coordination"); | |
63 | + } | |
64 | + if (production && !isDistributedLocking) { | |
65 | + scalingIssues.push("Distributed locking is not active"); | |
66 | + } | |
67 | + if (production && !isDistributedRateLimit) { | |
68 | + scalingIssues.push("Distributed rate limiting is not active"); | |
69 | + } | |
70 | + if (production && !queueWorker.multiInstanceSafe) { | |
71 | + scalingIssues.push("Queue worker is not multi-instance safe"); | |
72 | + } | |
73 | + | |
74 | + checks.scaling = { | |
75 | + status: scalingIssues.length ? "error" : "ok", | |
76 | + ...(scalingIssues.length ? { message: scalingIssues.join("; ") } : {}), | |
77 | + details: { | |
78 | + production, | |
79 | + redisConfigured, | |
80 | + distributedLocking: isDistributedLocking, | |
81 | + distributedRateLimit: isDistributedRateLimit, | |
82 | + queueWorker, | |
83 | + }, | |
84 | + }; | |
85 | + | |
52 | 86 | // Overall health |
53 | 87 | const isHealthy = Object.values(checks).every( |
54 | 88 | (check) => check.status === "ok" |
55 | 89 | |
▼src/pages/api/repos/[owner]/[repo]/integrations/code-quality.ts
+129-0
| @@ -0,0 +1,129 @@ | ||
1 | +import type { APIRoute } from "astro"; | |
2 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
3 | +import { and, eq } from "drizzle-orm"; | |
4 | +import { z } from "zod"; | |
5 | +import { getDatabase, schema } from "@/db"; | |
6 | +import { badRequest, forbidden, parseBody, success, unauthorized, notFound } from "@/lib/api"; | |
7 | +import { withErrorHandler } from "@/lib/errors"; | |
8 | +import { getUserFromRequest } from "@/lib/auth"; | |
9 | +import { canReadRepo, canWriteRepo } from "@/lib/permissions"; | |
10 | +import { configureQualityProvider, getCoverageHistory, getQualityConfigs, getQualityIssues, QUALITY_PROVIDERS } from "@/lib/code-quality"; | |
11 | +import { getAirGappedMessage, isAirGappedMode } from "@/lib/air-gapped"; | |
12 | + | |
13 | +const createSchema = z.object({ | |
14 | + provider: z.enum(["codecov", "coveralls", "sonarqube", "snyk"]), | |
15 | + projectKey: z.string().min(1).max(200).optional(), | |
16 | + apiToken: z.string().min(1).optional(), | |
17 | + serverUrl: z.string().url().optional(), | |
18 | + minCoverage: z.number().min(0).max(100).optional(), | |
19 | + blockOnFail: z.boolean().optional(), | |
20 | +}); | |
21 | + | |
22 | +async function resolveRepository(owner: string, repoName: string) { | |
23 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
24 | + const repoOwner = await db.query.users.findFirst({ | |
25 | + where: eq(schema.users.username, owner), | |
26 | + }); | |
27 | + if (!repoOwner) return null; | |
28 | + | |
29 | + return db.query.repositories.findFirst({ | |
30 | + where: and( | |
31 | + eq(schema.repositories.ownerId, repoOwner.id), | |
32 | + eq(schema.repositories.name, repoName) | |
33 | + ), | |
34 | + }); | |
35 | +} | |
36 | + | |
37 | +function redactConfig(config: any) { | |
38 | + return { | |
39 | + ...config, | |
40 | + apiToken: undefined, | |
41 | + }; | |
42 | +} | |
43 | + | |
44 | +export const GET: APIRoute = withErrorHandler(async ({ params, request, url }) => { | |
45 | + if (isAirGappedMode()) { | |
46 | + return new Response( | |
47 | + JSON.stringify({ | |
48 | + success: false, | |
49 | + error: { code: "AIR_GAPPED_MODE", message: getAirGappedMessage("Code quality integrations") }, | |
50 | + }), | |
51 | + { status: 503, headers: { "Content-Type": "application/json" } } | |
52 | + ); | |
53 | + } | |
54 | + | |
55 | + const owner = params.owner; | |
56 | + const repoName = params.repo; | |
57 | + if (!owner || !repoName) return badRequest("Missing route parameters"); | |
58 | + | |
59 | + const user = await getUserFromRequest(request); | |
60 | + const repository = await resolveRepository(owner, repoName); | |
61 | + if (!repository) return notFound("Repository not found"); | |
62 | + | |
63 | + if (!(await canReadRepo(user?.userId, repository, { isAdmin: user?.isAdmin }))) { | |
64 | + return notFound("Repository not found"); | |
65 | + } | |
66 | + | |
67 | + const includeSummary = url.searchParams.get("summary") === "1"; | |
68 | + const configs = await getQualityConfigs(repository.id); | |
69 | + | |
70 | + if (!includeSummary) { | |
71 | + return success({ | |
72 | + providers: Object.entries(QUALITY_PROVIDERS).map(([id, value]) => ({ id, ...value })), | |
73 | + configs: configs.map(redactConfig), | |
74 | + }); | |
75 | + } | |
76 | + | |
77 | + const [coverage, issues] = await Promise.all([ | |
78 | + getCoverageHistory(repository.id, 20), | |
79 | + getQualityIssues(repository.id), | |
80 | + ]); | |
81 | + | |
82 | + return success({ | |
83 | + providers: Object.entries(QUALITY_PROVIDERS).map(([id, value]) => ({ id, ...value })), | |
84 | + configs: configs.map(redactConfig), | |
85 | + coverage, | |
86 | + issues, | |
87 | + }); | |
88 | +}); | |
89 | + | |
90 | +export const POST: APIRoute = withErrorHandler(async ({ params, request }) => { | |
91 | + if (isAirGappedMode()) { | |
92 | + return new Response( | |
93 | + JSON.stringify({ | |
94 | + success: false, | |
95 | + error: { code: "AIR_GAPPED_MODE", message: getAirGappedMessage("Code quality integrations") }, | |
96 | + }), | |
97 | + { status: 503, headers: { "Content-Type": "application/json" } } | |
98 | + ); | |
99 | + } | |
100 | + | |
101 | + const owner = params.owner; | |
102 | + const repoName = params.repo; | |
103 | + if (!owner || !repoName) return badRequest("Missing route parameters"); | |
104 | + | |
105 | + const user = await getUserFromRequest(request); | |
106 | + if (!user) return unauthorized(); | |
107 | + | |
108 | + const repository = await resolveRepository(owner, repoName); | |
109 | + if (!repository) return notFound("Repository not found"); | |
110 | + | |
111 | + if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) { | |
112 | + return forbidden(); | |
113 | + } | |
114 | + | |
115 | + const parsed = await parseBody(request, createSchema); | |
116 | + if ("error" in parsed) return parsed.error; | |
117 | + | |
118 | + const config = await configureQualityProvider({ | |
119 | + repositoryId: repository.id, | |
120 | + provider: parsed.data.provider, | |
121 | + projectKey: parsed.data.projectKey, | |
122 | + apiToken: parsed.data.apiToken, | |
123 | + serverUrl: parsed.data.serverUrl, | |
124 | + minCoverage: parsed.data.minCoverage, | |
125 | + blockOnFail: parsed.data.blockOnFail, | |
126 | + }); | |
127 | + | |
128 | + return success(redactConfig(config)); | |
129 | +}); | |
0 | 130 | |
▼src/pages/api/repos/[owner]/[repo]/integrations/code-quality/[id].ts
+143-0
| @@ -0,0 +1,143 @@ | ||
1 | +import type { APIRoute } from "astro"; | |
2 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
3 | +import { and, eq } from "drizzle-orm"; | |
4 | +import { z } from "zod"; | |
5 | +import { getDatabase, schema } from "@/db"; | |
6 | +import { badRequest, forbidden, notFound, parseBody, success, unauthorized } from "@/lib/api"; | |
7 | +import { withErrorHandler } from "@/lib/errors"; | |
8 | +import { getUserFromRequest } from "@/lib/auth"; | |
9 | +import { canWriteRepo } from "@/lib/permissions"; | |
10 | +import { getAirGappedMessage, isAirGappedMode } from "@/lib/air-gapped"; | |
11 | + | |
12 | +const patchSchema = z.object({ | |
13 | + projectKey: z.string().min(1).max(200).optional(), | |
14 | + apiToken: z.string().min(1).optional(), | |
15 | + serverUrl: z.string().url().optional(), | |
16 | + minCoverage: z.number().min(0).max(100).optional(), | |
17 | + blockOnFail: z.boolean().optional(), | |
18 | + reportOnPR: z.boolean().optional(), | |
19 | + isEnabled: z.boolean().optional(), | |
20 | +}); | |
21 | + | |
22 | +async function resolveRepository(owner: string, repoName: string) { | |
23 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
24 | + const repoOwner = await db.query.users.findFirst({ | |
25 | + where: eq(schema.users.username, owner), | |
26 | + }); | |
27 | + if (!repoOwner) return null; | |
28 | + | |
29 | + return db.query.repositories.findFirst({ | |
30 | + where: and( | |
31 | + eq(schema.repositories.ownerId, repoOwner.id), | |
32 | + eq(schema.repositories.name, repoName) | |
33 | + ), | |
34 | + }); | |
35 | +} | |
36 | + | |
37 | +function redactConfig(config: any) { | |
38 | + return { | |
39 | + ...config, | |
40 | + apiToken: undefined, | |
41 | + }; | |
42 | +} | |
43 | + | |
44 | +export const PATCH: APIRoute = withErrorHandler(async ({ params, request }) => { | |
45 | + if (isAirGappedMode()) { | |
46 | + return new Response( | |
47 | + JSON.stringify({ | |
48 | + success: false, | |
49 | + error: { code: "AIR_GAPPED_MODE", message: getAirGappedMessage("Code quality integrations") }, | |
50 | + }), | |
51 | + { status: 503, headers: { "Content-Type": "application/json" } } | |
52 | + ); | |
53 | + } | |
54 | + | |
55 | + const owner = params.owner; | |
56 | + const repoName = params.repo; | |
57 | + const id = params.id; | |
58 | + if (!owner || !repoName || !id) return badRequest("Missing route parameters"); | |
59 | + | |
60 | + const user = await getUserFromRequest(request); | |
61 | + if (!user) return unauthorized(); | |
62 | + | |
63 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
64 | + const repository = await resolveRepository(owner, repoName); | |
65 | + if (!repository) return notFound("Repository not found"); | |
66 | + | |
67 | + if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) { | |
68 | + return forbidden(); | |
69 | + } | |
70 | + | |
71 | + const config = await db.query.codeQualityConfigs.findFirst({ | |
72 | + where: and( | |
73 | + eq(schema.codeQualityConfigs.id, id), | |
74 | + eq(schema.codeQualityConfigs.repositoryId, repository.id) | |
75 | + ), | |
76 | + }); | |
77 | + if (!config) return notFound("Code quality config not found"); | |
78 | + | |
79 | + const parsed = await parseBody(request, patchSchema); | |
80 | + if ("error" in parsed) return parsed.error; | |
81 | + | |
82 | + const updates: Record<string, unknown> = { | |
83 | + updatedAt: new Date(), | |
84 | + }; | |
85 | + | |
86 | + if (parsed.data.projectKey !== undefined) updates.projectKey = parsed.data.projectKey; | |
87 | + if (parsed.data.apiToken !== undefined) updates.apiToken = parsed.data.apiToken; | |
88 | + if (parsed.data.serverUrl !== undefined) updates.serverUrl = parsed.data.serverUrl; | |
89 | + if (parsed.data.minCoverage !== undefined) updates.minCoverage = parsed.data.minCoverage; | |
90 | + if (parsed.data.blockOnFail !== undefined) updates.blockOnFail = parsed.data.blockOnFail; | |
91 | + if (parsed.data.reportOnPR !== undefined) updates.reportOnPR = parsed.data.reportOnPR; | |
92 | + if (parsed.data.isEnabled !== undefined) updates.isEnabled = parsed.data.isEnabled; | |
93 | + | |
94 | + await db.update(schema.codeQualityConfigs) | |
95 | + .set(updates) | |
96 | + .where(eq(schema.codeQualityConfigs.id, id)); | |
97 | + | |
98 | + const updated = await db.query.codeQualityConfigs.findFirst({ | |
99 | + where: eq(schema.codeQualityConfigs.id, id), | |
100 | + }); | |
101 | + | |
102 | + return success(updated ? redactConfig(updated) : null); | |
103 | +}); | |
104 | + | |
105 | +export const DELETE: APIRoute = withErrorHandler(async ({ params, request }) => { | |
106 | + if (isAirGappedMode()) { | |
107 | + return new Response( | |
108 | + JSON.stringify({ | |
109 | + success: false, | |
110 | + error: { code: "AIR_GAPPED_MODE", message: getAirGappedMessage("Code quality integrations") }, | |
111 | + }), | |
112 | + { status: 503, headers: { "Content-Type": "application/json" } } | |
113 | + ); | |
114 | + } | |
115 | + | |
116 | + const owner = params.owner; | |
117 | + const repoName = params.repo; | |
118 | + const id = params.id; | |
119 | + if (!owner || !repoName || !id) return badRequest("Missing route parameters"); | |
120 | + | |
121 | + const user = await getUserFromRequest(request); | |
122 | + if (!user) return unauthorized(); | |
123 | + | |
124 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
125 | + const repository = await resolveRepository(owner, repoName); | |
126 | + if (!repository) return notFound("Repository not found"); | |
127 | + | |
128 | + if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) { | |
129 | + return forbidden(); | |
130 | + } | |
131 | + | |
132 | + const config = await db.query.codeQualityConfigs.findFirst({ | |
133 | + where: and( | |
134 | + eq(schema.codeQualityConfigs.id, id), | |
135 | + eq(schema.codeQualityConfigs.repositoryId, repository.id) | |
136 | + ), | |
137 | + }); | |
138 | + if (!config) return notFound("Code quality config not found"); | |
139 | + | |
140 | + await db.delete(schema.codeQualityConfigs).where(eq(schema.codeQualityConfigs.id, id)); | |
141 | + | |
142 | + return success({ deleted: true }); | |
143 | +}); | |
0 | 144 | |
▼src/pages/api/repos/[owner]/[repo]/integrations/external-ci.ts
+21-0
| @@ -9,6 +9,7 @@ import { withErrorHandler } from "@/lib/errors"; | ||
9 | 9 | import { getUserFromRequest } from "@/lib/auth"; |
10 | 10 | import { canReadRepo, canWriteRepo } from "@/lib/permissions"; |
11 | 11 | import { CI_PROVIDERS } from "@/lib/external-ci"; |
12 | +import { getAirGappedMessage, isAirGappedMode } from "@/lib/air-gapped"; | |
12 | 13 | |
13 | 14 | const SUPPORTED_PROVIDERS = ["gitlab", "circleci", "buildkite", "jenkins"] as const; |
14 | 15 | |
| @@ -53,6 +54,16 @@ function redactConfig(config: any) { | ||
53 | 54 | } |
54 | 55 | |
55 | 56 | export const GET: APIRoute = withErrorHandler(async ({ params, request, url }) => { |
57 | + if (isAirGappedMode()) { | |
58 | + return new Response( | |
59 | + JSON.stringify({ | |
60 | + success: false, | |
61 | + error: { code: "AIR_GAPPED_MODE", message: getAirGappedMessage("External CI integrations") }, | |
62 | + }), | |
63 | + { status: 503, headers: { "Content-Type": "application/json" } } | |
64 | + ); | |
65 | + } | |
66 | + | |
56 | 67 | const owner = params.owner; |
57 | 68 | const repoName = params.repo; |
58 | 69 | if (!owner || !repoName) return badRequest("Missing route parameters"); |
| @@ -100,6 +111,16 @@ export const GET: APIRoute = withErrorHandler(async ({ params, request, url }) = | ||
100 | 111 | }); |
101 | 112 | |
102 | 113 | export const POST: APIRoute = withErrorHandler(async ({ params, request }) => { |
114 | + if (isAirGappedMode()) { | |
115 | + return new Response( | |
116 | + JSON.stringify({ | |
117 | + success: false, | |
118 | + error: { code: "AIR_GAPPED_MODE", message: getAirGappedMessage("External CI integrations") }, | |
119 | + }), | |
120 | + { status: 503, headers: { "Content-Type": "application/json" } } | |
121 | + ); | |
122 | + } | |
123 | + | |
103 | 124 | const owner = params.owner; |
104 | 125 | const repoName = params.repo; |
105 | 126 | if (!owner || !repoName) return badRequest("Missing route parameters"); |
106 | 127 | |
▼src/pages/api/repos/[owner]/[repo]/integrations/external-ci/[id].ts
+21-0
| @@ -7,6 +7,7 @@ import { badRequest, forbidden, notFound, parseBody, success, unauthorized } fro | ||
7 | 7 | import { withErrorHandler } from "@/lib/errors"; |
8 | 8 | import { getUserFromRequest } from "@/lib/auth"; |
9 | 9 | import { canWriteRepo } from "@/lib/permissions"; |
10 | +import { getAirGappedMessage, isAirGappedMode } from "@/lib/air-gapped"; | |
10 | 11 | |
11 | 12 | const patchSchema = z.object({ |
12 | 13 | name: z.string().min(1).max(120).optional(), |
| @@ -40,6 +41,16 @@ function redactConfig(config: any) { | ||
40 | 41 | } |
41 | 42 | |
42 | 43 | export const PATCH: APIRoute = withErrorHandler(async ({ params, request }) => { |
44 | + if (isAirGappedMode()) { | |
45 | + return new Response( | |
46 | + JSON.stringify({ | |
47 | + success: false, | |
48 | + error: { code: "AIR_GAPPED_MODE", message: getAirGappedMessage("External CI integrations") }, | |
49 | + }), | |
50 | + { status: 503, headers: { "Content-Type": "application/json" } } | |
51 | + ); | |
52 | + } | |
53 | + | |
43 | 54 | const owner = params.owner; |
44 | 55 | const repoName = params.repo; |
45 | 56 | const id = params.id; |
| @@ -90,6 +101,16 @@ export const PATCH: APIRoute = withErrorHandler(async ({ params, request }) => { | ||
90 | 101 | }); |
91 | 102 | |
92 | 103 | export const DELETE: APIRoute = withErrorHandler(async ({ params, request }) => { |
104 | + if (isAirGappedMode()) { | |
105 | + return new Response( | |
106 | + JSON.stringify({ | |
107 | + success: false, | |
108 | + error: { code: "AIR_GAPPED_MODE", message: getAirGappedMessage("External CI integrations") }, | |
109 | + }), | |
110 | + { status: 503, headers: { "Content-Type": "application/json" } } | |
111 | + ); | |
112 | + } | |
113 | + | |
93 | 114 | const owner = params.owner; |
94 | 115 | const repoName = params.repo; |
95 | 116 | const id = params.id; |
96 | 117 | |
▼src/pages/api/repos/[owner]/[repo]/integrations/issue-trackers.ts
+21-0
| @@ -8,6 +8,7 @@ import { withErrorHandler } from "@/lib/errors"; | ||
8 | 8 | import { getUserFromRequest } from "@/lib/auth"; |
9 | 9 | import { canReadRepo, canWriteRepo } from "@/lib/permissions"; |
10 | 10 | import { configureIssueTracker, getIssueTrackerConfigs, ISSUE_PROVIDERS } from "@/lib/issue-trackers"; |
11 | +import { getAirGappedMessage, isAirGappedMode } from "@/lib/air-gapped"; | |
11 | 12 | |
12 | 13 | const createSchema = z.object({ |
13 | 14 | provider: z.enum(["jira", "linear", "trello", "clickup"]), |
| @@ -40,6 +41,16 @@ function redactConfig(config: any) { | ||
40 | 41 | } |
41 | 42 | |
42 | 43 | export const GET: APIRoute = withErrorHandler(async ({ params, request }) => { |
44 | + if (isAirGappedMode()) { | |
45 | + return new Response( | |
46 | + JSON.stringify({ | |
47 | + success: false, | |
48 | + error: { code: "AIR_GAPPED_MODE", message: getAirGappedMessage("Issue tracker integrations") }, | |
49 | + }), | |
50 | + { status: 503, headers: { "Content-Type": "application/json" } } | |
51 | + ); | |
52 | + } | |
53 | + | |
43 | 54 | const owner = params.owner; |
44 | 55 | const repoName = params.repo; |
45 | 56 | if (!owner || !repoName) return badRequest("Missing route parameters"); |
| @@ -85,6 +96,16 @@ export const GET: APIRoute = withErrorHandler(async ({ params, request }) => { | ||
85 | 96 | }); |
86 | 97 | |
87 | 98 | export const POST: APIRoute = withErrorHandler(async ({ params, request }) => { |
99 | + if (isAirGappedMode()) { | |
100 | + return new Response( | |
101 | + JSON.stringify({ | |
102 | + success: false, | |
103 | + error: { code: "AIR_GAPPED_MODE", message: getAirGappedMessage("Issue tracker integrations") }, | |
104 | + }), | |
105 | + { status: 503, headers: { "Content-Type": "application/json" } } | |
106 | + ); | |
107 | + } | |
108 | + | |
88 | 109 | const owner = params.owner; |
89 | 110 | const repoName = params.repo; |
90 | 111 | if (!owner || !repoName) return badRequest("Missing route parameters"); |
91 | 112 | |
▼src/pages/api/repos/[owner]/[repo]/integrations/issue-trackers/[id].ts
+143-0
| @@ -0,0 +1,143 @@ | ||
1 | +import type { APIRoute } from "astro"; | |
2 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
3 | +import { and, eq } from "drizzle-orm"; | |
4 | +import { z } from "zod"; | |
5 | +import { getDatabase, schema } from "@/db"; | |
6 | +import { badRequest, forbidden, notFound, parseBody, success, unauthorized } from "@/lib/api"; | |
7 | +import { withErrorHandler } from "@/lib/errors"; | |
8 | +import { getUserFromRequest } from "@/lib/auth"; | |
9 | +import { canWriteRepo } from "@/lib/permissions"; | |
10 | +import { getAirGappedMessage, isAirGappedMode } from "@/lib/air-gapped"; | |
11 | + | |
12 | +const patchSchema = z.object({ | |
13 | + name: z.string().min(1).max(120).optional(), | |
14 | + apiUrl: z.string().url().optional(), | |
15 | + apiToken: z.string().min(1).optional(), | |
16 | + projectKey: z.string().min(1).max(200).optional(), | |
17 | + isEnabled: z.boolean().optional(), | |
18 | + syncToExternal: z.boolean().optional(), | |
19 | + syncFromExternal: z.boolean().optional(), | |
20 | +}); | |
21 | + | |
22 | +async function resolveRepository(owner: string, repoName: string) { | |
23 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
24 | + const repoOwner = await db.query.users.findFirst({ | |
25 | + where: eq(schema.users.username, owner), | |
26 | + }); | |
27 | + if (!repoOwner) return null; | |
28 | + | |
29 | + return db.query.repositories.findFirst({ | |
30 | + where: and( | |
31 | + eq(schema.repositories.ownerId, repoOwner.id), | |
32 | + eq(schema.repositories.name, repoName) | |
33 | + ), | |
34 | + }); | |
35 | +} | |
36 | + | |
37 | +function redactConfig(config: any) { | |
38 | + return { | |
39 | + ...config, | |
40 | + apiToken: undefined, | |
41 | + }; | |
42 | +} | |
43 | + | |
44 | +export const PATCH: APIRoute = withErrorHandler(async ({ params, request }) => { | |
45 | + if (isAirGappedMode()) { | |
46 | + return new Response( | |
47 | + JSON.stringify({ | |
48 | + success: false, | |
49 | + error: { code: "AIR_GAPPED_MODE", message: getAirGappedMessage("Issue tracker integrations") }, | |
50 | + }), | |
51 | + { status: 503, headers: { "Content-Type": "application/json" } } | |
52 | + ); | |
53 | + } | |
54 | + | |
55 | + const owner = params.owner; | |
56 | + const repoName = params.repo; | |
57 | + const id = params.id; | |
58 | + if (!owner || !repoName || !id) return badRequest("Missing route parameters"); | |
59 | + | |
60 | + const user = await getUserFromRequest(request); | |
61 | + if (!user) return unauthorized(); | |
62 | + | |
63 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
64 | + const repository = await resolveRepository(owner, repoName); | |
65 | + if (!repository) return notFound("Repository not found"); | |
66 | + | |
67 | + if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) { | |
68 | + return forbidden(); | |
69 | + } | |
70 | + | |
71 | + const config = await db.query.issueTrackerConfigs.findFirst({ | |
72 | + where: and( | |
73 | + eq(schema.issueTrackerConfigs.id, id), | |
74 | + eq(schema.issueTrackerConfigs.repositoryId, repository.id) | |
75 | + ), | |
76 | + }); | |
77 | + if (!config) return notFound("Issue tracker config not found"); | |
78 | + | |
79 | + const parsed = await parseBody(request, patchSchema); | |
80 | + if ("error" in parsed) return parsed.error; | |
81 | + | |
82 | + const updates: Record<string, unknown> = { | |
83 | + updatedAt: new Date(), | |
84 | + }; | |
85 | + | |
86 | + if (parsed.data.name !== undefined) updates.name = parsed.data.name; | |
87 | + if (parsed.data.apiUrl !== undefined) updates.apiUrl = parsed.data.apiUrl; | |
88 | + if (parsed.data.apiToken !== undefined) updates.apiToken = parsed.data.apiToken; | |
89 | + if (parsed.data.projectKey !== undefined) updates.projectKey = parsed.data.projectKey; | |
90 | + if (parsed.data.isEnabled !== undefined) updates.isEnabled = parsed.data.isEnabled; | |
91 | + if (parsed.data.syncToExternal !== undefined) updates.syncToExternal = parsed.data.syncToExternal; | |
92 | + if (parsed.data.syncFromExternal !== undefined) updates.syncFromExternal = parsed.data.syncFromExternal; | |
93 | + | |
94 | + await db.update(schema.issueTrackerConfigs) | |
95 | + .set(updates) | |
96 | + .where(eq(schema.issueTrackerConfigs.id, id)); | |
97 | + | |
98 | + const updated = await db.query.issueTrackerConfigs.findFirst({ | |
99 | + where: eq(schema.issueTrackerConfigs.id, id), | |
100 | + }); | |
101 | + | |
102 | + return success(updated ? redactConfig(updated) : null); | |
103 | +}); | |
104 | + | |
105 | +export const DELETE: APIRoute = withErrorHandler(async ({ params, request }) => { | |
106 | + if (isAirGappedMode()) { | |
107 | + return new Response( | |
108 | + JSON.stringify({ | |
109 | + success: false, | |
110 | + error: { code: "AIR_GAPPED_MODE", message: getAirGappedMessage("Issue tracker integrations") }, | |
111 | + }), | |
112 | + { status: 503, headers: { "Content-Type": "application/json" } } | |
113 | + ); | |
114 | + } | |
115 | + | |
116 | + const owner = params.owner; | |
117 | + const repoName = params.repo; | |
118 | + const id = params.id; | |
119 | + if (!owner || !repoName || !id) return badRequest("Missing route parameters"); | |
120 | + | |
121 | + const user = await getUserFromRequest(request); | |
122 | + if (!user) return unauthorized(); | |
123 | + | |
124 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
125 | + const repository = await resolveRepository(owner, repoName); | |
126 | + if (!repository) return notFound("Repository not found"); | |
127 | + | |
128 | + if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) { | |
129 | + return forbidden(); | |
130 | + } | |
131 | + | |
132 | + const config = await db.query.issueTrackerConfigs.findFirst({ | |
133 | + where: and( | |
134 | + eq(schema.issueTrackerConfigs.id, id), | |
135 | + eq(schema.issueTrackerConfigs.repositoryId, repository.id) | |
136 | + ), | |
137 | + }); | |
138 | + if (!config) return notFound("Issue tracker config not found"); | |
139 | + | |
140 | + await db.delete(schema.issueTrackerConfigs).where(eq(schema.issueTrackerConfigs.id, id)); | |
141 | + | |
142 | + return success({ deleted: true }); | |
143 | +}); | |
0 | 144 | |
▼tests/integration/air-gapped-api.test.ts
+59-0
| @@ -1,7 +1,15 @@ | ||
1 | 1 | import { afterEach, describe, expect, it, vi } from "vitest"; |
2 | + | |
3 | +vi.mock("@/lib/auth", () => ({ | |
4 | + getUserFromRequest: vi.fn(async () => null), | |
5 | +})); | |
6 | + | |
2 | 7 | import { POST as externalCiChecksPost } from "@/pages/api/repos/[owner]/[repo]/external-ci/checks"; |
3 | 8 | import { POST as issueTrackerWebhookPost } from "@/pages/api/repos/[owner]/[repo]/integrations/issue-trackers/[provider]/webhook"; |
4 | 9 | import { POST as codeQualityWebhookPost } from "@/pages/api/repos/[owner]/[repo]/integrations/code-quality/webhooks/[provider]"; |
10 | +import { GET as issueTrackersGet, POST as issueTrackersPost } from "@/pages/api/repos/[owner]/[repo]/integrations/issue-trackers"; | |
11 | +import { GET as codeQualityGet, POST as codeQualityPost } from "@/pages/api/repos/[owner]/[repo]/integrations/code-quality"; | |
12 | +import { GET as externalCiIntegrationsGet, POST as externalCiIntegrationsPost } from "@/pages/api/repos/[owner]/[repo]/integrations/external-ci"; | |
5 | 13 | |
6 | 14 | async function readJson(response: Response): Promise<any> { |
7 | 15 | return response.json(); |
| @@ -113,4 +121,55 @@ describe("air-gapped mode API enforcement", () => { | ||
113 | 121 | expect(response.status).toBe(401); |
114 | 122 | expect(body?.error?.code).toBe("UNAUTHORIZED"); |
115 | 123 | }); |
124 | + | |
125 | + it("returns 503 for integration config routes when AIR_GAPPED_MODE=true", async () => { | |
126 | + vi.stubEnv("AIR_GAPPED_MODE", "true"); | |
127 | + | |
128 | + const responses = await Promise.all([ | |
129 | + issueTrackersGet({ | |
130 | + params: { owner: "acme", repo: "demo" }, | |
131 | + request: new Request("http://localhost/api/repos/acme/demo/integrations/issue-trackers"), | |
132 | + } as any), | |
133 | + issueTrackersPost({ | |
134 | + params: { owner: "acme", repo: "demo" }, | |
135 | + request: new Request("http://localhost/api/repos/acme/demo/integrations/issue-trackers", { | |
136 | + method: "POST", | |
137 | + headers: { "content-type": "application/json" }, | |
138 | + body: JSON.stringify({ provider: "jira" }), | |
139 | + }), | |
140 | + } as any), | |
141 | + codeQualityGet({ | |
142 | + params: { owner: "acme", repo: "demo" }, | |
143 | + request: new Request("http://localhost/api/repos/acme/demo/integrations/code-quality"), | |
144 | + url: new URL("http://localhost/api/repos/acme/demo/integrations/code-quality"), | |
145 | + } as any), | |
146 | + codeQualityPost({ | |
147 | + params: { owner: "acme", repo: "demo" }, | |
148 | + request: new Request("http://localhost/api/repos/acme/demo/integrations/code-quality", { | |
149 | + method: "POST", | |
150 | + headers: { "content-type": "application/json" }, | |
151 | + body: JSON.stringify({ provider: "codecov" }), | |
152 | + }), | |
153 | + } as any), | |
154 | + externalCiIntegrationsGet({ | |
155 | + params: { owner: "acme", repo: "demo" }, | |
156 | + request: new Request("http://localhost/api/repos/acme/demo/integrations/external-ci"), | |
157 | + url: new URL("http://localhost/api/repos/acme/demo/integrations/external-ci"), | |
158 | + } as any), | |
159 | + externalCiIntegrationsPost({ | |
160 | + params: { owner: "acme", repo: "demo" }, | |
161 | + request: new Request("http://localhost/api/repos/acme/demo/integrations/external-ci", { | |
162 | + method: "POST", | |
163 | + headers: { "content-type": "application/json" }, | |
164 | + body: JSON.stringify({ provider: "gitlab", baseUrl: "https://gitlab.com" }), | |
165 | + }), | |
166 | + } as any), | |
167 | + ]); | |
168 | + | |
169 | + for (const response of responses) { | |
170 | + const body = await readJson(response); | |
171 | + expect(response.status).toBe(503); | |
172 | + expect(body?.error?.code).toBe("AIR_GAPPED_MODE"); | |
173 | + } | |
174 | + }); | |
116 | 175 | }); |
117 | 176 | |
▼tests/unit/health-route.test.ts
+81-0
| @@ -0,0 +1,81 @@ | ||
1 | +import { afterEach, describe, expect, it, vi } from "vitest"; | |
2 | + | |
3 | +const state = vi.hoisted(() => ({ | |
4 | + distributedLocking: true, | |
5 | + distributedRateLimit: true, | |
6 | + queueMultiInstanceSafe: true, | |
7 | +})); | |
8 | + | |
9 | +const rawQueryMock = vi.hoisted(() => vi.fn(async () => [{ "?column?": 1 }])); | |
10 | +const fsAccessMock = vi.hoisted(() => vi.fn(async () => undefined)); | |
11 | + | |
12 | +vi.mock("@/db/adapter", () => ({ | |
13 | + getDb: () => ({ rawQuery: rawQueryMock }), | |
14 | +})); | |
15 | + | |
16 | +vi.mock("@/lib/distributed-lock", () => ({ | |
17 | + get isDistributedLocking() { | |
18 | + return state.distributedLocking; | |
19 | + }, | |
20 | +})); | |
21 | + | |
22 | +vi.mock("@/lib/rate-limit", () => ({ | |
23 | + get isDistributed() { | |
24 | + return state.distributedRateLimit; | |
25 | + }, | |
26 | +})); | |
27 | + | |
28 | +vi.mock("@/lib/queue-worker", () => ({ | |
29 | + getQueueWorkerScalingReadiness: () => ({ | |
30 | + inProcessGuardEnabled: true, | |
31 | + distributedLockingEnabled: state.distributedLocking, | |
32 | + multiInstanceSafe: state.queueMultiInstanceSafe, | |
33 | + }), | |
34 | +})); | |
35 | + | |
36 | +vi.mock("fs/promises", () => ({ | |
37 | + access: fsAccessMock, | |
38 | +})); | |
39 | + | |
40 | +import { GET as healthGet } from "@/pages/api/health"; | |
41 | + | |
42 | +async function readJson(response: Response): Promise<any> { | |
43 | + return response.json(); | |
44 | +} | |
45 | + | |
46 | +describe("health route scaling diagnostics", () => { | |
47 | + afterEach(() => { | |
48 | + vi.unstubAllEnvs(); | |
49 | + state.distributedLocking = true; | |
50 | + state.distributedRateLimit = true; | |
51 | + state.queueMultiInstanceSafe = true; | |
52 | + }); | |
53 | + | |
54 | + it("returns healthy scaling diagnostics when distributed systems are active", async () => { | |
55 | + vi.stubEnv("NODE_ENV", "production"); | |
56 | + vi.stubEnv("REDIS_URL", "redis://localhost:6379"); | |
57 | + | |
58 | + const response = await healthGet({} as any); | |
59 | + const body = await readJson(response); | |
60 | + | |
61 | + expect(response.status).toBe(200); | |
62 | + expect(body?.checks?.scaling?.status).toBe("ok"); | |
63 | + expect(body?.checks?.scaling?.details?.distributedLocking).toBe(true); | |
64 | + expect(body?.checks?.scaling?.details?.distributedRateLimit).toBe(true); | |
65 | + }); | |
66 | + | |
67 | + it("returns unhealthy when production scaling requirements are not met", async () => { | |
68 | + vi.stubEnv("NODE_ENV", "production"); | |
69 | + vi.stubEnv("REDIS_URL", ""); | |
70 | + state.distributedLocking = false; | |
71 | + state.distributedRateLimit = false; | |
72 | + state.queueMultiInstanceSafe = false; | |
73 | + | |
74 | + const response = await healthGet({} as any); | |
75 | + const body = await readJson(response); | |
76 | + | |
77 | + expect(response.status).toBe(503); | |
78 | + expect(body?.checks?.scaling?.status).toBe("error"); | |
79 | + expect(String(body?.checks?.scaling?.message || "")).toContain("REDIS_URL is required"); | |
80 | + }); | |
81 | +}); | |
0 | 82 | |