From c5af26e89833edccd313a2f2674942c1050e2b09 Mon Sep 17 00:00:00 2001 From: Kai Daniels Date: Wed, 9 Sep 2026 22:07:44 +0000 Subject: [PATCH 01/12] add Package Registry preview extension --- src/alrs/.gitignore | 17 + src/alrs/HISTORY.rst | 12 + src/alrs/LICENSE | 21 + src/alrs/README.md | 31 ++ src/alrs/azext_alrs/__init__.py | 31 ++ src/alrs/azext_alrs/_client_factory.py | 11 + src/alrs/azext_alrs/aaz/__init__.py | 6 + src/alrs/azext_alrs/aaz/latest/__init__.py | 9 + .../azext_alrs/aaz/latest/alrs/__cmd_group.py | 24 + .../azext_alrs/aaz/latest/alrs/__init__.py | 11 + .../aaz/latest/alrs/registry/__cmd_group.py | 24 + .../aaz/latest/alrs/registry/__init__.py | 17 + .../aaz/latest/alrs/registry/_create.py | 283 +++++++++++ .../aaz/latest/alrs/registry/_delete.py | 160 +++++++ .../aaz/latest/alrs/registry/_list.py | 378 +++++++++++++++ .../aaz/latest/alrs/registry/_show.py | 225 +++++++++ .../aaz/latest/alrs/registry/_update.py | 256 ++++++++++ .../aaz/latest/alrs/registry/_wait.py | 224 +++++++++ src/alrs/azext_alrs/azext_metadata.json | 4 + src/alrs/azext_alrs/commands/__init__.py | 31 ++ src/alrs/azext_alrs/commands/distro.py | 248 ++++++++++ src/alrs/azext_alrs/commands/package.py | 380 +++++++++++++++ src/alrs/azext_alrs/commands/publication.py | 94 ++++ src/alrs/azext_alrs/commands/remote.py | 232 +++++++++ src/alrs/azext_alrs/commands/repository.py | 325 +++++++++++++ .../azext_alrs/commands/repository_package.py | 194 ++++++++ .../azext_alrs/commands/repository_release.py | 302 ++++++++++++ src/alrs/azext_alrs/commands/task.py | 130 +++++ src/alrs/azext_alrs/server/__init__.py | 31 ++ src/alrs/azext_alrs/server/_data_plane.py | 410 ++++++++++++++++ src/alrs/azext_alrs/server/_paging.py | 90 ++++ src/alrs/azext_alrs/server/_polling.py | 55 +++ src/alrs/azext_alrs/server/_resolve.py | 129 +++++ src/alrs/azext_alrs/tests/__init__.py | 0 src/alrs/azext_alrs/tests/latest/__init__.py | 0 .../tests/latest/test_alrs_scenario.py | 9 + src/alrs/azext_alrs/tests/test_data_plane.py | 453 ++++++++++++++++++ .../tests/test_data_plane_composition.py | 156 ++++++ src/alrs/azext_alrs/tests/test_distro.py | 340 +++++++++++++ src/alrs/azext_alrs/tests/test_extension.py | 73 +++ src/alrs/azext_alrs/tests/test_package.py | 411 ++++++++++++++++ src/alrs/azext_alrs/tests/test_paging.py | 117 +++++ src/alrs/azext_alrs/tests/test_polling.py | 74 +++ src/alrs/azext_alrs/tests/test_publication.py | 139 ++++++ src/alrs/azext_alrs/tests/test_registry.py | 56 +++ src/alrs/azext_alrs/tests/test_remote.py | 211 ++++++++ src/alrs/azext_alrs/tests/test_repository.py | 335 +++++++++++++ .../tests/test_repository_package.py | 249 ++++++++++ .../tests/test_repository_release.py | 385 +++++++++++++++ src/alrs/azext_alrs/tests/test_resolve.py | 200 ++++++++ src/alrs/azext_alrs/tests/test_task.py | 140 ++++++ src/alrs/pyproject.toml | 69 +++ src/alrs/setup.py | 8 + 53 files changed, 7820 insertions(+) create mode 100644 src/alrs/.gitignore create mode 100644 src/alrs/HISTORY.rst create mode 100644 src/alrs/LICENSE create mode 100644 src/alrs/README.md create mode 100644 src/alrs/azext_alrs/__init__.py create mode 100644 src/alrs/azext_alrs/_client_factory.py create mode 100644 src/alrs/azext_alrs/aaz/__init__.py create mode 100644 src/alrs/azext_alrs/aaz/latest/__init__.py create mode 100644 src/alrs/azext_alrs/aaz/latest/alrs/__cmd_group.py create mode 100644 src/alrs/azext_alrs/aaz/latest/alrs/__init__.py create mode 100644 src/alrs/azext_alrs/aaz/latest/alrs/registry/__cmd_group.py create mode 100644 src/alrs/azext_alrs/aaz/latest/alrs/registry/__init__.py create mode 100644 src/alrs/azext_alrs/aaz/latest/alrs/registry/_create.py create mode 100644 src/alrs/azext_alrs/aaz/latest/alrs/registry/_delete.py create mode 100644 src/alrs/azext_alrs/aaz/latest/alrs/registry/_list.py create mode 100644 src/alrs/azext_alrs/aaz/latest/alrs/registry/_show.py create mode 100644 src/alrs/azext_alrs/aaz/latest/alrs/registry/_update.py create mode 100644 src/alrs/azext_alrs/aaz/latest/alrs/registry/_wait.py create mode 100644 src/alrs/azext_alrs/azext_metadata.json create mode 100644 src/alrs/azext_alrs/commands/__init__.py create mode 100644 src/alrs/azext_alrs/commands/distro.py create mode 100644 src/alrs/azext_alrs/commands/package.py create mode 100644 src/alrs/azext_alrs/commands/publication.py create mode 100644 src/alrs/azext_alrs/commands/remote.py create mode 100644 src/alrs/azext_alrs/commands/repository.py create mode 100644 src/alrs/azext_alrs/commands/repository_package.py create mode 100644 src/alrs/azext_alrs/commands/repository_release.py create mode 100644 src/alrs/azext_alrs/commands/task.py create mode 100644 src/alrs/azext_alrs/server/__init__.py create mode 100644 src/alrs/azext_alrs/server/_data_plane.py create mode 100644 src/alrs/azext_alrs/server/_paging.py create mode 100644 src/alrs/azext_alrs/server/_polling.py create mode 100644 src/alrs/azext_alrs/server/_resolve.py create mode 100644 src/alrs/azext_alrs/tests/__init__.py create mode 100644 src/alrs/azext_alrs/tests/latest/__init__.py create mode 100644 src/alrs/azext_alrs/tests/latest/test_alrs_scenario.py create mode 100644 src/alrs/azext_alrs/tests/test_data_plane.py create mode 100644 src/alrs/azext_alrs/tests/test_data_plane_composition.py create mode 100644 src/alrs/azext_alrs/tests/test_distro.py create mode 100644 src/alrs/azext_alrs/tests/test_extension.py create mode 100644 src/alrs/azext_alrs/tests/test_package.py create mode 100644 src/alrs/azext_alrs/tests/test_paging.py create mode 100644 src/alrs/azext_alrs/tests/test_polling.py create mode 100644 src/alrs/azext_alrs/tests/test_publication.py create mode 100644 src/alrs/azext_alrs/tests/test_registry.py create mode 100644 src/alrs/azext_alrs/tests/test_remote.py create mode 100644 src/alrs/azext_alrs/tests/test_repository.py create mode 100644 src/alrs/azext_alrs/tests/test_repository_package.py create mode 100644 src/alrs/azext_alrs/tests/test_repository_release.py create mode 100644 src/alrs/azext_alrs/tests/test_resolve.py create mode 100644 src/alrs/azext_alrs/tests/test_task.py create mode 100644 src/alrs/pyproject.toml create mode 100644 src/alrs/setup.py diff --git a/src/alrs/.gitignore b/src/alrs/.gitignore new file mode 100644 index 00000000000..e84e843cb66 --- /dev/null +++ b/src/alrs/.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/alrs/HISTORY.rst b/src/alrs/HISTORY.rst new file mode 100644 index 00000000000..f0a79453124 --- /dev/null +++ b/src/alrs/HISTORY.rst @@ -0,0 +1,12 @@ +.. :changelog: + +Release History +=============== + +0.1.0 ++++++ + +* Add the initial preview command surface for registries, repositories, + releases, packages, distributions, remotes, publications, and tasks. +* Generate registry management commands with AAZ. +* Require HTTPS for remote data-plane endpoints and retry only read requests. diff --git a/src/alrs/LICENSE b/src/alrs/LICENSE new file mode 100644 index 00000000000..22aed37e650 --- /dev/null +++ b/src/alrs/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/alrs/README.md b/src/alrs/README.md new file mode 100644 index 00000000000..b1d01e774ab --- /dev/null +++ b/src/alrs/README.md @@ -0,0 +1,31 @@ +# Azure Package Registry CLI extension + +This preview extension manages Azure Package Registry resources and their +repository content. + +Product and command naming are provisional while the service completes its +onboarding reviews. + +## Command groups + +- `az alrs registry` +- `az alrs repository` +- `az alrs repository release` +- `az alrs repository package` +- `az alrs package` +- `az alrs distro` +- `az alrs remote` +- `az alrs publication` +- `az alrs 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 alrs +az alrs --help +``` diff --git a/src/alrs/azext_alrs/__init__.py b/src/alrs/azext_alrs/__init__.py new file mode 100644 index 00000000000..953eb3ee148 --- /dev/null +++ b/src/alrs/azext_alrs/__init__.py @@ -0,0 +1,31 @@ +from azure.cli.core import AzCommandsLoader + + +class AlrsCommandsLoader(AzCommandsLoader): + def __init__(self, cli_ctx=None): + from azure.cli.core.commands import CliCommandType + + from azext_alrs._client_factory import cf_alrs + + alrs_custom = CliCommandType( + operations_tmpl="azext_alrs.commands.registry#{}", + client_factory=cf_alrs, + ) + super().__init__(cli_ctx=cli_ctx, custom_command_type=alrs_custom) + + def load_command_table(self, args): + from azure.cli.core.aaz import load_aaz_command_table + + from azext_alrs.commands import load_command_table + + load_aaz_command_table(self, "azext_alrs.aaz", args) + load_command_table(self, args) + return self.command_table + + def load_arguments(self, command): + from azext_alrs.commands import load_arguments + + load_arguments(self, command) + + +COMMAND_LOADER_CLS = AlrsCommandsLoader diff --git a/src/alrs/azext_alrs/_client_factory.py b/src/alrs/azext_alrs/_client_factory.py new file mode 100644 index 00000000000..c5bc75bccc4 --- /dev/null +++ b/src/alrs/azext_alrs/_client_factory.py @@ -0,0 +1,11 @@ +def cf_alrs(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 azure-mgmt-alrs client is wired up. + from azext_alrs.server import raise_if_dev_extension + + raise_if_dev_extension() + raise NotImplementedError("azure-mgmt-alrs SDK not yet available") diff --git a/src/alrs/azext_alrs/aaz/__init__.py b/src/alrs/azext_alrs/aaz/__init__.py new file mode 100644 index 00000000000..5757aea3175 --- /dev/null +++ b/src/alrs/azext_alrs/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/alrs/azext_alrs/aaz/latest/__init__.py b/src/alrs/azext_alrs/aaz/latest/__init__.py new file mode 100644 index 00000000000..7e832c711e8 --- /dev/null +++ b/src/alrs/azext_alrs/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/alrs/azext_alrs/aaz/latest/alrs/__cmd_group.py b/src/alrs/azext_alrs/aaz/latest/alrs/__cmd_group.py new file mode 100644 index 00000000000..3fe301e7e09 --- /dev/null +++ b/src/alrs/azext_alrs/aaz/latest/alrs/__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( + "alrs", +) +class __CMDGroup(AAZCommandGroup): + """Manage Alrs""" + + pass + + +__all__ = ["__CMDGroup"] diff --git a/src/alrs/azext_alrs/aaz/latest/alrs/__init__.py b/src/alrs/azext_alrs/aaz/latest/alrs/__init__.py new file mode 100644 index 00000000000..5a9d61963d6 --- /dev/null +++ b/src/alrs/azext_alrs/aaz/latest/alrs/__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/alrs/azext_alrs/aaz/latest/alrs/registry/__cmd_group.py b/src/alrs/azext_alrs/aaz/latest/alrs/registry/__cmd_group.py new file mode 100644 index 00000000000..f81ce569a77 --- /dev/null +++ b/src/alrs/azext_alrs/aaz/latest/alrs/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( + "alrs registry", +) +class __CMDGroup(AAZCommandGroup): + """Manage Azure Linux Repo Service (ALRS) registries.""" + + pass + + +__all__ = ["__CMDGroup"] diff --git a/src/alrs/azext_alrs/aaz/latest/alrs/registry/__init__.py b/src/alrs/azext_alrs/aaz/latest/alrs/registry/__init__.py new file mode 100644 index 00000000000..db73033039b --- /dev/null +++ b/src/alrs/azext_alrs/aaz/latest/alrs/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/alrs/azext_alrs/aaz/latest/alrs/registry/_create.py b/src/alrs/azext_alrs/aaz/latest/alrs/registry/_create.py new file mode 100644 index 00000000000..35b87e0ec50 --- /dev/null +++ b/src/alrs/azext_alrs/aaz/latest/alrs/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_alrs.server import raise_if_dev_extension + + +@register_command( + "alrs 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/alrs/azext_alrs/aaz/latest/alrs/registry/_delete.py b/src/alrs/azext_alrs/aaz/latest/alrs/registry/_delete.py new file mode 100644 index 00000000000..6265039baf9 --- /dev/null +++ b/src/alrs/azext_alrs/aaz/latest/alrs/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_alrs.server import raise_if_dev_extension + + +@register_command( + "alrs 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/alrs/azext_alrs/aaz/latest/alrs/registry/_list.py b/src/alrs/azext_alrs/aaz/latest/alrs/registry/_list.py new file mode 100644 index 00000000000..42c8560790b --- /dev/null +++ b/src/alrs/azext_alrs/aaz/latest/alrs/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_alrs.server import raise_if_dev_extension + + +@register_command( + "alrs 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/alrs/azext_alrs/aaz/latest/alrs/registry/_show.py b/src/alrs/azext_alrs/aaz/latest/alrs/registry/_show.py new file mode 100644 index 00000000000..84c9e3cdd83 --- /dev/null +++ b/src/alrs/azext_alrs/aaz/latest/alrs/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_alrs.server import raise_if_dev_extension + + +@register_command( + "alrs 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/alrs/azext_alrs/aaz/latest/alrs/registry/_update.py b/src/alrs/azext_alrs/aaz/latest/alrs/registry/_update.py new file mode 100644 index 00000000000..12aae4f980e --- /dev/null +++ b/src/alrs/azext_alrs/aaz/latest/alrs/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_alrs.server import raise_if_dev_extension + + +@register_command( + "alrs 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/alrs/azext_alrs/aaz/latest/alrs/registry/_wait.py b/src/alrs/azext_alrs/aaz/latest/alrs/registry/_wait.py new file mode 100644 index 00000000000..1509e69f706 --- /dev/null +++ b/src/alrs/azext_alrs/aaz/latest/alrs/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_alrs.server import raise_if_dev_extension + + +@register_command( + "alrs 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/alrs/azext_alrs/azext_metadata.json b/src/alrs/azext_alrs/azext_metadata.json new file mode 100644 index 00000000000..b87dc4a93ba --- /dev/null +++ b/src/alrs/azext_alrs/azext_metadata.json @@ -0,0 +1,4 @@ +{ + "azext.minCliCoreVersion": "2.55.0", + "azext.isPreview": true +} diff --git a/src/alrs/azext_alrs/commands/__init__.py b/src/alrs/azext_alrs/commands/__init__.py new file mode 100644 index 00000000000..406e3f6871b --- /dev/null +++ b/src/alrs/azext_alrs/commands/__init__.py @@ -0,0 +1,31 @@ +from azext_alrs.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/alrs/azext_alrs/commands/distro.py b/src/alrs/azext_alrs/commands/distro.py new file mode 100644 index 00000000000..9347f29377a --- /dev/null +++ b/src/alrs/azext_alrs/commands/distro.py @@ -0,0 +1,248 @@ +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_alrs.server import DataPlaneClient, list_all, resolve_id_or_name, wait_for_task + +DISTRO_TYPES = ["apt", "yum", "file"] + + +helps["alrs distro"] = """ +type: group +short-summary: Manage distributions that serve a repository's content. +""" + +helps["alrs distro list"] = """ +type: command +short-summary: List distributions. +examples: + - name: List all distributions in a registry. + text: az alrs distro list -g myrg -r myregistry + - name: List distributions serving a given repository. + text: az alrs distro list -g myrg -r myregistry --repository myrepo +""" + +helps["alrs distro create"] = """ +type: command +short-summary: Create a distribution. +examples: + - name: Create an apt distribution serving a repository. + text: | + az alrs distro create -g myrg -r myregistry -n mydistro \ + --type apt --base-path dists/stable --repository myrepo +""" + +helps["alrs distro show"] = """ +type: command +short-summary: Show details for a distribution. +examples: + - name: Show a distribution by name. + text: az alrs distro show -g myrg -r myregistry -n mydistro +""" + +helps["alrs distro update"] = """ +type: command +short-summary: Update a distribution. +examples: + - name: Change the base path a distribution is served at. + text: az alrs distro update -g myrg -r myregistry -n mydistro --base-path dists/next +""" + +helps["alrs distro delete"] = """ +type: command +short-summary: Delete a distribution. +examples: + - name: Delete a distribution by name. + text: az alrs distro delete -g myrg -r myregistry -n mydistro +""" + + +def register_commands(loader): + distro_type = CliCommandType(operations_tmpl="azext_alrs.commands.distro#{}") + with loader.command_group("alrs 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("alrs 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 ALRS registry.", + ) + c.argument( + "distro_name", + options_list=["--name", "-n"], + help="Name or ID of the distribution.", + ) + + with loader.argument_context("alrs distro create") as c: + c.argument( + "distro_type", + options_list=["--type"], + arg_type=get_enum_type(DISTRO_TYPES), + help="Distribution type.", + ) + + for scope in ("alrs distro create", "alrs 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("alrs 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/alrs/azext_alrs/commands/package.py b/src/alrs/azext_alrs/commands/package.py new file mode 100644 index 00000000000..571bd443636 --- /dev/null +++ b/src/alrs/azext_alrs/commands/package.py @@ -0,0 +1,380 @@ +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_alrs.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["alrs package"] = """ +type: group +short-summary: Upload and inspect packages. +""" + +helps["alrs package upload"] = """ +type: command +short-summary: Upload a package. +examples: + - name: Upload a package without adding it to a repository. + text: | + az alrs package upload -g myrg -r myregistry --file ./my-package.deb + - name: Upload a deb package to a repository. + text: | + az alrs 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 alrs 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 alrs package upload -g myrg -r myregistry --repository myrepo \ + --file ./my-package.rpm + - name: Upload a file package. + text: | + az alrs package upload -g myrg -r myregistry --repository myrepo \ + --file ./settings --type file +""" + +for _ptype, _summary in _TYPE_SUMMARY.items(): + helps[f"alrs package {_ptype}"] = f""" +type: group +short-summary: Manage {_summary}. +""" + helps[f"alrs package {_ptype} list"] = f""" +type: command +short-summary: List {_summary}. +""" + helps[f"alrs package {_ptype} show"] = """ +type: command +short-summary: Show details for a package. +""" + + +def register_commands(loader): + package_type = CliCommandType(operations_tmpl="azext_alrs.commands.package#{}") + with loader.command_group( + "alrs 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"alrs 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("alrs 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 ALRS registry.", + ) + + with loader.argument_context("alrs 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"alrs package {ptype} show") as c: + c.argument("package_id", options_list=["--id"], help="Package id.") + + with loader.argument_context(f"alrs 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"alrs 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"alrs 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("alrs 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/alrs/azext_alrs/commands/publication.py b/src/alrs/azext_alrs/commands/publication.py new file mode 100644 index 00000000000..3c4efb863ae --- /dev/null +++ b/src/alrs/azext_alrs/commands/publication.py @@ -0,0 +1,94 @@ +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_alrs.server import DataPlaneClient, list_all, resolve_id_or_name, wait_for_task + +helps["alrs publication"] = """ +type: group +short-summary: View and delete publications. +""" + +helps["alrs publication list"] = """ +type: command +short-summary: List publications. +examples: + - name: List all publications in a registry. + text: az alrs publication list -g myrg -r myregistry + - name: List publications for a given repository. + text: az alrs publication list -g myrg -r myregistry --repository myrepo +""" + +helps["alrs publication show"] = """ +type: command +short-summary: Show details for a publication. +examples: + - name: Show a publication by id. + text: az alrs publication show -g myrg -r myregistry --id mypublicationid +""" + +helps["alrs publication delete"] = """ +type: command +short-summary: Delete a publication. +examples: + - name: Delete a publication by id. + text: az alrs publication delete -g myrg -r myregistry --id mypublicationid +""" + + +def register_commands(loader): + publication_type = CliCommandType(operations_tmpl="azext_alrs.commands.publication#{}") + with loader.command_group( + "alrs 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("alrs 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 ALRS registry.", + ) + # Publications have no name; they're referenced by id. + c.argument("publication_id", options_list=["--id"], help="Publication id.") + + with loader.argument_context("alrs 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/alrs/azext_alrs/commands/remote.py b/src/alrs/azext_alrs/commands/remote.py new file mode 100644 index 00000000000..3972484ee7d --- /dev/null +++ b/src/alrs/azext_alrs/commands/remote.py @@ -0,0 +1,232 @@ +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_alrs.server import DataPlaneClient, list_all, resolve_id_or_name, wait_for_task + +REMOTE_TYPES = ["apt", "yum"] +DOWNLOAD_POLICIES = ["immediate", "on-demand", "streamed"] + + +helps["alrs remote"] = """ +type: group +short-summary: Manage upstream remotes that repositories sync from. +""" + +helps["alrs remote list"] = """ +type: command +short-summary: List remotes. +examples: + - name: List all remotes in a registry. + text: az alrs remote list -g myrg -r myregistry +""" + +helps["alrs remote create"] = """ +type: command +short-summary: Create a remote. +examples: + - name: Create an apt remote syncing selected distributions and components. + text: | + az alrs 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 alrs remote create -g myrg -r myregistry -n myremote --type yum \ + --url https://packages.microsoft.com/rhel/9/prod --policy on-demand +""" + +helps["alrs remote show"] = """ +type: command +short-summary: Show details for a remote. +examples: + - name: Show a remote by name. + text: az alrs remote show -g myrg -r myregistry -n myremote +""" + +helps["alrs remote update"] = """ +type: command +short-summary: Update a remote. +examples: + - name: Change the upstream URL a remote syncs from. + text: az alrs remote update -g myrg -r myregistry -n myremote --url http://archive.ubuntu.com/ubuntu + - name: Download packages only when clients request them. + text: az alrs remote update -g myrg -r myregistry -n myremote --policy on-demand +""" + +helps["alrs remote delete"] = """ +type: command +short-summary: Delete a remote. +examples: + - name: Delete a remote by name. + text: az alrs remote delete -g myrg -r myregistry -n myremote +""" + + +def register_commands(loader): + remote_type = CliCommandType(operations_tmpl="azext_alrs.commands.remote#{}") + with loader.command_group("alrs 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("alrs 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 ALRS registry.", + ) + c.argument( + "remote_name", + options_list=["--name", "-n"], + help="Name or ID of the remote.", + ) + + with loader.argument_context("alrs 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("alrs remote update") as c: + c.argument("url", options_list=["--url"], help="Upstream URL to sync from.") + + for scope in ("alrs remote create", "alrs 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 ("alrs remote create", "alrs 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("alrs 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/alrs/azext_alrs/commands/repository.py b/src/alrs/azext_alrs/commands/repository.py new file mode 100644 index 00000000000..e52fcf52f9c --- /dev/null +++ b/src/alrs/azext_alrs/commands/repository.py @@ -0,0 +1,325 @@ +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_alrs.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["alrs repository"] = """ +type: group +short-summary: Manage repositories within an ALRS registry. +""" + +helps["alrs repository list"] = """ +type: command +short-summary: List repositories in a registry. +examples: + - name: List all repositories in a registry. + text: az alrs repository list -g myrg -r myregistry +""" + +helps["alrs 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 alrs repository create -g myrg -r myregistry -n myrepo --type apt + - name: Create a repository that keeps the last 10 versions. + text: az alrs repository create -g myrg -r myregistry -n myrepo --type apt --retain-versions 10 + - name: Create a repository that keeps every version. + text: az alrs repository create -g myrg -r myregistry -n myrepo --type apt --retain-all +""" + +helps["alrs repository show"] = """ +type: command +short-summary: Show repository details. +examples: + - name: Show a repository by name. + text: az alrs repository show -g myrg -r myregistry -n myrepo +""" + +helps["alrs repository update"] = """ +type: command +short-summary: Update repository configuration. +examples: + - name: Bind a repository to a remote it syncs from. + text: az alrs repository update -g myrg -r myregistry -n myrepo --remote myremote + - name: Unset the remote. + text: az alrs repository update -g myrg -r myregistry -n myrepo --remote "" + - name: Change how many versions the repository keeps. + text: az alrs repository update -g myrg -r myregistry -n myrepo --retain-versions 3 + - name: Keep every version. + text: az alrs repository update -g myrg -r myregistry -n myrepo --retain-all +""" + +helps["alrs repository delete"] = """ +type: command +short-summary: Delete a repository and its contents. +examples: + - name: Delete a repository by name. + text: az alrs repository delete -g myrg -r myregistry -n myrepo +""" + +helps["alrs 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 alrs repository sync -g myrg -r myregistry -n myrepo + - name: Add upstream content without removing existing packages. + text: az alrs repository sync -g myrg -r myregistry -n myrepo --sync-mode additive + - name: Make the repository match the remote, removing anything not upstream. + text: az alrs repository sync -g myrg -r myregistry -n myrepo --sync-mode mirror +""" + +helps["alrs repository publish"] = """ +type: command +short-summary: Publish repository metadata (regenerate, sign, push to CDN). +examples: + - name: Publish a repository. + text: az alrs repository publish -g myrg -r myregistry -n myrepo + - name: Publish even when no changes are detected. + text: az alrs 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 --yes.") + return False + + +def register_commands(loader): + repository_type = CliCommandType(operations_tmpl="azext_alrs.commands.repository#{}") + with loader.command_group( + "alrs 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("alrs 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 ALRS registry.", + ) + c.argument( + "repository_name", + options_list=["--name", "-n"], + help="Name or ID of the repository.", + ) + + with loader.argument_context("alrs 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("alrs 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("alrs 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("alrs 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("alrs 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/alrs/azext_alrs/commands/repository_package.py b/src/alrs/azext_alrs/commands/repository_package.py new file mode 100644 index 00000000000..c9bca3f4ea2 --- /dev/null +++ b/src/alrs/azext_alrs/commands/repository_package.py @@ -0,0 +1,194 @@ +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_alrs.server import ( + DataPlaneClient, + resolve_id_or_name, + resolve_release, + resolve_release_name, + wait_for_task, +) + +helps["alrs repository package"] = """ +type: group +short-summary: Manage the packages contained in a repository. +""" + +helps["alrs repository package add"] = """ +type: command +short-summary: Add packages to a repository. +examples: + - name: Add packages to a repository. + text: az alrs 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 alrs 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 alrs 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 alrs repository package add -g myrg -r myregistry -n myrepo \ + --packages pkg1 --releases jammy --component contrib +""" + +helps["alrs repository package remove"] = """ +type: command +short-summary: Remove packages from a repository. +examples: + - name: Remove packages from a repository. + text: az alrs 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 alrs 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_alrs.commands.repository_package#{}" + ) + with loader.command_group( + "alrs 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("alrs 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 ALRS registry.", + ) + c.argument( + "repository_name", + options_list=["--name", "-n"], + help="Name or ID of the repository.", + ) + + for scope in ("alrs repository package add", "alrs 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/alrs/azext_alrs/commands/repository_release.py b/src/alrs/azext_alrs/commands/repository_release.py new file mode 100644 index 00000000000..f3966c4ea4e --- /dev/null +++ b/src/alrs/azext_alrs/commands/repository_release.py @@ -0,0 +1,302 @@ +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_alrs.server import ( + DataPlaneClient, + list_all, + resolve_id_or_name, + resolve_release, + resolve_release_component, + wait_for_task, +) + +helps["alrs repository release"] = """ +type: group +short-summary: Manage the apt releases (dists) of a repository. +""" + +helps["alrs repository release list"] = """ +type: command +short-summary: List releases. +examples: + - name: List all releases in a repository. + text: az alrs repository release list -g myrg -r myregistry --repository myrepo +""" + +helps["alrs repository release create"] = """ +type: command +short-summary: Create a release. +examples: + - name: Create a release with default components and architectures. + text: | + az alrs repository release create -g myrg -r myregistry --repository myrepo \ + -n jammy + - name: Create a release with explicit components and architectures. + text: | + az alrs repository release create -g myrg -r myregistry --repository myrepo \ + -n jammy --suite stable --components main,contrib --architectures amd64,arm64 +""" + +helps["alrs repository release delete"] = """ +type: command +short-summary: Delete a release. +examples: + - name: Delete a release by name. + text: az alrs repository release delete -g myrg -r myregistry --repository myrepo -n jammy +""" + +helps["alrs repository release component"] = """ +type: group +short-summary: Manage the components of an apt release. +""" + +helps["alrs repository release component list"] = """ +type: command +short-summary: List the components of a release. +examples: + - name: List the components of a release. + text: | + az alrs repository release component list -g myrg -r myregistry --repository myrepo \ + --release jammy +""" + +helps["alrs repository release component create"] = """ +type: command +short-summary: Create a release component. +examples: + - name: Add a component to an existing release. + text: | + az alrs repository release component create -g myrg -r myregistry --repository myrepo \ + --release jammy -n contrib +""" + +helps["alrs repository release component delete"] = """ +type: command +short-summary: Delete a release component. +examples: + - name: Delete a component from a release by name. + text: | + az alrs 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_alrs.commands.repository_release#{}" + ) + with loader.command_group( + "alrs 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( + "alrs 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("alrs 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 ALRS 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("alrs 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("alrs 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("alrs 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("alrs 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/alrs/azext_alrs/commands/task.py b/src/alrs/azext_alrs/commands/task.py new file mode 100644 index 00000000000..a8020e600d9 --- /dev/null +++ b/src/alrs/azext_alrs/commands/task.py @@ -0,0 +1,130 @@ +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_alrs.server import DataPlaneClient, list_all, poll_task + +TASK_STATES = ["completed", "failed", "running", "waiting", "canceled", "canceling", "skipped"] + + +helps["alrs task"] = """ +type: group +short-summary: View and manage background tasks. +""" + +helps["alrs task list"] = """ +type: command +short-summary: List tasks. +examples: + - name: List all tasks in a registry. + text: az alrs task list -g myrg -r myregistry + - name: List only failed tasks. + text: az alrs task list -g myrg -r myregistry --state failed +""" + +helps["alrs task show"] = """ +type: command +short-summary: Show details for a particular task. +examples: + - name: Show a task by id. + text: az alrs task show -g myrg -r myregistry --id mytaskid +""" + +helps["alrs task wait"] = """ +type: command +short-summary: Wait for a set of tasks to finish. +examples: + - name: Wait for several tasks to finish. + text: az alrs task wait -g myrg -r myregistry --ids task1,task2 +""" + +helps["alrs task cancel"] = """ +type: command +short-summary: Cancel a task. +examples: + - name: Cancel a running task by id. + text: az alrs task cancel -g myrg -r myregistry --id mytaskid +""" + + +def register_commands(loader): + task_type = CliCommandType(operations_tmpl="azext_alrs.commands.task#{}") + with loader.command_group("alrs 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("alrs 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 ALRS registry.", + ) + # Tasks have no name; they're referenced by id. + c.argument("task_id", options_list=["--id"], help="Task id.") + + with loader.argument_context("alrs 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("alrs 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/alrs/azext_alrs/server/__init__.py b/src/alrs/azext_alrs/server/__init__.py new file mode 100644 index 00000000000..a55385af54b --- /dev/null +++ b/src/alrs/azext_alrs/server/__init__.py @@ -0,0 +1,31 @@ +"""Data-plane server communication: HTTP client, polling, uploads.""" + +from azext_alrs.server._data_plane import ( + DataPlaneClient, + is_dev_extension, + raise_if_dev_extension, + resolve_api_endpoint, +) +from azext_alrs.server._paging import iter_results, list_all +from azext_alrs.server._polling import poll_task, wait_for_task +from azext_alrs.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/alrs/azext_alrs/server/_data_plane.py b/src/alrs/azext_alrs/server/_data_plane.py new file mode 100644 index 00000000000..261570671f4 --- /dev/null +++ b/src/alrs/azext_alrs/server/_data_plane.py @@ -0,0 +1,410 @@ +"""Shared HTTP client for the ALRS data plane. + +Foundation for every data-plane command (repository, package, distro, remote, +publication, task), which hit the registry's API endpoint directly, not ARM. +Uses the active Azure CLI login for authentication and maps service failures to +``azclierror`` exceptions. +""" + +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__) + +# The service team must confirm the final Entra audience before publication. +DATA_PLANE_RESOURCE = "https://api.alrs.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 service mounts its routes under this prefix. Path-versioning lets an older, +# independently distributed CLI keep working while the service evolves. Bump in +# lockstep with a breaking data-plane contract change. +DATA_PLANE_API_PREFIX = "/api/v1" + +# Keep this aligned with the reviewed ARM API specification. +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 = "alrs" + +# 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 development server, where authentication is skipped. +_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("alrs") + except PackageNotFoundError: + return "0.0.0" + + +class _LoggedRetry(Retry): + """Retry policy that logs each attempt through the Azure CLI logger.""" + + # 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 to azclierror. Unmapped failures use AzureResponseError. +_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 + if not parsed_url.hostname or (parsed_url.scheme != "https" and not is_local): + raise ValidationError( + "Registry API endpoints must use HTTPS. Only loopback development " + "endpoints may use HTTP." + ) + 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 ALRS 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 sequence in service logs. + self._cid = format(int(self._cid, 16) + 1, "x") + headers = { + "x-correlation-id": self._cid, + "alrs-cli-version": _cli_version(), + } + # A local development server has no Entra app, 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, *, retry: bool = True, **kwargs: Any + ) -> requests.Response: + _validate_request_path(path) + url = f"{self._api_root}{path}" + kwargs.setdefault("timeout", 600) + 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 retry 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}, + retry=False, + ) + + 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``, not from + the package or 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 _is_local_endpoint(base_url: str) -> bool: + return urlparse(base_url).hostname in _LOCAL_HOSTS + + +def _validate_request_path(path: str) -> None: + segments = path.strip("/").split("/") + has_unsafe_delimiter = any(char in path for char in ("?", "#", "\\", "%")) + has_unsafe_segment = not path.startswith("/") or any( + segment in {"", ".", ".."} for segment in segments + ) + has_control_character = any(ord(char) < 32 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 alrs 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 alrs registry ...) are not available in a " + "local dev build of this extension. A dev build targets the local ALRS " + 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 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-alrs 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 alrs 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/alrs/azext_alrs/server/_paging.py b/src/alrs/azext_alrs/server/_paging.py new file mode 100644 index 00000000000..3490837ae5b --- /dev/null +++ b/src/alrs/azext_alrs/server/_paging.py @@ -0,0 +1,90 @@ +"""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. ALRS 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/alrs/azext_alrs/server/_polling.py b/src/alrs/azext_alrs/server/_polling.py new file mode 100644 index 00000000000..1c3b5a98306 --- /dev/null +++ b/src/alrs/azext_alrs/server/_polling.py @@ -0,0 +1,55 @@ +"""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``. ARM long-running operations use the AAZ runtime instead. +""" + +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 complete. +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. The client reacquires its token + per request, so a token that expires mid-poll is refreshed on the next GET. + """ + 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/alrs/azext_alrs/server/_resolve.py b/src/alrs/azext_alrs/server/_resolve.py new file mode 100644 index 00000000000..88178bd81ba --- /dev/null +++ b/src/alrs/azext_alrs/server/_resolve.py @@ -0,0 +1,129 @@ +"""Turn a repository/remote reference into an id. + +Users can pass either an ALRS 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 ALRS 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_alrs.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 ALRS 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 ALRS id is content-deb-release_components-. +_RELEASE_COMPONENT_ID_PREFIX = "content-deb-release_components" + + +def _looks_like_id(resource: str, value: str) -> bool: + # an ALRS 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/alrs/azext_alrs/tests/__init__.py b/src/alrs/azext_alrs/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/alrs/azext_alrs/tests/latest/__init__.py b/src/alrs/azext_alrs/tests/latest/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/alrs/azext_alrs/tests/latest/test_alrs_scenario.py b/src/alrs/azext_alrs/tests/latest/test_alrs_scenario.py new file mode 100644 index 00000000000..17cd5f9b1cd --- /dev/null +++ b/src/alrs/azext_alrs/tests/latest/test_alrs_scenario.py @@ -0,0 +1,9 @@ +from azure.cli.testsdk import ScenarioTest + + +class AlrsCommandScenarioTest(ScenarioTest): + def test_extension_command_group_loads(self) -> None: + with self.assertRaises(SystemExit) as raised: + self.cli_ctx.invoke(["alrs", "--help"]) + + self.assertEqual(raised.exception.code, 0) diff --git a/src/alrs/azext_alrs/tests/test_data_plane.py b/src/alrs/azext_alrs/tests/test_data_plane.py new file mode 100644 index 00000000000..978f6723f1e --- /dev/null +++ b/src/alrs/azext_alrs/tests/test_data_plane.py @@ -0,0 +1,453 @@ +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_alrs.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.alrs.azure.net/" + ) + + +def test_base_url_normalized(client): + assert client.base_url == "https://r.eastus.api.alrs.azure.net" + + +def test_schemeless_endpoint_gets_https_scheme(): + from azext_alrs.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.alrs.azure.net") + assert client.base_url == "https://r.eastus.api.alrs.azure.net" + assert client._api_root == "https://r.eastus.api.alrs.azure.net/api/v1" + + +def test_api_version_prefix_appended_to_bare_host(): + from azext_alrs.server import _data_plane + + client = _data_plane.DataPlaneClient( + cli_ctx=object(), base_url="https://r.eastus.api.alrs.azure.net" + ) + assert client._api_root == "https://r.eastus.api.alrs.azure.net/api/v1" + + +def test_api_version_prefix_not_doubled_when_endpoint_already_versioned(): + from azext_alrs.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.alrs.azure.net", + "ftp://r.eastus.api.alrs.azure.net", + "https://", + ], +) +def test_non_https_remote_endpoint_rejected(base_url): + from azure.cli.core.azclierror import ValidationError + + from azext_alrs.server import _data_plane + + with pytest.raises(ValidationError, match="must use HTTPS"): + _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"] + return FakeResponse() + + client._session.request = fake_request + client.get("/repositories/") + + assert sent["method"] == "GET" + assert sent["url"] == "https://r.eastus.api.alrs.azure.net/api/v1/repositories/" + assert sent["headers"]["authorization"] == "Bearer tok123" + assert "x-correlation-id" in sent["headers"] + assert "alrs-cli-version" in sent["headers"] + + +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"), + (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/", + ], +) +def test_unsafe_request_path_rejected_before_auth(monkeypatch, path): + from azure.cli.core.azclierror import ValidationError + + from azext_alrs.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.alrs.azure.net" + ) + with pytest.raises(ValidationError, match="invalid request path"): + client.delete(path) + + +def test_streaming_multipart_body_is_reiterable(tmp_path): + from azext_alrs.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_alrs.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.alrs.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"]["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_alrs.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_alrs.server._data_plane import resolve_api_endpoint + + _patch_arm( + monkeypatch, [{"properties": {"apiEndpoint": "https://r.eastus.api.alrs.azure.net"}}] + ) + endpoint = resolve_api_endpoint(_cmd(), "r", resource_group_name="rg1") + assert endpoint == "https://r.eastus.api.alrs.azure.net" + + +def test_resolve_endpoint_subscription_wide_match(monkeypatch): + from azext_alrs.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_alrs.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_alrs.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_alrs.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_alrs.server import _data_plane + + module_dir = Path(_data_plane.__file__).resolve().parent + # Parent-of-parent of .../azext_alrs/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_alrs.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_alrs.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_alrs.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_alrs.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_alrs.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_alrs.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.alrs.azure.net", + ) + cmd = mock.Mock(cli_ctx=object()) + client = _data_plane.DataPlaneClient.for_registry(cmd, "r") + assert client.base_url == "https://r.eastus.api.alrs.azure.net" + + +def test_raise_if_dev_extension_raises_for_dev_build(monkeypatch): + from azure.cli.core.azclierror import ValidationError + + from azext_alrs.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_alrs.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_alrs.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 "alrs-cli-version" in sent["headers"] diff --git a/src/alrs/azext_alrs/tests/test_data_plane_composition.py b/src/alrs/azext_alrs/tests/test_data_plane_composition.py new file mode 100644 index 00000000000..ea7703b0cb0 --- /dev/null +++ b/src/alrs/azext_alrs/tests/test_data_plane_composition.py @@ -0,0 +1,156 @@ +"""Mocked-transport coverage for command-to-data-plane composition.""" + +import pytest + +pytest.importorskip("azure.cli.core") + +import requests + +from azext_alrs.commands import package, publication, remote, repository +from azext_alrs.server import _data_plane +from azext_alrs.server._data_plane import DataPlaneClient + +API = "https://r.eastus.api.alrs.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/alrs/azext_alrs/tests/test_distro.py b/src/alrs/azext_alrs/tests/test_distro.py new file mode 100644 index 00000000000..323974d0686 --- /dev/null +++ b/src/alrs/azext_alrs/tests/test_distro.py @@ -0,0 +1,340 @@ +import pytest + +pytest.importorskip("azure.cli.core") + +from azext_alrs.commands import distro + +VERBS = ["list", "create", "show", "update", "delete"] + +# real-looking ALRS 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 "alrs distro" in helps + for verb in VERBS: + assert f"alrs distro {verb}" in helps + + +def test_command_table_registers_distro_verbs(): + from azure.cli.core.mock import DummyCli + + from azext_alrs import AlrsCommandsLoader + + loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader.load_command_table(None) + + registered = {k for k in loader.command_table if k.startswith("alrs distro")} + expected = {f"alrs 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_alrs import AlrsCommandsLoader + + loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader.skip_applicability = True + loader.load_command_table(None) + loader.load_arguments("alrs 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("alrs distro", "registry_name") + assert {"--name", "-n"} <= options("alrs distro", "distro_name") + assert "--type" in options("alrs distro create", "distro_type") + assert {"--base-path"} <= options("alrs distro create", "base_path") + assert {"--repository"} <= options("alrs distro create", "repository") + assert {"--publication"} <= options("alrs distro create", "publication") + # --repository is optional; a distribution may serve a repository, a + # publication, or neither (backing can be set later). + assert not registry["alrs distro create"]["repository"].settings.get("required") + # --type is an enum type (case-insensitive), not a raw choices= list + assert registry["alrs 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"alrs distro {verb}"].supports_no_wait + for verb in ("show", "list"): + assert not loader.command_table[f"alrs 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/alrs/azext_alrs/tests/test_extension.py b/src/alrs/azext_alrs/tests/test_extension.py new file mode 100644 index 00000000000..8d70b713a14 --- /dev/null +++ b/src/alrs/azext_alrs/tests/test_extension.py @@ -0,0 +1,73 @@ +import pytest + +pytest.importorskip("azure.cli.core") + + +def test_loader_instantiates(): + from azext_alrs import COMMAND_LOADER_CLS, AlrsCommandsLoader + + assert COMMAND_LOADER_CLS is AlrsCommandsLoader + + +def test_client_factory_placeholder_raises(monkeypatch): + from azext_alrs._client_factory import cf_alrs + from azext_alrs.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_alrs(cli_ctx=None) + + +def test_command_table_registers_registry_verbs(): + from azure.cli.core.mock import DummyCli + + from azext_alrs import AlrsCommandsLoader + + loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader.load_command_table(None) + + registered = {k for k in loader.command_table if k.startswith("alrs registry")} + expected = { + f"alrs 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_alrs` — 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_alrs import AlrsCommandsLoader + + loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader.load_command_table(None) + + data_plane = { + name: cmd + for name, cmd in loader.command_table.items() + if name.startswith("alrs ") and not name.startswith("alrs 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_alrs.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"alrs registry {verb}"] + assert cmd.command_kwargs.get("client_factory") is None diff --git a/src/alrs/azext_alrs/tests/test_package.py b/src/alrs/azext_alrs/tests/test_package.py new file mode 100644 index 00000000000..3205bb9f294 --- /dev/null +++ b/src/alrs/azext_alrs/tests/test_package.py @@ -0,0 +1,411 @@ +import pytest + +pytest.importorskip("azure.cli.core") + +from azext_alrs.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 "alrs package" in helps + assert "alrs package upload" in helps + for sub in SUBGROUPS: + assert f"alrs package {sub}" in helps + assert f"alrs package {sub} list" in helps + assert f"alrs package {sub} show" in helps + + +def test_command_table_registers_per_type_subgroups(): + from azure.cli.core.mock import DummyCli + + from azext_alrs import AlrsCommandsLoader + + loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader.load_command_table(None) + + registered = {k for k in loader.command_table if k.startswith("alrs package")} + expected = {"alrs package upload"} + for sub in SUBGROUPS: + expected.add(f"alrs package {sub} list") + expected.add(f"alrs package {sub} show") + assert registered == expected + + +def test_arguments_are_scoped_per_type(): + from azure.cli.core.mock import DummyCli + + from azext_alrs import AlrsCommandsLoader + + loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader.skip_applicability = True + loader.load_command_table(None) + loader.load_arguments("alrs package upload") + for sub in SUBGROUPS: + loader.load_arguments(f"alrs package {sub} list") + loader.load_arguments(f"alrs 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 "alrs package" scope + assert {"--registry", "-r"} <= options("alrs package", "registry_name") + assert {"--id"} <= options(f"alrs package {sub} show", "package_id") + assert {"--repository"} <= options(f"alrs package {sub} list", "repository") + assert {"--name"} <= options(f"alrs package {sub} list", "name") + assert {"--sha256"} <= options(f"alrs 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"alrs package {sub} list", "release") + # file has no --release argument at all + assert "release" not in registry.get("alrs package file list", {}) + assert {"--file"} <= options("alrs package upload", "package") + assert {"--repository"} <= options("alrs package upload", "repository") + assert {"--type", "-t"} <= options("alrs package upload", "file_type") + assert {"--relative-path"} <= options("alrs package upload", "relative_path") + assert {"--release"} <= options("alrs package upload", "release") + assert {"--component"} <= options("alrs package upload", "component") + assert loader.command_table["alrs 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/alrs/azext_alrs/tests/test_paging.py b/src/alrs/azext_alrs/tests/test_paging.py new file mode 100644 index 00000000000..273a19a688e --- /dev/null +++ b/src/alrs/azext_alrs/tests/test_paging.py @@ -0,0 +1,117 @@ +import pytest + +pytest.importorskip("azure.cli.core") + +from azure.cli.core.azclierror import InvalidArgumentValueError + +from azext_alrs.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 ALRS 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/alrs/azext_alrs/tests/test_polling.py b/src/alrs/azext_alrs/tests/test_polling.py new file mode 100644 index 00000000000..1f96659e99c --- /dev/null +++ b/src/alrs/azext_alrs/tests/test_polling.py @@ -0,0 +1,74 @@ +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_alrs.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_alrs.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_alrs.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_alrs.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_alrs.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_alrs.server._polling import wait_for_task + + resp = FakeResponse(None, content=b"") + assert wait_for_task(object(), resp) is None diff --git a/src/alrs/azext_alrs/tests/test_publication.py b/src/alrs/azext_alrs/tests/test_publication.py new file mode 100644 index 00000000000..9e7743ea215 --- /dev/null +++ b/src/alrs/azext_alrs/tests/test_publication.py @@ -0,0 +1,139 @@ +import pytest + +pytest.importorskip("azure.cli.core") + +from azext_alrs.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 "alrs publication" in helps + for verb in VERBS: + assert f"alrs publication {verb}" in helps + + +def test_command_table_registers_publication_verbs(): + from azure.cli.core.mock import DummyCli + + from azext_alrs import AlrsCommandsLoader + + loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader.load_command_table(None) + + registered = {k for k in loader.command_table if k.startswith("alrs publication")} + expected = {f"alrs 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_alrs import AlrsCommandsLoader + + loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader.skip_applicability = True + loader.load_command_table(None) + loader.load_arguments("alrs 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("alrs publication", "registry_name") + assert {"--id"} <= options("alrs publication", "publication_id") + assert {"--repository"} <= options("alrs publication list", "repository") + # --no-wait is wired on delete only. + assert loader.command_table["alrs publication delete"].supports_no_wait + for verb in ("show", "list"): + assert not loader.command_table[f"alrs 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/alrs/azext_alrs/tests/test_registry.py b/src/alrs/azext_alrs/tests/test_registry.py new file mode 100644 index 00000000000..b132801e370 --- /dev/null +++ b/src/alrs/azext_alrs/tests/test_registry.py @@ -0,0 +1,56 @@ +# mypy: ignore-errors + +import pytest + +pytest.importorskip("azure.cli.core") + + +def test_generated_registry_commands_use_the_expected_api_contract(): + from azext_alrs.aaz.latest.alrs.registry._create import Create + from azext_alrs.aaz.latest.alrs.registry._delete import Delete + from azext_alrs.aaz.latest.alrs.registry._list import List + from azext_alrs.aaz.latest.alrs.registry._show import Show + from azext_alrs.aaz.latest.alrs.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_alrs import AlrsCommandsLoader + from azext_alrs.aaz.latest.alrs.registry._create import Create + from azext_alrs.aaz.latest.alrs.registry._delete import Delete + from azext_alrs.aaz.latest.alrs.registry._list import List + from azext_alrs.aaz.latest.alrs.registry._show import Show + from azext_alrs.aaz.latest.alrs.registry._update import Update + from azext_alrs.aaz.latest.alrs.registry._wait import Wait + from azext_alrs.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(AlrsCommandsLoader(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/alrs/azext_alrs/tests/test_remote.py b/src/alrs/azext_alrs/tests/test_remote.py new file mode 100644 index 00000000000..745ff330685 --- /dev/null +++ b/src/alrs/azext_alrs/tests/test_remote.py @@ -0,0 +1,211 @@ +import pytest + +pytest.importorskip("azure.cli.core") + +from azext_alrs.commands import remote + +VERBS = ["list", "create", "show", "update", "delete"] + +# real-looking ALRS 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 "alrs remote" in helps + for verb in VERBS: + assert f"alrs remote {verb}" in helps + + +def test_command_table_registers_remote_verbs(): + from azure.cli.core.mock import DummyCli + + from azext_alrs import AlrsCommandsLoader + + loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader.load_command_table(None) + + registered = {k for k in loader.command_table if k.startswith("alrs remote")} + expected = {f"alrs 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_alrs import AlrsCommandsLoader + + loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader.skip_applicability = True + loader.load_command_table(None) + loader.load_arguments("alrs 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("alrs remote", "registry_name") + assert {"--name", "-n"} <= options("alrs remote", "remote_name") + assert "--type" in options("alrs remote create", "remote_type") + assert {"--url"} <= options("alrs remote create", "url") + assert {"--policy"} <= options("alrs remote create", "policy") + assert {"--policy"} <= options("alrs remote update", "policy") + for scope in ("alrs remote create", "alrs remote update"): + policy_help = registry[scope]["policy"].settings["help"] + for policy in ("immediate", "on-demand", "streamed"): + assert policy in policy_help + assert {"--releases"} <= options("alrs 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"alrs remote {verb}"].supports_no_wait + for verb in ("show", "list"): + assert not loader.command_table[f"alrs 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/alrs/azext_alrs/tests/test_repository.py b/src/alrs/azext_alrs/tests/test_repository.py new file mode 100644 index 00000000000..cdf42f32576 --- /dev/null +++ b/src/alrs/azext_alrs/tests/test_repository.py @@ -0,0 +1,335 @@ +import pytest + +pytest.importorskip("azure.cli.core") + +from azext_alrs.commands import repository + +VERBS = ["list", "create", "show", "update", "delete", "sync", "publish"] + +# a real-looking ALRS 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 "alrs repository" in helps + for verb in VERBS: + assert f"alrs repository {verb}" in helps + + +def test_command_table_registers_repository_verbs(): + from azure.cli.core.mock import DummyCli + + from azext_alrs import AlrsCommandsLoader + + loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader.load_command_table(None) + + registered = { + k + for k in loader.command_table + if k.startswith("alrs repository") + and not k.startswith("alrs repository package") + and not k.startswith("alrs repository release") + } + expected = {f"alrs repository {v}" for v in VERBS} + assert registered == expected + + +def test_arguments_load_and_expose_expected_options(): + # In-process stand-in for `az alrs 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_alrs import AlrsCommandsLoader + + loader = AlrsCommandsLoader(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("alrs 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("alrs repository", "registry_name") + assert {"--name", "-n"} <= options("alrs repository", "repository_name") + assert "--type" in options("alrs repository create", "repository_type") + assert {"--retain-versions"} <= options("alrs repository create", "retain_versions") + assert {"--retain-all"} <= options("alrs repository create", "retain_all") + assert {"--remote"} <= options("alrs repository update", "remote_name") + assert {"--retain-versions"} <= options("alrs repository update", "retain_versions") + assert {"--retain-all"} <= options("alrs repository update", "retain_all") + assert {"--sync-mode"} <= options("alrs repository sync", "sync_mode") + assert {"--confirm"} <= options("alrs repository sync", "confirm") + assert loader.command_table["alrs 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"alrs repository {verb}"].supports_no_wait + for verb in ("create", "show", "list"): + assert not loader.command_table[f"alrs 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/alrs/azext_alrs/tests/test_repository_package.py b/src/alrs/azext_alrs/tests/test_repository_package.py new file mode 100644 index 00000000000..8e43da351bd --- /dev/null +++ b/src/alrs/azext_alrs/tests/test_repository_package.py @@ -0,0 +1,249 @@ +import pytest + +pytest.importorskip("azure.cli.core") + +from azext_alrs.commands import repository_package + +VERBS = ["add", "remove"] + +# a real-looking ALRS 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 "alrs repository package" in helps + for verb in VERBS: + assert f"alrs repository package {verb}" in helps + + +def test_command_table_registers_verbs(): + from azure.cli.core.mock import DummyCli + + from azext_alrs import AlrsCommandsLoader + + loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader.load_command_table(None) + + registered = {k for k in loader.command_table if k.startswith("alrs repository package")} + expected = {f"alrs repository package {v}" for v in VERBS} + assert registered == expected + for verb in ("add", "remove"): + assert loader.command_table[f"alrs repository package {verb}"].supports_no_wait + + +def test_arguments_expose_plural_releases_option(): + from azure.cli.core.mock import DummyCli + + from azext_alrs import AlrsCommandsLoader + + loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader.skip_applicability = True + loader.load_command_table(None) + loader.load_arguments("alrs repository package add") + arguments = loader.argument_registry.arguments + + for verb in VERBS: + scope = f"alrs 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/alrs/azext_alrs/tests/test_repository_release.py b/src/alrs/azext_alrs/tests/test_repository_release.py new file mode 100644 index 00000000000..f51fa61892d --- /dev/null +++ b/src/alrs/azext_alrs/tests/test_repository_release.py @@ -0,0 +1,385 @@ +import pytest + +pytest.importorskip("azure.cli.core") + +from azext_alrs.commands import repository_release as rel + +VERBS = ["list", "create", "delete"] + +# real-looking ALRS 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 "alrs repository release" in helps + for verb in VERBS: + assert f"alrs repository release {verb}" in helps + + +def test_command_table_registers_release_verbs(): + from azure.cli.core.mock import DummyCli + + from azext_alrs import AlrsCommandsLoader + + loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader.load_command_table(None) + + registered = { + k + for k in loader.command_table + if k.startswith("alrs repository release ") + and not k.startswith("alrs repository release component") + } + expected = {f"alrs 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_alrs import AlrsCommandsLoader + + loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader.skip_applicability = True + loader.load_command_table(None) + loader.load_arguments("alrs repository release create") + registry = loader.argument_registry.arguments + + def options(scope, dest): + return set(registry[scope][dest].settings.get("options_list") or []) + + grp = "alrs repository release" + create = "alrs 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"alrs repository release {verb}"].supports_no_wait + assert not loader.command_table["alrs 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 "alrs repository release component" in helps + for verb in COMPONENT_VERBS: + assert f"alrs repository release component {verb}" in helps + + +def test_command_table_registers_release_component_verbs(): + from azure.cli.core.mock import DummyCli + + from azext_alrs import AlrsCommandsLoader + + loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader.load_command_table(None) + + registered = { + k for k in loader.command_table if k.startswith("alrs repository release component") + } + expected = {f"alrs 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_alrs import AlrsCommandsLoader + + loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader.skip_applicability = True + loader.load_command_table(None) + loader.load_arguments("alrs repository release component create") + registry = loader.argument_registry.arguments + + def options(scope, dest): + return set(registry[scope][dest].settings.get("options_list") or []) + + base = "alrs repository release" + grp = "alrs 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"alrs repository release component {verb}"].supports_no_wait + assert not loader.command_table["alrs 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/alrs/azext_alrs/tests/test_resolve.py b/src/alrs/azext_alrs/tests/test_resolve.py new file mode 100644 index 00000000000..5056cfe7906 --- /dev/null +++ b/src/alrs/azext_alrs/tests/test_resolve.py @@ -0,0 +1,200 @@ +import pytest + +pytest.importorskip("azure.cli.core") + +from azext_alrs.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/alrs/azext_alrs/tests/test_task.py b/src/alrs/azext_alrs/tests/test_task.py new file mode 100644 index 00000000000..307fbbc28d9 --- /dev/null +++ b/src/alrs/azext_alrs/tests/test_task.py @@ -0,0 +1,140 @@ +import pytest + +pytest.importorskip("azure.cli.core") + +from azext_alrs.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 "alrs task" in helps + for verb in VERBS: + assert f"alrs task {verb}" in helps + + +def test_command_table_registers_task_verbs(): + from azure.cli.core.mock import DummyCli + + from azext_alrs import AlrsCommandsLoader + + loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader.load_command_table(None) + + registered = {k for k in loader.command_table if k.startswith("alrs task")} + expected = {f"alrs 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_alrs import AlrsCommandsLoader + + loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader.skip_applicability = True + loader.load_command_table(None) + loader.load_arguments("alrs 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("alrs task", "registry_name") + assert {"--id"} <= options("alrs task", "task_id") + assert {"--state"} <= options("alrs task list", "state") + + loader.load_arguments("alrs task wait") + assert {"--ids"} <= options("alrs task wait", "task_ids") + assert registry["alrs 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/alrs/pyproject.toml b/src/alrs/pyproject.toml new file mode 100644 index 00000000000..dc515492df2 --- /dev/null +++ b/src/alrs/pyproject.toml @@ -0,0 +1,69 @@ +[build-system] +requires = ["setuptools>=70"] +build-backend = "setuptools.build_meta" + +[project] +name = "alrs" +version = "0.1.0" +description = "Microsoft Azure Command-Line Tools Package Registry Extension" +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/alrs" + +[tool.setuptools.dynamic] +readme = { file = ["README.md", "HISTORY.rst"], content-type = "text/markdown" } + +[tool.setuptools.packages.find] +include = ["azext_alrs*"] + +[tool.setuptools.package-data] +azext_alrs = ["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_alrs.*" +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/alrs/setup.py b/src/alrs/setup.py new file mode 100644 index 00000000000..e59d546ab2c --- /dev/null +++ b/src/alrs/setup.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python + +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from setuptools import setup + +setup() From 59c59c0bb27594f675400b9e27647f0a6389b01e Mon Sep 17 00:00:00 2001 From: Kai Daniels Date: Thu, 10 Sep 2026 18:42:05 +0000 Subject: [PATCH 02/12] tighten data-plane URL validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 08bed320-d21b-400f-a944-11d6d6129c22 --- src/alrs/azext_alrs/server/_data_plane.py | 11 +++++++++-- src/alrs/azext_alrs/tests/test_data_plane.py | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/alrs/azext_alrs/server/_data_plane.py b/src/alrs/azext_alrs/server/_data_plane.py index 261570671f4..59a73df5d2b 100644 --- a/src/alrs/azext_alrs/server/_data_plane.py +++ b/src/alrs/azext_alrs/server/_data_plane.py @@ -147,11 +147,18 @@ def __init__(self, cli_ctx: Any, base_url: str, resource: str = DATA_PLANE_RESOU base_url = base_url.rstrip("/") parsed_url = urlparse(base_url) is_local = parsed_url.hostname in _LOCAL_HOSTS - if not parsed_url.hostname or (parsed_url.scheme != "https" and not is_local): + 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 parsed_url.query or parsed_url.fragment: + raise ValidationError( + "Registry API endpoints must not include a query string or fragment." + ) 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 @@ -300,7 +307,7 @@ def _validate_request_path(path: str) -> None: has_unsafe_segment = not path.startswith("/") or any( segment in {"", ".", ".."} for segment in segments ) - has_control_character = any(ord(char) < 32 for char in path) + 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.") diff --git a/src/alrs/azext_alrs/tests/test_data_plane.py b/src/alrs/azext_alrs/tests/test_data_plane.py index 978f6723f1e..1dd6415c991 100644 --- a/src/alrs/azext_alrs/tests/test_data_plane.py +++ b/src/alrs/azext_alrs/tests/test_data_plane.py @@ -64,6 +64,7 @@ def test_api_version_prefix_not_doubled_when_endpoint_already_versioned(): [ "http://r.eastus.api.alrs.azure.net", "ftp://r.eastus.api.alrs.azure.net", + "ftp://localhost:8100", "https://", ], ) @@ -76,6 +77,22 @@ def test_non_https_remote_endpoint_rejected(base_url): _data_plane.DataPlaneClient(cli_ctx=object(), base_url=base_url) +@pytest.mark.parametrize( + "base_url", + [ + "https://r.eastus.api.alrs.azure.net?x=1", + "https://r.eastus.api.alrs.azure.net#fragment", + ], +) +def test_endpoint_query_or_fragment_rejected(base_url): + from azure.cli.core.azclierror import ValidationError + + from azext_alrs.server import _data_plane + + with pytest.raises(ValidationError, match="query string or fragment"): + _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"}) @@ -149,6 +166,7 @@ def boom(method, url, **kw): "/publications/id#fragment/", "/publications\\..\\repositories\\id/", "/publications//id/", + "/publications/\x7f/id/", ], ) def test_unsafe_request_path_rejected_before_auth(monkeypatch, path): From bc45845e6300c36f940bd1867d69dee94c9bca43 Mon Sep 17 00:00:00 2001 From: Kai Daniels Date: Thu, 10 Sep 2026 21:04:11 +0000 Subject: [PATCH 03/12] close data-plane transport gaps Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 08bed320-d21b-400f-a944-11d6d6129c22 --- src/alrs/HISTORY.rst | 3 +- src/alrs/azext_alrs/server/_data_plane.py | 34 +++++++----- src/alrs/azext_alrs/tests/test_data_plane.py | 56 +++++++++++++++++++- 3 files changed, 78 insertions(+), 15 deletions(-) diff --git a/src/alrs/HISTORY.rst b/src/alrs/HISTORY.rst index f0a79453124..1dd23b52e50 100644 --- a/src/alrs/HISTORY.rst +++ b/src/alrs/HISTORY.rst @@ -9,4 +9,5 @@ Release History * Add the initial preview command surface for registries, repositories, releases, packages, distributions, remotes, publications, and tasks. * Generate registry management commands with AAZ. -* Require HTTPS for remote data-plane endpoints and retry only read requests. +* Reject invalid data-plane endpoints and paths, disable redirects, and retry + only GET requests. diff --git a/src/alrs/azext_alrs/server/_data_plane.py b/src/alrs/azext_alrs/server/_data_plane.py index 59a73df5d2b..29d01ad3a4e 100644 --- a/src/alrs/azext_alrs/server/_data_plane.py +++ b/src/alrs/azext_alrs/server/_data_plane.py @@ -155,10 +155,14 @@ def __init__(self, cli_ctx: Any, base_url: str, resource: str = DATA_PLANE_RESOU "Registry API endpoints must use HTTPS. Only loopback development " "endpoints may use HTTP." ) - if parsed_url.query or parsed_url.fragment: + 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 @@ -213,12 +217,12 @@ def _headers(self) -> dict[str, str]: headers["authorization"] = f"Bearer {token}" return headers - def request( - self, method: str, path: str, *, retry: bool = True, **kwargs: Any - ) -> requests.Response: + 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) @@ -227,7 +231,7 @@ def request( logger.debug("%s: %s", key, kwargs[key]) try: - request = self._session.request if retry else requests.request + 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 @@ -253,7 +257,6 @@ def post_multipart( path, data=body, headers={"Content-Type": body.content_type}, - retry=False, ) def patch(self, path: str, **kwargs: Any) -> requests.Response: @@ -297,15 +300,16 @@ def is_dev_extension() -> bool: return False -def _is_local_endpoint(base_url: str) -> bool: - return urlparse(base_url).hostname in _LOCAL_HOSTS - - def _validate_request_path(path: str) -> None: - segments = path.strip("/").split("/") + 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 path.startswith("/") or any( - segment in {"", ".", ".."} for segment in segments + 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: @@ -341,6 +345,10 @@ def _acquire_token(cli_ctx: Any, resource: str) -> str: 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) diff --git a/src/alrs/azext_alrs/tests/test_data_plane.py b/src/alrs/azext_alrs/tests/test_data_plane.py index 1dd6415c991..151d11aa5d5 100644 --- a/src/alrs/azext_alrs/tests/test_data_plane.py +++ b/src/alrs/azext_alrs/tests/test_data_plane.py @@ -82,6 +82,8 @@ def test_non_https_remote_endpoint_rejected(base_url): [ "https://r.eastus.api.alrs.azure.net?x=1", "https://r.eastus.api.alrs.azure.net#fragment", + "https://r.eastus.api.alrs.azure.net?", + "https://r.eastus.api.alrs.azure.net#", ], ) def test_endpoint_query_or_fragment_rejected(base_url): @@ -93,6 +95,22 @@ def test_endpoint_query_or_fragment_rejected(base_url): _data_plane.DataPlaneClient(cli_ctx=object(), base_url=base_url) +@pytest.mark.parametrize( + "base_url", + [ + "https://r.eastus.api.alrs.azure.net/api", + "https://r.eastus.api.alrs.azure.net/other", + ], +) +def test_unexpected_endpoint_path_rejected(base_url): + from azure.cli.core.azclierror import ValidationError + + from azext_alrs.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"}) @@ -103,18 +121,49 @@ def test_request_prepends_base_url_and_attaches_auth(client): 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/") + client.get("/repositories/", allow_redirects=True) assert sent["method"] == "GET" assert sent["url"] == "https://r.eastus.api.alrs.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 "alrs-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: ( @@ -132,6 +181,7 @@ def test_correlation_id_increments_per_request(client): (401, "UnauthorizedError"), (403, "ForbiddenError"), (404, "ResourceNotFoundError"), + (307, "AzureResponseError"), (500, "AzureResponseError"), # unmapped -> generic, still traceback-free ], ) @@ -167,6 +217,9 @@ def boom(method, url, **kw): "/publications\\..\\repositories\\id/", "/publications//id/", "/publications/\x7f/id/", + "//publications/id/", + "/publications/id//", + "/", ], ) def test_unsafe_request_path_rejected_before_auth(monkeypatch, path): @@ -249,6 +302,7 @@ def test_post_multipart_sends_streaming_body_without_retry(client, monkeypatch, 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"]) From 3d3876823d470861a9456d2d9109a8375f8cfc52 Mon Sep 17 00:00:00 2001 From: Kai Daniels Date: Tue, 15 Sep 2026 20:00:40 +0000 Subject: [PATCH 04/12] rename package registry extension to apr Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 99135581-9cf9-4a99-a7b3-63692da88967 --- src/alrs/README.md | 31 ------ src/alrs/azext_alrs/__init__.py | 31 ------ src/{alrs => apr}/.gitignore | 0 src/{alrs => apr}/HISTORY.rst | 2 + src/{alrs => apr}/LICENSE | 0 src/apr/README.md | 28 +++++ src/apr/azext_apr/__init__.py | 31 ++++++ .../azext_apr}/_client_factory.py | 8 +- .../azext_apr}/aaz/__init__.py | 0 .../azext_apr}/aaz/latest/__init__.py | 0 .../azext_apr/aaz/latest/apr}/__cmd_group.py | 4 +- .../azext_apr/aaz/latest/apr}/__init__.py | 0 .../aaz/latest/apr}/registry/__cmd_group.py | 4 +- .../aaz/latest/apr}/registry/__init__.py | 0 .../aaz/latest/apr}/registry/_create.py | 4 +- .../aaz/latest/apr}/registry/_delete.py | 4 +- .../aaz/latest/apr}/registry/_list.py | 4 +- .../aaz/latest/apr}/registry/_show.py | 4 +- .../aaz/latest/apr}/registry/_update.py | 4 +- .../aaz/latest/apr}/registry/_wait.py | 4 +- .../azext_apr}/azext_metadata.json | 0 .../azext_apr}/commands/__init__.py | 2 +- .../azext_apr}/commands/distro.py | 40 +++---- .../azext_apr}/commands/package.py | 44 ++++---- .../azext_apr}/commands/publication.py | 28 ++--- .../azext_apr}/commands/remote.py | 46 ++++---- .../azext_apr}/commands/repository.py | 68 ++++++------ .../azext_apr}/commands/repository_package.py | 30 ++--- .../azext_apr}/commands/repository_release.py | 50 ++++----- .../azext_apr}/commands/task.py | 34 +++--- .../azext_apr}/server/__init__.py | 8 +- .../azext_apr}/server/_data_plane.py | 22 ++-- .../azext_apr}/server/_paging.py | 2 +- .../azext_apr}/server/_polling.py | 0 .../azext_apr}/server/_resolve.py | 12 +- .../azext_apr}/tests/__init__.py | 0 .../azext_apr}/tests/latest/__init__.py | 0 .../tests/latest/test_apr_scenario.py} | 4 +- .../azext_apr}/tests/test_data_plane.py | 104 +++++++++--------- .../tests/test_data_plane_composition.py | 8 +- .../azext_apr}/tests/test_distro.py | 42 +++---- .../azext_apr}/tests/test_extension.py | 30 ++--- .../azext_apr}/tests/test_package.py | 64 +++++------ .../azext_apr}/tests/test_paging.py | 4 +- .../azext_apr}/tests/test_polling.py | 12 +- .../azext_apr}/tests/test_publication.py | 30 ++--- .../azext_apr}/tests/test_registry.py | 28 ++--- .../azext_apr}/tests/test_remote.py | 42 +++---- .../azext_apr}/tests/test_repository.py | 54 ++++----- .../tests/test_repository_package.py | 26 ++--- .../tests/test_repository_release.py | 58 +++++----- .../azext_apr}/tests/test_resolve.py | 2 +- .../azext_apr}/tests/test_task.py | 32 +++--- src/{alrs => apr}/pyproject.toml | 10 +- src/{alrs => apr}/setup.py | 0 55 files changed, 548 insertions(+), 551 deletions(-) delete mode 100644 src/alrs/README.md delete mode 100644 src/alrs/azext_alrs/__init__.py rename src/{alrs => apr}/.gitignore (100%) rename src/{alrs => apr}/HISTORY.rst (80%) rename src/{alrs => apr}/LICENSE (100%) create mode 100644 src/apr/README.md create mode 100644 src/apr/azext_apr/__init__.py rename src/{alrs/azext_alrs => apr/azext_apr}/_client_factory.py (64%) rename src/{alrs/azext_alrs => apr/azext_apr}/aaz/__init__.py (100%) rename src/{alrs/azext_alrs => apr/azext_apr}/aaz/latest/__init__.py (100%) rename src/{alrs/azext_alrs/aaz/latest/alrs => apr/azext_apr/aaz/latest/apr}/__cmd_group.py (91%) rename src/{alrs/azext_alrs/aaz/latest/alrs => apr/azext_apr/aaz/latest/apr}/__init__.py (100%) rename src/{alrs/azext_alrs/aaz/latest/alrs => apr/azext_apr/aaz/latest/apr}/registry/__cmd_group.py (87%) rename src/{alrs/azext_alrs/aaz/latest/alrs => apr/azext_apr/aaz/latest/apr}/registry/__init__.py (100%) rename src/{alrs/azext_alrs/aaz/latest/alrs => apr/azext_apr/aaz/latest/apr}/registry/_create.py (99%) rename src/{alrs/azext_alrs/aaz/latest/alrs => apr/azext_apr/aaz/latest/apr}/registry/_delete.py (98%) rename src/{alrs/azext_alrs/aaz/latest/alrs => apr/azext_apr/aaz/latest/apr}/registry/_list.py (99%) rename src/{alrs/azext_alrs/aaz/latest/alrs => apr/azext_apr/aaz/latest/apr}/registry/_show.py (98%) rename src/{alrs/azext_alrs/aaz/latest/alrs => apr/azext_apr/aaz/latest/apr}/registry/_update.py (99%) rename src/{alrs/azext_alrs/aaz/latest/alrs => apr/azext_apr/aaz/latest/apr}/registry/_wait.py (98%) rename src/{alrs/azext_alrs => apr/azext_apr}/azext_metadata.json (100%) rename src/{alrs/azext_alrs => apr/azext_apr}/commands/__init__.py (88%) rename src/{alrs/azext_alrs => apr/azext_apr}/commands/distro.py (86%) rename src/{alrs/azext_alrs => apr/azext_apr}/commands/package.py (89%) rename src/{alrs/azext_alrs => apr/azext_apr}/commands/publication.py (74%) rename src/{alrs/azext_alrs => apr/azext_apr}/commands/remote.py (82%) rename src/{alrs/azext_alrs => apr/azext_apr}/commands/repository.py (81%) rename src/{alrs/azext_alrs => apr/azext_apr}/commands/repository_package.py (83%) rename src/{alrs/azext_alrs => apr/azext_apr}/commands/repository_release.py (83%) rename src/{alrs/azext_alrs => apr/azext_apr}/commands/task.py (78%) rename src/{alrs/azext_alrs => apr/azext_apr}/server/__init__.py (73%) rename src/{alrs/azext_alrs => apr/azext_apr}/server/_data_plane.py (96%) rename src/{alrs/azext_alrs => apr/azext_apr}/server/_paging.py (97%) rename src/{alrs/azext_alrs => apr/azext_apr}/server/_polling.py (100%) rename src/{alrs/azext_alrs => apr/azext_apr}/server/_resolve.py (92%) rename src/{alrs/azext_alrs => apr/azext_apr}/tests/__init__.py (100%) rename src/{alrs/azext_alrs => apr/azext_apr}/tests/latest/__init__.py (100%) rename src/{alrs/azext_alrs/tests/latest/test_alrs_scenario.py => apr/azext_apr/tests/latest/test_apr_scenario.py} (68%) rename src/{alrs/azext_alrs => apr/azext_apr}/tests/test_data_plane.py (83%) rename src/{alrs/azext_alrs => apr/azext_apr}/tests/test_data_plane_composition.py (95%) rename src/{alrs/azext_alrs => apr/azext_apr}/tests/test_distro.py (90%) rename src/{alrs/azext_alrs => apr/azext_apr}/tests/test_extension.py (70%) rename src/{alrs/azext_alrs => apr/azext_apr}/tests/test_package.py (86%) rename src/{alrs/azext_alrs => apr/azext_apr}/tests/test_paging.py (96%) rename src/{alrs/azext_alrs => apr/azext_apr}/tests/test_polling.py (86%) rename src/{alrs/azext_alrs => apr/azext_apr}/tests/test_publication.py (81%) rename src/{alrs/azext_alrs => apr/azext_apr}/tests/test_registry.py (62%) rename src/{alrs/azext_alrs => apr/azext_apr}/tests/test_remote.py (82%) rename src/{alrs/azext_alrs => apr/azext_apr}/tests/test_repository.py (85%) rename src/{alrs/azext_alrs => apr/azext_apr}/tests/test_repository_package.py (91%) rename src/{alrs/azext_alrs => apr/azext_apr}/tests/test_repository_release.py (87%) rename src/{alrs/azext_alrs => apr/azext_apr}/tests/test_resolve.py (99%) rename src/{alrs/azext_alrs => apr/azext_apr}/tests/test_task.py (81%) rename src/{alrs => apr}/pyproject.toml (93%) rename src/{alrs => apr}/setup.py (100%) diff --git a/src/alrs/README.md b/src/alrs/README.md deleted file mode 100644 index b1d01e774ab..00000000000 --- a/src/alrs/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# Azure Package Registry CLI extension - -This preview extension manages Azure Package Registry resources and their -repository content. - -Product and command naming are provisional while the service completes its -onboarding reviews. - -## Command groups - -- `az alrs registry` -- `az alrs repository` -- `az alrs repository release` -- `az alrs repository package` -- `az alrs package` -- `az alrs distro` -- `az alrs remote` -- `az alrs publication` -- `az alrs 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 alrs -az alrs --help -``` diff --git a/src/alrs/azext_alrs/__init__.py b/src/alrs/azext_alrs/__init__.py deleted file mode 100644 index 953eb3ee148..00000000000 --- a/src/alrs/azext_alrs/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -from azure.cli.core import AzCommandsLoader - - -class AlrsCommandsLoader(AzCommandsLoader): - def __init__(self, cli_ctx=None): - from azure.cli.core.commands import CliCommandType - - from azext_alrs._client_factory import cf_alrs - - alrs_custom = CliCommandType( - operations_tmpl="azext_alrs.commands.registry#{}", - client_factory=cf_alrs, - ) - super().__init__(cli_ctx=cli_ctx, custom_command_type=alrs_custom) - - def load_command_table(self, args): - from azure.cli.core.aaz import load_aaz_command_table - - from azext_alrs.commands import load_command_table - - load_aaz_command_table(self, "azext_alrs.aaz", args) - load_command_table(self, args) - return self.command_table - - def load_arguments(self, command): - from azext_alrs.commands import load_arguments - - load_arguments(self, command) - - -COMMAND_LOADER_CLS = AlrsCommandsLoader diff --git a/src/alrs/.gitignore b/src/apr/.gitignore similarity index 100% rename from src/alrs/.gitignore rename to src/apr/.gitignore diff --git a/src/alrs/HISTORY.rst b/src/apr/HISTORY.rst similarity index 80% rename from src/alrs/HISTORY.rst rename to src/apr/HISTORY.rst index 1dd23b52e50..7451e65abbe 100644 --- a/src/alrs/HISTORY.rst +++ b/src/apr/HISTORY.rst @@ -8,6 +8,8 @@ Release History * 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/alrs/LICENSE b/src/apr/LICENSE similarity index 100% rename from src/alrs/LICENSE rename to src/apr/LICENSE 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..984265c4433 --- /dev/null +++ b/src/apr/azext_apr/__init__.py @@ -0,0 +1,31 @@ +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/alrs/azext_alrs/_client_factory.py b/src/apr/azext_apr/_client_factory.py similarity index 64% rename from src/alrs/azext_alrs/_client_factory.py rename to src/apr/azext_apr/_client_factory.py index c5bc75bccc4..87c7101caa4 100644 --- a/src/alrs/azext_alrs/_client_factory.py +++ b/src/apr/azext_apr/_client_factory.py @@ -1,11 +1,11 @@ -def cf_alrs(cli_ctx, *_): +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 azure-mgmt-alrs client is wired up. - from azext_alrs.server import raise_if_dev_extension + # real azure-mgmt-apr client is wired up. + from azext_apr.server import raise_if_dev_extension raise_if_dev_extension() - raise NotImplementedError("azure-mgmt-alrs SDK not yet available") + raise NotImplementedError("azure-mgmt-apr SDK not yet available") diff --git a/src/alrs/azext_alrs/aaz/__init__.py b/src/apr/azext_apr/aaz/__init__.py similarity index 100% rename from src/alrs/azext_alrs/aaz/__init__.py rename to src/apr/azext_apr/aaz/__init__.py diff --git a/src/alrs/azext_alrs/aaz/latest/__init__.py b/src/apr/azext_apr/aaz/latest/__init__.py similarity index 100% rename from src/alrs/azext_alrs/aaz/latest/__init__.py rename to src/apr/azext_apr/aaz/latest/__init__.py diff --git a/src/alrs/azext_alrs/aaz/latest/alrs/__cmd_group.py b/src/apr/azext_apr/aaz/latest/apr/__cmd_group.py similarity index 91% rename from src/alrs/azext_alrs/aaz/latest/alrs/__cmd_group.py rename to src/apr/azext_apr/aaz/latest/apr/__cmd_group.py index 3fe301e7e09..fd8eb2906e0 100644 --- a/src/alrs/azext_alrs/aaz/latest/alrs/__cmd_group.py +++ b/src/apr/azext_apr/aaz/latest/apr/__cmd_group.py @@ -13,10 +13,10 @@ @register_command_group( - "alrs", + "apr", ) class __CMDGroup(AAZCommandGroup): - """Manage Alrs""" + """Manage Azure Package Registry.""" pass diff --git a/src/alrs/azext_alrs/aaz/latest/alrs/__init__.py b/src/apr/azext_apr/aaz/latest/apr/__init__.py similarity index 100% rename from src/alrs/azext_alrs/aaz/latest/alrs/__init__.py rename to src/apr/azext_apr/aaz/latest/apr/__init__.py diff --git a/src/alrs/azext_alrs/aaz/latest/alrs/registry/__cmd_group.py b/src/apr/azext_apr/aaz/latest/apr/registry/__cmd_group.py similarity index 87% rename from src/alrs/azext_alrs/aaz/latest/alrs/registry/__cmd_group.py rename to src/apr/azext_apr/aaz/latest/apr/registry/__cmd_group.py index f81ce569a77..63a94b3059e 100644 --- a/src/alrs/azext_alrs/aaz/latest/alrs/registry/__cmd_group.py +++ b/src/apr/azext_apr/aaz/latest/apr/registry/__cmd_group.py @@ -13,10 +13,10 @@ @register_command_group( - "alrs registry", + "apr registry", ) class __CMDGroup(AAZCommandGroup): - """Manage Azure Linux Repo Service (ALRS) registries.""" + """Manage Azure Package Registry registries.""" pass diff --git a/src/alrs/azext_alrs/aaz/latest/alrs/registry/__init__.py b/src/apr/azext_apr/aaz/latest/apr/registry/__init__.py similarity index 100% rename from src/alrs/azext_alrs/aaz/latest/alrs/registry/__init__.py rename to src/apr/azext_apr/aaz/latest/apr/registry/__init__.py diff --git a/src/alrs/azext_alrs/aaz/latest/alrs/registry/_create.py b/src/apr/azext_apr/aaz/latest/apr/registry/_create.py similarity index 99% rename from src/alrs/azext_alrs/aaz/latest/alrs/registry/_create.py rename to src/apr/azext_apr/aaz/latest/apr/registry/_create.py index 35b87e0ec50..d5c308504fa 100644 --- a/src/alrs/azext_alrs/aaz/latest/alrs/registry/_create.py +++ b/src/apr/azext_apr/aaz/latest/apr/registry/_create.py @@ -10,11 +10,11 @@ # flake8: noqa from azure.cli.core.aaz import * -from azext_alrs.server import raise_if_dev_extension +from azext_apr.server import raise_if_dev_extension @register_command( - "alrs registry create", + "apr registry create", ) class Create(AAZCommand): """Create a Registry""" diff --git a/src/alrs/azext_alrs/aaz/latest/alrs/registry/_delete.py b/src/apr/azext_apr/aaz/latest/apr/registry/_delete.py similarity index 98% rename from src/alrs/azext_alrs/aaz/latest/alrs/registry/_delete.py rename to src/apr/azext_apr/aaz/latest/apr/registry/_delete.py index 6265039baf9..4317d071c61 100644 --- a/src/alrs/azext_alrs/aaz/latest/alrs/registry/_delete.py +++ b/src/apr/azext_apr/aaz/latest/apr/registry/_delete.py @@ -10,11 +10,11 @@ # flake8: noqa from azure.cli.core.aaz import * -from azext_alrs.server import raise_if_dev_extension +from azext_apr.server import raise_if_dev_extension @register_command( - "alrs registry delete", + "apr registry delete", confirmation="Are you sure you want to perform this operation?", ) class Delete(AAZCommand): diff --git a/src/alrs/azext_alrs/aaz/latest/alrs/registry/_list.py b/src/apr/azext_apr/aaz/latest/apr/registry/_list.py similarity index 99% rename from src/alrs/azext_alrs/aaz/latest/alrs/registry/_list.py rename to src/apr/azext_apr/aaz/latest/apr/registry/_list.py index 42c8560790b..376fd9d58f0 100644 --- a/src/alrs/azext_alrs/aaz/latest/alrs/registry/_list.py +++ b/src/apr/azext_apr/aaz/latest/apr/registry/_list.py @@ -10,11 +10,11 @@ # flake8: noqa from azure.cli.core.aaz import * -from azext_alrs.server import raise_if_dev_extension +from azext_apr.server import raise_if_dev_extension @register_command( - "alrs registry list", + "apr registry list", ) class List(AAZCommand): """List Registry resources by resource group""" diff --git a/src/alrs/azext_alrs/aaz/latest/alrs/registry/_show.py b/src/apr/azext_apr/aaz/latest/apr/registry/_show.py similarity index 98% rename from src/alrs/azext_alrs/aaz/latest/alrs/registry/_show.py rename to src/apr/azext_apr/aaz/latest/apr/registry/_show.py index 84c9e3cdd83..2dbf2daa505 100644 --- a/src/alrs/azext_alrs/aaz/latest/alrs/registry/_show.py +++ b/src/apr/azext_apr/aaz/latest/apr/registry/_show.py @@ -10,11 +10,11 @@ # flake8: noqa from azure.cli.core.aaz import * -from azext_alrs.server import raise_if_dev_extension +from azext_apr.server import raise_if_dev_extension @register_command( - "alrs registry show", + "apr registry show", ) class Show(AAZCommand): """Get a Registry""" diff --git a/src/alrs/azext_alrs/aaz/latest/alrs/registry/_update.py b/src/apr/azext_apr/aaz/latest/apr/registry/_update.py similarity index 99% rename from src/alrs/azext_alrs/aaz/latest/alrs/registry/_update.py rename to src/apr/azext_apr/aaz/latest/apr/registry/_update.py index 12aae4f980e..212113547df 100644 --- a/src/alrs/azext_alrs/aaz/latest/alrs/registry/_update.py +++ b/src/apr/azext_apr/aaz/latest/apr/registry/_update.py @@ -10,11 +10,11 @@ # flake8: noqa from azure.cli.core.aaz import * -from azext_alrs.server import raise_if_dev_extension +from azext_apr.server import raise_if_dev_extension @register_command( - "alrs registry update", + "apr registry update", ) class Update(AAZCommand): """Update a Registry""" diff --git a/src/alrs/azext_alrs/aaz/latest/alrs/registry/_wait.py b/src/apr/azext_apr/aaz/latest/apr/registry/_wait.py similarity index 98% rename from src/alrs/azext_alrs/aaz/latest/alrs/registry/_wait.py rename to src/apr/azext_apr/aaz/latest/apr/registry/_wait.py index 1509e69f706..189035fe590 100644 --- a/src/alrs/azext_alrs/aaz/latest/alrs/registry/_wait.py +++ b/src/apr/azext_apr/aaz/latest/apr/registry/_wait.py @@ -10,11 +10,11 @@ # flake8: noqa from azure.cli.core.aaz import * -from azext_alrs.server import raise_if_dev_extension +from azext_apr.server import raise_if_dev_extension @register_command( - "alrs registry wait", + "apr registry wait", ) class Wait(AAZWaitCommand): """Place the CLI in a waiting state until a condition is met.""" diff --git a/src/alrs/azext_alrs/azext_metadata.json b/src/apr/azext_apr/azext_metadata.json similarity index 100% rename from src/alrs/azext_alrs/azext_metadata.json rename to src/apr/azext_apr/azext_metadata.json diff --git a/src/alrs/azext_alrs/commands/__init__.py b/src/apr/azext_apr/commands/__init__.py similarity index 88% rename from src/alrs/azext_alrs/commands/__init__.py rename to src/apr/azext_apr/commands/__init__.py index 406e3f6871b..c8c60b7171f 100644 --- a/src/alrs/azext_alrs/commands/__init__.py +++ b/src/apr/azext_apr/commands/__init__.py @@ -1,4 +1,4 @@ -from azext_alrs.commands import ( # registers helps on import +from azext_apr.commands import ( # registers helps on import distro, package, publication, diff --git a/src/alrs/azext_alrs/commands/distro.py b/src/apr/azext_apr/commands/distro.py similarity index 86% rename from src/alrs/azext_alrs/commands/distro.py rename to src/apr/azext_apr/commands/distro.py index 9347f29377a..208bf1bbf48 100644 --- a/src/alrs/azext_alrs/commands/distro.py +++ b/src/apr/azext_apr/commands/distro.py @@ -10,64 +10,64 @@ ) from knack.help_files import helps -from azext_alrs.server import DataPlaneClient, list_all, resolve_id_or_name, wait_for_task +from azext_apr.server import DataPlaneClient, list_all, resolve_id_or_name, wait_for_task DISTRO_TYPES = ["apt", "yum", "file"] -helps["alrs distro"] = """ +helps["apr distro"] = """ type: group short-summary: Manage distributions that serve a repository's content. """ -helps["alrs distro list"] = """ +helps["apr distro list"] = """ type: command short-summary: List distributions. examples: - name: List all distributions in a registry. - text: az alrs distro list -g myrg -r myregistry + text: az apr distro list -g myrg -r myregistry - name: List distributions serving a given repository. - text: az alrs distro list -g myrg -r myregistry --repository myrepo + text: az apr distro list -g myrg -r myregistry --repository myrepo """ -helps["alrs distro create"] = """ +helps["apr distro create"] = """ type: command short-summary: Create a distribution. examples: - name: Create an apt distribution serving a repository. text: | - az alrs distro create -g myrg -r myregistry -n mydistro \ + az apr distro create -g myrg -r myregistry -n mydistro \ --type apt --base-path dists/stable --repository myrepo """ -helps["alrs distro show"] = """ +helps["apr distro show"] = """ type: command short-summary: Show details for a distribution. examples: - name: Show a distribution by name. - text: az alrs distro show -g myrg -r myregistry -n mydistro + text: az apr distro show -g myrg -r myregistry -n mydistro """ -helps["alrs distro update"] = """ +helps["apr distro update"] = """ type: command short-summary: Update a distribution. examples: - name: Change the base path a distribution is served at. - text: az alrs distro update -g myrg -r myregistry -n mydistro --base-path dists/next + text: az apr distro update -g myrg -r myregistry -n mydistro --base-path dists/next """ -helps["alrs distro delete"] = """ +helps["apr distro delete"] = """ type: command short-summary: Delete a distribution. examples: - name: Delete a distribution by name. - text: az alrs distro delete -g myrg -r myregistry -n mydistro + text: az apr distro delete -g myrg -r myregistry -n mydistro """ def register_commands(loader): - distro_type = CliCommandType(operations_tmpl="azext_alrs.commands.distro#{}") - with loader.command_group("alrs distro", custom_command_type=distro_type, is_preview=True) as g: + 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") @@ -76,12 +76,12 @@ def register_commands(loader): def load_arguments(loader, _command): - with loader.argument_context("alrs distro") as c: + 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 ALRS registry.", + help="Name of the parent APR registry.", ) c.argument( "distro_name", @@ -89,7 +89,7 @@ def load_arguments(loader, _command): help="Name or ID of the distribution.", ) - with loader.argument_context("alrs distro create") as c: + with loader.argument_context("apr distro create") as c: c.argument( "distro_type", options_list=["--type"], @@ -97,7 +97,7 @@ def load_arguments(loader, _command): help="Distribution type.", ) - for scope in ("alrs distro create", "alrs distro update"): + for scope in ("apr distro create", "apr distro update"): with loader.argument_context(scope) as c: c.argument( "base_path", @@ -124,7 +124,7 @@ def load_arguments(loader, _command): help="Mark the distribution as hidden.", ) - with loader.argument_context("alrs distro list") as c: + with loader.argument_context("apr distro list") as c: c.ignore("distro_name") c.argument( "repository", diff --git a/src/alrs/azext_alrs/commands/package.py b/src/apr/azext_apr/commands/package.py similarity index 89% rename from src/alrs/azext_alrs/commands/package.py rename to src/apr/azext_apr/commands/package.py index 571bd443636..c4743bff02f 100644 --- a/src/alrs/azext_alrs/commands/package.py +++ b/src/apr/azext_apr/commands/package.py @@ -12,7 +12,7 @@ from knack.help_files import helps from knack.log import get_logger -from azext_alrs.server import ( +from azext_apr.server import ( DataPlaneClient, list_all, resolve_id_or_name, @@ -38,77 +38,77 @@ } -helps["alrs package"] = """ +helps["apr package"] = """ type: group short-summary: Upload and inspect packages. """ -helps["alrs package upload"] = """ +helps["apr package upload"] = """ type: command short-summary: Upload a package. examples: - name: Upload a package without adding it to a repository. text: | - az alrs package upload -g myrg -r myregistry --file ./my-package.deb + az apr package upload -g myrg -r myregistry --file ./my-package.deb - name: Upload a deb package to a repository. text: | - az alrs package upload -g myrg -r myregistry --repository myrepo \ + 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 alrs package upload -g myrg -r myregistry --repository myrepo \ + 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 alrs package upload -g myrg -r myregistry --repository myrepo \ + az apr package upload -g myrg -r myregistry --repository myrepo \ --file ./my-package.rpm - name: Upload a file package. text: | - az alrs package upload -g myrg -r myregistry --repository myrepo \ + az apr package upload -g myrg -r myregistry --repository myrepo \ --file ./settings --type file """ for _ptype, _summary in _TYPE_SUMMARY.items(): - helps[f"alrs package {_ptype}"] = f""" + helps[f"apr package {_ptype}"] = f""" type: group short-summary: Manage {_summary}. """ - helps[f"alrs package {_ptype} list"] = f""" + helps[f"apr package {_ptype} list"] = f""" type: command short-summary: List {_summary}. """ - helps[f"alrs package {_ptype} show"] = """ + 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_alrs.commands.package#{}") + package_type = CliCommandType(operations_tmpl="azext_apr.commands.package#{}") with loader.command_group( - "alrs package", custom_command_type=package_type, is_preview=True + "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"alrs package {ptype}", custom_command_type=package_type, is_preview=True + 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("alrs package") as c: + 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 ALRS registry.", + help="Name of the parent APR registry.", ) - with loader.argument_context("alrs package upload") as c: + 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", @@ -143,10 +143,10 @@ def load_arguments(loader, _command): ) for ptype in PACKAGE_TYPES: - with loader.argument_context(f"alrs package {ptype} show") as c: + 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"alrs package {ptype} list") as c: + with loader.argument_context(f"apr package {ptype} list") as c: c.argument( "repository", options_list=["--repository"], @@ -161,7 +161,7 @@ def load_arguments(loader, _command): ) for ptype in _APT_TYPES: - with loader.argument_context(f"alrs package {ptype} list") as c: + with loader.argument_context(f"apr package {ptype} list") as c: c.argument( "release", options_list=["--release"], @@ -169,7 +169,7 @@ def load_arguments(loader, _command): ) for ptype in _VERSIONED_TYPES: - with loader.argument_context(f"alrs package {ptype} list") as c: + with loader.argument_context(f"apr package {ptype} list") as c: c.argument( "version", options_list=["--version"], @@ -181,7 +181,7 @@ def load_arguments(loader, _command): help="Filter packages by architecture.", ) - with loader.argument_context("alrs package rpm list") as c: + with loader.argument_context("apr package rpm list") as c: c.argument( "release", options_list=["--release"], diff --git a/src/alrs/azext_alrs/commands/publication.py b/src/apr/azext_apr/commands/publication.py similarity index 74% rename from src/alrs/azext_alrs/commands/publication.py rename to src/apr/azext_apr/commands/publication.py index 3c4efb863ae..5f13c0ff055 100644 --- a/src/alrs/azext_alrs/commands/publication.py +++ b/src/apr/azext_apr/commands/publication.py @@ -2,44 +2,44 @@ from azure.cli.core.commands.parameters import resource_group_name_type from knack.help_files import helps -from azext_alrs.server import DataPlaneClient, list_all, resolve_id_or_name, wait_for_task +from azext_apr.server import DataPlaneClient, list_all, resolve_id_or_name, wait_for_task -helps["alrs publication"] = """ +helps["apr publication"] = """ type: group short-summary: View and delete publications. """ -helps["alrs publication list"] = """ +helps["apr publication list"] = """ type: command short-summary: List publications. examples: - name: List all publications in a registry. - text: az alrs publication list -g myrg -r myregistry + text: az apr publication list -g myrg -r myregistry - name: List publications for a given repository. - text: az alrs publication list -g myrg -r myregistry --repository myrepo + text: az apr publication list -g myrg -r myregistry --repository myrepo """ -helps["alrs publication show"] = """ +helps["apr publication show"] = """ type: command short-summary: Show details for a publication. examples: - name: Show a publication by id. - text: az alrs publication show -g myrg -r myregistry --id mypublicationid + text: az apr publication show -g myrg -r myregistry --id mypublicationid """ -helps["alrs publication delete"] = """ +helps["apr publication delete"] = """ type: command short-summary: Delete a publication. examples: - name: Delete a publication by id. - text: az alrs publication delete -g myrg -r myregistry --id mypublicationid + text: az apr publication delete -g myrg -r myregistry --id mypublicationid """ def register_commands(loader): - publication_type = CliCommandType(operations_tmpl="azext_alrs.commands.publication#{}") + publication_type = CliCommandType(operations_tmpl="azext_apr.commands.publication#{}") with loader.command_group( - "alrs publication", custom_command_type=publication_type, is_preview=True + "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") @@ -47,17 +47,17 @@ def register_commands(loader): def load_arguments(loader, _command): - with loader.argument_context("alrs publication") as c: + 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 ALRS registry.", + 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("alrs publication list") as c: + with loader.argument_context("apr publication list") as c: c.ignore("publication_id") c.argument( "repository", diff --git a/src/alrs/azext_alrs/commands/remote.py b/src/apr/azext_apr/commands/remote.py similarity index 82% rename from src/alrs/azext_alrs/commands/remote.py rename to src/apr/azext_apr/commands/remote.py index 3972484ee7d..b90e8766de9 100644 --- a/src/alrs/azext_alrs/commands/remote.py +++ b/src/apr/azext_apr/commands/remote.py @@ -6,70 +6,70 @@ ) from knack.help_files import helps -from azext_alrs.server import DataPlaneClient, list_all, resolve_id_or_name, wait_for_task +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["alrs remote"] = """ +helps["apr remote"] = """ type: group short-summary: Manage upstream remotes that repositories sync from. """ -helps["alrs remote list"] = """ +helps["apr remote list"] = """ type: command short-summary: List remotes. examples: - name: List all remotes in a registry. - text: az alrs remote list -g myrg -r myregistry + text: az apr remote list -g myrg -r myregistry """ -helps["alrs remote create"] = """ +helps["apr remote create"] = """ type: command short-summary: Create a remote. examples: - name: Create an apt remote syncing selected distributions and components. text: | - az alrs remote create -g myrg -r myregistry -n myremote --type apt \ + 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 alrs remote create -g myrg -r myregistry -n myremote --type yum \ + az apr remote create -g myrg -r myregistry -n myremote --type yum \ --url https://packages.microsoft.com/rhel/9/prod --policy on-demand """ -helps["alrs remote show"] = """ +helps["apr remote show"] = """ type: command short-summary: Show details for a remote. examples: - name: Show a remote by name. - text: az alrs remote show -g myrg -r myregistry -n myremote + text: az apr remote show -g myrg -r myregistry -n myremote """ -helps["alrs remote update"] = """ +helps["apr remote update"] = """ type: command short-summary: Update a remote. examples: - name: Change the upstream URL a remote syncs from. - text: az alrs remote update -g myrg -r myregistry -n myremote --url http://archive.ubuntu.com/ubuntu + 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 alrs remote update -g myrg -r myregistry -n myremote --policy on-demand + text: az apr remote update -g myrg -r myregistry -n myremote --policy on-demand """ -helps["alrs remote delete"] = """ +helps["apr remote delete"] = """ type: command short-summary: Delete a remote. examples: - name: Delete a remote by name. - text: az alrs remote delete -g myrg -r myregistry -n myremote + text: az apr remote delete -g myrg -r myregistry -n myremote """ def register_commands(loader): - remote_type = CliCommandType(operations_tmpl="azext_alrs.commands.remote#{}") - with loader.command_group("alrs remote", custom_command_type=remote_type, is_preview=True) as g: + 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") @@ -78,12 +78,12 @@ def register_commands(loader): def load_arguments(loader, _command): - with loader.argument_context("alrs remote") as c: + 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 ALRS registry.", + help="Name of the parent APR registry.", ) c.argument( "remote_name", @@ -91,7 +91,7 @@ def load_arguments(loader, _command): help="Name or ID of the remote.", ) - with loader.argument_context("alrs remote create") as c: + with loader.argument_context("apr remote create") as c: c.argument( "remote_type", options_list=["--type"], @@ -100,10 +100,10 @@ def load_arguments(loader, _command): ) c.argument("url", options_list=["--url"], help="Upstream URL to sync from.") - with loader.argument_context("alrs remote update") as c: + with loader.argument_context("apr remote update") as c: c.argument("url", options_list=["--url"], help="Upstream URL to sync from.") - for scope in ("alrs remote create", "alrs remote update"): + for scope in ("apr remote create", "apr remote update"): with loader.argument_context(scope) as c: c.argument( "policy", @@ -117,7 +117,7 @@ def load_arguments(loader, _command): ) # Comma-separated apt release lists. These have no analog for yum remotes. - for scope in ("alrs remote create", "alrs remote update"): + for scope in ("apr remote create", "apr remote update"): with loader.argument_context(scope) as c: c.argument( "releases", @@ -135,7 +135,7 @@ def load_arguments(loader, _command): help="Comma-separated architectures to sync (apt remotes only).", ) - with loader.argument_context("alrs remote list") as c: + with loader.argument_context("apr remote list") as c: c.ignore("remote_name") c.argument( "limit", diff --git a/src/alrs/azext_alrs/commands/repository.py b/src/apr/azext_apr/commands/repository.py similarity index 81% rename from src/alrs/azext_alrs/commands/repository.py rename to src/apr/azext_apr/commands/repository.py index e52fcf52f9c..945d8d780ee 100644 --- a/src/alrs/azext_alrs/commands/repository.py +++ b/src/apr/azext_apr/commands/repository.py @@ -11,7 +11,7 @@ from knack.log import get_logger from knack.prompting import NoTTYException, prompt_y_n -from azext_alrs.server import DataPlaneClient, list_all, resolve_id_or_name, wait_for_task +from azext_apr.server import DataPlaneClient, list_all, resolve_id_or_name, wait_for_task logger = get_logger(__name__) @@ -19,81 +19,81 @@ SYNC_MODES = ["additive", "mirror"] -helps["alrs repository"] = """ +helps["apr repository"] = """ type: group -short-summary: Manage repositories within an ALRS registry. +short-summary: Manage repositories within an APR registry. """ -helps["alrs repository list"] = """ +helps["apr repository list"] = """ type: command short-summary: List repositories in a registry. examples: - name: List all repositories in a registry. - text: az alrs repository list -g myrg -r myregistry + text: az apr repository list -g myrg -r myregistry """ -helps["alrs repository create"] = """ +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 alrs repository create -g myrg -r myregistry -n myrepo --type apt + 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 alrs repository create -g myrg -r myregistry -n myrepo --type apt --retain-versions 10 + 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 alrs repository create -g myrg -r myregistry -n myrepo --type apt --retain-all + text: az apr repository create -g myrg -r myregistry -n myrepo --type apt --retain-all """ -helps["alrs repository show"] = """ +helps["apr repository show"] = """ type: command short-summary: Show repository details. examples: - name: Show a repository by name. - text: az alrs repository show -g myrg -r myregistry -n myrepo + text: az apr repository show -g myrg -r myregistry -n myrepo """ -helps["alrs repository update"] = """ +helps["apr repository update"] = """ type: command short-summary: Update repository configuration. examples: - name: Bind a repository to a remote it syncs from. - text: az alrs repository update -g myrg -r myregistry -n myrepo --remote myremote + text: az apr repository update -g myrg -r myregistry -n myrepo --remote myremote - name: Unset the remote. - text: az alrs repository update -g myrg -r myregistry -n myrepo --remote "" + text: az apr repository update -g myrg -r myregistry -n myrepo --remote "" - name: Change how many versions the repository keeps. - text: az alrs repository update -g myrg -r myregistry -n myrepo --retain-versions 3 + text: az apr repository update -g myrg -r myregistry -n myrepo --retain-versions 3 - name: Keep every version. - text: az alrs repository update -g myrg -r myregistry -n myrepo --retain-all + text: az apr repository update -g myrg -r myregistry -n myrepo --retain-all """ -helps["alrs repository delete"] = """ +helps["apr repository delete"] = """ type: command short-summary: Delete a repository and its contents. examples: - name: Delete a repository by name. - text: az alrs repository delete -g myrg -r myregistry -n myrepo + text: az apr repository delete -g myrg -r myregistry -n myrepo """ -helps["alrs repository sync"] = """ +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 alrs repository sync -g myrg -r myregistry -n myrepo + text: az apr repository sync -g myrg -r myregistry -n myrepo - name: Add upstream content without removing existing packages. - text: az alrs repository sync -g myrg -r myregistry -n myrepo --sync-mode additive + 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 alrs repository sync -g myrg -r myregistry -n myrepo --sync-mode mirror + text: az apr repository sync -g myrg -r myregistry -n myrepo --sync-mode mirror """ -helps["alrs repository publish"] = """ +helps["apr repository publish"] = """ type: command short-summary: Publish repository metadata (regenerate, sign, push to CDN). examples: - name: Publish a repository. - text: az alrs repository publish -g myrg -r myregistry -n myrepo + text: az apr repository publish -g myrg -r myregistry -n myrepo - name: Publish even when no changes are detected. - text: az alrs repository publish -g myrg -r myregistry -n myrepo --force + text: az apr repository publish -g myrg -r myregistry -n myrepo --force """ @@ -111,9 +111,9 @@ def _confirm_mirror_sync(command_args): def register_commands(loader): - repository_type = CliCommandType(operations_tmpl="azext_alrs.commands.repository#{}") + repository_type = CliCommandType(operations_tmpl="azext_apr.commands.repository#{}") with loader.command_group( - "alrs repository", custom_command_type=repository_type, is_preview=True + "apr repository", custom_command_type=repository_type, is_preview=True ) as g: g.custom_command("list", "list_repositories") g.custom_command("create", "create_repository") @@ -130,12 +130,12 @@ def register_commands(loader): def load_arguments(loader, _command): - with loader.argument_context("alrs repository") as c: + 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 ALRS registry.", + help="Name of the parent APR registry.", ) c.argument( "repository_name", @@ -143,7 +143,7 @@ def load_arguments(loader, _command): help="Name or ID of the repository.", ) - with loader.argument_context("alrs repository create") as c: + with loader.argument_context("apr repository create") as c: c.argument( "repository_type", options_list=["--type"], @@ -163,7 +163,7 @@ def load_arguments(loader, _command): help="Keep every repository version. Mutually exclusive with --retain-versions.", ) - with loader.argument_context("alrs repository update") as c: + with loader.argument_context("apr repository update") as c: c.argument( "remote_name", options_list=["--remote"], @@ -185,7 +185,7 @@ def load_arguments(loader, _command): help="Keep every repository version. Mutually exclusive with --retain-versions.", ) - with loader.argument_context("alrs repository list") as c: + with loader.argument_context("apr repository list") as c: c.ignore("repository_name") c.argument( "limit", @@ -193,7 +193,7 @@ def load_arguments(loader, _command): help="Max number of repositories to return. Omit to return all.", ) - with loader.argument_context("alrs repository publish") as c: + with loader.argument_context("apr repository publish") as c: c.argument( "force", options_list=["--force"], @@ -201,7 +201,7 @@ def load_arguments(loader, _command): help="Publish even if no changes are detected.", ) - with loader.argument_context("alrs repository sync") as c: + with loader.argument_context("apr repository sync") as c: c.argument( "sync_mode", options_list=["--sync-mode"], diff --git a/src/alrs/azext_alrs/commands/repository_package.py b/src/apr/azext_apr/commands/repository_package.py similarity index 83% rename from src/alrs/azext_alrs/commands/repository_package.py rename to src/apr/azext_apr/commands/repository_package.py index c9bca3f4ea2..d0592fb8bd6 100644 --- a/src/alrs/azext_alrs/commands/repository_package.py +++ b/src/apr/azext_apr/commands/repository_package.py @@ -3,7 +3,7 @@ from azure.cli.core.commands.parameters import resource_group_name_type from knack.help_files import helps -from azext_alrs.server import ( +from azext_apr.server import ( DataPlaneClient, resolve_id_or_name, resolve_release, @@ -11,62 +11,62 @@ wait_for_task, ) -helps["alrs repository package"] = """ +helps["apr repository package"] = """ type: group short-summary: Manage the packages contained in a repository. """ -helps["alrs repository package add"] = """ +helps["apr repository package add"] = """ type: command short-summary: Add packages to a repository. examples: - name: Add packages to a repository. - text: az alrs repository package add -g myrg -r myregistry -n myrepo --packages pkg1,pkg2 + 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 alrs repository package add -g myrg -r myregistry -n myrepo \ + 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 alrs repository package add -g myrg -r myregistry -n myrepo \ + 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 alrs repository package add -g myrg -r myregistry -n myrepo \ + az apr repository package add -g myrg -r myregistry -n myrepo \ --packages pkg1 --releases jammy --component contrib """ -helps["alrs repository package remove"] = """ +helps["apr repository package remove"] = """ type: command short-summary: Remove packages from a repository. examples: - name: Remove packages from a repository. - text: az alrs repository package remove -g myrg -r myregistry -n myrepo --packages pkg1,pkg2 + 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 alrs repository package remove -g myrg -r myregistry -n myrepo \ + 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_alrs.commands.repository_package#{}" + operations_tmpl="azext_apr.commands.repository_package#{}" ) with loader.command_group( - "alrs repository package", custom_command_type=repository_package_type, is_preview=True + "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("alrs repository package") as c: + 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 ALRS registry.", + help="Name of the parent APR registry.", ) c.argument( "repository_name", @@ -74,7 +74,7 @@ def load_arguments(loader, _command): help="Name or ID of the repository.", ) - for scope in ("alrs repository package add", "alrs repository package remove"): + for scope in ("apr repository package add", "apr repository package remove"): with loader.argument_context(scope) as c: c.argument( "packages", diff --git a/src/alrs/azext_alrs/commands/repository_release.py b/src/apr/azext_apr/commands/repository_release.py similarity index 83% rename from src/alrs/azext_alrs/commands/repository_release.py rename to src/apr/azext_apr/commands/repository_release.py index f3966c4ea4e..deb93b36f98 100644 --- a/src/alrs/azext_alrs/commands/repository_release.py +++ b/src/apr/azext_apr/commands/repository_release.py @@ -2,7 +2,7 @@ from azure.cli.core.commands.parameters import resource_group_name_type from knack.help_files import helps -from azext_alrs.server import ( +from azext_apr.server import ( DataPlaneClient, list_all, resolve_id_or_name, @@ -11,89 +11,89 @@ wait_for_task, ) -helps["alrs repository release"] = """ +helps["apr repository release"] = """ type: group short-summary: Manage the apt releases (dists) of a repository. """ -helps["alrs repository release list"] = """ +helps["apr repository release list"] = """ type: command short-summary: List releases. examples: - name: List all releases in a repository. - text: az alrs repository release list -g myrg -r myregistry --repository myrepo + text: az apr repository release list -g myrg -r myregistry --repository myrepo """ -helps["alrs repository release create"] = """ +helps["apr repository release create"] = """ type: command short-summary: Create a release. examples: - name: Create a release with default components and architectures. text: | - az alrs repository release create -g myrg -r myregistry --repository myrepo \ + az apr repository release create -g myrg -r myregistry --repository myrepo \ -n jammy - name: Create a release with explicit components and architectures. text: | - az alrs repository release create -g myrg -r myregistry --repository myrepo \ + az apr repository release create -g myrg -r myregistry --repository myrepo \ -n jammy --suite stable --components main,contrib --architectures amd64,arm64 """ -helps["alrs repository release delete"] = """ +helps["apr repository release delete"] = """ type: command short-summary: Delete a release. examples: - name: Delete a release by name. - text: az alrs repository release delete -g myrg -r myregistry --repository myrepo -n jammy + text: az apr repository release delete -g myrg -r myregistry --repository myrepo -n jammy """ -helps["alrs repository release component"] = """ +helps["apr repository release component"] = """ type: group short-summary: Manage the components of an apt release. """ -helps["alrs repository release component list"] = """ +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 alrs repository release component list -g myrg -r myregistry --repository myrepo \ + az apr repository release component list -g myrg -r myregistry --repository myrepo \ --release jammy """ -helps["alrs repository release component create"] = """ +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 alrs repository release component create -g myrg -r myregistry --repository myrepo \ + az apr repository release component create -g myrg -r myregistry --repository myrepo \ --release jammy -n contrib """ -helps["alrs repository release component delete"] = """ +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 alrs repository release component delete -g myrg -r myregistry --repository myrepo \ + 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_alrs.commands.repository_release#{}" + operations_tmpl="azext_apr.commands.repository_release#{}" ) with loader.command_group( - "alrs repository release", custom_command_type=repository_release_type, is_preview=True + "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( - "alrs repository release component", + "apr repository release component", custom_command_type=repository_release_type, is_preview=True, ) as g: @@ -105,12 +105,12 @@ def register_commands(loader): def load_arguments(loader, _command): - with loader.argument_context("alrs repository release") as c: + 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 ALRS registry.", + help="Name of the parent APR registry.", ) c.argument( "repository", @@ -123,7 +123,7 @@ def load_arguments(loader, _command): help="Name or ID of the release.", ) - with loader.argument_context("alrs repository release create") as c: + with loader.argument_context("apr repository release create") as c: c.argument( "release_name", options_list=["--name", "-n"], @@ -150,7 +150,7 @@ def load_arguments(loader, _command): help="Comma-separated list of architectures. Defaults to 'amd64,arm64,armhf'.", ) - with loader.argument_context("alrs repository release list") as c: + with loader.argument_context("apr repository release list") as c: c.argument( "release_name", options_list=["--name", "-n"], @@ -162,7 +162,7 @@ def load_arguments(loader, _command): help="Max number of releases to return. Omit to return all.", ) - with loader.argument_context("alrs repository release component") as c: + with loader.argument_context("apr repository release component") as c: c.argument( "release", options_list=["--release"], @@ -174,7 +174,7 @@ def load_arguments(loader, _command): help="Name of the release component (e.g. main, contrib).", ) - with loader.argument_context("alrs repository release component list") as c: + with loader.argument_context("apr repository release component list") as c: c.argument( "limit", type=int, diff --git a/src/alrs/azext_alrs/commands/task.py b/src/apr/azext_apr/commands/task.py similarity index 78% rename from src/alrs/azext_alrs/commands/task.py rename to src/apr/azext_apr/commands/task.py index a8020e600d9..b2d6c76dbe6 100644 --- a/src/alrs/azext_alrs/commands/task.py +++ b/src/apr/azext_apr/commands/task.py @@ -6,54 +6,54 @@ ) from knack.help_files import helps -from azext_alrs.server import DataPlaneClient, list_all, poll_task +from azext_apr.server import DataPlaneClient, list_all, poll_task TASK_STATES = ["completed", "failed", "running", "waiting", "canceled", "canceling", "skipped"] -helps["alrs task"] = """ +helps["apr task"] = """ type: group short-summary: View and manage background tasks. """ -helps["alrs task list"] = """ +helps["apr task list"] = """ type: command short-summary: List tasks. examples: - name: List all tasks in a registry. - text: az alrs task list -g myrg -r myregistry + text: az apr task list -g myrg -r myregistry - name: List only failed tasks. - text: az alrs task list -g myrg -r myregistry --state failed + text: az apr task list -g myrg -r myregistry --state failed """ -helps["alrs task show"] = """ +helps["apr task show"] = """ type: command short-summary: Show details for a particular task. examples: - name: Show a task by id. - text: az alrs task show -g myrg -r myregistry --id mytaskid + text: az apr task show -g myrg -r myregistry --id mytaskid """ -helps["alrs task wait"] = """ +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 alrs task wait -g myrg -r myregistry --ids task1,task2 + text: az apr task wait -g myrg -r myregistry --ids task1,task2 """ -helps["alrs task cancel"] = """ +helps["apr task cancel"] = """ type: command short-summary: Cancel a task. examples: - name: Cancel a running task by id. - text: az alrs task cancel -g myrg -r myregistry --id mytaskid + text: az apr task cancel -g myrg -r myregistry --id mytaskid """ def register_commands(loader): - task_type = CliCommandType(operations_tmpl="azext_alrs.commands.task#{}") - with loader.command_group("alrs task", custom_command_type=task_type, is_preview=True) as g: + 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") @@ -61,17 +61,17 @@ def register_commands(loader): def load_arguments(loader, _command): - with loader.argument_context("alrs task") as c: + 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 ALRS registry.", + 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("alrs task list") as c: + with loader.argument_context("apr task list") as c: c.ignore("task_id") c.argument( "state", @@ -85,7 +85,7 @@ def load_arguments(loader, _command): help="Max number of tasks to return. Omit to return all.", ) - with loader.argument_context("alrs task wait") as c: + with loader.argument_context("apr task wait") as c: c.ignore("task_id") c.argument( "task_ids", diff --git a/src/alrs/azext_alrs/server/__init__.py b/src/apr/azext_apr/server/__init__.py similarity index 73% rename from src/alrs/azext_alrs/server/__init__.py rename to src/apr/azext_apr/server/__init__.py index a55385af54b..c55a2a7e03f 100644 --- a/src/alrs/azext_alrs/server/__init__.py +++ b/src/apr/azext_apr/server/__init__.py @@ -1,14 +1,14 @@ """Data-plane server communication: HTTP client, polling, uploads.""" -from azext_alrs.server._data_plane import ( +from azext_apr.server._data_plane import ( DataPlaneClient, is_dev_extension, raise_if_dev_extension, resolve_api_endpoint, ) -from azext_alrs.server._paging import iter_results, list_all -from azext_alrs.server._polling import poll_task, wait_for_task -from azext_alrs.server._resolve import ( +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, diff --git a/src/alrs/azext_alrs/server/_data_plane.py b/src/apr/azext_apr/server/_data_plane.py similarity index 96% rename from src/alrs/azext_alrs/server/_data_plane.py rename to src/apr/azext_apr/server/_data_plane.py index 29d01ad3a4e..06e6ecf0828 100644 --- a/src/alrs/azext_alrs/server/_data_plane.py +++ b/src/apr/azext_apr/server/_data_plane.py @@ -1,4 +1,4 @@ -"""Shared HTTP client for the ALRS data plane. +"""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. @@ -33,7 +33,7 @@ logger = get_logger(__name__) # The service team must confirm the final Entra audience before publication. -DATA_PLANE_RESOURCE = "https://api.alrs.azure.net" +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; @@ -50,7 +50,7 @@ # 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 = "alrs" +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 @@ -99,7 +99,7 @@ def __iter__(self) -> Iterator[bytes]: def _cli_version() -> str: try: - return version("alrs") + return version("apr") except PackageNotFoundError: return "0.0.0" @@ -194,7 +194,7 @@ def for_registry( # / DEV_LOCAL_ENDPOINT). An installed build always resolves via ARM. if is_dev_extension(): logger.warning( - "Dev build detected (azdev); targeting local ALRS server %s and " + "Dev build detected (azdev); targeting local APR server %s and " "skipping ARM registry lookup for '%s'.", DEV_LOCAL_ENDPOINT, registry_name, @@ -209,7 +209,7 @@ def _headers(self) -> dict[str, str]: self._cid = format(int(self._cid, 16) + 1, "x") headers = { "x-correlation-id": self._cid, - "alrs-cli-version": _cli_version(), + "apr-cli-version": _cli_version(), } # A local development server has no Entra app, so skip auth for localhost. if not self._local: @@ -319,15 +319,15 @@ def _validate_request_path(path: str) -> None: def raise_if_dev_extension() -> None: """Refuse a control-plane (ARM) command when running as a dev build. - Control-plane verbs (``az alrs registry ...``) manage the Azure resource via + 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 alrs registry ...) are not available in a " - "local dev build of this extension. A dev build targets the local ALRS " + "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, " @@ -370,7 +370,7 @@ def resolve_api_endpoint( 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-alrs SDK ships. + ARM (``send_raw_request``) until the azure-mgmt-apr SDK ships. """ sub = get_subscription_id(cmd.cli_ctx) if resource_group_name: @@ -403,7 +403,7 @@ def _find_registry_in_subscription(cli_ctx: Any, sub: str, registry_name: str) - if not matches: raise ResourceNotFoundError( f"Registry '{registry_name}' not found in subscription {sub}. " - "Check 'az alrs registry list'." + "Check 'az apr registry list'." ) if len(matches) > 1: # ARM names are unique per resource group, not per subscription, so a bare diff --git a/src/alrs/azext_alrs/server/_paging.py b/src/apr/azext_apr/server/_paging.py similarity index 97% rename from src/alrs/azext_alrs/server/_paging.py rename to src/apr/azext_apr/server/_paging.py index 3490837ae5b..8ed250a51d7 100644 --- a/src/alrs/azext_alrs/server/_paging.py +++ b/src/apr/azext_apr/server/_paging.py @@ -1,7 +1,7 @@ """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. ALRS strips Pulp's ``next``/``previous`` links +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. diff --git a/src/alrs/azext_alrs/server/_polling.py b/src/apr/azext_apr/server/_polling.py similarity index 100% rename from src/alrs/azext_alrs/server/_polling.py rename to src/apr/azext_apr/server/_polling.py diff --git a/src/alrs/azext_alrs/server/_resolve.py b/src/apr/azext_apr/server/_resolve.py similarity index 92% rename from src/alrs/azext_alrs/server/_resolve.py rename to src/apr/azext_apr/server/_resolve.py index 88178bd81ba..40d8edc0916 100644 --- a/src/alrs/azext_alrs/server/_resolve.py +++ b/src/apr/azext_apr/server/_resolve.py @@ -1,10 +1,10 @@ """Turn a repository/remote reference into an id. -Users can pass either an ALRS resource id or a plain name. If it's a name, we +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 ALRS data-plane's own single-segment ids (they slot +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. @@ -15,22 +15,22 @@ from azure.cli.core.azclierror import RequiredArgumentMissingError, ResourceNotFoundError -from azext_alrs.server._paging import iter_results +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 ALRS id is content-deb-releases-. +# /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 ALRS id is content-deb-release_components-. +# 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 ALRS resource id is a single path segment ending in a uuid, optionally + # 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-") diff --git a/src/alrs/azext_alrs/tests/__init__.py b/src/apr/azext_apr/tests/__init__.py similarity index 100% rename from src/alrs/azext_alrs/tests/__init__.py rename to src/apr/azext_apr/tests/__init__.py diff --git a/src/alrs/azext_alrs/tests/latest/__init__.py b/src/apr/azext_apr/tests/latest/__init__.py similarity index 100% rename from src/alrs/azext_alrs/tests/latest/__init__.py rename to src/apr/azext_apr/tests/latest/__init__.py diff --git a/src/alrs/azext_alrs/tests/latest/test_alrs_scenario.py b/src/apr/azext_apr/tests/latest/test_apr_scenario.py similarity index 68% rename from src/alrs/azext_alrs/tests/latest/test_alrs_scenario.py rename to src/apr/azext_apr/tests/latest/test_apr_scenario.py index 17cd5f9b1cd..80c26c1092e 100644 --- a/src/alrs/azext_alrs/tests/latest/test_alrs_scenario.py +++ b/src/apr/azext_apr/tests/latest/test_apr_scenario.py @@ -1,9 +1,9 @@ from azure.cli.testsdk import ScenarioTest -class AlrsCommandScenarioTest(ScenarioTest): +class AprCommandScenarioTest(ScenarioTest): def test_extension_command_group_loads(self) -> None: with self.assertRaises(SystemExit) as raised: - self.cli_ctx.invoke(["alrs", "--help"]) + self.cli_ctx.invoke(["apr", "--help"]) self.assertEqual(raised.exception.code, 0) diff --git a/src/alrs/azext_alrs/tests/test_data_plane.py b/src/apr/azext_apr/tests/test_data_plane.py similarity index 83% rename from src/alrs/azext_alrs/tests/test_data_plane.py rename to src/apr/azext_apr/tests/test_data_plane.py index 151d11aa5d5..94bf8b32d52 100644 --- a/src/alrs/azext_alrs/tests/test_data_plane.py +++ b/src/apr/azext_apr/tests/test_data_plane.py @@ -17,41 +17,41 @@ def json(self): @pytest.fixture def client(monkeypatch): - from azext_alrs.server import _data_plane + 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.alrs.azure.net/" + 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.alrs.azure.net" + assert client.base_url == "https://r.eastus.api.apr.azure.net" def test_schemeless_endpoint_gets_https_scheme(): - from azext_alrs.server import _data_plane + 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.alrs.azure.net") - assert client.base_url == "https://r.eastus.api.alrs.azure.net" - assert client._api_root == "https://r.eastus.api.alrs.azure.net/api/v1" + 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_alrs.server import _data_plane + from azext_apr.server import _data_plane client = _data_plane.DataPlaneClient( - cli_ctx=object(), base_url="https://r.eastus.api.alrs.azure.net" + cli_ctx=object(), base_url="https://r.eastus.api.apr.azure.net" ) - assert client._api_root == "https://r.eastus.api.alrs.azure.net/api/v1" + 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_alrs.server import _data_plane + 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/...'. @@ -62,8 +62,8 @@ def test_api_version_prefix_not_doubled_when_endpoint_already_versioned(): @pytest.mark.parametrize( "base_url", [ - "http://r.eastus.api.alrs.azure.net", - "ftp://r.eastus.api.alrs.azure.net", + "http://r.eastus.api.apr.azure.net", + "ftp://r.eastus.api.apr.azure.net", "ftp://localhost:8100", "https://", ], @@ -71,7 +71,7 @@ def test_api_version_prefix_not_doubled_when_endpoint_already_versioned(): def test_non_https_remote_endpoint_rejected(base_url): from azure.cli.core.azclierror import ValidationError - from azext_alrs.server import _data_plane + 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) @@ -80,16 +80,16 @@ def test_non_https_remote_endpoint_rejected(base_url): @pytest.mark.parametrize( "base_url", [ - "https://r.eastus.api.alrs.azure.net?x=1", - "https://r.eastus.api.alrs.azure.net#fragment", - "https://r.eastus.api.alrs.azure.net?", - "https://r.eastus.api.alrs.azure.net#", + "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_alrs.server import _data_plane + 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) @@ -98,14 +98,14 @@ def test_endpoint_query_or_fragment_rejected(base_url): @pytest.mark.parametrize( "base_url", [ - "https://r.eastus.api.alrs.azure.net/api", - "https://r.eastus.api.alrs.azure.net/other", + "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_alrs.server import _data_plane + 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) @@ -128,11 +128,11 @@ def fake_request(method, url, **kwargs): client.get("/repositories/", allow_redirects=True) assert sent["method"] == "GET" - assert sent["url"] == "https://r.eastus.api.alrs.azure.net/api/v1/repositories/" + 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 "alrs-cli-version" in sent["headers"] + assert "apr-cli-version" in sent["headers"] @pytest.mark.parametrize( @@ -225,7 +225,7 @@ def boom(method, url, **kw): def test_unsafe_request_path_rejected_before_auth(monkeypatch, path): from azure.cli.core.azclierror import ValidationError - from azext_alrs.server import _data_plane + from azext_apr.server import _data_plane monkeypatch.setattr( _data_plane, @@ -233,14 +233,14 @@ def test_unsafe_request_path_rejected_before_auth(monkeypatch, path): 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.alrs.azure.net" + 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_alrs.server._data_plane import _StreamingMultipart + from azext_apr.server._data_plane import _StreamingMultipart package = tmp_path / "package.deb" package.write_bytes(b"package contents") @@ -259,14 +259,14 @@ def test_streaming_multipart_body_is_reiterable(tmp_path): def test_streaming_multipart_prepares_as_chunked_transfer(tmp_path): import requests - from azext_alrs.server._data_plane import _StreamingMultipart + 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.alrs.azure.net/api/v1/packages/", + "https://r.eastus.api.apr.azure.net/api/v1/packages/", data=body, headers={"Content-Type": body.content_type}, ).prepare() @@ -312,7 +312,7 @@ def test_post_multipart_sends_streaming_body_without_retry(client, monkeypatch, def _patch_arm(monkeypatch, pages): """Patch send_raw_request to yield successive ARM pages, and a fixed sub.""" - from azext_alrs.server import _data_plane + from azext_apr.server import _data_plane responses = iter(pages) monkeypatch.setattr(_data_plane, "get_subscription_id", lambda cli_ctx: "sub-1") @@ -330,17 +330,15 @@ def _cmd(): def test_resolve_endpoint_with_resource_group(monkeypatch): - from azext_alrs.server._data_plane import resolve_api_endpoint + from azext_apr.server._data_plane import resolve_api_endpoint - _patch_arm( - monkeypatch, [{"properties": {"apiEndpoint": "https://r.eastus.api.alrs.azure.net"}}] - ) + _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.alrs.azure.net" + assert endpoint == "https://r.eastus.api.apr.azure.net" def test_resolve_endpoint_subscription_wide_match(monkeypatch): - from azext_alrs.server._data_plane import resolve_api_endpoint + from azext_apr.server._data_plane import resolve_api_endpoint _patch_arm( monkeypatch, @@ -358,7 +356,7 @@ def test_resolve_endpoint_subscription_wide_match(monkeypatch): def test_resolve_endpoint_ambiguous_name_requires_rg(monkeypatch): from azure.cli.core.azclierror import RequiredArgumentMissingError - from azext_alrs.server._data_plane import resolve_api_endpoint + from azext_apr.server._data_plane import resolve_api_endpoint _patch_arm( monkeypatch, @@ -378,7 +376,7 @@ def test_resolve_endpoint_ambiguous_name_requires_rg(monkeypatch): def test_resolve_endpoint_not_found(monkeypatch): from azure.cli.core.azclierror import ResourceNotFoundError - from azext_alrs.server._data_plane import resolve_api_endpoint + from azext_apr.server._data_plane import resolve_api_endpoint _patch_arm(monkeypatch, [{"value": []}]) with pytest.raises(ResourceNotFoundError): @@ -388,7 +386,7 @@ def test_resolve_endpoint_not_found(monkeypatch): def test_resolve_endpoint_missing_api_endpoint(monkeypatch): from azure.cli.core.azclierror import AzureResponseError - from azext_alrs.server._data_plane import resolve_api_endpoint + from azext_apr.server._data_plane import resolve_api_endpoint _patch_arm(monkeypatch, [{"properties": {}}]) with pytest.raises(AzureResponseError): @@ -409,10 +407,10 @@ def _patch_extensions(monkeypatch, ext_type, cover=True): import azure.cli.core.extension as ext_mod - from azext_alrs.server import _data_plane + from azext_apr.server import _data_plane module_dir = Path(_data_plane.__file__).resolve().parent - # Parent-of-parent of .../azext_alrs/server is the repo dir that contains the + # 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" @@ -421,21 +419,21 @@ def _patch_extensions(monkeypatch, ext_type, cover=True): def test_is_dev_extension_true_for_dev_build(monkeypatch): - from azext_alrs.server import _data_plane + 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_alrs.server import _data_plane + 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_alrs.server import _data_plane + 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. @@ -446,7 +444,7 @@ def test_is_dev_extension_false_when_module_not_under_any_extension(monkeypatch) def test_is_dev_extension_false_on_error(monkeypatch): import azure.cli.core.extension as ext_mod - from azext_alrs.server import _data_plane + from azext_apr.server import _data_plane def _boom(*a, **k): raise RuntimeError("core too old") @@ -458,7 +456,7 @@ def _boom(*a, **k): def test_for_registry_dev_build_uses_local_and_skips_arm(monkeypatch): from unittest import mock - from azext_alrs.server import _data_plane + from azext_apr.server import _data_plane def _boom(*a, **k): raise AssertionError("resolve_api_endpoint should not be called for a dev build") @@ -474,23 +472,23 @@ def _boom(*a, **k): def test_for_registry_installed_build_resolves_via_arm(monkeypatch): from unittest import mock - from azext_alrs.server import _data_plane + 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.alrs.azure.net", + 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.alrs.azure.net" + 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_alrs.server import _data_plane + from azext_apr.server import _data_plane monkeypatch.setattr(_data_plane, "is_dev_extension", lambda: True) with pytest.raises(ValidationError): @@ -498,7 +496,7 @@ def test_raise_if_dev_extension_raises_for_dev_build(monkeypatch): def test_raise_if_dev_extension_noop_for_installed_build(monkeypatch): - from azext_alrs.server import _data_plane + 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 @@ -506,7 +504,7 @@ def test_raise_if_dev_extension_noop_for_installed_build(monkeypatch): @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_alrs.server import _data_plane + from azext_apr.server import _data_plane # If auth were attempted for localhost this would blow up, proving it's skipped. monkeypatch.setattr( @@ -522,4 +520,4 @@ def test_localhost_client_skips_auth(monkeypatch, base_url): client.get("/repositories/") assert "authorization" not in sent["headers"] assert "x-correlation-id" in sent["headers"] - assert "alrs-cli-version" in sent["headers"] + assert "apr-cli-version" in sent["headers"] diff --git a/src/alrs/azext_alrs/tests/test_data_plane_composition.py b/src/apr/azext_apr/tests/test_data_plane_composition.py similarity index 95% rename from src/alrs/azext_alrs/tests/test_data_plane_composition.py rename to src/apr/azext_apr/tests/test_data_plane_composition.py index ea7703b0cb0..dc361d31379 100644 --- a/src/alrs/azext_alrs/tests/test_data_plane_composition.py +++ b/src/apr/azext_apr/tests/test_data_plane_composition.py @@ -6,11 +6,11 @@ import requests -from azext_alrs.commands import package, publication, remote, repository -from azext_alrs.server import _data_plane -from azext_alrs.server._data_plane import DataPlaneClient +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.alrs.azure.net" +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" diff --git a/src/alrs/azext_alrs/tests/test_distro.py b/src/apr/azext_apr/tests/test_distro.py similarity index 90% rename from src/alrs/azext_alrs/tests/test_distro.py rename to src/apr/azext_apr/tests/test_distro.py index 323974d0686..2dfcd869ed0 100644 --- a/src/alrs/azext_alrs/tests/test_distro.py +++ b/src/apr/azext_apr/tests/test_distro.py @@ -2,11 +2,11 @@ pytest.importorskip("azure.cli.core") -from azext_alrs.commands import distro +from azext_apr.commands import distro VERBS = ["list", "create", "show", "update", "delete"] -# real-looking ALRS resource ids - match the resolver's regex, so they skip the name lookup +# 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" @@ -64,54 +64,54 @@ def fake(monkeypatch): def test_helps_registered(): from knack.help_files import helps - assert "alrs distro" in helps + assert "apr distro" in helps for verb in VERBS: - assert f"alrs distro {verb}" in helps + assert f"apr distro {verb}" in helps def test_command_table_registers_distro_verbs(): from azure.cli.core.mock import DummyCli - from azext_alrs import AlrsCommandsLoader + from azext_apr import AprCommandsLoader - loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader = AprCommandsLoader(cli_ctx=DummyCli()) loader.load_command_table(None) - registered = {k for k in loader.command_table if k.startswith("alrs distro")} - expected = {f"alrs distro {v}" for v in VERBS} + 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_alrs import AlrsCommandsLoader + from azext_apr import AprCommandsLoader - loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader = AprCommandsLoader(cli_ctx=DummyCli()) loader.skip_applicability = True loader.load_command_table(None) - loader.load_arguments("alrs distro create") + 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("alrs distro", "registry_name") - assert {"--name", "-n"} <= options("alrs distro", "distro_name") - assert "--type" in options("alrs distro create", "distro_type") - assert {"--base-path"} <= options("alrs distro create", "base_path") - assert {"--repository"} <= options("alrs distro create", "repository") - assert {"--publication"} <= options("alrs distro create", "publication") + 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["alrs distro create"]["repository"].settings.get("required") + assert not registry["apr distro create"]["repository"].settings.get("required") # --type is an enum type (case-insensitive), not a raw choices= list - assert registry["alrs distro create"]["distro_type"].settings.get("choices") + 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"alrs distro {verb}"].supports_no_wait + assert loader.command_table[f"apr distro {verb}"].supports_no_wait for verb in ("show", "list"): - assert not loader.command_table[f"alrs distro {verb}"].supports_no_wait + assert not loader.command_table[f"apr distro {verb}"].supports_no_wait def test_list_pages_through_everything_by_default(fake): diff --git a/src/alrs/azext_alrs/tests/test_extension.py b/src/apr/azext_apr/tests/test_extension.py similarity index 70% rename from src/alrs/azext_alrs/tests/test_extension.py rename to src/apr/azext_apr/tests/test_extension.py index 8d70b713a14..f338b72acde 100644 --- a/src/alrs/azext_alrs/tests/test_extension.py +++ b/src/apr/azext_apr/tests/test_extension.py @@ -4,33 +4,33 @@ def test_loader_instantiates(): - from azext_alrs import COMMAND_LOADER_CLS, AlrsCommandsLoader + from azext_apr import COMMAND_LOADER_CLS, AprCommandsLoader - assert COMMAND_LOADER_CLS is AlrsCommandsLoader + assert COMMAND_LOADER_CLS is AprCommandsLoader def test_client_factory_placeholder_raises(monkeypatch): - from azext_alrs._client_factory import cf_alrs - from azext_alrs.server import _data_plane + 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_alrs(cli_ctx=None) + cf_apr(cli_ctx=None) def test_command_table_registers_registry_verbs(): from azure.cli.core.mock import DummyCli - from azext_alrs import AlrsCommandsLoader + from azext_apr import AprCommandsLoader - loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader = AprCommandsLoader(cli_ctx=DummyCli()) loader.load_command_table(None) - registered = {k for k in loader.command_table if k.startswith("alrs registry")} + registered = {k for k in loader.command_table if k.startswith("apr registry")} expected = { - f"alrs registry {verb}" for verb in ["create", "delete", "show", "list", "update", "wait"] + f"apr registry {verb}" for verb in ["create", "delete", "show", "list", "update", "wait"] } assert registered == expected @@ -39,20 +39,20 @@ 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_alrs` — so the command + # 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_alrs import AlrsCommandsLoader + from azext_apr import AprCommandsLoader - loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + 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("alrs ") and not name.startswith("alrs registry") + 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(): @@ -61,7 +61,7 @@ def test_data_plane_commands_resolve_to_own_module_without_control_plane_factory 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_alrs.commands.registry#{}", ( + 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" ) @@ -69,5 +69,5 @@ def test_data_plane_commands_resolve_to_own_module_without_control_plane_factory # 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"alrs registry {verb}"] + cmd = loader.command_table[f"apr registry {verb}"] assert cmd.command_kwargs.get("client_factory") is None diff --git a/src/alrs/azext_alrs/tests/test_package.py b/src/apr/azext_apr/tests/test_package.py similarity index 86% rename from src/alrs/azext_alrs/tests/test_package.py rename to src/apr/azext_apr/tests/test_package.py index 3205bb9f294..868a6c0539f 100644 --- a/src/alrs/azext_alrs/tests/test_package.py +++ b/src/apr/azext_apr/tests/test_package.py @@ -2,7 +2,7 @@ pytest.importorskip("azure.cli.core") -from azext_alrs.commands import package +from azext_apr.commands import package # each package type is its own subgroup with list + show SUBGROUPS = ["deb", "debsrc", "rpm", "file"] @@ -65,67 +65,67 @@ def fake(monkeypatch): def test_helps_registered(): from knack.help_files import helps - assert "alrs package" in helps - assert "alrs package upload" in helps + assert "apr package" in helps + assert "apr package upload" in helps for sub in SUBGROUPS: - assert f"alrs package {sub}" in helps - assert f"alrs package {sub} list" in helps - assert f"alrs package {sub} show" in helps + 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_alrs import AlrsCommandsLoader + from azext_apr import AprCommandsLoader - loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader = AprCommandsLoader(cli_ctx=DummyCli()) loader.load_command_table(None) - registered = {k for k in loader.command_table if k.startswith("alrs package")} - expected = {"alrs package upload"} + registered = {k for k in loader.command_table if k.startswith("apr package")} + expected = {"apr package upload"} for sub in SUBGROUPS: - expected.add(f"alrs package {sub} list") - expected.add(f"alrs package {sub} show") + 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_alrs import AlrsCommandsLoader + from azext_apr import AprCommandsLoader - loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader = AprCommandsLoader(cli_ctx=DummyCli()) loader.skip_applicability = True loader.load_command_table(None) - loader.load_arguments("alrs package upload") + loader.load_arguments("apr package upload") for sub in SUBGROUPS: - loader.load_arguments(f"alrs package {sub} list") - loader.load_arguments(f"alrs package {sub} show") + 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 "alrs package" scope - assert {"--registry", "-r"} <= options("alrs package", "registry_name") - assert {"--id"} <= options(f"alrs package {sub} show", "package_id") - assert {"--repository"} <= options(f"alrs package {sub} list", "repository") - assert {"--name"} <= options(f"alrs package {sub} list", "name") - assert {"--sha256"} <= options(f"alrs package {sub} list", "sha256") + # 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"alrs package {sub} list", "release") + assert {"--release"} <= options(f"apr package {sub} list", "release") # file has no --release argument at all - assert "release" not in registry.get("alrs package file list", {}) - assert {"--file"} <= options("alrs package upload", "package") - assert {"--repository"} <= options("alrs package upload", "repository") - assert {"--type", "-t"} <= options("alrs package upload", "file_type") - assert {"--relative-path"} <= options("alrs package upload", "relative_path") - assert {"--release"} <= options("alrs package upload", "release") - assert {"--component"} <= options("alrs package upload", "component") - assert loader.command_table["alrs package upload"].supports_no_wait + 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): diff --git a/src/alrs/azext_alrs/tests/test_paging.py b/src/apr/azext_apr/tests/test_paging.py similarity index 96% rename from src/alrs/azext_alrs/tests/test_paging.py rename to src/apr/azext_apr/tests/test_paging.py index 273a19a688e..b539ef06e14 100644 --- a/src/alrs/azext_alrs/tests/test_paging.py +++ b/src/apr/azext_apr/tests/test_paging.py @@ -4,7 +4,7 @@ from azure.cli.core.azclierror import InvalidArgumentValueError -from azext_alrs.server import iter_results, list_all +from azext_apr.server import iter_results, list_all class FakeResp: @@ -29,7 +29,7 @@ def get(self, path, **kwargs): def test_walks_pages_until_count_is_reached(): - # The ALRS server strips `next`; the pager must walk by offset off `count`. + # The APR server strips `next`; the pager must walk by offset off `count`. fake = PagingFake( [ {"count": 105, "results": list(range(100))}, diff --git a/src/alrs/azext_alrs/tests/test_polling.py b/src/apr/azext_apr/tests/test_polling.py similarity index 86% rename from src/alrs/azext_alrs/tests/test_polling.py rename to src/apr/azext_apr/tests/test_polling.py index 1f96659e99c..236beee61ed 100644 --- a/src/alrs/azext_alrs/tests/test_polling.py +++ b/src/apr/azext_apr/tests/test_polling.py @@ -25,7 +25,7 @@ def get(self, path): def test_poll_task_returns_on_completed(): - from azext_alrs.server._polling import poll_task + 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) @@ -36,7 +36,7 @@ def test_poll_task_returns_on_completed(): def test_poll_task_raises_on_failure(): from azure.cli.core.azclierror import AzureResponseError - from azext_alrs.server._polling import poll_task + from azext_apr.server._polling import poll_task client = FakeClient([{"id": "t1", "state": "failed", "error": {"description": "boom"}}]) with pytest.raises(AzureResponseError, match="boom"): @@ -44,7 +44,7 @@ def test_poll_task_raises_on_failure(): def test_wait_for_task_no_wait_returns_handle_without_polling(): - from azext_alrs.server._polling import wait_for_task + from azext_apr.server._polling import wait_for_task client = FakeClient([]) # must not be touched resp = FakeResponse({"task": "t1"}) @@ -53,7 +53,7 @@ def test_wait_for_task_no_wait_returns_handle_without_polling(): def test_wait_for_task_polls_when_task_present(monkeypatch): - from azext_alrs.server import _polling + from azext_apr.server import _polling monkeypatch.setattr(_polling, "poll_task", lambda c, tid: {"id": tid, "state": "completed"}) resp = FakeResponse({"task": "t1"}) @@ -61,14 +61,14 @@ def test_wait_for_task_polls_when_task_present(monkeypatch): def test_wait_for_task_passthrough_when_no_task(): - from azext_alrs.server._polling import wait_for_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_alrs.server._polling import wait_for_task + 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/alrs/azext_alrs/tests/test_publication.py b/src/apr/azext_apr/tests/test_publication.py similarity index 81% rename from src/alrs/azext_alrs/tests/test_publication.py rename to src/apr/azext_apr/tests/test_publication.py index 9e7743ea215..b73d9bf732e 100644 --- a/src/alrs/azext_alrs/tests/test_publication.py +++ b/src/apr/azext_apr/tests/test_publication.py @@ -2,7 +2,7 @@ pytest.importorskip("azure.cli.core") -from azext_alrs.commands import publication +from azext_apr.commands import publication VERBS = ["list", "show", "delete"] @@ -51,45 +51,45 @@ def fake(monkeypatch): def test_helps_registered(): from knack.help_files import helps - assert "alrs publication" in helps + assert "apr publication" in helps for verb in VERBS: - assert f"alrs publication {verb}" in helps + assert f"apr publication {verb}" in helps def test_command_table_registers_publication_verbs(): from azure.cli.core.mock import DummyCli - from azext_alrs import AlrsCommandsLoader + from azext_apr import AprCommandsLoader - loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader = AprCommandsLoader(cli_ctx=DummyCli()) loader.load_command_table(None) - registered = {k for k in loader.command_table if k.startswith("alrs publication")} - expected = {f"alrs publication {v}" for v in VERBS} + 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_alrs import AlrsCommandsLoader + from azext_apr import AprCommandsLoader - loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader = AprCommandsLoader(cli_ctx=DummyCli()) loader.skip_applicability = True loader.load_command_table(None) - loader.load_arguments("alrs publication list") + 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("alrs publication", "registry_name") - assert {"--id"} <= options("alrs publication", "publication_id") - assert {"--repository"} <= options("alrs publication list", "repository") + 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["alrs publication delete"].supports_no_wait + assert loader.command_table["apr publication delete"].supports_no_wait for verb in ("show", "list"): - assert not loader.command_table[f"alrs publication {verb}"].supports_no_wait + assert not loader.command_table[f"apr publication {verb}"].supports_no_wait def test_list_pages_through_everything_by_default(fake): diff --git a/src/alrs/azext_alrs/tests/test_registry.py b/src/apr/azext_apr/tests/test_registry.py similarity index 62% rename from src/alrs/azext_alrs/tests/test_registry.py rename to src/apr/azext_apr/tests/test_registry.py index b132801e370..5a6f96ec7d6 100644 --- a/src/alrs/azext_alrs/tests/test_registry.py +++ b/src/apr/azext_apr/tests/test_registry.py @@ -6,11 +6,11 @@ def test_generated_registry_commands_use_the_expected_api_contract(): - from azext_alrs.aaz.latest.alrs.registry._create import Create - from azext_alrs.aaz.latest.alrs.registry._delete import Delete - from azext_alrs.aaz.latest.alrs.registry._list import List - from azext_alrs.aaz.latest.alrs.registry._show import Show - from azext_alrs.aaz.latest.alrs.registry._update import Update + 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 = ( @@ -33,14 +33,14 @@ 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_alrs import AlrsCommandsLoader - from azext_alrs.aaz.latest.alrs.registry._create import Create - from azext_alrs.aaz.latest.alrs.registry._delete import Delete - from azext_alrs.aaz.latest.alrs.registry._list import List - from azext_alrs.aaz.latest.alrs.registry._show import Show - from azext_alrs.aaz.latest.alrs.registry._update import Update - from azext_alrs.aaz.latest.alrs.registry._wait import Wait - from azext_alrs.server import _data_plane + 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 = [ @@ -49,7 +49,7 @@ def test_generated_registry_commands_block_dev_builds(monkeypatch): Show(cli_ctx=DummyCli()), List(cli_ctx=DummyCli()), Update(cli_ctx=DummyCli()), - Wait(AlrsCommandsLoader(cli_ctx=DummyCli())), + Wait(AprCommandsLoader(cli_ctx=DummyCli())), ] for command in commands: with pytest.raises(ValidationError, match="not available in a local dev build"): diff --git a/src/alrs/azext_alrs/tests/test_remote.py b/src/apr/azext_apr/tests/test_remote.py similarity index 82% rename from src/alrs/azext_alrs/tests/test_remote.py rename to src/apr/azext_apr/tests/test_remote.py index 745ff330685..d9527b8aa36 100644 --- a/src/alrs/azext_alrs/tests/test_remote.py +++ b/src/apr/azext_apr/tests/test_remote.py @@ -2,11 +2,11 @@ pytest.importorskip("azure.cli.core") -from azext_alrs.commands import remote +from azext_apr.commands import remote VERBS = ["list", "create", "show", "update", "delete"] -# real-looking ALRS resource id - matches the resolver's regex, so it skips the name lookup +# 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" @@ -59,54 +59,54 @@ def fake(monkeypatch): def test_helps_registered(): from knack.help_files import helps - assert "alrs remote" in helps + assert "apr remote" in helps for verb in VERBS: - assert f"alrs remote {verb}" in helps + assert f"apr remote {verb}" in helps def test_command_table_registers_remote_verbs(): from azure.cli.core.mock import DummyCli - from azext_alrs import AlrsCommandsLoader + from azext_apr import AprCommandsLoader - loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader = AprCommandsLoader(cli_ctx=DummyCli()) loader.load_command_table(None) - registered = {k for k in loader.command_table if k.startswith("alrs remote")} - expected = {f"alrs remote {v}" for v in VERBS} + 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_alrs import AlrsCommandsLoader + from azext_apr import AprCommandsLoader - loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader = AprCommandsLoader(cli_ctx=DummyCli()) loader.skip_applicability = True loader.load_command_table(None) - loader.load_arguments("alrs remote create") + 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("alrs remote", "registry_name") - assert {"--name", "-n"} <= options("alrs remote", "remote_name") - assert "--type" in options("alrs remote create", "remote_type") - assert {"--url"} <= options("alrs remote create", "url") - assert {"--policy"} <= options("alrs remote create", "policy") - assert {"--policy"} <= options("alrs remote update", "policy") - for scope in ("alrs remote create", "alrs remote update"): + 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("alrs remote create", "releases") + 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"alrs remote {verb}"].supports_no_wait + assert loader.command_table[f"apr remote {verb}"].supports_no_wait for verb in ("show", "list"): - assert not loader.command_table[f"alrs remote {verb}"].supports_no_wait + assert not loader.command_table[f"apr remote {verb}"].supports_no_wait def test_list_pages_through_everything_by_default(fake): diff --git a/src/alrs/azext_alrs/tests/test_repository.py b/src/apr/azext_apr/tests/test_repository.py similarity index 85% rename from src/alrs/azext_alrs/tests/test_repository.py rename to src/apr/azext_apr/tests/test_repository.py index cdf42f32576..ca67f0c3d56 100644 --- a/src/alrs/azext_alrs/tests/test_repository.py +++ b/src/apr/azext_apr/tests/test_repository.py @@ -2,11 +2,11 @@ pytest.importorskip("azure.cli.core") -from azext_alrs.commands import repository +from azext_apr.commands import repository VERBS = ["list", "create", "show", "update", "delete", "sync", "publish"] -# a real-looking ALRS resource id - matches the resolver's regex, so it skips the name lookup +# 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" @@ -60,67 +60,67 @@ def fake(monkeypatch): def test_helps_registered(): from knack.help_files import helps - assert "alrs repository" in helps + assert "apr repository" in helps for verb in VERBS: - assert f"alrs repository {verb}" in helps + assert f"apr repository {verb}" in helps def test_command_table_registers_repository_verbs(): from azure.cli.core.mock import DummyCli - from azext_alrs import AlrsCommandsLoader + from azext_apr import AprCommandsLoader - loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader = AprCommandsLoader(cli_ctx=DummyCli()) loader.load_command_table(None) registered = { k for k in loader.command_table - if k.startswith("alrs repository") - and not k.startswith("alrs repository package") - and not k.startswith("alrs repository release") + if k.startswith("apr repository") + and not k.startswith("apr repository package") + and not k.startswith("apr repository release") } - expected = {f"alrs repository {v}" for v in VERBS} + 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 alrs repository ... --help`: running load_arguments + # 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_alrs import AlrsCommandsLoader + from azext_apr import AprCommandsLoader - loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + 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("alrs repository create") + 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("alrs repository", "registry_name") - assert {"--name", "-n"} <= options("alrs repository", "repository_name") - assert "--type" in options("alrs repository create", "repository_type") - assert {"--retain-versions"} <= options("alrs repository create", "retain_versions") - assert {"--retain-all"} <= options("alrs repository create", "retain_all") - assert {"--remote"} <= options("alrs repository update", "remote_name") - assert {"--retain-versions"} <= options("alrs repository update", "retain_versions") - assert {"--retain-all"} <= options("alrs repository update", "retain_all") - assert {"--sync-mode"} <= options("alrs repository sync", "sync_mode") - assert {"--confirm"} <= options("alrs repository sync", "confirm") - assert loader.command_table["alrs repository sync"].confirmation is ( + 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"alrs repository {verb}"].supports_no_wait + assert loader.command_table[f"apr repository {verb}"].supports_no_wait for verb in ("create", "show", "list"): - assert not loader.command_table[f"alrs repository {verb}"].supports_no_wait + assert not loader.command_table[f"apr repository {verb}"].supports_no_wait def test_list_pages_through_everything_by_default(fake): diff --git a/src/alrs/azext_alrs/tests/test_repository_package.py b/src/apr/azext_apr/tests/test_repository_package.py similarity index 91% rename from src/alrs/azext_alrs/tests/test_repository_package.py rename to src/apr/azext_apr/tests/test_repository_package.py index 8e43da351bd..9fbd6ffadb8 100644 --- a/src/alrs/azext_alrs/tests/test_repository_package.py +++ b/src/apr/azext_apr/tests/test_repository_package.py @@ -2,11 +2,11 @@ pytest.importorskip("azure.cli.core") -from azext_alrs.commands import repository_package +from azext_apr.commands import repository_package VERBS = ["add", "remove"] -# a real-looking ALRS resource id - matches the resolver's regex, so it skips the name lookup +# 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" @@ -65,39 +65,39 @@ def fake(monkeypatch): def test_helps_registered(): from knack.help_files import helps - assert "alrs repository package" in helps + assert "apr repository package" in helps for verb in VERBS: - assert f"alrs repository package {verb}" in helps + assert f"apr repository package {verb}" in helps def test_command_table_registers_verbs(): from azure.cli.core.mock import DummyCli - from azext_alrs import AlrsCommandsLoader + from azext_apr import AprCommandsLoader - loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader = AprCommandsLoader(cli_ctx=DummyCli()) loader.load_command_table(None) - registered = {k for k in loader.command_table if k.startswith("alrs repository package")} - expected = {f"alrs repository package {v}" for v in VERBS} + 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"alrs repository package {verb}"].supports_no_wait + 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_alrs import AlrsCommandsLoader + from azext_apr import AprCommandsLoader - loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader = AprCommandsLoader(cli_ctx=DummyCli()) loader.skip_applicability = True loader.load_command_table(None) - loader.load_arguments("alrs repository package add") + loader.load_arguments("apr repository package add") arguments = loader.argument_registry.arguments for verb in VERBS: - scope = f"alrs repository package {verb}" + scope = f"apr repository package {verb}" options = arguments[scope]["releases"].settings["options_list"] assert "--releases" in options assert "--release" not in options diff --git a/src/alrs/azext_alrs/tests/test_repository_release.py b/src/apr/azext_apr/tests/test_repository_release.py similarity index 87% rename from src/alrs/azext_alrs/tests/test_repository_release.py rename to src/apr/azext_apr/tests/test_repository_release.py index f51fa61892d..f053cac044a 100644 --- a/src/alrs/azext_alrs/tests/test_repository_release.py +++ b/src/apr/azext_apr/tests/test_repository_release.py @@ -2,11 +2,11 @@ pytest.importorskip("azure.cli.core") -from azext_alrs.commands import repository_release as rel +from azext_apr.commands import repository_release as rel VERBS = ["list", "create", "delete"] -# real-looking ALRS resource ids - match the resolver regexes so they skip name lookups +# 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" @@ -63,45 +63,45 @@ def fake(monkeypatch): def test_helps_registered(): from knack.help_files import helps - assert "alrs repository release" in helps + assert "apr repository release" in helps for verb in VERBS: - assert f"alrs repository release {verb}" in helps + assert f"apr repository release {verb}" in helps def test_command_table_registers_release_verbs(): from azure.cli.core.mock import DummyCli - from azext_alrs import AlrsCommandsLoader + from azext_apr import AprCommandsLoader - loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader = AprCommandsLoader(cli_ctx=DummyCli()) loader.load_command_table(None) registered = { k for k in loader.command_table - if k.startswith("alrs repository release ") - and not k.startswith("alrs repository release component") + if k.startswith("apr repository release ") + and not k.startswith("apr repository release component") } - expected = {f"alrs repository release {v}" for v in VERBS} + 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_alrs import AlrsCommandsLoader + from azext_apr import AprCommandsLoader - loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader = AprCommandsLoader(cli_ctx=DummyCli()) loader.skip_applicability = True loader.load_command_table(None) - loader.load_arguments("alrs repository release create") + 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 = "alrs repository release" - create = "alrs repository release create" + 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") @@ -111,8 +111,8 @@ def options(scope, dest): 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"alrs repository release {verb}"].supports_no_wait - assert not loader.command_table["alrs repository release list"].supports_no_wait + 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): @@ -257,42 +257,42 @@ def get(path, **kwargs): def test_component_helps_registered(): from knack.help_files import helps - assert "alrs repository release component" in helps + assert "apr repository release component" in helps for verb in COMPONENT_VERBS: - assert f"alrs repository release component {verb}" in helps + 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_alrs import AlrsCommandsLoader + from azext_apr import AprCommandsLoader - loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader = AprCommandsLoader(cli_ctx=DummyCli()) loader.load_command_table(None) registered = { - k for k in loader.command_table if k.startswith("alrs repository release component") + k for k in loader.command_table if k.startswith("apr repository release component") } - expected = {f"alrs repository release component {v}" for v in COMPONENT_VERBS} + 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_alrs import AlrsCommandsLoader + from azext_apr import AprCommandsLoader - loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader = AprCommandsLoader(cli_ctx=DummyCli()) loader.skip_applicability = True loader.load_command_table(None) - loader.load_arguments("alrs repository release component create") + 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 = "alrs repository release" - grp = "alrs repository release component" + 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") @@ -300,8 +300,8 @@ def options(scope, dest): 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"alrs repository release component {verb}"].supports_no_wait - assert not loader.command_table["alrs repository release component list"].supports_no_wait + 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): diff --git a/src/alrs/azext_alrs/tests/test_resolve.py b/src/apr/azext_apr/tests/test_resolve.py similarity index 99% rename from src/alrs/azext_alrs/tests/test_resolve.py rename to src/apr/azext_apr/tests/test_resolve.py index 5056cfe7906..d0ed802d8ef 100644 --- a/src/alrs/azext_alrs/tests/test_resolve.py +++ b/src/apr/azext_apr/tests/test_resolve.py @@ -2,7 +2,7 @@ pytest.importorskip("azure.cli.core") -from azext_alrs.server._resolve import ( +from azext_apr.server._resolve import ( resolve_id_or_name, resolve_release, resolve_release_name, diff --git a/src/alrs/azext_alrs/tests/test_task.py b/src/apr/azext_apr/tests/test_task.py similarity index 81% rename from src/alrs/azext_alrs/tests/test_task.py rename to src/apr/azext_apr/tests/test_task.py index 307fbbc28d9..111b7344a59 100644 --- a/src/alrs/azext_alrs/tests/test_task.py +++ b/src/apr/azext_apr/tests/test_task.py @@ -2,7 +2,7 @@ pytest.importorskip("azure.cli.core") -from azext_alrs.commands import task +from azext_apr.commands import task VERBS = ["list", "show", "wait", "cancel"] @@ -49,45 +49,45 @@ def fake(monkeypatch): def test_helps_registered(): from knack.help_files import helps - assert "alrs task" in helps + assert "apr task" in helps for verb in VERBS: - assert f"alrs task {verb}" in helps + assert f"apr task {verb}" in helps def test_command_table_registers_task_verbs(): from azure.cli.core.mock import DummyCli - from azext_alrs import AlrsCommandsLoader + from azext_apr import AprCommandsLoader - loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader = AprCommandsLoader(cli_ctx=DummyCli()) loader.load_command_table(None) - registered = {k for k in loader.command_table if k.startswith("alrs task")} - expected = {f"alrs task {v}" for v in VERBS} + 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_alrs import AlrsCommandsLoader + from azext_apr import AprCommandsLoader - loader = AlrsCommandsLoader(cli_ctx=DummyCli()) + loader = AprCommandsLoader(cli_ctx=DummyCli()) loader.skip_applicability = True loader.load_command_table(None) - loader.load_arguments("alrs task list") + 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("alrs task", "registry_name") - assert {"--id"} <= options("alrs task", "task_id") - assert {"--state"} <= options("alrs task list", "state") + 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("alrs task wait") - assert {"--ids"} <= options("alrs task wait", "task_ids") - assert registry["alrs task wait"]["task_ids"].settings.get("required") is True + 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): diff --git a/src/alrs/pyproject.toml b/src/apr/pyproject.toml similarity index 93% rename from src/alrs/pyproject.toml rename to src/apr/pyproject.toml index dc515492df2..a792a5658c3 100644 --- a/src/alrs/pyproject.toml +++ b/src/apr/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=70"] build-backend = "setuptools.build_meta" [project] -name = "alrs" +name = "apr" version = "0.1.0" description = "Microsoft Azure Command-Line Tools Package Registry Extension" dynamic = ["readme"] @@ -28,16 +28,16 @@ classifiers = [ dependencies = [] [project.urls] -Home = "https://github.com/Azure/azure-cli-extensions/tree/main/src/alrs" +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_alrs*"] +include = ["azext_apr*"] [tool.setuptools.package-data] -azext_alrs = ["azext_metadata.json"] +azext_apr = ["azext_metadata.json"] [tool.ruff] line-length = 100 @@ -57,7 +57,7 @@ strict = true python_version = "3.10" [[tool.mypy.overrides]] -module = "azext_alrs.*" +module = "azext_apr.*" warn_unused_ignores = false # azure-cli-core and knack ship no type stubs; don't fail strict mode on them. diff --git a/src/alrs/setup.py b/src/apr/setup.py similarity index 100% rename from src/alrs/setup.py rename to src/apr/setup.py From e6fca4d394116cd7f65c6b0d3ee5cb6d8ecf7a47 Mon Sep 17 00:00:00 2001 From: Kai Daniels Date: Tue, 15 Sep 2026 20:25:13 +0000 Subject: [PATCH 05/12] sync apr source with service main Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 99135581-9cf9-4a99-a7b3-63692da88967 --- src/apr/azext_apr/_client_factory.py | 4 +- .../aaz/latest/apr/registry/__cmd_group.py | 2 +- src/apr/azext_apr/server/_data_plane.py | 41 ++++++++++++------- src/apr/azext_apr/server/_polling.py | 10 +++-- .../tests/latest/test_apr_scenario.py | 3 ++ 5 files changed, 39 insertions(+), 21 deletions(-) diff --git a/src/apr/azext_apr/_client_factory.py b/src/apr/azext_apr/_client_factory.py index 87c7101caa4..6c2c9a70860 100644 --- a/src/apr/azext_apr/_client_factory.py +++ b/src/apr/azext_apr/_client_factory.py @@ -4,8 +4,8 @@ def cf_apr(cli_ctx, *_): # 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 azure-mgmt-apr client is wired up. + # real management-plane SDK client is wired up. from azext_apr.server import raise_if_dev_extension raise_if_dev_extension() - raise NotImplementedError("azure-mgmt-apr SDK not yet available") + raise NotImplementedError("management-plane SDK client not yet available") 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 index 63a94b3059e..ed9e29d284b 100644 --- a/src/apr/azext_apr/aaz/latest/apr/registry/__cmd_group.py +++ b/src/apr/azext_apr/aaz/latest/apr/registry/__cmd_group.py @@ -16,7 +16,7 @@ "apr registry", ) class __CMDGroup(AAZCommandGroup): - """Manage Azure Package Registry registries.""" + """Manage Azure Package Registry (APR) registries.""" pass diff --git a/src/apr/azext_apr/server/_data_plane.py b/src/apr/azext_apr/server/_data_plane.py index 06e6ecf0828..dbdd0437df9 100644 --- a/src/apr/azext_apr/server/_data_plane.py +++ b/src/apr/azext_apr/server/_data_plane.py @@ -2,8 +2,8 @@ Foundation for every data-plane command (repository, package, distro, remote, publication, task), which hit the registry's API endpoint directly, not ARM. -Uses the active Azure CLI login for authentication and maps service failures to -``azclierror`` exceptions. +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 @@ -32,17 +32,24 @@ logger = get_logger(__name__) -# The service team must confirm the final Entra audience before publication. +# 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 service mounts its routes under this prefix. Path-versioning lets an older, -# independently distributed CLI keep working while the service evolves. Bump in -# lockstep with a breaking data-plane contract change. +# 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" -# Keep this aligned with the reviewed ARM API specification. +# 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" @@ -60,7 +67,9 @@ # server, and an installed build always resolves the real endpoint via ARM. DEV_LOCAL_ENDPOINT = "http://localhost:8100" -# Hosts treated as a local development server, where authentication is skipped. +# 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. @@ -105,7 +114,8 @@ def _cli_version() -> str: class _LoggedRetry(Retry): - """Retry policy that logs each attempt through the Azure CLI logger.""" + """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 @@ -118,7 +128,8 @@ def increment( # pylint: disable=keyword-arg-before-vararg return retry -# HTTP status to azclierror. Unmapped failures use AzureResponseError. +# 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, @@ -205,13 +216,15 @@ def for_registry( def _headers(self) -> dict[str, str]: # Increment the correlation id per request so a single command's calls - # share a traceable sequence in service logs. + # 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 development server has no Entra app, so skip auth for localhost. + # 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}" @@ -273,8 +286,8 @@ def is_dev_extension() -> bool: 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``, not from - the package or project name, so a name lookup is + 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``. diff --git a/src/apr/azext_apr/server/_polling.py b/src/apr/azext_apr/server/_polling.py index 1c3b5a98306..e7fd95ddc54 100644 --- a/src/apr/azext_apr/server/_polling.py +++ b/src/apr/azext_apr/server/_polling.py @@ -2,7 +2,8 @@ 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``. ARM long-running operations use the AAZ runtime instead. +``--no-wait``. Mirrors pmc/client.py:poll_task. (ARM LROs use the SDK's +``LROPoller`` instead — that's not this module.) """ import time @@ -13,15 +14,16 @@ logger = get_logger(__name__) -# A task in any of these states is complete. +# 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. The client reacquires its token - per request, so a token that expires mid-poll is refreshed on the next GET. + 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)) diff --git a/src/apr/azext_apr/tests/latest/test_apr_scenario.py b/src/apr/azext_apr/tests/latest/test_apr_scenario.py index 80c26c1092e..342c822415a 100644 --- a/src/apr/azext_apr/tests/latest/test_apr_scenario.py +++ b/src/apr/azext_apr/tests/latest/test_apr_scenario.py @@ -1,5 +1,8 @@ +import pytest from azure.cli.testsdk import ScenarioTest +pytestmark = pytest.mark.azdev + class AprCommandScenarioTest(ScenarioTest): def test_extension_command_group_loads(self) -> None: From 0010216d39f759ad9dbcac958235bc2dbb4fb249 Mon Sep 17 00:00:00 2001 From: Kai Daniels Date: Tue, 15 Sep 2026 20:27:15 +0000 Subject: [PATCH 06/12] configure apr linter exceptions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 99135581-9cf9-4a99-a7b3-63692da88967 --- src/apr/linter_exclusions.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/apr/linter_exclusions.yml 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 From e1090b4d2b67968ea7e488cccabd25d417865adc Mon Sep 17 00:00:00 2001 From: Kai Daniels Date: Tue, 15 Sep 2026 20:40:30 +0000 Subject: [PATCH 07/12] expand APR offline scenario coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 99135581-9cf9-4a99-a7b3-63692da88967 --- .../tests/latest/test_apr_scenario.py | 87 ++++++++++++++++++- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/src/apr/azext_apr/tests/latest/test_apr_scenario.py b/src/apr/azext_apr/tests/latest/test_apr_scenario.py index 342c822415a..9beef59b3a9 100644 --- a/src/apr/azext_apr/tests/latest/test_apr_scenario.py +++ b/src/apr/azext_apr/tests/latest/test_apr_scenario.py @@ -3,10 +3,93 @@ 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", "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 test_extension_command_group_loads(self) -> None: + def _assert_help_loads(self, command: tuple[str, ...]) -> None: with self.assertRaises(SystemExit) as raised: - self.cli_ctx.invoke(["apr", "--help"]) + 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) From 6edc5623c2c1900d767bdd487a45745ca0589460 Mon Sep 17 00:00:00 2001 From: Kai Daniels Date: Tue, 15 Sep 2026 21:18:40 +0000 Subject: [PATCH 08/12] align APR preview metadata Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 99135581-9cf9-4a99-a7b3-63692da88967 --- src/apr/HISTORY.rst | 4 ++-- src/apr/pyproject.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/apr/HISTORY.rst b/src/apr/HISTORY.rst index 7451e65abbe..29f73046fca 100644 --- a/src/apr/HISTORY.rst +++ b/src/apr/HISTORY.rst @@ -3,8 +3,8 @@ Release History =============== -0.1.0 -+++++ +1.0.0b1 +++++++++ * Add the initial preview command surface for registries, repositories, releases, packages, distributions, remotes, publications, and tasks. diff --git a/src/apr/pyproject.toml b/src/apr/pyproject.toml index a792a5658c3..8fba500468f 100644 --- a/src/apr/pyproject.toml +++ b/src/apr/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "apr" -version = "0.1.0" -description = "Microsoft Azure Command-Line Tools Package Registry Extension" +version = "1.0.0b1" +description = "Manage Azure Package Registry resources, repositories, packages, publications, and tasks." dynamic = ["readme"] license = { text = "MIT" } requires-python = ">=3.10" From fa3cde8ce01ac84e5e3be520cab237e3601e9d06 Mon Sep 17 00:00:00 2001 From: Kai Daniels Date: Wed, 16 Sep 2026 00:10:07 +0000 Subject: [PATCH 09/12] add required APR license headers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 99135581-9cf9-4a99-a7b3-63692da88967 --- src/apr/azext_apr/__init__.py | 5 +++++ src/apr/azext_apr/_client_factory.py | 5 +++++ src/apr/azext_apr/commands/__init__.py | 5 +++++ src/apr/azext_apr/commands/distro.py | 5 +++++ src/apr/azext_apr/commands/package.py | 5 +++++ src/apr/azext_apr/commands/publication.py | 5 +++++ src/apr/azext_apr/commands/remote.py | 5 +++++ src/apr/azext_apr/commands/repository.py | 5 +++++ src/apr/azext_apr/commands/repository_package.py | 5 +++++ src/apr/azext_apr/commands/repository_release.py | 5 +++++ src/apr/azext_apr/commands/task.py | 5 +++++ src/apr/azext_apr/server/__init__.py | 5 +++++ src/apr/azext_apr/server/_data_plane.py | 5 +++++ src/apr/azext_apr/server/_paging.py | 5 +++++ src/apr/azext_apr/server/_polling.py | 5 +++++ src/apr/azext_apr/server/_resolve.py | 5 +++++ src/apr/azext_apr/tests/__init__.py | 4 ++++ src/apr/azext_apr/tests/latest/__init__.py | 4 ++++ src/apr/azext_apr/tests/latest/test_apr_scenario.py | 5 +++++ src/apr/azext_apr/tests/test_data_plane.py | 5 +++++ src/apr/azext_apr/tests/test_data_plane_composition.py | 5 +++++ src/apr/azext_apr/tests/test_distro.py | 5 +++++ src/apr/azext_apr/tests/test_extension.py | 5 +++++ src/apr/azext_apr/tests/test_package.py | 5 +++++ src/apr/azext_apr/tests/test_paging.py | 5 +++++ src/apr/azext_apr/tests/test_polling.py | 5 +++++ src/apr/azext_apr/tests/test_publication.py | 5 +++++ src/apr/azext_apr/tests/test_registry.py | 5 +++++ src/apr/azext_apr/tests/test_remote.py | 5 +++++ src/apr/azext_apr/tests/test_repository.py | 5 +++++ src/apr/azext_apr/tests/test_repository_package.py | 5 +++++ src/apr/azext_apr/tests/test_repository_release.py | 5 +++++ src/apr/azext_apr/tests/test_resolve.py | 5 +++++ src/apr/azext_apr/tests/test_task.py | 5 +++++ src/apr/setup.py | 6 ++++-- 35 files changed, 172 insertions(+), 2 deletions(-) diff --git a/src/apr/azext_apr/__init__.py b/src/apr/azext_apr/__init__.py index 984265c4433..609261d9464 100644 --- a/src/apr/azext_apr/__init__.py +++ b/src/apr/azext_apr/__init__.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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 diff --git a/src/apr/azext_apr/_client_factory.py b/src/apr/azext_apr/_client_factory.py index 6c2c9a70860..a57b10cf1c2 100644 --- a/src/apr/azext_apr/_client_factory.py +++ b/src/apr/azext_apr/_client_factory.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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 diff --git a/src/apr/azext_apr/commands/__init__.py b/src/apr/azext_apr/commands/__init__.py index c8c60b7171f..550dfafea65 100644 --- a/src/apr/azext_apr/commands/__init__.py +++ b/src/apr/azext_apr/commands/__init__.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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, diff --git a/src/apr/azext_apr/commands/distro.py b/src/apr/azext_apr/commands/distro.py index 208bf1bbf48..c7878fda4e3 100644 --- a/src/apr/azext_apr/commands/distro.py +++ b/src/apr/azext_apr/commands/distro.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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, diff --git a/src/apr/azext_apr/commands/package.py b/src/apr/azext_apr/commands/package.py index c4743bff02f..e3cf317f6c8 100644 --- a/src/apr/azext_apr/commands/package.py +++ b/src/apr/azext_apr/commands/package.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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 ( diff --git a/src/apr/azext_apr/commands/publication.py b/src/apr/azext_apr/commands/publication.py index 5f13c0ff055..aa2aa707820 100644 --- a/src/apr/azext_apr/commands/publication.py +++ b/src/apr/azext_apr/commands/publication.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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 diff --git a/src/apr/azext_apr/commands/remote.py b/src/apr/azext_apr/commands/remote.py index b90e8766de9..1af23b83425 100644 --- a/src/apr/azext_apr/commands/remote.py +++ b/src/apr/azext_apr/commands/remote.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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 ( diff --git a/src/apr/azext_apr/commands/repository.py b/src/apr/azext_apr/commands/repository.py index 945d8d780ee..09e0ab3cb2c 100644 --- a/src/apr/azext_apr/commands/repository.py +++ b/src/apr/azext_apr/commands/repository.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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, diff --git a/src/apr/azext_apr/commands/repository_package.py b/src/apr/azext_apr/commands/repository_package.py index d0592fb8bd6..7a48d902625 100644 --- a/src/apr/azext_apr/commands/repository_package.py +++ b/src/apr/azext_apr/commands/repository_package.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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 diff --git a/src/apr/azext_apr/commands/repository_release.py b/src/apr/azext_apr/commands/repository_release.py index deb93b36f98..e46e9d64c2a 100644 --- a/src/apr/azext_apr/commands/repository_release.py +++ b/src/apr/azext_apr/commands/repository_release.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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 diff --git a/src/apr/azext_apr/commands/task.py b/src/apr/azext_apr/commands/task.py index b2d6c76dbe6..1118395257a 100644 --- a/src/apr/azext_apr/commands/task.py +++ b/src/apr/azext_apr/commands/task.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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 ( diff --git a/src/apr/azext_apr/server/__init__.py b/src/apr/azext_apr/server/__init__.py index c55a2a7e03f..10aa3780d02 100644 --- a/src/apr/azext_apr/server/__init__.py +++ b/src/apr/azext_apr/server/__init__.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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 ( diff --git a/src/apr/azext_apr/server/_data_plane.py b/src/apr/azext_apr/server/_data_plane.py index dbdd0437df9..d50bb3a7de9 100644 --- a/src/apr/azext_apr/server/_data_plane.py +++ b/src/apr/azext_apr/server/_data_plane.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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, diff --git a/src/apr/azext_apr/server/_paging.py b/src/apr/azext_apr/server/_paging.py index 8ed250a51d7..a0ca0e9ddd7 100644 --- a/src/apr/azext_apr/server/_paging.py +++ b/src/apr/azext_apr/server/_paging.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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 diff --git a/src/apr/azext_apr/server/_polling.py b/src/apr/azext_apr/server/_polling.py index e7fd95ddc54..20c57e15507 100644 --- a/src/apr/azext_apr/server/_polling.py +++ b/src/apr/azext_apr/server/_polling.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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 diff --git a/src/apr/azext_apr/server/_resolve.py b/src/apr/azext_apr/server/_resolve.py index 40d8edc0916..1cd99c75c53 100644 --- a/src/apr/azext_apr/server/_resolve.py +++ b/src/apr/azext_apr/server/_resolve.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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 diff --git a/src/apr/azext_apr/tests/__init__.py b/src/apr/azext_apr/tests/__init__.py index e69de29bb2d..66794229797 100644 --- a/src/apr/azext_apr/tests/__init__.py +++ 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. +# -------------------------------------------------------------------------------------------- \ No newline at end of file diff --git a/src/apr/azext_apr/tests/latest/__init__.py b/src/apr/azext_apr/tests/latest/__init__.py index e69de29bb2d..66794229797 100644 --- a/src/apr/azext_apr/tests/latest/__init__.py +++ 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. +# -------------------------------------------------------------------------------------------- \ No newline at end of file diff --git a/src/apr/azext_apr/tests/latest/test_apr_scenario.py b/src/apr/azext_apr/tests/latest/test_apr_scenario.py index 9beef59b3a9..c89bf138240 100644 --- a/src/apr/azext_apr/tests/latest/test_apr_scenario.py +++ b/src/apr/azext_apr/tests/latest/test_apr_scenario.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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 diff --git a/src/apr/azext_apr/tests/test_data_plane.py b/src/apr/azext_apr/tests/test_data_plane.py index 94bf8b32d52..77280c20dbf 100644 --- a/src/apr/azext_apr/tests/test_data_plane.py +++ b/src/apr/azext_apr/tests/test_data_plane.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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") diff --git a/src/apr/azext_apr/tests/test_data_plane_composition.py b/src/apr/azext_apr/tests/test_data_plane_composition.py index dc361d31379..5bf96eb9ccc 100644 --- a/src/apr/azext_apr/tests/test_data_plane_composition.py +++ b/src/apr/azext_apr/tests/test_data_plane_composition.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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 diff --git a/src/apr/azext_apr/tests/test_distro.py b/src/apr/azext_apr/tests/test_distro.py index 2dfcd869ed0..3b84ed48cf2 100644 --- a/src/apr/azext_apr/tests/test_distro.py +++ b/src/apr/azext_apr/tests/test_distro.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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") diff --git a/src/apr/azext_apr/tests/test_extension.py b/src/apr/azext_apr/tests/test_extension.py index f338b72acde..dad01bc626d 100644 --- a/src/apr/azext_apr/tests/test_extension.py +++ b/src/apr/azext_apr/tests/test_extension.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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") diff --git a/src/apr/azext_apr/tests/test_package.py b/src/apr/azext_apr/tests/test_package.py index 868a6c0539f..545ea2afe0d 100644 --- a/src/apr/azext_apr/tests/test_package.py +++ b/src/apr/azext_apr/tests/test_package.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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") diff --git a/src/apr/azext_apr/tests/test_paging.py b/src/apr/azext_apr/tests/test_paging.py index b539ef06e14..b4695f42996 100644 --- a/src/apr/azext_apr/tests/test_paging.py +++ b/src/apr/azext_apr/tests/test_paging.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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") diff --git a/src/apr/azext_apr/tests/test_polling.py b/src/apr/azext_apr/tests/test_polling.py index 236beee61ed..76859f2b118 100644 --- a/src/apr/azext_apr/tests/test_polling.py +++ b/src/apr/azext_apr/tests/test_polling.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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") diff --git a/src/apr/azext_apr/tests/test_publication.py b/src/apr/azext_apr/tests/test_publication.py index b73d9bf732e..f73c0b5c743 100644 --- a/src/apr/azext_apr/tests/test_publication.py +++ b/src/apr/azext_apr/tests/test_publication.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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") diff --git a/src/apr/azext_apr/tests/test_registry.py b/src/apr/azext_apr/tests/test_registry.py index 5a6f96ec7d6..b4f23ec04fe 100644 --- a/src/apr/azext_apr/tests/test_registry.py +++ b/src/apr/azext_apr/tests/test_registry.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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 diff --git a/src/apr/azext_apr/tests/test_remote.py b/src/apr/azext_apr/tests/test_remote.py index d9527b8aa36..f262a0055c2 100644 --- a/src/apr/azext_apr/tests/test_remote.py +++ b/src/apr/azext_apr/tests/test_remote.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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") diff --git a/src/apr/azext_apr/tests/test_repository.py b/src/apr/azext_apr/tests/test_repository.py index ca67f0c3d56..daef9d6580d 100644 --- a/src/apr/azext_apr/tests/test_repository.py +++ b/src/apr/azext_apr/tests/test_repository.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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") diff --git a/src/apr/azext_apr/tests/test_repository_package.py b/src/apr/azext_apr/tests/test_repository_package.py index 9fbd6ffadb8..e7855ed2e93 100644 --- a/src/apr/azext_apr/tests/test_repository_package.py +++ b/src/apr/azext_apr/tests/test_repository_package.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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") diff --git a/src/apr/azext_apr/tests/test_repository_release.py b/src/apr/azext_apr/tests/test_repository_release.py index f053cac044a..98bbaab029b 100644 --- a/src/apr/azext_apr/tests/test_repository_release.py +++ b/src/apr/azext_apr/tests/test_repository_release.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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") diff --git a/src/apr/azext_apr/tests/test_resolve.py b/src/apr/azext_apr/tests/test_resolve.py index d0ed802d8ef..bc84454b012 100644 --- a/src/apr/azext_apr/tests/test_resolve.py +++ b/src/apr/azext_apr/tests/test_resolve.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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") diff --git a/src/apr/azext_apr/tests/test_task.py b/src/apr/azext_apr/tests/test_task.py index 111b7344a59..8a5457cecfe 100644 --- a/src/apr/azext_apr/tests/test_task.py +++ b/src/apr/azext_apr/tests/test_task.py @@ -1,3 +1,8 @@ +# -------------------------------------------------------------------------------------------- +# 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") diff --git a/src/apr/setup.py b/src/apr/setup.py index e59d546ab2c..6e1720171e8 100644 --- a/src/apr/setup.py +++ b/src/apr/setup.py @@ -1,7 +1,9 @@ #!/usr/bin/env python -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. +# -------------------------------------------------------------------------------------------- +# 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 From f0f6d3b733e999ce18890edb33e5c6b8cf5decc2 Mon Sep 17 00:00:00 2001 From: Kai Daniels Date: Wed, 16 Sep 2026 00:13:08 +0000 Subject: [PATCH 10/12] register APR service metadata Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 99135581-9cf9-4a99-a7b3-63692da88967 --- src/apr/azext_apr/_client_factory.py | 2 ++ src/apr/azext_apr/tests/__init__.py | 2 +- src/apr/azext_apr/tests/latest/__init__.py | 2 +- src/service_name.json | 5 +++++ 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/apr/azext_apr/_client_factory.py b/src/apr/azext_apr/_client_factory.py index a57b10cf1c2..b238cfee19c 100644 --- a/src/apr/azext_apr/_client_factory.py +++ b/src/apr/azext_apr/_client_factory.py @@ -3,6 +3,8 @@ # 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 diff --git a/src/apr/azext_apr/tests/__init__.py b/src/apr/azext_apr/tests/__init__.py index 66794229797..34913fb394d 100644 --- a/src/apr/azext_apr/tests/__init__.py +++ b/src/apr/azext_apr/tests/__init__.py @@ -1,4 +1,4 @@ # -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- \ No newline at end of file +# -------------------------------------------------------------------------------------------- diff --git a/src/apr/azext_apr/tests/latest/__init__.py b/src/apr/azext_apr/tests/latest/__init__.py index 66794229797..34913fb394d 100644 --- a/src/apr/azext_apr/tests/latest/__init__.py +++ b/src/apr/azext_apr/tests/latest/__init__.py @@ -1,4 +1,4 @@ # -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- \ No newline at end of file +# -------------------------------------------------------------------------------------------- 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", From b0c63f2fd861ada5bd4e90d2b03164df54b583c0 Mon Sep 17 00:00:00 2001 From: Kai Daniels Date: Wed, 16 Sep 2026 00:14:40 +0000 Subject: [PATCH 11/12] align APR client factory formatting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 99135581-9cf9-4a99-a7b3-63692da88967 --- src/apr/azext_apr/_client_factory.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/apr/azext_apr/_client_factory.py b/src/apr/azext_apr/_client_factory.py index b238cfee19c..a7f9aca1c60 100644 --- a/src/apr/azext_apr/_client_factory.py +++ b/src/apr/azext_apr/_client_factory.py @@ -4,7 +4,6 @@ # -------------------------------------------------------------------------------------------- - def cf_apr(cli_ctx, *_): del cli_ctx # Control-plane (ARM) client factory. The az command pipeline calls this From 72e11b411dbb8252ab9ae294077be0f19de5cfdc Mon Sep 17 00:00:00 2001 From: Kai Daniels Date: Wed, 16 Sep 2026 19:11:57 +0000 Subject: [PATCH 12/12] address APR review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 99135581-9cf9-4a99-a7b3-63692da88967 --- src/apr/azext_apr/commands/repository.py | 2 +- src/apr/azext_apr/tests/latest/test_apr_scenario.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/apr/azext_apr/commands/repository.py b/src/apr/azext_apr/commands/repository.py index 09e0ab3cb2c..100e010e1cc 100644 --- a/src/apr/azext_apr/commands/repository.py +++ b/src/apr/azext_apr/commands/repository.py @@ -111,7 +111,7 @@ def _confirm_mirror_sync(command_args): "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 --yes.") + logger.warning("Unable to prompt for confirmation as no tty available. Use --confirm.") return False diff --git a/src/apr/azext_apr/tests/latest/test_apr_scenario.py b/src/apr/azext_apr/tests/latest/test_apr_scenario.py index c89bf138240..6386e61ddbd 100644 --- a/src/apr/azext_apr/tests/latest/test_apr_scenario.py +++ b/src/apr/azext_apr/tests/latest/test_apr_scenario.py @@ -16,6 +16,7 @@ ("apr", "registry", "list"), ("apr", "registry", "show"), ("apr", "registry", "update"), + ("apr", "registry", "wait"), ("apr", "repository"), ("apr", "repository", "create"), ("apr", "repository", "delete"),