diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ba1c6b80a..226428122 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,4 +8,9 @@ updates: - package-ecosystem: "pip" # See documentation for possible values directory: "/" # Location of package manifests schedule: - interval: "daily" + interval: "weekly" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 5bea77b2c..d97f93452 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -7,7 +7,3 @@ **What are the steps to reproduce the issue or verify the changes?** **How do I run the relevant unit/integration tests?** - -## 📷 Preview - -**If applicable, include a screenshot or code snippet of this change. Otherwise, please remove this section.** \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..dd8eeea17 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ + +name: Continuous Integration + +on: + push: + branches: + - dev + - main + pull_request: + workflow_dispatch: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: checkout repo + uses: actions/checkout@v6 + + - name: setup python 3 + uses: actions/setup-python@v6 + with: + python-version: '3.x' + + - name: install dependencies + run: make dev-install + + - name: run linter + run: make lint + + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Run tests + run: | + pip install ".[test]" + tox diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..c7b208528 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,38 @@ +name: "CodeQL Advanced" + +on: + push: + branches: [ "dev", "main", "proj/*" ] + pull_request: + branches: [ "dev", "main", "proj/*" ] + schedule: + - cron: '39 0 * * 6' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + security-events: write + + strategy: + fail-fast: false + matrix: + include: + - language: python + build-mode: none + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + queries: security-and-quality + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 000000000..ffba32062 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,18 @@ +name: 'Dependency review' +on: + pull_request: + branches: [ "dev", "main", "proj/*" ] +permissions: + contents: read + pull-requests: write + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: 'Checkout repository' + uses: actions/checkout@v6 + - name: 'Dependency Review' + uses: actions/dependency-review-action@v4 + with: + comment-summary-in-pr: on-failure diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index 4d44d48d3..f765b0a0d 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -2,6 +2,22 @@ on: pull_request: workflow_dispatch: inputs: + run_db_fork_tests: + description: 'Set this parameter to "true" to run fork database related test cases' + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' + run_db_tests: + description: 'Set this parameter to "true" to run database related test cases' + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' test_suite: description: 'Enter specific test suite. E.g. domain, linode_client' required: false @@ -11,6 +27,14 @@ on: pull_request_number: description: 'The number of the PR.' required: false + test_report_upload: + description: 'Indicates whether to upload the test report to object storage. Defaults to "false"' + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' name: PR E2E Tests @@ -32,7 +56,7 @@ jobs: # Check out merge commit - name: Checkout PR - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ inputs.sha }} fetch-depth: 0 @@ -64,21 +88,13 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.x' - name: Install Python deps run: pip install -U setuptools wheel boto3 certifi - - name: Download kubectl and calicoctl for LKE clusters - run: | - curl -LO "https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl" - curl -LO "https://github.com/projectcalico/calico/releases/download/v3.25.0/calicoctl-linux-amd64" - chmod +x calicoctl-linux-amd64 kubectl - mv calicoctl-linux-amd64 /usr/local/bin/calicoctl - mv kubectl /usr/local/bin/kubectl - - name: Install Python SDK run: make dev-install env: @@ -88,19 +104,12 @@ jobs: run: | timestamp=$(date +'%Y%m%d%H%M') report_filename="${timestamp}_sdk_test_report.xml" - make testint TEST_ARGS="--junitxml=${report_filename}" TEST_SUITE="${{ github.event.inputs.test_suite }}" - env: - LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} - - - name: Apply Calico Rules to LKE - if: always() - run: | - cd scripts && ./lke_calico_rules_e2e.sh + make test-int RUN_DB_FORK_TESTS=${{ github.event.inputs.run_db_fork_tests }} RUN_DB_TESTS=${{ github.event.inputs.run_db_tests }} TEST_ARGS="--junitxml=${report_filename}" TEST_SUITE="${{ github.event.inputs.test_suite }}" env: LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} - name: Upload test results - if: always() + if: always() && github.repository == 'linode/linode_api4-python' && (github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && inputs.test_report_upload == 'true')) run: | filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') python3 e2e_scripts/tod_scripts/xml_to_obj_storage/scripts/add_gha_info_to_xml.py \ @@ -114,7 +123,7 @@ jobs: LINODE_CLI_OBJ_ACCESS_KEY: ${{ secrets.LINODE_CLI_OBJ_ACCESS_KEY }} LINODE_CLI_OBJ_SECRET_KEY: ${{ secrets.LINODE_CLI_OBJ_SECRET_KEY }} - - uses: actions/github-script@v6 + - uses: actions/github-script@v8 id: update-check-run if: ${{ inputs.pull_request_number != '' && fromJson(steps.commit-hash.outputs.data).repository.pullRequest.headRef.target.oid == inputs.sha }} env: @@ -141,3 +150,60 @@ jobs: conclusion: process.env.conclusion }); return result; + + apply-calico-rules: + runs-on: ubuntu-latest + needs: [integration-fork-ubuntu] + if: ${{ success() || failure() }} + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: 'recursive' + + - name: Download kubectl and calicoctl for LKE clusters + run: | + curl -LO "https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl" + curl -LO "https://github.com/projectcalico/calico/releases/download/v3.25.0/calicoctl-linux-amd64" + chmod +x calicoctl-linux-amd64 kubectl + mv calicoctl-linux-amd64 /usr/local/bin/calicoctl + mv kubectl /usr/local/bin/kubectl + + - name: Apply Calico Rules to LKE + run: | + cd e2e_scripts/cloud_security_scripts/lke_calico_rules/ && ./lke_calico_rules_e2e.sh + env: + LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} + + add-fw-to-remaining-instances: + runs-on: ubuntu-latest + needs: [integration-fork-ubuntu] + if: ${{ success() || failure() }} + + steps: + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.x' + + - name: Install Linode CLI + run: | + pip install linode-cli + + - name: Create Firewall and Attach to Instances + run: | + FIREWALL_ID=$(linode-cli firewalls create --label "e2e-fw-$(date +%s)" --rules.inbound_policy "DROP" --rules.outbound_policy "ACCEPT" --text --format=id --no-headers) + echo "Created Firewall with ID: $FIREWALL_ID" + + for instance_id in $(linode-cli linodes list --format "id" --text --no-header); do + echo "Attaching firewall to instance: $instance_id" + if linode-cli firewalls device-create "$FIREWALL_ID" --id "$instance_id" --type linode; then + echo "Firewall attached to instance $instance_id successfully." + else + echo "An error occurred while attaching firewall to instance $instance_id. Skipping..." + fi + done + env: + LINODE_CLI_TOKEN: ${{ secrets.LINODE_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 48cb55e13..df1a41841 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -3,28 +3,69 @@ name: Integration Tests on: workflow_dispatch: inputs: + run_db_fork_tests: + description: 'Set this parameter to "true" to run fork database related test cases' + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' + run_db_tests: + description: 'Set this parameter to "true" to run database related test cases' + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' + test_suite: + description: 'Enter specific test suite. E.g. domain, linode_client' + required: false use_minimal_test_account: - description: 'Use minimal test account' + description: 'Indicate whether to use a minimal test account with limited resources for testing. Defaults to "false"' required: false default: 'false' sha: - description: 'The hash value of the commit' - required: false + description: 'Specify commit hash to test. This value is mandatory to ensure the tests run against a specific commit' + required: true default: '' + python-version: + description: 'Specify the Python version to use for running tests. Leave empty to use the default Python version configured in the environment' + required: false + run-eol-python-version: + description: 'Indicates whether to run tests using an End-of-Life (EOL) Python version. Defaults to "false". Choose "true" to include tests for deprecated Python versions' + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' + test_report_upload: + description: 'Indicates whether to upload the test report to object storage. Defaults to "false"' + type: choice + required: false + default: 'false' + options: + - 'true' + - 'false' push: branches: - main - dev +env: + DEFAULT_PYTHON_VERSION: "3.10" + EOL_PYTHON_VERSION: "3.9" + EXIT_STATUS: 0 + jobs: integration-tests: runs-on: ubuntu-latest - env: - EXIT_STATUS: 0 steps: - name: Clone Repository with SHA if: ${{ inputs.sha != '' }} - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 submodules: 'recursive' @@ -32,15 +73,15 @@ jobs: - name: Clone Repository without SHA if: ${{ inputs.sha == '' }} - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 submodules: 'recursive' - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: - python-version: '3.x' + python-version: ${{ inputs.run-eol-python-version == 'true' && env.EOL_PYTHON_VERSION || inputs.python-version || env.DEFAULT_PYTHON_VERSION }} - name: Install Python deps run: pip install -U setuptools wheel boto3 certifi @@ -50,14 +91,6 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Download kubectl and calicoctl for LKE clusters - run: | - curl -LO "https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl" - curl -LO "https://github.com/projectcalico/calico/releases/download/v3.25.0/calicoctl-linux-amd64" - chmod +x calicoctl-linux-amd64 kubectl - mv calicoctl-linux-amd64 /usr/local/bin/calicoctl - mv kubectl /usr/local/bin/kubectl - - name: Set LINODE_TOKEN run: | echo "LINODE_TOKEN=${{ secrets[inputs.use_minimal_test_account == 'true' && 'MINIMAL_LINODE_TOKEN' || 'LINODE_TOKEN'] }}" >> $GITHUB_ENV @@ -66,18 +99,116 @@ jobs: run: | timestamp=$(date +'%Y%m%d%H%M') report_filename="${timestamp}_sdk_test_report.xml" - make testint TEST_ARGS="--junitxml=${report_filename}" + make test-int RUN_DB_FORK_TESTS=${{ github.event.inputs.run_db_fork_tests }} RUN_DB_TESTS=${{ github.event.inputs.run_db_tests }} TEST_SUITE="${{ github.event.inputs.test_suite }}" TEST_ARGS="--junitxml=${report_filename}" env: LINODE_TOKEN: ${{ env.LINODE_TOKEN }} - - name: Apply Calico Rules to LKE + - name: Upload Test Report as Artifact if: always() + uses: actions/upload-artifact@v6 + with: + name: test-report-file + if-no-files-found: ignore + path: '*.xml' + retention-days: 1 + + apply-calico-rules: + runs-on: ubuntu-latest + needs: [integration-tests] + if: ${{ success() || failure() }} + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: 'recursive' + + - name: Set LINODE_TOKEN + run: | + echo "LINODE_TOKEN=${{ secrets[inputs.use_minimal_test_account == 'true' && 'MINIMAL_LINODE_TOKEN' || 'LINODE_TOKEN'] }}" >> $GITHUB_ENV + + - name: Download kubectl and calicoctl for LKE clusters + run: | + curl -LO "https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl" + curl -LO "https://github.com/projectcalico/calico/releases/download/v3.25.0/calicoctl-linux-amd64" + chmod +x calicoctl-linux-amd64 kubectl + mv calicoctl-linux-amd64 /usr/local/bin/calicoctl + mv kubectl /usr/local/bin/kubectl + + - name: Apply Calico Rules to LKE run: | - cd scripts && ./lke_calico_rules_e2e.sh + cd e2e_scripts/cloud_security_scripts/lke_calico_rules/ && ./lke_calico_rules_e2e.sh env: LINODE_TOKEN: ${{ env.LINODE_TOKEN }} - - name: Upload test results + add-fw-to-remaining-instances: + runs-on: ubuntu-latest + needs: [integration-tests] + if: ${{ success() || failure() }} + + steps: + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.x' + + - name: Install Linode CLI + run: | + pip install linode-cli + + - name: Set LINODE_TOKEN + run: | + echo "LINODE_TOKEN=${{ secrets[inputs.use_minimal_test_account == 'true' && 'MINIMAL_LINODE_TOKEN' || 'LINODE_TOKEN'] }}" >> $GITHUB_ENV + + - name: Create Firewall and Attach to Instances + run: | + FIREWALL_ID=$(linode-cli firewalls create --label "e2e-fw-$(date +%s)" --rules.inbound_policy "DROP" --rules.outbound_policy "ACCEPT" --text --format=id --no-headers) + echo "Created Firewall with ID: $FIREWALL_ID" + + for instance_id in $(linode-cli linodes list --format "id" --text --no-header); do + echo "Attaching firewall to instance: $instance_id" + if linode-cli firewalls device-create "$FIREWALL_ID" --id "$instance_id" --type linode; then + echo "Firewall attached to instance $instance_id successfully." + else + echo "An error occurred while attaching firewall to instance $instance_id. Skipping..." + fi + done + env: + LINODE_CLI_TOKEN: ${{ env.LINODE_TOKEN }} + + process-upload-report: + runs-on: ubuntu-latest + needs: [integration-tests] + # Run even if integration tests fail on main repository AND push event OR test_report_upload is true in case of manual run + if: always() && github.repository == 'linode/linode_api4-python' && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.test_report_upload == 'true')) + outputs: + summary: ${{ steps.set-test-summary.outputs.summary }} + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: 'recursive' + + - name: Download test report + uses: actions/download-artifact@v8 + with: + name: test-report-file + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.x' + + - name: Install Python dependencies + run: pip3 install requests wheel boto3==1.35.99 + + - name: Set release version env + run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV + + - name: Add variables and upload test results if: always() run: | filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') @@ -91,3 +222,62 @@ jobs: env: LINODE_CLI_OBJ_ACCESS_KEY: ${{ secrets.LINODE_CLI_OBJ_ACCESS_KEY }} LINODE_CLI_OBJ_SECRET_KEY: ${{ secrets.LINODE_CLI_OBJ_SECRET_KEY }} + + - name: Generate test summary and save to output + id: set-test-summary + run: | + filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') + test_output=$(python3 e2e_scripts/tod_scripts/generate_test_summary.py "${filename}") + { + echo 'summary<> "$GITHUB_OUTPUT" + + notify-slack: + runs-on: ubuntu-latest + needs: [integration-tests, process-upload-report] + if: ${{ (success() || failure()) }} # Run even if integration tests fail and only on main repository + steps: + - name: Notify Slack + id: main_message + uses: slackapi/slack-github-action@v2.1.1 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} + payload: | + channel: ${{ secrets.SLACK_CHANNEL_ID }} + blocks: + - type: section + text: + type: mrkdwn + text: ":rocket: *${{ github.workflow }} Completed in: ${{ github.repository }}* ${{ needs.integration-tests.result == 'success' && ':white_check_mark:' || ':failed:' }}" + - type: divider + - type: section + fields: + - type: mrkdwn + text: "*Build Result:*\n${{ needs.integration-tests.result == 'success' && ':large_green_circle: Build Passed' || ':red_circle: Build Failed' }}" + - type: mrkdwn + text: "*Branch:*\n`${{ github.ref_name }}`" + - type: section + fields: + - type: mrkdwn + text: "*Commit Hash:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>" + - type: mrkdwn + text: "*Run URL:*\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run Details>" + - type: divider + - type: context + elements: + - type: mrkdwn + text: "Triggered by: :bust_in_silhouette: `${{ github.actor }}`" + + - name: Test summary thread + if: success() + uses: slackapi/slack-github-action@v2.1.1 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} + payload: | + channel: ${{ secrets.SLACK_CHANNEL_ID }} + thread_ts: "${{ steps.main_message.outputs.ts }}" + text: "${{ needs.process-upload-report.outputs.summary }}" \ No newline at end of file diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index da42b7e4a..14e770b11 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -18,10 +18,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Run Labeler - uses: crazy-max/ghaction-github-labeler@de749cf181958193cb7debf1a9c5bb28922f3e1b + uses: crazy-max/ghaction-github-labeler@548a7c3603594ec17c819e1239f281a3b801ab4d with: github-token: ${{ secrets.GITHUB_TOKEN }} yaml-file: .github/labels.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 9f9391533..000000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Linting Actions -on: - pull_request: null - push: - branches: - - master - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - name: checkout repo - uses: actions/checkout@v4 - - - name: setup python 3 - uses: actions/setup-python@v5 - with: - python-version: '3.x' - - - name: install dependencies - run: make dev-install - - - name: run linter - run: make lint \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index ee290360c..000000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,26 +0,0 @@ - -name: Test Suite - -on: - push: - branches: - - dev - - main - pull_request: - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ['3.8','3.9','3.10','3.11', '3.12'] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Run tests - run: | - pip install ".[test]" - tox diff --git a/.github/workflows/nightly-smoke-tests.yml b/.github/workflows/nightly-smoke-tests.yml index b1a0fcbfd..644ea9ce4 100644 --- a/.github/workflows/nightly-smoke-tests.yml +++ b/.github/workflows/nightly-smoke-tests.yml @@ -4,19 +4,27 @@ on: schedule: - cron: "0 0 * * *" workflow_dispatch: + inputs: + sha: + description: 'Commit SHA to test' + required: false + default: '' + type: string + jobs: smoke_tests: + if: github.repository == 'linode/linode_api4-python' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: dev - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.x' @@ -29,7 +37,41 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Run smoke tests + id: smoke_tests run: | - make smoketest + make test-smoke env: LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} + + - name: Notify Slack + if: always() && github.repository == 'linode/linode_api4-python' + uses: slackapi/slack-github-action@v2.1.1 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} + payload: | + channel: ${{ secrets.SLACK_CHANNEL_ID }} + blocks: + - type: section + text: + type: mrkdwn + text: ":rocket: *${{ github.workflow }} Completed in: ${{ github.repository }}* :white_check_mark:" + - type: divider + - type: section + fields: + - type: mrkdwn + text: "*Build Result:*\n${{ steps.smoke_tests.outcome == 'success' && ':large_green_circle: Build Passed' || ':red_circle: Build Failed' }}" + - type: mrkdwn + text: "*Branch:*\n`${{ github.ref_name }}`" + - type: section + fields: + - type: mrkdwn + text: "*Commit Hash:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>" + - type: mrkdwn + text: "*Run URL:*\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run Details>" + - type: divider + - type: context + elements: + - type: mrkdwn + text: "Triggered by: :bust_in_silhouette: `${{ github.actor }}`" + diff --git a/.github/workflows/publish-pypi.yaml b/.github/workflows/publish-pypi.yaml index bca202209..a791be4c9 100644 --- a/.github/workflows/publish-pypi.yaml +++ b/.github/workflows/publish-pypi.yaml @@ -5,13 +5,17 @@ on: types: [ published ] jobs: pypi-release: + permissions: + # IMPORTANT: this permission is mandatory for trusted publishing + id-token: write runs-on: ubuntu-latest + environment: pypi-release steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.x' @@ -24,6 +28,4 @@ jobs: LINODE_SDK_VERSION: ${{ github.event.release.tag_name }} - name: Publish the release artifacts to PyPI - uses: pypa/gh-action-pypi-publish@2f6f737ca5f74c637829c0f5c3acd0e29ea5e8bf # pin@release/v1.8.11 - with: - password: ${{ secrets.PYPI_API_TOKEN }} + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # pin@release/v1.13.0 diff --git a/.github/workflows/release-cross-repo-test.yml b/.github/workflows/release-cross-repo-test.yml index 8708c3422..69bf8031f 100644 --- a/.github/workflows/release-cross-repo-test.yml +++ b/.github/workflows/release-cross-repo-test.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout linode_api4 repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 submodules: 'recursive' @@ -25,12 +25,12 @@ jobs: run: sudo apt-get install -y build-essential - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: '3.10' - name: Checkout ansible repo - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: repository: linode/ansible_linode path: .ansible/collections/ansible_collections/linode/cloud @@ -43,7 +43,7 @@ jobs: pip install -r requirements.txt -r requirements-dev.txt --upgrade-strategy only-if-needed - name: install ansible dependencies - run: ansible-galaxy collection install amazon.aws:==6.0.1 + run: ansible-galaxy collection install amazon.aws:==9.1.0 - name: install collection run: | diff --git a/.github/workflows/release-notify-slack.yml b/.github/workflows/release-notify-slack.yml new file mode 100644 index 000000000..4b01f094b --- /dev/null +++ b/.github/workflows/release-notify-slack.yml @@ -0,0 +1,24 @@ +name: Notify Dev DX Channel on Release +on: + release: + types: [published] + workflow_dispatch: null + +jobs: + notify: + if: github.repository == 'linode/linode_api4-python' + runs-on: ubuntu-latest + steps: + - name: Notify Slack - Main Message + id: main_message + uses: slackapi/slack-github-action@v2.1.1 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} + payload: | + channel: ${{ secrets.DEV_DX_SLACK_CHANNEL_ID }} + blocks: + - type: section + text: + type: mrkdwn + text: "*New Release Published: _linode_api4-python_ <${{ github.event.release.html_url }}|${{ github.event.release.tag_name }}> is now live!* :tada:" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6043f36e5..7beded74d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ docs/_build/* venv baked_version .vscode +.DS_Store diff --git a/.pylintrc b/.pylintrc index 2084a0c5d..49a156351 100644 --- a/.pylintrc +++ b/.pylintrc @@ -50,7 +50,7 @@ confidence= # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use"--disable=all --enable=classes # --disable=W" -disable=consider-using-dict-items,blacklisted-name,invalid-name,missing-docstring,empty-docstring,unneeded-not,singleton-comparison,misplaced-comparison-constant,unidiomatic-typecheck,consider-using-enumerate,consider-iterating-dictionary,bad-classmethod-argument,bad-mcs-method-argument,bad-mcs-classmethod-argument,single-string-used-for-slots,line-too-long,too-many-lines,trailing-whitespace,missing-final-newline,trailing-newlines,multiple-statements,superfluous-parens,bad-whitespace,mixed-line-endings,unexpected-line-ending-format,bad-continuation,wrong-spelling-in-comment,wrong-spelling-in-docstring,invalid-characters-in-docstring,multiple-imports,wrong-import-order,ungrouped-imports,wrong-import-position,old-style-class,len-as-condition,fatal,astroid-error,parse-error,method-check-failed,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,literal-comparison,no-self-use,no-classmethod-decorator,no-staticmethod-decorator,cyclic-import,duplicate-code,too-many-ancestors,too-many-instance-attributes,too-few-public-methods,too-many-public-methods,too-many-return-statements,too-many-branches,too-many-arguments,too-many-locals,too-many-statements,too-many-boolean-expressions,consider-merging-isinstance,too-many-nested-blocks,simplifiable-if-statement,redefined-argument-from-local,no-else-return,consider-using-ternary,trailing-comma-tuple,unreachable,dangerous-default-value,pointless-statement,pointless-string-statement,expression-not-assigned,unnecessary-pass,unnecessary-lambda,duplicate-key,deprecated-lambda,assign-to-new-keyword,useless-else-on-loop,exec-used,eval-used,confusing-with-statement,using-constant-test,lost-exception,assert-on-tuple,attribute-defined-outside-init,bad-staticmethod-argument,protected-access,arguments-differ,signature-differs,abstract-method,super-init-not-called,no-init,non-parent-init-called,useless-super-delegation,unnecessary-semicolon,bad-indentation,mixed-indentation,lowercase-l-suffix,wildcard-import,deprecated-module,relative-import,reimported,import-self,misplaced-future,fixme,invalid-encoded-data,global-variable-undefined,global-variable-not-assigned,global-statement,global-at-module-level,unused-import,unused-variable,unused-argument,unused-wildcard-import,redefined-outer-name,redefined-builtin,redefine-in-handler,undefined-loop-variable,cell-var-from-loop,bare-except,broad-except,duplicate-except,nonstandard-exception,binary-op-exception,property-on-old-class,logging-not-lazy,logging-format-interpolation,bad-format-string-key,unused-format-string-key,bad-format-string,missing-format-argument-key,unused-format-string-argument,format-combined-specification,missing-format-attribute,invalid-format-index,anomalous-backslash-in-string,anomalous-unicode-escape-in-string,bad-open-mode,boolean-datetime,redundant-unittest-assert,deprecated-method,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call,useless-object-inheritance,comparison-with-callable,bad-option-value,consider-using-f-string,unspecified-encoding,missing-timeout,unnecessary-dunder-call,no-value-for-parameter,c-extension-no-member,attribute-defined-outside-init,use-a-generator +disable=consider-using-dict-items,blacklisted-name,invalid-name,missing-docstring,empty-docstring,unneeded-not,singleton-comparison,misplaced-comparison-constant,unidiomatic-typecheck,consider-using-enumerate,consider-iterating-dictionary,bad-classmethod-argument,bad-mcs-method-argument,bad-mcs-classmethod-argument,single-string-used-for-slots,line-too-long,too-many-lines,trailing-whitespace,missing-final-newline,trailing-newlines,multiple-statements,superfluous-parens,bad-whitespace,mixed-line-endings,unexpected-line-ending-format,bad-continuation,wrong-spelling-in-comment,wrong-spelling-in-docstring,invalid-characters-in-docstring,multiple-imports,wrong-import-order,ungrouped-imports,wrong-import-position,old-style-class,len-as-condition,fatal,astroid-error,parse-error,method-check-failed,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,literal-comparison,no-self-use,no-classmethod-decorator,no-staticmethod-decorator,cyclic-import,duplicate-code,too-many-ancestors,too-many-instance-attributes,too-few-public-methods,too-many-public-methods,too-many-return-statements,too-many-branches,too-many-arguments,too-many-locals,too-many-statements,too-many-boolean-expressions,consider-merging-isinstance,too-many-nested-blocks,simplifiable-if-statement,redefined-argument-from-local,no-else-return,consider-using-ternary,trailing-comma-tuple,unreachable,dangerous-default-value,pointless-statement,pointless-string-statement,expression-not-assigned,unnecessary-pass,unnecessary-lambda,duplicate-key,deprecated-lambda,assign-to-new-keyword,useless-else-on-loop,exec-used,eval-used,confusing-with-statement,using-constant-test,lost-exception,assert-on-tuple,attribute-defined-outside-init,bad-staticmethod-argument,protected-access,arguments-differ,signature-differs,abstract-method,super-init-not-called,no-init,non-parent-init-called,useless-super-delegation,unnecessary-semicolon,bad-indentation,mixed-indentation,lowercase-l-suffix,wildcard-import,deprecated-module,relative-import,reimported,import-self,misplaced-future,fixme,invalid-encoded-data,global-variable-undefined,global-variable-not-assigned,global-statement,global-at-module-level,unused-import,unused-variable,unused-argument,unused-wildcard-import,redefined-outer-name,redefined-builtin,redefine-in-handler,undefined-loop-variable,cell-var-from-loop,bare-except,broad-except,duplicate-except,nonstandard-exception,binary-op-exception,property-on-old-class,logging-not-lazy,logging-format-interpolation,bad-format-string-key,unused-format-string-key,bad-format-string,missing-format-argument-key,unused-format-string-argument,format-combined-specification,missing-format-attribute,invalid-format-index,anomalous-backslash-in-string,anomalous-unicode-escape-in-string,bad-open-mode,boolean-datetime,redundant-unittest-assert,deprecated-method,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call,useless-object-inheritance,comparison-with-callable,bad-option-value,consider-using-f-string,unspecified-encoding,missing-timeout,unnecessary-dunder-call,no-value-for-parameter,c-extension-no-member,attribute-defined-outside-init,use-a-generator,too-many-positional-arguments # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/CODEOWNERS b/CODEOWNERS index 69cb641ca..e023b0d14 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,2 +1 @@ -* @linode/dx - +* @linode/dx @linode/dx-sdets diff --git a/MANIFEST.in b/MANIFEST.in index 96c48f6d8..d15ca4b00 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,6 @@ +# Include all files under test/ directory in source distribution only graft test + +# Exclude Python bytecode global-exclude *.pyc -include baked_version \ No newline at end of file +global-exclude __pycache__ diff --git a/Makefile b/Makefile index 03a527169..ce7ef77d0 100644 --- a/Makefile +++ b/Makefile @@ -1,29 +1,9 @@ PYTHON ?= python3 -TEST_CASE_COMMAND := -TEST_SUITE := -TEST_ARGS := - LINODE_SDK_VERSION ?= "0.0.0.dev" VERSION_MODULE_DOCSTRING ?= \"\"\"\nThe version of this linode_api4 package.\n\"\"\"\n\n VERSION_FILE := ./linode_api4/version.py -ifdef TEST_CASE - TEST_CASE_COMMAND = -k $(TEST_CASE) -endif - -ifdef TEST_SUITE - ifneq ($(TEST_SUITE),linode_client) - ifneq ($(TEST_SUITE),login_client) - TEST_COMMAND = models/$(TEST_SUITE) - else - TEST_COMMAND = login_client - endif - else - TEST_COMMAND = linode_client - endif -endif - .PHONY: clean clean: mkdir -p dist @@ -73,14 +53,21 @@ lint: build $(PYTHON) -m pylint linode_api4 $(PYTHON) -m twine check dist/* -.PHONY: testint -testint: - $(PYTHON) -m pytest test/integration/${TEST_COMMAND} ${TEST_CASE_COMMAND} ${TEST_ARGS} +# Integration Test Arguments +# TEST_SUITE: Optional, specify a test suite (e.g. domain), Default to run everything if not set +# TEST_CASE: Optional, specify a test case (e.g. 'test_image_replication') +# TEST_ARGS: Optional, additional arguments for pytest (e.g. '-v' for verbose mode) + +TEST_COMMAND = $(if $(TEST_SUITE),$(if $(filter $(TEST_SUITE),linode_client login_client filters),$(TEST_SUITE),models/$(TEST_SUITE))) + +.PHONY: test-int +test-int: + $(PYTHON) -m pytest test/integration/${TEST_COMMAND} $(if $(TEST_CASE),-k $(TEST_CASE)) ${TEST_ARGS} -.PHONY: testunit -testunit: +.PHONY: test-unit +test-unit: $(PYTHON) -m pytest test/unit -.PHONY: smoketest -smoketest: +.PHONY: test-smoke +test-smoke: $(PYTHON) -m pytest -m smoke test/integration \ No newline at end of file diff --git a/README.rst b/README.rst index 1e6b310f4..5615bb488 100644 --- a/README.rst +++ b/README.rst @@ -148,16 +148,16 @@ Running the tests ^^^^^^^^^^^^^^^^^ Run the tests locally using the make command. Run the entire test suite using command below:: - make testint + make test-int To run a specific package/suite, use the environment variable `TEST_SUITE` using directory names in `integration/...` folder :: - make TEST_SUITE="account" testint // Runs tests in `integration/models/account` directory - make TEST_SUITE="linode_client" testint // Runs tests in `integration/linode_client` directory + make TEST_SUITE="account" test-int // Runs tests in `integration/models/account` directory + make TEST_SUITE="linode_client" test-int // Runs tests in `integration/linode_client` directory -Lastly to run a specific test case use environment variable `TEST_CASE` with `testint` command:: +Lastly to run a specific test case use environment variable `TEST_CASE` with `test-int` command:: - make TEST_CASE=test_get_domain_record testint + make TEST_CASE=test_get_domain_record test-int Documentation ------------- diff --git a/docs/conf.py b/docs/conf.py index cd15307ac..ee6609943 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,11 +10,11 @@ # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. +# documentation root, use Path(...).absolute() to make it absolute, like shown here. # -import os import sys -sys.path.insert(0, os.path.abspath('..')) +from pathlib import Path +sys.path.insert(0, str(Path('..').absolute())) # -- Project information ----------------------------------------------------- diff --git a/docs/linode_api4/linode_client.rst b/docs/linode_api4/linode_client.rst index 9e8d135c6..8a602f1c8 100644 --- a/docs/linode_api4/linode_client.rst +++ b/docs/linode_api4/linode_client.rst @@ -125,6 +125,15 @@ Includes methods for interacting with our Longview service. :members: :special-members: +LockGroup +^^^^^^^^^^^^^ + +Includes methods for interacting with our Lock service. + +.. autoclass:: linode_api4.linode_client.LockGroup + :members: + :special-members: + NetworkingGroup ^^^^^^^^^^^^^^^ diff --git a/e2e_scripts b/e2e_scripts index b56178520..3265074d0 160000 --- a/e2e_scripts +++ b/e2e_scripts @@ -1 +1 @@ -Subproject commit b56178520fae446a0a4f38df6259deb845efa667 +Subproject commit 3265074d0d7ff8db6ce5207084051e1fc45d0763 diff --git a/linode_api4/__init__.py b/linode_api4/__init__.py index b347b607d..69fa1111c 100644 --- a/linode_api4/__init__.py +++ b/linode_api4/__init__.py @@ -1,7 +1,7 @@ # isort: skip_file from linode_api4.objects import * from linode_api4.errors import ApiError, UnexpectedResponseError -from linode_api4.linode_client import LinodeClient +from linode_api4.linode_client import LinodeClient, MonitorClient from linode_api4.login_client import LinodeLoginClient, OAuthScopes from linode_api4.paginated_list import PaginatedList from linode_api4.polling import EventPoller diff --git a/linode_api4/common.py b/linode_api4/common.py index df3da9733..ac77d2a05 100644 --- a/linode_api4/common.py +++ b/linode_api4/common.py @@ -1,4 +1,7 @@ -import os +from dataclasses import dataclass +from pathlib import Path + +from linode_api4.objects import JSONObject SSH_KEY_TYPES = ( "ssh-dss", @@ -44,9 +47,9 @@ def load_and_validate_keys(authorized_keys): ret.append(k) else: # it doesn't appear to be a key.. is it a path to the key? - k = os.path.expanduser(k) - if os.path.isfile(k): - with open(k) as f: + k_path = Path(k).expanduser() + if k_path.is_file(): + with open(k_path) as f: ret.append(f.read().rstrip()) else: raise ValueError( @@ -57,3 +60,24 @@ def load_and_validate_keys(authorized_keys): ) ) return ret + + +@dataclass +class Price(JSONObject): + """ + Price contains the core fields of a price object returned by various pricing endpoints. + """ + + hourly: int = 0 + monthly: int = 0 + + +@dataclass +class RegionPrice(JSONObject): + """ + RegionPrice contains the core fields of a region_price object returned by various pricing endpoints. + """ + + id: int = 0 + hourly: int = 0 + monthly: int = 0 diff --git a/linode_api4/errors.py b/linode_api4/errors.py index bc2df6108..511ac8c57 100644 --- a/linode_api4/errors.py +++ b/linode_api4/errors.py @@ -1,4 +1,11 @@ +# Necessary to maintain compatibility with Python < 3.11 +from __future__ import annotations + from builtins import super +from json import JSONDecodeError +from typing import Any, Dict, Optional + +from requests import Response class ApiError(RuntimeError): @@ -8,14 +15,90 @@ class ApiError(RuntimeError): often, this will be caused by invalid input to the API. """ - def __init__(self, message, status=400, json=None): + def __init__( + self, + message: str, + status: int = 400, + json: Optional[Dict[str, Any]] = None, + response: Optional[Response] = None, + ): super().__init__(message) + self.status = status self.json = json + self.response = response + self.errors = [] + if json and "errors" in json and isinstance(json["errors"], list): self.errors = [e["reason"] for e in json["errors"]] + @classmethod + def from_response( + cls, + response: Response, + message: Optional[str] = None, + disable_formatting: bool = False, + ) -> Optional[ApiError]: + """ + Creates an ApiError object from the given response, + or None if the response does not contain an error. + + :arg response: The response to create an ApiError from. + :arg message: An optional message to prepend to the error's message. + :arg disable_formatting: If true, the error's message will not automatically be formatted + with details from the API response. + + :returns: The new API error. + """ + + if response.status_code < 400 or response.status_code > 599: + # No error was found + return None + + request = response.request + + try: + response_json = response.json() + except JSONDecodeError: + response_json = None + + # Use the user-defined message is formatting is disabled + if disable_formatting: + return cls( + message, + status=response.status_code, + json=response_json, + response=response, + ) + + # Build the error string + error_fmt = "N/A" + + if response_json is not None and "errors" in response_json: + errors = [] + + for error in response_json["errors"]: + field = error.get("field") + reason = error.get("reason") + errors.append(f"{field + ': ' if field else ''}{reason}") + + error_fmt = "; ".join(errors) + + elif len(response.text or "") > 0: + error_fmt = response.text + + return cls( + ( + f"{message + ': ' if message is not None else ''}" + f"{f'{request.method} {request.path_url}: ' if request else ''}" + f"[{response.status_code}] {error_fmt}" + ), + status=response.status_code, + json=response_json, + response=response, + ) + class UnexpectedResponseError(RuntimeError): """ @@ -26,7 +109,41 @@ class UnexpectedResponseError(RuntimeError): library, and should be fixed with changes to this codebase. """ - def __init__(self, message, status=200, json=None): + def __init__( + self, + message: str, + status: int = 200, + json: Optional[Dict[str, Any]] = None, + response: Optional[Response] = None, + ): super().__init__(message) + self.status = status self.json = json + self.response = response + + @classmethod + def from_response( + cls, + message: str, + response: Response, + ) -> Optional[UnexpectedResponseError]: + """ + Creates an UnexpectedResponseError object from the given response and message. + + :arg message: The message to create this error with. + :arg response: The response to create an UnexpectedResponseError from. + :returns: The new UnexpectedResponseError. + """ + + try: + response_json = response.json() + except JSONDecodeError: + response_json = None + + return cls( + message, + status=response.status_code, + json=response_json, + response=response, + ) diff --git a/linode_api4/groups/__init__.py b/linode_api4/groups/__init__.py index db08d8939..c835972bc 100644 --- a/linode_api4/groups/__init__.py +++ b/linode_api4/groups/__init__.py @@ -6,9 +6,15 @@ from .database import * from .domain import * from .image import * +from .image_share_group import * from .linode import * from .lke import * +from .lke_tier import * +from .lock import * from .longview import * +from .maintenance import * +from .monitor import * +from .monitor_api import * from .networking import * from .nodebalancer import * from .object_storage import * diff --git a/linode_api4/groups/account.py b/linode_api4/groups/account.py index c2c69c624..6f8c6528e 100644 --- a/linode_api4/groups/account.py +++ b/linode_api4/groups/account.py @@ -101,7 +101,7 @@ def settings(self): s = AccountSettings(self.client, result["managed"], result) return s - def invoices(self): + def invoices(self, *filters): """ Returns Invoices issued to this account. @@ -112,9 +112,9 @@ def invoices(self): :returns: Invoices issued to this account. :rtype: PaginatedList of Invoice """ - return self.client._get_and_filter(Invoice) + return self.client._get_and_filter(Invoice, *filters) - def payments(self): + def payments(self, *filters): """ Returns a list of Payments made on this account. @@ -123,7 +123,7 @@ def payments(self): :returns: A list of payments made on this account. :rtype: PaginatedList of Payment """ - return self.client._get_and_filter(Payment) + return self.client._get_and_filter(Payment, *filters) def oauth_clients(self, *filters): """ @@ -201,7 +201,7 @@ def maintenance(self): """ Returns a collection of Maintenance objects for any entity a user has permissions to view. Cancelled Maintenance objects are not returned. - API Documentation: https://techdocs.akamai.com/linode-api/reference/get-account-logins + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-maintenance :returns: A list of Maintenance objects on this account. :rtype: List of Maintenance objects as MappedObjects @@ -337,7 +337,7 @@ def add_promo_code(self, promo_code): json=resp, ) - def service_transfers(self): + def service_transfers(self, *filters): """ Returns a collection of all created and accepted Service Transfers for this account, regardless of the user that created or accepted the transfer. @@ -347,7 +347,7 @@ def service_transfers(self): :rtype: PaginatedList of ServiceTransfer """ - return self.client._get_and_filter(ServiceTransfer) + return self.client._get_and_filter(ServiceTransfer, *filters) def service_transfer_create(self, entities): """ diff --git a/linode_api4/groups/database.py b/linode_api4/groups/database.py index 957c136cf..9546100a8 100644 --- a/linode_api4/groups/database.py +++ b/linode_api4/groups/database.py @@ -1,13 +1,21 @@ +from typing import Any, Dict, Union + +from linode_api4 import ( + MySQLDatabaseConfigOptions, + PostgreSQLDatabaseConfigOptions, +) from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group from linode_api4.objects import ( - Base, Database, DatabaseEngine, + DatabasePrivateNetwork, DatabaseType, MySQLDatabase, PostgreSQLDatabase, + drop_null_keys, ) +from linode_api4.objects.base import _flatten_request_body_recursive class DatabaseGroup(Group): @@ -62,6 +70,26 @@ def engines(self, *filters): """ return self.client._get_and_filter(DatabaseEngine, *filters) + def mysql_config_options(self): + """ + Returns a detailed list of all the configuration options for MySQL Databases. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-databases-mysql-config + + :returns: The JSON configuration options for MySQL Databases. + """ + return self.client.get("/databases/mysql/config", model=self) + + def postgresql_config_options(self): + """ + Returns a detailed list of all the configuration options for PostgreSQL Databases. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-databases-postgresql-config + + :returns: The JSON configuration options for PostgreSQL Databases. + """ + return self.client.get("/databases/postgresql/config", model=self) + def instances(self, *filters): """ Returns a list of Managed Databases active on this account. @@ -92,7 +120,16 @@ def mysql_instances(self, *filters): """ return self.client._get_and_filter(MySQLDatabase, *filters) - def mysql_create(self, label, region, engine, ltype, **kwargs): + def mysql_create( + self, + label, + region, + engine, + ltype, + engine_config: Union[MySQLDatabaseConfigOptions, Dict[str, Any]] = None, + private_network: Union[DatabasePrivateNetwork, Dict[str, Any]] = None, + **kwargs, + ): """ Creates an :any:`MySQLDatabase` on this account with the given label, region, engine, and node type. For example:: @@ -122,17 +159,81 @@ def mysql_create(self, label, region, engine, ltype, **kwargs): :type engine: str or Engine :param ltype: The Linode Type to use for this cluster :type ltype: str or Type + :param engine_config: The configuration options for this MySQL cluster + :type engine_config: Dict[str, Any] or MySQLDatabaseConfigOptions + :param private_network: The private network settings to use for this cluster + :type private_network: Dict[str, Any] or DatabasePrivateNetwork """ params = { "label": label, - "region": region.id if issubclass(type(region), Base) else region, - "engine": engine.id if issubclass(type(engine), Base) else engine, - "type": ltype.id if issubclass(type(ltype), Base) else ltype, + "region": region, + "engine": engine, + "type": ltype, + "engine_config": engine_config, + "private_network": private_network, + } + params.update(kwargs) + + result = self.client.post( + "/databases/mysql/instances", + data=_flatten_request_body_recursive(drop_null_keys(params)), + ) + + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating MySQL Database", json=result + ) + + d = MySQLDatabase(self.client, result["id"], result) + return d + + def mysql_fork(self, source, restore_time, **kwargs): + """ + Forks an :any:`MySQLDatabase` on this account with + the given restore_time. label, region, engine, and ltype are optional. + For example:: + + client = LinodeClient(TOKEN) + + db_to_fork = client.database.mysql_instances()[0] + + new_fork = client.database.mysql_fork( + db_to_fork.id, + db_to_fork.updated, + label="new-fresh-label" + ) + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-databases-mysql-instances + + :param source: The id of the source database + :type source: int + :param restore_time: The timestamp for the fork + :type restore_time: datetime + :param label: The name for this cluster + :type label: str + :param region: The region to deploy this cluster in + :type region: str | Region + :param engine: The engine to deploy this cluster with + :type engine: str | Engine + :param ltype: The Linode Type to use for this cluster + :type ltype: str | Type + """ + + params = { + "fork": { + "source": source, + "restore_time": restore_time.strftime("%Y-%m-%dT%H:%M:%S"), + } } + if "ltype" in kwargs: + params["type"] = kwargs["ltype"] params.update(kwargs) - result = self.client.post("/databases/mysql/instances", data=params) + result = self.client.post( + "/databases/mysql/instances", + data=_flatten_request_body_recursive(drop_null_keys(params)), + ) if "id" not in result: raise UnexpectedResponseError( @@ -157,7 +258,18 @@ def postgresql_instances(self, *filters): """ return self.client._get_and_filter(PostgreSQLDatabase, *filters) - def postgresql_create(self, label, region, engine, ltype, **kwargs): + def postgresql_create( + self, + label, + region, + engine, + ltype, + engine_config: Union[ + PostgreSQLDatabaseConfigOptions, Dict[str, Any] + ] = None, + private_network: Union[DatabasePrivateNetwork, Dict[str, Any]] = None, + **kwargs, + ): """ Creates an :any:`PostgreSQLDatabase` on this account with the given label, region, engine, and node type. For example:: @@ -187,18 +299,81 @@ def postgresql_create(self, label, region, engine, ltype, **kwargs): :type engine: str or Engine :param ltype: The Linode Type to use for this cluster :type ltype: str or Type + :param engine_config: The configuration options for this PostgreSQL cluster + :type engine_config: Dict[str, Any] or PostgreSQLDatabaseConfigOptions + :param private_network: The private network settings to use for this cluster + :type private_network: Dict[str, Any] or DatabasePrivateNetwork """ params = { "label": label, - "region": region.id if issubclass(type(region), Base) else region, - "engine": engine.id if issubclass(type(engine), Base) else engine, - "type": ltype.id if issubclass(type(ltype), Base) else ltype, + "region": region, + "engine": engine, + "type": ltype, + "engine_config": engine_config, + "private_network": private_network, + } + params.update(kwargs) + + result = self.client.post( + "/databases/postgresql/instances", + data=_flatten_request_body_recursive(drop_null_keys(params)), + ) + + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating PostgreSQL Database", + json=result, + ) + + d = PostgreSQLDatabase(self.client, result["id"], result) + return d + + def postgresql_fork(self, source, restore_time, **kwargs): + """ + Forks an :any:`PostgreSQLDatabase` on this account with + the given restore_time. label, region, engine, and ltype are optional. + For example:: + + client = LinodeClient(TOKEN) + + db_to_fork = client.database.postgresql_instances()[0] + + new_fork = client.database.postgresql_fork( + db_to_fork.id, + db_to_fork.updated, + label="new-fresh-label" + ) + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-databases-postgresql-instances + + :param source: The id of the source database + :type source: int + :param restore_time: The timestamp for the fork + :type restore_time: datetime + :param label: The name for this cluster + :type label: str + :param region: The region to deploy this cluster in + :type region: str | Region + :param engine: The engine to deploy this cluster with + :type engine: str | Engine + :param ltype: The Linode Type to use for this cluster + :type ltype: str | Type + """ + + params = { + "fork": { + "source": source, + "restore_time": restore_time.strftime("%Y-%m-%dT%H:%M:%S"), + } } + if "ltype" in kwargs: + params["type"] = kwargs["ltype"] params.update(kwargs) result = self.client.post( - "/databases/postgresql/instances", data=params + "/databases/postgresql/instances", + data=_flatten_request_body_recursive(drop_null_keys(params)), ) if "id" not in result: diff --git a/linode_api4/groups/group.py b/linode_api4/groups/group.py index c591b7fda..b7c0e1eeb 100644 --- a/linode_api4/groups/group.py +++ b/linode_api4/groups/group.py @@ -3,9 +3,9 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from linode_api4 import LinodeClient + from linode_api4.linode_client import BaseClient class Group: - def __init__(self, client: LinodeClient): + def __init__(self, client: BaseClient): self.client = client diff --git a/linode_api4/groups/image.py b/linode_api4/groups/image.py index 451a73d19..fda56fb0a 100644 --- a/linode_api4/groups/image.py +++ b/linode_api4/groups/image.py @@ -4,7 +4,8 @@ from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group -from linode_api4.objects import Base, Disk, Image +from linode_api4.objects import Disk, Image +from linode_api4.objects.base import _flatten_request_body_recursive from linode_api4.util import drop_null_keys @@ -32,8 +33,8 @@ def __call__(self, *filters): def create( self, disk: Union[Disk, int], - label: str = None, - description: str = None, + label: Optional[str] = None, + description: Optional[str] = None, cloud_init: bool = False, tags: Optional[List[str]] = None, ): @@ -58,7 +59,7 @@ def create( :rtype: Image """ params = { - "disk_id": disk.id if issubclass(type(disk), Base) else disk, + "disk_id": disk, "label": label, "description": description, "tags": tags, @@ -67,7 +68,10 @@ def create( if cloud_init: params["cloud_init"] = cloud_init - result = self.client.post("/images", data=drop_null_keys(params)) + result = self.client.post( + "/images", + data=_flatten_request_body_recursive(drop_null_keys(params)), + ) if not "id" in result: raise UnexpectedResponseError( @@ -82,7 +86,7 @@ def create_upload( self, label: str, region: str, - description: str = None, + description: Optional[str] = None, cloud_init: bool = False, tags: Optional[List[str]] = None, ) -> Tuple[Image, str]: @@ -132,7 +136,7 @@ def upload( label: str, region: str, file: BinaryIO, - description: str = None, + description: Optional[str] = None, tags: Optional[List[str]] = None, ) -> Image: """ diff --git a/linode_api4/groups/image_share_group.py b/linode_api4/groups/image_share_group.py new file mode 100644 index 000000000..e932f400b --- /dev/null +++ b/linode_api4/groups/image_share_group.py @@ -0,0 +1,142 @@ +from typing import Optional + +from linode_api4.groups import Group +from linode_api4.objects import ( + ImageShareGroup, + ImageShareGroupImagesToAdd, + ImageShareGroupToken, +) +from linode_api4.objects.base import _flatten_request_body_recursive +from linode_api4.util import drop_null_keys + + +class ImageShareGroupAPIGroup(Group): + """ + Collections related to Private Image Sharing. + + NOTE: Private Image Sharing features are in beta and may not be generally available. + """ + + def __call__(self, *filters): + """ + Retrieves a list of Image Share Groups created by the user (producer). + You can filter this query to retrieve only Image Share Groups + relevant to a specific query, for example:: + + filtered_share_groups = client.sharegroups( + ImageShareGroup.label == "my-label") + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-sharegroups + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of Image Share Groups. + :rtype: PaginatedList of ImageShareGroup + """ + return self.client._get_and_filter(ImageShareGroup, *filters) + + def sharegroups_by_image_id(self, image_id: str): + """ + Retrieves a list of Image Share Groups that share a specific Private Image. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-images-sharegroups-image + + :param image_id: The ID of the Image to query for. + :type image_id: str + + :returns: A list of Image Share Groups sharing the specified Image. + :rtype: PaginatedList of ImageShareGroup + """ + return self.client._get_and_filter( + ImageShareGroup, endpoint="/images/{}/sharegroups".format(image_id) + ) + + def tokens(self, *filters): + """ + Retrieves a list of Image Share Group Tokens created by the user (consumer). + You can filter this query to retrieve only Image Share Group Tokens + relevant to a specific query, for example:: + + filtered_share_group_tokens = client.sharegroups.tokens( + ImageShareGroupToken.label == "my-label") + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-user-tokens + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of Image Share Group Tokens. + :rtype: PaginatedList of ImageShareGroupToken + """ + return self.client._get_and_filter(ImageShareGroupToken, *filters) + + def create_sharegroup( + self, + label: Optional[str] = None, + description: Optional[str] = None, + images: Optional[ImageShareGroupImagesToAdd] = None, + ): + """ + Creates a new Image Share Group. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-sharegroups + + :param label: The label for the resulting Image Share Group. + :type label: str + :param description: The description for the new Image Share Group. + :type description: str + :param images: A list of Images to share in the new Image Share Group, formatted in JSON. + :type images: Optional[ImageShareGroupImagesToAdd] + + :returns: The new Image Share Group. + :rtype: ImageShareGroup + """ + params = { + "label": label, + "description": description, + } + + if images: + params["images"] = images + + result = self.client.post( + "/images/sharegroups", + data=_flatten_request_body_recursive(drop_null_keys(params)), + ) + + return ImageShareGroup(self.client, result["id"], result) + + def create_token( + self, valid_for_sharegroup_uuid: str, label: Optional[str] = None + ): + """ + Creates a new Image Share Group Token and returns the token value. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-sharegroup-tokens + + :param valid_for_sharegroup_uuid: The UUID of the Image Share Group that this token will be valid for. + :type valid_for_sharegroup_uuid: Optional[str] + :param label: The label for the resulting Image Share Group Token. + :type label: str + + :returns: The new Image Share Group Token object and the one-time use token itself. + :rtype: (ImageShareGroupToken, str) + """ + params = {"valid_for_sharegroup_uuid": valid_for_sharegroup_uuid} + + if label: + params["label"] = label + + result = self.client.post( + "/images/sharegroups/tokens", + data=_flatten_request_body_recursive(drop_null_keys(params)), + ) + + token_value = result.pop("token", None) + token_obj = ImageShareGroupToken( + self.client, result["token_uuid"], result + ) + return token_obj, token_value diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index da3ba501d..2bd51fa97 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -1,25 +1,30 @@ import base64 -import os -from collections.abc import Iterable -from typing import Optional, Union +from pathlib import Path +from typing import Any, Dict, List, Optional, Union -from linode_api4 import InstanceDiskEncryptionType from linode_api4.common import load_and_validate_keys from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group from linode_api4.objects import ( - Base, - ConfigInterface, Firewall, - Image, Instance, + InstanceDiskEncryptionType, Kernel, + PlacementGroup, StackScript, Type, ) +from linode_api4.objects.base import _flatten_request_body_recursive from linode_api4.objects.filtering import Filter -from linode_api4.objects.linode import _expand_placement_group_assignment -from linode_api4.paginated_list import PaginatedList +from linode_api4.objects.linode import ( + Backup, + InstancePlacementGroupAssignment, + InterfaceGeneration, + NetworkInterface, + _expand_placement_group_assignment, +) +from linode_api4.objects.linode_interfaces import LinodeInterfaceOptions +from linode_api4.util import drop_null_keys class LinodeGroup(Group): @@ -135,9 +140,28 @@ def instance_create( region, image=None, authorized_keys=None, + firewall: Optional[Union[Firewall, int]] = None, + backup: Optional[Union[Backup, int]] = None, + stackscript: Optional[Union[StackScript, int]] = None, disk_encryption: Optional[ Union[InstanceDiskEncryptionType, str] ] = None, + placement_group: Optional[ + Union[ + InstancePlacementGroupAssignment, + PlacementGroup, + Dict[str, Any], + int, + ] + ] = None, + interfaces: Optional[ + List[ + Union[LinodeInterfaceOptions, NetworkInterface, Dict[str, Any]], + ] + ] = None, + interface_generation: Optional[Union[InterfaceGeneration, str]] = None, + network_helper: Optional[bool] = None, + maintenance_policy: Optional[str] = None, **kwargs, ): """ @@ -215,6 +239,30 @@ def instance_create( "us-east", backup=snapshot) + **Create an Instance with explicit interfaces:** + + To create a new Instance with explicit interfaces, provide list of + LinodeInterfaceOptions objects or dicts to the "interfaces" field:: + + linode, password = client.linode.instance_create( + "g6-standard-1", + "us-mia", + image="linode/ubuntu24.04", + + # This can be configured as an account-wide default + interface_generation=InterfaceGeneration.LINODE, + + interfaces=[ + LinodeInterfaceOptions( + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ipv6=True + ), + public=LinodeInterfacePublicOptions + ) + ] + ) + **Create an empty Instance** If you want to create an empty Instance that you will configure manually, @@ -278,9 +326,16 @@ def instance_create( :type disk_encryption: InstanceDiskEncryptionType or str :param interfaces: An array of Network Interfaces to add to this Linode’s Configuration Profile. At least one and up to three Interface objects can exist in this array. - :type interfaces: list[ConfigInterface] or list[dict[str, Any]] + :type interfaces: List[LinodeInterfaceOptions], List[NetworkInterface], or List[dict[str, Any]] :param placement_group: A Placement Group to create this Linode under. :type placement_group: Union[InstancePlacementGroupAssignment, PlacementGroup, Dict[str, Any], int] + :param interface_generation: The generation of network interfaces this Linode uses. + :type interface_generation: InterfaceGeneration or str + :param network_helper: Whether this instance should have Network Helper enabled. + :type network_helper: bool + :param maintenance_policy: The slug of the maintenance policy to apply during maintenance. + If not provided, the default policy (linode/migrate) will be applied. + :type maintenance_policy: str :returns: A new Instance object, or a tuple containing the new Instance and the generated password. @@ -290,65 +345,42 @@ def instance_create( This usually indicates that you are using an outdated library. """ + ret_pass = None if image and not "root_pass" in kwargs: ret_pass = Instance.generate_root_password() kwargs["root_pass"] = ret_pass - authorized_keys = load_and_validate_keys(authorized_keys) - - if "stackscript" in kwargs: - # translate stackscripts - kwargs["stackscript_id"] = ( - kwargs["stackscript"].id - if issubclass(type(kwargs["stackscript"]), Base) - else kwargs["stackscript"] - ) - del kwargs["stackscript"] - - if "backup" in kwargs: - # translate backups - kwargs["backup_id"] = ( - kwargs["backup"].id - if issubclass(type(kwargs["backup"]), Base) - else kwargs["backup"] - ) - del kwargs["backup"] - - if "firewall" in kwargs: - fw = kwargs.pop("firewall") - kwargs["firewall_id"] = fw.id if isinstance(fw, Firewall) else fw - - if "interfaces" in kwargs: - interfaces = kwargs.get("interfaces") - if interfaces is not None and isinstance(interfaces, Iterable): - kwargs["interfaces"] = [ - i._serialize() if isinstance(i, ConfigInterface) else i - for i in interfaces - ] - - if "placement_group" in kwargs: - kwargs["placement_group"] = _expand_placement_group_assignment( - kwargs.get("placement_group") - ) - params = { - "type": ltype.id if issubclass(type(ltype), Base) else ltype, - "region": region.id if issubclass(type(region), Base) else region, - "image": ( - (image.id if issubclass(type(image), Base) else image) - if image + "type": ltype, + "region": region, + "image": image, + "authorized_keys": load_and_validate_keys(authorized_keys), + # These will automatically be flattened below + "firewall_id": firewall, + "backup_id": backup, + "stackscript_id": stackscript, + "maintenance_policy": maintenance_policy, + # Special cases + "disk_encryption": ( + str(disk_encryption) if disk_encryption else None + ), + "placement_group": ( + _expand_placement_group_assignment(placement_group) + if placement_group else None ), - "authorized_keys": authorized_keys, + "interfaces": interfaces, + "interface_generation": interface_generation, + "network_helper": network_helper, } - if disk_encryption is not None: - params["disk_encryption"] = str(disk_encryption) - params.update(kwargs) - result = self.client.post("/linode/instances", data=params) + result = self.client.post( + "/linode/instances", + data=_flatten_request_body_recursive(drop_null_keys(params)), + ) if not "id" in result: raise UnexpectedResponseError( @@ -421,25 +453,13 @@ def stackscript_create( :returns: The new StackScript :rtype: StackScript """ - image_list = None - if type(images) is list or type(images) is PaginatedList: - image_list = [ - d.id if issubclass(type(d), Base) else d for d in images - ] - elif type(images) is Image: - image_list = [images.id] - elif type(images) is str: - image_list = [images] - else: - raise ValueError( - "images must be a list of Images or a single Image" - ) script_body = script if not script.startswith("#!"): # it doesn't look like a stackscript body, let's see if it's a file - if os.path.isfile(script): - with open(script) as f: + script_path = Path(script) + if script_path.is_file(): + with open(script_path) as f: script_body = f.read() else: raise ValueError( @@ -448,14 +468,17 @@ def stackscript_create( params = { "label": label, - "images": image_list, + "images": images, "is_public": public, "script": script_body, "description": desc if desc else "", } params.update(kwargs) - result = self.client.post("/linode/stackscripts", data=params) + result = self.client.post( + "/linode/stackscripts", + data=_flatten_request_body_recursive(params), + ) if not "id" in result: raise UnexpectedResponseError( diff --git a/linode_api4/groups/lke.py b/linode_api4/groups/lke.py index f2bc5a388..330c1d378 100644 --- a/linode_api4/groups/lke.py +++ b/linode_api4/groups/lke.py @@ -1,11 +1,13 @@ -from typing import Any, Dict, Union +from typing import Any, Dict, Optional, Union from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group +from linode_api4.groups.lke_tier import LKETierGroup from linode_api4.objects import ( KubeVersion, LKECluster, LKEClusterControlPlaneOptions, + LKEType, Type, drop_null_keys, ) @@ -60,11 +62,13 @@ def cluster_create( self, region, label, - node_pools, kube_version, + node_pools: Optional[list] = None, control_plane: Union[ LKEClusterControlPlaneOptions, Dict[str, Any] ] = None, + apl_enabled: bool = False, + tier: Optional[str] = None, **kwargs, ): """ @@ -99,14 +103,31 @@ def cluster_create( formatted dicts. :param kube_version: The version of Kubernetes to use :type kube_version: KubeVersion or str - :param control_plane: Dict[str, Any] or LKEClusterControlPlaneRequest - :type control_plane: The control plane configuration of this LKE cluster. + :param control_plane: The control plane configuration of this LKE cluster. + :type control_plane: Dict[str, Any] or LKEClusterControlPlaneRequest + :param apl_enabled: Whether this cluster should use APL. + NOTE: This field is in beta and may only + function if base_url is set to `https://api.linode.com/v4beta`. + :type apl_enabled: bool + :param tier: The tier of LKE cluster to create. + NOTE: This field is in beta and may only + function if base_url is set to `https://api.linode.com/v4beta`. + :type tier: str :param kwargs: Any other arguments to pass along to the API. See the API docs for possible values. :returns: The new LKE Cluster :rtype: LKECluster """ + if node_pools is None: + node_pools = [] + + if len(node_pools) == 0 and ( + tier is None or tier.lower() != "enterprise" + ): + raise ValueError( + "LKE standard clusters must have at least one node pool." + ) params = { "label": label, @@ -116,12 +137,17 @@ def cluster_create( node_pools if isinstance(node_pools, list) else [node_pools] ), "control_plane": control_plane, + "tier": tier, } params.update(kwargs) + # Prevent errors for users without access to APL + if apl_enabled: + params["apl_enabled"] = apl_enabled + result = self.client.post( "/lke/clusters", - data=_flatten_request_body_recursive(drop_null_keys(params)), + data=drop_null_keys(_flatten_request_body_recursive(params)), ) if "id" not in result: @@ -155,3 +181,36 @@ def node_pool(self, node_type: Union[Type, str], node_count: int, **kwargs): result.update(kwargs) return result + + def types(self, *filters): + """ + Returns a :any:`PaginatedList` of :any:`LKEType` objects that represents a valid LKE type. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lke-types + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A Paginated List of LKE types that match the query. + :rtype: PaginatedList of LKEType + """ + + return self.client._get_and_filter( + LKEType, *filters, endpoint="/lke/types" + ) + + def tier(self, id: str) -> LKETierGroup: + """ + Returns an object representing the LKE tier API path. + + NOTE: LKE tiers may not currently be available to all users. + + :param id: The ID of the tier. + :type id: str + + :returns: An object representing the LKE tier API path. + :rtype: LKETier + """ + + return LKETierGroup(self.client, id) diff --git a/linode_api4/groups/lke_tier.py b/linode_api4/groups/lke_tier.py new file mode 100644 index 000000000..e5b8d11e5 --- /dev/null +++ b/linode_api4/groups/lke_tier.py @@ -0,0 +1,40 @@ +from linode_api4.groups import Group +from linode_api4.objects import TieredKubeVersion + + +class LKETierGroup(Group): + """ + Encapsulates methods related to a specific LKE tier. This + should not be instantiated on its own, but should instead be used through + an instance of :any:`LinodeClient`:: + + client = LinodeClient(token) + instances = client.lke.tier("standard") # use the LKETierGroup + + This group contains all features beneath the `/lke/tiers/{tier}` group in the API v4. + """ + + def __init__(self, client: "LinodeClient", tier: str): + super().__init__(client) + self.tier = tier + + def versions(self, *filters): + """ + Returns a paginated list of versions for this tier matching the given filters. + + API Documentation: Not Yet Available + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A paginated list of kube versions that match the query. + :rtype: PaginatedList of TieredKubeVersion + """ + + return self.client._get_and_filter( + TieredKubeVersion, + endpoint=f"/lke/tiers/{self.tier}/versions", + parent_id=self.tier, + *filters, + ) diff --git a/linode_api4/groups/lock.py b/linode_api4/groups/lock.py new file mode 100644 index 000000000..42cc58d80 --- /dev/null +++ b/linode_api4/groups/lock.py @@ -0,0 +1,72 @@ +from typing import Union + +from linode_api4.errors import UnexpectedResponseError +from linode_api4.groups import Group +from linode_api4.objects import Lock, LockType + +__all__ = ["LockGroup"] + + +class LockGroup(Group): + """ + Encapsulates methods for interacting with Resource Locks. + + Resource locks prevent deletion or modification of resources. + Currently, only Linode instances can be locked. + """ + + def __call__(self, *filters): + """ + Returns a list of all Resource Locks on the account. + + This is intended to be called off of the :any:`LinodeClient` + class, like this:: + + locks = client.locks() + + API Documentation: TBD + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of Resource Locks on the account. + :rtype: PaginatedList of Lock + """ + return self.client._get_and_filter(Lock, *filters) + + def create( + self, + entity_type: str, + entity_id: Union[int, str], + lock_type: Union[LockType, str], + ) -> Lock: + """ + Creates a new Resource Lock for the specified entity. + + API Documentation: TBD + + :param entity_type: The type of entity to lock (e.g., "linode"). + :type entity_type: str + :param entity_id: The ID of the entity to lock. + :type entity_id: int | str + :param lock_type: The type of lock to create. Defaults to "cannot_delete". + :type lock_type: LockType | str + + :returns: The newly created Resource Lock. + :rtype: Lock + """ + params = { + "entity_type": entity_type, + "entity_id": entity_id, + "lock_type": lock_type, + } + + result = self.client.post("/locks", data=params) + + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating lock!", json=result + ) + + return Lock(self.client, result["id"], result) diff --git a/linode_api4/groups/maintenance.py b/linode_api4/groups/maintenance.py new file mode 100644 index 000000000..63cb424df --- /dev/null +++ b/linode_api4/groups/maintenance.py @@ -0,0 +1,23 @@ +from linode_api4.groups import Group +from linode_api4.objects import MappedObject + + +class MaintenanceGroup(Group): + """ + Collections related to Maintenance. + """ + + def maintenance_policies(self): + """ + Returns a collection of MaintenancePolicy objects representing + available maintenance policies that can be applied to Linodes + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-maintenance-policies + + :returns: A list of Maintenance Policies that can be applied to Linodes + :rtype: List of MaintenancePolicy objects as MappedObjects + """ + + result = self.client.get("/maintenance/policies", model=self) + + return [MappedObject(**r) for r in result["data"]] diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py new file mode 100644 index 000000000..66943ade5 --- /dev/null +++ b/linode_api4/groups/monitor.py @@ -0,0 +1,286 @@ +from typing import Any, Optional + +from linode_api4 import PaginatedList +from linode_api4.errors import UnexpectedResponseError +from linode_api4.groups import Group +from linode_api4.objects import ( + AlertChannel, + AlertDefinition, + MonitorDashboard, + MonitorMetricsDefinition, + MonitorService, + MonitorServiceToken, +) + +__all__ = [ + "MonitorGroup", +] + + +class MonitorGroup(Group): + """ + Encapsulates Monitor-related methods of the :any:`LinodeClient`. + + This group contains all features beneath the `/monitor` group in the API v4. + """ + + def dashboards( + self, *filters, service_type: Optional[str] = None + ) -> PaginatedList: + """ + Returns a list of dashboards. If `service_type` is provided, it fetches dashboards + for the specific service type. If None, it fetches all dashboards. + + dashboards = client.monitor.dashboards() + dashboard = client.load(MonitorDashboard, 1) + dashboards_by_service = client.monitor.dashboards(service_type="dbaas") + + .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. + + API Documentation: + - All Dashboards: https://techdocs.akamai.com/linode-api/reference/get-dashboards-all + - Dashboards by Service: https://techdocs.akamai.com/linode-api/reference/get-dashboards + + :param service_type: The service type to get dashboards for. + :type service_type: Optional[str] + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of Dashboards. + :rtype: PaginatedList of Dashboard + """ + endpoint = ( + f"/monitor/services/{service_type}/dashboards" + if service_type + else "/monitor/dashboards" + ) + + return self.client._get_and_filter( + MonitorDashboard, + *filters, + endpoint=endpoint, + ) + + def services( + self, + *filters, + ) -> PaginatedList: + """ + Lists services supported by ACLP. + supported_services = client.monitor.services() + service_details = client.monitor.load(MonitorService, "dbaas") + + .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-monitor-services + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-monitor-services-for-service-type + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: Lists monitor services + :rtype: PaginatedList of the Services + """ + endpoint = "/monitor/services" + + return self.client._get_and_filter( + MonitorService, + *filters, + endpoint=endpoint, + ) + + def metric_definitions( + self, service_type: str, *filters + ) -> list[MonitorMetricsDefinition]: + """ + Returns metrics for a specific service type. + + metrics = client.monitor.list_metric_definitions(service_type="dbaas") + .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-monitor-information + + :param service_type: The service type to get metrics for. + :type service_type: str + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: Returns a List of metrics for a service + :rtype: PaginatedList of metrics + """ + return self.client._get_and_filter( + MonitorMetricsDefinition, + *filters, + endpoint=f"/monitor/services/{service_type}/metric-definitions", + ) + + def create_token( + self, service_type: str, entity_ids: list[Any] + ) -> MonitorServiceToken: + """ + Returns a JWE Token for a specific service type. + token = client.monitor.create_token(service_type="dbaas", entity_ids=[1234]) + + .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-get-token + + :param service_type: The service type to create token for. + :type service_type: str + :param entity_ids: The list of entity IDs for which the token is valid. + :type entity_ids: any + + :returns: Returns a token for a service + :rtype: str + """ + + params = {"entity_ids": entity_ids} + + result = self.client.post( + f"/monitor/services/{service_type}/token", data=params + ) + + if "token" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating token!", json=result + ) + return MonitorServiceToken(token=result["token"]) + + def alert_definitions( + self, + *filters, + service_type: Optional[str] = None, + ) -> PaginatedList: + """ + Retrieve alert definitions. + + Returns a paginated collection of :class:`AlertDefinition` objects. If you + need to obtain a single :class:`AlertDefinition`, use :meth:`LinodeClient.load` + and supply the `service_type` as the parent identifier, for example: + + alerts = client.monitor.alert_definitions() + alerts_by_service = client.monitor.alert_definitions(service_type="dbaas") + .. note:: This endpoint is in beta and requires using the v4beta base URL. + + API Documentation: + https://techdocs.akamai.com/linode-api/reference/get-alert-definitions + https://techdocs.akamai.com/linode-api/reference/get-alert-definitions-for-service-type + + :param service_type: Optional service type to scope the query (e.g. ``"dbaas"``). + :type service_type: Optional[str] + :param filters: Optional filtering expressions to apply to the returned + collection. See :doc:`Filtering Collections`. + + :returns: A paginated list of :class:`AlertDefinition` objects. + :rtype: PaginatedList[AlertDefinition] + """ + + endpoint = "/monitor/alert-definitions" + if service_type: + endpoint = f"/monitor/services/{service_type}/alert-definitions" + + # Requesting a list + return self.client._get_and_filter( + AlertDefinition, *filters, endpoint=endpoint + ) + + def alert_channels(self, *filters) -> PaginatedList: + """ + List alert channels for the authenticated account. + + Returns a paginated collection of :class:`AlertChannel` objects which + describe destinations for alert notifications (for example: email + lists, webhooks, PagerDuty, Slack, etc.). By default this method + returns all channels visible to the authenticated account; you can + supply optional filter expressions to restrict the results. + + Examples: + channels = client.monitor.alert_channels() + + .. note:: This endpoint is in beta and requires using the v4beta base URL. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-alert-channels + + :param filters: Optional filter expressions to apply to the collection. + See :doc:`Filtering Collections` for details. + :returns: A paginated list of :class:`AlertChannel` objects. + :rtype: PaginatedList[AlertChannel] + """ + return self.client._get_and_filter(AlertChannel, *filters) + + def create_alert_definition( + self, + service_type: str, + label: str, + severity: int, + channel_ids: list[int], + rule_criteria: dict, + trigger_conditions: dict, + entity_ids: Optional[list[str]] = None, + description: Optional[str] = None, + ) -> AlertDefinition: + """ + Create a new alert definition for a given service type. + + The alert definition configures when alerts are fired and which channels + are notified. + + .. note:: This endpoint is in beta and requires using the v4beta base URL. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-alert-definition-for-service-type + + :param service_type: Service type for which to create the alert definition + (e.g. ``"dbaas"``). + :type service_type: str + :param label: Human-readable label for the alert definition. + :type label: str + :param severity: Severity level for the alert (numeric severity used by API). + :type severity: int + :param channel_ids: List of alert channel IDs to notify when the alert fires. + :type channel_ids: list[int] + :param rule_criteria: Rule criteria that determine when the alert + should be evaluated. Structure depends on the service + metric definitions. + :type rule_criteria: dict + :param trigger_conditions: Trigger conditions that define when + the alert should transition state. + :type trigger_conditions: dict + :param entity_ids: (Optional) Restrict the alert to a subset of entity IDs. + :type entity_ids: Optional[list[str]] + :param description: (Optional) Longer description for the alert definition. + :type description: Optional[str] + + :returns: The newly created :class:`AlertDefinition`. + :rtype: AlertDefinition + + .. note:: + For updating an alert definition, use the ``save()`` method on the :class:`AlertDefinition` object. + For deleting an alert definition, use the ``delete()`` method directly on the :class:`AlertDefinition` object. + """ + params = { + "label": label, + "severity": severity, + "channel_ids": channel_ids, + "rule_criteria": rule_criteria, + "trigger_conditions": trigger_conditions, + } + if description is not None: + params["description"] = description + if entity_ids is not None: + params["entity_ids"] = entity_ids + + # API will validate service_type and return an error if missing + result = self.client.post( + f"/monitor/services/{service_type}/alert-definitions", data=params + ) + + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating alert definition!", + json=result, + ) + + return AlertDefinition(self.client, result["id"], service_type, result) diff --git a/linode_api4/groups/monitor_api.py b/linode_api4/groups/monitor_api.py new file mode 100644 index 000000000..48e2b2c30 --- /dev/null +++ b/linode_api4/groups/monitor_api.py @@ -0,0 +1,59 @@ +__all__ = [ + "MetricsGroup", +] + +from typing import Any, Dict, List, Optional, Union + +from linode_api4 import drop_null_keys +from linode_api4.groups import Group +from linode_api4.objects.base import _flatten_request_body_recursive +from linode_api4.objects.monitor_api import EntityMetricOptions, EntityMetrics + + +class MetricsGroup(Group): + """ + Encapsulates Monitor-related methods of the :any:`MonitorClient`. + + This group contains all features related to metrics in the API monitor-api. + """ + + def fetch_metrics( + self, + service_type: str, + entity_ids: list, + metrics: List[Union[EntityMetricOptions, Dict[str, Any]]], + **kwargs, + ) -> Optional[EntityMetrics]: + """ + Returns metrics information for the individual entities within a specific service type. + + API documentation: https://techdocs.akamai.com/linode-api/reference/post-read-metric + + :param service_type: The service being monitored. + Currently, only the Managed Databases (dbaas) service type is supported. + :type service_type: str + + :param entity_ids: The id for each individual entity from a service_type. + :type entity_ids: list + + :param metrics: A list of metric objects, each specifying a metric name and its corresponding aggregation function. + :type metrics: list of EntityMetricOptions or Dict[str, Any] + + :param kwargs: Any other arguments accepted by the api. Please refer to the API documentation for full info. + + :returns: Service metrics requested. + :rtype: EntityMetrics or None + """ + params = { + "entity_ids": entity_ids, + "metrics": metrics, + } + + params.update(kwargs) + + result = self.client.post( + f"/monitor/services/{service_type}/metrics", + data=drop_null_keys(_flatten_request_body_recursive(params)), + ) + + return EntityMetrics.from_json(result) diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index 7ba6919e4..b16d12d9a 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -1,15 +1,23 @@ +from typing import Any, Dict, Optional, Union + from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group from linode_api4.objects import ( VLAN, Base, Firewall, + FirewallCreateDevicesOptions, + FirewallSettings, + FirewallTemplate, Instance, IPAddress, IPv6Pool, IPv6Range, + NetworkTransferPrice, Region, ) +from linode_api4.objects.base import _flatten_request_body_recursive +from linode_api4.util import drop_null_keys class NetworkingGroup(Group): @@ -32,7 +40,15 @@ def firewalls(self, *filters): """ return self.client._get_and_filter(Firewall, *filters) - def firewall_create(self, label, rules, **kwargs): + def firewall_create( + self, + label: str, + rules: Dict[str, Any], + devices: Optional[ + Union[FirewallCreateDevicesOptions, Dict[str, Any]] + ] = None, + **kwargs, + ): """ Creates a new Firewall, either in the given Region or attached to the given Instance. @@ -43,6 +59,8 @@ def firewall_create(self, label, rules, **kwargs): :type label: str :param rules: The rules to apply to the new Firewall. For more information on Firewall rules, see our `Firewalls Documentation`_. :type rules: dict + :param devices: Represents devices to create created alongside a Linode Firewall. + :type devices: Optional[Union[FirewallCreateDevicesOptions, Dict[str, Any]]] :returns: The new Firewall. :rtype: Firewall @@ -80,10 +98,14 @@ def firewall_create(self, label, rules, **kwargs): params = { "label": label, "rules": rules, + "devices": devices, } params.update(kwargs) - result = self.client.post("/networking/firewalls", data=params) + result = self.client.post( + "/networking/firewalls", + data=drop_null_keys(_flatten_request_body_recursive(params)), + ) if not "id" in result: raise UnexpectedResponseError( @@ -93,6 +115,43 @@ def firewall_create(self, label, rules, **kwargs): f = Firewall(self.client, result["id"], result) return f + def firewall_templates(self, *filters): + """ + Returns a list of Firewall Templates available to the current user. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-firewall-templates + + NOTE: This feature may not currently be available to all users. + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of Firewall Templates available to the current user. + :rtype: PaginatedList of FirewallTemplate + """ + return self.client._get_and_filter(FirewallTemplate, *filters) + + def firewall_settings(self) -> FirewallSettings: + """ + Returns an object representing the Linode Firewall settings for the current user. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-firewall-settings + + NOTE: This feature may not currently be available to all users. + :returns: An object representing the Linode Firewall settings for the current user. + :rtype: FirewallSettings + """ + result = self.client.get("/networking/firewalls/settings") + + if "default_firewall_ids" not in result: + raise UnexpectedResponseError( + "Unexpected response when getting firewall settings!", + json=result, + ) + + return FirewallSettings(self.client, None, result) + def ips(self, *filters): """ Returns a list of IP addresses on this account, excluding private addresses. @@ -123,6 +182,64 @@ def ipv6_ranges(self, *filters): """ return self.client._get_and_filter(IPv6Range, *filters) + def ipv6_range_allocate( + self, + prefix_length: int, + route_target: Optional[str] = None, + linode: Optional[Union[Instance, int]] = None, + **kwargs, + ) -> IPv6Range: + """ + Creates an IPv6 Range and assigns it based on the provided Linode or route target IPv6 SLAAC address. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-ipv6-range + + Create an IPv6 range assigned to a Linode by ID:: + + range = client.networking.ipv6_range_allocate(64, linode_id=123) + + + Create an IPv6 range assigned to a Linode by SLAAC:: + + range = client.networking.ipv6_range_allocate( + 64, + route_target=instance.ipv6.split("/")[0] + ) + + :param prefix_length: The prefix length of the IPv6 range. + :type prefix_length: int + :param route_target: The IPv6 SLAAC address to assign this range to. Required if linode is not specified. + :type route_target: str + :param linode: The ID of the Linode to assign this range to. + The SLAAC address for the provided Linode is used as the range's route_target. + Required if linode is not specified. + :type linode: Instance or int + + :returns: The new IPAddress. + :rtype: IPAddress + """ + + params = { + "prefix_length": prefix_length, + "route_target": route_target, + "linode_id": linode, + } + + params.update(**kwargs) + + result = self.client.post( + "/networking/ipv6/ranges", + data=drop_null_keys(_flatten_request_body_recursive(params)), + ) + + if not "range" in result: + raise UnexpectedResponseError( + "Unexpected response when allocating IPv6 range!", json=result + ) + + result = IPv6Range(self.client, result["range"], result) + return result + def ipv6_pools(self, *filters): """ Returns a list of IPv6 pools on this account. @@ -348,3 +465,48 @@ def ip_addresses_assign(self, assignments, region): params = {"assignments": assignments, "region": region} self.client.post("/networking/ips/assign", model=self, data=params) + + def transfer_prices(self, *filters): + """ + Returns a :any:`PaginatedList` of :any:`NetworkTransferPrice` objects that represents a valid network transfer price. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-network-transfer-prices + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A Paginated List of network transfer price that match the query. + :rtype: PaginatedList of NetworkTransferPrice + """ + + return self.client._get_and_filter( + NetworkTransferPrice, *filters, endpoint="/network-transfer/prices" + ) + + def delete_vlan(self, vlan, region): + """ + This operation deletes a VLAN. + You can't delete a VLAN if it's still attached to a Linode. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/delete-vlan + + :param vlan: The label of the VLAN to be deleted. + :type vlan: str or VLAN + :param region: The VLAN's region. + :type region: str or Region + """ + if isinstance(region, Region): + region = region.id + + if isinstance(vlan, VLAN): + vlan = vlan.label + resp = self.client.delete( + "/networking/vlans/{}/{}".format(region, vlan), + model=self, + ) + + if "error" in resp: + return False + + return True diff --git a/linode_api4/groups/nodebalancer.py b/linode_api4/groups/nodebalancer.py index 50068f8eb..57830c8c4 100644 --- a/linode_api4/groups/nodebalancer.py +++ b/linode_api4/groups/nodebalancer.py @@ -1,6 +1,6 @@ from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group -from linode_api4.objects import Base, NodeBalancer +from linode_api4.objects import Base, NodeBalancer, NodeBalancerType class NodeBalancerGroup(Group): @@ -50,3 +50,21 @@ def create(self, region, **kwargs): n = NodeBalancer(self.client, result["id"], result) return n + + def types(self, *filters): + """ + Returns a :any:`PaginatedList` of :any:`NodeBalancerType` objects that represents a valid NodeBalancer type. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-node-balancer-types + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A Paginated List of NodeBalancer types that match the query. + :rtype: PaginatedList of NodeBalancerType + """ + + return self.client._get_and_filter( + NodeBalancerType, *filters, endpoint="/nodebalancers/types" + ) diff --git a/linode_api4/groups/object_storage.py b/linode_api4/groups/object_storage.py index f531932e0..5ffab3ffc 100644 --- a/linode_api4/groups/object_storage.py +++ b/linode_api4/groups/object_storage.py @@ -5,6 +5,12 @@ from deprecated import deprecated +from linode_api4 import ( + ObjectStorageEndpoint, + ObjectStorageEndpointType, + ObjectStorageType, + PaginatedList, +) from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group from linode_api4.objects import ( @@ -15,6 +21,7 @@ ObjectStorageCluster, ObjectStorageKeyPermission, ObjectStorageKeys, + ObjectStorageQuota, ) from linode_api4.util import drop_null_keys @@ -65,6 +72,24 @@ def keys(self, *filters): """ return self.client._get_and_filter(ObjectStorageKeys, *filters) + def types(self, *filters): + """ + Returns a paginated list of Object Storage Types. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-types + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A Paginated List of Object Storage types that match the query. + :rtype: PaginatedList of ObjectStorageType + """ + + return self.client._get_and_filter( + ObjectStorageType, *filters, endpoint="/object-storage/types" + ) + def keys_create( self, label: str, @@ -272,6 +297,30 @@ def transfer(self): return MappedObject(**result) + def endpoints(self, *filters) -> PaginatedList: + """ + Returns a paginated list of all Object Storage endpoints available in your account. + + This is intended to be called from the :any:`LinodeClient` + class, like this:: + + endpoints = client.object_storage.endpoints() + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-endpoints + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of Object Storage Endpoints that matched the query. + :rtype: PaginatedList of ObjectStorageEndpoint + """ + return self.client._get_and_filter( + ObjectStorageEndpoint, + *filters, + endpoint="/object-storage/endpoints", + ) + def buckets(self, *filters): """ Returns a paginated list of all Object Storage Buckets that you own. @@ -299,6 +348,8 @@ def bucket_create( label: str, acl: ObjectStorageACL = ObjectStorageACL.PRIVATE, cors_enabled=False, + s3_endpoint: Optional[str] = None, + endpoint_type: Optional[ObjectStorageEndpointType] = None, ): """ Creates an Object Storage Bucket in the specified cluster. Accounts with @@ -320,6 +371,13 @@ def bucket_create( should be created. :type cluster: str + :param endpoint_type: The type of s3_endpoint available to the active user in this region. + :type endpoint_type: str + Enum: E0,E1,E2,E3 + + :param s3_endpoint: The active user's s3 endpoint URL, based on the endpoint_type and region. + :type s3_endpoint: str + :param cors_enabled: If true, the bucket will be created with CORS enabled for all origins. For more fine-grained controls of CORS, use the S3 API directly. @@ -346,6 +404,8 @@ def bucket_create( "label": label, "acl": acl, "cors_enabled": cors_enabled, + "s3_endpoint": s3_endpoint, + "endpoint_type": endpoint_type, } if self.is_cluster(cluster_or_region_id): @@ -458,3 +518,18 @@ def object_url_create( ) return MappedObject(**result) + + def quotas(self, *filters): + """ + Lists the active ObjectStorage-related quotas applied to your account. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-quotas + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of Object Storage Quotas that matched the query. + :rtype: PaginatedList of ObjectStorageQuota + """ + return self.client._get_and_filter(ObjectStorageQuota, *filters) diff --git a/linode_api4/groups/polling.py b/linode_api4/groups/polling.py index 7dff2d3d5..8ef2c4feb 100644 --- a/linode_api4/groups/polling.py +++ b/linode_api4/groups/polling.py @@ -1,3 +1,5 @@ +from typing import Optional + import polling from linode_api4.groups import Group @@ -13,7 +15,7 @@ def event_poller_create( self, entity_type: str, action: str, - entity_id: int = None, + entity_id: Optional[int] = None, ) -> EventPoller: """ Creates a new instance of the EventPoller class. diff --git a/linode_api4/groups/profile.py b/linode_api4/groups/profile.py index 4c49a2b5a..ee583a1ac 100644 --- a/linode_api4/groups/profile.py +++ b/linode_api4/groups/profile.py @@ -1,5 +1,5 @@ -import os from datetime import datetime +from pathlib import Path from linode_api4 import UnexpectedResponseError from linode_api4.common import SSH_KEY_TYPES @@ -322,9 +322,9 @@ def ssh_key_upload(self, key, label): """ if not key.startswith(SSH_KEY_TYPES): # this might be a file path - look for it - path = os.path.expanduser(key) - if os.path.isfile(path): - with open(path) as f: + key_path = Path(key).expanduser() + if key_path.is_file(): + with open(key_path) as f: key = f.read().strip() if not key.startswith(SSH_KEY_TYPES): raise ValueError("Invalid SSH Public Key") diff --git a/linode_api4/groups/region.py b/linode_api4/groups/region.py index baf8697e4..54bb37f0d 100644 --- a/linode_api4/groups/region.py +++ b/linode_api4/groups/region.py @@ -1,6 +1,9 @@ from linode_api4.groups import Group from linode_api4.objects import Region -from linode_api4.objects.region import RegionAvailabilityEntry +from linode_api4.objects.region import ( + RegionAvailabilityEntry, + RegionVPCAvailability, +) class RegionGroup(Group): @@ -43,3 +46,34 @@ def availability(self, *filters): return self.client._get_and_filter( RegionAvailabilityEntry, *filters, endpoint="/regions/availability" ) + + def vpc_availability(self, *filters): + """ + Returns VPC availability data for all regions. + + NOTE: IPv6 VPCs may not currently be available to all users. + + This endpoint supports pagination with the following parameters: + - page: Page number (>= 1) + - page_size: Number of items per page (25-500) + + Pagination is handled automatically by PaginatedList. To configure page_size, + set it when creating the LinodeClient: + + client = LinodeClient(token, page_size=100) + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-regions-vpc-availability + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of VPC availability data for regions. + :rtype: PaginatedList of RegionVPCAvailability + """ + + return self.client._get_and_filter( + RegionVPCAvailability, + *filters, + endpoint="/regions/vpc-availability", + ) diff --git a/linode_api4/groups/volume.py b/linode_api4/groups/volume.py index edbfdfbf8..39d0aeaaa 100644 --- a/linode_api4/groups/volume.py +++ b/linode_api4/groups/volume.py @@ -1,6 +1,7 @@ from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group -from linode_api4.objects import Base, Volume +from linode_api4.objects import Volume, VolumeType +from linode_api4.objects.base import _flatten_request_body_recursive class VolumeGroup(Group): @@ -45,7 +46,9 @@ def create(self, label, region=None, linode=None, size=20, **kwargs): tags included do not exist, they will be created as part of this operation. :type tags: list[str] - + :param encryption: Whether the new Volume should opt in or out of disk encryption. + :type encryption: str + Note: Block Storage Disk Encryption is not currently available to all users. :returns: The new Volume. :rtype: Volume """ @@ -55,14 +58,15 @@ def create(self, label, region=None, linode=None, size=20, **kwargs): params = { "label": label, "size": size, - "region": region.id if issubclass(type(region), Base) else region, - "linode_id": ( - linode.id if issubclass(type(linode), Base) else linode - ), + "region": region, + "linode_id": linode, } params.update(kwargs) - result = self.client.post("/volumes", data=params) + result = self.client.post( + "/volumes", + data=_flatten_request_body_recursive(params), + ) if not "id" in result: raise UnexpectedResponseError( @@ -71,3 +75,21 @@ def create(self, label, region=None, linode=None, size=20, **kwargs): v = Volume(self.client, result["id"], result) return v + + def types(self, *filters): + """ + Returns a :any:`PaginatedList` of :any:`VolumeType` objects that represents a valid Volume type. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-volume-types + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A Paginated List of Volume types that match the query. + :rtype: PaginatedList of VolumeType + """ + + return self.client._get_and_filter( + VolumeType, *filters, endpoint="/volumes/types" + ) diff --git a/linode_api4/groups/vpc.py b/linode_api4/groups/vpc.py index fa8066cea..eda931292 100644 --- a/linode_api4/groups/vpc.py +++ b/linode_api4/groups/vpc.py @@ -2,8 +2,10 @@ from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group -from linode_api4.objects import VPC, Region, VPCIPAddress +from linode_api4.objects import VPC, Region, VPCIPAddress, VPCIPv6RangeOptions +from linode_api4.objects.base import _flatten_request_body_recursive from linode_api4.paginated_list import PaginatedList +from linode_api4.util import drop_null_keys class VPCGroup(Group): @@ -33,6 +35,7 @@ def create( region: Union[Region, str], description: Optional[str] = None, subnets: Optional[List[Dict[str, Any]]] = None, + ipv6: Optional[List[Union[VPCIPv6RangeOptions, Dict[str, Any]]]] = None, **kwargs, ) -> VPC: """ @@ -48,6 +51,8 @@ def create( :type description: Optional[str] :param subnets: A list of subnets to create under this VPC. :type subnets: List[Dict[str, Any]] + :param ipv6: The IPv6 address ranges for this VPC. + :type ipv6: List[Union[VPCIPv6RangeOptions, Dict[str, Any]]] :returns: The new VPC object. :rtype: VPC @@ -55,11 +60,11 @@ def create( params = { "label": label, "region": region.id if isinstance(region, Region) else region, + "description": description, + "ipv6": ipv6, + "subnets": subnets, } - if description is not None: - params["description"] = description - if subnets is not None and len(subnets) > 0: for subnet in subnets: if not isinstance(subnet, dict): @@ -67,11 +72,12 @@ def create( f"Unsupported type for subnet: {type(subnet)}" ) - params["subnets"] = subnets - params.update(kwargs) - result = self.client.post("/vpcs", data=params) + result = self.client.post( + "/vpcs", + data=drop_null_keys(_flatten_request_body_recursive(params)), + ) if not "id" in result: raise UnexpectedResponseError( diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 66e3d45fe..0e89142b3 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -3,7 +3,7 @@ import json import logging from importlib.metadata import version -from typing import BinaryIO, List, Tuple +from typing import BinaryIO, List, Optional, Tuple from urllib import parse import requests @@ -16,9 +16,14 @@ DatabaseGroup, DomainGroup, ImageGroup, + ImageShareGroupAPIGroup, LinodeGroup, LKEGroup, + LockGroup, LongviewGroup, + MaintenanceGroup, + MetricsGroup, + MonitorGroup, NetworkingGroup, NodeBalancerGroup, ObjectStorageGroup, @@ -50,11 +55,48 @@ def get_backoff_time(self): return self.backoff_factor -class LinodeClient: +class BaseClient: + """ + The base class for a client. + + :param token: The authentication token to use for communication with the + API. Can be either a Personal Access Token or an OAuth Token. + :type token: str + :param base_url: The base URL for API requests. Generally, you shouldn't + change this. + :type base_url: str + :param user_agent: What to append to the User Agent of all requests made + by this client. Setting this allows Linode's internal + monitoring applications to track the usage of your + application. Setting this is not necessary, but some + applications may desire this behavior. + :type user_agent: str + :param page_size: The default size to request pages at. If not given, + the API's default page size is used. Valid values + can be found in the API docs, but at time of writing + are between 25 and 500. + :type page_size: int + :param retry: Whether API requests should automatically be retries on known + intermittent responses. + :type retry: bool + :param retry_rate_limit_interval: The amount of time to wait between HTTP request + retries. + :type retry_rate_limit_interval: Union[float, int] + :param retry_max: The number of request retries that should be attempted before + raising an API error. + :type retry_max: int + :type retry_statuses: List of int + :param retry_statuses: Additional HTTP response statuses to retry on. + By default, the client will retry on 408, 429, and 502 + responses. + :param ca_path: The path to a CA file to use for API requests in this client. + :type ca_path: str + """ + def __init__( self, token, - base_url="https://api.linode.com/v4", + base_url, user_agent=None, page_size=None, retry=True, @@ -63,42 +105,6 @@ def __init__( retry_statuses=None, ca_path=None, ): - """ - The main interface to the Linode API. - - :param token: The authentication token to use for communication with the - API. Can be either a Personal Access Token or an OAuth Token. - :type token: str - :param base_url: The base URL for API requests. Generally, you shouldn't - change this. - :type base_url: str - :param user_agent: What to append to the User Agent of all requests made - by this client. Setting this allows Linode's internal - monitoring applications to track the usage of your - application. Setting this is not necessary, but some - applications may desire this behavior. - :type user_agent: str - :param page_size: The default size to request pages at. If not given, - the API's default page size is used. Valid values - can be found in the API docs, but at time of writing - are between 25 and 500. - :type page_size: int - :param retry: Whether API requests should automatically be retries on known - intermittent responses. - :type retry: bool - :param retry_rate_limit_interval: The amount of time to wait between HTTP request - retries. - :type retry_rate_limit_interval: Union[float, int] - :param retry_max: The number of request retries that should be attempted before - raising an API error. - :type retry_max: int - :type retry_statuses: List of int - :param retry_statuses: Additional HTTP response statuses to retry on. - By default, the client will retry on 408, 429, and 502 - responses. - :param ca_path: The path to a CA file to use for API requests in this client. - :type ca_path: str - """ self.base_url = base_url self._add_user_agent = user_agent self.token = token @@ -137,70 +143,6 @@ def __init__( self.session.mount("http://", retry_adapter) self.session.mount("https://", retry_adapter) - #: Access methods related to Linodes - see :any:`LinodeGroup` for - #: more information - self.linode = LinodeGroup(self) - - #: Access methods related to your user - see :any:`ProfileGroup` for - #: more information - self.profile = ProfileGroup(self) - - #: Access methods related to your account - see :any:`AccountGroup` for - #: more information - self.account = AccountGroup(self) - - #: Access methods related to networking on your account - see - #: :any:`NetworkingGroup` for more information - self.networking = NetworkingGroup(self) - - #: Access methods related to support - see :any:`SupportGroup` for more - #: information - self.support = SupportGroup(self) - - #: Access information related to the Longview service - see - #: :any:`LongviewGroup` for more information - self.longview = LongviewGroup(self) - - #: Access methods related to Object Storage - see :any:`ObjectStorageGroup` - #: for more information - self.object_storage = ObjectStorageGroup(self) - - #: Access methods related to LKE - see :any:`LKEGroup` for more information. - self.lke = LKEGroup(self) - - #: Access methods related to Managed Databases - see :any:`DatabaseGroup` for more information. - self.database = DatabaseGroup(self) - - #: Access methods related to NodeBalancers - see :any:`NodeBalancerGroup` for more information. - self.nodebalancers = NodeBalancerGroup(self) - - #: Access methods related to Domains - see :any:`DomainGroup` for more information. - self.domains = DomainGroup(self) - - #: Access methods related to Tags - See :any:`TagGroup` for more information. - self.tags = TagGroup(self) - - #: Access methods related to Volumes - See :any:`VolumeGroup` for more information. - self.volumes = VolumeGroup(self) - - #: Access methods related to Regions - See :any:`RegionGroup` for more information. - self.regions = RegionGroup(self) - - #: Access methods related to Images - See :any:`ImageGroup` for more information. - self.images = ImageGroup(self) - - #: Access methods related to VPCs - See :any:`VPCGroup` for more information. - self.vpcs = VPCGroup(self) - - #: Access methods related to Event polling - See :any:`PollingGroup` for more information. - self.polling = PollingGroup(self) - - #: Access methods related to Beta Program - See :any:`BetaProgramGroup` for more information. - self.beta = BetaProgramGroup(self) - - #: Access methods related to VM placement - See :any:`PlacementAPIGroup` for more information. - self.placement = PlacementAPIGroup(self) - @property def _user_agent(self): return "{}python-linode_api4/{} {}".format( @@ -287,23 +229,9 @@ def _api_call( if warning: logger.warning("Received warning from server: {}".format(warning)) - if 399 < response.status_code < 600: - j = None - error_msg = "{}: ".format(response.status_code) - try: - j = response.json() - if "errors" in j.keys(): - for e in j["errors"]: - msg = e.get("reason", "") - field = e.get("field", None) - - error_msg += "{}{}; ".format( - f"[{field}] " if field is not None else "", - msg, - ) - except: - pass - raise ApiError(error_msg, status=response.status_code, json=j) + api_error = ApiError.from_response(response) + if api_error is not None: + raise api_error if response.status_code != 204: j = response.json() @@ -378,6 +306,174 @@ def __setattr__(self, key, value): super().__setattr__(key, value) + # helper functions + def _get_and_filter( + self, + obj_type, + *filters, + endpoint=None, + parent_id=None, + ): + parsed_filters = None + if filters: + if len(filters) > 1: + parsed_filters = and_( + *filters + ).dct # pylint: disable=no-value-for-parameter + else: + parsed_filters = filters[0].dct + + # Use sepcified endpoint + if endpoint: + return self._get_objects( + endpoint, obj_type, parent_id=parent_id, filters=parsed_filters + ) + else: + return self._get_objects( + obj_type.api_list(), + obj_type, + parent_id=parent_id, + filters=parsed_filters, + ) + + +class LinodeClient(BaseClient): + def __init__( + self, + token, + base_url="https://api.linode.com/v4", + user_agent=None, + page_size=None, + retry=True, + retry_rate_limit_interval=1.0, + retry_max=5, + retry_statuses=None, + ca_path=None, + ): + """ + The main interface to the Linode API. + + :param token: The authentication token to use for communication with the + API. Can be either a Personal Access Token or an OAuth Token. + :type token: str + :param base_url: The base URL for API requests. Generally, you shouldn't + change this. + :type base_url: str + :param user_agent: What to append to the User Agent of all requests made + by this client. Setting this allows Linode's internal + monitoring applications to track the usage of your + application. Setting this is not necessary, but some + applications may desire this behavior. + :type user_agent: str + :param page_size: The default size to request pages at. If not given, + the API's default page size is used. Valid values + can be found in the API docs, but at time of writing + are between 25 and 500. + :type page_size: int + :param retry: Whether API requests should automatically be retries on known + intermittent responses. + :type retry: bool + :param retry_rate_limit_interval: The amount of time to wait between HTTP request + retries. + :type retry_rate_limit_interval: Union[float, int] + :param retry_max: The number of request retries that should be attempted before + raising an API error. + :type retry_max: int + :type retry_statuses: List of int + :param retry_statuses: Additional HTTP response statuses to retry on. + By default, the client will retry on 408, 429, and 502 + responses. + :param ca_path: The path to a CA file to use for API requests in this client. + :type ca_path: str + """ + #: Access methods related to Linodes - see :any:`LinodeGroup` for + #: more information + self.linode = LinodeGroup(self) + + #: Access methods related to your user - see :any:`ProfileGroup` for + #: more information + self.profile = ProfileGroup(self) + + #: Access methods related to your account - see :any:`AccountGroup` for + #: more information + self.account = AccountGroup(self) + + #: Access methods related to networking on your account - see + #: :any:`NetworkingGroup` for more information + self.networking = NetworkingGroup(self) + + #: Access methods related to maintenance on your account - see + #: :any:`MaintenanceGroup` for more information + self.maintenance = MaintenanceGroup(self) + + #: Access methods related to support - see :any:`SupportGroup` for more + #: information + self.support = SupportGroup(self) + + #: Access information related to the Longview service - see + #: :any:`LongviewGroup` for more information + self.longview = LongviewGroup(self) + + #: Access methods related to Object Storage - see :any:`ObjectStorageGroup` + #: for more information + self.object_storage = ObjectStorageGroup(self) + + #: Access methods related to LKE - see :any:`LKEGroup` for more information. + self.lke = LKEGroup(self) + + #: Access methods related to Managed Databases - see :any:`DatabaseGroup` for more information. + self.database = DatabaseGroup(self) + + #: Access methods related to NodeBalancers - see :any:`NodeBalancerGroup` for more information. + self.nodebalancers = NodeBalancerGroup(self) + + #: Access methods related to Domains - see :any:`DomainGroup` for more information. + self.domains = DomainGroup(self) + + #: Access methods related to Tags - See :any:`TagGroup` for more information. + self.tags = TagGroup(self) + + #: Access methods related to Volumes - See :any:`VolumeGroup` for more information. + self.volumes = VolumeGroup(self) + + #: Access methods related to Regions - See :any:`RegionGroup` for more information. + self.regions = RegionGroup(self) + + #: Access methods related to Images - See :any:`ImageGroup` for more information. + self.images = ImageGroup(self) + + #: Access methods related to Image Share Groups - See :any:`ImageShareGroupAPIGroup` for more information. + self.sharegroups = ImageShareGroupAPIGroup(self) + + #: Access methods related to VPCs - See :any:`VPCGroup` for more information. + self.vpcs = VPCGroup(self) + + #: Access methods related to Event polling - See :any:`PollingGroup` for more information. + self.polling = PollingGroup(self) + + #: Access methods related to Beta Program - See :any:`BetaProgramGroup` for more information. + self.beta = BetaProgramGroup(self) + + #: Access methods related to VM placement - See :any:`PlacementAPIGroup` for more information. + self.placement = PlacementAPIGroup(self) + + self.monitor = MonitorGroup(self) + + #: Access methods related to Resource Locks - See :any:`LockGroup` for more information. + self.locks = LockGroup(self) + + super().__init__( + token=token, + base_url=base_url, + user_agent=user_agent, + page_size=page_size, + retry=retry, + retry_rate_limit_interval=retry_rate_limit_interval, + retry_max=retry_max, + retry_statuses=retry_statuses, + ca_path=ca_path, + ) + def image_create(self, disk, label=None, description=None, tags=None): """ .. note:: This method is an alias to maintain backwards compatibility. @@ -391,8 +487,8 @@ def image_create_upload( self, label: str, region: str, - description: str = None, - tags: List[str] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, ) -> Tuple[Image, str]: """ .. note:: This method is an alias to maintain backwards compatibility. @@ -409,8 +505,8 @@ def image_upload( label: str, region: str, file: BinaryIO, - description: str = None, - tags: List[str] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, ) -> Image: """ .. note:: This method is an alias to maintain backwards compatibility. @@ -468,21 +564,59 @@ def volume_create(self, label, region=None, linode=None, size=20, **kwargs): label, region=region, linode=linode, size=size, **kwargs ) - # helper functions - def _get_and_filter(self, obj_type, *filters, endpoint=None): - parsed_filters = None - if filters: - if len(filters) > 1: - parsed_filters = and_( - *filters - ).dct # pylint: disable=no-value-for-parameter - else: - parsed_filters = filters[0].dct - # Use sepcified endpoint - if endpoint: - return self._get_objects(endpoint, obj_type, filters=parsed_filters) - else: - return self._get_objects( - obj_type.api_list(), obj_type, filters=parsed_filters - ) +class MonitorClient(BaseClient): + """ + The main interface to the Monitor API. + + :param token: The authentication Personal Access Token token to use for + communication with the API. You may want to generate one using + Linode Client. For example: + linode_client.monitor.create_token( + service_type="dbaas", entity_ids=[entity_id] + ) + :type token: str + :param base_url: The base URL for monitor API requests. Generally, you shouldn't + change this. + :type base_url: str + :param user_agent: What to append to the User Agent of all requests made + by this client. Setting this allows Linode's internal + monitoring applications to track the usage of your + application. Setting this is not necessary, but some + applications may desire this behavior. + :type user_agent: str + :param page_size: The default size to request pages at. If not given, + the API's default page size is used. Valid values + can be found in the API docs. + :type page_size: int + :param ca_path: The path to a CA file to use for API requests in this client. + :type ca_path: str + """ + + def __init__( + self, + token, + base_url="https://monitor-api.linode.com/v2beta", + user_agent=None, + page_size=None, + ca_path=None, + retry=True, + retry_rate_limit_interval=1.0, + retry_max=5, + retry_statuses=None, + ): + #: Access methods related to your monitor metrics - see :any:`MetricsGroup` for + #: more information + self.metrics = MetricsGroup(self) + + super().__init__( + token=token, + base_url=base_url, + user_agent=user_agent, + page_size=page_size, + retry=retry, + retry_rate_limit_interval=retry_rate_limit_interval, + retry_max=retry_max, + retry_statuses=retry_statuses, + ca_path=ca_path, + ) diff --git a/linode_api4/login_client.py b/linode_api4/login_client.py index 1263ee49c..e21c5c4b2 100644 --- a/linode_api4/login_client.py +++ b/linode_api4/login_client.py @@ -434,10 +434,9 @@ def oauth_redirect(): ) if r.status_code != 200: - raise ApiError( - "OAuth token exchange failed", - status=r.status_code, - json=r.json(), + raise ApiError.from_response( + r, + message="OAuth token exchange failed", ) token = r.json()["access_token"] @@ -479,7 +478,7 @@ def refresh_oauth_token(self, refresh_token): ) if r.status_code != 200: - raise ApiError("Refresh failed", r) + raise ApiError.from_response(r, message="Refresh failed") token = r.json()["access_token"] scopes = OAuthScopes.parse(r.json()["scopes"]) @@ -516,5 +515,5 @@ def expire_token(self, token): ) if r.status_code != 200: - raise ApiError("Failed to expire token!", r) + raise ApiError.from_response(r, "Failed to expire token!") return True diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py index 3ecce4584..89a681635 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -3,10 +3,11 @@ from .dbase import DerivedBase from .serializable import JSONObject from .filtering import and_, or_ -from .region import Region +from .region import Region, Capability from .image import Image from .linode import * -from .volume import Volume +from .linode_interfaces import * +from .volume import * from .domain import * from .account import * from .networking import * @@ -21,3 +22,7 @@ from .vpc import * from .beta import * from .placement import * +from .monitor import * +from .monitor_api import * +from .image_share_group import * +from .lock import * diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index 9365a9127..a4aca1848 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -3,23 +3,24 @@ from datetime import datetime import requests +from deprecated import deprecated from linode_api4.errors import ApiError, UnexpectedResponseError -from linode_api4.objects import ( - DATE_FORMAT, - Base, - DerivedBase, - Domain, - Image, - Instance, - Property, - StackScript, - Volume, -) +from linode_api4.objects import DATE_FORMAT, Volume +from linode_api4.objects.base import Base, Property +from linode_api4.objects.database import Database +from linode_api4.objects.dbase import DerivedBase +from linode_api4.objects.domain import Domain +from linode_api4.objects.image import Image +from linode_api4.objects.linode import Instance, StackScript from linode_api4.objects.longview import LongviewClient, LongviewSubscription +from linode_api4.objects.networking import Firewall from linode_api4.objects.nodebalancer import NodeBalancer from linode_api4.objects.profile import PersonalAccessToken +from linode_api4.objects.serializable import StrEnum from linode_api4.objects.support import SupportTicket +from linode_api4.objects.volume import Volume +from linode_api4.objects.vpc import VPC class Account(Base): @@ -180,6 +181,24 @@ class Login(Base): } +class AccountSettingsInterfacesForNewLinodes(StrEnum): + """ + A string enum corresponding to valid values + for the AccountSettings(...).interfaces_for_new_linodes field. + + NOTE: This feature may not currently be available to all users. + """ + + legacy_config_only = "legacy_config_only" + legacy_config_default_but_linode_allowed = ( + "legacy_config_default_but_linode_allowed" + ) + linode_default_but_legacy_config_allowed = ( + "linode_default_but_legacy_config_allowed" + ) + linode_only = "linode_only" + + class AccountSettings(Base): """ Information related to your Account settings. @@ -194,10 +213,12 @@ class AccountSettings(Base): "network_helper": Property(mutable=True), "managed": Property(), "longview_subscription": Property( - slug_relationship=LongviewSubscription + slug_relationship=LongviewSubscription, mutable=False ), "object_storage": Property(), "backups_enabled": Property(mutable=True), + "interfaces_for_new_linodes": Property(mutable=True), + "maintenance_policy": Property(mutable=True), } @@ -220,12 +241,18 @@ class Event(Base): "user_id": Property(), "username": Property(), "entity": Property(), - "time_remaining": Property(), + "time_remaining": Property(), # Deprecated "rate": Property(), "status": Property(), "duration": Property(), "secondary_entity": Property(), "message": Property(), + "maintenance_policy_set": Property(), + "description": Property(), + "source": Property(), + "not_before": Property(is_datetime=True), + "start_time": Property(is_datetime=True), + "complete_time": Property(is_datetime=True), } @property @@ -306,6 +333,12 @@ def volume(self): return Volume(self._client, self.entity.id) return None + @deprecated( + reason="`mark_read` API is deprecated. Use the 'mark_seen' " + "API instead. Please note that the `mark_seen` API functions " + "differently and will mark all events up to and including the " + "referenced event-id as 'seen' rather than individual events.", + ) def mark_read(self): """ Marks a single Event as read. @@ -437,8 +470,10 @@ def thumbnail(self, dump_to=None): ) if not result.status_code == 200: - raise ApiError( - "No thumbnail found for OAuthClient {}".format(self.id) + raise ApiError.from_response( + result, + "No thumbnail found for OAuthClient {}".format(self.id), + disable_formatting=True, ) if dump_to: @@ -473,12 +508,9 @@ def set_thumbnail(self, thumbnail): data=thumbnail, ) - if not result.status_code == 200: - errors = [] - j = result.json() - if "errors" in j: - errors = [e["reason"] for e in j["errors"]] - raise ApiError("{}: {}".format(result.status_code, errors), json=j) + api_exc = ApiError.from_response(result) + if api_exc is not None: + raise api_exc return True @@ -554,10 +586,6 @@ def get_obj_grants(): """ Returns Grant keys mapped to Object types. """ - from linode_api4.objects import ( # pylint: disable=import-outside-toplevel - Database, - Firewall, - ) return ( ("linode", Instance), @@ -569,6 +597,7 @@ def get_obj_grants(): ("longview", LongviewClient), ("database", Database), ("firewall", Firewall), + ("vpc", VPC), ) @@ -606,7 +635,7 @@ def entity(self): ) return self.cls(self._client, self.id) - def _serialize(self): + def _serialize(self, *args, **kwargs): """ Returns this grant in as JSON the api will accept. This is only relevant in the context of UserGrants.save @@ -641,10 +670,47 @@ def _populate(self, json): self.global_grants = type("global_grants", (object,), json["global"]) for key, cls in get_obj_grants(): - lst = [] - for gdct in json[key]: - lst.append(Grant(self._client, cls, gdct)) - setattr(self, key, lst) + if key in json: + lst = [] + for gdct in json[key]: + lst.append(Grant(self._client, cls, gdct)) + setattr(self, key, lst) + + @property + def _global_grants_dict(self): + """ + The global grants stored in this object. + """ + return { + k: v + for k, v in vars(self.global_grants).items() + if not k.startswith("_") + } + + @property + def _grants_dict(self): + """ + The grants stored in this object. + """ + grants = {} + for key, _ in get_obj_grants(): + if hasattr(self, key): + lst = [] + for cg in getattr(self, key): + lst.append(cg._serialize()) + grants[key] = lst + + return grants + + def _serialize(self, *args, **kwargs): + """ + Returns the user grants in as JSON the api will accept. + This is only relevant in the context of UserGrants.save + """ + return { + "global": self._global_grants_dict, + **self._grants_dict, + } def save(self): """ @@ -653,19 +719,7 @@ def save(self): API Documentation: https://techdocs.akamai.com/linode-api/reference/put-user-grants """ - req = { - "global": { - k: v - for k, v in vars(self.global_grants).items() - if not k.startswith("_") - }, - } - - for key, _ in get_obj_grants(): - lst = [] - for cg in getattr(self, key): - lst.append(cg._serialize()) - req[key] = lst + req = self._serialize() result = self._client.put( UserGrants.api_endpoint.format(username=self.username), data=req diff --git a/linode_api4/objects/base.py b/linode_api4/objects/base.py index 6c9b1bece..78e53fd45 100644 --- a/linode_api4/objects/base.py +++ b/linode_api4/objects/base.py @@ -1,5 +1,6 @@ import time from datetime import datetime, timedelta +from functools import cached_property from typing import Any, Dict, Optional from linode_api4.objects.serializable import JSONObject @@ -35,27 +36,43 @@ def __init__( nullable=False, unordered=False, json_object=None, + alias_of: Optional[str] = None, ): """ A Property is an attribute returned from the API, and defines metadata - about that value. These are expected to be used as the values of a + about that value. These are expected to be used as the values of a class-level dict named 'properties' in subclasses of Base. - mutable - This Property should be sent in a call to save() - identifier - This Property identifies the object in the API - volatile - Re-query for this Property if the local value is older than the - volatile refresh timeout - relationship - The API Object this Property represents - derived_class - The sub-collection type this Property represents - is_datetime - True if this Property should be parsed as a datetime.datetime - id_relationship - This Property should create a relationship with this key as the ID - (This should be used on fields ending with '_id' only) - slug_relationship - This property is a slug related for a given type. - nullable - This property can be explicitly null on PUT requests. - unordered - The order of this property is not significant. - NOTE: This field is currently only for annotations purposes - and does not influence any update or decoding/encoding logic. - json_object - The JSONObject class this property should be decoded into. + :param mutable: This Property should be sent in a call to save() + :type mutable: bool + :param identifier: This Property identifies the object in the API + :type identifier: bool + :param volatile: Re-query for this Property if the local value is older than the + volatile refresh timeout + :type volatile: bool + :param relationship: The API Object this Property represents + :type relationship: type or None + :param derived_class: The sub-collection type this Property represents + :type derived_class: type or None + :param is_datetime: True if this Property should be parsed as a datetime.datetime + :type is_datetime: bool + :param id_relationship: This Property should create a relationship with this key as the ID + (This should be used on fields ending with '_id' only) + :type id_relationship: type or None + :param slug_relationship: This property is a slug related for a given type + :type slug_relationship: type or None + :param nullable: This property can be explicitly null on PUT requests + :type nullable: bool + :param unordered: The order of this property is not significant. + NOTE: This field is currently only for annotations purposes + and does not influence any update or decoding/encoding logic. + :type unordered: bool + :param json_object: The JSONObject class this property should be decoded into + :type json_object: type or None + :param alias_of: The original API attribute name when the property key is aliased. + This is useful when the API attribute name is a Python reserved word, + allowing you to use a different key while preserving the original name. + :type alias_of: str or None """ self.mutable = mutable self.identifier = identifier @@ -68,6 +85,7 @@ def __init__( self.nullable = nullable self.unordered = unordered self.json_class = json_object + self.alias_of = alias_of class MappedObject: @@ -114,6 +132,9 @@ def _flatten_base_subclass(obj: "Base") -> Optional[Dict[str, Any]]: @property def dict(self): + return self._serialize() + + def _serialize(self, is_put: bool = False) -> Dict[str, Any]: result = vars(self).copy() cls = type(self) @@ -123,7 +144,7 @@ def dict(self): elif isinstance(v, list): result[k] = [ ( - item.dict + item._serialize(is_put=is_put) if isinstance(item, (cls, JSONObject)) else ( self._flatten_base_subclass(item) @@ -136,7 +157,7 @@ def dict(self): elif isinstance(v, Base): result[k] = self._flatten_base_subclass(v) elif isinstance(v, JSONObject): - result[k] = v.dict + result[k] = v._serialize(is_put=is_put) return result @@ -236,6 +257,7 @@ def __setattr__(self, name, value): """ Enforces allowing editing of only Properties defined as mutable """ + if name in type(self).properties.keys(): if not type(self).properties[name].mutable: raise AttributeError( @@ -248,6 +270,21 @@ def __setattr__(self, name, value): self._set(name, value) + @cached_property + def properties_with_alias(self) -> dict[str, tuple[str, Property]]: + """ + Gets a dictionary of aliased properties for this object. + + :returns: A dict mapping original API attribute names to their alias names and + corresponding Property instances. + :rtype: dict[str, tuple[str, Property]] + """ + return { + prop.alias_of: (alias, prop) + for alias, prop in type(self).properties.items() + if prop.alias_of + } + def save(self, force=True) -> bool: """ Send this object's mutable values to the server in a PUT request. @@ -278,9 +315,9 @@ def save(self, force=True) -> bool: data[key] = None # Ensure we serialize any values that may not be already serialized - data = _flatten_request_body_recursive(data) + data = _flatten_request_body_recursive(data, is_put=True) else: - data = self._serialize() + data = self._serialize(is_put=True) resp = self._client.put(type(self).api_endpoint, model=self, data=data) @@ -316,7 +353,7 @@ def invalidate(self): self._set("_populated", False) - def _serialize(self): + def _serialize(self, is_put: bool = False): """ A helper method to build a dict of all mutable Properties of this object @@ -341,11 +378,12 @@ def _serialize(self): ): value = None - result[k] = value + api_key = k if not v.alias_of else v.alias_of + result[api_key] = value # Resolve the underlying IDs of results for k, v in result.items(): - result[k] = _flatten_request_body_recursive(v) + result[k] = _flatten_request_body_recursive(v, is_put=is_put) return result @@ -369,55 +407,55 @@ def _populate(self, json): self._set("_raw_json", json) self._set("_updated", False) - for key in json: - if key in ( - k - for k in type(self).properties.keys() - if not type(self).properties[k].identifier - ): - if ( - type(self).properties[key].relationship - and not json[key] is None - ): - if isinstance(json[key], list): + valid_keys = set( + k + for k, v in type(self).properties.items() + if (not v.identifier) and (not v.alias_of) + ) | set(self.properties_with_alias.keys()) + + for api_key in json: + if api_key in valid_keys: + prop = type(self).properties.get(api_key) + prop_key = api_key + + if prop is None: + prop_key, prop = self.properties_with_alias[api_key] + + if prop.relationship and json[api_key] is not None: + if isinstance(json[api_key], list): objs = [] - for d in json[key]: + for d in json[api_key]: if not "id" in d: continue - new_class = type(self).properties[key].relationship + new_class = prop.relationship obj = new_class.make_instance( d["id"], getattr(self, "_client") ) if obj: obj._populate(d) objs.append(obj) - self._set(key, objs) + self._set(prop_key, objs) else: - if isinstance(json[key], dict): - related_id = json[key]["id"] + if isinstance(json[api_key], dict): + related_id = json[api_key]["id"] else: - related_id = json[key] - new_class = type(self).properties[key].relationship + related_id = json[api_key] + new_class = prop.relationship obj = new_class.make_instance( related_id, getattr(self, "_client") ) - if obj and isinstance(json[key], dict): - obj._populate(json[key]) - self._set(key, obj) - elif ( - type(self).properties[key].slug_relationship - and not json[key] is None - ): + if obj and isinstance(json[api_key], dict): + obj._populate(json[api_key]) + self._set(prop_key, obj) + elif prop.slug_relationship and json[api_key] is not None: # create an object of the expected type with the given slug self._set( - key, - type(self) - .properties[key] - .slug_relationship(self._client, json[key]), + prop_key, + prop.slug_relationship(self._client, json[api_key]), ) - elif type(self).properties[key].json_class: - json_class = type(self).properties[key].json_class - json_value = json[key] + elif prop.json_class: + json_class = prop.json_class + json_value = json[api_key] # build JSON object if isinstance(json_value, list): @@ -426,25 +464,29 @@ def _populate(self, json): else: value = json_class.from_json(json_value) - self._set(key, value) - elif type(json[key]) is dict: - self._set(key, MappedObject(**json[key])) - elif type(json[key]) is list: + self._set(prop_key, value) + elif type(json[api_key]) is dict: + self._set(prop_key, MappedObject(**json[api_key])) + elif type(json[api_key]) is list: # we're going to use MappedObject's behavior with lists to # expand these, then grab the resulting value to set - mapping = MappedObject(_list=json[key]) - self._set(key, mapping._list) # pylint: disable=no-member - elif type(self).properties[key].is_datetime: + mapping = MappedObject(_list=json[api_key]) + self._set( + prop_key, mapping._list + ) # pylint: disable=no-member + elif prop.is_datetime: try: - t = time.strptime(json[key], DATE_FORMAT) - self._set(key, datetime.fromtimestamp(time.mktime(t))) + t = time.strptime(json[api_key], DATE_FORMAT) + self._set( + prop_key, datetime.fromtimestamp(time.mktime(t)) + ) except: # if this came back, there's probably an issue with the # python library; a field was marked as a datetime but # wasn't in the expected format. - self._set(key, json[key]) + self._set(prop_key, json[api_key]) else: - self._set(key, json[key]) + self._set(prop_key, json[api_key]) self._set("_populated", True) self._set("_last_updated", datetime.now()) @@ -503,7 +545,7 @@ def make_instance(cls, id, client, parent_id=None, json=None): return Base.make(id, client, cls, parent_id=parent_id, json=json) -def _flatten_request_body_recursive(data: Any) -> Any: +def _flatten_request_body_recursive(data: Any, is_put: bool = False) -> Any: """ This is a helper recursively flatten the given data for use in an API request body. @@ -515,15 +557,21 @@ def _flatten_request_body_recursive(data: Any) -> Any: """ if isinstance(data, dict): - return {k: _flatten_request_body_recursive(v) for k, v in data.items()} + return { + k: _flatten_request_body_recursive(v, is_put=is_put) + for k, v in data.items() + } if isinstance(data, list): - return [_flatten_request_body_recursive(v) for v in data] + return [_flatten_request_body_recursive(v, is_put=is_put) for v in data] if isinstance(data, Base): return data.id + if isinstance(data, ExplicitNullValue) or data == ExplicitNullValue: + return None + if isinstance(data, MappedObject) or issubclass(type(data), JSONObject): - return data.dict + return data._serialize(is_put=is_put) return data diff --git a/linode_api4/objects/beta.py b/linode_api4/objects/beta.py index c957aa584..45d5c5102 100644 --- a/linode_api4/objects/beta.py +++ b/linode_api4/objects/beta.py @@ -19,4 +19,5 @@ class BetaProgram(Base): "ended": Property(is_datetime=True), "greenlight_only": Property(), "more_info": Property(), + "beta_class": Property(alias_of="class"), } diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py index 6a028722c..b3c6f8c35 100644 --- a/linode_api4/objects/database.py +++ b/linode_api4/objects/database.py @@ -1,4 +1,12 @@ -from linode_api4.objects import Base, DerivedBase, MappedObject, Property +from dataclasses import dataclass, field +from typing import Optional + +from linode_api4.objects import ( + Base, + JSONObject, + MappedObject, + Property, +) class DatabaseType(Base): @@ -63,58 +71,150 @@ def invalidate(self): Base.invalidate(self) -class DatabaseBackup(DerivedBase): +@dataclass +class DatabasePrivateNetwork(JSONObject): """ - A generic Managed Database backup. - - This class is not intended to be used on its own. - Use the appropriate subclasses for the corresponding database engine. (e.g. MySQLDatabaseBackup) + DatabasePrivateNetwork is used to specify + a Database Cluster's private network settings during its creation. """ - api_endpoint = "" - derived_url_path = "backups" - parent_id_name = "database_id" - - properties = { - "created": Property(is_datetime=True), - "id": Property(identifier=True), - "label": Property(), - "type": Property(), - } + vpc_id: Optional[int] = None + subnet_id: Optional[int] = None + public_access: Optional[bool] = None - def restore(self): - """ - Restore a backup to a Managed Database on your Account. - API Documentation: +@dataclass +class MySQLDatabaseConfigMySQLOptions(JSONObject): + """ + MySQLDatabaseConfigMySQLOptions represents the fields in the mysql + field of the MySQLDatabaseConfigOptions class + """ - - MySQL: https://techdocs.akamai.com/linode-api/reference/post-databases-mysql-instance-backup-restore - - PostgreSQL: https://techdocs.akamai.com/linode-api/reference/post-databases-postgre-sql-instance-backup-restore - """ + connect_timeout: Optional[int] = None + default_time_zone: Optional[str] = None + group_concat_max_len: Optional[float] = None + information_schema_stats_expiry: Optional[int] = None + innodb_change_buffer_max_size: Optional[int] = None + innodb_flush_neighbors: Optional[int] = None + innodb_ft_min_token_size: Optional[int] = None + innodb_ft_server_stopword_table: Optional[str] = None + innodb_lock_wait_timeout: Optional[int] = None + innodb_log_buffer_size: Optional[int] = None + innodb_online_alter_log_max_size: Optional[int] = None + innodb_read_io_threads: Optional[int] = None + innodb_rollback_on_timeout: Optional[bool] = None + innodb_thread_concurrency: Optional[int] = None + innodb_write_io_threads: Optional[int] = None + interactive_timeout: Optional[int] = None + internal_tmp_mem_storage_engine: Optional[str] = None + max_allowed_packet: Optional[int] = None + max_heap_table_size: Optional[int] = None + net_buffer_length: Optional[int] = None + net_read_timeout: Optional[int] = None + net_write_timeout: Optional[int] = None + sort_buffer_size: Optional[int] = None + sql_mode: Optional[str] = None + sql_require_primary_key: Optional[bool] = None + tmp_table_size: Optional[int] = None + wait_timeout: Optional[int] = None + + +@dataclass +class MySQLDatabaseConfigOptions(JSONObject): + """ + MySQLDatabaseConfigOptions is used to specify + a MySQL Database Cluster's configuration options during its creation. + """ - return self._client.post( - "{}/restore".format(self.api_endpoint), model=self - ) + mysql: Optional[MySQLDatabaseConfigMySQLOptions] = None + binlog_retention_period: Optional[int] = None -class MySQLDatabaseBackup(DatabaseBackup): +@dataclass +class PostgreSQLDatabaseConfigPGLookoutOptions(JSONObject): """ - A backup for an accessible Managed MySQL Database. - - API Documentation: https://techdocs.akamai.com/linode-api/reference/get-databases-mysql-instance-backup + PostgreSQLDatabasePGLookoutConfigOptions represents the fields in the pglookout + field of the PostgreSQLDatabasePGConfigOptions class """ - api_endpoint = "/databases/mysql/instances/{database_id}/backups/{id}" + max_failover_replication_time_lag: Optional[int] = None -class PostgreSQLDatabaseBackup(DatabaseBackup): +@dataclass +class PostgreSQLDatabaseConfigPGOptions(JSONObject): + """ + PostgreSQLDatabasePGConfigOptions represents the fields in the pg + field of the PostgreSQLDatabasePGConfigOptions class """ - A backup for an accessible Managed PostgreSQL Database. - API Documentation: https://techdocs.akamai.com/linode-api/reference/get-databases-postgresql-instance-backup + autovacuum_analyze_scale_factor: Optional[float] = None + autovacuum_analyze_threshold: Optional[int] = None + autovacuum_max_workers: Optional[int] = None + autovacuum_naptime: Optional[int] = None + autovacuum_vacuum_cost_delay: Optional[int] = None + autovacuum_vacuum_cost_limit: Optional[int] = None + autovacuum_vacuum_scale_factor: Optional[float] = None + autovacuum_vacuum_threshold: Optional[int] = None + bgwriter_delay: Optional[int] = None + bgwriter_flush_after: Optional[int] = None + bgwriter_lru_maxpages: Optional[int] = None + bgwriter_lru_multiplier: Optional[float] = None + deadlock_timeout: Optional[int] = None + default_toast_compression: Optional[str] = None + idle_in_transaction_session_timeout: Optional[int] = None + jit: Optional[bool] = None + max_files_per_process: Optional[int] = None + max_locks_per_transaction: Optional[int] = None + max_logical_replication_workers: Optional[int] = None + max_parallel_workers: Optional[int] = None + max_parallel_workers_per_gather: Optional[int] = None + max_pred_locks_per_transaction: Optional[int] = None + max_replication_slots: Optional[int] = None + max_slot_wal_keep_size: Optional[int] = None + max_stack_depth: Optional[int] = None + max_standby_archive_delay: Optional[int] = None + max_standby_streaming_delay: Optional[int] = None + max_wal_senders: Optional[int] = None + max_worker_processes: Optional[int] = None + password_encryption: Optional[str] = None + pg_partman_bgw_interval: Optional[int] = field( + default=None, metadata={"json_key": "pg_partman_bgw.interval"} + ) + pg_partman_bgw_role: Optional[str] = field( + default=None, metadata={"json_key": "pg_partman_bgw.role"} + ) + pg_stat_monitor_pgsm_enable_query_plan: Optional[bool] = field( + default=None, + metadata={"json_key": "pg_stat_monitor.pgsm_enable_query_plan"}, + ) + pg_stat_monitor_pgsm_max_buckets: Optional[int] = field( + default=None, metadata={"json_key": "pg_stat_monitor.pgsm_max_buckets"} + ) + pg_stat_statements_track: Optional[str] = field( + default=None, metadata={"json_key": "pg_stat_statements.track"} + ) + temp_file_limit: Optional[int] = None + timezone: Optional[str] = None + track_activity_query_size: Optional[int] = None + track_commit_timestamp: Optional[str] = None + track_functions: Optional[str] = None + track_io_timing: Optional[str] = None + wal_sender_timeout: Optional[int] = None + wal_writer_delay: Optional[int] = None + + +@dataclass +class PostgreSQLDatabaseConfigOptions(JSONObject): + """ + PostgreSQLDatabaseConfigOptions is used to specify + a PostgreSQL Database Cluster's configuration options during its creation. """ - api_endpoint = "/databases/postgresql/instances/{database_id}/backups/{id}" + pg: Optional[PostgreSQLDatabaseConfigPGOptions] = None + pg_stat_monitor_enable: Optional[bool] = None + pglookout: Optional[PostgreSQLDatabaseConfigPGLookoutOptions] = None + shared_buffers_percentage: Optional[float] = None + work_mem: Optional[int] = None class MySQLDatabase(Base): @@ -130,21 +230,27 @@ class MySQLDatabase(Base): "id": Property(identifier=True), "label": Property(mutable=True), "allow_list": Property(mutable=True, unordered=True), - "backups": Property(derived_class=MySQLDatabaseBackup), - "cluster_size": Property(), + "cluster_size": Property(mutable=True), "created": Property(is_datetime=True), "encrypted": Property(), "engine": Property(), "hosts": Property(), "port": Property(), "region": Property(), - "replication_type": Property(), "ssl_connection": Property(), "status": Property(volatile=True), - "type": Property(), + "type": Property(mutable=True), + "fork": Property(), + "oldest_restore_time": Property(is_datetime=True), "updated": Property(volatile=True, is_datetime=True), "updates": Property(mutable=True), "version": Property(), + "engine_config": Property( + mutable=True, json_object=MySQLDatabaseConfigOptions + ), + "private_network": Property( + mutable=True, json_object=DatabasePrivateNetwork, nullable=True + ), } @property @@ -219,25 +325,6 @@ def patch(self): "{}/patch".format(MySQLDatabase.api_endpoint), model=self ) - def backup_create(self, label, **kwargs): - """ - Creates a snapshot backup of a Managed MySQL Database. - - API Documentation: https://techdocs.akamai.com/linode-api/reference/post-databases-mysql-instance-backup - """ - - params = { - "label": label, - } - params.update(kwargs) - - self._client.post( - "{}/backups".format(MySQLDatabase.api_endpoint), - model=self, - data=params, - ) - self.invalidate() - def invalidate(self): """ Clear out cached properties. @@ -249,6 +336,30 @@ def invalidate(self): Base.invalidate(self) + def suspend(self): + """ + Suspend a MySQL Managed Database, releasing idle resources and keeping only necessary data. + + API documentation: https://techdocs.akamai.com/linode-api/reference/suspend-databases-mysql-instance + """ + self._client.post( + "{}/suspend".format(MySQLDatabase.api_endpoint), model=self + ) + + return self.invalidate() + + def resume(self): + """ + Resume a suspended MySQL Managed Database. + + API documentation: https://techdocs.akamai.com/linode-api/reference/resume-databases-mysql-instance + """ + self._client.post( + "{}/resume".format(MySQLDatabase.api_endpoint), model=self + ) + + return self.invalidate() + class PostgreSQLDatabase(Base): """ @@ -263,22 +374,27 @@ class PostgreSQLDatabase(Base): "id": Property(identifier=True), "label": Property(mutable=True), "allow_list": Property(mutable=True, unordered=True), - "backups": Property(derived_class=PostgreSQLDatabaseBackup), - "cluster_size": Property(), + "cluster_size": Property(mutable=True), "created": Property(is_datetime=True), "encrypted": Property(), "engine": Property(), "hosts": Property(), "port": Property(), "region": Property(), - "replication_commit_type": Property(), - "replication_type": Property(), "ssl_connection": Property(), "status": Property(volatile=True), - "type": Property(), + "type": Property(mutable=True), + "fork": Property(), + "oldest_restore_time": Property(is_datetime=True), "updated": Property(volatile=True, is_datetime=True), "updates": Property(mutable=True), "version": Property(), + "engine_config": Property( + mutable=True, json_object=PostgreSQLDatabaseConfigOptions + ), + "private_network": Property( + mutable=True, json_object=DatabasePrivateNetwork, nullable=True + ), } @property @@ -354,25 +470,6 @@ def patch(self): "{}/patch".format(PostgreSQLDatabase.api_endpoint), model=self ) - def backup_create(self, label, **kwargs): - """ - Creates a snapshot backup of a Managed PostgreSQL Database. - - API Documentation: https://techdocs.akamai.com/linode-api/reference/post-databases-postgre-sql-instance-backup - """ - - params = { - "label": label, - } - params.update(kwargs) - - self._client.post( - "{}/backups".format(PostgreSQLDatabase.api_endpoint), - model=self, - data=params, - ) - self.invalidate() - def invalidate(self): """ Clear out cached properties. @@ -384,6 +481,30 @@ def invalidate(self): Base.invalidate(self) + def suspend(self): + """ + Suspend a PostgreSQL Managed Database, releasing idle resources and keeping only necessary data. + + API documentation: https://techdocs.akamai.com/linode-api/reference/suspend-databases-postgre-sql-instance + """ + self._client.post( + "{}/suspend".format(PostgreSQLDatabase.api_endpoint), model=self + ) + + return self.invalidate() + + def resume(self): + """ + Resume a suspended PostgreSQL Managed Database. + + API documentation: https://techdocs.akamai.com/linode-api/reference/resume-databases-postgre-sql-instance + """ + self._client.post( + "{}/resume".format(PostgreSQLDatabase.api_endpoint), model=self + ) + + return self.invalidate() + ENGINE_TYPE_TRANSLATION = { "mysql": MySQLDatabase, @@ -414,9 +535,13 @@ class Database(Base): "region": Property(), "status": Property(), "type": Property(), + "fork": Property(), "updated": Property(), "updates": Property(), "version": Property(), + "private_network": Property( + json_object=DatabasePrivateNetwork, nullable=True + ), } @property diff --git a/linode_api4/objects/image.py b/linode_api4/objects/image.py index b2c413f86..50dc23f74 100644 --- a/linode_api4/objects/image.py +++ b/linode_api4/objects/image.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import List, Union +from typing import List, Optional, Union from linode_api4.objects import Base, Property, Region from linode_api4.objects.serializable import JSONObject, StrEnum @@ -24,8 +24,42 @@ class ImageRegion(JSONObject): The region and status of an image replica. """ + include_none_values = True + region: str = "" - status: ReplicationStatus = None + status: Optional[ReplicationStatus] = None + + +@dataclass +class ImageSharingSharedWith(JSONObject): + """ + Data representing who an Image has been shared with. + """ + + sharegroup_count: Optional[int] = None + sharegroup_list_url: Optional[str] = None + + +@dataclass +class ImageSharingSharedBy(JSONObject): + """ + Data representing who shared an Image. + """ + + sharegroup_id: Optional[int] = None + sharegroup_uuid: Optional[str] = None + sharegroup_label: Optional[str] = None + source_image_id: Optional[str] = None + + +@dataclass +class ImageSharing(JSONObject): + """ + The Image Sharing status of an Image. + """ + + shared_with: Optional[ImageSharingSharedWith] = None + shared_by: Optional[ImageSharingSharedBy] = None class Image(Base): @@ -49,6 +83,7 @@ class Image(Base): "updated": Property(is_datetime=True), "type": Property(), "is_public": Property(), + "is_shared": Property(), "vendor": Property(), "size": Property(), "deprecated": Property(), @@ -58,14 +93,13 @@ class Image(Base): "tags": Property(mutable=True, unordered=True), "total_size": Property(), "regions": Property(json_object=ImageRegion, unordered=True), + "image_sharing": Property(json_object=ImageSharing), } def replicate(self, regions: Union[List[str], List[Region]]): """ Replicate the image to other regions. - Note: Image replication may not currently be available to all users. - API Documentation: https://techdocs.akamai.com/linode-api/reference/post-replicate-image :param regions: A list of regions that the customer wants to replicate this image in. diff --git a/linode_api4/objects/image_share_group.py b/linode_api4/objects/image_share_group.py new file mode 100644 index 000000000..6c75fc7f9 --- /dev/null +++ b/linode_api4/objects/image_share_group.py @@ -0,0 +1,344 @@ +__all__ = [ + "ImageShareGroupImageToAdd", + "ImageShareGroupImagesToAdd", + "ImageShareGroupImageToUpdate", + "ImageShareGroupMemberToAdd", + "ImageShareGroupMemberToUpdate", + "ImageShareGroup", + "ImageShareGroupToken", +] +from dataclasses import dataclass +from typing import List, Optional + +from linode_api4.objects import Base, MappedObject, Property +from linode_api4.objects.serializable import JSONObject + + +@dataclass +class ImageShareGroupImageToAdd(JSONObject): + """ + Data representing an Image to add to an Image Share Group. + """ + + id: str + label: Optional[str] = None + description: Optional[str] = None + + def to_dict(self): + d = {"id": self.id} + if self.label is not None: + d["label"] = self.label + if self.description is not None: + d["description"] = self.description + return d + + +@dataclass +class ImageShareGroupImagesToAdd(JSONObject): + """ + Data representing a list of Images to add to an Image Share Group. + """ + + images: List[ImageShareGroupImageToAdd] + + +@dataclass +class ImageShareGroupImageToUpdate(JSONObject): + """ + Data to update an Image shared in an Image Share Group. + """ + + image_share_id: str + label: Optional[str] = None + description: Optional[str] = None + + def to_dict(self): + d = {"image_share_id": self.image_share_id} + if self.label is not None: + d["label"] = self.label + if self.description is not None: + d["description"] = self.description + return d + + +@dataclass +class ImageShareGroupMemberToAdd(JSONObject): + """ + Data representing a Member to add to an Image Share Group. + """ + + token: str + label: str + + +@dataclass +class ImageShareGroupMemberToUpdate(JSONObject): + """ + Data to update a Member in an Image Share Group. + """ + + token_uuid: str + label: str + + +class ImageShareGroup(Base): + """ + An Image Share Group is a group to share private images with other users. This class is intended + to be used by a Producer of an Image Share Group, and not a Consumer. + + NOTE: Private Image Sharing features are in beta and may not be generally available. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-sharegroup + """ + + api_endpoint = "/images/sharegroups/{id}" + + properties = { + "id": Property(identifier=True), + "uuid": Property(), + "label": Property(mutable=True), + "description": Property(mutable=True), + "is_suspended": Property(), + "images_count": Property(), + "members_count": Property(), + "created": Property(is_datetime=True), + "updated": Property(is_datetime=True), + "expiry": Property(is_datetime=True), + } + + def add_images(self, images: ImageShareGroupImagesToAdd): + """ + Add private images to be shared in the Image Share Group. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-sharegroup-images + + :param images: A list of Images to share in the Image Share Group, formatted in JSON. + :type images: ImageShareGroupImagesToAdd + + :returns: A list of the new Image shares. + :rtype: List of MappedObject + """ + params = {"images": [img.to_dict() for img in images.images]} + + result = self._client.post( + "{}/images".format(self.api_endpoint), model=self, data=params + ) + + # Sync this object to reflect the new images added to the share group. + self.invalidate() + + # Expect result to be a dict with a 'data' key + image_list = result.get("data", []) + return [MappedObject(**item) for item in image_list] + + def get_image_shares(self): + """ + Retrieves a list of images shared in the Image Share Group. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-sharegroup-images + + :returns: A list of the Image shares. + :rtype: List of MappedObject + """ + result = self._client.get( + "{}/images".format(self.api_endpoint), + model=self, + ) + image_list = result.get("data", []) + return [MappedObject(**item) for item in image_list] + + def update_image_share(self, image: ImageShareGroupImageToUpdate): + """ + Update the label and description of an Image shared in the Image Share Group. + Note that the ID provided in the image parameter must be the shared ID of an + Image already shared in the Image Share Group, not the private ID. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/put-sharegroup-imageshare + + :param image: The Image to update, formatted in JSON. + :type image: ImageShareGroupImageToUpdate + + :returns: The updated Image share. + :rtype: MappedObject + """ + params = image.to_dict() + + result = self._client.put( + "{}/images/{}".format(self.api_endpoint, image.image_share_id), + model=self, + data=params, + ) + + return MappedObject(**result) + + def revoke_image_share(self, image_share_id: str): + """ + Revoke an Image shared in the Image Share Group. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/delete-sharegroup-imageshare + + :param image_share_id: The ID of the Image share to revoke. + :type image_share_id: str + """ + self._client.delete( + "{}/images/{}".format(self.api_endpoint, image_share_id), model=self + ) + + # Sync this object to reflect the revoked image share. + self.invalidate() + + def add_member(self, member: ImageShareGroupMemberToAdd): + """ + Add a Member to the Image Share Group. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-sharegroup-members + + :param member: The Member to add, formatted in JSON. + :type member: ImageShareGroupMemberToAdd + + :returns: The new Member. + :rtype: MappedObject + """ + params = { + "token": member.token, + "label": member.label, + } + + result = self._client.post( + "{}/members".format(self.api_endpoint), model=self, data=params + ) + + # Sync this object to reflect the new member added to the share group. + self.invalidate() + + return MappedObject(**result) + + def get_members(self): + """ + Retrieves a list of members in the Image Share Group. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-sharegroup-members + + :returns: List of members. + :rtype: List of MappedObject + """ + result = self._client.get( + "{}/members".format(self.api_endpoint), + model=self, + ) + member_list = result.get("data", []) + return [MappedObject(**item) for item in member_list] + + def get_member(self, token_uuid: str): + """ + Get a Member in the Image Share Group. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-sharegroup-member-token + + :param token_uuid: The UUID of the token corresponding to the Member to retrieve. + :type token_uuid: str + + :returns: The requested Member. + :rtype: MappedObject + """ + result = self._client.get( + "{}/members/{}".format(self.api_endpoint, token_uuid), model=self + ) + + return MappedObject(**result) + + def update_member(self, member: ImageShareGroupMemberToUpdate): + """ + Update the label of a Member in the Image Share Group. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/put-sharegroup-member-token + + :param member: The Member to update, formatted in JSON. + :type member: ImageShareGroupMemberToUpdate + + :returns: The updated Member. + :rtype: MappedObject + """ + params = { + "label": member.label, + } + + result = self._client.put( + "{}/members/{}".format(self.api_endpoint, member.token_uuid), + model=self, + data=params, + ) + + return MappedObject(**result) + + def remove_member(self, token_uuid: str): + """ + Remove a Member from the Image Share Group. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/delete-sharegroup-member-token + + :param token_uuid: The UUID of the token corresponding to the Member to remove. + :type token_uuid: str + """ + self._client.delete( + "{}/members/{}".format(self.api_endpoint, token_uuid), model=self + ) + + # Sync this object to reflect the removed member. + self.invalidate() + + +class ImageShareGroupToken(Base): + """ + An Image Share Group Token is a token that can be used to access the Images shared in an Image Share Group. + This class is intended to be used by a Consumer of an Image Share Group, and not a Producer. + + NOTE: Private Image Sharing features are in beta and may not be generally available. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-sharegroup-token + """ + + api_endpoint = "/images/sharegroups/tokens/{token_uuid}" + id_attribute = "token_uuid" + properties = { + "token_uuid": Property(identifier=True), + "status": Property(), + "label": Property(mutable=True), + "valid_for_sharegroup_uuid": Property(), + "created": Property(is_datetime=True), + "updated": Property(is_datetime=True), + "expiry": Property(is_datetime=True), + "sharegroup_uuid": Property(), + "sharegroup_label": Property(), + } + + def get_sharegroup(self): + """ + Gets details about the Image Share Group that this token provides access to. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-sharegroup-by-token + + :returns: The requested Image Share Group. + :rtype: MappedObject + """ + result = self._client.get( + "{}/sharegroup".format(self.api_endpoint), model=self + ) + + return MappedObject(**result) + + def get_images(self): + """ + Retrieves a paginated list of images shared in the Image Share Group that this token provides access to. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-sharegroup-images-by-token + + :returns: List of images. + :rtype: List of MappedObject + """ + result = self._client.get( + "{}/sharegroup/images".format(self.api_endpoint), + model=self, + ) + image_list = result.get("data", []) + return [MappedObject(**item) for item in image_list] diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 39564200f..3ffe4b232 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -1,6 +1,8 @@ +import copy import string import sys -from dataclasses import dataclass +import warnings +from dataclasses import dataclass, field from datetime import datetime from enum import Enum from os import urandom @@ -8,25 +10,42 @@ from typing import Any, Dict, List, Optional, Union from urllib import parse -from linode_api4 import util from linode_api4.common import load_and_validate_keys from linode_api4.errors import UnexpectedResponseError -from linode_api4.objects import ( +from linode_api4.objects.base import ( Base, - DerivedBase, - Image, - JSONObject, + MappedObject, Property, - Region, + _flatten_request_body_recursive, ) -from linode_api4.objects.base import MappedObject +from linode_api4.objects.dbase import DerivedBase from linode_api4.objects.filtering import FilterableAttribute -from linode_api4.objects.networking import IPAddress, IPv6Range, VPCIPAddress -from linode_api4.objects.serializable import StrEnum +from linode_api4.objects.image import Image +from linode_api4.objects.linode_interfaces import ( + LinodeInterface, + LinodeInterfaceDefaultRouteOptions, + LinodeInterfacePublicOptions, + LinodeInterfacesSettings, + LinodeInterfaceVLANOptions, + LinodeInterfaceVPCOptions, +) +from linode_api4.objects.networking import ( + Firewall, + IPAddress, + IPv6Range, + VPCIPAddress, +) +from linode_api4.objects.nodebalancer import NodeBalancer +from linode_api4.objects.region import Region +from linode_api4.objects.serializable import JSONObject, StrEnum from linode_api4.objects.vpc import VPC, VPCSubnet from linode_api4.paginated_list import PaginatedList +from linode_api4.util import drop_null_keys, generate_device_suffixes PASSWORD_CHARS = string.ascii_letters + string.digits + string.punctuation +MIN_DEVICE_LIMIT = 8 +MB_PER_GB = 1024 +MAX_DEVICE_LIMIT = 64 class InstanceDiskEncryptionType(StrEnum): @@ -95,14 +114,14 @@ def restore_to(self, linode, **kwargs): """ d = { - "linode_id": ( - linode.id if issubclass(type(linode), Base) else linode - ), + "linode_id": linode, } d.update(kwargs) self._client.post( - "{}/restore".format(Backup.api_endpoint), model=self, data=d + "{}/restore".format(Backup.api_endpoint), + model=self, + data=_flatten_request_body_recursive(d), ) return True @@ -202,7 +221,7 @@ def resize(self, new_size): class Kernel(Base): """ The primary component of every Linux system. The kernel interfaces - with the system’s hardware and it controls the operating system’s core functionality. + with the system’s hardware, and it controls the operating system’s core functionality. Your Compute Instance is capable of running one of three kinds of kernels: @@ -222,6 +241,10 @@ class Kernel(Base): to compile the kernel from source than to download it from your package manager. For more information on custom compiled kernels, review our guides for Debian, Ubuntu, and CentOS. + .. note:: + The ``xen`` property is deprecated and is no longer returned by the API. + It is maintained for backward compatibility only. + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-kernel """ @@ -241,6 +264,16 @@ class Kernel(Base): "pvops": Property(), } + def __getattribute__(self, name: str) -> object: + if name == "xen": + warnings.warn( + "The 'xen' property of Kernel is deprecated and is no longer " + "returned by the API. It is maintained for backward compatibility only.", + DeprecationWarning, + stacklevel=2, + ) + return super().__getattribute__(name) + class Type(Base): """ @@ -263,6 +296,7 @@ class Type(Base): "vcpus": Property(), "gpus": Property(), "successor": Property(), + "accelerated_devices": Property(), # type_class is populated from the 'class' attribute of the returned JSON } @@ -284,10 +318,83 @@ def _populate(self, json): @dataclass class ConfigInterfaceIPv4(JSONObject): + """ + ConfigInterfaceIPv4 represents the IPv4 configuration of a VPC interface. + """ + vpc: str = "" nat_1_1: str = "" +@dataclass +class ConfigInterfaceIPv6SLAACOptions(JSONObject): + """ + ConfigInterfaceIPv6SLAACOptions is used to set a single IPv6 SLAAC configuration of a VPC interface. + """ + + range: str = "" + + +@dataclass +class ConfigInterfaceIPv6RangeOptions(JSONObject): + """ + ConfigInterfaceIPv6RangeOptions is used to set a single IPv6 range configuration of a VPC interface. + """ + + range: str = "" + + +@dataclass +class ConfigInterfaceIPv6Options(JSONObject): + """ + ConfigInterfaceIPv6Options is used to set the IPv6 configuration of a VPC interface. + """ + + slaac: List[ConfigInterfaceIPv6SLAACOptions] = field( + default_factory=lambda: [] + ) + ranges: List[ConfigInterfaceIPv6RangeOptions] = field( + default_factory=lambda: [] + ) + is_public: bool = False + + +@dataclass +class ConfigInterfaceIPv6SLAAC(JSONObject): + """ + ConfigInterfaceIPv6SLAAC represents a single SLAAC address under a VPC interface's IPv6 configuration. + """ + + put_class = ConfigInterfaceIPv6SLAACOptions + + range: str = "" + address: str = "" + + +@dataclass +class ConfigInterfaceIPv6Range(JSONObject): + """ + ConfigInterfaceIPv6Range represents a single IPv6 address under a VPC interface's IPv6 configuration. + """ + + put_class = ConfigInterfaceIPv6RangeOptions + + range: str = "" + + +@dataclass +class ConfigInterfaceIPv6(JSONObject): + """ + ConfigInterfaceIPv6 represents the IPv6 configuration of a VPC interface. + """ + + put_class = ConfigInterfaceIPv6Options + + slaac: List[ConfigInterfaceIPv6SLAAC] = field(default_factory=lambda: []) + ranges: List[ConfigInterfaceIPv6Range] = field(default_factory=lambda: []) + is_public: bool = False + + class NetworkInterface(DerivedBase): """ This class represents a Configuration Profile's network interface object. @@ -313,6 +420,7 @@ class NetworkInterface(DerivedBase): "vpc_id": Property(id_relationship=VPC), "subnet_id": Property(), "ipv4": Property(mutable=True, json_object=ConfigInterfaceIPv4), + "ipv6": Property(mutable=True, json_object=ConfigInterfaceIPv6), "ip_ranges": Property(mutable=True), } @@ -384,7 +492,10 @@ class ConfigInterface(JSONObject): # VPC-specific vpc_id: Optional[int] = None subnet_id: Optional[int] = None + ipv4: Optional[Union[ConfigInterfaceIPv4, Dict[str, Any]]] = None + ipv6: Optional[Union[ConfigInterfaceIPv6, Dict[str, Any]]] = None + ip_ranges: Optional[List[str]] = None # Computed @@ -393,7 +504,7 @@ class ConfigInterface(JSONObject): def __repr__(self): return f"Interface: {self.purpose}" - def _serialize(self): + def _serialize(self, is_put: bool = False): purpose_formats = { "public": {"purpose": "public", "primary": self.primary}, "vlan": { @@ -405,11 +516,8 @@ def _serialize(self): "purpose": "vpc", "primary": self.primary, "subnet_id": self.subnet_id, - "ipv4": ( - self.ipv4.dict - if isinstance(self.ipv4, ConfigInterfaceIPv4) - else self.ipv4 - ), + "ipv4": self.ipv4, + "ipv6": self.ipv6, "ip_ranges": self.ip_ranges, }, } @@ -419,11 +527,14 @@ def _serialize(self): f"Unknown interface purpose: {self.purpose}", ) - return { - k: v - for k, v in purpose_formats[self.purpose].items() - if v is not None - } + return _flatten_request_body_recursive( + { + k: v + for k, v in purpose_formats[self.purpose].items() + if v is not None + }, + is_put=is_put, + ) class Config(DerivedBase): @@ -503,16 +614,16 @@ def _populate(self, json): self._set("devices", MappedObject(**devices)) - def _serialize(self): + def _serialize(self, is_put: bool = False): """ Overrides _serialize to transform interfaces into json """ - partial = DerivedBase._serialize(self) + partial = DerivedBase._serialize(self, is_put=is_put) interfaces = [] for c in self.interfaces: if isinstance(c, ConfigInterface): - interfaces.append(c._serialize()) + interfaces.append(c._serialize(is_put=is_put)) else: interfaces.append(c) @@ -564,6 +675,7 @@ def interface_create_vpc( subnet: Union[int, VPCSubnet], primary=False, ipv4: Union[Dict[str, Any], ConfigInterfaceIPv4] = None, + ipv6: Union[Dict[str, Any], ConfigInterfaceIPv6Options] = None, ip_ranges: Optional[List[str]] = None, ) -> NetworkInterface: """ @@ -577,6 +689,8 @@ def interface_create_vpc( :type primary: bool :param ipv4: The IPv4 configuration of the interface for the associated subnet. :type ipv4: Dict or ConfigInterfaceIPv4 + :param ipv6: The IPv6 configuration of the interface for the associated subnet. + :type ipv6: Dict or ConfigInterfaceIPv6Options :param ip_ranges: A list of IPs or IP ranges in the VPC subnet. Packets to these CIDRs are routed through the VPC network interface. @@ -587,19 +701,16 @@ def interface_create_vpc( """ params = { "purpose": "vpc", - "subnet_id": subnet.id if isinstance(subnet, VPCSubnet) else subnet, + "subnet_id": subnet, "primary": primary, + "ipv4": ipv4, + "ipv6": ipv6, + "ip_ranges": ip_ranges, } - if ipv4 is not None: - params["ipv4"] = ( - ipv4.dict if isinstance(ipv4, ConfigInterfaceIPv4) else ipv4 - ) - - if ip_ranges is not None: - params["ip_ranges"] = ip_ranges - - return self._interface_create(params) + return self._interface_create( + drop_null_keys(_flatten_request_body_recursive(params)) + ) def interface_reorder(self, interfaces: List[Union[int, NetworkInterface]]): """ @@ -646,6 +757,33 @@ class MigrationType: WARM = "warm" +class InterfaceGeneration(StrEnum): + """ + A string enum representing which interface generation a Linode is using. + """ + + LEGACY_CONFIG = "legacy_config" + LINODE = "linode" + + +@dataclass +class UpgradeInterfacesResult(JSONObject): + """ + Contains information about an Linode Interface upgrade operation. + + NOTE: If dry_run is True, each returned interface will be of type Dict[str, Any]. + Otherwise, each returned interface will be of type LinodeInterface. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-upgrade-linode-interfaces + """ + + dry_run: bool = False + config_id: int = 0 + interfaces: List[Union[Dict[str, Any], LinodeInterface]] = field( + default_factory=list + ) + + class Instance(Base): """ A Linode Instance. @@ -678,6 +816,10 @@ class Instance(Base): "has_user_data": Property(), "disk_encryption": Property(), "lke_cluster_id": Property(), + "capabilities": Property(unordered=True), + "interface_generation": Property(), + "maintenance_policy": Property(mutable=True), + "locks": Property(unordered=True), } @property @@ -688,8 +830,8 @@ def ips(self): API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-ips - :returns: A List of the ips of the Linode Instance. - :rtype: List[IPAddress] + :returns: Information about the IP addresses assigned to this instance. + :rtype: MappedObject """ if not hasattr(self, "_ips"): result = self._client.get( @@ -954,6 +1096,9 @@ def invalidate(self): if hasattr(self, "_placement_group"): del self._placement_group + if hasattr(self, "_interfaces"): + del self._interfaces + Base.invalidate(self) def boot(self, config=None): @@ -1061,8 +1206,6 @@ def resize( :rtype: bool """ - new_type = new_type.id if issubclass(type(new_type), Base) else new_type - params = { "type": new_type, "allow_auto_disk_resize": allow_auto_disk_resize, @@ -1071,7 +1214,9 @@ def resize( params.update(kwargs) resp = self._client.post( - "{}/resize".format(Instance.api_endpoint), model=self, data=params + "{}/resize".format(Instance.api_endpoint), + model=self, + data=_flatten_request_body_recursive(params), ) if "error" in resp: @@ -1130,9 +1275,19 @@ def config_create( from .volume import Volume # pylint: disable=import-outside-toplevel hypervisor_prefix = "sd" if self.hypervisor == "kvm" else "xvd" + + device_limit = int( + max( + MIN_DEVICE_LIMIT, + min(self.specs.memory // MB_PER_GB, MAX_DEVICE_LIMIT), + ) + ) + device_names = [ - hypervisor_prefix + string.ascii_lowercase[i] for i in range(0, 8) + hypervisor_prefix + suffix + for suffix in generate_device_suffixes(device_limit) ] + device_map = { device_names[i]: None for i in range(0, len(device_names)) } @@ -1187,7 +1342,7 @@ def config_create( param_interfaces.append(interface) params = { - "kernel": kernel.id if issubclass(type(kernel), Base) else kernel, + "kernel": kernel, "label": ( label if label @@ -1199,7 +1354,9 @@ def config_create( params.update(kwargs) result = self._client.post( - "{}/configs".format(Instance.api_endpoint), model=self, data=params + "{}/configs".format(Instance.api_endpoint), + model=self, + data=_flatten_request_body_recursive(params), ) self.invalidate() @@ -1221,6 +1378,9 @@ def disk_create( root_pass=None, authorized_keys=None, authorized_users=None, + disk_encryption: Optional[ + Union[InstanceDiskEncryptionType, str] + ] = None, stackscript=None, **stackscript_args, ): @@ -1245,6 +1405,9 @@ def disk_create( as trusted for the root user. These user's keys should already be set up, see :any:`ProfileGroup.ssh_keys` for details. + :param disk_encryption: The disk encryption policy for this Linode. + NOTE: Disk encryption may not currently be available to all users. + :type disk_encryption: InstanceDiskEncryptionType or str :param stackscript: A StackScript object, or the ID of one, to deploy to this disk. Requires deploying a compatible image. :param **stackscript_args: Any arguments to pass to the StackScript, as defined @@ -1272,25 +1435,27 @@ def disk_create( "filesystem": filesystem, "authorized_keys": authorized_keys, "authorized_users": authorized_users, + "stackscript_id": stackscript, } + if disk_encryption is not None: + params["disk_encryption"] = str(disk_encryption) + if image: params.update( { - "image": ( - image.id if issubclass(type(image), Base) else image - ), + "image": image, "root_pass": root_pass, } ) - if stackscript: - params["stackscript_id"] = stackscript.id - if stackscript_args: - params["stackscript_data"] = stackscript_args + if stackscript_args: + params["stackscript_data"] = stackscript_args result = self._client.post( - "{}/disks".format(Instance.api_endpoint), model=self, data=params + "{}/disks".format(Instance.api_endpoint), + model=self, + data=_flatten_request_body_recursive(drop_null_keys(params)), ) self.invalidate() @@ -1450,18 +1615,20 @@ def rebuild( authorized_keys = load_and_validate_keys(authorized_keys) params = { - "image": image.id if issubclass(type(image), Base) else image, + "image": image, "root_pass": root_pass, "authorized_keys": authorized_keys, + "disk_encryption": ( + str(disk_encryption) if disk_encryption else None + ), } - if disk_encryption is not None: - params["disk_encryption"] = str(disk_encryption) - params.update(kwargs) result = self._client.post( - "{}/rebuild".format(Instance.api_endpoint), model=self, data=params + "{}/rebuild".format(Instance.api_endpoint), + model=self, + data=_flatten_request_body_recursive(drop_null_keys(params)), ) if not "id" in result: @@ -1586,7 +1753,7 @@ def initiate_migration( """ params = { - "region": region.id if issubclass(type(region), Base) else region, + "region": region, "upgrade": upgrade, "type": migration_type, "placement_group": _expand_placement_group_assignment( @@ -1594,10 +1761,10 @@ def initiate_migration( ), } - util.drop_null_keys(params) - self._client.post( - "{}/migrate".format(Instance.api_endpoint), model=self, data=params + "{}/migrate".format(Instance.api_endpoint), + model=self, + data=_flatten_request_body_recursive(drop_null_keys(params)), ) def firewalls(self): @@ -1609,9 +1776,6 @@ def firewalls(self): :returns: A List of Firewalls of the Linode Instance. :rtype: List[Firewall] """ - from linode_api4.objects import ( # pylint: disable=import-outside-toplevel - Firewall, - ) result = self._client.get( "{}/firewalls".format(Instance.api_endpoint), model=self @@ -1622,6 +1786,22 @@ def firewalls(self): for firewall in result["data"] ] + def apply_firewalls(self): + """ + Reapply assigned firewalls to a Linode in case they were not applied successfully. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-apply-firewalls + + :returns: Returns True if the operation was successful + :rtype: bool + """ + + self._client.post( + "{}/firewalls/apply".format(Instance.api_endpoint), model=self + ) + + return True + def nodebalancers(self): """ View a list of NodeBalancers that are assigned to this Linode and readable by the requesting User. @@ -1631,9 +1811,6 @@ def nodebalancers(self): :returns: A List of Nodebalancers of the Linode Instance. :rtype: List[Nodebalancer] """ - from linode_api4.objects import ( # pylint: disable=import-outside-toplevel - NodeBalancer, - ) result = self._client.get( "{}/nodebalancers".format(Instance.api_endpoint), model=self @@ -1735,21 +1912,12 @@ def clone( if not isinstance(disks, list) and not isinstance(disks, PaginatedList): disks = [disks] - cids = [c.id if issubclass(type(c), Base) else c for c in configs] - dids = [d.id if issubclass(type(d), Base) else d for d in disks] - params = { - "linode_id": ( - to_linode.id if issubclass(type(to_linode), Base) else to_linode - ), - "region": region.id if issubclass(type(region), Base) else region, - "type": ( - instance_type.id - if issubclass(type(instance_type), Base) - else instance_type - ), - "configs": cids if cids else None, - "disks": dids if dids else None, + "linode_id": to_linode, + "region": region, + "type": instance_type, + "configs": configs, + "disks": disks, "label": label, "group": group, "with_backups": with_backups, @@ -1758,10 +1926,10 @@ def clone( ), } - util.drop_null_keys(params) - result = self._client.post( - "{}/clone".format(Instance.api_endpoint), model=self, data=params + "{}/clone".format(Instance.api_endpoint), + model=self, + data=_flatten_request_body_recursive(drop_null_keys(params)), ) if not "id" in result: @@ -1825,6 +1993,217 @@ def stats_for(self, dt): model=self, ) + def interface_create( + self, + firewall: Optional[Union[Firewall, int]] = None, + default_route: Optional[ + Union[Dict[str, Any], LinodeInterfaceDefaultRouteOptions] + ] = None, + public: Optional[ + Union[Dict[str, Any], LinodeInterfacePublicOptions] + ] = None, + vlan: Optional[ + Union[Dict[str, Any], LinodeInterfaceVLANOptions] + ] = None, + vpc: Optional[Union[Dict[str, Any], LinodeInterfaceVPCOptions]] = None, + **kwargs, + ) -> LinodeInterface: + """ + Creates a new interface under this Linode. + Linode interfaces are not interchangeable with Config interfaces. + + NOTE: Linode interfaces may not currently be available to all users. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-linode-interface + + Example: Creating a simple public interface for this Linode:: + + interface = instance.interface_create( + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ipv6=True + ), + public=LinodeInterfacePublicOptions() + ) + + Example: Creating a simple VPC interface for this Linode:: + + interface = instance.interface_create( + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True + ), + vpc=LinodeInterfaceVPCOptions( + subnet_id=12345 + ) + ) + + Example: Creating a simple VLAN interface for this Linode:: + + interface = instance.interface_create( + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True + ), + vlan=LinodeInterfaceVLANOptions( + vlan_label="my-vlan" + ) + ) + + :param firewall: The firewall this interface should be assigned to. + :param default_route: The desired default route configuration of the new interface. + :param public: The public-specific configuration of the new interface. + If set, the new instance will be a public interface. + :param vlan: The VLAN-specific configuration of the new interface. + If set, the new instance will be a VLAN interface. + :param vpc: The VPC-specific configuration of the new interface. + If set, the new instance will be a VPC interface. + + :returns: The newly created Linode Interface. + :rtype: LinodeInterface + """ + + params = { + "firewall_id": firewall, + "default_route": default_route, + "public": public, + "vlan": vlan, + "vpc": vpc, + } + + params.update(kwargs) + + result = self._client.post( + "{}/interfaces".format(Instance.api_endpoint), + model=self, + data=drop_null_keys(_flatten_request_body_recursive(params)), + ) + + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response creating interface!", json=result + ) + + return LinodeInterface(self._client, result["id"], self.id, json=result) + + @property + def interfaces_settings(self) -> LinodeInterfacesSettings: + """ + The settings for all interfaces under this Linode. + + NOTE: Linode interfaces may not currently be available to all users. + + :returns: The settings for instance-level interface settings for this Linode. + :rtype: LinodeInterfacesSettings + """ + + # NOTE: We do not implement this as a Property because Property does + # not currently have a mechanism for 1:1 sub-entities. + + if not hasattr(self, "_interfaces_settings"): + self._set( + "_interfaces_settings", + # We don't use lazy loading here because it can trigger a known issue + # where setting fields for updates before the entity has been lazy loaded + # causes the user's value to be discarded. + self._client.load(LinodeInterfacesSettings, self.id), + ) + + return self._interfaces_settings + + @property + def linode_interfaces(self) -> Optional[list[LinodeInterface]]: + """ + All interfaces for this Linode. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-interface + + :returns: An ordered list of linode interfaces under this Linode. If the linode is with legacy config interfaces, returns None. + :rtype: Optional[list[LinodeInterface]] + """ + + if self.interface_generation != InterfaceGeneration.LINODE: + return None + + if not hasattr(self, "_interfaces"): + result = self._client.get( + "{}/interfaces".format(Instance.api_endpoint), + model=self, + ) + if "interfaces" not in result: + raise UnexpectedResponseError( + "Got unexpected response when retrieving Linode interfaces", + json=result, + ) + + self._set( + "_interfaces", + [ + LinodeInterface( + self._client, iface["id"], self.id, json=iface + ) + for iface in result["interfaces"] + ], + ) + + return self._interfaces + + def upgrade_interfaces( + self, + config: Optional[Union[Config, int]] = None, + dry_run: bool = False, + **kwargs, + ) -> UpgradeInterfacesResult: + """ + Automatically upgrades all legacy config interfaces of a + single configuration profile to Linode interfaces. + + NOTE: If dry_run is True, interfaces in the result will be + of type MappedObject rather than LinodeInterface. + + NOTE: Linode interfaces may not currently be available to all users. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-upgrade-linode-interfaces + + :param config: The configuration profile the legacy interfaces to + upgrade are under. + :type config: Config or int + :param dry_run: Whether this operation should be a dry run, + which will return the interfaces that would be + created if the operation were completed. + :type dry_run: bool + + :returns: Information about the newly upgraded interfaces. + :rtype: UpgradeInterfacesResult + """ + params = {"config_id": config, "dry_run": dry_run} + + params.update(kwargs) + + result = self._client.post( + "{}/upgrade-interfaces".format(Instance.api_endpoint), + model=self, + data=_flatten_request_body_recursive(drop_null_keys(params)), + ) + + # This resolves an edge case where `result["interfaces"]` persists across + # multiple calls, which can cause parsing errors when expanding them below. + result = copy.deepcopy(result) + + self.invalidate() + + # We don't convert interface dicts to LinodeInterface objects on dry runs + # actual API entities aren't created. + if dry_run: + result["interfaces"] = [ + MappedObject(**iface) for iface in result["interfaces"] + ] + else: + result["interfaces"] = [ + LinodeInterface(self._client, iface["id"], self.id, iface) + for iface in result["interfaces"] + ] + + return UpgradeInterfacesResult.from_json(result) + class UserDefinedFieldType(Enum): text = 1 @@ -1906,8 +2285,8 @@ def _populate(self, json): ndist = [Image(self._client, d) for d in self.images] self._set("images", ndist) - def _serialize(self): - dct = Base._serialize(self) + def _serialize(self, is_put: bool = False): + dct = Base._serialize(self, is_put=is_put) dct["images"] = [d.id for d in self.images] return dct @@ -1915,7 +2294,7 @@ def _serialize(self): def _expand_placement_group_assignment( pg: Union[ InstancePlacementGroupAssignment, "PlacementGroup", Dict[str, Any], int - ] + ], ) -> Optional[Dict[str, Any]]: """ Expands the placement group argument into a dict for use in an API request body. diff --git a/linode_api4/objects/linode_interfaces.py b/linode_api4/objects/linode_interfaces.py new file mode 100644 index 000000000..0598d1f3c --- /dev/null +++ b/linode_api4/objects/linode_interfaces.py @@ -0,0 +1,552 @@ +from dataclasses import dataclass, field +from typing import List, Optional + +from linode_api4.objects.base import Base, Property +from linode_api4.objects.dbase import DerivedBase +from linode_api4.objects.networking import Firewall +from linode_api4.objects.serializable import JSONObject + + +@dataclass +class LinodeInterfacesSettingsDefaultRouteOptions(JSONObject): + """ + The options used to configure the default route settings for a Linode's network interfaces. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + ipv4_interface_id: Optional[int] = None + ipv6_interface_id: Optional[int] = None + + +@dataclass +class LinodeInterfacesSettingsDefaultRoute(JSONObject): + """ + The default route settings for a Linode's network interfaces. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfacesSettingsDefaultRouteOptions + + ipv4_interface_id: Optional[int] = None + ipv4_eligible_interface_ids: List[int] = field(default_factory=list) + ipv6_interface_id: Optional[int] = None + ipv6_eligible_interface_ids: List[int] = field(default_factory=list) + + +class LinodeInterfacesSettings(Base): + """ + The settings related to a Linode's network interfaces. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-interface-settings + + NOTE: Linode interfaces may not currently be available to all users. + """ + + api_endpoint = "/linode/instances/{id}/interfaces/settings" + + properties = { + "id": Property(identifier=True), + "network_helper": Property(mutable=True), + "default_route": Property( + mutable=True, json_object=LinodeInterfacesSettingsDefaultRoute + ), + } + + +# Interface POST Options +@dataclass +class LinodeInterfaceDefaultRouteOptions(JSONObject): + """ + Options accepted when creating or updating a Linode Interface's default route settings. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + ipv4: Optional[bool] = None + ipv6: Optional[bool] = None + + +@dataclass +class LinodeInterfaceVPCIPv4AddressOptions(JSONObject): + """ + Options accepted for a single address when creating or updating the IPv4 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + address: Optional[str] = None + primary: Optional[bool] = None + nat_1_1_address: Optional[str] = None + + +@dataclass +class LinodeInterfaceVPCIPv4RangeOptions(JSONObject): + """ + Options accepted for a single range when creating or updating the IPv4 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + range: str = "" + + +@dataclass +class LinodeInterfaceVPCIPv4Options(JSONObject): + """ + Options accepted when creating or updating the IPv4 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + addresses: Optional[List[LinodeInterfaceVPCIPv4AddressOptions]] = None + ranges: Optional[List[LinodeInterfaceVPCIPv4RangeOptions]] = None + + +@dataclass +class LinodeInterfaceVPCIPv6SLAACOptions(JSONObject): + """ + Options accepted for a single SLAAC when creating or updating the IPv6 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + range: Optional[str] = None + + +@dataclass +class LinodeInterfaceVPCIPv6RangeOptions(JSONObject): + """ + Options accepted for a single range when creating or updating the IPv6 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + range: Optional[str] = None + + +@dataclass +class LinodeInterfaceVPCIPv6Options(JSONObject): + """ + Options accepted when creating or updating the IPv6 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + is_public: Optional[bool] = None + slaac: Optional[List[LinodeInterfaceVPCIPv6SLAACOptions]] = None + ranges: Optional[List[LinodeInterfaceVPCIPv6RangeOptions]] = None + + +@dataclass +class LinodeInterfaceVPCOptions(JSONObject): + """ + VPC-exclusive options accepted when creating or updating a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + subnet_id: int = 0 + ipv4: Optional[LinodeInterfaceVPCIPv4Options] = None + ipv6: Optional[LinodeInterfaceVPCIPv6Options] = None + + +@dataclass +class LinodeInterfacePublicIPv4AddressOptions(JSONObject): + """ + Options accepted for a single address when creating or updating the IPv4 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + address: str = "" + primary: Optional[bool] = None + + +@dataclass +class LinodeInterfacePublicIPv4Options(JSONObject): + """ + Options accepted when creating or updating the IPv4 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + addresses: Optional[List[LinodeInterfacePublicIPv4AddressOptions]] = None + + +@dataclass +class LinodeInterfacePublicIPv6RangeOptions(JSONObject): + """ + Options accepted for a single range when creating or updating the IPv6 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + range: str = "" + + +@dataclass +class LinodeInterfacePublicIPv6Options(JSONObject): + """ + Options accepted when creating or updating the IPv6 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + ranges: Optional[List[LinodeInterfacePublicIPv6RangeOptions]] = None + + +@dataclass +class LinodeInterfacePublicOptions(JSONObject): + """ + Public-exclusive options accepted when creating or updating a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + ipv4: Optional[LinodeInterfacePublicIPv4Options] = None + ipv6: Optional[LinodeInterfacePublicIPv6Options] = None + + +@dataclass +class LinodeInterfaceVLANOptions(JSONObject): + """ + VLAN-exclusive options accepted when creating or updating a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + vlan_label: str = "" + ipam_address: Optional[str] = None + + +@dataclass +class LinodeInterfaceOptions(JSONObject): + """ + Options accepted when creating or updating a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + always_include = { + # If a default firewall_id isn't configured, the API requires that + # firewall_id is defined in the LinodeInterface POST body. + "firewall_id" + } + + firewall_id: Optional[int] = None + default_route: Optional[LinodeInterfaceDefaultRouteOptions] = None + vpc: Optional[LinodeInterfaceVPCOptions] = None + public: Optional[LinodeInterfacePublicOptions] = None + vlan: Optional[LinodeInterfaceVLANOptions] = None + + +# Interface GET Response + + +@dataclass +class LinodeInterfaceDefaultRoute(JSONObject): + """ + The default route configuration of a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfaceDefaultRouteOptions + + ipv4: bool = False + ipv6: bool = False + + +@dataclass +class LinodeInterfaceVPCIPv4Address(JSONObject): + """ + A single address under the IPv4 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfaceVPCIPv4AddressOptions + + address: str = "" + primary: bool = False + nat_1_1_address: Optional[str] = None + + +@dataclass +class LinodeInterfaceVPCIPv4Range(JSONObject): + """ + A single range under the IPv4 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfaceVPCIPv4RangeOptions + + range: str = "" + + +@dataclass +class LinodeInterfaceVPCIPv4(JSONObject): + """ + A single address under the IPv4 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfaceVPCIPv4Options + + addresses: List[LinodeInterfaceVPCIPv4Address] = field(default_factory=list) + ranges: List[LinodeInterfaceVPCIPv4Range] = field(default_factory=list) + + +@dataclass +class LinodeInterfaceVPCIPv6SLAAC(JSONObject): + """ + A single SLAAC entry under the IPv6 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + range: str = "" + address: str = "" + + +@dataclass +class LinodeInterfaceVPCIPv6Range(JSONObject): + """ + A single range under the IPv6 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + range: str = "" + + +@dataclass +class LinodeInterfaceVPCIPv6(JSONObject): + """ + A single address under the IPv6 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfaceVPCIPv6Options + + is_public: bool = False + slaac: List[LinodeInterfaceVPCIPv6SLAAC] = field(default_factory=list) + ranges: List[LinodeInterfaceVPCIPv6Range] = field(default_factory=list) + + +@dataclass +class LinodeInterfaceVPC(JSONObject): + """ + VPC-specific configuration field for a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfaceVPCOptions + + vpc_id: int = 0 + subnet_id: int = 0 + + ipv4: Optional[LinodeInterfaceVPCIPv4] = None + ipv6: Optional[LinodeInterfaceVPCIPv6] = None + + +@dataclass +class LinodeInterfacePublicIPv4Address(JSONObject): + """ + A single address under the IPv4 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfacePublicIPv4AddressOptions + + address: str = "" + primary: bool = False + + +@dataclass +class LinodeInterfacePublicIPv4Shared(JSONObject): + """ + A single shared address under the IPv4 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + address: str = "" + linode_id: int = 0 + + +@dataclass +class LinodeInterfacePublicIPv4(JSONObject): + """ + The IPv4 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfacePublicIPv4Options + + addresses: List[LinodeInterfacePublicIPv4Address] = field( + default_factory=list + ) + shared: List[LinodeInterfacePublicIPv4Shared] = field(default_factory=list) + + +@dataclass +class LinodeInterfacePublicIPv6SLAAC(JSONObject): + """ + A single SLAAC entry under the IPv6 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + address: str = "" + prefix: int = 0 + + +@dataclass +class LinodeInterfacePublicIPv6Shared(JSONObject): + """ + A single shared range under the IPv6 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + range: str = "" + route_target: Optional[str] = None + + +@dataclass +class LinodeInterfacePublicIPv6Range(JSONObject): + """ + A single range under the IPv6 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfacePublicIPv6RangeOptions + + range: str = "" + route_target: Optional[str] = None + + +@dataclass +class LinodeInterfacePublicIPv6(JSONObject): + """ + The IPv6 configuration of a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfacePublicIPv6Options + + slaac: List[LinodeInterfacePublicIPv6SLAAC] = field(default_factory=list) + shared: List[LinodeInterfacePublicIPv6Shared] = field(default_factory=list) + ranges: List[LinodeInterfacePublicIPv6Range] = field(default_factory=list) + + +@dataclass +class LinodeInterfacePublic(JSONObject): + """ + Public-specific configuration fields for a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfacePublicOptions + + ipv4: Optional[LinodeInterfacePublicIPv4] = None + ipv6: Optional[LinodeInterfacePublicIPv6] = None + + +@dataclass +class LinodeInterfaceVLAN(JSONObject): + """ + VLAN-specific configuration fields for a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfaceVLANOptions + + vlan_label: str = "" + ipam_address: Optional[str] = None + + +class LinodeInterface(DerivedBase): + """ + A Linode's network interface. + + NOTE: Linode interfaces may not currently be available to all users. + + NOTE: When using the ``save()`` method, certain local fields with computed values will + not be refreshed on the local object until after ``invalidate()`` has been called:: + + # Automatically assign an IPv4 address from the associated VPC Subnet + interface.vpc.ipv4.addresses[0].address = "auto" + + # Save the interface + interface.save() + + # Invalidate the interface + interface.invalidate() + + # Access the new address + print(interface.vpc.ipv4.addresses[0].address) + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-interface + """ + + api_endpoint = "/linode/instances/{linode_id}/interfaces/{id}" + derived_url_path = "interfaces" + parent_id_name = "linode_id" + + properties = { + "linode_id": Property(identifier=True), + "id": Property(identifier=True), + "mac_address": Property(), + "created": Property(is_datetime=True), + "updated": Property(is_datetime=True), + "version": Property(), + "default_route": Property( + mutable=True, + json_object=LinodeInterfaceDefaultRoute, + ), + "public": Property(mutable=True, json_object=LinodeInterfacePublic), + "vlan": Property(mutable=True, json_object=LinodeInterfaceVLAN), + "vpc": Property(mutable=True, json_object=LinodeInterfaceVPC), + } + + def firewalls(self, *filters) -> List[Firewall]: + """ + Retrieves a list of Firewalls for this Linode Interface. + Linode interfaces are not interchangeable with Config interfaces. + + NOTE: Linode interfaces may not currently be available to all users. + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A List of Firewalls for this Linode Interface. + :rtype: List[Firewall] + + NOTE: Caching is disabled on this method and each call will make + an additional Linode API request. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-interface-firewalls + """ + + return self._client._get_and_filter( + Firewall, + *filters, + endpoint="{}/firewalls".format(LinodeInterface.api_endpoint).format( + **vars(self) + ), + ) diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index 7889c9c07..aa506a606 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -2,17 +2,39 @@ from typing import Any, Dict, List, Optional, Union from urllib import parse +from linode_api4.common import Price, RegionPrice from linode_api4.errors import UnexpectedResponseError from linode_api4.objects import ( Base, DerivedBase, Instance, + InstanceDiskEncryptionType, JSONObject, MappedObject, Property, Region, Type, ) +from linode_api4.objects.base import _flatten_request_body_recursive +from linode_api4.util import drop_null_keys + + +class LKEType(Base): + """ + An LKEType represents the structure of a valid LKE type. + Currently the LKEType can only be retrieved by listing, i.e.: + types = client.lke.types() + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-lke-types + """ + + properties = { + "id": Property(identifier=True), + "label": Property(), + "price": Property(json_object=Price), + "region_prices": Property(json_object=RegionPrice), + "transfer": Property(), + } class KubeVersion(Base): @@ -29,6 +51,26 @@ class KubeVersion(Base): } +class TieredKubeVersion(DerivedBase): + """ + A TieredKubeVersion is a version of Kubernetes that is specific to a certain LKE tier. + + NOTE: LKE tiers may not currently be available to all users. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lke-version + """ + + api_endpoint = "/lke/tiers/{tier}/versions/{id}" + parent_id_name = "tier" + id_attribute = "id" + derived_url_path = "versions" + + properties = { + "id": Property(identifier=True), + "tier": Property(identifier=True), + } + + @dataclass class LKENodePoolTaint(JSONObject): """ @@ -36,6 +78,8 @@ class LKENodePoolTaint(JSONObject): applied to a node pool. """ + include_none_values = True + key: Optional[str] = None value: Optional[str] = None effect: Optional[str] = None @@ -58,8 +102,6 @@ class LKEClusterControlPlaneACLOptions(JSONObject): """ LKEClusterControlPlaneACLOptions is used to set the ACL configuration of an LKE cluster's control plane. - - NOTE: Control Plane ACLs may not currently be available to all users. """ enabled: Optional[bool] = None @@ -84,8 +126,10 @@ class LKEClusterControlPlaneACLAddresses(JSONObject): to access an LKE cluster's control plane. """ - ipv4: List[str] = None - ipv6: List[str] = None + include_none_values = True + + ipv4: Optional[List[str]] = None + ipv6: Optional[List[str]] = None @dataclass @@ -93,12 +137,12 @@ class LKEClusterControlPlaneACL(JSONObject): """ LKEClusterControlPlaneACL describes the ACL configuration of an LKE cluster's control plane. - - NOTE: Control Plane ACLs may not currently be available to all users. """ + include_none_values = True + enabled: bool = False - addresses: LKEClusterControlPlaneACLAddresses = None + addresses: Optional[LKEClusterControlPlaneACLAddresses] = None class LKENodePoolNode: @@ -132,6 +176,8 @@ class LKENodePool(DerivedBase): An LKE Node Pool describes a pool of Linode Instances that exist within an LKE Cluster. + NOTE: The k8s_version and update_strategy fields are only available for LKE Enterprise clusters. + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lke-node-pool """ @@ -142,6 +188,7 @@ class LKENodePool(DerivedBase): properties = { "id": Property(identifier=True), "cluster_id": Property(identifier=True), + "label": Property(mutable=True), "type": Property(slug_relationship=Type), "disks": Property(), "disk_encryption": Property(), @@ -153,6 +200,13 @@ class LKENodePool(DerivedBase): "tags": Property(mutable=True, unordered=True), "labels": Property(mutable=True), "taints": Property(mutable=True), + # Enterprise-specific properties + # Ideally we would use slug_relationship=TieredKubeVersion here, but + # it isn't possible without an extra request because the tier is not + # directly exposed in the node pool response. + "k8s_version": Property(mutable=True), + "update_strategy": Property(mutable=True), + "firewall_id": Property(mutable=True), } def _parse_raw_node( @@ -232,6 +286,8 @@ class LKECluster(Base): "k8s_version": Property(slug_relationship=KubeVersion, mutable=True), "pools": Property(derived_class=LKENodePool), "control_plane": Property(mutable=True), + "apl_enabled": Property(), + "tier": Property(), } def invalidate(self): @@ -311,9 +367,7 @@ def control_plane_acl(self) -> LKEClusterControlPlaneACL: """ Gets the ACL configuration of this cluster's control plane. - NOTE: Control Plane ACLs may not currently be available to all users. - - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lke-cluster-acl :returns: The cluster's control plane ACL configuration. :rtype: LKEClusterControlPlaneACL @@ -328,12 +382,50 @@ def control_plane_acl(self) -> LKEClusterControlPlaneACL: return LKEClusterControlPlaneACL.from_json(self._control_plane_acl) + @property + def apl_console_url(self) -> Optional[str]: + """ + Returns the URL of this cluster's APL installation if this cluster + is APL-enabled, else None. + + :returns: The URL of the APL console for this cluster. + :rtype: str or None + """ + + if not self.apl_enabled: + return None + + return f"https://console.lke{self.id}.akamai-apl.net" + + @property + def apl_health_check_url(self) -> Optional[str]: + """ + Returns the URL of this cluster's APL health check endpoint if this cluster + is APL-enabled, else None. + + :returns: The URL of the APL console for this cluster. + :rtype: str or None + """ + + if not self.apl_enabled: + return None + + return f"https://auth.lke{self.id}.akamai-apl.net/ready" + def node_pool_create( self, node_type: Union[Type, str], node_count: int, - labels: Dict[str, str] = None, + labels: Optional[Dict[str, str]] = None, taints: List[Union[LKENodePoolTaint, Dict[str, Any]]] = None, + k8s_version: Optional[ + Union[str, KubeVersion, TieredKubeVersion] + ] = None, + update_strategy: Optional[str] = None, + label: str = None, + disk_encryption: Optional[ + Union[str, InstanceDiskEncryptionType] + ] = None, **kwargs, ): """ @@ -348,28 +440,40 @@ def node_pool_create( :param labels: A dict mapping labels to their values to apply to this pool. :type labels: Dict[str, str] :param taints: A list of taints to apply to this pool. - :type taints: List of :any:`LKENodePoolTaint` or dict + :type taints: List of :any:`LKENodePoolTaint` or dict. + :param k8s_version: The Kubernetes version to use for this pool. + NOTE: This field is specific to enterprise clusters. + :type k8s_version: str, KubeVersion, or TieredKubeVersion + :param update_strategy: The strategy to use when updating this node pool. + NOTE: This field is specific to enterprise clusters. + :type update_strategy: str + :param disk_encryption: Local disk encryption setting for this LKE node pool. + One of 'enabled' or 'disabled'. Defaults to 'disabled'. + :type disk_encryption: str or InstanceDiskEncryptionType :param kwargs: Any other arguments to pass to the API. See the API docs for possible values. :returns: The new Node Pool + :param label: The name of the node pool. + :type label: str :rtype: LKENodePool """ params = { "type": node_type, + "label": label, "count": node_count, + "labels": labels, + "taints": taints, + "k8s_version": k8s_version, + "update_strategy": update_strategy, + "disk_encryption": disk_encryption, } - - if labels is not None: - params["labels"] = labels - - if taints is not None: - params["taints"] = taints - params.update(kwargs) result = self._client.post( - "{}/pools".format(LKECluster.api_endpoint), model=self, data=params + "{}/pools".format(LKECluster.api_endpoint), + model=self, + data=drop_null_keys(_flatten_request_body_recursive(params)), ) self.invalidate() @@ -502,9 +606,7 @@ def control_plane_acl_update( """ Updates the ACL configuration for this cluster's control plane. - NOTE: Control Plane ACLs may not currently be available to all users. - - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/put-lke-cluster-acl :param acl: The ACL configuration to apply to this cluster. :type acl: LKEClusterControlPlaneACLOptions or Dict[str, Any] @@ -518,7 +620,7 @@ def control_plane_acl_update( result = self._client.put( f"{LKECluster.api_endpoint}/control_plane_acl", model=self, - data={"acl": acl}, + data={"acl": drop_null_keys(acl)}, ) acl = result.get("acl") @@ -533,9 +635,7 @@ def control_plane_acl_delete(self): This has the same effect as calling control_plane_acl_update with the `enabled` field set to False. Access controls are disabled and all rules are deleted. - NOTE: Control Plane ACLs may not currently be available to all users. - - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/delete-lke-cluster-acl """ self._client.delete( f"{LKECluster.api_endpoint}/control_plane_acl", model=self diff --git a/linode_api4/objects/lock.py b/linode_api4/objects/lock.py new file mode 100644 index 000000000..9cee64517 --- /dev/null +++ b/linode_api4/objects/lock.py @@ -0,0 +1,47 @@ +from dataclasses import dataclass + +from linode_api4.objects.base import Base, Property +from linode_api4.objects.serializable import JSONObject, StrEnum + +__all__ = ["LockType", "LockEntity", "Lock"] + + +class LockType(StrEnum): + """ + LockType defines valid values for resource lock types. + + API Documentation: TBD + """ + + cannot_delete = "cannot_delete" + cannot_delete_with_subresources = "cannot_delete_with_subresources" + + +@dataclass +class LockEntity(JSONObject): + """ + Represents the entity that is locked. + + API Documentation: TBD + """ + + id: int = 0 + type: str = "" + label: str = "" + url: str = "" + + +class Lock(Base): + """ + A resource lock that prevents deletion or modification of a resource. + + API Documentation: TBD + """ + + api_endpoint = "/locks/{id}" + + properties = { + "id": Property(identifier=True), + "lock_type": Property(), + "entity": Property(json_object=LockEntity), + } diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py new file mode 100644 index 000000000..ca8f83921 --- /dev/null +++ b/linode_api4/objects/monitor.py @@ -0,0 +1,507 @@ +from dataclasses import dataclass, field +from typing import List, Optional, Union + +from linode_api4.objects import DerivedBase +from linode_api4.objects.base import Base, Property +from linode_api4.objects.serializable import JSONObject, StrEnum + +__all__ = [ + "AggregateFunction", + "Alert", + "AlertChannel", + "AlertDefinition", + "AlertType", + "Alerts", + "MonitorDashboard", + "MonitorMetricsDefinition", + "MonitorService", + "MonitorServiceToken", + "RuleCriteria", + "TriggerConditions", +] + + +class AggregateFunction(StrEnum): + """ + Enum for supported aggregate functions. + """ + + min = "min" + max = "max" + avg = "avg" + sum = "sum" + count = "count" + rate = "rate" + increase = "increase" + last = "last" + + +class ChartType(StrEnum): + """ + Enum for supported chart types. + """ + + line = "line" + area = "area" + + +class ServiceType(StrEnum): + """ + Enum for supported service types. + """ + + dbaas = "dbaas" + linode = "linode" + lke = "lke" + vpc = "vpc" + nodebalancer = "nodebalancer" + firewall = "firewall" + object_storage = "object_storage" + aclb = "aclb" + net_load_balancer = "netloadbalancer" + + +class MetricType(StrEnum): + """ + Enum for supported metric type + """ + + gauge = "gauge" + counter = "counter" + histogram = "histogram" + summary = "summary" + + +class CriteriaCondition(StrEnum): + """ + Enum for supported CriteriaCondition + Currently, only ALL is supported. + """ + + all = "ALL" + + +class MetricUnit(StrEnum): + """ + Enum for supported metric units. + """ + + COUNT = "count" + PERCENT = "percent" + BYTE = "byte" + SECOND = "second" + BITS_PER_SECOND = "bits_per_second" + MILLISECOND = "millisecond" + KB = "KB" + MB = "MB" + GB = "GB" + RATE = "rate" + BYTES_PER_SECOND = "bytes_per_second" + PERCENTILE = "percentile" + RATIO = "ratio" + OPS_PER_SECOND = "ops_per_second" + IOPS = "iops" + KILO_BYTES_PER_SECOND = "kilo_bytes_per_second" + SESSIONS_PER_SECOND = "sessions_per_second" + PACKETS_PER_SECOND = "packets_per_second" + KILO_BITS_PER_SECOND = "kilo_bits_per_second" + + +class DashboardType(StrEnum): + """ + Enum for supported dashboard types. + """ + + standard = "standard" + custom = "custom" + + +class AlertStatus(StrEnum): + """ + Enum for supported alert status values. + """ + + AlertDefinitionStatusProvisioning = "provisioning" + AlertDefinitionStatusEnabling = "enabling" + AlertDefinitionStatusDisabling = "disabling" + AlertDefinitionStatusEnabled = "enabled" + AlertDefinitionStatusDisabled = "disabled" + AlertDefinitionStatusFailed = "failed" + + +@dataclass +class Filter(JSONObject): + """ + Represents a filter in the filters list of a dashboard widget. + """ + + dimension_label: str = "" + operator: str = "" + value: str = "" + + +@dataclass +class DashboardWidget(JSONObject): + """ + Represents a single widget in the widgets list. + """ + + metric: str = "" + unit: MetricUnit = "" + label: str = "" + color: str = "" + size: int = 0 + chart_type: ChartType = "" + y_label: str = "" + aggregate_function: AggregateFunction = "" + group_by: Optional[List[str]] = None + _filters: Optional[List[Filter]] = field( + default=None, metadata={"json_key": "filters"} + ) + + def __getattribute__(self, name): + """Override to handle the filters attribute specifically to avoid metaclass conflict.""" + if name == "filters": + return object.__getattribute__(self, "_filters") + return object.__getattribute__(self, name) + + def __setattr__(self, name, value): + """Override to handle setting the filters attribute.""" + if name == "filters": + object.__setattr__(self, "_filters", value) + else: + object.__setattr__(self, name, value) + + +@dataclass +class ServiceAlert(JSONObject): + """ + Represents alert configuration options for a monitor service. + """ + + polling_interval_seconds: Optional[List[int]] = None + evaluation_period_seconds: Optional[List[int]] = None + scope: Optional[List[str]] = None + + +@dataclass +class Dimension(JSONObject): + """ + Represents a single dimension in the dimensions list. + """ + + dimension_label: Optional[str] = None + label: Optional[str] = None + values: Optional[List[str]] = None + + +@dataclass +class MonitorMetricsDefinition(JSONObject): + """ + Represents a single metric definition in the metrics definition list. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-monitor-information + """ + + metric: str = "" + label: str = "" + metric_type: MetricType = "" + unit: MetricUnit = "" + scrape_interval: int = 0 + is_alertable: bool = False + dimensions: Optional[List[Dimension]] = None + available_aggregate_functions: Optional[List[AggregateFunction]] = None + + +class MonitorDashboard(Base): + """ + Dashboard details. + + List dashboards: https://techdocs.akamai.com/linode-api/get-dashboards-all + """ + + api_endpoint = "/monitor/dashboards/{id}" + properties = { + "id": Property(identifier=True), + "created": Property(is_datetime=True), + "label": Property(), + "service_type": Property(ServiceType), + "type": Property(DashboardType), + "widgets": Property(json_object=DashboardWidget), + "updated": Property(is_datetime=True), + } + + +class MonitorService(Base): + """ + Represents a single service type. + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-monitor-services + + """ + + api_endpoint = "/monitor/services/{service_type}" + id_attribute = "service_type" + properties = { + "service_type": Property(ServiceType), + "label": Property(), + "alert": Property(json_object=ServiceAlert), + } + + +@dataclass +class MonitorServiceToken(JSONObject): + """ + A token for the requested service_type. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-get-token + """ + + token: str = "" + + +@dataclass +class TriggerConditions(JSONObject): + """ + Represents the trigger/evaluation configuration for an alert. + + Expected JSON example: + "trigger_conditions": { + "criteria_condition": "ALL", + "evaluation_period_seconds": 60, + "polling_interval_seconds": 10, + "trigger_occurrences": 3 + } + + Fields: + - criteria_condition: "ALL" (currently, only "ALL" is supported) + - evaluation_period_seconds: seconds over which the rule(s) are evaluated + - polling_interval_seconds: how often metrics are sampled (seconds) + - trigger_occurrences: how many consecutive evaluation periods must match to trigger + """ + + criteria_condition: CriteriaCondition = CriteriaCondition.all + evaluation_period_seconds: int = 0 + polling_interval_seconds: int = 0 + trigger_occurrences: int = 0 + + +@dataclass +class DimensionFilter(JSONObject): + """ + A single dimension filter used inside a Rule. + + Example JSON: + { + "dimension_label": "node_type", + "label": "Node Type", + "operator": "eq", + "value": "primary" + } + """ + + dimension_label: str = "" + label: str = "" + operator: str = "" + value: Optional[str] = None + + +@dataclass +class Rule(JSONObject): + """ + A single rule within RuleCriteria. + Example JSON: + { + "aggregate_function": "avg", + "dimension_filters": [ ... ], + "label": "Memory Usage", + "metric": "memory_usage", + "operator": "gt", + "threshold": 95, + "unit": "percent" + } + """ + + aggregate_function: Optional[Union[AggregateFunction, str]] = None + dimension_filters: Optional[List[DimensionFilter]] = None + label: str = "" + metric: str = "" + operator: str = "" + threshold: Optional[float] = None + unit: Optional[str] = None + + +@dataclass +class RuleCriteria(JSONObject): + """ + Container for a list of Rule objects, matching the JSON shape: + "rule_criteria": { "rules": [ { ... }, ... ] } + """ + + rules: Optional[List[Rule]] = None + + +@dataclass +class Alert(JSONObject): + """ + Represents an alert definition reference within an AlertChannel. + + Fields: + - id: int - Unique identifier of the alert definition. + - label: str - Human-readable name for the alert definition. + - type: str - Type of the alert (e.g., 'alerts-definitions'). + - url: str - API URL for the alert definition. + """ + + id: int = 0 + label: str = "" + _type: str = field(default="", metadata={"json_key": "type"}) + url: str = "" + + +@dataclass +class Alerts(JSONObject): + """ + Represents a collection of alert definitions within an AlertChannel. + + Fields: + - items: List[Alert] - List of alert definitions. + """ + + items: List[Alert] = field(default_factory=list) + + +class AlertType(StrEnum): + """ + Enumeration of alert origin types used by alert definitions. + + Values: + - system: Alerts that originate from the system (built-in or platform-managed). + - user: Alerts created and managed by users (custom alerts). + + The API uses this value in the `type` field of alert-definition responses. + This enum can be used to compare or validate the `type` value when + processing alert definitions. + """ + + system = "system" + user = "user" + + +class AlertDefinition(DerivedBase): + """ + Represents an alert definition for a monitor service. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-alert-definition + """ + + api_endpoint = "/monitor/services/{service_type}/alert-definitions/{id}" + derived_url_path = "alert-definitions" + parent_id_name = "service_type" + id_attribute = "id" + + properties = { + "id": Property(identifier=True), + "service_type": Property(identifier=True), + "label": Property(mutable=True), + "severity": Property(mutable=True), + "type": Property(mutable=True), + "status": Property(mutable=True), + "has_more_resources": Property(mutable=True), + "rule_criteria": Property(mutable=True, json_object=RuleCriteria), + "trigger_conditions": Property( + mutable=True, json_object=TriggerConditions + ), + "alert_channels": Property(mutable=True, json_object=Alerts), + "created": Property(is_datetime=True), + "updated": Property(is_datetime=True), + "updated_by": Property(), + "created_by": Property(), + "entity_ids": Property(mutable=True), + "description": Property(mutable=True), + "service_class": Property(alias_of="class"), + } + + +@dataclass +class EmailChannelContent(JSONObject): + """ + Represents the content for an email alert channel. + """ + + email_addresses: Optional[List[str]] = None + + +@dataclass +class ChannelContent(JSONObject): + """ + Represents the content block for an AlertChannel, which varies by channel type. + """ + + email: Optional[EmailChannelContent] = None + # Other channel types like 'webhook', 'slack' could be added here as Optional fields. + + +@dataclass +class EmailDetails(JSONObject): + """ + Represents email-specific details for an alert channel. + """ + + usernames: Optional[List[str]] = None + recipient_type: Optional[str] = None + + +@dataclass +class ChannelDetails(JSONObject): + """ + Represents the details block for an AlertChannel, which varies by channel type. + """ + + email: Optional[EmailDetails] = None + + +@dataclass +class AlertInfo(JSONObject): + """ + Represents a reference to alerts associated with an alert channel. + Fields: + - url: str - API URL to fetch the alerts for this channel + - type: str - Type identifier (e.g., 'alerts-definitions') + - alert_count: int - Number of alerts associated with this channel + """ + + url: str = "" + _type: str = field(default="", metadata={"json_key": "type"}) + alert_count: int = 0 + + +class AlertChannel(Base): + """ + Represents an alert channel used to deliver notifications when alerts + fire. Alert channels define a destination and configuration for + notifications (for example: email lists, webhooks, PagerDuty, Slack, etc.). + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-alert-channels + + This class maps to the Monitor API's `/monitor/alert-channels` resource + and is used by the SDK to list, load, and inspect channels. + + NOTE: Only read operations are supported for AlertChannel at this time. + Create, update, and delete (CRUD) operations are not allowed. + """ + + api_endpoint = "/monitor/alert-channels/{id}" + + properties = { + "id": Property(identifier=True), + "label": Property(), + "type": Property(), + "channel_type": Property(), + "details": Property(mutable=False, json_object=ChannelDetails), + "alerts": Property(mutable=False, json_object=AlertInfo), + "content": Property(mutable=False, json_object=ChannelContent), + "created": Property(is_datetime=True), + "updated": Property(is_datetime=True), + "created_by": Property(), + "updated_by": Property(), + } diff --git a/linode_api4/objects/monitor_api.py b/linode_api4/objects/monitor_api.py new file mode 100644 index 000000000..c3496668c --- /dev/null +++ b/linode_api4/objects/monitor_api.py @@ -0,0 +1,44 @@ +__all__ = [ + "EntityMetrics", + "EntityMetricsData", + "EntityMetricsDataResult", + "EntityMetricsStats", + "EntityMetricOptions", +] +from dataclasses import dataclass, field +from typing import List, Optional + +from linode_api4.objects.monitor import AggregateFunction +from linode_api4.objects.serializable import JSONObject + + +@dataclass +class EntityMetricsStats(JSONObject): + executionTimeMsec: int = 0 + seriesFetched: str = "" + + +@dataclass +class EntityMetricsDataResult(JSONObject): + metric: dict = field(default_factory=dict) + values: list = field(default_factory=list) + + +@dataclass +class EntityMetricsData(JSONObject): + result: Optional[List[EntityMetricsDataResult]] = None + resultType: str = "" + + +@dataclass +class EntityMetrics(JSONObject): + data: Optional[EntityMetricsData] = None + isPartial: bool = False + stats: Optional[EntityMetricsStats] = None + status: str = "" + + +@dataclass +class EntityMetricOptions(JSONObject): + name: str = "" + aggregate_function: AggregateFunction = "" diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index 993961098..ed975ab71 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -1,8 +1,12 @@ -from dataclasses import dataclass -from typing import Optional +from dataclasses import dataclass, field +from typing import List, Optional +from linode_api4.common import Price, RegionPrice from linode_api4.errors import UnexpectedResponseError -from linode_api4.objects import Base, DerivedBase, JSONObject, Property, Region +from linode_api4.objects.base import Base, Property +from linode_api4.objects.dbase import DerivedBase +from linode_api4.objects.region import Region +from linode_api4.objects.serializable import JSONObject class IPv6Pool(Base): @@ -83,6 +87,7 @@ class IPAddress(Base): "public": Property(), "rdns": Property(mutable=True), "linode_id": Property(), + "interface_id": Property(), "region": Property(slug_relationship=Region), "vpc_nat_1_1": Property(json_object=InstanceIPNAT1To1), } @@ -93,8 +98,36 @@ def linode(self): if not hasattr(self, "_linode"): self._set("_linode", Instance(self._client, self.linode_id)) + return self._linode + @property + def interface(self) -> Optional["LinodeInterface"]: + """ + Returns the Linode Interface associated with this IP address. + + NOTE: This function will only return Linode interfaces, not Config interfaces. + + NOTE: Linode interfaces may not currently be available to all users. + + :returns: The Linode Interface associated with this IP address. + :rtype: LinodeInterface + """ + + from .linode_interfaces import LinodeInterface # pylint: disable-all + + if self.interface_id in (None, 0): + self._set("_interface", None) + elif not hasattr(self, "_interface"): + self._set( + "_interface", + LinodeInterface( + self._client, self.linode_id, self.interface_id + ), + ) + + return self._interface + def to(self, linode): """ This is a helper method for ip-assign, and should not be used outside @@ -108,6 +141,25 @@ def to(self, linode): return {"address": self.address, "linode_id": linode.id} + def delete(self): + """ + Override the delete() function from Base to use the correct endpoint. + """ + resp = self._client.delete( + "/linode/instances/{}/ips/{}".format(self.linode_id, self.address), + model=self, + ) + + if "error" in resp: + return False + self.invalidate() + return True + + +@dataclass +class VPCIPAddressIPv6(JSONObject): + slaac_address: str = "" + @dataclass class VPCIPAddress(JSONObject): @@ -134,6 +186,10 @@ class VPCIPAddress(JSONObject): address_range: Optional[str] = None nat_1_1: Optional[str] = None + ipv6_range: Optional[str] = None + ipv6_is_public: Optional[bool] = None + ipv6_addresses: Optional[List[VPCIPAddressIPv6]] = None + class VLAN(Base): """ @@ -158,6 +214,53 @@ class VLAN(Base): } +@dataclass +class FirewallCreateDevicesOptions(JSONObject): + """ + Represents devices to create created alongside a Linode Firewall. + """ + + linodes: List[int] = field(default_factory=list) + nodebalancers: List[int] = field(default_factory=list) + linode_interfaces: List[int] = field(default_factory=list) + + +@dataclass +class FirewallSettingsDefaultFirewallIDs(JSONObject): + """ + Contains the IDs of Linode Firewalls that should be used by default + when creating various interface types. + + NOTE: This feature may not currently be available to all users. + """ + + include_none_values = True + + vpc_interface: Optional[int] = None + public_interface: Optional[int] = None + linode: Optional[int] = None + nodebalancer: Optional[int] = None + + +class FirewallSettings(Base): + """ + Represents the Firewall settings for the current user. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-firewall-settings + + NOTE: This feature may not currently be available to all users. + """ + + api_endpoint = "/networking/firewalls/settings" + + properties = { + "default_firewall_ids": Property( + json_object=FirewallSettingsDefaultFirewallIDs, + mutable=True, + ), + } + + class FirewallDevice(DerivedBase): """ An object representing the assignment between a Linode Firewall and another Linode resource. @@ -226,6 +329,37 @@ def get_rules(self): "{}/rules".format(self.api_endpoint), model=self ) + @property + def rule_versions(self): + """ + Gets the JSON rule versions for this Firewall. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-firewall-rule-versions + + :returns: Lists the current and historical rules of the firewall (that is not deleted), + using version. Whenever the rules update, the version increments from 1. + :rtype: dict + """ + return self._client.get( + "{}/history".format(self.api_endpoint), model=self + ) + + def get_rule_version(self, version): + """ + Gets the JSON for a specific rule version for this Firewall. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-firewall-rule-version + + :param version: The firewall rule version to view. + :type version: int + + :returns: Gets a specific firewall rule version for an enabled or disabled firewall. + :rtype: dict + """ + return self._client.get( + "{}/history/rules/{}".format(self.api_endpoint, version), model=self + ) + def device_create(self, id, type="linode", **kwargs): """ Creates and attaches a device to this Firewall @@ -256,3 +390,37 @@ def device_create(self, id, type="linode", **kwargs): c = FirewallDevice(self._client, result["id"], self.id, result) return c + + +class FirewallTemplate(Base): + """ + Represents a single Linode Firewall template. + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-firewall-template + + NOTE: This feature may not currently be available to all users. + """ + + api_endpoint = "/networking/firewalls/templates/{slug}" + + id_attribute = "slug" + + properties = {"slug": Property(identifier=True), "rules": Property()} + + +class NetworkTransferPrice(Base): + """ + An NetworkTransferPrice represents the structure of a valid network transfer price. + Currently the NetworkTransferPrice can only be retrieved by listing, i.e.: + types = client.networking.transfer_prices() + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-network-transfer-prices + """ + + properties = { + "id": Property(identifier=True), + "label": Property(), + "price": Property(json_object=Price), + "region_prices": Property(json_object=RegionPrice), + "transfer": Property(), + } diff --git a/linode_api4/objects/nodebalancer.py b/linode_api4/objects/nodebalancer.py index 2aeb6180c..f70553295 100644 --- a/linode_api4/objects/nodebalancer.py +++ b/linode_api4/objects/nodebalancer.py @@ -1,15 +1,30 @@ -import os +from pathlib import Path from urllib import parse +from linode_api4.common import Price, RegionPrice from linode_api4.errors import UnexpectedResponseError -from linode_api4.objects import ( - Base, - DerivedBase, - MappedObject, - Property, - Region, -) +from linode_api4.objects.base import Base, MappedObject, Property +from linode_api4.objects.dbase import DerivedBase from linode_api4.objects.networking import Firewall, IPAddress +from linode_api4.objects.region import Region + + +class NodeBalancerType(Base): + """ + An NodeBalancerType represents the structure of a valid NodeBalancer type. + Currently the NodeBalancerType can only be retrieved by listing, i.e.: + types = client.nodebalancers.types() + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-node-balancer-types + """ + + properties = { + "id": Property(identifier=True), + "label": Property(), + "price": Property(json_object=Price), + "region_prices": Property(json_object=RegionPrice), + "transfer": Property(), + } class NodeBalancerNode(DerivedBase): @@ -62,6 +77,8 @@ class NodeBalancerConfig(DerivedBase): The configuration information for a single port of this NodeBalancer. API documentation: https://techdocs.akamai.com/linode-api/reference/get-node-balancer-config + + NOTE: UDP NodeBalancer Configs may not currently be available to all users. """ api_endpoint = "/nodebalancers/{nodebalancer_id}/configs/{id}" @@ -82,6 +99,8 @@ class NodeBalancerConfig(DerivedBase): "check_path": Property(mutable=True), "check_body": Property(mutable=True), "check_passive": Property(mutable=True), + "udp_check_port": Property(mutable=True), + "udp_session_timeout": Property(), "ssl_cert": Property(mutable=True), "ssl_key": Property(mutable=True), "ssl_commonname": Property(), @@ -91,6 +110,20 @@ class NodeBalancerConfig(DerivedBase): "proxy_protocol": Property(mutable=True), } + def _serialize(self, is_put: bool = False): + """ + This override removes the `cipher_suite` field from the PUT request + body on calls to save(...) for UDP configs, which is rejected by + the API. + """ + + result = super()._serialize(is_put) + + if is_put and result["protocol"] == "udp" and "cipher_suite" in result: + del result["cipher_suite"] + + return result + @property def nodes(self): """ @@ -187,12 +220,14 @@ def load_ssl_data(self, cert_file, key_file): # we're disabling warnings here because these attributes are defined dynamically # through linode.objects.Base, and pylint isn't privy - if os.path.isfile(os.path.expanduser(cert_file)): - with open(os.path.expanduser(cert_file)) as f: + cert_path = Path(cert_file).expanduser() + if cert_path.is_file(): + with open(cert_path) as f: self.ssl_cert = f.read() - if os.path.isfile(os.path.expanduser(key_file)): - with open(os.path.expanduser(key_file)) as f: + key_path = Path(key_file).expanduser() + if key_path.is_file(): + with open(key_path) as f: self.ssl_key = f.read() @@ -218,6 +253,8 @@ class NodeBalancer(Base): "configs": Property(derived_class=NodeBalancerConfig), "transfer": Property(), "tags": Property(mutable=True, unordered=True), + "client_udp_sess_throttle": Property(mutable=True), + "locks": Property(unordered=True), } # create derived objects diff --git a/linode_api4/objects/object_storage.py b/linode_api4/objects/object_storage.py index f4ddfe9b5..a2e61405f 100644 --- a/linode_api4/objects/object_storage.py +++ b/linode_api4/objects/object_storage.py @@ -1,8 +1,10 @@ +from dataclasses import dataclass from typing import Optional from urllib import parse from deprecated import deprecated +from linode_api4.common import Price, RegionPrice from linode_api4.errors import UnexpectedResponseError from linode_api4.objects import ( Base, @@ -11,7 +13,7 @@ Property, Region, ) -from linode_api4.objects.serializable import StrEnum +from linode_api4.objects.serializable import JSONObject, StrEnum from linode_api4.util import drop_null_keys @@ -28,6 +30,55 @@ class ObjectStorageKeyPermission(StrEnum): READ_WRITE = "read_write" +class ObjectStorageEndpointType(StrEnum): + E0 = "E0" + E1 = "E1" + E2 = "E2" + E3 = "E3" + + +@dataclass +class ObjectStorageEndpoint(JSONObject): + """ + ObjectStorageEndpoint contains the core fields of an object storage endpoint object. + + NOTE: This is not implemented as a typical API object (Base) because Object Storage Endpoints + cannot be refreshed, as there is no singular GET endpoint. + """ + + region: str = "" + endpoint_type: ObjectStorageEndpointType = "" + s3_endpoint: Optional[str] = None + + +@dataclass +class ObjectStorageQuotaUsage(JSONObject): + """ + ObjectStorageQuotaUsage contains the fields of an object storage quota usage information. + """ + + quota_limit: int = 0 + usage: int = 0 + + +class ObjectStorageType(Base): + """ + An ObjectStorageType represents the structure of a valid Object Storage type. + Currently, the ObjectStorageType can only be retrieved by listing, i.e.: + types = client.object_storage.types() + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-types + """ + + properties = { + "id": Property(identifier=True), + "label": Property(), + "price": Property(json_object=Price), + "region_prices": Property(json_object=RegionPrice), + "transfer": Property(), + } + + class ObjectStorageBucket(DerivedBase): """ A bucket where objects are stored in. @@ -47,6 +98,8 @@ class ObjectStorageBucket(DerivedBase): "label": Property(identifier=True), "objects": Property(), "size": Property(), + "endpoint_type": Property(), + "s3_endpoint": Property(), } @classmethod @@ -63,13 +116,8 @@ def make_instance(cls, id, client, parent_id=None, json=None): Override this method to pass in the parent_id from the _raw_json object when it's available. """ - if json is None: - return None - - cluster_or_region = json.get("region") or json.get("cluster") - - if parent_id is None and cluster_or_region: - parent_id = cluster_or_region + if json is not None: + parent_id = parent_id or json.get("region") or json.get("cluster") if parent_id: return super().make(id, client, cls, parent_id=parent_id, json=json) @@ -78,6 +126,31 @@ def make_instance(cls, id, client, parent_id=None, json=None): "Unexpected json response when making a new Object Storage Bucket instance." ) + def access_get(self): + """ + Returns a result object which wraps the current access config for this ObjectStorageBucket. + + API Documentation: TODO + + :returns: A result object which wraps the access that this ObjectStorageBucket is currently configured with. + :rtype: MappedObject + """ + result = self._client.get( + "{}/access".format(self.api_endpoint), + model=self, + ) + + if not any( + key in result + for key in ["acl", "acl_xml", "cors_enabled", "cors_xml"] + ): + raise UnexpectedResponseError( + "Unexpected response when getting the bucket access config of a bucket!", + json=result, + ) + + return MappedObject(**result) + def access_modify( self, acl: Optional[ObjectStorageACL] = None, @@ -503,3 +576,41 @@ class ObjectStorageKeys(Base): "limited": Property(), "regions": Property(unordered=True), } + + +class ObjectStorageQuota(Base): + """ + An Object Storage related quota information on your account. + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-quota + """ + + api_endpoint = "/object-storage/quotas/{quota_id}" + id_attribute = "quota_id" + + properties = { + "quota_id": Property(identifier=True), + "quota_name": Property(), + "endpoint_type": Property(), + "s3_endpoint": Property(), + "description": Property(), + "quota_limit": Property(), + "resource_metric": Property(), + } + + def usage(self): + """ + Gets usage data for a specific ObjectStorage Quota resource you can have on your account and the current usage for that resource. + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-quota-usage + + :returns: The Object Storage Quota usage. + :rtype: ObjectStorageQuotaUsage + """ + + result = self._client.get( + f"{type(self).api_endpoint}/usage", + model=self, + ) + + return ObjectStorageQuotaUsage.from_json(result) diff --git a/linode_api4/objects/placement.py b/linode_api4/objects/placement.py index aa894af33..e436cf701 100644 --- a/linode_api4/objects/placement.py +++ b/linode_api4/objects/placement.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import List, Union +from typing import List, Optional, Union from linode_api4.objects.base import Base, Property from linode_api4.objects.linode import Instance @@ -34,6 +34,26 @@ class PlacementGroupMember(JSONObject): is_compliant: bool = False +@dataclass +class MigratedInstance(JSONObject): + """ + The ID for a compute instance being migrated into or out of the placement group. + """ + + linode_id: int = 0 + + +@dataclass +class PlacementGroupMigrations(JSONObject): + """ + Any compute instances that are being migrated to or from the placement group. + Returns an empty object if no migrations are taking place. + """ + + inbound: Optional[List[MigratedInstance]] = None + outbound: Optional[List[MigratedInstance]] = None + + class PlacementGroup(Base): """ NOTE: Placement Groups may not currently be available to all users. @@ -54,6 +74,7 @@ class PlacementGroup(Base): "placement_group_policy": Property(), "is_compliant": Property(), "members": Property(json_object=PlacementGroupMember), + "migrations": Property(json_object=PlacementGroupMigrations), } def assign( diff --git a/linode_api4/objects/region.py b/linode_api4/objects/region.py index 6d8178eff..9a77dc485 100644 --- a/linode_api4/objects/region.py +++ b/linode_api4/objects/region.py @@ -1,8 +1,70 @@ from dataclasses import dataclass -from typing import List +from typing import List, Optional from linode_api4.errors import UnexpectedResponseError from linode_api4.objects.base import Base, JSONObject, Property +from linode_api4.objects.serializable import StrEnum + + +class Capability(StrEnum): + """ + An enum class that represents the capabilities that Linode offers + across different regions and services. + + These capabilities indicate what services are available in each data center. + """ + + linodes = "Linodes" + nodebalancers = "NodeBalancers" + block_storage = "Block Storage" + object_storage = "Object Storage" + object_storage_regions = "Object Storage Access Key Regions" + object_storage_endpoint_types = "Object Storage Endpoint Types" + lke = "Kubernetes" + lke_ha_controlplanes = "LKE HA Control Planes" + lke_e = "Kubernetes Enterprise" + firewall = "Cloud Firewall" + gpu = "GPU Linodes" + vlans = "Vlans" + vpcs = "VPCs" + vpcs_extra = "VPCs Extra" + machine_images = "Machine Images" + dbaas = "Managed Databases" + dbaas_beta = "Managed Databases Beta" + bs_migrations = "Block Storage Migrations" + metadata = "Metadata" + premium_plans = "Premium Plans" + edge_plans = "Edge Plans" + distributed_plans = "Distributed Plans" + lke_control_plane_acl = "LKE Network Access Control List (IP ACL)" + aclb = "Akamai Cloud Load Balancer" + support_ticket_severity = "Support Ticket Severity" + backups = "Backups" + placement_group = "Placement Group" + disk_encryption = "Disk Encryption" + la_disk_encryption = "LA Disk Encryption" + akamai_ram_protection = "Akamai RAM Protection" + blockstorage_encryption = "Block Storage Encryption" + blockstorage_perf_b1 = "Block Storage Performance B1" + blockstorage_perf_b1_default = "Block Storage Performance B1 Default" + aclp = "Akamai Cloud Pulse" + aclp_logs = "Akamai Cloud Pulse Logs" + aclp_logs_lkee = "Akamai Cloud Pulse Logs LKE-E Audit" + aclp_logs_dc_lkee = "ACLP Logs Datacenter LKE-E" + smtp_enabled = "SMTP Enabled" + stackscripts = "StackScripts" + vpu = "NETINT Quadra T1U" + linode_interfaces = "Linode Interfaces" + maintenance_policy = "Maintenance Policy" + vpc_dual_stack = "VPC Dual Stack" + vpc_ipv6_stack = "VPC IPv6 Stack" + nlb = "Network LoadBalancer" + natgateway = "NAT Gateway" + lke_e_byovpc = "Kubernetes Enterprise BYO VPC" + lke_e_stacktype = "Kubernetes Enterprise Dual Stack" + ruleset = "Cloud Firewall Rule Set" + prefixlists = "Cloud Firewall Prefix Lists" + current_prefixlists = "Cloud Firewall Prefix List Current References" @dataclass @@ -16,6 +78,18 @@ class RegionPlacementGroupLimits(JSONObject): maximum_linodes_per_pg: int = 0 +@dataclass +class RegionMonitors(JSONObject): + """ + Represents the monitor services available in a region. + Lists the services in this region that support metrics and alerts + use with Akamai Cloud Pulse (ACLP). + """ + + alerts: Optional[list[str]] = None + metrics: Optional[list[str]] = None + + class Region(Base): """ A Region. Regions correspond to individual data centers, each located in a different geographical area. @@ -35,6 +109,7 @@ class Region(Base): "placement_group_limits": Property( json_object=RegionPlacementGroupLimits ), + "monitors": Property(json_object=RegionMonitors), } @property @@ -50,6 +125,29 @@ def availability(self) -> List["RegionAvailabilityEntry"]: return [RegionAvailabilityEntry.from_json(v) for v in result] + @property + def vpc_availability(self) -> "RegionVPCAvailability": + """ + Returns VPC availability data for this region. + + NOTE: IPv6 VPCs may not currently be available to all users. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-region-vpc-availability + + :returns: VPC availability data for this region. + :rtype: RegionVPCAvailability + """ + result = self._client.get( + f"{self.api_endpoint}/vpc-availability", model=self + ) + + if result is None: + raise UnexpectedResponseError( + "Expected VPC availability data, got None." + ) + + return RegionVPCAvailability.from_json(result) + @dataclass class RegionAvailabilityEntry(JSONObject): @@ -59,6 +157,21 @@ class RegionAvailabilityEntry(JSONObject): API Documentation: https://techdocs.akamai.com/linode-api/reference/get-region-availability """ - region: str = None - plan: str = None + region: Optional[str] = None + plan: Optional[str] = None + available: bool = False + + +@dataclass +class RegionVPCAvailability(JSONObject): + """ + Represents the VPC availability data for a region. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-regions-vpc-availability + + NOTE: IPv6 VPCs may not currently be available to all users. + """ + + region: Optional[str] = None available: bool = False + available_ipv6_prefix_lengths: Optional[List[int]] = None diff --git a/linode_api4/objects/serializable.py b/linode_api4/objects/serializable.py index b0e7a2503..c1f59f6d4 100644 --- a/linode_api4/objects/serializable.py +++ b/linode_api4/objects/serializable.py @@ -1,5 +1,5 @@ import inspect -from dataclasses import dataclass +from dataclasses import dataclass, fields from enum import Enum from types import SimpleNamespace from typing import ( @@ -9,6 +9,7 @@ List, Optional, Set, + Type, Union, get_args, get_origin, @@ -58,6 +59,12 @@ class JSONObject(metaclass=JSONFilterableMetaclass): ) """ + include_none_values: ClassVar[bool] = False + """ + If true, all None values for this class will be explicitly included in + the serialized output for instance of this class. + """ + always_include: ClassVar[Set[str]] = {} """ A set of keys corresponding to fields that should always be @@ -65,6 +72,13 @@ class JSONObject(metaclass=JSONFilterableMetaclass): are None. """ + put_class: ClassVar[Optional[Type["JSONObject"]]] = None + """ + An alternative JSONObject class to use as the schema for PUT requests. + This prevents read-only fields from being included in PUT request bodies, + which in theory will result in validation errors from the API. + """ + def __init__(self): raise NotImplementedError( "JSONObject is not intended to be constructed directly" @@ -134,7 +148,7 @@ def _parse_attr(cls, json_value: Any, field_type: type): @classmethod def from_json(cls, json: Dict[str, Any]) -> Optional["JSONObject"]: """ - Creates an instance of this class from a JSON dict. + Creates an instance of this class from a JSON dict, respecting json_key metadata. """ if json is None: return None @@ -143,16 +157,26 @@ def from_json(cls, json: Dict[str, Any]) -> Optional["JSONObject"]: type_hints = get_type_hints(cls) - for k in vars(obj): - setattr(obj, k, cls._parse_attr(json.get(k), type_hints.get(k))) + for f in fields(cls): + json_key = f.metadata.get("json_key", f.name) + field_type = type_hints.get(f.name) + value = json.get(json_key) + parsed_value = cls._parse_attr(value, field_type) + setattr(obj, f.name, parsed_value) return obj - def _serialize(self) -> Dict[str, Any]: + def _serialize(self, is_put: bool = False) -> Dict[str, Any]: """ Serializes this object into a JSON dict. """ cls = type(self) + + if is_put and cls.put_class is not None: + cls = cls.put_class + + cls_field_keys = {field.name for field in fields(cls)} + type_hints = get_type_hints(cls) def attempt_serialize(value: Any) -> Any: @@ -160,7 +184,17 @@ def attempt_serialize(value: Any) -> Any: Attempts to serialize the given value, else returns the value unchanged. """ if issubclass(type(value), JSONObject): - return value._serialize() + return value._serialize(is_put=is_put) + + # Needed to avoid circular imports without a breaking change + from linode_api4.objects.base import ( # pylint: disable=import-outside-toplevel + ExplicitNullValue, + ) + + if value == ExplicitNullValue or isinstance( + value, ExplicitNullValue + ): + return None return value @@ -169,7 +203,11 @@ def should_include(key: str, value: Any) -> bool: Returns whether the given key/value pair should be included in the resulting dict. """ - if key in cls.always_include: + # During PUT operations, keys not present in the put_class should be excluded + if key not in cls_field_keys: + return False + + if cls.include_none_values or key in cls.always_include: return True hint = type_hints.get(key) @@ -187,7 +225,11 @@ def should_include(key: str, value: Any) -> bool: result = {} - for k, v in vars(self).items(): + for f in fields(self): + k = f.name + json_key = f.metadata.get("json_key", k) + v = getattr(self, k) + if not should_include(k, v): continue @@ -198,7 +240,7 @@ def should_include(key: str, value: Any) -> bool: else: v = attempt_serialize(v) - result[k] = v + result[json_key] = v return result diff --git a/linode_api4/objects/support.py b/linode_api4/objects/support.py index f835b3f31..548f58f16 100644 --- a/linode_api4/objects/support.py +++ b/linode_api4/objects/support.py @@ -174,12 +174,9 @@ def upload_attachment(self, attachment: Union[Path, str]): files={"file": f}, ) - if not result.status_code == 200: - errors = [] - j = result.json() - if "errors" in j: - errors = [e["reason"] for e in j["errors"]] - raise ApiError("{}: {}".format(result.status_code, errors), json=j) + api_exc = ApiError.from_response(result) + if api_exc is not None: + raise api_exc return True diff --git a/linode_api4/objects/volume.py b/linode_api4/objects/volume.py index 6b126cc75..cda9932ab 100644 --- a/linode_api4/objects/volume.py +++ b/linode_api4/objects/volume.py @@ -1,5 +1,31 @@ +from linode_api4.common import Price, RegionPrice from linode_api4.errors import UnexpectedResponseError -from linode_api4.objects import Base, Instance, Property, Region +from linode_api4.objects.base import ( + Base, + Property, + _flatten_request_body_recursive, +) +from linode_api4.objects.linode import Instance, Region +from linode_api4.objects.region import Region +from linode_api4.util import drop_null_keys + + +class VolumeType(Base): + """ + An VolumeType represents the structure of a valid Volume type. + Currently the VolumeType can only be retrieved by listing, i.e.: + types = client.volumes.types() + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-volume-types + """ + + properties = { + "id": Property(identifier=True), + "label": Property(), + "price": Property(json_object=Price), + "region_prices": Property(json_object=RegionPrice), + "transfer": Property(), + } class Volume(Base): @@ -25,6 +51,7 @@ class Volume(Base): "filesystem_path": Property(), "hardware_type": Property(), "linode_label": Property(), + "encryption": Property(), } def attach(self, to_linode, config=None): @@ -41,21 +68,16 @@ def attach(self, to_linode, config=None): If not given, the last booted Config will be chosen. :type config: Union[Config, int] """ + + body = { + "linode_id": to_linode, + "config": config, + } + result = self._client.post( "{}/attach".format(Volume.api_endpoint), model=self, - data={ - "linode_id": ( - to_linode.id - if issubclass(type(to_linode), Base) - else to_linode - ), - "config": ( - None - if not config - else config.id if issubclass(type(config), Base) else config - ), - }, + data=_flatten_request_body_recursive(drop_null_keys(body)), ) if not "id" in result: diff --git a/linode_api4/objects/vpc.py b/linode_api4/objects/vpc.py index 456bdcfbc..4adecc2e3 100644 --- a/linode_api4/objects/vpc.py +++ b/linode_api4/objects/vpc.py @@ -1,22 +1,74 @@ from dataclasses import dataclass -from typing import List, Optional +from typing import Any, Dict, List, Optional, Union from linode_api4.errors import UnexpectedResponseError from linode_api4.objects import Base, DerivedBase, Property, Region +from linode_api4.objects.base import _flatten_request_body_recursive +from linode_api4.objects.networking import VPCIPAddress from linode_api4.objects.serializable import JSONObject from linode_api4.paginated_list import PaginatedList +from linode_api4.util import drop_null_keys + + +@dataclass +class VPCIPv6RangeOptions(JSONObject): + """ + VPCIPv6RangeOptions is used to specify an IPv6 range when creating or updating a VPC. + """ + + range: str = "" + allocation_class: Optional[str] = None + + +@dataclass +class VPCIPv6Range(JSONObject): + """ + VPCIPv6Range represents a single VPC IPv6 range. + """ + + put_class = VPCIPv6RangeOptions + + range: str = "" + + +@dataclass +class VPCSubnetIPv6RangeOptions(JSONObject): + """ + VPCSubnetIPv6RangeOptions is used to specify an IPv6 range when creating or updating a VPC subnet. + """ + + range: str = "" + + +@dataclass +class VPCSubnetIPv6Range(JSONObject): + """ + VPCSubnetIPv6Range represents a single VPC subnet IPv6 range. + """ + + put_class = VPCSubnetIPv6RangeOptions + + range: str = "" @dataclass class VPCSubnetLinodeInterface(JSONObject): id: int = 0 + config_id: Optional[int] = None active: bool = False @dataclass class VPCSubnetLinode(JSONObject): id: int = 0 - interfaces: List[VPCSubnetLinodeInterface] = None + interfaces: Optional[List[VPCSubnetLinodeInterface]] = None + + +@dataclass +class VPCSubnetDatabase(JSONObject): + id: int = 0 + ipv4_range: Optional[str] = None + ipv6_ranges: Optional[List[str]] = None class VPCSubnet(DerivedBase): @@ -34,7 +86,9 @@ class VPCSubnet(DerivedBase): "id": Property(identifier=True), "label": Property(mutable=True), "ipv4": Property(), + "ipv6": Property(json_object=VPCSubnetIPv6Range, unordered=True), "linodes": Property(json_object=VPCSubnetLinode, unordered=True), + "databases": Property(json_object=VPCSubnetDatabase, unordered=True), "created": Property(is_datetime=True), "updated": Property(is_datetime=True), } @@ -54,6 +108,7 @@ class VPC(Base): "label": Property(mutable=True), "description": Property(mutable=True), "region": Property(slug_relationship=Region), + "ipv6": Property(json_object=VPCIPv6Range, unordered=True), "subnets": Property(derived_class=VPCSubnet), "created": Property(is_datetime=True), "updated": Property(is_datetime=True), @@ -63,6 +118,9 @@ def subnet_create( self, label: str, ipv4: Optional[str] = None, + ipv6: Optional[ + List[Union[VPCSubnetIPv6RangeOptions, Dict[str, Any]]] + ] = None, **kwargs, ) -> VPCSubnet: """ @@ -75,19 +133,16 @@ def subnet_create( :param ipv4: The IPv4 range of this subnet in CIDR format. :type ipv4: str :param ipv6: The IPv6 range of this subnet in CIDR format. - :type ipv6: str + :type ipv6: List[Union[VPCSubnetIPv6RangeOptions, Dict[str, Any]]] """ - params = { - "label": label, - } - - if ipv4 is not None: - params["ipv4"] = ipv4 + params = {"label": label, "ipv4": ipv4, "ipv6": ipv6} params.update(kwargs) result = self._client.post( - "{}/subnets".format(VPC.api_endpoint), model=self, data=params + "{}/subnets".format(VPC.api_endpoint), + model=self, + data=drop_null_keys(_flatten_request_body_recursive(params)), ) self.invalidate() @@ -110,11 +165,6 @@ def ips(self) -> PaginatedList: :rtype: PaginatedList of VPCIPAddress """ - # need to avoid circular import - from linode_api4.objects import ( # pylint: disable=import-outside-toplevel - VPCIPAddress, - ) - return self._client._get_and_filter( VPCIPAddress, endpoint="/vpcs/{}/ips".format(self.id) ) diff --git a/linode_api4/polling.py b/linode_api4/polling.py index 947e59e47..7dc08d915 100644 --- a/linode_api4/polling.py +++ b/linode_api4/polling.py @@ -104,7 +104,7 @@ def __init__( client: "LinodeClient", entity_type: str, action: str, - entity_id: int = None, + entity_id: Optional[int] = None, ): self._client = client self._entity_type = entity_type diff --git a/linode_api4/util.py b/linode_api4/util.py index 1ddbcc25b..f661367af 100644 --- a/linode_api4/util.py +++ b/linode_api4/util.py @@ -2,6 +2,7 @@ Contains various utility functions. """ +import string from typing import Any, Dict @@ -27,3 +28,28 @@ def recursive_helper(value: Any) -> Any: return value return recursive_helper(data) + + +def generate_device_suffixes(n: int) -> list[str]: + """ + Generate n alphabetical suffixes starting with a, b, c, etc. + After z, continue with aa, ab, ac, etc. followed by aaa, aab, etc. + Example: + generate_device_suffixes(30) -> + ['a', 'b', 'c', ..., 'z', 'aa', 'ab', 'ac', 'ad'] + """ + letters = string.ascii_lowercase + result = [] + i = 0 + + while len(result) < n: + s = "" + x = i + while True: + s = letters[x % 26] + s + x = x // 26 - 1 + if x < 0: + break + result.append(s) + i += 1 + return result diff --git a/pyproject.toml b/pyproject.toml index 6720a965c..4d8542cfa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ name = "linode_api4" authors = [{ name = "Linode", email = "devs@linode.com" }] description = "The official Python SDK for Linode API v4" readme = "README.rst" -requires-python = ">=3.8" +requires-python = ">=3.9" keywords = [ "akamai", "Akamai Connected Cloud", @@ -25,7 +25,6 @@ classifiers = [ "License :: OSI Approved :: BSD License", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -53,6 +52,7 @@ dev = [ "sphinxcontrib-fulltoc>=1.2.0", "build>=0.10.0", "twine>=4.0.2", + "pytest-rerunfailures", ] doc = [ @@ -70,7 +70,7 @@ Repository = "https://github.com/linode/linode_api4-python.git" version = { attr = "linode_api4.version.__version__" } [tool.setuptools.packages.find] -exclude = ['contrib', 'docs', 'test', 'test.*'] +exclude = ['contrib', 'docs', 'build', 'build.*', 'test', 'test.*'] [tool.isort] profile = "black" @@ -88,3 +88,9 @@ in-place = true recursive = true remove-all-unused-imports = true remove-duplicate-keys = false + +[tool.pytest.ini_options] +markers = [ + "smoke: mark a test as a smoke test", + "flaky: mark a test as a flaky test for rerun" +] \ No newline at end of file diff --git a/test/fixtures/account.json b/test/fixtures/account.json index 1d823798b..001d7adad 100644 --- a/test/fixtures/account.json +++ b/test/fixtures/account.json @@ -16,7 +16,8 @@ "Linodes", "NodeBalancers", "Block Storage", - "Object Storage" + "Object Storage", + "Linode Interfaces" ], "active_promotions": [ { diff --git a/test/fixtures/account_events_123.json b/test/fixtures/account_events_123.json index 4c2b7141d..b24156f90 100644 --- a/test/fixtures/account_events_123.json +++ b/test/fixtures/account_events_123.json @@ -1,27 +1,31 @@ { - "action": "ticket_create", - "created": "2018-01-01T00:01:01", - "duration": 300.56, - "entity": { - "id": 11111, - "label": "Problem booting my Linode", - "type": "ticket", - "url": "/v4/support/tickets/11111" - }, - "id": 123, - "message": "None", - "percent_complete": null, - "rate": null, - "read": true, - "secondary_entity": { - "id": "linode/debian9", - "label": "linode1234", - "type": "linode", - "url": "/v4/linode/instances/1234" - }, - "seen": true, - "status": null, - "time_remaining": null, - "username": "exampleUser" - } - \ No newline at end of file + "action": "ticket_create", + "created": "2025-03-25T12:00:00", + "duration": 300.56, + "entity": { + "id": 11111, + "label": "Problem booting my Linode", + "type": "ticket", + "url": "/v4/support/tickets/11111" + }, + "id": 123, + "message": "Ticket created for user issue.", + "percent_complete": null, + "rate": null, + "read": true, + "secondary_entity": { + "id": "linode/debian9", + "label": "linode1234", + "type": "linode", + "url": "/v4/linode/instances/1234" + }, + "seen": true, + "status": "completed", + "username": "exampleUser", + "maintenance_policy_set": "Tentative", + "description": "Scheduled maintenance", + "source": "user", + "not_before": "2025-03-25T12:00:00", + "start_time": "2025-03-25T12:30:00", + "complete_time": "2025-03-25T13:00:00" +} \ No newline at end of file diff --git a/test/fixtures/account_maintenance.json b/test/fixtures/account_maintenance.json index aeeab91e6..30f8ed19e 100644 --- a/test/fixtures/account_maintenance.json +++ b/test/fixtures/account_maintenance.json @@ -1,19 +1,41 @@ { - "data": [ - { - "entity": { - "id": 123, - "label": "demo-linode", - "type": "Linode", - "url": "https://api.linode.com/v4/linode/instances/{linodeId}" - }, - "reason": "This maintenance will allow us to update the BIOS on the host's motherboard.", - "status": "started", - "type": "reboot", - "when": "2020-07-09T00:01:01" - } - ], - "page": 1, - "pages": 1, - "results": 1 -} \ No newline at end of file + "pages": 1, + "page": 1, + "results": 2, + "data": [ + { + "entity": { + "id": 1234, + "label": "Linode #1234", + "type": "linode", + "url": "/linodes/1234" + }, + "reason": "Scheduled upgrade to faster NVMe hardware.", + "type": "linode_migrate", + "maintenance_policy_set": "linode/power_off_on", + "description": "Scheduled Maintenance", + "source": "platform", + "not_before": "2025-03-25T10:00:00Z", + "start_time": "2025-03-25T12:00:00Z", + "complete_time": "2025-03-25T14:00:00Z", + "status": "scheduled" + }, + { + "entity": { + "id": 1234, + "label": "Linode #1234", + "type": "linode", + "url": "/linodes/1234" + }, + "reason": "Pending migration of Linode #1234 to a new host.", + "type": "linode_migrate", + "maintenance_policy_set": "linode/migrate", + "description": "Emergency Maintenance", + "source": "user", + "not_before": "2025-03-26T15:00:00Z", + "start_time": "2025-03-26T15:00:00Z", + "complete_time": "2025-03-26T17:00:00Z", + "status": "in-progress" + } + ] +} diff --git a/test/fixtures/account_settings.json b/test/fixtures/account_settings.json index 77a2fdac3..963c37306 100644 --- a/test/fixtures/account_settings.json +++ b/test/fixtures/account_settings.json @@ -3,5 +3,7 @@ "managed": false, "network_helper": false, "object_storage": "active", - "backups_enabled": true + "backups_enabled": true, + "interfaces_for_new_linodes": "linode_default_but_legacy_config_allowed", + "maintenance_policy": "linode/migrate" } diff --git a/test/fixtures/databases_instances.json b/test/fixtures/databases_instances.json index 3b3f4d602..d2e6f0cf9 100644 --- a/test/fixtures/databases_instances.json +++ b/test/fixtures/databases_instances.json @@ -11,7 +11,7 @@ "engine": "mysql", "hosts": { "primary": "lin-123-456-mysql-mysql-primary.servers.linodedb.net", - "secondary": "lin-123-456-mysql-primary-private.servers.linodedb.net" + "standby": "lin-123-456-mysql-primary-private.servers.linodedb.net" }, "id": 123, "instance_uri": "/v4/databases/mysql/instances/123", @@ -27,7 +27,12 @@ "hour_of_day": 0, "week_of_month": null }, - "version": "8.0.26" + "version": "8.0.26", + "private_network": { + "vpc_id": 1234, + "subnet_id": 5678, + "public_access": true + } } ], "page": 1, diff --git a/test/fixtures/databases_mysql_config.json b/test/fixtures/databases_mysql_config.json new file mode 100644 index 000000000..9cba0afd4 --- /dev/null +++ b/test/fixtures/databases_mysql_config.json @@ -0,0 +1,230 @@ +{ + "mysql": { + "connect_timeout": { + "description": "The number of seconds that the mysqld server waits for a connect packet before responding with Bad handshake", + "example": 10, + "maximum": 3600, + "minimum": 2, + "requires_restart": false, + "type": "integer" + }, + "default_time_zone": { + "description": "Default server time zone as an offset from UTC (from -12:00 to +12:00), a time zone name, or 'SYSTEM' to use the MySQL server default.", + "example": "+03:00", + "maxLength": 100, + "minLength": 2, + "pattern": "^([-+][\\d:]*|[\\w/]*)$", + "requires_restart": false, + "type": "string" + }, + "group_concat_max_len": { + "description": "The maximum permitted result length in bytes for the GROUP_CONCAT() function.", + "example": 1024, + "maximum": 18446744073709551600, + "minimum": 4, + "requires_restart": false, + "type": "integer" + }, + "information_schema_stats_expiry": { + "description": "The time, in seconds, before cached statistics expire", + "example": 86400, + "maximum": 31536000, + "minimum": 900, + "requires_restart": false, + "type": "integer" + }, + "innodb_change_buffer_max_size": { + "description": "Maximum size for the InnoDB change buffer, as a percentage of the total size of the buffer pool. Default is 25", + "example": 30, + "maximum": 50, + "minimum": 0, + "requires_restart": false, + "type": "integer" + }, + "innodb_flush_neighbors": { + "description": "Specifies whether flushing a page from the InnoDB buffer pool also flushes other dirty pages in the same extent (default is 1): 0 - dirty pages in the same extent are not flushed, 1 - flush contiguous dirty pages in the same extent, 2 - flush dirty pages in the same extent", + "example": 0, + "maximum": 2, + "minimum": 0, + "requires_restart": false, + "type": "integer" + }, + "innodb_ft_min_token_size": { + "description": "Minimum length of words that are stored in an InnoDB FULLTEXT index. Changing this parameter will lead to a restart of the MySQL service.", + "example": 3, + "maximum": 16, + "minimum": 0, + "requires_restart": true, + "type": "integer" + }, + "innodb_ft_server_stopword_table": { + "description": "This option is used to specify your own InnoDB FULLTEXT index stopword list for all InnoDB tables.", + "example": "db_name/table_name", + "maxLength": 1024, + "pattern": "^.+/.+$", + "requires_restart": false, + "type": [ + "null", + "string" + ] + }, + "innodb_lock_wait_timeout": { + "description": "The length of time in seconds an InnoDB transaction waits for a row lock before giving up. Default is 120.", + "example": 50, + "maximum": 3600, + "minimum": 1, + "requires_restart": false, + "type": "integer" + }, + "innodb_log_buffer_size": { + "description": "The size in bytes of the buffer that InnoDB uses to write to the log files on disk.", + "example": 16777216, + "maximum": 4294967295, + "minimum": 1048576, + "requires_restart": false, + "type": "integer" + }, + "innodb_online_alter_log_max_size": { + "description": "The upper limit in bytes on the size of the temporary log files used during online DDL operations for InnoDB tables.", + "example": 134217728, + "maximum": 1099511627776, + "minimum": 65536, + "requires_restart": false, + "type": "integer" + }, + "innodb_read_io_threads": { + "description": "The number of I/O threads for read operations in InnoDB. Default is 4. Changing this parameter will lead to a restart of the MySQL service.", + "example": 10, + "maximum": 64, + "minimum": 1, + "requires_restart": true, + "type": "integer" + }, + "innodb_rollback_on_timeout": { + "description": "When enabled a transaction timeout causes InnoDB to abort and roll back the entire transaction. Changing this parameter will lead to a restart of the MySQL service.", + "example": true, + "requires_restart": true, + "type": "boolean" + }, + "innodb_thread_concurrency": { + "description": "Defines the maximum number of threads permitted inside of InnoDB. Default is 0 (infinite concurrency - no limit)", + "example": 10, + "maximum": 1000, + "minimum": 0, + "requires_restart": false, + "type": "integer" + }, + "innodb_write_io_threads": { + "description": "The number of I/O threads for write operations in InnoDB. Default is 4. Changing this parameter will lead to a restart of the MySQL service.", + "example": 10, + "maximum": 64, + "minimum": 1, + "requires_restart": true, + "type": "integer" + }, + "interactive_timeout": { + "description": "The number of seconds the server waits for activity on an interactive connection before closing it.", + "example": 3600, + "maximum": 604800, + "minimum": 30, + "requires_restart": false, + "type": "integer" + }, + "internal_tmp_mem_storage_engine": { + "description": "The storage engine for in-memory internal temporary tables.", + "enum": [ + "TempTable", + "MEMORY" + ], + "example": "TempTable", + "requires_restart": false, + "type": "string" + }, + "max_allowed_packet": { + "description": "Size of the largest message in bytes that can be received by the server. Default is 67108864 (64M)", + "example": 67108864, + "maximum": 1073741824, + "minimum": 102400, + "requires_restart": false, + "type": "integer" + }, + "max_heap_table_size": { + "description": "Limits the size of internal in-memory tables. Also set tmp_table_size. Default is 16777216 (16M)", + "example": 16777216, + "maximum": 1073741824, + "minimum": 1048576, + "requires_restart": false, + "type": "integer" + }, + "net_buffer_length": { + "description": "Start sizes of connection buffer and result buffer. Default is 16384 (16K). Changing this parameter will lead to a restart of the MySQL service.", + "example": 16384, + "maximum": 1048576, + "minimum": 1024, + "requires_restart": true, + "type": "integer" + }, + "net_read_timeout": { + "description": "The number of seconds to wait for more data from a connection before aborting the read.", + "example": 30, + "maximum": 3600, + "minimum": 1, + "requires_restart": false, + "type": "integer" + }, + "net_write_timeout": { + "description": "The number of seconds to wait for a block to be written to a connection before aborting the write.", + "example": 30, + "maximum": 3600, + "minimum": 1, + "requires_restart": false, + "type": "integer" + }, + "sort_buffer_size": { + "description": "Sort buffer size in bytes for ORDER BY optimization. Default is 262144 (256K)", + "example": 262144, + "maximum": 1073741824, + "minimum": 32768, + "requires_restart": false, + "type": "integer" + }, + "sql_mode": { + "description": "Global SQL mode. Set to empty to use MySQL server defaults. When creating a new service and not setting this field Akamai default SQL mode (strict, SQL standard compliant) will be assigned.", + "example": "ANSI,TRADITIONAL", + "maxLength": 1024, + "pattern": "^[A-Z_]*(,[A-Z_]+)*$", + "requires_restart": false, + "type": "string" + }, + "sql_require_primary_key": { + "description": "Require primary key to be defined for new tables or old tables modified with ALTER TABLE and fail if missing. It is recommended to always have primary keys because various functionality may break if any large table is missing them.", + "example": true, + "requires_restart": false, + "type": "boolean" + }, + "tmp_table_size": { + "description": "Limits the size of internal in-memory tables. Also set max_heap_table_size. Default is 16777216 (16M)", + "example": 16777216, + "maximum": 1073741824, + "minimum": 1048576, + "requires_restart": false, + "type": "integer" + }, + "wait_timeout": { + "description": "The number of seconds the server waits for activity on a noninteractive connection before closing it.", + "example": 28800, + "maximum": 2147483, + "minimum": 1, + "requires_restart": false, + "type": "integer" + } + }, + "binlog_retention_period": { + "description": "The minimum amount of time in seconds to keep binlog entries before deletion. This may be extended for services that require binlog entries for longer than the default for example if using the MySQL Debezium Kafka connector.", + "example": 600, + "maximum": 86400, + "minimum": 600, + "requires_restart": false, + "type": "integer" + } +} \ No newline at end of file diff --git a/test/fixtures/databases_mysql_instances.json b/test/fixtures/databases_mysql_instances.json index 2ea73ddc2..c442b8345 100644 --- a/test/fixtures/databases_mysql_instances.json +++ b/test/fixtures/databases_mysql_instances.json @@ -11,7 +11,7 @@ "engine": "mysql", "hosts": { "primary": "lin-123-456-mysql-mysql-primary.servers.linodedb.net", - "secondary": "lin-123-456-mysql-primary-private.servers.linodedb.net" + "standby": "lin-123-456-mysql-primary-private.servers.linodedb.net" }, "id": 123, "label": "example-db", @@ -29,7 +29,44 @@ "hour_of_day": 0, "week_of_month": null }, - "version": "8.0.26" + "version": "8.0.26", + "engine_config": { + "binlog_retention_period": 600, + "mysql": { + "connect_timeout": 10, + "default_time_zone": "+03:00", + "group_concat_max_len": 1024, + "information_schema_stats_expiry": 86400, + "innodb_change_buffer_max_size": 30, + "innodb_flush_neighbors": 0, + "innodb_ft_min_token_size": 3, + "innodb_ft_server_stopword_table": "db_name/table_name", + "innodb_lock_wait_timeout": 50, + "innodb_log_buffer_size": 16777216, + "innodb_online_alter_log_max_size": 134217728, + "innodb_read_io_threads": 10, + "innodb_rollback_on_timeout": true, + "innodb_thread_concurrency": 10, + "innodb_write_io_threads": 10, + "interactive_timeout": 3600, + "internal_tmp_mem_storage_engine": "TempTable", + "max_allowed_packet": 67108864, + "max_heap_table_size": 16777216, + "net_buffer_length": 16384, + "net_read_timeout": 30, + "net_write_timeout": 30, + "sort_buffer_size": 262144, + "sql_mode": "ANSI,TRADITIONAL", + "sql_require_primary_key": true, + "tmp_table_size": 16777216, + "wait_timeout": 28800 + } + }, + "private_network": { + "vpc_id": 1234, + "subnet_id": 5678, + "public_access": true + } } ], "page": 1, diff --git a/test/fixtures/databases_mysql_instances_123_backups.json b/test/fixtures/databases_mysql_instances_123_backups.json deleted file mode 100644 index 671c68826..000000000 --- a/test/fixtures/databases_mysql_instances_123_backups.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "data": [ - { - "created": "2022-01-01T00:01:01", - "id": 456, - "label": "Scheduled - 02/04/22 11:11 UTC-XcCRmI", - "type": "auto" - } - ], - "page": 1, - "pages": 1, - "results": 1 -} \ No newline at end of file diff --git a/test/fixtures/databases_mysql_instances_123_backups_456_restore.json b/test/fixtures/databases_mysql_instances_123_resume.json similarity index 100% rename from test/fixtures/databases_mysql_instances_123_backups_456_restore.json rename to test/fixtures/databases_mysql_instances_123_resume.json diff --git a/test/fixtures/databases_postgresql_instances_123_backups_456_restore.json b/test/fixtures/databases_mysql_instances_123_suspend.json similarity index 100% rename from test/fixtures/databases_postgresql_instances_123_backups_456_restore.json rename to test/fixtures/databases_mysql_instances_123_suspend.json diff --git a/test/fixtures/databases_postgresql_config.json b/test/fixtures/databases_postgresql_config.json new file mode 100644 index 000000000..9a93d0aa9 --- /dev/null +++ b/test/fixtures/databases_postgresql_config.json @@ -0,0 +1,367 @@ +{ + "pg": { + "autovacuum_analyze_scale_factor": { + "description": "Specifies a fraction of the table size to add to autovacuum_analyze_threshold when deciding whether to trigger an ANALYZE. The default is 0.2 (20% of table size)", + "maximum": 1.0, + "minimum": 0.0, + "requires_restart": false, + "type": "number" + }, + "autovacuum_analyze_threshold": { + "description": "Specifies the minimum number of inserted, updated or deleted tuples needed to trigger an ANALYZE in any one table. The default is 50 tuples.", + "maximum": 2147483647, + "minimum": 0, + "requires_restart": false, + "type": "integer" + }, + "autovacuum_max_workers": { + "description": "Specifies the maximum number of autovacuum processes (other than the autovacuum launcher) that may be running at any one time. The default is three. This parameter can only be set at server start.", + "maximum": 20, + "minimum": 1, + "requires_restart": false, + "type": "integer" + }, + "autovacuum_naptime": { + "description": "Specifies the minimum delay between autovacuum runs on any given database. The delay is measured in seconds, and the default is one minute", + "maximum": 86400, + "minimum": 1, + "requires_restart": false, + "type": "integer" + }, + "autovacuum_vacuum_cost_delay": { + "description": "Specifies the cost delay value that will be used in automatic VACUUM operations. If -1 is specified, the regular vacuum_cost_delay value will be used. The default value is 20 milliseconds", + "maximum": 100, + "minimum": -1, + "requires_restart": false, + "type": "integer" + }, + "autovacuum_vacuum_cost_limit": { + "description": "Specifies the cost limit value that will be used in automatic VACUUM operations. If -1 is specified (which is the default), the regular vacuum_cost_limit value will be used.", + "maximum": 10000, + "minimum": -1, + "requires_restart": false, + "type": "integer" + }, + "autovacuum_vacuum_scale_factor": { + "description": "Specifies a fraction of the table size to add to autovacuum_vacuum_threshold when deciding whether to trigger a VACUUM. The default is 0.2 (20% of table size)", + "maximum": 1.0, + "minimum": 0.0, + "requires_restart": false, + "type": "number" + }, + "autovacuum_vacuum_threshold": { + "description": "Specifies the minimum number of updated or deleted tuples needed to trigger a VACUUM in any one table. The default is 50 tuples", + "maximum": 2147483647, + "minimum": 0, + "requires_restart": false, + "type": "integer" + }, + "bgwriter_delay": { + "description": "Specifies the delay between activity rounds for the background writer in milliseconds. Default is 200.", + "example": 200, + "maximum": 10000, + "minimum": 10, + "requires_restart": false, + "type": "integer" + }, + "bgwriter_flush_after": { + "description": "Whenever more than bgwriter_flush_after bytes have been written by the background writer, attempt to force the OS to issue these writes to the underlying storage. Specified in kilobytes, default is 512. Setting of 0 disables forced writeback.", + "example": 512, + "maximum": 2048, + "minimum": 0, + "requires_restart": false, + "type": "integer" + }, + "bgwriter_lru_maxpages": { + "description": "In each round, no more than this many buffers will be written by the background writer. Setting this to zero disables background writing. Default is 100.", + "example": 100, + "maximum": 1073741823, + "minimum": 0, + "requires_restart": false, + "type": "integer" + }, + "bgwriter_lru_multiplier": { + "description": "The average recent need for new buffers is multiplied by bgwriter_lru_multiplier to arrive at an estimate of the number that will be needed during the next round, (up to bgwriter_lru_maxpages). 1.0 represents a \u201cjust in time\u201d policy of writing exactly the number of buffers predicted to be needed. Larger values provide some cushion against spikes in demand, while smaller values intentionally leave writes to be done by server processes. The default is 2.0.", + "example": 2.0, + "maximum": 10, + "minimum": 0, + "requires_restart": false, + "type": "number" + }, + "deadlock_timeout": { + "description": "This is the amount of time, in milliseconds, to wait on a lock before checking to see if there is a deadlock condition.", + "example": 1000, + "maximum": 1800000, + "minimum": 500, + "requires_restart": false, + "type": "integer" + }, + "default_toast_compression": { + "description": "Specifies the default TOAST compression method for values of compressible columns (the default is lz4).", + "enum": [ + "lz4", + "pglz" + ], + "example": "lz4", + "requires_restart": false, + "type": "string" + }, + "idle_in_transaction_session_timeout": { + "description": "Time out sessions with open transactions after this number of milliseconds", + "maximum": 604800000, + "minimum": 0, + "requires_restart": false, + "type": "integer" + }, + "jit": { + "description": "Controls system-wide use of Just-in-Time Compilation (JIT).", + "example": true, + "requires_restart": false, + "type": "boolean" + }, + "max_files_per_process": { + "description": "PostgreSQL maximum number of files that can be open per process", + "maximum": 4096, + "minimum": 1000, + "requires_restart": false, + "type": "integer" + }, + "max_locks_per_transaction": { + "description": "PostgreSQL maximum locks per transaction", + "maximum": 6400, + "minimum": 64, + "requires_restart": false, + "type": "integer" + }, + "max_logical_replication_workers": { + "description": "PostgreSQL maximum logical replication workers (taken from the pool of max_parallel_workers)", + "maximum": 64, + "minimum": 4, + "requires_restart": false, + "type": "integer" + }, + "max_parallel_workers": { + "description": "Sets the maximum number of workers that the system can support for parallel queries", + "maximum": 96, + "minimum": 0, + "requires_restart": false, + "type": "integer" + }, + "max_parallel_workers_per_gather": { + "description": "Sets the maximum number of workers that can be started by a single Gather or Gather Merge node", + "maximum": 96, + "minimum": 0, + "requires_restart": false, + "type": "integer" + }, + "max_pred_locks_per_transaction": { + "description": "PostgreSQL maximum predicate locks per transaction", + "maximum": 5120, + "minimum": 64, + "requires_restart": false, + "type": "integer" + }, + "max_replication_slots": { + "description": "PostgreSQL maximum replication slots", + "maximum": 64, + "minimum": 8, + "requires_restart": false, + "type": "integer" + }, + "max_slot_wal_keep_size": { + "description": "PostgreSQL maximum WAL size (MB) reserved for replication slots. Default is -1 (unlimited). wal_keep_size minimum WAL size setting takes precedence over this.", + "maximum": 2147483647, + "minimum": -1, + "requires_restart": false, + "type": "integer" + }, + "max_stack_depth": { + "description": "Maximum depth of the stack in bytes", + "maximum": 6291456, + "minimum": 2097152, + "requires_restart": false, + "type": "integer" + }, + "max_standby_archive_delay": { + "description": "Max standby archive delay in milliseconds", + "maximum": 43200000, + "minimum": 1, + "requires_restart": false, + "type": "integer" + }, + "max_standby_streaming_delay": { + "description": "Max standby streaming delay in milliseconds", + "maximum": 43200000, + "minimum": 1, + "requires_restart": false, + "type": "integer" + }, + "max_wal_senders": { + "description": "PostgreSQL maximum WAL senders", + "maximum": 64, + "minimum": 20, + "requires_restart": false, + "type": "integer" + }, + "max_worker_processes": { + "description": "Sets the maximum number of background processes that the system can support", + "maximum": 96, + "minimum": 8, + "requires_restart": false, + "type": "integer" + }, + "password_encryption": { + "description": "Chooses the algorithm for encrypting passwords.", + "enum": [ + "md5", + "scram-sha-256" + ], + "example": "scram-sha-256", + "requires_restart": false, + "type": [ + "string", + "null" + ] + }, + "pg_partman_bgw.interval": { + "description": "Sets the time interval to run pg_partman's scheduled tasks", + "example": 3600, + "maximum": 604800, + "minimum": 3600, + "requires_restart": false, + "type": "integer" + }, + "pg_partman_bgw.role": { + "description": "Controls which role to use for pg_partman's scheduled background tasks.", + "example": "myrolename", + "maxLength": 64, + "pattern": "^[_A-Za-z0-9][-._A-Za-z0-9]{0,63}$", + "requires_restart": false, + "type": "string" + }, + "pg_stat_monitor.pgsm_enable_query_plan": { + "description": "Enables or disables query plan monitoring", + "example": false, + "requires_restart": false, + "type": "boolean" + }, + "pg_stat_monitor.pgsm_max_buckets": { + "description": "Sets the maximum number of buckets", + "example": 10, + "maximum": 10, + "minimum": 1, + "requires_restart": false, + "type": "integer" + }, + "pg_stat_statements.track": { + "description": "Controls which statements are counted. Specify top to track top-level statements (those issued directly by clients), all to also track nested statements (such as statements invoked within functions), or none to disable statement statistics collection. The default value is top.", + "enum": [ + "all", + "top", + "none" + ], + "requires_restart": false, + "type": [ + "string" + ] + }, + "temp_file_limit": { + "description": "PostgreSQL temporary file limit in KiB, -1 for unlimited", + "example": 5000000, + "maximum": 2147483647, + "minimum": -1, + "requires_restart": false, + "type": "integer" + }, + "timezone": { + "description": "PostgreSQL service timezone", + "example": "Europe/Helsinki", + "maxLength": 64, + "pattern": "^[\\w/]*$", + "requires_restart": false, + "type": "string" + }, + "track_activity_query_size": { + "description": "Specifies the number of bytes reserved to track the currently executing command for each active session.", + "example": 1024, + "maximum": 10240, + "minimum": 1024, + "requires_restart": false, + "type": "integer" + }, + "track_commit_timestamp": { + "description": "Record commit time of transactions.", + "enum": [ + "off", + "on" + ], + "example": "off", + "requires_restart": false, + "type": "string" + }, + "track_functions": { + "description": "Enables tracking of function call counts and time used.", + "enum": [ + "all", + "pl", + "none" + ], + "requires_restart": false, + "type": "string" + }, + "track_io_timing": { + "description": "Enables timing of database I/O calls. This parameter is off by default, because it will repeatedly query the operating system for the current time, which may cause significant overhead on some platforms.", + "enum": [ + "off", + "on" + ], + "example": "off", + "requires_restart": false, + "type": "string" + }, + "wal_sender_timeout": { + "description": "Terminate replication connections that are inactive for longer than this amount of time, in milliseconds. Setting this value to zero disables the timeout.", + "example": 60000, + "requires_restart": false, + "type": "integer" + }, + "wal_writer_delay": { + "description": "WAL flush interval in milliseconds. Note that setting this value to lower than the default 200ms may negatively impact performance", + "example": 50, + "maximum": 200, + "minimum": 10, + "requires_restart": false, + "type": "integer" + } + }, + "pg_stat_monitor_enable": { + "description": "Enable the pg_stat_monitor extension. Enabling this extension will cause the cluster to be restarted. When this extension is enabled, pg_stat_statements results for utility commands are unreliable", + "requires_restart": true, + "type": "boolean" + }, + "pglookout": { + "max_failover_replication_time_lag": { + "description": "Number of seconds of master unavailability before triggering database failover to standby", + "maximum": 9223372036854775000, + "minimum": 10, + "requires_restart": false, + "type": "integer" + } + }, + "shared_buffers_percentage": { + "description": "Percentage of total RAM that the database server uses for shared memory buffers. Valid range is 20-60 (float), which corresponds to 20% - 60%. This setting adjusts the shared_buffers configuration value.", + "example": 41.5, + "maximum": 60.0, + "minimum": 20.0, + "requires_restart": false, + "type": "number" + }, + "work_mem": { + "description": "Sets the maximum amount of memory to be used by a query operation (such as a sort or hash table) before writing to temporary disk files, in MB. Default is 1MB + 0.075% of total RAM (up to 32MB).", + "example": 4, + "maximum": 1024, + "minimum": 1, + "requires_restart": false, + "type": "integer" + } +} \ No newline at end of file diff --git a/test/fixtures/databases_postgresql_instances.json b/test/fixtures/databases_postgresql_instances.json index 2740b836d..7e22cbbc1 100644 --- a/test/fixtures/databases_postgresql_instances.json +++ b/test/fixtures/databases_postgresql_instances.json @@ -11,7 +11,7 @@ "engine": "postgresql", "hosts": { "primary": "lin-0000-000-pgsql-primary.servers.linodedb.net", - "secondary": "lin-0000-000-pgsql-primary-private.servers.linodedb.net" + "standby": "lin-0000-000-pgsql-primary-private.servers.linodedb.net" }, "id": 123, "label": "example-db", @@ -30,7 +30,65 @@ "hour_of_day": 0, "week_of_month": null }, - "version": "13.2" + "version": "13.2", + "engine_config": { + "pg": { + "autovacuum_analyze_scale_factor": 0.5, + "autovacuum_analyze_threshold": 100, + "autovacuum_max_workers": 10, + "autovacuum_naptime": 100, + "autovacuum_vacuum_cost_delay": 50, + "autovacuum_vacuum_cost_limit": 100, + "autovacuum_vacuum_scale_factor": 0.5, + "autovacuum_vacuum_threshold": 100, + "bgwriter_delay": 200, + "bgwriter_flush_after": 512, + "bgwriter_lru_maxpages": 100, + "bgwriter_lru_multiplier": 2.0, + "deadlock_timeout": 1000, + "default_toast_compression": "lz4", + "idle_in_transaction_session_timeout": 100, + "jit": true, + "max_files_per_process": 100, + "max_locks_per_transaction": 100, + "max_logical_replication_workers": 32, + "max_parallel_workers": 64, + "max_parallel_workers_per_gather": 64, + "max_pred_locks_per_transaction": 1000, + "max_replication_slots": 32, + "max_slot_wal_keep_size": 100, + "max_stack_depth": 3507152, + "max_standby_archive_delay": 1000, + "max_standby_streaming_delay": 1000, + "max_wal_senders": 32, + "max_worker_processes": 64, + "password_encryption": "scram-sha-256", + "pg_partman_bgw.interval": 3600, + "pg_partman_bgw.role": "myrolename", + "pg_stat_monitor.pgsm_enable_query_plan": false, + "pg_stat_monitor.pgsm_max_buckets": 10, + "pg_stat_statements.track": "top", + "temp_file_limit": 5000000, + "timezone": "Europe/Helsinki", + "track_activity_query_size": 1024, + "track_commit_timestamp": "off", + "track_functions": "all", + "track_io_timing": "off", + "wal_sender_timeout": 60000, + "wal_writer_delay": 50 + }, + "pg_stat_monitor_enable": true, + "pglookout": { + "max_failover_replication_time_lag": 1000 + }, + "shared_buffers_percentage": 41.5, + "work_mem": 4 + }, + "private_network": { + "vpc_id": 1234, + "subnet_id": 5678, + "public_access": true + } } ], "page": 1, diff --git a/test/fixtures/databases_postgresql_instances_123_backups.json b/test/fixtures/databases_postgresql_instances_123_backups.json deleted file mode 100644 index 671c68826..000000000 --- a/test/fixtures/databases_postgresql_instances_123_backups.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "data": [ - { - "created": "2022-01-01T00:01:01", - "id": 456, - "label": "Scheduled - 02/04/22 11:11 UTC-XcCRmI", - "type": "auto" - } - ], - "page": 1, - "pages": 1, - "results": 1 -} \ No newline at end of file diff --git a/test/fixtures/databases_postgresql_instances_123_resume.json b/test/fixtures/databases_postgresql_instances_123_resume.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/test/fixtures/databases_postgresql_instances_123_resume.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test/fixtures/databases_postgresql_instances_123_suspend.json b/test/fixtures/databases_postgresql_instances_123_suspend.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/test/fixtures/databases_postgresql_instances_123_suspend.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test/fixtures/images.json b/test/fixtures/images.json index 357110bc7..37b31445f 100644 --- a/test/fixtures/images.json +++ b/test/fixtures/images.json @@ -26,7 +26,9 @@ "region": "us-east", "status": "available" } - ] + ], + "is_shared": false, + "image_sharing": null }, { "created": "2017-01-01T00:01:01", @@ -55,7 +57,9 @@ "region": "us-mia", "status": "pending" } - ] + ], + "is_shared": false, + "image_sharing": null }, { "created": "2017-01-01T00:01:01", @@ -72,7 +76,9 @@ "eol": "2026-07-01T04:00:00", "expiry": "2026-08-01T04:00:00", "updated": "2020-07-01T04:00:00", - "capabilities": [] + "capabilities": [], + "is_shared": false, + "image_sharing": null }, { "created": "2017-08-20T14:01:01", @@ -89,7 +95,15 @@ "eol": "2026-07-01T04:00:00", "expiry": "2026-08-01T04:00:00", "updated": "2020-07-01T04:00:00", - "capabilities": ["cloud-init"] + "capabilities": ["cloud-init"], + "is_shared": false, + "image_sharing": { + "shared_by": null, + "shared_with": { + "sharegroup_count": 0, + "sharegroup_list_url": "/images/private/123/sharegroups" + } + } } ] } \ No newline at end of file diff --git a/test/fixtures/images_private_1234_sharegroups.json b/test/fixtures/images_private_1234_sharegroups.json new file mode 100644 index 000000000..925b12627 --- /dev/null +++ b/test/fixtures/images_private_1234_sharegroups.json @@ -0,0 +1,19 @@ +{ + "data": [ + { + "created": "2025-04-14T22:44:02", + "description": "My group of images to share with my team.", + "expiry": null, + "id": 1, + "images_count": 1, + "is_suspended": false, + "label": "My Shared Images", + "members_count": 0, + "updated": null, + "uuid": "1533863e-16a4-47b5-b829-ac0f35c13278" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/images_sharegroups.json b/test/fixtures/images_sharegroups.json new file mode 100644 index 000000000..53b54c07a --- /dev/null +++ b/test/fixtures/images_sharegroups.json @@ -0,0 +1,31 @@ +{ + "data": [ + { + "created": "2025-04-14T22:44:02", + "description": "My group of images to share with my team.", + "expiry": null, + "id": 1, + "images_count": 0, + "is_suspended": false, + "label": "My Shared Images", + "members_count": 0, + "updated": null, + "uuid": "1533863e-16a4-47b5-b829-ac0f35c13278" + }, + { + "created": "2025-04-14T22:44:03", + "description": "My other group of images to share with my team.", + "expiry": null, + "id": 2, + "images_count": 1, + "is_suspended": false, + "label": "My other Shared Images", + "members_count": 3, + "updated": null, + "uuid": "30ee6599-eb0f-478c-9e55-4073c6c24a39" + } + ], + "page": 1, + "pages": 1, + "results": 2 +} diff --git a/test/fixtures/images_sharegroups_1234.json b/test/fixtures/images_sharegroups_1234.json new file mode 100644 index 000000000..9817ea3d9 --- /dev/null +++ b/test/fixtures/images_sharegroups_1234.json @@ -0,0 +1,12 @@ +{ + "created": "2025-04-14T22:44:02", + "description": "My group of images to share with my team.", + "expiry": null, + "id": 1234, + "images_count": 0, + "is_suspended": false, + "label": "My Shared Images", + "members_count": 0, + "updated": null, + "uuid": "1533863e-16a4-47b5-b829-ac0f35c13278" +} \ No newline at end of file diff --git a/test/fixtures/images_sharegroups_1234_images.json b/test/fixtures/images_sharegroups_1234_images.json new file mode 100644 index 000000000..f63e52392 --- /dev/null +++ b/test/fixtures/images_sharegroups_1234_images.json @@ -0,0 +1,45 @@ +{ + "data": [ + { + "capabilities": [ + "cloud-init", + "distributed-sites" + ], + "created": "2021-08-14T22:44:02", + "created_by": null, + "deprecated": false, + "description": "Example image description.", + "eol": "2026-07-01T04:00:00", + "expiry": null, + "id": "shared/1", + "is_public": true, + "is_shared": null, + "label": "Debian 11", + "regions": [ + { + "region": "us-iad", + "status": "available" + } + ], + "size": 2500, + "status": "available", + "tags": [ + "repair-image", + "fix-1" + ], + "total_size": 1234567, + "type": "manual", + "updated": "2021-08-14T22:44:02", + "vendor": null, + "image_sharing": { + "shared_with": null, + "shared_by": { + "sharegroup_id": 1234, + "sharegroup_uuid": "0ee8e1c1-b19b-4052-9487-e3b13faac111", + "sharegroup_label": "test-group-minecraft-1", + "source_image_id": null + } + } + } + ] +} \ No newline at end of file diff --git a/test/fixtures/images_sharegroups_1234_images_shared_1.json b/test/fixtures/images_sharegroups_1234_images_shared_1.json new file mode 100644 index 000000000..1b1179c93 --- /dev/null +++ b/test/fixtures/images_sharegroups_1234_images_shared_1.json @@ -0,0 +1,41 @@ +{ + "capabilities": [ + "cloud-init", + "distributed-sites" + ], + "created": "2021-08-14T22:44:02", + "created_by": null, + "deprecated": false, + "description": "Example image description.", + "eol": "2026-07-01T04:00:00", + "expiry": null, + "id": "shared/1", + "is_public": true, + "is_shared": null, + "label": "Debian 11", + "regions": [ + { + "region": "us-iad", + "status": "available" + } + ], + "size": 2500, + "status": "available", + "tags": [ + "repair-image", + "fix-1" + ], + "total_size": 1234567, + "type": "manual", + "updated": "2021-08-14T22:44:02", + "vendor": null, + "image_sharing": { + "shared_with": null, + "shared_by": { + "sharegroup_id": 1234, + "sharegroup_uuid": "0ee8e1c1-b19b-4052-9487-e3b13faac111", + "sharegroup_label": "test-group-minecraft-1", + "source_image_id": null + } + } +} diff --git a/test/fixtures/images_sharegroups_1234_members.json b/test/fixtures/images_sharegroups_1234_members.json new file mode 100644 index 000000000..424f8b23c --- /dev/null +++ b/test/fixtures/images_sharegroups_1234_members.json @@ -0,0 +1,15 @@ +{ + "data": [ + { + "created": "2025-08-04T10:07:59", + "expiry": null, + "label": "New Member", + "status": "active", + "token_uuid": "4591075e-4ba8-43c9-a521-928c3d4a135d", + "updated": null + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/fixtures/images_sharegroups_1234_members_abc123.json b/test/fixtures/images_sharegroups_1234_members_abc123.json new file mode 100644 index 000000000..156458ccc --- /dev/null +++ b/test/fixtures/images_sharegroups_1234_members_abc123.json @@ -0,0 +1,8 @@ +{ + "created": "2025-08-04T10:07:59", + "expiry": null, + "label": "New Member", + "status": "active", + "token_uuid": "abc123", + "updated": null +} \ No newline at end of file diff --git a/test/fixtures/images_sharegroups_tokens.json b/test/fixtures/images_sharegroups_tokens.json new file mode 100644 index 000000000..916ae8ae6 --- /dev/null +++ b/test/fixtures/images_sharegroups_tokens.json @@ -0,0 +1,18 @@ +{ + "data": [ + { + "created": "2025-08-04T10:09:09", + "expiry": null, + "label": "My Sharegroup Token", + "sharegroup_label": "A Sharegroup", + "sharegroup_uuid": "e1d0e58b-f89f-4237-84ab-b82077342359", + "status": "active", + "token_uuid": "13428362-5458-4dad-b14b-8d0d4d648f8c", + "updated": null, + "valid_for_sharegroup_uuid": "e1d0e58b-f89f-4237-84ab-b82077342359" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/images_sharegroups_tokens_abc123.json b/test/fixtures/images_sharegroups_tokens_abc123.json new file mode 100644 index 000000000..d7d4d045d --- /dev/null +++ b/test/fixtures/images_sharegroups_tokens_abc123.json @@ -0,0 +1,12 @@ +{ + "created": "2025-08-04T10:09:09", + "expiry": null, + "label": "My Sharegroup Token", + "sharegroup_label": "A Sharegroup", + "sharegroup_uuid": "e1d0e58b-f89f-4237-84ab-b82077342359", + "status": "active", + "token_uuid": "abc123", + "updated": null, + "valid_for_sharegroup_uuid": "e1d0e58b-f89f-4237-84ab-b82077342359", + "token": "asupersecrettoken" +} \ No newline at end of file diff --git a/test/fixtures/images_sharegroups_tokens_abc123_sharegroup.json b/test/fixtures/images_sharegroups_tokens_abc123_sharegroup.json new file mode 100644 index 000000000..2dfd5e928 --- /dev/null +++ b/test/fixtures/images_sharegroups_tokens_abc123_sharegroup.json @@ -0,0 +1,9 @@ +{ + "created": "2025-04-14T22:44:02", + "description": "Group of base operating system images and engineers used for CI/CD pipelines and infrastructure automation", + "id": 1234, + "is_suspended": false, + "label": "DevOps Base Images", + "updated": null, + "uuid": "1533863e-16a4-47b5-b829-ac0f35c13278" +} \ No newline at end of file diff --git a/test/fixtures/images_sharegroups_tokens_abc123_sharegroup_images.json b/test/fixtures/images_sharegroups_tokens_abc123_sharegroup_images.json new file mode 100644 index 000000000..f63e52392 --- /dev/null +++ b/test/fixtures/images_sharegroups_tokens_abc123_sharegroup_images.json @@ -0,0 +1,45 @@ +{ + "data": [ + { + "capabilities": [ + "cloud-init", + "distributed-sites" + ], + "created": "2021-08-14T22:44:02", + "created_by": null, + "deprecated": false, + "description": "Example image description.", + "eol": "2026-07-01T04:00:00", + "expiry": null, + "id": "shared/1", + "is_public": true, + "is_shared": null, + "label": "Debian 11", + "regions": [ + { + "region": "us-iad", + "status": "available" + } + ], + "size": 2500, + "status": "available", + "tags": [ + "repair-image", + "fix-1" + ], + "total_size": 1234567, + "type": "manual", + "updated": "2021-08-14T22:44:02", + "vendor": null, + "image_sharing": { + "shared_with": null, + "shared_by": { + "sharegroup_id": 1234, + "sharegroup_uuid": "0ee8e1c1-b19b-4052-9487-e3b13faac111", + "sharegroup_label": "test-group-minecraft-1", + "source_image_id": null + } + } + } + ] +} \ No newline at end of file diff --git a/test/fixtures/linode_instances.json b/test/fixtures/linode_instances.json index 38a3cf912..08cbe80c8 100644 --- a/test/fixtures/linode_instances.json +++ b/test/fixtures/linode_instances.json @@ -38,7 +38,9 @@ ], "updated": "2017-01-01T00:00:00", "image": "linode/ubuntu17.04", - "tags": ["something"], + "tags": [ + "something" + ], "host_uuid": "3a3ddd59d9a78bb8de041391075df44de62bfec8", "watchdog_enabled": true, "disk_encryption": "disabled", @@ -48,7 +50,8 @@ "label": "test", "placement_group_type": "anti_affinity:local", "placement_group_policy": "strict" - } + }, + "maintenance_policy" : "linode/migrate" }, { "group": "test", @@ -91,6 +94,52 @@ "disk_encryption": "enabled", "lke_cluster_id": 18881, "placement_group": null + }, + { + "id": 124, + "status": "running", + "type": "g6-standard-1", + "alerts": { + "network_in": 5, + "network_out": 5, + "cpu": 90, + "transfer_quota": 80, + "io": 5000 + }, + "group": "test", + "hypervisor": "kvm", + "label": "linode124", + "backups": { + "enabled": true, + "schedule": { + "window": "W02", + "day": "Scheduling" + } + }, + "specs": { + "memory": 2048, + "disk": 30720, + "vcpus": 1, + "transfer": 2000 + }, + "ipv6": "1235:abcd::1234:abcd:89ef:67cd/64", + "created": "2017-01-01T00:00:00", + "region": "us-east-1", + "ipv4": [ + "124.45.67.89" + ], + "updated": "2017-01-01T00:00:00", + "image": "linode/ubuntu24.04", + "tags": [ + "something" + ], + "host_uuid": "3b3ddd59d9a78bb8de041391075df44de62bfec8", + "watchdog_enabled": true, + "disk_encryption": "disabled", + "lke_cluster_id": null, + "placement_group": null, + "interface_generation": "linode", + "maintenance_policy" : "linode/power_off_on" } ] } diff --git a/test/fixtures/linode_instances_123_configs.json b/test/fixtures/linode_instances_123_configs.json index 581b84caa..082f8eefd 100644 --- a/test/fixtures/linode_instances_123_configs.json +++ b/test/fixtures/linode_instances_123_configs.json @@ -16,31 +16,45 @@ "id": 456789, "interfaces": [ { - "id": 456, - "purpose": "public", - "primary": true + "id": 456, + "purpose": "public", + "primary": true }, { - "id": 123, - "purpose": "vpc", - "primary": true, - "active": true, - "vpc_id": 123456, - "subnet_id": 789, - "ipv4": { - "vpc": "10.0.0.2", - "nat_1_1": "any" - }, - "ip_ranges": [ - "10.0.0.0/24" - ] + "id": 123, + "purpose": "vpc", + "primary": true, + "active": true, + "vpc_id": 123456, + "subnet_id": 789, + "ipv4": { + "vpc": "10.0.0.2", + "nat_1_1": "any" + }, + "ipv6": { + "slaac": [ + { + "range": "1234::5678/64", + "address": "1234::5678" + } + ], + "ranges": [ + { + "range": "1234::5678/64" + } + ], + "is_public": true + }, + "ip_ranges": [ + "10.0.0.0/24" + ] }, { - "id": 321, - "primary": false, - "ipam_address":"10.0.0.2", - "label":"test-interface", - "purpose":"vlan" + "id": 321, + "primary": false, + "ipam_address": "10.0.0.2", + "label": "test-interface", + "purpose": "vlan" } ], "run_level": "default", diff --git a/test/fixtures/linode_instances_123_configs_456789.json b/test/fixtures/linode_instances_123_configs_456789.json index 93e41f86b..8f4387af9 100644 --- a/test/fixtures/linode_instances_123_configs_456789.json +++ b/test/fixtures/linode_instances_123_configs_456789.json @@ -1,65 +1,79 @@ { - "root_device":"/dev/sda", - "comments":"", - "helpers":{ - "updatedb_disabled":true, - "modules_dep":true, - "devtmpfs_automount":true, - "distro":true, - "network":false - }, - "label":"My Ubuntu 17.04 LTS Profile", - "created":"2014-10-07T20:04:00", - "memory_limit":0, - "id":456789, - "interfaces": [ - { - "id": 456, - "purpose": "public", - "primary": true + "root_device": "/dev/sda", + "comments": "", + "helpers": { + "updatedb_disabled": true, + "modules_dep": true, + "devtmpfs_automount": true, + "distro": true, + "network": false + }, + "label": "My Ubuntu 17.04 LTS Profile", + "created": "2014-10-07T20:04:00", + "memory_limit": 0, + "id": 456789, + "interfaces": [ + { + "id": 456, + "purpose": "public", + "primary": true + }, + { + "id": 123, + "purpose": "vpc", + "primary": true, + "active": true, + "vpc_id": 123456, + "subnet_id": 789, + "ipv4": { + "vpc": "10.0.0.2", + "nat_1_1": "any" }, - { - "id": 123, - "purpose": "vpc", - "primary": true, - "active": true, - "vpc_id": 123456, - "subnet_id": 789, - "ipv4": { - "vpc": "10.0.0.2", - "nat_1_1": "any" - }, - "ip_ranges": [ - "10.0.0.0/24" - ] + "ipv6": { + "slaac": [ + { + "range": "1234::5678/64", + "address": "1234::5678" + } + ], + "ranges": [ + { + "range": "1234::5678/64" + } + ], + "is_public": true }, - { - "id": 321, - "primary": false, - "ipam_address":"10.0.0.2", - "label":"test-interface", - "purpose":"vlan" - } - ], - "run_level":"default", - "initrd":null, - "virt_mode":"paravirt", - "kernel":"linode/latest-64bit", - "updated":"2014-10-07T20:04:00", - "devices":{ - "sda":{ - "disk_id":12345, - "volume_id":null - }, - "sdc":null, - "sde":null, - "sdh":null, - "sdg":null, - "sdb":{ - "disk_id":12346, - "volume_id":null - }, - "sdf":null, - "sdd":null - } + "ip_ranges": [ + "10.0.0.0/24" + ] + }, + { + "id": 321, + "primary": false, + "ipam_address": "10.0.0.2", + "label": "test-interface", + "purpose": "vlan" + } + ], + "run_level": "default", + "initrd": null, + "virt_mode": "paravirt", + "kernel": "linode/latest-64bit", + "updated": "2014-10-07T20:04:00", + "devices": { + "sda": { + "disk_id": 12345, + "volume_id": null + }, + "sdc": null, + "sde": null, + "sdh": null, + "sdg": null, + "sdb": { + "disk_id": 12346, + "volume_id": null + }, + "sdf": null, + "sdd": null + } } \ No newline at end of file diff --git a/test/fixtures/linode_instances_123_configs_456789_interfaces.json b/test/fixtures/linode_instances_123_configs_456789_interfaces.json index 86c709071..120551365 100644 --- a/test/fixtures/linode_instances_123_configs_456789_interfaces.json +++ b/test/fixtures/linode_instances_123_configs_456789_interfaces.json @@ -1,34 +1,48 @@ { - "data": [ - { - "id": 456, - "purpose": "public", - "primary": true + "data": [ + { + "id": 456, + "purpose": "public", + "primary": true + }, + { + "id": 123, + "purpose": "vpc", + "primary": true, + "active": true, + "vpc_id": 123456, + "subnet_id": 789, + "ipv4": { + "vpc": "10.0.0.2", + "nat_1_1": "any" }, - { - "id": 123, - "purpose": "vpc", - "primary": true, - "active": true, - "vpc_id": 123456, - "subnet_id": 789, - "ipv4": { - "vpc": "10.0.0.2", - "nat_1_1": "any" - }, - "ip_ranges": [ - "10.0.0.0/24" - ] + "ipv6": { + "slaac": [ + { + "range": "1234::5678/64", + "address": "1234::5678" + } + ], + "ranges": [ + { + "range": "1234::5678/64" + } + ], + "is_public": true }, - { - "id": 321, - "primary": false, - "ipam_address":"10.0.0.2", - "label":"test-interface", - "purpose":"vlan" - } - ], - "page": 1, - "pages": 1, - "results": 1 + "ip_ranges": [ + "10.0.0.0/24" + ] + }, + { + "id": 321, + "primary": false, + "ipam_address": "10.0.0.2", + "label": "test-interface", + "purpose": "vlan" + } + ], + "page": 1, + "pages": 1, + "results": 1 } \ No newline at end of file diff --git a/test/fixtures/linode_instances_123_configs_456789_interfaces_123.json b/test/fixtures/linode_instances_123_configs_456789_interfaces_123.json index d02673aeb..c120905b2 100644 --- a/test/fixtures/linode_instances_123_configs_456789_interfaces_123.json +++ b/test/fixtures/linode_instances_123_configs_456789_interfaces_123.json @@ -1,15 +1,29 @@ { - "id": 123, - "purpose": "vpc", - "primary": true, - "active": true, - "vpc_id": 123456, - "subnet_id": 789, - "ipv4": { - "vpc": "10.0.0.2", - "nat_1_1": "any" - }, - "ip_ranges": [ - "10.0.0.0/24" - ] + "id": 123, + "purpose": "vpc", + "primary": true, + "active": true, + "vpc_id": 123456, + "subnet_id": 789, + "ipv4": { + "vpc": "10.0.0.2", + "nat_1_1": "any" + }, + "ipv6": { + "slaac": [ + { + "range": "1234::5678/64", + "address": "1234::5678" + } + ], + "ranges": [ + { + "range": "1234::5678/64" + } + ], + "is_public": true + }, + "ip_ranges": [ + "10.0.0.0/24" + ] } \ No newline at end of file diff --git a/test/fixtures/linode_instances_124.json b/test/fixtures/linode_instances_124.json new file mode 100644 index 000000000..6c059ba41 --- /dev/null +++ b/test/fixtures/linode_instances_124.json @@ -0,0 +1,43 @@ +{ + "id": 124, + "status": "running", + "type": "g6-standard-1", + "alerts": { + "network_in": 5, + "network_out": 5, + "cpu": 90, + "transfer_quota": 80, + "io": 5000 + }, + "group": "test", + "hypervisor": "kvm", + "label": "linode124", + "backups": { + "enabled": true, + "schedule": { + "window": "W02", + "day": "Scheduling" + } + }, + "specs": { + "memory": 2048, + "disk": 30720, + "vcpus": 1, + "transfer": 2000 + }, + "ipv6": "1235:abcd::1234:abcd:89ef:67cd/64", + "created": "2017-01-01T00:00:00", + "region": "us-east-1", + "ipv4": [ + "124.45.67.89" + ], + "updated": "2017-01-01T00:00:00", + "image": "linode/ubuntu24.04", + "tags": ["something"], + "host_uuid": "3b3ddd59d9a78bb8de041391075df44de62bfec8", + "watchdog_enabled": true, + "disk_encryption": "disabled", + "lke_cluster_id": null, + "placement_group": null, + "interface_generation": "linode" +} \ No newline at end of file diff --git a/test/fixtures/linode_instances_124_interfaces.json b/test/fixtures/linode_instances_124_interfaces.json new file mode 100644 index 000000000..dbb6f79fb --- /dev/null +++ b/test/fixtures/linode_instances_124_interfaces.json @@ -0,0 +1,117 @@ +{ + "interfaces": [ + { + "created": "2025-01-01T00:01:01", + "default_route": { + "ipv4": true, + "ipv6": true + }, + "id": 123, + "mac_address": "22:00:AB:CD:EF:01", + "public": { + "ipv4": { + "addresses": [ + { + "address": "172.30.0.50", + "primary": true + } + ], + "shared": [ + { + "address": "172.30.0.51", + "linode_id": 125 + } + ] + }, + "ipv6": { + "ranges": [ + { + "range": "2600:3c09:e001:59::/64", + "route_target": "2600:3c09::ff:feab:cdef" + }, + { + "range": "2600:3c09:e001:5a::/64", + "route_target": "2600:3c09::ff:feab:cdef" + } + ], + "shared": [ + { + "range": "2600:3c09:e001:2a::/64", + "route_target": null + } + ], + "slaac": [ + { + "address": "2600:3c09::ff:feab:cdef", + "prefix": 64 + } + ] + } + }, + "updated": "2025-01-01T00:01:01", + "version": 1, + "vlan": null, + "vpc": null + }, + { + "id": 456, + "mac_address": "22:00:AB:CD:EF:01", + "created": "2024-01-01T00:01:01", + "updated": "2024-01-01T00:01:01", + "default_route": { + "ipv4": true + }, + "version": 1, + "vpc": { + "vpc_id": 123456, + "subnet_id": 789, + "ipv4": { + "addresses": [ + { + "address": "192.168.22.3", + "primary": true + } + ], + "ranges": [ + { + "range": "192.168.22.16/28" + }, + { + "range": "192.168.22.32/28" + } + ] + }, + "ipv6": { + "is_public": true, + "slaac": [ + { + "range": "1234::/64", + "address": "1234::5678" + } + ], + "ranges": [ + { + "range": "4321::/64" + } + ] + } + }, + "public": null, + "vlan": null + }, + { + "id": 789, + "mac_address": "22:00:AB:CD:EF:01", + "created": "2024-01-01T00:01:01", + "updated": "2024-01-01T00:01:01", + "default_route": {}, + "version": 1, + "vpc": null, + "public": null, + "vlan": { + "vlan_label": "my_vlan", + "ipam_address": "10.0.0.1/24" + } + } + ] +} \ No newline at end of file diff --git a/test/fixtures/linode_instances_124_interfaces_123.json b/test/fixtures/linode_instances_124_interfaces_123.json new file mode 100644 index 000000000..2dc912812 --- /dev/null +++ b/test/fixtures/linode_instances_124_interfaces_123.json @@ -0,0 +1,53 @@ +{ + "created": "2025-01-01T00:01:01", + "default_route": { + "ipv4": true, + "ipv6": true + }, + "id": 123, + "mac_address": "22:00:AB:CD:EF:01", + "public": { + "ipv4": { + "addresses": [ + { + "address": "172.30.0.50", + "primary": true + } + ], + "shared": [ + { + "address": "172.30.0.51", + "linode_id": 125 + } + ] + }, + "ipv6": { + "ranges": [ + { + "range": "2600:3c09:e001:59::/64", + "route_target": "2600:3c09::ff:feab:cdef" + }, + { + "range": "2600:3c09:e001:5a::/64", + "route_target": "2600:3c09::ff:feab:cdef" + } + ], + "shared": [ + { + "range": "2600:3c09:e001:2a::/64", + "route_target": null + } + ], + "slaac": [ + { + "address": "2600:3c09::ff:feab:cdef", + "prefix": 64 + } + ] + } + }, + "updated": "2025-01-01T00:01:01", + "version": 1, + "vlan": null, + "vpc": null +} \ No newline at end of file diff --git a/test/fixtures/linode_instances_124_interfaces_123_firewalls.json b/test/fixtures/linode_instances_124_interfaces_123_firewalls.json new file mode 100644 index 000000000..17a4a9199 --- /dev/null +++ b/test/fixtures/linode_instances_124_interfaces_123_firewalls.json @@ -0,0 +1,56 @@ +{ + "data": [ + { + "created": "2018-01-01T00:01:01", + "id": 123, + "label": "firewall123", + "rules": { + "inbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24" + ], + "ipv6": [ + "2001:DB8::/32" + ] + }, + "description": "An example firewall rule description.", + "label": "firewallrule123", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "inbound_policy": "DROP", + "outbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24" + ], + "ipv6": [ + "2001:DB8::/32" + ] + }, + "description": "An example firewall rule description.", + "label": "firewallrule123", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "outbound_policy": "DROP" + }, + "status": "enabled", + "tags": [ + "example tag", + "another example" + ], + "updated": "2018-01-02T00:01:01" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/linode_instances_124_interfaces_456.json b/test/fixtures/linode_instances_124_interfaces_456.json new file mode 100644 index 000000000..8ec4abd3d --- /dev/null +++ b/test/fixtures/linode_instances_124_interfaces_456.json @@ -0,0 +1,42 @@ +{ + "id": 456, + "mac_address": "22:00:AB:CD:EF:01", + "created": "2024-01-01T00:01:01", + "updated": "2024-01-01T00:01:01", + "default_route": { + "ipv4":true + }, + "version": 1, + "vpc": { + "vpc_id": 123456, + "subnet_id": 789, + "ipv4": { + "addresses": [ + { + "address": "192.168.22.3", + "primary": true + } + ], + "ranges": [ + { "range": "192.168.22.16/28"}, + { "range": "192.168.22.32/28"} + ] + }, + "ipv6": { + "is_public": true, + "slaac": [ + { + "range": "1234::/64", + "address": "1234::5678" + } + ], + "ranges": [ + { + "range": "4321::/64" + } + ] + } + }, + "public": null, + "vlan": null +} diff --git a/test/fixtures/linode_instances_124_interfaces_789.json b/test/fixtures/linode_instances_124_interfaces_789.json new file mode 100644 index 000000000..d533b8e21 --- /dev/null +++ b/test/fixtures/linode_instances_124_interfaces_789.json @@ -0,0 +1,14 @@ +{ + "id": 789, + "mac_address": "22:00:AB:CD:EF:01", + "created": "2024-01-01T00:01:01", + "updated": "2024-01-01T00:01:01", + "default_route": {}, + "version": 1, + "vpc": null, + "public": null, + "vlan": { + "vlan_label": "my_vlan", + "ipam_address": "10.0.0.1/24" + } +} diff --git a/test/fixtures/linode_instances_124_interfaces_settings.json b/test/fixtures/linode_instances_124_interfaces_settings.json new file mode 100644 index 000000000..b454c438e --- /dev/null +++ b/test/fixtures/linode_instances_124_interfaces_settings.json @@ -0,0 +1,16 @@ +{ + "network_helper": true, + "default_route": { + "ipv4_interface_id": 123, + "ipv4_eligible_interface_ids": [ + 123, + 456, + 789 + ], + "ipv6_interface_id": 456, + "ipv6_eligible_interface_ids": [ + 123, + 456 + ] + } +} \ No newline at end of file diff --git a/test/fixtures/linode_instances_124_upgrade-interfaces.json b/test/fixtures/linode_instances_124_upgrade-interfaces.json new file mode 100644 index 000000000..fa1015029 --- /dev/null +++ b/test/fixtures/linode_instances_124_upgrade-interfaces.json @@ -0,0 +1,119 @@ +{ + "dry_run": true, + "config_id": 123, + "interfaces": [ + { + "created": "2025-01-01T00:01:01", + "default_route": { + "ipv4": true, + "ipv6": true + }, + "id": 123, + "mac_address": "22:00:AB:CD:EF:01", + "public": { + "ipv4": { + "addresses": [ + { + "address": "172.30.0.50", + "primary": true + } + ], + "shared": [ + { + "address": "172.30.0.51", + "linode_id": 125 + } + ] + }, + "ipv6": { + "ranges": [ + { + "range": "2600:3c09:e001:59::/64", + "route_target": "2600:3c09::ff:feab:cdef" + }, + { + "range": "2600:3c09:e001:5a::/64", + "route_target": "2600:3c09::ff:feab:cdef" + } + ], + "shared": [ + { + "range": "2600:3c09:e001:2a::/64", + "route_target": null + } + ], + "slaac": [ + { + "address": "2600:3c09::ff:feab:cdef", + "prefix": 64 + } + ] + } + }, + "updated": "2025-01-01T00:01:01", + "version": 1, + "vlan": null, + "vpc": null + }, + { + "id": 456, + "mac_address": "22:00:AB:CD:EF:01", + "created": "2024-01-01T00:01:01", + "updated": "2024-01-01T00:01:01", + "default_route": { + "ipv4": true + }, + "version": 1, + "vpc": { + "vpc_id": 123456, + "subnet_id": 789, + "ipv4": { + "addresses": [ + { + "address": "192.168.22.3", + "primary": true + } + ], + "ranges": [ + { + "range": "192.168.22.16/28" + }, + { + "range": "192.168.22.32/28" + } + ] + }, + "ipv6": { + "is_public": true, + "slaac": [ + { + "range": "1234::/64", + "address": "1234::5678" + } + ], + "ranges": [ + { + "range": "4321::/64" + } + ] + } + }, + "public": null, + "vlan": null + }, + { + "id": 789, + "mac_address": "22:00:AB:CD:EF:01", + "created": "2024-01-01T00:01:01", + "updated": "2024-01-01T00:01:01", + "default_route": {}, + "version": 1, + "vpc": null, + "public": null, + "vlan": { + "vlan_label": "my_vlan", + "ipam_address": "10.0.0.1/24" + } + } + ] +} \ No newline at end of file diff --git a/test/fixtures/linode_types.json b/test/fixtures/linode_types.json index 819867b79..dee3209ee 100644 --- a/test/fixtures/linode_types.json +++ b/test/fixtures/linode_types.json @@ -1,9 +1,10 @@ { - "results": 4, + "results": 5, "pages": 1, "page": 1, "data": [ { + "accelerated_devices": 0, "disk": 20480, "memory": 1024, "transfer": 1000, @@ -52,6 +53,7 @@ "successor": null }, { + "accelerated_devices": 0, "disk": 20480, "memory": 16384, "transfer": 5000, @@ -100,6 +102,7 @@ "successor": null }, { + "accelerated_devices": 0, "disk": 30720, "memory": 2048, "transfer": 2000, @@ -148,6 +151,7 @@ "successor": null }, { + "accelerated_devices": 0, "disk": 49152, "memory": 4096, "transfer": 3000, @@ -194,6 +198,33 @@ } ], "successor": null + }, + { + "id": "g1-accelerated-netint-vpu-t1u1-m", + "label": "Netint Quadra T1U x1 Medium", + "price": { + "hourly": 0.0, + "monthly": 0.0 + }, + "region_prices": [], + "addons": { + "backups": { + "price": { + "hourly": 0.0, + "monthly": 0.0 + }, + "region_prices": [] + } + }, + "memory": 24576, + "disk": 307200, + "transfer": 0, + "vcpus": 12, + "gpus": 0, + "network_out": 16000, + "class": "accelerated", + "successor": null, + "accelerated_devices": 1 } ] } \ No newline at end of file diff --git a/test/fixtures/lke_clusters.json b/test/fixtures/lke_clusters.json index 787a2fae5..1a932c8ec 100644 --- a/test/fixtures/lke_clusters.json +++ b/test/fixtures/lke_clusters.json @@ -6,5 +6,6 @@ "label": "example-cluster", "region": "ap-west", "k8s_version": "1.19", - "tags": [] + "tags": [], + "apl_enabled": true } diff --git a/test/fixtures/lke_clusters_18881.json b/test/fixtures/lke_clusters_18881.json index 755d11c58..a520e49ea 100644 --- a/test/fixtures/lke_clusters_18881.json +++ b/test/fixtures/lke_clusters_18881.json @@ -6,8 +6,10 @@ "label": "example-cluster", "region": "ap-west", "k8s_version": "1.19", + "tier": "standard", "tags": [], "control_plane": { "high_availability": true - } + }, + "apl_enabled": true } \ No newline at end of file diff --git a/test/fixtures/lke_clusters_18881_pools_456.json b/test/fixtures/lke_clusters_18881_pools_456.json index f904b9c95..7bf68a6f8 100644 --- a/test/fixtures/lke_clusters_18881_pools_456.json +++ b/test/fixtures/lke_clusters_18881_pools_456.json @@ -34,6 +34,8 @@ "foo": "bar", "bar": "foo" }, + "label": "example-node-pool", + "firewall_id": 456, "type": "g6-standard-4", "disk_encryption": "enabled" } \ No newline at end of file diff --git a/test/fixtures/lke_clusters_18882.json b/test/fixtures/lke_clusters_18882.json new file mode 100644 index 000000000..49548c018 --- /dev/null +++ b/test/fixtures/lke_clusters_18882.json @@ -0,0 +1,14 @@ +{ + "id": 18881, + "status": "ready", + "created": "2021-02-10T23:54:21", + "updated": "2021-02-10T23:54:21", + "label": "example-cluster-2", + "region": "ap-west", + "k8s_version": "1.31.1+lke1", + "tier": "enterprise", + "tags": [], + "control_plane": { + "high_availability": true + } +} \ No newline at end of file diff --git a/test/fixtures/lke_clusters_18882_pools_789.json b/test/fixtures/lke_clusters_18882_pools_789.json new file mode 100644 index 000000000..8a5ba21d8 --- /dev/null +++ b/test/fixtures/lke_clusters_18882_pools_789.json @@ -0,0 +1,20 @@ +{ + "id": 789, + "type": "g6-standard-2", + "label": "enterprise-node-pool", + "count": 3, + "nodes": [], + "disks": [], + "autoscaler": { + "enabled": false, + "min": 3, + "max": 3 + }, + "labels": {}, + "taints": [], + "tags": [], + "disk_encryption": "enabled", + "k8s_version": "1.31.1+lke1", + "firewall_id": 789, + "update_strategy": "rolling_update" +} \ No newline at end of file diff --git a/test/fixtures/lke_tiers_standard_versions.json b/test/fixtures/lke_tiers_standard_versions.json new file mode 100644 index 000000000..5dfeeb4ab --- /dev/null +++ b/test/fixtures/lke_tiers_standard_versions.json @@ -0,0 +1,19 @@ +{ + "data": [ + { + "id": "1.32", + "tier": "standard" + }, + { + "id": "1.31", + "tier": "standard" + }, + { + "id": "1.30", + "tier": "standard" + } + ], + "page": 1, + "pages": 1, + "results": 3 +} diff --git a/test/fixtures/lke_types.json b/test/fixtures/lke_types.json new file mode 100644 index 000000000..7d27a7f86 --- /dev/null +++ b/test/fixtures/lke_types.json @@ -0,0 +1,38 @@ +{ + "data": [ + { + "id": "lke-sa", + "label": "LKE Standard Availability", + "price": { + "hourly": 0, + "monthly": 0 + }, + "region_prices": [], + "transfer": 0 + }, + { + "id": "lke-ha", + "label": "LKE High Availability", + "price": { + "hourly": 0.09, + "monthly": 60 + }, + "region_prices": [ + { + "id": "id-cgk", + "hourly": 0.108, + "monthly": 72 + }, + { + "id": "br-gru", + "hourly": 0.126, + "monthly": 84 + } + ], + "transfer": 0 + } + ], + "page": 1, + "pages": 1, + "results": 2 +} \ No newline at end of file diff --git a/test/fixtures/locks.json b/test/fixtures/locks.json new file mode 100644 index 000000000..b84056b6b --- /dev/null +++ b/test/fixtures/locks.json @@ -0,0 +1,27 @@ +{ + "data": [ + { + "id": 1, + "lock_type": "cannot_delete", + "entity": { + "id": 123, + "type": "linode", + "label": "test-linode", + "url": "/v4/linode/instances/123" + } + }, + { + "id": 2, + "lock_type": "cannot_delete_with_subresources", + "entity": { + "id": 456, + "type": "linode", + "label": "another-linode", + "url": "/v4/linode/instances/456" + } + } + ], + "page": 1, + "pages": 1, + "results": 2 +} diff --git a/test/fixtures/locks_1.json b/test/fixtures/locks_1.json new file mode 100644 index 000000000..ed7a802bf --- /dev/null +++ b/test/fixtures/locks_1.json @@ -0,0 +1,10 @@ +{ + "id": 1, + "lock_type": "cannot_delete", + "entity": { + "id": 123, + "type": "linode", + "label": "test-linode", + "url": "/v4/linode/instances/123" + } +} diff --git a/test/fixtures/maintenance_policies.json b/test/fixtures/maintenance_policies.json new file mode 100644 index 000000000..409255a07 --- /dev/null +++ b/test/fixtures/maintenance_policies.json @@ -0,0 +1,28 @@ +{ + "data": [ + { + "slug": "linode/migrate", + "label": "Migrate", + "description": "Migrates the Linode to a new host while it remains fully operational. Recommended for maximizing availability.", + "type": "migrate", + "notification_period_sec": 3600, + "is_default": true + }, + { + "slug": "linode/power_off_on", + "label": "Power Off/Power On", + "description": "Powers off the Linode at the start of the maintenance event and reboots it once the maintenance finishes. Recommended for maximizing performance.", + "type": "power_off_on", + "notification_period_sec": 1800, + "is_default": false + }, + { + "slug": "private/12345", + "label": "Critical Workload - Avoid Migration", + "description": "Custom policy designed to power off and perform maintenance during user-defined windows only.", + "type": "power_off_on", + "notification_period_sec": 7200, + "is_default": false + } + ] +} \ No newline at end of file diff --git a/test/fixtures/monitor_alert-definitions.json b/test/fixtures/monitor_alert-definitions.json new file mode 100644 index 000000000..92b6e0e4c --- /dev/null +++ b/test/fixtures/monitor_alert-definitions.json @@ -0,0 +1,26 @@ +{ + "data": [ + { + "id": 12345, + "label": "Test Alert for DBAAS", + "service_type": "dbaas", + "severity": 1, + "type": "user", + "description": "A test alert for dbaas service", + "entity_ids": ["13217"], + "alert_channels": [], + "has_more_resources": false, + "rule_criteria": null, + "trigger_conditions": null, + "class": "alert", + "notification_groups": [], + "status": "active", + "created": "2024-01-01T00:00:00", + "updated": "2024-01-01T00:00:00", + "updated_by": "tester" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/monitor_dashboards.json b/test/fixtures/monitor_dashboards.json new file mode 100644 index 000000000..5e56923a1 --- /dev/null +++ b/test/fixtures/monitor_dashboards.json @@ -0,0 +1,41 @@ +{ + "data": [ + { + "created": "2024-10-10T05:01:58", + "id": 1, + "label": "Resource Usage", + "service_type": "dbaas", + "type": "standard", + "updated": "2024-10-10T05:01:58", + "widgets": [ + { + "aggregate_function": "sum", + "chart_type": "area", + "color": "default", + "label": "CPU Usage", + "metric": "cpu_usage", + "size": 12, + "unit": "%", + "y_label": "cpu_usage", + "group_by": ["entity_id"], + "filters": null + }, + { + "aggregate_function": "sum", + "chart_type": "area", + "color": "default", + "label": "Disk I/O Write", + "metric": "write_iops", + "size": 6, + "unit": "IOPS", + "y_label": "write_iops", + "group_by": ["entity_id"], + "filters": null + } + ] + } + ], + "page": 1, + "pages": 1, + "results": 1 + } \ No newline at end of file diff --git a/test/fixtures/monitor_dashboards_1.json b/test/fixtures/monitor_dashboards_1.json new file mode 100644 index 000000000..afb5d71ee --- /dev/null +++ b/test/fixtures/monitor_dashboards_1.json @@ -0,0 +1,34 @@ +{ + "created": "2024-10-10T05:01:58", + "id": 1, + "label": "Resource Usage", + "service_type": "dbaas", + "type": "standard", + "updated": "2024-10-10T05:01:58", + "widgets": [ + { + "aggregate_function": "sum", + "chart_type": "area", + "color": "default", + "label": "CPU Usage", + "metric": "cpu_usage", + "size": 12, + "unit": "%", + "y_label": "cpu_usage", + "group_by": ["entity_id"], + "filters": null + }, + { + "aggregate_function": "sum", + "chart_type": "area", + "color": "default", + "label": "Available Memory", + "metric": "available_memory", + "size": 6, + "unit": "GB", + "y_label": "available_memory", + "group_by": ["entity_id"], + "filters": null + } + ] + } \ No newline at end of file diff --git a/test/fixtures/monitor_services.json b/test/fixtures/monitor_services.json new file mode 100644 index 000000000..7a568866c --- /dev/null +++ b/test/fixtures/monitor_services.json @@ -0,0 +1,11 @@ +{ + "data": [ + { + "label": "Databases", + "service_type": "dbaas" + } + ], + "page": 1, + "pages": 1, + "results": 1 + } \ No newline at end of file diff --git a/test/fixtures/monitor_services_dbaas.json b/test/fixtures/monitor_services_dbaas.json new file mode 100644 index 000000000..211833847 --- /dev/null +++ b/test/fixtures/monitor_services_dbaas.json @@ -0,0 +1,15 @@ +{ + "service_type": "dbaas", + "label": "Databases", + "alert": { + "polling_interval_seconds": [ + 300 + ], + "evaluation_period_seconds": [ + 300 + ], + "scope": [ + "entity" + ] + } +} \ No newline at end of file diff --git a/test/fixtures/monitor_services_dbaas_alert-definitions.json b/test/fixtures/monitor_services_dbaas_alert-definitions.json new file mode 100644 index 000000000..0c7067a8a --- /dev/null +++ b/test/fixtures/monitor_services_dbaas_alert-definitions.json @@ -0,0 +1,52 @@ +{ + "data": [ + { + "id": 12345, + "label": "Test Alert for DBAAS", + "service_type": "dbaas", + "severity": 1, + "type": "user", + "description": "A test alert for dbaas service", + "entity_ids": [ + "13217" + ], + "alert_channels": [], + "has_more_resources": false, + "rule_criteria": { + "rules": [ + { + "aggregate_function": "avg", + "dimension_filters": [ + { + "dimension_label": "node_type", + "label": "Node Type", + "operator": "eq", + "value": "primary" + } + ], + "label": "High CPU Usage", + "metric": "cpu_usage", + "operator": "gt", + "threshold": 90, + "unit": "percent" + } + ] + }, + "trigger_conditions": { + "criteria_condition": "ALL", + "evaluation_period_seconds": 300, + "polling_interval_seconds": 60, + "trigger_occurrences": 3 + }, + "class": "alert", + "notification_groups": [], + "status": "active", + "created": "2024-01-01T00:00:00", + "updated": "2024-01-01T00:00:00", + "updated_by": "tester" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json b/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json new file mode 100644 index 000000000..822e18b24 --- /dev/null +++ b/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json @@ -0,0 +1,44 @@ +{ + "id": 12345, + "label": "Test Alert for DBAAS", + "service_type": "dbaas", + "severity": 1, + "type": "user", + "description": "A test alert for dbaas service", + "entity_ids": [ + "13217" + ], + "alert_channels": [], + "has_more_resources": false, + "rule_criteria": { + "rules": [ + { + "aggregate_function": "avg", + "dimension_filters": [ + { + "dimension_label": "node_type", + "label": "Node Type", + "operator": "eq", + "value": "primary" + } + ], + "label": "High CPU Usage", + "metric": "cpu_usage", + "operator": "gt", + "threshold": 90, + "unit": "percent" + } + ] + }, + "trigger_conditions": { + "criteria_condition": "ALL", + "evaluation_period_seconds": 300, + "polling_interval_seconds": 60, + "trigger_occurrences": 3 + }, + "class": "alert", + "status": "active", + "created": "2024-01-01T00:00:00", + "updated": "2024-01-01T00:00:00", + "updated_by": "tester" +} diff --git a/test/fixtures/monitor_services_dbaas_dashboards.json b/test/fixtures/monitor_services_dbaas_dashboards.json new file mode 100644 index 000000000..e39a231b2 --- /dev/null +++ b/test/fixtures/monitor_services_dbaas_dashboards.json @@ -0,0 +1,48 @@ +{ + "data": [ + { + "created": "2024-10-10T05:01:58", + "id": 1, + "label": "Resource Usage", + "service_type": "dbaas", + "type": "standard", + "updated": "2024-10-10T05:01:58", + "widgets": [ + { + "aggregate_function": "sum", + "chart_type": "area", + "color": "default", + "label": "CPU Usage", + "metric": "cpu_usage", + "size": 12, + "unit": "%", + "y_label": "cpu_usage", + "group_by": ["entity_id"], + "filters": null + }, + { + "aggregate_function": "sum", + "chart_type": "area", + "color": "default", + "label": "Memory Usage", + "metric": "memory_usage", + "size": 6, + "unit": "%", + "y_label": "memory_usage", + "group_by": ["entity_id"], + "filters": [ + { + "dimension_label": "pattern", + "operator": "in", + "value": "publicout,privateout" + } + ] + + } + ] + } + ], + "page": 1, + "pages": 1, + "results": 1 + } \ No newline at end of file diff --git a/test/fixtures/monitor_services_dbaas_metric-definitions.json b/test/fixtures/monitor_services_dbaas_metric-definitions.json new file mode 100644 index 000000000..c493b23a3 --- /dev/null +++ b/test/fixtures/monitor_services_dbaas_metric-definitions.json @@ -0,0 +1,55 @@ +{ + "data": [ + { + "available_aggregate_functions": [ + "max", + "avg", + "min", + "sum" + ], + "dimensions": [ + { + "dimension_label": "node_type", + "label": "Node Type", + "values": [ + "primary", + "secondary" + ] + } + ], + "is_alertable": true, + "label": "CPU Usage", + "metric": "cpu_usage", + "metric_type": "gauge", + "scrape_interval": "60s", + "unit": "percent" + }, + { + "available_aggregate_functions": [ + "max", + "avg", + "min", + "sum" + ], + "dimensions": [ + { + "dimension_label": "node_type", + "label": "Node Type", + "values": [ + "primary", + "secondary" + ] + } + ], + "is_alertable": true, + "label": "Disk I/O Read", + "metric": "read_iops", + "metric_type": "gauge", + "scrape_interval": "60s", + "unit": "iops" + } + ], + "page": 1, + "pages": 1, + "results": 2 + } \ No newline at end of file diff --git a/test/fixtures/monitor_services_dbaas_metrics.json b/test/fixtures/monitor_services_dbaas_metrics.json new file mode 100644 index 000000000..67657cb78 --- /dev/null +++ b/test/fixtures/monitor_services_dbaas_metrics.json @@ -0,0 +1,47 @@ +{ + "data": { + "result": [ + { + "metric": { + "entity_id": 13316, + "metric_name": "avg_read_iops", + "node_id": "primary-9" + }, + "values": [ + [ + 1728996500, + "90.55555555555556" + ], + [ + 1729043400, + "14890.583333333334" + ] + ] + }, + { + "metric": { + "entity_id": 13217, + "metric_name": "avg_cpu_usage", + "node_id": "primary-0" + }, + "values": [ + [ + 1728996500, + "12.45" + ], + [ + 1729043400, + "18.67" + ] + ] + } + ], + "resultType": "matrix" + }, + "isPartial": false, + "stats": { + "executionTimeMsec": 21, + "seriesFetched": "2" + }, + "status": "success" +} \ No newline at end of file diff --git a/test/fixtures/monitor_services_dbaas_token.json b/test/fixtures/monitor_services_dbaas_token.json new file mode 100644 index 000000000..b1aa0d786 --- /dev/null +++ b/test/fixtures/monitor_services_dbaas_token.json @@ -0,0 +1,3 @@ +{ + "token": "abcdefhjigkfghh" +} \ No newline at end of file diff --git a/test/fixtures/monitor_services_linode_token.json b/test/fixtures/monitor_services_linode_token.json new file mode 100644 index 000000000..b1aa0d786 --- /dev/null +++ b/test/fixtures/monitor_services_linode_token.json @@ -0,0 +1,3 @@ +{ + "token": "abcdefhjigkfghh" +} \ No newline at end of file diff --git a/test/fixtures/network-transfer_prices.json b/test/fixtures/network-transfer_prices.json new file mode 100644 index 000000000..d595864ef --- /dev/null +++ b/test/fixtures/network-transfer_prices.json @@ -0,0 +1,38 @@ +{ + "data": [ + { + "id": "distributed_network_transfer", + "label": "Distributed Network Transfer", + "price": { + "hourly": 0.01, + "monthly": null + }, + "region_prices": [], + "transfer": 0 + }, + { + "id": "network_transfer", + "label": "Network Transfer", + "price": { + "hourly": 0.005, + "monthly": null + }, + "region_prices": [ + { + "id": "id-cgk", + "hourly": 0.015, + "monthly": null + }, + { + "id": "br-gru", + "hourly": 0.007, + "monthly": null + } + ], + "transfer": 0 + } + ], + "page": 1, + "pages": 1, + "results": 2 +} \ No newline at end of file diff --git a/test/fixtures/networking_firewalls_123_devices.json b/test/fixtures/networking_firewalls_123_devices.json index ae4efe2d0..e43e3725a 100644 --- a/test/fixtures/networking_firewalls_123_devices.json +++ b/test/fixtures/networking_firewalls_123_devices.json @@ -10,9 +10,20 @@ }, "id": 123, "updated": "2018-01-02T00:01:01" + }, + { + "created": "2018-01-01T00:01:01", + "entity": { + "id": 123, + "label": null, + "type": "interface", + "url": "/v4/linode/instances/123/interfaces/123" + }, + "id": 456, + "updated": "2018-01-02T00:01:01" } ], "page": 1, "pages": 1, - "results": 1 + "results": 2 } \ No newline at end of file diff --git a/test/fixtures/networking_firewalls_123_devices_456.json b/test/fixtures/networking_firewalls_123_devices_456.json new file mode 100644 index 000000000..aa76901ee --- /dev/null +++ b/test/fixtures/networking_firewalls_123_devices_456.json @@ -0,0 +1,11 @@ +{ + "created": "2018-01-01T00:01:01", + "entity": { + "id": 123, + "label": null, + "type": "interface", + "url": "/v4/linode/instances/123/interfaces/123" + }, + "id": 456, + "updated": "2018-01-02T00:01:01" +} \ No newline at end of file diff --git a/test/fixtures/networking_firewalls_123_history.json b/test/fixtures/networking_firewalls_123_history.json new file mode 100644 index 000000000..13f2b0df7 --- /dev/null +++ b/test/fixtures/networking_firewalls_123_history.json @@ -0,0 +1,21 @@ +{ + "data": [ + { + "updated": "2025-03-07T17:06:36", + "status": "enabled", + "rules": { + "version": 1 + } + }, + { + "updated": "2025-03-07T17:06:36", + "status": "enabled", + "rules": { + "version": 2 + } + } + ], + "page": 1, + "pages": 1, + "results": 2 +} diff --git a/test/fixtures/networking_firewalls_123_history_rules_2.json b/test/fixtures/networking_firewalls_123_history_rules_2.json new file mode 100644 index 000000000..3819436f8 --- /dev/null +++ b/test/fixtures/networking_firewalls_123_history_rules_2.json @@ -0,0 +1,24 @@ +{ + "inbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "0.0.0.0/0" + ], + "ipv6": [ + "ff00::/8" + ] + }, + "description": "A really cool firewall rule.", + "label": "really-cool-firewall-rule", + "ports": "80", + "protocol": "TCP" + } + ], + "inbound_policy": "ACCEPT", + "outbound": [], + "outbound_policy": "DROP", + "version": 2, + "fingerprint": "96c9568c" +} diff --git a/test/fixtures/networking_firewalls_settings.json b/test/fixtures/networking_firewalls_settings.json new file mode 100644 index 000000000..bfb7b2853 --- /dev/null +++ b/test/fixtures/networking_firewalls_settings.json @@ -0,0 +1,8 @@ +{ + "default_firewall_ids": { + "vpc_interface": 123, + "public_interface": 456, + "linode": 789, + "nodebalancer": 321 + } +} \ No newline at end of file diff --git a/test/fixtures/networking_firewalls_templates.json b/test/fixtures/networking_firewalls_templates.json new file mode 100644 index 000000000..b0267c7b4 --- /dev/null +++ b/test/fixtures/networking_firewalls_templates.json @@ -0,0 +1,93 @@ +{ + "data": [ + { + "slug": "public", + "rules": { + "outbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24", + "198.51.100.2/32" + ], + "ipv6": [ + "2001:DB8::/128" + ] + }, + "description": "test", + "label": "test-rule", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "outbound_policy": "DROP", + "inbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24", + "198.51.100.2/32" + ], + "ipv6": [ + "2001:DB8::/128" + ] + }, + "description": "test", + "label": "test-rule", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "inbound_policy": "DROP" + } + }, + { + "slug": "vpc", + "rules": { + "outbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24", + "198.51.100.2/32" + ], + "ipv6": [ + "2001:DB8::/128" + ] + }, + "description": "test", + "label": "test-rule", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "outbound_policy": "DROP", + "inbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24", + "198.51.100.2/32" + ], + "ipv6": [ + "2001:DB8::/128" + ] + }, + "description": "test", + "label": "test-rule", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "inbound_policy": "DROP" + } + } + ], + "page": 1, + "pages": 1, + "results": 2 +} \ No newline at end of file diff --git a/test/fixtures/networking_firewalls_templates_public.json b/test/fixtures/networking_firewalls_templates_public.json new file mode 100644 index 000000000..6b33e9f73 --- /dev/null +++ b/test/fixtures/networking_firewalls_templates_public.json @@ -0,0 +1,43 @@ +{ + "slug": "public", + "rules": { + "outbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24", + "198.51.100.2/32" + ], + "ipv6": [ + "2001:DB8::/128" + ] + }, + "description": "test", + "label": "test-rule", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "outbound_policy": "DROP", + "inbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24", + "198.51.100.2/32" + ], + "ipv6": [ + "2001:DB8::/128" + ] + }, + "description": "test", + "label": "test-rule", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "inbound_policy": "DROP" + } +} \ No newline at end of file diff --git a/test/fixtures/networking_firewalls_templates_vpc.json b/test/fixtures/networking_firewalls_templates_vpc.json new file mode 100644 index 000000000..839bd6824 --- /dev/null +++ b/test/fixtures/networking_firewalls_templates_vpc.json @@ -0,0 +1,43 @@ +{ + "slug": "vpc", + "rules": { + "outbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24", + "198.51.100.2/32" + ], + "ipv6": [ + "2001:DB8::/128" + ] + }, + "description": "test", + "label": "test-rule", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "outbound_policy": "DROP", + "inbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24", + "198.51.100.2/32" + ], + "ipv6": [ + "2001:DB8::/128" + ] + }, + "description": "test", + "label": "test-rule", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "inbound_policy": "DROP" + } +} \ No newline at end of file diff --git a/test/fixtures/networking_ips_127.0.0.1.json b/test/fixtures/networking_ips_127.0.0.1.json index 9d3cfb449..7abb0fabd 100644 --- a/test/fixtures/networking_ips_127.0.0.1.json +++ b/test/fixtures/networking_ips_127.0.0.1.json @@ -2,6 +2,7 @@ "address": "127.0.0.1", "gateway": "127.0.0.1", "linode_id": 123, + "interface_id": 456, "prefix": 24, "public": true, "rdns": "test.example.org", diff --git a/test/fixtures/nodebalancers.json b/test/fixtures/nodebalancers.json index 85eec186b..9b4dc8dae 100644 --- a/test/fixtures/nodebalancers.json +++ b/test/fixtures/nodebalancers.json @@ -10,7 +10,8 @@ "updated": "2018-01-01T00:01:01", "label": "balancer123456", "client_conn_throttle": 0, - "tags": ["something"] + "tags": ["something"], + "locks": ["cannot_delete_with_subresources"] }, { "created": "2018-01-01T00:01:01", @@ -22,7 +23,8 @@ "updated": "2018-01-01T00:01:01", "label": "balancer123457", "client_conn_throttle": 0, - "tags": [] + "tags": [], + "locks": [] } ], "results": 2, diff --git a/test/fixtures/nodebalancers_123456.json b/test/fixtures/nodebalancers_123456.json index e965d4379..a78c8d3e3 100644 --- a/test/fixtures/nodebalancers_123456.json +++ b/test/fixtures/nodebalancers_123456.json @@ -10,5 +10,8 @@ "client_conn_throttle": 0, "tags": [ "something" + ], + "locks": [ + "cannot_delete_with_subresources" ] } \ No newline at end of file diff --git a/test/fixtures/nodebalancers_123456_configs.json b/test/fixtures/nodebalancers_123456_configs.json index f12f1345f..cab9fb981 100644 --- a/test/fixtures/nodebalancers_123456_configs.json +++ b/test/fixtures/nodebalancers_123456_configs.json @@ -24,9 +24,35 @@ "protocol": "http", "ssl_fingerprint": "", "proxy_protocol": "none" + }, + { + "check": "connection", + "check_attempts": 2, + "stickiness": "table", + "check_interval": 5, + "check_body": "", + "id": 65431, + "check_passive": true, + "algorithm": "roundrobin", + "check_timeout": 3, + "check_path": "/", + "ssl_cert": null, + "ssl_commonname": "", + "port": 80, + "nodebalancer_id": 123456, + "cipher_suite": "none", + "ssl_key": null, + "nodes_status": { + "up": 0, + "down": 0 + }, + "protocol": "udp", + "ssl_fingerprint": "", + "proxy_protocol": "none", + "udp_check_port": 12345 } ], - "results": 1, + "results": 2, "page": 1, "pages": 1 } diff --git a/test/fixtures/nodebalancers_123456_configs_65432_nodes.json b/test/fixtures/nodebalancers_123456_configs_65432_nodes.json index 658edbb50..f8ffd9edf 100644 --- a/test/fixtures/nodebalancers_123456_configs_65432_nodes.json +++ b/test/fixtures/nodebalancers_123456_configs_65432_nodes.json @@ -9,9 +9,19 @@ "mode": "accept", "config_id": 54321, "nodebalancer_id": 123456 + }, + { + "id": 12345, + "address": "192.168.210.120", + "label": "node12345", + "status": "UP", + "weight": 50, + "mode": "none", + "config_id": 123456, + "nodebalancer_id": 123456 } ], "pages": 1, "page": 1, - "results": 1 + "results": 2 } diff --git a/test/fixtures/nodebalancers_types.json b/test/fixtures/nodebalancers_types.json new file mode 100644 index 000000000..9e5d3fa53 --- /dev/null +++ b/test/fixtures/nodebalancers_types.json @@ -0,0 +1,28 @@ +{ + "data": [ + { + "id": "nodebalancer", + "label": "NodeBalancer", + "price": { + "hourly": 0.015, + "monthly": 10 + }, + "region_prices": [ + { + "id": "id-cgk", + "hourly": 0.018, + "monthly": 12 + }, + { + "id": "br-gru", + "hourly": 0.021, + "monthly": 14 + } + ], + "transfer": 0 + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/fixtures/object-storage_buckets_us-east-1.json b/test/fixtures/object-storage_buckets_us-east-1.json index f99a944a6..f1479dabb 100644 --- a/test/fixtures/object-storage_buckets_us-east-1.json +++ b/test/fixtures/object-storage_buckets_us-east-1.json @@ -6,7 +6,9 @@ "hostname": "example-bucket.us-east-1.linodeobjects.com", "label": "example-bucket", "objects": 4, - "size": 188318981 + "size": 188318981, + "endpoint_type": "E1", + "s3_endpoint": "us-east-12.linodeobjects.com" } ], "page": 1, diff --git a/test/fixtures/object-storage_buckets_us-east-1_example-bucket.json b/test/fixtures/object-storage_buckets_us-east-1_example-bucket.json index bb93ec99a..c9c6344ee 100644 --- a/test/fixtures/object-storage_buckets_us-east-1_example-bucket.json +++ b/test/fixtures/object-storage_buckets_us-east-1_example-bucket.json @@ -5,5 +5,7 @@ "hostname": "example-bucket.us-east-1.linodeobjects.com", "label": "example-bucket", "objects": 4, - "size": 188318981 + "size": 188318981, + "endpoint_type": "E1", + "s3_endpoint": "us-east-12.linodeobjects.com" } \ No newline at end of file diff --git a/test/fixtures/object-storage_buckets_us-east_example-bucket_access.json b/test/fixtures/object-storage_buckets_us-east_example-bucket_access.json new file mode 100644 index 000000000..852803146 --- /dev/null +++ b/test/fixtures/object-storage_buckets_us-east_example-bucket_access.json @@ -0,0 +1,6 @@ +{ + "acl": "authenticated-read", + "acl_xml": "..." +} \ No newline at end of file diff --git a/test/fixtures/object-storage_quotas.json b/test/fixtures/object-storage_quotas.json new file mode 100644 index 000000000..e831d7303 --- /dev/null +++ b/test/fixtures/object-storage_quotas.json @@ -0,0 +1,25 @@ +{ + "data": [ + { + "quota_id": "obj-objects-us-ord-1", + "quota_name": "Object Storage Maximum Objects", + "description": "Maximum number of Objects this customer is allowed to have on this endpoint.", + "endpoint_type": "E1", + "s3_endpoint": "us-iad-1.linodeobjects.com", + "quota_limit": 50, + "resource_metric": "object" + }, + { + "quota_id": "obj-bucket-us-ord-1", + "quota_name": "Object Storage Maximum Buckets", + "description": "Maximum number of buckets this customer is allowed to have on this endpoint.", + "endpoint_type": "E1", + "s3_endpoint": "us-iad-1.linodeobjects.com", + "quota_limit": 50, + "resource_metric": "bucket" + } + ], + "page": 1, + "pages": 1, + "results": 2 +} \ No newline at end of file diff --git a/test/fixtures/object-storage_quotas_obj-objects-us-ord-1.json b/test/fixtures/object-storage_quotas_obj-objects-us-ord-1.json new file mode 100644 index 000000000..e01d743c3 --- /dev/null +++ b/test/fixtures/object-storage_quotas_obj-objects-us-ord-1.json @@ -0,0 +1,9 @@ +{ + "quota_id": "obj-objects-us-ord-1", + "quota_name": "Object Storage Maximum Objects", + "description": "Maximum number of Objects this customer is allowed to have on this endpoint.", + "endpoint_type": "E1", + "s3_endpoint": "us-iad-1.linodeobjects.com", + "quota_limit": 50, + "resource_metric": "object" +} \ No newline at end of file diff --git a/test/fixtures/object-storage_quotas_obj-objects-us-ord-1_usage.json b/test/fixtures/object-storage_quotas_obj-objects-us-ord-1_usage.json new file mode 100644 index 000000000..59b306044 --- /dev/null +++ b/test/fixtures/object-storage_quotas_obj-objects-us-ord-1_usage.json @@ -0,0 +1,4 @@ +{ + "quota_limit": 100, + "usage": 10 +} diff --git a/test/fixtures/object-storage_types.json b/test/fixtures/object-storage_types.json new file mode 100644 index 000000000..029823580 --- /dev/null +++ b/test/fixtures/object-storage_types.json @@ -0,0 +1,23 @@ +{ + "data": [ + { + "id": "objectstorage", + "label": "Object Storage", + "price": { + "hourly": 0.0015, + "monthly": 0.1 + }, + "region_prices": [ + { + "hourly": 0.00018, + "id": "us-east", + "monthly": 0.12 + } + ], + "transfer": 0 + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/fixtures/placement_groups.json b/test/fixtures/placement_groups.json index 758fc8521..bf05f9936 100644 --- a/test/fixtures/placement_groups.json +++ b/test/fixtures/placement_groups.json @@ -12,7 +12,19 @@ "linode_id": 123, "is_compliant": true } - ] + ], + "migrations": { + "inbound": [ + { + "linode_id": 123 + } + ], + "outbound": [ + { + "linode_id": 456 + } + ] + } } ], "page": 1, diff --git a/test/fixtures/placement_groups_123.json b/test/fixtures/placement_groups_123.json index 453e9fd5f..c7a9cab27 100644 --- a/test/fixtures/placement_groups_123.json +++ b/test/fixtures/placement_groups_123.json @@ -10,5 +10,17 @@ "linode_id": 123, "is_compliant": true } - ] + ], + "migrations": { + "inbound": [ + { + "linode_id": 123 + } + ], + "outbound": [ + { + "linode_id": 456 + } + ] + } } \ No newline at end of file diff --git a/test/fixtures/regions.json b/test/fixtures/regions.json index 5fe55e200..1482def37 100644 --- a/test/fixtures/regions.json +++ b/test/fixtures/regions.json @@ -6,7 +6,8 @@ "capabilities": [ "Linodes", "NodeBalancers", - "Block Storage" + "Block Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -26,7 +27,8 @@ "capabilities": [ "Linodes", "NodeBalancers", - "Block Storage" + "Block Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -46,7 +48,8 @@ "capabilities": [ "Linodes", "NodeBalancers", - "Block Storage" + "Block Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -62,7 +65,8 @@ "capabilities": [ "Linodes", "NodeBalancers", - "Block Storage" + "Block Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -82,7 +86,8 @@ "capabilities": [ "Linodes", "NodeBalancers", - "Block Storage" + "Block Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -102,7 +107,8 @@ "capabilities": [ "Linodes", "NodeBalancers", - "Block Storage" + "Block Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -123,8 +129,17 @@ "Linodes", "NodeBalancers", "Block Storage", - "Object Storage" + "Object Storage", + "Linode Interfaces" ], + "monitors": { + "alerts": [ + "Managed Databases" + ], + "metrics": [ + "Managed Databases" + ] + }, "status": "ok", "resolvers": { "ipv4": "66.228.42.5,96.126.106.5,50.116.53.5,50.116.58.5,50.116.61.5,50.116.62.5,66.175.211.5,97.107.133.4,207.192.69.4,207.192.69.5", @@ -143,7 +158,8 @@ "capabilities": [ "Linodes", "NodeBalancers", - "Block Storage" + "Block Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -164,7 +180,8 @@ "Linodes", "NodeBalancers", "Block Storage", - "Object Storage" + "Object Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -185,7 +202,8 @@ "Linodes", "NodeBalancers", "Block Storage", - "Object Storage" + "Object Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -205,7 +223,8 @@ "capabilities": [ "Linodes", "NodeBalancers", - "Block Storage" + "Block Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { diff --git a/test/fixtures/regions_us-east_vpc-availability.json b/test/fixtures/regions_us-east_vpc-availability.json new file mode 100644 index 000000000..209959e5d --- /dev/null +++ b/test/fixtures/regions_us-east_vpc-availability.json @@ -0,0 +1,5 @@ +{ + "region": "us-east", + "available": true, + "available_ipv6_prefix_lengths": [52, 48] +} diff --git a/test/fixtures/regions_vpc-availability.json b/test/fixtures/regions_vpc-availability.json new file mode 100644 index 000000000..5e4d386df --- /dev/null +++ b/test/fixtures/regions_vpc-availability.json @@ -0,0 +1,132 @@ +{ + "data": [ + { + "region": "us-east", + "available": true, + "available_ipv6_prefix_lengths": [52, 48] + }, + { + "region": "us-west", + "available": true, + "available_ipv6_prefix_lengths": [56, 52, 48] + }, + { + "region": "nl-ams", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "us-ord", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "us-iad", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "fr-par", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "us-sea", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "br-gru", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "se-sto", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "es-mad", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "in-maa", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "jp-osa", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "it-mil", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "us-mia", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "id-cgk", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "us-lax", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "gb-lon", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "au-mel", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "in-bom-2", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "de-fra-2", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "sg-sin-2", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "jp-tyo-3", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "fr-par-2", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "ca-central", + "available": false, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "ap-southeast", + "available": false, + "available_ipv6_prefix_lengths": [] + } + ], + "page": 1, + "pages": 2, + "results": 50 +} diff --git a/test/fixtures/volumes.json b/test/fixtures/volumes.json index 18ba4f6da..2e8c86338 100644 --- a/test/fixtures/volumes.json +++ b/test/fixtures/volumes.json @@ -41,9 +41,24 @@ "filesystem_path": "this/is/a/file/path", "hardware_type": "nvme", "linode_label": "some_label" + }, + { + "id": 4, + "label": "block4", + "created": "2017-08-04T03:00:00", + "region": "ap-west-1a", + "linode_id": null, + "size": 40, + "updated": "2017-08-04T04:00:00", + "status": "active", + "tags": ["something"], + "filesystem_path": "this/is/a/file/path", + "hardware_type": "hdd", + "linode_label": null, + "encryption": "enabled" } ], - "results": 3, + "results": 4, "pages": 1, "page": 1 } diff --git a/test/fixtures/volumes_types.json b/test/fixtures/volumes_types.json new file mode 100644 index 000000000..9b975506e --- /dev/null +++ b/test/fixtures/volumes_types.json @@ -0,0 +1,28 @@ +{ + "data": [ + { + "id": "volume", + "label": "Storage Volume", + "price": { + "hourly": 0.00015, + "monthly": 0.1 + }, + "region_prices": [ + { + "id": "id-cgk", + "hourly": 0.00018, + "monthly": 0.12 + }, + { + "id": "br-gru", + "hourly": 0.00021, + "monthly": 0.14 + } + ], + "transfer": 0 + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/fixtures/vpcs.json b/test/fixtures/vpcs.json index 9a7cc5038..822f3bae1 100644 --- a/test/fixtures/vpcs.json +++ b/test/fixtures/vpcs.json @@ -5,6 +5,11 @@ "id": 123456, "description": "A very real VPC.", "region": "us-southeast", + "ipv6": [ + { + "range": "fd71:1140:a9d0::/52" + } + ], "created": "2018-01-01T00:01:01", "updated": "2018-01-01T00:01:01" } diff --git a/test/fixtures/vpcs_123456.json b/test/fixtures/vpcs_123456.json index e4c16437a..af6d2cff8 100644 --- a/test/fixtures/vpcs_123456.json +++ b/test/fixtures/vpcs_123456.json @@ -3,6 +3,11 @@ "id": 123456, "description": "A very real VPC.", "region": "us-southeast", + "ipv6": [ + { + "range": "fd71:1140:a9d0::/52" + } + ], "created": "2018-01-01T00:01:01", "updated": "2018-01-01T00:01:01" } \ No newline at end of file diff --git a/test/fixtures/vpcs_123456_ips.json b/test/fixtures/vpcs_123456_ips.json index 70b4b8a60..10cb94f3c 100644 --- a/test/fixtures/vpcs_123456_ips.json +++ b/test/fixtures/vpcs_123456_ips.json @@ -1,34 +1,44 @@ { - "data": [ + "data": [ + { + "address": "10.0.0.2", + "address_range": null, + "vpc_id": 123456, + "subnet_id": 654321, + "region": "us-ord", + "linode_id": 111, + "config_id": 222, + "interface_id": 333, + "active": true, + "nat_1_1": null, + "gateway": "10.0.0.1", + "prefix": 8, + "subnet_mask": "255.0.0.0" + }, + { + "address": "10.0.0.3", + "address_range": null, + "vpc_id": 41220, + "subnet_id": 41184, + "region": "us-ord", + "linode_id": 56323949, + "config_id": 59467106, + "interface_id": 1248358, + "active": true, + "nat_1_1": null, + "gateway": "10.0.0.1", + "prefix": 8, + "subnet_mask": "255.0.0.0" + }, + { + "ipv6_range": "fd71:1140:a9d0::/52", + "ipv6_is_public": true, + "ipv6_addresses": [ { - "address": "10.0.0.2", - "address_range": null, - "vpc_id": 123456, - "subnet_id": 654321, - "region": "us-ord", - "linode_id": 111, - "config_id": 222, - "interface_id": 333, - "active": true, - "nat_1_1": null, - "gateway": "10.0.0.1", - "prefix": 8, - "subnet_mask": "255.0.0.0" - }, - { - "address": "10.0.0.3", - "address_range": null, - "vpc_id": 41220, - "subnet_id": 41184, - "region": "us-ord", - "linode_id": 56323949, - "config_id": 59467106, - "interface_id": 1248358, - "active": true, - "nat_1_1": null, - "gateway": "10.0.0.1", - "prefix": 8, - "subnet_mask": "255.0.0.0" + "slaac_address": "fd71:1140:a9d0::/52" } - ] + ], + "vpc_id": 123456 + } + ] } diff --git a/test/fixtures/vpcs_123456_subnets.json b/test/fixtures/vpcs_123456_subnets.json index f846399df..8239daec2 100644 --- a/test/fixtures/vpcs_123456_subnets.json +++ b/test/fixtures/vpcs_123456_subnets.json @@ -4,21 +4,37 @@ "label": "test-subnet", "id": 789, "ipv4": "10.0.0.0/24", + "ipv6": [ + { + "range": "fd71:1140:a9d0::/52" + } + ], "linodes": [ { "id": 12345, "interfaces": [ { "id": 678, - "active": true + "active": true, + "config_id": null }, { "id": 543, - "active": false + "active": false, + "config_id": null } ] } ], + "databases": [ + { + "id": 12345, + "ipv4_range": "10.0.0.0/24", + "ipv6_ranges": [ + "2001:db8::/64" + ] + } + ], "created": "2018-01-01T00:01:01", "updated": "2018-01-01T00:01:01" } diff --git a/test/fixtures/vpcs_123456_subnets_789.json b/test/fixtures/vpcs_123456_subnets_789.json index ba6973472..199156130 100644 --- a/test/fixtures/vpcs_123456_subnets_789.json +++ b/test/fixtures/vpcs_123456_subnets_789.json @@ -2,21 +2,37 @@ "label": "test-subnet", "id": 789, "ipv4": "10.0.0.0/24", + "ipv6": [ + { + "range": "fd71:1140:a9d0::/52" + } + ], "linodes": [ { "id": 12345, "interfaces": [ { "id": 678, - "active": true + "active": true, + "config_id": null }, { "id": 543, - "active": false + "active": false, + "config_id": null } ] } ], + "databases": [ + { + "id": 12345, + "ipv4_range": "10.0.0.0/24", + "ipv6_ranges": [ + "2001:db8::/64" + ] + } + ], "created": "2018-01-01T00:01:01", "updated": "2018-01-01T00:01:01" } \ No newline at end of file diff --git a/test/fixtures/vpcs_ips.json b/test/fixtures/vpcs_ips.json index d6f16c2e9..7849f5d76 100644 --- a/test/fixtures/vpcs_ips.json +++ b/test/fixtures/vpcs_ips.json @@ -14,6 +14,16 @@ "gateway": "10.0.0.1", "prefix": 24, "subnet_mask": "255.255.255.0" + }, + { + "ipv6_range": "fd71:1140:a9d0::/52", + "ipv6_is_public": true, + "ipv6_addresses": [ + { + "slaac_address": "fd71:1140:a9d0::/52" + } + ], + "vpc_id": 123456 } ], "page": 1, diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 220cd4093..a5c832f4f 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -1,15 +1,34 @@ import ipaddress +import logging import os import random import time -from typing import Set +from test.integration.helpers import ( + get_test_label, + send_request_when_resource_available, + wait_for_condition, +) +from test.integration.models.database.helpers import get_db_engine_id +from typing import Optional, Set import pytest import requests from requests.exceptions import ConnectionError, RequestException -from linode_api4 import ApiError, PlacementGroupPolicy, PlacementGroupType -from linode_api4.linode_client import LinodeClient +from linode_api4 import ( + ExplicitNullValue, + InterfaceGeneration, + LinodeInterfaceDefaultRouteOptions, + LinodeInterfaceOptions, + LinodeInterfacePublicOptions, + LinodeInterfaceVLANOptions, + LinodeInterfaceVPCOptions, + PlacementGroupPolicy, + PlacementGroupType, + PostgreSQLDatabase, +) +from linode_api4.errors import ApiError +from linode_api4.linode_client import LinodeClient, MonitorClient from linode_api4.objects import Region ENV_TOKEN_NAME = "LINODE_TOKEN" @@ -17,6 +36,16 @@ ENV_REGION_OVERRIDE = "LINODE_TEST_REGION_OVERRIDE" ENV_API_CA_NAME = "LINODE_API_CA" RUN_LONG_TESTS = "RUN_LONG_TESTS" +SKIP_E2E_FIREWALL = "SKIP_E2E_FIREWALL" + +ALL_ACCOUNT_AVAILABILITIES = { + "Linodes", + "NodeBalancers", + "Block Storage", + "Kubernetes", +} + +logger = logging.getLogger(__name__) def get_token(): @@ -27,15 +56,10 @@ def get_api_url(): return os.environ.get(ENV_API_URL_NAME, "https://api.linode.com/v4beta") -def get_random_label(): - timestamp = str(time.time_ns())[:-5] - label = "label_" + timestamp - - return label - - -def get_region( - client: LinodeClient, capabilities: Set[str] = None, site_type: str = None +def get_regions( + client: LinodeClient, + capabilities: Optional[Set[str]] = None, + site_type: Optional[str] = None, ): region_override = os.environ.get(ENV_REGION_OVERRIDE) @@ -45,15 +69,52 @@ def get_region( regions = client.regions() + account_regional_availabilities = {} + try: + account_availabilities = client.account.availabilities() + for availability in account_availabilities: + account_regional_availabilities[availability.region] = ( + availability.available + ) + except ApiError: + logger.warning( + "Failed to retrieve account availabilities for regions. " + "Assuming required capabilities are available in all regions for this account. " + "Tests may fail if the account lacks access to necessary capabilities in the selected region." + ) + if capabilities is not None: + required_capabilities = set(capabilities) + required_account_capabilities = required_capabilities.intersection( + ALL_ACCOUNT_AVAILABILITIES + ) + regions = [ - v for v in regions if set(capabilities).issubset(v.capabilities) + v + for v in regions + if required_capabilities.issubset(v.capabilities) + and required_account_capabilities.issubset( + account_regional_availabilities.get( + v.id, + ( + [] + if account_regional_availabilities + else ALL_ACCOUNT_AVAILABILITIES + ), + ) + ) ] if site_type is not None: regions = [v for v in regions if v.site_type == site_type] - return random.choice(regions) + return regions + + +def get_region( + client: LinodeClient, capabilities: Set[str] = None, site_type: str = "core" +): + return random.choice(get_regions(client, capabilities, site_type)) def get_api_ca_file(): @@ -67,6 +128,12 @@ def run_long_tests(): @pytest.fixture(autouse=True, scope="session") def e2e_test_firewall(test_linode_client): + # Allow skipping firewall creation for local runs: set SKIP_E2E_FIREWALL=1 + if os.environ.get(SKIP_E2E_FIREWALL): + # Yield None so fixtures depending on this receive a falsy value but the session continues. + yield None + return + def is_valid_ipv4(address): try: ipaddress.IPv4Address(address) @@ -153,14 +220,12 @@ def create_inbound_rule(ipv4_address, ipv6_address): def create_linode(test_linode_client, e2e_test_firewall): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] - timestamp = str(time.time_ns()) - label = "TestSDK-" + timestamp + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") + label = get_test_label(length=8) linode_instance, password = client.linode.instance_create( "g6-nanode-1", - chosen_region, + region, image="linode/debian12", label=label, firewall=e2e_test_firewall, @@ -175,15 +240,13 @@ def create_linode(test_linode_client, e2e_test_firewall): def create_linode_for_pass_reset(test_linode_client, e2e_test_firewall): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] - timestamp = str(time.time_ns()) - label = "TestSDK-" + timestamp + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") + label = get_test_label(length=8) linode_instance, password = client.linode.instance_create( "g6-nanode-1", - chosen_region, - image="linode/debian10", + region, + image="linode/debian12", label=label, firewall=e2e_test_firewall, ) @@ -263,36 +326,36 @@ def test_domain(test_linode_client): @pytest.fixture(scope="session") def test_volume(test_linode_client): client = test_linode_client - timestamp = str(time.time_ns()) - region = client.regions()[4] - label = "TestSDK-" + timestamp + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") + label = get_test_label(length=8) volume = client.volume_create(label=label, region=region) yield volume - timeout = 100 # give 100s for volume to be detached before deletion + send_request_when_resource_available(timeout=100, func=volume.delete) - start_time = time.time() - while time.time() - start_time < timeout: - try: - res = volume.delete() - if res: - break - else: - time.sleep(3) - except ApiError as e: - if time.time() - start_time > timeout: - raise e +@pytest.fixture(scope="session") +def test_volume_with_encryption(test_linode_client): + client = test_linode_client + region = get_region(client, {"Block Storage Encryption"}) + label = get_test_label(length=8) + + volume = client.volume_create( + label=label, region=region, encryption="enabled" + ) + + yield volume + + send_request_when_resource_available(timeout=100, func=volume.delete) @pytest.fixture def test_tag(test_linode_client): client = test_linode_client - timestamp = str(time.time_ns()) - label = "TestSDK-" + timestamp + label = get_test_label(length=8) tag = client.tag_create(label=label) @@ -305,11 +368,10 @@ def test_tag(test_linode_client): def test_nodebalancer(test_linode_client): client = test_linode_client - timestamp = str(time.time_ns()) - label = "TestSDK-" + timestamp + label = get_test_label(length=8) nodebalancer = client.nodebalancer_create( - region=get_region(client), label=label + region=get_region(client, capabilities={"NodeBalancers"}), label=label ) yield nodebalancer @@ -320,8 +382,7 @@ def test_nodebalancer(test_linode_client): @pytest.fixture def test_longview_client(test_linode_client): client = test_linode_client - timestamp = str(time.time_ns()) - label = "TestSDK-" + timestamp + label = get_test_label(length=8) longview_client = client.longview.client_create(label=label) yield longview_client @@ -333,7 +394,8 @@ def test_longview_client(test_linode_client): def test_sshkey(test_linode_client, ssh_key_gen): pub_key = ssh_key_gen[0] client = test_linode_client - key = client.profile.ssh_key_upload(pub_key, "IntTestSDK-sshkey") + key_label = get_test_label(8) + "_key" + key = client.profile.ssh_key_upload(pub_key, key_label) yield key @@ -343,7 +405,7 @@ def test_sshkey(test_linode_client, ssh_key_gen): @pytest.fixture def access_keys_object_storage(test_linode_client): client = test_linode_client - label = "TestSDK-obj-storage-key" + label = get_test_label(length=8) key = client.object_storage.keys_create(label) yield key @@ -361,8 +423,7 @@ def test_firewall(test_linode_client): "inbound_policy": "ACCEPT", } - timestamp = str(time.time_ns()) - label = "firewall_" + timestamp + label = get_test_label(8) + "_firewall" firewall = client.networking.firewall_create( label=label, rules=rules, status="enabled" @@ -376,7 +437,7 @@ def test_firewall(test_linode_client): @pytest.fixture def test_oauth_client(test_linode_client): client = test_linode_client - label = get_random_label() + "_oauth" + label = get_test_label(length=8) + "_oauth" oauth_client = client.account.oauth_client_create( label, "https://localhost/oauth/callback" @@ -391,12 +452,15 @@ def test_oauth_client(test_linode_client): def create_vpc(test_linode_client): client = test_linode_client - timestamp = str(int(time.time())) + label = get_test_label(length=10) vpc = client.vpcs.create( - "pythonsdk-" + timestamp, - get_region(test_linode_client, {"VPCs"}), + label=label, + region=get_region( + test_linode_client, {"VPCs", "VPC IPv6 Stack", "Linode Interfaces"} + ), description="test description", + ipv6=[{"range": "auto"}], ) yield vpc @@ -405,7 +469,11 @@ def create_vpc(test_linode_client): @pytest.fixture(scope="session") def create_vpc_with_subnet(test_linode_client, create_vpc): - subnet = create_vpc.subnet_create("test-subnet", ipv4="10.0.0.0/24") + subnet = create_vpc.subnet_create( + label="test-subnet", + ipv4="10.0.0.0/24", + ipv6=[{"range": "auto"}], + ) yield create_vpc, subnet @@ -418,8 +486,7 @@ def create_vpc_with_subnet_and_linode( ): vpc, subnet = create_vpc_with_subnet - timestamp = str(int(time.time())) - label = "TestSDK-" + timestamp + label = get_test_label(length=8) instance, password = test_linode_client.linode.instance_create( "g6-standard-1", @@ -438,18 +505,18 @@ def create_vpc_with_subnet_and_linode( def create_multiple_vpcs(test_linode_client): client = test_linode_client - timestamp = str(int(time.time_ns() % 10**10)) + label = get_test_label(length=10) - timestamp_2 = str(int(time.time_ns() % 10**10)) + label_2 = get_test_label(length=10) vpc_1 = client.vpcs.create( - "pythonsdk-" + timestamp, + label, get_region(test_linode_client, {"VPCs"}), description="test description", ) vpc_2 = client.vpcs.create( - "pythonsdk-" + timestamp_2, + label_2, get_region(test_linode_client, {"VPCs"}), description="test description", ) @@ -465,10 +532,10 @@ def create_multiple_vpcs(test_linode_client): def create_placement_group(test_linode_client): client = test_linode_client - timestamp = str(int(time.time())) + label = get_test_label(10) pg = client.placement.group_create( - "pythonsdk-" + timestamp, + label, get_region(test_linode_client, {"Placement Group"}), PlacementGroupType.anti_affinity_local, PlacementGroupPolicy.flexible, @@ -504,3 +571,160 @@ def pytest_configure(config): "markers", "smoke: mark test as part of smoke test suite", ) + + +@pytest.fixture(scope="session") +def linode_for_vlan_tests(test_linode_client, e2e_test_firewall): + client = test_linode_client + region = get_region(client, {"Linodes", "Vlans"}, site_type="core") + label = get_test_label(length=8) + + linode_instance, password = client.linode.instance_create( + "g6-nanode-1", + region, + image="linode/debian12", + label=label, + firewall=e2e_test_firewall, + ) + + yield linode_instance + + linode_instance.delete() + + +@pytest.fixture(scope="function") +def linode_with_interface_generation_linode( + test_linode_client, + e2e_test_firewall, + # We won't be using this all the time, but it's + # necessary for certain consumers of this fixture + create_vpc_with_subnet, +): + client = test_linode_client + + label = get_test_label() + + instance = client.linode.instance_create( + "g6-nanode-1", + create_vpc_with_subnet[0].region, + label=label, + interface_generation=InterfaceGeneration.LINODE, + booted=False, + ) + + yield instance + + instance.delete() + + +@pytest.fixture(scope="function") +def linode_with_linode_interfaces( + test_linode_client, e2e_test_firewall, create_vpc_with_subnet +): + client = test_linode_client + vpc, subnet = create_vpc_with_subnet + + # Are there regions where VPCs are supported but Linode Interfaces aren't? + region = vpc.region + label = get_test_label() + + instance, _ = client.linode.instance_create( + "g6-nanode-1", + region, + image="linode/debian12", + label=label, + booted=False, + interface_generation=InterfaceGeneration.LINODE, + interfaces=[ + LinodeInterfaceOptions( + firewall_id=e2e_test_firewall.id, + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ipv6=True, + ), + public=LinodeInterfacePublicOptions(), + ), + LinodeInterfaceOptions( + firewall_id=ExplicitNullValue, + vpc=LinodeInterfaceVPCOptions( + subnet_id=subnet.id, + ), + ), + LinodeInterfaceOptions( + vlan=LinodeInterfaceVLANOptions( + vlan_label="test-vlan", ipam_address="10.0.0.5/32" + ), + ), + ], + ) + + yield instance + + instance.delete() + + +@pytest.fixture(scope="session") +def test_create_postgres_db(test_linode_client): + client = test_linode_client + label = get_test_label() + "-postgresqldb" + region = "us-ord" + engine_id = get_db_engine_id(client, "postgresql") + dbtype = "g6-standard-1" + + db = client.database.postgresql_create( + label=label, + region=region, + engine=engine_id, + ltype=dbtype, + cluster_size=None, + ) + + def get_db_status(): + return db.status == "active" + + # TAKES 15-30 MINUTES TO FULLY PROVISION DB + wait_for_condition(60, 2000, get_db_status) + + yield db + + send_request_when_resource_available(300, db.delete) + + +@pytest.fixture(scope="session") +def get_monitor_token_for_db_entities( + test_linode_client, test_create_postgres_db +): + client = test_linode_client + + dbs = client.database.postgresql_instances() + + if len(dbs) < 1: + db_id = test_create_postgres_db.id + else: + db_id = dbs[0].id + + region = client.load(PostgreSQLDatabase, db_id).region + dbs = client.database.instances() + + # only collect entity_ids in the same region + entity_ids = [db.id for db in dbs if db.region == region] + + # create token for the particular service + token = client.monitor.create_token( + service_type="dbaas", entity_ids=entity_ids + ) + + yield token, entity_ids + + +@pytest.fixture(scope="session") +def test_monitor_client(get_monitor_token_for_db_entities): + api_ca_file = get_api_ca_file() + token, entity_ids = get_monitor_token_for_db_entities + + client = MonitorClient( + token.token, + ca_path=api_ca_file, + ) + + return client, entity_ids diff --git a/test/integration/filters/fixtures.py b/test/integration/filters/fixtures.py new file mode 100644 index 000000000..e753236dd --- /dev/null +++ b/test/integration/filters/fixtures.py @@ -0,0 +1,37 @@ +from test.integration.conftest import get_region +from test.integration.helpers import get_test_label + +import pytest + + +@pytest.fixture(scope="package") +def domain_instance(test_linode_client): + client = test_linode_client + + domain_addr = get_test_label(5) + "-example.com" + soa_email = "dx-test-email@linode.com" + + domain = client.domain_create(domain=domain_addr, soa_email=soa_email) + + yield domain + + domain.delete() + + +@pytest.fixture(scope="package") +def lke_cluster_instance(test_linode_client): + node_type = test_linode_client.linode.types()[1] # g6-standard-1 + version = test_linode_client.lke.versions()[0] + + region = get_region(test_linode_client, {"Kubernetes", "Disk Encryption"}) + + node_pool = test_linode_client.lke.node_pool(node_type, 3) + label = get_test_label() + "_cluster" + + cluster = test_linode_client.lke.cluster_create( + region, label, version, [node_pool] + ) + + yield cluster + + cluster.delete() diff --git a/test/integration/filters/model_filters_test.py b/test/integration/filters/model_filters_test.py new file mode 100644 index 000000000..55bed6ac3 --- /dev/null +++ b/test/integration/filters/model_filters_test.py @@ -0,0 +1,85 @@ +from test.integration.filters.fixtures import ( # noqa: F401 + domain_instance, + lke_cluster_instance, +) + +from linode_api4.objects import ( + DatabaseEngine, + DatabaseType, + Domain, + Firewall, + Image, + LKECluster, + Type, +) + + +def test_database_type_model_filter(test_linode_client): + client = test_linode_client + + db_disk = client.database.types()[0].disk + + filtered_db_type = client.database.types(DatabaseType.disk == db_disk) + + assert db_disk == filtered_db_type[0].disk + + +def test_database_engine_model_filter(test_linode_client): + client = test_linode_client + + engine = "mysql" + + filtered_db_engine = client.database.engines( + DatabaseEngine.engine == engine + ) + + assert len(client.database.engines()) > len(filtered_db_engine) + + +def test_domain_model_filter(test_linode_client, domain_instance): + client = test_linode_client + + filtered_domain = client.domains(Domain.domain == domain_instance.domain) + + assert domain_instance.id == filtered_domain[0].id + + +def test_image_model_filter(test_linode_client): + client = test_linode_client + + filtered_images = client.images(Image.label.contains("Debian")) + + assert len(client.images()) > len(filtered_images) + + +def test_linode_type_model_filter(test_linode_client): + client = test_linode_client + + filtered_types = client.linode.types(Type.label.contains("Linode")) + + assert len(filtered_types) > 0 + assert "Linode" in filtered_types[0].label + + +def test_lke_cluster_model_filter(test_linode_client, lke_cluster_instance): + client = test_linode_client + lke_cluster = lke_cluster_instance + + filtered_cluster = client.lke.clusters( + LKECluster.label.contains(lke_cluster.label) + ) + + assert filtered_cluster[0].id == lke_cluster.id + + +def test_networking_firewall_model_filter( + test_linode_client, e2e_test_firewall +): + client = test_linode_client + + filtered_firewall = client.networking.firewalls( + Firewall.label.contains(e2e_test_firewall.label) + ) + + assert len(filtered_firewall) > 0 + assert e2e_test_firewall.label in filtered_firewall[0].label diff --git a/test/integration/helpers.py b/test/integration/helpers.py index e0aab06c4..969ca70a9 100644 --- a/test/integration/helpers.py +++ b/test/integration/helpers.py @@ -1,122 +1,59 @@ +import random import time +from string import ascii_lowercase from typing import Callable -from linode_api4 import PaginatedList from linode_api4.errors import ApiError -from linode_api4.linode_client import LinodeClient -def get_test_label(): - unique_timestamp = str(time.time_ns())[:-3] - label = "test_" + unique_timestamp - return label - - -def get_rand_nanosec_test_label(): - unique_timestamp = str(time.time_ns())[:-3] - label = "test_" + unique_timestamp - return label - - -def delete_instance_with_test_kw(paginated_list: PaginatedList): - for i in paginated_list: - try: - if hasattr(i, "label"): - label = getattr(i, "label") - if "IntTestSDK" in str(label): - i.delete() - elif "lke" in str(label): - iso_created_date = getattr(i, "created") - created_time = int( - time.mktime(iso_created_date.timetuple()) - ) - timestamp = int(time.time()) - if (timestamp - created_time) < 86400: - i.delete() - elif hasattr(i, "domain"): - domain = getattr(i, "domain") - if "IntTestSDK" in domain: - i.delete() - except AttributeError as e: - if "IntTestSDK" in str(i.__dict__): - i.delete() - - -def delete_all_test_instances(client: LinodeClient): - tags = client.tags() - linodes = client.linode.instances() - images = client.images() - volumes = client.volumes() - nodebalancers = client.nodebalancers() - domains = client.domains() - longview_clients = client.longview.clients() - clusters = client.lke.clusters() - firewalls = client.networking.firewalls() - - delete_instance_with_test_kw(tags) - delete_instance_with_test_kw(linodes) - delete_instance_with_test_kw(images) - delete_instance_with_test_kw(volumes) - delete_instance_with_test_kw(nodebalancers) - delete_instance_with_test_kw(domains) - delete_instance_with_test_kw(longview_clients) - delete_instance_with_test_kw(clusters) - delete_instance_with_test_kw(firewalls) +def get_test_label(length: int = 8): + return "".join(random.choice(ascii_lowercase) for i in range(length)) def wait_for_condition( interval: int, timeout: int, condition: Callable, *args ) -> object: - start_time = time.time() - while True: - if condition(*args): - break - - if time.time() - start_time > timeout: - raise TimeoutError("Wait for condition timeout error") - + end_time = time.time() + timeout + while time.time() < end_time: + result = condition(*args) + if result: + return result time.sleep(interval) + raise TimeoutError( + f"Timeout Error: resource not available in {timeout} seconds" + ) # Retry function to help in case of requests sending too quickly before instance is ready def retry_sending_request( - retries: int, condition: Callable, *args, **kwargs + retries: int, condition: Callable, *args, backoff: int = 5, **kwargs ) -> object: - curr_t = 0 - while curr_t < retries: + for attempt in range(1, retries + 1): try: - curr_t += 1 - res = condition(*args, **kwargs) - return res - except ApiError: - if curr_t >= retries: - raise ApiError - time.sleep(5) + return condition(*args, **kwargs) + except ApiError as e: + if attempt == retries: + raise Exception( + "Api Error: Failed after all retry attempts" + ) from e + time.sleep(backoff) def send_request_when_resource_available( timeout: int, func: Callable, *args, **kwargs ) -> object: start_time = time.time() + retry_statuses = {400, 500, 503} while True: try: - res = func(*args, **kwargs) - return res + return func(*args, **kwargs) except ApiError as e: - if ( - e.status == 400 - or e.status == 500 - or "Please try again later" in str(e.__dict__) - ): + if e.status in retry_statuses or "Please try again later" in str(e): if time.time() - start_time > timeout: raise TimeoutError( - "Timeout Error: resource is not available in " - + str(timeout) - + " seconds" + f"Timeout Error: resource not available in {timeout} seconds" ) time.sleep(10) else: raise e - - return res diff --git a/test/integration/linode_client/test_errors.py b/test/integration/linode_client/test_errors.py new file mode 100644 index 000000000..2c3ab57b5 --- /dev/null +++ b/test/integration/linode_client/test_errors.py @@ -0,0 +1,28 @@ +from linode_api4.errors import ApiError + + +def test_error_404(test_linode_client): + api_exc = None + + try: + test_linode_client.get("/invalid/endpoint") + except ApiError as exc: + api_exc = exc + + assert str(api_exc) == "GET /v4beta/invalid/endpoint: [404] Not found" + + +def test_error_400(test_linode_client): + api_exc = None + + try: + test_linode_client.linode.instance_create( + "g6-fake-plan", "us-fakeregion" + ) + except ApiError as exc: + api_exc = exc + + assert str(api_exc) == ( + "POST /v4beta/linode/instances: [400] type: A valid plan type by that ID was not found; " + "region: region is not valid" + ) diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index 92224abd4..4060064d3 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -12,16 +12,14 @@ @pytest.fixture(scope="session") def setup_client_and_linode(test_linode_client, e2e_test_firewall): client = test_linode_client - chosen_region = get_region( - client, {"Kubernetes", "NodeBalancers"}, "core" - ).id + region = get_region(client, {"Kubernetes", "NodeBalancers"}, "core").id label = get_test_label() linode_instance, password = client.linode.instance_create( "g6-nanode-1", - chosen_region, - image="linode/debian10", + region, + image="linode/debian12", label=label, firewall=e2e_test_firewall, ) @@ -87,6 +85,7 @@ def test_get_regions(test_linode_client): @pytest.mark.smoke +@pytest.mark.flaky(reruns=3, reruns_delay=2) def test_image_create(setup_client_and_linode): client = setup_client_and_linode[0] linode = setup_client_and_linode[1] @@ -222,7 +221,7 @@ def test_get_account_settings(test_linode_client): assert account_settings._populated == True assert re.search( - "'network_helper':\s*(True|False)", str(account_settings._raw_json) + r"'network_helper':\s*(True|False)", str(account_settings._raw_json) ) @@ -232,11 +231,11 @@ def test_get_account_settings(test_linode_client): # LinodeGroupTests def test_create_linode_instance_without_image(test_linode_client): client = test_linode_client - chosen_region = get_region(client, {"Linodes"}, "core").id + region = get_region(client, {"Linodes"}, "core").id label = get_test_label() linode_instance = client.linode.instance_create( - "g6-nanode-1", chosen_region, label=label + "g6-nanode-1", region, label=label ) assert linode_instance.label == label @@ -251,19 +250,19 @@ def test_create_linode_instance_without_image(test_linode_client): def test_create_linode_instance_with_image(setup_client_and_linode): linode = setup_client_and_linode[1] - assert re.search("linode/debian10", str(linode.image)) + assert re.search("linode/debian12", str(linode.image)) def test_create_linode_with_interfaces(test_linode_client): client = test_linode_client - chosen_region = get_region(client, {"Vlans", "Linodes"}).id + region = get_region(client, {"Vlans", "Linodes"}, site_type="core").id label = get_test_label() linode_instance, password = client.linode.instance_create( "g6-nanode-1", - chosen_region, + region, label=label, - image="linode/debian10", + image="linode/debian12", interfaces=[ {"purpose": "public"}, ConfigInterface( @@ -329,10 +328,10 @@ def test_cluster_create_with_api_objects(test_linode_client): node_type = client.linode.types()[1] # g6-standard-1 version = client.lke.versions()[0] region = get_region(client, {"Kubernetes"}) - node_pools = client.lke.node_pool(node_type, 3) + node_pool = client.lke.node_pool(node_type, 3) label = get_test_label() - cluster = client.lke.cluster_create(region, label, node_pools, version) + cluster = client.lke.cluster_create(region, label, version, [node_pool]) assert cluster.region.id == region.id assert cluster.k8s_version.id == version.id @@ -351,8 +350,8 @@ def test_fails_to_create_cluster_with_invalid_version(test_linode_client): cluster = client.lke.cluster_create( region, "example-cluster", - {"type": "g6-standard-1", "count": 3}, invalid_version, + {"type": "g6-standard-1", "count": 3}, ) except ApiError as e: assert "not valid" in str(e.json) diff --git a/test/integration/login_client/test_login_client.py b/test/integration/login_client/test_login_client.py index 7cb4246ea..24519346c 100644 --- a/test/integration/login_client/test_login_client.py +++ b/test/integration/login_client/test_login_client.py @@ -27,6 +27,7 @@ def test_oauth_client_two(test_linode_client): oauth_client.delete() +@pytest.mark.smoke def test_get_oathclient(test_linode_client, test_oauth_client): client = test_linode_client @@ -96,6 +97,7 @@ def test_linode_login_client_generate_login_url_with_scope(linode_login_client): assert "scopes=linodes%3Aread_write" in url +@pytest.mark.skip("Endpoint may be deprecated") def test_linode_login_client_expire_token( linode_login_client, test_oauth_client ): diff --git a/test/integration/models/account/test_account.py b/test/integration/models/account/test_account.py index a9dce4a3a..4c4dcc134 100644 --- a/test/integration/models/account/test_account.py +++ b/test/integration/models/account/test_account.py @@ -1,6 +1,11 @@ import time from datetime import datetime -from test.integration.helpers import get_test_label +from test.integration.conftest import get_region +from test.integration.helpers import ( + get_test_label, + retry_sending_request, + wait_for_condition, +) import pytest @@ -36,7 +41,7 @@ def test_get_account(test_linode_client): def test_get_login(test_linode_client): client = test_linode_client - login = client.load(Login(client, "", {}), "") + login = retry_sending_request(3, client.load, Login(client, "", {}), "") updated_time = int(time.mktime(getattr(login, "_last_updated").timetuple())) @@ -58,27 +63,65 @@ def test_get_account_settings(test_linode_client): assert "longview_subscription" in str(account_settings._raw_json) assert "backups_enabled" in str(account_settings._raw_json) assert "object_storage" in str(account_settings._raw_json) + assert isinstance(account_settings.interfaces_for_new_linodes, str) + assert "maintenance_policy" in str(account_settings._raw_json) + + +def test_update_maintenance_policy(test_linode_client): + client = test_linode_client + settings = client.load(AccountSettings(client, ""), "") + + original_policy = settings.maintenance_policy + new_policy = ( + "linode/power_off_on" + if original_policy == "linode/migrate" + else "linode/migrate" + ) + + settings.maintenance_policy = new_policy + settings.save() + + updated = client.load(AccountSettings(client, ""), "") + assert updated.maintenance_policy == new_policy + + settings.maintenance_policy = original_policy + settings.save() + + updated = client.load(AccountSettings(client, ""), "") + assert updated.maintenance_policy == original_policy @pytest.mark.smoke -def test_latest_get_event(test_linode_client): +def test_latest_get_event(test_linode_client, e2e_test_firewall): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label() linode, password = client.linode.instance_create( - "g6-nanode-1", chosen_region, image="linode/debian10", label=label + "g6-nanode-1", + region, + image="linode/debian12", + label=label, + firewall=e2e_test_firewall, ) - events = client.load(Event, "") + def get_linode_status(): + return linode.status == "running" + + # To ensure the Linode is running and the 'event' key has been populated + wait_for_condition(3, 100, get_linode_status) - latest_event = events._raw_json.get("data")[0] + events = client.load(Event, "") + latest_events = events._raw_json.get("data")[:15] linode.delete() - assert label in latest_event["entity"]["label"] + for event in latest_events: + if label == event["entity"]["label"]: + break + else: + assert False, f"Linode '{label}' not found in the last 15 events" def test_get_user(test_linode_client): diff --git a/test/integration/models/database/helpers.py b/test/integration/models/database/helpers.py new file mode 100644 index 000000000..134e7e7c2 --- /dev/null +++ b/test/integration/models/database/helpers.py @@ -0,0 +1,132 @@ +from linode_api4 import LinodeClient +from linode_api4.objects import ( + MySQLDatabase, + MySQLDatabaseConfigMySQLOptions, + MySQLDatabaseConfigOptions, + PostgreSQLDatabase, + PostgreSQLDatabaseConfigOptions, + PostgreSQLDatabaseConfigPGOptions, +) + + +# Test Helpers +def get_db_engine_id(client: LinodeClient, engine: str): + engines = client.database.engines() + engine_id = "" + for e in engines: + if e.engine == engine: + engine_id = e.id + + return str(engine_id) + + +def get_sql_db_status(client: LinodeClient, db_id, status: str): + db = client.load(MySQLDatabase, db_id) + return db.status == status + + +def get_postgres_db_status(client: LinodeClient, db_id, status: str): + db = client.load(PostgreSQLDatabase, db_id) + return db.status == status + + +def make_full_mysql_engine_config(): + return MySQLDatabaseConfigOptions( + binlog_retention_period=600, + mysql=MySQLDatabaseConfigMySQLOptions( + connect_timeout=20, + default_time_zone="+00:00", + group_concat_max_len=1024, + information_schema_stats_expiry=900, + innodb_change_buffer_max_size=25, + innodb_flush_neighbors=1, + innodb_ft_min_token_size=3, + innodb_ft_server_stopword_table="db_name/table_name", + innodb_lock_wait_timeout=50, + innodb_log_buffer_size=16777216, + innodb_online_alter_log_max_size=134217728, + innodb_read_io_threads=4, + innodb_rollback_on_timeout=True, + innodb_thread_concurrency=8, + innodb_write_io_threads=4, + interactive_timeout=300, + internal_tmp_mem_storage_engine="TempTable", + max_allowed_packet=67108864, + max_heap_table_size=16777216, + net_buffer_length=16384, + net_read_timeout=30, + net_write_timeout=60, + sort_buffer_size=262144, + sql_mode="TRADITIONAL", + sql_require_primary_key=False, + tmp_table_size=16777216, + wait_timeout=28800, + ), + ) + + +def make_mysql_engine_config_w_nullable_field(): + return MySQLDatabaseConfigOptions( + mysql=MySQLDatabaseConfigMySQLOptions( + innodb_ft_server_stopword_table=None, + ), + ) + + +def make_full_postgres_engine_config(): + return PostgreSQLDatabaseConfigOptions( + pg=PostgreSQLDatabaseConfigPGOptions( + autovacuum_analyze_scale_factor=0.1, + autovacuum_analyze_threshold=50, + autovacuum_max_workers=3, + autovacuum_naptime=60, + autovacuum_vacuum_cost_delay=20, + autovacuum_vacuum_cost_limit=200, + autovacuum_vacuum_scale_factor=0.2, + autovacuum_vacuum_threshold=50, + bgwriter_delay=200, + bgwriter_flush_after=64, + bgwriter_lru_maxpages=100, + bgwriter_lru_multiplier=2.0, + deadlock_timeout=1000, + default_toast_compression="lz4", + idle_in_transaction_session_timeout=600000, + jit=True, + max_files_per_process=1000, + max_locks_per_transaction=64, + max_logical_replication_workers=4, + max_parallel_workers=4, + max_parallel_workers_per_gather=2, + max_pred_locks_per_transaction=64, + max_replication_slots=10, + max_slot_wal_keep_size=2048, + max_stack_depth=6291456, + max_standby_archive_delay=30000, + max_standby_streaming_delay=30000, + max_wal_senders=20, + max_worker_processes=8, + password_encryption="scram-sha-256", + temp_file_limit=1, + timezone="UTC", + track_activity_query_size=2048, + track_functions="all", + wal_sender_timeout=60000, + wal_writer_delay=200, + pg_partman_bgw_interval=3600, + pg_partman_bgw_role="myrolename", + pg_stat_monitor_pgsm_enable_query_plan=True, + pg_stat_monitor_pgsm_max_buckets=2, + pg_stat_statements_track="top", + ), + pg_stat_monitor_enable=True, + shared_buffers_percentage=25.0, + work_mem=1024, + ) + + +def make_postgres_engine_config_w_password_encryption_null(): + return PostgreSQLDatabaseConfigOptions( + pg=PostgreSQLDatabaseConfigPGOptions( + password_encryption=None, + ), + ) diff --git a/test/integration/models/database/test_database.py b/test/integration/models/database/test_database.py index b9502abdc..7092eca06 100644 --- a/test/integration/models/database/test_database.py +++ b/test/integration/models/database/test_database.py @@ -1,43 +1,23 @@ -import re +import os import time from test.integration.helpers import ( get_test_label, send_request_when_resource_available, wait_for_condition, ) +from test.integration.models.database.helpers import ( + get_db_engine_id, + get_postgres_db_status, + get_sql_db_status, +) import pytest -from linode_api4 import LinodeClient from linode_api4.objects import MySQLDatabase, PostgreSQLDatabase -# Test Helpers -def get_db_engine_id(client: LinodeClient, engine: str): - engines = client.database.engines() - engine_id = "" - for e in engines: - if e.engine == engine: - engine_id = e.id - - return str(engine_id) - - -def get_sql_db_status(client: LinodeClient, db_id, status: str): - db = client.load(MySQLDatabase, db_id) - return db.status == status - - -def get_postgres_db_status(client: LinodeClient, db_id, status: str): - db = client.load(PostgreSQLDatabase, db_id) - return db.status == status - - @pytest.fixture(scope="session") def test_create_sql_db(test_linode_client): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) client = test_linode_client label = get_test_label() + "-sqldb" region = "us-ord" @@ -65,9 +45,6 @@ def get_db_status(): @pytest.fixture(scope="session") def test_create_postgres_db(test_linode_client): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) client = test_linode_client label = get_test_label() + "-postgresqldb" region = "us-ord" @@ -93,108 +70,92 @@ def get_db_status(): send_request_when_resource_available(300, db.delete) -# ------- SQL DB Test cases ------- -def test_get_types(test_linode_client): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" +@pytest.mark.skipif( + os.getenv("RUN_DB_FORK_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_FORK_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_fork_sql_db(test_linode_client, test_create_sql_db): + client = test_linode_client + db_fork = client.database.mysql_fork( + test_create_sql_db.id, test_create_sql_db.updated ) + + def get_db_fork_status(): + return db_fork.status == "active" + + # TAKES 15-30 MINUTES TO FULLY PROVISION DB + wait_for_condition(60, 2000, get_db_fork_status) + + assert db_fork.fork.source == test_create_sql_db.id + + db_fork.delete() + + +@pytest.mark.skipif( + os.getenv("RUN_DB_FORK_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_FORK_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_fork_postgres_db(test_linode_client, test_create_postgres_db): + client = test_linode_client + db_fork = client.database.postgresql_fork( + test_create_postgres_db.id, test_create_postgres_db.updated + ) + + def get_db_fork_status(): + return db_fork.status == "active" + + # TAKES 15-30 MINUTES TO FULLY PROVISION DB + wait_for_condition(60, 2000, get_db_fork_status) + + assert db_fork.fork.source == test_create_postgres_db.id + + db_fork.delete() + + +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_get_types(test_linode_client): client = test_linode_client types = client.database.types() assert "nanode" in types[0].type_class assert "g6-nanode-1" in types[0].id - assert types[0].engines.mongodb[0].price.monthly == 15 +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) def test_get_engines(test_linode_client): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) client = test_linode_client engines = client.database.engines() for e in engines: assert e.engine in ["mysql", "postgresql"] - assert re.search("[0-9]+.[0-9]+", e.version) + # assert re.search("[0-9]+.[0-9]+", e.version) assert e.id == e.engine + "/" + e.version +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) def test_database_instance(test_linode_client, test_create_sql_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) dbs = test_linode_client.database.mysql_instances() assert str(test_create_sql_db.id) in str(dbs.lists) -# ------- POSTGRESQL DB Test cases ------- -def test_get_sql_db_instance(test_linode_client, test_create_sql_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) - dbs = test_linode_client.database.mysql_instances() - database = "" - for db in dbs: - if db.id == test_create_sql_db.id: - database = db - - assert str(test_create_sql_db.id) == str(database.id) - assert str(test_create_sql_db.label) == str(database.label) - assert database.cluster_size == 1 - assert database.engine == "mysql" - assert "-mysql-primary.servers.linodedb.net" in database.hosts.primary - - -def test_update_sql_db(test_linode_client, test_create_sql_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) - db = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) - - new_allow_list = ["192.168.0.1/32"] - label = get_test_label() + "updatedSQLDB" - - db.allow_list = new_allow_list - db.updates.day_of_week = 2 - db.label = label - - res = db.save() - - database = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) - - wait_for_condition( - 30, - 300, - get_sql_db_status, - test_linode_client, - test_create_sql_db.id, - "active", - ) - - assert res - assert database.allow_list == new_allow_list - assert database.label == label - assert database.updates.day_of_week == 2 - - -def test_create_sql_backup(test_linode_client, test_create_sql_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_mysql_suspend_resume(test_linode_client, test_create_sql_db): db = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) - label = "database_backup_test" - - wait_for_condition( - 30, - 300, - get_sql_db_status, - test_linode_client, - test_create_sql_db.id, - "active", - ) - db.backup_create(label=label, target="secondary") + db.suspend() wait_for_condition( 10, @@ -202,12 +163,13 @@ def test_create_sql_backup(test_linode_client, test_create_sql_db): get_sql_db_status, test_linode_client, test_create_sql_db.id, - "backing_up", + "suspended", ) - assert db.status == "backing_up" + assert db.status == "suspended" + + db.resume() - # list backup and most recently created one is first element of the array wait_for_condition( 30, 600, @@ -217,66 +179,79 @@ def test_create_sql_backup(test_linode_client, test_create_sql_db): "active", ) - backup = db.backups[0] + assert db.status == "active" - assert backup.label == label - assert backup.database_id == test_create_sql_db.id - assert db.status == "active" +# ------- POSTGRESQL DB Test cases ------- +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_get_sql_db_instance(test_linode_client, test_create_sql_db): + dbs = test_linode_client.database.mysql_instances() + database = "" + for db in dbs: + if db.id == test_create_sql_db.id: + database = db - backup.delete() + assert str(test_create_sql_db.id) == str(database.id) + assert str(test_create_sql_db.label) == str(database.label) + assert database.cluster_size == 1 + assert database.engine == "mysql" + assert ".g2a.akamaidb.net" in database.hosts.primary -def test_sql_backup_restore(test_linode_client, test_create_sql_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_update_sql_db(test_linode_client, test_create_sql_db): db = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) - try: - backup = db.backups[0] - except IndexError as e: - pytest.skip( - "Skipping this test. Reason: Couldn't find db backup instance" - ) - backup.restore() + new_allow_list = ["192.168.0.1/32"] + label = get_test_label() + "updatedSQLDB" - wait_for_condition( - 10, - 300, - get_sql_db_status, - test_linode_client, - test_create_sql_db.id, - "restoring", - ) + db.allow_list = new_allow_list + db.updates.day_of_week = 2 + db.label = label - assert db.status == "restoring" + res = db.save() wait_for_condition( 30, - 1000, + 300, get_sql_db_status, test_linode_client, test_create_sql_db.id, "active", ) - assert db.status == "active" + database = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) + + assert res + assert database.allow_list == new_allow_list + # Label assertion is commented out because the API updates + # the label intermittently, causing test failures. The issue + # is tracked in TPT-4268. + # assert database.label == label + assert database.updates.day_of_week == 2 +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) def test_get_sql_ssl(test_linode_client, test_create_sql_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) db = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) assert "ca_certificate" in str(db.ssl) +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) def test_sql_patch(test_linode_client, test_create_sql_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) db = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) db.patch() @@ -304,40 +279,42 @@ def test_sql_patch(test_linode_client, test_create_sql_db): assert db.status == "active" +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) def test_get_sql_credentials(test_linode_client, test_create_sql_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) db = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) - assert db.credentials.username == "linroot" + assert db.credentials.username == "akmadmin" assert db.credentials.password +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) def test_reset_sql_credentials(test_linode_client, test_create_sql_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) db = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) old_pass = str(db.credentials.password) - - print(old_pass) db.credentials_reset() time.sleep(5) - - assert db.credentials.username == "linroot" + assert db.credentials.username == "akmadmin" assert db.credentials.password != old_pass # ------- POSTGRESQL DB Test cases ------- +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) def test_get_postgres_db_instance(test_linode_client, test_create_postgres_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) dbs = test_linode_client.database.postgresql_instances() + database = None + for db in dbs: if db.id == test_create_postgres_db.id: database = db @@ -346,13 +323,14 @@ def test_get_postgres_db_instance(test_linode_client, test_create_postgres_db): assert str(test_create_postgres_db.label) == str(database.label) assert database.cluster_size == 1 assert database.engine == "postgresql" - assert "pgsql-primary.servers.linodedb.net" in database.hosts.primary + assert "g2a.akamaidb.net" in database.hosts.primary +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) def test_update_postgres_db(test_linode_client, test_create_postgres_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) db = test_linode_client.load(PostgreSQLDatabase, test_create_postgres_db.id) new_allow_list = ["192.168.0.1/32"] @@ -364,10 +342,6 @@ def test_update_postgres_db(test_linode_client, test_create_postgres_db): res = db.save() - database = test_linode_client.load( - PostgreSQLDatabase, test_create_postgres_db.id - ) - wait_for_condition( 30, 1000, @@ -377,114 +351,99 @@ def test_update_postgres_db(test_linode_client, test_create_postgres_db): "active", ) + database = test_linode_client.load( + PostgreSQLDatabase, test_create_postgres_db.id + ) + assert res assert database.allow_list == new_allow_list - assert database.label == label + # Label assertion is commented out because the API updates + # the label intermittently, causing test failures. The issue + # is tracked in TPT-4268. + # assert database.label == label assert database.updates.day_of_week == 2 -def test_create_postgres_backup(test_linode_client, test_create_postgres_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) - pytest.skip( - "Failing due to '400: The backup snapshot request failed, please contact support.'" - ) +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_get_postgres_ssl(test_linode_client, test_create_postgres_db): db = test_linode_client.load(PostgreSQLDatabase, test_create_postgres_db.id) - label = "database_backup_test" - wait_for_condition( - 30, - 1000, - get_postgres_db_status, - test_linode_client, - test_create_postgres_db.id, - "active", - ) + assert "ca_certificate" in str(db.ssl) + - db.backup_create(label=label, target="secondary") +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_postgres_patch(test_linode_client, test_create_postgres_db): + db = test_linode_client.load(PostgreSQLDatabase, test_create_postgres_db.id) + + db.patch() - # list backup and most recently created one is first element of the array wait_for_condition( 10, 300, - get_sql_db_status, + get_postgres_db_status, test_linode_client, test_create_postgres_db.id, - "backing_up", + "updating", ) - assert db.status == "backing_up" + assert db.status == "updating" - # list backup and most recently created one is first element of the array wait_for_condition( 30, 600, - get_sql_db_status, + get_postgres_db_status, test_linode_client, test_create_postgres_db.id, "active", ) - # list backup and most recently created one is first element of the array - backup = db.backups[0] - - assert backup.label == label - assert backup.database_id == test_create_postgres_db.id + assert db.status == "active" -def test_postgres_backup_restore(test_linode_client, test_create_postgres_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_get_postgres_credentials(test_linode_client, test_create_postgres_db): db = test_linode_client.load(PostgreSQLDatabase, test_create_postgres_db.id) - try: - backup = db.backups[0] - except IndexError as e: - pytest.skip( - "Skipping this test. Reason: Couldn't find db backup instance" - ) - - backup.restore() + assert db.credentials.username == "akmadmin" + assert db.credentials.password - wait_for_condition( - 30, - 1000, - get_postgres_db_status, - test_linode_client, - test_create_postgres_db.id, - "restoring", - ) - wait_for_condition( - 30, - 1000, - get_postgres_db_status, - test_linode_client, - test_create_postgres_db.id, - "active", - ) +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_reset_postgres_credentials( + test_linode_client, test_create_postgres_db +): + db = test_linode_client.load(PostgreSQLDatabase, test_create_postgres_db.id) - assert db.status == "active" + old_pass = str(db.credentials.password) + db.credentials_reset() -def test_get_postgres_ssl(test_linode_client, test_create_postgres_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) - db = test_linode_client.load(PostgreSQLDatabase, test_create_postgres_db.id) + time.sleep(5) - assert "ca_certificate" in str(db.ssl) + assert db.credentials.username == "akmadmin" + assert db.credentials.password != old_pass -def test_postgres_patch(test_linode_client, test_create_postgres_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_postgres_suspend_resume(test_linode_client, test_create_postgres_db): db = test_linode_client.load(PostgreSQLDatabase, test_create_postgres_db.id) - db.patch() + db.suspend() wait_for_condition( 10, @@ -492,10 +451,12 @@ def test_postgres_patch(test_linode_client, test_create_postgres_db): get_postgres_db_status, test_linode_client, test_create_postgres_db.id, - "updating", + "suspended", ) - assert db.status == "updating" + assert db.status == "suspended" + + db.resume() wait_for_condition( 30, @@ -507,31 +468,3 @@ def test_postgres_patch(test_linode_client, test_create_postgres_db): ) assert db.status == "active" - - -def test_get_postgres_credentials(test_linode_client, test_create_postgres_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) - db = test_linode_client.load(PostgreSQLDatabase, test_create_postgres_db.id) - - assert db.credentials.username == "linpostgres" - assert db.credentials.password - - -def test_reset_postgres_credentials( - test_linode_client, test_create_postgres_db -): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) - db = test_linode_client.load(PostgreSQLDatabase, test_create_postgres_db.id) - - old_pass = str(db.credentials.password) - - db.credentials_reset() - - time.sleep(5) - - assert db.credentials.username == "linpostgres" - assert db.credentials.password != old_pass diff --git a/test/integration/models/database/test_database_engine_config.py b/test/integration/models/database/test_database_engine_config.py new file mode 100644 index 000000000..184b63522 --- /dev/null +++ b/test/integration/models/database/test_database_engine_config.py @@ -0,0 +1,475 @@ +import os +from test.integration.helpers import ( + get_test_label, + send_request_when_resource_available, + wait_for_condition, +) +from test.integration.models.database.helpers import ( + get_db_engine_id, + get_postgres_db_status, + get_sql_db_status, + make_full_mysql_engine_config, + make_full_postgres_engine_config, + make_mysql_engine_config_w_nullable_field, + make_postgres_engine_config_w_password_encryption_null, +) + +import pytest + +from linode_api4.errors import ApiError +from linode_api4.objects import ( + MySQLDatabase, + MySQLDatabaseConfigMySQLOptions, + MySQLDatabaseConfigOptions, + PostgreSQLDatabase, + PostgreSQLDatabaseConfigOptions, + PostgreSQLDatabaseConfigPGOptions, +) + + +@pytest.fixture(scope="session") +def mysql_db_with_engine_config(test_linode_client): + client = test_linode_client + label = get_test_label() + "-sqldb" + region = "us-ord" + engine_id = get_db_engine_id(client, "mysql") + dbtype = "g6-standard-1" + + db = client.database.mysql_create( + label=label, + region=region, + engine=engine_id, + ltype=dbtype, + cluster_size=None, + engine_config=make_full_mysql_engine_config(), + ) + + def get_db_status(): + return db.status == "active" + + # Usually take 10-15m to provision + wait_for_condition(60, 2000, get_db_status) + + yield db + + send_request_when_resource_available(300, db.delete) + + +@pytest.fixture(scope="session") +def postgres_db_with_engine_config(test_linode_client): + client = test_linode_client + label = get_test_label() + "-postgresqldb" + region = "us-ord" + engine_id = "postgresql/17" + dbtype = "g6-standard-1" + + db = client.database.postgresql_create( + label=label, + region=region, + engine=engine_id, + ltype=dbtype, + cluster_size=None, + engine_config=make_full_postgres_engine_config(), + ) + + def get_db_status(): + return db.status == "active" + + # Usually take 10-15m to provision + wait_for_condition(60, 2000, get_db_status) + + yield db + + send_request_when_resource_available(300, db.delete) + + +# MYSQL +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_get_mysql_config(test_linode_client): + config = test_linode_client.database.mysql_config_options() + + # Top-level keys + assert "binlog_retention_period" in config + assert "mysql" in config + + # binlog_retention_period checks + brp = config["binlog_retention_period"] + assert isinstance(brp, dict) + assert brp["type"] == "integer" + assert brp["minimum"] == 600 + assert brp["maximum"] == 9007199254740991 + assert brp["requires_restart"] is False + + # mysql sub-keys + mysql = config["mysql"] + + # mysql valid fields + expected_keys = [ + "connect_timeout", + "default_time_zone", + "group_concat_max_len", + "information_schema_stats_expiry", + "innodb_change_buffer_max_size", + "innodb_flush_neighbors", + "innodb_ft_min_token_size", + "innodb_ft_server_stopword_table", + "innodb_lock_wait_timeout", + "innodb_log_buffer_size", + "innodb_online_alter_log_max_size", + "innodb_read_io_threads", + "innodb_rollback_on_timeout", + "innodb_thread_concurrency", + "innodb_write_io_threads", + "interactive_timeout", + "internal_tmp_mem_storage_engine", + "max_allowed_packet", + "max_heap_table_size", + "net_buffer_length", + "net_read_timeout", + "net_write_timeout", + "sort_buffer_size", + "sql_mode", + "sql_require_primary_key", + "tmp_table_size", + "wait_timeout", + ] + + # Assert all valid fields are present + for key in expected_keys: + assert key in mysql, f"{key} not found in mysql config" + + assert mysql["connect_timeout"]["type"] == "integer" + assert mysql["default_time_zone"]["type"] == "string" + assert mysql["innodb_rollback_on_timeout"]["type"] == "boolean" + assert "enum" in mysql["internal_tmp_mem_storage_engine"] + assert "pattern" in mysql["sql_mode"] + + +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_create_mysql_with_engine_config(mysql_db_with_engine_config): + db = mysql_db_with_engine_config + actual_config = db.engine_config.mysql + expected_config = make_full_mysql_engine_config().mysql.__dict__ + + for key, expected_value in expected_config.items(): + actual_value = getattr(actual_config, key) + assert ( + actual_value == expected_value + ), f"{key} mismatch: expected {expected_value}, got {actual_value}" + + +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_update_mysql_engine_config( + test_linode_client, mysql_db_with_engine_config +): + db = mysql_db_with_engine_config + + db.updates.day_of_week = 2 + db.engine_config = MySQLDatabaseConfigOptions( + mysql=MySQLDatabaseConfigMySQLOptions(connect_timeout=50), + binlog_retention_period=880, + ) + + db.save() + + wait_for_condition( + 30, + 300, + get_sql_db_status, + test_linode_client, + db.id, + "active", + ) + + database = test_linode_client.load(MySQLDatabase, db.id) + + assert database.updates.day_of_week == 2 + assert database.engine_config.mysql.connect_timeout == 50 + assert database.engine_config.binlog_retention_period == 880 + + +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_list_mysql_engine_config( + test_linode_client, mysql_db_with_engine_config +): + dbs = test_linode_client.database.mysql_instances() + + db_ids = [db.id for db in dbs] + + assert mysql_db_with_engine_config.id in db_ids + + +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_get_mysql_engine_config( + test_linode_client, mysql_db_with_engine_config +): + db = test_linode_client.load(MySQLDatabase, mysql_db_with_engine_config.id) + + assert isinstance(db, MySQLDatabase) + + +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_create_mysql_db_nullable_field(test_linode_client): + client = test_linode_client + label = get_test_label(5) + "-sqldb" + region = "us-ord" + engine_id = get_db_engine_id(client, "mysql") + dbtype = "g6-standard-1" + + db = client.database.mysql_create( + label=label, + region=region, + engine=engine_id, + ltype=dbtype, + cluster_size=None, + engine_config=make_mysql_engine_config_w_nullable_field(), + ) + + assert db.engine_config.mysql.innodb_ft_server_stopword_table is None + + send_request_when_resource_available(300, db.delete) + + +# POSTGRESQL +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_get_postgres_config(test_linode_client): + config = test_linode_client.database.postgresql_config_options() + + # Top-level keys and structure + assert "pg" in config + + assert "pg_stat_monitor_enable" in config + assert config["pg_stat_monitor_enable"]["type"] == "boolean" + + assert "shared_buffers_percentage" in config + assert config["shared_buffers_percentage"]["type"] == "number" + assert config["shared_buffers_percentage"]["minimum"] >= 1 + + assert "work_mem" in config + assert config["work_mem"]["type"] == "integer" + assert "minimum" in config["work_mem"] + + pg = config["pg"] + + # postgres valid fields + expected_keys = [ + "autovacuum_analyze_scale_factor", + "autovacuum_analyze_threshold", + "autovacuum_max_workers", + "autovacuum_naptime", + "autovacuum_vacuum_cost_delay", + "autovacuum_vacuum_cost_limit", + "autovacuum_vacuum_scale_factor", + "autovacuum_vacuum_threshold", + "bgwriter_delay", + "bgwriter_flush_after", + "bgwriter_lru_maxpages", + "bgwriter_lru_multiplier", + "deadlock_timeout", + "default_toast_compression", + "idle_in_transaction_session_timeout", + "jit", + "max_files_per_process", + "max_locks_per_transaction", + "max_logical_replication_workers", + "max_parallel_workers", + "max_parallel_workers_per_gather", + "max_pred_locks_per_transaction", + "max_replication_slots", + "max_slot_wal_keep_size", + "max_stack_depth", + "max_standby_archive_delay", + "max_standby_streaming_delay", + "max_wal_senders", + "max_worker_processes", + "password_encryption", + "pg_partman_bgw.interval", + "pg_partman_bgw.role", + "pg_stat_monitor.pgsm_enable_query_plan", + "pg_stat_monitor.pgsm_max_buckets", + "pg_stat_statements.track", + "temp_file_limit", + "timezone", + "track_activity_query_size", + "track_commit_timestamp", + "track_functions", + "track_io_timing", + "wal_sender_timeout", + "wal_writer_delay", + ] + + # Assert all valid fields are present + for key in expected_keys: + assert key in pg, f"{key} not found in postgresql config" + + assert pg["autovacuum_analyze_scale_factor"]["type"] == "number" + assert pg["autovacuum_analyze_threshold"]["type"] == "integer" + assert pg["autovacuum_max_workers"]["requires_restart"] is True + assert pg["default_toast_compression"]["enum"] == ["lz4", "pglz"] + assert pg["jit"]["type"] == "boolean" + assert "enum" in pg["password_encryption"] + assert "pattern" in pg["pg_partman_bgw.role"] + assert pg["pg_stat_monitor.pgsm_enable_query_plan"]["type"] == "boolean" + assert pg["pg_stat_monitor.pgsm_max_buckets"]["requires_restart"] is True + assert pg["pg_stat_statements.track"]["enum"] == ["all", "top", "none"] + assert pg["track_commit_timestamp"]["enum"] == ["off", "on"] + assert pg["track_functions"]["enum"] == ["all", "pl", "none"] + assert pg["track_io_timing"]["enum"] == ["off", "on"] + + +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_create_postgres_with_engine_config( + test_linode_client, postgres_db_with_engine_config +): + db = postgres_db_with_engine_config + actual_config = db.engine_config.pg + expected_config = make_full_postgres_engine_config().pg.__dict__ + + for key, expected_value in expected_config.items(): + actual_value = getattr(actual_config, key, None) + assert ( + actual_value is None or actual_value == expected_value + ), f"{key} mismatch: expected {expected_value}, got {actual_value}" + + +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_update_postgres_engine_config( + test_linode_client, postgres_db_with_engine_config +): + db = postgres_db_with_engine_config + + db.updates.day_of_week = 2 + db.engine_config = PostgreSQLDatabaseConfigOptions( + pg=PostgreSQLDatabaseConfigPGOptions( + autovacuum_analyze_threshold=70, deadlock_timeout=2000 + ), + shared_buffers_percentage=25.0, + ) + + db.save() + + wait_for_condition( + 30, + 300, + get_postgres_db_status, + test_linode_client, + db.id, + "active", + ) + + database = test_linode_client.load(PostgreSQLDatabase, db.id) + + assert database.updates.day_of_week == 2 + assert database.engine_config.pg.autovacuum_analyze_threshold == 70 + assert database.engine_config.pg.deadlock_timeout == 2000 + assert database.engine_config.shared_buffers_percentage == 25.0 + + +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_create_pg13_with_lz4_error(test_linode_client): + client = test_linode_client + label = get_test_label() + "-postgresqldb" + region = "us-ord" + engine_id = get_db_engine_id(client, "postgresql/13") + dbtype = "g6-standard-1" + + try: + client.database.postgresql_create( + label=label, + region=region, + engine=engine_id, + ltype=dbtype, + cluster_size=None, + engine_config=PostgreSQLDatabaseConfigOptions( + pg=PostgreSQLDatabaseConfigPGOptions( + default_toast_compression="lz4" + ), + work_mem=4, + ), + ) + except ApiError as e: + assert "An error occurred" in str(e.json) + assert e.status == 500 + + +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_list_postgres_engine_config( + test_linode_client, postgres_db_with_engine_config +): + dbs = test_linode_client.database.postgresql_instances() + + db_ids = [db.id for db in dbs] + + assert postgres_db_with_engine_config.id in db_ids + + +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_get_postgres_engine_config( + test_linode_client, postgres_db_with_engine_config +): + db = test_linode_client.load( + PostgreSQLDatabase, postgres_db_with_engine_config.id + ) + + assert isinstance(db, PostgreSQLDatabase) + + +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_create_postgres_db_password_encryption_default_md5(test_linode_client): + client = test_linode_client + label = get_test_label() + "-postgresqldb" + region = "us-ord" + engine_id = "postgresql/17" + dbtype = "g6-standard-1" + + db = client.database.postgresql_create( + label=label, + region=region, + engine=engine_id, + ltype=dbtype, + cluster_size=None, + engine_config=make_postgres_engine_config_w_password_encryption_null(), + ) + + assert db.engine_config.pg.password_encryption == "md5" + + send_request_when_resource_available(300, db.delete) diff --git a/test/integration/models/domain/test_domain.py b/test/integration/models/domain/test_domain.py index cf5a54710..d7956d421 100644 --- a/test/integration/models/domain/test_domain.py +++ b/test/integration/models/domain/test_domain.py @@ -23,8 +23,6 @@ def test_save_null_values_excluded(test_linode_client, test_domain): domain.master_ips = ["127.0.0.1"] res = domain.save() - assert res - def test_zone_file_view(test_linode_client, test_domain): domain = test_linode_client.load(Domain, test_domain.id) @@ -42,13 +40,13 @@ def get_zone_file_view(): def test_clone(test_linode_client, test_domain): domain = test_linode_client.load(Domain, test_domain.id) timestamp = str(time.time_ns()) - dom = "example.clone-" + timestamp + "-IntTestSDK.org" + dom = "example.clone-" + timestamp + "-inttestsdk.org" domain.clone(dom) - ds = test_linode_client.domains() - time.sleep(1) + ds = test_linode_client.domains() + domains = [i.domain for i in ds] assert dom in domains diff --git a/test/integration/models/firewall/test_firewall.py b/test/integration/models/firewall/test_firewall.py index 7a7f58ff1..16805f3b8 100644 --- a/test/integration/models/firewall/test_firewall.py +++ b/test/integration/models/firewall/test_firewall.py @@ -1,4 +1,6 @@ import time +from test.integration.conftest import get_region +from test.integration.helpers import get_test_label import pytest @@ -8,12 +10,11 @@ @pytest.fixture(scope="session") def linode_fw(test_linode_client): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] - label = "linode_instance_fw_device" + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") + label = get_test_label() linode_instance, password = client.linode.instance_create( - "g6-nanode-1", chosen_region, image="linode/debian10", label=label + "g6-nanode-1", region, image="linode/debian12", label=label ) yield linode_instance @@ -79,6 +80,5 @@ def test_get_device(test_linode_client, test_firewall, linode_fw): FirewallDevice, firewall.devices.first().id, firewall.id ) - assert firewall_device.entity.label == "linode_instance_fw_device" assert firewall_device.entity.type == "linode" assert "/v4/linode/instances/" in firewall_device.entity.url diff --git a/test/integration/models/firewall/test_firewall_templates.py b/test/integration/models/firewall/test_firewall_templates.py new file mode 100644 index 000000000..11d6ccb6f --- /dev/null +++ b/test/integration/models/firewall/test_firewall_templates.py @@ -0,0 +1,33 @@ +from linode_api4 import FirewallTemplate, MappedObject + + +def __assert_firewall_template_rules(rules: MappedObject): + # We can't confidently say that these rules will not be changed + # in the future, so we can just do basic assertions here. + assert isinstance(rules.inbound_policy, str) + assert len(rules.inbound_policy) > 0 + + assert isinstance(rules.outbound_policy, str) + assert len(rules.outbound_policy) > 0 + + assert isinstance(rules.outbound, list) + assert isinstance(rules.inbound, list) + + +def test_list_firewall_templates(test_linode_client): + templates = test_linode_client.networking.firewall_templates() + assert len(templates) > 0 + + for template in templates: + assert isinstance(template.slug, str) + assert len(template.slug) > 0 + + __assert_firewall_template_rules(template.rules) + + +def test_get_firewall_template(test_linode_client): + template = test_linode_client.load(FirewallTemplate, "vpc") + + assert template.slug == "vpc" + + __assert_firewall_template_rules(template.rules) diff --git a/test/integration/models/image/test_image.py b/test/integration/models/image/test_image.py index 5c4025dfc..18e223ff0 100644 --- a/test/integration/models/image/test_image.py +++ b/test/integration/models/image/test_image.py @@ -1,21 +1,47 @@ from io import BytesIO -from test.integration.conftest import get_region -from test.integration.helpers import ( - delete_instance_with_test_kw, - get_test_label, -) +from test.integration.conftest import get_regions +from test.integration.helpers import get_test_label import polling import pytest +from linode_api4 import LinodeClient from linode_api4.objects import Image +DISALLOWED_IMAGE_REGIONS = { + "gb-lon", + "au-mel", + "sg-sin-2", + "jp-tyo-3", + "no-osl-1", +} + + +def get_image_upload_regions(client: LinodeClient): + """ + This is necessary because the API does not currently expose + a capability for regions that allow custom image uploads. + + In the future, we should remove this if the API exposes a custom images capability or + if all Object Storage regions support custom images. + """ + + return [ + region + for region in get_regions( + client, + capabilities={"Linodes", "Object Storage"}, + site_type="core", + ) + if region.id not in DISALLOWED_IMAGE_REGIONS + ] + @pytest.fixture(scope="session") def image_upload_url(test_linode_client): label = get_test_label() + "_image" - region = get_region(test_linode_client, site_type="core") + region = get_image_upload_regions(test_linode_client)[0] test_linode_client.image_create_upload( label, region.id, "integration test image upload" @@ -26,28 +52,28 @@ def image_upload_url(test_linode_client): yield image image.delete() - images = test_linode_client.images() - delete_instance_with_test_kw(images) @pytest.fixture(scope="session") def test_uploaded_image(test_linode_client): test_image_content = ( - b"\x1F\x8B\x08\x08\xBD\x5C\x91\x60\x00\x03\x74\x65\x73\x74\x2E\x69" - b"\x6D\x67\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x1f\x8b\x08\x08\xbd\x5c\x91\x60\x00\x03\x74\x65\x73\x74\x2e\x69" + b"\x6d\x67\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00" ) label = get_test_label() + "_image" + regions = get_image_upload_regions(test_linode_client) + image = test_linode_client.image_upload( label, - "us-east", + regions[1].id, BytesIO(test_image_content), description="integration test image upload", tags=["tests"], ) - yield image + yield image, regions image.delete() @@ -60,16 +86,21 @@ def test_get_image(test_linode_client, image_upload_url): def test_image_create_upload(test_linode_client, test_uploaded_image): - image = test_linode_client.load(Image, test_uploaded_image.id) + uploaded_image, _ = test_uploaded_image + + image = test_linode_client.load(Image, uploaded_image.id) - assert image.label == test_uploaded_image.label + assert image.label == uploaded_image.label assert image.description == "integration test image upload" assert image.tags[0] == "tests" @pytest.mark.smoke +@pytest.mark.flaky(reruns=3, reruns_delay=2) def test_image_replication(test_linode_client, test_uploaded_image): - image = test_linode_client.load(Image, test_uploaded_image.id) + uploaded_image, regions = test_uploaded_image + + image = test_linode_client.load(Image, uploaded_image.id) # wait for image to be available for replication def poll_func() -> bool: @@ -85,8 +116,10 @@ def poll_func() -> bool: except polling.TimeoutException: print("failed to wait for image status: timeout period expired.") - # image replication works stably in these two regions - image.replicate(["us-east", "eu-west"]) + replicate_regions = [r.id for r in regions[:2]] + image.replicate(replicate_regions) - assert image.label == test_uploaded_image.label + assert image.label == uploaded_image.label assert len(image.regions) == 2 + assert image.regions[0].region in replicate_regions + assert image.regions[1].region in replicate_regions diff --git a/test/integration/models/linode/interfaces/test_interfaces.py b/test/integration/models/linode/interfaces/test_interfaces.py new file mode 100644 index 000000000..650a9cb6c --- /dev/null +++ b/test/integration/models/linode/interfaces/test_interfaces.py @@ -0,0 +1,361 @@ +import copy +import ipaddress + +import pytest + +from linode_api4 import ( + ApiError, + Instance, + LinodeInterface, + LinodeInterfaceDefaultRouteOptions, + LinodeInterfacePublicIPv4AddressOptions, + LinodeInterfacePublicIPv4Options, + LinodeInterfacePublicIPv6Options, + LinodeInterfacePublicIPv6RangeOptions, + LinodeInterfacePublicOptions, + LinodeInterfaceVLANOptions, + LinodeInterfaceVPCIPv4AddressOptions, + LinodeInterfaceVPCIPv4Options, + LinodeInterfaceVPCIPv4RangeOptions, + LinodeInterfaceVPCOptions, +) + + +def test_linode_create_with_linode_interfaces( + create_vpc_with_subnet, + linode_with_linode_interfaces, +): + instance: Instance = linode_with_linode_interfaces + vpc, subnet = create_vpc_with_subnet + + def __assert_base(iface: LinodeInterface): + assert iface.id is not None + assert iface.linode_id == instance.id + + assert iface.created is not None + assert iface.updated is not None + + assert isinstance(iface.mac_address, str) + assert iface.version + + def __assert_public(iface: LinodeInterface): + __assert_base(iface) + + assert iface.default_route.ipv4 + assert iface.default_route.ipv6 + + assert iface.public.ipv4.addresses[0].address == instance.ipv4[0] + assert iface.public.ipv4.addresses[0].primary + assert len(iface.public.ipv4.shared) == 0 + + assert iface.public.ipv6.slaac[0].address == instance.ipv6.split("/")[0] + assert iface.public.ipv6.slaac[0].prefix == 64 + assert len(iface.public.ipv6.shared) == 0 + assert len(iface.public.ipv6.ranges) == 0 + + def __assert_vpc(iface: LinodeInterface): + __assert_base(iface) + + assert not iface.default_route.ipv4 + assert not iface.default_route.ipv6 + + assert iface.vpc.vpc_id == vpc.id + assert iface.vpc.subnet_id == subnet.id + + assert ipaddress.ip_address( + iface.vpc.ipv4.addresses[0].address + ) in ipaddress.ip_network(subnet.ipv4) + assert iface.vpc.ipv4.addresses[0].primary + assert iface.vpc.ipv4.addresses[0].nat_1_1_address is None + + assert len(iface.vpc.ipv4.ranges) == 0 + + slaac_entry = iface.vpc.ipv6.slaac[0] + assert ipaddress.ip_address( + slaac_entry.address + ) in ipaddress.ip_network(slaac_entry.range) + assert not iface.vpc.ipv6.is_public + assert len(iface.vpc.ipv6.ranges) == 0 + + def __assert_vlan(iface: LinodeInterface): + __assert_base(iface) + + assert not iface.default_route.ipv4 + assert not iface.default_route.ipv6 + + assert iface.vlan.vlan_label == "test-vlan" + assert iface.vlan.ipam_address == "10.0.0.5/32" + + __assert_public(instance.linode_interfaces[0]) + __assert_vpc(instance.linode_interfaces[1]) + __assert_vlan(instance.linode_interfaces[2]) + + instance.invalidate() + + __assert_public(instance.linode_interfaces[0]) + __assert_vpc(instance.linode_interfaces[1]) + __assert_vlan(instance.linode_interfaces[2]) + + +@pytest.fixture +def linode_interface_public( + test_linode_client, + e2e_test_firewall, + linode_with_interface_generation_linode, +): + instance: Instance = linode_with_interface_generation_linode + + ipv6_range = test_linode_client.networking.ipv6_range_allocate( + 64, linode=instance.id + ) + + yield instance.interface_create( + firewall_id=e2e_test_firewall.id, + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ipv6=True, + ), + public=LinodeInterfacePublicOptions( + ipv4=LinodeInterfacePublicIPv4Options( + addresses=[ + LinodeInterfacePublicIPv4AddressOptions( + address=instance.ips.ipv4.public[0].address, + primary=True, + ) + ] + ), + ipv6=LinodeInterfacePublicIPv6Options( + ranges=[ + LinodeInterfacePublicIPv6RangeOptions( + range=ipv6_range.range, + ) + ] + ), + ), + ), instance, ipv6_range + + +@pytest.fixture +def linode_interface_vpc( + test_linode_client, + e2e_test_firewall, + linode_with_interface_generation_linode, + create_vpc_with_subnet, +): + instance: Instance = linode_with_interface_generation_linode + vpc, subnet = create_vpc_with_subnet + + yield instance.interface_create( + firewall_id=e2e_test_firewall.id, + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ), + vpc=LinodeInterfaceVPCOptions( + subnet_id=subnet.id, + ipv4=LinodeInterfaceVPCIPv4Options( + addresses=[ + LinodeInterfaceVPCIPv4AddressOptions( + address="auto", + primary=True, + nat_1_1_address=None, + ) + ], + ranges=[ + LinodeInterfaceVPCIPv4RangeOptions( + range="/32", + ) + ], + ), + ), + ), instance, vpc, subnet + + +@pytest.fixture +def linode_interface_vlan( + test_linode_client, + e2e_test_firewall, + linode_with_interface_generation_linode, + create_vpc_with_subnet, +): + instance: Instance = linode_with_interface_generation_linode + + yield instance.interface_create( + vlan=LinodeInterfaceVLANOptions( + vlan_label="test-vlan", ipam_address="10.0.0.5/32" + ), + ), instance + + +def test_linode_interface_create_public(linode_interface_public): + iface, instance, ipv6_range = linode_interface_public + + assert iface.id is not None + assert iface.linode_id == instance.id + + assert iface.created is not None + assert iface.updated is not None + + assert isinstance(iface.mac_address, str) + assert iface.version + + assert iface.default_route.ipv4 + assert iface.default_route.ipv6 + + assert ( + iface.public.ipv4.addresses[0].address + == instance.ips.ipv4.public[0].address + ) + assert iface.public.ipv4.addresses[0].primary + assert len(iface.public.ipv4.shared) == 0 + + assert iface.public.ipv6.ranges[0].range == ipv6_range.range + assert ( + iface.public.ipv6.ranges[0].route_target == instance.ipv6.split("/")[0] + ) + assert iface.public.ipv6.slaac[0].address == instance.ipv6.split("/")[0] + assert iface.public.ipv6.slaac[0].prefix == 64 + assert len(iface.public.ipv6.shared) == 0 + + +def test_linode_interface_update_public(linode_interface_public): + iface, instance, ipv6_range = linode_interface_public + + old_public_ipv4 = copy.deepcopy(iface.public.ipv4) + + iface.public.ipv4.addresses += [ + LinodeInterfacePublicIPv4AddressOptions(address="auto", primary=True) + ] + iface.public.ipv4.addresses[0].primary = False + + iface.public.ipv6.ranges[0].range = "/64" + + iface.save() + + iface.invalidate() + + assert len(iface.public.ipv4.addresses) == 2 + + address = iface.public.ipv4.addresses[0] + assert address.address == old_public_ipv4.addresses[0].address + assert not address.primary + + address = iface.public.ipv4.addresses[1] + assert ipaddress.ip_address(address.address) + assert address.primary + + assert len(iface.public.ipv6.ranges) == 1 + + range = iface.public.ipv6.ranges[0] + assert len(range.range) > 0 + assert ipaddress.ip_network(range.range) + + +def test_linode_interface_create_vpc(linode_interface_vpc): + iface, instance, vpc, subnet = linode_interface_vpc + + assert iface.id is not None + assert iface.linode_id == instance.id + + assert iface.created is not None + assert iface.updated is not None + + assert isinstance(iface.mac_address, str) + assert iface.version + + assert iface.default_route.ipv4 + assert iface.default_route.ipv6 + + assert iface.vpc.vpc_id == vpc.id + assert iface.vpc.subnet_id == subnet.id + + assert len(iface.vpc.ipv4.addresses[0].address) > 0 + assert iface.vpc.ipv4.addresses[0].primary + + assert iface.vpc.ipv4.addresses[0].nat_1_1_address is None + + assert iface.vpc.ipv4.ranges[0].range.split("/")[1] == "32" + + assert iface.default_route.ipv6 + ipv6 = iface.vpc.ipv6 + assert ipv6 and ipv6.is_public is False + + if ipv6.slaac: + assert ipv6.ranges == [] and len(ipv6.slaac) == 1 + assert ipv6.slaac[0].range and ipv6.slaac[0].address + elif ipv6.ranges: + assert ipv6.slaac == [] and len(ipv6.ranges) > 0 + + +def test_linode_interface_update_vpc(linode_interface_vpc): + iface, instance, vpc, subnet = linode_interface_vpc + + iface.vpc.subnet_id = 0 + + try: + iface.save() + except ApiError: + pass + else: + raise Exception("Expected error when updating subnet_id to 0") + + iface.invalidate() + + old_ipv4 = copy.deepcopy(iface.vpc.ipv4) + + iface.vpc.ipv4.addresses[0].address = "auto" + iface.vpc.ipv4.ranges += [ + LinodeInterfaceVPCIPv4RangeOptions( + range="/32", + ) + ] + + iface.save() + iface.invalidate() + + address = iface.vpc.ipv4.addresses[0] + assert ipaddress.ip_address(address.address) + + range = iface.vpc.ipv4.ranges[0] + assert ipaddress.ip_network(range.range) + assert range.range == old_ipv4.ranges[0].range + + range = iface.vpc.ipv4.ranges[1] + assert ipaddress.ip_network(range.range) + assert range.range != old_ipv4.ranges[0].range + + +def test_linode_interface_create_vlan( + linode_interface_vlan, +): + iface, instance = linode_interface_vlan + + assert iface.id is not None + assert iface.linode_id == instance.id + + assert iface.created is not None + assert iface.updated is not None + + assert isinstance(iface.mac_address, str) + assert iface.version + + assert not iface.default_route.ipv4 + assert not iface.default_route.ipv6 + + assert iface.vlan.vlan_label == "test-vlan" + assert iface.vlan.ipam_address == "10.0.0.5/32" + + +# NOTE: VLAN interface updates current aren't supported + + +def test_linode_interface_firewalls(e2e_test_firewall, linode_interface_public): + iface, instance, ipv6_range = linode_interface_public + + assert iface.id is not None + assert iface.linode_id == instance.id + + firewalls = iface.firewalls() + + firewall = firewalls[0] + assert firewall.id == e2e_test_firewall.id + assert firewall.label == e2e_test_firewall.label diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index afedce93d..9f6194fa9 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -1,3 +1,4 @@ +import ipaddress import time from test.integration.conftest import get_region from test.integration.helpers import ( @@ -9,7 +10,6 @@ import pytest -from linode_api4 import VPCIPAddress from linode_api4.errors import ApiError from linode_api4.objects import ( Config, @@ -17,6 +17,8 @@ ConfigInterfaceIPv4, Disk, Instance, + InterfaceGeneration, + LinodeInterface, Type, ) from linode_api4.objects.linode import InstanceDiskEncryptionType, MigrationType @@ -25,8 +27,7 @@ @pytest.fixture(scope="session") def linode_with_volume_firewall(test_linode_client): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label() rules = { @@ -38,8 +39,8 @@ def linode_with_volume_firewall(test_linode_client): linode_instance, password = client.linode.instance_create( "g6-nanode-1", - chosen_region, - image="linode/debian10", + region, + image="linode/debian12", label=label + "_modlinode", ) @@ -68,18 +69,60 @@ def linode_with_volume_firewall(test_linode_client): linode_instance.delete() -@pytest.fixture(scope="session") -def linode_for_network_interface_tests(test_linode_client, e2e_test_firewall): +@pytest.fixture(scope="function") +def linode_for_legacy_interface_tests(test_linode_client, e2e_test_firewall): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] - timestamp = str(time.time_ns()) - label = "TestSDK-" + timestamp + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") + label = get_test_label(length=8) linode_instance, password = client.linode.instance_create( "g6-nanode-1", - chosen_region, - image="linode/debian10", + region, + image="linode/debian12", + label=label, + firewall=e2e_test_firewall, + interface_generation=InterfaceGeneration.LEGACY_CONFIG, + ) + + yield linode_instance + + linode_instance.delete() + + +@pytest.fixture(scope="function") +def linode_and_vpc_for_legacy_interface_tests_offline( + test_linode_client, create_vpc_with_subnet, e2e_test_firewall +): + vpc, subnet = create_vpc_with_subnet + + label = get_test_label(length=8) + + instance, password = test_linode_client.linode.instance_create( + "g6-standard-1", + vpc.region, + booted=False, + image="linode/debian11", + label=label, + firewall=e2e_test_firewall, + interface_generation=InterfaceGeneration.LEGACY_CONFIG, + ) + + yield vpc, subnet, instance, password + + instance.delete() + + +@pytest.fixture(scope="session") +def linode_for_vpu_tests(test_linode_client, e2e_test_firewall): + client = test_linode_client + region = "us-lax" + + label = get_test_label(length=8) + + linode_instance, password = client.linode.instance_create( + "g1-accelerated-netint-vpu-t1u1-s", + region, + image="linode/debian12", label=label, firewall=e2e_test_firewall, ) @@ -92,13 +135,12 @@ def linode_for_network_interface_tests(test_linode_client, e2e_test_firewall): @pytest.fixture def linode_for_disk_tests(test_linode_client, e2e_test_firewall): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label() linode_instance, password = client.linode.instance_create( "g6-nanode-1", - chosen_region, + region, image="linode/alpine3.19", label=label + "_long_tests", firewall=e2e_test_firewall, @@ -107,12 +149,12 @@ def linode_for_disk_tests(test_linode_client, e2e_test_firewall): # Provisioning time wait_for_condition(10, 300, get_status, linode_instance, "running") - linode_instance.shutdown() + send_request_when_resource_available(300, linode_instance.shutdown) wait_for_condition(10, 100, get_status, linode_instance, "offline") # Now it allocates 100% disk space hence need to clear some space for tests - linode_instance.disks[1].delete() + send_request_when_resource_available(300, linode_instance.disks[1].delete) test_linode_client.polling.event_poller_create( "linode", "disk_delete", entity_id=linode_instance.id @@ -123,17 +165,35 @@ def linode_for_disk_tests(test_linode_client, e2e_test_firewall): linode_instance.delete() +@pytest.fixture +def linode_with_block_storage_encryption(test_linode_client, e2e_test_firewall): + client = test_linode_client + region = get_region(client, {"Linodes", "Block Storage Encryption"}) + label = get_test_label() + + linode_instance, password = client.linode.instance_create( + "g6-nanode-1", + region, + image="linode/alpine3.19", + label=label + "block-storage-encryption", + firewall=e2e_test_firewall, + ) + + yield linode_instance + + linode_instance.delete() + + @pytest.fixture def create_linode_for_long_running_tests(test_linode_client, e2e_test_firewall): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label() linode_instance, password = client.linode.instance_create( "g6-nanode-1", - chosen_region, - image="linode/debian10", + region, + image="linode/debian12", label=label + "_long_tests", firewall=e2e_test_firewall, ) @@ -148,15 +208,14 @@ def linode_with_disk_encryption(test_linode_client, request): client = test_linode_client target_region = get_region(client, {"Disk Encryption"}) - timestamp = str(time.time_ns()) - label = "TestSDK-" + timestamp + label = get_test_label(length=8) disk_encryption = request.param linode_instance, password = client.linode.instance_create( "g6-nanode-1", target_region, - image="linode/ubuntu23.04", + image="linode/ubuntu24.10", label=label, booted=False, disk_encryption=disk_encryption, @@ -183,6 +242,13 @@ def test_get_linode(test_linode_client, linode_with_volume_firewall): assert linode.id == linode_with_volume_firewall.id +def test_get_vpu(test_linode_client, linode_for_vpu_tests): + linode = test_linode_client.load(Instance, linode_for_vpu_tests.id) + + assert linode.label == linode_for_vpu_tests.label + assert hasattr(linode.specs, "accelerated_devices") + + def test_linode_transfer(test_linode_client, linode_with_volume_firewall): linode = test_linode_client.load(Instance, linode_with_volume_firewall.id) @@ -196,14 +262,12 @@ def test_linode_transfer(test_linode_client, linode_with_volume_firewall): def test_linode_rebuild(test_linode_client): client = test_linode_client - # TODO(LDE): Uncomment once LDE is available - # chosen_region = get_region(client, {"Disk Encryption"}) - chosen_region = get_region(client) + region = get_region(client, {"Disk Encryption"}) label = get_test_label() + "_rebuild" linode, password = client.linode.instance_create( - "g6-nanode-1", chosen_region, image="linode/debian10", label=label + "g6-nanode-1", region, image="linode/debian12", label=label ) wait_for_condition(10, 100, get_status, linode, "running") @@ -211,18 +275,16 @@ def test_linode_rebuild(test_linode_client): retry_sending_request( 3, linode.rebuild, - "linode/debian10", - # TODO(LDE): Uncomment once LDE is available - # disk_encryption=InstanceDiskEncryptionType.disabled, + "linode/debian12", + disk_encryption=InstanceDiskEncryptionType.disabled, ) - wait_for_condition(10, 100, get_status, linode, "rebuilding") + wait_for_condition(10, 300, get_status, linode, "rebuilding") assert linode.status == "rebuilding" - assert linode.image.id == "linode/debian10" + assert linode.image.id == "linode/debian12" - # TODO(LDE): Uncomment once LDE is available - # assert linode.disk_encryption == InstanceDiskEncryptionType.disabled + assert linode.disk_encryption == InstanceDiskEncryptionType.disabled wait_for_condition(10, 300, get_status, linode, "running") @@ -257,14 +319,13 @@ def test_update_linode(create_linode): def test_delete_linode(test_linode_client): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label() linode_instance, password = client.linode.instance_create( "g6-nanode-1", - chosen_region, - image="linode/debian10", + region, + image="linode/debian12", label=label + "_linode", ) @@ -312,14 +373,15 @@ def test_linode_boot(create_linode): assert linode.status == "running" +@pytest.mark.flaky(reruns=3, reruns_delay=2) def test_linode_resize(create_linode_for_long_running_tests): linode = create_linode_for_long_running_tests - wait_for_condition(10, 100, get_status, linode, "running") + wait_for_condition(10, 240, get_status, linode, "running") retry_sending_request(3, linode.resize, "g6-standard-6") - wait_for_condition(10, 100, get_status, linode, "resizing") + wait_for_condition(10, 240, get_status, linode, "resizing") assert linode.status == "resizing" @@ -329,6 +391,7 @@ def test_linode_resize(create_linode_for_long_running_tests): assert linode.status == "running" +@pytest.mark.flaky(reruns=3, reruns_delay=2) def test_linode_resize_with_class( test_linode_client, create_linode_for_long_running_tests ): @@ -352,6 +415,7 @@ def test_linode_resize_with_class( assert linode.status == "running" +@pytest.mark.flaky(reruns=3, reruns_delay=2) def test_linode_resize_with_migration_type( test_linode_client, create_linode_for_long_running_tests, @@ -372,7 +436,7 @@ def test_linode_resize_with_migration_type( # there is no resizing state in warm migration anymore hence wait for resizing and poll event test_linode_client.polling.event_poller_create( "linode", "linode_resize", entity_id=linode.id - ).wait_for_next_event_finished(interval=5) + ).wait_for_next_event_finished(interval=5, timeout=500) wait_for_condition( 10, @@ -413,7 +477,15 @@ def test_linode_firewalls(linode_with_volume_firewall): firewalls = linode.firewalls() assert len(firewalls) > 0 - assert "test" in firewalls[0].label + assert "firewall" in firewalls[0].label + + +def test_linode_apply_firewalls(linode_with_volume_firewall): + linode = linode_with_volume_firewall + + result = linode.apply_firewalls() + + assert result def test_linode_volumes(linode_with_volume_firewall): @@ -422,11 +494,9 @@ def test_linode_volumes(linode_with_volume_firewall): volumes = linode.volumes() assert len(volumes) > 0 - assert "test" in volumes[0].label + assert "_volume" in volumes[0].label -# TODO(LDE): Remove skip once LDE is available -@pytest.mark.skip("LDE is not currently enabled") @pytest.mark.parametrize( "linode_with_disk_encryption", ["disabled"], indirect=True ) @@ -439,6 +509,13 @@ def test_linode_with_disk_encryption_disabled(linode_with_disk_encryption): ) +def test_linode_with_block_storage_encryption( + linode_with_block_storage_encryption, +): + linode = linode_with_block_storage_encryption + assert "Block Storage Encryption" in linode.capabilities + + def wait_for_disk_status(disk: Disk, timeout): start_time = time.time() while True: @@ -485,6 +562,7 @@ def test_linode_create_disk(test_linode_client, linode_for_disk_tests): assert disk.linode_id == linode.id +@pytest.mark.flaky(reruns=3, reruns_delay=2) def test_linode_instance_password(create_linode_for_pass_reset): linode = create_linode_for_pass_reset[0] password = create_linode_for_pass_reset[1] @@ -512,29 +590,176 @@ def test_linode_ips(create_linode): assert ips.ipv4.public[0].address == linode.ipv4[0] -def test_linode_initate_migration(test_linode_client): +def test_linode_initate_migration(test_linode_client, e2e_test_firewall): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label() + "_migration" linode, _ = client.linode.instance_create( - "g6-nanode-1", chosen_region, image="linode/debian12", label=label + "g6-nanode-1", + region, + image="linode/debian12", + label=label, + firewall=e2e_test_firewall, ) # Says it could take up to ~6 hrs for migration to fully complete send_request_when_resource_available( 300, linode.initiate_migration, - region="us-mia", + region="us-central", migration_type=MigrationType.COLD, ) + def get_linode_status(): + return linode.status == "offline" + + # To verify that Linode's status changed before deletion (during migration status is set to 'offline') + wait_for_condition(5, 120, get_linode_status) + res = linode.delete() assert res +def test_linode_upgrade_interfaces( + linode_for_legacy_interface_tests, + linode_and_vpc_for_legacy_interface_tests_offline, +): + vpc, subnet, linode, _ = linode_and_vpc_for_legacy_interface_tests_offline + config = linode.configs[0] + + new_interfaces = [ + {"purpose": "public"}, + ConfigInterface( + purpose="vlan", label="cool-vlan", ipam_address="10.0.0.4/32" + ), + ConfigInterface( + purpose="vpc", + subnet_id=subnet.id, + primary=True, + ipv4=ConfigInterfaceIPv4(vpc="10.0.0.2", nat_1_1="any"), + ip_ranges=["10.0.0.5/32"], + ), + ] + config.interfaces = new_interfaces + + config.save() + + def __assert_base(iface: LinodeInterface): + assert iface.id is not None + assert iface.created is not None + assert iface.updated is not None + assert iface.version is not None + + assert len(iface.mac_address) > 0 + + def __assert_public(iface: LinodeInterface): + __assert_base(iface) + + assert not iface.default_route.ipv4 + assert not iface.default_route.ipv6 + + assert len(iface.public.ipv4.addresses) == 0 + assert len(iface.public.ipv4.shared) == 0 + + assert len(iface.public.ipv6.slaac) == 1 + assert iface.public.ipv6.slaac[0].address == linode.ipv6.split("/")[0] + + assert len(iface.public.ipv6.ranges) == 0 + assert len(iface.public.ipv6.shared) == 0 + + def __assert_vpc(iface: LinodeInterface): + __assert_base(iface) + + assert iface.default_route.ipv4 + assert iface.default_route.ipv6 + + assert iface.vpc.vpc_id == vpc.id + assert iface.vpc.subnet_id == subnet.id + + assert len(iface.vpc.ipv4.addresses) == 1 + assert iface.vpc.ipv4.addresses[0].address == "10.0.0.2" + assert iface.vpc.ipv4.addresses[0].primary + assert iface.vpc.ipv4.addresses[0].nat_1_1_address is not None + + assert len(iface.vpc.ipv4.ranges) == 1 + assert iface.vpc.ipv4.ranges[0].range == "10.0.0.5/32" + + assert len(iface.vpc.ipv6.slaac) == 1 + + ipaddress.IPv6Network(iface.vpc.ipv6.slaac[0].range) + ipaddress.IPv6Address(iface.vpc.ipv6.slaac[0].address) + + assert len(iface.vpc.ipv6.ranges) == 0 + assert iface.vpc.ipv6.is_public is False + + def __assert_vlan(iface: LinodeInterface): + __assert_base(iface) + + assert not iface.default_route.ipv4 + assert not iface.default_route.ipv6 + + assert iface.vlan.vlan_label == "cool-vlan" + assert iface.vlan.ipam_address == "10.0.0.4/32" + + result = linode.upgrade_interfaces(dry_run=True) + + assert result.dry_run + assert result.config_id == config.id + + __assert_public(result.interfaces[0]) + __assert_vlan(result.interfaces[1]) + __assert_vpc(result.interfaces[2]) + + result = linode.upgrade_interfaces(config=config) + + assert not result.dry_run + assert result.config_id == config.id + + __assert_public(linode.linode_interfaces[0]) + __assert_vlan(linode.linode_interfaces[1]) + __assert_vpc(linode.linode_interfaces[2]) + + +def test_linode_interfaces_settings(linode_with_linode_interfaces): + linode = linode_with_linode_interfaces + settings = linode.interfaces_settings + + assert settings.network_helper is not None + assert ( + settings.default_route.ipv4_interface_id + == linode.linode_interfaces[0].id + ) + assert settings.default_route.ipv4_eligible_interface_ids == [ + linode.linode_interfaces[0].id, + linode.linode_interfaces[1].id, + ] + + assert ( + settings.default_route.ipv6_interface_id + == linode.linode_interfaces[0].id + ) + assert settings.default_route.ipv6_eligible_interface_ids == [ + linode.linode_interfaces[0].id, + linode.linode_interfaces[1].id, + ] + + # Arbitrary updates + settings.network_helper = True + settings.default_route.ipv4_interface_id = linode.linode_interfaces[1].id + + settings.save() + settings.invalidate() + + # Assert updates + assert settings.network_helper is not None + assert ( + settings.default_route.ipv4_interface_id + == linode.linode_interfaces[1].id + ) + + def test_config_update_interfaces(create_linode): linode = create_linode config = linode.configs[0] @@ -573,6 +798,9 @@ def test_get_linode_types(test_linode_client): assert len(types) > 0 assert "g6-nanode-1" in ids + for linode_type in types: + assert hasattr(linode_type, "accelerated_devices") + def test_get_linode_types_overrides(test_linode_client): types = test_linode_client.linode.types() @@ -590,6 +818,7 @@ def test_get_linode_types_overrides(test_linode_client): assert linode_type.region_prices[0].monthly >= 0 +@pytest.mark.flaky(reruns=3, reruns_delay=2) def test_save_linode_noforce(test_linode_client, create_linode): linode = create_linode old_label = linode.label @@ -601,6 +830,7 @@ def test_save_linode_noforce(test_linode_client, create_linode): assert old_label != linode.label +@pytest.mark.flaky(reruns=3, reruns_delay=2) def test_save_linode_force(test_linode_client, create_linode): linode = create_linode old_label = linode.label @@ -613,8 +843,8 @@ def test_save_linode_force(test_linode_client, create_linode): class TestNetworkInterface: - def test_list(self, linode_for_network_interface_tests): - linode = linode_for_network_interface_tests + def test_list(self, linode_for_legacy_interface_tests): + linode = linode_for_legacy_interface_tests config: Config = linode.configs[0] @@ -634,8 +864,8 @@ def test_list(self, linode_for_network_interface_tests): assert interface[1].label == label assert interface[1].ipam_address == "10.0.0.3/32" - def test_create_public(self, linode_for_network_interface_tests): - linode = linode_for_network_interface_tests + def test_create_public(self, linode_for_legacy_interface_tests): + linode = linode_for_legacy_interface_tests config: Config = linode.configs[0] @@ -652,8 +882,8 @@ def test_create_public(self, linode_for_network_interface_tests): assert interface.purpose == "public" assert interface.primary - def test_create_vlan(self, linode_for_network_interface_tests): - linode = linode_for_network_interface_tests + def test_create_vlan(self, linode_for_legacy_interface_tests): + linode = linode_for_legacy_interface_tests config: Config = linode.configs[0] @@ -671,13 +901,17 @@ def test_create_vlan(self, linode_for_network_interface_tests): assert interface.label == "testvlan" assert interface.ipam_address == "10.0.0.2/32" + def test_create_vpu(self, test_linode_client, linode_for_vpu_tests): + assert hasattr(linode_for_vpu_tests.specs, "accelerated_devices") + def test_create_vpc( self, test_linode_client, - linode_for_network_interface_tests, - create_vpc_with_subnet_and_linode, + linode_and_vpc_for_legacy_interface_tests_offline, ): - vpc, subnet, linode, _ = create_vpc_with_subnet_and_linode + vpc, subnet, linode, _ = ( + linode_and_vpc_for_legacy_interface_tests_offline + ) config: Config = linode.configs[0] @@ -687,7 +921,7 @@ def test_create_vpc( interface = config.interface_create_vpc( subnet=subnet, primary=True, - ipv4=ConfigInterfaceIPv4(vpc="10.0.0.2", nat_1_1="any"), + ipv4=ConfigInterfaceIPv4(vpc="10.0.0.3", nat_1_1="any"), ip_ranges=["10.0.0.5/32"], ) @@ -696,7 +930,7 @@ def test_create_vpc( assert interface.id == config.interfaces[0].id assert interface.subnet.id == subnet.id assert interface.purpose == "vpc" - assert interface.ipv4.vpc == "10.0.0.2" + assert interface.ipv4.vpc == "10.0.0.3" assert interface.ipv4.nat_1_1 == linode.ipv4[0] assert interface.ip_ranges == ["10.0.0.5/32"] @@ -714,11 +948,30 @@ def test_create_vpc( assert vpc_range_ip.address_range == "10.0.0.5/32" assert not vpc_range_ip.active + assert isinstance(vpc.ipv6, list) + assert len(vpc.ipv6) > 0 + assert isinstance(vpc.ipv6[0].range, str) + assert ":" in vpc.ipv6[0].range + + # TODO:: Add `VPCIPAddress.filters.linode_id == linode.id` filter back + # Attempt to resolve the IP from /vpcs/ips - all_vpc_ips = test_linode_client.vpcs.ips( - VPCIPAddress.filters.linode_id == linode.id + all_vpc_ips = test_linode_client.vpcs.ips() + matched_ip = next( + ( + ip + for ip in all_vpc_ips + if ip.address == vpc_ip.address + and ip.vpc_id == vpc_ip.vpc_id + and ip.linode_id == vpc_ip.linode_id + ), + None, ) - assert all_vpc_ips[0].dict == vpc_ip.dict + + assert ( + matched_ip is not None + ), f"Expected VPC IP {vpc_ip.address} not found in /vpcs/ips" + assert matched_ip.dict == vpc_ip.dict # Test getting the ips under this specific VPC vpc_ips = vpc.ips @@ -728,12 +981,47 @@ def test_create_vpc( assert vpc_ips[0].linode_id == linode.id assert vpc_ips[0].nat_1_1 == linode.ips.ipv4.public[0].address + # Validate VPC IPv6 IPs from /vpcs/ips + all_vpc_ipv6 = test_linode_client.get("/vpcs/ipv6s")["data"] + + # Find matching VPC IPv6 entry + matched_ipv6 = next( + ( + ip + for ip in all_vpc_ipv6 + if ip["vpc_id"] == vpc.id + and ip["linode_id"] == linode.id + and ip["interface_id"] == interface.id + and ip["subnet_id"] == subnet.id + ), + None, + ) + + assert ( + matched_ipv6 + ), f"No VPC IPv6 found for Linode {linode.id} in VPC {vpc.id}" + + assert matched_ipv6["ipv6_range"].count(":") >= 2 + assert not matched_ipv6["ipv6_is_public"] + + ipv6_addresses = matched_ipv6.get("ipv6_addresses", []) + assert ( + isinstance(ipv6_addresses, list) and ipv6_addresses + ), "No IPv6 addresses found" + + slaac = ipv6_addresses[0] + assert ( + isinstance(slaac.get("slaac_address"), str) + and ":" in slaac["slaac_address"] + ) + def test_update_vpc( self, - linode_for_network_interface_tests, - create_vpc_with_subnet_and_linode, + linode_and_vpc_for_legacy_interface_tests_offline, ): - vpc, subnet, linode, _ = create_vpc_with_subnet_and_linode + vpc, subnet, linode, _ = ( + linode_and_vpc_for_legacy_interface_tests_offline + ) config: Config = linode.configs[0] @@ -743,11 +1031,11 @@ def test_update_vpc( interface = config.interface_create_vpc( subnet=subnet, primary=True, - ip_ranges=["10.0.0.5/32"], + ip_ranges=["10.0.0.8/32"], ) interface.primary = False - interface.ip_ranges = ["10.0.0.6/32"] + interface.ip_ranges = ["10.0.0.9/32"] interface.ipv4.vpc = "10.0.0.3" interface.ipv4.nat_1_1 = "any" @@ -760,10 +1048,10 @@ def test_update_vpc( assert interface.purpose == "vpc" assert interface.ipv4.vpc == "10.0.0.3" assert interface.ipv4.nat_1_1 == linode.ipv4[0] - assert interface.ip_ranges == ["10.0.0.6/32"] + assert interface.ip_ranges == ["10.0.0.9/32"] - def test_reorder(self, linode_for_network_interface_tests): - linode = linode_for_network_interface_tests + def test_reorder(self, linode_for_legacy_interface_tests): + linode = linode_for_legacy_interface_tests config: Config = linode.configs[0] @@ -815,3 +1103,47 @@ def test_delete_interface_containing_vpc( # returns true when delete successful assert result + + +def test_create_linode_with_maintenance_policy(test_linode_client): + client = test_linode_client + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") + label = get_test_label() + + policies = client.maintenance.maintenance_policies() + assert policies, "No maintenance policies returned from API" + + non_default_policy = next((p for p in policies if not p.is_default), None) + assert non_default_policy, "No non-default maintenance policy available" + + linode_instance, password = client.linode.instance_create( + "g6-nanode-1", + region, + image="linode/debian12", + label=label + "_with_policy", + maintenance_policy=non_default_policy.slug, + ) + + assert linode_instance.id is not None + assert linode_instance.label.startswith(label) + assert linode_instance.maintenance_policy == non_default_policy.slug + + linode_instance.delete() + + +def test_update_linode_maintenance_policy(create_linode, test_linode_client): + client = test_linode_client + linode = create_linode + + policies = client.maintenance.maintenance_policies() + assert policies, "No maintenance policies returned from API" + + non_default_policy = next((p for p in policies if not p.is_default), None) + assert non_default_policy, "No non-default maintenance policy found" + + linode.maintenance_policy_id = non_default_policy.slug + result = linode.save() + + linode.invalidate() + assert result + assert linode.maintenance_policy_id == non_default_policy.slug diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index 2f659f9a7..96ab1d3cc 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -14,9 +14,17 @@ LKEClusterControlPlaneACLAddressesOptions, LKEClusterControlPlaneACLOptions, LKEClusterControlPlaneOptions, + TieredKubeVersion, ) +from linode_api4.common import RegionPrice from linode_api4.errors import ApiError -from linode_api4.objects import LKECluster, LKENodePool, LKENodePoolTaint +from linode_api4.objects import ( + LKECluster, + LKENodePool, + LKENodePoolTaint, + LKEType, +) +from linode_api4.objects.linode import InstanceDiskEncryptionType @pytest.fixture(scope="session") @@ -24,15 +32,13 @@ def lke_cluster(test_linode_client): node_type = test_linode_client.linode.types()[1] # g6-standard-1 version = test_linode_client.lke.versions()[0] - # TODO(LDE): Uncomment once LDE is available - # region = get_region(test_linode_client, {"Kubernetes", "Disk Encryption"}) - region = get_region(test_linode_client, {"Kubernetes"}) + region = get_region(test_linode_client, {"Kubernetes", "Disk Encryption"}) node_pools = test_linode_client.lke.node_pool(node_type, 3) label = get_test_label() + "_cluster" cluster = test_linode_client.lke.cluster_create( - region, label, node_pools, version + region, label, version, node_pools ) yield cluster @@ -40,7 +46,7 @@ def lke_cluster(test_linode_client): cluster.delete() -@pytest.fixture(scope="session") +@pytest.fixture(scope="function") def lke_cluster_with_acl(test_linode_client): node_type = test_linode_client.linode.types()[1] # g6-standard-1 version = test_linode_client.lke.versions()[0] @@ -51,8 +57,8 @@ def lke_cluster_with_acl(test_linode_client): cluster = test_linode_client.lke.cluster_create( region, label, - node_pools, version, + node_pools, control_plane=LKEClusterControlPlaneOptions( acl=LKEClusterControlPlaneACLOptions( enabled=True, @@ -97,7 +103,66 @@ def lke_cluster_with_labels_and_taints(test_linode_client): label = get_test_label() + "_cluster" cluster = test_linode_client.lke.cluster_create( - region, label, node_pools, version + region, label, version, node_pools + ) + + yield cluster + + cluster.delete() + + +@pytest.fixture(scope="session") +def lke_cluster_with_apl(test_linode_client): + version = test_linode_client.lke.versions()[0] + + region = get_region(test_linode_client, {"Kubernetes", "Disk Encryption"}) + + # NOTE: g6-dedicated-4 is the minimum APL-compatible Linode type + node_pools = test_linode_client.lke.node_pool("g6-dedicated-4", 3) + label = get_test_label() + "_cluster" + + cluster = test_linode_client.lke.cluster_create( + region, + label, + version, + node_pools, + control_plane=LKEClusterControlPlaneOptions( + high_availability=True, + ), + apl_enabled=True, + ) + + yield cluster + + cluster.delete() + + +@pytest.fixture(scope="session") +def lke_cluster_enterprise(e2e_test_firewall, test_linode_client): + # We use the oldest version here so we can test upgrades + version = sorted( + v.id for v in test_linode_client.lke.tier("enterprise").versions() + )[0] + + region = get_region( + test_linode_client, {"Kubernetes Enterprise", "Disk Encryption"} + ) + + node_pools = test_linode_client.lke.node_pool( + "g6-dedicated-2", + 3, + k8s_version=version, + update_strategy="rolling_update", + firewall_id=e2e_test_firewall.id, + ) + label = get_test_label() + "_cluster" + + cluster = test_linode_client.lke.cluster_create( + region, + label, + version, + node_pools, + tier="enterprise", ) yield cluster @@ -121,6 +186,7 @@ def test_get_lke_clusters(test_linode_client, lke_cluster): assert cluster._raw_json == lke_cluster._raw_json +@pytest.mark.smoke def test_get_lke_pool(test_linode_client, lke_cluster): cluster = lke_cluster @@ -139,8 +205,25 @@ def _to_comparable(p: LKENodePool) -> Dict[str, Any]: assert _to_comparable(cluster.pools[0]) == _to_comparable(pool) - # TODO(LDE): Uncomment once LDE is available - # assert pool.disk_encryption == InstanceDiskEncryptionType.enabled + assert pool.disk_encryption in ( + InstanceDiskEncryptionType.enabled, + InstanceDiskEncryptionType.disabled, + ) + + +def test_node_pool_create_with_disk_encryption(test_linode_client, lke_cluster): + node_type = test_linode_client.linode.types()[1] + + pool = lke_cluster.node_pool_create( + node_type, + 1, + disk_encryption=InstanceDiskEncryptionType.enabled, + ) + + try: + assert pool.disk_encryption == InstanceDiskEncryptionType.enabled + finally: + pool.delete() def test_cluster_dashboard_url_view(lke_cluster): @@ -257,20 +340,52 @@ def test_lke_cluster_acl(lke_cluster_with_acl): acl = cluster.control_plane_acl_update( LKEClusterControlPlaneACLOptions( + enabled=True, addresses=LKEClusterControlPlaneACLAddressesOptions( ipv4=["10.0.0.2/32"] - ) + ), ) ) assert acl == cluster.control_plane_acl assert acl.addresses.ipv4 == ["10.0.0.2/32"] + +def test_lke_cluster_update_acl_null_addresses(lke_cluster_with_acl): + cluster = lke_cluster_with_acl + + # Addresses should not be included in the request if it's null, + # else an error will be returned by the API. + # See: TPT-3489 + acl = cluster.control_plane_acl_update( + {"enabled": False, "addresses": None} + ) + + assert acl == cluster.control_plane_acl + assert acl.addresses.ipv4 == [] + + +def test_lke_cluster_disable_acl(lke_cluster_with_acl): + cluster = lke_cluster_with_acl + + assert cluster.control_plane_acl.enabled + + acl = cluster.control_plane_acl_update( + LKEClusterControlPlaneACLOptions( + enabled=False, + ) + ) + + assert acl.enabled is False + assert acl == cluster.control_plane_acl + assert acl.addresses.ipv4 == [] + cluster.control_plane_acl_delete() assert not cluster.control_plane_acl.enabled +@pytest.mark.flaky(reruns=3, reruns_delay=2) def test_lke_cluster_labels_and_taints(lke_cluster_with_labels_and_taints): pool = lke_cluster_with_labels_and_taints.pools[0] @@ -320,3 +435,86 @@ def test_lke_cluster_labels_and_taints(lke_cluster_with_labels_and_taints): assert vars(pool.labels) == updated_labels assert updated_taints[0] in pool.taints assert LKENodePoolTaint.from_json(updated_taints[1]) in pool.taints + + +@pytest.mark.flaky(reruns=3, reruns_delay=2) +def test_lke_cluster_with_apl(lke_cluster_with_apl): + assert lke_cluster_with_apl.apl_enabled == True + assert ( + lke_cluster_with_apl.apl_console_url + == f"https://console.lke{lke_cluster_with_apl.id}.akamai-apl.net" + ) + assert ( + lke_cluster_with_apl.apl_health_check_url + == f"https://auth.lke{lke_cluster_with_apl.id}.akamai-apl.net/ready" + ) + + +def test_lke_cluster_enterprise( + e2e_test_firewall, + test_linode_client, + lke_cluster_enterprise, +): + lke_cluster_enterprise.invalidate() + assert lke_cluster_enterprise.tier == "enterprise" + + pool = lke_cluster_enterprise.pools[0] + assert str(pool.k8s_version) == lke_cluster_enterprise.k8s_version.id + assert pool.update_strategy == "rolling_update" + assert pool.firewall_id == e2e_test_firewall.id + + target_version = sorted( + v.id for v in test_linode_client.lke.tier("enterprise").versions() + )[0] + pool.update_strategy = "on_recycle" + pool.k8s_version = target_version + + pool.save() + + pool.invalidate() + + assert pool.k8s_version == target_version + assert pool.update_strategy == "on_recycle" + + +def test_lke_tiered_versions(test_linode_client): + def __assert_version(tier: str, version: TieredKubeVersion): + assert version.tier == tier + assert len(version.id) > 0 + + standard_versions = test_linode_client.lke.tier("standard").versions() + assert len(standard_versions) > 0 + + standard_version = standard_versions[0] + __assert_version("standard", standard_version) + + standard_version.invalidate() + __assert_version("standard", standard_version) + + enterprise_versions = test_linode_client.lke.tier("enterprise").versions() + assert len(enterprise_versions) > 0 + + enterprise_version = enterprise_versions[0] + __assert_version("enterprise", enterprise_version) + + enterprise_version.invalidate() + __assert_version("enterprise", enterprise_version) + + +def test_lke_types(test_linode_client): + types = test_linode_client.lke.types() + + if len(types) > 0: + for lke_type in types: + assert type(lke_type) is LKEType + assert lke_type.price.monthly is None or ( + isinstance(lke_type.price.monthly, (float, int)) + and lke_type.price.monthly >= 0 + ) + if len(lke_type.region_prices) > 0: + region_price = lke_type.region_prices[0] + assert type(region_price) is RegionPrice + assert lke_type.price.monthly is None or ( + isinstance(lke_type.price.monthly, (float, int)) + and lke_type.price.monthly >= 0 + ) diff --git a/test/integration/models/lock/__init__.py b/test/integration/models/lock/__init__.py new file mode 100644 index 000000000..1e07a34ee --- /dev/null +++ b/test/integration/models/lock/__init__.py @@ -0,0 +1 @@ +# This file is intentionally left empty to make the directory a Python package. diff --git a/test/integration/models/lock/test_lock.py b/test/integration/models/lock/test_lock.py new file mode 100644 index 000000000..f2139a176 --- /dev/null +++ b/test/integration/models/lock/test_lock.py @@ -0,0 +1,151 @@ +from test.integration.conftest import get_region +from test.integration.helpers import ( + get_test_label, + send_request_when_resource_available, +) + +import pytest + +from linode_api4.objects import Lock, LockType + + +@pytest.fixture(scope="function") +def linode_for_lock(test_linode_client, e2e_test_firewall): + """ + Create a Linode instance for testing locks. + """ + client = test_linode_client + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") + label = get_test_label(length=8) + + linode_instance, _ = client.linode.instance_create( + "g6-nanode-1", + region, + image="linode/debian12", + label=label, + firewall=e2e_test_firewall, + ) + + yield linode_instance + + # Clean up any locks on the Linode before deleting it + locks = client.locks() + for lock in locks: + if ( + lock.entity.id == linode_instance.id + and lock.entity.type == "linode" + ): + lock.delete() + + send_request_when_resource_available( + timeout=100, func=linode_instance.delete + ) + + +@pytest.fixture(scope="function") +def test_lock(test_linode_client, linode_for_lock): + """ + Create a lock for testing. + """ + lock = test_linode_client.locks.create( + entity_type="linode", + entity_id=linode_for_lock.id, + lock_type=LockType.cannot_delete, + ) + + yield lock + + # Clean up lock if it still exists + try: + lock.delete() + except Exception: + pass # Lock may have been deleted by the test + + +@pytest.mark.smoke +def test_get_lock(test_linode_client, test_lock): + """ + Test that a lock can be retrieved by ID. + """ + lock = test_linode_client.load(Lock, test_lock.id) + + assert lock.id == test_lock.id + assert lock.lock_type == "cannot_delete" + assert lock.entity is not None + assert lock.entity.type == "linode" + + +def test_list_locks(test_linode_client, test_lock): + """ + Test that locks can be listed. + """ + locks = test_linode_client.locks() + + assert len(locks) > 0 + + # Verify our test lock is in the list + lock_ids = [lock.id for lock in locks] + assert test_lock.id in lock_ids + + +def test_create_lock_cannot_delete(test_linode_client, linode_for_lock): + """ + Test creating a cannot_delete lock. + """ + lock = test_linode_client.locks.create( + entity_type="linode", + entity_id=linode_for_lock.id, + lock_type=LockType.cannot_delete, + ) + + assert lock.id is not None + assert lock.lock_type == "cannot_delete" + assert lock.entity.id == linode_for_lock.id + assert lock.entity.type == "linode" + assert lock.entity.label == linode_for_lock.label + + # Clean up + lock.delete() + + +def test_create_lock_cannot_delete_with_subresources( + test_linode_client, linode_for_lock +): + """ + Test creating a cannot_delete_with_subresources lock. + """ + lock = test_linode_client.locks.create( + entity_type="linode", + entity_id=linode_for_lock.id, + lock_type=LockType.cannot_delete_with_subresources, + ) + + assert lock.id is not None + assert lock.lock_type == "cannot_delete_with_subresources" + assert lock.entity.id == linode_for_lock.id + assert lock.entity.type == "linode" + + # Clean up + lock.delete() + + +def test_delete_lock(test_linode_client, linode_for_lock): + """ + Test that a lock can be deleted using the Lock object's delete method. + """ + # Create a lock + lock = test_linode_client.locks.create( + entity_type="linode", + entity_id=linode_for_lock.id, + lock_type=LockType.cannot_delete, + ) + + lock_id = lock.id + + # Delete the lock using the object method + lock.delete() + + # Verify the lock no longer exists + locks = test_linode_client.locks() + lock_ids = [lk.id for lk in locks] + assert lock_id not in lock_ids diff --git a/test/integration/models/longview/test_longview.py b/test/integration/models/longview/test_longview.py index f04875e63..6a6855460 100644 --- a/test/integration/models/longview/test_longview.py +++ b/test/integration/models/longview/test_longview.py @@ -1,5 +1,6 @@ import re import time +from test.integration.helpers import get_test_label import pytest @@ -22,7 +23,7 @@ def test_update_longview_label(test_linode_client, test_longview_client): longview = test_linode_client.load(LongviewClient, test_longview_client.id) old_label = longview.label - label = "updated_longview_label" + label = get_test_label(10) longview.label = label @@ -33,7 +34,7 @@ def test_update_longview_label(test_linode_client, test_longview_client): def test_delete_client(test_linode_client, test_longview_client): client = test_linode_client - label = "TestSDK-longview" + label = get_test_label(length=8) longview_client = client.longview.client_create(label=label) time.sleep(5) diff --git a/test/integration/models/maintenance/test_maintenance.py b/test/integration/models/maintenance/test_maintenance.py new file mode 100644 index 000000000..509d06cf6 --- /dev/null +++ b/test/integration/models/maintenance/test_maintenance.py @@ -0,0 +1,12 @@ +def test_get_maintenance_policies(test_linode_client): + client = test_linode_client + + policies = client.maintenance.maintenance_policies() + + assert isinstance(policies, list) + assert all(hasattr(p, "slug") for p in policies) + + slugs = [p.slug for p in policies] + assert any( + slug in slugs for slug in ["linode/migrate", "linode/power_off_on"] + ) diff --git a/test/integration/models/monitor/test_monitor.py b/test/integration/models/monitor/test_monitor.py new file mode 100644 index 000000000..908ac1a44 --- /dev/null +++ b/test/integration/models/monitor/test_monitor.py @@ -0,0 +1,277 @@ +import time +from test.integration.helpers import ( + get_test_label, + send_request_when_resource_available, + wait_for_condition, +) + +import pytest + +from linode_api4 import LinodeClient +from linode_api4.objects import ( + AlertDefinition, + ApiError, + MonitorDashboard, + MonitorMetricsDefinition, + MonitorService, + MonitorServiceToken, +) +from linode_api4.objects.monitor import AlertStatus + + +# List all dashboards +def test_get_all_dashboards(test_linode_client): + client = test_linode_client + dashboards = client.monitor.dashboards() + assert isinstance(dashboards[0], MonitorDashboard) + + dashboard_get = dashboards[0] + get_service_type = dashboard_get.service_type + + # Fetch Dashboard by ID + dashboard_by_id = client.load(MonitorDashboard, 1) + assert isinstance(dashboard_by_id, MonitorDashboard) + assert dashboard_by_id.id == 1 + + # #Fetch Dashboard by service_type + dashboards_by_svc = client.monitor.dashboards(service_type=get_service_type) + assert isinstance(dashboards_by_svc[0], MonitorDashboard) + assert dashboards_by_svc[0].service_type == get_service_type + + +def test_filter_and_group_by(test_linode_client): + client = test_linode_client + dashboards_by_svc = client.monitor.dashboards(service_type="linode") + assert isinstance(dashboards_by_svc[0], MonitorDashboard) + + # Get the first dashboard for linode service type + dashboard = dashboards_by_svc[0] + assert dashboard.service_type == "linode" + + # Ensure the dashboard has widgets + assert hasattr( + dashboard, "widgets" + ), "Dashboard should have widgets attribute" + assert dashboard.widgets is not None, "Dashboard widgets should not be None" + assert ( + len(dashboard.widgets) > 0 + ), "Dashboard should have at least one widget" + + # Test the first widget's group_by and filters fields + widget = dashboard.widgets[0] + + # Test group_by field type + group_by = widget.group_by + assert group_by is None or isinstance( + group_by, list + ), "group_by should be None or list type" + if group_by is not None: + for item in group_by: + assert isinstance(item, str), "group_by items should be strings" + + # Test filters field type + filters = widget.filters + assert filters is None or isinstance( + filters, list + ), "filters should be None or list type" + if filters is not None: + from linode_api4.objects.monitor import Filter + + for filter_item in filters: + assert isinstance( + filter_item, Filter + ), "filter items should be Filter objects" + assert hasattr( + filter_item, "dimension_label" + ), "Filter should have dimension_label" + assert hasattr( + filter_item, "operator" + ), "Filter should have operator" + assert hasattr(filter_item, "value"), "Filter should have value" + + +# List supported services +def test_get_supported_services(test_linode_client): + client = test_linode_client + supported_services = client.monitor.services() + assert isinstance(supported_services[0], MonitorService) + + get_supported_service = supported_services[0].service_type + + # Get details for a particular service + service_details = client.load(MonitorService, get_supported_service) + assert isinstance(service_details, MonitorService) + assert service_details.service_type == get_supported_service + + # Get Metric definition details for that particular service + metric_definitions = client.monitor.metric_definitions( + service_type=get_supported_service + ) + assert isinstance(metric_definitions[0], MonitorMetricsDefinition) + + +def test_get_not_supported_service(test_linode_client): + client = test_linode_client + with pytest.raises(RuntimeError) as err: + client.load(MonitorService, "saas") + assert "[404] Not found" in str(err.value) + + +# Test Helpers +def get_db_engine_id(client: LinodeClient, engine: str): + engines = client.database.engines() + engine_id = "" + for e in engines: + if e.engine == engine: + engine_id = e.id + + return str(engine_id) + + +@pytest.fixture(scope="session") +def test_create_and_test_db(test_linode_client): + client = test_linode_client + label = get_test_label() + "-sqldb" + region = "us-ord" + engine_id = get_db_engine_id(client, "mysql") + dbtype = "g6-standard-1" + + db = client.database.mysql_create( + label=label, + region=region, + engine=engine_id, + ltype=dbtype, + cluster_size=None, + ) + + def get_db_status(): + return db.status == "active" + + # TAKES 15-30 MINUTES TO FULLY PROVISION DB + wait_for_condition(60, 2000, get_db_status) + + yield db + send_request_when_resource_available(300, db.delete) + + +def test_my_db_functionality(test_linode_client, test_create_and_test_db): + client = test_linode_client + assert test_create_and_test_db.status == "active" + + entity_id = test_create_and_test_db.id + + # create token for the particular service + token = client.monitor.create_token( + service_type="dbaas", entity_ids=[entity_id] + ) + assert isinstance(token, MonitorServiceToken) + assert len(token.token) > 0, "Token should not be empty" + assert hasattr(token, "token"), "Response object has no 'token' attribute" + + +def test_integration_create_get_update_delete_alert_definition( + test_linode_client, +): + """E2E: create an alert definition, fetch it, update it, then delete it. + + This test attempts to be resilient: it cleans up the created definition + in a finally block so CI doesn't leak resources. + """ + client = test_linode_client + service_type = "dbaas" + label = get_test_label() + "-e2e-alert" + + rule_criteria = { + "rules": [ + { + "aggregate_function": "avg", + "dimension_filters": [ + { + "dimension_label": "node_type", + "label": "Node Type", + "operator": "eq", + "value": "primary", + } + ], + "label": "Memory Usage", + "metric": "memory_usage", + "operator": "gt", + "threshold": 90, + "unit": "percent", + } + ] + } + trigger_conditions = { + "criteria_condition": "ALL", + "evaluation_period_seconds": 300, + "polling_interval_seconds": 300, + "trigger_occurrences": 1, + } + + # Make the label unique and ensure it begins/ends with an alphanumeric char + label = f"{label}-{int(time.time())}" + description = "E2E alert created by SDK integration test" + + # Pick an existing alert channel to attach to the definition; skip if none + channels = list(client.monitor.alert_channels()) + if not channels: + pytest.skip( + "No alert channels available on account for creating alert definitions" + ) + + created = None + + def wait_for_alert_ready(alert_id, service_type: str): + timeout = 360 # maximum time in seconds to wait for alert creation + initial_timeout = 1 + start = time.time() + interval = initial_timeout + alert = client.load(AlertDefinition, alert_id, service_type) + while ( + getattr(alert, "status", None) + != AlertStatus.AlertDefinitionStatusEnabled + and (time.time() - start) < timeout + ): + time.sleep(interval) + interval *= 2 + try: + alert._api_get() + except ApiError as e: + # transient errors while polling; continue until timeout + if e.status != 404: + raise + return alert + + try: + # Create the alert definition using API-compliant top-level fields + created = client.monitor.create_alert_definition( + service_type=service_type, + label=label, + severity=1, + description=description, + channel_ids=[channels[0].id], + rule_criteria=rule_criteria, + trigger_conditions=trigger_conditions, + ) + + assert created.id + assert getattr(created, "label", None) == label + + created = wait_for_alert_ready(created.id, service_type) + + updated = client.load(AlertDefinition, created.id, service_type) + updated.label = f"{label}-updated" + updated.save() + + updated = wait_for_alert_ready(updated.id, service_type) + + assert created.id == updated.id + assert updated.label == f"{label}-updated" + + finally: + if created: + # Best-effort cleanup; allow transient errors. + delete_alert = client.load( + AlertDefinition, created.id, service_type + ) + delete_alert.delete() diff --git a/test/integration/models/monitor_api/test_monitor_api.py b/test/integration/models/monitor_api/test_monitor_api.py new file mode 100644 index 000000000..d9fd755b3 --- /dev/null +++ b/test/integration/models/monitor_api/test_monitor_api.py @@ -0,0 +1,11 @@ +def test_monitor_api_fetch_dbaas_metrics(test_monitor_client): + client, entity_ids = test_monitor_client + + metrics = client.metrics.fetch_metrics( + "dbaas", + entity_ids=entity_ids, + metrics=[{"name": "read_iops", "aggregate_function": "avg"}], + relative_time_duration={"unit": "hr", "value": 1}, + ) + + assert metrics.status == "success" diff --git a/test/integration/models/networking/test_networking.py b/test/integration/models/networking/test_networking.py index d9f13063e..27ffbb444 100644 --- a/test/integration/models/networking/test_networking.py +++ b/test/integration/models/networking/test_networking.py @@ -1,8 +1,68 @@ -from test.integration.helpers import get_rand_nanosec_test_label +import time +from test.integration.conftest import ( + get_api_ca_file, + get_api_url, + get_region, + get_token, +) +from test.integration.helpers import ( + get_test_label, + retry_sending_request, + wait_for_condition, +) import pytest +from linode_api4 import Instance, LinodeClient from linode_api4.objects import Config, ConfigInterfaceIPv4, Firewall, IPAddress +from linode_api4.objects.networking import ( + FirewallCreateDevicesOptions, + NetworkTransferPrice, + Price, +) + +TEST_REGION = get_region( + LinodeClient( + token=get_token(), + base_url=get_api_url(), + ca_path=get_api_ca_file(), + ), + {"Linodes", "Cloud Firewall"}, + site_type="core", +) + + +def create_linode_func(test_linode_client): + client = test_linode_client + + label = get_test_label() + + linode_instance, _ = client.linode.instance_create( + "g6-nanode-1", + TEST_REGION, + image="linode/debian12", + label=label, + ) + + return linode_instance + + +@pytest.fixture +def create_linode_for_ip_share(test_linode_client): + linode = create_linode_func(test_linode_client) + + yield linode + + linode.delete() + + +@pytest.fixture +def create_linode_to_be_shared_with_ips(test_linode_client): + linode = create_linode_func(test_linode_client) + + yield linode + + linode.delete() @pytest.mark.smoke @@ -17,38 +77,98 @@ def test_get_networking_rules(test_linode_client, test_firewall): assert "outbound_policy" in str(rules) -def create_linode(test_linode_client): +@pytest.fixture +def create_linode_without_firewall(test_linode_client): + """ + WARNING: This is specifically reserved for Firewall testing. + Don't use this if the Linode will not be assigned to a firewall. + """ + client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] - label = get_rand_nanosec_test_label() + region = get_region(client, {"Cloud Firewall"}, "core").id - linode_instance, _ = client.linode.instance_create( + label = get_test_label() + + instance = client.linode.instance_create( "g6-nanode-1", - chosen_region, - image="linode/debian12", + region, label=label, ) - return linode_instance + yield client, instance + + instance.delete() @pytest.fixture -def create_linode_for_ip_share(test_linode_client): - linode = create_linode(test_linode_client) +def create_firewall_with_device(create_linode_without_firewall): + client, target_instance = create_linode_without_firewall + + firewall = client.networking.firewall_create( + get_test_label(), + rules={ + "inbound_policy": "DROP", + "outbound_policy": "DROP", + }, + devices=FirewallCreateDevicesOptions(linodes=[target_instance.id]), + ) - yield linode + yield firewall, target_instance - linode.delete() + firewall.delete() -@pytest.fixture -def create_linode_to_be_shared_with_ips(test_linode_client): - linode = create_linode(test_linode_client) +def test_get_networking_rule_versions(test_linode_client, test_firewall): + firewall = test_linode_client.load(Firewall, test_firewall.id) - yield linode + # Update the firewall's rules + new_rules = { + "inbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": ["0.0.0.0/0"], + "ipv6": ["ff00::/8"], + }, + "description": "A really cool firewall rule.", + "label": "really-cool-firewall-rule", + "ports": "80", + "protocol": "TCP", + } + ], + "inbound_policy": "ACCEPT", + "outbound": [], + "outbound_policy": "DROP", + } + firewall.update_rules(new_rules) + time.sleep(1) + + rule_versions = firewall.rule_versions + + # Original firewall rules + old_rule_version = firewall.get_rule_version(1) + + # Updated firewall rules + new_rule_version = firewall.get_rule_version(2) + + assert "rules" in str(rule_versions) + assert "version" in str(rule_versions) + assert rule_versions["results"] == 2 + + assert old_rule_version["inbound"] == [] + assert old_rule_version["inbound_policy"] == "ACCEPT" + assert old_rule_version["outbound"] == [] + assert old_rule_version["outbound_policy"] == "DROP" + assert old_rule_version["version"] == 1 - linode.delete() + assert ( + new_rule_version["inbound"][0]["description"] + == "A really cool firewall rule." + ) + assert new_rule_version["inbound_policy"] == "ACCEPT" + assert new_rule_version["outbound"] == [] + assert new_rule_version["outbound_policy"] == "DROP" + assert new_rule_version["version"] == 2 @pytest.mark.smoke @@ -121,3 +241,113 @@ def test_ip_info_vpc(test_linode_client, create_vpc_with_subnet_and_linode): assert ip_info.vpc_nat_1_1.address == "10.0.0.2" assert ip_info.vpc_nat_1_1.vpc_id == vpc.id assert ip_info.vpc_nat_1_1.subnet_id == subnet.id + + +def test_network_transfer_prices(test_linode_client): + transfer_prices = test_linode_client.networking.transfer_prices() + + if len(transfer_prices) > 0: + assert type(transfer_prices[0]) is NetworkTransferPrice + assert type(transfer_prices[0].price) is Price + assert ( + transfer_prices[0].price is None + or transfer_prices[0].price.hourly >= 0 + ) + + +def test_allocate_and_delete_ip(test_linode_client, create_linode): + linode = create_linode + ip = test_linode_client.networking.ip_allocate(linode.id) + linode.invalidate() + + assert ip.linode_id == linode.id + assert ip.address in linode.ipv4 + + is_deleted = ip.delete() + + assert is_deleted is True + + +def get_status(linode: Instance, status: str): + return linode.status == status + + +def test_create_and_delete_vlan(test_linode_client, linode_for_vlan_tests): + linode = linode_for_vlan_tests + + config: Config = linode.configs[0] + + config.interfaces = [] + config.save() + + vlan_label = f"{get_test_label(8)}-testvlan" + interface = config.interface_create_vlan( + label=vlan_label, ipam_address="10.0.0.2/32" + ) + + config.invalidate() + + assert interface.id == config.interfaces[0].id + assert interface.purpose == "vlan" + assert interface.label == vlan_label + + # Remove the VLAN interface and reboot Linode + config.interfaces = [] + config.save() + + wait_for_condition(3, 100, get_status, linode, "running") + + retry_sending_request(3, linode.reboot) + + wait_for_condition(3, 100, get_status, linode, "rebooting") + assert linode.status == "rebooting" + + wait_for_condition(3, 100, get_status, linode, "running") + + # Delete the VLAN + is_deleted = test_linode_client.networking.delete_vlan( + vlan_label, linode.region + ) + + assert is_deleted is True + + +def test_create_firewall_with_linode_device(create_firewall_with_device): + firewall, target_instance = create_firewall_with_device + + devices = firewall.devices + + assert len(devices) == 1 + assert devices[0].entity.id == target_instance.id + + +# TODO (Enhanced Interfaces): Add test for interface device + + +def test_get_global_firewall_settings(test_linode_client): + settings = test_linode_client.networking.firewall_settings() + + assert settings.default_firewall_ids is not None + assert all( + k in {"vpc_interface", "public_interface", "linode", "nodebalancer"} + for k in vars(settings.default_firewall_ids).keys() + ) + + +def test_ip_info(test_linode_client, create_linode): + linode = create_linode + wait_for_condition(3, 100, get_status, linode, "running") + + ip_info = test_linode_client.load(IPAddress, linode.ipv4[0]) + + assert ip_info.address == linode.ipv4[0] + assert ip_info.gateway is not None + assert ip_info.linode_id == linode.id + assert ip_info.interface_id is None + assert ip_info.prefix == 24 + assert ip_info.public + assert ip_info.rdns is not None + assert ip_info.region.id == linode.region.id + assert ip_info.subnet_mask is not None + assert ip_info.type == "ipv4" + assert ip_info.vpc_nat_1_1 is None diff --git a/test/integration/models/nodebalancer/test_nodebalancer.py b/test/integration/models/nodebalancer/test_nodebalancer.py index ab3095aaa..692efb027 100644 --- a/test/integration/models/nodebalancer/test_nodebalancer.py +++ b/test/integration/models/nodebalancer/test_nodebalancer.py @@ -1,22 +1,42 @@ import re +from test.integration.conftest import ( + get_api_ca_file, + get_api_url, + get_region, + get_token, +) +from test.integration.helpers import get_test_label import pytest -from linode_api4 import ApiError -from linode_api4.objects import NodeBalancerConfig, NodeBalancerNode +from linode_api4 import ApiError, LinodeClient, NodeBalancer +from linode_api4.objects import ( + NodeBalancerConfig, + NodeBalancerNode, + NodeBalancerType, + RegionPrice, +) + +TEST_REGION = get_region( + LinodeClient( + token=get_token(), + base_url=get_api_url(), + ca_path=get_api_ca_file(), + ), + {"Linodes", "Cloud Firewall", "NodeBalancers"}, + site_type="core", +) @pytest.fixture(scope="session") def linode_with_private_ip(test_linode_client, e2e_test_firewall): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] - label = "linode_with_privateip" + label = get_test_label(8) linode_instance, password = client.linode.instance_create( "g6-nanode-1", - chosen_region, - image="linode/debian10", + TEST_REGION, + image="linode/debian12", label=label, private_ip=True, firewall=e2e_test_firewall, @@ -30,12 +50,10 @@ def linode_with_private_ip(test_linode_client, e2e_test_firewall): @pytest.fixture(scope="session") def create_nb_config(test_linode_client, e2e_test_firewall): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] - label = "nodebalancer_test" + label = get_test_label(8) nb = client.nodebalancer_create( - region=chosen_region, label=label, firewall=e2e_test_firewall.id + region=TEST_REGION, label=label, firewall=e2e_test_firewall.id ) config = nb.config_create() @@ -46,6 +64,55 @@ def create_nb_config(test_linode_client, e2e_test_firewall): nb.delete() +@pytest.fixture(scope="session") +def create_nb_config_with_udp(test_linode_client, e2e_test_firewall): + client = test_linode_client + label = get_test_label(8) + + nb = client.nodebalancer_create( + region=TEST_REGION, label=label, firewall=e2e_test_firewall.id + ) + + config = nb.config_create(protocol="udp", udp_check_port=1234) + + yield config + + config.delete() + nb.delete() + + +@pytest.fixture(scope="session") +def create_nb(test_linode_client, e2e_test_firewall): + client = test_linode_client + label = get_test_label(8) + + nb = client.nodebalancer_create( + region=TEST_REGION, label=label, firewall=e2e_test_firewall.id + ) + + yield nb + + nb.delete() + + +def test_create_nb(test_linode_client, e2e_test_firewall): + client = test_linode_client + label = get_test_label(8) + + nb = client.nodebalancer_create( + region=TEST_REGION, + label=label, + firewall=e2e_test_firewall.id, + client_udp_sess_throttle=5, + ) + + assert TEST_REGION, nb.region + assert label == nb.label + assert 5 == nb.client_udp_sess_throttle + + nb.delete() + + def test_get_nodebalancer_config(test_linode_client, create_nb_config): config = test_linode_client.load( NodeBalancerConfig, @@ -54,6 +121,67 @@ def test_get_nodebalancer_config(test_linode_client, create_nb_config): ) +def test_get_nb_config_with_udp(test_linode_client, create_nb_config_with_udp): + config = test_linode_client.load( + NodeBalancerConfig, + create_nb_config_with_udp.id, + create_nb_config_with_udp.nodebalancer_id, + ) + + assert "udp" == config.protocol + assert 1234 == config.udp_check_port + assert 2 == config.udp_session_timeout + + +def test_update_nb_config(test_linode_client, create_nb_config_with_udp): + config = test_linode_client.load( + NodeBalancerConfig, + create_nb_config_with_udp.id, + create_nb_config_with_udp.nodebalancer_id, + ) + + config.udp_check_port = 4321 + config.save() + + config_updated = test_linode_client.load( + NodeBalancerConfig, + create_nb_config_with_udp.id, + create_nb_config_with_udp.nodebalancer_id, + ) + + assert 4321 == config_updated.udp_check_port + + +def test_get_nb(test_linode_client, create_nb): + nb = test_linode_client.load( + NodeBalancer, + create_nb.id, + ) + + assert nb.id == create_nb.id + + +def test_update_nb(test_linode_client, create_nb): + nb = test_linode_client.load( + NodeBalancer, + create_nb.id, + ) + + new_label = f"{nb.label}-ThisNewLabel" + + nb.label = new_label + nb.client_udp_sess_throttle = 5 + nb.save() + + nb_updated = test_linode_client.load( + NodeBalancer, + create_nb.id, + ) + + assert new_label == nb_updated.label + assert 5 == nb_updated.client_udp_sess_throttle + + @pytest.mark.smoke def test_create_nb_node( test_linode_client, create_nb_config, linode_with_private_ip @@ -73,6 +201,7 @@ def test_create_nb_node( assert "node_test" == node.label +@pytest.mark.smoke def test_get_nb_node(test_linode_client, create_nb_config): node = test_linode_client.load( NodeBalancerNode, @@ -88,7 +217,10 @@ def test_update_nb_node(test_linode_client, create_nb_config): create_nb_config.nodebalancer_id, ) node = config.nodes[0] - node.label = "ThisNewLabel" + + new_label = f"{node.label}-ThisNewLabel" + + node.label = new_label node.weight = 50 node.mode = "accept" node.save() @@ -99,7 +231,7 @@ def test_update_nb_node(test_linode_client, create_nb_config): (create_nb_config.id, create_nb_config.nodebalancer_id), ) - assert "ThisNewLabel" == node_updated.label + assert new_label == node_updated.label assert 50 == node_updated.weight assert "accept" == node_updated.mode @@ -121,3 +253,22 @@ def test_delete_nb_node(test_linode_client, create_nb_config): (create_nb_config.id, create_nb_config.nodebalancer_id), ) assert "Not Found" in str(e.json) + + +def test_nodebalancer_types(test_linode_client): + types = test_linode_client.nodebalancers.types() + + if len(types) > 0: + for nb_type in types: + assert type(nb_type) is NodeBalancerType + assert nb_type.price.monthly is None or ( + isinstance(nb_type.price.monthly, (float, int)) + and nb_type.price.monthly >= 0 + ) + if len(nb_type.region_prices) > 0: + region_price = nb_type.region_prices[0] + assert type(region_price) is RegionPrice + assert region_price.monthly is None or ( + isinstance(region_price.monthly, (float, int)) + and region_price.monthly >= 0 + ) diff --git a/test/integration/models/object_storage/test_obj.py b/test/integration/models/object_storage/test_obj.py index 3042f326a..047dfbdb4 100644 --- a/test/integration/models/object_storage/test_obj.py +++ b/test/integration/models/object_storage/test_obj.py @@ -1,25 +1,35 @@ import time -from test.integration.conftest import get_region +from test.integration.helpers import send_request_when_resource_available import pytest +from linode_api4.common import RegionPrice from linode_api4.linode_client import LinodeClient from linode_api4.objects.object_storage import ( ObjectStorageACL, ObjectStorageBucket, ObjectStorageCluster, + ObjectStorageEndpointType, ObjectStorageKeyPermission, ObjectStorageKeys, + ObjectStorageType, ) @pytest.fixture(scope="session") def region(test_linode_client: LinodeClient): - return get_region(test_linode_client, {"Object Storage"}).id + return "us-southeast" # uncomment get_region(test_linode_client, {"Object Storage"}).id @pytest.fixture(scope="session") -def bucket(test_linode_client: LinodeClient, region: str): +def endpoints(test_linode_client: LinodeClient): + return test_linode_client.object_storage.endpoints() + + +@pytest.fixture(scope="session") +def bucket( + test_linode_client: LinodeClient, region: str +) -> ObjectStorageBucket: bucket = test_linode_client.object_storage.bucket_create( cluster_or_region=region, label="bucket-" + str(time.time_ns()), @@ -28,7 +38,33 @@ def bucket(test_linode_client: LinodeClient, region: str): ) yield bucket - bucket.delete() + send_request_when_resource_available(timeout=100, func=bucket.delete) + + +@pytest.fixture(scope="session") +def bucket_with_endpoint( + test_linode_client: LinodeClient, endpoints +) -> ObjectStorageBucket: + selected_endpoint = next( + ( + e + for e in endpoints + if e.endpoint_type == ObjectStorageEndpointType.E1 + ), + None, + ) + + bucket = test_linode_client.object_storage.bucket_create( + cluster_or_region=selected_endpoint.region, + label="bucket-" + str(time.time_ns()), + acl=ObjectStorageACL.PRIVATE, + cors_enabled=False, + endpoint_type=selected_endpoint.endpoint_type, + ) + + yield bucket + + send_request_when_resource_available(timeout=100, func=bucket.delete) @pytest.fixture(scope="session") @@ -71,19 +107,39 @@ def test_keys( assert loaded_key.label == obj_key.label assert loaded_limited_key.label == obj_limited_key.label + assert ( + loaded_limited_key.regions[0].endpoint_type + in ObjectStorageEndpointType.__members__.values() + ) -def test_bucket( - test_linode_client: LinodeClient, - bucket: ObjectStorageBucket, -): - loaded_bucket = test_linode_client.load(ObjectStorageBucket, bucket.label) +def test_bucket(test_linode_client: LinodeClient, bucket: ObjectStorageBucket): + loaded_bucket = test_linode_client.load( + ObjectStorageBucket, + target_id=bucket.label, + target_parent_id=bucket.region, + ) assert loaded_bucket.label == bucket.label assert loaded_bucket.region == bucket.region -def test_bucket( +def test_bucket_with_endpoint( + test_linode_client: LinodeClient, bucket_with_endpoint: ObjectStorageBucket +): + loaded_bucket = test_linode_client.load( + ObjectStorageBucket, + target_id=bucket_with_endpoint.label, + target_parent_id=bucket_with_endpoint.region, + ) + + assert loaded_bucket.label == bucket_with_endpoint.label + assert loaded_bucket.region == bucket_with_endpoint.region + assert loaded_bucket.s3_endpoint is not None + assert loaded_bucket.endpoint_type == "E1" + + +def test_buckets_in_region( test_linode_client: LinodeClient, bucket: ObjectStorageBucket, region: str, @@ -93,6 +149,7 @@ def test_bucket( assert any(b.label == bucket.label for b in buckets) +@pytest.mark.smoke def test_list_obj_storage_bucket( test_linode_client: LinodeClient, bucket: ObjectStorageBucket, @@ -102,6 +159,14 @@ def test_list_obj_storage_bucket( assert any(target_bucket_id == b.id for b in buckets) +def test_bucket_access_get(bucket: ObjectStorageBucket): + access = bucket.access_get() + + assert access.acl is not None + assert access.acl_xml is not None + assert access.cors_enabled is not None + + def test_bucket_access_modify(bucket: ObjectStorageBucket): bucket.access_modify(ObjectStorageACL.PRIVATE, cors_enabled=True) @@ -129,3 +194,22 @@ def test_get_buckets_in_cluster( ): cluster = test_linode_client.load(ObjectStorageCluster, bucket.cluster) assert any(bucket.id == b.id for b in cluster.buckets_in_cluster()) + + +def test_object_storage_types(test_linode_client): + types = test_linode_client.object_storage.types() + + if len(types) > 0: + for object_storage_type in types: + assert type(object_storage_type) is ObjectStorageType + assert object_storage_type.price.monthly is None or ( + isinstance(object_storage_type.price.monthly, (float, int)) + and object_storage_type.price.monthly >= 0 + ) + if len(object_storage_type.region_prices) > 0: + region_price = object_storage_type.region_prices[0] + assert type(region_price) is RegionPrice + assert object_storage_type.price.monthly is None or ( + isinstance(object_storage_type.price.monthly, (float, int)) + and object_storage_type.price.monthly >= 0 + ) diff --git a/test/integration/models/object_storage/test_obj_quotas.py b/test/integration/models/object_storage/test_obj_quotas.py new file mode 100644 index 000000000..10a546bc7 --- /dev/null +++ b/test/integration/models/object_storage/test_obj_quotas.py @@ -0,0 +1,45 @@ +import pytest + +from linode_api4.objects.object_storage import ( + ObjectStorageQuota, + ObjectStorageQuotaUsage, +) + + +def test_list_and_get_obj_storage_quotas(test_linode_client): + quotas = test_linode_client.object_storage.quotas() + + if len(quotas) < 1: + pytest.skip("No available quota for testing. Skipping now...") + + found_quota = quotas[0] + + get_quota = test_linode_client.load( + ObjectStorageQuota, found_quota.quota_id + ) + + assert found_quota.quota_id == get_quota.quota_id + assert found_quota.quota_name == get_quota.quota_name + assert found_quota.endpoint_type == get_quota.endpoint_type + assert found_quota.s3_endpoint == get_quota.s3_endpoint + assert found_quota.description == get_quota.description + assert found_quota.quota_limit == get_quota.quota_limit + assert found_quota.resource_metric == get_quota.resource_metric + + +def test_get_obj_storage_quota_usage(test_linode_client): + quotas = test_linode_client.object_storage.quotas() + + if len(quotas) < 1: + pytest.skip("No available quota for testing. Skipping now...") + + quota_id = quotas[0].quota_id + quota = test_linode_client.load(ObjectStorageQuota, quota_id) + + quota_usage = quota.usage() + + assert isinstance(quota_usage, ObjectStorageQuotaUsage) + assert quota_usage.quota_limit >= 0 + + if quota_usage.usage is not None: + assert quota_usage.usage >= 0 diff --git a/test/integration/models/placement/test_placement.py b/test/integration/models/placement/test_placement.py index 7919ef432..21c6519f5 100644 --- a/test/integration/models/placement/test_placement.py +++ b/test/integration/models/placement/test_placement.py @@ -1,6 +1,21 @@ -from linode_api4 import PlacementGroup +from test.integration.conftest import get_region +from test.integration.helpers import ( + get_test_label, + send_request_when_resource_available, +) +import pytest +from linode_api4 import ( + MigratedInstance, + MigrationType, + PlacementGroup, + PlacementGroupPolicy, + PlacementGroupType, +) + + +@pytest.mark.smoke def test_get_pg(test_linode_client, create_placement_group): """ Tests that a Placement Group can be loaded. @@ -9,6 +24,7 @@ def test_get_pg(test_linode_client, create_placement_group): assert pg.id == create_placement_group.id +@pytest.mark.smoke def test_update_pg(test_linode_client, create_placement_group): """ Tests that a Placement Group can be updated successfully. @@ -44,3 +60,54 @@ def test_pg_assignment(test_linode_client, create_placement_group_with_linode): assert pg.members[0].linode_id == inst.id assert inst.placement_group.id == pg.id + + +def test_pg_migration( + test_linode_client, e2e_test_firewall, create_placement_group +): + """ + Tests that an instance can be migrated into and our of PGs successfully. + """ + client = test_linode_client + + label_pg = get_test_label(10) + + label_instance = get_test_label(10) + + pg_outbound = client.placement.group_create( + label_pg, + get_region(test_linode_client, {"Placement Group"}), + PlacementGroupType.anti_affinity_local, + PlacementGroupPolicy.flexible, + ) + + linode = client.linode.instance_create( + "g6-nanode-1", + pg_outbound.region, + label=label_instance, + placement_group=pg_outbound, + ) + + pg_inbound = create_placement_group + + # Says it could take up to ~6 hrs for migration to fully complete + send_request_when_resource_available( + 300, + linode.initiate_migration, + placement_group=pg_inbound.id, + migration_type=MigrationType.COLD, + region=pg_inbound.region, + ) + + pg_inbound = test_linode_client.load(PlacementGroup, pg_inbound.id) + pg_outbound = test_linode_client.load(PlacementGroup, pg_outbound.id) + + assert pg_inbound.migrations.inbound[0] == MigratedInstance( + linode_id=linode.id + ) + assert pg_outbound.migrations.outbound[0] == MigratedInstance( + linode_id=linode.id + ) + + linode.delete() + pg_outbound.delete() diff --git a/test/integration/models/profile/test_profile.py b/test/integration/models/profile/test_profile.py index cafec12ea..6942eea38 100644 --- a/test/integration/models/profile/test_profile.py +++ b/test/integration/models/profile/test_profile.py @@ -1,6 +1,9 @@ +import pytest + from linode_api4.objects import PersonalAccessToken, Profile, SSHKey +@pytest.mark.smoke def test_user_profile(test_linode_client): client = test_linode_client @@ -18,6 +21,8 @@ def test_get_personal_access_token_objects(test_linode_client): assert isinstance(personal_access_tokens[0], PersonalAccessToken) +@pytest.mark.smoke +@pytest.mark.flaky(reruns=3, reruns_delay=2) def test_get_sshkeys(test_linode_client, test_sshkey): client = test_linode_client @@ -29,6 +34,7 @@ def test_get_sshkeys(test_linode_client, test_sshkey): assert test_sshkey.label in ssh_labels +@pytest.mark.flaky(reruns=3, reruns_delay=2) def test_ssh_key_create(test_sshkey, ssh_key_gen): pub_key = ssh_key_gen[0] key = test_sshkey diff --git a/test/integration/models/region/test_region.py b/test/integration/models/region/test_region.py new file mode 100644 index 000000000..d9d4006a7 --- /dev/null +++ b/test/integration/models/region/test_region.py @@ -0,0 +1,62 @@ +import pytest + +from linode_api4.objects import Region + + +@pytest.mark.smoke +def test_list_regions_vpc_availability(test_linode_client): + """ + Test listing VPC availability for all regions. + """ + client = test_linode_client + + vpc_availability = client.regions.vpc_availability() + + assert len(vpc_availability) > 0 + + for entry in vpc_availability: + assert entry.region is not None + assert len(entry.region) > 0 + assert entry.available is not None + assert isinstance(entry.available, bool) + # available_ipv6_prefix_lengths may be empty list but should exist + assert entry.available_ipv6_prefix_lengths is not None + assert isinstance(entry.available_ipv6_prefix_lengths, list) + + +@pytest.mark.smoke +def test_get_region_vpc_availability_via_object(test_linode_client): + """ + Test getting VPC availability via the Region object property. + """ + client = test_linode_client + + # Get the first available region + regions = client.regions() + assert len(regions) > 0 + test_region_id = regions[0].id + + region = Region(client, test_region_id) + vpc_avail = region.vpc_availability + + assert vpc_avail is not None + assert vpc_avail.region == test_region_id + assert vpc_avail.available is not None + assert isinstance(vpc_avail.available, bool) + assert vpc_avail.available_ipv6_prefix_lengths is not None + assert isinstance(vpc_avail.available_ipv6_prefix_lengths, list) + + +def test_vpc_availability_available_regions(test_linode_client): + """ + Test that some regions have VPC availability enabled. + """ + client = test_linode_client + + vpc_availability = client.regions.vpc_availability() + + # Filter for regions where VPC is available + available_regions = [v for v in vpc_availability if v.available] + + # There should be at least some regions with VPC available + assert len(available_regions) > 0 diff --git a/test/integration/models/sharegroups/test_sharegroups.py b/test/integration/models/sharegroups/test_sharegroups.py new file mode 100644 index 000000000..9c66bad90 --- /dev/null +++ b/test/integration/models/sharegroups/test_sharegroups.py @@ -0,0 +1,251 @@ +import datetime +from test.integration.conftest import get_region +from test.integration.helpers import ( + get_test_label, +) + +import pytest + +from linode_api4.objects import ( + Image, + ImageShareGroup, + ImageShareGroupImagesToAdd, + ImageShareGroupImageToAdd, + ImageShareGroupImageToUpdate, + ImageShareGroupMemberToAdd, + ImageShareGroupMemberToUpdate, + ImageShareGroupToken, +) + + +def wait_for_image_status( + test_linode_client, image_id, expected_status, timeout=360, interval=5 +): + import time + + get_image = test_linode_client.load(Image, image_id) + timer = 0 + while get_image.status != expected_status and timer < timeout: + time.sleep(interval) + timer += interval + get_image = test_linode_client.load(Image, image_id) + if timer >= timeout: + raise TimeoutError( + f"Created image did not reach status '{expected_status}' within {timeout} seconds." + ) + + +@pytest.fixture(scope="class") +def sample_linode(test_linode_client, e2e_test_firewall): + client = test_linode_client + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") + label = get_test_label(length=8) + + linode_instance, password = client.linode.instance_create( + "g6-nanode-1", + region, + image="linode/alpine3.19", + label=label + "_modlinode", + ) + yield linode_instance + linode_instance.delete() + + +@pytest.fixture(scope="class") +def create_image_id(test_linode_client, sample_linode): + create_image = test_linode_client.images.create( + sample_linode.disks[0], + label="linode-api4python-test-image-sharing-image", + ) + wait_for_image_status(test_linode_client, create_image.id, "available") + yield create_image.id + create_image.delete() + + +@pytest.fixture(scope="function") +def share_group_id(test_linode_client): + group_label = get_test_label(8) + "_sharegroup_api4_test" + group = test_linode_client.sharegroups.create_sharegroup( + label=group_label, + description="Test api4python", + ) + yield group.id + group.delete() + + +def test_get_share_groups(test_linode_client, share_group_id): + response = test_linode_client.sharegroups() + sharegroups_list = response.lists[0] + assert len(sharegroups_list) > 0 + assert sharegroups_list[0].api_endpoint == "/images/sharegroups/{id}" + assert sharegroups_list[0].id > 0 + assert sharegroups_list[0].description != "" + assert isinstance(sharegroups_list[0].images_count, int) + assert not sharegroups_list[0].is_suspended + assert sharegroups_list[0].label != "" + assert isinstance(sharegroups_list[0].members_count, int) + assert sharegroups_list[0].uuid != "" + assert isinstance(sharegroups_list[0].created, datetime.date) + assert not sharegroups_list[0].expiry + + +def test_add_update_remove_share_group(test_linode_client): + group_label = get_test_label(8) + "_sharegroup_api4_test" + share_group = test_linode_client.sharegroups.create_sharegroup( + label=group_label, + description="Test api4python create", + ) + assert share_group.api_endpoint == "/images/sharegroups/{id}" + assert share_group.id > 0 + assert share_group.description == "Test api4python create" + assert isinstance(share_group.images_count, int) + assert not share_group.is_suspended + assert share_group.label == group_label + assert isinstance(share_group.members_count, int) + assert share_group.uuid != "" + assert isinstance(share_group.created, datetime.date) + assert not share_group.updated + assert not share_group.expiry + + load_share_group = test_linode_client.load(ImageShareGroup, share_group.id) + assert load_share_group.id == share_group.id + assert load_share_group.description == "Test api4python create" + + load_share_group.label = "Updated Sharegroup Label" + load_share_group.description = "Test update description" + load_share_group.save() + load_share_group_after_update = test_linode_client.load( + ImageShareGroup, share_group.id + ) + assert load_share_group_after_update.id == share_group.id + assert load_share_group_after_update.label == "Updated Sharegroup Label" + assert ( + load_share_group_after_update.description == "Test update description" + ) + + share_group.delete() + with pytest.raises(RuntimeError) as err: + test_linode_client.load(ImageShareGroup, share_group.id) + assert "[404] Not found" in str(err.value) + + +def test_add_get_update_revoke_image_to_share_group( + test_linode_client, create_image_id, share_group_id +): + share_group = test_linode_client.load(ImageShareGroup, share_group_id) + add_image_response = share_group.add_images( + ImageShareGroupImagesToAdd( + images=[ + ImageShareGroupImageToAdd(id=create_image_id), + ] + ) + ) + assert 0 < len(add_image_response) + assert ( + add_image_response[0].image_sharing.shared_by.sharegroup_id + == share_group.id + ) + assert ( + add_image_response[0].image_sharing.shared_by.source_image_id + == create_image_id + ) + + get_response = share_group.get_image_shares() + assert 0 < len(get_response) + assert ( + get_response[0].image_sharing.shared_by.sharegroup_id == share_group.id + ) + assert ( + get_response[0].image_sharing.shared_by.source_image_id + == create_image_id + ) + assert get_response[0].description == "" + + update_response = share_group.update_image_share( + ImageShareGroupImageToUpdate( + image_share_id=get_response[0].id, description="Description update" + ) + ) + assert update_response.description == "Description update" + + share_groups_by_image_id_response = ( + test_linode_client.sharegroups.sharegroups_by_image_id(create_image_id) + ) + assert 0 < len(share_groups_by_image_id_response.lists) + assert share_groups_by_image_id_response.lists[0][0].id == share_group.id + + share_group.revoke_image_share(get_response[0].id) + get_after_revoke_response = share_group.get_image_shares() + assert len(get_after_revoke_response) == 0 + + +def test_list_tokens(test_linode_client): + response = test_linode_client.sharegroups.tokens() + assert response.page_endpoint == "images/sharegroups/tokens" + assert len(response.lists[0]) >= 0 + + +def test_create_token_to_own_share_group_error(test_linode_client): + group_label = get_test_label(8) + "_sharegroup_api4_test" + response_create_share_group = ( + test_linode_client.sharegroups.create_sharegroup( + label=group_label, + description="Test api4python create", + ) + ) + with pytest.raises(RuntimeError) as err: + test_linode_client.sharegroups.create_token( + response_create_share_group.uuid + ) + assert "[400] valid_for_sharegroup_uuid" in str(err.value) + assert "You may not create a token for your own sharegroup" in str( + err.value + ) + + response_create_share_group.delete() + + +def test_get_invalid_token(test_linode_client): + with pytest.raises(RuntimeError) as err: + test_linode_client.load(ImageShareGroupToken, "36b0-4d52_invalid") + assert "[404] Not found" in str(err.value) + + +def test_try_to_add_member_invalid_token(test_linode_client, share_group_id): + share_group = test_linode_client.load(ImageShareGroup, share_group_id) + with pytest.raises(RuntimeError) as err: + share_group.add_member( + ImageShareGroupMemberToAdd( + token="not_existing_token", + label="New Member", + ) + ) + assert "[500] Invalid token format" in str(err.value) + + +def test_list_share_group_members(test_linode_client, share_group_id): + share_group = test_linode_client.load(ImageShareGroup, share_group_id) + response = share_group.get_members() + assert 0 == len(response) + + +def test_try_to_get_update_revoke_share_group_member_by_invalid_token( + test_linode_client, share_group_id +): + share_group = test_linode_client.load(ImageShareGroup, share_group_id) + with pytest.raises(RuntimeError) as err: + share_group.get_member("not_existing_token") + assert "[404] Not found" in str(err.value) + + with pytest.raises(RuntimeError) as err: + share_group.update_member( + ImageShareGroupMemberToUpdate( + token_uuid="not_existing_token", + label="Update Member", + ) + ) + assert "[404] Not found" in str(err.value) + + with pytest.raises(RuntimeError) as err: + share_group.remove_member("not_existing_token") + assert "[404] Not found" in str(err.value) diff --git a/test/integration/models/volume/test_blockstorage.py b/test/integration/models/volume/test_blockstorage.py new file mode 100644 index 000000000..8dac88e18 --- /dev/null +++ b/test/integration/models/volume/test_blockstorage.py @@ -0,0 +1,40 @@ +from test.integration.conftest import get_region +from test.integration.helpers import get_test_label, retry_sending_request + + +def test_config_create_with_extended_volume_limit(test_linode_client): + client = test_linode_client + + region = get_region(client, {"Linodes", "Block Storage"}, site_type="core") + label = get_test_label() + + linode, _ = client.linode.instance_create( + "g6-standard-6", + region, + image="linode/debian12", + label=label, + ) + + volumes = [ + client.volume_create( + f"{label}-vol-{i}", + region=region, + size=10, + ) + for i in range(12) + ] + + config = linode.config_create(volumes=volumes) + + devices = config._raw_json["devices"] + + assert len([d for d in devices.values() if d is not None]) == 12 + + assert "sdi" in devices + assert "sdj" in devices + assert "sdk" in devices + assert "sdl" in devices + + linode.delete() + for v in volumes: + retry_sending_request(3, v.delete) diff --git a/test/integration/models/volume/test_volume.py b/test/integration/models/volume/test_volume.py index 08e836a13..56395d203 100644 --- a/test/integration/models/volume/test_volume.py +++ b/test/integration/models/volume/test_volume.py @@ -1,54 +1,72 @@ import time -from test.integration.conftest import get_token +from test.integration.conftest import ( + get_api_ca_file, + get_api_url, + get_region, + get_token, +) from test.integration.helpers import ( get_test_label, retry_sending_request, + send_request_when_resource_available, wait_for_condition, ) import pytest -from linode_api4 import ApiError, LinodeClient -from linode_api4.objects import Volume +from linode_api4 import LinodeClient +from linode_api4.objects import RegionPrice, Volume, VolumeType + +TEST_REGION = get_region( + LinodeClient( + token=get_token(), + base_url=get_api_url(), + ca_path=get_api_ca_file(), + ), + {"Linodes", "Cloud Firewall"}, + site_type="core", +) + + +@pytest.fixture(scope="session") +def test_volume(test_linode_client): + client = test_linode_client + label = get_test_label(length=8) + + volume = client.volume_create(label=label, region=TEST_REGION) + + yield volume + + send_request_when_resource_available(timeout=100, func=volume.delete) @pytest.fixture(scope="session") def linode_for_volume(test_linode_client, e2e_test_firewall): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] - timestamp = str(time.time_ns()) - label = "TestSDK-" + timestamp + + label = get_test_label(length=8) linode_instance, password = client.linode.instance_create( "g6-nanode-1", - chosen_region, - image="linode/debian10", + TEST_REGION, + image="linode/debian12", label=label, firewall=e2e_test_firewall, ) yield linode_instance - timeout = 100 # give 100s for volume to be detached before deletion - - start_time = time.time() - - while time.time() - start_time < timeout: - try: - res = linode_instance.delete() - - if res: - break - else: - time.sleep(3) - except ApiError as e: - if time.time() - start_time > timeout: - raise e + send_request_when_resource_available( + timeout=100, func=linode_instance.delete + ) def get_status(volume: Volume, status: str): - client = LinodeClient(token=get_token()) + client = LinodeClient( + token=get_token(), + base_url=get_api_url(), + ca_path=get_api_ca_file(), + ) volume = client.load(Volume, volume.id) return volume.status == status @@ -60,17 +78,26 @@ def test_get_volume(test_linode_client, test_volume): assert volume.id == test_volume.id +def test_get_volume_with_encryption( + test_linode_client, test_volume_with_encryption +): + volume = test_linode_client.load(Volume, test_volume_with_encryption.id) + + assert volume.id == test_volume_with_encryption.id + assert volume.encryption == "enabled" + + def test_update_volume_tag(test_linode_client, test_volume): volume = test_volume - tag_1 = "volume_test_tag1" - tag_2 = "volume_test_tag2" + tag_1 = get_test_label(10) + tag_2 = get_test_label(10) volume.tags = [tag_1, tag_2] volume.save() volume = test_linode_client.load(Volume, test_volume.id) - assert [tag_1, tag_2] == volume.tags + assert all(tag in volume.tags for tag in [tag_1, tag_2]) def test_volume_resize(test_linode_client, test_volume): @@ -104,7 +131,7 @@ def test_attach_volume_to_linode( volume = test_volume linode = linode_for_volume - res = retry_sending_request(5, volume.attach, linode.id) + res = retry_sending_request(5, volume.attach, linode.id, backoff=30) assert res @@ -121,3 +148,22 @@ def test_detach_volume_to_linode( # time wait for volume to detach before deletion occurs time.sleep(30) + + +def test_volume_types(test_linode_client): + types = test_linode_client.volumes.types() + + if len(types) > 0: + for volume_type in types: + assert type(volume_type) is VolumeType + assert volume_type.price.monthly is None or ( + isinstance(volume_type.price.monthly, (float, int)) + and volume_type.price.monthly >= 0 + ) + if len(volume_type.region_prices) > 0: + region_price = volume_type.region_prices[0] + assert type(region_price) is RegionPrice + assert region_price.monthly is None or ( + isinstance(region_price.monthly, (float, int)) + and region_price.monthly >= 0 + ) diff --git a/test/integration/models/vpc/test_vpc.py b/test/integration/models/vpc/test_vpc.py index 6af3380b7..85d32d858 100644 --- a/test/integration/models/vpc/test_vpc.py +++ b/test/integration/models/vpc/test_vpc.py @@ -5,12 +5,15 @@ from linode_api4 import VPC, ApiError, VPCSubnet +@pytest.mark.smoke def test_get_vpc(test_linode_client, create_vpc): vpc = test_linode_client.load(VPC, create_vpc.id) test_linode_client.vpcs() assert vpc.id == create_vpc.id + assert isinstance(vpc.ipv6[0].range, str) +@pytest.mark.smoke def test_update_vpc(test_linode_client, create_vpc): vpc = create_vpc new_label = create_vpc.label + "-updated" @@ -29,10 +32,15 @@ def test_update_vpc(test_linode_client, create_vpc): def test_get_subnet(test_linode_client, create_vpc_with_subnet): vpc, subnet = create_vpc_with_subnet loaded_subnet = test_linode_client.load(VPCSubnet, subnet.id, vpc.id) - + assert loaded_subnet.ipv4 == subnet.ipv4 + assert loaded_subnet.ipv6 is not None + assert loaded_subnet.ipv6[0].range.startswith( + vpc.ipv6[0].range.split("::")[0] + ) assert loaded_subnet.id == subnet.id +@pytest.mark.smoke def test_update_subnet(test_linode_client, create_vpc_with_subnet): vpc, subnet = create_vpc_with_subnet new_label = subnet.label + "-updated" @@ -53,7 +61,6 @@ def test_fails_create_vpc_invalid_data(test_linode_client): description="test description", ) assert excinfo.value.status == 400 - assert "Label must include only ASCII" in str(excinfo.value.json) def test_get_all_vpcs(test_linode_client, create_multiple_vpcs): @@ -75,7 +82,6 @@ def test_fails_update_vpc_invalid_data(create_vpc): vpc.save() assert excinfo.value.status == 400 - assert "Label must include only ASCII" in str(excinfo.value.json) def test_fails_create_subnet_invalid_data(create_vpc): @@ -85,7 +91,9 @@ def test_fails_create_subnet_invalid_data(create_vpc): create_vpc.subnet_create("test-subnet", ipv4=invalid_ipv4) assert excinfo.value.status == 400 - assert "ipv4 must be an IPv4 network" in str(excinfo.value.json) + error_msg = str(excinfo.value.json) + + assert "Must be an IPv4 network" in error_msg def test_fails_update_subnet_invalid_data(create_vpc_with_subnet): @@ -97,4 +105,36 @@ def test_fails_update_subnet_invalid_data(create_vpc_with_subnet): subnet.save() assert excinfo.value.status == 400 - assert "Label must include only ASCII" in str(excinfo.value.json) + assert "Must only use ASCII" in str(excinfo.value.json) + + +def test_fails_create_subnet_with_invalid_ipv6_range(create_vpc): + valid_ipv4 = "10.0.0.0/24" + invalid_ipv6 = [{"range": "2600:3c11:e5b9::/5a"}] + + with pytest.raises(ApiError) as excinfo: + create_vpc.subnet_create( + label="bad-ipv6-subnet", + ipv4=valid_ipv4, + ipv6=invalid_ipv6, + ) + + assert excinfo.value.status == 400 + error = excinfo.value.json["errors"] + + assert any( + e["field"] == "ipv6[0].range" + and "Must be an IPv6 network" in e["reason"] + for e in error + ) + + +def test_get_vpc_ipv6s(test_linode_client): + ipv6s = test_linode_client.get("/vpcs/ipv6s")["data"] + + assert isinstance(ipv6s, list) + + for ipv6 in ipv6s: + assert "vpc_id" in ipv6 + assert isinstance(ipv6["ipv6_range"], str) + assert isinstance(ipv6["ipv6_addresses"], list) diff --git a/test/unit/base.py b/test/unit/base.py index e143f8f64..bc0ec2f08 100644 --- a/test/unit/base.py +++ b/test/unit/base.py @@ -4,7 +4,7 @@ from mock import patch -from linode_api4 import LinodeClient +from linode_api4 import LinodeClient, MonitorClient FIXTURES = TestFixtures() @@ -202,3 +202,29 @@ def mock_delete(self): mocked requests """ return MethodMock("delete", {}) + + +class MonitorClientBaseCase(TestCase): + def setUp(self): + self.client = MonitorClient("testing", base_url="/") + + self.get_patch = patch( + "linode_api4.linode_client.requests.Session.get", + side_effect=mock_get, + ) + self.get_patch.start() + + def tearDown(self): + self.get_patch.stop() + + def mock_post(self, return_dct): + """ + Returns a MethodMock mocking a POST. This should be used in a with + statement. + + :param return_dct: The JSON that should be returned from this POST + + :returns: A MethodMock object who will capture the parameters of the + mocked requests + """ + return MethodMock("post", return_dct) diff --git a/test/unit/errors_test.py b/test/unit/errors_test.py new file mode 100644 index 000000000..017c96280 --- /dev/null +++ b/test/unit/errors_test.py @@ -0,0 +1,104 @@ +from types import SimpleNamespace +from unittest import TestCase + +from linode_api4.errors import ApiError, UnexpectedResponseError + + +class ApiErrorTest(TestCase): + def test_from_response(self): + mock_response = SimpleNamespace( + status_code=400, + json=lambda: { + "errors": [ + {"reason": "foo"}, + {"field": "bar", "reason": "oh no"}, + ] + }, + text='{"errors": [{"reason": "foo"}, {"field": "bar", "reason": "oh no"}]}', + request=SimpleNamespace( + method="POST", + path_url="foo/bar", + ), + ) + + exc = ApiError.from_response(mock_response) + + assert str(exc) == "POST foo/bar: [400] foo; bar: oh no" + assert exc.status == 400 + assert exc.json == { + "errors": [{"reason": "foo"}, {"field": "bar", "reason": "oh no"}] + } + assert exc.response.request.method == "POST" + assert exc.response.request.path_url == "foo/bar" + + def test_from_response_non_json_body(self): + mock_response = SimpleNamespace( + status_code=500, + json=lambda: None, + text="foobar", + request=SimpleNamespace( + method="POST", + path_url="foo/bar", + ), + ) + + exc = ApiError.from_response(mock_response) + + assert str(exc) == "POST foo/bar: [500] foobar" + assert exc.status == 500 + assert exc.json is None + assert exc.response.request.method == "POST" + assert exc.response.request.path_url == "foo/bar" + + def test_from_response_empty_body(self): + mock_response = SimpleNamespace( + status_code=500, + json=lambda: None, + text=None, + request=SimpleNamespace( + method="POST", + path_url="foo/bar", + ), + ) + + exc = ApiError.from_response(mock_response) + + assert str(exc) == "POST foo/bar: [500] N/A" + assert exc.status == 500 + assert exc.json is None + assert exc.response.request.method == "POST" + assert exc.response.request.path_url == "foo/bar" + + def test_from_response_no_request(self): + mock_response = SimpleNamespace( + status_code=500, json=lambda: None, text="foobar", request=None + ) + + exc = ApiError.from_response(mock_response) + + assert str(exc) == "[500] foobar" + assert exc.status == 500 + assert exc.json is None + assert exc.response.request is None + + +class UnexpectedResponseErrorTest(TestCase): + def test_from_response(self): + mock_response = SimpleNamespace( + status_code=400, + json=lambda: { + "foo": "bar", + }, + request=SimpleNamespace( + method="POST", + path_url="foo/bar", + ), + ) + + exc = UnexpectedResponseError.from_response("foobar", mock_response) + + assert str(exc) == "foobar" + assert exc.status == 400 + assert exc.json == {"foo": "bar"} + assert exc.response.request.method == "POST" + assert exc.response.request.path_url == "foo/bar" diff --git a/test/unit/fixtures.py b/test/unit/fixtures.py index 52d41d84c..c943da95c 100644 --- a/test/unit/fixtures.py +++ b/test/unit/fixtures.py @@ -1,9 +1,8 @@ import json -import os import re -import sys +from pathlib import Path -FIXTURES_DIR = sys.path[0] + "/test/fixtures" +FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" # This regex is useful for finding individual underscore characters, # which is necessary to allow us to use underscores in URL paths. @@ -30,18 +29,18 @@ def _load_fixtures(self): """ self.fixtures = {} - for json_file in os.listdir(FIXTURES_DIR): - if not json_file.endswith(".json"): + for json_file in FIXTURES_DIR.iterdir(): + if json_file.suffix != ".json": continue - with open(FIXTURES_DIR + "/" + json_file) as f: + with open(json_file) as f: raw = f.read() data = json.loads(raw) - fixture_url = PATH_REPLACEMENT_REGEX.sub("/", json_file).replace( - "__", "_" - )[:-5] + fixture_url = PATH_REPLACEMENT_REGEX.sub( + "/", json_file.name + ).replace("__", "_")[:-5] self.fixtures[fixture_url] = data diff --git a/test/unit/groups/database_test.py b/test/unit/groups/database_test.py new file mode 100644 index 000000000..8038e8c6b --- /dev/null +++ b/test/unit/groups/database_test.py @@ -0,0 +1,1465 @@ +import logging +from test.unit.base import ClientBaseCase + +from linode_api4.objects import MySQLDatabase + +logger = logging.getLogger(__name__) + + +class DatabaseTest(ClientBaseCase): + """ + Tests methods of the DatabaseGroup class + """ + + def test_get_types(self): + """ + Test that database types are properly handled + """ + types = self.client.database.types() + + self.assertEqual(len(types), 1) + self.assertEqual(types[0].type_class, "nanode") + self.assertEqual(types[0].id, "g6-nanode-1") + self.assertEqual(types[0].engines.mysql[0].price.monthly, 20) + + def test_get_engines(self): + """ + Test that database engines are properly handled + """ + engines = self.client.database.engines() + + self.assertEqual(len(engines), 2) + + self.assertEqual(engines[0].engine, "mysql") + self.assertEqual(engines[0].id, "mysql/8.0.26") + self.assertEqual(engines[0].version, "8.0.26") + + self.assertEqual(engines[1].engine, "postgresql") + self.assertEqual(engines[1].id, "postgresql/10.14") + self.assertEqual(engines[1].version, "10.14") + + def test_get_databases(self): + """ + Test that databases are properly handled + """ + dbs = self.client.database.instances() + + self.assertEqual(len(dbs), 1) + self.assertEqual(dbs[0].allow_list[1], "192.0.1.0/24") + self.assertEqual(dbs[0].cluster_size, 3) + self.assertEqual(dbs[0].encrypted, False) + self.assertEqual(dbs[0].engine, "mysql") + self.assertEqual( + dbs[0].hosts.primary, + "lin-123-456-mysql-mysql-primary.servers.linodedb.net", + ) + self.assertEqual( + dbs[0].hosts.standby, + "lin-123-456-mysql-primary-private.servers.linodedb.net", + ) + self.assertEqual(dbs[0].id, 123) + self.assertEqual(dbs[0].region, "us-east") + self.assertEqual(dbs[0].updates.duration, 3) + self.assertEqual(dbs[0].version, "8.0.26") + self.assertEqual(dbs[0].private_network.vpc_id, 1234) + self.assertEqual(dbs[0].private_network.subnet_id, 5678) + self.assertEqual(dbs[0].private_network.public_access, True) + + def test_database_instance(self): + """ + Ensures that the .instance attribute properly translates database types + """ + + dbs = self.client.database.instances() + db_translated = dbs[0].instance + + self.assertTrue(isinstance(db_translated, MySQLDatabase)) + self.assertEqual(db_translated.ssl_connection, True) + + def test_mysql_config_options(self): + """ + Test that MySQL configuration options can be retrieved + """ + + config = self.client.database.mysql_config_options() + + self.assertEqual( + "The number of seconds that the mysqld server waits for a connect packet before responding with Bad handshake", + config["mysql"]["connect_timeout"]["description"], + ) + self.assertEqual(10, config["mysql"]["connect_timeout"]["example"]) + self.assertEqual(3600, config["mysql"]["connect_timeout"]["maximum"]) + self.assertEqual(2, config["mysql"]["connect_timeout"]["minimum"]) + self.assertFalse(config["mysql"]["connect_timeout"]["requires_restart"]) + self.assertEqual("integer", config["mysql"]["connect_timeout"]["type"]) + + self.assertEqual( + "Default server time zone as an offset from UTC (from -12:00 to +12:00), a time zone name, or 'SYSTEM' to use the MySQL server default.", + config["mysql"]["default_time_zone"]["description"], + ) + self.assertEqual( + "+03:00", config["mysql"]["default_time_zone"]["example"] + ) + self.assertEqual(100, config["mysql"]["default_time_zone"]["maxLength"]) + self.assertEqual(2, config["mysql"]["default_time_zone"]["minLength"]) + self.assertEqual( + "^([-+][\\d:]*|[\\w/]*)$", + config["mysql"]["default_time_zone"]["pattern"], + ) + self.assertFalse( + config["mysql"]["default_time_zone"]["requires_restart"] + ) + self.assertEqual("string", config["mysql"]["default_time_zone"]["type"]) + + self.assertEqual( + "The maximum permitted result length in bytes for the GROUP_CONCAT() function.", + config["mysql"]["group_concat_max_len"]["description"], + ) + self.assertEqual( + 1024, config["mysql"]["group_concat_max_len"]["example"] + ) + self.assertEqual( + 18446744073709551600, + config["mysql"]["group_concat_max_len"]["maximum"], + ) + self.assertEqual(4, config["mysql"]["group_concat_max_len"]["minimum"]) + self.assertFalse( + config["mysql"]["group_concat_max_len"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["group_concat_max_len"]["type"] + ) + + self.assertEqual( + "The time, in seconds, before cached statistics expire", + config["mysql"]["information_schema_stats_expiry"]["description"], + ) + self.assertEqual( + 86400, config["mysql"]["information_schema_stats_expiry"]["example"] + ) + self.assertEqual( + 31536000, + config["mysql"]["information_schema_stats_expiry"]["maximum"], + ) + self.assertEqual( + 900, config["mysql"]["information_schema_stats_expiry"]["minimum"] + ) + self.assertFalse( + config["mysql"]["information_schema_stats_expiry"][ + "requires_restart" + ] + ) + self.assertEqual( + "integer", + config["mysql"]["information_schema_stats_expiry"]["type"], + ) + + self.assertEqual( + "Maximum size for the InnoDB change buffer, as a percentage of the total size of the buffer pool. Default is 25", + config["mysql"]["innodb_change_buffer_max_size"]["description"], + ) + self.assertEqual( + 30, config["mysql"]["innodb_change_buffer_max_size"]["example"] + ) + self.assertEqual( + 50, config["mysql"]["innodb_change_buffer_max_size"]["maximum"] + ) + self.assertEqual( + 0, config["mysql"]["innodb_change_buffer_max_size"]["minimum"] + ) + self.assertFalse( + config["mysql"]["innodb_change_buffer_max_size"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["innodb_change_buffer_max_size"]["type"] + ) + + self.assertEqual( + "Specifies whether flushing a page from the InnoDB buffer pool also flushes other dirty pages in the same extent (default is 1): 0 - dirty pages in the same extent are not flushed, 1 - flush contiguous dirty pages in the same extent, 2 - flush dirty pages in the same extent", + config["mysql"]["innodb_flush_neighbors"]["description"], + ) + self.assertEqual( + 0, config["mysql"]["innodb_flush_neighbors"]["example"] + ) + self.assertEqual( + 2, config["mysql"]["innodb_flush_neighbors"]["maximum"] + ) + self.assertEqual( + 0, config["mysql"]["innodb_flush_neighbors"]["minimum"] + ) + self.assertFalse( + config["mysql"]["innodb_flush_neighbors"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["innodb_flush_neighbors"]["type"] + ) + + self.assertEqual( + "Minimum length of words that are stored in an InnoDB FULLTEXT index. Changing this parameter will lead to a restart of the MySQL service.", + config["mysql"]["innodb_ft_min_token_size"]["description"], + ) + self.assertEqual( + 3, config["mysql"]["innodb_ft_min_token_size"]["example"] + ) + self.assertEqual( + 16, config["mysql"]["innodb_ft_min_token_size"]["maximum"] + ) + self.assertEqual( + 0, config["mysql"]["innodb_ft_min_token_size"]["minimum"] + ) + self.assertTrue( + config["mysql"]["innodb_ft_min_token_size"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["innodb_ft_min_token_size"]["type"] + ) + + self.assertEqual( + "This option is used to specify your own InnoDB FULLTEXT index stopword list for all InnoDB tables.", + config["mysql"]["innodb_ft_server_stopword_table"]["description"], + ) + self.assertEqual( + "db_name/table_name", + config["mysql"]["innodb_ft_server_stopword_table"]["example"], + ) + self.assertEqual( + 1024, + config["mysql"]["innodb_ft_server_stopword_table"]["maxLength"], + ) + self.assertEqual( + "^.+/.+$", + config["mysql"]["innodb_ft_server_stopword_table"]["pattern"], + ) + self.assertFalse( + config["mysql"]["innodb_ft_server_stopword_table"][ + "requires_restart" + ] + ) + self.assertEqual( + ["null", "string"], + config["mysql"]["innodb_ft_server_stopword_table"]["type"], + ) + + self.assertEqual( + "The length of time in seconds an InnoDB transaction waits for a row lock before giving up. Default is 120.", + config["mysql"]["innodb_lock_wait_timeout"]["description"], + ) + self.assertEqual( + 50, config["mysql"]["innodb_lock_wait_timeout"]["example"] + ) + self.assertEqual( + 3600, config["mysql"]["innodb_lock_wait_timeout"]["maximum"] + ) + self.assertEqual( + 1, config["mysql"]["innodb_lock_wait_timeout"]["minimum"] + ) + self.assertFalse( + config["mysql"]["innodb_lock_wait_timeout"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["innodb_lock_wait_timeout"]["type"] + ) + + self.assertEqual( + "The size in bytes of the buffer that InnoDB uses to write to the log files on disk.", + config["mysql"]["innodb_log_buffer_size"]["description"], + ) + self.assertEqual( + 16777216, config["mysql"]["innodb_log_buffer_size"]["example"] + ) + self.assertEqual( + 4294967295, config["mysql"]["innodb_log_buffer_size"]["maximum"] + ) + self.assertEqual( + 1048576, config["mysql"]["innodb_log_buffer_size"]["minimum"] + ) + self.assertFalse( + config["mysql"]["innodb_log_buffer_size"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["innodb_log_buffer_size"]["type"] + ) + + self.assertEqual( + "The upper limit in bytes on the size of the temporary log files used during online DDL operations for InnoDB tables.", + config["mysql"]["innodb_online_alter_log_max_size"]["description"], + ) + self.assertEqual( + 134217728, + config["mysql"]["innodb_online_alter_log_max_size"]["example"], + ) + self.assertEqual( + 1099511627776, + config["mysql"]["innodb_online_alter_log_max_size"]["maximum"], + ) + self.assertEqual( + 65536, + config["mysql"]["innodb_online_alter_log_max_size"]["minimum"], + ) + self.assertFalse( + config["mysql"]["innodb_online_alter_log_max_size"][ + "requires_restart" + ] + ) + self.assertEqual( + "integer", + config["mysql"]["innodb_online_alter_log_max_size"]["type"], + ) + + self.assertEqual( + "The number of I/O threads for read operations in InnoDB. Default is 4. Changing this parameter will lead to a restart of the MySQL service.", + config["mysql"]["innodb_read_io_threads"]["description"], + ) + self.assertEqual( + 10, config["mysql"]["innodb_read_io_threads"]["example"] + ) + self.assertEqual( + 64, config["mysql"]["innodb_read_io_threads"]["maximum"] + ) + self.assertEqual( + 1, config["mysql"]["innodb_read_io_threads"]["minimum"] + ) + self.assertTrue( + config["mysql"]["innodb_read_io_threads"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["innodb_read_io_threads"]["type"] + ) + + self.assertEqual( + "When enabled a transaction timeout causes InnoDB to abort and roll back the entire transaction. Changing this parameter will lead to a restart of the MySQL service.", + config["mysql"]["innodb_rollback_on_timeout"]["description"], + ) + self.assertTrue( + config["mysql"]["innodb_rollback_on_timeout"]["example"] + ) + self.assertTrue( + config["mysql"]["innodb_rollback_on_timeout"]["requires_restart"] + ) + self.assertEqual( + "boolean", config["mysql"]["innodb_rollback_on_timeout"]["type"] + ) + + self.assertEqual( + "Defines the maximum number of threads permitted inside of InnoDB. Default is 0 (infinite concurrency - no limit)", + config["mysql"]["innodb_thread_concurrency"]["description"], + ) + self.assertEqual( + 10, config["mysql"]["innodb_thread_concurrency"]["example"] + ) + self.assertEqual( + 1000, config["mysql"]["innodb_thread_concurrency"]["maximum"] + ) + self.assertEqual( + 0, config["mysql"]["innodb_thread_concurrency"]["minimum"] + ) + self.assertFalse( + config["mysql"]["innodb_thread_concurrency"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["innodb_thread_concurrency"]["type"] + ) + + self.assertEqual( + "The number of I/O threads for write operations in InnoDB. Default is 4. Changing this parameter will lead to a restart of the MySQL service.", + config["mysql"]["innodb_write_io_threads"]["description"], + ) + self.assertEqual( + 10, config["mysql"]["innodb_write_io_threads"]["example"] + ) + self.assertEqual( + 64, config["mysql"]["innodb_write_io_threads"]["maximum"] + ) + self.assertEqual( + 1, config["mysql"]["innodb_write_io_threads"]["minimum"] + ) + self.assertTrue( + config["mysql"]["innodb_write_io_threads"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["innodb_write_io_threads"]["type"] + ) + + self.assertEqual( + "The number of seconds the server waits for activity on an interactive connection before closing it.", + config["mysql"]["interactive_timeout"]["description"], + ) + self.assertEqual( + 3600, config["mysql"]["interactive_timeout"]["example"] + ) + self.assertEqual( + 604800, config["mysql"]["interactive_timeout"]["maximum"] + ) + self.assertEqual(30, config["mysql"]["interactive_timeout"]["minimum"]) + self.assertFalse( + config["mysql"]["interactive_timeout"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["interactive_timeout"]["type"] + ) + + self.assertEqual( + "The storage engine for in-memory internal temporary tables.", + config["mysql"]["internal_tmp_mem_storage_engine"]["description"], + ) + self.assertEqual( + "TempTable", + config["mysql"]["internal_tmp_mem_storage_engine"]["example"], + ) + self.assertEqual( + ["TempTable", "MEMORY"], + config["mysql"]["internal_tmp_mem_storage_engine"]["enum"], + ) + self.assertFalse( + config["mysql"]["internal_tmp_mem_storage_engine"][ + "requires_restart" + ] + ) + self.assertEqual( + "string", config["mysql"]["internal_tmp_mem_storage_engine"]["type"] + ) + + self.assertEqual( + "Size of the largest message in bytes that can be received by the server. Default is 67108864 (64M)", + config["mysql"]["max_allowed_packet"]["description"], + ) + self.assertEqual( + 67108864, config["mysql"]["max_allowed_packet"]["example"] + ) + self.assertEqual( + 1073741824, config["mysql"]["max_allowed_packet"]["maximum"] + ) + self.assertEqual( + 102400, config["mysql"]["max_allowed_packet"]["minimum"] + ) + self.assertFalse( + config["mysql"]["max_allowed_packet"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["max_allowed_packet"]["type"] + ) + + self.assertEqual( + "Limits the size of internal in-memory tables. Also set tmp_table_size. Default is 16777216 (16M)", + config["mysql"]["max_heap_table_size"]["description"], + ) + self.assertEqual( + 16777216, config["mysql"]["max_heap_table_size"]["example"] + ) + self.assertEqual( + 1073741824, config["mysql"]["max_heap_table_size"]["maximum"] + ) + self.assertEqual( + 1048576, config["mysql"]["max_heap_table_size"]["minimum"] + ) + self.assertFalse( + config["mysql"]["max_heap_table_size"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["max_heap_table_size"]["type"] + ) + + self.assertEqual( + "Start sizes of connection buffer and result buffer. Default is 16384 (16K). Changing this parameter will lead to a restart of the MySQL service.", + config["mysql"]["net_buffer_length"]["description"], + ) + self.assertEqual(16384, config["mysql"]["net_buffer_length"]["example"]) + self.assertEqual( + 1048576, config["mysql"]["net_buffer_length"]["maximum"] + ) + self.assertEqual(1024, config["mysql"]["net_buffer_length"]["minimum"]) + self.assertTrue( + config["mysql"]["net_buffer_length"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["net_buffer_length"]["type"] + ) + + self.assertEqual( + "The number of seconds to wait for more data from a connection before aborting the read.", + config["mysql"]["net_read_timeout"]["description"], + ) + self.assertEqual(30, config["mysql"]["net_read_timeout"]["example"]) + self.assertEqual(3600, config["mysql"]["net_read_timeout"]["maximum"]) + self.assertEqual(1, config["mysql"]["net_read_timeout"]["minimum"]) + self.assertFalse( + config["mysql"]["net_read_timeout"]["requires_restart"] + ) + self.assertEqual("integer", config["mysql"]["net_read_timeout"]["type"]) + + self.assertEqual( + "The number of seconds to wait for a block to be written to a connection before aborting the write.", + config["mysql"]["net_write_timeout"]["description"], + ) + self.assertEqual(30, config["mysql"]["net_write_timeout"]["example"]) + self.assertEqual(3600, config["mysql"]["net_write_timeout"]["maximum"]) + self.assertEqual(1, config["mysql"]["net_write_timeout"]["minimum"]) + self.assertFalse( + config["mysql"]["net_write_timeout"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["net_write_timeout"]["type"] + ) + + self.assertEqual( + "Sort buffer size in bytes for ORDER BY optimization. Default is 262144 (256K)", + config["mysql"]["sort_buffer_size"]["description"], + ) + self.assertEqual(262144, config["mysql"]["sort_buffer_size"]["example"]) + self.assertEqual( + 1073741824, config["mysql"]["sort_buffer_size"]["maximum"] + ) + self.assertEqual(32768, config["mysql"]["sort_buffer_size"]["minimum"]) + self.assertFalse( + config["mysql"]["sort_buffer_size"]["requires_restart"] + ) + self.assertEqual("integer", config["mysql"]["sort_buffer_size"]["type"]) + + self.assertEqual( + "Global SQL mode. Set to empty to use MySQL server defaults. When creating a new service and not setting this field Akamai default SQL mode (strict, SQL standard compliant) will be assigned.", + config["mysql"]["sql_mode"]["description"], + ) + self.assertEqual( + "ANSI,TRADITIONAL", config["mysql"]["sql_mode"]["example"] + ) + self.assertEqual(1024, config["mysql"]["sql_mode"]["maxLength"]) + self.assertEqual( + "^[A-Z_]*(,[A-Z_]+)*$", config["mysql"]["sql_mode"]["pattern"] + ) + self.assertFalse(config["mysql"]["sql_mode"]["requires_restart"]) + self.assertEqual("string", config["mysql"]["sql_mode"]["type"]) + + self.assertEqual( + "Require primary key to be defined for new tables or old tables modified with ALTER TABLE and fail if missing. It is recommended to always have primary keys because various functionality may break if any large table is missing them.", + config["mysql"]["sql_require_primary_key"]["description"], + ) + self.assertTrue(config["mysql"]["sql_require_primary_key"]["example"]) + self.assertFalse( + config["mysql"]["sql_require_primary_key"]["requires_restart"] + ) + self.assertEqual( + "boolean", config["mysql"]["sql_require_primary_key"]["type"] + ) + + self.assertEqual( + "Limits the size of internal in-memory tables. Also set max_heap_table_size. Default is 16777216 (16M)", + config["mysql"]["tmp_table_size"]["description"], + ) + self.assertEqual(16777216, config["mysql"]["tmp_table_size"]["example"]) + self.assertEqual( + 1073741824, config["mysql"]["tmp_table_size"]["maximum"] + ) + self.assertEqual(1048576, config["mysql"]["tmp_table_size"]["minimum"]) + self.assertFalse(config["mysql"]["tmp_table_size"]["requires_restart"]) + self.assertEqual("integer", config["mysql"]["tmp_table_size"]["type"]) + + self.assertEqual( + "The number of seconds the server waits for activity on a noninteractive connection before closing it.", + config["mysql"]["wait_timeout"]["description"], + ) + self.assertEqual(28800, config["mysql"]["wait_timeout"]["example"]) + self.assertEqual(2147483, config["mysql"]["wait_timeout"]["maximum"]) + self.assertEqual(1, config["mysql"]["wait_timeout"]["minimum"]) + self.assertFalse(config["mysql"]["wait_timeout"]["requires_restart"]) + self.assertEqual("integer", config["mysql"]["wait_timeout"]["type"]) + + self.assertEqual( + "The minimum amount of time in seconds to keep binlog entries before deletion. This may be extended for services that require binlog entries for longer than the default for example if using the MySQL Debezium Kafka connector.", + config["binlog_retention_period"]["description"], + ) + self.assertEqual(600, config["binlog_retention_period"]["example"]) + self.assertEqual(86400, config["binlog_retention_period"]["maximum"]) + self.assertEqual(600, config["binlog_retention_period"]["minimum"]) + self.assertFalse(config["binlog_retention_period"]["requires_restart"]) + self.assertEqual("integer", config["binlog_retention_period"]["type"]) + + def test_postgresql_config_options(self): + """ + Test that PostgreSQL configuration options can be retrieved + """ + + config = self.client.database.postgresql_config_options() + + self.assertEqual( + "Specifies a fraction of the table size to add to autovacuum_analyze_threshold when " + + "deciding whether to trigger an ANALYZE. The default is 0.2 (20% of table size)", + config["pg"]["autovacuum_analyze_scale_factor"]["description"], + ) + self.assertEqual( + 1.0, config["pg"]["autovacuum_analyze_scale_factor"]["maximum"] + ) + self.assertEqual( + 0.0, config["pg"]["autovacuum_analyze_scale_factor"]["minimum"] + ) + self.assertFalse( + config["pg"]["autovacuum_analyze_scale_factor"]["requires_restart"] + ) + self.assertEqual( + "number", config["pg"]["autovacuum_analyze_scale_factor"]["type"] + ) + + self.assertEqual( + "Specifies the minimum number of inserted, updated or deleted tuples needed to trigger an ANALYZE in any one table. The default is 50 tuples.", + config["pg"]["autovacuum_analyze_threshold"]["description"], + ) + self.assertEqual( + 2147483647, config["pg"]["autovacuum_analyze_threshold"]["maximum"] + ) + self.assertEqual( + 0, config["pg"]["autovacuum_analyze_threshold"]["minimum"] + ) + self.assertFalse( + config["pg"]["autovacuum_analyze_threshold"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["autovacuum_analyze_threshold"]["type"] + ) + + self.assertEqual( + "Specifies the maximum number of autovacuum processes (other than the autovacuum launcher) that may be running at any one time. The default is three. This parameter can only be set at server start.", + config["pg"]["autovacuum_max_workers"]["description"], + ) + self.assertEqual(20, config["pg"]["autovacuum_max_workers"]["maximum"]) + self.assertEqual(1, config["pg"]["autovacuum_max_workers"]["minimum"]) + self.assertFalse( + config["pg"]["autovacuum_max_workers"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["autovacuum_max_workers"]["type"] + ) + + self.assertEqual( + "Specifies the minimum delay between autovacuum runs on any given database. The delay is measured in seconds, and the default is one minute", + config["pg"]["autovacuum_naptime"]["description"], + ) + self.assertEqual(86400, config["pg"]["autovacuum_naptime"]["maximum"]) + self.assertEqual(1, config["pg"]["autovacuum_naptime"]["minimum"]) + self.assertFalse(config["pg"]["autovacuum_naptime"]["requires_restart"]) + self.assertEqual("integer", config["pg"]["autovacuum_naptime"]["type"]) + + self.assertEqual( + "Specifies the cost delay value that will be used in automatic VACUUM operations. If -1 is specified, the regular vacuum_cost_delay value will be used. The default value is 20 milliseconds", + config["pg"]["autovacuum_vacuum_cost_delay"]["description"], + ) + self.assertEqual( + 100, config["pg"]["autovacuum_vacuum_cost_delay"]["maximum"] + ) + self.assertEqual( + -1, config["pg"]["autovacuum_vacuum_cost_delay"]["minimum"] + ) + self.assertFalse( + config["pg"]["autovacuum_vacuum_cost_delay"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["autovacuum_vacuum_cost_delay"]["type"] + ) + + self.assertEqual( + "Specifies the cost limit value that will be used in automatic VACUUM operations. If -1 is specified (which is the default), the regular vacuum_cost_limit value will be used.", + config["pg"]["autovacuum_vacuum_cost_limit"]["description"], + ) + self.assertEqual( + 10000, config["pg"]["autovacuum_vacuum_cost_limit"]["maximum"] + ) + self.assertEqual( + -1, config["pg"]["autovacuum_vacuum_cost_limit"]["minimum"] + ) + self.assertFalse( + config["pg"]["autovacuum_vacuum_cost_limit"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["autovacuum_vacuum_cost_limit"]["type"] + ) + + self.assertEqual( + "Specifies a fraction of the table size to add to autovacuum_vacuum_threshold when deciding whether to trigger a VACUUM. The default is 0.2 (20% of table size)", + config["pg"]["autovacuum_vacuum_scale_factor"]["description"], + ) + self.assertEqual( + 1.0, config["pg"]["autovacuum_vacuum_scale_factor"]["maximum"] + ) + self.assertEqual( + 0.0, config["pg"]["autovacuum_vacuum_scale_factor"]["minimum"] + ) + self.assertFalse( + config["pg"]["autovacuum_vacuum_scale_factor"]["requires_restart"] + ) + self.assertEqual( + "number", config["pg"]["autovacuum_vacuum_scale_factor"]["type"] + ) + + self.assertEqual( + "Specifies the minimum number of updated or deleted tuples needed to trigger a VACUUM in any one table. The default is 50 tuples", + config["pg"]["autovacuum_vacuum_threshold"]["description"], + ) + self.assertEqual( + 2147483647, config["pg"]["autovacuum_vacuum_threshold"]["maximum"] + ) + self.assertEqual( + 0, config["pg"]["autovacuum_vacuum_threshold"]["minimum"] + ) + self.assertFalse( + config["pg"]["autovacuum_vacuum_threshold"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["autovacuum_vacuum_threshold"]["type"] + ) + + self.assertEqual( + "Specifies the delay between activity rounds for the background writer in milliseconds. Default is 200.", + config["pg"]["bgwriter_delay"]["description"], + ) + self.assertEqual(200, config["pg"]["bgwriter_delay"]["example"]) + self.assertEqual(10000, config["pg"]["bgwriter_delay"]["maximum"]) + self.assertEqual(10, config["pg"]["bgwriter_delay"]["minimum"]) + self.assertFalse(config["pg"]["bgwriter_delay"]["requires_restart"]) + self.assertEqual("integer", config["pg"]["bgwriter_delay"]["type"]) + + self.assertEqual( + "Whenever more than bgwriter_flush_after bytes have been written by the background writer, attempt to force the OS to issue these writes to the underlying storage. Specified in kilobytes, default is 512. Setting of 0 disables forced writeback.", + config["pg"]["bgwriter_flush_after"]["description"], + ) + self.assertEqual(512, config["pg"]["bgwriter_flush_after"]["example"]) + self.assertEqual(2048, config["pg"]["bgwriter_flush_after"]["maximum"]) + self.assertEqual(0, config["pg"]["bgwriter_flush_after"]["minimum"]) + self.assertFalse( + config["pg"]["bgwriter_flush_after"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["bgwriter_flush_after"]["type"] + ) + + self.assertEqual( + "In each round, no more than this many buffers will be written by the background writer. Setting this to zero disables background writing. Default is 100.", + config["pg"]["bgwriter_lru_maxpages"]["description"], + ) + self.assertEqual(100, config["pg"]["bgwriter_lru_maxpages"]["example"]) + self.assertEqual( + 1073741823, config["pg"]["bgwriter_lru_maxpages"]["maximum"] + ) + self.assertEqual(0, config["pg"]["bgwriter_lru_maxpages"]["minimum"]) + self.assertFalse( + config["pg"]["bgwriter_lru_maxpages"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["bgwriter_lru_maxpages"]["type"] + ) + + self.assertEqual( + "The average recent need for new buffers is multiplied by bgwriter_lru_multiplier to arrive at an estimate of the number that will be needed during the next round, (up to bgwriter_lru_maxpages). 1.0 represents a “just in time” policy of writing exactly the number of buffers predicted to be needed. Larger values provide some cushion against spikes in demand, while smaller values intentionally leave writes to be done by server processes. The default is 2.0.", + config["pg"]["bgwriter_lru_multiplier"]["description"], + ) + self.assertEqual( + 2.0, config["pg"]["bgwriter_lru_multiplier"]["example"] + ) + self.assertEqual( + 10.0, config["pg"]["bgwriter_lru_multiplier"]["maximum"] + ) + self.assertEqual( + 0.0, config["pg"]["bgwriter_lru_multiplier"]["minimum"] + ) + self.assertFalse( + config["pg"]["bgwriter_lru_multiplier"]["requires_restart"] + ) + self.assertEqual( + "number", config["pg"]["bgwriter_lru_multiplier"]["type"] + ) + + self.assertEqual( + "This is the amount of time, in milliseconds, to wait on a lock before checking to see if there is a deadlock condition.", + config["pg"]["deadlock_timeout"]["description"], + ) + self.assertEqual(1000, config["pg"]["deadlock_timeout"]["example"]) + self.assertEqual(1800000, config["pg"]["deadlock_timeout"]["maximum"]) + self.assertEqual(500, config["pg"]["deadlock_timeout"]["minimum"]) + self.assertFalse(config["pg"]["deadlock_timeout"]["requires_restart"]) + self.assertEqual("integer", config["pg"]["deadlock_timeout"]["type"]) + + self.assertEqual( + "Specifies the default TOAST compression method for values of compressible columns (the default is lz4).", + config["pg"]["default_toast_compression"]["description"], + ) + self.assertEqual( + ["lz4", "pglz"], config["pg"]["default_toast_compression"]["enum"] + ) + self.assertEqual( + "lz4", config["pg"]["default_toast_compression"]["example"] + ) + self.assertFalse( + config["pg"]["default_toast_compression"]["requires_restart"] + ) + self.assertEqual( + "string", config["pg"]["default_toast_compression"]["type"] + ) + + self.assertEqual( + "Time out sessions with open transactions after this number of milliseconds", + config["pg"]["idle_in_transaction_session_timeout"]["description"], + ) + self.assertEqual( + 604800000, + config["pg"]["idle_in_transaction_session_timeout"]["maximum"], + ) + self.assertEqual( + 0, config["pg"]["idle_in_transaction_session_timeout"]["minimum"] + ) + self.assertFalse( + config["pg"]["idle_in_transaction_session_timeout"][ + "requires_restart" + ] + ) + self.assertEqual( + "integer", + config["pg"]["idle_in_transaction_session_timeout"]["type"], + ) + + self.assertEqual( + "Controls system-wide use of Just-in-Time Compilation (JIT).", + config["pg"]["jit"]["description"], + ) + self.assertTrue(config["pg"]["jit"]["example"]) + self.assertFalse(config["pg"]["jit"]["requires_restart"]) + self.assertEqual("boolean", config["pg"]["jit"]["type"]) + + self.assertEqual( + "PostgreSQL maximum number of files that can be open per process", + config["pg"]["max_files_per_process"]["description"], + ) + self.assertEqual(4096, config["pg"]["max_files_per_process"]["maximum"]) + self.assertEqual(1000, config["pg"]["max_files_per_process"]["minimum"]) + self.assertFalse( + config["pg"]["max_files_per_process"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["max_files_per_process"]["type"] + ) + + self.assertEqual( + "PostgreSQL maximum locks per transaction", + config["pg"]["max_locks_per_transaction"]["description"], + ) + self.assertEqual( + 6400, config["pg"]["max_locks_per_transaction"]["maximum"] + ) + self.assertEqual( + 64, config["pg"]["max_locks_per_transaction"]["minimum"] + ) + self.assertFalse( + config["pg"]["max_locks_per_transaction"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["max_locks_per_transaction"]["type"] + ) + + self.assertEqual( + "PostgreSQL maximum logical replication workers (taken from the pool of max_parallel_workers)", + config["pg"]["max_logical_replication_workers"]["description"], + ) + self.assertEqual( + 64, config["pg"]["max_logical_replication_workers"]["maximum"] + ) + self.assertEqual( + 4, config["pg"]["max_logical_replication_workers"]["minimum"] + ) + self.assertFalse( + config["pg"]["max_logical_replication_workers"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["max_logical_replication_workers"]["type"] + ) + + self.assertEqual( + "Sets the maximum number of workers that the system can support for parallel queries", + config["pg"]["max_parallel_workers"]["description"], + ) + self.assertEqual(96, config["pg"]["max_parallel_workers"]["maximum"]) + self.assertEqual(0, config["pg"]["max_parallel_workers"]["minimum"]) + self.assertFalse( + config["pg"]["max_parallel_workers"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["max_parallel_workers"]["type"] + ) + + self.assertEqual( + "Sets the maximum number of workers that can be started by a single Gather or Gather Merge node", + config["pg"]["max_parallel_workers_per_gather"]["description"], + ) + self.assertEqual( + 96, config["pg"]["max_parallel_workers_per_gather"]["maximum"] + ) + self.assertEqual( + 0, config["pg"]["max_parallel_workers_per_gather"]["minimum"] + ) + self.assertFalse( + config["pg"]["max_parallel_workers_per_gather"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["max_parallel_workers_per_gather"]["type"] + ) + + self.assertEqual( + "PostgreSQL maximum predicate locks per transaction", + config["pg"]["max_pred_locks_per_transaction"]["description"], + ) + self.assertEqual( + 5120, config["pg"]["max_pred_locks_per_transaction"]["maximum"] + ) + self.assertEqual( + 64, config["pg"]["max_pred_locks_per_transaction"]["minimum"] + ) + self.assertFalse( + config["pg"]["max_pred_locks_per_transaction"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["max_pred_locks_per_transaction"]["type"] + ) + + self.assertEqual( + "PostgreSQL maximum replication slots", + config["pg"]["max_replication_slots"]["description"], + ) + self.assertEqual(64, config["pg"]["max_replication_slots"]["maximum"]) + self.assertEqual(8, config["pg"]["max_replication_slots"]["minimum"]) + self.assertFalse( + config["pg"]["max_replication_slots"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["max_replication_slots"]["type"] + ) + + self.assertEqual( + "PostgreSQL maximum WAL size (MB) reserved for replication slots. Default is -1 (unlimited). wal_keep_size minimum WAL size setting takes precedence over this.", + config["pg"]["max_slot_wal_keep_size"]["description"], + ) + self.assertEqual( + 2147483647, config["pg"]["max_slot_wal_keep_size"]["maximum"] + ) + self.assertEqual(-1, config["pg"]["max_slot_wal_keep_size"]["minimum"]) + self.assertFalse( + config["pg"]["max_slot_wal_keep_size"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["max_slot_wal_keep_size"]["type"] + ) + + self.assertEqual( + "Maximum depth of the stack in bytes", + config["pg"]["max_stack_depth"]["description"], + ) + self.assertEqual(6291456, config["pg"]["max_stack_depth"]["maximum"]) + self.assertEqual(2097152, config["pg"]["max_stack_depth"]["minimum"]) + self.assertFalse(config["pg"]["max_stack_depth"]["requires_restart"]) + self.assertEqual("integer", config["pg"]["max_stack_depth"]["type"]) + + self.assertEqual( + "Max standby archive delay in milliseconds", + config["pg"]["max_standby_archive_delay"]["description"], + ) + self.assertEqual( + 43200000, config["pg"]["max_standby_archive_delay"]["maximum"] + ) + self.assertEqual( + 1, config["pg"]["max_standby_archive_delay"]["minimum"] + ) + self.assertFalse( + config["pg"]["max_standby_archive_delay"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["max_standby_archive_delay"]["type"] + ) + + self.assertEqual( + "Max standby streaming delay in milliseconds", + config["pg"]["max_standby_streaming_delay"]["description"], + ) + self.assertEqual( + 43200000, config["pg"]["max_standby_streaming_delay"]["maximum"] + ) + self.assertEqual( + 1, config["pg"]["max_standby_streaming_delay"]["minimum"] + ) + self.assertFalse( + config["pg"]["max_standby_streaming_delay"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["max_standby_streaming_delay"]["type"] + ) + + self.assertEqual( + "PostgreSQL maximum WAL senders", + config["pg"]["max_wal_senders"]["description"], + ) + self.assertEqual(64, config["pg"]["max_wal_senders"]["maximum"]) + self.assertEqual(20, config["pg"]["max_wal_senders"]["minimum"]) + self.assertFalse(config["pg"]["max_wal_senders"]["requires_restart"]) + self.assertEqual("integer", config["pg"]["max_wal_senders"]["type"]) + + self.assertEqual( + "Sets the maximum number of background processes that the system can support", + config["pg"]["max_worker_processes"]["description"], + ) + self.assertEqual(96, config["pg"]["max_worker_processes"]["maximum"]) + self.assertEqual(8, config["pg"]["max_worker_processes"]["minimum"]) + self.assertFalse( + config["pg"]["max_worker_processes"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["max_worker_processes"]["type"] + ) + + self.assertEqual( + "Chooses the algorithm for encrypting passwords.", + config["pg"]["password_encryption"]["description"], + ) + self.assertEqual( + ["md5", "scram-sha-256"], + config["pg"]["password_encryption"]["enum"], + ) + self.assertEqual( + "scram-sha-256", config["pg"]["password_encryption"]["example"] + ) + self.assertFalse( + config["pg"]["password_encryption"]["requires_restart"] + ) + self.assertEqual( + ["string", "null"], config["pg"]["password_encryption"]["type"] + ) + + self.assertEqual( + "Sets the time interval to run pg_partman's scheduled tasks", + config["pg"]["pg_partman_bgw.interval"]["description"], + ) + self.assertEqual( + 3600, config["pg"]["pg_partman_bgw.interval"]["example"] + ) + self.assertEqual( + 604800, config["pg"]["pg_partman_bgw.interval"]["maximum"] + ) + self.assertEqual( + 3600, config["pg"]["pg_partman_bgw.interval"]["minimum"] + ) + self.assertFalse( + config["pg"]["pg_partman_bgw.interval"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["pg_partman_bgw.interval"]["type"] + ) + + self.assertEqual( + "Controls which role to use for pg_partman's scheduled background tasks.", + config["pg"]["pg_partman_bgw.role"]["description"], + ) + self.assertEqual( + "myrolename", config["pg"]["pg_partman_bgw.role"]["example"] + ) + self.assertEqual(64, config["pg"]["pg_partman_bgw.role"]["maxLength"]) + self.assertEqual( + "^[_A-Za-z0-9][-._A-Za-z0-9]{0,63}$", + config["pg"]["pg_partman_bgw.role"]["pattern"], + ) + self.assertFalse( + config["pg"]["pg_partman_bgw.role"]["requires_restart"] + ) + self.assertEqual("string", config["pg"]["pg_partman_bgw.role"]["type"]) + + self.assertEqual( + "Enables or disables query plan monitoring", + config["pg"]["pg_stat_monitor.pgsm_enable_query_plan"][ + "description" + ], + ) + self.assertFalse( + config["pg"]["pg_stat_monitor.pgsm_enable_query_plan"]["example"] + ) + self.assertFalse( + config["pg"]["pg_stat_monitor.pgsm_enable_query_plan"][ + "requires_restart" + ] + ) + self.assertEqual( + "boolean", + config["pg"]["pg_stat_monitor.pgsm_enable_query_plan"]["type"], + ) + + self.assertEqual( + "Sets the maximum number of buckets", + config["pg"]["pg_stat_monitor.pgsm_max_buckets"]["description"], + ) + self.assertEqual( + 10, config["pg"]["pg_stat_monitor.pgsm_max_buckets"]["example"] + ) + self.assertEqual( + 10, config["pg"]["pg_stat_monitor.pgsm_max_buckets"]["maximum"] + ) + self.assertEqual( + 1, config["pg"]["pg_stat_monitor.pgsm_max_buckets"]["minimum"] + ) + self.assertFalse( + config["pg"]["pg_stat_monitor.pgsm_max_buckets"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["pg_stat_monitor.pgsm_max_buckets"]["type"] + ) + + self.assertEqual( + "Controls which statements are counted. Specify top to track top-level statements (those issued directly by clients), all to also track nested statements (such as statements invoked within functions), or none to disable statement statistics collection. The default value is top.", + config["pg"]["pg_stat_statements.track"]["description"], + ) + self.assertEqual( + ["all", "top", "none"], + config["pg"]["pg_stat_statements.track"]["enum"], + ) + self.assertFalse( + config["pg"]["pg_stat_statements.track"]["requires_restart"] + ) + self.assertEqual( + ["string"], config["pg"]["pg_stat_statements.track"]["type"] + ) + + self.assertEqual( + "PostgreSQL temporary file limit in KiB, -1 for unlimited", + config["pg"]["temp_file_limit"]["description"], + ) + self.assertEqual(5000000, config["pg"]["temp_file_limit"]["example"]) + self.assertEqual(2147483647, config["pg"]["temp_file_limit"]["maximum"]) + self.assertEqual(-1, config["pg"]["temp_file_limit"]["minimum"]) + self.assertFalse(config["pg"]["temp_file_limit"]["requires_restart"]) + self.assertEqual("integer", config["pg"]["temp_file_limit"]["type"]) + + self.assertEqual( + "PostgreSQL service timezone", + config["pg"]["timezone"]["description"], + ) + self.assertEqual("Europe/Helsinki", config["pg"]["timezone"]["example"]) + self.assertEqual(64, config["pg"]["timezone"]["maxLength"]) + self.assertEqual("^[\\w/]*$", config["pg"]["timezone"]["pattern"]) + self.assertFalse(config["pg"]["timezone"]["requires_restart"]) + self.assertEqual("string", config["pg"]["timezone"]["type"]) + + self.assertEqual( + "Specifies the number of bytes reserved to track the currently executing command for each active session.", + config["pg"]["track_activity_query_size"]["description"], + ) + self.assertEqual( + 1024, config["pg"]["track_activity_query_size"]["example"] + ) + self.assertEqual( + 10240, config["pg"]["track_activity_query_size"]["maximum"] + ) + self.assertEqual( + 1024, config["pg"]["track_activity_query_size"]["minimum"] + ) + self.assertFalse( + config["pg"]["track_activity_query_size"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["track_activity_query_size"]["type"] + ) + + self.assertEqual( + "Record commit time of transactions.", + config["pg"]["track_commit_timestamp"]["description"], + ) + self.assertEqual( + "off", config["pg"]["track_commit_timestamp"]["example"] + ) + self.assertEqual( + ["off", "on"], config["pg"]["track_commit_timestamp"]["enum"] + ) + self.assertFalse( + config["pg"]["track_commit_timestamp"]["requires_restart"] + ) + self.assertEqual( + "string", config["pg"]["track_commit_timestamp"]["type"] + ) + + self.assertEqual( + "Enables tracking of function call counts and time used.", + config["pg"]["track_functions"]["description"], + ) + self.assertEqual( + ["all", "pl", "none"], config["pg"]["track_functions"]["enum"] + ) + self.assertFalse(config["pg"]["track_functions"]["requires_restart"]) + self.assertEqual("string", config["pg"]["track_functions"]["type"]) + + self.assertEqual( + "Enables timing of database I/O calls. This parameter is off by default, because it will repeatedly query the operating system for the current time, which may cause significant overhead on some platforms.", + config["pg"]["track_io_timing"]["description"], + ) + self.assertEqual("off", config["pg"]["track_io_timing"]["example"]) + self.assertEqual(["off", "on"], config["pg"]["track_io_timing"]["enum"]) + self.assertFalse(config["pg"]["track_io_timing"]["requires_restart"]) + self.assertEqual("string", config["pg"]["track_io_timing"]["type"]) + + self.assertEqual( + "Terminate replication connections that are inactive for longer than this amount of time, in milliseconds. Setting this value to zero disables the timeout.", + config["pg"]["wal_sender_timeout"]["description"], + ) + self.assertEqual(60000, config["pg"]["wal_sender_timeout"]["example"]) + self.assertFalse(config["pg"]["wal_sender_timeout"]["requires_restart"]) + self.assertEqual("integer", config["pg"]["wal_sender_timeout"]["type"]) + + self.assertEqual( + "WAL flush interval in milliseconds. Note that setting this value to lower than the default 200ms may negatively impact performance", + config["pg"]["wal_writer_delay"]["description"], + ) + self.assertEqual(50, config["pg"]["wal_writer_delay"]["example"]) + self.assertEqual(200, config["pg"]["wal_writer_delay"]["maximum"]) + self.assertEqual(10, config["pg"]["wal_writer_delay"]["minimum"]) + self.assertFalse(config["pg"]["wal_writer_delay"]["requires_restart"]) + self.assertEqual("integer", config["pg"]["wal_writer_delay"]["type"]) + + self.assertEqual( + "Enable the pg_stat_monitor extension. Enabling this extension will cause the cluster to be restarted. When this extension is enabled, pg_stat_statements results for utility commands are unreliable", + config["pg_stat_monitor_enable"]["description"], + ) + self.assertTrue(config["pg_stat_monitor_enable"]["requires_restart"]) + self.assertEqual("boolean", config["pg_stat_monitor_enable"]["type"]) + + self.assertEqual( + "Number of seconds of master unavailability before triggering database failover to standby", + config["pglookout"]["max_failover_replication_time_lag"][ + "description" + ], + ) + self.assertEqual( + int(9223372036854775000), + config["pglookout"]["max_failover_replication_time_lag"]["maximum"], + ) + self.assertEqual( + int(10), + config["pglookout"]["max_failover_replication_time_lag"]["minimum"], + ) + self.assertFalse( + config["pglookout"]["max_failover_replication_time_lag"][ + "requires_restart" + ] + ) + self.assertEqual( + "integer", + config["pglookout"]["max_failover_replication_time_lag"]["type"], + ) + + self.assertEqual( + "Percentage of total RAM that the database server uses for shared memory buffers. Valid range is 20-60 (float), which corresponds to 20% - 60%. This setting adjusts the shared_buffers configuration value.", + config["shared_buffers_percentage"]["description"], + ) + self.assertEqual(41.5, config["shared_buffers_percentage"]["example"]) + self.assertEqual(60.0, config["shared_buffers_percentage"]["maximum"]) + self.assertEqual(20.0, config["shared_buffers_percentage"]["minimum"]) + self.assertFalse( + config["shared_buffers_percentage"]["requires_restart"] + ) + self.assertEqual("number", config["shared_buffers_percentage"]["type"]) + + self.assertEqual( + "Sets the maximum amount of memory to be used by a query operation (such as a sort or hash table) before writing to temporary disk files, in MB. Default is 1MB + 0.075% of total RAM (up to 32MB).", + config["work_mem"]["description"], + ) + self.assertEqual(4, config["work_mem"]["example"]) + self.assertEqual(1024, config["work_mem"]["maximum"]) + self.assertEqual(1, config["work_mem"]["minimum"]) + self.assertFalse(config["work_mem"]["requires_restart"]) + self.assertEqual("integer", config["work_mem"]["type"]) + + def test_get_mysql_instances(self): + """ + Test that mysql instances can be retrieved properly + """ + dbs = self.client.database.mysql_instances() + + self.assertEqual(len(dbs), 1) + self.assertEqual(dbs[0].allow_list[1], "192.0.1.0/24") + self.assertEqual(dbs[0].cluster_size, 3) + self.assertEqual(dbs[0].encrypted, False) + self.assertEqual(dbs[0].engine, "mysql") + self.assertEqual( + dbs[0].hosts.primary, + "lin-123-456-mysql-mysql-primary.servers.linodedb.net", + ) + self.assertEqual( + dbs[0].hosts.standby, + "lin-123-456-mysql-primary-private.servers.linodedb.net", + ) + self.assertEqual(dbs[0].id, 123) + self.assertEqual(dbs[0].region, "us-east") + self.assertEqual(dbs[0].updates.duration, 3) + self.assertEqual(dbs[0].version, "8.0.26") + self.assertEqual(dbs[0].engine_config.binlog_retention_period, 600) + self.assertEqual(dbs[0].engine_config.mysql.connect_timeout, 10) + self.assertEqual(dbs[0].engine_config.mysql.default_time_zone, "+03:00") + self.assertEqual(dbs[0].engine_config.mysql.group_concat_max_len, 1024) + self.assertEqual( + dbs[0].engine_config.mysql.information_schema_stats_expiry, 86400 + ) + self.assertEqual( + dbs[0].engine_config.mysql.innodb_change_buffer_max_size, 30 + ) + self.assertEqual(dbs[0].engine_config.mysql.innodb_flush_neighbors, 0) + self.assertEqual(dbs[0].engine_config.mysql.innodb_ft_min_token_size, 3) + self.assertEqual( + dbs[0].engine_config.mysql.innodb_ft_server_stopword_table, + "db_name/table_name", + ) + self.assertEqual( + dbs[0].engine_config.mysql.innodb_lock_wait_timeout, 50 + ) + self.assertEqual( + dbs[0].engine_config.mysql.innodb_log_buffer_size, 16777216 + ) + self.assertEqual( + dbs[0].engine_config.mysql.innodb_online_alter_log_max_size, + 134217728, + ) + self.assertEqual(dbs[0].engine_config.mysql.innodb_read_io_threads, 10) + self.assertTrue(dbs[0].engine_config.mysql.innodb_rollback_on_timeout) + self.assertEqual( + dbs[0].engine_config.mysql.innodb_thread_concurrency, 10 + ) + self.assertEqual(dbs[0].engine_config.mysql.innodb_write_io_threads, 10) + self.assertEqual(dbs[0].engine_config.mysql.interactive_timeout, 3600) + self.assertEqual( + dbs[0].engine_config.mysql.internal_tmp_mem_storage_engine, + "TempTable", + ) + self.assertEqual( + dbs[0].engine_config.mysql.max_allowed_packet, 67108864 + ) + self.assertEqual( + dbs[0].engine_config.mysql.max_heap_table_size, 16777216 + ) + self.assertEqual(dbs[0].engine_config.mysql.net_buffer_length, 16384) + self.assertEqual(dbs[0].engine_config.mysql.net_read_timeout, 30) + self.assertEqual(dbs[0].engine_config.mysql.net_write_timeout, 30) + self.assertEqual(dbs[0].engine_config.mysql.sort_buffer_size, 262144) + self.assertEqual( + dbs[0].engine_config.mysql.sql_mode, "ANSI,TRADITIONAL" + ) + self.assertTrue(dbs[0].engine_config.mysql.sql_require_primary_key) + self.assertEqual(dbs[0].engine_config.mysql.tmp_table_size, 16777216) + self.assertEqual(dbs[0].engine_config.mysql.wait_timeout, 28800) + + self.assertEqual(dbs[0].private_network.vpc_id, 1234) + self.assertEqual(dbs[0].private_network.subnet_id, 5678) + self.assertEqual(dbs[0].private_network.public_access, True) + + def test_get_postgresql_instances(self): + """ + Test that postgresql instances can be retrieved properly + """ + dbs = self.client.database.postgresql_instances() + + self.assertEqual(len(dbs), 1) + self.assertEqual(dbs[0].allow_list[1], "192.0.1.0/24") + self.assertEqual(dbs[0].cluster_size, 3) + self.assertEqual(dbs[0].encrypted, False) + self.assertEqual(dbs[0].engine, "postgresql") + self.assertEqual( + dbs[0].hosts.primary, + "lin-0000-000-pgsql-primary.servers.linodedb.net", + ) + self.assertEqual( + dbs[0].hosts.standby, + "lin-0000-000-pgsql-primary-private.servers.linodedb.net", + ) + self.assertEqual(dbs[0].id, 123) + self.assertEqual(dbs[0].region, "us-east") + self.assertEqual(dbs[0].updates.duration, 3) + self.assertEqual(dbs[0].version, "13.2") + + print(dbs[0].engine_config.pg.__dict__) + + self.assertTrue(dbs[0].engine_config.pg_stat_monitor_enable) + self.assertEqual( + dbs[0].engine_config.pglookout.max_failover_replication_time_lag, + 1000, + ) + self.assertEqual(dbs[0].engine_config.shared_buffers_percentage, 41.5) + self.assertEqual(dbs[0].engine_config.work_mem, 4) + self.assertEqual( + dbs[0].engine_config.pg.autovacuum_analyze_scale_factor, 0.5 + ) + self.assertEqual( + dbs[0].engine_config.pg.autovacuum_analyze_threshold, 100 + ) + self.assertEqual(dbs[0].engine_config.pg.autovacuum_max_workers, 10) + self.assertEqual(dbs[0].engine_config.pg.autovacuum_naptime, 100) + self.assertEqual( + dbs[0].engine_config.pg.autovacuum_vacuum_cost_delay, 50 + ) + self.assertEqual( + dbs[0].engine_config.pg.autovacuum_vacuum_cost_limit, 100 + ) + self.assertEqual( + dbs[0].engine_config.pg.autovacuum_vacuum_scale_factor, 0.5 + ) + self.assertEqual( + dbs[0].engine_config.pg.autovacuum_vacuum_threshold, 100 + ) + self.assertEqual(dbs[0].engine_config.pg.bgwriter_delay, 200) + self.assertEqual(dbs[0].engine_config.pg.bgwriter_flush_after, 512) + self.assertEqual(dbs[0].engine_config.pg.bgwriter_lru_maxpages, 100) + self.assertEqual(dbs[0].engine_config.pg.bgwriter_lru_multiplier, 2.0) + self.assertEqual(dbs[0].engine_config.pg.deadlock_timeout, 1000) + self.assertEqual( + dbs[0].engine_config.pg.default_toast_compression, "lz4" + ) + self.assertEqual( + dbs[0].engine_config.pg.idle_in_transaction_session_timeout, 100 + ) + self.assertTrue(dbs[0].engine_config.pg.jit) + self.assertEqual(dbs[0].engine_config.pg.max_files_per_process, 100) + self.assertEqual(dbs[0].engine_config.pg.max_locks_per_transaction, 100) + self.assertEqual( + dbs[0].engine_config.pg.max_logical_replication_workers, 32 + ) + self.assertEqual(dbs[0].engine_config.pg.max_parallel_workers, 64) + self.assertEqual( + dbs[0].engine_config.pg.max_parallel_workers_per_gather, 64 + ) + self.assertEqual( + dbs[0].engine_config.pg.max_pred_locks_per_transaction, 1000 + ) + self.assertEqual(dbs[0].engine_config.pg.max_replication_slots, 32) + self.assertEqual(dbs[0].engine_config.pg.max_slot_wal_keep_size, 100) + self.assertEqual(dbs[0].engine_config.pg.max_stack_depth, 3507152) + self.assertEqual( + dbs[0].engine_config.pg.max_standby_archive_delay, 1000 + ) + self.assertEqual( + dbs[0].engine_config.pg.max_standby_streaming_delay, 1000 + ) + self.assertEqual(dbs[0].engine_config.pg.max_wal_senders, 32) + self.assertEqual(dbs[0].engine_config.pg.max_worker_processes, 64) + self.assertEqual( + dbs[0].engine_config.pg.password_encryption, "scram-sha-256" + ) + self.assertEqual(dbs[0].engine_config.pg.pg_partman_bgw_interval, 3600) + self.assertEqual( + dbs[0].engine_config.pg.pg_partman_bgw_role, "myrolename" + ) + self.assertFalse( + dbs[0].engine_config.pg.pg_stat_monitor_pgsm_enable_query_plan + ) + self.assertEqual( + dbs[0].engine_config.pg.pg_stat_monitor_pgsm_max_buckets, 10 + ) + self.assertEqual( + dbs[0].engine_config.pg.pg_stat_statements_track, "top" + ) + self.assertEqual(dbs[0].engine_config.pg.temp_file_limit, 5000000) + self.assertEqual(dbs[0].engine_config.pg.timezone, "Europe/Helsinki") + self.assertEqual( + dbs[0].engine_config.pg.track_activity_query_size, 1024 + ) + self.assertEqual(dbs[0].engine_config.pg.track_commit_timestamp, "off") + self.assertEqual(dbs[0].engine_config.pg.track_functions, "all") + self.assertEqual(dbs[0].engine_config.pg.track_io_timing, "off") + self.assertEqual(dbs[0].engine_config.pg.wal_sender_timeout, 60000) + self.assertEqual(dbs[0].engine_config.pg.wal_writer_delay, 50) + + self.assertEqual(dbs[0].private_network.vpc_id, 1234) + self.assertEqual(dbs[0].private_network.subnet_id, 5678) + self.assertEqual(dbs[0].private_network.public_access, True) diff --git a/test/unit/groups/image_share_group_test.py b/test/unit/groups/image_share_group_test.py new file mode 100644 index 000000000..c9787264f --- /dev/null +++ b/test/unit/groups/image_share_group_test.py @@ -0,0 +1,153 @@ +from test.unit.base import ClientBaseCase + + +class ImageTest(ClientBaseCase): + """ + Tests methods of the ImageShareGroupAPIGroup class + """ + + def test_image_share_groups(self): + """ + Test that Image Share Groups can be retrieved successfully. + """ + sharegroups = self.client.sharegroups() + self.assertEqual(len(sharegroups), 2) + + self.assertEqual(sharegroups[0].id, 1) + self.assertEqual( + sharegroups[0].description, + "My group of images to share with my team.", + ) + self.assertEqual(sharegroups[0].images_count, 0) + self.assertEqual(sharegroups[0].is_suspended, False) + self.assertEqual(sharegroups[0].label, "My Shared Images") + self.assertEqual(sharegroups[0].members_count, 0) + self.assertEqual( + sharegroups[0].uuid, "1533863e-16a4-47b5-b829-ac0f35c13278" + ) + + self.assertEqual(sharegroups[1].id, 2) + self.assertEqual( + sharegroups[1].description, + "My other group of images to share with my team.", + ) + self.assertEqual(sharegroups[1].images_count, 1) + self.assertEqual(sharegroups[1].is_suspended, False) + self.assertEqual(sharegroups[1].label, "My other Shared Images") + self.assertEqual(sharegroups[1].members_count, 3) + self.assertEqual( + sharegroups[1].uuid, "30ee6599-eb0f-478c-9e55-4073c6c24a39" + ) + + def test_image_share_groups_by_image_id(self): + """ + Test that Image Share Groups where a given private image is currently shared can be retrieved successfully. + """ + + sharegroups = self.client.sharegroups.sharegroups_by_image_id( + "private/1234" + ) + self.assertEqual(len(sharegroups), 1) + + self.assertEqual(sharegroups[0].id, 1) + self.assertEqual( + sharegroups[0].description, + "My group of images to share with my team.", + ) + self.assertEqual(sharegroups[0].images_count, 1) + self.assertEqual(sharegroups[0].is_suspended, False) + self.assertEqual(sharegroups[0].label, "My Shared Images") + self.assertEqual(sharegroups[0].members_count, 0) + self.assertEqual( + sharegroups[0].uuid, "1533863e-16a4-47b5-b829-ac0f35c13278" + ) + + def test_image_share_group_tokens(self): + """ + Test that Image Share Group tokens can be retrieved successfully. + """ + + tokens = self.client.sharegroups.tokens() + self.assertEqual(len(tokens), 1) + + self.assertEqual( + tokens[0].token_uuid, "13428362-5458-4dad-b14b-8d0d4d648f8c" + ) + self.assertEqual(tokens[0].label, "My Sharegroup Token") + self.assertEqual(tokens[0].sharegroup_label, "A Sharegroup") + self.assertEqual( + tokens[0].sharegroup_uuid, "e1d0e58b-f89f-4237-84ab-b82077342359" + ) + self.assertEqual( + tokens[0].valid_for_sharegroup_uuid, + "e1d0e58b-f89f-4237-84ab-b82077342359", + ) + self.assertEqual(tokens[0].status, "active") + + def test_image_share_group_create(self): + """ + Test that an Image Share Group can be created successfully. + """ + + with self.mock_post("/images/sharegroups/1234") as m: + sharegroup = self.client.sharegroups.create_sharegroup( + label="My Shared Images", + description="My group of images to share with my team.", + ) + + assert m.call_url == "/images/sharegroups" + + self.assertEqual( + m.call_data, + { + "label": "My Shared Images", + "description": "My group of images to share with my team.", + }, + ) + + self.assertEqual(sharegroup.id, 1234) + self.assertEqual( + sharegroup.description, + "My group of images to share with my team.", + ) + self.assertEqual(sharegroup.images_count, 0) + self.assertEqual(sharegroup.is_suspended, False) + self.assertEqual(sharegroup.label, "My Shared Images") + self.assertEqual(sharegroup.members_count, 0) + self.assertEqual( + sharegroup.uuid, "1533863e-16a4-47b5-b829-ac0f35c13278" + ) + + def test_image_share_group_token_create(self): + """ + Test that an Image Share Group token can be created successfully. + """ + + with self.mock_post("/images/sharegroups/tokens/abc123") as m: + token = self.client.sharegroups.create_token( + label="My Sharegroup Token", + valid_for_sharegroup_uuid="e1d0e58b-f89f-4237-84ab-b82077342359", + ) + + assert m.call_url == "/images/sharegroups/tokens" + + self.assertEqual( + m.call_data, + { + "label": "My Sharegroup Token", + "valid_for_sharegroup_uuid": "e1d0e58b-f89f-4237-84ab-b82077342359", + }, + ) + + self.assertEqual(token[0].token_uuid, "abc123") + self.assertEqual(token[0].label, "My Sharegroup Token") + self.assertEqual(token[0].sharegroup_label, "A Sharegroup") + self.assertEqual( + token[0].sharegroup_uuid, "e1d0e58b-f89f-4237-84ab-b82077342359" + ) + self.assertEqual( + token[0].valid_for_sharegroup_uuid, + "e1d0e58b-f89f-4237-84ab-b82077342359", + ) + self.assertEqual(token[0].status, "active") + self.assertEqual(token[1], "asupersecrettoken") diff --git a/test/unit/groups/image_test.py b/test/unit/groups/image_test.py new file mode 100644 index 000000000..e2aab386b --- /dev/null +++ b/test/unit/groups/image_test.py @@ -0,0 +1,37 @@ +from test.unit.base import ClientBaseCase + + +class ImageTest(ClientBaseCase): + """ + Tests methods of the Image class + """ + + def test_image_create_cloud_init(self): + """ + Test that an image can be created successfully with cloud-init. + """ + + with self.mock_post("images/private/123") as m: + self.client.images.create( + "Test Image", + "us-southeast", + description="very real image upload.", + cloud_init=True, + ) + + self.assertTrue(m.call_data["cloud_init"]) + + def test_image_create_upload_cloud_init(self): + """ + Test that an image upload URL can be created successfully with cloud-init. + """ + + with self.mock_post("images/upload") as m: + self.client.images.create_upload( + "Test Image", + "us-southeast", + description="very real image upload.", + cloud_init=True, + ) + + self.assertTrue(m.call_data["cloud_init"]) diff --git a/test/unit/groups/linode_test.py b/test/unit/groups/linode_test.py new file mode 100644 index 000000000..03278f03b --- /dev/null +++ b/test/unit/groups/linode_test.py @@ -0,0 +1,164 @@ +from test.unit.base import ClientBaseCase +from test.unit.objects.linode_interface_test import ( + build_interface_options_public, + build_interface_options_vlan, + build_interface_options_vpc, +) + +from linode_api4 import InstancePlacementGroupAssignment, InterfaceGeneration +from linode_api4.objects import ConfigInterface + + +class LinodeTest(ClientBaseCase): + """ + Tests methods of the Linode class + """ + + def test_instance_create_with_user_data(self): + """ + Tests that the metadata field is populated on Linode create. + """ + + with self.mock_post("linode/instances/123") as m: + self.client.linode.instance_create( + "g6-nanode-1", + "us-southeast", + metadata=self.client.linode.build_instance_metadata( + user_data="cool" + ), + ) + + self.assertEqual( + m.call_data, + { + "region": "us-southeast", + "type": "g6-nanode-1", + "metadata": {"user_data": "Y29vbA=="}, + }, + ) + + def test_instance_create_with_interfaces_legacy(self): + """ + Tests that user can pass a list of interfaces on Linode create. + """ + interfaces = [ + {"purpose": "public"}, + ConfigInterface( + purpose="vlan", label="cool-vlan", ipam_address="10.0.0.4/32" + ), + ] + with self.mock_post("linode/instances/123") as m: + self.client.linode.instance_create( + "us-southeast", + "g6-nanode-1", + interface_generation=InterfaceGeneration.LEGACY_CONFIG, + interfaces=interfaces, + ) + + self.assertEqual( + m.call_data["interfaces"], + [ + {"purpose": "public"}, + { + "purpose": "vlan", + "label": "cool-vlan", + "ipam_address": "10.0.0.4/32", + }, + ], + ) + + def test_build_instance_metadata(self): + """ + Tests that the metadata field is built correctly. + """ + self.assertEqual( + self.client.linode.build_instance_metadata(user_data="cool"), + {"user_data": "Y29vbA=="}, + ) + + self.assertEqual( + self.client.linode.build_instance_metadata( + user_data="cool", encode_user_data=False + ), + {"user_data": "cool"}, + ) + + def test_create_with_placement_group(self): + """ + Tests that you can create a Linode with a Placement Group + """ + + with self.mock_post("linode/instances/123") as m: + self.client.linode.instance_create( + "g6-nanode-1", + "eu-west", + placement_group=InstancePlacementGroupAssignment( + id=123, + compliant_only=True, + ), + ) + + self.assertEqual( + m.call_data["placement_group"], {"id": 123, "compliant_only": True} + ) + + def test_instance_create_with_interfaces_linode(self): + """ + Tests that a Linode can be created alongside multiple LinodeInterfaces. + """ + + interfaces = [ + build_interface_options_public(), + build_interface_options_vpc(), + build_interface_options_vlan(), + ] + + with self.mock_post("linode/instances/124") as m: + self.client.linode.instance_create( + "g6-nanode-1", + "us-mia", + interface_generation=InterfaceGeneration.LINODE, + interfaces=interfaces, + ) + + assert m.call_data == { + "region": "us-mia", + "type": "g6-nanode-1", + "interface_generation": "linode", + "interfaces": [iface._serialize() for iface in interfaces], + } + + def test_create_with_maintenance_policy(self): + """ + Tests that you can create a Linode with a maintenance policy + """ + + with self.mock_post("linode/instances/123") as m: + self.client.linode.instance_create( + "g6-nanode-1", + "eu-west", + maintenance_policy="linode/migrate", + ) + + self.assertEqual(m.call_data["maintenance_policy"], "linode/migrate") + + +class TypeTest(ClientBaseCase): + def test_get_types(self): + """ + Tests that Linode types can be returned + """ + types = self.client.linode.types() + + self.assertEqual(len(types), 5) + for t in types: + self.assertTrue(t._populated) + self.assertIsNotNone(t.id) + self.assertIsNotNone(t.label) + self.assertIsNotNone(t.disk) + self.assertIsNotNone(t.type_class) + self.assertIsNotNone(t.gpus) + self.assertIsNone(t.successor) + self.assertIsNotNone(t.region_prices) + self.assertIsNotNone(t.addons.backups.region_prices) + self.assertIsNotNone(t.accelerated_devices) diff --git a/test/unit/groups/lke_test.py b/test/unit/groups/lke_test.py new file mode 100644 index 000000000..802960192 --- /dev/null +++ b/test/unit/groups/lke_test.py @@ -0,0 +1,93 @@ +from test.unit.base import ClientBaseCase + +from linode_api4.objects import ( + LKEClusterControlPlaneACLAddressesOptions, + LKEClusterControlPlaneACLOptions, + LKEClusterControlPlaneOptions, +) + + +class LKETest(ClientBaseCase): + """ + Tests methods of the LKE class + """ + + def test_cluster_create_with_acl(self): + """ + Tests that an LKE cluster can be created with a control plane ACL configuration. + """ + + with self.mock_post("lke/clusters") as m: + self.client.lke.cluster_create( + "us-mia", + "test-acl-cluster", + "1.29", + [self.client.lke.node_pool("g6-nanode-1", 3)], + control_plane=LKEClusterControlPlaneOptions( + acl=LKEClusterControlPlaneACLOptions( + enabled=True, + addresses=LKEClusterControlPlaneACLAddressesOptions( + ipv4=["10.0.0.1/32"], ipv6=["1234::5678"] + ), + ) + ), + ) + + assert "high_availability" not in m.call_data["control_plane"] + assert m.call_data["control_plane"]["acl"]["enabled"] + assert m.call_data["control_plane"]["acl"]["addresses"]["ipv4"] == [ + "10.0.0.1/32" + ] + assert m.call_data["control_plane"]["acl"]["addresses"]["ipv6"] == [ + "1234::5678" + ] + + def test_cluster_create_enterprise_without_node_pools(self): + """ + Tests that an enterprise LKE cluster can be created without node pools. + """ + with self.mock_post("lke/clusters") as m: + self.client.lke.cluster_create( + "us-west", + "test-enterprise-cluster", + "1.29", + tier="enterprise", + ) + + assert m.call_data["region"] == "us-west" + assert m.call_data["label"] == "test-enterprise-cluster" + assert m.call_data["k8s_version"] == "1.29" + assert m.call_data["tier"] == "enterprise" + assert m.call_data["node_pools"] == [] + + def test_cluster_create_enterprise_case_insensitive(self): + """ + Tests that tier comparison is case-insensitive for enterprise tier. + """ + with self.mock_post("lke/clusters") as m: + self.client.lke.cluster_create( + "us-west", + "test-enterprise-cluster", + "1.29", + tier="ENTERPRISE", + ) + + assert m.call_data["tier"] == "ENTERPRISE" + assert m.call_data["node_pools"] == [] + + def test_cluster_create_standard_without_node_pools_raises_error(self): + """ + Tests that creating a standard LKE cluster without node pools raises ValueError. + """ + with self.assertRaises(ValueError) as context: + self.client.lke.cluster_create( + "us-east", + "test-standard-cluster", + "1.29", + tier="standard", + ) + + self.assertIn( + "LKE standard clusters must have at least one node pool", + str(context.exception), + ) diff --git a/test/unit/groups/lke_tier_test.py b/test/unit/groups/lke_tier_test.py new file mode 100644 index 000000000..de4ae5212 --- /dev/null +++ b/test/unit/groups/lke_tier_test.py @@ -0,0 +1,18 @@ +from test.unit.base import ClientBaseCase + + +class LKETierGroupTest(ClientBaseCase): + """ + Tests methods under the LKETierGroup class. + """ + + def test_list_versions(self): + """ + Tests that LKE versions can be listed for a given tier. + """ + + tiers = self.client.lke.tier("standard").versions() + + assert tiers[0].id == "1.32" + assert tiers[1].id == "1.31" + assert tiers[2].id == "1.30" diff --git a/test/unit/groups/lock_test.py b/test/unit/groups/lock_test.py new file mode 100644 index 000000000..a1e3af26a --- /dev/null +++ b/test/unit/groups/lock_test.py @@ -0,0 +1,66 @@ +from test.unit.base import ClientBaseCase + +from linode_api4.objects import LockType + + +class LockGroupTest(ClientBaseCase): + """ + Tests methods of the LockGroup class + """ + + def test_list_locks(self): + """ + Tests that locks can be retrieved using client.locks() + """ + locks = self.client.locks() + + self.assertEqual(len(locks), 2) + self.assertEqual(locks[0].id, 1) + self.assertEqual(locks[0].lock_type, LockType.cannot_delete) + self.assertEqual(locks[0].entity.id, 123) + self.assertEqual(locks[0].entity.type, "linode") + self.assertEqual(locks[1].id, 2) + self.assertEqual( + locks[1].lock_type, LockType.cannot_delete_with_subresources + ) + self.assertEqual(locks[1].entity.id, 456) + + def test_create_lock(self): + """ + Tests that a lock can be created using client.locks.create() + """ + with self.mock_post("/locks/1") as m: + lock = self.client.locks.create( + entity_type="linode", + entity_id=123, + lock_type=LockType.cannot_delete, + ) + + self.assertEqual(m.call_url, "/locks") + self.assertEqual(m.call_data["entity_type"], "linode") + self.assertEqual(m.call_data["entity_id"], 123) + self.assertEqual(m.call_data["lock_type"], LockType.cannot_delete) + + self.assertEqual(lock.id, 1) + self.assertEqual(lock.lock_type, LockType.cannot_delete) + self.assertIsNotNone(lock.entity) + self.assertEqual(lock.entity.id, 123) + + def test_create_lock_with_subresources(self): + """ + Tests that a lock with subresources can be created + """ + with self.mock_post("/locks/1") as m: + self.client.locks.create( + entity_type="linode", + entity_id=456, + lock_type=LockType.cannot_delete_with_subresources, + ) + + self.assertEqual(m.call_url, "/locks") + self.assertEqual(m.call_data["entity_type"], "linode") + self.assertEqual(m.call_data["entity_id"], 456) + self.assertEqual( + m.call_data["lock_type"], + LockType.cannot_delete_with_subresources, + ) diff --git a/test/unit/groups/monitor_api_test.py b/test/unit/groups/monitor_api_test.py new file mode 100644 index 000000000..9515895ae --- /dev/null +++ b/test/unit/groups/monitor_api_test.py @@ -0,0 +1,118 @@ +from test.unit.base import ClientBaseCase, MonitorClientBaseCase + +from linode_api4 import PaginatedList +from linode_api4.objects import ( + AggregateFunction, + AlertDefinition, + EntityMetricOptions, +) + + +class MonitorAPITest(MonitorClientBaseCase): + """ + Tests methods of the Monitor API group + """ + + def test_fetch_metrics(self): + service_type = "dbaas" + url = f"/monitor/services/{service_type}/metrics" + with self.mock_post(url) as mock_post: + metrics = self.client.metrics.fetch_metrics( + service_type, + entity_ids=[13217, 13316], + metrics=[ + EntityMetricOptions( + name="avg_read_iops", + aggregate_function=AggregateFunction("avg"), + ), + {"name": "avg_cpu_usage", "aggregate_function": "avg"}, + ], + relative_time_duration={"unit": "hr", "value": 1}, + ) + + # assert call data + assert mock_post.call_url == url + assert mock_post.call_data == { + "entity_ids": [13217, 13316], + "metrics": [ + {"name": "avg_read_iops", "aggregate_function": "avg"}, + {"name": "avg_cpu_usage", "aggregate_function": "avg"}, + ], + "relative_time_duration": {"unit": "hr", "value": 1}, + } + + # assert the metrics data + metric_data = metrics.data.result[0] + + assert metrics.data.resultType == "matrix" + assert metric_data.metric["entity_id"] == 13316 + assert metric_data.metric["metric_name"] == "avg_read_iops" + assert metric_data.metric["node_id"] == "primary-9" + assert metric_data.values[0][0] == 1728996500 + assert metric_data.values[0][1] == "90.55555555555556" + + assert metrics.status == "success" + assert metrics.stats.executionTimeMsec == 21 + assert metrics.stats.seriesFetched == "2" + assert not metrics.isPartial + + +class MonitorAlertDefinitionsTest(ClientBaseCase): + def test_alert_definition(self): + service_type = "dbaas" + url = f"/monitor/services/{service_type}/alert-definitions" + with self.mock_get(url) as mock_get: + alert = self.client.monitor.alert_definitions( + service_type=service_type + ) + + assert mock_get.call_url == url + + # assert collection and element types + assert isinstance(alert, PaginatedList) + assert isinstance(alert[0], AlertDefinition) + + # fetch the raw JSON from the client and assert its fields + raw = self.client.get(url) + # raw is a paginated response; check first item's fields + first = raw["data"][0] + assert first["label"] == "Test Alert for DBAAS" + assert first["service_type"] == "dbaas" + assert first["status"] == "active" + assert first["created"] == "2024-01-01T00:00:00" + + def test_create_alert_definition(self): + service_type = "dbaas" + url = f"/monitor/services/{service_type}/alert-definitions" + result = { + "id": 67890, + "label": "Created Alert", + "service_type": service_type, + "severity": 1, + "status": "active", + } + + with self.mock_post(result) as mock_post: + alert = self.client.monitor.create_alert_definition( + service_type=service_type, + label="Created Alert", + severity=1, + channel_ids=[1, 2], + rule_criteria={"rules": []}, + trigger_conditions={"criteria_condition": "ALL"}, + entity_ids=["13217"], + description="created via test", + ) + + assert mock_post.call_url == url + # payload should include the provided fields + assert mock_post.call_data["label"] == "Created Alert" + assert mock_post.call_data["severity"] == 1 + assert "channel_ids" in mock_post.call_data + + assert isinstance(alert, AlertDefinition) + assert alert.id == 67890 + + # fetch the same response from the client and assert + resp = self.client.post(url, data={}) + assert resp["label"] == "Created Alert" diff --git a/test/unit/groups/networking_test.py b/test/unit/groups/networking_test.py new file mode 100644 index 000000000..72cc95cda --- /dev/null +++ b/test/unit/groups/networking_test.py @@ -0,0 +1,17 @@ +from test.unit.base import ClientBaseCase +from test.unit.objects.firewall_test import FirewallTemplatesTest + + +class NetworkingGroupTest(ClientBaseCase): + """ + Tests methods under the NetworkingGroup class. + """ + + def test_get_templates(self): + templates = self.client.networking.firewall_templates() + + assert templates[0].slug == "public" + FirewallTemplatesTest.assert_rules(templates[0].rules) + + assert templates[1].slug == "vpc" + FirewallTemplatesTest.assert_rules(templates[1].rules) diff --git a/test/unit/groups/placement_test.py b/test/unit/groups/placement_test.py new file mode 100644 index 000000000..3c8337845 --- /dev/null +++ b/test/unit/groups/placement_test.py @@ -0,0 +1,68 @@ +from test.unit.base import ClientBaseCase + +from linode_api4 import PlacementGroupPolicy +from linode_api4.objects import ( + MigratedInstance, + PlacementGroup, + PlacementGroupMember, + PlacementGroupType, +) + + +class PlacementTest(ClientBaseCase): + """ + Tests methods of the Placement Group + """ + + def test_list_pgs(self): + """ + Tests that you can list PGs. + """ + + pgs = self.client.placement.groups() + + self.validate_pg_123(pgs[0]) + assert pgs[0]._populated + + def test_create_pg(self): + """ + Tests that you can create a Placement Group. + """ + + with self.mock_post("/placement/groups/123") as m: + pg = self.client.placement.group_create( + "test", + "eu-west", + PlacementGroupType.anti_affinity_local, + PlacementGroupPolicy.strict, + ) + + assert m.call_url == "/placement/groups" + + self.assertEqual( + m.call_data, + { + "label": "test", + "region": "eu-west", + "placement_group_type": str( + PlacementGroupType.anti_affinity_local + ), + "placement_group_policy": PlacementGroupPolicy.strict, + }, + ) + + assert pg._populated + self.validate_pg_123(pg) + + def validate_pg_123(self, pg: PlacementGroup): + assert pg.id == 123 + assert pg.label == "test" + assert pg.region.id == "eu-west" + assert pg.placement_group_type == "anti_affinity:local" + assert pg.placement_group_policy == "strict" + assert pg.is_compliant + assert pg.members[0] == PlacementGroupMember( + linode_id=123, is_compliant=True + ) + assert pg.migrations.inbound[0] == MigratedInstance(linode_id=123) + assert pg.migrations.outbound[0] == MigratedInstance(linode_id=456) diff --git a/test/unit/objects/polling_test.py b/test/unit/groups/polling_test.py similarity index 100% rename from test/unit/objects/polling_test.py rename to test/unit/groups/polling_test.py diff --git a/test/unit/groups/region_test.py b/test/unit/groups/region_test.py new file mode 100644 index 000000000..35826c534 --- /dev/null +++ b/test/unit/groups/region_test.py @@ -0,0 +1,75 @@ +import json +from test.unit.base import ClientBaseCase + +from linode_api4.objects.region import RegionAvailabilityEntry + + +class RegionTest(ClientBaseCase): + """ + Tests methods of the Region class + """ + + def test_list_availability(self): + """ + Tests that region availability can be listed and filtered on. + """ + + with self.mock_get("/regions/availability") as m: + avail_entries = self.client.regions.availability( + RegionAvailabilityEntry.filters.region == "us-east", + RegionAvailabilityEntry.filters.plan == "premium4096.7", + ) + + assert len(avail_entries) > 0 + + for entry in avail_entries: + assert entry.region is not None + assert len(entry.region) > 0 + assert len(entry.plan) > 0 + assert entry.available is not None + + # Ensure all three pages are read + assert m.call_count == 3 + assert m.mock.call_args_list[0].args[0] == "//regions/availability" + + assert ( + m.mock.call_args_list[1].args[0] + == "//regions/availability?page=2&page_size=100" + ) + assert ( + m.mock.call_args_list[2].args[0] + == "//regions/availability?page=3&page_size=100" + ) + + # Ensure the filter headers are correct + for k, call in m.mock.call_args_list: + assert json.loads(call.get("headers").get("X-Filter")) == { + "+and": [{"region": "us-east"}, {"plan": "premium4096.7"}] + } + + def test_list_vpc_availability(self): + """ + Tests that region VPC availability can be listed. + """ + + with self.mock_get("/regions/vpc-availability") as m: + vpc_entries = self.client.regions.vpc_availability() + + assert len(vpc_entries) > 0 + + for entry in vpc_entries: + assert len(entry.region) > 0 + assert entry.available is not None + # available_ipv6_prefix_lengths may be empty list but should exist + assert entry.available_ipv6_prefix_lengths is not None + + # Ensure both pages are read + assert m.call_count == 2 + assert ( + m.mock.call_args_list[0].args[0] == "//regions/vpc-availability" + ) + + assert ( + m.mock.call_args_list[1].args[0] + == "//regions/vpc-availability?page=2&page_size=25" + ) diff --git a/test/unit/groups/vpc_test.py b/test/unit/groups/vpc_test.py new file mode 100644 index 000000000..7b8c985d2 --- /dev/null +++ b/test/unit/groups/vpc_test.py @@ -0,0 +1,107 @@ +import datetime +from test.unit.base import ClientBaseCase + +from linode_api4 import DATE_FORMAT, VPC, VPCSubnet + + +class VPCTest(ClientBaseCase): + """ + Tests methods of the VPC Group + """ + + def test_create_vpc(self): + """ + Tests that you can create a VPC. + """ + + with self.mock_post("/vpcs/123456") as m: + vpc = self.client.vpcs.create("test-vpc", "us-southeast") + + self.assertEqual(m.call_url, "/vpcs") + + self.assertEqual( + m.call_data, + { + "label": "test-vpc", + "region": "us-southeast", + }, + ) + + self.assertEqual(vpc._populated, True) + self.validate_vpc_123456(vpc) + + def test_create_vpc_with_subnet(self): + """ + Tests that you can create a VPC. + """ + + with self.mock_post("/vpcs/123456") as m: + vpc = self.client.vpcs.create( + "test-vpc", + "us-southeast", + subnets=[{"label": "test-subnet", "ipv4": "10.0.0.0/24"}], + ) + + self.assertEqual(m.call_url, "/vpcs") + + self.assertEqual( + m.call_data, + { + "label": "test-vpc", + "region": "us-southeast", + "subnets": [ + {"label": "test-subnet", "ipv4": "10.0.0.0/24"} + ], + }, + ) + + self.assertEqual(vpc._populated, True) + self.validate_vpc_123456(vpc) + + def test_list_ips(self): + """ + Validates that all VPC IPs can be listed. + """ + + with self.mock_get("/vpcs/ips") as m: + result = self.client.vpcs.ips() + + assert m.call_url == "/vpcs/ips" + assert len(result) == 1 + + ip = result[0] + assert ip.address == "10.0.0.2" + assert ip.address_range is None + assert ip.vpc_id == 123 + assert ip.subnet_id == 456 + assert ip.region == "us-mia" + assert ip.linode_id == 123 + assert ip.config_id == 456 + assert ip.interface_id == 789 + assert ip.active + assert ip.nat_1_1 == "172.233.179.133" + assert ip.gateway == "10.0.0.1" + assert ip.prefix == 24 + assert ip.subnet_mask == "255.255.255.0" + + def validate_vpc_123456(self, vpc: VPC): + expected_dt = datetime.datetime.strptime( + "2018-01-01T00:01:01", DATE_FORMAT + ) + + self.assertEqual(vpc.label, "test-vpc") + self.assertEqual(vpc.description, "A very real VPC.") + self.assertEqual(vpc.region.id, "us-southeast") + self.assertEqual(vpc.created, expected_dt) + self.assertEqual(vpc.updated, expected_dt) + + def validate_vpc_subnet_789(self, subnet: VPCSubnet): + expected_dt = datetime.datetime.strptime( + "2018-01-01T00:01:01", DATE_FORMAT + ) + + self.assertEqual(subnet.label, "test-subnet") + self.assertEqual(subnet.ipv4, "10.0.0.0/24") + self.assertEqual(subnet.linodes[0].id, 12345) + self.assertEqual(subnet.created, expected_dt) + self.assertEqual(subnet.updated, expected_dt) diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index 84c003e97..e82f3562d 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -1,7 +1,7 @@ from datetime import datetime from test.unit.base import ClientBaseCase -from linode_api4 import LongviewSubscription +from linode_api4 import FirewallCreateDevicesOptions, LongviewSubscription from linode_api4.objects.beta import BetaProgram from linode_api4.objects.linode import Instance from linode_api4.objects.networking import IPAddress @@ -44,7 +44,13 @@ def test_get_account(self): self.assertEqual(a.balance, 0) self.assertEqual( a.capabilities, - ["Linodes", "NodeBalancers", "Block Storage", "Object Storage"], + [ + "Linodes", + "NodeBalancers", + "Block Storage", + "Object Storage", + "Linode Interfaces", + ], ) def test_get_regions(self): @@ -63,12 +69,18 @@ def test_get_regions(self): "NodeBalancers", "Block Storage", "Object Storage", + "Linode Interfaces", ], ) else: self.assertEqual( region.capabilities, - ["Linodes", "NodeBalancers", "Block Storage"], + [ + "Linodes", + "NodeBalancers", + "Block Storage", + "Linode Interfaces", + ], ) self.assertEqual(region.status, "ok") self.assertIsNotNone(region.resolvers) @@ -150,7 +162,7 @@ def test_image_create(self): def test_get_volumes(self): v = self.client.volumes() - self.assertEqual(len(v), 3) + self.assertEqual(len(v), 4) self.assertEqual(v[0].label, "block1") self.assertEqual(v[0].region.id, "us-east-1a") self.assertEqual(v[1].label, "block2") @@ -307,6 +319,59 @@ def get_mock(*params, verify=True, **kwargs): assert called +class MaintenanceGroupTest(ClientBaseCase): + """ + Tests methods of the MaintenanceGroup + """ + + def test_maintenance(self): + """ + Tests that maintenance can be retrieved + Tests that maintenance can be retrieved + """ + with self.mock_get("/maintenance/policies") as m: + result = self.client.maintenance.maintenance_policies() + + self.assertEqual(m.call_url, "/maintenance/policies") + self.assertEqual(len(result), 3) + + policy_migrate = result[0] + policy_power_off_on = result[1] + policy_custom = result[2] + + self.assertEqual(policy_migrate.slug, "linode/migrate") + self.assertEqual(policy_migrate.label, "Migrate") + self.assertEqual( + policy_migrate.description, + "Migrates the Linode to a new host while it remains fully operational. Recommended for maximizing availability.", + ) + self.assertEqual(policy_migrate.type, "migrate") + self.assertEqual(policy_migrate.notification_period_sec, 3600) + self.assertTrue(policy_migrate.is_default) + + self.assertEqual(policy_power_off_on.slug, "linode/power_off_on") + self.assertEqual(policy_power_off_on.label, "Power Off/Power On") + self.assertEqual( + policy_power_off_on.description, + "Powers off the Linode at the start of the maintenance event and reboots it once the maintenance finishes. Recommended for maximizing performance.", + ) + self.assertEqual(policy_power_off_on.type, "power_off_on") + self.assertEqual(policy_power_off_on.notification_period_sec, 1800) + self.assertFalse(policy_power_off_on.is_default) + + self.assertEqual(policy_custom.slug, "private/12345") + self.assertEqual( + policy_custom.label, "Critical Workload - Avoid Migration" + ) + self.assertEqual( + policy_custom.description, + "Custom policy designed to power off and perform maintenance during user-defined windows only.", + ) + self.assertEqual(policy_custom.type, "power_off_on") + self.assertEqual(policy_custom.notification_period_sec, 7200) + self.assertFalse(policy_custom.is_default) + + class AccountGroupTest(ClientBaseCase): """ Tests methods of the AccountGroup @@ -353,12 +418,56 @@ def test_maintenance(self): """ with self.mock_get("/account/maintenance") as m: result = self.client.account.maintenance() + self.assertEqual(m.call_url, "/account/maintenance") - self.assertEqual(len(result), 1) + self.assertEqual(len(result), 2) + + maintenance_1 = result[0] + maintenance_2 = result[1] + + # First maintenance + self.assertEqual( + maintenance_1.reason, + "Scheduled upgrade to faster NVMe hardware.", + ) + self.assertEqual(maintenance_1.entity.id, 1234) + self.assertEqual(maintenance_1.entity.label, "Linode #1234") + self.assertEqual(maintenance_1.entity.type, "linode") + self.assertEqual(maintenance_1.entity.url, "/linodes/1234") + self.assertEqual( + maintenance_1.maintenance_policy_set, "linode/power_off_on" + ) + self.assertEqual(maintenance_1.description, "Scheduled Maintenance") + self.assertEqual(maintenance_1.source, "platform") + self.assertEqual(maintenance_1.not_before, "2025-03-25T10:00:00Z") + self.assertEqual(maintenance_1.start_time, "2025-03-25T12:00:00Z") + self.assertEqual( + maintenance_1.complete_time, "2025-03-25T14:00:00Z" + ) + self.assertEqual(maintenance_1.status, "scheduled") + self.assertEqual(maintenance_1.type, "linode_migrate") + + # Second maintenance + self.assertEqual( + maintenance_2.reason, + "Pending migration of Linode #1234 to a new host.", + ) + self.assertEqual(maintenance_2.entity.id, 1234) + self.assertEqual(maintenance_2.entity.label, "Linode #1234") + self.assertEqual(maintenance_2.entity.type, "linode") + self.assertEqual(maintenance_2.entity.url, "/linodes/1234") self.assertEqual( - result[0].reason, - "This maintenance will allow us to update the BIOS on the host's motherboard.", + maintenance_2.maintenance_policy_set, "linode/migrate" ) + self.assertEqual(maintenance_2.description, "Emergency Maintenance") + self.assertEqual(maintenance_2.source, "user") + self.assertEqual(maintenance_2.not_before, "2025-03-26T15:00:00Z") + self.assertEqual(maintenance_2.start_time, "2025-03-26T15:00:00Z") + self.assertEqual( + maintenance_2.complete_time, "2025-03-26T17:00:00Z" + ) + self.assertEqual(maintenance_2.status, "in-progress") + self.assertEqual(maintenance_2.type, "linode_migrate") def test_notifications(self): """ @@ -708,7 +817,7 @@ def test_cluster_create_with_api_objects(self): node_pools = self.client.lke.node_pool(node_type, 3) with self.mock_post("lke/clusters") as m: cluster = self.client.lke.cluster_create( - region, "example-cluster", node_pools, version + region, "example-cluster", version, node_pools ) self.assertEqual(m.call_data["region"], "ap-west") self.assertEqual( @@ -720,6 +829,19 @@ def test_cluster_create_with_api_objects(self): self.assertEqual(cluster.region.id, "ap-west") self.assertEqual(cluster.k8s_version.id, "1.19") + def test_lke_types(self): + """ + Tests that a list of LKETypes can be retrieved + """ + types = self.client.lke.types() + self.assertEqual(len(types), 2) + self.assertEqual(types[1].id, "lke-ha") + self.assertEqual(types[1].price.hourly, 0.09) + self.assertEqual(types[1].price.monthly, 60) + self.assertEqual(types[1].region_prices[0].id, "id-cgk") + self.assertEqual(types[1].region_prices[0].hourly, 0.108) + self.assertEqual(types[1].region_prices[0].monthly, 72) + def test_cluster_create_with_string_repr(self): """ Tests clusters can be created using string representations @@ -728,8 +850,8 @@ def test_cluster_create_with_string_repr(self): cluster = self.client.lke.cluster_create( "ap-west", "example-cluster", - {"type": "g6-standard-1", "count": 3}, "1.19", + {"type": "g6-standard-1", "count": 3}, ) self.assertEqual(m.call_data["region"], "ap-west") self.assertEqual( @@ -967,6 +1089,21 @@ def test_get_keys(self): self.assertEqual(key2.access_key, "testAccessKeyHere456") self.assertEqual(key2.secret_key, "[REDACTED]") + def test_object_storage_types(self): + """ + Tests that a list of ObjectStorageTypes can be retrieved + """ + types = self.client.object_storage.types() + self.assertEqual(len(types), 1) + self.assertEqual(types[0].id, "objectstorage") + self.assertEqual(types[0].label, "Object Storage") + self.assertEqual(types[0].price.hourly, 0.0015) + self.assertEqual(types[0].price.monthly, 0.1) + self.assertEqual(types[0].region_prices[0].id, "us-east") + self.assertEqual(types[0].region_prices[0].hourly, 0.00018) + self.assertEqual(types[0].region_prices[0].monthly, 0.12) + self.assertEqual(types[0].transfer, 0) + def test_keys_create(self): """ Tests that you can create Object Storage Keys @@ -1157,7 +1294,12 @@ def test_firewall_create(self): } f = self.client.networking.firewall_create( - "test-firewall-1", rules, status="enabled" + "test-firewall-1", + rules, + devices=FirewallCreateDevicesOptions( + linodes=[123], nodebalancers=[456], linode_interfaces=[789] + ), + status="enabled", ) self.assertIsNotNone(f) @@ -1172,6 +1314,11 @@ def test_firewall_create(self): "label": "test-firewall-1", "status": "enabled", "rules": rules, + "devices": { + "linodes": [123], + "nodebalancers": [456], + "linode_interfaces": [789], + }, }, ) @@ -1186,6 +1333,47 @@ def test_get_firewalls(self): self.assertEqual(firewall.id, 123) + def test_get_firewall_settings(self): + """ + Tests that firewall settings can be retrieved + """ + settings = self.client.networking.firewall_settings() + + assert settings.default_firewall_ids.vpc_interface == 123 + assert settings.default_firewall_ids.public_interface == 456 + assert settings.default_firewall_ids.linode == 789 + assert settings.default_firewall_ids.nodebalancer == 321 + + settings.invalidate() + + assert settings.default_firewall_ids.vpc_interface == 123 + assert settings.default_firewall_ids.public_interface == 456 + assert settings.default_firewall_ids.linode == 789 + assert settings.default_firewall_ids.nodebalancer == 321 + + def test_update_firewall_settings(self): + """ + Tests that firewall settings can be updated + """ + settings = self.client.networking.firewall_settings() + + settings.default_firewall_ids.vpc_interface = 321 + settings.default_firewall_ids.public_interface = 654 + settings.default_firewall_ids.linode = 987 + settings.default_firewall_ids.nodebalancer = 123 + + with self.mock_put("networking/firewalls/settings") as m: + settings.save() + + assert m.call_data == { + "default_firewall_ids": { + "vpc_interface": 321, + "public_interface": 654, + "linode": 987, + "nodebalancer": 123, + } + } + def test_ip_addresses_share(self): """ Tests that you can submit a correct ip addresses share api request. @@ -1236,3 +1424,57 @@ def test_ipv6_ranges(self): ranges = self.client.networking.ipv6_ranges() self.assertEqual(len(ranges), 1) self.assertEqual(ranges[0].range, "2600:3c01::") + + def test_network_transfer_prices(self): + """ + Tests that a list of NetworkTransferPrices can be retrieved + """ + transfer_prices = self.client.networking.transfer_prices() + self.assertEqual(len(transfer_prices), 2) + self.assertEqual(transfer_prices[1].id, "network_transfer") + self.assertEqual(transfer_prices[1].price.hourly, 0.005) + self.assertEqual(transfer_prices[1].price.monthly, None) + self.assertEqual(len(transfer_prices[1].region_prices), 2) + self.assertEqual(transfer_prices[1].region_prices[0].id, "id-cgk") + self.assertEqual(transfer_prices[1].region_prices[0].hourly, 0.015) + self.assertEqual(transfer_prices[1].region_prices[0].monthly, None) + + +class NodeBalancerGroupTest(ClientBaseCase): + """ + Tests methods of the NodeBalancerGroup + """ + + def test_nodebalancer_types(self): + """ + Tests that a list of NodebalancerTypes can be retrieved + """ + types = self.client.nodebalancers.types() + self.assertEqual(len(types), 1) + self.assertEqual(types[0].id, "nodebalancer") + self.assertEqual(types[0].price.hourly, 0.015) + self.assertEqual(types[0].price.monthly, 10) + self.assertEqual(len(types[0].region_prices), 2) + self.assertEqual(types[0].region_prices[0].id, "id-cgk") + self.assertEqual(types[0].region_prices[0].hourly, 0.018) + self.assertEqual(types[0].region_prices[0].monthly, 12) + + +class VolumeGroupTest(ClientBaseCase): + """ + Tests methods of the VolumeGroup + """ + + def test_volume_types(self): + """ + Tests that a list of VolumeTypes can be retrieved + """ + types = self.client.volumes.types() + self.assertEqual(len(types), 1) + self.assertEqual(types[0].id, "volume") + self.assertEqual(types[0].price.hourly, 0.00015) + self.assertEqual(types[0].price.monthly, 0.1) + self.assertEqual(len(types[0].region_prices), 2) + self.assertEqual(types[0].region_prices[0].id, "id-cgk") + self.assertEqual(types[0].region_prices[0].hourly, 0.00018) + self.assertEqual(types[0].region_prices[0].monthly, 0.12) diff --git a/test/unit/objects/account_test.py b/test/unit/objects/account_test.py index 053cc3d0e..da807d182 100644 --- a/test/unit/objects/account_test.py +++ b/test/unit/objects/account_test.py @@ -1,6 +1,9 @@ +from collections.abc import Iterable +from copy import deepcopy from datetime import datetime from test.unit.base import ClientBaseCase +from linode_api4 import AccountSettingsInterfacesForNewLinodes from linode_api4.objects import ( Account, AccountAvailability, @@ -21,10 +24,12 @@ ServiceTransfer, StackScript, User, + UserGrants, Volume, get_obj_grants, ) from linode_api4.objects.account import ChildAccount +from linode_api4.objects.vpc import VPC class InvoiceTest(ClientBaseCase): @@ -93,6 +98,7 @@ def test_get_account(self): self.assertEqual(account.balance_uninvoiced, 145) self.assertEqual(account.billing_source, "akamai") self.assertEqual(account.euuid, "E1AF5EEC-526F-487D-B317EBEB34C87D71") + self.assertIn("Linode Interfaces", account.capabilities) def test_get_login(self): """ @@ -117,6 +123,50 @@ def test_get_account_settings(self): self.assertEqual(settings.network_helper, False) self.assertEqual(settings.object_storage, "active") self.assertEqual(settings.backups_enabled, True) + self.assertEqual( + settings.interfaces_for_new_linodes, + AccountSettingsInterfacesForNewLinodes.linode_default_but_legacy_config_allowed, + ) + + def test_post_account_settings(self): + """ + Tests that account settings can be updated successfully + """ + settings = self.client.account.settings() + + settings.network_helper = True + settings.backups_enabled = False + settings.interfaces_for_new_linodes = ( + AccountSettingsInterfacesForNewLinodes.linode_only + ) + + with self.mock_put("/account/settings") as m: + settings.save() + + assert m.call_data == { + "network_helper": True, + "backups_enabled": False, + "interfaces_for_new_linodes": AccountSettingsInterfacesForNewLinodes.linode_only, + "maintenance_policy": "linode/migrate", + } + + def test_update_account_settings(self): + """ + Tests that account settings can be updated + """ + with self.mock_put("account/settings") as m: + settings = AccountSettings(self.client, False, {}) + + settings.maintenance_policy = "linode/migrate" + settings.save() + + self.assertEqual(m.call_url, "/account/settings") + self.assertEqual( + m.call_data, + { + "maintenance_policy": "linode/migrate", + }, + ) def test_get_event(self): """ @@ -125,20 +175,40 @@ def test_get_event(self): event = Event(self.client, 123, {}) self.assertEqual(event.action, "ticket_create") - self.assertEqual(event.created, datetime(2018, 1, 1, 0, 1, 1)) + self.assertEqual(event.created, datetime(2025, 3, 25, 12, 0, 0)) self.assertEqual(event.duration, 300.56) + self.assertIsNotNone(event.entity) + self.assertEqual(event.entity.id, 11111) + self.assertEqual(event.entity.label, "Problem booting my Linode") + self.assertEqual(event.entity.type, "ticket") + self.assertEqual(event.entity.url, "/v4/support/tickets/11111") + self.assertEqual(event.id, 123) - self.assertEqual(event.message, "None") + self.assertEqual(event.message, "Ticket created for user issue.") self.assertIsNone(event.percent_complete) self.assertIsNone(event.rate) self.assertTrue(event.read) + self.assertIsNotNone(event.secondary_entity) + self.assertEqual(event.secondary_entity.id, "linode/debian9") + self.assertEqual(event.secondary_entity.label, "linode1234") + self.assertEqual(event.secondary_entity.type, "linode") + self.assertEqual( + event.secondary_entity.url, "/v4/linode/instances/1234" + ) + self.assertTrue(event.seen) - self.assertIsNone(event.status) - self.assertIsNone(event.time_remaining) + self.assertEqual(event.status, "completed") self.assertEqual(event.username, "exampleUser") + self.assertEqual(event.maintenance_policy_set, "Tentative") + self.assertEqual(event.description, "Scheduled maintenance") + self.assertEqual(event.source, "user") + self.assertEqual(event.not_before, datetime(2025, 3, 25, 12, 0, 0)) + self.assertEqual(event.start_time, datetime(2025, 3, 25, 12, 30, 0)) + self.assertEqual(event.complete_time, datetime(2025, 3, 25, 13, 0, 0)) + def test_get_invoice(self): """ Tests that an invoice is loaded correctly by ID @@ -204,22 +274,6 @@ def test_get_payment_method(self): self.assertTrue(paymentMethod.is_default) self.assertEqual(paymentMethod.type, "credit_card") - def test_get_user_grant(self): - """ - Tests that a user grant is loaded correctly - """ - grants = get_obj_grants() - - self.assertTrue(grants.count(("linode", Instance)) > 0) - self.assertTrue(grants.count(("domain", Domain)) > 0) - self.assertTrue(grants.count(("stackscript", StackScript)) > 0) - self.assertTrue(grants.count(("nodebalancer", NodeBalancer)) > 0) - self.assertTrue(grants.count(("volume", Volume)) > 0) - self.assertTrue(grants.count(("image", Image)) > 0) - self.assertTrue(grants.count(("longview", LongviewClient)) > 0) - self.assertTrue(grants.count(("database", Database)) > 0) - self.assertTrue(grants.count(("firewall", Firewall)) > 0) - def test_payment_method_make_default(self): """ Tests that making a payment method default creates the correct api request. @@ -309,3 +363,88 @@ def test_child_account_create_token(self): token = child_account.create_token() self.assertEqual(token.token, "abcdefghijklmnop") self.assertEqual(m.call_data, {}) + + +def test_get_user_grant(): + """ + Tests that a user grant is loaded correctly + """ + grants = get_obj_grants() + + assert grants.count(("linode", Instance)) > 0 + assert grants.count(("domain", Domain)) > 0 + assert grants.count(("stackscript", StackScript)) > 0 + assert grants.count(("nodebalancer", NodeBalancer)) > 0 + assert grants.count(("volume", Volume)) > 0 + assert grants.count(("image", Image)) > 0 + assert grants.count(("longview", LongviewClient)) > 0 + assert grants.count(("database", Database)) > 0 + assert grants.count(("firewall", Firewall)) > 0 + assert grants.count(("vpc", VPC)) > 0 + + +def test_user_grants_serialization(): + """ + Tests that user grants from JSON is serialized correctly + """ + user_grants_json = { + "database": [ + {"id": 123, "label": "example-entity", "permissions": "read_only"} + ], + "domain": [ + {"id": 123, "label": "example-entity", "permissions": "read_only"} + ], + "firewall": [ + {"id": 123, "label": "example-entity", "permissions": "read_only"} + ], + "global": { + "account_access": "read_only", + "add_databases": True, + "add_domains": True, + "add_firewalls": True, + "add_images": True, + "add_linodes": True, + "add_longview": True, + "add_nodebalancers": True, + "add_stackscripts": True, + "add_volumes": True, + "add_vpcs": True, + "cancel_account": False, + "child_account_access": True, + "longview_subscription": True, + }, + "image": [ + {"id": 123, "label": "example-entity", "permissions": "read_only"} + ], + "linode": [ + {"id": 123, "label": "example-entity", "permissions": "read_only"} + ], + "longview": [ + {"id": 123, "label": "example-entity", "permissions": "read_only"} + ], + "nodebalancer": [ + {"id": 123, "label": "example-entity", "permissions": "read_only"} + ], + "stackscript": [ + {"id": 123, "label": "example-entity", "permissions": "read_only"} + ], + "volume": [ + {"id": 123, "label": "example-entity", "permissions": "read_only"} + ], + "vpc": [ + {"id": 123, "label": "example-entity", "permissions": "read_only"} + ], + } + + expected_serialized_grants = deepcopy(user_grants_json) + + for grants in expected_serialized_grants.values(): + if isinstance(grants, Iterable): + for grant in grants: + if isinstance(grant, dict) and "label" in grant: + del grant["label"] + + assert ( + UserGrants(None, None, user_grants_json)._serialize() + == expected_serialized_grants + ) diff --git a/test/unit/objects/base_test.py b/test/unit/objects/base_test.py new file mode 100644 index 000000000..d60a3bd38 --- /dev/null +++ b/test/unit/objects/base_test.py @@ -0,0 +1,286 @@ +from dataclasses import dataclass +from test.unit.base import ClientBaseCase + +from linode_api4.objects import Base, JSONObject, MappedObject, Property +from linode_api4.objects.base import ( + ExplicitNullValue, + _flatten_request_body_recursive, +) + + +class FlattenRequestBodyRecursiveCase(ClientBaseCase): + """Test cases for _flatten_request_body_recursive function""" + + def test_flatten_primitive_types(self): + """Test that primitive types are returned as-is""" + self.assertEqual(_flatten_request_body_recursive(123), 123) + self.assertEqual(_flatten_request_body_recursive("test"), "test") + self.assertEqual(_flatten_request_body_recursive(3.14), 3.14) + self.assertEqual(_flatten_request_body_recursive(True), True) + self.assertEqual(_flatten_request_body_recursive(False), False) + self.assertEqual(_flatten_request_body_recursive(None), None) + + def test_flatten_dict(self): + """Test that dicts are recursively flattened""" + test_dict = {"key1": "value1", "key2": 123, "key3": True} + result = _flatten_request_body_recursive(test_dict) + self.assertEqual(result, test_dict) + + def test_flatten_nested_dict(self): + """Test that nested dicts are recursively flattened""" + test_dict = { + "level1": { + "level2": {"level3": "value", "number": 42}, + "string": "test", + }, + "array": [1, 2, 3], + } + result = _flatten_request_body_recursive(test_dict) + self.assertEqual(result, test_dict) + + def test_flatten_list(self): + """Test that lists are recursively flattened""" + test_list = [1, "two", 3.0, True] + result = _flatten_request_body_recursive(test_list) + self.assertEqual(result, test_list) + + def test_flatten_nested_list(self): + """Test that nested lists are recursively flattened""" + test_list = [[1, 2], [3, [4, 5]], "string"] + result = _flatten_request_body_recursive(test_list) + self.assertEqual(result, test_list) + + def test_flatten_base_object(self): + """Test that Base objects are flattened to their ID""" + + class TestBase(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "label": Property(mutable=True), + } + + obj = TestBase(self.client, 123) + result = _flatten_request_body_recursive(obj) + self.assertEqual(result, 123) + + def test_flatten_base_object_in_dict(self): + """Test that Base objects in dicts are flattened to their ID""" + + class TestBase(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "label": Property(mutable=True), + } + + obj = TestBase(self.client, 456) + test_dict = {"resource": obj, "name": "test"} + result = _flatten_request_body_recursive(test_dict) + self.assertEqual(result, {"resource": 456, "name": "test"}) + + def test_flatten_base_object_in_list(self): + """Test that Base objects in lists are flattened to their ID""" + + class TestBase(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "label": Property(mutable=True), + } + + obj1 = TestBase(self.client, 111) + obj2 = TestBase(self.client, 222) + test_list = [obj1, "middle", obj2] + result = _flatten_request_body_recursive(test_list) + self.assertEqual(result, [111, "middle", 222]) + + def test_flatten_explicit_null_instance(self): + """Test that ExplicitNullValue instances are converted to None""" + result = _flatten_request_body_recursive(ExplicitNullValue()) + self.assertIsNone(result) + + def test_flatten_explicit_null_class(self): + """Test that ExplicitNullValue class is converted to None""" + result = _flatten_request_body_recursive(ExplicitNullValue) + self.assertIsNone(result) + + def test_flatten_explicit_null_in_dict(self): + """Test that ExplicitNullValue in dicts is converted to None""" + test_dict = { + "field1": "value", + "field2": ExplicitNullValue(), + "field3": ExplicitNullValue, + } + result = _flatten_request_body_recursive(test_dict) + self.assertEqual( + result, {"field1": "value", "field2": None, "field3": None} + ) + + def test_flatten_explicit_null_in_list(self): + """Test that ExplicitNullValue in lists is converted to None""" + test_list = ["value", ExplicitNullValue(), ExplicitNullValue, 123] + result = _flatten_request_body_recursive(test_list) + self.assertEqual(result, ["value", None, None, 123]) + + def test_flatten_mapped_object(self): + """Test that MappedObject is serialized""" + mapped_obj = MappedObject(key1="value1", key2=123) + result = _flatten_request_body_recursive(mapped_obj) + self.assertEqual(result, {"key1": "value1", "key2": 123}) + + def test_flatten_mapped_object_nested(self): + """Test that nested MappedObject is serialized""" + mapped_obj = MappedObject( + outer="value", inner={"nested_key": "nested_value"} + ) + result = _flatten_request_body_recursive(mapped_obj) + # The inner dict becomes a MappedObject when created + self.assertIn("outer", result) + self.assertEqual(result["outer"], "value") + self.assertIn("inner", result) + + def test_flatten_mapped_object_in_dict(self): + """Test that MappedObject in dicts is serialized""" + mapped_obj = MappedObject(key="value") + test_dict = {"field": mapped_obj, "other": "data"} + result = _flatten_request_body_recursive(test_dict) + self.assertEqual(result, {"field": {"key": "value"}, "other": "data"}) + + def test_flatten_mapped_object_in_list(self): + """Test that MappedObject in lists is serialized""" + mapped_obj = MappedObject(key="value") + test_list = [mapped_obj, "string", 123] + result = _flatten_request_body_recursive(test_list) + self.assertEqual(result, [{"key": "value"}, "string", 123]) + + def test_flatten_json_object(self): + """Test that JSONObject subclasses are serialized""" + + @dataclass + class TestJSONObject(JSONObject): + field1: str = "" + field2: int = 0 + + json_obj = TestJSONObject.from_json({"field1": "test", "field2": 42}) + result = _flatten_request_body_recursive(json_obj) + self.assertEqual(result, {"field1": "test", "field2": 42}) + + def test_flatten_json_object_in_dict(self): + """Test that JSONObject in dicts is serialized""" + + @dataclass + class TestJSONObject(JSONObject): + name: str = "" + + json_obj = TestJSONObject.from_json({"name": "test"}) + test_dict = {"obj": json_obj, "value": 123} + result = _flatten_request_body_recursive(test_dict) + self.assertEqual(result, {"obj": {"name": "test"}, "value": 123}) + + def test_flatten_json_object_in_list(self): + """Test that JSONObject in lists is serialized""" + + @dataclass + class TestJSONObject(JSONObject): + id: int = 0 + + json_obj = TestJSONObject.from_json({"id": 999}) + test_list = [json_obj, "text"] + result = _flatten_request_body_recursive(test_list) + self.assertEqual(result, [{"id": 999}, "text"]) + + def test_flatten_complex_nested_structure(self): + """Test a complex nested structure with multiple types""" + + class TestBase(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + } + + @dataclass + class TestJSONObject(JSONObject): + value: str = "" + + base_obj = TestBase(self.client, 555) + mapped_obj = MappedObject(key="mapped") + json_obj = TestJSONObject.from_json({"value": "json"}) + + complex_structure = { + "base": base_obj, + "mapped": mapped_obj, + "json": json_obj, + "null": ExplicitNullValue(), + "list": [base_obj, mapped_obj, json_obj, ExplicitNullValue], + "nested": { + "deep": { + "base": base_obj, + "primitives": [1, "two", 3.0], + } + }, + } + + result = _flatten_request_body_recursive(complex_structure) + + self.assertEqual(result["base"], 555) + self.assertEqual(result["mapped"], {"key": "mapped"}) + self.assertEqual(result["json"], {"value": "json"}) + self.assertIsNone(result["null"]) + self.assertEqual( + result["list"], [555, {"key": "mapped"}, {"value": "json"}, None] + ) + self.assertEqual(result["nested"]["deep"]["base"], 555) + self.assertEqual( + result["nested"]["deep"]["primitives"], [1, "two", 3.0] + ) + + def test_flatten_with_is_put_false(self): + """Test that is_put parameter is passed through""" + + @dataclass + class TestJSONObject(JSONObject): + field: str = "" + + def _serialize(self, is_put=False): + return {"field": self.field, "is_put": is_put} + + json_obj = TestJSONObject.from_json({"field": "test"}) + result = _flatten_request_body_recursive(json_obj, is_put=False) + self.assertEqual(result, {"field": "test", "is_put": False}) + + def test_flatten_with_is_put_true(self): + """Test that is_put=True parameter is passed through""" + + @dataclass + class TestJSONObject(JSONObject): + field: str = "" + + def _serialize(self, is_put=False): + return {"field": self.field, "is_put": is_put} + + json_obj = TestJSONObject.from_json({"field": "test"}) + result = _flatten_request_body_recursive(json_obj, is_put=True) + self.assertEqual(result, {"field": "test", "is_put": True}) + + def test_flatten_empty_dict(self): + """Test that empty dicts are handled correctly""" + result = _flatten_request_body_recursive({}) + self.assertEqual(result, {}) + + def test_flatten_empty_list(self): + """Test that empty lists are handled correctly""" + result = _flatten_request_body_recursive([]) + self.assertEqual(result, []) + + def test_flatten_dict_with_none_values(self): + """Test that None values in dicts are preserved""" + test_dict = {"key1": "value", "key2": None, "key3": 0} + result = _flatten_request_body_recursive(test_dict) + self.assertEqual(result, test_dict) + + def test_flatten_list_with_none_values(self): + """Test that None values in lists are preserved""" + test_list = ["value", None, 0, ""] + result = _flatten_request_body_recursive(test_list) + self.assertEqual(result, test_list) diff --git a/test/unit/objects/database_test.py b/test/unit/objects/database_test.py index d5b84cebb..3d0eb4dad 100644 --- a/test/unit/objects/database_test.py +++ b/test/unit/objects/database_test.py @@ -1,75 +1,17 @@ +import logging from test.unit.base import ClientBaseCase -from linode_api4 import PostgreSQLDatabase +from linode_api4 import ( + DatabasePrivateNetwork, + MySQLDatabaseConfigMySQLOptions, + MySQLDatabaseConfigOptions, + PostgreSQLDatabase, + PostgreSQLDatabaseConfigOptions, + PostgreSQLDatabaseConfigPGOptions, +) from linode_api4.objects import MySQLDatabase - -class DatabaseTest(ClientBaseCase): - """ - Tests methods of the DatabaseGroup class - """ - - def test_get_types(self): - """ - Test that database types are properly handled - """ - types = self.client.database.types() - - self.assertEqual(len(types), 1) - self.assertEqual(types[0].type_class, "nanode") - self.assertEqual(types[0].id, "g6-nanode-1") - self.assertEqual(types[0].engines.mysql[0].price.monthly, 20) - - def test_get_engines(self): - """ - Test that database engines are properly handled - """ - engines = self.client.database.engines() - - self.assertEqual(len(engines), 2) - - self.assertEqual(engines[0].engine, "mysql") - self.assertEqual(engines[0].id, "mysql/8.0.26") - self.assertEqual(engines[0].version, "8.0.26") - - self.assertEqual(engines[1].engine, "postgresql") - self.assertEqual(engines[1].id, "postgresql/10.14") - self.assertEqual(engines[1].version, "10.14") - - def test_get_databases(self): - """ - Test that databases are properly handled - """ - dbs = self.client.database.instances() - - self.assertEqual(len(dbs), 1) - self.assertEqual(dbs[0].allow_list[1], "192.0.1.0/24") - self.assertEqual(dbs[0].cluster_size, 3) - self.assertEqual(dbs[0].encrypted, False) - self.assertEqual(dbs[0].engine, "mysql") - self.assertEqual( - dbs[0].hosts.primary, - "lin-123-456-mysql-mysql-primary.servers.linodedb.net", - ) - self.assertEqual( - dbs[0].hosts.secondary, - "lin-123-456-mysql-primary-private.servers.linodedb.net", - ) - self.assertEqual(dbs[0].id, 123) - self.assertEqual(dbs[0].region, "us-east") - self.assertEqual(dbs[0].updates.duration, 3) - self.assertEqual(dbs[0].version, "8.0.26") - - def test_database_instance(self): - """ - Ensures that the .instance attribute properly translates database types - """ - - dbs = self.client.database.instances() - db_translated = dbs[0].instance - - self.assertTrue(isinstance(db_translated, MySQLDatabase)) - self.assertEqual(db_translated.ssl_connection, True) +logger = logging.getLogger(__name__) class MySQLDatabaseTest(ClientBaseCase): @@ -77,35 +19,13 @@ class MySQLDatabaseTest(ClientBaseCase): Tests methods of the MySQLDatabase class """ - def test_get_instances(self): - """ - Test that database types are properly handled - """ - dbs = self.client.database.mysql_instances() - - self.assertEqual(len(dbs), 1) - self.assertEqual(dbs[0].allow_list[1], "192.0.1.0/24") - self.assertEqual(dbs[0].cluster_size, 3) - self.assertEqual(dbs[0].encrypted, False) - self.assertEqual(dbs[0].engine, "mysql") - self.assertEqual( - dbs[0].hosts.primary, - "lin-123-456-mysql-mysql-primary.servers.linodedb.net", - ) - self.assertEqual( - dbs[0].hosts.secondary, - "lin-123-456-mysql-primary-private.servers.linodedb.net", - ) - self.assertEqual(dbs[0].id, 123) - self.assertEqual(dbs[0].region, "us-east") - self.assertEqual(dbs[0].updates.duration, 3) - self.assertEqual(dbs[0].version, "8.0.26") - def test_create(self): """ Test that MySQL databases can be created """ + logger = logging.getLogger(__name__) + with self.mock_post("/databases/mysql/instances") as m: # We don't care about errors here; we just want to # validate the request. @@ -116,9 +36,22 @@ def test_create(self): "mysql/8.0.26", "g6-standard-1", cluster_size=3, + engine_config=MySQLDatabaseConfigOptions( + mysql=MySQLDatabaseConfigMySQLOptions( + connect_timeout=20 + ), + binlog_retention_period=200, + ), + private_network=DatabasePrivateNetwork( + vpc_id=1234, + subnet_id=5678, + public_access=True, + ), + ) + except Exception as e: + logger.warning( + "An error occurred while validating the request: %s", e ) - except Exception: - pass self.assertEqual(m.method, "post") self.assertEqual(m.call_url, "/databases/mysql/instances") @@ -127,6 +60,18 @@ def test_create(self): self.assertEqual(m.call_data["engine"], "mysql/8.0.26") self.assertEqual(m.call_data["type"], "g6-standard-1") self.assertEqual(m.call_data["cluster_size"], 3) + self.assertEqual( + m.call_data["engine_config"]["mysql"]["connect_timeout"], 20 + ) + self.assertEqual( + m.call_data["engine_config"]["binlog_retention_period"], 200 + ) + + self.assertEqual(m.call_data["private_network"]["vpc_id"], 1234) + self.assertEqual(m.call_data["private_network"]["subnet_id"], 5678) + self.assertEqual( + m.call_data["private_network"]["public_access"], True + ) def test_update(self): """ @@ -141,6 +86,15 @@ def test_update(self): db.updates.day_of_week = 2 db.allow_list = new_allow_list db.label = "cool" + db.engine_config = MySQLDatabaseConfigOptions( + mysql=MySQLDatabaseConfigMySQLOptions(connect_timeout=20), + binlog_retention_period=200, + ) + db.private_network = DatabasePrivateNetwork( + vpc_id=1234, + subnet_id=5678, + public_access=True, + ) db.save() @@ -149,60 +103,17 @@ def test_update(self): self.assertEqual(m.call_data["label"], "cool") self.assertEqual(m.call_data["updates"]["day_of_week"], 2) self.assertEqual(m.call_data["allow_list"], new_allow_list) - - def test_list_backups(self): - """ - Test that MySQL backups list properly - """ - - db = MySQLDatabase(self.client, 123) - backups = db.backups - - self.assertEqual(len(backups), 1) - - self.assertEqual(backups[0].id, 456) - self.assertEqual( - backups[0].label, "Scheduled - 02/04/22 11:11 UTC-XcCRmI" - ) - self.assertEqual(backups[0].type, "auto") - - def test_create_backup(self): - """ - Test that MySQL database backups can be updated - """ - - with self.mock_post("/databases/mysql/instances/123/backups") as m: - db = MySQLDatabase(self.client, 123) - - # We don't care about errors here; we just want to - # validate the request. - try: - db.backup_create("mybackup", target="secondary") - except Exception: - pass - - self.assertEqual(m.method, "post") self.assertEqual( - m.call_url, "/databases/mysql/instances/123/backups" + m.call_data["engine_config"]["mysql"]["connect_timeout"], 20 + ) + self.assertEqual( + m.call_data["engine_config"]["binlog_retention_period"], 200 ) - self.assertEqual(m.call_data["label"], "mybackup") - self.assertEqual(m.call_data["target"], "secondary") - - def test_backup_restore(self): - """ - Test that MySQL database backups can be restored - """ - - with self.mock_post( - "/databases/mysql/instances/123/backups/456/restore" - ) as m: - db = MySQLDatabase(self.client, 123) - - db.backups[0].restore() - self.assertEqual(m.method, "post") + self.assertEqual(m.call_data["private_network"]["vpc_id"], 1234) + self.assertEqual(m.call_data["private_network"]["subnet_id"], 5678) self.assertEqual( - m.call_url, "/databases/mysql/instances/123/backups/456/restore" + m.call_data["private_network"]["public_access"], True ) def test_patch(self): @@ -254,36 +165,40 @@ def test_reset_credentials(self): m.call_url, "/databases/mysql/instances/123/credentials/reset" ) + def test_suspend(self): + """ + Test MySQL Database suspend logic. + """ + with self.mock_post("/databases/mysql/instances/123/suspend") as m: + db = MySQLDatabase(self.client, 123) + + db.suspend() + + self.assertEqual(m.method, "post") + self.assertEqual( + m.call_url, "/databases/mysql/instances/123/suspend" + ) + + def test_resume(self): + """ + Test MySQL Database resume logic. + """ + with self.mock_post("/databases/mysql/instances/123/resume") as m: + db = MySQLDatabase(self.client, 123) + + db.resume() + + self.assertEqual(m.method, "post") + self.assertEqual( + m.call_url, "/databases/mysql/instances/123/resume" + ) + class PostgreSQLDatabaseTest(ClientBaseCase): """ Tests methods of the PostgreSQLDatabase class """ - def test_get_instances(self): - """ - Test that database types are properly handled - """ - dbs = self.client.database.postgresql_instances() - - self.assertEqual(len(dbs), 1) - self.assertEqual(dbs[0].allow_list[1], "192.0.1.0/24") - self.assertEqual(dbs[0].cluster_size, 3) - self.assertEqual(dbs[0].encrypted, False) - self.assertEqual(dbs[0].engine, "postgresql") - self.assertEqual( - dbs[0].hosts.primary, - "lin-0000-000-pgsql-primary.servers.linodedb.net", - ) - self.assertEqual( - dbs[0].hosts.secondary, - "lin-0000-000-pgsql-primary-private.servers.linodedb.net", - ) - self.assertEqual(dbs[0].id, 123) - self.assertEqual(dbs[0].region, "us-east") - self.assertEqual(dbs[0].updates.duration, 3) - self.assertEqual(dbs[0].version, "13.2") - def test_create(self): """ Test that PostgreSQL databases can be created @@ -299,6 +214,22 @@ def test_create(self): "postgresql/13.2", "g6-standard-1", cluster_size=3, + engine_config=PostgreSQLDatabaseConfigOptions( + pg=PostgreSQLDatabaseConfigPGOptions( + autovacuum_analyze_scale_factor=0.5, + pg_partman_bgw_interval=3600, + pg_partman_bgw_role="myrolename", + pg_stat_monitor_pgsm_enable_query_plan=False, + pg_stat_monitor_pgsm_max_buckets=10, + pg_stat_statements_track="top", + ), + work_mem=4, + ), + private_network=DatabasePrivateNetwork( + vpc_id=1234, + subnet_id=5678, + public_access=True, + ), ) except Exception: pass @@ -310,6 +241,43 @@ def test_create(self): self.assertEqual(m.call_data["engine"], "postgresql/13.2") self.assertEqual(m.call_data["type"], "g6-standard-1") self.assertEqual(m.call_data["cluster_size"], 3) + self.assertEqual( + m.call_data["engine_config"]["pg"][ + "autovacuum_analyze_scale_factor" + ], + 0.5, + ) + self.assertEqual( + m.call_data["engine_config"]["pg"]["pg_partman_bgw.interval"], + 3600, + ) + self.assertEqual( + m.call_data["engine_config"]["pg"]["pg_partman_bgw.role"], + "myrolename", + ) + self.assertEqual( + m.call_data["engine_config"]["pg"][ + "pg_stat_monitor.pgsm_enable_query_plan" + ], + False, + ) + self.assertEqual( + m.call_data["engine_config"]["pg"][ + "pg_stat_monitor.pgsm_max_buckets" + ], + 10, + ) + self.assertEqual( + m.call_data["engine_config"]["pg"]["pg_stat_statements.track"], + "top", + ) + self.assertEqual(m.call_data["engine_config"]["work_mem"], 4) + + self.assertEqual(m.call_data["private_network"]["vpc_id"], 1234) + self.assertEqual(m.call_data["private_network"]["subnet_id"], 5678) + self.assertEqual( + m.call_data["private_network"]["public_access"], True + ) def test_update(self): """ @@ -324,6 +292,18 @@ def test_update(self): db.updates.day_of_week = 2 db.allow_list = new_allow_list db.label = "cool" + db.engine_config = PostgreSQLDatabaseConfigOptions( + pg=PostgreSQLDatabaseConfigPGOptions( + autovacuum_analyze_scale_factor=0.5 + ), + work_mem=4, + ) + + db.private_network = DatabasePrivateNetwork( + vpc_id=1234, + subnet_id=5678, + public_access=True, + ) db.save() @@ -332,61 +312,18 @@ def test_update(self): self.assertEqual(m.call_data["label"], "cool") self.assertEqual(m.call_data["updates"]["day_of_week"], 2) self.assertEqual(m.call_data["allow_list"], new_allow_list) - - def test_list_backups(self): - """ - Test that PostgreSQL backups list properly - """ - - db = PostgreSQLDatabase(self.client, 123) - backups = db.backups - - self.assertEqual(len(backups), 1) - - self.assertEqual(backups[0].id, 456) - self.assertEqual( - backups[0].label, "Scheduled - 02/04/22 11:11 UTC-XcCRmI" - ) - self.assertEqual(backups[0].type, "auto") - - def test_create_backup(self): - """ - Test that PostgreSQL database backups can be created - """ - - with self.mock_post("/databases/postgresql/instances/123/backups") as m: - db = PostgreSQLDatabase(self.client, 123) - - # We don't care about errors here; we just want to - # validate the request. - try: - db.backup_create("mybackup", target="secondary") - except Exception: - pass - - self.assertEqual(m.method, "post") self.assertEqual( - m.call_url, "/databases/postgresql/instances/123/backups" + m.call_data["engine_config"]["pg"][ + "autovacuum_analyze_scale_factor" + ], + 0.5, ) - self.assertEqual(m.call_data["label"], "mybackup") - self.assertEqual(m.call_data["target"], "secondary") + self.assertEqual(m.call_data["engine_config"]["work_mem"], 4) - def test_backup_restore(self): - """ - Test that PostgreSQL database backups can be restored - """ - - with self.mock_post( - "/databases/postgresql/instances/123/backups/456/restore" - ) as m: - db = PostgreSQLDatabase(self.client, 123) - - db.backups[0].restore() - - self.assertEqual(m.method, "post") + self.assertEqual(m.call_data["private_network"]["vpc_id"], 1234) + self.assertEqual(m.call_data["private_network"]["subnet_id"], 5678) self.assertEqual( - m.call_url, - "/databases/postgresql/instances/123/backups/456/restore", + m.call_data["private_network"]["public_access"], True ) def test_patch(self): @@ -440,3 +377,31 @@ def test_reset_credentials(self): m.call_url, "/databases/postgresql/instances/123/credentials/reset", ) + + def test_suspend(self): + """ + Test PostgreSQL Database suspend logic. + """ + with self.mock_post("/databases/postgresql/instances/123/suspend") as m: + db = PostgreSQLDatabase(self.client, 123) + + db.suspend() + + self.assertEqual(m.method, "post") + self.assertEqual( + m.call_url, "/databases/postgresql/instances/123/suspend" + ) + + def test_resume(self): + """ + Test PostgreSQL Database resume logic. + """ + with self.mock_post("/databases/postgresql/instances/123/resume") as m: + db = PostgreSQLDatabase(self.client, 123) + + db.resume() + + self.assertEqual(m.method, "post") + self.assertEqual( + m.call_url, "/databases/postgresql/instances/123/resume" + ) diff --git a/test/unit/objects/firewall_test.py b/test/unit/objects/firewall_test.py index a46ea2750..f4c6efb66 100644 --- a/test/unit/objects/firewall_test.py +++ b/test/unit/objects/firewall_test.py @@ -1,5 +1,6 @@ from test.unit.base import ClientBaseCase +from linode_api4 import FirewallTemplate, MappedObject from linode_api4.objects import Firewall, FirewallDevice @@ -54,6 +55,21 @@ def test_update_rules(self): self.assertEqual(m.call_data, new_rules) + def test_create_device(self): + """ + Tests that firewall devices can be created successfully + """ + + firewall = Firewall(self.client, 123) + + with self.mock_post("networking/firewalls/123/devices/123") as m: + firewall.device_create(123, "linode") + assert m.call_data == {"id": 123, "type": "linode"} + + with self.mock_post("networking/firewalls/123/devices/456") as m: + firewall.device_create(123, "interface") + assert m.call_data == {"id": 123, "type": "interface"} + class FirewallDevicesTest(ClientBaseCase): """ @@ -65,7 +81,28 @@ def test_get_devices(self): Tests that devices can be pulled from a firewall """ firewall = Firewall(self.client, 123) - self.assertEqual(len(firewall.devices), 1) + assert len(firewall.devices) == 2 + + assert firewall.devices[0].created is not None + assert firewall.devices[0].id == 123 + assert firewall.devices[0].updated is not None + + assert firewall.devices[0].entity.id == 123 + assert firewall.devices[0].entity.label == "my-linode" + assert firewall.devices[0].entity.type == "linode" + assert firewall.devices[0].entity.url == "/v4/linode/instances/123" + + assert firewall.devices[1].created is not None + assert firewall.devices[1].id == 456 + assert firewall.devices[1].updated is not None + + assert firewall.devices[1].entity.id == 123 + assert firewall.devices[1].entity.label is None + assert firewall.devices[1].entity.type == "interface" + assert ( + firewall.devices[1].entity.url + == "/v4/linode/instances/123/interfaces/123" + ) def test_get_device(self): """ @@ -81,3 +118,43 @@ def test_get_device(self): self.assertEqual(device.entity.url, "/v4/linode/instances/123") self.assertEqual(device._populated, True) + + +class FirewallTemplatesTest(ClientBaseCase): + @staticmethod + def assert_rules(rules: MappedObject): + assert rules.outbound_policy == "DROP" + assert len(rules.outbound) == 1 + + assert rules.inbound_policy == "DROP" + assert len(rules.inbound) == 1 + + outbound_rule = rules.outbound[0] + assert outbound_rule.action == "ACCEPT" + assert outbound_rule.addresses.ipv4[0] == "192.0.2.0/24" + assert outbound_rule.addresses.ipv4[1] == "198.51.100.2/32" + assert outbound_rule.addresses.ipv6[0] == "2001:DB8::/128" + assert outbound_rule.description == "test" + assert outbound_rule.label == "test-rule" + assert outbound_rule.ports == "22-24, 80, 443" + assert outbound_rule.protocol == "TCP" + + inbound_rule = rules.inbound[0] + assert inbound_rule.action == "ACCEPT" + assert inbound_rule.addresses.ipv4[0] == "192.0.2.0/24" + assert inbound_rule.addresses.ipv4[1] == "198.51.100.2/32" + assert inbound_rule.addresses.ipv6[0] == "2001:DB8::/128" + assert inbound_rule.description == "test" + assert inbound_rule.label == "test-rule" + assert inbound_rule.ports == "22-24, 80, 443" + assert inbound_rule.protocol == "TCP" + + def test_get_public(self): + template = self.client.load(FirewallTemplate, "public") + assert template.slug == "public" + self.assert_rules(template.rules) + + def test_get_vpc(self): + template = self.client.load(FirewallTemplate, "vpc") + assert template.slug == "vpc" + self.assert_rules(template.rules) diff --git a/test/unit/objects/image_share_group_test.py b/test/unit/objects/image_share_group_test.py new file mode 100644 index 000000000..e02f0672c --- /dev/null +++ b/test/unit/objects/image_share_group_test.py @@ -0,0 +1,295 @@ +from test.unit.base import ClientBaseCase + +from linode_api4.objects import ( + ImageShareGroup, + ImageShareGroupImagesToAdd, + ImageShareGroupImageToAdd, + ImageShareGroupImageToUpdate, + ImageShareGroupMemberToAdd, + ImageShareGroupMemberToUpdate, + ImageShareGroupToken, +) + + +class ImageShareGroupTest(ClientBaseCase): + """ + Tests the methods of ImageShareGroup class + """ + + def test_get_sharegroup(self): + """ + Tests that an Image Share Group is loaded correctly by ID + """ + sharegroup = ImageShareGroup(self.client, 1234) + + self.assertEqual(sharegroup.id, 1234) + self.assertEqual( + sharegroup.description, "My group of images to share with my team." + ) + self.assertEqual(sharegroup.images_count, 0) + self.assertEqual(sharegroup.is_suspended, False) + self.assertEqual(sharegroup.label, "My Shared Images") + self.assertEqual(sharegroup.members_count, 0) + self.assertEqual( + sharegroup.uuid, "1533863e-16a4-47b5-b829-ac0f35c13278" + ) + + def test_update_sharegroup(self): + """ + Tests that an Image Share Group can be updated + """ + with self.mock_put("/images/sharegroups/1234") as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + sharegroup.label = "Updated Sharegroup Label" + sharegroup.description = "Updated description for my sharegroup." + sharegroup.save() + self.assertEqual(m.call_url, "/images/sharegroups/1234") + self.assertEqual( + m.call_data, + { + "label": "Updated Sharegroup Label", + "description": "Updated description for my sharegroup.", + }, + ) + + def test_delete_sharegroup(self): + """ + Tests that deleting an Image Share Group creates the correct api request + """ + with self.mock_delete() as m: + sharegroup = ImageShareGroup(self.client, 1234) + sharegroup.delete() + + self.assertEqual(m.call_url, "/images/sharegroups/1234") + + def test_add_images_to_sharegroup(self): + """ + Tests that Images can be added to an Image Share Group + """ + with self.mock_post("/images/sharegroups/1234/images") as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + sharegroup.add_images( + ImageShareGroupImagesToAdd( + images=[ + ImageShareGroupImageToAdd(id="private/123"), + ] + ) + ) + + self.assertEqual(m.call_url, "/images/sharegroups/1234/images") + self.assertEqual( + m.call_data, + { + "images": [ + {"id": "private/123"}, + ] + }, + ) + + def test_get_image_shares_in_sharegroup(self): + """ + Tests that Image Shares in an Image Share Group can be retrieved + """ + with self.mock_get("/images/sharegroups/1234/images") as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + images = sharegroup.get_image_shares() + + self.assertEqual(m.call_url, "/images/sharegroups/1234/images") + self.assertEqual(len(images), 1) + self.assertEqual(images[0].id, "shared/1") + + def test_update_image_in_sharegroup(self): + """ + Tests that an Image shared in an Image Share Group can be updated + """ + with self.mock_put("/images/sharegroups/1234/images/shared/1") as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + sharegroup.update_image_share( + ImageShareGroupImageToUpdate(image_share_id="shared/1") + ) + + self.assertEqual( + m.call_url, "/images/sharegroups/1234/images/shared/1" + ) + self.assertEqual( + m.call_data, + { + "image_share_id": "shared/1", + }, + ) + + def test_remove_image_from_sharegroup(self): + """ + Tests that an Image can be removed from an Image Share Group + """ + with self.mock_delete() as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + sharegroup.revoke_image_share("shared/1") + + self.assertEqual( + m.call_url, "/images/sharegroups/1234/images/shared/1" + ) + + def test_add_members_to_sharegroup(self): + """ + Tests that members can be added to an Image Share Group + """ + with self.mock_post("/images/sharegroups/1234/members") as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + sharegroup.add_member( + ImageShareGroupMemberToAdd( + token="secrettoken", + label="New Member", + ) + ) + + self.assertEqual(m.call_url, "/images/sharegroups/1234/members") + self.assertEqual( + m.call_data, + { + "token": "secrettoken", + "label": "New Member", + }, + ) + + def test_get_members_in_sharegroup(self): + """ + Tests that members in an Image Share Group can be retrieved + """ + with self.mock_get("/images/sharegroups/1234/members") as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + members = sharegroup.get_members() + + self.assertEqual(m.call_url, "/images/sharegroups/1234/members") + self.assertEqual(len(members), 1) + self.assertEqual( + members[0].token_uuid, "4591075e-4ba8-43c9-a521-928c3d4a135d" + ) + + def test_get_member_in_sharegroup(self): + """ + Tests that a specific member in an Image Share Group can be retrieved + """ + with self.mock_get("/images/sharegroups/1234/members/abc123") as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + member = sharegroup.get_member("abc123") + + self.assertEqual( + m.call_url, "/images/sharegroups/1234/members/abc123" + ) + self.assertEqual(member.token_uuid, "abc123") + + def test_update_member_in_sharegroup(self): + """ + Tests that a member in an Image Share Group can be updated + """ + with self.mock_put("/images/sharegroups/1234/members/abc123") as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + sharegroup.update_member( + ImageShareGroupMemberToUpdate( + token_uuid="abc123", + label="Updated Member Label", + ) + ) + + self.assertEqual( + m.call_url, "/images/sharegroups/1234/members/abc123" + ) + self.assertEqual( + m.call_data, + { + "label": "Updated Member Label", + }, + ) + + def test_remove_member_from_sharegroup(self): + """ + Tests that a member can be removed from an Image Share Group + """ + with self.mock_delete() as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + sharegroup.remove_member("abc123") + + self.assertEqual( + m.call_url, "/images/sharegroups/1234/members/abc123" + ) + + +class ImageShareGroupTokenTest(ClientBaseCase): + """ + Tests the methods of ImageShareGroupToken class + """ + + def test_get_sharegroup_token(self): + """ + Tests that an Image Share Group Token is loaded correctly by UUID + """ + token = self.client.load(ImageShareGroupToken, "abc123") + + self.assertEqual(token.token_uuid, "abc123") + self.assertEqual(token.label, "My Sharegroup Token") + self.assertEqual(token.sharegroup_label, "A Sharegroup") + self.assertEqual( + token.sharegroup_uuid, "e1d0e58b-f89f-4237-84ab-b82077342359" + ) + self.assertEqual(token.status, "active") + self.assertEqual( + token.valid_for_sharegroup_uuid, + "e1d0e58b-f89f-4237-84ab-b82077342359", + ) + + def test_update_sharegroup_token(self): + """ + Tests that an Image Share Group Token can be updated + """ + with self.mock_put("/images/sharegroups/tokens/abc123") as m: + token = self.client.load(ImageShareGroupToken, "abc123") + token.label = "Updated Token Label" + token.save() + self.assertEqual(m.call_url, "/images/sharegroups/tokens/abc123") + self.assertEqual( + m.call_data, + { + "label": "Updated Token Label", + }, + ) + + def test_delete_sharegroup_token(self): + """ + Tests that deleting an Image Share Group Token creates the correct api request + """ + with self.mock_delete() as m: + token = ImageShareGroupToken(self.client, "abc123") + token.delete() + + self.assertEqual(m.call_url, "/images/sharegroups/tokens/abc123") + + def test_sharegroup_token_get_sharegroup(self): + """ + Tests that the Image Share Group associated with a Token can be retrieved + """ + with self.mock_get("/images/sharegroups/tokens/abc123/sharegroup") as m: + token = self.client.load(ImageShareGroupToken, "abc123") + sharegroup = token.get_sharegroup() + + self.assertEqual( + m.call_url, "/images/sharegroups/tokens/abc123/sharegroup" + ) + self.assertEqual(sharegroup.id, 1234) + + def test_sharegroup_token_get_images(self): + """ + Tests that the Images associated with a Token can be retrieved + """ + with self.mock_get( + "/images/sharegroups/tokens/abc123/sharegroup/images" + ) as m: + token = self.client.load(ImageShareGroupToken, "abc123") + images = token.get_images() + + self.assertEqual( + m.call_url, + "/images/sharegroups/tokens/abc123/sharegroup/images", + ) + self.assertEqual(len(images), 1) + self.assertEqual(images[0].id, "shared/1") diff --git a/test/unit/objects/image_test.py b/test/unit/objects/image_test.py index d4851e777..1ea2fd66e 100644 --- a/test/unit/objects/image_test.py +++ b/test/unit/objects/image_test.py @@ -1,15 +1,15 @@ from datetime import datetime from io import BytesIO from test.unit.base import ClientBaseCase -from typing import BinaryIO +from typing import BinaryIO, Optional from unittest.mock import patch from linode_api4.objects import Image, Region # A minimal gzipped image that will be accepted by the API TEST_IMAGE_CONTENT = ( - b"\x1F\x8B\x08\x08\xBD\x5C\x91\x60\x00\x03\x74\x65\x73\x74\x2E\x69" - b"\x6D\x67\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x1f\x8b\x08\x08\xbd\x5c\x91\x60\x00\x03\x74\x65\x73\x74\x2e\x69" + b"\x6d\x67\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00" ) @@ -55,6 +55,8 @@ def test_get_image(self): self.assertEqual(image.total_size, 1100) self.assertEqual(image.regions[0].region, "us-east") self.assertEqual(image.regions[0].status, "available") + self.assertEqual(image.is_shared, False) + self.assertIsNone(image.image_sharing) def test_image_create_upload(self): """ @@ -95,7 +97,7 @@ def test_image_upload(self): Test that an image can be uploaded. """ - def put_mock(url: str, data: BinaryIO = None, **kwargs): + def put_mock(url: str, data: Optional[BinaryIO] = None, **kwargs): self.assertEqual(url, "https://linode.com/") self.assertEqual(data.read(), TEST_IMAGE_CONTENT) @@ -114,36 +116,6 @@ def put_mock(url: str, data: BinaryIO = None, **kwargs): self.assertEqual(image.tags[0], "test_tag") self.assertEqual(image.tags[1], "test2") - def test_image_create_cloud_init(self): - """ - Test that an image can be created successfully with cloud-init. - """ - - with self.mock_post("images/private/123") as m: - self.client.images.create( - "Test Image", - "us-southeast", - description="very real image upload.", - cloud_init=True, - ) - - self.assertTrue(m.call_data["cloud_init"]) - - def test_image_create_upload_cloud_init(self): - """ - Test that an image upload URL can be created successfully with cloud-init. - """ - - with self.mock_post("images/upload") as m: - self.client.images.create_upload( - "Test Image", - "us-southeast", - description="very real image upload.", - cloud_init=True, - ) - - self.assertTrue(m.call_data["cloud_init"]) - def test_image_replication(self): """ Test that image can be replicated. diff --git a/test/unit/objects/linode_interface_test.py b/test/unit/objects/linode_interface_test.py new file mode 100644 index 000000000..c021334e1 --- /dev/null +++ b/test/unit/objects/linode_interface_test.py @@ -0,0 +1,332 @@ +from datetime import datetime +from test.unit.base import ClientBaseCase + +from linode_api4 import ( + LinodeInterface, + LinodeInterfaceDefaultRouteOptions, + LinodeInterfaceOptions, + LinodeInterfacePublicIPv4AddressOptions, + LinodeInterfacePublicIPv4Options, + LinodeInterfacePublicIPv6Options, + LinodeInterfacePublicIPv6RangeOptions, + LinodeInterfacePublicOptions, + LinodeInterfaceVLANOptions, + LinodeInterfaceVPCIPv4AddressOptions, + LinodeInterfaceVPCIPv4Options, + LinodeInterfaceVPCIPv4RangeOptions, + LinodeInterfaceVPCIPv6SLAACOptions, + LinodeInterfaceVPCOptions, +) + + +def build_interface_options_public(): + return LinodeInterfaceOptions( + firewall_id=123, + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ipv6=True, + ), + public=LinodeInterfacePublicOptions( + ipv4=LinodeInterfacePublicIPv4Options( + addresses=[ + LinodeInterfacePublicIPv4AddressOptions( + address="172.30.0.50", primary=True + ) + ], + ), + ipv6=LinodeInterfacePublicIPv6Options( + ranges=[ + LinodeInterfacePublicIPv6RangeOptions( + range="2600:3c09:e001:59::/64" + ) + ] + ), + ), + ) + + +def build_interface_options_vpc(): + return LinodeInterfaceOptions( + firewall_id=123, + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ), + vpc=LinodeInterfaceVPCOptions( + subnet_id=123, + ipv4=LinodeInterfaceVPCIPv4Options( + addresses=[ + LinodeInterfaceVPCIPv4AddressOptions( + address="192.168.22.3", + primary=True, + nat_1_1_address="any", + ) + ], + ranges=[ + LinodeInterfaceVPCIPv4RangeOptions(range="192.168.22.16/28") + ], + ), + ), + ) + + +def build_interface_options_vlan(): + return LinodeInterfaceOptions( + vlan=LinodeInterfaceVLANOptions( + vlan_label="my_vlan", ipam_address="10.0.0.1/24" + ), + ) + + +class LinodeInterfaceTest(ClientBaseCase): + """ + Tests methods of the LinodeInterface class + """ + + @staticmethod + def assert_linode_124_interface_123(iface: LinodeInterface): + assert iface.id == 123 + + assert isinstance(iface.created, datetime) + assert isinstance(iface.updated, datetime) + + assert iface.default_route.ipv4 + assert iface.default_route.ipv6 + + assert iface.mac_address == "22:00:AB:CD:EF:01" + assert iface.version == 1 + + assert iface.vlan is None + assert iface.vpc is None + + # public.ipv4 assertions + assert iface.public.ipv4.addresses[0].address == "172.30.0.50" + assert iface.public.ipv4.addresses[0].primary + + assert iface.public.ipv4.shared[0].address == "172.30.0.51" + assert iface.public.ipv4.shared[0].linode_id == 125 + + # public.ipv6 assertions + assert iface.public.ipv6.ranges[0].range == "2600:3c09:e001:59::/64" + assert ( + iface.public.ipv6.ranges[0].route_target + == "2600:3c09::ff:feab:cdef" + ) + + assert iface.public.ipv6.ranges[1].range == "2600:3c09:e001:5a::/64" + assert ( + iface.public.ipv6.ranges[1].route_target + == "2600:3c09::ff:feab:cdef" + ) + + assert iface.public.ipv6.shared[0].range == "2600:3c09:e001:2a::/64" + assert iface.public.ipv6.shared[0].route_target is None + + assert iface.public.ipv6.slaac[0].address == "2600:3c09::ff:feab:cdef" + assert iface.public.ipv6.slaac[0].prefix == 64 + + @staticmethod + def assert_linode_124_interface_456(iface: LinodeInterface): + assert iface.id == 456 + + assert isinstance(iface.created, datetime) + assert isinstance(iface.updated, datetime) + + assert iface.default_route.ipv4 + assert not iface.default_route.ipv6 + + assert iface.mac_address == "22:00:AB:CD:EF:01" + assert iface.version == 1 + + assert iface.vlan is None + assert iface.public is None + + # vpc assertions + assert iface.vpc.vpc_id == 123456 + assert iface.vpc.subnet_id == 789 + + assert iface.vpc.ipv4.addresses[0].address == "192.168.22.3" + assert iface.vpc.ipv4.addresses[0].primary + + assert iface.vpc.ipv4.ranges[0].range == "192.168.22.16/28" + assert iface.vpc.ipv4.ranges[1].range == "192.168.22.32/28" + + assert iface.vpc.ipv6.is_public + + assert iface.vpc.ipv6.slaac[0].range == "1234::/64" + assert iface.vpc.ipv6.slaac[0].address == "1234::5678" + + assert iface.vpc.ipv6.ranges[0].range == "4321::/64" + + @staticmethod + def assert_linode_124_interface_789(iface: LinodeInterface): + assert iface.id == 789 + + assert isinstance(iface.created, datetime) + assert isinstance(iface.updated, datetime) + + assert iface.default_route.ipv4 is None + assert iface.default_route.ipv6 is None + + assert iface.mac_address == "22:00:AB:CD:EF:01" + assert iface.version == 1 + + assert iface.public is None + assert iface.vpc is None + + # vlan assertions + assert iface.vlan.vlan_label == "my_vlan" + assert iface.vlan.ipam_address == "10.0.0.1/24" + + def test_get_public(self): + iface = LinodeInterface(self.client, 123, 124) + + self.assert_linode_124_interface_123(iface) + iface.invalidate() + self.assert_linode_124_interface_123(iface) + + def test_get_vpc(self): + iface = LinodeInterface(self.client, 456, 124) + + self.assert_linode_124_interface_456(iface) + iface.invalidate() + self.assert_linode_124_interface_456(iface) + + def test_get_vlan(self): + iface = LinodeInterface(self.client, 789, 124) + + self.assert_linode_124_interface_789(iface) + iface.invalidate() + self.assert_linode_124_interface_789(iface) + + def test_update_public(self): + iface = LinodeInterface(self.client, 123, 124) + + self.assert_linode_124_interface_123(iface) + + iface.default_route.ipv4 = False + iface.default_route.ipv6 = False + + iface.public.ipv4.addresses = [ + LinodeInterfacePublicIPv4AddressOptions( + address="172.30.0.51", + primary=False, + ) + ] + + iface.public.ipv6.ranges = [ + LinodeInterfacePublicIPv6RangeOptions( + range="2600:3c09:e001:58::/64" + ) + ] + + with self.mock_put("/linode/instances/124/interfaces/123") as m: + iface.save() + + assert m.called + + assert m.call_data == { + "default_route": { + "ipv4": False, + "ipv6": False, + }, + "public": { + "ipv4": { + "addresses": [ + { + "address": "172.30.0.51", + "primary": False, + }, + ] + }, + "ipv6": { + "ranges": [ + { + "range": "2600:3c09:e001:58::/64", + } + ] + }, + }, + } + + def test_update_vpc(self): + iface = LinodeInterface(self.client, 456, 124) + + self.assert_linode_124_interface_456(iface) + + iface.default_route.ipv4 = False + + iface.vpc.subnet_id = 456 + + iface.vpc.ipv4.addresses = [ + LinodeInterfaceVPCIPv4AddressOptions( + address="192.168.22.4", primary=False, nat_1_1_address="auto" + ) + ] + + iface.vpc.ipv4.ranges = [ + LinodeInterfaceVPCIPv4RangeOptions( + range="192.168.22.17/28", + ) + ] + + iface.vpc.ipv6.is_public = False + + iface.vpc.ipv6.slaac = [ + LinodeInterfaceVPCIPv6SLAACOptions( + range="1233::/64", + ) + ] + + iface.vpc.ipv6.ranges = [ + LinodeInterfacePublicIPv6RangeOptions(range="9876::/64") + ] + + with self.mock_put("/linode/instances/124/interfaces/456") as m: + iface.save() + + assert m.called + + assert m.call_data == { + "default_route": { + "ipv4": False, + }, + "vpc": { + "subnet_id": 456, + "ipv4": { + "addresses": [ + { + "address": "192.168.22.4", + "primary": False, + "nat_1_1_address": "auto", + }, + ], + "ranges": [{"range": "192.168.22.17/28"}], + }, + "ipv6": { + "is_public": False, + "slaac": [{"range": "1233::/64"}], + "ranges": [{"range": "9876::/64"}], + }, + }, + } + + def test_delete(self): + iface = LinodeInterface(self.client, 123, 124) + + with self.mock_delete() as m: + iface.delete() + assert m.called + + def test_firewalls(self): + iface = LinodeInterface(self.client, 123, 124) + + firewalls = iface.firewalls() + + assert len(firewalls) == 1 + + assert firewalls[0].id == 123 + + # Check a few fields to make sure the Firewall object was populated + assert firewalls[0].label == "firewall123" + assert firewalls[0].rules.inbound[0].action == "ACCEPT" + assert firewalls[0].status == "enabled" diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 700e5d0db..40bbb5069 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -1,15 +1,27 @@ from datetime import datetime from test.unit.base import ClientBaseCase +from test.unit.objects.linode_interface_test import ( + LinodeInterfaceTest, + build_interface_options_public, + build_interface_options_vlan, + build_interface_options_vpc, +) from linode_api4 import ( InstanceDiskEncryptionType, - InstancePlacementGroupAssignment, + InterfaceGeneration, NetworkInterface, ) from linode_api4.objects import ( Config, ConfigInterface, ConfigInterfaceIPv4, + ConfigInterfaceIPv6, + ConfigInterfaceIPv6Options, + ConfigInterfaceIPv6Range, + ConfigInterfaceIPv6RangeOptions, + ConfigInterfaceIPv6SLAAC, + ConfigInterfaceIPv6SLAACOptions, Disk, Image, Instance, @@ -44,6 +56,7 @@ def test_get_linode(self): linode.disk_encryption, InstanceDiskEncryptionType.disabled ) self.assertEqual(linode.lke_cluster_id, None) + self.assertEqual(linode.maintenance_policy, "linode/migrate") json = linode._raw_json self.assertIsNotNone(json) @@ -157,6 +170,7 @@ def test_update_linode(self): linode.label = "NewLinodeLabel" linode.group = "new_group" + linode.maintenance_policy = "linode/power_off_on" linode.save() self.assertEqual(m.call_url, "/linode/instances/123") @@ -178,6 +192,7 @@ def test_update_linode(self): "group": "new_group", "tags": ["something"], "watchdog_enabled": True, + "maintenance_policy": "linode/power_off_on", }, ) @@ -284,6 +299,19 @@ def test_firewalls(self): self.assertEqual(m.call_url, "/linode/instances/123/firewalls") self.assertEqual(len(result), 1) + def test_apply_firewalls(self): + """ + Tests that you can submit a correct apply firewalls api request + """ + linode = Instance(self.client, 123) + + with self.mock_post({}) as m: + result = linode.apply_firewalls() + self.assertEqual( + m.call_url, "/linode/instances/123/firewalls/apply" + ) + self.assertEqual(result, True) + def test_volumes(self): """ Tests that you can submit a correct volumes api request @@ -413,7 +441,7 @@ def test_create_disk(self): 1234, label="test", authorized_users=["test"], - image="linode/debian10", + image="linode/debian12", ) self.assertEqual(m.call_url, "/linode/instances/123/disks") self.assertEqual( @@ -422,7 +450,7 @@ def test_create_disk(self): "size": 1234, "label": "test", "root_pass": gen_pass, - "image": "linode/debian10", + "image": "linode/debian12", "authorized_users": ["test"], "read_only": False, }, @@ -431,74 +459,6 @@ def test_create_disk(self): assert disk.id == 12345 assert disk.disk_encryption == InstanceDiskEncryptionType.disabled - def test_instance_create_with_user_data(self): - """ - Tests that the metadata field is populated on Linode create. - """ - - with self.mock_post("linode/instances/123") as m: - self.client.linode.instance_create( - "g6-nanode-1", - "us-southeast", - metadata=self.client.linode.build_instance_metadata( - user_data="cool" - ), - ) - - self.assertEqual( - m.call_data, - { - "region": "us-southeast", - "type": "g6-nanode-1", - "metadata": {"user_data": "Y29vbA=="}, - }, - ) - - def test_instance_create_with_interfaces(self): - """ - Tests that user can pass a list of interfaces on Linode create. - """ - interfaces = [ - {"purpose": "public"}, - ConfigInterface( - purpose="vlan", label="cool-vlan", ipam_address="10.0.0.4/32" - ), - ] - with self.mock_post("linode/instances/123") as m: - self.client.linode.instance_create( - "us-southeast", - "g6-nanode-1", - interfaces=interfaces, - ) - - self.assertEqual( - m.call_data["interfaces"], - [ - {"purpose": "public"}, - { - "purpose": "vlan", - "label": "cool-vlan", - "ipam_address": "10.0.0.4/32", - }, - ], - ) - - def test_build_instance_metadata(self): - """ - Tests that the metadata field is built correctly. - """ - self.assertEqual( - self.client.linode.build_instance_metadata(user_data="cool"), - {"user_data": "Y29vbA=="}, - ) - - self.assertEqual( - self.client.linode.build_instance_metadata( - user_data="cool", encode_user_data=False - ), - {"user_data": "cool"}, - ) - def test_get_placement_group(self): """ Tests that you can get the placement group for a Linode @@ -522,24 +482,164 @@ def test_get_placement_group(self): assert pg.label == "test" assert pg.placement_group_type == "anti_affinity:local" - def test_create_with_placement_group(self): - """ - Tests that you can create a Linode with a Placement Group - """ + def test_get_interfaces(self): + # Local import to avoid circular dependency + from linode_interface_test import ( # pylint: disable=import-outside-toplevel + LinodeInterfaceTest, + ) - with self.mock_post("linode/instances/123") as m: - self.client.linode.instance_create( - "g6-nanode-1", - "eu-west", - placement_group=InstancePlacementGroupAssignment( - id=123, - compliant_only=True, - ), - ) + instance = Instance(self.client, 124) - self.assertEqual( - m.call_data["placement_group"], {"id": 123, "compliant_only": True} + assert instance.interface_generation == InterfaceGeneration.LINODE + + interfaces = instance.linode_interfaces + + LinodeInterfaceTest.assert_linode_124_interface_123( + next(iface for iface in interfaces if iface.id == 123) + ) + + LinodeInterfaceTest.assert_linode_124_interface_456( + next(iface for iface in interfaces if iface.id == 456) + ) + + LinodeInterfaceTest.assert_linode_124_interface_789( + next(iface for iface in interfaces if iface.id == 789) + ) + + def test_get_interfaces_settings(self): + instance = Instance(self.client, 124) + iface_settings = instance.interfaces_settings + + assert iface_settings.network_helper + + assert iface_settings.default_route.ipv4_interface_id == 123 + assert iface_settings.default_route.ipv4_eligible_interface_ids == [ + 123, + 456, + 789, + ] + + assert iface_settings.default_route.ipv6_interface_id == 456 + assert iface_settings.default_route.ipv6_eligible_interface_ids == [ + 123, + 456, + ] + + def test_update_interfaces_settings(self): + instance = Instance(self.client, 124) + iface_settings = instance.interfaces_settings + + iface_settings.network_helper = False + iface_settings.default_route.ipv4_interface_id = 456 + iface_settings.default_route.ipv6_interface_id = 123 + + with self.mock_put("/linode/instances/124/interfaces/settings") as m: + iface_settings.save() + + assert m.call_data == { + "network_helper": False, + "default_route": { + "ipv4_interface_id": 456, + "ipv6_interface_id": 123, + }, + } + + def test_upgrade_interfaces(self): + # Local import to avoid circular dependency + from linode_interface_test import ( # pylint: disable=import-outside-toplevel + LinodeInterfaceTest, + ) + + instance = Instance(self.client, 124) + + with self.mock_post("/linode/instances/124/upgrade-interfaces") as m: + result = instance.upgrade_interfaces(123) + + assert m.called + assert m.call_data == {"config_id": 123, "dry_run": False} + + assert result.config_id == 123 + assert result.dry_run + + LinodeInterfaceTest.assert_linode_124_interface_123( + result.interfaces[0] ) + LinodeInterfaceTest.assert_linode_124_interface_456( + result.interfaces[1] + ) + LinodeInterfaceTest.assert_linode_124_interface_789( + result.interfaces[2] + ) + + def test_upgrade_interfaces_dry(self): + instance = Instance(self.client, 124) + + with self.mock_post("/linode/instances/124/upgrade-interfaces") as m: + result = instance.upgrade_interfaces(123, dry_run=True) + + assert m.called + assert m.call_data == { + "config_id": 123, + "dry_run": True, + } + + assert result.config_id == 123 + assert result.dry_run + + # We don't use the assertion helpers here because dry runs return + # a MappedObject. + assert result.interfaces[0].id == 123 + assert result.interfaces[0].public is not None + + assert result.interfaces[1].id == 456 + assert result.interfaces[1].vpc is not None + + assert result.interfaces[2].id == 789 + assert result.interfaces[2].vlan is not None + + def test_create_interface_public(self): + instance = Instance(self.client, 124) + + iface = build_interface_options_public() + + with self.mock_post("/linode/instances/124/interfaces/123") as m: + result = instance.interface_create(**vars(iface)) + + assert m.call_data == { + "firewall_id": iface.firewall_id, + "default_route": iface.default_route._serialize(), + "public": iface.public._serialize(), + } + + LinodeInterfaceTest.assert_linode_124_interface_123(result) + + def test_create_interface_vpc(self): + instance = Instance(self.client, 124) + + iface = build_interface_options_vpc() + + with self.mock_post("/linode/instances/124/interfaces/456") as m: + result = instance.interface_create(**vars(iface)) + + assert m.call_data == { + "firewall_id": iface.firewall_id, + "default_route": iface.default_route._serialize(), + "vpc": iface.vpc._serialize(), + } + + LinodeInterfaceTest.assert_linode_124_interface_456(result) + + def test_create_interface_vlan(self): + instance = Instance(self.client, 124) + + iface = build_interface_options_vlan() + + with self.mock_post("/linode/instances/124/interfaces/789") as m: + result = instance.interface_create(**vars(iface)) + + assert m.call_data == {"vlan": iface.vlan._serialize()} + + LinodeInterfaceTest.assert_linode_124_interface_789(result) class DiskTest(ClientBaseCase): @@ -581,15 +681,62 @@ def test_update_interfaces(self): new_interfaces = [ {"purpose": "public", "primary": True}, ConfigInterface("vlan", label="cool-vlan"), + ConfigInterface( + "vpc", + vpc_id=18881, + subnet_id=123, + ipv4=ConfigInterfaceIPv4(vpc="10.0.0.4", nat_1_1="any"), + ipv6=ConfigInterfaceIPv6( + slaac=[ + ConfigInterfaceIPv6SLAAC( + range="1234::5678/64", address="1234::5678" + ) + ], + ranges=[ + ConfigInterfaceIPv6Range(range="1234::5678/64") + ], + is_public=True, + ), + ), ] - expected_body = [new_interfaces[0], new_interfaces[1]._serialize()] config.interfaces = new_interfaces config.save() - self.assertEqual(m.call_url, "/linode/instances/123/configs/456789") - self.assertEqual(m.call_data.get("interfaces"), expected_body) + assert m.call_url == "/linode/instances/123/configs/456789" + assert m.call_data.get("interfaces") == [ + { + "purpose": "public", + "primary": True, + }, + { + "purpose": "vlan", + "label": "cool-vlan", + }, + { + "purpose": "vpc", + "subnet_id": 123, + "ipv4": { + "vpc": "10.0.0.4", + "nat_1_1": "any", + }, + "ipv6": { + "slaac": [ + { + "range": "1234::5678/64", + # NOTE: Address is read-only so it shouldn't be specified here + } + ], + "ranges": [ + { + "range": "1234::5678/64", + } + ], + "is_public": True, + }, + }, + ] def test_get_config(self): json = self.client.get("/linode/instances/123/configs/456789") @@ -619,6 +766,24 @@ def test_interface_ipv4(self): self.assertEqual(ipv4.vpc, "10.0.0.1") self.assertEqual(ipv4.nat_1_1, "any") + def test_interface_ipv6(self): + json = { + "slaac": [{"range": "1234::5678/64", "address": "1234::5678"}], + "ranges": [{"range": "1234::5678/64"}], + "is_public": True, + } + + ipv6 = ConfigInterfaceIPv6.from_json(json) + + assert len(ipv6.slaac) == 1 + assert ipv6.slaac[0].range == "1234::5678/64" + assert ipv6.slaac[0].address == "1234::5678" + + assert len(ipv6.ranges) == 1 + assert ipv6.ranges[0].range == "1234::5678/64" + + assert ipv6.is_public + def test_config_devices_unwrap(self): """ Tests that config devices can be successfully converted to a dict. @@ -650,23 +815,6 @@ def test_get_stackscript(self): class TypeTest(ClientBaseCase): - def test_get_types(self): - """ - Tests that Linode types can be returned - """ - types = self.client.linode.types() - - self.assertEqual(len(types), 4) - for t in types: - self.assertTrue(t._populated) - self.assertIsNotNone(t.id) - self.assertIsNotNone(t.label) - self.assertIsNotNone(t.disk) - self.assertIsNotNone(t.type_class) - self.assertIsNotNone(t.gpus) - self.assertIsNone(t.successor) - self.assertIsNotNone(t.region_prices) - self.assertIsNotNone(t.addons.backups.region_prices) def test_get_type_by_id(self): """ @@ -839,6 +987,11 @@ def test_create_interface_vpc(self): subnet=VPCSubnet(self.client, 789, 123456), primary=True, ipv4=ConfigInterfaceIPv4(vpc="10.0.0.4", nat_1_1="any"), + ipv6=ConfigInterfaceIPv6Options( + slaac=[ConfigInterfaceIPv6SLAACOptions(range="auto")], + ranges=[ConfigInterfaceIPv6RangeOptions(range="auto")], + is_public=True, + ), ip_ranges=["10.0.0.0/24"], ) @@ -852,6 +1005,11 @@ def test_create_interface_vpc(self): "primary": True, "subnet_id": 789, "ipv4": {"vpc": "10.0.0.4", "nat_1_1": "any"}, + "ipv6": { + "slaac": [{"range": "auto"}], + "ranges": [{"range": "auto"}], + "is_public": True, + }, "ip_ranges": ["10.0.0.0/24"], } @@ -860,8 +1018,19 @@ def test_create_interface_vpc(self): assert interface.primary assert interface.vpc.id == 123456 assert interface.subnet.id == 789 + assert interface.ipv4.vpc == "10.0.0.2" assert interface.ipv4.nat_1_1 == "any" + + assert len(interface.ipv6.slaac) == 1 + assert interface.ipv6.slaac[0].range == "1234::5678/64" + assert interface.ipv6.slaac[0].address == "1234::5678" + + assert len(interface.ipv6.ranges) == 1 + assert interface.ipv6.ranges[0].range == "1234::5678/64" + + assert interface.ipv6.is_public + assert interface.ip_ranges == ["10.0.0.0/24"] def test_update(self): @@ -869,6 +1038,7 @@ def test_update(self): interface._api_get() interface.ipv4.vpc = "10.0.0.3" + interface.ipv6.is_public = False interface.primary = False interface.ip_ranges = ["10.0.0.2/32"] @@ -886,6 +1056,11 @@ def test_update(self): assert m.call_data == { "primary": False, "ipv4": {"vpc": "10.0.0.3", "nat_1_1": "any"}, + "ipv6": { + "slaac": [{"range": "1234::5678/64"}], + "ranges": [{"range": "1234::5678/64"}], + "is_public": False, + }, "ip_ranges": ["10.0.0.2/32"], } @@ -906,8 +1081,17 @@ def test_get_vpc(self): self.assertEqual(interface.purpose, "vpc") self.assertEqual(interface.vpc.id, 123456) self.assertEqual(interface.subnet.id, 789) + self.assertEqual(interface.ipv4.vpc, "10.0.0.2") self.assertEqual(interface.ipv4.nat_1_1, "any") + + self.assertEqual(len(interface.ipv6.slaac), 1) + self.assertEqual(interface.ipv6.slaac[0].range, "1234::5678/64") + self.assertEqual(interface.ipv6.slaac[0].address, "1234::5678") + self.assertEqual(len(interface.ipv6.ranges), 1) + self.assertEqual(interface.ipv6.ranges[0].range, "1234::5678/64") + self.assertEqual(interface.ipv6.is_public, True) + self.assertEqual(interface.ip_ranges, ["10.0.0.0/24"]) self.assertEqual(interface.active, True) diff --git a/test/unit/objects/lke_test.py b/test/unit/objects/lke_test.py index 100f36487..91f9ed3fe 100644 --- a/test/unit/objects/lke_test.py +++ b/test/unit/objects/lke_test.py @@ -2,7 +2,7 @@ from test.unit.base import ClientBaseCase from unittest.mock import MagicMock -from linode_api4 import InstanceDiskEncryptionType +from linode_api4 import InstanceDiskEncryptionType, TieredKubeVersion from linode_api4.objects import ( LKECluster, LKEClusterControlPlaneACLAddressesOptions, @@ -39,6 +39,7 @@ def test_get_cluster(self): self.assertEqual(cluster.region.id, "ap-west") self.assertEqual(cluster.k8s_version.id, "1.19") self.assertTrue(cluster.control_plane.high_availability) + self.assertTrue(cluster.apl_enabled) def test_get_pool(self): """ @@ -50,6 +51,8 @@ def test_get_pool(self): assert pool.id == 456 assert pool.cluster_id == 18881 assert pool.type.id == "g6-standard-4" + assert pool.label == "example-node-pool" + assert pool.firewall_id == 456 assert pool.disk_encryption == InstanceDiskEncryptionType.enabled assert pool.disks is not None @@ -161,41 +164,12 @@ def test_load_node_pool(self): self.assertEqual(pool.id, 456) self.assertEqual(pool.cluster_id, 18881) self.assertEqual(pool.type.id, "g6-standard-4") + self.assertEqual(pool.label, "example-node-pool") self.assertIsNotNone(pool.disks) self.assertIsNotNone(pool.nodes) self.assertIsNotNone(pool.autoscaler) self.assertIsNotNone(pool.tags) - def test_cluster_create_with_acl(self): - """ - Tests that an LKE cluster can be created with a control plane ACL configuration. - """ - - with self.mock_post("lke/clusters") as m: - self.client.lke.cluster_create( - "us-mia", - "test-acl-cluster", - [self.client.lke.node_pool("g6-nanode-1", 3)], - "1.29", - control_plane=LKEClusterControlPlaneOptions( - acl=LKEClusterControlPlaneACLOptions( - enabled=True, - addresses=LKEClusterControlPlaneACLAddressesOptions( - ipv4=["10.0.0.1/32"], ipv6=["1234::5678"] - ), - ) - ), - ) - - assert "high_availability" not in m.call_data["control_plane"] - assert m.call_data["control_plane"]["acl"]["enabled"] - assert m.call_data["control_plane"]["acl"]["addresses"]["ipv4"] == [ - "10.0.0.1/32" - ] - assert m.call_data["control_plane"]["acl"]["addresses"]["ipv6"] == [ - "1234::5678" - ] - def test_cluster_get_acl(self): """ Tests that an LKE cluster can be created with a control plane ACL configuration. @@ -280,6 +254,8 @@ def test_lke_node_pool_update(self): pool.tags = ["foobar"] pool.count = 5 + pool.label = "testing-label" + pool.firewall_id = 852 pool.autoscaler = { "enabled": True, "min": 2, @@ -303,9 +279,11 @@ def test_lke_node_pool_update(self): "min": 2, "max": 10, }, + "label": "testing-label", "labels": { "updated-key": "updated-value", }, + "firewall_id": 852, "taints": [ { "key": "updated-key", @@ -324,6 +302,7 @@ def test_cluster_create_with_labels_and_taints(self): self.client.lke.cluster_create( "us-mia", "test-acl-cluster", + "1.29", [ self.client.lke.node_pool( "g6-nanode-1", @@ -339,7 +318,6 @@ def test_cluster_create_with_labels_and_taints(self): ], ) ], - "1.29", ) assert m.call_data["node_pools"][0] == { @@ -352,6 +330,40 @@ def test_cluster_create_with_labels_and_taints(self): ], } + def test_cluster_create_with_apl(self): + """ + Tests that an LKE cluster can be created with APL enabled. + """ + + with self.mock_post("lke/clusters") as m: + cluster = self.client.lke.cluster_create( + "us-mia", + "test-aapl-cluster", + "1.29", + [ + self.client.lke.node_pool( + "g6-dedicated-4", + 3, + ) + ], + apl_enabled=True, + control_plane=LKEClusterControlPlaneOptions( + high_availability=True, + ), + ) + + assert m.call_data["apl_enabled"] == True + assert m.call_data["control_plane"]["high_availability"] == True + + assert ( + cluster.apl_console_url == "https://console.lke18881.akamai-apl.net" + ) + + assert ( + cluster.apl_health_check_url + == "https://auth.lke18881.akamai-apl.net/ready" + ) + def test_populate_with_taints(self): """ Tests that LKENodePool correctly handles a list of LKENodePoolTaint and Dict objects. @@ -493,3 +505,63 @@ def test_populate_with_mixed_types(self): assert self.pool.nodes[0].id == "node7" assert self.pool.nodes[1].id == "node8" assert self.pool.nodes[2].id == "node9" + + def test_cluster_create_acl_null_addresses(self): + with self.mock_post("lke/clusters") as m: + self.client.lke.cluster_create( + region="us-mia", + label="foobar", + kube_version="1.32", + node_pools=[self.client.lke.node_pool("g6-standard-1", 3)], + control_plane={ + "acl": { + "enabled": False, + "addresses": None, + } + }, + ) + + # Addresses should not be included in the API request if it's null + # See: TPT-3489 + assert m.call_data["control_plane"] == { + "acl": { + "enabled": False, + } + } + + def test_cluster_update_acl_null_addresses(self): + cluster = LKECluster(self.client, 18881) + + with self.mock_put("lke/clusters/18881/control_plane_acl") as m: + cluster.control_plane_acl_update( + { + "enabled": True, + "addresses": None, + } + ) + + # Addresses should not be included in the API request if it's null + # See: TPT-3489 + assert m.call_data == {"acl": {"enabled": True}} + + def test_cluster_enterprise(self): + cluster = LKECluster(self.client, 18882) + + assert cluster.tier == "enterprise" + assert cluster.k8s_version.id == "1.31.1+lke1" + + pool = LKENodePool(self.client, 789, 18882) + assert pool.k8s_version == "1.31.1+lke1" + assert pool.update_strategy == "rolling_update" + assert pool.label == "enterprise-node-pool" + assert pool.firewall_id == 789 + + def test_lke_tiered_version(self): + version = TieredKubeVersion(self.client, "1.32", "standard") + + assert version.id == "1.32" + + # Ensure the version is properly refreshed + version.invalidate() + + assert version.id == "1.32" diff --git a/test/unit/objects/lock_test.py b/test/unit/objects/lock_test.py new file mode 100644 index 000000000..ce630d0b6 --- /dev/null +++ b/test/unit/objects/lock_test.py @@ -0,0 +1,34 @@ +from test.unit.base import ClientBaseCase + +from linode_api4.objects.lock import Lock, LockEntity + + +class LockTest(ClientBaseCase): + """ + Tests methods of the Lock class + """ + + def test_get_lock(self): + """ + Tests that a lock is loaded correctly by ID + """ + lock = Lock(self.client, 1) + + self.assertEqual(lock.id, 1) + self.assertEqual(lock.lock_type, "cannot_delete") + self.assertIsInstance(lock.entity, LockEntity) + self.assertEqual(lock.entity.id, 123) + self.assertEqual(lock.entity.type, "linode") + self.assertEqual(lock.entity.label, "test-linode") + self.assertEqual(lock.entity.url, "/v4/linode/instances/123") + + def test_delete_lock(self): + """ + Tests that a lock can be deleted using the Lock object's delete method + """ + lock = Lock(self.client, 1) + + with self.mock_delete() as m: + lock.delete() + + self.assertEqual(m.call_url, "/locks/1") diff --git a/test/unit/objects/monitor_test.py b/test/unit/objects/monitor_test.py new file mode 100644 index 000000000..329a09063 --- /dev/null +++ b/test/unit/objects/monitor_test.py @@ -0,0 +1,148 @@ +import datetime +from test.unit.base import ClientBaseCase + +from linode_api4.objects import MonitorDashboard, MonitorService + + +class MonitorTest(ClientBaseCase): + """ + Tests the methods of MonitorServiceSupported class + """ + + def test_supported_services(self): + """ + Test the services supported by monitor + """ + service = self.client.monitor.services() + self.assertEqual(len(service), 1) + self.assertEqual(service[0].label, "Databases") + self.assertEqual(service[0].service_type, "dbaas") + + def test_dashboard_by_ID(self): + """ + Test the dashboard by ID API + """ + dashboard = self.client.load(MonitorDashboard, 1) + self.assertEqual(dashboard.type, "standard") + self.assertEqual( + dashboard.created, datetime.datetime(2024, 10, 10, 5, 1, 58) + ) + self.assertEqual(dashboard.id, 1) + self.assertEqual(dashboard.label, "Resource Usage") + self.assertEqual(dashboard.service_type, "dbaas") + self.assertEqual( + dashboard.updated, datetime.datetime(2024, 10, 10, 5, 1, 58) + ) + self.assertEqual(dashboard.widgets[0].aggregate_function, "sum") + self.assertEqual(dashboard.widgets[0].chart_type, "area") + self.assertEqual(dashboard.widgets[0].color, "default") + self.assertEqual(dashboard.widgets[0].label, "CPU Usage") + self.assertEqual(dashboard.widgets[0].metric, "cpu_usage") + self.assertEqual(dashboard.widgets[0].size, 12) + self.assertEqual(dashboard.widgets[0].unit, "%") + self.assertEqual(dashboard.widgets[0].y_label, "cpu_usage") + self.assertEqual(dashboard.widgets[0].group_by, ["entity_id"]) + self.assertIsNone(dashboard.widgets[0].filters) + + def test_dashboard_by_service_type(self): + dashboards = self.client.monitor.dashboards(service_type="dbaas") + self.assertEqual(dashboards[0].type, "standard") + self.assertEqual( + dashboards[0].created, datetime.datetime(2024, 10, 10, 5, 1, 58) + ) + self.assertEqual(dashboards[0].id, 1) + self.assertEqual(dashboards[0].label, "Resource Usage") + self.assertEqual(dashboards[0].service_type, "dbaas") + self.assertEqual( + dashboards[0].updated, datetime.datetime(2024, 10, 10, 5, 1, 58) + ) + self.assertEqual(dashboards[0].widgets[0].aggregate_function, "sum") + self.assertEqual(dashboards[0].widgets[0].chart_type, "area") + self.assertEqual(dashboards[0].widgets[0].color, "default") + self.assertEqual(dashboards[0].widgets[0].label, "CPU Usage") + self.assertEqual(dashboards[0].widgets[0].metric, "cpu_usage") + self.assertEqual(dashboards[0].widgets[0].size, 12) + self.assertEqual(dashboards[0].widgets[0].unit, "%") + self.assertEqual(dashboards[0].widgets[0].y_label, "cpu_usage") + self.assertEqual(dashboards[0].widgets[0].group_by, ["entity_id"]) + self.assertIsNone(dashboards[0].widgets[0].filters) + + # Test the second widget which has filters + self.assertEqual(dashboards[0].widgets[1].label, "Memory Usage") + self.assertEqual(dashboards[0].widgets[1].group_by, ["entity_id"]) + self.assertIsNotNone(dashboards[0].widgets[1].filters) + self.assertEqual(len(dashboards[0].widgets[1].filters), 1) + self.assertEqual( + dashboards[0].widgets[1].filters[0].dimension_label, "pattern" + ) + self.assertEqual(dashboards[0].widgets[1].filters[0].operator, "in") + self.assertEqual( + dashboards[0].widgets[1].filters[0].value, "publicout,privateout" + ) + + def test_get_all_dashboards(self): + dashboards = self.client.monitor.dashboards() + self.assertEqual(dashboards[0].type, "standard") + self.assertEqual( + dashboards[0].created, datetime.datetime(2024, 10, 10, 5, 1, 58) + ) + self.assertEqual(dashboards[0].id, 1) + self.assertEqual(dashboards[0].label, "Resource Usage") + self.assertEqual(dashboards[0].service_type, "dbaas") + self.assertEqual( + dashboards[0].updated, datetime.datetime(2024, 10, 10, 5, 1, 58) + ) + self.assertEqual(dashboards[0].widgets[0].aggregate_function, "sum") + self.assertEqual(dashboards[0].widgets[0].chart_type, "area") + self.assertEqual(dashboards[0].widgets[0].color, "default") + self.assertEqual(dashboards[0].widgets[0].label, "CPU Usage") + self.assertEqual(dashboards[0].widgets[0].metric, "cpu_usage") + self.assertEqual(dashboards[0].widgets[0].size, 12) + self.assertEqual(dashboards[0].widgets[0].unit, "%") + self.assertEqual(dashboards[0].widgets[0].y_label, "cpu_usage") + self.assertEqual(dashboards[0].widgets[0].group_by, ["entity_id"]) + self.assertIsNone(dashboards[0].widgets[0].filters) + + def test_specific_service_details(self): + data = self.client.load(MonitorService, "dbaas") + self.assertEqual(data.label, "Databases") + self.assertEqual(data.service_type, "dbaas") + + # Test alert configuration + self.assertIsNotNone(data.alert) + self.assertEqual(data.alert.polling_interval_seconds, [300]) + self.assertEqual(data.alert.evaluation_period_seconds, [300]) + self.assertEqual(data.alert.scope, ["entity"]) + + def test_metric_definitions(self): + + metrics = self.client.monitor.metric_definitions(service_type="dbaas") + self.assertEqual( + metrics[0].available_aggregate_functions, + ["max", "avg", "min", "sum"], + ) + self.assertTrue(metrics[0].is_alertable) + self.assertEqual(metrics[0].label, "CPU Usage") + self.assertEqual(metrics[0].metric, "cpu_usage") + self.assertEqual(metrics[0].metric_type, "gauge") + self.assertEqual(metrics[0].scrape_interval, "60s") + self.assertEqual(metrics[0].unit, "percent") + self.assertEqual(metrics[0].dimensions[0].dimension_label, "node_type") + self.assertEqual(metrics[0].dimensions[0].label, "Node Type") + self.assertEqual( + metrics[0].dimensions[0].values, ["primary", "secondary"] + ) + + def test_create_token(self): + + with self.mock_post("/monitor/services/dbaas/token") as m: + self.client.monitor.create_token( + service_type="dbaas", entity_ids=[189690, 188020] + ) + self.assertEqual(m.return_dct["token"], "abcdefhjigkfghh") + + with self.mock_post("/monitor/services/linode/token") as m: + self.client.monitor.create_token( + service_type="linode", entity_ids=["compute-instance-1"] + ) + self.assertEqual(m.return_dct["token"], "abcdefhjigkfghh") diff --git a/test/unit/objects/networking_test.py b/test/unit/objects/networking_test.py index dabf1ee2b..cd2e1b15e 100644 --- a/test/unit/objects/networking_test.py +++ b/test/unit/objects/networking_test.py @@ -1,6 +1,6 @@ from test.unit.base import ClientBaseCase -from linode_api4 import ExplicitNullValue +from linode_api4 import VLAN, ExplicitNullValue, Instance, Region from linode_api4.objects import Firewall, IPAddress, IPv6Range @@ -47,6 +47,54 @@ def test_get_rules(self): self.assertEqual(result["inbound_policy"], "DROP") self.assertEqual(result["outbound_policy"], "DROP") + def test_get_rule_versions(self): + """ + Tests that you can submit a correct firewall rule versions view api request. + """ + + firewall = Firewall(self.client, 123) + + with self.mock_get("/networking/firewalls/123/history") as m: + result = firewall.rule_versions + self.assertEqual(m.call_url, "/networking/firewalls/123/history") + self.assertEqual(result["data"][0]["status"], "enabled") + self.assertEqual(result["data"][0]["rules"]["version"], 1) + self.assertEqual(result["data"][0]["status"], "enabled") + self.assertEqual(result["data"][1]["rules"]["version"], 2) + + def test_get_rule_version(self): + """ + Tests that you can submit a correct firewall rule version view api request. + """ + + firewall = Firewall(self.client, 123) + + with self.mock_get("/networking/firewalls/123/history/rules/2") as m: + result = firewall.get_rule_version(2) + self.assertEqual( + m.call_url, "/networking/firewalls/123/history/rules/2" + ) + self.assertEqual(result["inbound"][0]["action"], "ACCEPT") + self.assertEqual( + result["inbound"][0]["addresses"]["ipv4"][0], "0.0.0.0/0" + ) + self.assertEqual( + result["inbound"][0]["addresses"]["ipv6"][0], "ff00::/8" + ) + self.assertEqual( + result["inbound"][0]["description"], + "A really cool firewall rule.", + ) + self.assertEqual( + result["inbound"][0]["label"], "really-cool-firewall-rule" + ) + self.assertEqual(result["inbound"][0]["ports"], "80") + self.assertEqual(result["inbound"][0]["protocol"], "TCP") + self.assertEqual(result["outbound"], []) + self.assertEqual(result["inbound_policy"], "ACCEPT") + self.assertEqual(result["outbound_policy"], "DROP") + self.assertEqual(result["version"], 2) + def test_rdns_reset(self): """ Tests that the RDNS of an IP and be reset using an explicit null value. @@ -73,13 +121,53 @@ def test_rdns_reset(self): self.assertEqual(m.call_data_raw, '{"rdns": null}') - def test_vpc_nat_1_1(self): + def test_get_ip(self): """ - Tests that the vpc_nat_1_1 of an IP can be retrieved. + Tests retrieving comprehensive IP address information, including all relevant properties. """ ip = IPAddress(self.client, "127.0.0.1") - self.assertEqual(ip.vpc_nat_1_1.vpc_id, 242) - self.assertEqual(ip.vpc_nat_1_1.subnet_id, 194) - self.assertEqual(ip.vpc_nat_1_1.address, "139.144.244.36") + def __validate_ip(_ip: IPAddress): + assert _ip.address == "127.0.0.1" + assert _ip.gateway == "127.0.0.1" + assert _ip.linode_id == 123 + assert _ip.interface_id == 456 + assert _ip.prefix == 24 + assert _ip.public + assert _ip.rdns == "test.example.org" + assert _ip.region.id == "us-east" + assert _ip.subnet_mask == "255.255.255.0" + assert _ip.type == "ipv4" + assert _ip.vpc_nat_1_1.vpc_id == 242 + assert _ip.vpc_nat_1_1.subnet_id == 194 + assert _ip.vpc_nat_1_1.address == "139.144.244.36" + + __validate_ip(ip) + ip.invalidate() + __validate_ip(ip) + + def test_delete_ip(self): + """ + Tests that deleting an IP creates the correct api request + """ + with self.mock_delete() as m: + ip = IPAddress(self.client, "127.0.0.1") + ip.to(Instance(self.client, 123)) + ip.delete() + + self.assertEqual(m.call_url, "/linode/instances/123/ips/127.0.0.1") + + def test_delete_vlan(self): + """ + Tests that deleting a VLAN creates the correct api request + """ + with self.mock_delete() as m: + self.client.networking.delete_vlan( + VLAN(self.client, "vlan-test"), + Region(self.client, "us-southeast"), + ) + + self.assertEqual( + m.call_url, "/networking/vlans/us-southeast/vlan-test" + ) diff --git a/test/unit/objects/nodebalancers_test.py b/test/unit/objects/nodebalancers_test.py index 05f0ad7de..c02b40ea3 100644 --- a/test/unit/objects/nodebalancers_test.py +++ b/test/unit/objects/nodebalancers_test.py @@ -42,6 +42,23 @@ def test_get_config(self): self.assertEqual(config.ssl_fingerprint, "") self.assertEqual(config.proxy_protocol, "none") + config_udp = NodeBalancerConfig(self.client, 65431, 123456) + self.assertEqual(config_udp.protocol, "udp") + self.assertEqual(config_udp.udp_check_port, 12345) + + def test_update_config_udp(self): + """ + Tests that a config with a protocol of udp can be updated and that cipher suite is properly excluded in save() + """ + with self.mock_put("nodebalancers/123456/configs/65431") as m: + config = self.client.load(NodeBalancerConfig, 65431, 123456) + config.udp_check_port = 54321 + config.save() + + self.assertEqual(m.call_url, "/nodebalancers/123456/configs/65431") + self.assertEqual(m.call_data["udp_check_port"], 54321) + self.assertNotIn("cipher_suite", m.call_data) + class NodeBalancerNodeTest(ClientBaseCase): """ @@ -66,6 +83,9 @@ def test_get_node(self): self.assertEqual(node.config_id, 65432) self.assertEqual(node.nodebalancer_id, 123456) + node_udp = NodeBalancerNode(self.client, 12345, (65432, 123456)) + self.assertEqual(node_udp.mode, "none") + def test_create_node(self): """ Tests that a node can be created @@ -155,6 +175,24 @@ def test_update(self): }, ) + def test_locks_not_in_put(self): + """ + Test that locks are not included in PUT request when updating a NodeBalancer. + Locks are managed through the separate /v4/locks endpoint. + """ + nb = NodeBalancer(self.client, 123456) + # Access locks to ensure it's loaded + self.assertEqual(nb.locks, ["cannot_delete_with_subresources"]) + + nb.label = "new-label" + + with self.mock_put("nodebalancers/123456") as m: + nb.save() + self.assertEqual(m.call_url, "/nodebalancers/123456") + # Verify locks is NOT in the PUT data + self.assertNotIn("locks", m.call_data) + self.assertEqual(m.call_data["label"], "new-label") + def test_firewalls(self): """ Test that you can get the firewalls for the requested NodeBalancer. diff --git a/test/unit/objects/object_storage_test.py b/test/unit/objects/object_storage_test.py index 95d781a84..b7ff7e49c 100644 --- a/test/unit/objects/object_storage_test.py +++ b/test/unit/objects/object_storage_test.py @@ -1,10 +1,12 @@ from datetime import datetime from test.unit.base import ClientBaseCase +from linode_api4 import ObjectStorageEndpointType from linode_api4.objects import ( ObjectStorageACL, ObjectStorageBucket, ObjectStorageCluster, + ObjectStorageQuota, ) @@ -35,6 +37,14 @@ def test_object_storage_bucket_api_get(self): ) self.assertEqual(object_storage_bucket.objects, 4) self.assertEqual(object_storage_bucket.size, 188318981) + self.assertEqual( + object_storage_bucket.endpoint_type, + ObjectStorageEndpointType.E1, + ) + self.assertEqual( + object_storage_bucket.s3_endpoint, + "us-east-12.linodeobjects.com", + ) self.assertEqual(m.call_url, object_storage_bucket_api_get_url) def test_object_storage_bucket_delete(self): @@ -48,6 +58,22 @@ def test_object_storage_bucket_delete(self): object_storage_bucket.delete() self.assertEqual(m.call_url, object_storage_bucket_delete_url) + def test_bucket_access_get(self): + bucket_access_get_url = ( + "/object-storage/buckets/us-east/example-bucket/access" + ) + with self.mock_get(bucket_access_get_url) as m: + object_storage_bucket = ObjectStorageBucket( + self.client, "example-bucket", "us-east" + ) + result = object_storage_bucket.access_get() + self.assertIsNotNone(result) + self.assertEqual(m.call_url, bucket_access_get_url) + self.assertEqual(result.acl, "authenticated-read") + self.assertEqual(result.cors_enabled, True) + self.assertEqual(result.acl_xml, "...") + def test_bucket_access_modify(self): """ Test that you can modify bucket access settings. @@ -115,6 +141,8 @@ def test_buckets_in_cluster(self): self.assertEqual(bucket.label, "example-bucket") self.assertEqual(bucket.objects, 4) self.assertEqual(bucket.size, 188318981) + self.assertEqual(bucket.endpoint_type, ObjectStorageEndpointType.E1) + self.assertEqual(bucket.s3_endpoint, "us-east-12.linodeobjects.com") def test_ssl_cert_delete(self): """ @@ -257,3 +285,53 @@ def test_object_acl_config_update(self): "name": "example", }, ) + + def test_quota_get_and_list(self): + """ + Test that you can get and list an Object storage quota and usage information. + """ + quota = ObjectStorageQuota( + self.client, + "obj-objects-us-ord-1", + ) + + self.assertIsNotNone(quota) + self.assertEqual(quota.quota_id, "obj-objects-us-ord-1") + self.assertEqual(quota.quota_name, "Object Storage Maximum Objects") + self.assertEqual( + quota.description, + "Maximum number of Objects this customer is allowed to have on this endpoint.", + ) + self.assertEqual(quota.endpoint_type, "E1") + self.assertEqual(quota.s3_endpoint, "us-iad-1.linodeobjects.com") + self.assertEqual(quota.quota_limit, 50) + self.assertEqual(quota.resource_metric, "object") + + quota_usage_url = "/object-storage/quotas/obj-objects-us-ord-1/usage" + with self.mock_get(quota_usage_url) as m: + usage = quota.usage() + self.assertIsNotNone(usage) + self.assertEqual(m.call_url, quota_usage_url) + self.assertEqual(usage.quota_limit, 100) + self.assertEqual(usage.usage, 10) + + quota_list_url = "/object-storage/quotas" + with self.mock_get(quota_list_url) as m: + quotas = self.client.object_storage.quotas() + self.assertIsNotNone(quotas) + self.assertEqual(m.call_url, quota_list_url) + self.assertEqual(len(quotas), 2) + self.assertEqual(quotas[0].quota_id, "obj-objects-us-ord-1") + self.assertEqual( + quotas[0].quota_name, "Object Storage Maximum Objects" + ) + self.assertEqual( + quotas[0].description, + "Maximum number of Objects this customer is allowed to have on this endpoint.", + ) + self.assertEqual(quotas[0].endpoint_type, "E1") + self.assertEqual( + quotas[0].s3_endpoint, "us-iad-1.linodeobjects.com" + ) + self.assertEqual(quotas[0].quota_limit, 50) + self.assertEqual(quotas[0].resource_metric, "object") diff --git a/test/unit/objects/placement_test.py b/test/unit/objects/placement_test.py index 71d171644..08fcdc1e4 100644 --- a/test/unit/objects/placement_test.py +++ b/test/unit/objects/placement_test.py @@ -1,10 +1,9 @@ from test.unit.base import ClientBaseCase -from linode_api4 import PlacementGroupPolicy from linode_api4.objects import ( + MigratedInstance, PlacementGroup, PlacementGroupMember, - PlacementGroupType, ) @@ -24,46 +23,6 @@ def test_get_placement_group(self): self.validate_pg_123(pg) assert pg._populated - def test_list_pgs(self): - """ - Tests that you can list PGs. - """ - - pgs = self.client.placement.groups() - - self.validate_pg_123(pgs[0]) - assert pgs[0]._populated - - def test_create_pg(self): - """ - Tests that you can create a Placement Group. - """ - - with self.mock_post("/placement/groups/123") as m: - pg = self.client.placement.group_create( - "test", - "eu-west", - PlacementGroupType.anti_affinity_local, - PlacementGroupPolicy.strict, - ) - - assert m.call_url == "/placement/groups" - - self.assertEqual( - m.call_data, - { - "label": "test", - "region": "eu-west", - "placement_group_type": str( - PlacementGroupType.anti_affinity_local - ), - "placement_group_policy": PlacementGroupPolicy.strict, - }, - ) - - assert pg._populated - self.validate_pg_123(pg) - def test_pg_assign(self): """ Tests that you can assign to a PG. @@ -116,3 +75,5 @@ def validate_pg_123(self, pg: PlacementGroup): assert pg.members[0] == PlacementGroupMember( linode_id=123, is_compliant=True ) + assert pg.migrations.inbound[0] == MigratedInstance(linode_id=123) + assert pg.migrations.outbound[0] == MigratedInstance(linode_id=456) diff --git a/test/unit/objects/property_alias_test.py b/test/unit/objects/property_alias_test.py new file mode 100644 index 000000000..09efa0e7e --- /dev/null +++ b/test/unit/objects/property_alias_test.py @@ -0,0 +1,191 @@ +""" +Tests for Property alias_of functionality +""" + +from test.unit.base import ClientBaseCase + +from linode_api4.objects import Base, Property + + +class PropertyAliasTest(ClientBaseCase): + """Test cases for Property alias_of parameter""" + + def test_alias_populate_from_json(self): + """Test that aliased properties are populated correctly from JSON""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(mutable=True, alias_of="class"), + "label": Property(mutable=True), + } + + json_data = { + "id": 123, + "class": "premium", + "label": "test-label", + } + + obj = TestModel(self.client, 123, json_data) + + # The aliased property should be set using the Python-friendly name + self.assertEqual(obj.service_class, "premium") + self.assertEqual(obj.label, "test-label") + + def test_alias_serialize(self): + """Test that aliased properties serialize back to original API names""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(mutable=True, alias_of="class"), + "label": Property(mutable=True), + } + + obj = TestModel(self.client, 123) + obj._set("service_class", "premium") + obj._set("label", "test-label") + obj._set("_populated", True) + + result = obj._serialize() + + # The serialized output should use the original API attribute name + self.assertIn("class", result) + self.assertEqual(result["class"], "premium") + self.assertEqual(result["label"], "test-label") + # Should not contain the aliased name + self.assertNotIn("service_class", result) + + def test_properties_with_alias(self): + """Test that properties_with_alias returns correct mapping""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(mutable=True, alias_of="class"), + "beta_type": Property(alias_of="type"), + "label": Property(mutable=True), + } + + obj = TestModel(self.client, 123) + + alias_map = obj.properties_with_alias + + # Should contain mappings for aliased properties + self.assertIn("class", alias_map) + self.assertIn("type", alias_map) + + # Should map to tuples of (alias_name, Property) + alias_name, prop = alias_map["class"] + self.assertEqual(alias_name, "service_class") + self.assertEqual(prop.alias_of, "class") + + alias_name, prop = alias_map["type"] + self.assertEqual(alias_name, "beta_type") + self.assertEqual(prop.alias_of, "type") + + # Non-aliased properties should not be in the map + self.assertNotIn("label", alias_map) + self.assertNotIn("id", alias_map) + + def test_alias_no_conflict_with_regular_properties(self): + """Test that aliased properties don't conflict with regular properties""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(mutable=True, alias_of="class"), + "label": Property(mutable=True), + "status": Property(), + } + + json_data = { + "id": 123, + "class": "premium", + "label": "test-label", + "status": "active", + } + + obj = TestModel(self.client, 123, json_data) + + # All properties should be set correctly + self.assertEqual(obj.service_class, "premium") + self.assertEqual(obj.label, "test-label") + self.assertEqual(obj.status, "active") + + def test_multiple_aliases(self): + """Test handling multiple aliased properties""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(mutable=True, alias_of="class"), + "beta_type": Property(mutable=True, alias_of="type"), + "import_data": Property(mutable=True, alias_of="import"), + } + + json_data = { + "id": 123, + "class": "premium", + "type": "beta", + "import": "data", + } + + obj = TestModel(self.client, 123, json_data) + + # All aliased properties should be populated + self.assertEqual(obj.service_class, "premium") + self.assertEqual(obj.beta_type, "beta") + self.assertEqual(obj.import_data, "data") + + # Serialization should use original names + obj._set("_populated", True) + result = obj._serialize() + + self.assertEqual(result["class"], "premium") + self.assertEqual(result["type"], "beta") + self.assertEqual(result["import"], "data") + + def test_alias_with_none_value(self): + """Test that aliased properties handle None values correctly""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(mutable=True, alias_of="class"), + } + + json_data = { + "id": 123, + "class": None, + } + + obj = TestModel(self.client, 123, json_data) + + # The aliased property should be None + self.assertIsNone(obj.service_class) + + def test_alias_cached_property(self): + """Test that properties_with_alias is cached""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(alias_of="class"), + } + + obj = TestModel(self.client, 123) + + # Access the cached property twice + result1 = obj.properties_with_alias + result2 = obj.properties_with_alias + + # Should return the same object (cached) + self.assertIs(result1, result2) diff --git a/test/unit/objects/region_test.py b/test/unit/objects/region_test.py index a7fcc2694..7bc3ae9f8 100644 --- a/test/unit/objects/region_test.py +++ b/test/unit/objects/region_test.py @@ -1,8 +1,6 @@ -import json from test.unit.base import ClientBaseCase from linode_api4.objects import Region -from linode_api4.objects.region import RegionAvailabilityEntry class RegionTest(ClientBaseCase): @@ -17,7 +15,6 @@ def test_get_region(self): region = Region(self.client, "us-east") self.assertEqual(region.id, "us-east") - self.assertIsNotNone(region.capabilities) self.assertEqual(region.country, "us") self.assertEqual(region.label, "label7") self.assertEqual(region.status, "ok") @@ -30,46 +27,13 @@ def test_get_region(self): region.placement_group_limits.maximum_linodes_per_pg, 5 ) - def test_list_availability(self): - """ - Tests that region availability can be listed and filtered on. - """ - - with self.mock_get("/regions/availability") as m: - avail_entries = self.client.regions.availability( - RegionAvailabilityEntry.filters.region == "us-east", - RegionAvailabilityEntry.filters.plan == "premium4096.7", - ) - - assert len(avail_entries) > 0 - - for entry in avail_entries: - assert entry.region is not None - assert len(entry.region) > 0 - - assert entry.plan is not None - assert len(entry.plan) > 0 - - assert entry.available is not None + # Test monitors section + self.assertIsNotNone(region.monitors) + self.assertEqual(region.monitors.alerts, ["Managed Databases"]) + self.assertEqual(region.monitors.metrics, ["Managed Databases"]) - # Ensure all three pages are read - assert m.call_count == 3 - assert m.mock.call_args_list[0].args[0] == "//regions/availability" - - assert ( - m.mock.call_args_list[1].args[0] - == "//regions/availability?page=2&page_size=100" - ) - assert ( - m.mock.call_args_list[2].args[0] - == "//regions/availability?page=3&page_size=100" - ) - - # Ensure the filter headers are correct - for k, call in m.mock.call_args_list: - assert json.loads(call.get("headers").get("X-Filter")) == { - "+and": [{"region": "us-east"}, {"plan": "premium4096.7"}] - } + self.assertIsNotNone(region.capabilities) + self.assertIn("Linode Interfaces", region.capabilities) def test_region_availability(self): """ @@ -85,3 +49,15 @@ def test_region_availability(self): assert len(entry.plan) > 0 assert entry.available is not None + + def test_region_vpc_availability(self): + """ + Tests that VPC availability for a specific region can be retrieved. + """ + vpc_avail = Region(self.client, "us-east").vpc_availability + + assert vpc_avail is not None + assert vpc_avail.region == "us-east" + assert vpc_avail.available is True + assert vpc_avail.available_ipv6_prefix_lengths is not None + assert isinstance(vpc_avail.available_ipv6_prefix_lengths, list) diff --git a/test/unit/objects/serializable_test.py b/test/unit/objects/serializable_test.py index 579417e1c..f7dff4297 100644 --- a/test/unit/objects/serializable_test.py +++ b/test/unit/objects/serializable_test.py @@ -1,8 +1,8 @@ from dataclasses import dataclass from test.unit.base import ClientBaseCase -from typing import Optional +from typing import Optional, Union -from linode_api4 import JSONObject +from linode_api4 import Base, ExplicitNullValue, JSONObject, Property class JSONObjectTest(ClientBaseCase): @@ -14,15 +14,97 @@ class Foo(JSONObject): foo: Optional[str] = None bar: Optional[str] = None baz: str = None + foobar: Union[str, ExplicitNullValue, None] = None foo = Foo().dict assert foo["foo"] is None assert "bar" not in foo assert foo["baz"] is None + assert "foobar" not in foo - foo = Foo(foo="test", bar="test2", baz="test3").dict + foo = Foo(foo="test", bar="test2", baz="test3", foobar="test4").dict assert foo["foo"] == "test" assert foo["bar"] == "test2" assert foo["baz"] == "test3" + assert foo["foobar"] == "test4" + + def test_serialize_optional_include_None(self): + @dataclass + class Foo(JSONObject): + include_none_values = True + + foo: Optional[str] = None + bar: Optional[str] = None + baz: str = None + foobar: Union[str, ExplicitNullValue, None] = None + + foo = Foo().dict + + assert foo["foo"] is None + assert foo["bar"] is None + assert foo["baz"] is None + assert foo["foobar"] is None + + foo = Foo( + foo="test", bar="test2", baz="test3", foobar=ExplicitNullValue() + ).dict + + assert foo["foo"] == "test" + assert foo["bar"] == "test2" + assert foo["baz"] == "test3" + assert foo["foobar"] is None + + def test_serialize_put_class(self): + """ + Ensures that the JSONObject put_class ClassVar functions as expected. + """ + + @dataclass + class SubStructOptions(JSONObject): + test1: Optional[str] = None + + @dataclass + class SubStruct(JSONObject): + put_class = SubStructOptions + + test1: str = "" + test2: int = 0 + + class Model(Base): + api_endpoint = "/foo/bar" + + properties = { + "id": Property(identifier=True), + "substruct": Property(mutable=True, json_object=SubStruct), + } + + mock_response = { + "id": 123, + "substruct": { + "test1": "abc", + "test2": 321, + }, + } + + with self.mock_get(mock_response) as mock: + obj = self.client.load(Model, 123) + + assert mock.called + + assert obj.id == 123 + assert obj.substruct.test1 == "abc" + assert obj.substruct.test2 == 321 + + obj.substruct.test1 = "cba" + + with self.mock_put(mock_response) as mock: + obj.save() + + assert mock.called + assert mock.call_data == { + "substruct": { + "test1": "cba", + } + } diff --git a/test/unit/objects/volume_test.py b/test/unit/objects/volume_test.py index c18ac8d89..1344c2b94 100644 --- a/test/unit/objects/volume_test.py +++ b/test/unit/objects/volume_test.py @@ -31,6 +31,10 @@ def test_get_volume(self): self.assertEqual(volume.hardware_type, "hdd") self.assertEqual(volume.linode_label, None) + def test_get_volume_with_encryption(self): + volume = Volume(self.client, 4) + self.assertEqual(volume.encryption, "enabled") + def test_update_volume_tags(self): """ Tests that updating tags on an entity send the correct request diff --git a/test/unit/objects/vpc_test.py b/test/unit/objects/vpc_test.py index 4d80716d4..90ec348da 100644 --- a/test/unit/objects/vpc_test.py +++ b/test/unit/objects/vpc_test.py @@ -30,55 +30,6 @@ def test_list_vpcs(self): self.validate_vpc_123456(vpcs[0]) self.assertEqual(vpcs[0]._populated, True) - def test_create_vpc(self): - """ - Tests that you can create a VPC. - """ - - with self.mock_post("/vpcs/123456") as m: - vpc = self.client.vpcs.create("test-vpc", "us-southeast") - - self.assertEqual(m.call_url, "/vpcs") - - self.assertEqual( - m.call_data, - { - "label": "test-vpc", - "region": "us-southeast", - }, - ) - - self.assertEqual(vpc._populated, True) - self.validate_vpc_123456(vpc) - - def test_create_vpc_with_subnet(self): - """ - Tests that you can create a VPC. - """ - - with self.mock_post("/vpcs/123456") as m: - vpc = self.client.vpcs.create( - "test-vpc", - "us-southeast", - subnets=[{"label": "test-subnet", "ipv4": "10.0.0.0/24"}], - ) - - self.assertEqual(m.call_url, "/vpcs") - - self.assertEqual( - m.call_data, - { - "label": "test-vpc", - "region": "us-southeast", - "subnets": [ - {"label": "test-subnet", "ipv4": "10.0.0.0/24"} - ], - }, - ) - - self.assertEqual(vpc._populated, True) - self.validate_vpc_123456(vpc) - def test_get_subnet(self): """ Tests that you can list VPCs. @@ -138,7 +89,7 @@ def test_list_ips(self): ip = result[0] assert ip.address == "10.0.0.2" - assert ip.address_range == None + assert ip.address_range is None assert ip.vpc_id == 123 assert ip.subnet_id == 456 assert ip.region == "us-mia" @@ -162,16 +113,32 @@ def validate_vpc_123456(self, vpc: VPC): self.assertEqual(vpc.created, expected_dt) self.assertEqual(vpc.updated, expected_dt) + self.assertEqual(vpc.ipv6[0].range, "fd71:1140:a9d0::/52") + def validate_vpc_subnet_789(self, subnet: VPCSubnet): expected_dt = datetime.datetime.strptime( "2018-01-01T00:01:01", DATE_FORMAT ) - self.assertEqual(subnet.label, "test-subnet") - self.assertEqual(subnet.ipv4, "10.0.0.0/24") - self.assertEqual(subnet.linodes[0].id, 12345) - self.assertEqual(subnet.created, expected_dt) - self.assertEqual(subnet.updated, expected_dt) + assert subnet.label == "test-subnet" + assert subnet.ipv4 == "10.0.0.0/24" + assert subnet.linodes[0].id == 12345 + assert subnet.created == expected_dt + assert subnet.updated == expected_dt + + assert subnet.databases[0].id == 12345 + assert subnet.databases[0].ipv4_range == "10.0.0.0/24" + assert subnet.databases[0].ipv6_ranges == ["2001:db8::/64"] + + assert subnet.linodes[0].interfaces[0].id == 678 + assert subnet.linodes[0].interfaces[0].active + assert subnet.linodes[0].interfaces[0].config_id is None + + assert subnet.linodes[0].interfaces[1].id == 543 + assert not subnet.linodes[0].interfaces[1].active + assert subnet.linodes[0].interfaces[1].config_id is None + + self.assertEqual(subnet.ipv6[0].range, "fd71:1140:a9d0::/52") def test_list_vpc_ips(self): """ @@ -197,3 +164,11 @@ def test_list_vpc_ips(self): self.assertEqual(vpc_ip.gateway, "10.0.0.1") self.assertEqual(vpc_ip.prefix, 8) self.assertEqual(vpc_ip.subnet_mask, "255.0.0.0") + + vpc_ip_2 = vpc_ips[2] + + self.assertEqual(vpc_ip_2.ipv6_range, "fd71:1140:a9d0::/52") + self.assertEqual(vpc_ip_2.ipv6_is_public, True) + self.assertEqual( + vpc_ip_2.ipv6_addresses[0].slaac_address, "fd71:1140:a9d0::/52" + ) diff --git a/test/unit/util_test.py b/test/unit/util_test.py index 3123a4447..35adf38ff 100644 --- a/test/unit/util_test.py +++ b/test/unit/util_test.py @@ -1,6 +1,6 @@ import unittest -from linode_api4.util import drop_null_keys +from linode_api4.util import drop_null_keys, generate_device_suffixes class UtilTest(unittest.TestCase): @@ -53,3 +53,122 @@ def test_drop_null_keys_recursive(self): } assert drop_null_keys(value) == expected_output + + def test_generate_device_suffixes(self): + """ + Tests whether generate_device_suffixes works as expected. + """ + + expected_output_12 = [ + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + ] + assert generate_device_suffixes(12) == expected_output_12 + + expected_output_30 = [ + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "aa", + "ab", + "ac", + "ad", + ] + assert generate_device_suffixes(30) == expected_output_30 + + expected_output_60 = [ + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "aa", + "ab", + "ac", + "ad", + "ae", + "af", + "ag", + "ah", + "ai", + "aj", + "ak", + "al", + "am", + "an", + "ao", + "ap", + "aq", + "ar", + "as", + "at", + "au", + "av", + "aw", + "ax", + "ay", + "az", + "ba", + "bb", + "bc", + "bd", + "be", + "bf", + "bg", + "bh", + ] + assert generate_device_suffixes(60) == expected_output_60 diff --git a/tox.ini b/tox.ini index 209db7170..266c26717 100644 --- a/tox.ini +++ b/tox.ini @@ -9,6 +9,7 @@ deps = mock pylint httpretty + pytest-rerunfailures commands = python -m pip install . coverage run --source linode_api4 -m pytest test/unit