qontract-reconcile 0.10.1rc922__py3-none-any.whl → 0.10.1rc924__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.1rc922
3
+ Version: 0.10.1rc924
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
@@ -366,8 +366,8 @@ reconcile/gql_definitions/status_board/status_board.py,sha256=vHEzncabujkqbjJ-ib
366
366
  reconcile/gql_definitions/statuspage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
367
367
  reconcile/gql_definitions/statuspage/statuspages.py,sha256=CTRzjiR9k41LqlkgyoNHwC2JERsoD_Run_aK7jw_Ono,5299
368
368
  reconcile/gql_definitions/templating/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
369
- reconcile/gql_definitions/templating/template_collection.py,sha256=GiCtqtTj8602ppCatuF9HhQkHXDOd76Z9CSJQ1KwBuc,3532
370
- reconcile/gql_definitions/templating/templates.py,sha256=ejAvQ13zfNMQTz3FWtRUic6dSvio3aAgBKEqt600hbk,2821
369
+ reconcile/gql_definitions/templating/template_collection.py,sha256=QA68QNszPgJY996pPPJmdTULI1gjcT8MDvk1NLKi6_Y,4017
370
+ reconcile/gql_definitions/templating/templates.py,sha256=bV1JWwxDW8opARBOrZ_h5NBsJfM-9aL-povmTZnpTOk,3296
371
371
  reconcile/gql_definitions/terraform_cloudflare_dns/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
372
372
  reconcile/gql_definitions/terraform_cloudflare_dns/app_interface_cloudflare_dns_settings.py,sha256=eyGX9HcTF6MZbOYZ6Kl6Mg3k6nJTUtwqs9gDxBP_8Dk,1920
373
373
  reconcile/gql_definitions/terraform_cloudflare_dns/terraform_cloudflare_zones.py,sha256=dBQ2tyAp-eRZs59mguaTc6-x67JUoSxtZ8mOjbRqDuc,5832
@@ -463,12 +463,12 @@ reconcile/templates/jira-checkpoint-missinginfo.j2,sha256=c_Vvg-lEENsB3tgxm9B6Y9
463
463
  reconcile/templates/rosa-classic-cluster-creation.sh.j2,sha256=FVBmnR2FmVModhqOYNBInhF8zk0Qnj9og9KHK5-X9v0,2361
464
464
  reconcile/templates/rosa-hcp-cluster-creation.sh.j2,sha256=2wTdv9qvapCvT8NSi_hq8sXhpFSaxRX-V6Cao1diCI8,2393
465
465
  reconcile/templating/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
466
- reconcile/templating/renderer.py,sha256=VQVsQxMjKfftpsgzpd1jJ-bhrsgi8zjDYXYzMfI4vZA,12104
467
- reconcile/templating/validator.py,sha256=tGI7PwPMAmRJ_-Gg_O7NoZO8ytg_QUeCQAv6G1smitc,4736
466
+ reconcile/templating/renderer.py,sha256=oUUiHHSgD5bcUN3Xf-yoEezAf3LjqN-2GlYhQUTPIZY,13067
467
+ reconcile/templating/validator.py,sha256=5f9f35PCHOOdjb7KZquL2YdabyuAUokPDa4xutSEHIQ,5360
468
468
  reconcile/templating/lib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
469
469
  reconcile/templating/lib/merge_request_manager.py,sha256=4oe3EwQOP7CZSraocivbRzzOuVb0ooElaUS2_DGsF50,5603
470
470
  reconcile/templating/lib/model.py,sha256=fb6FYYLQjmoh2DjVKO7TEWCuDPf1Q34xmOx0M9Z07ek,324
471
- reconcile/templating/lib/rendering.py,sha256=ShYt_BOMS75yiJ7lNcI9NoZyQp5wpy_lEmzVETY57dE,5030
471
+ reconcile/templating/lib/rendering.py,sha256=6kt8NCCwB4vLKYal7KtRmBDguIC1p_PIQCRr-vL7p5w,5504
472
472
  reconcile/terraform_init/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
473
473
  reconcile/terraform_init/integration.py,sha256=glQ9uy8Kj2aTQXCAupwSFeih7reX_xMX_UuWW_ywBMU,6100
474
474
  reconcile/terraform_init/merge_request.py,sha256=3CYtgSd7Q9zjKg4wsDz437EPCRfGeZZ8fZ0Y-ChKXJY,1475
@@ -537,7 +537,7 @@ reconcile/test/test_quay_repos.py,sha256=TdkcRF_a8PLp01Kti9eZZN-vGup2yPBT4Iba3k0
537
537
  reconcile/test/test_queries.py,sha256=SpH3RmNpBjEr_ne3VjAMCgKK8RE1z1zo7bypkT5uoO4,1946
538
538
  reconcile/test/test_repo_owners.py,sha256=uRYMLbMmh-9usF0TerabZTZV-Z1CS4I6ybT-LQqCLe8,1423
539
539
  reconcile/test/test_requests_sender.py,sha256=7fd9C2kEFS0-CYtlsif66N1kO9c44pzuBPAJKR9igqU,5385
540
- reconcile/test/test_saasherder.py,sha256=f_8W8RC5di6IgiqtFaeRi6E9RJ43MGU2zFsLR_p30FQ,54524
540
+ reconcile/test/test_saasherder.py,sha256=jUn8d1DtdXq1pfDrThqIwx37Lgxa7mou3aZ-m3UrWdc,57129
541
541
  reconcile/test/test_saasherder_allowed_secret_paths.py,sha256=5NHQwNJO66at6HiyMZ5sVRTQDwxdvlOQo0KmkBWCw5Q,4853
542
542
  reconcile/test/test_secret_reader.py,sha256=kz7nzcPjvA08cytnvcA_PMA98AEyqJWsESkYeRn5xCk,4994
543
543
  reconcile/test/test_slack_base.py,sha256=pTUGvJ2S2wF3PhJyGWmiNXG52QtXKy2cbu-G8Ymrv6I,5019
@@ -689,7 +689,7 @@ reconcile/utils/pagerduty_api.py,sha256=_24i9S_4X7nlvHb-7clXRE0p1BG4ODjOzKxWO-F9
689
689
  reconcile/utils/parse_dhms_duration.py,sha256=TONpLnec5gHeF7k815YNJpQyDjXhkxZIcv9s8ffbTSY,1840
690
690
  reconcile/utils/password_validator.py,sha256=XwuWg-8CPlcuG7dl_oQ1G1h2gSVSnfMym_VkuprpWVg,2183
691
691
  reconcile/utils/prometheus.py,sha256=Ad0rwLbxRuuYjHwkwJloHEdK0bvy42h-p-HIT1DhDhs,3832
692
- reconcile/utils/promotion_state.py,sha256=Sd3otIb54vgpEe0JhFG1tkXhPS51LItmkI19pZJhDbE,3607
692
+ reconcile/utils/promotion_state.py,sha256=drkR0PSzfsUVPB0pisARzvUfHgk8KPbeK4FUZdP099Y,3894
693
693
  reconcile/utils/promtool.py,sha256=UmBfTHgW9Ys7fZ9BfhIVJEFGLkbge9y1AgL5PNHp7iA,2831
694
694
  reconcile/utils/quay_api.py,sha256=zbwi3YjL7dTDYHGWcrZ0mbxyZQuEB8v3sV_Km2O-mIs,7906
695
695
  reconcile/utils/raw_github_api.py,sha256=O6Q4vq7bi5ZWcfquPutc9rJ4Ef8_sFqd_RLgzpIoj0w,2920
@@ -741,7 +741,7 @@ reconcile/utils/internal_groups/models.py,sha256=y_IqBVqfGqNXiu0VudvBWFrm_-uafVm
741
741
  reconcile/utils/jinja2/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
742
742
  reconcile/utils/jinja2/extensions.py,sha256=7K-uo6G2eCWa98MHT8fRPYIKCLQB_5D2keqQ_LyAfHM,1293
743
743
  reconcile/utils/jinja2/filters.py,sha256=RVVkpf87FllrPUpqk_8KN-r1IsmnS0bpygAVvsvIr5g,4504
744
- reconcile/utils/jinja2/utils.py,sha256=BOqB17nFKzzdC13hqOebd-IDcstjkvmlyQJJGNaMbxM,6723
744
+ reconcile/utils/jinja2/utils.py,sha256=nyTS7SafbjQliIg0PZhqK0YGfFL0W2V6XA5CRaqOkrA,7809
745
745
  reconcile/utils/jobcontroller/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
746
746
  reconcile/utils/jobcontroller/controller.py,sha256=2V_vm5thFx6adW4bMy9CdHXFesuo6S4lSkEpGxkXSM0,14492
747
747
  reconcile/utils/jobcontroller/models.py,sha256=tSRAkUX23iyn4YPsWEicFXwRxw3mXb5B2pDOWmXX8wQ,6350
@@ -793,7 +793,7 @@ reconcile/utils/runtime/sharding.py,sha256=r0ieUtNed7NvknSw6qQrCkKpVXE1shuHGnfFc
793
793
  reconcile/utils/saasherder/__init__.py,sha256=J3MBZBFa5YmhqYm08QsjBXz8mFcVOCiOCkyIcw41t7E,343
794
794
  reconcile/utils/saasherder/interfaces.py,sha256=C2wrw34OXypshVocAsPrVZsSHptgw4g9u7Haa2wulZQ,9087
795
795
  reconcile/utils/saasherder/models.py,sha256=6MGie9SqsyP5ySjmk5bO5vPJ0-x53a0uzABxQO-WsB0,9746
796
- reconcile/utils/saasherder/saasherder.py,sha256=H3pLQAD6EkjwrFpfQN73LKPg8mYRilssIcZ9M3duizU,84555
796
+ reconcile/utils/saasherder/saasherder.py,sha256=ZTDf6uIpke6y9jKZgESjdY8hDI6Jf2J2UGZp6LJXBWY,85249
797
797
  reconcile/utils/terraform/__init__.py,sha256=zNbiyTWo35AT1sFTElL2j_AA0jJ_yWE_bfFn-nD2xik,250
798
798
  reconcile/utils/terraform/config.py,sha256=5UVrd563TMcvi4ooa5JvWVDW1I3bIWg484u79evfV_8,164
799
799
  reconcile/utils/terraform/config_client.py,sha256=3gUIIIEv52Vx7-VgQ2FZYfCCrfqUv_5gw_TQ3mbLcTs,4666
@@ -844,8 +844,8 @@ tools/test/test_qontract_cli.py,sha256=_D61RFGAN5x44CY1tYbouhlGXXABwYfxKSWSQx3Jr
844
844
  tools/test/test_saas_promotion_state.py,sha256=dy4kkSSAQ7bC0Xp2CociETGN-2aABEfL6FU5D9Jl00Y,6056
845
845
  tools/test/test_sd_app_sre_alert_report.py,sha256=v363r9zM7__0kR5K6mvJoGFcM9BvE33fWAayrqkpojA,2116
846
846
  tools/test/test_sre_checkpoints.py,sha256=SKqPPTl9ua0RFdSSofnoQX-JZE6dFLO3LRhfQzqtfh8,2607
847
- qontract_reconcile-0.10.1rc922.dist-info/METADATA,sha256=RHKa1xgyvZvdV1VeiDykO1dyWDUtbftBqj3cM9Cskx4,2273
848
- qontract_reconcile-0.10.1rc922.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
849
- qontract_reconcile-0.10.1rc922.dist-info/entry_points.txt,sha256=GKQqCl2j2X1BJQ69een6rHcR26PmnxnONLNOQB-nRjY,491
850
- qontract_reconcile-0.10.1rc922.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
851
- qontract_reconcile-0.10.1rc922.dist-info/RECORD,,
847
+ qontract_reconcile-0.10.1rc924.dist-info/METADATA,sha256=lDNUw1z1rLZBkUJGhvbJutmyOh-8hG0mfjXu2C88-Ck,2273
848
+ qontract_reconcile-0.10.1rc924.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
849
+ qontract_reconcile-0.10.1rc924.dist-info/entry_points.txt,sha256=GKQqCl2j2X1BJQ69een6rHcR26PmnxnONLNOQB-nRjY,491
850
+ qontract_reconcile-0.10.1rc924.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
851
+ qontract_reconcile-0.10.1rc924.dist-info/RECORD,,
@@ -45,6 +45,11 @@ query TemplateCollection_v1 {
45
45
  identifier
46
46
  }
47
47
  template
48
+ templateRenderOptions {
49
+ trimBlocks
50
+ lstripBlocks
51
+ keepTrailingNewline
52
+ }
48
53
  }
49
54
  }
50
55
  }
@@ -76,6 +81,12 @@ class TemplatePatchV1(ConfiguredBaseModel):
76
81
  identifier: Optional[str] = Field(..., alias="identifier")
77
82
 
78
83
 
84
+ class TemplateRenderOptionsV1(ConfiguredBaseModel):
85
+ trim_blocks: Optional[bool] = Field(..., alias="trimBlocks")
86
+ lstrip_blocks: Optional[bool] = Field(..., alias="lstripBlocks")
87
+ keep_trailing_newline: Optional[bool] = Field(..., alias="keepTrailingNewline")
88
+
89
+
79
90
  class TemplateV1(ConfiguredBaseModel):
80
91
  name: str = Field(..., alias="name")
81
92
  auto_approved: Optional[bool] = Field(..., alias="autoApproved")
@@ -83,6 +94,7 @@ class TemplateV1(ConfiguredBaseModel):
83
94
  target_path: str = Field(..., alias="targetPath")
84
95
  patch: Optional[TemplatePatchV1] = Field(..., alias="patch")
85
96
  template: str = Field(..., alias="template")
97
+ template_render_options: Optional[TemplateRenderOptionsV1] = Field(..., alias="templateRenderOptions")
86
98
 
87
99
 
88
100
  class TemplateCollectionV1(ConfiguredBaseModel):
@@ -38,6 +38,11 @@ query Templatev1 {
38
38
  expectedTargetPath
39
39
  expectedToRender
40
40
  }
41
+ templateRenderOptions {
42
+ trimBlocks
43
+ lstripBlocks
44
+ keepTrailingNewline
45
+ }
41
46
  }
42
47
  }
43
48
  """
@@ -63,6 +68,12 @@ class TemplateTestV1(ConfiguredBaseModel):
63
68
  expected_to_render: Optional[bool] = Field(..., alias="expectedToRender")
64
69
 
65
70
 
71
+ class TemplateRenderOptionsV1(ConfiguredBaseModel):
72
+ trim_blocks: Optional[bool] = Field(..., alias="trimBlocks")
73
+ lstrip_blocks: Optional[bool] = Field(..., alias="lstripBlocks")
74
+ keep_trailing_newline: Optional[bool] = Field(..., alias="keepTrailingNewline")
75
+
76
+
66
77
  class TemplateV1(ConfiguredBaseModel):
67
78
  name: str = Field(..., alias="name")
68
79
  auto_approved: Optional[bool] = Field(..., alias="autoApproved")
@@ -71,6 +82,7 @@ class TemplateV1(ConfiguredBaseModel):
71
82
  target_path: str = Field(..., alias="targetPath")
72
83
  template: str = Field(..., alias="template")
73
84
  template_test: list[TemplateTestV1] = Field(..., alias="templateTest")
85
+ template_render_options: Optional[TemplateRenderOptionsV1] = Field(..., alias="templateRenderOptions")
74
86
 
75
87
 
76
88
  class Templatev1QueryData(ConfiguredBaseModel):
@@ -5,7 +5,11 @@ from typing import Any, Protocol
5
5
 
6
6
  from pydantic import BaseModel
7
7
 
8
- from reconcile.utils.jinja2.utils import Jinja2TemplateError, process_jinja2_template
8
+ from reconcile.utils.jinja2.utils import (
9
+ Jinja2TemplateError,
10
+ TemplateRenderOptions,
11
+ process_jinja2_template,
12
+ )
9
13
  from reconcile.utils.jsonpath import parse_jsonpath
10
14
  from reconcile.utils.ruamel import create_ruamel_instance
11
15
  from reconcile.utils.secret_reader import SecretReaderBase
@@ -43,11 +47,13 @@ class Renderer(ABC):
43
47
  template: Template,
44
48
  data: TemplateData,
45
49
  secret_reader: SecretReaderBase | None = None,
50
+ template_render_options: TemplateRenderOptions | None = None,
46
51
  ):
47
52
  self.template = template
48
53
  self.data = data
49
54
  self.secret_reader = secret_reader
50
55
  self.ruamel_instance = create_ruamel_instance(explicit_start=True)
56
+ self.template_render_options = template_render_options
51
57
 
52
58
  def _jinja2_render_kwargs(self) -> dict[str, Any]:
53
59
  return {**self.data.variables, "current": self.data.current}
@@ -58,6 +64,7 @@ class Renderer(ABC):
58
64
  body=template,
59
65
  vars=self._jinja2_render_kwargs(),
60
66
  secret_reader=self.secret_reader,
67
+ template_render_options=self.template_render_options,
61
68
  )
62
69
  except Jinja2TemplateError as e:
63
70
  logging.error(f"Error rendering template {self.template.name}: {e}")
@@ -150,11 +157,18 @@ def create_renderer(
150
157
  template: Template,
151
158
  data: TemplateData,
152
159
  secret_reader: SecretReaderBase | None = None,
160
+ template_render_options: TemplateRenderOptions | None = None,
153
161
  ) -> Renderer:
154
162
  if template.patch:
155
- return PatchRenderer(template=template, data=data, secret_reader=secret_reader)
163
+ return PatchRenderer(
164
+ template=template,
165
+ data=data,
166
+ secret_reader=secret_reader,
167
+ template_render_options=template_render_options,
168
+ )
156
169
  return FullRenderer(
157
170
  template=template,
158
171
  data=data,
159
172
  secret_reader=secret_reader,
173
+ template_render_options=template_render_options,
160
174
  )
@@ -23,6 +23,7 @@ from reconcile.templating.lib.merge_request_manager import (
23
23
  )
24
24
  from reconcile.templating.lib.model import TemplateInput, TemplateOutput
25
25
  from reconcile.templating.lib.rendering import (
26
+ Renderer,
26
27
  TemplateData,
27
28
  create_renderer,
28
29
  )
@@ -32,12 +33,13 @@ from reconcile.typed_queries.gitlab_instances import get_gitlab_instances
32
33
  from reconcile.utils import gql
33
34
  from reconcile.utils.git import clone
34
35
  from reconcile.utils.gql import GqlApi, init_from_config
35
- from reconcile.utils.jinja2.utils import process_jinja2_template
36
+ from reconcile.utils.jinja2.utils import TemplateRenderOptions, process_jinja2_template
36
37
  from reconcile.utils.ruamel import create_ruamel_instance
37
38
  from reconcile.utils.runtime.integration import (
38
39
  PydanticRunParams,
39
40
  QontractReconcileIntegration,
40
41
  )
42
+ from reconcile.utils.secret_reader import SecretReaderBase
41
43
  from reconcile.utils.vcs import VCS
42
44
 
43
45
  QONTRACT_INTEGRATION = "template-renderer"
@@ -207,6 +209,31 @@ class TemplateRendererIntegration(QontractReconcileIntegration):
207
209
  def __init__(self, params: TemplateRendererIntegrationParams) -> None:
208
210
  super().__init__(params)
209
211
 
212
+ @staticmethod
213
+ def _create_renderer(
214
+ template: TemplateV1,
215
+ variables: dict,
216
+ secret_reader: SecretReaderBase | None = None,
217
+ ) -> Renderer:
218
+ return create_renderer(
219
+ template,
220
+ TemplateData(
221
+ variables=variables,
222
+ ),
223
+ secret_reader=secret_reader,
224
+ template_render_options=TemplateRenderOptions.create(
225
+ trim_blocks=template.template_render_options.trim_blocks
226
+ if template.template_render_options
227
+ else None,
228
+ lstrip_blocks=template.template_render_options.lstrip_blocks
229
+ if template.template_render_options
230
+ else None,
231
+ keep_trailing_newline=template.template_render_options.keep_trailing_newline
232
+ if template.template_render_options
233
+ else None,
234
+ ),
235
+ )
236
+
210
237
  def process_template(
211
238
  self,
212
239
  template: TemplateV1,
@@ -215,12 +242,8 @@ class TemplateRendererIntegration(QontractReconcileIntegration):
215
242
  ruaml_instance: yaml.YAML,
216
243
  template_input: TemplateInput,
217
244
  ) -> TemplateOutput | None:
218
- r = create_renderer(
219
- template,
220
- TemplateData(
221
- variables=variables,
222
- ),
223
- secret_reader=self.secret_reader,
245
+ r = TemplateRendererIntegration._create_renderer(
246
+ template, variables, secret_reader=self.secret_reader
224
247
  )
225
248
  target_path = r.render_target_path()
226
249
 
@@ -12,6 +12,7 @@ from reconcile.gql_definitions.templating.templates import (
12
12
  )
13
13
  from reconcile.templating.lib.rendering import Renderer, TemplateData, create_renderer
14
14
  from reconcile.utils import gql
15
+ from reconcile.utils.jinja2.utils import TemplateRenderOptions
15
16
  from reconcile.utils.ruamel import create_ruamel_instance
16
17
  from reconcile.utils.runtime.integration import (
17
18
  QontractReconcileIntegration,
@@ -50,6 +51,17 @@ class TemplateValidatorIntegration(QontractReconcileIntegration):
50
51
  current=ruaml_instance.load(template_test.current or ""),
51
52
  ),
52
53
  secret_reader=secret_reader,
54
+ template_render_options=TemplateRenderOptions.create(
55
+ trim_blocks=template.template_render_options.trim_blocks
56
+ if template.template_render_options
57
+ else None,
58
+ lstrip_blocks=template.template_render_options.lstrip_blocks
59
+ if template.template_render_options
60
+ else None,
61
+ keep_trailing_newline=template.template_render_options.keep_trailing_newline
62
+ if template.template_render_options
63
+ else None,
64
+ ),
53
65
  )
54
66
 
55
67
  @staticmethod
@@ -1085,6 +1085,7 @@ class TestConfigHashPromotionsValidation(TestCase):
1085
1085
  "success": True,
1086
1086
  "saas_file": self.saas_file.name,
1087
1087
  "target_config_hash": "ed2af38cf21f268c",
1088
+ "has_succeeded_once": True,
1088
1089
  }
1089
1090
  self.state_mock.get.return_value = publisher_state
1090
1091
  result = self.saasherder.validate_promotions()
@@ -1101,16 +1102,19 @@ class TestConfigHashPromotionsValidation(TestCase):
1101
1102
  "success": True,
1102
1103
  "saas_file": self.saas_file.name,
1103
1104
  "target_config_hash": "ed2af38cf21f268c",
1105
+ "has_succeeded_once": True,
1104
1106
  },
1105
1107
  {
1106
1108
  "success": True,
1107
1109
  "saas_file": self.saas_file.name,
1108
1110
  "target_config_hash": "ed2af38cf21f268c",
1111
+ "has_succeeded_once": True,
1109
1112
  },
1110
1113
  {
1111
1114
  "success": True,
1112
1115
  "saas_file": self.saas_file.name,
1113
1116
  "target_config_hash": "will_not_match",
1117
+ "has_succeeded_once": True,
1114
1118
  },
1115
1119
  ]
1116
1120
  self.state_mock.get.side_effect = publisher_states
@@ -1137,6 +1141,7 @@ class TestConfigHashPromotionsValidation(TestCase):
1137
1141
  "success": True,
1138
1142
  "saas_file": self.saas_file.name,
1139
1143
  "target_config_hash": "whatever",
1144
+ "has_succeeded_once": True,
1140
1145
  }
1141
1146
 
1142
1147
  self.assertEqual(len(self.saasherder.promotions), 4)
@@ -1148,6 +1153,62 @@ class TestConfigHashPromotionsValidation(TestCase):
1148
1153
  result = self.saasherder.validate_promotions()
1149
1154
  self.assertTrue(result)
1150
1155
 
1156
+ def test_promotion_state_re_deployment_failed(self) -> None:
1157
+ """A promotion is valid if it has ever succeeded for that ref.
1158
+ Re-deployment results should be neglected for validation.
1159
+ """
1160
+ publisher_state = {
1161
+ # Latest state is failed ...
1162
+ "success": False,
1163
+ "saas_file": self.saas_file.name,
1164
+ "target_config_hash": "ed2af38cf21f268c",
1165
+ # ... however, the deployment succeeded sometime before once.
1166
+ "has_succeeded_once": True,
1167
+ }
1168
+ self.state_mock.get.return_value = publisher_state
1169
+ result = self.saasherder.validate_promotions()
1170
+ self.assertTrue(result)
1171
+
1172
+ def test_promotion_state_never_successfully_deployed(self) -> None:
1173
+ """A promotion is invalid, if it never succeeded before."""
1174
+ publisher_state = {
1175
+ # Latest state is failed ...
1176
+ "success": False,
1177
+ "saas_file": self.saas_file.name,
1178
+ "target_config_hash": "ed2af38cf21f268c",
1179
+ # ... and it never succeeded once before.
1180
+ "has_succeeded_once": False,
1181
+ }
1182
+ self.state_mock.get.return_value = publisher_state
1183
+ result = self.saasherder.validate_promotions()
1184
+ self.assertFalse(result)
1185
+
1186
+ def test_promotion_state_success_backwards_compatibility_success(self) -> None:
1187
+ """Not all states have the has_succeeded_once attribute yet.
1188
+ If it doesnt exist, we should always fall back to latest success state.
1189
+ """
1190
+ publisher_state = {
1191
+ "success": True,
1192
+ "saas_file": self.saas_file.name,
1193
+ "target_config_hash": "ed2af38cf21f268c",
1194
+ }
1195
+ self.state_mock.get.return_value = publisher_state
1196
+ result = self.saasherder.validate_promotions()
1197
+ self.assertTrue(result)
1198
+
1199
+ def test_promotion_state_success_backwards_compatibility_fail(self) -> None:
1200
+ """Not all states have the has_succeeded_once attribute yet.
1201
+ If it doesnt exist, we should always fall back to latest success state.
1202
+ """
1203
+ publisher_state = {
1204
+ "success": False,
1205
+ "saas_file": self.saas_file.name,
1206
+ "target_config_hash": "ed2af38cf21f268c",
1207
+ }
1208
+ self.state_mock.get.return_value = publisher_state
1209
+ result = self.saasherder.validate_promotions()
1210
+ self.assertFalse(result)
1211
+
1151
1212
 
1152
1213
  @pytest.mark.usefixtures("inject_gql_class_factory")
1153
1214
  class TestSoakDays(TestCase):
@@ -1,8 +1,9 @@
1
1
  from functools import cache
2
- from typing import Any
2
+ from typing import Any, Self
3
3
 
4
4
  import jinja2
5
5
  from jinja2.sandbox import SandboxedEnvironment
6
+ from pydantic import BaseModel
6
7
  from sretoolbox.utils import retry
7
8
 
8
9
  from reconcile import queries
@@ -31,18 +32,46 @@ class Jinja2TemplateError(Exception):
31
32
  super().__init__("error processing jinja2 template: " + str(msg))
32
33
 
33
34
 
35
+ class TemplateRenderOptions(BaseModel):
36
+ trim_blocks: bool
37
+ lstrip_blocks: bool
38
+ keep_trailing_newline: bool
39
+
40
+ class Config:
41
+ frozen = True
42
+
43
+ @classmethod
44
+ def create(
45
+ cls,
46
+ trim_blocks: bool | None = None,
47
+ lstrip_blocks: bool | None = None,
48
+ keep_trailing_newline: bool | None = None,
49
+ ) -> Self:
50
+ return cls(
51
+ trim_blocks=trim_blocks or False,
52
+ lstrip_blocks=lstrip_blocks or False,
53
+ keep_trailing_newline=keep_trailing_newline or False,
54
+ )
55
+
56
+
34
57
  @cache
35
- def compile_jinja2_template(body: str, extra_curly: bool = False) -> Any:
36
- env: dict = {}
58
+ def compile_jinja2_template(
59
+ body: str,
60
+ extra_curly: bool = False,
61
+ template_render_options: TemplateRenderOptions | None = None,
62
+ ) -> Any:
63
+ if not template_render_options:
64
+ template_render_options = TemplateRenderOptions.create()
65
+ env: dict[str, Any] = template_render_options.dict()
37
66
  if extra_curly:
38
- env = {
67
+ env.update({
39
68
  "block_start_string": "{{%",
40
69
  "block_end_string": "%}}",
41
70
  "variable_start_string": "{{{",
42
71
  "variable_end_string": "}}}",
43
72
  "comment_start_string": "{{#",
44
73
  "comment_end_string": "#}}",
45
- }
74
+ })
46
75
 
47
76
  jinja_env = SandboxedEnvironment(
48
77
  extensions=[B64EncodeExtension, RaiseErrorExtension],
@@ -154,6 +183,7 @@ def process_jinja2_template(
154
183
  extra_curly: bool = False,
155
184
  settings: dict[str, Any] | None = None,
156
185
  secret_reader: SecretReaderBase | None = None,
186
+ template_render_options: TemplateRenderOptions | None = None,
157
187
  ) -> Any:
158
188
  if vars is None:
159
189
  vars = {}
@@ -187,7 +217,7 @@ def process_jinja2_template(
187
217
  for k, v in vars["_template_mocks"].items():
188
218
  vars[k] = lambda *args, **kwargs: v
189
219
  try:
190
- template = compile_jinja2_template(body, extra_curly)
220
+ template = compile_jinja2_template(body, extra_curly, template_render_options)
191
221
  r = template.render(vars)
192
222
  except Exception as e:
193
223
  raise Jinja2TemplateError(e)
@@ -197,9 +227,10 @@ def process_jinja2_template(
197
227
  def process_extracurlyjinja2_template(
198
228
  body: str,
199
229
  vars: dict[str, Any] | None = None,
200
- extra_curly: bool = True,
230
+ extra_curly: bool = True, # ignored. Just to be compatible with process_jinja2_template
201
231
  settings: dict[str, Any] | None = None,
202
232
  secret_reader: SecretReaderBase | None = None,
233
+ template_render_options: TemplateRenderOptions | None = None,
203
234
  ) -> Any:
204
235
  if vars is None:
205
236
  vars = {}
@@ -209,6 +240,7 @@ def process_extracurlyjinja2_template(
209
240
  extra_curly=True,
210
241
  settings=settings,
211
242
  secret_reader=secret_reader,
243
+ template_render_options=template_render_options,
212
244
  )
213
245
 
214
246
 
@@ -18,10 +18,15 @@ class PromotionData(BaseModel):
18
18
  requirements.
19
19
  """
20
20
 
21
+ # The success is primarily used for SAPM auto-promotions
21
22
  success: bool
22
23
  target_config_hash: str | None
23
24
  saas_file: str | None
24
25
  check_in: str | None
26
+ # Whether this promotion has ever succeeded
27
+ # Note, this shouldnt be overridden on subsequent promotions of same ref
28
+ # This attribute is primarily used by saasherder validations
29
+ has_succeeded_once: bool | None
25
30
 
26
31
  class Config:
27
32
  smart_union = True
@@ -1846,7 +1846,9 @@ class SaasHerder: # pylint: disable=too-many-public-methods
1846
1846
  target_uid=target_uid,
1847
1847
  pre_check_sha_exists=False,
1848
1848
  )
1849
- if not (deployment and deployment.success):
1849
+ if not (
1850
+ deployment and (deployment.success or deployment.has_succeeded_once)
1851
+ ):
1850
1852
  logging.error(
1851
1853
  f"Commit {promotion.commit_sha} was not "
1852
1854
  + f"published with success to channel {channel.name}"
@@ -1943,6 +1945,17 @@ class SaasHerder: # pylint: disable=too-many-public-methods
1943
1945
  all_subscribed_saas_file_paths = set()
1944
1946
  all_subscribed_target_paths = set()
1945
1947
  for channel in promotion.publish:
1948
+ # make sure we keep some attributes on re-deployments of same ref
1949
+ has_succeeded_once = success
1950
+ current_state = self._promotion_state.get_promotion_data(
1951
+ sha=promotion.commit_sha,
1952
+ channel=channel,
1953
+ target_uid=promotion.saas_target_uid,
1954
+ use_cache=True,
1955
+ )
1956
+ if current_state and current_state.has_succeeded_once:
1957
+ has_succeeded_once = True
1958
+
1946
1959
  # publish to state to pass promotion gate
1947
1960
  self._promotion_state.publish_promotion_data(
1948
1961
  sha=promotion.commit_sha,
@@ -1952,6 +1965,7 @@ class SaasHerder: # pylint: disable=too-many-public-methods
1952
1965
  saas_file=promotion.saas_file,
1953
1966
  success=success,
1954
1967
  target_config_hash=promotion.target_config_hash,
1968
+ has_succeeded_once=has_succeeded_once,
1955
1969
  # TODO: do not override - check if timestamp already exists
1956
1970
  check_in=str(now),
1957
1971
  ),