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
▼deploy/helm/opencodehub/Chart.yaml
+7-0
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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