diff --git a/src/common/k8s-api/__tests__/kube-api.test.ts b/src/common/k8s-api/__tests__/kube-api.test.ts index c63fc306cc..d76518be00 100644 --- a/src/common/k8s-api/__tests__/kube-api.test.ts +++ b/src/common/k8s-api/__tests__/kube-api.test.ts @@ -80,7 +80,7 @@ describe("createKubeApiForRemoteCluster", () => { it("should request pods from default namespace", () => { expect(fetchMock.mock.lastCall).toMatchObject([ - "https://127.0.0.1:6443/api/v1/namespaces/default/pods", + "https://127.0.0.1:6443/api/v1/pods", { headers: { "content-type": "application/json", @@ -93,7 +93,7 @@ describe("createKubeApiForRemoteCluster", () => { describe("when request resolves with data", () => { beforeEach(async () => { await fetchMock.resolveSpecific( - ["https://127.0.0.1:6443/api/v1/namespaces/default/pods"], + ["https://127.0.0.1:6443/api/v1/pods"], new Response(JSON.stringify({ kind: "PodList", apiVersion: "v1", @@ -1495,4 +1495,138 @@ describe("KubeApi", () => { }); }); }); + + describe("listing pods", () => { + let api: PodApi; + + beforeEach(() => { + api = new PodApi({ + request, + }); + }); + + describe("when listing pods with no descriptor", () => { + let listRequest: Promise; + + beforeEach(async () => { + listRequest = api.list(); + + await flushPromises(); + }); + + it("should request that the pods from all namespaces", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/pods", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with empty data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/pods"], + new Response(JSON.stringify({ + kind: "PodList", + apiVersion: "v1", + metadata: {}, + items: [], + })), + ); + }); + + it("the call should resolve to an empty list", async () => { + expect(await listRequest).toEqual([]); + }); + }); + }); + + describe("when listing pods with descriptor with namespace=''", () => { + let listRequest: Promise; + + beforeEach(async () => { + listRequest = api.list({ + namespace: "", + }); + + await flushPromises(); + }); + + it("should request that the pods from all namespaces", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/pods", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with empty data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/pods"], + new Response(JSON.stringify({ + kind: "PodList", + apiVersion: "v1", + metadata: {}, + items: [], + })), + ); + }); + + it("the call should resolve to an empty list", async () => { + expect(await listRequest).toEqual([]); + }); + }); + }); + + describe("when listing pods with descriptor with namespace='default'", () => { + let listRequest: Promise; + + beforeEach(async () => { + listRequest = api.list({ + namespace: "default", + }); + + await flushPromises(); + }); + + it("should request that the pods from just the default namespace", () => { + expect(fetchMock.mock.lastCall).toMatchObject([ + "http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods", + { + headers: { + "content-type": "application/json", + }, + method: "get", + }, + ]); + }); + + describe("when the request resolves with empty data", () => { + beforeEach(async () => { + await fetchMock.resolveSpecific( + ["http://127.0.0.1:9999/api-kube/api/v1/namespaces/default/pods"], + new Response(JSON.stringify({ + kind: "PodList", + apiVersion: "v1", + metadata: {}, + items: [], + })), + ); + }); + + it("the call should resolve to an empty list", async () => { + expect(await listRequest).toEqual([]); + }); + }); + }); + }); }); diff --git a/src/common/k8s-api/kube-api.ts b/src/common/k8s-api/kube-api.ts index d1b65e4af0..0bb7b1669a 100644 --- a/src/common/k8s-api/kube-api.ts +++ b/src/common/k8s-api/kube-api.ts @@ -393,13 +393,33 @@ export class KubeApi< }); } - getUrl({ name, namespace }: Partial = {}, query?: Partial) { + /** + * This method differs from {@link formatUrlForNotListing} because this treats `""` as "all namespaces" + * @param namespace The namespace to list in or `""` for all namespaces + */ + formatUrlForListing(namespace: string) { + return createKubeApiURL({ + apiPrefix: this.apiPrefix, + apiVersion: this.apiVersionWithGroup, + resource: this.apiResource, + namespace: this.isNamespaced + ? namespace ?? "default" + : undefined, + }); + } + + /** + * Format a URL pathname and query for acting upon a specific resource. + */ + formatUrlForNotListing(resource?: Partial, query?: Partial): string; + + formatUrlForNotListing({ name, namespace }: Partial = {}, query?: Partial) { const resourcePath = createKubeApiURL({ apiPrefix: this.apiPrefix, apiVersion: this.apiVersionWithGroup, resource: this.apiResource, namespace: this.isNamespaced - ? namespace ?? "default" // allow `""` to mean all namespaces + ? namespace || "default" : undefined, name, }); @@ -407,6 +427,13 @@ export class KubeApi< return resourcePath + (query ? `?${stringify(this.normalizeQuery(query))}` : ""); } + /** + * @deprecated use {@link formatUrlForNotListing} instead + */ + getUrl(resource?: Partial, query?: Partial) { + return this.formatUrlForNotListing(resource, query); + } + protected normalizeQuery(query: Partial = {}) { if (query.labelSelector) { query.labelSelector = [query.labelSelector].flat().join(","); @@ -484,7 +511,7 @@ export class KubeApi< async list({ namespace = "", reqInit }: KubeApiListOptions = {}, query?: KubeApiQueryParams): Promise { await this.checkPreferredVersion(); - const url = this.getUrl({ namespace }); + const url = this.formatUrlForListing(namespace); const res = await this.request.get(url, { query }, reqInit); const parsed = this.parseResponse(res, namespace); @@ -502,7 +529,7 @@ export class KubeApi< async get(desc: ResourceDescriptor, query?: KubeApiQueryParams): Promise { await this.checkPreferredVersion(); - const url = this.getUrl(desc); + const url = this.formatUrlForNotListing(desc); const res = await this.request.get(url, { query }); const parsed = this.parseResponse(res); @@ -516,7 +543,7 @@ export class KubeApi< async create({ name, namespace }: Partial, partialData?: PartialDeep): Promise { await this.checkPreferredVersion(); - const apiUrl = this.getUrl({ namespace }); + const apiUrl = this.formatUrlForNotListing({ namespace }); const data = merge(partialData, { kind: this.kind, apiVersion: this.apiVersionWithGroup, @@ -537,7 +564,7 @@ export class KubeApi< async update({ name, namespace }: ResourceDescriptor, data: PartialDeep): Promise { await this.checkPreferredVersion(); - const apiUrl = this.getUrl({ namespace, name }); + const apiUrl = this.formatUrlForNotListing({ namespace, name }); const res = await this.request.put(apiUrl, { data: merge(data, { @@ -562,7 +589,7 @@ export class KubeApi< async patch(desc: ResourceDescriptor, data: PartialDeep | Patch, strategy: KubeApiPatchType): Promise; async patch(desc: ResourceDescriptor, data: PartialDeep | Patch, strategy: KubeApiPatchType = "strategic"): Promise { await this.checkPreferredVersion(); - const apiUrl = this.getUrl(desc); + const apiUrl = this.formatUrlForNotListing(desc); const res = await this.request.patch(apiUrl, { data }, { headers: { @@ -580,7 +607,7 @@ export class KubeApi< async delete({ propagationPolicy = "Background", ...desc }: DeleteResourceDescriptor) { await this.checkPreferredVersion(); - const apiUrl = this.getUrl(desc); + const apiUrl = this.formatUrlForNotListing(desc); return this.request.del(apiUrl, { query: { @@ -590,7 +617,7 @@ export class KubeApi< } getWatchUrl(namespace?: string, query: KubeApiQueryParams = {}) { - return this.getUrl({ namespace }, { + return this.formatUrlForNotListing({ namespace }, { watch: 1, resourceVersion: this.getResourceVersion(namespace), ...query,