feat(gingress): add Git UA routing and convert gingress to Helm templates

- Route requests with git/JGit User-Agent directly to gitserver backend
- Parse gingress.io/git-backend annotation (format: namespace/name:port)
- Convert static gingress YAML to Helm templates under deploy/templates/gingress/
- Add gingress config block to values.yaml (namespace, replicas, ports, resources)
This commit is contained in:
ZhenYi 2026-05-10 22:47:18 +08:00
parent 670bcc8c06
commit 7148c8fd39
8 changed files with 154 additions and 63 deletions

View File

@ -131,7 +131,7 @@ fn process_ingress(ingress: &Ingress, store: &ConfigStore, _ingress_class: &str)
// Process annotations for advanced features // Process annotations for advanced features
let annotations = ingress.annotations(); let annotations = ingress.annotations();
process_annotations(&annotations, &ingress_prefix, store); process_annotations(&annotations, &ingress_prefix, &namespace, store);
store.signal_reload(); 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_REQUEST_HEADERS: &str = "gingress.io/request-headers";
const ANN_WEBSOCKET: &str = "gingress.io/websocket"; const ANN_WEBSOCKET: &str = "gingress.io/websocket";
const ANN_SESSION_AFFINITY: &str = "gingress.io/session-affinity"; 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. /// Parse Ingress annotations and write corresponding ConfigStore entries.
/// ///
@ -175,6 +176,7 @@ const ANN_SESSION_AFFINITY: &str = "gingress.io/session-affinity";
fn process_annotations( fn process_annotations(
annotations: &BTreeMap<String, String>, annotations: &BTreeMap<String, String>,
ingress_prefix: &str, ingress_prefix: &str,
namespace: &str,
store: &ConfigStore, store: &ConfigStore,
) { ) {
// Collect hosts from the ingress routes that were just stored // 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) { 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<gingress_proxy::config::Backend> {
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). /// Remove a set of hosts from the global websocket host list (scoped cleanup).
fn prune_websocket_hosts(store: &ConfigStore, hosts_to_remove: &[String]) { fn prune_websocket_hosts(store: &ConfigStore, hosts_to_remove: &[String]) {
if let Some(mut existing) = store.get::<Vec<String>>("websocket:hosts") { if let Some(mut existing) = store.get::<Vec<String>>("websocket:hosts") {

View File

@ -71,6 +71,7 @@ impl Reconciler {
let headers = self.collect_headers(); let headers = self.collect_headers();
let session_affinity = self.collect_session_affinity(&routes); let session_affinity = self.collect_session_affinity(&routes);
let websocket_hosts = self.collect_websocket_hosts(); let websocket_hosts = self.collect_websocket_hosts();
let git_upstream = self.collect_git_backend();
// Step 5: Build the complete ProxyConfig // Step 5: Build the complete ProxyConfig
let cfg = ProxyConfig { let cfg = ProxyConfig {
@ -81,6 +82,7 @@ impl Reconciler {
headers, headers,
session_affinity, session_affinity,
websocket_hosts, websocket_hosts,
git_upstream,
}; };
// Step 6: Validate // Step 6: Validate
@ -231,4 +233,9 @@ impl Reconciler {
.get::<Vec<String>>("websocket:hosts") .get::<Vec<String>>("websocket:hosts")
.unwrap_or_default() .unwrap_or_default()
} }
/// Collect git backend configuration from annotation.
fn collect_git_backend(&self) -> Option<gingress_proxy::config::Backend> {
self.store.get("git_backend")
}
} }

View File

@ -2,11 +2,11 @@ apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: gingress-controller name: gingress-controller
namespace: gingress-system namespace: {{ .Values.gingress.namespace | default "gingress-system" }}
labels: labels:
app: gingress app: gingress
spec: spec:
replicas: 2 replicas: {{ .Values.gingress.replicaCount | default 2 }}
selector: selector:
matchLabels: matchLabels:
app: gingress app: gingress
@ -18,28 +18,30 @@ spec:
serviceAccountName: gingress-controller serviceAccountName: gingress-controller
containers: containers:
- name: gingress - name: gingress
image: gingress:latest image: "{{ .Values.imageRegistry }}/{{ .Values.gingress.repository }}:{{ .Values.imageTag | default .Chart.AppVersion }}"
imagePullPolicy: IfNotPresent imagePullPolicy: {{ .Values.gingress.imagePullPolicy | default "IfNotPresent" }}
{{- with .Values.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
args: args:
- "--ingress-class=gingress" - "--ingress-class=gingress"
- "--bind-http=0.0.0.0:80" - "--bind-http=0.0.0.0:{{ .Values.gingress.httpPort | default 80 }}"
- "--bind-https=0.0.0.0:443" - "--bind-https=0.0.0.0:{{ .Values.gingress.httpsPort | default 443 }}"
- "--metrics-bind=0.0.0.0:8080" - "--metrics-bind=0.0.0.0:8080"
ports: ports:
- name: http - name: http
containerPort: 80 containerPort: {{ .Values.gingress.httpPort | default 80 }}
protocol: TCP protocol: TCP
- name: https - name: https
containerPort: 443 containerPort: {{ .Values.gingress.httpsPort | default 443 }}
protocol: TCP protocol: TCP
- name: metrics - name: metrics
containerPort: 8080 containerPort: 8080
protocol: TCP protocol: TCP
env: env:
- name: RUST_LOG - name: RUST_LOG
value: "info" value: {{ .Values.gingress.logLevel | default "info" | quote }}
- name: METRICS_PUSH_URL
value: "" # Optional: push to metrics aggregator
livenessProbe: livenessProbe:
httpGet: httpGet:
path: /healthz path: /healthz
@ -52,13 +54,10 @@ spec:
port: 8080 port: 8080
initialDelaySeconds: 5 initialDelaySeconds: 5
periodSeconds: 5 periodSeconds: 5
{{- with .Values.gingress.resources }}
resources: resources:
requests: {{- toYaml . | nindent 12 }}
cpu: 100m {{- end }}
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
affinity: affinity:
podAntiAffinity: podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution: preferredDuringSchedulingIgnoredDuringExecution:
@ -68,26 +67,3 @@ spec:
matchLabels: matchLabels:
app: gingress app: gingress
topologyKey: kubernetes.io/hostname 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

View File

@ -1,13 +1,13 @@
apiVersion: v1 apiVersion: v1
kind: Namespace kind: Namespace
metadata: metadata:
name: gingress-system name: {{ .Values.gingress.namespace | default "gingress-system" }}
--- ---
apiVersion: v1 apiVersion: v1
kind: ServiceAccount kind: ServiceAccount
metadata: metadata:
name: gingress-controller name: gingress-controller
namespace: gingress-system namespace: {{ .Values.gingress.namespace | default "gingress-system" }}
--- ---
apiVersion: rbac.authorization.k8s.io/v1 apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole kind: ClusterRole
@ -38,7 +38,7 @@ roleRef:
subjects: subjects:
- kind: ServiceAccount - kind: ServiceAccount
name: gingress-controller name: gingress-controller
namespace: gingress-system namespace: {{ .Values.gingress.namespace | default "gingress-system" }}
--- ---
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: IngressClass kind: IngressClass

View File

@ -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

View File

@ -137,6 +137,22 @@ services:
mountPath: /data mountPath: /data
subPath: static 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) # External PVC (managed outside Helm — not deleted on uninstall)
pvcName: "shared-data" pvcName: "shared-data"
@ -146,6 +162,7 @@ ingress:
className: "gingress" className: "gingress"
annotations: annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod" cert-manager.io/cluster-issuer: "letsencrypt-prod"
gingress.io/git-backend: "deploy-gitserver:8021"
hosts: hosts:
- host: gitdata.ai - host: gitdata.ai
paths: paths:

View File

@ -90,6 +90,8 @@ pub struct ProxyConfig {
pub session_affinity: HashMap<String, SessionAffinityConfig>, pub session_affinity: HashMap<String, SessionAffinityConfig>,
/// WebSocket enabled hosts /// WebSocket enabled hosts
pub websocket_hosts: Vec<String>, pub websocket_hosts: Vec<String>,
/// Git backend — requests with Git User-Agent are routed here
pub git_upstream: Option<Backend>,
} }
/// The shared configuration store: read-heavy, write-light. /// The shared configuration store: read-heavy, write-light.

View File

@ -41,6 +41,23 @@ impl GIngressProxy {
pub fn filter_chain(&self) -> &Arc<std::sync::RwLock<FilterChain>> { pub fn filter_chain(&self) -> &Arc<std::sync::RwLock<FilterChain>> {
&self.filter_chain &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<String> {
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] #[async_trait::async_trait]
@ -66,23 +83,29 @@ impl ProxyHttp for GIngressProxy {
let cfg = self.config.assemble_proxy_config(); let cfg = self.config.assemble_proxy_config();
// Match path to a route rule // Git User-Agent override: requests from git clients (git/2.x, JGit, etc.)
let path = session.req_header().uri.path(); // are routed directly to the git backend regardless of host/path matching.
let route = cfg.routes.get(&host).and_then(|rules| { let backend_key = if let Some(ref git_backend) = cfg.git_upstream {
rules.iter().find(|r| match r.path_type { let ua = session
crate::config::PathType::Prefix | crate::config::PathType::ImplementationSpecific => { .req_header()
path.starts_with(&r.path) .headers
} .get("user-agent")
crate::config::PathType::Exact => path == r.path, .and_then(|v| v.to_str().ok())
}) .unwrap_or("");
});
let backend_key = route.map(|r| { if ua.starts_with("git/") || ua.starts_with("JGit/") {
format!( tracing::debug!(host, ua, "Git UA detected, routing to git backend");
"upstream:{}/{}:{}", Some(format!(
r.backend.namespace, r.backend.name, r.backend.port "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 // Select endpoint via load balancer
let endpoint = backend_key let endpoint = backend_key
@ -105,7 +128,7 @@ impl ProxyHttp for GIngressProxy {
} }
None => pingora::Error::e_explain( None => pingora::Error::e_explain(
pingora::ErrorType::InternalError, 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()),
), ),
} }
} }