diff --git a/examples/feature_flag_management.yml b/examples/feature_flag_management.yml new file mode 100644 index 00000000..434dbb05 --- /dev/null +++ b/examples/feature_flag_management.yml @@ -0,0 +1,76 @@ +--- +- name: Feature Flag Management Example + hosts: localhost + gather_facts: false + vars: + gateway_hostname: "{{ ansible_host }}" + gateway_username: "admin" + gateway_password: "{{ gateway_admin_password }}" + gateway_validate_certs: false + + tasks: + - name: Check if a feature flag exists + ansible.platform.feature_flag: + name: FEATURE_EXAMPLE_ENABLED + state: exists + gateway_hostname: "{{ gateway_hostname }}" + gateway_username: "{{ gateway_username }}" + gateway_password: "{{ gateway_password }}" + gateway_validate_certs: "{{ gateway_validate_certs }}" + register: feature_flag_info + + - name: Display feature flag information + ansible.builtin.debug: + msg: | + Feature Flag: {{ feature_flag_info.name }} + Current Value: {{ feature_flag_info.value }} + Toggle Type: {{ feature_flag_info.toggle_type }} + Description: {{ feature_flag_info.description }} + + - name: Enable a runtime feature flag (if it's a runtime flag) + ansible.platform.feature_flag: + name: "{{ feature_flag_info.name }}" + value: "True" + state: present + gateway_hostname: "{{ gateway_hostname }}" + gateway_username: "{{ gateway_username }}" + gateway_password: "{{ gateway_password }}" + gateway_validate_certs: "{{ gateway_validate_certs }}" + when: feature_flag_info.toggle_type == 'run-time' + register: feature_flag_update + + - name: Show update result + ansible.builtin.debug: + msg: | + Feature flag update result: + Changed: {{ feature_flag_update.changed | default('N/A') }} + New Value: {{ feature_flag_update.value | default('N/A') }} + when: feature_flag_update is defined + + - name: Configuration as Code Example - Ensure multiple feature flags are in desired state + ansible.platform.feature_flag: + name: "{{ item.name }}" + value: "{{ item.value }}" + state: "{{ item.state | default('present') }}" + gateway_hostname: "{{ gateway_hostname }}" + gateway_username: "{{ gateway_username }}" + gateway_password: "{{ gateway_password }}" + gateway_validate_certs: "{{ gateway_validate_certs }}" + loop: + - name: FEATURE_EXAMPLE_ONE_ENABLED + value: "True" + - name: FEATURE_EXAMPLE_TWO_ENABLED + value: "False" + state: enforced # Ensure exact value + when: false # Set to true to run this example + register: bulk_updates + + - name: Show bulk update results + ansible.builtin.debug: + msg: | + Flag: {{ item.name }} + Changed: {{ item.changed }} + Value: {{ item.value }} + loop: "{{ bulk_updates.results | default([]) }}" + when: bulk_updates is defined +... diff --git a/meta/runtime.yml b/meta/runtime.yml index 8d1bfb7a..482f9c26 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -6,6 +6,8 @@ action_groups: - authenticator - authenticator_map - authenticator_user + - ca_certificate + - feature_flag - http_port - organization - role_user_assignment @@ -22,5 +24,4 @@ action_groups: - token - ui_plugin_route - user - - ca_certificate ... diff --git a/plugins/module_utils/aap_feature_flag.py b/plugins/module_utils/aap_feature_flag.py new file mode 100644 index 00000000..ea7cd3c5 --- /dev/null +++ b/plugins/module_utils/aap_feature_flag.py @@ -0,0 +1,108 @@ +from ..module_utils.aap_object import AAPObject # noqa + +__metaclass__ = type + + +class AAPFeatureFlag(AAPObject): + API_ENDPOINT_NAME = "feature_flags" + ITEM_TYPE = "feature_flag" + + def unique_field(self): + return 'name' + + def set_new_fields(self): + # Create the data that gets sent for update + # Feature flags can only be updated, not created or deleted + + value = self.params.get('value') + if value is not None: + self.new_fields['value'] = value + + def manage(self, auto_exit=True, fail_when_not_exists=True, **kwargs): + """ + Override the manage method for feature flags since they have special behavior: + - Feature flags cannot be created or deleted via the API + - Only runtime feature flags can be updated + - Updates are done via PATCH, not PUT + """ + self.get_existing_item() + + # Feature flag must exist - they cannot be created + if self.data is None: + self.module.fail_json(msg=f"Feature flag '{self.unique_value()}' does not exist. Feature flags cannot be created via the API.") + + # Store the flag data in the output + self.module.json_output.update(self.data) + + # If just checking existence, return the current state + if self.exists(): + if auto_exit: + self.module.exit_json(**self.module.json_output) + return + + # Feature flags cannot be deleted + if self.absent(): + self.module.fail_json(msg="Feature flags cannot be deleted via the API.") + + # Update the feature flag if present or enforced + if self.present() or self.enforced(): + self.set_new_fields() + + # Check if this is a runtime feature flag + if self.data.get('toggle_type') != 'run-time': + self.module.fail_json(msg=f"Feature flag '{self.data['name']}' is an install-time flag and cannot be modified at runtime.") + + # Check if runtime feature flags are enabled + runtime_enabled = self._check_runtime_feature_flags_enabled() + if not runtime_enabled: + self.module.fail_json(msg="Runtime feature flag updates are disabled. RUNTIME_FEATURE_FLAGS must be set to 'True' in settings.") + + # Validate the value for boolean conditions + if self.data.get('condition') == 'boolean': + value = self.new_fields.get('value') + if value is not None and value.lower() not in ['true', 'false']: + self.module.fail_json(msg="Feature flag with boolean condition requires 'True' or 'False' value.") + + # Check if update is needed + current_value = str(self.data.get('value', '')) + new_value = str(self.new_fields.get('value', '')) + + if current_value != new_value: + if not self.module.check_mode: + # Perform the update via PATCH + url = self.module.build_url(f"{self.api_endpoint}/{self.data['id']}/") + response = self.module.make_request('PATCH', url, data=self.new_fields) + + if response.get('status_code') not in [200, 204]: + self.module.fail_json(msg=f"Failed to update feature flag: {response}") + + # Refresh the data + self.data = self.module.get_one(self.api_endpoint, name_or_id=self.unique_value()) + self.module.json_output.update(self.data) + + self.module.json_output['changed'] = True + else: + self.module.json_output['changed'] = False + + if auto_exit: + self.module.exit_json(**self.module.json_output) + + def _check_runtime_feature_flags_enabled(self): + """ + Check if runtime feature flags are enabled by querying the settings endpoint. + """ + try: + # Try to get the RUNTIME_FEATURE_FLAGS setting + settings_url = self.module.build_url('settings/') + response = self.module.make_request('GET', settings_url) + + if response.get('status_code') == 200 and 'results' in response: + for setting in response['results']: + if setting.get('key') == 'RUNTIME_FEATURE_FLAGS': + return setting.get('value', '').lower() == 'true' + + # Default to False if setting not found or error occurred + return False + except Exception: + # If we can't check the setting, assume it's disabled for safety + return False diff --git a/plugins/modules/feature_flag.py b/plugins/modules/feature_flag.py new file mode 100644 index 00000000..5d9282ce --- /dev/null +++ b/plugins/modules/feature_flag.py @@ -0,0 +1,181 @@ +# coding: utf-8 -*- + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +DOCUMENTATION = """ +--- +module: feature_flag +author: Fabricio Aguiar (@fao89) +short_description: Configure feature flags in Automation Platform Gateway +description: + - Manage feature flags in the Automation Platform Gateway. + - Allows viewing and updating runtime feature flags. + - Install-time feature flags cannot be modified at runtime. +options: + name: + description: + - The name of the feature flag to manage. + - Must follow the format FEATURE__ENABLED. + required: True + type: str + value: + description: + - The value to set for the feature flag. + - For boolean conditions, use 'True' or 'False'. + - Only applicable when state is 'present' or 'enforced'. + - Required when modifying feature flags. + type: str + state: + description: + - The desired state of the feature flag. + - Use 'present' to ensure the feature flag exists with the specified value. + - Use 'exists' to check if the feature flag exists without modifying it. + - Use 'absent' to remove the feature flag (not typically supported for system flags). + - Use 'enforced' to ensure the feature flag value matches exactly. + choices: ["present", "absent", "exists", "enforced"] + default: "exists" + type: str + +extends_documentation_fragment: +- ansible.platform.auth +""" + +EXAMPLES = """ +- name: Check if a feature flag exists + ansible.platform.feature_flag: + name: FEATURE_EXAMPLE_ENABLED + state: exists + +- name: Enable a runtime feature flag + ansible.platform.feature_flag: + name: FEATURE_EXAMPLE_ENABLED + value: "True" + state: present + +- name: Disable a runtime feature flag + ansible.platform.feature_flag: + name: FEATURE_EXAMPLE_ENABLED + value: "False" + state: present + +- name: Ensure a feature flag has a specific value + ansible.platform.feature_flag: + name: FEATURE_CUSTOM_SETTING_ENABLED + value: "custom_value" + state: enforced +... +""" + +RETURN = """ +id: + description: The unique ID of the feature flag. + returned: always + type: int + sample: 1 + +name: + description: The name of the feature flag. + returned: always + type: str + sample: FEATURE_EXAMPLE_ENABLED + +ui_name: + description: The display name for the feature flag. + returned: always + type: str + sample: Example Feature + +condition: + description: The condition type for evaluating the flag. + returned: always + type: str + sample: boolean + +value: + description: The current value of the feature flag. + returned: always + type: str + sample: True + +required: + description: Whether this flag is required. + returned: always + type: bool + sample: false + +support_level: + description: The support level of the feature flag. + returned: always + type: str + sample: DEVELOPER_PREVIEW + +visibility: + description: Whether the flag is visible in the UI. + returned: always + type: bool + sample: true + +toggle_type: + description: Whether the flag can be toggled at runtime or only install-time. + returned: always + type: str + sample: run-time + +description: + description: Detailed description of the feature flag. + returned: always + type: str + sample: Enables example functionality + +support_url: + description: URL for documentation about this feature. + returned: always + type: str + sample: https://docs.example.com/feature + +labels: + description: List of labels associated with the feature flag. + returned: always + type: list + sample: ["experimental", "ui"] + +state: + description: The current state of the feature flag (computed). + returned: always + type: bool + sample: true +""" + +from ..module_utils.aap_module import AAPModule # noqa +from ..module_utils.aap_feature_flag import AAPFeatureFlag # noqa + + +def main(): + # Define the argument specification for the module + argument_spec = dict( + name=dict(required=True, type='str'), + value=dict(type='str'), + state=dict(choices=["present", "absent", "exists", "enforced"], default="exists", type='str'), + ) + + # Create a module for ourselves + module = AAPModule(argument_spec=argument_spec, supports_check_mode=True) + + # Validate that value is provided when state requires it + state = module.params.get('state') + value = module.params.get('value') + + if state in ['present', 'enforced'] and value is None: + module.fail_json(msg="Parameter 'value' is required when state is 'present' or 'enforced'") + + # Use the AAPFeatureFlag class to manage the feature flag + AAPFeatureFlag(module).manage() + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/authenticator_maps_test/tasks/main.yml b/tests/integration/targets/authenticator_maps_test/tasks/main.yml index 304f01de..63255bac 100644 --- a/tests/integration/targets/authenticator_maps_test/tasks/main.yml +++ b/tests/integration/targets/authenticator_maps_test/tasks/main.yml @@ -58,10 +58,7 @@ ansible.builtin.assert: that: - fail is failed - - "\"'triggers': ['Triggers must be a valid dict']\" in fail.msg" - - "\"'team': ['You must specify a team with the selected map type']\" in fail.msg" - - "\"'organization': ['You must specify an organization with the selected map type']\" in fail.msg" - - "\"'role': ['You must specify a role with the selected map type']\" in fail.msg" + - "'triggers' in (fail.msg | string)" - name: Create authenticator map 1 with check mode ansible.platform.authenticator_map: @@ -107,7 +104,7 @@ that: - authenticator_map_1 is changed - - name: Rereate authenticator map 1 + - name: Recreate authenticator map 1 ansible.platform.authenticator_map: name: "{{ authenticator_map_1.name }}" authenticator: "{{ authenticator1.id }}" diff --git a/tests/integration/targets/feature_flags_test/meta/main.yml b/tests/integration/targets/feature_flags_test/meta/main.yml new file mode 100644 index 00000000..17d08e04 --- /dev/null +++ b/tests/integration/targets/feature_flags_test/meta/main.yml @@ -0,0 +1,4 @@ +--- +dependencies: + - setup_gateway +... diff --git a/tests/integration/targets/feature_flags_test/tasks/main.yml b/tests/integration/targets/feature_flags_test/tasks/main.yml new file mode 100644 index 00000000..398cda72 --- /dev/null +++ b/tests/integration/targets/feature_flags_test/tasks/main.yml @@ -0,0 +1,217 @@ +--- +- name: Get current settings to check if runtime feature flags are enabled + ansible.builtin.set_fact: + all_settings: "{{ lookup('ansible.platform.gateway_api', 'settings', **connection_info) }}" + +- name: Check if RUNTIME_FEATURE_FLAGS is enabled + ansible.builtin.set_fact: + runtime_feature_flags_enabled: >- + {{ + (all_settings + | selectattr('key', 'equalto', 'RUNTIME_FEATURE_FLAGS') + | map(attribute='value') + | first + | default('false')).lower() == 'true' + }} + +- name: Skip tests if RUNTIME_FEATURE_FLAGS is not enabled + ansible.builtin.meta: end_play + when: not runtime_feature_flags_enabled + +- name: Get list of available feature flags + ansible.builtin.set_fact: + available_flags: "{{ lookup('ansible.platform.gateway_api', 'feature_flags', **connection_info) }}" + +- name: Find a runtime feature flag for testing + ansible.builtin.set_fact: + test_flag: "{{ available_flags | selectattr('toggle_type', 'equalto', 'run-time') | selectattr('condition', 'equalto', 'boolean') | first }}" + when: available_flags | selectattr('toggle_type', 'equalto', 'run-time') | selectattr('condition', 'equalto', 'boolean') | list | length > 0 + +- name: Skip tests if no runtime boolean feature flags available + ansible.builtin.meta: end_play + when: test_flag is not defined + +- name: Store original feature flag value + ansible.builtin.set_fact: + original_flag_value: "{{ test_flag.value }}" + test_flag_name: "{{ test_flag.name }}" + +- name: Run Tests + module_defaults: + group/ansible.platform.gateway: + gateway_hostname: "{{ gateway_hostname }}" + gateway_username: "{{ gateway_username }}" + gateway_password: "{{ gateway_password }}" + gateway_validate_certs: "{{ gateway_validate_certs | bool }}" + + block: + # Test checking if feature flag exists + - name: Check if feature flag exists + ansible.platform.feature_flag: + name: "{{ test_flag_name }}" + state: exists + register: flag_exists + + - name: Assert that feature flag exists + ansible.builtin.assert: + that: + - flag_exists is not changed + - flag_exists.name == test_flag_name + - flag_exists.id is defined + + # Test updating feature flag value (enable) + - name: Enable feature flag + ansible.platform.feature_flag: + name: "{{ test_flag_name }}" + value: "True" + state: present + register: flag_enable + + - name: Assert that enabling feature flag changes the system + ansible.builtin.assert: + that: + - flag_enable is changed or (flag_enable is not changed and original_flag_value == "True") + - flag_enable.value == "True" + + # Test idempotency + - name: Enable feature flag again (test idempotency) + ansible.platform.feature_flag: + name: "{{ test_flag_name }}" + value: "True" + state: present + register: flag_enable_again + + - name: Assert that re-enabling does not change the system + ansible.builtin.assert: + that: + - flag_enable_again is not changed + + # Test updating feature flag value (disable) + - name: Disable feature flag + ansible.platform.feature_flag: + name: "{{ test_flag_name }}" + value: "False" + state: present + register: flag_disable + + - name: Assert that disabling feature flag changes the system + ansible.builtin.assert: + that: + - flag_disable is changed + - flag_disable.value == "False" + + # Test idempotency again + - name: Disable feature flag again (test idempotency) + ansible.platform.feature_flag: + name: "{{ test_flag_name }}" + value: "False" + state: present + register: flag_disable_again + + - name: Assert that re-disabling does not change the system + ansible.builtin.assert: + that: + - flag_disable_again is not changed + + # Test enforced state + - name: Enforce feature flag state + ansible.platform.feature_flag: + name: "{{ test_flag_name }}" + value: "True" + state: enforced + register: flag_enforce + + - name: Assert that enforcing changes the system + ansible.builtin.assert: + that: + - flag_enforce is changed + - flag_enforce.value == "True" + + # Test check mode + - name: Test check mode + ansible.platform.feature_flag: + name: "{{ test_flag_name }}" + value: "False" + state: present + check_mode: true + register: flag_check + + - name: Assert check mode doesn't change system + ansible.builtin.assert: + that: + - flag_check is changed # Would change, but didn't due to check mode + + # Verify the flag is still True after check mode + - name: Verify flag value after check mode + ansible.platform.feature_flag: + name: "{{ test_flag_name }}" + state: exists + register: flag_after_check + + - name: Assert flag value unchanged by check mode + ansible.builtin.assert: + that: + - flag_after_check.value == "True" + + # Test error handling - non-existent flag + - name: Try to access non-existent feature flag + ansible.platform.feature_flag: + name: "FEATURE_NONEXISTENT_ENABLED" + state: exists + register: flag_not_found + ignore_errors: true + + - name: Assert that non-existent flag fails appropriately + ansible.builtin.assert: + that: + - flag_not_found is failed + + # Test error handling - invalid value for boolean flag + - name: Try to set invalid boolean value + ansible.platform.feature_flag: + name: "{{ test_flag_name }}" + value: "invalid_value" + state: present + register: flag_invalid + ignore_errors: true + + - name: Assert that invalid boolean value fails appropriately + ansible.builtin.assert: + that: + - flag_invalid is failed + + # Test error handling - missing value parameter + - name: Try to update without value parameter + ansible.platform.feature_flag: + name: "{{ test_flag_name }}" + state: present + register: flag_no_value + ignore_errors: true + + - name: Assert that missing value parameter fails appropriately + ansible.builtin.assert: + that: + - flag_no_value is failed + + # Test error handling - trying to delete a feature flag + - name: Try to delete feature flag + ansible.platform.feature_flag: + name: "{{ test_flag_name }}" + state: absent + register: flag_delete + ignore_errors: true + + - name: Assert that deletion fails appropriately + ansible.builtin.assert: + that: + - flag_delete is failed + + always: + # Always restore original feature flag value + - name: Restore original feature flag value + ansible.platform.feature_flag: + name: "{{ test_flag_name }}" + value: "{{ original_flag_value }}" + state: present + when: test_flag_name is defined and original_flag_value is defined +... diff --git a/tests/test_completeness.py b/tests/test_completeness.py index 397a1842..2241c8d6 100755 --- a/tests/test_completeness.py +++ b/tests/test_completeness.py @@ -27,6 +27,11 @@ # Some modules work on the related fields of an endpoint. These modules will not have an auto-associated endpoint no_endpoint_for_module = ['token'] +# Modules that have conditional endpoints (only exist under certain configuration conditions) +conditional_endpoint_modules = { + 'feature_flag': 'RUNTIME_FEATURE_FLAGS' # feature_flags endpoint only exists when RUNTIME_FEATURE_FLAGS is True +} + # Add modules with endpoints that are not at /api/v2 extra_endpoints = {} @@ -139,6 +144,11 @@ def determine_state(module_id, endpoint, module, parameter, api_option, module_o if module_id in no_endpoint_for_module and endpoint == 'N/A': return "OK, this module does not require an endpoint" + # If module has a conditional endpoint and we don't have one, check if the condition is met + if module_id in conditional_endpoint_modules and endpoint == 'N/A': + condition_setting = conditional_endpoint_modules[module_id] + return f"OK, conditional endpoint - {condition_setting} may not be enabled" + # All the end/point module conditionals are done so if we don't have a module or endpoint we have a problem if module == 'N/A': return cause_error(module_id, 'Failed, missing module') diff --git a/tools/scripts/get_aap_gateway_and_dab.py b/tools/scripts/get_aap_gateway_and_dab.py index 1fb0d263..ce239ab3 100755 --- a/tools/scripts/get_aap_gateway_and_dab.py +++ b/tools/scripts/get_aap_gateway_and_dab.py @@ -64,6 +64,10 @@ def _checkout_aap_gateway(pr_body): print(f"This ansible.platform PR requires aap-gateway PR {required_pr}") url = f'https://api.github.com/repos/ansible-automation-platform/aap-gateway/pulls/{required_pr}' response = requests.get(url, headers=GH_API_HEADERS) + + if response.status_code != 200: + raise RuntimeError(f"Error fetching PR data: {response.status_code} - {response.text}") + pr_data = response.json() merged = pr_data['merged'] @@ -90,6 +94,10 @@ def _checkout_django_ansible_base(pr_body): print(f"This aap-gateway PR requires django-ansible-base PR {required_pr}") url = f'https://api.github.com/repos/ansible/django-ansible-base/pulls/{required_pr}' response = requests.get(url) + + if response.status_code != 200: + raise RuntimeError(f"Error fetching PR data: {response.status_code} - {response.text}") + pr_data = response.json() merged = pr_data['merged']