@intentius/chant-k8s-client 0.58.0 → 0.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.ts +63 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/testing.d.ts +34 -0
- package/dist/testing.d.ts.map +1 -1
- package/dist/types.d.ts +18 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/watch.d.ts +75 -0
- package/dist/watch.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/client.ts +232 -0
- package/src/index.ts +10 -1
- package/src/testing.ts +85 -1
- package/src/types.ts +21 -1
- package/src/watch.test.ts +366 -0
- package/src/watch.ts +157 -0
package/dist/client.d.ts
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
* path that writes the `Authorization` header — runs for real, which is what
|
|
35
35
|
* makes a test that injects one worth writing.
|
|
36
36
|
*/
|
|
37
|
+
import { type WatchFrame } from "./watch.js";
|
|
37
38
|
import type { ApiResourceInfo, ClientProvenance, K8sClientOptions, K8sObject, ObjectRef, ResourceSelector, KubeconfigView } from "./types.js";
|
|
38
39
|
type ClientNode = typeof import("@kubernetes/client-node");
|
|
39
40
|
/**
|
|
@@ -136,6 +137,55 @@ export interface ReadLogOptions {
|
|
|
136
137
|
timestamps?: boolean;
|
|
137
138
|
signal?: AbortSignal;
|
|
138
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* Options for {@link K8sClient.watch} (chant #1981).
|
|
142
|
+
*
|
|
143
|
+
* A watch is a trigger channel, so the interesting options are the ones that
|
|
144
|
+
* bound it (a namespace, a label selector) rather than anything about what
|
|
145
|
+
* comes back.
|
|
146
|
+
*/
|
|
147
|
+
export interface WatchOptions {
|
|
148
|
+
/** Restrict to one namespace. Omitted watches across all of them. */
|
|
149
|
+
namespace?: string;
|
|
150
|
+
/** A label selector, e.g. `app.kubernetes.io/managed-by=chant`. */
|
|
151
|
+
labelSelector?: string;
|
|
152
|
+
/**
|
|
153
|
+
* Called once per event frame, with the frame the server sent.
|
|
154
|
+
*
|
|
155
|
+
* The frame is passed through verbatim rather than interpreted, because this
|
|
156
|
+
* package has no opinion about what a change means. Chant's own consumer
|
|
157
|
+
* (the k8s lexicon's `subscribeChanges`) ignores it entirely and calls a
|
|
158
|
+
* no-argument `onChange`, which is the rule that keeps a watch event from
|
|
159
|
+
* ever becoming an observation.
|
|
160
|
+
*/
|
|
161
|
+
onEvent?(frame: WatchFrame): void;
|
|
162
|
+
/**
|
|
163
|
+
* The watch ended for a reason that is not "you closed it". Called at most
|
|
164
|
+
* once, and the watch is over when it is: re-establishing is the caller's
|
|
165
|
+
* decision, made with the caller's own backoff.
|
|
166
|
+
*/
|
|
167
|
+
onError?(message: string): void;
|
|
168
|
+
/** Aborts the watch, exactly as {@link WatchHandle.close} does. */
|
|
169
|
+
signal?: AbortSignal;
|
|
170
|
+
/**
|
|
171
|
+
* How long to wait before reopening a stream the server closed cleanly.
|
|
172
|
+
* The API server ends a watch connection every few minutes by design, which
|
|
173
|
+
* is a reconnect rather than a failure. @default 1000
|
|
174
|
+
*/
|
|
175
|
+
reopenDelayMs?: number;
|
|
176
|
+
}
|
|
177
|
+
/** A live watch. Returned by {@link K8sClient.watch}. */
|
|
178
|
+
export interface WatchHandle {
|
|
179
|
+
/**
|
|
180
|
+
* Stop the watch and release its connection. Idempotent, and safe to call
|
|
181
|
+
* after the watch has already ended on its own. Resolves once the reader
|
|
182
|
+
* has actually unwound, so a caller that closes and then asserts on request
|
|
183
|
+
* counts is not racing the stream.
|
|
184
|
+
*/
|
|
185
|
+
close(): Promise<void>;
|
|
186
|
+
/** Resolves when the watch is over, however it ended. Never rejects. */
|
|
187
|
+
readonly done: Promise<void>;
|
|
188
|
+
}
|
|
139
189
|
/** The client surface the k8s lexicon consumes. */
|
|
140
190
|
export interface K8sClient {
|
|
141
191
|
/** Where this client is pointed and what authorized it. */
|
|
@@ -166,6 +216,19 @@ export interface K8sClient {
|
|
|
166
216
|
readIfPresent(ref: ObjectRef, options?: ReadOptions): Promise<K8sObject | undefined>;
|
|
167
217
|
/** LIST a kind, optionally namespaced and label-filtered. Follows `continue` tokens. */
|
|
168
218
|
list(selector: ResourceSelector, options?: ListOptions): Promise<K8sObject[]>;
|
|
219
|
+
/**
|
|
220
|
+
* WATCH a kind: a long-lived `GET ...?watch=1&resourceVersion=<rv>` whose
|
|
221
|
+
* NDJSON frames arrive at `options.onEvent` until somebody closes it (chant
|
|
222
|
+
* #1981).
|
|
223
|
+
*
|
|
224
|
+
* The `resourceVersion` comes from a LIST issued first, which is the only
|
|
225
|
+
* way to start a watch without a gap. A `410 Gone`, the server saying that
|
|
226
|
+
* version has aged out of its change history, re-LISTs and resumes from the
|
|
227
|
+
* new version rather than retrying the stale one; a stream the server closes
|
|
228
|
+
* cleanly is reopened from the last version seen. Anything else ends the
|
|
229
|
+
* watch through `options.onError`.
|
|
230
|
+
*/
|
|
231
|
+
watch(selector: ResourceSelector, options?: WatchOptions): Promise<WatchHandle>;
|
|
169
232
|
/**
|
|
170
233
|
* GET a Pod's `/log` subresource — plain text, not JSON, which is why this
|
|
171
234
|
* is its own method rather than a `read` variant. A snapshot only: the
|
package/dist/client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAeH,OAAO,KAAK,EACV,eAAe,EACf,gBAAgB,EAChB,gBAAgB,EAChB,SAAS,EACT,SAAS,EAET,gBAAgB,EAGhB,cAAc,EACf,MAAM,SAAS,CAAC;AAEjB,KAAK,UAAU,GAAG,cAAc,yBAAyB,CAAC,CAAC;AAI3D;;;;;;;GAOG;AACH,wBAAsB,cAAc,IAAI,OAAO,CAAC,UAAU,CAAC,CAQ1D;AAED,yEAAyE;AACzE,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO7D;AAED;;;;;;;;;GASG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,GAAE,IAAI,CAAC,gBAAgB,EAAE,YAAY,GAAG,gBAAgB,CAAM,GACpE,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAO7B;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,GAAE,IAAI,CAAC,gBAAgB,EAAE,YAAY,GAAG,gBAAgB,CAAM,GACpE,OAAO,CAAC,cAAc,CAAC,CAwBzB;AAED,wCAAwC;AACxC,MAAM,WAAW,WAAW;IAC1B,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,2CAA2C;AAC3C,MAAM,WAAW,YAAY;IAC3B;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,gFAAgF;IAChF,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,4CAA4C;AAC5C,MAAM,WAAW,aAAa;IAC5B,mFAAmF;IACnF,iBAAiB,CAAC,EAAE,YAAY,GAAG,YAAY,GAAG,QAAQ,CAAC;IAC3D,wDAAwD;IACxD,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,0CAA0C;AAC1C,MAAM,WAAW,WAAW;IAC1B,mEAAmE;IACnE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mEAAmE;IACnE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,2DAA2D;AAC3D,MAAM,WAAW,cAAc;IAC7B,+EAA+E;IAC/E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,sEAAsE;IACtE,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,6BAA6B;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,4CAA4C;IAC5C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,mDAAmD;IACnD,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,mDAAmD;AACnD,MAAM,WAAW,SAAS;IACxB,2DAA2D;IAC3D,QAAQ,CAAC,UAAU,EAAE,gBAAgB,CAAC;IACtC,kDAAkD;IAClD,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC;;;;OAIG;IACH,OAAO,CAAC,QAAQ,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,eAAe,GAAG,SAAS,CAAC,CAAC;IAChG,8EAA8E;IAC9E,IAAI,CAAC,GAAG,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;IAChE;;;;;;;;;;OAUG;IACH,OAAO,CAAC,GAAG,EAAE,SAAS,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IAC3E,wEAAwE;IACxE,aAAa,CAAC,GAAG,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC,CAAC;IACrF,wFAAwF;IACxF,IAAI,CAAC,QAAQ,EAAE,gBAAgB,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC,CAAC;IAC9E;;;;;;;;OAQG;IACH,OAAO,CAAC,GAAG,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACnE,4DAA4D;IAC5D,KAAK,CAAC,MAAM,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,YAAY,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;IACrE,iFAAiF;IACjF,MAAM,CAAC,GAAG,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/D,oEAAoE;IACpE,YAAY,CAAC,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,SAAS,CAAC,EAAE,EAAE,EAAE,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC;IAClG;;;;;;OAMG;IACH,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAAC;IAChF;;;;;;;OAOG;IACH,sBAAsB,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAChE;;;;;;;;;OASG;IACH,iBAAiB,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,eAAe,GAAG,SAAS,CAAC,CAAC;IAC9E,6FAA6F;IAC7F,kBAAkB,IAAI,MAAM,EAAE,CAAC;CAChC;AAED;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,8EAA8E;IAC9E,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AA6BD;;;;GAIG;AACH,wBAAsB,eAAe,CAAC,OAAO,GAAE,gBAAqB,GAAG,OAAO,CAAC,SAAS,CAAC,
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
* it stays that way.
|
|
13
13
|
*/
|
|
14
14
|
export { createK8sClient, readAmbientContext, readKubeconfigView, loadClientNode, isK8sClientAvailable, apiVersionPath, splitApiVersion, selectorText, refText, } from "./client.js";
|
|
15
|
-
export type { K8sClient, ReadOptions, ApplyOptions, DeleteOptions, ListOptions, ReadLogOptions, SelfSubjectInfo } from "./client.js";
|
|
15
|
+
export type { K8sClient, ReadOptions, ApplyOptions, DeleteOptions, ListOptions, ReadLogOptions, SelfSubjectInfo, WatchOptions, WatchHandle, } from "./client.js";
|
|
16
|
+
export { parseWatchFrames, isExpiredFrame, resourceVersionOf, streamLines } from "./watch.js";
|
|
17
|
+
export type { WatchFrame, WatchEventType } from "./watch.js";
|
|
16
18
|
export { K8sApiError, K8sTransportError, K8sClientUnavailableError, ExecCredentialNotAllowedError, FieldManagerError, KubeConfigError, UnknownResourceError, } from "./errors.js";
|
|
17
19
|
export type { K8sStatus } from "./errors.js";
|
|
18
20
|
export { CHANT_FIELD_MANAGER, FIELD_MANAGER_SEPARATOR, FIELD_MANAGER_MAX_LENGTH, fieldManagerFor, assertValidFieldManager, isChantFieldManager, chantStackOf, } from "./field-manager.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EACL,eAAe,EACf,kBAAkB,EAClB,kBAAkB,EAClB,cAAc,EACd,oBAAoB,EACpB,cAAc,EACd,eAAe,EACf,YAAY,EACZ,OAAO,GACR,MAAM,UAAU,CAAC;AAClB,YAAY,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EACL,eAAe,EACf,kBAAkB,EAClB,kBAAkB,EAClB,cAAc,EACd,oBAAoB,EACpB,cAAc,EACd,eAAe,EACf,YAAY,EACZ,OAAO,GACR,MAAM,UAAU,CAAC;AAClB,YAAY,EACV,SAAS,EAAE,WAAW,EAAE,YAAY,EAAE,aAAa,EAAE,WAAW,EAAE,cAAc,EAChF,eAAe,EAAE,YAAY,EAAE,WAAW,GAC3C,MAAM,UAAU,CAAC;AAKlB,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAC3F,YAAY,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE1D,OAAO,EACL,WAAW,EACX,iBAAiB,EACjB,yBAAyB,EACzB,6BAA6B,EAC7B,iBAAiB,EACjB,eAAe,EACf,oBAAoB,GACrB,MAAM,UAAU,CAAC;AAClB,YAAY,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAG1C,OAAO,EACL,mBAAmB,EACnB,uBAAuB,EACvB,wBAAwB,EACxB,eAAe,EACf,uBAAuB,EACvB,mBAAmB,EACnB,YAAY,GACb,MAAM,iBAAiB,CAAC;AACzB,YAAY,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAE5D,OAAO,EACL,yBAAyB,EACzB,sBAAsB,EACtB,mBAAmB,EACnB,oBAAoB,EACpB,oBAAoB,GACrB,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAGhE,OAAO,EACL,eAAe,EACf,WAAW,EACX,UAAU,EACV,aAAa,EACb,gBAAgB,EAChB,WAAW,EACX,YAAY,EACZ,aAAa,GACd,MAAM,kBAAkB,CAAC;AAC1B,YAAY,EAAE,kBAAkB,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAE5E,OAAO,EACL,sBAAsB,EACtB,2BAA2B,EAC3B,gBAAgB,EAChB,YAAY,EACZ,eAAe,GAChB,MAAM,eAAe,CAAC;AACvB,YAAY,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAEhE,OAAO,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAEnE,YAAY,EACV,eAAe,EACf,gBAAgB,EAChB,cAAc,EACd,gBAAgB,EAChB,qBAAqB,EACrB,cAAc,EACd,SAAS,EACT,SAAS,EACT,kBAAkB,EAClB,YAAY,EACZ,gBAAgB,EAChB,mBAAmB,GACpB,MAAM,SAAS,CAAC"}
|
package/dist/testing.d.ts
CHANGED
|
@@ -29,6 +29,13 @@ export interface FakeResponse {
|
|
|
29
29
|
status?: number;
|
|
30
30
|
body?: unknown;
|
|
31
31
|
headers?: Record<string, string>;
|
|
32
|
+
/**
|
|
33
|
+
* A streaming body, for a watch (chant #1981). Anything async-iterable will
|
|
34
|
+
* do, and {@link fakeWatchStream} is the usual source. Set it and the response
|
|
35
|
+
* exposes `body.stream()`, which is what the client reads instead of
|
|
36
|
+
* `text()` for a request it never expects to complete.
|
|
37
|
+
*/
|
|
38
|
+
stream?: AsyncIterable<string | Uint8Array>;
|
|
32
39
|
}
|
|
33
40
|
export type FakeRequestHandler = (request: RecordedRequest) => FakeResponse | Promise<FakeResponse>;
|
|
34
41
|
/** A recording {@link RequestLayer} driven by `handler`. */
|
|
@@ -81,4 +88,31 @@ export declare function apiResourceList(groupVersion: string, resources: Array<{
|
|
|
81
88
|
shortNames?: string[];
|
|
82
89
|
verbs?: string[];
|
|
83
90
|
}>): Record<string, unknown>;
|
|
91
|
+
/** A watch stream a test drives by hand. */
|
|
92
|
+
export interface FakeWatchStream extends AsyncIterable<string> {
|
|
93
|
+
/**
|
|
94
|
+
* Send one NDJSON frame down the stream. Objects are stringified; a string
|
|
95
|
+
* is sent verbatim, which is how a test produces a malformed or half-written
|
|
96
|
+
* frame. A newline is appended unless the string already ends in one.
|
|
97
|
+
*/
|
|
98
|
+
push(frame: unknown): void;
|
|
99
|
+
/** End the stream, as a server closing the connection does. */
|
|
100
|
+
close(): void;
|
|
101
|
+
/** How many frames have been consumed by the reader. */
|
|
102
|
+
readonly delivered: number;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* A controllable NDJSON stream for driving {@link import("./client.js").K8sClient.watch}
|
|
106
|
+
* against the fake cluster (chant #1981).
|
|
107
|
+
*
|
|
108
|
+
* The client reads it exactly as it reads a live watch: one frame per line,
|
|
109
|
+
* for as long as the connection stays open. So a test can push an ADDED, an
|
|
110
|
+
* expired-`410` ERROR, or nothing at all, and assert on what the client does
|
|
111
|
+
* about it, without a cluster, a socket, or a timer.
|
|
112
|
+
*/
|
|
113
|
+
export declare function fakeWatchStream(): FakeWatchStream;
|
|
114
|
+
/** A watch event frame, the shape the API server sends. */
|
|
115
|
+
export declare function watchFrame(type: string, object: Record<string, unknown>): Record<string, unknown>;
|
|
116
|
+
/** The `410 Gone` frame a watch gets when its resourceVersion has aged out. */
|
|
117
|
+
export declare function expiredWatchFrame(message?: string): Record<string, unknown>;
|
|
84
118
|
//# sourceMappingURL=testing.d.ts.map
|
package/dist/testing.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAsB,YAAY,EAAuB,MAAM,SAAS,CAAC;AAErF,8CAA8C;AAC9C,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,GAAG,EAAE,MAAM,CAAC;IACZ,iCAAiC;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,IAAI,EAAE,OAAO,CAAC;CACf;AAED,qFAAqF;AACrF,MAAM,WAAW,YAAY;IAC3B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"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"}
|
package/dist/types.d.ts
CHANGED
|
@@ -169,6 +169,24 @@ export interface ResponseContextLike {
|
|
|
169
169
|
headers: Record<string, string>;
|
|
170
170
|
body: {
|
|
171
171
|
text(): Promise<string>;
|
|
172
|
+
/**
|
|
173
|
+
* The body as it arrives, rather than once it is complete (chant #1981).
|
|
174
|
+
*
|
|
175
|
+
* `text()` reads a response to completion, which is the right shape for
|
|
176
|
+
* every request this client makes except one: a watch never completes, so
|
|
177
|
+
* a watch read through `text()` is a promise that resolves when the
|
|
178
|
+
* cluster hangs up and never before. client-node's own HTTP library
|
|
179
|
+
* already exposes `stream()` (its `undici` `fetch` response's web
|
|
180
|
+
* `ReadableStream`), so the seam widens rather than being invented.
|
|
181
|
+
*
|
|
182
|
+
* Typed `unknown` for the same reason the rest of this file avoids the
|
|
183
|
+
* library's classes: the decoder (`./watch.ts`'s `streamLines`) accepts a
|
|
184
|
+
* web `ReadableStream`, a Node `Readable`, or any async iterable. Optional
|
|
185
|
+
* because a transport without it is still a valid transport: the watch
|
|
186
|
+
* falls back to `text()`, which is exactly what a fake returning a
|
|
187
|
+
* complete NDJSON body wants.
|
|
188
|
+
*/
|
|
189
|
+
stream?(): unknown;
|
|
172
190
|
};
|
|
173
191
|
}
|
|
174
192
|
//# sourceMappingURL=types.d.ts.map
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/dist/watch.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Kubernetes watch, as much of it as can be written without a client
|
|
3
|
+
* (chant #1981).
|
|
4
|
+
*
|
|
5
|
+
* A watch is `GET <list path>?watch=1&resourceVersion=<rv>` answered with an
|
|
6
|
+
* open connection that emits one JSON document per line. NDJSON, forever,
|
|
7
|
+
* until somebody hangs up. Everything here is the frame side of that: parsing
|
|
8
|
+
* lines out of a byte stream, reading a frame's `resourceVersion`, and
|
|
9
|
+
* recognising the one failure a watch is expected to hit.
|
|
10
|
+
*
|
|
11
|
+
* ## `410 Gone`
|
|
12
|
+
*
|
|
13
|
+
* The API server keeps a bounded history of changes. A watch resuming from a
|
|
14
|
+
* `resourceVersion` older than that window cannot be told what it missed, and
|
|
15
|
+
* says so: an `ERROR` frame carrying a `Status` with `code: 410` / `reason:
|
|
16
|
+
* Expired`. There is exactly one correct response, and it is not to retry with
|
|
17
|
+
* the same `resourceVersion`. It is to LIST again, take the list's own
|
|
18
|
+
* `resourceVersion`, and watch from there. Anything else silently drops
|
|
19
|
+
* whatever happened during the gap.
|
|
20
|
+
*
|
|
21
|
+
* For chant's purposes the gap costs nothing anyway: the events are a trigger,
|
|
22
|
+
* never a fact, and the tick they wake re-observes the estate from scratch. A
|
|
23
|
+
* re-list is a re-list, not a reconciliation.
|
|
24
|
+
*/
|
|
25
|
+
import type { K8sObject } from "./types.js";
|
|
26
|
+
/** The event types the watch API emits. `BOOKMARK` requires `allowWatchBookmarks`. */
|
|
27
|
+
export type WatchEventType = "ADDED" | "MODIFIED" | "DELETED" | "BOOKMARK" | "ERROR";
|
|
28
|
+
/**
|
|
29
|
+
* One decoded NDJSON frame. `object` is whatever the server put in the frame:
|
|
30
|
+
* the changed resource for the four ordinary types, a `Status` for `ERROR`.
|
|
31
|
+
*/
|
|
32
|
+
export interface WatchFrame {
|
|
33
|
+
type: WatchEventType;
|
|
34
|
+
object: K8sObject;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Split a chunk of a watch stream into whole frames, carrying the trailing
|
|
38
|
+
* partial line over to the next call.
|
|
39
|
+
*
|
|
40
|
+
* Frames arrive split across TCP reads at arbitrary byte offsets, so a decoder
|
|
41
|
+
* that treats each chunk as a set of complete lines drops or corrupts every
|
|
42
|
+
* frame that straddles a boundary. `carry` is the fix and the reason this is a
|
|
43
|
+
* function rather than three lines inline.
|
|
44
|
+
*
|
|
45
|
+
* A line that is not JSON, or is JSON without a known `type`, is dropped
|
|
46
|
+
* rather than thrown: a watch is a trigger channel, and the correct response
|
|
47
|
+
* to a frame nobody can read is to keep watching.
|
|
48
|
+
*/
|
|
49
|
+
export declare function parseWatchFrames(chunk: string, carry?: string): {
|
|
50
|
+
frames: WatchFrame[];
|
|
51
|
+
carry: string;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Whether this frame is the API server saying the watch's `resourceVersion` has
|
|
55
|
+
* aged out of its history: the `410 Gone` that means re-list, not retry.
|
|
56
|
+
*
|
|
57
|
+
* Matched on `code` first and `reason` second, because both are set on the
|
|
58
|
+
* `Status` the server sends and a cluster that sets only one of them is still
|
|
59
|
+
* telling us the same thing.
|
|
60
|
+
*/
|
|
61
|
+
export declare function isExpiredFrame(frame: WatchFrame): boolean;
|
|
62
|
+
/** The `resourceVersion` to resume from after this frame, when it carries one. */
|
|
63
|
+
export declare function resourceVersionOf(frame: WatchFrame): string | undefined;
|
|
64
|
+
/**
|
|
65
|
+
* Decode whatever a response body's `stream()` returned into lines of text.
|
|
66
|
+
*
|
|
67
|
+
* Three shapes reach here and all three are real: a web `ReadableStream` (what
|
|
68
|
+
* `undici`'s `fetch` gives client-node's HTTP library, and so what a live
|
|
69
|
+
* cluster produces), a Node `Readable` or any other async iterable (what a
|
|
70
|
+
* hand-rolled transport gives), and `undefined` for a transport with no
|
|
71
|
+
* streaming seam at all, which is a fake that answered the whole body at once and
|
|
72
|
+
* is handled by the caller reading `text()` instead.
|
|
73
|
+
*/
|
|
74
|
+
export declare function streamLines(source: unknown): AsyncGenerator<string>;
|
|
75
|
+
//# sourceMappingURL=watch.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"watch.d.ts","sourceRoot":"","sources":["../src/watch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAEzC,sFAAsF;AACtF,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,UAAU,GAAG,SAAS,GAAG,UAAU,GAAG,OAAO,CAAC;AAErF;;;GAGG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,cAAc,CAAC;IACrB,MAAM,EAAE,SAAS,CAAC;CACnB;AAID;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,MAAM,EACb,KAAK,SAAK,GACT;IAAE,MAAM,EAAE,UAAU,EAAE,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CA2BzC;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAKzD;AAED,kFAAkF;AAClF,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,GAAG,SAAS,CAGvE;AAED;;;;;;;;;GASG;AACH,wBAAuB,WAAW,CAAC,MAAM,EAAE,OAAO,GAAG,cAAc,CAAC,MAAM,CAAC,CAuC1E"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-k8s-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.0",
|
|
4
4
|
"description": "Typed Kubernetes API client for chant — the read/write path of the k8s lexicon, kept out of the build path",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant/lexicons/k8s/api-client/",
|
package/src/client.ts
CHANGED
|
@@ -48,6 +48,7 @@ import { asFieldManagerConflict } from "./conflict";
|
|
|
48
48
|
import { assertValidFieldManager, CHANT_FIELD_MANAGER } from "./field-manager";
|
|
49
49
|
import { DEFAULT_CONCURRENCY, mapConcurrent } from "./concurrency";
|
|
50
50
|
import { loadKubeConfig } from "./kubeconfig";
|
|
51
|
+
import { isExpiredFrame, parseWatchFrames, resourceVersionOf, streamLines, type WatchFrame } from "./watch";
|
|
51
52
|
import type {
|
|
52
53
|
ApiResourceInfo,
|
|
53
54
|
ClientProvenance,
|
|
@@ -224,6 +225,57 @@ export interface ReadLogOptions {
|
|
|
224
225
|
signal?: AbortSignal;
|
|
225
226
|
}
|
|
226
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Options for {@link K8sClient.watch} (chant #1981).
|
|
230
|
+
*
|
|
231
|
+
* A watch is a trigger channel, so the interesting options are the ones that
|
|
232
|
+
* bound it (a namespace, a label selector) rather than anything about what
|
|
233
|
+
* comes back.
|
|
234
|
+
*/
|
|
235
|
+
export interface WatchOptions {
|
|
236
|
+
/** Restrict to one namespace. Omitted watches across all of them. */
|
|
237
|
+
namespace?: string;
|
|
238
|
+
/** A label selector, e.g. `app.kubernetes.io/managed-by=chant`. */
|
|
239
|
+
labelSelector?: string;
|
|
240
|
+
/**
|
|
241
|
+
* Called once per event frame, with the frame the server sent.
|
|
242
|
+
*
|
|
243
|
+
* The frame is passed through verbatim rather than interpreted, because this
|
|
244
|
+
* package has no opinion about what a change means. Chant's own consumer
|
|
245
|
+
* (the k8s lexicon's `subscribeChanges`) ignores it entirely and calls a
|
|
246
|
+
* no-argument `onChange`, which is the rule that keeps a watch event from
|
|
247
|
+
* ever becoming an observation.
|
|
248
|
+
*/
|
|
249
|
+
onEvent?(frame: WatchFrame): void;
|
|
250
|
+
/**
|
|
251
|
+
* The watch ended for a reason that is not "you closed it". Called at most
|
|
252
|
+
* once, and the watch is over when it is: re-establishing is the caller's
|
|
253
|
+
* decision, made with the caller's own backoff.
|
|
254
|
+
*/
|
|
255
|
+
onError?(message: string): void;
|
|
256
|
+
/** Aborts the watch, exactly as {@link WatchHandle.close} does. */
|
|
257
|
+
signal?: AbortSignal;
|
|
258
|
+
/**
|
|
259
|
+
* How long to wait before reopening a stream the server closed cleanly.
|
|
260
|
+
* The API server ends a watch connection every few minutes by design, which
|
|
261
|
+
* is a reconnect rather than a failure. @default 1000
|
|
262
|
+
*/
|
|
263
|
+
reopenDelayMs?: number;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** A live watch. Returned by {@link K8sClient.watch}. */
|
|
267
|
+
export interface WatchHandle {
|
|
268
|
+
/**
|
|
269
|
+
* Stop the watch and release its connection. Idempotent, and safe to call
|
|
270
|
+
* after the watch has already ended on its own. Resolves once the reader
|
|
271
|
+
* has actually unwound, so a caller that closes and then asserts on request
|
|
272
|
+
* counts is not racing the stream.
|
|
273
|
+
*/
|
|
274
|
+
close(): Promise<void>;
|
|
275
|
+
/** Resolves when the watch is over, however it ended. Never rejects. */
|
|
276
|
+
readonly done: Promise<void>;
|
|
277
|
+
}
|
|
278
|
+
|
|
227
279
|
/** The client surface the k8s lexicon consumes. */
|
|
228
280
|
export interface K8sClient {
|
|
229
281
|
/** Where this client is pointed and what authorized it. */
|
|
@@ -254,6 +306,19 @@ export interface K8sClient {
|
|
|
254
306
|
readIfPresent(ref: ObjectRef, options?: ReadOptions): Promise<K8sObject | undefined>;
|
|
255
307
|
/** LIST a kind, optionally namespaced and label-filtered. Follows `continue` tokens. */
|
|
256
308
|
list(selector: ResourceSelector, options?: ListOptions): Promise<K8sObject[]>;
|
|
309
|
+
/**
|
|
310
|
+
* WATCH a kind: a long-lived `GET ...?watch=1&resourceVersion=<rv>` whose
|
|
311
|
+
* NDJSON frames arrive at `options.onEvent` until somebody closes it (chant
|
|
312
|
+
* #1981).
|
|
313
|
+
*
|
|
314
|
+
* The `resourceVersion` comes from a LIST issued first, which is the only
|
|
315
|
+
* way to start a watch without a gap. A `410 Gone`, the server saying that
|
|
316
|
+
* version has aged out of its change history, re-LISTs and resumes from the
|
|
317
|
+
* new version rather than retrying the stale one; a stream the server closes
|
|
318
|
+
* cleanly is reopened from the last version seen. Anything else ends the
|
|
319
|
+
* watch through `options.onError`.
|
|
320
|
+
*/
|
|
321
|
+
watch(selector: ResourceSelector, options?: WatchOptions): Promise<WatchHandle>;
|
|
257
322
|
/**
|
|
258
323
|
* GET a Pod's `/log` subresource — plain text, not JSON, which is why this
|
|
259
324
|
* is its own method rather than a `read` variant. A snapshot only: the
|
|
@@ -706,6 +771,172 @@ export async function createK8sClient(options: K8sClientOptions = {}): Promise<K
|
|
|
706
771
|
return items;
|
|
707
772
|
}
|
|
708
773
|
|
|
774
|
+
/**
|
|
775
|
+
* Open the watch stream itself: the same request-building, auth and
|
|
776
|
+
* transport path every other call takes, stopping short of reading the body
|
|
777
|
+
* to completion. `send` cannot be reused because its last act is
|
|
778
|
+
* `body.text()`, which for a watch is a promise that resolves when the
|
|
779
|
+
* cluster hangs up.
|
|
780
|
+
*/
|
|
781
|
+
async function openStream(
|
|
782
|
+
path: string,
|
|
783
|
+
query: Record<string, string>,
|
|
784
|
+
signal: AbortSignal,
|
|
785
|
+
target: string,
|
|
786
|
+
): Promise<{ status: number; lines: AsyncIterable<string> }> {
|
|
787
|
+
const ctx = configuration.baseServer.makeRequestContext(path, "GET" as k8s.HttpMethod);
|
|
788
|
+
ctx.setHeaderParam("Accept", "application/json");
|
|
789
|
+
for (const [key, value] of Object.entries(query)) ctx.setQueryParam(key, value);
|
|
790
|
+
ctx.setSignal(signal);
|
|
791
|
+
await kc.applySecurityAuthentication(ctx);
|
|
792
|
+
|
|
793
|
+
let response: ResponseContextLike;
|
|
794
|
+
try {
|
|
795
|
+
response = (await configuration.httpApi.send(ctx).toPromise()) as unknown as ResponseContextLike;
|
|
796
|
+
} catch (err) {
|
|
797
|
+
throw noted(
|
|
798
|
+
new K8sTransportError(err instanceof Error ? err.message : String(err), target, { cause: err }),
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
if (response.httpStatusCode < 200 || response.httpStatusCode > 299) {
|
|
803
|
+
throw noted(K8sApiError.fromResponse(response.httpStatusCode, await response.body.text(), target));
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// A transport with no streaming seam answered the whole body at once,
|
|
807
|
+
// which a fake NDJSON fixture does, and a live watch never can.
|
|
808
|
+
const raw = response.body.stream?.();
|
|
809
|
+
if (raw === undefined || raw === null) {
|
|
810
|
+
const text = await response.body.text();
|
|
811
|
+
return { status: response.httpStatusCode, lines: (async function* () { yield* text.split("\n"); })() };
|
|
812
|
+
}
|
|
813
|
+
return { status: response.httpStatusCode, lines: streamLines(raw) };
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
async function watch(selector: ResourceSelector, opts: WatchOptions = {}): Promise<WatchHandle> {
|
|
817
|
+
const info = await resolveOrThrow(selector, opts.signal);
|
|
818
|
+
const target = `watch ${selectorText(selector)}`;
|
|
819
|
+
const reopenDelayMs = opts.reopenDelayMs ?? 1_000;
|
|
820
|
+
// Own controller so `close()` works whether or not the caller passed a
|
|
821
|
+
// signal, and so aborting the watch never touches the caller's.
|
|
822
|
+
const controller = new AbortController();
|
|
823
|
+
const stopOnCallerAbort = () => controller.abort();
|
|
824
|
+
if (opts.signal) {
|
|
825
|
+
if (opts.signal.aborted) controller.abort();
|
|
826
|
+
else opts.signal.addEventListener("abort", stopOnCallerAbort, { once: true });
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const listPath = opts.namespace
|
|
830
|
+
? objectPath(info, undefined, opts.namespace)
|
|
831
|
+
: `${apiVersionPath(info.apiVersion)}/${info.name}`;
|
|
832
|
+
const listQuery: Record<string, string> = {};
|
|
833
|
+
if (opts.labelSelector) listQuery.labelSelector = opts.labelSelector;
|
|
834
|
+
|
|
835
|
+
/** The list's own `resourceVersion`, where a watch with no gap starts. */
|
|
836
|
+
async function listResourceVersion(): Promise<string | undefined> {
|
|
837
|
+
const page = await sendJson<{ metadata?: { resourceVersion?: string } }>(listPath, "GET", {
|
|
838
|
+
signal: controller.signal,
|
|
839
|
+
// `limit=1` because nothing here wants the objects: the list exists
|
|
840
|
+
// for the `resourceVersion` in its metadata, which every page carries.
|
|
841
|
+
query: { ...listQuery, limit: "1" },
|
|
842
|
+
target: `${target} (initial list)`,
|
|
843
|
+
});
|
|
844
|
+
return page.metadata?.resourceVersion;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
let ended = false;
|
|
848
|
+
const end = (message?: string) => {
|
|
849
|
+
if (ended) return;
|
|
850
|
+
ended = true;
|
|
851
|
+
if (message !== undefined) opts.onError?.(message);
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
const done = (async () => {
|
|
855
|
+
try {
|
|
856
|
+
let resourceVersion = await listResourceVersion();
|
|
857
|
+
while (!controller.signal.aborted) {
|
|
858
|
+
const query: Record<string, string> = {
|
|
859
|
+
...listQuery,
|
|
860
|
+
watch: "1",
|
|
861
|
+
// Bookmarks are how a quiet watch keeps its resourceVersion fresh
|
|
862
|
+
// without any object changing, which is what keeps a reconnect
|
|
863
|
+
// from re-listing. A cluster that does not support them ignores it.
|
|
864
|
+
allowWatchBookmarks: "true",
|
|
865
|
+
};
|
|
866
|
+
if (resourceVersion) query.resourceVersion = resourceVersion;
|
|
867
|
+
|
|
868
|
+
const { lines } = await openStream(listPath, query, controller.signal, target);
|
|
869
|
+
let expired = false;
|
|
870
|
+
// Read the stream one line at a time, racing each read against the
|
|
871
|
+
// abort. `for await` would not do: a watch that is quiet is parked
|
|
872
|
+
// inside `next()` with nothing to wake it, so a `close()` that
|
|
873
|
+
// waited for the loop to notice would wait for the next event on a
|
|
874
|
+
// cluster that may never send one.
|
|
875
|
+
const iterator = lines[Symbol.asyncIterator]();
|
|
876
|
+
const stopped = new Promise<{ done: true; value: undefined }>((resolve) => {
|
|
877
|
+
const finish = () => resolve({ done: true, value: undefined });
|
|
878
|
+
if (controller.signal.aborted) finish();
|
|
879
|
+
else controller.signal.addEventListener("abort", finish, { once: true });
|
|
880
|
+
});
|
|
881
|
+
for (;;) {
|
|
882
|
+
const next = await Promise.race([iterator.next(), stopped]);
|
|
883
|
+
if (next.done || controller.signal.aborted) break;
|
|
884
|
+
// `lines` already yields whole lines, so every call here consumes
|
|
885
|
+
// its input completely and the carry comes back empty.
|
|
886
|
+
const { frames } = parseWatchFrames(`${next.value}\n`);
|
|
887
|
+
for (const frame of frames) {
|
|
888
|
+
if (isExpiredFrame(frame)) {
|
|
889
|
+
// The one failure a watch is expected to hit. Re-list, never
|
|
890
|
+
// retry the stale version. See ./watch.ts.
|
|
891
|
+
expired = true;
|
|
892
|
+
break;
|
|
893
|
+
}
|
|
894
|
+
const rv = resourceVersionOf(frame);
|
|
895
|
+
if (rv) resourceVersion = rv;
|
|
896
|
+
if (frame.type !== "ERROR") opts.onEvent?.(frame);
|
|
897
|
+
}
|
|
898
|
+
if (expired) break;
|
|
899
|
+
}
|
|
900
|
+
// Not awaited: a generator parked on a read that will never complete
|
|
901
|
+
// would make this the very wait the race above exists to avoid. The
|
|
902
|
+
// underlying connection is closed by the abort, not by this.
|
|
903
|
+
void iterator.return?.(undefined)?.catch?.(() => {});
|
|
904
|
+
if (controller.signal.aborted) break;
|
|
905
|
+
|
|
906
|
+
if (expired) {
|
|
907
|
+
resourceVersion = await listResourceVersion();
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
// The server closed the stream cleanly, which it does every few
|
|
911
|
+
// minutes by design. Reopen from where we left off, after a real
|
|
912
|
+
// timer even at zero delay: a server that closes instantly would
|
|
913
|
+
// otherwise spin this loop through microtasks alone and starve
|
|
914
|
+
// every timer in the process, the abort's included.
|
|
915
|
+
await new Promise((resolve) => {
|
|
916
|
+
const timer = setTimeout(resolve, reopenDelayMs);
|
|
917
|
+
controller.signal.addEventListener("abort", () => { clearTimeout(timer); resolve(undefined); }, { once: true });
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
end();
|
|
921
|
+
} catch (err) {
|
|
922
|
+
// An aborted watch is a closed watch, never a failure to report.
|
|
923
|
+
if (controller.signal.aborted) return end();
|
|
924
|
+
end(err instanceof Error ? err.message : String(err));
|
|
925
|
+
} finally {
|
|
926
|
+
opts.signal?.removeEventListener("abort", stopOnCallerAbort);
|
|
927
|
+
}
|
|
928
|
+
})();
|
|
929
|
+
|
|
930
|
+
return {
|
|
931
|
+
done,
|
|
932
|
+
async close() {
|
|
933
|
+
controller.abort();
|
|
934
|
+
ended = true;
|
|
935
|
+
await done;
|
|
936
|
+
},
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
|
|
709
940
|
async function apply(object: K8sObject, opts: ApplyOptions = {}): Promise<K8sObject> {
|
|
710
941
|
const apiVersion = object.apiVersion;
|
|
711
942
|
const kind = object.kind;
|
|
@@ -837,6 +1068,7 @@ export async function createK8sClient(options: K8sClientOptions = {}): Promise<K
|
|
|
837
1068
|
readIfPresent,
|
|
838
1069
|
list,
|
|
839
1070
|
readLog,
|
|
1071
|
+
watch,
|
|
840
1072
|
apply,
|
|
841
1073
|
delete: remove,
|
|
842
1074
|
concurrently: (items, fn) => mapConcurrent(items, fn, concurrency),
|
package/src/index.ts
CHANGED
|
@@ -23,7 +23,16 @@ export {
|
|
|
23
23
|
selectorText,
|
|
24
24
|
refText,
|
|
25
25
|
} from "./client";
|
|
26
|
-
export type {
|
|
26
|
+
export type {
|
|
27
|
+
K8sClient, ReadOptions, ApplyOptions, DeleteOptions, ListOptions, ReadLogOptions,
|
|
28
|
+
SelfSubjectInfo, WatchOptions, WatchHandle,
|
|
29
|
+
} from "./client";
|
|
30
|
+
|
|
31
|
+
// The watch (chant #1981): frame decoding, the `410 Gone` rule, and the
|
|
32
|
+
// stream-to-lines adapter. The client owns the connection; these are the
|
|
33
|
+
// pieces a consumer or a test may need to name.
|
|
34
|
+
export { parseWatchFrames, isExpiredFrame, resourceVersionOf, streamLines } from "./watch";
|
|
35
|
+
export type { WatchFrame, WatchEventType } from "./watch";
|
|
27
36
|
|
|
28
37
|
export {
|
|
29
38
|
K8sApiError,
|
package/src/testing.ts
CHANGED
|
@@ -32,6 +32,13 @@ export interface FakeResponse {
|
|
|
32
32
|
status?: number;
|
|
33
33
|
body?: unknown;
|
|
34
34
|
headers?: Record<string, string>;
|
|
35
|
+
/**
|
|
36
|
+
* A streaming body, for a watch (chant #1981). Anything async-iterable will
|
|
37
|
+
* do, and {@link fakeWatchStream} is the usual source. Set it and the response
|
|
38
|
+
* exposes `body.stream()`, which is what the client reads instead of
|
|
39
|
+
* `text()` for a request it never expects to complete.
|
|
40
|
+
*/
|
|
41
|
+
stream?: AsyncIterable<string | Uint8Array>;
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
export type FakeRequestHandler = (request: RecordedRequest) => FakeResponse | Promise<FakeResponse>;
|
|
@@ -74,7 +81,10 @@ export function fakeRequestLayer(handler: FakeRequestHandler): FakeRequestLayer
|
|
|
74
81
|
return {
|
|
75
82
|
httpStatusCode: status,
|
|
76
83
|
headers: { "content-type": "application/json", ...(result.headers ?? {}) },
|
|
77
|
-
body: {
|
|
84
|
+
body: {
|
|
85
|
+
text: async () => body,
|
|
86
|
+
...(result.stream ? { stream: () => result.stream } : {}),
|
|
87
|
+
},
|
|
78
88
|
};
|
|
79
89
|
},
|
|
80
90
|
};
|
|
@@ -186,3 +196,77 @@ export function apiResourceList(
|
|
|
186
196
|
})),
|
|
187
197
|
};
|
|
188
198
|
}
|
|
199
|
+
|
|
200
|
+
/** A watch stream a test drives by hand. */
|
|
201
|
+
export interface FakeWatchStream extends AsyncIterable<string> {
|
|
202
|
+
/**
|
|
203
|
+
* Send one NDJSON frame down the stream. Objects are stringified; a string
|
|
204
|
+
* is sent verbatim, which is how a test produces a malformed or half-written
|
|
205
|
+
* frame. A newline is appended unless the string already ends in one.
|
|
206
|
+
*/
|
|
207
|
+
push(frame: unknown): void;
|
|
208
|
+
/** End the stream, as a server closing the connection does. */
|
|
209
|
+
close(): void;
|
|
210
|
+
/** How many frames have been consumed by the reader. */
|
|
211
|
+
readonly delivered: number;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* A controllable NDJSON stream for driving {@link import("./client").K8sClient.watch}
|
|
216
|
+
* against the fake cluster (chant #1981).
|
|
217
|
+
*
|
|
218
|
+
* The client reads it exactly as it reads a live watch: one frame per line,
|
|
219
|
+
* for as long as the connection stays open. So a test can push an ADDED, an
|
|
220
|
+
* expired-`410` ERROR, or nothing at all, and assert on what the client does
|
|
221
|
+
* about it, without a cluster, a socket, or a timer.
|
|
222
|
+
*/
|
|
223
|
+
export function fakeWatchStream(): FakeWatchStream {
|
|
224
|
+
const queued: string[] = [];
|
|
225
|
+
let waiting: (() => void) | undefined;
|
|
226
|
+
let closed = false;
|
|
227
|
+
let delivered = 0;
|
|
228
|
+
|
|
229
|
+
const wake = () => {
|
|
230
|
+
const resume = waiting;
|
|
231
|
+
waiting = undefined;
|
|
232
|
+
resume?.();
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
get delivered() {
|
|
237
|
+
return delivered;
|
|
238
|
+
},
|
|
239
|
+
push(frame: unknown) {
|
|
240
|
+
if (closed) return;
|
|
241
|
+
const text = typeof frame === "string" ? frame : JSON.stringify(frame);
|
|
242
|
+
queued.push(text.endsWith("\n") ? text : `${text}\n`);
|
|
243
|
+
wake();
|
|
244
|
+
},
|
|
245
|
+
close() {
|
|
246
|
+
closed = true;
|
|
247
|
+
wake();
|
|
248
|
+
},
|
|
249
|
+
async *[Symbol.asyncIterator]() {
|
|
250
|
+
for (;;) {
|
|
251
|
+
while (queued.length > 0) {
|
|
252
|
+
delivered++;
|
|
253
|
+
yield queued.shift()!;
|
|
254
|
+
}
|
|
255
|
+
if (closed) return;
|
|
256
|
+
await new Promise<void>((resolve) => {
|
|
257
|
+
waiting = resolve;
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** A watch event frame, the shape the API server sends. */
|
|
265
|
+
export function watchFrame(type: string, object: Record<string, unknown>): Record<string, unknown> {
|
|
266
|
+
return { type, object };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The `410 Gone` frame a watch gets when its resourceVersion has aged out. */
|
|
270
|
+
export function expiredWatchFrame(message = "too old resource version: 1 (5000)"): Record<string, unknown> {
|
|
271
|
+
return watchFrame("ERROR", statusBody(410, "Expired", message));
|
|
272
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -182,5 +182,25 @@ export interface RequestContextLike {
|
|
|
182
182
|
export interface ResponseContextLike {
|
|
183
183
|
httpStatusCode: number;
|
|
184
184
|
headers: Record<string, string>;
|
|
185
|
-
body: {
|
|
185
|
+
body: {
|
|
186
|
+
text(): Promise<string>;
|
|
187
|
+
/**
|
|
188
|
+
* The body as it arrives, rather than once it is complete (chant #1981).
|
|
189
|
+
*
|
|
190
|
+
* `text()` reads a response to completion, which is the right shape for
|
|
191
|
+
* every request this client makes except one: a watch never completes, so
|
|
192
|
+
* a watch read through `text()` is a promise that resolves when the
|
|
193
|
+
* cluster hangs up and never before. client-node's own HTTP library
|
|
194
|
+
* already exposes `stream()` (its `undici` `fetch` response's web
|
|
195
|
+
* `ReadableStream`), so the seam widens rather than being invented.
|
|
196
|
+
*
|
|
197
|
+
* Typed `unknown` for the same reason the rest of this file avoids the
|
|
198
|
+
* library's classes: the decoder (`./watch.ts`'s `streamLines`) accepts a
|
|
199
|
+
* web `ReadableStream`, a Node `Readable`, or any async iterable. Optional
|
|
200
|
+
* because a transport without it is still a valid transport: the watch
|
|
201
|
+
* falls back to `text()`, which is exactly what a fake returning a
|
|
202
|
+
* complete NDJSON body wants.
|
|
203
|
+
*/
|
|
204
|
+
stream?(): unknown;
|
|
205
|
+
};
|
|
186
206
|
}
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The watch (chant #1981): frame decoding on its own, then the whole watch
|
|
3
|
+
* driven against the fake cluster, on the same request layer every other client
|
|
4
|
+
* test uses, so kubeconfig parsing, discovery, auth and URL construction all
|
|
5
|
+
* run for real and only the socket is fake. No cluster, no k3d, no timer past
|
|
6
|
+
* a few milliseconds.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, test, expect } from "vitest";
|
|
10
|
+
import { createK8sClient } from "./client";
|
|
11
|
+
import { isExpiredFrame, parseWatchFrames, resourceVersionOf, streamLines } from "./watch";
|
|
12
|
+
import {
|
|
13
|
+
apiResourceList,
|
|
14
|
+
expiredWatchFrame,
|
|
15
|
+
fakeKubeconfig,
|
|
16
|
+
fakeRequestLayer,
|
|
17
|
+
fakeWatchStream,
|
|
18
|
+
statusBody,
|
|
19
|
+
watchFrame,
|
|
20
|
+
} from "./testing";
|
|
21
|
+
import type { RecordedRequest } from "./testing";
|
|
22
|
+
|
|
23
|
+
const CORE_V1 = apiResourceList("v1", [
|
|
24
|
+
{ name: "configmaps", kind: "ConfigMap" },
|
|
25
|
+
{ name: "namespaces", kind: "Namespace", namespaced: false },
|
|
26
|
+
]);
|
|
27
|
+
const APPS_V1 = apiResourceList("apps/v1", [{ name: "deployments", kind: "Deployment" }]);
|
|
28
|
+
|
|
29
|
+
const DISCOVERY: Record<string, unknown> = {
|
|
30
|
+
"/api": { kind: "APIVersions", versions: ["v1"] },
|
|
31
|
+
"/apis": {
|
|
32
|
+
kind: "APIGroupList",
|
|
33
|
+
groups: [{ name: "apps", preferredVersion: { groupVersion: "apps/v1", version: "v1" }, versions: [{ groupVersion: "apps/v1" }] }],
|
|
34
|
+
},
|
|
35
|
+
"/api/v1": CORE_V1,
|
|
36
|
+
"/apis/apps/v1": APPS_V1,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function deployment(name: string, resourceVersion: string): Record<string, unknown> {
|
|
40
|
+
return {
|
|
41
|
+
apiVersion: "apps/v1",
|
|
42
|
+
kind: "Deployment",
|
|
43
|
+
metadata: { name, namespace: "prod", resourceVersion },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Wait for `predicate`, or give up. Never a bare sleep. */
|
|
48
|
+
async function waitFor(predicate: () => boolean, maxWaitMs = 3_000): Promise<void> {
|
|
49
|
+
const deadline = Date.now() + maxWaitMs;
|
|
50
|
+
while (!predicate() && Date.now() < deadline) {
|
|
51
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A fake cluster that answers discovery, one LIST per call (with an
|
|
57
|
+
* incrementing `resourceVersion`), and hands each watch request the next
|
|
58
|
+
* stream the test queued.
|
|
59
|
+
*/
|
|
60
|
+
function watchableCluster(streams: Array<ReturnType<typeof fakeWatchStream>>) {
|
|
61
|
+
const state = { lists: 0, watches: [] as RecordedRequest[] };
|
|
62
|
+
const layer = fakeRequestLayer((req) => {
|
|
63
|
+
if (req.path in DISCOVERY) return { body: DISCOVERY[req.path] };
|
|
64
|
+
if (req.query.watch === "1") {
|
|
65
|
+
state.watches.push(req);
|
|
66
|
+
const stream = streams.shift();
|
|
67
|
+
if (!stream) return { status: 500, body: statusBody(500, "InternalError", "no stream queued") };
|
|
68
|
+
return { stream };
|
|
69
|
+
}
|
|
70
|
+
if (req.path.endsWith("/deployments") || req.path.endsWith("/configmaps")) {
|
|
71
|
+
state.lists++;
|
|
72
|
+
return { body: { kind: "List", metadata: { resourceVersion: `rv-${state.lists}` }, items: [] } };
|
|
73
|
+
}
|
|
74
|
+
return { status: 404, body: statusBody(404, "NotFound", `${req.path} not found`) };
|
|
75
|
+
});
|
|
76
|
+
return { layer, state };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function watchingClient(layer: ReturnType<typeof fakeRequestLayer>) {
|
|
80
|
+
return createK8sClient({ kubeconfig: fakeKubeconfig(), requestLayer: layer });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe("parseWatchFrames", () => {
|
|
84
|
+
test("decodes whole NDJSON lines and carries the partial one over", () => {
|
|
85
|
+
const first = parseWatchFrames('{"type":"ADDED","object":{"kind":"Pod"}}\n{"type":"MODI');
|
|
86
|
+
expect(first.frames).toHaveLength(1);
|
|
87
|
+
expect(first.frames[0].type).toBe("ADDED");
|
|
88
|
+
expect(first.carry).toBe('{"type":"MODI');
|
|
89
|
+
|
|
90
|
+
const second = parseWatchFrames('FIED","object":{"kind":"Pod"}}\n', first.carry);
|
|
91
|
+
expect(second.frames).toHaveLength(1);
|
|
92
|
+
expect(second.frames[0].type).toBe("MODIFIED");
|
|
93
|
+
expect(second.carry).toBe("");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("drops a line that is not JSON, or JSON without a known type, and keeps going", () => {
|
|
97
|
+
const { frames } = parseWatchFrames(
|
|
98
|
+
['not json at all', '{"type":"WAT","object":{}}', '{"nope":1}', '{"type":"DELETED","object":{}}', ""].join("\n"),
|
|
99
|
+
);
|
|
100
|
+
expect(frames.map((f) => f.type)).toEqual(["DELETED"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("a frame with no object still decodes, rather than throwing", () => {
|
|
104
|
+
const { frames } = parseWatchFrames('{"type":"BOOKMARK"}\n');
|
|
105
|
+
expect(frames).toEqual([{ type: "BOOKMARK", object: {} }]);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("isExpiredFrame / resourceVersionOf", () => {
|
|
110
|
+
test("410 by code, and by reason for a cluster that sends only one", () => {
|
|
111
|
+
expect(isExpiredFrame({ type: "ERROR", object: statusBody(410, "Expired", "too old") })).toBe(true);
|
|
112
|
+
expect(isExpiredFrame({ type: "ERROR", object: { reason: "Gone" } })).toBe(true);
|
|
113
|
+
expect(isExpiredFrame({ type: "ERROR", object: statusBody(500, "InternalError", "boom") })).toBe(false);
|
|
114
|
+
// An ordinary event is never the expiry signal, whatever it carries.
|
|
115
|
+
expect(isExpiredFrame({ type: "MODIFIED", object: { code: 410 } as never })).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("reads the resumable resourceVersion, and nothing when there is none", () => {
|
|
119
|
+
expect(resourceVersionOf({ type: "ADDED", object: deployment("api", "77") })).toBe("77");
|
|
120
|
+
expect(resourceVersionOf({ type: "ADDED", object: { metadata: {} } })).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("streamLines", () => {
|
|
125
|
+
test("reassembles lines split across chunks, from bytes or strings", async () => {
|
|
126
|
+
const encoder = new TextEncoder();
|
|
127
|
+
const chunks = [encoder.encode('{"a":'), encoder.encode("1}\n{\"b\":2}\n"), "tail-without-newline"];
|
|
128
|
+
const source = (async function* () {
|
|
129
|
+
yield* chunks;
|
|
130
|
+
})();
|
|
131
|
+
const lines: string[] = [];
|
|
132
|
+
for await (const line of streamLines(source)) lines.push(line);
|
|
133
|
+
expect(lines).toEqual(['{"a":1}', '{"b":2}', "tail-without-newline"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("a web ReadableStream reads the same way", async () => {
|
|
137
|
+
const encoder = new TextEncoder();
|
|
138
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
139
|
+
start(controller) {
|
|
140
|
+
controller.enqueue(encoder.encode("one\ntw"));
|
|
141
|
+
controller.enqueue(encoder.encode("o\n"));
|
|
142
|
+
controller.close();
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
const lines: string[] = [];
|
|
146
|
+
for await (const line of streamLines(stream)) lines.push(line);
|
|
147
|
+
expect(lines).toEqual(["one", "two"]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("no stream at all yields nothing rather than throwing", async () => {
|
|
151
|
+
const lines: string[] = [];
|
|
152
|
+
for await (const line of streamLines(undefined)) lines.push(line);
|
|
153
|
+
expect(lines).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("K8sClient.watch, against the fake cluster", () => {
|
|
158
|
+
test("LISTs first for a resourceVersion, then opens the watch from it", async () => {
|
|
159
|
+
const stream = fakeWatchStream();
|
|
160
|
+
const { layer, state } = watchableCluster([stream]);
|
|
161
|
+
const c = await watchingClient(layer);
|
|
162
|
+
|
|
163
|
+
const seen: string[] = [];
|
|
164
|
+
const handle = await c.watch(
|
|
165
|
+
{ apiVersion: "apps/v1", kind: "Deployment" },
|
|
166
|
+
{ namespace: "prod", onEvent: (f) => seen.push(f.type) },
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
await waitFor(() => state.watches.length > 0);
|
|
170
|
+
const request = state.watches[0];
|
|
171
|
+
expect(request.path).toBe("/apis/apps/v1/namespaces/prod/deployments");
|
|
172
|
+
expect(request.query.watch).toBe("1");
|
|
173
|
+
expect(request.query.resourceVersion).toBe("rv-1");
|
|
174
|
+
expect(request.query.allowWatchBookmarks).toBe("true");
|
|
175
|
+
// The auth path ran for real, above the seam.
|
|
176
|
+
expect(request.headers.Authorization).toBe("Bearer test-token");
|
|
177
|
+
|
|
178
|
+
stream.push(watchFrame("ADDED", deployment("api", "43")));
|
|
179
|
+
stream.push(watchFrame("MODIFIED", deployment("api", "44")));
|
|
180
|
+
await waitFor(() => seen.length >= 2);
|
|
181
|
+
expect(seen).toEqual(["ADDED", "MODIFIED"]);
|
|
182
|
+
|
|
183
|
+
await handle.close();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("no namespace watches across all of them, and a label selector rides along", async () => {
|
|
187
|
+
const stream = fakeWatchStream();
|
|
188
|
+
const { layer, state } = watchableCluster([stream]);
|
|
189
|
+
const c = await watchingClient(layer);
|
|
190
|
+
const handle = await c.watch(
|
|
191
|
+
{ apiVersion: "apps/v1", kind: "Deployment" },
|
|
192
|
+
{ labelSelector: "app.kubernetes.io/managed-by=chant" },
|
|
193
|
+
);
|
|
194
|
+
await waitFor(() => state.watches.length > 0);
|
|
195
|
+
expect(state.watches[0].path).toBe("/apis/apps/v1/deployments");
|
|
196
|
+
expect(state.watches[0].query.labelSelector).toBe("app.kubernetes.io/managed-by=chant");
|
|
197
|
+
await handle.close();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("a 410 Gone re-LISTs and resumes from the new resourceVersion, never retrying the stale one", async () => {
|
|
201
|
+
const first = fakeWatchStream();
|
|
202
|
+
const second = fakeWatchStream();
|
|
203
|
+
const { layer, state } = watchableCluster([first, second]);
|
|
204
|
+
const c = await watchingClient(layer);
|
|
205
|
+
|
|
206
|
+
const seen: string[] = [];
|
|
207
|
+
const errors: string[] = [];
|
|
208
|
+
const handle = await c.watch(
|
|
209
|
+
{ apiVersion: "apps/v1", kind: "Deployment" },
|
|
210
|
+
{ namespace: "prod", reopenDelayMs: 0, onEvent: (f) => seen.push(f.type), onError: (m) => errors.push(m) },
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
await waitFor(() => state.watches.length >= 1);
|
|
214
|
+
first.push(expiredWatchFrame());
|
|
215
|
+
|
|
216
|
+
await waitFor(() => state.watches.length >= 2);
|
|
217
|
+
expect(state.watches[1].query.resourceVersion).toBe("rv-2"); // a fresh list, not "rv-1"
|
|
218
|
+
expect(state.lists).toBe(2);
|
|
219
|
+
|
|
220
|
+
// And the watch is still live: the expiry was a re-list, not an ending.
|
|
221
|
+
second.push(watchFrame("ADDED", deployment("api", "99")));
|
|
222
|
+
await waitFor(() => seen.length >= 1);
|
|
223
|
+
expect(seen).toEqual(["ADDED"]);
|
|
224
|
+
expect(errors).toEqual([]);
|
|
225
|
+
|
|
226
|
+
await handle.close();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("a stream the server closes cleanly is reopened from the last version seen", async () => {
|
|
230
|
+
const first = fakeWatchStream();
|
|
231
|
+
const second = fakeWatchStream();
|
|
232
|
+
const { layer, state } = watchableCluster([first, second]);
|
|
233
|
+
const c = await watchingClient(layer);
|
|
234
|
+
|
|
235
|
+
const errors: string[] = [];
|
|
236
|
+
const handle = await c.watch(
|
|
237
|
+
{ apiVersion: "apps/v1", kind: "Deployment" },
|
|
238
|
+
{ namespace: "prod", reopenDelayMs: 0, onError: (m) => errors.push(m) },
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
await waitFor(() => state.watches.length >= 1);
|
|
242
|
+
first.push(watchFrame("MODIFIED", deployment("api", "555")));
|
|
243
|
+
await waitFor(() => first.delivered >= 1);
|
|
244
|
+
first.close();
|
|
245
|
+
|
|
246
|
+
await waitFor(() => state.watches.length >= 2);
|
|
247
|
+
// Resumed from the frame, without paying for another LIST.
|
|
248
|
+
expect(state.watches[1].query.resourceVersion).toBe("555");
|
|
249
|
+
expect(state.lists).toBe(1);
|
|
250
|
+
expect(errors).toEqual([]);
|
|
251
|
+
|
|
252
|
+
await handle.close();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("a watch that cannot be opened reports once through onError and ends, never throwing into the caller", async () => {
|
|
256
|
+
const layer = fakeRequestLayer((req) => {
|
|
257
|
+
if (req.path in DISCOVERY) return { body: DISCOVERY[req.path] };
|
|
258
|
+
if (req.query.watch === "1") return { status: 403, body: statusBody(403, "Forbidden", "watch denied") };
|
|
259
|
+
return { body: { kind: "List", metadata: { resourceVersion: "rv-1" }, items: [] } };
|
|
260
|
+
});
|
|
261
|
+
const c = await watchingClient(layer);
|
|
262
|
+
|
|
263
|
+
const errors: string[] = [];
|
|
264
|
+
const handle = await c.watch(
|
|
265
|
+
{ apiVersion: "apps/v1", kind: "Deployment" },
|
|
266
|
+
{ namespace: "prod", onError: (m) => errors.push(m) },
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
await handle.done;
|
|
270
|
+
expect(errors).toHaveLength(1);
|
|
271
|
+
expect(errors[0]).toContain("watch denied");
|
|
272
|
+
await handle.close(); // idempotent
|
|
273
|
+
expect(errors).toHaveLength(1);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("close() stops the watch, reports nothing, and is safe to call twice", async () => {
|
|
277
|
+
const stream = fakeWatchStream();
|
|
278
|
+
const { layer, state } = watchableCluster([stream]);
|
|
279
|
+
const c = await watchingClient(layer);
|
|
280
|
+
|
|
281
|
+
const errors: string[] = [];
|
|
282
|
+
const handle = await c.watch(
|
|
283
|
+
{ apiVersion: "apps/v1", kind: "Deployment" },
|
|
284
|
+
{ namespace: "prod", reopenDelayMs: 0, onError: (m) => errors.push(m) },
|
|
285
|
+
);
|
|
286
|
+
await waitFor(() => state.watches.length >= 1);
|
|
287
|
+
|
|
288
|
+
await handle.close();
|
|
289
|
+
await handle.close();
|
|
290
|
+
await handle.done;
|
|
291
|
+
// A closed watch is not a failed watch.
|
|
292
|
+
expect(errors).toEqual([]);
|
|
293
|
+
|
|
294
|
+
// And nothing reopens after the close.
|
|
295
|
+
const watchesAtClose = state.watches.length;
|
|
296
|
+
stream.close();
|
|
297
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
298
|
+
expect(state.watches.length).toBe(watchesAtClose);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("an aborted caller signal closes the watch without reporting a failure", async () => {
|
|
302
|
+
const stream = fakeWatchStream();
|
|
303
|
+
const { layer, state } = watchableCluster([stream]);
|
|
304
|
+
const c = await watchingClient(layer);
|
|
305
|
+
const controller = new AbortController();
|
|
306
|
+
|
|
307
|
+
const errors: string[] = [];
|
|
308
|
+
const handle = await c.watch(
|
|
309
|
+
{ apiVersion: "apps/v1", kind: "Deployment" },
|
|
310
|
+
{ namespace: "prod", reopenDelayMs: 0, signal: controller.signal, onError: (m) => errors.push(m) },
|
|
311
|
+
);
|
|
312
|
+
await waitFor(() => state.watches.length >= 1);
|
|
313
|
+
|
|
314
|
+
controller.abort();
|
|
315
|
+
await handle.done;
|
|
316
|
+
expect(errors).toEqual([]);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("a kind the cluster's discovery does not serve refuses by name, before any stream", async () => {
|
|
320
|
+
const { layer } = watchableCluster([]);
|
|
321
|
+
const c = await watchingClient(layer);
|
|
322
|
+
await expect(c.watch({ apiVersion: "ray.io/v1", kind: "RayCluster" })).rejects.toThrow(/ray.io\/v1 RayCluster/);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("a cluster-scoped kind watches without a namespace segment", async () => {
|
|
326
|
+
const stream = fakeWatchStream();
|
|
327
|
+
const layer = fakeRequestLayer((req) => {
|
|
328
|
+
if (req.path in DISCOVERY) return { body: DISCOVERY[req.path] };
|
|
329
|
+
if (req.query.watch === "1") return { stream };
|
|
330
|
+
return { body: { kind: "List", metadata: { resourceVersion: "rv-1" }, items: [] } };
|
|
331
|
+
});
|
|
332
|
+
const c = await watchingClient(layer);
|
|
333
|
+
const handle = await c.watch({ apiVersion: "v1", kind: "Namespace" }, { namespace: "ignored" });
|
|
334
|
+
await waitFor(() => layer.requests.some((r) => r.query.watch === "1"));
|
|
335
|
+
expect(layer.requests.find((r) => r.query.watch === "1")!.path).toBe("/api/v1/namespaces");
|
|
336
|
+
await handle.close();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("a transport with no streaming seam falls back to the complete body", async () => {
|
|
340
|
+
// What a fixture returning a canned NDJSON document looks like: no
|
|
341
|
+
// `stream`, just text. The watch reads it as frames and then ends the
|
|
342
|
+
// stream, exactly as a server closing the connection would.
|
|
343
|
+
const ndjson =
|
|
344
|
+
[JSON.stringify(watchFrame("ADDED", deployment("api", "43"))), JSON.stringify(watchFrame("DELETED", deployment("api", "44")))].join("\n") + "\n";
|
|
345
|
+
const held = fakeWatchStream(); // keeps the reopened watch open, as a server does
|
|
346
|
+
let watches = 0;
|
|
347
|
+
const layer = fakeRequestLayer((req) => {
|
|
348
|
+
if (req.path in DISCOVERY) return { body: DISCOVERY[req.path] };
|
|
349
|
+
if (req.query.watch === "1") {
|
|
350
|
+
watches++;
|
|
351
|
+
return watches === 1 ? { body: ndjson } : { stream: held };
|
|
352
|
+
}
|
|
353
|
+
return { body: { kind: "List", metadata: { resourceVersion: "rv-1" }, items: [] } };
|
|
354
|
+
});
|
|
355
|
+
const c = await watchingClient(layer);
|
|
356
|
+
|
|
357
|
+
const seen: string[] = [];
|
|
358
|
+
const handle = await c.watch(
|
|
359
|
+
{ apiVersion: "apps/v1", kind: "Deployment" },
|
|
360
|
+
{ namespace: "prod", reopenDelayMs: 0, onEvent: (f) => seen.push(f.type) },
|
|
361
|
+
);
|
|
362
|
+
await waitFor(() => seen.length >= 2);
|
|
363
|
+
expect(seen).toEqual(["ADDED", "DELETED"]);
|
|
364
|
+
await handle.close();
|
|
365
|
+
});
|
|
366
|
+
});
|
package/src/watch.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Kubernetes watch, as much of it as can be written without a client
|
|
3
|
+
* (chant #1981).
|
|
4
|
+
*
|
|
5
|
+
* A watch is `GET <list path>?watch=1&resourceVersion=<rv>` answered with an
|
|
6
|
+
* open connection that emits one JSON document per line. NDJSON, forever,
|
|
7
|
+
* until somebody hangs up. Everything here is the frame side of that: parsing
|
|
8
|
+
* lines out of a byte stream, reading a frame's `resourceVersion`, and
|
|
9
|
+
* recognising the one failure a watch is expected to hit.
|
|
10
|
+
*
|
|
11
|
+
* ## `410 Gone`
|
|
12
|
+
*
|
|
13
|
+
* The API server keeps a bounded history of changes. A watch resuming from a
|
|
14
|
+
* `resourceVersion` older than that window cannot be told what it missed, and
|
|
15
|
+
* says so: an `ERROR` frame carrying a `Status` with `code: 410` / `reason:
|
|
16
|
+
* Expired`. There is exactly one correct response, and it is not to retry with
|
|
17
|
+
* the same `resourceVersion`. It is to LIST again, take the list's own
|
|
18
|
+
* `resourceVersion`, and watch from there. Anything else silently drops
|
|
19
|
+
* whatever happened during the gap.
|
|
20
|
+
*
|
|
21
|
+
* For chant's purposes the gap costs nothing anyway: the events are a trigger,
|
|
22
|
+
* never a fact, and the tick they wake re-observes the estate from scratch. A
|
|
23
|
+
* re-list is a re-list, not a reconciliation.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { K8sObject } from "./types";
|
|
27
|
+
|
|
28
|
+
/** The event types the watch API emits. `BOOKMARK` requires `allowWatchBookmarks`. */
|
|
29
|
+
export type WatchEventType = "ADDED" | "MODIFIED" | "DELETED" | "BOOKMARK" | "ERROR";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One decoded NDJSON frame. `object` is whatever the server put in the frame:
|
|
33
|
+
* the changed resource for the four ordinary types, a `Status` for `ERROR`.
|
|
34
|
+
*/
|
|
35
|
+
export interface WatchFrame {
|
|
36
|
+
type: WatchEventType;
|
|
37
|
+
object: K8sObject;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const EVENT_TYPES = new Set<string>(["ADDED", "MODIFIED", "DELETED", "BOOKMARK", "ERROR"]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Split a chunk of a watch stream into whole frames, carrying the trailing
|
|
44
|
+
* partial line over to the next call.
|
|
45
|
+
*
|
|
46
|
+
* Frames arrive split across TCP reads at arbitrary byte offsets, so a decoder
|
|
47
|
+
* that treats each chunk as a set of complete lines drops or corrupts every
|
|
48
|
+
* frame that straddles a boundary. `carry` is the fix and the reason this is a
|
|
49
|
+
* function rather than three lines inline.
|
|
50
|
+
*
|
|
51
|
+
* A line that is not JSON, or is JSON without a known `type`, is dropped
|
|
52
|
+
* rather than thrown: a watch is a trigger channel, and the correct response
|
|
53
|
+
* to a frame nobody can read is to keep watching.
|
|
54
|
+
*/
|
|
55
|
+
export function parseWatchFrames(
|
|
56
|
+
chunk: string,
|
|
57
|
+
carry = "",
|
|
58
|
+
): { frames: WatchFrame[]; carry: string } {
|
|
59
|
+
const combined = carry + chunk;
|
|
60
|
+
const lines = combined.split("\n");
|
|
61
|
+
// The last element is either "" (the chunk ended on a newline) or a partial
|
|
62
|
+
// line still waiting for its remainder.
|
|
63
|
+
const rest = lines.pop() ?? "";
|
|
64
|
+
const frames: WatchFrame[] = [];
|
|
65
|
+
|
|
66
|
+
for (const line of lines) {
|
|
67
|
+
const trimmed = line.trim();
|
|
68
|
+
if (!trimmed) continue;
|
|
69
|
+
let parsed: unknown;
|
|
70
|
+
try {
|
|
71
|
+
parsed = JSON.parse(trimmed);
|
|
72
|
+
} catch {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (!parsed || typeof parsed !== "object") continue;
|
|
76
|
+
const { type, object } = parsed as { type?: unknown; object?: unknown };
|
|
77
|
+
if (typeof type !== "string" || !EVENT_TYPES.has(type)) continue;
|
|
78
|
+
frames.push({
|
|
79
|
+
type: type as WatchEventType,
|
|
80
|
+
object: (object && typeof object === "object" ? object : {}) as K8sObject,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { frames, carry: rest };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Whether this frame is the API server saying the watch's `resourceVersion` has
|
|
89
|
+
* aged out of its history: the `410 Gone` that means re-list, not retry.
|
|
90
|
+
*
|
|
91
|
+
* Matched on `code` first and `reason` second, because both are set on the
|
|
92
|
+
* `Status` the server sends and a cluster that sets only one of them is still
|
|
93
|
+
* telling us the same thing.
|
|
94
|
+
*/
|
|
95
|
+
export function isExpiredFrame(frame: WatchFrame): boolean {
|
|
96
|
+
if (frame.type !== "ERROR") return false;
|
|
97
|
+
const status = frame.object as { code?: unknown; reason?: unknown };
|
|
98
|
+
if (status.code === 410) return true;
|
|
99
|
+
return status.reason === "Expired" || status.reason === "Gone";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The `resourceVersion` to resume from after this frame, when it carries one. */
|
|
103
|
+
export function resourceVersionOf(frame: WatchFrame): string | undefined {
|
|
104
|
+
const rv = frame.object?.metadata?.resourceVersion;
|
|
105
|
+
return typeof rv === "string" && rv.length > 0 ? rv : undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Decode whatever a response body's `stream()` returned into lines of text.
|
|
110
|
+
*
|
|
111
|
+
* Three shapes reach here and all three are real: a web `ReadableStream` (what
|
|
112
|
+
* `undici`'s `fetch` gives client-node's HTTP library, and so what a live
|
|
113
|
+
* cluster produces), a Node `Readable` or any other async iterable (what a
|
|
114
|
+
* hand-rolled transport gives), and `undefined` for a transport with no
|
|
115
|
+
* streaming seam at all, which is a fake that answered the whole body at once and
|
|
116
|
+
* is handled by the caller reading `text()` instead.
|
|
117
|
+
*/
|
|
118
|
+
export async function* streamLines(source: unknown): AsyncGenerator<string> {
|
|
119
|
+
const decoder = new TextDecoder();
|
|
120
|
+
let carry = "";
|
|
121
|
+
|
|
122
|
+
const emit = function* (chunk: string): Generator<string> {
|
|
123
|
+
const lines = (carry + chunk).split("\n");
|
|
124
|
+
carry = lines.pop() ?? "";
|
|
125
|
+
for (const line of lines) yield line;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const decode = (value: unknown): string =>
|
|
129
|
+
typeof value === "string" ? value : decoder.decode(value as Uint8Array, { stream: true });
|
|
130
|
+
|
|
131
|
+
if (source && typeof (source as { getReader?: unknown }).getReader === "function") {
|
|
132
|
+
const reader = (source as ReadableStream<Uint8Array>).getReader();
|
|
133
|
+
try {
|
|
134
|
+
for (;;) {
|
|
135
|
+
const { done, value } = await reader.read();
|
|
136
|
+
if (done) break;
|
|
137
|
+
yield* emit(decode(value));
|
|
138
|
+
}
|
|
139
|
+
} finally {
|
|
140
|
+
// Releasing matters: an un-released reader keeps the connection's
|
|
141
|
+
// backpressure machinery alive after the watch is done with it.
|
|
142
|
+
try {
|
|
143
|
+
reader.releaseLock();
|
|
144
|
+
} catch {
|
|
145
|
+
// Already released, or the stream is gone. Either is fine.
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} else if (source && typeof (source as AsyncIterable<unknown>)[Symbol.asyncIterator] === "function") {
|
|
149
|
+
for await (const value of source as AsyncIterable<unknown>) {
|
|
150
|
+
yield* emit(decode(value));
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (carry.trim()) yield carry;
|
|
157
|
+
}
|