diff --git a/docs/az-aro-python-development.md b/docs/az-aro-python-development.md index ea2752d0292..92afe7d712b 100644 --- a/docs/az-aro-python-development.md +++ b/docs/az-aro-python-development.md @@ -1,36 +1,34 @@ # `az aro` Python development -There are currently two codebases for the `az aro` command: +There are two codebases for the `az aro` command: -* The [downstream](https://github.com/Azure/ARO-RP/tree/master/python/az/aro) `az - aro` extension in this repo +- the [downstream] `az aro` extension in this repo, and +- the [upstream] `az aro` module -* The - [upstream](https://github.com/Azure/azure-cli/tree/dev/src/azure-cli/azure/cli/command_modules/aro) - `az aro` module. +[upstream]: https://github.com/Azure/azure-cli/tree/dev/src/azure-cli/azure/cli/command_modules/aro +[downstream]: https://github.com/Azure/ARO-RP/tree/master/python/az/aro -The upstream `az aro` command module is distributed with `az` and is -automatically present in the Azure cloud shell. Development/maintenance of this -module within the Azure CLI is handled the same as any other first-party Azure -CLI module. You can read more about how command modules are authored and -maintained [here](https://github.com/Azure/azure-cli/tree/dev/doc/authoring_command_modules). +The upstream `az aro` command module is distributed with `az` and is present in +the Azure cloud shell. Development/maintenance of this module within the Azure +CLI is treated as a first-party Azure CLI module. Please see [upstream +documentation] for more info on command module authoring and maintenance. -The downstream extension can be installed by an end user to override the module -(e.g. to use preview features). We also use the extension directly from this -codebase for development and testing of new API versions. Customers are +[upstream documentation]: https://github.com/Azure/azure-cli/tree/dev/doc/authoring_command_modules + +The downstream extension may be installed by an end user to override the command module +(for example: to use preview features). We use the extension directly from this +codebase for development and testing of new API versions. Customers are advised to use the upstream CLI. You can read more about how extensions are authored [here](https://github.com/Azure/azure-cli/blob/dev/doc/extensions/authoring.md), and some of the differences between extensions and command modules [here](https://github.com/Azure/azure-cli/blob/dev/doc/extensions/faq.md). - -We aim for the upstream and downstream codebase to be as closely in sync as +We aim for the upstream and downstream codebase to be as synchronized as possible. +## Dev environment setup for the `az aro` **command** -## Dev environment setup for the `az aro` command - -```bash +```sh git clone https://github.com/Azure/azure-cli.git cd azure-cli @@ -41,12 +39,12 @@ azdev setup -c ``` The `az aro` command is located in `src/azure-cli/azure/cli/command_modules/aro` -with tests in corresponding `tests` folder. +with tests in the corresponding `tests` folder. -## Dev environment setup for the `az aro` extension +## Dev environment setup for the `az aro` **extension** -```bash +```sh git clone https://github.com/Azure/ARO-RP.git cd ARO-RP @@ -54,9 +52,9 @@ make pyenv ``` The `az aro` extension is located in `python/az/aro/azext_aro` with tests in -corresponding `tests` folder. +the corresponding `tests` folder. -There is a very useful guide for authoring commands in the +There is a useful guide for authoring commands in the [azure-cli](https://github.com/Azure/azure-cli/tree/dev/doc/authoring_command_modules) repository. @@ -101,12 +99,9 @@ Recorded tests are run when the `--live` flag is not passed. ## Contributing Changes made to the `az aro` command will be made to `Azure/ARO-RP` **before** -being contributed -[upstream](https://github.com/Azure/azure-cli/tree/dev/src/azure-cli/azure/cli/command_modules/aro). -This will allow synchronization between the two repositories to be consistent. -Once changes are made and approved to -[downstream](https://github.com/Azure/ARO-RP/tree/master/python/az/aro), pull -requests to upstream can then be opened. +being contributed [upstream]. This will allow synchronization between the two +repositories to be consistent. Once changes are made and approved to +[downstream], pull requests to upstream can then be opened. When contributing to the `az aro` command upstream, imports will be different for testing. When submitting pull requests, tests that include `azdev style`, @@ -120,7 +115,7 @@ documentation. When upstream azure-cli releases a new version that we need to adopt, follow these steps to regenerate `requirements.txt`: -```bash +```sh # 1. Remove existing pyenv rm -rf pyenv diff --git a/python/README.md b/python/README.md new file mode 100644 index 00000000000..38fdd9b1570 --- /dev/null +++ b/python/README.md @@ -0,0 +1,43 @@ +# ARO-RP/python + +TODO: migrate the rest of this content to `docs/az-aro-python-development.md`. + +Welcome to the ARO command-line extension. + +Code in these directories make up the commands and logic for the `az aro` CLI. + +## Structure + +### `python/az/aro/azext_aro` + +This is where the majority of hand-written code lives. + +highlights: + +- `__init__.py` - Azure CLI entrypoint +- `commands.py` - ARO extension command structure definitions +- `custom.py` - Logic and helper methods for subcommands +- `_help.py` - Help output definitions + +### `python/az/aro/build` + +Locally generated code + +### `python/az/aro/azext_aro/aaz` + +Generated code vendored from AZ tooling (upstream?) that we occasionally change +when we need new classes or functions. + +### `python/az/client` + +More vendored code + +## Prerequisites + +Ensure you have `python` installed. I recommend using `asdf` +([ref](https://asdf-vm.com/)) if you aren't already. + +## Setup + +From the repo root, run `make pyenv` to create and setup our local python +virtual environment. diff --git a/python/az/aro/azext_aro/_help.py b/python/az/aro/azext_aro/_help.py index ed56198c469..a128a36e21f 100644 --- a/python/az/aro/azext_aro/_help.py +++ b/python/az/aro/azext_aro/_help.py @@ -109,3 +109,9 @@ short-summary: Get required identities long-summary: Get required identities for creating a cluster with managed identities. """ + +helps['aro identity create-required'] = """ + type: command + short-summary: Create required identities + long-summary: Create required identities to prepare for creating a cluster with managed identities. +""" diff --git a/python/az/aro/azext_aro/commands.py b/python/az/aro/azext_aro/commands.py index a4f05975c11..73992c9db8b 100644 --- a/python/az/aro/azext_aro/commands.py +++ b/python/az/aro/azext_aro/commands.py @@ -32,4 +32,5 @@ def load_command_table(loader, _): g.custom_command('validate', 'aro_validate') with loader.command_group('aro identity', aro_sdk, client_factory=cf_aro) as g: + g.custom_command('create-required', 'aro_identity_create_required', supports_no_wait=False) g.custom_command('get-required', 'aro_identity_get_required', supports_no_wait=False) diff --git a/python/az/aro/azext_aro/custom.py b/python/az/aro/azext_aro/custom.py index 8dcaeff1987..3ba07f63cc5 100644 --- a/python/az/aro/azext_aro/custom.py +++ b/python/az/aro/azext_aro/custom.py @@ -2,10 +2,13 @@ # Licensed under the Apache License 2.0. import collections -import random +import enum import os -from base64 import b64decode +import random import textwrap +import uuid + +from base64 import b64decode import azext_aro.vendored_sdks.azure.mgmt.redhatopenshift.v2025_07_25.models as openshiftcluster @@ -40,8 +43,10 @@ ) from azext_aro._validators import validate_subnets from azext_aro._dynamic_validators import validate_cluster_create, validate_cluster_delete +from azext_aro.aaz.latest.identity import Create as identity_create from azext_aro.aaz.latest.identity import Delete as identity_delete from azext_aro.aaz.latest.network.vnet.subnet import Show as subnet_show +from azext_aro.aaz.latest.role.assignment import Create as roleassignment_create from knack.log import get_logger @@ -57,6 +62,13 @@ FP_SERVICE_PRINCIPAL_ROLE = "42f3c60f-e7b1-46d7-ba56-6de681664342" +class Scope(enum.Enum): + """Role Assignment Scope""" + VNET = enum.auto() + MASTER_SUBNET = enum.auto() + WORKER_SUBNET = enum.auto() + + def rp_mode_development(): return os.environ.get('RP_MODE', '').lower() == 'development' @@ -767,64 +779,121 @@ def aro_identity_get_required(*, worker_subnet, vnet, vnet_resource_group_name=None) -> None: - - if not vnet_resource_group_name: - vnet_resource_group_name = resource_group_name - - if version not in aro_get_versions(client, location): - raise ValidationError("--version invalid") - - role_set = None - for tset in client.platform_workload_identity_role_sets.list(location): - if version.startswith(tset.open_shift_version): - role_set = tset - - if not role_set: - raise RuntimeError("Could not find identity requirements for provided version and location.") + _validate_version(client, version, location) + role_set = _get_pwi_role_set(client, version, location) logger.warning("Use the following commands to create the required managed identities:") - print_identity_create_cmd(resource_group_name, 'aro-cluster', location) + _print_identity_create_cmd(resource_group_name, 'aro-cluster', location) for role in role_set.platform_workload_identity_roles: - print_identity_create_cmd(resource_group_name, role.operator_name, location) + _print_identity_create_cmd(resource_group_name, role.operator_name, location) + sub_id = get_subscription_id(cmd.cli_ctx) auth_client = get_mgmt_service_client(cmd.cli_ctx, ResourceType.MGMT_AUTHORIZATION) logger.warning("\nUse the following commands to create the required role assignments" " over virtual network and/or subnets:") for role in role_set.platform_workload_identity_roles: - definition = auth_client.role_definitions.get_by_id(role.role_definition_id) - scopes: list[str] = [] - for permissions in definition.permissions: - for action in permissions.actions: - if action.startswith("Microsoft.Network/virtualNetworks/subnets/"): - scopes = [master_subnet, worker_subnet] - elif action.startswith("Microsoft.Network/virtualNetworks/"): - scopes = [vnet] - break - - for scope in scopes: - print_role_assignment_create_cmd( + for scope in _determine_scopes(cmd, role): + match scope: + case Scope.MASTER_SUBNET: + scopestr = master_subnet + case Scope.WORKER_SUBNET: + scopestr = worker_subnet + case Scope.VNET: + scopestr = vnet + + _print_role_assignment_create_cmd( f"$(az identity show -g '{resource_group_name}' -n '{role.operator_name}' --query principalId -o tsv)", - role.role_definition_id, - scope + # NOTE: i don't know why, but role.role_definition_id is not + # the full resource ID + f"{resource_id(subscription=sub_id)}{role.role_definition_id}", + scopestr ) logger.warning("\nUse the following commands to create the required role assignments" " over platform workload identities:") for role in role_set.platform_workload_identity_roles: - print_role_assignment_create_cmd( + _print_role_assignment_create_cmd( f"$(az identity show -g '{resource_group_name}' -n 'aro-cluster' --query principalId -o tsv)", ARO_FEDERATED_CREDENTIAL_ROLE, f"$(az identity show -g '{resource_group_name}' -n '{role.operator_name}' --query id -o tsv)" ) logger.warning("\nUse the following command to create the required role assignment over the virtual network:") - print_role_assignment_create_cmd( + _print_role_assignment_create_cmd( "$(az ad sp list --display-name 'Azure Red Hat OpenShift RP' --query '[0].id' -o tsv)", FP_SERVICE_PRINCIPAL_ROLE, vnet ) +def aro_identity_create_required(*, + cmd, + client, + resource_group_name, + location, + version, + master_subnet, + worker_subnet, + vnet, + vnet_resource_group_name=None) -> list: + + # FIXME: figure out how to do the fancy "in progress" spinner + + created_identities = list() + created_roleassignments = list() + + _validate_version(client, version, location) + role_set = _get_pwi_role_set(client, version, location) + + # cluster top-level identity + cluster_id = _identity_create(cmd, location, resource_group_name, "aro-cluster") + created_identities.append(cluster_id) + + sub_id = get_subscription_id(cmd.cli_ctx) + for role in role_set.platform_workload_identity_roles: + # cluster identities + identity = _identity_create(cmd, location, resource_group_name, role.operator_name) + created_identities.append(identity) + + # vnet/subnet role assignments + for scope in _determine_scopes(cmd, role): + match scope: + case Scope.MASTER_SUBNET: + scopestr = master_subnet + case Scope.WORKER_SUBNET: + scopestr = worker_subnet + case Scope.VNET: + scopestr = vnet + + id = identity["principalId"] + defn = f"{resource_id(subscription=sub_id)}{role.role_definition_id}" + ra = _roleassignment_create(cmd, id, defn, scopestr) + created_roleassignments.append(ra) + + # platform workload identity role assignment + id = cluster_id["principalId"] + defn = resource_id( + subscription=get_subscription_id(cmd.cli_ctx), + namespace="Microsoft.Authorization", + type="roleDefinitions", + name=ARO_FEDERATED_CREDENTIAL_ROLE + ) + topra = _roleassignment_create(cmd, id, defn, identity["id"]) + created_roleassignments.append(topra) + + id = AADManager(cmd.cli_ctx).get_service_principal_id(FP_CLIENT_ID) + defn = resource_id( + subscription=get_subscription_id(cmd.cli_ctx), + namespace="Microsoft.Authorization", + type="roleDefinitions", + name=FP_SERVICE_PRINCIPAL_ROLE, + ) + spra = _roleassignment_create(cmd, id, defn, vnet) + created_roleassignments.append(spra) + + return created_identities + created_roleassignments + + def ensure_resource_permissions(cli_ctx, oc, fail, sp_obj_ids): try: # Get cluster resources we need to assign permissions on, sort to ensure the same order of operations @@ -855,12 +924,20 @@ def ensure_resource_permissions(cli_ctx, oc, fail, sp_obj_ids): assign_role_to_resource(cli_ctx, resource, sp_id, role) -def print_identity_create_cmd(group, name, location): +def _get_pwi_role_set(client, version, location): + for rset in client.platform_workload_identity_role_sets.list(location): + if version.startswith(rset.open_shift_version): + return rset + + raise RuntimeError("Could not find identity requirements.") + + +def _print_identity_create_cmd(group, name, location) -> None: msg = f" az identity create -g \"{group}\" -n \"{name}\" -l \"{location}\"" logger.warning(msg) -def print_role_assignment_create_cmd(assignee, role, scope): +def _print_role_assignment_create_cmd(assignee, role, scope) -> None: msg = [ " az role assignment create", f"--assignee-object-id \"{assignee}\"", @@ -869,3 +946,43 @@ def print_role_assignment_create_cmd(assignee, role, scope): f"--scope \"{scope}\"", ] logger.warning(" ".join(msg)) + + +def _validate_version(client, version, location) -> None: + if version not in aro_get_versions(client, location): + raise InvalidArgumentValueError("--version invalid") + + +def _identity_create(cmd, location, group, name): + return identity_create(cli_ctx=cmd.cli_ctx)(command_args={ + "location": location, + "resource_group": group, + "resource_name": name, + }) + + +def _roleassignment_create(cmd, principal_id, role_definition_id, scope, name=None): + if not name: + name = str(uuid.uuid4()) + + return roleassignment_create(cli_ctx=cmd.cli_ctx)(command_args={ + "principal_id": principal_id, + "principal_type": "ServicePrincipal", + "role_definition_id": role_definition_id, + "scope": scope, + "role_assignment_name": name, + }) + + +def _determine_scopes(cmd, role) -> list[Scope]: + auth_client = get_mgmt_service_client(cmd.cli_ctx, ResourceType.MGMT_AUTHORIZATION) + definition = auth_client.role_definitions.get_by_id(role.role_definition_id) + + for permissions in definition.permissions: + for action in permissions.actions: + if action.startswith("Microsoft.Network/virtualNetworks/subnets/"): + return [Scope.MASTER_SUBNET, Scope.WORKER_SUBNET] + elif action.startswith("Microsoft.Network/virtualNetworks/"): + return [Scope.VNET] + + raise RuntimeError("Could not determine permissions.")