qontract-reconcile 0.10.2.dev160__py3-none-any.whl → 0.10.2.dev162__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.4
2
2
  Name: qontract-reconcile
3
- Version: 0.10.2.dev160
3
+ Version: 0.10.2.dev162
4
4
  Summary: Collection of tools to reconcile services with their desired state as defined in the app-interface DB.
5
5
  Project-URL: homepage, https://github.com/app-sre/qontract-reconcile
6
6
  Project-URL: repository, https://github.com/app-sre/qontract-reconcile
@@ -20,7 +20,7 @@ Requires-Dist: croniter<1.1.0,>=1.0.15
20
20
  Requires-Dist: dateparser~=1.1.7
21
21
  Requires-Dist: deepdiff==6.7.1
22
22
  Requires-Dist: dnspython~=2.1
23
- Requires-Dist: dt==1.1.61
23
+ Requires-Dist: dt==1.1.73
24
24
  Requires-Dist: filetype~=1.2.0
25
25
  Requires-Dist: gql==3.1.0
26
26
  Requires-Dist: hvac<0.8.0,>=0.7.0
@@ -10,7 +10,7 @@ reconcile/aws_iam_password_reset.py,sha256=O0JX2N5kNRKs3u2xzu4NNrI6p0ag5JWy3MTsv
10
10
  reconcile/aws_support_cases_sos.py,sha256=PDhilxQ4TBxVnxUPIUdTbKEaNUI0wzPiEsB91oHT2fY,3384
11
11
  reconcile/blackbox_exporter_endpoint_monitoring.py,sha256=O1wFp52EyF538c6txaWBs8eMtUIy19gyHZ6VzJ6QXS8,3512
12
12
  reconcile/checkpoint.py,sha256=_JhMxrye5BgkRMxWYuf7Upli6XayPINKSsuo3ynHTRc,5010
13
- reconcile/cli.py,sha256=hwqPcZVmazrhzq1esPBk5jNHSzpfr7o9EmuuMqiPxfg,108430
13
+ reconcile/cli.py,sha256=xyVnxNyq3IPISWwFlB9j4HAFjowXYv3EdsEGIMFhTy0,108438
14
14
  reconcile/closedbox_endpoint_monitoring_base.py,sha256=al7m8EgnnYx90rY1REryW3byN_ItfJfAzEeLtjbCfi0,4921
15
15
  reconcile/cluster_deployment_mapper.py,sha256=5gumAaRCcFXsabUJ1dnuUy9WrP_FEEM5JnOnE8ch9sE,2326
16
16
  reconcile/dashdotdb_base.py,sha256=83ZWIf5JJk3P_D69y2TmXRcQr6ELJGlv10OM0h7fJVs,4767
@@ -22,7 +22,7 @@ reconcile/database_access_manager.py,sha256=Z3aAmw2LsmMIIor-bOGzziVZdVNC82Gmw8oH
22
22
  reconcile/deadmanssnitch.py,sha256=n-5W-djUgwzpmdDM4eQIZpkkDmHY0vndt-42LJXI4Y8,7491
23
23
  reconcile/email_sender.py,sha256=38Wvl6WHqCwlqLx4oxVJOIeDmoJsyitD3g1F4jTkAj8,4246
24
24
  reconcile/gabi_authorized_users.py,sha256=Jwvo97nzUX3NIl2VHKuZlT0-I40qk2VnACbafe91T2o,4854
25
- reconcile/gcr_mirror.py,sha256=cdTd0CZU0qUsXJqe5k4dgpMQlyk__nyeGH0f_Cky3C0,8957
25
+ reconcile/gcp_image_mirror.py,sha256=M5pimd0j13BBWCa8vX0fftWO0pHBfQCATIIpOGggDSA,10332
26
26
  reconcile/github_org.py,sha256=Wc5cZamatuWsW2ZJT2ib5ps8l3iY3RXHwNUxVJerqz0,14173
27
27
  reconcile/github_owners.py,sha256=viE1KJ-zaTxuZ5yItg2C263J0brn-Q-3hR_DkYDMbhY,3122
28
28
  reconcile/github_repo_invites.py,sha256=U9UCzNVwrZ7MqODtFah8ogH0NNY-XjBin7G9gqHtCUY,2690
@@ -227,7 +227,7 @@ reconcile/glitchtip_project_alerts/integration.py,sha256=BgMx-NyV9mTuv7Sotb2OioC
227
227
  reconcile/glitchtip_project_dsn/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
228
228
  reconcile/glitchtip_project_dsn/integration.py,sha256=2iugub-kHYkHNK33n0v9_TeWonuxCPah_VkoTPvaajE,8077
229
229
  reconcile/gql_definitions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
230
- reconcile/gql_definitions/introspection.json,sha256=myUuHC_BLY3xZ0nDjnaQsvYFNM5eTiE3bWgNgM3e5iI,2290863
230
+ reconcile/gql_definitions/introspection.json,sha256=wNGZv8V6ivviCcwPLw34nzTR5fgwKRyE68Z93HbmBBs,2296576
231
231
  reconcile/gql_definitions/acs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
232
232
  reconcile/gql_definitions/acs/acs_instances.py,sha256=L91WW9LbhJbBSrECqShQpFtjoBOsmNIYLRpMbx1io5o,2181
233
233
  reconcile/gql_definitions/acs/acs_policies.py,sha256=bN5i4mks10Z23KJSj7jqp966Osq2dps4d-sPH9gjxEA,7008
@@ -335,6 +335,7 @@ reconcile/gql_definitions/fragments/aws_infra_management_account.py,sha256=uAmAL
335
335
  reconcile/gql_definitions/fragments/aws_vpc.py,sha256=T2egTwi2Rb0IRBBmsyag8xKpu_m6GbIAy80fhZNZwk8,1434
336
336
  reconcile/gql_definitions/fragments/aws_vpc_request.py,sha256=o0qUsPrFXs8GAbtgMXQmIJxc1mw5skSIzCcidE857g8,2460
337
337
  reconcile/gql_definitions/fragments/aws_vpc_request_subnet.py,sha256=qaTFT8cGzEslw51nUeb45Nfnv6kFxUm4CWrRR3xfBvA,760
338
+ reconcile/gql_definitions/fragments/container_image_mirror.py,sha256=qyfQlnKUCzFEPgUJ9VGmDYFmiGHR7VZ_YJNd4KeoolM,968
338
339
  reconcile/gql_definitions/fragments/deplopy_resources.py,sha256=0u3xYqL5NpMf149BJLfPhHqAOWu06aLULdNk_2Mulxg,1089
339
340
  reconcile/gql_definitions/fragments/disable.py,sha256=Ojw98OSxcovrtmw_aAyhaVHhIa1MSUbBfKX4i2IpI74,715
340
341
  reconcile/gql_definitions/fragments/email_service.py,sha256=0wKpICsg4pcMfr2lszvnqbuPX7wVYoJ5cYFU2uQkHbY,803
@@ -355,6 +356,9 @@ reconcile/gql_definitions/fragments/terraform_state.py,sha256=S5QuTR9YlvUObiU7he
355
356
  reconcile/gql_definitions/fragments/upgrade_policy.py,sha256=cVza8zfra1E3yBsHiS-hKbys17fvv572GFnKshJjluE,1246
356
357
  reconcile/gql_definitions/fragments/user.py,sha256=TZyFEs1fBg5PkvWdyCxFDZ_3aRhcQzusfhObXFiOU_0,1025
357
358
  reconcile/gql_definitions/fragments/vault_secret.py,sha256=8xoQJNx1jKw_1yradq1iLEYWzuOHra1bEHHU7WHKxqo,833
359
+ reconcile/gql_definitions/gcp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
360
+ reconcile/gql_definitions/gcp/gcp_docker_repos.py,sha256=HvNaJxQYNPBTDmk26cOUY5_C5oBfau4bdfuI-L1Vcps,3338
361
+ reconcile/gql_definitions/gcp/gcp_projects.py,sha256=7LslYFSN2r8vYhYdi4s-zOkIrqpqyt4ayxbPNMie9M4,2108
358
362
  reconcile/gql_definitions/gitlab_members/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
359
363
  reconcile/gql_definitions/gitlab_members/gitlab_instances.py,sha256=oYPvfiOsPTGHXQeSfxXvBuvJFrwp0VtE2F0lVKFQMoU,2206
360
364
  reconcile/gql_definitions/gitlab_members/permissions.py,sha256=Qzj3Fpv7xj8v9eygeP312nHRNg8er8XMRBveynPIyQM,3302
@@ -687,7 +691,7 @@ reconcile/utils/clusterhealth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5N
687
691
  reconcile/utils/clusterhealth/providerbase.py,sha256=DXomGYogckBLqWtXn0PXU0hWYxB6K0F7ernldrkHhVY,1140
688
692
  reconcile/utils/clusterhealth/telemeter.py,sha256=PllSLsJXvGNatmTF4mxCNPVbDrpr_MPk0m5pWj-LT6g,1534
689
693
  reconcile/utils/dynatrace/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
690
- reconcile/utils/dynatrace/client.py,sha256=RUk6KH-3CJyfJ1jolrdGQR4Hhz-tIWWJo9dsZ1IgJVw,3736
694
+ reconcile/utils/dynatrace/client.py,sha256=H8EjqmZlB1a2ionAjV8_R1ozs9lWbmPCYKLe0J8kZAs,2838
691
695
  reconcile/utils/glitchtip/__init__.py,sha256=FT6iBhGqoe7KExFdbgL8AYUb64iW_4snF5__Dcl7yt0,258
692
696
  reconcile/utils/glitchtip/client.py,sha256=F_x18QMHfeNoS3vS_VvjVm1IKnwGOsXvopjR9iwj4aY,8948
693
697
  reconcile/utils/glitchtip/models.py,sha256=FyNt9EA9IS6tlsvqz-j7SkL9MoT_zIUTun0EiC9No6U,6684
@@ -800,7 +804,7 @@ tools/saas_promotion_state/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJ
800
804
  tools/saas_promotion_state/saas_promotion_state.py,sha256=UfwwRLS5Ya4_Nh1w5n1dvoYtchQvYE9yj1VANt2IKqI,3925
801
805
  tools/sre_checkpoints/__init__.py,sha256=CDaDaywJnmRCLyl_NCcvxi-Zc0hTi_3OdwKiFOyS39I,145
802
806
  tools/sre_checkpoints/util.py,sha256=zEDbGr18ZeHNQwW8pUsr2JRjuXIPz--WAGJxZo9sv_Y,894
803
- qontract_reconcile-0.10.2.dev160.dist-info/METADATA,sha256=q307B4-jiPGwEIJonpzw-vEkV7sOmCmPOxubWtR60TI,24627
804
- qontract_reconcile-0.10.2.dev160.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
805
- qontract_reconcile-0.10.2.dev160.dist-info/entry_points.txt,sha256=5i9l54La3vQrDLAdwDKQWC0iG4sV9RRfOb1BpvzOWLc,698
806
- qontract_reconcile-0.10.2.dev160.dist-info/RECORD,,
807
+ qontract_reconcile-0.10.2.dev162.dist-info/METADATA,sha256=HuDQ0XJUu4_ZT4LGmWTJVTTnfcqy21AIo-LCxVesVYo,24627
808
+ qontract_reconcile-0.10.2.dev162.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
809
+ qontract_reconcile-0.10.2.dev162.dist-info/entry_points.txt,sha256=5i9l54La3vQrDLAdwDKQWC0iG4sV9RRfOb1BpvzOWLc,698
810
+ qontract_reconcile-0.10.2.dev162.dist-info/RECORD,,
reconcile/cli.py CHANGED
@@ -1849,15 +1849,13 @@ def quay_membership(ctx):
1849
1849
  run_integration(reconcile.quay_membership, ctx.obj)
1850
1850
 
1851
1851
 
1852
- @integration.command(
1853
- short_help="Mirrors external images into Google Container Registry."
1854
- )
1852
+ @integration.command(short_help="Mirrors external images into GCP Artifact Registry.")
1855
1853
  @click.pass_context
1856
1854
  @binary(["skopeo"])
1857
- def gcr_mirror(ctx):
1858
- import reconcile.gcr_mirror
1855
+ def gcp_image_mirror(ctx):
1856
+ import reconcile.gcp_image_mirror
1859
1857
 
1860
- run_integration(reconcile.gcr_mirror, ctx.obj)
1858
+ run_integration(reconcile.gcp_image_mirror, ctx.obj)
1861
1859
 
1862
1860
 
1863
1861
  @integration.command(short_help="Mirrors external images into Quay.")
@@ -0,0 +1,276 @@
1
+ import base64
2
+ import logging
3
+ import os
4
+ import re
5
+ import tempfile
6
+ import time
7
+ from typing import Any, Self
8
+
9
+ import requests
10
+ from pydantic import BaseModel
11
+ from sretoolbox.container import (
12
+ Image,
13
+ Skopeo,
14
+ )
15
+ from sretoolbox.container.image import ImageComparisonError
16
+ from sretoolbox.container.skopeo import SkopeoCmdError
17
+
18
+ import reconcile.gql_definitions.gcp.gcp_docker_repos as gql_gcp_repos
19
+ import reconcile.gql_definitions.gcp.gcp_projects as gql_gcp_projects
20
+ from reconcile import queries
21
+ from reconcile.gql_definitions.fragments.container_image_mirror import (
22
+ ContainerImageMirror,
23
+ )
24
+ from reconcile.gql_definitions.fragments.vault_secret import VaultSecret
25
+ from reconcile.utils import gql
26
+ from reconcile.utils.secret_reader import SecretReader
27
+
28
+ QONTRACT_INTEGRATION = "gcp-image-mirror"
29
+ REQUEST_TIMEOUT = 60
30
+ GCR_SECRET_PREFIX = "gcr_"
31
+ AR_SECRET_PREFIX = "ar_"
32
+
33
+
34
+ class ImageSyncItem(BaseModel):
35
+ mirror: ContainerImageMirror
36
+ destination_url: str
37
+ org_name: str
38
+
39
+
40
+ class SyncTask(BaseModel):
41
+ mirror_creds: str | None = None
42
+ source_url: str
43
+ dest_url: str
44
+ org_name: str
45
+
46
+
47
+ class QuayMirror:
48
+ def __init__(self, dry_run: bool = False) -> None:
49
+ self.dry_run = dry_run
50
+ self.gqlapi = gql.get_api()
51
+ settings = queries.get_app_interface_settings()
52
+ self.secret_reader = SecretReader(settings=settings)
53
+ self.skopeo_cli = Skopeo(dry_run)
54
+ self.push_creds = self._get_push_creds()
55
+ self.session = requests.Session()
56
+
57
+ def __enter__(self) -> Self:
58
+ return self
59
+
60
+ def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
61
+ self.session.close()
62
+
63
+ def run(self) -> None:
64
+ gql_result = gql_gcp_repos.query(query_func=self.gqlapi.query)
65
+ processed_repos = self.process_repos_to_sync(gql_result)
66
+ sync_tasks = self.process_sync_tasks(processed_repos)
67
+
68
+ for task in sync_tasks:
69
+ try:
70
+ dest_creds = self.push_creds[f"{GCR_SECRET_PREFIX}{task.org_name}"]
71
+ if "pkg.dev" in task.dest_url:
72
+ dest_creds = self.push_creds[f"{AR_SECRET_PREFIX}{task.org_name}"]
73
+
74
+ self.skopeo_cli.copy(
75
+ src_image=task.source_url,
76
+ src_creds=task.mirror_creds,
77
+ dst_image=task.dest_url,
78
+ dest_creds=dest_creds,
79
+ )
80
+ except SkopeoCmdError as details:
81
+ logging.error("[%s]", details)
82
+
83
+ # processes the GQL repos to come up with a list of items that need to be synced
84
+ def process_repos_to_sync(
85
+ self, repos: gql_gcp_repos.GcpDockerReposQueryData
86
+ ) -> list[ImageSyncItem]:
87
+ summary = list[ImageSyncItem]()
88
+ if repos.apps:
89
+ for app in repos.apps:
90
+ if app.gcr_repos:
91
+ for gcr_project in app.gcr_repos:
92
+ for gcr_repo in gcr_project.items:
93
+ if gcr_repo.mirror:
94
+ project_name = gcr_project.project.name
95
+ summary.append(
96
+ ImageSyncItem(
97
+ mirror=gcr_repo.mirror,
98
+ destination_url=f"gcr.io/{project_name}/{gcr_repo.name}",
99
+ org_name=project_name,
100
+ )
101
+ )
102
+ if app.artifact_registry_mirrors:
103
+ for ar_project in app.artifact_registry_mirrors:
104
+ for ar_repo in ar_project.items:
105
+ summary.append(
106
+ ImageSyncItem(
107
+ mirror=ar_repo.mirror,
108
+ destination_url=ar_repo.image_url,
109
+ org_name=ar_project.project.name,
110
+ )
111
+ )
112
+
113
+ return summary
114
+
115
+ @staticmethod
116
+ def sync_tag(
117
+ tags: list[str] | None, tags_exclude: list[str] | None, candidate: str
118
+ ) -> bool:
119
+ if tags is not None:
120
+ # When tags is defined, we don't look at tags_exclude
121
+ return any(re.match(tag, candidate) for tag in tags)
122
+
123
+ if tags_exclude is not None:
124
+ return any(re.match(tag, candidate) for tag in tags_exclude)
125
+ for tag_exclude in tags_exclude:
126
+ if re.match(tag_exclude, candidate):
127
+ return False
128
+ return True
129
+
130
+ # Both tags and tags_exclude are None, so
131
+ # tag must be synced
132
+ return True
133
+
134
+ # second layer of processing that matches up pull/push creds with each repo and determines what tags need to be synced
135
+ def process_sync_tasks(self, repos_to_sync: list[ImageSyncItem]) -> list[SyncTask]:
136
+ eight_hours = 28800 # 60 * 60 * 8
137
+ is_deep_sync = self._is_deep_sync(interval=eight_hours)
138
+
139
+ sync_tasks = list[SyncTask]()
140
+ for item in repos_to_sync:
141
+ image = Image(
142
+ f"{item.destination_url}",
143
+ session=self.session,
144
+ timeout=REQUEST_TIMEOUT,
145
+ )
146
+
147
+ mirror_url = item.mirror.url
148
+
149
+ username = None
150
+ password = None
151
+ mirror_creds = None
152
+ pull_credentials = item.mirror.pull_credentials
153
+ if pull_credentials:
154
+ raw_data = self.secret_reader.read_all(pull_credentials.dict())
155
+ username = raw_data["user"]
156
+ password = raw_data["token"]
157
+ mirror_creds = f"{username}:{password}"
158
+
159
+ image_mirror = Image(
160
+ mirror_url,
161
+ username=username,
162
+ password=password,
163
+ session=self.session,
164
+ timeout=REQUEST_TIMEOUT,
165
+ )
166
+
167
+ for tag in image_mirror:
168
+ if not self.sync_tag(
169
+ tags=item.mirror.tags,
170
+ tags_exclude=item.mirror.tags_exclude,
171
+ candidate=tag,
172
+ ):
173
+ continue
174
+
175
+ # the Image class allows you to fetch Image information at a specific tag with a get operator
176
+ upstream = image_mirror[tag]
177
+ downstream = image[tag]
178
+ if tag not in image:
179
+ logging.debug(
180
+ f"Image {image.image}: {downstream} and mirror {upstream} are out of sync"
181
+ )
182
+ sync_tasks.append(
183
+ SyncTask(
184
+ source_url=str(upstream),
185
+ mirror_creds=mirror_creds,
186
+ dest_url=str(downstream),
187
+ org_name=item.org_name,
188
+ )
189
+ )
190
+ continue
191
+
192
+ # Deep (slow) check only in non dry-run mode
193
+ if self.dry_run:
194
+ logging.debug(
195
+ f"Image {image.image}: {downstream} and mirror {upstream} are in sync"
196
+ )
197
+ continue
198
+
199
+ # Deep (slow) check only from time to time
200
+ if not is_deep_sync:
201
+ logging.debug(
202
+ f"Image {image.image}: {downstream} and mirror {upstream} are in sync"
203
+ )
204
+ continue
205
+
206
+ try:
207
+ if downstream == upstream:
208
+ logging.debug(
209
+ f"Image {image.image}: {downstream} and mirror {upstream} are in sync",
210
+ )
211
+ continue
212
+ except ImageComparisonError as details:
213
+ logging.error("[%s]", details)
214
+ continue
215
+
216
+ logging.debug(
217
+ f"Image {image.image}: {downstream} and mirror {upstream} are out of sync"
218
+ )
219
+ sync_tasks.append(
220
+ SyncTask(
221
+ source_url=str(upstream),
222
+ mirror_creds=mirror_creds,
223
+ dest_url=str(downstream),
224
+ org_name=item.org_name,
225
+ )
226
+ )
227
+
228
+ return sync_tasks
229
+
230
+ def _is_deep_sync(self, interval: int) -> bool:
231
+ control_file_name = "qontract-reconcile-gcp-image-mirror.timestamp"
232
+ control_file_path = os.path.join(tempfile.gettempdir(), control_file_name)
233
+ try:
234
+ with open(control_file_path, encoding="locale") as file_obj:
235
+ last_deep_sync = float(file_obj.read())
236
+ except FileNotFoundError:
237
+ self._record_timestamp(control_file_path)
238
+ return True
239
+
240
+ next_deep_sync = last_deep_sync + interval
241
+ if time.time() >= next_deep_sync:
242
+ self._record_timestamp(control_file_path)
243
+ return True
244
+
245
+ return False
246
+
247
+ def _decode_push_secret(self, secret: VaultSecret) -> str:
248
+ raw_data = self.secret_reader.read_all(secret.dict())
249
+ token = base64.b64decode(raw_data["token"]).decode()
250
+ return f"{raw_data['user']}:{token}"
251
+
252
+ @staticmethod
253
+ def _record_timestamp(path: str) -> None:
254
+ with open(path, "w", encoding="locale") as file_object:
255
+ file_object.write(str(time.time()))
256
+
257
+ def _get_push_creds(self) -> dict[str, str]:
258
+ result = gql_gcp_projects.query(query_func=self.gqlapi.query)
259
+
260
+ creds = dict[str, str]()
261
+ if result.gcp_projects:
262
+ for project_data in result.gcp_projects:
263
+ # support old pull secret for backwards compatibility (although they are both using artifact registry on the backend)
264
+ if project_data.gcr_push_credentials:
265
+ creds[f"{GCR_SECRET_PREFIX}{project_data.name}"] = (
266
+ self._decode_push_secret(project_data.gcr_push_credentials)
267
+ )
268
+ creds[f"{AR_SECRET_PREFIX}{project_data.name}"] = (
269
+ self._decode_push_secret(project_data.artifact_push_credentials)
270
+ )
271
+ return creds
272
+
273
+
274
+ def run(dry_run: bool) -> None:
275
+ with QuayMirror(dry_run) as gcp_image_mirror:
276
+ gcp_image_mirror.run()
@@ -0,0 +1,33 @@
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
+ class ConfiguredBaseModel(BaseModel):
24
+ class Config:
25
+ smart_union=True
26
+ extra=Extra.forbid
27
+
28
+
29
+ class ContainerImageMirror(ConfiguredBaseModel):
30
+ url: str = Field(..., alias="url")
31
+ pull_credentials: Optional[VaultSecret] = Field(..., alias="pullCredentials")
32
+ tags: Optional[list[str]] = Field(..., alias="tags")
33
+ tags_exclude: Optional[list[str]] = Field(..., alias="tagsExclude")
File without changes
@@ -0,0 +1,128 @@
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.container_image_mirror import ContainerImageMirror
21
+
22
+
23
+ DEFINITION = """
24
+ fragment ContainerImageMirror on ContainerImageMirror_v1 {
25
+ url
26
+ pullCredentials {
27
+ ...VaultSecret
28
+ }
29
+ tags
30
+ tagsExclude
31
+ }
32
+
33
+ fragment VaultSecret on VaultSecret_v1 {
34
+ path
35
+ field
36
+ version
37
+ format
38
+ }
39
+
40
+ query GcpDockerRepos {
41
+ apps: apps_v1 {
42
+ gcrRepos {
43
+ project {
44
+ name
45
+ }
46
+ items {
47
+ name
48
+ mirror {
49
+ ...ContainerImageMirror
50
+ }
51
+ }
52
+ }
53
+ artifactRegistryMirrors {
54
+ project {
55
+ name
56
+ }
57
+ items {
58
+ imageURL
59
+ mirror {
60
+ ...ContainerImageMirror
61
+ }
62
+ }
63
+ }
64
+ }
65
+ }
66
+ """
67
+
68
+
69
+ class ConfiguredBaseModel(BaseModel):
70
+ class Config:
71
+ smart_union=True
72
+ extra=Extra.forbid
73
+
74
+
75
+ class GcpProjectV1(ConfiguredBaseModel):
76
+ name: str = Field(..., alias="name")
77
+
78
+
79
+ class AppGcrReposItemsV1(ConfiguredBaseModel):
80
+ name: str = Field(..., alias="name")
81
+ mirror: Optional[ContainerImageMirror] = Field(..., alias="mirror")
82
+
83
+
84
+ class AppGcrReposV1(ConfiguredBaseModel):
85
+ project: GcpProjectV1 = Field(..., alias="project")
86
+ items: list[AppGcrReposItemsV1] = Field(..., alias="items")
87
+
88
+
89
+ class AppArtifactRegistryMirrorsV1_GcpProjectV1(ConfiguredBaseModel):
90
+ name: str = Field(..., alias="name")
91
+
92
+
93
+ class AppArtifactRegistryMirrorsItemsV1(ConfiguredBaseModel):
94
+ image_url: str = Field(..., alias="imageURL")
95
+ mirror: ContainerImageMirror = Field(..., alias="mirror")
96
+
97
+
98
+ class AppArtifactRegistryMirrorsV1(ConfiguredBaseModel):
99
+ project: AppArtifactRegistryMirrorsV1_GcpProjectV1 = Field(..., alias="project")
100
+ items: list[AppArtifactRegistryMirrorsItemsV1] = Field(..., alias="items")
101
+
102
+
103
+ class AppV1(ConfiguredBaseModel):
104
+ gcr_repos: Optional[list[AppGcrReposV1]] = Field(..., alias="gcrRepos")
105
+ artifact_registry_mirrors: Optional[list[AppArtifactRegistryMirrorsV1]] = Field(..., alias="artifactRegistryMirrors")
106
+
107
+
108
+ class GcpDockerReposQueryData(ConfiguredBaseModel):
109
+ apps: Optional[list[AppV1]] = Field(..., alias="apps")
110
+
111
+
112
+ def query(query_func: Callable, **kwargs: Any) -> GcpDockerReposQueryData:
113
+ """
114
+ This is a convenience function which queries and parses the data into
115
+ concrete types. It should be compatible with most GQL clients.
116
+ You do not have to use it to consume the generated data classes.
117
+ Alternatively, you can also mime and alternate the behavior
118
+ of this function in the caller.
119
+
120
+ Parameters:
121
+ query_func (Callable): Function which queries your GQL Server
122
+ kwargs: optional arguments that will be passed to the query function
123
+
124
+ Returns:
125
+ GcpDockerReposQueryData: queried data parsed into generated classes
126
+ """
127
+ raw_data: dict[Any, Any] = query_func(DEFINITION, **kwargs)
128
+ return GcpDockerReposQueryData(**raw_data)
@@ -0,0 +1,77 @@
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 GcpProjects {
32
+ gcp_projects: gcp_projects_v1 {
33
+ name
34
+ gcrPushCredentials {
35
+ ...VaultSecret
36
+ }
37
+ artifactPushCredentials {
38
+ ...VaultSecret
39
+ }
40
+ }
41
+ }
42
+ """
43
+
44
+
45
+ class ConfiguredBaseModel(BaseModel):
46
+ class Config:
47
+ smart_union=True
48
+ extra=Extra.forbid
49
+
50
+
51
+ class GcpProjectV1(ConfiguredBaseModel):
52
+ name: str = Field(..., alias="name")
53
+ gcr_push_credentials: Optional[VaultSecret] = Field(..., alias="gcrPushCredentials")
54
+ artifact_push_credentials: VaultSecret = Field(..., alias="artifactPushCredentials")
55
+
56
+
57
+ class GcpProjectsQueryData(ConfiguredBaseModel):
58
+ gcp_projects: Optional[list[GcpProjectV1]] = Field(..., alias="gcp_projects")
59
+
60
+
61
+ def query(query_func: Callable, **kwargs: Any) -> GcpProjectsQueryData:
62
+ """
63
+ This is a convenience function which queries and parses the data into
64
+ concrete types. It should be compatible with most GQL clients.
65
+ You do not have to use it to consume the generated data classes.
66
+ Alternatively, you can also mime and alternate the behavior
67
+ of this function in the caller.
68
+
69
+ Parameters:
70
+ query_func (Callable): Function which queries your GQL Server
71
+ kwargs: optional arguments that will be passed to the query function
72
+
73
+ Returns:
74
+ GcpProjectsQueryData: queried data parsed into generated classes
75
+ """
76
+ raw_data: dict[Any, Any] = query_func(DEFINITION, **kwargs)
77
+ return GcpProjectsQueryData(**raw_data)
@@ -15959,6 +15959,26 @@
15959
15959
  "isDeprecated": false,
15960
15960
  "deprecationReason": null
15961
15961
  },
15962
+ {
15963
+ "name": "artifactRegistryMirrors",
15964
+ "description": null,
15965
+ "args": [],
15966
+ "type": {
15967
+ "kind": "LIST",
15968
+ "name": null,
15969
+ "ofType": {
15970
+ "kind": "NON_NULL",
15971
+ "name": null,
15972
+ "ofType": {
15973
+ "kind": "OBJECT",
15974
+ "name": "AppArtifactRegistryMirrors_v1",
15975
+ "ofType": null
15976
+ }
15977
+ }
15978
+ },
15979
+ "isDeprecated": false,
15980
+ "deprecationReason": null
15981
+ },
15962
15982
  {
15963
15983
  "name": "quayRepos",
15964
15984
  "description": null,
@@ -16816,7 +16836,7 @@
16816
16836
  "deprecationReason": null
16817
16837
  },
16818
16838
  {
16819
- "name": "pushCredentials",
16839
+ "name": "gcrPushCredentials",
16820
16840
  "description": null,
16821
16841
  "args": [],
16822
16842
  "type": {
@@ -16826,6 +16846,22 @@
16826
16846
  },
16827
16847
  "isDeprecated": false,
16828
16848
  "deprecationReason": null
16849
+ },
16850
+ {
16851
+ "name": "artifactPushCredentials",
16852
+ "description": null,
16853
+ "args": [],
16854
+ "type": {
16855
+ "kind": "NON_NULL",
16856
+ "name": null,
16857
+ "ofType": {
16858
+ "kind": "OBJECT",
16859
+ "name": "VaultSecret_v1",
16860
+ "ofType": null
16861
+ }
16862
+ },
16863
+ "isDeprecated": false,
16864
+ "deprecationReason": null
16829
16865
  }
16830
16866
  ],
16831
16867
  "inputFields": null,
@@ -17027,6 +17063,100 @@
17027
17063
  "enumValues": null,
17028
17064
  "possibleTypes": null
17029
17065
  },
17066
+ {
17067
+ "kind": "OBJECT",
17068
+ "name": "AppArtifactRegistryMirrors_v1",
17069
+ "description": null,
17070
+ "fields": [
17071
+ {
17072
+ "name": "project",
17073
+ "description": null,
17074
+ "args": [],
17075
+ "type": {
17076
+ "kind": "NON_NULL",
17077
+ "name": null,
17078
+ "ofType": {
17079
+ "kind": "OBJECT",
17080
+ "name": "GcpProject_v1",
17081
+ "ofType": null
17082
+ }
17083
+ },
17084
+ "isDeprecated": false,
17085
+ "deprecationReason": null
17086
+ },
17087
+ {
17088
+ "name": "items",
17089
+ "description": null,
17090
+ "args": [],
17091
+ "type": {
17092
+ "kind": "NON_NULL",
17093
+ "name": null,
17094
+ "ofType": {
17095
+ "kind": "LIST",
17096
+ "name": null,
17097
+ "ofType": {
17098
+ "kind": "NON_NULL",
17099
+ "name": null,
17100
+ "ofType": {
17101
+ "kind": "OBJECT",
17102
+ "name": "AppArtifactRegistryMirrorsItems_v1",
17103
+ "ofType": null
17104
+ }
17105
+ }
17106
+ }
17107
+ },
17108
+ "isDeprecated": false,
17109
+ "deprecationReason": null
17110
+ }
17111
+ ],
17112
+ "inputFields": null,
17113
+ "interfaces": [],
17114
+ "enumValues": null,
17115
+ "possibleTypes": null
17116
+ },
17117
+ {
17118
+ "kind": "OBJECT",
17119
+ "name": "AppArtifactRegistryMirrorsItems_v1",
17120
+ "description": null,
17121
+ "fields": [
17122
+ {
17123
+ "name": "imageURL",
17124
+ "description": null,
17125
+ "args": [],
17126
+ "type": {
17127
+ "kind": "NON_NULL",
17128
+ "name": null,
17129
+ "ofType": {
17130
+ "kind": "SCALAR",
17131
+ "name": "String",
17132
+ "ofType": null
17133
+ }
17134
+ },
17135
+ "isDeprecated": false,
17136
+ "deprecationReason": null
17137
+ },
17138
+ {
17139
+ "name": "mirror",
17140
+ "description": null,
17141
+ "args": [],
17142
+ "type": {
17143
+ "kind": "NON_NULL",
17144
+ "name": null,
17145
+ "ofType": {
17146
+ "kind": "OBJECT",
17147
+ "name": "ContainerImageMirror_v1",
17148
+ "ofType": null
17149
+ }
17150
+ },
17151
+ "isDeprecated": false,
17152
+ "deprecationReason": null
17153
+ }
17154
+ ],
17155
+ "inputFields": null,
17156
+ "interfaces": [],
17157
+ "enumValues": null,
17158
+ "possibleTypes": null
17159
+ },
17030
17160
  {
17031
17161
  "kind": "OBJECT",
17032
17162
  "name": "AppQuayRepos_v1",
@@ -1,8 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from collections.abc import Iterable
4
- from datetime import datetime
5
- from unittest.mock import patch
6
4
 
7
5
  from dynatrace import Dynatrace
8
6
  from dynatrace.environment_v2.tokens_api import ApiTokenUpdate
@@ -35,32 +33,11 @@ class DynatraceAPIToken(BaseModel):
35
33
  scopes: list[str]
36
34
 
37
35
 
38
- # TODO: Remove once APPSRE-11428 is resolved #######
39
- ISO_8601 = "%Y-%m-%dT%H:%M:%S.%fZ"
40
- FIXED_ISO_8601 = "%Y-%m-%dT%H:%M:%SZ"
41
-
42
-
43
- def custom_iso8601_to_datetime(timestamp: str | None) -> datetime | None:
44
- if isinstance(timestamp, str):
45
- try:
46
- return datetime.strptime(timestamp, ISO_8601)
47
- except ValueError:
48
- return datetime.strptime(timestamp, FIXED_ISO_8601)
49
- return timestamp
50
-
51
-
52
- ################################################
53
-
54
-
55
36
  class DynatraceClient:
56
37
  def __init__(self, environment_url: str, api: Dynatrace) -> None:
57
38
  self._environment_url = environment_url
58
39
  self._api = api
59
40
 
60
- @patch(
61
- "dynatrace.environment_v2.tokens_api.iso8601_to_datetime",
62
- custom_iso8601_to_datetime,
63
- )
64
41
  def create_api_token(
65
42
  self, name: str, scopes: Iterable[str]
66
43
  ) -> DynatraceAPITokenCreated:
@@ -72,10 +49,6 @@ class DynatraceClient:
72
49
  ) from e
73
50
  return DynatraceAPITokenCreated(token=token.token, id=token.id)
74
51
 
75
- @patch(
76
- "dynatrace.environment_v2.tokens_api.iso8601_to_datetime",
77
- custom_iso8601_to_datetime,
78
- )
79
52
  def get_token_ids_map_for_name_prefix(self, prefix: str) -> dict[str, str]:
80
53
  try:
81
54
  dt_tokens = self._api.tokens.list()
@@ -87,10 +60,6 @@ class DynatraceClient:
87
60
  token.id: token.name for token in dt_tokens if token.name.startswith(prefix)
88
61
  }
89
62
 
90
- @patch(
91
- "dynatrace.environment_v2.tokens_api.iso8601_to_datetime",
92
- custom_iso8601_to_datetime,
93
- )
94
63
  def get_token_by_id(self, token_id: str) -> DynatraceAPIToken:
95
64
  try:
96
65
  token = self._api.tokens.get(token_id=token_id)
reconcile/gcr_mirror.py DELETED
@@ -1,278 +0,0 @@
1
- import base64
2
- import logging
3
- import os
4
- import re
5
- import tempfile
6
- import time
7
- from collections import defaultdict
8
- from typing import Any, Self
9
-
10
- import requests
11
- from sretoolbox.container import (
12
- Image,
13
- Skopeo,
14
- )
15
- from sretoolbox.container.image import ImageComparisonError
16
- from sretoolbox.container.skopeo import SkopeoCmdError
17
-
18
- from reconcile import queries
19
- from reconcile.utils import gql
20
- from reconcile.utils.secret_reader import SecretReader
21
-
22
- _LOG = logging.getLogger(__name__)
23
-
24
- QONTRACT_INTEGRATION = "gcr-mirror"
25
- REQUEST_TIMEOUT = 60
26
-
27
-
28
- class QuayMirror:
29
- GCR_PROJECT_CATALOG_QUERY = """
30
- {
31
- projects: gcp_projects_v1 {
32
- name
33
- pushCredentials {
34
- path
35
- field
36
- version
37
- format
38
- }
39
- }
40
- }
41
- """
42
-
43
- GCR_REPOS_QUERY = """
44
- {
45
- apps: apps_v1 {
46
- gcrRepos {
47
- project {
48
- name
49
- }
50
- items {
51
- name
52
- mirror {
53
- url
54
- pullCredentials {
55
- path
56
- field
57
- version
58
- format
59
- }
60
- tags
61
- tagsExclude
62
- }
63
- }
64
- }
65
- }
66
- }
67
- """
68
-
69
- def __init__(self, dry_run: bool = False) -> None:
70
- self.dry_run = dry_run
71
- self.gqlapi = gql.get_api()
72
- settings = queries.get_app_interface_settings()
73
- self.secret_reader = SecretReader(settings=settings)
74
- self.skopeo_cli = Skopeo(dry_run)
75
- self.push_creds = self._get_push_creds()
76
- self.session = requests.Session()
77
-
78
- def __enter__(self) -> Self:
79
- return self
80
-
81
- def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
82
- self.session.close()
83
-
84
- def run(self) -> None:
85
- sync_tasks = self.process_sync_tasks()
86
- for org, data in sync_tasks.items():
87
- for item in data:
88
- try:
89
- self.skopeo_cli.copy(
90
- src_image=item["mirror_url"],
91
- src_creds=item["mirror_creds"],
92
- dst_image=item["image_url"],
93
- dest_creds=self.push_creds[org],
94
- )
95
- except SkopeoCmdError as details:
96
- _LOG.error("[%s]", details)
97
-
98
- def process_repos_query(self) -> dict[str, list[dict[str, Any]]]:
99
- result = self.gqlapi.query(self.GCR_REPOS_QUERY)
100
-
101
- summary = defaultdict(list)
102
-
103
- for app in result["apps"]:
104
- gcr_repos = app.get("gcrRepos")
105
-
106
- if gcr_repos is None:
107
- continue
108
-
109
- for gcr_repo in gcr_repos:
110
- project = gcr_repo["project"]["name"]
111
- server_url = gcr_repo["project"].get("serverUrl") or "gcr.io"
112
- for item in gcr_repo["items"]:
113
- if item["mirror"] is None:
114
- continue
115
-
116
- summary[project].append({
117
- "name": item["name"],
118
- "mirror": item["mirror"],
119
- "server_url": server_url,
120
- })
121
-
122
- return summary
123
-
124
- @staticmethod
125
- def sync_tag(
126
- tags: list[str] | None, tags_exclude: list[str] | None, candidate: str
127
- ) -> bool:
128
- if tags is not None:
129
- # When tags is defined, we don't look at tags_exclude
130
- return any(re.match(tag, candidate) for tag in tags)
131
-
132
- if tags_exclude is not None:
133
- for tag_exclude in tags_exclude:
134
- if re.match(tag_exclude, candidate):
135
- return False
136
- return True
137
-
138
- # Both tags and tags_exclude are None, so
139
- # tag must be synced
140
- return True
141
-
142
- def process_sync_tasks(self) -> dict[str, list[dict[str, Any]]]:
143
- eight_hours = 28800 # 60 * 60 * 8
144
- is_deep_sync = self._is_deep_sync(interval=eight_hours)
145
-
146
- summary = self.process_repos_query()
147
-
148
- sync_tasks = defaultdict(list)
149
- for org, data in summary.items():
150
- for item in data:
151
- image = Image(
152
- f"{item['server_url']}/{org}/{item['name']}",
153
- session=self.session,
154
- timeout=REQUEST_TIMEOUT,
155
- )
156
-
157
- mirror_url = item["mirror"]["url"]
158
-
159
- username = None
160
- password = None
161
- mirror_creds = None
162
- if item["mirror"]["pullCredentials"] is not None:
163
- pull_credentials = item["mirror"]["pullCredentials"]
164
- raw_data = self.secret_reader.read_all(pull_credentials)
165
- username = raw_data["user"]
166
- password = raw_data["token"]
167
- mirror_creds = f"{username}:{password}"
168
-
169
- image_mirror = Image(
170
- mirror_url,
171
- username=username,
172
- password=password,
173
- session=self.session,
174
- timeout=REQUEST_TIMEOUT,
175
- )
176
-
177
- tags = item["mirror"].get("tags")
178
- tags_exclude = item["mirror"].get("tagsExclude")
179
-
180
- for tag in image_mirror:
181
- if not self.sync_tag(
182
- tags=tags, tags_exclude=tags_exclude, candidate=tag
183
- ):
184
- continue
185
-
186
- upstream = image_mirror[tag]
187
- downstream = image[tag]
188
- if tag not in image:
189
- _LOG.debug(
190
- "Image %s and mirror %s are out off sync",
191
- downstream,
192
- upstream,
193
- )
194
- sync_tasks[org].append({
195
- "mirror_url": str(upstream),
196
- "mirror_creds": mirror_creds,
197
- "image_url": str(downstream),
198
- })
199
- continue
200
-
201
- # Deep (slow) check only in non dry-run mode
202
- if self.dry_run:
203
- _LOG.debug(
204
- "Image %s and mirror %s are in sync", downstream, upstream
205
- )
206
- continue
207
-
208
- # Deep (slow) check only from time to time
209
- if not is_deep_sync:
210
- _LOG.debug(
211
- "Image %s and mirror %s are in sync", downstream, upstream
212
- )
213
- continue
214
-
215
- try:
216
- if downstream == upstream:
217
- _LOG.debug(
218
- "Image %s and mirror %s are in sync",
219
- downstream,
220
- upstream,
221
- )
222
- continue
223
- except ImageComparisonError as details:
224
- _LOG.error("[%s]", details)
225
- continue
226
-
227
- _LOG.debug(
228
- "Image %s and mirror %s are out of sync", downstream, upstream
229
- )
230
- sync_tasks[org].append({
231
- "mirror_url": str(upstream),
232
- "mirror_creds": mirror_creds,
233
- "image_url": str(downstream),
234
- })
235
-
236
- return sync_tasks
237
-
238
- def _is_deep_sync(self, interval: int) -> bool:
239
- control_file_name = "qontract-reconcile-gcr-mirror.timestamp"
240
- control_file_path = os.path.join(tempfile.gettempdir(), control_file_name)
241
- try:
242
- with open(control_file_path, encoding="locale") as file_obj:
243
- last_deep_sync = float(file_obj.read())
244
- except FileNotFoundError:
245
- self._record_timestamp(control_file_path)
246
- return True
247
-
248
- next_deep_sync = last_deep_sync + interval
249
- if time.time() >= next_deep_sync:
250
- self._record_timestamp(control_file_path)
251
- return True
252
-
253
- return False
254
-
255
- @staticmethod
256
- def _record_timestamp(path: str) -> None:
257
- with open(path, "w", encoding="locale") as file_object:
258
- file_object.write(str(time.time()))
259
-
260
- def _get_push_creds(self) -> dict[str, str]:
261
- result = self.gqlapi.query(self.GCR_PROJECT_CATALOG_QUERY)
262
-
263
- creds = {}
264
- for project_data in result["projects"]:
265
- push_secret = project_data["pushCredentials"]
266
- if push_secret is None:
267
- continue
268
-
269
- raw_data = self.secret_reader.read_all(push_secret)
270
- project = project_data["name"]
271
- token = base64.b64decode(raw_data["token"]).decode()
272
- creds[project] = f"{raw_data['user']}:{token}"
273
- return creds
274
-
275
-
276
- def run(dry_run: bool) -> None:
277
- with QuayMirror(dry_run) as gcr_mirror:
278
- gcr_mirror.run()