diff --git a/src/apr/.gitignore b/src/apr/.gitignore new file mode 100644 index 00000000000..e84e843cb66 --- /dev/null +++ b/src/apr/.gitignore @@ -0,0 +1,17 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +*.egg +build/ +dist/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# Virtualenv +.venv/ +venv/ + +# Packaging +MANIFEST diff --git a/src/apr/HISTORY.rst b/src/apr/HISTORY.rst new file mode 100644 index 00000000000..29f73046fca --- /dev/null +++ b/src/apr/HISTORY.rst @@ -0,0 +1,15 @@ +.. :changelog: + +Release History +=============== + +1.0.0b1 +++++++++ + +* Add the initial preview command surface for registries, repositories, + releases, packages, distributions, remotes, publications, and tasks. +* Use the final ``apr`` extension and command identity for Azure Package + Registry. +* Generate registry management commands with AAZ. +* Reject invalid data-plane endpoints and paths, disable redirects, and retry + only GET requests. diff --git a/src/apr/LICENSE b/src/apr/LICENSE new file mode 100644 index 00000000000..22aed37e650 --- /dev/null +++ b/src/apr/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Microsoft Corporation. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/apr/README.md b/src/apr/README.md new file mode 100644 index 00000000000..fdddedbc7c2 --- /dev/null +++ b/src/apr/README.md @@ -0,0 +1,28 @@ +# Azure Package Registry CLI extension + +This preview extension manages Azure Package Registry resources and their +repository content. + +## Command groups + +- `az apr registry` +- `az apr repository` +- `az apr repository release` +- `az apr repository package` +- `az apr package` +- `az apr distro` +- `az apr remote` +- `az apr publication` +- `az apr task` + +Registry commands use the ARM control plane. Repository and package commands +use the registry data-plane endpoint returned by ARM. + +## Installation + +After publication: + +```bash +az extension add --name apr +az apr --help +``` diff --git a/src/apr/azext_apr/__init__.py b/src/apr/azext_apr/__init__.py new file mode 100644 index 00000000000..609261d9464 --- /dev/null +++ b/src/apr/azext_apr/__init__.py @@ -0,0 +1,36 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azure.cli.core import AzCommandsLoader + + +class AprCommandsLoader(AzCommandsLoader): + def __init__(self, cli_ctx=None): + from azure.cli.core.commands import CliCommandType + + from azext_apr._client_factory import cf_apr + + apr_custom = CliCommandType( + operations_tmpl="azext_apr.commands.registry#{}", + client_factory=cf_apr, + ) + super().__init__(cli_ctx=cli_ctx, custom_command_type=apr_custom) + + def load_command_table(self, args): + from azure.cli.core.aaz import load_aaz_command_table + + from azext_apr.commands import load_command_table + + load_aaz_command_table(self, "azext_apr.aaz", args) + load_command_table(self, args) + return self.command_table + + def load_arguments(self, command): + from azext_apr.commands import load_arguments + + load_arguments(self, command) + + +COMMAND_LOADER_CLS = AprCommandsLoader diff --git a/src/apr/azext_apr/_client_factory.py b/src/apr/azext_apr/_client_factory.py new file mode 100644 index 00000000000..a7f9aca1c60 --- /dev/null +++ b/src/apr/azext_apr/_client_factory.py @@ -0,0 +1,17 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + + +def cf_apr(cli_ctx, *_): + del cli_ctx + # Control-plane (ARM) client factory. The az command pipeline calls this + # BEFORE the handler runs, so it's the right place to refuse control-plane + # commands while running as a local dev build — otherwise the same gate in the + # handlers would be shadowed by the error below. Keep this check first when the + # real management-plane SDK client is wired up. + from azext_apr.server import raise_if_dev_extension + + raise_if_dev_extension() + raise NotImplementedError("management-plane SDK client not yet available") diff --git a/src/apr/azext_apr/aaz/__init__.py b/src/apr/azext_apr/aaz/__init__.py new file mode 100644 index 00000000000..5757aea3175 --- /dev/null +++ b/src/apr/azext_apr/aaz/__init__.py @@ -0,0 +1,6 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- diff --git a/src/apr/azext_apr/aaz/latest/__init__.py b/src/apr/azext_apr/aaz/latest/__init__.py new file mode 100644 index 00000000000..7e832c711e8 --- /dev/null +++ b/src/apr/azext_apr/aaz/latest/__init__.py @@ -0,0 +1,9 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa diff --git a/src/apr/azext_apr/aaz/latest/apr/__cmd_group.py b/src/apr/azext_apr/aaz/latest/apr/__cmd_group.py new file mode 100644 index 00000000000..fd8eb2906e0 --- /dev/null +++ b/src/apr/azext_apr/aaz/latest/apr/__cmd_group.py @@ -0,0 +1,24 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- +# mypy: ignore-errors + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command_group( + "apr", +) +class __CMDGroup(AAZCommandGroup): + """Manage Azure Package Registry.""" + + pass + + +__all__ = ["__CMDGroup"] diff --git a/src/apr/azext_apr/aaz/latest/apr/__init__.py b/src/apr/azext_apr/aaz/latest/apr/__init__.py new file mode 100644 index 00000000000..5a9d61963d6 --- /dev/null +++ b/src/apr/azext_apr/aaz/latest/apr/__init__.py @@ -0,0 +1,11 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from .__cmd_group import * diff --git a/src/apr/azext_apr/aaz/latest/apr/registry/__cmd_group.py b/src/apr/azext_apr/aaz/latest/apr/registry/__cmd_group.py new file mode 100644 index 00000000000..ed9e29d284b --- /dev/null +++ b/src/apr/azext_apr/aaz/latest/apr/registry/__cmd_group.py @@ -0,0 +1,24 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- +# mypy: ignore-errors + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command_group( + "apr registry", +) +class __CMDGroup(AAZCommandGroup): + """Manage Azure Package Registry (APR) registries.""" + + pass + + +__all__ = ["__CMDGroup"] diff --git a/src/apr/azext_apr/aaz/latest/apr/registry/__init__.py b/src/apr/azext_apr/aaz/latest/apr/registry/__init__.py new file mode 100644 index 00000000000..db73033039b --- /dev/null +++ b/src/apr/azext_apr/aaz/latest/apr/registry/__init__.py @@ -0,0 +1,17 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from .__cmd_group import * +from ._create import * +from ._delete import * +from ._list import * +from ._show import * +from ._update import * +from ._wait import * diff --git a/src/apr/azext_apr/aaz/latest/apr/registry/_create.py b/src/apr/azext_apr/aaz/latest/apr/registry/_create.py new file mode 100644 index 00000000000..d5c308504fa --- /dev/null +++ b/src/apr/azext_apr/aaz/latest/apr/registry/_create.py @@ -0,0 +1,283 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- +# mypy: ignore-errors + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * +from azext_apr.server import raise_if_dev_extension + + +@register_command( + "apr registry create", +) +class Create(AAZCommand): + """Create a Registry""" + + _aaz_info = { + "version": "2026-04-01-preview", + "resources": [ + [ + "mgmt-plane", + "/subscriptions/{}/resourcegroups/{}/providers/microsoft.packageregistry/registries/{}", + "2026-04-01-preview", + ], + ], + } + + AZ_SUPPORT_NO_WAIT = True + + def _handler(self, command_args): + super()._handler(command_args) + return self.build_lro_poller(self._execute_operations, self._output) + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.registry_name = AAZStrArg( + options=["-n", "--name", "--registry-name"], + help="The name of the registry. 3-24 characters of lowercase letters, digits, and hyphens; must start and end with a letter or digit.", + required=True, + fmt=AAZStrArgFormat( + pattern="^[a-z0-9]([a-z0-9-]{1,22}[a-z0-9])$", + ), + ) + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + + # define Arg Group "Resource" + + _args_schema = cls._args_schema + _args_schema.location = AAZResourceLocationArg( + arg_group="Resource", + help="The geo-location where the resource lives", + required=True, + fmt=AAZResourceLocationArgFormat( + resource_group_arg="resource_group", + ), + ) + _args_schema.tags = AAZDictArg( + options=["--tags"], + arg_group="Resource", + help="Resource tags.", + ) + + tags = cls._args_schema.tags + tags.Element = AAZStrArg() + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + yield self.RegistriesCreateOrUpdate(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + raise_if_dev_extension() + + @register_callback + def post_operations(self): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True) + return result + + class RegistriesCreateOrUpdate(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [202]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + self.on_200_201, + self.on_error, + lro_options={"final-state-via": "azure-async-operation"}, + path_format_arguments=self.url_parameters, + ) + if session.http_response.status_code in [200, 201]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + self.on_200_201, + self.on_error, + lro_options={"final-state-via": "azure-async-operation"}, + path_format_arguments=self.url_parameters, + ) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.PackageRegistry/registries/{registryName}", + **self.url_parameters, + ) + + @property + def method(self): + return "PUT" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "registryName", + self.ctx.args.registry_name, + required=True, + ), + **self.serialize_url_param( + "resourceGroupName", + self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", + self.ctx.subscription_id, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", + "2026-04-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Content-Type", + "application/json", + ), + **self.serialize_header_param( + "Accept", + "application/json", + ), + } + return parameters + + @property + def content(self): + _content_value, _builder = self.new_content_builder( + self.ctx.args, + typ=AAZObjectType, + typ_kwargs={"flags": {"required": True, "client_flatten": True}}, + ) + _builder.set_prop( + "location", AAZStrType, ".location", typ_kwargs={"flags": {"required": True}} + ) + _builder.set_prop("tags", AAZDictType, ".tags") + + tags = _builder.get(".tags") + if tags is not None: + tags.set_elements(AAZStrType, ".") + + return self.serialize_content(_content_value) + + def on_200_201(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var("instance", data, schema_builder=self._build_schema_on_200_201) + + _schema_on_200_201 = None + + @classmethod + def _build_schema_on_200_201(cls): + if cls._schema_on_200_201 is not None: + return cls._schema_on_200_201 + + cls._schema_on_200_201 = AAZObjectType() + + _schema_on_200_201 = cls._schema_on_200_201 + _schema_on_200_201.id = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200_201.location = AAZStrType( + flags={"required": True}, + ) + _schema_on_200_201.name = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200_201.properties = AAZObjectType() + _schema_on_200_201.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + _schema_on_200_201.tags = AAZDictType() + _schema_on_200_201.type = AAZStrType( + flags={"read_only": True}, + ) + + properties = cls._schema_on_200_201.properties + properties.api_endpoint = AAZStrType( + serialized_name="apiEndpoint", + flags={"read_only": True}, + ) + properties.content_endpoint = AAZStrType( + serialized_name="contentEndpoint", + flags={"read_only": True}, + ) + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + + system_data = cls._schema_on_200_201.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + tags = cls._schema_on_200_201.tags + tags.Element = AAZStrType() + + return cls._schema_on_200_201 + + +class _CreateHelper: + """Helper class for Create""" + + +__all__ = ["Create"] diff --git a/src/apr/azext_apr/aaz/latest/apr/registry/_delete.py b/src/apr/azext_apr/aaz/latest/apr/registry/_delete.py new file mode 100644 index 00000000000..4317d071c61 --- /dev/null +++ b/src/apr/azext_apr/aaz/latest/apr/registry/_delete.py @@ -0,0 +1,160 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- +# mypy: ignore-errors + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * +from azext_apr.server import raise_if_dev_extension + + +@register_command( + "apr registry delete", + confirmation="Are you sure you want to perform this operation?", +) +class Delete(AAZCommand): + """Delete a Registry""" + + _aaz_info = { + "version": "2026-04-01-preview", + "resources": [ + [ + "mgmt-plane", + "/subscriptions/{}/resourcegroups/{}/providers/microsoft.packageregistry/registries/{}", + "2026-04-01-preview", + ], + ], + } + + AZ_SUPPORT_NO_WAIT = True + + def _handler(self, command_args): + super()._handler(command_args) + return self.build_lro_poller(self._execute_operations, None) + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.registry_name = AAZStrArg( + options=["-n", "--name", "--registry-name"], + help="The name of the registry. 3-24 characters of lowercase letters, digits, and hyphens; must start and end with a letter or digit.", + required=True, + id_part="name", + fmt=AAZStrArgFormat( + pattern="^[a-z0-9]([a-z0-9-]{1,22}[a-z0-9])$", + ), + ) + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + yield self.RegistriesDelete(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + raise_if_dev_extension() + + @register_callback + def post_operations(self): + pass + + class RegistriesDelete(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [202]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + self.on_none, + self.on_error, + lro_options={"final-state-via": "location"}, + path_format_arguments=self.url_parameters, + ) + if session.http_response.status_code in [204]: + return self.client.build_lro_polling( + self.ctx.args.no_wait, + session, + self.on_none, + self.on_error, + lro_options={"final-state-via": "location"}, + path_format_arguments=self.url_parameters, + ) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.PackageRegistry/registries/{registryName}", + **self.url_parameters, + ) + + @property + def method(self): + return "DELETE" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "registryName", + self.ctx.args.registry_name, + required=True, + ), + **self.serialize_url_param( + "resourceGroupName", + self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", + self.ctx.subscription_id, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", + "2026-04-01-preview", + required=True, + ), + } + return parameters + + def on_none(self, session): + pass + + +class _DeleteHelper: + """Helper class for Delete""" + + +__all__ = ["Delete"] diff --git a/src/apr/azext_apr/aaz/latest/apr/registry/_list.py b/src/apr/azext_apr/aaz/latest/apr/registry/_list.py new file mode 100644 index 00000000000..376fd9d58f0 --- /dev/null +++ b/src/apr/azext_apr/aaz/latest/apr/registry/_list.py @@ -0,0 +1,378 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- +# mypy: ignore-errors + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * +from azext_apr.server import raise_if_dev_extension + + +@register_command( + "apr registry list", +) +class List(AAZCommand): + """List Registry resources by resource group""" + + _aaz_info = { + "version": "2026-04-01-preview", + "resources": [ + [ + "mgmt-plane", + "/subscriptions/{}/providers/microsoft.packageregistry/registries", + "2026-04-01-preview", + ], + [ + "mgmt-plane", + "/subscriptions/{}/resourcegroups/{}/providers/microsoft.packageregistry/registries", + "2026-04-01-preview", + ], + ], + } + + AZ_SUPPORT_PAGINATION = True + + def _handler(self, command_args): + super()._handler(command_args) + return self.build_paging(self._execute_operations, self._output) + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.resource_group = AAZResourceGroupNameArg() + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + condition_0 = has_value(self.ctx.args.resource_group) and has_value( + self.ctx.subscription_id + ) + condition_1 = ( + has_value(self.ctx.subscription_id) + and has_value(self.ctx.args.resource_group) is not True + ) + if condition_0: + self.RegistriesListByResourceGroup(ctx=self.ctx)() + if condition_1: + self.RegistriesListBySubscription(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + raise_if_dev_extension() + + @register_callback + def post_operations(self): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.vars.instance.value, client_flatten=True) + next_link = self.deserialize_output(self.ctx.vars.instance.next_link) + return result, next_link + + class RegistriesListByResourceGroup(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.PackageRegistry/registries", + **self.url_parameters, + ) + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "resourceGroupName", + self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", + self.ctx.subscription_id, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", + "2026-04-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", + "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var("instance", data, schema_builder=self._build_schema_on_200) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + + _schema_on_200 = cls._schema_on_200 + _schema_on_200.next_link = AAZStrType( + serialized_name="nextLink", + ) + _schema_on_200.value = AAZListType( + flags={"required": True}, + ) + + value = cls._schema_on_200.value + value.Element = AAZObjectType() + + _element = cls._schema_on_200.value.Element + _element.id = AAZStrType( + flags={"read_only": True}, + ) + _element.location = AAZStrType( + flags={"required": True}, + ) + _element.name = AAZStrType( + flags={"read_only": True}, + ) + _element.properties = AAZObjectType() + _element.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + _element.tags = AAZDictType() + _element.type = AAZStrType( + flags={"read_only": True}, + ) + + properties = cls._schema_on_200.value.Element.properties + properties.api_endpoint = AAZStrType( + serialized_name="apiEndpoint", + flags={"read_only": True}, + ) + properties.content_endpoint = AAZStrType( + serialized_name="contentEndpoint", + flags={"read_only": True}, + ) + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + + system_data = cls._schema_on_200.value.Element.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + tags = cls._schema_on_200.value.Element.tags + tags.Element = AAZStrType() + + return cls._schema_on_200 + + class RegistriesListBySubscription(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/providers/Microsoft.PackageRegistry/registries", + **self.url_parameters, + ) + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "subscriptionId", + self.ctx.subscription_id, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", + "2026-04-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", + "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var("instance", data, schema_builder=self._build_schema_on_200) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + + _schema_on_200 = cls._schema_on_200 + _schema_on_200.next_link = AAZStrType( + serialized_name="nextLink", + ) + _schema_on_200.value = AAZListType( + flags={"required": True}, + ) + + value = cls._schema_on_200.value + value.Element = AAZObjectType() + + _element = cls._schema_on_200.value.Element + _element.id = AAZStrType( + flags={"read_only": True}, + ) + _element.location = AAZStrType( + flags={"required": True}, + ) + _element.name = AAZStrType( + flags={"read_only": True}, + ) + _element.properties = AAZObjectType() + _element.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + _element.tags = AAZDictType() + _element.type = AAZStrType( + flags={"read_only": True}, + ) + + properties = cls._schema_on_200.value.Element.properties + properties.api_endpoint = AAZStrType( + serialized_name="apiEndpoint", + flags={"read_only": True}, + ) + properties.content_endpoint = AAZStrType( + serialized_name="contentEndpoint", + flags={"read_only": True}, + ) + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + + system_data = cls._schema_on_200.value.Element.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + tags = cls._schema_on_200.value.Element.tags + tags.Element = AAZStrType() + + return cls._schema_on_200 + + +class _ListHelper: + """Helper class for List""" + + +__all__ = ["List"] diff --git a/src/apr/azext_apr/aaz/latest/apr/registry/_show.py b/src/apr/azext_apr/aaz/latest/apr/registry/_show.py new file mode 100644 index 00000000000..2dbf2daa505 --- /dev/null +++ b/src/apr/azext_apr/aaz/latest/apr/registry/_show.py @@ -0,0 +1,225 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- +# mypy: ignore-errors + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * +from azext_apr.server import raise_if_dev_extension + + +@register_command( + "apr registry show", +) +class Show(AAZCommand): + """Get a Registry""" + + _aaz_info = { + "version": "2026-04-01-preview", + "resources": [ + [ + "mgmt-plane", + "/subscriptions/{}/resourcegroups/{}/providers/microsoft.packageregistry/registries/{}", + "2026-04-01-preview", + ], + ], + } + + def _handler(self, command_args): + super()._handler(command_args) + self._execute_operations() + return self._output() + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.registry_name = AAZStrArg( + options=["-n", "--name", "--registry-name"], + help="The name of the registry. 3-24 characters of lowercase letters, digits, and hyphens; must start and end with a letter or digit.", + required=True, + id_part="name", + fmt=AAZStrArgFormat( + pattern="^[a-z0-9]([a-z0-9-]{1,22}[a-z0-9])$", + ), + ) + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + self.RegistriesGet(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + raise_if_dev_extension() + + @register_callback + def post_operations(self): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True) + return result + + class RegistriesGet(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.PackageRegistry/registries/{registryName}", + **self.url_parameters, + ) + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "registryName", + self.ctx.args.registry_name, + required=True, + ), + **self.serialize_url_param( + "resourceGroupName", + self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", + self.ctx.subscription_id, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", + "2026-04-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", + "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var("instance", data, schema_builder=self._build_schema_on_200) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + + _schema_on_200 = cls._schema_on_200 + _schema_on_200.id = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200.location = AAZStrType( + flags={"required": True}, + ) + _schema_on_200.name = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200.properties = AAZObjectType() + _schema_on_200.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + _schema_on_200.tags = AAZDictType() + _schema_on_200.type = AAZStrType( + flags={"read_only": True}, + ) + + properties = cls._schema_on_200.properties + properties.api_endpoint = AAZStrType( + serialized_name="apiEndpoint", + flags={"read_only": True}, + ) + properties.content_endpoint = AAZStrType( + serialized_name="contentEndpoint", + flags={"read_only": True}, + ) + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + + system_data = cls._schema_on_200.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + tags = cls._schema_on_200.tags + tags.Element = AAZStrType() + + return cls._schema_on_200 + + +class _ShowHelper: + """Helper class for Show""" + + +__all__ = ["Show"] diff --git a/src/apr/azext_apr/aaz/latest/apr/registry/_update.py b/src/apr/azext_apr/aaz/latest/apr/registry/_update.py new file mode 100644 index 00000000000..212113547df --- /dev/null +++ b/src/apr/azext_apr/aaz/latest/apr/registry/_update.py @@ -0,0 +1,256 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- +# mypy: ignore-errors + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * +from azext_apr.server import raise_if_dev_extension + + +@register_command( + "apr registry update", +) +class Update(AAZCommand): + """Update a Registry""" + + _aaz_info = { + "version": "2026-04-01-preview", + "resources": [ + [ + "mgmt-plane", + "/subscriptions/{}/resourcegroups/{}/providers/microsoft.packageregistry/registries/{}", + "2026-04-01-preview", + ], + ], + } + + def _handler(self, command_args): + super()._handler(command_args) + self._execute_operations() + return self._output() + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.registry_name = AAZStrArg( + options=["-n", "--name", "--registry-name"], + help="The name of the registry. 3-24 characters of lowercase letters, digits, and hyphens; must start and end with a letter or digit.", + required=True, + id_part="name", + fmt=AAZStrArgFormat( + pattern="^[a-z0-9]([a-z0-9-]{1,22}[a-z0-9])$", + ), + ) + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + + # define Arg Group "Properties" + + _args_schema = cls._args_schema + _args_schema.tags = AAZDictArg( + options=["--tags"], + arg_group="Properties", + help="Resource tags.", + ) + + tags = cls._args_schema.tags + tags.Element = AAZStrArg() + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + self.RegistriesUpdate(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + raise_if_dev_extension() + + @register_callback + def post_operations(self): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True) + return result + + class RegistriesUpdate(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.PackageRegistry/registries/{registryName}", + **self.url_parameters, + ) + + @property + def method(self): + return "PATCH" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "registryName", + self.ctx.args.registry_name, + required=True, + ), + **self.serialize_url_param( + "resourceGroupName", + self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", + self.ctx.subscription_id, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", + "2026-04-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Content-Type", + "application/json", + ), + **self.serialize_header_param( + "Accept", + "application/json", + ), + } + return parameters + + @property + def content(self): + _content_value, _builder = self.new_content_builder( + self.ctx.args, + typ=AAZObjectType, + typ_kwargs={"flags": {"required": True, "client_flatten": True}}, + ) + _builder.set_prop("tags", AAZDictType, ".tags") + + tags = _builder.get(".tags") + if tags is not None: + tags.set_elements(AAZStrType, ".") + + return self.serialize_content(_content_value) + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var("instance", data, schema_builder=self._build_schema_on_200) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + + _schema_on_200 = cls._schema_on_200 + _schema_on_200.id = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200.location = AAZStrType( + flags={"required": True}, + ) + _schema_on_200.name = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200.properties = AAZObjectType() + _schema_on_200.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + _schema_on_200.tags = AAZDictType() + _schema_on_200.type = AAZStrType( + flags={"read_only": True}, + ) + + properties = cls._schema_on_200.properties + properties.api_endpoint = AAZStrType( + serialized_name="apiEndpoint", + flags={"read_only": True}, + ) + properties.content_endpoint = AAZStrType( + serialized_name="contentEndpoint", + flags={"read_only": True}, + ) + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + + system_data = cls._schema_on_200.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + tags = cls._schema_on_200.tags + tags.Element = AAZStrType() + + return cls._schema_on_200 + + +class _UpdateHelper: + """Helper class for Update""" + + +__all__ = ["Update"] diff --git a/src/apr/azext_apr/aaz/latest/apr/registry/_wait.py b/src/apr/azext_apr/aaz/latest/apr/registry/_wait.py new file mode 100644 index 00000000000..189035fe590 --- /dev/null +++ b/src/apr/azext_apr/aaz/latest/apr/registry/_wait.py @@ -0,0 +1,224 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- +# mypy: ignore-errors + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * +from azext_apr.server import raise_if_dev_extension + + +@register_command( + "apr registry wait", +) +class Wait(AAZWaitCommand): + """Place the CLI in a waiting state until a condition is met.""" + + _aaz_info = { + "resources": [ + [ + "mgmt-plane", + "/subscriptions/{}/resourcegroups/{}/providers/microsoft.packageregistry/registries/{}", + "2026-04-01-preview", + ], + ] + } + + def _handler(self, command_args): + super()._handler(command_args) + self._execute_operations() + return self._output() + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + # define Arg Group "" + + _args_schema = cls._args_schema + _args_schema.registry_name = AAZStrArg( + options=["-n", "--name", "--registry-name"], + help="The name of the registry. 3-24 characters of lowercase letters, digits, and hyphens; must start and end with a letter or digit.", + required=True, + id_part="name", + fmt=AAZStrArgFormat( + pattern="^[a-z0-9]([a-z0-9-]{1,22}[a-z0-9])$", + ), + ) + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + self.RegistriesGet(ctx=self.ctx)() + self.post_operations() + + @register_callback + def pre_operations(self): + raise_if_dev_extension() + + @register_callback + def post_operations(self): + pass + + def _output(self, *args, **kwargs): + result = self.deserialize_output(self.ctx.vars.instance, client_flatten=False) + return result + + class RegistriesGet(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200]: + return self.on_200(session) + + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.PackageRegistry/registries/{registryName}", + **self.url_parameters, + ) + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def url_parameters(self): + parameters = { + **self.serialize_url_param( + "registryName", + self.ctx.args.registry_name, + required=True, + ), + **self.serialize_url_param( + "resourceGroupName", + self.ctx.args.resource_group, + required=True, + ), + **self.serialize_url_param( + "subscriptionId", + self.ctx.subscription_id, + required=True, + ), + } + return parameters + + @property + def query_parameters(self): + parameters = { + **self.serialize_query_param( + "api-version", + "2026-04-01-preview", + required=True, + ), + } + return parameters + + @property + def header_parameters(self): + parameters = { + **self.serialize_header_param( + "Accept", + "application/json", + ), + } + return parameters + + def on_200(self, session): + data = self.deserialize_http_content(session) + self.ctx.set_var("instance", data, schema_builder=self._build_schema_on_200) + + _schema_on_200 = None + + @classmethod + def _build_schema_on_200(cls): + if cls._schema_on_200 is not None: + return cls._schema_on_200 + + cls._schema_on_200 = AAZObjectType() + + _schema_on_200 = cls._schema_on_200 + _schema_on_200.id = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200.location = AAZStrType( + flags={"required": True}, + ) + _schema_on_200.name = AAZStrType( + flags={"read_only": True}, + ) + _schema_on_200.properties = AAZObjectType() + _schema_on_200.system_data = AAZObjectType( + serialized_name="systemData", + flags={"read_only": True}, + ) + _schema_on_200.tags = AAZDictType() + _schema_on_200.type = AAZStrType( + flags={"read_only": True}, + ) + + properties = cls._schema_on_200.properties + properties.api_endpoint = AAZStrType( + serialized_name="apiEndpoint", + flags={"read_only": True}, + ) + properties.content_endpoint = AAZStrType( + serialized_name="contentEndpoint", + flags={"read_only": True}, + ) + properties.provisioning_state = AAZStrType( + serialized_name="provisioningState", + flags={"read_only": True}, + ) + + system_data = cls._schema_on_200.system_data + system_data.created_at = AAZStrType( + serialized_name="createdAt", + ) + system_data.created_by = AAZStrType( + serialized_name="createdBy", + ) + system_data.created_by_type = AAZStrType( + serialized_name="createdByType", + ) + system_data.last_modified_at = AAZStrType( + serialized_name="lastModifiedAt", + ) + system_data.last_modified_by = AAZStrType( + serialized_name="lastModifiedBy", + ) + system_data.last_modified_by_type = AAZStrType( + serialized_name="lastModifiedByType", + ) + + tags = cls._schema_on_200.tags + tags.Element = AAZStrType() + + return cls._schema_on_200 + + +class _WaitHelper: + """Helper class for Wait""" + + +__all__ = ["Wait"] diff --git a/src/apr/azext_apr/azext_metadata.json b/src/apr/azext_apr/azext_metadata.json new file mode 100644 index 00000000000..b87dc4a93ba --- /dev/null +++ b/src/apr/azext_apr/azext_metadata.json @@ -0,0 +1,4 @@ +{ + "azext.minCliCoreVersion": "2.55.0", + "azext.isPreview": true +} diff --git a/src/apr/azext_apr/commands/__init__.py b/src/apr/azext_apr/commands/__init__.py new file mode 100644 index 00000000000..550dfafea65 --- /dev/null +++ b/src/apr/azext_apr/commands/__init__.py @@ -0,0 +1,36 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azext_apr.commands import ( # registers helps on import + distro, + package, + publication, + remote, + repository, + repository_package, + repository_release, + task, +) + +_MODULES = ( + repository, + repository_package, + repository_release, + remote, + distro, + package, + publication, + task, +) + + +def load_command_table(loader, _args): + for mod in _MODULES: + mod.register_commands(loader) + + +def load_arguments(loader, command): + for mod in _MODULES: + mod.load_arguments(loader, command) diff --git a/src/apr/azext_apr/commands/distro.py b/src/apr/azext_apr/commands/distro.py new file mode 100644 index 00000000000..c7878fda4e3 --- /dev/null +++ b/src/apr/azext_apr/commands/distro.py @@ -0,0 +1,253 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azure.cli.core.azclierror import ( + MutuallyExclusiveArgumentError, + RequiredArgumentMissingError, +) +from azure.cli.core.commands import CliCommandType +from azure.cli.core.commands.parameters import ( + get_enum_type, + get_three_state_flag, + resource_group_name_type, +) +from knack.help_files import helps + +from azext_apr.server import DataPlaneClient, list_all, resolve_id_or_name, wait_for_task + +DISTRO_TYPES = ["apt", "yum", "file"] + + +helps["apr distro"] = """ +type: group +short-summary: Manage distributions that serve a repository's content. +""" + +helps["apr distro list"] = """ +type: command +short-summary: List distributions. +examples: + - name: List all distributions in a registry. + text: az apr distro list -g myrg -r myregistry + - name: List distributions serving a given repository. + text: az apr distro list -g myrg -r myregistry --repository myrepo +""" + +helps["apr distro create"] = """ +type: command +short-summary: Create a distribution. +examples: + - name: Create an apt distribution serving a repository. + text: | + az apr distro create -g myrg -r myregistry -n mydistro \ + --type apt --base-path dists/stable --repository myrepo +""" + +helps["apr distro show"] = """ +type: command +short-summary: Show details for a distribution. +examples: + - name: Show a distribution by name. + text: az apr distro show -g myrg -r myregistry -n mydistro +""" + +helps["apr distro update"] = """ +type: command +short-summary: Update a distribution. +examples: + - name: Change the base path a distribution is served at. + text: az apr distro update -g myrg -r myregistry -n mydistro --base-path dists/next +""" + +helps["apr distro delete"] = """ +type: command +short-summary: Delete a distribution. +examples: + - name: Delete a distribution by name. + text: az apr distro delete -g myrg -r myregistry -n mydistro +""" + + +def register_commands(loader): + distro_type = CliCommandType(operations_tmpl="azext_apr.commands.distro#{}") + with loader.command_group("apr distro", custom_command_type=distro_type, is_preview=True) as g: + g.custom_command("list", "list_distros") + g.custom_command("create", "create_distro", supports_no_wait=True) + g.custom_show_command("show", "show_distro") + g.custom_command("update", "update_distro", supports_no_wait=True) + g.custom_command("delete", "delete_distro", confirmation=True, supports_no_wait=True) + + +def load_arguments(loader, _command): + with loader.argument_context("apr distro") as c: + c.argument("resource_group_name", resource_group_name_type) + c.argument( + "registry_name", + options_list=["--registry", "-r"], + help="Name of the parent APR registry.", + ) + c.argument( + "distro_name", + options_list=["--name", "-n"], + help="Name or ID of the distribution.", + ) + + with loader.argument_context("apr distro create") as c: + c.argument( + "distro_type", + options_list=["--type"], + arg_type=get_enum_type(DISTRO_TYPES), + help="Distribution type.", + ) + + for scope in ("apr distro create", "apr distro update"): + with loader.argument_context(scope) as c: + c.argument( + "base_path", + options_list=["--base-path"], + help="Base path the distribution is served at.", + ) + c.argument( + "repository", + options_list=["--repository"], + help="Name or ID of the repository to serve. The distribution follows " + "the repository's latest publication automatically. Cannot be used " + "with --publication.", + ) + c.argument( + "publication", + options_list=["--publication"], + help="ID of a specific publication to serve as a fixed snapshot. " + "Cannot be used with --repository.", + ) + c.argument( + "hidden", + options_list=["--hidden"], + arg_type=get_three_state_flag(), + help="Mark the distribution as hidden.", + ) + + with loader.argument_context("apr distro list") as c: + c.ignore("distro_name") + c.argument( + "repository", + options_list=["--repository"], + help="Name or ID of a repository to filter distributions by.", + ) + c.argument( + "limit", + type=int, + help="Max number of distributions to return. Omit to return all.", + ) + + +def _client(cmd, registry_name, resource_group_name): + return DataPlaneClient.for_registry(cmd, registry_name, resource_group_name) + + +def _distro_from_task(client, result, fallback_id=None): + """Return the distribution the task acted on, not the raw task. + + Create tasks carry the new id in ``created_resources``; update falls back to + the known id. A ``--no-wait`` handle (``{"task": ...}``) is left untouched. + """ + if isinstance(result, dict) and result.get("task"): + return result + created = result.get("created_resources") if isinstance(result, dict) else None + distro_id = (created or [None])[0] or fallback_id + if distro_id is None: + return result + return client.get(f"/distributions/{distro_id}/").json() + + +def list_distros(cmd, registry_name, repository=None, resource_group_name=None, limit=None): + client = _client(cmd, registry_name, resource_group_name) + params = None + if repository: + params = {"repository": resolve_id_or_name(client, "repositories", repository)} + return list_all(client, "/distributions/", max_items=limit, params=params) + + +def _validate_backing(repository, publication): + if repository is not None and publication is not None: + raise MutuallyExclusiveArgumentError( + "Pass only one of --repository or --publication; " + "a distribution serves one or the other." + ) + + +def create_distro( + cmd, + registry_name, + distro_name, + distro_type, + base_path, + repository=None, + publication=None, + hidden=None, + resource_group_name=None, + no_wait=False, +): + _validate_backing(repository, publication) + client = _client(cmd, registry_name, resource_group_name) + data = { + "name": distro_name, + "type": distro_type, + "base_path": base_path, + } + if hidden is not None: + data["hidden"] = hidden + if repository is not None: + data["repository"] = resolve_id_or_name(client, "repositories", repository) + if publication is not None: + data["publication"] = publication + resp = client.post("/distributions/", json=data) + result = wait_for_task(client, resp, no_wait) + return _distro_from_task(client, result) + + +def show_distro(cmd, registry_name, distro_name, resource_group_name=None): + client = _client(cmd, registry_name, resource_group_name) + distro_id = resolve_id_or_name(client, "distributions", distro_name) + return client.get(f"/distributions/{distro_id}/").json() + + +def update_distro( + cmd, + registry_name, + distro_name, + base_path=None, + repository=None, + publication=None, + hidden=None, + resource_group_name=None, + no_wait=False, +): + _validate_backing(repository, publication) + client = _client(cmd, registry_name, resource_group_name) + data = {} + if base_path: + data["base_path"] = base_path + if repository is not None: + data["repository"] = resolve_id_or_name(client, "repositories", repository) + if publication is not None: + data["publication"] = publication + if hidden is not None: + data["hidden"] = hidden + if not data: + raise RequiredArgumentMissingError( + "Nothing to update - pass --base-path, --repository, --publication, or --hidden." + ) + distro_id = resolve_id_or_name(client, "distributions", distro_name) + resp = client.patch(f"/distributions/{distro_id}/", json=data) + result = wait_for_task(client, resp, no_wait) + return _distro_from_task(client, result, fallback_id=distro_id) + + +def delete_distro(cmd, registry_name, distro_name, resource_group_name=None, no_wait=False): + client = _client(cmd, registry_name, resource_group_name) + distro_id = resolve_id_or_name(client, "distributions", distro_name) + resp = client.delete(f"/distributions/{distro_id}/") + return wait_for_task(client, resp, no_wait) diff --git a/src/apr/azext_apr/commands/package.py b/src/apr/azext_apr/commands/package.py new file mode 100644 index 00000000000..e3cf317f6c8 --- /dev/null +++ b/src/apr/azext_apr/commands/package.py @@ -0,0 +1,385 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from pathlib import Path + +from azure.cli.core.azclierror import ( + InvalidArgumentValueError, + RequiredArgumentMissingError, +) +from azure.cli.core.commands import CliCommandType +from azure.cli.core.commands.parameters import ( + get_enum_type, + resource_group_name_type, +) +from knack.help_files import helps +from knack.log import get_logger + +from azext_apr.server import ( + DataPlaneClient, + list_all, + resolve_id_or_name, + resolve_release, + wait_for_task, +) + +logger = get_logger(__name__) + +PACKAGE_TYPES = ["deb", "debsrc", "rpm", "file"] +UPLOAD_PACKAGE_TYPES = ["deb", "rpm", "file"] +# Types whose --release is an apt suite resolved against a repository. rpm's +# --release is a plain string; file has no release concept, so it gets no flag. +_APT_TYPES = ("deb", "debsrc") +# Types that carry a package version and architecture (file has neither). +_VERSIONED_TYPES = ("deb", "debsrc", "rpm") + +_TYPE_SUMMARY = { + "deb": "deb packages", + "debsrc": "deb source packages", + "rpm": "rpm packages", + "file": "file packages", +} + + +helps["apr package"] = """ +type: group +short-summary: Upload and inspect packages. +""" + +helps["apr package upload"] = """ +type: command +short-summary: Upload a package. +examples: + - name: Upload a package without adding it to a repository. + text: | + az apr package upload -g myrg -r myregistry --file ./my-package.deb + - name: Upload a deb package to a repository. + text: | + az apr package upload -g myrg -r myregistry --repository myrepo \ + --release jammy --file ./my-package.deb + - name: Upload a deb package to a component of an apt repository. + text: | + az apr package upload -g myrg -r myregistry --repository myrepo \ + --release jammy --component contrib --file ./my-package.deb + - name: Upload an RPM package to a repository. + text: | + az apr package upload -g myrg -r myregistry --repository myrepo \ + --file ./my-package.rpm + - name: Upload a file package. + text: | + az apr package upload -g myrg -r myregistry --repository myrepo \ + --file ./settings --type file +""" + +for _ptype, _summary in _TYPE_SUMMARY.items(): + helps[f"apr package {_ptype}"] = f""" +type: group +short-summary: Manage {_summary}. +""" + helps[f"apr package {_ptype} list"] = f""" +type: command +short-summary: List {_summary}. +""" + helps[f"apr package {_ptype} show"] = """ +type: command +short-summary: Show details for a package. +""" + + +def register_commands(loader): + package_type = CliCommandType(operations_tmpl="azext_apr.commands.package#{}") + with loader.command_group( + "apr package", custom_command_type=package_type, is_preview=True + ) as g: + g.custom_command("upload", "upload_package", supports_no_wait=True) + # Per-type subgroups keep each command's filters scoped to the type it lists, + # instead of one --type flag carrying every type's options. + for ptype in PACKAGE_TYPES: + with loader.command_group( + f"apr package {ptype}", custom_command_type=package_type, is_preview=True + ) as g: + g.custom_command("list", f"list_{ptype}_packages") + g.custom_show_command("show", "show_package") + + +def load_arguments(loader, _command): + with loader.argument_context("apr package") as c: + c.argument("resource_group_name", resource_group_name_type) + c.argument( + "registry_name", + options_list=["--registry", "-r"], + help="Name of the parent APR registry.", + ) + + with loader.argument_context("apr package upload") as c: + c.argument("package", options_list=["--file"], help="Path to the package file to upload.") + c.argument( + "repository", + options_list=["--repository"], + help=( + "Name or ID of the target repository. Omit to upload without adding it to a " + "repository." + ), + ) + c.argument( + "file_type", + options_list=["--type", "-t"], + arg_type=get_enum_type(UPLOAD_PACKAGE_TYPES), + help=( + "Package type. Omit for .deb and .rpm files. Use --type file for arbitrary files." + ), + ) + c.argument( + "relative_path", + options_list=["--relative-path"], + help="Path to store a file package at.", + ) + c.argument( + "release", + options_list=["--release"], + help="Release name or ID for a deb package added to an apt repository.", + ) + c.argument( + "component", + options_list=["--component"], + help="Release component for a deb package added to an apt repository.", + ) + + for ptype in PACKAGE_TYPES: + with loader.argument_context(f"apr package {ptype} show") as c: + c.argument("package_id", options_list=["--id"], help="Package id.") + + with loader.argument_context(f"apr package {ptype} list") as c: + c.argument( + "repository", + options_list=["--repository"], + help="Filter packages by repository (name or ID).", + ) + c.argument("name", options_list=["--name"], help="Filter packages by name.") + c.argument("sha256", options_list=["--sha256"], help="Filter packages by sha256 sum.") + c.argument( + "limit", + type=int, + help="Max number of packages to return. Omit to return all.", + ) + + for ptype in _APT_TYPES: + with loader.argument_context(f"apr package {ptype} list") as c: + c.argument( + "release", + options_list=["--release"], + help="Filter by apt release (name or ID, resolved within --repository).", + ) + + for ptype in _VERSIONED_TYPES: + with loader.argument_context(f"apr package {ptype} list") as c: + c.argument( + "version", + options_list=["--version"], + help="Filter packages by exact version.", + ) + c.argument( + "arch", + options_list=["--arch"], + help="Filter packages by architecture.", + ) + + with loader.argument_context("apr package rpm list") as c: + c.argument( + "release", + options_list=["--release"], + help="Filter by the package release string.", + ) + + +def _client(cmd, registry_name, resource_group_name): + return DataPlaneClient.for_registry(cmd, registry_name, resource_group_name) + + +def upload_package( + cmd, + registry_name, + package, + repository=None, + file_type=None, + relative_path=None, + release=None, + component=None, + resource_group_name=None, + no_wait=False, +): + package_path = Path(package) + if not package_path.is_file(): + raise InvalidArgumentValueError( + f"Pass --file with an existing regular file: {package_path}" + ) + inferred_type = file_type or {".deb": "deb", ".rpm": "rpm"}.get(package_path.suffix.lower()) + if release is not None or component is not None: + if repository is None: + raise RequiredArgumentMissingError("--release and --component require --repository.") + if inferred_type != "deb": + raise InvalidArgumentValueError( + "--release and --component are only valid for deb package uploads." + ) + if repository is not None and inferred_type == "deb" and release is None: + raise RequiredArgumentMissingError( + "Pass --release when adding a deb package to a repository." + ) + client = _client(cmd, registry_name, resource_group_name) + fields = {} + if repository is not None: + repository_id = resolve_id_or_name(client, "repositories", repository) + else: + repository_id = None + logger.warning( + "Package upload is unattached and subject to configured orphan cleanup. " + "Add it to a repository promptly." + ) + if inferred_type == "deb" and repository_id is not None: + release_id = resolve_release(client, repository_id, release) + fields["repository"] = repository_id + fields["release"] = release_id + if component is not None: + fields["component"] = component + elif repository_id is not None: + fields["repository"] = repository_id + if file_type is not None: + fields["file_type"] = file_type + if relative_path is not None: + fields["relative_path"] = relative_path + resp = client.post_multipart("/packages/", fields=fields, file_path=package_path) + return wait_for_task(client, resp, no_wait) + + +def show_package(cmd, registry_name, package_id, resource_group_name=None): + client = _client(cmd, registry_name, resource_group_name) + # The server resolves a package by id directly - no name lookup. + return client.get(f"/packages/{package_id}/").json() + + +def _list(cmd, registry_name, package_type, resource_group_name, limit, repository, name, sha256): + """Shared package-list plumbing: resolve the repo filter and page the results. + + Returns the client, the resolved repository id (or None), and the params + dict so per-type wrappers can add their own filters (e.g. --release). + """ + del package_type, limit + client = _client(cmd, registry_name, resource_group_name) + params = {} + repository_id = resolve_id_or_name(client, "repositories", repository) if repository else None + if repository_id: + params["repository"] = repository_id + if name: + params["name"] = name + if sha256: + params["sha256"] = sha256 + return client, repository_id, params + + +def _run_list(client, package_type, params, limit): + return list_all(client, f"/{package_type}/packages/", max_items=limit, params=params or None) + + +def _resolve_apt_release(client, repository_id, release): + """Resolve an apt --release to an id, requiring --repository to scope it. + + The server filters apt packages by a composite "," + value, so a release can't be applied without a repository (an id would also + pass resolve_release, so guard here rather than relying on the name lookup). + """ + if not repository_id: + raise RequiredArgumentMissingError("Filtering by --release requires --repository.") + return resolve_release(client, repository_id, release) + + +def _add_nevra(params, version=None, arch=None): + """Add the version/architecture filters when supplied. + + The server unifies naming, so ``arch`` is sent as-is (it remaps to pulp_deb's + ``architecture`` for deb types); ``version`` is a native filter everywhere. + """ + if version: + params["version"] = version + if arch: + params["arch"] = arch + + +def list_deb_packages( + cmd, + registry_name, + repository=None, + name=None, + sha256=None, + release=None, + version=None, + arch=None, + resource_group_name=None, + limit=None, +): + client, repository_id, params = _list( + cmd, registry_name, "deb", resource_group_name, limit, repository, name, sha256 + ) + if release: + params["release"] = _resolve_apt_release(client, repository_id, release) + _add_nevra(params, version=version, arch=arch) + return _run_list(client, "deb", params, limit) + + +def list_debsrc_packages( + cmd, + registry_name, + repository=None, + name=None, + sha256=None, + release=None, + version=None, + arch=None, + resource_group_name=None, + limit=None, +): + client, repository_id, params = _list( + cmd, registry_name, "debsrc", resource_group_name, limit, repository, name, sha256 + ) + if release: + params["release"] = _resolve_apt_release(client, repository_id, release) + _add_nevra(params, version=version, arch=arch) + return _run_list(client, "debsrc", params, limit) + + +def list_rpm_packages( + cmd, + registry_name, + repository=None, + name=None, + sha256=None, + release=None, + version=None, + arch=None, + resource_group_name=None, + limit=None, +): + client, _repository_id, params = _list( + cmd, registry_name, "rpm", resource_group_name, limit, repository, name, sha256 + ) + # rpm release is a plain NVR field, not a resource - pass it straight through. + if release: + params["release"] = release + _add_nevra(params, version=version, arch=arch) + return _run_list(client, "rpm", params, limit) + + +def list_file_packages( + cmd, + registry_name, + repository=None, + name=None, + sha256=None, + resource_group_name=None, + limit=None, +): + client, _repository_id, params = _list( + cmd, registry_name, "file", resource_group_name, limit, repository, name, sha256 + ) + return _run_list(client, "file", params, limit) diff --git a/src/apr/azext_apr/commands/publication.py b/src/apr/azext_apr/commands/publication.py new file mode 100644 index 00000000000..aa2aa707820 --- /dev/null +++ b/src/apr/azext_apr/commands/publication.py @@ -0,0 +1,99 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azure.cli.core.commands import CliCommandType +from azure.cli.core.commands.parameters import resource_group_name_type +from knack.help_files import helps + +from azext_apr.server import DataPlaneClient, list_all, resolve_id_or_name, wait_for_task + +helps["apr publication"] = """ +type: group +short-summary: View and delete publications. +""" + +helps["apr publication list"] = """ +type: command +short-summary: List publications. +examples: + - name: List all publications in a registry. + text: az apr publication list -g myrg -r myregistry + - name: List publications for a given repository. + text: az apr publication list -g myrg -r myregistry --repository myrepo +""" + +helps["apr publication show"] = """ +type: command +short-summary: Show details for a publication. +examples: + - name: Show a publication by id. + text: az apr publication show -g myrg -r myregistry --id mypublicationid +""" + +helps["apr publication delete"] = """ +type: command +short-summary: Delete a publication. +examples: + - name: Delete a publication by id. + text: az apr publication delete -g myrg -r myregistry --id mypublicationid +""" + + +def register_commands(loader): + publication_type = CliCommandType(operations_tmpl="azext_apr.commands.publication#{}") + with loader.command_group( + "apr publication", custom_command_type=publication_type, is_preview=True + ) as g: + g.custom_command("list", "list_publications") + g.custom_show_command("show", "show_publication") + g.custom_command("delete", "delete_publication", confirmation=True, supports_no_wait=True) + + +def load_arguments(loader, _command): + with loader.argument_context("apr publication") as c: + c.argument("resource_group_name", resource_group_name_type) + c.argument( + "registry_name", + options_list=["--registry", "-r"], + help="Name of the parent APR registry.", + ) + # Publications have no name; they're referenced by id. + c.argument("publication_id", options_list=["--id"], help="Publication id.") + + with loader.argument_context("apr publication list") as c: + c.ignore("publication_id") + c.argument( + "repository", + options_list=["--repository"], + help="Filter publications by repository (Name or ID).", + ) + c.argument( + "limit", + type=int, + help="Max number of publications to return. Omit to return all.", + ) + + +def _client(cmd, registry_name, resource_group_name): + return DataPlaneClient.for_registry(cmd, registry_name, resource_group_name) + + +def list_publications(cmd, registry_name, repository=None, resource_group_name=None, limit=None): + client = _client(cmd, registry_name, resource_group_name) + params = None + if repository: + params = {"repository": resolve_id_or_name(client, "repositories", repository)} + return list_all(client, "/publications/", max_items=limit, params=params) + + +def show_publication(cmd, registry_name, publication_id, resource_group_name=None): + client = _client(cmd, registry_name, resource_group_name) + return client.get(f"/publications/{publication_id}/").json() + + +def delete_publication(cmd, registry_name, publication_id, resource_group_name=None, no_wait=False): + client = _client(cmd, registry_name, resource_group_name) + resp = client.delete(f"/publications/{publication_id}/") + return wait_for_task(client, resp, no_wait) diff --git a/src/apr/azext_apr/commands/remote.py b/src/apr/azext_apr/commands/remote.py new file mode 100644 index 00000000000..1af23b83425 --- /dev/null +++ b/src/apr/azext_apr/commands/remote.py @@ -0,0 +1,237 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azure.cli.core.azclierror import RequiredArgumentMissingError +from azure.cli.core.commands import CliCommandType +from azure.cli.core.commands.parameters import ( + get_enum_type, + resource_group_name_type, +) +from knack.help_files import helps + +from azext_apr.server import DataPlaneClient, list_all, resolve_id_or_name, wait_for_task + +REMOTE_TYPES = ["apt", "yum"] +DOWNLOAD_POLICIES = ["immediate", "on-demand", "streamed"] + + +helps["apr remote"] = """ +type: group +short-summary: Manage upstream remotes that repositories sync from. +""" + +helps["apr remote list"] = """ +type: command +short-summary: List remotes. +examples: + - name: List all remotes in a registry. + text: az apr remote list -g myrg -r myregistry +""" + +helps["apr remote create"] = """ +type: command +short-summary: Create a remote. +examples: + - name: Create an apt remote syncing selected distributions and components. + text: | + az apr remote create -g myrg -r myregistry -n myremote --type apt \ + --url http://archive.ubuntu.com/ubuntu \ + --releases jammy,focal --components main,universe + - name: Create a yum remote. + text: | + az apr remote create -g myrg -r myregistry -n myremote --type yum \ + --url https://packages.microsoft.com/rhel/9/prod --policy on-demand +""" + +helps["apr remote show"] = """ +type: command +short-summary: Show details for a remote. +examples: + - name: Show a remote by name. + text: az apr remote show -g myrg -r myregistry -n myremote +""" + +helps["apr remote update"] = """ +type: command +short-summary: Update a remote. +examples: + - name: Change the upstream URL a remote syncs from. + text: az apr remote update -g myrg -r myregistry -n myremote --url http://archive.ubuntu.com/ubuntu + - name: Download packages only when clients request them. + text: az apr remote update -g myrg -r myregistry -n myremote --policy on-demand +""" + +helps["apr remote delete"] = """ +type: command +short-summary: Delete a remote. +examples: + - name: Delete a remote by name. + text: az apr remote delete -g myrg -r myregistry -n myremote +""" + + +def register_commands(loader): + remote_type = CliCommandType(operations_tmpl="azext_apr.commands.remote#{}") + with loader.command_group("apr remote", custom_command_type=remote_type, is_preview=True) as g: + g.custom_command("list", "list_remotes") + g.custom_command("create", "create_remote", supports_no_wait=True) + g.custom_show_command("show", "show_remote") + g.custom_command("update", "update_remote", supports_no_wait=True) + g.custom_command("delete", "delete_remote", confirmation=True, supports_no_wait=True) + + +def load_arguments(loader, _command): + with loader.argument_context("apr remote") as c: + c.argument("resource_group_name", resource_group_name_type) + c.argument( + "registry_name", + options_list=["--registry", "-r"], + help="Name of the parent APR registry.", + ) + c.argument( + "remote_name", + options_list=["--name", "-n"], + help="Name or ID of the remote.", + ) + + with loader.argument_context("apr remote create") as c: + c.argument( + "remote_type", + options_list=["--type"], + arg_type=get_enum_type(REMOTE_TYPES), + help="Remote type.", + ) + c.argument("url", options_list=["--url"], help="Upstream URL to sync from.") + + with loader.argument_context("apr remote update") as c: + c.argument("url", options_list=["--url"], help="Upstream URL to sync from.") + + for scope in ("apr remote create", "apr remote update"): + with loader.argument_context(scope) as c: + c.argument( + "policy", + options_list=["--policy"], + arg_type=get_enum_type(DOWNLOAD_POLICIES), + help=( + "How packages are fetched from upstream. immediate downloads packages during " + "sync; on-demand downloads and stores each package when first requested; " + "streamed fetches each package on every request without storing it." + ), + ) + + # Comma-separated apt release lists. These have no analog for yum remotes. + for scope in ("apr remote create", "apr remote update"): + with loader.argument_context(scope) as c: + c.argument( + "releases", + options_list=["--releases"], + help="Comma-separated upstream releases to sync (apt remotes only).", + ) + c.argument( + "components", + options_list=["--components"], + help="Comma-separated components to sync (apt remotes only).", + ) + c.argument( + "architectures", + options_list=["--architectures"], + help="Comma-separated architectures to sync (apt remotes only).", + ) + + with loader.argument_context("apr remote list") as c: + c.ignore("remote_name") + c.argument( + "limit", + type=int, + help="Max number of remotes to return. Omit to return all.", + ) + + +def _client(cmd, registry_name, resource_group_name): + return DataPlaneClient.for_registry(cmd, registry_name, resource_group_name) + + +def _split(value): + # comma-separated CLI value -> list of trimmed, non-empty items + return [item.strip() for item in value.split(",") if item.strip()] + + +def list_remotes(cmd, registry_name, resource_group_name=None, limit=None): + client = _client(cmd, registry_name, resource_group_name) + return list_all(client, "/remotes/", max_items=limit) + + +def create_remote( + cmd, + registry_name, + remote_name, + remote_type, + url, + releases=None, + components=None, + architectures=None, + resource_group_name=None, + no_wait=False, + policy=None, +): + client = _client(cmd, registry_name, resource_group_name) + data = {"name": remote_name, "type": remote_type, "url": url} + if policy: + data["policy"] = policy.replace("-", "_") + if releases: + data["releases"] = _split(releases) + if components: + data["components"] = _split(components) + if architectures: + data["architectures"] = _split(architectures) + resp = client.post("/remotes/", json=data) + return wait_for_task(client, resp, no_wait) + + +def show_remote(cmd, registry_name, remote_name, resource_group_name=None): + client = _client(cmd, registry_name, resource_group_name) + remote_id = resolve_id_or_name(client, "remotes", remote_name) + return client.get(f"/remotes/{remote_id}/").json() + + +def update_remote( + cmd, + registry_name, + remote_name, + url=None, + releases=None, + components=None, + architectures=None, + resource_group_name=None, + no_wait=False, + policy=None, +): + data = {} + if url: + data["url"] = url + if policy: + data["policy"] = policy.replace("-", "_") + if releases: + data["releases"] = _split(releases) + if components: + data["components"] = _split(components) + if architectures: + data["architectures"] = _split(architectures) + if not data: + raise RequiredArgumentMissingError( + "Nothing to update - pass --url, --policy, --releases, --components, " + "or --architectures." + ) + client = _client(cmd, registry_name, resource_group_name) + remote_id = resolve_id_or_name(client, "remotes", remote_name) + resp = client.patch(f"/remotes/{remote_id}/", json=data) + return wait_for_task(client, resp, no_wait) + + +def delete_remote(cmd, registry_name, remote_name, resource_group_name=None, no_wait=False): + client = _client(cmd, registry_name, resource_group_name) + remote_id = resolve_id_or_name(client, "remotes", remote_name) + resp = client.delete(f"/remotes/{remote_id}/") + return wait_for_task(client, resp, no_wait) diff --git a/src/apr/azext_apr/commands/repository.py b/src/apr/azext_apr/commands/repository.py new file mode 100644 index 00000000000..100e010e1cc --- /dev/null +++ b/src/apr/azext_apr/commands/repository.py @@ -0,0 +1,330 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azure.cli.core.azclierror import ( + MutuallyExclusiveArgumentError, + RequiredArgumentMissingError, +) +from azure.cli.core.commands import CliCommandType +from azure.cli.core.commands.parameters import ( + get_enum_type, + resource_group_name_type, +) +from knack.help_files import helps +from knack.log import get_logger +from knack.prompting import NoTTYException, prompt_y_n + +from azext_apr.server import DataPlaneClient, list_all, resolve_id_or_name, wait_for_task + +logger = get_logger(__name__) + +REPOSITORY_TYPES = ["apt", "yum", "file"] +SYNC_MODES = ["additive", "mirror"] + + +helps["apr repository"] = """ +type: group +short-summary: Manage repositories within an APR registry. +""" + +helps["apr repository list"] = """ +type: command +short-summary: List repositories in a registry. +examples: + - name: List all repositories in a registry. + text: az apr repository list -g myrg -r myregistry +""" + +helps["apr repository create"] = """ +type: command +short-summary: Create a new repository (apt, yum, or file) within a registry. +examples: + - name: Create an apt repository. + text: az apr repository create -g myrg -r myregistry -n myrepo --type apt + - name: Create a repository that keeps the last 10 versions. + text: az apr repository create -g myrg -r myregistry -n myrepo --type apt --retain-versions 10 + - name: Create a repository that keeps every version. + text: az apr repository create -g myrg -r myregistry -n myrepo --type apt --retain-all +""" + +helps["apr repository show"] = """ +type: command +short-summary: Show repository details. +examples: + - name: Show a repository by name. + text: az apr repository show -g myrg -r myregistry -n myrepo +""" + +helps["apr repository update"] = """ +type: command +short-summary: Update repository configuration. +examples: + - name: Bind a repository to a remote it syncs from. + text: az apr repository update -g myrg -r myregistry -n myrepo --remote myremote + - name: Unset the remote. + text: az apr repository update -g myrg -r myregistry -n myrepo --remote "" + - name: Change how many versions the repository keeps. + text: az apr repository update -g myrg -r myregistry -n myrepo --retain-versions 3 + - name: Keep every version. + text: az apr repository update -g myrg -r myregistry -n myrepo --retain-all +""" + +helps["apr repository delete"] = """ +type: command +short-summary: Delete a repository and its contents. +examples: + - name: Delete a repository by name. + text: az apr repository delete -g myrg -r myregistry -n myrepo +""" + +helps["apr repository sync"] = """ +type: command +short-summary: Sync packages from an upstream remote into this repository. +examples: + - name: Sync a repository from its bound remote. + text: az apr repository sync -g myrg -r myregistry -n myrepo + - name: Add upstream content without removing existing packages. + text: az apr repository sync -g myrg -r myregistry -n myrepo --sync-mode additive + - name: Make the repository match the remote, removing anything not upstream. + text: az apr repository sync -g myrg -r myregistry -n myrepo --sync-mode mirror +""" + +helps["apr repository publish"] = """ +type: command +short-summary: Publish repository metadata (regenerate, sign, push to CDN). +examples: + - name: Publish a repository. + text: az apr repository publish -g myrg -r myregistry -n myrepo + - name: Publish even when no changes are detected. + text: az apr repository publish -g myrg -r myregistry -n myrepo --force +""" + + +def _confirm_mirror_sync(command_args): + if command_args.get("sync_mode") != "mirror" or command_args.get("confirm"): + return True + + try: + return prompt_y_n( + "Mirror sync removes repository packages that are not present upstream. Continue?" + ) + except NoTTYException: + logger.warning("Unable to prompt for confirmation as no tty available. Use --confirm.") + return False + + +def register_commands(loader): + repository_type = CliCommandType(operations_tmpl="azext_apr.commands.repository#{}") + with loader.command_group( + "apr repository", custom_command_type=repository_type, is_preview=True + ) as g: + g.custom_command("list", "list_repositories") + g.custom_command("create", "create_repository") + g.custom_show_command("show", "show_repository") + g.custom_command("update", "update_repository", supports_no_wait=True) + g.custom_command("delete", "delete_repository", confirmation=True, supports_no_wait=True) + g.custom_command( + "sync", + "sync_repository", + confirmation=_confirm_mirror_sync, + supports_no_wait=True, + ) + g.custom_command("publish", "publish_repository", supports_no_wait=True) + + +def load_arguments(loader, _command): + with loader.argument_context("apr repository") as c: + c.argument("resource_group_name", resource_group_name_type) + c.argument( + "registry_name", + options_list=["--registry", "-r"], + help="Name of the parent APR registry.", + ) + c.argument( + "repository_name", + options_list=["--name", "-n"], + help="Name or ID of the repository.", + ) + + with loader.argument_context("apr repository create") as c: + c.argument( + "repository_type", + options_list=["--type"], + arg_type=get_enum_type(REPOSITORY_TYPES), + help="Repository type.", + ) + c.argument( + "retain_versions", + options_list=["--retain-versions"], + type=int, + help="Number of repository versions to keep; older ones are pruned. Defaults to 5.", + ) + c.argument( + "retain_all", + options_list=["--retain-all"], + action="store_true", + help="Keep every repository version. Mutually exclusive with --retain-versions.", + ) + + with loader.argument_context("apr repository update") as c: + c.argument( + "remote_name", + options_list=["--remote"], + help="Name or ID of the remote this repository syncs from. Empty string unsets it.", + ) + c.argument( + "retain_versions", + options_list=["--retain-versions"], + type=int, + help=( + "Number of repository versions to keep; older ones are pruned. " + "Omit to leave unchanged." + ), + ) + c.argument( + "retain_all", + options_list=["--retain-all"], + action="store_true", + help="Keep every repository version. Mutually exclusive with --retain-versions.", + ) + + with loader.argument_context("apr repository list") as c: + c.ignore("repository_name") + c.argument( + "limit", + type=int, + help="Max number of repositories to return. Omit to return all.", + ) + + with loader.argument_context("apr repository publish") as c: + c.argument( + "force", + options_list=["--force"], + action="store_true", + help="Publish even if no changes are detected.", + ) + + with loader.argument_context("apr repository sync") as c: + c.argument( + "sync_mode", + options_list=["--sync-mode"], + arg_type=get_enum_type(SYNC_MODES), + help=( + "How upstream content is applied to the repository. additive (the " + "default) only adds; mirror also removes anything not upstream." + ), + ) + c.argument( + "confirm", + options_list=["--confirm"], + action="store_true", + help="Confirm a destructive mirror sync without prompting.", + ) + + +def _client(cmd, registry_name, resource_group_name): + return DataPlaneClient.for_registry(cmd, registry_name, resource_group_name) + + +def list_repositories(cmd, registry_name, resource_group_name=None, limit=None): + client = _client(cmd, registry_name, resource_group_name) + return list_all(client, "/repositories/", max_items=limit) + + +def create_repository( + cmd, + registry_name, + repository_name, + repository_type, + retain_versions=None, + retain_all=False, + resource_group_name=None, +): + if retain_all and retain_versions is not None: + raise MutuallyExclusiveArgumentError( + "Pass either --retain-versions or --retain-all, not both." + ) + client = _client(cmd, registry_name, resource_group_name) + data = {"name": repository_name, "type": repository_type} + # --retain-all sends null (keep every version); a count overrides it; omitting + # both lets the server apply its default (5). + if retain_all: + data["retain_repo_versions"] = None + elif retain_versions is not None: + data["retain_repo_versions"] = retain_versions + return client.post("/repositories/", json=data).json() + + +def show_repository(cmd, registry_name, repository_name, resource_group_name=None): + client = _client(cmd, registry_name, resource_group_name) + repository_id = resolve_id_or_name(client, "repositories", repository_name) + return client.get(f"/repositories/{repository_id}/").json() + + +def update_repository( + cmd, + registry_name, + repository_name, + remote_name=None, + retain_versions=None, + retain_all=False, + resource_group_name=None, + no_wait=False, +): + if retain_all and retain_versions is not None: + raise MutuallyExclusiveArgumentError( + "Pass either --retain-versions or --retain-all, not both." + ) + if remote_name is None and retain_versions is None and not retain_all: + raise RequiredArgumentMissingError( + "Nothing to update - pass --remote, --retain-versions, or --retain-all." + ) + client = _client(cmd, registry_name, resource_group_name) + repository_id = resolve_id_or_name(client, "repositories", repository_name) + data = {} + if remote_name is not None: + # resolve_id_or_name passes "" straight through, so an empty --remote stays + # "" and `remote or None` below turns it into a JSON null to clear the remote. + remote = resolve_id_or_name(client, "remotes", remote_name) + data["remote"] = remote or None + if retain_all: + data["retain_repo_versions"] = None # null keeps every version. + elif retain_versions is not None: + data["retain_repo_versions"] = retain_versions + resp = client.patch(f"/repositories/{repository_id}/", json=data) + return wait_for_task(client, resp, no_wait) + + +def delete_repository(cmd, registry_name, repository_name, resource_group_name=None, no_wait=False): + client = _client(cmd, registry_name, resource_group_name) + repository_id = resolve_id_or_name(client, "repositories", repository_name) + resp = client.delete(f"/repositories/{repository_id}/") + return wait_for_task(client, resp, no_wait) + + +def sync_repository( + cmd, + registry_name, + repository_name, + resource_group_name=None, + no_wait=False, + sync_mode=None, + confirm=False, +): + del confirm + client = _client(cmd, registry_name, resource_group_name) + repository_id = resolve_id_or_name(client, "repositories", repository_name) + kwargs = {"json": {"sync_mode": sync_mode}} if sync_mode else {} + resp = client.post(f"/repositories/{repository_id}/sync/", **kwargs) + return wait_for_task(client, resp, no_wait) + + +def publish_repository( + cmd, registry_name, repository_name, force=False, resource_group_name=None, no_wait=False +): + client = _client(cmd, registry_name, resource_group_name) + repository_id = resolve_id_or_name(client, "repositories", repository_name) + resp = client.post(f"/repositories/{repository_id}/publish/", json={"force": force}) + return wait_for_task(client, resp, no_wait) diff --git a/src/apr/azext_apr/commands/repository_package.py b/src/apr/azext_apr/commands/repository_package.py new file mode 100644 index 00000000000..7a48d902625 --- /dev/null +++ b/src/apr/azext_apr/commands/repository_package.py @@ -0,0 +1,199 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azure.cli.core.azclierror import RequiredArgumentMissingError +from azure.cli.core.commands import CliCommandType +from azure.cli.core.commands.parameters import resource_group_name_type +from knack.help_files import helps + +from azext_apr.server import ( + DataPlaneClient, + resolve_id_or_name, + resolve_release, + resolve_release_name, + wait_for_task, +) + +helps["apr repository package"] = """ +type: group +short-summary: Manage the packages contained in a repository. +""" + +helps["apr repository package add"] = """ +type: command +short-summary: Add packages to a repository. +examples: + - name: Add packages to a repository. + text: az apr repository package add -g myrg -r myregistry -n myrepo --packages pkg1,pkg2 + - name: Add packages to a specific release of an apt repository. + text: | + az apr repository package add -g myrg -r myregistry -n myrepo \ + --packages pkg1 --releases jammy + - name: Add packages to multiple releases of an apt repository. + text: | + az apr repository package add -g myrg -r myregistry -n myrepo \ + --packages pkg1 --releases jammy,focal + - name: Add packages to a specific component of an apt release. + text: | + az apr repository package add -g myrg -r myregistry -n myrepo \ + --packages pkg1 --releases jammy --component contrib +""" + +helps["apr repository package remove"] = """ +type: command +short-summary: Remove packages from a repository. +examples: + - name: Remove packages from a repository. + text: az apr repository package remove -g myrg -r myregistry -n myrepo --packages pkg1,pkg2 + - name: Remove packages from a specific component of an apt release. + text: | + az apr repository package remove -g myrg -r myregistry -n myrepo \ + --packages pkg1 --releases jammy --component contrib +""" + + +def register_commands(loader): + repository_package_type = CliCommandType( + operations_tmpl="azext_apr.commands.repository_package#{}" + ) + with loader.command_group( + "apr repository package", custom_command_type=repository_package_type, is_preview=True + ) as g: + g.custom_command("add", "add_packages", supports_no_wait=True) + g.custom_command("remove", "remove_packages", confirmation=True, supports_no_wait=True) + + +def load_arguments(loader, _command): + with loader.argument_context("apr repository package") as c: + c.argument("resource_group_name", resource_group_name_type) + c.argument( + "registry_name", + options_list=["--registry", "-r"], + help="Name of the parent APR registry.", + ) + c.argument( + "repository_name", + options_list=["--name", "-n"], + help="Name or ID of the repository.", + ) + + for scope in ("apr repository package add", "apr repository package remove"): + with loader.argument_context(scope) as c: + c.argument( + "packages", + options_list=["--packages"], + help="Comma-separated package ids.", + ) + c.argument( + "releases", + options_list=["--releases"], + help="Comma-separated apt release names or ids. Required for apt repositories.", + ) + c.argument( + "component", + options_list=["--component"], + help="Component within --releases to target. Defaults to 'main' (apt only).", + ) + + +def _client(cmd, registry_name, resource_group_name): + return DataPlaneClient.for_registry(cmd, registry_name, resource_group_name) + + +def _split(value): + # comma-separated CLI value -> list of trimmed, non-empty items + return [item.strip() for item in value.split(",") if item.strip()] + + +def _patch_packages( + cmd, + registry_name, + repository_name, + key, + packages, + releases, + component, + resource_group_name, + no_wait, +): + package_list = _split(packages) if packages else [] + if not package_list: + raise RequiredArgumentMissingError("Pass --packages with one or more package ids.") + if component and not releases: + raise RequiredArgumentMissingError("--component requires --releases.") + if releases: + release_list = _split(releases) + if not release_list: + raise RequiredArgumentMissingError( + "Pass --releases with one or more release names or ids." + ) + else: + release_list = [None] + client = _client(cmd, registry_name, resource_group_name) + repository_id = resolve_id_or_name(client, "repositories", repository_name) + release_ids = [ + resolve_release(client, repository_id, release) if release else None + for release in release_list + ] + release_list = [ + resolve_release_name(client, repository_id, release) if release else None + for release in release_ids + ] + responses = [] + for rel in release_list: + data: dict[str, object] = {key: package_list} + if rel: + data["release"] = rel + if component: + data["component"] = component + responses.append(client.patch(f"/repositories/{repository_id}/packages/", json=data)) + results = [wait_for_task(client, response, no_wait) for response in responses] + return results[0] if len(results) == 1 else results + + +def add_packages( + cmd, + registry_name, + repository_name, + packages, + releases=None, + component=None, + resource_group_name=None, + no_wait=False, +): + return _patch_packages( + cmd, + registry_name, + repository_name, + "add_packages", + packages, + releases, + component, + resource_group_name, + no_wait, + ) + + +def remove_packages( + cmd, + registry_name, + repository_name, + packages, + releases=None, + component=None, + resource_group_name=None, + no_wait=False, +): + return _patch_packages( + cmd, + registry_name, + repository_name, + "remove_packages", + packages, + releases, + component, + resource_group_name, + no_wait, + ) diff --git a/src/apr/azext_apr/commands/repository_release.py b/src/apr/azext_apr/commands/repository_release.py new file mode 100644 index 00000000000..e46e9d64c2a --- /dev/null +++ b/src/apr/azext_apr/commands/repository_release.py @@ -0,0 +1,307 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azure.cli.core.commands import CliCommandType +from azure.cli.core.commands.parameters import resource_group_name_type +from knack.help_files import helps + +from azext_apr.server import ( + DataPlaneClient, + list_all, + resolve_id_or_name, + resolve_release, + resolve_release_component, + wait_for_task, +) + +helps["apr repository release"] = """ +type: group +short-summary: Manage the apt releases (dists) of a repository. +""" + +helps["apr repository release list"] = """ +type: command +short-summary: List releases. +examples: + - name: List all releases in a repository. + text: az apr repository release list -g myrg -r myregistry --repository myrepo +""" + +helps["apr repository release create"] = """ +type: command +short-summary: Create a release. +examples: + - name: Create a release with default components and architectures. + text: | + az apr repository release create -g myrg -r myregistry --repository myrepo \ + -n jammy + - name: Create a release with explicit components and architectures. + text: | + az apr repository release create -g myrg -r myregistry --repository myrepo \ + -n jammy --suite stable --components main,contrib --architectures amd64,arm64 +""" + +helps["apr repository release delete"] = """ +type: command +short-summary: Delete a release. +examples: + - name: Delete a release by name. + text: az apr repository release delete -g myrg -r myregistry --repository myrepo -n jammy +""" + +helps["apr repository release component"] = """ +type: group +short-summary: Manage the components of an apt release. +""" + +helps["apr repository release component list"] = """ +type: command +short-summary: List the components of a release. +examples: + - name: List the components of a release. + text: | + az apr repository release component list -g myrg -r myregistry --repository myrepo \ + --release jammy +""" + +helps["apr repository release component create"] = """ +type: command +short-summary: Create a release component. +examples: + - name: Add a component to an existing release. + text: | + az apr repository release component create -g myrg -r myregistry --repository myrepo \ + --release jammy -n contrib +""" + +helps["apr repository release component delete"] = """ +type: command +short-summary: Delete a release component. +examples: + - name: Delete a component from a release by name. + text: | + az apr repository release component delete -g myrg -r myregistry --repository myrepo \ + --release jammy -n contrib +""" + + +def register_commands(loader): + repository_release_type = CliCommandType( + operations_tmpl="azext_apr.commands.repository_release#{}" + ) + with loader.command_group( + "apr repository release", custom_command_type=repository_release_type, is_preview=True + ) as g: + g.custom_command("list", "list_releases") + g.custom_command("create", "create_release", supports_no_wait=True) + g.custom_command("delete", "delete_release", confirmation=True, supports_no_wait=True) + with loader.command_group( + "apr repository release component", + custom_command_type=repository_release_type, + is_preview=True, + ) as g: + g.custom_command("list", "list_release_components") + g.custom_command("create", "create_release_component", supports_no_wait=True) + g.custom_command( + "delete", "delete_release_component", confirmation=True, supports_no_wait=True + ) + + +def load_arguments(loader, _command): + with loader.argument_context("apr repository release") as c: + c.argument("resource_group_name", resource_group_name_type) + c.argument( + "registry_name", + options_list=["--registry", "-r"], + help="Name of the parent APR registry.", + ) + c.argument( + "repository", + options_list=["--repository"], + help="Name or ID of the parent repository.", + ) + c.argument( + "release_name", + options_list=["--name", "-n"], + help="Name or ID of the release.", + ) + + with loader.argument_context("apr repository release create") as c: + c.argument( + "release_name", + options_list=["--name", "-n"], + help="Name to distribute the release under (the apt distribution/dist name).", + ) + c.argument( + "codename", + options_list=["--codename"], + help="Release codename. Defaults to the release name.", + ) + c.argument( + "suite", + options_list=["--suite"], + help="Release suite (e.g. stable). Defaults to the release name.", + ) + c.argument( + "components", + options_list=["--components"], + help="Comma-separated list of components. Defaults to 'main'.", + ) + c.argument( + "architectures", + options_list=["--architectures"], + help="Comma-separated list of architectures. Defaults to 'amd64,arm64,armhf'.", + ) + + with loader.argument_context("apr repository release list") as c: + c.argument( + "release_name", + options_list=["--name", "-n"], + help="Filter to releases with this name.", + ) + c.argument( + "limit", + type=int, + help="Max number of releases to return. Omit to return all.", + ) + + with loader.argument_context("apr repository release component") as c: + c.argument( + "release", + options_list=["--release"], + help="Name or ID of the release the component belongs to.", + ) + c.argument( + "component_name", + options_list=["--name", "-n"], + help="Name of the release component (e.g. main, contrib).", + ) + + with loader.argument_context("apr repository release component list") as c: + c.argument( + "limit", + type=int, + help="Max number of components to return. Omit to return all.", + ) + + +def _client(cmd, registry_name, resource_group_name): + return DataPlaneClient.for_registry(cmd, registry_name, resource_group_name) + + +def _split(value): + # comma-separated CLI value -> list of trimmed, non-empty items + return [item.strip() for item in value.split(",") if item.strip()] + + +def list_releases( + cmd, registry_name, repository, release_name=None, resource_group_name=None, limit=None +): + client = _client(cmd, registry_name, resource_group_name) + repository_id = resolve_id_or_name(client, "repositories", repository) + params = {"name": release_name} if release_name else None + return list_all( + client, f"/repositories/{repository_id}/releases/", max_items=limit, params=params + ) + + +def create_release( + cmd, + registry_name, + repository, + release_name, + codename=None, + suite=None, + components=None, + architectures=None, + resource_group_name=None, + no_wait=False, +): + client = _client(cmd, registry_name, resource_group_name) + repository_id = resolve_id_or_name(client, "repositories", repository) + data = {"name": release_name} + if codename: + data["codename"] = codename + if suite: + data["suite"] = suite + if components: + component_list = _split(components) + if component_list: + data["components"] = component_list + if architectures: + architecture_list = _split(architectures) + if architecture_list: + data["architectures"] = architecture_list + resp = client.post(f"/repositories/{repository_id}/releases/", json=data) + return wait_for_task(client, resp, no_wait) + + +def delete_release( + cmd, registry_name, repository, release_name, resource_group_name=None, no_wait=False +): + client = _client(cmd, registry_name, resource_group_name) + repository_id = resolve_id_or_name(client, "repositories", repository) + release_id = resolve_release(client, repository_id, release_name) + resp = client.delete(f"/repositories/{repository_id}/releases/{release_id}/") + return wait_for_task(client, resp, no_wait) + + +def list_release_components( + cmd, + registry_name, + repository, + release, + component_name=None, + resource_group_name=None, + limit=None, +): + client = _client(cmd, registry_name, resource_group_name) + repository_id = resolve_id_or_name(client, "repositories", repository) + release_id = resolve_release(client, repository_id, release) + params = {"component": component_name} if component_name else None + return list_all( + client, + f"/repositories/{repository_id}/releases/{release_id}/components/", + max_items=limit, + params=params, + ) + + +def create_release_component( + cmd, + registry_name, + repository, + release, + component_name, + resource_group_name=None, + no_wait=False, +): + client = _client(cmd, registry_name, resource_group_name) + repository_id = resolve_id_or_name(client, "repositories", repository) + release_id = resolve_release(client, repository_id, release) + resp = client.post( + f"/repositories/{repository_id}/releases/{release_id}/components/", + json={"name": component_name}, + ) + return wait_for_task(client, resp, no_wait) + + +def delete_release_component( + cmd, + registry_name, + repository, + release, + component_name, + resource_group_name=None, + no_wait=False, +): + client = _client(cmd, registry_name, resource_group_name) + repository_id = resolve_id_or_name(client, "repositories", repository) + release_id = resolve_release(client, repository_id, release) + component_id = resolve_release_component(client, repository_id, release_id, component_name) + resp = client.delete( + f"/repositories/{repository_id}/releases/{release_id}/components/{component_id}/" + ) + return wait_for_task(client, resp, no_wait) diff --git a/src/apr/azext_apr/commands/task.py b/src/apr/azext_apr/commands/task.py new file mode 100644 index 00000000000..1118395257a --- /dev/null +++ b/src/apr/azext_apr/commands/task.py @@ -0,0 +1,135 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azure.cli.core.azclierror import AzureResponseError, InvalidArgumentValueError +from azure.cli.core.commands import CliCommandType +from azure.cli.core.commands.parameters import ( + get_enum_type, + resource_group_name_type, +) +from knack.help_files import helps + +from azext_apr.server import DataPlaneClient, list_all, poll_task + +TASK_STATES = ["completed", "failed", "running", "waiting", "canceled", "canceling", "skipped"] + + +helps["apr task"] = """ +type: group +short-summary: View and manage background tasks. +""" + +helps["apr task list"] = """ +type: command +short-summary: List tasks. +examples: + - name: List all tasks in a registry. + text: az apr task list -g myrg -r myregistry + - name: List only failed tasks. + text: az apr task list -g myrg -r myregistry --state failed +""" + +helps["apr task show"] = """ +type: command +short-summary: Show details for a particular task. +examples: + - name: Show a task by id. + text: az apr task show -g myrg -r myregistry --id mytaskid +""" + +helps["apr task wait"] = """ +type: command +short-summary: Wait for a set of tasks to finish. +examples: + - name: Wait for several tasks to finish. + text: az apr task wait -g myrg -r myregistry --ids task1,task2 +""" + +helps["apr task cancel"] = """ +type: command +short-summary: Cancel a task. +examples: + - name: Cancel a running task by id. + text: az apr task cancel -g myrg -r myregistry --id mytaskid +""" + + +def register_commands(loader): + task_type = CliCommandType(operations_tmpl="azext_apr.commands.task#{}") + with loader.command_group("apr task", custom_command_type=task_type, is_preview=True) as g: + g.custom_command("list", "list_tasks") + g.custom_show_command("show", "show_task") + g.custom_command("wait", "wait_task") + g.custom_command("cancel", "cancel_task", confirmation=True) + + +def load_arguments(loader, _command): + with loader.argument_context("apr task") as c: + c.argument("resource_group_name", resource_group_name_type) + c.argument( + "registry_name", + options_list=["--registry", "-r"], + help="Name of the parent APR registry.", + ) + # Tasks have no name; they're referenced by id. + c.argument("task_id", options_list=["--id"], help="Task id.") + + with loader.argument_context("apr task list") as c: + c.ignore("task_id") + c.argument( + "state", + options_list=["--state"], + arg_type=get_enum_type(TASK_STATES), + help="Filter by state.", + ) + c.argument( + "limit", + type=int, + help="Max number of tasks to return. Omit to return all.", + ) + + with loader.argument_context("apr task wait") as c: + c.ignore("task_id") + c.argument( + "task_ids", + options_list=["--ids"], + required=True, + help="Comma-separated list of task ids to wait on.", + ) + + +def _client(cmd, registry_name, resource_group_name): + return DataPlaneClient.for_registry(cmd, registry_name, resource_group_name) + + +def list_tasks(cmd, registry_name, state=None, resource_group_name=None, limit=None): + client = _client(cmd, registry_name, resource_group_name) + params = {"state": state} if state else None + return list_all(client, "/tasks/", max_items=limit, params=params) + + +def show_task(cmd, registry_name, task_id, resource_group_name=None): + client = _client(cmd, registry_name, resource_group_name) + return client.get(f"/tasks/{task_id}/").json() + + +def wait_task(cmd, registry_name, task_ids, resource_group_name=None): + ids = [t.strip() for t in (task_ids or "").split(",") if t.strip()] + if not ids: + raise InvalidArgumentValueError("--ids must contain at least one task id.") + client = _client(cmd, registry_name, resource_group_name) + failures = [] + for task_id in ids: + try: + poll_task(client, task_id) + except AzureResponseError as exc: + failures.append(str(exc)) + if failures: + raise AzureResponseError("; ".join(failures)) + + +def cancel_task(cmd, registry_name, task_id, resource_group_name=None): + client = _client(cmd, registry_name, resource_group_name) + return client.patch(f"/tasks/{task_id}/cancel/").json() diff --git a/src/apr/azext_apr/server/__init__.py b/src/apr/azext_apr/server/__init__.py new file mode 100644 index 00000000000..10aa3780d02 --- /dev/null +++ b/src/apr/azext_apr/server/__init__.py @@ -0,0 +1,36 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Data-plane server communication: HTTP client, polling, uploads.""" + +from azext_apr.server._data_plane import ( + DataPlaneClient, + is_dev_extension, + raise_if_dev_extension, + resolve_api_endpoint, +) +from azext_apr.server._paging import iter_results, list_all +from azext_apr.server._polling import poll_task, wait_for_task +from azext_apr.server._resolve import ( + resolve_id_or_name, + resolve_release, + resolve_release_component, + resolve_release_name, +) + +__all__ = [ + "DataPlaneClient", + "is_dev_extension", + "raise_if_dev_extension", + "resolve_api_endpoint", + "iter_results", + "list_all", + "poll_task", + "wait_for_task", + "resolve_id_or_name", + "resolve_release", + "resolve_release_component", + "resolve_release_name", +] diff --git a/src/apr/azext_apr/server/_data_plane.py b/src/apr/azext_apr/server/_data_plane.py new file mode 100644 index 00000000000..d50bb3a7de9 --- /dev/null +++ b/src/apr/azext_apr/server/_data_plane.py @@ -0,0 +1,443 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Shared HTTP client for the APR data plane. + +Foundation for every data-plane command (repository, package, distro, remote, +publication, task), which hit the registry's API endpoint directly, not ARM. +Mirrors pmc/client.py but Azure-CLI-native: token from ``az login`` (no MSAL), +errors as ``azclierror``. REST contract: docs/design/server.md (mirrors Pulp). +""" + +import json +import uuid +from collections.abc import Iterator +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import requests +from azure.cli.core._profile import Profile +from azure.cli.core.azclierror import ( + AzureResponseError, + BadRequestError, + ForbiddenError, + RequiredArgumentMissingError, + ResourceNotFoundError, + UnauthorizedError, + ValidationError, +) +from azure.cli.core.commands.client_factory import get_subscription_id +from azure.cli.core.util import send_raw_request +from knack.log import get_logger +from requests.adapters import HTTPAdapter, Retry + +logger = get_logger(__name__) + +# FILL-IN: the Entra audience for the data-plane API is not finalized. The plane +# is MISE-protected (docs/design/server.md §MISE); the audience GUID/URI hasn't +# been published. Using the API host as the resource URI is the conventional +# default for *.azure.net data planes — confirm with the server team before GA. +DATA_PLANE_RESOURCE = "https://api.apr.azure.net" + +# Data-plane API version. The CLI owns the version segment (rather than baking it +# into ARM's apiEndpoint) so that the ARM-published endpoint stays a bare host; +# the server mounts its routes under this same prefix (container_images/server, +# settings.API_PREFIX). Path-versioning lets an old, independently-distributed CLI +# keep working while the server evolves — mirrors PMC's /api/v4 and upstream Pulp's +# /pulp/api/v3. Bump in lockstep with a breaking data-plane contract change (and +# pair with a server-advertised minimum CLI version when that handshake lands). +DATA_PLANE_API_PREFIX = "/api/v1" + +# FILL-IN: the RP publishes preview api-versions (docs/design/rp.md §"API +# versioning") but none is pinned yet. Bump when the registry RT registration +# lands. +REGISTRY_API_VERSION = "2026-04-01-preview" + +REGISTRY_RESOURCE_TYPE = "Microsoft.PackageRegistry/registries" + +# This extension's published name (matches the wheel / `az extension` name). Used +# only in user-facing messages; dev-build detection is by path (see +# is_dev_extension), because a dev extension's name comes from its directory. +EXTENSION_NAME = "apr" + +# Where a dev build talks. There is deliberately NO user-facing endpoint override: +# Azure CLI reserves env-var and `az config` surface for the CLI itself, and a +# shipped extension must never let a user redirect where the data-plane bearer +# token is sent (token-phishing / cross-cloud hazard). Instead, a *dev-loaded* +# build (azdev; see is_dev_extension) always targets the local docker-compose +# server, and an installed build always resolves the real endpoint via ARM. +DEV_LOCAL_ENDPOINT = "http://localhost:8100" + +# Hosts treated as a local dev server: no Entra app exists to mint a token for, +# and the server assumes every request is authenticated (AGENTS.md), so we skip +# auth for these. +_LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"}) + +# Transient statuses worth retrying for safe read operations. +_RETRY_STATUSES = (104, 502, 503, 504) +_RETRY_METHODS = frozenset({"GET"}) +_UPLOAD_CHUNK_SIZE = 1024 * 1024 + + +class _StreamingMultipart: # pylint: disable=too-few-public-methods + """Re-iterable multipart body backed by a local file.""" + + def __init__(self, fields: dict[str, str], file_path: Path) -> None: + self._fields = fields + self._file_path = file_path + self._boundary = uuid.uuid4().hex + self.content_type = f"multipart/form-data; boundary={self._boundary}" + + def __iter__(self) -> Iterator[bytes]: + boundary = self._boundary.encode() + for name, value in self._fields.items(): + yield b"--" + boundary + b"\r\n" + yield f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode() + yield value.encode() + yield b"\r\n" + + filename = self._file_path.name.replace("\\", "\\\\").replace('"', '\\"') + filename = filename.replace("\r", "").replace("\n", "") + yield b"--" + boundary + b"\r\n" + yield f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode() + yield b"Content-Type: application/octet-stream\r\n\r\n" + with self._file_path.open("rb") as file: + while chunk := file.read(_UPLOAD_CHUNK_SIZE): + yield chunk + yield b"\r\n--" + boundary + b"--\r\n" + + +def _cli_version() -> str: + try: + return version("apr") + except PackageNotFoundError: + return "0.0.0" + + +class _LoggedRetry(Retry): + """Retry that logs each attempt — mirrors pmc/client.py:LoggedRetry, but via + the knack logger instead of typer.echo (§3.7: never print()).""" + + # Keep Retry.increment's positional compatibility across urllib3 versions. + def increment( # pylint: disable=keyword-arg-before-vararg + self, method=None, url=None, response=None, error=None, *args, **kwargs + ): # type: ignore[no-untyped-def] + retry = super().increment(method, url, response, error, *args, **kwargs) + if not retry.is_exhausted(): + reason = f"status {response.status}" if response else f"error {error}" + logger.warning("Retrying '%s %s' due to %s", method, (url or "").split("?")[0], reason) + return retry + + +# HTTP status -> azclierror. Anything unmapped falls through to AzureResponseError +# so the user still gets a clean, traceback-free message (§3.8). +_STATUS_ERRORS = { + 400: BadRequestError, + 401: UnauthorizedError, + 403: ForbiddenError, + 404: ResourceNotFoundError, +} + + +class DataPlaneClient: + """``requests.Session`` wrapper bound to one registry's API endpoint. + + Build via :meth:`for_registry`; call get/post/patch/delete with a relative + path (e.g. ``/repositories/``). Handles base URL, auth, headers, retries, + and error mapping so command files stay thin. + """ + + def __init__(self, cli_ctx: Any, base_url: str, resource: str = DATA_PLANE_RESOURCE) -> None: + self._cli_ctx = cli_ctx + base_url = base_url.strip() + # ARM may publish apiEndpoint as a bare host (see DATA_PLANE_API_PREFIX + # note). Normalize to an absolute https URL so requests can send it and + # urlparse() can see the hostname. A dev/local endpoint already carries + # its scheme, so this is a no-op there. + if "://" not in base_url: + base_url = f"https://{base_url}" + base_url = base_url.rstrip("/") + parsed_url = urlparse(base_url) + is_local = parsed_url.hostname in _LOCAL_HOSTS + scheme = parsed_url.scheme.lower() + is_https = scheme == "https" + is_loopback_http = is_local and scheme == "http" + if not parsed_url.hostname or (not is_https and not is_loopback_http): + raise ValidationError( + "Registry API endpoints must use HTTPS. Only loopback development " + "endpoints may use HTTP." + ) + if "?" in base_url or "#" in base_url: + raise ValidationError( + "Registry API endpoints must not include a query string or fragment." + ) + if parsed_url.path not in {"", DATA_PLANE_API_PREFIX}: + raise ValidationError( + f"Registry API endpoints must be a bare host or end with '{DATA_PLANE_API_PREFIX}'." + ) + self.base_url = base_url + # The CLI owns the versioned API path (see DATA_PLANE_API_PREFIX), so an + # endpoint — from ARM or the dev-build local server — is a bare host. Append + # the version once, tolerating an endpoint that already carries it so we + # never emit '/api/v1/api/v1/...'. + if self.base_url.endswith(DATA_PLANE_API_PREFIX): + self._api_root = self.base_url + else: + self._api_root = f"{self.base_url}{DATA_PLANE_API_PREFIX}" + self._resource = resource + self._local = is_local + self._cid = uuid.uuid4().hex + + self._session = requests.Session() + retries = _LoggedRetry( + total=3, + backoff_factor=1, + allowed_methods=_RETRY_METHODS, + status_forcelist=_RETRY_STATUSES, + ) + self._session.mount(self.base_url, HTTPAdapter(max_retries=retries)) + + @classmethod + def for_registry( + cls, cmd: Any, registry_name: str, resource_group_name: str | None = None + ) -> "DataPlaneClient": + # A dev build (azdev) always talks to the local docker-compose server and + # skips ARM entirely; there is no user-facing override (see is_dev_extension + # / DEV_LOCAL_ENDPOINT). An installed build always resolves via ARM. + if is_dev_extension(): + logger.warning( + "Dev build detected (azdev); targeting local APR server %s and " + "skipping ARM registry lookup for '%s'.", + DEV_LOCAL_ENDPOINT, + registry_name, + ) + return cls(cmd.cli_ctx, DEV_LOCAL_ENDPOINT) + endpoint = resolve_api_endpoint(cmd, registry_name, resource_group_name) + return cls(cmd.cli_ctx, endpoint) + + def _headers(self) -> dict[str, str]: + # Increment the correlation id per request so a single command's calls + # share a traceable, monotonically-related sequence in the server logs + # (mirrors pmc/client.py:_get_headers). + self._cid = format(int(self._cid, 16) + 1, "x") + headers = { + "x-correlation-id": self._cid, + "apr-cli-version": _cli_version(), + } + # A local dev server has no Entra app to mint a token against and assumes + # every request is authenticated (AGENTS.md), so skip auth for localhost. + if not self._local: + token = _acquire_token(self._cli_ctx, self._resource) + headers["authorization"] = f"Bearer {token}" + return headers + + def request(self, method: str, path: str, **kwargs: Any) -> requests.Response: + method = method.upper() + _validate_request_path(path) + url = f"{self._api_root}{path}" + kwargs.setdefault("timeout", 600) + kwargs["allow_redirects"] = False + kwargs["headers"] = {**self._headers(), **kwargs.get("headers", {})} + + logger.debug("Request: %s %s", method, url) + for key in ("params", "json", "data"): + if key in kwargs: + logger.debug("%s: %s", key, kwargs[key]) + + try: + request = self._session.request if method == "GET" else requests.request + resp = request(method, url, **kwargs) + except requests.RequestException as exc: + raise AzureResponseError(f"Request to {url} failed: {exc}") from exc + + logger.debug("Response: %s %s -> %s", method, url, resp.status_code) + _raise_for_status(resp) + return resp + + def get(self, path: str, **kwargs: Any) -> requests.Response: + return self.request("GET", path, **kwargs) + + def post(self, path: str, **kwargs: Any) -> requests.Response: + return self.request("POST", path, **kwargs) + + def post_multipart( + self, path: str, *, fields: dict[str, str], file_path: Path + ) -> requests.Response: + # A successful upload can still lose its response. Retrying its POST + # would replay package creation without an idempotency key. + body = _StreamingMultipart(fields, file_path) + return self.request( + "POST", + path, + data=body, + headers={"Content-Type": body.content_type}, + ) + + def patch(self, path: str, **kwargs: Any) -> requests.Response: + return self.request("PATCH", path, **kwargs) + + def delete(self, path: str, **kwargs: Any) -> requests.Response: + return self.request("DELETE", path, **kwargs) + + +def is_dev_extension() -> bool: + """True when *this* extension is loaded as a dev build (azdev), not an + installed wheel. + + Azure CLI tags each loaded extension with an ``ext_type`` of ``'dev'`` (added + via ``azdev`` / ``extension.dev_sources``) or ``'whl'`` (added via + ``az extension add``). We match by path rather than by name: a dev extension's + name is derived from the directory that holds its ``*.egg-info`` (``apr`` in + this repo layout), not from the package/project name, so a name lookup is + unreliable. Instead we find the loaded extension whose path contains this + module and read its ``ext_type``. + + We drive all local-dev behavior off this instead of a user-facing override: a + shipped extension must never let a user redirect the data-plane endpoint (and + thus where the bearer token is sent), and env-var / ``az config`` surface + belongs to the CLI itself, not an extension. Any failure (no matching + extension, older core) is treated as "not a dev build". + """ + try: + from azure.cli.core.extension import get_extensions + + here = Path(__file__).resolve().parent + for ext in get_extensions(): + raw_path = getattr(ext, "path", "") or "" + if not raw_path: + continue + ext_path = Path(raw_path).resolve() + if here == ext_path or ext_path in here.parents: + return ext.ext_type == "dev" + return False + except Exception: + return False + + +def _validate_request_path(path: str) -> None: + segments = path[1:].split("/") if path.startswith("/") else [] + if segments and segments[-1] == "": + segments.pop() + has_unsafe_delimiter = any(char in path for char in ("?", "#", "\\", "%")) + has_unsafe_segment = ( + not segments + or path.startswith("//") + or path.endswith("//") + or any(segment in {"", ".", ".."} for segment in segments) + ) + has_control_character = any(ord(char) < 32 or ord(char) == 127 for char in path) + if has_unsafe_delimiter or has_unsafe_segment or has_control_character: + raise ValidationError("The service returned or constructed an invalid request path.") + + +def raise_if_dev_extension() -> None: + """Refuse a control-plane (ARM) command when running as a dev build. + + Control-plane verbs (``az apr registry ...``) manage the Azure resource via + ARM; a dev build talks only to the local ``docker compose`` server, behind + which there is no ARM, so fail with a clear message instead of silently hitting + real ARM. Data-plane commands are unaffected — they target the local server. + """ + if is_dev_extension(): + raise ValidationError( + "Control-plane commands (az apr registry ...) are not available in a " + "local dev build of this extension. A dev build targets the local APR " + f"server ({DEV_LOCAL_ENDPOINT}), which has no ARM behind it. Install the " + f"published extension ('az extension add --name {EXTENSION_NAME}') to " + "manage registries via ARM. Data-plane commands (repository, package, " + "distro, remote, publication, task) work against the local server." + ) + + +def _acquire_token(cli_ctx: Any, resource: str) -> str: + # No MSAL/cert flow — the user is already logged in via ``az login`` and the + # Profile hands us a ready token. Called per request so long task polls pick + # up a refreshed token automatically. + creds, _, _ = Profile(cli_ctx=cli_ctx).get_raw_token(resource=resource) + # creds == (token_type, token, full_token_entry) + return str(creds[1]) + + +def _raise_for_status(resp: requests.Response) -> None: + if 300 <= resp.status_code < 400: + raise AzureResponseError( + f"Unexpected redirect from the data-plane service: {resp.status_code} {resp.reason}." + ) + if resp.status_code < 400: + return + err_cls = _STATUS_ERRORS.get(resp.status_code, AzureResponseError) + raise err_cls(f"{resp.status_code} {resp.reason}: {_error_detail(resp)}") + + +def _error_detail(resp: requests.Response) -> str: + try: + return json.dumps(resp.json()) + except ValueError: + return resp.text or "(no response body)" + + +def resolve_api_endpoint( + cmd: Any, registry_name: str, resource_group_name: str | None = None +) -> str: + """Resolve a registry's data-plane API endpoint via an ARM GET. + + Reads ``properties.apiEndpoint`` off the ``Microsoft.PackageRegistry`` + resource. Data-plane commands take ``--registry`` with no ``-g``, so without + a resource group we list the subscription and match by name. Uses generic + ARM (``send_raw_request``) until the azure-mgmt-apr SDK ships. + """ + sub = get_subscription_id(cmd.cli_ctx) + if resource_group_name: + scope = f"/subscriptions/{sub}/resourceGroups/{resource_group_name}/providers" + url = f"{scope}/{REGISTRY_RESOURCE_TYPE}/{registry_name}?api-version={REGISTRY_API_VERSION}" + registry = send_raw_request(cmd.cli_ctx, "GET", url).json() + else: + registry = _find_registry_in_subscription(cmd.cli_ctx, sub, registry_name) + + endpoint = (registry.get("properties") or {}).get("apiEndpoint") + if not endpoint: + raise AzureResponseError( + f"Registry '{registry_name}' has no apiEndpoint; it may still be provisioning." + ) + return str(endpoint) + + +def _find_registry_in_subscription(cli_ctx: Any, sub: str, registry_name: str) -> dict[str, Any]: + url = ( + f"/subscriptions/{sub}/providers/{REGISTRY_RESOURCE_TYPE}" + f"?api-version={REGISTRY_API_VERSION}" + ) + target = registry_name.lower() + matches: list[dict[str, Any]] = [] + while url: + page = send_raw_request(cli_ctx, "GET", url).json() + matches.extend(r for r in page.get("value", []) if r.get("name", "").lower() == target) + url = page.get("nextLink") + + if not matches: + raise ResourceNotFoundError( + f"Registry '{registry_name}' not found in subscription {sub}. " + "Check 'az apr registry list'." + ) + if len(matches) > 1: + # ARM names are unique per resource group, not per subscription, so a bare + # --registry can be ambiguous. Make the user disambiguate, never guess. + rgs = ", ".join(sorted(_resource_group_of(m) for m in matches)) + raise RequiredArgumentMissingError( + f"Multiple registries named '{registry_name}' in subscription {sub} " + f"(resource groups: {rgs}). Pass --resource-group to disambiguate." + ) + return matches[0] + + +def _resource_group_of(resource: dict[str, Any]) -> str: + # ARM id: /subscriptions//resourceGroups//providers/... + parts = str(resource.get("id", "")).split("/") + lowered = [p.lower() for p in parts] + if "resourcegroups" not in lowered: + return "(unknown)" + return parts[lowered.index("resourcegroups") + 1] diff --git a/src/apr/azext_apr/server/_paging.py b/src/apr/azext_apr/server/_paging.py new file mode 100644 index 00000000000..a0ca0e9ddd7 --- /dev/null +++ b/src/apr/azext_apr/server/_paging.py @@ -0,0 +1,95 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Walk Pulp's offset-paginated list endpoints. + +The server caps a single page, so ``--limit`` can exceed one page and we can't +just forward it as the page size. APR strips Pulp's ``next``/``previous`` links +(they leak the internal Pulp host), so we can't page off ``next`` - we walk by +offset until we've collected ``max_items`` (if given) or all ``count`` items the +server reports. +""" + +from collections.abc import Iterator +from typing import Any + +from azure.cli.core.azclierror import InvalidArgumentValueError + +PAGE_SIZE = 100 + + +def _iter_pages( + client: Any, + path: str, + max_items: int | None = None, + params: dict[str, Any] | None = None, +) -> Iterator[Any]: + if max_items is not None and max_items < 1: + raise InvalidArgumentValueError("--limit must be 1 or greater.") + base_params = dict(params or {}) + item_count = 0 + offset = 0 + while True: + page_params = {**base_params, "limit": PAGE_SIZE, "offset": offset} + if max_items is not None: + remaining = max_items - item_count + if remaining <= 0: + return + page_params["limit"] = min(PAGE_SIZE, remaining) + + body = client.get(path, params=page_params).json() + yield body + if not isinstance(body, dict) or "results" not in body: + return + item_count += len(body["results"]) + if max_items is not None and item_count >= max_items: + return + # The server reports the total in `count` and strips `next`, so stop once + # we've collected everything. The empty-page guard prevents an infinite + # loop if `count` is missing or stale. + count = body.get("count") + if not body["results"] or count is None or item_count >= count: + return + offset = item_count + + +def iter_results( + client: Any, + path: str, + max_items: int | None = None, + params: dict[str, Any] | None = None, +) -> Iterator[Any]: + """Yield items from a paginated list endpoint as each page is fetched.""" + yielded = 0 + for body in _iter_pages(client, path, max_items=max_items, params=params): + if not isinstance(body, dict) or "results" not in body: + return + results = body["results"] + if max_items is not None: + results = results[: max_items - yielded] + yield from results + yielded += len(results) + + +def list_all( + client: Any, + path: str, + max_items: int | None = None, + params: dict[str, Any] | None = None, +) -> Any: + """Collect results from a paginated list endpoint. + + ``params`` are extra query filters (e.g. ``{"repository": }``) merged + into every page request. Returns the raw body unchanged if it isn't a + standard ``{"results": [...]}`` page (so non-paginated endpoints still work). + """ + items: list[Any] = [] + for body in _iter_pages(client, path, max_items=max_items, params=params): + if not isinstance(body, dict) or "results" not in body: + return body + items.extend(body["results"]) + if max_items is not None and len(items) >= max_items: + return items[:max_items] + return items diff --git a/src/apr/azext_apr/server/_polling.py b/src/apr/azext_apr/server/_polling.py new file mode 100644 index 00000000000..20c57e15507 --- /dev/null +++ b/src/apr/azext_apr/server/_polling.py @@ -0,0 +1,62 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Data-plane async task polling. + +The data plane returns Pulp-style ``{"task": ""}`` bodies for long-running +ops; poll ``/tasks/{id}/`` to completion by default, or return the handle on +``--no-wait``. Mirrors pmc/client.py:poll_task. (ARM LROs use the SDK's +``LROPoller`` instead — that's not this module.) +""" + +import time +from typing import Any + +from azure.cli.core.azclierror import AzureResponseError +from knack.log import get_logger + +logger = get_logger(__name__) + +# A task in any of these states is done (matches PMC's FINISHED_TASK_STATES). +FINISHED_TASK_STATES = ("skipped", "completed", "failed", "canceled") + + +def poll_task(client: Any, task_id: str, interval: float = 1.0) -> dict[str, Any]: + """Poll ``/tasks/{task_id}/`` until it reaches a finished state. + + Raises on a non-completed terminal state. No 401-retry dance (PMC needs one + because it caches the token; our client re-acquires per request, so a token + that expires mid-poll is refreshed on the next GET automatically). + """ + task: dict[str, Any] = client.get(f"/tasks/{task_id}/").json() + logger.warning("Waiting for task %s...", task.get("id", task_id)) + + while task["state"] not in FINISHED_TASK_STATES: + time.sleep(interval) + task = client.get(f"/tasks/{task['id']}/").json() + + if task["state"] != "completed": + raise AzureResponseError(_task_failure_message(task)) + return task + + +def wait_for_task(client: Any, resp: Any, no_wait: bool = False) -> dict[str, Any] | None: + """Resolve a data-plane response that may carry a background task. + + Returns the body unchanged when there's no task or ``--no-wait`` was given; + otherwise polls to completion and returns the finished task. Call at the end + of any data-plane handler that can kick off an LRO. + """ + body: dict[str, Any] | None = resp.json() if getattr(resp, "content", None) else None + task_id = body.get("task") if isinstance(body, dict) else None + if no_wait or not task_id: + return body + return poll_task(client, task_id) + + +def _task_failure_message(task: dict[str, Any]) -> str: + error = task.get("error") or {} + detail = error.get("description") or task.get("state", "unknown") + return f"Task {task.get('id')} did not complete ({task.get('state')}): {detail}" diff --git a/src/apr/azext_apr/server/_resolve.py b/src/apr/azext_apr/server/_resolve.py new file mode 100644 index 00000000000..1cd99c75c53 --- /dev/null +++ b/src/apr/azext_apr/server/_resolve.py @@ -0,0 +1,134 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Turn a repository/remote reference into an id. + +Users can pass either an APR resource id or a plain name. If it's a name, we +look it up by filtering the list endpoint on an exact name +(//?name=), which returns 0 or 1 matches. + +Note: these ids are the APR data-plane's own single-segment ids (they slot +into paths like /repositories//), not Pulp hrefs. Pulp's native ids are +hrefs such as /pulp/api/v3/repositories/rpm/rpm// and are never surfaced +to the CLI - the data-plane abstracts Pulp away. +""" + +import re +from typing import Any + +from azure.cli.core.azclierror import RequiredArgumentMissingError, ResourceNotFoundError + +from azext_apr.server._paging import iter_results + +_UUID = r"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}" + +# Apt releases (deb + debsrc share them) translate from Pulp's +# /content/deb/releases// href, so their APR id is content-deb-releases-. +# This is server-defined and may shift as the data plane matures. +_RELEASE_ID_PREFIX = "content-deb-releases" + +# Release components translate from /content/deb/release_components//, so +# their APR id is content-deb-release_components-. +_RELEASE_COMPONENT_ID_PREFIX = "content-deb-release_components" + + +def _looks_like_id(resource: str, value: str) -> bool: + # an APR resource id is a single path segment ending in a uuid, optionally + # with a leading resource/type token (e.g. "-deb-"). we key + # off the uuid and anchor any prefix to the known resource (not an open-ended + # [a-z-]*) so a plain name that happens to end in a uuid (e.g. "my-repo-") + # isn't misread as an id and shoved straight into a request path. fullmatch so + # trailing junk or newlines fall through to the name lookup too. + return re.fullmatch(rf"(?:{re.escape(resource)}-[a-z-]*)?{_UUID}", value) is not None + + +def resolve_id_or_name(client: Any, resource: str, value: str) -> str: + """Return value as-is if it already looks like an id, else look it up by name. + + resource is the collection path segment, e.g. "repositories" or "remotes". + Raises ResourceNotFoundError if nothing by that name exists. + """ + if not value or _looks_like_id(resource, value): + return value + + body = client.get(f"/{resource}/", params={"name": value}).json() + results = body.get("results") if isinstance(body, dict) else body + if not results: + raise ResourceNotFoundError(f"Could not find {resource} with id or name '{value}'.") + return str(results[0]["id"]) + + +def resolve_release(client: Any, repository_id: str | None, value: str) -> str: + """Resolve an apt release name-or-id to an id. + + Releases are nested under a repository, so a name lookup needs the repo in + the path: GET /repositories//releases/?name=. A value that is + already an id needs no repo context and is returned as-is. + """ + if not value or _looks_like_id(_RELEASE_ID_PREFIX, value): + return value + if not repository_id: + raise RequiredArgumentMissingError("Resolving a release by name requires --repository.") + body = client.get(f"/repositories/{repository_id}/releases/", params={"name": value}).json() + results = body.get("results") if isinstance(body, dict) else body + if not results: + raise ResourceNotFoundError( + f"Could not find release with id or name '{value}' in repository '{repository_id}'." + ) + return str(results[0]["id"]) + + +def resolve_release_name(client: Any, repository_id: str | None, value: str) -> str: + """Resolve an apt release name-or-id to its name (pulp_deb's ``distribution``). + + The packages endpoint scopes a change by release *name*, not id, so an id + has to be turned back into one. A value that isn't an id is already a name + and is returned as-is. The release list carries no id filter, so the repo's + releases are matched client-side; this also confirms the release is in the + repository the caller named. + """ + if not value or not _looks_like_id(_RELEASE_ID_PREFIX, value): + return value + if not repository_id: + raise RequiredArgumentMissingError("Resolving a release by id requires --repository.") + for release in iter_results(client, f"/repositories/{repository_id}/releases/"): + if release.get("id") == value: + return str(release["distribution"]) + raise ResourceNotFoundError( + f"Could not find release with id '{value}' in repository '{repository_id}'." + ) + + +def resolve_release_component( + client: Any, repository_id: str | None, release_id: str | None, value: str +) -> str: + """Resolve a release component name-or-id to an id. + + A component name (e.g. main) is only unique within a release, so a name + lookup is scoped to the release's components: + GET /repositories//releases//components/?component=. + A value that is already an id is returned as-is. + """ + if not value or _looks_like_id(_RELEASE_COMPONENT_ID_PREFIX, value): + return value + if not repository_id: + raise RequiredArgumentMissingError( + "Resolving a release component by name requires --repository." + ) + if not release_id: + raise RequiredArgumentMissingError( + "Resolving a release component by name requires --release." + ) + body = client.get( + f"/repositories/{repository_id}/releases/{release_id}/components/", + params={"component": value}, + ).json() + results = body.get("results") if isinstance(body, dict) else body + if not results: + raise ResourceNotFoundError( + f"Could not find release component '{value}' in release '{release_id}' " + f"of repository '{repository_id}'." + ) + return str(results[0]["id"]) diff --git a/src/apr/azext_apr/tests/__init__.py b/src/apr/azext_apr/tests/__init__.py new file mode 100644 index 00000000000..34913fb394d --- /dev/null +++ b/src/apr/azext_apr/tests/__init__.py @@ -0,0 +1,4 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- diff --git a/src/apr/azext_apr/tests/latest/__init__.py b/src/apr/azext_apr/tests/latest/__init__.py new file mode 100644 index 00000000000..34913fb394d --- /dev/null +++ b/src/apr/azext_apr/tests/latest/__init__.py @@ -0,0 +1,4 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- diff --git a/src/apr/azext_apr/tests/latest/test_apr_scenario.py b/src/apr/azext_apr/tests/latest/test_apr_scenario.py new file mode 100644 index 00000000000..6386e61ddbd --- /dev/null +++ b/src/apr/azext_apr/tests/latest/test_apr_scenario.py @@ -0,0 +1,101 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import pytest +from azure.cli.testsdk import ScenarioTest + +pytestmark = pytest.mark.azdev + +HELP_COMMANDS = ( + ("apr",), + ("apr", "registry"), + ("apr", "registry", "create"), + ("apr", "registry", "delete"), + ("apr", "registry", "list"), + ("apr", "registry", "show"), + ("apr", "registry", "update"), + ("apr", "registry", "wait"), + ("apr", "repository"), + ("apr", "repository", "create"), + ("apr", "repository", "delete"), + ("apr", "repository", "list"), + ("apr", "repository", "publish"), + ("apr", "repository", "show"), + ("apr", "repository", "sync"), + ("apr", "repository", "update"), + ("apr", "repository", "release"), + ("apr", "repository", "release", "create"), + ("apr", "repository", "release", "delete"), + ("apr", "repository", "release", "list"), + ("apr", "repository", "release", "component"), + ("apr", "repository", "release", "component", "create"), + ("apr", "repository", "release", "component", "delete"), + ("apr", "repository", "release", "component", "list"), + ("apr", "repository", "package"), + ("apr", "repository", "package", "add"), + ("apr", "repository", "package", "remove"), + ("apr", "package"), + ("apr", "package", "upload"), + ("apr", "package", "deb"), + ("apr", "package", "deb", "list"), + ("apr", "package", "deb", "show"), + ("apr", "package", "debsrc"), + ("apr", "package", "debsrc", "list"), + ("apr", "package", "debsrc", "show"), + ("apr", "package", "rpm"), + ("apr", "package", "rpm", "list"), + ("apr", "package", "rpm", "show"), + ("apr", "package", "file"), + ("apr", "package", "file", "list"), + ("apr", "package", "file", "show"), + ("apr", "distro"), + ("apr", "distro", "create"), + ("apr", "distro", "delete"), + ("apr", "distro", "list"), + ("apr", "distro", "show"), + ("apr", "distro", "update"), + ("apr", "remote"), + ("apr", "remote", "create"), + ("apr", "remote", "delete"), + ("apr", "remote", "list"), + ("apr", "remote", "show"), + ("apr", "remote", "update"), + ("apr", "publication"), + ("apr", "publication", "delete"), + ("apr", "publication", "list"), + ("apr", "publication", "show"), + ("apr", "task"), + ("apr", "task", "cancel"), + ("apr", "task", "list"), + ("apr", "task", "show"), + ("apr", "task", "wait"), +) + +REQUIRED_ARGUMENT_COMMANDS = ( + ("apr", "repository", "show"), + ("apr", "package", "upload"), + ("apr", "task", "wait"), +) + + +class AprCommandScenarioTest(ScenarioTest): + def _assert_help_loads(self, command: tuple[str, ...]) -> None: + with self.assertRaises(SystemExit) as raised: + self.cli_ctx.invoke([*command, "--help"]) + + self.assertEqual(raised.exception.code, 0) + + def test_extension_command_surface_loads(self) -> None: + for command in HELP_COMMANDS: + with self.subTest(command=" ".join(command)): + self._assert_help_loads(command) + + def test_representative_commands_validate_required_arguments(self) -> None: + for command in REQUIRED_ARGUMENT_COMMANDS: + with self.subTest(command=" ".join(command)): + with self.assertRaises(SystemExit) as raised: + self.cli_ctx.invoke(list(command)) + + self.assertEqual(raised.exception.code, 2) diff --git a/src/apr/azext_apr/tests/test_data_plane.py b/src/apr/azext_apr/tests/test_data_plane.py new file mode 100644 index 00000000000..77280c20dbf --- /dev/null +++ b/src/apr/azext_apr/tests/test_data_plane.py @@ -0,0 +1,528 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import pytest + +pytest.importorskip("azure.cli.core") + + +class FakeResponse: + def __init__(self, status_code=200, body=b"{}", json_data=None, reason="OK"): + self.status_code = status_code + self.content = body + self.reason = reason + self.text = body.decode() if isinstance(body, bytes) else body + self._json = {} if json_data is None else json_data + + def json(self): + return self._json + + +@pytest.fixture +def client(monkeypatch): + from azext_apr.server import _data_plane + + # No real az session — hand back a canned token. + monkeypatch.setattr(_data_plane, "_acquire_token", lambda cli_ctx, resource: "tok123") + return _data_plane.DataPlaneClient( + cli_ctx=object(), base_url="https://r.eastus.api.apr.azure.net/" + ) + + +def test_base_url_normalized(client): + assert client.base_url == "https://r.eastus.api.apr.azure.net" + + +def test_schemeless_endpoint_gets_https_scheme(): + from azext_apr.server import _data_plane + + # ARM may publish apiEndpoint as a bare host with no scheme; the client must + # normalize it to an absolute https URL so requests can send it (and so + # urlparse() can see the hostname for localhost detection). + client = _data_plane.DataPlaneClient(cli_ctx=object(), base_url="r.eastus.api.apr.azure.net") + assert client.base_url == "https://r.eastus.api.apr.azure.net" + assert client._api_root == "https://r.eastus.api.apr.azure.net/api/v1" + + +def test_api_version_prefix_appended_to_bare_host(): + from azext_apr.server import _data_plane + + client = _data_plane.DataPlaneClient( + cli_ctx=object(), base_url="https://r.eastus.api.apr.azure.net" + ) + assert client._api_root == "https://r.eastus.api.apr.azure.net/api/v1" + + +def test_api_version_prefix_not_doubled_when_endpoint_already_versioned(): + from azext_apr.server import _data_plane + + # A dev-build local server or apiEndpoint that already carries the version + # must not produce '/api/v1/api/v1/...'. + client = _data_plane.DataPlaneClient(cli_ctx=object(), base_url="http://localhost:8000/api/v1/") + assert client._api_root == "http://localhost:8000/api/v1" + + +@pytest.mark.parametrize( + "base_url", + [ + "http://r.eastus.api.apr.azure.net", + "ftp://r.eastus.api.apr.azure.net", + "ftp://localhost:8100", + "https://", + ], +) +def test_non_https_remote_endpoint_rejected(base_url): + from azure.cli.core.azclierror import ValidationError + + from azext_apr.server import _data_plane + + with pytest.raises(ValidationError, match="must use HTTPS"): + _data_plane.DataPlaneClient(cli_ctx=object(), base_url=base_url) + + +@pytest.mark.parametrize( + "base_url", + [ + "https://r.eastus.api.apr.azure.net?x=1", + "https://r.eastus.api.apr.azure.net#fragment", + "https://r.eastus.api.apr.azure.net?", + "https://r.eastus.api.apr.azure.net#", + ], +) +def test_endpoint_query_or_fragment_rejected(base_url): + from azure.cli.core.azclierror import ValidationError + + from azext_apr.server import _data_plane + + with pytest.raises(ValidationError, match="query string or fragment"): + _data_plane.DataPlaneClient(cli_ctx=object(), base_url=base_url) + + +@pytest.mark.parametrize( + "base_url", + [ + "https://r.eastus.api.apr.azure.net/api", + "https://r.eastus.api.apr.azure.net/other", + ], +) +def test_unexpected_endpoint_path_rejected(base_url): + from azure.cli.core.azclierror import ValidationError + + from azext_apr.server import _data_plane + + with pytest.raises(ValidationError, match="bare host or end with"): + _data_plane.DataPlaneClient(cli_ctx=object(), base_url=base_url) + + +def test_retries_only_safe_read_operations(client): + adapter = client._session.get_adapter(client.base_url) + assert adapter.max_retries.allowed_methods == frozenset({"GET"}) + + +def test_request_prepends_base_url_and_attaches_auth(client): + sent = {} + + def fake_request(method, url, **kwargs): + sent["method"], sent["url"], sent["headers"] = method, url, kwargs["headers"] + sent["allow_redirects"] = kwargs["allow_redirects"] + return FakeResponse() + + client._session.request = fake_request + client.get("/repositories/", allow_redirects=True) + + assert sent["method"] == "GET" + assert sent["url"] == "https://r.eastus.api.apr.azure.net/api/v1/repositories/" + assert sent["allow_redirects"] is False + assert sent["headers"]["authorization"] == "Bearer tok123" + assert "x-correlation-id" in sent["headers"] + assert "apr-cli-version" in sent["headers"] + + +@pytest.mark.parametrize( + ("method_name", "path"), + [ + ("post", "/repositories/"), + ("patch", "/repositories/id/"), + ("delete", "/repositories/id/"), + ], +) +def test_mutations_bypass_retrying_session(client, monkeypatch, method_name, path): + import requests + + sent = {} + client._session.request = lambda *args, **kwargs: pytest.fail( + "mutations must not use the retrying session" + ) + monkeypatch.setattr( + requests, + "request", + lambda method, url, **kwargs: ( + sent.update(method=method, url=url, kwargs=kwargs) or FakeResponse() + ), + ) + + getattr(client, method_name)(path) + + assert sent["method"] == method_name.upper() + assert sent["kwargs"]["allow_redirects"] is False + + +def test_correlation_id_increments_per_request(client): + seen = [] + client._session.request = lambda method, url, **kw: ( + seen.append(kw["headers"]["x-correlation-id"]) or FakeResponse() + ) + client.get("/a/") + client.get("/b/") + assert int(seen[1], 16) == int(seen[0], 16) + 1 + + +@pytest.mark.parametrize( + "status,exc_name", + [ + (400, "BadRequestError"), + (401, "UnauthorizedError"), + (403, "ForbiddenError"), + (404, "ResourceNotFoundError"), + (307, "AzureResponseError"), + (500, "AzureResponseError"), # unmapped -> generic, still traceback-free + ], +) +def test_http_errors_map_to_azclierror(client, status, exc_name): + from azure.cli.core import azclierror + + client._session.request = lambda method, url, **kw: FakeResponse( + status_code=status, reason="Boom", json_data={"detail": "nope"} + ) + with pytest.raises(getattr(azclierror, exc_name)): + client.get("/repositories/") + + +def test_transport_failure_wrapped(client): + import requests + from azure.cli.core.azclierror import AzureResponseError + + def boom(method, url, **kw): + raise requests.ConnectionError("down") + + client._session.request = boom + with pytest.raises(AzureResponseError): + client.get("/repositories/") + + +@pytest.mark.parametrize( + "path", + [ + "/publications/../repositories/repositories-rpm-id/", + "/publications/%2e%2e/repositories/repositories-rpm-id/", + "/publications/id?target=/repositories/", + "/publications/id#fragment/", + "/publications\\..\\repositories\\id/", + "/publications//id/", + "/publications/\x7f/id/", + "//publications/id/", + "/publications/id//", + "/", + ], +) +def test_unsafe_request_path_rejected_before_auth(monkeypatch, path): + from azure.cli.core.azclierror import ValidationError + + from azext_apr.server import _data_plane + + monkeypatch.setattr( + _data_plane, + "_acquire_token", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("token acquired for unsafe path")), + ) + client = _data_plane.DataPlaneClient( + cli_ctx=object(), base_url="https://r.eastus.api.apr.azure.net" + ) + with pytest.raises(ValidationError, match="invalid request path"): + client.delete(path) + + +def test_streaming_multipart_body_is_reiterable(tmp_path): + from azext_apr.server._data_plane import _StreamingMultipart + + package = tmp_path / "package.deb" + package.write_bytes(b"package contents") + body = _StreamingMultipart({"repository": "repositories-deb-apt-id"}, package) + + first = b"".join(body) + second = b"".join(body) + + assert first == second + assert b'name="repository"' in first + assert b"repositories-deb-apt-id" in first + assert b'filename="package.deb"' in first + assert b"package contents" in first + + +def test_streaming_multipart_prepares_as_chunked_transfer(tmp_path): + import requests + + from azext_apr.server._data_plane import _StreamingMultipart + + package = tmp_path / "package.deb" + package.write_bytes(b"package contents") + body = _StreamingMultipart({"repository": "repositories-deb-apt-id"}, package) + prepared = requests.Request( + "POST", + "https://r.eastus.api.apr.azure.net/api/v1/packages/", + data=body, + headers={"Content-Type": body.content_type}, + ).prepare() + + assert prepared.body is body + assert prepared.headers["Transfer-Encoding"] == "chunked" + assert "Content-Length" not in prepared.headers + + +def test_post_multipart_sends_streaming_body_without_retry(client, monkeypatch, tmp_path): + import requests + + package = tmp_path / "package.deb" + package.write_bytes(b"package contents") + sent = {} + + client._session.request = lambda *args, **kwargs: pytest.fail( + "multipart uploads must not use the retrying session" + ) + monkeypatch.setattr( + requests, + "request", + lambda method, url, **kwargs: ( + sent.update(method=method, url=url, kwargs=kwargs) or FakeResponse() + ), + ) + + client.post_multipart( + "/packages/", + fields={"repository": "repositories-deb-apt-id"}, + file_path=package, + ) + + assert sent["method"] == "POST" + assert sent["url"].endswith("/api/v1/packages/") + assert sent["kwargs"]["allow_redirects"] is False + assert sent["kwargs"]["headers"]["Content-Type"].startswith("multipart/form-data; boundary=") + assert b"package contents" in b"".join(sent["kwargs"]["data"]) + + +# --- endpoint resolution --------------------------------------------------- + + +def _patch_arm(monkeypatch, pages): + """Patch send_raw_request to yield successive ARM pages, and a fixed sub.""" + from azext_apr.server import _data_plane + + responses = iter(pages) + monkeypatch.setattr(_data_plane, "get_subscription_id", lambda cli_ctx: "sub-1") + monkeypatch.setattr( + _data_plane, + "send_raw_request", + lambda cli_ctx, method, url: FakeResponse(json_data=next(responses)), + ) + + +def _cmd(): + from unittest import mock + + return mock.Mock(cli_ctx=object()) + + +def test_resolve_endpoint_with_resource_group(monkeypatch): + from azext_apr.server._data_plane import resolve_api_endpoint + + _patch_arm(monkeypatch, [{"properties": {"apiEndpoint": "https://r.eastus.api.apr.azure.net"}}]) + endpoint = resolve_api_endpoint(_cmd(), "r", resource_group_name="rg1") + assert endpoint == "https://r.eastus.api.apr.azure.net" + + +def test_resolve_endpoint_subscription_wide_match(monkeypatch): + from azext_apr.server._data_plane import resolve_api_endpoint + + _patch_arm( + monkeypatch, + [ + { + "value": [{"name": "other", "properties": {"apiEndpoint": "x"}}], + "nextLink": "/page2", + }, + {"value": [{"name": "r", "properties": {"apiEndpoint": "https://hit"}}]}, + ], + ) + assert resolve_api_endpoint(_cmd(), "R") == "https://hit" # case-insensitive + + +def test_resolve_endpoint_ambiguous_name_requires_rg(monkeypatch): + from azure.cli.core.azclierror import RequiredArgumentMissingError + + from azext_apr.server._data_plane import resolve_api_endpoint + + _patch_arm( + monkeypatch, + [ + { + "value": [ + {"name": "r", "id": "/subscriptions/s/resourceGroups/rg1/providers/x"}, + {"name": "r", "id": "/subscriptions/s/resourceGroups/rg2/providers/x"}, + ] + } + ], + ) + with pytest.raises(RequiredArgumentMissingError, match="rg1, rg2"): + resolve_api_endpoint(_cmd(), "r") + + +def test_resolve_endpoint_not_found(monkeypatch): + from azure.cli.core.azclierror import ResourceNotFoundError + + from azext_apr.server._data_plane import resolve_api_endpoint + + _patch_arm(monkeypatch, [{"value": []}]) + with pytest.raises(ResourceNotFoundError): + resolve_api_endpoint(_cmd(), "missing") + + +def test_resolve_endpoint_missing_api_endpoint(monkeypatch): + from azure.cli.core.azclierror import AzureResponseError + + from azext_apr.server._data_plane import resolve_api_endpoint + + _patch_arm(monkeypatch, [{"properties": {}}]) + with pytest.raises(AzureResponseError): + resolve_api_endpoint(_cmd(), "r", resource_group_name="rg1") + + +# --- dev-build detection / localhost auth skip ----------------------------- + + +def _patch_extensions(monkeypatch, ext_type, cover=True): + """Patch azure.cli.core.extension.get_extensions() to return one fake + extension. When cover is True its path contains this test module's package + (so is_dev_extension matches it by path); otherwise the path is unrelated. + """ + import os + from pathlib import Path + from unittest import mock + + import azure.cli.core.extension as ext_mod + + from azext_apr.server import _data_plane + + module_dir = Path(_data_plane.__file__).resolve().parent + # Parent-of-parent of .../azext_apr/server is the repo dir that contains the + # package — this is the dir a dev extension is rooted at. + covering_path = str(module_dir.parent.parent) + path = covering_path if cover else f"{os.sep}some{os.sep}unrelated{os.sep}extension" + fake = mock.Mock(ext_type=ext_type, path=path) + monkeypatch.setattr(ext_mod, "get_extensions", lambda *a, **k: [fake], raising=True) + + +def test_is_dev_extension_true_for_dev_build(monkeypatch): + from azext_apr.server import _data_plane + + _patch_extensions(monkeypatch, "dev") + assert _data_plane.is_dev_extension() is True + + +def test_is_dev_extension_false_for_wheel(monkeypatch): + from azext_apr.server import _data_plane + + _patch_extensions(monkeypatch, "whl") + assert _data_plane.is_dev_extension() is False + + +def test_is_dev_extension_false_when_module_not_under_any_extension(monkeypatch): + from azext_apr.server import _data_plane + + # A dev extension exists, but its path does not contain this module — so we + # must not misattribute its 'dev' type to ourselves. + _patch_extensions(monkeypatch, "dev", cover=False) + assert _data_plane.is_dev_extension() is False + + +def test_is_dev_extension_false_on_error(monkeypatch): + import azure.cli.core.extension as ext_mod + + from azext_apr.server import _data_plane + + def _boom(*a, **k): + raise RuntimeError("core too old") + + monkeypatch.setattr(ext_mod, "get_extensions", _boom, raising=True) + assert _data_plane.is_dev_extension() is False + + +def test_for_registry_dev_build_uses_local_and_skips_arm(monkeypatch): + from unittest import mock + + from azext_apr.server import _data_plane + + def _boom(*a, **k): + raise AssertionError("resolve_api_endpoint should not be called for a dev build") + + monkeypatch.setattr(_data_plane, "resolve_api_endpoint", _boom) + monkeypatch.setattr(_data_plane, "is_dev_extension", lambda: True) + cmd = mock.Mock(cli_ctx=object()) + client = _data_plane.DataPlaneClient.for_registry(cmd, "local") + assert client.base_url == _data_plane.DEV_LOCAL_ENDPOINT + assert client.base_url == "http://localhost:8100" + + +def test_for_registry_installed_build_resolves_via_arm(monkeypatch): + from unittest import mock + + from azext_apr.server import _data_plane + + monkeypatch.setattr(_data_plane, "is_dev_extension", lambda: False) + monkeypatch.setattr( + _data_plane, + "resolve_api_endpoint", + lambda cmd, name, rg=None: "https://r.eastus.api.apr.azure.net", + ) + cmd = mock.Mock(cli_ctx=object()) + client = _data_plane.DataPlaneClient.for_registry(cmd, "r") + assert client.base_url == "https://r.eastus.api.apr.azure.net" + + +def test_raise_if_dev_extension_raises_for_dev_build(monkeypatch): + from azure.cli.core.azclierror import ValidationError + + from azext_apr.server import _data_plane + + monkeypatch.setattr(_data_plane, "is_dev_extension", lambda: True) + with pytest.raises(ValidationError): + _data_plane.raise_if_dev_extension() + + +def test_raise_if_dev_extension_noop_for_installed_build(monkeypatch): + from azext_apr.server import _data_plane + + monkeypatch.setattr(_data_plane, "is_dev_extension", lambda: False) + _data_plane.raise_if_dev_extension() # must not raise + + +@pytest.mark.parametrize("base_url", ["http://localhost:8000", "http://127.0.0.1:8000"]) +def test_localhost_client_skips_auth(monkeypatch, base_url): + from azext_apr.server import _data_plane + + # If auth were attempted for localhost this would blow up, proving it's skipped. + monkeypatch.setattr( + _data_plane, + "_acquire_token", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("token acquired for localhost")), + ) + client = _data_plane.DataPlaneClient(cli_ctx=object(), base_url=base_url) + sent = {} + client._session.request = lambda method, url, **kw: ( + sent.update(headers=kw["headers"]) or FakeResponse() + ) + client.get("/repositories/") + assert "authorization" not in sent["headers"] + assert "x-correlation-id" in sent["headers"] + assert "apr-cli-version" in sent["headers"] diff --git a/src/apr/azext_apr/tests/test_data_plane_composition.py b/src/apr/azext_apr/tests/test_data_plane_composition.py new file mode 100644 index 00000000000..5bf96eb9ccc --- /dev/null +++ b/src/apr/azext_apr/tests/test_data_plane_composition.py @@ -0,0 +1,161 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Mocked-transport coverage for command-to-data-plane composition.""" + +import pytest + +pytest.importorskip("azure.cli.core") + +import requests + +from azext_apr.commands import package, publication, remote, repository +from azext_apr.server import _data_plane +from azext_apr.server._data_plane import DataPlaneClient + +API = "https://r.eastus.api.apr.azure.net" +# The client owns the versioned path, so ARM still hands back the bare host as +# apiEndpoint, but every request goes to the versioned root. +API_ROOT = f"{API}/api/v1" +REPOSITORY_ID = "repositories-deb-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + +class Response: + def __init__(self, status=200, data=None, reason="OK"): + self.status_code = status + self._data = data + self.content = b"" if data is None else b"{...}" + self.reason = reason + self.text = "" + + def json(self): + return self._data + + +class Command: + cli_ctx = object() + + +@pytest.fixture +def transport(monkeypatch): + # These tests exercise the installed/ARM path, so pin the build type to a + # wheel install regardless of any ambient `extension.dev_sources` config. + monkeypatch.setattr(_data_plane, "is_dev_extension", lambda: False) + monkeypatch.setattr(_data_plane, "_acquire_token", lambda cli_ctx, resource: "tok") + monkeypatch.setattr(_data_plane, "get_subscription_id", lambda cli_ctx: "sub-123") + monkeypatch.setattr( + _data_plane, + "send_raw_request", + lambda cli_ctx, method, url: Response(data={"properties": {"apiEndpoint": API}}), + ) + + calls = [] + routes = {} + + def request(self, method, url, **kwargs): + calls.append((method, url, kwargs)) + key = (method, url) + if key not in routes: + pytest.fail(f"Unexpected data-plane request: {method} {url} kwargs={kwargs}") + return routes[key] + + monkeypatch.setattr(requests.Session, "request", request) + return calls, routes + + +def test_publish_uses_real_client_and_polls_task(transport): + calls, routes = transport + routes[("POST", f"{API_ROOT}/repositories/{REPOSITORY_ID}/publish/")] = Response( + 202, {"task": "tasks-9"} + ) + routes[("GET", f"{API_ROOT}/tasks/tasks-9/")] = Response( + 200, {"id": "tasks-9", "state": "completed"} + ) + + result = repository.publish_repository( + Command(), "myreg", REPOSITORY_ID, force=True, resource_group_name="rg" + ) + + assert result["state"] == "completed" + post = next(call for call in calls if call[0] == "POST") + assert post[1] == f"{API_ROOT}/repositories/{REPOSITORY_ID}/publish/" + assert post[2]["json"] == {"force": True} + assert post[2]["headers"]["authorization"].startswith("Bearer ") + assert "x-correlation-id" in post[2]["headers"] + assert ("GET", f"{API_ROOT}/tasks/tasks-9/") in [(method, url) for method, url, _ in calls] + + +def test_remote_create_no_wait_skips_task_poll(transport): + calls, routes = transport + routes[("POST", f"{API_ROOT}/remotes/")] = Response(202, {"task": "tasks-3"}) + + result = remote.create_remote( + Command(), + "myreg", + "upstream", + "apt", + "https://upstream.example", + releases="jammy", + resource_group_name="rg", + no_wait=True, + ) + + assert result == {"task": "tasks-3"} + assert all("/tasks/" not in url for _, url, _ in calls) + + +def test_package_upload_uses_streaming_multipart_and_polls_task(transport, tmp_path): + calls, routes = transport + source = tmp_path / "package.deb" + source.write_bytes(b"package contents") + routes[("GET", f"{API_ROOT}/repositories/")] = Response( + 200, {"results": [{"id": REPOSITORY_ID}]} + ) + routes[("GET", f"{API_ROOT}/repositories/{REPOSITORY_ID}/releases/")] = Response( + 200, {"results": [{"id": "releases-jammy"}]} + ) + routes[("POST", f"{API_ROOT}/packages/")] = Response(202, {"task": "tasks-upload"}) + routes[("GET", f"{API_ROOT}/tasks/tasks-upload/")] = Response( + 200, {"id": "tasks-upload", "state": "completed"} + ) + + result = package.upload_package( + Command(), + "myreg", + str(source), + "myrepo", + file_type="deb", + release="jammy", + resource_group_name="rg", + ) + + assert result == {"id": "tasks-upload", "state": "completed"} + post = next(call for call in calls if call[0] == "POST") + body = b"".join(post[2]["data"]) + assert b'name="repository"' in body + assert REPOSITORY_ID.encode() in body + assert b'name="release"' in body + assert b"releases-jammy" in body + assert b'name="file_type"' in body + assert b"deb" in body + assert b'filename="package.deb"' in body + assert b"package contents" in body + assert all(method != "PATCH" for method, _, _ in calls) + + +@pytest.mark.parametrize("factory", [repository._client, remote._client, publication._client]) +def test_command_client_factories_delegate_to_data_plane_client(monkeypatch, factory): + calls = [] + client = object() + + def for_registry(cls, cmd, registry_name, resource_group_name=None): + calls.append((cmd, registry_name, resource_group_name)) + return client + + monkeypatch.setattr(DataPlaneClient, "for_registry", classmethod(for_registry)) + + command = Command() + assert factory(command, "myreg", "rg") is client + assert calls == [(command, "myreg", "rg")] diff --git a/src/apr/azext_apr/tests/test_distro.py b/src/apr/azext_apr/tests/test_distro.py new file mode 100644 index 00000000000..3b84ed48cf2 --- /dev/null +++ b/src/apr/azext_apr/tests/test_distro.py @@ -0,0 +1,345 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import pytest + +pytest.importorskip("azure.cli.core") + +from azext_apr.commands import distro + +VERBS = ["list", "create", "show", "update", "delete"] + +# real-looking APR resource ids - match the resolver's regex, so they skip the name lookup +DISTRO_ID = "distributions-deb-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +REPOSITORY_ID = "repositories-deb-apt-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +PUBLICATION_ID = "publications-deb-apt-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + +class FakeResp: + def __init__(self, json_data=None): + self._json = {} if json_data is None else json_data + self.content = b"{}" + + def json(self): + return self._json + + +class FakeClient: + """Records calls; answers name-filter lookups so the resolver can run.""" + + def __init__(self): + self.calls = [] + self.post_response = {"id": DISTRO_ID} + self.patch_response = {"id": DISTRO_ID} + self.delete_response = {} + + def _record(self, method, path, kwargs): + self.calls.append((method, path, kwargs)) + + def get(self, path, **kwargs): + self._record("GET", path, kwargs) + # the resolver looks a name up via the list endpoint with a ?name= filter + if "name" in (kwargs.get("params") or {}): + resource = path.strip("/").split("/")[0] + return FakeResp({"results": [{"id": f"{resource}-resolved-id"}]}) + return FakeResp({"results": []}) + + def post(self, path, **kwargs): + self._record("POST", path, kwargs) + return FakeResp(self.post_response) + + def patch(self, path, **kwargs): + self._record("PATCH", path, kwargs) + return FakeResp(self.patch_response) + + def delete(self, path, **kwargs): + self._record("DELETE", path, kwargs) + return FakeResp(self.delete_response) + + +@pytest.fixture +def fake(monkeypatch): + client = FakeClient() + monkeypatch.setattr(distro, "_client", lambda cmd, rn, rg=None: client) + return client + + +def test_helps_registered(): + from knack.help_files import helps + + assert "apr distro" in helps + for verb in VERBS: + assert f"apr distro {verb}" in helps + + +def test_command_table_registers_distro_verbs(): + from azure.cli.core.mock import DummyCli + + from azext_apr import AprCommandsLoader + + loader = AprCommandsLoader(cli_ctx=DummyCli()) + loader.load_command_table(None) + + registered = {k for k in loader.command_table if k.startswith("apr distro")} + expected = {f"apr distro {v}" for v in VERBS} + assert registered == expected + + +def test_arguments_load_and_expose_expected_options(): + from azure.cli.core.mock import DummyCli + + from azext_apr import AprCommandsLoader + + loader = AprCommandsLoader(cli_ctx=DummyCli()) + loader.skip_applicability = True + loader.load_command_table(None) + loader.load_arguments("apr distro create") + registry = loader.argument_registry.arguments + + def options(scope, dest): + return set(registry[scope][dest].settings.get("options_list") or []) + + assert {"--registry", "-r"} <= options("apr distro", "registry_name") + assert {"--name", "-n"} <= options("apr distro", "distro_name") + assert "--type" in options("apr distro create", "distro_type") + assert {"--base-path"} <= options("apr distro create", "base_path") + assert {"--repository"} <= options("apr distro create", "repository") + assert {"--publication"} <= options("apr distro create", "publication") + # --repository is optional; a distribution may serve a repository, a + # publication, or neither (backing can be set later). + assert not registry["apr distro create"]["repository"].settings.get("required") + # --type is an enum type (case-insensitive), not a raw choices= list + assert registry["apr distro create"]["distro_type"].settings.get("choices") + # --no-wait is wired (via supports_no_wait) on the long-running verbs only. + for verb in ("create", "update", "delete"): + assert loader.command_table[f"apr distro {verb}"].supports_no_wait + for verb in ("show", "list"): + assert not loader.command_table[f"apr distro {verb}"].supports_no_wait + + +def test_list_pages_through_everything_by_default(fake): + distro.list_distros(None, "reg") + assert fake.calls == [("GET", "/distributions/", {"params": {"limit": 100, "offset": 0}})] + + +def test_list_with_limit_caps_the_first_page(fake): + distro.list_distros(None, "reg", limit=5) + assert fake.calls == [("GET", "/distributions/", {"params": {"limit": 5, "offset": 0}})] + + +def test_list_with_zero_limit_is_rejected(fake): + from azure.cli.core.azclierror import InvalidArgumentValueError + + with pytest.raises(InvalidArgumentValueError): + distro.list_distros(None, "reg", limit=0) + assert fake.calls == [] + + +def test_list_filters_by_resolved_repository(fake): + distro.list_distros(None, "reg", repository="myrepo") + # name resolved to an id first, then that id is passed as the list filter + resolve, listing = fake.calls + assert resolve[:2] == ("GET", "/repositories/") + assert resolve[2]["params"] == {"name": "myrepo"} + assert listing == ( + "GET", + "/distributions/", + {"params": {"repository": "repositories-resolved-id", "limit": 100, "offset": 0}}, + ) + + +def test_list_repository_filter_passes_id_through(fake): + distro.list_distros(None, "reg", repository=REPOSITORY_ID) + # an id needs no lookup, so it's a single list call with the filter applied + assert fake.calls == [ + ( + "GET", + "/distributions/", + {"params": {"repository": REPOSITORY_ID, "limit": 100, "offset": 0}}, + ) + ] + + +def test_create_posts_full_body_with_resolved_repository(fake): + distro.create_distro(None, "reg", "mydistro", "apt", "my/path", REPOSITORY_ID) + method, path, kwargs = fake.calls[0] + assert (method, path) == ("POST", "/distributions/") + assert kwargs["json"] == { + "name": "mydistro", + "type": "apt", + "base_path": "my/path", + "repository": REPOSITORY_ID, + } + + +def test_create_resolves_repository_name(fake): + distro.create_distro(None, "reg", "mydistro", "apt", "my/path", "myrepo", hidden=True) + resolve, create = fake.calls + assert resolve[:2] == ("GET", "/repositories/") + assert create[2]["json"]["repository"] == "repositories-resolved-id" + assert create[2]["json"]["hidden"] is True + + +def test_create_with_publication_sends_id_and_omits_repository(fake): + distro.create_distro(None, "reg", "mydistro", "apt", "my/path", publication=PUBLICATION_ID) + method, path, kwargs = fake.calls[0] + assert (method, path) == ("POST", "/distributions/") + assert kwargs["json"]["publication"] == PUBLICATION_ID + assert "repository" not in kwargs["json"] + + +def test_create_without_backing_omits_repository_and_publication(fake): + distro.create_distro(None, "reg", "mydistro", "apt", "my/path") + body = fake.calls[0][2]["json"] + assert "repository" not in body + assert "publication" not in body + + +def test_create_rejects_repository_and_publication_together(fake): + from azure.cli.core.azclierror import MutuallyExclusiveArgumentError + + with pytest.raises(MutuallyExclusiveArgumentError): + distro.create_distro( + None, "reg", "mydistro", "apt", "my/path", REPOSITORY_ID, PUBLICATION_ID + ) + assert fake.calls == [] + + +def test_create_rejects_empty_repository_with_publication(fake): + # an empty string is still "provided" - it must not slip past validation + from azure.cli.core.azclierror import MutuallyExclusiveArgumentError + + with pytest.raises(MutuallyExclusiveArgumentError): + distro.create_distro(None, "reg", "mydistro", "apt", "my/path", "", PUBLICATION_ID) + assert fake.calls == [] + + +def test_update_sets_publication(fake): + distro.update_distro(None, "reg", DISTRO_ID, publication=PUBLICATION_ID) + patch = next(c for c in fake.calls if c[0] == "PATCH") + assert patch[2]["json"] == {"publication": PUBLICATION_ID} + + +def test_update_rejects_repository_and_publication_together(fake): + from azure.cli.core.azclierror import MutuallyExclusiveArgumentError + + with pytest.raises(MutuallyExclusiveArgumentError): + distro.update_distro( + None, "reg", DISTRO_ID, repository="myrepo", publication=PUBLICATION_ID + ) + assert fake.calls == [] + + +def test_show_resolves_name_then_gets(fake): + distro.show_distro(None, "reg", "mydistro") + paths = [c[1] for c in fake.calls] + assert paths == ["/distributions/", "/distributions/distributions-resolved-id/"] + assert fake.calls[0][2]["params"] == {"name": "mydistro"} + + +def test_show_passes_id_through_without_lookup(fake): + distro.show_distro(None, "reg", DISTRO_ID) + assert fake.calls == [("GET", f"/distributions/{DISTRO_ID}/", {})] + + +def test_update_requires_a_property(fake): + from azure.cli.core.azclierror import RequiredArgumentMissingError + + with pytest.raises(RequiredArgumentMissingError): + distro.update_distro(None, "reg", DISTRO_ID) + assert fake.calls == [] + + +def test_update_patches_only_provided_fields(fake): + distro.update_distro(None, "reg", DISTRO_ID, base_path="new/path", hidden=False) + method, path, kwargs = fake.calls[0] + assert (method, path) == ("PATCH", f"/distributions/{DISTRO_ID}/") + assert kwargs["json"] == {"base_path": "new/path", "hidden": False} + + +def test_update_resolves_repository(fake): + distro.update_distro(None, "reg", DISTRO_ID, repository="myrepo") + # resolves the repository name, then patches with the id + assert fake.calls[0][:2] == ("GET", "/repositories/") + patch = next(c for c in fake.calls if c[0] == "PATCH") + assert patch[:2] == ("PATCH", f"/distributions/{DISTRO_ID}/") + assert patch[2]["json"] == {"repository": "repositories-resolved-id"} + + +def test_update_ignores_empty_base_path(fake): + # an empty --base-path is falsy, so it's treated as "not provided" + from azure.cli.core.azclierror import RequiredArgumentMissingError + + with pytest.raises(RequiredArgumentMissingError): + distro.update_distro(None, "reg", DISTRO_ID, base_path="") + assert fake.calls == [] + + +def test_delete_resolves_and_deletes(fake): + distro.delete_distro(None, "reg", DISTRO_ID) + assert fake.calls == [("DELETE", f"/distributions/{DISTRO_ID}/", {})] + + +def test_create_no_wait_returns_task_handle_without_polling(fake): + fake.post_response = {"task": "t1"} + result = distro.create_distro( + None, "reg", "mydistro", "apt", "my/path", REPOSITORY_ID, no_wait=True + ) + assert result == {"task": "t1"} + assert all("/tasks/" not in c[1] for c in fake.calls) + + +def test_delete_waits_on_task_by_default(fake): + # an async delete returns a task handle; without --no-wait we poll it to completion + fake.delete_response = {"task": "t1"} + + def get(path, **kwargs): + fake._record("GET", path, kwargs) + if path == "/tasks/t1/": + return FakeResp({"id": "t1", "state": "completed"}) + return FakeResp({"results": []}) + + fake.get = get + distro.delete_distro(None, "reg", DISTRO_ID) + assert ("GET", "/tasks/t1/", {}) in fake.calls + + +def test_create_returns_distribution_from_created_resources(fake): + # async create returns a task; we poll it, read created_resources, then GET + # the new distribution so the user sees the resource, not a bare task. + fake.post_response = {"task": "t1"} + + def get(path, **kwargs): + fake._record("GET", path, kwargs) + if path == "/tasks/t1/": + return FakeResp({"id": "t1", "state": "completed", "created_resources": [DISTRO_ID]}) + if path == f"/distributions/{DISTRO_ID}/": + return FakeResp({"id": DISTRO_ID, "name": "mydistro"}) + return FakeResp({"results": []}) + + fake.get = get + result = distro.create_distro(None, "reg", "mydistro", "apt", "my/path", REPOSITORY_ID) + assert result == {"id": DISTRO_ID, "name": "mydistro"} + assert ("GET", "/tasks/t1/", {}) in fake.calls + assert ("GET", f"/distributions/{DISTRO_ID}/", {}) in fake.calls + + +def test_update_returns_distribution_after_task(fake): + # async update returns a task with no created_resources; fall back to the + # known id and GET the updated distribution. + fake.patch_response = {"task": "t1"} + + def get(path, **kwargs): + fake._record("GET", path, kwargs) + if path == "/tasks/t1/": + return FakeResp({"id": "t1", "state": "completed"}) + if path == f"/distributions/{DISTRO_ID}/": + return FakeResp({"id": DISTRO_ID, "base_path": "new/path"}) + return FakeResp({"results": []}) + + fake.get = get + result = distro.update_distro(None, "reg", DISTRO_ID, base_path="new/path") + assert result == {"id": DISTRO_ID, "base_path": "new/path"} + assert ("GET", f"/distributions/{DISTRO_ID}/", {}) in fake.calls diff --git a/src/apr/azext_apr/tests/test_extension.py b/src/apr/azext_apr/tests/test_extension.py new file mode 100644 index 00000000000..dad01bc626d --- /dev/null +++ b/src/apr/azext_apr/tests/test_extension.py @@ -0,0 +1,78 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import pytest + +pytest.importorskip("azure.cli.core") + + +def test_loader_instantiates(): + from azext_apr import COMMAND_LOADER_CLS, AprCommandsLoader + + assert COMMAND_LOADER_CLS is AprCommandsLoader + + +def test_client_factory_placeholder_raises(monkeypatch): + from azext_apr._client_factory import cf_apr + from azext_apr.server import _data_plane + + # a dev build is refused before reaching the placeholder, and this test runs + # as one for anyone who has registered extension.dev_sources + monkeypatch.setattr(_data_plane, "is_dev_extension", lambda: False) + with pytest.raises(NotImplementedError): + cf_apr(cli_ctx=None) + + +def test_command_table_registers_registry_verbs(): + from azure.cli.core.mock import DummyCli + + from azext_apr import AprCommandsLoader + + loader = AprCommandsLoader(cli_ctx=DummyCli()) + loader.load_command_table(None) + + registered = {k for k in loader.command_table if k.startswith("apr registry")} + expected = { + f"apr registry {verb}" for verb in ["create", "delete", "show", "list", "update", "wait"] + } + assert registered == expected + + +def test_data_plane_commands_resolve_to_own_module_without_control_plane_factory(): + # Regression guard: every data-plane command group must register with + # `custom_command_type=`. Passing the type positionally (as + # `command_type`) silently falls back to the loader default, which points at + # the control-plane `registry` module AND attaches `cf_apr` — so the command + # resolves to a nonexistent `registry#` op and dies in the client + # factory before its handler ever runs. + from azure.cli.core.mock import DummyCli + + from azext_apr import AprCommandsLoader + + loader = AprCommandsLoader(cli_ctx=DummyCli()) + loader.load_command_table(None) + + data_plane = { + name: cmd + for name, cmd in loader.command_table.items() + if name.startswith("apr ") and not name.startswith("apr registry") + } + assert data_plane, "expected data-plane commands to be registered" + for name, cmd in data_plane.items(): + kwargs = cmd.command_kwargs + assert kwargs.get("client_factory") is None, ( + f"{name!r} leaked the control-plane client_factory; register its group " + f"with custom_command_type=, not a positional command_type" + ) + assert kwargs["operations_tmpl"] != "azext_apr.commands.registry#{}", ( + f"{name!r} resolves to the control-plane registry module instead of its " + f"own data-plane module" + ) + + # Registry commands are generated AAZ management-plane commands rather than + # custom commands that inherit the data-plane loader's placeholder factory. + for verb in ["create", "delete", "show", "list", "update", "wait"]: + cmd = loader.command_table[f"apr registry {verb}"] + assert cmd.command_kwargs.get("client_factory") is None diff --git a/src/apr/azext_apr/tests/test_package.py b/src/apr/azext_apr/tests/test_package.py new file mode 100644 index 00000000000..545ea2afe0d --- /dev/null +++ b/src/apr/azext_apr/tests/test_package.py @@ -0,0 +1,416 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import pytest + +pytest.importorskip("azure.cli.core") + +from azext_apr.commands import package + +# each package type is its own subgroup with list + show +SUBGROUPS = ["deb", "debsrc", "rpm", "file"] + +PACKAGE_ID = "packages-deb-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +REPOSITORY_ID = "repositories-deb-apt-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +RELEASE_ID = "content-deb-releases-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +TASK_ID = "tasks-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + +class FakeResp: + def __init__(self, json_data=None): + self._json = {} if json_data is None else json_data + self.content = b"{}" + + def json(self): + return self._json + + +class FakeClient: + """Records calls; answers name-filter lookups so the resolver can run.""" + + def __init__(self): + self.calls = [] + self.created_resources = [] + + def _record(self, method, path, kwargs): + self.calls.append((method, path, kwargs)) + + def get(self, path, **kwargs): + self._record("GET", path, kwargs) + if path == f"/tasks/{TASK_ID}/": + body = {"id": TASK_ID, "state": "completed"} + if self.created_resources: + body["created_resources"] = self.created_resources + return FakeResp(body) + if "name" in (kwargs.get("params") or {}): + # key off the last path segment so nested lookups (releases) resolve + # to their own id, not the parent repository's + resource = path.strip("/").split("/")[-1] + return FakeResp({"results": [{"id": f"{resource}-resolved-id"}]}) + return FakeResp({"results": []}) + + def post_multipart(self, path, **kwargs): + self._record("POST_MULTIPART", path, kwargs) + return FakeResp({"task": TASK_ID}) + + def patch(self, path, **kwargs): + self._record("PATCH", path, kwargs) + return FakeResp({"task": TASK_ID}) + + +@pytest.fixture +def fake(monkeypatch): + client = FakeClient() + monkeypatch.setattr(package, "_client", lambda cmd, rn, rg=None: client) + return client + + +def test_helps_registered(): + from knack.help_files import helps + + assert "apr package" in helps + assert "apr package upload" in helps + for sub in SUBGROUPS: + assert f"apr package {sub}" in helps + assert f"apr package {sub} list" in helps + assert f"apr package {sub} show" in helps + + +def test_command_table_registers_per_type_subgroups(): + from azure.cli.core.mock import DummyCli + + from azext_apr import AprCommandsLoader + + loader = AprCommandsLoader(cli_ctx=DummyCli()) + loader.load_command_table(None) + + registered = {k for k in loader.command_table if k.startswith("apr package")} + expected = {"apr package upload"} + for sub in SUBGROUPS: + expected.add(f"apr package {sub} list") + expected.add(f"apr package {sub} show") + assert registered == expected + + +def test_arguments_are_scoped_per_type(): + from azure.cli.core.mock import DummyCli + + from azext_apr import AprCommandsLoader + + loader = AprCommandsLoader(cli_ctx=DummyCli()) + loader.skip_applicability = True + loader.load_command_table(None) + loader.load_arguments("apr package upload") + for sub in SUBGROUPS: + loader.load_arguments(f"apr package {sub} list") + loader.load_arguments(f"apr package {sub} show") + registry = loader.argument_registry.arguments + + def options(scope, dest): + return set(registry[scope][dest].settings.get("options_list") or []) + + for sub in SUBGROUPS: + # registry_name/-r propagates from the top "apr package" scope + assert {"--registry", "-r"} <= options("apr package", "registry_name") + assert {"--id"} <= options(f"apr package {sub} show", "package_id") + assert {"--repository"} <= options(f"apr package {sub} list", "repository") + assert {"--name"} <= options(f"apr package {sub} list", "name") + assert {"--sha256"} <= options(f"apr package {sub} list", "sha256") + + # --release only exists on the types that have a release concept + for sub in ("deb", "debsrc", "rpm"): + assert {"--release"} <= options(f"apr package {sub} list", "release") + # file has no --release argument at all + assert "release" not in registry.get("apr package file list", {}) + assert {"--file"} <= options("apr package upload", "package") + assert {"--repository"} <= options("apr package upload", "repository") + assert {"--type", "-t"} <= options("apr package upload", "file_type") + assert {"--relative-path"} <= options("apr package upload", "relative_path") + assert {"--release"} <= options("apr package upload", "release") + assert {"--component"} <= options("apr package upload", "component") + assert loader.command_table["apr package upload"].supports_no_wait + + +def test_upload_resolves_repository_and_waits_for_task(fake, tmp_path): + source = tmp_path / "package.deb" + source.write_bytes(b"package contents") + + result = package.upload_package( + None, + "reg", + str(source), + "myrepo", + file_type="file", + relative_path="config/package.deb", + ) + + assert fake.calls[0] == ("GET", "/repositories/", {"params": {"name": "myrepo"}}) + method, path, kwargs = fake.calls[1] + assert (method, path) == ("POST_MULTIPART", "/packages/") + assert kwargs["fields"] == { + "repository": "repositories-resolved-id", + "file_type": "file", + "relative_path": "config/package.deb", + } + assert kwargs["file_path"] == source + assert fake.calls[2] == ("GET", f"/tasks/{TASK_ID}/", {}) + assert result == {"id": TASK_ID, "state": "completed"} + + +def test_upload_without_repository_skips_resolution(fake, tmp_path): + source = tmp_path / "package.deb" + source.write_bytes(b"package contents") + + result = package.upload_package(None, "reg", str(source), no_wait=True) + + assert fake.calls == [ + ( + "POST_MULTIPART", + "/packages/", + {"fields": {}, "file_path": source}, + ) + ] + assert result == {"task": TASK_ID} + + +def test_upload_no_wait_returns_task_handle(fake, tmp_path): + source = tmp_path / "package.rpm" + source.write_bytes(b"package contents") + + result = package.upload_package(None, "reg", str(source), REPOSITORY_ID, no_wait=True) + + assert result == {"task": TASK_ID} + assert all(path != f"/tasks/{TASK_ID}/" for _, path, _ in fake.calls) + + +@pytest.mark.parametrize("extension", [".deb", ".rpm"]) +def test_upload_lets_server_infer_deb_and_rpm_types(fake, tmp_path, extension): + source = tmp_path / f"package{extension}" + source.write_bytes(b"package contents") + + if extension == ".deb": + package.upload_package(None, "reg", str(source), no_wait=True) + else: + package.upload_package(None, "reg", str(source), REPOSITORY_ID, no_wait=True) + + _, path, kwargs = fake.calls[-1] + assert path == "/packages/" + expected = {} if extension == ".deb" else {"repository": REPOSITORY_ID} + assert kwargs["fields"] == expected + + +def test_upload_deb_adds_created_package_to_release_component(fake, tmp_path): + source = tmp_path / "package.deb" + source.write_bytes(b"package contents") + + package.upload_package( + None, + "reg", + str(source), + "myrepo", + release="jammy", + component="contrib", + ) + + assert fake.calls[0] == ("GET", "/repositories/", {"params": {"name": "myrepo"}}) + assert fake.calls[1] == ( + "GET", + "/repositories/repositories-resolved-id/releases/", + {"params": {"name": "jammy"}}, + ) + assert fake.calls[2][0:2] == ("POST_MULTIPART", "/packages/") + assert fake.calls[2][2]["fields"] == { + "repository": "repositories-resolved-id", + "release": "releases-resolved-id", + "component": "contrib", + } + assert all(method != "PATCH" for method, _, _ in fake.calls) + + +def test_upload_deb_allows_no_wait(fake, tmp_path): + source = tmp_path / "package.deb" + source.write_bytes(b"package contents") + + result = package.upload_package( + None, "reg", str(source), "myrepo", release="jammy", no_wait=True + ) + + assert result == {"task": TASK_ID} + assert all(path != f"/tasks/{TASK_ID}/" for _, path, _ in fake.calls) + + +def test_upload_deb_requires_release_when_attached(fake, tmp_path): + from azure.cli.core.azclierror import RequiredArgumentMissingError + + source = tmp_path / "package.deb" + source.write_bytes(b"package contents") + + with pytest.raises(RequiredArgumentMissingError, match="--release"): + package.upload_package(None, "reg", str(source), REPOSITORY_ID) + assert fake.calls == [] + + +def test_upload_rejects_non_file_before_creating_client(fake): + from azure.cli.core.azclierror import InvalidArgumentValueError + + with pytest.raises(InvalidArgumentValueError): + package.upload_package(None, "reg", "/not/a/package.deb", REPOSITORY_ID) + assert fake.calls == [] + + +def test_show_gets_by_id_without_lookup(fake): + package.show_package(None, "reg", PACKAGE_ID) + assert fake.calls == [("GET", f"/packages/{PACKAGE_ID}/", {})] + + +def test_each_type_hits_its_own_endpoint(fake): + handlers = { + "deb": package.list_deb_packages, + "debsrc": package.list_debsrc_packages, + "rpm": package.list_rpm_packages, + "file": package.list_file_packages, + } + for ptype, handler in handlers.items(): + fake.calls.clear() + handler(None, "reg") + assert fake.calls == [ + ("GET", f"/{ptype}/packages/", {"params": {"limit": 100, "offset": 0}}) + ] + + +def test_list_filters_by_name(fake): + package.list_deb_packages(None, "reg", name="cowsay") + assert fake.calls == [ + ("GET", "/deb/packages/", {"params": {"name": "cowsay", "limit": 100, "offset": 0}}) + ] + + +def test_list_filters_by_sha256(fake): + sha = "a" * 64 + package.list_deb_packages(None, "reg", sha256=sha) + assert fake.calls == [ + ("GET", "/deb/packages/", {"params": {"sha256": sha, "limit": 100, "offset": 0}}) + ] + + +def test_deb_resolves_release_within_repository(fake): + package.list_deb_packages(None, "reg", repository=REPOSITORY_ID, release="jammy") + # repository is an id (no lookup); release name resolves against the nested + # releases endpoint, then the release id is sent as the filter + resolve = next(c for c in fake.calls if c[0] == "GET" and c[1].endswith("/releases/")) + assert resolve[1] == f"/repositories/{REPOSITORY_ID}/releases/" + assert resolve[2]["params"] == {"name": "jammy"} + listing = fake.calls[-1] + assert listing[1] == "/deb/packages/" + assert listing[2]["params"]["release"] == "releases-resolved-id" + + +def test_debsrc_resolves_release_within_repository(fake): + package.list_debsrc_packages(None, "reg", repository=REPOSITORY_ID, release="jammy") + listing = fake.calls[-1] + assert listing[1] == "/debsrc/packages/" + assert listing[2]["params"]["release"] == "releases-resolved-id" + + +def test_deb_release_id_passes_through(fake): + package.list_deb_packages(None, "reg", repository=REPOSITORY_ID, release=RELEASE_ID) + # an id needs no nested lookup - single list call + assert fake.calls == [ + ( + "GET", + "/deb/packages/", + { + "params": { + "repository": REPOSITORY_ID, + "release": RELEASE_ID, + "limit": 100, + "offset": 0, + } + }, + ) + ] + + +def test_deb_release_name_requires_repository(fake): + from azure.cli.core.azclierror import RequiredArgumentMissingError + + with pytest.raises(RequiredArgumentMissingError): + package.list_deb_packages(None, "reg", release="jammy") + + +def test_deb_release_id_requires_repository(fake): + # The server's composite release filter needs a repository scope, so even a + # release id (which needs no name lookup) requires --repository. + from azure.cli.core.azclierror import RequiredArgumentMissingError + + with pytest.raises(RequiredArgumentMissingError): + package.list_deb_packages(None, "reg", release=RELEASE_ID) + + +def test_rpm_release_passes_through_as_string(fake): + package.list_rpm_packages(None, "reg", release="9.el9") + # rpm release is a plain NVR field, not a resource - no lookup + assert fake.calls == [ + ("GET", "/rpm/packages/", {"params": {"release": "9.el9", "limit": 100, "offset": 0}}) + ] + + +def test_deb_filters_by_version_and_arch(fake): + package.list_deb_packages(None, "reg", version="2.0", arch="amd64") + # arch is sent as-is; the server remaps it to pulp_deb's ``architecture`` + assert fake.calls == [ + ( + "GET", + "/deb/packages/", + {"params": {"version": "2.0", "arch": "amd64", "limit": 100, "offset": 0}}, + ) + ] + + +def test_rpm_filters_by_version_and_arch(fake): + package.list_rpm_packages(None, "reg", version="8.2", arch="x86_64") + assert fake.calls == [ + ( + "GET", + "/rpm/packages/", + {"params": {"version": "8.2", "arch": "x86_64", "limit": 100, "offset": 0}}, + ) + ] + + +def test_list_with_limit_caps_the_first_page(fake): + package.list_deb_packages(None, "reg", limit=5) + assert fake.calls == [("GET", "/deb/packages/", {"params": {"limit": 5, "offset": 0}})] + + +def test_list_with_zero_limit_is_rejected(fake): + from azure.cli.core.azclierror import InvalidArgumentValueError + + with pytest.raises(InvalidArgumentValueError): + package.list_deb_packages(None, "reg", limit=0) + assert fake.calls == [] + + +def test_list_filters_by_resolved_repository(fake): + package.list_deb_packages(None, "reg", repository="myrepo") + resolve, listing = fake.calls + assert resolve[:2] == ("GET", "/repositories/") + assert resolve[2]["params"] == {"name": "myrepo"} + assert listing == ( + "GET", + "/deb/packages/", + {"params": {"repository": "repositories-resolved-id", "limit": 100, "offset": 0}}, + ) + + +def test_list_repository_filter_passes_id_through(fake): + package.list_file_packages(None, "reg", repository=REPOSITORY_ID) + assert fake.calls == [ + ( + "GET", + "/file/packages/", + {"params": {"repository": REPOSITORY_ID, "limit": 100, "offset": 0}}, + ) + ] diff --git a/src/apr/azext_apr/tests/test_paging.py b/src/apr/azext_apr/tests/test_paging.py new file mode 100644 index 00000000000..b4695f42996 --- /dev/null +++ b/src/apr/azext_apr/tests/test_paging.py @@ -0,0 +1,122 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import pytest + +pytest.importorskip("azure.cli.core") + +from azure.cli.core.azclierror import InvalidArgumentValueError + +from azext_apr.server import iter_results, list_all + + +class FakeResp: + def __init__(self, json_data): + self._json = json_data + + def json(self): + return self._json + + +class PagingFake: + """Serves pre-programmed pages in order, recording each call's params.""" + + def __init__(self, pages): + self._pages = pages + self.calls = [] + + def get(self, path, **kwargs): + params = kwargs.get("params") + self.calls.append(params) + return FakeResp(self._pages[len(self.calls) - 1]) + + +def test_walks_pages_until_count_is_reached(): + # The APR server strips `next`; the pager must walk by offset off `count`. + fake = PagingFake( + [ + {"count": 105, "results": list(range(100))}, + {"count": 105, "results": list(range(100, 105))}, + ] + ) + result = list_all(fake, "/repositories/") + assert result == list(range(105)) + # second page must advance the offset to where the first page left off + assert fake.calls == [ + {"limit": 100, "offset": 0}, + {"limit": 100, "offset": 100}, + ] + + +def test_iter_results_fetches_lazily_and_can_stop_after_one_page(): + fake = PagingFake( + [ + {"count": 200, "results": list(range(100))}, + {"count": 200, "results": list(range(100, 200))}, + ] + ) + results = iter_results(fake, "/repositories/") + assert fake.calls == [] + + assert next(results) == 0 + assert fake.calls == [{"limit": 100, "offset": 0}] + + +def test_single_page_stops_without_a_next_link(): + # A full result set on one page (count == len(results)) must not fetch again, + # even though there's no `next` link to signal the end. + fake = PagingFake([{"count": 42, "results": list(range(42))}]) + result = list_all(fake, "/repositories/") + assert result == list(range(42)) + assert fake.calls == [{"limit": 100, "offset": 0}] + + +def test_empty_page_guards_against_infinite_loop(): + # If the server reports more than it returns (stale count), the empty-page + # guard stops us instead of looping forever. + fake = PagingFake( + [ + {"count": 500, "results": list(range(100))}, + {"count": 500, "results": []}, + ] + ) + result = list_all(fake, "/repositories/") + assert result == list(range(100)) + assert fake.calls == [ + {"limit": 100, "offset": 0}, + {"limit": 100, "offset": 100}, + ] + + +def test_max_items_spanning_pages_caps_and_stops(): + fake = PagingFake( + [ + {"count": 500, "results": list(range(100))}, + {"count": 500, "results": list(range(100, 150))}, + ] + ) + result = list_all(fake, "/repositories/", max_items=150) + assert result == list(range(150)) + # second page requests only the remaining 50 and we stop at the cap + assert fake.calls == [ + {"limit": 100, "offset": 0}, + {"limit": 50, "offset": 100}, + ] + + +def test_returns_bare_body_when_not_a_page(): + # A non-paginated endpoint may answer with a plain body instead of the + # {count, results} envelope; hand it back untouched rather than choke. + fake = PagingFake([[{"name": "a"}]]) + assert list_all(fake, "/repositories/") == [{"name": "a"}] + assert fake.calls == [{"limit": 100, "offset": 0}] + + +@pytest.mark.parametrize("limit", [0, -1]) +def test_rejects_a_limit_below_one(limit): + fake = PagingFake([]) + with pytest.raises(InvalidArgumentValueError, match="--limit must be 1 or greater"): + list_all(fake, "/tasks/", max_items=limit) + assert fake.calls == [] diff --git a/src/apr/azext_apr/tests/test_polling.py b/src/apr/azext_apr/tests/test_polling.py new file mode 100644 index 00000000000..76859f2b118 --- /dev/null +++ b/src/apr/azext_apr/tests/test_polling.py @@ -0,0 +1,79 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import pytest + +pytest.importorskip("azure.cli.core") + + +class FakeResponse: + def __init__(self, json_data, content=b"{}"): + self.content = content + self._json = json_data + + def json(self): + return self._json + + +class FakeClient: + """Returns successive canned task states for each GET /tasks/{id}/.""" + + def __init__(self, states): + self._states = iter(states) + self.calls = [] + + def get(self, path): + self.calls.append(path) + return FakeResponse(next(self._states)) + + +def test_poll_task_returns_on_completed(): + from azext_apr.server._polling import poll_task + + client = FakeClient([{"id": "t1", "state": "running"}, {"id": "t1", "state": "completed"}]) + task = poll_task(client, "t1", interval=0) + assert task["state"] == "completed" + assert client.calls == ["/tasks/t1/", "/tasks/t1/"] + + +def test_poll_task_raises_on_failure(): + from azure.cli.core.azclierror import AzureResponseError + + from azext_apr.server._polling import poll_task + + client = FakeClient([{"id": "t1", "state": "failed", "error": {"description": "boom"}}]) + with pytest.raises(AzureResponseError, match="boom"): + poll_task(client, "t1", interval=0) + + +def test_wait_for_task_no_wait_returns_handle_without_polling(): + from azext_apr.server._polling import wait_for_task + + client = FakeClient([]) # must not be touched + resp = FakeResponse({"task": "t1"}) + assert wait_for_task(client, resp, no_wait=True) == {"task": "t1"} + assert client.calls == [] + + +def test_wait_for_task_polls_when_task_present(monkeypatch): + from azext_apr.server import _polling + + monkeypatch.setattr(_polling, "poll_task", lambda c, tid: {"id": tid, "state": "completed"}) + resp = FakeResponse({"task": "t1"}) + assert _polling.wait_for_task(object(), resp)["state"] == "completed" + + +def test_wait_for_task_passthrough_when_no_task(): + from azext_apr.server._polling import wait_for_task + + resp = FakeResponse({"id": "repo-1", "name": "pkgs"}) + assert wait_for_task(object(), resp) == {"id": "repo-1", "name": "pkgs"} + + +def test_wait_for_task_handles_empty_body(): + from azext_apr.server._polling import wait_for_task + + resp = FakeResponse(None, content=b"") + assert wait_for_task(object(), resp) is None diff --git a/src/apr/azext_apr/tests/test_publication.py b/src/apr/azext_apr/tests/test_publication.py new file mode 100644 index 00000000000..f73c0b5c743 --- /dev/null +++ b/src/apr/azext_apr/tests/test_publication.py @@ -0,0 +1,144 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import pytest + +pytest.importorskip("azure.cli.core") + +from azext_apr.commands import publication + +VERBS = ["list", "show", "delete"] + +PUB_ID = "publications-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +REPOSITORY_ID = "repositories-deb-apt-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + +class FakeResp: + def __init__(self, json_data=None): + self._json = {} if json_data is None else json_data + self.content = b"{}" + + def json(self): + return self._json + + +class FakeClient: + """Records calls; answers name-filter lookups so the resolver can run.""" + + def __init__(self): + self.calls = [] + self.delete_response = {} + + def _record(self, method, path, kwargs): + self.calls.append((method, path, kwargs)) + + def get(self, path, **kwargs): + self._record("GET", path, kwargs) + if "name" in (kwargs.get("params") or {}): + resource = path.strip("/").split("/")[0] + return FakeResp({"results": [{"id": f"{resource}-resolved-id"}]}) + return FakeResp({"results": []}) + + def delete(self, path, **kwargs): + self._record("DELETE", path, kwargs) + return FakeResp(self.delete_response) + + +@pytest.fixture +def fake(monkeypatch): + client = FakeClient() + monkeypatch.setattr(publication, "_client", lambda cmd, rn, rg=None: client) + return client + + +def test_helps_registered(): + from knack.help_files import helps + + assert "apr publication" in helps + for verb in VERBS: + assert f"apr publication {verb}" in helps + + +def test_command_table_registers_publication_verbs(): + from azure.cli.core.mock import DummyCli + + from azext_apr import AprCommandsLoader + + loader = AprCommandsLoader(cli_ctx=DummyCli()) + loader.load_command_table(None) + + registered = {k for k in loader.command_table if k.startswith("apr publication")} + expected = {f"apr publication {v}" for v in VERBS} + assert registered == expected + + +def test_arguments_load_and_expose_expected_options(): + from azure.cli.core.mock import DummyCli + + from azext_apr import AprCommandsLoader + + loader = AprCommandsLoader(cli_ctx=DummyCli()) + loader.skip_applicability = True + loader.load_command_table(None) + loader.load_arguments("apr publication list") + registry = loader.argument_registry.arguments + + def options(scope, dest): + return set(registry[scope][dest].settings.get("options_list") or []) + + assert {"--registry", "-r"} <= options("apr publication", "registry_name") + assert {"--id"} <= options("apr publication", "publication_id") + assert {"--repository"} <= options("apr publication list", "repository") + # --no-wait is wired on delete only. + assert loader.command_table["apr publication delete"].supports_no_wait + for verb in ("show", "list"): + assert not loader.command_table[f"apr publication {verb}"].supports_no_wait + + +def test_list_pages_through_everything_by_default(fake): + publication.list_publications(None, "reg") + assert fake.calls == [("GET", "/publications/", {"params": {"limit": 100, "offset": 0}})] + + +def test_list_with_repository_resolves_and_filters(fake): + publication.list_publications(None, "reg", repository="myrepo") + # first the resolver looks the repo name up, then the filtered list page + assert fake.calls[0][1] == "/repositories/" + assert fake.calls[0][2]["params"] == {"name": "myrepo"} + method, path, kwargs = fake.calls[1] + assert (method, path) == ("GET", "/publications/") + assert kwargs["params"] == { + "repository": "repositories-resolved-id", + "limit": 100, + "offset": 0, + } + + +def test_list_with_repository_id_skips_lookup(fake): + publication.list_publications(None, "reg", repository=REPOSITORY_ID) + assert fake.calls == [ + ( + "GET", + "/publications/", + {"params": {"repository": REPOSITORY_ID, "limit": 100, "offset": 0}}, + ) + ] + + +def test_show_gets_by_id(fake): + publication.show_publication(None, "reg", PUB_ID) + assert fake.calls == [("GET", f"/publications/{PUB_ID}/", {})] + + +def test_delete_deletes_by_id(fake): + publication.delete_publication(None, "reg", PUB_ID) + assert fake.calls == [("DELETE", f"/publications/{PUB_ID}/", {})] + + +def test_no_wait_returns_handle_without_polling(fake): + fake.delete_response = {"task": "t1"} + result = publication.delete_publication(None, "reg", PUB_ID, no_wait=True) + assert result == {"task": "t1"} + assert all("/tasks/" not in c[1] for c in fake.calls) diff --git a/src/apr/azext_apr/tests/test_registry.py b/src/apr/azext_apr/tests/test_registry.py new file mode 100644 index 00000000000..b4f23ec04fe --- /dev/null +++ b/src/apr/azext_apr/tests/test_registry.py @@ -0,0 +1,61 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# mypy: ignore-errors + +import pytest + +pytest.importorskip("azure.cli.core") + + +def test_generated_registry_commands_use_the_expected_api_contract(): + from azext_apr.aaz.latest.apr.registry._create import Create + from azext_apr.aaz.latest.apr.registry._delete import Delete + from azext_apr.aaz.latest.apr.registry._list import List + from azext_apr.aaz.latest.apr.registry._show import Show + from azext_apr.aaz.latest.apr.registry._update import Update + + commands = [Create, Delete, Show, Update] + expected_path = ( + "/subscriptions/{}/resourcegroups/{}/providers/microsoft.packageregistry/registries/{}" + ) + for command in commands: + assert command._aaz_info["version"] == "2026-04-01-preview" + assert command._aaz_info["resources"][0][1] == expected_path + + assert Create.AZ_SUPPORT_NO_WAIT + # Delete tears down the whole data plane, so it is long-running like create. + assert Delete.AZ_SUPPORT_NO_WAIT + assert [resource[1] for resource in List._aaz_info["resources"]] == [ + "/subscriptions/{}/providers/microsoft.packageregistry/registries", + expected_path.rsplit("/", 1)[0], + ] + + +def test_generated_registry_commands_block_dev_builds(monkeypatch): + from azure.cli.core.azclierror import ValidationError + from azure.cli.core.mock import DummyCli + + from azext_apr import AprCommandsLoader + from azext_apr.aaz.latest.apr.registry._create import Create + from azext_apr.aaz.latest.apr.registry._delete import Delete + from azext_apr.aaz.latest.apr.registry._list import List + from azext_apr.aaz.latest.apr.registry._show import Show + from azext_apr.aaz.latest.apr.registry._update import Update + from azext_apr.aaz.latest.apr.registry._wait import Wait + from azext_apr.server import _data_plane + + monkeypatch.setattr(_data_plane, "is_dev_extension", lambda: True) + commands = [ + Create(cli_ctx=DummyCli()), + Delete(cli_ctx=DummyCli()), + Show(cli_ctx=DummyCli()), + List(cli_ctx=DummyCli()), + Update(cli_ctx=DummyCli()), + Wait(AprCommandsLoader(cli_ctx=DummyCli())), + ] + for command in commands: + with pytest.raises(ValidationError, match="not available in a local dev build"): + command.pre_operations() diff --git a/src/apr/azext_apr/tests/test_remote.py b/src/apr/azext_apr/tests/test_remote.py new file mode 100644 index 00000000000..f262a0055c2 --- /dev/null +++ b/src/apr/azext_apr/tests/test_remote.py @@ -0,0 +1,216 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import pytest + +pytest.importorskip("azure.cli.core") + +from azext_apr.commands import remote + +VERBS = ["list", "create", "show", "update", "delete"] + +# real-looking APR resource id - matches the resolver's regex, so it skips the name lookup +REMOTE_ID = "remotes-deb-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + +class FakeResp: + def __init__(self, json_data=None): + self._json = {} if json_data is None else json_data + self.content = b"{}" + + def json(self): + return self._json + + +class FakeClient: + """Records calls; answers name-filter lookups so the resolver can run.""" + + def __init__(self): + self.calls = [] + self.delete_response = {} + + def _record(self, method, path, kwargs): + self.calls.append((method, path, kwargs)) + + def get(self, path, **kwargs): + self._record("GET", path, kwargs) + if "name" in (kwargs.get("params") or {}): + resource = path.strip("/").split("/")[0] + return FakeResp({"results": [{"id": f"{resource}-resolved-id"}]}) + return FakeResp({"results": []}) + + def post(self, path, **kwargs): + self._record("POST", path, kwargs) + return FakeResp({"id": REMOTE_ID}) + + def patch(self, path, **kwargs): + self._record("PATCH", path, kwargs) + return FakeResp({"id": REMOTE_ID}) + + def delete(self, path, **kwargs): + self._record("DELETE", path, kwargs) + return FakeResp(self.delete_response) + + +@pytest.fixture +def fake(monkeypatch): + client = FakeClient() + monkeypatch.setattr(remote, "_client", lambda cmd, rn, rg=None: client) + return client + + +def test_helps_registered(): + from knack.help_files import helps + + assert "apr remote" in helps + for verb in VERBS: + assert f"apr remote {verb}" in helps + + +def test_command_table_registers_remote_verbs(): + from azure.cli.core.mock import DummyCli + + from azext_apr import AprCommandsLoader + + loader = AprCommandsLoader(cli_ctx=DummyCli()) + loader.load_command_table(None) + + registered = {k for k in loader.command_table if k.startswith("apr remote")} + expected = {f"apr remote {v}" for v in VERBS} + assert registered == expected + + +def test_arguments_load_and_expose_expected_options(): + from azure.cli.core.mock import DummyCli + + from azext_apr import AprCommandsLoader + + loader = AprCommandsLoader(cli_ctx=DummyCli()) + loader.skip_applicability = True + loader.load_command_table(None) + loader.load_arguments("apr remote create") + registry = loader.argument_registry.arguments + + def options(scope, dest): + return set(registry[scope][dest].settings.get("options_list") or []) + + assert {"--registry", "-r"} <= options("apr remote", "registry_name") + assert {"--name", "-n"} <= options("apr remote", "remote_name") + assert "--type" in options("apr remote create", "remote_type") + assert {"--url"} <= options("apr remote create", "url") + assert {"--policy"} <= options("apr remote create", "policy") + assert {"--policy"} <= options("apr remote update", "policy") + for scope in ("apr remote create", "apr remote update"): + policy_help = registry[scope]["policy"].settings["help"] + for policy in ("immediate", "on-demand", "streamed"): + assert policy in policy_help + assert {"--releases"} <= options("apr remote create", "releases") + # --no-wait is wired (via supports_no_wait) on the mutating verbs only. + for verb in ("create", "update", "delete"): + assert loader.command_table[f"apr remote {verb}"].supports_no_wait + for verb in ("show", "list"): + assert not loader.command_table[f"apr remote {verb}"].supports_no_wait + + +def test_list_pages_through_everything_by_default(fake): + remote.list_remotes(None, "reg") + assert fake.calls == [("GET", "/remotes/", {"params": {"limit": 100, "offset": 0}})] + + +def test_list_with_limit_caps_the_first_page(fake): + remote.list_remotes(None, "reg", limit=5) + assert fake.calls == [("GET", "/remotes/", {"params": {"limit": 5, "offset": 0}})] + + +def test_create_posts_name_type_url(fake): + remote.create_remote(None, "reg", "myremote", "apt", "https://u") + method, path, kwargs = fake.calls[0] + assert (method, path) == ("POST", "/remotes/") + assert kwargs["json"] == {"name": "myremote", "type": "apt", "url": "https://u"} + + +def test_create_translates_download_policy(fake): + remote.create_remote(None, "reg", "myremote", "yum", "https://u", policy="on-demand") + assert fake.calls[0][2]["json"] == { + "name": "myremote", + "type": "yum", + "url": "https://u", + "policy": "on_demand", + } + + +def test_create_splits_comma_lists_into_arrays(fake): + remote.create_remote( + None, + "reg", + "myremote", + "apt", + "https://u", + releases="jammy, focal", + components="main,universe", + architectures="amd64", + ) + assert fake.calls[0][2]["json"] == { + "name": "myremote", + "type": "apt", + "url": "https://u", + "releases": ["jammy", "focal"], + "components": ["main", "universe"], + "architectures": ["amd64"], + } + + +def test_show_resolves_name_then_gets(fake): + remote.show_remote(None, "reg", "myremote") + paths = [c[1] for c in fake.calls] + assert paths == ["/remotes/", "/remotes/remotes-resolved-id/"] + assert fake.calls[0][2]["params"] == {"name": "myremote"} + + +def test_show_passes_id_through_without_lookup(fake): + remote.show_remote(None, "reg", REMOTE_ID) + assert fake.calls == [("GET", f"/remotes/{REMOTE_ID}/", {})] + + +def test_update_sets_only_provided_fields(fake): + remote.update_remote( + None, + "reg", + REMOTE_ID, + url="https://new", + policy="on-demand", + releases="jammy", + components="main", + architectures="amd64", + ) + method, path, kwargs = fake.calls[0] + assert (method, path) == ("PATCH", f"/remotes/{REMOTE_ID}/") + assert kwargs["json"] == { + "url": "https://new", + "policy": "on_demand", + "releases": ["jammy"], + "components": ["main"], + "architectures": ["amd64"], + } + + +def test_update_requires_a_property(fake): + from azure.cli.core.azclierror import RequiredArgumentMissingError + + with pytest.raises(RequiredArgumentMissingError): + remote.update_remote(None, "reg", REMOTE_ID) + assert fake.calls == [] + + +def test_delete_resolves_and_deletes(fake): + remote.delete_remote(None, "reg", REMOTE_ID) + assert fake.calls == [("DELETE", f"/remotes/{REMOTE_ID}/", {})] + + +def test_no_wait_returns_handle_without_polling(fake): + fake.delete_response = {"task": "t1"} + result = remote.delete_remote(None, "reg", REMOTE_ID, no_wait=True) + assert result == {"task": "t1"} + assert all("/tasks/" not in c[1] for c in fake.calls) diff --git a/src/apr/azext_apr/tests/test_repository.py b/src/apr/azext_apr/tests/test_repository.py new file mode 100644 index 00000000000..daef9d6580d --- /dev/null +++ b/src/apr/azext_apr/tests/test_repository.py @@ -0,0 +1,340 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import pytest + +pytest.importorskip("azure.cli.core") + +from azext_apr.commands import repository + +VERBS = ["list", "create", "show", "update", "delete", "sync", "publish"] + +# a real-looking APR resource id - matches the resolver's regex, so it skips the name lookup +REPOSITORY_ID = "repositories-deb-apt-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +REMOTE_ID = "remotes-deb-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + +class FakeResp: + def __init__(self, json_data=None): + self._json = {} if json_data is None else json_data + self.content = b"{}" + + def json(self): + return self._json + + +class FakeClient: + """Records calls; answers name-filter lookups so the resolver can run.""" + + def __init__(self): + self.calls = [] + + def _record(self, method, path, kwargs): + self.calls.append((method, path, kwargs)) + + def get(self, path, **kwargs): + self._record("GET", path, kwargs) + # the resolver looks a name up via the list endpoint with a ?name= filter + if "name" in (kwargs.get("params") or {}): + resource = path.strip("/").split("/")[0] + return FakeResp({"results": [{"id": f"{resource}-resolved-id"}]}) + return FakeResp({"results": []}) + + def post(self, path, **kwargs): + self._record("POST", path, kwargs) + return FakeResp({"id": REPOSITORY_ID}) + + def patch(self, path, **kwargs): + self._record("PATCH", path, kwargs) + return FakeResp({"id": REPOSITORY_ID}) + + def delete(self, path, **kwargs): + self._record("DELETE", path, kwargs) + return FakeResp({}) + + +@pytest.fixture +def fake(monkeypatch): + client = FakeClient() + monkeypatch.setattr(repository, "_client", lambda cmd, rn, rg=None: client) + return client + + +def test_helps_registered(): + from knack.help_files import helps + + assert "apr repository" in helps + for verb in VERBS: + assert f"apr repository {verb}" in helps + + +def test_command_table_registers_repository_verbs(): + from azure.cli.core.mock import DummyCli + + from azext_apr import AprCommandsLoader + + loader = AprCommandsLoader(cli_ctx=DummyCli()) + loader.load_command_table(None) + + registered = { + k + for k in loader.command_table + if k.startswith("apr repository") + and not k.startswith("apr repository package") + and not k.startswith("apr repository release") + } + expected = {f"apr repository {v}" for v in VERBS} + assert registered == expected + + +def test_arguments_load_and_expose_expected_options(): + # In-process stand-in for `az apr repository ... --help`: running load_arguments + # for every verb exercises each argument_context block (catches a bad + # options_list/arg_type) and lets us assert the surface. + from azure.cli.core.mock import DummyCli + + from azext_apr import AprCommandsLoader + + loader = AprCommandsLoader(cli_ctx=DummyCli()) + loader.skip_applicability = True + loader.load_command_table(None) + # load_arguments runs every verb's argument_context block, which is what + # catches a busted options_list/arg_type before it ever hits a real shell. + loader.load_arguments("apr repository create") + registry = loader.argument_registry.arguments + + def options(scope, dest): + return set(registry[scope][dest].settings.get("options_list") or []) + + assert {"--registry", "-r"} <= options("apr repository", "registry_name") + assert {"--name", "-n"} <= options("apr repository", "repository_name") + assert "--type" in options("apr repository create", "repository_type") + assert {"--retain-versions"} <= options("apr repository create", "retain_versions") + assert {"--retain-all"} <= options("apr repository create", "retain_all") + assert {"--remote"} <= options("apr repository update", "remote_name") + assert {"--retain-versions"} <= options("apr repository update", "retain_versions") + assert {"--retain-all"} <= options("apr repository update", "retain_all") + assert {"--sync-mode"} <= options("apr repository sync", "sync_mode") + assert {"--confirm"} <= options("apr repository sync", "confirm") + assert loader.command_table["apr repository sync"].confirmation is ( + repository._confirm_mirror_sync + ) + # --no-wait is wired (via supports_no_wait) on the long-running verbs only. + for verb in ("update", "delete", "sync", "publish"): + assert loader.command_table[f"apr repository {verb}"].supports_no_wait + for verb in ("create", "show", "list"): + assert not loader.command_table[f"apr repository {verb}"].supports_no_wait + + +def test_list_pages_through_everything_by_default(fake): + repository.list_repositories(None, "reg") + # one page, no `next`, so a single GET with paging params and a flat list back + assert fake.calls == [("GET", "/repositories/", {"params": {"limit": 100, "offset": 0}})] + + +def test_list_with_limit_caps_the_first_page(fake): + repository.list_repositories(None, "reg", limit=5) + # limit smaller than the page size, so one GET capped to 5 and we're done + assert fake.calls == [("GET", "/repositories/", {"params": {"limit": 5, "offset": 0}})] + + +def test_list_with_zero_limit_is_rejected(fake): + from azure.cli.core.azclierror import InvalidArgumentValueError + + with pytest.raises(InvalidArgumentValueError): + repository.list_repositories(None, "reg", limit=0) + assert fake.calls == [] + + +def test_list_with_negative_limit_is_rejected(fake): + from azure.cli.core.azclierror import InvalidArgumentValueError + + with pytest.raises(InvalidArgumentValueError): + repository.list_repositories(None, "reg", limit=-1) + assert fake.calls == [] + + +def test_create_posts_name_and_type(fake): + repository.create_repository(None, "reg", "myrepo", "apt") + method, path, kwargs = fake.calls[0] + assert (method, path) == ("POST", "/repositories/") + assert kwargs["json"] == {"name": "myrepo", "type": "apt"} + + +def test_create_omits_retain_versions_by_default(fake): + # No --retain-versions, so the server applies its own default (5). + repository.create_repository(None, "reg", "myrepo", "apt") + assert "retain_repo_versions" not in fake.calls[0][2]["json"] + + +def test_create_forwards_retain_versions(fake): + repository.create_repository(None, "reg", "myrepo", "apt", retain_versions=10) + assert fake.calls[0][2]["json"] == { + "name": "myrepo", + "type": "apt", + "retain_repo_versions": 10, + } + + +def test_create_retain_all_sends_null(fake): + # --retain-all keeps every version, forwarded to the server as null. + repository.create_repository(None, "reg", "myrepo", "apt", retain_all=True) + assert fake.calls[0][2]["json"] == { + "name": "myrepo", + "type": "apt", + "retain_repo_versions": None, + } + + +def test_create_rejects_retain_versions_and_retain_all(fake): + from azure.cli.core.azclierror import MutuallyExclusiveArgumentError + + with pytest.raises(MutuallyExclusiveArgumentError): + repository.create_repository( + None, "reg", "myrepo", "apt", retain_versions=5, retain_all=True + ) + assert fake.calls == [] + + +def test_show_resolves_name_then_gets(fake): + repository.show_repository(None, "reg", "myrepo") + paths = [c[1] for c in fake.calls] + assert paths == ["/repositories/", "/repositories/repositories-resolved-id/"] + assert fake.calls[0][2]["params"] == {"name": "myrepo"} + + +def test_show_passes_id_through_without_lookup(fake): + repository.show_repository(None, "reg", REPOSITORY_ID) + assert fake.calls == [("GET", f"/repositories/{REPOSITORY_ID}/", {})] + + +def test_delete_resolves_and_deletes(fake): + repository.delete_repository(None, "reg", REPOSITORY_ID) + assert fake.calls == [("DELETE", f"/repositories/{REPOSITORY_ID}/", {})] + + +def test_sync_posts_no_body(fake): + # remote is bound via `repository update --remote`; sync itself carries nothing. + repository.sync_repository(None, "reg", REPOSITORY_ID) + assert fake.calls == [("POST", f"/repositories/{REPOSITORY_ID}/sync/", {})] + + +@pytest.mark.parametrize("sync_mode", ["additive", "mirror"]) +def test_sync_posts_sync_mode(fake, sync_mode): + repository.sync_repository(None, "reg", REPOSITORY_ID, sync_mode=sync_mode) + assert fake.calls == [ + ( + "POST", + f"/repositories/{REPOSITORY_ID}/sync/", + {"json": {"sync_mode": sync_mode}}, + ) + ] + + +def test_sync_confirm_is_not_sent_to_server(fake): + repository.sync_repository( + None, + "reg", + REPOSITORY_ID, + sync_mode="mirror", + confirm=True, + ) + assert fake.calls == [ + ( + "POST", + f"/repositories/{REPOSITORY_ID}/sync/", + {"json": {"sync_mode": "mirror"}}, + ) + ] + + +@pytest.mark.parametrize("sync_mode", [None, "additive"]) +def test_sync_confirmation_skips_non_destructive_modes(monkeypatch, sync_mode): + def unexpected_prompt(_message): + pytest.fail("Non-destructive sync should not prompt for confirmation.") + + monkeypatch.setattr(repository, "prompt_y_n", unexpected_prompt) + + assert repository._confirm_mirror_sync({"sync_mode": sync_mode}) + + +def test_sync_confirmation_prompts_for_mirror(monkeypatch): + prompts = [] + monkeypatch.setattr( + repository, + "prompt_y_n", + lambda message: prompts.append(message) or True, + ) + + assert repository._confirm_mirror_sync({"sync_mode": "mirror"}) + assert prompts == [ + "Mirror sync removes repository packages that are not present upstream. Continue?" + ] + + +def test_sync_confirmation_skips_prompt_when_confirmed(monkeypatch): + def unexpected_prompt(_message): + pytest.fail("--confirm should bypass the confirmation prompt.") + + monkeypatch.setattr(repository, "prompt_y_n", unexpected_prompt) + + assert repository._confirm_mirror_sync({"sync_mode": "mirror", "confirm": True}) + + +def test_publish_posts_force(fake): + repository.publish_repository(None, "reg", REPOSITORY_ID, force=True) + assert fake.calls == [ + ("POST", f"/repositories/{REPOSITORY_ID}/publish/", {"json": {"force": True}}) + ] + + +def test_publish_defaults_force_false(fake): + repository.publish_repository(None, "reg", REPOSITORY_ID) + assert fake.calls[0][2]["json"] == {"force": False} + + +def test_update_requires_a_property(fake): + from azure.cli.core.azclierror import RequiredArgumentMissingError + + with pytest.raises(RequiredArgumentMissingError): + repository.update_repository(None, "reg", REPOSITORY_ID) + assert fake.calls == [] + + +def test_update_sets_remote(fake): + repository.update_repository(None, "reg", REPOSITORY_ID, remote_name=REMOTE_ID) + method, path, kwargs = fake.calls[0] + assert (method, path) == ("PATCH", f"/repositories/{REPOSITORY_ID}/") + assert kwargs["json"] == {"remote": REMOTE_ID} + + +def test_update_unsets_remote_with_empty_string(fake): + repository.update_repository(None, "reg", REPOSITORY_ID, remote_name="") + assert fake.calls[0][2]["json"] == {"remote": None} + + +def test_update_sets_retain_versions(fake): + repository.update_repository(None, "reg", REPOSITORY_ID, retain_versions=3) + method, path, kwargs = fake.calls[0] + assert (method, path) == ("PATCH", f"/repositories/{REPOSITORY_ID}/") + assert kwargs["json"] == {"retain_repo_versions": 3} + + +def test_update_retain_all_sends_null(fake): + repository.update_repository(None, "reg", REPOSITORY_ID, retain_all=True) + assert fake.calls[0][2]["json"] == {"retain_repo_versions": None} + + +def test_update_rejects_retain_versions_and_retain_all(fake): + from azure.cli.core.azclierror import MutuallyExclusiveArgumentError + + with pytest.raises(MutuallyExclusiveArgumentError): + repository.update_repository(None, "reg", REPOSITORY_ID, retain_versions=5, retain_all=True) + assert fake.calls == [] + + +def test_no_wait_returns_handle_without_polling(fake): + repository.sync_repository(None, "reg", REPOSITORY_ID, no_wait=True) + assert all("/tasks/" not in c[1] for c in fake.calls) diff --git a/src/apr/azext_apr/tests/test_repository_package.py b/src/apr/azext_apr/tests/test_repository_package.py new file mode 100644 index 00000000000..e7855ed2e93 --- /dev/null +++ b/src/apr/azext_apr/tests/test_repository_package.py @@ -0,0 +1,254 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import pytest + +pytest.importorskip("azure.cli.core") + +from azext_apr.commands import repository_package + +VERBS = ["add", "remove"] + +# a real-looking APR resource id - matches the resolver's regex, so it skips the name lookup +REPOSITORY_ID = "repositories-deb-apt-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +JAMMY_RELEASE_ID = "content-deb-releases-11111111-2222-3333-4444-555555555555" +FOCAL_RELEASE_ID = "content-deb-releases-66666666-7777-8888-9999-aaaaaaaaaaaa" + + +class FakeResp: + def __init__(self, json_data=None): + self._json = {} if json_data is None else json_data + self.content = b"{}" + + def json(self): + return self._json + + +class FakeClient: + """Records calls; answers name-filter lookups so the resolver can run.""" + + def __init__(self): + self.calls = [] + self.patch_response = {} + self.releases = [ + {"id": JAMMY_RELEASE_ID, "distribution": "jammy"}, + {"id": FOCAL_RELEASE_ID, "distribution": "focal"}, + ] + + def _record(self, method, path, kwargs): + self.calls.append((method, path, kwargs)) + + def get(self, path, **kwargs): + self._record("GET", path, kwargs) + if path.endswith("/releases/"): + name = (kwargs.get("params") or {}).get("name") + releases = ( + [release for release in self.releases if release["distribution"] == name] + if name + else self.releases + ) + return FakeResp({"results": releases}) + if "name" in (kwargs.get("params") or {}): + resource = path.strip("/").split("/")[-1] + return FakeResp({"results": [{"id": f"{resource}-resolved-id"}]}) + return FakeResp({"results": []}) + + def patch(self, path, **kwargs): + self._record("PATCH", path, kwargs) + return FakeResp(self.patch_response) + + +@pytest.fixture +def fake(monkeypatch): + client = FakeClient() + monkeypatch.setattr(repository_package, "_client", lambda cmd, rn, rg=None: client) + return client + + +def test_helps_registered(): + from knack.help_files import helps + + assert "apr repository package" in helps + for verb in VERBS: + assert f"apr repository package {verb}" in helps + + +def test_command_table_registers_verbs(): + from azure.cli.core.mock import DummyCli + + from azext_apr import AprCommandsLoader + + loader = AprCommandsLoader(cli_ctx=DummyCli()) + loader.load_command_table(None) + + registered = {k for k in loader.command_table if k.startswith("apr repository package")} + expected = {f"apr repository package {v}" for v in VERBS} + assert registered == expected + for verb in ("add", "remove"): + assert loader.command_table[f"apr repository package {verb}"].supports_no_wait + + +def test_arguments_expose_plural_releases_option(): + from azure.cli.core.mock import DummyCli + + from azext_apr import AprCommandsLoader + + loader = AprCommandsLoader(cli_ctx=DummyCli()) + loader.skip_applicability = True + loader.load_command_table(None) + loader.load_arguments("apr repository package add") + arguments = loader.argument_registry.arguments + + for verb in VERBS: + scope = f"apr repository package {verb}" + options = arguments[scope]["releases"].settings["options_list"] + assert "--releases" in options + assert "--release" not in options + + +def test_add_patches_add_packages(fake): + repository_package.add_packages(None, "reg", REPOSITORY_ID, "pkg-a,pkg-b") + assert fake.calls == [ + ( + "PATCH", + f"/repositories/{REPOSITORY_ID}/packages/", + {"json": {"add_packages": ["pkg-a", "pkg-b"]}}, + ) + ] + + +def test_remove_patches_remove_packages(fake): + repository_package.remove_packages(None, "reg", REPOSITORY_ID, "pkg-a") + assert fake.calls == [ + ( + "PATCH", + f"/repositories/{REPOSITORY_ID}/packages/", + {"json": {"remove_packages": ["pkg-a"]}}, + ) + ] + + +def test_release_is_passed_through(fake): + repository_package.add_packages(None, "reg", REPOSITORY_ID, "pkg-a", releases="jammy") + patch = next(call for call in fake.calls if call[0] == "PATCH") + assert patch[2]["json"] == {"add_packages": ["pkg-a"], "release": "jammy"} + + +def test_release_id_is_converted_to_its_name(fake): + # An id is still accepted (as `package upload --release` does), but the + # packages endpoint scopes by distribution name, so it's resolved back. + fake.releases = [{"id": JAMMY_RELEASE_ID, "distribution": "jammy"}] + repository_package.add_packages(None, "reg", REPOSITORY_ID, "pkg-a", releases=JAMMY_RELEASE_ID) + assert fake.calls[0][1] == f"/repositories/{REPOSITORY_ID}/releases/" + assert fake.calls[1][2]["json"] == {"add_packages": ["pkg-a"], "release": "jammy"} + + +def test_component_is_passed_through(fake): + repository_package.add_packages( + None, "reg", REPOSITORY_ID, "pkg-a", releases="jammy", component="contrib" + ) + patch = next(call for call in fake.calls if call[0] == "PATCH") + assert patch[2]["json"] == { + "add_packages": ["pkg-a"], + "release": "jammy", + "component": "contrib", + } + + +def test_component_without_release_is_rejected(fake): + from azure.cli.core.azclierror import RequiredArgumentMissingError + + # Sent alone, the server would scope the change to pulp_deb's default + # distribution rather than the one the user meant. + with pytest.raises(RequiredArgumentMissingError): + repository_package.add_packages(None, "reg", REPOSITORY_ID, "pkg-a", component="contrib") + assert fake.calls == [] + + +def test_multiple_releases_fan_out_one_patch_each(fake): + fake.patch_response = {"task": "t1"} + result = repository_package.add_packages( + None, + "reg", + REPOSITORY_ID, + "pkg-a", + releases="jammy,focal", + component="contrib", + no_wait=True, + ) + bodies = [call[2]["json"] for call in fake.calls if call[0] == "PATCH"] + assert bodies == [ + {"add_packages": ["pkg-a"], "release": "jammy", "component": "contrib"}, + {"add_packages": ["pkg-a"], "release": "focal", "component": "contrib"}, + ] + assert result == [{"task": "t1"}, {"task": "t1"}] + + +def test_all_release_ids_are_resolved_before_patching(fake): + from azure.cli.core.azclierror import ResourceNotFoundError + + missing_release_id = "content-deb-releases-bbbbbbbb-cccc-dddd-eeee-ffffffffffff" + with pytest.raises(ResourceNotFoundError): + repository_package.add_packages( + None, + "reg", + REPOSITORY_ID, + "pkg-a", + releases=f"jammy,{missing_release_id}", + ) + assert all(call[0] != "PATCH" for call in fake.calls) + + +@pytest.mark.parametrize("verb", VERBS) +def test_all_release_names_are_resolved_before_patching(fake, verb): + from azure.cli.core.azclierror import ResourceNotFoundError + + with pytest.raises(ResourceNotFoundError): + getattr(repository_package, f"{verb}_packages")( + None, + "reg", + REPOSITORY_ID, + "pkg-a", + releases="jammy,typo", + ) + assert all(call[0] != "PATCH" for call in fake.calls) + + +def test_resolves_repository_name_before_patching(fake): + repository_package.add_packages(None, "reg", "myrepo", "pkg-a") + paths = [c[1] for c in fake.calls] + assert paths == ["/repositories/", "/repositories/repositories-resolved-id/packages/"] + assert fake.calls[0][2]["params"] == {"name": "myrepo"} + + +def test_add_without_packages_is_rejected(fake): + from azure.cli.core.azclierror import RequiredArgumentMissingError + + with pytest.raises(RequiredArgumentMissingError): + repository_package.add_packages(None, "reg", REPOSITORY_ID, "") + assert fake.calls == [] + + +def test_add_with_only_separators_is_rejected(fake): + from azure.cli.core.azclierror import RequiredArgumentMissingError + + with pytest.raises(RequiredArgumentMissingError): + repository_package.add_packages(None, "reg", REPOSITORY_ID, " , ") + assert fake.calls == [] + + +def test_releases_with_only_separators_are_rejected(fake): + from azure.cli.core.azclierror import RequiredArgumentMissingError + + with pytest.raises(RequiredArgumentMissingError): + repository_package.add_packages(None, "reg", REPOSITORY_ID, "pkg-a", releases=" , ") + assert fake.calls == [] + + +def test_no_wait_returns_handle_without_polling(fake): + fake.patch_response = {"task": "t1"} + result = repository_package.remove_packages(None, "reg", REPOSITORY_ID, "pkg-a", no_wait=True) + assert result == {"task": "t1"} + assert all("/tasks/" not in c[1] for c in fake.calls) diff --git a/src/apr/azext_apr/tests/test_repository_release.py b/src/apr/azext_apr/tests/test_repository_release.py new file mode 100644 index 00000000000..98bbaab029b --- /dev/null +++ b/src/apr/azext_apr/tests/test_repository_release.py @@ -0,0 +1,390 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import pytest + +pytest.importorskip("azure.cli.core") + +from azext_apr.commands import repository_release as rel + +VERBS = ["list", "create", "delete"] + +# real-looking APR resource ids - match the resolver regexes so they skip name lookups +REPOSITORY_ID = "repositories-deb-apt-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +RELEASE_ID = "content-deb-releases-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +COMPONENT_ID = "content-deb-release_components-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + +class FakeResp: + def __init__(self, json_data=None): + self._json = {} if json_data is None else json_data + self.content = b"{}" + + def json(self): + return self._json + + +class FakeClient: + """Records calls; answers name-filter lookups so the resolvers can run.""" + + def __init__(self): + self.calls = [] + self.post_response = {"id": RELEASE_ID} + self.delete_response = {} + + def _record(self, method, path, kwargs): + self.calls.append((method, path, kwargs)) + + def get(self, path, **kwargs): + self._record("GET", path, kwargs) + params = kwargs.get("params") or {} + # the resolvers look a name up via the list endpoint with a ?name= filter + if "name" in params: + resource = path.strip("/").split("/")[-1] + return FakeResp({"results": [{"id": f"{resource}-resolved-id"}]}) + # a component name is resolved via the release's components list (?component=) + if "component" in params: + return FakeResp({"results": [{"id": COMPONENT_ID}]}) + return FakeResp({"results": []}) + + def post(self, path, **kwargs): + self._record("POST", path, kwargs) + return FakeResp(self.post_response) + + def delete(self, path, **kwargs): + self._record("DELETE", path, kwargs) + return FakeResp(self.delete_response) + + +@pytest.fixture +def fake(monkeypatch): + client = FakeClient() + monkeypatch.setattr(rel, "_client", lambda cmd, rn, rg=None: client) + return client + + +def test_helps_registered(): + from knack.help_files import helps + + assert "apr repository release" in helps + for verb in VERBS: + assert f"apr repository release {verb}" in helps + + +def test_command_table_registers_release_verbs(): + from azure.cli.core.mock import DummyCli + + from azext_apr import AprCommandsLoader + + loader = AprCommandsLoader(cli_ctx=DummyCli()) + loader.load_command_table(None) + + registered = { + k + for k in loader.command_table + if k.startswith("apr repository release ") + and not k.startswith("apr repository release component") + } + expected = {f"apr repository release {v}" for v in VERBS} + assert registered == expected + + +def test_arguments_load_and_expose_expected_options(): + from azure.cli.core.mock import DummyCli + + from azext_apr import AprCommandsLoader + + loader = AprCommandsLoader(cli_ctx=DummyCli()) + loader.skip_applicability = True + loader.load_command_table(None) + loader.load_arguments("apr repository release create") + registry = loader.argument_registry.arguments + + def options(scope, dest): + return set(registry[scope][dest].settings.get("options_list") or []) + + grp = "apr repository release" + create = "apr repository release create" + assert {"--registry", "-r"} <= options(grp, "registry_name") + assert {"--repository"} <= options(grp, "repository") + assert {"--name", "-n"} <= options(grp, "release_name") + assert {"--codename"} <= options(create, "codename") + assert {"--suite"} <= options(create, "suite") + assert {"--components"} <= options(create, "components") + assert {"--architectures"} <= options(create, "architectures") + # --no-wait is wired on the long-running verbs only + for verb in ("create", "delete"): + assert loader.command_table[f"apr repository release {verb}"].supports_no_wait + assert not loader.command_table["apr repository release list"].supports_no_wait + + +def test_list_pages_through_everything_by_default(fake): + rel.list_releases(None, "reg", REPOSITORY_ID) + assert fake.calls == [ + ( + "GET", + f"/repositories/{REPOSITORY_ID}/releases/", + {"params": {"limit": 100, "offset": 0}}, + ) + ] + + +def test_list_resolves_repository_name(fake): + rel.list_releases(None, "reg", "myrepo") + resolve, listing = fake.calls + assert resolve[:2] == ("GET", "/repositories/") + assert resolve[2]["params"] == {"name": "myrepo"} + assert listing[1] == "/repositories/repositories-resolved-id/releases/" + + +def test_list_filters_by_name(fake): + rel.list_releases(None, "reg", REPOSITORY_ID, release_name="jammy") + assert fake.calls == [ + ( + "GET", + f"/repositories/{REPOSITORY_ID}/releases/", + {"params": {"name": "jammy", "limit": 100, "offset": 0}}, + ) + ] + + +def test_list_with_zero_limit_is_rejected(fake): + from azure.cli.core.azclierror import InvalidArgumentValueError + + with pytest.raises(InvalidArgumentValueError): + rel.list_releases(None, "reg", REPOSITORY_ID, limit=0) + + +def test_create_posts_name_only_by_default(fake): + rel.create_release(None, "reg", REPOSITORY_ID, "jammy") + method, path, kwargs = fake.calls[0] + assert (method, path) == ("POST", f"/repositories/{REPOSITORY_ID}/releases/") + assert kwargs["json"] == {"name": "jammy"} + + +def test_create_splits_components_and_architectures(fake): + rel.create_release( + None, + "reg", + REPOSITORY_ID, + "jammy", + suite="stable", + components="main, contrib", + architectures="amd64,arm64", + ) + body = fake.calls[0][2]["json"] + assert body == { + "name": "jammy", + "suite": "stable", + "components": ["main", "contrib"], + "architectures": ["amd64", "arm64"], + } + + +def test_create_resolves_repository_name(fake): + rel.create_release(None, "reg", "myrepo", "jammy") + resolve, create = fake.calls + assert resolve[:2] == ("GET", "/repositories/") + assert create[1] == "/repositories/repositories-resolved-id/releases/" + + +def test_delete_resolves_release_name(fake): + rel.delete_release(None, "reg", REPOSITORY_ID, "jammy") + resolve, delete = fake.calls + # the release name is resolved under the repo path before the delete + assert resolve[:2] == ("GET", f"/repositories/{REPOSITORY_ID}/releases/") + assert resolve[2]["params"] == {"name": "jammy"} + assert delete[0] == "DELETE" + assert delete[1] == f"/repositories/{REPOSITORY_ID}/releases/releases-resolved-id/" + + +def test_create_passes_codename(fake): + rel.create_release(None, "reg", REPOSITORY_ID, "jammy", codename="jammy-cn") + assert fake.calls[0][2]["json"] == {"name": "jammy", "codename": "jammy-cn"} + + +def test_create_ignores_empty_component_and_architecture_lists(fake): + # a value that splits to nothing (e.g. just commas) must not send an empty + # override - the server applies its own defaults when the field is absent + rel.create_release(None, "reg", REPOSITORY_ID, "jammy", components=",", architectures=" , ") + assert fake.calls[0][2]["json"] == {"name": "jammy"} + + +def test_create_no_wait_returns_task_handle_without_polling(fake): + fake.post_response = {"task": "t1"} + result = rel.create_release(None, "reg", REPOSITORY_ID, "jammy", no_wait=True) + assert result == {"task": "t1"} + assert all("/tasks/" not in c[1] for c in fake.calls) + + +def test_create_waits_on_task_by_default(fake): + # an async create returns a task handle; without --no-wait we poll to completion + fake.post_response = {"task": "t1"} + + def get(path, **kwargs): + fake._record("GET", path, kwargs) + if path == "/tasks/t1/": + return FakeResp({"id": "t1", "state": "completed"}) + return FakeResp({"results": []}) + + fake.get = get + result = rel.create_release(None, "reg", REPOSITORY_ID, "jammy") + assert ("GET", "/tasks/t1/", {}) in fake.calls + assert result == {"id": "t1", "state": "completed"} + + +def test_delete_no_wait_returns_task_handle_without_polling(fake): + fake.delete_response = {"task": "t1"} + result = rel.delete_release(None, "reg", REPOSITORY_ID, RELEASE_ID, no_wait=True) + assert result == {"task": "t1"} + assert all("/tasks/" not in c[1] for c in fake.calls) + + +def test_delete_waits_on_task_by_default(fake): + fake.delete_response = {"task": "t1"} + + def get(path, **kwargs): + fake._record("GET", path, kwargs) + if path == "/tasks/t1/": + return FakeResp({"id": "t1", "state": "completed"}) + return FakeResp({"results": []}) + + fake.get = get + rel.delete_release(None, "reg", REPOSITORY_ID, RELEASE_ID) + assert ("GET", "/tasks/t1/", {}) in fake.calls + + +COMPONENT_VERBS = ["list", "create", "delete"] + + +def test_component_helps_registered(): + from knack.help_files import helps + + assert "apr repository release component" in helps + for verb in COMPONENT_VERBS: + assert f"apr repository release component {verb}" in helps + + +def test_command_table_registers_release_component_verbs(): + from azure.cli.core.mock import DummyCli + + from azext_apr import AprCommandsLoader + + loader = AprCommandsLoader(cli_ctx=DummyCli()) + loader.load_command_table(None) + + registered = { + k for k in loader.command_table if k.startswith("apr repository release component") + } + expected = {f"apr repository release component {v}" for v in COMPONENT_VERBS} + assert registered == expected + + +def test_component_arguments_load_and_expose_expected_options(): + from azure.cli.core.mock import DummyCli + + from azext_apr import AprCommandsLoader + + loader = AprCommandsLoader(cli_ctx=DummyCli()) + loader.skip_applicability = True + loader.load_command_table(None) + loader.load_arguments("apr repository release component create") + registry = loader.argument_registry.arguments + + def options(scope, dest): + return set(registry[scope][dest].settings.get("options_list") or []) + + base = "apr repository release" + grp = "apr repository release component" + # registry/repository are shared from the base release scope + assert {"--registry", "-r"} <= options(base, "registry_name") + assert {"--repository"} <= options(base, "repository") + assert {"--release"} <= options(grp, "release") + assert {"--name", "-n"} <= options(grp, "component_name") + # --no-wait is wired on the long-running verbs only + for verb in ("create", "delete"): + assert loader.command_table[f"apr repository release component {verb}"].supports_no_wait + assert not loader.command_table["apr repository release component list"].supports_no_wait + + +def test_component_list_pages_through_everything_by_default(fake): + rel.list_release_components(None, "reg", REPOSITORY_ID, RELEASE_ID) + assert fake.calls == [ + ( + "GET", + f"/repositories/{REPOSITORY_ID}/releases/{RELEASE_ID}/components/", + {"params": {"limit": 100, "offset": 0}}, + ) + ] + + +def test_component_list_resolves_release_name(fake): + rel.list_release_components(None, "reg", REPOSITORY_ID, "jammy") + resolve, listing = fake.calls + assert resolve[:2] == ("GET", f"/repositories/{REPOSITORY_ID}/releases/") + assert resolve[2]["params"] == {"name": "jammy"} + assert listing[1] == ( + f"/repositories/{REPOSITORY_ID}/releases/releases-resolved-id/components/" + ) + + +def test_component_list_filters_by_component(fake): + rel.list_release_components(None, "reg", REPOSITORY_ID, RELEASE_ID, component_name="contrib") + assert fake.calls == [ + ( + "GET", + f"/repositories/{REPOSITORY_ID}/releases/{RELEASE_ID}/components/", + {"params": {"component": "contrib", "limit": 100, "offset": 0}}, + ) + ] + + +def test_component_list_with_zero_limit_is_rejected(fake): + from azure.cli.core.azclierror import InvalidArgumentValueError + + with pytest.raises(InvalidArgumentValueError): + rel.list_release_components(None, "reg", REPOSITORY_ID, RELEASE_ID, limit=0) + + +def test_component_create_posts_name_only(fake): + rel.create_release_component(None, "reg", REPOSITORY_ID, RELEASE_ID, "contrib") + method, path, kwargs = fake.calls[0] + assert (method, path) == ( + "POST", + f"/repositories/{REPOSITORY_ID}/releases/{RELEASE_ID}/components/", + ) + # the release's distribution is supplied by the path, so only the name is sent + assert kwargs["json"] == {"name": "contrib"} + + +def test_component_create_resolves_release_name(fake): + rel.create_release_component(None, "reg", REPOSITORY_ID, "jammy", "contrib") + resolve, create = fake.calls + assert resolve[:2] == ("GET", f"/repositories/{REPOSITORY_ID}/releases/") + assert create[1] == (f"/repositories/{REPOSITORY_ID}/releases/releases-resolved-id/components/") + + +def test_component_delete_resolves_component_name(fake): + rel.delete_release_component(None, "reg", REPOSITORY_ID, RELEASE_ID, "contrib") + resolve, delete = fake.calls + # the component is resolved (by name, scoped to the release) before the delete + assert resolve[:2] == ( + "GET", + f"/repositories/{REPOSITORY_ID}/releases/{RELEASE_ID}/components/", + ) + assert resolve[2]["params"] == {"component": "contrib"} + assert delete[0] == "DELETE" + assert delete[1] == ( + f"/repositories/{REPOSITORY_ID}/releases/{RELEASE_ID}/components/{COMPONENT_ID}/" + ) + + +def test_component_delete_accepts_component_id_without_resolving(fake): + rel.delete_release_component(None, "reg", REPOSITORY_ID, RELEASE_ID, COMPONENT_ID) + # an id-looking value skips the resolve GET and deletes directly + assert [c[0] for c in fake.calls] == ["DELETE"] + assert fake.calls[0][1] == ( + f"/repositories/{REPOSITORY_ID}/releases/{RELEASE_ID}/components/{COMPONENT_ID}/" + ) diff --git a/src/apr/azext_apr/tests/test_resolve.py b/src/apr/azext_apr/tests/test_resolve.py new file mode 100644 index 00000000000..bc84454b012 --- /dev/null +++ b/src/apr/azext_apr/tests/test_resolve.py @@ -0,0 +1,205 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import pytest + +pytest.importorskip("azure.cli.core") + +from azext_apr.server._resolve import ( + resolve_id_or_name, + resolve_release, + resolve_release_name, +) + +ID = "repositories-deb-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +RELEASE_ID = "content-deb-releases-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + +class FakeResp: + def __init__(self, data): + self._data = data + + def json(self): + return self._data + + +class FakeClient: + def __init__(self, data): + self._data = data + self.calls = [] + + def get(self, path, **kwargs): + self.calls.append((path, kwargs)) + return FakeResp(self._data) + + +class PagingClient: + def __init__(self, pages): + self._pages = pages + self.calls = [] + + def get(self, path, **kwargs): + self.calls.append((path, kwargs)) + return FakeResp(self._pages[len(self.calls) - 1]) + + +def test_id_is_passed_through_without_hitting_the_server(): + client = FakeClient({}) + assert resolve_id_or_name(client, "repositories", ID) == ID + assert client.calls == [] + + +def test_empty_value_passes_through(): + client = FakeClient({}) + assert resolve_id_or_name(client, "repositories", "") == "" + assert client.calls == [] + + +def test_name_is_resolved_via_list_name_filter(): + client = FakeClient({"results": [{"id": ID}]}) + assert resolve_id_or_name(client, "repositories", "myrepo") == ID + path, kwargs = client.calls[0] + assert path == "/repositories/" + assert kwargs["params"] == {"name": "myrepo"} + + +def test_name_that_embeds_a_uuid_is_still_treated_as_a_name(): + # fullmatch: trailing text after a uuid means it's a name, not an id, + # so it must go through the name lookup instead of straight to the server. + client = FakeClient({"results": [{"id": ID}]}) + name = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-nightly" + assert resolve_id_or_name(client, "repositories", name) == ID + assert client.calls[0][0] == "/repositories/" + + +def test_leading_name_before_a_uuid_is_treated_as_a_name(): + # "my-repo-" is a name, not an id: the id prefix is anchored to the + # resource ("repositories-..."), so a user name that merely ends in a uuid + # falls through to the name lookup instead of being shoved into a path. + client = FakeClient({"results": [{"id": ID}]}) + name = "my-repo-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + assert resolve_id_or_name(client, "repositories", name) == ID + assert client.calls[0][0] == "/repositories/" + + +def test_bare_uuid_is_treated_as_an_id(): + client = FakeClient({}) + bare = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + assert resolve_id_or_name(client, "repositories", bare) == bare + assert client.calls == [] + + +def test_id_with_trailing_newline_is_not_treated_as_an_id(): + # fullmatch rejects a trailing newline (which .match()+$ would have allowed), + # so a dirty value goes to the name lookup instead of into a request path. + client = FakeClient({"results": [{"id": ID}]}) + assert resolve_id_or_name(client, "repositories", ID + "\n") == ID + assert client.calls[0][0] == "/repositories/" + + +def test_unknown_name_raises_not_found(): + from azure.cli.core.azclierror import ResourceNotFoundError + + # name filter matched nothing - empty results list + client = FakeClient({"results": []}) + with pytest.raises(ResourceNotFoundError): + resolve_id_or_name(client, "remotes", "ghost") + + +def test_release_id_passes_through_without_a_repo_or_server_call(): + client = FakeClient({}) + assert resolve_release(client, None, RELEASE_ID) == RELEASE_ID + assert client.calls == [] + + +def test_release_name_resolves_against_the_nested_repo_endpoint(): + client = FakeClient({"results": [{"id": RELEASE_ID}]}) + assert resolve_release(client, ID, "jammy") == RELEASE_ID + path, kwargs = client.calls[0] + assert path == f"/repositories/{ID}/releases/" + assert kwargs["params"] == {"name": "jammy"} + + +def test_release_name_without_a_repo_raises(): + from azure.cli.core.azclierror import RequiredArgumentMissingError + + client = FakeClient({}) + with pytest.raises(RequiredArgumentMissingError): + resolve_release(client, None, "jammy") + assert client.calls == [] + + +def test_release_unknown_name_raises_not_found(): + from azure.cli.core.azclierror import ResourceNotFoundError + + client = FakeClient({"results": []}) + with pytest.raises(ResourceNotFoundError): + resolve_release(client, ID, "ghost") + + +def test_release_name_passes_through_without_a_server_call(): + # The packages endpoint wants a name, so a name needs no lookup at all. + client = FakeClient({}) + assert resolve_release_name(client, ID, "jammy") == "jammy" + assert client.calls == [] + + +def test_release_id_resolves_back_to_its_distribution_name(): + client = FakeClient({"results": [{"id": RELEASE_ID, "distribution": "jammy"}]}) + assert resolve_release_name(client, ID, RELEASE_ID) == "jammy" + path, _ = client.calls[0] + assert path == f"/repositories/{ID}/releases/" + + +def test_release_id_matches_the_right_release_among_several(): + other_id = "content-deb-releases-11111111-2222-3333-4444-555555555555" + client = FakeClient( + { + "results": [ + {"id": other_id, "distribution": "focal"}, + {"id": RELEASE_ID, "distribution": "jammy"}, + ] + } + ) + assert resolve_release_name(client, ID, RELEASE_ID) == "jammy" + + +def test_release_id_pages_until_match_and_stops_early(): + client = PagingClient( + [ + { + "count": 300, + "results": [{"id": f"other-{index}"} for index in range(100)], + }, + { + "count": 300, + "results": [{"id": RELEASE_ID, "distribution": "jammy"}], + }, + ] + ) + + assert resolve_release_name(client, ID, RELEASE_ID) == "jammy" + assert client.calls == [ + (f"/repositories/{ID}/releases/", {"params": {"limit": 100, "offset": 0}}), + (f"/repositories/{ID}/releases/", {"params": {"limit": 100, "offset": 100}}), + ] + + +def test_release_id_without_a_repo_raises(): + from azure.cli.core.azclierror import RequiredArgumentMissingError + + client = FakeClient({}) + with pytest.raises(RequiredArgumentMissingError): + resolve_release_name(client, None, RELEASE_ID) + assert client.calls == [] + + +def test_release_id_from_another_repository_raises_not_found(): + from azure.cli.core.azclierror import ResourceNotFoundError + + # The id is only meaningful inside the repo the caller named. + client = FakeClient({"results": [{"id": "content-deb-releases-" + "9" * 8, "d": "x"}]}) + with pytest.raises(ResourceNotFoundError): + resolve_release_name(client, ID, RELEASE_ID) diff --git a/src/apr/azext_apr/tests/test_task.py b/src/apr/azext_apr/tests/test_task.py new file mode 100644 index 00000000000..8a5457cecfe --- /dev/null +++ b/src/apr/azext_apr/tests/test_task.py @@ -0,0 +1,145 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import pytest + +pytest.importorskip("azure.cli.core") + +from azext_apr.commands import task + +VERBS = ["list", "show", "wait", "cancel"] + + +class FakeResp: + def __init__(self, json_data=None): + self._json = {} if json_data is None else json_data + self.content = b"{}" + + def json(self): + return self._json + + +class FakeClient: + """Records calls; answers /tasks/{id}/ polls with a configurable state.""" + + def __init__(self): + self.calls = [] + self.task_states = {} + + def _record(self, method, path, kwargs): + self.calls.append((method, path, kwargs)) + + def get(self, path, **kwargs): + self._record("GET", path, kwargs) + if path.startswith("/tasks/") and path != "/tasks/": + task_id = path.strip("/").split("/")[-1] + state = self.task_states.get(task_id, "completed") + return FakeResp({"id": task_id, "state": state}) + return FakeResp({"results": []}) + + def patch(self, path, **kwargs): + self._record("PATCH", path, kwargs) + return FakeResp({"id": "t1", "state": "canceling"}) + + +@pytest.fixture +def fake(monkeypatch): + client = FakeClient() + monkeypatch.setattr(task, "_client", lambda cmd, rn, rg=None: client) + return client + + +def test_helps_registered(): + from knack.help_files import helps + + assert "apr task" in helps + for verb in VERBS: + assert f"apr task {verb}" in helps + + +def test_command_table_registers_task_verbs(): + from azure.cli.core.mock import DummyCli + + from azext_apr import AprCommandsLoader + + loader = AprCommandsLoader(cli_ctx=DummyCli()) + loader.load_command_table(None) + + registered = {k for k in loader.command_table if k.startswith("apr task")} + expected = {f"apr task {v}" for v in VERBS} + assert registered == expected + + +def test_arguments_load_and_expose_expected_options(): + from azure.cli.core.mock import DummyCli + + from azext_apr import AprCommandsLoader + + loader = AprCommandsLoader(cli_ctx=DummyCli()) + loader.skip_applicability = True + loader.load_command_table(None) + loader.load_arguments("apr task list") + registry = loader.argument_registry.arguments + + def options(scope, dest): + return set(registry[scope][dest].settings.get("options_list") or []) + + assert {"--registry", "-r"} <= options("apr task", "registry_name") + assert {"--id"} <= options("apr task", "task_id") + assert {"--state"} <= options("apr task list", "state") + + loader.load_arguments("apr task wait") + assert {"--ids"} <= options("apr task wait", "task_ids") + assert registry["apr task wait"]["task_ids"].settings.get("required") is True + + +def test_list_pages_through_everything_by_default(fake): + task.list_tasks(None, "reg") + assert fake.calls == [("GET", "/tasks/", {"params": {"limit": 100, "offset": 0}})] + + +def test_list_filters_by_state(fake): + task.list_tasks(None, "reg", state="failed") + assert fake.calls == [ + ("GET", "/tasks/", {"params": {"state": "failed", "limit": 100, "offset": 0}}) + ] + + +def test_show_gets_task_directly(fake): + task.show_task(None, "reg", "taskid") + assert fake.calls == [("GET", "/tasks/taskid/", {})] + + +def test_wait_polls_each_id(fake): + task.wait_task(None, "reg", "id1, id2") + paths = [c[1] for c in fake.calls] + assert paths == ["/tasks/id1/", "/tasks/id2/"] + + +def test_wait_aggregates_failures(fake): + from azure.cli.core.azclierror import AzureResponseError + + fake.task_states = {"id2": "failed"} + with pytest.raises(AzureResponseError) as exc_info: + task.wait_task(None, "reg", "id1,id2") + assert "id2" in str(exc_info.value) + + +@pytest.mark.parametrize("task_ids", [" , ", None]) +def test_wait_rejects_invalid_ids_before_creating_client(monkeypatch, task_ids): + from azure.cli.core.azclierror import InvalidArgumentValueError + + monkeypatch.setattr( + task, + "_client", + lambda *_args, **_kwargs: pytest.fail("client should not be created"), + ) + with pytest.raises(InvalidArgumentValueError): + task.wait_task(None, "reg", task_ids) + + +def test_cancel_patches_cancel_route(fake): + task.cancel_task(None, "reg", "taskid") + assert fake.calls == [("PATCH", "/tasks/taskid/cancel/", {})] diff --git a/src/apr/linter_exclusions.yml b/src/apr/linter_exclusions.yml new file mode 100644 index 00000000000..4d76f2d1bd9 --- /dev/null +++ b/src/apr/linter_exclusions.yml @@ -0,0 +1,35 @@ +# APR data-plane operations return service task IDs and share `az apr task wait` +# instead of duplicating a wait command under every resource group. +apr distro: + rule_exclusions: + - require_wait_command_if_no_wait +apr package: + rule_exclusions: + - require_wait_command_if_no_wait +apr publication: + rule_exclusions: + - require_wait_command_if_no_wait +apr remote: + rule_exclusions: + - require_wait_command_if_no_wait +apr repository: + rule_exclusions: + - require_wait_command_if_no_wait +apr repository package: + rule_exclusions: + - require_wait_command_if_no_wait +apr repository release: + rule_exclusions: + - require_wait_command_if_no_wait +apr repository release component: + rule_exclusions: + - require_wait_command_if_no_wait + +# Registry commands are generated from the AAZ model. Examples will be added +# with the reviewed model instead of edited into generated source. +apr registry create: + rule_exclusions: + - missing_command_example +apr registry update: + rule_exclusions: + - missing_command_example diff --git a/src/apr/pyproject.toml b/src/apr/pyproject.toml new file mode 100644 index 00000000000..8fba500468f --- /dev/null +++ b/src/apr/pyproject.toml @@ -0,0 +1,69 @@ +[build-system] +requires = ["setuptools>=70"] +build-backend = "setuptools.build_meta" + +[project] +name = "apr" +version = "1.0.0b1" +description = "Manage Azure Package Registry resources, repositories, packages, publications, and tasks." +dynamic = ["readme"] +license = { text = "MIT" } +requires-python = ">=3.10" +authors = [ + { name = "Microsoft Corporation", email = "azpycli@microsoft.com" }, +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "License :: OSI Approved :: MIT License", +] +dependencies = [] + +[project.urls] +Home = "https://github.com/Azure/azure-cli-extensions/tree/main/src/apr" + +[tool.setuptools.dynamic] +readme = { file = ["README.md", "HISTORY.rst"], content-type = "text/markdown" } + +[tool.setuptools.packages.find] +include = ["azext_apr*"] + +[tool.setuptools.package-data] +azext_apr = ["azext_metadata.json"] + +[tool.ruff] +line-length = 100 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "PTH", # flake8-use-pathlib + "T20", # flake8-print +] + +[tool.mypy] +strict = true +python_version = "3.10" + +[[tool.mypy.overrides]] +module = "azext_apr.*" +warn_unused_ignores = false + +# azure-cli-core and knack ship no type stubs; don't fail strict mode on them. +[[tool.mypy.overrides]] +module = ["azure.cli.*", "knack.*", "rpm_rs"] +ignore_missing_imports = true + +[tool.pyright] +pythonVersion = "3.10" diff --git a/src/apr/setup.py b/src/apr/setup.py new file mode 100644 index 00000000000..6e1720171e8 --- /dev/null +++ b/src/apr/setup.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from setuptools import setup + +setup() diff --git a/src/service_name.json b/src/service_name.json index be03e7d1546..0d6346dafb2 100644 --- a/src/service_name.json +++ b/src/service_name.json @@ -59,6 +59,11 @@ "AzureServiceName": "API Center", "URL": "https://learn.microsoft.com/en-us/azure/api-center" }, + { + "Command": "az apr", + "AzureServiceName": "Azure Package Registry", + "URL": "" + }, { "Command": "az appnet", "AzureServiceName": "Azure Kubernetes Application Network",