Add support for https in USM software client
This change add support for https with SSL protocol and certificate. The USM client can work with either insecure (disable SSL/TLS certificate verification) or with SSL certificate. The client is also modified to support sessions and versions. These changes are adapted from cgtsclient. This adds three authorization modes, [token, keystone & local-root]. In token mode, a keystone token and software-url is used for auth. Eg: $ software \ --software-url "http://192.168.204.1:5497" \ --os-auth-token "${TOKEN}" list In keystone mode, sourced keystone configs in env is used for auth. Eg: $ source /etc/platform/openrc; software list In local-root mode, authorization is by privileged user (root/sudo) of the controller where software application is running. Eg: $ sudo software list Optional arguments specific to https: -k, --insecure --cert-file CERT_FILE --key-file KEY_FILE --ca-file CA_FILE Example usage for insecure connection: software -k list Story: 2010676 Task: 49666 Test Plan: PASS: Verify software cli output for http endpoints PASS: Verify software cli output for https endpoints Change-Id: I2e2ff115b8d03cddb02e026da84f389918238dab Signed-off-by: Joseph Vazhappilly <joseph.vazhappillypaily@windriver.com>
This commit is contained in:
parent
202751d57b
commit
0cd1d59425
|
@ -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"
|
||||||
|
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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))
|
|
@ -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_IPV4 = 4
|
||||||
ADDRESS_VERSION_IPV6 = 6
|
ADDRESS_VERSION_IPV6 = 6
|
||||||
CONTROLLER_FLOATING_HOSTNAME = "controller"
|
CONTROLLER_FLOATING_HOSTNAME = "controller"
|
||||||
|
@ -64,3 +64,8 @@ SCRATCH_DIR = "/scratch"
|
||||||
DEPLOYING = 'deploying'
|
DEPLOYING = 'deploying'
|
||||||
FAILED = 'failed'
|
FAILED = 'failed'
|
||||||
PENDING = 'pending'
|
PENDING = 'pending'
|
||||||
|
|
||||||
|
# Authorization modes of software cli
|
||||||
|
KEYSTONE = 'keystone'
|
||||||
|
TOKEN = 'token'
|
||||||
|
LOCAL_ROOT = 'local_root'
|
||||||
|
|
|
@ -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
|
File diff suppressed because it is too large
Load Diff
|
@ -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()
|
|
@ -1,7 +1,7 @@
|
||||||
#
|
#
|
||||||
# SPDX-License-Identifier: Apache-2.0
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
#
|
#
|
||||||
# Copyright (c) 2023 Wind River Systems, Inc.
|
# Copyright (c) 2023-2024 Wind River Systems, Inc.
|
||||||
#
|
#
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
@ -138,8 +138,7 @@ class SoftwareClientHelpTestCase(SoftwareClientTestCase, SoftwareClientNonRootMi
|
||||||
"""When no arguments are called, it should call print_usage"""
|
"""When no arguments are called, it should call print_usage"""
|
||||||
shell_args = [self.PROG, ]
|
shell_args = [self.PROG, ]
|
||||||
self._test_method(shell_args=shell_args)
|
self._test_method(shell_args=shell_args)
|
||||||
mock_usage.assert_called()
|
mock_help.assert_called()
|
||||||
mock_help.assert_not_called()
|
|
||||||
mock_check.assert_not_called()
|
mock_check.assert_not_called()
|
||||||
|
|
||||||
@mock.patch('software_client.software_client.check_for_os_region_name')
|
@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"""
|
"""When -h is passed in, this should invoke print_help"""
|
||||||
shell_args = [self.PROG, "-h"]
|
shell_args = [self.PROG, "-h"]
|
||||||
self._test_method(shell_args=shell_args)
|
self._test_method(shell_args=shell_args)
|
||||||
mock_usage.assert_not_called()
|
|
||||||
mock_help.assert_called()
|
mock_help.assert_called()
|
||||||
mock_check.assert_not_called()
|
mock_check.assert_not_called()
|
||||||
|
|
||||||
|
@ -160,6 +158,5 @@ class SoftwareClientHelpTestCase(SoftwareClientTestCase, SoftwareClientNonRootMi
|
||||||
"""invalid args should invoke print_usage"""
|
"""invalid args should invoke print_usage"""
|
||||||
shell_args = [self.PROG, "invalid_arg"]
|
shell_args = [self.PROG, "invalid_arg"]
|
||||||
self._test_method(shell_args=shell_args)
|
self._test_method(shell_args=shell_args)
|
||||||
mock_usage.assert_called()
|
|
||||||
mock_help.assert_not_called()
|
mock_help.assert_not_called()
|
||||||
mock_check.assert_not_called()
|
mock_check.assert_not_called()
|
||||||
|
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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 "<address pool %s>" % 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
|
|
@ -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)
|
|
@ -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)
|
|
@ -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 "<release %s>" % 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={})
|
|
@ -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)
|
|
@ -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)
|
|
@ -3,5 +3,6 @@ hacking
|
||||||
|
|
||||||
bandit
|
bandit
|
||||||
coverage
|
coverage
|
||||||
|
httplib2
|
||||||
pylint
|
pylint
|
||||||
stestr
|
stestr
|
||||||
|
|
Loading…
Reference in New Issue