qontract-reconcile 0.10.1rc735__py3-none-any.whl → 0.10.1rc737__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.1rc735
3
+ Version: 0.10.1rc737
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
@@ -10,7 +10,7 @@ reconcile/aws_iam_password_reset.py,sha256=NwErtrqgBiXr7eGCAHdtGGOx0S7-4JnSc29Ie
10
10
  reconcile/aws_support_cases_sos.py,sha256=Jk6_XjDeJSYxgRGqcEAOcynt9qJF2r5HPIPcSKmoBv8,2974
11
11
  reconcile/blackbox_exporter_endpoint_monitoring.py,sha256=W_VJagnsJR1v5oqjlI3RJJE0_nhtJ0m81RS8zWA5u5c,3538
12
12
  reconcile/checkpoint.py,sha256=R2WFXUXLTB4sWMi4GeA4eegsuf_1-Q4vH8M0Toh3Ij4,5036
13
- reconcile/cli.py,sha256=XBUVgDXU9vIUWu4F3qkNN39FlMH7XN8kaGjzfShPWGM,97162
13
+ reconcile/cli.py,sha256=Fra8s6kxIuCoLF-moSto81gDFoJ6Q-WrsaRcVxR-KRI,98281
14
14
  reconcile/closedbox_endpoint_monitoring_base.py,sha256=SMhkcQqprWvThrIJa3U_3uh5w1h-alleW1QnCJFY4Qw,4909
15
15
  reconcile/cluster_deployment_mapper.py,sha256=2Ah-nu-Mdig0pjuiZl_XLrmVAjYzFjORR3dMlCgkmw0,2352
16
16
  reconcile/dashdotdb_base.py,sha256=a5aPLVxyqPSbjdB0Ty-uliOtxwvEbbEljHJKxdK3-Zk,4813
@@ -331,8 +331,8 @@ reconcile/gql_definitions/status_board/status_board.py,sha256=vHEzncabujkqbjJ-ib
331
331
  reconcile/gql_definitions/statuspage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
332
332
  reconcile/gql_definitions/statuspage/statuspages.py,sha256=gxDb42H93nwtBg7oFRb6Gk9pbAZpsWk_y4Y0s3_g3nE,3520
333
333
  reconcile/gql_definitions/templating/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
334
- reconcile/gql_definitions/templating/template_collection.py,sha256=QYqEsy8BBdX9GK4SuOdSe_hqOHK4IHNz9eJ5P2f4TRQ,3007
335
- reconcile/gql_definitions/templating/templates.py,sha256=ujPPFZm9BbkRrxkcR7ZyReDYfFem4uxONYoPuzgiBTc,2735
334
+ reconcile/gql_definitions/templating/template_collection.py,sha256=lS0vzEKV2ZrzOqOEriqpy0yBgKjb2Ftrzgx6PIH46_4,3310
335
+ reconcile/gql_definitions/templating/templates.py,sha256=ejAvQ13zfNMQTz3FWtRUic6dSvio3aAgBKEqt600hbk,2821
336
336
  reconcile/gql_definitions/terraform_cloudflare_dns/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
337
337
  reconcile/gql_definitions/terraform_cloudflare_dns/app_interface_cloudflare_dns_settings.py,sha256=eyGX9HcTF6MZbOYZ6Kl6Mg3k6nJTUtwqs9gDxBP_8Dk,1920
338
338
  reconcile/gql_definitions/terraform_cloudflare_dns/terraform_cloudflare_zones.py,sha256=uVZYu5EUcvdAQYBK5YKD0mjoMKDb5inSuCJrrOD5KpE,5704
@@ -342,6 +342,8 @@ reconcile/gql_definitions/terraform_cloudflare_resources/terraform_cloudflare_re
342
342
  reconcile/gql_definitions/terraform_cloudflare_users/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
343
343
  reconcile/gql_definitions/terraform_cloudflare_users/app_interface_setting_cloudflare_and_vault.py,sha256=KFey-0ItgpGPeIwViGKqb55HAFJoKdi3eCNSIzf6Rc8,1960
344
344
  reconcile/gql_definitions/terraform_cloudflare_users/terraform_cloudflare_roles.py,sha256=1KiTikTKSSRYmISN8tY29rJ_fVtgBnT7sK85IJjN420,4027
345
+ reconcile/gql_definitions/terraform_init/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
346
+ reconcile/gql_definitions/terraform_init/aws_accounts.py,sha256=OJ0hDbRachRaDkL-OGT6-byr9cKdBiQDnNCpwUe3oJ8,2674
345
347
  reconcile/gql_definitions/terraform_repo/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
346
348
  reconcile/gql_definitions/terraform_repo/terraform_repo.py,sha256=pefTRhb0ZcWm_j6dYz6m6qNqZFteqgrQ25SgUqRAVaI,3173
347
349
  reconcile/gql_definitions/terraform_resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -419,12 +421,16 @@ reconcile/templates/jira-checkpoint-missinginfo.j2,sha256=c_Vvg-lEENsB3tgxm9B6Y9
419
421
  reconcile/templates/rosa-classic-cluster-creation.sh.j2,sha256=0UHfYtXRVJqP07VJQx456cRI6EbZNBgamtP_8nb4WPY,2353
420
422
  reconcile/templates/rosa-hcp-cluster-creation.sh.j2,sha256=O7Bf3WQIJhsZoEqaYA0wRktUO4yXXCb4BQkuvvp-C80,2385
421
423
  reconcile/templating/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
422
- reconcile/templating/renderer.py,sha256=JUUzXGXzhcq-bhj81se7lLWgVfrUIEpO5vh6JgppbrI,10178
423
- reconcile/templating/validator.py,sha256=QGH2VSk7sVBuojhk9quRAbj_XBykHN-KZ53DbEneUJs,4391
424
+ reconcile/templating/renderer.py,sha256=2FWbnefT2siozQpXXkuvVKUo6cePMqLY4BMYpqXg6xM,10652
425
+ reconcile/templating/validator.py,sha256=pvDEc6veznEZzjypkoRJUGMMFLWosU-zd7i3j7JeNjE,4670
424
426
  reconcile/templating/lib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
425
- reconcile/templating/lib/merge_request_manager.py,sha256=El3ufdVjHP4EW-LztX7zPiI9syJBJ6ETGRmiM9K4Nqw,5112
426
- reconcile/templating/lib/model.py,sha256=TYiH2xL63awd30U-fkZDLEzuxkLdUEgzJ913DEzpFoM,265
427
+ reconcile/templating/lib/merge_request_manager.py,sha256=JUkfF3smaQ8onzKF5F7UpmA7MWaQpftANy6dDo1FCug,5464
428
+ reconcile/templating/lib/model.py,sha256=fb6FYYLQjmoh2DjVKO7TEWCuDPf1Q34xmOx0M9Z07ek,324
427
429
  reconcile/templating/lib/rendering.py,sha256=_BVQ2gqip8K1AgLYfaTWh8NKJFTW6VjUZ6rBI_GH30E,5061
430
+ reconcile/terraform_init/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
431
+ reconcile/terraform_init/integration.py,sha256=xcFKTc_or3xB3kE_I3OECNkkgbwALIwwdiktnF-MyWI,6114
432
+ reconcile/terraform_init/merge_request.py,sha256=3CYtgSd7Q9zjKg4wsDz437EPCRfGeZZ8fZ0Y-ChKXJY,1475
433
+ reconcile/terraform_init/merge_request_manager.py,sha256=fMcT6hbdEF3nFATJpvr8BedvQHq_MzFkgVJSloBNwOQ,3101
428
434
  reconcile/test/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
429
435
  reconcile/test/conftest.py,sha256=rQousYrxUz-EwAIbsYO6bIwR1B4CrOz9y_zaUVo2lfI,4466
430
436
  reconcile/test/fixtures.py,sha256=9SDWAUlSd1rCx7z3GhULHcpr-I6FyCsXxaFAZIqYQsQ,591
@@ -664,9 +670,10 @@ reconcile/utils/acs/notifiers.py,sha256=LfWw9LGq7hA90A69n8Ie9f-NozvCGdYwXEXRLIc1
664
670
  reconcile/utils/acs/policies.py,sha256=_jAz6cv8KRYtDsXjGoJgNbD8_9PUa5LSwwVlpK4A_cQ,5505
665
671
  reconcile/utils/acs/rbac.py,sha256=ugsLM9Pb7FbUbdq85E3VzXGMaB9ZovXob7tdWCxwqZ8,8808
666
672
  reconcile/utils/aws_api_typed/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
667
- reconcile/utils/aws_api_typed/api.py,sha256=gqfZISSQLp6tHEAwEsroLWwyU4ZdbwHi9p0rNBQyLuI,7901
673
+ reconcile/utils/aws_api_typed/api.py,sha256=rGUh7-gc8AcjUFuLgQxKyPy1KGY7ffNe6zT1EDdurOM,8283
668
674
  reconcile/utils/aws_api_typed/iam.py,sha256=ka46H2-SzTCgy6EJYapKTzyZK9vR1bkfD0wF8bDdy1Q,2201
669
675
  reconcile/utils/aws_api_typed/organization.py,sha256=oXftcLVuSs9qej6efdssl38FvjeZaQC5R2Wj3NzxX4U,5529
676
+ reconcile/utils/aws_api_typed/s3.py,sha256=J2uOTtEFgMyKT22pa4DbFnV7zfg575m2DeidQaeselM,1034
670
677
  reconcile/utils/aws_api_typed/service_quotas.py,sha256=OU1D8LCmMw1IT87nt45LqXhguzcWwC8AaBdDTI7tz98,3018
671
678
  reconcile/utils/aws_api_typed/sts.py,sha256=5Sauncj9Fif3YDLkJYkBZrtOX0v0bGAqOmY0A5Bh9yA,1237
672
679
  reconcile/utils/aws_api_typed/support.py,sha256=PH3UW96Ne4_8I1J-_Vqj-DsK73gYGeVdOH13eD1783c,2447
@@ -770,8 +777,8 @@ tools/test/test_app_interface_metrics_exporter.py,sha256=SX7qL3D1SIRKFo95FoQztvf
770
777
  tools/test/test_qontract_cli.py,sha256=w2l4BHB09k1d-BGJ1jBUNCqDv7zkqYrMHojQXg-21kQ,4155
771
778
  tools/test/test_sd_app_sre_alert_report.py,sha256=v363r9zM7__0kR5K6mvJoGFcM9BvE33fWAayrqkpojA,2116
772
779
  tools/test/test_sre_checkpoints.py,sha256=SKqPPTl9ua0RFdSSofnoQX-JZE6dFLO3LRhfQzqtfh8,2607
773
- qontract_reconcile-0.10.1rc735.dist-info/METADATA,sha256=AQww9xL2v9VdTWvywruYZoPnQRbH2TO1wlIpHlpfGqE,2382
774
- qontract_reconcile-0.10.1rc735.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
775
- qontract_reconcile-0.10.1rc735.dist-info/entry_points.txt,sha256=rIxI5zWtHNlfpDeq1a7pZXAPoqf7HG32KMTN3MeWK_8,429
776
- qontract_reconcile-0.10.1rc735.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
777
- qontract_reconcile-0.10.1rc735.dist-info/RECORD,,
780
+ qontract_reconcile-0.10.1rc737.dist-info/METADATA,sha256=cSDUzKY1nBbpIuGIWfYeQyeIlIvmyU8slP8iVQOiR7Q,2382
781
+ qontract_reconcile-0.10.1rc737.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
782
+ qontract_reconcile-0.10.1rc737.dist-info/entry_points.txt,sha256=rIxI5zWtHNlfpDeq1a7pZXAPoqf7HG32KMTN3MeWK_8,429
783
+ qontract_reconcile-0.10.1rc737.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
784
+ qontract_reconcile-0.10.1rc737.dist-info/RECORD,,
reconcile/cli.py CHANGED
@@ -1032,6 +1032,41 @@ def aws_account_manager(
1032
1032
  )
1033
1033
 
1034
1034
 
1035
+ @integration.command(short_help="Initialize AWS accounts for Terraform usage.")
1036
+ @account_name
1037
+ @click.option(
1038
+ "--state-tmpl-resource",
1039
+ help="Resource name of the state template-collection template in the app-interface.",
1040
+ required=True,
1041
+ default="/terraform-init/terraform-state.yml",
1042
+ )
1043
+ @click.option(
1044
+ "--template-collection-root-path",
1045
+ help="File path to the root directory to store new state template-collections.",
1046
+ required=True,
1047
+ default="data/templating/collections/terraform-init",
1048
+ )
1049
+ @click.pass_context
1050
+ def terraform_init(
1051
+ ctx, account_name, state_tmpl_resource, template_collection_root_path
1052
+ ):
1053
+ from reconcile.terraform_init.integration import (
1054
+ TerraformInitIntegration,
1055
+ TerraformInitIntegrationParams,
1056
+ )
1057
+
1058
+ run_class_integration(
1059
+ integration=TerraformInitIntegration(
1060
+ TerraformInitIntegrationParams(
1061
+ account_name=account_name,
1062
+ state_tmpl_resource=state_tmpl_resource,
1063
+ template_collection_root_path=template_collection_root_path,
1064
+ )
1065
+ ),
1066
+ ctx=ctx.obj,
1067
+ )
1068
+
1069
+
1035
1070
  @integration.command(short_help="Manage Jenkins roles association via REST API.")
1036
1071
  @click.pass_context
1037
1072
  def jenkins_roles(ctx):
@@ -22,7 +22,9 @@ DEFINITION = """
22
22
  query TemplateCollection_v1 {
23
23
  template_collection_v1 {
24
24
  name
25
+ additionalMrLabels
25
26
  description
27
+ enableAutoApproval
26
28
  variables {
27
29
  static
28
30
  dynamic {
@@ -32,6 +34,7 @@ query TemplateCollection_v1 {
32
34
  }
33
35
  templates {
34
36
  name
37
+ autoApproved
35
38
  condition
36
39
  targetPath
37
40
  patch {
@@ -68,6 +71,7 @@ class TemplatePatchV1(ConfiguredBaseModel):
68
71
 
69
72
  class TemplateV1(ConfiguredBaseModel):
70
73
  name: str = Field(..., alias="name")
74
+ auto_approved: Optional[bool] = Field(..., alias="autoApproved")
71
75
  condition: Optional[str] = Field(..., alias="condition")
72
76
  target_path: str = Field(..., alias="targetPath")
73
77
  patch: Optional[TemplatePatchV1] = Field(..., alias="patch")
@@ -76,7 +80,9 @@ class TemplateV1(ConfiguredBaseModel):
76
80
 
77
81
  class TemplateCollectionV1(ConfiguredBaseModel):
78
82
  name: str = Field(..., alias="name")
83
+ additional_mr_labels: Optional[list[str]] = Field(..., alias="additionalMrLabels")
79
84
  description: str = Field(..., alias="description")
85
+ enable_auto_approval: Optional[bool] = Field(..., alias="enableAutoApproval")
80
86
  variables: Optional[TemplateCollectionVariablesV1] = Field(..., alias="variables")
81
87
  templates: list[TemplateV1] = Field(..., alias="templates")
82
88
 
@@ -22,6 +22,7 @@ DEFINITION = """
22
22
  query Templatev1 {
23
23
  template_v1 {
24
24
  name
25
+ autoApproved
25
26
  condition
26
27
  patch {
27
28
  path
@@ -64,6 +65,7 @@ class TemplateTestV1(ConfiguredBaseModel):
64
65
 
65
66
  class TemplateV1(ConfiguredBaseModel):
66
67
  name: str = Field(..., alias="name")
68
+ auto_approved: Optional[bool] = Field(..., alias="autoApproved")
67
69
  condition: Optional[str] = Field(..., alias="condition")
68
70
  patch: Optional[TemplatePatchV1] = Field(..., alias="patch")
69
71
  target_path: str = Field(..., alias="targetPath")
File without changes
@@ -0,0 +1,93 @@
1
+ """
2
+ Generated by qenerate plugin=pydantic_v1. DO NOT MODIFY MANUALLY!
3
+ """
4
+ from collections.abc import Callable # noqa: F401 # pylint: disable=W0611
5
+ from datetime import datetime # noqa: F401 # pylint: disable=W0611
6
+ from enum import Enum # noqa: F401 # pylint: disable=W0611
7
+ from typing import ( # noqa: F401 # pylint: disable=W0611
8
+ Any,
9
+ Optional,
10
+ Union,
11
+ )
12
+
13
+ from pydantic import ( # noqa: F401 # pylint: disable=W0611
14
+ BaseModel,
15
+ Extra,
16
+ Field,
17
+ Json,
18
+ )
19
+
20
+ from reconcile.gql_definitions.fragments.vault_secret import VaultSecret
21
+
22
+
23
+ DEFINITION = """
24
+ fragment VaultSecret on VaultSecret_v1 {
25
+ path
26
+ field
27
+ version
28
+ format
29
+ }
30
+
31
+ query TerraformInitAWSAccounts {
32
+ accounts: awsaccounts_v1 {
33
+ name
34
+ terraformUsername
35
+ terraformState {
36
+ region
37
+ }
38
+ resourcesDefaultRegion
39
+ automationToken {
40
+ ...VaultSecret
41
+ }
42
+ disable {
43
+ integrations
44
+ }
45
+ }
46
+ }
47
+ """
48
+
49
+
50
+ class ConfiguredBaseModel(BaseModel):
51
+ class Config:
52
+ smart_union=True
53
+ extra=Extra.forbid
54
+
55
+
56
+ class TerraformStateAWSV1(ConfiguredBaseModel):
57
+ region: str = Field(..., alias="region")
58
+
59
+
60
+ class DisableClusterAutomationsV1(ConfiguredBaseModel):
61
+ integrations: Optional[list[str]] = Field(..., alias="integrations")
62
+
63
+
64
+ class AWSAccountV1(ConfiguredBaseModel):
65
+ name: str = Field(..., alias="name")
66
+ terraform_username: Optional[str] = Field(..., alias="terraformUsername")
67
+ terraform_state: Optional[TerraformStateAWSV1] = Field(..., alias="terraformState")
68
+ resources_default_region: str = Field(..., alias="resourcesDefaultRegion")
69
+ automation_token: VaultSecret = Field(..., alias="automationToken")
70
+ disable: Optional[DisableClusterAutomationsV1] = Field(..., alias="disable")
71
+
72
+
73
+ class TerraformInitAWSAccountsQueryData(ConfiguredBaseModel):
74
+ accounts: Optional[list[AWSAccountV1]] = Field(..., alias="accounts")
75
+
76
+
77
+ def query(query_func: Callable, **kwargs: Any) -> TerraformInitAWSAccountsQueryData:
78
+ """
79
+ This is a convenience function which queries and parses the data into
80
+ concrete types. It should be compatible with most GQL clients.
81
+ You do not have to use it to consume the generated data classes.
82
+ Alternatively, you can also mime and alternate the behavior
83
+ of this function in the caller.
84
+
85
+ Parameters:
86
+ query_func (Callable): Function which queries your GQL Server
87
+ kwargs: optional arguments that will be passed to the query function
88
+
89
+ Returns:
90
+ TerraformInitAWSAccountsQueryData: queried data parsed into generated classes
91
+ """
92
+ raw_data: dict[Any, Any] = query_func(DEFINITION, **kwargs)
93
+ return TerraformInitAWSAccountsQueryData(**raw_data)
@@ -13,6 +13,7 @@ from reconcile.utils.merge_request_manager.parser import (
13
13
  Parser,
14
14
  )
15
15
  from reconcile.utils.mr import MergeRequestBase
16
+ from reconcile.utils.mr.labels import AUTO_MERGE
16
17
  from reconcile.utils.vcs import VCS
17
18
 
18
19
  DATA_SEPARATOR = (
@@ -120,16 +121,23 @@ class TemplateRenderingMR(MergeRequestBase):
120
121
  )
121
122
 
122
123
 
124
+ class MrData(BaseModel):
125
+ data: list[TemplateOutput]
126
+ auto_approved: bool
127
+
128
+
123
129
  class MergeRequestManager(MergeRequestManagerBase[TemplateInfo]):
124
130
  def __init__(self, vcs: VCS, parser: Parser):
125
131
  super().__init__(vcs, parser, TR_LABEL)
126
132
 
127
- def create_merge_request(self, output: list[TemplateOutput]) -> None:
133
+ def create_merge_request(self, data: MrData) -> None:
128
134
  if not self._housekeeping_ran:
129
135
  self.housekeeping()
130
136
 
131
- collections = {o.input.collection for o in output if o.input}
132
- collection_hashes = {o.input.collection_hash for o in output if o.input}
137
+ output = data.data
138
+ collections = {o.input.collection for o in output}
139
+ collection_hashes = {o.input.collection_hash for o in output}
140
+ additional_labels = {label for o in output for label in o.input.labels}
133
141
  # From the way the code is written, we can assert that there is only one collection and one template hash
134
142
  assert len(collections) == 1
135
143
  assert len(collection_hashes) == 1
@@ -158,6 +166,12 @@ class MergeRequestManager(MergeRequestManagerBase[TemplateInfo]):
158
166
  logging.info("Opening MR for %s with hash (%s)", collection, collection_hash)
159
167
  mr_labels = [TR_LABEL]
160
168
 
169
+ if data.auto_approved:
170
+ mr_labels.append(AUTO_MERGE)
171
+
172
+ if additional_labels:
173
+ mr_labels.extend(additional_labels)
174
+
161
175
  self._vcs.open_app_interface_merge_request(
162
176
  mr=TemplateRenderingMR(
163
177
  title=title,
@@ -1,15 +1,16 @@
1
- from typing import Optional
2
-
3
1
  from pydantic import BaseModel
4
2
 
5
3
 
6
4
  class TemplateInput(BaseModel):
7
5
  collection: str
8
6
  collection_hash: str
7
+ enable_auto_approval: bool = False
8
+ labels: list[str] = []
9
9
 
10
10
 
11
11
  class TemplateOutput(BaseModel):
12
- input: Optional[TemplateInput]
12
+ input: TemplateInput
13
13
  is_new: bool = False
14
14
  path: str
15
15
  content: str
16
+ auto_approved: bool = False
@@ -17,6 +17,7 @@ from reconcile.gql_definitions.templating.template_collection import (
17
17
  )
18
18
  from reconcile.templating.lib.merge_request_manager import (
19
19
  MergeRequestManager,
20
+ MrData,
20
21
  create_parser,
21
22
  )
22
23
  from reconcile.templating.lib.model import TemplateInput, TemplateOutput
@@ -59,6 +60,19 @@ class FilePersistence(ABC):
59
60
  def read(self, path: str) -> Optional[str]:
60
61
  pass
61
62
 
63
+ @staticmethod
64
+ def _read_local_file(path: str) -> Optional[str]:
65
+ try:
66
+ with open(
67
+ path,
68
+ "r",
69
+ encoding="utf-8",
70
+ ) as f:
71
+ return f.read()
72
+ except FileNotFoundError:
73
+ logging.debug(f"File not found: {path}, need to create it")
74
+ return None
75
+
62
76
 
63
77
  class LocalFilePersistence(FilePersistence):
64
78
  """
@@ -80,16 +94,7 @@ class LocalFilePersistence(FilePersistence):
80
94
  f.write(output.content)
81
95
 
82
96
  def read(self, path: str) -> Optional[str]:
83
- try:
84
- with open(
85
- f"{join_path(self.app_interface_data_path, path)}",
86
- "r",
87
- encoding="utf-8",
88
- ) as f:
89
- return f.read()
90
- except FileNotFoundError:
91
- logging.debug(f"File not found: {path}, need to create it")
92
- return None
97
+ return self._read_local_file(join_path(self.app_interface_data_path, path))
93
98
 
94
99
 
95
100
  class PersistenceTransaction(FilePersistence):
@@ -127,6 +132,8 @@ class ClonedRepoGitlabPersistence(FilePersistence):
127
132
  """
128
133
  This class is used to persist the rendered templates in a cloned gitlab repo
129
134
  Reads are from the local filesystem, writes are done via utils.VCS abstraction
135
+
136
+ Only one MR is created per run. Auto-approval MRs are prefered.
130
137
  """
131
138
 
132
139
  def __init__(self, local_path: str, vcs: VCS, mr_manager: MergeRequestManager):
@@ -136,19 +143,19 @@ class ClonedRepoGitlabPersistence(FilePersistence):
136
143
 
137
144
  def write(self, outputs: list[TemplateOutput]) -> None:
138
145
  self.mr_manager.housekeeping()
139
- self.mr_manager.create_merge_request(outputs)
146
+
147
+ if any([o.input.enable_auto_approval for o in outputs]):
148
+ auto_approved = [o for o in outputs if o.auto_approved]
149
+ if auto_approved:
150
+ self.mr_manager.create_merge_request(
151
+ MrData(data=auto_approved, auto_approved=True)
152
+ )
153
+ return
154
+
155
+ self.mr_manager.create_merge_request(MrData(data=outputs, auto_approved=False))
140
156
 
141
157
  def read(self, path: str) -> Optional[str]:
142
- try:
143
- with open(
144
- f"{join_path(self.local_path, path)}",
145
- "r",
146
- encoding="utf-8",
147
- ) as f:
148
- return f.read()
149
- except FileNotFoundError:
150
- logging.debug(f"File not found: {path}, need to create it")
151
- return None
158
+ return self._read_local_file(join_path(self.local_path, path))
152
159
 
153
160
 
154
161
  def unpack_static_variables(
@@ -187,6 +194,7 @@ class TemplateRendererIntegration(QontractReconcileIntegration):
187
194
  variables: dict,
188
195
  persistence: FilePersistence,
189
196
  ruaml_instance: yaml.YAML,
197
+ template_input: TemplateInput,
190
198
  ) -> Optional[TemplateOutput]:
191
199
  r = create_renderer(
192
200
  template,
@@ -217,6 +225,8 @@ class TemplateRendererIntegration(QontractReconcileIntegration):
217
225
  path=target_path,
218
226
  content=output,
219
227
  is_new=current_str is None,
228
+ auto_approved=template.auto_approved or False,
229
+ input=template_input,
220
230
  )
221
231
  return None
222
232
 
@@ -244,18 +254,17 @@ class TemplateRendererIntegration(QontractReconcileIntegration):
244
254
  ).hexdigest()
245
255
 
246
256
  with PersistenceTransaction(persistence, dry_run) as p:
257
+ input = TemplateInput(
258
+ collection=c.name,
259
+ collection_hash=template_hash,
260
+ enable_auto_approval=c.enable_auto_approval or False,
261
+ labels=c.additional_mr_labels or [],
262
+ )
247
263
  for template in c.templates:
248
264
  output = self.process_template(
249
- template,
250
- variables,
251
- p,
252
- ruamel_instance,
265
+ template, variables, p, ruamel_instance, input
253
266
  )
254
267
  if output:
255
- output.input = TemplateInput(
256
- collection=c.name,
257
- collection_hash=template_hash,
258
- )
259
268
  outputs.append(output)
260
269
 
261
270
  if not dry_run:
@@ -127,7 +127,11 @@ class TemplateValidatorIntegration(QontractReconcileIntegration):
127
127
  if diffs:
128
128
  for diff in diffs:
129
129
  logging.error(f"template: {diff.template}, test: {diff.test}")
130
- logging.debug(diff.diff)
130
+ # This log should never be added except for local debugging.
131
+ # Credentials could be leaked, i.e. creating an MR with a diff,
132
+ # using a template, that uses the vault function.
133
+ # Use template-validator CLI instead.
134
+ # logging.debug(diff.diff)
131
135
  raise ValueError("Template validation failed")
132
136
 
133
137
  @property
File without changes
@@ -0,0 +1,165 @@
1
+ import logging
2
+ from collections.abc import Callable
3
+ from datetime import datetime, timezone
4
+ from typing import Any
5
+
6
+ import jinja2
7
+
8
+ from reconcile.gql_definitions.terraform_init.aws_accounts import AWSAccountV1
9
+ from reconcile.gql_definitions.terraform_init.aws_accounts import (
10
+ query as aws_accounts_query,
11
+ )
12
+ from reconcile.terraform_init.merge_request import Renderer, create_parser
13
+ from reconcile.terraform_init.merge_request_manager import MergeRequestManager, MrData
14
+ from reconcile.typed_queries.app_interface_repo_url import get_app_interface_repo_url
15
+ from reconcile.typed_queries.github_orgs import get_github_orgs
16
+ from reconcile.typed_queries.gitlab_instances import get_gitlab_instances
17
+ from reconcile.utils import gql
18
+ from reconcile.utils.aws_api_typed.api import AWSApi, AWSStaticCredentials
19
+ from reconcile.utils.defer import defer
20
+ from reconcile.utils.disabled_integrations import integration_is_enabled
21
+ from reconcile.utils.runtime.integration import (
22
+ PydanticRunParams,
23
+ QontractReconcileIntegration,
24
+ )
25
+ from reconcile.utils.semver_helper import make_semver
26
+ from reconcile.utils.unleash import get_feature_toggle_state
27
+ from reconcile.utils.vcs import VCS
28
+
29
+ QONTRACT_INTEGRATION = "terraform-init"
30
+ QONTRACT_INTEGRATION_VERSION = make_semver(1, 0, 0)
31
+
32
+
33
+ class TerraformInitIntegrationParams(PydanticRunParams):
34
+ account_name: str | None
35
+ state_tmpl_resource: str = "/terraform-init/terraform-state.yml"
36
+ template_collection_root_path: str = "data/templating/collections/terraform-init"
37
+
38
+
39
+ class TerraformInitIntegration(
40
+ QontractReconcileIntegration[TerraformInitIntegrationParams]
41
+ ):
42
+ """Initialize AWS accounts for Terraform usage."""
43
+
44
+ @property
45
+ def name(self) -> str:
46
+ return QONTRACT_INTEGRATION
47
+
48
+ def get_early_exit_desired_state(
49
+ self, query_func: Callable | None = None
50
+ ) -> dict[str, Any]:
51
+ """Return the desired state for early exit."""
52
+ if not query_func:
53
+ query_func = gql.get_api().query
54
+ return {
55
+ "accounts": [
56
+ account.dict() for account in self.get_aws_accounts(query_func)
57
+ ],
58
+ }
59
+
60
+ def get_aws_accounts(
61
+ self, query_func: Callable, account_name: str | None = None
62
+ ) -> list[AWSAccountV1]:
63
+ """Return all AWS accounts with terraform username but no terraform state set."""
64
+ return [
65
+ account
66
+ for account in aws_accounts_query(query_func).accounts or []
67
+ if integration_is_enabled(self.name, account)
68
+ and (not account_name or account.name == account_name)
69
+ and account.terraform_username
70
+ and not account.terraform_state
71
+ ]
72
+
73
+ def render_state_collection(
74
+ self, template: str, bucket_name: str, account: AWSAccountV1
75
+ ) -> str:
76
+ return jinja2.Template(
77
+ template,
78
+ undefined=jinja2.StrictUndefined,
79
+ trim_blocks=True,
80
+ lstrip_blocks=True,
81
+ keep_trailing_newline=True,
82
+ ).render({
83
+ "account_name": account.name,
84
+ "bucket_name": bucket_name,
85
+ "region": account.resources_default_region,
86
+ "timestamp": int(datetime.now(tz=timezone.utc).timestamp()),
87
+ })
88
+
89
+ def reconcile_account(
90
+ self,
91
+ account_aws_api: AWSApi,
92
+ merge_request_manager: MergeRequestManager,
93
+ dry_run: bool,
94
+ state_collection: str,
95
+ bucket_name: str,
96
+ account: AWSAccountV1,
97
+ ) -> None:
98
+ logging.info("Creating bucket '%s' for account '%s'", bucket_name, account.name)
99
+ if not dry_run:
100
+ # the creation of the bucket is idempotent
101
+ account_aws_api.s3.create_bucket(
102
+ name=bucket_name, region=account.resources_default_region
103
+ )
104
+ merge_request_manager.create_merge_request(
105
+ data=MrData(
106
+ account=account.name,
107
+ content=state_collection,
108
+ path=f"{self.params.template_collection_root_path}/{account.name}.yml",
109
+ )
110
+ )
111
+
112
+ @defer
113
+ def run(self, dry_run: bool, defer: Callable | None = None) -> None:
114
+ """Run the integration."""
115
+ gql_api = gql.get_api()
116
+ accounts = self.get_aws_accounts(
117
+ gql_api.query, account_name=self.params.account_name
118
+ )
119
+ if not accounts:
120
+ # nothing to do
121
+ return
122
+
123
+ vcs = VCS(
124
+ secret_reader=self.secret_reader,
125
+ github_orgs=get_github_orgs(),
126
+ gitlab_instances=get_gitlab_instances(),
127
+ app_interface_repo_url=get_app_interface_repo_url(),
128
+ dry_run=dry_run,
129
+ allow_deleting_mrs=False,
130
+ allow_opening_mrs=True,
131
+ )
132
+ if defer:
133
+ defer(vcs.cleanup)
134
+ merge_request_manager = MergeRequestManager(
135
+ vcs=vcs,
136
+ renderer=Renderer(),
137
+ parser=create_parser(),
138
+ auto_merge_enabled=get_feature_toggle_state(
139
+ integration_name=f"{self.name}-allow-auto-merge-mrs", default=False
140
+ ),
141
+ )
142
+ state_template = gql_api.get_resource(path=self.params.state_tmpl_resource)[
143
+ "content"
144
+ ]
145
+ for account in accounts:
146
+ secret = self.secret_reader.read_all_secret(account.automation_token)
147
+ with AWSApi(
148
+ AWSStaticCredentials(
149
+ access_key_id=secret["aws_access_key_id"],
150
+ secret_access_key=secret["aws_secret_access_key"],
151
+ region=account.resources_default_region,
152
+ )
153
+ ) as account_aws_api:
154
+ bucket_name = f"terraform-{account.name}"
155
+ state_collection = self.render_state_collection(
156
+ state_template, bucket_name, account
157
+ )
158
+ self.reconcile_account(
159
+ account_aws_api,
160
+ merge_request_manager,
161
+ dry_run,
162
+ state_collection,
163
+ bucket_name,
164
+ account,
165
+ )
@@ -0,0 +1,57 @@
1
+ import re
2
+ import string
3
+
4
+ from pydantic import BaseModel
5
+
6
+ from reconcile.utils.merge_request_manager.parser import Parser
7
+
8
+ PROMOTION_DATA_SEPARATOR = "**DO NOT MANUALLY CHANGE ANYTHING BELOW THIS LINE**"
9
+ VERSION = "1.0.0"
10
+ LABEL = "terraform-init"
11
+
12
+ VERSION_REF = "tf_init_version"
13
+ ACCOUNT_REF = "account"
14
+ COMPILED_REGEXES = {
15
+ i: re.compile(rf".*{i}: (.*)$", re.MULTILINE) for i in [VERSION_REF, ACCOUNT_REF]
16
+ }
17
+
18
+ DESC = string.Template(
19
+ f"""
20
+ This MR is triggered by app-interface's [terraform-init](https://github.com/app-sre/qontract-reconcile/tree/master/reconcile/terraform_init).
21
+
22
+ Please **do not remove** the **{LABEL}** label from this MR!
23
+
24
+ Parts of this description are used by integration to manage the MR.
25
+
26
+ {PROMOTION_DATA_SEPARATOR}
27
+
28
+ * {VERSION_REF}: {VERSION}
29
+ * {ACCOUNT_REF}: $account
30
+ """
31
+ )
32
+
33
+
34
+ class Info(BaseModel):
35
+ account: str
36
+
37
+
38
+ def create_parser() -> Parser:
39
+ """Create a parser for MRs created by terraform-init."""
40
+
41
+ return Parser[Info](
42
+ klass=Info,
43
+ compiled_regexes=COMPILED_REGEXES,
44
+ version_ref=VERSION_REF,
45
+ expected_version=VERSION,
46
+ data_separator=PROMOTION_DATA_SEPARATOR,
47
+ )
48
+
49
+
50
+ class Renderer:
51
+ """This class is only concerned with rendering text for MRs."""
52
+
53
+ def render_description(self, account: str) -> str:
54
+ return DESC.safe_substitute(account=account)
55
+
56
+ def render_title(self, account: str) -> str:
57
+ return f"[auto] Terraform State settings for AWS account {account}"
@@ -0,0 +1,102 @@
1
+ import logging
2
+
3
+ from gitlab.exceptions import GitlabGetError
4
+ from pydantic import BaseModel
5
+
6
+ from reconcile.terraform_init.merge_request import (
7
+ LABEL,
8
+ Info,
9
+ Renderer,
10
+ )
11
+ from reconcile.utils.gitlab_api import GitLabApi
12
+ from reconcile.utils.merge_request_manager.merge_request_manager import (
13
+ MergeRequestManagerBase,
14
+ )
15
+ from reconcile.utils.merge_request_manager.parser import Parser
16
+ from reconcile.utils.mr.base import MergeRequestBase
17
+ from reconcile.utils.mr.labels import AUTO_MERGE
18
+ from reconcile.utils.vcs import VCS
19
+
20
+
21
+ class TerraformInitMR(MergeRequestBase):
22
+ name = "TerraformInit"
23
+
24
+ def __init__(
25
+ self, title: str, description: str, path: str, content: str, labels: list[str]
26
+ ):
27
+ super().__init__()
28
+ self._title = title
29
+ self._description = description
30
+ self._path = path
31
+ self._content = content
32
+ self.labels = labels
33
+
34
+ @property
35
+ def title(self) -> str:
36
+ return self._title
37
+
38
+ @property
39
+ def description(self) -> str:
40
+ return self._description
41
+
42
+ def process(self, gitlab_cli: GitLabApi) -> None:
43
+ gitlab_cli.create_file(
44
+ branch_name=self.branch,
45
+ file_path=self._path,
46
+ commit_message="add terraform state template collection",
47
+ content=self._content,
48
+ )
49
+
50
+
51
+ class MrData(BaseModel):
52
+ account: str
53
+ content: str
54
+ path: str
55
+
56
+
57
+ class MergeRequestManager(MergeRequestManagerBase[Info]):
58
+ """Manager for the merge requests.
59
+
60
+ This class is responsible for housekeeping (closing old/bad MRs) and
61
+ opening new MRs.
62
+ """
63
+
64
+ def __init__(
65
+ self, vcs: VCS, renderer: Renderer, parser: Parser, auto_merge_enabled: bool
66
+ ):
67
+ super().__init__(vcs, parser, LABEL)
68
+ self._renderer = renderer
69
+ self._auto_merge_enabled = auto_merge_enabled
70
+
71
+ def create_merge_request(self, data: MrData) -> None:
72
+ """Open a new MR, if not already present, for an AWS account and close any outdated before."""
73
+ if not self._housekeeping_ran:
74
+ self.housekeeping()
75
+
76
+ if self._merge_request_already_exists({"account": data.account}):
77
+ logging.info("MR already exists for %s", data.account)
78
+ return None
79
+
80
+ try:
81
+ self._vcs.get_file_content_from_app_interface_master(file_path=data.path)
82
+ # the file exists, nothing to do
83
+ return None
84
+ except GitlabGetError as e:
85
+ if e.response_code != 404:
86
+ raise
87
+
88
+ description = self._renderer.render_description(account=data.account)
89
+ title = self._renderer.render_title(account=data.account)
90
+ logging.info("Open MR for %s", data.account)
91
+ mr_labels = [LABEL]
92
+ if self._auto_merge_enabled:
93
+ mr_labels.append(AUTO_MERGE)
94
+ self._vcs.open_app_interface_merge_request(
95
+ mr=TerraformInitMR(
96
+ path=data.path,
97
+ title=title,
98
+ description=description,
99
+ content=data.content,
100
+ labels=mr_labels,
101
+ )
102
+ )
@@ -11,11 +11,13 @@ from pydantic import BaseModel
11
11
 
12
12
  import reconcile.utils.aws_api_typed.iam
13
13
  import reconcile.utils.aws_api_typed.organization
14
+ import reconcile.utils.aws_api_typed.s3
14
15
  import reconcile.utils.aws_api_typed.service_quotas
15
16
  import reconcile.utils.aws_api_typed.sts
16
17
  import reconcile.utils.aws_api_typed.support
17
18
  from reconcile.utils.aws_api_typed.iam import AWSApiIam
18
19
  from reconcile.utils.aws_api_typed.organization import AWSApiOrganizations
20
+ from reconcile.utils.aws_api_typed.s3 import AWSApiS3
19
21
  from reconcile.utils.aws_api_typed.service_quotas import AWSApiServiceQuotas
20
22
  from reconcile.utils.aws_api_typed.sts import AWSApiSts
21
23
  from reconcile.utils.aws_api_typed.support import AWSApiSupport
@@ -161,6 +163,9 @@ class AWSApi:
161
163
  case reconcile.utils.aws_api_typed.organization.AWSApiOrganizations:
162
164
  client = self.session.client("organizations")
163
165
  api = api_cls(client)
166
+ case reconcile.utils.aws_api_typed.s3.AWSApiS3:
167
+ client = self.session.client("s3")
168
+ api = api_cls(client)
164
169
  case reconcile.utils.aws_api_typed.service_quotas.AWSApiServiceQuotas:
165
170
  client = self.session.client("service-quotas")
166
171
  api = api_cls(client)
@@ -186,6 +191,11 @@ class AWSApi:
186
191
  """Return an AWS Organizations Api client."""
187
192
  return self._init_sub_api(AWSApiOrganizations)
188
193
 
194
+ @cached_property
195
+ def s3(self) -> AWSApiS3:
196
+ """Return an AWS S3 Api client."""
197
+ return self._init_sub_api(AWSApiS3)
198
+
189
199
  @cached_property
190
200
  def service_quotas(self) -> AWSApiServiceQuotas:
191
201
  """Return an AWS Service Quotas Api client."""
@@ -0,0 +1,26 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ if TYPE_CHECKING:
4
+ from mypy_boto3_s3 import S3Client
5
+ from mypy_boto3_s3.literals import BucketLocationConstraintType
6
+ else:
7
+ S3Client = BucketLocationConstraintType = object
8
+
9
+
10
+ class AWSApiS3:
11
+ def __init__(self, client: S3Client) -> None:
12
+ self.client = client
13
+
14
+ def create_bucket(self, name: str, region: str) -> str:
15
+ """Create an S3 bucket without any ACLs therefore the creator will be the owner and returns the ARN."""
16
+ bucket_kwargs = {}
17
+ if region != "us-east-1":
18
+ # you can't specify the location if it's us-east-1 :(
19
+ # see valid values "LocationConstraint" here: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateBucketConfiguration.html
20
+ bucket_kwargs = {
21
+ "CreateBucketConfiguration": {
22
+ "LocationConstraint": region,
23
+ },
24
+ }
25
+ self.client.create_bucket(Bucket=name, **bucket_kwargs) # type: ignore
26
+ return f"arn:aws:s3:::{name}"