diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..25c010d --- /dev/null +++ b/.env.example @@ -0,0 +1,65 @@ +# ============================================================ +# GitDataAI Development Environment Variables +# Copy to .env and adjust as needed +# ============================================================ + +# ── Database ─────────────────────────────────────────────── +POSTGRES_USER=gitdata +POSTGRES_PASSWORD=gitdata123 +POSTGRES_DB=app + +# ── MinIO (S3-compatible storage) ────────────────────────── +MINIO_ROOT_USER=admin +MINIO_ROOT_PASSWORD=mysecret123 + +# ── Application ──────────────────────────────────────────── +APP_API_PORT=8080 +APP_NAME=gitdata +APP_DOMAIN_URL=http://localhost +APP_SESSION_SECRET=supersecretdevkey123 + +# ── Database Connection ──────────────────────────────────── +APP_DATABASE_URL=postgres://gitdata:gitdata123@localhost:5432/app + +# ── Redis ────────────────────────────────────────────────── +APP_REDIS_URLS=redis://localhost:6379 + +# ── Qdrant Vector DB ─────────────────────────────────────── +APP_QDRANT_URL=http://localhost:6333 + +# ── NATS ─────────────────────────────────────────────────── +NATS_URL=nats://localhost:4222 + +# ── Storage (S3) ─────────────────────────────────────────── +APP_STORAGE_BACKEND=s3 +APP_STORAGE_S3_BUCKET=gitdata +APP_STORAGE_S3_REGION=us-east-1 +APP_STORAGE_S3_ENDPOINT_URL=http://localhost:9000 +APP_STORAGE_S3_ACCESS_KEY_ID=admin +APP_STORAGE_S3_SECRET_ACCESS_KEY=mysecret123 +APP_STORAGE_S3_FORCE_PATH_STYLE=true + +# ── Git Service (gitpod) ─────────────────────────────────── +APP_GIT_RPC_ADDR=0.0.0.0 +APP_GIT_RPC_PORT=5030 +APP_GIT_HTTP_PORT=5023 +APP_SSH_PORT=5022 + +# ── GitSync Health ───────────────────────────────────────── +APP_GITSYNC_HEALTH_PORT=5083 + +# ── SMTP (Email) ─────────────────────────────────────────── +# For development, use MailHog: docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog +APP_SMTP_HOST=localhost +APP_SMTP_PORT=1025 +APP_SMTP_USERNAME=dev +APP_SMTP_PASSWORD=dev +APP_SMTP_FROM=Gitdata + +# ── AI / Embed ───────────────────────────────────────────── +APP_AI_BASIC_URL=http://localhost:11434/v1 +APP_AI_API_KEY=ollama +APP_EMBED_MODEL_BASE_URL=http://localhost:11434/v1 +APP_EMBED_MODEL_API_KEY=ollama +APP_EMBED_MODEL_NAME=nomic-embed-text +APP_EMBED_MODEL_DIMENSIONS=768 diff --git a/chart/app/.helmignore b/chart/app/.helmignore new file mode 100644 index 0000000..e69de29 diff --git a/chart/app/Chart.yaml b/chart/app/Chart.yaml new file mode 100644 index 0000000..87fd54f --- /dev/null +++ b/chart/app/Chart.yaml @@ -0,0 +1,17 @@ +apiVersion: v2 +name: gitdataai +description: GitDataAI — Git platform with AI-powered code agents + +type: application +version: 0.2.0 +appVersion: "1.0.0" + +keywords: + - git + - ai + - agent + - code-review + - cicd + +maintainers: + - name: gitdataai-team diff --git a/chart/app/templates/NOTES.txt b/chart/app/templates/NOTES.txt new file mode 100644 index 0000000..105147e --- /dev/null +++ b/chart/app/templates/NOTES.txt @@ -0,0 +1,11 @@ +{{- $svcNames := list "gitdata" "gitpod" "gitsync" "email" }} +{{- range $svcName := $svcNames }} +{{- $svcCfg := index $.Values $svcName }} +{{- if and $svcCfg.enabled (not (eq $svcName "email")) }} +{{- if eq $svcName "gitpod" }} +{{- printf " http://%s-http.%s.svc.cluster.local:%d/" (include "app.serviceFullname" (dict "root" $ "name" $svcName)) (include "app.namespace" $) (int $svcCfg.service.port) }} +{{- else }} +{{- printf " http://%s.%s.svc.cluster.local:%d/" (include "app.serviceFullname" (dict "root" $ "name" $svcName)) (include "app.namespace" $) (int $svcCfg.service.port) }} +{{- end }} +{{- end }} +{{- end }} diff --git a/chart/app/templates/_helpers.tpl b/chart/app/templates/_helpers.tpl new file mode 100644 index 0000000..eb91b70 --- /dev/null +++ b/chart/app/templates/_helpers.tpl @@ -0,0 +1,125 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "app.name" -}} +{{- default .Chart.Name .Values.global.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "app.fullname" -}} +{{- if .Values.global.fullnameOverride }} +{{- .Values.global.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.global.nameOverride }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "app.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels. +*/}} +{{- define "app.labels" -}} +helm.sh/chart: {{ include "app.chart" . }} +app.kubernetes.io/name: {{ include "app.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Service labels. +*/}} +{{- define "app.serviceLabels" -}} +{{- $root := .root }} +{{- $name := .name }} +helm.sh/chart: {{ include "app.chart" $root }} +app.kubernetes.io/name: {{ $name }} +app.kubernetes.io/instance: {{ $root.Release.Name }} +app.kubernetes.io/component: {{ $name }} +{{- if $root.Chart.AppVersion }} +app.kubernetes.io/version: {{ $root.Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ $root.Release.Service }} +{{- end }} + +{{/* +Selector labels. +*/}} +{{- define "app.serviceSelectorLabels" -}} +{{- $root := .root }} +{{- $name := .name }} +app.kubernetes.io/name: {{ $name }} +app.kubernetes.io/instance: {{ $root.Release.Name }} +app.kubernetes.io/component: {{ $name }} +{{- end }} + +{{/* +Fully qualified service name: -- +*/}} +{{- define "app.serviceFullname" -}} +{{- $root := .root }} +{{- $name := .name }} +{{- if $root.Values.global.fullnameOverride }} +{{- printf "%s-%s" $root.Values.global.fullnameOverride $name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $chartName := default $root.Chart.Name $root.Values.global.nameOverride }} +{{- printf "%s-%s-%s" $root.Release.Name $chartName $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} + +{{/* +Namespace. +*/}} +{{- define "app.namespace" -}} +{{- .Values.global.namespace | default .Release.Namespace }} +{{- end }} + +{{/* +ServiceAccount name. +*/}} +{{- define "app.serviceAccountName" -}} +{{- if .Values.serviceAccount.name }} +{{- .Values.serviceAccount.name }} +{{- else }} +{{- include "app.fullname" . }} +{{- end }} +{{- end }} + +{{/* +Docker image reference. +*/}} +{{- define "app.image" -}} +{{- $globalRegistry := .root.Values.global.image.registry }} +{{- $registry := .svc.registry | default $globalRegistry }} +{{- $name := .svc.name }} +{{- $tag := .svc.tag | default .root.Values.global.image.tag | default "latest" }} +{{- printf "%s/%s:%s" $registry $name $tag }} +{{- end }} + +{{/* +Image pull secrets. +*/}} +{{- define "app.imagePullSecrets" -}} +{{- with .Values.global.imagePullSecrets }} +imagePullSecrets: + {{- toYaml . | nindent 2 }} +{{- end }} +{{- end }} + +{{/* +Gitpod RPC cluster DNS address. +*/}} +{{- define "app.gitpodRpcAddr" -}} +{{ include "app.serviceFullname" (dict "root" . "name" "gitpod") }}-rpc.{{ include "app.namespace" . }} +{{- end }} diff --git a/chart/app/templates/configmap.yaml b/chart/app/templates/configmap.yaml new file mode 100644 index 0000000..975f585 --- /dev/null +++ b/chart/app/templates/configmap.yaml @@ -0,0 +1,28 @@ +{{/* +Single shared ConfigMap for all services. +Merges global.env with service-specific overrides. +*/}} +{{- $allEnv := deepCopy ($.Values.global.env | default dict) }} +{{- /* Auto-fill APP_GIT_RPC_ADDR for gitdata -> gitpod-rpc service */}} +{{- if and $.Values.gitdata.enabled (not $.Values.gitdata.env.APP_GIT_RPC_ADDR) }} +{{- $_ := set $allEnv "APP_GIT_RPC_ADDR" (include "app.gitpodRpcAddr" $) }} +{{- end }} +{{- range $svcName, $svc := dict "gitdata" $.Values.gitdata "gitpod" $.Values.gitpod "gitsync" $.Values.gitsync "email" $.Values.email }} +{{- if $svc.enabled }} +{{- $allEnv = merge $allEnv ($svc.env | default dict) }} +{{- end }} +{{- end }} +{{- if $allEnv }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "app.fullname" $ }} + namespace: {{ $.Values.global.namespace | default $.Release.Namespace }} + labels: + {{- include "app.labels" $ | nindent 4 }} +data: + {{- range $k, $v := $allEnv }} + {{ $k }}: {{ $v | quote }} + {{- end }} +{{- end }} diff --git a/chart/app/templates/deployment.yaml b/chart/app/templates/deployment.yaml new file mode 100644 index 0000000..b42af8b --- /dev/null +++ b/chart/app/templates/deployment.yaml @@ -0,0 +1,381 @@ +{{/* +Deployments — One per enabled service. +All pods share app-data-pvc mounted at /data. +*/}} + +{{/* ============================================================ + gitdata — Main API server + ============================================================ */}} +{{- if .Values.gitdata.enabled }} +{{- $svc := .Values.gitdata }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "app.serviceFullname" (dict "root" . "name" "gitdata") }} + namespace: {{ include "app.namespace" . }} + labels: + {{- include "app.serviceLabels" (dict "root" . "name" "gitdata") | nindent 4 }} +spec: + replicas: {{ $svc.replicaCount }} + selector: + matchLabels: + {{- include "app.serviceSelectorLabels" (dict "root" . "name" "gitdata") | nindent 6 }} + template: + metadata: + annotations: + {{- with $svc.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "app.serviceLabels" (dict "root" . "name" "gitdata") | nindent 8 }} + spec: + {{- include "app.imagePullSecrets" . | nindent 6 }} + serviceAccountName: {{ include "app.serviceAccountName" . }} + securityContext: + {{- toYaml $svc.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + image: {{ include "app.image" (dict "root" . "svc" $svc.image) }} + imagePullPolicy: {{ .Values.global.image.pullPolicy }} + ports: + - name: http + containerPort: 8080 + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "app.fullname" . }} + resources: + {{- toYaml $svc.resources | nindent 12 }} + securityContext: + {{- toYaml $svc.securityContext | nindent 12 }} + volumeMounts: + - name: data + mountPath: /data + {{- with $svc.volumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + startupProbe: + httpGet: + path: {{ $svc.startupProbe.httpGet.path }} + port: {{ $svc.startupProbe.httpGet.port }} + initialDelaySeconds: {{ $svc.startupProbe.initialDelaySeconds }} + periodSeconds: {{ $svc.startupProbe.periodSeconds }} + failureThreshold: {{ $svc.startupProbe.failureThreshold }} + livenessProbe: + httpGet: + path: {{ $svc.livenessProbe.httpGet.path }} + port: {{ $svc.livenessProbe.httpGet.port }} + periodSeconds: {{ $svc.livenessProbe.periodSeconds }} + readinessProbe: + httpGet: + path: {{ $svc.readinessProbe.httpGet.path }} + port: {{ $svc.readinessProbe.httpGet.port }} + periodSeconds: {{ $svc.readinessProbe.periodSeconds }} + volumes: + - name: data + persistentVolumeClaim: + claimName: app-data-pvc + {{- with $svc.volumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $svc.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $svc.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $svc.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} + +{{/* ============================================================ + gitpod — Git protocol server (HTTP + SSH + gRPC) + ============================================================ */}} +{{- if .Values.gitpod.enabled }} +{{- $svc := .Values.gitpod }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "app.serviceFullname" (dict "root" . "name" "gitpod") }} + namespace: {{ include "app.namespace" . }} + labels: + {{- include "app.serviceLabels" (dict "root" . "name" "gitpod") | nindent 4 }} +spec: + replicas: {{ $svc.replicaCount }} + selector: + matchLabels: + {{- include "app.serviceSelectorLabels" (dict "root" . "name" "gitpod") | nindent 6 }} + template: + metadata: + annotations: + {{- with $svc.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "app.serviceLabels" (dict "root" . "name" "gitpod") | nindent 8 }} + spec: + {{- include "app.imagePullSecrets" . | nindent 6 }} + serviceAccountName: {{ include "app.serviceAccountName" . }} + securityContext: + {{- toYaml $svc.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + image: {{ include "app.image" (dict "root" . "svc" $svc.image) }} + imagePullPolicy: {{ .Values.global.image.pullPolicy }} + ports: + - name: http + containerPort: 8080 + protocol: TCP + - name: ssh + containerPort: 2222 + protocol: TCP + - name: grpc + containerPort: 50051 + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "app.fullname" . }} + resources: + {{- toYaml $svc.resources | nindent 12 }} + securityContext: + {{- toYaml $svc.securityContext | nindent 12 }} + volumeMounts: + - name: data + mountPath: /data + {{- with $svc.volumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- if $svc.sshHostKeySecret }} + - name: ssh-host-key + mountPath: /etc/ssh + readOnly: true + {{- end }} + startupProbe: + httpGet: + path: {{ $svc.startupProbe.httpGet.path }} + port: {{ $svc.startupProbe.httpGet.port }} + initialDelaySeconds: {{ $svc.startupProbe.initialDelaySeconds }} + periodSeconds: {{ $svc.startupProbe.periodSeconds }} + failureThreshold: {{ $svc.startupProbe.failureThreshold }} + livenessProbe: + httpGet: + path: {{ $svc.livenessProbe.httpGet.path }} + port: {{ $svc.livenessProbe.httpGet.port }} + periodSeconds: {{ $svc.livenessProbe.periodSeconds }} + readinessProbe: + httpGet: + path: {{ $svc.readinessProbe.httpGet.path }} + port: {{ $svc.readinessProbe.httpGet.port }} + periodSeconds: {{ $svc.readinessProbe.periodSeconds }} + volumes: + - name: data + persistentVolumeClaim: + claimName: app-data-pvc + {{- if $svc.sshHostKeySecret }} + - name: ssh-host-key + secret: + secretName: {{ $svc.sshHostKeySecret }} + defaultMode: 0600 + {{- end }} + {{- with $svc.volumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $svc.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $svc.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $svc.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} + +{{/* ============================================================ + gitsync — Git sync worker + ============================================================ */}} +{{- if .Values.gitsync.enabled }} +{{- $svc := .Values.gitsync }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "app.serviceFullname" (dict "root" . "name" "gitsync") }} + namespace: {{ include "app.namespace" . }} + labels: + {{- include "app.serviceLabels" (dict "root" . "name" "gitsync") | nindent 4 }} +spec: + replicas: {{ $svc.replicaCount }} + selector: + matchLabels: + {{- include "app.serviceSelectorLabels" (dict "root" . "name" "gitsync") | nindent 6 }} + template: + metadata: + annotations: + {{- with $svc.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "app.serviceLabels" (dict "root" . "name" "gitsync") | nindent 8 }} + spec: + {{- include "app.imagePullSecrets" . | nindent 6 }} + serviceAccountName: {{ include "app.serviceAccountName" . }} + securityContext: + {{- toYaml $svc.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + image: {{ include "app.image" (dict "root" . "svc" $svc.image) }} + imagePullPolicy: {{ .Values.global.image.pullPolicy }} + ports: + - name: health + containerPort: 8081 + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "app.fullname" . }} + resources: + {{- toYaml $svc.resources | nindent 12 }} + securityContext: + {{- toYaml $svc.securityContext | nindent 12 }} + volumeMounts: + - name: data + mountPath: /data + {{- with $svc.volumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + startupProbe: + httpGet: + path: {{ $svc.startupProbe.httpGet.path }} + port: {{ $svc.startupProbe.httpGet.port }} + initialDelaySeconds: {{ $svc.startupProbe.initialDelaySeconds }} + periodSeconds: {{ $svc.startupProbe.periodSeconds }} + failureThreshold: {{ $svc.startupProbe.failureThreshold }} + livenessProbe: + httpGet: + path: {{ $svc.livenessProbe.httpGet.path }} + port: {{ $svc.livenessProbe.httpGet.port }} + periodSeconds: {{ $svc.livenessProbe.periodSeconds }} + readinessProbe: + httpGet: + path: {{ $svc.readinessProbe.httpGet.path }} + port: {{ $svc.readinessProbe.httpGet.port }} + periodSeconds: {{ $svc.readinessProbe.periodSeconds }} + volumes: + - name: data + persistentVolumeClaim: + claimName: app-data-pvc + {{- with $svc.volumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $svc.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $svc.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $svc.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} + +{{/* ============================================================ + email — Email worker service + ============================================================ */}} +{{- if .Values.email.enabled }} +{{- $svc := .Values.email }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "app.serviceFullname" (dict "root" . "name" "email") }} + namespace: {{ include "app.namespace" . }} + labels: + {{- include "app.serviceLabels" (dict "root" . "name" "email") | nindent 4 }} +spec: + replicas: {{ $svc.replicaCount }} + selector: + matchLabels: + {{- include "app.serviceSelectorLabels" (dict "root" . "name" "email") | nindent 6 }} + template: + metadata: + annotations: + {{- with $svc.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "app.serviceLabels" (dict "root" . "name" "email") | nindent 8 }} + spec: + {{- include "app.imagePullSecrets" . | nindent 6 }} + serviceAccountName: {{ include "app.serviceAccountName" . }} + securityContext: + {{- toYaml $svc.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + image: {{ include "app.image" (dict "root" . "svc" $svc.image) }} + imagePullPolicy: {{ .Values.global.image.pullPolicy }} + ports: + - name: health + containerPort: 8083 + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "app.fullname" . }} + resources: + {{- toYaml $svc.resources | nindent 12 }} + securityContext: + {{- toYaml $svc.securityContext | nindent 12 }} + volumeMounts: + - name: data + mountPath: /data + {{- with $svc.volumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + startupProbe: + httpGet: + path: {{ $svc.startupProbe.httpGet.path }} + port: {{ $svc.startupProbe.httpGet.port }} + initialDelaySeconds: {{ $svc.startupProbe.initialDelaySeconds }} + periodSeconds: {{ $svc.startupProbe.periodSeconds }} + failureThreshold: {{ $svc.startupProbe.failureThreshold }} + livenessProbe: + httpGet: + path: {{ $svc.livenessProbe.httpGet.path }} + port: {{ $svc.livenessProbe.httpGet.port }} + periodSeconds: {{ $svc.livenessProbe.periodSeconds }} + readinessProbe: + httpGet: + path: {{ $svc.readinessProbe.httpGet.path }} + port: {{ $svc.readinessProbe.httpGet.port }} + periodSeconds: {{ $svc.readinessProbe.periodSeconds }} + volumes: + - name: data + persistentVolumeClaim: + claimName: app-data-pvc + {{- with $svc.volumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $svc.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $svc.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $svc.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/chart/app/templates/hpa.yaml b/chart/app/templates/hpa.yaml new file mode 100644 index 0000000..ffd15a3 --- /dev/null +++ b/chart/app/templates/hpa.yaml @@ -0,0 +1,38 @@ +{{- range $svcName := list "gitdata" "gitpod" "gitsync" "email" }} +{{- $svcCfg := index $.Values $svcName }} +{{- $hpaCfg := index $.Values.autoscaling $svcName }} +{{- if and $svcCfg.enabled $hpaCfg.enabled }} +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "app.serviceFullname" (dict "root" $ "name" $svcName) }} + namespace: {{ include "app.namespace" $ }} + labels: + {{- include "app.serviceLabels" (dict "root" $ "name" $svcName) | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "app.serviceFullname" (dict "root" $ "name" $svcName) }} + minReplicas: {{ $hpaCfg.minReplicas }} + maxReplicas: {{ $hpaCfg.maxReplicas }} + metrics: + {{- if $hpaCfg.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ $hpaCfg.targetCPUUtilizationPercentage }} + {{- end }} + {{- if $hpaCfg.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ $hpaCfg.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} +{{- end }} diff --git a/chart/app/templates/ingress.yaml b/chart/app/templates/ingress.yaml new file mode 100644 index 0000000..aad89c6 --- /dev/null +++ b/chart/app/templates/ingress.yaml @@ -0,0 +1,61 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "app.fullname" . }} + namespace: {{ include "app.namespace" . }} + labels: + {{- include "app.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + ingressClassName: {{ .Values.ingress.className }} + {{- if .Values.ingress.api.tls }} + tls: + {{- range .Values.ingress.api.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- range .Values.ingress.git.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.api.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "app.serviceFullname" (dict "root" $ "name" "gitdata") }} + port: + number: {{ $.Values.gitdata.service.port }} + {{- end }} + {{- end }} + {{- range .Values.ingress.git.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "app.serviceFullname" (dict "root" $ "name" "gitpod") }}-http + port: + number: {{ $.Values.gitpod.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/chart/app/templates/pdb.yaml b/chart/app/templates/pdb.yaml new file mode 100644 index 0000000..5bc5ba7 --- /dev/null +++ b/chart/app/templates/pdb.yaml @@ -0,0 +1,21 @@ +{{- if .Values.podDisruptionBudget.enabled }} +{{- range $svcName := list "gitdata" "gitpod" "gitsync" "email" }} +{{- $svcCfg := index $.Values $svcName }} +{{- $pdbCfg := index $.Values.podDisruptionBudget $svcName }} +{{- if and $svcCfg.enabled $pdbCfg.minAvailable }} +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "app.serviceFullname" (dict "root" $ "name" $svcName) }} + namespace: {{ $.Values.global.namespace | default $.Release.Namespace }} + labels: + {{- include "app.serviceLabels" (dict "root" $ "name" $svcName) | nindent 4 }} +spec: + minAvailable: {{ $pdbCfg.minAvailable }} + selector: + matchLabels: + {{- include "app.serviceSelectorLabels" (dict "root" $ "name" $svcName) | nindent 6 }} +{{- end }} +{{- end }} +{{- end }} diff --git a/chart/app/templates/service.yaml b/chart/app/templates/service.yaml new file mode 100644 index 0000000..2c29bb2 --- /dev/null +++ b/chart/app/templates/service.yaml @@ -0,0 +1,141 @@ +{{/* +Generate Services for each enabled service. +*/}} + +{{- if .Values.gitdata.enabled }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "app.serviceFullname" (dict "root" . "name" "gitdata") }} + namespace: {{ include "app.namespace" . }} + labels: + {{- include "app.serviceLabels" (dict "root" . "name" "gitdata") | nindent 4 }} + {{- with .Values.gitdata.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.gitdata.service.type }} + ports: + - port: {{ .Values.gitdata.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "app.serviceSelectorLabels" (dict "root" . "name" "gitdata") | nindent 4 }} +{{- end }} + +{{- if .Values.gitpod.enabled }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "app.serviceFullname" (dict "root" . "name" "gitpod") }}-http + namespace: {{ include "app.namespace" . }} + labels: + {{- include "app.serviceLabels" (dict "root" . "name" "gitpod") | nindent 4 }} + {{- with .Values.gitpod.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.gitpod.service.type }} + ports: + - port: {{ .Values.gitpod.service.port | default 8080 }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "app.serviceSelectorLabels" (dict "root" . "name" "gitpod") | nindent 4 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "app.serviceFullname" (dict "root" . "name" "gitpod") }}-ssh + namespace: {{ include "app.namespace" . }} + labels: + {{- include "app.serviceLabels" (dict "root" . "name" "gitpod") | nindent 4 }} + {{- with .Values.gitpod.sshService.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.gitpod.sshService.type }} + ports: + - port: {{ .Values.gitpod.sshService.port | default 2222 }} + targetPort: ssh + protocol: TCP + name: ssh + selector: + {{- include "app.serviceSelectorLabels" (dict "root" . "name" "gitpod") | nindent 4 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "app.serviceFullname" (dict "root" . "name" "gitpod") }}-rpc + namespace: {{ include "app.namespace" . }} + labels: + {{- include "app.serviceLabels" (dict "root" . "name" "gitpod") | nindent 4 }} + {{- with .Values.gitpod.rpcService.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.gitpod.rpcService.type }} + ports: + - port: {{ .Values.gitpod.rpcService.port | default 50051 }} + targetPort: grpc + protocol: TCP + name: grpc + selector: + {{- include "app.serviceSelectorLabels" (dict "root" . "name" "gitpod") | nindent 4 }} +{{- end }} + +{{- if .Values.gitsync.enabled }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "app.serviceFullname" (dict "root" . "name" "gitsync") }} + namespace: {{ include "app.namespace" . }} + labels: + {{- include "app.serviceLabels" (dict "root" . "name" "gitsync") | nindent 4 }} + {{- with .Values.gitsync.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.gitsync.service.type }} + ports: + - port: {{ .Values.gitsync.service.port | default 8081 }} + targetPort: health + protocol: TCP + name: health + selector: + {{- include "app.serviceSelectorLabels" (dict "root" . "name" "gitsync") | nindent 4 }} +{{- end }} + +{{- if and .Values.email.enabled .Values.email.service.enabled }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "app.serviceFullname" (dict "root" . "name" "email") }} + namespace: {{ include "app.namespace" . }} + labels: + {{- include "app.serviceLabels" (dict "root" . "name" "email") | nindent 4 }} + {{- with .Values.email.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.email.service.type }} + ports: + - port: {{ .Values.email.service.port | default 8083 }} + targetPort: health + protocol: TCP + name: health + selector: + {{- include "app.serviceSelectorLabels" (dict "root" . "name" "email") | nindent 4 }} +{{- end }} diff --git a/chart/app/templates/serviceaccount.yaml b/chart/app/templates/serviceaccount.yaml new file mode 100644 index 0000000..19d0038 --- /dev/null +++ b/chart/app/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "app.serviceAccountName" . }} + namespace: {{ include "app.namespace" . }} + labels: + {{- include "app.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/chart/app/templates/servicemonitor.yaml b/chart/app/templates/servicemonitor.yaml new file mode 100644 index 0000000..f860653 --- /dev/null +++ b/chart/app/templates/servicemonitor.yaml @@ -0,0 +1,36 @@ +{{- if .Values.serviceMonitor.enabled }} +{{- $svcNames := list "gitdata" "gitpod" "gitsync" "email" }} +{{- range $svcName := $svcNames }} +{{- $svcCfg := index $.Values $svcName }} +{{- $monitorCfg := index $.Values.serviceMonitor.services $svcName }} +{{- if and $svcCfg.enabled $monitorCfg }} +--- +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "app.serviceFullname" (dict "root" $ "name" $svcName) }} + namespace: {{ include "app.namespace" $ }} + labels: + {{- include "app.serviceLabels" (dict "root" $ "name" $svcName) | nindent 4 }} + {{- with $.Values.serviceMonitor.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with $.Values.serviceMonitor.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + endpoints: + - interval: {{ $.Values.serviceMonitor.interval }} + port: {{ if eq $svcName "gitdata" }}http{{ else if eq $svcName "gitpod" }}http{{ else }}health{{ end }} + {{- if eq $svcName "gitdata" }} + path: /metrics + {{- else }} + path: /health + {{- end }} + selector: + matchLabels: + {{- include "app.serviceSelectorLabels" (dict "root" $ "name" $svcName) | nindent 6 }} +{{- end }} +{{- end }} +{{- end }} diff --git a/chart/app/values.yaml b/chart/app/values.yaml new file mode 100644 index 0000000..c6d2222 --- /dev/null +++ b/chart/app/values.yaml @@ -0,0 +1,365 @@ +global: + image: + registry: "harbor.gitdata.me/app" + pullPolicy: IfNotPresent + tag: "latest" + imagePullSecrets: [] + nameOverride: "" + fullnameOverride: "" + namespace: "gitdataai" + +serviceAccount: + create: true + annotations: {} + name: "" + +gitdata: + enabled: true + replicaCount: 1 + image: + name: gitdata-gitdata + registry: "" + tag: "" + + env: + APP_API_PORT: "8080" + APP_OTEL_SERVICE_NAME: "gitdata-api" + APP_GIT_RPC_ADDR: "" + APP_GIT_RPC_PORT: "50051" + + service: + type: ClusterIP + port: 8080 + annotations: {} + + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: 1000m + memory: 1Gi + startupProbe: + httpGet: + path: /metrics + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 30 + + livenessProbe: + httpGet: + path: /metrics + port: http + periodSeconds: 15 + + readinessProbe: + httpGet: + path: /metrics + port: http + periodSeconds: 10 + + podAnnotations: + prometheus.io/scrape: "true" + prometheus.io/port: "8080" + prometheus.io/path: "/metrics" + + podSecurityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + + securityContext: + readOnlyRootFilesystem: false + allowPrivilegeEscalation: false + + nodeSelector: {} + tolerations: [] + affinity: {} + volumes: [] + volumeMounts: [] + + +gitpod: + enabled: true + replicaCount: 1 + image: + name: gitdata-gitpod + registry: "" + tag: "" + + env: + APP_GIT_HTTP_PORT: "8080" + APP_SSH_PORT: "2222" + APP_GIT_RPC_ADDR: "0.0.0.0" + APP_GIT_RPC_PORT: "50051" + APP_OTEL_SERVICE_NAME: "gitpod" + APP_SSH_DOMAIN: "" + APP_GIT_HTTP_DOMAIN: "" + APP_REPOS_ROOT: "/data/repos" + + service: + type: ClusterIP + port: 8080 + annotations: {} + + sshService: + type: LoadBalancer + port: 2222 + annotations: {} + + rpcService: + type: ClusterIP + port: 50051 + annotations: {} + + resources: + requests: + cpu: 500m + memory: 512Mi + limits: + cpu: 2000m + memory: 2Gi + + startupProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 30 + + livenessProbe: + httpGet: + path: /health + port: http + periodSeconds: 20 + + readinessProbe: + httpGet: + path: /health + port: http + periodSeconds: 15 + + podAnnotations: {} + podSecurityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + + securityContext: + readOnlyRootFilesystem: false + allowPrivilegeEscalation: false + + nodeSelector: {} + tolerations: [] + affinity: {} + + # -- SSH host key secret (mount to /etc/ssh) + sshHostKeySecret: "" + + # -- Data volumes (repos storage) + volumes: [] + volumeMounts: [] + + +gitsync: + enabled: true + replicaCount: 1 + image: + name: gitdata-gitsync + registry: "" + tag: "" + + env: + APP_GITSYNC_HEALTH_PORT: "8081" + APP_OTEL_SERVICE_NAME: "gitsync" + APP_REPOS_ROOT: "/data/repos" + + service: + type: ClusterIP + port: 8081 + annotations: {} + + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + + startupProbe: + httpGet: + path: /health + port: health + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 30 + + livenessProbe: + httpGet: + path: /health + port: health + periodSeconds: 30 + + readinessProbe: + httpGet: + path: /health + port: health + periodSeconds: 15 + + podAnnotations: {} + podSecurityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + + securityContext: + readOnlyRootFilesystem: false + allowPrivilegeEscalation: false + + nodeSelector: {} + tolerations: [] + affinity: {} + + + volumes: [] + volumeMounts: [] + + +email: + enabled: true + replicaCount: 1 + image: + name: gitdata-email + registry: "" + tag: "" + + env: + APP_EMAIL_HEALTH_PORT: "8083" + APP_OTEL_SERVICE_NAME: "email-service" + + service: + enabled: false + type: ClusterIP + port: 8083 + annotations: {} + + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 256Mi + + startupProbe: + httpGet: + path: /health + port: health + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 30 + + livenessProbe: + httpGet: + path: /health + port: health + periodSeconds: 30 + + readinessProbe: + httpGet: + path: /health + port: health + periodSeconds: 15 + + podAnnotations: {} + podSecurityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + + securityContext: + readOnlyRootFilesystem: false + allowPrivilegeEscalation: false + + nodeSelector: {} + tolerations: [] + affinity: {} + volumes: [] + volumeMounts: [] + +ingress: + enabled: true + className: "nginx" + annotations: + cert-manager.io/cluster-issuer: "cloudflare-acme-cluster-issuer" + api: + hosts: + - host: dev.gitdata.ai + paths: + - path: / + pathType: Prefix + tls: + - hosts: + - dev.gitdata.ai + secretName: dev-gitdata-ai-tls + git: + hosts: + - host: gitdev.gitdata.ai + paths: + - path: / + pathType: Prefix + tls: + - hosts: + - gitdev.gitdata.ai + secretName: gitdev-gitdata-ai-tls + +serviceMonitor: + enabled: false + interval: 30s + labels: {} + annotations: {} + services: + gitdata: true + gitpod: true + gitsync: true + email: true + +autoscaling: + gitdata: + enabled: false + minReplicas: 1 + maxReplicas: 10 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: "" + gitpod: + enabled: false + minReplicas: 1 + maxReplicas: 5 + targetCPUUtilizationPercentage: 75 + targetMemoryUtilizationPercentage: "" + gitsync: + enabled: false + minReplicas: 1 + maxReplicas: 5 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: "" + email: + enabled: false + minReplicas: 1 + maxReplicas: 3 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: "" + +podDisruptionBudget: + enabled: false + gitdata: + minAvailable: 1 + gitpod: + minAvailable: 1 + gitsync: + minAvailable: "" + email: + minAvailable: "" diff --git a/lib/track/Cargo.toml b/lib/track/Cargo.toml new file mode 100644 index 0000000..438eae1 --- /dev/null +++ b/lib/track/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "track" +version.workspace = true +edition.workspace = true +authors.workspace = true +description.workspace = true +repository.workspace = true +readme.workspace = true +homepage.workspace = true +license.workspace = true +keywords.workspace = true +categories.workspace = true +documentation.workspace = true +[lib] +path = "lib.rs" +name = "track" +[dependencies] +config = { workspace = true } + +opentelemetry = { version = "0.32.0", features = ["trace", "logs", "metrics"] } +opentelemetry_sdk = { version = "0.32.1", features = ["trace", "logs", "metrics", "rt-tokio"] } +opentelemetry-otlp = { version = "0.32.0", features = ["http-proto", "trace", "logs", "metrics", "reqwest-client", "tls-roots"] } +opentelemetry-appender-tracing = "0.32.0" +opentelemetry-prometheus = "0.32.0" +prometheus = "0.13" +tokio = { version = "1", features = ["full"] } +tracing-subscriber = { version = "0.3.20", features = ["fmt", "env-filter", "json"] } +tracing-appender = "0.2" +anyhow = { workspace = true } +tracing-opentelemetry = "0.33.0" +tracing = "0.1.41" + +[lints] +workspace = true diff --git a/lib/track/lib.rs b/lib/track/lib.rs new file mode 100644 index 0000000..596ebba --- /dev/null +++ b/lib/track/lib.rs @@ -0,0 +1,10 @@ +pub mod metrics; +mod otel; + +pub use metrics::{MetricsRegistry, record_otel_counter}; +pub use otel::LgtmGuard as OtelGuard; +pub use otel::LgtmGuard; +pub use otel::init_lgtm; + +// Re-export prometheus types so downstream crates only need `track`. +pub use prometheus::{Counter, CounterVec, Gauge, Histogram, HistogramVec}; diff --git a/lib/track/metrics.rs b/lib/track/metrics.rs new file mode 100644 index 0000000..55983a7 --- /dev/null +++ b/lib/track/metrics.rs @@ -0,0 +1,249 @@ +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use opentelemetry::KeyValue; +use prometheus::{ + CounterVec, Encoder, Gauge, HistogramOpts, HistogramVec, Opts, Registry, + TextEncoder, +}; + +#[derive(Clone)] +pub struct MetricsRegistry { + inner: Arc, +} + +struct MetricsRegistryInner { + registry: Registry, + counters: Mutex>, + histograms: Mutex>, + counter_vecs: Mutex>, + histogram_vecs: Mutex>, + gauges: Mutex>, +} + +impl MetricsRegistry { + pub fn new() -> Self { + Self { + inner: Arc::new(MetricsRegistryInner { + registry: Registry::new(), + counters: Mutex::new(HashMap::new()), + histograms: Mutex::new(HashMap::new()), + counter_vecs: Mutex::new(HashMap::new()), + histogram_vecs: Mutex::new(HashMap::new()), + gauges: Mutex::new(HashMap::new()), + }), + } + } + + pub fn registry(&self) -> &Registry { + &self.inner.registry + } + + pub fn register_counter( + &self, + name: &str, + help: &str, + ) -> prometheus::Result { + let mut counters = + self.inner.counters.lock().expect("metrics mutex poisoned"); + if let Some(counter) = counters.get(name) { + return Ok(counter.clone()); + } + + let counter = prometheus::Counter::new(name, help)?; + self.inner.registry.register(Box::new(counter.clone()))?; + counters.insert(name.to_string(), counter.clone()); + Ok(counter) + } + + pub fn register_histogram( + &self, + name: &str, + help: &str, + buckets: Vec, + ) -> prometheus::Result { + let mut histograms = self + .inner + .histograms + .lock() + .expect("metrics mutex poisoned"); + if let Some(histogram) = histograms.get(name) { + return Ok(histogram.clone()); + } + + let opts = prometheus::HistogramOpts::new(name, help).buckets(buckets); + let histogram = prometheus::Histogram::with_opts(opts)?; + self.inner.registry.register(Box::new(histogram.clone()))?; + histograms.insert(name.to_string(), histogram.clone()); + Ok(histogram) + } + + pub fn register_counter_vec( + &self, + name: &str, + help: &str, + labels: &[&str], + ) -> prometheus::Result { + let mut counter_vecs = self + .inner + .counter_vecs + .lock() + .expect("metrics mutex poisoned"); + if let Some(cv) = counter_vecs.get(name) { + return Ok(cv.clone()); + } + + let opts = Opts::new(name, help); + let cv = CounterVec::new(opts, labels)?; + self.inner.registry.register(Box::new(cv.clone()))?; + counter_vecs.insert(name.to_string(), cv.clone()); + Ok(cv) + } + + pub fn register_histogram_vec( + &self, + name: &str, + help: &str, + labels: &[&str], + buckets: Vec, + ) -> prometheus::Result { + let mut histogram_vecs = self + .inner + .histogram_vecs + .lock() + .expect("metrics mutex poisoned"); + if let Some(hv) = histogram_vecs.get(name) { + return Ok(hv.clone()); + } + + let opts = HistogramOpts::new(name, help).buckets(buckets); + let hv = HistogramVec::new(opts, labels)?; + self.inner.registry.register(Box::new(hv.clone()))?; + histogram_vecs.insert(name.to_string(), hv.clone()); + Ok(hv) + } + + pub fn register_gauge( + &self, + name: &str, + help: &str, + ) -> prometheus::Result { + let mut gauges = + self.inner.gauges.lock().expect("metrics mutex poisoned"); + if let Some(gauge) = gauges.get(name) { + return Ok(gauge.clone()); + } + + let gauge = prometheus::Gauge::new(name, help)?; + self.inner.registry.register(Box::new(gauge.clone()))?; + gauges.insert(name.to_string(), gauge.clone()); + Ok(gauge) + } + + pub fn encode(&self) -> Result { + let mut buffer = Vec::new(); + let encoder = TextEncoder::new(); + encoder + .encode(&self.inner.registry.gather(), &mut buffer) + .map_err(|e| format!("failed to encode metrics: {e}"))?; + String::from_utf8(buffer).map_err(|e| format!("invalid utf8: {e}")) + } + + pub fn gather(&self) -> Vec { + self.inner.registry.gather() + } +} + +impl Default for MetricsRegistry { + fn default() -> Self { + Self::new() + } +} + +pub fn record_otel_counter( + name: &'static str, + value: u64, + labels: &[(&'static str, String)], +) { + if value == 0 { + return; + } + + let attrs: Vec = labels + .iter() + .map(|(key, value)| KeyValue::new(*key, value.clone())) + .collect(); + + static COUNTERS: std::sync::OnceLock< + std::sync::Mutex< + HashMap<&'static str, opentelemetry::metrics::Counter>, + >, + > = std::sync::OnceLock::new(); + + let counters = + COUNTERS.get_or_init(|| std::sync::Mutex::new(HashMap::new())); + let mut map = counters.lock().expect("otel counter mutex poisoned"); + let counter = map + .entry(name) + .or_insert_with(|| { + opentelemetry::global::meter("gitdataai") + .u64_counter(name) + .build() + }) + .clone(); + drop(map); + counter.add(value, &attrs); +} + +#[cfg(test)] +mod tests { + use super::MetricsRegistry; + + #[test] + fn repeated_counter_vec_registration_reuses_metric() { + let registry = MetricsRegistry::new(); + + let first = registry + .register_counter_vec("test_events_total", "Test events", &["kind"]) + .expect("first registration should succeed"); + let second = registry + .register_counter_vec("test_events_total", "Test events", &["kind"]) + .expect("second registration should reuse existing metric"); + + first.with_label_values(&["a"]).inc(); + second.with_label_values(&["a"]).inc(); + + let encoded = registry.encode().expect("metrics should encode"); + assert!(encoded.contains("test_events_total{kind=\"a\"} 2")); + } + + #[test] + fn repeated_histogram_vec_registration_reuses_metric() { + let registry = MetricsRegistry::new(); + + let first = registry + .register_histogram_vec( + "test_duration_seconds", + "Test duration", + &["kind"], + vec![0.1, 1.0], + ) + .expect("first registration should succeed"); + let second = registry + .register_histogram_vec( + "test_duration_seconds", + "Test duration", + &["kind"], + vec![0.1, 1.0], + ) + .expect("second registration should reuse existing metric"); + + first.with_label_values(&["a"]).observe(0.2); + second.with_label_values(&["a"]).observe(0.3); + + let encoded = registry.encode().expect("metrics should encode"); + assert!(encoded.contains("test_duration_seconds_count{kind=\"a\"} 2")); + } +} diff --git a/lib/track/otel.rs b/lib/track/otel.rs new file mode 100644 index 0000000..7c7a268 --- /dev/null +++ b/lib/track/otel.rs @@ -0,0 +1,430 @@ +use std::collections::HashMap; +use std::io; +use std::time::Duration; + +use anyhow::{Context, bail}; +use config::AppConfig; +use opentelemetry::KeyValue; +use opentelemetry::trace::TracerProvider; +use opentelemetry_otlp::{WithExportConfig, WithHttpConfig}; +use opentelemetry_sdk::{ + Resource, logs::SdkLoggerProvider, metrics::SdkMeterProvider, + trace::SdkTracerProvider, +}; +use tracing_subscriber::{EnvFilter, Registry, layer::SubscriberExt}; + +const OTEL_EXPORT_TIMEOUT: Duration = Duration::from_secs(10); + +type WorkerGuard = tracing_appender::non_blocking::WorkerGuard; + +/// `io::Write` adapter that strips the path prefix down to `gitdataai/` for +/// cleaner log output. +#[derive(Clone)] +struct StripWriter { + inner: W, +} + +impl StripWriter { + fn new(inner: W) -> Self { + Self { inner } + } +} + +impl io::Write for StripWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + let s = String::from_utf8_lossy(buf); + if let Some(pos) = s.rfind("gitdataai/") { + let start = pos + "gitdataai/".len(); + self.inner.write_all(s[start..].as_bytes())?; + } else { + self.inner.write_all(buf)?; + } + Ok(buf.len()) + } + fn flush(&mut self) -> io::Result<()> { + self.inner.flush() + } +} + +fn strip_writer() -> impl for<'a> tracing_subscriber::fmt::MakeWriter<'a> { + || StripWriter::new(io::stdout()) +} + +pub struct LgtmGuard { + tracer_provider: SdkTracerProvider, + logger_provider: SdkLoggerProvider, + meter_provider: SdkMeterProvider, + _file_guard: Option, +} + +struct OtlpEndpoints { + traces: String, + logs: String, + metrics: String, +} + +impl Drop for LgtmGuard { + fn drop(&mut self) { + let mut had_error = false; + if let Err(err) = self.tracer_provider.shutdown() { + had_error = true; + eprintln!("failed to shutdown OTel tracer provider: {err}"); + } + if let Err(err) = self.meter_provider.shutdown() { + had_error = true; + eprintln!("failed to shutdown OTel meter provider: {err}"); + } + if let Err(err) = self.logger_provider.shutdown() { + had_error = true; + eprintln!("failed to shutdown OTel logger provider: {err}"); + } + drop(self._file_guard.take()); + if !had_error { + eprintln!("OpenTelemetry providers shut down"); + } + } +} + +pub fn init_lgtm(config: &AppConfig) -> anyhow::Result> { + let filter = EnvFilter::try_new(config.log_level()?)?; + let log_format = config.log_format()?; + + if !config.otel_enabled()? { + let file_guard = init_fmt_subscriber(filter, &log_format, config)?; + if let Some(guard) = file_guard { + static FILE_GUARD: std::sync::OnceLock = + std::sync::OnceLock::new(); + let _ = FILE_GUARD.set(guard); + } + return Ok(None); + } + + let (endpoint, tracer_provider, logger_provider, meter_provider) = + build_lgtm_guard(config)?; + let guard = LgtmGuard { + tracer_provider: tracer_provider.clone(), + logger_provider: logger_provider.clone(), + meter_provider: meter_provider.clone(), + _file_guard: None, + }; + let file_guard = + install_otel_subscriber(filter, &guard, &log_format, config)?; + opentelemetry::global::set_meter_provider(meter_provider.clone()); + tracing::info!(endpoint = %endpoint, "LGTM observability initialized"); + + Ok(Some(LgtmGuard { + tracer_provider, + logger_provider, + meter_provider, + _file_guard: file_guard, + })) +} + +fn build_lgtm_guard( + config: &AppConfig, +) -> anyhow::Result<( + String, + SdkTracerProvider, + SdkLoggerProvider, + SdkMeterProvider, +)> { + let endpoint = config.otel_endpoint()?; + let endpoints = build_otlp_endpoints(&endpoint)?; + let headers = build_headers(config)?; + let resource = build_resource(config)?; + + let trace_exporter = build_trace_exporter(&endpoints.traces, &headers)?; + let log_exporter = build_log_exporter(&endpoints.logs, &headers)?; + let metric_exporter = build_metric_exporter(&endpoints.metrics, headers)?; + + let tracer_provider = SdkTracerProvider::builder() + .with_batch_exporter(trace_exporter) + .with_resource(resource.clone()) + .build(); + let logger_provider = SdkLoggerProvider::builder() + .with_batch_exporter(log_exporter) + .with_resource(resource.clone()) + .build(); + let meter_provider = SdkMeterProvider::builder() + .with_periodic_exporter(metric_exporter) + .with_resource(resource) + .build(); + + Ok((endpoint, tracer_provider, logger_provider, meter_provider)) +} + +fn install_otel_subscriber( + filter: EnvFilter, + guard: &LgtmGuard, + log_format: &str, + config: &AppConfig, +) -> anyhow::Result> { + let tracer = guard.tracer_provider.tracer(env!("CARGO_PKG_NAME")); + let otel_log_layer = + opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge::new( + &guard.logger_provider, + ); + + if config.log_file_enabled()? { + let dir = config + .log_file_path() + .unwrap_or_else(|_| "./logs".to_string()); + let file_appender = + tracing_appender::rolling::daily(dir, "gitdataai.log"); + let (non_blocking, file_guard) = + tracing_appender::non_blocking(file_appender); + let file_writer = { + let nb = non_blocking; + move || StripWriter::new(nb.clone()) + }; + if log_format.eq_ignore_ascii_case("json") { + let subscriber = Registry::default() + .with(filter) + .with(tracing_opentelemetry::layer().with_tracer(tracer)) + .with(otel_log_layer) + .with( + tracing_subscriber::fmt::layer() + .json() + .with_target(false) + .with_file(true) + .with_line_number(true) + .with_writer(strip_writer()), + ) + .with( + tracing_subscriber::fmt::layer() + .json() + .with_ansi(false) + .with_target(false) + .with_file(true) + .with_line_number(true) + .with_writer(file_writer), + ); + tracing::subscriber::set_global_default(subscriber) + .context("failed to initialize tracing subscriber")?; + return Ok(Some(file_guard)); + } + let subscriber = Registry::default() + .with(filter) + .with(tracing_opentelemetry::layer().with_tracer(tracer)) + .with(otel_log_layer) + .with( + tracing_subscriber::fmt::layer() + .with_target(false) + .with_file(true) + .with_line_number(true) + .with_writer(strip_writer()), + ) + .with( + tracing_subscriber::fmt::layer() + .with_ansi(false) + .with_target(false) + .with_file(true) + .with_line_number(true) + .with_writer(file_writer), + ); + tracing::subscriber::set_global_default(subscriber) + .context("failed to initialize tracing subscriber")?; + return Ok(Some(file_guard)); + } + + if log_format.eq_ignore_ascii_case("json") { + let subscriber = Registry::default() + .with(filter) + .with(tracing_opentelemetry::layer().with_tracer(tracer)) + .with(otel_log_layer) + .with( + tracing_subscriber::fmt::layer() + .json() + .with_target(false) + .with_file(true) + .with_line_number(true) + .with_writer(strip_writer()), + ); + tracing::subscriber::set_global_default(subscriber) + .context("failed to initialize tracing subscriber")?; + return Ok(None); + } + + let subscriber = Registry::default() + .with(filter) + .with(tracing_opentelemetry::layer().with_tracer(tracer)) + .with(otel_log_layer) + .with( + tracing_subscriber::fmt::layer() + .with_target(false) + .with_file(true) + .with_line_number(true) + .with_writer(strip_writer()), + ); + tracing::subscriber::set_global_default(subscriber) + .context("failed to initialize tracing subscriber")?; + Ok(None) +} + +fn build_trace_exporter( + endpoint: &str, + headers: &HashMap, +) -> anyhow::Result { + opentelemetry_otlp::SpanExporter::builder() + .with_http() + .with_endpoint(endpoint) + .with_timeout(OTEL_EXPORT_TIMEOUT) + .with_headers(headers.clone()) + .build() + .context("failed to build OTLP trace exporter") +} + +fn build_log_exporter( + endpoint: &str, + headers: &HashMap, +) -> anyhow::Result { + opentelemetry_otlp::LogExporter::builder() + .with_http() + .with_endpoint(endpoint) + .with_timeout(OTEL_EXPORT_TIMEOUT) + .with_headers(headers.clone()) + .build() + .context("failed to build OTLP log exporter") +} + +fn build_metric_exporter( + endpoint: &str, + headers: HashMap, +) -> anyhow::Result { + opentelemetry_otlp::MetricExporter::builder() + .with_http() + .with_endpoint(endpoint) + .with_timeout(OTEL_EXPORT_TIMEOUT) + .with_headers(headers) + .build() + .context("failed to build OTLP metric exporter") +} + +fn init_fmt_subscriber( + filter: EnvFilter, + log_format: &str, + config: &AppConfig, +) -> anyhow::Result> { + let file_enabled = config.log_file_enabled()?; + + if log_format.eq_ignore_ascii_case("json") { + if file_enabled { + let dir = config + .log_file_path() + .unwrap_or_else(|_| "./logs".to_string()); + let file_appender = + tracing_appender::rolling::daily(dir, "gitdataai.log"); + let (non_blocking, guard) = + tracing_appender::non_blocking(file_appender); + let subscriber = tracing_subscriber::fmt() + .json() + .with_env_filter(filter) + .with_target(false) + .with_file(true) + .with_line_number(true) + .with_writer({ + let nb = non_blocking; + move || StripWriter::new(nb.clone()) + }) + .finish(); + tracing::subscriber::set_global_default(subscriber) + .context("failed to initialize tracing subscriber")?; + return Ok(Some(guard)); + } + let subscriber = tracing_subscriber::fmt() + .json() + .with_env_filter(filter) + .with_target(false) + .with_file(true) + .with_line_number(true) + .with_writer(strip_writer()) + .finish(); + tracing::subscriber::set_global_default(subscriber) + .context("failed to initialize tracing subscriber")?; + return Ok(None); + } + + if file_enabled { + let dir = config + .log_file_path() + .unwrap_or_else(|_| "./logs".to_string()); + let file_appender = + tracing_appender::rolling::daily(dir, "gitdataai.log"); + let (non_blocking, guard) = + tracing_appender::non_blocking(file_appender); + let subscriber = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(false) + .with_file(true) + .with_line_number(true) + .with_writer({ + let nb = non_blocking; + move || StripWriter::new(nb.clone()) + }) + .finish(); + tracing::subscriber::set_global_default(subscriber) + .context("failed to initialize tracing subscriber")?; + return Ok(Some(guard)); + } + let subscriber = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(false) + .with_file(true) + .with_line_number(true) + .with_writer(strip_writer()) + .finish(); + tracing::subscriber::set_global_default(subscriber) + .context("failed to initialize tracing subscriber")?; + Ok(None) +} + +fn build_resource(config: &AppConfig) -> anyhow::Result { + Ok(Resource::builder() + .with_service_name(config.otel_service_name()?) + .with_attributes([KeyValue::new( + "service.version", + config.otel_service_version()?, + )]) + .build()) +} + +fn build_headers( + config: &AppConfig, +) -> anyhow::Result> { + let mut headers = HashMap::new(); + if let Some(auth) = config.otel_authorization()? { + headers.insert("Authorization".to_string(), auth); + } else if let Some(token) = config.otel_organization()? { + headers.insert( + "signoz-access-token".to_string(), + format!("ingest:{token}"), + ); + } + Ok(headers) +} + +fn build_otlp_endpoints(endpoint: &str) -> anyhow::Result { + let base = normalize_otlp_base(endpoint)?; + Ok(OtlpEndpoints { + traces: format!("{base}/v1/traces"), + logs: format!("{base}/v1/logs"), + metrics: format!("{base}/v1/metrics"), + }) +} + +fn normalize_otlp_base(endpoint: &str) -> anyhow::Result { + let endpoint = endpoint.trim().trim_end_matches('/'); + if endpoint.is_empty() { + bail!("APP_OTEL_ENDPOINT must not be empty"); + } + for suffix in ["/v1/traces", "/v1/logs", "/v1/metrics"] { + if let Some(base) = endpoint.strip_suffix(suffix) { + let base = base.trim_end_matches('/'); + if base.is_empty() { + bail!("APP_OTEL_ENDPOINT base URL must not be empty"); + } + return Ok(base.to_string()); + } + } + Ok(endpoint.to_string()) +}