Install
openclaw skills install skills-sh:reason-machines/data-skills/infrastructure-cicd-data-engineeringInfrastructure CI/CD for Data Engineering > Skill by ara.so — Data Skills collection This project demonstrates practical CI/CD patterns for deploying data infrastructure changes using GitHub Actions, Terraform, and AWS. It uses OpenID Connect (OIDC) for secure, keyless…
openclaw skills install skills-sh:reason-machines/data-skills/infrastructure-cicd-data-engineeringSkill by ara.so — Data Skills collection
This project demonstrates practical CI/CD patterns for deploying data infrastructure changes using GitHub Actions, Terraform, and AWS. It uses OpenID Connect (OIDC) for secure, keyless authentication between GitHub Actions and AWS, eliminating the need for long-lived AWS credentials.
.
├── terraform/
│ ├── bootstrap/ # Initial setup (S3 backend, OIDC)
│ │ └── main.tf
│ └── main/ # Main infrastructure definitions
│ └── main.tf
├── .github/
│ └── workflows/
│ ├── ci.yml # Format and validation checks
│ └── deploy.yml # Deployment workflow
└── tear-down.sh # Cleanup script
# Verify Terraform installation
terraform version
# Verify AWS credentials
aws sts get-caller-identity
The bootstrap process creates:
# Initialize and apply bootstrap configuration
terraform -chdir=terraform/bootstrap init
terraform -chdir=terraform/bootstrap apply
# Capture the outputs
terraform -chdir=terraform/bootstrap output
Expected output:
github_actions_role_arn = "arn:aws:iam::123456789012:role/github-actions-role"
state_bucket_name = "my-terraform-state-bucket"
Create a repository secret named AWS_ROLE_ARN:
Settings → Secrets and variables → Actions → New repository secretAWS_ROLE_ARN# Example ARN format (don't include quotes when pasting)
arn:aws:iam::123456789012:role/github-actions-role
Set up a production environment with manual approval:
Settings → Environments → New environmentproductionterraform/bootstrap/main.tf (simplified example):
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
# S3 bucket for Terraform state
resource "aws_s3_bucket" "terraform_state" {
bucket = "${var.project_name}-terraform-state-${var.environment}"
tags = {
Name = "Terraform State Bucket"
Environment = var.environment
ManagedBy = "Terraform"
}
}
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
# DynamoDB table for state locking
resource "aws_dynamodb_table" "terraform_locks" {
name = "${var.project_name}-terraform-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
tags = {
Name = "Terraform State Lock Table"
Environment = var.environment
ManagedBy = "Terraform"
}
}
# OIDC provider for GitHub Actions
resource "aws_iam_openid_connect_provider" "github_actions" {
url = "https://token.actions.githubusercontent.com"
client_id_list = [
"sts.amazonaws.com"
]
thumbprint_list = [
"6938fd4d98bab03faadb97b34396831e3780aea1"
]
}
# IAM role for GitHub Actions
resource "aws_iam_role" "github_actions" {
name = "github-actions-terraform-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.github_actions.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:${var.github_org}/${var.github_repo}:*"
}
}
}
]
})
}
# Attach policies to the role
resource "aws_iam_role_policy_attachment" "github_actions_admin" {
role = aws_iam_role.github_actions.name
policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
}
# Outputs
output "github_actions_role_arn" {
value = aws_iam_role.github_actions.arn
description = "ARN of the IAM role for GitHub Actions"
}
output "state_bucket_name" {
value = aws_s3_bucket.terraform_state.bucket
description = "Name of the S3 bucket for Terraform state"
}
output "state_lock_table_name" {
value = aws_dynamodb_table.terraform_locks.name
description = "Name of the DynamoDB table for state locking"
}
terraform/bootstrap/variables.tf:
variable "aws_region" {
description = "AWS region for resources"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Project name for resource naming"
type = string
default = "data-infra"
}
variable "environment" {
description = "Environment name"
type = string
default = "production"
}
variable "github_org" {
description = "GitHub organization or username"
type = string
}
variable "github_repo" {
description = "GitHub repository name"
type = string
}
terraform/main/main.tf (example data infrastructure):
terraform {
required_version = ">= 1.0"
backend "s3" {
bucket = "data-infra-terraform-state-production"
key = "main/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "data-infra-terraform-locks"
encrypt = true
}
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "Terraform"
DeployedBy = "GitHub-Actions"
}
}
}
# Example: S3 bucket for data lake
resource "aws_s3_bucket" "data_lake" {
bucket = "${var.project_name}-data-lake-${var.environment}"
}
resource "aws_s3_bucket_versioning" "data_lake" {
bucket = aws_s3_bucket.data_lake.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "data_lake" {
bucket = aws_s3_bucket.data_lake.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
# Example: Glue database for data catalog
resource "aws_glue_catalog_database" "analytics" {
name = "${var.project_name}_analytics_${var.environment}"
description = "Analytics data catalog database"
}
# Example: IAM role for Glue jobs
resource "aws_iam_role" "glue_job" {
name = "${var.project_name}-glue-job-role-${var.environment}"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Service = "glue.amazonaws.com"
}
Action = "sts:AssumeRole"
}
]
})
}
resource "aws_iam_role_policy_attachment" "glue_service" {
role = aws_iam_role.glue_job.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSGlueServiceRole"
}
# Outputs
output "data_lake_bucket" {
value = aws_s3_bucket.data_lake.bucket
description = "Name of the data lake S3 bucket"
}
output "glue_database" {
value = aws_glue_catalog_database.analytics.name
description = "Name of the Glue catalog database"
}
.github/workflows/ci.yml:
name: Terraform CI
on:
pull_request:
branches:
- main
paths:
- 'terraform/**'
- '.github/workflows/ci.yml'
permissions:
contents: read
pull-requests: write
jobs:
terraform-checks:
name: Terraform Checks
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.5.0
- name: Terraform Format Check
id: fmt
run: terraform fmt -check -recursive terraform/
continue-on-error: true
- name: Terraform Init (Main)
run: terraform -chdir=terraform/main init -backend=false
- name: Terraform Validate (Main)
run: terraform -chdir=terraform/main validate
- name: Comment PR
if: steps.fmt.outcome == 'failure'
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '❌ Terraform formatting check failed. Run `terraform fmt -recursive terraform/` to fix.'
})
- name: Fail if format check failed
if: steps.fmt.outcome == 'failure'
run: exit 1
.github/workflows/deploy.yml:
name: Deploy Infrastructure
on:
push:
branches:
- main
paths:
- 'terraform/main/**'
workflow_dispatch:
permissions:
id-token: write
contents: read
pull-requests: write
jobs:
terraform-plan:
name: Terraform Plan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.5.0
- name: Terraform Init
run: terraform -chdir=terraform/main init
- name: Terraform Plan
id: plan
run: |
terraform -chdir=terraform/main plan -no-color -out=tfplan
terraform -chdir=terraform/main show -no-color tfplan > plan.txt
- name: Upload plan
uses: actions/upload-artifact@v4
with:
name: terraform-plan
path: |
terraform/main/tfplan
plan.txt
retention-days: 5
terraform-apply:
name: Terraform Apply
needs: terraform-plan
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.5.0
- name: Terraform Init
run: terraform -chdir=terraform/main init
- name: Download plan
uses: actions/download-artifact@v4
with:
name: terraform-plan
path: terraform/main/
- name: Terraform Apply
run: terraform -chdir=terraform/main apply -auto-approve tfplan
terraform/main/:# terraform/main/kinesis.tf
resource "aws_kinesis_stream" "events" {
name = "${var.project_name}-events-${var.environment}"
shard_count = 1
retention_period = 24
shard_level_metrics = [
"IncomingBytes",
"IncomingRecords",
"OutgoingBytes",
"OutgoingRecords",
]
}
output "kinesis_stream_name" {
value = aws_kinesis_stream.events.name
description = "Name of the Kinesis stream"
}
terraform fmt -recursive terraform/
terraform -chdir=terraform/main init -backend=false
terraform -chdir=terraform/main validate
Create a pull request:
Merge to main:
terraform planterraform apply executes# View workflow runs
gh run list --workflow=deploy.yml
# View specific run logs
gh run view <run-id> --log
# Check specific job
gh run view <run-id> --job=<job-id>
# Initialize with backend
terraform -chdir=terraform/main init
# Plan changes
terraform -chdir=terraform/main plan
# Apply (be careful in production!)
terraform -chdir=terraform/main apply
| Secret Name | Description | Example |
|---|---|---|
AWS_ROLE_ARN | IAM role ARN for GitHub Actions | arn:aws:iam::123456789012:role/github-actions-role |
Create terraform/main/terraform.tfvars:
aws_region = "us-east-1"
project_name = "my-data-platform"
environment = "production"
# Additional configuration
enable_monitoring = true
data_retention_days = 90
terraform/main/environments/dev.tfvars:
environment = "dev"
project_name = "my-data-platform"
aws_region = "us-east-1"
# Dev-specific settings
enable_monitoring = false
data_retention_days = 7
terraform/main/environments/prod.tfvars:
environment = "production"
project_name = "my-data-platform"
aws_region = "us-east-1"
enable_monitoring = true
data_retention_days = 90
Modify workflow to use environment-specific variables:
- name: Terraform Plan
run: |
terraform -chdir=terraform/main plan \
-var-file=environments/${{ github.event.inputs.environment }}.tfvars \
-out=tfplan
jobs:
terraform-plan:
strategy:
matrix:
environment: [dev, staging, production]
name: Plan - ${{ matrix.environment }}
runs-on: ubuntu-latest
environment: ${{ matrix.environment }}
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets[format('AWS_ROLE_ARN_{0}', matrix.environment)] }}
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
- name: Terraform Plan
run: |
terraform -chdir=terraform/main plan \
-var-file=environments/${{ matrix.environment }}.tfvars \
-out=tfplan-${{ matrix.environment }}
.github/workflows/drift-detection.yml:
name: Terraform Drift Detection
on:
schedule:
- cron: '0 6 * * *' # Daily at 6 AM UTC
workflow_dispatch:
permissions:
id-token: write
contents: read
issues: write
jobs:
detect-drift:
name: Detect Infrastructure Drift
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
- name: Terraform Init
run: terraform -chdir=terraform/main init
- name: Terraform Plan
id: plan
run: |
terraform -chdir=terraform/main plan -detailed-exitcode -no-color > plan.txt
continue-on-error: true
- name: Create Issue on Drift
if: steps.plan.outputs.exitcode == 2
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const plan = fs.readFileSync('plan.txt', 'utf8');
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: '⚠️ Infrastructure Drift Detected',
body: `Drift detected in Terraform state.\n\n\`\`\`\n${plan}\n\`\`\``,
labels: ['drift', 'infrastructure']
});
Add to .github/workflows/ci.yml:
- name: Setup Infracost
uses: infracost/actions/setup@v2
with:
api-key: ${{ secrets.INFRACOST_API_KEY }}
- name: Generate cost estimate
run: |
infracost breakdown \
--path=terraform/main \
--format=json \
--out-file=/tmp/infracost.json
- name: Post cost comment
run: |
infracost comment github \
--path=/tmp/infracost.json \
--repo=$GITHUB_REPOSITORY \
--github-token=${{ secrets.GITHUB_TOKEN }} \
--pull-request=${{ github.event.pull_request.number }}
Solution: Ensure AWS credentials are properly configured in GitHub Actions:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
Cause: Another Terraform operation is running or a previous operation failed to release the lock.
Solution:
# Force unlock (use with caution)
terraform -chdir=terraform/main force-unlock <LOCK_ID>
Cause: OIDC provider not configured correctly or role ARN is incorrect.
Solution:
AWS_ROLE_ARN secret matches bootstrap outputid-token: write permissionError: Terraform files not properly formatted.
Solution:
# Fix formatting locally
terraform fmt -recursive terraform/
# Check what would change
terraform fmt -check -recursive terraform/
# Commit and push
git add terraform/
git commit -m "fix: format terraform files"
git push
Error: "Error: Failed to get existing workspaces: S3 bucket does not exist"
Cause: Backend configuration references a bucket that doesn't exist.
Solution:
terraform -chdir=terraform/bootstrap outputterraform/main/main.tf with correct bucket nameterraform initCause: Production environment not configured or reviewers not set.
Solution:
Settings → Environments → productionDestroy all resources:
# Run the teardown script
./tear-down.sh
# Or manually
terraform -chdir=terraform/main destroy
terraform -chdir=terraform/bootstrap destroy
tear-down.sh example:
#!/bin/bash
set -e
echo "Destroying main infrastructure..."
terraform -chdir=terraform/main destroy -auto-approve
echo "Destroying bootstrap resources..."
terraform -chdir=terraform/bootstrap destroy -auto-approve
echo "Cleanup complete!"
terraform fmt before committing8e7bd5f22574