diff --git a/backend/cmd/headlamp.go b/backend/cmd/headlamp.go index 4cda0b5c6b3..279c38f82a4 100644 --- a/backend/cmd/headlamp.go +++ b/backend/cmd/headlamp.go @@ -49,15 +49,15 @@ import ( "github.com/gorilla/mux" auth "github.com/kubernetes-sigs/headlamp/backend/pkg/auth" "github.com/kubernetes-sigs/headlamp/backend/pkg/cache" + "github.com/kubernetes-sigs/headlamp/backend/pkg/clusterinventory" cfg "github.com/kubernetes-sigs/headlamp/backend/pkg/config" "github.com/kubernetes-sigs/headlamp/backend/pkg/headlampconfig" - "github.com/kubernetes-sigs/headlamp/backend/pkg/serviceproxy" - "github.com/kubernetes-sigs/headlamp/backend/pkg/helm" "github.com/kubernetes-sigs/headlamp/backend/pkg/kubeconfig" "github.com/kubernetes-sigs/headlamp/backend/pkg/logger" "github.com/kubernetes-sigs/headlamp/backend/pkg/plugins" "github.com/kubernetes-sigs/headlamp/backend/pkg/portforward" + "github.com/kubernetes-sigs/headlamp/backend/pkg/serviceproxy" "github.com/kubernetes-sigs/headlamp/backend/pkg/spa" "github.com/kubernetes-sigs/headlamp/backend/pkg/telemetry" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -69,6 +69,7 @@ import ( v1 "k8s.io/apimachinery/pkg/apis/meta/v1" k8sruntime "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/clientcmd/api" @@ -461,6 +462,40 @@ func addPluginListRoute(config *HeadlampConfig, r *mux.Router) { }).Methods("GET") } +func startClusterInventory(ctx context.Context, config *HeadlampConfig) error { + if !config.EnableClusterInventory { + return nil + } + + var hubConfig *rest.Config + + if config.UseInCluster { + inClusterConfig, err := rest.InClusterConfig() + if err != nil { + return fmt.Errorf("get in-cluster config for cluster inventory: %w", err) + } + + hubConfig = inClusterConfig + } + + runner, err := clusterinventory.NewRunner(clusterinventory.Options{ + Store: config.KubeConfigStore, + ProviderFile: config.ClusterInventoryProviderFile, + LabelSelector: config.ClusterInventoryLabelSelector, + RootReconcileInterval: config.ClusterInventoryRootReconcileInterval, + NoCRDCacheTTL: config.ClusterInventoryNoCRDCacheTTL, + HubConfig: hubConfig, + DiscoverFromStore: !config.UseInCluster, + }) + if err != nil { + return err + } + + go runner.Run(ctx) + + return nil +} + func setupInClusterContext(config *HeadlampConfig) { if config.UnsafeUseServiceAccountToken { logger.Log( @@ -1403,6 +1438,25 @@ func hostValidationMiddleware(listenAddr string, port uint) func(http.Handler) h } } +func serverHandler(ctx context.Context, config *HeadlampConfig) (http.Handler, error) { + handler := createHeadlampHandler(ctx, config) + + if err := startClusterInventory(ctx, config); err != nil { + return nil, err + } + + handler = config.OIDCTokenRefreshMiddleware(handler) + + // Only validate the Host header when listening on a loopback address. + // When bound to a non-loopback address (e.g. behind a reverse proxy), + // arbitrary Host headers are expected and must be allowed through. + if isLoopbackAddr(config.ListenAddr) { + handler = hostValidationMiddleware(config.ListenAddr, config.Port)(handler) + } + + return handler, nil +} + //nolint:funlen func StartHeadlampServer(config *HeadlampConfig) { tel, err := initTelemetry(config) @@ -1436,14 +1490,10 @@ func StartHeadlampServer(config *HeadlampConfig) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - handler := createHeadlampHandler(ctx, config) - handler = config.OIDCTokenRefreshMiddleware(handler) - - // Only validate the Host header when listening on a loopback address. - // When bound to a non-loopback address (e.g. behind a reverse proxy), - // arbitrary Host headers are expected and must be allowed through. - if isLoopbackAddr(config.ListenAddr) { - handler = hostValidationMiddleware(config.ListenAddr, config.Port)(handler) + handler, err := serverHandler(ctx, config) + if err != nil { + logger.Log(logger.LevelError, nil, err, "starting cluster inventory discovery") + return } listenHost := strings.TrimPrefix(strings.TrimSuffix(config.ListenAddr, "]"), "[") @@ -2039,20 +2089,26 @@ func (c *HeadlampConfig) getClusters() []Cluster { clusterID := context.ClusterID + metadata := map[string]interface{}{ + "source": source, + "namespace": context.KubeContext.Namespace, + "extensions": context.KubeContext.Extensions, + "origin": map[string]interface{}{ + "kubeconfig": kubeconfigPath, + }, + "originalName": context.Name, + "clusterID": clusterID, + } + + if context.Source == kubeconfig.ClusterInventory && context.ClusterInventory != nil { + metadata["clusterInventory"] = context.ClusterInventory + } + clusters = append(clusters, Cluster{ Name: context.Name, Server: context.Cluster.Server, AuthType: context.AuthType(), - Metadata: map[string]interface{}{ - "source": source, - "namespace": context.KubeContext.Namespace, - "extensions": context.KubeContext.Extensions, - "origin": map[string]interface{}{ - "kubeconfig": kubeconfigPath, - }, - "originalName": context.Name, - "clusterID": clusterID, - }, + Metadata: metadata, }) } diff --git a/backend/cmd/headlamp_test.go b/backend/cmd/headlamp_test.go index cb56c19a412..1b2c584eff4 100644 --- a/backend/cmd/headlamp_test.go +++ b/backend/cmd/headlamp_test.go @@ -42,6 +42,7 @@ import ( "github.com/google/uuid" "github.com/gorilla/mux" "github.com/kubernetes-sigs/headlamp/backend/pkg/cache" + inventorymetadata "github.com/kubernetes-sigs/headlamp/backend/pkg/clusterinventory/metadata" "github.com/kubernetes-sigs/headlamp/backend/pkg/config" "github.com/kubernetes-sigs/headlamp/backend/pkg/headlampconfig" "github.com/kubernetes-sigs/headlamp/backend/pkg/kubeconfig" @@ -406,6 +407,86 @@ func TestDynamicClustersKubeConfig(t *testing.T) { } } +func TestGetClustersClusterInventorySource(t *testing.T) { + kubeConfigStore := kubeconfig.NewContextStore() + require.NoError(t, kubeConfigStore.AddContext(clusterInventoryConfigContext())) + + c := &HeadlampConfig{ + HeadlampConfig: &headlampconfig.HeadlampConfig{ + HeadlampCFG: &headlampconfig.HeadlampCFG{KubeConfigStore: kubeConfigStore}, + }, + } + + clusters := c.getClusters() + require.Len(t, clusters, 1) + assert.Equal(t, "cluster_inventory", clusters[0].Metadata["source"]) + assert.Equal(t, "cluster-inventory/in-cluster/default/spoke-a", clusters[0].Metadata["clusterID"]) + inventory, ok := clusters[0].Metadata["clusterInventory"].(*inventorymetadata.Metadata) + require.True(t, ok) + assert.Equal(t, "default", inventory.Profile.Namespace) + assert.Equal(t, "spoke-a", inventory.Profile.Name) + require.Len(t, inventory.Conditions, 1) + assert.Equal(t, "ControlPlaneHealthy", inventory.Conditions[0].Type) + assert.Equal(t, metav1.ConditionFalse, inventory.Conditions[0].Status) + require.NotNil(t, inventory.Version) + assert.Equal(t, "v1.35.0", inventory.Version.Kubernetes) + assert.Equal(t, []inventorymetadata.Property{{Name: "region", Value: "us-west1"}}, inventory.Properties) + + recorder := httptest.NewRecorder() + c.getConfig(recorder, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/config", nil)) + + var config clientConfig + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &config)) + require.Len(t, config.Clusters, 1) + configInventory, ok := config.Clusters[0].Metadata["clusterInventory"].(map[string]interface{}) + require.True(t, ok) + configProfile, ok := configInventory["profile"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "default", configProfile["namespace"]) + assert.Equal(t, "spoke-a", configProfile["name"]) + + configConditions, ok := configInventory["conditions"].([]interface{}) + require.True(t, ok) + require.Len(t, configConditions, 1) + configCondition, ok := configConditions[0].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "ControlPlaneHealthy", configCondition["type"]) + assert.Equal(t, "False", configCondition["status"]) +} + +// clusterInventoryConfigContext returns a minimal discovered context with Cluster Inventory metadata. +func clusterInventoryConfigContext() *kubeconfig.Context { + return &kubeconfig.Context{ + Name: "cluster-inventory-in-cluster--default--spoke-a--dbdb0aa95e5d", + KubeContext: &api.Context{Cluster: "spoke-a", AuthInfo: "spoke-a", Namespace: "default"}, + Cluster: &api.Cluster{Server: "https://spoke-a.example.com"}, + AuthInfo: &api.AuthInfo{}, + Source: kubeconfig.ClusterInventory, + ClusterID: "cluster-inventory/in-cluster/default/spoke-a", + ClusterInventory: &inventorymetadata.Metadata{ + Profile: inventorymetadata.Profile{ + Namespace: "default", + Name: "spoke-a", + Key: "in-cluster/default/spoke-a", + }, + Conditions: []metav1.Condition{ + { + Type: "ControlPlaneHealthy", + Status: metav1.ConditionFalse, + Reason: "HealthCheckFailed", + Message: "control plane endpoint is not ready", + LastTransitionTime: metav1.NewTime(time.Date(2026, 5, 10, 0, 0, 0, 0, time.UTC)), + ObservedGeneration: 3, + }, + }, + Version: &inventorymetadata.Version{Kubernetes: "v1.35.0"}, + Properties: []inventorymetadata.Property{ + {Name: "region", Value: "us-west1"}, + }, + }, + } +} + func TestInvalidKubeConfig(t *testing.T) { cache := cache.New[interface{}]() kubeConfigStore := kubeconfig.NewContextStore() diff --git a/backend/cmd/server.go b/backend/cmd/server.go index 46c32db3c44..66afa47e2ab 100644 --- a/backend/cmd/server.go +++ b/backend/cmd/server.go @@ -94,6 +94,7 @@ func buildHeadlampCFG(conf *config.Config, kubeConfigStore kubeconfig.ContextSto UserPluginDir: conf.UserPluginsDir, EnableHelm: conf.EnableHelm, EnableDynamicClusters: conf.EnableDynamicClusters, + EnableClusterInventory: conf.EnableClusterInventory, AllowKubeconfigChanges: conf.AllowKubeconfigChanges, WatchPluginsChanges: conf.WatchPluginsChanges, KubeConfigStore: kubeConfigStore, @@ -105,16 +106,20 @@ func buildHeadlampCFG(conf *config.Config, kubeConfigStore kubeconfig.ContextSto return strings.Split(conf.ProxyURLs, ",") }(), - TLSCertPath: conf.TLSCertPath, - TLSKeyPath: conf.TLSKeyPath, - SessionTTL: conf.SessionTTL, - PodDebugImage: conf.PodDebugImage, - OidcUseCookie: conf.OidcUseCookie, - DefaultLightTheme: conf.DefaultLightTheme, - DefaultDarkTheme: conf.DefaultDarkTheme, - ForceTheme: conf.ForceTheme, - UnsafeUseServiceAccountToken: conf.UnsafeUseServiceAccountToken, - ServiceAccountTokenPath: conf.ServiceAccountTokenPath, + ClusterInventoryProviderFile: conf.ClusterInventoryProviderFile, + ClusterInventoryLabelSelector: conf.ClusterInventoryLabelSelector, + ClusterInventoryRootReconcileInterval: conf.ClusterInventoryRootReconcileInterval, + ClusterInventoryNoCRDCacheTTL: conf.ClusterInventoryNoCRDCacheTTL, + TLSCertPath: conf.TLSCertPath, + TLSKeyPath: conf.TLSKeyPath, + SessionTTL: conf.SessionTTL, + PodDebugImage: conf.PodDebugImage, + OidcUseCookie: conf.OidcUseCookie, + DefaultLightTheme: conf.DefaultLightTheme, + DefaultDarkTheme: conf.DefaultDarkTheme, + ForceTheme: conf.ForceTheme, + UnsafeUseServiceAccountToken: conf.UnsafeUseServiceAccountToken, + ServiceAccountTokenPath: conf.ServiceAccountTokenPath, } } diff --git a/backend/go.mod b/backend/go.mod index 72b8abb1dff..d640afb471b 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -21,7 +21,7 @@ require ( k8s.io/apimachinery v0.35.3 k8s.io/cli-runtime v0.35.3 k8s.io/client-go v0.35.3 - k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/kube-openapi v0.0.0-20260319004828-5883c5ee87b9 // indirect k8s.io/kubectl v0.35.3 // indirect k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 sigs.k8s.io/yaml v1.6.0 @@ -47,6 +47,7 @@ require ( golang.org/x/term v0.41.0 gopkg.in/yaml.v2 v2.4.0 k8s.io/klog/v2 v2.140.0 + sigs.k8s.io/cluster-inventory-api v0.1.0 ) require ( @@ -71,7 +72,7 @@ require ( github.com/containerd/platforms v0.2.1 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch v5.9.11+incompatible // indirect github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect github.com/fatih/color v1.17.0 // indirect @@ -83,13 +84,24 @@ require ( github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.21.0 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/jsonreference v0.21.5 // indirect + github.com/go-openapi/swag v0.25.5 // indirect + github.com/go-openapi/swag/cmdutils v0.25.5 // indirect + github.com/go-openapi/swag/conv v0.25.5 // indirect + github.com/go-openapi/swag/fileutils v0.25.5 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // indirect + github.com/go-openapi/swag/jsonutils v0.25.5 // indirect + github.com/go-openapi/swag/loading v0.25.5 // indirect + github.com/go-openapi/swag/mangling v0.25.5 // indirect + github.com/go-openapi/swag/netutils v0.25.5 // indirect + github.com/go-openapi/swag/stringutils v0.25.5 // indirect + github.com/go-openapi/swag/typeutils v0.25.5 // indirect + github.com/go-openapi/swag/yamlutils v0.25.5 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/google/btree v1.1.3 // indirect - github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/gosuri/uitable v0.0.4 // indirect github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect @@ -99,7 +111,6 @@ require ( github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jmoiron/sqlx v1.4.0 // indirect - github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect @@ -107,7 +118,6 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/lib/pq v1.10.9 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect - github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect @@ -143,14 +153,14 @@ require ( github.com/xlab/treeprint v1.2.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect - golang.org/x/time v0.12.0 // indirect + golang.org/x/time v0.15.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/grpc v1.80.0 // indirect @@ -162,6 +172,7 @@ require ( k8s.io/apiserver v0.35.3 // indirect k8s.io/component-base v0.35.3 // indirect oras.land/oras-go/v2 v2.6.0 // indirect + sigs.k8s.io/controller-runtime v0.23.3 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/kustomize/api v0.20.1 // indirect sigs.k8s.io/kustomize/kyaml v0.20.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 8f8d952eed5..82b514e8434 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -109,8 +109,8 @@ github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6Uezg github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= -github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -157,12 +157,40 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= -github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= +github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= +github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= +github.com/go-openapi/swag v0.25.5 h1:pNkwbUEeGwMtcgxDr+2GBPAk4kT+kJ+AaB+TMKAg+TU= +github.com/go-openapi/swag v0.25.5/go.mod h1:B3RT6l8q7X803JRxa2e59tHOiZlX1t8viplOcs9CwTA= +github.com/go-openapi/swag/cmdutils v0.25.5 h1:yh5hHrpgsw4NwM9KAEtaDTXILYzdXh/I8Whhx9hKj7c= +github.com/go-openapi/swag/cmdutils v0.25.5/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g= +github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k= +github.com/go-openapi/swag/fileutils v0.25.5 h1:B6JTdOcs2c0dBIs9HnkyTW+5gC+8NIhVBUwERkFhMWk= +github.com/go-openapi/swag/fileutils v0.25.5/go.mod h1:V3cT9UdMQIaH4WiTrUc9EPtVA4txS0TOmRURmhGF4kc= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo= +github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo= +github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU= +github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g= +github.com/go-openapi/swag/mangling v0.25.5 h1:hyrnvbQRS7vKePQPHHDso+k6CGn5ZBs5232UqWZmJZw= +github.com/go-openapi/swag/mangling v0.25.5/go.mod h1:6hadXM/o312N/h98RwByLg088U61TPGiltQn71Iw0NY= +github.com/go-openapi/swag/netutils v0.25.5 h1:LZq2Xc2QI8+7838elRAaPCeqJnHODfSyOa7ZGfxDKlU= +github.com/go-openapi/swag/netutils v0.25.5/go.mod h1:lHbtmj4m57APG/8H7ZcMMSWzNqIQcu0RFiXrPUara14= +github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M= +github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII= +github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E= +github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc= +github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ= +github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM= +github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= +github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -206,8 +234,8 @@ github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= -github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= +github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -220,8 +248,8 @@ github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= +github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -298,8 +326,6 @@ github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -337,8 +363,6 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -402,10 +426,10 @@ github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/npillmayer/nestext v0.1.3/go.mod h1:h2lrijH8jpicr25dFY+oAJLyzlya6jhnuG+zWp9L0Uk= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= -github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= -github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= -github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= -github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= +github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= +github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= +github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -572,8 +596,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -678,8 +702,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -778,14 +802,18 @@ k8s.io/component-base v0.35.3 h1:mbKbzoIMy7JDWS/wqZobYW1JDVRn/RKRaoMQHP9c4P0= k8s.io/component-base v0.35.3/go.mod h1:IZ8LEG30kPN4Et5NeC7vjNv5aU73ku5MS15iZyvyMYk= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= -k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= -k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/kube-openapi v0.0.0-20260319004828-5883c5ee87b9 h1:Sztf7ESG9tAXRW/ACJZjrj5jhdOUqS2KFRQT+CTvu78= +k8s.io/kube-openapi v0.0.0-20260319004828-5883c5ee87b9/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= k8s.io/kubectl v0.35.3 h1:1KqSYXk/sodU7VeDvK6atX2kAGUZd2QTeR5K7Hb9r9w= k8s.io/kubectl v0.35.3/go.mod h1:GPHxZqRe+u/i3gTBoVQHeIyq2NilfNPj9hDWeuN3x5s= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= +sigs.k8s.io/cluster-inventory-api v0.1.0 h1:DG/hLTIJkdkKfuyMMA0ybbtBbFNWr7S4QeQcAmlSnGo= +sigs.k8s.io/cluster-inventory-api v0.1.0/go.mod h1:7J3M6srZ1I4snZR+p5zxgEBdXnia3tlHo5ODMHJpEUk= +sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= +sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/kustomize/api v0.20.1 h1:iWP1Ydh3/lmldBnH/S5RXgT98vWYMaTUL1ADcr+Sv7I= diff --git a/backend/pkg/clusterinventory/clusterinventory.go b/backend/pkg/clusterinventory/clusterinventory.go new file mode 100644 index 00000000000..d7117850871 --- /dev/null +++ b/backend/pkg/clusterinventory/clusterinventory.go @@ -0,0 +1,1122 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package clusterinventory + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "hash" + "net/http" + "net/url" + "sort" + "strings" + "sync" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + k8sruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/tools/clientcmd/api" + + inventorymetadata "github.com/kubernetes-sigs/headlamp/backend/pkg/clusterinventory/metadata" + "github.com/kubernetes-sigs/headlamp/backend/pkg/kubeconfig" + "github.com/kubernetes-sigs/headlamp/backend/pkg/logger" + apisv1alpha1 "sigs.k8s.io/cluster-inventory-api/apis/v1alpha1" + ciaclient "sigs.k8s.io/cluster-inventory-api/client/clientset/versioned" + externalversions "sigs.k8s.io/cluster-inventory-api/client/informers/externalversions" + "sigs.k8s.io/cluster-inventory-api/pkg/access" +) + +const ( + // DefaultRootReconcileInterval is the default interval for reconciling Cluster Inventory roots. + DefaultRootReconcileInterval = 5 * time.Minute + // DefaultNoCRDCacheTTL is the default TTL for API servers that do not have the ClusterProfile CRD. + DefaultNoCRDCacheTTL = 2 * time.Hour + + clusterInventoryContextPrefix = "cluster-inventory-" + clusterInventoryIDPrefix = "cluster-inventory/" + inClusterRootID = "in-cluster" + storeRootPrefix = "store/" + + clusterExecConfigExtensionKey = "client.authentication.k8s.io/exec" +) + +// Options controls Cluster Inventory discovery. +type Options struct { + Store kubeconfig.ContextStore + ProviderFile string + LabelSelector string + RootReconcileInterval time.Duration + NoCRDCacheTTL time.Duration + HubConfig *rest.Config + DiscoverFromStore bool +} + +// Runner watches ClusterProfile resources and syncs them into Headlamp's context store. +type Runner struct { + store kubeconfig.ContextStore + accessConfig *access.Config + rootReconcileInterval time.Duration + noCRDCacheTTL time.Duration + labelSelector labels.Selector + hubConfig *rest.Config + discoverFromStore bool + + clientForConfig func(*rest.Config) (ciaclient.Interface, error) + now func() time.Time + + mu sync.Mutex + roots map[string]*rootState + profiles map[string]profileState + profileKeysByRoot map[string]map[string]struct{} + noCRD map[string]time.Time +} + +type rootState struct { + rootID string + serverURL string + fingerprint string + ctx context.Context + cancel context.CancelFunc + informer cache.SharedIndexInformer +} + +type rootInformer struct { + state *rootState + factory externalversions.SharedInformerFactory +} + +type profileState struct { + contextName string +} + +// NewRunner validates options, parses the provider file, and returns a discovery runner. +func NewRunner(opts Options) (*Runner, error) { + if opts.Store == nil { + return nil, errors.New("context store is required") + } + + if opts.ProviderFile == "" { + return nil, errors.New("cluster inventory provider file is required") + } + + accessConfig, err := access.NewFromFile(opts.ProviderFile) + if err != nil { + return nil, fmt.Errorf("load cluster inventory provider file: %w", err) + } + + labelSelector, err := normalizeLabelSelector(opts.LabelSelector) + if err != nil { + return nil, err + } + + rootReconcileInterval := opts.RootReconcileInterval + if rootReconcileInterval <= 0 { + rootReconcileInterval = DefaultRootReconcileInterval + } + + noCRDCacheTTL := opts.NoCRDCacheTTL + if noCRDCacheTTL <= 0 { + noCRDCacheTTL = DefaultNoCRDCacheTTL + } + + return &Runner{ + store: opts.Store, + accessConfig: accessConfig, + rootReconcileInterval: rootReconcileInterval, + noCRDCacheTTL: noCRDCacheTTL, + labelSelector: labelSelector, + hubConfig: opts.HubConfig, + discoverFromStore: opts.DiscoverFromStore, + clientForConfig: func(config *rest.Config) (ciaclient.Interface, error) { + return ciaclient.NewForConfig(config) + }, + now: time.Now, + roots: map[string]*rootState{}, + profiles: map[string]profileState{}, + profileKeysByRoot: map[string]map[string]struct{}{}, + noCRD: map[string]time.Time{}, + }, nil +} + +// Run blocks until ctx is cancelled and reconciles long-lived root informers. +func (r *Runner) Run(ctx context.Context) { + defer r.stopAllRoots() + + r.reconcileRoots(ctx) + + ticker := time.NewTicker(r.rootReconcileInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + r.reconcileRoots(ctx) + } + } +} + +func (r *Runner) reconcileRoots(ctx context.Context) { + if err := ctx.Err(); err != nil { + return + } + + presentRoots := map[string]struct{}{} + desiredRoots := map[string]*rest.Config{} + storeRootsLoaded := true + + if r.hubConfig != nil { + presentRoots[inClusterRootID] = struct{}{} + desiredRoots[inClusterRootID] = r.hubConfig + } + + if r.discoverFromStore { + storeRootsLoaded = r.collectStoreSeedRoots(desiredRoots, presentRoots) + } + + r.stopMissingRoots(presentRoots, storeRootsLoaded) + + rootIDs := make([]string, 0, len(desiredRoots)) + for rootID := range desiredRoots { + rootIDs = append(rootIDs, rootID) + } + + sort.Strings(rootIDs) + + for _, rootID := range rootIDs { + r.reconcileRoot(ctx, rootID, desiredRoots[rootID]) + } +} + +func (r *Runner) collectStoreSeedRoots( + desiredRoots map[string]*rest.Config, + presentRoots map[string]struct{}, +) bool { + contexts, err := r.store.GetContexts() + if err != nil { + logger.Log(logger.LevelWarn, nil, err, "cluster-inventory: failed to get seed contexts") + + return false + } + + sort.Slice(contexts, func(i, j int) bool { + return contexts[i].Name < contexts[j].Name + }) + + for _, headlampContext := range contexts { + if headlampContext.Source == kubeconfig.ClusterInventory { + continue + } + + if headlampContext.Internal { + continue + } + + rootID := storeRootPrefix + headlampContext.Name + presentRoots[rootID] = struct{}{} + + seedConfig, err := headlampContext.RESTConfig() + if err != nil { + logger.Log(logger.LevelWarn, map[string]string{"context": headlampContext.Name}, err, + "cluster-inventory: failed to build seed rest config") + + continue + } + + desiredRoots[rootID] = seedConfig + } + + return true +} + +func (r *Runner) reconcileRoot(ctx context.Context, rootID string, config *rest.Config) { + if config == nil { + return + } + + if err := ctx.Err(); err != nil { + return + } + + serverURL := normalizeServerURL(config.Host) + if r.hasNoCRD(serverURL) { + r.stopRoot(rootID, true) + + return + } + + fingerprint := restConfigFingerprint(config) + if r.rootMatches(rootID, serverURL, fingerprint) { + return + } + + rootInformer, ok := r.newRootInformer(ctx, rootID, serverURL, fingerprint, config) + if !ok { + return + } + + previous, current := r.activateRoot(rootInformer.state) + if current { + rootInformer.state.cancel() + + return + } + + if previous != nil { + previous.cancel() + } + + go r.runRootInformer(rootInformer.state, rootInformer.factory) +} + +func (r *Runner) rootMatches(rootID, serverURL, fingerprint string) bool { + r.mu.Lock() + defer r.mu.Unlock() + + current := r.roots[rootID] + + return current != nil && current.serverURL == serverURL && current.fingerprint == fingerprint +} + +func (r *Runner) newRootInformer( + ctx context.Context, + rootID string, + serverURL string, + fingerprint string, + config *rest.Config, +) (*rootInformer, bool) { + client, err := r.clientForConfig(rest.CopyConfig(config)) + if err != nil { + logger.Log(logger.LevelWarn, map[string]string{"root": rootID, "server": config.Host}, err, + "cluster-inventory: failed to create client") + + return nil, false + } + + rootCtx, cancel := context.WithCancel(ctx) + factory := r.newClusterProfileInformerFactory(client) + informer := factory.Apis().V1alpha1().ClusterProfiles().Informer() + state := &rootState{ + rootID: rootID, + serverURL: serverURL, + fingerprint: fingerprint, + ctx: rootCtx, + cancel: cancel, + informer: informer, + } + + _, err = informer.AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + r.handleClusterProfileUpsert(state, obj) + }, + UpdateFunc: func(_, newObj interface{}) { + r.handleClusterProfileUpsert(state, newObj) + }, + DeleteFunc: func(obj interface{}) { + r.handleClusterProfileDelete(state, obj) + }, + }) + if err != nil { + cancel() + logger.Log(logger.LevelWarn, map[string]string{"root": rootID, "server": config.Host}, err, + "cluster-inventory: failed to add ClusterProfile event handler") + + return nil, false + } + + if err := informer.SetWatchErrorHandler(func(_ *cache.Reflector, err error) { + r.handleRootWatchError(state, err) + }); err != nil { + cancel() + logger.Log(logger.LevelWarn, map[string]string{"root": rootID, "server": config.Host}, err, + "cluster-inventory: failed to set ClusterProfile watch error handler") + + return nil, false + } + + return &rootInformer{state: state, factory: factory}, true +} + +func (r *Runner) newClusterProfileInformerFactory(client ciaclient.Interface) externalversions.SharedInformerFactory { + return externalversions.NewSharedInformerFactoryWithOptions(client, 0, r.clusterProfileInformerOptions()...) +} + +func (r *Runner) clusterProfileInformerOptions() []externalversions.SharedInformerOption { + if r.labelSelector == nil { + return nil + } + + selector := r.labelSelector.String() + + return []externalversions.SharedInformerOption{ + externalversions.WithTweakListOptions(func(options *metav1.ListOptions) { + options.LabelSelector = selector + }), + } +} + +func (r *Runner) activateRoot(state *rootState) (*rootState, bool) { + r.mu.Lock() + defer r.mu.Unlock() + + previous := r.roots[state.rootID] + if previous != nil && previous.serverURL == state.serverURL && previous.fingerprint == state.fingerprint { + return nil, true + } + + r.roots[state.rootID] = state + + return previous, false +} + +func (r *Runner) runRootInformer(state *rootState, factory externalversions.SharedInformerFactory) { + defer factory.Shutdown() + + factory.Start(state.ctx.Done()) + + if !cache.WaitForCacheSync(state.ctx.Done(), state.informer.HasSynced) { + return + } + + r.completeRootSyncFromCache(state) + + <-state.ctx.Done() +} + +func (r *Runner) handleClusterProfileUpsert(state *rootState, obj interface{}) { + cp, ok := clusterProfileFromObject(obj) + if !ok { + logger.Log(logger.LevelWarn, map[string]string{"root": state.rootID}, nil, + "cluster-inventory: ignored non-ClusterProfile informer event") + + return + } + + profileKey := makeProfileKey(state.rootID, cp.Namespace+"/"+cp.Name) + if !r.clusterProfileMatchesSelector(cp) { + r.pruneClusterProfile(state, profileKey) + + return + } + + if !r.recordRootProfile(state, profileKey) { + return + } + + r.syncClusterProfile(state.ctx, state, profileKey, cp) +} + +func (r *Runner) handleClusterProfileDelete(state *rootState, obj interface{}) { + cp, ok := clusterProfileFromObject(obj) + if !ok { + logger.Log(logger.LevelWarn, map[string]string{"root": state.rootID}, nil, + "cluster-inventory: ignored non-ClusterProfile delete event") + + return + } + + profileKey := makeProfileKey(state.rootID, cp.Namespace+"/"+cp.Name) + r.pruneClusterProfile(state, profileKey) +} + +func (r *Runner) handleRootWatchError(state *rootState, err error) { + if isNoCRDError(err) { + r.markRootNoCRD(state) + logger.Log(logger.LevelInfo, map[string]string{"root": state.rootID, "server": state.serverURL}, nil, + "cluster-inventory: ClusterProfile CRD is not available") + + return + } + + logger.Log(logger.LevelWarn, map[string]string{"root": state.rootID, "server": state.serverURL}, err, + "cluster-inventory: ClusterProfile watch error") +} + +func (r *Runner) syncClusterProfile( + ctx context.Context, + state *rootState, + profileKey string, + cp *apisv1alpha1.ClusterProfile, +) { + if err := ctx.Err(); err != nil { + return + } + + if !r.isCurrentRoot(state) { + return + } + + headlampContext, ok := r.contextFromClusterProfile(profileKey, cp) + if !ok { + return + } + + if !r.isCurrentRoot(state) { + return + } + + if err := r.store.AddContext(headlampContext); err != nil { + logger.Log(logger.LevelWarn, map[string]string{"clusterprofile": profileKey}, err, + "cluster-inventory: failed to add context") + + return + } + + r.recordSyncedProfile(state, profileKey, headlampContext.Name) +} + +func (r *Runner) contextFromClusterProfile( + profileKey string, + cp *apisv1alpha1.ClusterProfile, +) (*kubeconfig.Context, bool) { + if len(cp.Status.AccessProviders) == 0 { + logger.Log(logger.LevelInfo, map[string]string{"clusterprofile": profileKey}, nil, + "cluster-inventory: ClusterProfile has no access providers") + + return nil, false + } + + restConfig, err := copyAccessConfig(r.accessConfig).BuildConfigFromCP(accessOnlyClusterProfile(cp)) + if err != nil { + logger.Log(logger.LevelWarn, map[string]string{"clusterprofile": profileKey}, err, + "cluster-inventory: failed to build rest config") + + return nil, false + } + + contextName := contextNameFromProfileKey(profileKey) + + headlampContext, err := restConfigToContext(restConfig, contextName, profileKey) + if err != nil { + logger.Log(logger.LevelWarn, map[string]string{"clusterprofile": profileKey}, err, + "cluster-inventory: failed to convert rest config") + + return nil, false + } + + if err := headlampContext.SetupProxy(); err != nil { + logger.Log(logger.LevelWarn, map[string]string{"clusterprofile": profileKey}, err, + "cluster-inventory: failed to setup proxy") + + return nil, false + } + + headlampContext.ClusterInventory = clusterInventoryMetadataFromProfile(profileKey, cp) + + return headlampContext, true +} + +func clusterInventoryMetadataFromProfile( + profileKey string, + cp *apisv1alpha1.ClusterProfile, +) *inventorymetadata.Metadata { + metadata := &inventorymetadata.Metadata{ + Profile: inventorymetadata.Profile{ + Namespace: cp.Namespace, + Name: cp.Name, + Key: profileKey, + }, + Conditions: append([]metav1.Condition(nil), cp.Status.Conditions...), + } + + if cp.Status.Version.Kubernetes != "" { + metadata.Version = &inventorymetadata.Version{ + Kubernetes: cp.Status.Version.Kubernetes, + } + } + + if len(cp.Status.Properties) > 0 { + metadata.Properties = make([]inventorymetadata.Property, len(cp.Status.Properties)) + for i, property := range cp.Status.Properties { + metadata.Properties[i] = inventorymetadata.Property{ + Name: property.Name, + Value: property.Value, + LastObservedTime: property.LastObservedTime, + } + } + } + + return metadata +} + +func (r *Runner) recordSyncedProfile(state *rootState, profileKey string, contextName string) { + r.mu.Lock() + defer r.mu.Unlock() + + if r.roots[state.rootID] != state { + return + } + + r.profiles[profileKey] = profileState{contextName: contextName} +} + +func (r *Runner) completeRootSyncFromCache(state *rootState) { + seen := map[string]struct{}{} + + for _, obj := range state.informer.GetIndexer().List() { + cp, ok := clusterProfileFromObject(obj) + if !ok { + continue + } + + if !r.clusterProfileMatchesSelector(cp) { + continue + } + + profileKey := makeProfileKey(state.rootID, cp.Namespace+"/"+cp.Name) + seen[profileKey] = struct{}{} + } + + r.mu.Lock() + + if r.roots[state.rootID] != state { + r.mu.Unlock() + return + } + + previous := r.profileKeysByRoot[state.rootID] + r.profileKeysByRoot[state.rootID] = seen + + var contextNames []string + + for profileKey := range previous { + if _, ok := seen[profileKey]; ok { + continue + } + + contextNames = append(contextNames, r.pruneProfileLocked(profileKey)...) + } + r.mu.Unlock() + + r.removeContexts(contextNames) +} + +func (r *Runner) recordRootProfile(state *rootState, profileKey string) bool { + r.mu.Lock() + defer r.mu.Unlock() + + if r.roots[state.rootID] != state { + return false + } + + if r.profileKeysByRoot[state.rootID] == nil { + r.profileKeysByRoot[state.rootID] = map[string]struct{}{} + } + + r.profileKeysByRoot[state.rootID][profileKey] = struct{}{} + + return true +} + +func (r *Runner) clusterProfileMatchesSelector(cp *apisv1alpha1.ClusterProfile) bool { + return r.labelSelector == nil || r.labelSelector.Matches(labels.Set(cp.Labels)) +} + +func (r *Runner) pruneClusterProfile(state *rootState, profileKey string) { + r.mu.Lock() + + if r.roots[state.rootID] != state { + r.mu.Unlock() + return + } + + delete(r.profileKeysByRoot[state.rootID], profileKey) + contextNames := r.pruneProfileLocked(profileKey) + r.mu.Unlock() + + r.removeContexts(contextNames) +} + +func (r *Runner) stopMissingRoots(presentRoots map[string]struct{}, storeRootsLoaded bool) { + r.mu.Lock() + + cancels := make([]context.CancelFunc, 0, len(r.roots)) + + var contextNames []string + + for rootID, state := range r.roots { + if _, ok := presentRoots[rootID]; ok { + continue + } + + if rootID != inClusterRootID && (!storeRootsLoaded || !strings.HasPrefix(rootID, storeRootPrefix)) { + continue + } + + cancels = append(cancels, state.cancel) + + delete(r.roots, rootID) + contextNames = append(contextNames, r.pruneRootLocked(rootID)...) + } + + r.mu.Unlock() + + r.removeContexts(contextNames) + + for _, cancel := range cancels { + cancel() + } +} + +func (r *Runner) stopRoot(rootID string, prune bool) { + var ( + cancel context.CancelFunc + contextNames []string + ) + + r.mu.Lock() + if state := r.roots[rootID]; state != nil { + cancel = state.cancel + + delete(r.roots, rootID) + } + + if prune { + contextNames = r.pruneRootLocked(rootID) + } + r.mu.Unlock() + + r.removeContexts(contextNames) + + if cancel != nil { + cancel() + } +} + +func (r *Runner) stopAllRoots() { + r.mu.Lock() + + cancels := make([]context.CancelFunc, 0, len(r.roots)) + + for rootID, state := range r.roots { + cancels = append(cancels, state.cancel) + + delete(r.roots, rootID) + } + r.mu.Unlock() + + for _, cancel := range cancels { + cancel() + } +} + +func (r *Runner) pruneRootLocked(rootID string) []string { + contextNames := make([]string, 0, len(r.profileKeysByRoot[rootID])) + + for profileKey := range r.profileKeysByRoot[rootID] { + contextNames = append(contextNames, r.pruneProfileLocked(profileKey)...) + } + + delete(r.profileKeysByRoot, rootID) + + return contextNames +} + +func (r *Runner) pruneProfileLocked(profileKey string) []string { + state, ok := r.profiles[profileKey] + if !ok { + return nil + } + + delete(r.profiles, profileKey) + + return []string{state.contextName} +} + +func (r *Runner) removeContexts(contextNames []string) { + for _, contextName := range contextNames { + if err := r.store.RemoveContext(contextName); err != nil { + logger.Log(logger.LevelWarn, map[string]string{"context": contextName}, err, + "cluster-inventory: failed to prune context") + } + } +} + +func (r *Runner) hasNoCRD(serverURL string) bool { + r.mu.Lock() + defer r.mu.Unlock() + + expiresAt, ok := r.noCRD[serverURL] + if !ok { + return false + } + + if !r.now().Before(expiresAt) { + delete(r.noCRD, serverURL) + return false + } + + return true +} + +func (r *Runner) markNoCRD(serverURL string) { + r.mu.Lock() + defer r.mu.Unlock() + + r.noCRD[serverURL] = r.now().Add(r.noCRDCacheTTL) +} + +func (r *Runner) markRootNoCRD(state *rootState) { + var ( + cancel context.CancelFunc + contextNames []string + ) + + r.mu.Lock() + if r.roots[state.rootID] == state { + r.noCRD[state.serverURL] = r.now().Add(r.noCRDCacheTTL) + cancel = state.cancel + delete(r.roots, state.rootID) + contextNames = r.pruneRootLocked(state.rootID) + } + r.mu.Unlock() + + r.removeContexts(contextNames) + + if cancel != nil { + cancel() + } +} + +func (r *Runner) isCurrentRoot(state *rootState) bool { + r.mu.Lock() + defer r.mu.Unlock() + + return r.roots[state.rootID] == state +} + +func makeProfileKey(rootID, profilePath string) string { + return rootID + "/" + profilePath +} + +func normalizeLabelSelector(selector string) (labels.Selector, error) { + selector = strings.TrimSpace(selector) + if selector == "" { + return nil, nil + } + + parsed, err := labels.Parse(selector) + if err != nil { + return nil, fmt.Errorf("invalid cluster-inventory-label-selector: %w", err) + } + + return parsed, nil +} + +func contextNameFromProfileKey(profileKey string) string { + return clusterInventoryContextPrefix + + kubeconfig.MakeDNSFriendly(profileKey) + + "--" + + profileKeyHashSuffix(profileKey) +} + +func profileKeyHashSuffix(profileKey string) string { + sum := sha256.Sum256([]byte(profileKey)) + + return hex.EncodeToString(sum[:6]) +} + +func normalizeServerURL(host string) string { + parsed, err := url.Parse(host) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return strings.TrimRight(host, "/") + } + + parsed.Path = strings.TrimRight(parsed.Path, "/") + parsed.RawQuery = "" + parsed.Fragment = "" + + return strings.TrimRight(parsed.String(), "/") +} + +func restConfigFingerprint(config *rest.Config) string { + fingerprintHash := sha256.New() + + writeRestConfigFingerprint(fingerprintHash, config) + writeTLSConfigFingerprint(fingerprintHash, config) + writeImpersonateFingerprint(fingerprintHash, config) + writeExecFingerprint(fingerprintHash, config.ExecProvider) + + return hex.EncodeToString(fingerprintHash.Sum(nil)) +} + +func writeRestConfigFingerprint(fingerprintHash hash.Hash, config *rest.Config) { + writeHashString(fingerprintHash, config.Host) + writeHashString(fingerprintHash, config.APIPath) + writeHashString(fingerprintHash, config.Username) + writeHashString(fingerprintHash, config.Password) + writeHashString(fingerprintHash, config.BearerToken) + writeHashString(fingerprintHash, config.BearerTokenFile) +} + +func writeTLSConfigFingerprint(fingerprintHash hash.Hash, config *rest.Config) { + writeHashString(fingerprintHash, config.ServerName) + writeHashString(fingerprintHash, config.CAFile) + writeHashString(fingerprintHash, config.CertFile) + writeHashString(fingerprintHash, config.KeyFile) + writeHashString(fingerprintHash, fmt.Sprintf("%t", config.Insecure)) + writeHashBytes(fingerprintHash, config.CAData) + writeHashBytes(fingerprintHash, config.CertData) + writeHashBytes(fingerprintHash, config.KeyData) +} + +func writeImpersonateFingerprint(fingerprintHash hash.Hash, config *rest.Config) { + writeHashString(fingerprintHash, config.Impersonate.UserName) + + for _, group := range config.Impersonate.Groups { + writeHashString(fingerprintHash, group) + } + + extraKeys := make([]string, 0, len(config.Impersonate.Extra)) + for key := range config.Impersonate.Extra { + extraKeys = append(extraKeys, key) + } + + sort.Strings(extraKeys) + + for _, key := range extraKeys { + writeHashString(fingerprintHash, key) + + for _, value := range config.Impersonate.Extra[key] { + writeHashString(fingerprintHash, value) + } + } +} + +func writeExecFingerprint(fingerprintHash hash.Hash, execProvider *api.ExecConfig) { + if execProvider == nil { + return + } + + writeHashString(fingerprintHash, execProvider.APIVersion) + writeHashString(fingerprintHash, execProvider.Command) + writeHashString(fingerprintHash, execProvider.InstallHint) + writeHashString(fingerprintHash, fmt.Sprintf("%t", execProvider.ProvideClusterInfo)) + + for _, arg := range execProvider.Args { + writeHashString(fingerprintHash, arg) + } + + for _, env := range execProvider.Env { + writeHashString(fingerprintHash, env.Name) + writeHashString(fingerprintHash, env.Value) + } + + writeExecConfigFingerprint(fingerprintHash, execProvider.Config) +} + +func writeExecConfigFingerprint(fingerprintHash hash.Hash, config k8sruntime.Object) { + if config == nil { + return + } + + writeHashString(fingerprintHash, fmt.Sprintf("%T", config)) + + configJSON, err := json.Marshal(config) + if err != nil { + writeHashString(fingerprintHash, fmt.Sprintf("%#v", config)) + + return + } + + writeHashBytes(fingerprintHash, configJSON) +} + +func writeHashString(fingerprintHash hash.Hash, value string) { + _, _ = fingerprintHash.Write([]byte(value)) + _, _ = fingerprintHash.Write([]byte{0}) +} + +func writeHashBytes(fingerprintHash hash.Hash, value []byte) { + _, _ = fingerprintHash.Write(value) + _, _ = fingerprintHash.Write([]byte{0}) +} + +func clusterProfileFromObject(obj interface{}) (*apisv1alpha1.ClusterProfile, bool) { + switch typed := obj.(type) { + case *apisv1alpha1.ClusterProfile: + return typed, true + case cache.DeletedFinalStateUnknown: + return clusterProfileFromObject(typed.Obj) + case *cache.DeletedFinalStateUnknown: + return clusterProfileFromObject(typed.Obj) + default: + return nil, false + } +} + +func accessOnlyClusterProfile(cp *apisv1alpha1.ClusterProfile) *apisv1alpha1.ClusterProfile { + return &apisv1alpha1.ClusterProfile{ + TypeMeta: cp.TypeMeta, + ObjectMeta: *cp.ObjectMeta.DeepCopy(), + Spec: cp.Spec, + Status: apisv1alpha1.ClusterProfileStatus{ + AccessProviders: append([]apisv1alpha1.AccessProvider(nil), cp.Status.AccessProviders...), + }, + } +} + +func copyAccessConfig(in *access.Config) *access.Config { + out := &access.Config{Providers: make([]access.Provider, len(in.Providers))} + for i, provider := range in.Providers { + out.Providers[i] = provider + if provider.ExecConfig == nil { + continue + } + + execConfig := *provider.ExecConfig + execConfig.Args = append([]string(nil), provider.ExecConfig.Args...) + execConfig.Env = append([]api.ExecEnvVar(nil), provider.ExecConfig.Env...) + out.Providers[i].ExecConfig = &execConfig + } + + return out +} + +func isNoCRDError(err error) bool { + if err == nil { + return false + } + + if meta.IsNoMatchError(err) { + return true + } + + if apierrors.IsNotFound(err) { + return isClusterProfileNotFound(err) + } + + message := err.Error() + + return strings.Contains(message, "no matches for kind") && + strings.Contains(message, apisv1alpha1.ClusterProfileKind) +} + +func isClusterProfileNotFound(err error) bool { + statusErr := &apierrors.StatusError{} + if errors.As(err, &statusErr) && statusDetailsMatchClusterProfiles(statusErr.ErrStatus.Details) { + return true + } + + message := err.Error() + + return strings.Contains(message, "clusterprofiles") || + (strings.Contains(message, "ClusterProfile") && strings.Contains(message, apisv1alpha1.Group)) +} + +func statusDetailsMatchClusterProfiles(details *metav1.StatusDetails) bool { + if details == nil || details.Group != apisv1alpha1.Group { + return false + } + + return details.Kind == apisv1alpha1.ClusterProfileKind || details.Name == "clusterprofiles" +} + +func proxyURLFromRestConfig(restConfig *rest.Config) (string, error) { + if restConfig.Proxy == nil { + return "", nil + } + + proxyRequestURL, err := url.Parse(restConfig.Host) + if err != nil { + return "", fmt.Errorf("proxy request URL: %w", err) + } + + if proxyRequestURL.Scheme == "" || proxyRequestURL.Host == "" { + return "", fmt.Errorf("proxy request URL missing scheme or host: %q", restConfig.Host) + } + + proxyURL, err := restConfig.Proxy(&http.Request{URL: proxyRequestURL}) + if err != nil { + return "", fmt.Errorf("proxy URL: %w", err) + } + + if proxyURL == nil { + return "", nil + } + + return proxyURL.String(), nil +} + +// restConfigToContext builds a Headlamp kubeconfig.Context from a generated rest.Config. +func restConfigToContext(restConfig *rest.Config, contextName, profileKey string) (*kubeconfig.Context, error) { + if restConfig == nil { + return nil, errors.New("restConfig is nil") + } + + if contextName == "" { + return nil, errors.New("contextName is empty") + } + + if profileKey == "" { + return nil, errors.New("profileKey is empty") + } + + cluster := &api.Cluster{ + Server: restConfig.Host, + CertificateAuthorityData: restConfig.CAData, + CertificateAuthority: restConfig.CAFile, + InsecureSkipTLSVerify: restConfig.Insecure, + TLSServerName: restConfig.ServerName, + } + + proxyURL, err := proxyURLFromRestConfig(restConfig) + if err != nil { + return nil, err + } + + cluster.ProxyURL = proxyURL + + if restConfig.ExecProvider != nil && restConfig.ExecProvider.Config != nil { + cluster.Extensions = map[string]k8sruntime.Object{ + clusterExecConfigExtensionKey: restConfig.ExecProvider.Config, + } + } + + authInfo := &api.AuthInfo{} + // Cluster Inventory access semantics live in the SDK/provider. Headlamp stores + // the exec bridge for client-go instead of materializing provider credentials. + if restConfig.ExecProvider != nil { + authInfo.Exec = restConfig.ExecProvider.DeepCopy() + authInfo.Exec.InteractiveMode = api.NeverExecInteractiveMode + } + + kubeContext := &api.Context{ + Cluster: contextName, + AuthInfo: contextName, + } + + return &kubeconfig.Context{ + Name: contextName, + KubeContext: kubeContext, + Cluster: cluster, + AuthInfo: authInfo, + Source: kubeconfig.ClusterInventory, + KubeConfigPath: "", + ClusterID: clusterInventoryIDPrefix + profileKey, + }, nil +} diff --git a/backend/pkg/clusterinventory/clusterinventory_test.go b/backend/pkg/clusterinventory/clusterinventory_test.go new file mode 100644 index 00000000000..14ca9d4406f --- /dev/null +++ b/backend/pkg/clusterinventory/clusterinventory_test.go @@ -0,0 +1,1135 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//nolint:testpackage // tests cover unexported discovery state. +package clusterinventory + +import ( + "context" + "errors" + "net/http" + "net/url" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8sruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/watch" + clientfeatures "k8s.io/client-go/features" + clientfeaturestesting "k8s.io/client-go/features/testing" + "k8s.io/client-go/rest" + k8stesting "k8s.io/client-go/testing" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + clientcmdv1 "k8s.io/client-go/tools/clientcmd/api/v1" + + inventorymetadata "github.com/kubernetes-sigs/headlamp/backend/pkg/clusterinventory/metadata" + "github.com/kubernetes-sigs/headlamp/backend/pkg/kubeconfig" + apisv1alpha1 "sigs.k8s.io/cluster-inventory-api/apis/v1alpha1" + ciaclient "sigs.k8s.io/cluster-inventory-api/client/clientset/versioned" + ciafake "sigs.k8s.io/cluster-inventory-api/client/clientset/versioned/fake" +) + +const ( + clusterInventoryTestEventuallyTimeout = 10 * time.Second + clusterInventoryTestEventuallyTick = 20 * time.Millisecond +) + +func writeProviderFile(t *testing.T, providers ...string) string { + t.Helper() + + if len(providers) == 0 { + providers = []string{"static-token"} + } + + providerJSON := "" + + for i, provider := range providers { + if i > 0 { + providerJSON += "," + } + + providerJSON += `{ + "name": "` + provider + `", + "execConfig": { + "apiVersion": "client.authentication.k8s.io/v1", + "command": "/bin/echo", + "provideClusterInfo": true + } + }` + } + + path := filepath.Join(t.TempDir(), "providers.json") + err := os.WriteFile(path, []byte(`{"providers":[`+providerJSON+`]}`), 0o600) + require.NoError(t, err) + + return path +} + +func newTestRunner(t *testing.T, opts Options) *Runner { + t.Helper() + + if opts.Store == nil { + opts.Store = kubeconfig.NewContextStore() + } + + if opts.ProviderFile == "" { + opts.ProviderFile = writeProviderFile(t) + } + + runner, err := NewRunner(opts) + require.NoError(t, err) + + return runner +} + +func clusterProfile(name, providerName, server string) *apisv1alpha1.ClusterProfile { + cp := &apisv1alpha1.ClusterProfile{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: name, + }, + } + + if providerName != "" { + cp.Status.AccessProviders = []apisv1alpha1.AccessProvider{ + { + Name: providerName, + Cluster: clientcmdv1.Cluster{ + Server: server, + CertificateAuthorityData: []byte("ca-" + name), + }, + }, + } + } + + return cp +} + +func clusterProfileWithControlPlaneCondition( + status metav1.ConditionStatus, + reason string, + message string, +) *apisv1alpha1.ClusterProfile { + cp := clusterProfile("spoke-a", "static-token", "https://spoke-a.example.com") + cp.Status.Conditions = []metav1.Condition{ + { + Type: apisv1alpha1.ClusterConditionControlPlaneHealthy, + Status: status, + Reason: reason, + Message: message, + }, + } + + return cp +} + +func listErrorClient(err error) *ciafake.Clientset { + client := ciafake.NewSimpleClientset() + client.PrependReactor("list", "clusterprofiles", func(k8stesting.Action) (bool, k8sruntime.Object, error) { + return true, nil, err + }) + + return client +} + +func getProfileContext(store kubeconfig.ContextStore, profileKey string) (*kubeconfig.Context, error) { + return store.GetContext(contextNameFromProfileKey(profileKey)) +} + +func testStoreContext(name string, source int, server, token string, internal bool) *kubeconfig.Context { + return &kubeconfig.Context{ + Name: name, + Source: source, + KubeContext: &clientcmdapi.Context{Cluster: name, AuthInfo: name}, + Cluster: &clientcmdapi.Cluster{Server: server}, + AuthInfo: &clientcmdapi.AuthInfo{Token: token}, + Internal: internal, + } +} + +func testRunnerContext(t *testing.T, runner *Runner) context.Context { + t.Helper() + + ctx, cancel := context.WithCancel(context.Background()) + + t.Cleanup(func() { + cancel() + runner.stopAllRoots() + }) + + return ctx +} + +func reconcileAndWaitForRoot(t *testing.T, ctx context.Context, runner *Runner, rootID string) { + t.Helper() + + runner.reconcileRoots(ctx) + waitForRootSync(t, runner, rootID) +} + +func waitForRootSync(t *testing.T, runner *Runner, rootID string) { + t.Helper() + + require.Eventually(t, func() bool { + runner.mu.Lock() + state := runner.roots[rootID] + runner.mu.Unlock() + + return state != nil && state.informer.HasSynced() + }, clusterInventoryTestEventuallyTimeout, clusterInventoryTestEventuallyTick) +} + +func requireProfileContextEventually( + t *testing.T, + store kubeconfig.ContextStore, + profileKey string, +) *kubeconfig.Context { + t.Helper() + + var headlampContext *kubeconfig.Context + + require.Eventually(t, func() bool { + ctx, err := getProfileContext(store, profileKey) + if err != nil { + return false + } + + headlampContext = ctx + + return true + }, clusterInventoryTestEventuallyTimeout, clusterInventoryTestEventuallyTick) + + return headlampContext +} + +func requireNoProfileContextEventually(t *testing.T, store kubeconfig.ContextStore, profileKey string) { + t.Helper() + + require.Eventually(t, func() bool { + _, err := getProfileContext(store, profileKey) + + return err != nil + }, clusterInventoryTestEventuallyTimeout, clusterInventoryTestEventuallyTick) +} + +type watchListClient struct { + ciaclient.Interface +} + +func (watchListClient) IsWatchListSemanticsUnSupported() bool { + return false +} + +type removeLockDetectingStore struct { + kubeconfig.ContextStore + runner *Runner + removeWhileLocked atomic.Bool +} + +func (s *removeLockDetectingStore) RemoveContext(name string) error { + if s.runner != nil { + if s.runner.mu.TryLock() { + s.runner.mu.Unlock() + } else { + s.removeWhileLocked.Store(true) + } + } + + return s.ContextStore.RemoveContext(name) +} + +func TestNewRunnerValidatesProviderFile(t *testing.T) { + store := kubeconfig.NewContextStore() + + _, err := NewRunner(Options{Store: store}) + require.ErrorContains(t, err, "provider file is required") + + _, err = NewRunner(Options{Store: store, ProviderFile: "/does/not/exist"}) + require.ErrorContains(t, err, "load cluster inventory provider file") + + malformed := filepath.Join(t.TempDir(), "malformed.json") + require.NoError(t, os.WriteFile(malformed, []byte("{"), 0o600)) + + _, err = NewRunner(Options{Store: store, ProviderFile: malformed}) + require.ErrorContains(t, err, "load cluster inventory provider file") + + _, err = NewRunner(Options{ + Store: store, + ProviderFile: writeProviderFile(t), + LabelSelector: "headlamp.dev/ignore in (", + }) + require.ErrorContains(t, err, "invalid cluster-inventory-label-selector") +} + +func TestRestConfigToContextPreservesConfig(t *testing.T) { + proxyURL, err := url.Parse("http://proxy.example.com:8080") + require.NoError(t, err) + + execConfig := &clientcmdapi.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1", + Command: "/bin/token", + Args: []string{"--cluster", "spoke"}, + Env: []clientcmdapi.ExecEnvVar{{Name: "TOKEN", Value: "redacted"}}, + ProvideClusterInfo: true, + Config: &k8sruntime.Unknown{Raw: []byte(`{"kind":"ExecConfig"}`)}, + } + + restConfig := &rest.Config{ + Host: "https://spoke.example.com", + TLSClientConfig: rest.TLSClientConfig{ + CAData: []byte("ca-data"), + CAFile: "/tmp/ca.pem", + Insecure: true, + ServerName: "spoke.internal", + }, + ExecProvider: execConfig, + Proxy: func(req *http.Request) (*url.URL, error) { + assert.Equal(t, "https://spoke.example.com", req.URL.String()) + + return proxyURL, nil + }, + } + + ctx, err := restConfigToContext(restConfig, "ctx-name", "root/ns/spoke") + require.NoError(t, err) + + assert.Equal(t, "ctx-name", ctx.Name) + assert.Equal(t, "https://spoke.example.com", ctx.Cluster.Server) + assert.Equal(t, []byte("ca-data"), ctx.Cluster.CertificateAuthorityData) + assert.Equal(t, "/tmp/ca.pem", ctx.Cluster.CertificateAuthority) + assert.True(t, ctx.Cluster.InsecureSkipTLSVerify) + assert.Equal(t, "spoke.internal", ctx.Cluster.TLSServerName) + assert.Equal(t, "http://proxy.example.com:8080", ctx.Cluster.ProxyURL) + assert.Equal(t, kubeconfig.ClusterInventory, ctx.Source) + assert.Equal(t, "cluster-inventory/root/ns/spoke", ctx.ClusterID) + require.NotNil(t, ctx.AuthInfo.Exec) + assert.Equal(t, "/bin/token", ctx.AuthInfo.Exec.Command) + assert.Equal(t, clientcmdapi.NeverExecInteractiveMode, ctx.AuthInfo.Exec.InteractiveMode) + assert.Equal(t, execConfig.Config, ctx.Cluster.Extensions[clusterExecConfigExtensionKey]) +} + +func TestContextFromClusterProfilePreservesInventoryMetadata(t *testing.T) { + runner := newTestRunner(t, Options{}) + profileKey := "in-cluster/default/spoke-a" + cp := clusterProfile("spoke-a", "static-token", "https://spoke-a.example.com") + cp.Status.Conditions = []metav1.Condition{ + { + Type: apisv1alpha1.ClusterConditionControlPlaneHealthy, + Status: metav1.ConditionFalse, + Reason: "HealthCheckFailed", + Message: "control plane endpoint is not ready", + LastTransitionTime: metav1.NewTime(time.Date(2026, 5, 10, 0, 0, 0, 0, time.UTC)), + ObservedGeneration: 3, + }, + } + cp.Status.Version = apisv1alpha1.ClusterVersion{Kubernetes: "v1.35.0"} + cp.Status.Properties = []apisv1alpha1.Property{ + { + Name: "region", + Value: "us-west1", + LastObservedTime: metav1.NewTime(time.Date(2026, 5, 10, 1, 0, 0, 0, time.UTC)), + }, + } + + headlampContext, ok := runner.contextFromClusterProfile(profileKey, cp) + require.True(t, ok) + + require.NotNil(t, headlampContext.ClusterInventory) + assert.Equal(t, inventorymetadata.Profile{ + Namespace: "default", + Name: "spoke-a", + Key: profileKey, + }, headlampContext.ClusterInventory.Profile) + assert.Equal(t, cp.Status.Conditions, headlampContext.ClusterInventory.Conditions) + require.NotNil(t, headlampContext.ClusterInventory.Version) + assert.Equal(t, "v1.35.0", headlampContext.ClusterInventory.Version.Kubernetes) + assert.Equal(t, []inventorymetadata.Property{ + { + Name: "region", + Value: "us-west1", + LastObservedTime: cp.Status.Properties[0].LastObservedTime, + }, + }, headlampContext.ClusterInventory.Properties) +} + +func TestClusterProfileDeletePrunesContextOutsideRunnerLock(t *testing.T) { + store := &removeLockDetectingStore{ContextStore: kubeconfig.NewContextStore()} + runner := newTestRunner(t, Options{ + Store: store, + HubConfig: &rest.Config{Host: "https://hub.example.com"}, + }) + store.runner = runner + + state := &rootState{rootID: inClusterRootID} + profileKey := makeProfileKey(state.rootID, "default/spoke-a") + contextName := contextNameFromProfileKey(profileKey) + require.NoError(t, store.AddContext(&kubeconfig.Context{Name: contextName})) + + runner.mu.Lock() + runner.roots[state.rootID] = state + runner.profileKeysByRoot[state.rootID] = map[string]struct{}{profileKey: {}} + runner.profiles[profileKey] = profileState{contextName: contextName} + runner.mu.Unlock() + + runner.handleClusterProfileDelete(state, &apisv1alpha1.ClusterProfile{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "spoke-a", + }, + }) + + assert.False(t, store.removeWhileLocked.Load()) + + _, err := store.GetContext(contextName) + require.Error(t, err) + + runner.mu.Lock() + _, profileExists := runner.profiles[profileKey] + _, rootProfileExists := runner.profileKeysByRoot[state.rootID][profileKey] + runner.mu.Unlock() + + assert.False(t, profileExists) + assert.False(t, rootProfileExists) +} + +func TestContextNameFromProfileKey(t *testing.T) { + tests := []struct { + profileKey string + want string + }{ + {"in-cluster/ns/name", "cluster-inventory-in-cluster--ns--name--c2adabb8b734"}, + {"store/minikube/ns/name", "cluster-inventory-store--minikube--ns--name--f8b7bcd1f9fb"}, + {"store/seed/ns/name with space", "cluster-inventory-store--seed--ns--name__with__space--86a59f47f71a"}, + {"ns/a--b", "cluster-inventory-ns--a--b--3a9038b84ee9"}, + {"ns--a/b", "cluster-inventory-ns--a--b--7bc90dd90ccb"}, + {"ns/a_b + c", "cluster-inventory-ns--a_b__+__c--b955996621e2"}, + } + + for _, tt := range tests { + t.Run(tt.profileKey, func(t *testing.T) { + assert.Equal(t, tt.want, contextNameFromProfileKey(tt.profileKey)) + }) + } +} + +func TestContextNameFromProfileKeyAvoidsSeparatorCollisions(t *testing.T) { + assert.NotEqual(t, + contextNameFromProfileKey("ns/a--b"), + contextNameFromProfileKey("ns--a/b"), + ) +} + +func TestInformerInitialSyncUsesAccessProviders(t *testing.T) { + store := kubeconfig.NewContextStore() + runner := newTestRunner(t, Options{ + Store: store, + HubConfig: &rest.Config{Host: "https://hub.example.com"}, + }) + + runner.clientForConfig = func(*rest.Config) (ciaclient.Interface, error) { + return ciafake.NewSimpleClientset( + clusterProfile("spoke-a", "static-token", "https://spoke-a.example.com"), + clusterProfile("no-access", "", "https://no-access.example.com"), + ), nil + } + + ctx := testRunnerContext(t, runner) + reconcileAndWaitForRoot(t, ctx, runner, inClusterRootID) + + requireProfileContextEventually(t, store, "in-cluster/default/spoke-a") + requireNoProfileContextEventually(t, store, "in-cluster/default/no-access") +} + +func TestInformerInitialSyncIgnoresLabelSelectedProfiles(t *testing.T) { + store := kubeconfig.NewContextStore() + ignoredProfile := clusterProfile("ignored", "static-token", "https://ignored.example.com") + ignoredProfile.Labels = map[string]string{"headlamp.dev/ignore": "true"} + + runner := newTestRunner(t, Options{ + Store: store, + HubConfig: &rest.Config{Host: "https://hub.example.com"}, + LabelSelector: "!headlamp.dev/ignore", + }) + + runner.clientForConfig = func(*rest.Config) (ciaclient.Interface, error) { + return ciafake.NewSimpleClientset( + clusterProfile("spoke-a", "static-token", "https://spoke-a.example.com"), + ignoredProfile, + ), nil + } + + ctx := testRunnerContext(t, runner) + reconcileAndWaitForRoot(t, ctx, runner, inClusterRootID) + + requireProfileContextEventually(t, store, "in-cluster/default/spoke-a") + requireNoProfileContextEventually(t, store, "in-cluster/default/ignored") +} + +func TestTransientWatchFailureDoesNotPrunePreviousContexts(t *testing.T) { + store := kubeconfig.NewContextStore() + runner := newTestRunner(t, Options{ + Store: store, + HubConfig: &rest.Config{Host: "https://hub.example.com"}, + }) + + client := ciafake.NewSimpleClientset( + clusterProfile("spoke-a", "static-token", "https://spoke-a.example.com"), + ) + client.PrependWatchReactor("clusterprofiles", func(k8stesting.Action) (bool, watch.Interface, error) { + return true, nil, errors.New("temporary outage") + }) + + runner.clientForConfig = func(*rest.Config) (ciaclient.Interface, error) { + return client, nil + } + + ctx := testRunnerContext(t, runner) + reconcileAndWaitForRoot(t, ctx, runner, inClusterRootID) + + requireProfileContextEventually(t, store, "in-cluster/default/spoke-a") +} + +func TestInitialSyncFailureDoesNotPrunePreviousContexts(t *testing.T) { + store := kubeconfig.NewContextStore() + runner := newTestRunner(t, Options{ + Store: store, + HubConfig: &rest.Config{Host: "https://hub.example.com"}, + }) + + runner.clientForConfig = func(config *rest.Config) (ciaclient.Interface, error) { + if config.Host == "https://temporary-outage.example.com" { + return listErrorClient(errors.New("temporary outage")), nil + } + + return ciafake.NewSimpleClientset( + clusterProfile("spoke-a", "static-token", "https://spoke-a.example.com"), + ), nil + } + + ctx := testRunnerContext(t, runner) + reconcileAndWaitForRoot(t, ctx, runner, inClusterRootID) + requireProfileContextEventually(t, store, "in-cluster/default/spoke-a") + + runner.hubConfig = &rest.Config{Host: "https://temporary-outage.example.com"} + runner.reconcileRoots(ctx) + + requireProfileContextEventually(t, store, "in-cluster/default/spoke-a") +} + +func TestProviderFailureDoesNotPrunePreviousContext(t *testing.T) { + store := kubeconfig.NewContextStore() + runner := newTestRunner(t, Options{ + Store: store, + HubConfig: &rest.Config{Host: "https://hub.example.com"}, + }) + + client := ciafake.NewSimpleClientset( + clusterProfile("spoke-a", "static-token", "https://spoke-a.example.com"), + ) + + runner.clientForConfig = func(*rest.Config) (ciaclient.Interface, error) { + return client, nil + } + + ctx := testRunnerContext(t, runner) + reconcileAndWaitForRoot(t, ctx, runner, inClusterRootID) + headlampContext := requireProfileContextEventually(t, store, "in-cluster/default/spoke-a") + require.Equal(t, "https://spoke-a.example.com", headlampContext.Cluster.Server) + + updated := clusterProfile("spoke-a", "missing-provider", "https://spoke-a-updated.example.com") + _, err := client.ApisV1alpha1().ClusterProfiles("default").Update(ctx, updated, metav1.UpdateOptions{}) + require.NoError(t, err) + + headlampContext = requireProfileContextEventually(t, store, "in-cluster/default/spoke-a") + assert.Equal(t, "https://spoke-a.example.com", headlampContext.Cluster.Server) +} + +func TestInformerDoesNotDiscoverClusterProfilesFromDiscoveredClusters(t *testing.T) { + store := kubeconfig.NewContextStore() + runner := newTestRunner(t, Options{ + Store: store, + HubConfig: &rest.Config{Host: "https://hub.example.com"}, + }) + + clientRequests := map[string]int{} + runner.clientForConfig = func(config *rest.Config) (ciaclient.Interface, error) { + clientRequests[config.Host]++ + + switch config.Host { + case "https://hub.example.com": + return ciafake.NewSimpleClientset( + clusterProfile("spoke-a", "static-token", "https://spoke-a.example.com"), + clusterProfile("hub2", "static-token", "https://hub2.example.com"), + ), nil + default: + t.Fatalf("unexpected root watcher for %s", config.Host) + + return nil, nil + } + } + + ctx := testRunnerContext(t, runner) + reconcileAndWaitForRoot(t, ctx, runner, inClusterRootID) + + requireProfileContextEventually(t, store, "in-cluster/default/spoke-a") + requireProfileContextEventually(t, store, "in-cluster/default/hub2") + requireNoProfileContextEventually(t, store, "in-cluster/default/hub2/default/spoke-b") + assert.Equal(t, 1, clientRequests["https://hub.example.com"]) + assert.Zero(t, clientRequests["https://hub2.example.com"]) +} + +func TestInitialSyncPrunesMissingDirectProfiles(t *testing.T) { + store := kubeconfig.NewContextStore() + runner := newTestRunner(t, Options{ + Store: store, + HubConfig: &rest.Config{Host: "https://hub.example.com"}, + }) + + runner.clientForConfig = func(config *rest.Config) (ciaclient.Interface, error) { + if config.Host == "https://hub-next.example.com" { + return ciafake.NewSimpleClientset( + clusterProfile("spoke-b", "static-token", "https://spoke-b.example.com"), + ), nil + } + + return ciafake.NewSimpleClientset( + clusterProfile("spoke-a", "static-token", "https://spoke-a.example.com"), + clusterProfile("spoke-b", "static-token", "https://spoke-b.example.com"), + ), nil + } + + ctx := testRunnerContext(t, runner) + reconcileAndWaitForRoot(t, ctx, runner, inClusterRootID) + requireProfileContextEventually(t, store, "in-cluster/default/spoke-a") + requireProfileContextEventually(t, store, "in-cluster/default/spoke-b") + + runner.hubConfig = &rest.Config{Host: "https://hub-next.example.com"} + reconcileAndWaitForRoot(t, ctx, runner, inClusterRootID) + + requireNoProfileContextEventually(t, store, "in-cluster/default/spoke-a") + requireProfileContextEventually(t, store, "in-cluster/default/spoke-b") +} + +func TestStoreSeedsSkipClusterInventoryAndAllowSameServerPerRoot(t *testing.T) { + store := kubeconfig.NewContextStore() + require.NoError(t, store.AddContext(testStoreContext( + "seed-a", kubeconfig.KubeConfig, "https://shared.example.com", "token-a", false))) + require.NoError(t, store.AddContext(testStoreContext( + "seed-b", kubeconfig.DynamicCluster, "https://shared.example.com", "token-b", false))) + require.NoError(t, store.AddContext(testStoreContext( + "internal-seed", kubeconfig.DynamicCluster, "https://internal.example.com", "token-internal", true))) + require.NoError(t, store.AddContext(testStoreContext( + "discovered", kubeconfig.ClusterInventory, "https://ignored.example.com", "", false))) + + runner := newTestRunner(t, Options{ + Store: store, + DiscoverFromStore: true, + }) + + requestedTokens := map[string]int{} + requestedHosts := map[string]int{} + runner.clientForConfig = func(config *rest.Config) (ciaclient.Interface, error) { + requestedTokens[config.BearerToken]++ + requestedHosts[config.Host]++ + + switch config.BearerToken { + case "token-a": + return ciafake.NewSimpleClientset( + clusterProfile("from-a", "static-token", "https://a.example.com"), + ), nil + case "token-b": + return ciafake.NewSimpleClientset( + clusterProfile("from-b", "static-token", "https://b.example.com"), + ), nil + default: + return ciafake.NewSimpleClientset(), nil + } + } + + ctx := testRunnerContext(t, runner) + runner.reconcileRoots(ctx) + waitForRootSync(t, runner, "store/seed-a") + waitForRootSync(t, runner, "store/seed-b") + + assert.Equal(t, 1, requestedTokens["token-a"]) + assert.Equal(t, 1, requestedTokens["token-b"]) + assert.Zero(t, requestedTokens["token-internal"]) + assert.Zero(t, requestedHosts["https://ignored.example.com"]) + assert.Zero(t, requestedHosts["https://internal.example.com"]) + requireProfileContextEventually(t, store, "store/seed-a/default/from-a") + requireProfileContextEventually(t, store, "store/seed-b/default/from-b") +} + +func TestRemovedStoreSeedStopsWatcherAndPrunesDiscoveredContexts(t *testing.T) { + store := kubeconfig.NewContextStore() + require.NoError(t, store.AddContext(testStoreContext( + "seed-a", kubeconfig.KubeConfig, "https://seed-a.example.com", "token-a", false))) + + runner := newTestRunner(t, Options{ + Store: store, + DiscoverFromStore: true, + }) + + requestedHosts := map[string]int{} + runner.clientForConfig = func(config *rest.Config) (ciaclient.Interface, error) { + requestedHosts[config.Host]++ + + switch config.Host { + case "https://seed-a.example.com": + return ciafake.NewSimpleClientset( + clusterProfile("from-a", "static-token", "https://from-a.example.com"), + ), nil + default: + t.Fatalf("unexpected root watcher for %s", config.Host) + + return nil, nil + } + } + + ctx := testRunnerContext(t, runner) + reconcileAndWaitForRoot(t, ctx, runner, "store/seed-a") + requireProfileContextEventually(t, store, "store/seed-a/default/from-a") + assert.Equal(t, 1, requestedHosts["https://seed-a.example.com"]) + assert.Zero(t, requestedHosts["https://from-a.example.com"]) + + require.NoError(t, store.RemoveContext("seed-a")) + + runner.reconcileRoots(ctx) + + requireNoProfileContextEventually(t, store, "store/seed-a/default/from-a") + require.Eventually(t, func() bool { + runner.mu.Lock() + defer runner.mu.Unlock() + + return runner.roots["store/seed-a"] == nil + }, clusterInventoryTestEventuallyTimeout, clusterInventoryTestEventuallyTick) +} + +func TestStoreSeedConfigChangeRestartsWatcher(t *testing.T) { + store := kubeconfig.NewContextStore() + require.NoError(t, store.AddContext(testStoreContext( + "seed-a", kubeconfig.KubeConfig, "https://seed.example.com", "token-a", false))) + + runner := newTestRunner(t, Options{ + Store: store, + DiscoverFromStore: true, + }) + + requestedTokens := map[string]int{} + runner.clientForConfig = func(config *rest.Config) (ciaclient.Interface, error) { + requestedTokens[config.BearerToken]++ + + switch config.BearerToken { + case "token-a": + return ciafake.NewSimpleClientset( + clusterProfile("from-a", "static-token", "https://from-a.example.com"), + ), nil + case "token-b": + return ciafake.NewSimpleClientset( + clusterProfile("from-b", "static-token", "https://from-b.example.com"), + ), nil + default: + t.Fatalf("unexpected token %q", config.BearerToken) + + return nil, nil + } + } + + ctx := testRunnerContext(t, runner) + reconcileAndWaitForRoot(t, ctx, runner, "store/seed-a") + requireProfileContextEventually(t, store, "store/seed-a/default/from-a") + + require.NoError(t, store.AddContext(testStoreContext( + "seed-a", kubeconfig.KubeConfig, "https://seed.example.com", "token-b", false))) + + reconcileAndWaitForRoot(t, ctx, runner, "store/seed-a") + + assert.Equal(t, 1, requestedTokens["token-a"]) + assert.Equal(t, 1, requestedTokens["token-b"]) + requireNoProfileContextEventually(t, store, "store/seed-a/default/from-a") + requireProfileContextEventually(t, store, "store/seed-a/default/from-b") +} + +func TestRestConfigFingerprintIncludesExecConfig(t *testing.T) { + execConfig := func(raw string) *clientcmdapi.ExecConfig { + return &clientcmdapi.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1", + Command: "/bin/token", + Config: &k8sruntime.Unknown{Raw: []byte(raw)}, + } + } + + configA := &rest.Config{ + Host: "https://seed.example.com", + ExecProvider: execConfig(`{"audience":"a"}`), + } + configAAgain := &rest.Config{ + Host: "https://seed.example.com", + ExecProvider: execConfig(`{"audience":"a"}`), + } + configB := &rest.Config{ + Host: "https://seed.example.com", + ExecProvider: execConfig(`{"audience":"b"}`), + } + + assert.Equal(t, restConfigFingerprint(configA), restConfigFingerprint(configAAgain)) + assert.NotEqual(t, restConfigFingerprint(configA), restConfigFingerprint(configB)) +} + +func TestSelfReferencingProfileDoesNotTriggerChildWatcher(t *testing.T) { + store := kubeconfig.NewContextStore() + runner := newTestRunner(t, Options{ + Store: store, + HubConfig: &rest.Config{Host: "https://hub.example.com"}, + }) + + clientRequests := 0 + runner.clientForConfig = func(*rest.Config) (ciaclient.Interface, error) { + clientRequests++ + + return ciafake.NewSimpleClientset( + clusterProfile("self", "static-token", "https://hub.example.com"), + ), nil + } + + ctx := testRunnerContext(t, runner) + reconcileAndWaitForRoot(t, ctx, runner, inClusterRootID) + + assert.Equal(t, 1, clientRequests) + requireProfileContextEventually(t, store, "in-cluster/default/self") +} + +func TestWatchAddUpdateDeleteSyncsContext(t *testing.T) { + store := kubeconfig.NewContextStore() + runner := newTestRunner(t, Options{ + Store: store, + HubConfig: &rest.Config{Host: "https://hub.example.com"}, + }) + + client := ciafake.NewSimpleClientset() + runner.clientForConfig = func(*rest.Config) (ciaclient.Interface, error) { + return client, nil + } + + ctx := testRunnerContext(t, runner) + reconcileAndWaitForRoot(t, ctx, runner, inClusterRootID) + + created, err := client.ApisV1alpha1().ClusterProfiles("default").Create( + ctx, + clusterProfile("spoke-a", "static-token", "https://spoke-a.example.com"), + metav1.CreateOptions{}, + ) + require.NoError(t, err) + + headlampContext := requireProfileContextEventually(t, store, "in-cluster/default/spoke-a") + require.Equal(t, "https://spoke-a.example.com", headlampContext.Cluster.Server) + + updated := clusterProfile("spoke-a", "static-token", "https://spoke-a-updated.example.com") + updated.ObjectMeta = created.ObjectMeta + _, err = client.ApisV1alpha1().ClusterProfiles("default").Update(ctx, updated, metav1.UpdateOptions{}) + require.NoError(t, err) + + require.Eventually(t, func() bool { + headlampContext, err := getProfileContext(store, "in-cluster/default/spoke-a") + + return err == nil && headlampContext.Cluster.Server == "https://spoke-a-updated.example.com" + }, clusterInventoryTestEventuallyTimeout, clusterInventoryTestEventuallyTick) + + require.NoError(t, client.ApisV1alpha1().ClusterProfiles("default").Delete( + ctx, + "spoke-a", + metav1.DeleteOptions{}, + )) + + requireNoProfileContextEventually(t, store, "in-cluster/default/spoke-a") +} + +func TestWatchUpdateSyncsClusterInventoryConditions(t *testing.T) { + store := kubeconfig.NewContextStore() + runner := newTestRunner(t, Options{ + Store: store, + HubConfig: &rest.Config{Host: "https://hub.example.com"}, + }) + + client := ciafake.NewSimpleClientset() + runner.clientForConfig = func(*rest.Config) (ciaclient.Interface, error) { + return client, nil + } + + ctx := testRunnerContext(t, runner) + reconcileAndWaitForRoot(t, ctx, runner, inClusterRootID) + + createdProfile := clusterProfileWithControlPlaneCondition( + metav1.ConditionFalse, + "HealthCheckFailed", + "control plane endpoint is not ready", + ) + created, err := client.ApisV1alpha1().ClusterProfiles("default").Create( + ctx, + createdProfile, + metav1.CreateOptions{}, + ) + require.NoError(t, err) + + headlampContext := requireProfileContextEventually(t, store, "in-cluster/default/spoke-a") + require.NotNil(t, headlampContext.ClusterInventory) + require.Len(t, headlampContext.ClusterInventory.Conditions, 1) + require.Equal(t, metav1.ConditionFalse, headlampContext.ClusterInventory.Conditions[0].Status) + + updatedProfile := clusterProfileWithControlPlaneCondition( + metav1.ConditionTrue, + "HealthCheckSucceeded", + "control plane endpoint is ready", + ) + updatedProfile.ObjectMeta = created.ObjectMeta + _, err = client.ApisV1alpha1().ClusterProfiles("default").Update(ctx, updatedProfile, metav1.UpdateOptions{}) + require.NoError(t, err) + + require.Eventually(t, func() bool { + headlampContext, err := getProfileContext(store, "in-cluster/default/spoke-a") + if err != nil || headlampContext.ClusterInventory == nil { + return false + } + + conditions := headlampContext.ClusterInventory.Conditions + + return len(conditions) == 1 && + conditions[0].Type == apisv1alpha1.ClusterConditionControlPlaneHealthy && + conditions[0].Status == metav1.ConditionTrue && + conditions[0].Reason == "HealthCheckSucceeded" + }, clusterInventoryTestEventuallyTimeout, clusterInventoryTestEventuallyTick) +} + +func TestClusterProfileUpdateToIgnoredLabelPrunesContext(t *testing.T) { + store := kubeconfig.NewContextStore() + runner := newTestRunner(t, Options{ + Store: store, + HubConfig: &rest.Config{Host: "https://hub.example.com"}, + LabelSelector: "!headlamp.dev/ignore", + }) + + client := ciafake.NewSimpleClientset() + runner.clientForConfig = func(*rest.Config) (ciaclient.Interface, error) { + return client, nil + } + + ctx := testRunnerContext(t, runner) + reconcileAndWaitForRoot(t, ctx, runner, inClusterRootID) + + created, err := client.ApisV1alpha1().ClusterProfiles("default").Create( + ctx, + clusterProfile("spoke-a", "static-token", "https://spoke-a.example.com"), + metav1.CreateOptions{}, + ) + require.NoError(t, err) + + requireProfileContextEventually(t, store, "in-cluster/default/spoke-a") + + updated := clusterProfile("spoke-a", "static-token", "https://spoke-a.example.com") + updated.ObjectMeta = created.ObjectMeta + updated.Labels = map[string]string{"headlamp.dev/ignore": "true"} + _, err = client.ApisV1alpha1().ClusterProfiles("default").Update(ctx, updated, metav1.UpdateOptions{}) + require.NoError(t, err) + + requireNoProfileContextEventually(t, store, "in-cluster/default/spoke-a") +} + +func TestNoCRDPrunesAndSuppressesRootUntilTTL(t *testing.T) { + store := kubeconfig.NewContextStore() + runner := newTestRunner(t, Options{ + Store: store, + HubConfig: &rest.Config{Host: "https://hub.example.com"}, + NoCRDCacheTTL: time.Minute, + }) + + now := time.Date(2026, time.May, 8, 0, 0, 0, 0, time.UTC) + noCRDMode := true + clientRequests := map[string]int{} + runner.now = func() time.Time { return now } + runner.clientForConfig = func(config *rest.Config) (ciaclient.Interface, error) { + clientRequests[config.Host]++ + + if config.Host == "https://no-crd.example.com" { + if noCRDMode { + return listErrorClient(apierrors.NewNotFound(schema.GroupResource{ + Group: apisv1alpha1.Group, + Resource: "clusterprofiles", + }, "clusterprofiles")), nil + } + + return ciafake.NewSimpleClientset( + clusterProfile("spoke-b", "static-token", "https://spoke-b.example.com"), + ), nil + } + + return ciafake.NewSimpleClientset( + clusterProfile("spoke-a", "static-token", "https://spoke-a.example.com"), + ), nil + } + + ctx := testRunnerContext(t, runner) + reconcileAndWaitForRoot(t, ctx, runner, inClusterRootID) + requireProfileContextEventually(t, store, "in-cluster/default/spoke-a") + + runner.hubConfig = &rest.Config{Host: "https://no-crd.example.com"} + runner.reconcileRoots(ctx) + + requireNoProfileContextEventually(t, store, "in-cluster/default/spoke-a") + + noCRDClientRequests := clientRequests["https://no-crd.example.com"] + require.NotZero(t, noCRDClientRequests) + + runner.reconcileRoots(ctx) + assert.Equal(t, noCRDClientRequests, clientRequests["https://no-crd.example.com"]) + + now = now.Add(2 * time.Minute) + noCRDMode = false + + reconcileAndWaitForRoot(t, ctx, runner, inClusterRootID) + + requireProfileContextEventually(t, store, "in-cluster/default/spoke-b") + assert.Greater(t, clientRequests["https://no-crd.example.com"], noCRDClientRequests) +} + +func TestClusterProfileInformerUsesWatchListOptions(t *testing.T) { + clientfeaturestesting.SetFeatureDuringTest(t, clientfeatures.WatchListClient, true) + + store := kubeconfig.NewContextStore() + runner := newTestRunner(t, Options{ + Store: store, + HubConfig: &rest.Config{Host: "https://hub.example.com"}, + LabelSelector: "!headlamp.dev/ignore", + }) + + client := ciafake.NewSimpleClientset() + optionsCh := make(chan metav1.ListOptions, 1) + fakeWatch := watch.NewFake() + + client.PrependWatchReactor("clusterprofiles", func(action k8stesting.Action) (bool, watch.Interface, error) { + watchAction, ok := action.(interface { + GetListOptions() metav1.ListOptions + }) + require.True(t, ok) + + select { + case optionsCh <- watchAction.GetListOptions(): + default: + } + + return true, fakeWatch, nil + }) + + runner.clientForConfig = func(*rest.Config) (ciaclient.Interface, error) { + return watchListClient{Interface: client}, nil + } + + ctx := testRunnerContext(t, runner) + runner.reconcileRoots(ctx) + + var options metav1.ListOptions + select { + case options = <-optionsCh: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for ClusterProfile watch-list request") + } + + require.NotNil(t, options.SendInitialEvents) + assert.True(t, *options.SendInitialEvents) + assert.True(t, options.AllowWatchBookmarks) + assert.Equal(t, metav1.ResourceVersionMatchNotOlderThan, options.ResourceVersionMatch) + assert.Equal(t, "!headlamp.dev/ignore", options.LabelSelector) + + fakeWatch.Stop() +} + +func TestNoCRDErrorClassification(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + name: "clusterprofiles not found", + err: apierrors.NewNotFound(schema.GroupResource{ + Group: apisv1alpha1.Group, + Resource: "clusterprofiles", + }, "clusterprofiles"), + want: true, + }, + { + name: "no match", + err: &meta.NoResourceMatchError{ + PartialResource: schema.GroupVersionResource{ + Group: apisv1alpha1.Group, + Version: apisv1alpha1.Version, + Resource: "clusterprofiles", + }, + }, + want: true, + }, + { + name: "forbidden", + err: apierrors.NewForbidden(schema.GroupResource{ + Group: apisv1alpha1.Group, + Resource: "clusterprofiles", + }, "clusterprofiles", errors.New("denied")), + want: false, + }, + { + name: "transport", + err: errors.New("connection refused"), + want: false, + }, + { + name: "other not found", + err: apierrors.NewNotFound(schema.GroupResource{ + Group: "", + Resource: "pods", + }, "pods"), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isNoCRDError(tt.err)) + }) + } +} + +func TestNoCRDCacheTTL(t *testing.T) { + runner := newTestRunner(t, Options{ + NoCRDCacheTTL: time.Minute, + }) + + now := time.Date(2026, time.May, 8, 0, 0, 0, 0, time.UTC) + runner.now = func() time.Time { return now } + + runner.markNoCRD("https://no-crd.example.com") + assert.True(t, runner.hasNoCRD("https://no-crd.example.com")) + + now = now.Add(2 * time.Minute) + + assert.False(t, runner.hasNoCRD("https://no-crd.example.com")) +} diff --git a/backend/pkg/clusterinventory/metadata/metadata.go b/backend/pkg/clusterinventory/metadata/metadata.go new file mode 100644 index 00000000000..eff64941ecc --- /dev/null +++ b/backend/pkg/clusterinventory/metadata/metadata.go @@ -0,0 +1,72 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package metadata contains Cluster Inventory metadata shapes exposed by Headlamp. +package metadata + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +// Profile identifies the ClusterProfile that produced a context. +type Profile struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Key string `json:"key"` +} + +// Version contains version details reported by Cluster Inventory. +type Version struct { + Kubernetes string `json:"kubernetes,omitempty"` +} + +// Property describes a property reported by Cluster Inventory. +type Property struct { + Name string `json:"name"` + Value string `json:"value"` + LastObservedTime metav1.Time `json:"lastObservedTime,omitempty"` +} + +// Metadata contains non-sensitive ClusterProfile status metadata. +// +// The fields mirror the non-access-provider parts of the upstream +// [ClusterProfileStatus] shape. +// +// [ClusterProfileStatus]: https://pkg.go.dev/sigs.k8s.io/cluster-inventory-api/apis/v1alpha1#ClusterProfileStatus +type Metadata struct { + Profile Profile `json:"profile"` + Conditions []metav1.Condition `json:"conditions,omitempty"` + Version *Version `json:"version,omitempty"` + Properties []Property `json:"properties,omitempty"` +} + +// DeepCopy returns an independent copy of Metadata. +func (m *Metadata) DeepCopy() *Metadata { + if m == nil { + return nil + } + + copied := &Metadata{ + Profile: m.Profile, + Conditions: append([]metav1.Condition(nil), m.Conditions...), + Properties: append([]Property(nil), m.Properties...), + } + + if m.Version != nil { + version := *m.Version + copied.Version = &version + } + + return copied +} diff --git a/backend/pkg/config/config.go b/backend/pkg/config/config.go index 81564739d3c..b0ddf379b11 100644 --- a/backend/pkg/config/config.go +++ b/backend/pkg/config/config.go @@ -11,12 +11,16 @@ import ( "path/filepath" "runtime" "strings" + "time" "github.com/knadh/koanf" "github.com/knadh/koanf/providers/basicflag" "github.com/knadh/koanf/providers/env" + "github.com/kubernetes-sigs/headlamp/backend/pkg/clusterinventory" "github.com/kubernetes-sigs/headlamp/backend/pkg/logger" "github.com/kubernetes-sigs/headlamp/backend/pkg/spa" + "k8s.io/apimachinery/pkg/labels" + "sigs.k8s.io/cluster-inventory-api/pkg/access" ) const ( @@ -42,23 +46,30 @@ type Config struct { // NoBrowser disables automatically opening the default browser when running // a locally embedded Headlamp binary (non in-cluster with spa.UseEmbeddedFiles == true). // It has no effect in in-cluster mode or when running without embedded frontend. - NoBrowser bool `koanf:"no-browser"` - CacheEnabled bool `koanf:"cache-enabled"` - EnableHelm bool `koanf:"enable-helm"` - EnableDynamicClusters bool `koanf:"enable-dynamic-clusters"` - AllowKubeconfigChanges bool `koanf:"allow-kubeconfig-changes"` - ListenAddr string `koanf:"listen-addr"` - WatchPluginsChanges bool `koanf:"watch-plugins-changes"` - Port uint `koanf:"port"` - KubeConfigPath string `koanf:"kubeconfig"` - SkippedKubeContexts string `koanf:"skipped-kube-contexts"` - StaticDir string `koanf:"html-static-dir"` - PluginsDir string `koanf:"plugins-dir"` - UserPluginsDir string `koanf:"user-plugins-dir"` - BaseURL string `koanf:"base-url"` - SessionTTL int `koanf:"session-ttl"` - PodDebugImage string `koanf:"pod-debug-image"` - ProxyURLs string `koanf:"proxy-urls"` + NoBrowser bool `koanf:"no-browser"` + CacheEnabled bool `koanf:"cache-enabled"` + EnableHelm bool `koanf:"enable-helm"` + EnableDynamicClusters bool `koanf:"enable-dynamic-clusters"` + EnableClusterInventory bool `koanf:"enable-cluster-inventory"` + AllowKubeconfigChanges bool `koanf:"allow-kubeconfig-changes"` + ListenAddr string `koanf:"listen-addr"` + WatchPluginsChanges bool `koanf:"watch-plugins-changes"` + Port uint `koanf:"port"` + KubeConfigPath string `koanf:"kubeconfig"` + SkippedKubeContexts string `koanf:"skipped-kube-contexts"` + StaticDir string `koanf:"html-static-dir"` + PluginsDir string `koanf:"plugins-dir"` + UserPluginsDir string `koanf:"user-plugins-dir"` + BaseURL string `koanf:"base-url"` + SessionTTL int `koanf:"session-ttl"` + PodDebugImage string `koanf:"pod-debug-image"` + ProxyURLs string `koanf:"proxy-urls"` + + ClusterInventoryProviderFile string `koanf:"cluster-inventory-provider-file"` + ClusterInventoryLabelSelector string `koanf:"cluster-inventory-label-selector"` + ClusterInventoryRootReconcileInterval time.Duration `koanf:"cluster-inventory-root-reconcile-interval"` + ClusterInventoryNoCRDCacheTTL time.Duration `koanf:"cluster-inventory-no-crd-cache-ttl"` + OidcClientID string `koanf:"oidc-client-id"` OidcValidatorClientID string `koanf:"oidc-validator-client-id"` OidcClientSecret string `koanf:"oidc-client-secret"` @@ -164,6 +175,10 @@ func (c *Config) Validate() error { } } + if err := c.validateClusterInventory(); err != nil { + return err + } + return nil } @@ -185,6 +200,40 @@ func (c *Config) validateOIDCCAFile() error { return nil } +func (c *Config) validateClusterInventory() error { + if !c.EnableClusterInventory { + return nil + } + + if c.ClusterInventoryProviderFile == "" { + return errors.New("cluster-inventory-provider-file is required when cluster inventory is enabled") + } + + info, err := os.Stat(c.ClusterInventoryProviderFile) + if err != nil { + return fmt.Errorf("error reading cluster-inventory-provider-file: %w", err) + } + + if !info.Mode().IsRegular() { + return errors.New("cluster-inventory-provider-file must be a regular file") + } + + if _, err := access.NewFromFile(c.ClusterInventoryProviderFile); err != nil { + return fmt.Errorf("invalid cluster-inventory-provider-file: %w", err) + } + + labelSelector := strings.TrimSpace(c.ClusterInventoryLabelSelector) + if labelSelector != "" { + if _, err := labels.Parse(labelSelector); err != nil { + return fmt.Errorf("invalid cluster-inventory-label-selector: %w", err) + } + } + + c.ClusterInventoryLabelSelector = labelSelector + + return nil +} + func (c *Config) validateServiceAccountTokenFlags() error { if !c.InCluster && (c.UnsafeUseServiceAccountToken || c.ServiceAccountTokenPath != "") { return errors.New("--unsafe-use-service-account-token and --service-account-token-path " + @@ -515,6 +564,16 @@ func addGeneralFlags(f *flag.FlagSet) { f.Uint("port", defaultPort, "Port to listen from") f.String("proxy-urls", "", "Allow proxy requests to specified URLs") f.Bool("enable-helm", false, "Enable Helm operations") + f.Bool("enable-cluster-inventory", false, + "Enable experimental/alpha automatic discovery of clusters from ClusterProfile resources") + f.String("cluster-inventory-provider-file", "", + "Path to the JSON configuration file for experimental/alpha Cluster Inventory access providers") + f.String("cluster-inventory-label-selector", "", + "Label selector used to filter ClusterProfile resources for experimental/alpha Cluster Inventory") + f.Duration("cluster-inventory-root-reconcile-interval", clusterinventory.DefaultRootReconcileInterval, + "Interval for reconciling experimental/alpha Cluster Inventory roots") + f.Duration("cluster-inventory-no-crd-cache-ttl", clusterinventory.DefaultNoCRDCacheTTL, + "How long to cache that an API server has no experimental/alpha ClusterProfile CRD") f.String("default-light-theme", "", "Default theme to use when user prefers light mode") f.String("default-dark-theme", "", "Default theme to use when user prefers dark mode") f.String("force-theme", "", "Force a specific theme, overriding user preferences") diff --git a/backend/pkg/config/config_test.go b/backend/pkg/config/config_test.go index 67f26d02d0e..9b04c72dd01 100644 --- a/backend/pkg/config/config_test.go +++ b/backend/pkg/config/config_test.go @@ -6,12 +6,42 @@ import ( "runtime" "strings" "testing" + "time" + "github.com/kubernetes-sigs/headlamp/backend/pkg/clusterinventory" "github.com/kubernetes-sigs/headlamp/backend/pkg/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestMain(m *testing.M) { + clusterInventoryEnv := []string{ + "HEADLAMP_CONFIG_ENABLE_CLUSTER_INVENTORY", + "HEADLAMP_CONFIG_CLUSTER_INVENTORY_PROVIDER_FILE", + "HEADLAMP_CONFIG_CLUSTER_INVENTORY_LABEL_SELECTOR", + "HEADLAMP_CONFIG_CLUSTER_INVENTORY_ROOT_RECONCILE_INTERVAL", + "HEADLAMP_CONFIG_CLUSTER_INVENTORY_NO_CRD_CACHE_TTL", + } + + previous := map[string]string{} + for _, key := range clusterInventoryEnv { + previous[key] = os.Getenv(key) + _ = os.Unsetenv(key) + } + + code := m.Run() + + for _, key := range clusterInventoryEnv { + if previous[key] == "" { + _ = os.Unsetenv(key) + } else { + _ = os.Setenv(key, previous[key]) + } + } + + os.Exit(code) +} + // getTestDataPath returns the absolute path to the test data directory. func getTestDataPath() string { // Get the current working directory @@ -29,6 +59,15 @@ func getTestDataPath() string { return filepath.Join(cwd, "pkg", "config", "test_data") } +func writeClusterInventoryProviderFile(t *testing.T) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "providers.json") + require.NoError(t, os.WriteFile(path, []byte(`{"providers":[]}`), 0o600)) + + return path +} + func TestParseBasic(t *testing.T) { tests := []struct { name string @@ -407,6 +446,121 @@ func TestProxyAuthFlags(t *testing.T) { } } +func TestParseClusterInventoryFlags(t *testing.T) { + providerFile := writeClusterInventoryProviderFile(t) + + conf, err := config.Parse([]string{ + "go run ./cmd", + "--enable-cluster-inventory", + "--cluster-inventory-provider-file=" + providerFile, + "--cluster-inventory-label-selector=environment=prod,!headlamp.dev/ignore", + "--cluster-inventory-root-reconcile-interval=15s", + "--cluster-inventory-no-crd-cache-ttl=1m", + }) + require.NoError(t, err) + + assert.True(t, conf.EnableClusterInventory) + assert.Equal(t, providerFile, conf.ClusterInventoryProviderFile) + assert.Equal(t, "environment=prod,!headlamp.dev/ignore", conf.ClusterInventoryLabelSelector) + assert.Equal(t, 15*time.Second, conf.ClusterInventoryRootReconcileInterval) + assert.Equal(t, time.Minute, conf.ClusterInventoryNoCRDCacheTTL) +} + +func TestParseClusterInventoryEnv(t *testing.T) { + providerFile := writeClusterInventoryProviderFile(t) + t.Setenv("HEADLAMP_CONFIG_ENABLE_CLUSTER_INVENTORY", "true") + t.Setenv("HEADLAMP_CONFIG_CLUSTER_INVENTORY_PROVIDER_FILE", providerFile) + t.Setenv("HEADLAMP_CONFIG_CLUSTER_INVENTORY_LABEL_SELECTOR", "!headlamp.dev/ignore") + + conf, err := config.Parse([]string{"go run ./cmd"}) + require.NoError(t, err) + + assert.True(t, conf.EnableClusterInventory) + assert.Equal(t, providerFile, conf.ClusterInventoryProviderFile) + assert.Equal(t, "!headlamp.dev/ignore", conf.ClusterInventoryLabelSelector) +} + +func TestParseClusterInventoryDefaultIntervals(t *testing.T) { + providerFile := writeClusterInventoryProviderFile(t) + + conf, err := config.Parse([]string{ + "go run ./cmd", + "--enable-cluster-inventory", + "--cluster-inventory-provider-file=" + providerFile, + }) + require.NoError(t, err) + + assert.Equal(t, clusterinventory.DefaultRootReconcileInterval, conf.ClusterInventoryRootReconcileInterval) + assert.Equal(t, clusterinventory.DefaultNoCRDCacheTTL, conf.ClusterInventoryNoCRDCacheTTL) + assert.Empty(t, conf.ClusterInventoryLabelSelector) +} + +func TestClusterInventoryValidation(t *testing.T) { + t.Run("disabled allows empty provider file", func(t *testing.T) { + conf, err := config.Parse([]string{"go run ./cmd"}) + require.NoError(t, err) + require.NotNil(t, conf) + assert.False(t, conf.EnableClusterInventory) + }) + + t.Run("enabled requires provider file", func(t *testing.T) { + conf, err := config.Parse([]string{"go run ./cmd", "--enable-cluster-inventory"}) + require.Error(t, err) + require.Nil(t, conf) + assert.Contains(t, err.Error(), "cluster-inventory-provider-file is required") + }) + + t.Run("enabled rejects missing provider file", func(t *testing.T) { + conf, err := config.Parse([]string{ + "go run ./cmd", + "--enable-cluster-inventory", + "--cluster-inventory-provider-file=/does/not/exist", + }) + require.Error(t, err) + require.Nil(t, conf) + assert.Contains(t, err.Error(), "error reading cluster-inventory-provider-file") + }) + + t.Run("enabled rejects directory provider file", func(t *testing.T) { + conf, err := config.Parse([]string{ + "go run ./cmd", + "--enable-cluster-inventory", + "--cluster-inventory-provider-file=" + t.TempDir(), + }) + require.Error(t, err) + require.Nil(t, conf) + assert.Contains(t, err.Error(), "cluster-inventory-provider-file must be a regular file") + }) + + t.Run("enabled rejects invalid provider file", func(t *testing.T) { + providerFile := filepath.Join(t.TempDir(), "providers.json") + require.NoError(t, os.WriteFile(providerFile, []byte(`{`), 0o600)) + + conf, err := config.Parse([]string{ + "go run ./cmd", + "--enable-cluster-inventory", + "--cluster-inventory-provider-file=" + providerFile, + }) + require.Error(t, err) + require.Nil(t, conf) + assert.Contains(t, err.Error(), "invalid cluster-inventory-provider-file") + }) +} + +func TestClusterInventoryRejectsInvalidLabelSelector(t *testing.T) { + providerFile := writeClusterInventoryProviderFile(t) + + conf, err := config.Parse([]string{ + "go run ./cmd", + "--enable-cluster-inventory", + "--cluster-inventory-provider-file=" + providerFile, + "--cluster-inventory-label-selector=headlamp.dev/ignore in (", + }) + require.Error(t, err) + require.Nil(t, conf) + assert.Contains(t, err.Error(), "invalid cluster-inventory-label-selector") +} + func TestOIDCTLSValidation(t *testing.T) { tests := []struct { name string diff --git a/backend/pkg/headlampconfig/headlampConfig.go b/backend/pkg/headlampconfig/headlampConfig.go index 6cea0d2f6f8..526ddd265ef 100644 --- a/backend/pkg/headlampconfig/headlampConfig.go +++ b/backend/pkg/headlampconfig/headlampConfig.go @@ -2,6 +2,7 @@ package headlampconfig import ( "net/http" + "time" "github.com/kubernetes-sigs/headlamp/backend/pkg/cache" "github.com/kubernetes-sigs/headlamp/backend/pkg/config" @@ -44,28 +45,29 @@ type HeadlampConfig struct { } type HeadlampCFG struct { - UseInCluster bool - InClusterContextName string - ListenAddr string - CacheEnabled bool - DevMode bool - Insecure bool - EnableHelm bool - EnableDynamicClusters bool - AllowKubeconfigChanges bool - WatchPluginsChanges bool - Port uint - KubeConfigPath string - SkippedKubeContexts string - StaticDir string - PluginDir string - UserPluginDir string - StaticPluginDir string - KubeConfigStore kubeconfig.ContextStore - Telemetry *telemetry.Telemetry - Metrics *telemetry.Metrics - BaseURL string - ProxyURLs []string + UseInCluster bool + InClusterContextName string + ListenAddr string + CacheEnabled bool + DevMode bool + Insecure bool + EnableHelm bool + EnableDynamicClusters bool + AllowKubeconfigChanges bool + WatchPluginsChanges bool + Port uint + KubeConfigPath string + SkippedKubeContexts string + StaticDir string + PluginDir string + UserPluginDir string + StaticPluginDir string + KubeConfigStore kubeconfig.ContextStore + Telemetry *telemetry.Telemetry + Metrics *telemetry.Metrics + BaseURL string + ProxyURLs []string + TLSCertPath string TLSKeyPath string SessionTTL int @@ -76,4 +78,10 @@ type HeadlampCFG struct { ForceTheme string UnsafeUseServiceAccountToken bool ServiceAccountTokenPath string + + EnableClusterInventory bool + ClusterInventoryProviderFile string + ClusterInventoryLabelSelector string + ClusterInventoryRootReconcileInterval time.Duration + ClusterInventoryNoCRDCacheTTL time.Duration } diff --git a/backend/pkg/kubeconfig/kubeconfig.go b/backend/pkg/kubeconfig/kubeconfig.go index e292fa27d6b..5f54d10e2b5 100644 --- a/backend/pkg/kubeconfig/kubeconfig.go +++ b/backend/pkg/kubeconfig/kubeconfig.go @@ -17,6 +17,7 @@ import ( "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/clientcmd/api" + inventorymetadata "github.com/kubernetes-sigs/headlamp/backend/pkg/clusterinventory/metadata" "github.com/kubernetes-sigs/headlamp/backend/pkg/exec" "github.com/kubernetes-sigs/headlamp/backend/pkg/logger" "k8s.io/client-go/kubernetes" @@ -56,6 +57,7 @@ const ( KubeConfig = 1 << iota DynamicCluster InCluster + ClusterInventory ) // Context contains all information related to a kubernetes context. @@ -73,6 +75,8 @@ type Context struct { KubeConfigPath string `json:"kubeConfigPath"` // ClusterID is the unique identifier for the cluster, consisting of the filepath and context name. ClusterID string `json:"clusterID"` + // ClusterInventory stores metadata copied from a Cluster Inventory ClusterProfile. + ClusterInventory *inventorymetadata.Metadata `json:"clusterInventory,omitempty"` } // Copy creates a deep copy of the Context, excluding the proxy field which is created on demand. @@ -115,16 +119,17 @@ func (c *Context) Copy() *Context { } return &Context{ - Name: c.Name, - KubeContext: kubeContext, - Cluster: cluster, - AuthInfo: authInfo, - Source: c.Source, - OidcConf: oidcConf, - Internal: c.Internal, - Error: c.Error, - KubeConfigPath: c.KubeConfigPath, - ClusterID: c.ClusterID, + Name: c.Name, + KubeContext: kubeContext, + Cluster: cluster, + AuthInfo: authInfo, + Source: c.Source, + OidcConf: oidcConf, + Internal: c.Internal, + Error: c.Error, + KubeConfigPath: c.KubeConfigPath, + ClusterID: c.ClusterID, + ClusterInventory: c.ClusterInventory.DeepCopy(), } } @@ -415,6 +420,8 @@ func (c *Context) SourceStr() string { return "dynamic_cluster" case InCluster: return "incluster" + case ClusterInventory: + return "cluster_inventory" default: return "unknown" } diff --git a/backend/pkg/kubeconfig/kubeconfig_test.go b/backend/pkg/kubeconfig/kubeconfig_test.go index f54cc4b03ab..d8263cdaae7 100644 --- a/backend/pkg/kubeconfig/kubeconfig_test.go +++ b/backend/pkg/kubeconfig/kubeconfig_test.go @@ -85,6 +85,26 @@ func TestLoadAndStoreKubeConfigs(t *testing.T) { }) } +func TestContextSourceStr(t *testing.T) { + tests := []struct { + name string + source int + want string + }{ + {"kubeconfig", kubeconfig.KubeConfig, "kubeconfig"}, + {"dynamic cluster", kubeconfig.DynamicCluster, "dynamic_cluster"}, + {"in cluster", kubeconfig.InCluster, "incluster"}, + {"cluster inventory", kubeconfig.ClusterInventory, "cluster_inventory"}, + {"unknown", 0, "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, (&kubeconfig.Context{Source: tt.source}).SourceStr()) + }) + } +} + func TestLoadContextsFromKubeConfigFile(t *testing.T) { t.Run("valid_file", func(t *testing.T) { kubeConfigFile := kubeConfigFilePath diff --git a/charts/headlamp/README.md b/charts/headlamp/README.md index 4592a231946..159cddd28cd 100644 --- a/charts/headlamp/README.md +++ b/charts/headlamp/README.md @@ -66,6 +66,41 @@ $ helm upgrade my-headlamp headlamp/headlamp \ --set image.tag=v ``` +### Installation with Cluster Inventory + +> **Warning** +> Cluster Inventory support in Headlamp is alpha/experimental and disabled by +> default. The upstream Cluster Inventory API is currently `v1alpha1` and this +> integration uses the `v0.1.x` API, so fields and behavior may change. + +```console +$ helm install my-headlamp headlamp/headlamp \ + --namespace kube-system \ + --values cluster-inventory-values.yaml +``` + +`cluster-inventory-values.yaml`: + +```yaml +config: + clusterInventory: + enabled: true + accessProvidersConfig: + providers: + - name: secretreader + execConfig: + apiVersion: client.authentication.k8s.io/v1 + command: /access-plugins/secretreader/secretreader-plugin + provideClusterInfo: true + plugins: + - name: secretreader + image: registry.k8s.io/cluster-inventory-api/secretreader:v0.1.1 + mountPath: /access-plugins/secretreader +``` + +`plugins[]` mounts Cluster Inventory access provider binaries as Kubernetes +`image` volumes; it is not for Headlamp UI plugins. + ## Configuration ### Core Parameters @@ -90,6 +125,12 @@ $ helm upgrade my-headlamp headlamp/headlamp \ | config.pluginsDir | string | `"/headlamp/plugins"` | Directory to load Headlamp plugins from | | config.enableHelm | bool | `false` | Enable Helm operations like install, upgrade and uninstall of Helm charts | | config.podDebugImage | string | `""` | Default image to use when creating pod debug containers | +| config.clusterInventory.enabled | bool | `false` | Enable experimental/alpha Cluster Inventory discovery | +| config.clusterInventory.accessProvidersConfig | object | `{}` | Experimental/alpha Cluster Inventory access providers config. Required when Cluster Inventory is enabled | +| config.clusterInventory.plugins | list | `[]` | Kubernetes image volumes that provide experimental/alpha Cluster Inventory access provider binaries | +| config.clusterInventory.labelSelector | string | `"!headlamp.dev/ignore"` | Kubernetes label selector used to filter experimental/alpha ClusterProfile resources | +| config.clusterInventory.rootReconcileInterval | string | `""` | Override the experimental/alpha Cluster Inventory root reconcile interval. Empty uses the Headlamp default | +| config.clusterInventory.noCRDCacheTTL | string | `""` | Override the experimental/alpha Cluster Inventory no-CRD cache TTL. Empty uses the Headlamp default | | config.extraArgs | array | `[]` | Additional arguments for Headlamp server | | config.tlsCertPath | string | `""` | Certificate for serving TLS | | config.tlsKeyPath | string | `""` | Key for serving TLS | @@ -148,6 +189,40 @@ config: name: your-oidc-secret ``` +### Cluster Inventory Configuration + +> **Warning** +> Cluster Inventory support in Headlamp is alpha/experimental and disabled by +> default. The upstream Cluster Inventory API is currently `v1alpha1` and this +> integration uses the `v0.1.x` API, so fields and behavior may change. + +When `config.clusterInventory.enabled` is true, the chart creates a provider +ConfigMap, makes it available read-only at `/etc/cluster-inventory/config.json`, +and adds the Headlamp Cluster Inventory flags automatically. + +```yaml +config: + clusterInventory: + enabled: true + accessProvidersConfig: + providers: + - name: secretreader + execConfig: + apiVersion: client.authentication.k8s.io/v1 + command: /access-plugins/secretreader/secretreader-plugin + provideClusterInfo: true + plugins: + - name: secretreader + image: registry.k8s.io/cluster-inventory-api/secretreader:v0.1.1 + mountPath: /access-plugins/secretreader +``` + +`plugins[]` is for Cluster Inventory access provider binaries, not Headlamp UI +plugins. Each entry renders as a Kubernetes `image` volume and is mounted +read-only into the Headlamp container. If an access provider `execConfig.command` +is configured, the command's parent directory must match one of the +absolute `plugins[].mountPath` values. + ### Deployment Configuration | Key | Type | Default | Description | diff --git a/charts/headlamp/templates/cluster-inventory-configmap.yaml b/charts/headlamp/templates/cluster-inventory-configmap.yaml new file mode 100644 index 00000000000..060bdf9b9b1 --- /dev/null +++ b/charts/headlamp/templates/cluster-inventory-configmap.yaml @@ -0,0 +1,33 @@ +{{- $clusterInventory := .Values.config.clusterInventory | default dict }} +{{- $clusterInventoryProviderConfigPath := "config.json" }} +{{- if $clusterInventory.enabled }} +{{- if not $clusterInventory.accessProvidersConfig }} +{{- fail "config.clusterInventory.enabled is true but config.clusterInventory.accessProvidersConfig is empty; either provide an access providers config or disable Cluster Inventory" }} +{{- end }} +{{- $mountPaths := list }} +{{- range $index, $plugin := ($clusterInventory.plugins | default list) }} +{{- $mountPath := $plugin.mountPath | default "" | toString | clean }} +{{- if not (hasPrefix "/" $mountPath) }} +{{- fail (printf "config.clusterInventory.plugins[%d].mountPath must be an absolute path, got %q" $index ($plugin.mountPath | default "" | toString)) }} +{{- end }} +{{- $mountPaths = append $mountPaths $mountPath }} +{{- end }} +{{- range ($clusterInventory.accessProvidersConfig.providers | default list) }} +{{- $cmd := (.execConfig | default dict).command | default "" }} +{{- if $cmd }} +{{- $cmdDir := dir $cmd | clean }} +{{- if not (has $cmdDir $mountPaths) }} +{{- fail (printf "provider %q: command dir %q does not match any config.clusterInventory.plugins[].mountPath %v" .name $cmdDir $mountPaths) }} +{{- end }} +{{- end }} +{{- end }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "headlamp.fullname" . }}-cluster-inventory + namespace: {{ include "headlamp.namespace" . }} + labels: + {{- include "headlamp.labels" . | nindent 4 }} +data: + {{ $clusterInventoryProviderConfigPath }}: {{ $clusterInventory.accessProvidersConfig | mustToJson | quote }} +{{- end }} diff --git a/charts/headlamp/templates/deployment.yaml b/charts/headlamp/templates/deployment.yaml index 42707107a7c..a9cc400fc80 100644 --- a/charts/headlamp/templates/deployment.yaml +++ b/charts/headlamp/templates/deployment.yaml @@ -11,6 +11,11 @@ {{- $usePKCE := "" }} {{- $useAccessToken := "" }} {{- $meUserInfoURL := "" }} +{{- $clusterInventory := .Values.config.clusterInventory | default dict }} +{{- $clusterInventoryEnabled := $clusterInventory.enabled | default false }} +{{- $clusterInventoryProviderFile := "/etc/cluster-inventory/config.json" }} +{{- $clusterInventoryProviderMountDir := "/etc/cluster-inventory" }} +{{- $clusterInventoryProviderConfigPath := "config.json" }} {{- $readOnlyRootFs := eq (include "headlamp.readOnlyRootFilesystem" .Values) "true" }} {{- $pluginManagerSecurityContext := dict }} {{- if .Values.pluginsManager.securityContext }} @@ -270,6 +275,19 @@ spec: {{- if and .Values.config.inCluster .Values.config.unsafeUseServiceAccountToken .Values.config.serviceAccountTokenPath }} - "-service-account-token-path={{ .Values.config.serviceAccountTokenPath }}" {{- end }} + {{- if $clusterInventoryEnabled }} + - "-enable-cluster-inventory" + - "-cluster-inventory-provider-file={{ $clusterInventoryProviderFile }}" + {{- with $clusterInventory.labelSelector }} + - "-cluster-inventory-label-selector={{ . }}" + {{- end }} + {{- with $clusterInventory.rootReconcileInterval }} + - "-cluster-inventory-root-reconcile-interval={{ . }}" + {{- end }} + {{- with $clusterInventory.noCRDCacheTTL }} + - "-cluster-inventory-no-crd-cache-ttl={{ . }}" + {{- end }} + {{- end }} {{- if not $oidc.externalSecret.enabled}} # Check if externalSecret is disabled {{- if or (ne $oidc.clientID "") (ne $clientID "") }} @@ -378,12 +396,26 @@ spec: failureThreshold: {{ .Values.probes.readinessProbe.failureThreshold | default 3 }} resources: {{- toYaml .Values.resources | nindent 12 }} - {{- if or .Values.pluginsManager.enabled .Values.volumeMounts $tmpCtx.addMount }} + {{- if or .Values.pluginsManager.enabled $clusterInventoryEnabled .Values.volumeMounts $tmpCtx.addMount }} volumeMounts: {{- if .Values.pluginsManager.enabled }} - name: plugins-dir mountPath: {{ .Values.config.pluginsDir }} {{- end }} + {{- if $clusterInventoryEnabled }} + - name: cluster-inventory-config + mountPath: {{ $clusterInventoryProviderMountDir }} + readOnly: true + {{- range $index, $plugin := ($clusterInventory.plugins | default list) }} + {{- $mountPath := $plugin.mountPath | default "" | toString | clean }} + {{- if not (hasPrefix "/" $mountPath) }} + {{- fail (printf "config.clusterInventory.plugins[%d].mountPath must be an absolute path, got %q" $index ($plugin.mountPath | default "" | toString)) }} + {{- end }} + - name: {{ $plugin.name }} + mountPath: {{ $mountPath }} + readOnly: true + {{- end }} + {{- end }} {{- with .Values.volumeMounts }} {{- toYaml . | nindent 12 }} {{- end }} @@ -465,7 +497,7 @@ spec: {{- with .Values.priorityClassName }} priorityClassName: {{ . | quote }} {{- end }} - {{- if or .Values.pluginsManager.enabled .Values.volumes $tmpCtx.addVolume $pluginsTmpCtx.addVolume }} + {{- if or .Values.pluginsManager.enabled $clusterInventoryEnabled .Values.volumes $tmpCtx.addVolume $pluginsTmpCtx.addVolume }} volumes: {{- if .Values.pluginsManager.enabled }} - name: plugins-dir @@ -478,6 +510,19 @@ spec: emptyDir: {} {{- end }} {{- end }} + {{- if $clusterInventoryEnabled }} + - name: cluster-inventory-config + configMap: + name: {{ include "headlamp.fullname" . }}-cluster-inventory + items: + - key: config.json + path: {{ $clusterInventoryProviderConfigPath }} + {{- range $plugin := ($clusterInventory.plugins | default list) }} + - name: {{ $plugin.name }} + image: + reference: {{ $plugin.image | quote }} + {{- end }} + {{- end }} {{- if $tmpCtx.addVolume }} - name: headlamp-tmp emptyDir: {} diff --git a/charts/headlamp/tests/expected_templates/cluster-inventory-plugins.yaml b/charts/headlamp/tests/expected_templates/cluster-inventory-plugins.yaml new file mode 100644 index 00000000000..42d9f3e24a3 --- /dev/null +++ b/charts/headlamp/tests/expected_templates/cluster-inventory-plugins.yaml @@ -0,0 +1,177 @@ +--- +# Source: headlamp/templates/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: headlamp + namespace: default + labels: + helm.sh/chart: headlamp-0.42.0 + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + app.kubernetes.io/version: "0.42.0" + app.kubernetes.io/managed-by: Helm +--- +# Source: headlamp/templates/secret.yaml +apiVersion: v1 +kind: Secret +metadata: + name: oidc + namespace: default +type: Opaque +data: +--- +# Source: headlamp/templates/cluster-inventory-configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: headlamp-cluster-inventory + namespace: default + labels: + helm.sh/chart: headlamp-0.42.0 + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + app.kubernetes.io/version: "0.42.0" + app.kubernetes.io/managed-by: Helm +data: + config.json: "{\"providers\":[{\"execConfig\":{\"apiVersion\":\"client.authentication.k8s.io/v1\",\"command\":\"/access-plugins/secretreader/secretreader-plugin\",\"provideClusterInfo\":true},\"name\":\"secretreader\"}]}" +--- +# Source: headlamp/templates/clusterrolebinding.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: headlamp-admin + labels: + helm.sh/chart: headlamp-0.42.0 + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + app.kubernetes.io/version: "0.42.0" + app.kubernetes.io/managed-by: Helm +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: +- kind: ServiceAccount + name: headlamp + namespace: default +--- +# Source: headlamp/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: headlamp + namespace: default + labels: + helm.sh/chart: headlamp-0.42.0 + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + app.kubernetes.io/version: "0.42.0" + app.kubernetes.io/managed-by: Helm +spec: + type: ClusterIP + + ports: + - port: 80 + targetPort: http + protocol: TCP + name: http + selector: + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp +--- +# Source: headlamp/templates/deployment.yaml +# This block of code is used to extract the values from the env. +# This is done to check if the values are non-empty and if they are, they are used in the deployment.yaml. + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: headlamp + namespace: default + labels: + helm.sh/chart: headlamp-0.42.0 + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + app.kubernetes.io/version: "0.42.0" + app.kubernetes.io/managed-by: Helm +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + template: + metadata: + labels: + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + spec: + serviceAccountName: headlamp + automountServiceAccountToken: true + hostUsers: true + securityContext: + {} + containers: + - name: headlamp + securityContext: + privileged: false + runAsGroup: 101 + runAsNonRoot: true + runAsUser: 100 + image: "ghcr.io/headlamp-k8s/headlamp:v0.42.0" + imagePullPolicy: IfNotPresent + + env: + args: + - "-in-cluster" + - "-in-cluster-context-name=main" + - "-plugins-dir=/headlamp/plugins" + - "-session-ttl=86400" + - "-enable-cluster-inventory" + - "-cluster-inventory-provider-file=/etc/cluster-inventory/config.json" + - "-cluster-inventory-label-selector=!headlamp.dev/ignore" + # Check if externalSecret is disabled + ports: + - name: http + containerPort: 4466 + protocol: TCP + livenessProbe: + httpGet: + path: "/" + port: http + scheme: HTTP + initialDelaySeconds: 0 + periodSeconds: 10 + timeoutSeconds: 1 + successThreshold: 1 + failureThreshold: 3 + readinessProbe: + httpGet: + path: "/" + port: http + scheme: HTTP + initialDelaySeconds: 0 + periodSeconds: 10 + timeoutSeconds: 1 + successThreshold: 1 + failureThreshold: 3 + resources: + {} + volumeMounts: + - name: cluster-inventory-config + mountPath: /etc/cluster-inventory + readOnly: true + - name: secretreader + mountPath: /access-plugins/secretreader + readOnly: true + volumes: + - name: cluster-inventory-config + configMap: + name: headlamp-cluster-inventory + items: + - key: config.json + path: config.json + - name: secretreader + image: + reference: "registry.k8s.io/cluster-inventory-api/secretreader:v0.1.1" diff --git a/charts/headlamp/tests/expected_templates/cluster-inventory.yaml b/charts/headlamp/tests/expected_templates/cluster-inventory.yaml new file mode 100644 index 00000000000..8cd6d2a5119 --- /dev/null +++ b/charts/headlamp/tests/expected_templates/cluster-inventory.yaml @@ -0,0 +1,173 @@ +--- +# Source: headlamp/templates/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: headlamp + namespace: default + labels: + helm.sh/chart: headlamp-0.42.0 + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + app.kubernetes.io/version: "0.42.0" + app.kubernetes.io/managed-by: Helm +--- +# Source: headlamp/templates/secret.yaml +apiVersion: v1 +kind: Secret +metadata: + name: oidc + namespace: default +type: Opaque +data: +--- +# Source: headlamp/templates/cluster-inventory-configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: headlamp-cluster-inventory + namespace: default + labels: + helm.sh/chart: headlamp-0.42.0 + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + app.kubernetes.io/version: "0.42.0" + app.kubernetes.io/managed-by: Helm +data: + config.json: "{\"providers\":[{\"name\":\"static-provider\"}]}" +--- +# Source: headlamp/templates/clusterrolebinding.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: headlamp-admin + labels: + helm.sh/chart: headlamp-0.42.0 + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + app.kubernetes.io/version: "0.42.0" + app.kubernetes.io/managed-by: Helm +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: +- kind: ServiceAccount + name: headlamp + namespace: default +--- +# Source: headlamp/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: headlamp + namespace: default + labels: + helm.sh/chart: headlamp-0.42.0 + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + app.kubernetes.io/version: "0.42.0" + app.kubernetes.io/managed-by: Helm +spec: + type: ClusterIP + + ports: + - port: 80 + targetPort: http + protocol: TCP + name: http + selector: + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp +--- +# Source: headlamp/templates/deployment.yaml +# This block of code is used to extract the values from the env. +# This is done to check if the values are non-empty and if they are, they are used in the deployment.yaml. + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: headlamp + namespace: default + labels: + helm.sh/chart: headlamp-0.42.0 + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + app.kubernetes.io/version: "0.42.0" + app.kubernetes.io/managed-by: Helm +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + template: + metadata: + labels: + app.kubernetes.io/name: headlamp + app.kubernetes.io/instance: headlamp + spec: + serviceAccountName: headlamp + automountServiceAccountToken: true + hostUsers: true + securityContext: + {} + containers: + - name: headlamp + securityContext: + privileged: false + runAsGroup: 101 + runAsNonRoot: true + runAsUser: 100 + image: "ghcr.io/headlamp-k8s/headlamp:v0.42.0" + imagePullPolicy: IfNotPresent + + env: + args: + - "-in-cluster" + - "-in-cluster-context-name=main" + - "-plugins-dir=/headlamp/plugins" + - "-session-ttl=86400" + - "-enable-cluster-inventory" + - "-cluster-inventory-provider-file=/etc/cluster-inventory/config.json" + - "-cluster-inventory-label-selector=!headlamp.dev/ignore" + - "-cluster-inventory-root-reconcile-interval=10s" + - "-cluster-inventory-no-crd-cache-ttl=30s" + # Check if externalSecret is disabled + ports: + - name: http + containerPort: 4466 + protocol: TCP + livenessProbe: + httpGet: + path: "/" + port: http + scheme: HTTP + initialDelaySeconds: 0 + periodSeconds: 10 + timeoutSeconds: 1 + successThreshold: 1 + failureThreshold: 3 + readinessProbe: + httpGet: + path: "/" + port: http + scheme: HTTP + initialDelaySeconds: 0 + periodSeconds: 10 + timeoutSeconds: 1 + successThreshold: 1 + failureThreshold: 3 + resources: + {} + volumeMounts: + - name: cluster-inventory-config + mountPath: /etc/cluster-inventory + readOnly: true + volumes: + - name: cluster-inventory-config + configMap: + name: headlamp-cluster-inventory + items: + - key: config.json + path: config.json diff --git a/charts/headlamp/tests/failing_test_cases/cluster-inventory-empty-access-providers.yaml b/charts/headlamp/tests/failing_test_cases/cluster-inventory-empty-access-providers.yaml new file mode 100644 index 00000000000..a5506854d9a --- /dev/null +++ b/charts/headlamp/tests/failing_test_cases/cluster-inventory-empty-access-providers.yaml @@ -0,0 +1,3 @@ +config: + clusterInventory: + enabled: true diff --git a/charts/headlamp/tests/failing_test_cases/cluster-inventory-plugin-invalid-name.yaml b/charts/headlamp/tests/failing_test_cases/cluster-inventory-plugin-invalid-name.yaml new file mode 100644 index 00000000000..6f275595fae --- /dev/null +++ b/charts/headlamp/tests/failing_test_cases/cluster-inventory-plugin-invalid-name.yaml @@ -0,0 +1,14 @@ +config: + clusterInventory: + enabled: true + accessProvidersConfig: + providers: + - name: secretreader + execConfig: + apiVersion: client.authentication.k8s.io/v1 + command: /access-plugins/secretreader/secretreader-plugin + provideClusterInfo: true + plugins: + - name: SecretReader + image: registry.k8s.io/cluster-inventory-api/secretreader:v0.1.1 + mountPath: /access-plugins/secretreader diff --git a/charts/headlamp/tests/failing_test_cases/cluster-inventory-plugin-mount-mismatch.yaml b/charts/headlamp/tests/failing_test_cases/cluster-inventory-plugin-mount-mismatch.yaml new file mode 100644 index 00000000000..b87ebbeb23d --- /dev/null +++ b/charts/headlamp/tests/failing_test_cases/cluster-inventory-plugin-mount-mismatch.yaml @@ -0,0 +1,14 @@ +config: + clusterInventory: + enabled: true + accessProvidersConfig: + providers: + - name: secretreader + execConfig: + apiVersion: client.authentication.k8s.io/v1 + command: /access-plugins/secretreader/secretreader-plugin + provideClusterInfo: true + plugins: + - name: secretreader + image: registry.k8s.io/cluster-inventory-api/secretreader:v0.1.1 + mountPath: /access-plugins/other diff --git a/charts/headlamp/tests/failing_test_cases/cluster-inventory-plugin-relative-mount-path.yaml b/charts/headlamp/tests/failing_test_cases/cluster-inventory-plugin-relative-mount-path.yaml new file mode 100644 index 00000000000..c24c61ab4fa --- /dev/null +++ b/charts/headlamp/tests/failing_test_cases/cluster-inventory-plugin-relative-mount-path.yaml @@ -0,0 +1,14 @@ +config: + clusterInventory: + enabled: true + accessProvidersConfig: + providers: + - name: secretreader + execConfig: + apiVersion: client.authentication.k8s.io/v1 + command: /access-plugins/secretreader/secretreader-plugin + provideClusterInfo: true + plugins: + - name: secretreader + image: registry.k8s.io/cluster-inventory-api/secretreader:v0.1.1 + mountPath: access-plugins/secretreader diff --git a/charts/headlamp/tests/test.sh b/charts/headlamp/tests/test.sh index b92ceb1fc37..fa2480ea85a 100755 --- a/charts/headlamp/tests/test.sh +++ b/charts/headlamp/tests/test.sh @@ -9,6 +9,7 @@ set -euo pipefail # Set up variables CHART_DIR="./charts/headlamp" TEST_CASES_DIR="${CHART_DIR}/tests/test_cases" +FAILING_TEST_CASES_DIR="${CHART_DIR}/tests/failing_test_cases" EXPECTED_TEMPLATES_DIR="${CHART_DIR}/tests/expected_templates" # Print header information @@ -27,6 +28,18 @@ render_templates() { fi } +# Function to verify templates fail to render for a specific values file +render_templates_expect_failure() { + values_file="$1" + if output=$(helm template headlamp ${CHART_DIR} --values ${values_file} 2>&1); then + echo "ERROR: Expected template rendering to fail for ${values_file}, but it succeeded" + echo "${output}" + exit 1 + else + echo "Template failure test PASSED for ${values_file}: $(echo "${output}" | grep -m1 '^Error:' || echo "${output}" | head -n 1)" + fi +} + # Clean up function to handle errors and cleanup cleanup() { # Get exit code @@ -119,4 +132,11 @@ else echo "No test cases found in ${TEST_CASES_DIR}. Skipping template testing." fi +# Check failing test cases +if [ -d "${FAILING_TEST_CASES_DIR}" ] && [ "$(ls -A ${FAILING_TEST_CASES_DIR})" ]; then + for values_file in ${FAILING_TEST_CASES_DIR}/*; do + render_templates_expect_failure "${values_file}" + done +fi + echo "Template testing completed." diff --git a/charts/headlamp/tests/test_cases/cluster-inventory-plugins.yaml b/charts/headlamp/tests/test_cases/cluster-inventory-plugins.yaml new file mode 100644 index 00000000000..968cf96a675 --- /dev/null +++ b/charts/headlamp/tests/test_cases/cluster-inventory-plugins.yaml @@ -0,0 +1,14 @@ +config: + clusterInventory: + enabled: true + accessProvidersConfig: + providers: + - name: secretreader + execConfig: + apiVersion: client.authentication.k8s.io/v1 + command: /access-plugins/secretreader/secretreader-plugin + provideClusterInfo: true + plugins: + - name: secretreader + image: registry.k8s.io/cluster-inventory-api/secretreader:v0.1.1 + mountPath: /access-plugins/secretreader/ diff --git a/charts/headlamp/tests/test_cases/cluster-inventory.yaml b/charts/headlamp/tests/test_cases/cluster-inventory.yaml new file mode 100644 index 00000000000..5aad61196c5 --- /dev/null +++ b/charts/headlamp/tests/test_cases/cluster-inventory.yaml @@ -0,0 +1,8 @@ +config: + clusterInventory: + enabled: true + accessProvidersConfig: + providers: + - name: static-provider + rootReconcileInterval: 10s + noCRDCacheTTL: 30s diff --git a/charts/headlamp/values.schema.json b/charts/headlamp/values.schema.json index 75f82717f22..32572341ffb 100644 --- a/charts/headlamp/values.schema.json +++ b/charts/headlamp/values.schema.json @@ -262,6 +262,64 @@ "type": "string", "description": "Path of private key file for TLS" }, + "clusterInventory": { + "type": "object", + "description": "Experimental/alpha Cluster Inventory configuration", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable experimental/alpha Cluster Inventory discovery", + "default": false + }, + "accessProvidersConfig": { + "type": "object", + "description": "Experimental/alpha Cluster Inventory access providers config", + "default": {} + }, + "plugins": { + "type": "array", + "description": "Kubernetes image volumes that provide experimental/alpha Cluster Inventory access provider binaries", + "items": { + "type": "object", + "required": ["name", "image", "mountPath"], + "properties": { + "name": { + "type": "string", + "maxLength": 63, + "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", + "description": "DNS label name of the plugin image volume" + }, + "image": { + "type": "string", + "description": "Image reference for the plugin image volume" + }, + "mountPath": { + "type": "string", + "pattern": "^/", + "description": "Absolute read-only mount path for the plugin image volume" + } + }, + "additionalProperties": false + }, + "default": [] + }, + "labelSelector": { + "type": "string", + "description": "Kubernetes label selector used to filter experimental/alpha ClusterProfile resources", + "default": "!headlamp.dev/ignore" + }, + "rootReconcileInterval": { + "type": "string", + "description": "Override the experimental/alpha Cluster Inventory root reconcile interval. Empty uses the Headlamp default", + "default": "" + }, + "noCRDCacheTTL": { + "type": "string", + "description": "Override the experimental/alpha Cluster Inventory no-CRD cache TTL. Empty uses the Headlamp default", + "default": "" + } + } + }, "extraArgs": { "type": "array", "description": "Extra arguments to pass to the application", diff --git a/charts/headlamp/values.yaml b/charts/headlamp/values.yaml index 3554de24974..b6e6da39e63 100644 --- a/charts/headlamp/values.yaml +++ b/charts/headlamp/values.yaml @@ -129,6 +129,30 @@ config: podDebugImage: "" # tlsCertPath: "/headlamp-cert/headlamp-ca.crt" # tlsKeyPath: "/headlamp-cert/headlamp-tls.key" + clusterInventory: + # -- Enable experimental/alpha Cluster Inventory discovery. + enabled: false + # -- Experimental/alpha Cluster Inventory access providers config. Required when enabled. + accessProvidersConfig: {} + # accessProvidersConfig: + # providers: + # - name: secretreader + # execConfig: + # apiVersion: client.authentication.k8s.io/v1 + # command: /access-plugins/secretreader/secretreader-plugin + # provideClusterInfo: true + # plugins[] uses the Kubernetes "image" volume type to mount experimental/alpha access provider binaries. + plugins: [] + # plugins: + # - name: secretreader + # image: registry.k8s.io/cluster-inventory-api/secretreader:v0.1.1 + # mountPath: /access-plugins/secretreader + # -- Kubernetes label selector used to filter experimental/alpha ClusterProfile resources. + labelSelector: "!headlamp.dev/ignore" + # -- Override the experimental/alpha Cluster Inventory root reconcile interval. Empty uses the Headlamp default. + rootReconcileInterval: "" + # -- Override the experimental/alpha Cluster Inventory no-CRD cache TTL. Empty uses the Headlamp default. + noCRDCacheTTL: "" # Extra arguments that can be given to the container. See charts/headlamp/README.md for more information. extraArgs: [] diff --git a/docs/development/cluster-inventory.md b/docs/development/cluster-inventory.md new file mode 100644 index 00000000000..9c898782d88 --- /dev/null +++ b/docs/development/cluster-inventory.md @@ -0,0 +1,128 @@ +--- +title: Cluster Inventory development +--- + +# Cluster Inventory development + +:::warning Experimental alpha feature + +Headlamp Cluster Inventory support is alpha/experimental and disabled by +default. The upstream Cluster Inventory API is currently `v1alpha1` and the +Headlamp integration uses the `v0.1.x` API, so fields and behavior may change. + +::: + +Headlamp can discover additional clusters from Cluster Inventory API +`ClusterProfile` resources when started with Cluster Inventory enabled. The +backend uses `sigs.k8s.io/cluster-inventory-api v0.1.0`, the `pkg/access` +provider configuration package, and `ClusterProfile.status.accessProviders`. + +The provider configuration file is not a `ClusterProfile` status object. It uses +the upstream access configuration shape with a top-level `providers` array: + +```json +{ + "providers": [ + { + "name": "static-token-spoke-a", + "execConfig": { + "apiVersion": "client.authentication.k8s.io/v1", + "command": "/tmp/headlamp-ci/static-token-exec.sh", + "provideClusterInfo": true + } + } + ] +} +``` + +Start the backend explicitly while testing: + +```bash +npm run backend:build +KUBECONFIG="$WORK/hub.kubeconfig" \ +HEADLAMP_BACKEND_TOKEN=headlamp \ +HEADLAMP_CONFIG_ENABLE_DYNAMIC_CLUSTERS=true \ +./backend/headlamp-server -dev -listen-addr=localhost \ + --enable-cluster-inventory \ + --cluster-inventory-provider-file "$WORK/provider-config.json" \ + --cluster-inventory-label-selector='!headlamp.dev/ignore' \ + --cluster-inventory-root-reconcile-interval=10s \ + --cluster-inventory-no-crd-cache-ttl=30s +``` + +In another terminal: + +```bash +npm run frontend:start +``` + +Install the `v0.1.0` CRD on clusters that publish inventory: + +```bash +kubectl --context kind-ci-hub apply -f \ + https://raw.githubusercontent.com/kubernetes-sigs/cluster-inventory-api/v0.1.0/config/crd/bases/multicluster.x-k8s.io_clusterprofiles.yaml +``` + +Patch sample status with `status.accessProviders` and health conditions: + +`ClusterProfile.spec.clusterManager.name` is required by the v0.1.0 CRD, even +when the access details are patched later through the status subresource. The +CRD also requires `reason` on each condition, so include it even when adapting +examples that omit the field. + +```bash +kubectl --context kind-ci-hub -n inventory-e2e apply -f - <<'EOF' +apiVersion: multicluster.x-k8s.io/v1alpha1 +kind: ClusterProfile +metadata: + name: spoke-a +spec: + clusterManager: + name: headlamp-local-e2e +EOF + +kubectl --context kind-ci-hub -n inventory-e2e patch clusterprofiles spoke-a \ + --subresource=status --type=merge \ + -p "$(jq -n --arg server "$SPOKE_A_SERVER" --arg ca "$SPOKE_A_CA" '{ + status: { + conditions: [{ + type: "ControlPlaneHealthy", + status: "True", + reason: "HealthCheckSucceeded", + message: "control plane endpoint is ready", + lastTransitionTime: "2026-05-10T00:00:00Z" + }], + accessProviders: [{ + name: "static-token-spoke-a", + cluster: { + server: $server, + "certificate-authority-data": $ca + } + }] + } + }')" +``` + +To hide a `ClusterProfile` from Headlamp, add the ignore label. The default +Helm chart selector is `!headlamp.dev/ignore`, so profiles with that label are +not watched or converted into Headlamp contexts: + +```bash +kubectl --context kind-ci-hub -n inventory-e2e label clusterprofile spoke-a \ + headlamp.dev/ignore=true +``` + +Run the focused web E2E only after the local topology is running: + +```bash +cd e2e-tests +HEADLAMP_CLUSTER_INVENTORY_E2E=true \ +HEADLAMP_TEST_URL=http://localhost:3000 \ +npx playwright test -g "Cluster Inventory" +``` + +Before cleanup, verify that setup artifacts stayed outside tracked paths: + +```bash +git status --short +``` diff --git a/docs/installation/in-cluster/index.md b/docs/installation/in-cluster/index.md index 2ded20f031f..1e26a641322 100644 --- a/docs/installation/in-cluster/index.md +++ b/docs/installation/in-cluster/index.md @@ -29,6 +29,45 @@ helm install my-headlamp headlamp/headlamp --namespace kube-system -f values.yam helm install my-headlamp headlamp/headlamp --namespace kube-system --set replicaCount=2 ``` +### Cluster Inventory + +Headlamp can discover clusters from Cluster Inventory API `ClusterProfile` +resources when Cluster Inventory is enabled. The Helm chart configures the +backend flags from `config.clusterInventory` and mounts an access provider +config file. + +Create `cluster-inventory-values.yaml`: + +```yaml +config: + clusterInventory: + enabled: true + accessProvidersConfig: + providers: + - name: secretreader + execConfig: + apiVersion: client.authentication.k8s.io/v1 + command: /access-plugins/secretreader/secretreader-plugin + provideClusterInfo: true + plugins: + - name: secretreader + image: registry.k8s.io/cluster-inventory-api/secretreader:v0.1.1 + mountPath: /access-plugins/secretreader +``` + +Then install Headlamp with the values file: + +```bash +helm install my-headlamp headlamp/headlamp --namespace kube-system --values cluster-inventory-values.yaml +``` + +The `accessProvidersConfig` object is the provider config consumed by the +Cluster Inventory access provider. The `plugins` entries are Kubernetes `image` +volumes that mount provider binaries into the Headlamp container, not Headlamp +UI plugins. By default, Headlamp watches `ClusterProfile` resources that do not +have the `headlamp.dev/ignore` label because the chart sets +`config.clusterInventory.labelSelector` to `!headlamp.dev/ignore`. + ## Using simple yaml We also maintain a simple/vanilla [file](https://github.com/kubernetes-sigs/headlamp/blob/main/kubernetes-headlamp.yaml) @@ -51,7 +90,7 @@ Headlamp supports optional TLS termination at the backend server. The default is By default, Headlamp uses the default service account from the namespace it is deployed to, and generates a kubeconfig from it named `main`. -If you wish to use another specific non-default kubeconfig file, then you can do it by mounting it to the default location at `/home/headlamp/.config/Headlamp/kubeconfigs/config`, or +If you wish to use another specific non-default kubeconfig file, then you can do it by mounting it to the default location at `/home/headlamp/.config/Headlamp/kubeconfigs/config`, or providing a custom path Headlamp with the ` -kubeconfig` argument or the KUBECONFIG env (through helm values.env) ### Use several kubeconfig files diff --git a/e2e-tests/tests/clusterInventory.spec.ts b/e2e-tests/tests/clusterInventory.spec.ts new file mode 100644 index 00000000000..fab8a134a58 --- /dev/null +++ b/e2e-tests/tests/clusterInventory.spec.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect, test } from '@playwright/test'; + +const baseURL = process.env.HEADLAMP_TEST_URL || 'http://localhost:3000'; +const backendToken = process.env.HEADLAMP_TEST_BACKEND_TOKEN || 'headlamp'; +const shouldRun = process.env.HEADLAMP_CLUSTER_INVENTORY_E2E === 'true'; + +test.describe('Cluster Inventory', () => { + test.skip(!shouldRun, 'Set HEADLAMP_CLUSTER_INVENTORY_E2E=true to run Cluster Inventory E2E'); + + test('discovers clusters and proxies to them', async ({ request }) => { + const configResponse = await request.get('/config', { + baseURL, + headers: { + 'X-HEADLAMP_BACKEND-TOKEN': backendToken, + }, + }); + expect(configResponse.status()).toBe(200); + + const config = await configResponse.json(); + const inventoryClusters = config.clusters.filter( + (cluster: any) => cluster.meta_data?.source === 'cluster_inventory' + ); + expect(inventoryClusters.length).toBeGreaterThanOrEqual(1); + + for (const cluster of inventoryClusters) { + const clusterName = encodeURIComponent(cluster.name); + const response = await request.get(`/clusters/${clusterName}/api/v1/namespaces`, { + baseURL, + headers: { + 'X-HEADLAMP_BACKEND-TOKEN': backendToken, + }, + }); + + expect(response.status(), `expected namespace proxy to work for ${cluster.name}`).toBe(200); + + const body = await response.json(); + expect(body).toHaveProperty('items'); + expect(Array.isArray(body.items)).toBe(true); + } + }); +}); diff --git a/frontend/src/components/App/Home/ClusterInventory/index.ts b/frontend/src/components/App/Home/ClusterInventory/index.ts new file mode 100644 index 00000000000..a28bfeb4e49 --- /dev/null +++ b/frontend/src/components/App/Home/ClusterInventory/index.ts @@ -0,0 +1,129 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ApiError } from '../../../../lib/k8s/api/v2/ApiError'; +import type { Cluster, KubeCondition } from '../../../../lib/k8s/cluster'; +import { getClusterStatus, getClusterStatusLabel } from '../clusterStatus'; + +const CLUSTER_INVENTORY_SOURCE = 'cluster_inventory'; +const CONTROL_PLANE_HEALTHY_CONDITION = 'ControlPlaneHealthy'; + +/** Cluster Inventory condition fields used by the cluster table. */ +export type ClusterInventoryCondition = Pick< + KubeCondition, + 'type' | 'status' | 'reason' | 'message' | 'lastTransitionTime' +>; + +/** Status kinds rendered by the Cluster Inventory-aware status cell. */ +export type ClusterStatusKind = 'active' | 'error' | 'unknown'; + +type Translate = (key: string) => string; + +/** Status information used to render a Cluster Inventory-aware status cell. */ +export interface ClusterStatusInfo { + kind: ClusterStatusKind; + text: string; + condition: ClusterInventoryCondition | null; +} + +/** Icon and palette mapping for Cluster Inventory-aware cluster statuses. */ +export const STATUS_VARIANTS: Record< + ClusterStatusKind, + { icon: string; colorKey: 'success' | 'error' | 'unknown'; coloredText: boolean } +> = { + active: { icon: 'mdi:cloud-check-variant', colorKey: 'success', coloredText: true }, + error: { icon: 'mdi:cloud-off', colorKey: 'error', coloredText: true }, + unknown: { icon: 'mdi:cloud-question', colorKey: 'unknown', coloredText: false }, +}; + +/** Returns true when a cluster was discovered from Cluster Inventory. */ +export function isClusterInventoryCluster(cluster: Cluster): boolean { + return cluster?.meta_data?.source === CLUSTER_INVENTORY_SOURCE; +} + +/** Returns the Cluster Inventory control plane health condition when present. */ +export function getControlPlaneHealthyCondition( + cluster: Cluster +): ClusterInventoryCondition | null { + if (!isClusterInventoryCluster(cluster)) { + return null; + } + + const conditions = cluster?.meta_data?.clusterInventory?.conditions; + if (!Array.isArray(conditions)) { + return null; + } + + return ( + conditions.find( + (condition: ClusterInventoryCondition) => condition.type === CONTROL_PLANE_HEALTHY_CONDITION + ) ?? null + ); +} + +/** Builds tooltip text from a Cluster Inventory health condition. */ +export function getConditionTooltip(condition: ClusterInventoryCondition): string { + return [condition.reason, condition.message, condition.lastTransitionTime] + .filter(Boolean) + .join('\n'); +} + +/** Returns the display status, preferring Cluster Inventory health when it reports failure. */ +export function getClusterStatusInfo( + cluster: Cluster, + error: ApiError | null | undefined, + t: Translate +): ClusterStatusInfo { + const condition = getControlPlaneHealthyCondition(cluster); + + if (condition?.status === 'False') { + return { kind: 'error', text: t('translation|Control plane unhealthy'), condition }; + } + + const status = getClusterStatus(error); + if (status === 'auth-error' || status === 'permission-error' || status === 'unavailable') { + return { kind: 'error', text: getClusterStatusLabel(t, error), condition }; + } + + if (condition?.status === 'Unknown' || status === 'loading') { + return { kind: 'unknown', text: '⋯', condition }; + } + + return { kind: 'active', text: getClusterStatusLabel(t, error), condition }; +} + +/** Returns the sortable status text for a Cluster Inventory-aware cluster status cell. */ +export function getClusterStatusAccessor( + cluster: Cluster, + error: ApiError | null | undefined, + t: Translate +): string | undefined { + const condition = getControlPlaneHealthyCondition(cluster); + if (condition?.status === 'False') { + return t('translation|Control plane unhealthy'); + } + + const status = getClusterStatus(error); + if (status === 'auth-error' || status === 'permission-error' || status === 'unavailable') { + return getClusterStatusLabel(t, error); + } + + if (condition?.status === 'Unknown') { + return t('translation|Unknown'); + } + + return getClusterStatusLabel(t, error); +} diff --git a/frontend/src/components/App/Home/ClusterTable.test.tsx b/frontend/src/components/App/Home/ClusterTable.test.tsx new file mode 100644 index 00000000000..10c30edd44f --- /dev/null +++ b/frontend/src/components/App/Home/ClusterTable.test.tsx @@ -0,0 +1,333 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ThemeProvider } from '@mui/material/styles'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { SnackbarProvider } from 'notistack'; +import { ReactNode } from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Cluster } from '../../../lib/k8s/cluster'; +import { createMuiTheme } from '../../../lib/themes'; +import ClusterContextMenu from './ClusterContextMenu'; +import ClusterTable from './ClusterTable'; + +const theme = createMuiTheme({ name: 'light', base: 'light' }); + +function renderWithTheme(ui: ReactNode) { + return render({ui}); +} + +vi.mock('react-i18next', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => key.split('|').pop() ?? key, + }), + }; +}); + +vi.mock('react-redux', () => ({ + useDispatch: () => vi.fn(), +})); + +vi.mock('../../../helpers', () => ({ + default: { + isElectron: () => true, + }, +})); + +vi.mock('../../../redux/hooks', () => ({ + useTypedSelector: (selector: (state: any) => any) => + selector({ + clusterProvider: { + clusterStatuses: [], + dialogs: [], + menuItems: [], + }, + config: { + allowKubeconfigChanges: true, + isDynamicClusterEnabled: true, + }, + }), +})); + +vi.mock('../../common', () => ({ + Loader: ({ title }: { title: string }) =>
{title}
, +})); + +vi.mock('../../common/Table', () => ({ + default: ({ columns, data }: { columns: any[]; data: Cluster[] }) => { + const originColumn = columns.find(column => column.id === 'origin'); + const statusColumn = columns.find(column => column.id === 'status'); + return ( + + + {data.map(cluster => ( + + + + + ))} + +
{originColumn.Cell({ row: { original: cluster } })}{statusColumn.Cell({ row: { original: cluster } })}
+ ); + }, +})); + +describe('ClusterTable', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('renders Cluster Inventory source labels', () => { + const cluster = { + name: 'spoke-a', + auth_type: '', + meta_data: { + source: 'cluster_inventory', + }, + } as Cluster; + + renderWithTheme( + + + + ); + + expect(screen.getByText('Cluster Inventory')).toBeInTheDocument(); + }); + + it('renders in-cluster source labels', () => { + const cluster = { + name: 'in-cluster', + auth_type: '', + meta_data: { + source: 'incluster', + }, + } as Cluster; + + renderWithTheme( + + + + ); + + expect(screen.getByText('In-cluster')).toBeInTheDocument(); + }); + + it('renders unhealthy Cluster Inventory control plane status', () => { + const cluster = { + name: 'spoke-a', + auth_type: '', + meta_data: { + source: 'cluster_inventory', + clusterInventory: { + conditions: [ + { + type: 'ControlPlaneHealthy', + status: 'False', + reason: 'HealthCheckFailed', + message: 'control plane endpoint is not ready', + lastTransitionTime: '2026-05-10T00:00:00Z', + }, + ], + }, + }, + } as Cluster; + + renderWithTheme( + + + + ); + + expect(screen.getByText('Control plane unhealthy')).toBeInTheDocument(); + }); + + it('keeps Active status for healthy Cluster Inventory clusters', () => { + const cluster = { + name: 'spoke-a', + auth_type: '', + meta_data: { + source: 'cluster_inventory', + clusterInventory: { + conditions: [ + { + type: 'ControlPlaneHealthy', + status: 'True', + }, + ], + }, + }, + } as Cluster; + + renderWithTheme( + + + + ); + + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + + it('falls back to reachability status when Cluster Inventory condition is missing', () => { + const cluster = { + name: 'spoke-a', + auth_type: '', + meta_data: { + source: 'cluster_inventory', + clusterInventory: { + conditions: [], + }, + }, + } as Cluster; + + renderWithTheme( + + + + ); + + expect(screen.getByText('Unavailable')).toBeInTheDocument(); + }); + + it('keeps status accessor aligned with reachability errors for unknown control plane health', () => { + const cluster = { + name: 'spoke-a', + auth_type: '', + meta_data: { + source: 'cluster_inventory', + clusterInventory: { + conditions: [ + { + type: 'ControlPlaneHealthy', + status: 'Unknown', + }, + ], + }, + }, + } as Cluster; + + renderWithTheme( + + + + ); + + expect(screen.getByText('Unavailable')).toBeInTheDocument(); + expect(screen.getByTestId('cluster-row-spoke-a')).toHaveAttribute( + 'data-status-accessor', + 'Unavailable' + ); + }); + + it('renders permission errors in the status cell and accessor', () => { + const cluster = { + name: 'spoke-a', + auth_type: '', + meta_data: { + source: 'kubeconfig', + }, + } as Cluster; + + renderWithTheme( + + + + ); + + expect(screen.getByText('Insufficient permissions')).toBeInTheDocument(); + expect(screen.getByTestId('cluster-row-spoke-a')).toHaveAttribute( + 'data-status-accessor', + 'Insufficient permissions' + ); + }); +}); + +describe('ClusterContextMenu', () => { + it('does not show delete actions for Cluster Inventory clusters', () => { + render( + + + + + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Actions' })); + + expect(screen.getByText('View')).toBeInTheDocument(); + expect(screen.queryByText('Delete')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/App/Home/ClusterTable.tsx b/frontend/src/components/App/Home/ClusterTable.tsx index a27f9c5f15d..654373b2b05 100644 --- a/frontend/src/components/App/Home/ClusterTable.tsx +++ b/frontend/src/components/App/Home/ClusterTable.tsx @@ -41,17 +41,25 @@ import { useTypedSelector } from '../../../redux/hooks'; import { Loader } from '../../common'; import Link from '../../common/Link'; import Table from '../../common/Table'; +import { LightTooltip } from '../../common/Tooltip'; import { useLocalStorageState } from '../../globalSearch/useLocalStorageState'; import ClusterBadge from '../../Sidebar/ClusterBadge'; import ClusterContextMenu from './ClusterContextMenu'; -import { canSelectCluster, getClusterStatus, getClusterStatusLabel } from './clusterStatus'; +import { + getClusterStatusAccessor, + getClusterStatusInfo, + getConditionTooltip, + isClusterInventoryCluster, + STATUS_VARIANTS, +} from './ClusterInventory'; +import { canSelectCluster } from './clusterStatus'; import { MULTI_HOME_ENABLED } from './config'; import { getCustomClusterNames } from './customClusterNames'; /** * ClusterStatus component displays the status of a cluster. * It shows an icon and a message indicating whether the cluster is active, loading, unavailable, - * requires authentication, or has insufficient permissions. + * requires authentication, has insufficient permissions, or has an unhealthy control plane. * * @param {Object} props - The component props. * @param {ApiError|null} [props.error] - The error object if there is an error with the cluster. @@ -69,48 +77,37 @@ function ClusterStatus({ error, cluster }: { error?: ApiError | null; cluster: C } return null; }, [customStatuses, cluster, error]); - const status = getClusterStatus(error); if (renderedCustomStatus !== null) { return renderedCustomStatus; } - const isLoading = status === 'loading'; - const isActive = status === 'active'; - const hasError = - status === 'auth-error' || status === 'permission-error' || status === 'unavailable'; - const statusText = getClusterStatusLabel(t, error); - - return ( - - - {hasError ? ( - - ) : isLoading ? ( - - ) : ( - - )} - - {statusText} - - + const { kind, text, condition } = getClusterStatusInfo(cluster, error, t); + const variant = STATUS_VARIANTS[kind]; + const color = theme.palette.home.status[variant.colorKey]; + const tooltip = condition ? getConditionTooltip(condition) : ''; + const statusContent = ( + + + + {text} + ); + + return tooltip ? ( + {tooltip}}> + {statusContent} + + ) : ( + statusContent + ); } export interface ClusterTableProps { @@ -201,10 +198,12 @@ export default function ClusterTable({ return sourcePath ? `Kubeconfig: ${sourcePath}` : 'Kubeconfig'; } else if (cluster?.meta_data?.source === 'dynamic_cluster') { return t('translation|Plugin'); - } else if (cluster?.meta_data?.source === 'in_cluster') { + } else if (cluster?.meta_data?.source === 'incluster') { return t('translation|In-cluster'); + } else if (isClusterInventoryCluster(cluster)) { + return t('translation|Cluster Inventory'); } - return 'Unknown'; + return t('translation|Unknown'); } const viewClusters = t('View Clusters'); @@ -286,7 +285,7 @@ export default function ClusterTable({ { id: 'status', header: t('Status'), - accessorFn: cluster => getClusterStatusLabel(t, errors[cluster?.name]), + accessorFn: cluster => getClusterStatusAccessor(cluster, errors[cluster?.name], t), Cell: ({ row: { original } }) => ( ), @@ -308,7 +307,7 @@ export default function ClusterTable({ muiTableBodyCellProps: { align: 'right', }, - accessorFn: cluster => getClusterStatusLabel(t, errors[cluster?.name]), + accessorFn: cluster => getClusterStatusAccessor(cluster, errors[cluster?.name], t), Cell: ({ row: { original: cluster } }) => { return ; }, diff --git a/frontend/src/components/App/Home/__snapshots__/index.Base.stories.storyshot b/frontend/src/components/App/Home/__snapshots__/index.Base.stories.storyshot index fd095d1fb69..4e71738dee1 100644 --- a/frontend/src/components/App/Home/__snapshots__/index.Base.stories.storyshot +++ b/frontend/src/components/App/Home/__snapshots__/index.Base.stories.storyshot @@ -653,18 +653,14 @@ class="MuiTableCell-root MuiTableCell-alignLeft MuiTableCell-sizeMedium css-1n4xkg0-MuiTableCell-root" >
-
-

- Active -

-
+ Active +

-
-

- Active -

-
+ Active +

-
-

- Active -

-
+ Active +