diff --git a/docusaurus/docs/extensions/resources-api.md b/docusaurus/docs/extensions/resources-api.md index 6fe00d41863..edabddb72bd 100644 --- a/docusaurus/docs/extensions/resources-api.md +++ b/docusaurus/docs/extensions/resources-api.md @@ -101,3 +101,4 @@ const customResources = await resources.cluster.findFiltered('mycompany.io.custo | :--- | :--- | | [Cluster API](./resources-api/interfaces/ClusterApi) | Interact with cluster-scoped Kubernetes resources (Pods, Deployments, Services, etc.) | | [Management API](./resources-api/interfaces/MgmtApi) | Interact with global Rancher resources (Users, Clusters, Settings, etc.) | +| [Resource Instance API](./resources-api/interfaces/ResourceInstanceApi) | Interact any given Kubernetes resource instance | diff --git a/shell/apis/intf/resources-api/resource-base.ts b/shell/apis/intf/resources-api/resource-base.ts index a589f78601a..fecfd51d78c 100644 --- a/shell/apis/intf/resources-api/resource-base.ts +++ b/shell/apis/intf/resources-api/resource-base.ts @@ -23,6 +23,25 @@ import { */ export type ResourceType = K8SResourceType | string; +/** + * @interface + * Data object for creating a new resource. Must include a `type` property. + * + * @example + * ```ts + * import { useResources, K8S } from '@shell/apis'; + * + * const resources = useResources(); + * + * await resources.cluster.create({ + * type: K8S.CONFIG_MAP, + * metadata: { name: 'my-config', namespace: 'default' }, + * data: { key: 'value' } + * }); + * ``` + */ +export type CreateResourceData = { type: ResourceType } & Record; + /** * @interface * Resources API "findAll" options diff --git a/shell/apis/intf/resources-api/resource-instance.ts b/shell/apis/intf/resources-api/resource-instance.ts new file mode 100644 index 00000000000..b855595b452 --- /dev/null +++ b/shell/apis/intf/resources-api/resource-instance.ts @@ -0,0 +1,96 @@ +import { SteveGetResponse } from '@shell/types/rancher/steve.api'; + +/** + * Instance-level operations available on resources returned by the Resources API. + * + * These methods operate on a specific resource that has already been fetched. + */ +export interface ResourceInstanceApi { + /** + * Applies a partial update to a resource using HTTP PATCH (merge-patch). + * + * Only the fields provided in `data` are sent to the server — the rest of the resource + * remains unchanged. The server response is merged back into this instance. + * + * Requires edit permissions (`canEdit`). + * + * @param data - An object containing only the fields to update. + * @returns a resource instance, updated with the server response. + * + * @example + * ```ts + * import { useResources, K8S } from '@shell/apis'; + * + * const resources = useResources(); + * const configMap = await resources.cluster.find(K8S.CONFIG_MAP, 'default/my-config'); + * + * await configMap.patch({ newKey: 'newValue' }); + * ``` + */ + patch(data: Record): Promise; + + /** + * Performs a full replacement update of a resource using HTTP PUT. + * + * Sends the entire current state of the resource to the server. + * + * Requires edit permissions (`canEdit`). + * + * @returns a resource instance, updated with the server response. + * + * @example + * ```ts + * import { useResources, K8S } from '@shell/apis'; + * + * const resources = useResources(); + * const configMap = await resources.cluster.find(K8S.CONFIG_MAP, 'default/my-config'); + * + * configMap.data.myKey = 'updatedValue'; + * await configMap.update(); + * ``` + */ + update(): Promise; + + /** + * Deletes a resource instance. + * + * Requires delete permissions (`canDelete`). + * + * @returns A promise that resolves when the resource has been deleted. + * + * @example + * ```ts + * import { useResources, K8S } from '@shell/apis'; + * + * const resources = useResources(); + * const pod = await resources.cluster.find(K8S.POD, 'default/my-pod-123'); + * + * await pod.delete(); + * ``` + */ + delete(): Promise; +} + +/** + * Represents a single resource instance returned from the Resources API. + * + * Provides instance-level operations such as deleting or updating a resource instance. + * The resource data (metadata, spec, status, etc.) is accessible directly on the instance. + * + * @template T - The shape of the underlying resource data (defaults to SteveGetResponse) + * + * @example + * ```ts + * import { useResources, K8S } from '@shell/apis'; + * + * const resources = useResources(); + * const pod = await resources.cluster.find(K8S.POD, 'my-pod-123'); + * + * // Access resource data directly + * console.log(pod.metadata.name); + * + * // Use instance operations + * await pod.delete(); + * ``` + */ +export type ResourceInstance = T & ResourceInstanceApi; diff --git a/shell/apis/intf/resources-api/resources-api.ts b/shell/apis/intf/resources-api/resources-api.ts index 34a5a9515f9..575f240ef01 100644 --- a/shell/apis/intf/resources-api/resources-api.ts +++ b/shell/apis/intf/resources-api/resources-api.ts @@ -1,8 +1,9 @@ import { - ResourceType, FindMethodOptions, FindAllMethodOptions, FindFilteredPageOptions, FindFilteredLabelSelectorOptions, + ResourceType, CreateResourceData, FindMethodOptions, FindAllMethodOptions, FindFilteredPageOptions, FindFilteredLabelSelectorOptions, FindFilteredPageResponse, FindFilteredLabelSelectorResponse } from './resource-base'; -import { SteveListResponse, SteveGetResponse } from '@shell/types/rancher/steve.api'; +import { ResourceInstance } from './resource-instance'; +import { SteveListResponse } from '@shell/types/rancher/steve.api'; /** * Base interface for all resource API operations. @@ -24,7 +25,7 @@ export interface ResourcesApi { /** * Finds a specific resource by its type and ID. * - * @template T - The type of the resource (defaults to SteveGetResponse) + * @template T - The type of the resource (defaults to ResourceInstance) * @param resourceType - The type of the resource to find (use **{@link K8S}** constant). See also {@link ResourceType}. * @param resourceId - The unique identifier of the resource to find. If the resource is namespaced, this should be in the format `namespace/name`. * @param options - Optional find arguments @@ -43,7 +44,7 @@ export interface ResourcesApi { * const node = await resources.cluster.find(K8S.NODE, 'worker-1'); * ``` */ - find( + find( resourceType: ResourceType, resourceId: string, options?: FindMethodOptions @@ -54,7 +55,7 @@ export interface ResourcesApi { * * Requires `ui-sql-cache` to be enabled. * - * @template T - The type of the resources (defaults to SteveListResponse) + * @template T - The type of the resources (defaults to ResourceInstance) * @param resourceType - The type of the resources to find (use **{@link K8S}** constant). See also {@link ResourceType}. * @param options - Pagination options with server-side filtering and sorting via the Steve API's pagination cache. See {@link FindFilteredPageOptions}. * @returns Response containing resource items (may be transient if requested, otherwise cached array). @@ -140,4 +141,113 @@ export interface ResourcesApi { resourceType: ResourceType, options?: FindAllMethodOptions ): Promise; + + /** + * Creates a new resource. + * + * The `data` object must include a `type` property identifying the resource type. + * Checks `canCreate` permissions before saving. + * + * @template T - The type of the resource (defaults to ResourceInstance) + * @param data - The resource data to create. Must include a `type` property (use **{@link K8S}** constant). See also {@link CreateResourceData}. + * @returns The created resource instance. + * + * @example + * ```ts + * import { useResources, K8S } from '@shell/apis'; + * + * const resources = useResources(); + * + * const configMap = await resources.cluster.create({ + * type: K8S.CONFIG_MAP, + * metadata: { name: 'my-config', namespace: 'default' }, + * data: { key: 'value' } + * }); + * ``` + */ + create( + data: CreateResourceData + ): Promise; + + /** + * Applies a partial update to a resource using HTTP PATCH (merge-patch). + * + * Only the fields provided in `data` are sent to the server. + * This is a raw HTTP operation — it does not check permissions or update the store cache. + * + * @template T - The type of the response (defaults to ResourceInstance) + * @param resourceType - The type of the resource (use **{@link K8S}** constant). See also {@link ResourceType}. + * @param resourceId - The unique identifier. If namespaced, use `namespace/name` format. + * @param data - An object containing only the fields to update. + * @returns The server response. + * + * @example + * ```ts + * import { useResources, K8S } from '@shell/apis'; + * + * const resources = useResources(); + * + * const result = await resources.cluster.patch(K8S.CONFIG_MAP, 'default/my-config', { + * data: { newKey: 'newValue' } + * }); + * ``` + */ + patch( + resourceType: ResourceType, + resourceId: string, + data: Record + ): Promise; + + /** + * Performs a full replacement update of a resource using HTTP PUT. + * + * Runs `cleanForSave` on the data before sending. + * This is a raw HTTP operation — it does not check permissions or update the store cache. + * + * @template T - The type of the response (defaults to ResourceInstance) + * @param resourceType - The type of the resource (use **{@link K8S}** constant). See also {@link ResourceType}. + * @param resourceId - The unique identifier. If namespaced, use `namespace/name` format. + * @param data - The complete resource data to send as the replacement. + * @returns The server response. + * + * @example + * ```ts + * import { useResources, K8S } from '@shell/apis'; + * + * const resources = useResources(); + * + * const result = await resources.cluster.update(K8S.CONFIG_MAP, 'default/my-config', { + * type: 'configmap', + * metadata: { name: 'my-config', namespace: 'default', resourceVersion: '12345' }, + * data: { key: 'replacedValue' } + * }); + * ``` + */ + update( + resourceType: ResourceType, + resourceId: string, + data: Record + ): Promise; + + /** + * Deletes a resource by type and ID using HTTP DELETE. + * + * This is a raw HTTP operation — it does not check permissions or update the store cache. + * + * @param resourceType - The type of the resource (use **{@link K8S}** constant). See also {@link ResourceType}. + * @param resourceId - The unique identifier. If namespaced, use `namespace/name` format. + * + * @example + * ```ts + * import { useResources, K8S } from '@shell/apis'; + * + * const resources = useResources(); + * + * await resources.cluster.delete(K8S.CONFIG_MAP, 'default/my-config'); + * ``` + */ + delete( + resourceType: ResourceType, + resourceId: string + ): Promise; } diff --git a/shell/apis/intf/resources.ts b/shell/apis/intf/resources.ts index 3b8e4a390f1..42f21f57aa5 100644 --- a/shell/apis/intf/resources.ts +++ b/shell/apis/intf/resources.ts @@ -6,11 +6,13 @@ export * from '@shell/apis/intf/resources-api/resources-api'; export * from '@shell/apis/intf/resources-api/cluster-api'; export * from '@shell/apis/intf/resources-api/mgmt-api'; export { - ResourceType, FindMethodOptions, FindAllMethodOptions, FindFilteredPageOptions, FindFilteredLabelSelectorOptions, + ResourceType, CreateResourceData, FindMethodOptions, FindAllMethodOptions, FindFilteredPageOptions, FindFilteredLabelSelectorOptions, FindFilteredPageResponse, FindFilteredLabelSelectorResponse } from '@shell/apis/intf/resources-api/resource-base'; export * from '@shell/apis/intf/resources-api/resource-constants'; +export * from '@shell/apis/intf/resources-api/resource-instance'; + export { SteveGetResponse, SteveListResponse } from '@shell/types/rancher/steve.api'; export { KubeLabelSelector, KubeLabelSelectorExpression } from '@shell/types/kube/kube-api'; export { diff --git a/shell/apis/resources/__tests__/resource-instance-class.test.ts b/shell/apis/resources/__tests__/resource-instance-class.test.ts new file mode 100644 index 00000000000..b9d8e08e18b --- /dev/null +++ b/shell/apis/resources/__tests__/resource-instance-class.test.ts @@ -0,0 +1,289 @@ +import { + describe, it, expect, jest, beforeEach, afterEach +} from '@jest/globals'; +import { ResourceInstanceImpl } from '../resource-instance-class'; + +describe('resourceInstanceImpl', () => { + let consoleErrorSpy: any; + + beforeEach(() => { + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + function createMockModel(overrides: Record = {}) { + return { + type: 'configmap', + id: 'default/my-config', + metadata: { name: 'my-config', namespace: 'default' }, + spec: {}, + canEdit: true, + canDelete: true, + linkFor: jest.fn().mockImplementation((name: string) => `https://rancher/v1/configmaps/default/my-config`), + $dispatch: jest.fn().mockResolvedValue(undefined), + save: jest.fn().mockResolvedValue(undefined), + remove: jest.fn().mockResolvedValue(undefined), + toJSON: jest.fn().mockReturnValue({ type: 'configmap', id: 'default/my-config' }), + ...overrides, + }; + } + + describe('constructor', () => { + it('should proxy data properties from the model', () => { + // Arrange + const model = createMockModel({ metadata: { name: 'test', namespace: 'default' } }); + + // Act + const instance = new ResourceInstanceImpl(model); + + // Assert + expect(instance.type).toStrictEqual('configmap'); + expect(instance.id).toStrictEqual('default/my-config'); + expect(instance.metadata).toStrictEqual({ name: 'test', namespace: 'default' }); + }); + + it('should make _model non-enumerable', () => { + // Arrange & Act + const instance = new ResourceInstanceImpl(createMockModel()); + const descriptor = Object.getOwnPropertyDescriptor(instance, '_model'); + + // Assert + expect(descriptor).toBeDefined(); + expect(descriptor?.enumerable).toStrictEqual(false); + }); + + it('should not proxy keys starting with underscore', () => { + // Arrange + const model = createMockModel({ _internal: 'secret' }); + + // Act + const instance = new ResourceInstanceImpl(model); + + // Assert + expect(Object.keys(instance)).not.toContain('_internal'); + }); + + it('should not proxy function properties', () => { + // Arrange & Act + const instance = new ResourceInstanceImpl(createMockModel()); + + // Assert + expect(Object.keys(instance)).not.toContain('save'); + expect(Object.keys(instance)).not.toContain('remove'); + expect(Object.keys(instance)).not.toContain('linkFor'); + }); + + it('should reflect model changes through proxied getters', () => { + // Arrange + const model = createMockModel(); + const instance = new ResourceInstanceImpl(model); + + // Act + model.metadata = { name: 'updated', namespace: 'other' }; + + // Assert + expect(instance.metadata).toStrictEqual({ name: 'updated', namespace: 'other' }); + }); + + it('should write through to the model via proxied setters', () => { + // Arrange + const model = createMockModel(); + const instance = new ResourceInstanceImpl(model) as any; + + // Act + instance.metadata = { name: 'changed', namespace: 'new-ns' }; + + // Assert + expect(model.metadata).toStrictEqual({ name: 'changed', namespace: 'new-ns' }); + }); + }); + + describe('patch', () => { + it('should send a PATCH request and load the response into the store', async() => { + // Arrange + const patchResponse = { + type: 'configmap', id: 'default/my-config', kind: 'ConfigMap', data: { key: 'patched' } + }; + const model = createMockModel(); + + model.$dispatch + .mockResolvedValueOnce(patchResponse) + .mockResolvedValueOnce(undefined); + + const instance = new ResourceInstanceImpl(model); + + // Act + const result = await instance.patch({ data: { key: 'patched' } }); + + // Assert + expect(result).toStrictEqual(instance); + expect(model.$dispatch).toHaveBeenCalledWith('request', { + opt: { + url: 'https://rancher/v1/configmaps/default/my-config', + method: 'patch', + headers: { 'content-type': 'application/merge-patch+json' }, + data: { data: { key: 'patched' } }, + }, + type: 'configmap' + }); + expect(model.$dispatch).toHaveBeenCalledWith('load', { + data: patchResponse, + existing: model, + invalidatePageCache: false, + }); + }); + + it('should not call load when response is a Table', async() => { + // Arrange + const tableResponse = { kind: 'Table', rows: [] }; + const model = createMockModel(); + + model.$dispatch.mockResolvedValueOnce(tableResponse); + + const instance = new ResourceInstanceImpl(model); + + // Act + await instance.patch({ data: { key: 'value' } }); + + // Assert + expect(model.$dispatch).toHaveBeenCalledTimes(1); + expect(model.$dispatch).not.toHaveBeenCalledWith('load', expect.anything()); + }); + + it('should use update link first, then fall back to self link', async() => { + // Arrange + const model = createMockModel(); + + model.linkFor.mockImplementation((name: string) => { + if (name === 'update') { + return null; + } + + return 'https://rancher/v1/configmaps/default/my-config'; + }); + model.$dispatch.mockResolvedValueOnce(null); + + const instance = new ResourceInstanceImpl(model); + + // Act + await instance.patch({ data: {} }); + + // Assert + expect(model.linkFor).toHaveBeenCalledWith('update'); + expect(model.linkFor).toHaveBeenCalledWith('self'); + }); + + it('should throw when canEdit is false', async() => { + // Arrange + const model = createMockModel({ canEdit: false }); + const instance = new ResourceInstanceImpl(model); + + // Act & Assert + await expect(instance.patch({ data: {} })).rejects.toThrow( + 'ResourceInstance API error - configmap/default/my-config - Cannot patch: permission denied' + ); + expect(model.$dispatch).not.toHaveBeenCalled(); + }); + }); + + describe('update', () => { + it('should call model.save() and return the instance', async() => { + // Arrange + const model = createMockModel(); + const instance = new ResourceInstanceImpl(model); + + // Act + const result = await instance.update(); + + // Assert + expect(result).toStrictEqual(instance); + expect(model.save).toHaveBeenCalledTimes(1); + }); + + it('should throw when canEdit is false', async() => { + // Arrange + const model = createMockModel({ canEdit: false }); + const instance = new ResourceInstanceImpl(model); + + // Act & Assert + await expect(instance.update()).rejects.toThrow( + 'ResourceInstance API error - configmap/default/my-config - Cannot update: permission denied' + ); + expect(model.save).not.toHaveBeenCalled(); + }); + + it('should propagate errors from model.save()', async() => { + // Arrange + const model = createMockModel(); + + model.save.mockRejectedValue(new Error('Conflict')); + + const instance = new ResourceInstanceImpl(model); + + // Act & Assert + await expect(instance.update()).rejects.toThrow('Conflict'); + }); + }); + + describe('delete', () => { + it('should call model.remove()', async() => { + // Arrange + const model = createMockModel(); + const instance = new ResourceInstanceImpl(model); + + // Act + await instance.delete(); + + // Assert + expect(model.remove).toHaveBeenCalledTimes(1); + }); + + it('should throw when canDelete is false', async() => { + // Arrange + const model = createMockModel({ canDelete: false }); + const instance = new ResourceInstanceImpl(model); + + // Act & Assert + await expect(instance.delete()).rejects.toThrow( + 'ResourceInstance API error - configmap/default/my-config - Cannot delete: permission denied' + ); + expect(model.remove).not.toHaveBeenCalled(); + }); + + it('should propagate errors from model.remove()', async() => { + // Arrange + const model = createMockModel(); + + model.remove.mockRejectedValue(new Error('Not Found')); + + const instance = new ResourceInstanceImpl(model); + + // Act & Assert + await expect(instance.delete()).rejects.toThrow('Not Found'); + }); + }); + + describe('toJSON', () => { + it('should delegate to model.toJSON()', () => { + // Arrange + const jsonData = { + type: 'configmap', id: 'default/my-config', metadata: { name: 'my-config' } + }; + const model = createMockModel(); + + model.toJSON.mockReturnValue(jsonData); + + const instance = new ResourceInstanceImpl(model); + + // Act + const result = instance.toJSON(); + + // Assert + expect(result).toStrictEqual(jsonData); + expect(model.toJSON).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/shell/apis/resources/__tests__/resources-api-class.test.ts b/shell/apis/resources/__tests__/resources-api-class.test.ts index 95db6b87b21..6dcc14a1f96 100644 --- a/shell/apis/resources/__tests__/resources-api-class.test.ts +++ b/shell/apis/resources/__tests__/resources-api-class.test.ts @@ -49,7 +49,7 @@ describe.each(['cluster', 'management'] as const)('resourcesApiClassImpl with st const result = await resourcesApi.find('pod', 'default/test-pod', { watch: true }); // Assert - expect(result).toStrictEqual(mockResource); + expect(result).toMatchObject(mockResource); expect(mockDispatch).toHaveBeenCalledWith(`${ storeType }/find`, { type: 'pod', id: 'default/test-pod', @@ -72,7 +72,7 @@ describe.each(['cluster', 'management'] as const)('resourcesApiClassImpl with st const result = await resourcesApi.find('node', 'worker-1'); // Assert - expect(result).toStrictEqual(mockResource); + expect(result).toMatchObject(mockResource); expect(mockDispatch).toHaveBeenCalledWith(`${ storeType }/find`, { type: 'node', id: 'worker-1', @@ -105,7 +105,7 @@ describe.each(['cluster', 'management'] as const)('resourcesApiClassImpl with st const result = await resourcesApi.find('custom.crd', 'my-resource'); // Assert - expect(result).toStrictEqual(mockResource); + expect(result).toMatchObject(mockResource); expect(mockDispatch).toHaveBeenCalledWith(`${ storeType }/find`, { type: 'custom.crd', id: 'my-resource', @@ -155,7 +155,7 @@ describe.each(['cluster', 'management'] as const)('resourcesApiClassImpl with st const result = await resourcesApi.find('apps.deployment', 'kube-system/test-deployment'); // Assert - expect(result).toStrictEqual(mockDeployment); + expect(result).toMatchObject(mockDeployment); expect(mockDispatch).toHaveBeenCalledWith(`${ storeType }/find`, { type: 'apps.deployment', id: 'kube-system/test-deployment', @@ -179,7 +179,9 @@ describe.each(['cluster', 'management'] as const)('resourcesApiClassImpl with st const result = await resourcesApi.findFiltered('pod', { labelSelector }); // Assert - expect(result).toStrictEqual(mockResources); + expect(result).toHaveLength(2); + expect((result as any[])[0]).toMatchObject(mockResources[0]); + expect((result as any[])[1]).toMatchObject(mockResources[1]); expect(mockDispatch).toHaveBeenCalledWith(`${ storeType }/findLabelSelector`, { type: 'pod', matching: { @@ -303,8 +305,9 @@ describe.each(['cluster', 'management'] as const)('resourcesApiClassImpl with st } as any); // Assert - expect(result).toStrictEqual(transientResponse); expect(result).toHaveProperty('data'); + expect((result as any).data).toHaveLength(1); + expect((result as any).data[0]).toMatchObject({ metadata: { name: 'pod-1' } }); }); it('should find resources with pagination options when enabled', async() => { @@ -328,7 +331,9 @@ describe.each(['cluster', 'management'] as const)('resourcesApiClassImpl with st const result = await resourcesApi.findFiltered('pod', paginationOptions as any); // Assert - expect(result).toStrictEqual(mockResources); + expect(result).toHaveLength(2); + expect((result as any[])[0]).toMatchObject(mockResources[0]); + expect((result as any[])[1]).toMatchObject(mockResources[1]); expect(mockDispatch).toHaveBeenCalledWith(`${ storeType }/findPage`, { type: 'pod', opt: paginationOptions @@ -361,9 +366,10 @@ describe.each(['cluster', 'management'] as const)('resourcesApiClassImpl with st const result = await resourcesApi.findFiltered('pod', paginationOptions as any); // Assert - expect(result).toStrictEqual(transientResponse); expect(result).toHaveProperty('data'); expect(result).toHaveProperty('pagination'); + expect((result as any).data).toHaveLength(1); + expect((result as any).data[0]).toMatchObject({ metadata: { name: 'pod-1' } }); }); it('should throw error when pagination is not enabled', async() => { @@ -442,7 +448,10 @@ describe.each(['cluster', 'management'] as const)('resourcesApiClassImpl with st const result = await resourcesApi.findAll('pod'); // Assert - expect(result).toStrictEqual(mockResources); + expect(result).toHaveLength(3); + expect((result as any[])[0]).toMatchObject(mockResources[0]); + expect((result as any[])[1]).toMatchObject(mockResources[1]); + expect((result as any[])[2]).toMatchObject(mockResources[2]); expect(mockDispatch).toHaveBeenCalledWith(`${ storeType }/findAll`, { type: 'pod', opt: {} @@ -547,4 +556,269 @@ describe.each(['cluster', 'management'] as const)('resourcesApiClassImpl with st expect(mockDispatch).toHaveBeenCalledTimes(1); }); }); + + describe('create', () => { + it('should create a resource when canCreate is true', async() => { + // Arrange + const mockModel = { + canCreate: true, + save: jest.fn().mockResolvedValue(undefined), + type: 'configmap', + metadata: { name: 'my-config', namespace: 'default' }, + }; + + mockDispatch.mockResolvedValue(mockModel); + + // Act + const result = await resourcesApi.create({ + type: 'configmap', + metadata: { name: 'my-config', namespace: 'default' }, + }); + + // Assert + expect(mockDispatch).toHaveBeenCalledWith(`${ storeType }/create`, { + type: 'configmap', + metadata: { name: 'my-config', namespace: 'default' }, + }); + expect(mockModel.save).toHaveBeenCalledTimes(1); + expect(result).toBeDefined(); + }); + + it('should throw when canCreate is false', async() => { + // Arrange + const mockModel = { + canCreate: false, + save: jest.fn(), + }; + + mockDispatch.mockResolvedValue(mockModel); + + // Act & Assert + await expect(resourcesApi.create({ type: 'configmap' })).rejects.toThrow( + `Resource API error - ${ storeType } - Cannot create resource of type "configmap": permission denied` + ); + expect(mockModel.save).not.toHaveBeenCalled(); + }); + + it('should throw error and log when save fails', async() => { + // Arrange + const mockModel = { + canCreate: true, + save: jest.fn().mockRejectedValue(new Error('Save failed')), + }; + + mockDispatch.mockResolvedValue(mockModel); + + // Act & Assert + await expect(resourcesApi.create({ type: 'configmap' })).rejects.toThrow( + `Resource API error - ${ storeType } - Failed to create resource of type "configmap": Save failed` + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + `Resource API error - ${ storeType } - Failed to create resource of type "configmap": Save failed` + ); + }); + }); + + describe('patch', () => { + it('should send a PATCH request with merge-patch content type', async() => { + // Arrange + const mockResponse = { metadata: { name: 'my-config' }, data: { key: 'patched' } }; + + mockSchemaFor.mockReturnValue({ + attributes: { namespaced: true }, + linkFor: () => 'https://rancher/v1/configmaps' + }); + mockDispatch.mockResolvedValue(mockResponse); + + // Act + const result = await resourcesApi.patch('configmap', 'default/my-config', { data: { key: 'patched' } }); + + // Assert + expect(result).toStrictEqual(mockResponse); + expect(mockDispatch).toHaveBeenCalledWith(`${ storeType }/request`, { + opt: { + url: 'https://rancher/v1/configmaps/default/my-config', + method: 'patch', + headers: { 'content-type': 'application/merge-patch+json' }, + data: { data: { key: 'patched' } }, + } + }); + }); + + it('should throw error for namespaced resource without namespace in id', async() => { + // Arrange + mockSchemaFor.mockReturnValue({ attributes: { namespaced: true } }); + + // Act & Assert + await expect(resourcesApi.patch('configmap', 'my-config', {})).rejects.toThrow( + `Resource API error - ${ storeType } - Resource "configmap" is namespaced. The resourceId must be in "namespace/name" format, but received "my-config"` + ); + }); + + it('should throw error when no schema is found', async() => { + // Arrange + mockSchemaFor.mockReturnValue(null); + + // Act & Assert + await expect(resourcesApi.patch('configmap', 'default/my-config', {})).rejects.toThrow( + `Resource API error - ${ storeType } - No schema found for type "configmap"` + ); + }); + + it('should throw error and log when request fails', async() => { + // Arrange + mockSchemaFor.mockReturnValue({ + attributes: { namespaced: false }, + linkFor: () => 'https://rancher/v1/nodes' + }); + mockDispatch.mockRejectedValue(new Error('Network error')); + + // Act & Assert + await expect(resourcesApi.patch('node', 'worker-1', {})).rejects.toThrow( + `Resource API error - ${ storeType } - Failed to patch resource node/worker-1: Network error` + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + `Resource API error - ${ storeType } - Failed to patch resource node/worker-1: Network error` + ); + }); + }); + + describe('update', () => { + it('should send a PUT request with cleanForSave applied', async() => { + // Arrange + const inputData = { + type: 'configmap', + metadata: { name: 'my-config', namespace: 'default' }, + data: { key: 'value' }, + }; + const cleanedData = { ...inputData }; + const mockModel = { cleanForSave: jest.fn().mockReturnValue(cleanedData) }; + const mockResponse = { ...inputData, metadata: { ...inputData.metadata, resourceVersion: '2' } }; + + mockSchemaFor.mockReturnValue({ + attributes: { namespaced: true }, + linkFor: () => 'https://rancher/v1/configmaps' + }); + // First dispatch: create (returns model), second dispatch: request (returns response) + mockDispatch + .mockResolvedValueOnce(mockModel) + .mockResolvedValueOnce(mockResponse); + + // Act + const result = await resourcesApi.update('configmap', 'default/my-config', inputData); + + // Assert + expect(result).toStrictEqual(mockResponse); + expect(mockDispatch).toHaveBeenCalledWith(`${ storeType }/create`, inputData); + expect(mockModel.cleanForSave).toHaveBeenCalledWith({ ...inputData }); + expect(mockDispatch).toHaveBeenCalledWith(`${ storeType }/request`, { + opt: { + url: 'https://rancher/v1/configmaps/default/my-config', + method: 'put', + headers: { 'content-type': 'application/json' }, + data: cleanedData, + } + }); + }); + + it('should throw error for namespaced resource without namespace in id', async() => { + // Arrange + mockSchemaFor.mockReturnValue({ attributes: { namespaced: true } }); + + // Act & Assert + await expect(resourcesApi.update('configmap', 'my-config', {})).rejects.toThrow( + `Resource API error - ${ storeType } - Resource "configmap" is namespaced. The resourceId must be in "namespace/name" format, but received "my-config"` + ); + }); + + it('should throw error and log when request fails', async() => { + // Arrange + const mockModel = { cleanForSave: jest.fn().mockReturnValue({}) }; + + mockSchemaFor.mockReturnValue({ + attributes: { namespaced: false }, + linkFor: () => 'https://rancher/v1/nodes' + }); + mockDispatch + .mockResolvedValueOnce(mockModel) + .mockRejectedValueOnce(new Error('Conflict')); + + // Act & Assert + await expect(resourcesApi.update('node', 'worker-1', {})).rejects.toThrow( + `Resource API error - ${ storeType } - Failed to update resource node/worker-1: Conflict` + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + `Resource API error - ${ storeType } - Failed to update resource node/worker-1: Conflict` + ); + }); + }); + + describe('delete', () => { + it('should send a DELETE request with the correct URL', async() => { + // Arrange + mockSchemaFor.mockReturnValue({ + attributes: { namespaced: true }, + linkFor: () => 'https://rancher/v1/configmaps' + }); + mockDispatch.mockResolvedValue(undefined); + + // Act + await resourcesApi.delete('configmap', 'default/my-config'); + + // Assert + expect(mockDispatch).toHaveBeenCalledWith(`${ storeType }/request`, { + opt: { + url: 'https://rancher/v1/configmaps/default/my-config', + method: 'delete', + } + }); + }); + + it('should work with cluster-scoped resources', async() => { + // Arrange + mockSchemaFor.mockReturnValue({ + attributes: { namespaced: false }, + linkFor: () => 'https://rancher/v1/nodes' + }); + mockDispatch.mockResolvedValue(undefined); + + // Act + await resourcesApi.delete('node', 'worker-1'); + + // Assert + expect(mockDispatch).toHaveBeenCalledWith(`${ storeType }/request`, { + opt: { + url: 'https://rancher/v1/nodes/worker-1', + method: 'delete', + } + }); + }); + + it('should throw error for namespaced resource without namespace in id', async() => { + // Arrange + mockSchemaFor.mockReturnValue({ attributes: { namespaced: true } }); + + // Act & Assert + await expect(resourcesApi.delete('configmap', 'my-config')).rejects.toThrow( + `Resource API error - ${ storeType } - Resource "configmap" is namespaced. The resourceId must be in "namespace/name" format, but received "my-config"` + ); + }); + + it('should throw error and log when request fails', async() => { + // Arrange + mockSchemaFor.mockReturnValue({ + attributes: { namespaced: false }, + linkFor: () => 'https://rancher/v1/nodes' + }); + mockDispatch.mockRejectedValue(new Error('Not Found')); + + // Act & Assert + await expect(resourcesApi.delete('node', 'worker-1')).rejects.toThrow( + `Resource API error - ${ storeType } - Failed to delete resource node/worker-1: Not Found` + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + `Resource API error - ${ storeType } - Failed to delete resource node/worker-1: Not Found` + ); + }); + }); }); diff --git a/shell/apis/resources/resource-instance-class.ts b/shell/apis/resources/resource-instance-class.ts new file mode 100644 index 00000000000..411eaa03b33 --- /dev/null +++ b/shell/apis/resources/resource-instance-class.ts @@ -0,0 +1,138 @@ +/** + * Internal implementation of ResourceInstanceApi. + * + * Wraps a store model instance and exposes a curated set of methods. + * The public interface (ResourceInstanceApi) controls what's visible + * to extension developers via IntelliSense and documentation. + * + */ +export class ResourceInstanceImpl { + /** + * The underlying store model instance (Resource/SteveModel). + * Not exposed on the ResourceInstanceApi interface. + * Defined as non-enumerable in the constructor so that structuredClone and + * Vue's reactive proxy skip it (it contains functions and store references). + */ + declare _model: any; + + private surfaceError(message: string, e?: any): never { + console.error(`ResourceInstance API error - ${ this._model?.type }/${ this._model?.id } - ${ message }`); // eslint-disable-line no-console + throw new Error(`ResourceInstance API error - ${ this._model?.type }/${ this._model?.id } - ${ message }`, { cause: e }); + } + + constructor(model: any) { + // Store the model as non-enumerable so that structuredClone (used by Vue 3 HMR) + // and JSON.stringify skip it — the model contains functions, store dispatch refs, + // and circular references that cannot be cloned or serialized. + Object.defineProperty(this, '_model', { + value: model, + enumerable: false, + writable: true, + configurable: true, + }); + + // Proxy each data property (metadata, spec, status, etc.) from the model onto this + // instance via getter/setter pairs. This lets consumers access resource data directly + // (e.g. pod.metadata.name) while keeping the model as the single source of truth. + // Internal/private keys (prefixed with _) and functions are excluded — only plain + // data properties are surfaced. + const descriptors = Object.getOwnPropertyDescriptors(model); + + for (const key of Object.keys(descriptors)) { + if (key.startsWith('_') || key === 'constructor') { + continue; + } + + if (typeof model[key] !== 'function') { + Object.defineProperty(this, key, { + get: () => this._model[key], + set: (val) => { + this._model[key] = val; + }, + enumerable: true, + configurable: true, + }); + } + } + } + + // ========================================================================= + // Public methods — these satisfy ResourceInstanceApi + // ========================================================================= + + /** + * Applies a partial update to this resource using HTTP PATCH (merge-patch). + * + * Only the fields provided in `data` are sent to the server — the rest of the resource + * remains unchanged. The server response is merged back into this instance. + * + * Requires edit permissions (`canEdit`). + * + * @param data - An object containing only the fields to update. + * @returns This resource instance, updated with the server response. + */ + async patch(data: Record) { + if (!this._model.canEdit) { + this.surfaceError('Cannot patch: permission denied'); + } + + const url = this._model.linkFor('update') || this._model.linkFor('self'); + const res = await this._model.$dispatch('request', { + opt: { + url, + method: 'patch', + headers: { 'content-type': 'application/merge-patch+json' }, + data, + }, + type: this._model.type + }); + + if (res && res.kind !== 'Table') { + await this._model.$dispatch('load', { + data: res, existing: this._model, invalidatePageCache: false + }); + } + + return this; + } + + /** + * Performs a full replacement update of this resource using HTTP PUT. + * + * Sends the entire current state of the resource to the server. + * + * Requires edit permissions (`canEdit`). + * + * @returns This resource instance, updated with the server response. + */ + async update() { + if (!this._model.canEdit) { + this.surfaceError('Cannot update: permission denied'); + } + + await this._model.save(); + + return this; + } + + /** + * Deletes this resource from the cluster. + * + * Requires delete permissions (`canDelete`). + */ + async delete() { + if (!this._model.canDelete) { + this.surfaceError('Cannot delete: permission denied'); + } + + await this._model.remove(); + } + + // ========================================================================= + // Internal methods — available to shell code, NOT on the public interface + // ========================================================================= + + toJSON() { + return typeof this._model.toJSON === 'function' ? this._model.toJSON() : { ...this._model }; + } +} diff --git a/shell/apis/resources/resources-api-class.ts b/shell/apis/resources/resources-api-class.ts index 5188050db7e..c714240a0a5 100644 --- a/shell/apis/resources/resources-api-class.ts +++ b/shell/apis/resources/resources-api-class.ts @@ -1,8 +1,10 @@ import { - ResourceType, FindMethodOptions, FindAllMethodOptions, FindFilteredPageOptions, FindFilteredLabelSelectorOptions, + ResourceType, CreateResourceData, FindMethodOptions, FindAllMethodOptions, FindFilteredPageOptions, FindFilteredLabelSelectorOptions, FindFilteredPageResponse, FindFilteredLabelSelectorResponse } from '@shell/apis/intf/resources-api/resource-base'; +import { ResourceInstance } from '@shell/apis/intf/resources-api/resource-instance'; import { ResourcesApi } from '@shell/apis/intf/resources-api/resources-api'; +import { ResourceInstanceImpl } from '@shell/apis/resources/resource-instance-class'; import { SteveListResponse, SteveGetResponse } from '@shell/types/rancher/steve.api'; import { Store } from 'vuex'; @@ -16,6 +18,20 @@ export class ResourcesApiClassImpl implements ResourcesApi { throw new Error(`Resource API error - ${ this.storeType } - ${ message }`, { cause: e }); } + private resourceUrl(resourceType: ResourceType, resourceId: string): string { + if (this.isNamespaced(resourceType) && !resourceId.includes('/')) { + this.surfaceError(`Resource "${ resourceType }" is namespaced. The resourceId must be in "namespace/name" format, but received "${ resourceId }"`); + } + + const schema = this.store.getters[`${ this.storeType }/schemaFor`]?.(resourceType); + + if (!schema) { + this.surfaceError(`No schema found for type "${ resourceType }"`); + } + + return `${ schema.linkFor('collection') }/${ resourceId }`; + } + private isNamespaced(resourceType: ResourceType): boolean { const schema = this.store.getters[`${ this.storeType }/schemaFor`]?.(resourceType); @@ -65,7 +81,7 @@ export class ResourcesApiClassImpl implements ResourcesApi { opt: options || {} }); - return (resource as T) ?? null; + return resource ? new ResourceInstanceImpl(resource) as T : null; } catch (e: unknown) { this.surfaceError(`Failed to find resource ${ resourceType }/${ resourceId }: ${ (e as Error).message }`, e); } @@ -125,6 +141,14 @@ export class ResourcesApiClassImpl implements ResourcesApi { opt: safeOption }); + if (Array.isArray(response)) { + return response.map((r: any) => new ResourceInstanceImpl(r)) as FindFilteredPageResponse; + } + + if (response?.data) { + response.data = response.data.map((r: any) => new ResourceInstanceImpl(r)); + } + return response as FindFilteredPageResponse; } else if ('labelSelector' in options) { // label selector mode const safeOption = options as FindFilteredLabelSelectorOptions; @@ -138,6 +162,14 @@ export class ResourcesApiClassImpl implements ResourcesApi { opt: rest }); + if (Array.isArray(resources)) { + return resources.map((r: any) => new ResourceInstanceImpl(r)) as FindFilteredLabelSelectorResponse; + } + + if (resources?.data) { + resources.data = resources.data.map((r: any) => new ResourceInstanceImpl(r)); + } + return resources as FindFilteredLabelSelectorResponse; } else { return this.surfaceError('findFiltered request was made with unknown options'); @@ -179,9 +211,178 @@ export class ResourcesApiClassImpl implements ResourcesApi { opt: options || {} }); - return resources as T[]; + return (resources || []).map((r: any) => new ResourceInstanceImpl(r)) as T[]; } catch (e: unknown) { this.surfaceError(`Failed to find all resources ${ resourceType }: ${ (e as Error).message }`, e); } } + + /** + * Creates a new resource. + * + * Classifies the data via the store, checks `canCreate` permissions, and persists via HTTP POST. + * + * @template T - The type of the resource (defaults to ResourceInstance) + * @param data - The resource data to create. Must include a `type` property (use **{@link K8S}** constant). See also {@link CreateResourceData}. + * @returns The created resource instance. + * + * @example + * ```ts + * import { useResources, K8S } from '@shell/apis'; + * + * const resources = useResources(); + * + * const configMap = await resources.cluster.create({ + * type: K8S.CONFIG_MAP, + * metadata: { name: 'my-config', namespace: 'default' }, + * data: { key: 'value' } + * }); + * ``` + */ + async create( + data: CreateResourceData + ): Promise { + try { + const model = await this.store.dispatch(`${ this.storeType }/create`, data); + + if (!model.canCreate) { + this.surfaceError(`Cannot create resource of type "${ data.type }": permission denied`); + } + + await model.save(); + + return new ResourceInstanceImpl(model) as T; + } catch (e: unknown) { + this.surfaceError(`Failed to create resource of type "${ data.type }": ${ (e as Error).message }`, e); + } + } + + /** + * Applies a partial update to a resource using HTTP PATCH (merge-patch). + * + * Only the fields provided in `data` are sent to the server. + * This is a raw HTTP operation — it does not check permissions or update the store cache. + * + * @template T - The type of the response (defaults to ResourceInstance) + * @param resourceType - The type of the resource (use **{@link K8S}** constant). See also {@link ResourceType}. + * @param resourceId - The unique identifier. If namespaced, use `namespace/name` format. + * @param data - An object containing only the fields to update. + * @returns The server response. + * + * @example + * ```ts + * import { useResources, K8S } from '@shell/apis'; + * + * const resources = useResources(); + * + * const result = await resources.cluster.patch(K8S.CONFIG_MAP, 'default/my-config', { + * data: { newKey: 'newValue' } + * }); + * ``` + */ + async patch( + resourceType: ResourceType, + resourceId: string, + data: Record + ): Promise { + try { + const url = this.resourceUrl(resourceType, resourceId); + const res = await this.store.dispatch(`${ this.storeType }/request`, { + opt: { + url, + method: 'patch', + headers: { 'content-type': 'application/merge-patch+json' }, + data, + } + }); + + return res as T; + } catch (e: unknown) { + this.surfaceError(`Failed to patch resource ${ resourceType }/${ resourceId }: ${ (e as Error).message }`, e); + } + } + + /** + * Performs a full replacement update of a resource using HTTP PUT. + * + * Runs `cleanForSave` on the data before sending. + * This is a raw HTTP operation — it does not check permissions or update the store cache. + * + * @template T - The type of the response (defaults to ResourceInstance) + * @param resourceType - The type of the resource (use **{@link K8S}** constant). See also {@link ResourceType}. + * @param resourceId - The unique identifier. If namespaced, use `namespace/name` format. + * @param data - The complete resource data to send as the replacement. + * @returns The server response. + * + * @example + * ```ts + * import { useResources, K8S } from '@shell/apis'; + * + * const resources = useResources(); + * + * const result = await resources.cluster.update(K8S.CONFIG_MAP, 'default/my-config', { + * type: 'configmap', + * metadata: { name: 'my-config', namespace: 'default', resourceVersion: '12345' }, + * data: { key: 'replacedValue' } + * }); + * ``` + */ + async update( + resourceType: ResourceType, + resourceId: string, + data: Record + ): Promise { + try { + const url = this.resourceUrl(resourceType, resourceId); + const model = await this.store.dispatch(`${ this.storeType }/create`, data); + const cleanData = model.cleanForSave({ ...data }); + const res = await this.store.dispatch(`${ this.storeType }/request`, { + opt: { + url, + method: 'put', + headers: { 'content-type': 'application/json' }, + data: cleanData, + } + }); + + return res as T; + } catch (e: unknown) { + this.surfaceError(`Failed to update resource ${ resourceType }/${ resourceId }: ${ (e as Error).message }`, e); + } + } + + /** + * Deletes a resource by type and ID using HTTP DELETE. + * + * This is a raw HTTP operation — it does not check permissions or update the store cache. + * + * @param resourceType - The type of the resource (use **{@link K8S}** constant). See also {@link ResourceType}. + * @param resourceId - The unique identifier. If namespaced, use `namespace/name` format. + * + * @example + * ```ts + * import { useResources, K8S } from '@shell/apis'; + * + * const resources = useResources(); + * + * await resources.cluster.delete(K8S.CONFIG_MAP, 'default/my-config'); + * ``` + */ + async delete( + resourceType: ResourceType, + resourceId: string + ): Promise { + try { + const url = this.resourceUrl(resourceType, resourceId); + + await this.store.dispatch(`${ this.storeType }/request`, { + opt: { + url, + method: 'delete', + } + }); + } catch (e: unknown) { + this.surfaceError(`Failed to delete resource ${ resourceType }/${ resourceId }: ${ (e as Error).message }`, e); + } + } } diff --git a/shell/config/product/explorer.js b/shell/config/product/explorer.js index 11157114bb2..d1021d48828 100644 --- a/shell/config/product/explorer.js +++ b/shell/config/product/explorer.js @@ -19,7 +19,7 @@ import { USER_ID, USERNAME, USER_DISPLAY_NAME, USER_PROVIDER, USER_LAST_LOGIN, USER_DISABLED_IN, USER_DELETED_IN, WORKLOAD_ENDPOINTS, STORAGE_CLASS_DEFAULT, STORAGE_CLASS_PROVISIONER, PERSISTENT_VOLUME_SOURCE, HPA_REFERENCE, MIN_REPLICA, MAX_REPLICA, CURRENT_REPLICA, - ACCESS_KEY, DESCRIPTION, EXPIRES, EXPIRY_STATE, LAST_USED, SUB_TYPE, AGE_NORMAN, SCOPE_NORMAN, PERSISTENT_VOLUME_CLAIM, RECLAIM_POLICY, PV_REASON, WORKLOAD_HEALTH_SCALE, POD_RESTARTS, + ACCESS_KEY, DESCRIPTION, EXPIRES, EXPIRY_STATE, LAST_USED, SUB_TYPE, AGE_NORMAN, SCOPE_NORMAN, PERSISTENT_VOLUME_CLAIM, PERSISTENT_VOLUME_CAPACITY, RECLAIM_POLICY, PV_REASON, WORKLOAD_HEALTH_SCALE, POD_RESTARTS, DURATION, MESSAGE, REASON, EVENT_TYPE, OBJECT, ROLE, ROLES, VERSION, INTERNAL_EXTERNAL_IP, KUBE_NODE_OS, CPU, RAM, SECRET_DATA, EVENT_LAST_SEEN_TIME, EVENT_FIRST_SEEN_TIME, @@ -238,7 +238,7 @@ export function init(store) { configureType(MANAGEMENT.PSA, { localOnly: true }); headers(PV, - [STATE, NAME_COL, RECLAIM_POLICY, PERSISTENT_VOLUME_CLAIM, PERSISTENT_VOLUME_SOURCE, PV_REASON, AGE], + [STATE, NAME_COL, RECLAIM_POLICY, PERSISTENT_VOLUME_CLAIM, PERSISTENT_VOLUME_SOURCE, PERSISTENT_VOLUME_CAPACITY, PV_REASON, AGE], [ STEVE_STATE_COL, STEVE_NAME_COL, @@ -252,6 +252,7 @@ export function init(store) { sort: false, search: false, }, + PERSISTENT_VOLUME_CAPACITY, PV_REASON, STEVE_AGE_COL, ] diff --git a/shell/config/table-headers.js b/shell/config/table-headers.js index 600aa00ef97..a97dc80d02e 100644 --- a/shell/config/table-headers.js +++ b/shell/config/table-headers.js @@ -109,6 +109,13 @@ export const PERSISTENT_VOLUME_SOURCE = { sort: ['provisioner'], }; +export const PERSISTENT_VOLUME_CAPACITY = { + name: 'persistent_volume_capacity', + labelKey: 'tableHeaders.persistentVolumeCapacity', + value: 'spec.capacity.storage', + sort: ['provisioner'], +}; + /** * Link to the PVC associated with PV */ diff --git a/shell/list/persistentvolume.vue b/shell/list/persistentvolume.vue index 86993716b1d..1fcd1627b28 100644 --- a/shell/list/persistentvolume.vue +++ b/shell/list/persistentvolume.vue @@ -5,6 +5,7 @@ import { PVC } from '@shell/config/types'; import { ActionFindPageArgs } from '@shell/types/store/dashboard-store.types'; import { FilterArgs, PaginationFilterField, PaginationParamFilter } from '@shell/types/store/pagination.types'; import { PagTableFetchPageSecondaryResourcesOpts, PagTableFetchSecondaryResourcesOpts, PagTableFetchSecondaryResourcesReturns } from '@shell/types/components/paginatedResourceTable'; +import { K8S } from '@shell/apis'; export default defineComponent({ name: 'ListPersistentVolume', @@ -38,6 +39,32 @@ export default defineComponent({ }, }, + data() { + return { + newPv: undefined as any, + createInstanceData: { + // apiVersion: 'v1', + // kind: 'PersistentVolume', + type: K8S.PV, + metadata: { + name: 'alex-pv-test', + annotations: {}, + labels: {}, + }, + spec: { + accessModes: ['ReadWriteOnce'], + awsElasticBlockStore: { + partition: 0, + readOnly: false, + volumeID: 'aws-dummy-volume-uuid', + }, + capacity: { storage: '10Gi' }, + storageClassName: '', + } + } + }; + }, + methods: { /** * of type PagTableFetchSecondaryResources @@ -72,11 +99,98 @@ export default defineComponent({ return this.$store.dispatch(`cluster/findPage`, { type: PVC, opt }); }, + async createNewInstance() { + const data = await this.$resources.cluster.create(this.createInstanceData); + + this.newPv = data; + + console.error('Created new instance:', data); // eslint-disable-line no-console + }, + async patchInstance() { + const newData = { spec: { capacity: { storage: '111Gi' } } }; + const data = await this.$resources.cluster.patch(K8S.PV, this.newPv.id, newData); + + console.error('PATCH instance via RESOURCES API:', data); // eslint-disable-line no-console + }, + async updateInstance() { + // doesn't work because of the non-enumerable _model property on ResourceInstanceImpl, which causes structuredClone to throw a DataCloneError + // const newData = structuredClone(this.newPv); + const newData = structuredClone(this.newPv.toJSON()); + // or simply + // const newData = JSON.parse(JSON.stringify(this.newPv)); + + newData.spec.capacity.storage = '1222Gi'; + const data = await this.$resources.cluster.update(K8S.PV, this.newPv.id, newData); + + console.error('UPDATE instance via RESOURCES API:', data); // eslint-disable-line no-console + }, + async deleteInstance() { + await this.$resources.cluster.delete(K8S.PV, this.newPv.id); + }, + async patchInstanceApi() { + const newData = { spec: { capacity: { storage: '11Gi' } } }; + const data = await this.newPv.patch(newData); + + console.error('Patched instance via Instance API:', data); // eslint-disable-line no-console + }, + async updateInstanceApi() { + console.error('this.newPv before update:', this.newPv); // eslint-disable-line no-console + this.newPv.spec.capacity.storage = '12Gi'; + const data = await this.newPv.update(); + + console.error('Updated instance via Instance API:', data); // eslint-disable-line no-console + }, + async deleteInstanceApi() { + console.error('this.newPv before delete:', this.newPv); // eslint-disable-line no-console + await this.newPv.delete(); + } } });