├── .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 |  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 |
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 |
You may return to your terminal to continue.
39 |Hello, World!
" 68 | "" 69 | ) 70 | 71 | 72 | @pytest.fixture 73 | def static_site_error(): 74 | return ( 75 | "" 76 | "" 77 | "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 | --------------------------------------------------------------------------------