Skip to content

Commit 82542e8

Browse files
ehelmsclaude
andcommitted
Add feature removal validation and removability metadata
Co-Authored-By: Claude <noreply@anthropic.com>
1 parent d1e550b commit 82542e8

6 files changed

Lines changed: 169 additions & 19 deletions

File tree

src/features.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ container-gateway:
107107
- foreman
108108
bmc:
109109
description: Power management for bare metal hosts (IPMI, Redfish)
110+
removable: true
110111
foreman_proxy:
111112
plugin_name: bmc
112113
webhooks:
@@ -116,10 +117,12 @@ webhooks:
116117
hammer: foreman_webhooks
117118
templates:
118119
description: Templates feature for foreman-proxy
120+
removable: true
119121
foreman_proxy:
120122
plugin_name: templates
121123
registration:
122124
description: Host registration feature for foreman-proxy
125+
removable: true
123126
foreman_proxy:
124127
plugin_name: registration
125128
dependencies:

src/filter_plugins/foremanctl.py

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -71,32 +71,47 @@ def available_foreman_plugins(_value):
7171
return compact_list(plugins)
7272

7373

74-
def list_all_features(enabled_features, only_enabled=False):
74+
def list_all_features(enabled_features, only_enabled=False, flavor_features=None):
7575
enabled_list = []
7676
available_list = []
7777
list_internal = os.environ.get('FOREMANCTL_FEATURES_LIST_INTERNAL', '') == 'true'
78+
flavor_features_set = set(flavor_features or [])
79+
7880
for name, meta in FEATURE_MAP.items():
7981
internal = meta.get('internal', False)
8082
if internal and not list_internal:
8183
continue
8284
description = meta.get('description', '')
85+
86+
if name in flavor_features_set:
87+
removable = 'flavor'
88+
elif meta.get('removable', False):
89+
removable = 'yes'
90+
else:
91+
removable = 'no'
92+
8393
if has_feature(enabled_features, name):
84-
enabled_list.append((name, 'enabled', internal, description))
94+
enabled_list.append((name, 'enabled', internal, removable, description))
8595
elif not only_enabled:
86-
available_list.append((name, 'available', internal, description))
96+
available_list.append((name, 'available', internal, removable, description))
8797

8898
if not list_internal:
89-
output = [f"{'FEATURE':<25} {'STATE':<12} DESCRIPTION"]
90-
for name, state, _internal, description in enabled_list + available_list:
91-
output.append(f"{name:<25} {state:<12} {description}")
99+
output = [f"{'FEATURE':<25} {'STATE':<12} {'REMOVABLE':<13} DESCRIPTION"]
100+
for name, state, _internal, removable, description in enabled_list + available_list:
101+
output.append(f"{name:<25} {state:<12} {removable:<13} {description}")
92102
else:
93-
output = [f"{'FEATURE':<25} {'STATE':<12} {'INTERNAL':<8} DESCRIPTION"]
94-
for name, state, internal, description in enabled_list + available_list:
95-
output.append(f"{name:<25} {state:<12} {internal:<8} {description}")
103+
output = [f"{'FEATURE':<25} {'STATE':<12} {'INTERNAL':<8} {'REMOVABLE':<13} DESCRIPTION"]
104+
for name, state, internal, removable, description in enabled_list + available_list:
105+
output.append(f"{name:<25} {state:<12} {internal:<8} {removable:<13} {description}")
96106

97107
return "\n".join(output)
98108

99109

110+
def is_feature_removable(feature_name):
111+
"""Check if a feature supports removal."""
112+
return FEATURE_MAP.get(feature_name, {}).get('removable', False)
113+
114+
100115
def invalid_features(features):
101116
"""Return a list of unknown features not defined in features.yaml."""
102117
return [feature for feature in features if feature not in FEATURE_MAP]
@@ -112,6 +127,59 @@ def conflicting_features(features):
112127
return [f"{pair[0]} conflicts with {pair[1]}" for pair in conflicts]
113128

114129

130+
def validate_feature_removals(remove_features, flavor_features):
131+
"""Validate that requested feature removals are allowed.
132+
133+
Returns a list of error message strings. Empty list means all valid.
134+
"""
135+
errors = []
136+
137+
for feature in remove_features:
138+
if feature in flavor_features:
139+
errors.append(
140+
f"Cannot remove '{feature}' — it is a core feature of the current flavor. "
141+
f"Flavor features cannot be removed."
142+
)
143+
elif feature not in FEATURE_MAP:
144+
errors.append(
145+
f"Cannot remove unknown feature '{feature}'. "
146+
f"Run 'foremanctl features' to see available features."
147+
)
148+
elif not is_feature_removable(feature):
149+
errors.append(
150+
f"Cannot remove feature '{feature}' — this feature does not support removal. "
151+
f"Run 'foremanctl features' to see which features can be removed."
152+
)
153+
154+
return errors
155+
156+
157+
def unsatisfied_dependencies(enabled_features, remove_features=None):
158+
"""Detect removed features that are still required by an enabled feature.
159+
160+
``enabled_features`` is the effective feature list (flavor + user features
161+
with removals already subtracted). ``remove_features`` are the features the
162+
user asked to remove. Because dependencies are auto-included, a dependency
163+
is only ever "unsatisfied" when the user explicitly removes something that
164+
a still-enabled feature depends on.
165+
166+
Returns a list of error strings; an empty list means all removals are safe.
167+
"""
168+
remove_set = set(remove_features or [])
169+
if not remove_set:
170+
return []
171+
172+
errors = []
173+
for feature in enabled_features:
174+
still_required = get_dependencies_for_feature(feature) & remove_set
175+
for dependency in sorted(still_required):
176+
errors.append(
177+
f"Cannot remove '{dependency}' — it is required by enabled feature '{feature}'"
178+
)
179+
180+
return errors
181+
182+
115183
def hammer_plugins(value):
116184
dependencies = list(get_dependencies(filter_features(value)))
117185
features = set(filter_features(value + dependencies))
@@ -164,6 +232,8 @@ def filters(self):
164232
'list_all_features': list_all_features,
165233
'invalid_features': invalid_features,
166234
'conflicting_features': conflicting_features,
235+
'validate_feature_removals': validate_feature_removals,
236+
'unsatisfied_dependencies': unsatisfied_dependencies,
167237
'has_feature': has_feature,
168238
'databases_for_features': databases_for_features,
169239
'to_postgresql_databases': to_postgresql_databases,

src/playbooks/_flavor_features/metadata.obsah.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@ variables:
66
action: append_unique
77
remove_features:
88
parameter: --remove-feature
9-
help: Additional features to disable in this deployment.
10-
action: remove
11-
dest: features
9+
help: Features to remove from this deployment.
10+
action: append_unique
11+
persist: false
Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,69 @@
11
---
2+
- name: Validate feature removal requests
3+
ansible.builtin.assert:
4+
that:
5+
- check_features_removal_errors | length == 0
6+
fail_msg: |
7+
ERROR: Invalid feature removal request:
8+
{% for error in check_features_removal_errors %}
9+
- {{ error }}
10+
{% endfor %}
11+
vars:
12+
check_features_removal_errors: "{{ remove_features | validate_feature_removals(flavor_features) }}"
13+
when: remove_features | length > 0
14+
15+
- name: Validate feature dependencies
16+
ansible.builtin.assert:
17+
that:
18+
- check_features_dependency_errors | length == 0
19+
fail_msg: |
20+
ERROR: Removing features would break dependencies:
21+
{% for error in check_features_dependency_errors %}
22+
- {{ error }}
23+
{% endfor %}
24+
vars:
25+
check_features_dependency_errors: "{{ enabled_features | unsatisfied_dependencies(remove_features) }}"
26+
when: remove_features | length > 0
27+
228
- name: Validate requested features
329
ansible.builtin.assert:
430
that:
5-
- found_invalid_features | length == 0
31+
- check_features_invalid | length == 0
632
fail_msg: |
7-
ERROR: Unknown feature(s) requested: {{ found_invalid_features | join(', ') }}
33+
ERROR: Unknown feature(s) requested: {{ check_features_invalid | join(', ') }}
834
935
Run 'foremanctl features' to list all available features.
1036
vars:
11-
found_invalid_features: "{{ features | invalid_features }}"
37+
check_features_invalid: "{{ features | invalid_features }}"
1238
when: features | length > 0
1339

1440
- name: Validate feature conflicts
1541
ansible.builtin.assert:
1642
that:
17-
- found_conflicts | length == 0
43+
- check_features_conflicts | length == 0
1844
fail_msg: |
1945
ERROR: Conflicting features detected:
20-
{% for conflict in found_conflicts %}
46+
{% for conflict in check_features_conflicts %}
2147
- {{ conflict }}
2248
{% endfor %}
2349
2450
These features cannot be enabled together.
2551
vars:
26-
found_conflicts: "{{ enabled_features | conflicting_features }}"
52+
check_features_conflicts: "{{ enabled_features | conflicting_features }}"
53+
54+
- name: Persist feature removals
55+
when: remove_features | length > 0
56+
block:
57+
- name: Read current persisted parameters
58+
ansible.builtin.slurp:
59+
src: "{{ lookup('env', 'OBSAH_STATE') }}/parameters.yaml"
60+
register: check_features_persisted_params_raw
61+
62+
- name: Write updated parameters
63+
ansible.builtin.copy:
64+
content: "{{ check_features_current_params | combine({'features': check_features_updated_features}) | to_nice_yaml }}"
65+
dest: "{{ lookup('env', 'OBSAH_STATE') }}/parameters.yaml"
66+
mode: "0644"
67+
vars:
68+
check_features_current_params: "{{ check_features_persisted_params_raw.content | b64decode | from_yaml }}"
69+
check_features_updated_features: "{{ (check_features_current_params.features | default([])) | difference(remove_features) }}"

src/vars/defaults.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ certificates_source: default
33
database_mode: internal
44
tuning: default
55
features: []
6-
enabled_features: "{{ (flavor_features + features) }}"
6+
remove_features: []
7+
enabled_features: "{{ (flavor_features + features) | difference(remove_features) }}"

tests/unit/filter_test.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from foremanctl import foreman_proxy_plugins
55
from foremanctl import hammer_plugins
66
from foremanctl import list_all_features
7+
from foremanctl import unsatisfied_dependencies
78

89

910
def _asymmetric_conflicts():
@@ -68,6 +69,38 @@ def test_list_all_features_marks_dependency_as_enabled(monkeypatch):
6869
assert 'enabled' in child_line
6970

7071

72+
def test_unsatisfied_dependencies_none_when_nothing_removed():
73+
assert unsatisfied_dependencies(['foreman', 'katello'], []) == []
74+
75+
76+
def test_unsatisfied_dependencies_ignores_transitive_deps():
77+
# httpd/valkey/dynflow/tasks are never listed explicitly; with no removals
78+
# requested this must not report them as missing.
79+
assert unsatisfied_dependencies(['foreman', 'katello', 'pulp']) == []
80+
81+
82+
def test_unsatisfied_dependencies_detects_removed_dependency(monkeypatch):
83+
monkeypatch.setitem(FEATURE_MAP, 'test-parent', {'dependencies': ['test-child']})
84+
monkeypatch.setitem(FEATURE_MAP, 'test-child', {})
85+
result = unsatisfied_dependencies(['test-parent'], ['test-child'])
86+
assert result == ["Cannot remove 'test-child' — it is required by enabled feature 'test-parent'"]
87+
88+
89+
def test_unsatisfied_dependencies_detects_transitively_removed_dependency(monkeypatch):
90+
monkeypatch.setitem(FEATURE_MAP, 'test-parent', {'dependencies': ['test-mid']})
91+
monkeypatch.setitem(FEATURE_MAP, 'test-mid', {'dependencies': ['test-leaf']})
92+
monkeypatch.setitem(FEATURE_MAP, 'test-leaf', {})
93+
result = unsatisfied_dependencies(['test-parent'], ['test-leaf'])
94+
assert any("Cannot remove 'test-leaf'" in error for error in result)
95+
96+
97+
def test_unsatisfied_dependencies_allows_unrelated_removal(monkeypatch):
98+
monkeypatch.setitem(FEATURE_MAP, 'test-parent', {'dependencies': ['test-child']})
99+
monkeypatch.setitem(FEATURE_MAP, 'test-child', {})
100+
monkeypatch.setitem(FEATURE_MAP, 'test-other', {})
101+
assert unsatisfied_dependencies(['test-parent'], ['test-other']) == []
102+
103+
71104
def test_foreman_plugins_deduplicates(monkeypatch):
72105
monkeypatch.setitem(FEATURE_MAP, 'test-parent', {
73106
'dependencies': ['test-child'],

0 commit comments

Comments
 (0)