qontract-reconcile 0.10.1rc885__py3-none-any.whl → 0.10.1rc887__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: qontract-reconcile
3
- Version: 0.10.1rc885
3
+ Version: 0.10.1rc887
4
4
  Summary: Collection of tools to reconcile services with their desired state as defined in the app-interface DB.
5
5
  Home-page: https://github.com/app-sre/qontract-reconcile
6
6
  Author: Red Hat App-SRE Team
@@ -428,7 +428,7 @@ reconcile/saas_auto_promotions_manager/integration.py,sha256=haXTpwi0rEQtN9-MadB
428
428
  reconcile/saas_auto_promotions_manager/meta.py,sha256=76Jp50r6Y_KyJoXFfSjrt5YrCtXyg_A4FXXxHYiS3TE,161
429
429
  reconcile/saas_auto_promotions_manager/publisher.py,sha256=IZGu-PMffyk3fNL8QcZ2VBmYTZ5zigoCwNJ4_Ak6-C8,2966
430
430
  reconcile/saas_auto_promotions_manager/s3_exporter.py,sha256=IKlVWZmiPnvl7sKeF6JgAlhXZe5CovKTxQc0SNkNSx4,2583
431
- reconcile/saas_auto_promotions_manager/subscriber.py,sha256=YPzlv8jptvvWgMIoXn4Jtk7-LST8grrVyi0-qTpqWQQ,8474
431
+ reconcile/saas_auto_promotions_manager/subscriber.py,sha256=A-d_qIUo5jbseEaAN3YlVU46MQAVCw28j5HvORgaGtw,9149
432
432
  reconcile/saas_auto_promotions_manager/merge_request_manager/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
433
433
  reconcile/saas_auto_promotions_manager/merge_request_manager/batcher.py,sha256=CP392gq0yntzEkqpMJl2j-N4CGfFDFbBfK77J7Oo5Pg,7817
434
434
  reconcile/saas_auto_promotions_manager/merge_request_manager/desired_state.py,sha256=isY8frVsL3PlcdZmdZ4O0qyp76oczl4DUMX9uMArs5Y,1222
@@ -439,7 +439,7 @@ reconcile/saas_auto_promotions_manager/merge_request_manager/mr_parser.py,sha256
439
439
  reconcile/saas_auto_promotions_manager/merge_request_manager/open_merge_requests.py,sha256=-qGQOh6Jdp4lomNDij3zWVC0pl6uPHFWS5Woqcp5HQk,410
440
440
  reconcile/saas_auto_promotions_manager/merge_request_manager/renderer.py,sha256=IZ7cuH6uOi7f0aIPVi1irBmP0CIK5vmEuhKBJz4YA1s,7235
441
441
  reconcile/saas_auto_promotions_manager/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
442
- reconcile/saas_auto_promotions_manager/utils/saas_files_inventory.py,sha256=ZZ7JL6VPfKasq-XXi6CL2UZ89jOcC9uwLW1e8LMvgws,8187
442
+ reconcile/saas_auto_promotions_manager/utils/saas_files_inventory.py,sha256=rGvBWGcpryM-W3nasBET-PHAXY2ZZL9ihcTe70qoQEA,8660
443
443
  reconcile/skupper_network/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
444
444
  reconcile/skupper_network/integration.py,sha256=GEFlQTjK4DYXnvrQtsvFdZlYsbiFabM7IdBX69ZXWcY,10757
445
445
  reconcile/skupper_network/models.py,sha256=HEwlVKsbmMaKaaBGvITIiSYNEVdjwXVhLaOJgLSZ2xQ,6604
@@ -560,7 +560,7 @@ reconcile/test/test_version_bump.py,sha256=q6-3Y1roriI6YWpFwaHOMN7emEP3yL33sh_0V
560
560
  reconcile/test/test_vpc_peerings_validator.py,sha256=dFSmjc_dMN2GqMbntCFpa7PUZmyYuQ9DKffh-T5wmxM,6639
561
561
  reconcile/test/test_wrong_region.py,sha256=7KzL7OaICQ9Z3DW27zt_ykMN7_87owAFC-2CYjvGoyA,2138
562
562
  reconcile/test/saas_auto_promotions_manager/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
563
- reconcile/test/saas_auto_promotions_manager/conftest.py,sha256=WqTFZAqCtWBCl8vdY1xGXpxLTMxXDAaZJ65UmaMSs1Y,5966
563
+ reconcile/test/saas_auto_promotions_manager/conftest.py,sha256=tF6YMgeh9PHxlKsihL8qdLEgixk_k6mOGV1qS7ukHYI,6029
564
564
  reconcile/test/saas_auto_promotions_manager/test_integration_test.py,sha256=S30eXJSy2Vc3YLbCP7AfLkOiFGUVoKhEvEBL5vwnbfg,1848
565
565
  reconcile/test/saas_auto_promotions_manager/merge_request_manager/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
566
566
  reconcile/test/saas_auto_promotions_manager/merge_request_manager/merge_request_manager/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -810,7 +810,7 @@ tools/app_interface_metrics_exporter.py,sha256=zkwkxdAUAxjdc-pzx2_oJXG25fo0Fnyd5
810
810
  tools/app_interface_reporter.py,sha256=uy9eRHf6EdvD8ZY2WYdroGXm18DOdnqVZyxaWN3Bm_0,17724
811
811
  tools/glitchtip_access_reporter.py,sha256=oPBnk_YoDuljU3v0FaChzOwwnk4vap1xEE67QEjzdqs,2948
812
812
  tools/glitchtip_access_revalidation.py,sha256=8kbBJk04mkq28kWoRDDkfCGIF3GRg3pJrFAh1sW0dbk,2821
813
- tools/qontract_cli.py,sha256=xl_264MCbBAV-_YgZeBTVHnWPSagIzgbHNoQYEKWcFg,120361
813
+ tools/qontract_cli.py,sha256=PYFiVIc37qFOl3UxXt04o-V50Leu37pAFndTRMu_WBs,121059
814
814
  tools/sd_app_sre_alert_report.py,sha256=e9vAdyenUz2f5c8-z-5WY0wv-SJ9aePKDH2r4IwB6pc,5063
815
815
  tools/template_validation.py,sha256=qpKYaTgk0GOPGa2Ct5_5sKdwIHtCAKIBGzsMPuJU5fw,3371
816
816
  tools/cli_commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -830,16 +830,19 @@ tools/saas_metrics_exporter/commit_distance/__init__.py,sha256=47DEQpj8HBSa-_TIm
830
830
  tools/saas_metrics_exporter/commit_distance/channel.py,sha256=XEAh3eL8TmgMe7V2BsyxuXYWgvBBVdSJETd6Ec7cI04,2171
831
831
  tools/saas_metrics_exporter/commit_distance/commit_distance.py,sha256=pUWaZfZf0TYOwAW0gDdU8cYgTR586J_8S_DWxqHMWNA,3116
832
832
  tools/saas_metrics_exporter/commit_distance/metrics.py,sha256=5-y6n-sGACAS3eJ5ndY-2BFxcd0fxLfhvZmmBHu4JuA,426
833
+ tools/saas_promotion_state/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
834
+ tools/saas_promotion_state/saas_promotion_state.py,sha256=_jP9E8-VcWho6FIOGdcjNN6uvMVhpdXOMHw59qNnEmE,2855
833
835
  tools/sre_checkpoints/__init__.py,sha256=CDaDaywJnmRCLyl_NCcvxi-Zc0hTi_3OdwKiFOyS39I,145
834
836
  tools/sre_checkpoints/util.py,sha256=zEDbGr18ZeHNQwW8pUsr2JRjuXIPz--WAGJxZo9sv_Y,894
835
837
  tools/test/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
836
- tools/test/conftest.py,sha256=YD8rdUD3XJAV_TvdzlhXkImaMQ3CU6m-8eYRkbc9qpg,993
838
+ tools/test/conftest.py,sha256=YLtiauk_StNFE-lirLnfG_BpJmlB2NGMZISE9A4zwvk,2421
837
839
  tools/test/test_app_interface_metrics_exporter.py,sha256=SX7qL3D1SIRKFo95FoQztvftCWEEf-g1mfXOtgCog-g,1271
838
840
  tools/test/test_qontract_cli.py,sha256=_D61RFGAN5x44CY1tYbouhlGXXABwYfxKSWSQx3Jrss,4941
841
+ tools/test/test_saas_promotion_state.py,sha256=48Qe5UA5WTI5NVgL7Nz0TSS77osetcijfHNCNdsHfSI,2726
839
842
  tools/test/test_sd_app_sre_alert_report.py,sha256=v363r9zM7__0kR5K6mvJoGFcM9BvE33fWAayrqkpojA,2116
840
843
  tools/test/test_sre_checkpoints.py,sha256=SKqPPTl9ua0RFdSSofnoQX-JZE6dFLO3LRhfQzqtfh8,2607
841
- qontract_reconcile-0.10.1rc885.dist-info/METADATA,sha256=nIpGToZHpAippnwf9bsAGA-Yr4rK5f_Cy_dpDPR8Ook,2273
842
- qontract_reconcile-0.10.1rc885.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
843
- qontract_reconcile-0.10.1rc885.dist-info/entry_points.txt,sha256=GKQqCl2j2X1BJQ69een6rHcR26PmnxnONLNOQB-nRjY,491
844
- qontract_reconcile-0.10.1rc885.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
845
- qontract_reconcile-0.10.1rc885.dist-info/RECORD,,
844
+ qontract_reconcile-0.10.1rc887.dist-info/METADATA,sha256=hsI7-7-pLKcnuPvfEEaiX0kI_9dg2RVn3ADXezJXa1Q,2273
845
+ qontract_reconcile-0.10.1rc887.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
846
+ qontract_reconcile-0.10.1rc887.dist-info/entry_points.txt,sha256=GKQqCl2j2X1BJQ69een6rHcR26PmnxnONLNOQB-nRjY,491
847
+ qontract_reconcile-0.10.1rc887.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
848
+ qontract_reconcile-0.10.1rc887.dist-info/RECORD,,
@@ -44,6 +44,7 @@ class Subscriber:
44
44
  use_target_config_hash: bool,
45
45
  uid: str,
46
46
  soak_days: int,
47
+ blocked_versions: set[str],
47
48
  ):
48
49
  self.saas_name = saas_name
49
50
  self.template_name = template_name
@@ -58,6 +59,7 @@ class Subscriber:
58
59
  self.soak_days = soak_days
59
60
  self._content_hash = ""
60
61
  self._use_target_config_hash = use_target_config_hash
62
+ self._blocked_versions = blocked_versions
61
63
 
62
64
  def has_diff(self) -> bool:
63
65
  current_hashes = {
@@ -141,22 +143,42 @@ class Subscriber:
141
143
  break
142
144
  publisher_refs.add(publisher.commit_sha)
143
145
 
144
- if len(publisher_refs) > 1:
146
+ # By default we keep current state
147
+ self.desired_ref = self.ref
148
+
149
+ if any_bad_deployment:
150
+ logging.info(
151
+ "Subscriber at path %s promotion stopped because of bad publisher deployment",
152
+ self.target_file_path,
153
+ )
154
+ return
155
+
156
+ if len(publisher_refs) != 1:
145
157
  logging.info(
146
158
  "Publishers for subscriber at path %s have mismatching refs: %s",
147
159
  self.target_file_path,
148
160
  publisher_refs,
149
161
  )
150
- if (
151
- len(publisher_refs) != 1
152
- or any_bad_deployment
153
- or not self._passed_accumulated_soak_days()
154
- ):
155
- # We keep current state
156
- self.desired_ref = self.ref
157
- else:
158
- # We have a common single publisher ref w/o any deployment issues
159
- self.desired_ref = next(iter(publisher_refs))
162
+ return
163
+
164
+ if not self._passed_accumulated_soak_days():
165
+ logging.debug(
166
+ "Subscriber at path %s promotion stopped because of soak days",
167
+ self.target_file_path,
168
+ )
169
+ return
170
+
171
+ desired_ref = next(iter(publisher_refs))
172
+ if desired_ref in self._blocked_versions:
173
+ logging.info(
174
+ "Subscriber at path %s promotion stopped because of blocked ref: %s",
175
+ self.target_file_path,
176
+ desired_ref,
177
+ )
178
+ return
179
+
180
+ # Passed all gates -> lets promote desired ref
181
+ self.desired_ref = desired_ref
160
182
 
161
183
  def _compute_desired_config_hashes(self) -> None:
162
184
  """
@@ -86,6 +86,10 @@ class SaasFilesInventory:
86
86
 
87
87
  def _assemble_subscribers_with_auto_promotions(self) -> None:
88
88
  for saas_file in self._saas_files:
89
+ blocked_versions: dict[str, set[str]] = {}
90
+ for code_component in saas_file.app.code_components or []:
91
+ for version in code_component.blocked_versions or []:
92
+ blocked_versions.setdefault(code_component.url, set()).add(version)
89
93
  for resource_template in saas_file.resource_templates:
90
94
  for target in resource_template.targets:
91
95
  file_path = target.path if target.path else saas_file.path
@@ -98,6 +102,7 @@ class SaasFilesInventory:
98
102
  soak_days = (
99
103
  target.promotion.soak_days if target.promotion.soak_days else 0
100
104
  )
105
+ resource_template.url
101
106
  subscriber = Subscriber(
102
107
  uid=target.uid(
103
108
  parent_saas_file_name=saas_file.name,
@@ -109,6 +114,9 @@ class SaasFilesInventory:
109
114
  ref=target.ref,
110
115
  target_namespace=target.namespace,
111
116
  soak_days=soak_days,
117
+ blocked_versions=blocked_versions.get(
118
+ resource_template.url, set()
119
+ ),
112
120
  # Note: this will be refactored at a later point.
113
121
  # https://issues.redhat.com/browse/APPSRE-7516
114
122
  use_target_config_hash=bool(saas_file.publish_job_logs),
@@ -158,6 +158,7 @@ def subscriber_builder(
158
158
  template_name="",
159
159
  use_target_config_hash=data.get("USE_TARGET_CONFIG_HASH", True),
160
160
  soak_days=data.get("SOAK_DAYS", 0),
161
+ blocked_versions=data.get("BLOCKED_VERSIONS", {}),
161
162
  )
162
163
  subscriber.channels = channels
163
164
  subscriber.config_hashes_by_channel_name = cur_config_hashes_by_channel
tools/qontract_cli.py CHANGED
@@ -3658,6 +3658,25 @@ def gpg_encrypt(
3658
3658
  ).execute()
3659
3659
 
3660
3660
 
3661
+ @root.command()
3662
+ @click.option("--channel", help="the channel that state is part of")
3663
+ @click.option("--sha", help="the commit sha we want state for")
3664
+ @environ(["APP_INTERFACE_STATE_BUCKET"])
3665
+ def get_promotion_state(channel: str, sha: str):
3666
+ from tools.saas_promotion_state.saas_promotion_state import (
3667
+ SaasPromotionState,
3668
+ )
3669
+
3670
+ promotion_state = SaasPromotionState.create(promotion_state=None, saas_files=None)
3671
+ for publisher_id, state in promotion_state.get(channel=channel, sha=sha).items():
3672
+ print()
3673
+ if not state:
3674
+ print(f"No state found for {publisher_id=}")
3675
+ else:
3676
+ print(f"State for {publisher_id=}:")
3677
+ print(state)
3678
+
3679
+
3661
3680
  @root.command()
3662
3681
  @click.option("--change-type-name")
3663
3682
  @click.option("--role-name")
File without changes
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+
5
+ from reconcile.openshift_saas_deploy import (
6
+ QONTRACT_INTEGRATION as OPENSHIFT_SAAS_DEPLOY,
7
+ )
8
+ from reconcile.typed_queries.app_interface_vault_settings import (
9
+ get_app_interface_vault_settings,
10
+ )
11
+ from reconcile.typed_queries.saas_files import SaasFile, get_saas_files
12
+ from reconcile.utils.promotion_state import PromotionData, PromotionState
13
+ from reconcile.utils.secret_reader import create_secret_reader
14
+ from reconcile.utils.state import init_state
15
+
16
+
17
+ class SaasPromotionState:
18
+ def __init__(
19
+ self, promotion_state: PromotionState, saas_files: Iterable[SaasFile]
20
+ ) -> None:
21
+ self._promotion_state = promotion_state
22
+ self._saas_files = saas_files
23
+
24
+ def _publisher_ids_for_channel(
25
+ self, channel: str, saas_files: Iterable[SaasFile]
26
+ ) -> list[str]:
27
+ publisher_uids: list[str] = []
28
+ for saas_file in saas_files:
29
+ for resource_template in saas_file.resource_templates:
30
+ for target in resource_template.targets:
31
+ if not target.promotion:
32
+ continue
33
+ for publish_channel in target.promotion.publish or []:
34
+ if publish_channel == channel:
35
+ publisher_uids.append(
36
+ target.uid(
37
+ parent_saas_file_name=saas_file.name,
38
+ parent_resource_template_name=resource_template.name,
39
+ )
40
+ )
41
+ return publisher_uids
42
+
43
+ def get(self, channel: str, sha: str) -> dict[str, PromotionData | None]:
44
+ return {
45
+ publisher_id: self._promotion_state.get_promotion_data(
46
+ sha=sha,
47
+ channel=channel,
48
+ use_cache=False,
49
+ target_uid=publisher_id,
50
+ pre_check_sha_exists=False,
51
+ )
52
+ for publisher_id in self._publisher_ids_for_channel(
53
+ channel=channel, saas_files=self._saas_files
54
+ )
55
+ }
56
+
57
+ @staticmethod
58
+ def create(
59
+ promotion_state: PromotionState | None, saas_files: Iterable[SaasFile] | None
60
+ ) -> SaasPromotionState:
61
+ if not promotion_state:
62
+ vault_settings = get_app_interface_vault_settings()
63
+ secret_reader = create_secret_reader(use_vault=vault_settings.vault)
64
+ saas_deploy_state = init_state(
65
+ integration=OPENSHIFT_SAAS_DEPLOY, secret_reader=secret_reader
66
+ )
67
+ promotion_state = PromotionState(state=saas_deploy_state)
68
+ if not saas_files:
69
+ saas_files = get_saas_files()
70
+ return SaasPromotionState(
71
+ promotion_state=promotion_state, saas_files=saas_files
72
+ )
tools/test/conftest.py CHANGED
@@ -1,13 +1,17 @@
1
1
  from collections.abc import (
2
2
  Callable,
3
+ Iterable,
4
+ Mapping,
3
5
  MutableMapping,
4
6
  )
7
+ from pathlib import Path
5
8
  from typing import Any
6
9
 
7
10
  import pytest
8
11
  from pydantic import BaseModel
9
12
  from pydantic.error_wrappers import ValidationError
10
13
 
14
+ from reconcile.typed_queries.saas_files import SaasFile
11
15
  from reconcile.utils.models import data_default_none
12
16
 
13
17
 
@@ -15,6 +19,44 @@ class GQLClassFactoryError(Exception):
15
19
  pass
16
20
 
17
21
 
22
+ @pytest.fixture
23
+ def saas_files_builder(
24
+ gql_class_factory: Callable[[type[SaasFile], Mapping], SaasFile],
25
+ ) -> Callable[[Iterable[MutableMapping]], list[SaasFile]]:
26
+ def builder(data: Iterable[MutableMapping]) -> list[SaasFile]:
27
+ for d in data:
28
+ if "app" not in d:
29
+ d["app"] = {}
30
+ if "pipelinesProvider" not in d:
31
+ d["pipelinesProvider"] = {}
32
+ if "managedResourceTypes" not in d:
33
+ d["managedResourceTypes"] = []
34
+ if "imagePatterns" not in d:
35
+ d["imagePatterns"] = []
36
+ for rt in d.get("resourceTemplates", []):
37
+ for t in rt.get("targets", []):
38
+ ns = t["namespace"]
39
+ if "name" not in ns:
40
+ ns["name"] = "some_name"
41
+ if "environment" not in ns:
42
+ ns["environment"] = {}
43
+ if "app" not in ns:
44
+ ns["app"] = {}
45
+ if "cluster" not in ns:
46
+ ns["cluster"] = {}
47
+ return [gql_class_factory(SaasFile, d) for d in data]
48
+
49
+ return builder
50
+
51
+
52
+ @pytest.fixture
53
+ def fx() -> Callable:
54
+ def _fx(name: str) -> str:
55
+ return (Path(__file__).parent / "fixtures" / name).read_text()
56
+
57
+ return _fx
58
+
59
+
18
60
  @pytest.fixture
19
61
  def gql_class_factory() -> (
20
62
  Callable[
@@ -0,0 +1,86 @@
1
+ from collections.abc import (
2
+ Callable,
3
+ Iterable,
4
+ Mapping,
5
+ )
6
+ from unittest.mock import (
7
+ create_autospec,
8
+ )
9
+
10
+ from reconcile.typed_queries.saas_files import SaasFile
11
+ from reconcile.utils.promotion_state import PromotionData, PromotionState
12
+ from tools.saas_promotion_state.saas_promotion_state import (
13
+ SaasPromotionState,
14
+ )
15
+
16
+
17
+ def test_saas_promotion_state(
18
+ saas_files_builder: Callable[[Iterable[Mapping]], list[SaasFile]],
19
+ ) -> None:
20
+ saas_files = saas_files_builder([
21
+ {
22
+ "path": "/saas1.yml",
23
+ "name": "saas_1",
24
+ "resourceTemplates": [
25
+ {
26
+ "name": "template_1",
27
+ "url": "repo1/url",
28
+ "targets": [
29
+ {
30
+ "ref": "main",
31
+ "namespace": {"path": "/namespace1.yml"},
32
+ "promotion": {
33
+ "publish": ["channel-a"],
34
+ },
35
+ }
36
+ ],
37
+ }
38
+ ],
39
+ },
40
+ {
41
+ "path": "/saas2.yml",
42
+ "name": "saas_2",
43
+ "resourceTemplates": [
44
+ {
45
+ "name": "template_2",
46
+ "url": "repo2/url",
47
+ "targets": [
48
+ {
49
+ "ref": "main",
50
+ "namespace": {"path": "/namespace2.yml"},
51
+ "promotion": {
52
+ "publish": ["channel-b"],
53
+ "subscribe": ["channel-a"],
54
+ },
55
+ },
56
+ {
57
+ "ref": "main",
58
+ "namespace": {"path": "/namespace3.yml"},
59
+ },
60
+ ],
61
+ }
62
+ ],
63
+ },
64
+ ])
65
+
66
+ expected = PromotionData(
67
+ check_in="test1",
68
+ saas_file="test2",
69
+ success=True,
70
+ target_config_hash="test3",
71
+ )
72
+ promotion_state = create_autospec(spec=PromotionState)
73
+ promotion_state.get_promotion_data.return_value = expected
74
+ saas_promotion_state = SaasPromotionState.create(
75
+ promotion_state=promotion_state, saas_files=saas_files
76
+ )
77
+ result = saas_promotion_state.get(channel="channel-a", sha="main")
78
+
79
+ assert result == {"616af45d7fad7f4eea8d52b8b5e8a058cef82ab0": expected}
80
+ promotion_state.get_promotion_data.assert_called_once_with(
81
+ sha="main",
82
+ channel="channel-a",
83
+ use_cache=False,
84
+ target_uid="616af45d7fad7f4eea8d52b8b5e8a058cef82ab0",
85
+ pre_check_sha_exists=False,
86
+ )