From 617a37965994af3587cd4518880d561beea5d1c2 Mon Sep 17 00:00:00 2001 From: Joseph Vazhappilly Date: Tue, 30 Jan 2024 07:16:26 -0500 Subject: [PATCH] Add version to software-api and software client The software-api is versioned in order to allow for future upgrades. This implementation add 'v1' version to all software REST APIs and modify software client to use the updated 'v1' APIs. Example: - POST /v1/software/upload - GET /v1/software/query - GET /v1/software/show/ - GET /v1/software/commit_patch/ ... Story: 2010676 Task: 49478 Test Plan: PASS: Verify REST interfaces of software APIs PASS: Verify software client with new APIs PASS: Verify client with sudo, without keystone auth prior bootstrap PASS: Verify client without sudo, with keystone auth prior bootstrap Change-Id: I10250676fbbcf7501913f21dedea769b581128af Signed-off-by: Joseph Vazhappilly --- .../software_client/software_client.py | 58 ++-- software/pylint.rc | 6 +- software/requirements.txt | 1 + software/software/api/controllers/root.py | 292 ++++-------------- .../software/api/controllers/v1/__init__.py | 88 ++++++ software/software/api/controllers/v1/base.py | 52 ++++ software/software/api/controllers/v1/link.py | 43 +++ .../software/api/controllers/v1/software.py | 241 +++++++++++++++ 8 files changed, 515 insertions(+), 266 deletions(-) create mode 100644 software/software/api/controllers/v1/__init__.py create mode 100644 software/software/api/controllers/v1/base.py create mode 100644 software/software/api/controllers/v1/link.py create mode 100644 software/software/api/controllers/v1/software.py diff --git a/software-client/software_client/software_client.py b/software-client/software_client/software_client.py index 299523d8..4233daef 100644 --- a/software-client/software_client/software_client.py +++ b/software-client/software_client/software_client.py @@ -1,5 +1,5 @@ """ -Copyright (c) 2023 Wind River Systems, Inc. +Copyright (c) 2023-2024 Wind River Systems, Inc. SPDX-License-Identifier: Apache-2.0 @@ -313,7 +313,7 @@ def software_command_not_implemented_yet(args): def release_is_available_req(args): releases = "/".join(args.release) - url = "http://%s/software/is_available/%s" % (api_addr, releases) + url = "http://%s/v1/software/is_available/%s" % (api_addr, releases) headers = {} append_auth_token_if_required(headers) @@ -337,7 +337,7 @@ def release_is_available_req(args): def release_is_deployed_req(args): releases = "/".join(args.release) - url = "http://%s/software/is_deployed/%s" % (api_addr, releases) + url = "http://%s/v1/software/is_deployed/%s" % (api_addr, releases) headers = {} append_auth_token_if_required(headers) @@ -361,7 +361,7 @@ def release_is_deployed_req(args): def release_is_committed_req(args): releases = "/".join(args.release) - url = "http://%s/software/is_committed/%s" % (api_addr, releases) + url = "http://%s/v1/software/is_committed/%s" % (api_addr, releases) headers = {} append_auth_token_if_required(headers) @@ -426,7 +426,7 @@ def release_upload_req(args): encoder = MultipartEncoder(fields=to_upload_files) headers = {'Content-Type': encoder.content_type} - url = "http://%s/software/upload" % api_addr + url = "http://%s/v1/software/upload" % api_addr append_auth_token_if_required(headers) req = requests.post(url, data=to_upload_filenames if is_local else encoder, @@ -458,7 +458,7 @@ def release_delete_req(args): # Ignore interrupts during this function signal.signal(signal.SIGINT, signal.SIG_IGN) - url = "http://%s/software/delete/%s" % (api_addr, releases) + url = "http://%s/v1/software/delete/%s" % (api_addr, releases) headers = {} append_auth_token_if_required(headers) @@ -490,7 +490,7 @@ def commit_patch_req(args): elif args.all: # Get a list of all patches extra_opts = "&release=%s" % relopt - url = "http://%s/software/query?show=patch%s" % (api_addr, extra_opts) + url = "http://%s/v1/software/query?show=patch%s" % (api_addr, extra_opts) req = requests.get(url, headers=headers) @@ -519,7 +519,7 @@ def commit_patch_req(args): patches = "/".join(args.patch) # First, get a list of dependencies and ask for confirmation - url = "http://%s/software/query_dependencies/%s?recursive=yes" % (api_addr, patches) + url = "http://%s/v1/software/query_dependencies/%s?recursive=yes" % (api_addr, patches) req = requests.get(url, headers=headers) @@ -540,7 +540,7 @@ def commit_patch_req(args): return 1 # Run dry-run - url = "http://%s/software/commit_dry_run/%s" % (api_addr, patches) + url = "http://%s/v1/software/commit_dry_run/%s" % (api_addr, patches) req = requests.post(url, headers=headers) print_software_op_result(req) @@ -563,7 +563,7 @@ def commit_patch_req(args): print("Aborting...") return 1 - url = "http://%s/software/commit_patch/%s" % (api_addr, patches) + url = "http://%s/v1/software/commit_patch/%s" % (api_addr, patches) req = requests.post(url, headers=headers) if args.debug: @@ -579,7 +579,7 @@ def release_list_req(args): extra_opts = "" if args.release: extra_opts = "&release=%s" % args.release - url = "http://%s/software/query?show=%s%s" % (api_addr, state, extra_opts) + url = "http://%s/v1/software/query?show=%s%s" % (api_addr, state, extra_opts) headers = {} append_auth_token_if_required(headers) req = requests.get(url, headers=headers) @@ -653,7 +653,7 @@ def print_software_deploy_host_list_result(req): def deploy_host_list_req(args): - url = "http://%s/software/host_list" % api_addr + url = "http://%s/v1/software/host_list" % api_addr req = requests.get(url) if args.debug: print_result_debug(req) @@ -667,7 +667,7 @@ def release_show_req(args): # arg.release is a list releases = "/".join(args.release) - url = "http://%s/software/show/%s" % (api_addr, releases) + url = "http://%s/v1/software/show/%s" % (api_addr, releases) headers = {} append_auth_token_if_required(headers) @@ -683,7 +683,7 @@ def release_show_req(args): def wait_for_install_complete(agent_ip): - url = "http://%s/software/host_list" % api_addr + url = "http://%s/v1/software/host_list" % api_addr rc = 0 max_retries = 4 @@ -779,7 +779,7 @@ def host_install(args): agent_ip = args.agent # Issue deploy_host request and poll for results - url = "http://%s/software/deploy_host/%s" % (api_addr, agent_ip) + url = "http://%s/v1/software/deploy_host/%s" % (api_addr, agent_ip) if args.force: url += "/force" @@ -812,7 +812,7 @@ def host_install(args): def drop_host(args): host_ip = args.host - url = "http://%s/software/drop_host/%s" % (api_addr, host_ip) + url = "http://%s/v1/software/drop_host/%s" % (api_addr, host_ip) req = requests.post(url) @@ -828,7 +828,7 @@ def install_local(args): # pylint: disable=unused-argument # Ignore interrupts during this function signal.signal(signal.SIGINT, signal.SIG_IGN) - url = "http://%s/software/install_local" % (api_addr) + url = "http://%s/v1/software/install_local" % (api_addr) headers = {} append_auth_token_if_required(headers) @@ -866,7 +866,7 @@ def release_upload_dir_req(args): to_upload_files[software_file] = (software_file, open(software_file, 'rb')) encoder = MultipartEncoder(fields=to_upload_files) - url = "http://%s/software/upload" % api_addr + url = "http://%s/v1/software/upload" % api_addr headers = {'Content-Type': encoder.content_type} append_auth_token_if_required(headers) req = requests.post(url, @@ -887,7 +887,7 @@ def deploy_precheck_req(args): region_name = args.region_name # Issue deploy_precheck request - url = "http://%s/software/deploy_precheck/%s" % (api_addr, deployment) + url = "http://%s/v1/software/deploy_precheck/%s" % (api_addr, deployment) if args.force: url += "/force" url += "?region_name=%s" % region_name @@ -913,9 +913,9 @@ def deploy_start_req(args): # Issue deploy_start request if args.force: - url = "http://%s/software/deploy_start/%s/force" % (api_addr, deployment) + url = "http://%s/v1/software/deploy_start/%s/force" % (api_addr, deployment) else: - url = "http://%s/software/deploy_start/%s" % (api_addr, deployment) + url = "http://%s/v1/software/deploy_start/%s" % (api_addr, deployment) headers = {} append_auth_token_if_required(headers) @@ -937,7 +937,7 @@ def deploy_activate_req(args): signal.signal(signal.SIGINT, signal.SIG_IGN) # Issue deploy_start request - url = "http://%s/software/deploy_activate/%s" % (api_addr, deployment) + url = "http://%s/v1/software/deploy_activate/%s" % (api_addr, deployment) headers = {} append_auth_token_if_required(headers) @@ -959,7 +959,7 @@ def deploy_complete_req(args): signal.signal(signal.SIGINT, signal.SIG_IGN) # Issue deploy_complete request - url = "http://%s/software/deploy_complete/%s" % (api_addr, deployment) + url = "http://%s/v1/software/deploy_complete/%s" % (api_addr, deployment) headers = {} append_auth_token_if_required(headers) @@ -974,7 +974,7 @@ def deploy_complete_req(args): def deploy_show_req(args): - url = "http://%s/software/deploy_show" % api_addr + url = "http://%s/v1/software/deploy_show" % api_addr headers = {} append_auth_token_if_required(headers) req = requests.get(url, headers=headers) @@ -1016,7 +1016,7 @@ def deploy_host_req(args): agent_ip = args.agent # Issue deploy_host request and poll for results - url = "http://%s/software/deploy_host/%s" % (api_addr, agent_ip) + url = "http://%s/v1/software/deploy_host/%s" % (api_addr, agent_ip) if args.force: url += "/force" @@ -1051,7 +1051,7 @@ def patch_init_release(args): release = args.release - url = "http://%s/software/init_release/%s" % (api_addr, release) + url = "http://%s/v1/software/init_release/%s" % (api_addr, release) req = requests.post(url) @@ -1069,7 +1069,7 @@ def patch_del_release(args): release = args.release - url = "http://%s/software/del_release/%s" % (api_addr, release) + url = "http://%s/v1/software/del_release/%s" % (api_addr, release) req = requests.post(url) @@ -1086,7 +1086,7 @@ def patch_report_app_dependencies_req(args): # pylint: disable=unused-argument extra_opts_str = '?%s' % '&'.join(extra_opts) patches = "/".join(args) - url = "http://%s/software/report_app_dependencies/%s%s" \ + url = "http://%s/v1/software/report_app_dependencies/%s%s" \ % (api_addr, patches, extra_opts_str) headers = {} @@ -1102,7 +1102,7 @@ def patch_report_app_dependencies_req(args): # pylint: disable=unused-argument def patch_query_app_dependencies_req(): - url = "http://%s/software/query_app_dependencies" % api_addr + url = "http://%s/v1/software/query_app_dependencies" % api_addr headers = {} append_auth_token_if_required(headers) diff --git a/software/pylint.rc b/software/pylint.rc index 347f18ed..e6516404 100644 --- a/software/pylint.rc +++ b/software/pylint.rc @@ -466,12 +466,14 @@ confidence=HIGH, # W1505 deprecated-method # W1514 unspecified-encoding # W3101 missing-timeout -disable= C0103,C0114,C0115,C0116,C0201,C0206,C0209,C2801, +disable= C0103,C0114,C0115,C0116,C0201,C0202,C0206,C0209,C2801, C0301,C0302,C0325,C0411,C0413,C0415, R0205,R0402,R0801,R0902,R0903,R0904,R0911, R0912,R0913,R0914,R0915,R1702,R1705,R1714, R1715,R1722,R1724,R1725,R1732,R1735, - W0107,W0602,W0603,W0703,W0707,W0719,W1201,W1514,W3101 + W0107,W0231,W0602,W0603,W0621,W0622, + W0703,W0707,W0719,W1201,W1514,W3101, + E0605 # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/software/requirements.txt b/software/requirements.txt index 33386a6c..beede63e 100644 --- a/software/requirements.txt +++ b/software/requirements.txt @@ -14,3 +14,4 @@ PyGObject requests_toolbelt sh WebOb +WSME>=0.5b2 diff --git a/software/software/api/controllers/root.py b/software/software/api/controllers/root.py index 83d201a1..075b02ca 100644 --- a/software/software/api/controllers/root.py +++ b/software/software/api/controllers/root.py @@ -1,253 +1,75 @@ """ -Copyright (c) 2023 Wind River Systems, Inc. +Copyright (c) 2023-2024 Wind River Systems, Inc. SPDX-License-Identifier: Apache-2.0 """ -import cgi -import json -import os from oslo_log import log -from pecan import expose -from pecan import request -import shutil +import pecan +from pecan import rest -from software.exceptions import SoftwareError -from software.software_controller import sc -import software.utils as utils -import software.constants as constants +from software.api.controllers import v1 +from software.api.controllers.v1 import base +from software.api.controllers.v1 import link + +from wsme import types as wtypes +import wsmeext.pecan as wsme_pecan LOG = log.getLogger(__name__) -class SoftwareAPIController(object): +class Version(base.APIBase): + """An API version representation.""" - @expose('json') - def commit_patch(self, *args): - try: - result = sc.patch_commit(list(args)) - except SoftwareError as e: - return dict(error=str(e)) + id = wtypes.text + "The ID of the version, also acts as the release number" - sc.software_sync() + links = [link.Link] + "A Link that point to a specific version of the API" - return result - - @expose('json') - def commit_dry_run(self, *args): - try: - result = sc.patch_commit(list(args), dry_run=True) - except SoftwareError as e: - return dict(error=str(e)) - - return result - - @expose('json') - @expose('query.xml', content_type='application/xml') - def delete(self, *args): - try: - result = sc.software_release_delete_api(list(args)) - except SoftwareError as e: - return dict(error="Error: %s" % str(e)) - - sc.software_sync() - - return result - - @expose('json') - @expose('query.xml', content_type='application/xml') - def deploy_activate(self, *args): - if sc.any_patch_host_installing(): - return dict(error="Rejected: One or more nodes are installing a release.") - - try: - result = sc.software_deploy_activate_api(list(args)[0]) - except SoftwareError as e: - return dict(error="Error: %s" % str(e)) - - sc.software_sync() - return result - - @expose('json') - @expose('query.xml', content_type='application/xml') - def deploy_complete(self, *args): - if sc.any_patch_host_installing(): - return dict(error="Rejected: One or more nodes are installing a release.") - - try: - result = sc.software_deploy_complete_api(list(args)[0]) - except SoftwareError as e: - return dict(error="Error: %s" % str(e)) - - sc.software_sync() - return result - - @expose('json') - @expose('query.xml', content_type='application/xml') - def deploy_host(self, *args): - if len(list(args)) == 0: - return dict(error="Host must be specified for install") - force = False - if len(list(args)) > 1 and 'force' in list(args)[1:]: - force = True - - try: - result = sc.software_deploy_host_api(list(args)[0], force, async_req=True) - except SoftwareError as e: - return dict(error="Error: %s" % str(e)) - - return result - - @expose('json') - @expose('query.xml', content_type='application/xml') - def deploy_precheck(self, *args, **kwargs): - force = False - if 'force' in list(args): - force = True - - try: - result = sc.software_deploy_precheck_api(list(args)[0], force, **kwargs) - except SoftwareError as e: - return dict(error="Error: %s" % str(e)) - - return result - - @expose('json') - @expose('query.xml', content_type='application/xml') - def deploy_start(self, *args, **kwargs): - # if --force is provided - force = 'force' in list(args) - - if sc.any_patch_host_installing(): - return dict(error="Rejected: One or more nodes are installing releases.") - - try: - result = sc.software_deploy_start_api(list(args)[0], force, **kwargs) - except SoftwareError as e: - return dict(error="Error: %s" % str(e)) - - sc.send_latest_feed_commit_to_agent() - sc.software_sync() - - return result - - @expose('json') - @expose('query.xml', content_type='application/xml') - def deploy_show(self): - try: - result = sc.software_deploy_show_api() - except SoftwareError as e: - return dict(error="Error: %s" % str(e)) - - return result - - @expose('json') - @expose('query.xml', content_type='application/xml') - def install_local(self): - try: - result = sc.software_install_local_api() - except SoftwareError as e: - return dict(error="Error: %s" % str(e)) - - return result - - @expose('json') - def is_available(self, *args): - return sc.is_available(list(args)) - - @expose('json') - def is_committed(self, *args): - return sc.is_committed(list(args)) - - @expose('json') - def is_deployed(self, *args): - return sc.is_deployed(list(args)) - - @expose('json') - @expose('show.xml', content_type='application/xml') - def show(self, *args): - try: - result = sc.software_release_query_specific_cached(list(args)) - except SoftwareError as e: - return dict(error="Error: %s" % str(e)) - - return result - - @expose('json') - @expose('query.xml', content_type='application/xml') - def upload(self): - is_local = False - temp_dir = None - uploaded_files = [] - request_data = [] - local_files = [] - - # --local option only sends a list of file names - if (request.content_type == "text/plain"): - local_files = list(json.loads(request.body)) - is_local = True - else: - request_data = list(request.POST.items()) - temp_dir = os.path.join(constants.SCRATCH_DIR, 'upload_files') - - try: - if len(request_data) == 0 and len(local_files) == 0: - raise SoftwareError("No files uploaded") - - if is_local: - uploaded_files = local_files - LOG.info("Uploaded local files: %s", uploaded_files) - else: - # Protect against duplications - uploaded_files = sorted(set(request_data)) - # Save all uploaded files to /scratch/upload_files dir - for file_item in uploaded_files: - assert isinstance(file_item[1], cgi.FieldStorage) - utils.save_temp_file(file_item[1], temp_dir) - - # Get all uploaded files from /scratch dir - uploaded_files = utils.get_all_files(temp_dir) - LOG.info("Uploaded files: %s", uploaded_files) - - # Process uploaded files - return sc.software_release_upload(uploaded_files) - - except Exception as e: - return dict(error=str(e)) - finally: - # Remove all uploaded files from /scratch dir - sc.software_sync() - if temp_dir: - shutil.rmtree(temp_dir, ignore_errors=True) - - @expose('json') - @expose('query.xml', content_type='application/xml') - def query(self, **kwargs): - try: - sd = sc.software_release_query_cached(**kwargs) - except SoftwareError as e: - return dict(error="Error: %s" % str(e)) - - return dict(sd=sd) - - @expose('json') - @expose('query_hosts.xml', content_type='application/xml') - def host_list(self, *args): # pylint: disable=unused-argument - try: - query_hosts = sc.deploy_host_list() - except Exception as e: - return dict(error=str(e)) - return dict(data=query_hosts) + @classmethod + def convert(self, id): + version = Version() + version.id = id + version.links = [link.Link.make_link('self', pecan.request.host_url, + id, '', bookmark=True)] + return version -class RootController: - """pecan REST API root""" +class Root(base.APIBase): - @expose() - @expose('json') - def index(self): - """index for the root""" - return "Unified Software Management API, Available versions: /v1" + name = wtypes.text + "The name of the API" - software = SoftwareAPIController() - v1 = SoftwareAPIController() + description = wtypes.text + "Some information about this API" + + versions = [Version] + "Links to all the versions available in this API" + + default_version = Version + "A link to the default version of the API" + + @classmethod + def convert(self): + root = Root() + root.name = "StarlingX USM API" + root.description = ("Unified Software Management API allows for a " + "single REST API / CLI and single procedure for updating " + "the StarlingX software on a Standalone Cloud or Distributed Cloud." + ) + root.versions = [Version.convert('v1')] + root.default_version = Version.convert('v1') + return root + + +class RootController(rest.RestController): + + v1 = v1.Controller() + + @wsme_pecan.wsexpose(Root) + def get(self): + # NOTE: The reason why convert() it's being called for every + # request is because we need to get the host url from + # the request object to make the links. + return Root.convert() diff --git a/software/software/api/controllers/v1/__init__.py b/software/software/api/controllers/v1/__init__.py new file mode 100644 index 00000000..55adcd1d --- /dev/null +++ b/software/software/api/controllers/v1/__init__.py @@ -0,0 +1,88 @@ +# +# Copyright (c) 2013-2024 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# + +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# All Rights Reserved. +# + +""" +Version 1 of the USM API + +Specification can be found in code repo. +""" + +import pecan +import wsmeext.pecan as wsme_pecan +from pecan import rest +from wsme import types as wtypes + +from software.api.controllers.v1 import base +from software.api.controllers.v1 import link +from software.api.controllers.v1 import software + + +class MediaType(base.APIBase): + """A media type representation.""" + + base = wtypes.text + type = wtypes.text + + def __init__(self, base, type): + self.base = base + self.type = type + + +class V1(base.APIBase): + """The representation of the version 1 of the API.""" + + id = wtypes.text + "The ID of the version, also acts as the release number" + + media_types = [MediaType] + "An array of supported media types for this version" + + links = [link.Link] + "Links that point to a specific URL for this version and documentation" + + software = [link.Link] + "Links to the software resource" + + @classmethod + def convert(self): + v1 = V1() + v1.id = "v1" + v1.links = [link.Link.make_link('self', pecan.request.host_url, + 'v1', '', bookmark=True), + ] + v1.media_types = [MediaType('application/json', + 'application/vnd.openstack.software.v1+json')] + + v1.software = [link.Link.make_link('self', pecan.request.host_url, + 'software', ''), + link.Link.make_link('bookmark', + pecan.request.host_url, + 'software', '', + bookmark=True) + ] + + return v1 + + +class Controller(rest.RestController): + """Version 1 API controller root.""" + + software = software.SoftwareAPIController() + + @wsme_pecan.wsexpose(V1) + def get(self): + # NOTE: The reason why convert() it's being called for every + # request is because we need to get the host url from + # the request object to make the links. + return V1.convert() + + +__all__ = (Controller) diff --git a/software/software/api/controllers/v1/base.py b/software/software/api/controllers/v1/base.py new file mode 100644 index 00000000..86231654 --- /dev/null +++ b/software/software/api/controllers/v1/base.py @@ -0,0 +1,52 @@ +""" +Copyright (c) 2013-2024 Wind River Systems, Inc. + +SPDX-License-Identifier: Apache-2.0 + +""" + + +import datetime + +import wsme +from wsme import types as wtypes + + +class APIBase(wtypes.Base): + + created_at = datetime.datetime + "The time in UTC at which the object is created" + + updated_at = datetime.datetime + "The time in UTC at which the object is updated" + + def as_dict(self): + """Render this object as a dict of its fields.""" + return dict((k, getattr(self, k)) + for k in self.fields # pylint: disable=no-member + if hasattr(self, k) and + getattr(self, k) != wsme.Unset) + + def unset_fields_except(self, except_list=None): + """Unset fields so they don't appear in the message body. + + :param except_list: A list of fields that won't be touched. + + """ + if except_list is None: + except_list = [] + + for k in self.as_dict(): + if k not in except_list: + setattr(self, k, wsme.Unset) + + @classmethod + def from_rpc_object(cls, m, fields=None): + """Convert a RPC object to an API object.""" + obj_dict = m.as_dict() + # Unset non-required fields so they do not appear + # in the message body + obj_dict.update(dict((k, wsme.Unset) + for k in obj_dict.keys() + if fields and k not in fields)) + return cls(**obj_dict) diff --git a/software/software/api/controllers/v1/link.py b/software/software/api/controllers/v1/link.py new file mode 100644 index 00000000..a08fb83e --- /dev/null +++ b/software/software/api/controllers/v1/link.py @@ -0,0 +1,43 @@ +# Copyright 2013 Red Hat, 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. +# +# Copyright (c) 2013-2024 Wind River Systems, Inc. +# + +from wsme import types as wtypes + +from software.api.controllers.v1 import base + + +class Link(base.APIBase): + """A link representation.""" + + href = wtypes.text + "The url of a link." + + rel = wtypes.text + "The name of a link." + + type = wtypes.text + "Indicates the type of document/link." + + @classmethod + def make_link(cls, rel_name, url, resource, resource_args, + bookmark=False, type=wtypes.Unset): + template = '%s/%s' if bookmark else '%s/v1/%s' + template += '%s' if resource_args.startswith('?') else '/%s' + + return Link(href=(template) % (url, resource, resource_args), + rel=rel_name, type=type) diff --git a/software/software/api/controllers/v1/software.py b/software/software/api/controllers/v1/software.py new file mode 100644 index 00000000..335d8047 --- /dev/null +++ b/software/software/api/controllers/v1/software.py @@ -0,0 +1,241 @@ +""" +Copyright (c) 2023-2024 Wind River Systems, Inc. + +SPDX-License-Identifier: Apache-2.0 + +""" +import cgi +import json +import os +from oslo_log import log +from pecan import expose +from pecan import request +import shutil + +from software.exceptions import SoftwareError +from software.software_controller import sc +import software.utils as utils +import software.constants as constants + + +LOG = log.getLogger(__name__) + + +class SoftwareAPIController(object): + + @expose('json') + def commit_patch(self, *args): + try: + result = sc.patch_commit(list(args)) + except SoftwareError as e: + return dict(error=str(e)) + + sc.software_sync() + + return result + + @expose('json') + def commit_dry_run(self, *args): + try: + result = sc.patch_commit(list(args), dry_run=True) + except SoftwareError as e: + return dict(error=str(e)) + + return result + + @expose('json') + @expose('query.xml', content_type='application/xml') + def delete(self, *args): + try: + result = sc.software_release_delete_api(list(args)) + except SoftwareError as e: + return dict(error="Error: %s" % str(e)) + + sc.software_sync() + + return result + + @expose('json') + @expose('query.xml', content_type='application/xml') + def deploy_activate(self, *args): + if sc.any_patch_host_installing(): + return dict(error="Rejected: One or more nodes are installing a release.") + + try: + result = sc.software_deploy_activate_api(list(args)[0]) + except SoftwareError as e: + return dict(error="Error: %s" % str(e)) + + sc.software_sync() + return result + + @expose('json') + @expose('query.xml', content_type='application/xml') + def deploy_complete(self, *args): + if sc.any_patch_host_installing(): + return dict(error="Rejected: One or more nodes are installing a release.") + + try: + result = sc.software_deploy_complete_api(list(args)[0]) + except SoftwareError as e: + return dict(error="Error: %s" % str(e)) + + sc.software_sync() + return result + + @expose('json') + @expose('query.xml', content_type='application/xml') + def deploy_host(self, *args): + if len(list(args)) == 0: + return dict(error="Host must be specified for install") + force = False + if len(list(args)) > 1 and 'force' in list(args)[1:]: + force = True + + try: + result = sc.software_deploy_host_api(list(args)[0], force, async_req=True) + except SoftwareError as e: + return dict(error="Error: %s" % str(e)) + + return result + + @expose('json') + @expose('query.xml', content_type='application/xml') + def deploy_precheck(self, *args, **kwargs): + force = False + if 'force' in list(args): + force = True + + try: + result = sc.software_deploy_precheck_api(list(args)[0], force, **kwargs) + except SoftwareError as e: + return dict(error="Error: %s" % str(e)) + + return result + + @expose('json') + @expose('query.xml', content_type='application/xml') + def deploy_start(self, *args, **kwargs): + # if --force is provided + force = 'force' in list(args) + + if sc.any_patch_host_installing(): + return dict(error="Rejected: One or more nodes are installing releases.") + + try: + result = sc.software_deploy_start_api(list(args)[0], force, **kwargs) + except SoftwareError as e: + return dict(error="Error: %s" % str(e)) + + sc.send_latest_feed_commit_to_agent() + sc.software_sync() + + return result + + @expose('json') + @expose('query.xml', content_type='application/xml') + def deploy_show(self): + try: + result = sc.software_deploy_show_api() + except SoftwareError as e: + return dict(error="Error: %s" % str(e)) + + return result + + @expose('json') + @expose('query.xml', content_type='application/xml') + def install_local(self): + try: + result = sc.software_install_local_api() + except SoftwareError as e: + return dict(error="Error: %s" % str(e)) + + return result + + @expose('json') + def is_available(self, *args): + return sc.is_available(list(args)) + + @expose('json') + def is_committed(self, *args): + return sc.is_committed(list(args)) + + @expose('json') + def is_deployed(self, *args): + return sc.is_deployed(list(args)) + + @expose('json') + @expose('show.xml', content_type='application/xml') + def show(self, *args): + try: + result = sc.software_release_query_specific_cached(list(args)) + except SoftwareError as e: + return dict(error="Error: %s" % str(e)) + + return result + + @expose('json') + @expose('query.xml', content_type='application/xml') + def upload(self): + is_local = False + temp_dir = None + uploaded_files = [] + request_data = [] + local_files = [] + + # --local option only sends a list of file names + if (request.content_type == "text/plain"): + local_files = list(json.loads(request.body)) + is_local = True + else: + request_data = list(request.POST.items()) + temp_dir = os.path.join(constants.SCRATCH_DIR, 'upload_files') + + try: + if len(request_data) == 0 and len(local_files) == 0: + raise SoftwareError("No files uploaded") + + if is_local: + uploaded_files = local_files + LOG.info("Uploaded local files: %s", uploaded_files) + else: + # Protect against duplications + uploaded_files = sorted(set(request_data)) + # Save all uploaded files to /scratch/upload_files dir + for file_item in uploaded_files: + assert isinstance(file_item[1], cgi.FieldStorage) + utils.save_temp_file(file_item[1], temp_dir) + + # Get all uploaded files from /scratch dir + uploaded_files = utils.get_all_files(temp_dir) + LOG.info("Uploaded files: %s", uploaded_files) + + # Process uploaded files + return sc.software_release_upload(uploaded_files) + + except Exception as e: + return dict(error=str(e)) + finally: + # Remove all uploaded files from /scratch dir + sc.software_sync() + if temp_dir: + shutil.rmtree(temp_dir, ignore_errors=True) + + @expose('json') + @expose('query.xml', content_type='application/xml') + def query(self, **kwargs): + try: + sd = sc.software_release_query_cached(**kwargs) + except SoftwareError as e: + return dict(error="Error: %s" % str(e)) + + return dict(sd=sd) + + @expose('json') + @expose('query_hosts.xml', content_type='application/xml') + def host_list(self, *args): # pylint: disable=unused-argument + try: + query_hosts = sc.deploy_host_list() + except Exception as e: + return dict(error=str(e)) + return dict(data=query_hosts)