cloudpub 1.5.0__py3-none-any.whl → 1.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@
2
2
  import json
3
3
  import logging
4
4
  import os
5
+ from enum import IntEnum
5
6
  from typing import Any, Dict, Iterator, List, Optional, Tuple, Union, cast
6
7
 
7
8
  from deepdiff import DeepDiff
@@ -18,6 +19,7 @@ from cloudpub.models.ms_azure import (
18
19
  AzureResource,
19
20
  ConfigureStatus,
20
21
  CustomerLeads,
22
+ DiskVersion,
21
23
  Listing,
22
24
  ListingAsset,
23
25
  ListingTrailer,
@@ -38,6 +40,7 @@ from cloudpub.models.ms_azure import (
38
40
  from cloudpub.ms_azure.session import PartnerPortalSession
39
41
  from cloudpub.ms_azure.utils import (
40
42
  AzurePublishingMetadata,
43
+ TechnicalConfigLookUpData,
41
44
  create_disk_version_from_scratch,
42
45
  is_azure_job_not_complete,
43
46
  is_sas_present,
@@ -69,6 +72,15 @@ AZURE_PRODUCT_RESOURCES = Union[
69
72
  ]
70
73
 
71
74
 
75
+ class SasFoundStatus(IntEnum):
76
+ """Represent the submission target level of SAS found in a given product."""
77
+
78
+ missing = 0
79
+ draft = 1
80
+ preview = 2
81
+ live = 3
82
+
83
+
72
84
  class AzureService(BaseService[AzurePublishingMetadata]):
73
85
  """Service provider for Microsoft Azure using the Product Ingestion API."""
74
86
 
@@ -103,7 +115,10 @@ class AzureService(BaseService[AzurePublishingMetadata]):
103
115
  Returns:
104
116
  The job ID to track its status alongside the initial status.
105
117
  """
106
- log.debug("Received the following data to create/modify: %s" % json.dumps(data, indent=2))
118
+ if log.isEnabledFor(logging.DEBUG):
119
+ log.debug(
120
+ "Received the following data to create/modify: %s", json.dumps(data, indent=2)
121
+ )
107
122
  resp = self.session.post(path="configure", json=data)
108
123
  self._raise_for_status(response=resp)
109
124
  rsp_data = resp.json()
@@ -121,7 +136,7 @@ class AzureService(BaseService[AzurePublishingMetadata]):
121
136
  Returns:
122
137
  The updated job status.
123
138
  """
124
- log.debug(f"Query job details for \"{job_id}\"")
139
+ log.debug("Query job details for \"%s\"", job_id)
125
140
  resp = self.session.get(path=f"configure/{job_id}/status")
126
141
 
127
142
  # We don't want to fail if there's a server error thus we make a fake
@@ -129,9 +144,11 @@ class AzureService(BaseService[AzurePublishingMetadata]):
129
144
  if resp.status_code >= 500:
130
145
  log.warning(
131
146
  (
132
- f"Got HTTP {resp.status_code} from server when querying job {job_id} status."
133
- " Considering the job_status as \"pending\"."
134
- )
147
+ "Got HTTP %s from server when querying job %s status."
148
+ " Considering the job_status as \"pending\".",
149
+ ),
150
+ resp.status_code,
151
+ job_id,
135
152
  )
136
153
  return ConfigureStatus.from_json(
137
154
  {
@@ -177,24 +194,25 @@ class AzureService(BaseService[AzurePublishingMetadata]):
177
194
  error_message = f"Job {job_id} failed: \n{job_details.errors}"
178
195
  self._raise_error(InvalidStateError, error_message)
179
196
  elif job_details.job_result == "succeeded":
180
- log.debug(f"Job {job_id} succeeded")
197
+ log.debug("Job %s succeeded", job_id)
181
198
  return job_details
182
199
 
183
- def configure(self, resource: AzureResource) -> ConfigureStatus:
200
+ def configure(self, resources: List[AzureResource]) -> ConfigureStatus:
184
201
  """
185
202
  Create or update a resource and wait until it's done.
186
203
 
187
204
  Args:
188
- resource (AzureResource):
189
- The resource to create/modify in Azure.
205
+ resources (List[AzureResource]):
206
+ The list of resources to create/modify in Azure.
190
207
  Returns:
191
208
  dict: The result of job execution
192
209
  """
193
210
  data = {
194
211
  "$schema": self.CONFIGURE_SCHEMA.format(AZURE_API_VERSION=self.AZURE_API_VERSION),
195
- "resources": [resource.to_json()],
212
+ "resources": [x.to_json() for x in resources],
196
213
  }
197
- log.info("Data to configure: %s", json.dumps(data, indent=2))
214
+ if log.isEnabledFor(logging.DEBUG):
215
+ log.debug("Data to configure: %s", json.dumps(data, indent=2))
198
216
  res = self._configure(data=data)
199
217
  return self._wait_for_job_completion(job_id=res.job_id)
200
218
 
@@ -205,7 +223,7 @@ class AzureService(BaseService[AzurePublishingMetadata]):
205
223
  params: Dict[str, str] = {}
206
224
 
207
225
  while has_next:
208
- log.debug("Requesting the products list.")
226
+ log.info("Requesting the products list.")
209
227
  resp = self.session.get(path="/product", params=params)
210
228
  data = self._assert_dict(resp)
211
229
 
@@ -230,11 +248,26 @@ class AzureService(BaseService[AzurePublishingMetadata]):
230
248
  Returns:
231
249
  list: A list with ProductSummary for all products in Azure.
232
250
  """
251
+ log.info("Listing the products on Azure server.")
233
252
  if not self._products:
234
253
  self._products = [p for p in self.products]
235
254
  return self._products
236
255
 
237
- def get_product(self, product_id: str, first_target: str = "preview") -> Product:
256
+ def get_productid(self, product_name: str) -> str:
257
+ """Retrieve the desired product ID for the requested product name.
258
+
259
+ Args:
260
+ product_name (str): the product's name to retrieve its product ID.
261
+ Returns:
262
+ The requested product ID when found.
263
+ Raises NotFoundError when the product was not found.
264
+ """
265
+ for product in self.list_products():
266
+ if product.identity.name == product_name:
267
+ return product.id
268
+ raise NotFoundError(f"No such product with name {product_name}")
269
+
270
+ def get_product(self, product_id: str, target: str) -> Product:
238
271
  """
239
272
  Return the requested Product by its ID.
240
273
 
@@ -246,37 +279,31 @@ class AzureService(BaseService[AzurePublishingMetadata]):
246
279
  Args:
247
280
  product_durable_id (str)
248
281
  The product UUID
249
- first_target (str, optional)
250
- The first target to lookup into. Defaults to ``preview``.
282
+ target (str)
283
+ The submission target to retrieve the product from.
251
284
  Returns:
252
285
  Product: the requested product
253
286
  """
254
- targets = [first_target]
255
- for tgt in ["preview", "draft", "live"]:
256
- if tgt not in targets:
257
- targets.append(tgt)
258
-
259
- for t in targets:
260
- log.debug("Requesting the product ID \"%s\" with state \"%s\".", product_id, t)
261
- try:
262
- resp = self.session.get(
263
- path=f"/resource-tree/product/{product_id}", params={"targetType": t}
264
- )
265
- data = self._assert_dict(resp)
266
- return Product.from_json(data)
267
- except (ValueError, HTTPError):
268
- log.debug("Couldn't find the product \"%s\" with state \"%s\"", product_id, t)
287
+ log.info("Requesting the product ID \"%s\" with state \"%s\".", product_id, target)
288
+ try:
289
+ resp = self.session.get(
290
+ path=f"/resource-tree/product/{product_id}", params={"targetType": target}
291
+ )
292
+ data = self._assert_dict(resp)
293
+ return Product.from_json(data)
294
+ except (ValueError, HTTPError):
295
+ log.debug("Couldn't find the product \"%s\" with state \"%s\"", product_id, target)
269
296
  self._raise_error(NotFoundError, f"No such product with id \"{product_id}\"")
270
297
 
271
- def get_product_by_name(self, product_name: str, first_target: str = "preview") -> Product:
298
+ def get_product_by_name(self, product_name: str, target: str) -> Product:
272
299
  """
273
300
  Return the requested Product by its name from Legacy CPP API.
274
301
 
275
302
  Args:
276
303
  product_name (str)
277
304
  The product name according to Legacy CPP API.
278
- first_target (str, optional)
279
- The first target to lookup into. Defaults to ``preview``.
305
+ target (str, optional)
306
+ The submission target to retrieve the product from.
280
307
  Returns:
281
308
  Product: the requested product when found
282
309
  Raises:
@@ -285,7 +312,7 @@ class AzureService(BaseService[AzurePublishingMetadata]):
285
312
  for product in self.products:
286
313
  if product.identity.name == product_name:
287
314
  log.debug("Product alias \"%s\" has the ID \"%s\"", product_name, product.id)
288
- return self.get_product(product.id, first_target=first_target)
315
+ return self.get_product(product.id, target=target)
289
316
  self._raise_error(NotFoundError, f"No such product with name \"{product_name}\"")
290
317
 
291
318
  def get_submissions(self, product_id: str) -> List[ProductSubmission]:
@@ -314,6 +341,7 @@ class AzureService(BaseService[AzurePublishingMetadata]):
314
341
  Returns:
315
342
  Optional[ProductSubmission]: The requested submission when found.
316
343
  """
344
+ log.info("Looking up for submission in state \"%s\" for \"%s\"", state, product_id)
317
345
  submissions = self.get_submissions(product_id)
318
346
  for sub in submissions:
319
347
  if sub.target.targetType == state:
@@ -369,47 +397,49 @@ class AzureService(BaseService[AzurePublishingMetadata]):
369
397
  self._raise_error(NotFoundError, f"No such plan with name \"{plan_name}\"")
370
398
 
371
399
  def get_product_plan_by_name(
372
- self, product_name: str, plan_name: str
400
+ self,
401
+ product_name: str,
402
+ plan_name: str,
403
+ target: str,
373
404
  ) -> Tuple[Product, PlanSummary]:
374
405
  """Return a tuple with the desired Product and Plan after iterating over all targets.
375
406
 
376
407
  Args:
377
408
  product_name (str): The name of the product to search for
378
409
  plan_name (str): The name of the plan to search for
379
-
410
+ target (str)
411
+ The submission target to retrieve the product/plan from.
380
412
  Returns:
381
413
  Tuple[Product, PlanSummary]: The Product and PlanSummary when fonud
382
414
  Raises:
383
- NotFoundError whenever all targets are exhausted and no information was found
384
- """
385
- targets = ["preview", "draft", "live"]
386
-
387
- for tgt in targets:
388
- try:
389
- product = self.get_product_by_name(product_name, first_target=tgt)
390
- plan = self.get_plan_by_name(product, plan_name)
391
- return product, plan
392
- except NotFoundError:
393
- continue
394
- self._raise_error(
395
- NotFoundError, f"No such plan with name \"{plan_name} for {product_name}\""
396
- )
415
+ NotFoundError whenever no information was found in the respective submission target.
416
+ """
417
+ try:
418
+ product = self.get_product_by_name(product_name, target=target)
419
+ plan = self.get_plan_by_name(product, plan_name)
420
+ return product, plan
421
+ except NotFoundError:
422
+ self._raise_error(
423
+ NotFoundError, f"No such plan with name \"{plan_name} for {product_name}\""
424
+ )
397
425
 
398
- def diff_offer(self, product: Product, first_target="preview") -> DeepDiff:
426
+ def diff_offer(self, product: Product, target: str) -> DeepDiff:
399
427
  """Compute the difference between the provided product and the one in the remote.
400
428
 
401
429
  Args:
402
430
  product (Product)
403
431
  The local product to diff with the remote one.
404
- first_target (str)
405
- The first target to lookup into. Defaults to ``preview``.
432
+ target (str)
433
+ The submission target to retrieve the product from.
406
434
  Returns:
407
435
  DeepDiff: The diff data.
408
436
  """
409
- remote = self.get_product(product.id, first_target=first_target)
437
+ remote = self.get_product(product.id, target=target)
410
438
  return DeepDiff(remote.to_json(), product.to_json(), exclude_regex_paths=self.DIFF_EXCLUDES)
411
439
 
412
- def submit_to_status(self, product_id: str, status: str) -> ConfigureStatus:
440
+ def submit_to_status(
441
+ self, product_id: str, status: str, resources: Optional[List[AzureResource]] = None
442
+ ) -> ConfigureStatus:
413
443
  """
414
444
  Send a submission request to Microsoft with a new Product status.
415
445
 
@@ -418,9 +448,12 @@ class AzureService(BaseService[AzurePublishingMetadata]):
418
448
  The product ID to submit the new status.
419
449
  status (str)
420
450
  The new status: 'preview' or 'live'
451
+ resources (optional(list(AzureRerouce)))
452
+ Additional resources for modular push.
421
453
  Returns:
422
454
  The response from configure request.
423
455
  """
456
+ log.info("Submitting the status of \"%s\" to \"%s\"", product_id, status)
424
457
  # We need to get the previous state of the given one to request the submission
425
458
  prev_state_mapping = {
426
459
  "preview": "draft",
@@ -437,9 +470,12 @@ class AzureService(BaseService[AzurePublishingMetadata]):
437
470
 
438
471
  # Update the status with the expected one
439
472
  submission.target.targetType = status
473
+ cfg_res: List[AzureResource] = [submission]
474
+ if resources:
475
+ log.info("Performing a modular push to \"%s\" for \"%s\"", status, product_id)
476
+ cfg_res = resources + cfg_res
440
477
  log.debug("Set the status \"%s\" to submission.", status)
441
-
442
- return self.configure(resource=submission)
478
+ return self.configure(resources=cfg_res)
443
479
 
444
480
  @retry(
445
481
  wait=wait_fixed(300),
@@ -459,6 +495,7 @@ class AzureService(BaseService[AzurePublishingMetadata]):
459
495
  Raises:
460
496
  RuntimeError: whenever a publishing is already in progress.
461
497
  """
498
+ log.info("Ensuring no other publishing jobs are in progress for \"%s\"", product_id)
462
499
  submission_targets = ["preview", "live"]
463
500
 
464
501
  for target in submission_targets:
@@ -491,6 +528,79 @@ class AzureService(BaseService[AzurePublishingMetadata]):
491
528
  )
492
529
  return tconfigs[0] # It should have only one VMIPlanTechConfig per plan.
493
530
 
531
+ def get_modular_resources_to_publish(
532
+ self, product: Product, tech_config: VMIPlanTechConfig
533
+ ) -> List[AzureResource]:
534
+ """Return the required resources for a modular publishing.
535
+
536
+ According to Microsoft docs:
537
+ "For a modular publish, all resources are required except for the product level details
538
+ (for example, listing, availability, packages, reseller) as applicable to your
539
+ product type."
540
+
541
+ Args:
542
+ product (Product): The original product to filter the resources from
543
+ tech_config (VMIPlanTechConfig): The updated tech config to publish
544
+
545
+ Returns:
546
+ List[AzureResource]: _description_
547
+ """
548
+ # The following resources shouldn't be required:
549
+ # -> customer-leads
550
+ # -> test-drive
551
+ # -> property
552
+ # -> *listing*
553
+ # -> reseller
554
+ # -> price-and-availability-*
555
+ # NOTE: The "submission" resource will be already added by the "submit_to_status" method
556
+ #
557
+ # With that it needs only the related "product" and "plan" resources alongisde the
558
+ # updated tech_config
559
+ product_id = tech_config.product_id
560
+ plan_id = tech_config.plan_id
561
+ prod_res = cast(
562
+ List[ProductSummary],
563
+ [
564
+ prd
565
+ for prd in self.filter_product_resources(product=product, resource="product")
566
+ if prd.id == product_id
567
+ ],
568
+ )[0]
569
+ plan_res = cast(
570
+ List[PlanSummary],
571
+ [
572
+ pln
573
+ for pln in self.filter_product_resources(product=product, resource="plan")
574
+ if pln.id == plan_id
575
+ ],
576
+ )[0]
577
+ return [prod_res, plan_res, tech_config]
578
+
579
+ def compute_targets(self, product_id: str) -> List[str]:
580
+ """List all the possible publishing targets order to seek data from Azure.
581
+
582
+ It also returns the ordered list of targets with the following precedence:
583
+ ``live`` -> ``preview`` -> ``draft``
584
+
585
+ Args:
586
+ product_id (str)
587
+ The product_id to retrieve all existing submission targets.
588
+
589
+ Returns:
590
+ List[Str]: The ordered list with targets to lookup.
591
+ """
592
+ all_targets = ["live", "preview", "draft"]
593
+ computed_targets = []
594
+
595
+ # We cannot simply return all targets above because the existing product might
596
+ # lack one of them. So now we need to filter out unexisting targets.
597
+ product_submissions = self.get_submissions(product_id)
598
+ product_targets = [s.target.targetType for s in product_submissions]
599
+ for t in all_targets:
600
+ if t in product_targets:
601
+ computed_targets.append(t)
602
+ return computed_targets
603
+
494
604
  def _is_submission_in_preview(self, current: ProductSubmission) -> bool:
495
605
  """Return True if the latest submission state is "preview", False otherwise.
496
606
 
@@ -518,42 +628,33 @@ class AzureService(BaseService[AzurePublishingMetadata]):
518
628
  stop=stop_after_attempt(3),
519
629
  reraise=True,
520
630
  )
521
- def _publish_preview(self, product: Product, product_name: str) -> None:
631
+ def _publish_preview(
632
+ self, product: Product, product_name: str, resources: Optional[List[AzureResource]] = None
633
+ ) -> None:
522
634
  """
523
- Submit the product to 'preview' if it's not already in this state.
635
+ Submit the product to 'preview' after going through Azure Marketplace Validatoin.
524
636
 
525
637
  This is required to execute the validation pipeline on Azure side.
526
638
 
527
639
  Args:
528
640
  product
529
- The product with changes to publish live
641
+ The product with changes to publish to preview
530
642
  product_name
531
643
  The product name to display in logs.
644
+ resources:
645
+ Additional resources for modular push.
532
646
  """
533
- # We just want to set the ProductSubmission to 'preview' if it's not in this status.
534
- #
535
- # The `preview` stage runs the Azure pipeline which takes up to 4 days.
536
- # Meanwhile the `submit_for_status` will be blocked querying the `job_status`until
537
- # all the Azure verification pipeline finishes.
538
- submission: ProductSubmission = cast(
539
- List[ProductSubmission],
540
- self.filter_product_resources(product=product, resource="submission"),
541
- )[0]
542
- if not self._is_submission_in_preview(submission):
543
- log.info(
544
- "Submitting the product \"%s (%s)\" to \"preview\"." % (product_name, product.id)
647
+ res = self.submit_to_status(product_id=product.id, status='preview', resources=resources)
648
+
649
+ if res.job_result != 'succeeded' or not self.get_submission_state(
650
+ product.id, state="preview"
651
+ ):
652
+ errors = "\n".join(res.errors)
653
+ failure_msg = (
654
+ f"Failed to submit the product {product_name} ({product.id}) to preview. "
655
+ f"Status: {res.job_result} Errors: {errors}"
545
656
  )
546
- res = self.submit_to_status(product_id=product.id, status='preview')
547
-
548
- if res.job_result != 'succeeded' or not self.get_submission_state(
549
- product.id, state="preview"
550
- ):
551
- errors = "\n".join(res.errors)
552
- failure_msg = (
553
- f"Failed to submit the product {product.id} to preview. "
554
- f"Status: {res.job_result} Errors: {errors}"
555
- )
556
- raise RuntimeError(failure_msg)
657
+ raise RuntimeError(failure_msg)
557
658
 
558
659
  @retry(
559
660
  wait=wait_fixed(wait=60),
@@ -572,17 +673,133 @@ class AzureService(BaseService[AzurePublishingMetadata]):
572
673
  """
573
674
  # Note: the offer can only go `live` after successfully being changed to `preview`
574
675
  # which takes up to 4 days.
575
- log.info("Submitting the product \"%s (%s)\" to \"live\"." % (product_name, product.id))
576
676
  res = self.submit_to_status(product_id=product.id, status='live')
577
677
 
578
678
  if res.job_result != 'succeeded' or not self.get_submission_state(product.id, state="live"):
579
679
  errors = "\n".join(res.errors)
580
680
  failure_msg = (
581
- f"Failed to submit the product {product.id} to live. "
681
+ f"Failed to submit the product {product_name} ({product.id}) to live. "
582
682
  f"Status: {res.job_result} Errors: {errors}"
583
683
  )
584
684
  raise RuntimeError(failure_msg)
585
685
 
686
+ def _overwrite_disk_version(
687
+ self,
688
+ metadata: AzurePublishingMetadata,
689
+ product_name: str,
690
+ plan_name: str,
691
+ source: VMImageSource,
692
+ target: str,
693
+ ) -> TechnicalConfigLookUpData:
694
+ """Private method to overwrite the technical config with a new DiskVersion.
695
+
696
+ Args:
697
+ metadata (AzurePublishingMetadata): the incoming publishing metadata
698
+ product_name (str): the product (offer) name
699
+ plan_name (str): the plan name
700
+ source (VMImageSource): the source VMI to create and overwrite the new DiskVersion
701
+ target (str): the submission target.
702
+
703
+ Returns:
704
+ TechnicalConfigLookUpData: The overwritten tech_config for the product/plan
705
+ """
706
+ product, plan = self.get_product_plan_by_name(product_name, plan_name, target)
707
+ log.warning(
708
+ "Overwriting the plan \"%s\" on \"%s\" with the given image: \"%s\".",
709
+ plan_name,
710
+ target,
711
+ metadata.image_path,
712
+ )
713
+ tech_config = self.get_plan_tech_config(product, plan)
714
+ disk_version = create_disk_version_from_scratch(metadata, source)
715
+ tech_config.disk_versions = [disk_version]
716
+ return {
717
+ "metadata": metadata,
718
+ "tech_config": tech_config,
719
+ "sas_found": False,
720
+ "product": product,
721
+ "plan": plan,
722
+ "target": target,
723
+ }
724
+
725
+ def _look_up_sas_on_technical_config(
726
+ self, metadata: AzurePublishingMetadata, product_name: str, plan_name: str, target: str
727
+ ) -> TechnicalConfigLookUpData:
728
+ """Private method to lookup for the TechnicalConfig of a given target.
729
+
730
+ Args:
731
+ metadata (AzurePublishingMetadata): the incoming publishing metadata.
732
+ product_name (str): the product (offer) name
733
+ plan_name (str): the plan name
734
+ target (str): the submission target to look up the TechnicalConfig object
735
+
736
+ Returns:
737
+ TechnicalConfigLookUpData: The data retrieved for the given submission target.
738
+ """
739
+ product, plan = self.get_product_plan_by_name(product_name, plan_name, target)
740
+ log.info(
741
+ "Retrieving the technical config for \"%s\" on \"%s\".",
742
+ metadata.destination,
743
+ target,
744
+ )
745
+ tech_config = self.get_plan_tech_config(product, plan)
746
+ sas_found = False
747
+
748
+ if is_sas_present(tech_config, metadata.image_path, metadata.check_base_sas_only):
749
+ log.info(
750
+ "The destination \"%s\" on \"%s\" already contains the SAS URI: \"%s\".",
751
+ metadata.destination,
752
+ target,
753
+ metadata.image_path,
754
+ )
755
+ sas_found = True
756
+ return {
757
+ "metadata": metadata,
758
+ "tech_config": tech_config,
759
+ "sas_found": sas_found,
760
+ "product": product,
761
+ "plan": plan,
762
+ "target": target,
763
+ }
764
+
765
+ def _create_or_update_disk_version(
766
+ self,
767
+ tech_config_lookup: TechnicalConfigLookUpData,
768
+ source: VMImageSource,
769
+ disk_version: Optional[DiskVersion],
770
+ ) -> DiskVersion:
771
+ """Private method to create/update the DiskVersion of a given TechnicalConfig object.
772
+
773
+ Args:
774
+ tech_config_lookup (TechnicalConfigLookUpData): the incoming data to process
775
+ source (VMImageSource): the new VMI source to attach
776
+ disk_version (Optional[DiskVersion]): the disk version if it exists (for updates).
777
+
778
+ Returns:
779
+ DiskVersion: The updated DiskVersion
780
+ """
781
+ metadata = tech_config_lookup["metadata"]
782
+ target = tech_config_lookup["target"]
783
+ tech_config = tech_config_lookup["tech_config"]
784
+
785
+ # Check the images of the selected DiskVersion if it exists
786
+ if disk_version:
787
+ log.info(
788
+ "DiskVersion \"%s\" exists in \"%s\" on \"%s\" for the image \"%s\".",
789
+ disk_version.version_number,
790
+ metadata.destination,
791
+ target,
792
+ metadata.image_path,
793
+ )
794
+ # Update the disk version with the new SAS
795
+ disk_version = set_new_sas_disk_version(disk_version, metadata, source)
796
+ return disk_version
797
+ # The disk version doesn't exist, we need to create one from scratch
798
+ log.info("The DiskVersion doesn't exist, creating one from scratch.")
799
+ disk_version = create_disk_version_from_scratch(metadata, source)
800
+ tech_config.disk_versions.append(disk_version)
801
+ return disk_version
802
+
586
803
  def publish(self, metadata: AzurePublishingMetadata) -> None:
587
804
  """
588
805
  Associate a VM image with a given product listing (destination) and publish it if required.
@@ -596,71 +813,102 @@ class AzureService(BaseService[AzurePublishingMetadata]):
596
813
  # "product-name/plan-name"
597
814
  product_name = metadata.destination.split("/")[0]
598
815
  plan_name = metadata.destination.split("/")[-1]
599
- product, plan = self.get_product_plan_by_name(product_name, plan_name)
816
+ product_id = self.get_productid(product_name)
817
+ sas_in_target = SasFoundStatus.missing
600
818
  log.info(
601
- "Preparing to associate the image with the plan \"%s\" from product \"%s\""
602
- % (plan_name, product_name)
819
+ "Preparing to associate the image \"%s\" with the plan \"%s\" from product \"%s\"",
820
+ metadata.image_path,
821
+ plan_name,
822
+ product_name,
603
823
  )
604
824
 
605
- # 2. Retrieve the VM Technical configuration for the given plan
606
- log.debug("Retrieving the technical config for \"%s\"." % metadata.destination)
607
- tech_config = self.get_plan_tech_config(product, plan)
608
-
609
- # 3. Prepare the Disk Version
610
- log.debug("Creating the VMImageResource with SAS: \"%s\"" % metadata.image_path)
825
+ # 2. Prepare the Disk Version
826
+ log.info("Creating the VMImageResource with SAS for image: \"%s\"", metadata.image_path)
611
827
  sas = OSDiskURI(uri=metadata.image_path)
612
828
  source = VMImageSource(source_type="sasUri", os_disk=sas.to_json(), data_disks=[])
613
829
 
830
+ # 3. Set the new Disk Version into the product/plan if required
831
+ #
614
832
  # Note: If `overwrite` is True it means we can set this VM image as the only one in the
615
833
  # plan's technical config and discard all other VM images which may've been present.
616
- disk_version = None # just to make mypy happy
617
834
  if metadata.overwrite is True:
618
- log.warning("Overwriting the plan %s with the given image.", plan_name)
619
- disk_version = create_disk_version_from_scratch(metadata, source)
620
- tech_config.disk_versions = [disk_version]
621
-
622
- # We just want to append a new image if the SAS is not already present.
623
- elif not is_sas_present(tech_config, metadata.image_path, metadata.check_base_sas_only):
624
- # Here we can have the metadata.disk_version set or empty.
625
- # When set we want to get the existing disk_version which matches its value.
626
- log.debug("Scanning the disk versions from %s" % metadata.destination)
627
- disk_version = seek_disk_version(tech_config, metadata.disk_version)
628
-
629
- # Check the images of the selected DiskVersion if it exists
630
- if disk_version:
631
- log.debug(
632
- "DiskVersion \"%s\" exists in \"%s\"."
633
- % (disk_version.version_number, metadata.destination)
634
- )
635
- disk_version = set_new_sas_disk_version(disk_version, metadata, source)
636
-
637
- else: # The disk version doesn't exist, we need to create one from scratch
638
- log.debug("The DiskVersion doesn't exist, creating one from scratch.")
639
- disk_version = create_disk_version_from_scratch(metadata, source)
640
- tech_config.disk_versions.append(disk_version)
835
+ target = "draft" # It's expected to exist for whenever product.
836
+ res = self._overwrite_disk_version(metadata, product_name, plan_name, source, target)
837
+ tech_config = res["tech_config"]
641
838
  else:
642
- log.info(
643
- "The destination \"%s\" already contains the SAS URI: \"%s\"."
644
- % (metadata.destination, metadata.image_path)
645
- )
839
+ # Otherwise we need to check whether SAS isn't already present
840
+ # in any of the targets "preview", "live" or "draft" and if not attach and publish it.
841
+ for target in self.compute_targets(product_id):
842
+ res = self._look_up_sas_on_technical_config(
843
+ metadata, product_name, plan_name, target
844
+ )
845
+ tech_config = res["tech_config"]
846
+ # We don't want to seek for SAS anymore as it was already found
847
+ if res["sas_found"]:
848
+ sas_in_target = SasFoundStatus[target]
849
+ break
850
+ else:
851
+ # At this point there's no SAS URI in any target so we can safely add it
852
+
853
+ # Here we can have the metadata.disk_version set or empty.
854
+ # When set we want to get the existing disk_version which matches its value.
855
+ log.info(
856
+ "Scanning the disk versions from \"%s\" on \"%s\" for the image \"%s\"",
857
+ metadata.destination,
858
+ target,
859
+ metadata.image_path,
860
+ )
861
+ dv = seek_disk_version(tech_config, metadata.disk_version)
862
+ self._create_or_update_disk_version(res, source, dv)
646
863
 
647
864
  # 4. With the updated disk_version we should adjust the SKUs and submit the changes
648
- if disk_version:
649
- log.debug("Updating SKUs for \"%s\"." % metadata.destination)
865
+ if sas_in_target == SasFoundStatus.missing:
866
+ log.info("Updating SKUs for \"%s\" on \"%s\".", metadata.destination, target)
650
867
  tech_config.skus = update_skus(
651
868
  disk_versions=tech_config.disk_versions,
652
869
  generation=metadata.generation,
653
870
  plan_name=plan_name,
654
871
  old_skus=tech_config.skus,
655
872
  )
656
- log.debug("Updating the technical configuration for \"%s\"." % metadata.destination)
657
- self.configure(resource=tech_config)
873
+ log.info(
874
+ "Updating the technical configuration for \"%s\" on \"%s\".",
875
+ metadata.destination,
876
+ target,
877
+ )
878
+ self.configure(resources=[tech_config])
658
879
 
659
880
  # 5. Proceed to publishing if it was requested.
660
881
  # Note: The publishing will only occur if it made changes in disk_version.
661
- if disk_version and not metadata.keepdraft:
662
- logdiff(self.diff_offer(product))
663
- self.ensure_can_publish(product.id)
882
+ if not metadata.keepdraft:
883
+ product = res["product"]
884
+ # Get the submission state
885
+ submission: ProductSubmission = cast(
886
+ List[ProductSubmission],
887
+ self.filter_product_resources(product=product, resource="submission"),
888
+ )[0]
889
+
890
+ # We should only publish if there are new changes OR
891
+ # the existing offer was already in preview
892
+ if sas_in_target <= SasFoundStatus.draft or self._is_submission_in_preview(submission):
893
+ log.info(
894
+ "Publishing the new changes for \"%s\" on plan \"%s\"", product_name, plan_name
895
+ )
896
+ logdiff(self.diff_offer(product, target))
897
+ self.ensure_can_publish(product.id)
898
+
899
+ # According to the documentation we only need to pass the
900
+ # required resources for modular publish on "preview"
901
+ # 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
902
+ modular_resources = None
903
+ if metadata.modular_push:
904
+ modular_resources = self.get_modular_resources_to_publish(product, tech_config)
905
+ if sas_in_target < SasFoundStatus.preview:
906
+ self._publish_preview(product, product_name, resources=modular_resources)
907
+ if sas_in_target < SasFoundStatus.live:
908
+ self._publish_live(product, product_name)
664
909
 
665
- self._publish_preview(product, product_name)
666
- self._publish_live(product, product_name)
910
+ log.info(
911
+ "Finished publishing the image \"%s\" to \"%s\"",
912
+ metadata.image_path,
913
+ metadata.destination,
914
+ )
@@ -24,7 +24,7 @@ class AccessToken:
24
24
  """
25
25
  self.expires_on = datetime.fromtimestamp(int(json["expires_on"]))
26
26
  self.access_token = json["access_token"]
27
- log.debug(f"Obtained token with expiration date on {self.expires_on}")
27
+ log.debug("Obtained token with expiration date on %s", self.expires_on)
28
28
 
29
29
  def is_expired(self) -> bool:
30
30
  """Return True if the token is expired and False otherwise."""
@@ -108,7 +108,7 @@ class PartnerPortalSession:
108
108
  "AZURE_API_SECRET",
109
109
  ]
110
110
  for key in mandatory_keys:
111
- log.debug(f"Validating mandatory key \"{key}\"")
111
+ log.debug("Validating mandatory key \"%s\"", key)
112
112
  if key not in auth_keys.keys() or not auth_keys.get(key):
113
113
  err_msg = f'The key/value for "{key}" must be set.'
114
114
  log.error(err_msg)
@@ -117,7 +117,7 @@ class PartnerPortalSession:
117
117
 
118
118
  def _login(self) -> AccessToken:
119
119
  """Retrieve the authentication token from Microsoft."""
120
- log.info("Retrieving the bearer token from Microsoft")
120
+ log.debug("Retrieving the bearer token from Microsoft")
121
121
  url = self.LOGIN_URL_TMPL.format(**self.auth_keys)
122
122
 
123
123
  headers = {
@@ -156,7 +156,7 @@ class PartnerPortalSession:
156
156
  params = {}
157
157
  params.update(self._mandatory_params)
158
158
 
159
- log.info(f"Sending a {method} request to {path}")
159
+ log.debug("Sending a %s request to %s", method, path)
160
160
  formatted_url = self._prefix_url.format(**self.auth_keys)
161
161
  url = join_url(formatted_url, path)
162
162
  return self.session.request(method, url=url, params=params, headers=headers, **kwargs)
@@ -1,7 +1,7 @@
1
1
  # SPDX-License-Identifier: GPL-3.0-or-later
2
2
  import logging
3
3
  from operator import attrgetter
4
- from typing import Any, Dict, List, Optional, Tuple
4
+ from typing import Any, Dict, List, Optional, Tuple, TypedDict
5
5
 
6
6
  from deepdiff import DeepDiff
7
7
 
@@ -9,6 +9,8 @@ from cloudpub.common import PublishingMetadata # Cannot circular import AzurePu
9
9
  from cloudpub.models.ms_azure import (
10
10
  ConfigureStatus,
11
11
  DiskVersion,
12
+ PlanSummary,
13
+ Product,
12
14
  VMImageDefinition,
13
15
  VMImageSource,
14
16
  VMIPlanTechConfig,
@@ -54,6 +56,11 @@ class AzurePublishingMetadata(PublishingMetadata):
54
56
  check_base_sas_only (bool, optional):
55
57
  Indicates to skip checking SAS parameters when set as ``True``.
56
58
  Default to ``False``
59
+ modular_push (bool, optional):
60
+ Indicate whether to perform a modular push or not.
61
+ The modular push causes the effect to only publish
62
+ the changed plan instead of the whole offer to preview/live.
63
+ Default to ``False``.
57
64
  **kwargs
58
65
  Arguments for :class:`~cloudpub.common.PublishingMetadata`.
59
66
  """
@@ -64,6 +71,7 @@ class AzurePublishingMetadata(PublishingMetadata):
64
71
  self.recommended_sizes = recommended_sizes or []
65
72
  self.legacy_sku_id = kwargs.pop("legacy_sku_id", None)
66
73
  self.check_base_sas_only = kwargs.pop("check_base_sas_only", False)
74
+ self.modular_push = kwargs.pop("modular_push", None) or False
67
75
 
68
76
  if generation == "V1" or not support_legacy:
69
77
  self.legacy_sku_id = None
@@ -107,6 +115,17 @@ class AzurePublishingMetadata(PublishingMetadata):
107
115
  raise ValueError(f"Invalid SAS URI \"{self.image_path}\". Expected: http/https URL.")
108
116
 
109
117
 
118
+ class TechnicalConfigLookUpData(TypedDict):
119
+ """A typed dict to be used for private methods data exchange."""
120
+
121
+ metadata: AzurePublishingMetadata
122
+ tech_config: VMIPlanTechConfig
123
+ sas_found: bool
124
+ product: Product
125
+ plan: PlanSummary
126
+ target: str
127
+
128
+
110
129
  def get_image_type_mapping(architecture: str, generation: str) -> str:
111
130
  """Return the image type required by VMImageDefinition."""
112
131
  gen_map = {
@@ -150,22 +169,23 @@ def is_sas_eq(sas1: str, sas2: str, base_only=False) -> bool:
150
169
 
151
170
  # Base URL differs
152
171
  if base_sas1 != base_sas2:
153
- log.debug("Got different base SAS: %s - Expected: %s" % (base_sas1, base_sas2))
172
+ log.debug("Got different base SAS: %s - Expected: %s", base_sas1, base_sas2)
154
173
  return False
155
174
 
156
175
  if not base_only:
157
176
  # Parameters lengh differs
158
177
  if len(params_sas1) != len(params_sas2):
159
178
  log.debug(
160
- "Got different lengh of SAS parameters: len(%s) - Expected len(%s)"
161
- % (params_sas1, params_sas2)
179
+ "Got different lengh of SAS parameters: len(%s) - Expected len(%s)",
180
+ params_sas1,
181
+ params_sas2,
162
182
  )
163
183
  return False
164
184
 
165
185
  # Parameters values differs
166
186
  for k, v in params_sas1.items():
167
187
  if v != params_sas2.get(k, None):
168
- log.debug("The SAS parameter %s doesn't match %s." % (v, params_sas2.get(k, None)))
188
+ log.debug("The SAS parameter %s doesn't match %s.", v, params_sas2.get(k, None))
169
189
  return False
170
190
 
171
191
  # Equivalent SAS
@@ -203,8 +223,8 @@ def is_azure_job_not_complete(job_details: ConfigureStatus) -> bool:
203
223
  Returns:
204
224
  bool: False if job completed, True otherwise
205
225
  """
206
- log.debug(f"Checking if the job \"{job_details.job_id}\" is still running")
207
- log.debug(f"job {job_details.job_id} is in {job_details.job_status} state")
226
+ log.debug("Checking if the job \"%s\" is still running", job_details.job_id)
227
+ log.debug("job %s is in %s state", job_details.job_id, job_details.job_status)
208
228
  if job_details.job_status != "completed":
209
229
  return True
210
230
  return False
@@ -556,16 +576,18 @@ def set_new_sas_disk_version(
556
576
  Returns:
557
577
  The changed disk version with the given source.
558
578
  """
579
+ log.info("Setting up a new SAS disk version for \"%s\"", metadata.image_path)
559
580
  # If we already have a VMImageDefinition let's use it
560
581
  if disk_version.vm_images:
561
- log.debug("The DiskVersion \"%s\" contains inner images." % disk_version.version_number)
582
+ log.debug("The DiskVersion \"%s\" contains inner images.", disk_version.version_number)
562
583
  img, img_legacy = vm_images_by_generation(disk_version, metadata.architecture)
563
584
 
564
585
  # Now we replace the SAS URI for the vm_images
565
- log.debug(
586
+ log.info(
566
587
  "Adjusting the VMImages from existing DiskVersion \"%s\""
567
- "to fit the new image with SAS \"%s\"."
568
- % (disk_version.version_number, metadata.image_path)
588
+ "to fit the new image with SAS \"%s\".",
589
+ disk_version.version_number,
590
+ metadata.image_path,
569
591
  )
570
592
  disk_version.vm_images = prepare_vm_images(
571
593
  metadata=metadata,
@@ -577,11 +599,12 @@ def set_new_sas_disk_version(
577
599
  # If no VMImages, we need to create them from scratch
578
600
  else:
579
601
  log.debug(
580
- "The DiskVersion \"%s\" does not contain inner images." % disk_version.version_number
602
+ "The DiskVersion \"%s\" does not contain inner images.", disk_version.version_number
581
603
  )
582
- log.debug(
583
- "Setting the new image \"%s\" on DiskVersion \"%s\"."
584
- % (metadata.image_path, disk_version.version_number)
604
+ log.info(
605
+ "Setting the new image \"%s\" on DiskVersion \"%s\".",
606
+ metadata.image_path,
607
+ disk_version.version_number,
585
608
  )
586
609
  disk_version.vm_images = create_vm_image_definitions(metadata, source)
587
610
 
@@ -591,4 +614,4 @@ def set_new_sas_disk_version(
591
614
  def logdiff(diff: DeepDiff) -> None:
592
615
  """Log the offer diff if it exists."""
593
616
  if diff:
594
- log.warning(f"Found the following offer diff before publishing:\n{diff.pretty()}")
617
+ log.warning("Found the following offer diff before publishing:\n%s", diff.pretty())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cloudpub
3
- Version: 1.5.0
3
+ Version: 1.6.0
4
4
  Summary: Services for publishing products in cloud environments
5
5
  Home-page: https://github.com/release-engineering/cloudpub
6
6
  Author: Jonathan Gangi
@@ -10,11 +10,11 @@ cloudpub/models/aws.py,sha256=arzFqLmFw8O9Otk_VatLR5dmQ9FsdWT3f0Ibap7EW0o,42850
10
10
  cloudpub/models/common.py,sha256=iZ503VVFL9y0P_wXiK0f3flXV32VWBs9i-9NoYfJZUg,4970
11
11
  cloudpub/models/ms_azure.py,sha256=nzTp9IvAW-WEJuN20IAc93yY6YPHCTE0j116EfQUsPg,55974
12
12
  cloudpub/ms_azure/__init__.py,sha256=eeYXPd_wzDBmh0Hmzd5o4yzocFzM6n4r8qpCDy00kYk,117
13
- cloudpub/ms_azure/service.py,sha256=cMgKcOfgW0UwPPSGGH6Iiyqz2JidrEeowiY-SHq1mSU,26589
14
- cloudpub/ms_azure/session.py,sha256=7ZjBLBX4XSzx60Bxhn96kh64RJ3oQs734Tw3ZVSnFrU,6349
15
- cloudpub/ms_azure/utils.py,sha256=h6bEtMrlPsbayR-SlVEzlzxEC1i4fdSqH8Fn-m_xaMQ,20730
16
- cloudpub-1.5.0.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
17
- cloudpub-1.5.0.dist-info/METADATA,sha256=06r2pdV_DeEMDPue5D5iQ3rrmQe5fsEHF21zvlEFdFo,754
18
- cloudpub-1.5.0.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
19
- cloudpub-1.5.0.dist-info/top_level.txt,sha256=YnnJuTiWBpRI9zMkYUVcZNuvjzzJYblASj-7Q8m3Gzg,9
20
- cloudpub-1.5.0.dist-info/RECORD,,
13
+ cloudpub/ms_azure/service.py,sha256=ySc3ktibNhMw-ZvTgTIB1EZjliYu0OYSBi8sRBlpslg,36250
14
+ cloudpub/ms_azure/session.py,sha256=PXCSJ1dFkx43lQV0WFPnRxbpyOBccdtrMiWGPORT3Ro,6356
15
+ cloudpub/ms_azure/utils.py,sha256=goADEmIZBFuIDG5sH9dAZed_EWIUWUAtjzDTw9HyNsI,21512
16
+ cloudpub-1.6.0.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
17
+ cloudpub-1.6.0.dist-info/METADATA,sha256=bXsSCTG8RdWlsD5SjlqYrWgxsOQKOL5bRwk-fVVVP6Y,754
18
+ cloudpub-1.6.0.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
19
+ cloudpub-1.6.0.dist-info/top_level.txt,sha256=YnnJuTiWBpRI9zMkYUVcZNuvjzzJYblASj-7Q8m3Gzg,9
20
+ cloudpub-1.6.0.dist-info/RECORD,,