fixes
This commit is contained in:
1 parent
2cf2cebf5c
commit
23243ddbdc
13 files changed
+906
-55
No files matched your search
@@ -0,0 +1,194 @@
|
||||
// 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:])
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package topology
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
networkingv1 "k8s.io/api/networking/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func path(p string, pt networkingv1.PathType, svc string) networkingv1.HTTPIngressPath {
|
||||
return networkingv1.HTTPIngressPath{Path: p, PathType: &pt, Backend: networkingv1.IngressBackend{
|
||||
Service: &networkingv1.IngressServiceBackend{Name: svc, Port: networkingv1.ServiceBackendPort{Number: 80}}}}
|
||||
}
|
||||
|
||||
func fixture() Snapshot {
|
||||
ing := networkingv1.Ingress{ObjectMeta: metav1.ObjectMeta{Namespace: "shop", Name: "public"}, Spec: networkingv1.IngressSpec{Rules: []networkingv1.IngressRule{
|
||||
{Host: "shop.example.com", IngressRuleValue: networkingv1.IngressRuleValue{HTTP: &networkingv1.HTTPIngressRuleValue{Paths: []networkingv1.HTTPIngressPath{
|
||||
path("/", networkingv1.PathTypePrefix, "web"),
|
||||
path("/api", networkingv1.PathTypePrefix, "api"),
|
||||
path("/legacy", networkingv1.PathTypeExact, "old"),
|
||||
path("/pay", networkingv1.PathTypePrefix, "gone"),
|
||||
path("/cdn", networkingv1.PathTypePrefix, "cdn"),
|
||||
}}}},
|
||||
{Host: "*.example.com", IngressRuleValue: networkingv1.IngressRuleValue{HTTP: &networkingv1.HTTPIngressRuleValue{Paths: []networkingv1.HTTPIngressPath{
|
||||
path("/", networkingv1.PathTypePrefix, "web"),
|
||||
}}}},
|
||||
}}}
|
||||
svcs := []corev1.Service{
|
||||
{ObjectMeta: metav1.ObjectMeta{Namespace: "shop", Name: "web"}, Spec: corev1.ServiceSpec{Selector: map[string]string{"app": "web"}}},
|
||||
{ObjectMeta: metav1.ObjectMeta{Namespace: "shop", Name: "api"}, Spec: corev1.ServiceSpec{Selector: map[string]string{"app": "api"}}},
|
||||
{ObjectMeta: metav1.ObjectMeta{Namespace: "shop", Name: "old"}, Spec: corev1.ServiceSpec{Selector: map[string]string{"app": "old"}}},
|
||||
{ObjectMeta: metav1.ObjectMeta{Namespace: "shop", Name: "cdn"}, Spec: corev1.ServiceSpec{Type: corev1.ServiceTypeExternalName, ExternalName: "cdn.vendor.net"}},
|
||||
}
|
||||
dep := func(ns, name, app string) appsv1.Deployment {
|
||||
return appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name}, Spec: appsv1.DeploymentSpec{
|
||||
Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": app, "tier": "x"}}}}}
|
||||
}
|
||||
deps := []appsv1.Deployment{dep("shop", "web-v2", "web"), dep("shop", "api", "api"), dep("other", "web-other", "web"), dep("shop", "old", "old")}
|
||||
return Build([]networkingv1.Ingress{ing}, svcs, deps)
|
||||
}
|
||||
|
||||
func one(t *testing.T, s Snapshot, host, path string) Target {
|
||||
t.Helper()
|
||||
got := s.Resolve(host, path)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("%s%s: %d targets: %+v", host, path, len(got), got)
|
||||
}
|
||||
return got[0]
|
||||
}
|
||||
|
||||
func TestResolveLongestPathAndDeployments(t *testing.T) {
|
||||
s := fixture()
|
||||
if g := one(t, s, "shop.example.com", "/api/orders?x=1"); g.Service != "api" || len(g.Deployments) != 1 || g.Deployments[0] != "api" {
|
||||
t.Fatalf("api: %+v", g)
|
||||
}
|
||||
// the selector matches the same-namespace deployment only, not other/web-other
|
||||
if g := one(t, s, "shop.example.com", "/index"); g.Service != "web" || len(g.Deployments) != 1 || g.Deployments[0] != "web-v2" {
|
||||
t.Fatalf("web: %+v", g)
|
||||
}
|
||||
// /apix must not match the /api prefix (segment-wise)
|
||||
if g := one(t, s, "shop.example.com", "/apix"); g.Service != "web" {
|
||||
t.Fatalf("apix: %+v", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveHostsPortsAndExact(t *testing.T) {
|
||||
s := fixture()
|
||||
if g := one(t, s, "SHOP.example.com:443", "/"); g.Host != "shop.example.com" {
|
||||
t.Fatalf("host case/port: %+v", g)
|
||||
}
|
||||
if g := one(t, s, "blog.example.com", "/x"); g.Host != "*.example.com" {
|
||||
t.Fatalf("wildcard: %+v", g)
|
||||
}
|
||||
if got := s.Resolve("a.b.example.com", "/"); len(got) != 0 {
|
||||
t.Fatalf("wildcard matched two labels: %+v", got)
|
||||
}
|
||||
if g := one(t, s, "shop.example.com", "/legacy"); g.Service != "old" {
|
||||
t.Fatalf("exact: %+v", g)
|
||||
}
|
||||
if g := one(t, s, "shop.example.com", "/legacy/x"); g.Service != "web" {
|
||||
t.Fatalf("exact matched a subpath: %+v", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingAndExternalBackends(t *testing.T) {
|
||||
s := fixture()
|
||||
if g := one(t, s, "shop.example.com", "/pay"); !g.Missing || g.Service != "gone" {
|
||||
t.Fatalf("missing: %+v", g)
|
||||
}
|
||||
if g := one(t, s, "shop.example.com", "/cdn/a.js"); g.External != "cdn.vendor.net" || len(g.Deployments) != 0 {
|
||||
t.Fatalf("external: %+v", g)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package topology
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
networkingv1 "k8s.io/api/networking/v1"
|
||||
"k8s.io/client-go/informers"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/tools/cache"
|
||||
|
||||
kube "git.tornberg.me/go-otel/internal/kubernetes"
|
||||
)
|
||||
|
||||
// Registry serves the latest Snapshot. A nil *Registry resolves nothing, so
|
||||
// callers need no cluster to run.
|
||||
type Registry struct {
|
||||
snap atomic.Pointer[Snapshot]
|
||||
dirty atomic.Bool
|
||||
built atomic.Int64 // unix seconds of the last rebuild
|
||||
}
|
||||
|
||||
// Resolve returns the backends serving host+path.
|
||||
func (r *Registry) Resolve(host, path string) []Target {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
s := r.snap.Load()
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.Resolve(host, path)
|
||||
}
|
||||
|
||||
// Snapshot returns the current routing table.
|
||||
func (r *Registry) Snapshot() Snapshot {
|
||||
if r == nil {
|
||||
return Snapshot{}
|
||||
}
|
||||
if s := r.snap.Load(); s != nil {
|
||||
return *s
|
||||
}
|
||||
return Snapshot{}
|
||||
}
|
||||
|
||||
// NewFromEnv watches ingresses, services and deployments, following the same
|
||||
// switches as the rollout watcher (OTEL_KUBERNETES_ENABLED / _NAMESPACE). It
|
||||
// returns nil, nil when there is no cluster to watch.
|
||||
func NewFromEnv(ctx context.Context) (*Registry, error) {
|
||||
if strings.EqualFold(os.Getenv("OTEL_KUBERNETES_ENABLED"), "false") {
|
||||
return nil, nil
|
||||
}
|
||||
cfg, err := kube.RESTConfig()
|
||||
if err != nil {
|
||||
if os.Getenv("OTEL_KUBERNETES_ENABLED") == "true" {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
client, err := kubernetes.NewForConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create kubernetes client: %w", err)
|
||||
}
|
||||
var opts []informers.SharedInformerOption
|
||||
if ns := os.Getenv("OTEL_KUBERNETES_NAMESPACE"); ns != "" {
|
||||
opts = append(opts, informers.WithNamespace(ns))
|
||||
}
|
||||
factory := informers.NewSharedInformerFactoryWithOptions(client, 30*time.Minute, opts...)
|
||||
ing := factory.Networking().V1().Ingresses().Informer()
|
||||
svc := factory.Core().V1().Services().Informer()
|
||||
dep := factory.Apps().V1().Deployments().Informer()
|
||||
|
||||
r := &Registry{}
|
||||
mark := cache.ResourceEventHandlerFuncs{
|
||||
AddFunc: func(any) { r.dirty.Store(true) },
|
||||
UpdateFunc: func(_, _ any) { r.dirty.Store(true) },
|
||||
DeleteFunc: func(any) { r.dirty.Store(true) },
|
||||
}
|
||||
for _, i := range []cache.SharedIndexInformer{ing, svc, dep} {
|
||||
_, _ = i.AddEventHandler(mark)
|
||||
}
|
||||
rebuild := func() {
|
||||
var is []networkingv1.Ingress
|
||||
for _, o := range ing.GetStore().List() {
|
||||
if v, ok := o.(*networkingv1.Ingress); ok {
|
||||
is = append(is, *v)
|
||||
}
|
||||
}
|
||||
var ss []corev1.Service
|
||||
for _, o := range svc.GetStore().List() {
|
||||
if v, ok := o.(*corev1.Service); ok {
|
||||
ss = append(ss, *v)
|
||||
}
|
||||
}
|
||||
var ds []appsv1.Deployment
|
||||
for _, o := range dep.GetStore().List() {
|
||||
if v, ok := o.(*appsv1.Deployment); ok {
|
||||
ds = append(ds, *v)
|
||||
}
|
||||
}
|
||||
s := Build(is, ss, ds)
|
||||
r.snap.Store(&s)
|
||||
r.built.Store(time.Now().Unix())
|
||||
}
|
||||
go func() {
|
||||
factory.Start(ctx.Done())
|
||||
if !cache.WaitForCacheSync(ctx.Done(), ing.HasSynced, svc.HasSynced, dep.HasSynced) {
|
||||
log.Printf("topology: informers did not sync")
|
||||
return
|
||||
}
|
||||
rebuild()
|
||||
log.Printf("topology: %d ingress routes", len(r.Snapshot().Targets))
|
||||
// Coalesce bursts of changes (a rollout touches many objects).
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if r.dirty.Swap(false) {
|
||||
rebuild()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// HandleTopology serves GET /api/topology. With ?host=&path= it answers
|
||||
// "what sits behind this request"; without them it lists every route.
|
||||
func (r *Registry) HandleTopology(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
q := req.URL.Query()
|
||||
if host := q.Get("host"); host != "" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"targets": r.Resolve(host, q.Get("path"))})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"targets": r.Snapshot().Targets, "updated": r.built.Load()})
|
||||
}
|
||||
Reference in new issue
Block a user