195 lines
5.3 KiB
Go
195 lines
5.3 KiB
Go
// 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:])
|
|
}
|