diff --git a/ibm/mas_devops/plugins/action/apply_subscription.py b/ibm/mas_devops/plugins/action/apply_subscription.py index 93763b1c38..efe0f37db2 100644 --- a/ibm/mas_devops/plugins/action/apply_subscription.py +++ b/ibm/mas_devops/plugins/action/apply_subscription.py @@ -9,7 +9,7 @@ from mas.devops.olm import applySubscription, OLMException urllib3.disable_warnings() # Disabling warnings will prevent InsecureRequestWarnings from dynClient -logging.basicConfig(level=logging.INFO, format='%(asctime)s %(name)-20s %(levelname)-8s %(message)s', datefmt='%Y-%m-%d %H:%M:%S') + class ActionModule(ActionBase): """ @@ -22,6 +22,8 @@ class ActionModule(ActionBase): package_name: ibm-sls package_channel: 3.x install_mode: AllNamespaces (default is OwnNamespace) + install_plan_approval: Manual (optional, default is Automatic) + starting_csv: ibm-sls.v3.8.0 (optional) register: subscription """ def run(self, tmp=None, task_vars=None): @@ -42,10 +44,22 @@ def run(self, tmp=None, task_vars=None): # Which install mode? installMode = self._task.args.get('install_mode', 'OwnNamespace') + # Install plan approval and starting CSV (optional) + installPlanApproval = self._task.args.get('install_plan_approval', None) + startingCSV = self._task.args.get('starting_csv', None) + if namespace is None: raise AnsibleError(f"Error: namespace argument was not provided") if not isinstance(packageName, str): raise AnsibleError(f"Error: packageName argument is not a string") + + # Validate that Manual approval requires a startingCSV for automatic first-time approval + if installPlanApproval == "Manual" and startingCSV is None: + raise AnsibleError( + f"Error: When install_plan_approval is set to 'Manual', a starting_csv must be provided " + f"to allow automatic approval of the initial installation. Without starting_csv, the " + f"InstallPlan will remain in 'RequiresApproval' state indefinitely." + ) # Initialize DynamicClient and apply the Subscription host = self._task.args.get('host', None) @@ -53,7 +67,18 @@ def run(self, tmp=None, task_vars=None): dynClient = get_api_client(api_key=api_key, host=host) try: - subscription = applySubscription(dynClient, namespace, packageName, packageChannel, catalogSource, catalogSourceNamespace, config, installMode) + subscription = applySubscription( + dynClient, + namespace, + packageName, + packageChannel, + catalogSource, + catalogSourceNamespace, + config, + installMode, + installPlanApproval=installPlanApproval, + startingCSV=startingCSV + ) except OLMException as e: raise AnsibleError(f"Error applying subscription: {e}") diff --git a/ibm/mas_devops/plugins/action/verify_subscriptions.py b/ibm/mas_devops/plugins/action/verify_subscriptions.py index dfb5461c2b..e8b9ecc015 100644 --- a/ibm/mas_devops/plugins/action/verify_subscriptions.py +++ b/ibm/mas_devops/plugins/action/verify_subscriptions.py @@ -34,12 +34,21 @@ def run(self, tmp=None, task_vars=None): notAtLatest = [] for subscription in subs.items: - display.v(f"* {subscription.metadata.namespace}/{subscription.metadata.name} = {subscription.status.state}") - if subscription.status.state != "AtLatestKnown": + state = subscription.status.state + installPlanApproval = getattr(subscription.spec, 'installPlanApproval', 'Automatic') + display.v(f"* {subscription.metadata.namespace}/{subscription.metadata.name} = {state} (approval: {installPlanApproval})") + + # Accept both "AtLatestKnown" and "UpgradePending" with Manual approval as valid states + # UpgradePending with Manual approval means the operator is installed at the desired version + # but newer versions are available that require manual approval + isValidState = (state == "AtLatestKnown" or + (state == "UpgradePending" and installPlanApproval == "Manual")) + + if not isValidState: allSubscriptionsAtLatestThisLoop = False - notAtLatest.append(f"{subscription.metadata.namespace}/{subscription.metadata.name} = {subscription.status.state}") + notAtLatest.append(f"{subscription.metadata.namespace}/{subscription.metadata.name} = {state} (approval: {installPlanApproval})") else: - atLatest.append(f"{subscription.metadata.namespace}/{subscription.metadata.name} = {subscription.status.state}") + atLatest.append(f"{subscription.metadata.namespace}/{subscription.metadata.name} = {state} (approval: {installPlanApproval})") if allSubscriptionsAtLatestThisLoop: allSubscriptionsAtLatest = True diff --git a/ibm/mas_devops/roles/grafana/tasks/install/main.yml b/ibm/mas_devops/roles/grafana/tasks/install/main.yml index 76492677a0..bf61854fbf 100644 --- a/ibm/mas_devops/roles/grafana/tasks/install/main.yml +++ b/ibm/mas_devops/roles/grafana/tasks/install/main.yml @@ -57,11 +57,15 @@ # 4. Create Grafana Subscription # ----------------------------------------------------------------------------- +# Setting to Manual and starting_csv: grafana-operator.v5.21.2 due to +# grafana bug https://github.com/grafana/grafana-operator/issues/2552 - name: "install : Create Grafana v{{grafana_major_version}} Subscription" ibm.mas_devops.apply_subscription: namespace: "{{ grafana_namespace }}" package_name: grafana-operator package_channel: "v{{grafana_major_version}}" + install_plan_approval: Manual + starting_csv: grafana-operator.v5.21.2 config: env: - name: "WATCH_NAMESPACE" diff --git a/ibm/mas_devops/roles/mongodb/tasks/providers/atlas/create_database_users.yml b/ibm/mas_devops/roles/mongodb/tasks/providers/atlas/create_database_users.yml index ad646c3bd1..b0ae9cc3c7 100644 --- a/ibm/mas_devops/roles/mongodb/tasks/providers/atlas/create_database_users.yml +++ b/ibm/mas_devops/roles/mongodb/tasks/providers/atlas/create_database_users.yml @@ -179,4 +179,3 @@ - "Username: {{ atlas_db_username }}" - "Status: {{ 'Created' if atlas_user_check.status == 404 else 'Already exists' }}" - "==========================================" - diff --git a/ibm/mas_devops/roles/mongodb/tasks/providers/atlas/install.yml b/ibm/mas_devops/roles/mongodb/tasks/providers/atlas/install.yml index 9d7c07bdd2..42ddc7a6a8 100644 --- a/ibm/mas_devops/roles/mongodb/tasks/providers/atlas/install.yml +++ b/ibm/mas_devops/roles/mongodb/tasks/providers/atlas/install.yml @@ -625,4 +625,3 @@ - "Connection String (SRV) ..... {{ atlas_standard_srv }}" - "Database User ............... {{ atlas_db_username | default('(see atlas_database_users for multi-user credentials)') }}" - "Cluster State ............... {{ atlas_cluster_status.json.stateName }}" - diff --git a/ibm/mas_devops/tests/unit/plugins/action/test_verify_subscriptions.py b/ibm/mas_devops/tests/unit/plugins/action/test_verify_subscriptions.py index d55d7c94de..d2de0eea1f 100644 --- a/ibm/mas_devops/tests/unit/plugins/action/test_verify_subscriptions.py +++ b/ibm/mas_devops/tests/unit/plugins/action/test_verify_subscriptions.py @@ -66,8 +66,9 @@ def test_all_subscriptions_at_latest_first_attempt(self, action_module, mock_dis assert result['message'] == "All Subscriptions are at the latest known operator version" assert len(result['atLatest']) == 2 assert len(result['notAtLatest']) == 0 - assert 'openshift-operators/ibm-mas-operator = AtLatestKnown' in result['atLatest'] - assert 'openshift-operators/ibm-sls-operator = AtLatestKnown' in result['atLatest'] + # Check that the subscriptions are in atLatest with approval information + assert any('openshift-operators/ibm-mas-operator = AtLatestKnown' in item for item in result['atLatest']) + assert any('openshift-operators/ibm-sls-operator = AtLatestKnown' in item for item in result['atLatest']) def test_subscriptions_at_latest_after_retry(self, action_module, mock_display): """Test when Subscriptions reach latest after retry.""" @@ -303,5 +304,89 @@ def test_subscription_with_different_states(self, action_module, mock_display): with patch('verify_subscriptions.time.sleep'): with pytest.raises(AnsibleError, match="One or more subscriptions did not update"): action_module.run() + def test_upgrade_pending_with_manual_approval_is_valid(self, action_module, mock_display): + """Test that UpgradePending state with Manual approval is considered valid.""" + # Arrange + action_module._task.args = { + 'host': 'https://api.example.com', + 'api_key': 'test-key', + 'retries': 1, + 'delay': 1 + } + + # Create subscription with UpgradePending and Manual approval + mock_sub1 = Mock() + mock_sub1.metadata.namespace = 'openshift-operators' + mock_sub1.metadata.name = 'ibm-mas-operator' + mock_sub1.status.state = 'UpgradePending' + mock_sub1.spec.installPlanApproval = 'Manual' + + # Create subscription with AtLatestKnown + mock_sub2 = Mock() + mock_sub2.metadata.namespace = 'openshift-operators' + mock_sub2.metadata.name = 'ibm-sls-operator' + mock_sub2.status.state = 'AtLatestKnown' + mock_sub2.spec.installPlanApproval = 'Automatic' + + mock_subs = Mock() + mock_subs.items = [mock_sub1, mock_sub2] + + mock_sub_resource = Mock() + mock_sub_resource.get.return_value = mock_subs + + mock_resources = Mock() + mock_resources.get.return_value = mock_sub_resource + + mock_client = Mock() + mock_client.resources = mock_resources + + # Act + with patch('verify_subscriptions.get_api_client', return_value=mock_client): + result = action_module.run() + + # Assert + assert result['failed'] is False + assert result['changed'] is False + assert result['message'] == "All Subscriptions are at the latest known operator version" + assert len(result['atLatest']) == 2 + assert len(result['notAtLatest']) == 0 + assert 'openshift-operators/ibm-mas-operator = UpgradePending (approval: Manual)' in result['atLatest'] + assert 'openshift-operators/ibm-sls-operator = AtLatestKnown (approval: Automatic)' in result['atLatest'] + + def test_upgrade_pending_with_automatic_approval_is_invalid(self, action_module, mock_display): + """Test that UpgradePending state with Automatic approval is NOT considered valid.""" + # Arrange + action_module._task.args = { + 'host': 'https://api.example.com', + 'api_key': 'test-key', + 'retries': 1, + 'delay': 1 + } + + # Create subscription with UpgradePending and Automatic approval (should fail) + mock_sub = Mock() + mock_sub.metadata.namespace = 'openshift-operators' + mock_sub.metadata.name = 'ibm-mas-operator' + mock_sub.status.state = 'UpgradePending' + mock_sub.spec.installPlanApproval = 'Automatic' + + mock_subs = Mock() + mock_subs.items = [mock_sub] + + mock_sub_resource = Mock() + mock_sub_resource.get.return_value = mock_subs + + mock_resources = Mock() + mock_resources.get.return_value = mock_sub_resource + + mock_client = Mock() + mock_client.resources = mock_resources + + # Act & Assert + with patch('verify_subscriptions.get_api_client', return_value=mock_client): + with patch('verify_subscriptions.time.sleep'): + with pytest.raises(AnsibleError, match="One or more subscriptions did not update"): + action_module.run() + # Made with Bob