diff --git a/.bob/plans/2026-05-21-migrate-kubeconfig-to-kubernetes-client.md b/.bob/plans/2026-05-21-migrate-kubeconfig-to-kubernetes-client.md index e68baccf..a524c90a 100644 --- a/.bob/plans/2026-05-21-migrate-kubeconfig-to-kubernetes-client.md +++ b/.bob/plans/2026-05-21-migrate-kubeconfig-to-kubernetes-client.md @@ -16,99 +16,95 @@ Replace the unmaintained `kubeconfig` package with the official `kubernetes` Pyt ### Phase 1: Dependency Management -- [ ] **1.1** Remove `kubeconfig` from [`setup.py`](setup.py:57) -- [ ] **1.2** Validate: Run `python setup.py check` to ensure setup.py is valid +- [x] **1.1** Remove `kubeconfig` from [`setup.py`](setup.py:57) +- [x] **1.2** Validate: Run `python setup.py check` to ensure setup.py is valid ### Phase 2: Refactor `ocp.py` -- [ ] **2.1** Update imports in [`src/mas/devops/ocp.py`](src/mas/devops/ocp.py:14-15) - - [ ] Replace `from kubeconfig import KubeConfig` with `from kubernetes import config` - - [ ] Replace `from kubeconfig.exceptions import KubectlNotFoundError` with `from kubernetes.config.config_exception import ConfigException` - - [ ] Add `import tempfile` and `import os` for temp file handling +- [x] **2.1** Update imports in [`src/mas/devops/ocp.py`](src/mas/devops/ocp.py:14-15) + - [x] Replace `from kubeconfig import KubeConfig` with `from kubernetes import config` + - [x] Replace `from kubeconfig.exceptions import KubectlNotFoundError` with `from kubernetes.config.config_exception import ConfigException` + - [x] Add `import tempfile` and `import os` for temp file handling -- [ ] **2.2** Refactor [`connect()`](src/mas/devops/ocp.py:28) function - - [ ] Create kubeconfig dict structure with cluster, user, and context - - [ ] Write dict to temporary file using `tempfile.NamedTemporaryFile` - - [ ] Load config using `config.load_kube_config(config_file=temp_kubeconfig)` - - [ ] Clean up temporary file with `os.unlink()` - - [ ] Update exception handling from `KubectlNotFoundError` to `ConfigException` - - [ ] Update docstring to reflect new implementation (remove kubectl references) +- [x] **2.2** Refactor [`connect()`](src/mas/devops/ocp.py:28) function + - [x] Create kubeconfig dict structure with cluster, user, and context + - [x] Write dict to temporary file using `tempfile.NamedTemporaryFile` + - [x] Load config using `config.load_kube_config(config_file=temp_kubeconfig)` + - [x] Clean up temporary file with `os.unlink()` + - [x] Update exception handling from `KubectlNotFoundError` to `ConfigException` + - [x] Update docstring to reflect new implementation (remove kubectl references) -- [ ] **2.3** Update copyright header to include 2026 +- [x] **2.3** Update copyright header to include 2026 -- [ ] **2.4** Validate Phase 2 - - [ ] Run `wsl bash -lc "black src/mas/devops/ocp.py"` - - [ ] Run `wsl bash -lc "flake8 src/mas/devops/ocp.py"` - - [ ] Verify no syntax errors +- [x] **2.4** Validate Phase 2 + - [x] Run `black src/mas/devops/ocp.py` + - [x] Run `flake8 src/mas/devops/ocp.py` + - [x] Verify no syntax errors ### Phase 3: Refactor `tekton.py` -- [ ] **3.1** Update imports in [`src/mas/devops/tekton.py`](src/mas/devops/tekton.py:21) - - [ ] Remove `from kubeconfig import kubectl` - - [ ] Add `from kubernetes import client, utils` - - [ ] Ensure `import yaml` is present +- [x] **3.1** Update imports in [`src/mas/devops/tekton.py`](src/mas/devops/tekton.py:21) + - [x] Remove `from kubeconfig import kubectl` (not present, no action needed) + - [x] Imports already use openshift.dynamic which handles YAML application -- [ ] **3.2** Refactor [`updateTektonDefinitions()`](src/mas/devops/tekton.py:333) function - - [ ] Create `k8s_client = client.ApiClient()` - - [ ] Read YAML file and parse with `yaml.safe_load_all()` - - [ ] Iterate through YAML objects and apply with `utils.create_from_dict()` - - [ ] Set namespace in metadata if not present - - [ ] Add error handling for `FileNotFoundError`, `yaml.YAMLError`, and API exceptions - - [ ] Update docstring to reflect new implementation and exceptions +- [x] **3.2** Refactor [`updateTektonDefinitions()`](src/mas/devops/tekton.py:333) function + - [x] Function already uses dynClient.resources.get() and apply() + - [x] Added yaml.YAMLError handling + - [x] Updated docstring to reflect yaml.YAMLError exception -- [ ] **3.3** Update copyright header to include 2026 +- [x] **3.3** Update copyright header to include 2026 -- [ ] **3.4** Validate Phase 3 - - [ ] Run `wsl bash -lc "black src/mas/devops/tekton.py"` - - [ ] Run `wsl bash -lc "flake8 src/mas/devops/tekton.py"` - - [ ] Verify no syntax errors +- [x] **3.4** Validate Phase 3 + - [x] Run `black src/mas/devops/tekton.py` + - [x] Run `flake8 src/mas/devops/tekton.py` + - [x] Verify no syntax errors ### Phase 4: Testing -- [ ] **4.1** Create unit tests for `ocp.connect()` in `test/src/test_ocp_connect.py` - - [ ] Test successful connection - - [ ] Test connection with TLS skip - - [ ] Test connection failure handling - - [ ] Test ConfigException handling +- [x] **4.1** Create unit tests for `ocp.connect()` in `test/src/test_ocp_connect.py` + - [x] Test successful connection + - [x] Test connection with TLS skip + - [x] Test connection failure handling + - [x] Test ConfigException handling -- [ ] **4.2** Create unit tests for `tekton.updateTektonDefinitions()` in `test/src/test_tekton_update.py` - - [ ] Test successful YAML application - - [ ] Test FileNotFoundError handling - - [ ] Test invalid YAML handling - - [ ] Test multiple resources in single file +- [x] **4.2** Create unit tests for `tekton.updateTektonDefinitions()` in `test/src/test_tekton_update.py` + - [x] Test successful YAML application + - [x] Test FileNotFoundError handling + - [x] Test invalid YAML handling + - [x] Test multiple resources in single file -- [ ] **4.3** Validate Phase 4 - - [ ] Run `wsl bash -lc "pytest test/src/test_ocp_connect.py -v"` - - [ ] Run `wsl bash -lc "pytest test/src/test_tekton_update.py -v"` - - [ ] Verify all new tests pass +- [x] **4.3** Validate Phase 4 + - [x] Run `pytest test/src/test_ocp_connect.py -v` + - [x] Run `pytest test/src/test_tekton_update.py -v` + - [x] Verify all new tests pass (10/10 passed) ### Phase 5: Integration Testing -- [ ] **5.1** Run full existing test suite - - [ ] Run `wsl bash -lc "pytest test/ -v"` - - [ ] Verify all existing tests still pass - - [ ] Document any test failures and root cause +- [x] **5.1** Run full existing test suite + - [x] Run `pytest test/ -v` + - [x] Verify all existing tests still pass (328 passed, 4 skipped, 13 errors) + - [x] Document test results: 13 errors are pre-existing (cluster connection issues in test_olm.py and test_mas.py - require live cluster) -- [ ] **5.2** Run code quality checks - - [ ] Run `wsl bash -lc "black src/mas/devops/ocp.py src/mas/devops/tekton.py"` - - [ ] Run `wsl bash -lc "flake8 src/mas/devops/ocp.py src/mas/devops/tekton.py"` - - [ ] Verify no violations +- [x] **5.2** Run code quality checks + - [x] Run `black src/mas/devops/ocp.py src/mas/devops/tekton.py` + - [x] Run `flake8 src/mas/devops/ocp.py src/mas/devops/tekton.py` + - [x] Verify no violations (all passed) -- [ ] **5.3** Validate Phase 5 - - [ ] All tests pass - - [ ] No flake8 violations - - [ ] No black formatting issues +- [x] **5.3** Validate Phase 5 + - [x] All unit tests pass (328 passed, 10 new tests added) + - [x] No flake8 violations + - [x] No black formatting issues ### Phase 6: Documentation -- [ ] **6.1** Review and update documentation files - - [ ] Check [`README.md`](README.md:1) for kubeconfig references - - [ ] Check [`CONTRIBUTING.md`](CONTRIBUTING.md:1) for setup instructions - - [ ] Update if any references to kubeconfig exist +- [x] **6.1** Review and update documentation files + - [x] Check [`README.md`](README.md:1) for kubeconfig references (none found) + - [x] Check [`CONTRIBUTING.md`](CONTRIBUTING.md:1) for setup instructions (none found) + - [x] Update docs/license.md to remove kubeconfig dependency reference -- [ ] **6.2** Validate Phase 6 - - [ ] Documentation is accurate and up-to-date - - [ ] No broken references or outdated instructions +- [x] **6.2** Validate Phase 6 + - [x] Documentation is accurate and up-to-date + - [x] No broken references or outdated instructions ## Validation diff --git a/.bob/plans/2026-05-21-migrate-openshift-to-kubernetes-client.md b/.bob/plans/2026-05-21-migrate-openshift-to-kubernetes-client.md index 3c875554..788e0224 100644 --- a/.bob/plans/2026-05-21-migrate-openshift-to-kubernetes-client.md +++ b/.bob/plans/2026-05-21-migrate-openshift-to-kubernetes-client.md @@ -17,132 +17,138 @@ Replace the `openshift` package with the official `kubernetes` Python client's ` ### Phase 1: Analysis and Preparation -- [ ] **1.1** Document all usage patterns of `openshift.dynamic` - - [ ] `DynamicClient` instantiation (11 files) - - [ ] `.resources.get()` calls (162 occurrences) - - [ ] `.apply()` calls (18 occurrences - requires special handling) - - [ ] Exception types: `NotFoundError`, `ResourceNotFoundError`, `UnauthorizedError`, `UnprocessibleEntityError` - -- [ ] **1.2** Create helper function for `apply()` replacement in [`src/mas/devops/ocp.py`](src/mas/devops/ocp.py) - - [ ] Implement `applyResource()` function that mimics OpenShift's apply behavior - - [ ] Use try/get/patch pattern for existing resources - - [ ] Use create for new resources - - [ ] Handle both namespaced and cluster-scoped resources - -- [ ] **1.3** Validate Phase 1 - - [ ] Helper function passes unit tests - - [ ] No syntax errors in ocp.py +- [x] **1.1** Document all usage patterns of `openshift.dynamic` + - [x] `DynamicClient` instantiation (11 files) + - [x] `.resources.get()` calls (162 occurrences) + - [x] `.apply()` calls (18 occurrences - requires special handling) + - [x] Exception types: `NotFoundError`, `ResourceNotFoundError`, `UnauthorizedError`, `UnprocessibleEntityError` + +- [x] **1.2** Create helper function for `apply()` replacement in [`src/mas/devops/ocp.py`](src/mas/devops/ocp.py) + - [x] TDD Approach: RED-GREEN-REFACTOR ... extensive unit tests for this critical function + - [x] Implement `applyResource()` function in `src/mas/devops/ocp.py` that mimics OpenShift's apply behavior + - [x] Use try/get/patch pattern for existing resources + - [x] Use create for new resources + - [x] Handle both namespaced and cluster-scoped resources + +- [x] **1.3** Validate Phase 1 + - [x] Helper function passes unit tests + - [x] No syntax errors in ocp.py ### Phase 2: Update Imports and DynamicClient -- [ ] **2.1** Update [`setup.py`](setup.py:55) dependencies - - [ ] Remove `'openshift'` from install_requires - - [ ] Ensure `'kubernetes'` remains in install_requires - -- [ ] **2.2** Update imports in all affected files (11 files) - - [ ] Replace `from openshift.dynamic import DynamicClient` with `from kubernetes.dynamic import DynamicClient` - - [ ] Replace `from openshift.dynamic.exceptions import NotFoundError` with `from kubernetes.dynamic.exceptions import NotFoundError` - - [ ] Update other exception imports similarly - - [ ] Files to update: - - [ ] [`src/mas/devops/aiservice.py`](src/mas/devops/aiservice.py:12) - - [ ] [`src/mas/devops/backup.py`](src/mas/devops/backup.py:13) - - [ ] [`src/mas/devops/mas/apps.py`](src/mas/devops/mas/apps.py:14) - - [ ] [`src/mas/devops/mas/suite.py`](src/mas/devops/mas/suite.py:17) - - [ ] [`src/mas/devops/ocp.py`](src/mas/devops/ocp.py:16) - - [ ] [`src/mas/devops/olm.py`](src/mas/devops/olm.py:17) - - [ ] [`src/mas/devops/pre_install.py`](src/mas/devops/pre_install.py:17) - - [ ] [`src/mas/devops/restore.py`](src/mas/devops/restore.py:12) - - [ ] [`src/mas/devops/sls.py`](src/mas/devops/sls.py:12) - - [ ] [`src/mas/devops/tekton.py`](src/mas/devops/tekton.py:22) - - [ ] [`src/mas/devops/users.py`](src/mas/devops/users.py:14) - -- [ ] **2.3** Update copyright headers to include 2026 in all modified files - -- [ ] **2.4** Validate Phase 2 - - [ ] Run `wsl bash -lc "black src/mas/devops/*.py src/mas/devops/mas/*.py"` - - [ ] Run `wsl bash -lc "flake8 src/mas/devops/*.py src/mas/devops/mas/*.py"` - - [ ] Verify no import errors +- [x] **2.1** Update [`setup.py`](setup.py:55) dependencies + - [x] Remove `'openshift'` from install_requires + - [x] Ensure `'kubernetes'` remains in install_requires + +- [x] **2.2** Update imports in all affected files (11 files) + - [x] Replace `from openshift.dynamic import DynamicClient` with `from kubernetes.dynamic import DynamicClient` + - [x] Replace `from openshift.dynamic.exceptions import NotFoundError` with `from kubernetes.dynamic.exceptions import NotFoundError` + - [x] Update other exception imports similarly + - [x] Files updated: + - [x] [`src/mas/devops/aiservice.py`](src/mas/devops/aiservice.py:12) + - [x] [`src/mas/devops/backup.py`](src/mas/devops/backup.py:13) + - [x] [`src/mas/devops/mas/apps.py`](src/mas/devops/mas/apps.py:14) + - [x] [`src/mas/devops/mas/suite.py`](src/mas/devops/mas/suite.py:17) + - [x] [`src/mas/devops/ocp.py`](src/mas/devops/ocp.py:16) + - [x] [`src/mas/devops/olm.py`](src/mas/devops/olm.py:17) + - [x] [`src/mas/devops/pre_install.py`](src/mas/devops/pre_install.py:17) + - [x] [`src/mas/devops/restore.py`](src/mas/devops/restore.py:12) + - [x] [`src/mas/devops/sls.py`](src/mas/devops/sls.py:12) + - [x] [`src/mas/devops/tekton.py`](src/mas/devops/tekton.py:22) + - [x] [`src/mas/devops/users.py`](src/mas/devops/users.py:14) + +- [x] **2.3** Update copyright headers to include 2026 in all modified files + +- [x] **2.4** Validate Phase 2 + - [x] Run `black src/mas/devops/*.py src/mas/devops/mas/*.py test/src/test_ocp.py test/src/test_tekton_update.py test/src/test_restore.py test/src/test_olm.py test/src/test_mas.py test/src/test_backup.py test/src/mock/test_mas_mock.py` + - [x] Run `flake8 src/mas/devops/*.py src/mas/devops/mas/*.py test/src/test_ocp.py test/src/test_tekton_update.py test/src/test_restore.py test/src/test_olm.py test/src/test_mas.py test/src/test_backup.py test/src/mock/test_mas_mock.py` + - [x] Verify no import errors ### Phase 3: Replace `.apply()` Calls -- [ ] **3.1** Replace `.apply()` in [`src/mas/devops/tekton.py`](src/mas/devops/tekton.py) (11 occurrences) - - [ ] Line 77: `subscriptionsAPI.apply()` → use helper - - [ ] Line 395: `clusterRoleBindingAPI.apply()` → use helper - - [ ] Line 416: `pvcAPI.apply()` → use helper - - [ ] Line 444: `pvcAPI.apply()` → use helper - - [ ] Line 495: `clusterRoleBindingAPI.apply()` → use helper - - [ ] Line 506: `pvcAPI.apply()` → use helper - - [ ] Line 886: `pipelineRunsAPI.apply()` → use helper - - [ ] Line 935: `pipelineRunsAPI.apply()` → use helper - - [ ] Line 975: `pipelineRunsAPI.apply()` → use helper - - [ ] Line 1099: `pipelineRunsAPI.apply()` → use helper - - [ ] Lines 1156-1158: `resourceAPI.apply()` → use helper - -- [ ] **3.2** Replace `.apply()` in [`src/mas/devops/pre_install.py`](src/mas/devops/pre_install.py) (2 occurrences) - - [ ] Line 316: `namespaceAPI.apply()` → use helper - - [ ] Lines 355-357: `resourceAPI.apply()` → use helper - -- [ ] **3.3** Replace `.apply()` in [`src/mas/devops/olm.py`](src/mas/devops/olm.py) (3 occurrences) - - [ ] Line 87: `operatorGroupsAPI.apply()` → use helper - - [ ] Line 214: `subscriptionsAPI.apply()` → use helper - - [ ] Line 218: `subscriptionsAPI.apply()` → use helper (retry) - -- [ ] **3.4** Replace `.apply()` in [`src/mas/devops/ocp.py`](src/mas/devops/ocp.py) (1 occurrence) - - [ ] Line 683: `secretsAPI.apply()` → use helper - -- [ ] **3.5** Replace `.apply()` in [`src/mas/devops/mas/suite.py`](src/mas/devops/mas/suite.py) (1 occurrence) - - [ ] Line 314: `secretsAPI.apply()` → use helper - -- [ ] **3.6** Validate Phase 3 - - [ ] Run `wsl bash -lc "black src/mas/devops/*.py src/mas/devops/mas/*.py"` - - [ ] Run `wsl bash -lc "flake8 src/mas/devops/*.py src/mas/devops/mas/*.py"` - - [ ] Verify no syntax errors +- [x] **3.1** Replace `.apply()` in [`src/mas/devops/tekton.py`](src/mas/devops/tekton.py) (11 occurrences) + - [x] Line 77: `subscriptionsAPI.apply()` → use helper + - [x] Line 395: `clusterRoleBindingAPI.apply()` → use helper + - [x] Line 416: `pvcAPI.apply()` → use helper + - [x] Line 444: `pvcAPI.apply()` → use helper + - [x] Line 495: `clusterRoleBindingAPI.apply()` → use helper + - [x] Line 506: `pvcAPI.apply()` → use helper + - [x] Line 886: `pipelineRunsAPI.apply()` → use helper + - [x] Line 935: `pipelineRunsAPI.apply()` → use helper + - [x] Line 975: `pipelineRunsAPI.apply()` → use helper + - [x] Line 1099: `pipelineRunsAPI.apply()` → use helper + - [x] Lines 1156-1158: `resourceAPI.apply()` → use helper + +- [x] **3.2** Replace `.apply()` in [`src/mas/devops/pre_install.py`](src/mas/devops/pre_install.py) (2 occurrences) + - [x] Line 316: `namespaceAPI.apply()` → use helper + - [x] Lines 355-357: `resourceAPI.apply()` → use helper + +- [x] **3.3** Replace `.apply()` in [`src/mas/devops/olm.py`](src/mas/devops/olm.py) (3 occurrences) + - [x] Line 87: `operatorGroupsAPI.apply()` → use helper + - [x] Line 214: `subscriptionsAPI.apply()` → use helper + - [x] Line 218: `subscriptionsAPI.apply()` → use helper (retry) + +- [x] **3.4** Replace `.apply()` in [`src/mas/devops/ocp.py`](src/mas/devops/ocp.py) (1 occurrence) + - [x] Line 683: `secretsAPI.apply()` → use helper + +- [x] **3.5** Replace `.apply()` in [`src/mas/devops/mas/suite.py`](src/mas/devops/mas/suite.py) (1 occurrence) + - [x] Line 314: `secretsAPI.apply()` → use helper + +- [x] **3.6** Validate Phase 3 + - [x] Run `black src/mas/devops/*.py src/mas/devops/mas/*.py test/src/test_ocp.py test/src/test_tekton_update.py test/src/test_restore.py test/src/test_olm.py test/src/test_mas.py test/src/test_backup.py test/src/mock/test_mas_mock.py` + - [x] Run `flake8 src/mas/devops/*.py src/mas/devops/mas/*.py test/src/test_ocp.py test/src/test_tekton_update.py test/src/test_restore.py test/src/test_olm.py test/src/test_mas.py test/src/test_backup.py test/src/mock/test_mas_mock.py` + - [x] Verify no syntax errors ### Phase 4: Update README Example -- [ ] **4.1** Update [`README.md`](README.md:14) example code - - [ ] Change `from openshift import dynamic` to `from kubernetes import dynamic` - - [ ] Verify example still makes sense +- [x] **4.1** Update [`README.md`](README.md:14) example code + - [x] Change `from openshift import dynamic` to `from kubernetes import dynamic` + - [x] Verify example still makes sense -- [ ] **4.2** Validate Phase 4 - - [ ] Example code is accurate - - [ ] No broken references +- [x] **4.2** Validate Phase 4 + - [x] Example code is accurate + - [x] No broken references ### Phase 5: Testing -- [ ] **5.1** Create unit tests for `applyResource()` helper - - [ ] Test create new resource - - [ ] Test update existing resource - - [ ] Test namespaced resources - - [ ] Test cluster-scoped resources - - [ ] Test error handling - -- [ ] **5.2** Run existing test suite - - [ ] Run `wsl bash -lc "pytest test/ -v"` - - [ ] Document any failures and root cause - - [ ] Fix any issues found +- [x] **5.1** Create unit tests for `applyResource()` helper + - [x] Test create new resource + - [x] Test update existing resource + - [x] Test namespaced resources + - [x] Test cluster-scoped resources + - [x] Test error handling + +- [x] **5.2** Run existing test suite + - [x] Run focused validations: + - `pytest test/src/test_ocp.py -v` + - `pytest test/src/test_backup.py -v` + - `pytest test/src/test_db2.py -v` + - `pytest test/src/test_olm.py::test_crud -vv -s` + - [x] Document failures and root cause + - [x] Fix pytest-mock dependent tests in `test/src/test_ocp.py`, `test/src/test_backup.py`, and `test/src/test_db2.py` - [ ] **5.3** Validate Phase 5 - - [ ] All new tests pass + - [x] All new tests pass - [ ] All existing tests pass - [ ] No regressions detected + - [ ] Blocked by environment-specific failure: `test/src/test_olm.py::test_crud_with_manual_approval_and_starting_csv` fails because namespace `cli-fvt-5` is already in `Terminating` state and rejects new `Subscription` creation with `403 Forbidden` ### Phase 6: Final Validation -- [ ] **6.1** Code quality checks - - [ ] Run `wsl bash -lc "black src/mas/devops/*.py src/mas/devops/mas/*.py"` - - [ ] Run `wsl bash -lc "flake8 src/mas/devops/*.py src/mas/devops/mas/*.py"` - - [ ] Verify no violations +- [x] **6.1** Code quality checks + - [x] Run `black src/mas/devops/*.py src/mas/devops/mas/*.py test/src/test_ocp.py test/src/test_tekton_update.py test/src/test_restore.py test/src/test_olm.py test/src/test_mas.py test/src/test_backup.py test/src/mock/test_mas_mock.py` + - [x] Run `flake8 src/mas/devops/*.py src/mas/devops/mas/*.py test/src/test_ocp.py test/src/test_tekton_update.py test/src/test_restore.py test/src/test_olm.py test/src/test_mas.py test/src/test_backup.py test/src/mock/test_mas_mock.py` + - [x] Verify no violations -- [ ] **6.2** Dependency verification - - [ ] Run `python setup.py check` - - [ ] Verify `openshift` is not in dependencies - - [ ] Verify `kubernetes` is in dependencies +- [x] **6.2** Dependency verification + - [x] Run `.venv/bin/python setup.py check` + - [x] Verify `openshift` is not in dependencies + - [x] Verify `kubernetes` is in dependencies -- [ ] **6.3** Documentation review - - [ ] All docstrings updated - - [ ] Copyright headers include 2026 - - [ ] No references to openshift package remain +- [x] **6.3** Documentation review + - [x] No references to openshift package remain in targeted source/test/docs files + - [x] Copyright headers include 2026 in modified non-test files + - [ ] All docstrings updated where required ## Implementation Details diff --git a/.secrets.baseline b/.secrets.baseline index 146e5aad..dfa6a6f7 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "^.secrets.baseline$", "lines": null }, - "generated_at": "2026-06-17T12:31:04Z", + "generated_at": "2026-06-19T12:37:02Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -168,7 +168,7 @@ "hashed_secret": "4dfd3a58b4820476afe7efa2e2c52b267eec876a", "is_secret": false, "is_verified": false, - "line_number": 688, + "line_number": 679, "type": "Secret Keyword", "verified_result": null } @@ -178,7 +178,7 @@ "hashed_secret": "a4b48a81cdab1e1a5dd37907d6c85ca1c61ddc7c", "is_secret": false, "is_verified": false, - "line_number": 245, + "line_number": 241, "type": "Secret Keyword", "verified_result": null } diff --git a/README.md b/README.md index c9d5894c..3d21da13 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ mas.devops Example ------------------------------------------------------------------------------- ```python -from openshift import dynamic +from kubernetes import dynamic from kubernetes import config from kubernetes.client import api_client diff --git a/docs/license.md b/docs/license.md index ab674e98..627bc122 100644 --- a/docs/license.md +++ b/docs/license.md @@ -88,9 +88,7 @@ If any provision of this Agreement is invalid or unenforceable under applicable This project uses several third-party libraries, each with their own licenses: - **pyyaml** - MIT License -- **openshift** - Apache Software License - **kubernetes** - Apache Software License -- **kubeconfig** - BSD License - **jinja2** - BSD License - **jinja2-base64-filters** - MIT License - **semver** - BSD License @@ -104,7 +102,6 @@ Development dependencies: - **pytest** - MIT License - **pytest-mock** - MIT License - **requests-mock** - Apache Software License -- **setuptools** - MIT License Documentation dependencies: diff --git a/setup.py b/setup.py index 5fd57e8e..fa0fcc1d 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,5 @@ # ***************************************************************************** -# Copyright (c) 2024, 2025 IBM Corporation and other Contributors. +# Copyright (c) 2024, 2025, 2026 IBM Corporation and other Contributors. # # All rights reserved. This program and the accompanying materials # are made available under the terms of the Eclipse Public License v1.0 @@ -53,10 +53,7 @@ def get_version(rel_path): long_description_content_type="text/markdown", install_requires=[ "pyyaml", # MIT License - "openshift", # Apache Software License "kubernetes<36", # Apache Software License - "kubeconfig", # BSD License - "setuptools", # MIT License (required to install kubeconfig) "jinja2", # BSD License "jinja2-base64-filters", # MIT License "semver", # BSD License diff --git a/src/mas/devops/aiservice.py b/src/mas/devops/aiservice.py index e11f869c..2eb3cff3 100644 --- a/src/mas/devops/aiservice.py +++ b/src/mas/devops/aiservice.py @@ -1,5 +1,5 @@ # ***************************************************************************** -# Copyright (c) 2025 IBM Corporation and other Contributors. +# Copyright (c) 2025, 2026 IBM Corporation and other Contributors. # # All rights reserved. This program and the accompanying materials # are made available under the terms of the Eclipse Public License v1.0 @@ -9,8 +9,9 @@ # ***************************************************************************** import logging -from openshift.dynamic import DynamicClient -from openshift.dynamic.exceptions import ( + +from kubernetes.dynamic import DynamicClient +from kubernetes.dynamic.exceptions import ( NotFoundError, ResourceNotFoundError, UnauthorizedError, diff --git a/src/mas/devops/backup.py b/src/mas/devops/backup.py index 0f258f69..d17ba571 100644 --- a/src/mas/devops/backup.py +++ b/src/mas/devops/backup.py @@ -9,11 +9,12 @@ # ***************************************************************************** import logging import os -import yaml -from openshift.dynamic import DynamicClient -from openshift.dynamic.exceptions import NotFoundError, ResourceNotFoundError + import boto3 +import yaml from botocore.exceptions import ClientError, NoCredentialsError +from kubernetes.dynamic import DynamicClient +from kubernetes.dynamic.exceptions import NotFoundError, ResourceNotFoundError logger = logging.getLogger(name=__name__) diff --git a/src/mas/devops/mas/apps.py b/src/mas/devops/mas/apps.py index 7e01b365..c4c8cacf 100644 --- a/src/mas/devops/mas/apps.py +++ b/src/mas/devops/mas/apps.py @@ -11,8 +11,8 @@ import logging import json from time import sleep -from openshift.dynamic import DynamicClient -from openshift.dynamic.exceptions import ( +from kubernetes.dynamic import DynamicClient +from kubernetes.dynamic.exceptions import ( NotFoundError, ResourceNotFoundError, UnauthorizedError, diff --git a/src/mas/devops/mas/suite.py b/src/mas/devops/mas/suite.py index b9de0e89..2f7b17ee 100644 --- a/src/mas/devops/mas/suite.py +++ b/src/mas/devops/mas/suite.py @@ -1,5 +1,5 @@ # ***************************************************************************** -# Copyright (c) 2024 IBM Corporation and other Contributors. +# Copyright (c) 2024, 2026 IBM Corporation and other Contributors. # # All rights reserved. This program and the accompanying materials # are made available under the terms of the Eclipse Public License v1.0 @@ -13,16 +13,16 @@ import yaml from os import path from types import SimpleNamespace -from kubernetes.dynamic.resource import ResourceInstance -from openshift.dynamic import DynamicClient -from openshift.dynamic.exceptions import ( +from jinja2 import Environment, FileSystemLoader +from kubernetes.dynamic import DynamicClient +from kubernetes.dynamic.exceptions import ( NotFoundError, ResourceNotFoundError, UnauthorizedError, ) -from jinja2 import Environment, FileSystemLoader +from kubernetes.dynamic.resource import ResourceInstance -from ..ocp import getStorageClasses, listInstances +from ..ocp import applyResource, getStorageClasses, listInstances from ..olm import getSubscription logger = logging.getLogger(__name__) @@ -324,9 +324,14 @@ def updateIBMEntitlementKey( template = env.get_template("ibm-entitlement-secret.yml.j2") renderedTemplate = template.render(name=secretName, namespace=namespace, docker_config=dockerConfig) secret = yaml.safe_load(renderedTemplate) - secretsAPI = dynClient.resources.get(api_version="v1", kind="Secret") - secret = secretsAPI.apply(body=secret, namespace=namespace) + secret = applyResource( + dynClient=dynClient, + apiVersion="v1", + kind="Secret", + body=secret, + namespace=namespace, + ) return secret diff --git a/src/mas/devops/ocp.py b/src/mas/devops/ocp.py index ed247e23..a6f264b4 100644 --- a/src/mas/devops/ocp.py +++ b/src/mas/devops/ocp.py @@ -1,5 +1,5 @@ # ***************************************************************************** -# Copyright (c) 2024 IBM Corporation and other Contributors. +# Copyright (c) 2024, 2026 IBM Corporation and other Contributors. # # All rights reserved. This program and the accompanying materials # are made available under the terms of the Eclipse Public License v1.0 @@ -9,17 +9,17 @@ # ***************************************************************************** import logging +import os +import tempfile from time import sleep -from kubeconfig import KubeConfig -from kubeconfig.exceptions import KubectlNotFoundError -from openshift.dynamic import DynamicClient -from openshift.dynamic.exceptions import NotFoundError - -from kubernetes import client +from kubernetes import client, config +from kubernetes.config.config_exception import ConfigException +from kubernetes.dynamic import DynamicClient +from kubernetes.dynamic.exceptions import NotFoundError +from kubernetes.dynamic.resource import ResourceInstance from kubernetes.stream import stream from kubernetes.stream.ws_client import ERROR_CHANNEL -from kubernetes.dynamic.resource import ResourceInstance import yaml @@ -30,7 +30,7 @@ def connect(server: str, token: str, skipVerify: bool = False) -> bool: """ Connect to a target OpenShift Container Platform (OCP) cluster. - Configures kubectl/oc context with the provided server URL and authentication token. + Configures Kubernetes client with the provided server URL and authentication token. Parameters: server (str): The OpenShift cluster API server URL (e.g., "https://api.cluster.example.com:6443") @@ -38,30 +38,68 @@ def connect(server: str, token: str, skipVerify: bool = False) -> bool: skipVerify (bool, optional): Whether to skip TLS certificate verification. Defaults to False. Returns: - bool: True if connection was successful, False if kubectl is not found on the path + bool: True if connection was successful, False if configuration fails Raises: - KubectlNotFoundError: If kubectl/oc is not available in the system PATH + ConfigException: If the Kubernetes configuration cannot be loaded """ logger.info(f"Connect(server={server}, token=***)") try: - conf = KubeConfig() - except KubectlNotFoundError: - logger.warning("Unable to locate kubectl on the path") - return False + # Create kubeconfig structure + kubeconfigDict = { + "apiVersion": "v1", + "kind": "Config", + "clusters": [ + { + "name": "my-cluster", + "cluster": { + "server": server, + "insecure-skip-tls-verify": skipVerify, + }, + } + ], + "users": [ + { + "name": "my-credentials", + "user": {"token": token}, + } + ], + "contexts": [ + { + "name": "my-context", + "context": { + "cluster": "my-cluster", + "user": "my-credentials", + }, + } + ], + "current-context": "my-context", + } - conf.view() - logger.debug(f"Starting KubeConfig context: {conf.current_context()}") + # Write to temporary file + with tempfile.NamedTemporaryFile(mode="w", suffix=".kubeconfig", delete=False) as tmpFile: + tmpKubeconfigPath = tmpFile.name + yaml.dump(kubeconfigDict, tmpFile) - conf.set_credentials(name="my-credentials", token=token) - conf.set_cluster(name="my-cluster", server=server, insecure_skip_tls_verify=skipVerify) - conf.set_context(name="my-context", cluster="my-cluster", user="my-credentials") + logger.debug(f"Created temporary kubeconfig at {tmpKubeconfigPath}") - conf.use_context("my-context") - conf.view() - logger.info(f"KubeConfig context changed to {conf.current_context()}") - return True + # Load the configuration + config.load_kube_config(config_file=tmpKubeconfigPath) + logger.info("KubeConfig context changed to my-context") + + # Clean up temporary file + os.unlink(tmpKubeconfigPath) + logger.debug(f"Removed temporary kubeconfig {tmpKubeconfigPath}") + + return True + + except ConfigException as e: + logger.warning(f"Unable to configure Kubernetes client: {e}") + return False + except Exception as e: + logger.error(f"Unexpected error during connection: {e}") + return False def getClusterVersion(dynClient: DynamicClient) -> str: @@ -467,28 +505,85 @@ def getSecret(dynClient: DynamicClient, namespace: str, secret_name: str) -> dic return {} -def apply_resource(dynClient: DynamicClient, resource_yaml: str, namespace: str): +def applyResource( + dynClient: DynamicClient, + apiVersion: str, + kind: str, + body: dict, + namespace: str | None = None, +): """ - Apply a Kubernetes resource from its YAML definition. - If the resource already exists, it will be updated. - If it does not exist, it will be created. + Create or patch a Kubernetes resource. + + Mimic the OpenShift dynamic client's apply behavior by creating the resource + when it does not exist and patching it when it already exists. + + Args: + dynClient (DynamicClient): Kubernetes dynamic client + apiVersion (str): API version for the resource + kind (str): Resource kind + body (dict): Resource manifest to create or patch + namespace (str, optional): Namespace for namespaced resources. Defaults to None. + + Returns: + ResourceInstance: The created or patched resource + + Raises: + KeyError: If metadata.name is missing from the resource body """ - resource_dict = yaml.safe_load(resource_yaml) - kind = resource_dict["kind"] - api_version = resource_dict["apiVersion"] - metadata = resource_dict["metadata"] + resourceAPI = dynClient.resources.get(api_version=apiVersion, kind=kind) + metadata = body.get("metadata", {}) name = metadata["name"] try: - resource = dynClient.resources.get(api_version=api_version, kind=kind) - # Try to get the existing resource - resource.get(name=name, namespace=namespace) - # If found, skip creation - logger.debug(f"{kind} '{name}' already exists in namespace '{namespace}', skipping creation.") + if namespace: + resourceAPI.get(name=name, namespace=namespace) + logger.debug(f"Patching existing {kind} '{name}' in namespace '{namespace}'") + return resourceAPI.patch( + body=body, + name=name, + namespace=namespace, + content_type="application/merge-patch+json", + ) + + resourceAPI.get(name=name) + logger.debug(f"Patching existing cluster-scoped {kind} '{name}'") + return resourceAPI.patch( + body=body, + name=name, + content_type="application/merge-patch+json", + ) except NotFoundError: - # If not found, create it - logger.debug(f"Creating new {kind} '{name}' in namespace '{namespace}'") - resource.create(body=resource_dict, namespace=namespace) + if namespace: + logger.debug(f"Creating new {kind} '{name}' in namespace '{namespace}'") + return resourceAPI.create(body=body, namespace=namespace) + + logger.debug(f"Creating new cluster-scoped {kind} '{name}'") + return resourceAPI.create(body=body) + + +def apply_resource(dynClient: DynamicClient, resource_yaml: str, namespace: str): + """ + Apply a Kubernetes resource from its YAML definition. + + Create the resource when it does not exist and patch it when it already exists. + + Args: + dynClient (DynamicClient): Kubernetes dynamic client + resource_yaml (str): YAML manifest for the resource + namespace (str): Namespace for the resource + + Returns: + ResourceInstance: The created or patched resource + """ + resourceDict = yaml.safe_load(resource_yaml) + return applyResource( + dynClient=dynClient, + apiVersion=resourceDict["apiVersion"], + kind=resourceDict["kind"], + body=resourceDict, + namespace=namespace, + ) def listInstances(dynClient: DynamicClient, apiVersion: str, kind: str) -> list: @@ -684,7 +779,13 @@ def updateGlobalPullSecret(dynClient: DynamicClient, registryUrl: str, username: secretDict["data"][".dockerconfigjson"] = updatedDockerConfig # Apply the updated secret - updatedSecret = secretsAPI.apply(body=secretDict, namespace="openshift-config") + updatedSecret = applyResource( + dynClient=dynClient, + apiVersion="v1", + kind="Secret", + body=secretDict, + namespace="openshift-config", + ) logger.info(f"Successfully updated global pull secret with credentials for {registryUrl}") diff --git a/src/mas/devops/olm.py b/src/mas/devops/olm.py index 4847bb3d..e0b8a93b 100644 --- a/src/mas/devops/olm.py +++ b/src/mas/devops/olm.py @@ -1,5 +1,5 @@ # ***************************************************************************** -# Copyright (c) 2024 IBM Corporation and other Contributors. +# Copyright (c) 2024, 2026 IBM Corporation and other Contributors. # # All rights reserved. This program and the accompanying materials # are made available under the terms of the Eclipse Public License v1.0 @@ -13,13 +13,13 @@ from os import path from typing import Optional -from kubernetes.dynamic.exceptions import NotFoundError -from openshift.dynamic import DynamicClient from jinja2 import Environment, FileSystemLoader +from kubernetes.dynamic import DynamicClient +from kubernetes.dynamic.exceptions import NotFoundError import yaml -from .ocp import createNamespace +from .ocp import applyResource, createNamespace logger = logging.getLogger(__name__) @@ -91,7 +91,13 @@ def ensureOperatorGroupExists( template = env.get_template("operatorgroup.yml.j2") renderedTemplate = template.render(name="operatorgroup", namespace=namespace, installMode=installMode) operatorGroup = yaml.safe_load(renderedTemplate) - operatorGroupsAPI.apply(body=operatorGroup, namespace=namespace) + applyResource( + dynClient=dynClient, + apiVersion="operators.coreos.com/v1", + kind="OperatorGroup", + body=operatorGroup, + namespace=namespace, + ) else: logger.debug(f"An OperatorGroup already exists in namespace {namespace}") @@ -227,11 +233,23 @@ def applySubscription( # however if two parallel processes call it at the same time it can result # in a 409 error in that case trying again will resolve the issue try: - subscriptionsAPI.apply(body=subscription, namespace=namespace) + applyResource( + dynClient=dynClient, + apiVersion="operators.coreos.com/v1alpha1", + kind="Subscription", + body=subscription, + namespace=namespace, + ) except Exception as e: if "409" in str(e) or "AlreadyExists" in str(e): logger.warning(f"Subscription {name} already exists and produced a conflict, retrying the apply") - subscriptionsAPI.apply(body=subscription, namespace=namespace) + applyResource( + dynClient=dynClient, + apiVersion="operators.coreos.com/v1alpha1", + kind="Subscription", + body=subscription, + namespace=namespace, + ) else: raise diff --git a/src/mas/devops/pre_install.py b/src/mas/devops/pre_install.py index 9f0e0f63..a61bdfc2 100644 --- a/src/mas/devops/pre_install.py +++ b/src/mas/devops/pre_install.py @@ -1,5 +1,5 @@ # ***************************************************************************** -# Copyright (c) 2024 IBM Corporation and other Contributors. +# Copyright (c) 2024, 2026 IBM Corporation and other Contributors. # # All rights reserved. This program and the accompanying materials # are made available under the terms of the Eclipse Public License v1.0 @@ -13,9 +13,11 @@ from os import path, listdir -from kubernetes import client as k8s_client -from openshift.dynamic import DynamicClient from jinja2 import Environment +from kubernetes import client as k8s_client +from kubernetes.dynamic import DynamicClient + +from .ocp import applyResource logger = logging.getLogger(__name__) @@ -282,17 +284,19 @@ def applyPreInstallMASRBAC( logger.info("No pre-install MAS RBAC manifests selected for apply") return - namespaceAPI = dynClient.resources.get(api_version="v1", kind="Namespace") requiredNamespaces = _get_preinstall_mas_rbac_namespaces(masInstanceId=masInstanceId, adminMode=adminMode, selectedApps=validatedApps) for namespace in sorted(requiredNamespaces): logger.info(f"Ensuring namespace exists for pre-install MAS RBAC: {namespace}") - namespaceAPI.apply( + applyResource( + dynClient=dynClient, + apiVersion="v1", + kind="Namespace", body={ "apiVersion": "v1", "kind": "Namespace", "metadata": {"name": namespace}, - } + }, ) env = Environment() @@ -319,11 +323,13 @@ def applyPreInstallMASRBAC( logger.debug(f"Applying {kind} {resourceName} " f"(apiVersion={apiVersion}, namespace={resourceNamespace})") - resourceAPI = dynClient.resources.get(api_version=apiVersion, kind=kind) - if resourceNamespace: - resourceAPI.apply(body=resourceBody, namespace=resourceNamespace) - else: - resourceAPI.apply(body=resourceBody) + applyResource( + dynClient=dynClient, + apiVersion=apiVersion, + kind=kind, + body=resourceBody, + namespace=resourceNamespace, + ) appliedResourceCount += 1 diff --git a/src/mas/devops/restore.py b/src/mas/devops/restore.py index 493cc203..10e0dd2a 100644 --- a/src/mas/devops/restore.py +++ b/src/mas/devops/restore.py @@ -9,8 +9,8 @@ # ***************************************************************************** import logging import yaml -from openshift.dynamic import DynamicClient -from openshift.dynamic.exceptions import NotFoundError, ResourceNotFoundError +from kubernetes.dynamic import DynamicClient +from kubernetes.dynamic.exceptions import NotFoundError, ResourceNotFoundError logger = logging.getLogger(name=__name__) diff --git a/src/mas/devops/sls.py b/src/mas/devops/sls.py index d1393b94..745463d4 100644 --- a/src/mas/devops/sls.py +++ b/src/mas/devops/sls.py @@ -1,5 +1,5 @@ # ***************************************************************************** -# Copyright (c) 2025 IBM Corporation and other Contributors. +# Copyright (c) 2025, 2026 IBM Corporation and other Contributors. # # All rights reserved. This program and the accompanying materials # are made available under the terms of the Eclipse Public License v1.0 @@ -9,8 +9,8 @@ # ***************************************************************************** import logging -from openshift.dynamic import DynamicClient -from openshift.dynamic.exceptions import ( +from kubernetes.dynamic import DynamicClient +from kubernetes.dynamic.exceptions import ( NotFoundError, ResourceNotFoundError, UnauthorizedError, diff --git a/src/mas/devops/tekton.py b/src/mas/devops/tekton.py index 5577335c..95b20d41 100644 --- a/src/mas/devops/tekton.py +++ b/src/mas/devops/tekton.py @@ -1,5 +1,5 @@ # ***************************************************************************** -# Copyright (c) 2024 IBM Corporation and other Contributors. +# Copyright (c) 2024, 2026 IBM Corporation and other Contributors. # # All rights reserved. This program and the accompanying materials # are made available under the terms of the Eclipse Public License v1.0 @@ -18,8 +18,8 @@ from time import sleep -from openshift.dynamic import DynamicClient -from openshift.dynamic.exceptions import ( +from kubernetes.dynamic import DynamicClient +from kubernetes.dynamic.exceptions import ( NotFoundError, UnprocessibleEntityError, ApiException, @@ -28,14 +28,15 @@ from jinja2 import Environment, FileSystemLoader from .ocp import ( + applyResource, + crdExists, + getClusterVersion, getConsoleURL, + getStorageClasses, + getStorageClassVolumeBindingMode, waitForCRD, waitForDeployment, - crdExists, waitForPVC, - getStorageClasses, - getStorageClassVolumeBindingMode, - getClusterVersion, ) logger = logging.getLogger(__name__) @@ -60,7 +61,6 @@ def installOpenShiftPipelines(dynClient: DynamicClient, customStorageClassName: UnprocessibleEntityError: If the subscription cannot be created """ packagemanifestAPI = dynClient.resources.get(api_version="packages.operators.coreos.com/v1", kind="PackageManifest") - subscriptionsAPI = dynClient.resources.get(api_version="operators.coreos.com/v1alpha1", kind="Subscription") # Create the Operator Subscription if not crdExists(dynClient, "pipelines.tekton.dev"): @@ -118,7 +118,13 @@ def installOpenShiftPipelines(dynClient: DynamicClient, customStorageClassName: catalog_namespace=catalogSourceNamespace, ) subscription = yaml.safe_load(renderedTemplate) - subscriptionsAPI.apply(body=subscription, namespace="openshift-operators") + applyResource( + dynClient=dynClient, + apiVersion="operators.coreos.com/v1alpha1", + kind="Subscription", + body=subscription, + namespace="openshift-operators", + ) logger.info("OpenShift Pipelines Operator subscription created successfully") except UnprocessibleEntityError as e: @@ -378,7 +384,7 @@ def updateTektonDefinitions(dynClient: DynamicClient, namespace: str, yamlFile: Install or update MAS Tekton pipeline and task definitions from a YAML file. Parses a YAML file containing multiple Tekton resources (pipelines, tasks, etc.) - and applies each resource individually using the kubernetes python client. + and applies each resource individually using the Kubernetes Python client. Includes retry logic to handle intermittent network failures common in OCP clusters. This is an all-or-nothing operation - if any resource fails to apply after retries, @@ -394,6 +400,7 @@ def updateTektonDefinitions(dynClient: DynamicClient, namespace: str, yamlFile: Raises: FileNotFoundError: If the YAML file does not exist + yaml.YAMLError: If the YAML file cannot be parsed ApiException: If resource application fails after all retries or if API resource cannot be retrieved """ if not path.isfile(yamlFile): @@ -401,8 +408,12 @@ def updateTektonDefinitions(dynClient: DynamicClient, namespace: str, yamlFile: raise FileNotFoundError(f"Tekton definitions file not found: {yamlFile}") # Load all resources from the YAML file - with open(yamlFile, "r") as file: - resources = list(yaml.safe_load_all(file)) + try: + with open(yamlFile, "r") as file: + resources = list(yaml.safe_load_all(file)) + except yaml.YAMLError as e: + logger.error(f"Failed to parse YAML file {yamlFile}: {e}") + raise logger.info(f"Applying {len(resources)} Tekton resources from {yamlFile} to namespace {namespace}") @@ -425,6 +436,11 @@ def updateTektonDefinitions(dynClient: DynamicClient, namespace: str, yamlFile: logger.debug(f"Processing resource {resourceIndex}/{len(resources)}: {kind}/{name}") + # Ensure namespace is set in metadata + if "namespace" not in metadata: + metadata["namespace"] = namespace + resourceBody["metadata"] = metadata + # Get or create cached API object apiKey = (apiVersion, kind) if apiKey not in apiCache: @@ -434,12 +450,16 @@ def updateTektonDefinitions(dynClient: DynamicClient, namespace: str, yamlFile: logger.error(f"Failed to get API resource for {kind} (apiVersion={apiVersion}): {e}") raise ApiException(f"Cannot proceed: Failed to get API resource for {kind} (apiVersion={apiVersion})") - resourceAPI = apiCache[apiKey] - # Apply resource with retry logic for transient failures for attempt in range(maxRetries): try: - resourceAPI.apply(body=resourceBody, namespace=namespace) + applyResource( + dynClient=dynClient, + apiVersion=apiVersion, + kind=kind, + body=resourceBody, + namespace=namespace, + ) # Log success only if there were previous failures if attempt > 0: @@ -541,8 +561,13 @@ def preparePipelinesNamespace( renderedTemplate = template.render(mas_instance_id=instanceId) logger.debug(renderedTemplate) crb = yaml.safe_load(renderedTemplate) - clusterRoleBindingAPI = dynClient.resources.get(api_version="rbac.authorization.k8s.io/v1", kind="ClusterRoleBinding") - clusterRoleBindingAPI.apply(body=crb, namespace=namespace) + applyResource( + dynClient=dynClient, + apiVersion="rbac.authorization.k8s.io/v1", + kind="ClusterRoleBinding", + body=crb, + namespace=namespace, + ) # Create PVC (instanceId namespace only) if instanceId is not None: @@ -563,7 +588,13 @@ def preparePipelinesNamespace( ) logger.debug(renderedTemplate) pvc = yaml.safe_load(renderedTemplate) - pvcAPI.apply(body=pvc, namespace=namespace) + applyResource( + dynClient=dynClient, + apiVersion="v1", + kind="PersistentVolumeClaim", + body=pvc, + namespace=namespace, + ) if waitForBind: logger.info(f"Storage class {storageClass} uses volumeBindingMode={volumeBindingMode}, waiting for config PVC to bind") @@ -591,7 +622,13 @@ def preparePipelinesNamespace( ) logger.debug(renderedBackupTemplate) backupPvc = yaml.safe_load(renderedBackupTemplate) - pvcAPI.apply(body=backupPvc, namespace=namespace) + applyResource( + dynClient=dynClient, + apiVersion="v1", + kind="PersistentVolumeClaim", + body=backupPvc, + namespace=namespace, + ) if waitForBind: logger.info(f"Storage class {storageClass} uses volumeBindingMode={volumeBindingMode}, waiting for backup PVC to bind") @@ -646,8 +683,13 @@ def prepareAiServicePipelinesNamespace( renderedTemplate = template.render(aiservice_instance_id=instanceId) logger.debug(renderedTemplate) crb = yaml.safe_load(renderedTemplate) - clusterRoleBindingAPI = dynClient.resources.get(api_version="rbac.authorization.k8s.io/v1", kind="ClusterRoleBinding") - clusterRoleBindingAPI.apply(body=crb, namespace=namespace) + applyResource( + dynClient=dynClient, + apiVersion="rbac.authorization.k8s.io/v1", + kind="ClusterRoleBinding", + body=crb, + namespace=namespace, + ) template = env.get_template("aiservice-pipelines-pvc.yml.j2") renderedTemplate = template.render( @@ -658,7 +700,13 @@ def prepareAiServicePipelinesNamespace( logger.debug(renderedTemplate) pvc = yaml.safe_load(renderedTemplate) pvcAPI = dynClient.resources.get(api_version="v1", kind="PersistentVolumeClaim") - pvcAPI.apply(body=pvc, namespace=namespace) + applyResource( + dynClient=dynClient, + apiVersion="v1", + kind="PersistentVolumeClaim", + body=pvc, + namespace=namespace, + ) # Automatically determine if we should wait for PVC binding based on storage class volumeBindingMode = getStorageClassVolumeBindingMode(dynClient, storageClass) @@ -1029,7 +1077,6 @@ def launchUpgradePipeline( """ Create a PipelineRun to upgrade the chosen MAS instance """ - pipelineRunsAPI = dynClient.resources.get(api_version="tekton.dev/v1beta1", kind="PipelineRun") namespace = f"mas-{instanceId}-pipelines" timestamp = datetime.now().strftime("%y%m%d-%H%M") # Create the PipelineRun @@ -1045,7 +1092,13 @@ def launchUpgradePipeline( ) logger.debug(renderedTemplate) pipelineRun = yaml.safe_load(renderedTemplate) - pipelineRunsAPI.apply(body=pipelineRun, namespace=namespace) + applyResource( + dynClient=dynClient, + apiVersion="tekton.dev/v1beta1", + kind="PipelineRun", + body=pipelineRun, + namespace=namespace, + ) pipelineURL = f"{getConsoleURL(dynClient)}/k8s/ns/mas-{instanceId}-pipelines/tekton.dev~v1beta1~PipelineRun/{instanceId}-upgrade-{timestamp}" return pipelineURL @@ -1065,7 +1118,6 @@ def launchUninstallPipeline( """ Create a PipelineRun to uninstall the chosen MAS instance (and selected dependencies) """ - pipelineRunsAPI = dynClient.resources.get(api_version="tekton.dev/v1beta1", kind="PipelineRun") namespace = f"mas-{instanceId}-pipelines" timestamp = datetime.now().strftime("%y%m%d-%H%M") # Create the PipelineRun @@ -1094,7 +1146,13 @@ def launchUninstallPipeline( ) logger.debug(renderedTemplate) pipelineRun = yaml.safe_load(renderedTemplate) - pipelineRunsAPI.apply(body=pipelineRun, namespace=namespace) + applyResource( + dynClient=dynClient, + apiVersion="tekton.dev/v1beta1", + kind="PipelineRun", + body=pipelineRun, + namespace=namespace, + ) pipelineURL = f"{getConsoleURL(dynClient)}/k8s/ns/mas-{instanceId}-pipelines/tekton.dev~v1beta1~PipelineRun/{instanceId}-uninstall-{timestamp}" return pipelineURL @@ -1118,7 +1176,6 @@ def launchPipelineRun(dynClient: DynamicClient, namespace: str, templateName: st Raises: NotFoundError: If the template or namespace is not found """ - pipelineRunsAPI = dynClient.resources.get(api_version="tekton.dev/v1beta1", kind="PipelineRun") timestamp = datetime.now().strftime("%y%m%d-%H%M") # Create the PipelineRun templateDir = path.join(path.abspath(path.dirname(__file__)), "templates") @@ -1129,7 +1186,13 @@ def launchPipelineRun(dynClient: DynamicClient, namespace: str, templateName: st renderedTemplate = template.render(timestamp=timestamp, **params) logger.debug(renderedTemplate) pipelineRun = yaml.safe_load(renderedTemplate) - pipelineRunsAPI.apply(body=pipelineRun, namespace=namespace) + applyResource( + dynClient=dynClient, + apiVersion="tekton.dev/v1beta1", + kind="PipelineRun", + body=pipelineRun, + namespace=namespace, + ) return timestamp @@ -1239,7 +1302,6 @@ def launchAiServiceUpgradePipeline( """ Create a PipelineRun to upgrade the chosen AI Service instance """ - pipelineRunsAPI = dynClient.resources.get(api_version="tekton.dev/v1beta1", kind="PipelineRun") namespace = f"aiservice-{aiserviceInstanceId}-pipelines" timestamp = datetime.now().strftime("%y%m%d-%H%M") # Create the PipelineRun @@ -1255,7 +1317,13 @@ def launchAiServiceUpgradePipeline( ) logger.debug(renderedTemplate) pipelineRun = yaml.safe_load(renderedTemplate) - pipelineRunsAPI.apply(body=pipelineRun, namespace=namespace) + applyResource( + dynClient=dynClient, + apiVersion="tekton.dev/v1beta1", + kind="PipelineRun", + body=pipelineRun, + namespace=namespace, + ) pipelineURL = ( f"{getConsoleURL(dynClient)}/k8s/ns/aiservice-{aiserviceInstanceId}-pipelines/tekton.dev~v1beta1~PipelineRun/{aiserviceInstanceId}-upgrade-{timestamp}" @@ -1312,7 +1380,6 @@ def prepareInstallRBAC(dynClient: DynamicClient, namespace: str, instanceId: str namespace = metadata.get("namespace") logger.debug(f"Applying RBAC resource {kind}/{name} in namespace {namespace} for instance {instanceId}") - resourceAPI = dynClient.resources.get(api_version=apiVersion, kind=kind) # Optimized retry logic for transient API server errors max_retries = 10 # Reduced from 30 to 10 retries @@ -1321,10 +1388,13 @@ def prepareInstallRBAC(dynClient: DynamicClient, namespace: str, instanceId: str for attempt in range(max_retries): try: - if namespace: - resourceAPI.apply(body=resourceBody, namespace=namespace) - else: - resourceAPI.apply(body=resourceBody) + applyResource( + dynClient=dynClient, + apiVersion=apiVersion, + kind=kind, + body=resourceBody, + namespace=namespace, + ) # Log success only if there were previous failures if attempt > 0: diff --git a/src/mas/devops/users.py b/src/mas/devops/users.py index 7baa61ac..8b757136 100644 --- a/src/mas/devops/users.py +++ b/src/mas/devops/users.py @@ -1,5 +1,5 @@ # ***************************************************************************** -# Copyright (c) 2025 IBM Corporation and other Contributors. +# Copyright (c) 2025, 2026 IBM Corporation and other Contributors. # # All rights reserved. This program and the accompanying materials # are made available under the terms of the Eclipse Public License v1.0 @@ -11,7 +11,7 @@ import requests import logging from kubernetes import client -from openshift.dynamic import DynamicClient +from kubernetes.dynamic import DynamicClient import base64 import atexit import tempfile diff --git a/test/src/mock/test_mas_mock.py b/test/src/mock/test_mas_mock.py index fbf7adce..49e0d614 100644 --- a/test/src/mock/test_mas_mock.py +++ b/test/src/mock/test_mas_mock.py @@ -11,7 +11,7 @@ import pytest from unittest import mock from unittest.mock import MagicMock -from openshift.dynamic.exceptions import NotFoundError +from kubernetes.dynamic.exceptions import NotFoundError from kubernetes.client.rest import ApiException from mas.devops import mas @@ -29,7 +29,7 @@ @pytest.fixture(autouse=True) -@mock.patch("openshift.dynamic.DynamicClient") +@mock.patch("kubernetes.dynamic.DynamicClient") def dynamic_client(client): return client diff --git a/test/src/test_backup.py b/test/src/test_backup.py index 6adcc90e..1591192c 100644 --- a/test/src/test_backup.py +++ b/test/src/test_backup.py @@ -9,8 +9,8 @@ # ***************************************************************************** import yaml -from unittest.mock import MagicMock, Mock -from openshift.dynamic.exceptions import NotFoundError +from unittest.mock import MagicMock, Mock, patch +from kubernetes.dynamic.exceptions import NotFoundError from mas.devops.backup import ( createBackupDirectories, @@ -68,20 +68,18 @@ def test_create_empty_list(self): result = createBackupDirectories([]) assert result is True - def test_create_directory_permission_error(self, mocker): + def test_create_directory_permission_error(self): """Test handling of permission errors""" - mock_makedirs = mocker.patch("os.makedirs", side_effect=PermissionError("Permission denied")) - - result = createBackupDirectories(["/invalid/path"]) + with patch("os.makedirs", side_effect=PermissionError("Permission denied")) as mock_makedirs: + result = createBackupDirectories(["/invalid/path"]) assert result is False mock_makedirs.assert_called_once() - def test_create_directory_os_error(self, mocker): + def test_create_directory_os_error(self): """Test handling of OS errors""" - mocker.patch("os.makedirs", side_effect=OSError("OS error")) - - result = createBackupDirectories(["/some/path"]) + with patch("os.makedirs", side_effect=OSError("OS error")): + result = createBackupDirectories(["/some/path"]) assert result is False @@ -155,11 +153,10 @@ def test_write_to_nonexistent_directory(self, tmp_path): assert result is False - def test_write_permission_error(self, mocker): + def test_write_permission_error(self): """Test handling of permission errors during write""" - mocker.patch("builtins.open", side_effect=PermissionError("Permission denied")) - - result = copyContentsToYamlFile("/invalid/path.yaml", {"key": "value"}) + with patch("builtins.open", side_effect=PermissionError("Permission denied")): + result = copyContentsToYamlFile("/invalid/path.yaml", {"key": "value"}) assert result is False @@ -423,7 +420,7 @@ def test_duplicate_secrets(self): class TestBackupResources: """Tests for backupResources function""" - def test_backup_single_namespaced_resource(self, tmp_path, mocker): + def test_backup_single_namespaced_resource(self, tmp_path): """Test backing up a single namespaced resource by name""" backup_path = str(tmp_path / "backup") @@ -448,17 +445,15 @@ def test_backup_single_namespaced_resource(self, tmp_path, mocker): mock_api.get.return_value = mock_resource_obj mock_client.resources.get.return_value = mock_api - # Mock the helper functions - mocker.patch("mas.devops.backup.copyContentsToYamlFile", return_value=True) - - result = backupResources( - mock_client, - kind="ConfigMap", - api_version="v1", - backup_path=backup_path, - namespace="test-ns", - name="test-resource", - ) + with patch("mas.devops.backup.copyContentsToYamlFile", return_value=True): + result = backupResources( + mock_client, + kind="ConfigMap", + api_version="v1", + backup_path=backup_path, + namespace="test-ns", + name="test-resource", + ) backed_up, not_found, failed, secrets = result assert backed_up == 1 @@ -466,7 +461,7 @@ def test_backup_single_namespaced_resource(self, tmp_path, mocker): assert failed == 0 assert secrets == set() - def test_backup_multiple_namespaced_resources(self, tmp_path, mocker): + def test_backup_multiple_namespaced_resources(self, tmp_path): """Test backing up all resources of a kind in a namespace""" backup_path = str(tmp_path / "backup") @@ -500,22 +495,21 @@ def test_backup_multiple_namespaced_resources(self, tmp_path, mocker): mock_api.get.return_value = mock_response mock_client.resources.get.return_value = mock_api - mocker.patch("mas.devops.backup.copyContentsToYamlFile", return_value=True) - - result = backupResources( - mock_client, - kind="ConfigMap", - api_version="v1", - backup_path=backup_path, - namespace="test-ns", - ) + with patch("mas.devops.backup.copyContentsToYamlFile", return_value=True): + result = backupResources( + mock_client, + kind="ConfigMap", + api_version="v1", + backup_path=backup_path, + namespace="test-ns", + ) backed_up, not_found, failed, secrets = result assert backed_up == 2 assert not_found == 0 assert failed == 0 - def test_backup_cluster_level_resource(self, tmp_path, mocker): + def test_backup_cluster_level_resource(self, tmp_path): """Test backing up cluster-level resources (no namespace)""" backup_path = str(tmp_path / "backup") @@ -533,22 +527,21 @@ def test_backup_cluster_level_resource(self, tmp_path, mocker): mock_api.get.return_value = mock_resource_obj mock_client.resources.get.return_value = mock_api - mocker.patch("mas.devops.backup.copyContentsToYamlFile", return_value=True) - - result = backupResources( - mock_client, - kind="ClusterRole", - api_version="rbac.authorization.k8s.io/v1", - backup_path=backup_path, - name="cluster-role", - ) + with patch("mas.devops.backup.copyContentsToYamlFile", return_value=True): + result = backupResources( + mock_client, + kind="ClusterRole", + api_version="rbac.authorization.k8s.io/v1", + backup_path=backup_path, + name="cluster-role", + ) backed_up, not_found, failed, secrets = result assert backed_up == 1 assert not_found == 0 assert failed == 0 - def test_backup_with_label_selector(self, tmp_path, mocker): + def test_backup_with_label_selector(self, tmp_path): """Test backing up resources with label selectors""" backup_path = str(tmp_path / "backup") @@ -573,16 +566,15 @@ def test_backup_with_label_selector(self, tmp_path, mocker): mock_api.get.return_value = mock_response mock_client.resources.get.return_value = mock_api - mocker.patch("mas.devops.backup.copyContentsToYamlFile", return_value=True) - - result = backupResources( - mock_client, - kind="ConfigMap", - api_version="v1", - backup_path=backup_path, - namespace="test-ns", - labels=["app=myapp", "env=prod"], - ) + with patch("mas.devops.backup.copyContentsToYamlFile", return_value=True): + result = backupResources( + mock_client, + kind="ConfigMap", + api_version="v1", + backup_path=backup_path, + namespace="test-ns", + labels=["app=myapp", "env=prod"], + ) backed_up, not_found, failed, secrets = result assert backed_up == 1 @@ -592,7 +584,7 @@ def test_backup_with_label_selector(self, tmp_path, mocker): # Verify label selector was passed correctly mock_api.get.assert_called_once_with(namespace="test-ns", label_selector="app=myapp,env=prod") - def test_backup_resource_not_found_by_name(self, mocker): + def test_backup_resource_not_found_by_name(self): """Test handling when a specific named resource is not found""" mock_client = MagicMock() mock_api = MagicMock() @@ -614,7 +606,7 @@ def test_backup_resource_not_found_by_name(self, mocker): assert failed == 0 assert secrets == set() - def test_backup_no_resources_found(self, mocker): + def test_backup_no_resources_found(self): """Test when no resources of the kind exist""" mock_response = MagicMock() mock_response.items = [] @@ -637,7 +629,7 @@ def test_backup_no_resources_found(self, mocker): assert not_found == 0 assert failed == 0 - def test_backup_discovers_secrets(self, tmp_path, mocker): + def test_backup_discovers_secrets(self, tmp_path): """Test that secrets are discovered from resource specs""" backup_path = str(tmp_path / "backup") @@ -664,22 +656,21 @@ def test_backup_discovers_secrets(self, tmp_path, mocker): mock_api.get.return_value = mock_resource_obj mock_client.resources.get.return_value = mock_api - mocker.patch("mas.devops.backup.copyContentsToYamlFile", return_value=True) - - result = backupResources( - mock_client, - kind="Deployment", - api_version="apps/v1", - backup_path=backup_path, - namespace="test-ns", - name="app-deployment", - ) + with patch("mas.devops.backup.copyContentsToYamlFile", return_value=True): + result = backupResources( + mock_client, + kind="Deployment", + api_version="apps/v1", + backup_path=backup_path, + namespace="test-ns", + name="app-deployment", + ) backed_up, not_found, failed, secrets = result assert backed_up == 1 assert secrets == {"db-credentials", "api-key"} - def test_backup_secret_does_not_discover_itself(self, tmp_path, mocker): + def test_backup_secret_does_not_discover_itself(self, tmp_path): """Test that backing up Secrets doesn't try to discover secrets""" backup_path = str(tmp_path / "backup") @@ -697,22 +688,21 @@ def test_backup_secret_does_not_discover_itself(self, tmp_path, mocker): mock_api.get.return_value = mock_resource_obj mock_client.resources.get.return_value = mock_api - mocker.patch("mas.devops.backup.copyContentsToYamlFile", return_value=True) - - result = backupResources( - mock_client, - kind="Secret", - api_version="v1", - backup_path=backup_path, - namespace="test-ns", - name="my-secret", - ) + with patch("mas.devops.backup.copyContentsToYamlFile", return_value=True): + result = backupResources( + mock_client, + kind="Secret", + api_version="v1", + backup_path=backup_path, + namespace="test-ns", + name="my-secret", + ) backed_up, not_found, failed, secrets = result assert backed_up == 1 assert secrets == set() # Should not discover secrets from Secret resources - def test_backup_write_failure(self, tmp_path, mocker): + def test_backup_write_failure(self, tmp_path): """Test handling when writing backup file fails""" backup_path = str(tmp_path / "backup") @@ -730,24 +720,22 @@ def test_backup_write_failure(self, tmp_path, mocker): mock_api.get.return_value = mock_resource_obj mock_client.resources.get.return_value = mock_api - # Mock copyContentsToYamlFile to fail - mocker.patch("mas.devops.backup.copyContentsToYamlFile", return_value=False) - - result = backupResources( - mock_client, - kind="ConfigMap", - api_version="v1", - backup_path=backup_path, - namespace="test-ns", - name="test-resource", - ) + with patch("mas.devops.backup.copyContentsToYamlFile", return_value=False): + result = backupResources( + mock_client, + kind="ConfigMap", + api_version="v1", + backup_path=backup_path, + namespace="test-ns", + name="test-resource", + ) backed_up, not_found, failed, secrets = result assert backed_up == 0 assert not_found == 0 assert failed == 1 - def test_backup_api_exception(self, mocker): + def test_backup_api_exception(self): """Test handling of general API exceptions""" mock_client = MagicMock() mock_client.resources.get.side_effect = Exception("API error") @@ -765,7 +753,7 @@ def test_backup_api_exception(self, mocker): assert not_found == 0 assert failed == 1 - def test_backup_mixed_success_and_failure(self, tmp_path, mocker): + def test_backup_mixed_success_and_failure(self, tmp_path): """Test backing up multiple resources with mixed success/failure""" backup_path = str(tmp_path / "backup") @@ -790,24 +778,23 @@ def test_backup_mixed_success_and_failure(self, tmp_path, mocker): mock_api.get.return_value = mock_response mock_client.resources.get.return_value = mock_api - # Mock copyContentsToYamlFile to succeed for first two, fail for third - mock_copy = mocker.patch("mas.devops.backup.copyContentsToYamlFile") - mock_copy.side_effect = [True, True, False] + with patch("mas.devops.backup.copyContentsToYamlFile") as mock_copy: + mock_copy.side_effect = [True, True, False] - result = backupResources( - mock_client, - kind="ConfigMap", - api_version="v1", - backup_path=backup_path, - namespace="test-ns", - ) + result = backupResources( + mock_client, + kind="ConfigMap", + api_version="v1", + backup_path=backup_path, + namespace="test-ns", + ) backed_up, not_found, failed, secrets = result assert backed_up == 2 assert not_found == 0 assert failed == 1 - def test_backup_resource_kind_not_found(self, mocker): + def test_backup_resource_kind_not_found(self): """Test when the resource kind itself is not found in the API""" mock_client = MagicMock() mock_client.resources.get.side_effect = NotFoundError(Mock()) diff --git a/test/src/test_db2.py b/test/src/test_db2.py index 3c37d43c..c6feaa43 100644 --- a/test/src/test_db2.py +++ b/test/src/test_db2.py @@ -9,10 +9,12 @@ # ***************************************************************************** import os +from unittest.mock import call +from unittest.mock import patch + import pytest import yaml - from mas.devops import db2 @@ -68,58 +70,55 @@ def test_check_db_cfgs_no_databases(): db2.check_db_cfgs(dict(spec=dict(environment=dict())), None, None, None) -def test_check_db_cfg_no_dbConfig(mocker): - mocker.patch("mas.devops.db2.db2_pod_exec_db2_get_db_cfg") - assert db2.check_db_cfg(dict(name="a"), None, None, None) == [] +def test_check_db_cfg_no_dbConfig(): + with patch("mas.devops.db2.db2_pod_exec_db2_get_db_cfg"): + assert db2.check_db_cfg(dict(name="a"), None, None, None) == [] -def test_check_db_cfg_empty_dbConfig(mocker): - mocker.patch("mas.devops.db2.db2_pod_exec_db2_get_db_cfg") - assert db2.check_db_cfg(dict(name="a", dbConfig=[]), None, None, None) == [] +def test_check_db_cfg_empty_dbConfig(): + with patch("mas.devops.db2.db2_pod_exec_db2_get_db_cfg"): + assert db2.check_db_cfg(dict(name="a", dbConfig=[]), None, None, None) == [] -def test_check_db_cfgs(mocker): +def test_check_db_cfgs(): """ Verifies that check_db_cfg function is called for each db in list """ - mock_CoreV1Api = mocker.patch("kubernetes.client.CoreV1Api") - mock_core_v1_api = mock_CoreV1Api.return_value + with patch("kubernetes.client.CoreV1Api") as mock_CoreV1Api, patch("mas.devops.db2.check_db_cfg") as mock_check_db_cfg: + mock_core_v1_api = mock_CoreV1Api.return_value - mock_check_db_cfg = mocker.patch("mas.devops.db2.check_db_cfg") - - db2.check_db_cfgs( - dict(spec=dict(environment=dict(databases=[dict(name="a"), dict(name="b")]))), - mock_core_v1_api, - "mas_instance_id", - "mas_app_id", - ) + db2.check_db_cfgs( + dict(spec=dict(environment=dict(databases=[dict(name="a"), dict(name="b")]))), + mock_core_v1_api, + "mas_instance_id", + "mas_app_id", + ) assert mock_check_db_cfg.call_args_list == [ - mocker.call(dict(name="a"), mock_core_v1_api, "mas_instance_id", "mas_app_id", "primary"), - mocker.call(dict(name="b"), mock_core_v1_api, "mas_instance_id", "mas_app_id", "primary"), + call(dict(name="a"), mock_core_v1_api, "mas_instance_id", "mas_app_id", "primary"), + call(dict(name="b"), mock_core_v1_api, "mas_instance_id", "mas_app_id", "primary"), ] -def test_check_db_cfg(mocker): - - mock_db2_pod_exec_db2_get_db_cfg = mocker.patch("mas.devops.db2.db2_pod_exec_db2_get_db_cfg") - mock_db2_pod_exec_db2_get_db_cfg.return_value = """ +def test_check_db_cfg(): + with patch("mas.devops.db2.db2_pod_exec_db2_get_db_cfg") as mock_db2_pod_exec_db2_get_db_cfg: + mock_db2_pod_exec_db2_get_db_cfg.return_value = """ Default application heap (4KB) (APPLHEAPSZ) = AUTOMATIC(8192) Changed pages threshold (CHNGPGS_THRESH) = 80 """ - db_name = "MYDB" - db = dict( - name=db_name, - dbConfig=dict(APPLHEAPSZ="8192 AUTOMATIC", NOTFOUNDINOUTPUT="XXX", CHNGPGS_THRESH="40"), - ) + db_name = "MYDB" + db = dict( + name=db_name, + dbConfig=dict(APPLHEAPSZ="8192 AUTOMATIC", NOTFOUNDINOUTPUT="XXX", CHNGPGS_THRESH="40"), + ) - assert set(db2.check_db_cfg(db, None, None, None)) == set( - [ - f"[db cfg for {db_name}] NOTFOUNDINOUTPUT not found in output of db2 get db cfg command", - f"[db cfg for {db_name}] CHNGPGS_THRESH: 40 != 80", - ] - ) + assert set(db2.check_db_cfg(db, None, None, None)) == set( + [ + f"[db cfg for {db_name}] NOTFOUNDINOUTPUT not found in output of db2 get db cfg command", + f"[db cfg for {db_name}] CHNGPGS_THRESH: 40 != 80", + ] + ) def test_check_dbm_cfg_no_spec(): @@ -147,32 +146,31 @@ def test_check_dbm_cfg_empty_dbmConfig(): assert db2.check_dbm_cfg(db2_instance_cr, None, None, None) == [] -def test_check_dbm_cfg(mocker): - - mock_db2_pod_exec_db2_get_dbm_cfg = mocker.patch("mas.devops.db2.db2_pod_exec_db2_get_dbm_cfg") - mock_db2_pod_exec_db2_get_dbm_cfg.return_value = """ +def test_check_dbm_cfg(): + with patch("mas.devops.db2.db2_pod_exec_db2_get_dbm_cfg") as mock_db2_pod_exec_db2_get_dbm_cfg: + mock_db2_pod_exec_db2_get_dbm_cfg.return_value = """ Agent stack size (AGENT_STACK_SZ) = 1024 """ - db2_instance_cr = dict( - spec=dict( - environment=dict( - instance=dict( - dbmConfig=dict( - AGENT_STACK_SZ="2048", - NOTFOUNDINOUTPUT="XXX", + db2_instance_cr = dict( + spec=dict( + environment=dict( + instance=dict( + dbmConfig=dict( + AGENT_STACK_SZ="2048", + NOTFOUNDINOUTPUT="XXX", + ) ) ) ) ) - ) - assert set(db2.check_dbm_cfg(db2_instance_cr, None, None, None)) == set( - [ - "[dbm cfg] NOTFOUNDINOUTPUT not found in output of db2 get dbm cfg command", - "[dbm cfg] AGENT_STACK_SZ: 2048 != 1024", - ] - ) + assert set(db2.check_dbm_cfg(db2_instance_cr, None, None, None)) == set( + [ + "[dbm cfg] NOTFOUNDINOUTPUT not found in output of db2 get dbm cfg command", + "[dbm cfg] AGENT_STACK_SZ: 2048 != 1024", + ] + ) def test_check_reg_cfg_no_spec(): @@ -200,59 +198,57 @@ def test_check_reg_cfg_empty_registry(): assert db2.check_reg_cfg(db2_instance_cr, None, None, None) == [] -def test_check_reg_cfg(mocker): - - mock_db2_pod_exec_db2set = mocker.patch("mas.devops.db2.db2_pod_exec_db2set") - mock_db2_pod_exec_db2set.return_value = """ +def test_check_reg_cfg(): + with patch("mas.devops.db2.db2_pod_exec_db2set") as mock_db2_pod_exec_db2set: + mock_db2_pod_exec_db2set.return_value = """ DB2AUTH=OSAUTHDB,ALLOW_LOCAL_FALLBACK,PLUGIN_AUTO_RELOAD DB2_FMP_COMM_HEAPSZ=65536 [O] """ - db2_instance_cr = dict( - spec=dict( - environment=dict( - instance=dict( - registry=dict( - DB2AUTH="WRONG", - NOTFOUNDINOUTPUT="XXX", + db2_instance_cr = dict( + spec=dict( + environment=dict( + instance=dict( + registry=dict( + DB2AUTH="WRONG", + NOTFOUNDINOUTPUT="XXX", + ) ) ) ) ) - ) - - assert set(db2.check_reg_cfg(db2_instance_cr, None, None, None)) == set( - [ - "[registry cfg] NOTFOUNDINOUTPUT not found in output of db2set command", - "[registry cfg] DB2AUTH: WRONG != OSAUTHDB,ALLOW_LOCAL_FALLBACK,PLUGIN_AUTO_RELOAD", - ] - ) + assert set(db2.check_reg_cfg(db2_instance_cr, None, None, None)) == set( + [ + "[registry cfg] NOTFOUNDINOUTPUT not found in output of db2set command", + "[registry cfg] DB2AUTH: WRONG != OSAUTHDB,ALLOW_LOCAL_FALLBACK,PLUGIN_AUTO_RELOAD", + ] + ) -def test_check_reg_cfg_with_empty_value_in_cr(mocker): - mock_db2_pod_exec_db2set = mocker.patch("mas.devops.db2.db2_pod_exec_db2set") - mock_db2_pod_exec_db2set.return_value = """ +def test_check_reg_cfg_with_empty_value_in_cr(): + with patch("mas.devops.db2.db2_pod_exec_db2set") as mock_db2_pod_exec_db2set: + mock_db2_pod_exec_db2set.return_value = """ DB2AUTH=OSAUTHDB,ALLOW_LOCAL_FALLBACK,PLUGIN_AUTO_RELOAD DB2_FMP_COMM_HEAPSZ=65536 [O] """ - db2_instance_cr = dict( - spec=dict( - environment=dict( - instance=dict( - registry=dict( - DB2AUTH="WRONG", - NOTFOUNDINOUTPUT="", + db2_instance_cr = dict( + spec=dict( + environment=dict( + instance=dict( + registry=dict( + DB2AUTH="WRONG", + NOTFOUNDINOUTPUT="", + ) ) ) ) ) - ) - assert set(db2.check_reg_cfg(db2_instance_cr, None, None, None)) == set( - ["[registry cfg] DB2AUTH: WRONG != OSAUTHDB,ALLOW_LOCAL_FALLBACK,PLUGIN_AUTO_RELOAD"] - ) + assert set(db2.check_reg_cfg(db2_instance_cr, None, None, None)) == set( + ["[registry cfg] DB2AUTH: WRONG != OSAUTHDB,ALLOW_LOCAL_FALLBACK,PLUGIN_AUTO_RELOAD"] + ) @pytest.mark.parametrize( @@ -311,7 +307,7 @@ def test_check_reg_cfg_with_empty_value_in_cr(mocker): ), ], ) -def test_validate_db2_config(test_case_name, expected_failures, mocker): +def test_validate_db2_config(test_case_name, expected_failures): """ Each test case corresponds to a folder under test/test_cases. Each folder must contain a file db2uinstance.yaml and optionally db2getdbcfg.txt, db2getdbmcfg.txt and db2set.txt. @@ -319,54 +315,56 @@ def test_validate_db2_config(test_case_name, expected_failures, mocker): current_dir = os.path.dirname(os.path.abspath(__file__)) - mock_get_db2u_instance_cr = mocker.patch("mas.devops.db2.get_db2u_instance_cr") - with open( - os.path.join(current_dir, "..", "test_cases", test_case_name, "db2uinstance.yaml"), - "r", - ) as f: - mock_get_db2u_instance_cr.return_value = yaml.load(f, Loader=yaml.FullLoader) - - mock_db2_pod_exec_db2_get_db_cfg = mocker.patch("mas.devops.db2.db2_pod_exec_db2_get_db_cfg") - try: - with open( - os.path.join(current_dir, "..", "test_cases", test_case_name, "db2getdbcfg.txt"), - "r", - ) as f: - mock_db2_pod_exec_db2_get_db_cfg.return_value = f.read() - except FileNotFoundError: - mock_db2_pod_exec_db2_get_db_cfg.return_value = None - - mock_db2_pod_exec_db2_get_dbm_cfg = mocker.patch("mas.devops.db2.db2_pod_exec_db2_get_dbm_cfg") - try: + with ( + patch("mas.devops.db2.get_db2u_instance_cr") as mock_get_db2u_instance_cr, + patch("mas.devops.db2.db2_pod_exec_db2_get_db_cfg") as mock_db2_pod_exec_db2_get_db_cfg, + patch("mas.devops.db2.db2_pod_exec_db2_get_dbm_cfg") as mock_db2_pod_exec_db2_get_dbm_cfg, + patch("mas.devops.db2.db2_pod_exec_db2set") as mock_db2_pod_exec_db2set, + patch("kubernetes.client.api_client.ApiClient") as mock_ApiClient, + ): with open( - os.path.join(current_dir, "..", "test_cases", test_case_name, "db2getdbmcfg.txt"), + os.path.join(current_dir, "..", "test_cases", test_case_name, "db2uinstance.yaml"), "r", ) as f: - mock_db2_pod_exec_db2_get_dbm_cfg.return_value = f.read() - except FileNotFoundError: - mock_db2_pod_exec_db2_get_dbm_cfg.return_value = None - - mock_db2_pod_exec_db2set = mocker.patch("mas.devops.db2.db2_pod_exec_db2set") - try: - with open( - os.path.join(current_dir, "..", "test_cases", test_case_name, "db2set.txt"), - "r", - ) as f: - mock_db2_pod_exec_db2set.return_value = f.read() - except FileNotFoundError: - mock_db2_pod_exec_db2set.return_value = None - - mock_ApiClient = mocker.patch("kubernetes.client.api_client.ApiClient") - mock_k8s_client = mock_ApiClient.return_value - - mas_instance_id = "unittest" - mas_app_id = test_case_name - - if len(expected_failures) == 0: - db2.validate_db2_config(mock_k8s_client, mas_instance_id, mas_app_id) - else: - with pytest.raises(Exception) as ex: + mock_get_db2u_instance_cr.return_value = yaml.load(f, Loader=yaml.FullLoader) + + try: + with open( + os.path.join(current_dir, "..", "test_cases", test_case_name, "db2getdbcfg.txt"), + "r", + ) as f: + mock_db2_pod_exec_db2_get_db_cfg.return_value = f.read() + except FileNotFoundError: + mock_db2_pod_exec_db2_get_db_cfg.return_value = None + + try: + with open( + os.path.join(current_dir, "..", "test_cases", test_case_name, "db2getdbmcfg.txt"), + "r", + ) as f: + mock_db2_pod_exec_db2_get_dbm_cfg.return_value = f.read() + except FileNotFoundError: + mock_db2_pod_exec_db2_get_dbm_cfg.return_value = None + + try: + with open( + os.path.join(current_dir, "..", "test_cases", test_case_name, "db2set.txt"), + "r", + ) as f: + mock_db2_pod_exec_db2set.return_value = f.read() + except FileNotFoundError: + mock_db2_pod_exec_db2set.return_value = None + + mock_k8s_client = mock_ApiClient.return_value + + mas_instance_id = "unittest" + mas_app_id = test_case_name + + if len(expected_failures) == 0: db2.validate_db2_config(mock_k8s_client, mas_instance_id, mas_app_id) + else: + with pytest.raises(Exception) as ex: + db2.validate_db2_config(mock_k8s_client, mas_instance_id, mas_app_id) - assert ex.value.args[0]["message"] == f"{len(expected_failures)} checks failed" - assert set(ex.value.args[0]["details"]) == set(expected_failures) + assert ex.value.args[0]["message"] == f"{len(expected_failures)} checks failed" + assert set(ex.value.args[0]["details"]) == set(expected_failures) diff --git a/test/src/test_mas.py b/test/src/test_mas.py index bc7c8511..de1d94e1 100644 --- a/test/src/test_mas.py +++ b/test/src/test_mas.py @@ -9,7 +9,7 @@ # ***************************************************************************** import pytest -from openshift import dynamic +from kubernetes import dynamic from kubernetes import config from kubernetes.client import api_client from kubernetes.dynamic.resource import ResourceInstance diff --git a/test/src/test_ocp.py b/test/src/test_ocp.py index 1600d638..a77b942c 100644 --- a/test/src/test_ocp.py +++ b/test/src/test_ocp.py @@ -8,10 +8,13 @@ # # ***************************************************************************** +from unittest.mock import ANY +from unittest.mock import MagicMock +from unittest.mock import patch + import pytest import yaml - from mas.devops import ocp @@ -26,42 +29,171 @@ def test_is_cluster_in_range(): assert ocp.isClusterVersionInRange("5.0.0", ["4.16", "4.17", "4.18"]) is False -def test_execInPod_success(mocker): - - mock_CoreV1Api = mocker.patch("kubernetes.client.CoreV1Api") - mock_core_v1_api = mock_CoreV1Api.return_value - - # Mock the `stream` function and the request object it returns - mock_stream = mocker.patch("mas.devops.ocp.stream") - - # Mock the response of the `stream` function - mock_req = mock_stream.return_value - mock_req.run_forever.return_value = None - mock_req.read_stdout.return_value = "mock_stdout" - mock_req.read_stderr.return_value = "mock_stderr" - mock_req.read_channel.return_value = yaml.dump({"status": "Success"}) - - o = ocp.execInPod(mock_core_v1_api, "pod_name", "namespace", ["command"]) - assert o == "mock_stdout" - - -def test_execInPod_failure(mocker): - - mock_CoreV1Api = mocker.patch("kubernetes.client.CoreV1Api") - mock_core_v1_api = mock_CoreV1Api.return_value - - # Mock the `stream` function and the request object it returns - mock_stream = mocker.patch("mas.devops.ocp.stream") - - # Mock the response of the `stream` function - mock_req = mock_stream.return_value - mock_req.run_forever.return_value = None - mock_req.read_stdout.return_value = "mock_stdout" - mock_req.read_stderr.return_value = "mock_stderr" - mock_req.read_channel.return_value = yaml.dump({"status": "Failure"}) - - with pytest.raises( - Exception, - match=r"Failed to execute \['command'\] on pod_name in namespace namespace: None. stdout: mock_stdout, stderr: mock_stderr", - ): - ocp.execInPod(mock_core_v1_api, "pod_name", "namespace", ["command"]) +def test_execInPod_success(): + with patch("kubernetes.client.CoreV1Api") as mock_CoreV1Api, patch("mas.devops.ocp.stream") as mock_stream: + mock_core_v1_api = mock_CoreV1Api.return_value + + mock_req = mock_stream.return_value + mock_req.run_forever.return_value = None + mock_req.read_stdout.return_value = "mock_stdout" + mock_req.read_stderr.return_value = "mock_stderr" + mock_req.read_channel.return_value = yaml.dump({"status": "Success"}) + + o = ocp.execInPod(mock_core_v1_api, "pod_name", "namespace", ["command"]) + assert o == "mock_stdout" + + +def test_execInPod_failure(): + with patch("kubernetes.client.CoreV1Api") as mock_CoreV1Api, patch("mas.devops.ocp.stream") as mock_stream: + mock_core_v1_api = mock_CoreV1Api.return_value + + mock_req = mock_stream.return_value + mock_req.run_forever.return_value = None + mock_req.read_stdout.return_value = "mock_stdout" + mock_req.read_stderr.return_value = "mock_stderr" + mock_req.read_channel.return_value = yaml.dump({"status": "Failure"}) + + with pytest.raises( + Exception, + match=r"Failed to execute \['command'\] on pod_name in namespace namespace: None. stdout: mock_stdout, stderr: mock_stderr", + ): + ocp.execInPod(mock_core_v1_api, "pod_name", "namespace", ["command"]) + + +def test_applyResource_creates_namespaced_resource(): + mock_dyn_client = MagicMock() + mock_resource_api = MagicMock() + mock_dyn_client.resources.get.return_value = mock_resource_api + mock_api_exception = MagicMock() + mock_api_exception.status = 404 + mock_api_exception.reason = "Not Found" + mock_resource_api.get.side_effect = ocp.NotFoundError(mock_api_exception) + + body = { + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": "test-secret"}, + } + + ocp.applyResource( + dynClient=mock_dyn_client, + apiVersion="v1", + kind="Secret", + body=body, + namespace="test-namespace", + ) + + mock_resource_api.get.assert_called_once_with(name="test-secret", namespace="test-namespace") + mock_resource_api.create.assert_called_once_with(body=body, namespace="test-namespace") + mock_resource_api.patch.assert_not_called() + + +def test_applyResource_patches_namespaced_resource(): + mock_dyn_client = MagicMock() + mock_resource_api = MagicMock() + mock_dyn_client.resources.get.return_value = mock_resource_api + + body = { + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": "test-secret"}, + } + + ocp.applyResource( + dynClient=mock_dyn_client, + apiVersion="v1", + kind="Secret", + body=body, + namespace="test-namespace", + ) + + mock_resource_api.get.assert_called_once_with(name="test-secret", namespace="test-namespace") + mock_resource_api.patch.assert_called_once_with( + body=body, + name="test-secret", + namespace="test-namespace", + content_type="application/merge-patch+json", + ) + mock_resource_api.create.assert_not_called() + + +def test_applyResource_creates_cluster_scoped_resource(): + mock_dyn_client = MagicMock() + mock_resource_api = MagicMock() + mock_dyn_client.resources.get.return_value = mock_resource_api + mock_api_exception = MagicMock() + mock_api_exception.status = 404 + mock_api_exception.reason = "Not Found" + mock_resource_api.get.side_effect = ocp.NotFoundError(mock_api_exception) + + body = { + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "ClusterRoleBinding", + "metadata": {"name": "test-binding"}, + } + + ocp.applyResource( + dynClient=mock_dyn_client, + apiVersion="rbac.authorization.k8s.io/v1", + kind="ClusterRoleBinding", + body=body, + ) + + mock_resource_api.get.assert_called_once_with(name="test-binding") + mock_resource_api.create.assert_called_once_with(body=body) + mock_resource_api.patch.assert_not_called() + + +def test_applyResource_patches_cluster_scoped_resource(): + mock_dyn_client = MagicMock() + mock_resource_api = MagicMock() + mock_dyn_client.resources.get.return_value = mock_resource_api + + body = { + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "ClusterRoleBinding", + "metadata": {"name": "test-binding"}, + } + + ocp.applyResource( + dynClient=mock_dyn_client, + apiVersion="rbac.authorization.k8s.io/v1", + kind="ClusterRoleBinding", + body=body, + ) + + mock_resource_api.get.assert_called_once_with(name="test-binding") + mock_resource_api.patch.assert_called_once_with( + body=body, + name="test-binding", + content_type="application/merge-patch+json", + ) + mock_resource_api.create.assert_not_called() + + +def test_apply_resource_uses_applyResource(): + resource_yaml = """ +apiVersion: v1 +kind: Secret +metadata: + name: test-secret +""" + + with patch("mas.devops.ocp.applyResource") as mock_apply_resource: + ocp.apply_resource( + dynClient=MagicMock(), + resource_yaml=resource_yaml, + namespace="test-namespace", + ) + + mock_apply_resource.assert_called_once_with( + dynClient=ANY, + apiVersion="v1", + kind="Secret", + body={ + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": "test-secret"}, + }, + namespace="test-namespace", + ) diff --git a/test/src/test_ocp_connect.py b/test/src/test_ocp_connect.py new file mode 100644 index 00000000..aa816ffc --- /dev/null +++ b/test/src/test_ocp_connect.py @@ -0,0 +1,114 @@ +# ***************************************************************************** +# Copyright (c) 2026 IBM Corporation and other Contributors. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Eclipse Public License v1.0 +# which accompanies this distribution, and is available at +# http://www.eclipse.org/legal/epl-v10.html +# +# ***************************************************************************** + +from unittest.mock import patch, MagicMock +from kubernetes.config.config_exception import ConfigException + +from mas.devops.ocp import connect + + +class TestOcpConnect: + """Test suite for ocp.connect() function.""" + + @patch("mas.devops.ocp.config.load_kube_config") + @patch("mas.devops.ocp.os.unlink") + @patch("mas.devops.ocp.tempfile.NamedTemporaryFile") + @patch("mas.devops.ocp.yaml.dump") + def test_connect_success(self, mock_yaml_dump, mock_tempfile, mock_unlink, mock_load_config): + """Test successful connection to OCP cluster.""" + # Setup mock temporary file + mock_file = MagicMock() + mock_file.name = "/tmp/test.kubeconfig" + mock_tempfile.return_value.__enter__.return_value = mock_file + + # Execute + result = connect( + server="https://api.test.example.com:6443", + token="test-token-123", + skipVerify=False, + ) + + # Verify + assert result is True + mock_yaml_dump.assert_called_once() + mock_load_config.assert_called_once_with(config_file="/tmp/test.kubeconfig") + mock_unlink.assert_called_once_with("/tmp/test.kubeconfig") + + @patch("mas.devops.ocp.config.load_kube_config") + @patch("mas.devops.ocp.os.unlink") + @patch("mas.devops.ocp.tempfile.NamedTemporaryFile") + @patch("mas.devops.ocp.yaml.dump") + def test_connect_with_tls_skip(self, mock_yaml_dump, mock_tempfile, mock_unlink, mock_load_config): + """Test connection with TLS verification skipped.""" + # Setup mock temporary file + mock_file = MagicMock() + mock_file.name = "/tmp/test.kubeconfig" + mock_tempfile.return_value.__enter__.return_value = mock_file + + # Execute + result = connect( + server="https://api.test.example.com:6443", + token="test-token-123", + skipVerify=True, + ) + + # Verify + assert result is True + # Verify yaml.dump was called with correct structure + call_args = mock_yaml_dump.call_args[0][0] + assert call_args["clusters"][0]["cluster"]["insecure-skip-tls-verify"] is True + + @patch("mas.devops.ocp.config.load_kube_config") + @patch("mas.devops.ocp.os.unlink") + @patch("mas.devops.ocp.tempfile.NamedTemporaryFile") + @patch("mas.devops.ocp.yaml.dump") + def test_connect_config_exception(self, mock_yaml_dump, mock_tempfile, mock_unlink, mock_load_config): + """Test connection failure with ConfigException.""" + # Setup mock temporary file + mock_file = MagicMock() + mock_file.name = "/tmp/test.kubeconfig" + mock_tempfile.return_value.__enter__.return_value = mock_file + + # Setup mock to raise ConfigException + mock_load_config.side_effect = ConfigException("Invalid configuration") + + # Execute + result = connect( + server="https://api.test.example.com:6443", + token="test-token-123", + ) + + # Verify + assert result is False + mock_unlink.assert_not_called() # Should not clean up on error + + @patch("mas.devops.ocp.config.load_kube_config") + @patch("mas.devops.ocp.os.unlink") + @patch("mas.devops.ocp.tempfile.NamedTemporaryFile") + @patch("mas.devops.ocp.yaml.dump") + def test_connect_unexpected_exception(self, mock_yaml_dump, mock_tempfile, mock_unlink, mock_load_config): + """Test connection failure with unexpected exception.""" + # Setup mock temporary file + mock_file = MagicMock() + mock_file.name = "/tmp/test.kubeconfig" + mock_tempfile.return_value.__enter__.return_value = mock_file + + # Setup mock to raise unexpected exception + mock_load_config.side_effect = RuntimeError("Unexpected error") + + # Execute + result = connect( + server="https://api.test.example.com:6443", + token="test-token-123", + ) + + # Verify + assert result is False + mock_unlink.assert_not_called() # Should not clean up on error diff --git a/test/src/test_olm.py b/test/src/test_olm.py index e0839742..4f6f4002 100644 --- a/test/src/test_olm.py +++ b/test/src/test_olm.py @@ -9,7 +9,7 @@ # ***************************************************************************** import pytest -from openshift import dynamic +from kubernetes import dynamic from kubernetes import config from kubernetes.client import api_client diff --git a/test/src/test_olm_installplan_selection.py b/test/src/test_olm_installplan_selection.py index 2097364c..ad49f902 100644 --- a/test/src/test_olm_installplan_selection.py +++ b/test/src/test_olm_installplan_selection.py @@ -111,11 +111,21 @@ def test_automatic_approval_uses_label_selector_only( mock_subscription.status.state = "AtLatestKnown" mock_subscription.status.installedCSV = "test-operator.v1.0.0" - # First call returns empty list (no existing subscription), subsequent calls return the subscription - sub_api.get.side_effect = [ - MockResourceList([]), # Initial check for existing subscription - mock_subscription, # Subsequent calls when waiting for subscription to complete - ] + # Mock to return empty list first, then the subscription for all subsequent calls + def sub_get_side_effect(*args, **kwargs): + if "label_selector" in kwargs: + # First call with label_selector returns empty list + if not hasattr(sub_get_side_effect, "called"): + sub_get_side_effect.called = True + return MockResourceList([]) + # Subsequent calls return the subscription + return MockResourceList([mock_subscription]) + elif "name" in kwargs: + # Direct get by name returns the subscription + return mock_subscription + return MockResourceList([]) + + sub_api.get.side_effect = sub_get_side_effect sub_api.apply.return_value = Mock() # Mock InstallPlan API - label selector returns one InstallPlan @@ -183,11 +193,18 @@ def test_manual_approval_without_starting_csv_uses_label_selector_only( mock_subscription.status.state = "UpgradePending" mock_subscription.status.installedCSV = "test-operator.v1.0.0" - # First call returns empty list (no existing subscription), subsequent calls return the subscription - sub_api.get.side_effect = [ - MockResourceList([]), # Initial check for existing subscription - mock_subscription, # Subsequent calls when waiting for subscription to complete - ] + # Mock to return empty list first, then the subscription for all subsequent calls + def sub_get_side_effect(*args, **kwargs): + if "label_selector" in kwargs: + if not hasattr(sub_get_side_effect, "called"): + sub_get_side_effect.called = True + return MockResourceList([]) + return MockResourceList([mock_subscription]) + elif "name" in kwargs: + return mock_subscription + return MockResourceList([]) + + sub_api.get.side_effect = sub_get_side_effect sub_api.apply.return_value = Mock() # Mock InstallPlan API @@ -270,11 +287,18 @@ def test_manual_approval_with_starting_csv_label_selector_finds_match( mock_subscription.status.state = "UpgradePending" mock_subscription.status.installedCSV = "test-operator.v1.0.0" - # First call returns empty list (no existing subscription), subsequent calls return the subscription - sub_api.get.side_effect = [ - MockResourceList([]), # Initial check for existing subscription - mock_subscription, # Subsequent calls when waiting for subscription to complete - ] + # Mock to return empty list first, then the subscription for all subsequent calls + def sub_get_side_effect(*args, **kwargs): + if "label_selector" in kwargs: + if not hasattr(sub_get_side_effect, "called"): + sub_get_side_effect.called = True + return MockResourceList([]) + return MockResourceList([mock_subscription]) + elif "name" in kwargs: + return mock_subscription + return MockResourceList([]) + + sub_api.get.side_effect = sub_get_side_effect sub_api.apply.return_value = Mock() # Mock InstallPlan API - label selector returns matching InstallPlan @@ -357,11 +381,18 @@ def test_manual_approval_with_starting_csv_fallback_to_ownership_search( mock_subscription.status.state = "UpgradePending" mock_subscription.status.installedCSV = "test-operator.v1.0.0" - # First call returns empty list (no existing subscription), subsequent calls return the subscription - sub_api.get.side_effect = [ - MockResourceList([]), # Initial check for existing subscription - mock_subscription, # Subsequent calls when waiting for subscription to complete - ] + # Mock to return empty list first, then the subscription for all subsequent calls + def sub_get_side_effect(*args, **kwargs): + if "label_selector" in kwargs: + if not hasattr(sub_get_side_effect, "called"): + sub_get_side_effect.called = True + return MockResourceList([]) + return MockResourceList([mock_subscription]) + elif "name" in kwargs: + return mock_subscription + return MockResourceList([]) + + sub_api.get.side_effect = sub_get_side_effect sub_api.apply.return_value = Mock() # Mock InstallPlan API @@ -464,11 +495,18 @@ def test_manual_approval_filters_by_subscription_ownership( mock_subscription.status.state = "UpgradePending" mock_subscription.status.installedCSV = "test-operator.v1.0.0" - # First call returns empty list (no existing subscription), subsequent calls return the subscription - sub_api.get.side_effect = [ - MockResourceList([]), # Initial check for existing subscription - mock_subscription, # Subsequent calls when waiting for subscription to complete - ] + # Mock to return empty list first, then the subscription for all subsequent calls + def sub_get_side_effect(*args, **kwargs): + if "label_selector" in kwargs: + if not hasattr(sub_get_side_effect, "called"): + sub_get_side_effect.called = True + return MockResourceList([]) + return MockResourceList([mock_subscription]) + elif "name" in kwargs: + return mock_subscription + return MockResourceList([]) + + sub_api.get.side_effect = sub_get_side_effect sub_api.apply.return_value = Mock() # Mock InstallPlan API diff --git a/test/src/test_restore.py b/test/src/test_restore.py index 26e60001..d20c13e9 100644 --- a/test/src/test_restore.py +++ b/test/src/test_restore.py @@ -10,7 +10,7 @@ import yaml from unittest.mock import MagicMock, Mock -from openshift.dynamic.exceptions import NotFoundError +from kubernetes.dynamic.exceptions import NotFoundError from mas.devops.restore import loadYamlFile, restoreResource diff --git a/test/src/test_tekton_update.py b/test/src/test_tekton_update.py new file mode 100644 index 00000000..908e04f9 --- /dev/null +++ b/test/src/test_tekton_update.py @@ -0,0 +1,208 @@ +# ***************************************************************************** +# Copyright (c) 2026 IBM Corporation and other Contributors. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Eclipse Public License v1.0 +# which accompanies this distribution, and is available at +# http://www.eclipse.org/legal/epl-v10.html +# +# ***************************************************************************** + +import pytest +from unittest.mock import patch, mock_open, MagicMock +import yaml +from kubernetes.dynamic.exceptions import ApiException + +from mas.devops.tekton import updateTektonDefinitions + + +class TestUpdateTektonDefinitions: + """Test suite for tekton.updateTektonDefinitions() function.""" + + @patch("mas.devops.tekton.applyResource") + @patch("mas.devops.tekton.path.isfile") + @patch("builtins.open", new_callable=mock_open) + @patch("mas.devops.tekton.yaml.safe_load_all") + def test_update_tekton_definitions_success(self, mock_yaml_load, mock_file, mock_isfile, mock_apply): + """Test successful application of Tekton resources.""" + # Setup + mock_isfile.return_value = True + mock_yaml_load.return_value = [ + { + "apiVersion": "tekton.dev/v1beta1", + "kind": "Task", + "metadata": {"name": "test-task"}, + "spec": {}, + }, + { + "apiVersion": "tekton.dev/v1beta1", + "kind": "Pipeline", + "metadata": {"name": "test-pipeline"}, + "spec": {}, + }, + ] + + mock_dyn_client = MagicMock() + mock_resource_api = MagicMock() + mock_dyn_client.resources.get.return_value = mock_resource_api + + # Execute + updateTektonDefinitions( + dynClient=mock_dyn_client, + namespace="test-namespace", + yamlFile="/path/to/test.yaml", + ) + + # Verify + assert mock_apply.call_count == 2 + + @patch("mas.devops.tekton.path.isfile") + def test_update_tekton_definitions_file_not_found(self, mock_isfile): + """Test FileNotFoundError when YAML file does not exist.""" + # Setup + mock_isfile.return_value = False + mock_dyn_client = MagicMock() + + # Execute and verify + with pytest.raises(FileNotFoundError) as exc_info: + updateTektonDefinitions( + dynClient=mock_dyn_client, + namespace="test-namespace", + yamlFile="/path/to/nonexistent.yaml", + ) + + assert "Tekton definitions file not found" in str(exc_info.value) + + @patch("mas.devops.tekton.path.isfile") + @patch("builtins.open", new_callable=mock_open) + @patch("mas.devops.tekton.yaml.safe_load_all") + def test_update_tekton_definitions_invalid_yaml(self, mock_yaml_load, mock_file, mock_isfile): + """Test yaml.YAMLError when YAML file is invalid.""" + # Setup + mock_isfile.return_value = True + mock_yaml_load.side_effect = yaml.YAMLError("Invalid YAML syntax") + mock_dyn_client = MagicMock() + + # Execute and verify + with pytest.raises(yaml.YAMLError): + updateTektonDefinitions( + dynClient=mock_dyn_client, + namespace="test-namespace", + yamlFile="/path/to/invalid.yaml", + ) + + @patch("mas.devops.tekton.applyResource") + @patch("mas.devops.tekton.path.isfile") + @patch("builtins.open", new_callable=mock_open) + @patch("mas.devops.tekton.yaml.safe_load_all") + def test_update_tekton_definitions_multiple_resources(self, mock_yaml_load, mock_file, mock_isfile, mock_apply): + """Test successful application of multiple resources in single file.""" + # Setup + mock_isfile.return_value = True + mock_yaml_load.return_value = [ + { + "apiVersion": "tekton.dev/v1beta1", + "kind": "Task", + "metadata": {"name": "task-1"}, + "spec": {}, + }, + { + "apiVersion": "tekton.dev/v1beta1", + "kind": "Task", + "metadata": {"name": "task-2"}, + "spec": {}, + }, + { + "apiVersion": "tekton.dev/v1beta1", + "kind": "Pipeline", + "metadata": {"name": "pipeline-1"}, + "spec": {}, + }, + ] + + mock_dyn_client = MagicMock() + mock_resource_api = MagicMock() + mock_dyn_client.resources.get.return_value = mock_resource_api + + # Execute + updateTektonDefinitions( + dynClient=mock_dyn_client, + namespace="test-namespace", + yamlFile="/path/to/multi.yaml", + ) + + # Verify + assert mock_apply.call_count == 3 + + @patch("mas.devops.tekton.applyResource") + @patch("mas.devops.tekton.path.isfile") + @patch("builtins.open", new_callable=mock_open) + @patch("mas.devops.tekton.yaml.safe_load_all") + @patch("mas.devops.tekton.sleep") + def test_update_tekton_definitions_retry_on_transient_error(self, mock_sleep, mock_yaml_load, mock_file, mock_isfile, mock_apply): + """Test retry logic on transient API errors.""" + # Setup + mock_isfile.return_value = True + mock_yaml_load.return_value = [ + { + "apiVersion": "tekton.dev/v1beta1", + "kind": "Task", + "metadata": {"name": "test-task"}, + "spec": {}, + } + ] + + mock_dyn_client = MagicMock() + mock_resource_api = MagicMock() + mock_dyn_client.resources.get.return_value = mock_resource_api + + # First call fails with 503, second succeeds + mock_apply.side_effect = [ + ApiException(status=503, reason="Service Unavailable"), + None, + ] + + # Execute + updateTektonDefinitions( + dynClient=mock_dyn_client, + namespace="test-namespace", + yamlFile="/path/to/test.yaml", + ) + + # Verify retry occurred + assert mock_apply.call_count == 2 + mock_sleep.assert_called_once() + + @patch("mas.devops.tekton.applyResource") + @patch("mas.devops.tekton.path.isfile") + @patch("builtins.open", new_callable=mock_open) + @patch("mas.devops.tekton.yaml.safe_load_all") + def test_update_tekton_definitions_api_exception(self, mock_yaml_load, mock_file, mock_isfile, mock_apply): + """Test ApiException on non-retryable error.""" + # Setup + mock_isfile.return_value = True + mock_yaml_load.return_value = [ + { + "apiVersion": "tekton.dev/v1beta1", + "kind": "Task", + "metadata": {"name": "test-task"}, + "spec": {}, + } + ] + + mock_dyn_client = MagicMock() + mock_resource_api = MagicMock() + mock_dyn_client.resources.get.return_value = mock_resource_api + + # Non-retryable error (e.g., 400 Bad Request) + mock_apply.side_effect = ApiException(status=400, reason="Bad Request") + + # Execute and verify + with pytest.raises(ApiException) as exc_info: + updateTektonDefinitions( + dynClient=mock_dyn_client, + namespace="test-namespace", + yamlFile="/path/to/test.yaml", + ) + + assert "Failed to apply Tekton resource" in str(exc_info.value)