// Package topology keeps a read-only registry of how traffic reaches // workloads: Ingress rule -> Service -> Deployment. An error seen at the edge // (nginx) carries only a host and a path; the registry says which services and // deployments sit behind them, including ones that export no telemetry. package topology import ( "sort" "strings" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" ) // Target is one backend an ingress rule routes to. type Target struct { Ingress string `json:"ingress"` // namespace/name Host string `json:"host"` // "*" for a default backend Path string `json:"path"` PathType string `json:"pathType"` Namespace string `json:"namespace"` Service string `json:"service"` Port string `json:"port,omitempty"` External string `json:"external,omitempty"` // ExternalName service target: no workload in this cluster Deployments []string `json:"deployments,omitempty"` // names in Namespace whose pods the Service selects Missing bool `json:"missing,omitempty"` // the Service the rule names does not exist } // Snapshot is an immutable routing table. type Snapshot struct { Targets []Target `json:"targets"` } // Build joins ingresses, services and deployments. A Service selects a // Deployment when the Service's selector is a subset of the pod template's // labels in the same namespace; a Service without a selector selects nothing. func Build(ingresses []networkingv1.Ingress, services []corev1.Service, deployments []appsv1.Deployment) Snapshot { svc := map[string]corev1.Service{} for _, s := range services { svc[s.Namespace+"/"+s.Name] = s } var out []Target add := func(ing networkingv1.Ingress, host, path, pathType string, b *networkingv1.IngressServiceBackend) { if b == nil { return // resource backends (buckets etc.) are not workloads } t := Target{Ingress: ing.Namespace + "/" + ing.Name, Host: host, Path: path, PathType: pathType, Namespace: ing.Namespace, Service: b.Name} if b.Port.Name != "" { t.Port = b.Port.Name } else if b.Port.Number != 0 { t.Port = itoa(int(b.Port.Number)) } s, ok := svc[ing.Namespace+"/"+b.Name] switch { case !ok: t.Missing = true case s.Spec.Type == corev1.ServiceTypeExternalName: t.External = s.Spec.ExternalName default: t.Deployments = selected(s, deployments) } out = append(out, t) } for _, ing := range ingresses { if d := ing.Spec.DefaultBackend; d != nil { add(ing, "*", "/", "Prefix", d.Service) } for _, rule := range ing.Spec.Rules { host := rule.Host if host == "" { host = "*" } if rule.HTTP == nil { continue } for _, p := range rule.HTTP.Paths { pt := "ImplementationSpecific" if p.PathType != nil { pt = string(*p.PathType) } path := p.Path if path == "" { path = "/" } add(ing, host, path, pt, p.Backend.Service) } } } sort.Slice(out, func(i, j int) bool { a, b := out[i], out[j] if a.Host != b.Host { return a.Host < b.Host } if len(a.Path) != len(b.Path) { return len(a.Path) > len(b.Path) } return a.Path < b.Path }) return Snapshot{Targets: out} } func selected(s corev1.Service, deployments []appsv1.Deployment) []string { if len(s.Spec.Selector) == 0 { return nil } var out []string for _, d := range deployments { if d.Namespace != s.Namespace { continue } labels := d.Spec.Template.Labels match := true for k, v := range s.Spec.Selector { if labels[k] != v { match = false break } } if match { out = append(out, d.Name) } } sort.Strings(out) return out } // Resolve returns the targets that serve a request for host and path, best // match first: an exact host beats a wildcard, and within a host the longest // matching path wins. Only the best-matching rule per ingress is returned // unless several ingresses tie, which nginx would also treat as a conflict. func (s Snapshot) Resolve(host, path string) []Target { host = strings.ToLower(strings.TrimSuffix(host, ".")) if i := strings.LastIndex(host, ":"); i > 0 && !strings.Contains(host[i:], "]") { host = host[:i] } if path == "" { path = "/" } if i := strings.IndexAny(path, "?#"); i >= 0 { path = path[:i] } bestScore := -1 var out []Target for _, t := range s.Targets { hs := hostScore(t.Host, host) if hs < 0 || !pathMatches(t, path) { continue } score := hs*100000 + len(t.Path) switch { case score > bestScore: bestScore, out = score, []Target{t} case score == bestScore: out = append(out, t) } } return out } // hostScore is 2 for an exact host, 1 for a wildcard or catch-all, -1 for no match. func hostScore(rule, host string) int { switch { case rule == "*": return 0 case strings.EqualFold(rule, host): return 2 case strings.HasPrefix(rule, "*.") && strings.HasSuffix(host, rule[1:]) && strings.Count(host, ".") == strings.Count(rule, "."): return 1 } return -1 } func pathMatches(t Target, path string) bool { if t.PathType == "Exact" { return path == t.Path } p := strings.TrimSuffix(t.Path, "/") return p == "" || path == p || strings.HasPrefix(path, p+"/") } func itoa(n int) string { if n == 0 { return "0" } var b [12]byte i := len(b) for n > 0 { i-- b[i] = byte('0' + n%10) n /= 10 } return string(b[i:]) }