Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions .github/README-OIDC.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Example trust policy (replace placeholders):
}
```

Attach least-privilege IAM permissions for Terraform operations in `eu-west-1`.
Attach IAM permissions for Terraform operations. The checked-in policy avoids hardcoded account IDs and uses resource-name patterns plus tag conditions where AWS supports them.

You can start from:

Expand All @@ -54,15 +54,30 @@ Optional helper script (one-time bootstrap):

```bash
chmod +x scripts/bootstrap-oidc.sh
./scripts/bootstrap-oidc.sh --repo <OWNER/REPO> --branch main --profile personal
./scripts/bootstrap-oidc.sh --repo <OWNER/REPO> --branch main --profile <profile>
```

Re-run the helper after changes to `.github/iam/terraform-prod-policy.json`; it updates both trust policy and inline permissions.

## 3. Configure GitHub secrets

In GitHub repository settings, add:

- `AWS_GITHUB_OIDC_ROLE_ARN` = IAM role ARN created above

Optional repository variables:

- `AWS_REGION`
- `PROJECT_NAME`
- `ENVIRONMENT_NAME`
- `TF_STATE_BUCKET_PREFIX`
- `TF_STATE_BUCKET`
- `TF_LOCK_TABLE`
- `TF_STATE_KEY`
- `HELM_RELEASE_NAME`
- `K8S_NAMESPACE`
- `PLANE_APP_HOST`

## 4. Protect production apply

Create a GitHub Environment named `prod` and require reviewer approval.
Expand All @@ -71,8 +86,9 @@ The workflow already routes apply job to this environment.

## 5. Workflow behavior

- Pull requests: `init`, `validate`, `plan`
- Manual run (`workflow_dispatch`): choose `plan` or `apply`
- `Terraform Bootstrap`: run `apply` once before production plans so the S3/DynamoDB backend exists.
- Pull requests: production `init`, `validate`, and `plan`.
- Manual run (`workflow_dispatch`): choose production `plan` or `apply`.

## 6. RDS password handling

Expand Down
38 changes: 23 additions & 15 deletions .github/iam/terraform-prod-policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,11 @@
"Resource": "*",
"Condition": {
"StringEquals": {
"aws:RequestTag/Project": "plane",
"aws:RequestTag/Environment": "prod",
"aws:RequestTag/ManagedBy": "terraform"
},
"StringLike": {
"aws:RequestTag/Project": "*",
"aws:RequestTag/Environment": "*"
}
}
},
Expand All @@ -154,16 +156,18 @@
"mq:UpdateBroker",
"mq:RebootBroker",
"mq:CreateTags",
"mq:RemoveTags"
"mq:DeleteTags"
],
"Resource": [
"arn:aws:mq:eu-west-1:925606812216:broker:plane-prod-rabbitmq:*"
"arn:aws:mq:*:*:broker:*-rabbitmq:*"
Comment thread
WhiteRow33 marked this conversation as resolved.
],
"Condition": {
"StringEquals": {
"aws:ResourceTag/Project": "plane",
"aws:ResourceTag/Environment": "prod",
"aws:ResourceTag/ManagedBy": "terraform"
},
"StringLike": {
"aws:ResourceTag/Project": "*",
"aws:ResourceTag/Environment": "*"
}
}
},
Expand All @@ -176,9 +180,11 @@
"Resource": "*",
"Condition": {
"StringEquals": {
"aws:RequestTag/Project": "plane",
"aws:RequestTag/Environment": "prod",
"aws:RequestTag/ManagedBy": "terraform"
},
"StringLike": {
"aws:RequestTag/Project": "*",
"aws:RequestTag/Environment": "*"
}
}
},
Expand All @@ -205,14 +211,16 @@
"elasticache:RemoveTagsFromResource"
],
"Resource": [
"arn:aws:elasticache:eu-west-1:925606812216:subnetgroup:plane-prod-redis-subnet-group",
"arn:aws:elasticache:eu-west-1:925606812216:replicationgroup:plane-prod-redis"
"arn:aws:elasticache:*:*:subnetgroup:*-redis-subnet-group",
"arn:aws:elasticache:*:*:replicationgroup:*-redis"
],
"Condition": {
"StringEquals": {
"aws:ResourceTag/Project": "plane",
"aws:ResourceTag/Environment": "prod",
"aws:ResourceTag/ManagedBy": "terraform"
},
"StringLike": {
"aws:ResourceTag/Project": "*",
"aws:ResourceTag/Environment": "*"
}
}
},
Expand All @@ -221,9 +229,9 @@
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": [
"arn:aws:iam::925606812216:role/plane-prod-eks-cluster-role",
"arn:aws:iam::925606812216:role/plane-prod-eks-node-role",
"arn:aws:iam::925606812216:role/plane-prod-eks-ebs-csi-role"
"arn:aws:iam::*:role/*-eks-cluster-role",
"arn:aws:iam::*:role/*-eks-node-role",
"arn:aws:iam::*:role/*-eks-ebs-csi-role"
],
"Condition": {
"StringEquals": {
Expand Down
13 changes: 12 additions & 1 deletion .github/workflows/terraform-bootstrap.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@ on:
paths:
- 'terraform/environments/bootstrap/**'
- '.github/workflows/terraform-bootstrap.yml'
- '.github/iam/**'
- 'scripts/bootstrap-oidc.sh'
push:
branches:
- main
paths:
- 'terraform/environments/bootstrap/**'
- '.github/workflows/terraform-bootstrap.yml'
- '.github/iam/**'
- 'scripts/bootstrap-oidc.sh'
workflow_dispatch:
inputs:
action:
Expand All @@ -24,7 +28,14 @@ on:

env:
TF_DIR: terraform/environments/bootstrap
AWS_REGION: eu-west-1
AWS_REGION: ${{ vars.AWS_REGION || 'eu-west-1' }}
TF_STATE_BUCKET: ${{ vars.TF_STATE_BUCKET }}
TF_STATE_BUCKET_PREFIX: ${{ vars.TF_STATE_BUCKET_PREFIX || 'plane-cloud-platform-tfstate' }}
TF_LOCK_TABLE: ${{ vars.TF_LOCK_TABLE || 'plane-cloud-platform-tf-locks' }}
TF_VAR_aws_region: ${{ vars.AWS_REGION || 'eu-west-1' }}
TF_VAR_state_bucket_name: ${{ vars.TF_STATE_BUCKET }}
TF_VAR_state_bucket_prefix: ${{ vars.TF_STATE_BUCKET_PREFIX || 'plane-cloud-platform-tfstate' }}
TF_VAR_lock_table_name: ${{ vars.TF_LOCK_TABLE || 'plane-cloud-platform-tf-locks' }}

permissions:
id-token: write
Expand Down
107 changes: 95 additions & 12 deletions .github/workflows/terraform-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,27 @@ on:

env:
TF_DIR: terraform/environments/prod
AWS_REGION: eu-west-1
EKS_CLUSTER_NAME: plane-prod-eks
HELM_RELEASE_NAME: plane-dev
K8S_NAMESPACE: plane-dev
AWS_REGION: ${{ vars.AWS_REGION || 'eu-west-1' }}
PROJECT_NAME: ${{ vars.PROJECT_NAME || 'plane' }}
ENVIRONMENT_NAME: ${{ vars.ENVIRONMENT_NAME || 'prod' }}
EKS_CLUSTER_NAME: ${{ format('{0}-{1}-eks', vars.PROJECT_NAME || 'plane', vars.ENVIRONMENT_NAME || 'prod') }}
HELM_RELEASE_NAME: ${{ vars.HELM_RELEASE_NAME || format('{0}-{1}', vars.PROJECT_NAME || 'plane', vars.ENVIRONMENT_NAME || 'prod') }}
K8S_NAMESPACE: ${{ vars.K8S_NAMESPACE || format('{0}-{1}', vars.PROJECT_NAME || 'plane', vars.ENVIRONMENT_NAME || 'prod') }}
HELM_CHART_DIR: helm/plane
HELM_VALUES_FILE: helm/plane/values/dev.yaml
PLANE_DOCSTORE_SECRET_NAME: plane-dev-doc-store-secrets
HELM_VALUES_FILE: ${{ vars.HELM_VALUES_FILE || 'helm/plane/values/dev.yaml' }}
PLANE_APP_HOST: ${{ vars.PLANE_APP_HOST }}
PLANE_SERVICE_ACCOUNT_NAME: ${{ format('{0}-srv-account', vars.HELM_RELEASE_NAME || format('{0}-{1}', vars.PROJECT_NAME || 'plane', vars.ENVIRONMENT_NAME || 'prod')) }}
PLANE_DOCSTORE_SECRET_NAME: ${{ format('{0}-doc-store-secrets', vars.HELM_RELEASE_NAME || format('{0}-{1}', vars.PROJECT_NAME || 'plane', vars.ENVIRONMENT_NAME || 'prod')) }}
TF_STATE_BUCKET: ${{ vars.TF_STATE_BUCKET }}
TF_STATE_BUCKET_PREFIX: ${{ vars.TF_STATE_BUCKET_PREFIX || 'plane-cloud-platform-tfstate' }}
TF_STATE_KEY: ${{ vars.TF_STATE_KEY || 'prod/terraform.tfstate' }}
TF_LOCK_TABLE: ${{ vars.TF_LOCK_TABLE || 'plane-cloud-platform-tf-locks' }}
TF_VAR_aws_region: ${{ vars.AWS_REGION || 'eu-west-1' }}
TF_VAR_project_name: ${{ vars.PROJECT_NAME || 'plane' }}
TF_VAR_environment: ${{ vars.ENVIRONMENT_NAME || 'prod' }}
TF_VAR_plane_namespace: ${{ vars.K8S_NAMESPACE || format('{0}-{1}', vars.PROJECT_NAME || 'plane', vars.ENVIRONMENT_NAME || 'prod') }}
TF_VAR_plane_release_name: ${{ vars.HELM_RELEASE_NAME || format('{0}-{1}', vars.PROJECT_NAME || 'plane', vars.ENVIRONMENT_NAME || 'prod') }}
TF_VAR_plane_service_account_name: ${{ format('{0}-srv-account', vars.HELM_RELEASE_NAME || format('{0}-{1}', vars.PROJECT_NAME || 'plane', vars.ENVIRONMENT_NAME || 'prod')) }}

concurrency:
group: terraform-prod-main
Expand Down Expand Up @@ -84,17 +98,47 @@ jobs:
fi
done

- name: Terraform Init
- name: Resolve Terraform Backend
id: backend
run: |
set -euo pipefail
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
STATE_BUCKET="${TF_STATE_BUCKET:-${TF_STATE_BUCKET_PREFIX}-${ACCOUNT_ID}}"
echo "state_bucket=${STATE_BUCKET}" >> "$GITHUB_OUTPUT"

if aws s3api head-bucket --bucket "${STATE_BUCKET}" >/dev/null 2>&1; then
echo "available=true" >> "$GITHUB_OUTPUT"
else
echo "available=false" >> "$GITHUB_OUTPUT"
echo "::warning::Terraform backend bucket '${STATE_BUCKET}' does not exist. Run Terraform Bootstrap with action=apply before remote-backed production plans."
fi

- name: Require Backend For Manual Plan
if: github.event_name == 'workflow_dispatch' && steps.backend.outputs.available != 'true'
run: |
echo "Terraform backend bucket '${{ steps.backend.outputs.state_bucket }}' does not exist."
echo "Run Terraform Bootstrap with action=apply, then rerun Terraform Prod with action=plan."
exit 1

- name: Terraform Init
if: steps.backend.outputs.available == 'true'
run: |
terraform -chdir=${{ env.TF_DIR }} init -input=false \
-backend-config="bucket=plane-cloud-platform-tfstate-${ACCOUNT_ID}" \
-backend-config="dynamodb_table=plane-cloud-platform-tf-locks"
-backend-config="bucket=${{ steps.backend.outputs.state_bucket }}" \
-backend-config="key=${{ env.TF_STATE_KEY }}" \
-backend-config="region=${{ env.AWS_REGION }}" \
-backend-config="encrypt=true" \
-backend-config="dynamodb_table=${{ env.TF_LOCK_TABLE }}"

- name: Terraform Init Without Backend
if: steps.backend.outputs.available != 'true'
run: terraform -chdir=${{ env.TF_DIR }} init -backend=false -input=false

- name: Terraform Validate
run: terraform -chdir=${{ env.TF_DIR }} validate

- name: Terraform Plan
if: steps.backend.outputs.available == 'true'
run: terraform -chdir=${{ env.TF_DIR }} plan -input=false -out=tfplan

- name: Setup Helm
Expand All @@ -110,6 +154,7 @@ jobs:
run: helm template "${{ env.HELM_RELEASE_NAME }}" "${{ env.HELM_CHART_DIR }}" --namespace "${{ env.K8S_NAMESPACE }}" --values "${{ env.HELM_VALUES_FILE }}" > /dev/null

- name: Upload Plan Artifact
if: steps.backend.outputs.available == 'true'
uses: actions/upload-artifact@v4
with:
name: tfplan
Expand Down Expand Up @@ -158,9 +203,13 @@ jobs:
- name: Terraform Init
run: |
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
STATE_BUCKET="${TF_STATE_BUCKET:-${TF_STATE_BUCKET_PREFIX}-${ACCOUNT_ID}}"
terraform -chdir=${{ env.TF_DIR }} init -input=false \
-backend-config="bucket=plane-cloud-platform-tfstate-${ACCOUNT_ID}" \
-backend-config="dynamodb_table=plane-cloud-platform-tf-locks"
-backend-config="bucket=${STATE_BUCKET}" \
-backend-config="key=${{ env.TF_STATE_KEY }}" \
-backend-config="region=${{ env.AWS_REGION }}" \
-backend-config="encrypt=true" \
-backend-config="dynamodb_table=${{ env.TF_LOCK_TABLE }}"

- name: Terraform Apply
run: terraform -chdir=${{ env.TF_DIR }} apply -input=false -auto-approve
Expand Down Expand Up @@ -456,11 +505,14 @@ jobs:
PLANE_S3_ENDPOINT_URL: ${{ secrets.PLANE_S3_ENDPOINT_URL }}
run: |
S3_ENDPOINT_URL="${PLANE_S3_ENDPOINT_URL:-https://s3.${{ env.AWS_REGION }}.amazonaws.com}"
APP_HOST="${PLANE_APP_HOST:-bootstrap-${{ env.HELM_RELEASE_NAME }}.${{ env.K8S_NAMESPACE }}.invalid}"

helm upgrade --install "${{ env.HELM_RELEASE_NAME }}" "${{ env.HELM_CHART_DIR }}" \
--namespace "${{ env.K8S_NAMESPACE }}" \
--values "${{ env.HELM_VALUES_FILE }}" \
--values "${{ steps.plane_runtime_values.outputs.runtime_values_file }}" \
--set-string plane-ce.external_secrets.doc_store_existingSecret="${{ env.PLANE_DOCSTORE_SECRET_NAME }}" \
--set-string plane-ce.ingress.appHost="${APP_HOST}" \
--set-string plane-ce.env.docstore_bucket="${{ steps.tf_outputs.outputs.plane_docstore_bucket_name }}" \
--set-string plane-ce.env.aws_region="${{ env.AWS_REGION }}" \
--set-string plane-ce.env.aws_s3_endpoint_url="${S3_ENDPOINT_URL}" \
Expand All @@ -469,9 +521,40 @@ jobs:
--wait-for-jobs \
--timeout 20m

- name: Adopt Generated ALB Host
if: env.PLANE_APP_HOST == ''
run: |
set -euo pipefail

ALB_HOST=""
for _ in {1..40}; do
ALB_HOST="$(kubectl get ingress "${{ env.HELM_RELEASE_NAME }}-ingress" \
-n "${{ env.K8S_NAMESPACE }}" \
-o jsonpath='{.status.loadBalancer.ingress[0].hostname}' 2>/dev/null || true)"
if [ -n "${ALB_HOST}" ]; then
break
fi
sleep 15
done

if [ -z "${ALB_HOST}" ]; then
echo "Unable to resolve generated ALB hostname from ingress."
exit 1
fi

echo "No PLANE_APP_HOST repository variable set; using generated ALB host: ${ALB_HOST}"
helm upgrade "${{ env.HELM_RELEASE_NAME }}" "${{ env.HELM_CHART_DIR }}" \
--namespace "${{ env.K8S_NAMESPACE }}" \
--reuse-values \
--set-string plane-ce.ingress.appHost="${ALB_HOST}" \
--rollback-on-failure \
--wait \
--wait-for-jobs \
--timeout 20m

- name: Annotate Plane Service Account (IRSA)
run: |
kubectl annotate serviceaccount "${{ env.HELM_RELEASE_NAME }}-srv-account" \
kubectl annotate serviceaccount "${{ env.PLANE_SERVICE_ACCOUNT_NAME }}" \
-n "${{ env.K8S_NAMESPACE }}" \
eks.amazonaws.com/role-arn="${{ steps.tf_outputs.outputs.plane_irsa_role_arn }}" \
--overwrite
Expand Down
Loading
Loading