Skip to content

Commit 57ca03b

Browse files
committed
Signed-off-by: Alina Buzachis <[email protected]>
1 parent cbf9a48 commit 57ca03b

File tree

7 files changed

+309
-17
lines changed

7 files changed

+309
-17
lines changed

core/tasks.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import json
2+
import os
3+
import tarfile
4+
import tempfile
5+
6+
import aiohttp
7+
from asgiref.sync import sync_to_async
8+
from django.db import transaction
9+
10+
from .models import ControllerLabel
11+
from .models import PatternInstance
12+
from .models import Task
13+
14+
15+
async def update_task_status(task: Task, status_: str, details: dict):
16+
task.status = status_
17+
task.details = details
18+
await sync_to_async(task.save, thread_sensitive=True)()
19+
20+
21+
async def download_and_extract_tarball(url: str) -> dict:
22+
async with aiohttp.ClientSession() as session:
23+
async with session.get(url) as resp:
24+
if resp.status != 200:
25+
raise Exception(f"Failed to download tarball: HTTP {resp.status}")
26+
27+
with tempfile.NamedTemporaryFile(delete=False) as tmp:
28+
tmp.write(await resp.read())
29+
tmp_path = tmp.name
30+
31+
extract_dir = tempfile.mkdtemp()
32+
with tarfile.open(tmp_path, 'r:*') as tar:
33+
tar.extractall(path=extract_dir)
34+
35+
# Look for a .pattern.json or similar file
36+
for root, _, files in os.walk(extract_dir):
37+
for fname in files:
38+
if fname.endswith(".json"):
39+
with open(os.path.join(root, fname)) as f:
40+
return json.load(f)
41+
42+
raise Exception("Pattern definition JSON file not found in tarball")
43+
44+
45+
async def run_pattern_instance_task(instance_id: int, task_id: int):
46+
task = await sync_to_async(Task.objects.get, thread_sensitive=True)(id=task_id)
47+
48+
try:
49+
instance = await sync_to_async(PatternInstance.objects.select_related("pattern").get, thread_sensitive=True)(id=instance_id)
50+
pattern = instance.pattern
51+
52+
# Make sure the Pattern has pattern_definition loaded (could be empty)
53+
pattern_def = pattern.pattern_definition or {}
54+
55+
await update_task_status(task, "Running", {"info": "Processing PatternInstance"})
56+
57+
if not pattern_def:
58+
raise Exception("Pattern definition is missing. Cannot process instance.")
59+
60+
# Update instance fields with data from pattern definition inside transaction
61+
def update_instance():
62+
with transaction.atomic():
63+
if "execution_environment_id" in pattern_def:
64+
instance.controller_ee_id = int(pattern_def["execution_environment_id"])
65+
if "executors" in pattern_def:
66+
instance.executors = pattern_def["executors"]
67+
if "controller_labels" in pattern_def:
68+
for label_id in pattern_def["controller_labels"]:
69+
label_obj, _ = ControllerLabel.objects.get_or_create(label_id=label_id)
70+
instance.controller_labels.add(label_obj)
71+
72+
instance.controller_project_id = hash(pattern.pattern_name) % 10**6
73+
instance.save()
74+
75+
await sync_to_async(update_instance, thread_sensitive=True)()
76+
77+
await update_task_status(task, "Completed", {"info": "PatternInstance processed"})
78+
except Exception as e:
79+
await update_task_status(task, "Failed", {"error": str(e)})

core/tests/test_tasks.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
from unittest.mock import patch
2+
3+
from django.urls import reverse
4+
from rest_framework import status
5+
from rest_framework.test import APITestCase
6+
7+
from core.models import Automation
8+
from core.models import ControllerLabel
9+
from core.models import Pattern
10+
from core.models import PatternInstance
11+
from core.models import Task
12+
13+
14+
class SharedDataMixin:
15+
@classmethod
16+
def setUpTestData(cls):
17+
cls.pattern = Pattern.objects.create(
18+
collection_name="mynamespace.mycollection",
19+
collection_version="1.0.0",
20+
collection_version_uri="https://example.com/mynamespace/mycollection/",
21+
pattern_name="example_pattern",
22+
pattern_definition={"key": "value"},
23+
)
24+
25+
cls.pattern_instance = PatternInstance.objects.create(
26+
organization_id=1,
27+
controller_project_id=123,
28+
controller_ee_id=456,
29+
credentials={"user": "admin"},
30+
executors=[{"executor_type": "container"}],
31+
pattern=cls.pattern,
32+
)
33+
34+
cls.label = ControllerLabel.objects.create(label_id=5)
35+
cls.pattern_instance.controller_labels.add(cls.label)
36+
37+
cls.automation = Automation.objects.create(
38+
automation_type="job_template",
39+
automation_id=789,
40+
primary=True,
41+
pattern_instance=cls.pattern_instance,
42+
)
43+
44+
cls.task = Task.objects.create(status="Running", details={"progress": "50%"})
45+
46+
47+
class PatternInstanceViewSetTest(SharedDataMixin, APITestCase):
48+
def test_pattern_instance_list_view(self):
49+
url = reverse("patterninstance-list")
50+
response = self.client.get(url)
51+
self.assertEqual(response.status_code, status.HTTP_200_OK)
52+
self.assertEqual(len(response.data), 1)
53+
54+
def test_pattern_instance_detail_view(self):
55+
url = reverse("patterninstance-detail", args=[self.pattern_instance.pk])
56+
response = self.client.get(url)
57+
self.assertEqual(response.status_code, status.HTTP_200_OK)
58+
self.assertEqual(response.data["organization_id"], 1)
59+
60+
@patch("core.views.async_to_sync")
61+
def test_pattern_instance_create_view(self, mock_async_to_sync):
62+
url = reverse("patterninstance-list")
63+
data = {
64+
"organization_id": 2,
65+
"controller_project_id": 0,
66+
"controller_ee_id": 0,
67+
"credentials": {"user": "tester"},
68+
"executors": [],
69+
"pattern": self.pattern.id,
70+
}
71+
72+
response = self.client.post(url, data, format="json")
73+
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
74+
75+
instance = PatternInstance.objects.get(organization_id=2)
76+
self.assertIsNotNone(instance)
77+
78+
task_id = response.data["task_id"]
79+
task = Task.objects.get(id=task_id)
80+
self.assertEqual(task.status, "Initiated")
81+
82+
mock_async_to_sync.assert_called_once()
83+
self.assertIn("task_id", response.data)
84+
self.assertIn("message", response.data)
85+
86+
87+
class AutomationViewSetTest(SharedDataMixin, APITestCase):
88+
def test_automation_list_view(self):
89+
url = reverse("automation-list")
90+
response = self.client.get(url)
91+
self.assertEqual(response.status_code, status.HTTP_200_OK)
92+
self.assertEqual(len(response.data), 1)
93+
94+
def test_automation_detail_view(self):
95+
url = reverse("automation-detail", args=[self.automation.pk])
96+
response = self.client.get(url)
97+
self.assertEqual(response.status_code, status.HTTP_200_OK)
98+
self.assertEqual(response.data["automation_type"], "job_template")
99+
100+
101+
class ControllerLabelViewSetTest(SharedDataMixin, APITestCase):
102+
def test_label_list_view(self):
103+
url = reverse("controllerlabel-list")
104+
response = self.client.get(url)
105+
self.assertEqual(response.status_code, status.HTTP_200_OK)
106+
self.assertEqual(len(response.data), 1)
107+
108+
def test_label_detail_view(self):
109+
url = reverse("controllerlabel-detail", args=[self.label.id])
110+
response = self.client.get(url)
111+
self.assertEqual(response.status_code, status.HTTP_200_OK)
112+
self.assertIn('id', response.data)
113+
self.assertIn('label_id', response.data)
114+
self.assertEqual(response.data['label_id'], 5)

core/tests/test_views.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from unittest.mock import patch
2+
13
from django.urls import reverse
24
from rest_framework import status
35
from rest_framework.test import APITestCase
@@ -7,6 +9,7 @@
79
from core.models import Pattern
810
from core.models import PatternInstance
911
from core.models import Task
12+
from core.tasks import run_pattern_instance_task
1013

1114

1215
class SharedDataMixin:
@@ -96,7 +99,8 @@ def test_pattern_instance_detail_view(self):
9699
self.assertEqual(response.status_code, status.HTTP_200_OK)
97100
self.assertEqual(response.data["organization_id"], 1)
98101

99-
def test_pattern_instance_create_view(self):
102+
@patch("core.views.async_to_sync")
103+
def test_pattern_instance_create_view(self, mock_async_to_sync):
100104
url = reverse("patterninstance-list")
101105
data = {
102106
"organization_id": 2,
@@ -113,13 +117,13 @@ def test_pattern_instance_create_view(self):
113117
instance = PatternInstance.objects.get(organization_id=2)
114118
self.assertIsNotNone(instance)
115119

116-
task_id = response.data.get("task_id")
117-
self.assertIsInstance(task_id, int)
118-
120+
task_id = response.data["task_id"]
119121
task = Task.objects.get(id=task_id)
120122
self.assertEqual(task.status, "Initiated")
121-
self.assertEqual(task.details.get("model"), "PatternInstance")
122-
self.assertEqual(task.details.get("id"), instance.id)
123+
124+
mock_async_to_sync.assert_called_once()
125+
self.assertIn("task_id", response.data)
126+
self.assertIn("message", response.data)
123127

124128

125129
class AutomationViewSetTest(SharedDataMixin, APITestCase):

core/views.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from ansible_base.lib.utils.views.ansible_base import AnsibleBaseView
2+
from asgiref.sync import async_to_sync
23
from rest_framework import status
34
from rest_framework.decorators import api_view
45
from rest_framework.response import Response
@@ -15,6 +16,7 @@
1516
from .serializers import PatternInstanceSerializer
1617
from .serializers import PatternSerializer
1718
from .serializers import TaskSerializer
19+
from .tasks import run_pattern_instance_task
1820

1921

2022
class CoreViewSet(AnsibleBaseView):
@@ -53,18 +55,24 @@ class PatternInstanceViewSet(CoreViewSet, ModelViewSet):
5355
def create(self, request, *args, **kwargs):
5456
serializer = self.get_serializer(data=request.data)
5557
serializer.is_valid(raise_exception=True)
58+
5659
# Save initial PatternInstance
5760
instance = serializer.save()
5861

5962
# Create a Task entry to track this processing
6063
task = Task.objects.create(status="Initiated", details={"model": "PatternInstance", "id": instance.id})
6164

65+
# Schedule async background task to enrich this instance
66+
async_to_sync(run_pattern_instance_task)(instance.id, task.id)
67+
68+
headers = self.get_success_headers(serializer.data)
6269
return Response(
6370
{
6471
"task_id": task.id,
6572
"message": "PatternInstance creation initiated. Check task status for progress.",
6673
},
6774
status=status.HTTP_202_ACCEPTED,
75+
headers=headers,
6876
)
6977

7078

requirements/requirements-all.txt

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,101 @@
11
#
2-
# This file is autogenerated by pip-compile with Python 3.13
2+
# This file is autogenerated by pip-compile with Python 3.12
33
# by the following command:
44
#
5-
# pip-compile --output-file=requirements-all.txt requirements-dev.in requirements.in
5+
# pip-compile --output-file=requirements/requirements-all.txt requirements/requirements-dev.in requirements/requirements.in
66
#
7+
aiohappyeyeballs==2.6.1
8+
# via aiohttp
9+
aiohttp==3.12.13
10+
# via
11+
# -r requirements/requirements.in
12+
# aiohttp-utils
13+
aiohttp-utils==3.2.1
14+
# via -r requirements/requirements.in
15+
aiosignal==1.3.2
16+
# via aiohttp
717
asgiref==3.8.1
818
# via django
919
astor==0.8.1
1020
# via flynt
21+
attrs==25.3.0
22+
# via aiohttp
1123
black==24.10.0
12-
# via -r requirements-dev.in
24+
# via -r requirements/requirements-dev.in
25+
cffi==1.17.1
26+
# via cryptography
1327
click==8.2.1
1428
# via black
29+
cryptography==45.0.4
30+
# via django-ansible-base
1531
django==4.2.23
1632
# via
17-
# -r requirements.in
33+
# django-ansible-base
34+
# django-crum
1835
# djangorestframework
36+
django-ansible-base==2025.5.8
37+
# via -r requirements/requirements.in
38+
django-crum==0.7.9
39+
# via django-ansible-base
1940
djangorestframework==3.16.0
20-
# via -r requirements.in
41+
# via django-ansible-base
42+
dynaconf==3.2.11
43+
# via django-ansible-base
2144
flake8==6.1.0
22-
# via -r requirements-dev.in
45+
# via -r requirements/requirements-dev.in
2346
flynt==1.0.2
24-
# via -r requirements-dev.in
47+
# via -r requirements/requirements-dev.in
48+
frozenlist==1.7.0
49+
# via
50+
# aiohttp
51+
# aiosignal
52+
gunicorn==23.0.0
53+
# via aiohttp-utils
54+
idna==3.10
55+
# via yarl
56+
inflection==0.5.1
57+
# via django-ansible-base
2558
isort==5.13.2
26-
# via -r requirements-dev.in
59+
# via -r requirements/requirements-dev.in
2760
mccabe==0.7.0
2861
# via flake8
62+
multidict==6.6.3
63+
# via
64+
# aiohttp
65+
# yarl
2966
mypy==1.16.0
30-
# via -r requirements-dev.in
67+
# via -r requirements/requirements-dev.in
3168
mypy-extensions==1.1.0
3269
# via
3370
# black
3471
# mypy
3572
packaging==25.0
36-
# via black
73+
# via
74+
# black
75+
# gunicorn
3776
pathspec==0.12.1
3877
# via
3978
# black
4079
# mypy
4180
platformdirs==4.3.8
4281
# via black
82+
propcache==0.3.2
83+
# via
84+
# aiohttp
85+
# yarl
4386
pycodestyle==2.11.1
4487
# via flake8
88+
pycparser==2.22
89+
# via cffi
4590
pyflakes==3.1.0
4691
# via flake8
92+
python-mimeparse==2.0.0
93+
# via aiohttp-utils
4794
sqlparse==0.5.3
48-
# via django
95+
# via
96+
# django
97+
# django-ansible-base
4998
typing-extensions==4.13.2
5099
# via mypy
100+
yarl==1.20.1
101+
# via aiohttp

requirements/requirements.in

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
django-ansible-base==2025.5.8
2+
aiohttp==3.12.13
3+
aiohttp-utils==3.2.1

0 commit comments

Comments
 (0)