Files
LokiStack/backend/services/ingress_access.go
T
logan 2d73661dd1 feat: complete Loki query, insights, settings and deployment workflows
Add the Loki query UI, access policies, ingress insights, alert and registry settings, and their backend APIs. Include the related Kubernetes deployment and collector updates required by the integrated application.

Validated with go test ./..., npm run build, and npm run lint.
2026-10-25 21:28:32 +08:00

185 lines
7.4 KiB
Go

package services
import (
"context"
"fmt"
"strings"
"time"
appsv1 "k8s.io/api/apps/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/intstr"
"opsxloki/models"
)
const ingressNamespace = "ingress-nginx"
const ingressControllerName = "ingress-nginx-controller"
// The field names are shared with the JSON parser on the Loki query page.
const ingressAccessFormat = `{"time":"$time_iso8601","remote_addr":"$remote_addr","host":"$host","http_host":"$http_host","request":"$request","method":"$request_method","uri":"$request_uri","status":$status,"body_bytes_sent":$body_bytes_sent,"http_referer":"$http_referer","http_user_agent":"$http_user_agent","request_length":$request_length,"request_time":$request_time,"proxy_upstream_name":"$proxy_upstream_name","upstream_addr":"$upstream_addr","upstream_status":"$upstream_status","upstream_response_time":"$upstream_response_time"}`
type IngressAccessStatus struct {
Namespace string `json:"namespace"`
Deployment string `json:"deployment"`
Image string `json:"image"`
ConfigMap string `json:"configMap"`
ConfigMapAttached bool `json:"configMapAttached"`
CurrentFormat string `json:"currentFormat"`
TargetFormat string `json:"targetFormat"`
Standardized bool `json:"standardized"`
ManagedBy string `json:"managedBy"`
}
func ingressConfigArg(args []string) string {
for i, arg := range args {
if strings.HasPrefix(arg, "--configmap=") {
return strings.TrimPrefix(arg, "--configmap=")
}
if arg == "--configmap" && i+1 < len(args) {
return args[i+1]
}
}
return ""
}
func ingressAccessState(ctx context.Context, cluster *models.Cluster) (IngressAccessStatus, error) {
client, err := clusterClient(cluster, 10*time.Second)
if err != nil {
return IngressAccessStatus{}, err
}
dep, err := client.AppsV1().Deployments(ingressNamespace).Get(ctx, ingressControllerName, metav1.GetOptions{})
if err != nil {
return IngressAccessStatus{}, fmt.Errorf("读取 ingress-nginx 控制器失败: %w", err)
}
index := -1
for i, container := range dep.Spec.Template.Spec.Containers {
if strings.Contains(container.Image, "ingress-nginx/controller:") {
index = i
break
}
}
if index < 0 {
return IngressAccessStatus{}, fmt.Errorf("未识别到社区 ingress-nginx controller 容器")
}
container := dep.Spec.Template.Spec.Containers[index]
configName := ingressConfigArg(container.Args)
if configName == "" {
configName = ingressNamespace + "/" + ingressControllerName
}
parts := strings.Split(configName, "/")
if len(parts) != 2 || parts[0] != ingressNamespace || parts[1] == "" {
return IngressAccessStatus{}, fmt.Errorf("控制器使用其他命名空间的 ConfigMap: %s", configName)
}
cm, err := client.CoreV1().ConfigMaps(parts[0]).Get(ctx, parts[1], metav1.GetOptions{})
if err != nil {
return IngressAccessStatus{}, fmt.Errorf("读取 ingress-nginx ConfigMap 失败: %w", err)
}
format := cm.Data["log-format-upstream"]
return IngressAccessStatus{
Namespace: ingressNamespace, Deployment: dep.Name, Image: container.Image,
ConfigMap: configName, ConfigMapAttached: ingressConfigArg(container.Args) != "",
CurrentFormat: format, TargetFormat: ingressAccessFormat,
Standardized: format == ingressAccessFormat && cm.Data["log-format-escape-json"] == "true" && ingressConfigArg(container.Args) != "",
ManagedBy: dep.Labels["app.kubernetes.io/managed-by"],
}, nil
}
func InspectIngressAccess(ctx context.Context, cluster *models.Cluster) (IngressAccessStatus, error) {
return ingressAccessState(ctx, cluster)
}
// ApplyIngressAccess changes only the two log keys, preserving unrelated ingress settings.
// Explicitly attaching the ConfigMap is necessary for controllers launched without --configmap.
func ApplyIngressAccess(ctx context.Context, cluster *models.Cluster) (IngressAccessStatus, error) {
state, err := ingressAccessState(ctx, cluster)
if err != nil {
return state, err
}
client, err := clusterClient(cluster, 10*time.Second)
if err != nil {
return state, err
}
parts := strings.Split(state.ConfigMap, "/")
cm, err := client.CoreV1().ConfigMaps(parts[0]).Get(ctx, parts[1], metav1.GetOptions{})
if err != nil {
return state, err
}
if !state.ConfigMapAttached {
for key := range cm.Data {
if key != "log-format-upstream" && key != "log-format-escape-json" {
return state, fmt.Errorf("控制器尚未关联 ConfigMap,且其中已有 %s 等设置;为避免意外启用原有配置,已跳过自动配置", key)
}
}
}
previousFormat := strings.Replace(ingressAccessFormat, `"request":"$request",`, "", 1)
if current := cm.Data["log-format-upstream"]; current != "" && current != ingressAccessFormat && current != previousFormat {
return state, fmt.Errorf("已有自定义 access log 格式;为避免覆盖,已跳过自动配置")
}
if state.Standardized {
if err := ensureIngressRolloutStrategy(ctx, cluster); err != nil {
return state, err
}
return state, nil
}
if cm.Data == nil {
cm.Data = make(map[string]string)
}
cm.Data["log-format-upstream"] = ingressAccessFormat
cm.Data["log-format-escape-json"] = "true"
if _, err = client.CoreV1().ConfigMaps(parts[0]).Update(ctx, cm, metav1.UpdateOptions{}); err != nil {
return state, fmt.Errorf("更新 ingress-nginx ConfigMap 失败: %w", err)
}
if !state.ConfigMapAttached {
dep, err := client.AppsV1().Deployments(ingressNamespace).Get(ctx, ingressControllerName, metav1.GetOptions{})
if err != nil {
return state, err
}
for i := range dep.Spec.Template.Spec.Containers {
container := &dep.Spec.Template.Spec.Containers[i]
if strings.Contains(container.Image, "ingress-nginx/controller:") {
container.Args = append(container.Args, "--configmap="+state.ConfigMap)
break
}
}
setIngressRolloutStrategy(dep)
if _, err = client.AppsV1().Deployments(ingressNamespace).Update(ctx, dep, metav1.UpdateOptions{}); err != nil {
return state, fmt.Errorf("ConfigMap 已更新,但控制器尚未关联配置;更新 Deployment 失败: %w", err)
}
}
return ingressAccessState(ctx, cluster)
}
// Host-network replicas occupying every ingress node cannot use a surge pod:
// the new pod would need the same 80/443 ports as an existing replica.
func setIngressRolloutStrategy(dep *appsv1.Deployment) bool {
if !dep.Spec.Template.Spec.HostNetwork || dep.Spec.Replicas == nil || *dep.Spec.Replicas < 2 {
return false
}
zero, one := intstr.FromInt32(0), intstr.FromInt32(1)
strategy := &dep.Spec.Strategy
if strategy.Type == appsv1.RecreateDeploymentStrategyType {
return false
}
if strategy.RollingUpdate != nil && strategy.RollingUpdate.MaxSurge != nil && *strategy.RollingUpdate.MaxSurge == zero && strategy.RollingUpdate.MaxUnavailable != nil && *strategy.RollingUpdate.MaxUnavailable == one {
return false
}
strategy.Type = appsv1.RollingUpdateDeploymentStrategyType
strategy.RollingUpdate = &appsv1.RollingUpdateDeployment{MaxSurge: &zero, MaxUnavailable: &one}
return true
}
func ensureIngressRolloutStrategy(ctx context.Context, cluster *models.Cluster) error {
client, err := clusterClient(cluster, 10*time.Second)
if err != nil {
return err
}
dep, err := client.AppsV1().Deployments(ingressNamespace).Get(ctx, ingressControllerName, metav1.GetOptions{})
if err != nil {
return err
}
if setIngressRolloutStrategy(dep) {
_, err = client.AppsV1().Deployments(ingressNamespace).Update(ctx, dep, metav1.UpdateOptions{})
}
return err
}