Repository: kubernetes-client/javascript
Pull request #965 (2023-01-20 20:00:21)

support fieldSelector for Informer
In order to watch a resource list that is protected by a role on the resource name, we must be able to specify that name as fieldselector both in the request to watch (client-node.makeInformer()) and list (coreV1Api.listNamespaced[Resource]).

This is documented here: https://kubernetes.io/docs/reference/access-authn-authz/rbac/#referring-to-resources
> If you restrict list or watch by resourceName, clients must include a metadata.name field selector in their list or watch request that matches the specified resourceName in order to be authorized.

An example is outlined explained in broader detail in the accompanying PR: https://github.com/kubernetes/website/pull/29468

This is code to verify the desired state:

```javascript
const k8s = require('@kubernetes/client-node');
const fs = require('fs')
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const k8sCoreApi = kc.makeApiClient(k8s.CoreV1Api);
const listFn = () => this.k8sCoreApi.listNamespacedSecret("myNamespace", undefined, undefined, undefined, "metadata.name=mySecret");
const informer = k8s.makeInformer(kc, "/api/v1/namespaces/myNamespace/secrets", listFn, undefined, "metadata.name=mySecret");

informer.on(k8s.ERROR, (err) => {
    console.error(err);
    // winding up with a 403 if fieldSelector is not specified for the makeInformer request
    // the list request is okay though
});

informer.start();
```

Currently this code produces:

```javascript
Error: Forbidden
    at Request.<anonymous> (/app/beb-luigi-core/node_modules/@kubernetes/client-node/dist/watch.js:23:31)
    at Request.emit (events.js:314:20)
    at Request.onRequestResponse (/app/beb-luigi-core/node_modules/request/request.js:1059:10)
    at ClientRequest.emit (events.js:314:20)
    at HTTPParser.parserOnIncomingClient [as onIncoming] (_http_client.js:601:27)
    at HTTPParser.parserOnHeadersComplete (_http_common.js:122:17)
    at TLSSocket.socketOnData (_http_client.js:474:22)
    at TLSSocket.emit (events.js:314:20)
    at addChunk (_stream_readable.js:297:12)
    at readableAddChunk (_stream_readable.js:272:9) {
  statusCode: 403
}
```

When this role is in use:
````
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: secret-role
  namespace: myNamespace
rules:
  - apiGroups: [""]
    resources: ["secrets"]
    resourceNames: [ "mySecret" ]
    verbs: ["watch", "get", "list"]
```


Patch:
diff --git a/src/cache.ts b/src/cache.ts
index c41f44bb..ebd82250 100644
--- a/src/cache.ts
+++ b/src/cache.ts
@@ -38,6 +38,7 @@ export class ListWatch<T extends KubernetesObject> implements ObjectCache<T>, In
         private readonly listFn: ListPromise<T>,
         autoStart: boolean = true,
         private readonly labelSelector?: string,
+        private readonly fieldSelector?: string,
     ) {
         this.callbackCache[ADD] = [];
         this.callbackCache[UPDATE] = [];
@@ -169,10 +170,14 @@ export class ListWatch<T extends KubernetesObject> implements ObjectCache<T>, In
         } as {
             resourceVersion: string | undefined;
             labelSelector: string | undefined;
+            fieldSelector: string | undefined;
         };
         if (this.labelSelector !== undefined) {
             queryParams.labelSelector = ObjectSerializer.serialize(this.labelSelector, 'string');
         }
+        if (this.fieldSelector !== undefined) {
+            queryParams.fieldSelector = ObjectSerializer.serialize(this.fieldSelector, 'string');
+        }
         this.request = await this.watch.watch(
             this.path,
             queryParams,
diff --git a/src/informer.ts b/src/informer.ts
index e322a164..279903e6 100644
--- a/src/informer.ts
+++ b/src/informer.ts
@@ -44,7 +44,8 @@ export function makeInformer<T extends KubernetesObject>(
     path: string,
     listPromiseFn: ListPromise<T>,
     labelSelector?: string,
+    fieldSelector?: string,
 ): Informer<T> & ObjectCache<T> {
     const watch = new Watch(kubeconfig);
-    return new ListWatch<T>(path, watch, listPromiseFn, false, labelSelector);
+    return new ListWatch<T>(path, watch, listPromiseFn, false, labelSelector, fieldSelector);
 }


Test patch:
diff --git a/src/cache_test.ts b/src/cache_test.ts
index e0cacfeb..2f25715e 100644
--- a/src/cache_test.ts
+++ b/src/cache_test.ts
@@ -1438,6 +1438,57 @@ describe('ListWatchCache', () => {
         expect(reqOpts.qs.labelSelector).to.equal(APP_LABEL_SELECTOR);
     });
 
+    it('should send field selector', async () => {
+        const APP_FIELD_SELECTOR = 'metadata.name=name1';
+
+        const list: V1Namespace[] = [
+            {
+                metadata: {
+                    name: 'name1',
+                } as V1ObjectMeta,
+            } as V1Namespace,
+            {
+                metadata: {
+                    name: 'name2',
+                } as V1ObjectMeta,
+            } as V1Namespace,
+        ];
+        const listObj = {
+            metadata: {
+                resourceVersion: '12345',
+            } as V1ListMeta,
+            items: list,
+        } as V1NamespaceList;
+
+        const listFn: ListPromise<V1Namespace> = function (): Promise<{
+            response: http.IncomingMessage;
+            body: V1NamespaceList;
+        }> {
+            return new Promise<{ response: http.IncomingMessage; body: V1NamespaceList }>(
+                (resolve, reject) => {
+                    resolve({ response: {} as http.IncomingMessage, body: listObj });
+                },
+            );
+        };
+
+        const kc = new KubeConfig();
+        Object.assign(kc, fakeConfig);
+        const fakeRequestor = mock.mock(DefaultRequest);
+        const watch = new Watch(kc, mock.instance(fakeRequestor));
+
+        const fakeRequest = new FakeRequest();
+        mock.when(fakeRequestor.webRequest(mock.anything())).thenReturn(fakeRequest);
+
+        const informer = new ListWatch('/some/path', watch, listFn, false, undefined, APP_FIELD_SELECTOR);
+
+        await informer.start();
+
+        mock.verify(fakeRequestor.webRequest(mock.anything()));
+        const [opts] = mock.capture(fakeRequestor.webRequest).last();
+        const reqOpts: request.OptionsWithUri = opts as request.OptionsWithUri;
+        expect(reqOpts.qs.fieldSelector).to.equal(APP_FIELD_SELECTOR);
+    });
+
     it('should ignore request errors after it is aborted', async () => {
         const fakeWatch = mock.mock(Watch);
         const list: V1Pod[] = [];