diff --git a/development/playbooks/_flavor_features/metadata.obsah.yaml b/development/playbooks/_flavor_features/metadata.obsah.yaml index d40913a1a..2b59e0473 100644 --- a/development/playbooks/_flavor_features/metadata.obsah.yaml +++ b/development/playbooks/_flavor_features/metadata.obsah.yaml @@ -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 diff --git a/development/playbooks/deploy-dev/deploy-dev.yaml b/development/playbooks/deploy-dev/deploy-dev.yaml index 8e70dcc4b..884f17a91 100644 --- a/development/playbooks/deploy-dev/deploy-dev.yaml +++ b/development/playbooks/deploy-dev/deploy-dev.yaml @@ -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: diff --git a/src/features.yaml b/src/features.yaml index e4a96c1e8..a6b5cfae6 100644 --- a/src/features.yaml +++ b/src/features.yaml @@ -92,6 +92,7 @@ cloud-connector: - iop iop: description: iop services + removable: true dependencies: - rh-cloud conflicts: @@ -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: @@ -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: diff --git a/src/filter_plugins/foremanctl.py b/src/filter_plugins/foremanctl.py index 9eccd0560..d3bacaecc 100644 --- a/src/filter_plugins/foremanctl.py +++ b/src/filter_plugins/foremanctl.py @@ -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)] @@ -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] @@ -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)] @@ -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, diff --git a/src/playbooks/_flavor_features/metadata.obsah.yaml b/src/playbooks/_flavor_features/metadata.obsah.yaml index 532a92ddf..2b59e0473 100644 --- a/src/playbooks/_flavor_features/metadata.obsah.yaml +++ b/src/playbooks/_flavor_features/metadata.obsah.yaml @@ -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 diff --git a/src/playbooks/deploy/deploy.yaml b/src/playbooks/deploy/deploy.yaml index 0aa62d905..d406e82d8 100644 --- a/src/playbooks/deploy/deploy.yaml +++ b/src/playbooks/deploy/deploy.yaml @@ -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')" diff --git a/src/roles/check_features/tasks/main.yaml b/src/roles/check_features/tasks/main.yaml index 6d05c1b57..ac4a09482 100644 --- a/src/roles/check_features/tasks/main.yaml +++ b/src/roles/check_features/tasks/main.yaml @@ -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 }}" diff --git a/src/roles/iop_remove/tasks/main.yaml b/src/roles/iop_remove/tasks/main.yaml new file mode 100644 index 000000000..a7c196b43 --- /dev/null +++ b/src/roles/iop_remove/tasks/main.yaml @@ -0,0 +1,251 @@ +--- +- name: Deregister IOP Gateway smart proxy from Foreman + theforeman.foreman.smart_proxy: + name: iop-gateway + url: "https://localhost:24443" + state: absent + server_url: "{{ iop_core_foreman_url }}" + oauth1_consumer_key: "{{ iop_core_foreman_oauth_consumer_key }}" + oauth1_consumer_secret: "{{ iop_core_foreman_oauth_consumer_secret }}" + validate_certs: false + failed_when: false + +- name: Stop IOP container services + ansible.builtin.systemd: + name: "{{ item }}" + state: stopped + failed_when: false + loop: + - iop-service-vuln-manager + - iop-service-vuln-taskomatic + - iop-service-vuln-grouper + - iop-service-vuln-listener + - iop-service-vuln-evaluator-recalc + - iop-service-vuln-evaluator-upload + - iop-service-vuln-vmaas-sync + - iop-service-vuln-dbupgrade + - iop-service-vmaas-reposcan + - iop-service-vmaas-webapp-go + - iop-service-remediations-api + - iop-service-advisor-backend-api + - iop-service-advisor-backend-service + - iop-core-host-inventory-api + - iop-core-host-inventory-cleanup + - iop-core-host-inventory-migrate + - iop-core-host-inventory + - iop-core-gateway + - iop-core-engine + - iop-core-yuptoo + - iop-core-puptoo + - iop-core-ingress + - iop-core-kafka + +- name: Disable and stop IOP timer and path units + ansible.builtin.systemd: + name: "{{ item }}" + state: stopped + enabled: false + failed_when: false + loop: + - iop-core-host-inventory-cleanup.timer + - iop-cvemap-download.timer + - iop-cvemap-download.path + - iop-vex-download.timer + - iop-vex-download.path + - iop-service-vuln-vmaas-sync.timer + +- name: Remove IOP quadlet container files + ansible.builtin.file: + path: "/etc/containers/systemd/{{ item }}.container" + state: absent + loop: + - iop-service-vuln-manager + - iop-service-vuln-taskomatic + - iop-service-vuln-grouper + - iop-service-vuln-listener + - iop-service-vuln-evaluator-recalc + - iop-service-vuln-evaluator-upload + - iop-service-vuln-vmaas-sync + - iop-service-vuln-dbupgrade + - iop-service-vmaas-reposcan + - iop-service-vmaas-webapp-go + - iop-service-remediations-api + - iop-service-advisor-backend-api + - iop-service-advisor-backend-service + - iop-core-host-inventory-api + - iop-core-host-inventory-cleanup + - iop-core-host-inventory-migrate + - iop-core-host-inventory + - iop-core-gateway + - iop-core-engine + - iop-core-yuptoo + - iop-core-puptoo + - iop-core-ingress + - iop-core-kafka + +- name: Remove IOP systemd unit files + ansible.builtin.file: + path: "{{ item }}" + state: absent + loop: + - /etc/systemd/system/iop-core-host-inventory-cleanup.timer + - /etc/systemd/system/iop-cvemap-download.service + - /etc/systemd/system/iop-cvemap-download.timer + - /etc/systemd/system/iop-cvemap-download.path + - /etc/systemd/system/iop-vex-download.service + - /etc/systemd/system/iop-vex-download.timer + - /etc/systemd/system/iop-vex-download.path + - /etc/systemd/system/iop-service-vuln-vmaas-sync.timer + +- name: Reload systemd daemon + ansible.builtin.systemd: + daemon_reload: true + +- name: Remove IOP podman secrets + containers.podman.podman_secret: + name: "{{ item }}" + state: absent + failed_when: false + loop: + - iop-core-kafka-init-start + - iop-core-kafka-server-properties + - iop-core-kafka-init + - iop-core-engine-config-yml + - iop-core-gateway-server-cert + - iop-core-gateway-server-key + - iop-core-gateway-server-ca-cert + - iop-core-gateway-client-cert + - iop-core-gateway-client-key + - iop-core-gateway-client-ca-cert + - iop-core-gateway-relay-conf + - iop-core-host-inventory-database-username + - iop-core-host-inventory-database-password + - iop-core-host-inventory-database-name + - iop-core-host-inventory-database-host + - iop-core-host-inventory-database-port + - iop-service-advisor-backend-database-username + - iop-service-advisor-backend-database-password + - iop-service-advisor-backend-database-name + - iop-service-advisor-backend-database-host + - iop-service-advisor-backend-database-port + - iop-service-remediations-db-username + - iop-service-remediations-db-password + - iop-service-remediations-db-name + - iop-service-remediations-db-host + - iop-service-remediations-db-port + - iop-service-vmaas-reposcan-client-ca-cert + - iop-service-vmaas-reposcan-database-username + - iop-service-vmaas-reposcan-database-password + - iop-service-vmaas-reposcan-database-name + - iop-service-vmaas-reposcan-database-host + - iop-service-vmaas-reposcan-database-port + - iop-service-vulnerability-database-username + - iop-service-vulnerability-database-password + - iop-service-vulnerability-database-name + - iop-service-vulnerability-database-host + - iop-service-vulnerability-database-port + +- name: Remove IOP podman volumes + containers.podman.podman_volume: + name: "{{ item }}" + state: absent + failed_when: false + loop: + - iop-core-kafka-data + - iop-service-vmaas-data + +- name: Remove IOP image quadlet files + ansible.builtin.file: + path: "/etc/containers/systemd/{{ item }}" + state: absent + loop: + - iop-kafka.image + - iop-kafka.image.d + - iop-ingress.image + - iop-ingress.image.d + - iop-puptoo.image + - iop-puptoo.image.d + - iop-yuptoo.image + - iop-yuptoo.image.d + - iop-engine.image + - iop-engine.image.d + - iop-gateway.image + - iop-gateway.image.d + - iop-inventory.image + - iop-inventory.image.d + - iop-advisor.image + - iop-advisor.image.d + - iop-remediation.image + - iop-remediation.image.d + - iop-vmaas.image + - iop-vmaas.image.d + - iop-vulnerability.image + - iop-vulnerability.image.d + - iop-inventory-frontend.image + - iop-inventory-frontend.image.d + - iop-advisor-frontend.image + - iop-advisor-frontend.image.d + - iop-vulnerability-frontend.image + - iop-vulnerability-frontend.image.d + +- name: Remove IOP podman network + containers.podman.podman_network: + name: iop-core-network + state: absent + failed_when: false + +- name: Remove IOP Apache configuration files + ansible.builtin.file: + path: "{{ item }}" + state: absent + loop: + - /etc/httpd/conf.d/05-foreman-ssl.d/inventory-frontend.conf + - /etc/httpd/conf.d/05-foreman-ssl.d/advisor-frontend.conf + - /etc/httpd/conf.d/05-foreman-ssl.d/vulnerability-frontend.conf + notify: "httpd : Restart httpd" + +- name: Remove IOP frontend asset directories + ansible.builtin.file: + path: /var/www/iop + state: absent + +- name: Remove IOP cvemap downloader script + ansible.builtin.file: + path: /usr/local/bin/iop-cvemap-download.sh + state: absent + +- name: Remove IOP vex downloader script + ansible.builtin.file: + path: /usr/local/bin/iop-vex-downloader.sh + state: absent + +- name: Remove IOP vex data directory + ansible.builtin.file: + path: /var/www/html/pub/iop + state: absent + +- name: Remove FDW objects from advisor database + community.postgresql.postgresql_query: + login_db: "{{ iop_advisor_database_name }}" + login_user: postgres + login_password: "{{ postgresql_admin_password }}" + login_host: localhost + query: "{{ item }}" + loop: + - "DROP SERVER IF EXISTS hbi_server CASCADE" + - "DROP SCHEMA IF EXISTS inventory_source CASCADE" + - "DROP SCHEMA IF EXISTS inventory CASCADE" + failed_when: false + +- name: Remove FDW objects from vulnerability database + community.postgresql.postgresql_query: + login_db: "{{ iop_vulnerability_database_name }}" + login_user: postgres + login_password: "{{ postgresql_admin_password }}" + login_host: localhost + query: "{{ item }}" + loop: + - "DROP SERVER IF EXISTS hbi_server CASCADE" + - "DROP SCHEMA IF EXISTS inventory_source CASCADE" + - "DROP SCHEMA IF EXISTS inventory CASCADE" + failed_when: false diff --git a/src/vars/defaults.yml b/src/vars/defaults.yml index 28fd774c3..870f7af2b 100644 --- a/src/vars/defaults.yml +++ b/src/vars/defaults.yml @@ -3,4 +3,5 @@ certificates_source: default database_mode: internal tuning: default features: [] -enabled_features: "{{ (flavor_features + features) }}" +remove_features: [] +enabled_features: "{{ (flavor_features + features) | difference(remove_features) | resolve_dependencies }}" diff --git a/tests/unit/filter_test.py b/tests/unit/filter_test.py index 55d150d38..a74fd7d2a 100644 --- a/tests/unit/filter_test.py +++ b/tests/unit/filter_test.py @@ -1,6 +1,8 @@ from foremanctl import FEATURE_MAP from foremanctl import conflicting_features from foremanctl import list_all_features +from foremanctl import resolve_dependencies +from foremanctl import unsatisfied_dependencies def _asymmetric_conflicts(): @@ -63,3 +65,41 @@ def test_list_all_features_marks_dependency_as_enabled(monkeypatch): output = list_all_features(['test-parent']) child_line = next(line for line in output.splitlines() if line.startswith('test-child')) assert 'enabled' in child_line + + +def test_resolve_dependencies_adds_transitive_deps(monkeypatch): + monkeypatch.setitem(FEATURE_MAP, 'test-a', {'dependencies': ['test-b']}) + monkeypatch.setitem(FEATURE_MAP, 'test-b', {'dependencies': ['test-c']}) + monkeypatch.setitem(FEATURE_MAP, 'test-c', {}) + result = resolve_dependencies(['test-a']) + assert set(result) == {'test-a', 'test-b', 'test-c'} + + +def test_resolve_dependencies_no_duplicates(monkeypatch): + monkeypatch.setitem(FEATURE_MAP, 'test-a', {'dependencies': ['test-b']}) + monkeypatch.setitem(FEATURE_MAP, 'test-b', {}) + result = resolve_dependencies(['test-a', 'test-b']) + assert result.count('test-b') == 1 + + +def test_resolve_dependencies_preserves_input_order(monkeypatch): + monkeypatch.setitem(FEATURE_MAP, 'test-a', {}) + monkeypatch.setitem(FEATURE_MAP, 'test-b', {}) + result = resolve_dependencies(['test-b', 'test-a']) + assert result[:2] == ['test-b', 'test-a'] + + +def test_unsatisfied_dependencies_passes_after_resolve(monkeypatch): + monkeypatch.setitem(FEATURE_MAP, 'test-a', {'dependencies': ['test-b']}) + monkeypatch.setitem(FEATURE_MAP, 'test-b', {}) + resolved = resolve_dependencies(['test-a']) + assert unsatisfied_dependencies(resolved) == [] + + +def test_unsatisfied_dependencies_detects_missing(): + assert unsatisfied_dependencies(['katello']) != [] + + +def test_unsatisfied_dependencies_passes_with_resolved(): + resolved = resolve_dependencies(['katello']) + assert unsatisfied_dependencies(resolved) == []