Skip to content

Commit 3348a51

Browse files
Merge pull request #15 from alinabuzachis/async_create_pattern
AAP-47753: Implement run_pattern_task() for PatternViewSet
2 parents 7dcc333 + bed3404 commit 3348a51

21 files changed

+613
-8
lines changed

.pre-commit-config.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,5 @@ repos:
5252
- id: mypy
5353
pass_filenames: false
5454
args: [--config-file=pyproject.toml, --ignore-missing-imports, "."]
55+
additional_dependencies:
56+
- types-requests

CONTRIBUTING.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Hi there! We're excited to have you as a contributor.
1313
- [Set env variables for development](#set-env-variables-for-development)
1414
- [Configure postgres and run the dispatcher service](#configure-postgres-and-run-the-dispatcher-service)
1515
- [Configure and run the application](#configure-and-run-the-application)
16+
- [Development configuration](#development-configuration)
1617
- [Updating dependencies](#updating-dependencies)
1718
- [Running linters and code checks](#running-linters-and-code-checks)
1819
- [Running tests](#running-tests)
@@ -85,6 +86,31 @@ In a separate terminal window, run:
8586

8687
The application can be reached in your browser at `https://localhost:8000/`. The Django admin UI is accessible at `https://localhost:8000/admin` and the available API endpoints will be listed in the 404 information at `http://localhost:8000/api/pattern-service/v1/`.
8788

89+
### Development Configuration
90+
91+
Default configuration values for connecting to the Ansible Automation Platform (AAP) service are defined in `development_defaults.py`:
92+
93+
```bash
94+
AAP_URL = "http://localhost:44926" # Base URL of your AAP instance
95+
AAP_VALIDATE_CERTS = False # Whether to verify SSL certificates
96+
AAP_USERNAME = "admin" # Username for AAP authentication
97+
AAP_PASSWORD = "password" # Password for AAP authentication
98+
```
99+
100+
*Note*: These defaults are placeholders for local development only. You must provide proper values for your environment by setting environment variables prefixed with `PATTERN_SERVICE_` or via a `.env` file.
101+
For example:
102+
103+
```bash
104+
export PATTERN_SERVICE_AAP_URL="http://your-ip-address:44926"
105+
export PATTERN_SERVICE_AAP_VALIDATE_CERTS="False"
106+
export PATTERN_SERVICE_AAP_USERNAME="admin"
107+
export PATTERN_SERVICE_AAP_PASSWORD="your-password"
108+
```
109+
110+
This ensures secure and correct operation in your deployment or testing environment.
111+
112+
Dynaconf will prioritize environment variables and values in `.env` over defaults in `development_defaults.py`.
113+
88114
## Updating dependencies
89115

90116
Project dependencies for all environments are specified in the [pyproject.toml file](./pyproject.toml). A requirements.txt file is generated for each environment using pip-compile, to simplify dependency installation with pip.

core/apps.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from django.apps import AppConfig
55
from django.conf import settings
66

7+
from core.utils import validate_url
8+
79
logger = logging.getLogger(__name__)
810

911

@@ -12,5 +14,29 @@ class CoreConfig(AppConfig):
1214
name = "core"
1315

1416
def ready(self) -> None:
17+
# List of required AAP variables
18+
required_vars = [
19+
"AAP_URL",
20+
"AAP_USERNAME",
21+
"AAP_PASSWORD",
22+
]
23+
24+
# Check that each required setting is defined
25+
missing = [var for var in required_vars if not getattr(settings, var, None)]
26+
if missing:
27+
logger.error(
28+
f"Missing required configuration variables: {', '.join(missing)}"
29+
)
30+
raise RuntimeError(
31+
f"Required AAP variable not defined: {', '.join(missing)}. "
32+
)
33+
34+
# Validate AAP_URL
35+
try:
36+
settings.AAP_URL = validate_url(settings.AAP_URL)
37+
except ValueError as e:
38+
logger.error(f"AAP_URL validation failed: {e}")
39+
raise
40+
1541
# Configure dispatcher
1642
dispatcher_setup(config=settings.DISPATCHER_CONFIG)

core/models.py

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
from __future__ import annotations
22

3+
from typing import Any
4+
from typing import Dict
5+
from typing import Optional
6+
37
from ansible_base.lib.abstract_models import CommonModel
48
from django.db import models
59

@@ -85,12 +89,54 @@ class Meta:
8589
app_label = "core"
8690
ordering = ["id"]
8791

88-
status_choices = (
89-
("Initiated", "Initiated"),
90-
("Running", "Running"),
91-
("Completed", "Completed"),
92-
("Failed", "Failed"),
93-
)
92+
class Status(models.TextChoices):
93+
INITIATED = "Initiated"
94+
RUNNING = "Running"
95+
COMPLETED = "Completed"
96+
FAILED = "Failed"
9497

95-
status: models.CharField = models.CharField(max_length=20, choices=status_choices)
98+
status: models.CharField = models.CharField(max_length=20, choices=Status.choices)
9699
details: models.JSONField = models.JSONField(null=True, blank=True)
100+
101+
def set_status(
102+
self,
103+
new_status: str,
104+
details: Optional[Dict[str, Any]] = None,
105+
save_immediately: bool = True,
106+
) -> None:
107+
"""
108+
Safely update the task's status and optional details.
109+
110+
Args:
111+
new_status (str): The new status (must be one of Status.choices).
112+
details (dict, optional): Additional info about this status update.
113+
save_immediately (bool): If True, saves the instance to the database
114+
immediately.
115+
116+
Raises:
117+
ValueError: If the provided status is invalid.
118+
"""
119+
if new_status not in self.Status.values:
120+
raise ValueError(
121+
f"Invalid status '{new_status}'. Allowed values: {self.Status.values}"
122+
)
123+
124+
self.status = new_status
125+
self.details = details or {}
126+
if save_immediately:
127+
self.save(update_fields=["status", "details"])
128+
129+
def mark_initiated(self, details: Optional[Dict[str, Any]] = None) -> None:
130+
self.set_status(self.Status.INITIATED, details)
131+
132+
def mark_running(self, details: Optional[Dict[str, Any]] = None) -> None:
133+
self.set_status(self.Status.RUNNING, details)
134+
135+
def mark_completed(self, details: Optional[Dict[str, Any]] = None) -> None:
136+
self.set_status(self.Status.COMPLETED, details)
137+
138+
def mark_failed(self, details: Optional[Dict[str, Any]] = None) -> None:
139+
self.set_status(self.Status.FAILED, details)
140+
141+
def __str__(self) -> str:
142+
return f"Task #{self.pk} - {self.status}"

core/task_runner.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import json
2+
import logging
3+
import os
4+
5+
from core.utils.controller import build_collection_uri
6+
from core.utils.controller import download_collection
7+
8+
from .models import Pattern
9+
from .models import Task
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
def run_pattern_task(pattern_id: int, task_id: int) -> None:
15+
"""
16+
Orchestrates downloading a collection and saving a pattern definition.
17+
18+
Args:
19+
pattern_id (int): The ID of the pattern to process.
20+
task_id (int): The ID of the task.
21+
22+
Raises:
23+
FileNotFoundError: If the pattern definition is not found.
24+
Exception: If any other error occurs.
25+
"""
26+
task = Task.objects.get(id=task_id)
27+
task.mark_initiated({"info": "Processing started"})
28+
try:
29+
pattern = Pattern.objects.get(id=pattern_id)
30+
task.mark_running({"info": "Processing pattern"})
31+
with download_collection(
32+
pattern.collection_name, pattern.collection_version
33+
) as collection_path:
34+
path_to_definition = os.path.join(
35+
collection_path,
36+
"extensions",
37+
"patterns",
38+
pattern.pattern_name,
39+
"meta",
40+
"pattern.json",
41+
)
42+
with open(path_to_definition, "r") as file:
43+
definition = json.load(file)
44+
45+
pattern.pattern_definition = definition
46+
pattern.collection_version_uri = build_collection_uri(
47+
pattern.collection_name, pattern.collection_version
48+
)
49+
pattern.save(update_fields=["pattern_definition", "collection_version_uri"])
50+
task.mark_completed({"info": "Pattern processed successfully"})
51+
except FileNotFoundError:
52+
logger.error(f"Could not find pattern definition for task {task_id}")
53+
task.mark_failed({"error": "Pattern definition not found."})
54+
except Exception as e:
55+
error_message = f"An unexpected error occurred {str(e)}."
56+
logger.exception(f"Task {task_id} failed unexpectedly.")
57+
task.mark_failed({"error": error_message})
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import core.utils.controller.client as cc
2+
3+
4+
def test_get_http_session():
5+
"""Subsequent calls without force_refresh must not return the *same* object."""
6+
s1 = cc.get_http_session()
7+
s2 = cc.get_http_session()
8+
assert s1 is not s2
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import os
2+
from unittest.mock import MagicMock
3+
from unittest.mock import patch
4+
5+
import pytest
6+
7+
from core.utils.controller import build_collection_uri
8+
from core.utils.controller import download_collection
9+
10+
11+
@pytest.fixture
12+
def mock_tar_data():
13+
"""A fixture to provide mock tarball data."""
14+
return b"This is some mock tarball content"
15+
16+
17+
@pytest.fixture
18+
def mock_download_success(mock_tar_data):
19+
"""
20+
Corrected fixture to mock a successful download scenario,
21+
including mocking os.makedirs and ensuring cleanup.
22+
"""
23+
with (
24+
patch("core.utils.controller.helpers.get") as mock_get,
25+
patch("core.utils.controller.helpers.tarfile.open") as mock_tar_open,
26+
patch("core.utils.controller.helpers.os.makedirs") as mock_makedirs,
27+
patch("core.utils.controller.helpers.shutil.rmtree") as mock_rmtree,
28+
patch(
29+
"core.utils.controller.helpers.tempfile.mkdtemp",
30+
return_value="/mock/temp/dir",
31+
) as mock_mkdtemp,
32+
):
33+
34+
mock_response = MagicMock()
35+
mock_response.raw = mock_tar_data
36+
mock_get.return_value = mock_response
37+
38+
mock_tar_open.return_value.__enter__.return_value = MagicMock()
39+
40+
yield mock_get, mock_tar_open, mock_makedirs, mock_mkdtemp, mock_rmtree
41+
42+
43+
@pytest.fixture
44+
def mock_download_failure():
45+
"""
46+
Fixture to mock a download failure scenario by raising an exception
47+
and mocking the cleanup functions.
48+
"""
49+
with (
50+
patch(
51+
"core.utils.controller.helpers.get", side_effect=Exception("Network error")
52+
) as mock_get,
53+
patch("core.utils.controller.helpers.shutil.rmtree") as mock_rmtree,
54+
patch("core.utils.controller.helpers.os.makedirs"),
55+
patch(
56+
"core.utils.controller.helpers.tempfile.mkdtemp",
57+
return_value="/mock/temp/dir",
58+
) as mock_mkdtemp,
59+
):
60+
61+
yield mock_get, mock_rmtree, mock_mkdtemp
62+
63+
64+
@pytest.mark.parametrize(
65+
"collection_name, version, expected_uri",
66+
[
67+
(
68+
"another.collection",
69+
"2.0.0",
70+
(
71+
"http://localhost:44926/api/galaxy/v3/plugin/ansible/content/published/"
72+
"collections/artifacts/another-collection-2.0.0.tar.gz"
73+
),
74+
),
75+
(
76+
"edge.case",
77+
"0.1.0-beta",
78+
(
79+
"http://localhost:44926/api/galaxy/v3/plugin/ansible/content/published/"
80+
"collections/artifacts/edge-case-0.1.0-beta.tar.gz"
81+
),
82+
),
83+
],
84+
)
85+
def test_build_collection_uri(collection_name, version, expected_uri):
86+
"""
87+
Tests that various collection names and versions build the correct URI.
88+
"""
89+
assert build_collection_uri(collection_name, version) == expected_uri
90+
91+
92+
def test_download_collection_success(mock_download_success):
93+
"""
94+
Tests the successful download and extraction of a collection.
95+
"""
96+
mock_get, mock_tar_open, mock_makedirs, mock_mkdtemp, mock_rmtree = (
97+
mock_download_success
98+
)
99+
100+
collection_name = "my_namespace.my_collection"
101+
version = "1.0.0"
102+
expected_path = os.path.join("/mock/temp/dir", "my_namespace.my_collection-1.0.0")
103+
104+
with download_collection(collection_name, version) as path:
105+
# Assert that the correct path was yielded
106+
assert path == expected_path
107+
108+
mock_mkdtemp.assert_called_once()
109+
mock_makedirs.assert_called_once_with(expected_path, exist_ok=True)
110+
mock_get.assert_called_once()
111+
mock_tar_open.assert_called_once()
112+
113+
mock_rmtree.assert_called_once_with("/mock/temp/dir")
114+
115+
116+
def test_download_collection_failure(mock_download_failure):
117+
"""
118+
Tests that an exception during download.
119+
"""
120+
mock_get, mock_rmtree, mock_mkdtemp = mock_download_failure
121+
122+
collection_name = "my_namespace.my_collection"
123+
version = "1.0.0"
124+
125+
with pytest.raises(Exception, match="Network error"):
126+
with download_collection(collection_name, version):
127+
pass
128+
129+
mock_mkdtemp.assert_called_once()
130+
mock_rmtree.assert_called_once_with("/mock/temp/dir")

0 commit comments

Comments
 (0)