diff --git a/.github/labels.yml b/.github/labels.yml index 2a28fc812..83989042c 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -2,6 +2,9 @@ - name: new-feature description: for new features in the changelog. color: 225fee +- name: project + description: for new projects in the changelog. + color: 46BAF0 - name: improvement description: for improvements in existing functionality in the changelog. color: 22ee47 diff --git a/.github/release.yml b/.github/release.yml index 8417f9fb9..a2318fa64 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -3,7 +3,10 @@ changelog: labels: - ignore-for-release categories: - - title: โš ๏ธ Breaking Change + - title: ๐Ÿ“‹ New Project + labels: + - project + - title: โš ๏ธ Breaking Change labels: - breaking-change - title: ๐Ÿ› Bug Fixes @@ -18,7 +21,7 @@ changelog: - title: ๐Ÿงช Testing Improvements labels: - testing - - title: โš™๏ธ Repo/CI Improvements + - title: โš™๏ธ Repo/CI Improvements labels: - repo-ci-improvement - title: ๐Ÿ“– Documentation diff --git a/.github/workflows/e2e-suite-pr.yml b/.github/workflows/e2e-suite-windows.yml similarity index 52% rename from .github/workflows/e2e-suite-pr.yml rename to .github/workflows/e2e-suite-windows.yml index 74c5a4390..108c757d8 100644 --- a/.github/workflows/e2e-suite-pr.yml +++ b/.github/workflows/e2e-suite-windows.yml @@ -15,99 +15,6 @@ on: name: PR E2E Tests jobs: - integration-fork-ubuntu: - runs-on: ubuntu-latest - if: - github.event_name == 'workflow_dispatch' && inputs.sha != '' - - steps: - - uses: actions-ecosystem/action-regex-match@v2 - id: validate-tests - with: - text: ${{ inputs.test_path }} - regex: '[^a-z0-9-:.\/_]' # Tests validation - flags: gi - - # Check out merge commit - - name: Checkout PR - uses: actions/checkout@v4 - with: - ref: ${{ inputs.sha }} - - - name: Get the hash value of the latest commit from the PR branch - uses: octokit/graphql-action@v2.x - id: commit-hash - if: ${{ inputs.pull_request_number != '' }} - with: - query: | - query PRHeadCommitHash($owner: String!, $repo: String!, $pr_num: Int!) { - repository(owner:$owner, name:$repo) { - pullRequest(number: $pr_num) { - headRef { - target { - ... on Commit { - oid - } - } - } - } - } - } - owner: ${{ github.event.repository.owner.login }} - repo: ${{ github.event.repository.name }} - pr_num: ${{ fromJSON(inputs.pull_request_number) }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Update system packages - run: sudo apt-get update -y - - - name: Setup Python - uses: actions/setup-python@v4 - with: - python-version: '3.x' - - - name: Install Python deps - run: pip install .[dev,obj] - - - name: Install the CLI - run: make install - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - run: make INTEGRATION_TEST_PATH="${{ inputs.test_path }}" testint - if: ${{ steps.validate-tests.outputs.match == '' }} - env: - LINODE_CLI_TOKEN: ${{ secrets.LINODE_TOKEN }} - - - uses: actions/github-script@v6 - id: update-check-run - if: ${{ inputs.pull_request_number != '' && fromJson(steps.commit-hash.outputs.data).repository.pullRequest.headRef.target.oid == inputs.sha }} - env: - number: ${{ inputs.pull_request_number }} - job: ${{ github.job }} - conclusion: ${{ job.status }} - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { data: pull } = await github.rest.pulls.get({ - ...context.repo, - pull_number: process.env.number - }); - const ref = pull.head.sha; - const { data: checks } = await github.rest.checks.listForRef({ - ...context.repo, - ref - }); - const check = checks.check_runs.filter(c => c.name === process.env.job); - const { data: result } = await github.rest.checks.update({ - ...context.repo, - check_run_id: check[0].id, - status: 'completed', - conclusion: process.env.conclusion - }); - return result; - integration-fork-windows: runs-on: windows-latest if: diff --git a/.github/workflows/e2e-suite.yml b/.github/workflows/e2e-suite.yml index 0369db995..6a2b39b93 100644 --- a/.github/workflows/e2e-suite.yml +++ b/.github/workflows/e2e-suite.yml @@ -1,23 +1,82 @@ name: Integration Tests + on: - workflow_dispatch: null + workflow_dispatch: + inputs: + use_minimal_test_account: + description: 'Use minimal test account' + required: false + default: 'false' + test_path: + description: "The path from 'test/integration' to the target to be tested, e.g. 'cli'" + required: false + sha: + description: 'The hash value of the commit.' + required: false + default: '' + pull_request_number: + description: 'The number of the PR. Ensure sha value is provided' + required: false push: branches: - main - dev + jobs: integration-tests: - name: Run integration tests + name: Run integration tests on Ubuntu runs-on: ubuntu-latest - env: - EXIT_STATUS: 0 + if: github.event_name == 'workflow_dispatch' && inputs.sha != '' || github.event_name == 'push' || github.event_name == 'pull_request' steps: - - name: Clone Repository - uses: actions/checkout@v3 + - name: Validate Test Path + uses: actions-ecosystem/action-regex-match@v2 + id: validate-tests + if: ${{ inputs.test_path != '' }} + with: + text: ${{ inputs.test_path }} + regex: '[^a-z0-9-:.\/_]' # Tests validation + flags: gi + + - name: Checkout Repository with SHA + if: ${{ inputs.sha != '' }} + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: 'recursive' + ref: ${{ inputs.sha }} + + - name: Checkout Repository without SHA + if: ${{ inputs.sha == '' }} + uses: actions/checkout@v4 with: fetch-depth: 0 submodules: 'recursive' + - name: Get the hash value of the latest commit from the PR branch + uses: octokit/graphql-action@v2.x + id: commit-hash + if: ${{ inputs.pull_request_number != '' }} + with: + query: | + query PRHeadCommitHash($owner: String!, $repo: String!, $pr_num: Int!) { + repository(owner:$owner, name:$repo) { + pullRequest(number: $pr_num) { + headRef { + target { + ... on Commit { + oid + } + } + } + } + } + } + owner: ${{ github.event.repository.owner.login }} + repo: ${{ github.event.repository.name }} + pr_num: ${{ fromJSON(inputs.pull_request_number) }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Update system packages run: sudo apt-get update -y @@ -26,39 +85,85 @@ jobs: with: python-version: '3.x' - - name: Install Python deps - run: pip install wheel boto3 - - - name: Update cert - run: pip install certifi -U + - name: Install Python dependencies and update cert + run: | + pip install wheel boto3 && \ + pip install certifi -U && \ + pip install .[obj,dev] - - name: Install deps - run: pip install .[obj,dev] + - name: Download kubectl and calicoctl for LKE clusters + run: | + curl -LO "https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl" + curl -LO "https://github.com/projectcalico/calico/releases/download/v3.25.0/calicoctl-linux-amd64" + chmod +x calicoctl-linux-amd64 kubectl + mv calicoctl-linux-amd64 /usr/local/bin/calicoctl + mv kubectl /usr/local/bin/kubectl - name: Install Package run: make install env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Set LINODE_CLI_TOKEN + run: | + echo "LINODE_CLI_TOKEN=${{ secrets[inputs.use_minimal_test_account == 'true' && 'MINIMAL_LINODE_TOKEN' || 'LINODE_TOKEN'] }}" >> $GITHUB_ENV + - name: Run the integration test suite run: | timestamp=$(date +'%Y%m%d%H%M') report_filename="${timestamp}_cli_test_report.xml" make testint TEST_ARGS="--junitxml=${report_filename}" + if: ${{ steps.validate-tests.outputs.match == '' || inputs.test_path == '' }} + env: + LINODE_CLI_TOKEN: ${{ env.LINODE_CLI_TOKEN }} + + - name: Apply Calico Rules to LKE + if: always() + run: | + cd scripts && ./lke_calico_rules_e2e.sh env: - LINODE_CLI_TOKEN: ${{ secrets.LINODE_TOKEN }} + LINODE_TOKEN: ${{ env.LINODE_CLI_TOKEN }} - name: Upload test results if: always() run: | filename=$(ls | grep -E '^[0-9]{12}_cli_test_report\.xml$') - python tod_scripts/add_to_xml_test_report.py \ + python3 e2e_scripts/tod_scripts/xml_to_obj_storage/scripts/add_gha_info_to_xml.py \ --branch_name "${GITHUB_REF#refs/*/}" \ --gha_run_id "$GITHUB_RUN_ID" \ --gha_run_number "$GITHUB_RUN_NUMBER" \ --xmlfile "${filename}" sync - python tod_scripts/test_report_upload_script.py "${filename}" + python3 e2e_scripts/tod_scripts/xml_to_obj_storage/scripts/xml_to_obj.py "${filename}" env: LINODE_CLI_OBJ_ACCESS_KEY: ${{ secrets.LINODE_CLI_OBJ_ACCESS_KEY }} - LINODE_CLI_OBJ_SECRET_KEY: ${{ secrets.LINODE_CLI_OBJ_SECRET_KEY }} \ No newline at end of file + LINODE_CLI_OBJ_SECRET_KEY: ${{ secrets.LINODE_CLI_OBJ_SECRET_KEY }} + + - name: Update PR Check Run + uses: actions/github-script@v6 + id: update-check-run + if: ${{ inputs.pull_request_number != '' && fromJson(steps.commit-hash.outputs.data).repository.pullRequest.headRef.target.oid == inputs.sha }} + env: + number: ${{ inputs.pull_request_number }} + job: ${{ github.job }} + conclusion: ${{ job.status }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { data: pull } = await github.rest.pulls.get({ + ...context.repo, + pull_number: process.env.number + }); + const ref = pull.head.sha; + const { data: checks } = await github.rest.checks.listForRef({ + ...context.repo, + ref + }); + const check = checks.check_runs.filter(c => c.name === process.env.job); + const { data: result } = await github.rest.checks.update({ + ...context.repo, + check_run_id: check[0].id, + status: 'completed', + conclusion: process.env.conclusion + }); + return result; diff --git a/.github/workflows/nightly-smoke-tests.yml b/.github/workflows/nightly-smoke-tests.yml index c71ab50a9..76b725720 100644 --- a/.github/workflows/nightly-smoke-tests.yml +++ b/.github/workflows/nightly-smoke-tests.yml @@ -32,4 +32,4 @@ jobs: run: | make smoketest env: - LINODE_CLI_TOKEN: ${{ secrets.LINODE_TOKEN_2 }} + LINODE_CLI_TOKEN: ${{ secrets.LINODE_TOKEN }} diff --git a/.gitignore b/.gitignore index d02e666a7..77601cba7 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ test/.env .tmp* MANIFEST venv +openapi*.yaml diff --git a/.gitmodules b/.gitmodules index 9002a58fd..e1e863fba 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,6 +4,6 @@ [submodule "test/test_helper/bats-support"] path = test/test_helper/bats-support url = https://github.com/ztombol/bats-support -[submodule "tod_scripts"] - path = tod_scripts - url = https://github.com/linode/TOD-test-report-uploader.git +[submodule "e2e_scripts"] + path = e2e_scripts + url = https://github.com/linode/dx-e2e-test-scripts diff --git a/e2e_scripts b/e2e_scripts new file mode 160000 index 000000000..b56178520 --- /dev/null +++ b/e2e_scripts @@ -0,0 +1 @@ +Subproject commit b56178520fae446a0a4f38df6259deb845efa667 diff --git a/linodecli/__init__.py b/linodecli/__init__.py index 235ecc282..aa7331b40 100755 --- a/linodecli/__init__.py +++ b/linodecli/__init__.py @@ -13,6 +13,7 @@ from rich.table import Column, Table from linodecli import plugins +from linodecli.exit_codes import ExitCodes from .arg_helpers import ( bake_command, @@ -50,7 +51,11 @@ or TEST_MODE ) -cli = CLI(VERSION, handle_url_overrides(BASE_URL), skip_config=skip_config) +cli = CLI( + VERSION, + handle_url_overrides(BASE_URL, override_path=True), + skip_config=skip_config, +) def main(): # pylint: disable=too-many-branches,too-many-statements @@ -85,7 +90,7 @@ def main(): # pylint: disable=too-many-branches,too-many-statements # print version info and exit - but only if no command was given print(f"linode-cli {VERSION}") print(f"Built from spec version {cli.spec_version}") - sys.exit(0) + sys.exit(ExitCodes.SUCCESS) else: # something else might want to parse version # find where it was originally, as it was removed from args @@ -96,17 +101,17 @@ def main(): # pylint: disable=too-many-branches,too-many-statements if parsed.command == "bake": if parsed.action is None: print("No spec provided, cannot bake") - sys.exit(9) + sys.exit(ExitCodes.ARGUMENT_ERROR) bake_command(cli, parsed.action) - sys.exit(0) + sys.exit(ExitCodes.SUCCESS) elif cli.ops is None: # if not spec was found and we weren't baking, we're doomed - sys.exit(3) + sys.exit(ExitCodes.ARGUMENT_ERROR) if parsed.command == "register-plugin": if parsed.action is None: print("register-plugin requires a module name!") - sys.exit(9) + sys.exit(ExitCodes.ARGUMENT_ERROR) msg, code = register_plugin(parsed.action, cli.config, cli.ops) print(msg) sys.exit(code) @@ -114,32 +119,32 @@ def main(): # pylint: disable=too-many-branches,too-many-statements if parsed.command == "remove-plugin": if parsed.action is None: print("remove-plugin requires a plugin name to remove!") - sys.exit(9) + sys.exit(ExitCodes.ARGUMENT_ERROR) msg, code = remove_plugin(parsed.action, cli.config) print(msg) sys.exit(code) if parsed.command == "completion": print(get_completions(cli.ops, parsed.help, parsed.action)) - sys.exit(0) + sys.exit(ExitCodes.SUCCESS) # handle a help for the CLI if parsed.command is None or (parsed.command is None and parsed.help): parser.print_help() print_help_default() - sys.exit(0) + sys.exit(ExitCodes.SUCCESS) if parsed.command == "env-vars": print_help_env_vars() - sys.exit(0) + sys.exit(ExitCodes.SUCCESS) if parsed.command == "commands": print_help_commands(cli.ops) - sys.exit(0) + sys.exit(ExitCodes.SUCCESS) if parsed.command == "plugins": print_help_plugins(cli.config) - sys.exit(0) + sys.exit(ExitCodes.SUCCESS) # configure if parsed.command == "configure": @@ -151,7 +156,7 @@ def main(): # pylint: disable=too-many-branches,too-many-statements ) else: cli.configure() - sys.exit(0) + sys.exit(ExitCodes.SUCCESS) # block of commands for user-focused operations if parsed.command == "set-user": @@ -163,7 +168,7 @@ def main(): # pylint: disable=too-many-branches,too-many-statements ) else: cli.config.set_default_user(parsed.action) - sys.exit(0) + sys.exit(ExitCodes.SUCCESS) if parsed.command == "show-users": if parsed.help: @@ -177,7 +182,7 @@ def main(): # pylint: disable=too-many-branches,too-many-statements ) else: cli.config.print_users() - sys.exit(0) + sys.exit(ExitCodes.SUCCESS) if parsed.command == "remove-user": if parsed.help or not parsed.action: @@ -190,7 +195,7 @@ def main(): # pylint: disable=too-many-branches,too-many-statements ) else: cli.config.remove_user(parsed.action) - sys.exit(0) + sys.exit(ExitCodes.SUCCESS) # check for plugin invocation if parsed.command not in cli.ops and parsed.command in plugins.available( @@ -202,7 +207,7 @@ def main(): # pylint: disable=too-many-branches,too-many-statements plugin_args = argv[1:] # don't include the program name plugin_args.remove(parsed.command) # don't include the plugin name plugins.invoke(parsed.command, plugin_args, context) - sys.exit(0) + sys.exit(ExitCodes.SUCCESS) # unknown commands if ( @@ -211,7 +216,7 @@ def main(): # pylint: disable=too-many-branches,too-many-statements and parsed.command not in HELP_TOPICS ): print(f"Unrecognized command {parsed.command}") - sys.exit(1) + sys.exit(ExitCodes.UNRECOGNIZED_COMMAND) # handle a help for a command - either --help or no action triggers this if ( @@ -236,10 +241,10 @@ def main(): # pylint: disable=too-many-branches,too-many-statements table.add_row(*row) rprint(table) - sys.exit(0) + sys.exit(ExitCodes.SUCCESS) if parsed.command is not None and parsed.action is not None: if parsed.help: print_help_action(cli, parsed.command, parsed.action) - sys.exit(0) + sys.exit(ExitCodes.SUCCESS) cli.handle_command(parsed.command, parsed.action, args) diff --git a/linodecli/api_request.py b/linodecli/api_request.py index abb851c75..d3bf909f1 100644 --- a/linodecli/api_request.py +++ b/linodecli/api_request.py @@ -12,7 +12,8 @@ from packaging import version from requests import Response -from linodecli.helpers import API_CA_PATH +from linodecli.exit_codes import ExitCodes +from linodecli.helpers import API_CA_PATH, API_VERSION_OVERRIDE from .baked.operation import ( ExplicitEmptyListValue, @@ -184,14 +185,22 @@ def _build_filter_header( def _build_request_url(ctx, operation, parsed_args) -> str: - target_server = handle_url_overrides( + url_base = handle_url_overrides( operation.url_base, host=ctx.config.get_value("api_host"), - version=ctx.config.get_value("api_version"), scheme=ctx.config.get_value("api_scheme"), ) - result = f"{target_server}{operation.url_path}".format(**vars(parsed_args)) + result = f"{url_base}{operation.url_path}".format( + # {apiVersion} is defined in the endpoint paths for + # the TechDocs API specs + apiVersion=( + API_VERSION_OVERRIDE + or ctx.config.get_value("api_version") + or operation.default_api_version + ), + **vars(parsed_args), + ) if operation.method == "get": result += f"?page={ctx.page}&page_size={ctx.page_size}" @@ -394,7 +403,7 @@ def _handle_error(ctx, response): title="errors", to=sys.stderr, ) - sys.exit(1) + sys.exit(ExitCodes.REQUEST_FAILED) def _check_retry(response): diff --git a/linodecli/arg_helpers.py b/linodecli/arg_helpers.py index dc1c24990..36b0d2c59 100644 --- a/linodecli/arg_helpers.py +++ b/linodecli/arg_helpers.py @@ -11,6 +11,7 @@ import yaml from linodecli import plugins +from linodecli.exit_codes import ExitCodes from linodecli.helpers import ( register_args_shared, register_debug_arg, @@ -183,6 +184,6 @@ def bake_command(cli, spec_loc): raise RuntimeError(f"Request failed to {spec_loc}") except Exception as e: print(f"Could not load spec: {e}") - sys.exit(2) + sys.exit(ExitCodes.REQUEST_FAILED) cli.bake(spec) diff --git a/linodecli/baked/operation.py b/linodecli/baked/operation.py index 3bd1c6fbf..1494792cc 100644 --- a/linodecli/baked/operation.py +++ b/linodecli/baked/operation.py @@ -11,13 +11,15 @@ from collections import defaultdict from getpass import getpass from os import environ, path -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlparse import openapi3.paths -from openapi3.paths import Operation +from openapi3.paths import Operation, Parameter from linodecli.baked.request import OpenAPIFilteringRequest, OpenAPIRequest from linodecli.baked.response import OpenAPIResponse +from linodecli.exit_codes import ExitCodes from linodecli.output.output_handler import OutputHandler from linodecli.overrides import OUTPUT_OVERRIDES @@ -294,7 +296,9 @@ class OpenAPIOperation: This is the class that should be pickled when building the CLI. """ - def __init__(self, command, operation: Operation, method, params): + def __init__( + self, command, operation: Operation, method, params + ): # pylint: disable=too-many-locals,too-many-branches,too-many-statements """ Wraps an openapi3.Operation object and handles pulling out values relevant to the Linode CLI. @@ -308,16 +312,25 @@ def __init__(self, command, operation: Operation, method, params): self.response_model = None self.allowed_defaults = None + # The legacy spec uses "200" (str) in response keys + # while the new spec uses 200 (int). + response_key = "200" if "200" in operation.responses else 200 + if ( - "200" in operation.responses - and "application/json" in operation.responses["200"].content + response_key in operation.responses + and "application/json" in operation.responses[response_key].content ): self.response_model = OpenAPIResponse( - operation.responses["200"].content["application/json"] + operation.responses[response_key].content["application/json"] ) if method in ("post", "put") and operation.requestBody: - if "application/json" in operation.requestBody.content: + content = operation.requestBody.content + + if ( + "application/json" in content + and content["application/json"].schema is not None + ): self.request = OpenAPIRequest( operation.requestBody.content["application/json"] ) @@ -345,30 +358,27 @@ def __init__(self, command, operation: Operation, method, params): self.summary = operation.summary self.description = operation.description.split(".")[0] - self.params = [OpenAPIOperationParameter(c) for c in params] - # These fields must be stored separately - # to allow them to be easily modified - # at runtime. - self.url_base = ( - operation.servers[0].url - if operation.servers - else operation._root.servers[0].url - ) + # The apiVersion attribute should not be specified as a positional argument + self.params = [ + OpenAPIOperationParameter(param) + for param in params + if param.name not in {"apiVersion"} + ] - self.url_path = operation.path[-2] + self.url_base, self.url_path, self.default_api_version = ( + self._get_api_url_components(operation, params) + ) self.url = self.url_base + self.url_path - docs_url = None - tags = operation.tags - if tags is not None and len(tags) > 0 and len(operation.summary) > 0: - tag_path = self._flatten_url_path(tags[0]) - summary_path = self._flatten_url_path(operation.summary) - docs_url = ( - f"https://www.linode.com/docs/api/{tag_path}/#{summary_path}" + self.docs_url = self._resolve_operation_docs_url(operation) + + if self.docs_url is None: + print( + f"INFO: Could not resolve docs URL for {operation}", + file=sys.stderr, ) - self.docs_url = docs_url code_samples_ext = operation.extensions.get("code-samples") self.samples = ( @@ -400,6 +410,85 @@ def _flatten_url_path(tag: str) -> str: new_tag = re.sub(r"[^a-z ]", "", new_tag).replace(" ", "-") return new_tag + @staticmethod + def _resolve_api_version( + params: List[Parameter], server_url: str + ) -> Optional[str]: + """ + Returns the API version for a given list of params and target URL. + + :param params: The params for this operation's endpoint path. + :type params: List[Parameter] + :param server_url: The URL of server for this operation. + :type server_url: str + + :returns: The default API version if the URL has a version, else None. + :rtype: Optional[str] + """ + + # Remove empty segments from the URL path, stripping the first, + # last and any duplicate slashes if necessary. + # There shouldn't be a case where this is needed, but it's + # always good to make things more resilient :) + url_path_segments = [ + seg for seg in urlparse(server_url).path.split("/") if len(seg) > 0 + ] + if len(url_path_segments) > 0: + return "/".join(url_path_segments) + + version_param = next( + ( + param + for param in params + if param.name == "apiVersion" and param.in_ == "path" + ), + None, + ) + if version_param is not None: + return version_param.schema.default + + return None + + @staticmethod + def _get_api_url_components( + operation: Operation, params: List[Parameter] + ) -> Tuple[str, str, str]: + """ + Returns the URL components for a given operation. + + :param operation: The operation to get the URL components for. + :type operation: Operation + :param params: The parameters for this operation's route. + :type params: List[Parameter] + + :returns: The base URL, path, and default API version of the operation. + :rtype: Tuple[str, str, str] + """ + + url_server = ( + operation.servers[0].url + if operation.servers + # pylint: disable-next=protected-access + else operation._root.servers[0].url + ) + + url_base = urlparse(url_server)._replace(path="").geturl() + url_path = operation.path[-2] + + api_version = OpenAPIOperation._resolve_api_version(params, url_server) + if api_version is None: + raise ValueError( + f"Failed to resolve API version for operation {operation}" + ) + + # The apiVersion is only specified in the new-style OpenAPI spec, + # so we need to manually insert it into the path to maintain + # backwards compatibility + if "{apiVersion}" not in url_path: + url_path = "/{apiVersion}" + url_path + + return url_base, url_path, api_version + def process_response_json( self, json: Dict[str, Any], handler: OutputHandler ): # pylint: disable=redefined-outer-name @@ -436,6 +525,7 @@ def _add_args_filter(self, parser: argparse.ArgumentParser): # build args for filtering filterable_args = [] + for attr in self.response_model.attrs: if not attr.filterable: continue @@ -586,7 +676,7 @@ def _validate_parent_child_conflicts(self, parsed: argparse.Namespace): file=sys.stderr, ) - sys.exit(2) + sys.exit(ExitCodes.ARGUMENT_ERROR) @staticmethod def _handle_list_items( @@ -714,3 +804,45 @@ def parse_args(self, args: Any) -> argparse.Namespace: self._validate_parent_child_conflicts(parsed) return self._handle_list_items(list_items, parsed) + + @staticmethod + def _resolve_operation_docs_url_legacy( + operation: Operation, + ) -> Optional[str]: + """ + Gets the docs URL for a given operation in the legacy OpenAPI spec. + + :param operation: The target openapi3.Operation to get the docs URL for. + :type operation: str + + :returns: The docs URL if it can be resolved, else None + :rtype: Optional[str] + """ + tags = operation.tags + if tags is None or len(tags) < 1 or len(operation.summary) < 1: + return None + + tag_path = OpenAPIOperation._flatten_url_path(tags[0]) + summary_path = OpenAPIOperation._flatten_url_path(operation.summary) + return f"https://www.linode.com/docs/api/{tag_path}/#{summary_path}" + + @staticmethod + def _resolve_operation_docs_url(operation: Operation) -> Optional[str]: + """ + Gets the docs URL for a given OpenAPI operation. + + :param operation: The target openapi3.Operation to get the docs URL for. + :type operation: str + + :returns: The docs URL if it can be resolved, else None + :rtype: Optional[str] + """ + # Case for TechDocs + if ( + operation.externalDocs is not None + and operation.externalDocs.url is not None + ): + return operation.externalDocs.url + + # Case for legacy docs + return OpenAPIOperation._resolve_operation_docs_url_legacy(operation) diff --git a/linodecli/baked/parsing.py b/linodecli/baked/parsing.py new file mode 100644 index 000000000..5125e45dc --- /dev/null +++ b/linodecli/baked/parsing.py @@ -0,0 +1,184 @@ +""" +This module contains logic related to string parsing and replacement. +""" + +import functools +import re +from html import unescape +from typing import List, Tuple + +# Sentence delimiter, split on a period followed by any type of +# whitespace (space, new line, tab, etc.) +REGEX_SENTENCE_DELIMITER = re.compile(r"\.(?:\s|$)") + +# Matches on pattern __prefix__ at the beginning of a description +# or after a comma +REGEX_TECHDOCS_PREFIX = re.compile(r"(?:, |\A)__([\w-]+)__") + +# Matches on pattern [link title](https://.../) +REGEX_MARKDOWN_LINK = re.compile(r"\[(?P.*?)]\((?P.*?)\)") + +MARKDOWN_RICH_TRANSLATION = [ + # Inline code blocks (e.g. `cool code`) + ( + re.compile( + r"`(?P[^`]+)`", + ), + "italic deep_pink3 on grey15", + ), + # Bold tag (e.g. `**bold**` or `__bold__`) + ( + re.compile( + r"\*\*(?P[^_\s]+)\*\*", + ), + "b", + ), + ( + re.compile( + r"__(?P[^_\s]+)__", + ), + "b", + ), + # Italics tag (e.g. `*italics*` or `_italics_`) + ( + re.compile( + r"\*(?P[^*\s]+)\*", + ), + "i", + ), + ( + re.compile( + r"_(?P[^_\s]+)_", + ), + "i", + ), +] + + +def markdown_to_rich_markup(markdown: str) -> str: + """ + This function returns a version of the given argument description + with the appropriate color tags. + + NOTE, Rich does support Markdown rendering, but it isn't suitable for this + use-case quite yet due to some Group(...) padding issues and limitations + with syntax themes. + + :param markdown: The argument description to colorize. + :type markdown: str + + :returns: The translated Markdown + """ + + result = markdown + + for exp, style in MARKDOWN_RICH_TRANSLATION: + result = exp.sub( + # Necessary to avoid cell-var-in-loop linter fer + functools.partial( + lambda style, match: f"[{style}]{match['text']}[/]", style + ), + result, + ) + + return result + + +def extract_markdown_links(description: str) -> Tuple[str, List[str]]: + """ + Extracts all Markdown links from the given description and + returns them alongside the stripped description. + + :param description: The description of a CLI argument. + :type description: str + + :returns: The stripped description and a list of extracted links. + :rtype: Tuple[str, List[str]] + """ + result_links = [] + + def _sub_handler(match: re.Match) -> str: + link = match["link"] + if link.startswith("/"): + link = f"https://linode.com{link}" + + result_links.append(link) + return match["text"] + + result_description = REGEX_MARKDOWN_LINK.sub(_sub_handler, description) + + return result_description, result_links + + +def get_short_description(description: str) -> str: + """ + Gets the first relevant sentence in the given description. + + :param description: The description of a CLI argument. + :type description: str + + :returns: A single sentence from the description. + :rtype: set + """ + + target_lines = description.splitlines() + relevant_lines = None + + for i, line in enumerate(target_lines): + # Edge case for descriptions starting with a note + if line.lower().startswith("__note__"): + continue + + relevant_lines = target_lines[i:] + break + + if relevant_lines is None: + raise ValueError( + f"description does not contain any relevant lines: {description}", + ) + + return REGEX_SENTENCE_DELIMITER.split("\n".join(relevant_lines), 1)[0] + "." + + +def strip_techdocs_prefixes(description: str) -> str: + """ + Removes all bold prefixes from the given description. + + :param description: The description of a CLI argument. + :type description: str + + :returns: The stripped description + :rtype: str + """ + result_description = REGEX_TECHDOCS_PREFIX.sub( + "", description.lstrip() + ).lstrip() + + return result_description + + +def process_arg_description(description: str) -> Tuple[str, str]: + """ + Processes the given raw request argument description into one suitable + for help pages, etc. + + :param description: The original description for a request argument. + :type description: str + + :returns: The description in Rich markup and original Markdown format. + :rtype: Tuple[str, str] + """ + + if description == "": + return "", "" + + result = get_short_description(description) + result = strip_techdocs_prefixes(result) + result = result.replace("\n", " ").replace("\r", " ") + + description, links = extract_markdown_links(result) + + if len(links) > 0: + description += f" See: {'; '.join(links)}" + + return unescape(markdown_to_rich_markup(description)), unescape(description) diff --git a/linodecli/baked/request.py b/linodecli/baked/request.py index 5886b7317..9cf45c207 100644 --- a/linodecli/baked/request.py +++ b/linodecli/baked/request.py @@ -2,6 +2,8 @@ Request details for a CLI Operation """ +from linodecli.baked.parsing import process_arg_description + class OpenAPIRequestArg: """ @@ -44,11 +46,16 @@ def __init__( #: the larger response model self.path = prefix + "." + name if prefix else name - #: The description of this argument, for help display - self.description = ( - schema.description.split(".")[0] if schema.description else "" + description_rich, description = process_arg_description( + schema.description or "" ) + #: The description of this argument for Markdown/plaintext display + self.description = description + + #: The description of this argument for display on the help page + self.description_rich = description_rich + #: If this argument is required for requests self.required = required diff --git a/linodecli/cli.py b/linodecli/cli.py index d9c2651e2..a9f61a86c 100644 --- a/linodecli/cli.py +++ b/linodecli/cli.py @@ -12,6 +12,7 @@ from linodecli.api_request import do_request, get_all_pages from linodecli.baked import OpenAPIOperation from linodecli.configuration import CLIConfig +from linodecli.exit_codes import ExitCodes from linodecli.output.output_handler import OutputHandler, OutputMode METHODS = ("get", "post", "put", "delete") @@ -123,7 +124,7 @@ def handle_command(self, command, action, args): operation = self.find_operation(command, action) except ValueError as e: print(e, file=sys.stderr) - sys.exit(1) + sys.exit(ExitCodes.REQUEST_FAILED) if not self.pagination: result = get_all_pages(self, operation, args) diff --git a/linodecli/configuration/auth.py b/linodecli/configuration/auth.py index e77a6501a..7c9c5e775 100644 --- a/linodecli/configuration/auth.py +++ b/linodecli/configuration/auth.py @@ -12,6 +12,7 @@ import requests +from linodecli.exit_codes import ExitCodes from linodecli.helpers import API_CA_PATH TOKEN_GENERATION_URL = "https://cloud.linode.com/profile/tokens" @@ -59,7 +60,7 @@ def _handle_response_status( print(f"Could not contact {response.url} - Error: {response.status_code}") if exit_on_error: - sys.exit(4) + sys.exit(ExitCodes.REQUEST_FAILED) # TODO: merge config do_request and cli do_request @@ -243,7 +244,7 @@ def _get_token_web(base_url: str) -> Tuple[str, str]: if username is None: print("OAuth failed. Please try again of use a token for auth.") - sys.exit(1) + sys.exit(ExitCodes.OAUTH_ERROR) # the token returned via public oauth will expire in 2 hours, which # isn't great. Instead, we're gonna make a token that expires never @@ -345,6 +346,6 @@ def log_message(self, form, *args): # pylint: disable=arguments-differ "try token using a token by invoking with `linode-cli configure --token`, " "and open an issue at https://github.com/linode/linode-cli" ) - sys.exit(1) + sys.exit(ExitCodes.OAUTH_ERROR) return serv.token diff --git a/linodecli/configuration/config.py b/linodecli/configuration/config.py index 4b4fd453e..48226a148 100644 --- a/linodecli/configuration/config.py +++ b/linodecli/configuration/config.py @@ -7,6 +7,8 @@ import sys from typing import Any, Dict, List, Optional +from linodecli.exit_codes import ExitCodes + from .auth import ( _check_full_access, _do_get_request, @@ -95,7 +97,7 @@ def set_user(self, username: str): """ if not self.config.has_section(username): print(f"User {username} is not configured!") - sys.exit(1) + sys.exit(ExitCodes.USERNAME_ERROR) self.username = username @@ -112,7 +114,7 @@ def remove_user(self, username: str): f"Cannot remove {username} as they are the default user! You can " "change the default user with: `linode-cli set-user USERNAME`" ) - sys.exit(1) + sys.exit(ExitCodes.USERNAME_ERROR) if self.config.has_section(username): self.config.remove_section(username) @@ -129,7 +131,7 @@ def print_users(self): if sec != "DEFAULT": print(f'{"*" if sec == default_user else " "} {sec}') - sys.exit(0) + sys.exit(ExitCodes.SUCCESS) def set_default_user(self, username: str): """ @@ -137,7 +139,7 @@ def set_default_user(self, username: str): """ if not self.config.has_section(username): print(f"User {username} is not configured!") - sys.exit(1) + sys.exit(ExitCodes.USERNAME_ERROR) self.config.set("DEFAULT", "default-user", username) self.write_config() @@ -263,7 +265,7 @@ def update( ENV_TOKEN_NAME, None ): print(f"User {username} is not configured.") - sys.exit(1) + sys.exit(ExitCodes.USERNAME_ERROR) if not self.config.has_section(username) or allowed_defaults is None: return namespace diff --git a/linodecli/exit_codes.py b/linodecli/exit_codes.py new file mode 100644 index 000000000..c6497cfc2 --- /dev/null +++ b/linodecli/exit_codes.py @@ -0,0 +1,22 @@ +""" +This is an enumeration of the various exit codes in Linode CLI + +""" + +from enum import IntEnum + + +class ExitCodes(IntEnum): + """ + An enumeration of the various exit codes in Linode CLI + """ + + SUCCESS = 0 + UNRECOGNIZED_COMMAND = 1 + REQUEST_FAILED = 2 + OAUTH_ERROR = 3 + USERNAME_ERROR = 4 + FIREWALL_ERROR = 5 + KUBECONFIG_ERROR = 6 + ARGUMENT_ERROR = 7 + FILE_ERROR = 8 diff --git a/linodecli/help_pages.py b/linodecli/help_pages.py index c9ca64548..ed4be7aa4 100644 --- a/linodecli/help_pages.py +++ b/linodecli/help_pages.py @@ -3,7 +3,6 @@ help pages. """ -import re import textwrap from collections import defaultdict from typing import List, Optional @@ -13,6 +12,7 @@ from rich.console import Console from rich.padding import Padding from rich.table import Table +from rich.text import Text from linodecli import plugins from linodecli.baked import OpenAPIOperation @@ -205,7 +205,7 @@ def _help_action_print_filter_args(console: Console, op: OpenAPIOperation): if filterable_attrs: console.print("[bold]You may filter results with:[/]") for attr in filterable_attrs: - console.print(f" [bold magenta]--{attr.name}[/]") + console.print(f" [bold green]--{attr.name}[/]") console.print( "\nAdditionally, you may order results using --order-by and --order." @@ -239,16 +239,14 @@ def _help_action_print_body_args( prefix = f" ({', '.join(metadata)})" if len(metadata) > 0 else "" - description = _markdown_links_to_rich( - arg.description.replace("\n", " ").replace("\r", " ") + arg_text = Text.from_markup( + f"[bold green]--{arg.path}[/][bold]{prefix}:[/] {arg.description_rich}" ) - arg_str = ( - f"[bold magenta]--{arg.path}[/][bold]{prefix}[/]: {description}" + console.print( + Padding.indent(arg_text, (arg.depth * 2) + 2), ) - console.print(Padding.indent(arg_str.rstrip(), (arg.depth * 2) + 2)) - console.print() @@ -278,8 +276,8 @@ def _help_group_arguments( # leave it as is in the result if len(group) > 1: groups.append( - # Required arguments should come first in groups - sorted(group, key=lambda v: not v.required), + # Args should be ordered by least depth -> required -> path + sorted(group, key=lambda v: (v.depth, not v.required, v.path)), ) continue @@ -306,28 +304,3 @@ def _help_group_arguments( result += groups return result - - -def _markdown_links_to_rich(text): - """ - Returns the given text with Markdown links converted to Rich-compatible links. - """ - - result = text - - # Find all Markdown links - r = re.compile(r"\[(?P.*?)]\((?P.*?)\)") - - for match in r.finditer(text): - url = match.group("link") - - # Expand the URL if necessary - if url.startswith("/"): - url = f"https://linode.com{url}" - - # Replace with more readable text - result = result.replace( - match.group(), f"{match.group('text')} ([link={url}]{url}[/link])" - ) - - return result diff --git a/linodecli/helpers.py b/linodecli/helpers.py index eb6dba6b5..f94096194 100644 --- a/linodecli/helpers.py +++ b/linodecli/helpers.py @@ -6,6 +6,7 @@ import os from argparse import ArgumentParser from pathlib import Path +from typing import Optional from urllib.parse import urlparse API_HOST_OVERRIDE = os.getenv("LINODE_CLI_API_HOST") @@ -19,17 +20,23 @@ def handle_url_overrides( - url: str, host: str = None, version: str = None, scheme: str = None + url: str, + host: Optional[str] = None, + version: Optional[str] = None, + scheme: Optional[str] = None, + override_path: bool = False, ): """ Returns the URL with the API URL environment overrides applied. + If override_path is True and the API version env var is specified, + the URL path will be updated accordingly. """ parsed_url = urlparse(url) overrides = { "netloc": API_HOST_OVERRIDE or host, - "path": API_VERSION_OVERRIDE or version, + "path": (API_VERSION_OVERRIDE or version) if override_path else None, "scheme": API_SCHEME_OVERRIDE or scheme, } diff --git a/linodecli/output/output_handler.py b/linodecli/output/output_handler.py index 47639ca70..2960887c2 100644 --- a/linodecli/output/output_handler.py +++ b/linodecli/output/output_handler.py @@ -4,6 +4,7 @@ import copy import json +import sys from argparse import Namespace from enum import Enum, auto from sys import stdout @@ -243,8 +244,8 @@ def _get_columns(self, attrs, max_depth=1): for col in self.columns.split(","): for attr in attrs: # Display this column if the format string - # matches the column_name or path of this column - if col in (attr.column_name, attr.name): + # matches the path of this column + if col == attr.name: attrs.remove(attr) columns.append(attr) @@ -444,7 +445,8 @@ def configure( print( "WARNING: '--all' is a deprecated flag, " "and will be removed in a future version. " - "Please consider use '--all-columns' instead." + "Please consider use '--all-columns' instead.", + file=sys.stderr, ) self.columns = "*" elif parsed.format: diff --git a/linodecli/plugins/firewall-editor.py b/linodecli/plugins/firewall-editor.py index a972d70f1..d141c787a 100644 --- a/linodecli/plugins/firewall-editor.py +++ b/linodecli/plugins/firewall-editor.py @@ -14,6 +14,7 @@ from rich import print as rprint from rich.table import Table +from linodecli.exit_codes import ExitCodes from linodecli.plugins import inherit_plugin_args BOLD = "\033[1m" @@ -204,7 +205,7 @@ def _get_firewall(firewall_id, client): if code != 200: print(f"Error retrieving firewall: {code}") - sys.exit(1) + sys.exit(ExitCodes.FIREWALL_ERROR) code, rules = client.call_operation( "firewalls", "rules-list", args=[firewall_id] @@ -212,7 +213,7 @@ def _get_firewall(firewall_id, client): if code != 200: print(f"Error retrieving firewall rules: {code}") - sys.exit(2) + sys.exit(ExitCodes.FIREWALL_ERROR) return firewall, rules diff --git a/linodecli/plugins/get-kubeconfig.py b/linodecli/plugins/get-kubeconfig.py index f77ce4faa..137298981 100644 --- a/linodecli/plugins/get-kubeconfig.py +++ b/linodecli/plugins/get-kubeconfig.py @@ -13,6 +13,8 @@ import yaml +from linodecli.exit_codes import ExitCodes + PLUGIN_BASE = "linode-cli get-kubeconfig" @@ -61,14 +63,14 @@ def call(args, context): if code != 200: print(f"Error retrieving kubeconfig: {code}", file=sys.stderr) - sys.exit(1) + sys.exit(ExitCodes.KUBECONFIG_ERROR) # If --label was used, fetch the kubeconfig using the provided label elif parsed.label: kubeconfig = _get_kubeconfig_by_label(parsed.label, context.client) else: print("Either --label or --id must be used.", file=sys.stderr) - sys.exit(1) + sys.exit(ExitCodes.KUBECONFIG_ERROR) # Load the specified cluster's kubeconfig and the current kubeconfig cluster_config = yaml.safe_load( @@ -105,14 +107,14 @@ def _get_kubeconfig_by_label(cluster_label, client): if code != 200: print(f"Error retrieving cluster: {code}", file=sys.stderr) - sys.exit(1) + sys.exit(ExitCodes.KUBECONFIG_ERROR) if len(cluster["data"]) == 0: print( f"Cluster with label {cluster_label} does not exist.", file=sys.stderr, ) - sys.exit(1) + sys.exit(ExitCodes.KUBECONFIG_ERROR) code, kubeconfig = client.call_operation( "lke", "kubeconfig-view", args=[str(cluster["data"][0]["id"])] @@ -120,7 +122,7 @@ def _get_kubeconfig_by_label(cluster_label, client): if code != 200: print(f"Error retrieving kubeconfig: {code}", file=sys.stderr) - sys.exit(1) + sys.exit(ExitCodes.KUBECONFIG_ERROR) return kubeconfig @@ -132,7 +134,7 @@ def _load_config(filepath): if not data: print(f"Could not load file at {filepath}", file=sys.stderr) - sys.exit(1) + sys.exit(ExitCodes.KUBECONFIG_ERROR) return data diff --git a/linodecli/plugins/image-upload.py b/linodecli/plugins/image-upload.py index 1d9e6d022..9c89c3ffc 100644 --- a/linodecli/plugins/image-upload.py +++ b/linodecli/plugins/image-upload.py @@ -14,6 +14,7 @@ import requests +from linodecli.exit_codes import ExitCodes from linodecli.plugins import inherit_plugin_args PLUGIN_BASE = "linode-cli image-upload" @@ -121,7 +122,7 @@ def call(args, context): if len(results) < 1: print(f"No file found matching pattern {filepath}") - sys.exit(2) + sys.exit(ExitCodes.FILE_ERROR) if len(results) > 1: print( @@ -132,20 +133,20 @@ def call(args, context): if not os.path.isfile(filepath): print(f"No file at {filepath}; must be a path to a valid file.") - sys.exit(2) + sys.exit(ExitCodes.FILE_ERROR) # make sure it's not larger than the max upload size if os.path.getsize(filepath) > MAX_UPLOAD_SIZE: print( f"File {filepath} is too large; compressed size must be less than 5GB" ) - sys.exit(2) + sys.exit(ExitCodes.FILE_ERROR) if not parsed.region: print( "No region provided. Please set a default region or use --region" ) - sys.exit(1) + sys.exit(ExitCodes.ARGUMENT_ERROR) label = parsed.label or os.path.basename(filepath) @@ -166,16 +167,16 @@ def call(args, context): "reconfigure the CLI with `linode-cli configure` to ensure you " "can make this request." ) - sys.exit(3) + sys.exit(ExitCodes.REQUEST_FAILED) if status == 404: print( "It looks like you are not in the Machine Images Beta, and therefore " "cannot upload images yet. Please stay tuned, or open a support ticket " "to request access." ) - sys.exit(4) + sys.exit(ExitCodes.REQUEST_FAILED) print(f"Upload failed with status {status}; response was {resp}") - sys.exit(3) + sys.exit(ExitCodes.REQUEST_FAILED) # grab the upload URL and image data image = resp["image"] diff --git a/linodecli/plugins/metadata.py b/linodecli/plugins/metadata.py index 3b186a28b..8d13e1403 100644 --- a/linodecli/plugins/metadata.py +++ b/linodecli/plugins/metadata.py @@ -16,6 +16,7 @@ from rich import print as rprint from rich.table import Table +from linodecli.exit_codes import ExitCodes from linodecli.helpers import register_debug_arg PLUGIN_BASE = "linode-cli metadata" @@ -208,7 +209,7 @@ def call(args, context): if not parsed.endpoint in COMMAND_MAP or len(args) != 0: print_help(parser) - sys.exit(0) + sys.exit(ExitCodes.SUCCESS) # make a client, but only if we weren't printing help and endpoint is valid if "--help" not in args: @@ -222,9 +223,9 @@ def call(args, context): ) from exc else: print_help(parser) - sys.exit(0) + sys.exit(ExitCodes.SUCCESS) try: COMMAND_MAP[parsed.endpoint](client) - except ApiError as e: - sys.exit(f"Error: {e}") + except ApiError: + sys.exit(ExitCodes.REQUEST_FAILED) diff --git a/linodecli/plugins/obj/__init__.py b/linodecli/plugins/obj/__init__.py index 974500028..f12d53ee9 100644 --- a/linodecli/plugins/obj/__init__.py +++ b/linodecli/plugins/obj/__init__.py @@ -19,6 +19,7 @@ from linodecli.cli import CLI from linodecli.configuration import _do_get_request from linodecli.configuration.helpers import _default_thing_input +from linodecli.exit_codes import ExitCodes from linodecli.plugins import PluginContext, inherit_plugin_args from linodecli.plugins.obj.buckets import create_bucket, delete_bucket from linodecli.plugins.obj.config import ( @@ -159,11 +160,11 @@ def set_acl(get_client, args, **kwargs): # pylint: disable=unused-argument # make sure the call is sane if parsed.acl_public and parsed.acl_private: print("You may not set the ACL to public and private in the same call") - sys.exit(1) + sys.exit(ExitCodes.REQUEST_FAILED) if not parsed.acl_public and not parsed.acl_private: print("You must choose an ACL to apply") - sys.exit(1) + sys.exit(ExitCodes.REQUEST_FAILED) acl = "public-read" if parsed.acl_public else "private" bucket = parsed.bucket @@ -180,8 +181,8 @@ def set_acl(get_client, args, **kwargs): # pylint: disable=unused-argument try: set_acl_func(**set_acl_options) - except ClientError as e: - sys.exit(e) + except ClientError: + sys.exit(ExitCodes.REQUEST_FAILED) print("ACL updated") @@ -210,15 +211,15 @@ def show_usage(get_client, args, **kwargs): # pylint: disable=unused-argument bucket_names = [ b["Name"] for b in client.list_buckets().get("Buckets", []) ] - except ClientError as e: - sys.exit(e) + except ClientError: + sys.exit(ExitCodes.REQUEST_FAILED) grand_total = 0 for b in bucket_names: try: objects = client.list_objects_v2(Bucket=b).get("Contents", []) - except ClientError as e: - sys.exit(e) + except ClientError: + sys.exit(ExitCodes.REQUEST_FAILED) total = 0 obj_count = 0 @@ -238,7 +239,7 @@ def show_usage(get_client, args, **kwargs): # pylint: disable=unused-argument print("--------") print(f"{_denominate(grand_total)} Total") - sys.exit(0) + sys.exit(ExitCodes.SUCCESS) COMMAND_MAP = { @@ -327,7 +328,7 @@ def get_credentials(cli: CLI): f"You must set both {ENV_ACCESS_KEY_NAME} " f"and {ENV_SECRET_KEY_NAME}, or neither" ) - sys.exit(1) + sys.exit(ExitCodes.REQUEST_FAILED) # not given on command line, so look them up if not access_key: @@ -367,7 +368,9 @@ def call( "'pip3 install boto3' or 'pip install boto3'" ) - sys.exit(2) # requirements not met - we can't go on + sys.exit( + ExitCodes.REQUEST_FAILED + ) # requirements not met - we can't go on clusters = get_available_cluster(context.client) if not is_help else None parser = get_obj_args_parser(clusters) @@ -379,7 +382,7 @@ def call( if not parsed.command: print_help(parser) - sys.exit(0) + sys.exit(ExitCodes.SUCCESS) access_key = None secret_key = None @@ -395,7 +398,7 @@ def call( def try_get_default_cluster(): if not context.client.defaults: print("Error: cluster is required.") - sys.exit(1) + sys.exit(ExitCodes.REQUEST_FAILED) print( "Error: No default cluster is configured. Either configure the CLI " @@ -424,7 +427,8 @@ def get_client(): get_client, args, suppress_warnings=parsed.suppress_warnings ) except ClientError as e: - sys.exit(f"Error: {e}") + print(e) + sys.exit(ExitCodes.REQUEST_FAILED) elif parsed.command == "regenerate-keys": regenerate_s3_credentials( context.client, suppress_warnings=parsed.suppress_warnings @@ -433,7 +437,7 @@ def get_client(): _configure_plugin(context.client) else: print(f"No command {parsed.command}") - sys.exit(1) + sys.exit(ExitCodes.REQUEST_FAILED) def _get_boto_client(cluster, access_key, secret_key): @@ -488,7 +492,7 @@ def _get_s3_creds(client: CLI, force: bool = False): "configure the CLI, unset the 'LINODE_CLI_TOKEN' environment " "variable and then run `linode-cli configure`." ) - sys.exit(1) + sys.exit(ExitCodes.REQUEST_FAILED) # before we do anything, can they do object storage? status, resp = client.call_operation("account", "view") @@ -497,14 +501,14 @@ def _get_s3_creds(client: CLI, force: bool = False): if status == 401: # special case - oauth token isn't allowed to do this print(NO_SCOPES_ERROR) - sys.exit(4) + sys.exit(ExitCodes.REQUEST_FAILED) if status == 403: # special case - restricted users can't use obj print(NO_ACCESS_ERROR) - sys.exit(4) + sys.exit(ExitCodes.REQUEST_FAILED) # something went wrong - give up print("Key generation failed!") - sys.exit(4) + sys.exit(ExitCodes.REQUEST_FAILED) # label caps at 50 characters - trim some stuff maybe # static characters in label account for 13 total @@ -530,14 +534,14 @@ def _get_s3_creds(client: CLI, force: bool = False): if status == 401: # special case - oauth token isn't allowed to do this print(NO_SCOPES_ERROR) - sys.exit(4) + sys.exit(ExitCodes.REQUEST_FAILED) if status == 403: # special case - restricted users can't use obj print(NO_ACCESS_ERROR) - sys.exit(4) + sys.exit(ExitCodes.REQUEST_FAILED) # something went wrong - give up print("Key generation failed!") - sys.exit(3) + sys.exit(ExitCodes.REQUEST_FAILED) access_key = resp["access_key"] secret_key = resp["secret_key"] diff --git a/linodecli/plugins/obj/buckets.py b/linodecli/plugins/obj/buckets.py index 504cb47ce..b21b7129f 100644 --- a/linodecli/plugins/obj/buckets.py +++ b/linodecli/plugins/obj/buckets.py @@ -5,8 +5,10 @@ import sys from argparse import ArgumentParser +from linodecli.exit_codes import ExitCodes from linodecli.plugins import inherit_plugin_args from linodecli.plugins.obj.config import PLUGIN_BASE +from linodecli.plugins.obj.helpers import _delete_all_objects def create_bucket( @@ -30,7 +32,7 @@ def create_bucket( client.create_bucket(Bucket=parsed.name) print(f"Bucket {parsed.name} created") - sys.exit(0) + sys.exit(ExitCodes.SUCCESS) def delete_bucket( @@ -60,23 +62,9 @@ def delete_bucket( bucket_name = parsed.name if parsed.recursive: - objects = [ - {"Key": obj.get("Key")} - for obj in client.list_objects_v2(Bucket=bucket_name).get( - "Contents", [] - ) - if obj.get("Key") - ] - client.delete_objects( - Bucket=bucket_name, - Delete={ - "Objects": objects, - "Quiet": False, - }, - ) + _delete_all_objects(client, bucket_name) client.delete_bucket(Bucket=bucket_name) - print(f"Bucket {parsed.name} removed") - sys.exit(0) + sys.exit(ExitCodes.SUCCESS) diff --git a/linodecli/plugins/obj/helpers.py b/linodecli/plugins/obj/helpers.py index 4404b2732..6441c4a76 100644 --- a/linodecli/plugins/obj/helpers.py +++ b/linodecli/plugins/obj/helpers.py @@ -9,6 +9,7 @@ from rich.table import Table +from linodecli.exit_codes import ExitCodes from linodecli.plugins.obj.config import DATE_FORMAT INVALID_PAGE_MSG = "No result to show in this page." @@ -138,6 +139,53 @@ def flip_to_page(iterable: Iterable, page: int = 1): next(iterable) except StopIteration: print(INVALID_PAGE_MSG) - sys.exit(2) + sys.exit(ExitCodes.REQUEST_FAILED) return next(iterable) + + +def _get_objects_for_deletion_from_page(object_type, page, versioned=False): + return [ + ( + {"Key": obj["Key"], "VersionId": obj["VersionId"]} + if versioned + else {"Key": obj["Key"]} + ) + for obj in page.get(object_type, []) + ] + + +def _delete_all_objects(client, bucket_name): + pages = client.get_paginator("list_objects_v2").paginate( + Bucket=bucket_name, PaginationConfig={"PageSize": 1000} + ) + for page in pages: + client.delete_objects( + Bucket=bucket_name, + Delete={ + "Objects": _get_objects_for_deletion_from_page( + "Contents", page + ), + "Quiet": False, + }, + ) + + for page in client.get_paginator("list_object_versions").paginate( + Bucket=bucket_name, PaginationConfig={"PageSize": 1000} + ): + client.delete_objects( + Bucket=bucket_name, + Delete={ + "Objects": _get_objects_for_deletion_from_page( + "Versions", page, True + ) + }, + ) + client.delete_objects( + Bucket=bucket_name, + Delete={ + "Objects": _get_objects_for_deletion_from_page( + "DeleteMarkers", page, True + ) + }, + ) diff --git a/linodecli/plugins/obj/list.py b/linodecli/plugins/obj/list.py index 220bd4f2d..47b534a82 100644 --- a/linodecli/plugins/obj/list.py +++ b/linodecli/plugins/obj/list.py @@ -7,6 +7,7 @@ from rich import print as rprint +from linodecli.exit_codes import ExitCodes from linodecli.helpers import register_pagination_args_shared from linodecli.plugins import inherit_plugin_args from linodecli.plugins.obj.config import PLUGIN_BASE @@ -79,7 +80,7 @@ def list_objects_or_buckets( results = [page] except client.exceptions.NoSuchBucket: print("No bucket named " + bucket_name) - sys.exit(2) + sys.exit(ExitCodes.REQUEST_FAILED) for item in results: objects.extend(item.get("Contents", [])) @@ -107,7 +108,7 @@ def list_objects_or_buckets( tab = _borderless_table(data) rprint(tab) - sys.exit(0) + sys.exit(ExitCodes.SUCCESS) else: # list buckets buckets = client.list_buckets().get("Buckets", []) @@ -119,7 +120,7 @@ def list_objects_or_buckets( tab = _borderless_table(data) rprint(tab) - sys.exit(0) + sys.exit(ExitCodes.SUCCESS) def list_all_objects( @@ -165,4 +166,4 @@ def list_all_objects( f"{b}/{obj['Key']}" ) - sys.exit(0) + sys.exit(ExitCodes.SUCCESS) diff --git a/linodecli/plugins/obj/objects.py b/linodecli/plugins/obj/objects.py index ba345bb67..bdcaa64ab 100644 --- a/linodecli/plugins/obj/objects.py +++ b/linodecli/plugins/obj/objects.py @@ -16,6 +16,7 @@ # by print an error message pass +from linodecli.exit_codes import ExitCodes from linodecli.helpers import expand_globs from linodecli.plugins import inherit_plugin_args from linodecli.plugins.obj.config import ( @@ -80,7 +81,7 @@ def upload_object( for f in files: file_path = Path(f).resolve() if not file_path.is_file(): - sys.exit(f"No file {file_path}") + sys.exit(ExitCodes.FILE_ERROR) to_upload.append(file_path) @@ -111,8 +112,8 @@ def upload_object( ) try: client.upload_file(**upload_options) - except S3UploadFailedError as e: - sys.exit(e) + except S3UploadFailedError: + sys.exit(ExitCodes.REQUEST_FAILED) print("Done.") @@ -175,7 +176,7 @@ def get_object( print( f"ERROR: Output directory {destination_parent} does not exist locally." ) - sys.exit(1) + sys.exit(ExitCodes.REQUEST_FAILED) response = client.head_object( Bucket=bucket, diff --git a/linodecli/plugins/plugins.py b/linodecli/plugins/plugins.py index f658750db..480ac9022 100644 --- a/linodecli/plugins/plugins.py +++ b/linodecli/plugins/plugins.py @@ -10,6 +10,7 @@ from linodecli.cli import CLI from linodecli.configuration import CLIConfig +from linodecli.exit_codes import ExitCodes from linodecli.helpers import register_args_shared THIS_FILE = Path(__file__) @@ -128,7 +129,7 @@ def invoke(name: str, args: List[str], context: PluginContext): ) except KeyError: print(f"Plugin {name} is misconfigured - please re-register it") - sys.exit(9) + sys.exit(ExitCodes.REQUEST_FAILED) try: plugin = import_module(plugin_module_name) @@ -137,7 +138,7 @@ def invoke(name: str, args: List[str], context: PluginContext): f"Expected module '{plugin_module_name}' not found. " "Either {name} is misconfigured, or the backing module was uninstalled." ) - sys.exit(10) + sys.exit(ExitCodes.REQUEST_FAILED) else: raise ValueError("No plugin named {name}") diff --git a/linodecli/plugins/region-table.py b/linodecli/plugins/region-table.py index 9024a19c5..f26d3b972 100644 --- a/linodecli/plugins/region-table.py +++ b/linodecli/plugins/region-table.py @@ -9,6 +9,8 @@ from rich.console import Console from rich.table import Table +from linodecli.exit_codes import ExitCodes + def call(_, ctx): """ @@ -32,7 +34,7 @@ def call(_, ctx): if status != 200: print("It failed :(") - sys.exit(1) + sys.exit(ExitCodes.REQUEST_FAILED) output = Table() headers = ["ID", "Label", "Loc"] + [x[1] for x in capabilities] diff --git a/linodecli/plugins/ssh.py b/linodecli/plugins/ssh.py index d057b7eef..b5107591a 100644 --- a/linodecli/plugins/ssh.py +++ b/linodecli/plugins/ssh.py @@ -15,6 +15,7 @@ from sys import platform from typing import Any, Dict, Optional, Tuple +from linodecli.exit_codes import ExitCodes from linodecli.plugins import inherit_plugin_args @@ -28,7 +29,7 @@ def call(args, context): # pylint: disable=too-many-branches "information or to suggest a fix, please visit " "https://github.com/linode/linode-cli" ) - sys.exit(1) + sys.exit(ExitCodes.REQUEST_FAILED) parser = inherit_plugin_args( argparse.ArgumentParser("linode-cli ssh", add_help=True) @@ -53,7 +54,7 @@ def call(args, context): # pylint: disable=too-many-branches if not parsed.label: parser.print_help() - sys.exit(0) + sys.exit(ExitCodes.SUCCESS) username, label = parse_target_components(parsed.label) @@ -63,7 +64,7 @@ def call(args, context): # pylint: disable=too-many-branches print( f"{label} is not running (status is {target['status']}); operation aborted." ) - sys.exit(2) + sys.exit(ExitCodes.REQUEST_FAILED) # find a public IP Address to use address = parse_target_address(parsed, target) @@ -95,7 +96,7 @@ def find_linode_with_label(context, label: str) -> str: if result != 200: print(f"Could not retrieve Linode: {result} error") - sys.exit(2) + sys.exit(ExitCodes.REQUEST_FAILED) potential_matches = potential_matches["data"] @@ -111,7 +112,7 @@ def find_linode_with_label(context, label: str) -> str: print("Did you mean: ") print("\n".join([f" {p['label']}" for p in potential_matches])) - sys.exit(1) + sys.exit(ExitCodes.REQUEST_FAILED) def parse_target_components(label: str) -> Tuple[Optional[str], str]: diff --git a/scripts/lke-policy.yaml b/scripts/lke-policy.yaml new file mode 100644 index 000000000..9859ca8b4 --- /dev/null +++ b/scripts/lke-policy.yaml @@ -0,0 +1,78 @@ +apiVersion: projectcalico.org/v3 +kind: GlobalNetworkPolicy +metadata: + name: lke-rules +spec: + preDNAT: true + applyOnForward: true + order: 100 + # Remember to run calicoctl patch command for this to work + selector: "" + ingress: + # Allow ICMP + - action: Allow + protocol: ICMP + - action: Allow + protocol: ICMPv6 + + # Allow LKE-required ports + - action: Allow + protocol: TCP + destination: + nets: + - 192.168.128.0/17 + - 10.0.0.0/8 + ports: + - 10250 + - 10256 + - 179 + - action: Allow + protocol: UDP + destination: + nets: + - 192.168.128.0/17 + - 10.2.0.0/16 + ports: + - 51820 + + # Allow NodeBalancer ingress to the Node Ports & Allow DNS + - action: Allow + protocol: TCP + source: + nets: + - 192.168.255.0/24 + - 10.0.0.0/8 + destination: + ports: + - 53 + - 30000:32767 + - action: Allow + protocol: UDP + source: + nets: + - 192.168.255.0/24 + - 10.0.0.0/8 + destination: + ports: + - 53 + - 30000:32767 + + # Allow cluster internal communication + - action: Allow + destination: + nets: + - 10.0.0.0/8 + - action: Allow + source: + nets: + - 10.0.0.0/8 + + # 127.0.0.1/32 is needed for kubectl exec and node-shell + - action: Allow + destination: + nets: + - 127.0.0.1/32 + + # Block everything else + - action: Deny + - action: Log diff --git a/scripts/lke_calico_rules_e2e.sh b/scripts/lke_calico_rules_e2e.sh new file mode 100755 index 000000000..48ad5caec --- /dev/null +++ b/scripts/lke_calico_rules_e2e.sh @@ -0,0 +1,60 @@ +#!/bin/bash + +RETRIES=3 +DELAY=30 + +# Function to retry a command with exponential backoff +retry_command() { + local retries=$1 + local wait_time=60 + shift + until "$@"; do + if ((retries == 0)); then + echo "Command failed after multiple retries. Exiting." + exit 1 + fi + echo "Command failed. Retrying in $wait_time seconds..." + sleep $wait_time + ((retries--)) + wait_time=$((wait_time * 2)) + done +} + +# Fetch the list of LKE cluster IDs +CLUSTER_IDS=$(curl -s -H "Authorization: Bearer $LINODE_TOKEN" \ + -H "Content-Type: application/json" \ + "https://api.linode.com/v4/lke/clusters" | jq -r '.data[].id') + +# Check if CLUSTER_IDS is empty +if [ -z "$CLUSTER_IDS" ]; then + echo "All clusters have been cleaned and properly destroyed. No need to apply inbound or outbound rules" + exit 0 +fi + +for ID in $CLUSTER_IDS; do + echo "Applying Calico rules to nodes in Cluster ID: $ID" + + # Download cluster configuration file with retry + for ((i=1; i<=RETRIES; i++)); do + config_response=$(curl -sH "Authorization: Bearer $LINODE_TOKEN" "https://api.linode.com/v4/lke/clusters/$ID/kubeconfig") + if [[ $config_response != *"kubeconfig is not yet available"* ]]; then + echo $config_response | jq -r '.[] | @base64d' > "/tmp/${ID}_config.yaml" + break + fi + echo "Attempt $i to download kubeconfig for cluster $ID failed. Retrying in $DELAY seconds..." + sleep $DELAY + done + + if [[ $config_response == *"kubeconfig is not yet available"* ]]; then + echo "kubeconfig for cluster id:$ID not available after $RETRIES attempts, mostly likely it is an empty cluster. Skipping..." + else + # Export downloaded config file + export KUBECONFIG="/tmp/${ID}_config.yaml" + + retry_command $RETRIES kubectl get nodes + + retry_command $RETRIES calicoctl patch kubecontrollersconfiguration default --allow-version-mismatch --patch='{"spec": {"controllers": {"node": {"hostEndpoint": {"autoCreate": "Enabled"}}}}}' + + retry_command $RETRIES calicoctl apply --allow-version-mismatch -f "$(pwd)/lke-policy.yaml" + fi +done diff --git a/tests/fixtures/api_request_test_foobar_get.yaml b/tests/fixtures/api_request_test_foobar_get.yaml index 39cd2d49d..f7dd7704e 100644 --- a/tests/fixtures/api_request_test_foobar_get.yaml +++ b/tests/fixtures/api_request_test_foobar_get.yaml @@ -4,7 +4,6 @@ info: version: 1.0.0 servers: - url: http://localhost/v4 - paths: /foo/bar: get: diff --git a/tests/fixtures/api_request_test_foobar_post.yaml b/tests/fixtures/api_request_test_foobar_post.yaml index 5ce433eb7..0dbb6e65b 100644 --- a/tests/fixtures/api_request_test_foobar_post.yaml +++ b/tests/fixtures/api_request_test_foobar_post.yaml @@ -3,7 +3,7 @@ info: title: API Specification version: 1.0.0 servers: - - url: http://localhost + - url: http://localhost/v4 paths: /foo/bar: diff --git a/tests/fixtures/api_url_components_test.yaml b/tests/fixtures/api_url_components_test.yaml new file mode 100644 index 000000000..162ceabae --- /dev/null +++ b/tests/fixtures/api_url_components_test.yaml @@ -0,0 +1,48 @@ +openapi: 3.0.1 +info: + title: API Specification + version: 1.0.0 +servers: + - url: http://localhost/v19 +paths: + /foo/bar: + get: + operationId: fooBarGet + responses: + '200': + description: foobar + content: + application/json: {} + delete: + operationId: fooBarDelete + servers: + - url: http://localhost/v12beta + responses: + '200': + description: foobar + content: + application/json: {} + /{apiVersion}/bar/foo: + parameters: + - name: apiVersion + in: path + required: true + schema: + type: string + default: v9canary + get: + operationId: barFooGet + responses: + '200': + description: foobar + content: + application/json: {} + post: + operationId: barFooPost + servers: + - url: http://localhost/v100beta + responses: + '200': + description: foobar + content: + application/json: {} diff --git a/tests/fixtures/docs_url_test.yaml b/tests/fixtures/docs_url_test.yaml new file mode 100644 index 000000000..4f6003a35 --- /dev/null +++ b/tests/fixtures/docs_url_test.yaml @@ -0,0 +1,28 @@ +openapi: 3.0.1 +info: + title: API Specification + version: 1.0.0 +servers: + - url: http://localhost/v4 +paths: + /foo/bar: + get: + summary: get info + operationId: BarGet + description: This is description + tags: + - Foo + responses: + '200': + description: Successful response + content: + application/json: {} + post: + externalDocs: + description: cool docs url + url: https://techdocs.akamai.com/linode-api/reference/cool-docs-url + responses: + '200': + description: Successful response + content: + application/json: {} diff --git a/tests/fixtures/output_test_get.yaml b/tests/fixtures/output_test_get.yaml index 7c98bc57f..0abb06289 100644 --- a/tests/fixtures/output_test_get.yaml +++ b/tests/fixtures/output_test_get.yaml @@ -3,7 +3,7 @@ info: title: API Specification version: 1.0.0 servers: - - url: http://localhost + - url: http://localhost/v4 paths: /foo/bar: diff --git a/tests/fixtures/overrides_test_get.yaml b/tests/fixtures/overrides_test_get.yaml index a7bea3194..3f2ec2a40 100644 --- a/tests/fixtures/overrides_test_get.yaml +++ b/tests/fixtures/overrides_test_get.yaml @@ -3,7 +3,7 @@ info: title: API Specification version: 1.0.0 servers: - - url: http://localhost + - url: http://localhost/v4 paths: /foo/bar: diff --git a/tests/fixtures/response_test_get.yaml b/tests/fixtures/response_test_get.yaml index 9b899250f..953ea2784 100644 --- a/tests/fixtures/response_test_get.yaml +++ b/tests/fixtures/response_test_get.yaml @@ -3,7 +3,7 @@ info: title: API Specification version: 1.0.0 servers: - - url: http://localhost + - url: http://localhost/v4 paths: /foo/bar: diff --git a/tests/fixtures/subtable_test_get.yaml b/tests/fixtures/subtable_test_get.yaml index a53128dd4..fe06824cb 100644 --- a/tests/fixtures/subtable_test_get.yaml +++ b/tests/fixtures/subtable_test_get.yaml @@ -3,7 +3,7 @@ info: title: API Specification version: 1.0.0 servers: - - url: http://localhost + - url: http://localhost/v4 paths: /foo/bar: diff --git a/tests/integration/account/test_account.py b/tests/integration/account/test_account.py index 6c52aee59..f262652d5 100644 --- a/tests/integration/account/test_account.py +++ b/tests/integration/account/test_account.py @@ -244,7 +244,7 @@ def get_user_id(): "--delimiter", ",", "--format", - "id", + "username", ] ) .stdout.decode() diff --git a/tests/integration/beta/test_beta_program.py b/tests/integration/beta/test_beta_program.py index 4628de1c3..240272dff 100644 --- a/tests/integration/beta/test_beta_program.py +++ b/tests/integration/beta/test_beta_program.py @@ -22,7 +22,7 @@ def test_beta_list(): @pytest.fixture def get_beta_id(): - beta_id = ( + beta_ids = ( exec_test_command( BASE_CMD + [ @@ -39,7 +39,10 @@ def get_beta_id(): .rstrip() .splitlines() ) - first_id = beta_id[0] + if not beta_ids or beta_ids == [""]: + pytest.skip("No betas available to test.") + + first_id = beta_ids[0] yield first_id diff --git a/tests/integration/cli/test_help.py b/tests/integration/cli/test_help.py index 93eb19abb..6c7b848a0 100644 --- a/tests/integration/cli/test_help.py +++ b/tests/integration/cli/test_help.py @@ -2,7 +2,10 @@ import pytest -from tests.integration.helpers import exec_test_command +from tests.integration.helpers import ( + contains_at_least_one_of, + exec_test_command, +) @pytest.mark.smoke @@ -11,11 +14,18 @@ def test_help_page_for_non_aliased_actions(): output = process.stdout.decode() wrapped_output = textwrap.fill(output, width=150).replace("\n", "") - assert "Linodes List" in wrapped_output - assert ( - "API Documentation: https://www.linode.com/docs/api/linode-instances/#linodes-list" - in wrapped_output + assert contains_at_least_one_of( + wrapped_output, ["Linodes List", "List Linodes"] ) + + assert contains_at_least_one_of( + wrapped_output, + [ + "API Documentation: https://www.linode.com/docs/api/linode-instances/#linodes-list", + "API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-instances", + ], + ) + assert "You may filter results with:" in wrapped_output assert "--tags" in wrapped_output @@ -26,10 +36,17 @@ def test_help_page_for_aliased_actions(): output = process.stdout.decode() wrapped_output = textwrap.fill(output, width=150).replace("\n", "") - assert "Linodes List" in wrapped_output - assert ( - "API Documentation: https://www.linode.com/docs/api/linode-instances/#linodes-list" - in wrapped_output + assert contains_at_least_one_of( + wrapped_output, ["Linodes List", "List Linodes"] ) + + assert contains_at_least_one_of( + wrapped_output, + [ + "API Documentation: https://www.linode.com/docs/api/linode-instances/#linodes-list", + "API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-instances", + ], + ) + assert "You may filter results with:" in wrapped_output assert "--tags" in wrapped_output diff --git a/tests/integration/cli/test_host_overrides.py b/tests/integration/cli/test_host_overrides.py index e9ae66900..9ccbf41da 100644 --- a/tests/integration/cli/test_host_overrides.py +++ b/tests/integration/cli/test_host_overrides.py @@ -2,13 +2,16 @@ from pytest import MonkeyPatch +from linodecli.exit_codes import ExitCodes from tests.integration.helpers import INVALID_HOST, exec_failing_test_command def test_cli_command_fails_to_access_invalid_host(monkeypatch: MonkeyPatch): monkeypatch.setenv("LINODE_CLI_API_HOST", INVALID_HOST) - process = exec_failing_test_command(["linode-cli", "linodes", "ls"]) + process = exec_failing_test_command( + ["linode-cli", "linodes", "ls"], ExitCodes.UNRECOGNIZED_COMMAND + ) output = process.stderr.decode() expected_output = ["Max retries exceeded with url:", "wrongapi.linode.com"] @@ -29,7 +32,9 @@ def test_cli_command_fails_to_access_invalid_api_scheme( monkeypatch: MonkeyPatch, ): monkeypatch.setenv("LINODE_CLI_API_SCHEME", "ssh") - process = exec_failing_test_command(["linode-cli", "linodes", "ls"]) + process = exec_failing_test_command( + ["linode-cli", "linodes", "ls"], ExitCodes.UNRECOGNIZED_COMMAND + ) output = process.stderr.decode() assert "ssh://" in output diff --git a/tests/integration/domains/test_domain_records.py b/tests/integration/domains/test_domain_records.py index b8ea8d867..397ef18f0 100644 --- a/tests/integration/domains/test_domain_records.py +++ b/tests/integration/domains/test_domain_records.py @@ -5,6 +5,7 @@ from tests.integration.helpers import ( SUCCESS_STATUS_CODE, + contains_at_least_one_of, delete_target_id, exec_test_command, ) @@ -207,7 +208,9 @@ def test_help_records_list(test_domain_and_record): ) output = process.stdout.decode() - assert "Domain Records List" in output + assert contains_at_least_one_of( + output, ["List domain records", "Domain Records List"] + ) assert "You may filter results with:" in output assert "--type" in output assert "--name" in output diff --git a/tests/integration/events/test_events.py b/tests/integration/events/test_events.py index bc0d4d809..066f272f4 100644 --- a/tests/integration/events/test_events.py +++ b/tests/integration/events/test_events.py @@ -43,10 +43,15 @@ def test_print_events_usage_information(): output = process.stdout.decode() assert "linode-cli events [ACTION]" in output - assert re.search("mark-read.*Event Mark as Read", output) - assert re.search("mark-seen.*Event Mark as Seen", output) - assert re.search("list.*Events List", output) - assert re.search("view.*Event View", output) + + assert re.search( + "mark-read.*(Event Mark as Read|Mark an event as read)", output + ) + assert re.search( + "mark-seen.*(Event Mark as Seen|Mark an event as seen)", output + ) + assert re.search("list.*(Events List|List events)", output) + assert re.search("view.*(Event View|Get an event)", output) @pytest.mark.smoke diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index da52828d8..906c75bb3 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -2,7 +2,10 @@ import subprocess import time from string import ascii_lowercase -from typing import Callable, List +from typing import Callable, Container, Iterable, List, TypeVar + +from linodecli import ExitCodes +from linodecli.exit_codes import ExitCodes BASE_URL = "https://api.linode.com/v4/" INVALID_HOST = "https://wrongapi.linode.com" @@ -10,6 +13,9 @@ FAILED_STATUS_CODE = 256 COMMAND_JSON_OUTPUT = ["--suppress-warnings", "--no-defaults", "--json"] +# TypeVars for generic type hints below +T = TypeVar("T") + def get_random_text(length: int = 10): return "".join(random.choice(ascii_lowercase) for i in range(length)) @@ -34,7 +40,9 @@ def exec_test_command(args: List[str]): return process -def exec_failing_test_command(args: List[str], expected_code: int = 1): +def exec_failing_test_command( + args: List[str], expected_code: int = ExitCodes.REQUEST_FAILED +): process = subprocess.run(args, stderr=subprocess.PIPE) assert process.returncode == expected_code return process @@ -137,3 +145,7 @@ def count_lines(text: str): def assert_headers_in_lines(headers, lines): for header in headers: assert header in lines[0] + + +def contains_at_least_one_of(target: Container[T], search_for: Iterable[T]): + return any(v in target for v in search_for) diff --git a/tests/integration/image/test_plugin_image_upload.py b/tests/integration/image/test_plugin_image_upload.py index ce4d0cf4b..e17b5130a 100644 --- a/tests/integration/image/test_plugin_image_upload.py +++ b/tests/integration/image/test_plugin_image_upload.py @@ -57,7 +57,7 @@ def test_invalid_file( ) output = process.stdout.decode() - assert process.returncode == 2 + assert process.returncode == 8 assert f"No file at {file_path}" in output diff --git a/tests/integration/linodes/test_backups.py b/tests/integration/linodes/test_backups.py index 73c1cd567..474c6a756 100755 --- a/tests/integration/linodes/test_backups.py +++ b/tests/integration/linodes/test_backups.py @@ -73,7 +73,7 @@ def test_create_linode_with_backup_disabled( "list", "--id", linode_id, - "--format=id,enabled", + "--format=id,backups.enabled", "--delimiter", ",", "--text", @@ -102,7 +102,7 @@ def test_enable_backups(create_linode_setup): BASE_CMD + [ "list", - "--format=id,enabled", + "--format=id,backups.enabled", "--delimiter", ",", "--text", @@ -119,7 +119,7 @@ def test_create_backup_with_backup_enabled(linode_backup_enabled): BASE_CMD + [ "list", - "--format=id,enabled", + "--format=id,backups.enabled", "--delimiter", ",", "--text", diff --git a/tests/integration/networking/test_networking.py b/tests/integration/networking/test_networking.py index a7612b138..2d29dd166 100644 --- a/tests/integration/networking/test_networking.py +++ b/tests/integration/networking/test_networking.py @@ -57,14 +57,14 @@ def test_display_ips_for_available_linodes(test_linode_id): BASE_CMD + ["ips-list", "--text", "--no-headers", "--delimiter", ","] ).stdout.decode() - assert re.search("^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}", result) + assert re.search(r"^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}", result) assert re.search( - "ipv4,True,[0-9]{1,3}\-[0-9]{1,3}\-[0-9]{1,3}\-[0-9]{1,3}\.ip.linodeusercontent.com,.*,[0-9][0-9][0-9][0-9][0-9][0-9][0-9]*", + r"ipv4,True,[0-9]{1,3}\-[0-9]{1,3}\-[0-9]{1,3}\-[0-9]{1,3}\.ip.linodeusercontent.com,.*,[0-9][0-9][0-9][0-9][0-9][0-9][0-9]*", result, ) assert re.search("ipv6,True,,.*,[0-9][0-9][0-9][0-9][0-9][0-9]*", result) assert re.search( - "(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))", + r"(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))", result, ) @@ -97,7 +97,7 @@ def test_view_an_ip_address(test_linode_id): ] ).stdout.decode() - assert re.search("^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}", result) + assert re.search(r"^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}", result) def test_allocate_additional_private_ipv4_address(test_linode_id): @@ -120,7 +120,7 @@ def test_allocate_additional_private_ipv4_address(test_linode_id): ] ).stdout.decode() - assert re.search("^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}", result) + assert re.search(r"^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}", result) assert re.search( "ipv4,False,.*,[0-9][0-9][0-9][0-9][0-9][0-9][0-9]*", result ) diff --git a/tests/integration/nodebalancers/test_node_balancers.py b/tests/integration/nodebalancers/test_node_balancers.py index 728be8770..419d3b9aa 100644 --- a/tests/integration/nodebalancers/test_node_balancers.py +++ b/tests/integration/nodebalancers/test_node_balancers.py @@ -212,7 +212,7 @@ def test_display_public_ipv4_for_nodebalancer(test_node_balancers): "--no-headers", ] ).stdout.decode() - assert re.search("^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}", result) + assert re.search(r"^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}", result) def test_fail_to_view_nodebalancer_with_invalid_id(): diff --git a/tests/integration/obj/test_obj_plugin.py b/tests/integration/obj/test_obj_plugin.py index 3d04aa9e1..0bcaf88da 100644 --- a/tests/integration/obj/test_obj_plugin.py +++ b/tests/integration/obj/test_obj_plugin.py @@ -1,5 +1,6 @@ import json import logging +from concurrent.futures import ThreadPoolExecutor, wait from dataclasses import dataclass from typing import Callable, Optional @@ -94,8 +95,8 @@ def _create_bucket(bucket_name: Optional[str] = None): for bk in created_buckets: try: delete_bucket(bk) - except: - logging.exception(f"Failed to cleanup bucket: {bk}") + except Exception as e: + logging.exception(f"Failed to cleanup bucket: {bk}, {e}") def delete_bucket(bucket_name: str, force: bool = True): @@ -210,6 +211,31 @@ def test_multi_files_multi_bucket( assert "Done" in output +@pytest.mark.parametrize("num_files", [1005]) +def test_large_number_of_files_single_bucket_parallel( + create_bucket: Callable[[Optional[str]], str], + generate_test_files: GetTestFilesType, + keys: Keys, + monkeypatch: MonkeyPatch, + num_files: int, +): + patch_keys(keys, monkeypatch) + + bucket_name = create_bucket() + file_paths = generate_test_files(num_files) + + with ThreadPoolExecutor(50) as executor: + futures = [ + executor.submit( + exec_test_command, + BASE_CMD + ["put", str(file.resolve()), bucket_name], + ) + for file in file_paths + ] + + wait(futures) + + def test_all_rows( create_bucket: Callable[[Optional[str]], str], generate_test_files: GetTestFilesType, diff --git a/tests/integration/ssh/test_plugin_ssh.py b/tests/integration/ssh/test_plugin_ssh.py index 873f82097..610e4ec29 100644 --- a/tests/integration/ssh/test_plugin_ssh.py +++ b/tests/integration/ssh/test_plugin_ssh.py @@ -24,7 +24,6 @@ POLL_INTERVAL = 5 -@pytest.mark.skipif(platform == "win32", reason="Test N/A on Windows") @pytest.fixture def target_instance(ssh_key_pair_generator, linode_cloud_firewall): instance_label = f"cli-test-{get_random_text(length=6)}" diff --git a/tests/integration/ssh/test_ssh.py b/tests/integration/ssh/test_ssh.py index fc63a0819..219f4e840 100644 --- a/tests/integration/ssh/test_ssh.py +++ b/tests/integration/ssh/test_ssh.py @@ -13,7 +13,6 @@ SSH_SLEEP_PERIOD = 50 -@pytest.mark.skipif(platform == "win32", reason="Test N/A on Windows") @pytest.fixture(scope="package") def linode_in_running_state(ssh_key_pair_generator, linode_cloud_firewall): pubkey_file, privkey_file = ssh_key_pair_generator @@ -37,7 +36,7 @@ def linode_in_running_state(ssh_key_pair_generator, linode_cloud_firewall): .rstrip() ) - alpine_image = re.findall("linode/alpine[^\s]+", res)[0] + alpine_image = re.findall(r"linode/alpine[^\s]+", res)[0] plan = ( exec_test_command( @@ -118,7 +117,7 @@ def test_ssh_to_linode_and_get_kernel_version( + " -o StrictHostKeyChecking=no -o IdentitiesOnly=yes uname -r" ).read() - assert re.search("[0-9]\.[0-9]*\.[0-9]*-.*-virt", output) + assert re.search(r"[0-9]\.[0-9]*\.[0-9]*-.*-virt", output) @pytest.mark.skipif(platform == "win32", reason="Test N/A on Windows") diff --git a/tests/integration/stackscripts/test_stackscripts.py b/tests/integration/stackscripts/test_stackscripts.py index 07ecd2965..a81825c4e 100644 --- a/tests/integration/stackscripts/test_stackscripts.py +++ b/tests/integration/stackscripts/test_stackscripts.py @@ -33,7 +33,7 @@ def get_linode_image_lists(): .rstrip() ) - images = re.findall("linode/[^\s]+", all_images) + images = re.findall(r"linode/[^\s]+", all_images) return images diff --git a/tests/integration/support/test_support.py b/tests/integration/support/test_support.py index 06343ede9..f9aee1ae3 100644 --- a/tests/integration/support/test_support.py +++ b/tests/integration/support/test_support.py @@ -37,7 +37,7 @@ def test_tickets_list(): @pytest.fixture def tickets_id(): - ticket_ids = ( + res = ( exec_test_command( BASE_CMD + [ @@ -52,13 +52,18 @@ def tickets_id(): ) .stdout.decode() .rstrip() - .split(",") ) + ticket_ids = res.splitlines() + if not ticket_ids or ticket_ids == [""]: + pytest.skip("No support tickets available to test.") first_id = ticket_ids[0] yield first_id def test_tickets_view(tickets_id): + if not tickets_id: + pytest.skip("No support tickets available to view.") + ticket_id = tickets_id res = ( exec_test_command( @@ -91,6 +96,9 @@ def test_reply_support_ticket(tickets_id): def test_view_replies_support_ticket(tickets_id): + if not tickets_id: + pytest.skip("No support tickets available to view replies.") + ticket_id = tickets_id res = ( exec_test_command( diff --git a/tests/integration/volumes/test_volumes.py b/tests/integration/volumes/test_volumes.py index 7430c1157..ed60aedc3 100644 --- a/tests/integration/volumes/test_volumes.py +++ b/tests/integration/volumes/test_volumes.py @@ -147,10 +147,20 @@ def test_fail_to_create_volume_with_all_numberic_label(): def test_list_volume(test_volume_id): result = exec_test_command( - BASE_CMD + ["list", "--text", "--no-headers", "--delimiter", ","] + BASE_CMD + + [ + "list", + "--text", + "--no-headers", + "--delimiter", + ",", + "--format", + "id,label,status,region,size,linode_id,linode_label", + ] ).stdout.decode() assert re.search( - "[0-9]+,[A-Za-z0-9].*,.*,(creating|active|offline),.*", result + "[0-9]+,[A-Za-z0-9-]+,(creating|active|offline),[A-Za-z0-9-]+,[0-9]+,,", + result, ) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index cc1216c23..0d6031793 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -106,7 +106,7 @@ def list_operation(): linode-cli foo bar --filterable_result [value] - GET http://localhost/foo/bar + GET http://localhost/v4/foo/bar {} X-Filter: {"filterable_result": "value"} @@ -135,7 +135,7 @@ def create_operation(): linode-cli foo bar --generic_arg [generic_arg] test_param - POST http://localhost/foo/bar + POST http://localhost/v4/foo/bar { "generic_arg": "[generic_arg]", "test_param": test_param @@ -164,7 +164,7 @@ def list_operation_for_output_tests(): """ Creates the following CLI operation: - GET http://localhost/foo/bar + GET http://localhost/v4/foo/bar {} X-Filter: {"cool": "value"} @@ -192,7 +192,7 @@ def list_operation_for_overrides_test(): """ Creates the following CLI operation: - GET http://localhost/foo/bar + GET http://localhost/v4/foo/bar {} X-Filter: {"cool": "value"} @@ -221,7 +221,7 @@ def list_operation_for_response_test(): """ Creates the following CLI operation: - GET http://localhost/foo/bar + GET http://localhost/v4/foo/bar {} X-Filter: {"cool": "value"} @@ -250,7 +250,7 @@ def get_operation_for_subtable_test(): """ Creates the following CLI operation: - GET http://localhost/foo/bar + GET http://localhost/v4/foo/bar Returns { "table": [ @@ -288,6 +288,26 @@ def get_operation_for_subtable_test(): return make_test_operation(command, operation, method, path.parameters) +@pytest.fixture +def get_openapi_for_api_components_tests() -> OpenAPI: + """ + Creates a set of OpenAPI operations with various apiVersion and + `server` configurations. + """ + + return _get_parsed_spec("api_url_components_test.yaml") + + +@pytest.fixture +def get_openapi_for_docs_url_tests() -> OpenAPI: + """ + Creates a set of OpenAPI operations with a GET endpoint using the + legacy-style docs URL and a POST endpoint using the new-style docs URL. + """ + + return _get_parsed_spec("docs_url_test.yaml") + + @pytest.fixture def mocked_config(): """ diff --git a/tests/unit/test_api_request.py b/tests/unit/test_api_request.py index 24cfc434f..96fafe33a 100644 --- a/tests/unit/test_api_request.py +++ b/tests/unit/test_api_request.py @@ -187,7 +187,7 @@ def test_build_request_url_post(self, mock_cli, create_operation): mock_cli, create_operation, SimpleNamespace() ) - assert "http://localhost/foo/bar" == result + assert "http://localhost/v4/foo/bar" == result def test_build_filter_header(self, list_operation): result = api_request._build_filter_header( @@ -363,7 +363,7 @@ def test_do_request_post(self, mock_cli, create_operation): mock_response = Mock(status_code=200, reason="OK") def validate_http_request(url, headers=None, data=None, **kwargs): - assert url == "http://localhost/foo/bar" + assert url == "http://localhost/v4/foo/bar" assert data == json.dumps( { "test_param": 12345, diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index 25529bdfb..ff85944cc 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -82,7 +82,7 @@ def test_set_user(self): with contextlib.redirect_stdout(f): conf.set_user("bad_user") - assert err.value.code == 1 + assert err.value.code == 4 assert "not configured" in f.getvalue() conf.set_user("cli-dev2") @@ -101,7 +101,7 @@ def test_remove_user(self): conf.remove_user("cli-dev") assert "default user!" in f.getvalue() - assert err.value.code == 1 + assert err.value.code == 4 with patch("linodecli.configuration.open", mock_open()): conf.remove_user("cli-dev2") @@ -133,7 +133,7 @@ def test_set_default_user(self): with contextlib.redirect_stdout(f): conf.set_default_user("bad_user") - assert err.value.code == 1 + assert err.value.code == 4 assert "not configured" in f.getvalue() with patch("linodecli.configuration.open", mock_open()): diff --git a/tests/unit/test_help_pages.py b/tests/unit/test_help_pages.py index dcd8bb9b2..3c6fc6474 100644 --- a/tests/unit/test_help_pages.py +++ b/tests/unit/test_help_pages.py @@ -4,55 +4,58 @@ class TestHelpPages: - def test_filter_markdown_links(self): - """ - Ensures that Markdown links are properly converted to their rich equivalents. - """ - - original_text = "Here's [a relative link](/docs/cool) and [an absolute link](https://cloud.linode.com)." - expected_text = ( - "Here's a relative link ([link=https://linode.com/docs/cool]https://linode.com/docs/cool[/link]) " - "and an absolute link ([link=https://cloud.linode.com]https://cloud.linode.com[/link])." - ) - - assert ( - help_pages._markdown_links_to_rich(original_text) == expected_text - ) - def test_group_arguments(self, capsys): # NOTE: We use SimpleNamespace here so we can do deep comparisons using == args = [ SimpleNamespace( - read_only=False, - required=True, - path="foo", + read_only=False, required=False, depth=0, path="foobaz" + ), + SimpleNamespace( + read_only=False, required=False, depth=0, path="foobar" + ), + SimpleNamespace( + read_only=False, required=True, depth=0, path="barfoo" + ), + SimpleNamespace( + read_only=False, required=False, depth=0, path="foo" + ), + SimpleNamespace( + read_only=False, required=False, depth=1, path="foo.bar" + ), + SimpleNamespace( + read_only=False, required=False, depth=1, path="foo.foo" + ), + SimpleNamespace( + read_only=False, required=True, depth=1, path="foo.baz" ), - SimpleNamespace(read_only=False, required=False, path="foo.bar"), - SimpleNamespace(read_only=False, required=False, path="foobaz"), - SimpleNamespace(read_only=False, required=False, path="foo.foo"), - SimpleNamespace(read_only=False, required=False, path="foobar"), - SimpleNamespace(read_only=False, required=True, path="barfoo"), ] expected = [ [ - SimpleNamespace(read_only=False, required=True, path="barfoo"), + SimpleNamespace( + read_only=False, required=True, path="barfoo", depth=0 + ), ], [ - SimpleNamespace(read_only=False, required=False, path="foobar"), - SimpleNamespace(read_only=False, required=False, path="foobaz"), + SimpleNamespace( + read_only=False, required=False, path="foobar", depth=0 + ), + SimpleNamespace( + read_only=False, required=False, path="foobaz", depth=0 + ), ], [ SimpleNamespace( - read_only=False, - required=True, - path="foo", + read_only=False, required=False, path="foo", depth=0 + ), + SimpleNamespace( + read_only=False, required=True, path="foo.baz", depth=1 ), SimpleNamespace( - read_only=False, required=False, path="foo.bar" + read_only=False, required=False, path="foo.bar", depth=1 ), SimpleNamespace( - read_only=False, required=False, path="foo.foo" + read_only=False, required=False, path="foo.foo", depth=1 ), ], ] @@ -136,6 +139,7 @@ def test_action_help_post_method(self, capsys, mocker, mock_cli): required=True, path="path", description="test description", + description_rich="test description", depth=0, ), mocker.MagicMock( @@ -143,6 +147,7 @@ def test_action_help_post_method(self, capsys, mocker, mock_cli): required=False, path="path2", description="test description 2", + description_rich="test description 2", format="json", nullable=True, depth=0, diff --git a/tests/unit/test_operation.py b/tests/unit/test_operation.py index 36c583259..fefdf0947 100644 --- a/tests/unit/test_operation.py +++ b/tests/unit/test_operation.py @@ -4,7 +4,11 @@ import json from linodecli.baked import operation -from linodecli.baked.operation import ExplicitEmptyListValue, ExplicitNullValue +from linodecli.baked.operation import ( + ExplicitEmptyListValue, + ExplicitNullValue, + OpenAPIOperation, +) class TestOperation: @@ -230,7 +234,7 @@ def test_parse_args_conflicting_parent_child(self, create_operation): ] ) except SystemExit as sys_exit: - assert sys_exit.code == 2 + assert sys_exit.code == 7 else: raise RuntimeError("Expected system exit, got none") @@ -272,3 +276,38 @@ def test_array_arg_action_basic(self): # User specifies a normal value and an empty list value result = parser.parse_args(["--foo", "foo", "--foo", "[]"]) assert getattr(result, "foo") == ["foo", "[]"] + + def test_resolve_api_components(self, get_openapi_for_api_components_tests): + root = get_openapi_for_api_components_tests + + assert OpenAPIOperation._get_api_url_components( + operation=root.paths["/foo/bar"].get, params=[] + ) == ("http://localhost", "/{apiVersion}/foo/bar", "v19") + + assert OpenAPIOperation._get_api_url_components( + operation=root.paths["/foo/bar"].delete, params=[] + ) == ("http://localhost", "/{apiVersion}/foo/bar", "v12beta") + + assert OpenAPIOperation._get_api_url_components( + operation=root.paths["/{apiVersion}/bar/foo"].get, params=[] + ) == ("http://localhost", "/{apiVersion}/bar/foo", "v19") + + assert OpenAPIOperation._get_api_url_components( + operation=root.paths["/{apiVersion}/bar/foo"].post, params=[] + ) == ("http://localhost", "/{apiVersion}/bar/foo", "v100beta") + + def test_resolve_docs_url_legacy(self, get_openapi_for_docs_url_tests): + root = get_openapi_for_docs_url_tests + + assert ( + OpenAPIOperation._resolve_operation_docs_url( + root.paths["/foo/bar"].get + ) + == "https://www.linode.com/docs/api/foo/#get-info" + ) + assert ( + OpenAPIOperation._resolve_operation_docs_url( + root.paths["/foo/bar"].post + ) + == "https://techdocs.akamai.com/linode-api/reference/cool-docs-url" + ) diff --git a/tests/unit/test_parsing.py b/tests/unit/test_parsing.py new file mode 100644 index 000000000..f33bf874c --- /dev/null +++ b/tests/unit/test_parsing.py @@ -0,0 +1,103 @@ +from linodecli.baked.parsing import ( + extract_markdown_links, + get_short_description, + markdown_to_rich_markup, + strip_techdocs_prefixes, +) + + +class TestParsing: + """ + Unit tests for linodecli.parsing + """ + + def test_extract_markdown_links(self): + """ + Ensures that Markdown links are properly extracted and removed from a string. + """ + + original_text = "Here's [a relative link](/docs/cool) and [an absolute link](https://cloud.linode.com)." + + result_text, result_links = extract_markdown_links(original_text) + + assert result_text == "Here's a relative link and an absolute link." + assert result_links == [ + "https://linode.com/docs/cool", + "https://cloud.linode.com", + ] + + def test_get_first_sentence(self): + assert ( + get_short_description( + "This is a sentence. This is another sentence." + ) + == "This is a sentence." + ) + + # New line delimiter + assert ( + get_short_description( + "This is a sentence.\nThis is another sentence." + ) + == "This is a sentence." + ) + + # Multi-space delimiter + assert ( + get_short_description( + "This is a sentence. This is another sentence." + ) + == "This is a sentence." + ) + + # Tab delimiter + assert ( + get_short_description( + "This is a sentence. This is another sentence." + ) + == "This is a sentence." + ) + + assert ( + get_short_description("This is a sentence.") + == "This is a sentence." + ) + + assert ( + get_short_description( + "__Note__. This might be a sentence.\nThis is a sentence." + ) + == "This is a sentence." + ) + + def test_get_techdocs_prefixes(self): + assert ( + strip_techdocs_prefixes( + "__Read-only__ The last successful backup date. 'null' if there was no previous backup.", + ) + == "The last successful backup date. 'null' if there was no previous backup." + ) + + assert ( + strip_techdocs_prefixes( + "__Filterable__, __Read-only__ This Linode's ID which " + "must be provided for all operations impacting this Linode.", + ) + == "This Linode's ID which must be provided for all operations impacting this Linode." + ) + + assert ( + strip_techdocs_prefixes( + "Do something cool.", + ) + == "Do something cool." + ) + + def test_markdown_to_rich_markup(self): + assert ( + markdown_to_rich_markup( + "very *cool* **test** _string_*\n__wow__ *cool** `code block` `" + ) + == "very [i]cool[/] [b]test[/] [i]string[/]*\n[b]wow[/] [i]cool[/]* " + "[italic deep_pink3 on grey15]code block[/] `" + ) diff --git a/tests/unit/test_plugin_image_upload.py b/tests/unit/test_plugin_image_upload.py index ec6245fe9..0a7bd580a 100644 --- a/tests/unit/test_plugin_image_upload.py +++ b/tests/unit/test_plugin_image_upload.py @@ -30,7 +30,7 @@ def test_no_file(mock_cli, capsys: CaptureFixture): captured_text = capsys.readouterr().out - assert err.value.code == 2 + assert err.value.code == 8 assert "No file at blah.txt" in captured_text @@ -45,7 +45,7 @@ def test_file_too_large(mock_cli, capsys: CaptureFixture): captured_text = capsys.readouterr().out - assert err.value.code == 2 + assert err.value.code == 8 assert "File blah.txt is too large" in captured_text @@ -63,7 +63,7 @@ def test_unauthorized(mock_cli, capsys: CaptureFixture): captured_text = capsys.readouterr().out - assert err.value.code == 3 + assert err.value.code == 2 assert "Your token was not authorized to use this endpoint" in captured_text @@ -81,7 +81,7 @@ def test_non_beta(mock_cli, capsys: CaptureFixture): captured_text = capsys.readouterr().out - assert err.value.code == 4 + assert err.value.code == 2 assert ( "It looks like you are not in the Machine Images Beta" in captured_text ) @@ -101,7 +101,7 @@ def test_non_beta(mock_cli, capsys: CaptureFixture): captured_text = capsys.readouterr().out - assert err.value.code == 4 + assert err.value.code == 2 assert ( "It looks like you are not in the Machine Images Beta" in captured_text ) @@ -120,7 +120,7 @@ def test_failed_upload(mock_cli, capsys: CaptureFixture): captured_text = capsys.readouterr().out - assert err.value.code == 3 + assert err.value.code == 2 assert ( "Upload failed with status 500; response was it borked :(" in captured_text diff --git a/tests/unit/test_plugin_kubeconfig.py b/tests/unit/test_plugin_kubeconfig.py index 6e2d5bb21..8ee9c3dca 100644 --- a/tests/unit/test_plugin_kubeconfig.py +++ b/tests/unit/test_plugin_kubeconfig.py @@ -77,7 +77,7 @@ def test_no_label_no_id(mock_cli): PluginContext("REALTOKEN", mock_cli), ) - assert err.value.code == 1 + assert err.value.code == 6 assert "Either --label or --id must be used." in stderr_buf.getvalue() @@ -94,7 +94,7 @@ def test_nonexisting_label(mock_cli): PluginContext("REALTOKEN", mock_cli), ) - assert err.value.code == 1 + assert err.value.code == 6 assert ( "Cluster with label empty_data does not exist." in stderr_buf.getvalue() @@ -112,7 +112,7 @@ def test_nonexisting_id(mock_cli): PluginContext("REALTOKEN", mock_cli), ) - assert err.value.code == 1 + assert err.value.code == 6 assert "Error retrieving kubeconfig:" in stderr_buf.getvalue() @@ -136,7 +136,7 @@ def test_improper_file(mock_cli, fake_empty_file): PluginContext("REALTOKEN", mock_cli), ) - assert err.value.code == 1 + assert err.value.code == 6 assert "Could not load file at" in stderr_buf.getvalue() diff --git a/tests/unit/test_plugin_ssh.py b/tests/unit/test_plugin_ssh.py index 11b97660a..77f51f7d9 100644 --- a/tests/unit/test_plugin_ssh.py +++ b/tests/unit/test_plugin_ssh.py @@ -28,7 +28,7 @@ def test_windows_error(capsys: CaptureFixture): with pytest.raises(SystemExit) as err: plugin.call(["test@test"], None) - assert err.value.code == 1 + assert err.value.code == 2 captured_text = capsys.readouterr().out assert "This plugin is not currently supported in Windows." in captured_text @@ -137,7 +137,7 @@ def mock_call_operation(*a, filters=None): PluginContext("FAKETOKEN", mock_cli), test_label ) - assert err.value.code == 1 + assert err.value.code == 2 captured_text = capsys.readouterr().out diff --git a/tod_scripts b/tod_scripts deleted file mode 160000 index f6da35dcb..000000000 --- a/tod_scripts +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f6da35dcb089fced7bcaf2a3c6ad29929f0d126c