From 52575213e289d53e45dde43fa2458345cae01772 Mon Sep 17 00:00:00 2001 From: Francisco-xiq Date: Thu, 3 Sep 2026 14:10:47 +0000 Subject: [PATCH 1/8] feat(api): add k8s_pod_affinity field to Activation Refs ansible/eda-server-operator#226 --- src/aap_eda/api/serializers/activation.py | 21 ++++++++++++++ .../0074_activation_k8s_pod_affinity.py | 23 +++++++++++++++ src/aap_eda/core/models/activation.py | 10 +++++++ src/aap_eda/core/validators.py | 29 +++++++++++++++++++ .../services/activation/engine/common.py | 2 ++ 5 files changed, 85 insertions(+) create mode 100644 src/aap_eda/core/migrations/0074_activation_k8s_pod_affinity.py diff --git a/src/aap_eda/api/serializers/activation.py b/src/aap_eda/api/serializers/activation.py index 44998b3ca..416c736f9 100644 --- a/src/aap_eda/api/serializers/activation.py +++ b/src/aap_eda/api/serializers/activation.py @@ -417,6 +417,7 @@ class Meta: "k8s_pod_annotations", "k8s_pod_node_selector", "k8s_pod_tolerations", + "k8s_pod_affinity", ] read_only_fields = [ "id", @@ -498,6 +499,7 @@ class Meta: "k8s_pod_annotations", "k8s_pod_node_selector", "k8s_pod_tolerations", + "k8s_pod_affinity", ] read_only_fields = [ "id", @@ -570,6 +572,7 @@ def to_representation(self, activation): "rule_engine_credential_id": activation.rule_engine_credential_id, **_activation_k8s_pod_metadata_payload(activation), "k8s_pod_tolerations": activation.k8s_pod_tolerations, + "k8s_pod_affinity": activation.k8s_pod_affinity, } @@ -606,6 +609,7 @@ class Meta: "k8s_pod_annotations", "k8s_pod_node_selector", "k8s_pod_tolerations", + "k8s_pod_affinity", ] rulebook_id = serializers.IntegerField( @@ -659,6 +663,11 @@ class Meta: default=list, validators=[validators.validate_k8s_pod_tolerations], ) + k8s_pod_affinity = serializers.JSONField( + required=False, + default=dict, + validators=[validators.check_if_k8s_pod_affinity_valid], + ) def validate(self, data): _validate_credentials_and_token_and_rulebook(data=data, creating=True) @@ -736,6 +745,7 @@ def copy(self) -> dict: validators.validate_k8s_pod_tolerations( activation.k8s_pod_tolerations or [] ) + validators.check_if_k8s_pod_affinity_valid(activation.k8s_pod_affinity) validators.check_if_rulebook_exists(activation.rulebook_id) copied_data = { @@ -765,6 +775,7 @@ def copy(self) -> dict: "rule_engine_credential_id": activation.rule_engine_credential_id, **pod_metadata, "k8s_pod_tolerations": activation.k8s_pod_tolerations, + "k8s_pod_affinity": activation.k8s_pod_affinity, } if activation.eda_system_vault_credential: inputs = yaml.safe_load( @@ -881,6 +892,8 @@ def refill_needed_data( ) if "k8s_pod_tolerations" not in data: data["k8s_pod_tolerations"] = activation.k8s_pod_tolerations or [] + if "k8s_pod_affinity" not in data: + data["k8s_pod_affinity"] = activation.k8s_pod_affinity or {} if "extra_var" not in data: data["extra_var"] = activation.extra_var data["extra_var"] = _get_user_extra_vars(activation, data["extra_var"]) @@ -1170,6 +1183,7 @@ class Meta: "rule_engine_credential_id", "rule_engine_credential", "k8s_pod_tolerations", + "k8s_pod_affinity", ] read_only_fields = [ "id", @@ -1362,6 +1376,11 @@ class PostActivationSerializer( default=list, validators=[validators.validate_k8s_pod_tolerations], ) + k8s_pod_affinity = serializers.JSONField( + required=False, + default=dict, + validators=[validators.check_if_k8s_pod_affinity_valid], + ) def validate(self, data): _validate_credentials_and_token_and_rulebook(data=data, creating=False) @@ -1392,6 +1411,7 @@ class Meta: "k8s_pod_annotations", "k8s_pod_node_selector", "k8s_pod_tolerations", + "k8s_pod_affinity", "source_mappings", "skip_audit_events", "enable_persistence", @@ -1428,6 +1448,7 @@ def is_activation_valid(activation: models.Activation) -> tuple[bool, str]: data["rule_engine_credential_id"] = activation.rule_engine_credential_id data.update(_activation_k8s_pod_metadata_payload(activation)) data["k8s_pod_tolerations"] = activation.k8s_pod_tolerations or [] + data["k8s_pod_affinity"] = activation.k8s_pod_affinity or {} serializer = PostActivationSerializer(data=data) valid = serializer.is_valid() diff --git a/src/aap_eda/core/migrations/0074_activation_k8s_pod_affinity.py b/src/aap_eda/core/migrations/0074_activation_k8s_pod_affinity.py new file mode 100644 index 000000000..61e01d717 --- /dev/null +++ b/src/aap_eda/core/migrations/0074_activation_k8s_pod_affinity.py @@ -0,0 +1,23 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0073_activation_k8s_pod_tolerations"), + ] + + operations = [ + migrations.AddField( + model_name="activation", + name="k8s_pod_affinity", + field=models.JSONField( + blank=True, + default=dict, + help_text=( + "Kubernetes affinity rules (nodeAffinity, podAffinity, " + "podAntiAffinity) applied to activation job pods for " + "scheduling constraints." + ), + ), + ), + ] diff --git a/src/aap_eda/core/models/activation.py b/src/aap_eda/core/models/activation.py index 8eef9d170..2d7cc2cb7 100644 --- a/src/aap_eda/core/models/activation.py +++ b/src/aap_eda/core/models/activation.py @@ -197,6 +197,16 @@ class Meta: "so they can be scheduled onto tainted nodes." ), ) + k8s_pod_affinity = models.JSONField( + default=dict, + blank=True, + help_text=( + "Kubernetes affinity rules (nodeAffinity, podAffinity, " + "podAntiAffinity) applied to activation job pods for " + "scheduling constraints." + ), + ) + event_streams = models.ManyToManyField( EventStream, related_name="activations", default=None ) diff --git a/src/aap_eda/core/validators.py b/src/aap_eda/core/validators.py index 879f75f51..4457fa57c 100644 --- a/src/aap_eda/core/validators.py +++ b/src/aap_eda/core/validators.py @@ -411,6 +411,35 @@ def check_if_k8s_pod_node_selector_valid(value) -> None: _validate_label_value(k, v) +_K8S_AFFINITY_TOP_LEVEL_KEYS = frozenset( + {"nodeAffinity", "podAffinity", "podAntiAffinity"} +) + + +def check_if_k8s_pod_affinity_valid(value) -> None: + """Validate affinity dict at a structural level only.""" + if value in (None, {}): + return + if settings.DEPLOYMENT_TYPE != "k8s": + return + if not isinstance(value, dict): + raise serializers.ValidationError( + "k8s_pod_affinity must be a JSON object" + ) + unknown = set(value.keys()) - _K8S_AFFINITY_TOP_LEVEL_KEYS + if unknown: + raise serializers.ValidationError( + f"k8s_pod_affinity has unknown top-level keys: " + f"{sorted(unknown)}. Allowed: " + f"{sorted(_K8S_AFFINITY_TOP_LEVEL_KEYS)}" + ) + for key, sub_value in value.items(): + if not isinstance(sub_value, dict): + raise serializers.ValidationError( + f"k8s_pod_affinity.{key} must be a JSON object" + ) + + def check_credential_types( eda_credential_id: int, types: list[enums.DefaultCredentialType], diff --git a/src/aap_eda/services/activation/engine/common.py b/src/aap_eda/services/activation/engine/common.py index 43dbef0e6..2be26d880 100644 --- a/src/aap_eda/services/activation/engine/common.py +++ b/src/aap_eda/services/activation/engine/common.py @@ -131,6 +131,7 @@ class ContainerRequest(BaseModel): k8s_pod_annotations: tp.Optional[dict] = None k8s_pod_node_selector: tp.Optional[dict] = None k8s_pod_tolerations: tp.Optional[list[dict]] = None + k8s_pod_affinity: tp.Optional[dict] = None k8s_mem_limit: tp.Optional[str] = None k8s_cpu_limit: tp.Optional[str] = None log_tracking_id: tp.Optional[str] = None @@ -184,6 +185,7 @@ def get_container_request(self) -> ContainerRequest: k8s_pod_annotations=self.k8s_pod_annotations or {}, k8s_pod_node_selector=self.k8s_pod_node_selector or {}, k8s_pod_tolerations=self.k8s_pod_tolerations or [], + k8s_pod_affinity=self.k8s_pod_affinity or {}, k8s_mem_limit=settings.K8S_MEM_LIMIT, k8s_cpu_limit=settings.K8S_CPU_LIMIT, log_tracking_id=self.log_tracking_id, From c3c9729f795e846035fd90ee595cea80f0f2532f Mon Sep 17 00:00:00 2001 From: Francisco-xiq Date: Thu, 3 Sep 2026 14:10:50 +0000 Subject: [PATCH 2/8] feat(engine): apply k8s_pod_affinity to pod spec in kubernetes engine Refs ansible/eda-server-operator#226 --- .../services/activation/engine/kubernetes.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/aap_eda/services/activation/engine/kubernetes.py b/src/aap_eda/services/activation/engine/kubernetes.py index 0b1a8424a..587e42605 100644 --- a/src/aap_eda/services/activation/engine/kubernetes.py +++ b/src/aap_eda/services/activation/engine/kubernetes.py @@ -419,8 +419,19 @@ def _create_pod_template_spec( for t in tolerations ] - spec = k8sclient.V1PodSpec(**spec_kwargs) + affinity = request.k8s_pod_affinity or {} + + if affinity: + # affinity is passed through as the raw + # dict the user supplied (validated for shape only in + # core/validators.py). V1Affinity's nested structure + # (nodeAffinity/podAffinity/podAntiAffinity, each several + # levels deep) is accepted by the client's serializer as a + # plain dict without needing manual construction of the + # typed sub-objects confirmed against kubernetes==26.1.0 + spec_kwargs["affinity"] = affinity + spec = k8sclient.V1PodSpec(**spec_kwargs) pod_template = k8sclient.V1PodTemplateSpec( spec=spec, metadata=k8sclient.V1ObjectMeta(**pod_meta), From ff87b418d7d0a612ef5596c025862949ba6e57a1 Mon Sep 17 00:00:00 2001 From: Francisco-xiq Date: Thu, 3 Sep 2026 18:29:39 +0000 Subject: [PATCH 3/8] test(api): add coverage for k8s_pod_affinity validation and serialization --- tests/integration/api/test_activation.py | 26 +++++ .../services/activation/test_activation.py | 33 ++++++ tests/unit/test_k8s_pod_affinity.py | 102 ++++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 tests/unit/test_k8s_pod_affinity.py diff --git a/tests/integration/api/test_activation.py b/tests/integration/api/test_activation.py index 900a7e6e4..0dc66ee2b 100644 --- a/tests/integration/api/test_activation.py +++ b/tests/integration/api/test_activation.py @@ -1038,6 +1038,32 @@ def test_is_activation_valid( assert error == "{}" # noqa P103 +@pytest.mark.django_db +def test_is_activation_valid_with_k8s_pod_affinity( + default_activation: models.Activation, preseed_credential_types +): + default_activation.k8s_pod_affinity = { + "nodeAffinity": { + "requiredDuringSchedulingIgnoredDuringExecution": { + "nodeSelectorTerms": [ + { + "matchExpressions": [ + { + "key": "eda-lab/zone", + "operator": "In", + "values": ["a"], + } + ] + } + ] + } + } + } + valid, error = is_activation_valid(default_activation) + assert valid is True + assert error == "{}" # noqa P103 + + @pytest.mark.django_db @patch( "aap_eda.api.views.activation.check_dispatcherd_workers_health", diff --git a/tests/integration/services/activation/test_activation.py b/tests/integration/services/activation/test_activation.py index d6518d97d..1e9a5a2e6 100644 --- a/tests/integration/services/activation/test_activation.py +++ b/tests/integration/services/activation/test_activation.py @@ -140,6 +140,39 @@ def test_get_container_request(activation): assert "--skip-audit-events" not in cmdline.get_args() +@pytest.mark.django_db +def test_get_container_request_with_k8s_pod_affinity(activation): + """Test that k8s_pod_affinity is passed through to ContainerRequest.""" + affinity = { + "nodeAffinity": { + "requiredDuringSchedulingIgnoredDuringExecution": { + "nodeSelectorTerms": [ + { + "matchExpressions": [ + { + "key": "eda-lab/zone", + "operator": "In", + "values": ["a"], + } + ] + } + ] + } + } + } + activation.k8s_pod_affinity = affinity + activation.save(update_fields=["k8s_pod_affinity"]) + request = activation.get_container_request() + assert request.k8s_pod_affinity == affinity + + +@pytest.mark.django_db +def test_get_container_request_no_affinity_by_default(activation): + """Test that k8s_pod_affinity defaults to empty when unset.""" + request = activation.get_container_request() + assert request.k8s_pod_affinity == {} + + @pytest.mark.django_db def test_get_container_request_no_instance(activation_no_instance): """Test the construction of a ContainerRequest.""" diff --git a/tests/unit/test_k8s_pod_affinity.py b/tests/unit/test_k8s_pod_affinity.py new file mode 100644 index 000000000..06c8b6b3a --- /dev/null +++ b/tests/unit/test_k8s_pod_affinity.py @@ -0,0 +1,102 @@ +# Copyright 2026 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from unittest.mock import patch + +import pytest +from rest_framework import serializers + +from aap_eda.core.validators import check_if_k8s_pod_affinity_valid + + +@patch("aap_eda.core.validators.settings") +def test_affinity_skips_non_k8s(mock_settings): + mock_settings.DEPLOYMENT_TYPE = "podman" + check_if_k8s_pod_affinity_valid({"bogus": "value"}) + + +@patch("aap_eda.core.validators.settings") +def test_affinity_none_noop(mock_settings): + mock_settings.DEPLOYMENT_TYPE = "k8s" + check_if_k8s_pod_affinity_valid(None) + + +@patch("aap_eda.core.validators.settings") +def test_affinity_empty_dict_noop(mock_settings): + mock_settings.DEPLOYMENT_TYPE = "k8s" + check_if_k8s_pod_affinity_valid({}) + + +@patch("aap_eda.core.validators.settings") +def test_affinity_valid_node_affinity(mock_settings): + mock_settings.DEPLOYMENT_TYPE = "k8s" + check_if_k8s_pod_affinity_valid( + { + "nodeAffinity": { + "requiredDuringSchedulingIgnoredDuringExecution": { + "nodeSelectorTerms": [ + { + "matchExpressions": [ + { + "key": "eda-lab/zone", + "operator": "In", + "values": ["a"], + } + ] + } + ] + } + } + } + ) + + +@patch("aap_eda.core.validators.settings") +def test_affinity_valid_multiple_top_level_keys(mock_settings): + mock_settings.DEPLOYMENT_TYPE = "k8s" + check_if_k8s_pod_affinity_valid( + { + "nodeAffinity": { + "preferredDuringSchedulingIgnoredDuringExecution": [] + }, + "podAffinity": { + "preferredDuringSchedulingIgnoredDuringExecution": [] + }, + "podAntiAffinity": { + "preferredDuringSchedulingIgnoredDuringExecution": [] + }, + } + ) + + +@patch("aap_eda.core.validators.settings") +def test_affinity_not_a_dict(mock_settings): + mock_settings.DEPLOYMENT_TYPE = "k8s" + with pytest.raises(serializers.ValidationError, match="JSON object"): + check_if_k8s_pod_affinity_valid(["not-a-dict"]) + + +@patch("aap_eda.core.validators.settings") +def test_affinity_unknown_top_level_key(mock_settings): + mock_settings.DEPLOYMENT_TYPE = "k8s" + with pytest.raises( + serializers.ValidationError, match="unknown top-level keys" + ): + check_if_k8s_pod_affinity_valid({"bogusAffinity": {}}) + + +@patch("aap_eda.core.validators.settings") +def test_affinity_sub_value_not_a_dict(mock_settings): + mock_settings.DEPLOYMENT_TYPE = "k8s" + with pytest.raises(serializers.ValidationError, match="JSON object"): + check_if_k8s_pod_affinity_valid({"nodeAffinity": "not-a-dict"}) From c661a67c2b2eb26591e01d93652a12f7f6a20c59 Mon Sep 17 00:00:00 2001 From: Francisco-xiq Date: Thu, 3 Sep 2026 18:29:46 +0000 Subject: [PATCH 4/8] test(engine): add coverage for k8s_pod_affinity in pod spec --- .../activation/engine/test_kubernetes.py | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/tests/integration/services/activation/engine/test_kubernetes.py b/tests/integration/services/activation/engine/test_kubernetes.py index 62a2afc84..562a1c946 100644 --- a/tests/integration/services/activation/engine/test_kubernetes.py +++ b/tests/integration/services/activation/engine/test_kubernetes.py @@ -936,6 +936,96 @@ def test_engine_start_no_tolerations_by_default( assert pod_spec.tolerations is None +@mock.patch("aap_eda.services.activation.engine.kubernetes.watch.Watch") +@pytest.mark.django_db +def test_engine_start_applies_k8s_pod_affinity( + mock_watch, + init_kubernetes_data, + kubernetes_engine, + default_organization, +): + engine = kubernetes_engine + affinity = { + "nodeAffinity": { + "requiredDuringSchedulingIgnoredDuringExecution": { + "nodeSelectorTerms": [ + { + "matchExpressions": [ + { + "key": "eda-lab/zone", + "operator": "In", + "values": ["a"], + } + ] + } + ] + } + } + } + request = get_request( + init_kubernetes_data, + "test-user", + default_organization, + k8s_pod_affinity=affinity, + ) + log_handler = mock.MagicMock(spec=LogHandler) + mock_watch.return_value.stream.return_value = iter( + [ + { + "object": get_pod("Running"), + "type": "MODIFIED", + } + ] + ) + with mock.patch.object(engine.client, "batch_api") as batch_api: + with mock.patch.object(engine.client, "core_api") as core_api: + svc_mock = core_api.list_namespaced_service + svc_mock.return_value.items = None + engine.start(request, log_handler) + call_kwargs = batch_api.create_namespaced_job.call_args + job_body = call_kwargs.kwargs.get( + "body", call_kwargs[1].get("body") + ) + pod_spec = job_body.spec.template.spec + assert pod_spec.affinity == affinity + + +@mock.patch("aap_eda.services.activation.engine.kubernetes.watch.Watch") +@pytest.mark.django_db +def test_engine_start_no_affinity_by_default( + mock_watch, + init_kubernetes_data, + kubernetes_engine, + default_organization, +): + engine = kubernetes_engine + request = get_request( + init_kubernetes_data, + "test-user", + default_organization, + ) + log_handler = mock.MagicMock(spec=LogHandler) + mock_watch.return_value.stream.return_value = iter( + [ + { + "object": get_pod("Running"), + "type": "MODIFIED", + } + ] + ) + with mock.patch.object(engine.client, "batch_api") as batch_api: + with mock.patch.object(engine.client, "core_api") as core_api: + svc_mock = core_api.list_namespaced_service + svc_mock.return_value.items = None + engine.start(request, log_handler) + call_kwargs = batch_api.create_namespaced_job.call_args + job_body = call_kwargs.kwargs.get( + "body", call_kwargs[1].get("body") + ) + pod_spec = job_body.spec.template.spec + assert pod_spec.affinity is None + + @pytest.mark.django_db def test_get_job_pod_returns_pod_on_success(kubernetes_engine): """_get_job_pod returns the first pod when the API call succeeds.""" From 18d60e239137befb80222bc2f07be82ef8e7dfb9 Mon Sep 17 00:00:00 2001 From: Francisco-xiq Date: Thu, 3 Sep 2026 20:19:58 +0000 Subject: [PATCH 5/8] fix(api): expose k8s_pod_affinity in Activation serializer output and input Refs ansible/eda-server-operator#226 --- src/aap_eda/api/serializers/activation.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/aap_eda/api/serializers/activation.py b/src/aap_eda/api/serializers/activation.py index 416c736f9..e585f2a2a 100644 --- a/src/aap_eda/api/serializers/activation.py +++ b/src/aap_eda/api/serializers/activation.py @@ -825,6 +825,7 @@ class Meta: "k8s_pod_annotations", "k8s_pod_node_selector", "k8s_pod_tolerations", + "k8s_pod_affinity", ] rulebook_id = serializers.IntegerField( @@ -870,6 +871,11 @@ class Meta: default=list, validators=[validators.validate_k8s_pod_tolerations], ) + k8s_pod_affinity = serializers.JSONField( + required=False, + default=dict, + validators=[validators.check_if_k8s_pod_affinity_valid], + ) def refill_needed_data( self, data: dict, activation: models.Activation @@ -1030,6 +1036,7 @@ def to_representation(self, activation): "enable_persistence": activation.enable_persistence, "rule_engine_credential_id": activation.rule_engine_credential_id, "k8s_pod_tolerations": activation.k8s_pod_tolerations, + "k8s_pod_affinity": activation.k8s_pod_affinity, } @@ -1328,6 +1335,7 @@ def to_representation(self, activation): "rule_engine_credential_id": activation.rule_engine_credential_id, "rule_engine_credential": rule_engine_credential, "k8s_pod_tolerations": activation.k8s_pod_tolerations, + "k8s_pod_affinity": activation.k8s_pod_affinity, } From 326a7c56797dd7cd103591c27bb0ff0dac4c4fa3 Mon Sep 17 00:00:00 2001 From: Francisco-xiq Date: Thu, 3 Sep 2026 20:20:12 +0000 Subject: [PATCH 6/8] test(api): cover k8s_pod_affinity read/update serialization Refs ansible/eda-server-operator#226 --- tests/integration/api/test_activation.py | 48 ++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/integration/api/test_activation.py b/tests/integration/api/test_activation.py index 0dc66ee2b..39a9d544e 100644 --- a/tests/integration/api/test_activation.py +++ b/tests/integration/api/test_activation.py @@ -971,6 +971,7 @@ def assert_activation_base_data( assert data["created_at"] == activation.created_at assert data["modified_at"] <= activation.modified_at assert data["status_message"] + assert data["k8s_pod_affinity"] == (activation.k8s_pod_affinity or {}) def assert_activation_related_object_fks( @@ -1042,6 +1043,7 @@ def test_is_activation_valid( def test_is_activation_valid_with_k8s_pod_affinity( default_activation: models.Activation, preseed_credential_types ): + """Test that is_activation_valid succeeds when k8s_pod_affinity is set.""" default_activation.k8s_pod_affinity = { "nodeAffinity": { "requiredDuringSchedulingIgnoredDuringExecution": { @@ -1300,6 +1302,52 @@ def test_update_activation( assert activation.status == enums.ActivationStatus.PENDING +@pytest.mark.django_db +@patch("aap_eda.api.serializers.activation.settings.DEPLOYMENT_TYPE", "k8s") +@patch( + "aap_eda.api.views.activation.check_dispatcherd_workers_health", + return_value=True, +) +def test_update_activation_k8s_pod_affinity( + mock_health_check, + activation_payload: Dict[str, Any], + default_rulebook: models.Rulebook, + admin_client: APIClient, +): + """Test that k8s_pod_affinity can be set and read back via update.""" + activation_payload["is_enabled"] = False + response = admin_client.post( + f"{api_url_v1}/activations/", data=activation_payload + ) + assert response.status_code == status.HTTP_201_CREATED + id = response.data["id"] + affinity = { + "nodeAffinity": { + "requiredDuringSchedulingIgnoredDuringExecution": { + "nodeSelectorTerms": [ + { + "matchExpressions": [ + { + "key": "eda-lab/zone", + "operator": "In", + "values": ["a"], + } + ] + } + ] + } + } + } + response = admin_client.patch( + f"{api_url_v1}/activations/{id}/", + data={"k8s_pod_affinity": affinity}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["k8s_pod_affinity"] == affinity + activation = models.Activation.objects.get(id=id) + assert activation.k8s_pod_affinity == affinity + + @pytest.mark.django_db def test_update_activation_invalid_body( activation_payload: Dict[str, Any], From 0b79e1cb6f2a5e509450b2225c6e369795e88cf7 Mon Sep 17 00:00:00 2001 From: Francisco-xiq Date: Thu, 3 Sep 2026 20:20:23 +0000 Subject: [PATCH 7/8] docs(tests): add docstrings to k8s_pod_affinity tests --- .../services/activation/engine/test_kubernetes.py | 2 ++ tests/unit/test_k8s_pod_affinity.py | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/tests/integration/services/activation/engine/test_kubernetes.py b/tests/integration/services/activation/engine/test_kubernetes.py index 562a1c946..4af8c5e71 100644 --- a/tests/integration/services/activation/engine/test_kubernetes.py +++ b/tests/integration/services/activation/engine/test_kubernetes.py @@ -944,6 +944,7 @@ def test_engine_start_applies_k8s_pod_affinity( kubernetes_engine, default_organization, ): + """Test that k8s_pod_affinity is applied to the pod spec.""" engine = kubernetes_engine affinity = { "nodeAffinity": { @@ -998,6 +999,7 @@ def test_engine_start_no_affinity_by_default( kubernetes_engine, default_organization, ): + """Test that no affinity is applied to the pod spec by default.""" engine = kubernetes_engine request = get_request( init_kubernetes_data, diff --git a/tests/unit/test_k8s_pod_affinity.py b/tests/unit/test_k8s_pod_affinity.py index 06c8b6b3a..9b2d067e2 100644 --- a/tests/unit/test_k8s_pod_affinity.py +++ b/tests/unit/test_k8s_pod_affinity.py @@ -21,24 +21,28 @@ @patch("aap_eda.core.validators.settings") def test_affinity_skips_non_k8s(mock_settings): + """Test that validation is skipped when DEPLOYMENT_TYPE is not k8s.""" mock_settings.DEPLOYMENT_TYPE = "podman" check_if_k8s_pod_affinity_valid({"bogus": "value"}) @patch("aap_eda.core.validators.settings") def test_affinity_none_noop(mock_settings): + """Test that None is accepted as a no-op value.""" mock_settings.DEPLOYMENT_TYPE = "k8s" check_if_k8s_pod_affinity_valid(None) @patch("aap_eda.core.validators.settings") def test_affinity_empty_dict_noop(mock_settings): + """Test that an empty dict is accepted as a no-op value.""" mock_settings.DEPLOYMENT_TYPE = "k8s" check_if_k8s_pod_affinity_valid({}) @patch("aap_eda.core.validators.settings") def test_affinity_valid_node_affinity(mock_settings): + """Test that a valid nodeAffinity dict passes validation.""" mock_settings.DEPLOYMENT_TYPE = "k8s" check_if_k8s_pod_affinity_valid( { @@ -63,6 +67,7 @@ def test_affinity_valid_node_affinity(mock_settings): @patch("aap_eda.core.validators.settings") def test_affinity_valid_multiple_top_level_keys(mock_settings): + """Test that multiple valid top-level affinity keys pass validation.""" mock_settings.DEPLOYMENT_TYPE = "k8s" check_if_k8s_pod_affinity_valid( { @@ -81,6 +86,7 @@ def test_affinity_valid_multiple_top_level_keys(mock_settings): @patch("aap_eda.core.validators.settings") def test_affinity_not_a_dict(mock_settings): + """Test that a non-dict value is rejected.""" mock_settings.DEPLOYMENT_TYPE = "k8s" with pytest.raises(serializers.ValidationError, match="JSON object"): check_if_k8s_pod_affinity_valid(["not-a-dict"]) @@ -88,6 +94,7 @@ def test_affinity_not_a_dict(mock_settings): @patch("aap_eda.core.validators.settings") def test_affinity_unknown_top_level_key(mock_settings): + """Test that an unknown top-level key is rejected.""" mock_settings.DEPLOYMENT_TYPE = "k8s" with pytest.raises( serializers.ValidationError, match="unknown top-level keys" @@ -97,6 +104,7 @@ def test_affinity_unknown_top_level_key(mock_settings): @patch("aap_eda.core.validators.settings") def test_affinity_sub_value_not_a_dict(mock_settings): + """Test that a non-dict sub-value is rejected.""" mock_settings.DEPLOYMENT_TYPE = "k8s" with pytest.raises(serializers.ValidationError, match="JSON object"): check_if_k8s_pod_affinity_valid({"nodeAffinity": "not-a-dict"}) From 98a31bbe3b7ccddb6497a784573cf38e568e1855 Mon Sep 17 00:00:00 2001 From: Francisco-xiq Date: Fri, 4 Sep 2026 00:26:17 +0000 Subject: [PATCH 8/8] refactor(api): reduce complexity of refill_needed_data() --- src/aap_eda/api/serializers/activation.py | 35 ++++++++++------------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/src/aap_eda/api/serializers/activation.py b/src/aap_eda/api/serializers/activation.py index e585f2a2a..666bd43f3 100644 --- a/src/aap_eda/api/serializers/activation.py +++ b/src/aap_eda/api/serializers/activation.py @@ -880,26 +880,21 @@ class Meta: def refill_needed_data( self, data: dict, activation: models.Activation ) -> None: - if "name" not in data: - data["name"] = activation.name - if "k8s_service_name" not in data: - data["k8s_service_name"] = activation.k8s_service_name - if "k8s_pod_service_account_name" not in data: - data[ - "k8s_pod_service_account_name" - ] = activation.k8s_pod_service_account_name - if "k8s_pod_labels" not in data: - data["k8s_pod_labels"] = activation.k8s_pod_labels or {} - if "k8s_pod_annotations" not in data: - data["k8s_pod_annotations"] = activation.k8s_pod_annotations or {} - if "k8s_pod_node_selector" not in data: - data["k8s_pod_node_selector"] = ( - activation.k8s_pod_node_selector or {} - ) - if "k8s_pod_tolerations" not in data: - data["k8s_pod_tolerations"] = activation.k8s_pod_tolerations or [] - if "k8s_pod_affinity" not in data: - data["k8s_pod_affinity"] = activation.k8s_pod_affinity or {} + default_field_values = { + "name": activation.name, + "k8s_service_name": activation.k8s_service_name, + "k8s_pod_service_account_name": ( + activation.k8s_pod_service_account_name + ), + "k8s_pod_labels": activation.k8s_pod_labels or {}, + "k8s_pod_annotations": activation.k8s_pod_annotations or {}, + "k8s_pod_node_selector": activation.k8s_pod_node_selector or {}, + "k8s_pod_tolerations": activation.k8s_pod_tolerations or [], + "k8s_pod_affinity": activation.k8s_pod_affinity or {}, + } + for field_name, default_value in default_field_values.items(): + if field_name not in data: + data[field_name] = default_value if "extra_var" not in data: data["extra_var"] = activation.extra_var data["extra_var"] = _get_user_extra_vars(activation, data["extra_var"])