qontract-reconcile 0.10.1rc863__py3-none-any.whl → 0.10.1rc865__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.1rc863
3
+ Version: 0.10.1rc865
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=1x1ZIR1wKgnGh799yPcV8IQD1RHyHwWlDg9Llh1diOw,104736
13
+ reconcile/cli.py,sha256=zGgAkhK8Pl8NIKtnPr8fz5ewHa1_SSMJYtrvPa-ip6I,105151
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=R2JuwiXAEYAFiCtnztM_IIr1rtVzPpaWAmgxuDa2FgY,4813
@@ -35,7 +35,7 @@ reconcile/gitlab_labeler.py,sha256=IxE1XM5o4rDOFuR4cM2yAHTy4Uzdg3Nyz2mp7b8Fx1g,4
35
35
  reconcile/gitlab_members.py,sha256=M6LwFOrwgvl1NNdOJa1mrQFUon-bEVv1AyhGeLed454,8443
36
36
  reconcile/gitlab_mr_sqs_consumer.py,sha256=O46mdziPgGOndbU-0_UJKJVUaiEoVzJPEgKm4_UvYoI,2571
37
37
  reconcile/gitlab_owners.py,sha256=sn9njaKOtqcvnhi2qtm-faAfAR4zNqflbSuusA9RUuI,13456
38
- reconcile/gitlab_permissions.py,sha256=LcO5tVND3Rh-onU6WFh5ZWPCqZ-U4AVuFTZIs_bmPwA,3153
38
+ reconcile/gitlab_permissions.py,sha256=YaLiMqrn9YOM5e7o2NJVg6vtILyBXTryTScA4RhTyPo,3279
39
39
  reconcile/gitlab_projects.py,sha256=K3tFf_aD1W4Ijp5q-9Qek3kwFGEWPcZ1kd7tzFJ4GyQ,1781
40
40
  reconcile/integrations_manager.py,sha256=J_VV-HINI7YNav2NPIolePZkll-7VBuBXWAyMNhsM_Q,9535
41
41
  reconcile/jenkins_base.py,sha256=0Gocu3fU2YTltaxBlbDQOUvP-7CP2OSQV1ZRwtWeVXw,875
@@ -184,14 +184,15 @@ reconcile/dynatrace_token_provider/metrics.py,sha256=xiKkl8fTEBQaXJelGCPNTZhHAWd
184
184
  reconcile/external_resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
185
185
  reconcile/external_resources/aws.py,sha256=JvjKaABy2Pg8u8Lq82Acv4zMvpE3_qGKes7OG-zlHOM,2956
186
186
  reconcile/external_resources/factories.py,sha256=bLboXX5Dq0xN60mtDGNjCOLC6HlKofXMWQxVbRwMMwo,4485
187
- reconcile/external_resources/integration.py,sha256=rIfDVnTUy3qdmrh7NRwvZ7l1hOiNfk0a57J1ZsRSNrg,4959
188
- reconcile/external_resources/manager.py,sha256=APszaw9PRIiHnFyCffHZjIFseIwhlYROIPLt18pHUTQ,13685
189
- reconcile/external_resources/meta.py,sha256=SA4Km1r7ePdcNqHn2GA4ByQp4ZnIeo_n8qOOd-11IEg,151
187
+ reconcile/external_resources/integration.py,sha256=p07tnD4pww9QgFlHT18RRn929BL7zKzRHRvHQ_ETRAU,5113
188
+ reconcile/external_resources/integration_secrets_sync.py,sha256=cMEZhgCvABAMf-DWF051L6CRnJQdfbsISA_b1xuS940,1670
189
+ reconcile/external_resources/manager.py,sha256=d1YuCZWOnUV3nfZ0HJI-IIG5qef7eQ4ct7hSTXcFK4M,14576
190
+ reconcile/external_resources/meta.py,sha256=Z5guBceyaGyAzzA9kVb0-WaNpSli368NVUnWulxtMb4,551
190
191
  reconcile/external_resources/metrics.py,sha256=m2TIOao2N7pD6k45driFbBGVCC_N7ai44m-lLPfa5qk,454
191
192
  reconcile/external_resources/model.py,sha256=FJUb7rHU2l7YSAv-t4QaacL9pqheFBxhPydWSPqu3vY,7413
192
193
  reconcile/external_resources/reconciler.py,sha256=E50X_lnOD0OWYXMzyZld1P6dCFJFYjHGyICWff9bxlc,9323
193
- reconcile/external_resources/secrets_sync.py,sha256=g-ksvzmTlCTwo3PM3FgYXm0LUBcnwfAxcvisuR1jAMY,7982
194
- reconcile/external_resources/state.py,sha256=LrZ_-9DRmKOdPlYgxgaYlGAOa6lzKDgcmn-Zc4AQDX0,9333
194
+ reconcile/external_resources/secrets_sync.py,sha256=xFQ_ObWl29btbxzEJSkndvHBPcsVpSTkmUlWcUGzZ70,15132
195
+ reconcile/external_resources/state.py,sha256=fA_CzT4oNie4wnaImwW-W1duWEOKFyS1omcnMyYwx2Q,9644
195
196
  reconcile/glitchtip/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
196
197
  reconcile/glitchtip/integration.py,sha256=Y7ofQg_xCt3dOln3pjeXp7rAnwohCgD2zcUAb-Hciis,8375
197
198
  reconcile/glitchtip/reconciler.py,sha256=nUvDv7qG1ly0cA16MmlL6NV71yl1mJYLT2mui7lmi0Y,12402
@@ -274,9 +275,9 @@ reconcile/gql_definitions/dynatrace_token_provider/__init__.py,sha256=47DEQpj8HB
274
275
  reconcile/gql_definitions/dynatrace_token_provider/dynatrace_bootstrap_tokens.py,sha256=5gTuAnR2rnx2k6Rn7FMEAzw6GCZ6F5HZbqkmJ9-3NI4,2244
275
276
  reconcile/gql_definitions/external_resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
276
277
  reconcile/gql_definitions/external_resources/aws_accounts.py,sha256=XR69j9dpTQ0gv8y-AZN7AJ0dPvO-wbHscyCDgrax6Bk,2046
277
- reconcile/gql_definitions/external_resources/external_resources_modules.py,sha256=2VIb3hC2MRNhYDZ1al4PvudT2oSfd3fPGpzv4CEJNiw,2341
278
+ reconcile/gql_definitions/external_resources/external_resources_modules.py,sha256=g2KB2wRnb8zF7xCmDJJFmiRdE4z4aYa9HtY3vCBVwMA,2441
278
279
  reconcile/gql_definitions/external_resources/external_resources_namespaces.py,sha256=UyOAUY1rROenjTz6y-uSEFjrEwhh-lPsIQPbi6EQLFg,40915
279
- reconcile/gql_definitions/external_resources/external_resources_settings.py,sha256=989_pG9NWKB5BPvdwqjqZUYp_2qf-xYmJ9c9kq8Kmfw,2886
280
+ reconcile/gql_definitions/external_resources/external_resources_settings.py,sha256=Hw9n_90BPG6Lnt2PT3mHc6p0KEm2CxKxvSGRFc_Dhus,2982
280
281
  reconcile/gql_definitions/fragments/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
281
282
  reconcile/gql_definitions/fragments/aus_organization.py,sha256=uBKbTuBa3CZmTXR5HOcGhRcu2U9kM93KbYmoWTxcpB0,4767
282
283
  reconcile/gql_definitions/fragments/aws_account_common.py,sha256=3-7ZAP6GSff7Z2Syz2VQCLY4IySqBOSVmceaRiVNQpw,2385
@@ -647,7 +648,7 @@ reconcile/utils/environ.py,sha256=VnW3zp6Un_UJn5BU4FU8RfhuqtZp0s-VeuuHnqC_WcQ,51
647
648
  reconcile/utils/exceptions.py,sha256=DwfnWUpVOotpP79RWZ2pycmG6nKCL00RBIeZLYkQPW4,635
648
649
  reconcile/utils/expiration.py,sha256=BXwKE50sNIV-Lszke97fxitNkLxYszoOLW1LBgp_yqg,1246
649
650
  reconcile/utils/extended_early_exit.py,sha256=QSktrmfw37zSRMNk930tDbQsVeKxaPPPD43e79DGwZw,6754
650
- reconcile/utils/external_resource_spec.py,sha256=IRY8MCsyWKzt-Qj_hXiFKgkCvZu6VVyj6IYtqEb05BA,6618
651
+ reconcile/utils/external_resource_spec.py,sha256=OqEwhMgdpiWm9CnlpZjCMzqFmBDVfFDNYkcxvLmBwXM,6922
651
652
  reconcile/utils/external_resources.py,sha256=ZnBjQlMpiuCX0Ivm77eIMB4Um_RuAsoMtc8IyuvIxI4,7590
652
653
  reconcile/utils/filtering.py,sha256=zZnHH0u0SaTDyzuFXZ_mREURGLvjEqQIQy4z-7QBVlc,419
653
654
  reconcile/utils/git.py,sha256=BdxXFgQ1XOZpS-4qb3qMsKTCFDG8MlE26rv1jAhvCkM,1560
@@ -678,7 +679,7 @@ reconcile/utils/oc_connection_parameters.py,sha256=85slrnDigYwYmzhyceVkMElWzFArp
678
679
  reconcile/utils/oc_filters.py,sha256=R2Lf3fo0jQCeE62Ygeo_KN24XbAosq0QbjimYG6qHI4,1402
679
680
  reconcile/utils/oc_map.py,sha256=nT69J5pdPeIDnIYjD9fwY6GkE3BMQCf-AF0rmHJuUNw,9068
680
681
  reconcile/utils/ocm_base_client.py,sha256=X8qkPXfpfJdBKBtFv7zyGD33HNAEBJL8owf-ykrt-Ts,6469
681
- reconcile/utils/openshift_resource.py,sha256=l5VLvwZ1LRi3l8InMdJS0oc1w5juhgwpWD2Yg7rXubc,24763
682
+ reconcile/utils/openshift_resource.py,sha256=zA2hOhhvAb6v_NAcge_pem2EL1G7JMGolEfvFoHMgvM,24952
682
683
  reconcile/utils/openssl.py,sha256=QVvhzhpChq_4Daf_5wE1qeZJr4thg3DDjJPn4bOPD4E,365
683
684
  reconcile/utils/output.py,sha256=I_kXYyPcN1mlZmX16ZnLNGkhhwnal640GIdIaGJd4wE,2026
684
685
  reconcile/utils/pagerduty_api.py,sha256=fcSAUez6w51woDvbm0plJW2qSw6_NXQs1Fit_KTNitc,7653
@@ -836,8 +837,8 @@ tools/test/test_app_interface_metrics_exporter.py,sha256=SX7qL3D1SIRKFo95FoQztvf
836
837
  tools/test/test_qontract_cli.py,sha256=_D61RFGAN5x44CY1tYbouhlGXXABwYfxKSWSQx3Jrss,4941
837
838
  tools/test/test_sd_app_sre_alert_report.py,sha256=v363r9zM7__0kR5K6mvJoGFcM9BvE33fWAayrqkpojA,2116
838
839
  tools/test/test_sre_checkpoints.py,sha256=SKqPPTl9ua0RFdSSofnoQX-JZE6dFLO3LRhfQzqtfh8,2607
839
- qontract_reconcile-0.10.1rc863.dist-info/METADATA,sha256=y5iyh4zPDNPYn2dGYtX6BC-ak-Fy21lC_RxGqXh0BjA,2273
840
- qontract_reconcile-0.10.1rc863.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
841
- qontract_reconcile-0.10.1rc863.dist-info/entry_points.txt,sha256=GKQqCl2j2X1BJQ69een6rHcR26PmnxnONLNOQB-nRjY,491
842
- qontract_reconcile-0.10.1rc863.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
843
- qontract_reconcile-0.10.1rc863.dist-info/RECORD,,
840
+ qontract_reconcile-0.10.1rc865.dist-info/METADATA,sha256=hKbGCp2EG6vMoZ5lyFHsA9xtBs6aXsG0AINh0fD9w5A,2273
841
+ qontract_reconcile-0.10.1rc865.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
842
+ qontract_reconcile-0.10.1rc865.dist-info/entry_points.txt,sha256=GKQqCl2j2X1BJQ69een6rHcR26PmnxnONLNOQB-nRjY,491
843
+ qontract_reconcile-0.10.1rc865.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
844
+ qontract_reconcile-0.10.1rc865.dist-info/RECORD,,
reconcile/cli.py CHANGED
@@ -3712,6 +3712,24 @@ def external_resources(
3712
3712
  )
3713
3713
 
3714
3714
 
3715
+ @integration.command(
3716
+ short_help="Syncs External Resources Secrets from Vault to Clusters"
3717
+ )
3718
+ @click.pass_context
3719
+ @threaded(default=5)
3720
+ def external_resources_secrets_sync(
3721
+ ctx,
3722
+ thread_pool_size: int,
3723
+ ):
3724
+ import reconcile.external_resources.integration_secrets_sync
3725
+
3726
+ run_integration(
3727
+ reconcile.external_resources.integration_secrets_sync,
3728
+ ctx.obj,
3729
+ thread_pool_size,
3730
+ )
3731
+
3732
+
3715
3733
  def get_integration_cli_meta() -> dict[str, IntegrationMeta]:
3716
3734
  """
3717
3735
  returns all integrations known to cli.py via click introspection
@@ -124,7 +124,12 @@ def run(
124
124
  dry_run_job_suffix=dry_run_job_suffix,
125
125
  ),
126
126
  secrets_reconciler=build_incluster_secrets_reconciler(
127
- workers_cluster, workers_namespace, secret_reader, vault_path="app-sre"
127
+ workers_cluster,
128
+ workers_namespace,
129
+ secret_reader,
130
+ vault_path=er_settings.vault_secrets_path,
131
+ thread_pool_size=thread_pool_size,
132
+ dry_run=dry_run,
128
133
  ),
129
134
  )
130
135
 
@@ -0,0 +1,47 @@
1
+ from reconcile.external_resources.model import (
2
+ ExternalResourcesInventory,
3
+ load_module_inventory,
4
+ )
5
+ from reconcile.external_resources.secrets_sync import VaultSecretsReconciler
6
+ from reconcile.typed_queries.app_interface_vault_settings import (
7
+ get_app_interface_vault_settings,
8
+ )
9
+ from reconcile.typed_queries.external_resources import (
10
+ get_modules,
11
+ get_namespaces,
12
+ get_settings,
13
+ )
14
+ from reconcile.utils.openshift_resource import ResourceInventory
15
+ from reconcile.utils.secret_reader import create_secret_reader
16
+ from reconcile.utils.semver_helper import make_semver
17
+
18
+ QONTRACT_INTEGRATION = "external_resources_secrets_sync"
19
+ QONTRACT_INTEGRATION_VERSION = make_semver(0, 1, 0)
20
+
21
+
22
+ def run(dry_run: bool, thread_pool_size: int) -> None:
23
+ """Integration that syncs External Resources Outputs Secrets from Vault into
24
+ the target clusters
25
+ """
26
+ vault_settings = get_app_interface_vault_settings()
27
+ secret_reader = create_secret_reader(use_vault=vault_settings.vault)
28
+ er_settings = get_settings()[0]
29
+
30
+ namespaces = [ns for ns in get_namespaces() if ns.external_resources]
31
+ er_inventory = ExternalResourcesInventory(namespaces)
32
+ m_inventory = load_module_inventory(get_modules())
33
+
34
+ to_sync_specs = [
35
+ spec
36
+ for key, spec in er_inventory.items()
37
+ if m_inventory.get_from_external_resource_key(key).outputs_secret_sync
38
+ ]
39
+
40
+ reconciler = VaultSecretsReconciler(
41
+ ri=ResourceInventory(),
42
+ secrets_reader=secret_reader,
43
+ vault_path=er_settings.vault_secrets_path,
44
+ thread_pool_size=thread_pool_size,
45
+ dry_run=dry_run,
46
+ )
47
+ reconciler.sync_secrets(to_sync_specs)
@@ -212,7 +212,7 @@ class ExternalResourcesManager:
212
212
  ResourceStatus.DELETE_IN_PROGRESS,
213
213
  ResourceStatus.IN_PROGRESS,
214
214
  ]):
215
- return need_secret_sync
215
+ return False
216
216
 
217
217
  logging.info(
218
218
  "Reconciliation In progress. Action: %s, Key:%s",
@@ -231,7 +231,7 @@ class ExternalResourcesManager:
231
231
  r.key,
232
232
  )
233
233
  if r.action == Action.APPLY:
234
- state.resource_status = ResourceStatus.CREATED
234
+ state.resource_status = ResourceStatus.PENDING_SECRET_SYNC
235
235
  state.reconciliation_errors = 0
236
236
  self.state_mgr.set_external_resource_state(state)
237
237
  need_secret_sync = True
@@ -275,9 +275,31 @@ class ExternalResourcesManager:
275
275
  r.action == Action.APPLY and state.resource_status == ResourceStatus.CREATED
276
276
  )
277
277
 
278
- def _sync_secrets(self, keys: Iterable[ExternalResourceKey]) -> None:
279
- specs = [spec for key in keys if (spec := self.er_inventory.get(key))]
280
- self.secrets_reconciler.sync_secrets(specs=specs)
278
+ def _sync_secrets(
279
+ self,
280
+ to_sync_keys: Iterable[ExternalResourceKey],
281
+ ) -> None:
282
+ specs = [
283
+ spec for key in set(to_sync_keys) if (spec := self.er_inventory.get(key))
284
+ ]
285
+
286
+ sync_error_spec_keys = {
287
+ ExternalResourceKey.from_spec(spec)
288
+ for spec in self.secrets_reconciler.sync_secrets(specs=specs)
289
+ }
290
+
291
+ for key in to_sync_keys:
292
+ if key in sync_error_spec_keys:
293
+ logging.error(
294
+ "Outputs secret for key can not be reconciled. Key: %s", key
295
+ )
296
+ else:
297
+ logging.debug(
298
+ "Outputs secret for key has been reconciled. Marking resource as %s. Key: %s",
299
+ ResourceStatus.CREATED,
300
+ key,
301
+ )
302
+ self.state_mgr.update_resource_status(key, ResourceStatus.CREATED)
281
303
 
282
304
  def _build_external_resource(
283
305
  self, spec: ExternalResourceSpec, er_inventory: ExternalResourcesInventory
@@ -295,12 +317,12 @@ class ExternalResourcesManager:
295
317
  def handle_resources(self) -> None:
296
318
  desired_r = self._get_desired_objects_reconciliations()
297
319
  deleted_r = self._get_deleted_objects_reconciliations()
298
- to_sync: set[ExternalResourceKey] = set()
320
+ to_sync_keys: set[ExternalResourceKey] = set()
299
321
  for r in desired_r.union(deleted_r):
300
322
  state = self.state_mgr.get_external_resource_state(r.key)
301
323
  need_sync = self._update_in_progress_state(r, state)
302
324
  if need_sync:
303
- to_sync.add(r.key)
325
+ to_sync_keys.add(r.key)
304
326
 
305
327
  metrics.set_gauge(
306
328
  ExternalResourcesReconcileErrorsGauge(
@@ -316,8 +338,12 @@ class ExternalResourcesManager:
316
338
  self.reconciler.reconcile_resource(reconciliation=r)
317
339
  self._update_state(r, state)
318
340
 
319
- if to_sync:
320
- self._sync_secrets(keys=to_sync)
341
+ pending_sync_keys = self.state_mgr.get_keys_by_status(
342
+ ResourceStatus.PENDING_SECRET_SYNC
343
+ )
344
+
345
+ if to_sync_keys or pending_sync_keys:
346
+ self._sync_secrets(to_sync_keys=to_sync_keys | pending_sync_keys)
321
347
 
322
348
  def handle_dry_run_resources(self) -> None:
323
349
  desired_r = self._get_desired_objects_reconciliations()
@@ -2,3 +2,12 @@ from reconcile.utils.semver_helper import make_semver
2
2
 
3
3
  QONTRACT_INTEGRATION = "external_resources"
4
4
  QONTRACT_INTEGRATION_VERSION = make_semver(0, 1, 0)
5
+
6
+
7
+ SECRET_ANN_PREFIX = "external-resources"
8
+ SECRET_ANN_PROVISION_PROVIDER = SECRET_ANN_PREFIX + "/provision_provider"
9
+ SECRET_ANN_PROVISIONER = SECRET_ANN_PREFIX + "/provisioner_name"
10
+ SECRET_ANN_PROVIDER = SECRET_ANN_PREFIX + "/provider"
11
+ SECRET_ANN_IDENTIFIER = SECRET_ANN_PREFIX + "/identifier"
12
+ SECRET_UPDATED_AT = SECRET_ANN_PREFIX + "/updated_at"
13
+ SECRET_UPDATED_AT_TIMEFORMAT = "%Y-%m-%dT%H:%M:%SZ"
@@ -1,17 +1,27 @@
1
1
  import base64
2
2
  import json
3
+ import logging
3
4
  from abc import abstractmethod
4
5
  from collections.abc import Iterable, Mapping
6
+ from datetime import datetime, timezone
5
7
  from hashlib import shake_128
6
- from typing import Any, Optional, cast
8
+ from typing import Any, Optional
7
9
 
8
10
  from pydantic import BaseModel
9
- from sretoolbox.utils import retry
11
+ from sretoolbox.utils import threaded
10
12
 
11
13
  from reconcile.external_resources.meta import (
12
14
  QONTRACT_INTEGRATION,
13
15
  QONTRACT_INTEGRATION_VERSION,
16
+ SECRET_ANN_IDENTIFIER,
17
+ SECRET_ANN_PROVIDER,
18
+ SECRET_ANN_PROVISION_PROVIDER,
19
+ SECRET_ANN_PROVISIONER,
20
+ SECRET_UPDATED_AT,
21
+ SECRET_UPDATED_AT_TIMEFORMAT,
14
22
  )
23
+ from reconcile.external_resources.model import ExternalResourceKey
24
+ from reconcile.openshift_base import ApplyOptions, apply_action
15
25
  from reconcile.typed_queries.clusters_minimal import get_clusters_minimal
16
26
  from reconcile.utils.differ import diff_mappings
17
27
  from reconcile.utils.external_resource_spec import (
@@ -22,7 +32,7 @@ from reconcile.utils.oc import (
22
32
  )
23
33
  from reconcile.utils.oc_map import OCMap, init_oc_map_from_clusters
24
34
  from reconcile.utils.openshift_resource import OpenshiftResource, ResourceInventory
25
- from reconcile.utils.secret_reader import SecretReaderBase
35
+ from reconcile.utils.secret_reader import SecretNotFound, SecretReaderBase
26
36
  from reconcile.utils.three_way_diff_strategy import three_way_diff_using_hash
27
37
  from reconcile.utils.vault import (
28
38
  VaultClient,
@@ -39,54 +49,103 @@ class VaultSecret(BaseModel):
39
49
  q_format: Optional[str]
40
50
 
41
51
 
52
+ class SecretHelper:
53
+ @staticmethod
54
+ def get_comparable_secret(resource: OpenshiftResource) -> OpenshiftResource:
55
+ metadata = {k: v for k, v in resource.body["metadata"].items()}
56
+ metadata["annotations"] = {
57
+ k: v
58
+ for k, v in metadata.get("annotations", {}).items()
59
+ if k != SECRET_UPDATED_AT
60
+ }
61
+ new = OpenshiftResource(
62
+ body=resource.body | {"metadata": metadata},
63
+ integration=resource.integration,
64
+ integration_version=resource.integration_version,
65
+ error_details=resource.error_details,
66
+ caller_name=resource.caller_name,
67
+ validate_k8s_object=False,
68
+ )
69
+
70
+ return new
71
+
72
+ @staticmethod
73
+ def is_newer(a: OpenshiftResource, b: OpenshiftResource) -> bool:
74
+ try:
75
+ # ISO8601 with the same TZ can be compared as strings.
76
+ return a.annotations[SECRET_UPDATED_AT] > b.annotations[SECRET_UPDATED_AT]
77
+ except (KeyError, ValueError) as e:
78
+ logging.debug("Error comparing timestamps %s", e)
79
+ return False
80
+
81
+ @staticmethod
82
+ def compare(current: OpenshiftResource, desired: OpenshiftResource) -> bool:
83
+ if SECRET_UPDATED_AT not in current.annotations:
84
+ logging.debug(
85
+ "Current does not have the optimistic locking annotation. Apply"
86
+ )
87
+ return False
88
+ # if current is newer; don't apply
89
+ if SecretHelper.is_newer(current, desired):
90
+ logging.debug("Current Secret is newer than Desired: Don't Apply")
91
+ return True
92
+ cmp_current = SecretHelper.get_comparable_secret(current)
93
+ cmp_desired = SecretHelper.get_comparable_secret(desired)
94
+
95
+ return three_way_diff_using_hash(cmp_current, cmp_desired)
96
+
97
+
42
98
  class SecretsReconciler:
43
99
  def __init__(
44
100
  self,
45
101
  ri: ResourceInventory,
46
102
  secrets_reader: SecretReaderBase,
47
- vault_path: str,
48
- vault_client: VaultClient,
103
+ thread_pool_size: int,
104
+ dry_run: bool,
49
105
  ) -> None:
50
106
  self.secrets_reader = secrets_reader
51
107
  self.ri = ri
52
- self.vault_path = vault_path
53
- self.vault_client = cast(_VaultClient, vault_client)
108
+ self.thread_pool_size = thread_pool_size
109
+ self.dry_run = dry_run
54
110
 
55
111
  @abstractmethod
56
112
  def _populate_secret_data(self, specs: Iterable[ExternalResourceSpec]) -> None:
57
113
  raise NotImplementedError()
58
114
 
59
- def _populate_annotations(self, spec: ExternalResourceSpec) -> None:
115
+ def _annotate(self, spec: ExternalResourceSpec) -> None:
60
116
  try:
61
117
  annotations = json.loads(spec.resource["annotations"])
62
118
  except Exception:
63
119
  annotations = {}
64
-
65
- annotations["provision_provider"] = spec.provision_provider
66
- annotations["provisioner"] = spec.provisioner_name
67
- annotations["provider"] = spec.provider
68
- annotations["identifier"] = spec.identifier
69
-
120
+ annotations[SECRET_ANN_PROVISION_PROVIDER] = spec.provision_provider
121
+ annotations[SECRET_ANN_PROVISIONER] = spec.provisioner_name
122
+ annotations[SECRET_ANN_PROVIDER] = spec.provider
123
+ annotations[SECRET_ANN_IDENTIFIER] = spec.identifier
124
+ annotations[SECRET_UPDATED_AT] = spec.metadata[SECRET_UPDATED_AT]
70
125
  spec.resource["annotations"] = json.dumps(annotations)
71
126
 
72
- def _initialize_ri(
127
+ def _specs_with_secret(
73
128
  self,
74
- ri: ResourceInventory,
75
129
  specs: Iterable[ExternalResourceSpec],
130
+ ) -> Iterable[ExternalResourceSpec]:
131
+ return [spec for spec in specs if spec.secret]
132
+
133
+ def _add_secret_to_ri(
134
+ self,
135
+ spec: ExternalResourceSpec,
76
136
  ) -> None:
77
- for spec in specs:
78
- ri.initialize_resource_type(
79
- spec.cluster_name, spec.namespace_name, "Secret"
80
- )
81
- ri.add_desired(
82
- spec.cluster_name,
83
- spec.namespace_name,
84
- "Secret",
85
- name=spec.output_resource_name,
86
- value=spec.build_oc_secret(
87
- QONTRACT_INTEGRATION, QONTRACT_INTEGRATION_VERSION
88
- ),
89
- )
137
+ self.ri.initialize_resource_type(
138
+ spec.cluster_name, spec.namespace_name, "Secret"
139
+ )
140
+ self.ri.add_desired(
141
+ spec.cluster_name,
142
+ spec.namespace_name,
143
+ "Secret",
144
+ name=spec.output_resource_name,
145
+ value=spec.build_oc_secret(
146
+ QONTRACT_INTEGRATION, QONTRACT_INTEGRATION_VERSION
147
+ ).annotate(),
148
+ )
90
149
 
91
150
  def _init_ocmap(self, specs: Iterable[ExternalResourceSpec]) -> OCMap:
92
151
  return init_oc_map_from_clusters(
@@ -99,54 +158,117 @@ class SecretsReconciler:
99
158
  integration=QONTRACT_INTEGRATION,
100
159
  )
101
160
 
102
- @retry()
103
- def _write_secrets_to_vault(self, spec: ExternalResourceSpec) -> None:
104
- if spec.secret:
105
- secret_path = f"{self.vault_path}/{QONTRACT_INTEGRATION}/{spec.cluster_name}/{spec.namespace_name}/{spec.output_resource_name}"
106
- stringified_secret = {k: str(v) for k, v in spec.secret.items()}
107
- desired_secret = {"path": secret_path, "data": stringified_secret}
108
- self.vault_client.write(desired_secret, decode_base64=False)
161
+ def sync_secrets(
162
+ self, specs: Iterable[ExternalResourceSpec]
163
+ ) -> list[ExternalResourceSpec]:
164
+ """Sync outputs secrets to target clusters.
165
+ Logic:
166
+ Vault To Cluster:
167
+ If current is newer; don't apply.
168
+ If other changes; apply and Recycle Pods
169
+ Desired can not be newer than current.
170
+ External reosurce to Cluster (last reconciliation):
171
+ If updated_at annotation is the only change; Don't update
172
+ If other changes; Update Secret and Recycle Pods
173
+ Current can not be newer then Desired
109
174
 
110
- def sync_secrets(self, specs: Iterable[ExternalResourceSpec]) -> None:
175
+ :param specs: Specs that need sync the outputs secret to the target cluster
176
+ :return: specs that produced errors when syncing secrets to clusters.
177
+ """
111
178
  self._populate_secret_data(specs)
112
- ri = ResourceInventory()
113
- self._initialize_ri(ri, specs)
114
- ocmap = self._init_ocmap(specs)
115
- for item in ri:
116
- self.reconcile_data(item, ri, ocmap)
179
+
180
+ to_sync_specs = [spec for spec in self._specs_with_secret(specs)]
181
+ ocmap = self._init_ocmap(to_sync_specs)
182
+
183
+ for spec in to_sync_specs:
184
+ self._annotate(spec)
185
+ self._add_secret_to_ri(spec)
186
+
187
+ threaded.run(
188
+ self.reconcile_data,
189
+ self.ri,
190
+ thread_pool_size=self.thread_pool_size,
191
+ ocmap=ocmap,
192
+ )
193
+
194
+ if self.ri.has_error_registered():
195
+ # Return all specs as error if there are errors.
196
+ # There is no a clear way to kwno which specs failed.
197
+ return list(specs)
198
+ else:
199
+ return []
117
200
 
118
201
  def reconcile_data(
119
202
  self,
120
203
  ri_item: tuple[str, str, str, Mapping[str, Any]],
121
- ri: ResourceInventory,
122
204
  ocmap: OCMap,
123
205
  ) -> None:
124
206
  cluster, namespace, kind, data = ri_item
125
207
  oc = ocmap.get_cluster(cluster)
126
208
  names = list(data["desired"].keys())
127
209
 
210
+ logging.debug(
211
+ "Getting Secrets from cluster/namespace %s/%s", cluster, namespace
212
+ )
128
213
  items = oc.get_items("Secret", namespace=namespace, resource_names=names)
214
+
129
215
  for item in items:
130
- obj = OpenshiftResource(
216
+ current = OpenshiftResource(
131
217
  body=item,
132
218
  integration=QONTRACT_INTEGRATION,
133
219
  integration_version=QONTRACT_INTEGRATION_VERSION,
134
220
  )
135
- ri.add_current(cluster, namespace, kind, name=obj.name, value=obj)
221
+
222
+ self.ri.add_current(
223
+ cluster, namespace, kind, name=current.name, value=current
224
+ )
136
225
 
137
226
  diff = diff_mappings(
138
- data["current"], data["desired"], equal=three_way_diff_using_hash
227
+ data["current"], data["desired"], equal=SecretHelper.compare
139
228
  )
140
- items_to_update = [i.desired for i in diff.change.values()] + list(
229
+
230
+ items_to_update = [item.desired for item in diff.change.values()] + list(
141
231
  diff.add.values()
142
232
  )
143
- self.apply_action(oc, namespace, items_to_update)
233
+
234
+ self.apply_action(ocmap, cluster, namespace, items_to_update)
144
235
 
145
236
  def apply_action(
146
- self, oc: OCCli, namespace: str, items: Iterable[OpenshiftResource]
237
+ self,
238
+ ocmap: OCMap,
239
+ cluster: str,
240
+ namespace: str,
241
+ items: Iterable[OpenshiftResource],
147
242
  ) -> None:
148
- for i in items:
149
- oc.apply(namespace, resource=i)
243
+ options = ApplyOptions(
244
+ dry_run=self.dry_run,
245
+ no_dry_run_skip_compare=False,
246
+ wait_for_namespace=False,
247
+ recycle_pods=True,
248
+ take_over=False,
249
+ override_enable_deletion=False,
250
+ caller=None,
251
+ all_callers=None,
252
+ privileged=None,
253
+ enable_deletion=None,
254
+ )
255
+ for item in items:
256
+ logging.debug(
257
+ "Updating Secret Cluster: %s, Namespace: %s, Secret: %s",
258
+ cluster,
259
+ namespace,
260
+ item.name,
261
+ )
262
+
263
+ apply_action(
264
+ ocmap,
265
+ self.ri,
266
+ cluster,
267
+ namespace,
268
+ "Secret",
269
+ item,
270
+ options=options,
271
+ )
150
272
 
151
273
 
152
274
  class InClusterSecretsReconciler(SecretsReconciler):
@@ -159,56 +281,105 @@ class InClusterSecretsReconciler(SecretsReconciler):
159
281
  cluster: str,
160
282
  namespace: str,
161
283
  oc: OCCli,
284
+ thread_pool_size: int,
285
+ dry_run: bool,
162
286
  ):
163
- super().__init__(ri, secrets_reader, vault_path, vault_client)
287
+ super().__init__(ri, secrets_reader, thread_pool_size, dry_run)
164
288
 
165
289
  self.cluster = cluster
166
290
  self.namespace = namespace
167
291
  self.oc = oc
168
292
  self.source_secrets: list[str] = []
293
+ self.vault_client = vault_client
294
+ self.vault_path = vault_path
169
295
 
170
296
  def _get_spec_hash(self, spec: ExternalResourceSpec) -> str:
171
297
  secret_key = f"{spec.provision_provider}-{spec.provisioner_name}-{spec.provider}-{spec.identifier}"
172
298
  return shake_128(secret_key.encode("utf-8")).hexdigest(16)
173
299
 
300
+ def _get_spec_outputs_secret_name(self, spec: ExternalResourceSpec) -> str:
301
+ return "external-resources-output-" + self._get_spec_hash(spec)
302
+
174
303
  def _populate_secret_data(self, specs: Iterable[ExternalResourceSpec]) -> None:
175
304
  if not specs:
176
305
  return
177
- secrets_map = {
178
- "external-resources-output-" + self._get_spec_hash(spec): spec
179
- for spec in specs
180
- }
306
+
307
+ secrets_map = {self._get_spec_outputs_secret_name(spec): spec for spec in specs}
308
+
181
309
  secrets = self.oc.get_items(
182
310
  "Secret", namespace=self.namespace, resource_names=list(secrets_map.keys())
183
311
  )
312
+
184
313
  for secret in secrets:
185
314
  secret_name = secret["metadata"]["name"]
186
315
  spec = secrets_map[secret_name]
187
316
  data = dict[str, str]()
188
317
  for k, v in secret["data"].items():
189
318
  decoded = base64.b64decode(v).decode("utf-8")
319
+
190
320
  if decoded.startswith("__vault__:"):
191
321
  _secret_ref = json.loads(decoded.replace("__vault__:", ""))
192
322
  secret_ref = VaultSecret(**_secret_ref)
193
323
  data[k] = self.secrets_reader.read_secret(secret_ref)
194
324
  else:
195
325
  data[k] = decoded
326
+
327
+ spec.metadata[SECRET_UPDATED_AT] = datetime.now(timezone.utc).strftime(
328
+ SECRET_UPDATED_AT_TIMEFORMAT
329
+ )
196
330
  spec.secret = data
197
331
 
198
- self.source_secrets = list(secrets_map.keys())
332
+ def _delete_source_secret(self, spec: ExternalResourceSpec) -> None:
333
+ secret_name = self._get_spec_outputs_secret_name(spec)
334
+ logging.debug("Deleting secret " + secret_name)
335
+ self.oc.delete(namespace=self.namespace, kind="Secret", name=secret_name)
336
+
337
+ def _write_secret_to_vault(self, spec: ExternalResourceSpec) -> None:
338
+ secret_path = f"{self.vault_path}/{spec.cluster_name}/{spec.namespace_name}/{spec.identifier}"
339
+ secret = {k: str(v) for k, v in spec.secret.items()}
340
+ secret[SECRET_UPDATED_AT] = spec.metadata[SECRET_UPDATED_AT]
341
+ desired_secret = {"path": secret_path, "data": secret}
342
+ self.vault_client.write(desired_secret, decode_base64=False) # type: ignore[attr-defined]
199
343
 
200
- def _delete_source_secrets(self) -> None:
201
- for secret_name in self.source_secrets:
202
- print("Deleting secret " + secret_name)
203
- self.oc.delete(namespace=self.namespace, kind="Secret", name=secret_name)
344
+ def sync_secrets(
345
+ self, specs: Iterable[ExternalResourceSpec]
346
+ ) -> list[ExternalResourceSpec]:
347
+ try:
348
+ specs_with_error = super().sync_secrets(specs)
349
+ except Exception as e:
350
+ # There is no an easy way to map which secrets have not been reconciled with the specs. If the sync
351
+ # fails at this stage all the involved specs will be retried in the next iteration
352
+ logging.error(
353
+ "Error syncing Secrets to clusters. "
354
+ "All specs reconciled in this iteration are marked as pending secret synchronization\n%s",
355
+ e,
356
+ )
357
+ return list(specs)
358
+
359
+ for spec in self._specs_with_secret(specs):
360
+ try:
361
+ self._write_secret_to_vault(spec)
362
+ self._delete_source_secret(spec)
363
+ except Exception as e:
364
+ key = ExternalResourceKey.from_spec(spec)
365
+ logging.error(
366
+ "Error writing Secret to Vault or deleting the source secret: Key: %s, Secret: %s\n%s",
367
+ key,
368
+ self._get_spec_outputs_secret_name(spec),
369
+ e,
370
+ )
371
+ specs_with_error.append(spec)
204
372
 
205
- def sync_secrets(self, specs: Iterable[ExternalResourceSpec]) -> None:
206
- super().sync_secrets(specs)
207
- self._delete_source_secrets()
373
+ return specs_with_error
208
374
 
209
375
 
210
376
  def build_incluster_secrets_reconciler(
211
- cluster: str, namespace: str, secrets_reader: SecretReaderBase, vault_path: str
377
+ cluster: str,
378
+ namespace: str,
379
+ secrets_reader: SecretReaderBase,
380
+ vault_path: str,
381
+ thread_pool_size: int,
382
+ dry_run: bool,
212
383
  ) -> InClusterSecretsReconciler:
213
384
  ri = ResourceInventory()
214
385
  ocmap = init_oc_map_from_clusters(
@@ -217,13 +388,42 @@ def build_incluster_secrets_reconciler(
217
388
  integration=QONTRACT_INTEGRATION,
218
389
  )
219
390
  oc = ocmap.get_cluster(cluster)
220
- vault_client = VaultClient()
221
391
  return InClusterSecretsReconciler(
222
392
  cluster=cluster,
223
393
  namespace=namespace,
224
394
  ri=ri,
225
395
  oc=oc,
226
396
  vault_path=vault_path,
227
- vault_client=vault_client,
397
+ vault_client=VaultClient(),
228
398
  secrets_reader=secrets_reader,
399
+ thread_pool_size=thread_pool_size,
400
+ dry_run=dry_run,
229
401
  )
402
+
403
+
404
+ class VaultSecretsReconciler(SecretsReconciler):
405
+ def __init__(
406
+ self,
407
+ ri: ResourceInventory,
408
+ secrets_reader: SecretReaderBase,
409
+ vault_path: str,
410
+ thread_pool_size: int,
411
+ dry_run: bool,
412
+ ):
413
+ super().__init__(ri, secrets_reader, thread_pool_size, dry_run)
414
+ self.secrets_reader = secrets_reader
415
+ self.vault_path = vault_path
416
+
417
+ def _populate_secret_data(self, specs: Iterable[ExternalResourceSpec]) -> None:
418
+ threaded.run(self._read_secret, specs, self.thread_pool_size)
419
+
420
+ def _read_secret(self, spec: ExternalResourceSpec) -> None:
421
+ secret_path = f"{self.vault_path}/{spec.cluster_name}/{spec.namespace_name}/{spec.identifier}"
422
+ try:
423
+ logging.debug("Reading Secret %s", secret_path)
424
+ data = self.secrets_reader.read_all({"path": secret_path})
425
+ spec.metadata[SECRET_UPDATED_AT] = data[SECRET_UPDATED_AT]
426
+ del data[SECRET_UPDATED_AT]
427
+ spec.secret = data
428
+ except SecretNotFound:
429
+ logging.info("Error getting secret from vault, skipping. [%s]", secret_path)
@@ -35,6 +35,7 @@ class ResourceStatus(str, Enum):
35
35
  IN_PROGRESS: str = "IN_PROGRESS"
36
36
  DELETE_IN_PROGRESS: str = "DELETE_IN_PROGRESS"
37
37
  ERROR: str = "ERROR"
38
+ PENDING_SECRET_SYNC: str = "PENDING_SECRET_SYNC"
38
39
 
39
40
 
40
41
  class ExternalResourceState(BaseModel):
@@ -236,6 +237,15 @@ class ExternalResourcesStateDynamoDB:
236
237
  def get_all_resource_keys(self) -> set[ExternalResourceKey]:
237
238
  return {k for k in self.partial_resources.keys()}
238
239
 
240
+ def get_keys_by_status(
241
+ self, resource_status: ResourceStatus
242
+ ) -> set[ExternalResourceKey]:
243
+ return {
244
+ k
245
+ for k, v in self.partial_resources.items()
246
+ if v.resource_status == resource_status
247
+ }
248
+
239
249
  def update_resource_status(
240
250
  self, key: ExternalResourceKey, status: ResourceStatus
241
251
  ) -> None:
@@ -77,9 +77,11 @@ def share_project_with_group_members(
77
77
 
78
78
 
79
79
  def share_project_with_group(gl: GitLabApi, repos: list[str], dry_run: bool) -> None:
80
+ # get repos not owned by app-sre
81
+ non_app_sre_projects = {repo for repo in repos if "/app-sre/" not in repo}
80
82
  group_id, shared_projects = gl.get_group_id_and_shared_projects(APP_SRE_GROUP_NAME)
81
83
  shared_project_repos = {project["web_url"] for project in shared_projects}
82
- repos_to_share = set(repos) - shared_project_repos
84
+ repos_to_share = non_app_sre_projects - shared_project_repos
83
85
  for repo in repos_to_share:
84
86
  gl.share_project_with_group(repo_url=repo, group_id=group_id, dry_run=dry_run)
85
87
 
@@ -28,6 +28,7 @@ query ExternalResourcesModules {
28
28
  default_version
29
29
  reconcile_drift_interval_minutes
30
30
  reconcile_timeout_minutes
31
+ outputs_secret_sync
31
32
  }
32
33
  }
33
34
  """
@@ -47,6 +48,7 @@ class ExternalResourcesModuleV1(ConfiguredBaseModel):
47
48
  default_version: str = Field(..., alias="default_version")
48
49
  reconcile_drift_interval_minutes: str = Field(..., alias="reconcile_drift_interval_minutes")
49
50
  reconcile_timeout_minutes: str = Field(..., alias="reconcile_timeout_minutes")
51
+ outputs_secret_sync: bool = Field(..., alias="outputs_secret_sync")
50
52
 
51
53
 
52
54
  class ExternalResourcesModulesQueryData(ConfiguredBaseModel):
@@ -35,6 +35,7 @@ query ExternalResourcesSettings {
35
35
  tf_state_bucket
36
36
  tf_state_region
37
37
  tf_state_dynamodb_table
38
+ vault_secrets_path
38
39
  }
39
40
  }
40
41
  """
@@ -67,6 +68,7 @@ class ExternalResourcesSettingsV1(ConfiguredBaseModel):
67
68
  tf_state_bucket: Optional[str] = Field(..., alias="tf_state_bucket")
68
69
  tf_state_region: Optional[str] = Field(..., alias="tf_state_region")
69
70
  tf_state_dynamodb_table: Optional[str] = Field(..., alias="tf_state_dynamodb_table")
71
+ vault_secrets_path: str = Field(..., alias="vault_secrets_path")
70
72
 
71
73
 
72
74
  class ExternalResourcesSettingsQueryData(ConfiguredBaseModel):
@@ -87,6 +87,11 @@ class ExternalResourceSpec:
87
87
  resource: MutableMapping[str, Any]
88
88
  namespace: Mapping[str, Any]
89
89
  secret: Mapping[str, str] = field(init=False, default_factory=lambda: {})
90
+ # Metadata is used for processing data that shuold not be included in the secret data
91
+ # e.g: ERV2 adds a updated_at attribute that acts as optimistic lock.
92
+ metadata: MutableMapping[str, str] = field(
93
+ init=False, compare=False, repr=False, hash=False, default_factory=lambda: {}
94
+ )
90
95
 
91
96
  @property
92
97
  def provider(self) -> str:
@@ -14,6 +14,7 @@ from typing import (
14
14
  import semver
15
15
  from pydantic import BaseModel
16
16
 
17
+ from reconcile.external_resources.meta import SECRET_UPDATED_AT
17
18
  from reconcile.utils.metrics import GaugeMetric
18
19
 
19
20
  SECRET_MAX_KEY_LENGTH = 253
@@ -526,6 +527,9 @@ class OpenshiftResource:
526
527
  # remove qontract specific params
527
528
  for a in QONTRACT_ANNOTATIONS:
528
529
  annotations.pop(a, None)
530
+
531
+ # Remove external resources annotation used for optimistic locking
532
+ annotations.pop(SECRET_UPDATED_AT, None)
529
533
  return body
530
534
 
531
535
  @staticmethod