@@ -31,6 +31,7 @@ import (
3131 "net/url"
3232 "os"
3333 "path/filepath"
34+ "runtime"
3435 "strconv"
3536 "strings"
3637 "testing"
@@ -1610,6 +1611,246 @@ func TestCacheMiddleware_CacheInvalidation(t *testing.T) {
16101611 assert .Equal (t , http .StatusOK , resp1 .StatusCode )
16111612}
16121613
1614+ // newRealK8sHeadlampConfig creates a HeadlampConfig for integration tests
1615+ // that use a real Kubernetes cluster (e.g. minikube in CI).
1616+ // Uses a temp config dir so Headlamp's dynamic clusters file does not overwrite
1617+ // the main kubeconfig with stale entries.
1618+ //
1619+ //nolint:funlen
1620+ func newRealK8sHeadlampConfig (t * testing.T ) (* HeadlampConfig , string ) {
1621+ t .Helper ()
1622+
1623+ kubeConfigPath := os .Getenv ("KUBECONFIG" )
1624+ if kubeConfigPath == "" {
1625+ kubeConfigPath = config .GetDefaultKubeConfigPath ()
1626+ }
1627+
1628+ // KUBECONFIG may be a list of files separated by os.PathListSeparator.
1629+ paths := strings .Split (kubeConfigPath , string (os .PathListSeparator ))
1630+ kubeconfigExists := false
1631+
1632+ for _ , p := range paths {
1633+ if p == "" {
1634+ continue
1635+ }
1636+
1637+ if _ , err := os .Stat (p ); err == nil {
1638+ kubeconfigExists = true
1639+ break
1640+ } else if ! os .IsNotExist (err ) {
1641+ // For errors other than non-existence, let the loaders handle them;
1642+ // treat this as "exists" so we don't incorrectly skip.
1643+ kubeconfigExists = true
1644+ break
1645+ }
1646+ }
1647+
1648+ if ! kubeconfigExists {
1649+ t .Skipf ("kubeconfig not found at %s, skipping real K8s integration test" , kubeConfigPath )
1650+ }
1651+
1652+ tempDir , err := os .MkdirTemp ("" , "headlamp-integration-test" )
1653+ require .NoError (t , err )
1654+ t .Cleanup (func () { _ = os .RemoveAll (tempDir ) })
1655+
1656+ pluginDir := filepath .Join (tempDir , "plugins" )
1657+ userPluginDir := filepath .Join (tempDir , "user-plugins" )
1658+
1659+ require .NoError (t , os .MkdirAll (pluginDir , 0o755 ))
1660+ require .NoError (t , os .MkdirAll (userPluginDir , 0o755 ))
1661+
1662+ // Use temp dir as config home so Headlamp's dynamic clusters file
1663+ // (which can have stale minikube entries) does not overwrite the main kubeconfig.
1664+ tempConfigHome := filepath .Join (tempDir , "config-home" )
1665+ if runtime .GOOS == "darwin" {
1666+ require .NoError (t , os .MkdirAll (
1667+ filepath .Join (tempConfigHome , "Library" , "Application Support" , "Headlamp" , "kubeconfigs" ),
1668+ 0o755 ,
1669+ ))
1670+ t .Cleanup (setEnvForTest (t , "HOME" , tempConfigHome ))
1671+ } else {
1672+ require .NoError (t , os .MkdirAll (filepath .Join (tempConfigHome , "Headlamp" , "kubeconfigs" ), 0o755 ))
1673+ t .Cleanup (setEnvForTest (t , "XDG_CONFIG_HOME" , tempConfigHome ))
1674+ }
1675+
1676+ kubeConfigStore := kubeconfig .NewContextStore ()
1677+ err = kubeconfig .LoadAndStoreKubeConfigs (kubeConfigStore , kubeConfigPath , kubeconfig .KubeConfig , nil )
1678+ require .NoError (t , err , "failed to load kubeconfig" )
1679+
1680+ cfg , err := clientcmd .LoadFromFile (kubeConfigPath )
1681+ require .NoError (t , err , "failed to load kubeconfig for current context" )
1682+
1683+ clusterName := cfg .CurrentContext
1684+
1685+ if clusterName == "" {
1686+ clusters := (& HeadlampConfig {
1687+ HeadlampConfig : & headlampconfig.HeadlampConfig {
1688+ HeadlampCFG : & headlampconfig.HeadlampCFG {KubeConfigStore : kubeConfigStore },
1689+ },
1690+ }).getClusters ()
1691+ for _ , c := range clusters {
1692+ if c .Error == "" {
1693+ clusterName = c .Name
1694+ break
1695+ }
1696+ }
1697+ }
1698+
1699+ if clusterName == "" {
1700+ t .Skip ("no current or valid cluster in kubeconfig, skipping real K8s integration test" )
1701+ }
1702+
1703+ c := & HeadlampConfig {
1704+ HeadlampConfig : & headlampconfig.HeadlampConfig {
1705+ HeadlampCFG : & headlampconfig.HeadlampCFG {
1706+ UseInCluster : false ,
1707+ KubeConfigPath : kubeConfigPath ,
1708+ KubeConfigStore : kubeConfigStore ,
1709+ CacheEnabled : true ,
1710+ PluginDir : pluginDir ,
1711+ UserPluginDir : userPluginDir ,
1712+ },
1713+ Cache : cache .New [interface {}](),
1714+ TelemetryConfig : GetDefaultTestTelemetryConfig (),
1715+ TelemetryHandler : & telemetry.RequestHandler {},
1716+ },
1717+ }
1718+
1719+ return c , clusterName
1720+ }
1721+
1722+ // setEnvForTest sets an env var for the test and returns a cleanup that restores it.
1723+ func setEnvForTest (t * testing.T , key , value string ) func () {
1724+ t .Helper ()
1725+
1726+ old , had := os .LookupEnv (key )
1727+ require .NoError (t , os .Setenv (key , value ))
1728+
1729+ return func () {
1730+ if had {
1731+ _ = os .Setenv (key , old )
1732+ } else {
1733+ _ = os .Unsetenv (key )
1734+ }
1735+ }
1736+ }
1737+
1738+ // TestCacheMiddleware_CacheHitAndCacheMiss_RealK8s tests cache hit/miss with a
1739+ // real Kubernetes API server (e.g. minikube). Requires HEADLAMP_RUN_INTEGRATION_TESTS=true
1740+ // and a running cluster.
1741+ func TestCacheMiddleware_CacheHitAndCacheMiss_RealK8s (t * testing.T ) {
1742+ if os .Getenv ("HEADLAMP_RUN_INTEGRATION_TESTS" ) != strconv .FormatBool (istrue ) {
1743+ t .Skip ("skipping integration test" )
1744+ }
1745+
1746+ c , clusterName := newRealK8sHeadlampConfig (t )
1747+ handler := createHeadlampHandler (c )
1748+ ts := httptest .NewServer (handler )
1749+ t .Cleanup (ts .Close )
1750+
1751+ apiPath := "/clusters/" + clusterName + "/api/v1/namespaces/default/pods"
1752+ ctx := context .Background ()
1753+
1754+ resp1 , err := httpRequestWithContext (ctx , ts .URL + apiPath , "GET" )
1755+ require .NoError (t , err )
1756+ defer resp1 .Body .Close ()
1757+
1758+ require .Equal (t , http .StatusOK , resp1 .StatusCode , "first GET should succeed" )
1759+ firstFromCache := resp1 .Header .Get ("X-HEADLAMP-CACHE" )
1760+
1761+ resp2 , err := httpRequestWithContext (ctx , ts .URL + apiPath , "GET" )
1762+ require .NoError (t , err )
1763+ defer resp2 .Body .Close ()
1764+
1765+ require .Equal (t , http .StatusOK , resp2 .StatusCode , "second GET should succeed" )
1766+ secondFromCache := resp2 .Header .Get ("X-HEADLAMP-CACHE" )
1767+
1768+ assert .NotEqual (t , "true" , firstFromCache , "first request should not be from cache" )
1769+ assert .Equal (t , "true" , secondFromCache , "second request should be from cache" )
1770+ }
1771+
1772+ // TestCacheMiddleware_CacheInvalidation_RealK8s tests cache invalidation with a
1773+ // real Kubernetes API server. Creates a ConfigMap, invalidates via DELETE, then
1774+ // verifies the next GET fetches fresh data. Requires HEADLAMP_RUN_INTEGRATION_TESTS=true
1775+ // and a running cluster.
1776+ //
1777+ //nolint:funlen // Integration test requires setup, requests, and assertions in one function
1778+ func TestCacheMiddleware_CacheInvalidation_RealK8s (t * testing.T ) {
1779+ if os .Getenv ("HEADLAMP_RUN_INTEGRATION_TESTS" ) != strconv .FormatBool (istrue ) {
1780+ t .Skip ("skipping integration test" )
1781+ }
1782+
1783+ c , clusterName := newRealK8sHeadlampConfig (t )
1784+ handler := createHeadlampHandler (c )
1785+ ts := httptest .NewServer (handler )
1786+ t .Cleanup (ts .Close )
1787+
1788+ cmName := "headlamp-cache-test-" + strconv .FormatInt (time .Now ().UnixNano (), 10 )
1789+ cmPath := "/clusters/" + clusterName + "/api/v1/namespaces/default/configmaps/" + cmName
1790+ listPath := "/clusters/" + clusterName + "/api/v1/namespaces/default/configmaps"
1791+ ctx := context .Background ()
1792+
1793+ cmBody := []byte (fmt .Sprintf (
1794+ `{"kind":"ConfigMap","apiVersion":"v1","metadata":{"name":"%s"},"data":{"test":"value"}}` ,
1795+ cmName ,
1796+ ))
1797+
1798+ createReq , err := http .NewRequestWithContext (ctx , "POST" , ts .URL + listPath , bytes .NewReader (cmBody ))
1799+ require .NoError (t , err )
1800+ createReq .Header .Set ("Content-Type" , "application/json" )
1801+
1802+ createResp , err := http .DefaultClient .Do (createReq )
1803+ require .NoError (t , err )
1804+ createResp .Body .Close ()
1805+ require .Equal (t , http .StatusCreated , createResp .StatusCode , "creating ConfigMap should succeed" )
1806+
1807+ t .Cleanup (func () {
1808+ delReq , _ := http .NewRequestWithContext (context .Background (), "DELETE" , ts .URL + cmPath , nil )
1809+ resp , _ := http .DefaultClient .Do (delReq )
1810+
1811+ if resp != nil {
1812+ resp .Body .Close ()
1813+ }
1814+ })
1815+
1816+ resp1 , err := httpRequestWithContext (ctx , ts .URL + cmPath , "GET" )
1817+ require .NoError (t , err )
1818+
1819+ defer resp1 .Body .Close ()
1820+ require .Equal (t , http .StatusOK , resp1 .StatusCode )
1821+
1822+ delResp , err := httpRequestWithContext (ctx , ts .URL + cmPath , "DELETE" )
1823+ require .NoError (t , err )
1824+ delResp .Body .Close ()
1825+ require .Contains (t , []int {http .StatusOK , http .StatusAccepted }, delResp .StatusCode , "DELETE should succeed" )
1826+
1827+ // If DELETE returned 202 Accepted (asynchronous), poll until resource is deleted.
1828+ // If it returned 200 OK (synchronous), the resource should be immediately unavailable.
1829+ if delResp .StatusCode == http .StatusAccepted {
1830+ // Poll with timeout for asynchronous deletion
1831+ deadline := time .Now ().Add (10 * time .Second )
1832+ for time .Now ().Before (deadline ) {
1833+ resp , err := httpRequestWithContext (ctx , ts .URL + cmPath , "GET" )
1834+ if err == nil {
1835+ resp .Body .Close ()
1836+
1837+ if resp .StatusCode == http .StatusNotFound {
1838+ break
1839+ }
1840+ }
1841+
1842+ time .Sleep (500 * time .Millisecond )
1843+ }
1844+ }
1845+
1846+ resp2 , err := httpRequestWithContext (ctx , ts .URL + cmPath , "GET" )
1847+ require .NoError (t , err )
1848+ defer resp2 .Body .Close ()
1849+
1850+ require .Equal (t , http .StatusNotFound , resp2 .StatusCode ,
1851+ "GET after DELETE should return 404 (cache invalidated)" )
1852+ }
1853+
16131854//nolint:funlen
16141855func TestHandleClusterServiceProxy (t * testing.T ) {
16151856 cfg := & HeadlampConfig {
0 commit comments