diff --git a/doc/private_endpoint_connection_command_guideline.md b/doc/private_endpoint_connection_command_guideline.md new file mode 100644 index 00000000000..f64b43661bc --- /dev/null +++ b/doc/private_endpoint_connection_command_guideline.md @@ -0,0 +1,100 @@ +## Command Guideline + +#### Private Endpoint Connection + +- The parent resource should expose a single command group called `private-endpoint-connection` with four commands: `approve`, `reject`, `delete`, `show`. +- If `approve`, `reject` and `delete` commands are long running operations, please also provide `... private-endpoint-connection wait` command and support `--no-wait` in `approve` and `reject` commands. +- The `... private-endpoint-connection approve` command should look similar to the following, depending on which features are supported by the service. +``` +Arguments + --description : Comments for the approval. + --id : The ID of the private endpoint connection associated with the Key + Vault(Storage Account). If specified --vault-name and --name/-n, this should be omitted. + --name -n : The name of the private endpoint connection associated with the Key + Vault(Storage Account). Required if --id is not specified. + --resource-group -g : Proceed only if Key Vault(Storage Account) belongs to the specified resource group. + --vault-name(--account-name) : Name of the Key Vault(Storage Account). Required if --id is not specified. +``` +- The `... private-endpoint-connection reject` command should look similar to the following, depending on which features are supported by the service. +``` +Arguments + --description : Comments for the rejection. + --id : The ID of the private endpoint connection associated with the Key + Vault(Storage Account). If specified --vault-name and --name/-n, this should be omitted. + --name -n : The name of the private endpoint connection associated with the Key + Vault(Storage Account). Required if --id is not specified. + --resource-group -g : Proceed only if Key Vault(Storage Account) belongs to the specified resource group. + --vault-name(--account-name) : Name of the Key Vault(Storage Account). Required if --id is not specified. +``` +- The `... private-endpoint-connection show/delete` command should look similar to the following, depending on which features are supported by the service. +``` +Arguments + --id : The ID of the private endpoint connection associated with the Key + Vault(Storage Account). If specified --vault-name and --name/-n, this should be omitted. + --name -n : The name of the private endpoint connection associated with the Key + Vault(Storage Account). Required if --id is not specified. + --resource-group -g : Proceed only if Key Vault(Storage Account) belongs to the specified resource group. + --vault-name(--account-name) : Name of the Key Vault(Storage Account). Required if --id is not specified. +``` + +#### Private Link Resource + +- The parent resource should expose a single command group called `private-link-resource` with one commands: `list`. +- The `... private-link-resource list` command should look similar to the following, depending on which features are supported by the service. + +``` +Arguments + --vault-name(--account-name) [Required] : Name of the Key Vault(Storage Account). + --resource-group -g : Proceed only if Key Vault belongs to the specified resource group. +``` +- The output of `... private-link-resource list` should be a array instead of a dictionary. + +## Command Authoring + +Storage and keyvault modules both are good examples. Feel free to use them as reference. + +*Storage*: [PR Link](https://github.com/Azure/azure-cli/pull/12383) + +*Keyvault*: [Command Module](https://github.com/Azure/azure-cli/tree/dev/src/azure-cli/azure/cli/command_modules/keyvault) + +#### Parameters +We provide a build-in function `parse_proxy_resource_id` to parse private endpoint connection id. It can be used to support the `--id` argument. +``` +from azure.cli.core.util import parse_proxy_resource_id +pe_resource_id = "/subscriptions/0000/resourceGroups/clirg/" \ + "providers/Microsoft.Network/privateEndpoints/clipe/" \ + "privateLinkServiceConnections/peconnection" +result = parse_proxy_resource_id(pe_resource_id) +namespace.resource_group = result['resource_group'] +namespace.endpoint = result['name'] +namespace.name = result['child_name_1'] +``` +The best practice to support extra `--id` is to add extra argument in `_param.py`. Then you can use the `parse_proxy_resource_id` to parse the `--id` and delete this extra argument from the namspace. Storage's PR is a good example. +``` +with self.argument_context('storage account private-endpoint-connection {}'.format(item), resource_type=ResourceType.MGMT_STORAGE) as c: + c.extra('connection_id', options_list=['--id'], help='help='The ID of the private endpoint connection associated with the Storage Account.') +``` +``` +from azure.cli.core.util import parse_proxy_resource_id +result = parse_proxy_resource_id(namespace.connection_id) +namespace.resource_group = result['resource_group'] +namespace.endpoint = result['name'] +namespace.name = result['child_name_1'] +del namespace.connection_id +``` +#### Transform +In order to transform the output of the `list` command, we provide a transform function `gen_dict_to_list_transform`. +``` +from azure.cli.core.command.transform import gen_dict_to_list_transform +g.command('list', transform=gen_dict_to_list_transform(key='values')) +``` + +#### Test +- Integration test is mandatory. It should contain the following steps at least. + - Create a resource such as storage account or key vault. + - List all private link resources for the created resource. + - Create a private endpoint for the resource. + - Approve the private endpoint connection. + - Reject the private endpoint connection. + - Show the private endpoint connection. + - Delete the private endpoint connection. \ No newline at end of file diff --git a/src/azure-cli-core/azure/cli/core/commands/transform.py b/src/azure-cli-core/azure/cli/core/commands/transform.py index f08d7d6f459..2a5f3692290 100644 --- a/src/azure-cli-core/azure/cli/core/commands/transform.py +++ b/src/azure-cli-core/azure/cli/core/commands/transform.py @@ -63,3 +63,13 @@ def _resource_group_transform(_, **kwargs): def _x509_from_base64_to_hex_transform(_, **kwargs): _add_x509_hex(kwargs['event_data']['result']) + + +def gen_dict_to_list_transform(key='value'): + + def _dict_to_list_transform(result): + if hasattr(result, key): + return getattr(result, key) + return result + + return _dict_to_list_transform diff --git a/src/azure-cli-core/azure/cli/core/tests/test_util.py b/src/azure-cli-core/azure/cli/core/tests/test_util.py index 9e5307126f5..381f3be54c9 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_util.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_util.py @@ -14,7 +14,7 @@ from azure.cli.core.util import \ (get_file_json, truncate_text, shell_safe_json_parse, b64_to_hex, hash_string, random_string, open_page_in_browser, can_launch_browser, handle_exception, ConfiguredDefaultSetter, send_raw_request, - should_disable_connection_verify) + should_disable_connection_verify, parse_proxy_resource_id) class TestUtils(unittest.TestCase): @@ -121,6 +121,31 @@ def _run_test(length, force_lower): # Test force_lower _run_test(16, True) + def test_proxy_resource_parse(self): + mock_proxy_resource_id = "/subscriptions/0000/resourceGroups/clirg/" \ + "providers/Microsoft.Network/privateEndpoints/cli/" \ + "privateLinkServiceConnections/cliPec/privateLinkServiceConnectionsSubTypes/cliPecSubName" + result = parse_proxy_resource_id(mock_proxy_resource_id) + valid_dict_values = { + 'subscription': '0000', + 'resource_group': 'clirg', + 'namespace': 'Microsoft.Network', + 'type': 'privateEndpoints', + 'name': 'cli', + 'child_type_1': 'privateLinkServiceConnections', + 'child_name_1': 'cliPec', + 'child_type_2': 'privateLinkServiceConnectionsSubTypes', + 'child_name_2': 'cliPecSubName', + 'last_child_num': 2 + } + self.assertEqual(len(result.keys()), len(valid_dict_values.keys())) + for key, value in valid_dict_values.items(): + self.assertEqual(result[key], value) + + invalid_proxy_resource_id = "invalidProxyResourceID" + result = parse_proxy_resource_id(invalid_proxy_resource_id) + self.assertIsNone(result) + @mock.patch('webbrowser.open', autospec=True) @mock.patch('subprocess.Popen', autospec=True) def test_open_page_in_browser(self, sunprocess_open_mock, webbrowser_open_mock): diff --git a/src/azure-cli-core/azure/cli/core/util.py b/src/azure-cli-core/azure/cli/core/util.py index 1839cceacf7..8201a99e615 100644 --- a/src/azure-cli-core/azure/cli/core/util.py +++ b/src/azure-cli-core/azure/cli/core/util.py @@ -12,6 +12,7 @@ import platform import ssl import six +import re from six.moves.urllib.request import urlopen # pylint: disable=import-error from knack.log import get_logger @@ -29,6 +30,12 @@ 'Please add this certificate to the trusted CA bundle: https://github.com/Azure/azure-cli/blob/dev/doc/use_cli_effectively.md#working-behind-a-proxy. ' 'Error detail: {}') +_PROXYID_RE = re.compile( + '(?i)/subscriptions/(?P[^/]*)(/resourceGroups/(?P[^/]*))?' + '(/providers/(?P[^/]*)/(?P[^/]*)/(?P[^/]*)(?P.*))?') + +_CHILDREN_RE = re.compile('(?i)/(?P[^/]*)/(?P[^/]*)') + def handle_exception(ex): # pylint: disable=too-many-return-statements # For error code, follow guidelines at https://docs.python.org/2/library/sys.html#sys.exit, @@ -654,3 +661,39 @@ def _ssl_context(): def urlretrieve(url): req = urlopen(url, context=_ssl_context()) return req.read() + + +def parse_proxy_resource_id(rid): + """Parses a resource_id into its various parts. + + Return an empty dictionary, if invalid resource id. + + :param rid: The resource id being parsed + :type rid: str + :returns: A dictionary with with following key/value pairs (if found): + + - subscription: Subscription id + - resource_group: Name of resource group + - namespace: Namespace for the resource provider (i.e. Microsoft.Compute) + - type: Type of the root resource (i.e. virtualMachines) + - name: Name of the root resource + - child_type_{level}: Type of the child resource of that level + - child_name_{level}: Name of the child resource of that level + - last_child_num: Level of the last child + + :rtype: dict[str,str] + """ + if not rid: + return {} + match = _PROXYID_RE.match(rid) + if match: + result = match.groupdict() + children = _CHILDREN_RE.finditer(result['children'] or '') + count = None + for count, child in enumerate(children): + result.update({ + key + '_%d' % (count + 1): group for key, group in child.groupdict().items()}) + result['last_child_num'] = count + 1 if isinstance(count, int) else None + result.pop('children', None) + return {key: value for key, value in result.items() if value is not None} + return None diff --git a/src/azure-cli/azure/cli/command_modules/network/_params.py b/src/azure-cli/azure/cli/command_modules/network/_params.py index 81117b90cc7..0f9021071be 100644 --- a/src/azure-cli/azure/cli/command_modules/network/_params.py +++ b/src/azure-cli/azure/cli/command_modules/network/_params.py @@ -755,7 +755,7 @@ def load_arguments(self, _): c.argument('subnet', validator=get_subnet_validator(), help='Name or ID of an existing subnet. If name is specified, also specify --vnet-name.', id_part=None) c.argument('virtual_network_name', help='The virtual network (VNet) associated with the subnet (Omit if supplying a subnet id).', metavar='', id_part=None) c.argument('private_connection_resource_id', help='The resource id of which private enpoint connect to') - c.argument('group_ids', nargs='+', help='The ID(s) of the group(s) obtained from the remote resource that this private endpoint should connect to. You can use "az keyvault(storage/etc) private-endpoint show" to obtain the list of group ids.') + c.argument('group_ids', nargs='+', help='The ID(s) of the group(s) obtained from the remote resource that this private endpoint should connect to. You can use "az keyvault(storage/etc) private-link-resource list" to obtain the list of group ids.') c.argument('request_message', help='A message passed to the owner of the remote resource with this connection request. Restricted to 140 chars.') c.argument('manual_request', help='Use manual request to establish the connection', arg_type=get_three_state_flag()) c.argument('connection_name', help='Name of the private link service connection.')