Skip to content

AAP-17690 Inventory variables sourced from git project are not getting deleted after being removed from source #15928

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 28 commits into
base: devel
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
9f294fd
Delete existing all-group vars on inventory sync (with overwrite-vars…
djulich Mar 27, 2025
178236c
Implementation of inv var handling with file as db.
djulich Apr 4, 2025
7828164
Improve serialization to file of inv vars for src update
djulich Apr 4, 2025
748329e
Include inventory-level variable editing into inventory source update…
djulich Apr 8, 2025
1755f6d
Add group vars to inventory source update handling
djulich Apr 8, 2025
f84a562
Add support for overwrite_vars to new inventory source handling
djulich Apr 8, 2025
12b1d9a
Merge remote-tracking branch 'origin/devel' into AAP-17690
djulich Apr 13, 2025
9bd1a56
Persist inventory var history in the database instead of a file.
djulich Apr 16, 2025
3510711
Remove logging which was needed during development.
djulich Apr 16, 2025
4c4cd8b
Remove further debugging code and improve comments
djulich Apr 16, 2025
65b024b
Move special handling for user edits of variables into serializers
djulich Apr 17, 2025
b6c2af0
Relate the inventory variable history model to its inventory
djulich Apr 17, 2025
b7ae6a6
Allow for inventory variables to have the value 'None'
djulich Apr 17, 2025
6ee08a0
Fix KeyError in new inventory variable handling
djulich Apr 22, 2025
50dac13
Add unique-together constraint for new model InventoryGroupVariablesW…
djulich Apr 23, 2025
ef12483
Use only one special invsrc_id for initial update and manual updates
djulich Apr 23, 2025
10504fd
Fix internal server error when creating a new inventory
djulich Apr 23, 2025
6b15185
Print the empty string for a variable with value 'None'
djulich Apr 23, 2025
349758b
Fix comment which incorrectly states old behaviour
djulich Apr 23, 2025
a906012
Fix inventory_group_variables_update tests which did not take the new…
djulich Apr 23, 2025
6568948
Allow any type for Ansible-core variable values
djulich Apr 23, 2025
7ac8dec
Refactor misleading method names
djulich Apr 23, 2025
93dac5c
Fix internal server error when savig vars from group form
djulich Apr 23, 2025
f7137ef
Remove superfluous json conversion in front of JSONField
djulich Apr 23, 2025
2dff649
Call variable update from create/update instead from validate
djulich Apr 23, 2025
a39469c
Use group_id instead of group_name in model InventoryGroupVariablesWi…
djulich Apr 24, 2025
add8724
Disable new variable update handling for all regular (non-'all') groups
djulich Apr 24, 2025
a186fa6
Add live test to verify AAP-17690 (inv var deleted from source)
djulich Apr 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions awx/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
from awx.main.utils.filters import SmartFilter
from awx.main.utils.plugins import load_combined_inventory_source_options
from awx.main.utils.named_url_graph import reset_counters
from awx.main.utils.inventory_vars import update_group_variables
from awx.main.scheduler.task_manager_models import TaskManagerModels
from awx.main.redact import UriCleaner, REPLACE_STR
from awx.main.signals import update_inventory_computed_fields
Expand Down Expand Up @@ -1614,7 +1615,15 @@ def validate(self, attrs):

if kind == 'smart' and not host_filter:
raise serializers.ValidationError({'host_filter': _('Smart inventories must specify host_filter')})
return super(InventorySerializer, self).validate(attrs)
#
attrs = super(InventorySerializer, self).validate(attrs)
# The variables field contains vars from the all-group. Since this is
# not an update from an inventory source, we update the variables when
# the inventory object is saved.
vars = parse_yaml_or_json(attrs.get("variables"), silent_failure=False) # TODO: silent_failure or not?
update_group_variables("all", vars, None, 0)
#
return attrs


class ConstructedFieldMixin(serializers.Field):
Expand Down Expand Up @@ -1909,7 +1918,14 @@ def validate(self, attrs):
inventory = attrs.get('inventory', self.instance and self.instance.inventory or '')
if Host.objects.filter(name=name, inventory=inventory).exists():
raise serializers.ValidationError(_('A Host with that name already exists.'))
return super(GroupSerializer, self).validate(attrs)
attrs = super(GroupSerializer, self).validate(attrs)
# The variables field contains vars from the inventory group dialog.
# Since this is not an update from an inventory source, we update the
# variables when the inventory group object is saved.
vars = parse_yaml_or_json(attrs.get("variables"), silent_failure=False) # TODO: silent_failure or not?
update_group_variables(name, vars, None, 0)
#
return attrs

def validate_name(self, value):
if value in ('all', '_meta'):
Expand Down
28 changes: 16 additions & 12 deletions awx/main/management/commands/inventory_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from awx.main.models.rbac import batch_role_ancestor_rebuilding
from awx.main.utils import ignore_inventory_computed_fields, get_licenser
from awx.main.utils.execution_environments import get_default_execution_environment
from awx.main.utils.inventory_vars import update_group_variables
from awx.main.signals import disable_activity_stream
from awx.main.constants import STANDARD_INVENTORY_UPDATE_ENV

Expand Down Expand Up @@ -457,19 +458,19 @@ def _update_inventory(self):
"""
Update inventory variables from "all" group.
"""
# TODO: We disable variable overwrite here in case user-defined inventory variables get
# mangled. But we still need to figure out a better way of processing multiple inventory
# update variables mixing with each other.
# issue for this: https://github.com/ansible/awx/issues/11623

if self.inventory.kind == 'constructed' and self.inventory_source.overwrite_vars:
# NOTE: we had to add a exception case to not merge variables
# to make constructed inventory coherent
db_variables = self.all_group.variables
else:
db_variables = self.inventory.variables_dict
db_variables.update(self.all_group.variables)

db_variables = update_group_variables(
group="all",
newvars=self.all_group.variables,
dbvars=self.inventory.variables_dict,
invsrc_id=self.inventory_source.id,
overwrite=self.overwrite_vars,
)
#
if db_variables != self.inventory.variables_dict:
self.inventory.variables = json.dumps(db_variables)
self.inventory.save(update_fields=['variables'])
Expand Down Expand Up @@ -499,10 +500,13 @@ def _create_update_groups(self):
for group in self.inventory.groups.filter(name__in=group_names):
mem_group = self.all_group.all_groups[group.name]
db_variables = group.variables_dict
if self.overwrite_vars:
db_variables = mem_group.variables
else:
db_variables.update(mem_group.variables)
db_variables = update_group_variables(
group=group.name,
newvars=mem_group.variables,
dbvars=group.variables_dict,
invsrc_id=self.inventory_source.id,
overwrite=self.overwrite_vars,
)
if db_variables != group.variables_dict:
group.variables = json.dumps(db_variables)
group.save(update_fields=['variables'])
Expand Down
21 changes: 21 additions & 0 deletions awx/main/migrations/0203_inventorygroupvariableswithhistory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 4.2.20 on 2025-04-16 10:44

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('main', '0202_delete_token_cleanup_job'),
]

operations = [
migrations.CreateModel(
name='InventoryGroupVariablesWithHistory',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('group_name', models.CharField(max_length=256)),
('variables', models.JSONField()),
],
),
]
1 change: 1 addition & 0 deletions awx/main/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
InventorySource,
InventoryUpdate,
SmartInventoryMembership,
InventoryGroupVariablesWithHistory,
)
from awx.main.models.jobs import ( # noqa
Job,
Expand Down
14 changes: 14 additions & 0 deletions awx/main/models/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -1402,3 +1402,17 @@ class Meta:

def get_absolute_url(self, request=None):
return reverse('api:inventory_script_detail', kwargs={'pk': self.pk}, request=request)


class InventoryGroupVariablesWithHistory(models.Model):
"""
Represents the inventory variables of one inventory group.

The purpose of this model is to persist the update history of the group
variables. The update history is maintained in another class
(`InventoryGroupVariables`), this class here is just a container for the
database storage.
"""

group_name = models.CharField(max_length=256)
variables = models.JSONField() # The group variables, including their history.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to ask that someone, like @chrismeyersfsu weigh in on the choice of field type here, JSONField. There is some baggage with this, and I see json.dumps being used to save to this field.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now I removed the superfluous json.dumps and json.loads in front of the JSONField. Thanks for the heads-up, I totally didn't see that.

If @chrismeyersfsu has some concerns regarding the performance impact of a JSONField here, I could switch to a TextField with explicit serialization through json.dumps and json.loads.

105 changes: 105 additions & 0 deletions awx/main/tests/unit/utils/test_inventory_vars.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""
Test utility functions and classes for inventory variable handling.
"""

import pytest

from awx.main.utils.inventory_vars import InventoryVariable
from awx.main.utils.inventory_vars import InventoryGroupVariables


def test_inventory_variable_update_basic():
"""Test basic functionality of an inventory variable."""
x = InventoryVariable("x")
assert x.has_no_source
x.update(1, 101)
assert str(x) == "1"
x.update(2, 102)
assert str(x) == "2"
x.update(3, 103)
assert str(x) == "3"
x.update(None, 102)
assert str(x) == "3"
x.update(None, 103)
assert str(x) == "1"
x.update(None, 101)
assert x.value is None
assert x.has_no_source


@pytest.mark.parametrize(
"updates", # (<source_id>, <value>, <expected_value>)
[
((101, 1, 1),),
((101, 1, 1), (101, None, None)),
((101, 1, 1), (102, 2, 2), (102, None, 1)),
((101, 1, 1), (102, 2, 2), (101, None, 2), (102, None, None)),
(
(101, 0, 0),
(101, 1, 1),
(102, 2, 2),
(103, 3, 3),
(102, None, 3),
(103, None, 1),
(101, None, None),
),
],
)
def test_inventory_variable_update(updates: tuple[int, int | None, int | None]):
"""
Test if the variable value is set correctly on a sequence of updates.
"""
x = InventoryVariable("x")
for src_id, value, expected_value in updates:
x.update(value, src_id)
assert x.value == expected_value


def test_inventory_group_variables_update_basic():
"""Test basic functionality of an inventory variables update."""
vars = InventoryGroupVariables("all")
vars.update_from_src({"x": 1, "y": 2}, 101)
assert vars == {"x": 1, "y": 2}


@pytest.mark.parametrize(
"updates", # (<source_id>, <vars>: dict, <expected_vars>: dict)
[
((101, {"x": 1, "y": 1}, {"x": 1, "y": 1}),),
(
(101, {"x": 1, "y": 1}, {"x": 1, "y": 1}),
(102, {}, {"x": 1, "y": 1}),
),
(
(101, {"x": 1, "y": 1}, {"x": 1, "y": 1}),
(102, {"x": 2}, {"x": 2, "y": 1}),
),
(
(101, {"x": 1, "y": 1}, {"x": 1, "y": 1}),
(102, {"x": 2, "y": 2}, {"x": 2, "y": 2}),
),
(
(101, {"x": 1, "y": 1}, {"x": 1, "y": 1}),
(102, {"x": 2, "z": 2}, {"x": 2, "y": 1, "z": 2}),
),
(
(101, {"x": 1, "y": 1}, {"x": 1, "y": 1}),
(102, {"x": 2, "z": 2}, {"x": 2, "y": 1, "z": 2}),
(102, {}, {"x": 1, "y": 1}),
),
(
(101, {"x": 1, "y": 1}, {"x": 1, "y": 1}),
(102, {"x": 2, "z": 2}, {"x": 2, "y": 1, "z": 2}),
(103, {"x": 3}, {"x": 3, "y": 1, "z": 2}),
(101, {}, {"x": 3, "z": 2}),
),
],
)
def test_inventory_group_variables_update(updates: tuple[int, int | None, int | None]):
"""
Test if the group vars are set correctly on various update sequences.
"""
groupvars = InventoryGroupVariables("test_group")
for src_id, vars, expected_vars in updates:
groupvars.update_from_src(vars, src_id)
assert groupvars == expected_vars
Loading
Loading