Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 102 additions & 42 deletions backend/cmd/headlamp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"

Expand Down Expand Up @@ -418,6 +419,65 @@ 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 addInClusterContext(config *HeadlampConfig) {
headlampContext, err := kubeconfig.GetInClusterContext(
config.InClusterContextName,
config.OidcIdpIssuerURL,
config.OidcClientID, config.OidcClientSecret,
strings.Join(config.OidcScopes, ","),
config.OidcSkipTLSVerify,
config.OidcCACert)
if err != nil {
Comment thread
kahirokunn marked this conversation as resolved.
logger.Log(logger.LevelError, nil, err, "Failed to get in-cluster context")

return
}

headlampContext.Source = kubeconfig.InCluster

if err := headlampContext.SetupProxy(); err != nil {
logger.Log(logger.LevelError, nil, err, "Failed to setup proxy for in-cluster context")
}

if err := config.KubeConfigStore.AddContext(headlampContext); err != nil {
logger.Log(logger.LevelError, nil, err, "Failed to add in-cluster context")
}
}

//nolint:gocognit,funlen,gocyclo
func createHeadlampHandler(ctx context.Context, config *HeadlampConfig) http.Handler {
kubeConfigPath := config.KubeConfigPath
Expand Down Expand Up @@ -478,28 +538,7 @@ func createHeadlampHandler(ctx context.Context, config *HeadlampConfig) http.Han

// In-cluster
if config.UseInCluster {
context, err := kubeconfig.GetInClusterContext(
config.InClusterContextName,
config.OidcIdpIssuerURL,
config.OidcClientID, config.OidcClientSecret,
strings.Join(config.OidcScopes, ","),
config.OidcSkipTLSVerify,
config.OidcCACert)
if err != nil {
logger.Log(logger.LevelError, nil, err, "Failed to get in-cluster context")
}

context.Source = kubeconfig.InCluster

err = context.SetupProxy()
if err != nil {
logger.Log(logger.LevelError, nil, err, "Failed to setup proxy for in-cluster context")
}

err = config.KubeConfigStore.AddContext(context)
if err != nil {
logger.Log(logger.LevelError, nil, err, "Failed to add in-cluster context")
}
addInClusterContext(config)
}

if config.StaticDir != "" {
Expand Down Expand Up @@ -1259,6 +1298,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)
Expand Down Expand Up @@ -1292,14 +1350,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, "]"), "[")
Expand Down Expand Up @@ -1894,20 +1948,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,
})
}

Expand Down
81 changes: 81 additions & 0 deletions backend/cmd/headlamp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,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"
Expand Down Expand Up @@ -362,6 +363,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 {
Comment thread
kahirokunn marked this conversation as resolved.
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()
Expand Down
23 changes: 15 additions & 8 deletions backend/cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,19 +94,26 @@ 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,
BaseURL: conf.BaseURL,
ProxyURLs: 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,

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,
}
}

Expand Down
Loading
Loading