Skip to content

Commit cdf74f5

Browse files
committed
Rebase
Signed-off-by: Alina Buzachis <[email protected]>
1 parent b510690 commit cdf74f5

File tree

7 files changed

+316
-48
lines changed

7 files changed

+316
-48
lines changed

core/migrations/0004_task_updated_at_alter_task_status.py

Lines changed: 0 additions & 34 deletions
This file was deleted.

core/tests/test_controller_client.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ def test_get_http_session():
1212
s2 = cc.get_http_session()
1313
assert s1 is not s2
1414

15+
1516
def _fake_response(status_code: int, payload: dict | list) -> requests.Response:
1617
"""Return a Response-like mock that behaves for raise_for_status/json."""
1718
resp = MagicMock(spec=requests.Response)
@@ -26,12 +27,6 @@ def _fake_response(status_code: int, payload: dict | list) -> requests.Response:
2627
return resp
2728

2829

29-
def test_get_http_session():
30-
s1 = cc.get_http_session()
31-
s2 = cc.get_http_session()
32-
assert s1 is not s2
33-
34-
3530
@patch("core.utils.controller.client.get_http_session")
3631
def test_post_success(mock_get_http_session):
3732
session = MagicMock()

core/utils/controller/client.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,6 @@
1818
logger = logging.getLogger(__name__)
1919

2020

21-
F = TypeVar("F", bound=Callable[..., requests.Response])
22-
23-
2421
def get_http_session() -> Session:
2522
"""Creates and returns a new Session instance with AAP credentials."""
2623
session = Session()

core/utils/controller/helpers.py

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,274 @@ def download_collection(collection_name: str, version: str) -> Iterator[str]:
8080
if response:
8181
response.close() # Explicitly close the response object
8282
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+
)

core/utils/http_helpers.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,53 @@
11
import logging
2+
from functools import wraps
3+
from typing import Any
4+
from typing import Callable
5+
from typing import Optional
6+
from typing import TypeVar
27
from urllib.parse import urlparse
38

9+
import requests
10+
411
logger = logging.getLogger(__name__)
512

613

14+
F = TypeVar("F", bound=Callable[..., requests.Response])
15+
16+
17+
class RetryError(Exception):
18+
"""Custom exception raised when a retry limit is reached."""
19+
20+
def __init__(
21+
self, msg: str, request: Optional[Any] = None, response: Optional[Any] = None
22+
) -> None:
23+
super().__init__(msg)
24+
self.request = request
25+
self.response = response
26+
27+
28+
def safe_json(func: F) -> Callable[..., dict[str, Any]]:
29+
"""
30+
Decorator for functions that return a `requests.Response`.
31+
It attempts to parse JSON safely and falls back to raw text if needed.
32+
"""
33+
34+
@wraps(func)
35+
def wrapper(*args: Any, **kwargs: Any) -> dict[str, Any]:
36+
response = func(*args, **kwargs)
37+
try:
38+
return response.json()
39+
except ValueError:
40+
logger.warning(f"Non-JSON response from {response.url}: {response.text!r}")
41+
return {
42+
"detail": "Non-JSON response",
43+
"text": response.text,
44+
"status_code": response.status_code,
45+
"url": response.url,
46+
}
47+
48+
return wrapper
49+
50+
751
def validate_url(url: str) -> str:
852
"""Ensure the URL has a valid scheme and format."""
953
if not url.startswith(("http://", "https://")):

0 commit comments

Comments
 (0)