5
5
import urllib .parse
6
6
from functools import wraps
7
7
from typing import Any
8
+ from typing import Callable
8
9
from typing import Dict
9
10
from typing import List
10
11
from typing import Optional
11
12
from typing import Sequence
13
+ from typing import TypeVar
12
14
13
15
import requests
14
16
from requests import Session
29
31
30
32
settings = get_aap_settings ()
31
33
34
+ F = TypeVar ("F" , bound = Callable [..., requests .Response ])
35
+
32
36
33
37
class RetryError (Exception ):
34
38
"""Custom exception raised when a retry limit is reached."""
35
39
36
- def __init__ (self , msg : str , request = None , response = None ):
40
+ def __init__ (
41
+ self , msg : str , request : Optional [Any ] = None , response : Optional [Any ] = None
42
+ ) -> None :
37
43
super ().__init__ (msg )
38
44
self .request = request
39
45
self .response = response
40
46
41
47
42
48
def build_collection_uri (collection : str , version : str ) -> str :
49
+ """Builds the URI for a given collection and version."""
50
+ base_url = settings .url
51
+ path = "/api/galaxy/v3/plugin/ansible/content/published/collections/artifacts"
43
52
filename = f"{ collection } -{ version } .tar.gz"
44
- return f"{ settings .url } /api/galaxy/v3/plugin/ansible/content/published/collections/artifacts/{ filename } "
53
+
54
+ return f"{ base_url } { path } /{ filename } "
45
55
46
56
47
- def wait_for_project_sync (project_id : str , * , max_retries : int = 15 , initial_delay : float = 1 , max_delay : float = 60 , timeout : float = 30 ) -> None :
57
+ def wait_for_project_sync (
58
+ project_id : str ,
59
+ * ,
60
+ max_retries : int = 15 ,
61
+ initial_delay : float = 1 ,
62
+ max_delay : float = 60 ,
63
+ timeout : float = 30 ,
64
+ ) -> None :
48
65
"""
49
- Polls the AAP Controller project endpoint until the project sync completes successfully.
66
+ Polls the AAP Controller project endpoint until the project sync completes
67
+ successfully.
50
68
51
- This function checks the sync status of a project using its ID. It will keep polling
52
- until the status becomes 'successful', or until a maximum number of retries is reached.
53
- Uses exponential backoff with jitter between retries.
69
+ This function checks the sync status of a project using its ID. It will keep
70
+ polling until the status becomes 'successful', or until a maximum number of
71
+ retries is reached. Uses exponential backoff with jitter between retries.
54
72
55
73
Args:
56
74
project_id (str): The numeric ID of the project to monitor.
@@ -65,7 +83,9 @@ def wait_for_project_sync(project_id: str, *, max_retries: int = 15, initial_del
65
83
RequestException: For connection-related errors (e.g., network failures).
66
84
"""
67
85
session = get_http_session ()
68
- url = urllib .parse .urljoin (settings .url , f"/api/controller/v2/projects/{ project_id } " )
86
+ url = urllib .parse .urljoin (
87
+ settings .url , f"/api/controller/v2/projects/{ project_id } "
88
+ )
69
89
delay = initial_delay
70
90
71
91
for attempt in range (1 , max_retries + 1 ):
@@ -74,22 +94,31 @@ def wait_for_project_sync(project_id: str, *, max_retries: int = 15, initial_del
74
94
response .raise_for_status ()
75
95
status = response .json ().get ("status" )
76
96
if status == "successful" :
77
- logger .info (f"Project { project_id } synced successfully on attempt { attempt } ." )
97
+ logger .info (
98
+ f"Project { project_id } synced successfully on attempt { attempt } ."
99
+ )
78
100
return
79
101
80
102
logger .info (f"Project { project_id } status: '{ status } '. Retrying..." )
81
103
82
104
except HTTPError as e :
83
- if e .response .status_code not in (408 , 429 ) and 400 <= e .response .status_code < 500 :
105
+ if (
106
+ e .response .status_code not in (408 , 429 )
107
+ and 400 <= e .response .status_code < 500
108
+ ):
84
109
raise
85
- logger .warning (f"Retryable HTTP error ({ e .response .status_code } ) on attempt { attempt } " )
110
+ logger .warning (
111
+ f"Retryable HTTP error ({ e .response .status_code } ) on attempt { attempt } "
112
+ )
86
113
except (Timeout , RequestException ) as e :
87
114
logger .warning (f"Network error on attempt { attempt } : { e } " )
88
115
except Exception as e :
89
116
logger .error (f"Unexpected error on attempt { attempt } : { e } " )
90
117
91
118
if attempt == max_retries :
92
- raise RetryError (f"Project { project_id } failed to sync after { max_retries } attempts." )
119
+ raise RetryError (
120
+ f"Project { project_id } failed to sync after { max_retries } attempts."
121
+ )
93
122
94
123
jitter = random .uniform (0.8 , 1.2 )
95
124
sleep_time = min (delay * jitter , max_delay )
@@ -105,19 +134,19 @@ def get_http_session(force_refresh: bool = False) -> Session:
105
134
session = Session ()
106
135
session .auth = HTTPBasicAuth (settings .username , settings .password )
107
136
session .verify = settings .verify_ssl
108
- session .headers .update ({' Content-Type' : ' application/json' })
137
+ session .headers .update ({" Content-Type" : " application/json" })
109
138
_aap_session = session
110
139
return _aap_session
111
140
112
141
113
- def safe_json (func ) :
142
+ def safe_json (func : F ) -> Callable [..., dict [ str , Any ]] :
114
143
"""
115
144
Decorator for functions that return a `requests.Response`.
116
145
It attempts to parse JSON safely and falls back to raw text if needed.
117
146
"""
118
147
119
148
@wraps (func )
120
- def wrapper (* args , ** kwargs ) :
149
+ def wrapper (* args : Any , ** kwargs : Any ) -> dict [ str , Any ] :
121
150
response = func (* args , ** kwargs )
122
151
try :
123
152
return response .json ()
@@ -138,7 +167,7 @@ def post(
138
167
data : Dict ,
139
168
* ,
140
169
dedupe_keys : Sequence [str ] = ("name" , "organization" ),
141
- ) -> Dict :
170
+ ) -> Dict [ str , Any ] :
142
171
"""
143
172
Create a resource on the AAP controller.
144
173
If the POST fails with 400 because the object already exists,
@@ -166,18 +195,18 @@ def post(
166
195
return safe_json (lambda : response )()
167
196
168
197
except requests .exceptions .HTTPError as exc :
169
- if exc .response .status_code != 400 :
198
+ response = exc .response
199
+ if response .status_code != 400 :
170
200
raise
171
201
172
202
try :
173
- error_json = safe_json (lambda : exc . response )()
203
+ error_json = safe_json (lambda : response )()
174
204
except Exception :
175
- error_json = {"detail" : exc . response .text }
205
+ error_json = {"detail" : response .text }
176
206
177
207
logger .warning (f"AAP POST { url } failed with 400. Error response: { error_json } " )
178
208
logger .debug (f"Payload sent: { json .dumps (data , indent = 2 )} " )
179
-
180
- logger .debug (f"AAP POST { url } returned 400. Attempting dedup lookup with keys { str (dedupe_keys )} " )
209
+ logger .debug (f"AAP POST { url } 400; dedup lookup keys: { dedupe_keys } " )
181
210
182
211
# Attempt deduplication if resource already exists
183
212
params = {k : data [k ] for k in dedupe_keys if k in data }
@@ -187,15 +216,21 @@ def post(
187
216
results = safe_json (lambda : lookup_resp )().get ("results" , [])
188
217
189
218
if results :
190
- logger .debug (f"Resource already exists. Returning existing resource: { results [0 ]} " )
219
+ logger .debug (
220
+ f"Resource already exists. Returning existing resource: { results [0 ]} "
221
+ )
191
222
return results [0 ]
192
223
except Exception as e :
193
- logger .debug (f"Deduplication GET failed for { url } with params { params } : { e } " )
224
+ logger .debug (
225
+ f"Deduplication GET failed for { url } with params { params } : { e } "
226
+ )
194
227
195
228
# If dedupe fails or no match found, raise with full detail
196
229
raise requests .HTTPError (
197
- f"400 Bad Request for { url } .\n " f"Payload: { json .dumps (data , indent = 2 )} \n " f"Response: { json .dumps (error_json , indent = 2 )} " ,
198
- response = exc .response ,
230
+ f"400 Bad Request for { url } .\n "
231
+ f"Payload: { json .dumps (data , indent = 2 )} \n "
232
+ f"Response: { json .dumps (error_json , indent = 2 )} " ,
233
+ response = response ,
199
234
)
200
235
201
236
@@ -207,7 +242,9 @@ def get(path: str, params: Optional[Dict] = None) -> requests.Response:
207
242
return response
208
243
209
244
210
- def create_project (instance : PatternInstance , pattern : Pattern , pattern_def : Dict ) -> int :
245
+ def create_project (
246
+ instance : PatternInstance , pattern : Pattern , pattern_def : Dict
247
+ ) -> int :
211
248
"""
212
249
Creates a controller project on AAP using the pattern definition.
213
250
Args:
@@ -255,7 +292,9 @@ def create_execution_environment(instance: PatternInstance, pattern_def: Dict) -
255
292
return post ("/api/controller/v2/execution_environments/" , ee_def )["id" ]
256
293
257
294
258
- def create_labels (instance : PatternInstance , pattern_def : Dict ) -> List [ControllerLabel ]:
295
+ def create_labels (
296
+ instance : PatternInstance , pattern_def : Dict
297
+ ) -> List [ControllerLabel ]:
259
298
"""
260
299
Creates controller labels and returns model instances.
261
300
Args:
@@ -276,7 +315,9 @@ def create_labels(instance: PatternInstance, pattern_def: Dict) -> List[Controll
276
315
return labels
277
316
278
317
279
- def create_job_templates (instance : PatternInstance , pattern_def : Dict , project_id : int , ee_id : int ) -> List [Dict [str , Any ]]:
318
+ def create_job_templates (
319
+ instance : PatternInstance , pattern_def : Dict , project_id : int , ee_id : int
320
+ ) -> List [Dict [str , Any ]]:
280
321
"""
281
322
Creates job templates and associated surveys.
282
323
Args:
@@ -299,7 +340,9 @@ def create_job_templates(instance: PatternInstance, pattern_def: Dict, project_i
299
340
"organization" : instance .organization_id ,
300
341
"project" : project_id ,
301
342
"execution_environment" : ee_id ,
302
- "playbook" : f"extensions/patterns/{ pattern_def ['name' ]} /playbooks/{ jt ['playbook' ]} " ,
343
+ "playbook" : (
344
+ f"extensions/patterns/{ pattern_def ['name' ]} /playbooks/{ jt ['playbook' ]} "
345
+ ),
303
346
"ask_inventory_on_launch" : True ,
304
347
}
305
348
@@ -309,15 +352,16 @@ def create_job_templates(instance: PatternInstance, pattern_def: Dict, project_i
309
352
310
353
if survey :
311
354
logger .debug (f"Adding survey to job template { jt_id } " )
312
- # post(f"/api/controller/v2/job_templates/{jt_id}/survey_spec/", {"spec": survey})
313
355
post (f"/api/controller/v2/job_templates/{ jt_id } /survey_spec/" , survey )
314
356
315
357
automations .append ({"type" : "job_template" , "id" : jt_id , "primary" : primary })
316
358
317
359
return automations
318
360
319
361
320
- def assign_execute_roles (executors : Dict [str , List [Any ]], automations : List [Dict [str , Any ]]) -> None :
362
+ def assign_execute_roles (
363
+ executors : Dict [str , List [Any ]], automations : List [Dict [str , Any ]]
364
+ ) -> None :
321
365
"""
322
366
Assigns JobTemplate Execute role to teams and users.
323
367
Args:
@@ -328,7 +372,10 @@ def assign_execute_roles(executors: Dict[str, List[Any]], automations: List[Dict
328
372
return
329
373
330
374
# Get role ID for "Execute" on JobTemplate
331
- result = get ("/api/controller/v2/roles/" , params = {"name" : "Execute" , "content_type" : "job_template" })
375
+ result = get (
376
+ "/api/controller/v2/roles/" ,
377
+ params = {"name" : "Execute" , "content_type" : "job_template" },
378
+ )
332
379
roles_resp = result .json ()
333
380
if not roles_resp ["results" ]:
334
381
raise ValueError ("Could not find 'JobTemplate Execute' role." )
0 commit comments