Skip to content

Commit 98eaec9

Browse files
feat: support multiple awx tokens (#586)
1 parent a02b316 commit 98eaec9

File tree

13 files changed

+610
-111
lines changed

13 files changed

+610
-111
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ extend-ignore = [
109109
"S101", # Asserts allowed in tests
110110
"ARG", # Fixtures are not always used explicitly
111111
"SLF001", # Call private methods in tests
112+
"D", # Docstrings are not required in tests
112113
]
113114

114115
[tool.ruff.flake8-tidy-imports]

setup.cfg

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,5 @@ per-file-ignores =
1111
# Ignore "E501: Lint too long" for migrations, because migrations are generated automatically
1212
# and the nesting level is high.
1313
src/aap_eda/core/migrations/*:E501
14+
# Ignore docstring errors in tests.
15+
tests/*:D

src/aap_eda/api/serializers/activation.py

Lines changed: 65 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class Meta:
4848
"created_at",
4949
"modified_at",
5050
"status_message",
51+
"awx_token_id",
5152
]
5253
read_only_fields = [
5354
"id",
@@ -84,6 +85,7 @@ class Meta:
8485
"created_at",
8586
"modified_at",
8687
"status_message",
88+
"awx_token_id",
8789
]
8890
read_only_fields = ["id", "created_at", "modified_at"]
8991

@@ -111,12 +113,27 @@ def to_representation(self, activation):
111113
"created_at": activation.created_at,
112114
"modified_at": activation.modified_at,
113115
"status_message": activation.status_message,
116+
"awx_token_id": activation.awx_token_id,
114117
}
115118

116119

117120
class ActivationCreateSerializer(serializers.ModelSerializer):
118121
"""Serializer for creating the Activation."""
119122

123+
class Meta:
124+
model = models.Activation
125+
fields = [
126+
"name",
127+
"description",
128+
"is_enabled",
129+
"decision_environment_id",
130+
"rulebook_id",
131+
"extra_var_id",
132+
"user",
133+
"restart_policy",
134+
"awx_token_id",
135+
]
136+
120137
rulebook_id = serializers.IntegerField(
121138
validators=[validators.check_if_rulebook_exists]
122139
)
@@ -130,25 +147,26 @@ class ActivationCreateSerializer(serializers.ModelSerializer):
130147
)
131148
user = serializers.HiddenField(default=serializers.CurrentUserDefault())
132149

150+
awx_token_id = serializers.IntegerField(
151+
allow_null=True,
152+
validators=[validators.check_if_awx_token_exists],
153+
required=False,
154+
)
155+
133156
def validate(self, data):
134157
user = data["user"]
135-
validators.check_awx_tokens(user.id)
158+
awx_token = models.AwxToken.objects.filter(
159+
id=data.get("awx_token_id"),
160+
).first()
161+
if awx_token and awx_token.user != user:
162+
raise serializers.ValidationError(
163+
"The Awx Token does not belong to the user."
164+
)
165+
if not awx_token:
166+
validate_rulebook_token(data["rulebook_id"])
136167

137168
return data
138169

139-
class Meta:
140-
model = models.Activation
141-
fields = [
142-
"name",
143-
"description",
144-
"is_enabled",
145-
"decision_environment_id",
146-
"rulebook_id",
147-
"extra_var_id",
148-
"user",
149-
"restart_policy",
150-
]
151-
152170
def create(self, validated_data):
153171
rulebook_id = validated_data["rulebook_id"]
154172
rulebook = models.Rulebook.objects.get(id=rulebook_id)
@@ -225,6 +243,7 @@ class Meta:
225243
"modified_at",
226244
"restarted_at",
227245
"status_message",
246+
"awx_token_id",
228247
]
229248
read_only_fields = ["id", "created_at", "modified_at", "restarted_at"]
230249

@@ -290,6 +309,7 @@ def to_representation(self, activation):
290309
"modified_at": activation.modified_at,
291310
"restarted_at": restarted_at,
292311
"status_message": activation.status_message,
312+
"awx_token_id": activation.awx_token_id,
293313
}
294314

295315

@@ -305,10 +325,17 @@ class PostActivationSerializer(serializers.ModelSerializer):
305325
allow_null=True,
306326
validators=[validators.check_if_extra_var_exists],
307327
)
328+
awx_token_id = serializers.IntegerField(
329+
allow_null=True,
330+
validators=[validators.check_if_awx_token_exists],
331+
)
332+
rulebook_id = serializers.IntegerField(allow_null=True)
308333

309334
def validate(self, data):
310-
user_id = self.initial_data["user_id"]
311-
validators.check_awx_tokens(user_id)
335+
awx_token = data.get("awx_token_id")
336+
337+
if not awx_token:
338+
validate_rulebook_token(data["rulebook_id"])
312339

313340
return data
314341

@@ -323,6 +350,8 @@ class Meta:
323350
"user_id",
324351
"created_at",
325352
"modified_at",
353+
"awx_token_id",
354+
"rulebook_id",
326355
]
327356
read_only_fields = [
328357
"id",
@@ -356,3 +385,23 @@ def parse_validation_errors(errors: dict) -> str:
356385
messages = {key: str(error[0]) for key, error in errors.items() if error}
357386

358387
return str(messages)
388+
389+
390+
def validate_rulebook_token(rulebook_id: int) -> None:
391+
"""Validate if the rulebook requires an Awx Token."""
392+
rulebook = models.Rulebook.objects.get(id=rulebook_id)
393+
394+
# TODO: rulesets are stored as a string in the rulebook model
395+
# proper instrospection should require a validation of the
396+
# rulesets. https://issues.redhat.com/browse/AAP-19202
397+
try:
398+
rulesets_data = rulebook.get_rulesets_data()
399+
except ValueError:
400+
raise serializers.ValidationError("Invalid rulebook data.")
401+
402+
if validators.check_rulesets_require_token(
403+
rulesets_data,
404+
):
405+
raise serializers.ValidationError(
406+
"The rulebook requires an Awx Token.",
407+
)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Generated by Django 4.2.7 on 2023-12-21 18:50
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
def populate_awx_token(apps, schema_editor):
8+
"""Populate existing activations with AWX tokens.
9+
10+
Ensure that the awx_token field is populated for all existing
11+
activations after the migration is applied. If the user has an
12+
AWX token, use it. Always use the first AWX token, previous
13+
versions of the code only allowed one AWX token per user.
14+
"""
15+
Activation = apps.get_model("core", "Activation") # noqa: N806
16+
AWXToken = apps.get_model("core", "AWXToken") # noqa: N806
17+
for activation in Activation.objects.all():
18+
user = activation.user
19+
try:
20+
awx_token = AWXToken.objects.filter(user=user).first()
21+
if awx_token:
22+
activation.awx_token = awx_token
23+
activation.save()
24+
except AWXToken.DoesNotExist:
25+
pass
26+
27+
28+
class Migration(migrations.Migration):
29+
dependencies = [
30+
("core", "0013_auditaction_status_message"),
31+
]
32+
33+
operations = [
34+
migrations.AddField(
35+
model_name="activation",
36+
name="awx_token",
37+
field=models.ForeignKey(
38+
default=None,
39+
null=True,
40+
on_delete=django.db.models.deletion.SET_NULL,
41+
to="core.awxtoken",
42+
),
43+
),
44+
migrations.RunPython(
45+
populate_awx_token,
46+
reverse_code=migrations.RunPython.noop,
47+
),
48+
]

src/aap_eda/core/models/activation.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
UpdateFieldsRequiredError,
2828
)
2929

30-
from .user import User
30+
from .user import AwxToken, User
3131

3232
__all__ = (
3333
"Activation",
@@ -96,6 +96,12 @@ class Meta:
9696
on_delete=models.SET_NULL,
9797
related_name="+",
9898
)
99+
awx_token = models.ForeignKey(
100+
AwxToken,
101+
on_delete=models.SET_NULL,
102+
null=True,
103+
default=None,
104+
)
99105

100106
def save(self, *args, **kwargs):
101107
# when creating

src/aap_eda/core/models/rulebook.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import yaml
1516
from django.db import models
1617

1718
__all__ = (
@@ -36,11 +37,27 @@ class Meta:
3637
name = models.TextField(null=False)
3738
description = models.TextField(null=True, default="")
3839
# TODO: this field should not have a default value.
40+
# TODO: should the content of this field be validated?
41+
# https://issues.redhat.com/browse/AAP-19202
3942
rulesets = models.TextField(null=False, default="")
4043
project = models.ForeignKey("Project", on_delete=models.CASCADE, null=True)
4144
created_at = models.DateTimeField(auto_now_add=True, null=False)
4245
modified_at = models.DateTimeField(auto_now=True, null=False)
4346

47+
# For instrospection purposes we need to return
48+
# rulesets data unserialized.
49+
def get_rulesets_data(self) -> list[dict]:
50+
"""Return rulesets data as a list of dicts."""
51+
try:
52+
return yaml.safe_load(self.rulesets)
53+
except yaml.YAMLError as e:
54+
raise ValueError(
55+
(
56+
"Unable to parse rulesets data for rulebook "
57+
f" {self.id} - {self.name}: Error: {e}"
58+
)
59+
)
60+
4461

4562
class Ruleset(models.Model):
4663
class Meta:

src/aap_eda/core/validators.py

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414
import logging
15+
import typing as tp
1516

1617
import yaml
1718
from rest_framework import serializers
@@ -58,17 +59,43 @@ def check_if_extra_var_exists(extra_var_id: int) -> int:
5859
return extra_var_id
5960

6061

61-
def check_awx_tokens(user_id: int) -> int:
62-
tokens = models.AwxToken.objects.filter(user_id=user_id).count()
63-
if tokens == 0:
64-
raise serializers.ValidationError("No controller token specified")
65-
elif tokens > 1:
62+
def check_if_awx_token_exists(awx_token_id: int) -> int:
63+
try:
64+
models.AwxToken.objects.get(pk=awx_token_id)
65+
except models.AwxToken.DoesNotExist:
6666
raise serializers.ValidationError(
67-
"More than one controller token found, "
68-
"currently only 1 token is supported"
67+
f"AwxToken with id {awx_token_id} does not exist"
6968
)
69+
return awx_token_id
70+
71+
72+
def check_rulesets_require_token(
73+
rulesets_data: list[dict[str, tp.Any]],
74+
) -> bool:
75+
"""Inspect rulesets data to determine if a token is required.
76+
77+
Return True if any of the rules has an action that requires a token.
78+
"""
79+
required_actions = {"run_job_template", "run_workflow_template"}
80+
81+
for ruleset in rulesets_data:
82+
for rule in ruleset.get("rules", []):
83+
# When it is a single action dict
84+
if any(
85+
action_key in required_actions
86+
for action_key in rule.get("action", {})
87+
):
88+
return True
89+
90+
# When it is a list of actions
91+
if any(
92+
action_arg in required_actions
93+
for action in rule.get("actions", [])
94+
for action_arg in action
95+
):
96+
return True
7097

71-
return user_id
98+
return False
7299

73100

74101
def is_extra_var_dict(extra_var: str):

src/aap_eda/wsapi/consumers.py

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import base64
22
import json
33
import logging
4+
import typing as tp
45
from datetime import datetime
56
from enum import Enum
67

@@ -111,14 +112,15 @@ async def handle_workers(self, message: WorkerMessage):
111112
)
112113
await self.send(text_data=extra_var_message.json())
113114

114-
controller_info = ControllerInfo(
115-
url=settings.EDA_CONTROLLER_URL,
116-
token=await self.get_awx_token(message),
117-
ssl_verify=settings.EDA_CONTROLLER_SSL_VERIFY,
118-
)
119-
120115
await self.send(text_data=rulebook_message.json())
121-
await self.send(text_data=controller_info.json())
116+
awx_token = await self.get_awx_token(message)
117+
if awx_token:
118+
controller_info = ControllerInfo(
119+
url=settings.EDA_CONTROLLER_URL,
120+
token=awx_token,
121+
ssl_verify=settings.EDA_CONTROLLER_SSL_VERIFY,
122+
)
123+
await self.send(text_data=controller_info.json())
122124
await self.send(text_data=EndOfResponse().json())
123125

124126
# TODO: add broadcasting later by channel groups
@@ -320,15 +322,11 @@ def get_resources(
320322
return activation.rulebook_rulesets, extra_var
321323

322324
@database_sync_to_async
323-
def get_awx_token(self, message):
324-
# query for activation
325+
def get_awx_token(self, message: WorkerMessage) -> tp.Optional[str]:
326+
"""Get AWX token from the worker message."""
325327
activation_instance = models.ActivationInstance.objects.get(
326-
id=message.activation_id
328+
id=message.activation_id,
327329
)
330+
awx_token = activation_instance.activation.awx_token
328331

329-
# check/get AWX token
330-
awx_token = models.AwxToken.objects.filter(
331-
user_id=activation_instance.activation.user_id
332-
).first()
333-
334-
return awx_token.token.get_secret_value()
332+
return awx_token.token.get_secret_value() if awx_token else None

0 commit comments

Comments
 (0)