diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 6d49887fd..c77f7a84b 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -17,4 +17,4 @@ jobs: - name: Build the Docker image run: docker build . --file Dockerfile --tag linode/cli:$(date +%s) --build-arg="github_token=$GITHUB_TOKEN" env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/e2e-suite-windows.yml b/.github/workflows/e2e-suite-windows.yml index 108c757d8..9c74681eb 100644 --- a/.github/workflows/e2e-suite-windows.yml +++ b/.github/workflows/e2e-suite-windows.yml @@ -103,3 +103,60 @@ jobs: conclusion: process.env.conclusion }); return result; + + apply-calico-rules: + runs-on: ubuntu-latest + needs: [integration-fork-windows] + if: ${{ success() || failure() }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: 'recursive' + + - 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: Apply Calico Rules to LKE + run: | + cd e2e_scripts/cloud_security_scripts/lke_calico_rules/ && ./lke_calico_rules_e2e.sh + env: + LINODE_TOKEN: ${{ secrets.LINODE_TOKEN_2 }} + + add-fw-to-remaining-instances: + runs-on: ubuntu-latest + needs: [integration-fork-windows] + if: ${{ success() || failure() }} + + steps: + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install Linode CLI + run: | + pip install linode-cli + + - name: Create Firewall and Attach to Instances + run: | + FIREWALL_ID=$(linode-cli firewalls create --label "e2e-fw-$(date +%s)" --rules.inbound_policy "DROP" --rules.outbound_policy "ACCEPT" --text --format=id --no-headers) + echo "Created Firewall with ID: $FIREWALL_ID" + + for instance_id in $(linode-cli linodes list --format "id" --text --no-header); do + echo "Attaching firewall to instance: $instance_id" + if linode-cli firewalls device-create "$FIREWALL_ID" --id "$instance_id" --type linode; then + echo "Firewall attached to instance $instance_id successfully." + else + echo "An error occurred while attaching firewall to instance $instance_id. Skipping..." + fi + done + env: + LINODE_CLI_TOKEN: ${{ secrets.LINODE_TOKEN_2 }} \ No newline at end of file diff --git a/.github/workflows/e2e-suite.yml b/.github/workflows/e2e-suite.yml index b5e92fa60..8187c71f5 100644 --- a/.github/workflows/e2e-suite.yml +++ b/.github/workflows/e2e-suite.yml @@ -7,12 +7,12 @@ on: 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'" + module: + description: "The module from 'test/integration' to the target to be tested, e.g. 'cli, domains, events, etc'" required: false sha: description: 'The hash value of the commit.' - required: false + required: true default: '' pull_request_number: description: 'The number of the PR. Ensure sha value is provided' @@ -28,15 +28,6 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'workflow_dispatch' && inputs.sha != '' || github.event_name == 'push' || github.event_name == 'pull_request' steps: - - 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 @@ -91,14 +82,6 @@ jobs: pip install certifi -U && \ 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: @@ -112,18 +95,10 @@ jobs: 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 == '' }} + make testint TEST_ARGS="--junitxml=${report_filename}" MODULE="${{ inputs.module }}" 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_TOKEN: ${{ env.LINODE_CLI_TOKEN }} - - name: Upload test results if: always() run: | @@ -168,10 +143,75 @@ jobs: }); return result; + apply-calico-rules: + runs-on: ubuntu-latest + needs: [integration_tests] + if: ${{ success() || failure() }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: 'recursive' + + - 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: 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: Apply Calico Rules to LKE + run: | + cd e2e_scripts/cloud_security_scripts/lke_calico_rules/ && ./lke_calico_rules_e2e.sh + env: + LINODE_TOKEN: ${{ env.LINODE_CLI_TOKEN }} + + add-fw-to-remaining-instances: + runs-on: ubuntu-latest + needs: [integration_tests] + if: ${{ success() || failure() }} + + steps: + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install Linode CLI + run: | + pip install linode-cli + + - 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: Create Firewall and Attach to Instances + run: | + FIREWALL_ID=$(linode-cli firewalls create --label "e2e-fw-$(date +%s)" --rules.inbound_policy "DROP" --rules.outbound_policy "ACCEPT" --text --format=id --no-headers) + echo "Created Firewall with ID: $FIREWALL_ID" + + for instance_id in $(linode-cli linodes list --format "id" --text --no-header); do + echo "Attaching firewall to instance: $instance_id" + if linode-cli firewalls device-create "$FIREWALL_ID" --id "$instance_id" --type linode; then + echo "Firewall attached to instance $instance_id successfully." + else + echo "An error occurred while attaching firewall to instance $instance_id. Skipping..." + fi + done + env: + LINODE_CLI_TOKEN: ${{ env.LINODE_CLI_TOKEN }} + notify-slack: runs-on: ubuntu-latest needs: [integration_tests] - if: always() && github.repository == 'linode/linode-cli' # Run even if integration tests fail and only on main repository + if: ${{ (success() || failure()) && github.repository == 'linode/linode-cli' }} # Run even if integration tests fail and only on main repository steps: - name: Notify Slack diff --git a/.github/workflows/release-notify-slack.yml b/.github/workflows/release-notify-slack.yml new file mode 100644 index 000000000..be18d3536 --- /dev/null +++ b/.github/workflows/release-notify-slack.yml @@ -0,0 +1,30 @@ +name: Notify Dev DX Channel on Release +on: + release: + types: [published] + workflow_dispatch: null + +jobs: + notify: + if: github.repository == 'linode/linode-cli' + runs-on: ubuntu-latest + steps: + - name: Notify Slack - Main Message + id: main_message + uses: slackapi/slack-github-action@v1.27.0 + with: + channel-id: ${{ secrets.CLI_SLACK_CHANNEL_ID }} + payload: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*New Release Published: _linode-cli_ <${{ github.event.release.html_url }}|${{ github.event.release.tag_name }}> is now live!* :tada:" + } + } + ] + } + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 499343bc9..0397591fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ FROM python:3.11-slim AS builder ARG linode_cli_version + ARG github_token WORKDIR /src diff --git a/Makefile b/Makefile index a8884fdf2..42cae3c4d 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,8 @@ # # Makefile for more convenient building of the Linode CLI and its baked content # + +# Test-related arguments MODULE := TEST_CASE_COMMAND := TEST_ARGS := @@ -9,7 +11,6 @@ ifdef TEST_CASE TEST_CASE_COMMAND = -k $(TEST_CASE) endif - SPEC_VERSION ?= latest ifndef SPEC override SPEC = $(shell ./resolve_spec_url ${SPEC_VERSION}) diff --git a/linodecli/__init__.py b/linodecli/__init__.py index 776b6589f..6f2d62c5e 100755 --- a/linodecli/__init__.py +++ b/linodecli/__init__.py @@ -15,12 +15,7 @@ from linodecli import plugins from linodecli.exit_codes import ExitCodes -from .arg_helpers import ( - bake_command, - register_args, - register_plugin, - remove_plugin, -) +from .arg_helpers import register_args, register_plugin, remove_plugin from .cli import CLI from .completion import get_completions from .configuration import ENV_TOKEN_NAME @@ -101,9 +96,9 @@ def main(): # pylint: disable=too-many-branches,too-many-statements # handle a bake - this is used to parse a spec and bake it as a pickle if parsed.command == "bake": if parsed.action is None: - print("No spec provided, cannot bake") + print("No spec provided, cannot bake", file=sys.stderr) sys.exit(ExitCodes.ARGUMENT_ERROR) - bake_command(cli, parsed.action) + cli.bake(parsed.action) sys.exit(ExitCodes.SUCCESS) elif cli.ops is None: # if not spec was found and we weren't baking, we're doomed @@ -111,7 +106,7 @@ def main(): # pylint: disable=too-many-branches,too-many-statements if parsed.command == "register-plugin": if parsed.action is None: - print("register-plugin requires a module name!") + print("register-plugin requires a module name!", file=sys.stderr) sys.exit(ExitCodes.ARGUMENT_ERROR) msg, code = register_plugin(parsed.action, cli.config, cli.ops) print(msg) @@ -119,7 +114,10 @@ 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!") + print( + "remove-plugin requires a plugin name to remove!", + file=sys.stderr, + ) sys.exit(ExitCodes.ARGUMENT_ERROR) msg, code = remove_plugin(parsed.action, cli.config) print(msg) @@ -216,7 +214,7 @@ def main(): # pylint: disable=too-many-branches,too-many-statements and parsed.command not in plugins.available(cli.config) and parsed.command not in HELP_TOPICS ): - print(f"Unrecognized command {parsed.command}") + print(f"Unrecognized command {parsed.command}", file=sys.stderr) sys.exit(ExitCodes.UNRECOGNIZED_COMMAND) # handle a help for a command - either --help or no action triggers this diff --git a/linodecli/api_request.py b/linodecli/api_request.py index f73b1c883..036fe5141 100644 --- a/linodecli/api_request.py +++ b/linodecli/api_request.py @@ -283,6 +283,9 @@ def _print_request_debug_info(method, url, headers, body): """ print(f"> {method.__name__.upper()} {url}", file=sys.stderr) for k, v in headers.items(): + # If this is the Authorization header, sanitize the token + if k.lower() == "authorization": + v = "Bearer " + "*" * 64 print(f"> {k}: {v}", file=sys.stderr) print("> Body:", file=sys.stderr) print("> ", body or "", file=sys.stderr) diff --git a/linodecli/arg_helpers.py b/linodecli/arg_helpers.py index 36b0d2c59..5cd853ea3 100644 --- a/linodecli/arg_helpers.py +++ b/linodecli/arg_helpers.py @@ -2,16 +2,10 @@ """ Argument parser for the linode CLI """ - -import os import sys from importlib import import_module -import requests -import yaml - from linodecli import plugins -from linodecli.exit_codes import ExitCodes from linodecli.helpers import ( register_args_shared, register_debug_arg, @@ -107,7 +101,10 @@ def register_plugin(module, config, ops): reregistering = False if plugin_name in plugins.available(config): - print(f"WARNING: Plugin {plugin_name} is already registered.\n\n") + print( + f"WARNING: Plugin {plugin_name} is already registered.\n\n", + file=sys.stderr, + ) answer = input(f"Allow re-registration of {plugin_name}? [y/N] ") if not answer or answer not in "yY": return "Registration aborted.", 0 @@ -166,24 +163,3 @@ def remove_plugin(plugin_name, config): config.write_config() return f"Plugin {plugin_name} removed", 0 - - -def bake_command(cli, spec_loc): - """ - Handle a bake command from args - """ - try: - if os.path.exists(os.path.expanduser(spec_loc)): - with open(os.path.expanduser(spec_loc), encoding="utf-8") as f: - spec = yaml.safe_load(f.read()) - else: # try to GET it - resp = requests.get(spec_loc, timeout=120) - if resp.status_code == 200: - spec = yaml.safe_load(resp.content) - else: - raise RuntimeError(f"Request failed to {spec_loc}") - except Exception as e: - print(f"Could not load spec: {e}") - sys.exit(ExitCodes.REQUEST_FAILED) - - cli.bake(spec) diff --git a/linodecli/baked/operation.py b/linodecli/baked/operation.py index 1494792cc..218b9059c 100644 --- a/linodecli/baked/operation.py +++ b/linodecli/baked/operation.py @@ -366,9 +366,11 @@ def __init__( if param.name not in {"apiVersion"} ] - self.url_base, self.url_path, self.default_api_version = ( - self._get_api_url_components(operation, params) - ) + ( + 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 diff --git a/linodecli/baked/parsing.py b/linodecli/baked/parsing.py index 5125e45dc..dd41af5ef 100644 --- a/linodecli/baked/parsing.py +++ b/linodecli/baked/parsing.py @@ -9,7 +9,7 @@ # Sentence delimiter, split on a period followed by any type of # whitespace (space, new line, tab, etc.) -REGEX_SENTENCE_DELIMITER = re.compile(r"\.(?:\s|$)") +REGEX_SENTENCE_DELIMITER = re.compile(r"\W(?:\s|$)") # Matches on pattern __prefix__ at the beginning of a description # or after a comma diff --git a/linodecli/cli.py b/linodecli/cli.py index a9f61a86c..775a11e49 100644 --- a/linodecli/cli.py +++ b/linodecli/cli.py @@ -2,11 +2,17 @@ Responsible for managing spec and routing commands to operations. """ +import contextlib +import json import os import pickle import sys +from json import JSONDecodeError from sys import version_info +from typing import IO, Any, ContextManager, Dict +import requests +import yaml from openapi3 import OpenAPI from linodecli.api_request import do_request, get_all_pages @@ -40,11 +46,19 @@ def __init__(self, version, base_url, skip_config=False): self.config = CLIConfig(self.base_url, skip_config=skip_config) self.load_baked() - def bake(self, spec): + def bake(self, spec_location: str): """ - Generates ops and bakes them to a pickle + Generates ops and bakes them to a pickle. + + :param spec_location: The URL or file path of the OpenAPI spec to parse. """ - spec = OpenAPI(spec) + + try: + spec = self._load_openapi_spec(spec_location) + except Exception as e: + print(f"Failed to load spec: {e}") + sys.exit(ExitCodes.REQUEST_FAILED) + self.spec = spec self.ops = {} ext = { @@ -102,9 +116,10 @@ def load_baked(self): self.spec = self.ops.pop("_spec") else: print( - "No spec baked. Please bake by calling this script as follows:" + "No spec baked. Please bake by calling this script as follows:", + file=sys.stderr, ) - print(" python3 gen_cli.py bake /path/to/spec") + print(" python3 gen_cli.py bake /path/to/spec", file=sys.stderr) self.ops = None # this signals __init__.py to give up def _get_data_file(self): @@ -205,3 +220,85 @@ def user_agent(self) -> str: f"linode-api-docs/{self.spec_version} " f"python/{version_info[0]}.{version_info[1]}.{version_info[2]}" ) + + @staticmethod + def _load_openapi_spec(spec_location: str) -> OpenAPI: + """ + Attempts to load the raw OpenAPI spec (YAML or JSON) at the given location. + + :param spec_location: The location of the OpenAPI spec. + This can be a local path or a URL. + + :returns: A tuple containing the loaded OpenAPI object and the parsed spec in + dict format. + """ + + with CLI._get_spec_file_reader(spec_location) as f: + parsed = CLI._parse_spec_file(f) + + return OpenAPI(parsed) + + @staticmethod + @contextlib.contextmanager + def _get_spec_file_reader( + spec_location: str, + ) -> ContextManager[IO]: + """ + Returns a reader for an OpenAPI spec file from the given location. + + :param spec_location: The location of the OpenAPI spec. + This can be a local path or a URL. + + :returns: A context manager yielding the spec file's reader. + """ + + # Case for local file + local_path = os.path.expanduser(spec_location) + if os.path.exists(local_path): + f = open(local_path, "r", encoding="utf-8") + + try: + yield f + finally: + f.close() + + return + + # Case for remote file + resp = requests.get(spec_location, stream=True, timeout=120) + if resp.status_code != 200: + raise RuntimeError(f"Failed to GET {spec_location}") + + # We need to access the underlying urllib + # response here so we can return a reader + # usable in yaml.safe_load(...) and json.load(...) + resp.raw.decode_content = True + + try: + yield resp.raw + finally: + resp.close() + + @staticmethod + def _parse_spec_file(reader: IO) -> Dict[str, Any]: + """ + Parses the given file reader into a dict and returns a dict. + + :param reader: A reader for a YAML or JSON file. + + :returns: The parsed file. + """ + + errors = [] + + try: + return yaml.safe_load(reader) + except yaml.YAMLError as err: + errors.append(str(err)) + + try: + return json.load(reader) + except JSONDecodeError as err: + errors.append(str(err)) + + raise ValueError(f"Failed to parse spec file: {'; '.join(errors)}") diff --git a/linodecli/configuration/auth.py b/linodecli/configuration/auth.py index 7c9c5e775..a7e23b39d 100644 --- a/linodecli/configuration/auth.py +++ b/linodecli/configuration/auth.py @@ -58,7 +58,10 @@ def _handle_response_status( if 199 < response.status_code < 300: return - print(f"Could not contact {response.url} - Error: {response.status_code}") + print( + f"Could not contact {response.url} - Error: {response.status_code}", + file=sys.stderr, + ) if exit_on_error: sys.exit(ExitCodes.REQUEST_FAILED) @@ -194,7 +197,7 @@ def _username_for_token(base_url: str, token: str) -> str: u = _do_get_request(base_url, "/profile", token=token, exit_on_error=False) if "errors" in u: reasons = ",".join([c["reason"] for c in u["errors"]]) - print(f"That token didn't work: {reasons}") + print(f"That token didn't work: {reasons}", file=sys.stderr) return None return u["username"] @@ -243,7 +246,10 @@ def _get_token_web(base_url: str) -> Tuple[str, str]: username = _username_for_token(base_url, temp_token) if username is None: - print("OAuth failed. Please try again of use a token for auth.") + print( + "OAuth failed. Please try again of use a token for auth.", + file=sys.stderr, + ) sys.exit(ExitCodes.OAUTH_ERROR) # the token returned via public oauth will expire in 2 hours, which @@ -340,11 +346,11 @@ def log_message(self, form, *args): # pylint: disable=arguments-differ # serve requests one at a time until we get a token or are interrupted serv.handle_request() except KeyboardInterrupt: - print() print( - "Giving up. If you couldn't get web authentication to work, please " + "\nGiving up. If you couldn't get web authentication to work, please " "try token using a token by invoking with `linode-cli configure --token`, " - "and open an issue at https://github.com/linode/linode-cli" + "and open an issue at https://github.com/linode/linode-cli", + file=sys.stderr, ) sys.exit(ExitCodes.OAUTH_ERROR) diff --git a/linodecli/configuration/config.py b/linodecli/configuration/config.py index d857d0227..ea55da732 100644 --- a/linodecli/configuration/config.py +++ b/linodecli/configuration/config.py @@ -96,7 +96,7 @@ def set_user(self, username: str): :type username: str """ if not self.config.has_section(username): - print(f"User {username} is not configured!") + print(f"User {username} is not configured!", file=sys.stderr) sys.exit(ExitCodes.USERNAME_ERROR) self.username = username @@ -112,7 +112,8 @@ def remove_user(self, username: str): if self.default_username() == username: print( f"Cannot remove {username} as they are the default user! You can " - "change the default user with: `linode-cli set-user USERNAME`" + "change the default user with: `linode-cli set-user USERNAME`", + file=sys.stderr, ) sys.exit(ExitCodes.USERNAME_ERROR) @@ -138,7 +139,7 @@ def set_default_user(self, username: str): Sets the default user. If that user isn't in the config, exits with error """ if not self.config.has_section(username): - print(f"User {username} is not configured!") + print(f"User {username} is not configured!", file=sys.stderr) sys.exit(ExitCodes.USERNAME_ERROR) self.config.set("DEFAULT", "default-user", username) @@ -288,7 +289,7 @@ def update( if not self.config.has_option(username, "token") and not os.environ.get( ENV_TOKEN_NAME, None ): - print(f"User {username} is not configured.") + print(f"User {username} is not configured.", file=sys.stderr) sys.exit(ExitCodes.USERNAME_ERROR) if ( not self.config.has_section(username) @@ -328,7 +329,8 @@ def update( ): print( f"Using default values: {warn_dict}; " - "use the --no-defaults flag to disable defaults" + "use the --no-defaults flag to disable defaults", + file=sys.stderr, ) return argparse.Namespace(**ns_dict) diff --git a/linodecli/overrides.py b/linodecli/overrides.py index b7e843bc9..a1473b783 100644 --- a/linodecli/overrides.py +++ b/linodecli/overrides.py @@ -11,11 +11,21 @@ from rich.align import Align from rich.console import Console from rich.table import Table +from rich.theme import Theme from linodecli.output.output_handler import OutputMode OUTPUT_OVERRIDES = {} +REPLICA_STATUS_THEME = { + "available": "bright_green", + "creating": "bright_yellow", + "pending": "yellow", + "pending replication": "yellow", + "pending deletion": "red", + "replicating": "bright_yellow", +} + def output_override(command: str, action: str, output_mode: OutputMode): """ @@ -157,7 +167,12 @@ def build_replicas_output(replicas: List) -> Table: for replica in replicas: row = [] for h in replicas_headers: - row.append(Align(str(replica[h]), align="left")) + if h == "status" and replica[h] in REPLICA_STATUS_THEME: + row.append( + Align(str(replica[h]), align="left", style=replica[h]) + ) + else: + row.append(Align(str(replica[h]), align="left")) replicas_output.add_row(*row) return replicas_output @@ -167,24 +182,27 @@ def image_replicate_output(json_data) -> bool: """ Parse and format the image replicate output table. """ + console = Console(theme=Theme(REPLICA_STATUS_THEME)) + output = Table( header_style="bold", show_lines=True, ) row = [] - for header in json_data.keys(): - if header == "regions" and len(json_data[header]) > 0: - # leverage `replicas` in output for readability - output.add_column("replicas", justify="center") - row.append(build_replicas_output(json_data[header])) - elif json_data[header] is not None: - output.add_column(header, justify="center") - row.append(Align(str(json_data[header]), align="left")) + headers = ["id", "label", "status", "total_size", "regions"] + for header in headers: + if header in json_data: + if header == "regions" and len(json_data[header]) > 0: + # leverage `replicas` in output for readability + output.add_column("replicas", justify="center") + row.append(build_replicas_output(json_data[header])) + elif json_data[header] is not None: + output.add_column(header, justify="center") + row.append(Align(str(json_data[header]), align="left")) output.add_row(*row) - console = Console() console.print(output) return False diff --git a/linodecli/plugins/firewall-editor.py b/linodecli/plugins/firewall-editor.py index d141c787a..f7c1f494d 100644 --- a/linodecli/plugins/firewall-editor.py +++ b/linodecli/plugins/firewall-editor.py @@ -43,7 +43,7 @@ def input(input_text: str, validator: Callable[[str], None]): try: validator(value) except ValueError as err: - print(f"Invalid Input: {'; '.join(err.args)}") + print(f"Invalid Input: {'; '.join(err.args)}", file=sys.stderr) continue return value @@ -204,7 +204,7 @@ def _get_firewall(firewall_id, client): ) if code != 200: - print(f"Error retrieving firewall: {code}") + print(f"Error retrieving firewall: {code}", file=sys.stderr) sys.exit(ExitCodes.FIREWALL_ERROR) code, rules = client.call_operation( @@ -212,7 +212,7 @@ def _get_firewall(firewall_id, client): ) if code != 200: - print(f"Error retrieving firewall rules: {code}") + print(f"Error retrieving firewall rules: {code}", file=sys.stderr) sys.exit(ExitCodes.FIREWALL_ERROR) return firewall, rules @@ -449,7 +449,7 @@ def remove_rule(rules): change = InputValidation.input_io(rules) if len(change) < 1: - print("No entires to remove") + print("No entires to remove", file=sys.stderr) return False ind_str = InputValidation.input( @@ -614,9 +614,9 @@ def call(args, context): if code == 200: print("Rules updated successfully!") break - print(f"Error editing rules: {code}: {errors}") + print(f"Error editing rules: {code}: {errors}", file=sys.stderr) # block to see the error, then re-enter the editor sys.stdin.read(1) else: - print("Aborted.") + print("Aborted.", file=sys.stderr) break diff --git a/linodecli/plugins/image-upload.py b/linodecli/plugins/image-upload.py index 9c89c3ffc..19b1068c5 100644 --- a/linodecli/plugins/image-upload.py +++ b/linodecli/plugins/image-upload.py @@ -121,30 +121,36 @@ def call(args, context): results = glob.glob(filepath, recursive=True) if len(results) < 1: - print(f"No file found matching pattern {filepath}") + print(f"No file found matching pattern {filepath}", file=sys.stderr) sys.exit(ExitCodes.FILE_ERROR) if len(results) > 1: print( - f"warn: Found multiple files matching pattern {filepath}, using {results[0]}" + f"warn: Found multiple files matching pattern {filepath}, using {results[0]}", + file=sys.stderr, ) filepath = results[0] if not os.path.isfile(filepath): - print(f"No file at {filepath}; must be a path to a valid file.") + print( + f"No file at {filepath}; must be a path to a valid file.", + file=sys.stderr, + ) 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" + f"File {filepath} is too large; compressed size must be less than 5GB", + file=sys.stderr, ) sys.exit(ExitCodes.FILE_ERROR) if not parsed.region: print( - "No region provided. Please set a default region or use --region" + "No region provided. Please set a default region or use --region", + file=sys.stderr, ) sys.exit(ExitCodes.ARGUMENT_ERROR) @@ -165,17 +171,22 @@ def call(args, context): print( "Your token was not authorized to use this endpoint. Please " "reconfigure the CLI with `linode-cli configure` to ensure you " - "can make this request." + "can make this request.", + file=sys.stderr, ) 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." + "to request access.", + file=sys.stderr, ) sys.exit(ExitCodes.REQUEST_FAILED) - print(f"Upload failed with status {status}; response was {resp}") + print( + f"Upload failed with status {status}; response was {resp}", + file=sys.stderr, + ) sys.exit(ExitCodes.REQUEST_FAILED) # grab the upload URL and image data diff --git a/linodecli/plugins/obj/__init__.py b/linodecli/plugins/obj/__init__.py index 521d48565..32d2a3cf2 100644 --- a/linodecli/plugins/obj/__init__.py +++ b/linodecli/plugins/obj/__init__.py @@ -147,11 +147,14 @@ 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") + print( + "You may not set the ACL to public and private in the same call", + file=sys.stderr, + ) sys.exit(ExitCodes.REQUEST_FAILED) if not parsed.acl_public and not parsed.acl_private: - print("You must choose an ACL to apply") + print("You must choose an ACL to apply", file=sys.stderr) sys.exit(ExitCodes.REQUEST_FAILED) acl = "public-read" if parsed.acl_public else "private" bucket = parsed.bucket @@ -313,7 +316,8 @@ def get_credentials(cli: CLI): if bool(access_key) != bool(secret_key): print( f"You must set both {ENV_ACCESS_KEY_NAME} " - f"and {ENV_SECRET_KEY_NAME}, or neither" + f"and {ENV_SECRET_KEY_NAME}, or neither", + file=sys.stderr, ) sys.exit(ExitCodes.REQUEST_FAILED) @@ -336,7 +340,8 @@ def regenerate_s3_credentials(cli: CLI, suppress_warnings=False): print( "WARNING: Your old Object Storage keys _were not_ automatically expired! If you want " "to expire them, see `linode-cli object-storage keys-list` and " - "`linode-cli object-storage keys-delete [KEYID]`." + "`linode-cli object-storage keys-delete [KEYID]`.", + file=sys.stderr, ) @@ -383,12 +388,13 @@ def call( def try_get_default_cluster(): if not context.client.defaults: - print("Error: cluster is required.") + print("Error: cluster is required.", file=sys.stderr) sys.exit(ExitCodes.REQUEST_FAILED) print( "Error: No default cluster is configured. Either configure the CLI " - "or invoke with --cluster to specify a cluster." + "or invoke with --cluster to specify a cluster.", + file=sys.stderr, ) _configure_plugin(context.client) return context.client.config.plugin_get_value("cluster") @@ -413,7 +419,7 @@ def get_client(): get_client, args, suppress_warnings=parsed.suppress_warnings ) except ClientError as e: - print(e) + print(e, file=sys.stderr) sys.exit(ExitCodes.REQUEST_FAILED) elif parsed.command == "regenerate-keys": regenerate_s3_credentials( @@ -422,7 +428,7 @@ def get_client(): elif parsed.command == "configure": _configure_plugin(context.client) else: - print(f"No command {parsed.command}") + print(f"No command {parsed.command}", file=sys.stderr) sys.exit(ExitCodes.REQUEST_FAILED) @@ -476,7 +482,8 @@ def _get_s3_creds(client: CLI, force: bool = False): "Please set the following variables in your environment: " f"'{ENV_ACCESS_KEY_NAME}' and '{ENV_SECRET_KEY_NAME}'. If you'd rather " "configure the CLI, unset the 'LINODE_CLI_TOKEN' environment " - "variable and then run `linode-cli configure`." + "variable and then run `linode-cli configure`.", + file=sys.stderr, ) sys.exit(ExitCodes.REQUEST_FAILED) @@ -486,14 +493,14 @@ def _get_s3_creds(client: CLI, force: bool = False): if status != 200: if status == 401: # special case - oauth token isn't allowed to do this - print(NO_SCOPES_ERROR) + print(NO_SCOPES_ERROR, file=sys.stderr) sys.exit(ExitCodes.REQUEST_FAILED) if status == 403: # special case - restricted users can't use obj - print(NO_ACCESS_ERROR) + print(NO_ACCESS_ERROR, file=sys.stderr) sys.exit(ExitCodes.REQUEST_FAILED) # something went wrong - give up - print("Key generation failed!") + print("Key generation failed!", file=sys.stderr) sys.exit(ExitCodes.REQUEST_FAILED) # label caps at 50 characters - trim some stuff maybe @@ -519,14 +526,14 @@ def _get_s3_creds(client: CLI, force: bool = False): if status != 200: if status == 401: # special case - oauth token isn't allowed to do this - print(NO_SCOPES_ERROR) + print(NO_SCOPES_ERROR, file=sys.stderr) sys.exit(ExitCodes.REQUEST_FAILED) if status == 403: # special case - restricted users can't use obj - print(NO_ACCESS_ERROR) + print(NO_ACCESS_ERROR, file=sys.stderr) sys.exit(ExitCodes.REQUEST_FAILED) # something went wrong - give up - print("Key generation failed!") + print("Key generation failed!", file=sys.stderr) sys.exit(ExitCodes.REQUEST_FAILED) access_key = resp["access_key"] diff --git a/linodecli/plugins/obj/helpers.py b/linodecli/plugins/obj/helpers.py index 6441c4a76..9767dfcb9 100644 --- a/linodecli/plugins/obj/helpers.py +++ b/linodecli/plugins/obj/helpers.py @@ -138,7 +138,7 @@ def flip_to_page(iterable: Iterable, page: int = 1): try: next(iterable) except StopIteration: - print(INVALID_PAGE_MSG) + print(INVALID_PAGE_MSG, file=sys.stderr) sys.exit(ExitCodes.REQUEST_FAILED) return next(iterable) diff --git a/linodecli/plugins/obj/list.py b/linodecli/plugins/obj/list.py index 47b534a82..6a6184da7 100644 --- a/linodecli/plugins/obj/list.py +++ b/linodecli/plugins/obj/list.py @@ -79,7 +79,7 @@ def list_objects_or_buckets( results = [page] except client.exceptions.NoSuchBucket: - print("No bucket named " + bucket_name) + print("No bucket named " + bucket_name, file=sys.stderr) sys.exit(ExitCodes.REQUEST_FAILED) for item in results: diff --git a/linodecli/plugins/plugins.py b/linodecli/plugins/plugins.py index 480ac9022..0a067c26e 100644 --- a/linodecli/plugins/plugins.py +++ b/linodecli/plugins/plugins.py @@ -128,7 +128,10 @@ def invoke(name: str, args: List[str], context: PluginContext): "DEFAULT", f"plugin-name-{name}" ) except KeyError: - print(f"Plugin {name} is misconfigured - please re-register it") + print( + f"Plugin {name} is misconfigured - please re-register it", + file=sys.stderr, + ) sys.exit(ExitCodes.REQUEST_FAILED) try: @@ -136,7 +139,8 @@ def invoke(name: str, args: List[str], context: PluginContext): except ImportError: print( f"Expected module '{plugin_module_name}' not found. " - "Either {name} is misconfigured, or the backing module was uninstalled." + "Either {name} is misconfigured, or the backing module was uninstalled.", + file=sys.stderr, ) sys.exit(ExitCodes.REQUEST_FAILED) else: diff --git a/linodecli/plugins/region-table.py b/linodecli/plugins/region-table.py index f26d3b972..96f3f76c8 100644 --- a/linodecli/plugins/region-table.py +++ b/linodecli/plugins/region-table.py @@ -33,7 +33,7 @@ def call(_, ctx): ] if status != 200: - print("It failed :(") + print("It failed :(", file=sys.stderr) sys.exit(ExitCodes.REQUEST_FAILED) output = Table() diff --git a/linodecli/plugins/regionstats.py.example b/linodecli/plugins/regionstats.py.example index 78bda74ad..edcda513d 100644 --- a/linodecli/plugins/regionstats.py.example +++ b/linodecli/plugins/regionstats.py.example @@ -12,7 +12,7 @@ def call(args, context): status, result = context.client.call_operation('linodes', 'list') if status != 200: - print('It failed :(') + print('It failed :(', file=sys.stderr) exit(1) regions = {} diff --git a/linodecli/plugins/ssh.py b/linodecli/plugins/ssh.py index b5107591a..2ed53cbdb 100644 --- a/linodecli/plugins/ssh.py +++ b/linodecli/plugins/ssh.py @@ -27,7 +27,8 @@ def call(args, context): # pylint: disable=too-many-branches print( "This plugin is not currently supported in Windows. For more " "information or to suggest a fix, please visit " - "https://github.com/linode/linode-cli" + "https://github.com/linode/linode-cli", + file=sys.stderr, ) sys.exit(ExitCodes.REQUEST_FAILED) @@ -62,7 +63,8 @@ def call(args, context): # pylint: disable=too-many-branches if target["status"] != "running": print( - f"{label} is not running (status is {target['status']}); operation aborted." + f"{label} is not running (status is {target['status']}); operation aborted.", + file=sys.stderr, ) sys.exit(ExitCodes.REQUEST_FAILED) @@ -95,7 +97,7 @@ def find_linode_with_label(context, label: str) -> str: ) if result != 200: - print(f"Could not retrieve Linode: {result} error") + print(f"Could not retrieve Linode: {result} error", file=sys.stderr) sys.exit(ExitCodes.REQUEST_FAILED) potential_matches = potential_matches["data"] diff --git a/tests/fixtures/cli_test_load.json b/tests/fixtures/cli_test_load.json new file mode 100644 index 000000000..ba0a55a87 --- /dev/null +++ b/tests/fixtures/cli_test_load.json @@ -0,0 +1,95 @@ +{ + "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": "fooBarGet", + "description": "This is description", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OpenAPIResponseAttr" + } + }, + "page": { + "$ref": "#/components/schemas/PaginationEnvelope/properties/page" + }, + "pages": { + "$ref": "#/components/schemas/PaginationEnvelope/properties/pages" + }, + "results": { + "$ref": "#/components/schemas/PaginationEnvelope/properties/results" + } + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "OpenAPIResponseAttr": { + "type": "object", + "properties": { + "filterable_result": { + "x-linode-filterable": true, + "type": "string", + "description": "Filterable result value" + }, + "filterable_list_result": { + "x-linode-filterable": true, + "type": "array", + "items": { + "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 + } + } + } + } + } +} diff --git a/tests/fixtures/cli_test_load.yaml b/tests/fixtures/cli_test_load.yaml new file mode 100644 index 000000000..f7dd7704e --- /dev/null +++ b/tests/fixtures/cli_test_load.yaml @@ -0,0 +1,64 @@ +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: fooBarGet + description: This is description + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/OpenAPIResponseAttr' + page: + $ref: '#/components/schemas/PaginationEnvelope/properties/page' + pages: + $ref: '#/components/schemas/PaginationEnvelope/properties/pages' + results: + $ref: '#/components/schemas/PaginationEnvelope/properties/results' + +components: + schemas: + OpenAPIResponseAttr: + type: object + properties: + filterable_result: + x-linode-filterable: true + type: string + description: Filterable result value + filterable_list_result: + x-linode-filterable: true + type: array + items: + 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 \ No newline at end of file diff --git a/tests/integration/account/test_account.py b/tests/integration/account/test_account.py index f262652d5..cdac0aa6f 100644 --- a/tests/integration/account/test_account.py +++ b/tests/integration/account/test_account.py @@ -232,7 +232,7 @@ def test_user_list(): @pytest.fixture -def get_user_id(): +def username(): user_id = ( exec_test_command( [ @@ -255,11 +255,10 @@ def get_user_id(): yield first_id -def test_user_view(get_user_id): - user_id = get_user_id +def test_user_view(username: str): res = ( exec_test_command( - ["linode-cli", "users", "view", user_id, "--text", "--delimiter=,"] + ["linode-cli", "users", "view", username, "--text", "--delimiter=,"] ) .stdout.decode() .rstrip() @@ -316,3 +315,45 @@ def test_service_transfers(): headers = ["token", "expiry", "is_sender"] assert_headers_in_lines(headers, lines) + + +def test_maintenance_list(): + res = ( + exec_test_command( + BASE_CMD + ["maintenance-list", "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["entity.type", "entity.label"] + assert_headers_in_lines(headers, lines) + + +def test_notifications_list(): + res = ( + exec_test_command( + BASE_CMD + ["notifications-list", "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["label", "severity"] + assert_headers_in_lines(headers, lines) + + +def test_clients_list(): + res = ( + exec_test_command( + BASE_CMD + ["clients-list", "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["label", "status"] + assert_headers_in_lines(headers, lines) diff --git a/tests/integration/cli/test_help.py b/tests/integration/cli/test_help.py index 6c7b848a0..4c72de44e 100644 --- a/tests/integration/cli/test_help.py +++ b/tests/integration/cli/test_help.py @@ -12,7 +12,7 @@ def test_help_page_for_non_aliased_actions(): process = exec_test_command(["linode-cli", "linodes", "list", "--help"]) output = process.stdout.decode() - wrapped_output = textwrap.fill(output, width=150).replace("\n", "") + wrapped_output = textwrap.fill(output, width=180).replace("\n", "") assert contains_at_least_one_of( wrapped_output, ["Linodes List", "List Linodes"] @@ -34,7 +34,7 @@ def test_help_page_for_non_aliased_actions(): def test_help_page_for_aliased_actions(): process = exec_test_command(["linode-cli", "linodes", "ls", "--help"]) output = process.stdout.decode() - wrapped_output = textwrap.fill(output, width=150).replace("\n", "") + wrapped_output = textwrap.fill(output, width=180).replace("\n", "") assert contains_at_least_one_of( wrapped_output, ["Linodes List", "List Linodes"] diff --git a/tests/integration/image/test_plugin_image_upload.py b/tests/integration/image/test_plugin_image_upload.py index e17b5130a..c81621104 100644 --- a/tests/integration/image/test_plugin_image_upload.py +++ b/tests/integration/image/test_plugin_image_upload.py @@ -8,7 +8,11 @@ import pytest -from tests.integration.helpers import get_random_text +from tests.integration.helpers import ( + assert_headers_in_lines, + exec_failing_test_command, + get_random_text, +) REGION = "us-iad" BASE_CMD = ["linode-cli", "image-upload", "--region", REGION] @@ -52,13 +56,13 @@ def test_invalid_file( fake_image_file, ): file_path = fake_image_file + "_fake" - process = exec_test_command( - BASE_CMD + ["--label", "notimportant", file_path] + process = exec_failing_test_command( + BASE_CMD + ["--label", "notimportant", file_path], expected_code=8 ) - output = process.stdout.decode() + error_output = process.stderr.decode() assert process.returncode == 8 - assert f"No file at {file_path}" in output + assert f"No file at {file_path}" in error_output @pytest.mark.smoke @@ -135,3 +139,63 @@ def test_file_upload_cloud_init( # Delete the image process = exec_test_command(["linode-cli", "images", "rm", image[0]["id"]]) assert process.returncode == 0 + + +def test_image_list(): + res = ( + exec_test_command( + ["linode-cli", "images", "list", "--text", "--delimiter=,"] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["label", "description"] + assert_headers_in_lines(headers, lines) + + +@pytest.fixture +def get_image_id(): + image_id = ( + exec_test_command( + [ + "linode-cli", + "images", + "list", + "--text", + "--no-headers", + "--delimiter", + ",", + "--format", + "id", + ] + ) + .stdout.decode() + .rstrip() + .splitlines() + ) + first_id = image_id[0].split(",")[0] + yield first_id + + +def test_image_view(get_image_id): + image_id = get_image_id + res = ( + exec_test_command( + [ + "linode-cli", + "images", + "view", + image_id, + "--text", + "--delimiter=,", + ] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["label", "description"] + assert_headers_in_lines(headers, lines) diff --git a/tests/integration/linodes/test_linodes.py b/tests/integration/linodes/test_linodes.py index 4f336363b..8444ce1fd 100644 --- a/tests/integration/linodes/test_linodes.py +++ b/tests/integration/linodes/test_linodes.py @@ -5,6 +5,7 @@ from linodecli.exit_codes import ExitCodes from tests.integration.helpers import ( + assert_headers_in_lines, delete_target_id, exec_failing_test_command, exec_test_command, @@ -167,3 +168,72 @@ def test_add_tag_to_linode(setup_linodes): ).stdout.decode() assert unique_tag in result + + +def list_disk_list(setup_linodes): + linode_id = setup_linodes + res = ( + exec_test_command( + BASE_CMD + + [ + "disks-list", + linode_id, + "--text", + "--delimiter=,", + ] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["id", "label"] + assert_headers_in_lines(headers, lines) + + +@pytest.fixture +def get_disk_id(setup_linodes): + linode_id = setup_linodes + 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_disk_view(setup_linodes, get_disk_id): + linode_id = setup_linodes + disk_id = get_disk_id + res = ( + exec_test_command( + BASE_CMD + + [ + "disk-view", + linode_id, + disk_id, + "--text", + "--delimiter=,", + ] + ) + .stdout.decode() + .rstrip() + ) + lines = res.splitlines() + + headers = ["id", "label"] + assert_headers_in_lines(headers, lines) diff --git a/tests/integration/linodes/test_power_status.py b/tests/integration/linodes/test_power_status.py index 2ffb57f3a..b00091d7d 100644 --- a/tests/integration/linodes/test_power_status.py +++ b/tests/integration/linodes/test_power_status.py @@ -26,6 +26,15 @@ def create_linode_in_running_state(linode_cloud_firewall): delete_target_id("linodes", linode_id) +@pytest.fixture +def create_linode_in_running_state_for_reboot(linode_cloud_firewall): + linode_id = create_linode_and_wait(firewall_id=linode_cloud_firewall) + + yield linode_id + + delete_target_id("linodes", linode_id) + + @pytest.mark.smoke def test_create_linode_and_boot(test_linode_id): linode_id = test_linode_id @@ -36,9 +45,9 @@ 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): +def test_reboot_linode(create_linode_in_running_state_for_reboot): # create linode and wait until it is in "running" state - linode_id = create_linode_in_running_state + linode_id = create_linode_in_running_state_for_reboot # reboot linode from "running" status exec_test_command( diff --git a/tests/integration/linodes/test_rebuild.py b/tests/integration/linodes/test_rebuild.py index 82d7e20fb..eda84fe6d 100644 --- a/tests/integration/linodes/test_rebuild.py +++ b/tests/integration/linodes/test_rebuild.py @@ -27,15 +27,13 @@ def test_linode_id(linode_cloud_firewall): @pytest.mark.flaky(reruns=3, reruns_delay=2) def test_rebuild_fails_without_image(test_linode_id): - linode_id = test_linode_id - result = exec_failing_test_command( BASE_CMD + [ "rebuild", "--root_pass", DEFAULT_RANDOM_PASS, - linode_id, + test_linode_id, "--text", "--no-headers", ], diff --git a/tests/integration/lke/test_clusters.py b/tests/integration/lke/test_clusters.py index 8c0d24022..12b7c063d 100644 --- a/tests/integration/lke/test_clusters.py +++ b/tests/integration/lke/test_clusters.py @@ -2,11 +2,7 @@ import pytest -from tests.integration.helpers import ( - assert_headers_in_lines, - exec_test_command, - remove_lke_clusters, -) +from tests.integration.helpers import assert_headers_in_lines, exec_test_command BASE_CMD = ["linode-cli", "lke"] @@ -301,6 +297,24 @@ def test_version_view(test_version_id): headers = ["id"] assert_headers_in_lines(headers, lines) - # Sleep needed here for proper deletion of linodes that are related to lke cluster - time.sleep(5) - remove_lke_clusters() + + +def test_list_lke_types(): + types = ( + exec_test_command( + BASE_CMD + + [ + "types", + "--text", + ] + ) + .stdout.decode() + .rstrip() + ) + + headers = ["id", "label", "price.hourly", "price.monthly", "transfer"] + lines = types.splitlines() + + assert_headers_in_lines(headers, lines) + assert "LKE Standard Availability" in types + assert "LKE High Availability" in types diff --git a/tests/integration/ssh/test_plugin_ssh.py b/tests/integration/ssh/test_plugin_ssh.py index 610e4ec29..ab060643a 100644 --- a/tests/integration/ssh/test_plugin_ssh.py +++ b/tests/integration/ssh/test_plugin_ssh.py @@ -7,6 +7,7 @@ from tests.integration.helpers import ( COMMAND_JSON_OUTPUT, + exec_failing_test_command, get_random_text, wait_for_condition, ) @@ -83,9 +84,11 @@ def test_help(): @pytest.mark.skipif(platform == "win32", reason="Test N/A on Windows") def test_ssh_instance_provisioning(target_instance: Dict[str, Any]): - process = exec_test_command(BASE_CMD + ["root@" + target_instance["label"]]) + process = exec_failing_test_command( + BASE_CMD + ["root@" + target_instance["label"]], expected_code=2 + ) assert process.returncode == 2 - output = process.stdout.decode() + output = process.stderr.decode() assert "is not running" in output diff --git a/tests/integration/vlans/test_vlans.py b/tests/integration/vlans/test_vlans.py new file mode 100644 index 000000000..b35171a12 --- /dev/null +++ b/tests/integration/vlans/test_vlans.py @@ -0,0 +1,62 @@ +from tests.integration.helpers import assert_headers_in_lines, exec_test_command + +BASE_CMD = ["linode-cli", "vlans"] + + +def test_list_vlans(): + types = ( + exec_test_command( + BASE_CMD + + [ + "ls", + "--text", + ] + ) + .stdout.decode() + .rstrip() + ) + + headers = ["region", "label", "linodes"] + lines = types.splitlines() + + assert_headers_in_lines(headers, lines) + + +def test_list_vlans_help_menu(): + help_menu = ( + exec_test_command( + BASE_CMD + + [ + "ls", + "--h", + ] + ) + .stdout.decode() + .rstrip() + ) + + assert "linode-cli vlans ls\nList VLANs\n" in help_menu + assert ( + "https://techdocs.akamai.com/linode-api/reference/get-vlans" + in help_menu + ) + + +def test_delete_vlans_help_menu(): + help_menu = ( + exec_test_command( + BASE_CMD + + [ + "delete", + "--h", + ] + ) + .stdout.decode() + .rstrip() + ) + + assert "linode-cli vlans delete [LABEL] [REGIONID]" in help_menu + assert ( + "https://techdocs.akamai.com/linode-api/reference/delete-vlan" + in help_menu + ) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index fef49ab27..04fc5e79c 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1,5 +1,7 @@ import configparser -from typing import List +import contextlib +import os +from typing import ContextManager, List, TextIO import pytest from openapi3 import OpenAPI @@ -23,6 +25,25 @@ LOADED_FILES = {} +FIXTURES_PATH = "tests/fixtures" + + +@contextlib.contextmanager +def open_fixture(filename: str) -> ContextManager[TextIO]: + """ + Gets the reader for a given fixture. + + :returns: A context manager yielding the fixture's reader. + """ + + f = open(os.path.join(FIXTURES_PATH, filename), "r") + + try: + yield f + finally: + f.close() + + def _get_parsed_yaml(filename): """ Returns a python dict that is a parsed yaml file from the tests/fixtures @@ -33,8 +54,9 @@ def _get_parsed_yaml(filename): :type filename: str """ if filename not in LOADED_FILES: - with open("tests/fixtures/" + filename) as f: + with open_fixture(filename) as f: raw = f.read() + parsed = safe_load(raw) LOADED_FILES[filename] = parsed diff --git a/tests/unit/test_api_request.py b/tests/unit/test_api_request.py index 96fafe33a..13041f34b 100644 --- a/tests/unit/test_api_request.py +++ b/tests/unit/test_api_request.py @@ -44,13 +44,14 @@ def test_request_debug_info(self): api_request._print_request_debug_info( SimpleNamespace(__name__="get"), "https://definitely.linode.com/", - {"cool": "test"}, + {"cool": "test", "Authorization": "sensitiveinfo"}, "cool body", ) output = stderr_buf.getvalue() assert "> GET https://definitely.linode.com/" in output assert "> cool: test" in output + assert f"> Authorization: Bearer {'*' * 64}" in output assert "> Body:" in output assert "> cool body" in output assert "> " in output diff --git a/tests/unit/test_arg_helpers.py b/tests/unit/test_arg_helpers.py index e5ad7b3a8..3743061c2 100644 --- a/tests/unit/test_arg_helpers.py +++ b/tests/unit/test_arg_helpers.py @@ -1,5 +1,4 @@ #!/usr/local/bin/python3 -import pytest from linodecli import arg_helpers @@ -150,10 +149,3 @@ def test_remove_plugin_not_available(self, mocked_config): msg, code = arg_helpers.remove_plugin("testing.plugin", mocked_config) assert "not a registered plugin" in msg assert code == 14 - - def test_bake_command_bad_website(self, capsys, mock_cli): - with pytest.raises(SystemExit) as ex: - arg_helpers.bake_command(mock_cli, "https://website.com") - captured = capsys.readouterr() - assert ex.value.code == 2 - assert "Request failed to https://website.com" in captured.out diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index b7f41a79c..e9c2189dc 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -2,12 +2,16 @@ import copy import math +import os import re import pytest import requests +import requests_mock from pytest import MonkeyPatch +from tests.unit.conftest import FIXTURES_PATH, open_fixture + if True: from linodecli import CLI from linodecli.api_request import get_all_pages @@ -78,6 +82,46 @@ def test_user_agent(self, mock_cli: CLI): r"linode-cli/[0-9]+\.[0-9]+\.[0-9]+ linode-api-docs/[0-9]+\.[0-9]+\.[0-9]+ python/[0-9]+\.[0-9]+\.[0-9]+" ).match(mock_cli.user_agent) + def test_load_openapi_spec_json(self): + url_base = "https://localhost/" + path = "cli_test_load.json" + url = f"{url_base}{path}" + + with open_fixture(path) as f: + content = f.read() + + with requests_mock.Mocker() as m: + m.get(url, text=content) + + parsed_json_local = CLI._load_openapi_spec( + str(os.path.join(FIXTURES_PATH, path)) + ) + + parsed_json_http = CLI._load_openapi_spec(url) + + assert m.call_count == 1 + assert parsed_json_http.raw_element == parsed_json_local.raw_element + + def test_load_openapi_spec_yaml(self): + url_base = "https://localhost/" + path = "cli_test_load.yaml" + url = f"{url_base}{path}" + + with open_fixture(path) as f: + content = f.read() + + with requests_mock.Mocker() as m: + m.get(url, text=content) + + parsed_json_local = CLI._load_openapi_spec( + str(os.path.join(FIXTURES_PATH, path)) + ) + + parsed_json_http = CLI._load_openapi_spec(url) + + assert m.call_count == 1 + assert parsed_json_http.raw_element == parsed_json_local.raw_element + def test_get_all_pages( mock_cli: CLI, list_operation: OpenAPIOperation, monkeypatch: MonkeyPatch