Skip to content

Commit 1ae8ee5

Browse files
authored
Merge pull request #48 from ccremer/watch
Watch Kubernetes resources for efficient change detection
2 parents d15214c + 46fca16 commit 1ae8ee5

File tree

13 files changed

+331
-6
lines changed

13 files changed

+331
-6
lines changed

packages/kubernetes-client-example-fetch/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ Configure our cluster
3030

3131
```bash
3232
kubectl -n default create sa browser-client
33-
kubectl create clusterrolebinding browser-client --serviceaccount=default:browser-client --clusterrole cluster-admin
33+
kubectl create clusterrolebinding browser-client --serviceaccount=default:browser-client --clusterrole cluster-admin
34+
echo "VITE_KUBERNETES_API_URL=${VITE_KUBERNETES_API_URL}" > packages/kubernetes-client-example-fetch/.env
3435
```
3536

3637
## Create a token

packages/kubernetes-client-example-fetch/src/index.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ <h3>Demo</h3>
3939

4040
<button id="getBtn" class="btn btn-primary">Get</button>
4141
<button id="listBtn" class="btn btn-secondary">List</button>
42+
<button id="watchBtn" class="btn btn-secondary">Watch</button>
4243
</div>
4344
</div>
4445
</div>
@@ -48,6 +49,7 @@ <h3>Demo</h3>
4849
<input id="hideManagedFields" type="checkbox" role="switch" class="form-check-input" value="checked" />
4950
<label for="hideManagedFields" class="form-check-label">Hide Managed Fields</label>
5051
</div>
52+
<button id="clearOutputBtn" type="button" class="btn btn-outline-secondary">Clear Output</button>
5153
</div>
5254
</div>
5355
<div class="row mb-3">

packages/kubernetes-client-example-fetch/src/js/main.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@ import '../styles.scss'
22
import { Client, KubeClientBuilder } from '@ccremer/kubernetes-client/fetch'
33
import { newSelfSubjectRulesReview } from './types'
44
import { createAlert } from './alerts'
5+
import { WatchEvent } from '@ccremer/kubernetes-client/api'
6+
import { KubeObject } from '@ccremer/kubernetes-client/types/core'
57

68
console.debug('Starting up...')
79

810
window.onload = function () {
911
document.getElementById('createClient')?.addEventListener('click', createClient)
1012
document.getElementById('listBtn')?.addEventListener('click', listObjects)
1113
document.getElementById('getBtn')?.addEventListener('click', getObject)
14+
document.getElementById('watchBtn')?.addEventListener('click', watchObjects)
15+
document.getElementById('clearOutputBtn')?.addEventListener('click', clearOutput)
1216
const tokenElement = document.getElementById('token')
1317
if (tokenElement instanceof HTMLInputElement) {
1418
tokenElement.value = localStorage.getItem('token') ?? ''
@@ -62,7 +66,7 @@ function getObject(): void {
6266
const name = getName()
6367

6468
if (name) {
65-
console.debug('Fetching Object', `${kind}${namespace}/${name}`)
69+
console.debug('Fetching Object', `${kind}/${namespace}/${name}`)
6670
kubeClient
6771
.getById('v1', kind, name, namespace, { hideManagedFields: hideManagedFields() })
6872
.then((cm) => fillTextArea(cm))
@@ -72,6 +76,53 @@ function getObject(): void {
7276
}
7377
}
7478

79+
let abortController: AbortController | undefined
80+
81+
function watchObjects(): void {
82+
if (!kubeClient) return
83+
if (abortController) {
84+
abortController.abort('Stop')
85+
abortController = undefined
86+
toggleWatchButton(false)
87+
return
88+
}
89+
90+
const kind = getKind()
91+
const namespace = getNamespace()
92+
const name = getName()
93+
94+
const events: WatchEvent<KubeObject>[] = []
95+
96+
console.debug('Watching Objects in', `${kind}/${namespace}`)
97+
kubeClient
98+
.watchByID(
99+
{
100+
onUpdate: (event) => {
101+
if (event) {
102+
events.push(event)
103+
fillTextArea(events)
104+
}
105+
},
106+
onError: (err, effect) => {
107+
console.log('received err', err)
108+
if (err instanceof Error) createAlert(`Watch failed: ${err.message}`, 'danger')
109+
if (typeof err === 'string' && err === 'Stop') createAlert('Watch stopped', 'warning', 3000)
110+
if (effect?.closed) toggleWatchButton(false)
111+
},
112+
},
113+
'v1',
114+
kind,
115+
name,
116+
namespace,
117+
{ hideManagedFields: hideManagedFields() }
118+
)
119+
.then((result) => {
120+
abortController = result.abortController
121+
toggleWatchButton(true)
122+
})
123+
.catch((err) => createAlert(err.message, 'danger'))
124+
}
125+
75126
function getNamespace(): string {
76127
const namespaceInput = document.getElementById('namespace')
77128
return namespaceInput instanceof HTMLInputElement ? namespaceInput.value : 'default'
@@ -100,9 +151,22 @@ function fillTextArea(value: unknown): void {
100151
}
101152
}
102153

154+
function clearOutput(): void {
155+
const textArea = document.getElementById('kubeobject')
156+
if (textArea instanceof HTMLTextAreaElement) {
157+
textArea.value = ''
158+
}
159+
}
160+
103161
function enableDemo(): void {
104162
const container = document.getElementById('demo-container')
105163
if (container) {
106164
container.className = container.className.replace('visually-hidden', '')
107165
}
108166
}
167+
168+
function toggleWatchButton(isWatching: boolean): void {
169+
const btn = document.getElementById('watchBtn')
170+
if (!btn) return
171+
btn.innerText = isWatching ? 'Stop' : 'Watch'
172+
}

packages/kubernetes-client-example-fetch/tests/demo.spec.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,44 @@ test.describe('demo form', () => {
6161
}
6262
await expect(value).toEqual(expected)
6363
})
64+
65+
test('should watch configmaps', async ({ page }) => {
66+
await page.locator('#resourceKind').selectOption('ConfigMap')
67+
await page.locator('#namespace').type('default')
68+
await page.locator('#watchBtn').click()
69+
const area = page.locator('#kubeobject')
70+
await expect(area).toBeEnabled()
71+
const value = await area
72+
.inputValue()
73+
.then((v) => JSON.parse(v))
74+
.then((json) => {
75+
const obj = json[0].object
76+
delete obj.metadata.resourceVersion
77+
delete obj.metadata.managedFields
78+
delete obj.metadata.uid
79+
delete obj.metadata.annotations
80+
delete obj.metadata.creationTimestamp
81+
obj.data['ca.crt'] = ''
82+
return json[0]
83+
})
84+
const expected = {
85+
type: 'ADDED',
86+
object: {
87+
kind: 'ConfigMap',
88+
apiVersion: 'v1',
89+
metadata: {
90+
name: 'kube-root-ca.crt',
91+
namespace: 'default',
92+
},
93+
data: {
94+
'ca.crt': '',
95+
},
96+
},
97+
}
98+
await expect(value).toEqual(expected)
99+
await page.locator('#watchBtn').click()
100+
101+
const res = await page.locator('#alerts')
102+
await expect(res).toHaveText(['Watch stopped'])
103+
})
64104
})

packages/kubernetes-client-example-fetch/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"outDir": "./dist",
55
"baseUrl": ".",
66
"paths": {
7-
"@ccremer/kubernetes-client/*": ["../kubernetes-client/dist/*"]
7+
"@ccremer/kubernetes-client/*": ["../kubernetes-client/src/*"]
88
}
99
},
1010
"include": ["src/**/*"],

packages/kubernetes-client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"test": "vitest run",
1111
"clean": "rm -rf types fetch api index.*",
1212
"build": "rm -rf dist && tsc",
13+
"watch": "tsc --watch",
1314
"prepack": "cp package.json README.md dist/"
1415
},
1516
"repository": {

packages/kubernetes-client/src/api/client.ts

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { KubeList, KubeObject } from '../types/core'
2-
import { DeleteOptions, GetOptions, ListOptions, MutationOptions, PatchOptions } from './options'
2+
import { DeleteOptions, GetOptions, ListOptions, MutationOptions, PatchOptions, WatchOptions } from './options'
33

44
export interface ClientWithGet {
55
/**
@@ -85,7 +85,7 @@ export interface ClientWithPatch {
8585

8686
export interface ClientWithDelete {
8787
/**
88-
* Deletess the resource identified by the given metadata.
88+
* Deletes the resource identified by the given metadata.
8989
* @param apiVersion case-sensitive API group and version of the resource. E.g. `v1` for core resources, `apps/v1` for "apps" resources.
9090
* @param kind `kind` of the resource in singular form, case-insensitive.
9191
* @param name `metadata.name` of the resource
@@ -109,3 +109,73 @@ export interface ClientWithDelete {
109109
*/
110110
delete<K extends KubeObject>(fromBody: K, options?: DeleteOptions): Promise<void>
111111
}
112+
113+
export interface WatchEvent<K extends KubeObject> {
114+
object: K
115+
type: 'ADDED' | 'MODIFIED' | 'DELETED'
116+
}
117+
118+
export interface WatchHandlers<K extends KubeObject> {
119+
/**
120+
* Callback function that gets called for each event returned by the `watch` operation.
121+
* @param event the event as returned by Kubernetes.
122+
*/
123+
onUpdate: (event: WatchEvent<K>) => void
124+
/**
125+
* Callback function for any errors occurring in the `watch` operation.
126+
* @param err the error payload.
127+
* @param effect contains additional information about the error.
128+
*/
129+
onError?: (
130+
err: unknown,
131+
effect?: {
132+
/**
133+
* Closed indicates whether the operation has been cancelled, and there won't be any updates anymore.
134+
*/
135+
closed?: boolean
136+
/**
137+
* Continue indicates whether the operation continues
138+
*/
139+
continue?: boolean
140+
}
141+
) => void
142+
}
143+
144+
export interface WatchResult {
145+
abortController: AbortController
146+
}
147+
148+
export interface ClientWithWatch {
149+
/**
150+
* Starts a Kubernetes `watch` operation.
151+
* @param handlers contains the callback with which the updates are propagated.
152+
* @param apiVersion case-sensitive API group and version of the resource. E.g. `v1` for core resources, `apps/v1` for "apps" resources.
153+
* @param kind `kind` of the resource in singular form, case-insensitive.
154+
* @param name `metadata.name` of the resource.
155+
* Note: Watching a single resource doesn't work in Kubernetes, only in lists.
156+
* Therefore, you most likely need the `list` RBAC permission along with `watch`.
157+
* If a name is given, the callback is only called with updates that match the given `name`(client-side filtering).
158+
* @param namespace `metadata.namespace` of the resource, if resource is namespaced.
159+
* @param options settings to influence the request.
160+
* Consult the Kubernetes API docs for reference.
161+
* @return Promise with the {@link WatchResult}.
162+
* The Promise is resolved early as soon as the status code is 2xx and contains a body.
163+
* It's rejected if the request fails immediately.
164+
* Note: Even though the Promise is resolved, the request continues in the background, calling the handler functions.
165+
* Use the given {@link WatchResult.abortController} to manually cancel an ongoing watch request.
166+
*/
167+
watchByID<K extends KubeObject>(
168+
handlers: WatchHandlers<K>,
169+
apiVersion: string,
170+
kind: string,
171+
name?: string,
172+
namespace?: string,
173+
options?: WatchOptions
174+
): Promise<WatchResult>
175+
176+
/**
177+
* Starts a Kubernetes `watch` operation, but the object's metadata (apiVersion, kind, name, namespace) is extracted from the given body.
178+
* @see watchByID
179+
*/
180+
watch<K extends KubeObject>(handlers: WatchHandlers<K>, fromBody: K, options?: WatchOptions): Promise<WatchResult>
181+
}

packages/kubernetes-client/src/api/options.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,7 @@ export interface MutationOptions extends CommonOptions {
4141
export interface PatchOptions extends MutationOptions {
4242
force?: boolean
4343
}
44+
45+
export interface WatchOptions extends CommonOptions {
46+
allowWatchBookmarks?: boolean
47+
}

packages/kubernetes-client/src/fetch/builder.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
ClientWithList,
66
ClientWithPatch,
77
ClientWithUpdate,
8+
ClientWithWatch,
89
Config,
910
KubeConfig,
1011
KubernetesUrlGenerator,
@@ -19,7 +20,8 @@ export interface Client
1920
ClientWithList,
2021
ClientWithDelete,
2122
ClientWithUpdate,
22-
ClientWithPatch {}
23+
ClientWithPatch,
24+
ClientWithWatch {}
2325

2426
/**
2527
* KubeClientBuilder constructs a {@link Client} instance using the Fetch API as implementation.

0 commit comments

Comments
 (0)