@@ -80,3 +80,274 @@ def download_collection(collection_name: str, version: str) -> Iterator[str]:
80
80
if response :
81
81
response .close () # Explicitly close the response object
82
82
shutil .rmtree (temp_base_dir )
83
+
84
+
85
+ def create_project (
86
+ instance : PatternInstance , pattern : Pattern , pattern_def : Dict [str , Any ]
87
+ ) -> int :
88
+ """
89
+ Creates a controller project on AAP using the pattern definition.
90
+ Args:
91
+ instance (PatternInstance): The PatternInstance object.
92
+ pattern (Pattern): The related Pattern object.
93
+ pattern_def (Dict[str, Any]): The pattern definition dictionary.
94
+ Returns:
95
+ The created project ID.
96
+ """
97
+ project_def = pattern_def ["aap_resources" ]["controller_project" ]
98
+ project_def .update (
99
+ {
100
+ "organization" : instance .organization_id ,
101
+ "scm_type" : "archive" ,
102
+ "scm_url" : pattern .collection_version_uri ,
103
+ "credential" : instance .credentials .get ("id" ),
104
+ }
105
+ )
106
+ logger .debug (f"Project definition: { project_def } " )
107
+ project_id = post ("/api/controller/v2/projects/" , project_def )["id" ]
108
+ wait_for_project_sync (project_id )
109
+ return project_id
110
+
111
+
112
+ def create_execution_environment (
113
+ instance : PatternInstance , pattern_def : Dict [str , Any ]
114
+ ) -> int :
115
+ """
116
+ Creates an execution environment for the controller.
117
+ Args:
118
+ instance (PatternInstance): The PatternInstance object.
119
+ pattern_def (Dict[str, Any]): The pattern definition dictionary.
120
+ Returns:
121
+ The created execution environment ID.
122
+ """
123
+ ee_def = pattern_def ["aap_resources" ]["controller_execution_environment" ]
124
+ image_name = ee_def .pop ("image_name" )
125
+ ee_def .update (
126
+ {
127
+ "organization" : instance .organization_id ,
128
+ "credential" : instance .credentials .get ("ee" ),
129
+ "image" : f"{ settings .AAP_URL .split ('//' )[- 1 ]} /{ image_name } " ,
130
+ "pull" : ee_def .get ("pull" ) or "missing" ,
131
+ }
132
+ )
133
+ logger .debug (f"Execution Environment definition: { ee_def } " )
134
+ return post ("/api/controller/v2/execution_environments/" , ee_def )["id" ]
135
+
136
+
137
+ def create_labels (
138
+ instance : PatternInstance , pattern_def : Dict [str , Any ]
139
+ ) -> List [ControllerLabel ]:
140
+ """
141
+ Creates controller labels and returns model instances.
142
+ Args:
143
+ instance (PatternInstance): The PatternInstance object.
144
+ pattern_def (Dict[str, Any]): The pattern definition dictionary.
145
+ Returns:
146
+ List of ControllerLabel model instances.
147
+ """
148
+ labels = []
149
+ for name in pattern_def ["aap_resources" ]["controller_labels" ]:
150
+ label_def = {"name" : name , "organization" : instance .organization_id }
151
+ logger .debug (f"Creating label with definition: { label_def } " )
152
+
153
+ results = post ("/api/controller/v2/labels/" , label_def )
154
+ label_obj , _ = ControllerLabel .objects .get_or_create (label_id = results ["id" ])
155
+ labels .append (label_obj )
156
+
157
+ return labels
158
+
159
+
160
+ def create_job_templates (
161
+ instance : PatternInstance ,
162
+ pattern_def : Dict [str , Any ],
163
+ project_id : int ,
164
+ ee_id : int ,
165
+ ) -> List [Dict [str , Any ]]:
166
+ """
167
+ Creates job templates and associated surveys.
168
+ Args:
169
+ instance (PatternInstance): The PatternInstance object.
170
+ pattern_def (Dict[str, Any]): The pattern definition dictionary.
171
+ project_id (int): Controller project ID.
172
+ ee_id (int): Execution environment ID.
173
+ Returns:
174
+ List of dictionaries describing created automations.
175
+ """
176
+ automations = []
177
+ jt_defs = pattern_def ["aap_resources" ]["controller_job_templates" ]
178
+
179
+ for jt in jt_defs :
180
+ survey = jt .pop ("survey" , None )
181
+ primary = jt .pop ("primary" , False )
182
+
183
+ jt_payload = {
184
+ ** jt ,
185
+ "organization" : instance .organization_id ,
186
+ "project" : project_id ,
187
+ "execution_environment" : ee_id ,
188
+ "playbook" : (
189
+ f"extensions/patterns/{ pattern_def ['name' ]} /playbooks/{ jt ['playbook' ]} "
190
+ ),
191
+ "ask_inventory_on_launch" : True ,
192
+ }
193
+
194
+ logger .debug (f"Creating job template with payload: { jt_payload } " )
195
+ jt_res = post ("/api/controller/v2/job_templates/" , jt_payload )
196
+ jt_id = jt_res ["id" ]
197
+
198
+ if survey :
199
+ logger .debug (f"Adding survey to job template { jt_id } " )
200
+ post (f"/api/controller/v2/job_templates/{ jt_id } /survey_spec/" , survey )
201
+
202
+ automations .append ({"type" : "job_template" , "id" : jt_id , "primary" : primary })
203
+
204
+ return automations
205
+
206
+
207
+ def assign_execute_roles (
208
+ executors : Dict [str , List [Any ]], automations : List [Dict [str , Any ]]
209
+ ) -> None :
210
+ """
211
+ Assigns JobTemplate Execute role to teams and users.
212
+ Args:
213
+ executors (Dict[str, List[Any]]): Dictionary with "teams" and "users" lists.
214
+ automations (List[Dict[str, Any]]): List of job template metadata.
215
+ """
216
+ if not executors or (not executors ["teams" ] and not executors ["users" ]):
217
+ return
218
+
219
+ # Get role ID for "Execute" on JobTemplate
220
+ result = get (
221
+ "/api/controller/v2/roles/" ,
222
+ params = {"name" : "Execute" , "content_type" : "job_template" },
223
+ )
224
+ roles_resp = result .json ()
225
+ if not roles_resp ["results" ]:
226
+ raise ValueError ("Could not find 'JobTemplate Execute' role." )
227
+
228
+ role_id = roles_resp ["results" ][0 ]["id" ]
229
+
230
+ for auto in automations :
231
+ jt_id = auto ["id" ]
232
+ for team_id in executors .get ("teams" , []):
233
+ post (
234
+ "/api/controller/v2/role_assignments/" ,
235
+ {
236
+ "discriminator" : "team" ,
237
+ "assignee_id" : str (team_id ),
238
+ "content_type" : "job_template" ,
239
+ "object_id" : jt_id ,
240
+ "role_id" : role_id ,
241
+ },
242
+ )
243
+ for user_id in executors .get ("users" , []):
244
+ post (
245
+ "/api/controller/v2/role_assignments/" ,
246
+ {
247
+ "discriminator" : "user" ,
248
+ "assignee_id" : str (user_id ),
249
+ "content_type" : "job_template" ,
250
+ "object_id" : jt_id ,
251
+ "role_id" : role_id ,
252
+ },
253
+ )
254
+
255
+
256
+ def wait_for_project_sync (
257
+ project_id : str ,
258
+ * ,
259
+ max_retries : int = 15 ,
260
+ initial_delay : float = 1 ,
261
+ max_delay : float = 60 ,
262
+ timeout : float = 30 ,
263
+ ) -> None :
264
+ """
265
+ Polls the AAP Controller project endpoint until the project sync completes
266
+ successfully.
267
+ This function checks the sync status of a project using its ID. It will keep
268
+ polling until the status becomes 'successful', or until a maximum number of
269
+ retries is reached. Uses exponential backoff with jitter between retries.
270
+ Args:
271
+ project_id (str): The numeric ID of the project to monitor.
272
+ max_retries (int): Maximum number of times to retry checking the status.
273
+ initial_delay (float): Delay in seconds before the first retry.
274
+ max_delay (float): Upper limit on delay between retries.
275
+ timeout (float): Timeout in seconds for each HTTP request.
276
+ Raises:
277
+ RetryError: If the project does not sync after all retries.
278
+ HTTPError: For non-retryable 4xx/5xx errors.
279
+ RequestException: For connection-related errors (e.g., network failures).
280
+ """
281
+ session = get_http_session ()
282
+ url = urllib .parse .urljoin (
283
+ settings .AAP_URL , f"/api/controller/v2/projects/{ project_id } "
284
+ )
285
+ delay = initial_delay
286
+
287
+ for attempt in range (1 , max_retries + 1 ):
288
+ try :
289
+ response = session .get (url , timeout = timeout )
290
+ response .raise_for_status ()
291
+ status = response .json ().get ("status" )
292
+ if status == "successful" :
293
+ logger .info (
294
+ f"Project { project_id } synced successfully on attempt { attempt } ."
295
+ )
296
+ return
297
+
298
+ logger .info (f"Project { project_id } status: '{ status } '. Retrying..." )
299
+
300
+ except HTTPError as e :
301
+ if (
302
+ e .response .status_code not in (408 , 429 )
303
+ and 400 <= e .response .status_code < 500
304
+ ):
305
+ raise
306
+ logger .warning (
307
+ f"Retryable HTTP error ({ e .response .status_code } ) on attempt { attempt } "
308
+ )
309
+ except (Timeout , RequestException ) as e :
310
+ logger .warning (f"Network error on attempt { attempt } : { e } " )
311
+ except Exception as e :
312
+ logger .error (f"Unexpected error on attempt { attempt } : { e } " )
313
+
314
+ if attempt == max_retries :
315
+ raise RetryError (
316
+ f"Project { project_id } failed to sync after { max_retries } attempts."
317
+ )
318
+
319
+ jitter = random .uniform (0.8 , 1.2 )
320
+ sleep_time = min (delay * jitter , max_delay )
321
+ logger .debug (f"Waiting { sleep_time :.2f} s before retry #{ attempt + 1 } ..." )
322
+ time .sleep (sleep_time )
323
+ delay *= 2
324
+
325
+
326
+ def save_instance_state (
327
+ instance : PatternInstance ,
328
+ project_id : int ,
329
+ ee_id : int ,
330
+ labels : List [ControllerLabel ],
331
+ automations : List [Dict [str , Any ]],
332
+ ) -> None :
333
+ """
334
+ Saves the instance and links labels and automations inside a DB transaction.
335
+ Args:
336
+ instance: The PatternInstance to update.
337
+ project_id: Controller project ID.
338
+ ee_id: Execution environment ID.
339
+ labels: List of ControllerLabel objects.
340
+ automations: List of job template metadata.
341
+ """
342
+ with transaction .atomic ():
343
+ instance .controller_project_id = project_id
344
+ instance .controller_ee_id = ee_id
345
+ instance .save ()
346
+ for label in labels :
347
+ instance .controller_labels .add (label )
348
+ for auto in automations :
349
+ instance .automations .create (
350
+ automation_type = auto ["type" ],
351
+ automation_id = auto ["id" ],
352
+ primary = auto ["primary" ],
353
+ )
0 commit comments