sym-cli
Advanced tools
| """ | ||
| There is no client SDK for Python, Patrick provided some example code | ||
| for how to use the flag evaluation API from Python | ||
| """ | ||
| import base64 | ||
| import json | ||
| from typing import List, NamedTuple, Optional, Tuple, TypeVar | ||
| import click | ||
| import requests | ||
| class FlagsConfig(NamedTuple): | ||
| """Config for the LD Client SDK API""" | ||
| client_id: str | ||
| api_url: str = "app.launchdarkly.com" | ||
| def _cli_context(email: str) -> dict: | ||
| """Construct a multitype LD context for flag evaluation""" | ||
| return { | ||
| "kind": "multi", | ||
| "sym-cli": {"key": "prod"}, | ||
| "sym-user": {"key": email}, | ||
| } | ||
| def _resource_context(email: str, env: str) -> dict: | ||
| """ | ||
| Construct a multitype LD contxt that includes the | ||
| currently requested SSH resource, so we can find the | ||
| correctly mapped flag | ||
| """ | ||
| cli_context = _cli_context(email) | ||
| cli_context["sym-ssh-environment"] = {"key": env} | ||
| return cli_context | ||
| def _encode_context(context_dict: dict) -> str: | ||
| """Properly encode context (per Patrick)""" | ||
| json_str = json.dumps(context_dict) | ||
| base64_str = base64.urlsafe_b64encode(json_str.encode("utf-8")) | ||
| return base64_str.decode() | ||
| def _evaluate_context(context_json: dict, config: FlagsConfig) -> Optional[dict]: | ||
| """There is no client SDK for Python, Patrick provided this example""" | ||
| ctx = _encode_context(context_json) | ||
| url = f"https://{config.api_url}/sdk/evalx/{config.client_id}/contexts/{ctx}" | ||
| try: | ||
| response = requests.get(url) | ||
| data = response.json() | ||
| if code := data.get("code"): | ||
| message = data.get("message") | ||
| click.secho( | ||
| ( | ||
| f"Warning: unable to look up resource configuration in LaunchDarkly.\n" | ||
| f"Use --saml-config aws-config and specify the AWS profile name as your sym resource to connect.\n\n" | ||
| f"Code: '{code}'\n" | ||
| f"Message: '{message}'\n" | ||
| ), | ||
| err=True, | ||
| fg="yellow", | ||
| bold=True, | ||
| ) | ||
| return None | ||
| return data | ||
| except requests.exceptions.RequestException as ex: | ||
| click.secho( | ||
| ( | ||
| f"Warning: unable to look up resource configuration in LaunchDarkly.\n" | ||
| f"Use --saml-config aws-config and specify the AWS profile name as your sym resource to connect.\n\n" | ||
| f"{ex}" | ||
| ), | ||
| err=True, | ||
| fg="yellow", | ||
| bold=True, | ||
| ) | ||
| return None | ||
| T = TypeVar("T") | ||
| def _flag_value(flags: dict, flag_name: str, default: T) -> T: | ||
| if flag := flags.get(flag_name): | ||
| return flag.get("value", default) | ||
| return default | ||
| def get_sso_profile(resource: str, email: str, config: FlagsConfig) -> Tuple[bool, str]: | ||
| """ | ||
| Look up the AWS SSO profile name for the given requested resource name | ||
| Users request access to things like "production" but the config in AWS config | ||
| has a different name. | ||
| """ | ||
| context_json = _resource_context(email, resource) | ||
| sso_enabled = False | ||
| sso_profile = "" | ||
| if flags := _evaluate_context(context_json, config): | ||
| sso_enabled = _flag_value(flags, "sym-sso-enabled", sso_enabled) | ||
| sso_profile = _flag_value(flags, "sym-ssh-sso-profile", sso_profile) | ||
| return (sso_enabled, sso_profile) | ||
| def get_resource_list(email: str, config: FlagsConfig) -> Tuple[bool, List[str]]: | ||
| """ | ||
| Look up all possible resources in the CLI context. This overrides | ||
| the hard-coded list in params | ||
| """ | ||
| context_json = _cli_context(email) | ||
| sso_enabled = False | ||
| resource_list: List[str] = [] | ||
| if flags := _evaluate_context(context_json, config): | ||
| sso_enabled = _flag_value(flags, "sym-sso-enabled", sso_enabled) | ||
| resource_list = _flag_value(flags, "sym-cli-resource-list", resource_list) | ||
| return (sso_enabled, resource_list) |
+2
-1
| Metadata-Version: 2.1 | ||
| Name: sym-cli | ||
| Version: 0.5.1 | ||
| Version: 0.6.0 | ||
| Summary: Sym's Official CLI for Users | ||
@@ -34,2 +34,3 @@ Home-page: https://symops.com/ | ||
| Requires-Dist: sym-shared-cli (>=0.2.3,<0.3.0) | ||
| Requires-Dist: urllib3 (==1.26.15) | ||
| Requires-Dist: validators (>=0.18.1,<0.19.0) | ||
@@ -36,0 +37,0 @@ Project-URL: Documentation, https://docs.symops.com/docs/install-sym-cli |
+5
-1
| [tool.poetry] | ||
| name = "sym-cli" | ||
| version = "0.5.1" | ||
| version = "0.6.0" | ||
| description = "Sym's Official CLI for Users" | ||
@@ -16,2 +16,5 @@ authors = ["SymOps, Inc. <pypi@symops.io>"] | ||
| [tool.poetry.dependencies] | ||
| # Temporarily pinned, see https://lists.wikimedia.org/hyperkitty/list/wikitech-l@lists.wikimedia.org/thread/2BIZMR3W2FHRTPVQXSJKM5BVKIFZ6ZIN/ | ||
| urllib3 = "1.26.15" | ||
| python = "^3.8" | ||
@@ -62,2 +65,3 @@ click = "^8.0.0" | ||
| pytest-xdist = { extras = ["psutil"], version = "^2.2.0" } | ||
| types-requests = "^2.31.0.1" | ||
@@ -64,0 +68,0 @@ [tool.black] |
+154
-48
@@ -0,1 +1,3 @@ | ||
| import os | ||
| from dataclasses import InitVar, dataclass, field | ||
| from typing import Dict, List, NamedTuple, Optional, Tuple, TypedDict | ||
@@ -7,2 +9,3 @@ | ||
| from .config import Config | ||
| from .flags import FlagsConfig, get_resource_list, get_sso_profile | ||
@@ -14,2 +17,3 @@ | ||
| arn: str | ||
| sso_profile: Optional[str] = None | ||
| aws_saml_url: Optional[str] = None | ||
@@ -20,3 +24,4 @@ ansible_bucket: Optional[str] = None | ||
| class OrgParams(TypedDict, total=False): | ||
| @dataclass | ||
| class OrgParams: | ||
| resource_env_var: Optional[str] | ||
@@ -28,15 +33,86 @@ users: Dict[str, str] | ||
| saml2aws_params: Dict[str, str] | ||
| profiles: Dict[str, Profile] | ||
| flags_config: FlagsConfig | ||
| default_profiles: Dict[str, Profile] | ||
| # For legacy reasons, we need to maintain multiple org slugs for LD. | ||
| # This allows us to put the config in one place. | ||
| LaunchDarklyParams: OrgParams = { | ||
| "resource_env_var": "ENVIRONMENT", | ||
| "users": {"ssh": "ubuntu", "ansible": "ansible"}, | ||
| "domain": "launchdarkly.com", | ||
| "aws_saml_url": ( | ||
| _profiles: Dict[str, Profile] = field(init=False) | ||
| _sso_profiles: Dict[str, str] = field(init=False) | ||
| _sso_enabled: bool = field(init=False) | ||
| def __post_init__(self): | ||
| self._profiles = None | ||
| self._sso_profiles = {} | ||
| self._sso_enabled = False | ||
| @property | ||
| def sso_enabled(self) -> bool: | ||
| """Find out whether we should be using sso as an option for this org""" | ||
| if not self._profiles: | ||
| self._profiles = self._load_profiles() | ||
| return self._sso_enabled | ||
| @property | ||
| def profiles(self) -> Dict[str, Profile]: | ||
| """Get the list of profile options for this org""" | ||
| if not self._profiles: | ||
| self._profiles = self._load_profiles() | ||
| return self._profiles | ||
| def _load_profiles(self) -> Dict[str, Profile]: | ||
| """ | ||
| Use LaunchDarkly to look up a listing of resources, and overlay that | ||
| config on top of the hard-coded config if possible | ||
| """ | ||
| if not Config.should_analytics(): | ||
| return self.default_profiles | ||
| email = Config.get_email() | ||
| sso_enabled, resources = get_resource_list(email, self.flags_config) | ||
| self._sso_enabled = sso_enabled | ||
| if not sso_enabled or not resources: | ||
| return self.default_profiles | ||
| result = {} | ||
| for resource in resources: | ||
| if profile := self.default_profiles.get(resource): | ||
| result[resource] = profile | ||
| else: | ||
| result[resource] = Profile( | ||
| display_name=resource, | ||
| region="us-east-1", | ||
| arn="n/a", | ||
| aliases=[], | ||
| ) | ||
| return result | ||
| def sso_profile(self, resource: str) -> str: | ||
| """ | ||
| Use LaunchDarkly to look up an AWS SSO profile name for the current | ||
| resource, if possible. | ||
| """ | ||
| if result := self._sso_profiles.get(resource): | ||
| return result | ||
| if not Config.should_analytics(): | ||
| return resource | ||
| email = Config.get_email() | ||
| _, profile = get_sso_profile(resource, email, self.flags_config) | ||
| if profile: | ||
| self._sso_profiles[resource] = profile | ||
| return profile | ||
| return resource | ||
| def __getitem__(self, item): | ||
| return getattr(self, item) | ||
| LaunchDarklyParams = OrgParams( | ||
| resource_env_var="ENVIRONMENT", | ||
| users={"ssh": "ubuntu", "ansible": "ansible"}, | ||
| domain="launchdarkly.com", | ||
| aws_saml_url=( | ||
| "https://launchdarkly.okta.com/home/amazon_aws/0oaj4aow7gPk26Fy6356/272" | ||
| ), | ||
| "aws_okta_params": { | ||
| aws_okta_params={ | ||
| "mfa_provider": "OKTA", | ||
@@ -47,4 +123,8 @@ "mfa_factor_type": "auto", | ||
| }, | ||
| "saml2aws_params": {"mfa": "Auto", "aws_session_duration": "1800"}, | ||
| "profiles": { | ||
| saml2aws_params={"mfa": "Auto", "aws_session_duration": "1800"}, | ||
| flags_config=FlagsConfig( | ||
| client_id="647e348b49e9c114656d9506", | ||
| api_url="app.ld.catamorphic.com", | ||
| ), | ||
| default_profiles={ | ||
| "production": Profile( | ||
@@ -56,2 +136,3 @@ display_name="Production", | ||
| aliases=["prod"], | ||
| sso_profile="launchdarkly-main-ssh", | ||
| ), | ||
@@ -63,2 +144,3 @@ "staging": Profile( | ||
| ansible_bucket="launchdarkly-sym-ansible-staging", | ||
| sso_profile="launchdarkly-main-ssh", | ||
| ), | ||
@@ -71,2 +153,3 @@ "dr": Profile( | ||
| aliases=["production_dr"], | ||
| sso_profile="launchdarkly-main-ssh", | ||
| ), | ||
@@ -79,2 +162,3 @@ "dev": Profile( | ||
| aliases=["development"], | ||
| sso_profile="launchdarkly-dev-ssh", | ||
| ), | ||
@@ -87,2 +171,3 @@ "shared-services": Profile( | ||
| aliases=["shared_services"], | ||
| sso_profile="launchdarkly-shared-services-ssh", | ||
| ), | ||
@@ -94,2 +179,3 @@ "catamorphic": Profile( | ||
| ansible_bucket="launchdarkly-sym-ansible-catamorphic", | ||
| sso_profile="launchdarkly-main-ssh", | ||
| ), | ||
@@ -102,2 +188,3 @@ "catamorphic-dr": Profile( | ||
| aliases=["catamorphic_dr"], | ||
| sso_profile="launchdarkly-main-ssh", | ||
| ), | ||
@@ -109,3 +196,8 @@ "intuit-production": Profile( | ||
| ansible_bucket="launchdarkly-sym-ansible-intuit-production", | ||
| aliases=["production-intuit", "intuit_production", "intuit_production_use2"], | ||
| aliases=[ | ||
| "production-intuit", | ||
| "intuit_production", | ||
| "intuit_production_use2", | ||
| ], | ||
| sso_profile="launchdarkly-intuit-ssh", | ||
| ), | ||
@@ -118,2 +210,3 @@ "intuit-dr": Profile( | ||
| aliases=["intuit_dr", "intuit_production_dr"], | ||
| sso_profile="launchdarkly-intuit-ssh", | ||
| ), | ||
@@ -126,2 +219,3 @@ "intuit-staging": Profile( | ||
| aliases=["staging-intuit", "intuit_staging", "intuit_staging_use2"], | ||
| sso_profile="launchdarkly-intuit-ssh", | ||
| ), | ||
@@ -133,2 +227,3 @@ "production_apac": Profile( | ||
| ansible_bucket="launchdarkly-sym-ansible-production", | ||
| sso_profile="launchdarkly-main-ssh-apac", | ||
| ), | ||
@@ -140,2 +235,3 @@ "production_euw1": Profile( | ||
| ansible_bucket="launchdarkly-sym-ansible-production", | ||
| sso_profile="launchdarkly-main-ssh-euw1", | ||
| ), | ||
@@ -147,2 +243,3 @@ "staging_apac": Profile( | ||
| ansible_bucket="launchdarkly-sym-ansible-staging", | ||
| sso_profile="launchdarkly-main-ssh-apac", | ||
| ), | ||
@@ -154,2 +251,3 @@ "staging_euw1": Profile( | ||
| ansible_bucket="launchdarkly-sym-ansible-staging", | ||
| sso_profile="launchdarkly-main-ssh-euw1", | ||
| ), | ||
@@ -162,43 +260,47 @@ "sdk": Profile( | ||
| aliases=["sdk"], | ||
| sso_profile="launchdarkly-sdk-ssh", | ||
| ), | ||
| }, | ||
| } | ||
| ) | ||
| SymParams = OrgParams( | ||
| resource_env_var="ENVIRONMENT", | ||
| users={"ssh": "ubuntu", "ansible": "ubuntu"}, | ||
| domain="symops.io", | ||
| aws_saml_url=("https://dev-291131.okta.com/home/amazon_aws/0oaqlmsn7GMVgAyBK4x6/272"), | ||
| aws_okta_params={ | ||
| "mfa_provider": "OKTA", | ||
| "assume_role_ttl": "1h", | ||
| "session_ttl": "30m", | ||
| }, | ||
| saml2aws_params={"mfa": "Auto", "aws_session_duration": "1800"}, | ||
| flags_config=FlagsConfig( | ||
| client_id="61baa6c942189d14dec41c78", | ||
| ), | ||
| default_profiles={ | ||
| "test": Profile( | ||
| display_name="Test", | ||
| region="us-east-1", | ||
| arn="arn:aws:iam::838419636750:role/SSMTestRole", | ||
| aliases=["this_is_an_alias"], | ||
| ), | ||
| "test_euw1": Profile( | ||
| display_name="Test: EU West 1", | ||
| region="eu-west-1", | ||
| arn="arn:aws:iam::838419636750:role/SSMTestRole", | ||
| ), | ||
| "test-custom": Profile( | ||
| display_name="TestCustom", | ||
| region="us-east-1", | ||
| arn="arn:aws:iam::838419636750:role/SSMTestRoleCustomBucket", | ||
| ansible_bucket="sym-ansible-dev", | ||
| aliases=["test_custom", "test_custom2"], | ||
| ), | ||
| }, | ||
| ) | ||
| PARAMS: Dict[str, OrgParams] = { | ||
| "launch-darkly": LaunchDarklyParams, | ||
| "launchdarkly": LaunchDarklyParams, | ||
| "sym": { | ||
| "resource_env_var": "ENVIRONMENT", | ||
| "users": {"ssh": "ubuntu", "ansible": "ubuntu"}, | ||
| "domain": "symops.io", | ||
| "aws_saml_url": ( | ||
| "https://dev-291131.okta.com/home/amazon_aws/0oaqlmsn7GMVgAyBK4x6/272" | ||
| ), | ||
| "aws_okta_params": { | ||
| "mfa_provider": "OKTA", | ||
| "assume_role_ttl": "1h", | ||
| "session_ttl": "30m", | ||
| }, | ||
| "saml2aws_params": {"mfa": "Auto", "aws_session_duration": "1800"}, | ||
| "profiles": { | ||
| "test": Profile( | ||
| display_name="Test", | ||
| region="us-east-1", | ||
| arn="arn:aws:iam::838419636750:role/SSMTestRole", | ||
| aliases=["this_is_an_alias"], | ||
| ), | ||
| "test_euw1": Profile( | ||
| display_name="Test: EU West 1", | ||
| region="eu-west-1", | ||
| arn="arn:aws:iam::838419636750:role/SSMTestRole", | ||
| ), | ||
| "test-custom": Profile( | ||
| display_name="TestCustom", | ||
| region="us-east-1", | ||
| arn="arn:aws:iam::838419636750:role/SSMTestRoleCustomBucket", | ||
| ansible_bucket="sym-ansible-dev", | ||
| aliases=["test_custom", "test_custom2"], | ||
| ), | ||
| }, | ||
| }, | ||
| "sym": SymParams, | ||
| } | ||
@@ -211,2 +313,6 @@ | ||
| def is_sso_enabled() -> bool: | ||
| return get_org_params().sso_enabled | ||
| def get_aws_saml_url(resource: str) -> str: | ||
@@ -213,0 +319,0 @@ org_params = get_org_params() |
@@ -15,3 +15,3 @@ import os | ||
| from ..errors import InvalidResource | ||
| from ..helpers.params import Profile | ||
| from ..helpers.params import Profile, get_org_params | ||
| from .saml_client import SAMLClient | ||
@@ -27,6 +27,7 @@ | ||
| option_value = "aws-config" | ||
| priority = 0 | ||
| priority = 500 | ||
| setup_help = f"Set up your profile in `{str(AwsConfigPath)}`." | ||
| resource: str | ||
| profile_name: str | ||
| options: "GlobalOptions" | ||
@@ -37,4 +38,6 @@ boto_session: Session | ||
| super().__init__(resource, options=options) | ||
| self.resource = resource | ||
| self.profile_name = get_org_params().sso_profile(resource) | ||
| self.raise_if_invalid() | ||
| self.boto_session = Session(profile_name=self.resource) | ||
| self.boto_session = Session(profile_name=self.profile_name) | ||
@@ -51,3 +54,3 @@ @classmethod | ||
| config = cls._read_creds_config() | ||
| return config.has_section(cls._profile_name(resource)) | ||
| return config.has_section(cls._profile_config_name(resource)) | ||
@@ -57,13 +60,13 @@ @cached_property | ||
| config = self.__class__._read_creds_config() | ||
| return config[self.__class__._profile_name(self.resource)] | ||
| return config[self.__class__._profile_config_name(self.profile_name)] | ||
| @classmethod | ||
| def _profile_name(cls, resource: str): | ||
| return f"profile {resource}" | ||
| def _profile_config_name(cls, profile: str): | ||
| return f"profile {profile}" | ||
| def raise_if_invalid(self): | ||
| if self.__class__.validate_resource(self.resource): | ||
| if self.__class__.validate_resource(self.profile_name): | ||
| return | ||
| raise InvalidResource( | ||
| self.resource, self.__class__._read_creds_config().sections() | ||
| self.profile_name, self.__class__._read_creds_config().sections() | ||
| ) | ||
@@ -85,3 +88,3 @@ | ||
| def _exec(self, *args: str, **opts: str) -> Iterator[Tuple[Argument, ...]]: | ||
| with push_env("AWS_PROFILE", self.resource): | ||
| with push_env("AWS_PROFILE", self.profile_name): | ||
| yield (*args, opts) | ||
@@ -88,0 +91,0 @@ |
@@ -6,2 +6,5 @@ from typing import Literal, Optional, Type | ||
| from sym.cli.helpers.config import Config | ||
| from sym.cli.helpers.params import is_sso_enabled | ||
| from ..errors import SAMLClientNotFound | ||
@@ -12,3 +15,3 @@ from . import import_all | ||
| SAMLClientName = Literal["auto", "aws-okta", "saml2aws", "aws-profile", "aws-config"] | ||
| AUTO_EXCLUDED_SAML_CLIENT_NAMES = ["aws-profile", "aws-config"] | ||
| AUTO_EXCLUDED_SAML_CLIENT_NAMES = ["aws-profile"] | ||
@@ -26,7 +29,7 @@ | ||
| if saml_client_name == "auto": | ||
| excludes = AUTO_EXCLUDED_SAML_CLIENT_NAMES | ||
| if Config.is_logged_in() and is_sso_enabled(): | ||
| excludes.append("aws-config") | ||
| for client in SAMLClient.sorted_subclasses(): | ||
| if ( | ||
| client.option_value not in AUTO_EXCLUDED_SAML_CLIENT_NAMES | ||
| and has_command(client.binary) | ||
| ): | ||
| if client.option_value not in excludes and has_command(client.binary): | ||
| return client | ||
@@ -33,0 +36,0 @@ else: |
@@ -163,1 +163,22 @@ from contextlib import contextmanager | ||
| init("sym") | ||
| @pytest.fixture(autouse=True) | ||
| def patch_is_sso_enabled(monkeypatch): | ||
| monkeypatch.setattr("sym.cli.helpers.params.is_sso_enabled", lambda: False) | ||
| monkeypatch.setattr("sym.cli.saml_clients.chooser.is_sso_enabled", lambda: False) | ||
| @pytest.fixture(autouse=True) | ||
| def patch_should_analytics(monkeypatch): | ||
| monkeypatch.setattr("sym.cli.helpers.config.Config.should_analytics", lambda: False) | ||
| @pytest.fixture(autouse=True) | ||
| def patch_aws_config_priority(monkeypatch): | ||
| monkeypatch.setattr("sym.cli.saml_clients.aws_config.AwsConfig.priority", 0) | ||
| @pytest.fixture(autouse=True) | ||
| def patch_aws_detault_region(monkeypatch): | ||
| monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1") |
@@ -93,2 +93,3 @@ import tempfile | ||
| monkeypatch.setattr(SAMLClient, "check_is_setup", lambda self: ...) | ||
| monkeypatch.setattr(Config, "should_analytics", lambda: False) | ||
@@ -95,0 +96,0 @@ with sandbox.push_xdg_config_home(), sandbox.push_exec_path(), capture_command(): |
@@ -1,1 +0,1 @@ | ||
| __version__ = '0.5.1' | ||
| __version__ = '0.6.0' |
Alert delta unavailable
Currently unable to show alert delta for PyPI packages.
296477
2.99%114
0.88%7208
2.97%