From 38b4953e0598b4e9732d083fddfa6b1959b5f19d Mon Sep 17 00:00:00 2001 From: junfeng-li Date: Tue, 5 Dec 2023 15:34:40 +0000 Subject: [PATCH] software deploy show implementation This commit is to implement 'software deploy show' Only one upgrade is allowed in the deployment every time. [sysadmin@controller-0 ~(keystone_admin)]$ software deploy show From Release To Release Reboot Required State ============ ========== =============== ========= 23.09 24.03 Yes deploying Test Plan: PASS: built and installed the iso and ran the command with deploy in progress PASS: built and installed the iso and ran the command without deploy in progress Task: 49134 Story: 2010676 Change-Id: I292837f1b9b39afbc589a9cae08b4c4b7f363b5e Signed-off-by: junfeng-li --- .../software_client/software_client.py | 51 ++++-- software/software/api/controllers/root.py | 10 ++ software/software/constants.py | 20 ++- software/software/db/api.py | 20 +-- software/software/software_controller.py | 8 + software/software/software_entities.py | 155 ++++++++---------- software/software/utils.py | 21 ++- 7 files changed, 178 insertions(+), 107 deletions(-) diff --git a/software-client/software_client/software_client.py b/software-client/software_client/software_client.py index 9f87049f..00e62d4a 100644 --- a/software-client/software_client/software_client.py +++ b/software-client/software_client/software_client.py @@ -308,7 +308,7 @@ def release_is_available_req(args): print("An internal error has occurred. Please check /var/log/software.log for details") else: print("Error: %s has occurred. %s" % (req.status_code, req.reason)) - + return rc @@ -930,9 +930,40 @@ def deploy_complete_req(args): return check_rc(req) -def deploy_list_req(args): - print(args.deployment) - return 1 +def deploy_show_req(args): + url = "http://%s/software/deploy_show" % api_addr + headers = {} + append_auth_token_if_required(headers) + req = requests.get(url, headers=headers) + + if req.status_code >= 500: + print("An internal error has occurred. Please check /var/log/software.log for details") + return 1 + elif req.status_code >= 400: + print("Respond code %d. Error: %s" % (req.status_code, req.reason)) + return 1 + + data = json.loads(req.text) + if not data: + print("No deploy in progress.\n") + else: + data["reboot_required"] = "Yes" if data.get("reboot_required") else "No" + data_list = [[k, v] for k, v in data.items()] + transposed_data_list = list(zip(*data_list)) + + transposed_data_list[0] = [s.title().replace('_', ' ') for s in transposed_data_list[0]] + # Find the longest header string in each column + header_lengths = [len(str(x)) for x in transposed_data_list[0]] + # Find the longest content string in each column + content_lengths = [len(str(x)) for x in transposed_data_list[1]] + # Find the max of the two for each column + col_lengths = [(x if x > y else y) for x, y in zip(header_lengths, content_lengths)] + + print(' '.join(f"{x.center(col_lengths[i])}" for i, x in enumerate(transposed_data_list[0]))) + print(' '.join('=' * length for length in col_lengths)) + print(' '.join(f"{x.center(col_lengths[i])}" for i, x in enumerate(transposed_data_list[1]))) + + return 0 def deploy_host_req(args): @@ -1161,8 +1192,8 @@ def register_deploy_commands(commands): - activate - complete non root/sudo users can run: - - list - query-hosts + - show Deploy commands are region_restricted, which means that they are not permitted to be run in DC """ @@ -1249,13 +1280,13 @@ def register_deploy_commands(commands): cmd.add_argument('deployment', help='Deployment ID to complete') - # --- software deploy list --------------------------- + # --- software deploy show --------------------------- cmd = sub_cmds.add_parser( - 'list', - help='List the software deployments and their states' + 'show', + help='Show the software deployments states' ) - cmd.set_defaults(cmd='list') - cmd.set_defaults(func=deploy_list_req) + cmd.set_defaults(cmd='show') + cmd.set_defaults(func=deploy_show_req) cmd.set_defaults(restricted=False) # can run non root # --deployment is an optional argument cmd.add_argument('--deployment', diff --git a/software/software/api/controllers/root.py b/software/software/api/controllers/root.py index cfb6e6de..921b8ee2 100644 --- a/software/software/api/controllers/root.py +++ b/software/software/api/controllers/root.py @@ -127,6 +127,16 @@ class SoftwareAPIController(object): return result + @expose('json') + @expose('query.xml', content_type='application/xml') + def deploy_show(self): + try: + result = sc.software_deploy_show_api() + except SoftwareError as e: + return dict(error="Error: %s" % str(e)) + + return result + @expose('json') @expose('query.xml', content_type='application/xml') def install_local(self): diff --git a/software/software/constants.py b/software/software/constants.py index adc0cc2c..391d0dc2 100644 --- a/software/software/constants.py +++ b/software/software/constants.py @@ -4,7 +4,7 @@ Copyright (c) 2023 Wind River Systems, Inc. SPDX-License-Identifier: Apache-2.0 """ - +from enum import Enum import os try: # The tsconfig module is only available at runtime @@ -114,3 +114,21 @@ SOFTWARE_JSON_FILE = "/opt/software/software.json" WORKER_SUMMARY_DIR = "%s/summary" % SOFTWARE_STORAGE_DIR WORKER_DATETIME_FORMAT = "%Y%m%dT%H%M%S%f" +UNKNOWN_SOFTWARE_VERSION = "0.0.0" + + +class DEPLOY_STATES(Enum): + ACTIVATING = 'activating' + ACTIVATED = 'activated' + ACTIVATION_FAILED = 'activation-failed' + DATA_MIGRATION_FAILED = 'data-migration-failed' + DATA_MIGRATION = 'data-migration' + DEPLOYING = 'deploying' + DEPLOYED = 'deployed' + PRESTAGING = 'prestaging' + PRESTAGED = 'prestaged' + PRESTAGING_FAILED = 'prestaging-failed' + UPGRADE_CONTROLLERS = 'upgrade-controllers' + UPGRADE_CONTROLLER_FAILED = 'upgrade-controller-failed' + UPGRADE_HOSTS = 'upgrade-hosts' + UNKNOWN = 'unknown' diff --git a/software/software/db/api.py b/software/software/db/api.py index 668cce43..cc1152c8 100644 --- a/software/software/db/api.py +++ b/software/software/db/api.py @@ -1,10 +1,13 @@ from software.software_entities import DeployHandler from software.software_entities import DeployHostHandler +from software.constants import DEPLOY_STATES + def get_instance(): """Return a Software API instance.""" return SoftwareAPI() + class SoftwareAPI: _instance = None @@ -17,20 +20,17 @@ class SoftwareAPI: self.deploy_handler = DeployHandler() self.deploy_host_handler = DeployHostHandler() - def create_deploy(self, from_release, to_release, reboot_required): + def create_deploy(self, from_release, to_release, reboot_required: bool): self.deploy_handler.create(from_release, to_release, reboot_required) - def get_deploy(self, from_release, to_release): - return self.deploy_handler.query(from_release, to_release) + def get_deploy(self): + return self.deploy_handler.query() - def get_deploy_all(self): - return self.deploy_handler.query_all() + def update_deploy(self, state: DEPLOY_STATES): + self.deploy_handler.update(state) - def update_deploy(self, from_release, to_release, reboot_required, state): - self.deploy_handler.update(from_release, to_release, reboot_required, state) - - def delete_deploy(self, from_release, to_release): - self.deploy_handler.delete(from_release, to_release) + def delete_deploy(self): + self.deploy_handler.delete() def create_deploy_host(self, hostname, software_release, target_release): self.deploy_host_handler.create(hostname, software_release, target_release) diff --git a/software/software/software_controller.py b/software/software/software_controller.py index 1e46c77f..2328316e 100644 --- a/software/software/software_controller.py +++ b/software/software/software_controller.py @@ -59,6 +59,8 @@ from software.release_verify import verify_files import software.config as cfg import software.utils as utils +from software.db.api import get_instance + import software.messages as messages import software.constants as constants @@ -603,6 +605,8 @@ class PatchController(PatchService): self.hosts = {} self.controller_neighbours = {} + self.db_api_instance = get_instance() + # interim_state is used to track hosts that have not responded # with fresh queries since a patch was applied or removed, on # a per-patch basis. This allows the patch controller to move @@ -2258,6 +2262,10 @@ class PatchController(PatchService): return dict(info=msg_info, warning=msg_warning, error=msg_error) + def software_deploy_show_api(self): + # Retrieve deploy state from db + return self.db_api_instance.get_deploy() + def software_deploy_host_api(self, host_ip, force, async_req=False): msg_info = "" msg_warning = "" diff --git a/software/software/software_entities.py b/software/software/software_entities.py index 1dd24c0d..15319261 100644 --- a/software/software/software_entities.py +++ b/software/software/software_entities.py @@ -11,8 +11,15 @@ from enum import Enum from typing import List from software import constants -from software.exceptions import DeployDoNotExist, DeployAlreadyExist -from software.utils import check_instances, check_state, save_to_json_file, get_software_filesystem_data +from software.exceptions import DeployDoNotExist +from software.exceptions import DeployAlreadyExist +from software.utils import check_instances +from software.utils import check_state +from software.utils import save_to_json_file +from software.utils import get_software_filesystem_data +from software.utils import validate_versions + +from software.constants import DEPLOY_STATES LOG = logging.getLogger('main_logger') @@ -122,17 +129,11 @@ class CompatibleRelease(ABC): class Deploy(ABC): - def __init__(self): - self.states = Enum('States', 'activate-failed activated ' - 'data-migration-failed data-migration ' - 'activating prestaged prestaging ' - 'prestaging-failed ' - 'upgrade-controller-failed upgrade-controllers ' - 'upgrade-hosts') + pass @abstractmethod - def create(self, from_release: str, to_release: str, reboot_required:bool, state: str): + def create(self, from_release: str, to_release: str, reboot_required: bool, state: DEPLOY_STATES): """ Create a new deployment entry. @@ -142,51 +143,32 @@ class Deploy(ABC): :param state: The state of the deployment. """ - instances = [from_release, to_release] - if state: - check_state(state, self.states) - instances.append(state) + validate_versions([from_release, to_release]) check_instances([reboot_required], bool) - check_instances(instances, str) - pass + check_instances([state], DEPLOY_STATES) @abstractmethod - def query(self, from_release: str, to_release: str): + def query(self): """ Get deployments based on source and target release versions. - - :param from_release: The source release version. - :param to_release: The target release version. - """ - check_instances([from_release, to_release], str) pass @abstractmethod - def update(self, from_release: str, to_release: str, reboot_required:bool, state: str): + def update(self, new_state: DEPLOY_STATES): """ Update a deployment entry. - :param from_release: The source release version. - :param to_release: The target release version. - :param reboot_required: If is required to do host reboot. :param state: The state of the deployment. """ - check_instances([from_release, to_release, state], str) - check_instances([reboot_required], bool) - check_state(state, self.states) - pass + check_instances([new_state], DEPLOY_STATES) @abstractmethod - def delete(self, from_release: str, to_release: str): + def delete(self): """ Delete a deployment entry based on source and target release versions. - - :param from_release: The source release version. - :param to_release: The target release version. """ - check_instances([from_release, to_release], str) pass @@ -273,67 +255,74 @@ class DeployHosts(ABC): check_instances([hostname, software_release, target_release], str) pass + class DeployHandler(Deploy): def __init__(self): super().__init__() self.data = get_software_filesystem_data() - def create(self, from_release, to_release, reboot_required, state=None): + def create(self, from_release, to_release, reboot_required, state=DEPLOY_STATES.DEPLOYING): + """ + Create a new deploy with given from and to release version + :param from_release: The source release version. + :param to_release: The target release version. + :param reboot_required: If is required to do host reboot. + :param state: The state of the deployment. + """ super().create(from_release, to_release, reboot_required, state) - deploy = self.query(from_release, to_release) + deploy = self.query() if deploy: - raise DeployAlreadyExist("Error to create. Deploy already exist.") + raise DeployAlreadyExist("Error to create. Deploy already exists.") new_deploy = { "from_release": from_release, "to_release": to_release, "reboot_required": reboot_required, - "state": state + "state": state.value } - deploy_data = self.data.get("deploy", []) - if not deploy_data: - deploy_data = { - "deploy": [] - } - deploy_data["deploy"].append(new_deploy) - self.data.update(deploy_data) - else: - deploy_data.append(new_deploy) - save_to_json_file(constants.SOFTWARE_JSON_FILE, self.data) - def query(self, from_release, to_release): - super().query(from_release, to_release) - for deploy in self.data.get("deploy", []): - if deploy.get("from_release") == from_release and deploy.get("to_release") == to_release: - return deploy - return None + try: + self.data["deploy"] = new_deploy + save_to_json_file(constants.SOFTWARE_JSON_FILE, self.data) + except Exception: + self.data["deploy"] = {} - def query_all(self): - return self.data.get("deploy", []) + def query(self): + """ + Query deploy based on from and to release version + :return: A deploy dictionary + """ + super().query() + return self.data.get("deploy", {}) - def update(self, from_release, to_release, reboot_required, state): - super().update(from_release, to_release, reboot_required, state) - deploy = self.query(from_release, to_release) + def update(self, new_state: DEPLOY_STATES): + """ + Update deploy state based on from and to release version + :param new_state: The new state + """ + super().update(new_state) + deploy = self.query() if not deploy: - raise DeployDoNotExist("Error to update. Deploy do not exist.") - deploy_data = { - "deploy": [] - } - deploy_data["deploy"].append({ - "from_release": from_release, - "to_release": to_release, - "reboot_required": reboot_required, - "state": state - }) - self.data.update(deploy_data) - save_to_json_file(constants.SOFTWARE_JSON_FILE, self.data) + raise DeployDoNotExist("Error to update deploy state. No deploy in progress.") - def delete(self, from_release, to_release): - super().delete(from_release, to_release) - deploy = self.query(from_release, to_release) + try: + self.data["deploy"]["state"] = new_state.value + save_to_json_file(constants.SOFTWARE_JSON_FILE, self.data) + except Exception: + self.data["deploy"] = deploy + + def delete(self): + """ + Delete a deploy based on given from and to release version + """ + super().delete() + deploy = self.query() if not deploy: - raise DeployDoNotExist("Error to delete. Deploy do not exist.") - self.data.get("deploy").remove(deploy) - save_to_json_file(constants.SOFTWARE_JSON_FILE, self.data) + raise DeployDoNotExist("Error to delete deploy state. No deploy in progress.") + try: + self.data["deploy"] = {} + save_to_json_file(constants.SOFTWARE_JSON_FILE, self.data) + except Exception: + self.data["deploy"] = deploy class DeployHostHandler(DeployHosts): @@ -349,11 +338,11 @@ class DeployHostHandler(DeployHosts): raise DeployAlreadyExist("Error to create. Deploy host already exist.") new_deploy_host = { - "hostname": hostname, - "software_release": software_release, - "target_release": target_release, - "state": state - } + "hostname": hostname, + "software_release": software_release, + "target_release": target_release, + "state": state + } deploy_data = self.data.get("deploy_host", []) if not deploy_data: diff --git a/software/software/utils.py b/software/software/utils.py index 0523c8eb..7c699e86 100644 --- a/software/software/utils.py +++ b/software/software/utils.py @@ -6,6 +6,7 @@ SPDX-License-Identifier: Apache-2.0 """ import json import logging +import re import shutil from netaddr import IPAddress import os @@ -260,6 +261,7 @@ def save_to_json_file(file, data): json.dump(data, f) except Exception as e: LOG.error("Problem saving file %s: %s", file, e) + raise def load_from_json_file(file): @@ -297,9 +299,22 @@ def check_instances(params: list, instance): LOG.exception(msg) raise ValueError(msg) + def get_software_filesystem_data(): if os.path.exists(constants.SOFTWARE_JSON_FILE): - data = load_from_json_file(constants.SOFTWARE_JSON_FILE) + return load_from_json_file(constants.SOFTWARE_JSON_FILE) else: - data = {} - return data + return {} + + +def validate_versions(versions): + """ + Validate a list of versions + :param versions: list of versions + :raise: ValueError if version is invalid + """ + for version in versions: + if not re.match(r'[0-9]+\.[0-9]+(\.[0-9]+)?$', version): + msg = "Invalid version: %s" % version + LOG.exception(msg) + raise ValueError(msg)