Skip to content
Draft
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
5 changes: 5 additions & 0 deletions development/playbooks/_flavor_features/metadata.obsah.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,8 @@ variables:
parameter: --add-feature
help: Additional features to enable in this deployment.
action: append_unique
remove_features:
parameter: --remove-feature
help: Features to remove from this deployment.
action: append_unique
persist: false
3 changes: 3 additions & 0 deletions development/playbooks/deploy-dev/deploy-dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@
- role: cloud_connector
when:
- enabled_features | has_feature('cloud-connector')
- role: iop_remove
when:
- "'iop' in (remove_features | default([]))"
post_tasks:
- name: Stop Foreman development service
ansible.builtin.include_role:
Expand Down
4 changes: 4 additions & 0 deletions src/features.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ cloud-connector:
- iop
iop:
description: iop services
removable: true
dependencies:
- rh-cloud
conflicts:
Expand All @@ -107,6 +108,7 @@ container-gateway:
- foreman
bmc:
description: Power management for bare metal hosts (IPMI, Redfish)
removable: true
foreman_proxy:
plugin_name: bmc
webhooks:
Expand All @@ -116,10 +118,12 @@ webhooks:
hammer: foreman_webhooks
templates:
description: Templates feature for foreman-proxy
removable: true
foreman_proxy:
plugin_name: templates
registration:
description: Host registration feature for foreman-proxy
removable: true
foreman_proxy:
plugin_name: registration
dependencies:
Expand Down
84 changes: 76 additions & 8 deletions src/filter_plugins/foremanctl.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ def get_dependencies(features):
return dependencies


def resolve_dependencies(features):
"""Return features plus all their transitive dependencies."""
all_features = list(features)
for dep in get_dependencies(features):
if dep not in all_features:
all_features.append(dep)
return all_features


def foreman_plugins(value):
dependencies = list(get_dependencies(filter_features(value)))
plugins = [FEATURE_MAP.get(feature, {}).get('foreman', {}).get('plugin_name') for feature in filter_features(value + dependencies)]
Expand All @@ -79,23 +88,34 @@ def list_all_features(enabled_features, only_enabled=False):
if internal and not list_internal:
continue
description = meta.get('description', '')

if meta.get('removable', False):
removable = 'yes'
else:
removable = 'no'

if has_feature(enabled_features, name):
enabled_list.append((name, 'enabled', internal, description))
enabled_list.append((name, 'enabled', internal, removable, description))
elif not only_enabled:
available_list.append((name, 'available', internal, description))
available_list.append((name, 'available', internal, removable, description))

if not list_internal:
output = [f"{'FEATURE':<25} {'STATE':<12} DESCRIPTION"]
for name, state, _internal, description in enabled_list + available_list:
output.append(f"{name:<25} {state:<12} {description}")
output = [f"{'FEATURE':<25} {'STATE':<12} {'REMOVABLE':<13} DESCRIPTION"]
for name, state, _internal, removable, description in enabled_list + available_list:
output.append(f"{name:<25} {state:<12} {removable:<13} {description}")
else:
output = [f"{'FEATURE':<25} {'STATE':<12} {'INTERNAL':<8} DESCRIPTION"]
for name, state, internal, description in enabled_list + available_list:
output.append(f"{name:<25} {state:<12} {internal:<8} {description}")
output = [f"{'FEATURE':<25} {'STATE':<12} {'INTERNAL':<8} {'REMOVABLE':<13} DESCRIPTION"]
for name, state, internal, removable, description in enabled_list + available_list:
output.append(f"{name:<25} {state:<12} {internal:<8} {removable:<13} {description}")

return "\n".join(output)


def is_feature_removable(feature_name):
"""Check if a feature supports removal."""
return FEATURE_MAP.get(feature_name, {}).get('removable', False)


def invalid_features(features):
"""Return a list of unknown features not defined in features.yaml."""
return [feature for feature in features if feature not in FEATURE_MAP]
Expand All @@ -111,6 +131,51 @@ def conflicting_features(features):
return [f"{pair[0]} conflicts with {pair[1]}" for pair in conflicts]


def validate_feature_removals(remove_features, flavor_features):
"""Validate that requested feature removals are allowed.

Returns a list of error message strings. Empty list means all valid.
"""
errors = []

for feature in remove_features:
if feature in flavor_features:
errors.append(
f"Cannot remove '{feature}' — it is a core feature of the current flavor. "
f"Flavor features cannot be removed."
)
elif feature not in FEATURE_MAP:
errors.append(
f"Cannot remove unknown feature '{feature}'. "
f"Run 'foremanctl features' to see available features."
)
elif not is_feature_removable(feature):
errors.append(
f"Cannot remove feature '{feature}' — this feature does not support removal. "
f"Run 'foremanctl features' to see which features can be removed."
)

return errors


def unsatisfied_dependencies(enabled_features):
"""Check that all feature dependencies are satisfied.

Returns a list of error strings for missing dependencies.
"""
errors = []
enabled_set = set(enabled_features)

for feature in enabled_features:
missing = set(get_dependencies_for_feature(feature)) - enabled_set
if missing:
errors.append(
f"Feature '{feature}' requires {', '.join(sorted(missing))} which are not enabled"
)

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 @@ -161,6 +226,9 @@ def filters(self):
'list_all_features': list_all_features,
'invalid_features': invalid_features,
'conflicting_features': conflicting_features,
'validate_feature_removals': validate_feature_removals,
'resolve_dependencies': resolve_dependencies,
'unsatisfied_dependencies': unsatisfied_dependencies,
'has_feature': has_feature,
'databases_for_features': databases_for_features,
'to_postgresql_databases': to_postgresql_databases,
Expand Down
6 changes: 3 additions & 3 deletions src/playbooks/_flavor_features/metadata.obsah.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ variables:
action: append_unique
remove_features:
parameter: --remove-feature
help: Additional features to disable in this deployment.
action: remove
dest: features
help: Features to remove from this deployment.
action: append_unique
persist: false
4 changes: 4 additions & 0 deletions src/playbooks/deploy/deploy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@
when:
- "enabled_features | has_feature('iop')"
- database_mode == 'internal'
- role: iop_remove
when:
- "'iop' in (remove_features | default([]))"
- database_mode == 'internal'
- role: foreman_proxy
when:
- "enabled_features | has_feature('foreman-proxy')"
Expand Down
54 changes: 48 additions & 6 deletions src/roles/check_features/tasks/main.yaml
Original file line number Diff line number Diff line change
@@ -1,26 +1,68 @@
---
- name: Validate feature removal requests
ansible.builtin.assert:
that:
- check_features_removal_errors | length == 0
fail_msg: |
ERROR: Invalid feature removal request:
{% for error in check_features_removal_errors %}
- {{ error }}
{% endfor %}
vars:
check_features_removal_errors: "{{ remove_features | validate_feature_removals(flavor_features) }}"
when: remove_features | length > 0

- name: Validate feature dependencies
ansible.builtin.assert:
that:
- check_features_dependency_errors | length == 0
fail_msg: |
ERROR: Unsatisfied feature dependencies:
{% for error in check_features_dependency_errors %}
- {{ error }}
{% endfor %}
vars:
check_features_dependency_errors: "{{ enabled_features | unsatisfied_dependencies }}"

- name: Persist feature removals
when: remove_features | length > 0
block:
- name: Read current persisted parameters
ansible.builtin.slurp:
src: "{{ lookup('env', 'OBSAH_STATE') }}/parameters.yaml"
register: check_features_persisted_params_raw

- name: Write updated parameters
ansible.builtin.copy:
content: "{{ check_features_current_params | combine({'features': check_features_updated_features}) | to_nice_yaml }}"
dest: "{{ lookup('env', 'OBSAH_STATE') }}/parameters.yaml"
mode: "0644"
vars:
check_features_current_params: "{{ check_features_persisted_params_raw.content | b64decode | from_yaml }}"
check_features_updated_features: "{{ (check_features_current_params.features | default([])) | difference(remove_features) }}"

- name: Validate requested features
ansible.builtin.assert:
that:
- found_invalid_features | length == 0
- check_features_invalid | length == 0
fail_msg: |
ERROR: Unknown feature(s) requested: {{ found_invalid_features | join(', ') }}
ERROR: Unknown feature(s) requested: {{ check_features_invalid | join(', ') }}

Run 'foremanctl features' to list all available features.
vars:
found_invalid_features: "{{ features | invalid_features }}"
check_features_invalid: "{{ features | invalid_features }}"
when: features | length > 0

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

These features cannot be enabled together.
vars:
found_conflicts: "{{ enabled_features | conflicting_features }}"
check_features_conflicts: "{{ enabled_features | conflicting_features }}"
Loading
Loading