├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug.yml │ └── enhancement.yml ├── dependabot.yml ├── labels.yml ├── pull_request_template.md ├── release.yml └── workflows │ ├── ci.yml │ ├── codeql.yml │ ├── dependency-review.yml │ ├── e2e-suite-windows.yml │ ├── e2e-suite.yml │ ├── labeler.yml │ ├── nightly-smoke-tests.yml │ ├── publish-wiki.yml │ ├── release.yml │ └── remote-release-trigger.yml ├── .gitignore ├── .gitmodules ├── .pylintrc ├── .python-version ├── CODEOWNERS ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── demo.gif ├── examples └── third-party-plugin │ ├── README.md │ ├── example_third_party_plugin.py │ └── setup.py ├── linodecli ├── __init__.py ├── __main__.py ├── api_request.py ├── arg_helpers.py ├── baked │ ├── __init__.py │ ├── operation.py │ ├── parsing.py │ ├── request.py │ ├── response.py │ └── util.py ├── cli.py ├── completion.py ├── configuration │ ├── __init__.py │ ├── auth.py │ ├── config.py │ └── helpers.py ├── exit_codes.py ├── help_pages.py ├── helpers.py ├── oauth-landing-page.html ├── output │ ├── __init__.py │ ├── helpers.py │ └── output_handler.py ├── overrides.py ├── plugins │ ├── README.md │ ├── __init__.py │ ├── echo.py.example │ ├── firewall-editor.py │ ├── get-kubeconfig.py │ ├── image-upload.py │ ├── metadata.py │ ├── obj │ │ ├── __init__.py │ │ ├── buckets.py │ │ ├── config.py │ │ ├── helpers.py │ │ ├── list.py │ │ ├── objects.py │ │ └── website.py │ ├── plugins.py │ ├── region-table.py │ ├── regionstats.py.example │ └── ssh.py └── version.py ├── pyproject.toml ├── resolve_spec_url ├── scripts ├── cibuild.sh ├── cipublish.sh ├── cleanup.sh ├── lke-policy.yaml └── lke_calico_rules_e2e.sh ├── setup.py ├── tests ├── __init__.py ├── fixtures │ ├── api_request_test_foobar_get.yaml │ ├── api_request_test_foobar_post.yaml │ ├── api_request_test_foobar_put.yaml │ ├── api_url_components_test.yaml │ ├── cli_test_bake_missing_cmd_ext.yaml │ ├── cli_test_load.json │ ├── cli_test_load.yaml │ ├── docs_url_test.yaml │ ├── operation_with_one_ofs.yaml │ ├── output_test_get.yaml │ ├── overrides_test_get.yaml │ ├── response_test_get.yaml │ └── subtable_test_get.yaml ├── integration │ ├── __init__.py │ ├── account │ │ └── test_account.py │ ├── beta │ │ └── test_beta_program.py │ ├── cli │ │ ├── test_help.py │ │ └── test_host_overrides.py │ ├── conftest.py │ ├── database │ │ ├── test_database.py │ │ └── test_database_engine_config.py │ ├── domains │ │ ├── test_domain_records.py │ │ ├── test_domains_tags.py │ │ ├── test_master_domains.py │ │ └── test_slave_domains.py │ ├── events │ │ └── test_events.py │ ├── firewalls │ │ ├── test_firewalls.py │ │ └── test_firewalls_rules.py │ ├── fixture_types.py │ ├── helpers.py │ ├── image │ │ └── test_plugin_image_upload.py │ ├── kernels │ │ └── test_kernels.py │ ├── linodes │ │ ├── helpers_linodes.py │ │ ├── test_backups.py │ │ ├── test_configs.py │ │ ├── test_disk.py │ │ ├── test_interfaces.py │ │ ├── test_linode_interfaces.py │ │ ├── test_linodes.py │ │ ├── test_power_status.py │ │ ├── test_rebuild.py │ │ ├── test_resize.py │ │ └── test_types.py │ ├── lke │ │ ├── test_clusters.py │ │ ├── test_lke_acl.py │ │ └── test_lke_enterprise.py │ ├── longview │ │ └── test_longview.py │ ├── managed │ │ └── test_managed.py │ ├── networking │ │ └── test_networking.py │ ├── nodebalancers │ │ └── test_node_balancers.py │ ├── obj │ │ ├── conftest.py │ │ ├── test_obj_plugin.py │ │ ├── test_obj_quota.py │ │ └── test_object_storage.py │ ├── placements │ │ └── test_placements.py │ ├── regions │ │ └── test_plugin_region_table.py │ ├── ssh │ │ ├── test_plugin_ssh.py │ │ └── test_ssh.py │ ├── stackscripts │ │ └── test_stackscripts.py │ ├── support │ │ └── test_support.py │ ├── tags │ │ └── test_tags.py │ ├── users │ │ ├── test_profile.py │ │ └── test_users.py │ ├── vlans │ │ └── test_vlans.py │ ├── volumes │ │ ├── test_volumes.py │ │ └── test_volumes_resize.py │ └── vpc │ │ ├── __init__.py │ │ ├── conftest.py │ │ └── test_vpc.py └── unit │ ├── __init__.py │ ├── conftest.py │ ├── test_api_request.py │ ├── test_arg_helpers.py │ ├── test_cli.py │ ├── test_completion.py │ ├── test_configuration.py │ ├── test_help_pages.py │ ├── test_helpers.py │ ├── test_operation.py │ ├── test_output.py │ ├── test_overrides.py │ ├── test_parsing.py │ ├── test_plugin_image_upload.py │ ├── test_plugin_kubeconfig.py │ ├── test_plugin_metadata.py │ ├── test_plugin_obj.py │ ├── test_plugin_ssh.py │ ├── test_request.py │ └── test_response.py └── wiki ├── Configuration.md ├── Home.md ├── Installation.md ├── Output.md ├── Plugins.md ├── Uninstallation.md ├── Usage.md ├── _Sidebar.md └── development ├── Development - Index.md ├── Development - Overview.md ├── Development - Setup.md ├── Development - Skeleton.md └── Development - Testing.md /.dockerignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | test/.env 4 | .tmp* 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | title: "[Bug]: " 4 | labels: ["bug"] 5 | body: 6 | - type: input 7 | id: cli-version 8 | attributes: 9 | label: CLI Version 10 | description: What version of linode-cli are you running? `linode-cli -v` 11 | placeholder: linode-cli 5.24.0 Built from spec version 4.138.0 12 | validations: 13 | required: true 14 | - type: textarea 15 | id: resources 16 | attributes: 17 | label: Command 18 | description: The command executed to encounter this bug. Please ensure that all sensitive data has been removed. 19 | placeholder: | 20 | linode-cli linodes create --type g6-standard-1 --region us-southeast 21 | - type: textarea 22 | id: output 23 | attributes: 24 | label: Output 25 | description: The output of the command affected by the bug. Please ensure that all sensitive data has been removed. 26 | - type: textarea 27 | id: expected 28 | attributes: 29 | label: Expected Behavior 30 | description: What should have happened? 31 | validations: 32 | required: true 33 | - type: textarea 34 | id: actual 35 | attributes: 36 | label: Actual Behavior 37 | description: What actually happened? 38 | validations: 39 | required: true 40 | - type: textarea 41 | id: reproduce 42 | attributes: 43 | label: Steps to Reproduce 44 | description: If you are able to reproduce this issue, please list the steps below. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.yml: -------------------------------------------------------------------------------- 1 | name: Enhancement 2 | description: Request a feature 3 | title: "[Feature]: " 4 | labels: ["enhancement"] 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Description 10 | description: What would you like this feature to do in detail? 11 | validations: 12 | required: true 13 | - type: textarea 14 | id: usage 15 | attributes: 16 | label: Potential Usage Example 17 | description: A small codeblock example of how this feature will be used. 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "monthly" 16 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | # PR Labels 2 | - name: new-feature 3 | description: for new features in the changelog. 4 | color: 225fee 5 | - name: project 6 | description: for new projects in the changelog. 7 | color: 46BAF0 8 | - name: improvement 9 | description: for improvements in existing functionality in the changelog. 10 | color: 22ee47 11 | - name: repo-ci-improvement 12 | description: for improvements in the repository or CI workflow in the changelog. 13 | color: c922ee 14 | - name: bugfix 15 | description: for any bug fixes in the changelog. 16 | color: ed8e21 17 | - name: documentation 18 | description: for updates to the documentation in the changelog. 19 | color: d3e1e6 20 | - name: dependencies 21 | description: dependency updates usually from dependabot 22 | color: 5c9dff 23 | - name: testing 24 | description: for updates to the testing suite in the changelog. 25 | color: 933ac9 26 | - name: breaking-change 27 | description: for breaking changes in the changelog. 28 | color: ff0000 29 | - name: ignore-for-release 30 | description: PRs you do not want to render in the changelog 31 | color: 7b8eac 32 | - name: do-not-merge 33 | description: PRs that should not be merged until the commented issue is resolved 34 | color: eb1515 35 | # Issue Labels 36 | - name: enhancement 37 | description: issues that request a enhancement 38 | color: 22ee47 39 | - name: bug 40 | description: issues that report a bug 41 | color: ed8e21 42 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## 📝 Description 2 | 3 | **What does this PR do and why is this change necessary?** 4 | 5 | ## ✔️ How to Test 6 | 7 | **What are the steps to reproduce the issue or verify the changes?** 8 | 9 | **How do I run the relevant unit/integration tests?** 10 | 11 | ## 📷 Preview 12 | 13 | **If applicable, include a screenshot or code snippet of this change. Otherwise, please remove this section.** -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - ignore-for-release 5 | categories: 6 | - title: 📋 New Project 7 | labels: 8 | - project 9 | - title: ⚠️ Breaking Change 10 | labels: 11 | - breaking-change 12 | - title: 🐛 Bug Fixes 13 | labels: 14 | - bugfix 15 | - title: 🚀 New Features 16 | labels: 17 | - new-feature 18 | - title: 💡 Improvements 19 | labels: 20 | - improvement 21 | - title: 🧪 Testing Improvements 22 | labels: 23 | - testing 24 | - title: ⚙️ Repo/CI Improvements 25 | labels: 26 | - repo-ci-improvement 27 | - title: 📖 Documentation 28 | labels: 29 | - documentation 30 | - title: 📦 Dependency Updates 31 | labels: 32 | - dependencies 33 | - title: Other Changes 34 | labels: 35 | - "*" 36 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | on: 3 | workflow_dispatch: null 4 | push: 5 | pull_request: 6 | jobs: 7 | docker-build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Build the Docker image 12 | run: docker build . --file Dockerfile --tag linode/cli:$(date +%s) --build-arg="github_token=$GITHUB_TOKEN" 13 | env: 14 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 15 | 16 | lint: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: checkout repo 20 | uses: actions/checkout@v4 21 | 22 | - name: setup python 3 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: '3.x' 26 | 27 | - name: install dependencies 28 | run: make install 29 | 30 | - name: run linter 31 | run: make lint 32 | 33 | unit-tests-on-ubuntu: 34 | runs-on: ubuntu-latest 35 | strategy: 36 | matrix: 37 | python-version: [ "3.9","3.10","3.11", "3.12", "3.13" ] 38 | steps: 39 | - name: Clone Repository 40 | uses: actions/checkout@v4 41 | 42 | - name: Setup Python 43 | uses: actions/setup-python@v5 44 | with: 45 | python-version: ${{ matrix.python-version }} 46 | 47 | - name: Install Python dependencies 48 | run: pip install -U certifi 49 | 50 | - name: Install Package 51 | run: make install 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | 55 | - name: Run the unit test suite 56 | run: make test 57 | 58 | unit-tests-on-windows: 59 | runs-on: windows-latest 60 | steps: 61 | - name: Clone Repository 62 | uses: actions/checkout@v4 63 | 64 | - name: Setup Python 65 | uses: actions/setup-python@v5 66 | with: 67 | python-version: "3.x" 68 | 69 | - name: Install Python dependencies 70 | run: pip install -U certifi 71 | 72 | - name: Install Package 73 | shell: pwsh 74 | run: | 75 | make install 76 | env: 77 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 78 | 79 | - name: Run the unit test suite 80 | run: make test -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL Advanced" 2 | 3 | on: 4 | push: 5 | branches: [ "dev", "main" ] 6 | pull_request: 7 | branches: [ "dev", "main" ] 8 | schedule: 9 | - cron: "0 13 * * 5" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze (${{ matrix.language }}) 14 | runs-on: ubuntu-latest 15 | permissions: 16 | security-events: write 17 | 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | include: 22 | - language: python 23 | build-mode: none 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@v4 27 | 28 | - name: Initialize CodeQL 29 | uses: github/codeql-action/init@v3 30 | with: 31 | languages: ${{ matrix.language }} 32 | build-mode: ${{ matrix.build-mode }} 33 | 34 | - name: Perform CodeQL Analysis 35 | uses: github/codeql-action/analyze@v3 36 | with: 37 | category: "/language:${{matrix.language}}" 38 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | name: 'Dependency review' 2 | on: 3 | pull_request: 4 | branches: [ "dev", "main", "proj/*" ] 5 | 6 | permissions: 7 | contents: read 8 | pull-requests: write 9 | 10 | jobs: 11 | dependency-review: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: 'Checkout repository' 15 | uses: actions/checkout@v4 16 | - name: 'Dependency Review' 17 | uses: actions/dependency-review-action@v4 18 | with: 19 | comment-summary-in-pr: on-failure 20 | -------------------------------------------------------------------------------- /.github/workflows/labeler.yml: -------------------------------------------------------------------------------- 1 | name: labeler 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | paths: 8 | - '.github/labels.yml' 9 | - '.github/workflows/labeler.yml' 10 | pull_request: 11 | paths: 12 | - '.github/labels.yml' 13 | - '.github/workflows/labeler.yml' 14 | 15 | jobs: 16 | labeler: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - 20 | name: Checkout 21 | uses: actions/checkout@v4 22 | - 23 | name: Run Labeler 24 | uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 25 | with: 26 | github-token: ${{ secrets.GITHUB_TOKEN }} 27 | yaml-file: .github/labels.yml 28 | dry-run: ${{ github.event_name == 'pull_request' }} 29 | exclude: | 30 | help* 31 | *issue 32 | -------------------------------------------------------------------------------- /.github/workflows/nightly-smoke-tests.yml: -------------------------------------------------------------------------------- 1 | name: Nightly Smoke Tests 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | workflow_dispatch: 7 | inputs: 8 | sha: 9 | description: 'Commit SHA to test' 10 | required: false 11 | default: '' 12 | type: string 13 | 14 | jobs: 15 | smoke_tests: 16 | if: github.repository == 'linode/linode-cli' || github.event_name == 'workflow_dispatch' 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Checkout code 21 | uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | submodules: 'recursive' 25 | ref: ${{ github.event.inputs.sha || github.ref }} 26 | 27 | - name: Set up Python 28 | uses: actions/setup-python@v5 29 | with: 30 | python-version: '3.x' 31 | 32 | - name: Install Python deps 33 | run: pip install .[obj,dev] 34 | 35 | - name: Install Linode CLI 36 | run: make install 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | 40 | - name: Run smoke tests 41 | id: smoke_tests 42 | run: | 43 | make test-smoke 44 | env: 45 | LINODE_CLI_TOKEN: ${{ secrets.LINODE_TOKEN }} 46 | 47 | - name: Notify Slack 48 | if: always() && github.repository == 'linode/linode-cli' # Run even if integration tests fail and only on main repository 49 | uses: slackapi/slack-github-action@v2.1.0 50 | with: 51 | method: chat.postMessage 52 | token: ${{ secrets.SLACK_BOT_TOKEN }} 53 | payload: | 54 | channel: ${{ secrets.SLACK_CHANNEL_ID }} 55 | blocks: 56 | - type: section 57 | text: 58 | type: mrkdwn 59 | text: ":rocket: *${{ github.workflow }} Completed in: ${{ github.repository }}* :white_check_mark:" 60 | - type: divider 61 | - type: section 62 | fields: 63 | - type: mrkdwn 64 | text: "*Build Result:*\n${{ steps.smoke_tests.outcome == 'success' && ':large_green_circle: Build Passed' || ':red_circle: Build Failed' }}" 65 | - type: mrkdwn 66 | text: "*Branch:*\n`${{ github.ref_name }}`" 67 | - type: section 68 | fields: 69 | - type: mrkdwn 70 | text: "*Commit Hash:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>" 71 | - type: mrkdwn 72 | text: "*Run URL:*\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run Details>" 73 | - type: divider 74 | - type: context 75 | elements: 76 | - type: mrkdwn 77 | text: "Triggered by: :bust_in_silhouette: `${{ github.actor }}`" -------------------------------------------------------------------------------- /.github/workflows/publish-wiki.yml: -------------------------------------------------------------------------------- 1 | name: Publish wiki 2 | on: 3 | push: 4 | branches: [main] 5 | paths: 6 | - wiki/** 7 | - .github/workflows/publish-wiki.yml 8 | concurrency: 9 | group: publish-wiki 10 | cancel-in-progress: true 11 | permissions: 12 | contents: write 13 | jobs: 14 | publish-wiki: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: Andrew-Chen-Wang/github-wiki-action@50650fccf3a10f741995523cf9708c53cec8912a # pin@v4.4.0 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | workflow_dispatch: null 4 | release: 5 | types: [ published ] 6 | jobs: 7 | notify: 8 | needs: pypi-release 9 | if: github.repository == 'linode/linode-cli' 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Notify Slack - Main Message 13 | id: main_message 14 | uses: slackapi/slack-github-action@v2.1.0 15 | with: 16 | method: chat.postMessage 17 | token: ${{ secrets.SLACK_BOT_TOKEN }} 18 | payload: | 19 | channel: ${{ secrets.CLI_SLACK_CHANNEL_ID }} 20 | blocks: 21 | - type: section 22 | text: 23 | type: mrkdwn 24 | text: "*New Release Published: _linode-cli_ <${{ github.event.release.html_url }}|${{ github.event.release.tag_name }}> is now live!* :tada:" 25 | 26 | oci_publish: 27 | name: Build and publish the OCI image 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Clone Repository 31 | uses: actions/checkout@v4 32 | 33 | - name: setup python 3 34 | uses: actions/setup-python@v5 35 | with: 36 | python-version: '3.x' 37 | 38 | - name: Install deps 39 | run: make requirements 40 | 41 | - name: Set up QEMU 42 | uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # pin@v3.6.0 43 | 44 | - name: Set up Docker Buildx 45 | uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # pin@v3.10.0 46 | 47 | - name: Login to Docker Hub 48 | uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # pin@v3.4.0 49 | with: 50 | username: ${{ secrets.DOCKERHUB_USERNAME }} 51 | password: ${{ secrets.DOCKERHUB_TOKEN }} 52 | 53 | # This is necessary as we want to ensure that version tags 54 | # are properly formatted before passing them into the 55 | # DockerFile. 56 | - uses: actions/github-script@v7 57 | id: cli_version 58 | with: 59 | script: | 60 | let tag_name = '${{ github.event.release.tag_name }}'; 61 | 62 | if (tag_name.startsWith("v")) { 63 | tag_name = tag_name.slice(1); 64 | } 65 | 66 | return tag_name; 67 | result-encoding: string 68 | 69 | - name: Build and push to DockerHub 70 | uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # pin@v6.18.0 71 | with: 72 | context: . 73 | file: Dockerfile 74 | platforms: linux/amd64,linux/arm64 75 | push: true 76 | tags: linode/cli:${{ steps.cli_version.outputs.result }},linode/cli:latest 77 | build-args: | 78 | linode_cli_version=${{ steps.cli_version.outputs.result }} 79 | github_token=${{ secrets.GITHUB_TOKEN }} 80 | 81 | pypi-release: 82 | permissions: 83 | # IMPORTANT: this permission is mandatory for trusted publishing 84 | id-token: write 85 | runs-on: ubuntu-latest 86 | environment: pypi-release 87 | steps: 88 | - name: Checkout 89 | uses: actions/checkout@v4 90 | 91 | - name: Setup Python 92 | uses: actions/setup-python@v5 93 | with: 94 | python-version: '3.x' 95 | 96 | - name: Install Python deps 97 | run: pip install wheel 98 | 99 | - name: Install package requirements 100 | run: make requirements 101 | 102 | - name: Build the package 103 | run: make build 104 | env: 105 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 106 | LINODE_CLI_VERSION: ${{ github.event.release.tag_name }} 107 | 108 | - name: Publish the release artifacts to PyPI 109 | uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # pin@release/v1.12.4 110 | -------------------------------------------------------------------------------- /.github/workflows/remote-release-trigger.yml: -------------------------------------------------------------------------------- 1 | name: Remote Release Trigger 2 | on: 3 | repository_dispatch: 4 | types: [ cli-release ] 5 | jobs: 6 | remote-release-trigger: 7 | runs-on: ubuntu-22.04 8 | environment: CLI Automated Release 9 | steps: 10 | - name: Generate App Installation Token 11 | id: generate_token 12 | uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # pin@v1 13 | with: 14 | app_id: ${{ secrets.CLI_RELEASE_APP_ID }} 15 | private_key: ${{ secrets.CLI_RELEASE_PRIVATE_KEY }} 16 | 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | with: 20 | # We want to checkout the main branch 21 | ref: 'main' 22 | fetch-depth: 0 23 | 24 | - name: Get previous tag 25 | id: previoustag 26 | uses: WyriHaximus/github-action-get-previous-tag@04e8485ecb6487243907e330d522ff60f02283ce # pin@v1 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | 30 | - name: Calculate the desired release version 31 | id: calculate_version 32 | uses: actions/github-script@v7 33 | env: 34 | SPEC_VERSION: ${{ github.event.client_payload.spec_version }} 35 | PREVIOUS_CLI_VERSION: ${{ steps.previoustag.outputs.tag }} 36 | with: 37 | result-encoding: string 38 | version: ${{ steps.previoustag.outputs.tag }} 39 | script: | 40 | let spec_version_segments = process.env.SPEC_VERSION.replace("v", "").split("."); 41 | let cli_version_segments = process.env.PREVIOUS_CLI_VERSION.replace("v", "").split("."); 42 | 43 | // Default to a patch version bump 44 | let bump_idx = 2; 45 | 46 | // This is a minor version bump 47 | if (spec_version_segments[2] == "0") { 48 | bump_idx = 1; 49 | 50 | // The patch number should revert to 0 51 | cli_version_segments[2] = "0" 52 | } 53 | 54 | // Bump the version 55 | cli_version_segments[bump_idx] = (parseInt(cli_version_segments[bump_idx]) + 1).toString() 56 | 57 | return "v" + cli_version_segments.join(".") 58 | 59 | - name: Calculate the SHA of HEAD on the main branch 60 | id: calculate_head_sha 61 | run: echo "commit_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" 62 | 63 | - uses: rickstaa/action-create-tag@a1c7777fcb2fee4f19b0f283ba888afa11678b72 # pin@v1 64 | with: 65 | tag: ${{ steps.calculate_version.outputs.result }} 66 | commit_sha: ${{ steps.calculate_head_sha.outputs.commit_sha }} 67 | 68 | - name: Release 69 | uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # pin@v2.2.2 70 | with: 71 | target_commitish: 'main' 72 | token: ${{ steps.generate_token.outputs.token }} 73 | body: Built from Linode OpenAPI spec ${{ github.event.client_payload.spec_version }} 74 | tag_name: ${{ steps.calculate_version.outputs.result }} 75 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | __pycache__ 3 | build 4 | dist 5 | *.egg-info 6 | linode-cli.sh 7 | baked_version 8 | data-2 9 | data-3 10 | .DS_STORE 11 | Pipfile* 12 | test/.env 13 | .tmp* 14 | MANIFEST 15 | venv 16 | openapi*.yaml 17 | openapi*.json 18 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "test/test_helper/bats-assert"] 2 | path = test/test_helper/bats-assert 3 | url = https://github.com/ztombol/bats-assert 4 | [submodule "test/test_helper/bats-support"] 5 | path = test/test_helper/bats-support 6 | url = https://github.com/ztombol/bats-support 7 | [submodule "e2e_scripts"] 8 | path = e2e_scripts 9 | url = https://github.com/linode/dx-e2e-test-scripts 10 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | linode-cli 2 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @linode/dx 2 | 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | :+1::tada: First off, we appreciate you taking the time to contribute! THANK YOU! :tada::+1: 4 | 5 | We put together the handy guide below to help you get support for your work. Read on! 6 | 7 | ## I Just Want to Ask the Maintainers a Question 8 | 9 | The [Linode Community](https://www.linode.com/community/questions/) is a great place to get additional support. 10 | 11 | ## How Do I Submit A (Good) Bug Report or Feature Request 12 | 13 | Please open a [GitHub issue](../../issues/new/choose) to report bugs or suggest features. 14 | 15 | Please accurately fill out the appropriate GitHub issue form. 16 | 17 | When filing an issue or feature request, help us avoid duplication and redundant effort -- check existing open or recently closed issues first. 18 | 19 | Detailed bug reports and requests are easier for us to work with. Please include the following in your issue: 20 | 21 | * A reproducible test case or series of steps 22 | * The version of our code being used 23 | * Any modifications you've made, relevant to the bug 24 | * Anything unusual about your environment or deployment 25 | * Screenshots and code samples where illustrative and helpful 26 | 27 | ## How to Open a Pull Request 28 | 29 | We follow the [fork and pull model](https://opensource.guide/how-to-contribute/#opening-a-pull-request) for open source contributions. 30 | 31 | Tips for a faster merge: 32 | * address one feature or bug per pull request. 33 | * large formatting changes make it hard for us to focus on your work. 34 | * follow language coding conventions. 35 | * make sure that tests pass. 36 | * make sure your commits are atomic, [addressing one change per commit](https://chris.beams.io/posts/git-commit/). 37 | * add tests! 38 | 39 | ## Code of Conduct 40 | 41 | This project follows the [Linode Community Code of Conduct](https://www.linode.com/community/questions/conduct). 42 | 43 | ## Vulnerability Reporting 44 | 45 | If you discover a potential security issue in this project we ask that you notify Linode Security via our [vulnerability reporting process](https://hackerone.com/linode). Please do **not** create a public github issue. 46 | 47 | ## Licensing 48 | 49 | See the [LICENSE file](/LICENSE) for our project's licensing. 50 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13-slim AS builder 2 | 3 | ARG linode_cli_version 4 | 5 | ARG github_token 6 | 7 | WORKDIR /src 8 | 9 | RUN apt-get update && \ 10 | apt-get install -y make git 11 | 12 | COPY . . 13 | 14 | RUN make requirements 15 | 16 | RUN LINODE_CLI_VERSION=$linode_cli_version GITHUB_TOKEN=$github_token make build 17 | 18 | FROM python:3.13-slim 19 | 20 | COPY --from=builder /src/dist /dist 21 | 22 | RUN pip3 install --no-cache-dir /dist/*.whl boto3 23 | 24 | RUN useradd -ms /bin/bash cli 25 | USER cli:cli 26 | 27 | ENTRYPOINT ["linode-cli"] 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Linode, LLC 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of the copyright holder nor the names of its contributors 15 | may be used to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include linodecli/data-3 2 | include linodecli/oauth-landing-page.html 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # 2 | # Makefile for more convenient building of the Linode CLI and its baked content 3 | # 4 | 5 | SPEC_VERSION ?= latest 6 | ifndef SPEC 7 | override SPEC = $(shell ./resolve_spec_url ${SPEC_VERSION}) 8 | endif 9 | 10 | # Version-related variables 11 | VERSION_FILE := ./linodecli/version.py 12 | VERSION_MODULE_DOCSTRING ?= \"\"\"\nThe version of the Linode CLI.\n\"\"\"\n\n 13 | LINODE_CLI_VERSION ?= "0.0.0.dev" 14 | 15 | BAKE_FLAGS := --debug 16 | 17 | .PHONY: install 18 | install: check-prerequisites requirements build 19 | pip3 install --force dist/*.whl 20 | 21 | .PHONY: bake 22 | bake: clean 23 | ifeq ($(SKIP_BAKE), 1) 24 | @echo Skipping bake stage 25 | else 26 | python3 -m linodecli bake ${SPEC} --skip-config $(BAKE_FLAGS) 27 | cp data-3 linodecli/ 28 | endif 29 | 30 | .PHONY: create-version 31 | create-version: 32 | @printf "${VERSION_MODULE_DOCSTRING}__version__ = \"${LINODE_CLI_VERSION}\"\n" > $(VERSION_FILE) 33 | 34 | .PHONY: build 35 | build: clean create-version bake 36 | python3 -m build --wheel --sdist 37 | 38 | .PHONY: requirements 39 | requirements: 40 | pip3 install --upgrade ".[dev,obj]" 41 | 42 | .PHONY: lint 43 | lint: build 44 | pylint linodecli 45 | isort --check-only linodecli tests 46 | autoflake --check linodecli tests 47 | black --check --verbose linodecli tests 48 | twine check dist/* 49 | 50 | .PHONY: check-prerequisites 51 | check-prerequisites: 52 | @ pip3 -v >/dev/null 53 | @ python3 -V >/dev/null 54 | 55 | .PHONY: clean 56 | clean: 57 | rm -f linodecli/data-* 58 | rm -f linode-cli.sh baked_version 59 | rm -f data-* 60 | rm -rf dist linode_cli.egg-info build 61 | 62 | .PHONY: test-unit 63 | test-unit: 64 | @mkdir -p /tmp/linode/.config 65 | @orig_xdg_config_home=$${XDG_CONFIG_HOME:-}; \ 66 | export LINODE_CLI_TEST_MODE=1 XDG_CONFIG_HOME=/tmp/linode/.config; \ 67 | pytest -v tests/unit; \ 68 | exit_code=$$?; \ 69 | export XDG_CONFIG_HOME=$$orig_xdg_config_home; \ 70 | exit $$exit_code 71 | 72 | # Integration Test Arguments 73 | # TEST_SUITE: Optional, specify a test suite (e.g. domains), Default to run everything if not set 74 | # TEST_CASE: Optional, specify a test case (e.g. 'test_create_a_domain') 75 | # TEST_ARGS: Optional, additional arguments for pytest (e.g. '-v' for verbose mode) 76 | 77 | .PHONY: test-int 78 | test-int: 79 | pytest tests/integration/$(TEST_SUITE) $(if $(TEST_CASE),-k $(TEST_CASE)) $(TEST_ARGS) 80 | 81 | .PHONY: testall 82 | testall: 83 | pytest tests 84 | 85 | # Alias for unit; integration tests should be explicit 86 | .PHONY: test 87 | test: test-unit 88 | 89 | .PHONY: black 90 | black: 91 | black linodecli tests 92 | 93 | .PHONY: isort 94 | isort: 95 | isort linodecli tests 96 | 97 | .PHONY: autoflake 98 | autoflake: 99 | autoflake linodecli tests 100 | 101 | .PHONY: format 102 | format: black isort autoflake 103 | 104 | @PHONEY: test-smoke 105 | test-smoke: 106 | pytest -m smoke tests/integration 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # linode-cli (lin) 2 | 3 | The Linode Command Line Interface 4 | 5 | Provides easy access to any of the Linode API endpoints from the command line and displays results in an organized, configurable table. 6 | 7 | This project is automatically generated from the [Linode OpenAPI spec](https://www.linode.com/docs/api/) using the [openapi3 Python package](https://github.com/Dorthu/openapi3). 8 | 9 | ![Example of CLI in use](https://raw.githubusercontent.com/linode/linode-cli/main/demo.gif) 10 | 11 | Visit the [Wiki](../../wiki) for more information. 12 | 13 | ## Install 14 | 15 | Install via PyPI: 16 | ```bash 17 | pip3 install linode-cli 18 | ``` 19 | 20 | Visit the [Wiki](../../wiki/Installation) for more information. 21 | 22 | ## Contributing 23 | 24 | This CLI is generated from the [OpenAPI specification for Linode's API](https://github.com/linode/linode-api-docs). As 25 | such, many changes are made directly to the spec. 26 | 27 | Please follow the [Contributing Guidelines](https://github.com/linode/linode-cli/blob/main/CONTRIBUTING.md) when making a contribution. 28 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linode/linode-cli/d2bfe4a0ec67467fdb8edca1b8ba342d95f855cc/demo.gif -------------------------------------------------------------------------------- /examples/third-party-plugin/README.md: -------------------------------------------------------------------------------- 1 | # Example Third Party Plugin 2 | 3 | This is included as an example of how to develop a third party plugin for the 4 | Linode CLI. There are only two files: 5 | 6 | #### example_third_party_plugin.py 7 | 8 | This file contains the python source code for your plugin. Notably, it is a valid 9 | plugin because it exposes two attributes at the module level: 10 | 11 | * `PLUGIN_NAME` - a constant whose value is the string used to invoke the plugin 12 | once it's registered 13 | * `call(args, context)` - a function called when the plugin is invoked 14 | 15 | While this example is a single file, a module that exposes those two attributes 16 | at the top level is also a valid CLI plugin (define or import them in the module's 17 | `__init__.py` file to expose them at the module level). 18 | 19 | 20 | #### setup.py 21 | 22 | This file is used by setuptools to create a python module. This example is very 23 | sparse, but is enough to install the module locally and get you started. Please 24 | see the [setuptools docs](https://setuptools.readthedocs.io/en/latest/index.html) 25 | for all available options. 26 | 27 | ## Installation 28 | 29 | To install this example plugin, run the following in this directory: 30 | 31 | ```bash 32 | python setup.py install 33 | ``` 34 | 35 | ### Registration and Invocation 36 | 37 | Once installed, you have to register the plugin with the Linode CLI by python 38 | module name (as defined in `setup.py`): 39 | 40 | ```bash 41 | linode-cli register-plugin example_third_party_plugin 42 | ``` 43 | 44 | The CLI will print out the command to invoke this plugin, which in this example 45 | is: 46 | 47 | 48 | ```bash 49 | linode-cli example-plugin 50 | ``` 51 | 52 | Doing so will print `Hello world!` and exit. 53 | 54 | ## Development 55 | 56 | To begin working from this base, simply edit `example_third_party_plugin.py` and 57 | add whatever features you need. When it comes time to distribute your plugin, 58 | copy this entire directory elsewhere and modify the `setup.py` file as described 59 | within it to create your own module. 60 | 61 | To test your changes, simply reinstall the plugin as described above. This 62 | _does not_ require reregistering it, as it references the installed module and 63 | will invoke the updated code next time it's called. 64 | -------------------------------------------------------------------------------- /examples/third-party-plugin/example_third_party_plugin.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is an example third-party plugin. See `the plugin docs`_ for more 3 | information. 4 | 5 | .. _the plugin docs: https://github.com/linode/linode-cli/blob/main/linodecli/plugins/README.md 6 | """ 7 | 8 | #: This is the name the plugin will be invoked with once it's registered. Note 9 | #: that this name is different than the module name, which is what's used to 10 | #: register it. This is required for all third party plugins. 11 | PLUGIN_NAME = "example-plugin" 12 | 13 | 14 | def call(args, context): 15 | """ 16 | This is the entrypoint for the plugin when invoked through the CLI. See the 17 | docs linked above for more information. 18 | """ 19 | print("Hello world!") 20 | -------------------------------------------------------------------------------- /examples/third-party-plugin/setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file allows installation of this plugin as a python module. See the docs 3 | for setuptools for more information. 4 | """ 5 | from setuptools import setup 6 | 7 | setup( 8 | # replace this with the module name you want your plugin to install as 9 | name="example_third_party_plugin", 10 | # replace with your plugin's version - use semantic versioning if possible 11 | version=1, 12 | # this is used in pip to show details about your plugin 13 | description="Example third party plugin for the Linode CLI", 14 | # replace these fields with information about yourself or your organization 15 | author="linode", 16 | author_email="developers@linode.com", 17 | # in this case, the plugin is a single file, so that file is listed here 18 | # replace with the name of your plugin file, or use ``packages=[]`` to list 19 | # whole python modules to include 20 | py_modules=[ 21 | "example_third_party_plugin", 22 | ], 23 | ) 24 | -------------------------------------------------------------------------------- /linodecli/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Launches the CLI 3 | """ 4 | 5 | from linodecli import main 6 | 7 | main() 8 | -------------------------------------------------------------------------------- /linodecli/baked/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Collection of classes for handling the parsed OpenAPI Spec for the CLI 3 | """ 4 | 5 | from .operation import OpenAPIOperation 6 | -------------------------------------------------------------------------------- /linodecli/baked/util.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides various utility functions for use in baking logic. 3 | """ 4 | 5 | import re 6 | from collections import defaultdict 7 | from typing import Any, Dict, List, Set, Tuple 8 | 9 | from openapi3.schemas import Schema 10 | 11 | 12 | def _aggregate_schema_properties( 13 | schema: Schema, 14 | ) -> Tuple[Dict[str, Any], Set[str]]: 15 | """ 16 | Aggregates all properties in the given schema, accounting properties 17 | nested in oneOf and anyOf blocks. 18 | 19 | :param schema: The schema to aggregate properties from. 20 | :return: The aggregated properties and a set containing the keys of required properties. 21 | """ 22 | 23 | schema_count = 0 24 | properties = {} 25 | required = defaultdict(lambda: 0) 26 | 27 | def _handle_schema(_schema: Schema): 28 | if _schema.properties is None: 29 | return 30 | 31 | nonlocal schema_count 32 | schema_count += 1 33 | 34 | properties.update(dict(_schema.properties)) 35 | 36 | # Aggregate required keys and their number of usages. 37 | if _schema.required is not None: 38 | for key in _schema.required: 39 | required[key] += 1 40 | 41 | _handle_schema(schema) 42 | 43 | one_of = schema.oneOf or [] 44 | any_of = schema.anyOf or [] 45 | 46 | for entry in one_of + any_of: 47 | # pylint: disable=protected-access 48 | _handle_schema(Schema(schema.path, entry, schema._root)) 49 | 50 | return ( 51 | properties, 52 | # We only want to mark fields that are required by ALL subschema as required 53 | set(key for key, count in required.items() if count == schema_count), 54 | ) 55 | 56 | 57 | ESCAPED_PATH_DELIMITER_PATTERN = re.compile(r"(? str: 61 | """ 62 | Escapes periods in a segment by prefixing them with a backslash. 63 | 64 | :param segment: The input string segment to escape. 65 | :return: The escaped segment with periods replaced by '\\.'. 66 | """ 67 | return segment.replace(".", "\\.") 68 | 69 | 70 | def unescape_arg_segment(segment: str) -> str: 71 | """ 72 | Reverses the escaping of periods in a segment, turning '\\.' back into '.'. 73 | 74 | :param segment: The input string segment to unescape. 75 | :return: The unescaped segment with '\\.' replaced by '.'. 76 | """ 77 | return segment.replace("\\.", ".") 78 | 79 | 80 | def get_path_segments(path: str) -> List[str]: 81 | """ 82 | Splits a path string into segments using a delimiter pattern, 83 | and unescapes any escaped delimiters in the resulting segments. 84 | 85 | :param path: The full path string to split and unescape. 86 | :return: A list of unescaped path segments. 87 | """ 88 | return [ 89 | unescape_arg_segment(seg) 90 | for seg in ESCAPED_PATH_DELIMITER_PATTERN.split(path) 91 | ] 92 | 93 | 94 | def get_terminal_keys(data: Dict[str, Any]) -> List[str]: 95 | """ 96 | Recursively retrieves all terminal (non-dict) keys from a nested dictionary. 97 | 98 | :param data: The input dictionary, possibly nested. 99 | :return: A list of all terminal keys (keys whose values are not dictionaries). 100 | """ 101 | ret = [] 102 | 103 | for k, v in data.items(): 104 | if isinstance(v, dict): 105 | ret.extend(get_terminal_keys(v)) # recurse into nested dicts 106 | else: 107 | ret.append(k) # terminal key 108 | 109 | return ret 110 | -------------------------------------------------------------------------------- /linodecli/completion.py: -------------------------------------------------------------------------------- 1 | #!/usr/local/bin/python3 2 | """ 3 | Contains any code relevant to generating/updating shell completions for linode-cli 4 | """ 5 | from string import Template 6 | 7 | from openapi3 import OpenAPI 8 | 9 | 10 | def get_completions(ops, help_flag, action): 11 | """ 12 | Handle shell completions based on `linode-cli completion ____` 13 | """ 14 | if help_flag or not action: 15 | return ( 16 | "linode-cli completion [SHELL]\n\n" 17 | "Prints shell completions for the requested shell to stdout.\n" 18 | "Currently, only completions for bash and fish are available." 19 | ) 20 | if action == "bash": 21 | return get_bash_completions(ops) 22 | if action == "fish": 23 | return get_fish_completions(ops) 24 | return ( 25 | "Completions are only available for bash and fish at this time.\n\n" 26 | "To retrieve these, please invoke as\n" 27 | "`linode-cli completion bash` or `linode-cli completion fish`" 28 | ) 29 | 30 | 31 | def get_fish_completions(ops): 32 | """ 33 | Generates and returns fish shell completions based on the baked spec 34 | """ 35 | completion_template = Template( 36 | """# This is a generated file by Linode-CLI! Do not modify! 37 | complete -c linode-cli -n "not __fish_seen_subcommand_from $subcommands" -x -a '$subcommands --help' 38 | complete -c linode -n "not __fish_seen_subcommand_from $subcommands" -x -a '$subcommands --help' 39 | complete -c lin -n "not __fish_seen_subcommand_from $subcommands" -x -a '$subcommands --help' 40 | $command_items""" 41 | ) 42 | 43 | command_template = Template( 44 | """complete -c linode-cli -n "__fish_seen_subcommand_from $command" \ 45 | -x -a '$actions --help' 46 | complete -c linode -n "__fish_seen_subcommand_from $command" \ 47 | -x -a '$actions --help' 48 | complete -c lin -n "__fish_seen_subcommand_from $command" \ 49 | -x -a '$actions --help'""" 50 | ) 51 | 52 | command_blocks = [ 53 | command_template.safe_substitute( 54 | command=op, actions=" ".join(list(actions.keys())) 55 | ) 56 | for op, actions in ops.items() 57 | ] 58 | 59 | rendered = completion_template.safe_substitute( 60 | subcommands=" ".join(ops.keys()), 61 | command_items="\n".join(command_blocks), 62 | ) 63 | 64 | return rendered 65 | 66 | 67 | def get_bash_completions(ops): 68 | """ 69 | Generates and returns bash shell completions based on the baked spec 70 | """ 71 | completion_template = Template( 72 | """# This is a generated file by Linode-CLI! Do not modify! 73 | _linode_cli() 74 | { 75 | local cur prev opts 76 | COMPREPLY=() 77 | cur="${COMP_WORDS[COMP_CWORD]}" 78 | prev="${COMP_WORDS[COMP_CWORD-1]}" 79 | 80 | case "${prev}" in 81 | linode-cli | linode | lin) 82 | COMPREPLY=( $(compgen -W "$actions --help" -- ${cur}) ) 83 | return 0 84 | ;; 85 | $command_items 86 | *) 87 | ;; 88 | esac 89 | } 90 | 91 | complete -F _linode_cli linode-cli 92 | complete -F _linode_cli linode 93 | complete -F _linode_cli lin""" 94 | ) 95 | 96 | command_template = Template( 97 | """$command) 98 | COMPREPLY=( $(compgen -W "$actions --help" -- ${cur}) ) 99 | return 0 100 | ;;""" 101 | ) 102 | 103 | command_blocks = [ 104 | command_template.safe_substitute( 105 | command=op, actions=" ".join(list(actions.keys())) 106 | ) 107 | for op, actions in ops.items() 108 | if not isinstance(actions, OpenAPI) 109 | ] 110 | 111 | rendered = completion_template.safe_substitute( 112 | actions=" ".join(ops.keys()), 113 | command_items="\n ".join(command_blocks), 114 | ) 115 | 116 | return rendered 117 | -------------------------------------------------------------------------------- /linodecli/configuration/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configuration helper package for the Linode CLI. 3 | """ 4 | 5 | # Private methods need to be imported explicitly 6 | from .auth import * 7 | from .auth import ( 8 | _check_full_access, 9 | _do_get_request, 10 | _get_token_terminal, 11 | _get_token_web, 12 | ) 13 | from .config import * 14 | from .helpers import * 15 | from .helpers import ( 16 | _bool_input, 17 | _check_browsers, 18 | _config_get_with_default, 19 | _default_text_input, 20 | _default_thing_input, 21 | _get_config, 22 | _get_config_path, 23 | ) 24 | -------------------------------------------------------------------------------- /linodecli/exit_codes.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is an enumeration of the various exit codes in Linode CLI 3 | 4 | """ 5 | 6 | from enum import IntEnum 7 | 8 | 9 | class ExitCodes(IntEnum): 10 | """ 11 | An enumeration of the various exit codes in Linode CLI 12 | """ 13 | 14 | SUCCESS = 0 15 | UNRECOGNIZED_COMMAND = 1 16 | REQUEST_FAILED = 2 17 | OAUTH_ERROR = 3 18 | USERNAME_ERROR = 4 19 | FIREWALL_ERROR = 5 20 | KUBECONFIG_ERROR = 6 21 | ARGUMENT_ERROR = 7 22 | FILE_ERROR = 8 23 | UNRECOGNIZED_ACTION = 9 24 | -------------------------------------------------------------------------------- /linodecli/helpers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Various helper functions shared across multiple CLI components. 3 | """ 4 | 5 | import glob 6 | import os 7 | from argparse import ArgumentParser 8 | from pathlib import Path 9 | from typing import Optional 10 | from urllib.parse import urlparse 11 | 12 | API_HOST_OVERRIDE = os.getenv("LINODE_CLI_API_HOST") 13 | API_VERSION_OVERRIDE = os.getenv("LINODE_CLI_API_VERSION") 14 | API_SCHEME_OVERRIDE = os.getenv("LINODE_CLI_API_SCHEME") 15 | 16 | # A user-specified path to the CA file for use in API requests. 17 | # This field defaults to True to enable default verification if 18 | # no path is specified. 19 | API_CA_PATH = os.getenv("LINODE_CLI_CA", None) or True 20 | 21 | 22 | def handle_url_overrides( 23 | url: str, 24 | host: Optional[str] = None, 25 | version: Optional[str] = None, 26 | scheme: Optional[str] = None, 27 | override_path: bool = False, 28 | ): 29 | """ 30 | Returns the URL with the API URL environment overrides applied. 31 | If override_path is True and the API version env var is specified, 32 | the URL path will be updated accordingly. 33 | """ 34 | 35 | parsed_url = urlparse(url) 36 | 37 | overrides = { 38 | "netloc": API_HOST_OVERRIDE or host, 39 | "path": (API_VERSION_OVERRIDE or version) if override_path else None, 40 | "scheme": API_SCHEME_OVERRIDE or scheme, 41 | } 42 | 43 | # Apply overrides 44 | return parsed_url._replace( 45 | **{k: v for k, v in overrides.items() if v is not None} 46 | ).geturl() 47 | 48 | 49 | def register_pagination_args_shared(parser: ArgumentParser): 50 | """ 51 | Add pagination related arguments to the given 52 | ArgumentParser that may be shared across the CLI and plugins. 53 | """ 54 | parser.add_argument( 55 | "--page", 56 | metavar="PAGE", 57 | default=1, 58 | type=int, 59 | help="For listing actions, specifies the page to request", 60 | ) 61 | parser.add_argument( 62 | "--page-size", 63 | metavar="PAGESIZE", 64 | default=100, 65 | type=int, 66 | help="For listing actions, specifies the number of items per page, " 67 | "accepts any value between 25 and 500", 68 | ) 69 | parser.add_argument( 70 | "--all-rows", 71 | action="store_true", 72 | help="Output all possible rows in the results with pagination", 73 | ) 74 | 75 | 76 | def register_args_shared(parser: ArgumentParser): 77 | """ 78 | Adds certain arguments to the given ArgumentParser that may be shared across 79 | the CLI and plugins. 80 | This function is wrapped in linodecli.plugins. 81 | 82 | NOTE: This file is not located in arg_helpers.py to prevent a cyclic dependency. 83 | """ 84 | 85 | parser.add_argument( 86 | "--as-user", 87 | metavar="USERNAME", 88 | type=str, 89 | help="The username to execute this command as. This user must " 90 | "be configured.", 91 | ) 92 | 93 | parser.add_argument( 94 | "--suppress-warnings", 95 | action="store_true", 96 | help="Suppress warnings that are intended for human users. " 97 | "This is useful for scripting the CLI's behavior.", 98 | ) 99 | 100 | return parser 101 | 102 | 103 | def register_debug_arg(parser: ArgumentParser): 104 | """ 105 | Add the debug argument to the given 106 | ArgumentParser that may be shared across the CLI and plugins. 107 | """ 108 | parser.add_argument( 109 | "--debug", 110 | action="store_true", 111 | help="Enable verbose debug logging, including displaying HTTP debug output and " 112 | "configuring the Python logging package level to DEBUG.", 113 | ) 114 | 115 | 116 | def expand_globs(pattern: str): 117 | """ 118 | Expand glob pattern (for example, '/some/path/*.txt') 119 | to be a list of path object. 120 | """ 121 | results = glob.glob(pattern, recursive=True) 122 | if len(results) < 1: 123 | print(f"No file found matching pattern {pattern}") 124 | 125 | return [Path(x).resolve() for x in results] 126 | -------------------------------------------------------------------------------- /linodecli/oauth-landing-page.html: -------------------------------------------------------------------------------- 1 |
2 |
                                                         
 3 |  $M                         MVM                             
 4 |  $VV$M                 $V**:..V                             
 5 |   VVVVV$M         $V**:.......V                             
 6 |   $VVVVVVV$$ $I*:.............F                             
 7 |    VVVVVVVVV$*................F                             
 8 |    VVVVVVVVVVI................*                             
 9 |    $VVVVVVVVV$................*                             
10 |     VVVVVVVVV$:...............:                             
11 |     $VVVVVVVV$F...............:                             
12 |      $VVVVVVVV$..............:*                             
13 |        M$VVVVVM:.........:*IM                               
14 |          M$VVV$*......*F$                                   
15 |             M$VV..:F$                                       
16 |       M$       M$            V*$         M$$             MI 
17 |        VV$M              $I*...V         MVVVV$M      MV*.: 
18 |        $VVVVM        MV*:......F          $VVVVVV$M $*:...F 
19 |         VVVVVV$   MI*..........*            M$VVVVVV......$ 
20 |         VVVVVVVVM:.............*              M$VVVF.....:  
21 |         $VVVVVVV$*.............*           MI*:$VV$*...:FM  
22 |          VVVVVVVVV.............*MM      MV*...*$VV$:.*V     
23 |           MVVVVVVM..........:*V$VVV$  $*:.....* M$MIM       
24 |             M$VVV$*......:*V M$VVVVVVV........F             
25 |               M$VVI...:*V       $VVVVV.......:$             
26 |           M$    M$M:*V       MI*$VVVVV.....*$               
27 |            VV$             V*...$$VVVV..:FM                 
28 |            $VVV$        $*:.....V  $$V*V                    
29 |             VVVVV$   $F:........I                           
30 |             $VVVVVVM*...........F                           
31 |              $VVVVV$*.........:FM                           
32 |                $VVVVV.......*V                              
33 |                 M$VVM....:FM                                
34 |                   M$$:.*$                                   
35 |                      $M                                     
36 | 
37 |

Success!

38 |

You may return to your terminal to continue.

39 |
40 | 47 | -------------------------------------------------------------------------------- /linodecli/output/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Output formatting module for CLI and plugins. 3 | """ 4 | 5 | from .helpers import get_output_handler, register_output_args_shared 6 | from .output_handler import OutputHandler 7 | -------------------------------------------------------------------------------- /linodecli/output/helpers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helpers for CLI output arguments and OutputHandler. 3 | """ 4 | 5 | from argparse import ArgumentParser, Namespace 6 | 7 | from linodecli.output.output_handler import OutputHandler 8 | 9 | 10 | def register_output_args_shared(parser: ArgumentParser): 11 | """ 12 | Add output formatting related arguments to the ArgumentParser. 13 | """ 14 | parser.add_argument( 15 | "--text", 16 | action="store_true", 17 | help="Display text output with a delimiter (defaults to tabs).", 18 | ) 19 | parser.add_argument( 20 | "--delimiter", 21 | metavar="DELIMITER", 22 | type=str, 23 | help="The delimiter when displaying raw output.", 24 | ) 25 | parser.add_argument( 26 | "--json", action="store_true", help="Display output as JSON." 27 | ) 28 | parser.add_argument( 29 | "--markdown", 30 | action="store_true", 31 | help="Display output in Markdown format.", 32 | ) 33 | 34 | parser.add_argument( 35 | "--ascii-table", 36 | action="store_true", 37 | help="Display output in an ASCII table.", 38 | ) 39 | parser.add_argument( 40 | "--pretty", 41 | action="store_true", 42 | help="If set, pretty-print JSON output.", 43 | ) 44 | parser.add_argument( 45 | "--no-headers", 46 | action="store_true", 47 | help="If set, does not display headers in output.", 48 | ) 49 | parser.add_argument( 50 | "--all", 51 | action="store_true", 52 | help=( 53 | "Deprecated flag. An alias of '--all-columns', " 54 | "scheduled to be removed in a future version." 55 | ), 56 | ) 57 | parser.add_argument( 58 | "--all-columns", 59 | action="store_true", 60 | help=( 61 | "If set, displays all possible columns instead of " 62 | "the default columns. This may not work well on some terminals." 63 | ), 64 | ) 65 | parser.add_argument( 66 | "--format", 67 | metavar="FORMAT", 68 | type=str, 69 | help="The columns to display in output. Provide a comma-" 70 | "separated list of column names.", 71 | ) 72 | parser.add_argument( 73 | "--no-truncation", 74 | action="store_true", 75 | default=False, 76 | help="Prevent the truncation of long values in command outputs.", 77 | ) 78 | parser.add_argument( 79 | "--single-table", 80 | action="store_true", 81 | help="Disable printing multiple tables for complex API responses.", 82 | ) 83 | parser.add_argument( 84 | "--table", 85 | type=str, 86 | action="append", 87 | help="The specific table(s) to print in output of a command.", 88 | ) 89 | parser.add_argument( 90 | "--column-width", 91 | type=int, 92 | default=None, 93 | help="Sets the maximum width of each column in outputted tables. " 94 | "By default, columns are dynamically sized to fit the terminal.", 95 | ) 96 | 97 | 98 | def get_output_handler(parsed: Namespace, suppress_warnings: bool = False): 99 | """ 100 | Create a new OutputHandler and configure it with the parsed arguments. 101 | """ 102 | output_handler = OutputHandler() 103 | output_handler.configure(parsed, suppress_warnings) 104 | -------------------------------------------------------------------------------- /linodecli/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Init file for the Linode CLI plugins package. 3 | """ 4 | 5 | from .plugins import * 6 | -------------------------------------------------------------------------------- /linodecli/plugins/echo.py.example: -------------------------------------------------------------------------------- 1 | import argparse 2 | from linodecli.plugins import inherit_relevant_args 3 | 4 | def call(args, context): 5 | """ 6 | This method is invoked when this plugin is invoked on the command line. 7 | 8 | :param args: sys.argv, trimmed to represent only arguments to this command 9 | :type args: list 10 | """ 11 | parser = inherit_plugin_args( 12 | argparse.ArgumentParser("echo", add_help=True) 13 | ) 14 | 15 | parser.add_argument('word', nargs='*', help="The stuff to echo") 16 | data = parser.parse_args(args) 17 | 18 | print(' '.join(data.word)) 19 | -------------------------------------------------------------------------------- /linodecli/plugins/obj/buckets.py: -------------------------------------------------------------------------------- 1 | """ 2 | The bucket manipulation module of CLI Plugin for handling object storage 3 | """ 4 | 5 | import sys 6 | from argparse import ArgumentParser 7 | 8 | from linodecli.exit_codes import ExitCodes 9 | from linodecli.plugins import inherit_plugin_args 10 | from linodecli.plugins.obj.config import PLUGIN_BASE 11 | from linodecli.plugins.obj.helpers import _delete_all_objects 12 | 13 | 14 | def create_bucket( 15 | get_client, args, **kwargs 16 | ): # pylint: disable=unused-argument 17 | """ 18 | Creates a new bucket 19 | """ 20 | parser = inherit_plugin_args(ArgumentParser(PLUGIN_BASE + " mb")) 21 | 22 | parser.add_argument( 23 | "name", 24 | metavar="NAME", 25 | type=str, 26 | help="The name of the bucket to create.", 27 | ) 28 | 29 | parsed = parser.parse_args(args) 30 | client = get_client() 31 | 32 | client.create_bucket(Bucket=parsed.name) 33 | 34 | print(f"Bucket {parsed.name} created") 35 | sys.exit(ExitCodes.SUCCESS) 36 | 37 | 38 | def delete_bucket( 39 | get_client, args, **kwargs 40 | ): # pylint: disable=unused-argument 41 | """ 42 | Deletes a bucket 43 | """ 44 | parser = inherit_plugin_args(ArgumentParser(PLUGIN_BASE + " rb")) 45 | 46 | parser.add_argument( 47 | "name", 48 | metavar="NAME", 49 | type=str, 50 | help="The name of the bucket to remove.", 51 | ) 52 | parser.add_argument( 53 | "--recursive", 54 | action="store_true", 55 | help="If given, force removal of non-empty buckets by deleting " 56 | "all objects in the bucket before deleting the bucket. For " 57 | "large buckets, this may take a while.", 58 | ) 59 | 60 | parsed = parser.parse_args(args) 61 | client = get_client() 62 | bucket_name = parsed.name 63 | 64 | if parsed.recursive: 65 | _delete_all_objects(client, bucket_name) 66 | 67 | client.delete_bucket(Bucket=bucket_name) 68 | print(f"Bucket {parsed.name} removed") 69 | 70 | sys.exit(ExitCodes.SUCCESS) 71 | -------------------------------------------------------------------------------- /linodecli/plugins/obj/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | The config of the object storage plugin. 3 | """ 4 | 5 | import shutil 6 | 7 | ENV_ACCESS_KEY_NAME = "LINODE_CLI_OBJ_ACCESS_KEY" 8 | ENV_SECRET_KEY_NAME = "LINODE_CLI_OBJ_SECRET_KEY" 9 | # replace {} with the cluster name 10 | BASE_URL_TEMPLATE = "https://{}.linodeobjects.com" 11 | BASE_WEBSITE_TEMPLATE = "{bucket}.website-{cluster}.linodeobjects.com" 12 | 13 | # for all date output 14 | DATE_FORMAT = "%Y-%m-%d %H:%M" 15 | INCOMING_DATE_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" 16 | 17 | # for help commands 18 | PLUGIN_BASE = "linode-cli obj" 19 | 20 | columns = shutil.get_terminal_size(fallback=(80, 24)).columns 21 | PROGRESS_BAR_WIDTH = columns - 20 if columns > 30 else columns 22 | 23 | # constant error messages 24 | NO_SCOPES_ERROR = """Your OAuth token isn't authorized to create Object Storage keys. 25 | To fix this, please generate a new token at this URL: 26 | 27 | https://cloud.linode.com/profile/tokens 28 | 29 | Be sure to select 'Read/Write' for Object Storage and 'Read Only' 30 | for Account during token generation (as well as any other access 31 | you want the Linode CLI to have). 32 | 33 | Once you've generated a new token, you can use it with the 34 | Linode CLI by running this command and entering it: 35 | 36 | linode-cli configure 37 | """ 38 | 39 | NO_ACCESS_ERROR = ( 40 | "You are not authorized to use Object Storage at this time.\n" 41 | "Please contact your Linode Account administrator to request\n" 42 | "access, or ask them to generate Object Storage Keys for you\n" 43 | ) 44 | 45 | 46 | # Files larger than this need to be uploaded via a multipart upload 47 | UPLOAD_MAX_FILE_SIZE = 1024 * 1024 * 1024 * 5 48 | # This is how big (in MB) the chunks of the file that we upload will be 49 | MULTIPART_UPLOAD_CHUNK_SIZE_DEFAULT = 1024 50 | -------------------------------------------------------------------------------- /linodecli/plugins/obj/website.py: -------------------------------------------------------------------------------- 1 | """ 2 | The static website module of CLI Plugin for handling object storage 3 | """ 4 | 5 | from argparse import ArgumentParser 6 | 7 | from linodecli.plugins import inherit_plugin_args 8 | from linodecli.plugins.obj.config import BASE_WEBSITE_TEMPLATE, PLUGIN_BASE 9 | 10 | 11 | def enable_static_site( 12 | get_client, args, **kwargs 13 | ): # pylint: disable=unused-argument 14 | """ 15 | Turns a bucket into a static website 16 | """ 17 | parser = inherit_plugin_args(ArgumentParser(PLUGIN_BASE + " ws-create")) 18 | 19 | parser.add_argument( 20 | "bucket", 21 | metavar="BUCKET", 22 | type=str, 23 | help="The bucket to turn into a static site", 24 | ) 25 | parser.add_argument( 26 | "--ws-index", 27 | metavar="INDEX", 28 | required=True, 29 | type=str, 30 | help="The file to serve as the index of the website", 31 | ) 32 | parser.add_argument( 33 | "--ws-error", 34 | metavar="ERROR", 35 | type=str, 36 | help="The file to serve as the error page of the website", 37 | ) 38 | 39 | parsed = parser.parse_args(args) 40 | client = get_client() 41 | bucket = parsed.bucket 42 | 43 | # make the site 44 | print(f"Setting bucket {bucket} access control to be 'public-read'") 45 | 46 | client.put_bucket_acl( 47 | Bucket=bucket, 48 | ACL="public-read", 49 | ) 50 | 51 | index_page = parsed.ws_index 52 | 53 | ws_config = {"IndexDocument": {"Suffix": index_page}} 54 | if parsed.ws_error: 55 | ws_config["ErrorDocument"] = {"Key": parsed.ws_error} 56 | 57 | client.put_bucket_website( 58 | Bucket=bucket, 59 | WebsiteConfiguration=ws_config, 60 | ) 61 | 62 | print( 63 | "Static site now available at " 64 | f"{BASE_WEBSITE_TEMPLATE.format(cluster=client.cluster, bucket=bucket)}" 65 | "\nIf you still can't access the website, please check the " 66 | "Access Control List setting of the website related objects (files) " 67 | "in your bucket." 68 | ) 69 | 70 | 71 | def static_site_info( 72 | get_client, args, **kwargs 73 | ): # pylint: disable=unused-argument 74 | """ 75 | Returns info about a configured static site 76 | """ 77 | parser = inherit_plugin_args(ArgumentParser(PLUGIN_BASE + " ws-info")) 78 | 79 | parser.add_argument( 80 | "bucket", 81 | metavar="BUCKET", 82 | type=str, 83 | help="The bucket to return static site information on.", 84 | ) 85 | 86 | parsed = parser.parse_args(args) 87 | client = get_client() 88 | 89 | bucket = parsed.bucket 90 | 91 | response = client.get_bucket_website(Bucket=bucket) 92 | 93 | index = response.get("IndexDocument", {}).get("Suffix", "Not Configured") 94 | error = response.get("ErrorDocument", {}).get("Key", "Not Configured") 95 | 96 | endpoint = BASE_WEBSITE_TEMPLATE.format( 97 | cluster=client.cluster, bucket=bucket 98 | ) 99 | 100 | print(f"Bucket {bucket}: Website configuration") 101 | print(f"Website endpoint: {endpoint}") 102 | print(f"Index document: {index}") 103 | print(f"Error document: {error}") 104 | 105 | 106 | def disable_static_site( 107 | get_client, args, **kwargs 108 | ): # pylint: disable=unused-argument 109 | """ 110 | Disables static site for a bucket 111 | """ 112 | parser = inherit_plugin_args(ArgumentParser(PLUGIN_BASE + " du")) 113 | 114 | parser.add_argument( 115 | "bucket", 116 | metavar="BUCKET", 117 | type=str, 118 | nargs="?", 119 | help="The bucket to disable static site for.", 120 | ) 121 | 122 | parsed = parser.parse_args(args) 123 | client = get_client() 124 | 125 | bucket = parsed.bucket 126 | 127 | client.delete_bucket_website(Bucket=bucket) 128 | 129 | print(f"Website configuration deleted for {parsed.bucket}") 130 | -------------------------------------------------------------------------------- /linodecli/plugins/region-table.py: -------------------------------------------------------------------------------- 1 | """ 2 | The region-table plugin displays a table output 3 | for the capabilities of each region. 4 | """ 5 | 6 | import sys 7 | 8 | from rich.align import Align 9 | from rich.console import Console 10 | from rich.table import Table 11 | 12 | from linodecli.exit_codes import ExitCodes 13 | 14 | 15 | def call(_, ctx): 16 | """ 17 | Invokes the region-table plugin 18 | """ 19 | status, regions = ctx.client.call_operation("regions", "list") 20 | 21 | capabilities = [ 22 | ("Linodes", "Linodes"), 23 | ("GPU Linodes", "GPU"), 24 | ("NodeBalancers", "NB"), 25 | ("Kubernetes", "K8s"), 26 | ("Cloud Firewall", "FW"), 27 | ("Managed Databases", "DB"), 28 | ("Object Storage", "OBJ"), 29 | ("Vlans", "Vlan"), 30 | ("Premium Plans", "Premium"), 31 | ("Metadata", "Meta"), 32 | ("Block Storage", "Block"), 33 | ] 34 | 35 | if status != 200: 36 | print("It failed :(", file=sys.stderr) 37 | sys.exit(ExitCodes.REQUEST_FAILED) 38 | 39 | output = Table() 40 | headers = ["ID", "Label", "Loc"] + [x[1] for x in capabilities] 41 | for header in headers: 42 | output.add_column(header, justify="center") 43 | for region in regions["data"]: 44 | row = [ 45 | Align(region["id"], align="left"), 46 | Align(region["label"], align="left"), 47 | region["country"].upper(), 48 | ] + [ 49 | "✔" if c[0] in region["capabilities"] else "-" for c in capabilities 50 | ] 51 | output.add_row(*row) 52 | 53 | console = Console() 54 | console.print(output) 55 | -------------------------------------------------------------------------------- /linodecli/plugins/regionstats.py.example: -------------------------------------------------------------------------------- 1 | """ 2 | The regionstats plugin queries the API for all Linodes and breaks them down by 3 | region, printing the result to the command line. 4 | """ 5 | import argparse 6 | from sys import exit 7 | 8 | def call(args, context): 9 | """ 10 | Invokes the regionstats plugin 11 | """ 12 | status, result = context.client.call_operation('linodes', 'list') 13 | 14 | if status != 200: 15 | print('It failed :(', file=sys.stderr) 16 | exit(1) 17 | 18 | regions = {} 19 | 20 | for item in result['data']: 21 | region = item['region'] 22 | if region not in regions: 23 | regions[region] = 0 24 | regions[region] += 1 25 | 26 | if not regions: 27 | print("You don't have any linodes") 28 | exit(0) 29 | 30 | print("Linodes by Region:") 31 | for region, number in regions.items(): 32 | print("{}: {}".format(region, number)) 33 | -------------------------------------------------------------------------------- /linodecli/version.py: -------------------------------------------------------------------------------- 1 | """ 2 | The version of the Linode CLI. 3 | """ 4 | 5 | __version__ = "0.0.0.dev" 6 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel", "packaging"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "linode-cli" 7 | authors = [{ name = "Akamai Technologies Inc.", email = "developers@linode.com" }] 8 | description = "The official command-line interface for interacting with the Linode API." 9 | readme = "README.md" 10 | requires-python = ">=3.9" 11 | license = "BSD-3-Clause" 12 | classifiers = [] 13 | dependencies = [ 14 | "openapi3", 15 | "requests", 16 | "PyYAML", 17 | "packaging", 18 | "rich", 19 | "urllib3<3", 20 | "linode-metadata>=0.3.0" 21 | ] 22 | dynamic = ["version"] 23 | 24 | [project.optional-dependencies] 25 | obj = ["boto3>=1.36.0"] 26 | dev = [ 27 | "pylint>=2.17.4", 28 | "pytest>=7.3.1", 29 | "black>=23.1.0", 30 | "isort>=5.12.0", 31 | "autoflake>=2.0.1", 32 | "pytest-mock>=3.10.0", 33 | "requests-mock==1.12.1", 34 | "boto3-stubs[s3]", 35 | "build>=0.10.0", 36 | "twine>=4.0.2", 37 | "pytest-rerunfailures" 38 | ] 39 | 40 | [project.scripts] 41 | linode-cli = "linodecli:main" 42 | linode = "linodecli:main" 43 | lin = "linodecli:main" 44 | 45 | [tool.setuptools.dynamic] 46 | version = { attr = "linodecli.version.__version__" } 47 | 48 | [tool.setuptools.packages.find] 49 | include = ["linodecli*"] 50 | 51 | [tool.isort] 52 | profile = "black" 53 | line_length = 80 54 | 55 | [tool.black] 56 | line-length = 80 57 | target-version = ["py39", "py310", "py311", "py312", "py313"] 58 | 59 | [tool.autoflake] 60 | expand-star-imports = true 61 | ignore-init-module-imports = true 62 | ignore-pass-after-docstring = true 63 | in-place = true 64 | recursive = true 65 | remove-all-unused-imports = true 66 | remove-duplicate-keys = true 67 | -------------------------------------------------------------------------------- /resolve_spec_url: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Usage: 3 | # ./resolve_latest_spec 4 | # Prints the URL of the latest Linode OpenAPI spec on GitHub 5 | import os 6 | import sys 7 | 8 | import requests 9 | 10 | LINODE_DOCS_REPO = "linode/linode-api-docs" 11 | 12 | 13 | def get_latest_tag(): 14 | headers = {} 15 | 16 | token = os.getenv("GITHUB_TOKEN") 17 | if token is not None: 18 | headers["Authorization"] = f"Bearer {token}" 19 | 20 | data = requests.get( 21 | f"https://api.github.com/repos/{LINODE_DOCS_REPO}/releases/latest", 22 | headers=headers, 23 | ) 24 | 25 | if data.status_code != 200: 26 | raise RuntimeError("Got error from GitHub API: {}".format(data.json())) 27 | 28 | return data.json()["tag_name"] 29 | 30 | 31 | if __name__ == "__main__": 32 | if len(sys.argv) != 2: 33 | print(f"Invalid number of arguments: {len(sys.argv)}", file=sys.stderr) 34 | exit(1) 35 | 36 | desired_version = sys.argv[1] 37 | 38 | if desired_version.lower() == "latest": 39 | desired_version = get_latest_tag() 40 | 41 | print( 42 | f"https://raw.githubusercontent.com/{LINODE_DOCS_REPO}/{desired_version}/openapi.json" 43 | ) 44 | -------------------------------------------------------------------------------- /scripts/cibuild.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e -x 4 | 5 | docker build -t "cli-builder:${BUILD_TAG}" . 6 | 7 | docker run \ 8 | --rm \ 9 | -e USER=${USER} \ 10 | -e USERID=$(grep Uid /proc/self/status | cut -f2 | awk '{$1=$1};1') \ 11 | -u $(id -u) \ 12 | -v $(pwd):/src \ 13 | "cli-builder:${BUILD_TAG}" 14 | -------------------------------------------------------------------------------- /scripts/cipublish.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e -x 4 | 5 | docker run \ 6 | --entrypoint "" \ 7 | --rm \ 8 | -e USER=${USER} \ 9 | -e USERID=$(grep Uid /proc/self/status | cut -f2 | awk '{$1=$1};1') \ 10 | -u $(id -u) \ 11 | -v $(pwd):/src \ 12 | "cli-builder:${BUILD_TAG}" \ 13 | twine upload -u "${pypi_user}" -p "${pypi_password}" "/src/dist/*.whl" 14 | -------------------------------------------------------------------------------- /scripts/cleanup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e -x 4 | 5 | # Cleanup the docker image 6 | docker rmi cli-builder:${BUILD_TAG} linode-cli-test 7 | -------------------------------------------------------------------------------- /scripts/lke-policy.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: projectcalico.org/v3 2 | kind: GlobalNetworkPolicy 3 | metadata: 4 | name: lke-rules 5 | spec: 6 | preDNAT: true 7 | applyOnForward: true 8 | order: 100 9 | # Remember to run calicoctl patch command for this to work 10 | selector: "" 11 | ingress: 12 | # Allow ICMP 13 | - action: Allow 14 | protocol: ICMP 15 | - action: Allow 16 | protocol: ICMPv6 17 | 18 | # Allow LKE-required ports 19 | - action: Allow 20 | protocol: TCP 21 | destination: 22 | nets: 23 | - 192.168.128.0/17 24 | - 10.0.0.0/8 25 | ports: 26 | - 10250 27 | - 10256 28 | - 179 29 | - action: Allow 30 | protocol: UDP 31 | destination: 32 | nets: 33 | - 192.168.128.0/17 34 | - 10.2.0.0/16 35 | ports: 36 | - 51820 37 | 38 | # Allow NodeBalancer ingress to the Node Ports & Allow DNS 39 | - action: Allow 40 | protocol: TCP 41 | source: 42 | nets: 43 | - 192.168.255.0/24 44 | - 10.0.0.0/8 45 | destination: 46 | ports: 47 | - 53 48 | - 30000:32767 49 | - action: Allow 50 | protocol: UDP 51 | source: 52 | nets: 53 | - 192.168.255.0/24 54 | - 10.0.0.0/8 55 | destination: 56 | ports: 57 | - 53 58 | - 30000:32767 59 | 60 | # Allow cluster internal communication 61 | - action: Allow 62 | destination: 63 | nets: 64 | - 10.0.0.0/8 65 | - action: Allow 66 | source: 67 | nets: 68 | - 10.0.0.0/8 69 | 70 | # 127.0.0.1/32 is needed for kubectl exec and node-shell 71 | - action: Allow 72 | destination: 73 | nets: 74 | - 127.0.0.1/32 75 | 76 | # Block everything else 77 | - action: Deny 78 | - action: Log 79 | -------------------------------------------------------------------------------- /scripts/lke_calico_rules_e2e.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | RETRIES=3 4 | DELAY=30 5 | 6 | # Function to retry a command with exponential backoff 7 | retry_command() { 8 | local retries=$1 9 | local wait_time=60 10 | shift 11 | until "$@"; do 12 | if ((retries == 0)); then 13 | echo "Command failed after multiple retries. Exiting." 14 | exit 1 15 | fi 16 | echo "Command failed. Retrying in $wait_time seconds..." 17 | sleep $wait_time 18 | ((retries--)) 19 | wait_time=$((wait_time * 2)) 20 | done 21 | } 22 | 23 | # Fetch the list of LKE cluster IDs 24 | CLUSTER_IDS=$(curl -s -H "Authorization: Bearer $LINODE_TOKEN" \ 25 | -H "Content-Type: application/json" \ 26 | "https://api.linode.com/v4/lke/clusters" | jq -r '.data[].id') 27 | 28 | # Check if CLUSTER_IDS is empty 29 | if [ -z "$CLUSTER_IDS" ]; then 30 | echo "All clusters have been cleaned and properly destroyed. No need to apply inbound or outbound rules" 31 | exit 0 32 | fi 33 | 34 | for ID in $CLUSTER_IDS; do 35 | echo "Applying Calico rules to nodes in Cluster ID: $ID" 36 | 37 | # Download cluster configuration file with retry 38 | for ((i=1; i<=RETRIES; i++)); do 39 | config_response=$(curl -sH "Authorization: Bearer $LINODE_TOKEN" "https://api.linode.com/v4/lke/clusters/$ID/kubeconfig") 40 | if [[ $config_response != *"kubeconfig is not yet available"* ]]; then 41 | echo $config_response | jq -r '.[] | @base64d' > "/tmp/${ID}_config.yaml" 42 | break 43 | fi 44 | echo "Attempt $i to download kubeconfig for cluster $ID failed. Retrying in $DELAY seconds..." 45 | sleep $DELAY 46 | done 47 | 48 | if [[ $config_response == *"kubeconfig is not yet available"* ]]; then 49 | echo "kubeconfig for cluster id:$ID not available after $RETRIES attempts, mostly likely it is an empty cluster. Skipping..." 50 | else 51 | # Export downloaded config file 52 | export KUBECONFIG="/tmp/${ID}_config.yaml" 53 | 54 | retry_command $RETRIES kubectl get nodes 55 | 56 | retry_command $RETRIES calicoctl patch kubecontrollersconfiguration default --allow-version-mismatch --patch='{"spec": {"controllers": {"node": {"hostEndpoint": {"autoCreate": "Enabled"}}}}}' 57 | 58 | retry_command $RETRIES calicoctl apply --allow-version-mismatch -f "$(pwd)/lke-policy.yaml" 59 | fi 60 | done 61 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linode/linode-cli/d2bfe4a0ec67467fdb8edca1b8ba342d95f855cc/tests/__init__.py -------------------------------------------------------------------------------- /tests/fixtures/api_request_test_foobar_get.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.1 2 | info: 3 | title: API Specification 4 | version: 1.0.0 5 | servers: 6 | - url: http://localhost/v4 7 | paths: 8 | /foo/bar: 9 | get: 10 | summary: get info 11 | operationId: fooBarGet 12 | description: This is description 13 | responses: 14 | '200': 15 | description: Successful response 16 | content: 17 | application/json: 18 | schema: 19 | type: object 20 | properties: 21 | data: 22 | type: array 23 | items: 24 | $ref: '#/components/schemas/OpenAPIResponseAttr' 25 | page: 26 | $ref: '#/components/schemas/PaginationEnvelope/properties/page' 27 | pages: 28 | $ref: '#/components/schemas/PaginationEnvelope/properties/pages' 29 | results: 30 | $ref: '#/components/schemas/PaginationEnvelope/properties/results' 31 | 32 | components: 33 | schemas: 34 | OpenAPIResponseAttr: 35 | type: object 36 | properties: 37 | filterable_result: 38 | x-linode-filterable: true 39 | type: string 40 | description: Filterable result value 41 | filterable_list_result: 42 | x-linode-filterable: true 43 | type: array 44 | items: 45 | type: string 46 | description: Filterable result value 47 | PaginationEnvelope: 48 | type: object 49 | properties: 50 | pages: 51 | type: integer 52 | readOnly: true 53 | description: The total number of pages. 54 | example: 1 55 | page: 56 | type: integer 57 | readOnly: true 58 | description: The current page. 59 | example: 1 60 | results: 61 | type: integer 62 | readOnly: true 63 | description: The total number of results. 64 | example: 1 -------------------------------------------------------------------------------- /tests/fixtures/api_request_test_foobar_post.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.1 2 | info: 3 | title: API Specification 4 | version: 1.0.0 5 | servers: 6 | - url: http://localhost/v4 7 | 8 | paths: 9 | /foo/bar: 10 | post: 11 | summary: create foobar 12 | operationId: fooBarPost 13 | description: This is description 14 | requestBody: 15 | description: > 16 | The parameters to set when creating the Foobar 17 | required: True 18 | content: 19 | application/json: 20 | schema: 21 | required: 22 | - test_param 23 | - generic_arg 24 | allOf: 25 | - $ref: '#/components/schemas/FooBarCreate' 26 | responses: 27 | '200': 28 | description: Successful response 29 | content: 30 | application/json: 31 | schema: 32 | type: object 33 | properties: 34 | data: 35 | type: array 36 | items: 37 | $ref: '#/components/schemas/OpenAPIResponseAttr' 38 | page: 39 | $ref: '#/components/schemas/PaginationEnvelope/properties/page' 40 | pages: 41 | $ref: '#/components/schemas/PaginationEnvelope/properties/pages' 42 | results: 43 | $ref: '#/components/schemas/PaginationEnvelope/properties/results' 44 | 45 | components: 46 | schemas: 47 | OpenAPIResponseAttr: 48 | type: object 49 | properties: 50 | filterable_result: 51 | x-linode-filterable: true 52 | type: string 53 | description: Filterable result value 54 | PaginationEnvelope: 55 | type: object 56 | properties: 57 | pages: 58 | type: integer 59 | readOnly: true 60 | description: The total number of pages. 61 | example: 1 62 | page: 63 | type: integer 64 | readOnly: true 65 | description: The current page. 66 | example: 1 67 | results: 68 | type: integer 69 | readOnly: true 70 | description: The total number of results. 71 | example: 1 72 | FooBarCreate: 73 | type: object 74 | description: Foobar object request 75 | properties: 76 | test_param: 77 | x-linode-filterable: true 78 | type: integer 79 | description: The test parameter 80 | generic_arg: 81 | x-linode-filterable: true 82 | type: string 83 | description: The generic argument 84 | region: 85 | x-linode-filterable: true 86 | type: string 87 | description: The region 88 | nullable_int: 89 | type: integer 90 | nullable: true 91 | description: An arbitrary nullable int 92 | nullable_string: 93 | type: string 94 | nullable: true 95 | description: An arbitrary nullable string 96 | nullable_float: 97 | type: number 98 | nullable: true 99 | description: An arbitrary nullable float 100 | object_list: 101 | type: array 102 | description: An arbitrary list of objects. 103 | items: 104 | type: object 105 | description: An arbitrary object. 106 | properties: 107 | field_dict: 108 | type: object 109 | description: An arbitrary nested dict. 110 | properties: 111 | nested_string: 112 | type: string 113 | description: A deeply nested string. 114 | nested_int: 115 | type: number 116 | description: A deeply nested integer. 117 | field_array: 118 | type: array 119 | description: An arbitrary deeply nested array. 120 | items: 121 | type: string 122 | field_string: 123 | type: string 124 | description: An arbitrary field. 125 | field_int: 126 | type: number 127 | description: An arbitrary field. 128 | nullable_string: 129 | type: string 130 | description: An arbitrary nullable string. 131 | nullable: true 132 | -------------------------------------------------------------------------------- /tests/fixtures/api_request_test_foobar_put.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.1 2 | info: 3 | title: API Specification 4 | version: 1.0.0 5 | servers: 6 | - url: http://localhost/v4 7 | 8 | paths: 9 | /foo/bar/{barId}: 10 | parameters: 11 | - name: barId 12 | description: The ID of the bar. 13 | in: path 14 | required: true 15 | schema: 16 | type: string 17 | x-linode-cli-command: foo 18 | put: 19 | x-linode-cli-action: bar-update 20 | summary: update foobar 21 | operationId: fooBarPut 22 | description: This is description 23 | requestBody: 24 | description: > 25 | The parameters to set when updating the Foobar. 26 | required: True 27 | content: 28 | application/json: 29 | schema: 30 | allOf: 31 | - $ref: '#/components/schemas/FooBarUpdate' 32 | responses: 33 | '200': 34 | description: Successful response 35 | content: 36 | application/json: 37 | schema: 38 | $ref: '#/components/schemas/OpenAPIResponseAttr' 39 | 40 | components: 41 | schemas: 42 | OpenAPIResponseAttr: 43 | type: object 44 | properties: 45 | filterable_result: 46 | x-linode-filterable: true 47 | type: string 48 | description: Filterable result value 49 | PaginationEnvelope: 50 | type: object 51 | properties: 52 | pages: 53 | type: integer 54 | readOnly: true 55 | description: The total number of pages. 56 | example: 1 57 | page: 58 | type: integer 59 | readOnly: true 60 | description: The current page. 61 | example: 1 62 | results: 63 | type: integer 64 | readOnly: true 65 | description: The total number of results. 66 | example: 1 67 | FooBarUpdate: 68 | type: object 69 | description: Foobar object request 70 | properties: 71 | test_param: 72 | x-linode-filterable: true 73 | type: integer 74 | description: The test parameter 75 | generic_arg: 76 | x-linode-filterable: true 77 | type: string 78 | description: The generic argument 79 | region: 80 | x-linode-filterable: true 81 | type: string 82 | description: The region 83 | nullable_int: 84 | type: integer 85 | nullable: true 86 | description: An arbitrary nullable int 87 | nullable_string: 88 | type: string 89 | nullable: true 90 | description: An arbitrary nullable string 91 | nullable_float: 92 | type: number 93 | nullable: true 94 | description: An arbitrary nullable float 95 | object_list: 96 | type: array 97 | description: An arbitrary list of objects. 98 | items: 99 | type: object 100 | description: An arbitrary object. 101 | properties: 102 | field_dict: 103 | type: object 104 | description: An arbitrary nested dict. 105 | properties: 106 | nested_string: 107 | type: string 108 | description: A deeply nested string. 109 | nested_int: 110 | type: number 111 | description: A deeply nested integer. 112 | field_array: 113 | type: array 114 | description: An arbitrary deeply nested array. 115 | items: 116 | type: string 117 | field_string: 118 | type: string 119 | description: An arbitrary field. 120 | field_int: 121 | type: number 122 | description: An arbitrary field. 123 | nullable_string: 124 | type: string 125 | description: An arbitrary nullable string. 126 | nullable: true 127 | -------------------------------------------------------------------------------- /tests/fixtures/api_url_components_test.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.1 2 | info: 3 | title: API Specification 4 | version: 1.0.0 5 | servers: 6 | - url: http://localhost/v19 7 | paths: 8 | /foo/bar: 9 | get: 10 | operationId: fooBarGet 11 | responses: 12 | '200': 13 | description: foobar 14 | content: 15 | application/json: {} 16 | delete: 17 | operationId: fooBarDelete 18 | servers: 19 | - url: http://localhost/v12beta 20 | responses: 21 | '200': 22 | description: foobar 23 | content: 24 | application/json: {} 25 | /{apiVersion}/bar/foo: 26 | parameters: 27 | - name: apiVersion 28 | in: path 29 | required: true 30 | schema: 31 | type: string 32 | default: v9canary 33 | get: 34 | operationId: barFooGet 35 | responses: 36 | '200': 37 | description: foobar 38 | content: 39 | application/json: {} 40 | post: 41 | operationId: barFooPost 42 | servers: 43 | - url: http://localhost/v100beta 44 | responses: 45 | '200': 46 | description: foobar 47 | content: 48 | application/json: {} 49 | 50 | /{apiVersion}/bar: 51 | parameters: 52 | - name: apiVersion 53 | in: path 54 | required: true 55 | schema: 56 | type: string 57 | enum: 58 | - v1000 59 | - v1000beta 60 | get: 61 | operationId: barGet 62 | responses: 63 | '200': 64 | description: foobar 65 | content: 66 | application/json: {} 67 | -------------------------------------------------------------------------------- /tests/fixtures/cli_test_bake_missing_cmd_ext.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.1 2 | info: 3 | title: API Specification 4 | version: 1.0.0 5 | servers: 6 | - url: http://localhost/v4 7 | paths: 8 | /foo/bar: 9 | get: 10 | summary: get info 11 | operationId: fooBarGet 12 | description: This is description 13 | responses: 14 | '200': 15 | description: Successful response 16 | content: 17 | application/json: 18 | schema: 19 | type: object 20 | properties: 21 | data: 22 | type: array 23 | items: 24 | $ref: '#/components/schemas/OpenAPIResponseAttr' 25 | page: 26 | $ref: '#/components/schemas/PaginationEnvelope/properties/page' 27 | pages: 28 | $ref: '#/components/schemas/PaginationEnvelope/properties/pages' 29 | results: 30 | $ref: '#/components/schemas/PaginationEnvelope/properties/results' 31 | 32 | components: 33 | schemas: 34 | OpenAPIResponseAttr: 35 | type: object 36 | properties: 37 | filterable_result: 38 | x-linode-filterable: true 39 | type: string 40 | description: Filterable result value 41 | filterable_list_result: 42 | x-linode-filterable: true 43 | type: array 44 | items: 45 | type: string 46 | description: Filterable result value 47 | PaginationEnvelope: 48 | type: object 49 | properties: 50 | pages: 51 | type: integer 52 | readOnly: true 53 | description: The total number of pages. 54 | example: 1 55 | page: 56 | type: integer 57 | readOnly: true 58 | description: The current page. 59 | example: 1 60 | results: 61 | type: integer 62 | readOnly: true 63 | description: The total number of results. 64 | example: 1 -------------------------------------------------------------------------------- /tests/fixtures/cli_test_load.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.1", 3 | "info": { 4 | "title": "API Specification", 5 | "version": "1.0.0" 6 | }, 7 | "servers": [ 8 | { 9 | "url": "http://localhost/v4" 10 | } 11 | ], 12 | "paths": { 13 | "/foo/bar": { 14 | "get": { 15 | "summary": "get info", 16 | "operationId": "fooBarGet", 17 | "description": "This is description", 18 | "responses": { 19 | "200": { 20 | "description": "Successful response", 21 | "content": { 22 | "application/json": { 23 | "schema": { 24 | "type": "object", 25 | "properties": { 26 | "data": { 27 | "type": "array", 28 | "items": { 29 | "$ref": "#/components/schemas/OpenAPIResponseAttr" 30 | } 31 | }, 32 | "page": { 33 | "$ref": "#/components/schemas/PaginationEnvelope/properties/page" 34 | }, 35 | "pages": { 36 | "$ref": "#/components/schemas/PaginationEnvelope/properties/pages" 37 | }, 38 | "results": { 39 | "$ref": "#/components/schemas/PaginationEnvelope/properties/results" 40 | } 41 | } 42 | } 43 | } 44 | } 45 | } 46 | } 47 | } 48 | } 49 | }, 50 | "components": { 51 | "schemas": { 52 | "OpenAPIResponseAttr": { 53 | "type": "object", 54 | "properties": { 55 | "filterable_result": { 56 | "x-linode-filterable": true, 57 | "type": "string", 58 | "description": "Filterable result value" 59 | }, 60 | "filterable_list_result": { 61 | "x-linode-filterable": true, 62 | "type": "array", 63 | "items": { 64 | "type": "string" 65 | }, 66 | "description": "Filterable result value" 67 | } 68 | } 69 | }, 70 | "PaginationEnvelope": { 71 | "type": "object", 72 | "properties": { 73 | "pages": { 74 | "type": "integer", 75 | "readOnly": true, 76 | "description": "The total number of pages.", 77 | "example": 1 78 | }, 79 | "page": { 80 | "type": "integer", 81 | "readOnly": true, 82 | "description": "The current page.", 83 | "example": 1 84 | }, 85 | "results": { 86 | "type": "integer", 87 | "readOnly": true, 88 | "description": "The total number of results.", 89 | "example": 1 90 | } 91 | } 92 | } 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /tests/fixtures/cli_test_load.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.1 2 | info: 3 | title: API Specification 4 | version: 1.0.0 5 | servers: 6 | - url: http://localhost/v4 7 | paths: 8 | /foo/bar: 9 | get: 10 | summary: get info 11 | operationId: fooBarGet 12 | description: This is description 13 | responses: 14 | '200': 15 | description: Successful response 16 | content: 17 | application/json: 18 | schema: 19 | type: object 20 | properties: 21 | data: 22 | type: array 23 | items: 24 | $ref: '#/components/schemas/OpenAPIResponseAttr' 25 | page: 26 | $ref: '#/components/schemas/PaginationEnvelope/properties/page' 27 | pages: 28 | $ref: '#/components/schemas/PaginationEnvelope/properties/pages' 29 | results: 30 | $ref: '#/components/schemas/PaginationEnvelope/properties/results' 31 | 32 | components: 33 | schemas: 34 | OpenAPIResponseAttr: 35 | type: object 36 | properties: 37 | filterable_result: 38 | x-linode-filterable: true 39 | type: string 40 | description: Filterable result value 41 | filterable_list_result: 42 | x-linode-filterable: true 43 | type: array 44 | items: 45 | type: string 46 | description: Filterable result value 47 | PaginationEnvelope: 48 | type: object 49 | properties: 50 | pages: 51 | type: integer 52 | readOnly: true 53 | description: The total number of pages. 54 | example: 1 55 | page: 56 | type: integer 57 | readOnly: true 58 | description: The current page. 59 | example: 1 60 | results: 61 | type: integer 62 | readOnly: true 63 | description: The total number of results. 64 | example: 1 -------------------------------------------------------------------------------- /tests/fixtures/docs_url_test.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.1 2 | info: 3 | title: API Specification 4 | version: 1.0.0 5 | servers: 6 | - url: http://localhost/v4 7 | paths: 8 | /foo/bar: 9 | get: 10 | summary: get info 11 | operationId: BarGet 12 | description: This is description 13 | tags: 14 | - Foo 15 | responses: 16 | '200': 17 | description: Successful response 18 | content: 19 | application/json: {} 20 | post: 21 | externalDocs: 22 | description: cool docs url 23 | url: https://techdocs.akamai.com/linode-api/reference/cool-docs-url 24 | responses: 25 | '200': 26 | description: Successful response 27 | content: 28 | application/json: {} 29 | -------------------------------------------------------------------------------- /tests/fixtures/operation_with_one_ofs.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.1 2 | info: 3 | title: API Specification 4 | version: 1.0.0 5 | servers: 6 | - url: http://localhost/v4 7 | 8 | paths: 9 | /foo/bar: 10 | x-linode-cli-command: foo 11 | post: 12 | summary: Do something. 13 | operationId: fooBarPost 14 | description: This is description 15 | requestBody: 16 | description: Some description. 17 | required: True 18 | content: 19 | application/json: 20 | schema: 21 | $ref: '#/components/schemas/Foo' 22 | responses: 23 | '200': 24 | description: Successful response 25 | content: 26 | application/json: 27 | schema: 28 | $ref: '#/components/schemas/Foo' 29 | 30 | components: 31 | schemas: 32 | Foo: 33 | oneOf: 34 | - title: Usage 1 35 | type: object 36 | required: 37 | - foobar 38 | - barfoo 39 | properties: 40 | foobar: 41 | type: string 42 | description: Some foobar. 43 | barfoo: 44 | type: integer 45 | description: Some barfoo. 46 | - title: Usage 2 47 | type: object 48 | required: 49 | - foobar 50 | - foofoo 51 | properties: 52 | foobar: 53 | type: string 54 | description: Some foobar. 55 | foofoo: 56 | type: boolean 57 | description: Some foofoo. 58 | barbar: 59 | description: Some barbar. 60 | type: object 61 | anyOf: 62 | - type: object 63 | properties: 64 | foo: 65 | type: string 66 | description: Some foo. 67 | bar: 68 | type: integer 69 | description: Some bar. 70 | - type: object 71 | properties: 72 | baz: 73 | type: boolean 74 | description: Some baz. 75 | -------------------------------------------------------------------------------- /tests/fixtures/output_test_get.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.1 2 | info: 3 | title: API Specification 4 | version: 1.0.0 5 | servers: 6 | - url: http://localhost/v4 7 | 8 | paths: 9 | /foo/bar: 10 | get: 11 | summary: get info 12 | operationId: fooBarGet 13 | description: This is description 14 | responses: 15 | '200': 16 | description: Successful response 17 | content: 18 | application/json: 19 | schema: 20 | type: object 21 | properties: 22 | data: 23 | type: array 24 | items: 25 | $ref: '#/components/schemas/OpenAPIResponseAttr' 26 | page: 27 | $ref: '#/components/schemas/PaginationEnvelope/properties/page' 28 | pages: 29 | $ref: '#/components/schemas/PaginationEnvelope/properties/pages' 30 | results: 31 | $ref: '#/components/schemas/PaginationEnvelope/properties/results' 32 | 33 | components: 34 | schemas: 35 | OpenAPIResponseAttr: 36 | type: object 37 | properties: 38 | cool: 39 | x-linode-filterable: true 40 | type: string 41 | description: Filterable result value 42 | bar: 43 | x-linode-filterable: true 44 | type: string 45 | description: Filterable result value 46 | test: 47 | x-linode-filterable: true 48 | type: string 49 | description: Filterable result value 50 | 51 | PaginationEnvelope: 52 | type: object 53 | properties: 54 | pages: 55 | type: integer 56 | readOnly: true 57 | description: The total number of pages. 58 | example: 1 59 | page: 60 | type: integer 61 | readOnly: true 62 | description: The current page. 63 | example: 1 64 | results: 65 | type: integer 66 | readOnly: true 67 | description: The total number of results. 68 | example: 1 -------------------------------------------------------------------------------- /tests/fixtures/overrides_test_get.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.1 2 | info: 3 | title: API Specification 4 | version: 1.0.0 5 | servers: 6 | - url: http://localhost/v4 7 | 8 | paths: 9 | /foo/bar: 10 | get: 11 | summary: get info 12 | operationId: fooBarGet 13 | description: This is description 14 | responses: 15 | '200': 16 | description: Successful response 17 | content: 18 | application/json: 19 | schema: 20 | type: object 21 | properties: 22 | data: 23 | type: array 24 | items: 25 | $ref: '#/components/schemas/OpenAPIResponseAttr' 26 | page: 27 | $ref: '#/components/schemas/PaginationEnvelope/properties/page' 28 | pages: 29 | $ref: '#/components/schemas/PaginationEnvelope/properties/pages' 30 | results: 31 | $ref: '#/components/schemas/PaginationEnvelope/properties/results' 32 | 33 | components: 34 | schemas: 35 | OpenAPIResponseAttr: 36 | type: object 37 | properties: 38 | zone_file: 39 | x-linode-filterable: true 40 | type: array 41 | description: Filterable result value 42 | items: 43 | type: string 44 | 45 | PaginationEnvelope: 46 | type: object 47 | properties: 48 | pages: 49 | type: integer 50 | readOnly: true 51 | description: The total number of pages. 52 | example: 1 53 | page: 54 | type: integer 55 | readOnly: true 56 | description: The current page. 57 | example: 1 58 | results: 59 | type: integer 60 | readOnly: true 61 | description: The total number of results. 62 | example: 1 -------------------------------------------------------------------------------- /tests/fixtures/response_test_get.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.1 2 | info: 3 | title: API Specification 4 | version: 1.0.0 5 | servers: 6 | - url: http://localhost/v4 7 | 8 | paths: 9 | /foo/bar: 10 | get: 11 | summary: get info 12 | operationId: fooBarGet 13 | description: This is description 14 | responses: 15 | '200': 16 | description: Successful response 17 | content: 18 | application/json: 19 | schema: 20 | type: object 21 | properties: 22 | data: 23 | type: array 24 | items: 25 | $ref: '#/components/schemas/OpenAPIResponseAttr' 26 | page: 27 | $ref: '#/components/schemas/PaginationEnvelope/properties/page' 28 | pages: 29 | $ref: '#/components/schemas/PaginationEnvelope/properties/pages' 30 | results: 31 | $ref: '#/components/schemas/PaginationEnvelope/properties/results' 32 | dictLike: 33 | $ref: '#/components/schemas/DictionaryLikeObject' 34 | standard: 35 | $ref: '#/components/schemas/StandardObject' 36 | objectArray: 37 | $ref: '#/components/schemas/ArrayOfObjects' 38 | 39 | components: 40 | schemas: 41 | OpenAPIResponseAttr: 42 | type: object 43 | properties: 44 | foo.bar: 45 | x-linode-filterable: true 46 | type: string 47 | description: Filterable result value 48 | properties: 49 | bar: 50 | x-linode-filterable: true 51 | type: string 52 | description: Filterable result value 53 | PaginationEnvelope: 54 | type: object 55 | properties: 56 | pages: 57 | type: integer 58 | readOnly: true 59 | description: The total number of pages. 60 | example: 1 61 | page: 62 | type: integer 63 | readOnly: true 64 | description: The current page. 65 | example: 1 66 | results: 67 | type: integer 68 | readOnly: true 69 | description: The total number of results. 70 | example: 1 71 | DictionaryLikeObject: 72 | type: object 73 | additionalProperties: 74 | type: string # Arbitrary keys with string values 75 | description: Dictionary with arbitrary keys 76 | StandardObject: 77 | type: object 78 | properties: 79 | key1: 80 | type: string 81 | key2: 82 | type: integer 83 | description: Standard object with defined properties 84 | ArrayOfObjects: 85 | type: array 86 | items: 87 | type: object 88 | properties: 89 | subkey1: 90 | type: string 91 | subkey2: 92 | type: boolean 93 | description: Array of objects 94 | -------------------------------------------------------------------------------- /tests/fixtures/subtable_test_get.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.1 2 | info: 3 | title: API Specification 4 | version: 1.0.0 5 | servers: 6 | - url: http://localhost/v4 7 | 8 | paths: 9 | /foo/bar: 10 | get: 11 | summary: get info with a complex structure 12 | operationId: fooBarGet 13 | description: This is description 14 | responses: 15 | '200': 16 | description: Successful response 17 | content: 18 | application/json: 19 | x-linode-cli-subtables: 20 | - table 21 | - foo.table 22 | - foo.single_nested 23 | schema: 24 | type: object 25 | properties: 26 | table: 27 | type: array 28 | items: 29 | type: object 30 | properties: 31 | foo: 32 | type: string 33 | bar: 34 | type: integer 35 | foo: 36 | type: object 37 | properties: 38 | single_nested: 39 | type: object 40 | properties: 41 | foo: 42 | type: string 43 | bar: 44 | type: string 45 | table: 46 | type: array 47 | items: 48 | type: object 49 | properties: 50 | foobar: 51 | type: array 52 | format: ipv4 53 | items: 54 | type: string 55 | foobar: 56 | type: string -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linode/linode-cli/d2bfe4a0ec67467fdb8edca1b8ba342d95f855cc/tests/integration/__init__.py -------------------------------------------------------------------------------- /tests/integration/beta/test_beta_program.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tests.integration.helpers import assert_headers_in_lines, exec_test_command 4 | 5 | BASE_CMD = ["linode-cli", "betas"] 6 | 7 | 8 | def test_beta_list(): 9 | res = ( 10 | exec_test_command(BASE_CMD + ["list", "--text", "--delimiter=,"]) 11 | .stdout.decode() 12 | .rstrip() 13 | ) 14 | lines = res.splitlines() 15 | 16 | if len(lines) < 2 or len(lines[1].split(",")) == 0: 17 | pytest.skip("No beta program available to test") 18 | else: 19 | headers = ["label", "description"] 20 | assert_headers_in_lines(headers, lines) 21 | 22 | 23 | @pytest.fixture 24 | def get_beta_id(): 25 | beta_ids = ( 26 | exec_test_command( 27 | BASE_CMD 28 | + [ 29 | "list", 30 | "--text", 31 | "--no-headers", 32 | "--delimiter", 33 | ",", 34 | "--format", 35 | "id", 36 | ] 37 | ) 38 | .stdout.decode() 39 | .rstrip() 40 | .splitlines() 41 | ) 42 | if not beta_ids or beta_ids == [""]: 43 | pytest.skip("No betas available to test.") 44 | 45 | first_id = beta_ids[0] 46 | yield first_id 47 | 48 | 49 | def test_beta_view(get_beta_id): 50 | beta_id = get_beta_id 51 | if beta_id is None: 52 | pytest.skip("No beta program available to test") 53 | else: 54 | res = ( 55 | exec_test_command( 56 | BASE_CMD + ["view", beta_id, "--text", "--delimiter=,"] 57 | ) 58 | .stdout.decode() 59 | .rstrip() 60 | ) 61 | lines = res.splitlines() 62 | headers = ["label", "description"] 63 | assert_headers_in_lines(headers, lines) 64 | 65 | 66 | def test_beta_enrolled(): 67 | res = ( 68 | exec_test_command(BASE_CMD + ["enrolled", "--text", "--delimiter=,"]) 69 | .stdout.decode() 70 | .rstrip() 71 | ) 72 | lines = res.splitlines() 73 | 74 | headers = ["label", "enrolled"] 75 | assert_headers_in_lines(headers, lines) 76 | -------------------------------------------------------------------------------- /tests/integration/cli/test_help.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | 3 | import pytest 4 | 5 | from tests.integration.helpers import ( 6 | contains_at_least_one_of, 7 | exec_failing_test_command, 8 | exec_test_command, 9 | ) 10 | 11 | 12 | @pytest.mark.smoke 13 | def test_help_page_for_non_aliased_actions(): 14 | process = exec_test_command(["linode-cli", "linodes", "list", "--help"]) 15 | output = process.stdout.decode() 16 | wrapped_output = textwrap.fill(output, width=180).replace("\n", "") 17 | 18 | assert contains_at_least_one_of( 19 | wrapped_output, ["Linodes List", "List Linodes"] 20 | ) 21 | 22 | assert contains_at_least_one_of( 23 | wrapped_output, 24 | [ 25 | "API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode", 26 | "API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-instances", 27 | ], 28 | ) 29 | 30 | assert "You may filter results with:" in wrapped_output 31 | assert "--tags" in wrapped_output 32 | 33 | 34 | @pytest.mark.smoke 35 | def test_help_page_for_aliased_actions(): 36 | process = exec_test_command(["linode-cli", "linodes", "ls", "--help"]) 37 | output = process.stdout.decode() 38 | wrapped_output = textwrap.fill(output, width=180).replace("\n", "") 39 | 40 | assert contains_at_least_one_of( 41 | wrapped_output, ["Linodes List", "List Linodes"] 42 | ) 43 | 44 | assert contains_at_least_one_of( 45 | wrapped_output, 46 | [ 47 | "API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode", 48 | "API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-instances", 49 | ], 50 | ) 51 | 52 | assert "You may filter results with:" in wrapped_output 53 | assert "--tags" in wrapped_output 54 | 55 | 56 | def test_debug_output_contains_request_url(monkeypatch: pytest.MonkeyPatch): 57 | env_vars = { 58 | "LINODE_CLI_API_HOST": "api.linode.com", 59 | "LINODE_CLI_API_VERSION": "v4", 60 | "LINODE_CLI_API_SCHEME": "https", 61 | } 62 | for key, value in env_vars.items(): 63 | monkeypatch.setenv(key, value) 64 | 65 | output = exec_failing_test_command( 66 | [ 67 | "linode-cli", 68 | "linodes", 69 | "update", 70 | "--label", 71 | "foobar", 72 | "12345", 73 | "--debug", 74 | ] 75 | ).stderr.decode() 76 | wrapped_output = textwrap.fill(output, width=180).replace("\n", "") 77 | 78 | assert ( 79 | "PUT https://api.linode.com/v4/linode/instances/12345" in wrapped_output 80 | ) 81 | -------------------------------------------------------------------------------- /tests/integration/cli/test_host_overrides.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from pytest import MonkeyPatch 4 | 5 | from linodecli.exit_codes import ExitCodes 6 | from tests.integration.helpers import INVALID_HOST, exec_failing_test_command 7 | 8 | 9 | def test_cli_command_fails_to_access_invalid_host(monkeypatch: MonkeyPatch): 10 | monkeypatch.setenv("LINODE_CLI_API_HOST", INVALID_HOST) 11 | 12 | process = exec_failing_test_command( 13 | ["linode-cli", "linodes", "ls"], ExitCodes.UNRECOGNIZED_COMMAND 14 | ) 15 | output = process.stderr.decode() 16 | 17 | expected_output = ["Max retries exceeded with url:", "wrongapi.linode.com"] 18 | 19 | for eo in expected_output: 20 | assert eo in output 21 | 22 | 23 | def test_cli_uses_v4beta_when_override_is_set(monkeypatch: MonkeyPatch): 24 | monkeypatch.setenv("LINODE_CLI_API_VERSION", "v4beta") 25 | os.system("linode-cli linodes ls --debug 2>&1 | tee /tmp/output_file.txt") 26 | 27 | result = os.popen("cat /tmp/output_file.txt").read() 28 | assert "v4beta" in result 29 | 30 | 31 | def test_cli_command_fails_to_access_invalid_api_scheme( 32 | monkeypatch: MonkeyPatch, 33 | ): 34 | monkeypatch.setenv("LINODE_CLI_API_SCHEME", "ssh") 35 | process = exec_failing_test_command( 36 | ["linode-cli", "linodes", "ls"], ExitCodes.UNRECOGNIZED_COMMAND 37 | ) 38 | output = process.stderr.decode() 39 | 40 | assert "ssh://" in output 41 | -------------------------------------------------------------------------------- /tests/integration/domains/test_domains_tags.py: -------------------------------------------------------------------------------- 1 | import re 2 | import time 3 | 4 | import pytest 5 | 6 | from linodecli.exit_codes import ExitCodes 7 | from tests.integration.helpers import ( 8 | delete_tag, 9 | delete_target_id, 10 | exec_failing_test_command, 11 | exec_test_command, 12 | ) 13 | 14 | BASE_CMD = ["linode-cli", "domains"] 15 | 16 | 17 | # @pytest.mark.skip(reason="BUG 943") 18 | def test_fail_to_create_master_domain_with_invalid_tags(): 19 | timestamp = str(time.time_ns()) 20 | bad_tag = ( 21 | "a" * 300 22 | ) # Tag validation rules changed — '*' is no longer rejected 23 | 24 | exec_failing_test_command( 25 | BASE_CMD 26 | + [ 27 | "create", 28 | "--type", 29 | "master", 30 | "--domain", 31 | timestamp + "example.com", 32 | "--soa_email=" + timestamp + "pthiel@linode.com", 33 | "--text", 34 | "--no-header", 35 | "--format=id", 36 | "--tag", 37 | bad_tag, 38 | ], 39 | expected_code=ExitCodes.REQUEST_FAILED, 40 | ) 41 | 42 | 43 | # @pytest.mark.skip(reason="BUG 943") 44 | def test_fail_to_create_slave_domain_with_invalid_tags(): 45 | timestamp = str(time.time_ns()) 46 | bad_tag = "*" 47 | 48 | exec_failing_test_command( 49 | BASE_CMD 50 | + [ 51 | "create", 52 | "--type", 53 | "slave", 54 | "--domain", 55 | timestamp + "example.com", 56 | "--soa_email=" + timestamp + "pthiel@linode.com", 57 | "--text", 58 | "--no-header", 59 | "--format=id", 60 | "--tag", 61 | bad_tag, 62 | ], 63 | expected_code=ExitCodes.REQUEST_FAILED, 64 | ) 65 | 66 | 67 | @pytest.mark.smoke 68 | def test_create_master_domain_with_tags(): 69 | timestamp = str(time.time_ns()) 70 | tag = "foo" 71 | 72 | process = exec_test_command( 73 | BASE_CMD 74 | + [ 75 | "create", 76 | "--type", 77 | "master", 78 | "--domain", 79 | timestamp + "-example.com", 80 | "--soa_email=" + timestamp + "pthiel@linode.com", 81 | "--text", 82 | "--no-header", 83 | "--delimiter=,", 84 | "--format=id,domain,type,status,tags", 85 | "--tag", 86 | tag, 87 | ] 88 | ) 89 | output = process.stdout.decode().rstrip() 90 | assert re.search("[0-9]+,[0-9]+-example.com,master,active," + tag, output) 91 | 92 | res_arr = output.split(",") 93 | domain_id = res_arr[0] 94 | delete_target_id(target="domains", id=domain_id) 95 | 96 | 97 | # @pytest.mark.skip(reason="BUG 943") 98 | def test_delete_domain_and_tag(): 99 | timestamp = str(int(time.time())) 100 | tag = "zoo" 101 | 102 | domain_id = ( 103 | exec_test_command( 104 | BASE_CMD 105 | + [ 106 | "create", 107 | "--type", 108 | "master", 109 | "--domain", 110 | timestamp + "-example.com", 111 | "--soa_email=" + timestamp + "pthiel@linode.com", 112 | "--text", 113 | "--no-header", 114 | "--delimiter=,", 115 | "--format=id", 116 | "--tag", 117 | tag, 118 | ] 119 | ) 120 | .stdout.decode() 121 | .rstrip() 122 | ) 123 | # need to check if tag foo is still present while running this test 124 | result = exec_test_command(["linode-cli", "tags", "list"]).stdout.decode() 125 | 126 | if "zoo" in result: 127 | delete_tag("zoo") 128 | delete_target_id(target="domains", id=domain_id) 129 | -------------------------------------------------------------------------------- /tests/integration/domains/test_master_domains.py: -------------------------------------------------------------------------------- 1 | import re 2 | import time 3 | 4 | import pytest 5 | 6 | from linodecli.exit_codes import ExitCodes 7 | from tests.integration.helpers import ( 8 | delete_target_id, 9 | exec_failing_test_command, 10 | exec_test_command, 11 | ) 12 | 13 | BASE_CMD = ["linode-cli", "domains"] 14 | 15 | 16 | @pytest.fixture 17 | def master_test_domain(): 18 | timestamp = str(time.time_ns()) 19 | # Create domain 20 | master_domain_id = ( 21 | exec_test_command( 22 | BASE_CMD 23 | + [ 24 | "create", 25 | "--type", 26 | "master", 27 | "--domain", 28 | "BC" + timestamp + "-example.com", 29 | "--soa_email=pthiel" + timestamp + "@linode.com", 30 | "--text", 31 | "--no-header", 32 | "--delimiter", 33 | ",", 34 | "--format=id", 35 | ] 36 | ) 37 | .stdout.decode() 38 | .rstrip() 39 | ) 40 | 41 | yield master_domain_id 42 | 43 | delete_target_id(target="domains", id=master_domain_id) 44 | 45 | 46 | def test_create_domain_fails_without_spcified_type(): 47 | timestamp = str(time.time_ns()) 48 | 49 | # get debug output from linode-cli to a temporary file.. 50 | # not all output from the linode-cli goes to stdout, stderr 51 | 52 | result = exec_failing_test_command( 53 | BASE_CMD 54 | + [ 55 | "create", 56 | "--domain", 57 | "example.bc-" + timestamp + ".com", 58 | "--soa_email", 59 | "pthiel@linode.com", 60 | "--text", 61 | "--no-headers", 62 | ], 63 | expected_code=ExitCodes.REQUEST_FAILED, 64 | ).stderr.decode() 65 | 66 | assert "Request failed: 400" in result 67 | assert "type is required" in result 68 | 69 | 70 | def test_create_master_domain_fails_without_soa_email(): 71 | timestamp = str(time.time_ns()) 72 | result = exec_failing_test_command( 73 | BASE_CMD 74 | + [ 75 | "create", 76 | "--type", 77 | "master", 78 | "--domain", 79 | "example.bc-" + timestamp + ".com", 80 | "--text", 81 | "--no-headers", 82 | ], 83 | expected_code=ExitCodes.REQUEST_FAILED, 84 | ).stderr.decode() 85 | 86 | assert "Request failed: 400" in result 87 | assert "soa_email soa_email required when type=master" in result 88 | 89 | 90 | @pytest.mark.smoke 91 | def test_create_master_domain(master_domain): 92 | domain_id = master_domain 93 | assert re.search("[0-9]+", domain_id) 94 | 95 | 96 | def test_update_master_domain_soa_email(master_test_domain): 97 | # Remove --master_ips param when 872 is resolved 98 | timestamp = str(time.time_ns()) 99 | new_soa_email = "pthiel_new@linode.com" 100 | 101 | domain_id = master_test_domain 102 | 103 | result = exec_test_command( 104 | BASE_CMD 105 | + [ 106 | "update", 107 | domain_id, 108 | "--type", 109 | "master", 110 | "--master_ips", 111 | "8.8.8.8", 112 | "--soa_email", 113 | new_soa_email, 114 | "--format=soa_email", 115 | "--text", 116 | "--no-header", 117 | ] 118 | ).stdout.decode() 119 | 120 | assert new_soa_email in result 121 | 122 | 123 | def test_list_master_domain(master_test_domain): 124 | result = exec_test_command( 125 | BASE_CMD 126 | + [ 127 | "list", 128 | "--format=id,domain,type,status", 129 | "--text", 130 | "--no-header", 131 | "--delimiter", 132 | ",", 133 | ] 134 | ).stdout.decode() 135 | 136 | assert re.search("[0-9]+,BC[0-9]+-example.com,master,active", result) 137 | 138 | 139 | def test_show_domain_detail(master_test_domain): 140 | result = exec_test_command( 141 | BASE_CMD 142 | + [ 143 | "list", 144 | "--format=id,domain,type,status", 145 | "--text", 146 | "--no-header", 147 | "--delimiter", 148 | ",", 149 | ] 150 | ).stdout.decode() 151 | 152 | assert re.search("[0-9]+,BC[0-9]+-example.com,master,active", result) 153 | -------------------------------------------------------------------------------- /tests/integration/domains/test_slave_domains.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import time 4 | 5 | import pytest 6 | 7 | from tests.integration.helpers import ( 8 | FAILED_STATUS_CODE, 9 | SUCCESS_STATUS_CODE, 10 | delete_target_id, 11 | exec_test_command, 12 | ) 13 | 14 | BASE_CMD = ["linode-cli", "domains"] 15 | timestamp = str(time.time_ns()) 16 | 17 | 18 | @pytest.fixture 19 | def slave_domain_setup(): 20 | # Create domain 21 | slave_domain_id = ( 22 | exec_test_command( 23 | BASE_CMD 24 | + [ 25 | "create", 26 | "--type", 27 | "slave", 28 | "--domain", 29 | timestamp + "-example.com", 30 | "--master_ips", 31 | "1.1.1.1", 32 | "--text", 33 | "--no-header", 34 | "--delimiter", 35 | ",", 36 | "--format=id", 37 | ] 38 | ) 39 | .stdout.decode() 40 | .rstrip() 41 | ) 42 | 43 | yield slave_domain_id 44 | 45 | delete_target_id(target="domains", id=slave_domain_id) 46 | 47 | 48 | def test_create_slave_domain_fails_without_master_dns_server(): 49 | os.system( 50 | 'linode-cli domains create --type slave --domain "' 51 | + timestamp 52 | + '-example.com" --text --no-header 2>&1 | tee /tmp/test.txt' 53 | ) 54 | result = exec_test_command(["cat", "/tmp/test.txt"]).stdout.decode() 55 | 56 | assert "Request failed: 400" in result 57 | assert ( 58 | "master_ips You need at least one master DNS server IP address for this zone." 59 | in result 60 | ) 61 | 62 | 63 | @pytest.mark.smoke 64 | def test_create_slave_domain(slave_domain): 65 | domain_id = slave_domain 66 | assert re.search("[0-9]+", domain_id) 67 | 68 | 69 | def test_list_slave_domain(slave_domain): 70 | result = exec_test_command( 71 | BASE_CMD + ["list", "--text", "--no-header"] 72 | ).stdout.decode() 73 | assert "-example.com" in result 74 | 75 | 76 | @pytest.mark.skip(reason="BUG 872") 77 | def test_update_domain_fails_without_type(slave_domain_setup): 78 | domain_id = slave_domain_setup 79 | 80 | result = os.system( 81 | "linode-cli domains update " 82 | + domain_id 83 | + ' --master_ips 8.8.8.8 --text --no-header --deleteimiter "," --format "id,domain,type,status"' 84 | ) 85 | 86 | assert result == FAILED_STATUS_CODE 87 | 88 | 89 | def test_update_slave_domain(slave_domain_setup): 90 | domain_id = slave_domain_setup 91 | result = exec_test_command( 92 | BASE_CMD 93 | + [ 94 | "update", 95 | "--type", 96 | "slave", 97 | "--master_ips", 98 | "8.8.8.8", 99 | domain_id, 100 | "--text", 101 | "--no-header", 102 | ] 103 | ) 104 | 105 | assert result.returncode == SUCCESS_STATUS_CODE 106 | -------------------------------------------------------------------------------- /tests/integration/fixture_types.py: -------------------------------------------------------------------------------- 1 | """ 2 | Complicated type alias for fixtures and other stuff. 3 | """ 4 | 5 | from pathlib import Path 6 | from typing import Callable, List, Optional 7 | 8 | GetTestFilesType = Callable[[Optional[int], Optional[str]], List[Path]] 9 | GetTestFileType = Callable[[Optional[str], Optional[str], Optional[int]], Path] 10 | -------------------------------------------------------------------------------- /tests/integration/kernels/test_kernels.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | 5 | from tests.integration.helpers import exec_test_command 6 | 7 | BASE_CMD = ["linode-cli", "kernels", "list", "--text", "--no-headers"] 8 | 9 | 10 | def test_list_available_kernels(): 11 | process = exec_test_command(BASE_CMD + ["--format", "id"]) 12 | output = process.stdout.decode() 13 | 14 | for line in output.splitlines(): 15 | assert "linode" in line 16 | 17 | 18 | def test_fields_from_kernels_list(): 19 | process = exec_test_command( 20 | BASE_CMD 21 | + [ 22 | "--delimiter", 23 | ",", 24 | "--format", 25 | "id,version,kvm,architecture,pvops,deprecated,built", 26 | ] 27 | ) 28 | output = process.stdout.decode() 29 | 30 | for line in output.splitlines(): 31 | assert re.search( 32 | "linode/.*,.*,(False|True),(i386|x86_64),(False|True),(False|True),.*", 33 | line, 34 | ) 35 | 36 | 37 | @pytest.mark.smoke 38 | def test_view_kernel(): 39 | process = exec_test_command(BASE_CMD + ["--format", "id"]) 40 | output = process.stdout.decode() 41 | 42 | lines = output.splitlines() 43 | 44 | process = exec_test_command( 45 | [ 46 | "linode-cli", 47 | "kernels", 48 | "view", 49 | str(lines[0]), 50 | "--format", 51 | "id,version,kvm,architecture,pvops,deprecated,built", 52 | "--text", 53 | "--delimiter", 54 | ",", 55 | ] 56 | ) 57 | output = process.stdout.decode() 58 | 59 | assert "id,version,kvm,architecture,pvops,deprecated,built" in output 60 | 61 | assert re.search( 62 | "linode/.*,.*,(False|True),(i386|x86_64),(False|True),(False|True),.*", 63 | output, 64 | ) 65 | -------------------------------------------------------------------------------- /tests/integration/linodes/test_interfaces.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any, Dict 3 | 4 | import pytest 5 | 6 | from tests.integration.conftest import create_vpc_w_subnet 7 | from tests.integration.helpers import delete_target_id, exec_test_command 8 | from tests.integration.linodes.helpers_linodes import ( 9 | BASE_CMD, 10 | DEFAULT_RANDOM_PASS, 11 | DEFAULT_TEST_IMAGE, 12 | ) 13 | 14 | 15 | @pytest.fixture 16 | def linode_with_vpc_interface_as_json(linode_cloud_firewall): 17 | vpc_json = create_vpc_w_subnet() 18 | 19 | vpc_region = vpc_json["region"] 20 | vpc_id = str(vpc_json["id"]) 21 | subnet_id = int(vpc_json["subnets"][0]["id"]) 22 | 23 | linode_json = json.loads( 24 | exec_test_command( 25 | BASE_CMD 26 | + [ 27 | "create", 28 | "--type", 29 | "g6-nanode-1", 30 | "--region", 31 | vpc_region, 32 | "--image", 33 | DEFAULT_TEST_IMAGE, 34 | "--root_pass", 35 | DEFAULT_RANDOM_PASS, 36 | "--firewall_id", 37 | linode_cloud_firewall, 38 | "--interfaces", 39 | json.dumps( 40 | [ 41 | { 42 | "purpose": "vpc", 43 | "primary": True, 44 | "subnet_id": subnet_id, 45 | "ipv4": {"nat_1_1": "any", "vpc": "10.0.0.5"}, 46 | "ip_ranges": ["10.0.0.6/32"], 47 | }, 48 | {"purpose": "public"}, 49 | ] 50 | ), 51 | "--json", 52 | "--suppress-warnings", 53 | ] 54 | ) 55 | .stdout.decode() 56 | .rstrip() 57 | )[0] 58 | 59 | yield linode_json, vpc_json 60 | 61 | delete_target_id(target="linodes", id=str(linode_json["id"])) 62 | delete_target_id(target="vpcs", id=vpc_id) 63 | 64 | 65 | def assert_interface_configuration( 66 | linode_json: Dict[str, Any], vpc_json: Dict[str, Any] 67 | ): 68 | config_json = json.loads( 69 | exec_test_command( 70 | BASE_CMD 71 | + [ 72 | "configs-list", 73 | str(linode_json["id"]), 74 | "--json", 75 | "--suppress-warnings", 76 | ] 77 | ) 78 | .stdout.decode() 79 | .rstrip() 80 | )[0] 81 | 82 | vpc_interface = config_json["interfaces"][0] 83 | public_interface = config_json["interfaces"][1] 84 | 85 | assert vpc_interface["primary"] 86 | assert vpc_interface["purpose"] == "vpc" 87 | assert vpc_interface["subnet_id"] == vpc_json["subnets"][0]["id"] 88 | assert vpc_interface["vpc_id"] == vpc_json["id"] 89 | assert vpc_interface["ipv4"]["vpc"] == "10.0.0.5" 90 | assert vpc_interface["ipv4"]["nat_1_1"] == linode_json["ipv4"][0] 91 | assert vpc_interface["ip_ranges"][0] == "10.0.0.6/32" 92 | 93 | assert not public_interface["primary"] 94 | assert public_interface["purpose"] == "public" 95 | 96 | 97 | def test_with_vpc_interface_as_json(linode_with_vpc_interface_as_json): 98 | assert_interface_configuration(*linode_with_vpc_interface_as_json) 99 | -------------------------------------------------------------------------------- /tests/integration/linodes/test_power_status.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tests.integration.helpers import ( 4 | delete_target_id, 5 | exec_test_command, 6 | retry_exec_test_command_with_delay, 7 | ) 8 | from tests.integration.linodes.helpers_linodes import ( 9 | BASE_CMD, 10 | create_linode_and_wait, 11 | wait_until, 12 | ) 13 | 14 | 15 | @pytest.fixture 16 | def test_linode_id(linode_cloud_firewall): 17 | linode_id = create_linode_and_wait(firewall_id=linode_cloud_firewall) 18 | 19 | yield linode_id 20 | 21 | delete_target_id(target="linodes", id=linode_id) 22 | 23 | 24 | @pytest.fixture 25 | def linode_in_running_state(linode_cloud_firewall): 26 | linode_id = create_linode_and_wait(firewall_id=linode_cloud_firewall) 27 | 28 | yield linode_id 29 | 30 | delete_target_id("linodes", linode_id) 31 | 32 | 33 | @pytest.fixture 34 | def linode_in_running_state_for_reboot(linode_cloud_firewall): 35 | linode_id = create_linode_and_wait(firewall_id=linode_cloud_firewall) 36 | 37 | yield linode_id 38 | 39 | delete_target_id("linodes", linode_id) 40 | 41 | 42 | @pytest.mark.smoke 43 | def test_create_linode_and_boot(test_linode_id): 44 | linode_id = test_linode_id 45 | 46 | # returns false if status is not running after 240s 47 | result = wait_until(linode_id=linode_id, timeout=240, status="running") 48 | 49 | assert result, "Linode status has not changed to running from provisioning" 50 | 51 | 52 | @pytest.mark.flaky(reruns=3, reruns_delay=2) 53 | def test_reboot_linode(linode_in_running_state_for_reboot): 54 | # create linode and wait until it is in "running" state 55 | linode_id = linode_in_running_state_for_reboot 56 | 57 | # reboot linode from "running" status 58 | retry_exec_test_command_with_delay( 59 | BASE_CMD + ["reboot", linode_id, "--text", "--no-headers"], 3, 20 60 | ) 61 | 62 | assert wait_until( 63 | linode_id=linode_id, timeout=240, status="running" 64 | ), "Linode status has not changed to running from provisioning after reboot" 65 | 66 | 67 | @pytest.mark.flaky(reruns=3, reruns_delay=2) 68 | def test_shutdown_linode(test_linode_id): 69 | linode_id = test_linode_id 70 | 71 | # returns false if status is not running after 240s after reboot 72 | assert wait_until( 73 | linode_id=linode_id, timeout=240, status="running" 74 | ), "Linode status has not changed to running from provisioning" 75 | 76 | # shutdown linode that is in running state 77 | exec_test_command(BASE_CMD + ["shutdown", linode_id]) 78 | 79 | result = wait_until(linode_id=linode_id, timeout=180, status="offline") 80 | 81 | assert result, "Linode status has not changed to running from offline" 82 | -------------------------------------------------------------------------------- /tests/integration/linodes/test_types.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tests.integration.helpers import assert_headers_in_lines, exec_test_command 4 | 5 | 6 | # verifying the DC pricing changes along with types 7 | @pytest.mark.smoke 8 | def test_linode_type(): 9 | output = exec_test_command( 10 | ["linode-cli", "linodes", "types", "--text"] 11 | ).stdout.decode() 12 | 13 | headers = [ 14 | "id", 15 | "label", 16 | "class", 17 | "disk", 18 | "memory", 19 | "vcpus", 20 | "gpus", 21 | "network_out", 22 | "transfer", 23 | "price.hourly", 24 | "price.monthly", 25 | ] 26 | 27 | assert_headers_in_lines(headers, output.splitlines()) 28 | -------------------------------------------------------------------------------- /tests/integration/lke/test_lke_acl.py: -------------------------------------------------------------------------------- 1 | # │ cluster-acl-delete │ Delete the control plane access control list. │ 2 | # │ cluster-acl-update │ Update the control plane access control list. │ 3 | # │ cluster-acl-view │ Get the control plane access control list. │ 4 | import pytest 5 | 6 | from tests.integration.helpers import ( 7 | assert_headers_in_lines, 8 | delete_target_id, 9 | exec_test_command, 10 | get_cluster_id, 11 | get_random_region_with_caps, 12 | get_random_text, 13 | retry_exec_test_command_with_delay, 14 | ) 15 | 16 | BASE_CMD = ["linode-cli", "lke"] 17 | 18 | 19 | @pytest.fixture 20 | def test_lke_cluster_acl(): 21 | label = get_random_text(8) + "_cluster" 22 | 23 | test_region = get_random_region_with_caps( 24 | required_capabilities=["Linodes", "Kubernetes"] 25 | ) 26 | lke_version = ( 27 | exec_test_command( 28 | BASE_CMD 29 | + [ 30 | "versions-list", 31 | "--text", 32 | "--no-headers", 33 | ] 34 | ) 35 | .stdout.decode() 36 | .rstrip() 37 | .splitlines()[0] 38 | ) 39 | 40 | cluster_label = ( 41 | exec_test_command( 42 | BASE_CMD 43 | + [ 44 | "cluster-create", 45 | "--region", 46 | test_region, 47 | "--label", 48 | label, 49 | "--node_pools.type", 50 | "g6-standard-1", 51 | "--node_pools.count", 52 | "1", 53 | "--node_pools.disks", 54 | '[{"type":"ext4","size":1024}]', 55 | "--k8s_version", 56 | lke_version, 57 | "--control_plane.high_availability", 58 | "true", 59 | "--control_plane.acl.enabled", 60 | "true", 61 | "--text", 62 | "--delimiter", 63 | ",", 64 | "--no-headers", 65 | "--format", 66 | "label", 67 | "--no-defaults", 68 | ] 69 | ) 70 | .stdout.decode() 71 | .rstrip() 72 | ) 73 | 74 | cluster_id = get_cluster_id(label=cluster_label) 75 | 76 | yield cluster_id 77 | 78 | delete_target_id( 79 | target="lke", id=cluster_id, delete_command="cluster-delete" 80 | ) 81 | 82 | 83 | def test_cluster_acl_view(test_lke_cluster_acl): 84 | cluster_id = test_lke_cluster_acl 85 | 86 | acl = ( 87 | exec_test_command( 88 | BASE_CMD 89 | + [ 90 | "cluster-acl-view", 91 | cluster_id, 92 | "--text", 93 | ] 94 | ) 95 | .stdout.decode() 96 | .strip() 97 | ) 98 | 99 | headers = [ 100 | "acl.enabled", 101 | "acl.addresses.ipv4", 102 | "acl.addresses.ipv6", 103 | "acl.revision-id", 104 | ] 105 | 106 | assert_headers_in_lines(headers, acl.splitlines()) 107 | 108 | assert "True" in acl 109 | 110 | 111 | def test_cluster_acl_update(test_lke_cluster_acl): 112 | cluster_id = test_lke_cluster_acl 113 | 114 | print("RUNNING TEST") 115 | 116 | # Verify the update 117 | acl = ( 118 | exec_test_command( 119 | BASE_CMD 120 | + [ 121 | "cluster-acl-update", 122 | cluster_id, 123 | "--acl.addresses.ipv4", 124 | "203.0.113.1", 125 | "--acl.addresses.ipv6", 126 | "2001:db8:1234:abcd::/64", 127 | "--acl.enabled", 128 | "true", 129 | "--text", 130 | ] 131 | ) 132 | .stdout.decode() 133 | .strip() 134 | ) 135 | 136 | headers = [ 137 | "acl.enabled", 138 | "acl.addresses.ipv4", 139 | "acl.addresses.ipv6", 140 | "acl.revision-id", 141 | ] 142 | 143 | assert_headers_in_lines(headers, acl.splitlines()) 144 | 145 | assert "203.0.113.1" in acl 146 | assert "2001:db8:1234:abcd::/64" in acl 147 | 148 | 149 | def test_cluster_acl_delete(test_lke_cluster_acl): 150 | cluster_id = test_lke_cluster_acl 151 | 152 | retry_exec_test_command_with_delay( 153 | BASE_CMD + ["cluster-acl-delete", cluster_id] 154 | ) 155 | 156 | # Verify the deletion 157 | acl = ( 158 | exec_test_command( 159 | BASE_CMD 160 | + [ 161 | "cluster-acl-view", 162 | cluster_id, 163 | "--text", 164 | "--format=acl.enabled", 165 | "--text", 166 | ] 167 | ) 168 | .stdout.decode() 169 | .strip() 170 | ) 171 | 172 | assert "False" in acl 173 | -------------------------------------------------------------------------------- /tests/integration/longview/test_longview.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import pytest 4 | 5 | from tests.integration.helpers import ( 6 | assert_headers_in_lines, 7 | delete_target_id, 8 | exec_test_command, 9 | ) 10 | 11 | BASE_CMD = ["linode-cli", "longview"] 12 | 13 | 14 | @pytest.mark.smoke 15 | def test_create_longview_client(): 16 | new_label = str(time.time_ns()) + "label" 17 | result = exec_test_command( 18 | BASE_CMD 19 | + [ 20 | "create", 21 | "--label", 22 | new_label, 23 | "--text", 24 | "--no-headers", 25 | "--delimiter", 26 | ",", 27 | ] 28 | ).stdout.decode() 29 | 30 | assert new_label in result 31 | 32 | 33 | def test_longview_client_list(): 34 | res = ( 35 | exec_test_command(BASE_CMD + ["list", "--text", "--delimiter=,"]) 36 | .stdout.decode() 37 | .rstrip() 38 | ) 39 | lines = res.splitlines() 40 | headers = ["id", "label", "created"] 41 | assert_headers_in_lines(headers, lines) 42 | 43 | 44 | @pytest.fixture 45 | def get_client_id(): 46 | client_id = ( 47 | exec_test_command( 48 | BASE_CMD 49 | + [ 50 | "list", 51 | "--text", 52 | "--no-headers", 53 | "--delimiter", 54 | ",", 55 | "--format", 56 | "id", 57 | ] 58 | ) 59 | .stdout.decode() 60 | .rstrip() 61 | .splitlines() 62 | ) 63 | first_id = client_id[0] 64 | yield first_id 65 | 66 | 67 | def test_client_view(get_client_id): 68 | client_id = get_client_id 69 | res = ( 70 | exec_test_command( 71 | BASE_CMD + ["view", client_id, "--text", "--delimiter=,"] 72 | ) 73 | .stdout.decode() 74 | .rstrip() 75 | ) 76 | lines = res.splitlines() 77 | headers = ["id", "label", "created"] 78 | assert_headers_in_lines(headers, lines) 79 | 80 | 81 | def test_update_longview_client_list(get_client_id): 82 | client_id = get_client_id 83 | new_label = str(time.time_ns()) + "label" 84 | updated_label = ( 85 | exec_test_command( 86 | BASE_CMD 87 | + [ 88 | "update", 89 | client_id, 90 | "--label", 91 | new_label, 92 | "--text", 93 | "--no-headers", 94 | "--format=label", 95 | ] 96 | ) 97 | .stdout.decode() 98 | .rstrip() 99 | ) 100 | assert new_label == updated_label 101 | delete_target_id(target="longview", id=client_id) 102 | 103 | 104 | def test_longview_plan_view(): 105 | res = ( 106 | exec_test_command(BASE_CMD + ["plan-view", "--text", "--delimiter=,"]) 107 | .stdout.decode() 108 | .rstrip() 109 | ) 110 | lines = res.splitlines() 111 | headers = ["id", "label", "clients_included"] 112 | assert_headers_in_lines(headers, lines) 113 | 114 | 115 | def test_longview_subscriptions_list(): 116 | res = ( 117 | exec_test_command( 118 | BASE_CMD + ["subscriptions-list", "--text", "--delimiter=,"] 119 | ) 120 | .stdout.decode() 121 | .rstrip() 122 | ) 123 | lines = res.splitlines() 124 | headers = ["id", "label", "clients_included"] 125 | assert_headers_in_lines(headers, lines) 126 | 127 | 128 | @pytest.fixture 129 | def get_subscriptions_id(): 130 | subscriptions_id = ( 131 | exec_test_command( 132 | BASE_CMD 133 | + [ 134 | "subscriptions-list", 135 | "--text", 136 | "--no-headers", 137 | "--delimiter", 138 | ",", 139 | "--format", 140 | "id", 141 | ] 142 | ) 143 | .stdout.decode() 144 | .rstrip() 145 | .splitlines() 146 | ) 147 | first_id = subscriptions_id[0] 148 | yield first_id 149 | 150 | 151 | def test_longview_subscriptions_list_view(get_subscriptions_id): 152 | subscriptions_id = get_subscriptions_id 153 | res = ( 154 | exec_test_command( 155 | BASE_CMD 156 | + ["subscription-view", subscriptions_id, "--text", "--delimiter=,"] 157 | ) 158 | .stdout.decode() 159 | .rstrip() 160 | ) 161 | lines = res.splitlines() 162 | headers = ["id", "label", "clients_included"] 163 | assert_headers_in_lines(headers, lines) 164 | -------------------------------------------------------------------------------- /tests/integration/obj/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | from dataclasses import dataclass 3 | from typing import Callable, Optional 4 | 5 | import pytest 6 | from pytest import MonkeyPatch 7 | 8 | from linodecli.plugins.obj import ENV_ACCESS_KEY_NAME, ENV_SECRET_KEY_NAME 9 | from tests.integration.helpers import exec_test_command, get_random_text 10 | 11 | REGION = "us-southeast-1" 12 | CLI_CMD = ["linode-cli", "object-storage"] 13 | BASE_CMD = ["linode-cli", "obj", "--cluster", REGION] 14 | 15 | 16 | @dataclass 17 | class Keys: 18 | access_key: str 19 | secret_key: str 20 | 21 | 22 | def patch_keys(keys: Keys, monkeypatch: MonkeyPatch): 23 | assert keys.access_key is not None 24 | assert keys.secret_key is not None 25 | monkeypatch.setenv(ENV_ACCESS_KEY_NAME, keys.access_key) 26 | monkeypatch.setenv(ENV_SECRET_KEY_NAME, keys.secret_key) 27 | 28 | 29 | def delete_bucket(bucket_name: str, force: bool = True): 30 | args = BASE_CMD + ["rb", bucket_name] 31 | if force: 32 | args.append("--recursive") 33 | exec_test_command(args) 34 | return bucket_name 35 | 36 | 37 | @pytest.fixture 38 | def create_bucket( 39 | name_generator: Callable, keys: Keys, monkeypatch: MonkeyPatch 40 | ): 41 | created_buckets = set() 42 | patch_keys(keys, monkeypatch) 43 | 44 | def _create_bucket(bucket_name: Optional[str] = None): 45 | if not bucket_name: 46 | bucket_name = name_generator("test-bk") 47 | 48 | exec_test_command(BASE_CMD + ["mb", bucket_name]) 49 | created_buckets.add(bucket_name) 50 | return bucket_name 51 | 52 | yield _create_bucket 53 | for bk in created_buckets: 54 | try: 55 | delete_bucket(bk) 56 | except Exception as e: 57 | logging.exception(f"Failed to cleanup bucket: {bk}, {e}") 58 | 59 | 60 | @pytest.fixture 61 | def static_site_index(): 62 | return ( 63 | "" 64 | "" 65 | "Hello World" 66 | "" 67 | "

Hello, World!

" 68 | "" 69 | ) 70 | 71 | 72 | @pytest.fixture 73 | def static_site_error(): 74 | return ( 75 | "" 76 | "" 77 | "Error" 78 | "" 79 | "

Error!

" 80 | "" 81 | ) 82 | 83 | 84 | @pytest.fixture(scope="session") 85 | def keys(): 86 | response = json.loads( 87 | exec_test_command( 88 | CLI_CMD 89 | + [ 90 | "keys-create", 91 | "--label", 92 | "cli-integration-test-obj-key", 93 | "--json", 94 | ], 95 | ).stdout.decode() 96 | )[0] 97 | _keys = Keys( 98 | access_key=response.get("access_key"), 99 | secret_key=response.get("secret_key"), 100 | ) 101 | yield _keys 102 | exec_test_command(CLI_CMD + ["keys-delete", str(response.get("id"))]) 103 | 104 | 105 | @pytest.fixture(scope="session") 106 | def test_key(): 107 | label = get_random_text(10) 108 | key = ( 109 | exec_test_command( 110 | CLI_CMD 111 | + [ 112 | "keys-create", 113 | "--label", 114 | label, 115 | "--text", 116 | "--no-headers", 117 | "--format=id", 118 | ] 119 | ) 120 | .stdout.decode() 121 | .strip() 122 | ) 123 | 124 | yield key 125 | 126 | exec_test_command(CLI_CMD + ["keys-delete", key]) 127 | -------------------------------------------------------------------------------- /tests/integration/obj/test_obj_quota.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | 4 | from tests.integration.helpers import exec_test_command 5 | from tests.integration.obj.conftest import CLI_CMD 6 | 7 | 8 | def get_quota_id(): 9 | response = ( 10 | exec_test_command(CLI_CMD + ["get-object-storage-quotas", "--json"]) 11 | .stdout.decode() 12 | .rstrip() 13 | ) 14 | 15 | quotas = json.loads(response) 16 | if not quotas: 17 | return None 18 | 19 | random_quota = random.choice(quotas) 20 | return random_quota["quota_id"] 21 | 22 | 23 | def test_obj_quotas_list(): 24 | response = ( 25 | exec_test_command(CLI_CMD + ["get-object-storage-quotas", "--json"]) 26 | .stdout.decode() 27 | .rstrip() 28 | ) 29 | 30 | quotas = json.loads(response) 31 | 32 | quota = quotas[0] 33 | assert isinstance(quota, dict) 34 | 35 | required_fields = { 36 | "quota_id", 37 | "quota_name", 38 | "endpoint_type", 39 | "s3_endpoint", 40 | "description", 41 | "quota_limit", 42 | "resource_metric", 43 | } 44 | assert required_fields.issubset(quota.keys()) 45 | 46 | 47 | def test_obj_quota_view(): 48 | quota_id = get_quota_id() 49 | 50 | response = ( 51 | exec_test_command( 52 | CLI_CMD + ["get-object-storage-quota", quota_id, "--json"] 53 | ) 54 | .stdout.decode() 55 | .rstrip() 56 | ) 57 | 58 | data = json.loads(response) 59 | 60 | quota = data[0] 61 | assert isinstance(quota, dict) 62 | assert "quota_id" in quota 63 | assert "quota_name" in quota 64 | assert "endpoint_type" in quota 65 | assert "s3_endpoint" in quota 66 | assert "description" in quota 67 | assert "quota_limit" in quota 68 | assert "resource_metric" in quota 69 | 70 | assert isinstance(quota["quota_id"], str) 71 | assert isinstance(quota["quota_name"], str) 72 | assert isinstance(quota["endpoint_type"], str) 73 | assert isinstance(quota["s3_endpoint"], str) 74 | assert isinstance(quota["description"], str) 75 | assert isinstance(quota["quota_limit"], int) 76 | assert isinstance(quota["resource_metric"], str) 77 | 78 | 79 | def test_obj_quota_usage_view(): 80 | quota_id = get_quota_id() 81 | 82 | response = ( 83 | exec_test_command( 84 | CLI_CMD + ["get-object-storage-quota-usage", quota_id, "--json"] 85 | ) 86 | .stdout.decode() 87 | .rstrip() 88 | ) 89 | 90 | data = json.loads(response) 91 | 92 | item = data[0] 93 | assert isinstance(item, dict) 94 | assert "quota_limit" in item 95 | assert "usage" in item 96 | assert isinstance(item["quota_limit"], int) 97 | assert isinstance(item["usage"], int) 98 | -------------------------------------------------------------------------------- /tests/integration/placements/test_placements.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import pytest 4 | 5 | from tests.integration.helpers import ( 6 | assert_headers_in_lines, 7 | delete_target_id, 8 | exec_test_command, 9 | ) 10 | 11 | BASE_CMD = ["linode-cli", "placement"] 12 | 13 | 14 | @pytest.fixture 15 | def create_placement_group(): 16 | new_label = str(time.time_ns()) + "label" 17 | placement_group_id = ( 18 | exec_test_command( 19 | BASE_CMD 20 | + [ 21 | "group-create", 22 | "--label", 23 | new_label, 24 | "--region", 25 | "us-mia", 26 | "--placement_group_type", 27 | "anti_affinity:local", 28 | "--placement_group_policy", 29 | "strict", 30 | "--text", 31 | "--no-headers", 32 | "--format=id", 33 | ] 34 | ) 35 | .stdout.decode() 36 | .rstrip() 37 | ) 38 | yield placement_group_id 39 | delete_target_id( 40 | target="placement", delete_command="group-delete", id=placement_group_id 41 | ) 42 | 43 | 44 | def test_placement_group_list(): 45 | res = ( 46 | exec_test_command(BASE_CMD + ["groups-list", "--text", "--delimiter=,"]) 47 | .stdout.decode() 48 | .rstrip() 49 | ) 50 | lines = res.splitlines() 51 | headers = ["placement_group_type", "region", "label"] 52 | assert_headers_in_lines(headers, lines) 53 | 54 | 55 | def test_placement_group_view(create_placement_group): 56 | placement_group_id = create_placement_group 57 | res = ( 58 | exec_test_command( 59 | BASE_CMD 60 | + ["group-view", placement_group_id, "--text", "--delimiter=,"] 61 | ) 62 | .stdout.decode() 63 | .rstrip() 64 | ) 65 | lines = res.splitlines() 66 | 67 | headers = ["placement_group_type", "region", "label"] 68 | assert_headers_in_lines(headers, lines) 69 | 70 | 71 | @pytest.mark.skip(reason="BUG TPT-3109") 72 | def test_assign_placement_group(support_test_linode_id, create_placement_group): 73 | linode_id = support_test_linode_id 74 | placement_group_id = create_placement_group 75 | res = ( 76 | exec_test_command( 77 | BASE_CMD 78 | + [ 79 | "assign-linode", 80 | placement_group_id, 81 | "--linodes", 82 | linode_id, 83 | "--text", 84 | "--delimiter=,", 85 | ] 86 | ) 87 | .stdout.decode() 88 | .rstrip() 89 | ) 90 | assert placement_group_id in res 91 | 92 | 93 | @pytest.mark.skip(reason="BUG TPT-3109") 94 | def test_unassign_placement_group( 95 | support_test_linode_id, create_placement_group 96 | ): 97 | linode_id = support_test_linode_id 98 | placement_group_id = create_placement_group 99 | res = ( 100 | exec_test_command( 101 | BASE_CMD 102 | + [ 103 | "unassign-linode", 104 | placement_group_id, 105 | "--linode", 106 | linode_id, 107 | "--text", 108 | "--delimiter=,", 109 | ] 110 | ) 111 | .stdout.decode() 112 | .rstrip() 113 | ) 114 | assert placement_group_id not in res 115 | 116 | 117 | def test_update_placement_group(create_placement_group): 118 | placement_group_id = create_placement_group 119 | new_label = str(time.time_ns()) + "label" 120 | updated_label = ( 121 | exec_test_command( 122 | BASE_CMD 123 | + [ 124 | "group-update", 125 | placement_group_id, 126 | "--label", 127 | new_label, 128 | "--text", 129 | "--no-headers", 130 | "--format=label", 131 | ] 132 | ) 133 | .stdout.decode() 134 | .rstrip() 135 | ) 136 | assert new_label == updated_label 137 | -------------------------------------------------------------------------------- /tests/integration/regions/test_plugin_region_table.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | from typing import List 4 | 5 | import pytest 6 | 7 | from tests.integration.helpers import assert_headers_in_lines, exec_test_command 8 | 9 | BASE_CMD = ["linode-cli", "regions"] 10 | 11 | # Set the console width to 150 12 | env = os.environ.copy() 13 | env["COLUMNS"] = "150" 14 | 15 | 16 | def exe_test_command(args: List[str]): 17 | process = subprocess.run( 18 | args, 19 | stdout=subprocess.PIPE, 20 | env=env, 21 | ) 22 | return process 23 | 24 | 25 | def test_output(): 26 | process = exe_test_command(["linode-cli", "region-table"]) 27 | output = process.stdout.decode() 28 | lines = output.split("\n") 29 | lines = lines[3 : len(lines) - 2] 30 | for line in lines: 31 | assert "-" in line 32 | assert "✔" in line 33 | assert "│" in line 34 | 35 | 36 | def test_regions_list(): 37 | res = ( 38 | exec_test_command(BASE_CMD + ["list", "--text", "--delimiter=,"]) 39 | .stdout.decode() 40 | .rstrip() 41 | ) 42 | lines = res.splitlines() 43 | headers = ["label", "country", "capabilities"] 44 | assert_headers_in_lines(headers, lines) 45 | 46 | 47 | @pytest.mark.smoke 48 | def test_regions_list_avail(): 49 | res = ( 50 | exec_test_command(BASE_CMD + ["list-avail", "--text", "--delimiter=,"]) 51 | .stdout.decode() 52 | .rstrip() 53 | ) 54 | lines = res.splitlines() 55 | headers = ["region", "plan", "available"] 56 | assert_headers_in_lines(headers, lines) 57 | 58 | 59 | @pytest.fixture 60 | def get_region_id(): 61 | region_id = ( 62 | exec_test_command( 63 | BASE_CMD 64 | + [ 65 | "list-avail", 66 | "--text", 67 | "--no-headers", 68 | "--delimiter", 69 | ",", 70 | "--format", 71 | "region", 72 | ] 73 | ) 74 | .stdout.decode() 75 | .rstrip() 76 | .splitlines() 77 | ) 78 | first_id = region_id[0] 79 | yield first_id 80 | 81 | 82 | @pytest.mark.smoke 83 | def test_regions_view(get_region_id): 84 | region_id = get_region_id 85 | res = ( 86 | exec_test_command( 87 | BASE_CMD + ["view", region_id, "--text", "--delimiter=,"] 88 | ) 89 | .stdout.decode() 90 | .rstrip() 91 | ) 92 | lines = res.splitlines() 93 | headers = ["label", "country", "capabilities"] 94 | assert_headers_in_lines(headers, lines) 95 | 96 | 97 | def test_regions_view_avail(get_region_id): 98 | region_id = get_region_id 99 | res = ( 100 | exec_test_command( 101 | BASE_CMD + ["view-avail", region_id, "--text", "--delimiter=,"] 102 | ) 103 | .stdout.decode() 104 | .rstrip() 105 | ) 106 | lines = res.splitlines() 107 | headers = ["region", "plan", "available"] 108 | assert_headers_in_lines(headers, lines) 109 | -------------------------------------------------------------------------------- /tests/integration/ssh/test_plugin_ssh.py: -------------------------------------------------------------------------------- 1 | import json 2 | import subprocess 3 | from sys import platform 4 | from typing import Any, Dict, List, Optional 5 | 6 | import pytest 7 | 8 | from tests.integration.helpers import ( 9 | COMMAND_JSON_OUTPUT, 10 | exec_failing_test_command, 11 | get_random_region_with_caps, 12 | get_random_text, 13 | wait_for_condition, 14 | ) 15 | 16 | TEST_REGION = get_random_region_with_caps(required_capabilities=["Linodes"]) 17 | TEST_IMAGE = "linode/ubuntu24.10" 18 | TEST_TYPE = "g6-nanode-1" 19 | TEST_ROOT_PASS = "r00tp@ss!long-long-and-longer" 20 | 21 | BASE_CMD = ["linode-cli", "ssh"] 22 | 23 | 24 | INSTANCE_WAIT_TIMEOUT_SECONDS = 120 25 | SSH_WAIT_TIMEOUT_SECONDS = 80 26 | POLL_INTERVAL = 5 27 | 28 | 29 | @pytest.fixture 30 | def target_instance(ssh_key_pair_generator, linode_cloud_firewall): 31 | instance_label = f"cli-test-{get_random_text(length=6)}" 32 | 33 | pubkey_file, privkey_file = ssh_key_pair_generator 34 | 35 | with open(pubkey_file, "r") as f: 36 | pubkey = f.read().rstrip() 37 | 38 | process = exec_test_command( 39 | [ 40 | "linode-cli", 41 | "linodes", 42 | "create", 43 | "--image", 44 | TEST_IMAGE, 45 | "--region", 46 | TEST_REGION, 47 | "--type", 48 | TEST_TYPE, 49 | "--label", 50 | instance_label, 51 | "--root_pass", 52 | TEST_ROOT_PASS, 53 | "--authorized_keys", 54 | pubkey, 55 | "--firewall_id", 56 | linode_cloud_firewall, 57 | ] 58 | + COMMAND_JSON_OUTPUT 59 | ) 60 | assert process.returncode == 0 61 | instance_json = json.loads(process.stdout.decode())[0] 62 | 63 | yield instance_json 64 | 65 | process = exec_test_command( 66 | ["linode-cli", "linodes", "rm", str(instance_json["id"])] 67 | ) 68 | assert process.returncode == 0 69 | 70 | 71 | def exec_test_command(args: List[str], timeout=None): 72 | process = subprocess.run(args, stdout=subprocess.PIPE, timeout=timeout) 73 | return process 74 | 75 | 76 | @pytest.mark.skipif(platform == "win32", reason="Test N/A on Windows") 77 | def test_help(): 78 | process = exec_test_command(BASE_CMD + ["--help"]) 79 | output = process.stdout.decode() 80 | 81 | assert process.returncode == 0 82 | assert "[USERNAME@]LABEL" in output 83 | assert "uses the Linode's SLAAC address for SSH" in output 84 | 85 | 86 | @pytest.mark.skipif(platform == "win32", reason="Test N/A on Windows") 87 | def test_ssh_instance_provisioning(target_instance: Dict[str, Any]): 88 | process = exec_failing_test_command( 89 | BASE_CMD + ["root@" + target_instance["label"]], expected_code=2 90 | ) 91 | assert process.returncode == 2 92 | output = process.stderr.decode() 93 | 94 | assert "is not running" in output 95 | 96 | 97 | @pytest.mark.smoke 98 | @pytest.mark.skipif(platform == "win32", reason="Test N/A on Windows") 99 | def test_ssh_instance_ready( 100 | ssh_key_pair_generator, target_instance: Dict[str, Any] 101 | ): 102 | pubkey, privkey = ssh_key_pair_generator 103 | 104 | process: Optional[subprocess.CompletedProcess] = None 105 | instance_data = {} 106 | 107 | def instance_poll_func(): 108 | nonlocal instance_data 109 | nonlocal process 110 | 111 | process = exec_test_command( 112 | ["linode-cli", "linodes", "view", str(target_instance["id"])] 113 | + COMMAND_JSON_OUTPUT 114 | ) 115 | assert process.returncode == 0 116 | instance_data = json.loads(process.stdout.decode())[0] 117 | 118 | return instance_data["status"] == "running" 119 | 120 | def ssh_poll_func(): 121 | nonlocal process 122 | process = exec_test_command( 123 | BASE_CMD 124 | + [ 125 | "root@" + instance_data["label"], 126 | "-o", 127 | "StrictHostKeyChecking=no", 128 | "-o", 129 | "IdentitiesOnly=yes", 130 | "-i", 131 | privkey, 132 | "echo 'hello world!'", 133 | ] 134 | ) 135 | return process.returncode == 0 136 | 137 | # Wait for the instance to be ready 138 | wait_for_condition( 139 | POLL_INTERVAL, INSTANCE_WAIT_TIMEOUT_SECONDS, instance_poll_func 140 | ) 141 | 142 | assert process.returncode == 0 143 | assert instance_data["status"] == "running" 144 | 145 | # Wait for SSH to be available 146 | wait_for_condition(POLL_INTERVAL, SSH_WAIT_TIMEOUT_SECONDS, ssh_poll_func) 147 | 148 | assert process.returncode == 0 149 | 150 | # Did we get a response from the instance? 151 | assert "hello world!" in process.stdout.decode() 152 | -------------------------------------------------------------------------------- /tests/integration/ssh/test_ssh.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import time 4 | from sys import platform 5 | 6 | import pytest 7 | 8 | from tests.integration.helpers import delete_target_id, exec_test_command 9 | from tests.integration.linodes.helpers_linodes import create_linode_and_wait 10 | 11 | BASE_CMD = ["linode-cli", "ssh"] 12 | NUM_OF_RETRIES = 10 13 | SSH_SLEEP_PERIOD = 50 14 | 15 | 16 | @pytest.fixture(scope="package") 17 | def linode_in_running_state(ssh_key_pair_generator, linode_cloud_firewall): 18 | pubkey_file, privkey_file = ssh_key_pair_generator 19 | 20 | with open(pubkey_file, "r") as f: 21 | pubkey = f.read().rstrip() 22 | 23 | res = ( 24 | exec_test_command( 25 | [ 26 | "linode-cli", 27 | "images", 28 | "list", 29 | "--format", 30 | "id", 31 | "--text", 32 | "--no-headers", 33 | ] 34 | ) 35 | .stdout.decode() 36 | .rstrip() 37 | ) 38 | 39 | alpine_image = re.findall(r"linode/alpine[^\s]+", res)[0] 40 | 41 | plan = ( 42 | exec_test_command( 43 | [ 44 | "linode-cli", 45 | "linodes", 46 | "types", 47 | "--format", 48 | "id", 49 | "--text", 50 | "--no-headers", 51 | ] 52 | ) 53 | .stdout.decode() 54 | .rstrip() 55 | .splitlines()[0] 56 | ) 57 | 58 | linode_id = create_linode_and_wait( 59 | test_plan=plan, 60 | test_image=alpine_image, 61 | ssh_key=pubkey, 62 | firewall_id=linode_cloud_firewall, 63 | ) 64 | 65 | yield linode_id 66 | delete_target_id(target="linodes", id=linode_id) 67 | 68 | 69 | @pytest.mark.skipif(platform == "win32", reason="Test N/A on Windows") 70 | def test_display_ssh_plugin_usage_info(): 71 | result = exec_test_command(BASE_CMD + ["-h"]).stdout.decode() 72 | assert "[USERNAME@]LABEL" in result 73 | assert "uses the Linode's SLAAC address for SSH" in result 74 | 75 | 76 | @pytest.mark.skipif(platform == "win32", reason="Test N/A on Windows") 77 | def test_fail_to_ssh_to_nonexistent_linode(): 78 | os.system("linode-cli ssh root@aasdkjlf 2>&1 | tee /tmp/output_file.txt") 79 | 80 | result = os.popen("cat /tmp/output_file.txt").read().rstrip() 81 | 82 | assert "No Linode found for label aasdkjlf" in result 83 | 84 | 85 | @pytest.mark.skipif(platform == "win32", reason="Test N/A on Windows") 86 | def test_ssh_to_linode_and_get_kernel_version( 87 | linode_in_running_state, ssh_key_pair_generator 88 | ): 89 | linode_id = linode_in_running_state 90 | pubkey_file, privkey_file = ssh_key_pair_generator 91 | 92 | linode_label = ( 93 | exec_test_command( 94 | [ 95 | "linode-cli", 96 | "linodes", 97 | "list", 98 | "--id", 99 | linode_id, 100 | "--format", 101 | "label", 102 | "--text", 103 | "--no-headers", 104 | ] 105 | ) 106 | .stdout.decode() 107 | .rstrip() 108 | ) 109 | 110 | time.sleep(SSH_SLEEP_PERIOD) 111 | 112 | output = os.popen( 113 | "linode-cli ssh root@" 114 | + linode_label 115 | + " -i " 116 | + privkey_file 117 | + " -o StrictHostKeyChecking=no -o IdentitiesOnly=yes uname -r" 118 | ).read() 119 | 120 | assert re.search(r"[0-9]\.[0-9]*\.[0-9]*-.*-virt", output) 121 | 122 | 123 | @pytest.mark.skipif(platform == "win32", reason="Test N/A on Windows") 124 | def test_check_vm_for_ipv4_connectivity( 125 | linode_in_running_state, ssh_key_pair_generator 126 | ): 127 | pubkey_file, privkey_file = ssh_key_pair_generator 128 | linode_id = linode_in_running_state 129 | linode_label = ( 130 | exec_test_command( 131 | [ 132 | "linode-cli", 133 | "linodes", 134 | "list", 135 | "--id", 136 | linode_id, 137 | "--format", 138 | "label", 139 | "--text", 140 | "--no-headers", 141 | ] 142 | ) 143 | .stdout.decode() 144 | .rstrip() 145 | ) 146 | 147 | time.sleep(SSH_SLEEP_PERIOD) 148 | 149 | output = os.popen( 150 | "linode-cli ssh root@" 151 | + linode_label 152 | + " -i " 153 | + privkey_file 154 | + ' -o StrictHostKeyChecking=no -o IdentitiesOnly=yes "ping -4 -W60 -c3 google.com"' 155 | ).read() 156 | 157 | assert "0% packet loss" in output 158 | -------------------------------------------------------------------------------- /tests/integration/support/test_support.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tests.integration.helpers import assert_headers_in_lines, exec_test_command 4 | 5 | BASE_CMD = ["linode-cli", "tickets"] 6 | 7 | 8 | # this will create a support ticket on your account 9 | @pytest.mark.skip(reason="this will create a support ticket") 10 | def test_create_support_ticket(support_test_linode_id): 11 | linode_id = support_test_linode_id 12 | exec_test_command( 13 | BASE_CMD 14 | + [ 15 | "create", 16 | "--description", 17 | "Creating support ticket for test verification", 18 | "--linode_id", 19 | linode_id, 20 | "--summary", 21 | "Testing ticket" "--text", 22 | "--no-headers", 23 | ] 24 | ) 25 | 26 | 27 | def test_tickets_list(): 28 | res = ( 29 | exec_test_command(BASE_CMD + ["list", "--text", "--delimiter=,"]) 30 | .stdout.decode() 31 | .rstrip() 32 | ) 33 | lines = res.splitlines() 34 | headers = ["summary", "opened_by", "opened"] 35 | assert_headers_in_lines(headers, lines) 36 | 37 | 38 | @pytest.fixture 39 | def tickets_id(): 40 | res = ( 41 | exec_test_command( 42 | BASE_CMD 43 | + [ 44 | "list", 45 | "--text", 46 | "--no-headers", 47 | "--delimiter", 48 | ",", 49 | "--format", 50 | "id", 51 | ] 52 | ) 53 | .stdout.decode() 54 | .rstrip() 55 | ) 56 | ticket_ids = res.splitlines() 57 | if not ticket_ids or ticket_ids == [""]: 58 | pytest.skip("No support tickets available to test.") 59 | first_id = ticket_ids[0] 60 | yield first_id 61 | 62 | 63 | def test_tickets_view(tickets_id): 64 | if not tickets_id: 65 | pytest.skip("No support tickets available to view.") 66 | 67 | ticket_id = tickets_id 68 | res = ( 69 | exec_test_command( 70 | BASE_CMD + ["view", ticket_id, "--text", "--delimiter=,"] 71 | ) 72 | .stdout.decode() 73 | .rstrip() 74 | ) 75 | lines = res.splitlines() 76 | headers = ["summary", "opened_by", "opened"] 77 | assert_headers_in_lines(headers, lines) 78 | 79 | 80 | @pytest.mark.skip( 81 | reason="Creation of tickets are skipped no way of currently testing this" 82 | ) 83 | def test_reply_support_ticket(tickets_id): 84 | ticket_id = tickets_id 85 | exec_test_command( 86 | BASE_CMD 87 | + [ 88 | "reply", 89 | ticket_id, 90 | "--description", 91 | "test reply on the support ticket", 92 | "--text", 93 | "--no-headers", 94 | ] 95 | ) 96 | 97 | 98 | def test_view_replies_support_ticket(tickets_id): 99 | if not tickets_id: 100 | pytest.skip("No support tickets available to view replies.") 101 | 102 | ticket_id = tickets_id 103 | res = ( 104 | exec_test_command( 105 | BASE_CMD + ["replies", ticket_id, "--text", "--delimiter=,"] 106 | ) 107 | .stdout.decode() 108 | .rstrip() 109 | ) 110 | lines = res.splitlines() 111 | headers = ["created_by", "created"] 112 | assert_headers_in_lines(headers, lines) 113 | -------------------------------------------------------------------------------- /tests/integration/tags/test_tags.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import pytest 4 | 5 | from linodecli.exit_codes import ExitCodes 6 | from tests.integration.helpers import ( 7 | delete_target_id, 8 | exec_failing_test_command, 9 | exec_test_command, 10 | ) 11 | 12 | BASE_CMD = ["linode-cli", "tags"] 13 | unique_tag = str(int(time.time())) + "-tag" 14 | 15 | 16 | def test_display_tags(): 17 | exec_test_command(BASE_CMD + ["list"]) 18 | 19 | 20 | @pytest.fixture(scope="session") 21 | def test_tag_instance(): 22 | exec_test_command( 23 | BASE_CMD + ["create", "--label", unique_tag, "--text", "--no-headers"] 24 | ).stdout.decode() 25 | 26 | yield unique_tag 27 | 28 | delete_target_id("tags", unique_tag) 29 | 30 | 31 | @pytest.mark.smoke 32 | def test_view_unique_tag(test_tag_instance): 33 | result = exec_test_command( 34 | BASE_CMD + ["list", "--text", "--no-headers"] 35 | ).stdout.decode() 36 | assert test_tag_instance in result 37 | 38 | 39 | @pytest.mark.skip(reason="BUG = TPT-3650") 40 | def test_fail_to_create_tag_shorter_than_three_char(): 41 | bad_tag = "aa" 42 | result = exec_failing_test_command( 43 | BASE_CMD + ["create", "--label", bad_tag, "--text", "--no-headers"], 44 | ExitCodes.REQUEST_FAILED, 45 | ).stderr.decode() 46 | assert "Request failed: 400" in result 47 | assert "Length must be 3-50 characters" in result 48 | -------------------------------------------------------------------------------- /tests/integration/users/test_profile.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tests.integration.helpers import assert_headers_in_lines, exec_test_command 4 | 5 | BASE_CMD = ["linode-cli", "profile"] 6 | 7 | 8 | def test_profile_view(): 9 | res = ( 10 | exec_test_command(BASE_CMD + ["view", "--text", "--delimiter=,"]) 11 | .stdout.decode() 12 | .rstrip() 13 | ) 14 | lines = res.splitlines() 15 | headers = ["username", "email", "restricted"] 16 | assert_headers_in_lines(headers, lines) 17 | 18 | 19 | def test_profile_apps_list(): 20 | res = ( 21 | exec_test_command(BASE_CMD + ["apps-list", "--text", "--delimiter=,"]) 22 | .stdout.decode() 23 | .rstrip() 24 | ) 25 | lines = res.splitlines() 26 | headers = ["label", "scopes", "website"] 27 | assert_headers_in_lines(headers, lines) 28 | 29 | 30 | def test_profile_devices_list(): 31 | res = ( 32 | exec_test_command( 33 | BASE_CMD + ["devices-list", "--text", "--delimiter=,"] 34 | ) 35 | .stdout.decode() 36 | .rstrip() 37 | ) 38 | lines = res.splitlines() 39 | headers = ["created", "expiry", "user_agent"] 40 | assert_headers_in_lines(headers, lines) 41 | 42 | 43 | @pytest.fixture 44 | def login_ids(): 45 | login_id = ( 46 | exec_test_command( 47 | BASE_CMD 48 | + [ 49 | "logins-list", 50 | "--text", 51 | "--no-headers", 52 | "--delimiter", 53 | ",", 54 | "--format", 55 | "id", 56 | ] 57 | ) 58 | .stdout.decode() 59 | .rstrip() 60 | .splitlines() 61 | ) 62 | first_login_id = login_id[0] 63 | yield first_login_id 64 | 65 | 66 | def test_profile_login_list(): 67 | res = ( 68 | exec_test_command(BASE_CMD + ["logins-list", "--text", "--delimiter=,"]) 69 | .stdout.decode() 70 | .rstrip() 71 | ) 72 | lines = res.splitlines() 73 | headers = ["datetime", "username", "status"] 74 | assert_headers_in_lines(headers, lines) 75 | 76 | 77 | def test_profile_login_view(login_ids): 78 | login_id = login_ids 79 | res = ( 80 | exec_test_command( 81 | BASE_CMD + ["login-view", login_id, "--text", "--delimiter=,"] 82 | ) 83 | .stdout.decode() 84 | .rstrip() 85 | ) 86 | lines = res.splitlines() 87 | headers = ["datetime", "username", "status"] 88 | assert_headers_in_lines(headers, lines) 89 | 90 | 91 | def test_security_questions_list(): 92 | res = ( 93 | exec_test_command( 94 | [ 95 | "linode-cli", 96 | "security-questions", 97 | "list", 98 | "--text", 99 | "--delimiter=,", 100 | ] 101 | ) 102 | .stdout.decode() 103 | .rstrip() 104 | ) 105 | lines = res.splitlines() 106 | headers = ["security_questions.id", "security_questions.question"] 107 | assert_headers_in_lines(headers, lines) 108 | 109 | 110 | def test_profile_token_list(): 111 | res = ( 112 | exec_test_command(BASE_CMD + ["tokens-list", "--text", "--delimiter=,"]) 113 | .stdout.decode() 114 | .rstrip() 115 | ) 116 | lines = res.splitlines() 117 | headers = ["label", "scopes", "token"] 118 | assert_headers_in_lines(headers, lines) 119 | 120 | 121 | def test_sshkeys_list(): 122 | res = ( 123 | exec_test_command( 124 | [ 125 | "linode-cli", 126 | "sshkeys", 127 | "list", 128 | "--text", 129 | "--delimiter=,", 130 | ] 131 | ) 132 | .stdout.decode() 133 | .rstrip() 134 | ) 135 | lines = res.splitlines() 136 | headers = ["label", "ssh_key"] 137 | assert_headers_in_lines(headers, lines) 138 | -------------------------------------------------------------------------------- /tests/integration/users/test_users.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import pytest 4 | 5 | from tests.integration.helpers import exec_test_command 6 | 7 | BASE_CMD = ["linode-cli", "users"] 8 | unique_user = "test-user-" + str(int(time.time())) 9 | 10 | 11 | @pytest.fixture(scope="package", autouse=True) 12 | def teardown_fixture(): 13 | yield "setup" 14 | remove_users() 15 | 16 | 17 | def remove_users(): 18 | exec_test_command(BASE_CMD + ["delete", unique_user]) 19 | 20 | 21 | @pytest.fixture 22 | def test_create_user(): 23 | exec_test_command( 24 | BASE_CMD 25 | + [ 26 | "create", 27 | "--username", 28 | unique_user, 29 | "--email", 30 | unique_user + "@linode.com", 31 | "--restricted", 32 | "true", 33 | "--text", 34 | "--no-headers", 35 | ] 36 | ) 37 | 38 | 39 | def test_display_users(): 40 | exec_test_command(BASE_CMD + ["list"]) 41 | 42 | 43 | @pytest.mark.smoke 44 | @pytest.mark.usefixtures("test_create_user") 45 | def test_view_user(): 46 | exec_test_command(BASE_CMD + ["view", unique_user]) 47 | -------------------------------------------------------------------------------- /tests/integration/vlans/test_vlans.py: -------------------------------------------------------------------------------- 1 | from tests.integration.helpers import assert_headers_in_lines, exec_test_command 2 | 3 | BASE_CMD = ["linode-cli", "vlans"] 4 | 5 | 6 | def test_list_vlans(): 7 | types = ( 8 | exec_test_command( 9 | BASE_CMD 10 | + [ 11 | "ls", 12 | "--text", 13 | ] 14 | ) 15 | .stdout.decode() 16 | .rstrip() 17 | ) 18 | 19 | headers = ["region", "label", "linodes"] 20 | lines = types.splitlines() 21 | 22 | assert_headers_in_lines(headers, lines) 23 | 24 | 25 | def test_list_vlans_help_menu(): 26 | help_menu = ( 27 | exec_test_command( 28 | BASE_CMD 29 | + [ 30 | "ls", 31 | "--h", 32 | ] 33 | ) 34 | .stdout.decode() 35 | .rstrip() 36 | ) 37 | 38 | assert "linode-cli vlans ls" in help_menu 39 | assert ( 40 | "https://techdocs.akamai.com/linode-api/reference/get-vlans" 41 | in help_menu 42 | ) 43 | 44 | 45 | def test_delete_vlans_help_menu(): 46 | help_menu = ( 47 | exec_test_command( 48 | BASE_CMD 49 | + [ 50 | "delete", 51 | "--h", 52 | ] 53 | ) 54 | .stdout.decode() 55 | .rstrip() 56 | ) 57 | 58 | assert "linode-cli vlans delete [LABEL] [REGIONID]" in help_menu 59 | assert ( 60 | "https://techdocs.akamai.com/linode-api/reference/delete-vlan" 61 | in help_menu 62 | ) 63 | -------------------------------------------------------------------------------- /tests/integration/volumes/test_volumes_resize.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | 4 | import pytest 5 | 6 | from linodecli.exit_codes import ExitCodes 7 | from tests.integration.helpers import ( 8 | delete_target_id, 9 | exec_failing_test_command, 10 | exec_test_command, 11 | ) 12 | 13 | BASE_CMD = ["linode-cli", "volumes"] 14 | timestamp = str(time.time_ns()) 15 | VOLUME_CREATION_WAIT = 5 16 | 17 | 18 | @pytest.fixture(scope="package") 19 | def test_volume_id(): 20 | volume_id = ( 21 | exec_test_command( 22 | BASE_CMD 23 | + [ 24 | "create", 25 | "--label", 26 | "A" + timestamp, 27 | "--region", 28 | "us-ord", 29 | "--size", 30 | "10", 31 | "--text", 32 | "--no-headers", 33 | "--delimiter", 34 | ",", 35 | "--format", 36 | "id", 37 | ] 38 | ) 39 | .stdout.decode() 40 | .rstrip() 41 | ) 42 | 43 | yield volume_id 44 | 45 | delete_target_id(target="volumes", id=volume_id) 46 | 47 | 48 | def test_resize_fails_to_smaller_volume(test_volume_id): 49 | volume_id = test_volume_id 50 | time.sleep(VOLUME_CREATION_WAIT) 51 | result = exec_failing_test_command( 52 | BASE_CMD 53 | + ["resize", volume_id, "--size", "5", "--text", "--no-headers"], 54 | ExitCodes.REQUEST_FAILED, 55 | ).stderr.decode() 56 | 57 | assert "Request failed: 400" in result 58 | assert "Storage volumes can only be resized up" in result 59 | 60 | 61 | def test_resize_fails_to_volume_larger_than_1024gb(test_volume_id): 62 | volume_id = test_volume_id 63 | result = exec_failing_test_command( 64 | BASE_CMD 65 | + [ 66 | "resize", 67 | volume_id, 68 | "--size", 69 | "1024893405", 70 | "--text", 71 | "--no-headers", 72 | ], 73 | ExitCodes.REQUEST_FAILED, 74 | ).stderr.decode() 75 | 76 | if "test" == os.environ.get( 77 | "TEST_ENVIRONMENT", None 78 | ) or "dev" == os.environ.get("TEST_ENVIRONMENT", None): 79 | assert ( 80 | "Storage volumes cannot be resized larger than 1024 gigabytes" 81 | in result 82 | ) 83 | else: 84 | assert ( 85 | "Storage volumes cannot be resized larger than 16384 gigabytes" 86 | in result 87 | ) 88 | 89 | 90 | def test_resize_volume(test_volume_id): 91 | volume_id = test_volume_id 92 | 93 | exec_test_command( 94 | BASE_CMD 95 | + ["resize", volume_id, "--size", "11", "--text", "--no-headers"] 96 | ) 97 | 98 | result = exec_test_command( 99 | BASE_CMD 100 | + ["view", volume_id, "--format", "size", "--text", "--no-headers"] 101 | ).stdout.decode() 102 | 103 | assert "11" in result 104 | -------------------------------------------------------------------------------- /tests/integration/vpc/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linode/linode-cli/d2bfe4a0ec67467fdb8edca1b8ba342d95f855cc/tests/integration/vpc/__init__.py -------------------------------------------------------------------------------- /tests/integration/vpc/conftest.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import pytest 4 | 5 | from tests.integration.conftest import create_vpc_w_subnet 6 | from tests.integration.helpers import ( 7 | delete_target_id, 8 | exec_test_command, 9 | get_random_region_with_caps, 10 | ) 11 | 12 | 13 | @pytest.fixture 14 | def test_vpc_w_subnet(): 15 | vpc_json = create_vpc_w_subnet() 16 | vpc_id = str(vpc_json["id"]) 17 | 18 | yield vpc_id 19 | 20 | delete_target_id(target="vpcs", id=vpc_id) 21 | 22 | 23 | @pytest.fixture 24 | def test_vpc_wo_subnet(): 25 | region = get_random_region_with_caps(required_capabilities=["VPCs"]) 26 | 27 | label = str(time.time_ns()) + "label" 28 | 29 | vpc_id = ( 30 | exec_test_command( 31 | [ 32 | "linode-cli", 33 | "vpcs", 34 | "create", 35 | "--label", 36 | label, 37 | "--region", 38 | region, 39 | "--no-headers", 40 | "--text", 41 | "--format=id", 42 | ] 43 | ) 44 | .stdout.decode() 45 | .rstrip() 46 | ) 47 | 48 | yield vpc_id 49 | 50 | delete_target_id(target="vpcs", id=vpc_id) 51 | 52 | 53 | @pytest.fixture 54 | def test_subnet(test_vpc_wo_subnet): 55 | vpc_id = test_vpc_wo_subnet 56 | subnet_label = str(time.time_ns()) + "label" 57 | res = ( 58 | exec_test_command( 59 | [ 60 | "linode-cli", 61 | "vpcs", 62 | "subnet-create", 63 | "--label", 64 | subnet_label, 65 | "--ipv4", 66 | "10.0.0.0/24", 67 | vpc_id, 68 | "--text", 69 | "--no-headers", 70 | "--delimiter=,", 71 | ] 72 | ) 73 | .stdout.decode() 74 | .rstrip() 75 | ) 76 | 77 | yield res, subnet_label 78 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linode/linode-cli/d2bfe4a0ec67467fdb8edca1b8ba342d95f855cc/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/test_completion.py: -------------------------------------------------------------------------------- 1 | #!/usr/local/bin/python3 2 | """ 3 | Unit tests for linodecli.completion 4 | """ 5 | 6 | from linodecli import completion 7 | 8 | 9 | class TestCompletion: 10 | """ 11 | Unit tests for linodecli.completion 12 | """ 13 | 14 | ops = {"temp_key": {"temp_action": "description"}} 15 | fish_expected = """# This is a generated file by Linode-CLI! Do not modify! 16 | complete -c linode-cli -n "not __fish_seen_subcommand_from temp_key" -x -a 'temp_key --help' 17 | complete -c linode -n "not __fish_seen_subcommand_from temp_key" -x -a 'temp_key --help' 18 | complete -c lin -n "not __fish_seen_subcommand_from temp_key" -x -a 'temp_key --help' 19 | complete -c linode-cli -n "__fish_seen_subcommand_from temp_key" -x -a 'temp_action --help' 20 | complete -c linode -n "__fish_seen_subcommand_from temp_key" -x -a 'temp_action --help' 21 | complete -c lin -n "__fish_seen_subcommand_from temp_key" -x -a 'temp_action --help'""" 22 | bash_expected = """# This is a generated file by Linode-CLI! Do not modify! 23 | _linode_cli() 24 | { 25 | local cur prev opts 26 | COMPREPLY=() 27 | cur="${COMP_WORDS[COMP_CWORD]}" 28 | prev="${COMP_WORDS[COMP_CWORD-1]}" 29 | 30 | case "${prev}" in 31 | linode-cli | linode | lin) 32 | COMPREPLY=( $(compgen -W "temp_key --help" -- ${cur}) ) 33 | return 0 34 | ;; 35 | temp_key) 36 | COMPREPLY=( $(compgen -W "temp_action --help" -- ${cur}) ) 37 | return 0 38 | ;; 39 | *) 40 | ;; 41 | esac 42 | } 43 | 44 | complete -F _linode_cli linode-cli 45 | complete -F _linode_cli linode 46 | complete -F _linode_cli lin""" 47 | 48 | def test_fish_completion(self, mocker): 49 | """ 50 | Test if the fish completion renders correctly 51 | """ 52 | actual = completion.get_fish_completions(self.ops) 53 | assert actual == self.fish_expected 54 | 55 | def test_bash_completion(self, mocker): 56 | """ 57 | Test if the bash completion renders correctly 58 | """ 59 | # mocker = mocker.patch('linodecli-completion.get_bash_completions', return_value=self.bash_expected) 60 | actual = completion.get_bash_completions(self.ops) 61 | assert actual == self.bash_expected 62 | 63 | def test_get_completions(self): 64 | """ 65 | Test get_completions for arg parse 66 | """ 67 | actual = completion.get_completions(self.ops, False, "bash") 68 | assert actual == self.bash_expected 69 | 70 | actual = completion.get_completions(self.ops, False, "fish") 71 | assert actual == self.fish_expected 72 | 73 | actual = completion.get_completions(self.ops, False, "notrealshell") 74 | assert "invoke" in actual 75 | 76 | actual = completion.get_completions(self.ops, True, "") 77 | assert "[SHELL]" in actual 78 | -------------------------------------------------------------------------------- /tests/unit/test_helpers.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser 2 | 3 | from linodecli.helpers import ( 4 | register_args_shared, 5 | register_pagination_args_shared, 6 | ) 7 | 8 | 9 | class TestHelpers: 10 | """ 11 | Unit tests for linodecli.helpers 12 | """ 13 | 14 | def test_pagination_args_shared(self): 15 | parser = ArgumentParser() 16 | register_pagination_args_shared(parser) 17 | 18 | args = parser.parse_args( 19 | ["--page", "2", "--page-size", "50", "--all-rows"] 20 | ) 21 | assert args.page == 2 22 | assert args.page_size == 50 23 | assert args.all_rows 24 | 25 | def test_register_args_shared(self): 26 | parser = ArgumentParser() 27 | register_args_shared(parser) 28 | 29 | args = parser.parse_args( 30 | ["--as-user", "linode-user", "--suppress-warnings"] 31 | ) 32 | assert args.as_user == "linode-user" 33 | assert args.suppress_warnings 34 | -------------------------------------------------------------------------------- /tests/unit/test_overrides.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import io 3 | from unittest.mock import patch 4 | 5 | from linodecli import OutputMode 6 | from linodecli.overrides import OUTPUT_OVERRIDES 7 | 8 | 9 | class TestOverrides: 10 | """ 11 | Unit tests for linodecli.overrides 12 | """ 13 | 14 | def test_domains_zone_file( 15 | self, mock_cli, list_operation_for_overrides_test 16 | ): 17 | response_json = {"zone_file": ["line 1", "line 2"]} 18 | override_signature = ("domains", "zone-file", OutputMode.delimited) 19 | 20 | list_operation_for_overrides_test.command = "domains" 21 | list_operation_for_overrides_test.action = "zone-file" 22 | mock_cli.output_handler.mode = OutputMode.delimited 23 | 24 | stdout_buf = io.StringIO() 25 | 26 | with contextlib.redirect_stdout(stdout_buf): 27 | list_operation_for_overrides_test.process_response_json( 28 | response_json, mock_cli.output_handler 29 | ) 30 | 31 | assert stdout_buf.getvalue() == "line 1\nline 2\n" 32 | 33 | # Validate that the override will continue execution if it returns true 34 | def patch_func(*a): 35 | OUTPUT_OVERRIDES[override_signature](*a) 36 | return True 37 | 38 | with ( 39 | patch( 40 | "linodecli.baked.operation.OUTPUT_OVERRIDES", 41 | {override_signature: patch_func}, 42 | ), 43 | patch.object(mock_cli.output_handler, "print") as p, 44 | ): 45 | list_operation_for_overrides_test.process_response_json( 46 | response_json, mock_cli.output_handler 47 | ) 48 | assert p.called 49 | 50 | # Change the action to bypass the override 51 | stdout_buf = io.StringIO() 52 | 53 | list_operation_for_overrides_test.action = "zone-notfile" 54 | mock_cli.output_handler.mode = OutputMode.delimited 55 | 56 | with contextlib.redirect_stdout(stdout_buf): 57 | list_operation_for_overrides_test.process_response_json( 58 | response_json, mock_cli.output_handler 59 | ) 60 | 61 | assert stdout_buf.getvalue() != "line 1\nline 2\n" 62 | 63 | def test_types_region_prices_list( 64 | self, mock_cli, list_operation_for_overrides_test 65 | ): 66 | response_json = { 67 | "data": [ 68 | { 69 | "addons": { 70 | "backups": { 71 | "price": {"hourly": 0.008, "monthly": 5}, 72 | "region_prices": [ 73 | { 74 | "hourly": 0.0096, 75 | "id": "us-east", 76 | "monthly": 6, 77 | } 78 | ], 79 | } 80 | }, 81 | "class": "standard", 82 | "disk": 81920, 83 | "gpus": 0, 84 | "id": "g6-standard-2", 85 | "label": "Linode 4GB", 86 | "memory": 4096, 87 | "network_out": 1000, 88 | "price": {"hourly": 0.03, "monthly": 20}, 89 | "region_prices": [ 90 | {"hourly": 0.036, "id": "us-east", "monthly": 24} 91 | ], 92 | "successor": None, 93 | "transfer": 4000, 94 | "vcpus": 2, 95 | } 96 | ], 97 | "page": 1, 98 | "pages": 1, 99 | "results": 1, 100 | } 101 | 102 | override_signature = ("linodes", "types", OutputMode.table) 103 | 104 | list_operation_for_overrides_test.command = "linodes" 105 | list_operation_for_overrides_test.action = "types" 106 | mock_cli.output_handler.mode = OutputMode.table 107 | 108 | stdout_buf = io.StringIO() 109 | 110 | with contextlib.redirect_stdout(stdout_buf): 111 | list_operation_for_overrides_test.process_response_json( 112 | response_json, mock_cli.output_handler 113 | ) 114 | 115 | rows = stdout_buf.getvalue().split("\n") 116 | # assert that the overridden table has the new columns 117 | assert len(rows[1].split(rows[1][0])) == 15 118 | -------------------------------------------------------------------------------- /tests/unit/test_plugin_image_upload.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | from unittest.mock import patch 3 | 4 | import pytest 5 | from pytest import CaptureFixture 6 | 7 | from linodecli.plugins import PluginContext 8 | 9 | # Non-importable package name 10 | plugin = importlib.import_module("linodecli.plugins.image-upload") 11 | 12 | 13 | def test_print_help(capsys: CaptureFixture): 14 | with pytest.raises(SystemExit) as err: 15 | plugin.call(["--help"], None) 16 | 17 | captured_text = capsys.readouterr().out 18 | 19 | assert err.value.code == 0 20 | assert "The image file to upload" in captured_text 21 | assert "The region to upload the image to" in captured_text 22 | 23 | 24 | def test_no_file(mock_cli, capsys: CaptureFixture): 25 | with pytest.raises(SystemExit) as err: 26 | plugin.call( 27 | ["--label", "cool", "blah.txt"], 28 | PluginContext("REALTOKEN", mock_cli), 29 | ) 30 | 31 | captured_text = capsys.readouterr().err 32 | 33 | assert err.value.code == 8 34 | assert "No file at blah.txt" in captured_text 35 | 36 | 37 | @patch("os.path.isfile", lambda a: True) 38 | @patch("os.path.getsize", lambda a: plugin.MAX_UPLOAD_SIZE + 1) 39 | def test_file_too_large(mock_cli, capsys: CaptureFixture): 40 | args = ["--label", "cool", "blah.txt"] 41 | ctx = PluginContext("REALTOKEN", mock_cli) 42 | 43 | with pytest.raises(SystemExit) as err: 44 | plugin.call(args, ctx) 45 | 46 | captured_text = capsys.readouterr().err 47 | 48 | assert err.value.code == 8 49 | assert "File blah.txt is too large" in captured_text 50 | 51 | 52 | @patch("os.path.isfile", lambda a: True) 53 | @patch("os.path.getsize", lambda a: 1) 54 | def test_unauthorized(mock_cli, capsys: CaptureFixture): 55 | args = ["--label", "cool", "blah.txt"] 56 | 57 | mock_cli.call_operation = lambda *a: (401, None) 58 | 59 | ctx = PluginContext("REALTOKEN", mock_cli) 60 | 61 | with pytest.raises(SystemExit) as err: 62 | plugin.call(args, ctx) 63 | 64 | captured_text = capsys.readouterr().err 65 | 66 | assert err.value.code == 2 67 | assert "Your token was not authorized to use this endpoint" in captured_text 68 | 69 | 70 | @patch("os.path.isfile", lambda a: True) 71 | @patch("os.path.getsize", lambda a: 1) 72 | def test_non_beta(mock_cli, capsys: CaptureFixture): 73 | args = ["--label", "cool", "blah.txt"] 74 | 75 | mock_cli.call_operation = lambda *a: (404, None) 76 | 77 | ctx = PluginContext("REALTOKEN", mock_cli) 78 | 79 | with pytest.raises(SystemExit) as err: 80 | plugin.call(args, ctx) 81 | 82 | captured_text = capsys.readouterr().err 83 | 84 | assert err.value.code == 2 85 | assert ( 86 | "It looks like you are not in the Machine Images Beta" in captured_text 87 | ) 88 | 89 | 90 | @patch("os.path.isfile", lambda a: True) 91 | @patch("os.path.getsize", lambda a: 1) 92 | def test_non_beta(mock_cli, capsys: CaptureFixture): 93 | args = ["--label", "cool", "blah.txt"] 94 | 95 | mock_cli.call_operation = lambda *a: (404, None) 96 | 97 | ctx = PluginContext("REALTOKEN", mock_cli) 98 | 99 | with pytest.raises(SystemExit) as err: 100 | plugin.call(args, ctx) 101 | 102 | captured_text = capsys.readouterr().err 103 | 104 | assert err.value.code == 2 105 | assert ( 106 | "It looks like you are not in the Machine Images Beta" in captured_text 107 | ) 108 | 109 | 110 | @patch("os.path.isfile", lambda a: True) 111 | @patch("os.path.getsize", lambda a: 1) 112 | def test_failed_upload(mock_cli, capsys: CaptureFixture): 113 | args = ["--label", "cool", "blah.txt"] 114 | mock_cli.call_operation = lambda *a: (500, "it borked :(") 115 | 116 | ctx = PluginContext("REALTOKEN", mock_cli) 117 | 118 | with pytest.raises(SystemExit) as err: 119 | plugin.call(args, ctx) 120 | 121 | captured_text = capsys.readouterr().err 122 | 123 | assert err.value.code == 2 124 | assert ( 125 | "Upload failed with status 500; response was it borked :(" 126 | in captured_text 127 | ) 128 | -------------------------------------------------------------------------------- /tests/unit/test_plugin_metadata.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | 3 | import pytest 4 | from linode_metadata.objects.instance import InstanceResponse 5 | from linode_metadata.objects.networking import NetworkResponse 6 | from linode_metadata.objects.ssh_keys import SSHKeysResponse 7 | from pytest import CaptureFixture 8 | 9 | from linodecli.plugins.metadata import ( 10 | get_metadata_parser, 11 | print_instance_table, 12 | print_networking_tables, 13 | print_ssh_keys_table, 14 | ) 15 | 16 | plugin = importlib.import_module("linodecli.plugins.metadata") 17 | 18 | INSTANCE = InstanceResponse( 19 | json_data={ 20 | "id": 1, 21 | "host_uuid": "test_uuid", 22 | "label": "test-label", 23 | "region": "us-southeast", 24 | "tags": "test-tag", 25 | "type": "g6-standard-1", 26 | "specs": {"vcpus": 2, "disk": 3, "memory": 4, "transfer": 5, "gpus": 6}, 27 | "backups": {"enabled": False, "status": ["test1", "test2"]}, 28 | } 29 | ) 30 | 31 | NETWORKING = NetworkResponse( 32 | json_data={ 33 | "interfaces": [ 34 | { 35 | "label": "interface-label-1", 36 | "purpose": "purpose-1", 37 | "ipam_address": ["address1", "address2"], 38 | }, 39 | { 40 | "label": "interface-label-2", 41 | "purpose": "purpose-2", 42 | "ipam_address": ["address3", "address4"], 43 | }, 44 | ], 45 | "ipv4": { 46 | "public": ["public-1", "public-2"], 47 | "private": ["private-1", "private-2"], 48 | "shared": ["shared-1", "shared-2"], 49 | }, 50 | "ipv6": { 51 | "slaac": "slaac-1", 52 | "link_local": "link-local-1", 53 | "ranges": ["range-1", "range-2"], 54 | "shared_ranges": ["shared-range-1", "shared-range-2"], 55 | }, 56 | } 57 | ) 58 | 59 | SSH_KEYS = SSHKeysResponse( 60 | json_data={"users": {"root": ["ssh-key-1", "ssh-key-2"]}} 61 | ) 62 | 63 | SSH_KEYS_EMPTY = SSHKeysResponse(json_data={"users": {"root": None}}) 64 | 65 | 66 | def test_print_help(capsys: CaptureFixture): 67 | with pytest.raises(SystemExit) as err: 68 | plugin.call(["--help"], None) 69 | 70 | captured_text = capsys.readouterr().out 71 | 72 | assert err.value.code == 0 73 | assert "Available endpoints: " in captured_text 74 | assert "Get information about public SSH Keys" in captured_text 75 | 76 | 77 | def test_faulty_endpoint(capsys: CaptureFixture): 78 | with pytest.raises(SystemExit) as err: 79 | plugin.call(["blah"], None) 80 | 81 | captured_text = capsys.readouterr().out 82 | 83 | assert err.value.code == 0 84 | assert "Available endpoints: " in captured_text 85 | assert "Get information about public SSH Keys" in captured_text 86 | 87 | 88 | def test_instance_table(capsys: CaptureFixture): 89 | # Note: Test is brief since table is very large with all values included and captured text abbreviates a lot of values 90 | print_instance_table(INSTANCE) 91 | captured_text = capsys.readouterr() 92 | 93 | assert "id" in captured_text.out 94 | assert "1" in captured_text.out 95 | 96 | assert "3" in captured_text.out 97 | assert "2" in captured_text.out 98 | 99 | 100 | def test_networking_table(capsys: CaptureFixture): 101 | print_networking_tables(NETWORKING) 102 | captured_text = capsys.readouterr() 103 | 104 | assert "purpose" in captured_text.out 105 | assert "purpose-1" in captured_text.out 106 | 107 | assert "ip address" in captured_text.out 108 | assert "private-1" in captured_text.out 109 | assert "type" in captured_text.out 110 | assert "shared" in captured_text.out 111 | 112 | assert "slaac" in captured_text.out 113 | assert "slaac-1" in captured_text.out 114 | 115 | 116 | def test_ssh_key_table(capsys: CaptureFixture): 117 | print_ssh_keys_table(SSH_KEYS) 118 | captured_text = capsys.readouterr() 119 | 120 | assert "user" in captured_text.out 121 | assert "ssh key" in captured_text.out 122 | assert "root" in captured_text.out 123 | assert "ssh-key-1" in captured_text.out 124 | assert "ssh-key-2" in captured_text.out 125 | 126 | 127 | def test_empty_ssh_key_table(capsys: CaptureFixture): 128 | print_ssh_keys_table(SSH_KEYS_EMPTY) 129 | captured_text = capsys.readouterr() 130 | 131 | assert "user" in captured_text.out 132 | assert "ssh key" in captured_text.out 133 | 134 | 135 | def test_arg_parser(): 136 | parser = get_metadata_parser() 137 | parsed, args = parser.parse_known_args(["--debug"]) 138 | assert parsed.debug 139 | 140 | parsed, args = parser.parse_known_args(["--something-else"]) 141 | assert not parsed.debug 142 | -------------------------------------------------------------------------------- /tests/unit/test_plugin_obj.py: -------------------------------------------------------------------------------- 1 | from pytest import CaptureFixture 2 | 3 | from linodecli import CLI 4 | from linodecli.plugins.obj import get_obj_args_parser, helpers, print_help 5 | 6 | 7 | def test_print_help(mock_cli: CLI, capsys: CaptureFixture): 8 | parser = get_obj_args_parser() 9 | print_help(parser) 10 | captured_text = capsys.readouterr() 11 | assert parser.format_help() in captured_text.out 12 | assert ( 13 | "See --help for individual commands for more information" 14 | in captured_text.out 15 | ) 16 | 17 | 18 | def test_helpers_denominate(): 19 | assert helpers._denominate(0) == "0.0 KB" 20 | assert helpers._denominate(1) == "0.0 KB" 21 | assert helpers._denominate(12) == "0.01 KB" 22 | assert helpers._denominate(123) == "0.12 KB" 23 | assert helpers._denominate(1000) == "0.98 KB" 24 | 25 | assert helpers._denominate(1024) == "1.0 KB" 26 | assert helpers._denominate(1024**2) == "1.0 MB" 27 | assert helpers._denominate(1024**3) == "1.0 GB" 28 | assert helpers._denominate(1024**4) == "1.0 TB" 29 | assert helpers._denominate(1024**5) == "1024.0 TB" 30 | 31 | assert helpers._denominate(102400) == "100.0 KB" 32 | assert helpers._denominate(1024000) == "1000.0 KB" 33 | assert helpers._denominate((1024**2) // 10) == "102.4 KB" 34 | 35 | assert helpers._denominate(123456789) == "117.74 MB" 36 | assert helpers._denominate(1e23) == "90949470177.29 TB" 37 | -------------------------------------------------------------------------------- /tests/unit/test_request.py: -------------------------------------------------------------------------------- 1 | class TestRequest: 2 | """ 3 | Unit tests for baked requests. 4 | """ 5 | 6 | def test_handle_one_ofs(self, post_operation_with_one_ofs): 7 | args = post_operation_with_one_ofs.args 8 | 9 | arg_map = {arg.path: arg for arg in args} 10 | 11 | expected = { 12 | "foobar": ("string", "Some foobar.", True), 13 | "barfoo": ("integer", "Some barfoo.", False), 14 | "foofoo": ("boolean", "Some foofoo.", False), 15 | "barbar.foo": ("string", "Some foo.", False), 16 | "barbar.bar": ("integer", "Some bar.", False), 17 | "barbar.baz": ("boolean", "Some baz.", False), 18 | } 19 | 20 | for k, v in expected.items(): 21 | assert arg_map[k].datatype == v[0] 22 | assert arg_map[k].description == v[1] 23 | assert arg_map[k].required == v[2] 24 | -------------------------------------------------------------------------------- /wiki/Configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | The first time the CLI runs, it will prompt you to configure it. The CLI defaults 4 | to using web-based configuration, which is fast and convenient for users who 5 | have access to a browser. 6 | 7 | To manually configure the CLI or reconfigure it if your token expires, you can 8 | run the `configure` command:: 9 | ```bash 10 | linode-cli configure 11 | ``` 12 | 13 | If you prefer to provide a token directly through the terminal, possibly because 14 | you don't have access to a browser where you're configuring the CLI, pass the 15 | `--token` flag to the configure command as shown:: 16 | ```bash 17 | linode-cli configure --token 18 | ``` 19 | 20 | When configuring multiple users using web-based configuration, you may need to 21 | log out of cloud.linode.com before configuring a second user. 22 | 23 | ## Environment Variables 24 | 25 | If you prefer, you may store your token in an environment variable named 26 | `LINODE_CLI_TOKEN` instead of using the configuration file. Doing so allows you 27 | to bypass the initial configuration, and subsequent calls to `linode-cli configure` 28 | will allow you to set defaults without having to set a token. Be aware that if 29 | the environment variable should be unset, the Linode CLI will stop working until 30 | it is set again or the CLI is reconfigured with a token. 31 | 32 | You may also use environment variables to store your Object Storage Keys for 33 | the `obj` plugin that ships with the CLI. To do so, simply set 34 | `LINODE_CLI_OBJ_ACCESS_KEY` and `LINODE_CLI_OBJ_SECRET_KEY` to the 35 | appropriate values. This allows using Linode Object Storage through the CLI 36 | without having a configuration file, which is desirable in some situations. 37 | 38 | You may also specify the path to a custom Certificate Authority file using the `LINODE_CLI_CA` 39 | environment variable. 40 | 41 | If you wish to hide the API Version warning you can use the `LINODE_CLI_SUPPRESS_VERSION_WARNING` 42 | environment variable. 43 | 44 | You may also specify a custom configuration path using the `LINODE_CLI_CONFIG` environment variable 45 | to replace the default path `~/.config/linode-cli`. 46 | 47 | ## Configurable API URL 48 | 49 | In some cases you may want to run linode-cli against a non-default Linode API URL. 50 | This can be done using the following environment variables to override certain segments of the target API URL. 51 | 52 | * `LINODE_CLI_API_HOST` - The host of the Linode API instance (e.g. `api.linode.com`) 53 | 54 | * `LINODE_CLI_API_VERSION` - The Linode API version to use (e.g. `v4beta`) 55 | 56 | * `LINODE_CLI_API_SCHEME` - The request scheme to use (e.g. `https`) 57 | 58 | Alternatively, these values can be configured per-user using the ``linode-cli configure`` command. 59 | 60 | ## Multiple Users 61 | 62 | If you use the Linode CLI to manage multiple Linode accounts, you may configure 63 | additional users using the ``linode-cli configure`` command. The CLI will automatically 64 | detect that a new user is being configured based on the token given. 65 | 66 | ## Displaying Configured Users 67 | 68 | To see what users are configured, simply run the following:: 69 | ```bash 70 | linode-cli show-users 71 | ``` 72 | 73 | The user who is currently active will be indicated by an asterisk. 74 | 75 | ## Changing the Active User 76 | 77 | You may change the active user for all requests as follows:: 78 | ```bash 79 | linode-cli set-user USERNAME 80 | ``` 81 | 82 | Subsequent CLI commands will be executed as that user by default. 83 | 84 | Should you wish to execute a single request as a different user, you can supply 85 | the `--as-user` argument to specify the username you wish to act as for that 86 | command. This *will not* change the active user. 87 | 88 | ## Removing Configured Users 89 | 90 | To remove a user from you previously configured, run:: 91 | ```bash 92 | linode-cli remove-user USERNAME 93 | ``` 94 | 95 | Once a user is removed, they will need to be reconfigured if you wish to use the 96 | CLI for them again. 97 | -------------------------------------------------------------------------------- /wiki/Home.md: -------------------------------------------------------------------------------- 1 | Welcome to the linode-cli wiki! 2 | 3 | For installation instructions and usage guides, please 4 | refer to the sidebar of this page. -------------------------------------------------------------------------------- /wiki/Installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ## PyPi 4 | 5 | ```bash 6 | pip3 install linode-cli 7 | # for upgrading 8 | pip3 install linode-cli --upgrade 9 | ``` 10 | 11 | ## Docker 12 | 13 | ### Token 14 | ```bash 15 | docker run --rm -it -e LINODE_CLI_TOKEN=$LINODE_TOKEN linode/cli:latest linodes list 16 | ``` 17 | 18 | ### Config 19 | ```bash 20 | docker run --rm -it -v $HOME/.config/linode-cli:/home/cli/.config/linode-cli linode/cli:latest linodes list 21 | ``` 22 | 23 | ## GitHub Actions 24 | 25 | [Setup Linode CLI](https://github.com/marketplace/actions/setup-linode-cli) GitHub Action to automatically install and authenticate the cli in a GitHub Actions environment: 26 | ```yml 27 | - name: Install the Linode CLI 28 | uses: linode/action-linode-cli@v1 29 | with: 30 | token: ${{ secrets.LINODE_TOKEN }} 31 | ``` 32 | 33 | ## Community Distributions 34 | 35 | The Linode CLI is available through unofficial channels thanks to our awesome community! These distributions are not included in release testing. 36 | 37 | ### Homebrew 38 | 39 | ```bash 40 | brew install linode-cli 41 | brew upgrade linode-cli 42 | ``` 43 | # Building from Source 44 | 45 | In order to successfully build the CLI, your system will require the following: 46 | 47 | - The `make` command 48 | - `python3` 49 | - `pip3` (to install project dependencies) 50 | 51 | Before attempting a build, install python dependencies like this:: 52 | ```bash 53 | make requirements 54 | ``` 55 | 56 | Once everything is set up, you can initiate a build like so:: 57 | ```bash 58 | make build 59 | ``` 60 | 61 | If desired, you may pass in `SPEC=/path/to/openapi-spec` when running `build` 62 | or `install`. This can be a URL or a path to a local spec, and that spec will 63 | be used when generating the CLI. A yaml or json file is accepted. 64 | 65 | To install the package as part of the build process, use this command:: 66 | 67 | ```bash 68 | make install 69 | ``` 70 | -------------------------------------------------------------------------------- /wiki/Output.md: -------------------------------------------------------------------------------- 1 | # Customizing Output 2 | 3 | ## Changing Output Fields 4 | 5 | By default, the CLI displays on some pre-selected fields for a given type of 6 | response. If you want to see everything, just ask:: 7 | ```bash 8 | linode-cli linodes list --all-columns 9 | ``` 10 | 11 | Using `--all-columns` will cause the CLI to display all returned columns of 12 | output. Note that this will probably be hard to read on normal-sized screens 13 | for most actions. 14 | 15 | If you want even finer control over your output, you can request specific columns 16 | be displayed:: 17 | ```bash 18 | linode-cli linodes list --format 'id,region,status,disk,memory,vcpus,transfer' 19 | ``` 20 | 21 | This will show some identifying information about your Linode as well as the 22 | resources it has access to. Some of these fields would be hidden by default - 23 | that's ok. If you ask for a field, it'll be displayed. 24 | 25 | ## Output Formatting 26 | 27 | While the CLI by default outputs human-readable tables of data, you can use the 28 | CLI to generate output that is easier to process. 29 | 30 | ## Machine Readable Output 31 | 32 | To get more machine-readable output, simply request it:: 33 | ```bash 34 | linode-cli linodes list --text 35 | ``` 36 | 37 | If a tab is a bad delimiter, you can configure that as well:: 38 | ```bash 39 | linode-cli linodes list --text --delimiter ';' 40 | ``` 41 | 42 | You may also disable header rows (in any output format):: 43 | ```bash 44 | linode-cli linodes list --no-headers --text 45 | ``` 46 | 47 | ## JSON Output 48 | 49 | To get JSON output from the CLI, simple request it:: 50 | ```bash 51 | linode-cli linodes list --json --all-columns 52 | ``` 53 | 54 | While the `--all-columns` is optional, you probably want to see all output 55 | fields in your JSON output. If you want your JSON pretty-printed, we can do 56 | that too:: 57 | ```bash 58 | linode-cli linodes list --json --pretty --all-columns 59 | ``` 60 | -------------------------------------------------------------------------------- /wiki/Plugins.md: -------------------------------------------------------------------------------- 1 | # Plugins 2 | 3 | The Linode CLI allows its features to be expanded with plugins. Some official 4 | plugins come bundled with the CLI and are documented above. Additionally, anyone 5 | can write and distribute plugins for the CLI - these are called Third Party Plugins. 6 | 7 | To register a Third Party Plugin, use the following command:: 8 | ```bash 9 | linode-cli register-plugin PLUGIN_MODULE_NAME 10 | ``` 11 | 12 | Plugins should give the exact command required to register them. 13 | 14 | Once registered, the command to invoke the Third Party Plugin will be printed, and 15 | it will appear in the plugin list when invoking `linode-cli --help`. 16 | 17 | To remove a previously registered plugin, use the following command:: 18 | ```bash 19 | linode-cli remove-plugin PLUGIN_NAME 20 | ``` 21 | 22 | This command accepts the name used to invoke the plugin in the CLI as it appears 23 | in `linode-cli --help`, which may not be the same as the module name used to 24 | register it. 25 | 26 | ## Developing Plugins 27 | 28 | For information on how To write your own Third Party Plugin, see the [Plugins documentation](https://github.com/linode/linode-cli/blob/main/linodecli/plugins/README.md) 29 | -------------------------------------------------------------------------------- /wiki/Uninstallation.md: -------------------------------------------------------------------------------- 1 | # Uninstallation 2 | 3 | ## PyPi 4 | 5 | ```bash 6 | pip3 uninstall linode-cli 7 | ``` 8 | 9 | If you would like to remove the config file (easy to re-create) you must do so manually. 10 | 11 | ```bash 12 | rm $HOME/.config/linode-cli 13 | ``` 14 | -------------------------------------------------------------------------------- /wiki/_Sidebar.md: -------------------------------------------------------------------------------- 1 | - [Installation](./Installation) 2 | - [Uninstallation](./Uninstallation) 3 | - [Configuration](./Configuration) 4 | - [Usage](./Usage) 5 | - [Output](./Output) 6 | - [Plugins](./Plugins) 7 | - [Development](./Development%20-%20Index) 8 | - [Overview](./Development%20-%20Overview) 9 | - [Skeleton](./Development%20-%20Skeleton) 10 | - [Setup](./Development%20-%20Setup) 11 | - [Testing](./Development%20-%20Testing) 12 | -------------------------------------------------------------------------------- /wiki/development/Development - Index.md: -------------------------------------------------------------------------------- 1 | This guide will help you get started developing against and contributing to the Linode CLI. 2 | 3 | ## Index 4 | 5 | 1. [Overview](./Development%20-%20Overview) 6 | 2. [Skeleton](./Development%20-%20Skeleton) 7 | 3. [Setup](./Development%20-%20Setup) 8 | 4. [Testing](./Development%20-%20Testing) 9 | 10 | ## Contributing 11 | 12 | Once you're ready to contribute a change to the project, please refer to our [Contributing Guide](https://github.com/linode/linode-cli/blob/dev/CONTRIBUTING.md). -------------------------------------------------------------------------------- /wiki/development/Development - Setup.md: -------------------------------------------------------------------------------- 1 | The following guide outlines to the process for setting up the Linode CLI for development. 2 | 3 | ## Cloning the Repository 4 | 5 | The Linode CLI repository can be cloned locally using the following command: 6 | 7 | ```bash 8 | git clone git@github.com:linode/linode-cli.git 9 | ``` 10 | 11 | If you do not have an SSH key configured, you can alternatively use the following command: 12 | 13 | ```bash 14 | git clone https://github.com/linode/linode-cli.git 15 | ``` 16 | 17 | ## Configuring a VirtualEnv (recommended) 18 | 19 | A virtual env allows you to create virtual Python environment which can prevent potential 20 | Python dependency conflicts. 21 | 22 | To create a VirtualEnv, run the following: 23 | 24 | ```bash 25 | python3 -m venv .venv 26 | ``` 27 | 28 | To enter the VirtualEnv, run the following command (NOTE: This needs to be run every time you open your shell): 29 | 30 | ```bash 31 | source .venv/bin/activate 32 | ``` 33 | 34 | ## Installing Project Dependencies 35 | 36 | All Linode CLI Python requirements can be installed by running the following command: 37 | 38 | ```bash 39 | make requirements 40 | ``` 41 | 42 | ## Building and Installing the Project 43 | 44 | The Linode CLI can be built and installed using the `make install` target: 45 | 46 | ```bash 47 | make install 48 | ``` 49 | 50 | Alternatively you can build but not install the CLI using the `make build` target: 51 | 52 | ```bash 53 | make build 54 | ``` 55 | 56 | Optionally you can validate that you have installed a local version of the CLI using the `linode-cli --version` command: 57 | 58 | ```bash 59 | linode-cli --version 60 | 61 | # Output: 62 | # linode-cli 0.0.0 63 | # Built from spec version 4.173.0 64 | # 65 | # The 0.0.0 implies this is a locally built version of the CLI 66 | ``` 67 | 68 | ## Building Using a Custom OpenAPI Specification 69 | 70 | In some cases, you may want to build the CLI using a custom or modified OpenAPI specification. 71 | 72 | This can be achieved using the `SPEC` Makefile argument, for example: 73 | 74 | ```bash 75 | # Download the OpenAPI spec 76 | curl -o openapi.yaml https://raw.githubusercontent.com/linode/linode-api-docs/development/openapi.yaml 77 | 78 | # Many arbitrary changes to the spec 79 | 80 | # Build & install the CLI using the modified spec 81 | make SPEC=$PWD/openapi.yaml install 82 | ``` 83 | 84 | ## Next Steps 85 | 86 | To continue to the next step of this guide, continue to the [Testing page](./Development%20-%20Testing). -------------------------------------------------------------------------------- /wiki/development/Development - Skeleton.md: -------------------------------------------------------------------------------- 1 | The following section outlines the purpose of each file in the CLI. 2 | 3 | * `linode-cli` 4 | * `baked` 5 | * `__init__.py` - Contains imports for certain classes in this package 6 | * `colors.py` - Contains logic for colorizing strings in CLI outputs (deprecated) 7 | * `operation.py` - Contains the logic to parse an `OpenAPIOperation` from the OpenAPI spec and generate/execute a corresponding argparse parser 8 | * `request.py` - Contains the `OpenAPIRequest` and `OpenAPIRequestArg` classes 9 | * `response.py` - Contains `OpenAPIResponse` and `OpenAPIResponseAttr` classes 10 | * `configuration` 11 | * `__init__.py` - Contains imports for certain classes in this package 12 | * `auth.py` - Contains all the logic for the token generation OAuth workflow 13 | * `config.py` - Contains all the logic for loading, updating, and saving CLI configs 14 | * `helpers.py` - Contains various config-related helpers 15 | * `plugins` 16 | * `__init__.py` - Contains imports for certain classes in this package 17 | * `plugins.py` - Contains the shared wrapper that allows plugins to access CLI functionality 18 | * `__init__.py` - Contains the main entrypoint for the CLI; routes top-level commands to their corresponding functions 19 | * `__main__.py` - Calls the project entrypoint in `__init__.py` 20 | * `api_request.py` - Contains logic for building API request bodies, making API requests, and handling API responses/errors 21 | * `arg_helpers.py` - Contains miscellaneous logic for registering common argparse arguments and loading the OpenAPI spec 22 | * `cli.py` - Contains the `CLI` class, which routes all the logic baking, loading, executing, and outputting generated CLI commands 23 | * `completion.py` - Contains all the logic for generating shell completion files (`linode-cli completion`) 24 | * `helpers.py` - Contains various miscellaneous helpers, especially relating to string manipulation, etc. 25 | * `oauth-landing-page.html` - The page to show users in their browser when the OAuth workflow is complete. 26 | * `output.py` - Contains all the logic for handling generated command outputs, including formatting tables, filtering JSON, etc. 27 | * `overrides.py` - Contains hardcoded output override functions for select CLI commands. 28 | 29 | 30 | ## Next Steps 31 | 32 | To continue to the next step of this guide, continue to the [Setup page](./Development%20-%20Setup). -------------------------------------------------------------------------------- /wiki/development/Development - Testing.md: -------------------------------------------------------------------------------- 1 | This page gives an overview of how to run the various test suites for the Linode CLI. 2 | 3 | Before running any tests, built and installed the Linode CLI with your changes using `make install`. 4 | 5 | ## Running Unit Tests 6 | 7 | Unit tests can be run using the `make testunit` Makefile target. 8 | 9 | ## Running Integration Tests 10 | 11 | Running the tests locally is simple. The only requirements are that you export Linode API token as `LINODE_CLI_TOKEN`:: 12 | ```bash 13 | export LINODE_CLI_TOKEN="your_token" 14 | ``` 15 | 16 | More information on Managing Linode API tokens can be found in our [API Token Docs](https://www.linode.com/docs/products/tools/api/guides/manage-api-tokens/). 17 | 18 | In order to run the full integration test, run:: 19 | ```bash 20 | make testint 21 | ``` 22 | 23 | To run specific test package, use environment variable `INTEGRATION_TEST_PATH` with `testint` command:: 24 | ```bash 25 | make INTEGRATION_TEST_PATH="cli" testint 26 | ``` 27 | 28 | Lastly, to run specific test case, use environment variables `TEST_CASE` with `testint` command:: 29 | ```bash 30 | make TEST_CASE=test_help_page_for_non_aliased_actions testint 31 | ``` 32 | --------------------------------------------------------------------------------