From 3d7b5fee2b4e94bf760630e3358453ae63fee9ab Mon Sep 17 00:00:00 2001 From: Henry Dai Date: Thu, 23 Apr 2026 22:18:12 -0400 Subject: [PATCH 1/3] Fix --additional-data and --change-definition to accept free-form nested JSON The --additional-data argument was defined as AAZObjectArg with no child fields, causing 'Model AAZObjectArg has no field named safeFly' errors. The --change-definition details field had the same issue, rejecting ApiOperations payloads with 'no field named operations'. Changes: - Change additional_data from AAZObjectArg to AAZFreeFormDictArg - Change change_definition.details from AAZObjectArg to AAZFreeFormDictArg - Change corresponding AAZObjectType to AAZFreeFormDictType in builders and response schemas across create, update, show, and list - Add content injection for additionalData in custom.py (same pattern as changeDefinition) to work around AAZ builder serialization limitation - Add tests for SafeFly payload, links, and orchestration-tool arguments - Bump version to 1.0.0b2 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/azure-changesafety/HISTORY.rst | 7 + .../changesafety/changerecord/_create.py | 20 +-- .../latest/changesafety/changerecord/_list.py | 8 +- .../latest/changesafety/changerecord/_show.py | 8 +- .../changesafety/changerecord/_update.py | 12 +- .../azext_changesafety/custom.py | 72 ++++++-- .../tests/latest/test_changesafety.py | 157 +++++++++++++++++- src/azure-changesafety/setup.py | 2 +- 8 files changed, 249 insertions(+), 37 deletions(-) diff --git a/src/azure-changesafety/HISTORY.rst b/src/azure-changesafety/HISTORY.rst index a5a26f2bc31..67a8abcc5bc 100644 --- a/src/azure-changesafety/HISTORY.rst +++ b/src/azure-changesafety/HISTORY.rst @@ -3,6 +3,13 @@ Release History =============== +1.0.0b2 ++++++++ +* Fix ``--additional-data`` argument to accept free-form nested JSON (e.g., SafeFly payloads). +* Fix ``--change-definition`` details to accept free-form nested JSON (e.g., ApiOperations with operations array). +* Inject ``additionalData`` into request body via content override (AAZ builder workaround). +* Add content injection for ``additionalData`` in both Create and Update commands. + 1.0.0b1 +++++++ * Initial release. diff --git a/src/azure-changesafety/azext_changesafety/aaz/latest/changesafety/changerecord/_create.py b/src/azure-changesafety/azext_changesafety/aaz/latest/changesafety/changerecord/_create.py index d3785d855fe..64535a400ac 100644 --- a/src/azure-changesafety/azext_changesafety/aaz/latest/changesafety/changerecord/_create.py +++ b/src/azure-changesafety/azext_changesafety/aaz/latest/changesafety/changerecord/_create.py @@ -57,7 +57,7 @@ def _build_arguments_schema(cls, *args, **kwargs): # define Arg Group "Properties" _args_schema = cls._args_schema - _args_schema.additional_data = AAZObjectArg( + _args_schema.additional_data = AAZFreeFormDictArg( options=["--additional-data"], arg_group="Properties", help="Additional metadata for the change required for various orchestration tools.", @@ -139,7 +139,7 @@ def _build_arguments_schema(cls, *args, **kwargs): ) change_definition = cls._args_schema.change_definition - change_definition.details = AAZObjectArg( + change_definition.details = AAZFreeFormDictArg( options=["details"], help="Free form object containing additional details for the change definition.", required=True, @@ -371,7 +371,7 @@ def content(self): properties = _builder.get(".properties") if properties is not None: - properties.set_prop("additionalData", AAZObjectType, ".additional_data") + properties.set_prop("additionalData", AAZFreeFormDictType, ".additional_data") properties.set_prop("anticipatedEndTime", AAZStrType, ".anticipated_end_time", typ_kwargs={"flags": {"required": True}}) properties.set_prop("anticipatedStartTime", AAZStrType, ".anticipated_start_time", typ_kwargs={"flags": {"required": True}}) properties.set_prop("changeDefinition", AAZObjectType, ".change_definition", typ_kwargs={"flags": {"required": True}}) @@ -387,7 +387,7 @@ def content(self): change_definition = _builder.get(".properties.changeDefinition") if change_definition is not None: - change_definition.set_prop("details", AAZObjectType, ".details", typ_kwargs={"flags": {"required": True}}) + change_definition.set_prop("details", AAZFreeFormDictType, ".details", typ_kwargs={"flags": {"required": True}}) change_definition.set_prop("kind", AAZStrType, ".kind", typ_kwargs={"flags": {"required": True}}) change_definition.set_prop("name", AAZStrType, ".name", typ_kwargs={"flags": {"required": True}}) @@ -510,7 +510,7 @@ def _build_schema_on_200_201(cls): ) properties = cls._schema_on_200_201.properties - properties.additional_data = AAZObjectType( + properties.additional_data = AAZFreeFormDictType( serialized_name="additionalData", ) properties.anticipated_end_time = AAZStrType( @@ -559,7 +559,7 @@ def _build_schema_on_200_201(cls): ) change_definition = cls._schema_on_200_201.properties.change_definition - change_definition.details = AAZObjectType( + change_definition.details = AAZFreeFormDictType( flags={"required": True}, ) change_definition.kind = AAZStrType( @@ -754,7 +754,7 @@ def content(self): properties = _builder.get(".properties") if properties is not None: - properties.set_prop("additionalData", AAZObjectType, ".additional_data") + properties.set_prop("additionalData", AAZFreeFormDictType, ".additional_data") properties.set_prop("anticipatedEndTime", AAZStrType, ".anticipated_end_time", typ_kwargs={"flags": {"required": True}}) properties.set_prop("anticipatedStartTime", AAZStrType, ".anticipated_start_time", typ_kwargs={"flags": {"required": True}}) properties.set_prop("changeDefinition", AAZObjectType, ".change_definition", typ_kwargs={"flags": {"required": True}}) @@ -770,7 +770,7 @@ def content(self): change_definition = _builder.get(".properties.changeDefinition") if change_definition is not None: - change_definition.set_prop("details", AAZObjectType, ".details", typ_kwargs={"flags": {"required": True}}) + change_definition.set_prop("details", AAZFreeFormDictType, ".details", typ_kwargs={"flags": {"required": True}}) change_definition.set_prop("kind", AAZStrType, ".kind", typ_kwargs={"flags": {"required": True}}) change_definition.set_prop("name", AAZStrType, ".name", typ_kwargs={"flags": {"required": True}}) @@ -893,7 +893,7 @@ def _build_schema_on_200_201(cls): ) properties = cls._schema_on_200_201.properties - properties.additional_data = AAZObjectType( + properties.additional_data = AAZFreeFormDictType( serialized_name="additionalData", ) properties.anticipated_end_time = AAZStrType( @@ -942,7 +942,7 @@ def _build_schema_on_200_201(cls): ) change_definition = cls._schema_on_200_201.properties.change_definition - change_definition.details = AAZObjectType( + change_definition.details = AAZFreeFormDictType( flags={"required": True}, ) change_definition.kind = AAZStrType( diff --git a/src/azure-changesafety/azext_changesafety/aaz/latest/changesafety/changerecord/_list.py b/src/azure-changesafety/azext_changesafety/aaz/latest/changesafety/changerecord/_list.py index b9ee25f4a83..07f108e9973 100644 --- a/src/azure-changesafety/azext_changesafety/aaz/latest/changesafety/changerecord/_list.py +++ b/src/azure-changesafety/azext_changesafety/aaz/latest/changesafety/changerecord/_list.py @@ -169,7 +169,7 @@ def _build_schema_on_200(cls): ) properties = cls._schema_on_200.value.Element.properties - properties.additional_data = AAZObjectType( + properties.additional_data = AAZFreeFormDictType( serialized_name="additionalData", ) properties.anticipated_end_time = AAZStrType( @@ -218,7 +218,7 @@ def _build_schema_on_200(cls): ) change_definition = cls._schema_on_200.value.Element.properties.change_definition - change_definition.details = AAZObjectType( + change_definition.details = AAZFreeFormDictType( flags={"required": True}, ) change_definition.kind = AAZStrType( @@ -440,7 +440,7 @@ def _build_schema_on_200(cls): ) properties = cls._schema_on_200.value.Element.properties - properties.additional_data = AAZObjectType( + properties.additional_data = AAZFreeFormDictType( serialized_name="additionalData", ) properties.anticipated_end_time = AAZStrType( @@ -489,7 +489,7 @@ def _build_schema_on_200(cls): ) change_definition = cls._schema_on_200.value.Element.properties.change_definition - change_definition.details = AAZObjectType( + change_definition.details = AAZFreeFormDictType( flags={"required": True}, ) change_definition.kind = AAZStrType( diff --git a/src/azure-changesafety/azext_changesafety/aaz/latest/changesafety/changerecord/_show.py b/src/azure-changesafety/azext_changesafety/aaz/latest/changesafety/changerecord/_show.py index 43e37cae04b..de35c70e199 100644 --- a/src/azure-changesafety/azext_changesafety/aaz/latest/changesafety/changerecord/_show.py +++ b/src/azure-changesafety/azext_changesafety/aaz/latest/changesafety/changerecord/_show.py @@ -171,7 +171,7 @@ def _build_schema_on_200(cls): ) properties = cls._schema_on_200.properties - properties.additional_data = AAZObjectType( + properties.additional_data = AAZFreeFormDictType( serialized_name="additionalData", ) properties.anticipated_end_time = AAZStrType( @@ -220,7 +220,7 @@ def _build_schema_on_200(cls): ) change_definition = cls._schema_on_200.properties.change_definition - change_definition.details = AAZObjectType( + change_definition.details = AAZFreeFormDictType( flags={"required": True}, ) change_definition.kind = AAZStrType( @@ -435,7 +435,7 @@ def _build_schema_on_200(cls): ) properties = cls._schema_on_200.properties - properties.additional_data = AAZObjectType( + properties.additional_data = AAZFreeFormDictType( serialized_name="additionalData", ) properties.anticipated_end_time = AAZStrType( @@ -484,7 +484,7 @@ def _build_schema_on_200(cls): ) change_definition = cls._schema_on_200.properties.change_definition - change_definition.details = AAZObjectType( + change_definition.details = AAZFreeFormDictType( flags={"required": True}, ) change_definition.kind = AAZStrType( diff --git a/src/azure-changesafety/azext_changesafety/aaz/latest/changesafety/changerecord/_update.py b/src/azure-changesafety/azext_changesafety/aaz/latest/changesafety/changerecord/_update.py index 33313c3191c..185e4f88f00 100644 --- a/src/azure-changesafety/azext_changesafety/aaz/latest/changesafety/changerecord/_update.py +++ b/src/azure-changesafety/azext_changesafety/aaz/latest/changesafety/changerecord/_update.py @@ -60,7 +60,7 @@ def _build_arguments_schema(cls, *args, **kwargs): # define Arg Group "Properties" _args_schema = cls._args_schema - _args_schema.additional_data = AAZObjectArg( + _args_schema.additional_data = AAZFreeFormDictArg( options=["--additional-data"], arg_group="Properties", help="Additional metadata for the change required for various orchestration tools.", @@ -150,7 +150,7 @@ def _build_arguments_schema(cls, *args, **kwargs): ) change_definition = cls._args_schema.change_definition - change_definition.details = AAZObjectArg( + change_definition.details = AAZFreeFormDictArg( options=["details"], help="Free form object containing additional details for the change definition.", blank={}, @@ -717,7 +717,7 @@ def _update_instance(self, instance): properties = _builder.get(".properties") if properties is not None: - properties.set_prop("additionalData", AAZObjectType, ".additional_data") + properties.set_prop("additionalData", AAZFreeFormDictType, ".additional_data") properties.set_prop("anticipatedEndTime", AAZStrType, ".anticipated_end_time", typ_kwargs={"flags": {"required": True}}) properties.set_prop("anticipatedStartTime", AAZStrType, ".anticipated_start_time", typ_kwargs={"flags": {"required": True}}) properties.set_prop("changeDefinition", AAZObjectType, ".change_definition", typ_kwargs={"flags": {"required": True}}) @@ -733,7 +733,7 @@ def _update_instance(self, instance): change_definition = _builder.get(".properties.changeDefinition") if change_definition is not None: - change_definition.set_prop("details", AAZObjectType, ".details", typ_kwargs={"flags": {"required": True}}) + change_definition.set_prop("details", AAZFreeFormDictType, ".details", typ_kwargs={"flags": {"required": True}}) change_definition.set_prop("kind", AAZStrType, ".kind", typ_kwargs={"flags": {"required": True}}) change_definition.set_prop("name", AAZStrType, ".name", typ_kwargs={"flags": {"required": True}}) @@ -865,7 +865,7 @@ def _build_schema_change_record_read(cls, _schema): ) properties = _schema_change_record_read.properties - properties.additional_data = AAZObjectType( + properties.additional_data = AAZFreeFormDictType( serialized_name="additionalData", ) properties.anticipated_end_time = AAZStrType( @@ -914,7 +914,7 @@ def _build_schema_change_record_read(cls, _schema): ) change_definition = _schema_change_record_read.properties.change_definition - change_definition.details = AAZObjectType( + change_definition.details = AAZFreeFormDictType( flags={"required": True}, ) change_definition.kind = AAZStrType( diff --git a/src/azure-changesafety/azext_changesafety/custom.py b/src/azure-changesafety/azext_changesafety/custom.py index 2b86c0a4121..6424fd53aee 100644 --- a/src/azure-changesafety/azext_changesafety/custom.py +++ b/src/azure-changesafety/azext_changesafety/custom.py @@ -144,6 +144,28 @@ def _inject_change_definition_into_content(content, ctx): return content +def _inject_additional_data_into_content(content, ctx): + """Inject the parsed additionalData into the serialized request content. + + The AAZ content builder cannot serialize AAZFreeFormDictType correctly + (it produces {}), so we capture the raw value in pre_operations and + inject it here — the same pattern used for changeDefinition. + """ + additional_data_value = getattr(ctx.vars, "additional_data", None) + if additional_data_value is None: + return content + + additional_data = additional_data_value.to_serialized_data() + if not additional_data: + return content + + if content is None: + content = {} + properties = content.setdefault("properties", {}) + properties["additionalData"] = additional_data + return content + + def _preserve_change_definition_in_content(content, ctx): """Preserve the original changeDefinition from GET response in the update request. @@ -356,6 +378,19 @@ def pre_operations(self): self._ensure_schedule_defaults() _apply_stage_map_shortcut(self.ctx) + # Capture additional_data for injection into request content. + # The AAZ builder cannot serialize AAZFreeFormDictType, so we + # store the raw value and inject it via the content property. + additional_data_arg = getattr(self.ctx.args, "additional_data", None) + if has_value(additional_data_arg): + additional_data = additional_data_arg.to_serialized_data() + if additional_data: + self.ctx.set_var( + 'additional_data', + additional_data, + schema_builder=_build_any_type, + ) + change_definition_arg = getattr(self.ctx.args, "change_definition", None) change_definition_value = None self._raw_targets = [t for t in (self._raw_targets or []) if t and str(t) != 'Undefined'] @@ -490,21 +525,25 @@ def _output(self, *args, **kwargs): class ChangeRecordsCreateOrUpdateAtSubscriptionLevel( _ChangeRecordCreate.ChangeRecordsCreateOrUpdateAtSubscriptionLevel): - """Override PUT at subscription level to inject custom changeDefinition.""" + """Override PUT at subscription level to inject custom payloads.""" @property def content(self): content = super().content - return _inject_change_definition_into_content(content, self.ctx) + content = _inject_change_definition_into_content(content, self.ctx) + content = _inject_additional_data_into_content(content, self.ctx) + return content class ChangeRecordsCreateOrUpdate( _ChangeRecordCreate.ChangeRecordsCreateOrUpdate): - """Override PUT at resource group level to inject custom changeDefinition.""" + """Override PUT at resource group level to inject custom payloads.""" @property def content(self): content = super().content - return _inject_change_definition_into_content(content, self.ctx) + content = _inject_change_definition_into_content(content, self.ctx) + content = _inject_additional_data_into_content(content, self.ctx) + return content class ChangeRecordUpdate(_ChangeRecordUpdate): @@ -539,6 +578,17 @@ def pre_operations(self): super().pre_operations() _apply_stage_map_shortcut(self.ctx) + # Capture additional_data for injection (same pattern as Create) + additional_data_arg = getattr(self.ctx.args, "additional_data", None) + if has_value(additional_data_arg): + additional_data = additional_data_arg.to_serialized_data() + if additional_data: + self.ctx.set_var( + 'additional_data', + additional_data, + schema_builder=_build_any_type, + ) + class ChangeRecordsGetAtSubscriptionLevel( _ChangeRecordUpdate.ChangeRecordsGetAtSubscriptionLevel): """Override GET at subscription level to capture original changeDefinition.""" @@ -572,23 +622,25 @@ def on_200(self, session): class ChangeRecordsCreateOrUpdateAtSubscriptionLevel( _ChangeRecordUpdate.ChangeRecordsCreateOrUpdateAtSubscriptionLevel): - """Override PUT at subscription level to preserve original changeDefinition.""" + """Override PUT at subscription level to preserve changeDefinition and inject additionalData.""" @property def content(self): content = super().content - # Preserve original changeDefinition - it cannot be updated - return _preserve_change_definition_in_content(content, self.ctx) + content = _preserve_change_definition_in_content(content, self.ctx) + content = _inject_additional_data_into_content(content, self.ctx) + return content class ChangeRecordsCreateOrUpdate( _ChangeRecordUpdate.ChangeRecordsCreateOrUpdate): - """Override PUT at resource group level to preserve original changeDefinition.""" + """Override PUT at resource group level to preserve changeDefinition and inject additionalData.""" @property def content(self): content = super().content - # Preserve original changeDefinition - it cannot be updated - return _preserve_change_definition_in_content(content, self.ctx) + content = _preserve_change_definition_in_content(content, self.ctx) + content = _inject_additional_data_into_content(content, self.ctx) + return content class ChangeRecordShow(_ChangeRecordShow): diff --git a/src/azure-changesafety/azext_changesafety/tests/latest/test_changesafety.py b/src/azure-changesafety/azext_changesafety/tests/latest/test_changesafety.py index 902fe511f2c..36ff98393c6 100644 --- a/src/azure-changesafety/azext_changesafety/tests/latest/test_changesafety.py +++ b/src/azure-changesafety/azext_changesafety/tests/latest/test_changesafety.py @@ -93,9 +93,12 @@ def _build_mock_instance( change_definition=None, stage_map=None, anticipated_start_time=None, - anticipated_end_time=None): + anticipated_end_time=None, + additional_data=None, + links=None, + orchestration_tool=None): """Build a mock ChangeRecord instance.""" - return { + instance = { "id": f"/subscriptions/{subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.ChangeSafety/changeRecords/{name}", "name": name, "type": "Microsoft.ChangeSafety/changeRecords", @@ -116,6 +119,13 @@ def _build_mock_instance( } } } + if additional_data is not None: + instance["properties"]["additionalData"] = additional_data + if links is not None: + instance["properties"]["links"] = links + if orchestration_tool is not None: + instance["properties"]["orchestrationTool"] = orchestration_tool + return instance @staticmethod def _mock_create_execute(cmd): @@ -128,6 +138,9 @@ def _mock_create_execute(cmd): change_type = cls._get_arg_value(cmd, "change_type", "ManualTouch") rollout_type = cls._get_arg_value(cmd, "rollout_type", "Normal") comments = cls._get_arg_value(cmd, "comments") + additional_data = cls._get_arg_value(cmd, "additional_data") + links = cls._get_arg_value(cmd, "links") + orchestration_tool = cls._get_arg_value(cmd, "orchestration_tool") targets = copy.deepcopy(cmd._parsed_targets or []) change_definition_var = getattr(cmd.ctx.vars, "change_definition", None) change_definition_value = change_definition_var.to_serialized_data() if change_definition_var else None @@ -155,6 +168,9 @@ def _mock_create_execute(cmd): stage_map=stage_map_value, anticipated_start_time=start_time, anticipated_end_time=end_time, + additional_data=additional_data, + links=links, + orchestration_tool=orchestration_tool, ) cls._SCENARIO_STATE["instance"] = copy.deepcopy(instance) cmd.ctx.set_var("instance", copy.deepcopy(instance), schema_builder=lambda: AAZAnyType()) @@ -454,6 +470,143 @@ def test_change_record_crud_scenario(self): self.cmd('az changesafety changerecord delete -g {rg} -n {name} -y') self.assertNotIn("instance", type(self)._SCENARIO_STATE) + def test_additional_data_with_safefly_payload(self): + """Test --additional-data accepts nested SafeFly JSON and round-trips correctly.""" + resource_group = "rgAdditionalData" + change_record_name = self.create_random_name('chg', 12) + target_resource = ( + f"/subscriptions/{self.FAKE_SUBSCRIPTION_ID}/resourceGroups/{resource_group}/" + "providers/Microsoft.Network/trafficManagerProfiles/tm-test" + ) + self.kwargs.update({ + "rg": resource_group, + "name": change_record_name, + "change_type": "ManualTouch", + "rollout_type": "Normal", + "targets": f"resourceId={target_resource},operation=DELETE", + "additional_data": '{"safeFly":{"riskLevel":"Low","isLiveSiteMitigation":false,' + '"rollbackTested":"NA","productionTouchTool":"Other","icmId":"123456789"}}', + }) + + result = self.cmd( + 'az changesafety changerecord create -g {rg} -n {name} ' + '--change-type {change_type} --rollout-type {rollout_type} ' + '--targets "{targets}" ' + "--additional-data '{additional_data}'", + checks=[ + JMESPathCheck('properties.additionalData.safeFly.riskLevel', 'Low'), + JMESPathCheck('properties.additionalData.safeFly.isLiveSiteMitigation', False), + JMESPathCheck('properties.additionalData.safeFly.rollbackTested', 'NA'), + JMESPathCheck('properties.additionalData.safeFly.productionTouchTool', 'Other'), + JMESPathCheck('properties.additionalData.safeFly.icmId', '123456789'), + ], + ).get_output_in_json() + + # Verify the full nested structure is preserved + safe_fly = result["properties"]["additionalData"]["safeFly"] + self.assertEqual(safe_fly["riskLevel"], "Low") + self.assertFalse(safe_fly["isLiveSiteMitigation"]) + self.assertEqual(safe_fly["icmId"], "123456789") + + def test_create_with_links_and_orchestration_tool(self): + """Test --links and --orchestration-tool arguments.""" + resource_group = "rgLinksTest" + change_record_name = self.create_random_name('chg', 12) + target_resource = ( + f"/subscriptions/{self.FAKE_SUBSCRIPTION_ID}/resourceGroups/{resource_group}/" + "providers/Microsoft.Storage/storageAccounts/demo" + ) + self.kwargs.update({ + "rg": resource_group, + "name": change_record_name, + "change_type": "ManualTouch", + "rollout_type": "Normal", + "targets": f"resourceId={target_resource},operation=DELETE", + "orchestration_tool": "Azure Portal", + "links": '[{"name":"serviceCatalogEntry","uri":"https://microsoftservicetree.com/services/test-guid"}]', + }) + + result = self.cmd( + 'az changesafety changerecord create -g {rg} -n {name} ' + '--change-type {change_type} --rollout-type {rollout_type} ' + '--targets "{targets}" ' + '--orchestration-tool "{orchestration_tool}" ' + "--links '{links}'", + checks=[ + JMESPathCheck('properties.orchestrationTool', 'Azure Portal'), + JMESPathCheck('properties.links[0].name', 'serviceCatalogEntry'), + JMESPathCheck('properties.links[0].uri', 'https://microsoftservicetree.com/services/test-guid'), + ], + ).get_output_in_json() + + self.assertEqual(result["properties"]["orchestrationTool"], "Azure Portal") + + def test_safefly_full_scenario(self): + """Test the full SafeFly manual touch scenario from the bug report. + + Exercises: --additional-data, --links, --orchestration-tool, and --targets + together in a single create command. + """ + resource_group = "rgSafeFlyE2E" + change_record_name = self.create_random_name('chg', 12) + sub_id = self.FAKE_SUBSCRIPTION_ID + target_resource = ( + f"/subscriptions/{sub_id}/resourceGroups/{resource_group}/" + "providers/Microsoft.Storage/storageAccounts/mystorageacct1" + ) + additional_data_json = ( + '{"safeFly":{"riskLevel":"Low","isLiveSiteMitigation":false,' + '"rollbackTested":"NA","productionTouchTool":"Other","icmId":"123456789"}}' + ) + links_json = ( + '[{"name":"serviceCatalogEntry",' + '"uri":"https://microsoftservicetree.com/services/91949edc-8cae-421f-b8d5-f779ba3d52d3"}]' + ) + self.kwargs.update({ + "rg": resource_group, + "name": change_record_name, + "change_type": "ManualTouch", + "rollout_type": "Normal", + "description": "Delete ARM Resource using ChangeSafety and SafeFly", + "orchestration_tool": "Azure Portal", + "targets": f"resourceId={target_resource},operation=DELETE", + "additional_data": additional_data_json, + "links": links_json, + }) + + result = self.cmd( + 'az changesafety changerecord create -g {rg} -n {name} ' + '--description "{description}" ' + '--change-type {change_type} --rollout-type {rollout_type} ' + '--orchestration-tool "{orchestration_tool}" ' + "--links '{links}' " + "--additional-data '{additional_data}' " + '--targets "{targets}"', + checks=[ + JMESPathCheck('name', change_record_name), + JMESPathCheck('properties.changeType', 'ManualTouch'), + JMESPathCheck('properties.rolloutType', 'Normal'), + JMESPathCheck('properties.orchestrationTool', 'Azure Portal'), + # SafeFly additional data + JMESPathCheck('properties.additionalData.safeFly.riskLevel', 'Low'), + JMESPathCheck('properties.additionalData.safeFly.isLiveSiteMitigation', False), + JMESPathCheck('properties.additionalData.safeFly.icmId', '123456789'), + # Targets + JMESPathCheck('properties.changeDefinition.details.targets[0].resourceId', target_resource), + JMESPathCheck('properties.changeDefinition.details.targets[0].httpMethod', 'DELETE'), + # Links + JMESPathCheck('properties.links[0].name', 'serviceCatalogEntry'), + ], + ).get_output_in_json() + + # Full structure verification + props = result["properties"] + self.assertEqual(props["additionalData"]["safeFly"]["riskLevel"], "Low") + self.assertEqual(props["additionalData"]["safeFly"]["icmId"], "123456789") + self.assertFalse(props["additionalData"]["safeFly"]["isLiveSiteMitigation"]) + self.assertEqual(props["orchestrationTool"], "Azure Portal") + self.assertEqual(props["links"][0]["name"], "serviceCatalogEntry") + # ============================================================================= # StageMap Tests diff --git a/src/azure-changesafety/setup.py b/src/azure-changesafety/setup.py index 0e68f9c4516..37f412b0f6b 100644 --- a/src/azure-changesafety/setup.py +++ b/src/azure-changesafety/setup.py @@ -10,7 +10,7 @@ # HISTORY.rst entry. -VERSION = '1.0.0b1' +VERSION = '1.0.0b2' # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers From 8fb17fcdf8821c5f1caf2ad289a71139bacf4b00 Mon Sep 17 00:00:00 2001 From: Henry Dai Date: Tue, 26 May 2026 09:01:12 -0400 Subject: [PATCH 2/3] Retrigger CI (transient GitHub 403 incident on prior run) From a414f1d6f229f338615320068d432b4be0bb2ce0 Mon Sep 17 00:00:00 2001 From: Henry Dai Date: Fri, 29 May 2026 09:05:42 -0400 Subject: [PATCH 3/3] Address PR nits: use 'is None' / 'is not None' for additional_data checks Per review feedback, replace truthiness checks with explicit None checks so that an explicitly provided empty dict {} is treated as a valid user-supplied value rather than being silently dropped. - _inject_additional_data_into_content: 'if not additional_data' -> 'is None' - ChangeRecordCreate.pre_operations: 'if additional_data' -> 'is not None' - ChangeRecordUpdate.pre_operations: same fix for consistency (duplicated pattern) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/azure-changesafety/azext_changesafety/custom.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/azure-changesafety/azext_changesafety/custom.py b/src/azure-changesafety/azext_changesafety/custom.py index 6424fd53aee..8d7a864517f 100644 --- a/src/azure-changesafety/azext_changesafety/custom.py +++ b/src/azure-changesafety/azext_changesafety/custom.py @@ -156,7 +156,7 @@ def _inject_additional_data_into_content(content, ctx): return content additional_data = additional_data_value.to_serialized_data() - if not additional_data: + if additional_data is None: return content if content is None: @@ -384,7 +384,7 @@ def pre_operations(self): additional_data_arg = getattr(self.ctx.args, "additional_data", None) if has_value(additional_data_arg): additional_data = additional_data_arg.to_serialized_data() - if additional_data: + if additional_data is not None: self.ctx.set_var( 'additional_data', additional_data, @@ -582,7 +582,7 @@ def pre_operations(self): additional_data_arg = getattr(self.ctx.args, "additional_data", None) if has_value(additional_data_arg): additional_data = additional_data_arg.to_serialized_data() - if additional_data: + if additional_data is not None: self.ctx.set_var( 'additional_data', additional_data,