diff --git a/.terraform.lock.hcl b/.terraform.lock.hcl new file mode 100644 index 0000000..2c5d0fb --- /dev/null +++ b/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.94.1" + constraints = "~> 5.0" + hashes = [ + "h1:pm3uoaQYHaavwE83zsEzAFn/LKD1EWGiYRfzVxNCaIA=", + "zh:14fb41e50219660d5f02b977e6f786d8ce78766cce8c2f6b8131411b087ae945", + "zh:3bc5d12acd5e1a5f1cf78a7f05d0d63f988b57485e7d20c47e80a0b723a99d26", + "zh:4835e49377f80a37c6191a092f636e227a9f086e3cc3f0c9e1b554da8793cfe8", + "zh:605971275adae25096dca30a94e29931039133c667c1d9b38778a09594312964", + "zh:8ae46b4a9a67815facf59da0c56d74ef71bcc77ae79e8bfbac504fa43f267f8e", + "zh:913f3f371c3e6d1f040d6284406204b049977c13cb75aae71edb0ef8361da7dd", + "zh:91f85ae8c73932547ad7139ce0b047a6a7c7be2fd944e51db13231cc80ce6d8e", + "zh:96352ae4323ce137903b9fe879941f894a3ce9ef30df1018a0f29f285a448793", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9b51922c9201b1dc3d05b39f9972715db5f67297deee088793d02dea1832564b", + "zh:a689e82112aa71e15647b06502d5b585980cd9002c3cc8458f092e8c8a667696", + "zh:c3723fa3e6aff3c1cc0088bdcb1edee168fe60020f2f77161d135bf473f45ab2", + "zh:d6a2052b864dd394b01ad1bae32d0a7d257940ee47908d02df7fa7873981d619", + "zh:dda4c9c0406cc54ad8ee4f19173a32de7c6e73abb5a948ea0f342d567df26a1d", + "zh:f42e0fe592b97cbdf70612f0fbe2bab851835e2d1aaf8cbb87c3ab0f2c96bb27", + ] +} diff --git a/.terraform/providers/registry.terraform.io/hashicorp/aws/5.94.1/linux_amd64 b/.terraform/providers/registry.terraform.io/hashicorp/aws/5.94.1/linux_amd64 new file mode 120000 index 0000000..1263af3 --- /dev/null +++ b/.terraform/providers/registry.terraform.io/hashicorp/aws/5.94.1/linux_amd64 @@ -0,0 +1 @@ +/data/terraform/workspaces/arnol377/terraform-plugin-cache/registry.terraform.io/hashicorp/aws/5.94.1/linux_amd64 \ No newline at end of file diff --git a/.terraform_commits b/.terraform_commits new file mode 100644 index 0000000..ebeee12 --- /dev/null +++ b/.terraform_commits @@ -0,0 +1,56 @@ +[ + { + "commit_hash": "018399a6523b3c5c1d7503b2c0de8cd66eb8d8e5", + "commit_message": "Merge branch 'main' of github.e.it.census.gov:SCT-Engineering/eks-automation-lambda", + "author": "arnol377", + "timestamp": "2025-04-16T13:12:39.190090" + }, + { + "commit_hash": "018399a6523b3c5c1d7503b2c0de8cd66eb8d8e5", + "commit_message": "Merge branch 'main' of github.e.it.census.gov:SCT-Engineering/eks-automation-lambda", + "author": "arnol377", + "timestamp": "2025-04-16T13:13:33.377155" + }, + { + "commit_hash": "018399a6523b3c5c1d7503b2c0de8cd66eb8d8e5", + "commit_message": "Merge branch 'main' of github.e.it.census.gov:SCT-Engineering/eks-automation-lambda", + "author": "arnol377", + "timestamp": "2025-04-16T13:14:04.341717" + }, + { + "commit_hash": "018399a6523b3c5c1d7503b2c0de8cd66eb8d8e5", + "commit_message": "Merge branch 'main' of github.e.it.census.gov:SCT-Engineering/eks-automation-lambda", + "author": "arnol377", + "timestamp": "2025-04-16T13:46:26.319927" + }, + { + "commit_hash": "018399a6523b3c5c1d7503b2c0de8cd66eb8d8e5", + "commit_message": "Merge branch 'main' of github.e.it.census.gov:SCT-Engineering/eks-automation-lambda", + "author": "arnol377", + "timestamp": "2025-04-16T14:39:49.477391" + }, + { + "commit_hash": "018399a6523b3c5c1d7503b2c0de8cd66eb8d8e5", + "commit_message": "Merge branch 'main' of github.e.it.census.gov:SCT-Engineering/eks-automation-lambda", + "author": "arnol377", + "timestamp": "2025-04-16T14:41:30.994495" + }, + { + "commit_hash": "018399a6523b3c5c1d7503b2c0de8cd66eb8d8e5", + "commit_message": "Merge branch 'main' of github.e.it.census.gov:SCT-Engineering/eks-automation-lambda", + "author": "arnol377", + "timestamp": "2025-04-16T16:03:19.141297" + }, + { + "commit_hash": "018399a6523b3c5c1d7503b2c0de8cd66eb8d8e5", + "commit_message": "Merge branch 'main' of github.e.it.census.gov:SCT-Engineering/eks-automation-lambda", + "author": "arnol377", + "timestamp": "2025-04-16T16:06:13.577362" + }, + { + "commit_hash": "018399a6523b3c5c1d7503b2c0de8cd66eb8d8e5", + "commit_message": "Merge branch 'main' of github.e.it.census.gov:SCT-Engineering/eks-automation-lambda", + "author": "arnol377", + "timestamp": "2025-04-16T16:07:17.588493" + } +] \ No newline at end of file diff --git a/Makefile b/Makefile index 1b8e3b9..3834daf 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ -.PHONY: init venv install-deps clean terraform-init terraform-plan terraform-apply package all +.PHONY: init venv install-deps clean init plan apply destroy all invoke-lambda package-lambda PYTHON=python3 PIP=pip3 -TERRAFORM=terraform +TERRAFORM=tf VENV=.venv VENV_BIN=$(VENV)/bin VENV_PIP=$(VENV_BIN)/pip @@ -10,7 +10,7 @@ VENV_PIP=$(VENV_BIN)/pip # Set PIP_CONFIG_FILE to use custom pip.conf export PIP_CONFIG_FILE=$(CURDIR)/scripts/pip.conf -all: venv install-deps package terraform-apply +all: venv install-deps apply venv: test -d $(VENV) || $(PYTHON) -m venv $(VENV) @@ -26,17 +26,28 @@ clean: rm -rf .terraform/ rm -rf $(VENV) -package: venv - source $(VENV_BIN)/activate && chmod +x scripts/package.sh && ./scripts/package.sh - -terraform-init: +init: $(TERRAFORM) init -terraform-plan: terraform-init +plan: init $(TERRAFORM) plan -terraform-apply: terraform-init package +apply: init $(TERRAFORM) apply -auto-approve -terraform-destroy: +destroy: init $(TERRAFORM) destroy -auto-approve + +invoke-lambda: + @echo "Invoking Lambda function via API Gateway..." + @source $(VENV_BIN)/activate && $(PYTHON) scripts/invoke_lambda.py $(ARGS) + @echo "For more options, run: make invoke-lambda ARGS='-h'" + +package-lambda: venv install-deps + @echo "Packaging Lambda function and dependencies..." + @mkdir -p dist + @source $(VENV_BIN)/activate && $(PYTHON) scripts/package_lambda.py $(CURDIR) + @echo "Tainting S3 objects to ensure they are recreated on next apply..." + -$(TERRAFORM) taint 'aws_s3_object.lambda_package' + -$(TERRAFORM) taint 'aws_s3_object.lambda_layer' + @echo "Lambda package created successfully in the dist/ directory" diff --git a/api_gateway.tf b/api_gateway.tf new file mode 100644 index 0000000..bbbfbf0 --- /dev/null +++ b/api_gateway.tf @@ -0,0 +1,63 @@ +# API Gateway HTTP API without CORS (we'll add CORS separately) +resource "aws_apigatewayv2_api" "lambda_api" { + name = "${var.name}-api-gateway" + protocol_type = "HTTP" + cors_configuration { + allow_credentials = false + allow_headers = [ + "*", + ] + allow_methods = [ + "POST", + ] + allow_origins = [ + "*", + ] + expose_headers = [ + "*", + ] + max_age = 86400 + } + lifecycle { + ignore_changes = [ + cors_configuration + ] + } +} + +# API Gateway Integration with Lambda +resource "aws_apigatewayv2_integration" "lambda_integration" { + api_id = aws_apigatewayv2_api.lambda_api.id + integration_type = "AWS_PROXY" + integration_uri = aws_lambda_function.eks_automation.invoke_arn + payload_format_version = "2.0" +} + +# API Gateway Route for POST requests +resource "aws_apigatewayv2_route" "lambda_route" { + api_id = aws_apigatewayv2_api.lambda_api.id + route_key = "POST /" + target = "integrations/${aws_apigatewayv2_integration.lambda_integration.id}" +} + +# API Gateway Stage +resource "aws_apigatewayv2_stage" "lambda_stage" { + api_id = aws_apigatewayv2_api.lambda_api.id + name = "$default" + auto_deploy = true +} + +# Lambda Permission for API Gateway +resource "aws_lambda_permission" "api_gateway_permission" { + statement_id = "AllowAPIGatewayInvoke" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.eks_automation.function_name + principal = "apigateway.amazonaws.com" + source_arn = "${aws_apigatewayv2_api.lambda_api.execution_arn}/*/*" +} + +# Add API Gateway URL to outputs +output "api_gateway_invoke_url" { + value = "${aws_apigatewayv2_stage.lambda_stage.invoke_url}" + description = "API Gateway URL for invoking the Lambda function" +} diff --git a/backend.tf b/backend.tf.tmp similarity index 100% rename from backend.tf rename to backend.tf.tmp diff --git a/data.tf b/data.tf new file mode 100644 index 0000000..0b9fe53 --- /dev/null +++ b/data.tf @@ -0,0 +1,5 @@ +data aws_partition current {} + +data "aws_caller_identity" "current" {} + +data aws_region current {} \ No newline at end of file diff --git a/eks_automation/app.py b/eks_automation/app.py index 6d3af88..a4fd23a 100644 --- a/eks_automation/app.py +++ b/eks_automation/app.py @@ -3,41 +3,495 @@ # and writes the output to a file in a cloned GitHub repository. # The changes are then committed and pushed to the Census GitHub Enterprise Server, # creating a new repository for the Census EKS CI/CD pipeline. +# This implementation uses only pure Python with requests library (no Git CLI dependency). #################################################################################### import os import stat -import subprocess import shutil import logging - +import base64 +import time +import requests import json from jinja2 import Environment, FileSystemLoader - -# pylint: disable=import-error -from github import Github, Auth, GithubException -from git import Repo - -# pylint: enable=import-error +from urllib.parse import urlparse +from datetime import datetime import boto3 from botocore.exceptions import ClientError +import os -CENSUS_GITHUB_API = "https://github.e.it.census.gov/api/v3" -ORG_NAME = "SCT-Engineering" -SECRET_NAME = "/eks-cluster-deployment/github_token" +# Get configuration from environment variables with defaults +CENSUS_GITHUB_API = os.environ.get("CENSUS_GITHUB_API", "https://github.e.it.census.gov/api/v3") +ORG_NAME = os.environ.get("GITHUB_ORG_NAME", "SCT-Engineering") +SECRET_NAME = os.environ.get("GITHUB_TOKEN_SECRET_NAME", "/eks-cluster-deployment/github_token") -ORIG_REPO_NAME = "template-eks-cluster" +ORIG_REPO_NAME = os.environ.get("TEMPLATE_REPO_NAME", "template-eks-cluster") -TEMPLATE_FILE_NAME = "eks.hcl.j2" -HCL_FILE_NAME = "eks.hcl" +TEMPLATE_FILE_NAME = os.environ.get("TEMPLATE_FILE_NAME", "eks.hcl.j2") +HCL_FILE_NAME = os.environ.get("HCL_FILE_NAME", "eks.hcl") # Initialize the logger logger = logging.getLogger() logger.setLevel("INFO") # Set to "ERROR" to reduce logging messages. +class GitHubClient: + """A class to interact with GitHub API without relying on external Git binaries. + + This class encapsulates all GitHub API operations for managing repositories, + branches, files, commits and other Git operations using only the requests library. + """ + + def __init__(self, api_base_url, token, org_name): + """Initialize the GitHub client + + Args: + api_base_url (str): Base URL for the GitHub API + token (str): GitHub access token + org_name (str): GitHub organization name + """ + self.api_base_url = api_base_url + self.token = token + self.org_name = org_name + self.headers = self._create_headers() + + def _create_headers(self): + """Create headers for GitHub API requests + + Returns: + dict: Headers for GitHub API requests + """ + return { + "Authorization": f"token {self.token}", + "Accept": "application/vnd.github.v3+json", + "Content-Type": "application/json" + } + + def get_repository(self, repo_name, create=False): + """Get or create a repository in the GitHub organization + + Args: + repo_name (str): Name of the repository + create (bool): Whether to create the repo if it doesn't exist + + Returns: + dict: Repository information + """ + repo_api_url = f"{self.api_base_url}/repos/{self.org_name}/{repo_name}" + + # Try to get the repository + logger.info(f"Checking if repository {repo_name} exists") + response = requests.get(repo_api_url, headers=self.headers, verify=False) + + if response.status_code == 200: + # Repository exists + return response.json() + elif response.status_code == 404 and create: + # Repository doesn't exist, create it + logger.info(f"Creating repository {repo_name}") + create_url = f"{self.api_base_url}/orgs/{self.org_name}/repos" + repo_data = { + "name": repo_name, + "description": "EKS Automation CI/CD Pipeline Repo", + "private": True, + "visibility": "internal" + } + create_response = requests.post( + create_url, + headers=self.headers, + json=repo_data, + verify=False + ) + + if create_response.status_code in (201, 200): + return create_response.json() + else: + error_message = f"Failed to create repository: {create_response.status_code} - {create_response.text}" + logger.error(error_message) + raise Exception(error_message) + else: + error_message = f"Repository {repo_name} not found and create=False" + logger.error(error_message) + raise Exception(error_message) + + def get_default_branch(self, repo_name): + """Get the default branch of a repository + + Args: + repo_name (str): Name of the repository + + Returns: + str: Default branch name + """ + repo_api_url = f"{self.api_base_url}/repos/{self.org_name}/{repo_name}" + response = requests.get(repo_api_url, headers=self.headers, verify=False) + + if response.status_code == 200: + repo_info = response.json() + return repo_info["default_branch"] + else: + error_message = f"Failed to get default branch for {repo_name}: {response.status_code} - {response.text}" + logger.error(error_message) + raise Exception(error_message) + + def get_reference_sha(self, repo_name, ref): + """Get the SHA for a reference (branch, tag, etc) + + Args: + repo_name (str): Name of the repository + ref (str): Reference name (e.g., 'heads/main') + + Returns: + str: SHA of the reference + """ + api_url = f"{self.api_base_url}/repos/{self.org_name}/{repo_name}/git/refs/{ref}" + response = requests.get(api_url, headers=self.headers, verify=False) + + if response.status_code == 200: + ref_info = response.json() + return ref_info["object"]["sha"] + else: + error_message = f"Failed to get reference {ref} for {repo_name}: {response.status_code} - {response.text}" + logger.error(error_message) + raise Exception(error_message) + + def get_commit(self, repo_name, commit_sha): + """Get a commit by SHA + + Args: + repo_name (str): Name of the repository + commit_sha (str): Commit SHA + + Returns: + dict: Commit information + """ + api_url = f"{self.api_base_url}/repos/{self.org_name}/{repo_name}/git/commits/{commit_sha}" + response = requests.get(api_url, headers=self.headers, verify=False) + + if response.status_code == 200: + return response.json() + else: + error_message = f"Failed to get commit {commit_sha} for {repo_name}: {response.status_code} - {response.text}" + logger.error(error_message) + raise Exception(error_message) + + def get_tree(self, repo_name, tree_sha, recursive=False): + """Get a tree by SHA + + Args: + repo_name (str): Name of the repository + tree_sha (str): Tree SHA + recursive (bool): Whether to get the tree recursively + + Returns: + dict: Tree information + """ + api_url = f"{self.api_base_url}/repos/{self.org_name}/{repo_name}/git/trees/{tree_sha}" + if recursive: + api_url += "?recursive=1" + + response = requests.get(api_url, headers=self.headers, verify=False) + + if response.status_code == 200: + return response.json() + else: + error_message = f"Failed to get tree {tree_sha} for {repo_name}: {response.status_code} - {response.text}" + logger.error(error_message) + raise Exception(error_message) + + def download_repository_files(self, repo_name, tree, target_dir): + """Download all files from a repository tree to a local directory + + Args: + repo_name (str): Name of the repository + tree (dict): Tree information from get_tree() + target_dir (str): Directory to download files to + """ + for item in tree.get("tree", []): + if item["type"] == "blob": + # Get the blob contents + blob_url = f"{self.api_base_url}/repos/{self.org_name}/{repo_name}/git/blobs/{item['sha']}" + blob_response = requests.get(blob_url, headers=self.headers, verify=False) + + if blob_response.status_code == 200: + blob_data = blob_response.json() + content = None + + # Ensure the target directory exists + file_path = os.path.join(target_dir, item["path"]) + os.makedirs(os.path.dirname(file_path), exist_ok=True) + + # GitHub API returns base64 encoded content + if blob_data.get("encoding") == "base64": + content = base64.b64decode(blob_data.get("content", "")) + + if content is not None: + with open(file_path, "wb") as f: + f.write(content) + + def create_blob(self, repo_name, content): + """Create a blob in the repository + + Args: + repo_name (str): Name of the repository + content (bytes): Content of the blob + + Returns: + str: SHA of the created blob + """ + api_url = f"{self.api_base_url}/repos/{self.org_name}/{repo_name}/git/blobs" + + # Base64 encode the content + content_b64 = base64.b64encode(content).decode('utf-8') + + data = { + "content": content_b64, + "encoding": "base64" + } + + response = requests.post(api_url, headers=self.headers, json=data, verify=False) + + if response.status_code in (201, 200): + return response.json()["sha"] + else: + error_message = f"Failed to create blob for {repo_name}: {response.status_code} - {response.text}" + logger.error(error_message) + raise Exception(error_message) + + def create_tree(self, repo_name, tree_items, base_tree_sha=None): + """Create a tree in the repository + + Args: + repo_name (str): Name of the repository + tree_items (list): List of tree items (path, mode, type, sha) + base_tree_sha (str): SHA of the base tree (optional) + + Returns: + str: SHA of the created tree + """ + api_url = f"{self.api_base_url}/repos/{self.org_name}/{repo_name}/git/trees" + + data = { + "tree": tree_items + } + + if base_tree_sha: + data["base_tree"] = base_tree_sha + + response = requests.post(api_url, headers=self.headers, json=data, verify=False) + + if response.status_code in (201, 200): + return response.json()["sha"] + else: + error_message = f"Failed to create tree for {repo_name}: {response.status_code} - {response.text}" + logger.error(error_message) + raise Exception(error_message) + + def create_commit(self, repo_name, message, tree_sha, parent_shas): + """Create a commit in the repository + + Args: + repo_name (str): Name of the repository + message (str): Commit message + tree_sha (str): SHA of the tree + parent_shas (list): List of parent commit SHAs + + Returns: + str: SHA of the created commit + """ + api_url = f"{self.api_base_url}/repos/{self.org_name}/{repo_name}/git/commits" + + data = { + "message": message, + "tree": tree_sha, + "parents": parent_shas + } + + # Add committer/author information + current_time = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") + data["author"] = { + "name": "EKS Automation Lambda", + "email": "eks-automation@census.gov", + "date": current_time + } + data["committer"] = data["author"] + + response = requests.post(api_url, headers=self.headers, json=data, verify=False) + + if response.status_code in (201, 200): + return response.json()["sha"] + else: + error_message = f"Failed to create commit for {repo_name}: {response.status_code} - {response.text}" + logger.error(error_message) + raise Exception(error_message) + + def update_reference(self, repo_name, ref, sha): + """Update a reference in the repository + + Args: + repo_name (str): Name of the repository + ref (str): Reference name (e.g., 'heads/main') + sha (str): SHA to update the reference to + """ + api_url = f"{self.api_base_url}/repos/{self.org_name}/{repo_name}/git/refs/{ref}" + + data = { + "sha": sha, + "force": True + } + + response = requests.patch(api_url, headers=self.headers, json=data, verify=False) + + if response.status_code not in (200, 201): + error_message = f"Failed to update reference {ref} for {repo_name}: {response.status_code} - {response.text}" + logger.error(error_message) + raise Exception(error_message) + + def create_reference(self, repo_name, ref, sha): + """Create a reference in the repository + + Args: + repo_name (str): Name of the repository + ref (str): Full reference name (e.g., 'refs/heads/main') + sha (str): SHA to create the reference at + """ + api_url = f"{self.api_base_url}/repos/{self.org_name}/{repo_name}/git/refs" + + data = { + "ref": ref, + "sha": sha + } + + response = requests.post(api_url, headers=self.headers, json=data, verify=False) + + if response.status_code not in (201, 200): + error_message = f"Failed to create reference {ref} for {repo_name}: {response.status_code} - {response.text}" + logger.error(error_message) + raise Exception(error_message) + + def clone_repository_contents(self, source_repo, target_dir): + """Clone a repository's contents to a local directory using GitHub API + + Args: + source_repo (str): Name of the source repository + target_dir (str): Target directory to download files to + + Returns: + str: The default branch name of the repository + """ + # Get default branch of original repo + default_branch = self.get_default_branch(source_repo) + logger.info(f"Default branch for {source_repo}: {default_branch}") + + # Get tree from original repository + logger.info(f"Getting file tree from {source_repo}") + tree_sha = self.get_reference_sha(source_repo, f"heads/{default_branch}") + tree = self.get_tree(source_repo, tree_sha, recursive=True) + + # Download all files from original repo to work directory + logger.info(f"Downloading all files from {source_repo}") + self.download_repository_files(source_repo, tree, target_dir) + + return default_branch + + def commit_repository_contents(self, repo_name, work_dir, commit_message): + """Commit all files from a directory to a repository + + Args: + repo_name (str): Name of the repository + work_dir (str): Directory containing the files to commit + commit_message (str): Commit message + + Returns: + str: The default branch name of the repository + """ + # First, get the current state of the target repository + try: + target_default_branch = self.get_default_branch(repo_name) + except Exception: + # If we can't get the default branch, it might be a new repo + target_default_branch = "main" + + # Upload all files to the repository + tree_items = [] + + # Add all files from the work directory to the repository + for root, _, files in os.walk(work_dir): + for file in files: + file_path = os.path.join(root, file) + repo_path = os.path.relpath(file_path, work_dir) + + # Skip .git directory if it exists + if ".git" in repo_path.split(os.path.sep): + continue + + # Read file content + with open(file_path, "rb") as f: + file_content = f.read() + + # Create blob for the file + blob_sha = self.create_blob(repo_name, file_content) + + # Add to tree items + tree_items.append({ + "path": repo_path, + "mode": "100644", # Regular file + "type": "blob", + "sha": blob_sha + }) + + # Try to get the latest commit SHA for the branch + # If it doesn't exist, we'll create it + try: + latest_commit_sha = self.get_reference_sha(repo_name, f"heads/{target_default_branch}") + latest_commit = self.get_commit(repo_name, latest_commit_sha) + base_tree_sha = latest_commit["tree"]["sha"] + except Exception: + # If we can't get the reference, assume it's a new repo with no commits + base_tree_sha = None + + # Create a new tree with all the files + new_tree_sha = self.create_tree(repo_name, tree_items, base_tree_sha) + + # Create a commit with the new tree + if base_tree_sha: + # If we have a base tree, include the parent commit + new_commit_sha = self.create_commit( + repo_name, + commit_message, + new_tree_sha, + [latest_commit_sha] + ) + else: + # If it's a new repo, create the first commit + new_commit_sha = self.create_commit( + repo_name, + commit_message, + new_tree_sha, + [] + ) + + # Update or create the reference to point to the new commit + try: + self.update_reference( + repo_name, + f"heads/{target_default_branch}", + new_commit_sha + ) + except Exception: + # If the reference doesn't exist, create it + self.create_reference( + repo_name, + f"refs/heads/{target_default_branch}", + new_commit_sha + ) + + return target_default_branch + + # pylint: disable=unused-argument def lambda_handler(event, context): """ @@ -69,6 +523,7 @@ def lambda_handler(event, context): try: rendered = operate_github(project_name, eks_settings, HCL_FILE_NAME) except Exception as e: # pylint: disable=broad-exception-caught + logger.error(f"Error in operate_github: {str(e)}") return {"statusCode": 400, "body": json.dumps({"error": str(e)})} return { @@ -79,8 +534,10 @@ def lambda_handler(event, context): def operate_github(new_repo_name, eks_settings, output_hcl): - """Clone a GitHub repo, add an EKS parameter file rendered - from a template and the input JSON dta, and push to a new repo. + """Process template and create/update repository using GitHub API + + This implementation uses only the requests library and does not rely on git CLI + or any external binaries. Args: new_repo_name (str): Name of the new GitHub repo. @@ -91,90 +548,45 @@ def operate_github(new_repo_name, eks_settings, output_hcl): str: The rendered EKS parameter string. """ - # Get both the original repo and the new repo objects from GitHub. - # If the new repo doesn't exist, create it in GitHub. + # Get GitHub access token token = github_token() - org = github_org(CENSUS_GITHUB_API, ORG_NAME, token) - repo_orig = get_repo(org, ORIG_REPO_NAME) - repo_new = get_repo(org, new_repo_name, create=True) - - # In case the new repo already exists locally, delete it. - if os.path.exists(f"/tmp/{new_repo_name}"): - shutil.rmtree( - f"/tmp/{new_repo_name}", ignore_errors=False, onerror=remove_readonly - ) - - # Since Census GitHub Enterprise server uses a private TLS certificate, - # the certificate veriification must be disabled. - # This Git command will save the setting into ".gitconfig" file locally in the $HOME directory. - # Because the only writable place in Lambda fucntion is "/tmp", - # The HOME environment must be set to there. - # This is done using the "Environment" attribute in the "template.yaml" file. - cmd = ["git", "config", "--global", "http.sslVerify", "false"] - subprocess.run(cmd, check=False) - - # Clone the original repo. - # Since the only writable directory is "/tmp", we store the cloned repo there. - repo_url_with_token = f"https://{token}@{repo_orig.html_url.split('//')[1]}" - cloned_repo = Repo.clone_from(repo_url_with_token, f"/tmp/{new_repo_name}") - - # Change the remote URL of the local staging repo to the URL of the new repo. - repo_url_with_token = f"https://{token}@{repo_new.html_url.split('//')[1]}" - origin = cloned_repo.remotes.origin - origin.set_url(repo_url_with_token) - - # If the default branch of the original repo is "master", rename it to "main". - branch_name = cloned_repo.head.ref.name - if branch_name == "master": - current_branch = cloned_repo.heads.master - current_branch.rename("main", force=True) - - # Render the j2 template using the input data. + + # Create work directory if it doesn't exist + work_dir = f"/tmp/{new_repo_name}" + if os.path.exists(work_dir): + shutil.rmtree(work_dir, ignore_errors=False, onerror=remove_readonly) + os.makedirs(work_dir, exist_ok=True) + + # Initialize GitHub client + github = GitHubClient(CENSUS_GITHUB_API, token, ORG_NAME) + + # Get info about original repo + logger.info(f"Fetching original repository information: {ORIG_REPO_NAME}") + orig_repo = github.get_repository(ORIG_REPO_NAME) + + # Get or create the new repository + logger.info(f"Getting or creating repository: {new_repo_name}") + new_repo = github.get_repository(new_repo_name, create=True) + + # Clone the original repository contents + github.clone_repository_contents(ORIG_REPO_NAME, work_dir) + + # Render the template and write to file rendered = render_j2_template(eks_settings, TEMPLATE_FILE_NAME) - # Write the renderd data to a file in the local staging repository root directory - with open(f"/tmp/{new_repo_name}/{output_hcl}", "w") as file: + output_file_path = os.path.join(work_dir, output_hcl) + + logger.info(f"Writing rendered template to {output_file_path}") + with open(output_file_path, "w") as file: file.write(rendered) - - # Commit and push the changes. - cloned_repo.index.add(output_hcl) + + # Commit all files to the new repository commit_message = "Add the EKS parameter file by the Lambda function" - cloned_repo.index.commit(commit_message) - cloned_repo.git.push("--set-upstream", origin.name, "main", force=True) - + github.commit_repository_contents(new_repo_name, work_dir, commit_message) + + logger.info(f"Successfully updated {new_repo_name} repository") return rendered -def get_repo(org, repo_name, create=False): - """Retrieve a repository from GitHub Org. - - Args: - org (obj): GitHub Organization object - repo_name (str): Name of the repository to retrieve - create (bool): Whether to create it if the named repository doesn't exist - - Returns: - obj: GitHub repository object - """ - try: - repo = org.get_repo(repo_name) - except GithubException as e: - if e.status == 404: - if create: - logger.info("Create repo: %s", repo_name) - repo_desc = "EKS Automation CI/CD Pipeline Repo" - repo = org.create_repo( - repo_name, - description=repo_desc, - visibility="internal", - private=True, - ) - else: - logger.error("Repo: %s doesn't exist", repo_name) - raise - - return repo - - def render_j2_template(eks_settings, j2_template, j2_template_dir="templates/"): """Render the j2 template with the input JSON data @@ -194,26 +606,6 @@ def render_j2_template(eks_settings, j2_template, j2_template_dir="templates/"): return template.render(data=eks_settings) -def github_org(base_url, org_name, token): - """Get GitHub Organization Object - - Args: - base_url (str): Base URL of the GitHub Org. - org_name (str): name of the GitHub Org. - token (str): Access token to authenticated to the GitHub Org. - - Returns: - obj: the GitHub Org. - """ - - auth = Auth.Token(token) - # Since Census GitHub Enterprise server uses a private TLS certificate, - # the certificate veriification must be disabled. - g = Github(auth=auth, base_url=base_url, verify=False) - - return g.get_organization(org_name) - - def github_token(): """Retrieve GitHub access token from AWS SSM Parameter store @@ -226,7 +618,7 @@ def github_token(): "Value" ] except ClientError: - logger.error("Error occured when retrieving GitHub token from SSM Parameter") + logger.error("Error occurred when retrieving GitHub token from SSM Parameter") raise return token diff --git a/main.tf b/main.tf index 91bbd24..17c2594 100644 --- a/main.tf +++ b/main.tf @@ -1,156 +1,124 @@ -# API Gateway -resource "aws_api_gateway_rest_api" "eks_automation" { - name = var.name +# S3 Bucket for Lambda artifacts - Only needed for zip deployments +resource "aws_s3_bucket" "lambda_artifacts" { + count = var.lambda_deployment_type == "zip" ? 1 : 0 + + bucket = "${var.name}-lambda-artifacts-${data.aws_caller_identity.current.account_id}" + tags = local.common_tags -} - -resource "aws_api_gateway_resource" "eks_automation" { - rest_api_id = aws_api_gateway_rest_api.eks_automation.id - parent_id = aws_api_gateway_rest_api.eks_automation.root_resource_id - path_part = "EKSAutomation" -} - -resource "aws_api_gateway_method" "eks_automation" { - rest_api_id = aws_api_gateway_rest_api.eks_automation.id - resource_id = aws_api_gateway_resource.eks_automation.id - http_method = "POST" - authorization = "NONE" - api_key_required = true -} - -resource "aws_api_gateway_integration" "lambda" { - rest_api_id = aws_api_gateway_rest_api.eks_automation.id - resource_id = aws_api_gateway_resource.eks_automation.id - http_method = aws_api_gateway_method.eks_automation.http_method - integration_http_method = "POST" - type = "AWS_PROXY" - uri = aws_lambda_function.eks_automation.invoke_arn -} - -resource "aws_api_gateway_deployment" "eks_automation" { - rest_api_id = aws_api_gateway_rest_api.eks_automation.id - depends_on = [aws_api_gateway_integration.lambda] -} - -resource "aws_api_gateway_stage" "prod" { - deployment_id = aws_api_gateway_deployment.eks_automation.id - rest_api_id = aws_api_gateway_rest_api.eks_automation.id - stage_name = "Prod" -} - -resource "aws_lambda_permission" "apigw" { - statement_id = "AllowAPIGatewayInvoke" - action = "lambda:InvokeFunction" - function_name = aws_lambda_function.eks_automation.function_name - principal = "apigateway.amazonaws.com" - source_arn = "${aws_api_gateway_rest_api.eks_automation.execution_arn}/*/*" -} - -resource "aws_api_gateway_method" "options" { - rest_api_id = aws_api_gateway_rest_api.eks_automation.id - resource_id = aws_api_gateway_resource.eks_automation.id - http_method = "OPTIONS" - authorization = "NONE" -} - -resource "aws_api_gateway_integration" "options" { - rest_api_id = aws_api_gateway_rest_api.eks_automation.id - resource_id = aws_api_gateway_resource.eks_automation.id - http_method = aws_api_gateway_method.options.http_method - type = "MOCK" - request_templates = { - "application/json" = "{\"statusCode\": 200}" - } -} -resource "aws_api_gateway_method_response" "options" { - rest_api_id = aws_api_gateway_rest_api.eks_automation.id - resource_id = aws_api_gateway_resource.eks_automation.id - http_method = aws_api_gateway_method.options.http_method - status_code = "200" - - response_parameters = { - "method.response.header.Access-Control-Allow-Headers" = true, - "method.response.header.Access-Control-Allow-Methods" = true, - "method.response.header.Access-Control-Allow-Origin" = true + # Package Lambda function and layer + provisioner "local-exec" { + command = "${path.module}/scripts/package_lambda.py ${path.module}" + environment = { + PIP_CONFIG_FILE = "${path.module}/scripts/pip.conf" + } } } -resource "aws_api_gateway_integration_response" "options" { - rest_api_id = aws_api_gateway_rest_api.eks_automation.id - resource_id = aws_api_gateway_resource.eks_automation.id - http_method = aws_api_gateway_method.options.http_method - status_code = aws_api_gateway_method_response.options.status_code - - response_parameters = { - "method.response.header.Access-Control-Allow-Headers" = "'Content-Type,Authorization'", - "method.response.header.Access-Control-Allow-Methods" = "'POST,OPTIONS'", - "method.response.header.Access-Control-Allow-Origin" = "'*'" +resource "aws_s3_bucket_versioning" "lambda_artifacts" { + bucket = aws_s3_bucket.lambda_artifacts.id + versioning_configuration { + status = "Enabled" } } -resource "aws_api_gateway_usage_plan" "eks_automation" { - name = "${var.name}-usage-plan" - description = "Usage plan for EKS Automation API" - - api_stages { - api_id = aws_api_gateway_rest_api.eks_automation.id - stage = aws_api_gateway_stage.prod.stage_name - } - - quota_settings { - limit = 5000 - period = "MONTH" - } - - throttle_settings { - burst_limit = 500 - rate_limit = 100 - } - - tags = local.common_tags +# Upload Lambda package to S3 - Only for zip deployments +resource "aws_s3_object" "lambda_package" { + count = var.lambda_deployment_type == "zip" ? 1 : 0 + + bucket = aws_s3_bucket.lambda_artifacts[0].id + key = "eks_automation.zip" + source = "${path.module}/dist/eks_automation.zip" + depends_on = [aws_s3_bucket.lambda_artifacts] } -resource "aws_api_gateway_api_key" "eks_automation" { - name = "${var.name}-key" +# Upload Lambda layer to S3 - Only for zip deployments +resource "aws_s3_object" "lambda_layer" { + count = var.lambda_deployment_type == "zip" ? 1 : 0 + + bucket = aws_s3_bucket.lambda_artifacts[0].id + key = "layer.zip" + source = "${path.module}/dist/layer.zip" + depends_on = [aws_s3_bucket.lambda_artifacts] } -resource "aws_api_gateway_usage_plan_key" "eks_automation" { - key_id = aws_api_gateway_api_key.eks_automation.id - key_type = "API_KEY" - usage_plan_id = aws_api_gateway_usage_plan.eks_automation.id -} +# Lambda Function URL feature is not available or not working properly in AWS GovCloud +# Using API Gateway as an alternative (defined in api_gateway.tf) -# Lambda Layer +# Lambda Layer - Only used for zip deployments resource "aws_lambda_layer_version" "git" { - filename = "layer.zip" # Make sure to create this zip file with Git binaries - layer_name = "${var.name}-lambda-layer" - description = "${var.name} Lambda Layer" + count = var.lambda_deployment_type == "zip" ? 1 : 0 + + s3_bucket = aws_s3_bucket.lambda_artifacts.id + s3_key = "layer.zip" + layer_name = "${var.name}-lambda-layer" + description = "${var.name} Lambda Layer" compatible_runtimes = ["python3.9", "python3.10", "python3.11"] + depends_on = [aws_s3_object.lambda_layer] } # Lambda Function resource "aws_lambda_function" "eks_automation" { - filename = "eks_automation.zip" # Make sure to create this zip file - function_name = "${var.name}-eks-automation" - role = aws_iam_role.lambda_role.arn - handler = "app.lambda_handler" - runtime = "python3.11" - timeout = var.lambda_timeout + function_name = "${var.name}-eks-automation" + role = aws_iam_role.lambda_role.arn + timeout = var.lambda_timeout + + # Conditional deployment based on deployment type + dynamic "image_config" { + for_each = var.lambda_deployment_type == "container" ? [1] : [] + content { + command = ["app.lambda_handler"] + } + } + + # Set package type based on deployment method + package_type = var.lambda_deployment_type == "container" ? "Image" : "Zip" + + # Container image configuration + image_uri = var.lambda_deployment_type == "container" ? var.container_image_uri : null + + # Zip deployment configuration + s3_bucket = var.lambda_deployment_type == "zip" ? aws_s3_bucket.lambda_artifacts[0].id : null + s3_key = var.lambda_deployment_type == "zip" ? "eks_automation.zip" : null + handler = var.lambda_deployment_type == "zip" ? "app.lambda_handler" : null + runtime = var.lambda_deployment_type == "zip" ? "python3.9" : null + layers = var.lambda_deployment_type == "zip" ? [aws_lambda_layer_version.git[0].arn] : null vpc_config { subnet_ids = var.vpc_subnet_ids security_group_ids = var.vpc_security_group_ids } - layers = [aws_lambda_layer_version.git.arn] - environment { variables = { ENVIRONMENT = var.environment + CENSUS_GITHUB_API = "https://github-instance.your-domain.com/api/v3" + GITHUB_ORG_NAME = "Your-Organization" + GITHUB_TOKEN_SECRET_NAME = "/path/to/github/token" + TEMPLATE_REPO_NAME = "your-template-repo" + TEMPLATE_FILE_NAME = "template.j2" + HCL_FILE_NAME = "config.hcl" } } + tags = local.common_tags + depends_on = concat( + [ + aws_iam_role_policy_attachment.lambda_vpc_access, + aws_iam_role_policy.lambda_ssm_access, + ], + var.lambda_deployment_type == "zip" ? [ + aws_s3_object.lambda_package, + aws_s3_object.lambda_layer + ] : [] + ) +} + +# CloudWatch Log Group for Lambda Function +resource "aws_cloudwatch_log_group" "eks_automation" { + name = "/aws/lambda/${aws_lambda_function.eks_automation.function_name}" + retention_in_days = 14 # Or your desired retention period + tags = local.common_tags } @@ -177,7 +145,7 @@ resource "aws_iam_role" "lambda_role" { # IAM Policies resource "aws_iam_role_policy_attachment" "lambda_vpc_access" { role = aws_iam_role.lambda_role.name - policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" + policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" } resource "aws_iam_role_policy" "lambda_ssm_access" { @@ -205,3 +173,74 @@ resource "aws_iam_role_policy" "lambda_ssm_access" { ] }) } + +resource "aws_iam_role_policy" "lambda_function_url" { + name = "${var.name}-function-url-access" + role = aws_iam_role.lambda_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "lambda:CreateFunctionUrlConfig", + "lambda:UpdateFunctionUrlConfig", + "lambda:DeleteFunctionUrlConfig", + "lambda:GetFunctionUrlConfig", + "lambda:InvokeFunctionUrl", + "lambda:AddPermission", + "lambda:RemovePermission" + ] + Resource = [ + aws_lambda_function.eks_automation.arn, + "${aws_lambda_function.eks_automation.arn}/*" + ] + }, + { + Effect = "Allow" + Action = [ + "lambda:InvokeFunction" + ] + Resource = aws_lambda_function.eks_automation.arn + } + ] + }) +} + +# ECR Repository for Lambda container images +resource "aws_ecr_repository" "lambda_container" { + count = var.lambda_deployment_type == "container" && var.create_ecr_repository ? 1 : 0 + + name = coalesce(var.ecr_repository_name, "${var.name}-lambda-container") + image_tag_mutability = "MUTABLE" + + image_scanning_configuration { + scan_on_push = true + } + + tags = local.common_tags +} + +# ECR Repository Policy +resource "aws_ecr_repository_policy" "lambda_container_policy" { + count = var.lambda_deployment_type == "container" && var.create_ecr_repository ? 1 : 0 + + repository = aws_ecr_repository.lambda_container[0].name + policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Sid = "LambdaECRImageRetrievalPolicy", + Effect = "Allow", + Principal = { + Service = "lambda.amazonaws.com" + }, + Action = [ + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer" + ] + } + ] + }) +} diff --git a/outputs.tf b/outputs.tf index b5ab3ff..aca3e2f 100644 --- a/outputs.tf +++ b/outputs.tf @@ -1,10 +1,46 @@ -output "api_endpoint" { - description = "API Gateway endpoint URL" - value = "${aws_api_gateway_stage.prod.invoke_url}${aws_api_gateway_resource.eks_automation.path}" +# output "function_url" { +# description = "API Gateway URL for invoking the Lambda function" +# value = aws_apigatewayv2_stage.lambda_stage.invoke_url +# } + +# output "function_arn" { +# description = "Lambda function ARN" +# value = aws_lambda_function.eks_automation.arn +# } + +output user { + value = data.aws_caller_identity.current +} + +output log_group { + value = aws_cloudwatch_log_group.eks_automation.name } -output "api_key" { - description = "API Key for accessing the endpoint" - value = aws_api_gateway_api_key.eks_automation.value - sensitive = true +output aws_region { + value = data.aws_region.current.name } + +output "lambda_function_arn" { + description = "The ARN of the Lambda Function" + value = aws_lambda_function.eks_automation.arn +} + +output "lambda_function_name" { + description = "The name of the Lambda Function" + value = aws_lambda_function.eks_automation.function_name +} + +output "lambda_deployment_type" { + description = "The deployment type used for the Lambda function (zip or container)" + value = var.lambda_deployment_type +} + +output "ecr_repository_url" { + description = "The URL of the ECR repository (only applicable when using container deployment)" + value = var.lambda_deployment_type == "container" && var.create_ecr_repository ? aws_ecr_repository.lambda_container[0].repository_url : null +} + +output "container_image_uri" { + description = "The container image URI used for the Lambda function (only applicable when using container deployment)" + value = var.lambda_deployment_type == "container" ? var.container_image_uri : null +} \ No newline at end of file diff --git a/scripts/invoke_lambda.py b/scripts/invoke_lambda.py new file mode 100644 index 0000000..e6f854b --- /dev/null +++ b/scripts/invoke_lambda.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +""" +Script to invoke the EKS Automation Lambda function via API Gateway. +Parses Terraform outputs to get the API Gateway URL, log group name, and AWS region. +""" + +import json +import argparse +import subprocess +import sys +import requests +import os +import time +import boto3 +import datetime + + +class LambdaInvoker: + """Class for invoking Lambda functions and managing related operations.""" + + def __init__(self): + """Initialize the LambdaInvoker.""" + self.api_url = None + self.log_group_name = None + self.invocation_start_time = None + self.aws_region = None + self.session = None + + @staticmethod + def get_terraform_output(output_name): + """Get a specific Terraform output value by name.""" + try: + # Run Terraform output command + result = subprocess.run( + f"tf output -raw {output_name}", + shell=True, + capture_output=True, + text=True, + check=True, + ) + output_data = result.stdout + return output_data + except subprocess.CalledProcessError as e: + print(f"Error retrieving Terraform output '{output_name}': {e}") + print(f"stderr: {e.stderr}") + return None + except json.JSONDecodeError as e: + print(f"Error parsing Terraform output as JSON: {e}") + return None + + def initialize_from_terraform(self): + """Initialize API URL and log group name from Terraform outputs.""" + # Get API Gateway URL from Terraform output + output_name = "api_gateway_invoke_url" + self.api_url = self.get_terraform_output(output_name) + print(f"Found API Gateway URL in output: {output_name}. {self.api_url}") + if not self.api_url: + print("Error: Failed to find API Gateway URL in Terraform outputs") + return False + + # Get log group name from Terraform output + self.log_group_name = self.get_terraform_output("log_group") + if not self.log_group_name: + print("Warning: Failed to find CloudWatch log group in Terraform outputs") + print("CloudWatch logs will not be available") + else: + print(f"CloudWatch log group: {self.log_group_name}") + + # Get AWS region from Terraform output + self.aws_region = self.get_terraform_output("aws_region") + if not self.aws_region: + print("Warning: Failed to find AWS region in Terraform outputs") + print("Attempting to use default AWS region from environment or config") + else: + print(f"AWS Region: {self.aws_region}") + + print(f"API Gateway URL: {self.api_url}") + return True + + def initialize_aws_session(self, profile_name=None): + """ + Initialize AWS session for accessing AWS services + + Priority order for credentials: + 1. AWS environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN) + 2. AWS profile if specified + 3. Default credential provider chain + + Args: + profile_name: Optional AWS profile name to use (lower priority than env vars) + + Returns: + True if successful, False otherwise + """ + try: + # Check if AWS environment variables are set + if os.environ.get('AWS_ACCESS_KEY_ID') and os.environ.get('AWS_SECRET_ACCESS_KEY'): + print("Using AWS credentials from environment variables") + # When using environment variables, boto3.Session() will automatically pick them up + self.session = boto3.Session(region_name=self.aws_region) + elif profile_name: + print(f"Using AWS profile: {profile_name}") + self.session = boto3.Session(profile_name=profile_name, region_name=self.aws_region) + else: + print("Using default AWS credentials") + self.session = boto3.Session(region_name=self.aws_region) + + # Test that credentials are valid + sts = self.session.client('sts') + identity = sts.get_caller_identity() + print(f"Using AWS account: {identity['Account']}") + print(f"Using IAM identity: {identity['Arn']}") + return True + except Exception as e: + print(f"Error initializing AWS session: {e}") + print("Make sure your AWS credentials are properly configured") + print("Set AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and optionally AWS_SESSION_TOKEN environment variables") + return False + + def prepare_payload(self, args): + """Prepare the payload for Lambda invocation based on command-line arguments.""" + payload = None + if args.input: + try: + with open(args.input, 'r') as f: + payload = json.load(f) + except (IOError, json.JSONDecodeError) as e: + print(f"Error reading input file: {e}") + return None + elif args.payload: + try: + payload = json.loads(args.payload) + except json.JSONDecodeError as e: + print(f"Error parsing JSON payload: {e}") + return None + else: + # Default minimal payload if none provided + payload = {"message": "Test invocation"} + + print(f"Sending payload: {json.dumps(payload, indent=2)}") + return payload + + def invoke_lambda(self, payload, show_logs=False): + """ + Invoke the Lambda function via API Gateway and handle response + + Args: + payload: The payload to send to the Lambda function + show_logs: Whether to show CloudWatch logs regardless of success + + Returns: + 0 for success, 1 for failure + """ + # Record start time for log filtering + self.invocation_start_time = int(time.time() * 1000) + + try: + response = requests.post(self.api_url, json=payload) + print(f"Status code: {response.status_code}") + + try: + response_json = response.json() + print(f"Response: {json.dumps(response_json, indent=2)}") + except json.JSONDecodeError: + print(f"Response (raw): {response.text}") + + # Check if we need to display logs + should_show_logs = show_logs or not (200 <= response.status_code < 300) + print(f"Show logs: {should_show_logs}") + print(f"Log group name: {self.log_group_name}") + + # Fetch and display CloudWatch logs if needed + if should_show_logs and self.log_group_name: + # Wait a moment for logs to be available + time.sleep(2) + print("Fetching CloudWatch logs to help diagnose the issue...") + log_events = self.fetch_cloudwatch_logs(start_time=self.invocation_start_time) + self.display_cloudwatch_logs(log_events) + + # Return success if status code is 2xx + if 200 <= response.status_code < 300: + return 0 + else: + return 1 + except requests.RequestException as e: + print(f"Error invoking Lambda function: {e}") + + # Try to fetch logs on error + if self.log_group_name: + print("Fetching CloudWatch logs to help diagnose the issue...") + log_events = self.fetch_cloudwatch_logs(start_time=self.invocation_start_time) + self.display_cloudwatch_logs(log_events) + + return 1 + + def fetch_cloudwatch_logs(self, start_time=None, limit=20): + """ + Fetch recent logs from CloudWatch log group + + Args: + start_time: Start time for logs in Unix timestamp milliseconds + limit: Maximum number of log events to return + + Returns: + List of log events + """ + if not self.log_group_name: + return [] + + if start_time is None: + # Default to fetching logs from 5 minutes ago + start_time = int((datetime.datetime.now() - + datetime.timedelta(minutes=5)).timestamp() * 1000) + + try: + # Use the session to create a logs client with proper credentials + if self.session: + logs_client = self.session.client('logs') + else: + # Fallback to default client without session (uses environment credentials) + logs_client = boto3.client('logs', region_name=self.aws_region) + + response = logs_client.filter_log_events( + logGroupName=self.log_group_name, + startTime=start_time, + limit=limit, + interleaved=True, + ) + return response.get('events', []) + except Exception as e: + print(f"Error fetching CloudWatch logs: {e}") + print("This might be due to insufficient IAM permissions or invalid credentials") + return [] + + def display_cloudwatch_logs(self, log_events): + """ + Format and display CloudWatch log events + + Args: + log_events: List of CloudWatch log events + """ + if not log_events: + print("No recent CloudWatch logs found") + return + + print("\n=== Recent CloudWatch Logs ===") + for event in log_events: + timestamp = datetime.datetime.fromtimestamp( + event['timestamp'] / 1000).strftime('%Y-%m-%d %H:%M:%S') + message = event['message'].rstrip() + print(f"[{timestamp}] {message}") + print("=============================\n") + + +def main(): + """Main function to parse arguments and invoke the Lambda function.""" + parser = argparse.ArgumentParser( + description="Invoke EKS Automation Lambda function via API Gateway" + ) + parser.add_argument( + "-i", "--input", + help="Path to JSON file containing input payload", + default=os.path.join(os.path.dirname(__file__), "test_payload.json"), + required=False + ) + parser.add_argument( + "-p", "--payload", + help="JSON string payload to send to Lambda", + required=False + ) + parser.add_argument( + "--show-logs", + help="Always show CloudWatch logs, even on success", + action="store_true" + ) + parser.add_argument( + "--profile", + help="AWS profile name to use for credentials (environment variables take precedence if set)", + default=None + ) + + # Add an epilog message explaining credential options + parser.epilog = """ + AWS Authentication: + - Environment variables (preferred): AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN (optional) + - AWS profile (if specified with --profile) + - Default credential provider chain + """ + args = parser.parse_args() + + # Initialize the Lambda invoker + invoker = LambdaInvoker() + + # Initialize from Terraform outputs + if not invoker.initialize_from_terraform(): + sys.exit(1) + + # Initialize AWS session with credentials + if not invoker.initialize_aws_session(profile_name=args.profile): + print("Warning: Failed to initialize AWS session. Some features may not work.") + + # Prepare the payload + payload = invoker.prepare_payload(args) + if payload is None: + sys.exit(1) + + # Invoke the Lambda function + exit_code = invoker.invoke_lambda(payload, show_logs=args.show_logs) + return exit_code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/package.sh b/scripts/package.sh deleted file mode 100644 index d0e462b..0000000 --- a/scripts/package.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/bash - -# Create temporary directories -mkdir -p dist/lambda -mkdir -p dist/layer - -# Package Lambda function -cp -r eks_automation/* dist/lambda/ -cd dist/lambda -zip -r ../eks_automation.zip . -cd ../.. - -# Package Lambda layer -mkdir -p dist/layer/python -pip install -r eks_automation/requirements.txt -t dist/layer/python -cd dist/layer -zip -r ../layer.zip . -cd ../.. - -# Move zip files to root -mv dist/*.zip . - -# Cleanup -rm -rf dist diff --git a/scripts/package_lambda.py b/scripts/package_lambda.py new file mode 100755 index 0000000..37dd0ff --- /dev/null +++ b/scripts/package_lambda.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +""" +Lambda Function and Layer Packaging Script + +This script packages AWS Lambda function code and its dependencies into separate +zip files ready for deployment. It handles both the main function code and +a Lambda layer containing Python dependencies. +""" + +import asyncio +import logging +import os +import shutil +import sys +import zipfile +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) +logger = logging.getLogger("lambda_packager") + +@dataclass +class PackageConfig: + """Configuration for Lambda packaging.""" + workspace_dir: Path + dist_dir: Path + lambda_dir: Path + requirements_file: Path + + @classmethod + def from_workspace(cls, workspace_dir: Path) -> "PackageConfig": + """Create config from workspace directory.""" + return cls( + workspace_dir=workspace_dir, + dist_dir=workspace_dir / "dist", + lambda_dir=workspace_dir / "eks_automation", + requirements_file=workspace_dir / "eks_automation" / "requirements.txt" + ) + +class PackagingError(Exception): + """Base exception for packaging errors.""" + pass + +class DirectoryManager: + """Manages creation and cleanup of directories.""" + + def __init__(self, directory: Path): + self.directory = directory + + def __enter__(self) -> Path: + """Create clean directory.""" + if self.directory.exists(): + shutil.rmtree(self.directory) + self.directory.mkdir(parents=True) + return self.directory + + def __exit__(self, exc_type, exc_val, exc_tb): + """Handle cleanup if needed.""" + if exc_type is not None: + logger.error(f"Error occurred: {exc_val}") + return False + return True + +def create_zip(source_dir: Path, output_file: Path) -> None: + """Create a zip file from a directory.""" + logger.info(f"Creating zip file: {output_file}") + if not source_dir.exists(): + raise PackagingError(f"Source directory does not exist: {source_dir}") + + if not any(source_dir.iterdir()): + raise PackagingError(f"Source directory is empty: {source_dir}") + + output_file.parent.mkdir(parents=True, exist_ok=True) + if output_file.exists(): + output_file.unlink() + + try: + with zipfile.ZipFile(output_file, 'w', zipfile.ZIP_DEFLATED) as zf: + for root, _, files in os.walk(source_dir): + rel_dir = os.path.relpath(root, source_dir) + for file in files: + file_path = os.path.join(root, file) + arcname = os.path.join(rel_dir, file) + if rel_dir == '.': + arcname = file + zf.write(file_path, arcname) + + if not output_file.exists(): + raise PackagingError(f"Zip file was not created: {output_file}") + except Exception as e: + raise PackagingError(f"Failed to create zip file: {str(e)}") + +async def create_constraints_file(target_dir: Path) -> Path: + """Create a constraints file to ensure compatibility with Lambda runtime.""" + constraints_file = target_dir / "constraints.txt" + + logger.info("Creating constraints file for Lambda compatibility") + with open(constraints_file, "w") as f: + f.write("# Constraints for Lambda compatibility\n") + f.write("# Pin cryptography to version compatible with Lambda's GLIBC\n") + f.write("cryptography<38.0.0\n") + f.write("# Additional constraints for Lambda compatibility\n") + f.write("PyGithub<1.59.0\n") + f.write("GitPython<3.1.31\n") + + return constraints_file + +async def install_requirements(requirements_file: Path, target_dir: Path) -> None: + """Install Python requirements to target directory with constraints.""" + # Create constraints file + constraints_file = await create_constraints_file(target_dir.parent) + + logger.info(f"Installing requirements with constraints for Lambda compatibility") + process = await asyncio.create_subprocess_exec( + "pip", "install", "-r", str(requirements_file), "-t", str(target_dir), + "--constraint", str(constraints_file), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + + if process.returncode != 0: + raise PackagingError(f"pip install failed: {stderr.decode()}") + +async def package_lambda(config: PackageConfig) -> None: + """Package Lambda function and layer.""" + try: + # Set up directories + logger.info("Setting up directories...") + lambda_dist = config.dist_dir / "lambda" + layer_dir = config.dist_dir / "layer" + layer_python_dir = layer_dir / "python" + + # Create directories + with DirectoryManager(lambda_dist): + # Copy Lambda function code + logger.info("Copying Lambda function code...") + shutil.copytree(config.lambda_dir, lambda_dist, dirs_exist_ok=True) + # Create function zip + create_zip(lambda_dist, config.dist_dir / "eks_automation.zip") + + # Create layer if requirements exist + if config.requirements_file.exists(): + with DirectoryManager(layer_python_dir): + logger.info("Installing Python requirements...") + await install_requirements(config.requirements_file, layer_python_dir) + # Create zip from the layer directory containing the python subdirectory + create_zip(layer_python_dir.parent, config.dist_dir / "layer.zip") + else: + raise PackagingError("requirements.txt not found") + except Exception as e: + logger.error(f"Packaging failed: {str(e)}") + raise PackagingError(f"Lambda packaging failed: {str(e)}") + + logger.info("Successfully created Lambda package and layer") + +def main() -> None: + """Main entry point.""" + try: + if len(sys.argv) != 2: + raise PackagingError("Usage: package_lambda.py ") + + workspace_dir = Path(sys.argv[1]) + if not workspace_dir.exists(): + raise PackagingError(f"Workspace directory not found: {workspace_dir}") + + config = PackageConfig.from_workspace(workspace_dir) + asyncio.run(package_lambda(config)) + + except PackagingError as e: + logger.error(f"Error: {e}") + sys.exit(1) + except Exception as e: + logger.exception("Unexpected error occurred") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/scripts/requirements.txt b/scripts/requirements.txt index 9a69f78..fcad05a 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -1,5 +1 @@ -Jinja2>=3.1.0 -PyGithub>=2.1.1 -GitPython>=3.1.40 -boto3>=1.34.0 -botocore>=1.34.0 +rich>=10.0.0 diff --git a/scripts/test_payload.json b/scripts/test_payload.json new file mode 100644 index 0000000..9dea9e4 --- /dev/null +++ b/scripts/test_payload.json @@ -0,0 +1,26 @@ +{ + "project_name": "eks-automation-lambda-test1", + "eks_settings": { + "attrs": { + "account_name": "lab-dev-ew", + "aws_region": "us-gov-east-1", + "cluster_mailing_list": "matthew.c.morgan@census.gov", + "cluster_name": "csvd-platform-lab-mcm", + "eks_instance_disk_size": 100, + "eks_ng_desired_size": 2, + "eks_ng_max_size": 10, + "eks_ng_min_size": 2, + "environment": "development", + "environment_abbr": "dev", + "organization": "census:ocio:csvd", + "finops_project_name": "csvd_platformbaseline", + "finops_project_number": "fs0000000078", + "finops_project_role": "csvd_platformbaseline_app", + "vpc_domain_name": "dev.lab.csp2.census.gov", + "vpc_name": "vpc3-lab-dev" + }, + "tags" : { + "slim:schedule": "8:00-17:00" + } + } + } \ No newline at end of file diff --git a/varfiles/sct-engineering.tfvars b/varfiles/sct-engineering.tfvars index 3fe9e6f..6453b5f 100644 --- a/varfiles/sct-engineering.tfvars +++ b/varfiles/sct-engineering.tfvars @@ -1,4 +1,4 @@ -name = "eks-repo-automation" +name = "eks-repo-done-right" # This file contains the variable values for the Terraform configuration. # It is used to set up the AWS Lambda function and its associated resources. # The values here are specific to the development environment and should be @@ -21,11 +21,11 @@ finops_project_name = "csvd_platformbaseline" finops_project_number = "fs0000000078" finops_project_role = "csvd_platformbaseline_app" vpc_security_group_ids = [ - "sg-0641c697588b9aa6b", - "sg-0cc69de0fa6f337c5" + "sg-03cbf2a626ed55c7e" ] vpc_subnet_ids = [ - "subnet-062189d742937204e" + "subnet-05192178ac094f639", + "subnet-022370a5a03585376" ] lambda_timeout = 30 -aws_region = "us-gov-west-1" +aws_region = "us-gov-east-1" \ No newline at end of file diff --git a/variables.tf b/variables.tf index dccbac5..e70eb40 100644 --- a/variables.tf +++ b/variables.tf @@ -53,4 +53,69 @@ variable name { description = "Name of the resource" type = string default = "eks-automation" +} + +variable "lambda_deployment_type" { + description = "Lambda deployment type: 'zip' or 'container'" + type = string + default = "zip" + validation { + condition = contains(["zip", "container"], var.lambda_deployment_type) + error_message = "Valid values for lambda_deployment_type are 'zip' or 'container'" + } +} + +variable "container_image_uri" { + description = "ECR container image URI for Lambda container (required if lambda_deployment_type is 'container')" + type = string + default = null +} + +variable "create_ecr_repository" { + description = "Whether to create an ECR repository for Lambda container images" + type = bool + default = true +} + +variable "ecr_repository_name" { + description = "Name of the ECR repository for Lambda container images" + type = string + default = null +} + +# Environment variables for EKS Automation Lambda +variable "census_github_api" { + description = "URL for the Census GitHub API" + type = string + default = "https://github.e.it.census.gov/api/v3" +} + +variable "github_org_name" { + description = "GitHub organization name" + type = string + default = "SCT-Engineering" +} + +variable "github_token_secret_name" { + description = "AWS SSM parameter name for the GitHub token" + type = string + default = "/eks-cluster-deployment/github_token" +} + +variable "template_repo_name" { + description = "GitHub repository name for the EKS template" + type = string + default = "template-eks-cluster" +} + +variable "template_file_name" { + description = "Template file name for the EKS configuration" + type = string + default = "eks.hcl.j2" +} + +variable "hcl_file_name" { + description = "Output file name for the rendered HCL configuration" + type = string + default = "eks.hcl" } \ No newline at end of file