Skip to content

Commit fb0ec26

Browse files
authored
Merge branch 'main' into feature/event-severity-config-4566
2 parents 5c039e2 + d7d68c3 commit fb0ec26

File tree

5 files changed

+315
-52
lines changed

5 files changed

+315
-52
lines changed

.github/workflows/app-artifacts-mac.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ jobs:
9999
uses: azure/login@6c251865b4e6290e7b78be643ea2d005bc51f69a # v2.1.1
100100
with:
101101
client-id: ${{ secrets.WINDOWS_CLIENT_ID }}
102-
tenant-id: ${{ secrets. AZ_TENANT_ID }}
102+
tenant-id: ${{ secrets.AZ_TENANT_ID }}
103103
subscription-id: ${{ secrets.AZ_SUBSCRIPTION_ID }}
104104
- name: Fetch certificates
105105
if: ${{ inputs.signBinaries }}

.github/workflows/pr-to-update-minikube.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,13 @@ jobs:
4141
run: |
4242
gh repo sync headlamp-k8s/minikube
4343
env:
44-
GITHUB_TOKEN: ${{ secrets. KINVOLK_REPOS_TOKEN }}
44+
GITHUB_TOKEN: ${{ secrets.KINVOLK_REPOS_TOKEN }}
4545
- name: Check out minikube repo
4646
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
4747
with:
4848
repository: headlamp-k8s/minikube
4949
path: minikube
50-
token: ${{ secrets. KINVOLK_REPOS_TOKEN }}
50+
token: ${{ secrets.KINVOLK_REPOS_TOKEN }}
5151
fetch-depth: 0
5252
- name: Update headlamp version in minikube
5353
run: |
@@ -90,4 +90,4 @@ jobs:
9090
cc: @$user" \
9191
env:
9292
LATEST_HEADLAMP_TAG: ${{ env.LATEST_HEADLAMP_TAG }}
93-
GITHUB_TOKEN: ${{ secrets. KINVOLK_REPOS_TOKEN }}
93+
GITHUB_TOKEN: ${{ secrets.KINVOLK_REPOS_TOKEN }}

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{

0 commit comments

Comments
 (0)