qontract-reconcile 0.10.1rc58__py3-none-any.whl → 0.10.1rc60__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.1rc58
3
+ Version: 0.10.1rc60
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
@@ -7,7 +7,7 @@ reconcile/aws_iam_password_reset.py,sha256=NwErtrqgBiXr7eGCAHdtGGOx0S7-4JnSc29Ie
7
7
  reconcile/aws_support_cases_sos.py,sha256=i6bSWnlH9fh14P14PjVhFLwNl-q3fD733_rXKM_O51c,2992
8
8
  reconcile/blackbox_exporter_endpoint_monitoring.py,sha256=W_VJagnsJR1v5oqjlI3RJJE0_nhtJ0m81RS8zWA5u5c,3538
9
9
  reconcile/checkpoint.py,sha256=figtZRuWUvdpdSnkhAqeGvO5dI02TT6J3heyeFhlwqM,5016
10
- reconcile/cli.py,sha256=KVARhSmgO5IvCmlZwD4q6ZziFMtEPbXkc7O_qGa-hco,71195
10
+ reconcile/cli.py,sha256=c3tSmmo4ZwH23ve5iHDRA7zFLJvyG4DPw9G-LdWJkN0,71473
11
11
  reconcile/closedbox_endpoint_monitoring_base.py,sha256=0xg_d8dwd36Y8GY1mE-LLO1LQpPEMM77bzAfc_KdgzU,4870
12
12
  reconcile/cluster_deployment_mapper.py,sha256=2Ah-nu-Mdig0pjuiZl_XLrmVAjYzFjORR3dMlCgkmw0,2352
13
13
  reconcile/dashdotdb_base.py,sha256=Ca75-OQiu5HeA8Q6zQpEYuhyCSjeuWe99K4y9ipTORM,4032
@@ -94,7 +94,7 @@ reconcile/quay_mirror.py,sha256=9pwl1gLzRpsVXF5yPULM4ET_C5F8_xPmH8Mv8AS2AfI,1340
94
94
  reconcile/quay_mirror_org.py,sha256=E1OdRe-ppxTkNCwu20iVRhEdG1fPDBroLY02NgiMN7c,10381
95
95
  reconcile/quay_permissions.py,sha256=_3PCWjNWoU7VHlYgHzUevvL_jJmEMsWfXV_nzjeiyhU,4099
96
96
  reconcile/quay_repos.py,sha256=7609RBVQihis96FNOOe-i9tCTYwcTVy4WpKAL6HpnkU,7031
97
- reconcile/queries.py,sha256=rdmH3Pyh2KjX3ZMcYRMMSODO0AntdY7Iju0zdxd2QkI,49222
97
+ reconcile/queries.py,sha256=UMUfwqFmujdUJ9z1X_RN-Mktjag144An6jRgoZza57o,49432
98
98
  reconcile/query_validator.py,sha256=oLEZIAsQCzxmmZ7b9dSw-OKuEjpI1dbVu4XfCfjpmi8,1503
99
99
  reconcile/requests_sender.py,sha256=914iluuF4UVgG3VyxxtnHOu4yf6YKS2fIy6PViSsFTQ,3875
100
100
  reconcile/resource_scraper.py,sha256=vo1N9vLJCYWvXlTwFRIpEuWjx_39ZV9zxJlpoPq4g3U,2330
@@ -127,16 +127,18 @@ reconcile/aus/models.py,sha256=kRrs276iJClnNnEeWnJma2Gvx8E9kes77I_XbgKoVzk,4722
127
127
  reconcile/aus/ocm_addons_upgrade_scheduler_org.py,sha256=UjKFUeomkVpwVVkejPfNBMN7CQywIs5uOipUckfI8Y0,7659
128
128
  reconcile/aus/ocm_upgrade_scheduler.py,sha256=ndruHjyheMzjZgcIQQEG7qjLnhKL4pF_kIExxBrYZZM,5153
129
129
  reconcile/aus/ocm_upgrade_scheduler_org.py,sha256=tH0oQFJsO4UrEYspqiq-ne6-ULXA4NLY3g3hxaJKGEA,2867
130
+ reconcile/aws_ami_cleanup/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
131
+ reconcile/aws_ami_cleanup/integration.py,sha256=5-znzuQAGz9hcepBrVowzylqUQuAmVpAPRZKBl5QFZg,8752
130
132
  reconcile/change_owners/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
131
133
  reconcile/change_owners/approver.py,sha256=L7XJWJ-rgn8BOmeMb6lBDV8lHFCUaNoHGDSD7OH03vA,2244
132
134
  reconcile/change_owners/bundle.py,sha256=VTRXDMlcNYykqCPRIcCDTCA188Ouxglt7Sxeu1qep4E,5102
133
- reconcile/change_owners/change_owners.py,sha256=0JRRPaaCCfgNpxwVfWDYCCYoa0II6OSZuiO6jpVwsLM,14392
135
+ reconcile/change_owners/change_owners.py,sha256=WX-ekjI3IhdrJtvwieyeE32ho6cOsIOzFY3uzS25X5E,13268
134
136
  reconcile/change_owners/change_types.py,sha256=rWYmrw3rcE-lEZ-SQb3D38an5GhasaydpAtYpg5nPBU,28405
135
137
  reconcile/change_owners/changes.py,sha256=uGFukXgMkcO9xAXzF_LL0HLdNcQtSq0IyojsI1YIJTs,15889
136
138
  reconcile/change_owners/decision.py,sha256=uFuk06kzgifCg4ExaMRHEX6LIKnwaMkSQG8AeNG2uJo,4811
137
139
  reconcile/change_owners/diff.py,sha256=84pbx19C-QL1SgK16MxPqv3Ssiy1g8j0i978iSDb2kM,8840
138
140
  reconcile/change_owners/implicit_ownership.py,sha256=xga4X0OiZityQf5YRGLUSaIdr2Ij74H879-sjWzgTDk,4161
139
- reconcile/change_owners/self_service_roles.py,sha256=6xHBCaOeFjuFn5A3cBJZ77PdfkvOmYyHujuyohoEHb0,5173
141
+ reconcile/change_owners/self_service_roles.py,sha256=Ya5NPHCFpzMCDmxHrrjueXCYAap7jXzm0woudUEH6lc,6754
140
142
  reconcile/change_owners/tester.py,sha256=BfJM8dsurubnyDF9UR3oV2N3ClyjLsqDNq0Hy_UdjOk,8949
141
143
  reconcile/cna/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
142
144
  reconcile/cna/client.py,sha256=t9gJDrKf4ApBlgu8c4QUbmzrYoSo1QPsnAGfucva2_U,1562
@@ -155,6 +157,8 @@ reconcile/gql_definitions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJW
155
157
  reconcile/gql_definitions/advanced_upgrade_service/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
156
158
  reconcile/gql_definitions/advanced_upgrade_service/aus_clusters.py,sha256=eFo-836XT566MwEzlbb4W7eNdPmh_gUzDUUFwCzX7Kk,3731
157
159
  reconcile/gql_definitions/advanced_upgrade_service/aus_organization.py,sha256=rzxC3w1Bff3VSpI4zGN1wR5cSOmPxLYKBuwFGGwraPU,2872
160
+ reconcile/gql_definitions/aws_ami_cleanup/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
161
+ reconcile/gql_definitions/aws_ami_cleanup/asg_namespaces.py,sha256=hu0f4DQ5a6mP4L27DR9vbh01tuKYFS-vBE-PttOnFHE,3746
158
162
  reconcile/gql_definitions/change_owners/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
159
163
  reconcile/gql_definitions/change_owners/queries/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
160
164
  reconcile/gql_definitions/change_owners/queries/change_types.py,sha256=LViPCCECF0BQLk9RoB_4uUrkqQ3dRo73bVtxElqYLxQ,4993
@@ -297,6 +301,7 @@ reconcile/test/fixtures.py,sha256=VhvLXH0AWXEyu3FgPp7bcSTPmDPfMEa2v-_9cd8dCmw,57
297
301
  reconcile/test/test_aggregated_list.py,sha256=iiWitQuNYC58aimWaiBoE4NROHjr1NCgQ91MnHEG_Ro,6412
298
302
  reconcile/test/test_amtool.py,sha256=vxRhGieeydMBOb9UI2ziMHjJa8puMeGNsUhGhy-yMnk,1032
299
303
  reconcile/test/test_auto_promoter.py,sha256=4EtLLN0FAJGJnFoSCRsB5hHyY2-H3GMfnn5-p7kNCTg,10340
304
+ reconcile/test/test_aws_ami_cleanup.py,sha256=vHSldoVnZtfc1s_7KDh7GkzPTr7hF84ABj_vMNBR06s,8633
300
305
  reconcile/test/test_aws_ami_share.py,sha256=eSITdDoXs8mMY7P2lFxAX2DA0sJ9RW6D1tG8Rek0gLE,1981
301
306
  reconcile/test/test_aws_iam_keys.py,sha256=MfE9EvItyPNPAl5QaLlJFUvvrZFiar518TM2wWNjJn4,1829
302
307
  reconcile/test/test_aws_iam_password_reset.py,sha256=fnkqB90adR7W4L4saNdrtIiwnQB9bXgqJ9R1CKxjSnk,860
@@ -584,8 +589,8 @@ tools/sre_checkpoints/util.py,sha256=zEDbGr18ZeHNQwW8pUsr2JRjuXIPz--WAGJxZo9sv_Y
584
589
  tools/test/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
585
590
  tools/test/test_qontract_cli.py,sha256=awwTHEc2DWlykuqGIYM0WOBoSL0KRnOraCLk3C7izis,1401
586
591
  tools/test/test_sre_checkpoints.py,sha256=SKqPPTl9ua0RFdSSofnoQX-JZE6dFLO3LRhfQzqtfh8,2607
587
- qontract_reconcile-0.10.1rc58.dist-info/METADATA,sha256=oWz9xx5iFJYxcx-A0cQXP8M_NngpvZJY5z4m3EuADuA,2290
588
- qontract_reconcile-0.10.1rc58.dist-info/WHEEL,sha256=pkctZYzUS4AYVn6dJ-7367OJZivF2e8RA9b_ZBjif18,92
589
- qontract_reconcile-0.10.1rc58.dist-info/entry_points.txt,sha256=Af70EWPJxsTiCNF6gA-pWdw1A0Heqn-PZF-oBc5NmiU,302
590
- qontract_reconcile-0.10.1rc58.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
591
- qontract_reconcile-0.10.1rc58.dist-info/RECORD,,
592
+ qontract_reconcile-0.10.1rc60.dist-info/METADATA,sha256=JAgKlKekEmzyQTqdv9oMiVAsoeAyIaDysoW4U1WavCw,2290
593
+ qontract_reconcile-0.10.1rc60.dist-info/WHEEL,sha256=pkctZYzUS4AYVn6dJ-7367OJZivF2e8RA9b_ZBjif18,92
594
+ qontract_reconcile-0.10.1rc60.dist-info/entry_points.txt,sha256=Af70EWPJxsTiCNF6gA-pWdw1A0Heqn-PZF-oBc5NmiU,302
595
+ qontract_reconcile-0.10.1rc60.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
596
+ qontract_reconcile-0.10.1rc60.dist-info/RECORD,,
File without changes
@@ -0,0 +1,283 @@
1
+ import logging
2
+ import re
3
+ import sys
4
+ from collections.abc import (
5
+ Callable,
6
+ Mapping,
7
+ )
8
+ from datetime import (
9
+ datetime,
10
+ timedelta,
11
+ )
12
+ from typing import (
13
+ TYPE_CHECKING,
14
+ Any,
15
+ Optional,
16
+ )
17
+
18
+ from botocore.exceptions import ClientError
19
+ from pydantic import (
20
+ BaseModel,
21
+ Field,
22
+ )
23
+
24
+ from reconcile import queries
25
+ from reconcile.gql_definitions.aws_ami_cleanup.asg_namespaces import (
26
+ ASGImageGitV1,
27
+ ASGImageStaticV1,
28
+ NamespaceTerraformProviderResourceAWSV1,
29
+ NamespaceTerraformResourceASGV1,
30
+ NamespaceV1,
31
+ )
32
+ from reconcile.gql_definitions.aws_ami_cleanup.asg_namespaces import (
33
+ query as query_asg_namespaces,
34
+ )
35
+ from reconcile.status import ExitCodes
36
+ from reconcile.typed_queries.app_interface_vault_settings import (
37
+ get_app_interface_vault_settings,
38
+ )
39
+ from reconcile.utils import gql
40
+ from reconcile.utils.aws_api import AWSApi
41
+ from reconcile.utils.defer import defer
42
+ from reconcile.utils.parse_dhms_duration import dhms_to_seconds
43
+ from reconcile.utils.secret_reader import create_secret_reader
44
+ from reconcile.utils.terrascript_aws_client import TerrascriptClient as Terrascript
45
+
46
+ if TYPE_CHECKING:
47
+ from mypy_boto3_ec2 import EC2Client
48
+ else:
49
+ EC2Client = object
50
+
51
+ QONTRACT_INTEGRATION = "aws_ami_cleanup"
52
+ MANAGED_TAG = {"Key": "managed_by_integration", "Value": QONTRACT_INTEGRATION}
53
+
54
+
55
+ class CannotCompareTagsError(Exception):
56
+ pass
57
+
58
+
59
+ class AmiTag(BaseModel):
60
+ key: str = Field(alias="Key")
61
+ value: str = Field(alias="Value")
62
+
63
+ class Config:
64
+ allow_population_by_field_name = True
65
+ frozen = True
66
+
67
+
68
+ class AWSAmi(BaseModel):
69
+ name: str
70
+ image_id: str
71
+ tags: set[AmiTag]
72
+ creation_date: datetime
73
+
74
+ class Config:
75
+ frozen = True
76
+
77
+
78
+ class AIAmi(BaseModel):
79
+ identifier: str
80
+ tags: set[AmiTag]
81
+
82
+ class Config:
83
+ frozen = True
84
+
85
+
86
+ def get_aws_amis(
87
+ aws_api: AWSApi,
88
+ ec2_client: EC2Client,
89
+ owner: str,
90
+ regex: str,
91
+ age_in_seconds: int,
92
+ utc_now: datetime,
93
+ region: str,
94
+ ) -> list[AWSAmi]:
95
+ """Get amis that match regex older than given age"""
96
+
97
+ images = aws_api.paginate(
98
+ ec2_client, "describe_images", "Images", {"Owners": [owner]}
99
+ )
100
+
101
+ pattern = re.compile(regex)
102
+ results = []
103
+ for i in images:
104
+ if not re.search(pattern, i["Name"]):
105
+ continue
106
+
107
+ creation_date = datetime.strptime(i["CreationDate"], "%Y-%m-%dT%H:%M:%S.%fZ")
108
+ current_delta = utc_now - creation_date
109
+ delete_delta = timedelta(seconds=age_in_seconds)
110
+
111
+ if current_delta < delete_delta:
112
+ continue
113
+
114
+ # We have nothing to do with untagged AMIs since we will need tags to verify if AMI are
115
+ # in use or not.
116
+ if not i.get("Tags"):
117
+ continue
118
+
119
+ tags = {AmiTag(**tag) for tag in i.get("Tags")}
120
+ results.append(
121
+ AWSAmi(
122
+ name=i["Name"],
123
+ image_id=i["ImageId"],
124
+ tags=tags,
125
+ creation_date=creation_date,
126
+ )
127
+ )
128
+
129
+ return results
130
+
131
+
132
+ def get_region(
133
+ cleanup: Mapping[str, Any],
134
+ account: Mapping[str, Any],
135
+ ) -> str:
136
+ """Defines the region to search for AMIs."""
137
+ region = cleanup.get("region") or account["resourcesDefaultRegion"]
138
+ if region not in account["supportedDeploymentRegions"]:
139
+ raise ValueError(f"region {region} is not supported in {account['name']}")
140
+
141
+ return region
142
+
143
+
144
+ def get_app_interface_amis(
145
+ namespaces: Optional[list[NamespaceV1]], ts: Terrascript
146
+ ) -> list[AIAmi]:
147
+ """Returns all the ami referenced in ASGs in app-interface."""
148
+ app_interface_amis = []
149
+ for n in namespaces or []:
150
+ for er in n.external_resources or []:
151
+ if not isinstance(er, NamespaceTerraformProviderResourceAWSV1):
152
+ continue
153
+
154
+ for r in er.resources:
155
+ if not isinstance(r, NamespaceTerraformResourceASGV1):
156
+ continue
157
+
158
+ tags = set()
159
+ for i in r.image:
160
+ if isinstance(i, ASGImageGitV1):
161
+ tags.add(
162
+ AmiTag(
163
+ key=i.tag_name,
164
+ value=ts.get_commit_sha(i.dict(by_alias=True)),
165
+ )
166
+ )
167
+ elif isinstance(i, ASGImageStaticV1):
168
+ tags.add(AmiTag(key=i.tag_name, value=i.value))
169
+
170
+ app_interface_amis.append(AIAmi(identifier=r.identifier, tags=tags))
171
+
172
+ return app_interface_amis
173
+
174
+
175
+ def check_aws_ami_in_use(
176
+ aws_ami: AWSAmi, app_interface_amis: list[AIAmi]
177
+ ) -> Optional[str]:
178
+ """Verifies if the given AWS ami is in use in a defined app-interface ASG."""
179
+ for ai_ami in app_interface_amis:
180
+ # This can happen if the ASG init template has changed over the time. We don't have a way
181
+ # to properly delete these automatically since we cannot assure they are not in use.
182
+ # The integration will fail in this case and these amis will need to be handled manually.
183
+ if len(ai_ami.tags) > len(aws_ami.tags):
184
+ raise CannotCompareTagsError(
185
+ f"{ai_ami.identifier} AI AMI has more tags than {aws_ami.image_id} AWS AMI"
186
+ )
187
+
188
+ if ai_ami.tags.issubset(aws_ami.tags):
189
+ return ai_ami.identifier
190
+
191
+ return None
192
+
193
+
194
+ @defer
195
+ def run(dry_run: bool, thread_pool_size: int, defer: Optional[Callable] = None) -> None:
196
+ exit_code = ExitCodes.SUCCESS
197
+
198
+ # We still use here a non-typed query; accounts is passed to AWSApi and Terrascript classes
199
+ # which contain a vast amount of magic based on keys from that dict. Since this integration
200
+ # cannot still be properly monitored (see https://issues.redhat.com/browse/APPSRE-7674),
201
+ # it's easy that it breaks without being noticed. Once it is properly monitored, this should
202
+ # be moved to a typed query.
203
+ cleanup_accounts = [
204
+ a
205
+ for a in queries.get_aws_accounts(terraform_state=True, cleanup=True)
206
+ if a.get("cleanup")
207
+ ]
208
+
209
+ vault_settings = get_app_interface_vault_settings()
210
+
211
+ ts = Terrascript(
212
+ QONTRACT_INTEGRATION,
213
+ "",
214
+ thread_pool_size,
215
+ cleanup_accounts,
216
+ settings=vault_settings.dict(by_alias=True),
217
+ )
218
+
219
+ gqlapi = gql.get_api()
220
+ namespaces = query_asg_namespaces(query_func=gqlapi.query).namespaces or []
221
+ app_interface_amis = get_app_interface_amis(namespaces, ts)
222
+
223
+ secret_reader = create_secret_reader(use_vault=vault_settings.vault)
224
+ aws_api = AWSApi(1, cleanup_accounts, secret_reader=secret_reader, init_users=False)
225
+ if defer: # defer is provided by the method decorator; this makes just mypy happy.
226
+ defer(aws_api.cleanup)
227
+
228
+ utc_now = datetime.utcnow()
229
+ for account in cleanup_accounts:
230
+ for cleanup in account["cleanup"]:
231
+ if cleanup["provider"] != "ami":
232
+ continue
233
+
234
+ region = get_region(cleanup, account)
235
+ regex = cleanup["regex"]
236
+ age_in_seconds = dhms_to_seconds(cleanup["age"])
237
+
238
+ session = aws_api.get_session(account["name"])
239
+ ec2_client = aws_api.get_session_client(session, "ec2", region)
240
+
241
+ amis = get_aws_amis(
242
+ aws_api=aws_api,
243
+ ec2_client=ec2_client,
244
+ owner=account["uid"],
245
+ regex=regex,
246
+ age_in_seconds=age_in_seconds,
247
+ utc_now=utc_now,
248
+ region=region,
249
+ )
250
+
251
+ for aws_ami in amis:
252
+ try:
253
+ if identifier := check_aws_ami_in_use(aws_ami, app_interface_amis):
254
+ logging.info(
255
+ "Discarding ami %s with id %s as it is used in app-interface in %s",
256
+ aws_ami.name,
257
+ aws_ami.image_id,
258
+ identifier,
259
+ )
260
+ continue
261
+ except CannotCompareTagsError as e:
262
+ exit_code = ExitCodes.ERROR
263
+ logging.error(e)
264
+ continue
265
+
266
+ logging.info(
267
+ "Deregistering image %s with id %s created in %s",
268
+ aws_ami.name,
269
+ aws_ami.image_id,
270
+ aws_ami.creation_date,
271
+ )
272
+
273
+ try:
274
+ ec2_client.deregister_image(
275
+ ImageId=aws_ami.image_id, DryRun=dry_run
276
+ )
277
+ except ClientError as e:
278
+ if "DryRunOperation" in str(e):
279
+ logging.info(e)
280
+ continue
281
+ raise
282
+
283
+ sys.exit(exit_code)
@@ -29,12 +29,9 @@ from reconcile.change_owners.implicit_ownership import (
29
29
  )
30
30
  from reconcile.change_owners.self_service_roles import (
31
31
  cover_changes_with_self_service_roles,
32
+ fetch_self_service_roles,
32
33
  )
33
- from reconcile.gql_definitions.change_owners.queries import (
34
- change_types,
35
- self_service_roles,
36
- )
37
- from reconcile.gql_definitions.change_owners.queries.self_service_roles import RoleV1
34
+ from reconcile.gql_definitions.change_owners.queries import change_types
38
35
  from reconcile.utils import gql
39
36
  from reconcile.utils.gitlab_api import GitLabApi
40
37
  from reconcile.utils.mr.labels import (
@@ -78,33 +75,6 @@ def cover_changes(
78
75
  )
79
76
 
80
77
 
81
- def validate_self_service_role(role: RoleV1) -> None:
82
- for ssc in role.self_service or []:
83
- if ssc.change_type.context_schema:
84
- # check that all referenced datafiles have a schema that
85
- # is compatible with the change-type
86
- incompatible_datafiles = [
87
- df.path
88
- for df in ssc.datafiles or []
89
- if df.datafile_schema != ssc.change_type.context_schema
90
- ]
91
- if incompatible_datafiles:
92
- raise ValueError(
93
- f"The datafiles {incompatible_datafiles} are not compatible with the "
94
- f"{ssc.change_type.name} change-types contextSchema {ssc.change_type.context_schema}"
95
- )
96
-
97
-
98
- def fetch_self_service_roles(gql_api: gql.GqlApi) -> list[RoleV1]:
99
- roles: list[RoleV1] = []
100
- for r in self_service_roles.query(gql_api.query).roles or []:
101
- if not r.self_service:
102
- continue
103
- validate_self_service_role(r)
104
- roles.append(r)
105
- return roles
106
-
107
-
108
78
  def fetch_change_type_processors(
109
79
  gql_api: gql.GqlApi, file_diff_resolver: FileDiffResolver
110
80
  ) -> list[ChangeTypeProcessor]:
@@ -12,15 +12,62 @@ from reconcile.change_owners.change_types import (
12
12
  ChangeTypeProcessor,
13
13
  )
14
14
  from reconcile.change_owners.changes import BundleFileChange
15
+ from reconcile.gql_definitions.change_owners.queries import self_service_roles
15
16
  from reconcile.gql_definitions.change_owners.queries.self_service_roles import (
16
17
  PermissionGitlabGroupMembershipV1,
17
18
  PermissionSlackUsergroupV1,
18
19
  RoleV1,
19
20
  )
21
+ from reconcile.utils import gql
20
22
 
21
23
 
22
- class EmptySelfServiceRoleError(Exception):
23
- pass
24
+ class NoApproversInSelfServiceRoleError(Exception):
25
+ """
26
+ Thrown when a self-service role has no approvers
27
+ """
28
+
29
+
30
+ class DatafileIncompatibleWithChangeTypeError(Exception):
31
+ """
32
+ Thrown when a datafile and a change type are hooked up
33
+ in a self-service role, but are not compatible schema wise.
34
+ """
35
+
36
+
37
+ def fetch_self_service_roles(gql_api: gql.GqlApi) -> list[RoleV1]:
38
+ roles: list[RoleV1] = []
39
+ for r in self_service_roles.query(gql_api.query).roles or []:
40
+ if not r.self_service:
41
+ continue
42
+ validate_self_service_role(r)
43
+ roles.append(r)
44
+ return roles
45
+
46
+
47
+ def validate_self_service_role(role: RoleV1) -> None:
48
+ """
49
+ Validate that a self-service role has approvers and that the referenced
50
+ change-types and datafiles/resources are compatible.
51
+ """
52
+ if not role.users and not role.bots:
53
+ raise NoApproversInSelfServiceRoleError(
54
+ f"The role {role.name} has no users or bots "
55
+ "to drive the self-service process. Add approvers to the roles."
56
+ )
57
+ for ssc in role.self_service or []:
58
+ if ssc.change_type.context_schema:
59
+ # check that all referenced datafiles have a schema that
60
+ # is compatible with the change-type
61
+ incompatible_datafiles = [
62
+ df.path
63
+ for df in ssc.datafiles or []
64
+ if df.datafile_schema != ssc.change_type.context_schema
65
+ ]
66
+ if incompatible_datafiles:
67
+ raise DatafileIncompatibleWithChangeTypeError(
68
+ f"The datafiles {incompatible_datafiles} are not compatible with the "
69
+ f"{ssc.change_type.name} change-types contextSchema {ssc.change_type.context_schema}"
70
+ )
24
71
 
25
72
 
26
73
  def cover_changes_with_self_service_roles(
@@ -66,11 +113,6 @@ def change_type_contexts_for_self_service_roles(
66
113
  role_lookup[
67
114
  (BundleFileType.RESOURCEFILE, res, ss.change_type.name)
68
115
  ].append(r)
69
- if orphaned_roles:
70
- raise EmptySelfServiceRoleError(
71
- f"The roles {', '.join([r.name for r in orphaned_roles])} have no users or bots "
72
- "to drive the self-service process. Add approvers to the roles."
73
- )
74
116
 
75
117
  # match every BundleChange with every relevant ChangeTypeV1
76
118
  change_type_contexts = []
reconcile/cli.py CHANGED
@@ -966,6 +966,15 @@ def aws_ami_share(ctx):
966
966
  run_integration(reconcile.aws_ami_share, ctx.obj)
967
967
 
968
968
 
969
+ @integration.command(short_help="Cleanup old and unused AMIs.")
970
+ @threaded()
971
+ @click.pass_context
972
+ def aws_ami_cleanup(ctx, thread_pool_size):
973
+ import reconcile.aws_ami_cleanup.integration
974
+
975
+ run_integration(reconcile.aws_ami_cleanup.integration, ctx.obj, thread_pool_size)
976
+
977
+
969
978
  @integration.command(
970
979
  short_help="Generate AWS ECR image pull secrets and store them in Vault."
971
980
  )
File without changes
@@ -0,0 +1,132 @@
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
+
21
+ DEFINITION = """
22
+ query ASGNamespaces {
23
+ namespaces: namespaces_v1 {
24
+ name
25
+ externalResources {
26
+ provider
27
+ provisioner {
28
+ name
29
+ }
30
+ ... on NamespaceTerraformProviderResourceAWS_v1 {
31
+ resources {
32
+ provider
33
+ ... on NamespaceTerraformResourceASG_v1 {
34
+ identifier
35
+ image {
36
+ provider
37
+ ... on ASGImageGit_v1 {
38
+ tag_name
39
+ url
40
+ ref
41
+ }
42
+ ... on ASGImageStatic_v1 {
43
+ tag_name
44
+ value
45
+ }
46
+ }
47
+ }
48
+ }
49
+ }
50
+ }
51
+ }
52
+ }
53
+ """
54
+
55
+
56
+ class ConfiguredBaseModel(BaseModel):
57
+ class Config:
58
+ smart_union = True
59
+ extra = Extra.forbid
60
+
61
+
62
+ class ExternalResourcesProvisionerV1(ConfiguredBaseModel):
63
+ name: str = Field(..., alias="name")
64
+
65
+
66
+ class NamespaceExternalResourceV1(ConfiguredBaseModel):
67
+ provider: str = Field(..., alias="provider")
68
+ provisioner: ExternalResourcesProvisionerV1 = Field(..., alias="provisioner")
69
+
70
+
71
+ class NamespaceTerraformResourceAWSV1(ConfiguredBaseModel):
72
+ provider: str = Field(..., alias="provider")
73
+
74
+
75
+ class ASGImageV1(ConfiguredBaseModel):
76
+ provider: str = Field(..., alias="provider")
77
+
78
+
79
+ class ASGImageGitV1(ASGImageV1):
80
+ tag_name: str = Field(..., alias="tag_name")
81
+ url: str = Field(..., alias="url")
82
+ ref: str = Field(..., alias="ref")
83
+
84
+
85
+ class ASGImageStaticV1(ASGImageV1):
86
+ tag_name: str = Field(..., alias="tag_name")
87
+ value: str = Field(..., alias="value")
88
+
89
+
90
+ class NamespaceTerraformResourceASGV1(NamespaceTerraformResourceAWSV1):
91
+ identifier: str = Field(..., alias="identifier")
92
+ image: list[Union[ASGImageGitV1, ASGImageStaticV1, ASGImageV1]] = Field(
93
+ ..., alias="image"
94
+ )
95
+
96
+
97
+ class NamespaceTerraformProviderResourceAWSV1(NamespaceExternalResourceV1):
98
+ resources: list[
99
+ Union[NamespaceTerraformResourceASGV1, NamespaceTerraformResourceAWSV1]
100
+ ] = Field(..., alias="resources")
101
+
102
+
103
+ class NamespaceV1(ConfiguredBaseModel):
104
+ name: str = Field(..., alias="name")
105
+ external_resources: Optional[
106
+ list[
107
+ Union[NamespaceTerraformProviderResourceAWSV1, NamespaceExternalResourceV1]
108
+ ]
109
+ ] = Field(..., alias="externalResources")
110
+
111
+
112
+ class ASGNamespacesQueryData(ConfiguredBaseModel):
113
+ namespaces: Optional[list[NamespaceV1]] = Field(..., alias="namespaces")
114
+
115
+
116
+ def query(query_func: Callable, **kwargs: Any) -> ASGNamespacesQueryData:
117
+ """
118
+ This is a convenience function which queries and parses the data into
119
+ concrete types. It should be compatible with most GQL clients.
120
+ You do not have to use it to consume the generated data classes.
121
+ Alternatively, you can also mime and alternate the behavior
122
+ of this function in the caller.
123
+
124
+ Parameters:
125
+ query_func (Callable): Function which queries your GQL Server
126
+ kwargs: optional arguments that will be passed to the query function
127
+
128
+ Returns:
129
+ ASGNamespacesQueryData: queried data parsed into generated classes
130
+ """
131
+ raw_data: dict[Any, Any] = query_func(DEFINITION, **kwargs)
132
+ return ASGNamespacesQueryData(**raw_data)
reconcile/queries.py CHANGED
@@ -535,6 +535,16 @@ AWS_ACCOUNTS_QUERY = """
535
535
  }
536
536
  }
537
537
  {% endif %}
538
+ {% if cleanup %}
539
+ cleanup {
540
+ provider
541
+ ... on AWSAccountCleanupOptionAMI_v1 {
542
+ regex
543
+ age
544
+ region
545
+ }
546
+ }
547
+ {% endif %}
538
548
  }
539
549
  }
540
550
  """
@@ -547,6 +557,7 @@ def get_aws_accounts(
547
557
  sharing=False,
548
558
  terraform_state=False,
549
559
  ecrs=True,
560
+ cleanup=False,
550
561
  ):
551
562
  """Returns all AWS accounts"""
552
563
  gqlapi = gql.get_api()
@@ -559,6 +570,7 @@ def get_aws_accounts(
559
570
  sharing=sharing,
560
571
  terraform_state=terraform_state,
561
572
  ecrs=ecrs,
573
+ cleanup=cleanup,
562
574
  )
563
575
  return gqlapi.query(query)["accounts"]
564
576
 
@@ -0,0 +1,296 @@
1
+ # pylint does not consider frozen BaseModels as hashable and then complains that they cannot
2
+ # be members of a set.
3
+ # pylint: disable=unhashable-member
4
+
5
+ from collections.abc import Generator
6
+ from datetime import (
7
+ datetime,
8
+ timedelta,
9
+ )
10
+ from typing import (
11
+ TYPE_CHECKING,
12
+ Any,
13
+ )
14
+ from unittest.mock import MagicMock
15
+
16
+ import boto3
17
+ import pytest
18
+ from moto import mock_ec2
19
+ from pytest_mock import MockerFixture
20
+
21
+ from reconcile.aws_ami_cleanup.integration import (
22
+ AIAmi,
23
+ AmiTag,
24
+ AWSAmi,
25
+ CannotCompareTagsError,
26
+ check_aws_ami_in_use,
27
+ get_app_interface_amis,
28
+ get_aws_amis,
29
+ )
30
+ from reconcile.gql_definitions.aws_ami_cleanup.asg_namespaces import (
31
+ ASGNamespacesQueryData,
32
+ )
33
+ from reconcile.test.fixtures import Fixtures
34
+ from reconcile.utils.aws_api import AWSApi
35
+ from reconcile.utils.terrascript_aws_client import TerrascriptClient as Terrascript
36
+
37
+ if TYPE_CHECKING:
38
+ from mypy_boto3_ec2 import EC2Client
39
+ from mypy_boto3_ec2.type_defs import CreateImageResultTypeDef
40
+ else:
41
+ EC2Client = object
42
+ CreateImageResultTypeDef = dict
43
+
44
+ MOTO_DEFAULT_ACCOUNT = "123456789012"
45
+
46
+
47
+ @pytest.fixture
48
+ def accounts() -> list[dict[str, Any]]:
49
+ return [
50
+ {
51
+ "name": "some-account",
52
+ "automationToken": {
53
+ "path": "path",
54
+ },
55
+ "resourcesDefaultRegion": "default-region",
56
+ }
57
+ ]
58
+
59
+
60
+ @pytest.fixture
61
+ def aws_api(accounts: list[dict[str, Any]], mocker: MockerFixture) -> AWSApi:
62
+ mock_secret_reader = mocker.patch(
63
+ "reconcile.utils.aws_api.SecretReader", autospec=True
64
+ )
65
+ mock_secret_reader.return_value.read_all.return_value = {
66
+ "aws_access_key_id": "key_id",
67
+ "aws_secret_access_key": "access_key",
68
+ "region": "tf_state_bucket_region",
69
+ }
70
+ return AWSApi(1, accounts, init_users=False)
71
+
72
+
73
+ @pytest.fixture
74
+ def ec2_client() -> Generator[EC2Client, None, None]:
75
+ with mock_ec2():
76
+ yield boto3.client("ec2", region_name="us-east-1")
77
+
78
+
79
+ @pytest.fixture
80
+ def rhel_image(ec2_client: EC2Client) -> CreateImageResultTypeDef:
81
+ # RHEL7 ami from moto/ec2/resources/amis.json
82
+ reservation = ec2_client.run_instances(
83
+ ImageId="ami-bb9a6bc2", MinCount=1, MaxCount=1
84
+ )
85
+ instance_id = reservation["Instances"][0]["InstanceId"]
86
+
87
+ return ec2_client.create_image(
88
+ InstanceId=instance_id,
89
+ Name="ci-int-jenkins-worker-rhel7-sha-123456",
90
+ TagSpecifications=[
91
+ {
92
+ "ResourceType": "image",
93
+ "Tags": [
94
+ {"Key": "infra_commit", "Value": "sha-123456"},
95
+ {"Key": "type", "Value": "ci-int-jenkins-worker-rhel7"},
96
+ ],
97
+ },
98
+ ],
99
+ )
100
+
101
+
102
+ @pytest.fixture
103
+ def suse_image(ec2_client: EC2Client) -> CreateImageResultTypeDef:
104
+ # SUSE AMI from moto/ec2/resources/amis.json
105
+ reservation = ec2_client.run_instances(
106
+ ImageId="ami-35e92e4c", MinCount=1, MaxCount=1
107
+ )
108
+ instance_id = reservation["Instances"][0]["InstanceId"]
109
+
110
+ return ec2_client.create_image(
111
+ InstanceId=instance_id,
112
+ Name="ci-int-jenkins-worker-suse12-sha-789012",
113
+ TagSpecifications=[
114
+ {
115
+ "ResourceType": "image",
116
+ "Tags": [
117
+ {"Key": "infra_commit", "Value": "sha-789012"},
118
+ {"Key": "arch", "Value": "ci-int-jenkins-worker-suse12"},
119
+ ],
120
+ },
121
+ ],
122
+ )
123
+
124
+
125
+ @pytest.fixture
126
+ def ai_amis_fxt() -> list[AIAmi]:
127
+ return [
128
+ AIAmi(
129
+ identifier="ci-int-jenkins-worker-app-sre",
130
+ tags={
131
+ AmiTag(key="type", value="ci-int-jenkins-worker-app-sre"),
132
+ AmiTag(
133
+ key="infra_commit",
134
+ value="sha-0123",
135
+ ),
136
+ },
137
+ ),
138
+ AIAmi(
139
+ identifier="ci-int-jenkins-worker-app-interface",
140
+ tags={
141
+ AmiTag(key="type", value="ci-int-jenkins-worker-app-interface"),
142
+ AmiTag(
143
+ key="infra_commit",
144
+ value="sha-4567",
145
+ ),
146
+ },
147
+ ),
148
+ ]
149
+
150
+
151
+ def test_get_aws_amis_success(
152
+ ec2_client: EC2Client,
153
+ aws_api: AWSApi,
154
+ rhel_image: CreateImageResultTypeDef,
155
+ suse_image: CreateImageResultTypeDef,
156
+ ) -> None:
157
+ utc_now = datetime.utcnow() + timedelta(seconds=60)
158
+ amis = get_aws_amis(
159
+ aws_api=aws_api,
160
+ ec2_client=ec2_client,
161
+ owner=MOTO_DEFAULT_ACCOUNT,
162
+ regex="ci-int-jenkins-worker-rhel7.*",
163
+ age_in_seconds=30,
164
+ utc_now=utc_now,
165
+ region="us-east-1",
166
+ )
167
+
168
+ assert len(amis) == 1
169
+ assert amis[0].image_id == rhel_image["ImageId"]
170
+
171
+
172
+ def test_get_aws_amis_unmatched_regex(
173
+ ec2_client: EC2Client,
174
+ aws_api: AWSApi,
175
+ rhel_image: CreateImageResultTypeDef,
176
+ suse_image: CreateImageResultTypeDef,
177
+ ) -> None:
178
+ utc_now = datetime.utcnow() + timedelta(seconds=60)
179
+ amis = get_aws_amis(
180
+ aws_api=aws_api,
181
+ ec2_client=ec2_client,
182
+ owner=MOTO_DEFAULT_ACCOUNT,
183
+ regex="ci-int-jenkins-worker-centos7.*",
184
+ age_in_seconds=30,
185
+ utc_now=utc_now,
186
+ region="us-east-1",
187
+ )
188
+
189
+ assert len(amis) == 0
190
+
191
+
192
+ def test_get_aws_amis_different_account(
193
+ ec2_client: EC2Client,
194
+ aws_api: AWSApi,
195
+ rhel_image: CreateImageResultTypeDef,
196
+ suse_image: CreateImageResultTypeDef,
197
+ ) -> None:
198
+ utc_now = datetime.utcnow() + timedelta(seconds=60)
199
+ amis = get_aws_amis(
200
+ aws_api=aws_api,
201
+ ec2_client=ec2_client,
202
+ owner="789123456789",
203
+ regex="ci-int-jenkins-worker-rhel7.*",
204
+ age_in_seconds=30,
205
+ utc_now=utc_now,
206
+ region="us-east-1",
207
+ )
208
+
209
+ assert len(amis) == 0
210
+
211
+
212
+ def test_get_aws_amis_too_young(
213
+ ec2_client: EC2Client,
214
+ aws_api: AWSApi,
215
+ rhel_image: CreateImageResultTypeDef,
216
+ suse_image: CreateImageResultTypeDef,
217
+ ) -> None:
218
+ utc_now = datetime.utcnow() + timedelta(seconds=60)
219
+ amis = get_aws_amis(
220
+ aws_api=aws_api,
221
+ ec2_client=ec2_client,
222
+ owner=MOTO_DEFAULT_ACCOUNT,
223
+ regex="ci-int-jenkins-worker-rhel7.*",
224
+ age_in_seconds=90,
225
+ utc_now=utc_now,
226
+ region="us-east-1",
227
+ )
228
+
229
+ assert len(amis) == 0
230
+
231
+
232
+ def test_get_app_interface_amis(ai_amis_fxt: list[AIAmi]) -> None:
233
+ fixture = Fixtures("aws_ami_cleanup").get_anymarkup("namespaces.yaml")
234
+ namespaces = ASGNamespacesQueryData(**fixture).namespaces
235
+ ts = MagicMock(spec=Terrascript)
236
+ ts.get_commit_sha.side_effect = ["sha-0123", "sha-4567"]
237
+
238
+ app_interface_amis = get_app_interface_amis(namespaces, ts)
239
+ assert app_interface_amis[0].identifier == ai_amis_fxt[0].identifier
240
+ assert app_interface_amis[0].tags == ai_amis_fxt[0].tags
241
+ assert app_interface_amis[1].identifier == ai_amis_fxt[1].identifier
242
+ assert app_interface_amis[1].tags == ai_amis_fxt[1].tags
243
+
244
+
245
+ def test_check_aws_ami_in_use(ai_amis_fxt: list[AIAmi]) -> None:
246
+ utc_now = datetime.utcnow()
247
+ aws_ami = AWSAmi(
248
+ name="ci-int-jenkins-worker-app-sre-sha-0123",
249
+ image_id="ami-123456",
250
+ creation_date=utc_now,
251
+ tags={
252
+ AmiTag(key="infra_commit", value="sha-0123"),
253
+ AmiTag(key="type", value="ci-int-jenkins-worker-app-sre"),
254
+ },
255
+ )
256
+ assert check_aws_ami_in_use(aws_ami, ai_amis_fxt) == "ci-int-jenkins-worker-app-sre"
257
+
258
+ aws_ami = AWSAmi(
259
+ name="ci-int-jenkins-worker-app-interface-sha-4567",
260
+ image_id="ami-823445",
261
+ creation_date=utc_now,
262
+ tags={
263
+ AmiTag(key="type", value="ci-int-jenkins-worker-app-interface"),
264
+ AmiTag(key="infra_commit", value="sha-4567"),
265
+ },
266
+ )
267
+ assert (
268
+ check_aws_ami_in_use(aws_ami, ai_amis_fxt)
269
+ == "ci-int-jenkins-worker-app-interface"
270
+ )
271
+
272
+ aws_ami = AWSAmi(
273
+ name="ci-int-jenkins-worker-app-interface-a-different-sha",
274
+ image_id="ami-823445",
275
+ creation_date=utc_now,
276
+ tags={
277
+ AmiTag(key="type", value="ci-int-jenkins-worker-app-interface"),
278
+ AmiTag(key="infra_commit", value="a-different-sha"),
279
+ },
280
+ )
281
+
282
+ assert not check_aws_ami_in_use(aws_ami, ai_amis_fxt)
283
+
284
+ aws_ami = AWSAmi(
285
+ name="ci-int-jenkins-worker-app-interface-a-weird-one",
286
+ image_id="ami-823445",
287
+ creation_date=utc_now,
288
+ tags={
289
+ AmiTag(key="type", value="ci-int-jenkins-worker-app-interface"),
290
+ },
291
+ )
292
+
293
+ with pytest.raises(CannotCompareTagsError) as excinfo:
294
+ check_aws_ami_in_use(aws_ami, ai_amis_fxt)
295
+
296
+ assert "AI AMI has more tags than" in str(excinfo.value)