qontract-reconcile 0.10.1rc815__py3-none-any.whl → 0.10.1rc817__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.
- {qontract_reconcile-0.10.1rc815.dist-info → qontract_reconcile-0.10.1rc817.dist-info}/METADATA +1 -1
- {qontract_reconcile-0.10.1rc815.dist-info → qontract_reconcile-0.10.1rc817.dist-info}/RECORD +16 -11
- reconcile/cli.py +17 -0
- reconcile/gql_definitions/unleash_feature_toggles/__init__.py +0 -0
- reconcile/gql_definitions/unleash_feature_toggles/feature_toggles.py +111 -0
- reconcile/unleash_feature_toggles/__init__.py +0 -0
- reconcile/unleash_feature_toggles/integration.py +287 -0
- reconcile/utils/output.py +13 -10
- reconcile/utils/rest_api_base.py +11 -3
- reconcile/utils/unleash/__init__.py +11 -0
- reconcile/utils/{unleash.py → unleash/client.py} +11 -18
- reconcile/utils/unleash/server.py +145 -0
- tools/qontract_cli.py +4 -1
- reconcile/test/test_unleash.py +0 -214
- {qontract_reconcile-0.10.1rc815.dist-info → qontract_reconcile-0.10.1rc817.dist-info}/WHEEL +0 -0
- {qontract_reconcile-0.10.1rc815.dist-info → qontract_reconcile-0.10.1rc817.dist-info}/entry_points.txt +0 -0
- {qontract_reconcile-0.10.1rc815.dist-info → qontract_reconcile-0.10.1rc817.dist-info}/top_level.txt +0 -0
{qontract_reconcile-0.10.1rc815.dist-info → qontract_reconcile-0.10.1rc817.dist-info}/METADATA
RENAMED
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: qontract-reconcile
|
3
|
-
Version: 0.10.
|
3
|
+
Version: 0.10.1rc817
|
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
|
{qontract_reconcile-0.10.1rc815.dist-info → qontract_reconcile-0.10.1rc817.dist-info}/RECORD
RENAMED
@@ -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=
|
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
|
@@ -653,7 +656,7 @@ reconcile/utils/oc_map.py,sha256=nT69J5pdPeIDnIYjD9fwY6GkE3BMQCf-AF0rmHJuUNw,906
|
|
653
656
|
reconcile/utils/ocm_base_client.py,sha256=X8qkPXfpfJdBKBtFv7zyGD33HNAEBJL8owf-ykrt-Ts,6469
|
654
657
|
reconcile/utils/openshift_resource.py,sha256=Gxhc3oyYDCfMix7RDVvNAZX9O1bQII0KAjGsC5pu7oA,24831
|
655
658
|
reconcile/utils/openssl.py,sha256=QVvhzhpChq_4Daf_5wE1qeZJr4thg3DDjJPn4bOPD4E,365
|
656
|
-
reconcile/utils/output.py,sha256=
|
659
|
+
reconcile/utils/output.py,sha256=I_kXYyPcN1mlZmX16ZnLNGkhhwnal640GIdIaGJd4wE,2026
|
657
660
|
reconcile/utils/pagerduty_api.py,sha256=fcSAUez6w51woDvbm0plJW2qSw6_NXQs1Fit_KTNitc,7653
|
658
661
|
reconcile/utils/parse_dhms_duration.py,sha256=TONpLnec5gHeF7k815YNJpQyDjXhkxZIcv9s8ffbTSY,1840
|
659
662
|
reconcile/utils/password_validator.py,sha256=XwuWg-8CPlcuG7dl_oQ1G1h2gSVSnfMym_VkuprpWVg,2183
|
@@ -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=
|
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=
|
787
|
+
tools/qontract_cli.py,sha256=tAM6OXJkPnbSvZWOuhWQs9oKpL6Fn7qMEEirzCeuUY8,115006
|
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.
|
810
|
-
qontract_reconcile-0.10.
|
811
|
-
qontract_reconcile-0.10.
|
812
|
-
qontract_reconcile-0.10.
|
813
|
-
qontract_reconcile-0.10.
|
814
|
+
qontract_reconcile-0.10.1rc817.dist-info/METADATA,sha256=AMVfb5NoPAJUzzyIZHu9x339AEFTiIxc6j_bTZL0ilU,2314
|
815
|
+
qontract_reconcile-0.10.1rc817.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
|
816
|
+
qontract_reconcile-0.10.1rc817.dist-info/entry_points.txt,sha256=GKQqCl2j2X1BJQ69een6rHcR26PmnxnONLNOQB-nRjY,491
|
817
|
+
qontract_reconcile-0.10.1rc817.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
|
818
|
+
qontract_reconcile-0.10.1rc817.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):
|
File without changes
|
@@ -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
|
+
)
|
reconcile/utils/output.py
CHANGED
@@ -3,7 +3,7 @@ from collections.abc import (
|
|
3
3
|
Iterable,
|
4
4
|
Mapping,
|
5
5
|
)
|
6
|
-
from typing import Union
|
6
|
+
from typing import Optional, Union
|
7
7
|
|
8
8
|
import yaml
|
9
9
|
from tabulate import tabulate
|
@@ -13,7 +13,7 @@ def print_output(
|
|
13
13
|
options: Mapping[str, Union[str, bool]],
|
14
14
|
content: list[dict],
|
15
15
|
columns: Iterable[str] = (),
|
16
|
-
):
|
16
|
+
) -> Optional[str]:
|
17
17
|
if options["sort"]:
|
18
18
|
content.sort(key=lambda c: tuple(c.values()))
|
19
19
|
if options.get("to_string"):
|
@@ -23,17 +23,24 @@ def print_output(
|
|
23
23
|
|
24
24
|
output = options["output"]
|
25
25
|
|
26
|
+
formatted_content = None
|
26
27
|
if output == "table":
|
27
|
-
|
28
|
+
formatted_content = format_table(content, columns)
|
29
|
+
print(formatted_content)
|
28
30
|
elif output == "md":
|
29
|
-
|
31
|
+
formatted_content = format_table(content, columns, table_format="github")
|
32
|
+
print(formatted_content)
|
30
33
|
elif output == "json":
|
31
|
-
|
34
|
+
formatted_content = json.dumps(content)
|
35
|
+
print(formatted_content)
|
32
36
|
elif output == "yaml":
|
33
|
-
|
37
|
+
formatted_content = yaml.dump(content)
|
38
|
+
print(formatted_content)
|
34
39
|
else:
|
35
40
|
pass # error
|
36
41
|
|
42
|
+
return formatted_content
|
43
|
+
|
37
44
|
|
38
45
|
def format_table(content, columns, table_format="simple") -> str:
|
39
46
|
headers = [column.upper() for column in columns]
|
@@ -58,7 +65,3 @@ def format_table(content, columns, table_format="simple") -> str:
|
|
58
65
|
row_data.append(cell)
|
59
66
|
table_data.append(row_data)
|
60
67
|
return tabulate(table_data, headers=headers, tablefmt=table_format)
|
61
|
-
|
62
|
-
|
63
|
-
def print_table(content, columns, table_format="simple"):
|
64
|
-
print(format_table(content, columns, table_format))
|
reconcile/utils/rest_api_base.py
CHANGED
@@ -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
|
-
|
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
|
|
@@ -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:
|
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
|
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:
|
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(
|
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
@@ -2063,7 +2063,10 @@ def app_interface_review_queue(ctx) -> None:
|
|
2063
2063
|
|
2064
2064
|
queue_data.sort(key=itemgetter("updated_at"))
|
2065
2065
|
ctx.obj["options"]["sort"] = False # do not sort
|
2066
|
-
print_output(ctx.obj["options"], queue_data, columns)
|
2066
|
+
text = print_output(ctx.obj["options"], queue_data, columns)
|
2067
|
+
if text:
|
2068
|
+
slack = slackapi_from_queries("app-interface-review-queue")
|
2069
|
+
slack.chat_post_message("```\n" + text + "\n```")
|
2067
2070
|
|
2068
2071
|
|
2069
2072
|
@get.command()
|
reconcile/test/test_unleash.py
DELETED
@@ -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()
|
File without changes
|
File without changes
|
{qontract_reconcile-0.10.1rc815.dist-info → qontract_reconcile-0.10.1rc817.dist-info}/top_level.txt
RENAMED
File without changes
|