diff --git a/src/aaz_dev/cli/controller/az_client_generator.py b/src/aaz_dev/cli/controller/az_client_generator.py index 680d20ab..75eda598 100644 --- a/src/aaz_dev/cli/controller/az_client_generator.py +++ b/src/aaz_dev/cli/controller/az_client_generator.py @@ -79,6 +79,10 @@ def __init__(self, endpoints): def type(self): return self._endpoints.type + @property + def cloud_metadata(self): + return self._endpoints.cloud_metadata + def iter_hosts(self): for template in self._endpoints.templates: yield template.cloud, template.template @@ -108,4 +112,3 @@ def __init__(self, endpoints, cmd_ctx): @property def type(self): return self._endpoints.type - diff --git a/src/aaz_dev/cli/templates/aaz/profile/_clients.py.j2 b/src/aaz_dev/cli/templates/aaz/profile/_clients.py.j2 index 8be2be95..7ddd522b 100644 --- a/src/aaz_dev/cli/templates/aaz/profile/_clients.py.j2 +++ b/src/aaz_dev/cli/templates/aaz/profile/_clients.py.j2 @@ -21,8 +21,13 @@ class {{ client.cls_name }}(AAZBaseClient): CloudNameEnum.{{cloud}}: {{ template|constant_convert }}, {%- endfor %} } + {%- if client.endpoints.cloud_metadata %} + _CLOUD_HOST_METADATA_INDEX = {{ client.endpoints.cloud_metadata.selector_index|constant_convert }} + {%- if client.endpoints.cloud_metadata.prefix_template %} + _CLOUD_HOST_METADATA_PREFIX_TEMPLATE = {{ client.endpoints.cloud_metadata.prefix_template|constant_convert }} + {%- endif %} + {%- endif %} {%- elif client.endpoints.type == "http-operation" %} - {%- endif %} _AAD_CREDENTIAL_SCOPES = [ @@ -34,13 +39,25 @@ class {{ client.cls_name }}(AAZBaseClient): @classmethod def _build_base_url(cls, ctx, **kwargs): {%- if client.endpoints.type == "template" %} - return cls._CLOUD_HOST_TEMPLATES.get(ctx.cli_ctx.cloud.name, None) + {%- if client.endpoints.cloud_metadata %} + {%- if client.endpoints.cloud_metadata.prefix_template %} + endpoint = None + suffix = cls.get_cloud_suffix(ctx, cls._CLOUD_HOST_METADATA_INDEX) + if suffix: + endpoint = cls._CLOUD_HOST_METADATA_PREFIX_TEMPLATE + suffix + {%- else %} + endpoint = cls.get_cloud_endpoint(ctx, cls._CLOUD_HOST_METADATA_INDEX) + {%- endif %} + {%- endif %} + if not endpoint: + endpoint = cls._CLOUD_HOST_TEMPLATES.get(ctx.cli_ctx.cloud.name, None) + return endpoint {%- elif client.endpoints.type == "http-operation" %} cls._fetch_endpoint(ctx, **kwargs) - host = ctx.selectors.{{client.endpoints.selector.name}}.required().to_serialized_data() - if not isinstance(host, str): - raise ValueError(f"Invalid host value: '{host}'") - return host + endpoint = ctx.selectors.{{client.endpoints.selector.name}}.required().to_serialized_data() + if not isinstance(endpoint, str): + raise ValueError(f"Invalid host value type: '{type(endpoint)}' expected 'str'") + return endpoint {%- endif %} @classmethod diff --git a/src/aaz_dev/cli/tests/common.py b/src/aaz_dev/cli/tests/common.py index eb92574e..6d62a498 100644 --- a/src/aaz_dev/cli/tests/common.py +++ b/src/aaz_dev/cli/tests/common.py @@ -2810,6 +2810,10 @@ def prepare_monitor_metrics_aaz_2023_05_01_preview(self, ws_name): "template": "https://{region}.metrics.monitor.cloudapi.de" }, ], + "cloudMetadata": { + "selectorIndex": "suffixes.monitorMetrics", + "prefixTemplate": "https://{region}", + } }) self.assertTrue(rv.status_code == 200) diff --git a/src/aaz_dev/command/api/editor.py b/src/aaz_dev/command/api/editor.py index aa5ec18e..5ac91cb9 100644 --- a/src/aaz_dev/command/api/editor.py +++ b/src/aaz_dev/command/api/editor.py @@ -128,6 +128,7 @@ def editor_workspace_client_config(name): cfg_editor = manager.create_cfg_editor( auth=data['auth'], templates=data.get('templates', None), + cloud_medadata=data.get('cloudMetadata', None), arm_resource=data.get('resource', None), ) manager.save() @@ -136,6 +137,7 @@ def editor_workspace_client_config(name): result = cfg_editor.cfg.to_primitive() return jsonify(result) + @bp.route("/Workspaces//ClientConfig/AAZ/Compare", methods=("POST",)) def compare_workspace_client_config_version_with_aaz(name): manager = WorkspaceManager(name) diff --git a/src/aaz_dev/command/controller/workspace_client_cfg_editor.py b/src/aaz_dev/command/controller/workspace_client_cfg_editor.py index 315d0a01..6a99e864 100644 --- a/src/aaz_dev/command/controller/workspace_client_cfg_editor.py +++ b/src/aaz_dev/command/controller/workspace_client_cfg_editor.py @@ -5,7 +5,9 @@ from .client_cfg_reader import ClientCfgReader from .workspace_helper import ArgumentUpdateMixin -from command.model.configuration import CMDClientConfig, CMDDiffLevelEnum, CMDClientEndpointsByTemplate, CMDClientEndpointsByHttpOperation, CMDClientEndpoints +from command.model.configuration import (CMDClientConfig, CMDDiffLevelEnum, CMDClientEndpointsByTemplate, + CMDClientEndpointsByHttpOperation, CMDClientEndpoints, + CMDClientEndpointCloudMetadataTemplate) logger = logging.getLogger('backend') @@ -50,10 +52,12 @@ def new_client_cfg(cls, plane, auth, endpoints, ref_cfg: ClientCfgReader = None) return cfg_editor @classmethod - def new_client_endpoints_by_template(cls, templates): + def new_client_endpoints_by_template(cls, templates, cloud_medadata): endpoints = CMDClientEndpointsByTemplate(raw_data={ - "templates": templates + "templates": templates, }) + if cloud_medadata: + endpoints.cloud_metadata = CMDClientEndpointCloudMetadataTemplate(raw_data=cloud_medadata) endpoints.prepare() return endpoints diff --git a/src/aaz_dev/command/controller/workspace_manager.py b/src/aaz_dev/command/controller/workspace_manager.py index da8de122..7139a2de 100644 --- a/src/aaz_dev/command/controller/workspace_manager.py +++ b/src/aaz_dev/command/controller/workspace_manager.py @@ -1048,10 +1048,10 @@ def find_similar_args(self, *cmd_names, arg): # client config - def create_cfg_editor(self, auth, templates=None, arm_resource=None): + def create_cfg_editor(self, auth, templates=None, cloud_medadata=None, arm_resource=None): ref_cfg = self.load_client_cfg_editor() if templates: - endpoints = WorkspaceClientCfgEditor.new_client_endpoints_by_template(templates) + endpoints = WorkspaceClientCfgEditor.new_client_endpoints_by_template(templates, cloud_medadata) elif arm_resource: # arm_resrouce should have these keys: "module", "version", "id", "subresource" mod_names = arm_resource['module'] diff --git a/src/aaz_dev/command/model/configuration/__init__.py b/src/aaz_dev/command/model/configuration/__init__.py index 6b475de4..db4e73e5 100644 --- a/src/aaz_dev/command/model/configuration/__init__.py +++ b/src/aaz_dev/command/model/configuration/__init__.py @@ -25,7 +25,9 @@ CMDArgPromptInput, CMDPasswordArgPromptInput from ._arg_builder import CMDArgBuilder from ._arg_group import CMDArgGroup -from ._client import CMDClientConfig, CMDClientEndpoints, CMDClientEndpointsByHttpOperation, CMDClientEndpointsByTemplate, CMDClientEndpointTemplate, CMDClientAuth, CMDClientAADAuthConfig +from ._client import (CMDClientConfig, CMDClientEndpoints, CMDClientEndpointsByHttpOperation, + CMDClientEndpointsByTemplate, CMDClientEndpointTemplate, CMDClientAuth, CMDClientAADAuthConfig, + CMDClientEndpointCloudMetadataTemplate) from ._command import CMDCommand from ._command_group import CMDCommandGroup from ._condition import CMDConditionOperator, \ diff --git a/src/aaz_dev/command/model/configuration/_client.py b/src/aaz_dev/command/model/configuration/_client.py index 2afc2cb1..2dfdcd69 100644 --- a/src/aaz_dev/command/model/configuration/_client.py +++ b/src/aaz_dev/command/model/configuration/_client.py @@ -77,36 +77,16 @@ def diff(self, old, level): return diff -class CMDClientEndpointTemplate(Model): - cloud = CloudField(required=True) - - # https://{accountName}.{zone}.blob.storage.azure.net - template = StringType(required=True) - - class Options: - serialize_when_none = False - - def reformat(self, **kwargs): - parsed = urlparse(self.template) - if parsed.path: - if parsed.path == '/' and not parsed.params and not parsed.query and not parsed.fragment: - self.template = self.template.rstrip('/') - else: - raise exceptions.VerificationError('Invalid endpoints', details='"{}" contains path'.format(self.template)) - if not parsed.scheme or not parsed.netloc: - raise exceptions.VerificationError('Invalid endpoints', details='"{}" has no schema or hostname'.format(self.template)) - - def iter_placeholders(self): - parsed = urlparse(self.template) - return self._iter_placeholders(parsed.netloc) - - def _iter_placeholders(self, endpoint): +class _EndpointTemplateMixin: + @staticmethod + def _iter_placeholders(template): + endpoint = urlparse(template).netloc while True: idx = 0 required = True while idx < len(endpoint) and endpoint[idx] != '{': idx += 1 - endpoint = endpoint[idx+1:] + endpoint = endpoint[idx + 1:] if not endpoint: # not found '{' return @@ -122,6 +102,36 @@ def _iter_placeholders(self, endpoint): yield placeholder, required + @staticmethod + def _reformat(template): + parsed = urlparse(template) + if parsed.path: + if parsed.path == '/' and not parsed.params and not parsed.query and not parsed.fragment: + return template.rstrip('/') + else: + raise exceptions.VerificationError('Invalid endpoints', details='"{}" contains path'.format(template)) + if not parsed.scheme or not parsed.netloc: + raise exceptions.VerificationError('Invalid endpoints', details='"{}" has no schema or hostname'.format(template)) + + return template + + +class CMDClientEndpointTemplate(Model, _EndpointTemplateMixin): + + cloud = CloudField(required=True) + + # https://{accountName}.{zone}.blob.storage.azure.net + template = StringType(required=True) + + class Options: + serialize_when_none = False + + def iter_placeholders(self): + return self._iter_placeholders(self.template) + + def reformat(self, **kwargs): + self.template = self._reformat(self.template) + def diff(self, old, level): if type(self) is not type(old): return f"Type: {type(old)} != {type(self)}" @@ -134,6 +144,44 @@ def diff(self, old, level): return diff +class CMDClientEndpointCloudMetadataTemplate(Model, _EndpointTemplateMixin): + + selector_index = StringType( + required=True, + serialized_name="selectorIndex", + deserialize_from="selectorIndex", + ) # index, used to retrieve property in arm cloud metadata endpoints. + prefix_template = StringType( + serialized_name="prefixTemplate", + deserialize_from="prefixTemplate", + ) # prefix template, required for suffixes. + + class Options: + serialize_when_none = False + + def iter_placeholders(self): + if self.prefix_template is None: + return + for placeholder in self._iter_placeholders(self.prefix_template): + yield placeholder + + def reformat(self, **kwargs): + if self.prefix_template is None: + return + self.prefix_template = self._reformat(self.prefix_template) + + def diff(self, old, level): + if type(self) is not type(old): + return f"Type: {type(old)} != {type(self)}" + diff = {} + if level >= CMDDiffLevelEnum.BreakingChange: + if self.selector_index != old.selector_index: + diff['selector_index'] = f"{old.selector_index} != {self.selector_index}" + if self.prefix_template != old.prefix_template: + diff['prefix_template'] = f"{old.prefix_template} != {self.prefix_template}" + return diff + + class CMDClientEndpoints(Model): # properties as tags TYPE_VALUE = None # types: "template", "http-operation" @@ -182,12 +230,17 @@ class CMDClientEndpointsByTemplate(CMDClientEndpoints): TYPE_VALUE = 'template' templates = ListType(ModelType(CMDClientEndpointTemplate), required=True, min_size=1) + cloud_metadata = ModelType(CMDClientEndpointCloudMetadataTemplate, + serialized_name="cloudMetadata", + deserialize_from="cloudMetadata") params = ListType(CMDSchemaField()) def reformat(self, **kwargs): for template in self.templates: template.reformat(**kwargs) self.templates = sorted(self.templates, key=lambda e: e.cloud) + if self.cloud_metadata: + self.cloud_metadata.reformat(**kwargs) # make sure the placeholders across all the endpoints are consistent placeholders = {} @@ -202,9 +255,22 @@ def reformat(self, **kwargs): "required": required, "count": 1 } + expected_count = len(self.templates) + if self.cloud_metadata: + for placeholder, required in self.cloud_metadata.iter_placeholders(): + if placeholder in placeholders: + placeholders[placeholder]['count'] += 1 + if placeholders[placeholder]['required'] != required: + raise exceptions.VerificationError('Invalid endpoints', details='Inconsistent required for placeholder "{}" in endpoints or cloud metadata'.format(placeholder)) + else: + placeholders[placeholder] = { + "required": required, + "count": 1 + } + expected_count += 1 for item in placeholders.values(): - if item['count'] != len(self.templates): - raise exceptions.VerificationError('Invalid endpoints', details='placeholder "{}" is missed in some endpoints'.format(placeholder)) + if item['count'] != expected_count: + raise exceptions.VerificationError('Invalid endpoints', details='placeholder "{}" is missed in some endpoints or cloud metadata'.format(placeholder)) # make sure the parameters are consistent with the placeholders if self.params: @@ -229,6 +295,14 @@ def prepare(self): "required": required, "skip_url_encoding": True, }) + if self.cloud_metadata: + for placeholder, required in self.cloud_metadata.iter_placeholders(): + if placeholder not in params: + params[placeholder] = CMDStringSchema({ + "name": placeholder, + "required": required, + "skip_url_encoding": True, + }) self.params = sorted(params.values(), key=lambda p: p.name) or None def generate_args(self, ref_args): @@ -247,6 +321,12 @@ def diff(self, old, level): return f"Type: {type(old)} != {type(self)}" diff = {} if level >= CMDDiffLevelEnum.BreakingChange: + if (not self.cloud_metadata) != (not old.cloud_metadata): + diff['cloud_metadata'] = "Add cloud metadata" if self.cloud_metadata else "Remove cloud metadata" + elif self.cloud_metadata: + if cloud_metadata_diff := self.cloud_metadata.diff(old.cloud_metadata, level): + diff['cloud_metadata'] = cloud_metadata_diff + if len(self.templates) != len(old.templates): diff['templates'] = "template not match" else: diff --git a/src/aaz_dev/command/tests/api_tests/test_editor.py b/src/aaz_dev/command/tests/api_tests/test_editor.py index d5a75eb1..c9e4719b 100644 --- a/src/aaz_dev/command/tests/api_tests/test_editor.py +++ b/src/aaz_dev/command/tests/api_tests/test_editor.py @@ -2226,9 +2226,19 @@ def test_dataplane_monitor_metrics(self, ws_name): "template": "https://{region}.metrics.monitor.cloudapi.de" }, ], + "cloudMetadata": { + "selectorIndex": "suffixes.monitorMetrics", + "prefixTemplate": "https://{region}", + } }) self.assertTrue(rv.status_code == 200) + client_config = rv.get_json() + self.assertEqual(client_config['endpoints']['cloudMetadata'], { + "selectorIndex": "suffixes.monitorMetrics", + "prefixTemplate": "https://{region}", + }) + # update client arguments rv = c.get(f"{ws_url}/ClientConfig/Arguments/$Client.Endpoint.region") self.assertTrue(rv.status_code == 200) diff --git a/src/web/src/views/workspace/WSEditorClientConfig.tsx b/src/web/src/views/workspace/WSEditorClientConfig.tsx index e5b35396..7a928c56 100644 --- a/src/web/src/views/workspace/WSEditorClientConfig.tsx +++ b/src/web/src/views/workspace/WSEditorClientConfig.tsx @@ -6,6 +6,7 @@ import AddCircleRoundedIcon from '@mui/icons-material/AddCircleRounded'; import { styled } from '@mui/system'; import { Plane, Resource } from './WSEditorCommandContent'; import { SwaggerItemSelector } from './WSEditorSwaggerPicker'; +import AddRoundedIcon from '@mui/icons-material/AddRounded'; interface WSEditorClientConfigDialogProps { workspaceUrl: string, @@ -24,6 +25,8 @@ interface WSEditorClientConfigDialogState { templateAzureChinaCloud: string, templateAzureUSGovernment: string, templateAzureGermanCloud: string, + cloudMetadataSelectorIndex: string, + cloudMetadataPrefixTemplate: string, aadAuthScopes: string[], @@ -67,6 +70,13 @@ const AuthTypography = styled(Typography)(({ theme }) => ({ fontWeight: 400, })); +const TemplateSuffixTypography = styled(Typography)(({ theme }) => ({ + color: theme.palette.primary.main, + fontFamily: "'Roboto Condensed', sans-serif", + fontSize: 18, + fontWeight: 500, +})); + const MiddlePadding = styled(Box)(({ theme }) => ({ height: '1.5vh' })); @@ -86,6 +96,8 @@ class WSEditorClientConfigDialog extends React.Component { if (resourceProviderUrl != null) { this.setState({ @@ -379,6 +390,8 @@ class WSEditorClientConfigDialog extends React.Component { clientConfig.endpointTemplates![value.cloud] = value.template; }); + clientConfig.endpointCloudMetadata = res.data.endpoints.cloudMetadata; endpointType = "template"; templateAzureCloud = clientConfig.endpointTemplates!['AzureCloud'] ?? ""; templateAzureChinaCloud = clientConfig.endpointTemplates!['AzureChinaCloud'] ?? ""; templateAzureUSGovernment = clientConfig.endpointTemplates!['AzureUSGovernment'] ?? ""; templateAzureGermanCloud = clientConfig.endpointTemplates!['AzureGermanCloud'] ?? ""; + cloudMetadataSelectorIndex = clientConfig.endpointCloudMetadata?.selectorIndex ?? ""; + cloudMetadataPrefixTemplate = clientConfig.endpointCloudMetadata?.prefixTemplate ?? ""; } else if (res.data.endpoints.type === "http-operation") { clientConfig.endpointResource = res.data.endpoints.resource; let rpUrl: string = clientConfig.endpointResource!.swagger.split('/Paths/')[0]; @@ -419,6 +435,8 @@ class WSEditorClientConfigDialog extends React.Component { - let { aadAuthScopes, endpointType} = this.state + let { aadAuthScopes, endpointType } = this.state let templates: ClientEndpointTemplate[] | undefined = undefined; let resource: ClientEndpointResource | undefined = undefined; + let cloudMetadata: ClientEndpointCloudMetadata | undefined = undefined; if (endpointType === "template") { let { templateAzureCloud, templateAzureChinaCloud, templateAzureGermanCloud, templateAzureUSGovernment } = this.state @@ -474,21 +493,21 @@ class WSEditorClientConfigDialog extends React.Component 0 && !templateRegex.test(templateAzureChinaCloud)) { this.setState({ invalidText: "Azure China Cloud Endpoint Template is invalid." }); return; } - + if (templateAzureUSGovernment.length > 0 && !templateRegex.test(templateAzureUSGovernment)) { this.setState({ invalidText: "Azure US Government Endpoint Template is invalid." }); return; } - + if (templateAzureGermanCloud.length > 0 && !templateRegex.test(templateAzureGermanCloud)) { this.setState({ invalidText: "Azure German Cloud Endpoint Template is invalid." @@ -508,9 +527,35 @@ class WSEditorClientConfigDialog extends React.Component 0) { templates.push({ cloud: 'AzureGermanCloud', template: templateAzureGermanCloud }); } + + let { cloudMetadataSelectorIndex, cloudMetadataPrefixTemplate } = this.state; + cloudMetadataSelectorIndex = cloudMetadataSelectorIndex.trim(); + cloudMetadataPrefixTemplate = cloudMetadataPrefixTemplate.trim(); + if (cloudMetadataSelectorIndex.length < 1 && cloudMetadataPrefixTemplate.length > 0) { + this.setState({ + invalidText: "Cloud Metadata Selector Index is required." + }); + return; + } else if (cloudMetadataSelectorIndex.length > 0 ) { + + cloudMetadata = { + selectorIndex: cloudMetadataSelectorIndex, + } + if (cloudMetadataPrefixTemplate.length > 0) { + // verify template url using regex, like https://{vaultName} + if (!templateRegex.test(cloudMetadataPrefixTemplate)) { + this.setState({ + invalidText: "Cloud Metadata Prefix is invalid." + }); + return; + } + cloudMetadata.prefixTemplate = cloudMetadataPrefixTemplate; + } + } + } else if (endpointType === "http-operation") { - let {selectedPlane, selectedModule, selectedResourceProvider, selectedVersion, selectedResourceId, subresource, moduleOptionsCommonPrefix} = this.state; + let { selectedPlane, selectedModule, selectedResourceProvider, selectedVersion, selectedResourceId, subresource, moduleOptionsCommonPrefix } = this.state; if (!selectedPlane) { this.setState({ invalidText: "Plane is required." @@ -561,7 +606,7 @@ class WSEditorClientConfigDialog extends React.Component scope.trim()).filter(scope => scope.length > 0); if (aadAuthScopes.length < 1) { this.setState({ - invalidText: "AAD Auth Scopes is required." + invalidText: "MS Entra(AAD) Auth Scopes is required." }); return; } @@ -574,6 +619,7 @@ class WSEditorClientConfigDialog extends React.Component { @@ -588,6 +635,7 @@ class WSEditorClientConfigDialog extends React.Component ) } render() { - const { invalidText, updating, isAdd, aadAuthScopes, endpointType, templateAzureCloud, templateAzureChinaCloud, templateAzureUSGovernment, templateAzureGermanCloud } = this.state; + const { invalidText, updating, isAdd, aadAuthScopes, endpointType, templateAzureCloud, templateAzureChinaCloud, templateAzureUSGovernment, templateAzureGermanCloud, cloudMetadataSelectorIndex, cloudMetadataPrefixTemplate } = this.state; const { selectedModule, selectedResourceProvider, selectedVersion, selectedResourceId, subresource } = this.state; return ( + Default Templates + + From Cloud Metadata + + { + this.setState({ + cloudMetadataSelectorIndex: event.target.value, + }) + }} + margin='dense' + /> + + { + this.setState({ + cloudMetadataPrefixTemplate: event.target.value, + }) + }} + margin='dense' + /> + + .Suffix + } {endpointType === "http-operation" && } - AAD Auth Scopes + MS Entra(AAD) Auth Scopes {aadAuthScopes?.map(this.buildAadScopeInput)}