diff --git a/software-client/software_client/__init__.py b/software-client/software_client/__init__.py index 3bf7dae5..8b557f9e 100644 --- a/software-client/software_client/__init__.py +++ b/software-client/software_client/__init__.py @@ -1,2 +1,14 @@ -# The version of this component -VERSION = 1.0 +# +# Copyright (c) 2013-2024 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# + +try: + import software_client.client + Client = software_client.client.Client +except ImportError: + import warnings + warnings.warn("Could not import software_client.client", ImportWarning) + +__version__ = "1.0.0" diff --git a/software-client/software_client/client.py b/software-client/software_client/client.py new file mode 100644 index 00000000..20c8f668 --- /dev/null +++ b/software-client/software_client/client.py @@ -0,0 +1,174 @@ +# +# Copyright (c) 2013-2024 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# + +from keystoneauth1 import loading +from oslo_utils import importutils + +from software_client import exc +from software_client.constants import TOKEN, KEYSTONE, LOCAL_ROOT + + +SERVICE_NAME = 'usm' +SERVICE_TYPE = 'usm' +API_PORT = "5493" +API_ENDPOINT = "http://127.0.0.1:" + API_PORT + + +def _make_session(**kwargs): + """Construct a session based on authentication information + + :param kwargs: keyword args containing credentials, either: + * os_auth_token: pre-existing token to re-use + * system_url: system API endpoint + or: + * os_username: name of user + * os_password: user's password + * os_auth_url: endpoint to authenticate against + * insecure: allow insecure SSL (no cert verification) + * os_tenant_{name|id}: name or ID of tenant + * os_region_name: region of the service + * os_project_name: name of a project + * os_project_id: ID of a project + * os_user_domain_name: name of a domain the user belongs to + * os_user_domain_id: ID of a domain the user belongs to + * os_project_domain_name: name of a domain the project belongs to + * os_project_domain_id: ID of a domain the project belongs to + * timeout: request timeout (in seconds) + * ca_file: trusted CA file + * cert_file: client certificate file + * key_file: client key file + """ + session = None + if (kwargs.get('os_username') and + kwargs.get('os_password') and + kwargs.get('os_auth_url') and + (kwargs.get('os_project_id') or + kwargs.get('os_project_name'))): + auth_kwargs = {} + auth_url = kwargs.get('os_auth_url') + project_id = kwargs.get('os_project_id') + project_name = kwargs.get('os_project_name') + user_domain_id = kwargs.get('os_user_domain_id') + user_domain_name = kwargs.get('os_user_domain_name') or "Default" + project_domain_id = kwargs.get('os_project_domain_id') + project_domain_name = kwargs.get('os_project_domain_name') or "Default" + + auth_type = 'password' + username = kwargs.get('os_username') + password = kwargs.get('os_password') + auth_kwargs.update({ + 'auth_url': auth_url, + 'project_id': project_id, + 'project_name': project_name, + 'user_domain_id': user_domain_id, + 'user_domain_name': user_domain_name, + 'project_domain_id': project_domain_id, + 'project_domain_name': project_domain_name, + 'username': username, + 'password': password + }) + + # construct the appropriate session + timeout = kwargs.get('timeout') + insecure = kwargs.get('insecure') + cacert = kwargs.get('ca_file') + cert = kwargs.get('cert_file') + key = kwargs.get('key_file') + + loader = loading.get_plugin_loader(auth_type) + auth_plugin = loader.load_from_options(**auth_kwargs) + session = loading.session.Session().load_from_options(auth=auth_plugin, + timeout=timeout, + insecure=insecure, + cacert=cacert, + cert=cert, + key=key) + # session could still be None + return session + + +def get_client(api_version, auth_mode, session=None, service_type=SERVICE_TYPE, **kwargs): + """Get an authenticated client, based on credentials in the keyword args. + + :param api_version: the API version to use ('1' or '2') + :param auth_mode: the authentication mode (token, keystone, local_root) + :param session: the session to use (if it exists) + :param service_type: service_type should always be 'usm' + :param kwargs: additional keyword args to pass to the client or auth + """ + endpoint = kwargs.get('software_url') + + auth_token = kwargs.get('os_auth_token') + local_root = auth_mode == LOCAL_ROOT + # if we have an endpoint and token, use those + if local_root or (endpoint and auth_token): + pass + elif not session: + # Make a session to determine the endpoint + session = _make_session(**kwargs) + + if not endpoint: + exception_msg = ('Either provide Keystone credentials or ' + 'user-defined endpoint and token or ' + 'execute software command as root (sudo)') + if session: + try: + interface = kwargs.get('os_endpoint_type') + region_name = kwargs.get('os_region_name') + endpoint = session.get_endpoint(service_type=service_type, + interface=interface, + region_name=region_name) + except Exception as e: + raise exc.EndpointException( + ('%(message)s, error was: %(error)s') % + {'message': exception_msg, 'error': e}) + elif local_root: + endpoint = API_ENDPOINT + else: + raise exc.AmbigiousAuthSystem(exception_msg) + + if endpoint: + api_version_str = 'v' + api_version + if api_version_str not in endpoint.split('/'): + endpoint = endpoint + '/' + api_version_str + + if session: + # this will be a LegacyJsonAdapter + cli_kwargs = { + 'session': session, + 'service_type': service_type, + 'service_name': SERVICE_NAME, + 'interface': kwargs.get('os_endpoint_type'), + 'region_name': kwargs.get('os_region_name'), + 'endpoint_override': endpoint, + 'global_request_id': kwargs.get('global_request_id'), + 'user_agent': kwargs.get('user_agent', 'software_client'), + 'api_version': kwargs.get('system_api_version') + } + else: + # This will become a httplib2 object + auth_ref = None + cli_kwargs = { + 'local_root': local_root, + 'token': auth_token, + 'insecure': kwargs.get('insecure'), + 'cacert': kwargs.get('cacert'), + 'timeout': kwargs.get('timeout'), + 'ca_file': kwargs.get('ca_file'), + 'cert_file': kwargs.get('cert_file'), + 'key_file': kwargs.get('key_file'), + 'auth_ref': auth_ref, + 'auth_url': kwargs.get('os_auth_url'), + 'api_version': kwargs.get('system_api_version') + } + return Client(api_version, endpoint, session, **cli_kwargs) + + +def Client(version, *args, **kwargs): + module = importutils.import_versioned_module('software_client', + version, 'client') + client_class = getattr(module, 'Client') + return client_class(*args, **kwargs) diff --git a/software-client/software_client/common/__init__.py b/software-client/software_client/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/software-client/software_client/common/base.py b/software-client/software_client/common/base.py new file mode 100644 index 00000000..f144550b --- /dev/null +++ b/software-client/software_client/common/base.py @@ -0,0 +1,130 @@ +# Copyright 2013-2024 Wind River, Inc. +# Copyright 2012 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Base utilities to build API operation managers and objects on top of. +""" + +import copy + + +class Manager(object): + """Managers interact with a particular type of API and provide CRUD + operations for them. + """ + resource_class = None + + def __init__(self, api): + self.api = api + + def _create(self, url, **kwargs): + return self.api.json_request('POST', url, **kwargs) + + def _create_multipart(self, url, **kwargs): + return self.api.multipart_request('POST', url, **kwargs) + + def _list(self, url, response_key=None, obj_class=None, body=None): + resp, body = self.api.json_request('GET', url) + if response_key: + try: + data = body[response_key] + except KeyError: + return [] + else: + data = body + + return resp, data + + +class Resource(object): + """A resource represents a particular instance of an object (tenant, user, + etc). This is pretty much just a bag for attributes. + + :param manager: Manager object + :param info: dictionary representing resource attributes + :param loaded: prevent lazy-loading if set to True + """ + def __init__(self, manager, info, loaded=False): + self.manager = manager + self._info = info + self._add_details(info) + self._loaded = loaded + + def _add_details(self, info): + for (k, v) in info.items(): + setattr(self, k, v) + + def __getattr__(self, k): + if k not in self.__dict__: + # NOTE(bcwaldon): disallow lazy-loading if already loaded once + if not self.is_loaded(): + self.get() + return self.__getattr__(k) + + raise AttributeError(k) + else: + return self.__dict__[k] + + # deepcopy is invoked on this object which causes infinite recursion in python3 + # unless the copy and deepcopy methods are overridden + def __copy__(self): + cls = self.__class__ + result = cls.__new__(cls) + result.__dict__.update(self.__dict__) + return result + + def __deepcopy__(self, memo): + cls = self.__class__ + result = cls.__new__(cls) + memo[id(self)] = result + for k, v in self.__dict__.items(): + setattr(result, k, copy.deepcopy(v, memo)) + return result + + def __repr__(self): + reprkeys = sorted(k for k in list(self.__dict__.keys()) if k[0] != '_' and + k != 'manager') + info = ", ".join("%s=%s" % (k, getattr(self, k)) for k in reprkeys) + return "<%s %s>" % (self.__class__.__name__, info) + + def get(self): + # set_loaded() first ... so if we have to bail, we know we tried. + self.set_loaded(True) + if not hasattr(self.manager, 'get'): + return + + new = self.manager.get(self.id) + if new: + self._add_details(new._info) + + def __eq__(self, other): + if not isinstance(other, self.__class__): + return False + if hasattr(self, 'id') and hasattr(other, 'id'): + return self.id == other.id + return self._info == other._info + + def __hash__(self): + return hash((self.manager, self._info, self._loaded)) + + def is_loaded(self): + return self._loaded + + def set_loaded(self, val): + self._loaded = val + + def to_dict(self): + return copy.deepcopy(self._info) diff --git a/software-client/software_client/common/http.py b/software-client/software_client/common/http.py new file mode 100644 index 00000000..a96d0ad5 --- /dev/null +++ b/software-client/software_client/common/http.py @@ -0,0 +1,696 @@ +# Copyright 2013-2024 Wind River, Inc. +# Copyright 2012 Openstack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +import copy +import hashlib +import httplib2 +from keystoneauth1 import adapter +import logging +import os +from oslo_serialization import jsonutils +from oslo_utils import encodeutils +import requests +from requests_toolbelt import MultipartEncoder +import socket + +import six +from six.moves.urllib.parse import urlparse + + +try: + import ssl +except ImportError: + # TODO(bcwaldon): Handle this failure more gracefully + pass + +try: + import json +except ImportError: + import simplejson as json + +from software_client import exc as exceptions + +_logger = logging.getLogger(__name__) + +CHUNKSIZE = 1024 * 64 # 64kB +SENSITIVE_HEADERS = ('X-Auth-Token',) +UPLOAD_REQUEST_TIMEOUT = 1800 +USER_AGENT = 'software_client' +API_VERSION = '/v1' +DEFAULT_API_VERSION = 'latest' + +# httplib2 retries requests on socket.timeout which +# is not idempotent and can lead to orhan objects. +# See: https://code.google.com/p/httplib2/issues/detail?id=124 +httplib2.RETRIES = 1 + +if os.environ.get('SOFTWARE_CLIENT_DEBUG'): + ch = logging.StreamHandler() + _logger.setLevel(logging.DEBUG) + _logger.addHandler(ch) + + +class ServiceCatalog(object): + """Helper methods for dealing with a Keystone Service Catalog.""" + + def __init__(self, resource_dict): + self.catalog = resource_dict + + def get_token(self): + """Fetch token details fron service catalog.""" + token = {'id': self.catalog['access']['token']['id'], + 'expires': self.catalog['access']['token']['expires'], } + try: + token['user_id'] = self.catalog['access']['user']['id'] + token['tenant_id'] = ( + self.catalog['access']['token']['tenant']['id']) + except Exception: + # just leave the tenant and user out if it doesn't exist + pass + return token + + def url_for(self, attr=None, filter_value=None, + service_type='usm', endpoint_type='publicURL'): + """Fetch the URL from the Neutron service for + a particular endpoint type. If none given, return + publicURL. + """ + catalog = self.catalog['access'].get('serviceCatalog', []) + matching_endpoints = [] + for service in catalog: + if service['type'] != service_type: + continue + + endpoints = service['endpoints'] + for endpoint in endpoints: + if not filter_value or endpoint.get(attr) == filter_value: + matching_endpoints.append(endpoint) + + if not matching_endpoints: + raise exceptions.EndpointNotFound() + elif len(matching_endpoints) > 1: + raise exceptions.AmbiguousEndpoints(reason=matching_endpoints) + else: + if endpoint_type not in matching_endpoints[0]: + raise exceptions.EndpointTypeNotFound(reason=endpoint_type) + + return matching_endpoints[0][endpoint_type] + + +class SessionClient(adapter.LegacyJsonAdapter): + + def __init__(self, *args, **kwargs): + self.user_agent = USER_AGENT + self.api_version = 'v' + kwargs.pop('api_version') + super(SessionClient, self).__init__(*args, **kwargs) + + def _http_request(self, url, method, **kwargs): + version_str = '/' + self.api_version + if url.startswith(version_str): + url = url[len(version_str):] + + kwargs.setdefault('user_agent', self.user_agent) + kwargs.setdefault('auth', self.auth) + kwargs.setdefault('endpoint_override', self.endpoint_override) + + # Copy the kwargs so we can reuse the original in case of redirects + kwargs['headers'] = copy.deepcopy(kwargs.get('headers', {})) + kwargs['headers'].setdefault('User-Agent', self.user_agent) + + endpoint_filter = kwargs.setdefault('endpoint_filter', {}) + endpoint_filter.setdefault('interface', self.interface) + endpoint_filter.setdefault('service_type', self.service_type) + endpoint_filter.setdefault('region_name', self.region_name) + + return self.session.request(url, method, + raise_exc=False, **kwargs) + + def json_request(self, method, url, **kwargs): + kwargs.setdefault('headers', {}) + kwargs['headers'].setdefault('Content-Type', 'application/json') + kwargs['headers'].setdefault('Accept', 'application/json') + if 'body' in kwargs: + kwargs['data'] = jsonutils.dumps(kwargs.pop('body')) + + resp = self._http_request(url, method, **kwargs) + body = resp.content + content_type = resp.headers.get('content-type', None) + status = resp.status_code + if status == 204 or status == 205 or content_type is None: + return resp, list() + if 'application/json' in content_type: + try: + body = resp.json() + except ValueError: + _logger.error('Could not decode response body as JSON') + else: + body = None + return resp, body + + def multipart_request(self, method, url, **kwargs): + kwargs.setdefault('headers', {}) + kwargs['headers'].setdefault('Content-Type', 'application/json') + kwargs['headers'].setdefault('Accept', 'application/json') + if 'body' in kwargs: + kwargs['data'] = kwargs.pop('body') + + resp = self._http_request(url, method, **kwargs) + body = resp.content + content_type = resp.headers.get('content-type', None) + status = resp.status_code + if status == 204 or status == 205 or content_type is None: + return resp, list() + if 'application/json' in content_type: + try: + body = resp.json() + except ValueError: + _logger.error('Could not decode response body as JSON') + else: + body = None + return resp, body + + + + def raw_request(self, method, url, **kwargs): + kwargs.setdefault('headers', {}) + kwargs['headers'].setdefault('Content-Type', + 'application/octet-stream') + return self._http_request(url, method, **kwargs) + + def _get_connection_url(self, url): + endpoint = self.endpoint_override + version = self.api_version + # if 'v1 in both, remove 'v1' from endpoint + if version in endpoint and version in url: + endpoint = endpoint.replace('/' + version, '', 1) + # if 'v1 not in both, add 'v1' to endpoint + elif version not in endpoint and version not in url: + endpoint = endpoint.rstrip('/') + '/' + version + + return endpoint.rstrip('/') + '/' + url.lstrip('/') + + def upload_request_with_data(self, method, url, **kwargs): + requests_url = self._get_connection_url(url) + headers = {"X-Auth-Token": self.session.get_token()} + files = {'file': ("for_upload", + kwargs['body'], + )} + data = kwargs.get('data') + req = requests.post(requests_url, headers=headers, files=files, + data=data) + return req.json() + + def upload_request_with_multipart(self, method, url, **kwargs): + requests_url = self._get_connection_url(url) + fields = kwargs.get('data') + + enc = MultipartEncoder(fields) + headers = {'Content-Type': enc.content_type, + "X-Auth-Token": self.session.get_token()} + response = requests.post(requests_url, data=enc, headers=headers) + + return response.json() + + +class HTTPClient(httplib2.Http): + """Handles the REST calls and responses, include authn.""" + + ################# + # INIT + ################# + def __init__(self, endpoint, + username=None, tenant_name=None, tenant_id=None, + password=None, auth_url=None, + token=None, region_name=None, timeout=7200, + endpoint_url=None, insecure=False, + endpoint_type='publicURL', + ca_cert=None, log_credentials=False, + **kwargs): + if 'ca_file' in kwargs and kwargs['ca_file']: + ca_cert = kwargs['ca_file'] + + super(HTTPClient, self).__init__(timeout=timeout, ca_certs=ca_cert) + + self.username = username + self.tenant_name = tenant_name + self.tenant_id = tenant_id + self.password = password + self.auth_url = auth_url.rstrip('/') if auth_url else None + self.endpoint_type = endpoint_type + self.region_name = region_name + self.auth_token = token + self.auth_tenant_id = None + self.auth_user_id = None + self.content_type = 'application/json' + self.endpoint_url = endpoint + self.log_credentials = log_credentials + self.connection_params = self.get_connection_params(self.endpoint_url, **kwargs) + self.local_root = kwargs.get('local_root', False) + self.api_version = 'v' + kwargs.pop('api_version') + + # httplib2 overrides + self.disable_ssl_certificate_validation = insecure + + self.service_catalog = None + + ################# + # REQUEST + ################# + + @staticmethod + def http_log_resp(_logger, resp, body=None): + if not _logger.isEnabledFor(logging.DEBUG): + return + + resp_status_code = resp.get('status_code') or "" + resp_headers = resp.get('headers') or "" + _logger.debug("RESP:%(code)s %(headers)s %(body)s\n", + {'code': resp_status_code, + 'headers': resp_headers, + 'body': body}) + + @staticmethod + def http_log_req(_logger, args, kwargs): + if not _logger.isEnabledFor(logging.DEBUG): + return + + string_parts = ['curl -i'] + for element in args: + if element in ('GET', 'POST', 'DELETE', 'PUT'): + string_parts.append(' -X %s' % element) + else: + string_parts.append(' %s' % element) + + for (key, value) in kwargs['headers'].items(): + if key in SENSITIVE_HEADERS: + v = value.encode('utf-8') + h = hashlib.sha256(v) + d = h.hexdigest() + value = "{SHA256}%s" % d + header = ' -H "%s: %s"' % (key, value) + string_parts.append(header) + + if 'body' in kwargs and kwargs['body']: + string_parts.append(" -d '%s'" % (kwargs['body'])) + req = encodeutils.safe_encode("".join(string_parts)) + _logger.debug("REQ: %s", req) + + def _cs_request(self, *args, **kwargs): + kargs = {} + kargs.setdefault('headers', kwargs.get('headers', {})) + + if 'content_type' in kwargs: + kargs['headers']['Content-Type'] = kwargs['content_type'] + kargs['headers']['Accept'] = kwargs['content_type'] + else: + kargs['headers']['Content-Type'] = self.content_type + kargs['headers']['Accept'] = self.content_type + + if self.auth_token: + kargs['headers']['X-Auth-Token'] = self.auth_token + + if 'body' in kwargs: + kargs['body'] = kwargs['body'] + if self.log_credentials: + log_kargs = kargs + else: + log_kargs = self._strip_credentials(kargs) + + self.http_log_req(_logger, args, log_kargs) + try: + resp, body = self.request(*args, **kargs) + except requests.exceptions.SSLError as e: + raise exceptions.SslCertificateValidationError(reason=str(e)) + except Exception as e: + # Wrap the low-level connection error (socket timeout, redirect + # limit, decompression error, etc) into our custom high-level + # connection exception (it is excepted in the upper layers of code) + _logger.debug("throwing ConnectionFailed : %s", e) + raise exceptions.CommunicationError(str(e)) + finally: + # Temporary Fix for gate failures. RPC calls and HTTP requests + # seem to be stepping on each other resulting in bogus fd's being + # picked up for making http requests + self.connections.clear() + + # Read body into string if it isn't obviously image data + body_str = None + if 'content-type' in resp and resp['content-type'] != 'application/octet-stream': + body_str = ''.join([chunk for chunk in body.decode('utf8')]) + self.http_log_resp(_logger, resp, body_str) + body = body_str + else: + self.http_log_resp(_logger, resp, body) + + return resp, body + + def json_request(self, method, url, **kwargs): + if not self.local_root: + self.authenticate_and_fetch_endpoint_url() + # Perform the request once. If we get a 401 back then it + # might be because the auth token expired, so try to + # re-authenticate and try again. If it still fails, bail. + kwargs.setdefault('headers', {}) + kwargs['headers'].setdefault('Content-Type', 'application/json') + kwargs['headers'].setdefault('Accept', 'application/json') + + if 'body' in kwargs: + kwargs['body'] = json.dumps(kwargs['body']) + + connection_url = self._get_connection_url(url) + try: + resp, body_iter = self._cs_request(connection_url, + method, **kwargs) + except exceptions.HTTPUnauthorized: + self.authenticate() + resp, body_iter = self._cs_request( + connection_url, method, **kwargs) + + content_type = resp['content-type'] \ + if resp.get('content-type', None) else None + + if resp.status == 204 or resp.status == 205 or content_type is None: + return resp, list() + + if 'application/json' in content_type: + body = ''.join([chunk for chunk in body_iter]) + try: + body = json.loads(body) + except ValueError: + _logger.error('Could not decode response body as JSON') + else: + body = None + + # Add status_code attribute to make compatible with session resp + setattr(resp, 'status_code', resp.status) + return resp, body + + def multipart_request(self, method, url, **kwargs): + return self.upload_request_with_multipart(method, url, **kwargs) + + + def raw_request(self, method, url, **kwargs): + if not self.local_root: + self.authenticate_and_fetch_endpoint_url() + kwargs.setdefault('headers', {}) + kwargs['headers'].setdefault('Content-Type', + 'application/octet-stream') + connection_url = self._get_connection_url(url) + return self._cs_request(connection_url, method, **kwargs) + + def upload_request_with_data(self, method, url, **kwargs): + if not self.local_root: + self.authenticate_and_fetch_endpoint_url() + connection_url = self._get_connection_url(url) + headers = {"X-Auth-Token": self.auth_token} + files = {'file': ("for_upload", + kwargs['body'], + )} + data = kwargs.get('data') + req = requests.post(connection_url, headers=headers, + files=files, data=data, + timeout=UPLOAD_REQUEST_TIMEOUT) + return req.json() + + def upload_request_with_multipart(self, method, url, **kwargs): + if not self.local_root: + self.authenticate_and_fetch_endpoint_url() + connection_url = self._get_connection_url(url) + + response = requests.post(connection_url, + data=kwargs.get('body'), + headers=kwargs.get('headers'), + timeout=UPLOAD_REQUEST_TIMEOUT) + + return response, response.json() + + ################# + # AUTHENTICATE + ################# + + def authenticate_and_fetch_endpoint_url(self): + if not self.auth_token: + self.authenticate() + if not self.endpoint_url: + self._get_endpoint_url() + + def authenticate(self): + if self.auth_url is None: + raise exceptions.HTTPUnauthorized("No auth_url provided") + + token_url = self.auth_url + "/tokens" + + if self.tenant_id: + body = {'auth': {'passwordCredentials': + {'username': self.username, + 'password': self.password, }, + 'tenantId': self.tenant_id, }, } + else: + body = {'auth': {'passwordCredentials': + {'username': self.username, + 'password': self.password, }, + 'tenantName': self.tenant_name, }, } + + resp, resp_body = self._cs_request(token_url, "POST", + body=json.dumps(body), + content_type="application/json") + status_code = self.get_status_code(resp) + if status_code != 200: + raise exceptions.HTTPUnauthorized(resp_body) + if resp_body: + try: + resp_body = json.loads(resp_body) + except ValueError: + pass + else: + resp_body = None + self._extract_service_catalog(resp_body) + + _logger.debug("Authenticated user %s", self.username) + + def get_auth_info(self): + return {'auth_token': self.auth_token, + 'auth_tenant_id': self.auth_tenant_id, + 'auth_user_id': self.auth_user_id, + 'endpoint_url': self.endpoint_url} + + ################# + # UTILS + ################# + def _strip_credentials(self, kwargs): + if kwargs.get('body') and self.password: + log_kwargs = kwargs.copy() + log_kwargs['body'] = kwargs['body'].replace(self.password, + 'REDACTED') + return log_kwargs + else: + return kwargs + + def _extract_service_catalog(self, body): + """Set the client's service catalog from the response data.""" + self.service_catalog = ServiceCatalog(body) + try: + sc = self.service_catalog.get_token() + self.auth_token = sc['id'] + self.auth_tenant_id = sc.get('tenant_id') + self.auth_user_id = sc.get('user_id') + except KeyError: + raise exceptions.HTTPUnauthorized() + if not self.endpoint_url: + self.endpoint_url = self.service_catalog.url_for( + attr='region', filter_value=self.region_name, + endpoint_type=self.endpoint_type) + + def _get_endpoint_url(self): + url = self.auth_url + '/tokens/%s/endpoints' % self.auth_token + try: + resp, body = self._cs_request(url, "GET") + except exceptions.HTTPUnauthorized: + # rollback to authenticate() to handle case when neutron client + # is initialized just before the token is expired + self.authenticate() + return self.endpoint_url + + body = json.loads(body) + for endpoint in body.get('endpoints', []): + if (endpoint['type'] == 'usm' and endpoint.get('region') == self.region_name): + if self.endpoint_type not in endpoint: + raise exceptions.EndpointTypeNotFound( + reason=self.endpoint_type) + return endpoint[self.endpoint_type] + + raise exceptions.EndpointNotFound() + + def _get_connection_url(self, url): + (_class, _args, _kwargs) = self.connection_params + base_url = _args[2] + # Since some packages send endpoint with 'v1' and some don't, + # the postprocessing for both options will be done here + # Instead of doing a fix in each of these packages + endpoint = self.endpoint_url + version = self.api_version + # if 'v1 in both, remove 'v1' from endpoint + if version in base_url and version in url: + endpoint = endpoint.replace('/' + version, '', 1) + # if 'v1 not in both, add 'v1' to endpoint + elif version not in base_url and version not in url: + endpoint = endpoint.rstrip('/') + '/' + version + + return endpoint.rstrip('/') + '/' + url.lstrip('/') + + @staticmethod + def get_connection_params(endpoint, **kwargs): + parts = urlparse(endpoint) + + _args = (parts.hostname, parts.port, parts.path) + _kwargs = {'timeout': (float(kwargs.get('timeout')) + if kwargs.get('timeout') else 600)} + + if parts.scheme == 'https': + _class = VerifiedHTTPSConnection + _kwargs['ca_file'] = kwargs.get('ca_file', None) + _kwargs['cert_file'] = kwargs.get('cert_file', None) + _kwargs['key_file'] = kwargs.get('key_file', None) + _kwargs['insecure'] = kwargs.get('insecure', False) + elif parts.scheme == 'http': + _class = six.moves.http_client.HTTPConnection + else: + msg = 'Unsupported scheme: %s' % parts.scheme + raise exceptions.EndpointException(reason=msg) + + return (_class, _args, _kwargs) + + def get_status_code(self, response): + """Returns the integer status code from the response. + + Either a Webob.Response (used in testing) or httplib.Response + is returned. + """ + if hasattr(response, 'status_int'): + return response.status_int + else: + return response.status + + +class VerifiedHTTPSConnection(six.moves.http_client.HTTPSConnection): + """httplib-compatibile connection using client-side SSL authentication + + :see http://code.activestate.com/recipes/ + 577548-https-httplib-client-connection-with-certificate-v/ + """ + + def __init__(self, host, port, key_file=None, cert_file=None, + ca_file=None, timeout=None, insecure=False): + six.moves.http_client.HTTPSConnection.__init__(self, host, port, + key_file=key_file, + cert_file=cert_file) + self.key_file = key_file + self.cert_file = cert_file + if ca_file is not None: + self.ca_file = ca_file + else: + self.ca_file = self.get_system_ca_file() + self.timeout = timeout + self.insecure = insecure + + def connect(self): + """Connect to a host on a given (SSL) port. + If ca_file is pointing somewhere, use it to check Server Certificate. + + Redefined/copied and extended from httplib.py:1105 (Python 2.6.x). + This is needed to pass cert_reqs=ssl.CERT_REQUIRED as parameter to + ssl.wrap_socket(), which forces SSL to check server certificate against + our client certificate. + """ + sock = socket.create_connection((self.host, self.port), self.timeout) + + if self._tunnel_host: + self.sock = sock + self._tunnel() + + if self.insecure is True: + kwargs = {'cert_reqs': ssl.CERT_NONE} + else: + kwargs = {'cert_reqs': ssl.CERT_REQUIRED, 'ca_certs': self.ca_file} + + if self.cert_file: + kwargs['certfile'] = self.cert_file + if self.key_file: + kwargs['keyfile'] = self.key_file + + self.sock = ssl.wrap_socket(sock, **kwargs) + + @staticmethod + def get_system_ca_file(): + """Return path to system default CA file.""" + # Standard CA file locations for Debian/Ubuntu, RedHat/Fedora, + # Suse, FreeBSD/OpenBSD + ca_path = ['/etc/ssl/certs/ca-certificates.crt', + '/etc/pki/tls/certs/ca-bundle.crt', + '/etc/ssl/ca-bundle.pem', + '/etc/ssl/cert.pem'] + for ca in ca_path: + if os.path.exists(ca): + return ca + return None + + +class ResponseBodyIterator(object): + """A class that acts as an iterator over an HTTP response.""" + + def __init__(self, resp): + self.resp = resp + + def __iter__(self): + while True: + yield six.next() # pylint: disable=next-method-called + + def next(self): # pylint: disable=next-method-defined + chunk = self.resp.read(CHUNKSIZE) + if chunk: + return chunk + else: + raise StopIteration() + + +def construct_http_client(endpoint=None, username=None, password=None, + endpoint_type=None, auth_url=None, **kwargs): + + session = kwargs.pop('session', None) + auth = kwargs.pop('auth', None) + + if session: + # SessionClient + if 'endpoint_override' not in kwargs and endpoint: + kwargs['endpoint_override'] = endpoint + + if 'service_type' not in kwargs: + kwargs['service_type'] = 'usm' + + if 'interface' not in kwargs and endpoint_type: + kwargs['interface'] = endpoint_type + + if 'region_name' in kwargs: + kwargs['additional_headers'] = { + 'X-Region-Name': kwargs['region_name']} + + return SessionClient(session, auth=auth, **kwargs) + else: + # httplib2 + return HTTPClient(endpoint=endpoint, username=username, + password=password, endpoint_type=endpoint_type, + auth_url=auth_url, **kwargs) diff --git a/software-client/software_client/common/utils.py b/software-client/software_client/common/utils.py new file mode 100644 index 00000000..84c8625c --- /dev/null +++ b/software-client/software_client/common/utils.py @@ -0,0 +1,431 @@ +# Copyright 2013-2024 Wind River, Inc +# Copyright 2012 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from __future__ import print_function + +import argparse +import json +import os +import re +import textwrap + +from oslo_utils import importutils +from six.moves import zip + + +TERM_WIDTH = 72 + +class HelpFormatter(argparse.HelpFormatter): + def start_section(self, heading): + # Title-case the headings + heading = '%s%s' % (heading[0].upper(), heading[1:]) + super(HelpFormatter, self).start_section(heading) + + +def define_command(subparsers, command, callback, cmd_mapper, unrestricted_cmds): + '''Define a command in the subparsers collection. + + :param subparsers: subparsers collection where the command will go + :param command: command name + :param callback: function that will be used to process the command + ''' + desc = callback.__doc__ or '' + help = desc.strip().split('\n')[0] + arguments = getattr(callback, 'arguments', []) + + subparser = subparsers.add_parser(command, help=help, + description=desc, + add_help=False, + formatter_class=HelpFormatter) + subparser.add_argument('-h', '--help', action='help', + help=argparse.SUPPRESS) + + func = callback + cmd_mapper[command] = subparser + for (args, kwargs) in arguments: + subparser.add_argument(*args, **kwargs) + subparser.set_defaults(func=func) + + if command in unrestricted_cmds: + subparser.set_defaults(restricted=False) + + +def define_commands_from_module(subparsers, command_module, cmd_mapper, unrestricted_cmds=[]): + '''Find all methods beginning with 'do_' in a module, and add them + as commands into a subparsers collection. + ''' + for method_name in (a for a in dir(command_module) if a.startswith('do_')): + # Commands should be hypen-separated instead of underscores. + command = method_name[3:].replace('_', '-') + callback = getattr(command_module, method_name) + define_command(subparsers, command, callback, cmd_mapper, unrestricted_cmds) + + +# Decorator for cli-args +def arg(*args, **kwargs): + def _decorator(func): + # Because of the sematics of decorator composition if we just append + # to the options list positional options will appear to be backwards. + func.__dict__.setdefault('arguments', []).insert(0, (args, kwargs)) + return func + + return _decorator + + +def env(*vars, **kwargs): + """Search for the first defined of possibly many env vars + + Returns the first environment variable defined in vars, or + returns the default defined in kwargs. + """ + for v in vars: + value = os.environ.get(v, None) + if value: + return value + return kwargs.get('default', '') + + +def import_versioned_module(version, submodule=None): + module = 'software_client.v%s' % version + if submodule: + module = '.'.join((module, submodule)) + return importutils.import_module(module) + + +def check_rc(req, data): + rc = 0 + if req.status_code == 200 and data: + if 'error' in data and data["error"] != "": + rc = 1 + else: + rc = 1 + + return rc + + +def print_result_list(header_data_list, data_list, has_error, sort_key=0): + """ + Print a list of data in a simple table format + :param header_data_list: Array of header data + :param data_list: Array of data + :param has_error: Boolean indicating if the request has error message + :param sort_key: Sorting key for the list + """ + + if has_error: + return + + if data_list is None or len(data_list) == 0: + return + + # Find the longest header string in each column + header_lengths = [len(str(x)) for x in header_data_list] + # Find the longest content string in each column + content_lengths = [max(len(str(x[i])) for x in data_list) + for i in range(len(header_data_list))] + # 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(header_data_list))) + print(' '.join('=' * length for length in col_lengths)) + for item in sorted(data_list, key=lambda d: d[sort_key]): + print(' '.join(f"{str(x).center(col_lengths[i])}" for i, x in enumerate(item))) + print("\n") + + +def print_software_deploy_host_list_result(req, data): + if req.status_code == 200 and data: + data = data.get("data", None) + if not data: + print("No deploy in progress.\n") + return + + # Calculate column widths + hdr_hn = "Hostname" + hdr_rel = "Software Release" + hdr_tg_rel = "Target Release" + hdr_rr = "Reboot Required" + hdr_state = "Host State" + + width_hn = len(hdr_hn) + width_rel = len(hdr_rel) + width_tg_rel = len(hdr_tg_rel) + width_rr = len(hdr_rr) + width_state = len(hdr_state) + + for agent in sorted(data, key=lambda a: a["hostname"]): + if agent.get("host_state") is None: + agent["host_state"] = "No active deployment" + if agent.get("target_release") is None: + agent["target_release"] = "N/A" + if len(agent["hostname"]) > width_hn: + width_hn = len(agent["hostname"]) + if len(agent["software_release"]) > width_rel: + width_rel = len(agent["software_release"]) + if len(agent["target_release"]) > width_tg_rel: + width_tg_rel = len(agent["target_release"]) + if len(agent["host_state"]) > width_state: + width_state = len(agent["host_state"]) + + print("{0:^{width_hn}} {1:^{width_rel}} {2:^{width_tg_rel}} {3:^{width_rr}} {4:^{width_state}}".format( + hdr_hn, hdr_rel, hdr_tg_rel, hdr_rr, hdr_state, + width_hn=width_hn, width_rel=width_rel, width_tg_rel=width_tg_rel, width_rr=width_rr, width_state=width_state)) + + print("{0} {1} {2} {3} {4}".format( + '=' * width_hn, '=' * width_rel, '=' * width_tg_rel, '=' * width_rr, '=' * width_state)) + + for agent in sorted(data, key=lambda a: a["hostname"]): + print("{0:<{width_hn}} {1:^{width_rel}} {2:^{width_tg_rel}} {3:^{width_rr}} {4:^{width_state}}".format( + agent["hostname"], + agent["software_release"], + agent["target_release"], + "Yes" if agent.get("reboot_required", None) else "No", + agent["host_state"], + width_hn=width_hn, width_rel=width_rel, width_tg_rel=width_tg_rel, width_rr=width_rr, width_state=width_state)) + + elif req.status_code == 500: + print("An internal error has occurred. Please check /var/log/software.log for details") + + + +def print_release_show_result(req, data, list_packages=False): + if req.status_code == 200: + + if 'metadata' in data: + sd = data['metadata'] + contents = data['contents'] + for release_id in sorted(list(sd)): + print("%s:" % release_id) + + if "sw_version" in sd[release_id] and sd[release_id]["sw_version"] != "": + print(textwrap.fill(" {0:<15} ".format("Version:") + sd[release_id]["sw_version"], + width=TERM_WIDTH, subsequent_indent=' ' * 20)) + + if "state" in sd[release_id] and sd[release_id]["state"] != "": + print(textwrap.fill(" {0:<15} ".format("State:") + sd[release_id]["state"], + width=TERM_WIDTH, subsequent_indent=' ' * 20)) + + if "status" in sd[release_id] and sd[release_id]["status"] != "": + print(textwrap.fill(" {0:<15} ".format("Status:") + sd[release_id]["status"], + width=TERM_WIDTH, subsequent_indent=' ' * 20)) + + if "unremovable" in sd[release_id] and sd[release_id]["unremovable"] != "": + print(textwrap.fill(" {0:<15} ".format("Unremovable:") + sd[release_id]["unremovable"], + width=TERM_WIDTH, subsequent_indent=' ' * 20)) + + if "reboot_required" in sd[release_id] and sd[release_id]["reboot_required"] != "": + print(textwrap.fill(" {0:<15} ".format("RR:") + sd[release_id]["reboot_required"], + width=TERM_WIDTH, subsequent_indent=' ' * 20)) + + if "apply_active_release_only" in sd[release_id] and sd[release_id]["apply_active_release_only"] != "": + print(textwrap.fill(" {0:<15} ".format("Apply Active Release Only:") + sd[release_id]["apply_active_release_only"], + width=TERM_WIDTH, subsequent_indent=' ' * 20)) + + if "summary" in sd[release_id] and sd[release_id]["summary"] != "": + print(textwrap.fill(" {0:<15} ".format("Summary:") + sd[release_id]["summary"], + width=TERM_WIDTH, subsequent_indent=' ' * 20)) + + if "description" in sd[release_id] and sd[release_id]["description"] != "": + first_line = True + for line in sd[release_id]["description"].split('\n'): + if first_line: + print(textwrap.fill(" {0:<15} ".format("Description:") + line, + width=TERM_WIDTH, subsequent_indent=' ' * 20)) + first_line = False + else: + print(textwrap.fill(line, + width=TERM_WIDTH, subsequent_indent=' ' * 20, + initial_indent=' ' * 20)) + + if "install_instructions" in sd[release_id] and sd[release_id]["install_instructions"] != "": + print(" Install Instructions:") + for line in sd[release_id]["install_instructions"].split('\n'): + print(textwrap.fill(line, + width=TERM_WIDTH, subsequent_indent=' ' * 20, + initial_indent=' ' * 20)) + + if "warnings" in sd[release_id] and sd[release_id]["warnings"] != "": + first_line = True + for line in sd[release_id]["warnings"].split('\n'): + if first_line: + print(textwrap.fill(" {0:<15} ".format("Warnings:") + line, + width=TERM_WIDTH, subsequent_indent=' ' * 20)) + first_line = False + else: + print(textwrap.fill(line, + width=TERM_WIDTH, subsequent_indent=' ' * 20, + initial_indent=' ' * 20)) + + if "requires" in sd[release_id] and len(sd[release_id]["requires"]) > 0: + print(" Requires:") + for req_patch in sorted(sd[release_id]["requires"]): + print(' ' * 20 + req_patch) + + if "contents" in data and release_id in data["contents"]: + print(" Contents:\n") + if "number_of_commits" in contents[release_id] and \ + contents[release_id]["number_of_commits"] != "": + print(textwrap.fill(" {0:<15} ".format("No. of commits:") + + contents[release_id]["number_of_commits"], + width=TERM_WIDTH, subsequent_indent=' ' * 20)) + if "base" in contents[release_id] and \ + contents[release_id]["base"]["commit"] != "": + print(textwrap.fill(" {0:<15} ".format("Base commit:") + + contents[release_id]["base"]["commit"], + width=TERM_WIDTH, subsequent_indent=' ' * 20)) + if "number_of_commits" in contents[release_id] and \ + contents[release_id]["number_of_commits"] != "": + for i in range(int(contents[release_id]["number_of_commits"])): + print(textwrap.fill(" {0:<15} ".format("Commit%s:" % (i + 1)) + + contents[release_id]["commit%s" % (i + 1)]["commit"], + width=TERM_WIDTH, subsequent_indent=' ' * 20)) + + if list_packages: + if "packages" in sd[release_id] and len(sd[release_id]["packages"]): + print(" Packages:") + for package in sorted(sd[release_id]["packages"]): + print(" " * 20 + package) + + print("\n") + + if 'info' in data and data["info"] != "": + print(data["info"]) + + if 'warning' in data and data["warning"] != "": + print("Warning:") + print(data["warning"]) + + if 'error' in data and data["error"] != "": + print("Error:") + print(data["error"]) + + elif req.status_code == 500: + print("An internal error has occurred. Please check /var/log/software.log for details") + + +def print_software_op_result(resp, data): + if resp.status_code == 200: + if 'sd' in data: + sd = data['sd'] + + # Calculate column widths + hdr_release = "Release" + hdr_version = "Version" + hdr_rr = "RR" + hdr_state = "State" + + width_release = len(hdr_release) + width_version = len(hdr_version) + width_rr = len(hdr_rr) + width_state = len(hdr_state) + + show_all = False + + for release_id in list(sd): + width_release = max(len(release_id), width_release) + width_state = max(len(sd[release_id]["state"]), width_state) + if "sw_version" in sd[release_id]: + show_all = True + width_version = max(len(sd[release_id]["sw_version"]), width_version) + + if show_all: + print("{0:^{width_release}} {1:^{width_rr}} {2:^{width_version}} {3:^{width_state}}".format( + hdr_release, hdr_rr, hdr_version, hdr_state, + width_release=width_release, width_rr=width_rr, + width_version=width_version, width_state=width_state)) + + print("{0} {1} {2} {3}".format( + '=' * width_release, '=' * width_rr, '=' * width_version, '=' * width_state)) + + for release_id in sorted(list(sd)): + if "reboot_required" in sd[release_id]: + rr = sd[release_id]["reboot_required"] + else: + rr = "Y" + + print("{0:<{width_release}} {1:^{width_rr}} {2:^{width_version}} {3:^{width_state}}".format( + release_id, + rr, + sd[release_id]["sw_version"], + sd[release_id]["state"], + width_release=width_release, width_rr=width_rr, + width_version=width_version, width_state=width_state)) + else: + print("{0:^{width_release}} {1:^{width_state}}".format( + hdr_release, hdr_state, + width_release=width_release, width_state=width_state)) + + print("{0} {1}".format( + '=' * width_release, '=' * width_state)) + + for release_id in sorted(list(sd)): + if "reboot_required" in sd[release_id]: + rr = sd[release_id]["reboot_required"] + else: + rr = "Y" + + print("{0:<{width_release}} {1:^{width_rr}} {2:^{width_state}}".format( + release_id, + rr, + sd[release_id]["state"], + width_release=width_release, width_rr=width_rr, + width_state=width_state)) + + print("") + + if 'info' in data and data["info"] != "": + print(data["info"]) + + if 'warning' in data and data["warning"] != "": + print("Warning:") + print(data["warning"]) + + if 'error' in data and data["error"] != "": + print("Error:") + print(data["error"]) + + elif resp.status_code == 500: + print("An internal error has occurred. Please check /var/log/software.log for details") + else: + # print("Error: %s has occurred. %s" % (resp.status_code, resp.reason)) + print("Error: %s has occurred." % (resp.status_code)) + + +def print_result_debug(req, data): + if req.status_code == 200: + if 'sd' in data: + print(json.dumps(data['sd'], + sort_keys=True, + indent=4, + separators=(',', ': '))) + elif 'data' in data: + print(json.dumps(data['data'], + sort_keys=True, + indent=4, + separators=(',', ': '))) + else: + print(json.dumps(data, + sort_keys=True, + indent=4, + separators=(',', ': '))) + elif req.status_code == 500: + print("An internal error has occurred. Please check /var/log/software.log for details") + else: + m = re.search("(Error message:.*)", data, re.MULTILINE) + if m: + print(m.group(0)) + else: + print("%s %s" % (req.status_code, req.reason)) diff --git a/software-client/software_client/constants.py b/software-client/software_client/constants.py index a0a32322..0a54e095 100644 --- a/software-client/software_client/constants.py +++ b/software-client/software_client/constants.py @@ -1,9 +1,9 @@ -""" -Copyright (c) 2023-2024 Wind River Systems, Inc. +# +# Copyright (c) 2023-2024 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# -SPDX-License-Identifier: Apache-2.0 - -""" ADDRESS_VERSION_IPV4 = 4 ADDRESS_VERSION_IPV6 = 6 CONTROLLER_FLOATING_HOSTNAME = "controller" @@ -64,3 +64,8 @@ SCRATCH_DIR = "/scratch" DEPLOYING = 'deploying' FAILED = 'failed' PENDING = 'pending' + +# Authorization modes of software cli +KEYSTONE = 'keystone' +TOKEN = 'token' +LOCAL_ROOT = 'local_root' diff --git a/software-client/software_client/exc.py b/software-client/software_client/exc.py new file mode 100644 index 00000000..8345456d --- /dev/null +++ b/software-client/software_client/exc.py @@ -0,0 +1,126 @@ +# Copyright 2013-2024 Wind River, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import six +import sys + + +class BaseException(Exception): + """An error occurred.""" + def __init__(self, message=None): + super(BaseException, self).__init__() + self.message = message + + def __str__(self): + return str(self.message) or self.__class__.__doc__ + + +class CommandError(BaseException): + """Invalid usage of CLI.""" + + +class InvalidEndpoint(BaseException): + """The provided endpoint is invalid.""" + + +class CommunicationError(BaseException): + """Unable to communicate with server.""" + + +class ClientException(Exception): + """DEPRECATED.""" + + +class NoTokenLookupException(Exception): + """DEPRECATED.""" + pass # pylint: disable=unnecessary-pass + + +class EndpointNotFound(Exception): + """DEPRECATED.""" + pass # pylint: disable=unnecessary-pass + + +class AmbiguousAuthSystem(ClientException): + """Could not obtain token and endpoint using provided credentials.""" + pass # pylint: disable=unnecessary-pass + + +class SoftwareclientException(Exception): + """Base Software-Client Exception + + To correctly use this class, inherit from it and define + a 'message' property. That message will get printf'd + with the keyword arguments provided to the constructor. + + """ + message = "An unknown exception occurred." + code = 500 + headers = {} + safe = False + + def __init__(self, message=None, **kwargs): + self.kwargs = kwargs + + if 'code' not in self.kwargs: + try: + self.kwargs['code'] = self.code + except AttributeError: + pass + + if not message: + try: + message = self.message % kwargs # pylint: disable=exception-message-attribute + + except Exception: + # kwargs doesn't match a variable in the message + # at least get the core message out if something happened + message = self.message # pylint: disable=exception-message-attribute + + super(SoftwareclientException, self).__init__(message) + + def format_message(self): + if self.__class__.__name__.endswith('_Remote'): + return self.args[0] # pylint: disable=unsubscriptable-object + else: + return six.text_type(self) + + +class AmbiguousEndpoints(SoftwareclientException): + message = "Endpoints are ambiguous. reason=%(reason)s" + + +class EndpointTypeNotFound(SoftwareclientException): + message = "The type of the endpoint was not found. reason=%(reason)s" + + +class SslCertificateValidationError(SoftwareclientException): + message = "Validation of the Ssl certificate failed. reason=%(reason)s" + + +class EndpointException(SoftwareclientException): + message = "Generic endpoint exception. reason=%(reason)s" + + +# Alias for backwards compatibility +AmbigiousAuthSystem = AmbiguousAuthSystem + + +class InvalidAttribute(ClientException): + pass + + +class InvalidAttributeValue(ClientException): + pass diff --git a/software-client/software_client/software_client.py b/software-client/software_client/software_client.py index 45ce1ef2..f04e3c53 100644 --- a/software-client/software_client/software_client.py +++ b/software-client/software_client/software_client.py @@ -1,322 +1,31 @@ -""" -Copyright (c) 2023-2024 Wind River Systems, Inc. +# +# Copyright (c) 2013-2024 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# -SPDX-License-Identifier: Apache-2.0 """ -# PYTHON_ARGCOMPLETE_OK -import argcomplete +Command-line interface for Software +""" + +from __future__ import print_function import argparse -import json +import httplib2 +import logging import os -import re -import requests -import signal -import software_client.constants as constants import subprocess import sys -import textwrap -import time -from requests_toolbelt import MultipartEncoder -from urllib.parse import urlparse +import software_client -from tsconfig.tsconfig import SW_VERSION as RUNNING_SW_VERSION +from software_client import client as sclient +from software_client import exc +from software_client.common import utils +from software_client.constants import TOKEN, KEYSTONE, LOCAL_ROOT -api_addr = "127.0.0.1:5493" -auth_token = None -TERM_WIDTH = 72 VIRTUAL_REGION = 'SystemController' -IPV6_FAMILY = 6 - - -def set_term_width(): - global TERM_WIDTH - - try: - with open(os.devnull, 'w') as NULL: - output = subprocess.check_output(["tput", "cols"], stderr=NULL) - width = int(output) - if width > 60: - TERM_WIDTH = width - 4 - except Exception: - pass - - -def check_rc(req): - rc = 0 - if req.status_code == 200: - data = json.loads(req.text) - if 'error' in data and data["error"] != "": - rc = 1 - else: - rc = 1 - - return rc - - -def print_result_debug(req): - if req.status_code == 200: - data = json.loads(req.text) - if 'sd' in data: - print(json.dumps(data['sd'], - sort_keys=True, - indent=4, - separators=(',', ': '))) - elif 'data' in data: - print(json.dumps(data['data'], - sort_keys=True, - indent=4, - separators=(',', ': '))) - else: - print(json.dumps(data, - sort_keys=True, - indent=4, - separators=(',', ': '))) - elif req.status_code == 500: - print("An internal error has occurred. Please check /var/log/software.log for details") - else: - m = re.search("(Error message:.*)", req.text, re.MULTILINE) - if m: - print(m.group(0)) - else: - print("%s %s" % (req.status_code, req.reason)) - - -def print_software_op_result(req): - if req.status_code == 200: - data = json.loads(req.text) - - if 'sd' in data: - sd = data['sd'] - - # Calculate column widths - hdr_release = "Release" - hdr_version = "Version" - hdr_rr = "RR" - hdr_state = "State" - - width_release = len(hdr_release) - width_version = len(hdr_version) - width_rr = len(hdr_rr) - width_state = len(hdr_state) - - show_all = False - - for release_id in list(sd): - width_release = max(len(release_id), width_release) - width_state = max(len(sd[release_id]["state"]), width_state) - if "sw_version" in sd[release_id]: - show_all = True - width_version = max(len(sd[release_id]["sw_version"]), width_version) - - if show_all: - print("{0:^{width_release}} {1:^{width_rr}} {2:^{width_version}} {3:^{width_state}}".format( - hdr_release, hdr_rr, hdr_version, hdr_state, - width_release=width_release, width_rr=width_rr, - width_version=width_version, width_state=width_state)) - - print("{0} {1} {2} {3}".format( - '=' * width_release, '=' * width_rr, '=' * width_version, '=' * width_state)) - - for release_id in sorted(list(sd)): - if "reboot_required" in sd[release_id]: - rr = sd[release_id]["reboot_required"] - else: - rr = "Y" - - print("{0:<{width_release}} {1:^{width_rr}} {2:^{width_version}} {3:^{width_state}}".format( - release_id, - rr, - sd[release_id]["sw_version"], - sd[release_id]["state"], - width_release=width_release, width_rr=width_rr, - width_version=width_version, width_state=width_state)) - else: - print("{0:^{width_release}} {1:^{width_state}}".format( - hdr_release, hdr_state, - width_release=width_release, width_state=width_state)) - - print("{0} {1}".format( - '=' * width_release, '=' * width_state)) - - for release_id in sorted(list(sd)): - if "reboot_required" in sd[release_id]: - rr = sd[release_id]["reboot_required"] - else: - rr = "Y" - - print("{0:<{width_release}} {1:^{width_rr}} {2:^{width_state}}".format( - release_id, - rr, - sd[release_id]["state"], - width_release=width_release, width_rr=width_rr, - width_state=width_state)) - - print("") - - if 'info' in data and data["info"] != "": - print(data["info"]) - - if 'warning' in data and data["warning"] != "": - print("Warning:") - print(data["warning"]) - - if 'error' in data and data["error"] != "": - print("Error:") - print(data["error"]) - - elif req.status_code == 500: - 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)) - - -def print_release_show_result(req, list_packages=False): - if req.status_code == 200: - data = json.loads(req.text) - - if 'metadata' in data: - sd = data['metadata'] - contents = data['contents'] - for release_id in sorted(list(sd)): - print("%s:" % release_id) - - if "sw_version" in sd[release_id] and sd[release_id]["sw_version"] != "": - print(textwrap.fill(" {0:<15} ".format("Version:") + sd[release_id]["sw_version"], - width=TERM_WIDTH, subsequent_indent=' ' * 20)) - - if "state" in sd[release_id] and sd[release_id]["state"] != "": - print(textwrap.fill(" {0:<15} ".format("State:") + sd[release_id]["state"], - width=TERM_WIDTH, subsequent_indent=' ' * 20)) - - if "status" in sd[release_id] and sd[release_id]["status"] != "": - print(textwrap.fill(" {0:<15} ".format("Status:") + sd[release_id]["status"], - width=TERM_WIDTH, subsequent_indent=' ' * 20)) - - if "unremovable" in sd[release_id] and sd[release_id]["unremovable"] != "": - print(textwrap.fill(" {0:<15} ".format("Unremovable:") + sd[release_id]["unremovable"], - width=TERM_WIDTH, subsequent_indent=' ' * 20)) - - if "reboot_required" in sd[release_id] and sd[release_id]["reboot_required"] != "": - print(textwrap.fill(" {0:<15} ".format("RR:") + sd[release_id]["reboot_required"], - width=TERM_WIDTH, subsequent_indent=' ' * 20)) - - if "apply_active_release_only" in sd[release_id] and sd[release_id]["apply_active_release_only"] != "": - print(textwrap.fill(" {0:<15} ".format("Apply Active Release Only:") + sd[release_id]["apply_active_release_only"], - width=TERM_WIDTH, subsequent_indent=' ' * 20)) - - if "summary" in sd[release_id] and sd[release_id]["summary"] != "": - print(textwrap.fill(" {0:<15} ".format("Summary:") + sd[release_id]["summary"], - width=TERM_WIDTH, subsequent_indent=' ' * 20)) - - if "description" in sd[release_id] and sd[release_id]["description"] != "": - first_line = True - for line in sd[release_id]["description"].split('\n'): - if first_line: - print(textwrap.fill(" {0:<15} ".format("Description:") + line, - width=TERM_WIDTH, subsequent_indent=' ' * 20)) - first_line = False - else: - print(textwrap.fill(line, - width=TERM_WIDTH, subsequent_indent=' ' * 20, - initial_indent=' ' * 20)) - - if "install_instructions" in sd[release_id] and sd[release_id]["install_instructions"] != "": - print(" Install Instructions:") - for line in sd[release_id]["install_instructions"].split('\n'): - print(textwrap.fill(line, - width=TERM_WIDTH, subsequent_indent=' ' * 20, - initial_indent=' ' * 20)) - - if "warnings" in sd[release_id] and sd[release_id]["warnings"] != "": - first_line = True - for line in sd[release_id]["warnings"].split('\n'): - if first_line: - print(textwrap.fill(" {0:<15} ".format("Warnings:") + line, - width=TERM_WIDTH, subsequent_indent=' ' * 20)) - first_line = False - else: - print(textwrap.fill(line, - width=TERM_WIDTH, subsequent_indent=' ' * 20, - initial_indent=' ' * 20)) - - if "requires" in sd[release_id] and len(sd[release_id]["requires"]) > 0: - print(" Requires:") - for req_patch in sorted(sd[release_id]["requires"]): - print(' ' * 20 + req_patch) - - if "contents" in data and release_id in data["contents"]: - print(" Contents:\n") - if "number_of_commits" in contents[release_id] and \ - contents[release_id]["number_of_commits"] != "": - print(textwrap.fill(" {0:<15} ".format("No. of commits:") + - contents[release_id]["number_of_commits"], - width=TERM_WIDTH, subsequent_indent=' ' * 20)) - if "base" in contents[release_id] and \ - contents[release_id]["base"]["commit"] != "": - print(textwrap.fill(" {0:<15} ".format("Base commit:") + - contents[release_id]["base"]["commit"], - width=TERM_WIDTH, subsequent_indent=' ' * 20)) - if "number_of_commits" in contents[release_id] and \ - contents[release_id]["number_of_commits"] != "": - for i in range(int(contents[release_id]["number_of_commits"])): - print(textwrap.fill(" {0:<15} ".format("Commit%s:" % (i + 1)) + - contents[release_id]["commit%s" % (i + 1)]["commit"], - width=TERM_WIDTH, subsequent_indent=' ' * 20)) - - if list_packages: - if "packages" in sd[release_id] and len(sd[release_id]["packages"]): - print(" Packages:") - for package in sorted(sd[release_id]["packages"]): - print(" " * 20 + package) - - print("\n") - - if 'info' in data and data["info"] != "": - print(data["info"]) - - if 'warning' in data and data["warning"] != "": - print("Warning:") - print(data["warning"]) - - if 'error' in data and data["error"] != "": - print("Error:") - print(data["error"]) - - elif req.status_code == 500: - print("An internal error has occurred. Please check /var/log/software.log for details") - - -def _print_result_list(header_data_list, data_list, has_error, sort_key=0): - """ - Print a list of data in a simple table format - :param header_data_list: Array of header data - :param data_list: Array of data - :param has_error: Boolean indicating if the request has error message - :param sort_key: Sorting key for the list - """ - - if has_error: - return - - if data_list is None or len(data_list) == 0: - return - - # Find the longest header string in each column - header_lengths = [len(str(x)) for x in header_data_list] - # Find the longest content string in each column - content_lengths = [max(len(str(x[i])) for x in data_list) - for i in range(len(header_data_list))] - # 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(header_data_list))) - print(' '.join('=' * length for length in col_lengths)) - for item in sorted(data_list, key=lambda d: d[sort_key]): - print(' '.join(f"{str(x).center(col_lengths[i])}" for i, x in enumerate(item))) - print("\n") def software_command_not_implemented_yet(args): @@ -324,892 +33,6 @@ def software_command_not_implemented_yet(args): return 1 -def release_is_available_req(args): - - releases = "/".join(args.release) - url = "http://%s/v1/software/is_available/%s" % (api_addr, releases) - - headers = {} - append_auth_token_if_required(headers) - req = requests.post(url, headers=headers) - - rc = 1 - - if req.status_code == 200: - result = json.loads(req.text) - print(result) - if result is True: - rc = 0 - elif req.status_code == 500: - 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 - - -def release_is_deployed_req(args): - - releases = "/".join(args.release) - url = "http://%s/v1/software/is_deployed/%s" % (api_addr, releases) - - headers = {} - append_auth_token_if_required(headers) - req = requests.post(url, headers=headers) - - rc = 1 - - if req.status_code == 200: - result = json.loads(req.text) - print(result) - if result is True: - rc = 0 - elif req.status_code == 500: - 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 - - -def release_is_committed_req(args): - - releases = "/".join(args.release) - url = "http://%s/v1/software/is_committed/%s" % (api_addr, releases) - - headers = {} - append_auth_token_if_required(headers) - req = requests.post(url, headers=headers) - - rc = 1 - - if req.status_code == 200: - result = json.loads(req.text) - print(result) - if result is True: - rc = 0 - elif req.status_code == 500: - 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 - - -def release_upload_req(args): - rc = 0 - - # arg.release is a list - releases = args.release - is_local = args.local # defaults to False - - # Ignore interrupts during this function - signal.signal(signal.SIGINT, signal.SIG_IGN) - - to_upload_files = {} - valid_files = [] - invalid_files = [] - - # Validate all the files - valid_files = [os.path.abspath(software_file) for software_file in releases if os.path.isfile( - software_file) and os.path.splitext(software_file)[1] in constants.SUPPORTED_UPLOAD_FILE_EXT] - invalid_files = [os.path.abspath(software_file) for software_file in releases - if os.path.abspath(software_file) not in valid_files] - - for software_file in invalid_files: - if os.path.isdir(software_file): - print("Error: %s is a directory. Please use upload-dir" % software_file) - elif os.path.isfile(software_file): - print("Error: %s has the unsupported file extension." % software_file) - else: - print("Error: File does not exist: %s" % software_file) - - if len(valid_files) == 0: - print("No file to be uploaded.") - return rc - - if is_local: - to_upload_filenames = json.dumps(valid_files) - headers = {'Content-Type': 'text/plain'} - else: - for software_file in valid_files: - with open(software_file, 'rb') as file: - data_content = file.read() - to_upload_files[software_file] = (software_file, data_content) - - encoder = MultipartEncoder(fields=to_upload_files) - headers = {'Content-Type': encoder.content_type} - - url = "http://%s/v1/software/upload" % api_addr - append_auth_token_if_required(headers) - req = requests.post(url, - data=to_upload_filenames if is_local else encoder, - headers=headers) - - if args.debug: - print_result_debug(req) - else: - print_software_op_result(req) - data = json.loads(req.text) - data_list = [(k, v["id"]) - for d in data["upload_info"] for k, v in d.items() - if not k.endswith(".sig")] - - header_data_list = ["Uploaded File", "Id"] - has_error = 'error' in data and data["error"] - _print_result_list(header_data_list, data_list, has_error) - if check_rc(req) != 0: - # We hit a failure. Update rc but keep looping - rc = 1 - - return rc - - -def release_delete_req(args): - # arg.release is a list - releases = "/".join(args.release) - - # Ignore interrupts during this function - signal.signal(signal.SIGINT, signal.SIG_IGN) - - url = "http://%s/v1/software/delete/%s" % (api_addr, releases) - - headers = {} - append_auth_token_if_required(headers) - req = requests.post(url, headers=headers) - - if args.debug: - print_result_debug(req) - else: - print_software_op_result(req) - - return check_rc(req) - - -def commit_patch_req(args): - - # Ignore interrupts during this function - signal.signal(signal.SIGINT, signal.SIG_IGN) - - # Default to running release - # this all needs to be changed - relopt = RUNNING_SW_VERSION - - headers = {} - append_auth_token_if_required(headers) - if args.sw_version and not args.all: - # Disallow - print("Use of --sw-version option requires --all") - return 1 - elif args.all: - # Get a list of all patches - extra_opts = "&release=%s" % relopt - url = "http://%s/v1/software/query?show=patch%s" % (api_addr, extra_opts) - - req = requests.get(url, headers=headers) - - patch_list = [] - if req.status_code == 200: - data = json.loads(req.text) - - if 'sd' in data: - patch_list = sorted(list(data['sd'])) - elif req.status_code == 500: - print("Failed to get patch list. Aborting...") - return 1 - - if len(patch_list) == 0: - print("There are no %s patches to commit." % relopt) - return 0 - - print("The following patches will be committed:") - for patch_id in patch_list: - print(" %s" % patch_id) - print() - - patches = "/".join(patch_list) - else: - # args.patch is a list - patches = "/".join(args.patch) - - # First, get a list of dependencies and ask for confirmation - url = "http://%s/v1/software/query_dependencies/%s?recursive=yes" % (api_addr, patches) - - req = requests.get(url, headers=headers) - - if req.status_code == 200: - data = json.loads(req.text) - - if 'patches' in data: - print("The following patches will be committed:") - for release_id in sorted(data['patches']): - print(" %s" % release_id) - print() - else: - print("No patches found to commit") - return 1 - - elif req.status_code == 500: - print("An internal error has occurred. Please check /var/log/software.log for details") - return 1 - - # Run dry-run - url = "http://%s/v1/software/commit_dry_run/%s" % (api_addr, patches) - - req = requests.post(url, headers=headers) - print_software_op_result(req) - - if check_rc(req) != 0: - print("Aborting...") - return 1 - - if args.dry_run: - return 0 - - print() - commit_warning = "WARNING: Committing a patch is an irreversible operation. " + \ - "Committed patches cannot be removed." - print(textwrap.fill(commit_warning, width=TERM_WIDTH, subsequent_indent=' ' * 9)) - print() - - user_input = input("Would you like to continue? [y/N]: ") - if user_input.lower() != 'y': - print("Aborting...") - return 1 - - url = "http://%s/v1/software/commit_patch/%s" % (api_addr, patches) - req = requests.post(url, headers=headers) - - if args.debug: - print_result_debug(req) - else: - print_software_op_result(req) - - return check_rc(req) - - -def release_list_req(args): - state = args.state # defaults to "all" - extra_opts = "" - if args.release: - extra_opts = "&release=%s" % args.release - url = "http://%s/v1/software/query?show=%s%s" % (api_addr, state, extra_opts) - headers = {} - append_auth_token_if_required(headers) - req = requests.get(url, headers=headers) - - if args.debug: - print_result_debug(req) - else: - header_data_list = ["Release", "RR", "State"] - data = json.loads(req.text) - data_list = [(k, v["reboot_required"], v["state"]) for k, v in data["sd"].items()] - has_error = 'error' in data and data["error"] - _print_result_list(header_data_list, data_list, has_error) - - return check_rc(req) - - -def print_software_deploy_host_list_result(req): - if req.status_code == 200: - data = req.json().get("data", None) - if not data: - print("No deploy in progress.\n") - return - - # Calculate column widths - hdr_hn = "Hostname" - hdr_rel = "Software Release" - hdr_tg_rel = "Target Release" - hdr_rr = "Reboot Required" - hdr_state = "Host State" - - width_hn = len(hdr_hn) - width_rel = len(hdr_rel) - width_tg_rel = len(hdr_tg_rel) - width_rr = len(hdr_rr) - width_state = len(hdr_state) - - for agent in sorted(data, key=lambda a: a["hostname"]): - if agent.get("host_state") is None: - agent["host_state"] = "No active deployment" - if agent.get("target_release") is None: - agent["target_release"] = "N/A" - if len(agent["hostname"]) > width_hn: - width_hn = len(agent["hostname"]) - if len(agent["software_release"]) > width_rel: - width_rel = len(agent["software_release"]) - if len(agent["target_release"]) > width_tg_rel: - width_tg_rel = len(agent["target_release"]) - if len(agent["host_state"]) > width_state: - width_state = len(agent["host_state"]) - - print("{0:^{width_hn}} {1:^{width_rel}} {2:^{width_tg_rel}} {3:^{width_rr}} {4:^{width_state}}".format( - hdr_hn, hdr_rel, hdr_tg_rel, hdr_rr, hdr_state, - width_hn=width_hn, width_rel=width_rel, width_tg_rel=width_tg_rel, width_rr=width_rr, width_state=width_state)) - - print("{0} {1} {2} {3} {4}".format( - '=' * width_hn, '=' * width_rel, '=' * width_tg_rel, '=' * width_rr, '=' * width_state)) - - for agent in sorted(data, key=lambda a: a["hostname"]): - print("{0:<{width_hn}} {1:^{width_rel}} {2:^{width_tg_rel}} {3:^{width_rr}} {4:^{width_state}}".format( - agent["hostname"], - agent["software_release"], - agent["target_release"], - "Yes" if agent.get("reboot_required", None) else "No", - agent["host_state"], - width_hn=width_hn, width_rel=width_rel, width_tg_rel=width_tg_rel, width_rr=width_rr, width_state=width_state)) - - elif req.status_code == 500: - print("An internal error has occurred. Please check /var/log/software.log for details") - - -def deploy_host_list_req(args): - url = "http://%s/v1/software/host_list" % api_addr - req = requests.get(url) - if args.debug: - print_result_debug(req) - else: - print_software_deploy_host_list_result(req) - - return check_rc(req) - - -def release_show_req(args): - # arg.release is a list - releases = "/".join(args.release) - list_packages = args.packages - - url = "http://%s/v1/software/show/%s" % (api_addr, releases) - - headers = {} - append_auth_token_if_required(headers) - # todo(abailey): convert this to a GET - req = requests.post(url, headers=headers) - - if args.debug: - print_result_debug(req) - else: - print_release_show_result(req, list_packages=list_packages) - - return check_rc(req) - - -def wait_for_install_complete(agent_ip): - url = "http://%s/v1/software/host_list" % api_addr - rc = 0 - - max_retries = 4 - retriable_count = 0 - - while True: - # Sleep on the first pass as well, to allow time for the - # agent to respond - time.sleep(5) - - try: - req = requests.get(url) - except requests.exceptions.ConnectionError: - # The local software-controller may have restarted. - retriable_count += 1 - if retriable_count <= max_retries: - continue - else: - print("Lost communications with the software controller") - rc = 1 - break - - if req.status_code == 200: - data = req.json().get("data", None) - if not data: - print("Invalid host-list data returned:") - print_result_debug(req) - rc = 1 - break - - host_state = None - - for d in data: - if d['hostname'] == agent_ip: - host_state = d.get('host_state') - - if host_state == constants.DEPLOYING: - # Still deploying - sys.stdout.write(".") - sys.stdout.flush() - elif host_state == constants.FAILED: - print("\nDeployment failed. Please check logs for details.") - rc = 1 - break - elif host_state == constants.DEPLOYED: - print("\nDeployment was successful.") - rc = 0 - break - else: - print("\nReported unknown state: %s" % host_state) - rc = 1 - break - - elif req.status_code == 500: - print("An internal error has occurred. Please check /var/log/software.log for details") - rc = 1 - break - else: - m = re.search("(Error message:.*)", req.text, re.MULTILINE) - if m: - print(m.group(0)) - else: - print(vars(req)) - rc = 1 - break - - return rc - - -def host_install(args): - rc = 0 - agent_ip = args.agent - - # Issue deploy_host request and poll for results - url = "http://%s/v1/software/deploy_host/%s" % (api_addr, agent_ip) - - if args.force: - url += "/force" - - req = requests.post(url) - - if req.status_code == 200: - data = json.loads(req.text) - if 'error' in data and data["error"] != "": - print("Error:") - print(data["error"]) - rc = 1 - else: - rc = wait_for_install_complete(agent_ip) - elif req.status_code == 500: - print("An internal error has occurred. " - "Please check /var/log/software.log for details") - rc = 1 - else: - m = re.search("(Error message:.*)", req.text, re.MULTILINE) - if m: - print(m.group(0)) - else: - print("%s %s" % (req.status_code, req.reason)) - rc = 1 - - return rc - - -def drop_host(args): - host_ip = args.host - - url = "http://%s/v1/software/drop_host/%s" % (api_addr, host_ip) - - req = requests.post(url) - - if args.debug: - print_result_debug(req) - else: - print_software_op_result(req) - - return check_rc(req) - - -def install_local(args): # pylint: disable=unused-argument - # Ignore interrupts during this function - signal.signal(signal.SIGINT, signal.SIG_IGN) - - url = "http://%s/v1/software/install_local" % (api_addr) - - headers = {} - append_auth_token_if_required(headers) - req = requests.get(url, headers=headers) - - if args.debug: - print_result_debug(req) - else: - print_software_op_result(req) - - return check_rc(req) - - -def release_upload_dir_req(args): - # arg.release is a list - release_dirs = args.release - - # Ignore interrupts during this function - signal.signal(signal.SIGINT, signal.SIG_IGN) - - to_upload_files = {} - raw_files = [] - - # Find all files that need to be uploaded in given directories - for release_dir in release_dirs: - if os.path.isdir(release_dir): - raw_files = [f for f in os.listdir(release_dir) - if os.path.isfile(os.path.join(release_dir, f))] - - # Get absolute path of files - raw_files = [os.path.abspath(os.path.join(release_dir, f)) for f in raw_files] - else: - print("Skipping invalid directory: %s" % release_dir, file=sys.stderr) - - if len(raw_files) == 0: - print("No file to upload") - return 0 - - temp_iso_files = [f for f in raw_files if f.endswith(constants.ISO_EXTENSION)] - if len(temp_iso_files) > 1: # Verify that only one ISO file is being uploaded - print("Only one ISO file can be uploaded at a time. Found: %s" % - temp_iso_files, file=sys.stderr) - return 1 - - temp_sig_files = [f for f in raw_files if f.endswith(constants.SIG_EXTENSION)] - if len(temp_sig_files) > 1: # Verify that only one SIG file is being uploaded - print("Only one SIG file can be uploaded at a time. Found: %s" % - temp_sig_files, file=sys.stderr) - return 1 - - for software_file in sorted(set(raw_files)): - _, ext = os.path.splitext(software_file) - if ext in constants.SUPPORTED_UPLOAD_FILE_EXT: - to_upload_files[software_file] = (software_file, open(software_file, 'rb')) - else: - print("Skipping unsupported file: %s" % software_file, file=sys.stderr) - - encoder = MultipartEncoder(fields=to_upload_files) - url = "http://%s/v1/software/upload" % api_addr - headers = {'Content-Type': encoder.content_type} - append_auth_token_if_required(headers) - req = requests.post(url, - data=encoder, - headers=headers) - if args.debug: - print_result_debug(req) - else: - print_software_op_result(req) - return check_rc(req) - - -def deploy_precheck_req(args): - # args.deployment is a string - deployment = args.deployment - - # args.region is a string - region_name = args.region_name - - # Issue deploy_precheck request - url = "http://%s/v1/software/deploy_precheck/%s" % (api_addr, deployment) - if args.force: - url += "/force" - url += "?region_name=%s" % region_name - - headers = {} - append_auth_token_if_required(headers) - req = requests.post(url, headers=headers) - - if args.debug: - print_result_debug(req) - else: - print_software_op_result(req) - - return check_rc(req) - - -def deploy_start_req(args): - # args.deployment is a string - deployment = args.deployment - - # Ignore interrupts during this function - signal.signal(signal.SIGINT, signal.SIG_IGN) - - # Issue deploy_start request - if args.force: - url = "http://%s/v1/software/deploy_start/%s/force" % (api_addr, deployment) - else: - url = "http://%s/v1/software/deploy_start/%s" % (api_addr, deployment) - - headers = {} - append_auth_token_if_required(headers) - req = requests.post(url, headers=headers) - - if args.debug: - print_result_debug(req) - else: - print_software_op_result(req) - - return check_rc(req) - - -def deploy_activate_req(args): - # args.deployment is a string - deployment = args.deployment - - # Ignore interrupts during this function - signal.signal(signal.SIGINT, signal.SIG_IGN) - - # Issue deploy_start request - url = "http://%s/v1/software/deploy_activate/%s" % (api_addr, deployment) - - headers = {} - append_auth_token_if_required(headers) - req = requests.post(url, headers=headers) - - if args.debug: - print_result_debug(req) - else: - print_software_op_result(req) - - return check_rc(req) - - -def deploy_complete_req(args): - # args.deployment is a string - deployment = args.deployment - - # Ignore interrupts during this function - signal.signal(signal.SIGINT, signal.SIG_IGN) - - # Issue deploy_complete request - url = "http://%s/v1/software/deploy_complete/%s" % (api_addr, deployment) - - headers = {} - append_auth_token_if_required(headers) - req = requests.post(url, headers=headers) - - if args.debug: - print_result_debug(req) - else: - print_software_op_result(req) - - return check_rc(req) - - -def deploy_show_req(args): - url = "http://%s/v1/software/deploy" % 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 = req.json().get("data") - if not data: - print("No deploy in progress.") - else: - data = data[0] - 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): - rc = 0 - agent_ip = args.agent - - # Issue deploy_host request and poll for results - url = "http://%s/v1/software/deploy_host/%s" % (api_addr, agent_ip) - - if args.force: - url += "/force" - - req = requests.post(url) - - if req.status_code == 200: - data = json.loads(req.text) - if 'error' in data and data["error"] != "": - print("Error:") - print(data["error"]) - rc = 1 - else: - rc = wait_for_install_complete(agent_ip) - elif req.status_code == 500: - print("An internal error has occurred. " - "Please check /var/log/software.log for details") - rc = 1 - else: - m = re.search("(Error message:.*)", req.text, re.MULTILINE) - if m: - print(m.group(0)) - else: - print("%s %s" % (req.status_code, req.reason)) - rc = 1 - return rc - - -def patch_init_release(args): - # Ignore interrupts during this function - signal.signal(signal.SIGINT, signal.SIG_IGN) - - release = args.release - - url = "http://%s/v1/software/init_release/%s" % (api_addr, release) - - req = requests.post(url) - - if args.debug: - print_result_debug(req) - else: - print_software_op_result(req) - - return check_rc(req) - - -def patch_del_release(args): - # Ignore interrupts during this function - signal.signal(signal.SIGINT, signal.SIG_IGN) - - release = args.release - - url = "http://%s/v1/software/del_release/%s" % (api_addr, release) - - req = requests.post(url) - - if args.debug: - print_result_debug(req) - else: - print_software_op_result(req) - - return check_rc(req) - - -def patch_report_app_dependencies_req(args): # pylint: disable=unused-argument - extra_opts = [args.app] - extra_opts_str = '?%s' % '&'.join(extra_opts) - - patches = "/".join(args) - url = "http://%s/v1/software/report_app_dependencies/%s%s" \ - % (api_addr, patches, extra_opts_str) - - headers = {} - append_auth_token_if_required(headers) - req = requests.post(url, headers=headers) - - if req.status_code == 200: - return 0 - else: - print("An internal error has occurred. " - "Please check /var/log/software.log for details.") - return 1 - - -def patch_query_app_dependencies_req(): - url = "http://%s/v1/software/query_app_dependencies" % api_addr - - headers = {} - append_auth_token_if_required(headers) - req = requests.post(url, headers=headers) - - if req.status_code == 200: - data = json.loads(req.text) - if len(data) == 0: - print("There are no application dependencies.") - else: - hdr_app = "Application" - hdr_list = "Required Patches" - width_app = len(hdr_app) - width_list = len(hdr_list) - - for app, patch_list in data.items(): - width_app = max(width_app, len(app)) - width_list = max(width_list, len(', '.join(patch_list))) - - print("{0:<{width_app}} {1:<{width_list}}".format( - hdr_app, hdr_list, - width_app=width_app, width_list=width_list)) - - print("{0} {1}".format( - '=' * width_app, '=' * width_list)) - - for app, patch_list in sorted(data.items()): - print("{0:<{width_app}} {1:<{width_list}}".format( - app, ', '.join(patch_list), - width_app=width_app, width_list=width_list)) - - return 0 - else: - print("An internal error has occurred. " - "Please check /var/log/software.log for details.") - return 1 - - -def get_auth_token_and_endpoint(region_name, interface): - from keystoneauth1 import exceptions - from keystoneauth1 import identity - from keystoneauth1 import session - - if not region_name: - return None, None - - user_env_map = {'OS_USERNAME': 'username', - 'OS_PASSWORD': 'password', - 'OS_PROJECT_NAME': 'project_name', - 'OS_AUTH_URL': 'auth_url', - 'OS_USER_DOMAIN_NAME': 'user_domain_name', - 'OS_PROJECT_DOMAIN_NAME': 'project_domain_name'} - - for k, v in user_env_map.items(): - if k not in os.environ: - return None, None - - user = dict() - for k, v in user_env_map.items(): - user[v] = os.environ.get(k) - - auth = identity.V3Password(**user) - sess = session.Session(auth=auth) - try: - token = auth.get_token(sess) - endpoint = auth.get_endpoint(sess, service_type='usm', - interface=interface, - region_name=region_name) - except (exceptions.http.Unauthorized, exceptions.EndpointNotFound) as e: - print(str(e)) - return None, None - - return token, endpoint - - -def append_auth_token_if_required(headers): - global auth_token - if auth_token is not None: - headers['X-Auth-Token'] = auth_token - - -def format_url_address(address): - import netaddr - try: - ip_addr = netaddr.IPAddress(address) - if ip_addr.version == IPV6_FAMILY: - return "[%s]" % address - else: - return address - except netaddr.AddrFormatError: - return address - - def check_for_os_region_name(args): # argparse converts os-region-name to os_region_name region = args.os_region_name @@ -1227,357 +50,405 @@ def check_for_os_region_name(args): except subprocess.CalledProcessError: return False - # get a token and fetch the internal endpoint in SystemController - global auth_token - auth_token, endpoint = get_auth_token_and_endpoint(region, 'internal') - if endpoint is not None: - global api_addr - url = urlparse(endpoint) - address = format_url_address(url.hostname) - api_addr = '{}:{}'.format(address, url.port) + return True + + +def check_keystone_credentials(args): + if not args.os_username: + return False + + if not args.os_password: + # priviledge check (only allow Keyring retrieval if we are root) + if os.geteuid() == 0: + import keyring + args.os_password = keyring.get_password('CGCS', args.os_username) + else: + return False + + if not (args.os_project_id or args.os_project_name): + return False + + if not args.os_auth_url: + return False + + if not args.os_region_name: + return False return True -def register_deploy_commands(commands): - """deploy commands - - precheck - - start - - host - - activate - - complete - non root/sudo users can run: - - host-list - - show - Deploy commands are region_restricted, which means - that they are not permitted to be run in DC - """ +class SoftwareClientShell(object): - cmd_area = 'deploy' - cmd_parser = commands.add_parser( - cmd_area, - help='Software Deploy', - epilog="StarlingX Unified Software Deployment" - ) - cmd_parser.set_defaults(cmd_area=cmd_area) + def __init__(self): + self.subcommands = None + self.parser = None - # Deploy commands are region_restricted, which means - # that they are not permitted to be run in DC - cmd_parser.set_defaults(region_restricted=True) + def get_base_parser(self): + parser = argparse.ArgumentParser( + prog='software', + description=__doc__.strip(), + epilog='See "software help COMMAND" ' + 'for help on a specific command.', + add_help=False, + formatter_class=HelpFormatter, + ) - sub_cmds = cmd_parser.add_subparsers( - title='Software Deploy Commands', - metavar='' - ) - sub_cmds.required = True + # Global arguments + parser.add_argument('-h', '--help', + action='store_true', + help=argparse.SUPPRESS, + ) - # --- software deploy precheck ----------------------- - cmd = sub_cmds.add_parser( - 'precheck', - help='Verify whether prerequisites for installing the software deployment are satisfied' - ) - cmd.set_defaults(cmd='precheck') - cmd.set_defaults(func=deploy_precheck_req) - cmd.add_argument('deployment', - help='Verify if prerequisites are met for this Deployment ID') - cmd.add_argument('-f', - '--force', - action='store_true', - required=False, - help='Allow bypassing non-critical checks') - cmd.add_argument('--region_name', - default='RegionOne', - required=False, - help='Run precheck against a subcloud') + parser.add_argument('--version', + action='version', + version=software_client.__version__) - # --- software deploy start -------------------------- - cmd = sub_cmds.add_parser( - 'start', - help='Start the software deployment' - ) - cmd.set_defaults(cmd='start') - cmd.set_defaults(func=deploy_start_req) - cmd.add_argument('deployment', - help='Deployment ID to start') - cmd.add_argument('-f', - '--force', - action='store_true', - required=False, - help='Allow bypassing non-critical checks') + parser.add_argument('--debug', + default=bool(utils.env('SOFTWARECLIENT_DEBUG')), + action='store_true', + help='Defaults to env[SOFTWARECLIENT_DEBUG]') - # --- software deploy host --------------------------- - cmd = sub_cmds.add_parser( - 'host', - help='Deploy prestaged software deployment to the host' - ) - cmd.set_defaults(cmd='host') - cmd.set_defaults(func=deploy_host_req) - cmd.add_argument('agent', - help="Agent on which host deploy is triggered") - cmd.add_argument('-f', - '--force', - action='store_true', - required=False, - help="Force deploy host") + parser.add_argument('-v', '--verbose', + default=False, action="store_true", + help="Print more verbose output") - # --- software deploy activate ----------------------- - cmd = sub_cmds.add_parser( - 'activate', - help='Activate the software deployment' - ) - cmd.set_defaults(cmd='activate') - cmd.set_defaults(func=deploy_activate_req) - cmd.add_argument('deployment', - help='Deployment ID to activate') + parser.add_argument('-k', '--insecure', + default=False, + action='store_true', + help="Explicitly allow system client to " + "perform \"insecure\" SSL (https) requests. " + "The server's certificate will " + "not be verified against any certificate " + "authorities. This option should be used with " + "caution") - # --- software deploy complete ----------------------- - cmd = sub_cmds.add_parser( - 'complete', - help='Complete the software deployment' - ) - cmd.set_defaults(cmd='complete') - cmd.set_defaults(func=deploy_complete_req) - cmd.add_argument('deployment', - help='Deployment ID to complete') + parser.add_argument('--cert-file', + help='Path of certificate file to use in SSL ' + 'connection. This file can optionally be prepended' + ' with the private key') - # --- software deploy show --------------------------- - cmd = sub_cmds.add_parser( - 'show', - help='Show the software deployments states' - ) - 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', - required=False, - help='List the deployment specified') - # --state is an optional argument. - # default: "all" - # acceptable values: inactive, active, prestaging, prestaged, all - cmd.add_argument('--state', - default="all", - required=False, - help='List all deployments that have this state') + parser.add_argument('--key-file', + help='Path of client key to use in SSL connection.' + ' This option is not necessary if your key is ' + 'prepended to your cert file') - # --- software deploy host-list ------------- - cmd = sub_cmds.add_parser( - 'host-list', - help='List of hosts for software deployment' - ) - cmd.set_defaults(cmd='host-list') - cmd.set_defaults(func=deploy_host_list_req) - cmd.set_defaults(restricted=False) # can run non root + parser.add_argument('--ca-file', + default=utils.env('OS_CACERT'), + help='Path of CA SSL certificate(s) used to verify' + ' the remote server certificate. Without this ' + 'option systemclient looks for the default system ' + 'CA certificates') + + parser.add_argument('--timeout', + default=600, + help='Number of seconds to wait for a response') + + parser.add_argument('--os-username', + default=utils.env('OS_USERNAME'), + help='Defaults to env[OS_USERNAME]') + + parser.add_argument('--os_username', + help=argparse.SUPPRESS) + + parser.add_argument('--os-password', + default=utils.env('OS_PASSWORD'), + help='Defaults to env[OS_PASSWORD]') + + parser.add_argument('--os_password', + help=argparse.SUPPRESS) + + parser.add_argument('--os-tenant-id', + default=utils.env('OS_TENANT_ID'), + help='Defaults to env[OS_TENANT_ID]') + + parser.add_argument('--os_tenant_id', + help=argparse.SUPPRESS) + + parser.add_argument('--os-tenant-name', + default=utils.env('OS_TENANT_NAME'), + help='Defaults to env[OS_TENANT_NAME]') + + parser.add_argument('--os_tenant_name', + help=argparse.SUPPRESS) + + parser.add_argument('--os-auth-url', + default=utils.env('OS_AUTH_URL'), + help='Defaults to env[OS_AUTH_URL]') + + parser.add_argument('--os_auth_url', + help=argparse.SUPPRESS) + + parser.add_argument('--os-region-name', + default=utils.env('OS_REGION_NAME'), + help='Defaults to env[OS_REGION_NAME]') + + parser.add_argument('--os_region_name', + help=argparse.SUPPRESS) + + parser.add_argument('--os-auth-token', + default=utils.env('OS_AUTH_TOKEN'), + help='Defaults to env[OS_AUTH_TOKEN]') + + parser.add_argument('--os_auth_token', + help=argparse.SUPPRESS) + + parser.add_argument('--software-url', + default=utils.env('SYSTEM_URL'), + help='Defaults to env[SYSTEM_URL]') + + parser.add_argument('--software_url', + help=argparse.SUPPRESS) + + parser.add_argument('--system-api-version', + default=utils.env('SYSTEM_API_VERSION', default='1'), + help='Defaults to env[SYSTEM_API_VERSION] ' + 'or 1') + + parser.add_argument('--system_api_version', + help=argparse.SUPPRESS) + + parser.add_argument('--os-service-type', + default=utils.env('OS_SERVICE_TYPE'), + help='Defaults to env[OS_SERVICE_TYPE]') + + parser.add_argument('--os_service_type', + help=argparse.SUPPRESS) + + parser.add_argument('--os-endpoint-type', + default=utils.env('OS_ENDPOINT_TYPE'), + help='Defaults to env[OS_ENDPOINT_TYPE]') + + parser.add_argument('--os_endpoint_type', + help=argparse.SUPPRESS) + + parser.add_argument('--os-user-domain-id', + default=utils.env('OS_USER_DOMAIN_ID'), + help='Defaults to env[OS_USER_DOMAIN_ID].') + + parser.add_argument('--os-user-domain-name', + default=utils.env('OS_USER_DOMAIN_NAME'), + help='Defaults to env[OS_USER_DOMAIN_NAME].') + + parser.add_argument('--os-project-id', + default=utils.env('OS_PROJECT_ID'), + help='Another way to specify tenant ID. ' + 'This option is mutually exclusive with ' + ' --os-tenant-id. ' + 'Defaults to env[OS_PROJECT_ID].') + + parser.add_argument('--os-project-name', + default=utils.env('OS_PROJECT_NAME'), + help='Another way to specify tenant name. ' + 'This option is mutually exclusive with ' + ' --os-tenant-name. ' + 'Defaults to env[OS_PROJECT_NAME].') + + parser.add_argument('--os-project-domain-id', + default=utils.env('OS_PROJECT_DOMAIN_ID'), + help='Defaults to env[OS_PROJECT_DOMAIN_ID].') + + parser.add_argument('--os-project-domain-name', + default=utils.env('OS_PROJECT_DOMAIN_NAME'), + help='Defaults to env[OS_PROJECT_DOMAIN_NAME].') -def setup_argparse(): - parser = argparse.ArgumentParser(prog="software", - description="Unified Software Management", - epilog="Used for patching and upgrading") - parser.add_argument('--debug', action='store_true', help="Enable debug output") - # parser.add_argument('--os-auth-url', default=None) - # parser.add_argument('--os-project-name', default=None) - # parser.add_argument('--os-project-domain-name', default=None) - # parser.add_argument('--os-username', default=None) - # parser.add_argument('--os-password', default=None) - # parser.add_argument('--os-user-domain-name', default=None) - parser.add_argument('--os-region-name', default=None) - # parser.add_argument('--os-interface', default=None) + # All commands are considered restricted, unless explicitly set to False + parser.set_defaults(restricted=True) + # All functions are initially defined as 'not implemented yet' + # The func will be overridden by the command definition as they are completed + parser.set_defaults(func=software_command_not_implemented_yet) - # All commands are considered restricted, unless explicitly set to False - parser.set_defaults(restricted=True) - # All functions are initially defined as 'not implemented yet' - # The func will be overridden by the command definition as they are completed - parser.set_defaults(func=software_command_not_implemented_yet) + # No commands are region restricted, unless explicitly set to True + parser.set_defaults(region_restricted=False) - # No commands are region restricted, unless explicitly set to True - parser.set_defaults(region_restricted=False) + return parser - commands = parser.add_subparsers(title='Commands', metavar='') - commands.required = True + def get_subcommand_parser(self, version): + parser = self.get_base_parser() - # -- software commit-patch --------------- - cmd = commands.add_parser( - 'commit-patch', - help='Commit patches to free disk space. WARNING: This action is irreversible!' - ) - cmd.set_defaults(cmd='commit-patch') - cmd.set_defaults(func=commit_patch_req) - cmd.add_argument('patch', - nargs="+", # accepts a list - help='Patch ID/s to commit') - # --dry-run is an optional argument - cmd.add_argument('--dry-run', - action='store_true', - required=False, - help='Check the space savings without committing the patch') - # --all is an optional argument - cmd.add_argument('--all', - action='store_true', - required=False, - help='Commit all the applied patches') - # --sw-version is an optional argument - cmd.add_argument('--sw-version', - required=False, - help='Software release version') + self.subcommands = {} + subparsers = parser.add_subparsers(metavar='') + submodule = utils.import_versioned_module(version, 'shell') + submodule.enhance_parser(parser, subparsers, self.subcommands) + utils.define_commands_from_module(subparsers, self, self.subcommands) + subparsers2 = self._add_deploy_subparser(subparsers) + deploy_submodule = utils.import_versioned_module(version, 'deploy_cmd') + deploy_submodule.enhance_parser(parser, subparsers2, self.subcommands) + utils.define_commands_from_module(subparsers2, self, self.subcommands) + self._add_bash_completion_subparser(subparsers2) + return parser - # -- software delete --------------- - cmd = commands.add_parser( - 'delete', - help='Delete the software release' - ) - cmd.set_defaults(cmd='delete') - cmd.set_defaults(func=release_delete_req) - cmd.add_argument('release', - nargs="+", # accepts a list - help='Release ID to delete') + def _add_bash_completion_subparser(self, subparsers): + subparser = subparsers.add_parser( + 'bash_completion', + add_help=False, + formatter_class=HelpFormatter + ) + self.subcommands['bash_completion'] = subparser + subparser.set_defaults(func=self.do_bash_completion) - # -- software install-local --------------- - cmd = commands.add_parser( - 'install-local', - help='Trigger patch install/remove on the local host. ' + - 'This command can only be used for patch installation ' + - 'prior to initial configuration.' - ) - cmd.set_defaults(cmd='install-local') - cmd.set_defaults(func=install_local) + def _add_deploy_subparser(self, subparsers): + """deploy commands + - precheck + - start + - host + - activate + - complete + non root/sudo users can run: + - host-list + - show + Deploy commands are region_restricted, which means + that they are not permitted to be run in DC + """ - # --- software is-available ------ - cmd = commands.add_parser( - 'is-available', - help='Query Available state for list of releases. Returns True if all are Available, False otherwise.' - ) - cmd.set_defaults(cmd='is-available') - cmd.set_defaults(func=release_is_available_req) - cmd.add_argument('release', - nargs="+", # accepts a list - help='List of releases') + cmd_area = 'deploy' + cmd_parser = subparsers.add_parser( + cmd_area, + help='Software Deploy', + epilog="StarlingX Unified Software Deployment" + ) + cmd_parser.set_defaults(cmd_area=cmd_area) + self.subcommands['deploy'] = cmd_parser - # --- software is-committed ------ - cmd = commands.add_parser( - 'is-committed', - help='Query Committed state for list of releases. Returns True if all are Committed, False otherwise.' - ) - cmd.set_defaults(cmd='is-committed') - cmd.set_defaults(func=release_is_committed_req) - cmd.add_argument('release', - nargs="+", # accepts a list - help='List of releases') + # Deploy commands are region_restricted, which means + # that they are not permitted to be run in DC + cmd_parser.set_defaults(region_restricted=True) - # --- software is-deployed ------ - cmd = commands.add_parser( - 'is-deployed', - help='Query Deployed state for list of releases. Returns True if all are Deployed, False otherwise.' - ) - cmd.set_defaults(cmd='is-deployed') - cmd.set_defaults(func=release_is_deployed_req) - cmd.add_argument('release', - nargs="+", # accepts a list - help='List of releases') + sub_cmds = cmd_parser.add_subparsers( + title='Software Deploy Commands:', + metavar='' + ) + sub_cmds.required = True - # --- software list --------------------------- - cmd = commands.add_parser( - 'list', - help='List the software releases' - ) - cmd.set_defaults(cmd='list') - cmd.set_defaults(func=release_list_req) - cmd.set_defaults(restricted=False) # can run non root - # --release is an optional argument - cmd.add_argument('--release', - required=False, - help='filter against a release ID') - # --state is an optional argument. default: "all" - cmd.add_argument('--state', - default="all", - required=False, - help='filter against a release state') + return sub_cmds - # --- software show ----------------- - cmd = commands.add_parser( - 'show', - help='Show the software release' - ) - cmd.set_defaults(cmd='show') - cmd.set_defaults(func=release_show_req) - cmd.set_defaults(restricted=False) # can run non root - cmd.add_argument('release', - nargs="+", # accepts a list - help='release ID to print detailed information') - cmd.add_argument('--packages', - required=False, - default=False, - action='store_true', - help='list packages contained in the release') + def _setup_debugging(self, debug): + if debug: + logging.basicConfig( + format="%(levelname)s (%(module)s:%(lineno)d) %(message)s", + level=logging.DEBUG) - # --- software upload --------------- - cmd = commands.add_parser( - 'upload', - help='Upload software major or patch releases' - ) - cmd.set_defaults(cmd='upload') - cmd.set_defaults(func=release_upload_req) - cmd.add_argument('release', - metavar='(iso + sig) | patch', - nargs="+", # accepts a list - help=('pair of install iso and sig files for major release ' - '(GA or patched) and/or one or more files containing a ' - 'patch release. NOTE: specify at most ONE pair of (iso + sig)')) - cmd.add_argument('--local', - required=False, - default=False, - action='store_true', - help='Upload files from active controller') + httplib2.debuglevel = 1 + else: + logging.basicConfig(format="%(levelname)s %(message)s", level=logging.CRITICAL) - # --- software upload-dir ------ - cmd = commands.add_parser( - 'upload-dir', - help='Upload a software release dir' - ) - cmd.set_defaults(cmd='upload-dir') - cmd.set_defaults(func=release_upload_dir_req) - cmd.add_argument('release', - nargs="+", # accepts a list - help='directory containing software releases to upload') + def main(self, argv): + # Parse args once to find version + parser = self.get_base_parser() + (options, args) = parser.parse_known_args(argv) + self._setup_debugging(options.debug) - register_deploy_commands(commands) - return parser + # build available subcommands based on version + api_version = options.system_api_version + subcommand_parser = self.get_subcommand_parser(api_version) + self.parser = subcommand_parser + # Handle top-level --help/-h before attempting to parse + # a command off the command line + if options.help or not argv: + self.do_help(options) + return 0 -def main(): - set_term_width() + # Parse args again and call whatever callback was selected + args = subcommand_parser.parse_args(argv) - rc = 0 - parser = setup_argparse() - argcomplete.autocomplete(parser) - args = parser.parse_args() - dc_request = check_for_os_region_name(args) + # Short-circuit and deal with help command right away. + if args.func == self.do_help: # pylint: disable=comparison-with-callable + self.do_help(args) + return 0 + elif args.func == self.do_bash_completion: # pylint: disable=comparison-with-callable + self.do_bash_completion(args) + return 0 - # Reject the commands that are not supported in the virtual region - if dc_request and args.region_restricted: - global VIRTUAL_REGION - print("\n%s %s command is not allowed in %s region" % (args.cmd_area, - args.cmd, - VIRTUAL_REGION)) - rc = 1 - exit(rc) + dc_request = check_for_os_region_name(args) - global auth_token - if not auth_token: - region = os.environ.get("OS_REGION_NAME", None) - auth_token, endpoint = get_auth_token_and_endpoint(region, 'public') - if endpoint is not None: - global api_addr - url = urlparse(endpoint) - address = format_url_address(url.hostname) - api_addr = '{}:{}'.format(address, url.port) - - if auth_token is None and os.geteuid() != 0: - if args.restricted: - print("Error: Command must be run as sudo or root", file=sys.stderr) + # Reject the commands that are not supported in the virtual region + if dc_request and args.region_restricted: + global VIRTUAL_REGION + print("\n%s command is not allowed in %s region" % (args.cmd_area, + VIRTUAL_REGION)) rc = 1 exit(rc) - # Call the function registered with argparse, and pass the 'args' to it - rc = args.func(args) - exit(rc) + endpoint_type = 'public' + if dc_request: + endpoint_type = 'internal' + + # Identify authentication mode [token, keystone, local_root] + if args.software_url and args.os_auth_token: + auth_mode = TOKEN + elif check_keystone_credentials(args): + auth_mode = KEYSTONE + elif os.geteuid() == 0: + auth_mode = LOCAL_ROOT + else: + exception_msg = ('Invalid authentication credentials. ' + 'Acceptable authentication modes are, ' + 'user-defined endpoint & token OR ' + 'keystone credentials OR ' + 'software commands as root (sudo)') + raise exc.CommandError(exception_msg) + + args.os_endpoint_type = endpoint_type + client = sclient.get_client(api_version, auth_mode, **(args.__dict__)) + + try: + args.func(client, args) + except exc.Unauthorized: + raise exc.CommandError("Invalid Identity credentials.") + except exc.HTTPForbidden: + raise exc.CommandError("Error: Forbidden") + + def do_bash_completion(self, args): + """Prints all of the commands and options to stdout. + """ + commands = set() + options = set() + for sc_str, sc in self.subcommands.items(): + commands.add(sc_str) + for option in list(sc._optionals._option_string_actions): + options.add(option) + + commands.remove('bash_completion') + print(' '.join(commands | options)) + + @utils.arg('command', metavar='', nargs='?', + help='Display help for ') + def do_help(self, args): + """Display help about this program or one of its subcommands.""" + if getattr(args, 'command', None): + if args.command in self.subcommands: + self.subcommands[args.command].print_help() + else: + raise exc.CommandError("'%s' is not a valid subcommand" % + args.command) + else: + self.parser.print_help() + + +class HelpFormatter(argparse.HelpFormatter): + def start_section(self, heading): + # Title-case the headings + heading = '%s%s' % (heading[0].upper(), heading[1:]) + super(HelpFormatter, self).start_section(heading) + + +def main(): + try: + SoftwareClientShell().main(sys.argv[1:]) + + except KeyboardInterrupt as e: + print(('caught: %r, aborting' % (e)), file=sys.stderr) + sys.exit(0) + + except IOError: + sys.exit(0) + + except Exception as e: + print(e, file=sys.stderr) + sys.exit(1) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/software-client/software_client/tests/test_shell.py b/software-client/software_client/tests/test_shell.py new file mode 100644 index 00000000..56921df0 --- /dev/null +++ b/software-client/software_client/tests/test_shell.py @@ -0,0 +1,106 @@ +# +# Copyright (c) 2013-2024 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# + +import fixtures +import httplib2 +from unittest import mock +import re +from six.moves import cStringIO as StringIO +import sys +from testtools import matchers + +import keystoneauth1 + +from software_client import exc +from software_client import software_client +from software_client.tests import utils + +FAKE_ENV = {'OS_USERNAME': 'username', + 'OS_PASSWORD': 'password', + 'OS_PROJECT_NAME': 'project', + 'OS_REGION_NAME': 'region', + 'OS_TENANT_NAME': 'tenant_name', + 'OS_AUTH_URL': 'http://no.where'} + + +class ShellTest(utils.BaseTestCase): + re_options = re.DOTALL | re.MULTILINE + + mock_endpoint_patcher = mock.patch.object(keystoneauth1.session.Session, + 'get_endpoint') + + # Patch os.environ to avoid required auth info. + def make_env(self, exclude=None): + env = dict((k, v) for k, v in FAKE_ENV.items() if k != exclude) + self.useFixture(fixtures.MonkeyPatch('os.environ', env)) + + def setUp(self): + super(ShellTest, self).setUp() + self.mock_endpoint = self.mock_endpoint_patcher.start() + + def tearDown(self): + super(ShellTest, self).tearDown() + self.mock_endpoint_patcher.stop() + + def shell(self, argstr): + orig = sys.stdout + try: + sys.stdout = StringIO() + _shell = software_client.SoftwareClientShell() + _shell.main(argstr.split()) + except SystemExit: + exc_type, exc_value, exc_traceback = sys.exc_info() + self.assertEqual(exc_value.code, 0) + finally: + out = sys.stdout.getvalue() + sys.stdout.close() + sys.stdout = orig + + return out + + def test_help_unknown_command(self): + self.assertRaises(exc.CommandError, self.shell, 'help foofoo') + + def test_debug(self): + httplib2.debuglevel = 0 + self.shell('--debug help') + self.assertEqual(httplib2.debuglevel, 1) + + def test_help(self): + required = [ + '.*?^usage: software', + '.*?^See "software help COMMAND" ' + 'for help on a specific command', + ] + for argstr in ['--help', 'help']: + help_text = self.shell(argstr) + for r in required: + self.assertThat(help_text, + matchers.MatchesRegex(r, + self.re_options)) + + def test_help_on_subcommand(self): + required = [ + r'.*?^usage: software list \[--release RELEASE\] \[--state STATE\]' + r'', + r".*?^List the software releases", + r'', + r".*?^Optional arguments:", + r".*?--release RELEASE filter against a release ID", + r".*?--state STATE filter against a release state", + ] + argstrings = [ + 'help list', + ] + for argstr in argstrings: + help_text = self.shell(argstr) + for r in required: + self.assertThat(help_text, + matchers.MatchesRegex(r, self.re_options)) + + def test_auth_param(self): + self.make_env(exclude='OS_USERNAME') + self.test_help() diff --git a/software-client/software_client/tests/test_software_client.py b/software-client/software_client/tests/test_software_client.py index cb3b1049..1c773783 100644 --- a/software-client/software_client/tests/test_software_client.py +++ b/software-client/software_client/tests/test_software_client.py @@ -1,7 +1,7 @@ # # SPDX-License-Identifier: Apache-2.0 # -# Copyright (c) 2023 Wind River Systems, Inc. +# Copyright (c) 2023-2024 Wind River Systems, Inc. # import json @@ -138,8 +138,7 @@ class SoftwareClientHelpTestCase(SoftwareClientTestCase, SoftwareClientNonRootMi """When no arguments are called, it should call print_usage""" shell_args = [self.PROG, ] self._test_method(shell_args=shell_args) - mock_usage.assert_called() - mock_help.assert_not_called() + mock_help.assert_called() mock_check.assert_not_called() @mock.patch('software_client.software_client.check_for_os_region_name') @@ -149,7 +148,6 @@ class SoftwareClientHelpTestCase(SoftwareClientTestCase, SoftwareClientNonRootMi """When -h is passed in, this should invoke print_help""" shell_args = [self.PROG, "-h"] self._test_method(shell_args=shell_args) - mock_usage.assert_not_called() mock_help.assert_called() mock_check.assert_not_called() @@ -160,6 +158,5 @@ class SoftwareClientHelpTestCase(SoftwareClientTestCase, SoftwareClientNonRootMi """invalid args should invoke print_usage""" shell_args = [self.PROG, "invalid_arg"] self._test_method(shell_args=shell_args) - mock_usage.assert_called() mock_help.assert_not_called() mock_check.assert_not_called() diff --git a/software-client/software_client/tests/utils.py b/software-client/software_client/tests/utils.py new file mode 100644 index 00000000..409dd809 --- /dev/null +++ b/software-client/software_client/tests/utils.py @@ -0,0 +1,84 @@ +# Copyright 2024 Wind River Systems, Inc. +# Copyright 2012 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import copy +import fixtures +import testtools + +from software_client.common import http +from six import StringIO + + +class BaseTestCase(testtools.TestCase): + + def setUp(self): + super(BaseTestCase, self).setUp() + self.useFixture(fixtures.FakeLogger()) + + +class FakeAPI(object): + def __init__(self, fixtures): + self.fixtures = fixtures + self.calls = [] + + def _request(self, method, url, headers=None, body=None): + call = (method, url, headers or {}, body) + self.calls.append(call) + return self.fixtures[url][method] + + def raw_request(self, *args, **kwargs): + fixture = self._request(*args, **kwargs) + body_iter = http.ResponseBodyIterator(StringIO(fixture[1])) + return FakeResponse(fixture[0]), body_iter + + def json_request(self, *args, **kwargs): + fixture = self._request(*args, **kwargs) + return FakeResponse(fixture[0]), fixture[1] + + def upload_request_with_multipart(self, *args, **kwargs): + # TODO(gdossant): add 'data' parameter to _request method. + # It will impact more than 40 tests and must be done in + # a specific commit. + + kwargs.pop('check_exceptions') + data = kwargs.pop('data') + + fixture = self._request(*args, **kwargs) + + call = list(self.calls[0]) + call.append(data) + self.calls[0] = tuple(call) + + return fixture[1] + + +class FakeResponse(object): + def __init__(self, headers, body=None, version=None): + """:param headers: dict representing HTTP response headers + :param body: file-like object + """ + self.headers = headers + self.body = body + self.status_code = 200 + + def getheaders(self): + return copy.deepcopy(self.headers).items() + + def getheader(self, key, default): + return self.headers.get(key, default) + + def read(self, amt): + return self.body.read(amt) diff --git a/software-client/software_client/tests/v1/__init__.py b/software-client/software_client/tests/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/software-client/software_client/tests/v1/test_deploy.py b/software-client/software_client/tests/v1/test_deploy.py new file mode 100644 index 00000000..0d1f0372 --- /dev/null +++ b/software-client/software_client/tests/v1/test_deploy.py @@ -0,0 +1,180 @@ +# Copyright (c) 2019-2024 Wind River Systems, Inc. +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import testtools + +from software_client.tests import utils +import software_client.v1.deploy +import software_client.v1.deploy_shell + + +HOST_LIST = {'data': [{ + 'ip': '192.168.204.2', + 'hostname': 'controller-0', + 'deployed': True, + 'secs_since_ack': 20, + 'patch_failed': True, + 'stale_details': False, + 'latest_sysroot_commit': '95139a5067', + 'nodetype': 'controller', + 'subfunctions': ['controller', 'worker'], + 'sw_version': '24.03', + 'state': 'install-failed', + 'allow_insvc_patching': True, + 'interim_state': False, + 'reboot_required': False}] +} + + +fixtures = { + '/v1/software/host_list': + { + 'GET': ( + {}, + HOST_LIST, + ), + }, + '/v1/software/deploy_show': + { + 'GET': ( + {}, + {}, + ), + }, + '/v1/software/deploy_precheck/1': + { + 'GET': ( + {}, + {}, + ), + }, + '/v1/software/deploy_precheck/1/force?region_name=RegionOne': + { + 'POST': ( + {}, + {}, + ), + }, + '/v1/software/deploy_start/1/force': + { + 'POST': ( + {}, + {}, + ), + }, + '/v1/software/deploy_host/1/force': + { + 'POST': ( + {}, + {"error": True}, + ), + }, + '/v1/software/deploy_activate/1': + { + 'POST': ( + {}, + {}, + ), + }, + '/v1/software/deploy_complete/1': + { + 'POST': ( + {}, + {}, + ), + }, +} + +class Args: + def __init__(self, **kwargs): + for key, value in kwargs.items(): + if isinstance(value, dict): + self.__dict__[key] = Args(**value) + else: + self.__dict__[key] = value + + +class DeployManagerTest(testtools.TestCase): + + def setUp(self): + super(DeployManagerTest, self).setUp() + self.api = utils.FakeAPI(fixtures) + self.mgr = software_client.v1.deploy.DeployManager(self.api) + + def test_host_list(self): + hosts = self.mgr.host_list() + expect = [ + ('GET', '/v1/software/host_list', {}, None), + ] + self.assertEqual(self.api.calls, expect) + self.assertEqual(len(hosts), 2) + self.assertEqual(hosts[1]['data'][0]['hostname'], + HOST_LIST['data'][0]['hostname']) + + def test_show(self): + deploy = self.mgr.show() + expect = [ + ('GET', '/v1/software/deploy_show', {}, None), + ] + self.assertEqual(self.api.calls, expect) + + def test_precheck(self): + input = {'deployment': '1', 'region_name': 'RegionOne', 'force': 1} + args = Args(**input) + check = self.mgr.precheck(args) + expect = [ + ('POST', '/v1/software/deploy_precheck/1/force?region_name=RegionOne', {}, {}), + ] + self.assertEqual(self.api.calls, expect) + self.assertEqual(len(check), 2) + + def test_start(self): + input = {'deployment': '1', 'force': 1} + args = Args(**input) + resp = self.mgr.start(args) + expect = [ + ('POST', '/v1/software/deploy_start/1/force', {}, {}), + ] + self.assertEqual(self.api.calls, expect) + self.assertEqual(len(resp), 2) + + def test_host(self): + input = {'agent': '1', 'force': 1} + args = Args(**input) + resp = self.mgr.host(args) + expect = [ + ('POST', '/v1/software/deploy_host/1/force', {}, {}), + ] + self.assertEqual(self.api.calls, expect) + + def test_activate(self): + input = {'deployment': '1'} + args = Args(**input) + resp = self.mgr.activate(args) + expect = [ + ('POST', '/v1/software/deploy_activate/1', {}, {}), + ] + self.assertEqual(self.api.calls, expect) + self.assertEqual(len(resp), 2) + + def test_complete(self): + input = {'deployment': '1'} + args = Args(**input) + resp = self.mgr.complete(args) + expect = [ + ('POST', '/v1/software/deploy_complete/1', {}, {}), + ] + self.assertEqual(self.api.calls, expect) + self.assertEqual(len(resp), 2) diff --git a/software-client/software_client/tests/v1/test_release.py b/software-client/software_client/tests/v1/test_release.py new file mode 100644 index 00000000..ebfd4bdd --- /dev/null +++ b/software-client/software_client/tests/v1/test_release.py @@ -0,0 +1,196 @@ +# Copyright (c) 2013-2024 Wind River Systems, Inc. +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +import testtools + +from software_client.tests import utils +import software_client.v1.release + +RELEASE = { + 'sd': + {'starlingx-24.03.0': { + 'state': 'deployed', + 'sw_version': '24.03.0', + 'status': 'REL', + 'unremovable': 'Y', + 'summary': 'STX 24.03 GA release', + 'description': 'STX 24.03 major GA release', + 'install_instructions': '', + 'warnings': '', + 'apply_active_release_only': '', + 'reboot_required': 'Y', + 'requires': [], + 'packages': [] + } + } +} + +fixtures = { + '/v1/software/query?show=all': + { + 'GET': ( + {}, + {'sd': RELEASE['sd']}, + ), + }, + '/v1/software/show/1': + { + 'POST': ( + {}, + True, + ), + }, + '/v1/software/delete/1': + { + 'POST': ( + {}, + {}, + ), + }, + '/v1/software/is_available/1': + { + 'POST': ( + {}, + True, + ), + }, + '/v1/software/is_deployed/1': + { + 'POST': ( + {}, + False, + ), + }, + '/v1/software/is_committed/1': + { + 'POST': ( + {}, + False, + ), + }, + '/v1/software/install_local': + { + 'GET': ( + {}, + {}, + ), + }, +} + + +class Args: + def __init__(self, **kwargs): + for key, value in kwargs.items(): + if isinstance(value, dict): + self.__dict__[key] = Args(**value) + else: + self.__dict__[key] = value + + +class ReleaseManagerTest(testtools.TestCase): + + def setUp(self): + super(ReleaseManagerTest, self).setUp() + self.api = utils.FakeAPI(fixtures) + self.mgr = software_client.v1.release.ReleaseManager(self.api) + + def test_release_list(self): + input = {"state": "all", "release": ""} + args = Args(**input) + release = self.mgr.list(args) + expect = [ + ('GET', '/v1/software/query?show=all', {}, None), + ] + self.assertEqual(self.api.calls, expect) + self.assertEqual(len(release), 2) + + def test_release_show(self): + input = {"state": "", "release": "1"} + args = Args(**input) + release = self.mgr.show(args) + expect = [ + ('POST', '/v1/software/show/1', {}, {}), + ] + self.assertEqual(self.api.calls, expect) + self.assertEqual(len(release), 2) + + def test_release_delete(self): + response = self.mgr.release_delete("1") + expect = [ + ('POST', '/v1/software/delete/1', {}, {}), + ] + self.assertEqual(self.api.calls, expect) + self.assertEqual(len(response), 2) + + def test_is_available(self): + response = self.mgr.is_available('1') + expect = [ + ('POST', '/v1/software/is_available/1', {}, {}), + ] + self.assertEqual(self.api.calls, expect) + self.assertTrue(response[1], True) + + def test_is_deployed(self): + response = self.mgr.is_deployed('1') + expect = [ + ('POST', '/v1/software/is_deployed/1', {}, {}), + ] + self.assertEqual(self.api.calls, expect) + self.assertFalse(response[1], False) + + def test_is_committed(self): + response = self.mgr.is_committed('1') + expect = [ + ('POST', '/v1/software/is_committed/1', {}, {}), + ] + self.assertEqual(self.api.calls, expect) + self.assertFalse(response[1], True) + + def test_upload(self): + input = {'release': '1', 'local': ''} + args = Args(**input) + response = self.mgr.upload(args) + expect = [ + ('POST', '/v1/software/upload', {}, {}), + ] + self.assertNotEqual(self.api.calls, expect) + self.assertEqual(response, 0) + + def test_upload_dir(self): + input = {'release': '1'} + args = Args(**input) + response = self.mgr.upload_dir(args) + expect = [ + ('POST', '/v1/software/upload', {}, {}), + ] + self.assertNotEqual(self.api.calls, expect) + self.assertEqual(response, 0) + + def test_install_local(self): + self.mgr.install_local() + expect = [ + ('GET', '/v1/software/install_local', {}, None), + ] + self.assertEqual(self.api.calls, expect) + + def test_commit_patch(self): + input = {'sw_version': '1', 'all': ''} + args = Args(**input) + kernel = self.mgr.commit_patch(args) + expect = [ + ('GET', '/v1/software/commit_patch/1', {}, None), + ] + self.assertNotEqual(self.api.calls, expect) diff --git a/software-client/software_client/v1/__init__.py b/software-client/software_client/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/software-client/software_client/v1/client.py b/software-client/software_client/v1/client.py new file mode 100644 index 00000000..e9f19075 --- /dev/null +++ b/software-client/software_client/v1/client.py @@ -0,0 +1,34 @@ +# Copyright (c) 2013-2024 Wind River Systems, Inc. +# Copyright 2012-2024 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +from software_client.common import http +from software_client.v1 import release +from software_client.v1 import deploy + + +class Client(object): + """Client for the Software v1 API. + + """ + + def __init__(self, *args, **kwargs): + """Initialize a new client for the Software v1 API.""" + super(Client, self).__init__() + self.http_client = http.construct_http_client(*args, **kwargs) + + self.release = release.ReleaseManager(self.http_client) + self.deploy = deploy.DeployManager(self.http_client) diff --git a/software-client/software_client/v1/deploy.py b/software-client/software_client/v1/deploy.py new file mode 100644 index 00000000..30a4c53a --- /dev/null +++ b/software-client/software_client/v1/deploy.py @@ -0,0 +1,218 @@ +# +# Copyright (c) 2015-2024 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# + +import re +import requests +import signal +import sys +import time + +from software_client.common import base +from software_client.common import utils +from software_client import constants + + +class Deploy(base.Resource): + def __repr__(self): + return "
" % self._info + + +class DeployManager(base.Manager): + resource_class = Deploy + + def precheck(self, args): + # args.deployment is a string + deployment = args.deployment + + # args.region is a string + region_name = args.region_name + + path = "/v1/software/deploy_precheck/%s" % (deployment) + if args.force: + path += "/force" + path += "?region_name=%s" % region_name + + return self._create(path, body={}) + + def start(self, args): + # args.deployment is a string + deployment = args.deployment + + # Ignore interrupts during this function + signal.signal(signal.SIGINT, signal.SIG_IGN) + + # Issue deploy_start request + if args.force: + path = "/v1/software/deploy_start/%s/force" % (deployment) + else: + path = "/v1/software/deploy_start/%s" % (deployment) + + return self._create(path, body={}) + + def host(self, args): + # args.deployment is a string + agent_ip = args.agent + + # Issue deploy_host request and poll for results + path = "/v1/software/deploy_host/%s" % (agent_ip) + + if args.force: + path += "/force" + + req, data = self._create(path, body={}) + if req.status_code == 200: + if 'error' in data and data["error"] != "": + print("Error:") + print(data["error"]) + rc = 1 + else: + rc = self.wait_for_install_complete(agent_ip) + elif req.status_code == 500: + print("An internal error has occurred. " + "Please check /var/log/software.log for details") + rc = 1 + else: + m = re.search("(Error message:.*)", req.text, re.MULTILINE) + if m: + print(m.group(0)) + else: + print("%s %s" % (req.status_code, req.reason)) + rc = 1 + return rc + + def activate(self, args): + # args.deployment is a string + deployment = args.deployment + + # Ignore interrupts during this function + signal.signal(signal.SIGINT, signal.SIG_IGN) + + # Issue deploy_start request + path = "/v1/software/deploy_activate/%s" % (deployment) + + return self._create(path, body={}) + + def complete(self, args): + # args.deployment is a string + deployment = args.deployment + + # Ignore interrupts during this function + signal.signal(signal.SIGINT, signal.SIG_IGN) + + # Issue deploy_start request + path = "/v1/software/deploy_complete/%s" % (deployment) + + return self._create(path, body={}) + + def host_list(self): + path = '/v1/software/host_list' + return self._list(path, "") + + def show(self): + path = '/v1/software/deploy' + req, data = self._list(path, "") + + 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 + + if not data or data.get("data"): + print("No deploy in progress.") + else: + data = data.get("data") + data = data[0] + 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 wait_for_install_complete(self, agent_ip): + url = "/v1/software/host_list" + rc = 0 + + max_retries = 4 + retriable_count = 0 + + while True: + # Sleep on the first pass as well, to allow time for the + # agent to respond + time.sleep(5) + + try: + req, data = self._list(url) + except requests.exceptions.ConnectionError: + # The local software-controller may have restarted. + retriable_count += 1 + if retriable_count <= max_retries: + continue + else: + print("Lost communications with the software controller") + rc = 1 + break + + if req.status_code == 200: + data = data.get("data", None) + if not data: + print("Invalid host-list data returned:") + utils.print_result_debug(req, data) + rc = 1 + break + + host_state = None + + for d in data: + if d['hostname'] == agent_ip: + host_state = d.get('host_state') + + if host_state == constants.DEPLOYING: + # Still deploying + sys.stdout.write(".") + sys.stdout.flush() + elif host_state == constants.FAILED: + print("\nDeployment failed. Please check logs for details.") + rc = 1 + break + elif host_state == constants.DEPLOYED: + print("\nDeployment was successful.") + rc = 0 + break + else: + print("\nReported unknown state: %s" % host_state) + rc = 1 + break + + elif req.status_code == 500: + print("An internal error has occurred. Please check /var/log/software.log for details") + rc = 1 + break + else: + m = re.search("(Error message:.*)", req.text, re.MULTILINE) + if m: + print(m.group(0)) + else: + print(vars(req)) + rc = 1 + break + + return rc diff --git a/software-client/software_client/v1/deploy_cmd.py b/software-client/software_client/v1/deploy_cmd.py new file mode 100644 index 00000000..20cd482f --- /dev/null +++ b/software-client/software_client/v1/deploy_cmd.py @@ -0,0 +1,41 @@ +# +# Copyright (c) 2013-2024 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# +# + +from software_client.common import utils +from software_client.v1 import deploy_shell + + +DEPLOY_COMMAND_MODULES = [ + deploy_shell, +] + +# sofware deploy commands +# - precheck +# - start +# - host +# - activate +# - complete +# non root/sudo users can run: +# - host-list +# - show +# Deploy commands are region_restricted, which means +# that they are not permitted to be run in DC +# +# UN_RESTRICTED_COMMANDS is used to set argparser argument 'restricted' to False +UN_RESTRICTED_COMMANDS = ['show', 'host-list'] + + +def enhance_parser(parser, subparsers, cmd_mapper): + '''Take a basic (nonversioned) parser and enhance it with + commands and options specific for this version of API. + + :param parser: top level parser :param subparsers: top level + parser's subparsers collection where subcommands will go + ''' + for command_module in DEPLOY_COMMAND_MODULES: + utils.define_commands_from_module(subparsers, command_module, + cmd_mapper, UN_RESTRICTED_COMMANDS) diff --git a/software-client/software_client/v1/deploy_shell.py b/software-client/software_client/v1/deploy_shell.py new file mode 100644 index 00000000..1f7787a4 --- /dev/null +++ b/software-client/software_client/v1/deploy_shell.py @@ -0,0 +1,112 @@ +# +# Copyright (c) 2024 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# + +from software_client.common import utils + + +# --deployment is an optional argument +@utils.arg('--deployment', + required=False, + help='List the deployment specified') +# --state is an optional argument. +# default: "all" +# acceptable values: inactive, active, prestaging, prestaged, all +@utils.arg('--state', + choices=['inactive', 'active', 'prestaging', 'prestaged', 'all'], + default="all", + required=False, + help="List all deployments that have this state") +def do_show(cc, args): + """Show the software deployments states""" + return cc.deploy.show() + + +def do_host_list(cc, args): + """List of hosts for software deployment """ + req, data = cc.deploy.host_list() + if args.debug: + utils.print_result_debug(req, data) + else: + utils.print_software_deploy_host_list_result(req, data) + + return utils.check_rc(req, data) + + +@utils.arg('deployment', + help='Verify if prerequisites are met for this Deployment ID') +@utils.arg('-f', + '--force', + action='store_true', + required=False, + help='Allow bypassing non-critical checks') +@utils.arg('--region_name', + default='RegionOne', + required=False, + help='Run precheck against a subcloud') +def do_precheck(cc, args): + """Verify whether prerequisites for installing the software deployment are satisfied""" + req, data = cc.deploy.precheck(args) + if args.debug: + utils.print_result_debug(req, data) + else: + utils.print_software_op_result(req, data) + + return utils.check_rc(req, data) + + +@utils.arg('deployment', + help='Deployment ID to start') +@utils.arg('-f', + '--force', + action='store_true', + required=False, + help='Allow bypassing non-critical checks') +def do_start(cc, args): + """Start the software deployment""" + req, data = cc.deploy.start(args) + if args.debug: + utils.print_result_debug(req, data) + else: + utils.print_software_op_result(req, data) + + return utils.check_rc(req, data) + + +@utils.arg('agent', + help="Agent on which host deploy is triggered") +@utils.arg('-f', + '--force', + action='store_true', + required=False, + help="Force deploy host") +def do_host(cc, args): + """Deploy prestaged software deployment to the host""" + return cc.deploy.host(args) + + +@utils.arg('deployment', + help='Deployment ID to activate') +def do_activate(cc, args): + """Activate the software deployment""" + req, data = cc.deploy.activate(args) + if args.debug: + utils.print_result_debug(req, data) + else: + utils.print_software_op_result(req, data) + + return utils.check_rc(req, data) + +@utils.arg('deployment', + help='Deployment ID to complete') +def do_complete(cc, args): + """Complete the software deployment""" + req, data = cc.deploy.complete(args) + if args.debug: + utils.print_result_debug(req, data) + else: + utils.print_software_op_result(req, data) + + return utils.check_rc(req, data) diff --git a/software-client/software_client/v1/release.py b/software-client/software_client/v1/release.py new file mode 100644 index 00000000..1f3f248f --- /dev/null +++ b/software-client/software_client/v1/release.py @@ -0,0 +1,266 @@ +# +# Copyright (c) 2015-2024 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# + +import json +import os +import signal +import sys +import textwrap + +from requests_toolbelt import MultipartEncoder +from tsconfig.tsconfig import SW_VERSION as RUNNING_SW_VERSION + +from software_client.common import base +from software_client.common import utils +from software_client import constants + + +class Release(base.Resource): + def __repr__(self): + return "" % self._info + + +class ReleaseManager(base.Manager): + resource_class = Release + + def list(self, args): + state = args.state # defaults to "all" + extra_opts = "" + if args.release: + extra_opts = "&release=%s" % args.release + path = "/v1/software/query?show=%s%s" % (state, extra_opts) + return self._list(path, "") + + def is_available(self, release): + releases = "/".join(release) + path = '/v1/software/is_available/%s' % (releases) + return self._create(path, body={}) + + def is_deployed(self, release): + releases = "/".join(release) + path = '/v1/software/is_deployed/%s' % (releases) + return self._create(path, body={}) + + def is_committed(self, release): + releases = "/".join(release) + path = '/v1/software/is_committed/%s' % (releases) + return self._create(path, body={}) + + def upload(self, args): + rc = 0 + + # arg.release is a list + releases = args.release + is_local = args.local # defaults to False + + # Ignore interrupts during this function + signal.signal(signal.SIGINT, signal.SIG_IGN) + + to_upload_files = {} + valid_files = [] + invalid_files = [] + + # Validate all the files + valid_files = [os.path.abspath(software_file) for software_file in releases if os.path.isfile( + software_file) and os.path.splitext(software_file)[1] in constants.SUPPORTED_UPLOAD_FILE_EXT] + invalid_files = [os.path.abspath(software_file) for software_file in releases + if os.path.abspath(software_file) not in valid_files] + + for software_file in invalid_files: + if os.path.isdir(software_file): + print("Error: %s is a directory. Please use upload-dir" % software_file) + elif os.path.isfile(software_file): + print("Error: %s has the unsupported file extension." % software_file) + else: + print("Error: File does not exist: %s" % software_file) + + if len(valid_files) == 0: + print("No file to be uploaded.") + return rc + + path = '/v1/software/upload' + if is_local: + to_upload_filenames = json.dumps(valid_files) + headers = {'Content-Type': 'text/plain'} + return self._create(path, body=to_upload_filenames, headers=headers) + else: + for software_file in valid_files: + with open(software_file, 'rb') as file: + data_content = file.read() + to_upload_files[software_file] = (software_file, data_content) + + encoder = MultipartEncoder(fields=to_upload_files) + headers = {'Content-Type': encoder.content_type} + return self._create_multipart(path, body=encoder, headers=headers) + + def upload_dir(self, args): + # arg.release is a list + release_dirs = args.release + + # Ignore interrupts during this function + signal.signal(signal.SIGINT, signal.SIG_IGN) + + to_upload_files = {} + raw_files = [] + + # Find all files that need to be uploaded in given directories + for release_dir in release_dirs: + if os.path.isdir(release_dir): + raw_files = [f for f in os.listdir(release_dir) + if os.path.isfile(os.path.join(release_dir, f))] + + # Get absolute path of files + raw_files = [os.path.abspath(os.path.join(release_dir, f)) for f in raw_files] + else: + print("Skipping invalid directory: %s" % release_dir, file=sys.stderr) + + if len(raw_files) == 0: + print("No file to upload") + return 0 + + temp_iso_files = [f for f in raw_files if f.endswith(constants.ISO_EXTENSION)] + if len(temp_iso_files) > 1: # Verify that only one ISO file is being uploaded + print("Only one ISO file can be uploaded at a time. Found: %s" % + temp_iso_files, file=sys.stderr) + return 1 + + temp_sig_files = [f for f in raw_files if f.endswith(constants.SIG_EXTENSION)] + if len(temp_sig_files) > 1: # Verify that only one SIG file is being uploaded + print("Only one SIG file can be uploaded at a time. Found: %s" % + temp_sig_files, file=sys.stderr) + return 1 + + + for software_file in sorted(set(raw_files)): + _, ext = os.path.splitext(software_file) + if ext in constants.SUPPORTED_UPLOAD_FILE_EXT: + to_upload_files[software_file] = (software_file, open(software_file, 'rb')) + + encoder = MultipartEncoder(fields=to_upload_files) + headers = {'Content-Type': encoder.content_type} + path = '/v1/software/upload' + req, data = self._create_multipart(path, body=encoder, headers=headers) + if args.debug: + utils.print_result_debug(req, data) + else: + utils.print_software_op_result(req, data) + return utils.check_rc(req, data) + + def commit_patch(self, args): + # Ignore interrupts during this function + signal.signal(signal.SIGINT, signal.SIG_IGN) + + # Default to running release + # this all needs to be changed + relopt = RUNNING_SW_VERSION + + # append_auth_token_if_required(headers) + if args.sw_version and not args.all: + # Disallow + print("Use of --sw-version option requires --all") + return 1 + elif args.all: + # Get a list of all patches + extra_opts = "&release=%s" % relopt + url = "/v1/software/query?show=patch%s" % (extra_opts) + + resp, body = self._list(url, "") + + patch_list = [] + if resp.status_code == 200: + data = body + + if 'sd' in data: + patch_list = sorted(list(data['sd'])) + elif resp.status_code == 500: + print("Failed to get patch list. Aborting...") + return 1 + + if len(patch_list) == 0: + print("There are no %s patches to commit." % relopt) + return 0 + + print("The following patches will be committed:") + for patch_id in patch_list: + print(" %s" % patch_id) + print() + + patches = "/".join(patch_list) + else: + # args.patch is a list + patches = "/".join(args.patch) + + # First, get a list of dependencies and ask for confirmation + url = "/v1/software/query_dependencies/%s?recursive=yes" % (patches) + + resp, body = self._list(url, "") + + if resp.status_code == 200: + data = body + + if 'patches' in data: + print("The following patches will be committed:") + for release_id in sorted(data['patches']): + print(" %s" % release_id) + print() + else: + print("No patches found to commit") + return 1 + + elif resp.status_code == 500: + print("An internal error has occurred. Please check /var/log/software.log for details") + return 1 + + # Run dry-run + url = "/v1/software/commit_dry_run/%s" % (patches) + + resp, body = self._create(url, body={}) + utils.print_software_op_result(resp, body) + + if utils.check_rc(resp, body) != 0: + print("Aborting...") + return 1 + + if args.dry_run: + return 0 + + print() + commit_warning = "WARNING: Committing a patch is an irreversible operation. " + \ + "Committed patches cannot be removed." + print(textwrap.fill(commit_warning, width=utils.TERM_WIDTH, subsequent_indent=' ' * 9)) + print() + + user_input = input("Would you like to continue? [y/N]: ") + if user_input.lower() != 'y': + print("Aborting...") + return 1 + + url = "/v1/software/commit_patch/%s" % (patches) + req = self._create(url, body={}) + + if args.debug: + utils.print_result_debug(req) + else: + utils.print_software_op_result(req) + return + + def install_local(self): + # Ignore interrupts during this function + signal.signal(signal.SIGINT, signal.SIG_IGN) + + path = "/v1/software/install_local" + return self._list(path, "") + + def show(self, args): + releases = "/".join(args.release) + + path = "/v1/software/show/%s" % (releases) + return self._create(path, body={}) + + def release_delete(self, release_id): + release_ids = "/".join(release_id) + path = '/v1/software/delete/%s' % release_ids + return self._create(path, body={}) diff --git a/software-client/software_client/v1/release_shell.py b/software-client/software_client/v1/release_shell.py new file mode 100644 index 00000000..601836ef --- /dev/null +++ b/software-client/software_client/v1/release_shell.py @@ -0,0 +1,196 @@ +# +# Copyright (c) 2015-2024 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# + +from software_client.common import utils + + +# --release is an optional argument +@utils.arg('--release', + required=False, + help='filter against a release ID') +# --state is an optional argument. default: "all" +@utils.arg('--state', + default="all", + required=False, + help='filter against a release state') +def do_list(cc, args): + """List the software releases""" + req, data = cc.release.list(args) + if args.debug: + utils.print_result_debug(req, data) + else: + header_data_list = ["Release", "RR", "State"] + data_list = [(k, v["reboot_required"], v["state"]) for k, v in data["sd"].items()] + has_error = 'error' in data and data["error"] + utils.print_result_list(header_data_list, data_list, has_error) + + return utils.check_rc(req, data) + + +@utils.arg('release', + nargs="+", # accepts a list + help='Release ID to print detailed information') +@utils.arg('--packages', + required=False, + default=False, + action='store_true', + help='list packages contained in the release') +def do_show(cc, args): + """Show the software release""" + list_packages = args.packages + req, data = cc.release.show(args) + if args.debug: + utils.print_result_debug(req, data) + else: + utils.print_release_show_result(req, data, list_packages=list_packages) + + return utils.check_rc(req, data) + + +@utils.arg('patch', + nargs="+", # accepts a list + help='Patch ID/s to commit') +# --dry-run is an optional argument +@utils.arg('--dry-run', + action='store_true', + required=False, + help='Check the space savings without committing the patch') +# --all is an optional argument +@utils.arg('--all', + action='store_true', + required=False, + help='Commit all the applied patches') +# --sw-version is an optional argument +@utils.arg('--sw-version', + required=False, + help='Software release version') +def do_commit_patch(cc, args): + """Commit patches to free disk space. WARNING: This action is irreversible!""" + return cc.release.commit_patch(args) + + +def do_install_local(cc, args): + """ Trigger patch install/remove on the local host. + This command can only be used for patch installation + prior to initial configuration.""" + req, data = cc.release.install_local() + if args.debug: + utils.print_result_debug(req, data) + else: + utils.print_software_op_result(req, data) + + return utils.check_rc(req, data) + + +@utils.arg('release', + nargs="+", # accepts a list + help='List of releases') +def do_is_available(cc, args): + """Query Available state for list of releases. + Returns True if all are Available, False otherwise.""" + req, result = cc.release.is_available(args.release) + rc = 1 + if req.status_code == 200: + print(result) + if result is True: + rc = 0 + elif req.status_code == 500: + 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 + + +@utils.arg('release', + nargs="+", # accepts a list + help='List of releases') +def do_is_deployed(cc, args): + """Query Deployed state for list of releases. + Returns True if all are Deployed, False otherwise.""" + req, result = cc.release.is_deployed(args.release) + rc = 1 + if req.status_code == 200: + print(result) + if result is True: + rc = 0 + elif req.status_code == 500: + 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 + + +@utils.arg('release', + nargs="+", # accepts a list + help='List of releases') +def do_is_committed(cc, args): + """Query Committed state for list of releases. + Returns True if all are Committed, False otherwise.""" + req, result = cc.release.is_committed(args.release) + rc = 1 + if req.status_code == 200: + print(result) + if result is True: + rc = 0 + elif req.status_code == 500: + 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 + + +@utils.arg('release', + metavar='(iso + sig) | patch', + nargs="+", # accepts a list + help='Software releases to upload') +@utils.arg('--local', + required=False, + default=False, + action='store_true', + help=('pair of install iso and sig files for major release ' + '(GA or patched) and/or one or more files containing a ' + 'patch release. NOTE: specify at most ONE pair of (iso + sig)')) +def do_upload(cc, args): + """Upload a software release""" + req, data = cc.release.upload(args) + if args.debug: + utils.print_result_debug(req, data) + else: + utils.print_software_op_result(req, data) + data_list = [(k, v["id"]) + for d in data["upload_info"] for k, v in d.items() + if not k.endswith(".sig")] + + header_data_list = ["Uploaded File", "Id"] + has_error = 'error' in data and data["error"] + utils.print_result_list(header_data_list, data_list, has_error) + rc = 0 + if utils.check_rc(req, data) != 0: + # We hit a failure. Update rc but keep looping + rc = 1 + return rc + + +@utils.arg('release', + nargs="+", # accepts a list + help='Directory containing software releases to upload') +def do_upload_dir(cc, args): + """Upload a software release dir""" + return cc.release.upload_dir(args) + + +@utils.arg('release', + nargs="+", # accepts a list + help='Release ID to delete') +def do_delete(cc, args): + """Delete the software release""" + resp, body = cc.release.release_delete(args.release) + if args.debug: + utils.print_result_debug(resp, body) + else: + utils.print_software_op_result(resp, body) + + return utils.check_rc(resp, body) diff --git a/software-client/software_client/v1/shell.py b/software-client/software_client/v1/shell.py new file mode 100644 index 00000000..8fbe4e06 --- /dev/null +++ b/software-client/software_client/v1/shell.py @@ -0,0 +1,26 @@ +# +# Copyright (c) 2013-2024 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# +# + +from software_client.common import utils +from software_client.v1 import release_shell + + +COMMAND_MODULES = [ + release_shell, +] + + +def enhance_parser(parser, subparsers, cmd_mapper): + '''Take a basic (nonversioned) parser and enhance it with + commands and options specific for this version of API. + + :param parser: top level parser :param subparsers: top level + parser's subparsers collection where subcommands will go + ''' + for command_module in COMMAND_MODULES: + utils.define_commands_from_module(subparsers, command_module, + cmd_mapper) diff --git a/software-client/test-requirements.txt b/software-client/test-requirements.txt index 43806539..83c0f09a 100644 --- a/software-client/test-requirements.txt +++ b/software-client/test-requirements.txt @@ -3,5 +3,6 @@ hacking bandit coverage +httplib2 pylint stestr