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
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,46 @@ describe('gardener-dashboard', function () {
expect(dashboardContainer.env).toMatchSnapshot()
})

it('should render the kube client request timeout environment variable', async function () {
const values = {
global: {
dashboard: {
kubeClient: {
requestTimeout: 30000,
},
},
},
}
const documents = await renderTemplates(templates, values)
expect(documents).toHaveLength(1)
const [deployment] = documents
const dashboardContainer = deployment.spec.template.spec.containers[0]
expect(dashboardContainer.env).toEqual(expect.arrayContaining([{
name: 'KUBE_CLIENT_REQUEST_TIMEOUT',
value: '30000',
}]))
})

it('should render kube client request timeout 0', async function () {
const values = {
global: {
dashboard: {
kubeClient: {
requestTimeout: 0,
},
},
},
}
const documents = await renderTemplates(templates, values)
expect(documents).toHaveLength(1)
const [deployment] = documents
const dashboardContainer = deployment.spec.template.spec.containers[0]
expect(dashboardContainer.env).toEqual(expect.arrayContaining([{
name: 'KUBE_CLIENT_REQUEST_TIMEOUT',
value: '0',
}]))
})

it('should render the template with a sha256 tag', async function () {
const tag = 'sha256:4d529c1'
const values = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,10 @@ spec:
- name: KUBECONFIG
value: {{ required ".Values.global.dashboard.projectedKubeconfig.baseMountPath is required" .Values.global.dashboard.projectedKubeconfig.baseMountPath }}/kubeconfig
{{- end }}
{{- if ne .Values.global.dashboard.kubeClient.requestTimeout nil }}
- name: KUBE_CLIENT_REQUEST_TIMEOUT
value: {{ quote .Values.global.dashboard.kubeClient.requestTimeout }}
{{- end }}
- name: METRICS_PORT
value: {{ quote .Values.global.dashboard.metricsContainerPort }}
- name: POD_NAME
Expand Down
5 changes: 5 additions & 0 deletions charts/gardener-dashboard/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ global:
expirationSeconds: 43200 # 12 hours
audience: ''

kubeClient:
# Optional total request timeout in milliseconds for Kubernetes API requests.
# Defaults to 60000 when unset. Set to 0 to disable.
requestTimeout: ~

# If configured, the dashboard deployment uses a projected volume which presents the kubeconfig to the garden cluster.
# projectedKubeconfig:
# # Path the projected volume is mounted to. This is typically also the base path in the generic kubeconfig to refer to the token file.
Expand Down
11 changes: 11 additions & 0 deletions packages/kube-client/__tests__/cache.list-watcher.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ describe('kube-client', () => {
}])
})

it('#list with signal', () => {
const signal = {}
listWatcher.setAbortSignal(signal)
expect(listWatcher.list({ b: 2 })).toBe(body)
expect(listFunc).toHaveBeenCalledTimes(1)
expect(listFunc.mock.calls[0]).toEqual([{
signal,
searchParams: new URLSearchParams({ a: 1, b: 2 }),
}])
})

it('#watch', () => {
const signal = {}
listWatcher.setAbortSignal(signal)
Expand Down
139 changes: 139 additions & 0 deletions packages/kube-client/__tests__/index.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
//
// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors
//
// SPDX-License-Identifier: Apache-2.0
//

import { vi } from 'vitest'

const ENV_NAME = 'KUBE_CLIENT_REQUEST_TIMEOUT'
const originalRequestTimeout = process.env[ENV_NAME]

async function importKubeClientWithEnv (value) {
vi.resetModules()
if (value === undefined) {
delete process.env[ENV_NAME]
} else {
process.env[ENV_NAME] = value
}

const { default: kubeClient } = await import('../lib/index.js')
const { default: request } = await import('@gardener-dashboard/request')
return { kubeClient, request }
}

function requestTimeoutsFromExtendCalls (request) {
return request.extend.mock.calls.map(([clientConfig]) => clientConfig.requestTimeout)
}

function expectAllClientsToUseRequestTimeout (request, requestTimeout) {
expect(request.extend).toHaveBeenCalled()
expect(requestTimeoutsFromExtendCalls(request)).toEqual(
expect.arrayContaining([requestTimeout]),
)
expect(new Set(requestTimeoutsFromExtendCalls(request))).toEqual(new Set([requestTimeout]))
}

describe('kube-client package defaults', () => {
afterEach(() => {
if (originalRequestTimeout === undefined) {
delete process.env[ENV_NAME]
} else {
process.env[ENV_NAME] = originalRequestTimeout
}
vi.resetModules()
vi.clearAllMocks()
})

it('should apply KUBE_CLIENT_REQUEST_TIMEOUT to the package-level dashboard client', async () => {
expect.hasAssertions()
const { request } = await importKubeClientWithEnv('1234')

expectAllClientsToUseRequestTimeout(request, 1234)
})

it('should apply KUBE_CLIENT_REQUEST_TIMEOUT to user clients', async () => {
expect.hasAssertions()
const { kubeClient, request } = await importKubeClientWithEnv('2345')

request.extend.mockClear()
kubeClient.createClient({ auth: { bearer: 'bearer' } })

expectAllClientsToUseRequestTimeout(request, 2345)
})

it('should apply KUBE_CLIENT_REQUEST_TIMEOUT to dashboard clients', async () => {
expect.hasAssertions()
const { kubeClient, request } = await importKubeClientWithEnv('3456')

request.extend.mockClear()
kubeClient.createDashboardClient()

expectAllClientsToUseRequestTimeout(request, 3456)
})

it('should allow per-client options to override KUBE_CLIENT_REQUEST_TIMEOUT', async () => {
expect.hasAssertions()
const { kubeClient, request } = await importKubeClientWithEnv('4567')

request.extend.mockClear()
kubeClient.createDashboardClient({ requestTimeout: 7654 })

expectAllClientsToUseRequestTimeout(request, 7654)
})

it('should allow per-client requestTimeout 0 to override KUBE_CLIENT_REQUEST_TIMEOUT', async () => {
expect.hasAssertions()
const { kubeClient, request } = await importKubeClientWithEnv('4567')

request.extend.mockClear()
kubeClient.createDashboardClient({ requestTimeout: 0 })

expectAllClientsToUseRequestTimeout(request, 0)
})

it('should ignore explicit undefined requestTimeout options when applying KUBE_CLIENT_REQUEST_TIMEOUT', async () => {
expect.hasAssertions()
const { kubeClient, request } = await importKubeClientWithEnv('4567')

request.extend.mockClear()
kubeClient.createDashboardClient({ requestTimeout: undefined })

expectAllClientsToUseRequestTimeout(request, 4567)
})

it('should apply KUBE_CLIENT_REQUEST_TIMEOUT to derived kubeconfig clients', async () => {
expect.hasAssertions()
const { kubeClient, request } = await importKubeClientWithEnv('5678')
const { default: helper } = await import('./fixtures/helper.js')
const client = kubeClient.createDashboardClient()
const getSecretStub = vi.spyOn(client.core.secrets, 'get')
const testKubeconfig = helper.createTestKubeconfig({ token: 'bearer' }, { server: 'https://kubernetes:6443' })
getSecretStub.mockReturnValue({
data: {
kubeconfig: Buffer.from(testKubeconfig.toYAML()).toString('base64'),
},
})

request.extend.mockClear()
await client.createKubeconfigClient({ namespace: 'namespace', name: 'name' })

expectAllClientsToUseRequestTimeout(request, 5678)
})

it('should allow KUBE_CLIENT_REQUEST_TIMEOUT 0 to disable request timeouts', async () => {
expect.hasAssertions()
const { request } = await importKubeClientWithEnv('0')

expectAllClientsToUseRequestTimeout(request, 0)
})

it.each(['foo', '-1', '1.5', '2147483648'])('should fail fast for invalid KUBE_CLIENT_REQUEST_TIMEOUT value %s', async value => {
vi.resetModules()
process.env[ENV_NAME] = value

await expect(import('../lib/index.js')).rejects.toThrow(
'KUBE_CLIENT_REQUEST_TIMEOUT must be a non-negative integer <= 2147483647',
)
})
})
22 changes: 20 additions & 2 deletions packages/kube-client/__tests__/mixins.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,19 @@ describe('kube-client', () => {
describe('Readable', () => {
it('should get a resource', () => {
const testObject = new TestObject()
const [url, { method, searchParams }] = testObject.get('name', {})
const signal = new AbortController().signal
const [url, { method, searchParams, signal: forwardedSignal }] = testObject.get('name', { signal })
expect(url).toBe('dummies/name')
expect(method).toBe('get')
expect(searchParams.toString()).toBe('')
expect(forwardedSignal).toBe(signal)
})

it('should reject invalid get signals', () => {
const testObject = new TestObject()
expect(() => testObject.get('name', { signal: {} })).toThrow(
'The parameter "signal" must be empty or an instance of AbortSignal',
)
})

it('should list a resource', async () => {
Expand Down Expand Up @@ -272,10 +281,19 @@ describe('kube-client', () => {
describe('Readable', () => {
it('should get a resource', () => {
const testObject = new TestObject()
const [url, { method, searchParams }] = testObject.get('namespace', 'name', {})
const signal = new AbortController().signal
const [url, { method, searchParams, signal: forwardedSignal }] = testObject.get('namespace', 'name', { signal })
expect(url).toBe('namespaces/namespace/dummies/name')
expect(method).toBe('get')
expect(searchParams.toString()).toBe('')
expect(forwardedSignal).toBe(signal)
})

it('should reject invalid get signals', () => {
const testObject = new TestObject()
expect(() => testObject.get('namespace', 'name', { signal: {} })).toThrow(
'The parameter "signal" must be empty or an instance of AbortSignal',
)
})

it('should list a resource', async () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/kube-client/lib/cache/ListWatcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ class ListWatcher {
list (query) {
const searchParams = this.mergeSearchParams(query)
const options = { searchParams }
if (this.signal) {
options.signal = this.signal
}
return this.listFunc(options)
}
}
Expand Down
42 changes: 38 additions & 4 deletions packages/kube-client/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,56 @@
//

import assert from 'node:assert'
import Client from './Client.js'
import BaseClient from './Client.js'
import Store from './cache/Store.js'
import { Resources } from './resources/index.js'
import kubeConfig from '@gardener-dashboard/kube-config'

const { load } = kubeConfig
const MAX_TIMEOUT = 2_147_483_647 // Node.js TIMEOUT_MAX (2^31 - 1)

function parseRequestTimeout (value) {
if (value === undefined || value === '') {
return undefined
}
const requestTimeout = /^\d+$/.test(value) ? Number(value) : NaN
if (!Number.isFinite(requestTimeout) || requestTimeout > MAX_TIMEOUT) {
throw TypeError(`KUBE_CLIENT_REQUEST_TIMEOUT must be a non-negative integer <= ${MAX_TIMEOUT}`)
}
return requestTimeout
}

const requestTimeout = parseRequestTimeout(process.env.KUBE_CLIENT_REQUEST_TIMEOUT)
const defaultOptions = requestTimeout === undefined
? {}
: { requestTimeout }

class Client extends BaseClient {
constructor (clientConfig, options = {}) {
const { requestTimeout, ...rest } = options

const resolvedOptions = {
...defaultOptions,
...rest,
}

if (requestTimeout !== undefined) {
resolvedOptions.requestTimeout = requestTimeout
}

super(clientConfig, resolvedOptions)
}
}

const ac = new AbortController()
const clientConfig = load(process.env, { signal: ac.signal })

function createClient (options) {
function createClient (options = {}) {
assert.ok(options.auth && options.auth.bearer, 'Client credentials are required')
return new Client(clientConfig, options)
}

function createDashboardClient (options) {
function createDashboardClient (options = {}) {
return new Client(clientConfig, options)
}

Expand All @@ -29,7 +63,7 @@ function abortWatcher () {
}

// create a client instance for the gardener cluster with dashboard privileges
const dashboardClient = new Client(clientConfig)
const dashboardClient = createDashboardClient()

export default {
createClient,
Expand Down
9 changes: 7 additions & 2 deletions packages/kube-client/lib/mixins.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,17 @@ ClusterScoped.Readable = superclass => class extends superclass {
get (name, { searchParams, signal, ...options } = {}) {
assertName(name)
assertSearchParams(searchParams)
assertSignal(signal)
assertOptions(options)
const method = 'get'
const url = clusterScopedUrl(this.constructor.names, name)
searchParams = normalizeSearchParams(method, searchParams, options)
return this[http.request](url, { method, searchParams })
return this[http.request](url, { method, searchParams, signal })
}

list ({ searchParams, signal, ...options } = {}) {
assertSearchParams(searchParams)
assertSignal(signal)
assertOptions(options)
const method = 'get'
const url = clusterScopedUrl(this.constructor.names)
Expand All @@ -83,16 +85,18 @@ NamespaceScoped.Readable = superclass => class extends superclass {
assertNamespace(namespace)
assertName(name)
assertSearchParams(searchParams)
assertSignal(signal)
assertOptions(options)
const method = 'get'
const url = namespaceScopedUrl(this.constructor.names, namespace, name)
searchParams = normalizeSearchParams(method, searchParams, options)
return this[http.request](url, { method, searchParams })
return this[http.request](url, { method, searchParams, signal })
}

list (namespace, { searchParams, signal, ...options } = {}) {
assertNamespace(namespace)
assertSearchParams(searchParams)
assertSignal(signal)
assertOptions(options)
const method = 'get'
const url = namespaceScopedUrl(this.constructor.names, namespace)
Expand All @@ -103,6 +107,7 @@ NamespaceScoped.Readable = superclass => class extends superclass {

listAllNamespaces ({ searchParams, signal, ...options } = {}) {
assertSearchParams(searchParams)
assertSignal(signal)
assertOptions(options)
const method = 'get'
const url = namespaceScopedUrl(this.constructor.names)
Expand Down
Loading
Loading