diff --git a/apps/gingress/src/controller/ingress_watcher.rs b/apps/gingress/src/controller/ingress_watcher.rs index 5afa26e..3fe9cab 100644 --- a/apps/gingress/src/controller/ingress_watcher.rs +++ b/apps/gingress/src/controller/ingress_watcher.rs @@ -131,7 +131,7 @@ fn process_ingress(ingress: &Ingress, store: &ConfigStore, _ingress_class: &str) // Process annotations for advanced features let annotations = ingress.annotations(); - process_annotations(&annotations, &ingress_prefix, store); + process_annotations(&annotations, &ingress_prefix, &namespace, store); store.signal_reload(); } @@ -163,6 +163,7 @@ const ANN_RATE_LIMIT_BURST: &str = "gingress.io/rate-limit-burst"; const ANN_REQUEST_HEADERS: &str = "gingress.io/request-headers"; const ANN_WEBSOCKET: &str = "gingress.io/websocket"; const ANN_SESSION_AFFINITY: &str = "gingress.io/session-affinity"; +const ANN_GIT_BACKEND: &str = "gingress.io/git-backend"; /// Parse Ingress annotations and write corresponding ConfigStore entries. /// @@ -175,6 +176,7 @@ const ANN_SESSION_AFFINITY: &str = "gingress.io/session-affinity"; fn process_annotations( annotations: &BTreeMap, ingress_prefix: &str, + namespace: &str, store: &ConfigStore, ) { // Collect hosts from the ingress routes that were just stored @@ -256,6 +258,22 @@ fn process_annotations( ); } } + + // ── Git backend ── + // When present, requests with Git User-Agent (git/*, JGit/*) are routed to + // this backend instead of normal host+path matching. + // Value format: "namespace/service-name:port" or "service-name:port" (namespace from Ingress) + if let Some(val) = annotations.get(ANN_GIT_BACKEND) { + if let Some(backend) = parse_git_backend(val, &namespace) { + store.set("git_backend", &backend); + tracing::info!( + backend = format!("{}/{}:{}", backend.namespace, backend.name, backend.port), + "Git backend configured" + ); + } else { + tracing::warn!(annotation = %ANN_GIT_BACKEND, value = %val, "Invalid git-backend format, expected 'namespace/name:port' or 'name:port'"); + } + } } fn parse_rate_limit(val: &str, burst_override: Option<&String>) -> (u32, u32) { @@ -321,6 +339,34 @@ fn parse_session_affinity(val: &str) -> (bool, String, u64) { } } +/// Parse git-backend annotation value. +/// +/// Format: "namespace/name:port" or "name:port" (namespace defaults to Ingress namespace). +fn parse_git_backend(val: &str, default_namespace: &str) -> Option { + let val = val.trim(); + // Split off port: "namespace/name:port" → ("namespace/name", "port") + let (ns_name, port_str) = val.rsplit_once(':').unwrap_or((val, "")); + let port: u16 = port_str.parse().ok()?; + + // Split namespace and name: "namespace/name" → ("namespace", "name") + let (namespace, name) = if let Some((ns, n)) = ns_name.rsplit_once('/') { + (ns.to_string(), n.to_string()) + } else { + // No namespace specified — use the Ingress namespace + (default_namespace.to_string(), ns_name.to_string()) + }; + + if name.is_empty() { + return None; + } + + Some(gingress_proxy::config::Backend { + namespace, + name, + port, + }) +} + /// Remove a set of hosts from the global websocket host list (scoped cleanup). fn prune_websocket_hosts(store: &ConfigStore, hosts_to_remove: &[String]) { if let Some(mut existing) = store.get::>("websocket:hosts") { diff --git a/apps/gingress/src/controller/reconciler.rs b/apps/gingress/src/controller/reconciler.rs index e4c9460..c18490b 100644 --- a/apps/gingress/src/controller/reconciler.rs +++ b/apps/gingress/src/controller/reconciler.rs @@ -71,6 +71,7 @@ impl Reconciler { let headers = self.collect_headers(); let session_affinity = self.collect_session_affinity(&routes); let websocket_hosts = self.collect_websocket_hosts(); + let git_upstream = self.collect_git_backend(); // Step 5: Build the complete ProxyConfig let cfg = ProxyConfig { @@ -81,6 +82,7 @@ impl Reconciler { headers, session_affinity, websocket_hosts, + git_upstream, }; // Step 6: Validate @@ -231,4 +233,9 @@ impl Reconciler { .get::>("websocket:hosts") .unwrap_or_default() } + + /// Collect git backend configuration from annotation. + fn collect_git_backend(&self) -> Option { + self.store.get("git_backend") + } } diff --git a/deploy/gingress/deployment.yaml b/deploy/templates/gingress/deployment.yaml similarity index 55% rename from deploy/gingress/deployment.yaml rename to deploy/templates/gingress/deployment.yaml index 58fd40c..e93bc30 100644 --- a/deploy/gingress/deployment.yaml +++ b/deploy/templates/gingress/deployment.yaml @@ -2,11 +2,11 @@ apiVersion: apps/v1 kind: Deployment metadata: name: gingress-controller - namespace: gingress-system + namespace: {{ .Values.gingress.namespace | default "gingress-system" }} labels: app: gingress spec: - replicas: 2 + replicas: {{ .Values.gingress.replicaCount | default 2 }} selector: matchLabels: app: gingress @@ -18,28 +18,30 @@ spec: serviceAccountName: gingress-controller containers: - name: gingress - image: gingress:latest - imagePullPolicy: IfNotPresent + image: "{{ .Values.imageRegistry }}/{{ .Values.gingress.repository }}:{{ .Values.imageTag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.gingress.imagePullPolicy | default "IfNotPresent" }} + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} args: - "--ingress-class=gingress" - - "--bind-http=0.0.0.0:80" - - "--bind-https=0.0.0.0:443" + - "--bind-http=0.0.0.0:{{ .Values.gingress.httpPort | default 80 }}" + - "--bind-https=0.0.0.0:{{ .Values.gingress.httpsPort | default 443 }}" - "--metrics-bind=0.0.0.0:8080" ports: - name: http - containerPort: 80 + containerPort: {{ .Values.gingress.httpPort | default 80 }} protocol: TCP - name: https - containerPort: 443 + containerPort: {{ .Values.gingress.httpsPort | default 443 }} protocol: TCP - name: metrics containerPort: 8080 protocol: TCP env: - name: RUST_LOG - value: "info" - - name: METRICS_PUSH_URL - value: "" # Optional: push to metrics aggregator + value: {{ .Values.gingress.logLevel | default "info" | quote }} livenessProbe: httpGet: path: /healthz @@ -52,13 +54,10 @@ spec: port: 8080 initialDelaySeconds: 5 periodSeconds: 5 + {{- with .Values.gingress.resources }} resources: - requests: - cpu: 100m - memory: 128Mi - limits: - cpu: 500m - memory: 512Mi + {{- toYaml . | nindent 12 }} + {{- end }} affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: @@ -67,27 +66,4 @@ spec: labelSelector: matchLabels: app: gingress - topologyKey: kubernetes.io/hostname ---- -apiVersion: v1 -kind: Service -metadata: - name: gingress - namespace: gingress-system -spec: - type: LoadBalancer - selector: - app: gingress - ports: - - name: http - port: 80 - targetPort: 80 - protocol: TCP - - name: https - port: 443 - targetPort: 443 - protocol: TCP - - name: metrics - port: 8080 - targetPort: 8080 - protocol: TCP + topologyKey: kubernetes.io/hostname \ No newline at end of file diff --git a/deploy/gingress/rbac.yaml b/deploy/templates/gingress/rbac.yaml similarity index 79% rename from deploy/gingress/rbac.yaml rename to deploy/templates/gingress/rbac.yaml index 885284e..2e55388 100644 --- a/deploy/gingress/rbac.yaml +++ b/deploy/templates/gingress/rbac.yaml @@ -1,13 +1,13 @@ apiVersion: v1 kind: Namespace metadata: - name: gingress-system + name: {{ .Values.gingress.namespace | default "gingress-system" }} --- apiVersion: v1 kind: ServiceAccount metadata: name: gingress-controller - namespace: gingress-system + namespace: {{ .Values.gingress.namespace | default "gingress-system" }} --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole @@ -38,11 +38,11 @@ roleRef: subjects: - kind: ServiceAccount name: gingress-controller - namespace: gingress-system + namespace: {{ .Values.gingress.namespace | default "gingress-system" }} --- apiVersion: networking.k8s.io/v1 kind: IngressClass metadata: name: gingress spec: - controller: gingress.io/gingress-controller + controller: gingress.io/gingress-controller \ No newline at end of file diff --git a/deploy/templates/gingress/service.yaml b/deploy/templates/gingress/service.yaml new file mode 100644 index 0000000..9ffe023 --- /dev/null +++ b/deploy/templates/gingress/service.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Service +metadata: + name: gingress + namespace: {{ .Values.gingress.namespace | default "gingress-system" }} + labels: + app: gingress +spec: + type: LoadBalancer + selector: + app: gingress + ports: + - name: http + port: {{ .Values.gingress.httpPort | default 80 }} + targetPort: http + protocol: TCP + - name: https + port: {{ .Values.gingress.httpsPort | default 443 }} + targetPort: https + protocol: TCP \ No newline at end of file diff --git a/deploy/values.yaml b/deploy/values.yaml index c5523dd..f9aa89c 100644 --- a/deploy/values.yaml +++ b/deploy/values.yaml @@ -137,6 +137,22 @@ services: mountPath: /data subPath: static +# Gingress controller configuration +gingress: + namespace: "gingress-system" + repository: gingress + replicaCount: 2 + httpPort: 80 + httpsPort: 443 + logLevel: "info" + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + # External PVC (managed outside Helm — not deleted on uninstall) pvcName: "shared-data" @@ -146,6 +162,7 @@ ingress: className: "gingress" annotations: cert-manager.io/cluster-issuer: "letsencrypt-prod" + gingress.io/git-backend: "deploy-gitserver:8021" hosts: - host: gitdata.ai paths: diff --git a/libs/gingress-proxy/src/config.rs b/libs/gingress-proxy/src/config.rs index 383e7b1..0e27d56 100644 --- a/libs/gingress-proxy/src/config.rs +++ b/libs/gingress-proxy/src/config.rs @@ -90,6 +90,8 @@ pub struct ProxyConfig { pub session_affinity: HashMap, /// WebSocket enabled hosts pub websocket_hosts: Vec, + /// Git backend — requests with Git User-Agent are routed here + pub git_upstream: Option, } /// The shared configuration store: read-heavy, write-light. diff --git a/libs/gingress-proxy/src/server.rs b/libs/gingress-proxy/src/server.rs index 804566f..ea90f60 100644 --- a/libs/gingress-proxy/src/server.rs +++ b/libs/gingress-proxy/src/server.rs @@ -41,6 +41,23 @@ impl GIngressProxy { pub fn filter_chain(&self) -> &Arc> { &self.filter_chain } + + /// Match a request to a route rule based on host and path. + fn match_route(cfg: &crate::config::ProxyConfig, host: &str, path: &str) -> Option { + cfg.routes.get(host).and_then(|rules| { + rules.iter().find(|r| match r.path_type { + crate::config::PathType::Prefix | crate::config::PathType::ImplementationSpecific => { + path.starts_with(&r.path) + } + crate::config::PathType::Exact => path == r.path, + }) + }).map(|r| { + format!( + "upstream:{}/{}:{}", + r.backend.namespace, r.backend.name, r.backend.port + ) + }) + } } #[async_trait::async_trait] @@ -66,23 +83,29 @@ impl ProxyHttp for GIngressProxy { let cfg = self.config.assemble_proxy_config(); - // Match path to a route rule - let path = session.req_header().uri.path(); - let route = cfg.routes.get(&host).and_then(|rules| { - rules.iter().find(|r| match r.path_type { - crate::config::PathType::Prefix | crate::config::PathType::ImplementationSpecific => { - path.starts_with(&r.path) - } - crate::config::PathType::Exact => path == r.path, - }) - }); + // Git User-Agent override: requests from git clients (git/2.x, JGit, etc.) + // are routed directly to the git backend regardless of host/path matching. + let backend_key = if let Some(ref git_backend) = cfg.git_upstream { + let ua = session + .req_header() + .headers + .get("user-agent") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); - let backend_key = route.map(|r| { - format!( - "upstream:{}/{}:{}", - r.backend.namespace, r.backend.name, r.backend.port - ) - }); + if ua.starts_with("git/") || ua.starts_with("JGit/") { + tracing::debug!(host, ua, "Git UA detected, routing to git backend"); + Some(format!( + "upstream:{}/{}:{}", + git_backend.namespace, git_backend.name, git_backend.port + )) + } else { + // Normal route matching for non-git requests + Self::match_route(&cfg, &host, session.req_header().uri.path()) + } + } else { + Self::match_route(&cfg, &host, session.req_header().uri.path()) + }; // Select endpoint via load balancer let endpoint = backend_key @@ -105,7 +128,7 @@ impl ProxyHttp for GIngressProxy { } None => pingora::Error::e_explain( pingora::ErrorType::InternalError, - format!("no upstream found for host '{}' path '{}'", host, path), + format!("no upstream found for host '{}' path '{}'", host, session.req_header().uri.path()), ), } }