From 2a214119b51ebb263efcf62da11d44fd1cfaabba Mon Sep 17 00:00:00 2001 From: gnaveen-netapp Date: Mon, 4 May 2026 15:41:51 +0530 Subject: [PATCH] Support ScaleType for Storage Pools, ONTAP mode pools --- GEMINI.md | 17 +- README.md | 4 +- package-lock.json | 8 +- package.json | 2 +- .../handlers/storage-pool-handler.test.ts | 391 +++++++++++++++++- src/tools/handlers/storage-pool-handler.ts | 131 +++++- src/tools/storage-pool-tools.test.ts | 72 +++- src/tools/storage-pool-tools.ts | 36 +- 8 files changed, 627 insertions(+), 34 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index 576986b..2f9dbd4 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -120,8 +120,8 @@ Notes: - Users often type `flex` in lowercase; the server accepts `serviceLevel` case-insensitively for pool creation (for example `flex` or `FLEX`). - Minimum storage pool capacity (this project’s guidance): - `FLEX`: - - `FILE` / `UNIFIED`: **1024 GiB** - - `UNIFIED_LARGE_CAPACITY`: **6 TiB (6144 GiB)** + - `FILE` / `UNIFIED` (default scale): **1024 GiB** + - `UNIFIED` (large capacity): **6 TiB (6144 GiB)** - `STANDARD`, `PREMIUM`, `EXTREME`: **2048 GiB** - Flex custom performance: users can optionally provide `totalThroughputMibps` (MiBps) when creating a **FLEX** pool. This is only supported in select regions; if the API rejects it, suggest using default performance or a supported region/zone. - Manual QoS: `qosType` can be `AUTO` or `MANUAL` for storage pools. Manual QoS is supported for Standard/Premium/Extreme and **isn't available for Flex**. See the Google Cloud docs: `https://docs.cloud.google.com/netapp/volumes/docs/performance/optimize-performance#set_up_manual_qos_limits`. @@ -129,8 +129,17 @@ Notes: - If `location` is a **zone** (e.g. `us-central1-a`), that satisfies “zone in location” for FLEX pool creation and the request body should omit `zone`/`replicaZone`. - If `location` is a **region** (e.g. `us-central1`), FLEX pool creation requires both `zone` and `replicaZone`. - StoragePoolType: - - Users can optionally provide `storagePoolType` (`FILE`, `UNIFIED`, `UNIFIED_LARGE_CAPACITY`). - - `UNIFIED` and `UNIFIED_LARGE_CAPACITY` are only supported for **FLEX** service level. + - Users can optionally provide `storagePoolType` (`FILE`, `UNIFIED`). + - `UNIFIED` is only supported for **FLEX** service level. +- ScaleType: + - `scaleType` is **only applicable to FLEX `UNIFIED` pools**. Do not send it for `FILE` pools or non-FLEX service levels (Standard/Premium/Extreme). + - **Only send `SCALE_TYPE_SCALEOUT`** when the user explicitly wants a large capacity FLEX `UNIFIED` pool. For all other FLEX `UNIFIED` pools, omit `scaleType` entirely — the API defaults to `SCALE_TYPE_DEFAULT`. + - Do not ask the user about `scaleType` unless they are creating a large capacity pool. +- Mode: + - `mode` is optional: `DEFAULT` (standard pool) or `ONTAP` (ONTAP expert mode pool). + - `ONTAP` mode requires `storagePoolType: UNIFIED` and `serviceLevel: FLEX`. + - Do not ask for `mode` unless the user explicitly requests ONTAP expert mode. + - When listing or getting pools, `mode` is returned in the response and indicates whether the pool is a standard or ONTAP expert mode pool. - In simple terms: - **FLEX** is the newer service level focused on flexibility (smaller minimum sizes and, in some regions, more independent performance scaling). It is also available in many more regions. - **STANDARD / PREMIUM / EXTREME** are the classic tiers; Premium and Extreme are higher-performance tiers than Standard. diff --git a/README.md b/README.md index f6aa4c1..391c256 100644 --- a/README.md +++ b/README.md @@ -113,10 +113,12 @@ HTTP endpoint: `http://localhost:/message` **Service level guidance:** -- **FLEX** -- Smaller minimums, broader region availability, independent performance scaling. Minimum: 1024 GiB (FILE/UNIFIED) or 6144 GiB (UNIFIED_LARGE_CAPACITY). +- **FLEX** -- Smaller minimums, broader region availability, independent performance scaling. Minimum: 1024 GiB (FILE/UNIFIED) or 6144 GiB (UNIFIED large capacity). - **STANDARD / PREMIUM / EXTREME** -- Classic tiers with fixed performance-to-capacity ratio. Minimum: 2048 GiB. - `serviceLevel` is accepted case-insensitively (e.g. `flex` or `FLEX`). - FLEX pools in a region-level location require both `zone` and `replicaZone`; zone-level locations satisfy this automatically. +- `storagePoolType` accepts `FILE` or `UNIFIED`; `UNIFIED` is only available for FLEX. +- `scaleType`: only set to `SCALE_TYPE_SCALEOUT` when creating a large capacity FLEX `UNIFIED` pool. Omit for all other pools (defaults to standard capacity). ### Volume Tools diff --git a/package-lock.json b/package-lock.json index a1c4b2b..5c8bd8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@google-cloud/local-auth": "3.0.1", - "@google-cloud/netapp": "^0.16.0", + "@google-cloud/netapp": "^0.18.0", "@modelcontextprotocol/sdk": "^1.20.1", "axios": "^1.6.3", "pino": "^9.5.0", @@ -825,9 +825,9 @@ } }, "node_modules/@google-cloud/netapp": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@google-cloud/netapp/-/netapp-0.16.0.tgz", - "integrity": "sha512-X+7mn/VE/TOmoJWFGN+lX7Wen8q8AYshCLBltFXe86XdlpO4WVYZjqvgTrkFryzpmZMK9Rj0tBOaPIOhYrcg8A==", + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@google-cloud/netapp/-/netapp-0.18.0.tgz", + "integrity": "sha512-Ih2UaLGUBiUXogeEU1467Q0tTLbGCZKpRES5qj/yVLDFPv0byKBwVtgnfWe7Kj/9agiUK6zs9aQhcqqIpJJMwQ==", "license": "Apache-2.0", "dependencies": { "google-gax": "^5.0.0" diff --git a/package.json b/package.json index 84e7c8e..d8f2399 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "vitest": "^3.2.4" }, "dependencies": { - "@google-cloud/netapp": "^0.16.0", + "@google-cloud/netapp": "^0.18.0", "@google-cloud/local-auth": "3.0.1", "@modelcontextprotocol/sdk": "^1.20.1", "axios": "^1.6.3", diff --git a/src/tools/handlers/storage-pool-handler.test.ts b/src/tools/handlers/storage-pool-handler.test.ts index d5f8f47..eca6f47 100644 --- a/src/tools/handlers/storage-pool-handler.test.ts +++ b/src/tools/handlers/storage-pool-handler.test.ts @@ -109,7 +109,189 @@ describe('storage-pool-handler', () => { }); }); - it('createStoragePoolHandler rejects UNIFIED_* storagePoolType for non-FLEX service levels', async () => { + it('createStoragePoolHandler includes scaleType in request payload', async () => { + const createStoragePool = vi.fn().mockResolvedValue([{ name: 'op-create' }]); + createClientMock.mockReturnValue({ createStoragePool }); + + const { createStoragePoolHandler } = await import('./storage-pool-handler.js'); + await createStoragePoolHandler({ + projectId: 'p1', + location: 'us-central1-a', + storagePoolId: 'sp1', + capacityGib: 6144, + serviceLevel: 'FLEX', + network: 'net1', + storagePoolType: 'UNIFIED', + scaleType: 'SCALE_TYPE_SCALEOUT', + }); + + expect(createStoragePool).toHaveBeenCalledTimes(1); + expect(createStoragePool.mock.calls[0]?.[0]).toMatchObject({ + storagePool: expect.objectContaining({ + serviceLevel: 'FLEX', + type: 2, + scaleType: 'SCALE_TYPE_SCALEOUT', + }), + }); + }); + + it('createStoragePoolHandler accepts all valid scaleType values (case-insensitive)', async () => { + const createStoragePool = vi.fn().mockResolvedValue([{ name: 'op-create' }]); + createClientMock.mockReturnValue({ createStoragePool }); + const { createStoragePoolHandler } = await import('./storage-pool-handler.js'); + + const cases: [string, string][] = [ + ['SCALE_TYPE_DEFAULT', 'SCALE_TYPE_DEFAULT'], + ['scale_type_default', 'SCALE_TYPE_DEFAULT'], + ['SCALE_TYPE_SCALEOUT', 'SCALE_TYPE_SCALEOUT'], + ['scale_type_scaleout', 'SCALE_TYPE_SCALEOUT'], + ['SCALE_TYPE_UNSPECIFIED', 'SCALE_TYPE_UNSPECIFIED'], + ]; + for (const [scaleType, expected] of cases) { + createStoragePool.mockClear(); + await createStoragePoolHandler({ + projectId: 'p1', + location: 'us-central1-a', + storagePoolId: 'sp1', + capacityGib: 1024, + serviceLevel: 'FLEX', + storagePoolType: 'UNIFIED', + network: 'net1', + scaleType, + }); + expect(createStoragePool.mock.calls[0]?.[0]).toMatchObject({ + storagePool: expect.objectContaining({ scaleType: expected }), + }); + } + }); + + it('createStoragePoolHandler rejects invalid scaleType', async () => { + const createStoragePool = vi.fn().mockResolvedValue([{ name: 'op-create' }]); + createClientMock.mockReturnValue({ createStoragePool }); + const { createStoragePoolHandler } = await import('./storage-pool-handler.js'); + + const result = await createStoragePoolHandler({ + projectId: 'p1', + location: 'us-central1-a', + storagePoolId: 'sp1', + capacityGib: 1024, + serviceLevel: 'FLEX', + network: 'net1', + scaleType: 'INVALID_SCALE', + }); + + expect((result as any).isError).toBe(true); + expect((result as any).content?.[0]?.text).toContain('scaleType must be one of'); + expect(createStoragePool).not.toHaveBeenCalled(); + }); + + it('createStoragePoolHandler rejects non-string scaleType', async () => { + const createStoragePool = vi.fn().mockResolvedValue([{ name: 'op-create' }]); + createClientMock.mockReturnValue({ createStoragePool }); + const { createStoragePoolHandler } = await import('./storage-pool-handler.js'); + + const result = await createStoragePoolHandler({ + projectId: 'p1', + location: 'us-central1-a', + storagePoolId: 'sp1', + capacityGib: 1024, + serviceLevel: 'FLEX', + network: 'net1', + scaleType: 42, + }); + + expect((result as any).isError).toBe(true); + expect((result as any).content?.[0]?.text).toContain('scaleType must be a string enum name'); + expect(createStoragePool).not.toHaveBeenCalled(); + }); + + it('createStoragePoolHandler omits scaleType from payload when storagePoolType is not UNIFIED and scaleType not provided', async () => { + const createStoragePool = vi.fn().mockResolvedValue([{ name: 'op-create' }]); + createClientMock.mockReturnValue({ createStoragePool }); + const { createStoragePoolHandler } = await import('./storage-pool-handler.js'); + + await createStoragePoolHandler({ + projectId: 'p1', + location: 'us-central1', + storagePoolId: 'sp1', + capacityGib: 2048, + serviceLevel: 'PREMIUM', + network: 'net1', + }); + + const payload = createStoragePool.mock.calls[0]?.[0]?.storagePool; + expect(payload).not.toHaveProperty('scaleType'); + }); + + it('createStoragePoolHandler omits scaleType from payload when UNIFIED pool created without scaleType', async () => { + const createStoragePool = vi.fn().mockResolvedValue([{ name: 'op-create' }]); + createClientMock.mockReturnValue({ createStoragePool }); + const { createStoragePoolHandler } = await import('./storage-pool-handler.js'); + + // No auto-inference — scaleType must be provided explicitly + await createStoragePoolHandler({ + projectId: 'p1', + location: 'us-central1-a', + storagePoolId: 'sp1', + capacityGib: 6144, + serviceLevel: 'FLEX', + network: 'net1', + storagePoolType: 'UNIFIED', + }); + + const payload = createStoragePool.mock.calls[0]?.[0]?.storagePool; + expect(payload).not.toHaveProperty('scaleType'); + }); + + it('createStoragePoolHandler sends SCALE_TYPE_SCALEOUT when explicitly provided for large capacity UNIFIED pool', async () => { + const createStoragePool = vi.fn().mockResolvedValue([{ name: 'op-create' }]); + createClientMock.mockReturnValue({ createStoragePool }); + const { createStoragePoolHandler } = await import('./storage-pool-handler.js'); + + await createStoragePoolHandler({ + projectId: 'p1', + location: 'us-central1-a', + storagePoolId: 'sp1', + capacityGib: 6144, + serviceLevel: 'FLEX', + network: 'net1', + storagePoolType: 'UNIFIED', + scaleType: 'SCALE_TYPE_SCALEOUT', + }); + + expect(createStoragePool.mock.calls[0]?.[0]).toMatchObject({ + storagePool: expect.objectContaining({ + type: 2, + scaleType: 'SCALE_TYPE_SCALEOUT', + }), + }); + }); + + it('createStoragePoolHandler sends SCALE_TYPE_DEFAULT when explicitly provided for standard UNIFIED pool', async () => { + const createStoragePool = vi.fn().mockResolvedValue([{ name: 'op-create' }]); + createClientMock.mockReturnValue({ createStoragePool }); + const { createStoragePoolHandler } = await import('./storage-pool-handler.js'); + + await createStoragePoolHandler({ + projectId: 'p1', + location: 'us-central1-a', + storagePoolId: 'sp1', + capacityGib: 1024, + serviceLevel: 'FLEX', + network: 'net1', + storagePoolType: 'UNIFIED', + scaleType: 'SCALE_TYPE_DEFAULT', + }); + + expect(createStoragePool.mock.calls[0]?.[0]).toMatchObject({ + storagePool: expect.objectContaining({ + type: 2, + scaleType: 'SCALE_TYPE_DEFAULT', + }), + }); + }); + + it('createStoragePoolHandler rejects UNIFIED storagePoolType for non-FLEX service levels', async () => { const createStoragePool = vi.fn().mockResolvedValue([{ name: 'op-create' }]); createClientMock.mockReturnValue({ createStoragePool }); @@ -121,11 +303,157 @@ describe('storage-pool-handler', () => { capacityGib: 100, serviceLevel: 'STANDARD', network: 'net1', - storagePoolType: 'UNIFIED_LARGE_CAPACITY', + storagePoolType: 'UNIFIED', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('storagePoolType UNIFIED is only supported'); + expect(createStoragePool).not.toHaveBeenCalled(); + }); + + it('createStoragePoolHandler rejects ONTAP mode when storagePoolType is not UNIFIED', async () => { + const createStoragePool = vi.fn().mockResolvedValue([{ name: 'op-create' }]); + createClientMock.mockReturnValue({ createStoragePool }); + const { createStoragePoolHandler } = await import('./storage-pool-handler.js'); + + const result = await createStoragePoolHandler({ + projectId: 'p1', + location: 'us-central1-a', + storagePoolId: 'sp1', + capacityGib: 2048, + serviceLevel: 'FLEX', + network: 'net1', + storagePoolType: 'FILE', + mode: 'ONTAP', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('ONTAP mode requires storagePoolType UNIFIED'); + expect(createStoragePool).not.toHaveBeenCalled(); + }); + + it('createStoragePoolHandler rejects ONTAP mode when storagePoolType is not specified', async () => { + const createStoragePool = vi.fn().mockResolvedValue([{ name: 'op-create' }]); + createClientMock.mockReturnValue({ createStoragePool }); + const { createStoragePoolHandler } = await import('./storage-pool-handler.js'); + + const result = await createStoragePoolHandler({ + projectId: 'p1', + location: 'us-central1-a', + storagePoolId: 'sp1', + capacityGib: 2048, + serviceLevel: 'FLEX', + network: 'net1', + mode: 'ONTAP', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('storagePoolType was not specified'); + expect(createStoragePool).not.toHaveBeenCalled(); + }); + + it('createStoragePoolHandler rejects ONTAP mode when serviceLevel is not FLEX', async () => { + const createStoragePool = vi.fn().mockResolvedValue([{ name: 'op-create' }]); + createClientMock.mockReturnValue({ createStoragePool }); + const { createStoragePoolHandler } = await import('./storage-pool-handler.js'); + + const result = await createStoragePoolHandler({ + projectId: 'p1', + location: 'us-central1', + storagePoolId: 'sp1', + capacityGib: 2048, + serviceLevel: 'STANDARD', + network: 'net1', + storagePoolType: 'UNIFIED', + mode: 'ONTAP', }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('storagePoolType UNIFIED and UNIFIED_LARGE_CAPACITY'); + expect(result.content[0].text).toContain('ONTAP mode requires serviceLevel FLEX'); + expect(createStoragePool).not.toHaveBeenCalled(); + }); + + it('createStoragePoolHandler includes mode in request payload for ONTAP pool', async () => { + const createStoragePool = vi.fn().mockResolvedValue([{ name: 'op-create' }]); + createClientMock.mockReturnValue({ createStoragePool }); + const { createStoragePoolHandler } = await import('./storage-pool-handler.js'); + + await createStoragePoolHandler({ + projectId: 'p1', + location: 'us-central1-a', + storagePoolId: 'sp1', + capacityGib: 2048, + serviceLevel: 'FLEX', + network: 'net1', + storagePoolType: 'UNIFIED', + mode: 'ONTAP', + }); + + expect(createStoragePool.mock.calls[0]?.[0]).toMatchObject({ + storagePool: expect.objectContaining({ type: 2, mode: 'ONTAP' }), + }); + }); + + it('createStoragePoolHandler accepts lowercase mode and normalizes to uppercase', async () => { + const createStoragePool = vi.fn().mockResolvedValue([{ name: 'op-create' }]); + createClientMock.mockReturnValue({ createStoragePool }); + const { createStoragePoolHandler } = await import('./storage-pool-handler.js'); + + await createStoragePoolHandler({ + projectId: 'p1', + location: 'us-central1-a', + storagePoolId: 'sp1', + capacityGib: 2048, + serviceLevel: 'FLEX', + network: 'net1', + storagePoolType: 'UNIFIED', + mode: 'ontap', + }); + + expect(createStoragePool.mock.calls[0]?.[0]).toMatchObject({ + storagePool: expect.objectContaining({ mode: 'ONTAP' }), + }); + }); + + it('createStoragePoolHandler rejects invalid string mode value', async () => { + const createStoragePool = vi.fn().mockResolvedValue([{ name: 'op-create' }]); + createClientMock.mockReturnValue({ createStoragePool }); + const { createStoragePoolHandler } = await import('./storage-pool-handler.js'); + + const result = await createStoragePoolHandler({ + projectId: 'p1', + location: 'us-central1', + storagePoolId: 'sp1', + capacityGib: 1024, + serviceLevel: 'FLEX', + storagePoolType: 'UNIFIED', + network: 'net1', + mode: 'INVALID', + }); + + expect((result as any).isError).toBe(true); + expect(result.content[0].text).toContain('mode must be DEFAULT or ONTAP'); + expect(createStoragePool).not.toHaveBeenCalled(); + }); + + it('createStoragePoolHandler rejects non-string mode value', async () => { + const createStoragePool = vi.fn().mockResolvedValue([{ name: 'op-create' }]); + createClientMock.mockReturnValue({ createStoragePool }); + const { createStoragePoolHandler } = await import('./storage-pool-handler.js'); + + const result = await createStoragePoolHandler({ + projectId: 'p1', + location: 'us-central1', + storagePoolId: 'sp1', + capacityGib: 1024, + serviceLevel: 'FLEX', + storagePoolType: 'UNIFIED', + network: 'net1', + mode: 123, + }); + + expect((result as any).isError).toBe(true); + expect(result.content[0].text).toContain('mode must be a string'); expect(createStoragePool).not.toHaveBeenCalled(); }); @@ -611,6 +939,27 @@ describe('storage-pool-handler', () => { expect((result.structuredContent as any).createTime).toBeInstanceOf(Date); }); + it('getStoragePoolHandler includes mode in structured output', async () => { + const getStoragePool = vi.fn().mockResolvedValue([ + { + name: 'projects/p1/locations/us-central1/storagePools/ontap-pool', + capacityGib: '2048', + serviceLevel: 'FLEX', + mode: 'ONTAP', + }, + ]); + createClientMock.mockReturnValue({ getStoragePool }); + + const { getStoragePoolHandler } = await import('./storage-pool-handler.js'); + const result = await getStoragePoolHandler({ + projectId: 'p1', + location: 'us-central1', + storagePoolId: 'ontap-pool', + }); + + expect((result.structuredContent as any).mode).toBe('ONTAP'); + }); + it('getStoragePoolHandler covers error path', async () => { const getStoragePool = vi.fn().mockRejectedValue(new Error('boom')); createClientMock.mockReturnValue({ getStoragePool }); @@ -799,6 +1148,38 @@ describe('storage-pool-handler', () => { expect((result.structuredContent as any).storagePools[0].createTime).toBeInstanceOf(Date); }); + it('listStoragePoolsHandler includes mode in structured output for ONTAP and DEFAULT pools', async () => { + const listStoragePools = vi.fn().mockResolvedValue([ + [ + { + name: 'projects/p1/locations/us-central1/storagePools/ontap-pool', + capacityGib: '2048', + mode: 'ONTAP', + }, + { + name: 'projects/p1/locations/us-central1/storagePools/default-pool', + capacityGib: '1024', + mode: 'DEFAULT', + }, + { + name: 'projects/p1/locations/us-central1/storagePools/old-pool', + capacityGib: '512', + }, + ], + undefined, + undefined, + ]); + createClientMock.mockReturnValue({ listStoragePools }); + + const { listStoragePoolsHandler } = await import('./storage-pool-handler.js'); + const result = await listStoragePoolsHandler({ projectId: 'p1', location: 'us-central1' }); + const pools = (result.structuredContent as any).storagePools; + + expect(pools[0]).toMatchObject({ storagePoolId: 'ontap-pool', mode: 'ONTAP' }); + expect(pools[1]).toMatchObject({ storagePoolId: 'default-pool', mode: 'DEFAULT' }); + expect(pools[2].mode).toBeUndefined(); + }); + it('listStoragePoolsHandler covers error path', async () => { const listStoragePools = vi.fn().mockRejectedValue(new Error('boom')); createClientMock.mockReturnValue({ listStoragePools }); @@ -927,9 +1308,7 @@ describe('storage-pool-handler', () => { storagePoolType: 'UNIFIED', }); expect((nonFlex as any).isError).toBe(true); - expect((nonFlex as any).content?.[0]?.text).toContain( - 'UNIFIED and UNIFIED_LARGE_CAPACITY are only supported' - ); + expect((nonFlex as any).content?.[0]?.text).toContain('UNIFIED is only supported'); }); it('updateStoragePoolHandler supports updating zone and replicaZone', async () => { diff --git a/src/tools/handlers/storage-pool-handler.ts b/src/tools/handlers/storage-pool-handler.ts index 90f202e..457015d 100644 --- a/src/tools/handlers/storage-pool-handler.ts +++ b/src/tools/handlers/storage-pool-handler.ts @@ -9,14 +9,13 @@ function parseStoragePoolType(input: any): { value?: number; error?: string } { STORAGE_POOL_TYPE_UNSPECIFIED: 0, FILE: 1, UNIFIED: 2, - UNIFIED_LARGE_CAPACITY: 3, }; if (input === undefined || input === null) return {}; if (typeof input === 'number') { if (Object.values(enumMap).includes(input)) return { value: input }; - return { error: 'storagePoolType must be a valid enum number (0-3)' }; + return { error: 'storagePoolType must be a valid enum number (0-2)' }; } if (typeof input === 'string') { @@ -24,13 +23,46 @@ function parseStoragePoolType(input: any): { value?: number; error?: string } { if (enumMap[trimmed] !== undefined) return { value: enumMap[trimmed] }; return { error: - 'storagePoolType must be one of STORAGE_POOL_TYPE_UNSPECIFIED, FILE, UNIFIED, UNIFIED_LARGE_CAPACITY, or the corresponding enum number', + 'storagePoolType must be one of STORAGE_POOL_TYPE_UNSPECIFIED, FILE, UNIFIED, or the corresponding enum number', }; } return { error: 'storagePoolType must be a string enum name or enum number' }; } +function parseMode(input: any): { value?: string; error?: string } { + if (input === undefined || input === null) return {}; + + if (typeof input === 'string') { + const trimmed = input.trim().toUpperCase(); + if (trimmed === 'DEFAULT' || trimmed === 'ONTAP') return { value: trimmed }; + return { error: 'mode must be DEFAULT or ONTAP' }; + } + + return { error: 'mode must be a string' }; +} + +function parseScaleType(input: any): { value?: string; error?: string } { + const validValues = new Set([ + 'SCALE_TYPE_UNSPECIFIED', + 'SCALE_TYPE_DEFAULT', + 'SCALE_TYPE_SCALEOUT', + ]); + + if (input === undefined || input === null) return {}; + + if (typeof input === 'string') { + const trimmed = input.trim().toUpperCase(); + if (validValues.has(trimmed)) return { value: trimmed }; + return { + error: + 'scaleType must be one of SCALE_TYPE_UNSPECIFIED, SCALE_TYPE_DEFAULT, SCALE_TYPE_SCALEOUT', + }; + } + + return { error: 'scaleType must be a string enum name' }; +} + function normalizeStoragePoolState(state: any): string { return typeof state === 'string' ? state : 'UNKNOWN'; } @@ -61,6 +93,8 @@ export const createStoragePoolHandler: ToolHandler = async (args: { [key: string qosType, allowAutoTiering, storagePoolType, + scaleType, + mode, zone, replicaZone, } = args; @@ -91,10 +125,85 @@ export const createStoragePoolHandler: ToolHandler = async (args: { [key: string }; } - // New pool types (UNIFIED / UNIFIED_LARGE_CAPACITY) are only available for FLEX + const { value: parsedScaleType, error: scaleTypeError } = parseScaleType(scaleType); + if (scaleTypeError) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: `Error creating storage pool: ${scaleTypeError}`, + }, + ], + }; + } + + // scaleType is only applicable to FLEX UNIFIED pools + if (parsedScaleType !== undefined && normalizedServiceLevel !== 'FLEX') { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: 'Error creating storage pool: scaleType is only applicable to FLEX UNIFIED storage pools.', + }, + ], + }; + } + if (parsedScaleType !== undefined && parsedStoragePoolType !== 2) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: 'Error creating storage pool: scaleType requires storagePoolType to be explicitly set to UNIFIED.', + }, + ], + }; + } + + // ONTAP mode requires storagePoolType UNIFIED and serviceLevel FLEX + const { value: parsedMode, error: modeError } = parseMode(mode); + if (modeError) { + return { + isError: true, + content: [{ type: 'text' as const, text: `Error creating storage pool: ${modeError}` }], + }; + } + + if (parsedMode === 'ONTAP' && parsedStoragePoolType !== 2) { + const hint = + parsedStoragePoolType === undefined + ? 'storagePoolType was not specified' + : `storagePoolType is ${storagePoolType}`; + return { + isError: true, + content: [ + { + type: 'text' as const, + text: + `Error creating storage pool: ONTAP mode requires storagePoolType UNIFIED, but ${hint}. ` + + 'Please specify storagePoolType as UNIFIED.', + }, + ], + }; + } + if (parsedMode === 'ONTAP' && normalizedServiceLevel !== 'FLEX') { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: 'Error creating storage pool: ONTAP mode requires serviceLevel FLEX.', + }, + ], + }; + } + + // UNIFIED is only available for FLEX if ( parsedStoragePoolType !== undefined && - (parsedStoragePoolType === 2 || parsedStoragePoolType === 3) && + parsedStoragePoolType === 2 && normalizedServiceLevel !== 'FLEX' ) { return { @@ -102,7 +211,7 @@ export const createStoragePoolHandler: ToolHandler = async (args: { [key: string content: [ { type: 'text' as const, - text: 'Error creating storage pool: storagePoolType UNIFIED and UNIFIED_LARGE_CAPACITY are only supported when serviceLevel is FLEX.', + text: 'Error creating storage pool: storagePoolType UNIFIED is only supported when serviceLevel is FLEX.', }, ], }; @@ -184,6 +293,8 @@ export const createStoragePoolHandler: ToolHandler = async (args: { [key: string if (normalizedQosType) storagePoolPayload.qosType = normalizedQosType; if (allowAutoTiering !== undefined) storagePoolPayload.allowAutoTiering = allowAutoTiering; if (parsedStoragePoolType !== undefined) storagePoolPayload.type = parsedStoragePoolType; + if (parsedScaleType !== undefined) storagePoolPayload.scaleType = parsedScaleType; + if (parsedMode !== undefined) storagePoolPayload.mode = parsedMode; if (normalizedServiceLevel === 'FLEX') { // IMPORTANT: For zonal pools, the zone is encoded in the URL/location already; @@ -329,6 +440,7 @@ export const getStoragePoolHandler: ToolHandler = async (args: { [key: string]: qosType: storagePool.qosType, allowAutoTiering: storagePool.allowAutoTiering ?? false, storagePoolType: storagePool.type, + mode: (storagePool as any).mode, zone: storagePool.zone, replicaZone: storagePool.replicaZone, }; @@ -418,6 +530,7 @@ export const listStoragePoolsHandler: ToolHandler = async (args: { [key: string] qosType: pool.qosType, allowAutoTiering: pool.allowAutoTiering ?? false, storagePoolType: pool.type, + mode: pool.mode, zone: pool.zone, replicaZone: pool.replicaZone, }; @@ -540,8 +653,8 @@ export const updateStoragePoolHandler: ToolHandler = async (args: { [key: string }; } - // Only enforce FLEX for new types; FILE is allowed everywhere (and is the historical default) - if (parsedType === 2 || parsedType === 3) { + // Only enforce FLEX for UNIFIED; FILE is allowed everywhere (and is the historical default) + if (parsedType === 2) { const existing = await getExistingPool(); const existingServiceLevel = typeof existing?.serviceLevel === 'string' @@ -553,7 +666,7 @@ export const updateStoragePoolHandler: ToolHandler = async (args: { [key: string content: [ { type: 'text' as const, - text: 'Error updating storage pool: storagePoolType UNIFIED and UNIFIED_LARGE_CAPACITY are only supported when serviceLevel is FLEX.', + text: 'Error updating storage pool: storagePoolType UNIFIED is only supported when serviceLevel is FLEX.', }, ], }; diff --git a/src/tools/storage-pool-tools.test.ts b/src/tools/storage-pool-tools.test.ts index 7ce4d66..3b37117 100644 --- a/src/tools/storage-pool-tools.test.ts +++ b/src/tools/storage-pool-tools.test.ts @@ -42,7 +42,7 @@ describe('storage-pool-tools', () => { ).not.toThrow(); }); - it('createStoragePoolTool accepts storagePoolType values (validation is enforced in handler)', () => { + it('createStoragePoolTool accepts valid storagePoolType values and rejects removed UNIFIED_LARGE_CAPACITY', () => { const schema = z.object(createStoragePoolTool.inputSchema); expect(() => @@ -52,7 +52,7 @@ describe('storage-pool-tools', () => { storagePoolId: 'sp1', capacityGib: 100, serviceLevel: 'FLEX', - storagePoolType: 'UNIFIED_LARGE_CAPACITY', + storagePoolType: 'UNIFIED', }) ).not.toThrow(); @@ -66,6 +66,74 @@ describe('storage-pool-tools', () => { storagePoolType: 'FILE', }) ).not.toThrow(); + + // Negative: UNIFIED_LARGE_CAPACITY was removed from the enum; schema must reject it + expect(() => + schema.parse({ + projectId: 'p1', + location: 'us-central1', + storagePoolId: 'sp1', + capacityGib: 100, + serviceLevel: 'FLEX', + storagePoolType: 'UNIFIED_LARGE_CAPACITY', + }) + ).toThrow(); + }); + + it('createStoragePoolTool accepts scaleType values', () => { + const schema = z.object(createStoragePoolTool.inputSchema); + + for (const scaleType of [ + 'SCALE_TYPE_UNSPECIFIED', + 'SCALE_TYPE_DEFAULT', + 'SCALE_TYPE_SCALEOUT', + ]) { + expect(() => + schema.parse({ + projectId: 'p1', + location: 'us-central1', + storagePoolId: 'sp1', + capacityGib: 100, + serviceLevel: 'FLEX', + storagePoolType: 'UNIFIED', + scaleType, + }) + ).not.toThrow(); + } + + expect(() => + schema.parse({ + projectId: 'p1', + location: 'us-central1', + storagePoolId: 'sp1', + capacityGib: 100, + serviceLevel: 'FLEX', + storagePoolType: 'UNIFIED', + scaleType: 'INVALID_SCALE_TYPE', + }) + ).toThrow(); + }); + + it('createStoragePoolTool accepts DEFAULT and ONTAP mode (case-insensitive) and rejects other values', () => { + const schema = z.object(createStoragePoolTool.inputSchema); + + const base = { + projectId: 'p1', + location: 'us-central1', + storagePoolId: 'sp1', + capacityGib: 100, + serviceLevel: 'FLEX', + storagePoolType: 'UNIFIED', + network: 'net1', + }; + + for (const mode of ['DEFAULT', 'ONTAP', 'default', 'ontap']) { + expect(() => schema.parse({ ...base, mode })).not.toThrow(); + } + + for (const mode of ['INVALID', 'FILE', 123]) { + expect(() => schema.parse({ ...base, mode })).toThrow(); + } }); it('updateStoragePoolTool accepts totalThroughputMibps input', () => { diff --git a/src/tools/storage-pool-tools.ts b/src/tools/storage-pool-tools.ts index bbd5f67..30aa6df 100644 --- a/src/tools/storage-pool-tools.ts +++ b/src/tools/storage-pool-tools.ts @@ -77,13 +77,28 @@ export const createStoragePoolTool: ToolConfig = { ), storagePoolType: z .union([ - z.enum(['STORAGE_POOL_TYPE_UNSPECIFIED', 'FILE', 'UNIFIED', 'UNIFIED_LARGE_CAPACITY']), - z.enum(['storage_pool_type_unspecified', 'file', 'unified', 'unified_large_capacity']), + z.enum(['STORAGE_POOL_TYPE_UNSPECIFIED', 'FILE', 'UNIFIED']), + z.enum(['storage_pool_type_unspecified', 'file', 'unified']), z.number(), ]) .optional() .describe( - 'Storage pool type (StoragePoolType). UNIFIED and UNIFIED_LARGE_CAPACITY are only available for FLEX service level.' + 'Storage pool type (StoragePoolType). UNIFIED is only available for FLEX service level.' + ), + scaleType: z + .union([ + z.enum(['SCALE_TYPE_UNSPECIFIED', 'SCALE_TYPE_DEFAULT', 'SCALE_TYPE_SCALEOUT']), + z.enum(['scale_type_unspecified', 'scale_type_default', 'scale_type_scaleout']), + ]) + .optional() + .describe( + 'Scale type for a FLEX UNIFIED storage pool. Only send SCALE_TYPE_SCALEOUT when creating a large capacity pool; omit this field for all other FLEX UNIFIED pools.' + ), + mode: z + .union([z.enum(['DEFAULT', 'ONTAP']), z.enum(['default', 'ontap'])]) + .optional() + .describe( + 'Mode of the storage pool. DEFAULT for standard pools, ONTAP for ONTAP expert mode pools. ONTAP mode requires storagePoolType UNIFIED and serviceLevel FLEX.' ), }, outputSchema: { @@ -157,6 +172,7 @@ export const getStoragePoolTool: ToolConfig = { .union([z.string(), z.number()]) .optional() .describe('Storage pool type (StoragePoolType)'), + mode: z.string().optional().describe('Mode of the storage pool (DEFAULT or ONTAP)'), zone: z.string().optional().describe('Zone for the storage pool'), replicaZone: z.string().optional().describe('Replica zone for the storage pool'), }, @@ -174,7 +190,12 @@ export const listStoragePoolsTool: ToolConfig = { .string() .optional() .describe('The location to list storage pools from; omit or use "-" for all locations'), - filter: z.string().optional().describe('Filter expression for filtering results'), + filter: z + .string() + .optional() + .describe( + 'Optional filter expression, e.g. mode="ONTAP", state="READY", or mode="ONTAP" AND state="READY".' + ), pageSize: z.number().optional().describe('The maximum number of storage pools to return'), pageToken: z.string().optional().describe('Page token from a previous list request'), }, @@ -223,6 +244,7 @@ export const listStoragePoolsTool: ToolConfig = { .union([z.string(), z.number()]) .optional() .describe('Storage pool type (StoragePoolType)'), + mode: z.string().optional().describe('Mode of the storage pool (DEFAULT or ONTAP)'), zone: z.string().optional().describe('Zone for the storage pool'), replicaZone: z.string().optional().describe('Replica zone for the storage pool'), }) @@ -259,13 +281,13 @@ export const updateStoragePoolTool: ToolConfig = { ), storagePoolType: z .union([ - z.enum(['STORAGE_POOL_TYPE_UNSPECIFIED', 'FILE', 'UNIFIED', 'UNIFIED_LARGE_CAPACITY']), - z.enum(['storage_pool_type_unspecified', 'file', 'unified', 'unified_large_capacity']), + z.enum(['STORAGE_POOL_TYPE_UNSPECIFIED', 'FILE', 'UNIFIED']), + z.enum(['storage_pool_type_unspecified', 'file', 'unified']), z.number(), ]) .optional() .describe( - 'Storage pool type (StoragePoolType). UNIFIED and UNIFIED_LARGE_CAPACITY are only available for FLEX service level.' + 'Storage pool type (StoragePoolType). UNIFIED is only available for FLEX service level.' ), zone: z .string()