Skip to content

Commit bd9364a

Browse files
committed
Azure: Allow modular publishing
This commit introduces a new feature for Azure named "modular publish", which allows the library to only submit the changed technical config for a given offer/plan to "preview/live" instead of the whole offer like before. The new feature is disabled by default in order to keep the library consistent with its past behavior, being possible to activate it by setting the value `modular_push = True` on `AzurePublishingMetadata`. Refers to SPSTRAT-604 Signed-off-by: Jonathan Gangi <[email protected]>
1 parent 7d3730a commit bd9364a

File tree

3 files changed

+190
-14
lines changed

3 files changed

+190
-14
lines changed

cloudpub/ms_azure/service.py

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,9 @@ def diff_offer(self, product: Product, first_target="preview") -> DeepDiff:
417417
remote = self.get_product(product.id, first_target=first_target)
418418
return DeepDiff(remote.to_json(), product.to_json(), exclude_regex_paths=self.DIFF_EXCLUDES)
419419

420-
def submit_to_status(self, product_id: str, status: str) -> ConfigureStatus:
420+
def submit_to_status(
421+
self, product_id: str, status: str, resources: Optional[List[AzureResource]] = None
422+
) -> ConfigureStatus:
421423
"""
422424
Send a submission request to Microsoft with a new Product status.
423425
@@ -426,6 +428,8 @@ def submit_to_status(self, product_id: str, status: str) -> ConfigureStatus:
426428
The product ID to submit the new status.
427429
status (str)
428430
The new status: 'preview' or 'live'
431+
resources (optional(list(AzureRerouce)))
432+
Additional resources for modular push.
429433
Returns:
430434
The response from configure request.
431435
"""
@@ -446,9 +450,12 @@ def submit_to_status(self, product_id: str, status: str) -> ConfigureStatus:
446450

447451
# Update the status with the expected one
448452
submission.target.targetType = status
453+
cfg_res: List[AzureResource] = [submission]
454+
if resources:
455+
log.info("Performing a modular push to \"%s\" for \"%s\"", status, product_id)
456+
cfg_res = resources + cfg_res
449457
log.debug("Set the status \"%s\" to submission.", status)
450-
451-
return self.configure(resources=[submission])
458+
return self.configure(resources=cfg_res)
452459

453460
@retry(
454461
wait=wait_fixed(300),
@@ -501,6 +508,54 @@ def get_plan_tech_config(self, product: Product, plan: PlanSummary) -> VMIPlanTe
501508
)
502509
return tconfigs[0] # It should have only one VMIPlanTechConfig per plan.
503510

511+
def get_modular_resources_to_publish(
512+
self, product: Product, tech_config: VMIPlanTechConfig
513+
) -> List[AzureResource]:
514+
"""Return the required resources for a modular publishing.
515+
516+
According to Microsoft docs:
517+
"For a modular publish, all resources are required except for the product level details
518+
(for example, listing, availability, packages, reseller) as applicable to your
519+
product type."
520+
521+
Args:
522+
product (Product): The original product to filter the resources from
523+
tech_config (VMIPlanTechConfig): The updated tech config to publish
524+
525+
Returns:
526+
List[AzureResource]: _description_
527+
"""
528+
# The following resources shouldn't be required:
529+
# -> customer-leads
530+
# -> test-drive
531+
# -> property
532+
# -> *listing*
533+
# -> reseller
534+
# -> price-and-availability-*
535+
# NOTE: The "submission" resource will be already added by the "submit_to_status" method
536+
#
537+
# With that it needs only the related "product" and "plan" resources alongisde the
538+
# updated tech_config
539+
product_id = tech_config.product_id
540+
plan_id = tech_config.plan_id
541+
prod_res = cast(
542+
List[ProductSummary],
543+
[
544+
prd
545+
for prd in self.filter_product_resources(product=product, resource="product")
546+
if prd.id == product_id
547+
],
548+
)[0]
549+
plan_res = cast(
550+
List[PlanSummary],
551+
[
552+
pln
553+
for pln in self.filter_product_resources(product=product, resource="plan")
554+
if pln.id == plan_id
555+
],
556+
)[0]
557+
return [prod_res, plan_res, tech_config]
558+
504559
def _is_submission_in_preview(self, current: ProductSubmission) -> bool:
505560
"""Return True if the latest submission state is "preview", False otherwise.
506561
@@ -528,17 +583,21 @@ def _is_submission_in_preview(self, current: ProductSubmission) -> bool:
528583
stop=stop_after_attempt(3),
529584
reraise=True,
530585
)
531-
def _publish_preview(self, product: Product, product_name: str) -> None:
586+
def _publish_preview(
587+
self, product: Product, product_name: str, resources: Optional[List[AzureResource]] = None
588+
) -> None:
532589
"""
533590
Submit the product to 'preview' if it's not already in this state.
534591
535592
This is required to execute the validation pipeline on Azure side.
536593
537594
Args:
538595
product
539-
The product with changes to publish live
596+
The product with changes to publish to preview
540597
product_name
541598
The product name to display in logs.
599+
resources:
600+
Additional resources for modular push.
542601
"""
543602
# We just want to set the ProductSubmission to 'preview' if it's not in this status.
544603
#
@@ -553,14 +612,14 @@ def _publish_preview(self, product: Product, product_name: str) -> None:
553612
log.info("The product \"%s\" is already set to preview", product_name)
554613
return
555614

556-
res = self.submit_to_status(product_id=product.id, status='preview')
615+
res = self.submit_to_status(product_id=product.id, status='preview', resources=resources)
557616

558617
if res.job_result != 'succeeded' or not self.get_submission_state(
559618
product.id, state="preview"
560619
):
561620
errors = "\n".join(res.errors)
562621
failure_msg = (
563-
f"Failed to submit the product {product.id} to preview. "
622+
f"Failed to submit the product {product_name} ({product.id}) to preview. "
564623
f"Status: {res.job_result} Errors: {errors}"
565624
)
566625
raise RuntimeError(failure_msg)
@@ -587,7 +646,7 @@ def _publish_live(self, product: Product, product_name: str) -> None:
587646
if res.job_result != 'succeeded' or not self.get_submission_state(product.id, state="live"):
588647
errors = "\n".join(res.errors)
589648
failure_msg = (
590-
f"Failed to submit the product {product.id} to live. "
649+
f"Failed to submit the product {product_name} ({product.id}) to live. "
591650
f"Status: {res.job_result} Errors: {errors}"
592651
)
593652
raise RuntimeError(failure_msg)
@@ -705,7 +764,13 @@ def publish(self, metadata: AzurePublishingMetadata) -> None:
705764
logdiff(self.diff_offer(product))
706765
self.ensure_can_publish(product.id)
707766

708-
self._publish_preview(product, product_name)
767+
# According to the documentation we only need to pass the
768+
# required resources for modular publish on "preview"
769+
# https://learn.microsoft.com/en-us/partner-center/marketplace-offers/product-ingestion-api#method-2-publish-specific-draft-resources-also-known-as-modular-publish # noqa: E501
770+
modular_resources = None
771+
if metadata.modular_push:
772+
modular_resources = self.get_modular_resources_to_publish(product, tech_config)
773+
self._publish_preview(product, product_name, resources=modular_resources)
709774
self._publish_live(product, product_name)
710775

711776
log.info(

cloudpub/ms_azure/utils.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ def __init__(
5454
check_base_sas_only (bool, optional):
5555
Indicates to skip checking SAS parameters when set as ``True``.
5656
Default to ``False``
57+
modular_push (bool, optional):
58+
Indicate whether to perform a modular push or not.
59+
The modular push causes the effect to only publish
60+
the changed plan instead of the whole offer to preview/live.
61+
Default to ``False``.
5762
**kwargs
5863
Arguments for :class:`~cloudpub.common.PublishingMetadata`.
5964
"""
@@ -64,6 +69,7 @@ def __init__(
6469
self.recommended_sizes = recommended_sizes or []
6570
self.legacy_sku_id = kwargs.pop("legacy_sku_id", None)
6671
self.check_base_sas_only = kwargs.pop("check_base_sas_only", False)
72+
self.modular_push = kwargs.pop("modular_push", None) or False
6773

6874
if generation == "V1" or not support_legacy:
6975
self.legacy_sku_id = None

tests/ms_azure/test_service.py

Lines changed: 110 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -868,7 +868,10 @@ def test_publish_preview_success_on_retry(
868868
azure_service._publish_preview(product_obj, "test-product")
869869

870870
mock_subst.assert_has_calls(
871-
[mock.call(product_id=product_obj.id, status="preview") for _ in range(3)]
871+
[
872+
mock.call(product_id=product_obj.id, status="preview", resources=None)
873+
for _ in range(3)
874+
]
872875
)
873876
mock_getsubst.assert_has_calls(
874877
[mock.call(product_obj.id, state="preview") for _ in range(3)]
@@ -900,7 +903,7 @@ def test_publish_preview_fail_on_retry(
900903
# Remove the retry sleep
901904
azure_service._publish_preview.retry.sleep = mock.Mock() # type: ignore
902905
expected_err = (
903-
f"Failed to submit the product {product_obj.id} to preview. "
906+
f"Failed to submit the product test-product \\({product_obj.id}\\) to preview. "
904907
"Status: failed Errors: failure1\nfailure2"
905908
)
906909

@@ -960,7 +963,7 @@ def test_publish_live_fail_on_retry(
960963
# Remove the retry sleep
961964
azure_service._publish_live.retry.sleep = mock.Mock() # type: ignore
962965
expected_err = (
963-
f"Failed to submit the product {product_obj.id} to live. "
966+
f"Failed to submit the product test-product \\({product_obj.id}\\) to live. "
964967
"Status: failed Errors: failure1\nfailure2"
965968
)
966969

@@ -1417,7 +1420,7 @@ def test_publish_live_x64_only(
14171420
mock_diff_offer.assert_called_once_with(product_obj)
14181421
mock_configure.assert_called_once_with(resources=[technical_config_obj])
14191422
submit_calls = [
1420-
mock.call(product_id=product_obj.id, status="preview"),
1423+
mock.call(product_id=product_obj.id, status="preview", resources=None),
14211424
mock.call(product_id=product_obj.id, status="live"),
14221425
]
14231426
mock_submit.assert_has_calls(submit_calls)
@@ -1509,7 +1512,7 @@ def test_publish_live_arm64_only(
15091512
mock_diff_offer.assert_called_once_with(product_obj)
15101513
mock_configure.assert_called_once_with(resources=[technical_config_obj])
15111514
submit_calls = [
1512-
mock.call(product_id=product_obj.id, status="preview"),
1515+
mock.call(product_id=product_obj.id, status="preview", resources=None),
15131516
mock.call(product_id=product_obj.id, status="live"),
15141517
]
15151518
mock_submit.assert_has_calls(submit_calls)
@@ -1637,3 +1640,105 @@ def test_publish_live_when_state_is_preview(
16371640
'Updating the technical configuration for "example-product/plan-1" on "preview".'
16381641
not in caplog.text
16391642
)
1643+
1644+
@mock.patch("cloudpub.ms_azure.AzureService.configure")
1645+
def test_publish_live_modular_push(
1646+
self,
1647+
mock_configure: mock.MagicMock,
1648+
token: Dict[str, Any],
1649+
auth_dict: Dict[str, Any],
1650+
configure_success_response: Dict[str, Any],
1651+
product: Dict[str, Any],
1652+
products_list: Dict[str, Any],
1653+
product_summary: Dict[str, Any],
1654+
technical_config: Dict[str, Any],
1655+
submission: Dict[str, Any],
1656+
product_summary_obj: ProductSummary,
1657+
plan_summary_obj: PlanSummary,
1658+
metadata_azure_obj: mock.MagicMock,
1659+
gen2_image: Dict[str, Any],
1660+
caplog: pytest.LogCaptureFixture,
1661+
) -> None:
1662+
"""Ensure a modular publish works as intended."""
1663+
# Prepare testing data
1664+
metadata_azure_obj.keepdraft = False
1665+
metadata_azure_obj.destination = "example-product/plan-1"
1666+
metadata_azure_obj.modular_push = True
1667+
1668+
# Set the complementary submission states
1669+
submission_preview = deepcopy(submission)
1670+
submission_preview.update({"target": {"targetType": "preview"}})
1671+
submission_live = deepcopy(submission)
1672+
submission_live.update({"target": {"targetType": "live"}})
1673+
mock_configure.return_value = ConfigureStatus.from_json(configure_success_response)
1674+
1675+
# Expected results
1676+
new_dv = {
1677+
"version_number": metadata_azure_obj.disk_version,
1678+
"vm_images": [
1679+
{
1680+
"imageType": "x64Gen2",
1681+
"source": {
1682+
"sourceType": "sasUri",
1683+
"osDisk": {"uri": "https://foo.com/bar/image.vhd"},
1684+
"dataDisks": [],
1685+
},
1686+
}
1687+
],
1688+
"lifecycle_state": "generallyAvailable",
1689+
}
1690+
dvs = technical_config["vmImageVersions"] + [new_dv]
1691+
new_tc = deepcopy(technical_config)
1692+
new_tc["vmImageVersions"] = dvs
1693+
expected_tc = VMIPlanTechConfig.from_json(new_tc)
1694+
expected_modular_resources = [
1695+
product_summary_obj,
1696+
plan_summary_obj,
1697+
expected_tc,
1698+
ProductSubmission.from_json(submission_preview),
1699+
]
1700+
1701+
# Constants
1702+
login_url = "https://login.microsoftonline.com/foo/oauth2/token"
1703+
base_url = "https://graph.microsoft.com/rp/product-ingestion"
1704+
product_id = str(product_summary['id']).split("/")[-1]
1705+
1706+
# Test
1707+
with caplog.at_level(logging.INFO):
1708+
with requests_mock.Mocker() as m:
1709+
m.post(login_url, json=token)
1710+
m.get(f"{base_url}/product", json=products_list)
1711+
m.get(f"{base_url}/resource-tree/product/{product_id}", json=product)
1712+
m.get(
1713+
f"{base_url}/submission/{product_id}",
1714+
[
1715+
{"json": {"value": [submission]}}, # ensure_can_publish call "preview"
1716+
{"json": {"value": [submission]}}, # ensure_can_publish call "live"
1717+
{"json": {"value": [submission]}}, # push_preview: call submit_status
1718+
{"json": {"value": [submission_preview]}}, # push_preview: check result
1719+
{"json": {"value": [submission_preview]}}, # push_live: call submit_status
1720+
{"json": {"value": [submission_live]}}, # push_live: check result
1721+
],
1722+
)
1723+
azure_svc = AzureService(auth_dict)
1724+
azure_svc.publish(metadata=metadata_azure_obj)
1725+
1726+
# Present messages
1727+
assert "The DiskVersion doesn't exist, creating one from scratch." in caplog.text
1728+
assert (
1729+
"Found the following offer diff before publishing:\n"
1730+
"Item root['resources'][1]['vmImageVersions'][1] added to iterable."
1731+
) in caplog.text
1732+
assert (
1733+
'Updating the technical configuration for "example-product/plan-1" on "preview".'
1734+
in caplog.text
1735+
)
1736+
assert (
1737+
'Performing a modular push to "preview" for "ffffffff-ffff-ffff-ffff-ffffffffffff"'
1738+
in caplog.text
1739+
)
1740+
1741+
# Configure request
1742+
mock_configure.assert_has_calls(
1743+
[mock.call(resources=[expected_tc]), mock.call(resources=expected_modular_resources)]
1744+
)

0 commit comments

Comments
 (0)