From 4338f35f37e8287463118d72f99e4d43a6ba29fa Mon Sep 17 00:00:00 2001 From: WhiteRow33 Date: Wed, 27 May 2026 15:10:17 +0300 Subject: [PATCH 1/3] Make bootstrap and prod setup configurable --- .github/README-OIDC.md | 24 +- .github/iam/terraform-prod-policy.json | 38 ++-- .github/workflows/terraform-bootstrap.yml | 13 +- .github/workflows/terraform-prod.yml | 78 ++++++- README.md | 215 +++++++++++------- helm/plane/templates/doc-store-secret.yaml | 6 +- helm/plane/values/dev.yaml | 13 +- terraform/environments/bootstrap/main.tf | 2 +- terraform/environments/bootstrap/variables.tf | 6 + terraform/environments/prod/main.tf | 5 +- terraform/environments/prod/variables.tf | 14 +- terraform/environments/prod/versions.tf | 6 +- 12 files changed, 288 insertions(+), 132 deletions(-) diff --git a/.github/README-OIDC.md b/.github/README-OIDC.md index 45d8938..dfd0bff 100644 --- a/.github/README-OIDC.md +++ b/.github/README-OIDC.md @@ -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: @@ -54,15 +54,30 @@ Optional helper script (one-time bootstrap): ```bash chmod +x scripts/bootstrap-oidc.sh -./scripts/bootstrap-oidc.sh --repo --branch main --profile personal +./scripts/bootstrap-oidc.sh --repo --branch main --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. @@ -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 diff --git a/.github/iam/terraform-prod-policy.json b/.github/iam/terraform-prod-policy.json index 8165b14..8a76c6c 100644 --- a/.github/iam/terraform-prod-policy.json +++ b/.github/iam/terraform-prod-policy.json @@ -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": "*" } } }, @@ -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:*" ], "Condition": { "StringEquals": { - "aws:ResourceTag/Project": "plane", - "aws:ResourceTag/Environment": "prod", "aws:ResourceTag/ManagedBy": "terraform" + }, + "StringLike": { + "aws:ResourceTag/Project": "*", + "aws:ResourceTag/Environment": "*" } } }, @@ -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": "*" } } }, @@ -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": "*" } } }, @@ -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": { diff --git a/.github/workflows/terraform-bootstrap.yml b/.github/workflows/terraform-bootstrap.yml index 1a21133..6a512c2 100644 --- a/.github/workflows/terraform-bootstrap.yml +++ b/.github/workflows/terraform-bootstrap.yml @@ -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: @@ -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 diff --git a/.github/workflows/terraform-prod.yml b/.github/workflows/terraform-prod.yml index 036da62..78f2268 100644 --- a/.github/workflows/terraform-prod.yml +++ b/.github/workflows/terraform-prod.yml @@ -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 @@ -87,9 +101,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 Validate run: terraform -chdir=${{ env.TF_DIR }} validate @@ -158,9 +176,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 @@ -456,11 +478,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}" \ @@ -469,9 +494,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 diff --git a/README.md b/README.md index 68437ec..6e2ebe8 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,37 @@ The initial focus is a clean and extensible network foundation for EKS-based dep - `helm/plane`: Plane wrapper chart - `helm/plane/values/dev.yaml`: current starting values for development overrides +## Configuration model + +The repository is designed to work without editing environment-specific values directly into workflow files. + +Required GitHub secret: + +- `AWS_GITHUB_OIDC_ROLE_ARN`: IAM role assumed by GitHub Actions through OIDC + +Optional GitHub secrets: + +- `PLANE_S3_ENDPOINT_URL`: only for custom S3-compatible endpoints; leave unset for AWS S3 + +Optional GitHub repository variables: + +- `AWS_REGION`: defaults to `eu-west-1` +- `PROJECT_NAME`: defaults to `plane` +- `ENVIRONMENT_NAME`: defaults to `prod` +- `TF_STATE_BUCKET_PREFIX`: defaults to `plane-cloud-platform-tfstate` +- `TF_STATE_BUCKET`: exact backend bucket override; normally leave unset +- `TF_LOCK_TABLE`: defaults to `plane-cloud-platform-tf-locks` +- `TF_STATE_KEY`: defaults to `prod/terraform.tfstate` +- `HELM_RELEASE_NAME`: defaults to `-` (`plane-prod` with defaults) +- `K8S_NAMESPACE`: defaults to `-` (`plane-prod` with defaults) +- `HELM_VALUES_FILE`: defaults to `helm/plane/values/dev.yaml` +- `PLANE_APP_HOST`: optional custom domain for Plane + +If `PLANE_APP_HOST` is unset, the production workflow deploys once with a temporary bootstrap host, reads the generated ALB DNS name, then runs a second Helm upgrade to use that ALB DNS name as Plane's app host. If you already have a real domain, set `PLANE_APP_HOST` before the first production apply and later point DNS at the ALB. + ## Current AWS network design (implemented) -Region: `eu-west-1` +Default region: `eu-west-1` through `AWS_REGION` VPC: @@ -59,7 +87,7 @@ If a single AZ has issues, workloads in other AZs keep outbound internet access The production design is intentionally isolated and highly available at the platform layer: -- Plane runs on its own dedicated EKS cluster: `plane-prod-eks` +- Plane runs on its own dedicated EKS cluster: `--eks` (`plane-prod-eks` with defaults) - EKS control plane and worker nodes use private subnets across 3 Availability Zones - Public exposure is handled through ALB ingress; application workloads stay inside the cluster/VPC boundary - One NAT Gateway per AZ avoids a single shared egress dependency for private workloads @@ -98,9 +126,9 @@ The wrapper chart is located at `helm/plane` and environment overrides start wit - Wrapper chart creates `gp3-csi` StorageClass and Plane stateful components use it explicitly. - MinIO is disabled in `helm/plane/values/dev.yaml`; Plane document storage is configured for direct S3 usage. - Plane workloads use IRSA (IAM Role for Service Account) for S3 access; no static S3 access key is required. -- Wrapper chart creates Kubernetes Secret `plane-dev-doc-store-secrets` during `helm upgrade/install`, so app deploy is self-contained. +- CI derives the Plane service account and document-store secret names from `HELM_RELEASE_NAME`, and the wrapper chart creates the document-store secret during `helm upgrade/install`, so app deploy is self-contained. - The S3 doc-store bucket has CORS enabled for browser-based presigned uploads. -- CI defaults `AWS_S3_ENDPOINT_URL` to the regional S3 endpoint (`https://s3.eu-west-1.amazonaws.com`) to avoid global S3 endpoint redirects during uploads. +- CI defaults `AWS_S3_ENDPOINT_URL` to the regional S3 endpoint (`https://s3..amazonaws.com`) to avoid global S3 endpoint redirects during uploads. ## GitHub CI/CD (OIDC) @@ -117,7 +145,7 @@ Behavior: - Manual dispatch supports `plan` and `apply` - `apply` runs in GitHub Environment `prod` for approval-gated changes -Required GitHub secrets: +GitHub secrets used by the workflows: - `AWS_GITHUB_OIDC_ROLE_ARN` - `PLANE_S3_ENDPOINT_URL` (optional; when empty, CI uses `https://s3..amazonaws.com` for AWS S3) @@ -129,8 +157,8 @@ Deployment is handled in a single pipeline: - Workflow file: `.github/workflows/terraform-prod.yml` - PR: Terraform `init/validate/plan` + Helm `dependency update/lint/template` - Main push or manual `apply`: Terraform apply first, then Helm upgrade with `--rollback-on-failure --wait --wait-for-jobs` -- Target cluster: `plane-prod-eks` -- Target namespace/release: `plane-dev` +- Target cluster: `--eks` +- Target namespace/release: `K8S_NAMESPACE` / `HELM_RELEASE_NAME` ## Manual Helm Upgrade Runbook (Local) @@ -154,28 +182,30 @@ cp helm/plane/values/dev.runtime.example.yaml helm/plane/values/dev.runtime.loca 3. Run helm upgrade with both files: ```bash -helm upgrade --install plane-dev helm/plane \ - --namespace plane-dev \ +helm upgrade --install helm/plane \ + --namespace \ --create-namespace \ --values helm/plane/values/dev.yaml \ --values helm/plane/values/dev.runtime.local.yaml \ + --set-string plane-ce.external_secrets.doc_store_existingSecret="-doc-store-secrets" \ + --set-string plane-ce.ingress.appHost="" \ --set-string plane-ce.env.docstore_bucket="" \ - --set-string plane-ce.env.aws_region="eu-west-1" \ - --set-string plane-ce.env.aws_s3_endpoint_url="https://s3.eu-west-1.amazonaws.com" + --set-string plane-ce.env.aws_region="" \ + --set-string plane-ce.env.aws_s3_endpoint_url="https://s3..amazonaws.com" ``` Without the runtime override file, manual upgrades can fail due to empty managed-service URLs. ## Plane ALB Routing -Plane path routing is defined by the upstream `plane-ce` ingress template and is applied through this wrapper chart. +Plane path routing is defined by the upstream `plane-ce` ingress template and is applied through this wrapper chart. Service names are derived from `HELM_RELEASE_NAME`; examples below use the default release `plane-prod`. -- `/` -> `plane-dev-web:3000` -- `/api` -> `plane-dev-api:8000` -- `/auth` -> `plane-dev-api:8000` -- `/live/` -> `plane-dev-live:3000` -- `/spaces` -> `plane-dev-space:3000` -- `/god-mode` -> `plane-dev-admin:3000` +- `/` -> `plane-prod-web:3000` +- `/api` -> `plane-prod-api:8000` +- `/auth` -> `plane-prod-api:8000` +- `/live/` -> `plane-prod-live:3000` +- `/spaces` -> `plane-prod-space:3000` +- `/god-mode` -> `plane-prod-admin:3000` `aws-load-balancer-controller` is installed by the production workflow and ingress class is set to `alb` in `helm/plane/values/dev.yaml`. @@ -234,8 +264,8 @@ For a fresh AWS account, run bootstrap once before prod: 1. Run workflow `.github/workflows/terraform-bootstrap.yml` with `action=apply` 2. This creates: - - S3 state bucket: `plane-cloud-platform-tfstate-` - - DynamoDB lock table: `plane-cloud-platform-tf-locks` + - S3 state bucket: `-` unless `TF_STATE_BUCKET` is set + - DynamoDB lock table: `TF_LOCK_TABLE` 3. Then run normal prod workflow `.github/workflows/terraform-prod.yml` ## Workflow Runbook @@ -256,10 +286,11 @@ Recommended: Production Terraform uses an S3 remote backend with DynamoDB locking. -- Backend bucket pattern: `plane-cloud-platform-tfstate-` -- Lock table: `plane-cloud-platform-tf-locks` +- Backend bucket pattern: `-` unless `TF_STATE_BUCKET` is set +- Backend key: `TF_STATE_KEY` +- Lock table: `TF_LOCK_TABLE` -Bootstrap resources are managed by `terraform/environments/bootstrap` and should be applied once per AWS account (via bootstrap workflow). +The `terraform/environments/prod` backend block is intentionally partial. GitHub Actions passes backend bucket, key, region, encryption, and lock-table values during `terraform init`. Bootstrap resources are managed by `terraform/environments/bootstrap` and should be applied once per AWS account through the bootstrap workflow. For portability across fresh AWS accounts, the CI workflow also ensures `AWSServiceRoleForRDS` exists before Terraform runs. @@ -271,7 +302,7 @@ Instead of pasting policy JSON in the AWS Console, sync the repo policy file dir Prerequisites: - AWS CLI installed -- An admin-capable profile (example below uses `personal`) +- An admin-capable profile - Existing role name: `github-actions-terraform-prod` Command: @@ -281,7 +312,7 @@ aws iam put-role-policy ` --role-name github-actions-terraform-prod ` --policy-name terraform-prod-policy ` --policy-document file://.github/iam/terraform-prod-policy.json ` - --profile personal + --profile ``` ```bash @@ -289,7 +320,7 @@ aws iam put-role-policy \ --role-name github-actions-terraform-prod \ --policy-name terraform-prod-policy \ --policy-document file://.github/iam/terraform-prod-policy.json \ - --profile personal + --profile ``` Verify: @@ -298,14 +329,14 @@ Verify: aws iam get-role-policy ` --role-name github-actions-terraform-prod ` --policy-name terraform-prod-policy ` - --profile personal + --profile ``` ```bash aws iam get-role-policy \ --role-name github-actions-terraform-prod \ --policy-name terraform-prod-policy \ - --profile personal + --profile ``` When to run this: @@ -332,59 +363,81 @@ Planned additions include: ## Zero-to-Deploy Guide -Use this checklist to bring up the project from scratch in a new AWS account. - -1. Create GitHub repository secrets - - Add `AWS_GITHUB_OIDC_ROLE_ARN` in repository secrets. - - Add optional `PLANE_S3_ENDPOINT_URL` only for custom S3-compatible endpoints; leave empty for AWS S3. - -2. Configure AWS IAM/OIDC once (manual) - - Create GitHub OIDC identity provider (`token.actions.githubusercontent.com`) if missing. - - Create role `github-actions-terraform-prod` with trust policy for your repo/branch. - - Attach inline policy from `.github/iam/terraform-prod-policy.json`. - - Optional one-time bootstrap script: - - `chmod +x scripts/bootstrap-oidc.sh` - - `./scripts/bootstrap-oidc.sh --repo --branch main --profile personal` - - Sync policy with: - ```powershell - aws iam put-role-policy ` - --role-name github-actions-terraform-prod ` - --policy-name terraform-prod-policy ` - --policy-document file://.github/iam/terraform-prod-policy.json ` - --profile personal - ``` - ```bash - aws iam put-role-policy \ - --role-name github-actions-terraform-prod \ - --policy-name terraform-prod-policy \ - --policy-document file://.github/iam/terraform-prod-policy.json \ - --profile personal - ``` - -3. Run bootstrap once (manual trigger in GitHub Actions) - - Open workflow `Terraform Bootstrap`. - - Run with `action=apply`. - - This creates: - - `plane-cloud-platform-tfstate-` (S3 backend bucket) - - `plane-cloud-platform-tf-locks` (DynamoDB lock table) - -4. Run production workflow - - Open workflow `Terraform Prod`. - - Run `action=apply` (or push to `main` after PR flow). - - Pipeline order: - - Terraform init/validate/plan - - Terraform apply - - Helm upgrade/install for Plane (`--rollback-on-failure --wait --wait-for-jobs`) with Terraform outputs injected as Helm values - -5. Verify cluster and app - - `kubectl get pods -n plane-dev` - - `kubectl get jobs -n plane-dev` - - `kubectl get pvc -n plane-dev` - - Ensure migration job is `Complete`, core pods are `Running/Ready`, PVCs are `Bound`. - -6. Post-setup hardening (recommended) - - Restrict Plane IRSA role policy to exact bucket/prefix needs. - - Add secret rotation and operational runbook for S3/DB recovery. +Use this sequence for a fresh AWS account. The production workflow depends on the remote Terraform backend, so the bootstrap workflow must succeed before any production plan/apply can run. + +1. Log in to AWS locally with an admin-capable identity. + + ```bash + aws login + aws sts get-caller-identity + ``` + + With a named profile: + + ```bash + aws sts get-caller-identity --profile + ``` + +2. Create or update the GitHub OIDC role in AWS. + + ```bash + chmod +x scripts/bootstrap-oidc.sh + ./scripts/bootstrap-oidc.sh --repo --branch main --profile + ``` + + The script creates the GitHub OIDC provider if missing, creates or updates role `github-actions-terraform-prod`, and attaches `.github/iam/terraform-prod-policy.json`. Copy the printed role ARN. + +3. Configure GitHub repository settings. + + Required secret: + + - `AWS_GITHUB_OIDC_ROLE_ARN` = role ARN printed by `scripts/bootstrap-oidc.sh` + + Optional variables: + + - Set `AWS_REGION`, `PROJECT_NAME`, `ENVIRONMENT_NAME`, `HELM_RELEASE_NAME`, and `K8S_NAMESPACE` only when you want values other than the defaults. + - Set `PLANE_APP_HOST` if you have a real domain ready. Leave it unset to let CI adopt the generated ALB hostname automatically. + - Leave `TF_STATE_BUCKET` unset unless you need an exact backend bucket name. The default is `-`. + +4. Run the backend bootstrap once. + + In GitHub Actions, open workflow `Terraform Bootstrap` and run it manually with `action=apply`. + + It creates: + + - S3 state bucket: `-` unless `TF_STATE_BUCKET` is set + - DynamoDB lock table: `TF_LOCK_TABLE` + +5. Validate production after bootstrap. + + Open workflow `Terraform Prod` and run it manually with `action=plan`, or open a pull request that changes `terraform/**`, `helm/**`, or the workflow file. Before bootstrap exists, this plan cannot initialize the S3 backend. + +6. Deploy production. + + Open workflow `Terraform Prod` and run it manually with `action=apply`, or merge a reviewed PR to `main`. + + The apply job does the following: + + - initializes Terraform with the bootstrapped S3/DynamoDB backend + - creates VPC, EKS, RDS PostgreSQL, Amazon MQ RabbitMQ, ElastiCache Redis, S3 doc-store, and IAM/IRSA resources + - installs cluster add-ons: metrics-server, AWS Load Balancer Controller, and Cluster Autoscaler + - reads Terraform outputs and AWS Secrets Manager values + - runs Helm upgrade/install for Plane with runtime DB/Redis/RabbitMQ/S3 values + - if `PLANE_APP_HOST` is unset, reads the generated ALB hostname and runs a second Helm upgrade using that hostname + - annotates the Plane service account with the S3 IRSA role and restarts Plane workloads + +7. Verify the deployment. + + ```bash + aws eks update-kubeconfig --name --eks --region + kubectl get pods -n + kubectl get jobs -n + kubectl get ingress -n + ``` + + Ensure the migration job is `Complete`, core pods are `Running/Ready`, and the ingress has an ALB hostname. + +8. If you later attach a real domain, set `PLANE_APP_HOST` in GitHub repository variables and rerun `Terraform Prod` with `action=apply`. Then point your DNS record at the ALB hostname shown by the ingress. ## Next Steps diff --git a/helm/plane/templates/doc-store-secret.yaml b/helm/plane/templates/doc-store-secret.yaml index cb49956..ec17290 100644 --- a/helm/plane/templates/doc-store-secret.yaml +++ b/helm/plane/templates/doc-store-secret.yaml @@ -2,8 +2,8 @@ Create the Plane doc-store secret from chart values so releases are self-contained. */ -}} {{- $plane := index .Values "plane-ce" -}} -{{- if and $plane $plane.external_secrets }} -{{- $secretName := $plane.external_secrets.doc_store_existingSecret | default (printf "%s-doc-store-secrets" .Release.Name) -}} +{{- if and $plane $plane.external_secrets $plane.external_secrets.doc_store_existingSecret }} +{{- $secretName := $plane.external_secrets.doc_store_existingSecret -}} apiVersion: v1 kind: Secret metadata: @@ -17,7 +17,7 @@ stringData: FILE_SIZE_LIMIT: "5242880" AWS_S3_BUCKET_NAME: {{ (default "" $plane.env.docstore_bucket) | quote }} USE_MINIO: "0" - AWS_REGION: {{ (default "eu-west-1" $plane.env.aws_region) | quote }} + AWS_REGION: {{ (default "" $plane.env.aws_region) | quote }} {{- if $plane.env.aws_s3_endpoint_url }} AWS_S3_ENDPOINT_URL: {{ $plane.env.aws_s3_endpoint_url | quote }} {{- end }} diff --git a/helm/plane/values/dev.yaml b/helm/plane/values/dev.yaml index fdf1781..3c0d74b 100644 --- a/helm/plane/values/dev.yaml +++ b/helm/plane/values/dev.yaml @@ -20,7 +20,7 @@ autoscaling: plane-ce: external_secrets: - doc_store_existingSecret: plane-dev-doc-store-secrets + doc_store_existingSecret: "" postgres: local_setup: false @@ -40,14 +40,15 @@ plane-ce: pgdb_remote_url: "" # Set by CI from Terraform ElastiCache output. remote_redis_url: "" - docstore_bucket: "plane-dev-uploads" - aws_region: "eu-west-1" - aws_s3_endpoint_url: "https://s3.eu-west-1.amazonaws.com" + docstore_bucket: "" + aws_region: "" + aws_s3_endpoint_url: "" ingress: enabled: true - # Temporary host without Route53/custom domain. Replace with your own domain later. - appHost: k8s-planedev-planedev-a16f021bc3-1442748221.eu-west-1.elb.amazonaws.com + # CI injects PLANE_APP_HOST when set. If it is empty, CI first deploys with + # a bootstrap host and then adopts the generated ALB hostname automatically. + appHost: "" ingressClass: alb ingress_annotations: alb.ingress.kubernetes.io/scheme: internet-facing diff --git a/terraform/environments/bootstrap/main.tf b/terraform/environments/bootstrap/main.tf index 30a1aac..47adef7 100644 --- a/terraform/environments/bootstrap/main.tf +++ b/terraform/environments/bootstrap/main.tf @@ -1,7 +1,7 @@ data "aws_caller_identity" "current" {} locals { - effective_state_bucket_name = var.state_bucket_name != "" ? var.state_bucket_name : "plane-cloud-platform-tfstate-${data.aws_caller_identity.current.account_id}" + effective_state_bucket_name = var.state_bucket_name != "" ? var.state_bucket_name : "${var.state_bucket_prefix}-${data.aws_caller_identity.current.account_id}" } resource "aws_s3_bucket" "tf_state" { diff --git a/terraform/environments/bootstrap/variables.tf b/terraform/environments/bootstrap/variables.tf index f3b08d4..14ecad8 100644 --- a/terraform/environments/bootstrap/variables.tf +++ b/terraform/environments/bootstrap/variables.tf @@ -10,6 +10,12 @@ variable "state_bucket_name" { default = "" } +variable "state_bucket_prefix" { + description = "Prefix used for the default Terraform remote state bucket name" + type = string + default = "plane-cloud-platform-tfstate" +} + variable "lock_table_name" { description = "DynamoDB table name for Terraform state locking" type = string diff --git a/terraform/environments/prod/main.tf b/terraform/environments/prod/main.tf index 8089e54..9dc5c05 100644 --- a/terraform/environments/prod/main.tf +++ b/terraform/environments/prod/main.tf @@ -2,6 +2,9 @@ data "aws_caller_identity" "current" {} locals { plane_docstore_bucket_name = var.plane_docstore_bucket_name != "" ? var.plane_docstore_bucket_name : "${var.project_name}-${var.environment}-plane-docstore-${data.aws_caller_identity.current.account_id}" + plane_namespace = var.plane_namespace != "" ? var.plane_namespace : "${var.project_name}-${var.environment}" + plane_release_name = var.plane_release_name != "" ? var.plane_release_name : "${var.project_name}-${var.environment}" + plane_service_account_name = var.plane_service_account_name != "" ? var.plane_service_account_name : "${local.plane_release_name}-srv-account" } module "vpc" { @@ -190,7 +193,7 @@ resource "aws_iam_role" "plane_irsa" { Condition = { StringEquals = { "${module.eks.oidc_issuer_hostpath}:aud" = "sts.amazonaws.com" - "${module.eks.oidc_issuer_hostpath}:sub" = "system:serviceaccount:${var.plane_namespace}:${var.plane_service_account_name}" + "${module.eks.oidc_issuer_hostpath}:sub" = "system:serviceaccount:${local.plane_namespace}:${local.plane_service_account_name}" } } } diff --git a/terraform/environments/prod/variables.tf b/terraform/environments/prod/variables.tf index 53055f1..57fc366 100644 --- a/terraform/environments/prod/variables.tf +++ b/terraform/environments/prod/variables.tf @@ -346,15 +346,21 @@ variable "redis_auth_token" { } variable "plane_namespace" { - description = "Kubernetes namespace where Plane is deployed" + description = "Optional Kubernetes namespace override where Plane is deployed" type = string - default = "plane-dev" + default = "" +} + +variable "plane_release_name" { + description = "Helm release name for Plane; used to derive the default service account name" + type = string + default = "" } variable "plane_service_account_name" { - description = "Plane Kubernetes service account name used by workloads" + description = "Optional Plane Kubernetes service account name override used by workloads" type = string - default = "plane-dev-srv-account" + default = "" } variable "plane_docstore_bucket_name" { diff --git a/terraform/environments/prod/versions.tf b/terraform/environments/prod/versions.tf index af381e5..379a454 100644 --- a/terraform/environments/prod/versions.tf +++ b/terraform/environments/prod/versions.tf @@ -1,11 +1,7 @@ terraform { required_version = ">= 1.6.0" - backend "s3" { - key = "prod/terraform.tfstate" - region = "eu-west-1" - encrypt = true - } + backend "s3" {} required_providers { aws = { From a57d547d5d16ea4b2ac82090d1d67138a3879ae3 Mon Sep 17 00:00:00 2001 From: WhiteRow33 Date: Wed, 27 May 2026 15:21:15 +0300 Subject: [PATCH 2/3] Handle missing prod backend in PR validation --- .github/workflows/terraform-prod.yml | 31 ++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/.github/workflows/terraform-prod.yml b/.github/workflows/terraform-prod.yml index 78f2268..24527a9 100644 --- a/.github/workflows/terraform-prod.yml +++ b/.github/workflows/terraform-prod.yml @@ -98,21 +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=${STATE_BUCKET}" \ + -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 @@ -128,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 From 681de8c68932f9971c769e2cb0aaba4e31b8e32b Mon Sep 17 00:00:00 2001 From: WhiteRow33 Date: Wed, 27 May 2026 17:25:36 +0300 Subject: [PATCH 3/3] Document standard AWS CLI login flow --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6e2ebe8..6c305fc 100644 --- a/README.md +++ b/README.md @@ -365,14 +365,15 @@ Planned additions include: Use this sequence for a fresh AWS account. The production workflow depends on the remote Terraform backend, so the bootstrap workflow must succeed before any production plan/apply can run. -1. Log in to AWS locally with an admin-capable identity. +1. Configure and log in to AWS locally with an admin-capable identity. ```bash - aws login - aws sts get-caller-identity + aws configure sso --profile + aws sso login --profile + aws sts get-caller-identity --profile ``` - With a named profile: + If you use access keys or another credential source instead of IAM Identity Center, configure that profile and verify it resolves to the intended AWS account: ```bash aws sts get-caller-identity --profile