diff --git a/GEMINI.md b/GEMINI.md index 1429ef6..576986b 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -160,6 +160,16 @@ Notes: - Volume: set `tieringPolicy` on the volume (for example `tierAction: ENABLED`, optional `coolingThresholdDays`, optional `hotTierBypassModeEnabled`). - Hybrid replication: set `hybridReplicationParameters` on the volume (for example `replicationSchedule: HOURLY` and `hybridReplicationType: CONTINUOUS_REPLICATION`) along with peer details (cluster/SVM/IPs). - Large capacity volumes: set `largeCapacity: true` (Premium/Extreme only; minimum 15 TiB) and optionally `multipleEndpoints: true` for multiple storage endpoints. See the volume limits and overview docs. +- SMB attributes (only when `protocols` includes `SMB`): + - Boolean shortcuts on `gcnv_volume_create`: + - `smbEncryptData: true` → SMB encryption (`ENCRYPT_DATA`) + - `smbHideShare: true` → hidden / non-browsable share (`NON_BROWSABLE`) + - `smbAccessBasedEnumeration: true` → access-based enumeration (`ACCESS_BASED_ENUMERATION`) — controls the visibility of files and folders based on the permissions assigned to the user + - `smbContinuouslyAvailable: true` → CA share for SQL Server / FSLogix (`CONTINUOUSLY_AVAILABLE`); **permanent** on the volume. + - Advanced: `smbSettings: ["OPLOCKS", ...]` accepts raw API enum names and is merged with the booleans above. Do not pass `SMB_SETTINGS_UNSPECIFIED`. `BROWSABLE` together with `NON_BROWSABLE` (or `BROWSABLE` with `smbHideShare`) are rejected. `NON_BROWSABLE` and `CONTINUOUSLY_AVAILABLE` together (or `smbHideShare` with `smbContinuouslyAvailable`) are invalid — CA shares must be browsable. + - Any SMB flag without `protocols: ["SMB", ...]` is rejected. + - `CONTINUOUSLY_AVAILABLE` is **not supported on FLEX storage pools** — the server rejects the request before calling the API. Tell users to use STANDARD / PREMIUM / EXTREME for CA shares. + - Confirm CA explicitly with the user before creating, since it cannot be turned off later. ### Snapshots diff --git a/README.md b/README.md index c014126..f6aa4c1 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,16 @@ HTTP endpoint: `http://localhost:/message` **Large capacity volumes:** Set `largeCapacity: true` (Premium/Extreme only, minimum 15 TiB). Optional `multipleEndpoints: true`. +**SMB attributes:** When `protocols` includes `SMB`, `gcnv_volume_create` accepts optional SMB feature flags that map to the `smbSettings` field on the volume: + +- `smbEncryptData: true` → `ENCRYPT_DATA` (require SMB encryption in flight) +- `smbHideShare: true` → `NON_BROWSABLE` (hide the share from browse lists) +- `smbAccessBasedEnumeration: true` → `ACCESS_BASED_ENUMERATION` (ABE) — controls the visibility of files and folders based on the permissions assigned to the user +- `smbContinuouslyAvailable: true` → `CONTINUOUSLY_AVAILABLE` (CA share for SQL Server / FSLogix; choice is permanent on the volume) +- `smbSettings: ["OPLOCKS", ...]` — additional API enum values; merged with the booleans above. Do not pass `SMB_SETTINGS_UNSPECIFIED`; `BROWSABLE` and `NON_BROWSABLE` (or `BROWSABLE` together with `smbHideShare`) are rejected. `NON_BROWSABLE` and `CONTINUOUSLY_AVAILABLE` together (or `smbHideShare` with `smbContinuouslyAvailable`) are also rejected — CA shares must be browsable. + +These flags require `protocols` to include `SMB`. `CONTINUOUSLY_AVAILABLE` is **not supported on `FLEX` storage pools** — the request is rejected before reaching the API. Use `STANDARD`, `PREMIUM`, or `EXTREME` for CA shares. + ### Snapshot Tools | Tool | Description | diff --git a/src/tools/handlers/volume-handler.test.ts b/src/tools/handlers/volume-handler.test.ts index 21505a2..1d0e88c 100644 --- a/src/tools/handlers/volume-handler.test.ts +++ b/src/tools/handlers/volume-handler.test.ts @@ -122,6 +122,265 @@ describe('volume-handler', () => { }); }); + it('createVolumeHandler passes smbSettings when SMB flags are set', async () => { + const createVolume = vi.fn().mockResolvedValue([{ name: 'operations/op-smb' }]); + createClientMock.mockReturnValue({ createVolume }); + + const { createVolumeHandler } = await import('./volume-handler.js'); + await createVolumeHandler({ + projectId: 'p1', + location: 'us-central1', + storagePoolId: 'sp1', + volumeId: 'volsmb2', + capacityGib: 10, + protocols: ['SMB'], + smbEncryptData: true, + smbHideShare: true, + smbAccessBasedEnumeration: true, + }); + + expect(createVolume.mock.calls[0]?.[0]?.volume?.storagePool).toBe('sp1'); + expect(createVolume.mock.calls[0]?.[0]?.volume?.smbSettings).toEqual( + expect.arrayContaining(['ENCRYPT_DATA', 'NON_BROWSABLE', 'ACCESS_BASED_ENUMERATION']) + ); + expect(createVolume.mock.calls[0]?.[0]?.volume?.smbSettings).toHaveLength(3); + }); + + it('createVolumeHandler merges smbSettings array with boolean flags', async () => { + const createVolume = vi.fn().mockResolvedValue([{ name: 'operations/op-smb2' }]); + createClientMock.mockReturnValue({ createVolume }); + + const { createVolumeHandler } = await import('./volume-handler.js'); + await createVolumeHandler({ + projectId: 'p1', + location: 'us-central1', + storagePoolId: 'sp1', + volumeId: 'v1', + capacityGib: 10, + protocols: ['SMB'], + smbSettings: ['OPLOCKS'], + smbEncryptData: true, + }); + + const smb = createVolume.mock.calls[0]?.[0]?.volume?.smbSettings as string[]; + expect(smb).toEqual(expect.arrayContaining(['OPLOCKS', 'ENCRYPT_DATA'])); + expect(smb).toHaveLength(2); + }); + + it('createVolumeHandler rejects smb flags when protocols omit SMB', async () => { + const createVolume = vi.fn(); + createClientMock.mockReturnValue({ createVolume }); + + const { createVolumeHandler } = await import('./volume-handler.js'); + const result = await createVolumeHandler({ + projectId: 'p1', + location: 'us-central1', + storagePoolId: 'sp1', + volumeId: 'v1', + capacityGib: 10, + protocols: ['NFSV3'], + smbEncryptData: true, + }); + + expect(createVolume).not.toHaveBeenCalled(); + expect(result.isError).toBe(true); + expect((result.content?.[0] as any)?.text).toMatch(/SMB/); + }); + + it('createVolumeHandler rejects invalid smbSettings entries', async () => { + const createVolume = vi.fn(); + createClientMock.mockReturnValue({ createVolume }); + + const { createVolumeHandler } = await import('./volume-handler.js'); + const result = await createVolumeHandler({ + projectId: 'p1', + location: 'us-central1', + storagePoolId: 'sp1', + volumeId: 'v1', + capacityGib: 10, + protocols: ['SMB'], + smbSettings: ['NOT_A_REAL_SETTING'], + }); + + expect(createVolume).not.toHaveBeenCalled(); + expect(result.isError).toBe(true); + }); + + it('createVolumeHandler rejects smbSettings containing only SMB_SETTINGS_UNSPECIFIED', async () => { + const createVolume = vi.fn(); + createClientMock.mockReturnValue({ createVolume }); + + const { createVolumeHandler } = await import('./volume-handler.js'); + const result = await createVolumeHandler({ + projectId: 'p1', + location: 'us-central1', + storagePoolId: 'sp1', + volumeId: 'v1', + capacityGib: 10, + protocols: ['SMB'], + smbSettings: ['SMB_SETTINGS_UNSPECIFIED'], + }); + + expect(createVolume).not.toHaveBeenCalled(); + expect(result.isError).toBe(true); + expect((result.content?.[0] as any)?.text).toMatch(/SMB_SETTINGS_UNSPECIFIED/); + }); + + it('createVolumeHandler rejects conflicting BROWSABLE and NON_BROWSABLE', async () => { + const createVolume = vi.fn(); + createClientMock.mockReturnValue({ createVolume }); + + const { createVolumeHandler } = await import('./volume-handler.js'); + const both = await createVolumeHandler({ + projectId: 'p1', + location: 'us-central1', + storagePoolId: 'sp1', + volumeId: 'v1', + capacityGib: 10, + protocols: ['SMB'], + smbSettings: ['BROWSABLE', 'NON_BROWSABLE'], + }); + expect(both.isError).toBe(true); + expect(createVolume).not.toHaveBeenCalled(); + + const hideWithBrowse = await createVolumeHandler({ + projectId: 'p1', + location: 'us-central1', + storagePoolId: 'sp1', + volumeId: 'v2', + capacityGib: 10, + protocols: ['SMB'], + smbSettings: ['BROWSABLE'], + smbHideShare: true, + }); + expect(hideWithBrowse.isError).toBe(true); + expect(createVolume).not.toHaveBeenCalled(); + }); + + it('createVolumeHandler rejects NON_BROWSABLE + CONTINUOUSLY_AVAILABLE', async () => { + const createVolume = vi.fn(); + const getStoragePool = vi.fn(); + createClientMock.mockReturnValue({ createVolume, getStoragePool }); + + const { createVolumeHandler } = await import('./volume-handler.js'); + + // Both as enum values in smbSettings. + const bothEnum = await createVolumeHandler({ + projectId: 'p1', + location: 'us-central1', + storagePoolId: 'sp1', + volumeId: 'v1', + capacityGib: 10, + protocols: ['SMB'], + smbSettings: ['NON_BROWSABLE', 'CONTINUOUSLY_AVAILABLE'], + }); + expect(bothEnum.isError).toBe(true); + expect((bothEnum.content?.[0] as any)?.text).toMatch(/NON_BROWSABLE/); + expect((bothEnum.content?.[0] as any)?.text).toMatch(/CONTINUOUSLY_AVAILABLE/); + expect(getStoragePool).not.toHaveBeenCalled(); + expect(createVolume).not.toHaveBeenCalled(); + + // Both as boolean flags. + const bothFlags = await createVolumeHandler({ + projectId: 'p1', + location: 'us-central1', + storagePoolId: 'sp1', + volumeId: 'v2', + capacityGib: 10, + protocols: ['SMB'], + smbHideShare: true, + smbContinuouslyAvailable: true, + }); + expect(bothFlags.isError).toBe(true); + expect(getStoragePool).not.toHaveBeenCalled(); + expect(createVolume).not.toHaveBeenCalled(); + + // Mixed: flag + enum. + const mixed = await createVolumeHandler({ + projectId: 'p1', + location: 'us-central1', + storagePoolId: 'sp1', + volumeId: 'v3', + capacityGib: 10, + protocols: ['SMB'], + smbHideShare: true, + smbSettings: ['CONTINUOUSLY_AVAILABLE'], + }); + expect(mixed.isError).toBe(true); + expect(getStoragePool).not.toHaveBeenCalled(); + expect(createVolume).not.toHaveBeenCalled(); + }); + + it('createVolumeHandler rejects CONTINUOUSLY_AVAILABLE on FLEX pools (boolean flag)', async () => { + const createVolume = vi.fn(); + const getStoragePool = vi.fn().mockResolvedValue([{ serviceLevel: 'FLEX' }]); + createClientMock.mockReturnValue({ createVolume, getStoragePool }); + + const { createVolumeHandler } = await import('./volume-handler.js'); + const result = await createVolumeHandler({ + projectId: 'p1', + location: 'us-central1', + storagePoolId: 'pool-flex', + volumeId: 'v1', + capacityGib: 10, + protocols: ['SMB'], + smbContinuouslyAvailable: true, + }); + + expect(getStoragePool).toHaveBeenCalledWith({ + name: 'projects/p1/locations/us-central1/storagePools/pool-flex', + }); + expect(createVolume).not.toHaveBeenCalled(); + expect(result.isError).toBe(true); + expect((result.content?.[0] as any)?.text).toMatch(/FLEX/); + }); + + it('createVolumeHandler rejects CONTINUOUSLY_AVAILABLE on FLEX pools (smbSettings array)', async () => { + const createVolume = vi.fn(); + const getStoragePool = vi.fn().mockResolvedValue([{ serviceLevel: 'flex' }]); + createClientMock.mockReturnValue({ createVolume, getStoragePool }); + + const { createVolumeHandler } = await import('./volume-handler.js'); + const result = await createVolumeHandler({ + projectId: 'p1', + location: 'us-central1', + storagePoolId: 'projects/p1/locations/us-central1/storagePools/p1', + volumeId: 'v1', + capacityGib: 10, + protocols: ['SMB'], + smbSettings: ['CONTINUOUSLY_AVAILABLE'], + }); + + expect(getStoragePool).toHaveBeenCalledWith({ + name: 'projects/p1/locations/us-central1/storagePools/p1', + }); + expect(createVolume).not.toHaveBeenCalled(); + expect(result.isError).toBe(true); + }); + + it('createVolumeHandler passes CONTINUOUSLY_AVAILABLE when pool is not FLEX', async () => { + const createVolume = vi.fn().mockResolvedValue([{ name: 'operations/op-ca' }]); + const getStoragePool = vi.fn().mockResolvedValue([{ serviceLevel: 'STANDARD' }]); + createClientMock.mockReturnValue({ createVolume, getStoragePool }); + + const { createVolumeHandler } = await import('./volume-handler.js'); + await createVolumeHandler({ + projectId: 'p1', + location: 'us-central1', + storagePoolId: 'pool-std', + volumeId: 'v1', + capacityGib: 10, + protocols: ['SMB'], + smbContinuouslyAvailable: true, + }); + + expect(getStoragePool).toHaveBeenCalled(); + expect(createVolume.mock.calls[0]?.[0]?.volume?.storagePool).toBe('pool-std'); + expect(createVolume.mock.calls[0]?.[0]?.volume?.smbSettings).toContain( + 'CONTINUOUSLY_AVAILABLE' + ); + }); + it('createVolumeHandler supports Large Capacity Volumes (Premium/Extreme only)', async () => { const createVolume = vi.fn().mockResolvedValue([{ name: 'operations/op-lcv' }]); const getStoragePool = vi.fn().mockResolvedValue([{ serviceLevel: 'PREMIUM' }]); diff --git a/src/tools/handlers/volume-handler.ts b/src/tools/handlers/volume-handler.ts index f560f71..cbefb52 100644 --- a/src/tools/handlers/volume-handler.ts +++ b/src/tools/handlers/volume-handler.ts @@ -104,6 +104,89 @@ function normalizeProtocols(raw: any): { return { protocols: out }; } +/** Values accepted in Volume.smbSettings (GCNV v1beta). */ +const SMB_SETTING_API_VALUES = new Set([ + 'SMB_SETTINGS_UNSPECIFIED', + 'ENCRYPT_DATA', + 'BROWSABLE', + 'CHANGE_NOTIFY', + 'NON_BROWSABLE', + 'OPLOCKS', + 'SHOW_SNAPSHOT', + 'SHOW_PREVIOUS_VERSIONS', + 'ACCESS_BASED_ENUMERATION', + 'CONTINUOUSLY_AVAILABLE', +]); + +function buildSmbSettingsForCreate(args: { + smbSettings?: unknown; + smbEncryptData?: unknown; + smbHideShare?: unknown; + smbAccessBasedEnumeration?: unknown; + smbContinuouslyAvailable?: unknown; +}): { smbSettings?: string[]; error?: string } { + const out = new Set(); + + if (Array.isArray(args.smbSettings)) { + for (const item of args.smbSettings) { + if (typeof item !== 'string' || item.trim() === '') { + return { error: 'smbSettings must be an array of non-empty strings' }; + } + const v = item.trim().toUpperCase(); + if (!SMB_SETTING_API_VALUES.has(v)) { + return { + error: `Invalid smbSettings value "${item}". Allowed: ${[...SMB_SETTING_API_VALUES].join(', ')}`, + }; + } + if (v === 'SMB_SETTINGS_UNSPECIFIED') { + return { + error: + 'smbSettings must not include SMB_SETTINGS_UNSPECIFIED; omit smbSettings or pass concrete enum values.', + }; + } + out.add(v); + } + } else if (args.smbSettings !== undefined && args.smbSettings !== null) { + return { error: 'smbSettings must be an array of strings when provided' }; + } + + if (args.smbEncryptData === true) out.add('ENCRYPT_DATA'); + if (args.smbHideShare === true) out.add('NON_BROWSABLE'); + if (args.smbAccessBasedEnumeration === true) out.add('ACCESS_BASED_ENUMERATION'); + if (args.smbContinuouslyAvailable === true) out.add('CONTINUOUSLY_AVAILABLE'); + + if (out.has('BROWSABLE') && out.has('NON_BROWSABLE')) { + return { + error: + 'Conflicting SMB share visibility: BROWSABLE and NON_BROWSABLE cannot be set together (check smbSettings and smbHideShare).', + }; + } + + // Hide-share (NON_BROWSABLE) and CA share (CONTINUOUSLY_AVAILABLE) are mutually + // exclusive — the GCNV console enforces this in the volume creation UI, and CA + // shares are only supported on non-FLEX pools where this combination makes no + // practical sense (CA clients must be able to see/reconnect to the share). + if (out.has('NON_BROWSABLE') && out.has('CONTINUOUSLY_AVAILABLE')) { + return { + error: + 'Conflicting SMB attributes: NON_BROWSABLE (hide share) and CONTINUOUSLY_AVAILABLE (CA share) cannot be set together (check smbHideShare/smbContinuouslyAvailable and smbSettings).', + }; + } + + if (out.size === 0) return {}; + return { smbSettings: [...out] }; +} + +function resolveStoragePoolResourceName( + projectId: string, + location: string, + storagePoolId: string +): string { + return String(storagePoolId || '').includes('/') + ? storagePoolId + : `projects/${projectId}/locations/${location}/storagePools/${storagePoolId}`; +} + // Helper to format volume data for responses function formatVolumeData(volume: any): any { const result: any = {}; @@ -202,6 +285,11 @@ export const createVolumeHandler: ToolHandler = async (args: { [key: string]: an hostGroups, hostGroup, blockDevice, + smbSettings: rawSmbSettings, + smbEncryptData, + smbHideShare, + smbAccessBasedEnumeration, + smbContinuouslyAvailable, } = args; // Create a new NetApp client using the factory @@ -209,6 +297,11 @@ export const createVolumeHandler: ToolHandler = async (args: { [key: string]: an // Format the parent path for the volume const parent = `projects/${projectId}/locations/${location}`; + const storagePoolResourceName = resolveStoragePoolResourceName( + projectId, + location, + String(storagePoolId) + ); // Large Capacity Volumes guardrails: // - Premium/Extreme only (enforced by checking the storage pool service level) @@ -238,10 +331,7 @@ export const createVolumeHandler: ToolHandler = async (args: { [key: string]: an }; } - const storagePoolName = String(storagePoolId || '').includes('/') - ? storagePoolId - : `projects/${projectId}/locations/${location}/storagePools/${storagePoolId}`; - const [pool] = await netAppClient.getStoragePool({ name: storagePoolName }); + const [pool] = await netAppClient.getStoragePool({ name: storagePoolResourceName }); const poolServiceLevel = (pool?.serviceLevel || '').toString().toUpperCase(); if (poolServiceLevel !== 'PREMIUM' && poolServiceLevel !== 'EXTREME') { return { @@ -272,6 +362,47 @@ export const createVolumeHandler: ToolHandler = async (args: { [key: string]: an const normalizedProtocolNames: Array<'NFSV3' | 'NFSV4' | 'SMB' | 'ISCSI'> = protocols && protocols.length > 0 ? protocols : ['NFSV3']; + const { smbSettings: smbSettingsList, error: smbSettingsError } = buildSmbSettingsForCreate({ + smbSettings: rawSmbSettings, + smbEncryptData, + smbHideShare, + smbAccessBasedEnumeration, + smbContinuouslyAvailable, + }); + if (smbSettingsError) { + return { + isError: true, + content: [{ type: 'text' as const, text: `Error creating volume: ${smbSettingsError}` }], + }; + } + if (smbSettingsList && smbSettingsList.length > 0 && !normalizedProtocolNames.includes('SMB')) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: 'Error creating volume: smbSettings / SMB flags require protocols to include SMB.', + }, + ], + }; + } + + if (smbSettingsList?.includes('CONTINUOUSLY_AVAILABLE')) { + const [pool] = await netAppClient.getStoragePool({ name: storagePoolResourceName }); + const poolServiceLevel = (pool?.serviceLevel || '').toString().toUpperCase(); + if (poolServiceLevel === 'FLEX') { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: 'Error creating volume: Continuous availability (CA) share for SQL Server / FSLogix (CONTINUOUSLY_AVAILABLE) is not supported on FLEX storage pools. Use Standard, Premium, or Extreme.', + }, + ], + }; + } + } + const isIscsi = normalizedProtocolNames.includes('ISCSI'); if (isIscsi) { const hasFileProto = normalizedProtocolNames.some((p) => p !== 'ISCSI'); @@ -389,6 +520,7 @@ export const createVolumeHandler: ToolHandler = async (args: { [key: string]: an ...(throughputMibps !== undefined ? { throughputMibps } : {}), ...(largeCapacity !== undefined ? { largeCapacity } : {}), ...(multipleEndpoints !== undefined ? { multipleEndpoints } : {}), + ...(smbSettingsList && smbSettingsList.length > 0 ? { smbSettings: smbSettingsList } : {}), }, }; diff --git a/src/tools/volume-tools.ts b/src/tools/volume-tools.ts index 39c3e1d..f0f4942 100644 --- a/src/tools/volume-tools.ts +++ b/src/tools/volume-tools.ts @@ -294,6 +294,48 @@ export const createVolumeTool: ToolConfig = { .describe( 'Use multiple storage endpoints for Large Capacity Volumes (only valid when largeCapacity is true).' ), + smbEncryptData: z + .boolean() + .optional() + .describe( + 'When true, sets smbSettings ENCRYPT_DATA (SMB encryption). Requires protocols to include SMB.' + ), + smbHideShare: z + .boolean() + .optional() + .describe( + 'When true, sets smbSettings NON_BROWSABLE (hidden / not browsable share). Requires protocols to include SMB. Mutually exclusive with smbContinuouslyAvailable / CONTINUOUSLY_AVAILABLE.' + ), + smbAccessBasedEnumeration: z + .boolean() + .optional() + .describe( + 'When true, sets smbSettings ACCESS_BASED_ENUMERATION (ABE). Requires protocols to include SMB.' + ), + smbContinuouslyAvailable: z + .boolean() + .optional() + .describe( + 'When true, sets smbSettings CONTINUOUSLY_AVAILABLE (CA share for SQL Server / FSLogix). Permanent choice on the volume. Not supported on FLEX pools — request fails early. Requires protocols to include SMB. Mutually exclusive with smbHideShare / NON_BROWSABLE.' + ), + smbSettings: z + .array( + z.enum([ + 'ENCRYPT_DATA', + 'BROWSABLE', + 'CHANGE_NOTIFY', + 'NON_BROWSABLE', + 'OPLOCKS', + 'SHOW_SNAPSHOT', + 'SHOW_PREVIOUS_VERSIONS', + 'ACCESS_BASED_ENUMERATION', + 'CONTINUOUSLY_AVAILABLE', + ]) + ) + .optional() + .describe( + 'Additional smbSettings API enum names (e.g. OPLOCKS, CONTINUOUSLY_AVAILABLE). BROWSABLE and NON_BROWSABLE together (or BROWSABLE with smbHideShare) are invalid. NON_BROWSABLE and CONTINUOUSLY_AVAILABLE together (or smbHideShare with smbContinuouslyAvailable) are invalid — CA shares must be browsable. Merged with the smb* boolean flags. CONTINUOUSLY_AVAILABLE is rejected for FLEX pools. Requires protocols to include SMB when non-empty.' + ), }, outputSchema: { name: z.string().describe('The name of the created volume'),