diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 00000000..779b80f3 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,29 @@ +name: cd.yml +on: + push: + branches: + - main +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Build with Gradle + run: ./gradlew clean build -x test + + - name: Upload artifact to S3 + uses: aws-actions/s3-sync@v2 + with: + bucket: ${{ secrets.S3_BUCKET_NAME }} + source: build/libs/ + destination: build/ + region: ${{ secrets.AWS_REGION }} diff --git a/.gitignore b/.gitignore index c09c5f7f..d314c395 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,8 @@ src/main/resources/application-test.yml getImageVersion.sh -/sql/ \ No newline at end of file +/sql/ + +### terraform ### +terraform/.terraform.lock.hcl +terraform/.terraform \ No newline at end of file diff --git a/terraform/build_package.sh b/terraform/build_package.sh new file mode 100644 index 00000000..b6498f98 --- /dev/null +++ b/terraform/build_package.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +PKG_DIR=lambda_package +ZIP_PATH=${PKG_DIR}/lambda.zip + +rm -rf ${PKG_DIR} +mkdir -p ${PKG_DIR}/python + +cat > Dockerfile.build <<'DOCKER' +FROM public.ecr.aws/lambda/python:3.12 +WORKDIR /var/task +COPY handler.py ./ +RUN pip install --target ./param_deps paramiko +DOCKER + +docker build -t lambda-build -f Dockerfile.build . +container_id=$(docker create lambda-build) +docker cp ${container_id}:/var/task/param_deps ${PKG_DIR}/ +docker rm ${container_id} + +cp handler.py ${PKG_DIR}/ +cd ${PKG_DIR} +(cd param_deps && cp -r . ../) +rm -rf param_deps +zip -r ${ZIP_PATH} . +cd .. + +echo "✅ Built ${ZIP_PATH}. Run: terraform init && terraform apply" diff --git a/terraform/lambda/handler.py b/terraform/lambda/handler.py new file mode 100644 index 00000000..2801da87 --- /dev/null +++ b/terraform/lambda/handler.py @@ -0,0 +1,35 @@ +import boto3 +import paramiko +import io +import os + +s3 = boto3.client("s3") +ssm = boto3.client("ssm") + +def lambda_handler(event, context): + try: + record = event["Records"][0] + bucket = record["s3"]["bucket"]["name"] + key = record["s3"]["object"]["key"] + except Exception as e: + return {"error": "Invalid event", "detail": str(e)} + + param_name = os.environ.get("SSM_PARAM_NAME") + resp = ssm.get_parameter(Name=param_name, WithDecryption=True) + private_key = resp["Parameter"]["Value"] + pkey = paramiko.RSAKey.from_private_key(io.StringIO(private_key)) + + host = os.environ.get("HOME_HOST") + user = os.environ.get("HOME_USER") + + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(hostname=host, username=user, pkey=pkey, timeout=10) + + cmd = f"bash /home/{user}/deploy.sh {bucket} {key}" + stdin, stdout, stderr = ssh.exec_command(cmd) + out = stdout.read().decode() + err = stderr.read().decode() + ssh.close() + + return {"stdout": out, "stderr": err} diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 00000000..87bd152f --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,97 @@ +resource "aws_s3_bucket" "deploy_bucket" { + bucket = "${var.s3_bucket}-${random_id.bucket_suffix.hex}" +} + +# Optionally create SSH key parameter in SSM +resource "aws_ssm_parameter" "ssh_key" { + count = length(trim(var.ssh_private_key)) > 0 ? 1 : 0 + name = var.ssh_param_name + type = "SecureString" + value = var.ssh_private_key + overwrite = true +} + +# IAM Role for Lambda +data "aws_iam_policy_document" "lambda_assume_role" { + statement { + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["lambda.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "lambda_role" { + name = "lambda-cd-role-${random_id.bucket_suffix.hex}" + assume_role_policy = data.aws_iam_policy_document.lambda_assume_role.json +} + +resource "aws_iam_policy" "lambda_policy" { + name = "lambda-cd-policy-${random_id.bucket_suffix.hex}" + policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Effect = "Allow", + Action = ["ssm:GetParameter"], + Resource = ["arn:aws:ssm:${var.aws_region}:*:${var.ssh_param_name}"] + }, + { + Effect = "Allow", + Action = ["s3:GetObject", "s3:GetObjectVersion"], + Resource = ["${aws_s3_bucket.deploy_bucket.arn}/*"] + }, + { + Effect = "Allow", + Action = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"], + Resource = ["arn:aws:logs:*:*:*"] + } + ] + }) +} + +resource "aws_iam_role_policy_attachment" "attach_lambda_policy" { + role = aws_iam_role.lambda_role.name + policy_arn = aws_iam_policy.lambda_policy.arn +} + +# Lambda function +resource "aws_lambda_function" "deploy_lambda" { + filename = "${path.module}/lambda_package/lambda.zip" + function_name = "home-deploy-lambda-${random_id.bucket_suffix.hex}" + role = aws_iam_role.lambda_role.arn + handler = "handler.lambda_handler" + runtime = "python3.12" + source_code_hash = filebase64sha256("${path.module}/lambda_package/lambda.zip") + memory_size = var.lambda_memory + timeout = var.lambda_timeout + + environment { + variables = { + SSM_PARAM_NAME = var.ssh_param_name + HOME_HOST = var.home_host + HOME_USER = var.home_user + } + } +} + +resource "aws_lambda_permission" "s3_invoke" { + statement_id = "AllowS3Invoke" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.deploy_lambda.function_name + principal = "s3.amazonaws.com" + source_arn = aws_s3_bucket.deploy_bucket.arn +} + +resource "aws_s3_bucket_notification" "bucket_notification" { + bucket = aws_s3_bucket.deploy_bucket.id + + lambda_function { + lambda_function_arn = aws_lambda_function.deploy_lambda.arn + events = ["s3:ObjectCreated:Put"] + filter_prefix = "build/" + } + + depends_on = [aws_lambda_permission.s3_invoke] +} diff --git a/terraform/output.tf b/terraform/output.tf new file mode 100644 index 00000000..4bd90989 --- /dev/null +++ b/terraform/output.tf @@ -0,0 +1,7 @@ +output "s3_bucket" { + value = aws_s3_bucket.deploy_bucket.bucket +} + +output "lambda_function_name" { + value = aws_lambda_function.deploy_lambda.function_name +} diff --git a/terraform/provider.tf b/terraform/provider.tf new file mode 100644 index 00000000..5af2e304 --- /dev/null +++ b/terraform/provider.tf @@ -0,0 +1,13 @@ +terraform { + required_version = ">= 1.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 4.0" + } + } +} + +provider "aws" { + region = var.aws_region +} \ No newline at end of file diff --git a/terraform/random.tf b/terraform/random.tf new file mode 100644 index 00000000..f045b6d2 --- /dev/null +++ b/terraform/random.tf @@ -0,0 +1,3 @@ +resource "random_id" "bucket_suffix" { + byte_length = 4 +} \ No newline at end of file diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 00000000..5ad083ae --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,41 @@ +variable "aws_region" { + type = string + default = "ap-northeast-2" +} + +variable "s3_bucket" { + type = string + default = "home-deploy-bucket" +} + +variable "lambda_memory" { + type = number + default = 256 +} + +variable "lambda_timeout" { + type = number + default = 30 +} + +variable "ssh_private_key" { + type = string + description = "SSH private key contents (recommended to use env var or Secrets Manager)." + sensitive = true + default = "" +} + +variable "ssh_param_name" { + type = string + default = "/deploy/private-key" +} + +variable "home_host" { + type = string + default = "myhome.example.com" +} + +variable "home_user" { + type = string + default = "deploy" +}