Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/remote-release-trigger.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:

- name: Get previous tag
id: previoustag
uses: WyriHaximus/github-action-get-previous-tag@61819f33034117e6c686e6a31dba995a85afc9de # pin@v2.0.0
uses: WyriHaximus/github-action-get-previous-tag@83f26fea93bc7efcbca2eb5591f5eaaf66b8f206 # pin@v2.1.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Expand Down
2 changes: 2 additions & 0 deletions linodecli/baked/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

## Request Parsing Gotchas
- `_aggregate_schema_properties()` merges `oneOf`, `anyOf`, and `allOf`; a field is marked required only if every leaf schema that defines properties requires it. Schemas that only nest composition without their own `properties` do not increment the required-count denominator.
- Nested request objects are expanded when `_aggregate_schema_properties()` finds properties and the schema has `type: object` or no explicit type, unless the effective format is `json`. Explicit scalar and array types retain their dedicated parsing paths. A nested `oneOf`/`anyOf`/`allOf` without `type` normally becomes dotted CLI args (for example `--details.host`); `x-linode-cli-format: json` or `format: json` instead preserves a raw-JSON parent argument, with the CLI extension taking precedence.
- `OpenAPIRequest.attr_routes` stores per-`oneOf` option args keyed by schema `title`; oneOf entries without titles raise `ValueError`.
- `OpenAPIOperation.arg_routes` returns `self.request.attr_routes` (a dict) when a request exists, but `[]` when there is no request. Treat the empty case as a type inconsistency (list vs dict); callers that always call `.items()` will break on the no-request path.
- Request and response attributes marked `linode-cli-skip` are omitted from generated args/output.
Expand All @@ -48,6 +49,7 @@
- `--order-by` is restricted to filterable attrs and becomes `+order_by` in `X-Filter`; list filters serialize as `+and` entries.

## Response And Output Gotchas
- Nested response schemas with aggregated properties are expanded even without `type: object`; arbitrary-key dictionaries retain their existing leaf handling, and scalar compositions without properties are not expanded.
- Bake-time pagination detection is structural: top-level response schema must have exactly `pages`, `page`, `results`, and `data`; only `data.items` becomes display attrs (`is_paginated`).
- `OpenAPIResponse.fix_json()` handles `linode-cli-rows`, then `linode-cli-nested-list`, then unwraps via `"pages" in json` (not `self.is_paginated`), then wraps non-lists. Runtime unwrap is looser than bake-time pagination; do not tighten it to `is_paginated` without checking odd payloads that only include `pages`.
- `linode-cli-rows` paths are extracted from the raw API JSON and concatenated; list values extend the result, scalar/object values are appended.
Expand Down
9 changes: 7 additions & 2 deletions linodecli/baked/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,11 +164,16 @@ def _parse_request_model(
if v.extensions.get("linode-cli-skip"):
continue

# Nested oneOf/anyOf/allOf schemas may omit type: object.
nested_props, _ = _aggregate_schema_properties(v)
effective_format = v.extensions.get("linode-cli-format") or v.format

# Handle nested objects which aren't read-only and have properties
if (
v.type == "object"
v.type in (None, "object")
and not v.readOnly
and len(_aggregate_schema_properties(v)[0]) > 0
and nested_props
and effective_format != "json"
):
# nested objects receive a prefix and are otherwise parsed normally
pref = prefix + "." + k if prefix else k
Expand Down
4 changes: 3 additions & 1 deletion linodecli/baked/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,9 @@ def _parse_response_model(schema, prefix=None, nested_list_depth=0):
k, v, prefix=prefix, nested_list_depth=nested_list_depth
)
)
elif v.type == "object":
elif v.type == "object" or (
v.type is None and bool(_aggregate_schema_properties(v)[0])
):
attrs += _parse_response_model(v, prefix=pref)
elif v.type == "array" and (
v.items.type == "object"
Expand Down
1 change: 0 additions & 1 deletion linodecli/configuration/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
# Private methods need to be imported explicitly
from .auth import *
from .auth import (
_check_full_access,
_do_get_request,
_get_token_terminal,
_get_token_web,
Expand Down
29 changes: 0 additions & 29 deletions linodecli/configuration/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,35 +152,6 @@ def _do_request(
return result.json()


def _check_full_access(base_url: str, token: str) -> bool:
"""
Checks whether the given token has full-access permissions.

:param base_url: The base URL for the API.
:type base_url: str
:param token: The access token to use.
:type token :str

:returns: Whether the user has full access.
:rtype: bool
"""
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}

result = requests.get(
base_url + "/profile/grants",
headers=headers,
timeout=120,
verify=API_CA_PATH,
)

_handle_response_status(result, exit_on_error=True)

return result.status_code == 204


def _username_for_token(base_url: str, token: str) -> str:
"""
A helper function that returns the username associated with a token by
Expand Down
27 changes: 20 additions & 7 deletions linodecli/configuration/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
from linodecli.exit_codes import ExitCodes

from .auth import (
_check_full_access,
_do_get_request,
_get_token_terminal,
_get_token_web,
Expand Down Expand Up @@ -430,18 +429,32 @@ def configure(
[i["id"] for i in _do_get_request(self.base_url, "/images")["data"]]
)

is_full_access = _check_full_access(self.base_url, token)

auth_users = []

if is_full_access:
# Use /profile/sshkeys as a lightweight capability check. If the token
# lacks profile access (401) or the user is IAM-enrolled (403), skip
# the authorized_users prompt entirely. Capture the status code via a
# closure passed as status_validator so no new API surface is needed.
sshkeys_status_code = None

def _capture_sshkeys_status(status: int) -> bool:
nonlocal sshkeys_status_code
sshkeys_status_code = status
return status in (401, 403)

_do_get_request(
self.base_url,
"/profile/sshkeys",
token=token,
status_validator=_capture_sshkeys_status,
)

if sshkeys_status_code == 200:
users = _do_get_request(
self.base_url,
"/account/users",
token=token,
# Allow 401 responses so tokens without
# account perms can be configured
status_validator=lambda status: status == 401,
status_validator=lambda status: status in (401, 403),
)

if "data" in users:
Expand Down
1 change: 0 additions & 1 deletion tests/fixtures/operation_with_one_ofs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ components:
description: Some foofoo.
barbar:
description: Some barbar.
type: object
anyOf:
- type: object
properties:
Expand Down
9 changes: 9 additions & 0 deletions tests/integration/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,15 @@ def assert_help_actions_list(expected_actions, help_output):
assert expected_action in output_actions


def assert_help_parameters_list_for_command(expected_parameters, help_output):
for expected_parameter in expected_parameters:
assert re.search(
rf"^\s*--{re.escape(expected_parameter)}(?=\s|:)",
help_output,
re.MULTILINE,
), f"Missing help parameter: --{expected_parameter}"


def view_command_attribute(
command: str, action: str, item_id: str, attribute: str
) -> str:
Expand Down
4 changes: 2 additions & 2 deletions tests/integration/linodes/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,9 @@ def linode_backup_disabled(linode_cloud_firewall):


@pytest.fixture(scope="module")
def snapshot_of_linode():
def snapshot_of_linode(linode_cloud_firewall):
# get linode id after creation and wait for "running" status
linode_id = create_linode_and_wait()
linode_id = create_linode_and_wait(firewall_id=linode_cloud_firewall)
new_snapshot_label = "test_snapshot_" + get_random_text(5)

result = exec_test_command(
Expand Down
19 changes: 12 additions & 7 deletions tests/integration/linodes/test_backups.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@

import pytest

from tests.integration.helpers import BASE_CMDS, exec_test_command
from tests.integration.helpers import (
BASE_CMDS,
delete_target_id,
exec_test_command,
)
from tests.integration.linodes.fixtures import ( # noqa: F401
linode_backup_disabled,
linode_backup_enabled,
linode_basic_with_firewall,
snapshot_of_linode,
)
from tests.integration.linodes.helpers import (
check_account_settings,
Expand Down Expand Up @@ -91,9 +96,9 @@ def test_create_backup_with_backup_enabled(linode_backup_enabled):
os.environ.get("RUN_LONG_TESTS", None) != "True",
reason="Skipping long-running Test, to run set RUN_LONG_TESTS=True",
)
def test_take_snapshot_of_linode(firewall_id):
def test_take_snapshot_of_linode(linode_cloud_firewall):
# get linode id after creation and wait for "running" status
linode_id = create_linode_and_wait(firewall_id)
linode_id = create_linode_and_wait(firewall_id=linode_cloud_firewall)

snapshot_label = "test_snapshot1"

Expand All @@ -116,6 +121,8 @@ def test_take_snapshot_of_linode(firewall_id):
result,
)

delete_target_id("linodes", linode_id)


@pytest.mark.skipif(
os.environ.get("RUN_LONG_TESTS", None) != "True",
Expand Down Expand Up @@ -157,18 +164,16 @@ def test_cancel_backups(snapshot_of_linode):
result = exec_test_command(
BASE_CMDS["linodes"]
+ [
"snapshot",
"backups-list",
linode_id,
"--label",
new_snapshot_label,
"--text",
"--delimiter",
",",
"--no-headers",
]
)
assert re.search(
"[0-9]+,pending,snapshot,[0-9]+-[0-9]+-[0-9]+T[0-9]+:[0-9]+:[0-9]+,"
"[0-9]+,(pending|running),snapshot,[0-9]+-[0-9]+-[0-9]+T[0-9]+:[0-9]+:[0-9]+,"
+ new_snapshot_label,
result,
)
Expand Down
79 changes: 79 additions & 0 deletions tests/integration/nodebalancers/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
check_attribute_value,
delete_target_id,
exec_test_command,
get_random_region_with_caps,
get_random_text,
wait_for_condition,
)
from tests.integration.linodes.helpers import DEFAULT_TEST_IMAGE
Expand Down Expand Up @@ -362,3 +364,80 @@ def nodebalancer_with_reserved_ipv4(create_reserved_ip):
yield res_ip, nodebalancer_id

delete_target_id(target="nodebalancers", id=nodebalancer_id)


@pytest.fixture
def nodebalancer_premium_with_backend_vpc(
linode_cloud_firewall,
vpc_for_nb_premium_backend_connectivity,
):
test_subnet_id = exec_test_command(
BASE_CMDS["vpcs"]
+ [
"subnets-list",
vpc_for_nb_premium_backend_connectivity[0],
"--text",
"--no-headers",
"--delimiter=,",
]
).split(",")[0]

result = exec_test_command(
BASE_CMDS["nodebalancers"]
+ [
"create",
"--region",
vpc_for_nb_premium_backend_connectivity[1],
"--firewall_id",
linode_cloud_firewall,
"--type",
"premium",
"--backend_connectivity",
"vpc",
"--vpcs.ipv4_range",
"192.168.0.64/30",
"--vpcs.subnet_id",
test_subnet_id,
"--text",
"--delimiter",
",",
"--suppress-warnings",
"--no-headers",
]
)
nodebalancer_id = result.split(",")[0]

yield nodebalancer_id, result
delete_target_id(target="nodebalancers", id=nodebalancer_id)


@pytest.fixture
def vpc_for_nb_premium_backend_connectivity():
vpc_label = "test_vpc_" + get_random_text(5)
subnet_label = "test_vpc_" + get_random_text(5)
test_region = get_random_region_with_caps(
["NodeBalancers", "Premium NodeBalancers"]
)

test_vpc_id = exec_test_command(
BASE_CMDS["vpcs"]
+ [
"create",
"--label",
vpc_label,
"--region",
test_region,
"--subnets.ipv4",
"192.168.0.0/25",
"--subnets.label",
subnet_label,
"--text",
"--suppress-warnings",
"--format",
"id",
"--no-headers",
]
)

yield test_vpc_id, test_region
delete_target_id(target="vpcs", id=test_vpc_id)
Loading
Loading