From 55f828eb3dff24d3ba5de285d286212a3382368a Mon Sep 17 00:00:00 2001 From: Ryan Haasken Date: Mon, 23 Dec 2024 12:03:04 -0600 Subject: [PATCH] CRAYSAT-1938: Add first `sat bootprep` test --- constraints.txt | 1 + .../suites/ncn-sat-functional-tests.yaml | 1 + goss-testing/tests/ncn/goss-sat-bootprep.yaml | 41 ++ pyproject.toml | 1 + requirements.txt | 1 + .../tests/sat_functional/bootprep/__init__.py | 0 .../data/additional-inventory-config.yaml | 43 ++ .../bootprep/data/git-layers-config.yaml | 39 ++ .../bootprep/data/image-customization.yaml | 28 + .../data/ims-image-session-template.yaml | 27 + .../data/missing-playbook-config.yaml | 11 + .../bootprep/data/no-configs.yaml | 25 + .../bootprep/data/no-layers-config.yaml | 27 + .../bootprep/data/product-layers-config.yaml | 56 ++ .../data/special-parameters-config.yaml | 22 + .../sat_functional/bootprep/test_bootprep.py | 504 ++++++++++++++++++ 16 files changed, 827 insertions(+) create mode 100644 goss-testing/tests/ncn/goss-sat-bootprep.yaml create mode 100644 src/csm_testing/tests/sat_functional/bootprep/__init__.py create mode 100644 src/csm_testing/tests/sat_functional/bootprep/data/additional-inventory-config.yaml create mode 100644 src/csm_testing/tests/sat_functional/bootprep/data/git-layers-config.yaml create mode 100644 src/csm_testing/tests/sat_functional/bootprep/data/image-customization.yaml create mode 100644 src/csm_testing/tests/sat_functional/bootprep/data/ims-image-session-template.yaml create mode 100644 src/csm_testing/tests/sat_functional/bootprep/data/missing-playbook-config.yaml create mode 100644 src/csm_testing/tests/sat_functional/bootprep/data/no-configs.yaml create mode 100644 src/csm_testing/tests/sat_functional/bootprep/data/no-layers-config.yaml create mode 100644 src/csm_testing/tests/sat_functional/bootprep/data/product-layers-config.yaml create mode 100644 src/csm_testing/tests/sat_functional/bootprep/data/special-parameters-config.yaml create mode 100644 src/csm_testing/tests/sat_functional/bootprep/test_bootprep.py diff --git a/constraints.txt b/constraints.txt index 359bb881..c8e933e5 100644 --- a/constraints.txt +++ b/constraints.txt @@ -27,6 +27,7 @@ requests-oauthlib==2.0.0 requests-retry-session>=0.1,<0.2 rsa==4.9 s3transfer==0.5.2 +semver==2.13.0 six==1.16.0 typing-extensions==4.1.1 urllib3==1.26.20 diff --git a/goss-testing/suites/ncn-sat-functional-tests.yaml b/goss-testing/suites/ncn-sat-functional-tests.yaml index 54c06d7a..3eab868e 100644 --- a/goss-testing/suites/ncn-sat-functional-tests.yaml +++ b/goss-testing/suites/ncn-sat-functional-tests.yaml @@ -36,6 +36,7 @@ {{ $this_node_name := default $env_hostname $vars_hostname }} gossfile: {{if eq $this_node_name "ncn-m001"}} + ../tests/goss-sat-bootprep.yaml: {} ../tests/goss-sat-init.yaml: {} ../tests/goss-sat-nid2xname.yaml: {} ../tests/goss-sat-showrev.yaml: {} diff --git a/goss-testing/tests/ncn/goss-sat-bootprep.yaml b/goss-testing/tests/ncn/goss-sat-bootprep.yaml new file mode 100644 index 00000000..86efcd3f --- /dev/null +++ b/goss-testing/tests/ncn/goss-sat-bootprep.yaml @@ -0,0 +1,41 @@ +# +# MIT License +# +# (C) Copyright 2024 Hewlett Packard Enterprise Development LP +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# + +{{ $scripts := .Env.GOSS_BASE | printf "%s/scripts" }} +{{ $logrun := $scripts | printf "%s/log_run.sh" }} +{{ $sat_test := $scripts | printf "%s/python/sat_functional" }} +command: + {{ $test_label := "sat_bootprep_configs" }} + {{$test_label}}: + title: Test the 'sat bootprep run' command to create CFS configurations + meta: + desc: Tests that the 'sat bootprep run' command creates CFS configurations + sev: 0 + exec: |- + "{{$logrun}}" -l "{{$test_label}}" \ + "{{$sat_test}}" "bootprep.test_bootprep.TestBootprepCreateConfigs" + exit-status: 0 + stderr: + - OK + timeout: 50000 # timeout in milliseconds diff --git a/pyproject.toml b/pyproject.toml index cafe1d24..b9bf16ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,7 @@ license = { file = 'LICENSE' } [tool.setuptools] package-dir = {"" = "src"} +package-data = {"csm_testing" = ["tests/sat_functional/bootprep/data/*"]} [build-system] build-backend = 'setuptools.build_meta' diff --git a/requirements.txt b/requirements.txt index 3e157c83..ec378d94 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,6 +15,7 @@ requests-oauthlib packaging python-keycloak jsonschema +semver urllib3 # There are some additional Python packages that test scripts can rely on that are diff --git a/src/csm_testing/tests/sat_functional/bootprep/__init__.py b/src/csm_testing/tests/sat_functional/bootprep/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/csm_testing/tests/sat_functional/bootprep/data/additional-inventory-config.yaml b/src/csm_testing/tests/sat_functional/bootprep/data/additional-inventory-config.yaml new file mode 100644 index 00000000..31c26904 --- /dev/null +++ b/src/csm_testing/tests/sat_functional/bootprep/data/additional-inventory-config.yaml @@ -0,0 +1,43 @@ +--- +# Define a configuration that specifies additional_inventory +configurations: + +# A configuration that specifies additional_inventory with a branch +- name: "{{test.prefix}}-csm-additional-inventory-branch" + layers: + - name: csm-ncn_nodes + playbook: ncn_nodes.yml + product: + name: csm + version: "{{csm.version}}" + - name: csm-ncn-initrd + playbook: ncn-initrd.yml + product: + name: csm + branch: "{{csm.branch_name}}" + additional_inventory: + name: csm-additional-inventory + # Note that the CSM product's VCS repo does not really contain additional inventory, + # but it is sufficient to test creation of a configuration additional inventory. + url: "https://api-gw-service-nmn.local/vcs/cray/csm-config-management.git" + branch: "{{csm.branch_name}}" + +# A configuration that specifies additional_inventory with a commit hash +- name: "{{test.prefix}}-csm-additional-inventory-commit" + layers: + - name: csm-ncn_nodes + playbook: ncn_nodes.yml + product: + name: csm + version: "{{csm.version}}" + - name: csm-ncn-initrd + playbook: ncn-initrd.yml + product: + name: csm + branch: "{{csm.branch_name}}" + additional_inventory: + name: csm-additional-inventory + # Note that the CSM product's VCS repo does not really contain additional inventory, + # but it is sufficient to test creation of a configuration additional inventory. + url: "https://api-gw-service-nmn.local/vcs/cray/csm-config-management.git" + commit: "{{csm.commit_hash}}" diff --git a/src/csm_testing/tests/sat_functional/bootprep/data/git-layers-config.yaml b/src/csm_testing/tests/sat_functional/bootprep/data/git-layers-config.yaml new file mode 100644 index 00000000..dccd8373 --- /dev/null +++ b/src/csm_testing/tests/sat_functional/bootprep/data/git-layers-config.yaml @@ -0,0 +1,39 @@ +--- +# Define various configurations that define layers in terms of a git clone URL +configurations: + +# A configuration that speicfies a layer from a git clone URL and a commit +- name: "{{test.prefix}}-csm-git-commit-layer" + layers: + - name: csm-packages + playbook: csm_packages.yml + git: + url: "https://api-gw-service-nmn.local/vcs/cray/csm-config-management.git" + commit: "{{csm.commit_hash}}" + +# A configuration that speicfies a layer from a git clone URL and a branch +- name: "{{test.prefix}}-csm-git-branch-layer" + layers: + - name: csm-packages + playbook: csm_packages.yml + git: + url: "https://api-gw-service-nmn.local/vcs/cray/csm-config-management.git" + # master is the default branch of the repo and does not contain any content, + # but this is just a test that specifying a branch does work + branch: "{{csm.branch_name}}" + +# A configuration that specifies two layers with git clone URLs +- name: "{{test.prefix}}-csm-two-git-layers" + layers: + - name: csm-ncn_nodes + playbook: ncn_nodes.yml + git: + url: "https://api-gw-service-nmn.local/vcs/cray/csm-config-management.git" + commit: "{{csm.commit_hash}}" + - name: csm-ncn-initrd + playbook: ncn-initrd.yml + git: + url: "https://api-gw-service-nmn.local/vcs/cray/csm-config-management.git" + # master is the default branch of the repo and does not contain any content, + # but this is just a test that specifying a branch does work + branch: "{{csm.branch_name}}" diff --git a/src/csm_testing/tests/sat_functional/bootprep/data/image-customization.yaml b/src/csm_testing/tests/sat_functional/bootprep/data/image-customization.yaml new file mode 100644 index 00000000..675ae93c --- /dev/null +++ b/src/csm_testing/tests/sat_functional/bootprep/data/image-customization.yaml @@ -0,0 +1,28 @@ +--- +# Define a configuration to use in image customization +configurations: + +# A simple configuration that has just one layer that runs a simple playbook +- name: "{{test.prefix}}-simple-passing-configuration" + layers: + - name: successful-layer + playbook: test.yml + git: + # The url does not currently support templating, so this doesn't work yet + url: "https://api-gw-service-nmn.local/vcs/cray/{{test.vcs_repo_name}}.git" + branch: "main" + +images: +- name: "{{test.prefix}}-simple-customized-image" + base: + product: + name: csm + version: "{{csm.version}}" + type: image + filter: + arch: x86_64 + wildcard: "*barebones*" + configuration: "{{test.prefix}}-simple-passing-configuration" + configuration_group_names: + - Compute + diff --git a/src/csm_testing/tests/sat_functional/bootprep/data/ims-image-session-template.yaml b/src/csm_testing/tests/sat_functional/bootprep/data/ims-image-session-template.yaml new file mode 100644 index 00000000..a482f991 --- /dev/null +++ b/src/csm_testing/tests/sat_functional/bootprep/data/ims-image-session-template.yaml @@ -0,0 +1,27 @@ +--- +# Define a session template that references an existing IMS image +configurations: +# A simple configuration that has just one layer that runs a simple playbook +- name: "{{test.prefix}}-simple-passing-configuration" + layers: + - name: successful-layer + playbook: test.yml + git: + url: "https://api-gw-service-nmn.local/vcs/cray/{{test.vcs_repo_name}}.git" + branch: "main" + +session_templates: +- name: "{{test.prefix}}-simple-session-template" + image: + ims: + id: "{{csm.image_id}}" + configuration: "{{test.prefix}}-simple-passing-configuration" + bos_parameters: + boot_sets: + compute: + arch: X86 + kernel_parameters: "spire_join_token=${SPIRE_JOIN_TOKEN}" + node_roles_groups: + - Compute + rootfs_provider: "sbps" + rootfs_provider_passthrough: "sbps:v1:iqn.2023-06.csm.iscsi:_sbps-hsn._tcp.{{default.system_name}}.{{default.site_domain}}:300" diff --git a/src/csm_testing/tests/sat_functional/bootprep/data/missing-playbook-config.yaml b/src/csm_testing/tests/sat_functional/bootprep/data/missing-playbook-config.yaml new file mode 100644 index 00000000..f405852e --- /dev/null +++ b/src/csm_testing/tests/sat_functional/bootprep/data/missing-playbook-config.yaml @@ -0,0 +1,11 @@ +--- +# Define a configuration that has a layer without a playbook +# This should be allowed when using `--cfs-version v2` but not when using +# `--cfs-version v3`. +configurations: +- name: "{{test.prefix}}-csm-{{csm.version}}-product-layer" + layers: + - name: csm-packages-{{csm.version}} + product: + name: csm + version: "{{csm.version}}" diff --git a/src/csm_testing/tests/sat_functional/bootprep/data/no-configs.yaml b/src/csm_testing/tests/sat_functional/bootprep/data/no-configs.yaml new file mode 100644 index 00000000..1e6d76cd --- /dev/null +++ b/src/csm_testing/tests/sat_functional/bootprep/data/no-configs.yaml @@ -0,0 +1,25 @@ +# +# MIT License +# +# (C) Copyright 2025 Hewlett Packard Enterprise Development LP +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +--- +configurations: [] diff --git a/src/csm_testing/tests/sat_functional/bootprep/data/no-layers-config.yaml b/src/csm_testing/tests/sat_functional/bootprep/data/no-layers-config.yaml new file mode 100644 index 00000000..bd32f0b6 --- /dev/null +++ b/src/csm_testing/tests/sat_functional/bootprep/data/no-layers-config.yaml @@ -0,0 +1,27 @@ +# +# MIT License +# +# (C) Copyright 2025 Hewlett Packard Enterprise Development LP +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +--- +configurations: +- name: "{{test.prefix}}-no-layers" + layers: [] diff --git a/src/csm_testing/tests/sat_functional/bootprep/data/product-layers-config.yaml b/src/csm_testing/tests/sat_functional/bootprep/data/product-layers-config.yaml new file mode 100644 index 00000000..289768bc --- /dev/null +++ b/src/csm_testing/tests/sat_functional/bootprep/data/product-layers-config.yaml @@ -0,0 +1,56 @@ +--- +# Define various configurations that define layers in terms of a product +configurations: + +# A configuration that specifies a layer from a specific CSM version +- name: "{{test.prefix}}-csm-{{csm.version}}-product-layer" + layers: + - name: csm-packages-{{csm.version}} + playbook: csm_packages.yml + product: + name: csm + version: "{{csm.version}}" + +# A configuration that specifies a layer from the latest CSM version +- name: "{{test.prefix}}-csm-latest-product-layer" + layers: + - name: csm-packages-{{csm.version}} + playbook: csm_packages.yml + product: + name: csm + version: latest + +# A configuration that specifies a layer from the CSM product with a specific commit +- name: "{{test.prefix}}-csm-product-layer-commit" + layers: + - name: csm-packages-{{csm.version}} + playbook: csm_packages.yml + product: + name: csm + commit: "{{csm.commit_hash}}" + +# A configuration that specifies a layer from the CSM product with a specific branch +- name: "{{test.prefix}}-csm-product-layer-branch" + layers: + - name: csm-packages-{{csm.version}} + playbook: csm_packages.yml + product: + name: csm + # master is the default branch of the repo and does not contain any content, + # but this is just a test that specifying a branch does work + branch: "{{csm.branch_name}}" + +# A configuration that specifies two product-based layers +- name: "{{test.prefix}}-csm-two-product-layers" + layers: + - name: csm-ncn_nodes + playbook: ncn_nodes.yml + product: + name: csm + version: "{{csm.version}}" + - name: csm-ncn-initrd + playbook: ncn-initrd.yml + product: + name: csm + branch: "{{csm.branch_name}}" + diff --git a/src/csm_testing/tests/sat_functional/bootprep/data/special-parameters-config.yaml b/src/csm_testing/tests/sat_functional/bootprep/data/special-parameters-config.yaml new file mode 100644 index 00000000..8c2953b0 --- /dev/null +++ b/src/csm_testing/tests/sat_functional/bootprep/data/special-parameters-config.yaml @@ -0,0 +1,22 @@ +--- +# Define a configuration that specifies special_parameters in its layers +configurations: + +# A configuration that specifies two layers, each with special parameters +- name: "{{test.prefix}}-csm-special-parameters-layers" + layers: + - name: csm-ncn_nodes + playbook: ncn_nodes.yml + product: + name: csm + version: "{{csm.version}}" + special_parameters: + ims_require_dkms: False + - name: csm-ncn-initrd + playbook: ncn-initrd.yml + product: + name: csm + branch: "{{csm.branch_name}}" + special_parameters: + ims_require_dkms: True + diff --git a/src/csm_testing/tests/sat_functional/bootprep/test_bootprep.py b/src/csm_testing/tests/sat_functional/bootprep/test_bootprep.py new file mode 100644 index 00000000..8dedb757 --- /dev/null +++ b/src/csm_testing/tests/sat_functional/bootprep/test_bootprep.py @@ -0,0 +1,504 @@ +# +# MIT License +# +# (C) Copyright 2024 Hewlett Packard Enterprise Development LP +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +""" +Tests for functionality of the `sat bootprep` command. +""" +import base64 +import re +from datetime import datetime +import functools +import json +import logging +import os +import shlex +import shutil +import subprocess +import tempfile +from typing import List +import unittest + +from kubernetes.client import CoreV1Api +from kubernetes.client.exceptions import ApiException +from kubernetes.config.config_exception import ConfigException +from kubernetes.config.kube_config import load_kube_config +import pkg_resources +from semver import VersionInfo +import yaml + + +logging.basicConfig(level=logging.WARNING, format='%(levelname)s: %(message)s') + + +def skip_test_if_csm_var_missing(var_names: List[str]) -> callable: + """Decorator to skip a test if given CSM vars are unavailable""" + def decorator(test_method): + @functools.wraps(test_method) + def wrapper(self, *args, **kwargs): + missing_vars = [var_name for var_name in var_names if var_name not in self.csm_vars] + if missing_vars: + self.skipTest('\n'.join( + [f'Missing CSM vars from vars file: {", ".join(missing_vars)}.'] + + getattr(self, 'set_up_errors', [])) + ) + return test_method(self, *args, **kwargs) + + return wrapper + + return decorator + + +class BootprepRunTestCase(unittest.TestCase): + """Base test class for `sat bootprep run` tests. + + This base class handles setting up a temporary directory into which bootprep + input files will be copied. The `sat bootprep` command will be run with this + temporary directory as the current working directory, which allows it to access + the files it contains. + """ + + def setUp(self): + self.items_to_delete = { + 'configurations': [], + 'images': [], + 'session_templates': [] + } + + def tearDown(self): + for cfs_config_name in self.items_to_delete['configurations']: + self.delete_cfs_configuration(cfs_config_name) + + for ims_image_id in self.items_to_delete['images']: + self.delete_ims_image(ims_image_id) + + for bos_session_template_name in self.items_to_delete['session_templates']: + self.delete_bos_session_template(bos_session_template_name) + + self.delete_all_cfs_configurations_matching_prefix(self.test_prefix) + + @classmethod + def setUpClass(cls): + """Create a temporary directory for bootprep input files.""" + cls.set_up_errors = [] + cls.temp_dir = tempfile.TemporaryDirectory() # pylint: disable=consider-using-with + + cls.test_prefix = f'sat-bp-testing-{datetime.now().strftime("%Y-%m-%d")}' + + cls.get_product_catalog_data() + cls.get_csm_vars() + + # Create the vars.yaml file in the temporary directory + cls.vars_file_name = 'vars.yaml' + vars_file_path = os.path.join(cls.temp_dir.name, cls.vars_file_name) + vars_data = { + 'test': { + 'prefix': cls.test_prefix + }, + 'csm': cls.csm_vars + } + with open(vars_file_path, 'w', encoding='utf-8') as vars_file: + yaml.dump(vars_data, vars_file) + + @classmethod + def tearDownClass(cls): + """Remove the temporary directory for bootprep input files.""" + cls.temp_dir.cleanup() + + @classmethod + def get_product_catalog_data(cls) -> None: + """Get the product catalog data from the cray-product-catalog configmap + + We access the ConfiMap directly using the Kubernetes API to avoid using + the same cray_product_catalog.query module used by the sat code under test. + """ + cls.product_catalog_data = {} + try: + load_kube_config() + except ConfigException as err: + cls.set_up_errors.append(f'Unable to get product catalog; failed to ' + f'load kubeconfig: {err}') + return + + try: + kube_api = CoreV1Api() + # Get the data from the cray-product-catalog configmap in the services namespace + config_map = kube_api.read_namespaced_config_map('cray-product-catalog', 'services') + cls.product_catalog_data = config_map.data + except ApiException as err: + cls.set_up_errors.append(f'Unable to get product catalog; ' + f'failed to read configmap: {err}') + return + + + + @classmethod + def get_branch_name(cls, commit_url): + + git_repo_name = str.split(commit_url, "/")[-1] + vcs_credentials_command = "kubectl get secret -n services vcs-user-credentials -o jsonpath='{.data.vcs_password}'" + + try: + + proc = subprocess.run(shlex.split(vcs_credentials_command), stdout=subprocess.PIPE, stderr=subprocess.PIPE, + check=True) + vcs_creds_encoded = proc.stdout.decode() + vcs_creds_decoded = base64.b64decode(vcs_creds_encoded).decode('utf-8') + + git_command = f'git ls-remote --heads https://crayvcs:{vcs_creds_decoded}@api-gw-service-nmn.local/vcs/cray/' + git_repo_name + + proc = subprocess.run(shlex.split(git_command), stdout=subprocess.PIPE, stderr=subprocess.PIPE, + check=True) + + branches_unprocessed = proc.stdout.decode().splitlines() + branches = [line.split("/")[-1] for line in branches_unprocessed] + + if "main" in branches: + return "main" + elif "master" in branches: + return "master" + else: + return branches[0] + + except subprocess.CalledProcessError as err: + cls.set_up_errors.append() + logging.warning('Failed to get branch names for "%s" ' + 'created by test: %s' + 'defaulting to main branch', git_repo_name, err.stderr) + return "main" + + @classmethod + def get_csm_vars(cls): + """Get variables from the product catalog for the CSM product to use in the vars file.""" + cls.csm_vars = {} + if not cls.product_catalog_data: + return + + try: + csm_data = yaml.safe_load(cls.product_catalog_data['csm']) + except KeyError as err: + cls.set_up_errors.append(f'Unable to get CSM data from ' + f'product catalog; missing "{err}" key') + return + + good_csm_versions = [] + for version, data in csm_data.items(): + # Ensure that 'configuration' and 'images' keys are present, so that + # we can find the supplied VCS commit hash and the barebones image ID + if 'configuration' in data and 'images' in data: + good_csm_versions.append(version) + good_csm_versions.sort(key=VersionInfo.parse) + + if not good_csm_versions: + cls.set_up_errors.append('Unable to get CSM data from product catalog; no versions ' + 'with both "configuration" and "images" keys') + return + + cls.csm_vars['version'] = latest_csm_version = good_csm_versions[-1] + commit_url = "" + + try: + # Use a different CSM version for the commit hash if possible + first_csm_version = good_csm_versions[0] + cls.csm_vars['commit_hash'] = csm_data[first_csm_version]['configuration']['commit'] + commit_url = csm_data[first_csm_version]['configuration']['clone_url'] + except KeyError as err: + cls.set_up_errors.append(f'Unable to get commit hash from first ' + f'CSM version; missing "{err}" key') + + cls.csm_vars['branch_name'] = cls.get_branch_name(commit_url) + + latest_barebones_images = [image_data['id'] for image_name, image_data + in csm_data[latest_csm_version]['images'].items() + if 'barebones' in image_name] + if latest_barebones_images: + cls.csm_vars['image_id'] = latest_barebones_images[0] + else: + cls.set_up_errors.append('Unable to get barebones image id from latest CSM version') + + def assert_in_log_messages(self, level: str, message_substring: str, stderr: str) -> None: + """Assert that the given substring appears in a log message prefixed with the given level. + + Args: + level: The log level to check for the messages. + message_substring: The expected message + stderr: The stderr output from running the command + + Returns: + None + """ + messages = [line for line in stderr.splitlines() if line.startswith(level)] + self.assertTrue(any(message_substring in message for message in messages), + f'No {level} log message containing "{message_substring}" found in stderr') + + @staticmethod + def delete_cfs_configuration(cfs_config_name): + """Delete a CFS configuration using the 'cray' CLI. + + This relies on the cray CLI being configured and authenticated on the system. + """ + delete_command = f'cray cfs configurations delete {cfs_config_name}' + try: + subprocess.run(shlex.split(delete_command), check=True) + except subprocess.CalledProcessError as err: + logging.warning('Failed to delete CFS configuration "%s" ' + 'created by test: %s', cfs_config_name, err.stderr) + + + @staticmethod + def delete_all_cfs_configurations_matching_prefix(cfs_config_prefix): + """Find and delete all CFS configurations matching a prefix using the 'cray' CLI. + + This relies on the cray CLI being configured and authenticated on the system. + """ + find_command = f'cray cfs v3 configurations list' + found_configurations = [] + try: + proc = subprocess.run(shlex.split(find_command), stdout=subprocess.PIPE, stderr=subprocess.PIPE, + check=True) + proc_lines = proc.stdout.decode().splitlines() + + filtered_lines = [line for line in proc_lines if cfs_config_prefix in line] + + simplified_values = [re.search(r'"([^"]*)"$', item).group(1) for item in filtered_lines] + + found_configurations = simplified_values + + except subprocess.CalledProcessError as err: + logging.warning('Failed to find CFS configurations with prefix "%s" ' + 'created by test: %s', cfs_config_prefix, err.stderr) + + for configuration_name in found_configurations: + delete_command = f'cray cfs configurations delete {configuration_name}' + try: + subprocess.run(shlex.split(delete_command), check=True) + except subprocess.CalledProcessError as err: + logging.warning('Failed to delete CFS configuration "%s" ' + 'created by test: %s', configuration_name, err.stderr) + + + @staticmethod + def delete_ims_image(ims_image_id, permanent=True): + """Delete an IMS image using the 'cray' CLI. + + This relies on the cray CLI being configured and authenticated on the system. + """ + delete_command = f'cray ims images delete {ims_image_id}' + try: + subprocess.run(shlex.split(delete_command), check=True) + except subprocess.CalledProcessError as err: + logging.warning('Failed to delete IMS image "%s" ' + 'created by test: %s', ims_image_id, err.stderr) + return + + if permanent: + delete_command = f'cray ims deleted images delete {ims_image_id}' + try: + subprocess.run(shlex.split(delete_command), check=True) + except subprocess.CalledProcessError as err: + logging.warning('Failed to permanently delete IMS image "%s" ' + 'created by test: %s', ims_image_id, err.stderr) + + @staticmethod + def delete_bos_session_template(bos_session_template_name): + """Delete a BOS session template using the 'cray' CLI. + + This relies on the cray CLI being configured and authenticated on the system. + """ + delete_command = f'cray bos sessiontemplates delete {bos_session_template_name}' + try: + subprocess.run(shlex.split(delete_command), check=True) + except subprocess.CalledProcessError as err: + logging.warning('Failed to delete BOS session template "%s" ' + 'created by test: {%s}', bos_session_template_name, err.stderr) + + def run_bootprep(self, bootprep_file: str, bootprep_opts: str = None, + check: bool = True) -> subprocess.CompletedProcess: + """Run the `sat bootprep` command with the given arguments. + + This handles copying the bootprep input file into the temporary directory + and running the command with that directory as the current working directory. + + Args: + bootprep_file: The path to the bootprep input file to use. This file + will first be copied into the temporary directory created in the + setUpClass method. + bootprep_opts: The options to pass to the `sat bootprep run` command. + check: Whether to raise a subprocess.CalledProcessError if the command fails. + + Returns: + The subprocess.CompletedProcess object. + + Raises: + subprocess.CalledProcessError: If the command fails and check is True. + """ + bootprep_opts_str = f'--vars-file {self.vars_file_name}' + if bootprep_opts: + bootprep_opts_str += f' {bootprep_opts}' + # Find the path to the given bootprep_file in the data directory + src_bootprep_file_path = pkg_resources.resource_filename( + 'csm_testing', + f'tests/sat_functional/bootprep/data/{bootprep_file}' + ) + + # Copy the bootprep input file into the temporary directory + tmp_bootprep_file_path = os.path.join(self.temp_dir.name, os.path.basename(bootprep_file)) + shutil.copy(src_bootprep_file_path, tmp_bootprep_file_path) + + # Since the command is executed in the temporary directory containing + # the bootprep input file, just use the relative file path + command = f'sat bootprep run {bootprep_opts_str} {bootprep_file}' + try: + result = subprocess.run(shlex.split(command), cwd=self.temp_dir.name, check=check, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + except subprocess.CalledProcessError as err: + logging.error(f"\nFailed to run command: {' '.join(err.cmd)}\n" + f"Exit Status: {err.returncode}\n" + f"Standard Output: \n{err.output.decode()}\n" + f"Standard Error: \n{err.stderr.decode()}\n") + raise + + return result + + +class TestBootprepCreateConfigs(BootprepRunTestCase): + """Tests for creating CFS configurations using `sat bootprep run`""" + + def validate_cfs_config(self, config_name: str): + """Validate that a cfs configuration has been created on the system + """ + command = "cray cfs v3 configurations describe " + config_name + + try: + result = subprocess.run(shlex.split(command), cwd=self.temp_dir.name, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + configuration = result.stdout.decode() + + self.assertIn("clone_url", configuration) + self.assertIn(".git", configuration) + self.assertIn("commit", configuration) + self.assertIn("name", configuration) + + except subprocess.CalledProcessError as err: + logging.error("Failed to get cfs config %s" + "with error: %s", config_name, err) + + + def test_no_configs(self): + """Test that a file with an empty list of configs creates no configs""" + result = self.run_bootprep('no-configs.yaml', '--format json') + + report = json.loads(result.stdout.decode()) + self.assertEqual({}, report) + self.assert_in_log_messages( + "INFO", + "Nothing to create in collection of CFS configurations", + result.stderr.decode() + ) + + def test_no_layers(self): + """Test creating a CFS configuration with no layers""" + result = self.run_bootprep('no-layers-config.yaml', '--format json') + + report = json.loads(result.stdout.decode()) + self.assertEqual(1, len(report['configurations'])) + self.assertEqual(f'{self.test_prefix}-no-layers', report['configurations'][0]['name']) + + self.items_to_delete['configurations'].append(f'{self.test_prefix}-no-layers') + + @skip_test_if_csm_var_missing(['version', 'commit_hash']) + def test_product_layers(self): + """Test creating multiple CFS configurations using product-based layers""" + result = self.run_bootprep('product-layers-config.yaml', '--format json') + + report = json.loads(result.stdout.decode()) + self.assertEqual(5, len(report['configurations'])) + + for config in report['configurations']: + self.validate_cfs_config(config['name']) + + for config in report['configurations']: + self.items_to_delete['configurations'].append(config['name']) + + def test_git_layers(self): + """Test creating multiple CFS configurations with git-based layers""" + result = self.run_bootprep('git-layers-config.yaml', '--format json') + + report = json.loads(result.stdout.decode()) + self.assertEqual(3, len(report['configurations'])) + + for config in report['configurations']: + self.validate_cfs_config(config['name']) + + for config in report['configurations']: + self.items_to_delete['configurations'].append(config['name']) + + def test_special_parameters(self): + """Test creating a CFS configuration with special parameters""" + result = self.run_bootprep('special-parameters-config.yaml', '--format json') + + report = json.loads(result.stdout.decode()) + self.assertEqual(1, len(report['configurations'])) + self.assertEqual(f'{self.test_prefix}-csm-special-parameters-layers', + report['configurations'][0]['name']) + + for config in report['configurations']: + self.validate_cfs_config(config['name']) + + for config in report['configurations']: + self.items_to_delete['configurations'].append(config['name']) + + def test_additional_inventory(self): + """Test creating CFS configurations with additional inventory""" + result = self.run_bootprep('additional-inventory-config.yaml', '--format json') + + report = json.loads(result.stdout.decode()) + self.assertEqual(2, len(report['configurations'])) + self.assertEqual(f'{self.test_prefix}-csm-additional-inventory-branch', + report['configurations'][0]['name']) + + for config in report['configurations']: + self.validate_cfs_config(config['name']) + + for config in report['configurations']: + self.items_to_delete['configurations'].append(config['name']) + + def test_missing_playbook(self): + """Test creating a CFS configuration with a missing playbook using CFS v3 fails""" + result = self.run_bootprep('missing-playbook-config.yaml', '--format json', check=False) + + self.assertEqual(1, result.returncode) + decoded_stderr = result.stderr.decode() + self.assert_in_log_messages( + "ERROR", + "A playbook is required when using the CFS v3 API to create configurations", + decoded_stderr + ) + self.assert_in_log_messages( + "ERROR", + "The CFS configuration at index 0 is not valid.", + decoded_stderr + ) + + +if __name__ == '__main__': + unittest.main()