qontract-reconcile 0.10.1rc514__py3-none-any.whl → 0.10.1rc515__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.
Files changed (18) hide show
  1. {qontract_reconcile-0.10.1rc514.dist-info → qontract_reconcile-0.10.1rc515.dist-info}/METADATA +1 -1
  2. {qontract_reconcile-0.10.1rc514.dist-info → qontract_reconcile-0.10.1rc515.dist-info}/RECORD +17 -15
  3. reconcile/saas_auto_promotions_manager/integration.py +28 -3
  4. reconcile/saas_auto_promotions_manager/merge_request_manager/merge_request_manager.py +5 -5
  5. reconcile/saas_auto_promotions_manager/merge_request_manager/merge_request_manager_v2.py +144 -0
  6. reconcile/saas_auto_promotions_manager/merge_request_manager/mr_parser.py +13 -9
  7. reconcile/saas_auto_promotions_manager/merge_request_manager/reconciler.py +207 -0
  8. reconcile/saas_auto_promotions_manager/merge_request_manager/renderer.py +9 -6
  9. reconcile/test/saas_auto_promotions_manager/merge_request_manager/merge_request_manager/conftest.py +25 -12
  10. reconcile/test/saas_auto_promotions_manager/merge_request_manager/merge_request_manager/data_keys.py +3 -0
  11. reconcile/test/saas_auto_promotions_manager/merge_request_manager/merge_request_manager/test_merge_request_manager.py +51 -153
  12. reconcile/test/saas_auto_promotions_manager/merge_request_manager/merge_request_manager/test_mr_parser.py +11 -9
  13. reconcile/test/saas_auto_promotions_manager/merge_request_manager/merge_request_manager/test_reconciler.py +408 -0
  14. reconcile/test/saas_auto_promotions_manager/test_integration_test.py +23 -88
  15. reconcile/test/saas_auto_promotions_manager/merge_request_manager/merge_request_manager/test_unbatching.py +0 -96
  16. {qontract_reconcile-0.10.1rc514.dist-info → qontract_reconcile-0.10.1rc515.dist-info}/WHEEL +0 -0
  17. {qontract_reconcile-0.10.1rc514.dist-info → qontract_reconcile-0.10.1rc515.dist-info}/entry_points.txt +0 -0
  18. {qontract_reconcile-0.10.1rc514.dist-info → qontract_reconcile-0.10.1rc515.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: qontract-reconcile
3
- Version: 0.10.1rc514
3
+ Version: 0.10.1rc515
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
@@ -345,14 +345,16 @@ reconcile/rhidp/sso_client/base.py,sha256=EfQ2ewcOKh5idg46UKAkY6z0m_nGQfvnQKffa2
345
345
  reconcile/rhidp/sso_client/integration.py,sha256=kA8g7c38ZBSdrRtyfEqy_WgSreD1PbwY7ZIN-3tZRPc,2221
346
346
  reconcile/rhidp/sso_client/metrics.py,sha256=Tq7tSOsqL3XdcPUdozxqzSPIodUeOV87UCTqpuuqqhw,1013
347
347
  reconcile/saas_auto_promotions_manager/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
348
- reconcile/saas_auto_promotions_manager/integration.py,sha256=5hSvC3JUM4r63UFuC1IbeG6YbmdlzQrekwRqF5ggvCY,5667
348
+ reconcile/saas_auto_promotions_manager/integration.py,sha256=1nm3aV5a0frEPTSww4ue1mfQ_Px6XaRZoaBRhPgJiAU,6472
349
349
  reconcile/saas_auto_promotions_manager/publisher.py,sha256=4_M9Oykhj-kEZPUn05E2DY5gD6-x32Dgf7K3NPOkGEg,2029
350
350
  reconcile/saas_auto_promotions_manager/subscriber.py,sha256=cLhPlkT71J2LIice3SLmH1WpsqzV46gd0peMxrnqyRw,7452
351
351
  reconcile/saas_auto_promotions_manager/merge_request_manager/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
352
352
  reconcile/saas_auto_promotions_manager/merge_request_manager/merge_request.py,sha256=PNu7sE-tDUY61E03z5w0b93fdowZ8auCl0S4_vhYOKQ,1333
353
- reconcile/saas_auto_promotions_manager/merge_request_manager/merge_request_manager.py,sha256=CpTbL2wX4s6iQVZJzu-i8kTJOezCwPxyJTMoNdltqgc,7525
354
- reconcile/saas_auto_promotions_manager/merge_request_manager/mr_parser.py,sha256=gyEn7yp03XDf0P8QEDfXWTcGGehNQ2Ln_sQ3cUq00Bc,6945
355
- reconcile/saas_auto_promotions_manager/merge_request_manager/renderer.py,sha256=Sj3mwmBcml6t-49bPnXdq7snT9N0WKrPk2chU_0ylyE,7188
353
+ reconcile/saas_auto_promotions_manager/merge_request_manager/merge_request_manager.py,sha256=P4RH6y-qC9RKJxVguyOfOAe42a6AP1Ez-NlyMSH3j8I,7492
354
+ reconcile/saas_auto_promotions_manager/merge_request_manager/merge_request_manager_v2.py,sha256=AwCyvnNAKwRbESodHN2-fWG8Wpxua0nzQkcIsz4Gevg,5411
355
+ reconcile/saas_auto_promotions_manager/merge_request_manager/mr_parser.py,sha256=x8Gg-YjEFWEeDPJH3Y8SrfcJbwhLuAqCz4kIhfEyaaA,7060
356
+ reconcile/saas_auto_promotions_manager/merge_request_manager/reconciler.py,sha256=DgMw24exjuor9s4Y8y0Ftsjs5l7rl8D7dksMoJ1RF0A,7639
357
+ reconcile/saas_auto_promotions_manager/merge_request_manager/renderer.py,sha256=VSKCwY8jZ6NYwIT6edmVnpzY7cgjhn5fhX9xtlw2LD8,7341
356
358
  reconcile/saas_auto_promotions_manager/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
357
359
  reconcile/saas_auto_promotions_manager/utils/saas_files_inventory.py,sha256=mihuWynroB1Cea1Lsvf6V8Nb8PGiBdcLC0uhCX_52Y0,6966
358
360
  reconcile/skupper_network/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -452,14 +454,14 @@ reconcile/test/test_vpc_peerings_validator.py,sha256=dFSmjc_dMN2GqMbntCFpa7PUZmy
452
454
  reconcile/test/test_wrong_region.py,sha256=7KzL7OaICQ9Z3DW27zt_ykMN7_87owAFC-2CYjvGoyA,2138
453
455
  reconcile/test/saas_auto_promotions_manager/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
454
456
  reconcile/test/saas_auto_promotions_manager/conftest.py,sha256=tZNs35EuWulP53Cqt61RteOGY_uH4gfhtXfGQ8-awwM,3081
455
- reconcile/test/saas_auto_promotions_manager/test_integration_test.py,sha256=4LPWN7YWr2t_MRpwGf7pQ4AMSBEMYvTqTe5hY1rJyS0,4078
457
+ reconcile/test/saas_auto_promotions_manager/test_integration_test.py,sha256=UIafVpeof9Zd3Q17mEDIUwgdBGf8oFxkf7h8tLfmwCE,2077
456
458
  reconcile/test/saas_auto_promotions_manager/merge_request_manager/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
457
459
  reconcile/test/saas_auto_promotions_manager/merge_request_manager/merge_request_manager/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
458
- reconcile/test/saas_auto_promotions_manager/merge_request_manager/merge_request_manager/conftest.py,sha256=Ky5IAi0EzrHqP0eimV4Mq0FMVdrFEN-7P-Nst4wqB34,4276
459
- reconcile/test/saas_auto_promotions_manager/merge_request_manager/merge_request_manager/data_keys.py,sha256=ZTGXlpgAECZnNqsJbkYGr6fBreC_KejYz4tCJIg8iDM,573
460
- reconcile/test/saas_auto_promotions_manager/merge_request_manager/merge_request_manager/test_merge_request_manager.py,sha256=c0-c9wfd-3mOMFsCJUDOCx-EfoiT75hJypz72xwdMb8,5978
461
- reconcile/test/saas_auto_promotions_manager/merge_request_manager/merge_request_manager/test_mr_parser.py,sha256=9VLJKOW80NMQ3WCKDqtaOa0-StoFxaCzKN-NCPfeklo,9845
462
- reconcile/test/saas_auto_promotions_manager/merge_request_manager/merge_request_manager/test_unbatching.py,sha256=IqmVqWw98Gegwz5johzh13cqCONk7D_VbgK1y8VqRP4,3004
460
+ reconcile/test/saas_auto_promotions_manager/merge_request_manager/merge_request_manager/conftest.py,sha256=7O3lbk1EmEtUofqGncfiwMYvDPXrkQNPB59zlQ_zXkM,4588
461
+ reconcile/test/saas_auto_promotions_manager/merge_request_manager/merge_request_manager/data_keys.py,sha256=Z1IV51OUuzhd-3S8W-k7ixC-fkaglCokn0eakK0Z73s,606
462
+ reconcile/test/saas_auto_promotions_manager/merge_request_manager/merge_request_manager/test_merge_request_manager.py,sha256=ryROiiQNtZvuG820CB-RQ9FMmLAGshezyMDNDiJNA_E,2369
463
+ reconcile/test/saas_auto_promotions_manager/merge_request_manager/merge_request_manager/test_mr_parser.py,sha256=dcGHzxuafKSxmswSO1qF2WlKaqsmEvtERC6Lb8kDAN0,10019
464
+ reconcile/test/saas_auto_promotions_manager/merge_request_manager/merge_request_manager/test_reconciler.py,sha256=-rSv-C-JFaY0gsjSFgcXz9ASNQQOzwizaQEbVYJtLHg,14906
463
465
  reconcile/test/saas_auto_promotions_manager/merge_request_manager/renderer/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
464
466
  reconcile/test/saas_auto_promotions_manager/merge_request_manager/renderer/conftest.py,sha256=2rCSstewp4LPoEJHm5N7dGJexEtY8ndLHvoGZYjmpsc,1678
465
467
  reconcile/test/saas_auto_promotions_manager/merge_request_manager/renderer/data_keys.py,sha256=beHYQ9kgDLeBZgC2FvxQA3tHx1PO-RAMN8_kVcSdikI,90
@@ -666,8 +668,8 @@ tools/test/test_app_interface_metrics_exporter.py,sha256=SX7qL3D1SIRKFo95FoQztvf
666
668
  tools/test/test_qontract_cli.py,sha256=d18KrdhtUGqoC7_kWZU128U0-VJEj-0rjFkLVufcI6I,2755
667
669
  tools/test/test_sd_app_sre_alert_report.py,sha256=v363r9zM7__0kR5K6mvJoGFcM9BvE33fWAayrqkpojA,2116
668
670
  tools/test/test_sre_checkpoints.py,sha256=SKqPPTl9ua0RFdSSofnoQX-JZE6dFLO3LRhfQzqtfh8,2607
669
- qontract_reconcile-0.10.1rc514.dist-info/METADATA,sha256=-Ior_MrT3HmyZ-QKjTJqbS8J30opXi5LPdBud6CU0dg,2349
670
- qontract_reconcile-0.10.1rc514.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
671
- qontract_reconcile-0.10.1rc514.dist-info/entry_points.txt,sha256=rTjAv28I_CHLM8ID3OPqMI_suoQ9s7tFbim4aYjn9kk,376
672
- qontract_reconcile-0.10.1rc514.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
673
- qontract_reconcile-0.10.1rc514.dist-info/RECORD,,
671
+ qontract_reconcile-0.10.1rc515.dist-info/METADATA,sha256=Bgwt_oRZlcfCQ6ycYlopR_xt-fRVu3kWrlOW7wMuaqo,2349
672
+ qontract_reconcile-0.10.1rc515.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
673
+ qontract_reconcile-0.10.1rc515.dist-info/entry_points.txt,sha256=rTjAv28I_CHLM8ID3OPqMI_suoQ9s7tFbim4aYjn9kk,376
674
+ qontract_reconcile-0.10.1rc515.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
675
+ qontract_reconcile-0.10.1rc515.dist-info/RECORD,,
@@ -9,9 +9,15 @@ from reconcile.openshift_saas_deploy import (
9
9
  from reconcile.saas_auto_promotions_manager.merge_request_manager.merge_request_manager import (
10
10
  MergeRequestManager,
11
11
  )
12
+ from reconcile.saas_auto_promotions_manager.merge_request_manager.merge_request_manager_v2 import (
13
+ MergeRequestManagerV2,
14
+ )
12
15
  from reconcile.saas_auto_promotions_manager.merge_request_manager.mr_parser import (
13
16
  MRParser,
14
17
  )
18
+ from reconcile.saas_auto_promotions_manager.merge_request_manager.reconciler import (
19
+ Reconciler,
20
+ )
15
21
  from reconcile.saas_auto_promotions_manager.merge_request_manager.renderer import (
16
22
  Renderer,
17
23
  )
@@ -45,6 +51,7 @@ class SaasAutoPromotionsManager:
45
51
  deployment_state: PromotionState,
46
52
  vcs: VCS,
47
53
  saas_file_inventory: SaasFilesInventory,
54
+ merge_request_manager_v2: MergeRequestManagerV2,
48
55
  merge_request_manager: MergeRequestManager,
49
56
  thread_pool_size: int,
50
57
  dry_run: bool,
@@ -52,6 +59,7 @@ class SaasAutoPromotionsManager:
52
59
  self._deployment_state = deployment_state
53
60
  self._vcs = vcs
54
61
  self._saas_file_inventory = saas_file_inventory
62
+ self._merge_request_manager_v2 = merge_request_manager_v2
55
63
  self._merge_request_manager = merge_request_manager
56
64
  self._thread_pool_size = thread_pool_size
57
65
  self._dry_run = dry_run
@@ -88,11 +96,14 @@ class SaasAutoPromotionsManager:
88
96
  self._merge_request_manager.create_promotion_merge_requests(
89
97
  subscribers=subscribers_with_diff
90
98
  )
99
+ self._merge_request_manager_v2.reconcile(subscribers=subscribers_with_diff)
91
100
 
92
101
 
93
102
  def init_external_dependencies(
94
103
  dry_run: bool,
95
- ) -> tuple[PromotionState, VCS, SaasFilesInventory, MergeRequestManager]:
104
+ ) -> tuple[
105
+ PromotionState, VCS, SaasFilesInventory, MergeRequestManagerV2, MergeRequestManager
106
+ ]:
96
107
  """
97
108
  Lets initialize everything that involves calls to external dependencies:
98
109
  - VCS -> Gitlab / Github queries
@@ -120,17 +131,29 @@ def init_external_dependencies(
120
131
  allow_opening_mrs=allow_opening_mrs,
121
132
  )
122
133
  mr_parser = MRParser(vcs=vcs)
123
- merge_request_manager = MergeRequestManager(
134
+ merge_request_manager_v2 = MergeRequestManagerV2(
124
135
  vcs=vcs,
136
+ reconciler=Reconciler(),
125
137
  mr_parser=mr_parser,
126
138
  renderer=Renderer(),
127
139
  )
140
+ merge_request_manager = MergeRequestManager(
141
+ mr_parser=mr_parser,
142
+ renderer=Renderer(),
143
+ vcs=vcs,
144
+ )
128
145
  saas_files = get_saas_files()
129
146
  saas_inventory = SaasFilesInventory(saas_files=saas_files)
130
147
  deployment_state = PromotionState(
131
148
  state=init_state(integration=OPENSHIFT_SAAS_DEPLOY, secret_reader=secret_reader)
132
149
  )
133
- return deployment_state, vcs, saas_inventory, merge_request_manager
150
+ return (
151
+ deployment_state,
152
+ vcs,
153
+ saas_inventory,
154
+ merge_request_manager_v2,
155
+ merge_request_manager,
156
+ )
134
157
 
135
158
 
136
159
  @defer
@@ -143,6 +166,7 @@ def run(
143
166
  deployment_state,
144
167
  vcs,
145
168
  saas_inventory,
169
+ merge_request_manager_v2,
146
170
  merge_request_manager,
147
171
  ) = init_external_dependencies(dry_run=dry_run)
148
172
  if defer:
@@ -153,6 +177,7 @@ def run(
153
177
  vcs=vcs,
154
178
  saas_file_inventory=saas_inventory,
155
179
  merge_request_manager=merge_request_manager,
180
+ merge_request_manager_v2=merge_request_manager_v2,
156
181
  thread_pool_size=thread_pool_size,
157
182
  dry_run=dry_run,
158
183
  )
@@ -16,7 +16,6 @@ from reconcile.saas_auto_promotions_manager.merge_request_manager.renderer impor
16
16
  CHANNELS_REF,
17
17
  CONTENT_HASHES,
18
18
  IS_BATCHABLE,
19
- SAPM_LABEL,
20
19
  VERSION_REF,
21
20
  Renderer,
22
21
  )
@@ -25,6 +24,8 @@ from reconcile.utils.vcs import VCS
25
24
 
26
25
  ITEM_SEPARATOR = ","
27
26
 
27
+ SAPM_LABEL = "SAPM"
28
+
28
29
 
29
30
  class MergeRequestManager:
30
31
  """
@@ -68,14 +69,13 @@ class MergeRequestManager:
68
69
  "Closing this MR because it failed MR check and isn't marked un-batchable yet.",
69
70
  )
70
71
  # Remember these hashes as unbatchable
71
- content_hashes = mr.content_hashes.split(ITEM_SEPARATOR)
72
- self._unbatchable_hashes.update(content_hashes)
72
+ self._unbatchable_hashes.update(mr.content_hashes)
73
73
  else:
74
74
  open_mrs_after_unbatching.append(mr)
75
75
  self._open_mrs = open_mrs_after_unbatching
76
76
 
77
77
  def housekeeping(self) -> None:
78
- self._open_mrs = self._mr_parser.retrieve_open_mrs()
78
+ self._open_mrs = self._mr_parser.retrieve_open_mrs(label=SAPM_LABEL)
79
79
  self._unbatch_failed_mrs()
80
80
 
81
81
  def _aggregate_subscribers_per_channel_combo(
@@ -160,7 +160,7 @@ class MergeRequestManager:
160
160
  channels=channel_combo,
161
161
  is_batchable=combined_content_hash not in self._unbatchable_hashes,
162
162
  )
163
- title = self._renderer.render_title(channels=channel_combo)
163
+ title = self._renderer.render_title(is_draft=False, channels=channel_combo)
164
164
  logging.info(
165
165
  "Open MR for update in channel(s) %s",
166
166
  channel_combo,
@@ -0,0 +1,144 @@
1
+ import logging
2
+ from collections import defaultdict
3
+ from collections.abc import Iterable
4
+
5
+ from gitlab.exceptions import GitlabGetError
6
+
7
+ from reconcile.saas_auto_promotions_manager.merge_request_manager.merge_request import (
8
+ SAPMMR,
9
+ )
10
+ from reconcile.saas_auto_promotions_manager.merge_request_manager.mr_parser import (
11
+ MRParser,
12
+ )
13
+ from reconcile.saas_auto_promotions_manager.merge_request_manager.reconciler import (
14
+ Addition,
15
+ Promotion,
16
+ Reconciler,
17
+ )
18
+ from reconcile.saas_auto_promotions_manager.merge_request_manager.renderer import (
19
+ Renderer,
20
+ )
21
+ from reconcile.saas_auto_promotions_manager.subscriber import Subscriber
22
+ from reconcile.utils.vcs import VCS
23
+
24
+ BATCH_SIZE_LIMIT = 5
25
+ SAPM_LABEL = "SAPMCanary"
26
+
27
+
28
+ class MergeRequestManagerV2:
29
+ """
30
+ Manager for SAPM merge requests.
31
+
32
+ This class uses MRParser to fetch current state (currently open MRs).
33
+ This class calculates the desired state (i.e., desired promotions).
34
+ Desired state and current state are given to the Reconciler, which will
35
+ then determine a Diff (Additions and Deletions of MRs).
36
+
37
+ This class interacts with VCS to realize the result of the Diff.
38
+ """
39
+
40
+ def __init__(
41
+ self, vcs: VCS, mr_parser: MRParser, reconciler: Reconciler, renderer: Renderer
42
+ ):
43
+ self._vcs = vcs
44
+ self._mr_parser = mr_parser
45
+ self._renderer = renderer
46
+ self._reconciler = reconciler
47
+ self._content_hash_to_subscriber: dict[str, list[Subscriber]] = {}
48
+ self._sapm_mrs: list[SAPMMR] = []
49
+
50
+ def _aggregate_desired_state(
51
+ self, subscribers: Iterable[Subscriber]
52
+ ) -> list[Promotion]:
53
+ subscribers_per_channel_combo: dict[str, list[Subscriber]] = defaultdict(list)
54
+ for subscriber in subscribers:
55
+ channel_combo = ",".join([c.name for c in subscriber.channels])
56
+ subscribers_per_channel_combo[channel_combo].append(subscriber)
57
+
58
+ desired_promotions: list[Promotion] = []
59
+ for channel_combo, subs in subscribers_per_channel_combo.items():
60
+ combined_content_hash = Subscriber.combined_content_hash(subscribers=subs)
61
+ self._content_hash_to_subscriber[combined_content_hash] = subs
62
+ desired_promotions.append(
63
+ Promotion(
64
+ content_hashes={combined_content_hash},
65
+ channels={channel_combo},
66
+ )
67
+ )
68
+ return desired_promotions
69
+
70
+ def _render_mr(self, addition: Addition) -> None:
71
+ subs: list[Subscriber] = []
72
+ for content_hash in addition.content_hashes:
73
+ subs.extend(self._content_hash_to_subscriber[content_hash])
74
+ content_by_path: dict[str, str] = {}
75
+ has_error = False
76
+ for sub in subs:
77
+ if sub.target_file_path not in content_by_path:
78
+ try:
79
+ content_by_path[sub.target_file_path] = (
80
+ self._vcs.get_file_content_from_app_interface_master(
81
+ file_path=sub.target_file_path
82
+ )
83
+ )
84
+ except GitlabGetError as e:
85
+ if e.response_code == 404:
86
+ logging.error(
87
+ "The saas file %s does not exist anylonger. Most likely qontract-server data not in synch. This should resolve soon on its own.",
88
+ sub.target_file_path,
89
+ )
90
+ has_error = True
91
+ break
92
+ raise e
93
+ content_by_path[sub.target_file_path] = (
94
+ self._renderer.render_merge_request_content(
95
+ subscriber=sub,
96
+ current_content=content_by_path[sub.target_file_path],
97
+ )
98
+ )
99
+ if has_error:
100
+ return
101
+
102
+ description_hashes = ",".join(addition.content_hashes)
103
+ description_channels = ",".join(addition.channels)
104
+
105
+ description = self._renderer.render_description(
106
+ content_hashes=description_hashes,
107
+ channels=description_channels,
108
+ is_batchable=addition.batchable,
109
+ )
110
+ title = self._renderer.render_title(
111
+ is_draft=True, channels=description_channels
112
+ )
113
+ logging.info(
114
+ "Open MR for update in channel(s) %s",
115
+ description_channels,
116
+ )
117
+ self._sapm_mrs.append(
118
+ SAPMMR(
119
+ sapm_label=SAPM_LABEL,
120
+ content_by_path=content_by_path,
121
+ title=title,
122
+ description=description,
123
+ )
124
+ )
125
+
126
+ def reconcile(self, subscribers: Iterable[Subscriber]) -> None:
127
+ current_state = self._mr_parser.retrieve_open_mrs(label=SAPM_LABEL)
128
+ desired_state = self._aggregate_desired_state(subscribers=subscribers)
129
+ diff = self._reconciler.reconcile(
130
+ batch_limit=BATCH_SIZE_LIMIT,
131
+ desired_promotions=desired_state,
132
+ open_mrs=current_state,
133
+ )
134
+ for deletion in diff.deletions:
135
+ self._vcs.close_app_interface_mr(
136
+ mr=deletion.mr.raw,
137
+ comment=deletion.reason,
138
+ )
139
+
140
+ for addition in diff.additions:
141
+ self._render_mr(addition=addition)
142
+
143
+ for rendered_mr in self._sapm_mrs:
144
+ self._vcs.open_app_interface_merge_request(mr=rendered_mr)
@@ -9,7 +9,6 @@ from reconcile.saas_auto_promotions_manager.merge_request_manager.renderer impor
9
9
  CONTENT_HASHES,
10
10
  IS_BATCHABLE,
11
11
  PROMOTION_DATA_SEPARATOR,
12
- SAPM_LABEL,
13
12
  SAPM_VERSION,
14
13
  VERSION_REF,
15
14
  )
@@ -19,12 +18,15 @@ from reconcile.utils.vcs import VCS, MRCheckStatus
19
18
  @dataclass
20
19
  class OpenMergeRequest:
21
20
  raw: ProjectMergeRequest
22
- content_hashes: str
23
- channels: str
21
+ content_hashes: set[str]
22
+ channels: set[str]
24
23
  failed_mr_check: bool
25
24
  is_batchable: bool
26
25
 
27
26
 
27
+ ITEM_SEPARATOR = ","
28
+
29
+
28
30
  class MRParser:
29
31
  """
30
32
  We store state in MR descriptions.
@@ -49,11 +51,13 @@ class MRParser:
49
51
  return ""
50
52
  return groups[0]
51
53
 
52
- def _fetch_sapm_managed_open_merge_requests(self) -> list[ProjectMergeRequest]:
54
+ def _fetch_sapm_managed_open_merge_requests(
55
+ self, label: str
56
+ ) -> list[ProjectMergeRequest]:
53
57
  all_open_mrs = self._vcs.get_open_app_interface_merge_requests()
54
- return [mr for mr in all_open_mrs if SAPM_LABEL in mr.attributes.get("labels")]
58
+ return [mr for mr in all_open_mrs if label in mr.attributes.get("labels")]
55
59
 
56
- def retrieve_open_mrs(self) -> list[OpenMergeRequest]:
60
+ def retrieve_open_mrs(self, label: str) -> list[OpenMergeRequest]:
57
61
  """
58
62
  This function parses the state and returns a list of valid, parsed open MRs (current state).
59
63
  If any issue is encountered during parsing, we consider this MR
@@ -66,7 +70,7 @@ class MRParser:
66
70
  """
67
71
  open_mrs: list[OpenMergeRequest] = []
68
72
  seen: set[tuple[str, str, str]] = set()
69
- for mr in self._fetch_sapm_managed_open_merge_requests():
73
+ for mr in self._fetch_sapm_managed_open_merge_requests(label=label):
70
74
  attrs = mr.attributes
71
75
  desc = attrs.get("description")
72
76
  has_conflicts = attrs.get("has_conflicts", False)
@@ -177,8 +181,8 @@ class MRParser:
177
181
  open_mrs.append(
178
182
  OpenMergeRequest(
179
183
  raw=mr,
180
- content_hashes=content_hashes,
181
- channels=channels_refs,
184
+ content_hashes=set(content_hashes.split(ITEM_SEPARATOR)),
185
+ channels=set(channels_refs.split(ITEM_SEPARATOR)),
182
186
  failed_mr_check=mr_check_status == MRCheckStatus.FAILED,
183
187
  is_batchable=is_batchable_str == "True",
184
188
  )
@@ -0,0 +1,207 @@
1
+ from collections.abc import Iterable
2
+ from dataclasses import dataclass
3
+ from typing import Optional
4
+
5
+ from reconcile.saas_auto_promotions_manager.merge_request_manager.mr_parser import (
6
+ OpenMergeRequest,
7
+ )
8
+
9
+ MSG_MISSING_UNBATCHING = (
10
+ "Closing this MR because it failed MR check and isn't marked as un-batchable yet."
11
+ )
12
+ MSG_OUTDATED_CONTENT = "Closing this MR because it has out-dated content."
13
+ MSG_NEW_BATCH = "Closing this MR in favor of a new batch MR."
14
+
15
+
16
+ @dataclass
17
+ class Promotion:
18
+ content_hashes: set[str]
19
+ channels: set[str]
20
+
21
+
22
+ @dataclass
23
+ class Deletion:
24
+ mr: OpenMergeRequest
25
+ reason: str
26
+
27
+
28
+ @dataclass
29
+ class Addition:
30
+ content_hashes: set[str]
31
+ channels: set[str]
32
+ batchable: bool
33
+
34
+
35
+ @dataclass
36
+ class Diff:
37
+ deletions: list[Deletion]
38
+ additions: list[Addition]
39
+
40
+
41
+ class Reconciler:
42
+ """
43
+ The reconciler calculates a Diff. I.e., which MRs need to be opened (Addition)
44
+ and which MRs need to be closed (Deletion). The reconciler has no external
45
+ dependencies and does not interact with VCS. The reconciler expects to be
46
+ given the desired state (which promotions do we want) and the current state
47
+ (the currently open MRs) in order to calculate the Diff.
48
+ """
49
+
50
+ def __init__(self) -> None:
51
+ self._desired_promotions: Iterable[Promotion] = []
52
+ self._open_mrs: Iterable[OpenMergeRequest] = []
53
+
54
+ def _unbatch(self, diff: Diff) -> None:
55
+ """
56
+ We optimistically batch MRs together that didnt run through MR check yet.
57
+ Vast majority of auto-promotion MRs are succeeding checks, so we can stay optimistic.
58
+ In the rare case of an MR failing the check, we want to unbatch it.
59
+ I.e., we open a dedicated MR for each channel in the batched MR, mark the new MRs as non-batchable
60
+ and close the old batched MR. By doing so, we ensure that unrelated MRs are not blocking each other.
61
+ Unbatched MRs are marked and will never be batched again.
62
+ """
63
+ open_mrs_after_unbatching: list[OpenMergeRequest] = []
64
+ unbatchable_hashes: set[str] = set()
65
+ falsely_marked_batchable_hashes: set[str] = set()
66
+ for mr in self._open_mrs:
67
+ if not mr.is_batchable:
68
+ unbatchable_hashes.update(mr.content_hashes)
69
+ open_mrs_after_unbatching.append(mr)
70
+ elif mr.failed_mr_check:
71
+ falsely_marked_batchable_hashes.update(mr.content_hashes)
72
+ diff.deletions.append(
73
+ Deletion(
74
+ mr=mr,
75
+ reason=MSG_MISSING_UNBATCHING,
76
+ )
77
+ )
78
+ else:
79
+ open_mrs_after_unbatching.append(mr)
80
+ self._open_mrs = open_mrs_after_unbatching
81
+
82
+ desired_promotions_after_unbatching: list[Promotion] = []
83
+ for promotion in self._desired_promotions:
84
+ if promotion.content_hashes.issubset(unbatchable_hashes):
85
+ continue
86
+ elif promotion.content_hashes.issubset(falsely_marked_batchable_hashes):
87
+ diff.additions.append(
88
+ Addition(
89
+ content_hashes=promotion.content_hashes,
90
+ channels=promotion.channels,
91
+ batchable=False,
92
+ )
93
+ )
94
+ continue
95
+ else:
96
+ desired_promotions_after_unbatching.append(promotion)
97
+ self._desired_promotions = desired_promotions_after_unbatching
98
+
99
+ def _remove_outdated(self, diff: Diff) -> None:
100
+ """
101
+ We want to be sure that the open MRs are still addressing desired content.
102
+ We close MRs that are not addressing any content hash in a desired promotion.
103
+ """
104
+ all_desired_content_hashes: set[str] = set()
105
+ for promotion in self._desired_promotions:
106
+ all_desired_content_hashes.update(promotion.content_hashes)
107
+
108
+ open_mrs_after_deletion: list[OpenMergeRequest] = []
109
+ for mr in self._open_mrs:
110
+ if mr.content_hashes.issubset(all_desired_content_hashes):
111
+ open_mrs_after_deletion.append(mr)
112
+ continue
113
+ diff.deletions.append(
114
+ Deletion(
115
+ mr=mr,
116
+ reason=MSG_OUTDATED_CONTENT,
117
+ )
118
+ )
119
+ self._open_mrs = open_mrs_after_deletion
120
+
121
+ def _batch_remaining_mrs(self, diff: Diff, batch_limit: int) -> None:
122
+ """
123
+ Remaining desired promotions should be batched together
124
+ if they are not addressed yet by a valid open MR.
125
+
126
+ We do not want to let MR batches grow infinitely,
127
+ as constant change of a batch might starve MRs that
128
+ are already part of the batch.
129
+ In order for MRs to not grow infinitely, we apply a
130
+ BATCH_LIMIT per MR, i.e., a maximum number of hashes
131
+ that can be batched together.
132
+ """
133
+ submitted_content_hashes: set[str] = set()
134
+ for open_mr in self._open_mrs:
135
+ submitted_content_hashes.update(open_mr.content_hashes)
136
+
137
+ unsubmitted_promotions = [
138
+ prom
139
+ for prom in self._desired_promotions
140
+ if not prom.content_hashes.issubset(submitted_content_hashes)
141
+ ]
142
+
143
+ if not unsubmitted_promotions:
144
+ return
145
+
146
+ batch_with_capacity: Optional[OpenMergeRequest] = None
147
+ for mr in self._open_mrs:
148
+ if len(mr.content_hashes) < batch_limit:
149
+ batch_with_capacity = mr
150
+ # Note, there should always only be maximum one batch with capacity available
151
+ break
152
+
153
+ if batch_with_capacity:
154
+ # We disassemble the batch to its promotions
155
+ # can be added to new batch(es)
156
+ unsubmitted_promotions.append(
157
+ Promotion(
158
+ content_hashes=batch_with_capacity.content_hashes,
159
+ channels=batch_with_capacity.channels,
160
+ )
161
+ )
162
+ # Lets close the current batch so remaining promotions can
163
+ # be aggregated in new batch(es)
164
+ diff.deletions.append(
165
+ Deletion(
166
+ mr=batch_with_capacity,
167
+ reason=MSG_NEW_BATCH,
168
+ )
169
+ )
170
+
171
+ batched_mr = Addition(
172
+ content_hashes=set(),
173
+ channels=set(),
174
+ batchable=True,
175
+ )
176
+
177
+ for promotion in unsubmitted_promotions:
178
+ batched_mr.content_hashes.update(promotion.content_hashes)
179
+ batched_mr.channels.update(promotion.channels)
180
+ if len(batched_mr.content_hashes) >= batch_limit:
181
+ # Note, we might also be above the batch limit, but thats ok.
182
+ # We only ensure that we create a new batch now and dont grow further.
183
+ diff.additions.append(batched_mr)
184
+ batched_mr = Addition(
185
+ content_hashes=set(),
186
+ channels=set(),
187
+ batchable=True,
188
+ )
189
+ if batched_mr.content_hashes:
190
+ diff.additions.append(batched_mr)
191
+
192
+ def reconcile(
193
+ self,
194
+ desired_promotions: Iterable[Promotion],
195
+ open_mrs: Iterable[OpenMergeRequest],
196
+ batch_limit: int,
197
+ ) -> Diff:
198
+ self._open_mrs = open_mrs
199
+ self._desired_promotions = desired_promotions
200
+ diff = Diff(
201
+ deletions=[],
202
+ additions=[],
203
+ )
204
+ self._unbatch(diff=diff)
205
+ self._remove_outdated(diff=diff)
206
+ self._batch_remaining_mrs(diff=diff, batch_limit=batch_limit)
207
+ return diff
@@ -1,3 +1,4 @@
1
+ import hashlib
1
2
  from collections.abc import Mapping
2
3
  from copy import deepcopy
3
4
  from typing import Any
@@ -18,18 +19,17 @@ from reconcile.saas_auto_promotions_manager.subscriber import Subscriber
18
19
  PROMOTION_DATA_SEPARATOR = (
19
20
  "**SAPM Data - DO NOT MANUALLY CHANGE ANYTHING BELOW THIS LINE**"
20
21
  )
21
- SAPM_VERSION = "1.1.0"
22
- SAPM_LABEL = "SAPM"
22
+ SAPM_VERSION = "2.0.0"
23
23
  CONTENT_HASHES = "content_hashes"
24
24
  CHANNELS_REF = "channels"
25
25
  IS_BATCHABLE = "is_batchable"
26
26
  VERSION_REF = "sapm_version"
27
- SAPM_DESC = f"""
27
+ SAPM_DESC = """
28
28
  This is an auto-promotion triggered by app-interface's [saas-auto-promotions-manager](https://github.com/app-sre/qontract-reconcile/tree/master/reconcile/saas_auto_promotions_manager) (SAPM).
29
29
  The channel(s) mentioned in the MR title had an event.
30
30
  This MR promotes all subscribers with auto-promotions for these channel(s).
31
31
 
32
- Please **do not remove the {SAPM_LABEL} label** from this MR.
32
+ Please **do not remove or change any label** from this MR.
33
33
 
34
34
  Parts of this description are used by SAPM to manage auto-promotions.
35
35
  """
@@ -152,8 +152,11 @@ class Renderer:
152
152
  {VERSION_REF}: {SAPM_VERSION}
153
153
  """
154
154
 
155
- def render_title(self, channels: str) -> str:
156
- return f"[SAPM] auto-promotion for {channels}"
155
+ def render_title(self, is_draft: bool, channels: str) -> str:
156
+ content = f"[SAPM] auto-promotion ID {int(hashlib.sha256(channels.encode('utf-8')).hexdigest(), 16) % 10**8}"
157
+ if is_draft:
158
+ return f"Draft: {content}"
159
+ return content
157
160
 
158
161
 
159
162
  def _parse_expression(expression: str) -> Any: