diff --git a/.vscode/launch.json b/.vscode/launch.json index 0ba859c2b..557b893de 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -199,6 +199,17 @@ ], "justMyCode": true }, + { + "name": "Iambic: Simulate Generic Git Provider", + "type": "python", + "request": "launch", + "program": "${workspaceFolder}/dev_tools/simulate_generic_git_in_aws_lambda/simulate_generic_git.py", + "args": [ + ], + "console": "integratedTerminal", + "justMyCode": true, + "envFile": "${workspaceFolder}/.env", + }, { "name": "Iambic: Simulate Lambda GitHub Webhook", "type": "python", diff --git a/deployment/upgrade_iambic_version_for_generic_git_provider_lambda/iambic-github-app-updater-role-template.yaml b/deployment/upgrade_iambic_version_for_generic_git_provider_lambda/iambic-github-app-updater-role-template.yaml new file mode 100644 index 000000000..dba734214 --- /dev/null +++ b/deployment/upgrade_iambic_version_for_generic_git_provider_lambda/iambic-github-app-updater-role-template.yaml @@ -0,0 +1,53 @@ +template_type: NOQ::AWS::IAM::Role +template_schema_url: https://docs.iambic.org/reference/schemas/aws_iam_role_template +included_accounts: + - "REPLACE_THIS_WITH_YOUR_AWS_ACCOUNT_NAME_THAT_CONTAINS_IAMBIC_GENERIC_GIT_PROVIDER_LAMBDA_CODE" +identifier: iambic_generic_git_provider_updater +properties: + description: "Use to update IAMbic Generic Git Provider integration on AWS Lambda" + assume_role_policy_document: + statement: + - action: + - sts:AssumeRole + - sts:TagSession + effect: Allow + principal: + aws: "REPLACE_THIS_WITH_CI_CD_ROLE_THAT_WOULD_RUN_THE_UPDATER" + version: '2012-10-17' + inline_policies: + - policy_name: CloudFormation + statement: + - action: cloudformation:ListStacks + effect: Allow + resource: '*' + sid: ListPermissions + - action: + - cloudformation:DescribeStacks + - cloudformation:UpdateStack + effect: Allow + resource: arn:aws:cloudformation:*:{{var.account_id}}:stack/IAMbicGenericGitProviderLambda/* + version: '2012-10-17' + - policy_name: CodeBuild + statement: + - action: + - codebuild:BatchGetBuilds + - codebuild:StartBuild + effect: Allow + resource: arn:aws:codebuild:*:{{var.account_id}}:project/iambic_code_build + version: '2012-10-17' + - policy_name: ECR + statement: + - action: ecr:DescribeImages + effect: Allow + resource: arn:aws:ecr:*:{{var.account_id}}:repository/iambic-ecr-public/iambic/iambic + version: '2012-10-17' + - policy_name: Lambda + statement: + - action: + - lambda:GetFunctionUrlConfig + - lambda:ListTags + - lambda:UpdateFunctionCode + effect: Allow + resource: arn:aws:lambda:*:{{var.account_id}}:function:iambic_generic_git_provider_webhook + version: '2012-10-17' + role_name: iambic_generic_git_provider_updater \ No newline at end of file diff --git a/deployment/upgrade_iambic_version_for_generic_git_provider_lambda/upgrade_lambda.py b/deployment/upgrade_iambic_version_for_generic_git_provider_lambda/upgrade_lambda.py new file mode 100644 index 000000000..38d7602fc --- /dev/null +++ b/deployment/upgrade_iambic_version_for_generic_git_provider_lambda/upgrade_lambda.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import logging +import os +import time + +import boto3 + +REGION_NAME = os.environ.get("AWS_REGION", "us-east-1") +IAMBIC_CODE_BUILD_PROJECT_NAME = os.environ.get( + "IAMBIC_CODE_BUILD_PROJECT_NAME", "iambic_code_build" +) +IAMBIC_FUNCTION_NAME = os.environ.get( + "IAMBIC_FUNCTION_NAME", "iambic_generic_git_provider_webhook" +) +IAMBIC_REPOSITORY_NAME = os.environ.get( + "IAMBIC_REPOSITORY_NAME", "iambic-ecr-public/iambic/iambic" +) +IAMBIC_CF_LAMBDA_STACK_NAME = os.environ.get( + "IAMBIC_CF_LAMBDA_STACK_NAME", "IAMbicGenericGitProviderLambda" +) +IAMBIC_TARGET_VERSION = os.environ.get("IAMBIC_TARGET_VERSION", "latest") + + +def start_code_build_with_pin_version(ver): + code_build_client = boto3.client("codebuild", region_name=REGION_NAME) + + response = code_build_client.start_build( + projectName=IAMBIC_CODE_BUILD_PROJECT_NAME, + environmentVariablesOverride=[ + { + "name": "IMAGE_TAG", + "value": ver, + "type": "PLAINTEXT", + }, + ], + ) + + build_id = response["build"]["id"] + logging.info("Preparing container image. This process should take around 2 minutes") + for _ in range(6): + resp = code_build_client.batch_get_builds(ids=[build_id]) + build_status = resp["builds"][0]["buildStatus"] + if build_status == "IN_PROGRESS": + time.sleep(30) + continue + elif build_status == "SUCCEEDED": + break + else: + raise ValueError(f"build status is {build_status}") + + +def is_image_label_ready(ecr_client, ver): + if ver == "latest": + raise ValueError( + "We do not support `latest` as image label because ECR cache maybe out of date. Please point to a specific version" + ) + repository_name = IAMBIC_REPOSITORY_NAME + try: + resp = ecr_client.describe_images( + repositoryName=repository_name, imageIds=[{"imageTag": ver}] + ) + if len(resp["imageDetails"]) == 0: + return False + else: + return True + except ecr_client.exceptions.ImageNotFoundException: + return False + + +def wait_until_image_is_ready(ecr_client, ver): + print("Waiting for image label to be ready") + for _ in range(6): + if is_image_label_ready(ecr_client, ver): + break + else: + time.sleep(30) + continue + + +def update_lambda_code(ver): + client = boto3.client("lambda", region_name=REGION_NAME) + response = client.get_function( + FunctionName=IAMBIC_FUNCTION_NAME, + ) + image_uri = response["Code"]["ImageUri"] + base_uri, current_ver = image_uri.split(":") + assert base_uri + assert current_ver + new_image_uri = f"{base_uri}:{ver}" + print(f"new image uri: {new_image_uri}") + response = client.update_function_code( + FunctionName=IAMBIC_FUNCTION_NAME, + ImageUri=new_image_uri, + Publish=True, + ) + + +def update_cf_lambda_ver(ver): + client = boto3.client("cloudformation", region_name=REGION_NAME) + response = client.describe_stacks( + StackName=IAMBIC_CF_LAMBDA_STACK_NAME, + ) + existing_parameters = response["Stacks"][0]["Parameters"] + new_parameters = [] + image_uri = None + for param in existing_parameters: + if param["ParameterKey"] != "ImageUri": + new_parameters.append( + {"ParameterKey": param["ParameterKey"], "UsePreviousValue": True} + ) + else: + image_uri = param["ParameterValue"] + assert image_uri + base_uri, current_ver = image_uri.split(":") + assert base_uri + assert current_ver + new_image_uri = f"{base_uri}:{ver}" + print(f"new image uri: {new_image_uri}") + new_parameters.append({"ParameterKey": "ImageUri", "ParameterValue": new_image_uri}) + response = client.update_stack( + StackName=IAMBIC_CF_LAMBDA_STACK_NAME, + UsePreviousTemplate=True, + Parameters=new_parameters, + ) + for _ in range(6): + response = client.describe_stacks( + StackName=IAMBIC_CF_LAMBDA_STACK_NAME, + ) + stack_status = response["Stacks"][0]["StackStatus"] + if stack_status != "UPDATE_IN_PROGRESS": + print(f"stack status: {stack_status}") + break + else: + print("waiting for stack to finish updating") + time.sleep(60) + + +def upgrade_lambda(ver): + ecr_client = boto3.client("ecr", region_name=REGION_NAME) + if not is_image_label_ready(ecr_client, ver): + # only trigger the pull if the version is not already in ECR + # this helps speed up rollback + start_code_build_with_pin_version(ver) + # the wait is required due to eventual consistency + wait_until_image_is_ready(ecr_client, ver) + update_cf_lambda_ver(ver) + + +if __name__ == "__main__": + upgrade_lambda(IAMBIC_TARGET_VERSION) diff --git a/dev_tools/simulate_generic_git_in_aws_lambda/simulate_generic_git.py b/dev_tools/simulate_generic_git_in_aws_lambda/simulate_generic_git.py new file mode 100644 index 000000000..6763eb7fb --- /dev/null +++ b/dev_tools/simulate_generic_git_in_aws_lambda/simulate_generic_git.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import os +from functools import cache +from unittest.mock import patch + +import boto3 +import yaml + +from iambic.plugins.v0_1_0.generic_git_provider.aws_lambda_handler import run_handler + +DEV_REGION = os.environ.get("DEV_REGION", "us-west-2") +DEV_EMAIL_DOMAIN_SUFFIX = os.environ.get("DEV_EMAIL_DOMAIN_SUFFIX", "@example.com") +DEV_ACCOUNT_ID = os.environ.get("DEV_ACCOUNT_ID", "") +GIT_PROVIDER_UNDER_TEST = os.environ.get("GIT_PROVIDER_UNDER_TEST", "") + +# GIT_PROVIDER_UNDER_TEST valid ones are +# bitbucket +# codecommit +# gitlab + +# You cannot proceed without these values. Check your environment setup. +assert DEV_ACCOUNT_ID +assert GIT_PROVIDER_UNDER_TEST + + +@cache +def _get_app_secrets_as_lambda_context_current() -> dict: + # Create a Secrets Manager client + session = boto3.session.Session() + client = session.client(service_name="secretsmanager", region_name=DEV_REGION) + + try: + get_secret_value_response = client.get_secret_value( + SecretId="iambic-dev/generic-git-providers-secrets" + ) + except Exception as e: + # For a list of exceptions thrown, see + # https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html + raise e + + # Decrypts secret using the associated KMS key. + return yaml.safe_load(get_secret_value_response["SecretString"])["git_providers"][ + GIT_PROVIDER_UNDER_TEST + ] + + +if __name__ == "__main__": + # to simulate lambda, we are pretending to be a lambda function + os.environ["AWS_LAMBDA_FUNCTION_NAME"] = "simulate_generic_git.py" + + req = {"source": "EventBridgeCron", "command": "import"} + + with patch( + "iambic.plugins.v0_1_0.generic_git_provider.aws_lambda_handler._get_app_secrets_as_lambda_context_current", + new=_get_app_secrets_as_lambda_context_current, + ): + run_handler(req, None) diff --git a/docs/cep/000-cep-template.md b/docs/cep/000-cep-template.md index c668740cc..27d13a860 100644 --- a/docs/cep/000-cep-template.md +++ b/docs/cep/000-cep-template.md @@ -1,5 +1,8 @@ # CEP xxx - Code Enhancement Proposal Title +## Champion +Who will help organize the effort of getting this implemented? + ## Summary Short summary about a code enhancement proposal diff --git a/docs/cep/004-generic-git-provider-support.md b/docs/cep/004-generic-git-provider-support.md new file mode 100644 index 000000000..e10cbba54 --- /dev/null +++ b/docs/cep/004-generic-git-provider-support.md @@ -0,0 +1,53 @@ +# CEP 004 - Generic Git Provider Support + +## Champion +smoy + +## Summary +Quickly add support to other Git Provider that is not GitHub + +## Rationale +The GitHub integration took sometime because it uses GitHub App interaction model. Such app +support is not universal in other Git providers. We want to maximize other Git provider support +with minimum complexity. + +The most supported mechanism is git checkout repository using https. For the sake of concrete +examples, we will attempt to make this generic git provider at least support BitBucket, +AWS CodeCommit and GitLab. It's not limited to just these 3 providers. A Git provider +that supports git clone via https should be sufficient. + +https git clone for private repository typically involves http basic auth. We +recommend users use an repository scoped token for authentication. We strongly +advise against using actual username, password combination. Access token is +less prone to re-use across other services. + +Road Map +1. Launch import support with generic git provider. +2. Recruit additional help to implement Git Provider specific interactions. + +Git Provider specific interactions + +1. Each provider has different webhook event implementation details. +1. Each provider has different REST API +1. Each provider has different authentication + authorization model + +## Customer Experience +1. User will still use `iambic setup` to install a lambda function +1. The lambda function will be driven by AWS EventBridge to periodic +import. +1. During install, user will need to provide the following + +* username +* token +* clone url (must be https:// based) +* repo full name (typically company_name/repo_name ) +* default branch name (typically main or master) + +## Alternative +Is there alternative considered? + +## Implementation +What's needed on the implementation? + +## Compatibility concern +Is there any compatibility concern? \ No newline at end of file diff --git a/docs/web/docs/3-reference/14-generic-git-provider.mdx b/docs/web/docs/3-reference/14-generic-git-provider.mdx new file mode 100644 index 000000000..73badecc1 --- /dev/null +++ b/docs/web/docs/3-reference/14-generic-git-provider.mdx @@ -0,0 +1,54 @@ +--- +title: Setup IAMbic with Generic Git Provider +--- + +# Overview +IAMbic primarily supports GitHub, but you can set it up with other Git providers using this guide. +The method involves using HTTPS protocol and HTTP basic auth. Create a repository-specific access +token for safer authentication. + +If your Git provider is explicitly supported, it's better to use the specific +implementation for additional features. + +# Pre-requisites +1. Create a private repository called `iambic-templates` on your Git provider. Keep this repository private. +The contents of the repository will be like [this](https://github.com/noqdev/github-iambic-templates) +1. Run `iambic setup` and commit the generated config file to your private repository. +1. Generate a repository-scoped access token. Guidance for popular providers is provided below. + +# Generate Repository-Scoped Access Token + +## BitBucket +Follow the steps below to create a repository access token in BitBucket: +1. Navigate to `iambic-templates` → "Repository Settings" → "Access tokens" under Security. + +1. Click "Create Repository Access Token". + +1. Name it `iambic-integrations`, select "Write" under repository, and click Create. + + +1. Save the token. You'll use it as the "access token" and `x-token-auth` as the username. + + +## AWS CodeCommit +Follow these steps for AWS CodeCommit: +1. Navigate to IAM → User → "Security Credentials". + +1. Click "Generate credentials" and save them. + + +## GitLab +Here's how to generate a token in GitLab: +1. Go to `iambic-templates` → "Settings" → "Access Token". + +1. Click "Add new token", name it `iambic-integrations`, and select "Maintainer" role with "write repository" scope. + +1. Save the token. You'll use it as the "access token" and `iambic-integrations` as the username. + +# AWS Lambda Setup for Automatic Import +1. Navigate to your local copy of `iambic-templates` and run `iambic setup`. +1. Choose "Setup Generic Git Provider Integration using AWS Lambda". +1. Provide username, access token, clone URL, default branch, and repo full name when prompted. + 1. Example clone URL: "https://bitbucket.org/iambic-test-org/iambic-templates.git" + 1. Example repo full name: "YOUR_ORG_IDENTIFIER/iambic-templates" +1. Follow on-screen instructions to complete the setup. AWS Lambda will be configured to periodically run "iambic import". diff --git a/docs/web/static/img/git/generic-git-provider/aws-codecommit-iam-user-security-credentials-generate-credentials.png b/docs/web/static/img/git/generic-git-provider/aws-codecommit-iam-user-security-credentials-generate-credentials.png new file mode 100644 index 000000000..42809ecfa --- /dev/null +++ b/docs/web/static/img/git/generic-git-provider/aws-codecommit-iam-user-security-credentials-generate-credentials.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8a62c2ddd3943d6f2b816850a7375a4a0d8facf0de606ed0f558fda8230aacec +size 56092 diff --git a/docs/web/static/img/git/generic-git-provider/aws-codecommit-iam-user-security-credentials.png b/docs/web/static/img/git/generic-git-provider/aws-codecommit-iam-user-security-credentials.png new file mode 100644 index 000000000..7af40804a --- /dev/null +++ b/docs/web/static/img/git/generic-git-provider/aws-codecommit-iam-user-security-credentials.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:97ca939ee76231f5adaa1306d40695b90110d69e5480fcf1f3e0d803d99739a0 +size 95004 diff --git a/docs/web/static/img/git/generic-git-provider/aws-codecommit-iam-user.png b/docs/web/static/img/git/generic-git-provider/aws-codecommit-iam-user.png new file mode 100644 index 000000000..fa6644d91 --- /dev/null +++ b/docs/web/static/img/git/generic-git-provider/aws-codecommit-iam-user.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bdb1bf479d7c8642ea65e063b4e421f37cdd3c800d1b5a5ddc78a4e7e051c618 +size 91017 diff --git a/docs/web/static/img/git/generic-git-provider/bitbucket-clone.png b/docs/web/static/img/git/generic-git-provider/bitbucket-clone.png new file mode 100644 index 000000000..e7909aa61 --- /dev/null +++ b/docs/web/static/img/git/generic-git-provider/bitbucket-clone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4800baf3c24526eba7dbb572194b49c1ca20f8802bad66abfe211f89867ee242 +size 33357 diff --git a/docs/web/static/img/git/generic-git-provider/bitbucket-create-repository-access-token-2.png b/docs/web/static/img/git/generic-git-provider/bitbucket-create-repository-access-token-2.png new file mode 100644 index 000000000..6b291bc1c --- /dev/null +++ b/docs/web/static/img/git/generic-git-provider/bitbucket-create-repository-access-token-2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:64906beaf3d411af5fc5b30c53cac4141779e510e2f649e6224ef8d42ce61b56 +size 124779 diff --git a/docs/web/static/img/git/generic-git-provider/bitbucket-create-repository-access-token-3.png b/docs/web/static/img/git/generic-git-provider/bitbucket-create-repository-access-token-3.png new file mode 100644 index 000000000..b20c90790 --- /dev/null +++ b/docs/web/static/img/git/generic-git-provider/bitbucket-create-repository-access-token-3.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f3b0f1cadd4b3299ea3799461914be5c8493ae71e18c7a96ec22b3aee3a550e6 +size 60572 diff --git a/docs/web/static/img/git/generic-git-provider/bitbucket-create-repository-access-token.png b/docs/web/static/img/git/generic-git-provider/bitbucket-create-repository-access-token.png new file mode 100644 index 000000000..06254980c --- /dev/null +++ b/docs/web/static/img/git/generic-git-provider/bitbucket-create-repository-access-token.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e0d213d8b92473b39354402978802ddebdf38f9f8025f812897672f991d93308 +size 112749 diff --git a/docs/web/static/img/git/generic-git-provider/bitbucket-repository-settings.png b/docs/web/static/img/git/generic-git-provider/bitbucket-repository-settings.png new file mode 100644 index 000000000..6c478c9ba --- /dev/null +++ b/docs/web/static/img/git/generic-git-provider/bitbucket-repository-settings.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a1a39cdf1434d43e80ad193f37baefc35d36ed578b6231579efef2f4787753a9 +size 106597 diff --git a/docs/web/static/img/git/generic-git-provider/bitbucket-security-access-tokens.png b/docs/web/static/img/git/generic-git-provider/bitbucket-security-access-tokens.png new file mode 100644 index 000000000..4a30291d3 --- /dev/null +++ b/docs/web/static/img/git/generic-git-provider/bitbucket-security-access-tokens.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d42ab5610cfb4a1b7530eb4b62b5441796b45ee074edf050f43156ba3318f8b2 +size 105651 diff --git a/docs/web/static/img/git/generic-git-provider/gitlab-add-new-token-details.png b/docs/web/static/img/git/generic-git-provider/gitlab-add-new-token-details.png new file mode 100644 index 000000000..b39948e4d --- /dev/null +++ b/docs/web/static/img/git/generic-git-provider/gitlab-add-new-token-details.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:50ed01dc0896768f300b9081608938f0db70170e80bb46f5d29b542a710bbdfc +size 179523 diff --git a/docs/web/static/img/git/generic-git-provider/gitlab-add-new-token.png b/docs/web/static/img/git/generic-git-provider/gitlab-add-new-token.png new file mode 100644 index 000000000..0fd30c3f2 --- /dev/null +++ b/docs/web/static/img/git/generic-git-provider/gitlab-add-new-token.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9b48c557077bb66ca7008787b42cc9f9b59e6a4f11a3b8af751466d82acb02e8 +size 114376 diff --git a/docs/web/static/img/git/generic-git-provider/gitlab-settings.png b/docs/web/static/img/git/generic-git-provider/gitlab-settings.png new file mode 100644 index 000000000..b2a1d929c --- /dev/null +++ b/docs/web/static/img/git/generic-git-provider/gitlab-settings.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dc26e985ceda309ffaf3a44d37fe96cf89e51082c5e726919fdecbadffe4787b +size 161598 diff --git a/iambic/config/wizard.py b/iambic/config/wizard.py index a3a28db84..d881f2661 100644 --- a/iambic/config/wizard.py +++ b/iambic/config/wizard.py @@ -45,6 +45,9 @@ from iambic.core.template_generation import get_existing_template_map from iambic.core.utils import gather_templates, yaml from iambic.plugins.v0_1_0.aws.cloud_formation.utils import ( + create_code_build_roles_stack, + create_generic_git_provider_lambda_roles_stack, + create_generic_git_provider_lambda_stack, create_github_app_code_build_stack, create_github_app_ecr_pull_through_cache_stack, create_github_app_ecr_repo_stack, @@ -65,6 +68,7 @@ from iambic.plugins.v0_1_0.aws.models import ( ARN_RE, IAMBIC_CHANGE_DETECTION_SUFFIX, + IAMBIC_GENERIC_GIT_PROVIDER_SUFFIX, IAMBIC_HUB_ROLE_NAME, IAMBIC_SPOKE_ROLE_NAME, AWSAccount, @@ -1883,6 +1887,202 @@ def set_aws_cf_customization(self): tags = aws_cf_parse_key_value_string(unparse_tags) return hub_role_name, spoke_role_name, tags + def configuration_generic_git_provider_aws_lambda_setup(self): # noqa: C901 + input_username = questionary.text( + "username (consult your git provider docs when using access token): ", + ).ask() + input_token = questionary.password( + "access token (consult your git provider docs): ", + ).ask() + input_clone_url = questionary.text( + "clone url (must start with https://): ", + ).ask() + input_default_branch_name = questionary.text( + "default branch: ", default="main" + ).ask() + input_repo_full_name = questionary.text( + "repo full name (typically company_name/iambic-templates): ", + ).ask() + generic_git_provider_secrets = { + "username": input_username, + "token": input_token, + "clone_url": input_clone_url, + "default_branch_name": input_default_branch_name, + "repo_full_name": input_repo_full_name, + } + # TODO do a verification to prevent bad input get into secret manager + + click.echo( + "\nSetting up a GitHub App for IAMbic involves creating CloudFormation stacks. \n" + "If you wish to inspect the templates used or handle their deployment manually, use the `iambic_generic_git_provider` templates at the following location:\n" + "https://github.com/noqdev/iambic/tree/main/iambic/plugins/v0_1_0/aws/cloud_formation/templates\n" + "Note that IAMbic will verify the successful deployment of your stacks, and it will not attempt to overwrite or recreate them if they already exist.\n" + ) + + unparse_tags = questionary.text( + "Add Tags (leave blank or `team=ops_team, cost_center=engineering`): ", + default="", + validate=validate_aws_cf_input_tags, + ).ask() + tags = aws_cf_parse_key_value_string(unparse_tags) + if not questionary.confirm("Proceed?").unsafe_ask(): + return + + account_name_to_account_id = { + account.account_name: account.account_id + for account in self.config.aws.accounts + } + available_account_names = sorted(list(account_name_to_account_id.keys())) + questionary_params = {} + question_text = "We recommend you deploy Lambda integration on a non-management account.\nTarget AWS Account name: " + if len(available_account_names) == 1: + target_account_name = available_account_names[0] + elif len(available_account_names) < 10: + target_account_name = questionary.select( + question_text, choices=available_account_names, **questionary_params + ).unsafe_ask() + else: + target_account_name = questionary.autocomplete( + question_text, + choices=available_account_names, + style=CUSTOM_AUTO_COMPLETE_STYLE, + **questionary_params, + ).unsafe_ask() + + target_account_id = account_name_to_account_id[target_account_name] + log.info(f"Target AWS Account ID is {target_account_id}") + + session, _ = self.get_boto3_session_for_account(target_account_id) + + secretsmanager_client = session.client( + service_name="secretsmanager", region_name=self.aws_default_region + ) + secrets_kwargs = {} + generic_git_provider_secret_arn = None + if tags: + secrets_kwargs["Tags"] = tags + try: + response = secretsmanager_client.create_secret( + Name="iambic/generic-git-provider-secrets", + Description="iambic github app private key", + SecretString=yaml.dump(generic_git_provider_secrets), + **secrets_kwargs, + ) + generic_git_provider_secret_arn = response["ARN"] + except secretsmanager_client.exceptions.ResourceExistsException: + log.info( + f"iambic/generic-git-provider-secrets already exists in account: {target_account_id} in region: {self.aws_default_region}" + ) + + # verify the existing secrets actually match our known values + response = secretsmanager_client.describe_secret( + SecretId="iambic/generic-git-provider-secrets", + ) + generic_git_provider_secret_arn = response["ARN"] + response = secretsmanager_client.get_secret_value( + SecretId="iambic/generic-git-provider-secrets", + ) + + if not questionary.confirm( + "Continue with value in existing Secret?" + ).unsafe_ask(): + log.error( + "Please remove the iambic/generic-git-provider-secrets secret or update the secret before re-running this wizard" + ) + return + + cf_client = session.client( + "cloudformation", region_name=self.aws_default_region + ) + + # Note: We are not going to prompt user to give us an optional CloudFormation Role ARN + # to use because it seems like additional friction. If we get feedback to restore it, + # we will simply called cf_role_arn = self.cf_role_arn + cf_role_arn = None + + successfully_created = asyncio.run( + create_generic_git_provider_lambda_roles_stack( + cf_client, + self.hub_account_id, + IAMBIC_HUB_ROLE_NAME, + cf_role_arn, + tags=tags, + ) + ) + assert successfully_created + + # modify the trust policy of IambicHubRole to allow iambic lambda execution role + lambda_role_arn = f"arn:aws:iam::{target_account_id}:role/iambic_generic_git_provider_lambda_execution{IAMBIC_GENERIC_GIT_PROVIDER_SUFFIX}" + + self.github_app_amend_trust_policy_for_iambic_integration( + lambda_role_arn, target_sid="AllowIAMbicGenericGitProviderLambdaIntegration" + ) + + successfully_created = asyncio.run( + create_code_build_roles_stack( + cf_client, + cf_role_arn, + tags=tags, + ) + ) + assert successfully_created + + try: + successfully_created = asyncio.run( + create_github_app_ecr_pull_through_cache_stack( + cf_client, + cf_role_arn, + stack_name=f"IAMbicECRPullThroughCache{IAMBIC_GENERIC_GIT_PROVIDER_SUFFIX}", + tags=tags, + ) + ) + assert successfully_created + + successfully_created = asyncio.run( + create_github_app_ecr_repo_stack( + cf_client, + cf_role_arn, + stack_name=f"IAMbicECRRepo{IAMBIC_GENERIC_GIT_PROVIDER_SUFFIX}", + tags=tags, + ) + ) + assert successfully_created + + code_build_role_arn = f"arn:aws:iam::{target_account_id}:role/iambic_code_build{IAMBIC_GENERIC_GIT_PROVIDER_SUFFIX}" + code_build_name = f"iambic_code_build{IAMBIC_GENERIC_GIT_PROVIDER_SUFFIX}" + + successfully_created = asyncio.run( + create_github_app_code_build_stack( + cf_client, + target_account_id, + cf_role_arn, + code_build_role_arn=code_build_role_arn, + code_build_name=code_build_name, + stack_name=f"IAMbicGenericGitProviderCodeBuild{IAMBIC_GENERIC_GIT_PROVIDER_SUFFIX}", + tags=tags, + ) + ) + assert successfully_created + + except Exception as e: + log.error(str(e)) + # keep going: doing this because during development, we use the ecr cache rule and repo for many other things + + self.github_app_pull_latest_iambic_image(session) + + self.github_app_wait_until_image_is_ready(session) + + successfully_created = asyncio.run( + create_generic_git_provider_lambda_stack( + cf_client, + target_account_id, + generic_git_provider_secret_arn, + cf_role_arn, + tags=tags, + ) + ) + assert successfully_created + def configuration_github_app_aws_lambda_setup(self): # noqa: C901 from iambic.plugins.v0_1_0.aws.cloud_formation.utils import ( IAMBIC_GITHUB_APP_SUFFIX, @@ -2163,7 +2363,11 @@ def configuration_github_app_aws_lambda_setup(self): # noqa: C901 # Remove the local secrets because it's already saved in secret manager remove_github_app_secrets() - def github_app_amend_trust_policy_for_iambic_integration(self, lambda_role_arn): + def github_app_amend_trust_policy_for_iambic_integration( + self, lambda_role_arn, target_sid=None + ): + if target_sid is None: + target_sid = "AllowIAMbicLambdaIntegration" hub_session, _ = self.get_boto3_session_for_account(self.hub_account_id) hub_iam_client = hub_session.client("iam", region_name=self.aws_default_region) hub_role_arn = self.config.aws.hub_role_arn @@ -2174,13 +2378,13 @@ def github_app_amend_trust_policy_for_iambic_integration(self, lambda_role_arn): needs_to_add_statement = True statements: list = existing_trust_policy.get("Statement", []) new_statement = { - "Sid": "AllowIAMbicLambdaIntegration", + "Sid": target_sid, "Effect": "Allow", "Principal": {"AWS": lambda_role_arn}, "Action": ["sts:AssumeRole", "sts:TagSession"], } for statement in statements: # FIXME: watch out other cases - if statement.get("Sid", "") == "AllowIAMbicLambdaIntegration": + if statement.get("Sid", "") == target_sid: needs_to_add_statement = False if statement["Principal"]["AWS"] != lambda_role_arn: # Update the statement @@ -2332,6 +2536,9 @@ def run(self): # noqa: C901 if self.has_aws_account_or_organizations: choices.append("Setup GitHub App Integration using AWS Lambda") + choices.append( + "Setup Generic Git Provider Integration using AWS Lambda" + ) if ( self.config.aws.organizations and not self.config.aws.sqs_cloudtrail_changes_queues @@ -2379,6 +2586,16 @@ def run(self): # noqa: C901 log.info( "Unable to edit this attribute without CloudFormation permissions." ) + elif ( + action == "Setup Generic Git Provider Integration using AWS Lambda" + ): + self.setup_aws_configuration() + if self.has_confirm_cf_permissions: + self.configuration_generic_git_provider_aws_lambda_setup() + else: + log.info( + "Unable to edit this attribute without CloudFormation permissions." + ) elif action == "Setup AWS change detection": self.setup_aws_configuration() if self.has_cf_stacksets_permissions: diff --git a/iambic/plugins/v0_1_0/aws/cloud_formation/templates/iambic_code_build_roles.yaml b/iambic/plugins/v0_1_0/aws/cloud_formation/templates/iambic_code_build_roles.yaml new file mode 100644 index 000000000..b17cd1c24 --- /dev/null +++ b/iambic/plugins/v0_1_0/aws/cloud_formation/templates/iambic_code_build_roles.yaml @@ -0,0 +1,51 @@ +AWSTemplateFormatVersion: 2010-09-09 +Description: This creates AWS Roles required by the IAMbic git integration to run Lambda using a container image +Parameters: + IambicCodeBuildRoleName: + Type: String + Default: "iambic_lambda_code_build" + IambicECRRepoName: + Type: String + Default: "iambic-ecr-public/iambic/iambic" +Resources: + IambicCodeBuildRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: + - codebuild.amazonaws.com + Action: + - "sts:AssumeRole" + Description: Execution role for IAMbic code build + MaxSessionDuration: 3600 + Policies: + - PolicyName: code-build + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: "Logging" + Effect: Allow + Action: + - "logs:CreateLogGroup" + - "logs:CreateLogStream" + - "logs:PutLogEvents" + Resource: "*" + - Sid: "ECRGetAuthToken" + Effect: Allow + Action: + - "ecr:GetAuthorizationToken" + Resource: "*" + - Sid: "ECRPull" + Effect: Allow + Action: + - "ecr:BatchCheckLayerAvailability" + - "ecr:BatchImportUpstreamImage" + - "ecr:BatchGetImage" + - "ecr:GetDownloadUrlForLayer" + Resource: + - !Sub "arn:*:ecr:*:*:repository/${IambicECRRepoName}" + RoleName: !Ref IambicCodeBuildRoleName \ No newline at end of file diff --git a/iambic/plugins/v0_1_0/aws/cloud_formation/templates/iambic_generic_git_provider_lambda.yaml b/iambic/plugins/v0_1_0/aws/cloud_formation/templates/iambic_generic_git_provider_lambda.yaml new file mode 100644 index 000000000..48c6216e1 --- /dev/null +++ b/iambic/plugins/v0_1_0/aws/cloud_formation/templates/iambic_generic_git_provider_lambda.yaml @@ -0,0 +1,156 @@ +AWSTemplateFormatVersion: 2010-09-09 +Description: This creates a lambda function and function url to enable IAMbic generic git provider Integration +Parameters: + LambdaFunctionName: + Type: String + Default: "iambic_generic_git_provider_webhook" + ImageUri: + Type: String + LambdaExecutionRoleArn: + Type: String + GenericGitProviderSecretArn: + Type: String + LambdaMemorySize: + Type: Number + Default: 2048 + LambdaTimeout: + Type: Number + Default: 900 + ImportSchedule: + Type: String + Default: "cron(0 */2 * * ? *)" # Every 2 hours by default + ExpireSchedule: + Type: String + Default: "cron(5 * * * ? *)" # Every hour at minute 5 + EnforceSchedule: + Type: String + Default: "cron(0 * * * ? *)" # Every hour + DetectSchedule: + Type: String + Default: "cron(*/5 * * * ? *)" # Every 5 minutes +Resources: + IambicWebHookLambda: + Type: 'AWS::Lambda::Function' + Properties: + FunctionName: + Ref: LambdaFunctionName + PackageType: Image + ImageConfig: + Command: + - "iambic.plugins.v0_1_0.generic_git_provider.aws_lambda_handler.run_handler" + EntryPoint: + - "python" + - "-m" + - "awslambdaric" + Role: + Ref: LambdaExecutionRoleArn + Code: + ImageUri: + Ref: ImageUri + Description: IAMbic Webhook Lambda + TracingConfig: + Mode: Active + MemorySize: + Ref: LambdaMemorySize + Timeout: + Ref: LambdaTimeout + Environment: + Variables: + GENERIC_GIT_PROVIDER_SECRET_ARN: !Ref GenericGitProviderSecretArn + IambicWebHookLambdaLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Sub "/aws/lambda/${IambicWebHookLambda}" + RetentionInDays: 3 + IambicWebHookLambdaPermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref IambicWebHookLambda + # IambicWebHook is secured using shared webhook secret between GitHub App and Lambda + FunctionUrlAuthType: 'NONE' + Action: lambda:InvokeFunctionUrl + Principal: '*' + IambicWebHookUrl: + Type: 'AWS::Lambda::Url' + Properties: + AuthType: "NONE" + TargetFunctionArn: !GetAtt IambicWebHookLambda.Arn + DependsOn: + - IambicWebHookLambda + + ImportCronRule: + Type: 'AWS::Events::Rule' + Properties: + ScheduleExpression: + Ref: ImportSchedule + Targets: + - Arn: !GetAtt IambicWebHookLambda.Arn + Id: "ImportCronTarget" + Input: '{"command": "import", "source": "EventBridgeCron"}' + + ExpireCronRule: + Type: 'AWS::Events::Rule' + Properties: + ScheduleExpression: + Ref: ExpireSchedule + Targets: + - Arn: !GetAtt IambicWebHookLambda.Arn + Id: "ExpireCronTarget" + Input: '{"command": "expire", "source": "EventBridgeCron"}' + + EnforceCronRule: + Type: 'AWS::Events::Rule' + Properties: + ScheduleExpression: + Ref: EnforceSchedule + Targets: + - Arn: !GetAtt IambicWebHookLambda.Arn + Id: "EnforceCronTarget" + Input: '{"command": "enforce", "source": "EventBridgeCron"}' + + DetectCronRule: + Type: 'AWS::Events::Rule' + Properties: + ScheduleExpression: + Ref: DetectSchedule + Targets: + - Arn: !GetAtt IambicWebHookLambda.Arn + Id: "DetectCronTarget" + Input: '{"command": "detect", "source": "EventBridgeCron"}' + + ImportCronLambdaPermission: + Type: "AWS::Lambda::Permission" + Properties: + FunctionName: !Ref IambicWebHookLambda + Action: "lambda:InvokeFunction" + Principal: "events.amazonaws.com" + SourceArn: !GetAtt ImportCronRule.Arn + + ExpireCronLambdaPermission: + Type: "AWS::Lambda::Permission" + Properties: + FunctionName: !Ref IambicWebHookLambda + Action: "lambda:InvokeFunction" + Principal: "events.amazonaws.com" + SourceArn: !GetAtt ExpireCronRule.Arn + + EnforceCronLambdaPermission: + Type: "AWS::Lambda::Permission" + Properties: + FunctionName: !Ref IambicWebHookLambda + Action: "lambda:InvokeFunction" + Principal: "events.amazonaws.com" + SourceArn: !GetAtt EnforceCronRule.Arn + + DetectCronLambdaPermission: + Type: "AWS::Lambda::Permission" + Properties: + FunctionName: !Ref IambicWebHookLambda + Action: "lambda:InvokeFunction" + Principal: "events.amazonaws.com" + SourceArn: !GetAtt DetectCronRule.Arn + +Outputs: + FunctionUrl: + Description: URL of the Lambda Function + Value: !GetAtt IambicWebHookUrl.FunctionUrl \ No newline at end of file diff --git a/iambic/plugins/v0_1_0/aws/cloud_formation/templates/iambic_generic_git_provider_lambda_roles.yaml b/iambic/plugins/v0_1_0/aws/cloud_formation/templates/iambic_generic_git_provider_lambda_roles.yaml new file mode 100644 index 000000000..55950ebda --- /dev/null +++ b/iambic/plugins/v0_1_0/aws/cloud_formation/templates/iambic_generic_git_provider_lambda_roles.yaml @@ -0,0 +1,48 @@ +AWSTemplateFormatVersion: 2010-09-09 +Description: This creates AWS Roles required by the IAMbic generic git provider integration +Parameters: + IambicHubRoleArn: + Type: String + IambicWebhookLambdaExecutionRoleName: + Type: String + Default: "iambic_generic_git_provider_lambda_execution" +Resources: + IambicWebhookLambdaExecutionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + Action: + - "sts:AssumeRole" + Description: Execution role for IAMbic Lambda Webhook + MaxSessionDuration: 3600 + Policies: + - PolicyName: Lambda + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: "Logging" + Effect: Allow + Action: + - "logs:CreateLogGroup" + - "logs:CreateLogStream" + - "logs:PutLogEvents" + Resource: "*" + - Sid: "SecretReading" + Effect: Allow + Action: + - "secretsmanager:GetSecretValue" + Resource: + - "arn:aws:secretsmanager:*:*:secret:iambic/generic-git-provider-secrets-*" + - Sid: "AssumeRole" + Effect: Allow + Action: + - "sts:AssumeRole" + Resource: + - !Ref IambicHubRoleArn + RoleName: !Ref IambicWebhookLambdaExecutionRoleName diff --git a/iambic/plugins/v0_1_0/aws/cloud_formation/utils.py b/iambic/plugins/v0_1_0/aws/cloud_formation/utils.py index 9a9042b36..7abafecd7 100644 --- a/iambic/plugins/v0_1_0/aws/cloud_formation/utils.py +++ b/iambic/plugins/v0_1_0/aws/cloud_formation/utils.py @@ -10,6 +10,7 @@ from iambic.core.logger import log from iambic.plugins.v0_1_0.aws.models import ( IAMBIC_CHANGE_DETECTION_SUFFIX, + IAMBIC_GENERIC_GIT_PROVIDER_SUFFIX, IAMBIC_GITHUB_APP_SUFFIX, IAMBIC_HUB_ROLE_NAME, IAMBIC_SPOKE_ROLE_NAME, @@ -32,6 +33,24 @@ def get_central_rule_template_body() -> str: return f.read() +def get_generic_git_provider_lambda_roles_template_body() -> str: + template = f"{TEMPLATE_DIR}/iambic_generic_git_provider_lambda_roles.yaml" + with open(template, "r") as f: + return f.read() + + +def get_code_build_roles_template_body() -> str: + template = f"{TEMPLATE_DIR}/iambic_code_build_roles.yaml" + with open(template, "r") as f: + return f.read() + + +def get_generic_git_provider_lambda_template_body() -> str: + template = f"{TEMPLATE_DIR}/iambic_generic_git_provider_lambda.yaml" + with open(template, "r") as f: + return f.read() + + def get_github_app_roles_template_body() -> str: template = f"{TEMPLATE_DIR}/iambic_github_app_roles.yaml" with open(template, "r") as f: @@ -282,6 +301,68 @@ async def create_stack_set( return not bool(failed_instances) +async def create_generic_git_provider_lambda_roles_stack( + cf_client, + hub_account_id: str, + hub_role_name: str, + role_arn: str = None, + tags: Optional[dict] = None, +) -> bool: + additional_kwargs: dict[str, Any] = {"RoleARN": role_arn} if role_arn else {} + + if tags: + additional_kwargs["Tags"] = tags + + stack_created = await create_stack( + cf_client, + stack_name=f"IAMbicGenericGitLambdaRole{IAMBIC_GENERIC_GIT_PROVIDER_SUFFIX}", + template_body=get_generic_git_provider_lambda_roles_template_body(), + parameters=[ + { + "ParameterKey": "IambicHubRoleArn", + "ParameterValue": get_hub_role_arn( + hub_account_id, role_name=hub_role_name + ), + }, + { + "ParameterKey": "IambicWebhookLambdaExecutionRoleName", + "ParameterValue": f"iambic_generic_git_provider_lambda_execution{IAMBIC_GENERIC_GIT_PROVIDER_SUFFIX}", + }, + ], + Capabilities=["CAPABILITY_NAMED_IAM"], + **additional_kwargs, + ) + + return stack_created + + +async def create_code_build_roles_stack( + cf_client, + role_arn: str = None, + tags: Optional[dict] = None, +) -> bool: + additional_kwargs: dict[str, Any] = {"RoleARN": role_arn} if role_arn else {} + + if tags: + additional_kwargs["Tags"] = tags + + stack_created = await create_stack( + cf_client, + stack_name=f"IAMbicCodeBuildRoles{IAMBIC_GITHUB_APP_SUFFIX}", + template_body=get_code_build_roles_template_body(), + parameters=[ + { + "ParameterKey": "IambicCodeBuildRoleName", + "ParameterValue": f"iambic_code_build{IAMBIC_GENERIC_GIT_PROVIDER_SUFFIX}", + }, + ], + Capabilities=["CAPABILITY_NAMED_IAM"], + **additional_kwargs, + ) + + return stack_created + + async def create_github_app_roles_stack( cf_client, hub_account_id: str, @@ -324,6 +405,7 @@ async def create_github_app_roles_stack( async def create_github_app_ecr_pull_through_cache_stack( cf_client, role_arn: str = None, + stack_name: str = None, tags: Optional[dict] = None, ) -> bool: additional_kwargs: dict[str, Any] = {"RoleARN": role_arn} if role_arn else {} @@ -331,9 +413,12 @@ async def create_github_app_ecr_pull_through_cache_stack( if tags: additional_kwargs["Tags"] = tags + if stack_name is None: + stack_name = f"IAMbicGitHubAppECRPullThroughCache{IAMBIC_GITHUB_APP_SUFFIX}" + stack_created = await create_stack( cf_client, - stack_name=f"IAMbicGitHubAppECRPullThroughCache{IAMBIC_GITHUB_APP_SUFFIX}", + stack_name=stack_name, template_body=get_github_app_ecr_pull_through_template_body(), parameters=[], **additional_kwargs, @@ -345,6 +430,7 @@ async def create_github_app_ecr_pull_through_cache_stack( async def create_github_app_ecr_repo_stack( cf_client, role_arn: str = None, + stack_name: str = None, tags: Optional[dict] = None, ) -> bool: additional_kwargs: dict[str, Any] = {"RoleARN": role_arn} if role_arn else {} @@ -352,9 +438,12 @@ async def create_github_app_ecr_repo_stack( if tags: additional_kwargs["Tags"] = tags + if stack_name is None: + stack_name = f"IAMbicGitHubAppECRRepo{IAMBIC_GITHUB_APP_SUFFIX}" + stack_created = await create_stack( cf_client, - stack_name=f"IAMbicGitHubAppECRRepo{IAMBIC_GITHUB_APP_SUFFIX}", + stack_name=stack_name, template_body=get_github_app_ecr_repo_template_body(), parameters=[], **additional_kwargs, @@ -367,6 +456,9 @@ async def create_github_app_code_build_stack( cf_client, target_account_id: str, role_arn: str = None, + code_build_name: str = None, + code_build_role_arn: str = None, + stack_name: str = None, tags: Optional[dict] = None, ) -> bool: additional_kwargs: dict[str, Any] = {"RoleARN": role_arn} if role_arn else {} @@ -374,14 +466,67 @@ async def create_github_app_code_build_stack( if tags: additional_kwargs["Tags"] = tags + if code_build_role_arn is None: + code_build_role_arn = f"arn:aws:iam::{target_account_id}:role/iambic_code_build{IAMBIC_GITHUB_APP_SUFFIX}" + + if stack_name is None: + stack_name = f"IAMbicGitHubAppCodeBuild{IAMBIC_GITHUB_APP_SUFFIX}" + + if code_build_name is None: + code_build_name = ("iambic_code_build",) + stack_created = await create_stack( cf_client, - stack_name=f"IAMbicGitHubAppCodeBuild{IAMBIC_GITHUB_APP_SUFFIX}", + stack_name=stack_name, template_body=get_github_app_code_build_template_body(), parameters=[ { "ParameterKey": "CodeBuildServiceRoleArn", - "ParameterValue": f"arn:aws:iam::{target_account_id}:role/iambic_code_build{IAMBIC_GITHUB_APP_SUFFIX}", + "ParameterValue": code_build_role_arn, + }, + { + "ParameterKey": "IambicCodeBuildName", + "ParameterValue": code_build_name, + }, + ], + **additional_kwargs, + ) + + return stack_created + + +async def create_generic_git_provider_lambda_stack( + cf_client, + target_account_id: str, + generic_git_provider_secret_arn: str, + role_arn: str = None, + tags: Optional[dict] = None, +) -> bool: + region = cf_client.meta.region_name + additional_kwargs: dict[str, Any] = {"RoleARN": role_arn} if role_arn else {} + assert target_account_id + if tags: + additional_kwargs["Tags"] = tags + stack_created = await create_stack( + cf_client, + stack_name=f"IAMbicGenericGitProviderLambda{IAMBIC_GENERIC_GIT_PROVIDER_SUFFIX}", + template_body=get_generic_git_provider_lambda_template_body(), + parameters=[ + { + "ParameterKey": "ImageUri", + "ParameterValue": f"{target_account_id}.dkr.ecr.{region}.amazonaws.com/iambic-ecr-public/iambic/iambic:latest", + }, + { + "ParameterKey": "LambdaExecutionRoleArn", + "ParameterValue": f"arn:aws:iam::{target_account_id}:role/iambic_generic_git_provider_lambda_execution{IAMBIC_GENERIC_GIT_PROVIDER_SUFFIX}", + }, + { + "ParameterKey": "LambdaFunctionName", + "ParameterValue": f"iambic_generic_git_provider_webhook{IAMBIC_GENERIC_GIT_PROVIDER_SUFFIX}", + }, + { + "ParameterKey": "GenericGitProviderSecretArn", + "ParameterValue": generic_git_provider_secret_arn, }, ], **additional_kwargs, diff --git a/iambic/plugins/v0_1_0/aws/models.py b/iambic/plugins/v0_1_0/aws/models.py index 5a30e2637..1d8ee859e 100644 --- a/iambic/plugins/v0_1_0/aws/models.py +++ b/iambic/plugins/v0_1_0/aws/models.py @@ -57,6 +57,7 @@ IAMBIC_SPOKE_ROLE_NAME = os.getenv("IAMBIC_SPOKE_ROLE_NAME", "IambicSpokeRole") IAMBIC_CHANGE_DETECTION_SUFFIX = os.getenv("IAMBIC_CHANGE_DETECTION_SUFFIX", "") IAMBIC_GITHUB_APP_SUFFIX = os.getenv("IAMBIC_GITHUB_APP_SUFFIX", "") +IAMBIC_GENERIC_GIT_PROVIDER_SUFFIX = os.getenv("IAMBIC_GENERIC_GIT_PROVIDER_SUFFIX", "") def get_hub_role_arn(account_id: str, role_name=None) -> str: diff --git a/iambic/plugins/v0_1_0/generic_git_provider/__init__.py b/iambic/plugins/v0_1_0/generic_git_provider/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/iambic/plugins/v0_1_0/generic_git_provider/aws_lambda_handler.py b/iambic/plugins/v0_1_0/generic_git_provider/aws_lambda_handler.py new file mode 100644 index 000000000..b0fc6f174 --- /dev/null +++ b/iambic/plugins/v0_1_0/generic_git_provider/aws_lambda_handler.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import os +import shutil +import tempfile +import traceback +from functools import cache +from typing import Callable + +import boto3 +import yaml +from botocore.exceptions import ClientError + +import iambic.core.utils +import iambic.plugins.v0_1_0.github.github +from iambic.core.logger import log +from iambic.plugins.v0_1_0.generic_git_provider.generic_git_client import ( + create_git_client, +) +from iambic.plugins.v0_1_0.github.github import ( + _handle_import, + get_session_name, + iambic_app, +) + +GENERIC_GIT_PROVIDER_SECRET_ARN = os.environ.get("GENERIC_GIT_PROVIDER_SECRET_ARN") + + +def run_handler(event=None, context=None): + """ + Default handler for AWS Lambda. It is split out from the actual + handler so we can also run via IDE run configurations + """ + + # debug + print("Event: ", event) + + # Check if the event source is CloudWatch Events + if isinstance(event, dict) and event.get("source") == "EventBridgeCron": + return handle_events_cron(event, context) + + +@cache +def _get_app_secrets_as_lambda_context_current() -> dict: + # Create a Secrets Manager client + session = boto3.session.Session() + client = session.client(service_name="secretsmanager") + + try: + get_secret_value_response = client.get_secret_value( + SecretId=GENERIC_GIT_PROVIDER_SECRET_ARN, + ) + except ClientError as e: + # For a list of exceptions thrown, see + # https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html + raise e + + # Decrypts secret using the associated KMS key. + return yaml.safe_load(get_secret_value_response["SecretString"]) + + +def handle_events_cron(event=None, context=None) -> None: + secrets = _get_app_secrets_as_lambda_context_current() + git_client = create_git_client(secrets) + + command = event["command"] + + if not (callable := AWS_EVENTS_WORKFLOW_DISPATCH_MAP.get(command)): + log_params = {"command": command} + log.error("handle_events_cron: Unable to find command", **log_params) + return + + repo_url = git_client.repo_url + + # TODO, don't have a generic GitProviderRepo interface yet + # templates_repo = git_client.get_repo(REPOSITORY_FULL_NAME) + # TODO, don't have a generic GitProviderRepoFullName interface yet + default_branch = git_client.default_branch_name + repo_full_name = git_client.repo_full_name + temp_templates_directory = tempfile.mkdtemp(prefix="lambda") + os.chdir(temp_templates_directory) + getattr(iambic_app, "lambda").app.init_plan_output_path() + getattr(iambic_app, "lambda").app.init_repo_base_path() + iambic.plugins.v0_1_0.github.github.init_shared_data_directory() + iambic.core.utils.init_writable_directory() + + return callable( + git_client, + None, # TODO, don't have a generic GitProviderRepo interface yet + repo_full_name, + repo_url, + default_branch, + proposed_changes_path=getattr(iambic_app, "lambda").app.PLAN_OUTPUT_PATH, + ) + + +def workflow_wrapper(workflow_func: Callable, ux_op_name: str) -> Callable: + def wrapped_workflow_func( + github_client, + templates_repo, + repo_name: str, + repo_url: str, + default_branch: str, + proposed_changes_path: str = None, + ): + pull_number = ( + 0 # 0 is not a valid pull number in github. workflow implementation + ) + # does not have an associated PR. This is the path of least resistance adaption + # for stable session name + session_name = get_session_name(repo_name, pull_number) + os.environ["IAMBIC_SESSION_NAME"] = session_name + + try: + if proposed_changes_path: + # code smell to have to change a module variable + # to control the destination of proposed_changes.yaml + # It's questionable if we still need to depend on the lambda interface + # because lambda interface was created to dynamic populate template config + # but templates config is now already stored in the templates repo itself. + getattr( + iambic_app, "lambda" + ).app.PLAN_OUTPUT_PATH = proposed_changes_path + + template_changes = workflow_func( + repo_url, + default_branch, + github_client=github_client, + templates_repo=templates_repo, + ) + + if template_changes: + # TODO This is here just so pre-commit won't yell at me + assert True + + # TODO disable _post_artifact_to_companion_repository because i don't have an interface defined yet + # _process_template_changes( + # github_client, + # templates_repo, + # None, + # pull_number, + # proposed_changes_path, + # template_changes, + # f"{ux_op_name}", # TODO this can probably be improved for user-experience + # ) + except Exception as e: + captured_traceback = traceback.format_exc() + log.error("fault", exception=captured_traceback) + try: + temp_dir = tempfile.mkdtemp(suffix=None, prefix=None, dir=None) + with open(f"{temp_dir}/crash.txt", "w") as f: + f.write(captured_traceback) + # TODO disable _post_artifact_to_companion_repository because i don't have an interface defined yet + # _post_artifact_to_companion_repository( + # github_client, + # templates_repo, + # pull_number, + # f"{ux_op_name}", + # f"{temp_dir}/crash.txt", + # captured_traceback, + # default_base_name="crash.txt", + # ) + except Exception: + captured_traceback = traceback.format_exc() + log.error( + "fail to post exception to companion repo", + exception=captured_traceback, + ) + finally: + if temp_dir: + shutil.rmtree(temp_dir) + raise e + + return wrapped_workflow_func + + +AWS_EVENTS_WORKFLOW_DISPATCH_MAP: dict[str, Callable] = { + # "enforce": workflow_wrapper(_handle_enforce, "enforce"), + # "expire": workflow_wrapper(_handle_expire, "expire"), + "import": workflow_wrapper(_handle_import, "import"), + # "detect": workflow_wrapper( + # _handle_detect_changes_from_eventbridge, "detect" + # ), +} diff --git a/iambic/plugins/v0_1_0/generic_git_provider/generic_git_client.py b/iambic/plugins/v0_1_0/generic_git_provider/generic_git_client.py new file mode 100644 index 000000000..0913d98a0 --- /dev/null +++ b/iambic/plugins/v0_1_0/generic_git_provider/generic_git_client.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import urllib + + +class GenericGitClient(object): + def __init__(self, secrets): + """ + secrets has to be a dictionary format as the following + + username: non-empty string (before url_quote_plus) + token: non-empty string (before url_quote_plus) + clone_url: typically https://git-provider.com/group/repo_name (must be https:// protocol) + default_branch_name: typically main or master + repo_full_name: must be in group/repo format (like example_corp/iambic-templates) + """ + self.username = secrets["username"] + self.token = secrets["token"] + self.clone_url = secrets["clone_url"] + self.default_branch_name = secrets["default_branch_name"] + self.repo_full_name = secrets["repo_full_name"] + + # we should always take the precaution someone give us a non-https url + assert self.clone_url.startswith("https://") + # we should always take the precaution someone did not sneak an @ in the url + # that may accidentally be a password. A simple assert may print the potential + # url with password + if "@" in self.clone_url: + raise ValueError("@ is detected in url") + self.clone_url_without_protocol = self.clone_url.replace("https://", "") + + @property + def repo_url(self): + encoded_username = urllib.parse.quote_plus(self.username) + encoded_token = urllib.parse.quote_plus(self.token) + return f"https://{encoded_username}:{encoded_token}@{self.clone_url_without_protocol}" + + +def create_git_client(secrets): + return GenericGitClient(secrets)