qontract-reconcile 0.10.1rc816__py3-none-any.whl → 0.10.1rc818__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.1rc816
3
+ Version: 0.10.1rc818
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=laiCfiVjXsDnT5z18q_XGkH6tZdBSxKVva7nF4B0OQw,101452
13
+ reconcile/cli.py,sha256=UnVVYcG6lqGAhUcXXCUsxw-eVlU4AyHjI5fAE4E_4DQ,101994
14
14
  reconcile/closedbox_endpoint_monitoring_base.py,sha256=SMhkcQqprWvThrIJa3U_3uh5w1h-alleW1QnCJFY4Qw,4909
15
15
  reconcile/cluster_deployment_mapper.py,sha256=2Ah-nu-Mdig0pjuiZl_XLrmVAjYzFjORR3dMlCgkmw0,2352
16
16
  reconcile/dashdotdb_base.py,sha256=a5aPLVxyqPSbjdB0Ty-uliOtxwvEbbEljHJKxdK3-Zk,4813
@@ -376,6 +376,8 @@ reconcile/gql_definitions/terraform_resources/database_access_manager.py,sha256=
376
376
  reconcile/gql_definitions/terraform_resources/terraform_resources_namespaces.py,sha256=bY53hFt1Ki0oqjP03OX4nT80zdD29YwvnlKW3Df7zs8,42401
377
377
  reconcile/gql_definitions/terraform_tgw_attachments/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
378
378
  reconcile/gql_definitions/terraform_tgw_attachments/aws_accounts.py,sha256=GjCuLHOgm3eHbkpK7Q2i7l6tori5Y62uFlz3M89BYtA,2602
379
+ reconcile/gql_definitions/unleash_feature_toggles/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
380
+ reconcile/gql_definitions/unleash_feature_toggles/feature_toggles.py,sha256=AJsLOy2AhiMbfhoRPEwNF39FEKfg6Snwfi4CXscBZ2I,3227
379
381
  reconcile/gql_definitions/vault_instances/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
380
382
  reconcile/gql_definitions/vault_instances/vault_instances.py,sha256=8G26YFo1rqRkrPuQJzgEkZt9KiU1PRx3qyw3LMI5ZMI,7515
381
383
  reconcile/gql_definitions/vault_policies/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -538,7 +540,6 @@ reconcile/test/test_terraform_users.py,sha256=XOAfGvITCJPI1LTlISmHbA4ONMQMkxYUMT
538
540
  reconcile/test/test_terraform_vpc_peerings.py,sha256=ubcsKh0TrUIwuI1-W3ETIgzsFvzAyeoFmEJFC-IK6JY,20538
539
541
  reconcile/test/test_terraform_vpc_peerings_build_desired_state.py,sha256=DAfpb12I0PlqnuVUHK2vh4LH4d1OylT3H2GE_3TGZZI,47852
540
542
  reconcile/test/test_three_way_diff_strategy.py,sha256=2fjEqE2w4pIzKq18PRcADTSe01aGwsZfMGloU8xfNaE,3346
541
- reconcile/test/test_unleash.py,sha256=krPgOVmwTE6lb773040Ely9BPbNYOeOIY0_8BK72dgo,6690
542
543
  reconcile/test/test_utils_jinja2.py,sha256=TpzQlpFnLGzNEZp5WOh0o7AuBiGEktqO4MuwiiJW2YY,3895
543
544
  reconcile/test/test_vault_replication.py,sha256=wlc4jm9f8P641UvvxIFFFc5_unJysNkOVrKJscjhQr0,16867
544
545
  reconcile/test/test_vault_utils.py,sha256=vbJnc89XAuE07qbTuWxHM5o9F6R9SO5aHXA38fwxT7A,1122
@@ -600,6 +601,8 @@ reconcile/typed_queries/cost_report/cost_namespaces.py,sha256=VXIqdGE5lwa5z4UTRO
600
601
  reconcile/typed_queries/cost_report/settings.py,sha256=xbTMMUQnbub2pav4B-ctzzRe7ijjTv2bqfqdtb9OnO0,589
601
602
  reconcile/typed_queries/terraform_tgw_attachments/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
602
603
  reconcile/typed_queries/terraform_tgw_attachments/aws_accounts.py,sha256=T5HSeyBcGKP-LzDmIzs-WlBwOtSenYpm0Odw5--xdOg,410
604
+ reconcile/unleash_feature_toggles/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
605
+ reconcile/unleash_feature_toggles/integration.py,sha256=FLSgqSobefNUWH5ZWpTizbNeC9bkX65xmg-mjfsiENc,10975
603
606
  reconcile/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
604
607
  reconcile/utils/aggregated_list.py,sha256=pkYoBj7WwmaNgEefETqEOFTnQMcUzHE3mdsVdzGYj60,3372
605
608
  reconcile/utils/amtool.py,sha256=Rg6_TTvuS7M2QbOkIjy2sRetvf6aNVdv_DA9ugSgMi4,2303
@@ -663,7 +666,7 @@ reconcile/utils/promtool.py,sha256=kT2rFZSBaRqW7SSHAuYzGZzQxM5Dzk8KW1NnEUYZU_s,2
663
666
  reconcile/utils/quay_api.py,sha256=EuOegpb-7ntEjkKLFwM2Oo4Nw7SyFtmyl3sQ9aXMtrM,8152
664
667
  reconcile/utils/raw_github_api.py,sha256=ZHC-SZuAyRe1zaMoOU7Krt1-zecDxENd9c_NzQYqK9g,2968
665
668
  reconcile/utils/repo_owners.py,sha256=j-pUjc9PuDzq7KpjNLpnhqfU8tUG4nj2WMhFp4ick7g,6629
666
- reconcile/utils/rest_api_base.py,sha256=X5o4idyRCDzwnF5xFwmjyoaHmM1tXSZnykTA54Z7D2Q,4006
669
+ reconcile/utils/rest_api_base.py,sha256=jezb2MfIxf83HNSGbNNBStuEmVuVUuwFQnRmZsQaq6w,4297
667
670
  reconcile/utils/ruamel.py,sha256=FzL4_L0FnMOUZmgThrZSMJs5MTdXwiy-E9MZWfk8bh8,397
668
671
  reconcile/utils/secret_reader.py,sha256=7g4TuBxkOl2NgsuZUCRcdI_hKLP3JhXlY1byBSxWU3A,10305
669
672
  reconcile/utils/semver_helper.py,sha256=-WfPOMSA2v1h7hT3PwVf-Htg7wOsoKlQC1JdmDX2Ars,1268
@@ -678,7 +681,6 @@ reconcile/utils/terraform_client.py,sha256=mZEKpu6nbfiQd60wRkc8-5sljBTUTOgaAKnF8
678
681
  reconcile/utils/terrascript_aws_client.py,sha256=VlvIHgrZRiMFVgx6a8ZHxoiJoDwqbtOmsZFnwwNrdL0,273199
679
682
  reconcile/utils/three_way_diff_strategy.py,sha256=nyqeQsLCoPI6e16k2CF3b9KNgQLU-rPf5RtfdUfVMwE,4468
680
683
  reconcile/utils/throughput.py,sha256=iP4UWAe2LVhDo69mPPmgo9nQ7RxHD6_GS8MZe-aSiuM,344
681
- reconcile/utils/unleash.py,sha256=1D56CsZfE3ShDtN3IErE1T2eeIwNmxhK-yYbCotJ99E,3601
682
684
  reconcile/utils/vault.py,sha256=AYGG5aDJ7CSVhTFdZowfEg3iSQWenoAt676aGjHQMX8,14978
683
685
  reconcile/utils/vaultsecretref.py,sha256=3Ed2uBy36TzSvL0B-l4FoWQqB2SbBKDKEuUPIO608Bo,931
684
686
  reconcile/utils/vcs.py,sha256=iiAWQXNftKIRoakXEOPT6ubB_ybSuInIQ6jcMxa_NKk,8558
@@ -771,6 +773,9 @@ reconcile/utils/terrascript/cloudflare_client.py,sha256=2g_DYiWP4-k8KDxz9Nnxrk7Z
771
773
  reconcile/utils/terrascript/cloudflare_resources.py,sha256=quyHhbexW4Y2ksVyEouV0SVFtZLs42xPoELK4LvzAqw,16043
772
774
  reconcile/utils/terrascript/models.py,sha256=x9HReI0k71MHBpRTvvmPlE0G6rri5GTzPXM9cqyTWm0,475
773
775
  reconcile/utils/terrascript/resources.py,sha256=bQzglnO41KZZEIeXYgi-qlup1p8R03Qyx_V944LRPsc,1391
776
+ reconcile/utils/unleash/__init__.py,sha256=ayCrW7Cw9dxQYq56QwrjtTcCnMp0xAHr1R7re9jo6RM,219
777
+ reconcile/utils/unleash/client.py,sha256=RBVafhOUjdPo7T9TIlTpS6ABogaxfimYj5-3FyOOLts,3551
778
+ reconcile/utils/unleash/server.py,sha256=iq5bB6SY1ZWxDD6v19ym2TmCrbQr7fi8dCLVL9_FHV4,4438
774
779
  release/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
775
780
  release/test_version.py,sha256=4DKxDZkT0aoS6ibZFsH2fd_wNETij1qfn2pCgQtVCGo,2093
776
781
  release/version.py,sha256=cwKdXBxraLokNeid2hk-3jsR9bRP_Ku-0Eve7o5E6q0,4032
@@ -779,7 +784,7 @@ tools/app_interface_metrics_exporter.py,sha256=zkwkxdAUAxjdc-pzx2_oJXG25fo0Fnyd5
779
784
  tools/app_interface_reporter.py,sha256=upA-J-n-HXHKVDINRuMR7vTt-iJvQORKUVi9D3leQto,17738
780
785
  tools/glitchtip_access_reporter.py,sha256=oPBnk_YoDuljU3v0FaChzOwwnk4vap1xEE67QEjzdqs,2948
781
786
  tools/glitchtip_access_revalidation.py,sha256=8kbBJk04mkq28kWoRDDkfCGIF3GRg3pJrFAh1sW0dbk,2821
782
- tools/qontract_cli.py,sha256=tAM6OXJkPnbSvZWOuhWQs9oKpL6Fn7qMEEirzCeuUY8,115006
787
+ tools/qontract_cli.py,sha256=yBZSb2WoafCrJllcDREqhU8gN_5D_SThA2XxuLO9TEU,115172
783
788
  tools/sd_app_sre_alert_report.py,sha256=e9vAdyenUz2f5c8-z-5WY0wv-SJ9aePKDH2r4IwB6pc,5063
784
789
  tools/template_validation.py,sha256=-U-lTGeLaci8yWPEblCJeev2DOlY1jM9QOOh-O1zts8,3376
785
790
  tools/cli_commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -806,8 +811,8 @@ tools/test/test_app_interface_metrics_exporter.py,sha256=SX7qL3D1SIRKFo95FoQztvf
806
811
  tools/test/test_qontract_cli.py,sha256=_D61RFGAN5x44CY1tYbouhlGXXABwYfxKSWSQx3Jrss,4941
807
812
  tools/test/test_sd_app_sre_alert_report.py,sha256=v363r9zM7__0kR5K6mvJoGFcM9BvE33fWAayrqkpojA,2116
808
813
  tools/test/test_sre_checkpoints.py,sha256=SKqPPTl9ua0RFdSSofnoQX-JZE6dFLO3LRhfQzqtfh8,2607
809
- qontract_reconcile-0.10.1rc816.dist-info/METADATA,sha256=0QOFfcd_sODX-xn2jp2xwRVg-Lc3NEY3eCbA6roBlUg,2314
810
- qontract_reconcile-0.10.1rc816.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
811
- qontract_reconcile-0.10.1rc816.dist-info/entry_points.txt,sha256=GKQqCl2j2X1BJQ69een6rHcR26PmnxnONLNOQB-nRjY,491
812
- qontract_reconcile-0.10.1rc816.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
813
- qontract_reconcile-0.10.1rc816.dist-info/RECORD,,
814
+ qontract_reconcile-0.10.1rc818.dist-info/METADATA,sha256=VBhOHCd2GQlM3ZcA-6Jp7B6ApHslI8WL2mb42eBBDSg,2314
815
+ qontract_reconcile-0.10.1rc818.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
816
+ qontract_reconcile-0.10.1rc818.dist-info/entry_points.txt,sha256=GKQqCl2j2X1BJQ69een6rHcR26PmnxnONLNOQB-nRjY,491
817
+ qontract_reconcile-0.10.1rc818.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
818
+ qontract_reconcile-0.10.1rc818.dist-info/RECORD,,
reconcile/cli.py CHANGED
@@ -3562,6 +3562,23 @@ def acs_notifiers(ctx):
3562
3562
  )
3563
3563
 
3564
3564
 
3565
+ @integration.command(short_help="Manage Unleash feature toggles.")
3566
+ @click.option("--instance", help="Reconcile just this Unlash instance.", default=None)
3567
+ @click.pass_context
3568
+ def unleash_feature_toggles(ctx, instance):
3569
+ from reconcile.unleash_feature_toggles.integration import (
3570
+ UnleashTogglesIntegration,
3571
+ UnleashTogglesIntegrationParams,
3572
+ )
3573
+
3574
+ run_class_integration(
3575
+ integration=UnleashTogglesIntegration(
3576
+ UnleashTogglesIntegrationParams(instance=instance)
3577
+ ),
3578
+ ctx=ctx.obj,
3579
+ )
3580
+
3581
+
3565
3582
  @integration.command(short_help="Automate Deadmanssnitch Creation/Deletion")
3566
3583
  @click.pass_context
3567
3584
  def deadmanssnitch(ctx):
@@ -0,0 +1,111 @@
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 UnleashFeatureToggles {
32
+ instances: unleash_instances_v1 {
33
+ name
34
+ url
35
+ adminToken {
36
+ ...VaultSecret
37
+ }
38
+ allowUnmanagedFeatureToggles
39
+ projects {
40
+ name
41
+ feature_toggles {
42
+ name
43
+ description
44
+ delete
45
+ provider
46
+ unleash {
47
+ type
48
+ impressionData
49
+ environments
50
+ }
51
+ }
52
+ }
53
+ }
54
+ }
55
+ """
56
+
57
+
58
+ class ConfiguredBaseModel(BaseModel):
59
+ class Config:
60
+ smart_union=True
61
+ extra=Extra.forbid
62
+
63
+
64
+ class UnleashFeatureToggleV1(ConfiguredBaseModel):
65
+ q_type: Optional[str] = Field(..., alias="type")
66
+ impression_data: Optional[bool] = Field(..., alias="impressionData")
67
+ environments: Optional[Json] = Field(..., alias="environments")
68
+
69
+
70
+ class FeatureToggleUnleashV1(ConfiguredBaseModel):
71
+ name: str = Field(..., alias="name")
72
+ description: str = Field(..., alias="description")
73
+ delete: Optional[bool] = Field(..., alias="delete")
74
+ provider: str = Field(..., alias="provider")
75
+ unleash: UnleashFeatureToggleV1 = Field(..., alias="unleash")
76
+
77
+
78
+ class UnleashProjectV1(ConfiguredBaseModel):
79
+ name: str = Field(..., alias="name")
80
+ feature_toggles: Optional[list[FeatureToggleUnleashV1]] = Field(..., alias="feature_toggles")
81
+
82
+
83
+ class UnleashInstanceV1(ConfiguredBaseModel):
84
+ name: str = Field(..., alias="name")
85
+ url: str = Field(..., alias="url")
86
+ admin_token: Optional[VaultSecret] = Field(..., alias="adminToken")
87
+ allow_unmanaged_feature_toggles: Optional[bool] = Field(..., alias="allowUnmanagedFeatureToggles")
88
+ projects: Optional[list[UnleashProjectV1]] = Field(..., alias="projects")
89
+
90
+
91
+ class UnleashFeatureTogglesQueryData(ConfiguredBaseModel):
92
+ instances: Optional[list[UnleashInstanceV1]] = Field(..., alias="instances")
93
+
94
+
95
+ def query(query_func: Callable, **kwargs: Any) -> UnleashFeatureTogglesQueryData:
96
+ """
97
+ This is a convenience function which queries and parses the data into
98
+ concrete types. It should be compatible with most GQL clients.
99
+ You do not have to use it to consume the generated data classes.
100
+ Alternatively, you can also mime and alternate the behavior
101
+ of this function in the caller.
102
+
103
+ Parameters:
104
+ query_func (Callable): Function which queries your GQL Server
105
+ kwargs: optional arguments that will be passed to the query function
106
+
107
+ Returns:
108
+ UnleashFeatureTogglesQueryData: queried data parsed into generated classes
109
+ """
110
+ raw_data: dict[Any, Any] = query_func(DEFINITION, **kwargs)
111
+ return UnleashFeatureTogglesQueryData(**raw_data)
File without changes
@@ -0,0 +1,287 @@
1
+ import logging
2
+ from collections.abc import Callable, Iterable, Sequence, ValuesView
3
+ from typing import Any
4
+
5
+ from reconcile.gql_definitions.unleash_feature_toggles.feature_toggles import (
6
+ FeatureToggleUnleashV1,
7
+ UnleashInstanceV1,
8
+ UnleashProjectV1,
9
+ )
10
+ from reconcile.gql_definitions.unleash_feature_toggles.feature_toggles import (
11
+ query as unleash_instances_query,
12
+ )
13
+ from reconcile.utils import gql
14
+ from reconcile.utils.differ import DiffPair, diff_any_iterables, diff_mappings
15
+ from reconcile.utils.runtime.integration import (
16
+ PydanticRunParams,
17
+ QontractReconcileIntegration,
18
+ )
19
+ from reconcile.utils.semver_helper import make_semver
20
+ from reconcile.utils.unleash.server import (
21
+ Environment,
22
+ FeatureToggle,
23
+ FeatureToggleType,
24
+ Project,
25
+ TokenAuth,
26
+ UnleashServer,
27
+ )
28
+
29
+ QONTRACT_INTEGRATION = "unleash-feature-toggles"
30
+ QONTRACT_INTEGRATION_VERSION = make_semver(1, 0, 0)
31
+
32
+
33
+ class UnleashTogglesIntegrationParams(PydanticRunParams):
34
+ instance: str | None
35
+
36
+
37
+ def feature_toggle_equal(c: FeatureToggle, d: FeatureToggleUnleashV1) -> bool:
38
+ """Check if two feature toggles are different, but ignore the actual toggle state."""
39
+ assert d.unleash.q_type # make mypy happy
40
+ return (
41
+ c.description == d.description
42
+ and c.type == FeatureToggleType[d.unleash.q_type]
43
+ and c.impression_data == bool(d.unleash.impression_data)
44
+ )
45
+
46
+
47
+ class UnleashFeatureToggleException(Exception):
48
+ """Raised when a feature toggle is manually created."""
49
+
50
+
51
+ class UnleashFeatureToggleDeleteError(Exception):
52
+ """Raised when a feature toggle is not marked for deletion."""
53
+
54
+
55
+ class UnleashTogglesIntegration(
56
+ QontractReconcileIntegration[UnleashTogglesIntegrationParams]
57
+ ):
58
+ """Manage Unleash feature toggles."""
59
+
60
+ @property
61
+ def name(self) -> str:
62
+ return QONTRACT_INTEGRATION
63
+
64
+ def get_early_exit_desired_state(
65
+ self, query_func: Callable | None = None
66
+ ) -> dict[str, Any]:
67
+ """Return the desired state for early exit."""
68
+ if not query_func:
69
+ query_func = gql.get_api().query
70
+ return {
71
+ "toggles": [ft.dict() for ft in self.get_unleash_instances(query_func)],
72
+ }
73
+
74
+ def get_unleash_instances(
75
+ self, query_func: Callable, instance: str | None = None
76
+ ) -> list[UnleashInstanceV1]:
77
+ """Get all Unleash instances with their projects and feature toggles."""
78
+ instances = [
79
+ ui
80
+ for ui in unleash_instances_query(query_func).instances or []
81
+ if (not instance or ui.name == instance) and ui.admin_token
82
+ ]
83
+
84
+ # Set default values for all feature toggles
85
+ for inst in instances:
86
+ for project in inst.projects or []:
87
+ for feature_toggle in project.feature_toggles or []:
88
+ feature_toggle.unleash.q_type = (
89
+ feature_toggle.unleash.q_type or "release"
90
+ )
91
+ feature_toggle.unleash.impression_data = (
92
+ feature_toggle.unleash.impression_data or False
93
+ )
94
+ return instances
95
+
96
+ def fetch_current_state(self, client: UnleashServer) -> list[Project]:
97
+ """Fetch the current state of all Unleash projects including their feature toggles."""
98
+ return client.projects(include_feature_toggles=True)
99
+
100
+ def validate_unleash_projects(
101
+ self,
102
+ current_projects: Iterable[Project],
103
+ desired_projects: Iterable[UnleashProjectV1],
104
+ ) -> ValuesView[DiffPair[Project, UnleashProjectV1]]:
105
+ """Validate that all projects referenced in the desired state are actually exist."""
106
+ diff = diff_any_iterables(
107
+ current=current_projects,
108
+ desired=desired_projects,
109
+ current_key=lambda c: c.name.lower(),
110
+ desired_key=lambda d: d.name.lower(),
111
+ equal=lambda c, d: c.name.lower() == d.name.lower(),
112
+ )
113
+ if diff.add:
114
+ raise ValueError(f"Non-existing projects: {','.join(diff.add.keys())}")
115
+ return diff.identical.values()
116
+
117
+ def _reconcile_feature_toggles(
118
+ self,
119
+ client: UnleashServer,
120
+ instance: UnleashInstanceV1,
121
+ project_id: str,
122
+ dry_run: bool,
123
+ current_state: Sequence[FeatureToggle],
124
+ desired_state: Iterable[FeatureToggleUnleashV1],
125
+ ) -> None:
126
+ """Reconcile the feature toggles themselves."""
127
+ diff = diff_any_iterables(
128
+ current=current_state,
129
+ desired=[d for d in desired_state if d.delete is not True],
130
+ current_key=lambda c: c.name,
131
+ desired_key=lambda d: d.name,
132
+ equal=feature_toggle_equal,
133
+ )
134
+ for add in diff.add.values():
135
+ logging.info(
136
+ f"[{instance.name}/{project_id}] Adding feature toggle {add.name}"
137
+ )
138
+ try:
139
+ assert add.unleash.q_type # make mypy happy
140
+ feature_type = FeatureToggleType[add.unleash.q_type]
141
+ except KeyError:
142
+ raise ValueError(
143
+ f"[{instance.name}/{project_id}/{add.name}] Invalid feature toggle type '{add.unleash.q_type}', Possible values are: {', '.join(FeatureToggleType.__members__)}"
144
+ )
145
+ if not dry_run:
146
+ client.create_feature_toggle(
147
+ project_id=project_id,
148
+ name=add.name,
149
+ description=add.description,
150
+ type=feature_type,
151
+ impression_data=bool(add.unleash.impression_data),
152
+ )
153
+
154
+ for change in diff.change.values():
155
+ logging.info(
156
+ f"[{instance.name}/{project_id}] Changing feature toggle {change.desired.name}"
157
+ )
158
+ try:
159
+ assert change.desired.unleash.q_type # make mypy happy
160
+ feature_type = FeatureToggleType[change.desired.unleash.q_type]
161
+ except KeyError:
162
+ raise ValueError(
163
+ f"[{instance.name}/{project_id}/{change.current.name}] Invalid feature toggle type '{change.desired.unleash.q_type}', Possible values are: {', '.join(FeatureToggleType.__members__)}"
164
+ )
165
+ if not dry_run:
166
+ client.update_feature_toggle(
167
+ project_id=project_id,
168
+ name=change.current.name,
169
+ description=change.desired.description,
170
+ type=feature_type,
171
+ impression_data=bool(change.desired.unleash.impression_data),
172
+ )
173
+
174
+ for delete in diff.delete.values():
175
+ desired_toggle = next(
176
+ (d for d in desired_state if d.name == delete.name and d.delete), None
177
+ )
178
+ if desired_toggle:
179
+ logging.info(
180
+ f"[{instance.name}/{project_id}] Deleting feature toggle {delete.name}"
181
+ )
182
+ if not dry_run:
183
+ client.delete_feature_toggle(
184
+ project_id=project_id,
185
+ name=delete.name,
186
+ )
187
+ elif not instance.allow_unmanaged_feature_toggles:
188
+ raise UnleashFeatureToggleDeleteError(
189
+ f"[{instance.name}/{project_id}] Found unmanaged feature toggles '{[d.name for d in diff.delete.values()]}'"
190
+ )
191
+
192
+ def _reconcile_states(
193
+ self,
194
+ client: UnleashServer,
195
+ instance: UnleashInstanceV1,
196
+ project_id: str,
197
+ dry_run: bool,
198
+ current_state: Sequence[FeatureToggle],
199
+ desired_state: Iterable[FeatureToggleUnleashV1],
200
+ available_environments: Iterable[Environment],
201
+ ) -> None:
202
+ """Reconcile the feature toggle states."""
203
+ current_toggle_states = {
204
+ (state.name, env.name): env.enabled
205
+ for state in current_state
206
+ for env in state.environments
207
+ }
208
+ desired_toggle_states = {
209
+ (state.name, env_name): enabled
210
+ for state in desired_state
211
+ for env_name, enabled in (state.unleash.environments or {}).items()
212
+ }
213
+
214
+ diff_result = diff_mappings(
215
+ current=current_toggle_states, desired=desired_toggle_states
216
+ )
217
+ for (name, env), pair in diff_result.change.items():
218
+ if env not in available_environments:
219
+ raise ValueError(
220
+ f"[{instance.name}/{project_id}/{name}] Environment '{env}' does not exist in Unleash!"
221
+ )
222
+ if not dry_run:
223
+ client.set_feature_toggle_state(
224
+ project_id=project_id,
225
+ name=name,
226
+ environment=env,
227
+ enabled=pair.desired,
228
+ )
229
+
230
+ def reconcile(
231
+ self,
232
+ client: UnleashServer,
233
+ instance: UnleashInstanceV1,
234
+ project_id: str,
235
+ dry_run: bool,
236
+ current_state: Sequence[FeatureToggle],
237
+ desired_state: Iterable[FeatureToggleUnleashV1],
238
+ ) -> None:
239
+ """Reconcile the feature toggles."""
240
+ self._reconcile_feature_toggles(
241
+ client=client,
242
+ instance=instance,
243
+ project_id=project_id,
244
+ dry_run=dry_run,
245
+ current_state=current_state,
246
+ desired_state=desired_state,
247
+ )
248
+ self._reconcile_states(
249
+ client=client,
250
+ instance=instance,
251
+ project_id=project_id,
252
+ dry_run=dry_run,
253
+ current_state=current_state,
254
+ desired_state=desired_state,
255
+ available_environments=client.environments(project_id),
256
+ )
257
+
258
+ def run(self, dry_run: bool) -> None:
259
+ gql_api = gql.get_api()
260
+ instances = self.get_unleash_instances(
261
+ gql_api.query, instance=self.params.instance
262
+ )
263
+
264
+ for instance in instances:
265
+ assert instance.admin_token # make mypy happy
266
+ with UnleashServer(
267
+ host=instance.url,
268
+ auth=TokenAuth(self.secret_reader.read_secret(instance.admin_token)),
269
+ ) as client:
270
+ try:
271
+ project_pairs = self.validate_unleash_projects(
272
+ current_projects=self.fetch_current_state(client),
273
+ desired_projects=instance.projects or [],
274
+ )
275
+ except ValueError:
276
+ logging.error(f"[{instance.name}] Missing projects!")
277
+ raise
278
+
279
+ for pair in project_pairs:
280
+ self.reconcile(
281
+ client=client,
282
+ instance=instance,
283
+ project_id=pair.current.pk,
284
+ dry_run=dry_run,
285
+ current_state=pair.current.feature_toggles,
286
+ desired_state=pair.desired.feature_toggles or [],
287
+ )
@@ -1,3 +1,4 @@
1
+ import logging
1
2
  from typing import Any, Self
2
3
  from urllib.parse import urljoin
3
4
 
@@ -76,7 +77,14 @@ class ApiBase:
76
77
  def _get(self, url: str) -> dict[str, Any]:
77
78
  response = self.session.get(urljoin(self.host, url), timeout=self.read_timeout)
78
79
  response.raise_for_status()
79
- return response.json()
80
+ try:
81
+ return response.json()
82
+ except requests.exceptions.JSONDecodeError:
83
+ logging.error(
84
+ f"Failed to decode JSON response from {url}"
85
+ f"Response: {response.text}"
86
+ )
87
+ raise
80
88
 
81
89
  def _list(
82
90
  self, url: str, params: dict | None = None, attribute: str | None = None
@@ -100,7 +108,7 @@ class ApiBase:
100
108
  urljoin(self.host, url), json=data, timeout=self.read_timeout
101
109
  )
102
110
  response.raise_for_status()
103
- if response.status_code == 204:
111
+ if response.status_code == 204 or not response.text:
104
112
  return {}
105
113
  return response.json()
106
114
 
@@ -109,7 +117,7 @@ class ApiBase:
109
117
  urljoin(self.host, url), json=data, timeout=self.read_timeout
110
118
  )
111
119
  response.raise_for_status()
112
- if response.status_code == 204:
120
+ if response.status_code == 204 or not response.text:
113
121
  return {}
114
122
  return response.json()
115
123
 
@@ -0,0 +1,11 @@
1
+ from .client import (
2
+ get_feature_toggle_default,
3
+ get_feature_toggle_state,
4
+ get_feature_toggles,
5
+ )
6
+
7
+ __all__ = [
8
+ "get_feature_toggles",
9
+ "get_feature_toggle_state",
10
+ "get_feature_toggle_default",
11
+ ]
@@ -18,22 +18,22 @@ client_lock = threading.Lock()
18
18
 
19
19
 
20
20
  class CacheDict(BaseCache):
21
- def __init__(self):
22
- self.cache = {}
21
+ def __init__(self) -> None:
22
+ self.cache: dict = {}
23
23
 
24
- def set(self, key: str, value: Any):
24
+ def set(self, key: str, value: Any) -> None:
25
25
  self.cache[key] = value
26
26
 
27
- def mset(self, data: dict):
27
+ def mset(self, data: dict) -> None:
28
28
  self.cache.update(data)
29
29
 
30
- def get(self, key: str, default: Optional[Any] = None):
30
+ def get(self, key: str, default: Any | None = None) -> Any:
31
31
  return self.cache.get(key, default)
32
32
 
33
- def exists(self, key: str):
33
+ def exists(self, key: str) -> bool:
34
34
  return key in self.cache
35
35
 
36
- def destroy(self):
36
+ def destroy(self) -> None:
37
37
  self.cache = {}
38
38
 
39
39
 
@@ -85,28 +85,21 @@ def _get_unleash_api_client(api_url: str, auth_head: str) -> UnleashClient:
85
85
  return client
86
86
 
87
87
 
88
- def _shutdown_client():
89
- # Intended for test usage only
90
- with client_lock:
91
- if client:
92
- client.destroy()
93
-
94
-
95
- def get_feature_toggle_default(feature_name, context):
88
+ def get_feature_toggle_default(feature_name: str, context: dict) -> bool:
96
89
  return True
97
90
 
98
91
 
99
- def get_feature_toggle_default_false(feature_name, context):
92
+ def get_feature_toggle_default_false(feature_name: str, context: dict) -> bool:
100
93
  return False
101
94
 
102
95
 
103
96
  def get_feature_toggle_state(
104
- integration_name: str, context: Optional[dict] = None, default: bool = True
97
+ integration_name: str, context: dict | None = None, default: bool = True
105
98
  ) -> bool:
106
99
  api_url = os.environ.get("UNLEASH_API_URL")
107
100
  client_access_token = os.environ.get("UNLEASH_CLIENT_ACCESS_TOKEN")
108
101
  if not (api_url and client_access_token):
109
- return get_feature_toggle_default(None, None)
102
+ return get_feature_toggle_default("", {})
110
103
 
111
104
  c = _get_unleash_api_client(
112
105
  api_url,
@@ -0,0 +1,145 @@
1
+ from enum import Enum
2
+
3
+ import requests
4
+ from pydantic import BaseModel, Field
5
+
6
+ from reconcile.utils.rest_api_base import ApiBase, BearerTokenAuth
7
+
8
+
9
+ class FeatureToggleType(str, Enum):
10
+ experiment = "experiment"
11
+ kill_switch = "kill-switch"
12
+ release = "release"
13
+ operational = "operational"
14
+ permission = "permission"
15
+
16
+
17
+ class Environment(BaseModel):
18
+ name: str
19
+ enabled: bool
20
+
21
+ def __eq__(self, other: object) -> bool:
22
+ if isinstance(other, Environment):
23
+ return self.name == other.name
24
+ return self.name == other
25
+
26
+
27
+ class FeatureToggle(BaseModel):
28
+ name: str
29
+ type: FeatureToggleType = FeatureToggleType.release
30
+ description: str | None = None
31
+ impression_data: bool = Field(False, alias="impressionData")
32
+ environments: list[Environment]
33
+
34
+ class Config:
35
+ allow_population_by_field_name = True
36
+
37
+ def __eq__(self, other: object) -> bool:
38
+ if isinstance(other, FeatureToggle):
39
+ return self.name == other.name
40
+ return self.name == other
41
+
42
+
43
+ class Project(BaseModel):
44
+ pk: str = Field(alias="id")
45
+ name: str
46
+ feature_toggles: list[FeatureToggle] = []
47
+
48
+ class Config:
49
+ allow_population_by_field_name = True
50
+
51
+
52
+ class TokenAuth(BearerTokenAuth):
53
+ def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest:
54
+ r.headers["Authorization"] = self.token
55
+ return r
56
+
57
+
58
+ class UnleashServer(ApiBase):
59
+ """Unleash server API client."""
60
+
61
+ def projects(self, include_feature_toggles: bool = False) -> list[Project]:
62
+ """List all projects."""
63
+ projects = []
64
+ for project_data in self._list("/api/admin/projects", attribute="projects"):
65
+ project = Project(**project_data)
66
+ if include_feature_toggles:
67
+ project.feature_toggles = self.feature_toggles(project.pk)
68
+ projects.append(project)
69
+ return projects
70
+
71
+ def feature_toggles(self, project_id: str) -> list[FeatureToggle]:
72
+ """List all feature toggles for a project."""
73
+ return [
74
+ FeatureToggle(**i)
75
+ for i in self._list(
76
+ f"/api/admin/projects/{project_id}/features", attribute="features"
77
+ )
78
+ ]
79
+
80
+ def environments(self, project_id: str) -> list[Environment]:
81
+ """Gets the environments that are available for this project. An environment is available for a project if enabled in the project configuration."""
82
+ return [
83
+ Environment(**i)
84
+ for i in self._list(
85
+ f"/api/admin/environments/project/{project_id}",
86
+ attribute="environments",
87
+ )
88
+ ]
89
+
90
+ def create_feature_toggle(
91
+ self,
92
+ project_id: str,
93
+ name: str,
94
+ description: str,
95
+ type: FeatureToggleType,
96
+ impression_data: bool,
97
+ ) -> None:
98
+ """Create a feature toggle."""
99
+ self._post(
100
+ f"/api/admin/projects/{project_id}/features",
101
+ data={
102
+ "name": name,
103
+ "description": description,
104
+ "type": type.value,
105
+ "impressionData": impression_data,
106
+ },
107
+ )
108
+
109
+ def update_feature_toggle(
110
+ self,
111
+ project_id: str,
112
+ name: str,
113
+ description: str,
114
+ type: FeatureToggleType,
115
+ impression_data: bool,
116
+ ) -> None:
117
+ """Create a feature toggle."""
118
+ self._put(
119
+ f"/api/admin/projects/{project_id}/features/{name}",
120
+ data={
121
+ "description": description,
122
+ "type": type.value,
123
+ "impressionData": impression_data,
124
+ },
125
+ )
126
+
127
+ def delete_feature_toggle(self, project_id: str, name: str) -> None:
128
+ """Delete a feature toggle."""
129
+ # First archive the feature toggle
130
+ self._delete(f"/api/admin/projects/{project_id}/features/{name}")
131
+ # Then delete it
132
+ self._post(
133
+ f"/api/admin/projects/{project_id}/delete",
134
+ data={"features": [name]},
135
+ )
136
+
137
+ def set_feature_toggle_state(
138
+ self, project_id: str, name: str, environment: str, enabled: bool
139
+ ) -> None:
140
+ """Set the state of a feature toggle."""
141
+ base_url = f"/api/admin/projects/{project_id}/features/{name}/environments/{environment}"
142
+ if enabled:
143
+ self._post(f"{base_url}/on")
144
+ else:
145
+ self._post(f"{base_url}/off")
tools/qontract_cli.py CHANGED
@@ -1579,6 +1579,7 @@ def rds(ctx):
1579
1579
  "engine": rds_attr("engine", overrides, defaults),
1580
1580
  "engine_version": rds_attr("engine_version", overrides, defaults),
1581
1581
  "instance_class": rds_attr("instance_class", overrides, defaults),
1582
+ "storage_type": rds_attr("storage_type", overrides, defaults),
1582
1583
  }
1583
1584
  results.append(item)
1584
1585
 
@@ -1593,6 +1594,7 @@ def rds(ctx):
1593
1594
  {"key": "engine", "sortable": True},
1594
1595
  {"key": "engine_version", "sortable": True},
1595
1596
  {"key": "instance_class", "sortable": True},
1597
+ {"key": "storage_type", "sortable": True},
1596
1598
  ],
1597
1599
  "items": results,
1598
1600
  }
@@ -1617,6 +1619,7 @@ You can view the source of this Markdown to extract the JSON data.
1617
1619
  "engine",
1618
1620
  "engine_version",
1619
1621
  "instance_class",
1622
+ "storage_type",
1620
1623
  ]
1621
1624
  ctx.obj["options"]["sort"] = False
1622
1625
  print_output(ctx.obj["options"], results, columns)
@@ -1,214 +0,0 @@
1
- import os
2
- from collections.abc import Callable
3
-
4
- import pytest
5
- from pytest_httpserver import HTTPServer
6
- from UnleashClient.features import Feature
7
-
8
- import reconcile.utils.unleash
9
- from reconcile.utils.unleash import (
10
- DisableClusterStrategy,
11
- EnableClusterStrategy,
12
- _get_unleash_api_client,
13
- _shutdown_client,
14
- get_feature_toggle_default,
15
- get_feature_toggle_state,
16
- get_feature_toggles,
17
- )
18
-
19
-
20
- @pytest.fixture
21
- def reset_client():
22
- reconcile.utils.unleash.client = None
23
-
24
-
25
- def _setup_unleash_httpserver(features: dict, httpserver: HTTPServer) -> HTTPServer:
26
- httpserver.expect_request("/client/features").respond_with_json(features)
27
- httpserver.expect_request("/client/register", method="post").respond_with_data(
28
- status=202
29
- )
30
- return httpserver
31
-
32
-
33
- @pytest.fixture
34
- def setup_unleash_disable_cluster_strategy(httpserver: HTTPServer):
35
- def _(enabled: bool) -> HTTPServer:
36
- features = {
37
- "version": 2,
38
- "features": [
39
- {
40
- "strategies": [
41
- {
42
- "name": "disableCluster",
43
- "constraints": [],
44
- "parameters": {"cluster_name": "foo"},
45
- },
46
- ],
47
- "impressionData": False,
48
- "enabled": enabled,
49
- "name": "test-strategies",
50
- "description": "",
51
- "project": "default",
52
- "stale": False,
53
- "type": "release",
54
- "variants": [],
55
- }
56
- ],
57
- }
58
- return _setup_unleash_httpserver(features, httpserver)
59
-
60
- return _
61
-
62
-
63
- @pytest.fixture
64
- def setup_unleash_enable_cluster_strategy(httpserver: HTTPServer):
65
- def _(enabled: bool) -> HTTPServer:
66
- features = {
67
- "version": 2,
68
- "features": [
69
- {
70
- "strategies": [
71
- {
72
- "name": "enableCluster",
73
- "constraints": [],
74
- "parameters": {"cluster_name": "enabled-cluster"},
75
- },
76
- ],
77
- "impressionData": False,
78
- "enabled": enabled,
79
- "name": "test-strategies",
80
- "description": "",
81
- "project": "default",
82
- "stale": False,
83
- "type": "release",
84
- "variants": [],
85
- }
86
- ],
87
- }
88
-
89
- return _setup_unleash_httpserver(features, httpserver)
90
-
91
- return _
92
-
93
-
94
- def test__get_unleash_api_client(mocker):
95
- mocked_unleash_client = mocker.patch(
96
- "reconcile.utils.unleash.UnleashClient",
97
- autospec=True,
98
- )
99
- mocked_cache_dict = mocker.patch(
100
- "reconcile.utils.unleash.CacheDict",
101
- autospec=True,
102
- )
103
-
104
- c = _get_unleash_api_client("https://u/api", "foo")
105
-
106
- mocked_unleash_client.assert_called_once_with(
107
- url="https://u/api",
108
- app_name="qontract-reconcile",
109
- custom_headers={"Authorization": "foo"},
110
- cache=mocked_cache_dict.return_value,
111
- custom_strategies={
112
- "enableCluster": EnableClusterStrategy,
113
- "disableCluster": DisableClusterStrategy,
114
- },
115
- )
116
- mocked_unleash_client.return_value.initialize_client.assert_called_once_with()
117
- assert reconcile.utils.unleash.client == c
118
-
119
-
120
- def test__get_unleash_api_client_skip_create(mocker):
121
- mocked_unleash_client = mocker.patch(
122
- "reconcile.utils.unleash.UnleashClient",
123
- autospec=True,
124
- )
125
- u = mocked_unleash_client.return_value
126
- reconcile.utils.unleash.client = u
127
-
128
- c = _get_unleash_api_client("https://u/api", "foo")
129
-
130
- mocked_unleash_client.assert_not_called()
131
- assert reconcile.utils.unleash.client == c == u
132
-
133
-
134
- def test_get_feature_toggle_default():
135
- assert get_feature_toggle_default(None, None)
136
-
137
-
138
- def test_get_feature_toggle_state_env_missing():
139
- assert get_feature_toggle_state("foo")
140
-
141
-
142
- def test_get_feature_toggle_state(mocker, monkeypatch):
143
- def enabled_func(feature, context, fallback_function):
144
- return feature == "enabled"
145
-
146
- os.environ["UNLEASH_API_URL"] = "foo"
147
- os.environ["UNLEASH_CLIENT_ACCESS_TOKEN"] = "bar"
148
-
149
- defaultfunc = mocker.patch(
150
- "reconcile.utils.unleash.get_feature_toggle_default", return_value=True
151
- )
152
- monkeypatch.setattr(
153
- "reconcile.utils.unleash.client",
154
- mocker.patch("UnleashClient.UnleashClient", autospec=True),
155
- )
156
- mocker.patch(
157
- "UnleashClient.UnleashClient.is_enabled",
158
- side_effect=enabled_func,
159
- )
160
-
161
- assert get_feature_toggle_state("enabled")
162
- assert get_feature_toggle_state("disabled") is False
163
- assert defaultfunc.call_count == 0
164
-
165
-
166
- def test_get_feature_toggles(mocker, monkeypatch):
167
- c = mocker.patch("UnleashClient.UnleashClient")
168
- c.features = {
169
- "foo": Feature("foo", False, []),
170
- "bar": Feature("bar", True, []),
171
- }
172
-
173
- monkeypatch.setattr("reconcile.utils.unleash.client", c)
174
- toggles = get_feature_toggles("api", "token")
175
-
176
- assert toggles["foo"] == "disabled"
177
- assert toggles["bar"] == "enabled"
178
-
179
-
180
- def test_get_feature_toggle_state_with_strategy(
181
- reset_client: None, setup_unleash_disable_cluster_strategy: Callable
182
- ):
183
- httpserver = setup_unleash_disable_cluster_strategy(True)
184
- os.environ["UNLEASH_API_URL"] = httpserver.url_for("/")
185
- os.environ["UNLEASH_CLIENT_ACCESS_TOKEN"] = "bar"
186
- assert not get_feature_toggle_state(
187
- "test-strategies", context={"cluster_name": "foo"}
188
- )
189
- assert get_feature_toggle_state("test-strategies", context={"cluster_name": "bar"})
190
- _shutdown_client()
191
-
192
-
193
- def test_get_feature_toggle_state_disabled_with_strategy(
194
- reset_client: None, setup_unleash_disable_cluster_strategy: Callable
195
- ):
196
- httpserver = setup_unleash_disable_cluster_strategy(False)
197
- os.environ["UNLEASH_API_URL"] = httpserver.url_for("/")
198
- os.environ["UNLEASH_CLIENT_ACCESS_TOKEN"] = "bar"
199
- assert not get_feature_toggle_state(
200
- "test-strategies", context={"cluster_name": "bar"}
201
- )
202
- _shutdown_client()
203
-
204
-
205
- def test_get_feature_toggle_state_with_enable_cluster_strategy(
206
- reset_client: None, setup_unleash_enable_cluster_strategy: Callable
207
- ):
208
- httpserver = setup_unleash_enable_cluster_strategy(True)
209
- os.environ["UNLEASH_API_URL"] = httpserver.url_for("/")
210
- os.environ["UNLEASH_CLIENT_ACCESS_TOKEN"] = "bar"
211
- assert get_feature_toggle_state(
212
- "test-strategies", context={"cluster_name": "enabled-cluster"}
213
- )
214
- _shutdown_client()