55
66from __future__ import annotations
77import copy
8+ import json
9+ import re
810from typing import TYPE_CHECKING , Optional , List , Dict , Tuple , cast
911
1012from datadog_sync .utils .base_resource import BaseResource , ResourceConfig
11- from datadog_sync .utils .resource_utils import CustomClientHTTPError , check_diff
13+ from datadog_sync .utils .resource_utils import CustomClientHTTPError , check_diff , SkipResource
1214
1315if TYPE_CHECKING :
1416 from datadog_sync .utils .custom_client import CustomClient
@@ -84,9 +86,52 @@ async def create_resource(self, _id, resource) -> Tuple[str, Dict]:
8486 # role does not exist at the destination, so create it
8587 if role_name not in self .destination_roles_mapping :
8688 destination_client = self .config .destination_client
87- payload = {"data" : resource }
88- resp = await destination_client .post (self .resource_config .base_path , payload )
89- return _id , resp ["data" ]
89+
90+ # Retry loop to handle multiple invalid permissions
91+ max_retries = 1 + len (self .config .allow_partial_permissions_roles )
92+ retry_count = 0
93+
94+ while retry_count < max_retries :
95+ payload = {"data" : resource }
96+ try :
97+ resp = await destination_client .post (self .resource_config .base_path , payload )
98+ return _id , resp ["data" ]
99+ except CustomClientHTTPError as e :
100+ if e .status_code == 400 and self .config .allow_partial_permissions_roles :
101+ # Try to parse the invalid permission from the error
102+ invalid_permission = self ._parse_invalid_permission_from_error (str (e ))
103+
104+ if invalid_permission and invalid_permission in self .config .allow_partial_permissions_roles :
105+ # Check if we removed the permission successfully
106+ if self ._remove_permission_from_resource (resource , invalid_permission ):
107+ self .config .logger .warning (
108+ f"Trying again without '{ invalid_permission } ' permission for role '{ role_name } '"
109+ )
110+
111+ # Check if the modified resource now matches an existing destination role
112+ # This can happen if we already synced the role without this permission before
113+ if role_name in self .destination_roles_mapping :
114+ matching_destination_role = self .destination_roles_mapping [role_name ]
115+ role_copy = copy .deepcopy (resource )
116+ role_copy .update (matching_destination_role )
117+
118+ # If there's no diff, the role already exists without this permission
119+ if not check_diff (self .resource_config , resource , role_copy ):
120+ raise SkipResource (
121+ _id ,
122+ self .resource_type ,
123+ f"Role '{ role_name } ' already exists at destination "
124+ "without '{invalid_permission}' permission" ,
125+ )
126+
127+ retry_count += 1
128+ continue # Retry with the updated resource
129+
130+ # If we couldn't handle the error, re-raise it
131+ raise
132+
133+ # If we exhausted retries, raise an error
134+ raise Exception (f"Exceeded maximum retries ({ max_retries } ) while creating role '{ role_name } '" )
90135
91136 # role already exists at the destination
92137 matching_destination_role = self .destination_roles_mapping [role_name ]
@@ -108,21 +153,115 @@ async def create_resource(self, _id, resource) -> Tuple[str, Dict]:
108153
109154 async def update_resource (self , _id : str , resource : Dict ) -> Tuple [str , Dict ]:
110155 destination_client = self .config .destination_client
156+ role_name = resource ["attributes" ]["name" ]
111157 resource ["id" ] = self .config .state .destination [self .resource_type ][_id ]["id" ]
112- payload = {"data" : resource }
113- resp = await destination_client .patch (
114- self .resource_config .base_path + f"/{ self .config .state .destination [self .resource_type ][_id ]['id' ]} " ,
115- payload ,
116- )
117158
118- return _id , resp ["data" ]
159+ # Retry loop to handle multiple invalid permissions
160+ max_retries = 1 + len (self .config .allow_partial_permissions_roles )
161+ retry_count = 0
162+
163+ while retry_count < max_retries :
164+ payload = {"data" : resource }
165+ try :
166+ resp = await destination_client .patch (
167+ self .resource_config .base_path + f"/{ self .config .state .destination [self .resource_type ][_id ]['id' ]} " ,
168+ payload ,
169+ )
170+ return _id , resp ["data" ]
171+ except CustomClientHTTPError as e :
172+ if e .status_code == 400 and self .config .allow_partial_permissions_roles :
173+ # Try to parse the invalid permission from the error
174+ invalid_permission = self ._parse_invalid_permission_from_error (str (e ))
175+
176+ if invalid_permission and invalid_permission in self .config .allow_partial_permissions_roles :
177+ # Check if we removed the permission successfully
178+ if self ._remove_permission_from_resource (resource , invalid_permission ):
179+ self .config .logger .warning (
180+ f"Trying again without '{ invalid_permission } ' permission for role '{ role_name } '"
181+ )
182+
183+ # Check if the modified resource now matches the existing destination state
184+ # This can happen if we already synced the role without this permission before
185+ if _id in self .config .state .destination [self .resource_type ]:
186+ destination_resource = self .config .state .destination [self .resource_type ][_id ]
187+
188+ # If there's no diff, the role already exists without this permission
189+ if not check_diff (self .resource_config , resource , destination_resource ):
190+ raise SkipResource (
191+ _id ,
192+ self .resource_type ,
193+ f"Role '{ role_name } ' already exists at destination "
194+ "without '{invalid_permission}' permission" ,
195+ )
196+
197+ retry_count += 1
198+ continue # Retry with the updated resource
199+
200+ # If we couldn't handle the error, re-raise it
201+ raise
202+
203+ # If we exhausted retries, raise an error
204+ raise Exception (f"Exceeded maximum retries ({ max_retries } ) while updating role '{ role_name } '" )
119205
120206 async def delete_resource (self , _id : str ) -> None :
121207 destination_client = self .config .destination_client
122208 await destination_client .delete (
123209 self .resource_config .base_path + f"/{ self .config .state .destination [self .resource_type ][_id ]['id' ]} "
124210 )
125211
212+ def _parse_invalid_permission_from_error (self , error_message : str ) -> Optional [str ]:
213+ """Parse the invalid permission name from a 400 error message.
214+
215+ Example error: '400 Bad Request - {"title":"Generic Error","detail":"invalid UUID [assistant_access]"}'
216+ Returns: "assistant_access"
217+ """
218+ try :
219+ # Try to extract JSON from error message
220+ match = re .search (r"\{.*\}" , error_message )
221+ if not match :
222+ return None
223+
224+ error_json = json .loads (match .group (0 ))
225+
226+ # Look for "invalid UUID [permission_name]" pattern
227+ detail = error_json .get ("detail" , "" )
228+ uuid_match = re .search (r"invalid UUID \[([^\]]+)\]" , detail )
229+ if uuid_match :
230+ return uuid_match .group (1 )
231+
232+ # Also check in errors array if present
233+ errors = error_json .get ("errors" , [])
234+ for error in errors :
235+ if isinstance (error , str ):
236+ uuid_match = re .search (r"invalid UUID \[([^\]]+)\]" , error )
237+ if uuid_match :
238+ return uuid_match .group (1 )
239+ elif isinstance (error , dict ):
240+ detail = error .get ("detail" , "" )
241+ uuid_match = re .search (r"invalid UUID \[([^\]]+)\]" , detail )
242+ if uuid_match :
243+ return uuid_match .group (1 )
244+ except (json .JSONDecodeError , KeyError , AttributeError ):
245+ pass
246+
247+ return None
248+
249+ def _remove_permission_from_resource (self , resource : Dict , permission_id : str ) -> bool :
250+ """Remove a permission from the resource's relationships.
251+
252+ Returns True if permission was found and removed, False otherwise.
253+ """
254+ if "relationships" not in resource or "permissions" not in resource ["relationships" ]:
255+ return False
256+
257+ permissions_data = resource ["relationships" ]["permissions" ].get ("data" , [])
258+ original_length = len (permissions_data )
259+
260+ # Filter out the permission
261+ resource ["relationships" ]["permissions" ]["data" ] = [p for p in permissions_data if p .get ("id" ) != permission_id ]
262+
263+ return len (resource ["relationships" ]["permissions" ]["data" ]) < original_length
264+
126265 async def remap_permissions (self , resource ):
127266 if not self .destination_permissions :
128267 try :
0 commit comments