diff --git a/development/ansible.cfg b/development/ansible.cfg index 15225a2c6..40fc13d79 100644 --- a/development/ansible.cfg +++ b/development/ansible.cfg @@ -3,4 +3,5 @@ host_key_checking = False stdout_callback=debug stderr_callback=debug roles_path = ./roles:../src/roles +filter_plugins = ../src/filter_plugins display_skipped_hosts = no diff --git a/development/playbooks/deploy-dev/deploy-dev.yaml b/development/playbooks/deploy-dev/deploy-dev.yaml index caf89c1d9..892ee0a74 100644 --- a/development/playbooks/deploy-dev/deploy-dev.yaml +++ b/development/playbooks/deploy-dev/deploy-dev.yaml @@ -13,6 +13,11 @@ vars: httpd_foreman_backend: "http://localhost:3000" pre_tasks: + - name: Check cloud-connector prerequisites + ansible.builtin.include_role: + name: check_cloud_connector + when: "'cloud-connector' in enabled_features" + - name: Set development postgresql databases ansible.builtin.set_fact: postgresql_databases: @@ -55,6 +60,7 @@ foreman_development_enabled_plugins: "{{ foreman_development_enabled_plugins + ['foreman_ansible'] }}" roles: - role: pre_install + - role: check_features - role: certificates - role: postgresql - role: redis @@ -72,6 +78,12 @@ vars: iop_core_foreman_oauth_consumer_key: "{{ foreman_oauth_consumer_key }}" iop_core_foreman_oauth_consumer_secret: "{{ foreman_oauth_consumer_secret }}" + - role: cloud_connector + when: + - "'cloud-connector' in enabled_features" + vars: + cloud_connector_user: "{{ foreman_development_admin_user }}" + cloud_connector_password: "{{ foreman_development_admin_password }}" post_tasks: - name: Stop Foreman development service ansible.builtin.include_role: diff --git a/development/playbooks/deploy-dev/metadata.obsah.yaml b/development/playbooks/deploy-dev/metadata.obsah.yaml index 964d6bf5c..0e95871b3 100644 --- a/development/playbooks/deploy-dev/metadata.obsah.yaml +++ b/development/playbooks/deploy-dev/metadata.obsah.yaml @@ -29,6 +29,13 @@ variables: foreman_development_github_username: help: GitHub username to add as additional remote for git checkouts action: store + cloud_connector_http_proxy: + parameter: --cloud-connector-http-proxy + help: HTTP proxy URL for the cloud connector rhcd service. + foreman_development_preserve_plugin_branches: + parameter: --preserve-plugin-branches + help: Skip git checkout for plugins, preserving local branches and changes. + type: Boolean include: - _flavor_features diff --git a/forge b/forge index 76503996d..25c5c146d 100755 --- a/forge +++ b/forge @@ -5,7 +5,8 @@ OBSAH_BASE=$(dirname $(readlink -f $0)) OBSAH_DATA=${OBSAH_BASE}/development OBSAH_INVENTORY=${OBSAH_BASE}/inventories OBSAH_STATE=${OBSAH_BASE}/.var/lib/foremanctl -export OBSAH_NAME OBSAH_DATA OBSAH_INVENTORY OBSAH_STATE +OBSAH_PERSIST_PARAMS=true +export OBSAH_NAME OBSAH_DATA OBSAH_INVENTORY OBSAH_STATE OBSAH_PERSIST_PARAMS ANSIBLE_COLLECTIONS_PATH=${OBSAH_BASE}/build/collections/forge ANSIBLE_COLLECTIONS_SCAN_SYS_PATH=false diff --git a/src/features.yaml b/src/features.yaml index daf451bdc..4cdf3db4b 100644 --- a/src/features.yaml +++ b/src/features.yaml @@ -50,6 +50,12 @@ rh-cloud: hammer: foreman_rh_cloud dependencies: - katello +cloud-connector: + description: Cloud Connector for Red Hat Hybrid Cloud Console + dependencies: + - rh-cloud + conflicts: + - iop iop: description: iop services dependencies: diff --git a/src/filter_plugins/foremanctl.py b/src/filter_plugins/foremanctl.py index 25c9ebc1c..73de53500 100644 --- a/src/filter_plugins/foremanctl.py +++ b/src/filter_plugins/foremanctl.py @@ -93,6 +93,21 @@ def invalid_features(features): return [feature for feature in features if feature not in FEATURE_MAP] +def conflicting_features(enabled_features): + """Return list of conflict descriptions for any mutually exclusive features.""" + conflicts = [] + seen = set() + enabled_set = set(enabled_features) + for feature in enabled_features: + for conflict in FEATURE_MAP.get(feature, {}).get('conflicts', []): + if conflict in enabled_set: + 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 hammer_plugins(value): dependencies = list(get_dependencies(filter_features(value))) plugins = [FEATURE_MAP.get(feature, {}).get('hammer') for feature in filter_features(value + dependencies)] @@ -127,5 +142,6 @@ 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, 'has_feature': has_feature, } diff --git a/src/playbooks/deploy/deploy.yaml b/src/playbooks/deploy/deploy.yaml index 837d36c98..d7fb41c63 100644 --- a/src/playbooks/deploy/deploy.yaml +++ b/src/playbooks/deploy/deploy.yaml @@ -55,4 +55,7 @@ - role: hammer when: - "'hammer' in enabled_features" + - role: cloud_connector + when: + - "'cloud-connector' in enabled_features" - post_install diff --git a/src/playbooks/deploy/metadata.obsah.yaml b/src/playbooks/deploy/metadata.obsah.yaml index c1a32b484..c86496ba9 100644 --- a/src/playbooks/deploy/metadata.obsah.yaml +++ b/src/playbooks/deploy/metadata.obsah.yaml @@ -56,6 +56,9 @@ variables: parameter: --bmc-redfish-verify-ssl help: Verify SSL certificates for Redfish BMC connections. type: Boolean + cloud_connector_http_proxy: + parameter: --cloud-connector-http-proxy + help: HTTP proxy URL for the cloud connector rhcd service. constraints: required_together: diff --git a/src/roles/check_cloud_connector/tasks/main.yaml b/src/roles/check_cloud_connector/tasks/main.yaml new file mode 100644 index 000000000..e661437ba --- /dev/null +++ b/src/roles/check_cloud_connector/tasks/main.yaml @@ -0,0 +1,38 @@ +--- +- name: Check cloud-connector prerequisites + when: "'cloud-connector' in enabled_features" + block: + - name: Verify cloud-connector is not used with iop + ansible.builtin.assert: + that: + - "'iop' not in enabled_features" + fail_msg: >- + The cloud-connector feature cannot be used together with the iop feature. + Remove one of them with --remove-feature before deploying. + + - name: Check that consumer certificate exists + ansible.builtin.stat: + path: /etc/pki/consumer/cert.pem + register: check_cloud_connector_consumer_cert + + - name: Verify consumer certificate exists + ansible.builtin.assert: + that: + - check_cloud_connector_consumer_cert.stat.exists + fail_msg: >- + /etc/pki/consumer/cert.pem not found. + The system must be registered with subscription-manager. + + - name: Check that yggdrasil-worker-forwarder package is available + ansible.builtin.command: dnf info yggdrasil-worker-forwarder + changed_when: false + failed_when: false + register: check_cloud_connector_pkg_check + + - name: Verify yggdrasil-worker-forwarder is available + ansible.builtin.assert: + that: + - check_cloud_connector_pkg_check.rc == 0 + fail_msg: >- + The yggdrasil-worker-forwarder package is not available. + Ensure the appropriate repository is enabled. diff --git a/src/roles/check_features/tasks/main.yaml b/src/roles/check_features/tasks/main.yaml index 2e5d1a7a8..ff232fee5 100644 --- a/src/roles/check_features/tasks/main.yaml +++ b/src/roles/check_features/tasks/main.yaml @@ -13,3 +13,14 @@ vars: found_invalid_features: "{{ features | invalid_features }}" when: features | length > 0 + +- name: Validate no conflicting features + ansible.builtin.assert: + that: + - found_conflicting_features | length == 0 + fail_msg: | + ERROR: Conflicting features enabled: {{ found_conflicting_features | join(', ') }} + + Remove one of the conflicting features with --remove-feature before deploying. + vars: + found_conflicting_features: "{{ enabled_features | conflicting_features }}" diff --git a/src/roles/checks/tasks/main.yml b/src/roles/checks/tasks/main.yml index 90dbf9e1d..0cc796d47 100644 --- a/src/roles/checks/tasks/main.yml +++ b/src/roles/checks/tasks/main.yml @@ -3,6 +3,7 @@ ansible.builtin.include_tasks: execute_check.yml loop: - check_features + - check_cloud_connector - check_hostname - check_database_connection - check_system_requirements diff --git a/src/roles/cloud_connector/defaults/main.yaml b/src/roles/cloud_connector/defaults/main.yaml new file mode 100644 index 000000000..cd4987c11 --- /dev/null +++ b/src/roles/cloud_connector/defaults/main.yaml @@ -0,0 +1,5 @@ +--- +cloud_connector_url: "https://{{ ansible_facts['fqdn'] }}" +cloud_connector_user: admin +cloud_connector_password: changeme +cloud_connector_config_file: /etc/rhc/workers/foreman_rh_cloud.toml diff --git a/src/roles/cloud_connector/handlers/main.yaml b/src/roles/cloud_connector/handlers/main.yaml new file mode 100644 index 000000000..e08bcb1e6 --- /dev/null +++ b/src/roles/cloud_connector/handlers/main.yaml @@ -0,0 +1,6 @@ +--- +- name: Restart rhcd + ansible.builtin.service: + name: rhcd + state: restarted + daemon_reload: true diff --git a/src/roles/cloud_connector/tasks/http_proxy.yaml b/src/roles/cloud_connector/tasks/http_proxy.yaml new file mode 100644 index 000000000..2c5826ae7 --- /dev/null +++ b/src/roles/cloud_connector/tasks/http_proxy.yaml @@ -0,0 +1,17 @@ +--- +- name: Create systemd drop-in directory for rhcd + ansible.builtin.file: + state: directory + path: /etc/systemd/system/rhcd.service.d + owner: root + group: root + mode: '0755' + +- name: Deploy HTTP proxy systemd drop-in for rhcd + ansible.builtin.template: + src: proxy.conf.j2 + dest: /etc/systemd/system/rhcd.service.d/proxy.conf + owner: root + group: root + mode: '0644' + notify: Restart rhcd diff --git a/src/roles/cloud_connector/tasks/main.yaml b/src/roles/cloud_connector/tasks/main.yaml new file mode 100644 index 000000000..93b56dd01 --- /dev/null +++ b/src/roles/cloud_connector/tasks/main.yaml @@ -0,0 +1,103 @@ +--- +- name: Install rhc and yggdrasil-worker-forwarder + ansible.builtin.package: + name: + - rhc + - yggdrasil-worker-forwarder + disable_plugin: foreman-protector + +- name: Create workers directory + ansible.builtin.file: + state: directory + path: /etc/rhc/workers + owner: root + group: root + mode: '0755' + +- name: Configure foreman-rh-cloud worker + ansible.builtin.template: + src: foreman_rh_cloud.toml.j2 + dest: "{{ cloud_connector_config_file }}" + owner: root + group: root + mode: '0640' + notify: Restart rhcd + +- name: Create rhcd worker script + ansible.builtin.copy: + dest: /usr/libexec/rhc/foreman-rh-cloud-worker + content: | + #!/bin/bash + + CONFIG_FILE="{{ cloud_connector_config_file }}" exec /usr/libexec/yggdrasil-worker-forwarder + owner: root + group: root + mode: '0755' + +- name: Add Foreman CA to system trust store + ansible.builtin.copy: + src: "{{ foreman_ca_certificate }}" + dest: /etc/pki/ca-trust/source/anchors/foreman-ca.pem + remote_src: true + owner: root + group: root + mode: '0644' + notify: Restart rhcd + +- name: Update system CA trust + ansible.builtin.command: update-ca-trust + changed_when: true + +- name: Ensure rhcd started and enabled + ansible.builtin.service: + name: rhcd + state: started + enabled: true + +- name: Read client ID from CN of consumer certificate + ansible.builtin.command: openssl x509 -in /etc/pki/consumer/cert.pem -subject -noout + register: cloud_connector_cert_output + changed_when: false + +- name: Set rhc_instance_id in Foreman + ansible.builtin.uri: + url: "{{ cloud_connector_url }}/api/settings/rhc_instance_id" + user: "{{ cloud_connector_user }}" + password: "{{ cloud_connector_password }}" + body: + setting: + value: "{{ cloud_connector_client_id }}" + method: PUT + ca_path: "{{ foreman_ca_certificate }}" + force_basic_auth: true + body_format: json + vars: + cloud_connector_client_id: "{{ cloud_connector_cert_output.stdout | regex_search('CN\\s?=\\s?([a-z0-9-]+)', '\\1') | first }}" + +- name: Enable automatic inventory upload + ansible.builtin.uri: + url: "{{ cloud_connector_url }}/api/settings/allow_auto_inventory_upload" + user: "{{ cloud_connector_user }}" + password: "{{ cloud_connector_password }}" + body: + setting: + value: true + method: PUT + ca_path: "{{ foreman_ca_certificate }}" + force_basic_auth: true + body_format: json + +- name: Announce to Sources + ansible.builtin.uri: + url: "{{ cloud_connector_url }}/api/v2/rh_cloud/announce_to_sources" + user: "{{ cloud_connector_user }}" + password: "{{ cloud_connector_password }}" + method: POST + ca_path: "{{ foreman_ca_certificate }}" + force_basic_auth: true + body_format: json + status_code: [200, 201] + +- name: Configure HTTP proxy for rhcd + ansible.builtin.include_tasks: http_proxy.yaml + when: cloud_connector_http_proxy is defined diff --git a/src/roles/cloud_connector/templates/foreman_rh_cloud.toml.j2 b/src/roles/cloud_connector/templates/foreman_rh_cloud.toml.j2 new file mode 100644 index 000000000..0ed508931 --- /dev/null +++ b/src/roles/cloud_connector/templates/foreman_rh_cloud.toml.j2 @@ -0,0 +1,8 @@ +exec = "/usr/libexec/yggdrasil-worker-forwarder" +protocol = "grpc" +env = [ + "FORWARDER_USER={{ cloud_connector_user }}", + "FORWARDER_PASSWORD={{ cloud_connector_password }}", + "FORWARDER_URL={{ cloud_connector_url }}/api/v2/rh_cloud/cloud_request", + "FORWARDER_HANDLER=foreman_rh_cloud" +] diff --git a/src/roles/cloud_connector/templates/proxy.conf.j2 b/src/roles/cloud_connector/templates/proxy.conf.j2 new file mode 100644 index 000000000..3b59ade78 --- /dev/null +++ b/src/roles/cloud_connector/templates/proxy.conf.j2 @@ -0,0 +1,3 @@ +[Service] +Environment=HTTPS_PROXY={{ cloud_connector_http_proxy }} +Environment=NO_PROXY={{ cloud_connector_url | ansible.builtin.urlsplit('hostname') }} diff --git a/src/vars/base.yaml b/src/vars/base.yaml index b85b9d02f..830c8fd74 100644 --- a/src/vars/base.yaml +++ b/src/vars/base.yaml @@ -48,3 +48,7 @@ foreman_proxy_oauth_consumer_secret: "{{ foreman_oauth_consumer_secret }}" iop_core_foreman_url: "{{ foreman_url }}" iop_core_foreman_oauth_consumer_key: "{{ foreman_oauth_consumer_key }}" iop_core_foreman_oauth_consumer_secret: "{{ foreman_oauth_consumer_secret }}" + +cloud_connector_url: "{{ foreman_url }}" +cloud_connector_user: "{{ foreman_initial_admin_username }}" +cloud_connector_password: "{{ foreman_initial_admin_password }}" diff --git a/tests/cloud_connector_test.py b/tests/cloud_connector_test.py new file mode 100644 index 000000000..1869f80c4 --- /dev/null +++ b/tests/cloud_connector_test.py @@ -0,0 +1,38 @@ +import pytest + +pytestmark = pytest.mark.feature("cloud-connector") + + +def test_rhc_package_installed(server): + assert server.package("rhc").is_installed + + +def test_yggdrasil_worker_forwarder_package_installed(server): + assert server.package("yggdrasil-worker-forwarder").is_installed + + +def test_workers_directory_exists(server): + workers_dir = server.file("/etc/rhc/workers") + assert workers_dir.is_directory + assert workers_dir.mode == 0o755 + + +def test_worker_config_exists(server): + config = server.file("/etc/rhc/workers/foreman_rh_cloud.toml") + assert config.is_file + assert config.mode == 0o640 + assert config.contains("FORWARDER_HANDLER=foreman_rh_cloud") + assert config.contains("/api/v2/rh_cloud/cloud_request") + + +def test_worker_script_exists(server): + script = server.file("/usr/libexec/rhc/foreman-rh-cloud-worker") + assert script.is_file + assert script.mode == 0o755 + assert script.contains("yggdrasil-worker-forwarder") + + +def test_rhcd_service_running(server): + rhcd = server.service("rhcd") + assert rhcd.is_running + assert rhcd.is_enabled diff --git a/tests/unit/features_filter_test.py b/tests/unit/features_filter_test.py new file mode 100644 index 000000000..e1677e700 --- /dev/null +++ b/tests/unit/features_filter_test.py @@ -0,0 +1,60 @@ +from unittest.mock import patch + +from filter_plugins.foremanctl import conflicting_features + + +MOCK_FEATURES = { + 'alpha': {'description': 'Alpha feature'}, + 'beta': { + 'description': 'Beta feature', + 'conflicts': ['gamma'], + }, + 'gamma': {'description': 'Gamma feature'}, + 'delta': { + 'description': 'Delta feature', + 'conflicts': ['gamma'], + }, +} + + +@patch('filter_plugins.foremanctl.FEATURE_MAP', MOCK_FEATURES) +class TestConflictingFeatures: + def test_no_conflicts(self): + assert conflicting_features(['alpha', 'gamma']) == [] + + def test_detects_conflict(self): + result = conflicting_features(['beta', 'gamma']) + assert len(result) == 1 + assert 'beta conflicts with gamma' in result + + def test_no_conflict_when_only_one_side_enabled(self): + assert conflicting_features(['beta']) == [] + + def test_deduplicates_when_declared_on_both_sides(self): + both_declare = { + 'a': {'conflicts': ['b']}, + 'b': {'conflicts': ['a']}, + } + with patch('filter_plugins.foremanctl.FEATURE_MAP', both_declare): + result = conflicting_features(['a', 'b']) + assert len(result) == 1 + assert 'a conflicts with b' in result + + def test_multiple_conflicts(self): + result = conflicting_features(['beta', 'delta', 'gamma']) + assert len(result) == 2 + + def test_empty_features(self): + assert conflicting_features([]) == [] + + def test_features_without_conflicts_key(self): + assert conflicting_features(['alpha']) == [] + + def test_conflict_detected_when_added_separately(self): + """Simulates a previously persisted feature conflicting with a newly added one.""" + previously_persisted = ['beta'] + newly_added = ['gamma'] + enabled = previously_persisted + newly_added + result = conflicting_features(enabled) + assert len(result) == 1 + assert 'beta conflicts with gamma' in result