diff --git a/iac/README.md b/iac/README.md index c70acde62..efe762ba7 100644 --- a/iac/README.md +++ b/iac/README.md @@ -336,6 +336,10 @@ Create a service account that will be used to setup the compass realm: - `Service Account Key Admin (roles/iam.serviceAccountKeyAdmin)` - `Artifact Registry Administrator (roles/artifactregistry.admin)` - `Secret Manager Admin (roles/secretmanager.admin)` + - (Optionally) In case the service account is used to tear down resources in any of the environments, at the **realm's root folder level** assign the roles: + - `Owner (roles/owner)` + - `Project Deleter (roles/resourcemanager.projectDeleter)` + - `Artifact Registry Repository Admin (roles/artifactregistry.repoAdmin)` ### Step 5. Pulumi Stack diff --git a/iac/auth/__main__.py b/iac/auth/__main__.py index a0bb96145..7ab7528ec 100644 --- a/iac/auth/__main__.py +++ b/iac/auth/__main__.py @@ -1,3 +1,5 @@ + + import os import sys @@ -8,39 +10,35 @@ sys.path.insert(0, libs_dir) import pulumi -from dotenv import load_dotenv from setup_identity_platform import deploy_auth -from lib.std_pulumi import getenv, getstackref, getconfig, parse_realm_env_name_from_stack - -# Load environment variables from .env file -load_dotenv() +from lib.std_pulumi import load_dot_realm_env, getenv, getstackref, getconfig, parse_realm_env_name_from_stack def main(): - realm_name, environment_name, fully_qualified_environment_name = parse_realm_env_name_from_stack() + _, _, stack_name = parse_realm_env_name_from_stack() + # Load environment variables + load_dot_realm_env(stack_name) # get the config values location = getconfig(name="region", config="gcp") # get stack references - env_reference = pulumi.StackReference(f"tabiya-tech/compass-environment/{fully_qualified_environment_name}") + env_reference = pulumi.StackReference(f"tabiya-tech/compass-environment/{stack_name}") + environment_type = getstackref(env_reference, "environment_type") project_id = getstackref(env_reference, "project_id") frontend_domain = getstackref(env_reference, "frontend_domain") # Get environment variables + # Secrets are not stored in the pulumi state file but in the .env file gcp_oauth_client_id = getenv("GCP_OAUTH_CLIENT_ID") - gcp_oauth_client_secret = getenv("GCP_OAUTH_CLIENT_SECRET") - - pulumi.info(f"Using Environment: {fully_qualified_environment_name}") - pulumi.info(f'Using location: {location}') + gcp_oauth_client_secret = getenv("GCP_OAUTH_CLIENT_SECRET", secret=True) # Deploy the auth deploy_auth( - project=project_id, - realm_name=realm_name, location=location, - environment=environment_name, + environment_type=environment_type, + project=project_id, frontend_domain=frontend_domain, gcp_oauth_client_id=gcp_oauth_client_id, gcp_oauth_client_secret=gcp_oauth_client_secret diff --git a/iac/auth/identity_platform.py b/iac/auth/identity_platform.py new file mode 100644 index 000000000..0ea23ab7d --- /dev/null +++ b/iac/auth/identity_platform.py @@ -0,0 +1,485 @@ +import random +from typing import Any, Dict + +import google +import pulumi +from googleapiclient import discovery +from googleapiclient.errors import HttpError +from pulumi.dynamic import Resource, ResourceProvider, CreateResult, CheckResult, CheckFailure, UpdateResult, DiffResult +import pulumi_gcp as gcp + +# Determine the absolute path to the 'iac' directory +import os +import sys + +libs_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +# Add this directory to sys.path, +# so that we can import the iac/lib module when we run pulumi from withing the iac/auth directory +sys.path.insert(0, libs_dir) + +from transform_keys import convert_keys_to_snake_case, snake_to_camel, convert_keys_to_camel_case +from lib import int_to_base36, enable_debugger + + +class _Differ: + """ + Helper class for comparing the values of the Identity Platform configuration. + + Since the Identity Platform configuration is a nested structure, we need to compare the values of its nested keys. + Some values may be read-only and should not be included in the comparison. + + The configuration returned by the GCP API omits keys that are either not set, set to None, or set to empty dictionaries. + Similarly, boolean values set to False, string values set to empty strings, and list values set to empty lists + may also be omitted. + + The comparison logic should account for these omissions. + """ + + # Helper methods to get the values for the diff + @staticmethod + def get_boolean_value_for_diff(value: Any) -> bool: + if value is None: + return False + return value + + @staticmethod + def get_dict_value_for_diff(value: Any) -> dict: + if value is None: + return {} + return value + + @staticmethod + def get_list_value_for_diff(value: Any) -> list: + if value is None: + return [] + return value + + # Helper methods to compare the Permission values + @staticmethod + def _permissions_equal(old: any, new: any) -> bool: + # If both falsy, they are equal + if _Differ._permission_value_is_falsy(old) and _Differ._permission_value_is_falsy(new): + return True + # If one is falsy and the other is not falsy, they are not equal + if _Differ._permission_value_is_falsy(old) or _Differ._permission_value_is_falsy(new): + return False + # When both are truthy, compare the values + return old == new + + @staticmethod + def _permission_value_is_falsy(v: any) -> bool: + # If the permission key is None or an empty dict, it is "falsy" + if v is None or v == {}: + return True + # If the permission key is a dict with all falsy values, it is "falsy" + return (_Differ.get_boolean_value_for_diff(v.get("disabled_user_deletion")) is False + and _Differ.get_boolean_value_for_diff(v.get("disabled_user_signup")) is False) + + @staticmethod + def _client_values_is_falsy(v: any) -> bool: + # If the client key is None or an empty dict, it is "falsy" + if v is None or v == {}: + return True + # If the client.permissions key is "falsy", the client is "falsy" + return _Differ._permission_value_is_falsy(v.get("permissions")) + + ###################################################################### + # Methods to compare the values of the Identity Platform config + ###################################################################### + @staticmethod + def clients_equal(old: any, new: any) -> bool: + # The apikey and the firebase_subdomain cannot be updated, so we should not compare them + # The permissions can be updated, so we should compare them. + + # If both falsy, they are equal + if _Differ._client_values_is_falsy(old) and _Differ._client_values_is_falsy(new): + return True + # If one is falsy and the other is not falsy, they are not equal + if _Differ._client_values_is_falsy(old) or _Differ._client_values_is_falsy(new): + return False + # When both are truthy, compare the values of the permissions + return _Differ._permissions_equal(old.get("permissions"), new.get("permissions")) + + @staticmethod + def auto_delete_anonymous_users_equal(old: any, new: any) -> bool: + return _Differ.get_boolean_value_for_diff(old) == _Differ.get_boolean_value_for_diff(new) + + @staticmethod + def authorized_domains_equal(old: any, new: any) -> bool: + return _Differ.get_list_value_for_diff(old) == _Differ.get_list_value_for_diff(new) + + +def _getconfig_for_api_body(props: dict[str, Any]) -> dict[str, Any]: + """ + Converts the props to the format expected by the GCP API (camelCase keys). + Exclude any keys that are not part of the specification. + See https://cloud.google.com/identity-platform/docs/reference/rest/v2/Config for more details. + :param props: The props to convert, expected to be in snake_case format. + :return: A dictionary with the keys in camelCase format. + """ + + _allowed_keys_snake = ["authorized_domains", "autodelete_anonymous_users", "blocking_functions", "client", "mfa", + "monitoring", "multi_tenant", "quota", "sign_in", "sms_region_config"] + _allowed_keys_camel = [snake_to_camel(k) for k in _allowed_keys_snake] + _allowed_keys = _allowed_keys_snake + _allowed_keys_camel + + _body = {k: v for k, v in props.items() if k in _allowed_keys} + # remove the keys that are read-only + # sign_in.hash_config, client.api_key, client.firebase_subdomain + if _body.get("sign_in") and _body["sign_in"].get("hash_config"): + del _body["sign_in"]["hash_config"] + if _body.get("client"): + if _body["client"].get("api_key"): + del _body["client"]["api_key"] + if _body["client"].get("firebase_subdomain"): + del _body["client"]["firebase_subdomain"] + + cfg = convert_keys_to_camel_case(_body) + + return cfg + + +def _identity_toolkit_api_enable(credentials: Any, project_id: str): + """ + Enables the Identity Toolkit (Identity Platform) API for the given GCP project. + :param credentials: The credentials to use for the API request + :param project_id: The GCP project number + """ + try: + # See https://cloud.google.com/service-usage/docs/reference/rest/v1/services/enable + service = discovery.build("serviceusage", "v1", credentials=credentials) + service_name = f"projects/{project_id}/services/identitytoolkit.googleapis.com" + request = service.services().enable(name=service_name, body={}) + response = request.execute() + pulumi.info(f"Enabled Identity Toolkit API for project {project_id}") + pulumi.debug("Enabled Identity Toolkit API:", response) + except Exception as e: + pulumi.error(f"Failed to enable Identity Toolkit API: {e}") + raise + + +def _identity_toolkit_api_get_state(credentials: Any, project_id: str) -> bool: + """ + Returns the state the Identity Toolkit (Identity Platform) API for the given GCP project. + :param credentials: The credentials to use for the API request + :param project_id: The GCP project number + :return: True if the API is enabled, False if disabled or in an unknown state. + """ + try: + enable_debugger(5679) + # See https://cloud.google.com/service-usage/docs/reference/rest/v1/services/get + service = discovery.build("serviceusage", "v1", credentials=credentials) + service_name = f"projects/{project_id}/services/identitytoolkit.googleapis.com" + request = service.services().get(name=service_name) + response = request.execute() + pulumi.info(f"Get Identity Toolkit API state for project {project_id}") + pulumi.debug("Get State Identity Toolkit API:", response) + return response.get("state") == "ENABLED" + except Exception as e: + pulumi.error(f"Failed to get Identity Toolkit API state: {e}") + raise + + +def _identity_platform_enable(credentials: Any, project_id: str): + """ + Enables the Identity Toolkit (Identity Platform) for the given GCP project. + :param credentials: The credentials to use for the API request + :param project_id: The GCP project ID + """ + + # Build the Service Usage API client (v1) + try: + # See https://cloud.google.com/identity-platform/docs/reference/rest/v2/projects.identityPlatform/initializeAuth + service = discovery.build("identitytoolkit", "v2", credentials=credentials) + service_name = f"projects/{project_id}" + request = service.projects().identityPlatform().initializeAuth( + project=service_name, + body={} + ) + request.execute() + pulumi.info(f"Enabled Identity Platform for project: {project_id}") + except Exception as e: + pulumi.error(f"Failed to enable Identity Platform: {e}") + raise + + +def _identity_platform_get_config(credentials: Any, project_id: str) -> dict[str, Any] | None: + """ + Retrieves the Identity Platform config resource for the given GCP project. + :param credentials: + :param project_id: + :raises: HttpError if the API request fails + :return: None, if the Identity Platform not enabled or + a dictionary with the Identity Platform config resource with the keys in camelCase format. + See https://cloud.google.com/identity-platform/docs/reference/rest/v2/Config for more details + """ + try: + # See https://cloud.google.com/identity-platform/docs/reference/rest/v2/projects/getConfig + service = discovery.build('identitytoolkit', 'v2', credentials=credentials) + name = f"projects/{project_id}/config" + request = service.projects().getConfig(name=name) + response = request.execute() + pulumi.debug(f"Retrieved Identity Platform config: {response}") + return response + except HttpError as e: + if e.resp.status == 404 or e.resp.status == 403: + pulumi.warn(f"Identity Platform not enabled for project: {project_id}") + # This means the config (resource) is not found, which implies Identity Platform not enabled. + return None + # If it's some other status code, re-raise for visibility + pulumi.error(f"Failed to get Identity Platform config: {e}") + raise + except Exception as e: + pulumi.error(f"Failed to make the request to get Identity Platform config: {e}") + raise + + +def _identity_platform_update_config(credentials: Any, project_id: str, cfg: dict[str, Any]) -> dict[str, Any]: + """ + Updates the project's Identity Platform config resource. + :param credentials: The credentials to use for the API request + :param project_id: The GCP project ID + :param cfg: The Identity Platform config resource to update. It should be a dictionary with the keys in camelCase format. + See https://cloud.google.com/identity-platform/docs/reference/rest/v2/Config for more details. + :raises: HttpError if the API request fails + """ + + try: + # See https://cloud.google.com/identity-platform/docs/reference/rest/v2/projects/updateConfig + service = discovery.build(serviceName="identitytoolkit", version="v2", credentials=credentials) + name = f"projects/{project_id}/config" + # The 'updateMask' indicates which fields we want to update. + # An empty mask means we want to update all fields. + update_mask = None # "signIn,mfa,authorizedDomains" + request = service.projects().updateConfig( + name=name, + updateMask=update_mask, + body=cfg + ) + response = request.execute() + if cfg == {}: # If the config is empty, it means we are deleting the config + pulumi.info(f"Deleted Identity Platform config for project: {project_id}") + else: + pulumi.info(f"Updated Identity Platform config for project: {project_id}") + pulumi.debug("Updated Identity Platform config successfully:", response) + return response + except HttpError as e: + pulumi.error(f"Failed to update Identity Platform config: {e}") + raise + except Exception as e: + pulumi.error(f"Failed to make the request to update Identity Platform config: {e}") + raise + + +def get_credentials() -> Any: + # google_credentials = getenv("GOOGLE_CREDENTIALS") + # _credentials, _ = google.auth.load_credentials_from_file(google_credentials, scopes=["https://www.googleapis.com/auth/cloud-platform"]) + _credentials, _ = google.auth.default() + return _credentials + + +def _get_id() -> str: + """ + Generates a random ID for the Identity Platform resource. + :return: + """ + random_int = random.Random().randrange( + 2 ** 32 - 1, # 32bit integer + 2 ** 64 - 1) # 64bit integer + random_id = int_to_base36(random_int) + return f"identity-platform-{random_id}" + + +def _apply_config(props: dict[str, Any]) -> dict[str, Any]: + _project_id = props.get("project") + if _project_id is None: + raise ValueError(f"'project' is missing in props! Received props: {props}") + + _credentials = get_credentials() + if _credentials is None: + raise ValueError("Failed to get the credentials.") + + # Check if the project has an identity platform enabled + if _identity_platform_get_config(_credentials, _project_id): # if there is already config so it is enabled + pulumi.info(f"Identity Platform already enabled for the project: {_project_id}") + else: # if not enabled, enable it + pulumi.info(f"Identity Platform is not enabled for the project: {_project_id} and will be enabled now.") + if not _identity_toolkit_api_get_state(_credentials, _project_id): + pulumi.info(f"Identity Toolkit API not enabled for project: {_project_id}") + # First make sure the Identity Toolkit API is enabled + _identity_toolkit_api_enable(_credentials, _project_id) + else: + pulumi.info(f"Identity Toolkit API already enabled for project: {_project_id}") + # Then enable the Identity Platform + _identity_platform_enable(_credentials, _project_id) + + # To Convert the props to the format expected by the GCP API (camelCase keys) + _cfg = _getconfig_for_api_body(props) + + # Update the Identity Platform config and get the current config + _current_cfg = _identity_platform_update_config(_credentials, _project_id, _cfg) + + # Convert the keys to snake_case to match the input props + _current_cfg = convert_keys_to_snake_case(_current_cfg) + # If present, remove some keys that should not be returned in the outputs + # sign_in.hash_config + if _current_cfg.get("sign_in") and _current_cfg["sign_in"].get("hash_config"): + del _current_cfg["sign_in"]["hash_config"] + + # Set the project id in the response so that it is available in the outputs when deleting the resource + _current_cfg["project"] = _project_id + # Set the resource name in the response so that it is available in the outputs when deleting the resource + _current_cfg["resource_name"] = props.get("resource_name") + return _current_cfg + + +# Custom Resource Provider for Identity Platform +class IdentityPlatformProvider(ResourceProvider): + def __init__(self): + super().__init__() + + def check(self, _olds: Dict[str, Any], news: Dict[str, Any]) -> CheckResult: + pulumi.info("Checking the Identity Platform config") + + _failures = [] + _project_id = news.get("project") + if _project_id is None: + _failures.append(CheckFailure("project", "project is missing in props")) + + # Convert the news to a dictionary in the snake case format to match the input props + _news = convert_keys_to_snake_case(news) + + # Check if the project has an identity platform enabled + return CheckResult(_news, _failures) + + def create(self, props: dict[str, Any]) -> CreateResult: + pulumi.info("Creating the Identity Platform") + cfg = _apply_config(props) + return CreateResult(id_=_get_id(), outs=cfg) + + def update( + self, + _id: str, + _olds: Dict[str, Any], + _news: Dict[str, Any], + ) -> UpdateResult: + pulumi.info("Updating the Identity Platform") + cfg = _apply_config(_news) + return UpdateResult(cfg) + + def diff( + self, + _id: str, + _olds: Dict[str, Any], + _news: Dict[str, Any], + ) -> DiffResult: + enable_debugger(5679) + pulumi.info("Diffing the Identity Platform") + # If the project is changed, delete the resource before replacing it + if _news.get("project") != _olds.get("project"): + return DiffResult(True, ["project"], [], True) + if _news.get("resource_name") != _olds.get("resource_name") and _olds.get("resource_name") is not None: + # When the resource name is changed, we need to delete the resource before replacing it + # to avoid deleting the config after the resource is created with the new name + # For this to work use the opts: aliases=[pulumi.Alias(name=)] so that pulumi can track the resource and + # do a diff before deciding to delete or replace. + # If the resource name is changed without the alias, pulumi will not even diff the resource and will just create it with the new name and then + # delete the old resource. This will cause the config to be deleted after the new resource is created. + return DiffResult(True, ["resource_name"], [], True) + + _changes = False + # Iterate over the properties and check if the values have changed + for k, v in _news.items(): + if k == "provider" or k == "resource_name" or k == "project": + continue + elif k == "client": + if not _Differ.clients_equal(v, _olds.get(k)): + _changes = True + break + elif k == "autodelete_anonymous_users": + if not _Differ.auto_delete_anonymous_users_equal(v, _olds.get(k)): + _changes = True + break + elif v != _olds.get(k): + _changes = True + break + + return DiffResult(_changes, [], [], False) + + def delete(self, _id: str, _props: Any): + """ + Deletes the Identity Platform config for the given project. + The platform cannot be disabled once it has been enabled. + :param _id: + :param _props: + """ + pulumi.info("Deleting the Identity Platform") + + _project_id = _props.get("project") + + if _project_id is None: + raise ValueError(f"'project' is missing in props! Received props: {_props}") + + _credentials = get_credentials() + if _credentials is None: + raise ValueError("Failed to get the credentials.") + + # Check if the project has an identity platform enabled + if not _identity_platform_get_config(_credentials, _project_id): # If there is no config, it is not enabled + pulumi.warn(f"Identity Platform already disabled for the project: {_project_id}") + return + + pulumi.info(f"IdentityPlatform config will be deleted for project: {_project_id}") + # Delete the Identity Platform config + _identity_platform_update_config(_credentials, _project_id, {}) # pass an empty config to delete the config + + +# Custom Resource for Identity Platform +class IdentityPlatform(Resource): + """ + Custom Resource for Managing the Identity Platform in GCP. + + The Identity Platform cannot be disabled once enabled, and the standard Pulumi GCP provider (gcp.identityplatform.Config) has the following limitations: + 1. Fails to re-create the Identity Platform after the resource is destroyed. + 2. Does not clean up the Identity Platform configuration when the resource is destroyed. + 3. Requires either the deletion and recreation of the project that hosts the identity platform, or a manual import workaround + to re-create the resource after a `pulumi destroy` due to its read-only nature. + Workaround for the Standard Implementation: + With the standard implementation (gcp.identityplatform.Config), + re-creating the stack after a `pulumi destroy` requires manually importing the Identity Platform configuration using: + + $ pulumi import gcp:identityplatform/config:Config default {{project}} + + Replace `{{project}}` with your GCP project ID (e.g., `auth-poc-422113`). + This is needed because the Identity Platform remains enabled in the underlying GCP project. + + Improvements in the Current Implementation: + This custom implementation addresses these issues by: + - Supporting updates to an already-enabled Identity Platform. + - Allowing deletion via an empty configuration. + - Handling re-creation of the resource even if the Identity Platform was previously enabled, eliminating the need for manual imports. + + This approach ensures smooth management of the Identity Platform lifecycle in Pulumi, avoiding the limitations of the standard provider. + """ + + # (Optional) For IDE type hints, define class attributes + client: pulumi.Output[dict] + + def __init__(self, name: str, *, config: gcp.identityplatform.ConfigArgs = None, opts: pulumi.ResourceOptions = None): + _props = {**config.__dict__} + if _props.get("project") is None: + # If the project is not set in the config args, get it from the provider + _provider = getattr(opts, "provider", None) # Safe check for opts and opts.provider + _project = getattr(_provider, "project", None) # Safe check for provider and provider.project + if _project is None: + raise ValueError("The 'project' should be set in the config args or in the provider args.") + _props["project"] = _project + + # Manually add client.api_key and client.firebase_subdomain to the properties + # so they are included in the resource outputs. Only take client.permissions + # from the configuration arguments, and only if it is present, as it can be updated. + # Other values are read-only and should not be included in the outputs + if _props.get("client") is None: + _props["client"] = gcp.identityplatform.ConfigClientArgs() + _props["resource_name"] = name + super().__init__(IdentityPlatformProvider(), name=name, props=_props, opts=opts) diff --git a/iac/auth/setup_identity_platform.py b/iac/auth/setup_identity_platform.py index 7da84dab4..130baf03a 100644 --- a/iac/auth/setup_identity_platform.py +++ b/iac/auth/setup_identity_platform.py @@ -1,64 +1,68 @@ import pulumi import pulumi_gcp as gcp -from lib.std_pulumi import get_resource_name, ProjectBaseConfig, get_project_base_config +from environment.env_types import EnvironmentTypes -""" -# The gcp.identityplatform cannot be disabled after it has been enabled for a GCP project. -# This code should work when it is run the first time for a new GCP project. -# However, if the pulumi stack is removed (pulumi destroy), this code will fail when the stack is re-created (pulumi up) -# as the identity platform has already been enabled for the project. -# The solution is to import the identity platform configs to the pulumi projects with the following command -# $ pulumi import gcp:identityplatform/config:Config default {{project}} -# where {{project}} is for example auth-poc-422113 or compass-dev-418218. -# After the resource has been imported to the pulumi stack, the code is able to update the configs again. -""" +# Determine the absolute path to the 'iac' directory +import os +import sys + +libs_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +# Add this directory to sys.path, +# so that we can import the iac/lib module when we run pulumi from withing the iac/auth directory +sys.path.insert(0, libs_dir) + +from lib import get_resource_name, ProjectBaseConfig, get_project_base_config +from identity_platform import IdentityPlatform def _setup_identity_platform( *, basic_config: ProjectBaseConfig, - realm_name: str, + environment_type: pulumi.Output[EnvironmentTypes], frontend_domain: pulumi.Output[str], gcp_oauth_client_id: str, gcp_oauth_client_secret: str): - # for the development environment allow localhost - _authorized_domains = [frontend_domain] - if basic_config.environment == "dev": - _authorized_domains.append("localhost") - # add more domains here depending on how the application is accessed. + def _get_authorized_domains(args) -> list[str]: + _frontend_domain = args[0] + _environment_type = args[1] + _authorized_domains = [_frontend_domain] + if _environment_type == EnvironmentTypes.DEV: + # add more domains here depending on how the application is accessed. + _authorized_domains.append("localhost") + return _authorized_domains - # Use name "idp_config" as we may require to import this from GCP. - idp_config = gcp.identityplatform.Config( - "default", - authorized_domains=_authorized_domains, - mfa=gcp.identityplatform.ConfigMfaArgs( - state="DISABLED", - ), - sign_in=gcp.identityplatform.ConfigSignInArgs( - allow_duplicate_emails=False, - anonymous=gcp.identityplatform.ConfigSignInAnonymousArgs( - enabled=True, + authorized_domains = pulumi.Output.all(frontend_domain, environment_type).apply(_get_authorized_domains) + idp_config = IdentityPlatform( + get_resource_name(resource="identity-platform", resource_type="default-config"), + config=gcp.identityplatform.ConfigArgs( + authorized_domains=authorized_domains, + mfa=gcp.identityplatform.ConfigMfaArgs( + state="DISABLED", ), - email=gcp.identityplatform.ConfigSignInEmailArgs( - enabled=True, - password_required=True + sign_in=gcp.identityplatform.ConfigSignInArgs( + allow_duplicate_emails=True, + anonymous=gcp.identityplatform.ConfigSignInAnonymousArgs( + enabled=True, + ), + email=gcp.identityplatform.ConfigSignInEmailArgs( + enabled=True, + password_required=True + ), ), ), - project=basic_config.project, - opts=pulumi.ResourceOptions(provider=basic_config.provider) - ) + opts=pulumi.ResourceOptions(provider=basic_config.provider, + delete_before_replace=True, + aliases=[pulumi.Alias(name="identity-platform-config")], + ) - pulumi.export("identity_platform_client_api_key", idp_config.client.api_key.unsecret(idp_config.client.api_key)) - pulumi.export( - "identity_platform_client_firebase_subdomain", - idp_config.client.firebase_subdomain.unsecret(idp_config.client.firebase_subdomain) ) - + pulumi.export("identity_platform_client_api_key", idp_config.client.apply(lambda c: c.get("api_key"))) + pulumi.export("identity_platform_client_firebase_subdomain", idp_config.client.apply(lambda c: c.get("firebase_subdomain"))) # Enable Google Authentication gcp.identityplatform.DefaultSupportedIdpConfig( - get_resource_name(resource=f"{realm_name}-{basic_config.environment}-google-idp", resource_type="config"), + get_resource_name(resource="google-idp", resource_type="config"), client_id=gcp_oauth_client_id, client_secret=gcp_oauth_client_secret, idp_id="google.com", @@ -69,11 +73,10 @@ def _setup_identity_platform( def deploy_auth(*, - project: pulumi.Output[str], - realm_name: str, location: str, - environment: str, - frontend_domain: pulumi.Output[str], + environment_type: pulumi.Output[EnvironmentTypes], + project: pulumi.Output[str], + frontend_domain: pulumi.Output[str], gcp_oauth_client_id: str, gcp_oauth_client_secret: str): """ @@ -89,13 +92,12 @@ def deploy_auth(*, _basic_config = get_project_base_config( project=project, location=location, - environment=environment ) # Setup Google Cloud Identity Platform that provides Firebase based authentications _setup_identity_platform( basic_config=_basic_config, - realm_name=realm_name, + environment_type=environment_type, frontend_domain=frontend_domain, gcp_oauth_client_id=gcp_oauth_client_id, gcp_oauth_client_secret=gcp_oauth_client_secret diff --git a/iac/auth/transform_keys.py b/iac/auth/transform_keys.py new file mode 100644 index 000000000..221f26ed1 --- /dev/null +++ b/iac/auth/transform_keys.py @@ -0,0 +1,94 @@ +from typing import Any + + +def snake_to_camel(snake_str: str) -> str: + """ + Convert a snake_case string to camelCase. + """ + components = snake_str.split('_') + return components[0] + ''.join(word.title() for word in components[1:]) + + +def camel_to_snake(camel_str: str) -> str: + """ + Convert a camelCase string to snake_case. + """ + return ''.join(['_' + c.lower() if c.isupper() else c for c in camel_str]).lstrip('_') + + +def _convert_keys(data: Any, key_converter: callable) -> Any: + """ + Recursively converts all keys in a dictionary or nested structure using the provided key_converter. + + Args: + data (Any): The data to process (dictionary, list, or primitive). + key_converter (callable): A function to convert keys (e.g., snake_to_camel or camel_to_snake). + + Returns: + Any: A new data structure with converted keys. + """ + if isinstance(data, dict): + return {key_converter(k): _convert_keys(v, key_converter) for k, v in data.items()} + elif isinstance(data, list): + return [_convert_keys(item, key_converter) for item in data] + else: + return data + + +def convert_keys_to_camel_case(data: dict[str, Any]) -> dict[str, Any]: + """ + Recursively converts all keys in a dictionary from snake_case to camelCase. + + Args: + data (Dict[str, Any]): The dictionary with snake_case keys. + + Returns: + Dict[str, Any]: A new dictionary with camelCase keys. + """ + return _convert_keys(data, snake_to_camel) + + +def convert_keys_to_snake_case(data: dict[str, Any]) -> dict[str, Any]: + """ + Recursively converts all keys in a dictionary from camelCase to snake_case. + + Args: + data (Dict[str, Any]): The dictionary with camelCase keys. + + Returns: + Dict[str, Any]: A new dictionary with snake_case keys. + """ + return _convert_keys(data, camel_to_snake) + + +def pulumi_object_to_dict(obj: Any) -> dict[str, Any]: + """ + Public method: Converts an object's attributes to a dictionary with camelCase keys. + Always returns a dictionary. + """ + result = _pulumi_object_to_dict(obj) + if not isinstance(result, dict): + raise TypeError("object_to_dict must return a dictionary. Internal logic failed.") + return result + + +def _pulumi_object_to_dict(obj: Any) -> dict[str, Any] | list[Any] | Any: + """ + Private helper: Recursively converts an object's attributes to camelCase keys. + Can return a dictionary, list, or primitive value. + """ + if isinstance(obj, dict): + # Convert dictionary keys to camelCase + return {snake_to_camel(k): _pulumi_object_to_dict(v) for k, v in obj.items()} + elif isinstance(obj, list): + # Recursively process lists + return [_pulumi_object_to_dict(item) for item in obj] + elif hasattr(obj, "__dict__"): + # Convert object's attributes to a dictionary + return {snake_to_camel(k): _pulumi_object_to_dict(v) for k, v in vars(obj).items()} + elif hasattr(obj, "_values"): # For Pulumi input objects + # Handle Pulumi objects with _values + return {snake_to_camel(k): _pulumi_object_to_dict(v) for k, v in obj._values.items()} + else: + # Primitive types are returned as-is + return obj diff --git a/iac/aws-ns/__main__.py b/iac/aws-ns/__main__.py index 7b0364578..4cae4c793 100644 --- a/iac/aws-ns/__main__.py +++ b/iac/aws-ns/__main__.py @@ -9,24 +9,23 @@ import pulumi from deploy_aws_ns import deploy_aws_ns -from dotenv import load_dotenv -from lib.std_pulumi import parse_realm_env_name_from_stack, getstackref - -# Load environment variables from .env file -load_dotenv() +from lib.std_pulumi import parse_realm_env_name_from_stack, getstackref, load_dot_realm_env def main(): - _, _1, fully_qualified_environment_name = parse_realm_env_name_from_stack() + _, _, stack_name = parse_realm_env_name_from_stack() - env_reference = pulumi.StackReference(f"tabiya-tech/compass-environment/{fully_qualified_environment_name}") - domain_name = getstackref(env_reference, "domain_name") + # Load environment variables + load_dot_realm_env(stack_name) - common_stack_ref = pulumi.StackReference(f"tabiya-tech/compass-common/{fully_qualified_environment_name}") - ns_records = common_stack_ref.get_output("ns-records") + # get stack reference to the environment + env_reference = pulumi.StackReference(f"tabiya-tech/compass-environment/{stack_name}") + domain_name = getstackref(env_reference, "domain_name") - # Get the domain name + # get stack reference to the common stack + common_stack_ref = pulumi.StackReference(f"tabiya-tech/compass-common/{stack_name}") + ns_records = getstackref(common_stack_ref, "ns-records") # Deploy the aws ns deploy_aws_ns( diff --git a/iac/backend/__main__.py b/iac/backend/__main__.py index 74c7aa3f2..9437e413c 100644 --- a/iac/backend/__main__.py +++ b/iac/backend/__main__.py @@ -10,25 +10,22 @@ from deploy_backend import deploy_backend, BackendServiceConfig -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -from lib import getconfig, getstackref, getenv, parse_realm_env_name_from_stack +from lib import getconfig, getstackref, getenv, parse_realm_env_name_from_stack, load_dot_realm_env def main(): # The environment is the stack name - realm_name, environment_name, fully_qualified_environment_name = parse_realm_env_name_from_stack() - pulumi.info(f"Using Environment: {fully_qualified_environment_name}") + realm_name, environment_name, stack_name = parse_realm_env_name_from_stack() + + # Load environment variables + load_dot_realm_env(stack_name) # Get the config values location = getconfig("region", "gcp") pulumi.info(f'Using location: {location}') # Get stack references - env_reference = pulumi.StackReference(f"tabiya-tech/compass-environment/{fully_qualified_environment_name}") + env_reference = pulumi.StackReference(f"tabiya-tech/compass-environment/{stack_name}") docker_repository = getstackref(env_reference, "docker_repository") project = getstackref(env_reference, "project_id") project_number = getstackref(env_reference, "project_number") @@ -66,7 +63,6 @@ def main(): root_project_id=root_project_id, project=project, location=location, - environment_name=environment_name, project_number=project_number, backend_service_cfg=backend_service_cfg, docker_repository=docker_repository, diff --git a/iac/backend/deploy_backend.py b/iac/backend/deploy_backend.py index 00a488d63..339914905 100644 --- a/iac/backend/deploy_backend.py +++ b/iac/backend/deploy_backend.py @@ -25,8 +25,8 @@ class BackendServiceConfig: vertex_api_region: str target_environment_name: str target_environment_type: str | pulumi.Output[str] - backend_url: str | pulumi.Output[str] - frontend_url: str | pulumi.Output[str] + backend_url: str | pulumi.Output[str] + frontend_url: str | pulumi.Output[str] sentry_backend_dsn: str enable_sentry: str gcp_oauth_client_id: str @@ -51,9 +51,9 @@ def _setup_api_gateway(*, ): apigw_service_account = gcp.serviceaccount.Account( resource_name=get_resource_name(resource="api-gateway", resource_type="sa"), - account_id=f"compassapigwsrvacct{basic_config.environment.replace('-', '')}", + account_id="api-gateway-sa", project=basic_config.project, - display_name=f"Compass API Gateway {basic_config.environment} Service Account", + display_name="API Gateway Service Account", opts=pulumi.ResourceOptions(depends_on=dependencies, provider=basic_config.provider), ) @@ -90,6 +90,7 @@ def _setup_api_gateway(*, openapi_documents=[ gcp.apigateway.ApiConfigOpenapiDocumentArgs( document=gcp.apigateway.ApiConfigOpenapiDocumentDocumentArgs( + # TODO: is the usage of path here correct? path=get_resource_name(resource="api-gateway", resource_type="config.yaml"), contents=apigw_config_yaml_b64encoded, ), @@ -106,7 +107,7 @@ def _setup_api_gateway(*, api_gateway = gcp.apigateway.Gateway( resource_name=get_resource_name(resource="api-gateway"), api_config=apigw_config.id, - display_name=f"Compass API Gateway {basic_config.environment}", + display_name="Backend API Gateway", gateway_id="backend-api-gateway", project=basic_config.project, region=basic_config.location, @@ -149,6 +150,7 @@ def _grant_docker_repository_access_to_project_service_account( opts=pulumi.ResourceOptions(provider=basic_config.provider), ) + def _get_fully_qualified_image_name( docker_repository: pulumi.Output[gcp.artifactregistry.Repository], tag: str @@ -268,7 +270,6 @@ def deploy_backend( *, location: str, root_project_id: Output[str], - environment_name: str, project: str | Output[str], project_number: Output[str], backend_service_cfg: BackendServiceConfig, @@ -278,7 +279,7 @@ def deploy_backend( """ Deploy the backend infrastructure """ - basic_config = get_project_base_config(project=project, location=location, environment=environment_name) + basic_config = get_project_base_config(project=project, location=location) # grant the project service account access to the docker repository so that it can pull images membership = _grant_docker_repository_access_to_project_service_account( diff --git a/iac/common/__main__.py b/iac/common/__main__.py index 45ab6b12b..065ca7dc6 100644 --- a/iac/common/__main__.py +++ b/iac/common/__main__.py @@ -7,26 +7,23 @@ # so that we can import the iac/lib module when we run pulumi from withing the iac/common directory. sys.path.insert(0, libs_dir) -from urllib.parse import urlparse import pulumi from deploy_common import deploy_common -from dotenv import load_dotenv -from lib.std_pulumi import getenv, getconfig, getstackref, parse_realm_env_name_from_stack - -# Load environment variables from .env file -load_dotenv() +from lib.std_pulumi import getconfig, getstackref, parse_realm_env_name_from_stack, load_dot_realm_env def main(): - _, environment_name, fully_qualified_environment_name = parse_realm_env_name_from_stack() - pulumi.info(f"Using Environment: {fully_qualified_environment_name}") + _, _, stack_name = parse_realm_env_name_from_stack() + + # Load environment variables. + load_dot_realm_env(stack_name) # Get the config values location = getconfig("region", "gcp") - pulumi.info(f'Using location:{location}') - env_reference = pulumi.StackReference(f"tabiya-tech/compass-environment/{fully_qualified_environment_name}") + # Get stack reference for environment + env_reference = pulumi.StackReference(f"tabiya-tech/compass-environment/{stack_name}") project = getstackref(env_reference, "project_id") domain_name = getstackref(env_reference, "domain_name") @@ -34,13 +31,13 @@ def main(): frontend_url = getstackref(env_reference, "frontend_url") backend_url = getstackref(env_reference, "backend_url") - # Get the frontend bucket name - frontend_stack_ref = pulumi.StackReference(f"tabiya-tech/compass-frontend/{fully_qualified_environment_name}") + # Get stack reference for frontend + frontend_stack_ref = pulumi.StackReference(f"tabiya-tech/compass-frontend/{stack_name}") frontend_bucket_name = getstackref(frontend_stack_ref, "bucket_name") frontend_bucket_name.apply(lambda name: print(f"Using frontend bucket name: {name}")) - # Get the backend api gateway id - backend_stack_ref = pulumi.StackReference(f"tabiya-tech/compass-backend/{fully_qualified_environment_name}") + # Get stack reference for backend + backend_stack_ref = pulumi.StackReference(f"tabiya-tech/compass-backend/{stack_name}") api_gateway_id = getstackref(backend_stack_ref, "apigateway_id") api_gateway_id.apply(lambda _id: print(f"Using API gateway id: {_id}")) @@ -48,14 +45,10 @@ def main(): deploy_common( project=project, location=location, - environment=environment_name, domain_name=domain_name, - frontend_domain=frontend_domain, - frontend_bucket_name=frontend_bucket_name, frontend_url=frontend_url, - backend_url=backend_url, api_gateway_id=api_gateway_id) diff --git a/iac/common/deploy_common.py b/iac/common/deploy_common.py index 4211a4424..b1ac225af 100644 --- a/iac/common/deploy_common.py +++ b/iac/common/deploy_common.py @@ -93,8 +93,8 @@ def _get_path_rule(_service_url: str): # Map /* -> /* of the backend service. backend_path_rule = backend_url.apply(_get_path_rule) backend_rule = gcp.compute.URLMapPathMatcherPathRuleArgs(paths=[backend_path_rule], - service=api_gateway_backend_service.id, - route_action=route_action) + service=api_gateway_backend_service.id, + route_action=route_action) # Swagger UI will request /openapi.json, so we need to rewrite the path to the correct one. backend_openapi_rule = gcp.compute.URLMapPathMatcherPathRuleArgs(paths=["/openapi.json"], @@ -189,7 +189,6 @@ def _create_dns(*, basic_config: ProjectBaseConfig, domain_name: pulumi.Output[s def deploy_common(*, project: pulumi.Output[str], location: str, - environment: str, domain_name: pulumi.Output[str], frontend_domain: pulumi.Output[str], @@ -198,7 +197,7 @@ def deploy_common(*, backend_url: pulumi.Output[str], api_gateway_id: pulumi.Output[str]): - basic_config = get_project_base_config(project=project, location=location, environment=environment) + basic_config = get_project_base_config(project=project, location=location) # Create the DNS dns_zone = _create_dns(basic_config=basic_config, diff --git a/iac/environment/Pulumi.new-dev.yaml b/iac/environment/Pulumi.new-dev.yaml index 05679b9ee..50553cd16 100644 --- a/iac/environment/Pulumi.new-dev.yaml +++ b/iac/environment/Pulumi.new-dev.yaml @@ -1,3 +1,4 @@ config: gcp:region: "us-central1" environment_type: "dev" + realm_name: "tabiya-compass" diff --git a/iac/environment/__main__.py b/iac/environment/__main__.py index 47b2e1413..ee4e354b1 100644 --- a/iac/environment/__main__.py +++ b/iac/environment/__main__.py @@ -1,6 +1,8 @@ import os import sys +from env_types import EnvironmentTypes + # Determine the absolute path to the 'iac' directory libs_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) # Add this directory to sys.path, @@ -8,21 +10,24 @@ sys.path.insert(0, libs_dir) import pulumi -from lib import getconfig, getstackref, parse_realm_env_name_from_stack +from lib import getconfig, getstackref, parse_realm_env_name_from_stack, load_dot_realm_env from create_new_environment import create_new_environment -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - def main(): - realm_name, environment_name, _ = parse_realm_env_name_from_stack() + realm_name, environment_name, stack_name = parse_realm_env_name_from_stack() + + # Load environment variables + load_dot_realm_env(stack_name) # Get the config values gcp_region = getconfig("region", "gcp") - environment_type = getconfig("environment_type") + + environment_type: EnvironmentTypes = EnvironmentTypes(getconfig("environment_type")) + # check that it is one of the allowed values + if environment_type not in EnvironmentTypes: + raise ValueError(f"environment_type {environment_type} is not allowed. Allowed values are {EnvironmentTypes}") + pulumi.info(f'Creating environment:{environment_name} with type:{environment_type} in realm:{realm_name}') # Get realm stack references @@ -34,7 +39,7 @@ def main(): base_domain_name = getstackref(realm_reference, "base_domain_name") # if the environment is prod, use the compass upper folder, otherwise use the compass lower folder - if environment_type == "prod": + if environment_type == EnvironmentTypes.PROD: folder_id = getstackref(realm_reference, "upper_env_folder_id") else: folder_id = getstackref(realm_reference, "lower_env_folder_id") @@ -43,12 +48,15 @@ def main(): pulumi.export("realm_name", realm_name) pulumi.export("root_project_id", root_project_id) pulumi.export("docker_repository", docker_repository) - pulumi.export("environment_type", environment_type) - - # DOMAIN_NAME=..tabiya.tech + pulumi.export("environment_type", environment_type.value) + # DOMAIN_NAME=.. domain_name = base_domain_name.apply(lambda _base_domain_name: f"{environment_name}.{realm_name}.{_base_domain_name}") pulumi.export("domain_name", domain_name) + # We need to set up the frontend and backend URLs here, and not in the fronend and backend iac projects as we would have liked, so that: + # a) to ensure that they do not conflict with each other + # b) to ensure that the frontend URL is available for both the frontend and also the auth iac project + # c) to ensure that the frontend URL is available for the backend iac project to set up the CORS policy pulumi.export("frontend_domain", domain_name) pulumi.export("frontend_url", domain_name.apply(lambda _domain_name: f"https://{_domain_name}")) diff --git a/iac/environment/create_new_environment.py b/iac/environment/create_new_environment.py index 069152954..1393b17ca 100644 --- a/iac/environment/create_new_environment.py +++ b/iac/environment/create_new_environment.py @@ -1,10 +1,11 @@ import hashlib from typing import Mapping +from pulumi_random import RandomInteger import pulumi import pulumi_gcp as gcp import pulumiverse_time as time -from lib.std_pulumi import get_resource_name, enable_services +from lib.std_pulumi import enable_services, get_resource_name, int_to_base36 from auth import REQUIRED_SERVICES as AUTH_SERVICES from backend import REQUIRED_SERVICES as BACKEND_SERVICES @@ -54,10 +55,21 @@ def create_new_environment(*, # all the required services are enabled on this micro-task project. # The project should be created in either the Lower Environments folder or the Prod Folder + project_name = f"{realm_name}-{environment_name}" + # Add the project's name to the project id so that we can recognize the project's service accounts of that project easily + # Truncate to 30 characters to avoid the 30 characters limit of the project's id + random_id = RandomInteger("random", + min=2 ** 32 - 1, # 32bit integer + max=2 ** 62, # Slightly below the 64-bit maximum as a valid integer + keepers={ + "version": "1" # Changing this will regenerate the random integer + }) + project_id = random_id.result.apply(lambda _id: f"{project_name}-{int_to_base36(_id)}"[:30]) # convert the random integer to base36 approx 12 characters project = gcp.organizations.Project( # we are using the get_resource_name function to generate a unique name for the project resource_name=get_resource_name(resource="project"), - name=f"{realm_name}-{environment_name}", + name=project_name, + project_id=project_id, folder_id=folder_id, billing_account=billing_account ) @@ -151,14 +163,14 @@ def initial_apis_map(urns: list[str]) -> Mapping[str, str]: ) # enable all the required services for the environment - enable_services( + services = enable_services( provider=environment_gcp_provider, service_names=SERVICES_TO_ENABLE, dependencies=initial_apis + [sleep_for_a_while] ) # create the service keys path for environment variables. - gcp.secretmanager.Secret( + secret = gcp.secretmanager.Secret( get_resource_name(resource="environment-config", resource_type="secret"), secret_id=f"{realm_name}-{environment_name}-environment-config", project=project.project_id, @@ -167,7 +179,8 @@ def initial_apis_map(urns: list[str]) -> Mapping[str, str]: replication={ "auto": {}, }, - opts=pulumi.ResourceOptions(provider=environment_gcp_provider)) + opts=pulumi.ResourceOptions(provider=environment_gcp_provider, depends_on=services)) + pulumi.export("secret_name", secret.name) pulumi.export("project_id", project.id.apply(lambda _id: _id.replace("projects/", ""))) pulumi.export("project_number", project.number) diff --git a/iac/environment/env_types.py b/iac/environment/env_types.py new file mode 100644 index 000000000..7c27384c6 --- /dev/null +++ b/iac/environment/env_types.py @@ -0,0 +1,17 @@ +# There are 3 types of environments: +# - Development +# - Testing +# - Production +from enum import Enum + + +class EnvironmentTypes(Enum): + """ + Environment Types + """ + DEV = "dev" + TEST = "test" + PROD = "prod" + + def __str__(self): + return self.value diff --git a/iac/frontend/__main__.py b/iac/frontend/__main__.py index c4950c22a..9d725120f 100644 --- a/iac/frontend/__main__.py +++ b/iac/frontend/__main__.py @@ -9,28 +9,26 @@ import pulumi from deploy_frontend import deploy_frontend -from dotenv import load_dotenv -# Load environment variables from .env file -load_dotenv() - -from lib.std_pulumi import getconfig, parse_realm_env_name_from_stack, getstackref +from lib.std_pulumi import getconfig, parse_realm_env_name_from_stack, getstackref, load_dot_realm_env def main(): - _, environment_name, fully_qualified_environment_name = parse_realm_env_name_from_stack() - pulumi.info(f"Using Environment: {environment_name}") + _, _, stack_name = parse_realm_env_name_from_stack() + + # Load environment variables + load_dot_realm_env(stack_name) # Get the config values location = getconfig("region", "gcp") pulumi.info(f'Using location:{location}') # get stack reference - env_reference = pulumi.StackReference(f"tabiya-tech/compass-environment/{fully_qualified_environment_name}") + env_reference = pulumi.StackReference(f"tabiya-tech/compass-environment/{stack_name}") project = getstackref(env_reference, "project_id") # Deploy the frontend - deploy_frontend(project, location, environment_name) + deploy_frontend(project, location) if __name__ == "__main__": diff --git a/iac/frontend/deploy_frontend.py b/iac/frontend/deploy_frontend.py index b3544b8b6..a3d553e80 100644 --- a/iac/frontend/deploy_frontend.py +++ b/iac/frontend/deploy_frontend.py @@ -55,8 +55,8 @@ def _make_bucket_public(basic_config: ProjectBaseConfig, bucket_name: pulumi.Out ) -def deploy_frontend(project: pulumi.Output[str], location: str, environment: str): - basic_config = get_project_base_config(project=project, location=location, environment=environment) +def deploy_frontend(project: pulumi.Output[str], location: str): + basic_config = get_project_base_config(project=project, location=location) bucket = _create_bucket(basic_config, "frontend") diff --git a/iac/lib/std_pulumi.py b/iac/lib/std_pulumi.py index 1d43e8bbd..b8d3d0a1c 100644 --- a/iac/lib/std_pulumi.py +++ b/iac/lib/std_pulumi.py @@ -1,6 +1,7 @@ import base64 import os import re +import string import pulumi import pulumi_gcp as gcp @@ -9,20 +10,22 @@ from typing import Optional, Any +from dotenv import find_dotenv, load_dotenv + @dataclass(frozen=True) class ProjectBaseConfig: - project: str - location: str - # TODO: add also realm_name - # TODO: add also environment_type - environment: Optional[str] # TODO: rename to environment_name + project: str | pulumi.Output[str] + location: str | pulumi.Output[str] provider: gcp.Provider -def getstackref(stack_ref: pulumi.StackReference, name: str) -> pulumi.Output[Any]: +def getstackref(stack_ref: pulumi.StackReference, name: str, secret: bool = False) -> pulumi.Output[Any]: """ - Get the stack reference value + Get the stack reference value. Log the value if it is not a secret, otherwise log the secret value as a series of '*' + :param stack_ref: the stack reference + :param name: the value name + :param secret: whether the value is a secret :return: """ value_output = stack_ref.get_output(name) @@ -32,15 +35,21 @@ def handle_value(v: Any) -> Any: pulumi.error(f"{name} is not set in the referenced stack") raise ValueError(f"{name} is not set in the referenced stack") + if not secret: + pulumi.log.info(f"Using stack ref variable {name}: {v}") + else: + pulumi.log.info(f"Using secret stack ref variable {name}: {'*' * len(v)}") + value_output.apply(handle_value) return value_output -def getconfig(name: str, config: Optional[str] = None) -> str: +def getconfig(name: str, config: Optional[str] = None, *, secret: bool = False) -> str: """ - Get the configuration value from the pulumi configuration + Get the configuration value from the pulumi configuration. Log the value if it is not a secret, otherwise log the secret value as a series of '*' :param name: the configuration name - :param config: the pulumi configuration + :param config: the pulumi configuration namespace + :param secret: whether the configuration is a secret :return: the configuration value :raises ValueError: if the configuration value is not set """ @@ -48,19 +57,35 @@ def getconfig(name: str, config: Optional[str] = None) -> str: value = pulumi.Config(config).require(name) if not value: raise ValueError(f"configuration value {name} is not set") + + if not secret: + pulumi.log.info(f"Using configuration variable {name}: {value}") + else: + pulumi.log.info(f"Using secret configuration variable {name}: {'*' * len(value)}") return value -def getenv(name: str) -> str: +def load_dot_realm_env(stack_name: str): + # Load environment variables from .env. + dot_env = find_dotenv(filename=f".env.{stack_name}", raise_error_if_not_found=True) + load_dotenv(dotenv_path=dot_env) + + +def getenv(name: str, secret: bool = False) -> str: """ - Get the environment variable + Get the environment variable. Log the value if it is not a secret, otherwise log the secret value as a series of '*' :param name: the environment variable name + :param secret: whether the environment variable is a secret :return: the environment variable value :raises ValueError: if the environment variable is not set """ value = os.getenv(name) if not value: raise ValueError(f"environment variable {name} is not set") + if not secret: + pulumi.log.info(f"Using environment variable {name}: {value}") + else: + pulumi.log.info(f"Using secret environment variable {name}: {'*' * len(value)}") return value @@ -68,7 +93,7 @@ def get_file_as_string(file: str): return Path(file).read_text() -def get_project_base_config(project: str | pulumi.Output[str], location: str, environment: Optional[str] = None): +def get_project_base_config(*, project: str | pulumi.Output[str], location: str): """ Get the project base configuration The configuration includes a provider that can be used to create resources in the project, independently of the @@ -76,34 +101,28 @@ def get_project_base_config(project: str | pulumi.Output[str], location: str, en This is especially important when creating resources in a project that is not the root project (the service account is created in the root project). - :param project: The project id to create the resources in - :param location: The location of the project - :param environment: The environment the project is in - :return: The project base configuration + :param project: The project id to create the resources in. + :param location: The location of the project. + :return: The project base configuration. """ gcp_provider = gcp.Provider( "gcp_provider", project=project, user_project_override=True) - return ProjectBaseConfig(project=project, location=location, environment=environment, provider=gcp_provider) + return ProjectBaseConfig(project=project, location=location, provider=gcp_provider) -def get_resource_name(resource: str, *, environment: str = None, resource_type: str = None): +# construct the project name based on the realm name and environment name etc +def get_resource_name(*, resource: str, resource_type: str = None): """ Get the resource name - :param environment: :param resource: :param resource_type: :return: """ - name = resource if resource_type: - name = f"{name}-{resource_type}" - - if environment: - name = f"{name}-{environment}" - - return name + return f"{resource}-{resource_type}" + return resource def enable_services(provider: gcp.Provider, service_names: list[str], dependencies: list) -> list[gcp.projects.Service]: @@ -111,7 +130,7 @@ def enable_services(provider: gcp.Provider, service_names: list[str], dependenci for service_name in service_names: srv = gcp.projects.Service( - get_resource_name(service_name.split('.')[0], resource_type="service"), + get_resource_name(resource=service_name.split('.')[0], resource_type="service"), project=provider.project, service=service_name, # Do not disable the service when the resource is destroyed @@ -129,28 +148,42 @@ def enable_services(provider: gcp.Provider, service_names: list[str], dependenci return services -def parse_realm_env_name_from_stack() -> tuple[str, str, str]: +def get_realm_and_env_name_from_stack(stack_name: str) -> tuple[str, str]: """ - Parse the stack name to get the realm and environment names. - The stack name is in the format realm_name.environment_name - The fully qualified environment name is realm_name.environment_name - :return: the realm name, environment name, and the fully qualified environment name (realm_name.environment_name) + Get the realm and environment names from the fully qualified environment name + :param stack_name: + :return: the realm name, environment name """ - fully_qualified_environment_name = pulumi.get_stack() - - parts = fully_qualified_environment_name.split(".") + parts = stack_name.split(".") if len(parts) != 2: - raise ValueError(f"Invalid stack name {fully_qualified_environment_name}. It must be in the format realm_name.environment_name") + raise ValueError(f"Invalid stack name {stack_name}. It must be in the format realm_name.environment_name") # Validate the realm name. This constrain is needed as the environment project name will be based on the realm name and environment name reg_ex = r"^[a-zA-Z0-9-'\" !]{3,29}$" if not re.match(reg_ex, f"{parts[0]}{parts[1]}"): raise ValueError( - f"Invalid stack name {fully_qualified_environment_name} parts. Both parts together, excluding the dot (.), " + f"Invalid stack name {stack_name} parts. Both parts together, excluding the dot (.), " f"must be 3 to 29 characters with lowercase and uppercase " f"letters, numbers, hyphen, single-quote, double-quote, space, and exclamation point") - return parts[0], parts[1], fully_qualified_environment_name + return parts[0], parts[1] + + +def parse_realm_env_name_from_stack() -> tuple[str, str, str]: + """ + Parse the stack name to get the realm and environment names. + The stack name is in the format realm_name.environment_name + The fully qualified environment name is realm_name.environment_name + :return: the realm name, environment name, and the fully qualified environment name (realm_name.environment_name) + """ + stack_name = pulumi.get_stack() + realm_name, environment_name = get_realm_and_env_name_from_stack(stack_name) + pulumi.log.info(f"Using Realm: {realm_name}") + pulumi.log.info(f"Using Environment: {environment_name}") + pulumi.log.info(f"Using Stack Name: {stack_name}") + + return realm_name, environment_name, stack_name + def save_content_in_file(file_path: str, content: str): """ @@ -160,16 +193,51 @@ def save_content_in_file(file_path: str, content: str): file.write(content) -def base64_encode(string: Optional[str]) -> str: +def base64_encode(_string: Optional[str]) -> str: """ Base64 encode the string :return: """ - if not string: + if not _string: raise ValueError("string to encode is required") - encoded_bytes = base64.b64encode(string.encode("utf-8")) + encoded_bytes = base64.b64encode(_string.encode("utf-8")) encoded_string = encoded_bytes.decode("utf-8") return encoded_string + + +BASE36_ALPHABET = string.digits + string.ascii_lowercase # "0123456789abcdefghijklmnopqrstuvwxyz" + + +def int_to_base36(num): + """ + Convert an integer to a base36 string + :param num: The integer to convert + :return: The base36 string + """ + if num == 0: + return BASE36_ALPHABET[0] + + base36 = [] + base = len(BASE36_ALPHABET) + + while num > 0: + num, rem = divmod(num, base) + base36.append(BASE36_ALPHABET[rem]) + + return ''.join(reversed(base36)) + + +# Helper method to enable the remote debugger in IntelliJ +def enable_debugger(port: int): + """ + Enables the remote debugger in IntelliJ. + :param port: The port of the remote debugger to attach to. + :return: + """ + pulumi.info("Starting the auth pulumi stack") + import pydevd_pycharm + pydevd_pycharm.settrace('localhost', port=port, stdoutToServer=True, stderrToServer=True, suspend=False) + pulumi.info("Remote debugger attached") diff --git a/iac/organization/Pulumi.base.yaml b/iac/organization/Pulumi.base.yaml deleted file mode 100644 index 00de000da..000000000 --- a/iac/organization/Pulumi.base.yaml +++ /dev/null @@ -1,2 +0,0 @@ -config: - region: us-central1 diff --git a/iac/realm/__main__.py b/iac/realm/__main__.py index 34edec20a..99ea667b6 100644 --- a/iac/realm/__main__.py +++ b/iac/realm/__main__.py @@ -11,12 +11,8 @@ sys.path.insert(0, libs_dir) from create_realm import create_realm -from dotenv import load_dotenv from lib.std_pulumi import getconfig -# Load environment variables from .env file -load_dotenv() - def main(): # Get the realm name from the stack @@ -36,6 +32,8 @@ def main(): organization_id = getconfig("gcp_organization_id") root_folder_id = getconfig("gcp_root_folder_id") root_project_id = getconfig("gcp_root_project_id") + upper_env_identity_projects_folder_id = getconfig("gcp_upper_env_identity_projects_folder_id") + lower_env_identity_projects_folder_id = getconfig("gcp_lower_env_identity_projects_folder_id") base_domain_name = getconfig("base_domain_name") # Export the realm config so that it can be referenced in downstream stacks. @@ -59,6 +57,8 @@ def main(): realm_name=realm_name, root_folder_id=root_folder_id, root_project_id=root_project_id, + upper_env_identity_projects_folder_id=upper_env_identity_projects_folder_id, + lower_env_identity_projects_folder_id=lower_env_identity_projects_folder_id, keys_path=keys_path ) diff --git a/iac/realm/create_realm.py b/iac/realm/create_realm.py index 126c5548e..cd701ffe8 100644 --- a/iac/realm/create_realm.py +++ b/iac/realm/create_realm.py @@ -21,11 +21,11 @@ def _get_custom_role_valid_name(name: str) -> str: return "".join([c if c.isalnum() or c in ['_', '.'] else '_' for c in name]) -def _grant_folder_roles_to_group(*, realm_name: str, folder_id: pulumi.Output[str] | str, group_name: str, group: gcp.cloudidentity.Group, roles: list[str], +def _grant_folder_roles_to_group(*, folder_id: pulumi.Output[str] | str, folder_name: str, group_name: str, group: gcp.cloudidentity.Group, roles: list[str], provider: gcp.Provider): for role in roles: gcp.folder.IAMMember( - get_resource_name(resource=f"{group_name}-group-{realm_name}-{role}", resource_type="iam-member"), + get_resource_name(resource=f"{folder_name}-{group_name}-group-{role}", resource_type="iam-member"), folder=folder_id, role=role, member=group.group_key.apply(lambda g: f"group:{g.id}"), @@ -47,7 +47,7 @@ def _grant_roles_to_service_account(*, service_account: pulumi.Output[gcp.organi # wait for the artifact registry admin membership to take effect # see https://cloud.google.com/iam/docs/access-change-propagation - sleep_2_minutes = time.Sleep(get_resource_name(resource="sleep-for-2-min-for-role-membership"), + sleep_2_minutes = time.Sleep(get_resource_name(resource="sleep-for-2-min-for-roles-membership"), create_duration="120s", triggers=triggers_map, opts=pulumi.ResourceOptions(depends_on=memberships) @@ -56,7 +56,6 @@ def _grant_roles_to_service_account(*, service_account: pulumi.Output[gcp.organi def _create_repositories(*, - realm_name: str, region: str, admins_group: gcp.cloudidentity.Group, developers_group: gcp.cloudidentity.Group, @@ -74,17 +73,17 @@ def _create_repositories(*, # Create a repository - a docker repository docker_repository = gcp.artifactregistry.Repository( - get_resource_name(resource=f"docker-{realm_name}", resource_type="repository"), + get_resource_name(resource="docker", resource_type="repository"), project=provider.project, location=region, format="DOCKER", - repository_id=f"{realm_name}-docker-repository", + repository_id="docker-repository", opts=pulumi.ResourceOptions(protect=protected_from_deletion, depends_on=dependencies, provider=provider), ) # Devs and admins can read and write to the repository gcp.artifactregistry.RepositoryIamMember( - resource_name=get_resource_name(resource=f"devs-group-{realm_name}-docker-repository-admin", resource_type="iam-member"), + resource_name=get_resource_name(resource="devs-group-docker-repository-admin", resource_type="iam-member"), project=provider.project, location=region, repository=docker_repository.name, @@ -94,7 +93,7 @@ def _create_repositories(*, ) gcp.artifactregistry.RepositoryIamMember( - resource_name=get_resource_name(resource=f"admins-group-{realm_name}-docker-repository-admin", resource_type="iam-member"), + resource_name=get_resource_name(resource="admins-group-docker-repository-admin", resource_type="iam-member"), project=provider.project, location=region, repository=docker_repository.name, @@ -105,16 +104,16 @@ def _create_repositories(*, # Create a repository - a generic artifact repository generic_repository = gcp.artifactregistry.Repository( - get_resource_name(resource=f"generic-{realm_name}", resource_type="repository"), + get_resource_name(resource="generic", resource_type="repository"), project=provider.project, location=region, format="GENERIC", - repository_id=f"{realm_name}-generic-repository", + repository_id="generic-repository", opts=pulumi.ResourceOptions(protect=protected_from_deletion, depends_on=dependencies, provider=provider), ) gcp.artifactregistry.RepositoryIamMember( - resource_name=get_resource_name(resource=f"devs-group-{realm_name}-generic-repository-admin", resource_type="iam-member"), + resource_name=get_resource_name(resource="devs-group-generic-repository-admin", resource_type="iam-member"), project=provider.project, location=region, repository=generic_repository.name, @@ -124,7 +123,7 @@ def _create_repositories(*, ) gcp.artifactregistry.RepositoryIamMember( - resource_name=get_resource_name(resource=f"admins-group-{realm_name}-generic-repository-admin", resource_type="iam-member"), + resource_name=get_resource_name(resource="admins-group-generic-repository-admin", resource_type="iam-member"), project=provider.project, location=region, repository=generic_repository.name, @@ -133,7 +132,6 @@ def _create_repositories(*, opts=pulumi.ResourceOptions(provider=provider, depends_on=[generic_repository, admins_group]), ) - return docker_repository, generic_repository @@ -144,6 +142,8 @@ def _create_organizational_base(*, billing_account_id: str, realm_name: str, root_folder_id: str, + upper_env_identity_projects_folder_id: str, + lower_env_identity_projects_folder_id: str, dependencies: list[pulumi.Resource] ) -> tuple[gcp.organizations.Folder, gcp.organizations.Folder, gcp.cloudidentity.Group, gcp.cloudidentity.Group]: """ @@ -173,6 +173,14 @@ def _create_organizational_base(*, opts=pulumi.ResourceOptions(protect=protected_from_deletion, depends_on=dependencies, provider=provider) ) + # Create a FolderCleaner resource to clean any identity projects manually created in when the folder is deleted + # folder_cleaner = FolderCleaner( + # "folder-cleaner", + # folder_id=lower_envs_folder.folder_id, # "folders/19651652285", + # opts=pulumi.ResourceOptions(depends_on=[lower_envs_folder]) # Ensure it runs after the folder is created + # ) + # pulumi.export("folder_cleaner", folder_cleaner) + prod_envs_folder = gcp.organizations.Folder( get_resource_name(resource="prod_environments", resource_type="folder"), display_name="Prod Environments", @@ -182,7 +190,7 @@ def _create_organizational_base(*, # Create a custom role for the developers and admins realm_developers_admin_extra_role = gcp.organizations.IAMCustomRole( - get_resource_name(resource=f"{realm_name}-developers-admins-extra-permissions", resource_type="custom-role"), + get_resource_name(resource="developers-admins-extra-permissions", resource_type="custom-role"), role_id=f"{_get_custom_role_valid_name(realm_name)}_devs_admins_extra_permissions", title=f"Developers-admins extra permissions for: {realm_name}", org_id=organization_id, @@ -239,10 +247,11 @@ def _create_organizational_base(*, # Assign roles to the groups # Developers roles root_folder_dev_roles = ["roles/viewer", "roles/resourcemanager.folderViewer"] - _grant_folder_roles_to_group(realm_name=realm_name, folder_id=root_folder_id, group_name="devs", group=realm_developers, roles=root_folder_dev_roles, + _grant_folder_roles_to_group(folder_id=root_folder_id, folder_name="realm-root-folder", group_name="devs", group=realm_developers, + roles=root_folder_dev_roles, provider=provider) gcp.folder.IAMMember( - get_resource_name(resource=f"devs-group-{realm_name}-extra", resource_type="iam-member"), + get_resource_name(resource="devs-group-extra", resource_type="iam-member"), folder=root_folder_id, role=realm_developers_admin_extra_role.name, member=realm_developers.group_key.apply(lambda group: f"group:{group.id}"), @@ -251,10 +260,13 @@ def _create_organizational_base(*, lower_env_folder_dev_roles = ["roles/editor", "roles/resourcemanager.projectCreator", "roles/resourcemanager.projectDeleter", "roles/serviceusage.serviceUsageAdmin"] - _grant_folder_roles_to_group(realm_name=realm_name, folder_id=lower_envs_folder.folder_id, group_name="devs", group=realm_developers, + _grant_folder_roles_to_group(folder_id=lower_envs_folder.folder_id, folder_name="lower-env-folder", group_name="devs", group=realm_developers, + roles=lower_env_folder_dev_roles, provider=provider) + _grant_folder_roles_to_group(folder_id=lower_env_identity_projects_folder_id, folder_name="lower-env-identity-projects-folder", group_name="devs", + group=realm_developers, roles=lower_env_folder_dev_roles, provider=provider) gcp.billing.AccountIamMember( - get_resource_name(resource=f"devs-group-{realm_name}-billinguser", resource_type="iam-member"), + get_resource_name(resource="devs-group-billing-user", resource_type="iam-member"), billing_account_id=billing_account_id, role="roles/billing.user", member=realm_developers.group_key.apply(lambda group: f"group:{group.id}"), @@ -264,17 +276,17 @@ def _create_organizational_base(*, # Admin roles root_folder_admin_roles = ["roles/owner", "roles/resourcemanager.projectCreator", "roles/resourcemanager.projectDeleter", "roles/serviceusage.serviceUsageAdmin", "roles/resourcemanager.folderViewer"] - _grant_folder_roles_to_group(realm_name=realm_name, folder_id=root_folder_id, group_name="admins", group=realm_admins, + _grant_folder_roles_to_group(folder_id=root_folder_id, folder_name="realm-root-folder", group_name="admins", group=realm_admins, roles=root_folder_admin_roles, provider=provider) gcp.folder.IAMMember( - get_resource_name(resource=f"admins-group-{realm_name}-extra", resource_type="iam-member"), + get_resource_name(resource="admins-group-extra", resource_type="iam-member"), folder=root_folder_id, role=realm_developers_admin_extra_role.name, member=realm_admins.group_key.apply(lambda group: f"group:{group.id}"), opts=pulumi.ResourceOptions(depends_on=[realm_admins], provider=provider) ) gcp.billing.AccountIamMember( - get_resource_name(resource=f"admins-group-{realm_name}-billinguser", resource_type="iam-member"), + get_resource_name(resource="admins-group-billing-user", resource_type="iam-member"), billing_account_id=billing_account_id, role="roles/billing.user", member=realm_admins.group_key.apply(lambda group: f"group:{group.id}"), @@ -288,7 +300,7 @@ def _export_key(*, service_account: gcp.serviceaccount.Account, key_name: str, k # wait for the artifact registry admin membership to take effect # see https://cloud.google.com/iam/docs/access-change-propagation - sleep_1_minutes = time.Sleep(get_resource_name(resource=f"sleep-for-1-min-before-{key_name}"), + sleep_1_minutes = time.Sleep(get_resource_name(resource=f"wait-for-1-min-before-key-{key_name}-export", resource_type="sleep"), create_duration="60s", opts=pulumi.ResourceOptions(depends_on=service_account) ) @@ -307,6 +319,7 @@ def _handle_value(key: str): def _create_service_accounts(*, + realm_name: str, developers: gcp.cloudidentity.Group, admins: gcp.cloudidentity.Group, keys_path: str, @@ -335,7 +348,7 @@ def _create_service_accounts(*, ) # Export the service account key - lower_env_service_account_key_file_name = keys_path + "/lower-env-ci-cd-key.json" + lower_env_service_account_key_file_name = keys_path + f"/{realm_name}-lower-env-ci-cd-key.json" _export_key(service_account=lower_env_service_account, key_name="lower-env-ci-cd-key", key_file_name=lower_env_service_account_key_file_name, @@ -364,7 +377,7 @@ def _create_service_accounts(*, ] ) # Export the service account key - upper_env_service_account_key_file_name = keys_path + "/upper-env-ci-cd-key.json" + upper_env_service_account_key_file_name = keys_path + f"/{realm_name}-upper-env-ci-cd-key.json" _export_key(service_account=upper_env_service_account, key_name="upper-env-ci-cd-key", key_file_name=upper_env_service_account_key_file_name, @@ -389,10 +402,10 @@ def _enable_required_services(*, provider: gcp.Provider) -> time.Sleep: "cloudidentity.googleapis.com", # Required for the artifact registry "artifactregistry.googleapis.com", - # Required to create projects that have a billing enabled + # Required to create environement projects that have a billing enabled "cloudbilling.googleapis.com", - - # Required for the secret manager - the environment configs secret file. + # Required for enabling the identity toolkit in the environment projects + "identitytoolkit.googleapis.com", ] services = enable_services(provider=provider, service_names=_REQUIRED_SERVICES, dependencies=[]) @@ -402,7 +415,7 @@ def _enable_required_services(*, provider: gcp.Provider) -> time.Sleep: # md5 hash of the concatenated string of the service names triggers_map = {"services": hashlib.md5("".join(_REQUIRED_SERVICES).encode('utf-8')).hexdigest()} return time.Sleep( - get_resource_name(resource="sleep-for-2-min-for-services-to-be-enabled"), + get_resource_name(resource="wait-for-2-min-for-services-to-be-enabled", resource_type="sleep"), triggers=triggers_map, create_duration="120s", opts=pulumi.ResourceOptions(depends_on=services) ) @@ -414,6 +427,8 @@ def create_realm(*, billing_account_id: str, root_folder_id: str, root_project_id: str, + upper_env_identity_projects_folder_id: str, + lower_env_identity_projects_folder_id: str, region: str, realm_name: str, keys_path: str @@ -447,6 +462,8 @@ def create_realm(*, customer_id=customer_id, billing_account_id=billing_account_id, root_folder_id=root_folder_id, + upper_env_identity_projects_folder_id=upper_env_identity_projects_folder_id, + lower_env_identity_projects_folder_id=lower_env_identity_projects_folder_id, dependencies=wait_for_dependencies, provider=provider) # Create the service accounts @@ -454,12 +471,12 @@ def create_realm(*, upper_env_service_account, lower_env_service_account_key_file_name, upper_env_service_account_key_file_name) = _create_service_accounts( + realm_name=realm_name, developers=realm_developers, admins=realm_admins, keys_path=keys_path, dependencies=wait_for_dependencies, provider=provider) # Create the artifact registry repository docker_repository, generic_repository = _create_repositories( - realm_name=realm_name, region=region, admins_group=realm_admins, developers_group=realm_developers, @@ -470,7 +487,9 @@ def create_realm(*, pulumi.export("docker_repository", docker_repository) pulumi.export("generic_repository", generic_repository) pulumi.export("lower_env_folder_id", lower_envs_folder.folder_id) + pulumi.export("lower_env_identity_projects_folder_id", lower_env_identity_projects_folder_id) pulumi.export("upper_env_folder_id", prod_envs_folder.folder_id) + pulumi.export("upper_env_identity_projects_folder_id", upper_env_identity_projects_folder_id) pulumi.export("lower_env_service_account", lower_env_service_account) pulumi.export("upper_env_service_account", upper_env_service_account) pulumi.export("lower_env_service_account_key_file_name", lower_env_service_account_key_file_name) diff --git a/iac/requirements.txt b/iac/requirements.txt index b4d9c3ea7..6227dc538 100644 --- a/iac/requirements.txt +++ b/iac/requirements.txt @@ -3,5 +3,9 @@ pulumi-gcp>=7.0.0,<8.0.0 python-dotenv>=1.0.1,<1.1.0 pulumiverse_time>=0.0.17 pulumi-aws>=6.0.0 - -google-cloud-secret-manager>=2.22.1 +pulumi_random>=4.0.0, <5.0.0 +pyyaml>=6.0.0,<7.0.0 +google-cloud-secret-manager>=2.22.1,<3.0.0 +google-api-python-client>=2.159.0, <3.0.0 +google-auth>=2.37.0, <3.0.0 +pydevd-pycharm~=243.22562.218 \ No newline at end of file