diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ba1c6b80a..a995d61d4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,3 +9,7 @@ updates: directory: "/" # Location of package manifests schedule: interval: "daily" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" diff --git a/.github/workflows/e2e-suite-windows.yml b/.github/workflows/e2e-suite-windows.yml index 9c74681eb..66e9b7b3d 100644 --- a/.github/workflows/e2e-suite-windows.yml +++ b/.github/workflows/e2e-suite-windows.yml @@ -2,9 +2,17 @@ on: pull_request: workflow_dispatch: inputs: - test_path: - description: "The path from 'test/integration' to the target to be tested, e.g. 'cli'" + module: + description: "The module from 'test/integration' to the target to be tested, e.g. 'cli, domains, events, etc'" required: false + run_long_tests: + description: "Select True to run long tests, e.g. database, rebuild, etc" + required: false + type: choice + options: + - "True" + - "False" + default: "False" sha: description: 'The hash value of the commit.' required: true @@ -21,13 +29,6 @@ jobs: 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 @@ -72,7 +73,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - run: make INTEGRATION_TEST_PATH="${{ inputs.test_path }}" testint + - run: make MODULE="${{ inputs.module }}" RUN_LONG_TESTS="${{ inputs.run_long_tests }}" testint env: LINODE_CLI_TOKEN: ${{ secrets.LINODE_TOKEN_2 }} diff --git a/.github/workflows/e2e-suite.yml b/.github/workflows/e2e-suite.yml index 8187c71f5..5f16dfb9b 100644 --- a/.github/workflows/e2e-suite.yml +++ b/.github/workflows/e2e-suite.yml @@ -10,6 +10,14 @@ on: module: description: "The module from 'test/integration' to the target to be tested, e.g. 'cli, domains, events, etc'" required: false + run_long_tests: + description: "Select True to run long tests, e.g. database, rebuild, etc" + required: false + type: choice + options: + - "True" + - "False" + default: "False" sha: description: 'The hash value of the commit.' required: true @@ -17,11 +25,32 @@ on: pull_request_number: description: 'The number of the PR. Ensure sha value is provided' required: false + openapi_spec_url: + description: 'URL of the OpenAPI spec to use for the tests' + required: false + default: '' + python-version: + description: 'Specify Python version to use' + required: false + run-eol-python-version: + description: 'Run EOL python version?' + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' + push: branches: - main - dev +env: + DEFAULT_PYTHON_VERSION: "3.10" + EOL_PYTHON_VERSION: "3.8" + EXIT_STATUS: 0 + jobs: integration_tests: name: Run integration tests on Ubuntu @@ -74,7 +103,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v4 with: - python-version: '3.x' + python-version: ${{ inputs.run-eol-python-version == 'true' && env.EOL_PYTHON_VERSION || inputs.python-version || env.DEFAULT_PYTHON_VERSION }} - name: Install Python dependencies and update cert run: | @@ -83,7 +112,7 @@ jobs: pip install .[obj,dev] - name: Install Package - run: make install + run: make install SPEC="${{ inputs.OPENAPI_SPEC_URL }}" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -95,7 +124,7 @@ jobs: run: | timestamp=$(date +'%Y%m%d%H%M') report_filename="${timestamp}_cli_test_report.xml" - make testint TEST_ARGS="--junitxml=${report_filename}" MODULE="${{ inputs.module }}" + make testint TEST_ARGS="--junitxml=${report_filename}" MODULE="${{ inputs.module }}" RUN_LONG_TESTS="${{ inputs.run_long_tests }}" env: LINODE_CLI_TOKEN: ${{ env.LINODE_CLI_TOKEN }} @@ -272,4 +301,4 @@ jobs: ] } env: - SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} \ No newline at end of file + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index da42b7e4a..444c69ffd 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@v4 - name: Run Labeler - uses: crazy-max/ghaction-github-labeler@de749cf181958193cb7debf1a9c5bb28922f3e1b + uses: crazy-max/ghaction-github-labeler@b54af0c25861143e7c8813d7cbbf46d2c341680c with: github-token: ${{ secrets.GITHUB_TOKEN }} yaml-file: .github/labels.yml diff --git a/.github/workflows/publish-oci.yml b/.github/workflows/publish-oci.yml index 1fe7f1583..e4db94de7 100644 --- a/.github/workflows/publish-oci.yml +++ b/.github/workflows/publish-oci.yml @@ -20,13 +20,13 @@ jobs: run: make requirements - name: Set up QEMU - uses: docker/setup-qemu-action@2b82ce82d56a2a04d2637cd93a637ae1b359c0a7 # pin@v2.2.0 + uses: docker/setup-qemu-action@49b3bc8e6bdd4a60e6116a5414239cba5943d3cf # pin@v3.2.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@ecf95283f03858871ff00b787d79c419715afc34 # pin@v2.7.0 - name: Login to Docker Hub - uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # pin@v2.2.0 + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # pin@v3.3.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/remote-release-trigger.yml b/.github/workflows/remote-release-trigger.yml index 91b449518..03722a74a 100644 --- a/.github/workflows/remote-release-trigger.yml +++ b/.github/workflows/remote-release-trigger.yml @@ -9,7 +9,7 @@ jobs: steps: - name: Generate App Installation Token id: generate_token - uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 # pin@v1 + uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # pin@v1 with: app_id: ${{ secrets.CLI_RELEASE_APP_ID }} private_key: ${{ secrets.CLI_RELEASE_PRIVATE_KEY }} @@ -23,7 +23,7 @@ jobs: - name: Get previous tag id: previoustag - uses: WyriHaximus/github-action-get-previous-tag@385a2a0b6abf6c2efeb95adfac83d96d6f968e0c # pin@v1 + uses: WyriHaximus/github-action-get-previous-tag@04e8485ecb6487243907e330d522ff60f02283ce # pin@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index e4119a022..c4b5890ae 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -6,6 +6,9 @@ on: jobs: unit-tests-on-ubuntu: runs-on: ubuntu-latest + strategy: + matrix: + python-version: [ '3.9','3.10','3.11', '3.12' ] steps: - name: Clone Repository uses: actions/checkout@v3 @@ -14,9 +17,9 @@ jobs: run: sudo apt-get update -y - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: '3.x' + python-version: ${{ matrix.python-version }} - name: Install Python wheel run: pip install wheel boto3 diff --git a/Makefile b/Makefile index 42cae3c4d..3f9c6682b 100644 --- a/Makefile +++ b/Makefile @@ -72,7 +72,9 @@ testunit: @orig_xdg_config_home=$${XDG_CONFIG_HOME:-}; \ export LINODE_CLI_TEST_MODE=1 XDG_CONFIG_HOME=/tmp/linode/.config; \ pytest -v tests/unit; \ - export XDG_CONFIG_HOME=$$orig_xdg_config_home + exit_code=$$?; \ + export XDG_CONFIG_HOME=$$orig_xdg_config_home; \ + exit $$exit_code .PHONY: testint testint: diff --git a/linodecli/__init__.py b/linodecli/__init__.py index 6f2d62c5e..3fa192e8c 100755 --- a/linodecli/__init__.py +++ b/linodecli/__init__.py @@ -230,4 +230,5 @@ def main(): # pylint: disable=too-many-branches,too-many-statements if parsed.help: print_help_action(cli, parsed.command, parsed.action) 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 036fe5141..2155039b7 100644 --- a/linodecli/api_request.py +++ b/linodecli/api_request.py @@ -259,11 +259,13 @@ def _build_request_body(ctx, operation, parsed_args) -> Optional[str]: if ctx.defaults: parsed_args = ctx.config.update(parsed_args, operation.allowed_defaults) + param_names = {param.name for param in operation.params} + expanded_json = {} # expand paths for k, v in vars(parsed_args).items(): - if v is None: + if v is None or k in param_names: continue cur = expanded_json diff --git a/linodecli/baked/operation.py b/linodecli/baked/operation.py index 218b9059c..3eb3234e3 100644 --- a/linodecli/baked/operation.py +++ b/linodecli/baked/operation.py @@ -17,6 +17,7 @@ import openapi3.paths from openapi3.paths import Operation, Parameter +from linodecli.baked.parsing import simplify_description from linodecli.baked.request import OpenAPIFilteringRequest, OpenAPIRequest from linodecli.baked.response import OpenAPIResponse from linodecli.exit_codes import ExitCodes @@ -356,8 +357,12 @@ def __init__( self.action_aliases = {} self.action = action - self.summary = operation.summary - self.description = operation.description.split(".")[0] + # Ensure the summary has punctuation + self.summary = operation.summary.rstrip(".") + "." + + self.description_rich, self.description = simplify_description( + operation.description or "" + ) # The apiVersion attribute should not be specified as a positional argument self.params = [ @@ -366,6 +371,20 @@ def __init__( if param.name not in {"apiVersion"} ] + # Validation to ensure no conflicting arguments & param names are found. + # This is necessary because arguments and parameters are both parsed into the + # same result namespace by argparse. + if self.request is not None and hasattr(self.request, "attrs"): + param_names = {param.name for param in self.params} + + for attr in self.request.attrs: + if attr not in param_names: + continue + + raise ValueError( + f"Attribute {attr.name} conflicts with parameter of the same name" + ) + ( self.url_base, self.url_path, @@ -447,7 +466,13 @@ def _resolve_api_version( None, ) if version_param is not None: - return version_param.schema.default + schema = version_param.schema + + if schema.default: + return schema.default + + if schema.enum and len(schema.enum) > 0: + return schema.enum[0] return None diff --git a/linodecli/baked/parsing.py b/linodecli/baked/parsing.py index dd41af5ef..9846b064e 100644 --- a/linodecli/baked/parsing.py +++ b/linodecli/baked/parsing.py @@ -5,15 +5,15 @@ import functools import re from html import unescape -from typing import List, Tuple +from typing import List, Optional, Tuple # Sentence delimiter, split on a period followed by any type of # whitespace (space, new line, tab, etc.) -REGEX_SENTENCE_DELIMITER = re.compile(r"\W(?:\s|$)") +REGEX_SENTENCE_DELIMITER = re.compile(r"\.(?:\s|$)", flags=re.M) # Matches on pattern __prefix__ at the beginning of a description # or after a comma -REGEX_TECHDOCS_PREFIX = re.compile(r"(?:, |\A)__([\w-]+)__") +REGEX_TECHDOCS_PREFIX = re.compile(r"(?:, |\A)__([^_]+)__") # Matches on pattern [link title](https://.../) REGEX_MARKDOWN_LINK = re.compile(r"\[(?P.*?)]\((?P.*?)\)") @@ -121,23 +121,35 @@ def get_short_description(description: str) -> str: :rtype: set """ - target_lines = description.splitlines() - relevant_lines = None - - for i, line in enumerate(target_lines): + def __simplify(sentence: str) -> Optional[str]: # Edge case for descriptions starting with a note - if line.lower().startswith("__note__"): - continue + if sentence.lower().startswith("__note__"): + return None + + sentence = strip_techdocs_prefixes(sentence) - relevant_lines = target_lines[i:] - break + # Check that the sentence still has content after stripping prefixes + if len(sentence) < 2: + return None - if relevant_lines is None: + return sentence + "." + + # Find the first relevant sentence + result = next( + simplified + for simplified in iter( + __simplify(sentence) + for sentence in REGEX_SENTENCE_DELIMITER.split(description) + ) + if simplified is not None + ) + + if result is None: raise ValueError( f"description does not contain any relevant lines: {description}", ) - return REGEX_SENTENCE_DELIMITER.split("\n".join(relevant_lines), 1)[0] + "." + return result def strip_techdocs_prefixes(description: str) -> str: @@ -150,14 +162,10 @@ def strip_techdocs_prefixes(description: str) -> str: :returns: The stripped description :rtype: str """ - result_description = REGEX_TECHDOCS_PREFIX.sub( - "", description.lstrip() - ).lstrip() - - return result_description + return REGEX_TECHDOCS_PREFIX.sub("", description.lstrip()).lstrip() -def process_arg_description(description: str) -> Tuple[str, str]: +def simplify_description(description: str) -> Tuple[str, str]: """ Processes the given raw request argument description into one suitable for help pages, etc. @@ -173,12 +181,12 @@ def process_arg_description(description: str) -> Tuple[str, str]: return "", "" result = get_short_description(description) - result = strip_techdocs_prefixes(result) result = result.replace("\n", " ").replace("\r", " ") - description, links = extract_markdown_links(result) + # NOTE: Links should only be separated from Rich Markdown links + result_no_links, links = extract_markdown_links(result) if len(links) > 0: - description += f" See: {'; '.join(links)}" + result_no_links += f" See: {'; '.join(links)}" - return unescape(markdown_to_rich_markup(description)), unescape(description) + return unescape(markdown_to_rich_markup(result_no_links)), unescape(result) diff --git a/linodecli/baked/request.py b/linodecli/baked/request.py index 9cf45c207..b0bf07340 100644 --- a/linodecli/baked/request.py +++ b/linodecli/baked/request.py @@ -2,7 +2,7 @@ Request details for a CLI Operation """ -from linodecli.baked.parsing import process_arg_description +from linodecli.baked.parsing import simplify_description class OpenAPIRequestArg: @@ -46,7 +46,7 @@ def __init__( #: the larger response model self.path = prefix + "." + name if prefix else name - description_rich, description = process_arg_description( + description_rich, description = simplify_description( schema.description or "" ) diff --git a/linodecli/cli.py b/linodecli/cli.py index 775a11e49..1838afd25 100644 --- a/linodecli/cli.py +++ b/linodecli/cli.py @@ -208,7 +208,7 @@ def find_operation(self, command, action): return op # Fail if no matching alias was found - raise ValueError(f"No action {action} for command {command}") + raise ValueError(f"Action not found for command {command}: {action}") @property def user_agent(self) -> str: diff --git a/linodecli/exit_codes.py b/linodecli/exit_codes.py index c6497cfc2..d859309e9 100644 --- a/linodecli/exit_codes.py +++ b/linodecli/exit_codes.py @@ -20,3 +20,4 @@ class ExitCodes(IntEnum): KUBECONFIG_ERROR = 6 ARGUMENT_ERROR = 7 FILE_ERROR = 8 + UNRECOGNIZED_ACTION = 9 diff --git a/linodecli/help_pages.py b/linodecli/help_pages.py index 2af635fb3..44e80f461 100644 --- a/linodecli/help_pages.py +++ b/linodecli/help_pages.py @@ -15,9 +15,10 @@ from rich.table import Column, Table from rich.text import Text -from linodecli import plugins from linodecli.baked import OpenAPIOperation from linodecli.baked.request import OpenAPIRequestArg +from linodecli.exit_codes import ExitCodes +from linodecli.plugins import plugins HELP_ENV_VARS = { "LINODE_CLI_TOKEN": "A Linode Personal Access Token for the CLI to make requests with. " @@ -181,8 +182,9 @@ def print_help_action( """ try: op = cli.find_operation(command, action) - except ValueError: - return + except ValueError as exc: + print(exc, file=sys.stderr) + sys.exit(ExitCodes.UNRECOGNIZED_ACTION) console = Console(highlight=False) diff --git a/linodecli/overrides.py b/linodecli/overrides.py index a1473b783..245190cd6 100644 --- a/linodecli/overrides.py +++ b/linodecli/overrides.py @@ -76,6 +76,46 @@ def handle_image_replicate(operation, output_handler, json_data) -> bool: return image_replicate_output(json_data) +@output_override("placement", "group-view", OutputMode.table) +def handle_placement_group_view(operation, output_handler, json_data) -> bool: + # pylint: disable=unused-argument + """ + Override the output of 'linode-cli placement group-view' to show PG members. + """ + return pg_view_output(json_data) + + +@output_override("placement", "group-update", OutputMode.table) +def handle_placement_group_update(operation, output_handler, json_data) -> bool: + # pylint: disable=unused-argument + """ + Override the output of 'linode-cli placement group-update' to show PG members. + """ + return pg_view_output(json_data) + + +@output_override("placement", "assign-linode", OutputMode.table) +def handle_placement_assign_linode( + operation, output_handler, json_data +) -> bool: + # pylint: disable=unused-argument + """ + Override the output of 'linode-cli placement assign-linode' to show PG members. + """ + return pg_view_output(json_data) + + +@output_override("placement", "unassign-linode", OutputMode.table) +def handle_placement_unassign_linode( + operation, output_handler, json_data +) -> bool: + # pylint: disable=unused-argument + """ + Override the output of 'linode-cli placement unassign-linode' to show PG members. + """ + return pg_view_output(json_data) + + def linode_types_with_region_prices( operation, output_handler, json_data ) -> bool: @@ -206,3 +246,48 @@ def image_replicate_output(json_data) -> bool: console.print(output) return False + + +def build_pg_members(members: List) -> Table: + """ + Format nested linode members list to a sub-table. + """ + table = Table() + + member_headers = members[0].keys() + for h in member_headers: + table.add_column(h, justify="center") + + for member in members: + row = [] + for h in member_headers: + row.append(Align(str(member[h]), align="left")) + table.add_row(*row) + + return table + + +def pg_view_output(json_data) -> bool: + """ + Parse and format the placement group output table. + """ + output = Table( + header_style="bold", + show_lines=True, + ) + + row = [] + for header in json_data: + if json_data[header] is not None: + output.add_column(header, justify="center") + if header == "members" and len(json_data[header]) > 0: + row.append(build_pg_members(json_data[header])) + else: + row.append(Align(str(json_data[header]), align="left")) + + output.add_row(*row) + + console = Console() + console.print(output) + + return False diff --git a/tests/fixtures/api_request_test_foobar_put.yaml b/tests/fixtures/api_request_test_foobar_put.yaml new file mode 100644 index 000000000..9b7bdd6c3 --- /dev/null +++ b/tests/fixtures/api_request_test_foobar_put.yaml @@ -0,0 +1,126 @@ +openapi: 3.0.1 +info: + title: API Specification + version: 1.0.0 +servers: + - url: http://localhost/v4 + +paths: + /foo/bar/{barId}: + parameters: + - name: barId + description: The ID of the bar. + in: path + required: true + schema: + type: string + x-linode-cli-command: foo + put: + x-linode-cli-action: bar-update + summary: update foobar + operationId: fooBarPut + description: This is description + requestBody: + description: > + The parameters to set when updating the Foobar. + required: True + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/FooBarUpdate' + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/OpenAPIResponseAttr' + +components: + schemas: + OpenAPIResponseAttr: + type: object + properties: + filterable_result: + x-linode-filterable: true + type: string + description: Filterable result value + PaginationEnvelope: + type: object + properties: + pages: + type: integer + readOnly: true + description: The total number of pages. + example: 1 + page: + type: integer + readOnly: true + description: The current page. + example: 1 + results: + type: integer + readOnly: true + description: The total number of results. + example: 1 + FooBarUpdate: + type: object + description: Foobar object request + properties: + test_param: + x-linode-filterable: true + type: integer + description: The test parameter + generic_arg: + x-linode-filterable: true + type: string + description: The generic argument + region: + x-linode-filterable: true + type: string + description: The region + nullable_int: + type: integer + nullable: true + description: An arbitrary nullable int + nullable_string: + type: string + nullable: true + description: An arbitrary nullable string + nullable_float: + type: number + nullable: true + description: An arbitrary nullable float + object_list: + type: array + description: An arbitrary list of objects. + items: + type: object + description: An arbitrary object. + properties: + field_dict: + type: object + description: An arbitrary nested dict. + properties: + nested_string: + type: string + description: A deeply nested string. + nested_int: + type: number + description: A deeply nested integer. + field_array: + type: array + description: An arbitrary deeply nested array. + items: + type: string + field_string: + type: string + description: An arbitrary field. + field_int: + type: number + description: An arbitrary field. + nullable_string: + type: string + description: An arbitrary nullable string. + nullable: true diff --git a/tests/fixtures/api_url_components_test.yaml b/tests/fixtures/api_url_components_test.yaml index 162ceabae..22a64976a 100644 --- a/tests/fixtures/api_url_components_test.yaml +++ b/tests/fixtures/api_url_components_test.yaml @@ -46,3 +46,21 @@ paths: description: foobar content: application/json: {} + + /{apiVersion}/bar: + parameters: + - name: apiVersion + in: path + required: true + schema: + type: string + enum: + - v1000 + - v1000beta + get: + operationId: barGet + responses: + '200': + description: foobar + content: + application/json: {} diff --git a/tests/integration/cli/test_help.py b/tests/integration/cli/test_help.py index 4c72de44e..5b7108cfe 100644 --- a/tests/integration/cli/test_help.py +++ b/tests/integration/cli/test_help.py @@ -4,6 +4,7 @@ from tests.integration.helpers import ( contains_at_least_one_of, + exec_failing_test_command, exec_test_command, ) @@ -50,3 +51,30 @@ def test_help_page_for_aliased_actions(): assert "You may filter results with:" in wrapped_output assert "--tags" in wrapped_output + + +def test_debug_output_contains_request_url(monkeypatch: pytest.MonkeyPatch): + env_vars = { + "LINODE_CLI_API_HOST": "api.linode.com", + "LINODE_CLI_API_VERSION": "v4", + "LINODE_CLI_API_SCHEME": "https", + } + for key, value in env_vars.items(): + monkeypatch.setenv(key, value) + + output = exec_failing_test_command( + [ + "linode-cli", + "linodes", + "update", + "--label", + "foobar", + "12345", + "--debug", + ] + ).stderr.decode() + wrapped_output = textwrap.fill(output, width=180).replace("\n", "") + + assert ( + "PUT https://api.linode.com/v4/linode/instances/12345" in wrapped_output + ) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 2a4f211fa..a0f283314 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -20,6 +20,7 @@ from tests.integration.helpers import ( delete_target_id, exec_test_command, + get_random_region_with_caps, get_random_text, ) from tests.integration.linodes.helpers_linodes import ( @@ -465,34 +466,6 @@ def nodebalancer_with_default_conf(linode_cloud_firewall): delete_target_id(target="nodebalancers", id=nodebalancer_id) -def get_regions_with_capabilities(capabilities): - regions = ( - exec_test_command( - [ - "linode-cli", - "regions", - "ls", - "--text", - "--no-headers", - "--format=id,capabilities", - ] - ) - .stdout.decode() - .rstrip() - ) - - regions = regions.split("\n") - - regions_with_all_caps = [] - - for region in regions: - region_name = region.split()[0] - if all(capability in region for capability in capabilities): - regions_with_all_caps.append(region_name) - - return regions_with_all_caps - - def create_vpc_w_subnet(): """ Creates and returns a VPC and a corresponding subnet. @@ -504,7 +477,7 @@ def create_vpc_w_subnet(): See: https://github.com/pytest-dev/pytest/issues/1216 """ - region = get_regions_with_capabilities(["VPCs"])[0] + region = get_random_region_with_caps(required_capabilities=["VPCs"]) vpc_label = str(time.time_ns()) + "label" subnet_label = str(time.time_ns()) + "label" diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 906c75bb3..dab183ed5 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -1,3 +1,4 @@ +import json import random import subprocess import time @@ -71,8 +72,8 @@ def delete_tag(arg: str): assert result.returncode == SUCCESS_STATUS_CODE -def delete_target_id(target: str, id: str, subcommand: str = "delete"): - command = ["linode-cli", target, subcommand, id] +def delete_target_id(target: str, id: str, delete_command: str = "delete"): + command = ["linode-cli", target, delete_command, id] result = exec_test_command(command) assert result.returncode == SUCCESS_STATUS_CODE @@ -149,3 +150,47 @@ def assert_headers_in_lines(headers, lines): def contains_at_least_one_of(target: Container[T], search_for: Iterable[T]): return any(v in target for v in search_for) + + +def retry_exec_test_command_with_delay( + args: List[str], retries: int = 3, delay: int = 2 +): + for attempt in range(retries): + process = subprocess.run(args, stdout=subprocess.PIPE) + + # Check if the command succeeded + if process.returncode == 0: + return process + else: + print( + f"Attempt {attempt + 1} failed, retrying in {delay} seconds..." + ) + time.sleep(delay) + + assert process.returncode == 0, f"Command failed after {retries} retries" + return process + + +def get_random_region_with_caps( + required_capabilities: List[str], site_type="core" +): + json_regions_data = ( + exec_test_command(["linode-cli", "regions", "ls", "--json"]) + .stdout.decode() + .strip() + ) + + # Parse regions JSON data + regions = json.loads(json_regions_data) + + matching_regions = [ + region + for region in regions + if all(cap in region["capabilities"] for cap in required_capabilities) + and region["site_type"] == site_type + ] + + # Extract the region ids + matching_region_ids = [region["id"] for region in matching_regions] + + return random.choice(matching_region_ids) if matching_region_ids else None diff --git a/tests/integration/linodes/helpers_linodes.py b/tests/integration/linodes/helpers_linodes.py index b16c26730..e6b7323e0 100644 --- a/tests/integration/linodes/helpers_linodes.py +++ b/tests/integration/linodes/helpers_linodes.py @@ -71,7 +71,9 @@ def wait_until(linode_id: "str", timeout, status: "str", period=5): return False -def create_linode(firewall_id: "str", test_region=DEFAULT_REGION): +def create_linode( + firewall_id: "str", test_region=DEFAULT_REGION, disk_encryption=False +): # create linode linode_id = ( exec_test_command( @@ -89,6 +91,8 @@ def create_linode(firewall_id: "str", test_region=DEFAULT_REGION): DEFAULT_RANDOM_PASS, "--firewall_id", firewall_id, + "--disk_encryption", + "enabled" if disk_encryption else "disabled", "--format=id", "--text", "--no-headers", @@ -183,73 +187,40 @@ def create_linode_and_wait( test_plan=DEFAULT_LINODE_TYPE, test_image=DEFAULT_TEST_IMAGE, test_region=DEFAULT_REGION, + disk_encryption=False, ): - linode_type = test_plan - - # key_pair = generate_random_ssh_key() + # Base command + command = [ + "linode-cli", + "linodes", + "create", + "--type", + test_plan, + "--region", + test_region, + "--image", + test_image, + "--root_pass", + DEFAULT_RANDOM_PASS, + "--firewall_id", + firewall_id, + "--format=id", + "--backups_enabled", + "true", + "--disk_encryption", + "enabled" if disk_encryption else "disabled", + "--text", + "--no-headers", + ] - output = "" - # if ssh key is successfully generated + # Add SSH key if provided if ssh_key: - output = ( - exec_test_command( - [ - "linode-cli", - "linodes", - "create", - "--type", - linode_type, - "--region", - test_region, - "--image", - test_image, - "--root_pass", - DEFAULT_RANDOM_PASS, - "--authorized_keys", - ssh_key, - "--firewall_id", - firewall_id, - "--format=id", - "--backups_enabled", - "true", - "--text", - "--no-headers", - ] - ) - .stdout.decode() - .rstrip() - ) - else: - output = ( - exec_test_command( - [ - "linode-cli", - "linodes", - "create", - "--type", - linode_type, - "--region", - "us-ord", - "--image", - test_image, - "--root_pass", - DEFAULT_RANDOM_PASS, - "--firewall_id", - firewall_id, - "--format=id", - "--backups_enabled", - "true", - "--text", - "--no-headers", - ] - ) - .stdout.decode() - .rstrip() - ) - linode_id = output + command.extend(["--authorized_keys", ssh_key]) + + linode_id = exec_test_command(command).stdout.decode().strip() # wait until linode is running, wait_until returns True when it is in running state - result = (wait_until(linode_id=linode_id, timeout=240, status="running"),) + result = wait_until(linode_id=linode_id, timeout=240, status="running") assert result, "linode failed to change status to running" @@ -275,3 +246,24 @@ def set_backups_enabled_in_account_settings(toggle: bool): result = exec_test_command(command).stdout.decode().rstrip() return result + + +def get_disk_ids(linode_id): + disk_ids = ( + exec_test_command( + BASE_CMD + + [ + "disks-list", + linode_id, + "--text", + "--no-headers", + "--format", + "id", + ] + ) + .stdout.decode() + .rstrip() + .splitlines() + ) + + return disk_ids diff --git a/tests/integration/linodes/test_backups.py b/tests/integration/linodes/test_backups.py index 474c6a756..dd5c904e7 100755 --- a/tests/integration/linodes/test_backups.py +++ b/tests/integration/linodes/test_backups.py @@ -131,8 +131,8 @@ def test_create_backup_with_backup_enabled(linode_backup_enabled): @pytest.mark.skipif( - os.environ.get("RUN_LONG_TESTS", None) != "TRUE", - reason="Skipping long-running Test, to run set RUN_LONG_TESTS=TRUE", + os.environ.get("RUN_LONG_TESTS", None) != "True", + reason="Skipping long-running Test, to run set RUN_LONG_TESTS=True", ) def test_take_snapshot_of_linode(): # get linode id after creation and wait for "running" status @@ -159,8 +159,8 @@ def test_take_snapshot_of_linode(): @pytest.mark.skipif( - os.environ.get("RUN_LONG_TESTS", None) != "TRUE", - reason="Skipping long-running Test, to run set RUN_LONG_TESTS=TRUE", + os.environ.get("RUN_LONG_TESTS", None) != "True", + reason="Skipping long-running Test, to run set RUN_LONG_TESTS=True", ) def test_view_the_snapshot(snapshot_of_linode): # get linode id after creation and wait for "running" status @@ -187,8 +187,8 @@ def test_view_the_snapshot(snapshot_of_linode): @pytest.mark.skipif( - os.environ.get("RUN_LONG_TESTS", None) != "TRUE", - reason="Skipping long-running Test, to run set RUN_LONG_TESTS=TRUE", + os.environ.get("RUN_LONG_TESTS", None) != "True", + reason="Skipping long-running Test, to run set RUN_LONG_TESTS=True", ) def test_cancel_backups(snapshot_of_linode): # get linode id after creation and wait for "running" status diff --git a/tests/integration/linodes/test_configs.py b/tests/integration/linodes/test_configs.py new file mode 100644 index 000000000..a934ab1d3 --- /dev/null +++ b/tests/integration/linodes/test_configs.py @@ -0,0 +1,447 @@ +import json +import time + +import pytest + +from tests.integration.conftest import create_vpc_w_subnet +from tests.integration.helpers import ( + assert_headers_in_lines, + delete_target_id, + exec_test_command, + get_random_region_with_caps, + get_random_text, + retry_exec_test_command_with_delay, +) +from tests.integration.linodes.helpers_linodes import ( + BASE_CMD, + create_linode, + get_disk_ids, +) + +TEST_REGION = get_random_region_with_caps( + required_capabilities=["Linodes", "VPCs"] +) + + +@pytest.fixture(scope="session", autouse=True) +def linode_instance_config_tests(linode_cloud_firewall): + + linode_id = create_linode( + firewall_id=linode_cloud_firewall, + disk_encryption=False, + test_region=TEST_REGION, + ) + + yield linode_id + + delete_target_id(target="linodes", id=linode_id) + + +@pytest.fixture(scope="session", autouse=True) +def linode_disk_config(linode_instance_config_tests): + linode_id = linode_instance_config_tests + + label = get_random_text(5) + "_config" + disk_id = get_disk_ids(linode_id=linode_id)[1] + + config_id = ( + exec_test_command( + BASE_CMD + + [ + "config-create", + linode_id, + "--label", + label, + "--devices.sda.disk_id", + disk_id, + "--no-headers", + "--format=id", + "--text", + ] + ) + .stdout.decode() + .rstrip() + ) + + yield config_id + + +@pytest.fixture(scope="session", autouse=True) +def test_vpc_w_subnet(request): + vpc_json = create_vpc_w_subnet() + vpc_id = str(vpc_json["id"]) + + yield vpc_id + + +@pytest.fixture(scope="session", autouse=True) +def cleanup_vpc(request, test_vpc_w_subnet): + # Register finalizer to delete VPC after the entire session, with a delay + def delayed_cleanup(): + time.sleep(5) # Delay if necessary + delete_target_id(target="vpcs", id=test_vpc_w_subnet) + + request.addfinalizer(delayed_cleanup) + + +@pytest.fixture(scope="session", autouse=True) +def config_vpc_interface( + linode_instance_config_tests, linode_disk_config, test_vpc_w_subnet +): + linode_id = linode_instance_config_tests + config_id = linode_disk_config + subnet_id = get_subnet_id(vpc_id=test_vpc_w_subnet) + + interface_id = ( + exec_test_command( + BASE_CMD + + [ + "config-interface-add", + linode_id, + config_id, + "--purpose", + "vpc", + "--primary", + "false", + "--subnet_id", + subnet_id, + "--ipv4.vpc", + "10.0.0.3", + "--text", + "--no-headers", + "--format", + "id", + ] + ) + .stdout.decode() + .rstrip() + ) + + yield interface_id + + retry_exec_test_command_with_delay( + BASE_CMD + + [ + "config-interface-delete", + linode_id, + config_id, + interface_id, + ] + ) + + +def create_vpc_w_subnet(): + vpc_label = get_random_text(5) + "vpc" + subnet_label = get_random_text(5) + "subnet" + + vpc_json = json.loads( + exec_test_command( + [ + "linode-cli", + "vpcs", + "create", + "--label", + vpc_label, + "--region", + TEST_REGION, + "--subnets.ipv4", + "10.0.0.0/24", + "--subnets.label", + subnet_label, + "--json", + "--suppress-warnings", + ] + ) + .stdout.decode() + .rstrip() + )[0] + + return vpc_json + + +def get_subnet_id(vpc_id): + subnet_id = ( + exec_test_command( + [ + "linode-cli", + "vpcs", + "subnets-list", + vpc_id, + "--text", + "--format=id", + "--no-headers", + ] + ) + .stdout.decode() + .rstrip() + ) + + return subnet_id + + +def test_config_create(linode_instance_config_tests): + linode_id = linode_instance_config_tests + + label = get_random_text(5) + "_config" + disk_id = get_disk_ids(linode_id=linode_id)[1] + + result = ( + exec_test_command( + BASE_CMD + + [ + "config-create", + linode_id, + "--label", + label, + "--devices.sda.disk_id", + disk_id, + "--text", + ] + ) + .stdout.decode() + .rstrip() + ) + + headers = ["id", "label", "kernel"] + + assert_headers_in_lines(headers, result.splitlines()) + assert label in result + + +def test_config_delete(linode_instance_config_tests): + linode_id = linode_instance_config_tests + + label = get_random_text(5) + "_config" + disk_id = get_disk_ids(linode_id=linode_id)[1] + + config_id = ( + exec_test_command( + BASE_CMD + + [ + "config-create", + linode_id, + "--label", + label, + "--devices.sda.disk_id", + disk_id, + "--no-headers", + "--format=id", + "--text", + ] + ) + .stdout.decode() + .rstrip() + ) + + res = retry_exec_test_command_with_delay( + BASE_CMD + + [ + "config-delete", + linode_id, + config_id, + ] + ) + + assert res.returncode == 0 + + +def test_config_update_label(linode_instance_config_tests, linode_disk_config): + linode_id = linode_instance_config_tests + config_id = linode_disk_config + + updated_label = get_random_text(4) + "_updatedconfig" + + res = ( + exec_test_command( + BASE_CMD + + [ + "config-update", + linode_id, + config_id, + "--label", + updated_label, + "--text", + ] + ) + .stdout.decode() + .rstrip() + ) + + headers = ["id", "label", "kernel"] + + assert_headers_in_lines(headers, res.splitlines()) + assert updated_label in res + + +def test_config_view(linode_instance_config_tests, linode_disk_config): + linode_id = linode_instance_config_tests + config_id = linode_disk_config + + res = ( + exec_test_command( + BASE_CMD + ["config-view", linode_id, config_id, "--text"] + ) + .stdout.decode() + .rstrip() + ) + + headers = ["id", "label", "kernel"] + + assert_headers_in_lines(headers, res.splitlines()) + assert config_id in res + + +def test_configs_list(linode_instance_config_tests): + linode_id = linode_instance_config_tests + + res = ( + exec_test_command(BASE_CMD + ["configs-list", linode_id, "--text"]) + .stdout.decode() + .rstrip() + ) + + headers = ["id", "label", "kernel"] + + assert_headers_in_lines(headers, res.splitlines()) + + +def test_config_interface_add_vlan( + linode_instance_config_tests, linode_disk_config +): + linode_id = linode_instance_config_tests + config_id = linode_disk_config + + label = get_random_text(5) + "vlan" + + res = ( + exec_test_command( + BASE_CMD + + [ + "config-interface-add", + linode_id, + config_id, + "--purpose", + "vlan", + "--primary", + "false", + "--label", + label, + "--text", + ] + ) + .stdout.decode() + .rstrip() + ) + + headers = ["id", "label", "purpose", "ipam_address"] + + assert_headers_in_lines(headers, res.splitlines()) + assert label in res + assert "vlan" in res + + +def test_config_interface_update( + linode_instance_config_tests, linode_disk_config, config_vpc_interface +): + linode_id = linode_instance_config_tests + config_id = linode_disk_config + interface_id = config_vpc_interface + + res = ( + exec_test_command( + BASE_CMD + + [ + "config-interface-update", + linode_id, + config_id, + interface_id, + "--ipv4.vpc", + "10.0.0.5", + "--text", + ] + ) + .stdout.decode() + .rstrip() + ) + + headers = ["id", "label", "purpose", "ipam_address"] + + assert_headers_in_lines(headers, res.splitlines()) + + +def test_config_interface_view( + linode_instance_config_tests, linode_disk_config, config_vpc_interface +): + linode_id = linode_instance_config_tests + config_id = linode_disk_config + interface_id = config_vpc_interface + + res = ( + exec_test_command( + BASE_CMD + + [ + "config-interface-view", + linode_id, + config_id, + interface_id, + "--text", + ] + ) + .stdout.decode() + .rstrip() + ) + + headers = ["id", "label", "purpose", "ipam_address"] + + assert_headers_in_lines(headers, res.splitlines()) + assert interface_id in res + + +def test_config_interfaces_list( + linode_instance_config_tests, linode_disk_config +): + linode_id = linode_instance_config_tests + config_id = linode_disk_config + + res = ( + exec_test_command( + BASE_CMD + + [ + "config-interfaces-list", + linode_id, + config_id, + "--text", + ] + ) + .stdout.decode() + .rstrip() + ) + + headers = ["id", "label", "purpose", "ipam_address"] + + assert_headers_in_lines(headers, res.splitlines()) + + +def config_interfaces_order( + linode_instance_config_tests, linode_disk_config, config_vpc_interface +): + linode_id = linode_instance_config_tests + config_id = linode_disk_config + interface_id = config_vpc_interface + + process = ( + exec_test_command( + BASE_CMD + + [ + "config-interfaces-order", + linode_id, + config_id, + "--ids", + interface_id, + ] + ) + .stdout.decode() + .rstrip() + ) + + assert process.returncode == 0 diff --git a/tests/integration/linodes/test_disk.py b/tests/integration/linodes/test_disk.py new file mode 100644 index 000000000..3dff48a09 --- /dev/null +++ b/tests/integration/linodes/test_disk.py @@ -0,0 +1,210 @@ +import pytest + +from tests.integration.helpers import ( + assert_headers_in_lines, + delete_target_id, + exec_test_command, + get_random_region_with_caps, + get_random_text, + retry_exec_test_command_with_delay, + wait_for_condition, +) +from tests.integration.linodes.helpers_linodes import ( + BASE_CMD, + create_linode_and_wait, + get_disk_ids, + wait_until, +) + +TEST_REGION = get_random_region_with_caps(required_capabilities=["Linodes"]) + + +@pytest.fixture(scope="session", autouse=True) +def linode_instance_disk_tests(linode_cloud_firewall): + + linode_id = create_linode_and_wait( + firewall_id=linode_cloud_firewall, + disk_encryption=False, + test_region=TEST_REGION, + test_plan="g6-standard-4", + ) + + retry_exec_test_command_with_delay(BASE_CMD + ["shutdown", linode_id]) + + wait_until(linode_id=linode_id, timeout=240, status="offline") + + yield linode_id + + delete_target_id(target="linodes", id=linode_id) + + +def test_disk_resize_clone_and_create(linode_instance_disk_tests): + linode_id = linode_instance_disk_tests + + disk_id = get_disk_ids(linode_id=linode_id)[1] + + # resize disk + retry_exec_test_command_with_delay( + BASE_CMD + + [ + "disk-resize", + linode_id, + disk_id, + "--size", + "50", + ], + retries=3, + delay=10, + ) + + def disk_poll_func(): + status = ( + exec_test_command( + BASE_CMD + + [ + "disk-view", + linode_id, + disk_id, + "--text", + "--no-headers", + "--format=status", + ] + ) + .stdout.decode() + .rstrip() + ) + + return status == "ready" + + # Wait for the instance to be ready + wait_for_condition(15, 150, disk_poll_func) + + # clone disk + res = ( + retry_exec_test_command_with_delay( + BASE_CMD + + [ + "disk-clone", + linode_id, + disk_id, + "--text", + ], + retries=3, + delay=10, + ) + .stdout.decode() + .rstrip() + ) + + headers = ["id", "label", "status", "size", "filesystem", "disk_encryption"] + + assert_headers_in_lines(headers, res.splitlines()) + + assert "Copy of" in res + assert "50" in res + + label = get_random_text(5) + "disk" + + # create new disk + res = ( + retry_exec_test_command_with_delay( + BASE_CMD + + [ + "disk-create", + linode_id, + "--size", + "15", + "--label", + label, + "--text", + ], + retries=3, + delay=10, + ) + .stdout.decode() + .rstrip() + ) + + headers = ["id", "label", "status", "size", "filesystem", "disk_encryption"] + + assert_headers_in_lines(headers, res.splitlines()) + + assert label in res + assert "15" in res + # assert "disabled" in res + + +def test_disk_reset_password(linode_instance_disk_tests): + linode_id = linode_instance_disk_tests + disk_id = get_disk_ids(linode_id)[1] + + res = retry_exec_test_command_with_delay( + BASE_CMD + + [ + "disk-reset-password", + linode_id, + disk_id, + "--password", + "ThIsIsRanDomPaSsWoRD", + "--text", + ], + retries=3, + delay=10, + ) + + assert res.returncode == 0 + + +def test_disk_update(linode_instance_disk_tests): + linode_id = linode_instance_disk_tests + disk_id = get_disk_ids(linode_id)[1] + + update_label = get_random_text(5) + "newdisk" + + res = ( + ( + retry_exec_test_command_with_delay( + BASE_CMD + + [ + "disk-update", + linode_id, + disk_id, + "--label", + update_label, + "--text", + ], + retries=3, + delay=10, + ) + ) + .stdout.decode() + .rstrip() + ) + + headers = ["id", "label", "status", "size", "filesystem", "disk_encryption"] + + assert_headers_in_lines(headers, res.splitlines()) + assert update_label in res + + +def test_disks_list(linode_instance_disk_tests): + linode_id = linode_instance_disk_tests + + res = ( + ( + retry_exec_test_command_with_delay( + BASE_CMD + + [ + "disks-list", + linode_id, + "--text", + ] + ) + ) + .stdout.decode() + .rstrip() + ) + + headers = ["id", "label", "status", "size", "filesystem", "disk_encryption"] + + assert_headers_in_lines(headers, res.splitlines()) diff --git a/tests/integration/linodes/test_linodes.py b/tests/integration/linodes/test_linodes.py index 8444ce1fd..1150d9fa7 100644 --- a/tests/integration/linodes/test_linodes.py +++ b/tests/integration/linodes/test_linodes.py @@ -9,12 +9,14 @@ delete_target_id, exec_failing_test_command, exec_test_command, + get_random_region_with_caps, ) from tests.integration.linodes.helpers_linodes import ( BASE_CMD, DEFAULT_LABEL, DEFAULT_RANDOM_PASS, DEFAULT_TEST_IMAGE, + create_linode, wait_until, ) @@ -23,7 +25,7 @@ @pytest.fixture(scope="package", autouse=True) -def setup_linodes(linode_cloud_firewall): +def test_linode_instance(linode_cloud_firewall): linode_id = ( exec_test_command( BASE_CMD @@ -61,6 +63,31 @@ def setup_linodes(linode_cloud_firewall): delete_target_id(target="linodes", id=linode_id) +@pytest.fixture +def test_disk_id(test_linode_instance): + linode_id = test_linode_instance + disk_id = ( + exec_test_command( + BASE_CMD + + [ + "disks-list", + linode_id, + "--text", + "--no-headers", + "--delimiter", + ",", + "--format", + "id", + ] + ) + .stdout.decode() + .rstrip() + .splitlines() + ) + first_id = disk_id[0].split(",")[0] + yield first_id + + def test_update_linode_with_a_image(): result = exec_test_command(BASE_CMD + ["update", "--help"]).stdout.decode() @@ -77,8 +104,8 @@ def test_create_linodes_with_a_label(linode_with_label): @pytest.mark.smoke -def test_view_linode_configuration(setup_linodes): - linode_id = setup_linodes +def test_view_linode_configuration(test_linode_instance): + linode_id = test_linode_instance result = exec_test_command( BASE_CMD + [ @@ -142,15 +169,15 @@ def test_create_linode_without_image_and_not_boot(linode_wo_image): assert "offline" in result -def test_list_linodes(setup_linodes): +def test_list_linodes(test_linode_instance): result = exec_test_command( BASE_CMD + ["list", "--format", "label", "--text", "--no-headers"] ).stdout.decode() assert linode_label in result -def test_add_tag_to_linode(setup_linodes): - linode_id = setup_linodes +def test_add_tag_to_linode(test_linode_instance): + linode_id = test_linode_instance unique_tag = "tag" + str(int(time.time())) result = exec_test_command( @@ -170,8 +197,8 @@ def test_add_tag_to_linode(setup_linodes): assert unique_tag in result -def list_disk_list(setup_linodes): - linode_id = setup_linodes +def list_disk_list(test_linode_instance): + linode_id = test_linode_instance res = ( exec_test_command( BASE_CMD @@ -191,49 +218,93 @@ def list_disk_list(setup_linodes): assert_headers_in_lines(headers, lines) -@pytest.fixture -def get_disk_id(setup_linodes): - linode_id = setup_linodes - disk_id = ( +def test_disk_view(test_linode_instance, test_disk_id): + linode_id = test_linode_instance + disk_id = test_disk_id + res = ( exec_test_command( BASE_CMD + [ - "disks-list", + "disk-view", linode_id, + disk_id, "--text", - "--no-headers", - "--delimiter", - ",", - "--format", - "id", + "--delimiter=,", ] ) .stdout.decode() .rstrip() - .splitlines() ) - first_id = disk_id[0].split(",")[0] - yield first_id + lines = res.splitlines() + + headers = ["id", "label"] + assert_headers_in_lines(headers, lines) + assert disk_id in res + +def test_create_linode_disk_encryption_enabled(linode_cloud_firewall): + test_region = get_random_region_with_caps( + required_capabilities=["Linodes", "Disk Encryption"] + ) + + linode_id = create_linode( + firewall_id=linode_cloud_firewall, + disk_encryption=True, + test_region=test_region, + ) -def test_disk_view(setup_linodes, get_disk_id): - linode_id = setup_linodes - disk_id = get_disk_id res = ( exec_test_command( BASE_CMD + [ - "disk-view", + "view", linode_id, - disk_id, "--text", "--delimiter=,", + "--format=id,disk_encryption", ] ) .stdout.decode() .rstrip() ) - lines = res.splitlines() - headers = ["id", "label"] - assert_headers_in_lines(headers, lines) + headers = ["id", "disk_encryption"] + assert_headers_in_lines(headers, res.splitlines()) + + assert linode_id in res and "enabled" in res + + delete_target_id(target="linodes", id=linode_id) + + +def test_create_linode_disk_encryption_disabled(linode_cloud_firewall): + test_region = get_random_region_with_caps( + required_capabilities=["Linodes", "Disk Encryption"] + ) + + linode_id = create_linode( + firewall_id=linode_cloud_firewall, + disk_encryption=False, + test_region=test_region, + ) + + res = ( + exec_test_command( + BASE_CMD + + [ + "view", + linode_id, + "--text", + "--delimiter=,", + "--format=id,disk_encryption", + ] + ) + .stdout.decode() + .rstrip() + ) + + headers = ["id", "disk_encryption"] + assert_headers_in_lines(headers, res.splitlines()) + + assert linode_id in res and "disabled" in res + + delete_target_id(target="linodes", id=linode_id) diff --git a/tests/integration/linodes/test_power_status.py b/tests/integration/linodes/test_power_status.py index b00091d7d..984035ad5 100644 --- a/tests/integration/linodes/test_power_status.py +++ b/tests/integration/linodes/test_power_status.py @@ -1,6 +1,10 @@ import pytest -from tests.integration.helpers import delete_target_id, exec_test_command +from tests.integration.helpers import ( + delete_target_id, + exec_test_command, + retry_exec_test_command_with_delay, +) from tests.integration.linodes.helpers_linodes import ( BASE_CMD, create_linode_and_wait, @@ -18,7 +22,7 @@ def test_linode_id(linode_cloud_firewall): @pytest.fixture -def create_linode_in_running_state(linode_cloud_firewall): +def linode_in_running_state(linode_cloud_firewall): linode_id = create_linode_and_wait(firewall_id=linode_cloud_firewall) yield linode_id @@ -27,7 +31,7 @@ def create_linode_in_running_state(linode_cloud_firewall): @pytest.fixture -def create_linode_in_running_state_for_reboot(linode_cloud_firewall): +def linode_in_running_state_for_reboot(linode_cloud_firewall): linode_id = create_linode_and_wait(firewall_id=linode_cloud_firewall) yield linode_id @@ -45,19 +49,19 @@ def test_create_linode_and_boot(test_linode_id): assert result, "Linode status has not changed to running from provisioning" -def test_reboot_linode(create_linode_in_running_state_for_reboot): +@pytest.mark.flaky(reruns=3, reruns_delay=2) +def test_reboot_linode(linode_in_running_state_for_reboot): # create linode and wait until it is in "running" state - linode_id = create_linode_in_running_state_for_reboot + linode_id = linode_in_running_state_for_reboot # reboot linode from "running" status - exec_test_command( - BASE_CMD + ["reboot", linode_id, "--text", "--no-headers"] + retry_exec_test_command_with_delay( + BASE_CMD + ["reboot", linode_id, "--text", "--no-headers"], 3, 20 ) - # returns false if status is not running after 240s after reboot assert wait_until( linode_id=linode_id, timeout=240, status="running" - ), "Linode status has not changed to running from provisioning" + ), "Linode status has not changed to running from provisioning after reboot" @pytest.mark.flaky(reruns=3, reruns_delay=2) diff --git a/tests/integration/linodes/test_rebuild.py b/tests/integration/linodes/test_rebuild.py index eda84fe6d..707bebff0 100644 --- a/tests/integration/linodes/test_rebuild.py +++ b/tests/integration/linodes/test_rebuild.py @@ -7,6 +7,8 @@ delete_target_id, exec_failing_test_command, exec_test_command, + get_random_region_with_caps, + retry_exec_test_command_with_delay, ) from tests.integration.linodes.helpers_linodes import ( BASE_CMD, @@ -17,8 +19,15 @@ @pytest.fixture -def test_linode_id(linode_cloud_firewall): - linode_id = create_linode_and_wait(firewall_id=linode_cloud_firewall) +def linode_for_rebuild_tests(linode_cloud_firewall): + test_region = get_random_region_with_caps( + required_capabilities=["Linodes", "Disk Encryption"] + ) + linode_id = create_linode_and_wait( + firewall_id=linode_cloud_firewall, + disk_encryption=False, + test_region=test_region, + ) yield linode_id @@ -26,14 +35,14 @@ def test_linode_id(linode_cloud_firewall): @pytest.mark.flaky(reruns=3, reruns_delay=2) -def test_rebuild_fails_without_image(test_linode_id): +def test_rebuild_fails_without_image(linode_for_rebuild_tests): result = exec_failing_test_command( BASE_CMD + [ "rebuild", "--root_pass", DEFAULT_RANDOM_PASS, - test_linode_id, + linode_for_rebuild_tests, "--text", "--no-headers", ], @@ -44,8 +53,8 @@ def test_rebuild_fails_without_image(test_linode_id): assert "You must specify an image" in result -def test_rebuild_fails_with_invalid_image(test_linode_id): - linode_id = test_linode_id +def test_rebuild_fails_with_invalid_image(linode_for_rebuild_tests): + linode_id = linode_for_rebuild_tests rebuild_image = "bad/image" result = exec_failing_test_command( @@ -66,26 +75,13 @@ def test_rebuild_fails_with_invalid_image(test_linode_id): @pytest.mark.skipif( - os.environ.get("RUN_LONG_TESTS", None) != "TRUE", - reason="Skipping long-running Test, to run set RUN_LONG_TESTS=TRUE", + os.environ.get("RUN_LONG_TESTS", None) != "True", + reason="Skipping long-running Test, to run set RUN_LONG_TESTS=True", ) -def test_rebuild_a_linode(test_linode_id): - linode_id = test_linode_id - rebuild_image = ( - exec_test_command( - [ - "linode-cli", - "images", - "list", - "--text", - "--no-headers" "--format", - "id", - ] - ) - .stdout.decode() - .rstrip() - .splitlines()[4] - ) +@pytest.mark.flaky(reruns=3, reruns_delay=5) +def test_rebuild_a_linode(linode_for_rebuild_tests): + linode_id = linode_for_rebuild_tests + rebuild_image = "linode/alpine3.20" # trigger rebuild exec_test_command( @@ -117,3 +113,115 @@ def test_rebuild_a_linode(test_linode_id): + ["view", linode_id, "--format", "image", "--text", "--no-headers"] ).stdout.decode() assert rebuild_image in result + + +@pytest.mark.skipif( + os.environ.get("RUN_LONG_TESTS", None) != "True", + reason="Skipping long-running Test, to run set RUN_LONG_TESTS=True", +) +@pytest.mark.flaky(reruns=3, reruns_delay=5) +def test_rebuild_linode_disk_encryption_enabled(linode_for_rebuild_tests): + linode_id = linode_for_rebuild_tests + rebuild_image = "linode/alpine3.20" + + # trigger rebuild + retry_exec_test_command_with_delay( + BASE_CMD + + [ + "rebuild", + linode_id, + "--image", + rebuild_image, + "--root_pass", + DEFAULT_RANDOM_PASS, + "--text", + "--no-headers", + "--disk_encryption", + "enabled", + ], + retries=3, + delay=10, + ).stdout.decode() + + # check status for rebuilding + assert wait_until( + linode_id=linode_id, timeout=180, status="rebuilding" + ), "linode failed to change status to rebuilding.." + + # check if rebuilding finished + assert wait_until( + linode_id=linode_id, timeout=180, status="running" + ), "linode failed to change status to running from rebuilding.." + + result = exec_test_command( + BASE_CMD + + [ + "view", + linode_id, + "--format", + "image", + "--text", + "--no-headers", + "--format=id,image,disk_encryption", + ] + ).stdout.decode() + + assert "enabled" in result + assert rebuild_image in result + + +@pytest.mark.skipif( + os.environ.get("RUN_LONG_TESTS", None) != "True", + reason="Skipping long-running Test, to run set RUN_LONG_TESTS=True", +) +@pytest.mark.flaky(reruns=3, reruns_delay=5) +def test_rebuild_linode_disk_encryption_disabled(linode_for_rebuild_tests): + linode_id = linode_for_rebuild_tests + rebuild_image = "linode/alpine3.20" + + # trigger rebuild + retry_exec_test_command_with_delay( + BASE_CMD + + [ + "rebuild", + linode_id, + "--image", + rebuild_image, + "--root_pass", + DEFAULT_RANDOM_PASS, + "--text", + "--no-headers", + "--disk_encryption", + "disabled", + ], + retries=3, + delay=10, + ).stdout.decode() + + # check status for rebuilding + assert wait_until( + linode_id=linode_id, timeout=180, status="rebuilding" + ), "linode failed to change status to rebuilding.." + + # check if rebuilding finished + assert wait_until( + linode_id=linode_id, timeout=180, status="running" + ), "linode failed to change status to running from rebuilding.." + + result = retry_exec_test_command_with_delay( + BASE_CMD + + [ + "view", + linode_id, + "--format", + "image", + "--text", + "--no-headers", + "--format=id,image,disk_encryption", + ], + retries=3, + delay=10, + ).stdout.decode() + + assert "disabled" in result + assert rebuild_image in result diff --git a/tests/integration/linodes/test_resize.py b/tests/integration/linodes/test_resize.py index fc573ba36..5213cee7b 100644 --- a/tests/integration/linodes/test_resize.py +++ b/tests/integration/linodes/test_resize.py @@ -140,8 +140,8 @@ def test_resize_fail_to_invalid_plan(test_linode_id): @pytest.mark.skipif( - os.environ.get("RUN_LONG_TESTS", None) != "TRUE", - reason="Skipping long-running Test, to run set RUN_LONG_TESTS=TRUE", + os.environ.get("RUN_LONG_TESTS", None) != "True", + reason="Skipping long-running Test, to run set RUN_LONG_TESTS=True", ) def test_resize_to_next_size_plan(test_linode_id): linode_id = test_linode_id diff --git a/tests/integration/linodes/test_types.py b/tests/integration/linodes/test_types.py index 5cb962c86..a92b53fac 100644 --- a/tests/integration/linodes/test_types.py +++ b/tests/integration/linodes/test_types.py @@ -1,29 +1,27 @@ -import os -import subprocess -from typing import List - import pytest -env = os.environ.copy() -env["COLUMNS"] = "200" - - -def exec_test_command(args: List[str]): - process = subprocess.run( - args, - stdout=subprocess.PIPE, - env=env, - ) - return process +from tests.integration.helpers import assert_headers_in_lines, exec_test_command # verifying the DC pricing changes along with types @pytest.mark.smoke def test_linode_type(): - process = exec_test_command(["linode-cli", "linodes", "types"]) - output = process.stdout.decode() - assert " price.hourly " in output - assert " price.monthly " in output - assert " region_prices " in output - assert " hourly " in output - assert " monthly " in output + output = exec_test_command( + ["linode-cli", "linodes", "types", "--text"] + ).stdout.decode() + + headers = [ + "id", + "label", + "class", + "disk", + "memory", + "vcpus", + "gpus", + "network_out", + "transfer", + "price.hourly", + "price.monthly", + ] + + assert_headers_in_lines(headers, output.splitlines()) diff --git a/tests/integration/lke/test_clusters.py b/tests/integration/lke/test_clusters.py index 12b7c063d..3b214dba0 100644 --- a/tests/integration/lke/test_clusters.py +++ b/tests/integration/lke/test_clusters.py @@ -1,80 +1,225 @@ -import time - import pytest -from tests.integration.helpers import assert_headers_in_lines, exec_test_command +from tests.integration.helpers import ( + assert_headers_in_lines, + delete_target_id, + exec_test_command, + get_random_region_with_caps, + get_random_text, + retry_exec_test_command_with_delay, +) BASE_CMD = ["linode-cli", "lke"] -@pytest.mark.smoke -def test_deploy_an_lke_cluster(): - timestamp = str(time.time_ns()) - label = "cluster_test" + timestamp - - lke_version = ( +def get_lke_version_id(): + version_id = ( exec_test_command( BASE_CMD + [ "versions-list", "--text", "--no-headers", + "--delimiter", + ",", + "--format", + "id", ] ) .stdout.decode() .rstrip() - .splitlines()[0] + .splitlines() ) - result = exec_test_command( - BASE_CMD - + [ - "cluster-create", - "--region", - "us-ord", - "--label", - label, - "--node_pools.type", - "g6-standard-1", - "--node_pools.count", - "1", - "--node_pools.disks", - '[{"type":"ext4","size":1024}]', - "--k8s_version", - lke_version, - "--text", - "--delimiter", - ",", - "--no-headers", - "--format", - "label,region,k8s_version", - "--no-defaults", - ] - ).stdout.decode() - assert label + ",us-ord," + lke_version in result + first_id = version_id[0] + return first_id -@pytest.fixture -def get_cluster_id(): + +def get_node_pool_id(cluster_id): + cluster_id + nodepool_id = ( + exec_test_command( + BASE_CMD + + [ + "pools-list", + cluster_id, + "--text", + "--no-headers", + "--format", + "id", + ] + ) + .stdout.decode() + .rstrip() + .splitlines() + ) + + first_id = nodepool_id[0] + + return first_id + + +def get_pool_nodesid(cluster_id): + cluster_id + nodepool_id = ( + exec_test_command( + BASE_CMD + + [ + "pools-list", + cluster_id, + "--text", + "--no-headers", + "--format", + "nodes.id", + ] + ) + .stdout.decode() + .rstrip() + .splitlines() + ) + + first_id = nodepool_id[0] + + return first_id + + +def get_cluster_id(label: str): cluster_id = ( exec_test_command( BASE_CMD + [ "clusters-list", "--text", + "--format=id", + "--no-headers", + "--label", + label, + ] + ) + .stdout.decode() + .rstrip() + ) + + return cluster_id + + +@pytest.fixture +def test_lke_cluster(): + label = get_random_text(8) + "_cluster" + + test_region = get_random_region_with_caps( + required_capabilities=["Linodes", "Kubernetes"] + ) + lke_version = ( + exec_test_command( + BASE_CMD + + [ + "versions-list", + "--text", "--no-headers", + ] + ) + .stdout.decode() + .rstrip() + .splitlines()[0] + ) + + cluster_label = ( + exec_test_command( + BASE_CMD + + [ + "cluster-create", + "--region", + test_region, + "--label", + label, + "--node_pools.type", + "g6-standard-1", + "--node_pools.count", + "1", + "--node_pools.disks", + '[{"type":"ext4","size":1024}]', + "--k8s_version", + lke_version, + "--text", "--delimiter", ",", + "--no-headers", "--format", - "id", + "label", + "--no-defaults", + ] + ) + .stdout.decode() + .rstrip() + ) + + cluster_id = get_cluster_id(label=cluster_label) + + yield cluster_id + + delete_target_id( + target="lke", id=cluster_id, delete_command="cluster-delete" + ) + + +@pytest.mark.smoke +def test_deploy_an_lke_cluster(): + label = get_random_text(8) + "_cluster" + + test_region = get_random_region_with_caps( + required_capabilities=["Linodes", "Kubernetes"] + ) + lke_version = ( + exec_test_command( + BASE_CMD + + [ + "versions-list", + "--text", + "--no-headers", + ] + ) + .stdout.decode() + .rstrip() + .splitlines()[0] + ) + + cluster_label = ( + exec_test_command( + BASE_CMD + + [ + "cluster-create", + "--region", + test_region, + "--label", + label, + "--node_pools.type", + "g6-standard-1", + "--node_pools.count", + "1", + "--node_pools.disks", + '[{"type":"ext4","size":1024}]', + "--k8s_version", + lke_version, + "--text", + "--delimiter", + ",", + "--no-headers", + "--format=label", ] ) .stdout.decode() .rstrip() - .splitlines() ) - first_id = cluster_id[0] - yield first_id + + assert label == cluster_label + + cluster_id = get_cluster_id(label=cluster_label) + + delete_target_id( + target="lke", id=cluster_id, delete_command="cluster-delete" + ) def test_lke_cluster_list(): @@ -91,8 +236,8 @@ def test_lke_cluster_list(): assert_headers_in_lines(headers, lines) -def test_view_lke_cluster(get_cluster_id): - cluster_id = get_cluster_id +def test_view_lke_cluster(test_lke_cluster): + cluster_id = test_lke_cluster res = ( exec_test_command( @@ -106,9 +251,10 @@ def test_view_lke_cluster(get_cluster_id): assert_headers_in_lines(headers, lines) -def test_update_kubernetes_cluster(get_cluster_id): - cluster_id = get_cluster_id - new_label = "cluster_test" + str(time.time_ns()) +def test_update_kubernetes_cluster(test_lke_cluster): + cluster_id = test_lke_cluster + new_label = get_random_text(5) + "_updated_cluster" + updated_label = ( exec_test_command( BASE_CMD @@ -125,16 +271,18 @@ def test_update_kubernetes_cluster(get_cluster_id): .stdout.decode() .rstrip() ) + assert new_label == updated_label -@pytest.mark.flaky(reruns=3, reruns_delay=2) -def test_list_kubernetes_endpoint(get_cluster_id): - cluster_id = get_cluster_id +def test_list_kubernetes_endpoint(test_lke_cluster): + cluster_id = test_lke_cluster res = ( - exec_test_command( + retry_exec_test_command_with_delay( BASE_CMD - + ["api-endpoints-list", cluster_id, "--text", "--delimiter=,"] + + ["api-endpoints-list", cluster_id, "--text", "--delimiter=,"], + retries=3, + delay=30, ) .stdout.decode() .rstrip() @@ -145,8 +293,8 @@ def test_list_kubernetes_endpoint(get_cluster_id): assert_headers_in_lines(headers, lines) -def test_cluster_dashboard_url(get_cluster_id): - cluster_id = get_cluster_id +def test_cluster_dashboard_url(test_lke_cluster): + cluster_id = test_lke_cluster res = ( exec_test_command( BASE_CMD @@ -161,33 +309,8 @@ def test_cluster_dashboard_url(get_cluster_id): assert_headers_in_lines(headers, lines) -@pytest.fixture -def get_node_pool_id(get_cluster_id): - cluster_id = get_cluster_id - nodepool_id = ( - exec_test_command( - BASE_CMD - + [ - "pools-list", - cluster_id, - "--text", - "--no-headers", - "--delimiter", - ",", - "--format", - "id", - ] - ) - .stdout.decode() - .rstrip() - .splitlines() - ) - first_id = nodepool_id[0] - yield first_id - - -def test_node_pool_list(get_cluster_id): - cluster_id = get_cluster_id +def test_node_pool_list(test_lke_cluster): + cluster_id = test_lke_cluster res = ( exec_test_command( BASE_CMD + ["pools-list", cluster_id, "--text", "--delimiter=,"] @@ -201,9 +324,10 @@ def test_node_pool_list(get_cluster_id): assert_headers_in_lines(headers, lines) -def test_view_pool(get_cluster_id, get_node_pool_id): - cluster_id = get_cluster_id - node_pool_id = get_node_pool_id +def test_view_pool(test_lke_cluster): + cluster_id = test_lke_cluster + node_pool_id = get_node_pool_id(cluster_id) + res = ( exec_test_command( BASE_CMD @@ -212,17 +336,18 @@ def test_view_pool(get_cluster_id, get_node_pool_id): .stdout.decode() .rstrip() ) + lines = res.splitlines() headers = ["type", "labels.value"] assert_headers_in_lines(headers, lines) -@pytest.mark.skip(reason="BUG TPT-TPT-3145") -def test_update_node_pool(get_cluster_id, get_node_pool_id): - cluster_id = get_cluster_id - node_pool_id = get_node_pool_id - new_label = "cluster_test" + str(time.time_ns()) - updated_count = ( +def test_update_node_pool(test_lke_cluster): + cluster_id = test_lke_cluster + node_pool_id = get_node_pool_id(cluster_id) + new_label = get_random_text(8) + "updated_pool" + + result = ( exec_test_command( BASE_CMD + [ @@ -231,7 +356,7 @@ def test_update_node_pool(get_cluster_id, get_node_pool_id): node_pool_id, "--count", "5", - "--label.value", + "--labels.value", new_label, "--text", "--no-headers", @@ -241,13 +366,14 @@ def test_update_node_pool(get_cluster_id, get_node_pool_id): .stdout.decode() .rstrip() ) - assert new_label == updated_count + assert new_label in result + + +def test_view_node(test_lke_cluster): + cluster_id = test_lke_cluster + node_pool_id = get_pool_nodesid(cluster_id) -@pytest.mark.skip(reason="BUG TPT-TPT-3145") -def test_view_node(get_cluster_id, get_node_pool_id): - cluster_id = get_cluster_id - node_pool_id = get_node_pool_id res = ( exec_test_command( BASE_CMD @@ -256,36 +382,14 @@ def test_view_node(get_cluster_id, get_node_pool_id): .stdout.decode() .rstrip() ) + lines = res.splitlines() - headers = ["type", "labels.value"] + headers = ["id", "id,instance_id,status"] assert_headers_in_lines(headers, lines) -@pytest.fixture -def test_version_id(): - version_id = ( - exec_test_command( - BASE_CMD - + [ - "versions-list", - "--text", - "--no-headers", - "--delimiter", - ",", - "--format", - "id", - ] - ) - .stdout.decode() - .rstrip() - .splitlines() - ) - first_id = version_id[0] - yield first_id - - -def test_version_view(test_version_id): - version_id = test_version_id +def test_version_view(): + version_id = get_lke_version_id() res = ( exec_test_command( BASE_CMD + ["version-view", version_id, "--text", "--delimiter=,"] @@ -318,3 +422,93 @@ def test_list_lke_types(): assert_headers_in_lines(headers, lines) assert "LKE Standard Availability" in types assert "LKE High Availability" in types + + +def test_create_node_pool_default_to_disk_encryption_enabled(test_lke_cluster): + cluster_id = test_lke_cluster + + result = ( + exec_test_command( + BASE_CMD + + [ + "pool-create", + cluster_id, + "--count", + "1", + "--type", + "g6-standard-4", + "--text", + "--format=id,disk_encryption,type", + "--no-headers", + ] + ) + .stdout.decode() + .rstrip() + ) + + assert "enabled" in result + assert "g6-standard-4" in result + + +@pytest.fixture +def test_node_pool(test_lke_cluster): + cluster_id = test_lke_cluster + + node_pool_id = ( + exec_test_command( + BASE_CMD + + [ + "pool-create", + cluster_id, + "--count", + "1", + "--type", + "g6-standard-4", + "--text", + "--format=id", + "--no-headers", + ] + ) + .stdout.decode() + .rstrip() + ) + + yield node_pool_id + + +def test_pool_view(test_lke_cluster, test_node_pool): + cluster_id = test_lke_cluster + + node_pool_id = test_node_pool + + node_pool = ( + exec_test_command( + BASE_CMD + + [ + "pool-view", + cluster_id, + node_pool_id, + "--text", + ] + ) + .stdout.decode() + .rstrip() + ) + + lines = node_pool.splitlines() + + headers = [ + "autoscaler.enabled", + "autoscaler.max", + "autoscaler.min", + "count", + "disk_encryption", + "id", + "labels.key", + "labels.value", + "tags", + "taints", + "type", + ] + + assert_headers_in_lines(headers, lines) diff --git a/tests/integration/managed/test_managed.py b/tests/integration/managed/test_managed.py index c8e53478b..28baa23dd 100644 --- a/tests/integration/managed/test_managed.py +++ b/tests/integration/managed/test_managed.py @@ -101,7 +101,7 @@ def test_managed_contact_update(get_contact_id): ) assert update_name == unique_name1 delete_target_id( - target="managed", subcommand="contact-delete", id=contact_id + target="managed", delete_command="contact-delete", id=contact_id ) @@ -197,7 +197,7 @@ def test_managed_credentials_update(get_credential_id): ) assert update_label == new_label delete_target_id( - target="managed", subcommand="credential-revoke", id=credential_id + target="managed", delete_command="credential-revoke", id=credential_id ) diff --git a/tests/integration/obj/test_obj_bucket.py b/tests/integration/obj/test_obj_bucket.py index 637a702f8..17f86dae4 100644 --- a/tests/integration/obj/test_obj_bucket.py +++ b/tests/integration/obj/test_obj_bucket.py @@ -144,5 +144,5 @@ def test_obj_storage_key_update(get_key_id): ) assert new_label == updated_label delete_target_id( - target="object-storage", subcommand="keys-delete", id=key_id + target="object-storage", delete_command="keys-delete", id=key_id ) diff --git a/tests/integration/placements/test_placements.py b/tests/integration/placements/test_placements.py index 7fa2309da..a05b97d97 100644 --- a/tests/integration/placements/test_placements.py +++ b/tests/integration/placements/test_placements.py @@ -37,7 +37,7 @@ def create_placement_group(): ) yield placement_group_id delete_target_id( - target="placement", subcommand="group-delete", id=placement_group_id + target="placement", delete_command="group-delete", id=placement_group_id ) diff --git a/tests/integration/ssh/test_plugin_ssh.py b/tests/integration/ssh/test_plugin_ssh.py index ab060643a..136c8135c 100644 --- a/tests/integration/ssh/test_plugin_ssh.py +++ b/tests/integration/ssh/test_plugin_ssh.py @@ -8,12 +8,13 @@ from tests.integration.helpers import ( COMMAND_JSON_OUTPUT, exec_failing_test_command, + get_random_region_with_caps, get_random_text, wait_for_condition, ) -TEST_REGION = "us-southeast" -TEST_IMAGE = "linode/alpine3.16" +TEST_REGION = get_random_region_with_caps(required_capabilities=["Linodes"]) +TEST_IMAGE = "linode/ubuntu24.10" TEST_TYPE = "g6-nanode-1" TEST_ROOT_PASS = "r00tp@ss!long-long-and-longer" diff --git a/tests/integration/vlans/test_vlans.py b/tests/integration/vlans/test_vlans.py index b35171a12..894fef9f7 100644 --- a/tests/integration/vlans/test_vlans.py +++ b/tests/integration/vlans/test_vlans.py @@ -35,7 +35,7 @@ def test_list_vlans_help_menu(): .rstrip() ) - assert "linode-cli vlans ls\nList VLANs\n" in help_menu + assert "linode-cli vlans ls" in help_menu assert ( "https://techdocs.akamai.com/linode-api/reference/get-vlans" in help_menu diff --git a/tests/integration/vpc/conftest.py b/tests/integration/vpc/conftest.py index a4e9f26be..4f7165aca 100644 --- a/tests/integration/vpc/conftest.py +++ b/tests/integration/vpc/conftest.py @@ -2,11 +2,12 @@ import pytest -from tests.integration.conftest import ( - create_vpc_w_subnet, - get_regions_with_capabilities, +from tests.integration.conftest import create_vpc_w_subnet +from tests.integration.helpers import ( + delete_target_id, + exec_test_command, + get_random_region_with_caps, ) -from tests.integration.helpers import delete_target_id, exec_test_command @pytest.fixture @@ -21,7 +22,7 @@ def test_vpc_w_subnet(): @pytest.fixture def test_vpc_wo_subnet(): - region = get_regions_with_capabilities(["VPCs"])[0] + region = get_random_region_with_caps(required_capabilities=["VPCs"]) label = str(time.time_ns()) + "label" diff --git a/tests/integration/vpc/test_vpc.py b/tests/integration/vpc/test_vpc.py index f03e5fdce..cee877212 100644 --- a/tests/integration/vpc/test_vpc.py +++ b/tests/integration/vpc/test_vpc.py @@ -4,10 +4,10 @@ import pytest from linodecli.exit_codes import ExitCodes -from tests.integration.conftest import get_regions_with_capabilities from tests.integration.helpers import ( exec_failing_test_command, exec_test_command, + get_random_region_with_caps, ) BASE_CMD = ["linode-cli", "vpcs"] @@ -161,7 +161,7 @@ def test_update_subnet(test_vpc_w_subnet): def test_fails_to_create_vpc_invalid_label(): invalid_label = "invalid_label" - region = get_regions_with_capabilities(["VPCs"])[0] + region = get_random_region_with_caps(required_capabilities=["VPCs"]) res = ( exec_failing_test_command( @@ -186,7 +186,7 @@ def test_fails_to_create_vpc_duplicate_label(test_vpc_wo_subnet): .stdout.decode() .rstrip() ) - region = get_regions_with_capabilities(["VPCs"])[0] + region = get_random_region_with_caps(required_capabilities=["VPCs"]) res = ( exec_failing_test_command( @@ -220,7 +220,6 @@ def test_fails_to_update_vpc_invalid_label(test_vpc_wo_subnet): def test_fails_to_create_vpc_subnet_w_invalid_label(test_vpc_wo_subnet): vpc_id = test_vpc_wo_subnet invalid_label = "invalid_label" - region = get_regions_with_capabilities(["VPCs"])[0] res = exec_failing_test_command( BASE_CMD diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 04fc5e79c..d97b1df60 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -182,6 +182,37 @@ def create_operation(): return create_operation +@pytest.fixture +def update_operation(): + """ + Creates the following CLI operation: + + linode-cli foo bar-update --generic_arg [generic_arg] test_param + + PUT http://localhost/v4/foo/bar/{fooId} + { + "generic_arg": "[generic_arg]", + "test_param": test_param + } + """ + + spec = _get_parsed_spec("api_request_test_foobar_put.yaml") + + dict_values = list(spec.paths.values()) + + # Get parameters for OpenAPIOperation() from yaml fixture + path = dict_values[0] + command = path.extensions.get("linode-cli-command", "default") + operation = getattr(path, "put") + method = "put" + + create_operation = make_test_operation( + command, operation, method, path.parameters + ) + + return create_operation + + @pytest.fixture def list_operation_for_output_tests(): """ diff --git a/tests/unit/test_api_request.py b/tests/unit/test_api_request.py index 13041f34b..6b99c941f 100644 --- a/tests/unit/test_api_request.py +++ b/tests/unit/test_api_request.py @@ -389,6 +389,33 @@ def validate_http_request(url, headers=None, data=None, **kwargs): assert result == mock_response + def test_do_request_put(self, mock_cli, update_operation): + mock_response = Mock(status_code=200, reason="OK") + + def validate_http_request(url, headers=None, data=None, **kwargs): + assert url == "http://localhost/v4/foo/bar/567" + assert data == json.dumps( + { + "test_param": 12345, + "generic_arg": "foobar", + "region": "us-southeast", # default + } + ) + assert "Authorization" in headers + + return mock_response + + update_operation.allowed_defaults = ["region"] + + with patch("linodecli.api_request.requests.put", validate_http_request): + result = api_request.do_request( + mock_cli, + update_operation, + ["--generic_arg", "foobar", "--test_param", "12345", "567"], + ) + + assert result == mock_response + def test_outdated_cli(self, mock_cli): # "outdated" version mock_cli.suppress_warnings = False diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index e9c2189dc..f54d41a15 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -73,7 +73,7 @@ def test_find_operation( with pytest.raises(ValueError, match=r"Command not found: *"): mock_cli.find_operation("bad", "list") - with pytest.raises(ValueError, match=r"No action *"): + with pytest.raises(ValueError, match=r"Action not found for command *"): mock_cli.find_operation("foo", "cool") mock_cli.find_operation("cool", "cool") diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index 4015a69e9..2cd96f4c6 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -80,7 +80,7 @@ def test_set_user(self): f = io.StringIO() with pytest.raises(SystemExit) as err: - with contextlib.redirect_stdout(f): + with contextlib.redirect_stderr(f): conf.set_user("bad_user") assert err.value.code == 4 @@ -98,7 +98,7 @@ def test_remove_user(self): f = io.StringIO() with pytest.raises(SystemExit) as err: - with contextlib.redirect_stdout(f): + with contextlib.redirect_stderr(f): conf.remove_user("cli-dev") assert "default user!" in f.getvalue() @@ -131,7 +131,7 @@ def test_set_default_user(self): f = io.StringIO() with pytest.raises(SystemExit) as err: - with contextlib.redirect_stdout(f): + with contextlib.redirect_stderr(f): conf.set_default_user("bad_user") assert err.value.code == 4 @@ -226,7 +226,7 @@ def test_update(self): ] f = io.StringIO() - with contextlib.redirect_stdout(f): + with contextlib.redirect_stderr(f): result = vars(conf.update(ns, allowed_defaults)) assert "--no-defaults" in f.getvalue() diff --git a/tests/unit/test_help_pages.py b/tests/unit/test_help_pages.py index 96d1ea3b1..8709bfd3c 100644 --- a/tests/unit/test_help_pages.py +++ b/tests/unit/test_help_pages.py @@ -1,7 +1,11 @@ +import contextlib from io import StringIO from types import SimpleNamespace -from linodecli import help_pages +import pytest + +from linodecli import CLI, help_pages +from linodecli.baked import OpenAPIOperation from tests.unit.conftest import assert_contains_ordered_substrings @@ -100,7 +104,7 @@ def test_help_with_ops(self, capsys, mocked_config): def test_help_with_ops_with_plugins(self, capsys, mocker, mocked_config): mocker.patch( - "linodecli.arg_helpers.plugins.available", + "linodecli.help_pages.plugins.available", return_value=["testing.plugin"], ) help_pages.print_help_plugins(mocked_config) @@ -120,10 +124,28 @@ def test_help_topics(self, capsys): assert topic in captured.out # arg_helpers.print_help_action(cli, command, action) - def test_action_help_value_error(self, capsys, mock_cli): - help_pages.print_help_action(mock_cli, None, None) - captured = capsys.readouterr() - assert not captured.out + def test_action_help_value_error( + self, capsys, mock_cli: CLI, create_operation: OpenAPIOperation + ): + mock_cli.ops = { + "foo": { + "bar": create_operation, + } + } + + stderr_buf = StringIO() + + with pytest.raises(SystemExit), contextlib.redirect_stderr(stderr_buf): + help_pages.print_help_action(mock_cli, "fake", "fake") + + assert "Command not found: fake" in stderr_buf.getvalue() + + stderr_buf = StringIO() + + with pytest.raises(SystemExit), contextlib.redirect_stderr(stderr_buf): + help_pages.print_help_action(mock_cli, "foo", "fake") + + assert "Action not found for command foo: fake" in stderr_buf.getvalue() def test_action_help_post_method(self, capsys, mocker, mock_cli): mocked_ops = mocker.MagicMock() diff --git a/tests/unit/test_operation.py b/tests/unit/test_operation.py index fefdf0947..d150a3207 100644 --- a/tests/unit/test_operation.py +++ b/tests/unit/test_operation.py @@ -296,6 +296,25 @@ def test_resolve_api_components(self, get_openapi_for_api_components_tests): operation=root.paths["/{apiVersion}/bar/foo"].post, params=[] ) == ("http://localhost", "/{apiVersion}/bar/foo", "v100beta") + def test_resolve_api_version(self, get_openapi_for_api_components_tests): + root = get_openapi_for_api_components_tests + + assert ( + OpenAPIOperation._resolve_api_version( + params=root.paths["/{apiVersion}/bar/foo"].parameters, + server_url="http://localhost", + ) + == "v9canary" + ) + + assert ( + OpenAPIOperation._resolve_api_version( + params=root.paths["/{apiVersion}/bar"].parameters, + server_url="http://localhost", + ) + == "v1000" + ) + def test_resolve_docs_url_legacy(self, get_openapi_for_docs_url_tests): root = get_openapi_for_docs_url_tests diff --git a/tests/unit/test_parsing.py b/tests/unit/test_parsing.py index f33bf874c..7f1df97c6 100644 --- a/tests/unit/test_parsing.py +++ b/tests/unit/test_parsing.py @@ -2,6 +2,7 @@ extract_markdown_links, get_short_description, markdown_to_rich_markup, + simplify_description, strip_techdocs_prefixes, ) @@ -65,7 +66,7 @@ def test_get_first_sentence(self): assert ( get_short_description( - "__Note__. This might be a sentence.\nThis is a sentence." + "__Note__ This might be a sentence.\nThis is a sentence." ) == "This is a sentence." ) @@ -101,3 +102,29 @@ def test_markdown_to_rich_markup(self): == "very [i]cool[/] [b]test[/] [i]string[/]*\n[b]wow[/] [i]cool[/]* " "[italic deep_pink3 on grey15]code block[/] `" ) + + def test_simplify_description(self): + # This description was not parsed correctly prior to PR #680. + assert simplify_description( + "The authentication methods that are allowed when connecting to " + "[the Linode Shell (Lish)](https://www.linode.com/docs/guides/lish/).\n" + "\n" + "- `keys_only` is the most secure if you intend to use Lish.\n" + "- `disabled` is recommended if you do not intend to use Lish at all.\n" + "- If this account's Cloud Manager authentication type is set to a Third-Party Authentication method, " + "`password_keys` cannot be used as your Lish authentication method. To view this account's Cloud Manager " + "`authentication_type` field, send a request to the " + "[Get a profile](https://techdocs.akamai.com/linode-api/reference/get-profile) operation." + ) == ( + "The authentication methods that are allowed when connecting to the Linode Shell (Lish). " + "See: https://www.linode.com/docs/guides/lish/", + "The authentication methods that are allowed when connecting to " + "[the Linode Shell (Lish)](https://www.linode.com/docs/guides/lish/).", + ) + + assert simplify_description( + "A unique, user-defined `string` referring to the Managed Database." + ) == ( + "A unique, user-defined [italic deep_pink3 on grey15]string[/] referring to the Managed Database.", + "A unique, user-defined `string` referring to the Managed Database.", + ) diff --git a/tests/unit/test_plugin_image_upload.py b/tests/unit/test_plugin_image_upload.py index 0a7bd580a..d02faf4d3 100644 --- a/tests/unit/test_plugin_image_upload.py +++ b/tests/unit/test_plugin_image_upload.py @@ -28,7 +28,7 @@ def test_no_file(mock_cli, capsys: CaptureFixture): PluginContext("REALTOKEN", mock_cli), ) - captured_text = capsys.readouterr().out + captured_text = capsys.readouterr().err assert err.value.code == 8 assert "No file at blah.txt" in captured_text @@ -43,7 +43,7 @@ def test_file_too_large(mock_cli, capsys: CaptureFixture): with pytest.raises(SystemExit) as err: plugin.call(args, ctx) - captured_text = capsys.readouterr().out + captured_text = capsys.readouterr().err assert err.value.code == 8 assert "File blah.txt is too large" in captured_text @@ -61,7 +61,7 @@ def test_unauthorized(mock_cli, capsys: CaptureFixture): with pytest.raises(SystemExit) as err: plugin.call(args, ctx) - captured_text = capsys.readouterr().out + captured_text = capsys.readouterr().err assert err.value.code == 2 assert "Your token was not authorized to use this endpoint" in captured_text @@ -79,7 +79,7 @@ def test_non_beta(mock_cli, capsys: CaptureFixture): with pytest.raises(SystemExit) as err: plugin.call(args, ctx) - captured_text = capsys.readouterr().out + captured_text = capsys.readouterr().err assert err.value.code == 2 assert ( @@ -99,7 +99,7 @@ def test_non_beta(mock_cli, capsys: CaptureFixture): with pytest.raises(SystemExit) as err: plugin.call(args, ctx) - captured_text = capsys.readouterr().out + captured_text = capsys.readouterr().err assert err.value.code == 2 assert ( @@ -118,7 +118,7 @@ def test_failed_upload(mock_cli, capsys: CaptureFixture): with pytest.raises(SystemExit) as err: plugin.call(args, ctx) - captured_text = capsys.readouterr().out + captured_text = capsys.readouterr().err assert err.value.code == 2 assert ( diff --git a/tests/unit/test_plugin_ssh.py b/tests/unit/test_plugin_ssh.py index 77f51f7d9..92471fde9 100644 --- a/tests/unit/test_plugin_ssh.py +++ b/tests/unit/test_plugin_ssh.py @@ -30,7 +30,7 @@ def test_windows_error(capsys: CaptureFixture): assert err.value.code == 2 - captured_text = capsys.readouterr().out + captured_text = capsys.readouterr().err assert "This plugin is not currently supported in Windows." in captured_text @@ -54,7 +54,7 @@ def mock_call_operation(*a, filters=None): assert err.value.code == 2 - captured_text = capsys.readouterr().out + captured_text = capsys.readouterr().err assert ( f"{test_label} is not running (status is provisioning)" in captured_text ) @@ -163,7 +163,7 @@ def mock_call_operation(*a, filters=None): assert err.value.code == 2 - captured_text = capsys.readouterr().out + captured_text = capsys.readouterr().err assert "Could not retrieve Linode: 500 error" in captured_text