Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions docs/developer/feature-metadata.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ The following properties are defined:
* `role` (_String_): The name of the Ansible role to be executed if the feature is not implemented as a Foreman Proxy plugin.
* `hammer` (_String_): The name of the Hammer plugin to be enabled (the package installed will be `hammer-cli-plugin-{{ hammer }}`).
* `dependencies` (_Array_ of _String_): List of features that are automatically enabled when the user requests this feature. Usually will point at features with `internal: true`.
* `conflicts` (_Array_ of _String_): List of features that are mutually exclusive with this feature. If both are enabled, deployment will fail with an error. Conflicts must be declared on both sides — if feature A lists B in its conflicts, B must also list A.

Properties can be omitted.

Expand Down
22 changes: 22 additions & 0 deletions docs/developer/how-to-add-a-feature.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,28 @@ dynflow:
plugin_name: dynflow
```

### Conflicts

Use `conflicts` to declare that two features are mutually exclusive and cannot both be enabled in the same deployment. Conflicts must be declared on both sides:

```yaml
cloud-connector:
description: Cloud Connector for Red Hat Hybrid Cloud Console
dependencies:
- rh-cloud
conflicts:
- iop

iop:
description: iop services
dependencies:
- rh-cloud
conflicts:
- cloud-connector
```

When a user tries to enable both conflicting features, the deploy will fail early with an error identifying the conflict.

## Step 2: Configure the Foreman Proxy Plugin (if needed)

If the feature has no `foreman_proxy` section, skip to Step 3.
Expand Down
28 changes: 28 additions & 0 deletions src/filter_plugins/foremanctl.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,32 @@ def invalid_features(features):
return [feature for feature in features if feature not in FEATURE_MAP]


def conflicting_features(features):
"""Return a list of conflict violation strings for enabled features."""
seen = set()
conflicts = []
for feature in features:
for conflict in FEATURE_MAP.get(feature, {}).get('conflicts', []):
if conflict in features:
pair = tuple(sorted([feature, conflict]))
if pair not in seen:
seen.add(pair)
conflicts.append(f"{pair[0]} conflicts with {pair[1]}")
return conflicts


def asymmetric_conflicts():
"""Return a list of features with asymmetric conflict declarations."""
errors = []
for feature, meta in FEATURE_MAP.items():
for conflict in meta.get('conflicts', []):
if conflict not in FEATURE_MAP:
errors.append(f"{feature} declares conflict with unknown feature {conflict}")
elif feature not in FEATURE_MAP.get(conflict, {}).get('conflicts', []):
errors.append(f"{feature} declares conflict with {conflict}, but {conflict} does not declare conflict with {feature}")
return errors


def hammer_plugins(value):
dependencies = list(get_dependencies(filter_features(value)))
plugins = [FEATURE_MAP.get(feature, {}).get('hammer') for feature in filter_features(value + dependencies)]
Expand Down Expand Up @@ -137,6 +163,8 @@ def filters(self):
'available_foreman_proxy_plugins': available_foreman_proxy_plugins,
'list_all_features': list_all_features,
'invalid_features': invalid_features,
'conflicting_features': conflicting_features,
'asymmetric_conflicts': asymmetric_conflicts,
'has_feature': has_feature,
'to_postgresql_databases': to_postgresql_databases,
'to_postgresql_users': to_postgresql_users,
Expand Down
14 changes: 14 additions & 0 deletions src/roles/check_features/tasks/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,17 @@
vars:
found_invalid_features: "{{ features | invalid_features }}"
when: features | length > 0

- name: Validate feature conflicts
ansible.builtin.assert:
that:
- found_conflicts | length == 0
fail_msg: |
ERROR: Conflicting features detected:
{% for conflict in found_conflicts %}
- {{ conflict }}
{% endfor %}

These features cannot be enabled together.
vars:
found_conflicts: "{{ enabled_features | conflicting_features }}"
51 changes: 51 additions & 0 deletions tests/unit/filter_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import os
import sys

sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', 'src', 'filter_plugins'))

from foremanctl import FEATURE_MAP
from foremanctl import asymmetric_conflicts
from foremanctl import conflicting_features


def test_no_conflicts():
assert conflicting_features(['foreman', 'hammer']) == []


def test_detects_conflict(monkeypatch):
monkeypatch.setitem(FEATURE_MAP, 'test-a', {'conflicts': ['test-b']})
monkeypatch.setitem(FEATURE_MAP, 'test-b', {'conflicts': ['test-a']})
result = conflicting_features(['test-a', 'test-b'])
assert len(result) == 1
assert 'test-a conflicts with test-b' in result


def test_deduplicates_conflict_pairs(monkeypatch):
monkeypatch.setitem(FEATURE_MAP, 'test-a', {'conflicts': ['test-b']})
monkeypatch.setitem(FEATURE_MAP, 'test-b', {'conflicts': ['test-a']})
result = conflicting_features(['test-b', 'test-a'])
assert len(result) == 1


def test_no_conflict_when_only_one_present(monkeypatch):
monkeypatch.setitem(FEATURE_MAP, 'test-a', {'conflicts': ['test-b']})
monkeypatch.setitem(FEATURE_MAP, 'test-b', {'conflicts': ['test-a']})
assert conflicting_features(['test-a', 'foreman']) == []


def test_no_asymmetric_conflicts_in_features_yaml():
errors = asymmetric_conflicts()
assert errors == [], f"Asymmetric conflicts found: {errors}"


def test_asymmetric_conflict_detected(monkeypatch):
monkeypatch.setitem(FEATURE_MAP, 'test-a', {'conflicts': ['test-b']})
monkeypatch.setitem(FEATURE_MAP, 'test-b', {})
errors = asymmetric_conflicts()
assert any('test-a declares conflict with test-b' in e for e in errors)


def test_conflict_with_unknown_feature_detected(monkeypatch):
monkeypatch.setitem(FEATURE_MAP, 'test-a', {'conflicts': ['nonexistent']})
errors = asymmetric_conflicts()
assert any('unknown feature nonexistent' in e for e in errors)