Compare commits
No commits in common. "f082429a58cd69417aec94d46c32337f27f7b99c" and "003f0477f4db43dceda5760a6b2402dddb2c28d9" have entirely different histories.
f082429a58
...
003f0477f4
@ -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, &namespace, store);
|
process_annotations(&annotations, &ingress_prefix, store);
|
||||||
|
|
||||||
store.signal_reload();
|
store.signal_reload();
|
||||||
}
|
}
|
||||||
@ -163,7 +163,6 @@ 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.
|
||||||
///
|
///
|
||||||
@ -176,7 +175,6 @@ const ANN_GIT_BACKEND: &str = "gingress.io/git-backend";
|
|||||||
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
|
||||||
@ -258,22 +256,6 @@ 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) {
|
||||||
@ -339,34 +321,6 @@ 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") {
|
||||||
|
|||||||
@ -71,7 +71,6 @@ 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 {
|
||||||
@ -82,7 +81,6 @@ impl Reconciler {
|
|||||||
headers,
|
headers,
|
||||||
session_affinity,
|
session_affinity,
|
||||||
websocket_hosts,
|
websocket_hosts,
|
||||||
git_upstream,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Step 6: Validate
|
// Step 6: Validate
|
||||||
@ -233,9 +231,4 @@ 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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
75
deploy.sh
75
deploy.sh
@ -1,75 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# ── helpers ──────────────────────────────────────────────────────────
|
|
||||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
|
||||||
log() { echo -e "${GREEN}[OK]${NC} $*"; }
|
|
||||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
|
||||||
err() { echo -e "${RED}[ERR]${NC} $*"; exit 1; }
|
|
||||||
|
|
||||||
command_exists() { command -v "$1" &>/dev/null; }
|
|
||||||
|
|
||||||
# ── defaults ─────────────────────────────────────────────────────────
|
|
||||||
NAMESPACE="${NAMESPACE:-app}"
|
|
||||||
RELEASE="${RELEASE:-deploy}"
|
|
||||||
CHART_DIR="${CHART_DIR:-./deploy}"
|
|
||||||
REGISTRY="${REGISTRY:-harbor.gitdata.me/gtateam}"
|
|
||||||
TAG="${TAG:-$(git rev-parse --short HEAD)}"
|
|
||||||
CONFIG_MAP="${CONFIG_MAP:-app-env}"
|
|
||||||
PVC_NAME="${PVC_NAME:-shared-data}"
|
|
||||||
|
|
||||||
# ── prerequisites ────────────────────────────────────────────────────
|
|
||||||
command_exists helm || err "helm not found — install via https://helm.sh/docs/intro/install/"
|
|
||||||
command_exists kubectl || err "kubectl not found — install via https://kubernetes.io/docs/tasks/tools/"
|
|
||||||
|
|
||||||
log "helm $(helm version --short)"
|
|
||||||
log "kubectl $(kubectl version --client --short 2>/dev/null || kubectl version -o json 2>/dev/null | grep gitVersion)"
|
|
||||||
|
|
||||||
# ── 1. Ensure namespace ──────────────────────────────────────────────
|
|
||||||
log "Ensuring namespace $NAMESPACE exists..."
|
|
||||||
kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f -
|
|
||||||
|
|
||||||
# ── 2. Ensure prerequisites ─────────────────────────────────────────
|
|
||||||
# ConfigMap (must exist before Helm install)
|
|
||||||
if ! kubectl get configmap "$CONFIG_MAP" -n "$NAMESPACE" &>/dev/null; then
|
|
||||||
err "ConfigMap '$CONFIG_MAP' not found in namespace '$NAMESPACE' — create it first"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# PVC (must exist before Helm install)
|
|
||||||
if ! kubectl get pvc "$PVC_NAME" -n "$NAMESPACE" &>/dev/null; then
|
|
||||||
err "PVC '$PVC_NAME' not found in namespace '$NAMESPACE' — create it first"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# cert-manager ClusterIssuer
|
|
||||||
if ! kubectl get clusterissuer letsencrypt-prod &>/dev/null; then
|
|
||||||
warn "ClusterIssuer 'letsencrypt-prod' not found — TLS certificate issuance will fail"
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "Prerequisites verified"
|
|
||||||
|
|
||||||
# ── 3. Lint chart ────────────────────────────────────────────────────
|
|
||||||
log "Linting Helm chart..."
|
|
||||||
helm lint "$CHART_DIR" || err "Helm lint failed"
|
|
||||||
|
|
||||||
# ── 4. Deploy ────────────────────────────────────────────────────────
|
|
||||||
log "Deploying release $RELEASE with tag $TAG..."
|
|
||||||
|
|
||||||
helm upgrade --install "$RELEASE" "$CHART_DIR" \
|
|
||||||
--namespace "$NAMESPACE" \
|
|
||||||
--set imageRegistry="$REGISTRY" \
|
|
||||||
--set imageTag="$TAG" \
|
|
||||||
--set configMapName="$CONFIG_MAP" \
|
|
||||||
--set pvcName="$PVC_NAME" \
|
|
||||||
--wait \
|
|
||||||
--timeout 5m
|
|
||||||
|
|
||||||
log "Release $RELEASE deployed successfully"
|
|
||||||
|
|
||||||
# ── 5. Verify ────────────────────────────────────────────────────────
|
|
||||||
log "Checking deployment status..."
|
|
||||||
kubectl get deployments -n "$NAMESPACE" -l app.kubernetes.io/instance="$RELEASE"
|
|
||||||
kubectl get pods -n "$NAMESPACE" -l app.kubernetes.io/instance="$RELEASE"
|
|
||||||
kubectl get services -n "$NAMESPACE" -l app.kubernetes.io/instance="$RELEASE"
|
|
||||||
kubectl get ingress -n "$NAMESPACE"
|
|
||||||
|
|
||||||
log "Deployment complete"
|
|
||||||
@ -21,5 +21,3 @@
|
|||||||
.idea/
|
.idea/
|
||||||
*.tmproj
|
*.tmproj
|
||||||
.vscode/
|
.vscode/
|
||||||
# Secrets
|
|
||||||
.server.yaml
|
|
||||||
|
|||||||
@ -2,11 +2,11 @@ apiVersion: apps/v1
|
|||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
name: gingress-controller
|
name: gingress-controller
|
||||||
namespace: {{ .Values.gingress.namespace | default "gingress-system" }}
|
namespace: gingress-system
|
||||||
labels:
|
labels:
|
||||||
app: gingress
|
app: gingress
|
||||||
spec:
|
spec:
|
||||||
replicas: {{ .Values.gingress.replicaCount | default 2 }}
|
replicas: 2
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: gingress
|
app: gingress
|
||||||
@ -18,30 +18,28 @@ spec:
|
|||||||
serviceAccountName: gingress-controller
|
serviceAccountName: gingress-controller
|
||||||
containers:
|
containers:
|
||||||
- name: gingress
|
- name: gingress
|
||||||
image: "{{ .Values.imageRegistry }}/{{ .Values.gingress.repository }}:{{ .Values.imageTag | default .Chart.AppVersion }}"
|
image: gingress:latest
|
||||||
imagePullPolicy: {{ .Values.gingress.imagePullPolicy | default "IfNotPresent" }}
|
imagePullPolicy: IfNotPresent
|
||||||
{{- with .Values.securityContext }}
|
|
||||||
securityContext:
|
|
||||||
{{- toYaml . | nindent 12 }}
|
|
||||||
{{- end }}
|
|
||||||
args:
|
args:
|
||||||
- "--ingress-class=gingress"
|
- "--ingress-class=gingress"
|
||||||
- "--bind-http=0.0.0.0:{{ .Values.gingress.httpPort | default 80 }}"
|
- "--bind-http=0.0.0.0:80"
|
||||||
- "--bind-https=0.0.0.0:{{ .Values.gingress.httpsPort | default 443 }}"
|
- "--bind-https=0.0.0.0:443"
|
||||||
- "--metrics-bind=0.0.0.0:8080"
|
- "--metrics-bind=0.0.0.0:8080"
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: {{ .Values.gingress.httpPort | default 80 }}
|
containerPort: 80
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
- name: https
|
- name: https
|
||||||
containerPort: {{ .Values.gingress.httpsPort | default 443 }}
|
containerPort: 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: {{ .Values.gingress.logLevel | default "info" | quote }}
|
value: "info"
|
||||||
|
- name: METRICS_PUSH_URL
|
||||||
|
value: "" # Optional: push to metrics aggregator
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /healthz
|
path: /healthz
|
||||||
@ -54,10 +52,13 @@ spec:
|
|||||||
port: 8080
|
port: 8080
|
||||||
initialDelaySeconds: 5
|
initialDelaySeconds: 5
|
||||||
periodSeconds: 5
|
periodSeconds: 5
|
||||||
{{- with .Values.gingress.resources }}
|
|
||||||
resources:
|
resources:
|
||||||
{{- toYaml . | nindent 12 }}
|
requests:
|
||||||
{{- end }}
|
cpu: 100m
|
||||||
|
memory: 128Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
affinity:
|
affinity:
|
||||||
podAntiAffinity:
|
podAntiAffinity:
|
||||||
preferredDuringSchedulingIgnoredDuringExecution:
|
preferredDuringSchedulingIgnoredDuringExecution:
|
||||||
@ -66,4 +67,27 @@ spec:
|
|||||||
labelSelector:
|
labelSelector:
|
||||||
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
|
||||||
@ -1,13 +1,13 @@
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Namespace
|
kind: Namespace
|
||||||
metadata:
|
metadata:
|
||||||
name: {{ .Values.gingress.namespace | default "gingress-system" }}
|
name: gingress-system
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: ServiceAccount
|
kind: ServiceAccount
|
||||||
metadata:
|
metadata:
|
||||||
name: gingress-controller
|
name: gingress-controller
|
||||||
namespace: {{ .Values.gingress.namespace | default "gingress-system" }}
|
namespace: gingress-system
|
||||||
---
|
---
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
kind: ClusterRole
|
kind: ClusterRole
|
||||||
@ -38,11 +38,11 @@ roleRef:
|
|||||||
subjects:
|
subjects:
|
||||||
- kind: ServiceAccount
|
- kind: ServiceAccount
|
||||||
name: gingress-controller
|
name: gingress-controller
|
||||||
namespace: {{ .Values.gingress.namespace | default "gingress-system" }}
|
namespace: gingress-system
|
||||||
---
|
---
|
||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: IngressClass
|
kind: IngressClass
|
||||||
metadata:
|
metadata:
|
||||||
name: gingress
|
name: gingress
|
||||||
spec:
|
spec:
|
||||||
controller: gingress.io/gingress-controller
|
controller: gingress.io/gingress-controller
|
||||||
@ -1,20 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: {{ include "deploy.serviceFullname" (dict "root" . "svcKey" "gitserver") }}-ssh
|
|
||||||
labels:
|
|
||||||
{{- include "deploy.labels" . | nindent 4 }}
|
|
||||||
app.kubernetes.io/component: gitserver
|
|
||||||
spec:
|
|
||||||
type: LoadBalancer
|
|
||||||
ports:
|
|
||||||
- port: {{ .Values.services.gitserver.ports.ssh }}
|
|
||||||
targetPort: ssh
|
|
||||||
protocol: TCP
|
|
||||||
name: ssh
|
|
||||||
selector:
|
|
||||||
{{- include "deploy.serviceSelectorLabels" (dict "root" . "svcKey" "gitserver") | nindent 4 }}
|
|
||||||
@ -137,50 +137,25 @@ services:
|
|||||||
mountPath: /data
|
mountPath: /data
|
||||||
subPath: static
|
subPath: static
|
||||||
|
|
||||||
# Gingress controller configuration
|
|
||||||
gingress:
|
|
||||||
namespace: "app"
|
|
||||||
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"
|
||||||
|
|
||||||
# Ingress — handled by gingress controller
|
# Ingress — only for the main app service
|
||||||
ingress:
|
ingress:
|
||||||
enabled: true
|
enabled: false
|
||||||
className: "gingress"
|
className: ""
|
||||||
annotations:
|
annotations: {}
|
||||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
|
||||||
gingress.io/git-backend: "deploy-gitserver:8021"
|
|
||||||
hosts:
|
hosts:
|
||||||
- host: gitdata.ai
|
- host: chart-example.local
|
||||||
paths:
|
paths:
|
||||||
- path: /
|
- path: /
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
serviceName: app
|
serviceName: app
|
||||||
servicePort: 3000
|
servicePort: 3000
|
||||||
- host: static.gitdata.ai
|
tls: []
|
||||||
paths:
|
# - secretName: chart-example-tls
|
||||||
- path: /
|
# hosts:
|
||||||
pathType: Prefix
|
# - chart-example.local
|
||||||
serviceName: static_server
|
|
||||||
servicePort: 8081
|
|
||||||
tls:
|
|
||||||
- secretName: gitdata-ai-tls
|
|
||||||
hosts:
|
|
||||||
- gitdata.ai
|
|
||||||
- static.gitdata.ai
|
|
||||||
|
|
||||||
imagePullSecrets: []
|
imagePullSecrets: []
|
||||||
nameOverride: ""
|
nameOverride: ""
|
||||||
|
|||||||
@ -90,8 +90,6 @@ 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.
|
||||||
|
|||||||
@ -41,23 +41,6 @@ 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]
|
||||||
@ -83,29 +66,23 @@ impl ProxyHttp for GIngressProxy {
|
|||||||
|
|
||||||
let cfg = self.config.assemble_proxy_config();
|
let cfg = self.config.assemble_proxy_config();
|
||||||
|
|
||||||
// Git User-Agent override: requests from git clients (git/2.x, JGit, etc.)
|
// Match path to a route rule
|
||||||
// are routed directly to the git backend regardless of host/path matching.
|
let path = session.req_header().uri.path();
|
||||||
let backend_key = if let Some(ref git_backend) = cfg.git_upstream {
|
let route = cfg.routes.get(&host).and_then(|rules| {
|
||||||
let ua = session
|
rules.iter().find(|r| match r.path_type {
|
||||||
.req_header()
|
crate::config::PathType::Prefix | crate::config::PathType::ImplementationSpecific => {
|
||||||
.headers
|
path.starts_with(&r.path)
|
||||||
.get("user-agent")
|
}
|
||||||
.and_then(|v| v.to_str().ok())
|
crate::config::PathType::Exact => path == r.path,
|
||||||
.unwrap_or("");
|
})
|
||||||
|
});
|
||||||
|
|
||||||
if ua.starts_with("git/") || ua.starts_with("JGit/") {
|
let backend_key = route.map(|r| {
|
||||||
tracing::debug!(host, ua, "Git UA detected, routing to git backend");
|
format!(
|
||||||
Some(format!(
|
"upstream:{}/{}:{}",
|
||||||
"upstream:{}/{}:{}",
|
r.backend.namespace, r.backend.name, r.backend.port
|
||||||
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
|
||||||
@ -128,7 +105,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, session.req_header().uri.path()),
|
format!("no upstream found for host '{}' path '{}'", host, path),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,41 @@ const ROLE_COLORS: Record<string, string> = {
|
|||||||
member: 'var(--role-blue)',
|
member: 'var(--role-blue)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Fallback mock data for non-project pages or loading states */
|
||||||
|
const MOCK_ROLES = [
|
||||||
|
{
|
||||||
|
name: 'Admin',
|
||||||
|
color: 'var(--role-red)',
|
||||||
|
members: [
|
||||||
|
{ name: 'ZhenYi', status: 'online' as const, activity: 'Coding' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Maintainer',
|
||||||
|
color: 'var(--role-orange)',
|
||||||
|
members: [
|
||||||
|
{ name: 'Alex', status: 'online' as const, activity: '' },
|
||||||
|
{ name: 'Mia', status: 'idle' as const, activity: 'Reviewing PR' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Developer',
|
||||||
|
color: 'var(--role-blue)',
|
||||||
|
members: [
|
||||||
|
{ name: 'Tom', status: 'online' as const, activity: '' },
|
||||||
|
{ name: 'Luna', status: 'offline' as const, activity: '' },
|
||||||
|
{ name: 'Jake', status: 'offline' as const, activity: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Guest',
|
||||||
|
color: 'var(--role-gray)',
|
||||||
|
members: [
|
||||||
|
{ name: 'Sam', status: 'offline' as const, activity: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function useRoomSafe() {
|
function useRoomSafe() {
|
||||||
try {
|
try {
|
||||||
return useRoom();
|
return useRoom();
|
||||||
@ -33,7 +68,6 @@ export function MemberList() {
|
|||||||
|
|
||||||
const [groups, setGroups] = useState<MemberGroup[]>([]);
|
const [groups, setGroups] = useState<MemberGroup[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [apiPresence, setApiPresence] = useState<Map<string, PresenceStatus>>(new Map());
|
const [apiPresence, setApiPresence] = useState<Map<string, PresenceStatus>>(new Map());
|
||||||
|
|
||||||
// Fetch project presence from API
|
// Fetch project presence from API
|
||||||
@ -93,12 +127,10 @@ export function MemberList() {
|
|||||||
if (!projectName) {
|
if (!projectName) {
|
||||||
setGroups([]);
|
setGroups([]);
|
||||||
setTotal(0);
|
setTotal(0);
|
||||||
setLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
projectMembersGrouped(projectName)
|
projectMembersGrouped(projectName)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
@ -112,109 +144,139 @@ export function MemberList() {
|
|||||||
console.error('[MemberList] failed to load project members:', err);
|
console.error('[MemberList] failed to load project members:', err);
|
||||||
setGroups([]);
|
setGroups([]);
|
||||||
setTotal(0);
|
setTotal(0);
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) setLoading(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [projectName]);
|
}, [projectName]);
|
||||||
|
|
||||||
// Loading state
|
// Real project members loaded
|
||||||
if (loading) {
|
if (groups.length > 0) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex flex-col items-center justify-center h-full w-[240px]"
|
className="flex flex-col h-full w-[240px] pt-4 px-2 overflow-y-auto"
|
||||||
style={{ backgroundColor: 'var(--surface-sidebar)' }}
|
style={{ backgroundColor: 'var(--surface-sidebar)' }}
|
||||||
>
|
>
|
||||||
<div className="w-5 h-5 rounded-full border-2 border-t-transparent animate-spin" style={{ borderColor: 'var(--text-muted)', borderTopColor: 'transparent' }} />
|
<div
|
||||||
|
className="px-2 py-2 text-[11px] font-semibold uppercase tracking-wider"
|
||||||
|
style={{ color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
Members — {total}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groups.map((g) => {
|
||||||
|
const color = ROLE_COLORS[g.role.toLowerCase()] || 'var(--role-gray)';
|
||||||
|
return (
|
||||||
|
<div key={g.role} className="mb-2">
|
||||||
|
<div
|
||||||
|
className="flex items-center px-2 py-1 text-[11px] font-semibold uppercase tracking-wider"
|
||||||
|
style={{ color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
<span style={{ color }}>{g.role}</span>
|
||||||
|
<span className="ml-1">— {g.members.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{g.members.map((m) => {
|
||||||
|
const presence = presenceMap.get(m.user_id) || 'offline';
|
||||||
|
const isOffline = presence === 'offline';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m.user_id}
|
||||||
|
className={`flex items-center gap-3 px-2 py-1.5 rounded-[4px] transition-colors cursor-pointer w-full text-left ${
|
||||||
|
isOffline ? 'opacity-40' : ''
|
||||||
|
}`}
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||||
|
<Avatar
|
||||||
|
senderType="user"
|
||||||
|
displayName={m.username}
|
||||||
|
avatarUrl={m.avatar_url}
|
||||||
|
roleColor={color}
|
||||||
|
size={32}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 rounded-full border-[3px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: STATUS_COLORS[presence] || STATUS_COLORS.offline,
|
||||||
|
borderColor: 'var(--surface-sidebar)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[13px] font-medium truncate" style={{ color }}>
|
||||||
|
{m.username}
|
||||||
|
</p>
|
||||||
|
{m.display_name && (
|
||||||
|
<p className="text-[11px] truncate" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{m.display_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// No project selected or no members
|
// Loading / no project selected: show mock fallback
|
||||||
if (groups.length === 0) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex flex-col items-center justify-center h-full w-[240px]"
|
|
||||||
style={{ backgroundColor: 'var(--surface-sidebar)' }}
|
|
||||||
>
|
|
||||||
<p className="text-[12px]" style={{ color: 'var(--text-muted)' }}>
|
|
||||||
{projectName ? 'No members' : 'Select a project'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Real project members
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex flex-col h-full w-[240px] pt-4 px-2 overflow-y-auto"
|
className="flex flex-col h-full w-[240px] pt-6 px-2 overflow-y-auto"
|
||||||
style={{ backgroundColor: 'var(--surface-sidebar)' }}
|
style={{ backgroundColor: 'var(--surface-sidebar)' }}
|
||||||
>
|
>
|
||||||
<div
|
{MOCK_ROLES.map((role) => (
|
||||||
className="px-2 py-2 text-[11px] font-semibold uppercase tracking-wider"
|
<div key={role.name} className="mb-2">
|
||||||
style={{ color: 'var(--text-muted)' }}
|
<div
|
||||||
>
|
className="flex items-center px-1 py-1 text-[11px] font-semibold uppercase"
|
||||||
Members — {total}
|
style={{ color: 'var(--text-muted)' }}
|
||||||
</div>
|
>
|
||||||
|
<span style={{ color: role.color }}>{role.name}</span>
|
||||||
{groups.map((g) => {
|
<span className="ml-1">— {role.members.length}</span>
|
||||||
const color = ROLE_COLORS[g.role.toLowerCase()] || 'var(--role-gray)';
|
|
||||||
return (
|
|
||||||
<div key={g.role} className="mb-2">
|
|
||||||
<div
|
|
||||||
className="flex items-center px-2 py-1 text-[11px] font-semibold uppercase tracking-wider"
|
|
||||||
style={{ color: 'var(--text-muted)' }}
|
|
||||||
>
|
|
||||||
<span style={{ color }}>{g.role}</span>
|
|
||||||
<span className="ml-1">— {g.members.length}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{g.members.map((m) => {
|
|
||||||
const presence = presenceMap.get(m.user_id) || 'offline';
|
|
||||||
const isOffline = presence === 'offline';
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={m.user_id}
|
|
||||||
className={`flex items-center gap-3 px-2 py-1.5 rounded-[4px] transition-colors cursor-pointer w-full text-left ${
|
|
||||||
isOffline ? 'opacity-40' : ''
|
|
||||||
}`}
|
|
||||||
style={{ color: 'var(--text-primary)' }}
|
|
||||||
>
|
|
||||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
|
||||||
<Avatar
|
|
||||||
senderType="user"
|
|
||||||
displayName={m.username}
|
|
||||||
avatarUrl={m.avatar_url}
|
|
||||||
roleColor={color}
|
|
||||||
size={32}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 rounded-full border-[3px]"
|
|
||||||
style={{
|
|
||||||
backgroundColor: STATUS_COLORS[presence] || STATUS_COLORS.offline,
|
|
||||||
borderColor: 'var(--surface-sidebar)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-[13px] font-medium truncate" style={{ color }}>
|
|
||||||
{m.username}
|
|
||||||
</p>
|
|
||||||
{m.display_name && (
|
|
||||||
<p className="text-[11px] truncate" style={{ color: 'var(--text-muted)' }}>
|
|
||||||
{m.display_name}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})}
|
{role.members.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.name}
|
||||||
|
className={`flex items-center gap-3 px-2 py-1.5 rounded-[4px] transition-colors cursor-pointer w-full text-left ${
|
||||||
|
m.status === 'offline' ? 'opacity-40' : ''
|
||||||
|
}`}
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center relative flex-shrink-0"
|
||||||
|
style={{ backgroundColor: role.color }}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium" style={{ color: 'var(--text-inverse)' }}>
|
||||||
|
{m.name[0]}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 rounded-full border-[3px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: STATUS_COLORS[m.status],
|
||||||
|
borderColor: 'var(--surface-sidebar)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[13px] font-medium truncate" style={{ color: role.color }}>
|
||||||
|
{m.name}
|
||||||
|
</p>
|
||||||
|
{m.activity && (
|
||||||
|
<p className="text-[11px] truncate" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{m.activity}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user