Skip to content

Commit bb47768

Browse files
authored
Merge pull request #4557 from krrish-sehgal/backend-cache-integration-tests
Backend cache integration tests
2 parents 10316ab + 2f7f5d4 commit bb47768

File tree

3 files changed

+311
-48
lines changed

3 files changed

+311
-48
lines changed

backend/cmd/headlamp_test.go

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
16141855
func TestHandleClusterServiceProxy(t *testing.T) {
16151856
cfg := &HeadlampConfig{

backend/cmd/server.go

Lines changed: 65 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -183,67 +183,85 @@ func GetContextKeyAndKContext(w http.ResponseWriter,
183183
return ctx, span, contextKey, kContext, nil
184184
}
185185

186-
// CacheMiddleWare is Middleware for Caching purpose. It involves generating key for a request,
187-
// authorizing user , store resource data in cache and returns data if key is present.
188-
func CacheMiddleWare(c *HeadlampConfig) mux.MiddlewareFunc {
189-
return func(next http.Handler) http.Handler {
190-
if !c.CacheEnabled {
191-
return next
186+
// handleCacheRequest processes a request with caching logic.
187+
func handleCacheRequest(c *HeadlampConfig, next http.Handler, w http.ResponseWriter, r *http.Request) {
188+
if k8cache.SkipWebSocket(r, next, w) {
189+
return
190+
}
191+
192+
ctx, span, contextKey, kContext, err := GetContextKeyAndKContext(w, r, c)
193+
if err != nil {
194+
return
195+
}
196+
197+
if err := k8cache.HandleNonGETCacheInvalidation(k8sResponseCache, w, r, next, contextKey); err != nil {
198+
// ErrHandled is a sentinel error indicating the request was fully
199+
// processed during cache invalidation. For non-GET requests
200+
// (POST/PUT/DELETE), HandleNonGETCacheInvalidation invalidates the
201+
// cache, makes a fresh request to K8s, stores the response, and
202+
// writes the response to the client. When ErrHandled is returned,
203+
// the request has already been handled and we must return early to
204+
// avoid processing the request again or writing duplicate responses.
205+
if errors.Is(err, k8cache.ErrHandled) {
206+
return
192207
}
193208

194-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
195-
if k8cache.SkipWebSocket(r, next, w) {
196-
return
197-
}
209+
c.handleError(w, ctx, span, err, "error while invalidating keys", http.StatusInternalServerError)
198210

199-
ctx, span, contextKey, kContext, err := GetContextKeyAndKContext(w, r, c)
200-
if err != nil {
201-
return
202-
}
211+
return
212+
}
203213

204-
if err := k8cache.HandleNonGETCacheInvalidation(k8sResponseCache, w, r, next, contextKey); err != nil {
205-
c.handleError(w, ctx, span, err, "error while invalidating keys", http.StatusInternalServerError)
206-
return
207-
}
214+
rcw := k8cache.NewResponseCapture(w)
215+
216+
key, err := k8cache.GenerateKey(r.URL, contextKey)
217+
if err != nil {
218+
c.handleError(w, ctx, span, err, "failed to generate key ", http.StatusBadRequest)
219+
return
220+
}
208221

209-
rcw := k8cache.NewResponseCapture(w)
222+
isAllowed, authErr := k8cache.IsAllowed(kContext, r)
223+
if authErr != nil {
224+
k8cache.ServeFromCacheOrForwardToK8s(k8sResponseCache, isAllowed, next, key, w, r, rcw)
210225

211-
key, err := k8cache.GenerateKey(r.URL, contextKey)
212-
if err != nil {
213-
c.handleError(w, ctx, span, err, "failed to generate key ", http.StatusBadRequest)
214-
return
215-
}
226+
return
227+
} else if !isAllowed && k8cache.IsAuthBypassURL(r.URL.Path) {
228+
_ = k8cache.ReturnAuthErrorResponse(w, r, contextKey)
216229

217-
isAllowed, authErr := k8cache.IsAllowed(kContext, r)
218-
if authErr != nil {
219-
k8cache.ServeFromCacheOrForwardToK8s(k8sResponseCache, isAllowed, next, key, w, r, rcw)
230+
return
231+
}
220232

221-
return
222-
} else if !isAllowed && k8cache.IsAuthBypassURL(r.URL.Path) {
223-
_ = k8cache.ReturnAuthErrorResponse(w, r, contextKey)
233+
served, err := k8cache.LoadFromCache(k8sResponseCache, isAllowed, key, w, r)
234+
if err != nil {
235+
c.handleError(w, ctx, span, errors.New(kContext.Error), "failed to load from cache", http.StatusServiceUnavailable)
236+
return
237+
}
224238

225-
return
226-
}
239+
if served {
240+
c.TelemetryHandler.RecordEvent(span, "Served from cache")
241+
return
242+
}
227243

228-
served, err := k8cache.LoadFromCache(k8sResponseCache, isAllowed, key, w, r)
229-
if err != nil {
230-
c.handleError(w, ctx, span, errors.New(kContext.Error), "failed to load from cache", http.StatusServiceUnavailable)
231-
}
244+
k8cache.CheckForChanges(k8sResponseCache, contextKey, *kContext)
232245

233-
if served {
234-
c.TelemetryHandler.RecordEvent(span, "Served from cache")
235-
return
236-
}
246+
next.ServeHTTP(rcw, r)
237247

238-
k8cache.CheckForChanges(k8sResponseCache, contextKey, *kContext)
248+
err = k8cache.StoreK8sResponseInCache(k8sResponseCache, r.URL, rcw, r, key)
249+
if err != nil {
250+
c.handleError(w, ctx, span, errors.New(kContext.Error), "error while storing into cache", http.StatusBadRequest)
251+
return
252+
}
253+
}
239254

240-
next.ServeHTTP(rcw, r)
255+
// CacheMiddleWare is Middleware for Caching purpose. It involves generating key for a request,
256+
// authorizing user , store resource data in cache and returns data if key is present.
257+
func CacheMiddleWare(c *HeadlampConfig) mux.MiddlewareFunc {
258+
return func(next http.Handler) http.Handler {
259+
if !c.CacheEnabled {
260+
return next
261+
}
241262

242-
err = k8cache.StoreK8sResponseInCache(k8sResponseCache, r.URL, rcw, r, key)
243-
if err != nil {
244-
c.handleError(w, ctx, span, errors.New(kContext.Error), "error while storing into cache", http.StatusBadRequest)
245-
return
246-
}
263+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
264+
handleCacheRequest(c, next, w, r)
247265
})
248266
}
249267
}

0 commit comments

Comments
 (0)