1+ import hashlib
12import logging
23from enum import Enum
34from typing import Optional
@@ -25,23 +26,33 @@ class DataCenter(str, Enum):
2526
2627class NetworkVolume (DeployableResource ):
2728 """
28- NetworkVolume resource for creating and managing Runpod netowrk volumes.
29+ NetworkVolume resource for creating and managing Runpod network volumes.
2930
3031 This class handles the creation, deployment, and management of network volumes
31- that can be attached to serverless resources.
32+ that can be attached to serverless resources. Supports idempotent deployment
33+ where multiple volumes with the same name will reuse existing volumes.
3234
3335 """
3436
3537 # Internal fixed value
3638 dataCenterId : DataCenter = Field (default = DataCenter .EU_RO_1 , frozen = True )
3739
3840 id : Optional [str ] = Field (default = None )
39- name : Optional [ str ] = None
40- size : Optional [int ] = Field (default = 10 , gt = 0 ) # Size in GB
41+ name : str
42+ size : Optional [int ] = Field (default = 100 , gt = 0 ) # Size in GB
4143
4244 def __str__ (self ) -> str :
4345 return f"{ self .__class__ .__name__ } :{ self .id } "
4446
47+ @property
48+ def resource_id (self ) -> str :
49+ """Unique resource ID based on name and datacenter for idempotent behavior."""
50+ # Use name + datacenter to ensure idempotence
51+ resource_type = self .__class__ .__name__
52+ config_key = f"{ self .name } :{ self .dataCenterId .value } "
53+ hash_obj = hashlib .md5 (f"{ resource_type } :{ config_key } " .encode ())
54+ return f"{ resource_type } _{ hash_obj .hexdigest ()} "
55+
4556 @field_serializer ("dataCenterId" )
4657 def serialize_data_center_id (self , value : Optional [DataCenter ]) -> Optional [str ]:
4758 """Convert DataCenter enum to string."""
@@ -61,24 +72,57 @@ def url(self) -> str:
6172 raise ValueError ("Network volume ID is not set" )
6273 return f"{ CONSOLE_BASE_URL } /user/storage"
6374
64- async def create_network_volume (self ) -> str :
75+ def is_deployed (self ) -> bool :
6576 """
66- Creates a network volume using the provided configuration.
67- Returns the volume ID.
77+ Checks if the network volume resource is deployed and available.
6878 """
69- async with RunpodRestClient () as client :
70- # Create the network volume
71- payload = self .model_dump (exclude_none = True )
72- result = await client .create_network_volume (payload )
79+ return self .id is not None
80+
81+ def _normalize_volumes_response (self , volumes_response ) -> list :
82+ """Normalize API response to list format."""
83+ if isinstance (volumes_response , list ):
84+ return volumes_response
85+ return volumes_response .get ("networkVolumes" , [])
86+
87+ def _find_matching_volume (self , existing_volumes : list ) -> Optional [dict ]:
88+ """Find existing volume matching name and datacenter."""
89+ for volume_data in existing_volumes :
90+ if (
91+ volume_data .get ("name" ) == self .name
92+ and volume_data .get ("dataCenterId" ) == self .dataCenterId .value
93+ ):
94+ return volume_data
95+ return None
96+
97+ async def _find_existing_volume (self , client ) -> Optional ["NetworkVolume" ]:
98+ """Check for existing volume with same name and datacenter."""
99+ if not self .name :
100+ return None
101+
102+ log .debug (f"Checking for existing network volume with name: { self .name } " )
103+ volumes_response = await client .list_network_volumes ()
104+ existing_volumes = self ._normalize_volumes_response (volumes_response )
105+
106+ if matching_volume := self ._find_matching_volume (existing_volumes ):
107+ log .info (
108+ f"Found existing network volume: { matching_volume .get ('id' )} with name '{ self .name } '"
109+ )
110+ # Update our instance with the existing volume's ID
111+ self .id = matching_volume .get ("id" )
112+ return self
113+
114+ return None
115+
116+ async def _create_new_volume (self , client ) -> "NetworkVolume" :
117+ """Create a new network volume."""
118+ log .debug (f"Creating new network volume: { self .name or 'unnamed' } " )
119+ payload = self .model_dump (exclude_none = True )
120+ result = await client .create_network_volume (payload )
73121
74122 if volume := self .__class__ (** result ):
75123 return volume
76124
77- def is_deployed (self ) -> bool :
78- """
79- Checks if the network volume resource is deployed and available.
80- """
81- return self .id is not None
125+ raise ValueError ("Deployment failed, no volume was created." )
82126
83127 async def deploy (self ) -> "DeployableResource" :
84128 """
@@ -91,16 +135,13 @@ async def deploy(self) -> "DeployableResource":
91135 log .debug (f"{ self } exists" )
92136 return self
93137
94- # Create the network volume
95138 async with RunpodRestClient () as client :
96- # Create the network volume
97- payload = self .model_dump (exclude_none = True )
98- result = await client .create_network_volume (payload )
99-
100- if volume := self .__class__ (** result ):
101- return volume
139+ # Check for existing volume first
140+ if existing_volume := await self ._find_existing_volume (client ):
141+ return existing_volume
102142
103- raise ValueError ("Deployment failed, no volume was created." )
143+ # No existing volume found, create a new one
144+ return await self ._create_new_volume (client )
104145
105146 except Exception as e :
106147 log .error (f"{ self } failed to deploy: { e } " )
0 commit comments