From 3cefcd93f7350cb9966d29cc30ee38c896c34deb Mon Sep 17 00:00:00 2001 From: semih702 Date: Wed, 17 Jun 2026 23:02:33 +0300 Subject: [PATCH 1/2] Polish Plane AWS deployment kit --- .github/README-OIDC.md | 26 +- .github/iam/terraform-prod-policy.json | 55 +- .github/workflows/ci.yml | 93 ++++ .github/workflows/terraform-bootstrap.yml | 98 +++- .github/workflows/terraform-prod.yml | 496 ++++++++++++----- .gitignore | 3 + README.md | 498 +++--------------- docs/iam.md | 38 ++ docs/proposal-to-plane.md | 117 ++++ docs/runbook.md | 43 ++ docs/service-modes.md | 62 +++ helm/plane/Chart.lock | 4 +- helm/plane/Chart.yaml | 2 +- helm/plane/templates/doc-store-secret.yaml | 17 +- helm/plane/templates/ingress.yaml | 80 +++ .../templates/required-runtime-values.yaml | 56 ++ helm/plane/values.yaml | 5 + helm/plane/values/dev.runtime.example.yaml | 10 + helm/plane/values/dev.yaml | 30 +- scripts/bootstrap-oidc.sh | 4 +- terraform/environments/prod/main.tf | 126 ++++- terraform/environments/prod/outputs.tf | 33 +- terraform/environments/prod/variables.tf | 70 ++- terraform/modules/amazonmq-rabbitmq/main.tf | 2 +- terraform/modules/elasticache-redis/main.tf | 2 +- terraform/modules/rds-postgres/main.tf | 4 +- terraform/modules/rds-postgres/variables.tf | 6 + 27 files changed, 1322 insertions(+), 658 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 docs/iam.md create mode 100644 docs/proposal-to-plane.md create mode 100644 docs/runbook.md create mode 100644 docs/service-modes.md create mode 100644 helm/plane/templates/ingress.yaml create mode 100644 helm/plane/templates/required-runtime-values.yaml diff --git a/.github/README-OIDC.md b/.github/README-OIDC.md index 45d8938..e15ba5d 100644 --- a/.github/README-OIDC.md +++ b/.github/README-OIDC.md @@ -33,10 +33,7 @@ Example trust policy (replace placeholders): "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" }, "StringLike": { - "token.actions.githubusercontent.com:sub": [ - "repo:/:ref:refs/heads/main", - "repo:/:pull_request" - ] + "token.actions.githubusercontent.com:sub": "repo:/:ref:refs/heads/main" } } } @@ -44,7 +41,7 @@ Example trust policy (replace placeholders): } ``` -Attach least-privilege IAM permissions for Terraform operations in `eu-west-1`. +Attach the IAM permissions used by the workflows. You can start from: @@ -62,6 +59,11 @@ chmod +x scripts/bootstrap-oidc.sh In GitHub repository settings, add: - `AWS_GITHUB_OIDC_ROLE_ARN` = IAM role ARN created above +- Optional: `PLANE_S3_ENDPOINT_URL` if you use S3-compatible storage instead of AWS S3 + +Optional repository variables include `AWS_REGION`, `TF_STATE_BUCKET`, `TF_STATE_BUCKET_PREFIX`, `TF_STATE_KEY`, `TF_LOCK_TABLE_NAME`, `HELM_RELEASE_NAME`, `K8S_NAMESPACE`, `PLANE_APP_HOST`, `POSTGRES_MODE`, `REDIS_MODE`, `RABBITMQ_MODE`, and `OBJECT_STORE_MODE`. + +External service modes may require additional secrets. See `docs/service-modes.md`. ## 4. Protect production apply @@ -71,17 +73,17 @@ 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` - -## 6. RDS password handling +- Pull requests and pushes to `main`: secret-free `CI` workflow only. +- Manual `Terraform Bootstrap` run (`workflow_dispatch`): choose `plan` or `apply`. +- Manual `Terraform Prod` run (`workflow_dispatch`): choose `plan` or `apply`. +- Production apply is manual; `push` does not auto-apply infrastructure. -RDS master password is generated by Terraform using `random_password` and stored in AWS Secrets Manager. +## 6. Secret handling -No database password needs to be stored in GitHub secrets. +RDS, RabbitMQ, Redis, MinIO, and Plane app secrets are generated by Terraform and stored in AWS Secrets Manager for the modes that need workflow-generated credentials. External modes read only the explicitly configured GitHub secrets. ## Security notes - Do not store AWS access keys in GitHub secrets. - OIDC role trust policy must be repo-scoped. -- Keep Terraform state backend protected (S3 + DynamoDB lock is recommended as next step). +- Keep Terraform state backend protected (S3 + DynamoDB lock). diff --git a/.github/iam/terraform-prod-policy.json b/.github/iam/terraform-prod-policy.json index 634743a..deb19ec 100644 --- a/.github/iam/terraform-prod-policy.json +++ b/.github/iam/terraform-prod-policy.json @@ -19,9 +19,13 @@ "secretsmanager:Describe*", "secretsmanager:GetResourcePolicy", "autoscaling:Describe*", + "sts:GetCallerIdentity", "s3:Get*", "s3:List*", - "dynamodb:DescribeTable" + "dynamodb:DescribeTable", + "dynamodb:DescribeContinuousBackups", + "dynamodb:DescribeTimeToLive", + "dynamodb:ListTagsOfResource" ], "Resource": "*" }, @@ -50,6 +54,15 @@ "ec2:ReleaseAddress", "ec2:CreateNatGateway", "ec2:DeleteNatGateway", + "ec2:CreateNetworkInterface", + "ec2:DeleteNetworkInterface", + "ec2:CreateNetworkInterfacePermission", + "ec2:DeleteNetworkInterfacePermission", + "ec2:DetachNetworkInterface", + "ec2:ModifyNetworkInterfaceAttribute", + "ec2:CreateVpcEndpoint", + "ec2:DeleteVpcEndpoints", + "ec2:ModifyVpcEndpoint", "ec2:CreateSecurityGroup", "ec2:DeleteSecurityGroup", "ec2:AuthorizeSecurityGroupIngress", @@ -78,6 +91,7 @@ "eks:UntagResource", "iam:CreateRole", "iam:DeleteRole", + "iam:UpdateAssumeRolePolicy", "iam:TagRole", "iam:UntagRole", "iam:CreatePolicy", @@ -124,7 +138,8 @@ "dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem", - "dynamodb:UpdateItem" + "dynamodb:UpdateItem", + "route53:AssociateVPCWithHostedZone" ], "Resource": "*" }, @@ -137,8 +152,6 @@ "Resource": "*", "Condition": { "StringEquals": { - "aws:RequestTag/Project": "plane", - "aws:RequestTag/Environment": "prod", "aws:RequestTag/ManagedBy": "terraform" } } @@ -151,15 +164,13 @@ "mq:UpdateBroker", "mq:RebootBroker", "mq:CreateTags", - "mq:RemoveTags" + "mq:DeleteTags" ], "Resource": [ - "arn:aws:mq:eu-west-1:211125458668:broker:plane-prod-rabbitmq:*" + "arn:aws:mq:*:*:broker:*-rabbitmq:*" ], "Condition": { "StringEquals": { - "aws:ResourceTag/Project": "plane", - "aws:ResourceTag/Environment": "prod", "aws:ResourceTag/ManagedBy": "terraform" } } @@ -173,8 +184,6 @@ "Resource": "*", "Condition": { "StringEquals": { - "aws:RequestTag/Project": "plane", - "aws:RequestTag/Environment": "prod", "aws:RequestTag/ManagedBy": "terraform" } } @@ -202,13 +211,11 @@ "elasticache:RemoveTagsFromResource" ], "Resource": [ - "arn:aws:elasticache:eu-west-1:211125458668:subnetgroup:plane-prod-redis-subnet-group", - "arn:aws:elasticache:eu-west-1:211125458668: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" } } @@ -218,9 +225,9 @@ "Effect": "Allow", "Action": "iam:PassRole", "Resource": [ - "arn:aws:iam::211125458668:role/plane-prod-eks-cluster-role", - "arn:aws:iam::211125458668:role/plane-prod-eks-node-role", - "arn:aws:iam::211125458668: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": { @@ -228,6 +235,20 @@ } } }, + { + "Sid": "AllowCreateEksServiceLinkedRoles", + "Effect": "Allow", + "Action": "iam:CreateServiceLinkedRole", + "Resource": "*", + "Condition": { + "StringEquals": { + "iam:AWSServiceName": [ + "eks.amazonaws.com", + "eks-nodegroup.amazonaws.com" + ] + } + } + }, { "Sid": "AllowCreateRdsServiceLinkedRole", "Effect": "Allow", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9c49bc2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,93 @@ +name: CI + +on: + pull_request: + paths: + - 'terraform/**' + - 'helm/**' + - '.github/**' + - 'scripts/**' + - 'README.md' + - 'docs/**' + - '.gitignore' + push: + branches: + - main + paths: + - 'terraform/**' + - 'helm/**' + - '.github/**' + - 'scripts/**' + - 'README.md' + - 'docs/**' + - '.gitignore' + +permissions: + contents: read + +concurrency: + group: ci-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + static: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 1.8.5 + terraform_wrapper: false + + - name: Setup Helm + uses: azure/setup-helm@v4 + + - name: Validate IAM Policy JSON + run: python -m json.tool .github/iam/terraform-prod-policy.json > /dev/null + + - name: Terraform Format Check + run: terraform fmt -check -recursive + + - name: Terraform Init Bootstrap + run: terraform -chdir=terraform/environments/bootstrap init -backend=false -input=false + + - name: Terraform Validate Bootstrap + run: terraform -chdir=terraform/environments/bootstrap validate + + - name: Terraform Init Prod + run: terraform -chdir=terraform/environments/prod init -backend=false -input=false + + - name: Terraform Validate Prod + run: terraform -chdir=terraform/environments/prod validate + + - name: Helm Dependency Build + run: | + helm repo add makeplane https://helm.plane.so --force-update + helm dependency build helm/plane + + - name: Helm Lint + run: | + helm lint helm/plane \ + --values helm/plane/values/dev.yaml \ + --set-string plane-ce.env.pgdb_remote_url="postgresql://ci:ci@example.invalid:5432/plane" \ + --set-string plane-ce.env.remote_redis_url="rediss://:ci@example.invalid:6379/0" \ + --set-string plane-ce.rabbitmq.external_rabbitmq_url="amqps://ci:ci@example.invalid:5671/" \ + --set-string plane-ce.env.docstore_bucket="ci-docstore" \ + --set-string plane-ce.env.secret_key="ci-template-secret-key" \ + --set-string plane-ce.env.live_server_secret_key="ci-template-live-secret-key" + + - name: Helm Template + run: | + helm template plane helm/plane \ + --namespace plane \ + --values helm/plane/values/dev.yaml \ + --set-string plane-ce.env.pgdb_remote_url="postgresql://ci:ci@example.invalid:5432/plane" \ + --set-string plane-ce.env.remote_redis_url="rediss://:ci@example.invalid:6379/0" \ + --set-string plane-ce.rabbitmq.external_rabbitmq_url="amqps://ci:ci@example.invalid:5671/" \ + --set-string plane-ce.env.docstore_bucket="ci-docstore" \ + --set-string plane-ce.env.secret_key="ci-template-secret-key" \ + --set-string plane-ce.env.live_server_secret_key="ci-template-live-secret-key" \ + > /dev/null diff --git a/.github/workflows/terraform-bootstrap.yml b/.github/workflows/terraform-bootstrap.yml index 1a21133..3d53606 100644 --- a/.github/workflows/terraform-bootstrap.yml +++ b/.github/workflows/terraform-bootstrap.yml @@ -1,16 +1,6 @@ name: Terraform Bootstrap on: - pull_request: - paths: - - 'terraform/environments/bootstrap/**' - - '.github/workflows/terraform-bootstrap.yml' - push: - branches: - - main - paths: - - 'terraform/environments/bootstrap/**' - - '.github/workflows/terraform-bootstrap.yml' workflow_dispatch: inputs: action: @@ -24,15 +14,21 @@ 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_NAME: ${{ vars.TF_LOCK_TABLE_NAME || 'plane-cloud-platform-tf-locks' }} permissions: id-token: write contents: read +concurrency: + group: terraform-bootstrap-${{ github.ref }} + cancel-in-progress: false + jobs: plan: - if: github.event_name == 'pull_request' || github.event_name == 'push' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: - name: Checkout @@ -52,11 +48,48 @@ jobs: - name: Terraform Init run: terraform -chdir=${{ env.TF_DIR }} init -input=false + - name: Import Existing Bootstrap Resources + run: | + set -euo pipefail + ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) + STATE_BUCKET="${TF_STATE_BUCKET:-${{ env.TF_STATE_BUCKET_PREFIX }}-${ACCOUNT_ID}}" + tf_var_args=( + -var="aws_region=${{ env.AWS_REGION }}" + -var="state_bucket_name=${STATE_BUCKET}" + -var="lock_table_name=${{ env.TF_LOCK_TABLE_NAME }}" + ) + + import_if_available() { + local address="$1" + local id="$2" + if terraform -chdir=${{ env.TF_DIR }} state show "$address" >/dev/null 2>&1; then + return 0 + fi + terraform -chdir=${{ env.TF_DIR }} import -input=false "${tf_var_args[@]}" "$address" "$id" || true + } + + if aws s3api head-bucket --bucket "${STATE_BUCKET}" >/dev/null 2>&1; then + import_if_available aws_s3_bucket.tf_state "${STATE_BUCKET}" + import_if_available aws_s3_bucket_versioning.tf_state "${STATE_BUCKET}" + import_if_available aws_s3_bucket_server_side_encryption_configuration.tf_state "${STATE_BUCKET}" + import_if_available aws_s3_bucket_public_access_block.tf_state "${STATE_BUCKET}" + fi + + if aws dynamodb describe-table --table-name "${{ env.TF_LOCK_TABLE_NAME }}" >/dev/null 2>&1; then + import_if_available aws_dynamodb_table.tf_locks "${{ env.TF_LOCK_TABLE_NAME }}" + fi + - name: Terraform Validate run: terraform -chdir=${{ env.TF_DIR }} validate - name: Terraform Plan - run: terraform -chdir=${{ env.TF_DIR }} plan -input=false -out=tfplan + run: | + ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) + STATE_BUCKET="${TF_STATE_BUCKET:-${{ env.TF_STATE_BUCKET_PREFIX }}-${ACCOUNT_ID}}" + terraform -chdir=${{ env.TF_DIR }} plan -input=false -out=tfplan \ + -var="aws_region=${{ env.AWS_REGION }}" \ + -var="state_bucket_name=${STATE_BUCKET}" \ + -var="lock_table_name=${{ env.TF_LOCK_TABLE_NAME }}" apply: if: github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'apply' @@ -81,5 +114,42 @@ jobs: - name: Terraform Init run: terraform -chdir=${{ env.TF_DIR }} init -input=false + - name: Import Existing Bootstrap Resources + run: | + set -euo pipefail + ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) + STATE_BUCKET="${TF_STATE_BUCKET:-${{ env.TF_STATE_BUCKET_PREFIX }}-${ACCOUNT_ID}}" + tf_var_args=( + -var="aws_region=${{ env.AWS_REGION }}" + -var="state_bucket_name=${STATE_BUCKET}" + -var="lock_table_name=${{ env.TF_LOCK_TABLE_NAME }}" + ) + + import_if_available() { + local address="$1" + local id="$2" + if terraform -chdir=${{ env.TF_DIR }} state show "$address" >/dev/null 2>&1; then + return 0 + fi + terraform -chdir=${{ env.TF_DIR }} import -input=false "${tf_var_args[@]}" "$address" "$id" || true + } + + if aws s3api head-bucket --bucket "${STATE_BUCKET}" >/dev/null 2>&1; then + import_if_available aws_s3_bucket.tf_state "${STATE_BUCKET}" + import_if_available aws_s3_bucket_versioning.tf_state "${STATE_BUCKET}" + import_if_available aws_s3_bucket_server_side_encryption_configuration.tf_state "${STATE_BUCKET}" + import_if_available aws_s3_bucket_public_access_block.tf_state "${STATE_BUCKET}" + fi + + if aws dynamodb describe-table --table-name "${{ env.TF_LOCK_TABLE_NAME }}" >/dev/null 2>&1; then + import_if_available aws_dynamodb_table.tf_locks "${{ env.TF_LOCK_TABLE_NAME }}" + fi + - name: Terraform Apply - run: terraform -chdir=${{ env.TF_DIR }} apply -input=false -auto-approve + run: | + ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) + STATE_BUCKET="${TF_STATE_BUCKET:-${{ env.TF_STATE_BUCKET_PREFIX }}-${ACCOUNT_ID}}" + terraform -chdir=${{ env.TF_DIR }} apply -input=false -auto-approve \ + -var="aws_region=${{ env.AWS_REGION }}" \ + -var="state_bucket_name=${STATE_BUCKET}" \ + -var="lock_table_name=${{ env.TF_LOCK_TABLE_NAME }}" diff --git a/.github/workflows/terraform-prod.yml b/.github/workflows/terraform-prod.yml index 375d711..d6466fb 100644 --- a/.github/workflows/terraform-prod.yml +++ b/.github/workflows/terraform-prod.yml @@ -1,20 +1,6 @@ name: Terraform Prod on: - pull_request: - paths: - - 'terraform/environments/prod/**' - - 'terraform/modules/**' - - 'helm/**' - - '.github/workflows/terraform-prod.yml' - push: - branches: - - main - paths: - - 'terraform/environments/prod/**' - - 'terraform/modules/**' - - 'helm/**' - - '.github/workflows/terraform-prod.yml' workflow_dispatch: inputs: action: @@ -28,16 +14,36 @@ 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' }} + 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_NAME: ${{ vars.TF_LOCK_TABLE_NAME || '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.DEPLOY_ENVIRONMENT || 'prod' }} + TF_VAR_postgres_mode: ${{ vars.POSTGRES_MODE || 'aws-managed' }} + TF_VAR_redis_mode: ${{ vars.REDIS_MODE || 'aws-managed' }} + TF_VAR_rabbitmq_mode: ${{ vars.RABBITMQ_MODE || 'aws-managed' }} + TF_VAR_object_store_mode: ${{ vars.OBJECT_STORE_MODE || 's3-managed' }} + TF_VAR_plane_namespace: ${{ vars.K8S_NAMESPACE || 'plane' }} + TF_VAR_plane_service_account_name: ${{ format('{0}-srv-account', vars.HELM_RELEASE_NAME || 'plane') }} + TF_VAR_eks_cluster_admin_principal_arns: '["${{ secrets.AWS_GITHUB_OIDC_ROLE_ARN }}"]' + POSTGRES_MODE: ${{ vars.POSTGRES_MODE || 'aws-managed' }} + REDIS_MODE: ${{ vars.REDIS_MODE || 'aws-managed' }} + RABBITMQ_MODE: ${{ vars.RABBITMQ_MODE || 'aws-managed' }} + OBJECT_STORE_MODE: ${{ vars.OBJECT_STORE_MODE || 's3-managed' }} + EKS_CLUSTER_NAME: ${{ vars.EKS_CLUSTER_NAME || format('{0}-{1}-eks', vars.PROJECT_NAME || 'plane', vars.DEPLOY_ENVIRONMENT || 'prod') }} + HELM_RELEASE_NAME: ${{ vars.HELM_RELEASE_NAME || 'plane' }} + K8S_NAMESPACE: ${{ vars.K8S_NAMESPACE || 'plane' }} + PLANE_SERVICE_ACCOUNT_NAME: ${{ format('{0}-srv-account', vars.HELM_RELEASE_NAME || 'plane') }} HELM_CHART_DIR: helm/plane HELM_VALUES_FILE: helm/plane/values/dev.yaml - PLANE_DOCSTORE_SECRET_NAME: plane-dev-doc-store-secrets + PLANE_DOCSTORE_SECRET_NAME: ${{ vars.PLANE_DOCSTORE_SECRET_NAME || format('{0}-doc-store-secrets', vars.HELM_RELEASE_NAME || 'plane') }} + PLANE_APP_HOST: ${{ vars.PLANE_APP_HOST || '' }} concurrency: - group: terraform-prod-main + group: terraform-prod-${{ github.ref }} cancel-in-progress: false permissions: @@ -46,7 +52,7 @@ permissions: jobs: validate-plan: - if: github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'plan') + if: github.event.inputs.action == 'plan' runs-on: ubuntu-latest steps: - name: Checkout @@ -64,57 +70,82 @@ jobs: role-to-assume: ${{ secrets.AWS_GITHUB_OIDC_ROLE_ARN }} aws-region: ${{ env.AWS_REGION }} - - name: Ensure AWS Service-Linked Roles + - name: Resolve Terraform Backend + id: backend run: | set -euo pipefail - services=( - "rds.amazonaws.com:AWSServiceRoleForRDS" - "mq.amazonaws.com:AWSServiceRoleForAmazonMQ" - "elasticache.amazonaws.com:AWSServiceRoleForElastiCache" - ) - for entry in "${services[@]}"; do - IFS=':' read -r service_name role_name <<< "$entry" - if aws iam get-role --role-name "$role_name" >/dev/null 2>&1; then - echo "$role_name already exists." - else - echo "Creating $role_name for $service_name..." - aws iam create-service-linked-role --aws-service-name "$service_name" - fi - done + ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) + STATE_BUCKET="${TF_STATE_BUCKET:-${{ env.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}' is not available. 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 }}' is not available." + 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: | - ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) 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="dynamodb_table=${{ env.TF_LOCK_TABLE_NAME }}" \ + -backend-config="region=${{ env.AWS_REGION }}" + + - 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 uses: azure/setup-helm@v4 - - name: Helm Dependency Update - run: helm dependency update "${{ env.HELM_CHART_DIR }}" + - name: Helm Dependency Build + run: | + helm repo add makeplane https://helm.plane.so --force-update + helm dependency build "${{ env.HELM_CHART_DIR }}" - name: Helm Lint - run: helm lint "${{ env.HELM_CHART_DIR }}" --values "${{ env.HELM_VALUES_FILE }}" + run: | + helm lint "${{ env.HELM_CHART_DIR }}" \ + --values "${{ env.HELM_VALUES_FILE }}" \ + --set-string plane-ce.env.pgdb_remote_url="postgresql://ci:ci@example.invalid:5432/plane" \ + --set-string plane-ce.env.remote_redis_url="rediss://:ci@example.invalid:6379/0" \ + --set-string plane-ce.rabbitmq.external_rabbitmq_url="amqps://ci:ci@example.invalid:5671/" \ + --set-string plane-ce.env.docstore_bucket="ci-docstore" \ + --set-string plane-ce.env.secret_key="ci-template-secret-key" \ + --set-string plane-ce.env.live_server_secret_key="ci-template-live-secret-key" - name: Helm Template Validate - 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 - uses: actions/upload-artifact@v4 - with: - name: tfplan - path: ${{ env.TF_DIR }}/tfplan + run: | + helm template "${{ env.HELM_RELEASE_NAME }}" "${{ env.HELM_CHART_DIR }}" \ + --namespace "${{ env.K8S_NAMESPACE }}" \ + --values "${{ env.HELM_VALUES_FILE }}" \ + --set-string plane-ce.env.pgdb_remote_url="postgresql://ci:ci@example.invalid:5432/plane" \ + --set-string plane-ce.env.remote_redis_url="rediss://:ci@example.invalid:6379/0" \ + --set-string plane-ce.rabbitmq.external_rabbitmq_url="amqps://ci:ci@example.invalid:5671/" \ + --set-string plane-ce.env.docstore_bucket="ci-docstore" \ + --set-string plane-ce.env.secret_key="ci-template-secret-key" \ + --set-string plane-ce.env.live_server_secret_key="ci-template-live-secret-key" \ + > /dev/null apply: - if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'apply') + if: github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'apply' runs-on: ubuntu-latest environment: prod steps: @@ -137,10 +168,18 @@ jobs: run: | set -euo pipefail services=( - "rds.amazonaws.com:AWSServiceRoleForRDS" - "mq.amazonaws.com:AWSServiceRoleForAmazonMQ" - "elasticache.amazonaws.com:AWSServiceRoleForElastiCache" + "eks.amazonaws.com:AWSServiceRoleForAmazonEKS" + "eks-nodegroup.amazonaws.com:AWSServiceRoleForAmazonEKSNodegroup" ) + if [ "${POSTGRES_MODE}" = "aws-managed" ]; then + services+=("rds.amazonaws.com:AWSServiceRoleForRDS") + fi + if [ "${RABBITMQ_MODE}" = "aws-managed" ]; then + services+=("mq.amazonaws.com:AWSServiceRoleForAmazonMQ") + fi + if [ "${REDIS_MODE}" = "aws-managed" ]; then + services+=("elasticache.amazonaws.com:AWSServiceRoleForElastiCache") + fi for entry in "${services[@]}"; do IFS=':' read -r service_name role_name <<< "$entry" if aws iam get-role --role-name "$role_name" >/dev/null 2>&1; then @@ -154,9 +193,12 @@ jobs: - name: Terraform Init run: | ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) + STATE_BUCKET="${TF_STATE_BUCKET:-${{ env.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="dynamodb_table=${{ env.TF_LOCK_TABLE_NAME }}" \ + -backend-config="region=${{ env.AWS_REGION }}" - name: Terraform Apply run: terraform -chdir=${{ env.TF_DIR }} apply -input=false -auto-approve @@ -167,8 +209,10 @@ jobs: - name: Setup Helm uses: azure/setup-helm@v4 - - name: Helm Dependency Update - run: helm dependency update "${{ env.HELM_CHART_DIR }}" + - name: Helm Dependency Build + run: | + helm repo add makeplane https://helm.plane.so --force-update + helm dependency build "${{ env.HELM_CHART_DIR }}" - name: Ensure namespace exists run: kubectl get namespace "${{ env.K8S_NAMESPACE }}" || kubectl create namespace "${{ env.K8S_NAMESPACE }}" @@ -203,13 +247,14 @@ jobs: VPC_ID="$(terraform -chdir=${{ env.TF_DIR }} output -raw vpc_id)" NODE_GROUP_NAME="$(terraform -chdir=${{ env.TF_DIR }} output -raw eks_node_group_name)" - DOCSTORE_BUCKET="$(terraform -chdir=${{ env.TF_DIR }} output -raw plane_docstore_bucket_name)" - PLANE_IRSA_ROLE_ARN="$(terraform -chdir=${{ env.TF_DIR }} output -raw plane_irsa_role_arn)" - RDS_SECRET_ARN="$(terraform -chdir=${{ env.TF_DIR }} output -raw rds_credentials_secret_arn)" - RABBITMQ_SECRET_ARN="$(terraform -chdir=${{ env.TF_DIR }} output -raw rabbitmq_credentials_secret_arn)" - REDIS_SECRET_ARN="$(terraform -chdir=${{ env.TF_DIR }} output -raw redis_credentials_secret_arn)" + PLANE_APP_SECRET_ARN="$(terraform -chdir=${{ env.TF_DIR }} output -raw plane_app_secret_arn)" AWS_LBC_IRSA_ROLE_ARN="$(terraform -chdir=${{ env.TF_DIR }} output -raw eks_aws_load_balancer_controller_irsa_role_arn)" CLUSTER_AUTOSCALER_IRSA_ROLE_ARN="$(output_raw_or_empty eks_cluster_autoscaler_irsa_role_arn)" + DOCSTORE_BUCKET="" + PLANE_IRSA_ROLE_ARN="" + RDS_SECRET_ARN="" + RABBITMQ_SECRET_ARN="" + REDIS_SECRET_ARN="" if [ -z "$CLUSTER_AUTOSCALER_IRSA_ROLE_ARN" ]; then ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)" @@ -219,14 +264,29 @@ jobs: require_nonempty "vpc_id" "$VPC_ID" require_nonempty "eks_node_group_name" "$NODE_GROUP_NAME" - require_nonempty "plane_docstore_bucket_name" "$DOCSTORE_BUCKET" - require_nonempty "rds_credentials_secret_arn" "$RDS_SECRET_ARN" - require_nonempty "rabbitmq_credentials_secret_arn" "$RABBITMQ_SECRET_ARN" - require_nonempty "redis_credentials_secret_arn" "$REDIS_SECRET_ARN" - require_iam_role_arn "plane_irsa_role_arn" "$PLANE_IRSA_ROLE_ARN" + require_nonempty "plane_app_secret_arn" "$PLANE_APP_SECRET_ARN" require_iam_role_arn "eks_aws_load_balancer_controller_irsa_role_arn" "$AWS_LBC_IRSA_ROLE_ARN" require_iam_role_arn "eks_cluster_autoscaler_irsa_role_arn" "$CLUSTER_AUTOSCALER_IRSA_ROLE_ARN" + if [ "${POSTGRES_MODE}" = "aws-managed" ]; then + RDS_SECRET_ARN="$(terraform -chdir=${{ env.TF_DIR }} output -raw rds_credentials_secret_arn)" + require_nonempty "rds_credentials_secret_arn" "$RDS_SECRET_ARN" + fi + if [ "${RABBITMQ_MODE}" = "aws-managed" ]; then + RABBITMQ_SECRET_ARN="$(terraform -chdir=${{ env.TF_DIR }} output -raw rabbitmq_credentials_secret_arn)" + require_nonempty "rabbitmq_credentials_secret_arn" "$RABBITMQ_SECRET_ARN" + fi + if [ "${REDIS_MODE}" = "aws-managed" ]; then + REDIS_SECRET_ARN="$(terraform -chdir=${{ env.TF_DIR }} output -raw redis_credentials_secret_arn)" + require_nonempty "redis_credentials_secret_arn" "$REDIS_SECRET_ARN" + fi + if [ "${OBJECT_STORE_MODE}" = "s3-managed" ]; then + DOCSTORE_BUCKET="$(terraform -chdir=${{ env.TF_DIR }} output -raw plane_docstore_bucket_name)" + PLANE_IRSA_ROLE_ARN="$(terraform -chdir=${{ env.TF_DIR }} output -raw plane_irsa_role_arn)" + require_nonempty "plane_docstore_bucket_name" "$DOCSTORE_BUCKET" + require_iam_role_arn "plane_irsa_role_arn" "$PLANE_IRSA_ROLE_ARN" + fi + echo "vpc_id=$VPC_ID" >> "$GITHUB_OUTPUT" echo "eks_node_group_name=$NODE_GROUP_NAME" >> "$GITHUB_OUTPUT" echo "plane_docstore_bucket_name=$DOCSTORE_BUCKET" >> "$GITHUB_OUTPUT" @@ -234,6 +294,7 @@ jobs: echo "rds_credentials_secret_arn=$RDS_SECRET_ARN" >> "$GITHUB_OUTPUT" echo "rabbitmq_credentials_secret_arn=$RABBITMQ_SECRET_ARN" >> "$GITHUB_OUTPUT" echo "redis_credentials_secret_arn=$REDIS_SECRET_ARN" >> "$GITHUB_OUTPUT" + echo "plane_app_secret_arn=$PLANE_APP_SECRET_ARN" >> "$GITHUB_OUTPUT" echo "aws_load_balancer_controller_irsa_role_arn=$AWS_LBC_IRSA_ROLE_ARN" >> "$GITHUB_OUTPUT" echo "cluster_autoscaler_irsa_role_arn=$CLUSTER_AUTOSCALER_IRSA_ROLE_ARN" >> "$GITHUB_OUTPUT" @@ -373,102 +434,279 @@ jobs: --overwrite fi - - name: Build Runtime Helm Values (RDS + Amazon MQ + ElastiCache) + - name: Ensure Plane Service Account (IRSA) + if: env.OBJECT_STORE_MODE == 's3-managed' + run: | + cat < /tmp/plane-runtime-values.yaml <> "$GITHUB_OUTPUT" + plane = values["plane-ce"] + env = plane["env"] + + if plane["postgres"]["local_setup"]: + env["pgdb_username"] = "plane" + env["pgdb_password"] = os.environ["LOCAL_POSTGRES_PASSWORD"] + env["pgdb_name"] = "plane" + elif os.environ["PGDB_REMOTE_URL"]: + env["pgdb_remote_url"] = os.environ["PGDB_REMOTE_URL"] + + if not plane["redis"]["local_setup"] and os.environ["REDIS_URL"]: + env["remote_redis_url"] = os.environ["REDIS_URL"] + + if plane["rabbitmq"]["local_setup"]: + plane["rabbitmq"]["default_user"] = "plane" + plane["rabbitmq"]["default_password"] = os.environ["LOCAL_RABBITMQ_PASSWORD"] + elif os.environ["AMQP_URL"]: + plane["rabbitmq"]["external_rabbitmq_url"] = os.environ["AMQP_URL"] + + if plane["minio"]["local_setup"]: + plane["minio"]["root_user"] = "admin" + plane["minio"]["root_password"] = os.environ["LOCAL_MINIO_ROOT_PASSWORD"] + env["docstore_bucket"] = os.environ["DOCSTORE_BUCKET"] + else: + env["docstore_bucket"] = os.environ["DOCSTORE_BUCKET"] + env["aws_region"] = os.environ["AWS_REGION_VALUE"] + env["aws_access_key"] = os.environ["AWS_ACCESS_KEY_ID_VALUE"] + env["aws_secret_access_key"] = os.environ["AWS_SECRET_ACCESS_KEY_VALUE"] + env["aws_s3_endpoint_url"] = os.environ["AWS_S3_ENDPOINT_URL_VALUE"] + + with open("/tmp/plane-runtime-values.json", "w", encoding="utf-8") as f: + json.dump(values, f) + PY + + echo "runtime_values_file=/tmp/plane-runtime-values.json" >> "$GITHUB_OUTPUT" - name: Helm Upgrade/Install run: | + set -euo pipefail + + helm_set_args=( + --set-string "plane-ce.external_secrets.doc_store_existingSecret=${{ env.PLANE_DOCSTORE_SECRET_NAME }}" + ) + + if [ -n "${{ env.PLANE_APP_HOST }}" ]; then + helm_set_args+=(--set-string "plane-ce.ingress.appHost=${{ env.PLANE_APP_HOST }}") + fi + 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.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="${{ secrets.PLANE_S3_ENDPOINT_URL }}" \ + "${helm_set_args[@]}" \ --rollback-on-failure \ --wait \ --wait-for-jobs \ --timeout 20m - name: Annotate Plane Service Account (IRSA) + if: env.OBJECT_STORE_MODE == 's3-managed' 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 - name: Restart Plane Workloads After IRSA Annotation + if: env.OBJECT_STORE_MODE == 's3-managed' run: | for deploy in "${{ env.HELM_RELEASE_NAME }}-api-wl" "${{ env.HELM_RELEASE_NAME }}-live-wl" "${{ env.HELM_RELEASE_NAME }}-worker-wl" "${{ env.HELM_RELEASE_NAME }}-beat-worker-wl" "${{ env.HELM_RELEASE_NAME }}-web-wl" "${{ env.HELM_RELEASE_NAME }}-admin-wl" "${{ env.HELM_RELEASE_NAME }}-space-wl"; do kubectl rollout restart "deployment/${deploy}" -n "${{ env.K8S_NAMESPACE }}" || true diff --git a/.gitignore b/.gitignore index 08a1e27..3e93e1f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ crash.log # local runtime overrides with sensitive connection URLs helm/plane/values/dev.runtime.local.yaml +# local/private artifacts +*.pdf + diff --git a/README.md b/README.md index d141dc6..5f35c85 100644 --- a/README.md +++ b/README.md @@ -1,457 +1,123 @@ -# Plane Cloud Deployment on AWS +# Plane AWS Deployment Kit -This repository contains the infrastructure and deployment configuration for running Plane on AWS. +This repository is a fork-and-deploy kit for running Plane Community Edition on AWS with GitHub Actions, Terraform, and Helm. -The goal is to manage cloud infrastructure and application delivery from a single repository, while keeping concerns separated logically: +The default path is intentionally simple: -- `terraform/`: infrastructure provisioning (AWS network and, later, platform resources) -- `helm/`: application deployment configuration (Plane Helm wrapper chart and environment values) +1. Fork the repository. +2. Create one AWS IAM role trusted by GitHub OIDC. +3. Add that role ARN as `AWS_GITHUB_OIDC_ROLE_ARN`. +4. Run `Terraform Bootstrap` once. +5. Run `Terraform Prod` to create infrastructure and install Plane. -## Why this repository exists +No long-lived AWS access keys are required for the default AWS-managed deployment. -We are building a production-oriented Plane setup for a small team (up to ~20 users) on AWS. +## Default Architecture -The initial focus is a clean and extensible network foundation for EKS-based deployment, with application-level services and sizing decisions to be finalized incrementally. +Terraform creates: -## Repository layout +- VPC, public/private subnets, NAT gateways, route tables, and Kubernetes subnet tags +- EKS cluster, managed node group, EBS CSI addon, and IRSA roles +- Optional AWS-managed Plane dependencies: RDS PostgreSQL, Amazon MQ RabbitMQ, ElastiCache Redis, and S3 docstore +- Secrets Manager entries for generated service credentials and Plane app secrets +- Terraform state backend resources through the bootstrap workflow -- `terraform/environments/prod`: production environment Terraform root module -- `terraform/environments/bootstrap`: one-time backend bootstrap (S3 state bucket + DynamoDB lock table) -- `terraform/modules/vpc`: reusable VPC module -- `helm/plane`: Plane wrapper chart -- `helm/plane/values/dev.yaml`: current starting values for development overrides - -## Current AWS network design (implemented) - -Region: `eu-west-1` - -VPC: - -- CIDR: `172.26.0.0/16` -- DNS support: enabled -- DNS hostnames: enabled - -Subnets across 3 Availability Zones: - -- Public: - - `172.26.0.0/22` - - `172.26.4.0/22` - - `172.26.8.0/22` -- Private: - - `172.26.12.0/22` - - `172.26.16.0/22` - - `172.26.20.0/22` - -Routing and egress: - -- 1 Internet Gateway -- 1 public route table (associated with all public subnets) -- 3 private route tables (one per private subnet/AZ) -- 3 NAT Gateways (one per AZ) - -### Why 3 NAT Gateways? - -We intentionally use one NAT Gateway per AZ to avoid cross-AZ egress dependencies and improve availability. - -If a single AZ has issues, workloads in other AZs keep outbound internet access via their local NAT Gateway. - -## Deployment approach - -Terraform manages the AWS infrastructure baseline. - -Helm manages Plane deployment configuration and environment-specific overrides. - -This structure allows us to evolve infrastructure and app delivery independently while still operating from one repository. - -## Plane Helm Bootstrap - -To install Plane charts locally before deployment: +Helm installs: -```powershell -helm repo add makeplane https://helm.plane.so -helm repo update -``` - -```bash -helm repo add makeplane https://helm.plane.so -helm repo update -``` - -The wrapper chart is located at `helm/plane` and environment overrides start with `helm/plane/values/dev.yaml`. - -## Plane Storage Notes - -- EKS now uses managed `aws-ebs-csi-driver` addon for dynamic PVC provisioning. -- 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. - -## GitHub CI/CD (OIDC) - -GitHub Actions is configured to run Terraform using AWS OIDC federation instead of long-lived access keys. +- Plane CE through the wrapper chart in `helm/plane` +- A `gp3-csi` StorageClass +- AWS Load Balancer Controller-backed ALB ingress +- metrics-server and Cluster Autoscaler -- Workflow file: `.github/workflows/terraform-prod.yml` -- Bootstrap workflow: `.github/workflows/terraform-bootstrap.yml` -- OIDC setup guide: `.github/README-OIDC.md` +## Workflows -Behavior: +- `CI`: runs on pull requests and pushes to `main`; it does not request AWS credentials or GitHub secrets. +- `Terraform Bootstrap`: manual only; creates/imports the S3 state bucket and DynamoDB lock table. +- `Terraform Prod`: manual only; runs `plan` or `apply` for AWS infrastructure and the Plane Helm release. -- Bootstrap workflow manages remote-state prerequisites (S3 + DynamoDB) -- Pull requests run `terraform init`, `validate`, and `plan` -- Manual dispatch supports `plan` and `apply` -- `apply` runs in GitHub Environment `prod` for approval-gated changes +This split keeps public fork PRs reviewable without exposing repository secrets. -Required GitHub secrets: +## Required Secret -- `AWS_GITHUB_OIDC_ROLE_ARN` -- `PLANE_S3_ENDPOINT_URL` (optional for AWS S3; can be empty) +Create this GitHub repository secret after creating the AWS OIDC role: -## Unified Terraform + Helm Workflow +- `AWS_GITHUB_OIDC_ROLE_ARN`: IAM role ARN assumed by GitHub Actions -Deployment is handled in a single pipeline: +## Common Variables -- 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` +The defaults work for the standard AWS-managed path. -## Manual Helm Upgrade Runbook (Local) +- `AWS_REGION`: default `eu-west-1` +- `PROJECT_NAME`: default `plane` +- `DEPLOY_ENVIRONMENT`: default `prod` +- `TF_STATE_BUCKET`: optional explicit Terraform state bucket +- `TF_STATE_BUCKET_PREFIX`: default `plane-cloud-platform-tfstate` +- `TF_STATE_KEY`: default `prod/terraform.tfstate` +- `TF_LOCK_TABLE_NAME`: default `plane-cloud-platform-tf-locks` +- `HELM_RELEASE_NAME`: default `plane` +- `K8S_NAMESPACE`: default `plane` +- `PLANE_APP_HOST`: optional custom host; when empty, the ALB DNS name is usable after deploy -Important: `helm/plane/values/dev.yaml` is not sufficient by itself for managed services. -CI injects runtime values for: +## Service Modes -- `plane-ce.env.pgdb_remote_url` -- `plane-ce.env.remote_redis_url` -- `plane-ce.rabbitmq.external_rabbitmq_url` +Each Plane dependency can be AWS-managed, in-cluster, or external. -For manual/local helm upgrades, add a second values file with these runtime URLs. +- `POSTGRES_MODE`: `aws-managed`, `in-cluster`, or `external` +- `REDIS_MODE`: `aws-managed`, `in-cluster`, or `external` +- `RABBITMQ_MODE`: `aws-managed`, `in-cluster`, or `external` +- `OBJECT_STORE_MODE`: `s3-managed`, `minio-in-cluster`, or `external-s3` -1. Create a local override from the example: +See [docs/service-modes.md](docs/service-modes.md) for the required variables and secrets for each mode. -```bash -cp helm/plane/values/dev.runtime.example.yaml helm/plane/values/dev.runtime.local.yaml -``` - -2. Fill `helm/plane/values/dev.runtime.local.yaml` with real DB/Redis/RabbitMQ connection URLs. +## Zero-To-Deploy -3. Run helm upgrade with both files: +From a machine with AWS CLI admin access to your AWS account: ```bash -helm upgrade --install plane-dev helm/plane \ - --namespace plane-dev \ - --create-namespace \ - --values helm/plane/values/dev.yaml \ - --values helm/plane/values/dev.runtime.local.yaml \ - --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="" +chmod +x scripts/bootstrap-oidc.sh +./scripts/bootstrap-oidc.sh --repo / --branch main --profile ``` -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-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` - -`aws-load-balancer-controller` is installed by the production workflow and ingress class is set to `alb` in `helm/plane/values/dev.yaml`. - -## Plane CE CSV Import (API Script) - -Plane Community Edition does not provide the built-in CSV importer UI. -Use the script below to import CSV rows via Plane API: +Add the printed role ARN as `AWS_GITHUB_OIDC_ROLE_ARN`. -- Script: `scripts/import-plane-csv.py` -- Runtime: Python 3.x (no external pip dependency) +Then run these GitHub Actions manually: -1. Export required environment variables +1. `Terraform Bootstrap` with `action=apply` +2. `Terraform Prod` with `action=plan` +3. `Terraform Prod` with `action=apply` -```bash -export PLANE_BASE_URL="http://" -export PLANE_WORKSPACE_SLUG="" -export PLANE_API_KEY="" -``` - -2. Run a dry-run first +After deploy: ```bash -python3 scripts/import-plane-csv.py \ - --csv ./issues.csv \ - --project-identifier \ - --dry-run +aws eks update-kubeconfig --name plane-prod-eks --region eu-west-1 +kubectl get ingress plane-ingress -n plane ``` -3. Import for real - -```bash -python3 scripts/import-plane-csv.py \ - --csv ./issues.csv \ - --project-identifier \ - --create-missing-labels \ - --skip-errors -``` - -Supported CSV columns: - -- Required: `name` (or `title`) -- Optional: `description`, `description_html`, `priority`, `state`, `state_id` -- Optional: `labels`, `label_ids`, `assignees`, `assignee_ids` -- Optional: `estimate_point`, `start_date`, `target_date`, `type`, `module`, `parent` - -Notes: - -- Multi-value fields (`labels`, `assignees`, etc.) can use `;`, `,`, or `|` separators. -- `assignees` resolves by member email/display name. -- `state` and `labels` resolve by name. - If you use `--create-missing-labels`, missing labels are created automatically. - -## Bootstrap First (Best Practice) - -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` -3. Then run normal prod workflow `.github/workflows/terraform-prod.yml` - -## Workflow Runbook - -Use GitHub Actions workflow `Terraform Prod` for controlled infrastructure changes. - -1. Open a pull request for Terraform changes. -2. PR automatically runs `init`, `validate`, and `plan`. -3. Merge to `main` after review. -4. Push to `main` automatically triggers `apply` (approval-gated by `prod` environment if enabled). - -Recommended: - -- Keep direct local `terraform apply` disabled in team process. -- Use pull requests for all Terraform edits so plan runs are visible before apply. +Open the ALB hostname unless you configured `PLANE_APP_HOST`. -## Terraform State Backend +## Security Notes -Production Terraform uses an S3 remote backend with DynamoDB locking. +- GitHub uses OIDC to assume a short-lived AWS role. +- Database, RabbitMQ, Redis, MinIO, and Plane app secrets are generated by Terraform and stored in AWS Secrets Manager for workflow injection. +- Terraform state can contain generated secrets; keep the S3 backend private, encrypted, and access controlled. +- The production apply job uses the GitHub `prod` environment so repository owners can require manual approval. +- Do not add AWS access keys to GitHub for the default deployment path. -- Backend bucket pattern: `plane-cloud-platform-tfstate-` -- Lock table: `plane-cloud-platform-tf-locks` +More detail: -Bootstrap resources are managed by `terraform/environments/bootstrap` and should be applied once per AWS account (via bootstrap workflow). +- [docs/proposal-to-plane.md](docs/proposal-to-plane.md) +- [docs/iam.md](docs/iam.md) +- [docs/runbook.md](docs/runbook.md) +- [.github/README-OIDC.md](.github/README-OIDC.md) -For portability across fresh AWS accounts, the CI workflow also ensures -`AWSServiceRoleForRDS` exists before Terraform runs. - -## IAM Policy Sync Guide (No Manual Copy/Paste) - -Instead of pasting policy JSON in the AWS Console, sync the repo policy file directly with AWS CLI. - -Prerequisites: - -- AWS CLI installed -- An admin-capable profile (example below uses `personal`) -- Existing role name: `github-actions-terraform-prod` - -Command: - -```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 -``` - -Verify: - -```powershell -aws iam get-role-policy ` - --role-name github-actions-terraform-prod ` - --policy-name terraform-prod-policy ` - --profile personal -``` - -```bash -aws iam get-role-policy \ - --role-name github-actions-terraform-prod \ - --policy-name terraform-prod-policy \ - --profile personal -``` - -When to run this: - -- After any change to `.github/iam/terraform-prod-policy.json` -- Before re-running failing workflows caused by missing IAM permissions - -## Future roadmap - -Planned additions include: - -- Argo CD for GitOps-based continuous delivery as a later-stage platform improvement, with separate applications for platform add-ons and Plane -- External Secrets Operator (ESO) to sync AWS Secrets Manager values into Kubernetes instead of generating runtime values in CI -- A staging environment and promotion flow (`dev` -> `staging` -> `prod`) -- Renovate for Terraform provider, Helm chart, and GitHub Actions update automation -- Near-term preference: use Renovate first for Plane version detection and update PRs before introducing GitOps complexity -- Reproducible Helm dependency management with pinned chart versions and lock-file based installs -- Automated, low-downtime Plane rollouts as Helm chart updates are published and promoted through controlled environments - -## Notes - -- Use the correct AWS profile/account before running Terraform. -- Run `terraform plan` before `terraform apply` for safer changes. - -## 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` (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. - -## Next Steps - -To move this repository closer to production-grade best practices, implement the following in order: - -1. IAM least-privilege refinement - - Reduce broad permissions in `.github/iam/terraform-prod-policy.json`. - - Scope S3, IAM, EKS, and RDS actions to exact required resources and operations. - - Constrain `iam:PassRole` with explicit role ARNs and `iam:PassedToService`. - -2. Secret management hardening - - Keep runtime secrets in AWS Secrets Manager and remove CI-generated temporary runtime values files. - - Add External Secrets Operator (ESO) so Kubernetes secrets are synced automatically. - - Add secret rotation policy and runbook. - -3. Managed data services migration - - PostgreSQL: migrated to Amazon RDS PostgreSQL with CI-injected `pgdb_remote_url`. - - RabbitMQ: migrated to Amazon MQ for RabbitMQ with CI-injected `external_rabbitmq_url`. - - Redis: migrated to Amazon ElastiCache with CI-injected `remote_redis_url`. - - Note: Amazon SQS is an option only with app-level queue integration changes; it is not a direct RabbitMQ drop-in. - -4. DNS and TLS hardening - - Attach a real domain (Route 53 or external registrar). - - Use ACM certificates and enforce HTTPS redirects on ALB ingress. - - Add ExternalDNS and tighten network/security group rules. - -5. Observability, SLOs, and alerting - - Add metrics/logging dashboards and alerts (CloudWatch and/or Prometheus/Grafana stack). - - Track pod health, node pressure, API error rates, queue lag, and database availability. - -6. Release safety controls - - Enforce main branch protection with required checks and PR review. - - Add post-deploy smoke tests in CI/CD. - -7. Disaster recovery readiness - - Document backup and restore runbooks for RDS and S3. - - Run periodic recovery drills and validate RTO/RPO targets. - -8. CI/CD pipeline optimization - - Run Terraform and Helm conditionally using path filters (`terraform/**` vs `helm/**`) so unrelated changes do not trigger both stacks. - - Split the current monolithic apply into focused jobs/workflows (`infra-apply`, `addons-deploy`, `app-deploy`) and execute only what changed. - - Move cluster add-on upgrades (metrics-server, aws-load-balancer-controller, cluster-autoscaler) to a separate workflow or run them only when add-on inputs change. - - Skip Terraform apply when plan has no changes. - - Use `helm diff upgrade` and skip Helm upgrade when rendered manifests are unchanged. - - Replace `helm dependency update` in CI with lock-file based dependency resolution (`Chart.lock` + `helm dependency build`) for faster and reproducible runs. - - Add caching for Terraform providers and Helm cache directories. - - Restart Plane workloads only when the service account annotation actually changes, instead of restarting on every deployment. - - Improve workflow concurrency (for example `cancel-in-progress: true` on PR workflows) to avoid wasted runner time. - -9. Network cost optimization with VPC endpoints - - Evaluate whether NAT gateways can be removed entirely for private workloads. - - Add the required VPC endpoints for AWS services used from private subnets, such as S3, ECR (`api` and `dkr`), STS, CloudWatch Logs, and Secrets Manager. - - Keep private node and pod traffic on AWS backbone where possible, reduce NAT data processing charges, and document which endpoints are mandatory before disabling NAT. - -10. GitOps adoption with Argo CD - - Bootstrap Argo CD after cluster creation and keep its base installation declarative. - - Model separate Argo CD applications for cluster add-ons and the Plane application. - - Let GitHub Actions focus on validation, plan output, and policy checks while Argo CD owns in-cluster reconciliation. - - Treat this as a later optimization; for simple upstream Plane version awareness and upgrade PRs, Renovate is sufficient in the near term. - -11. Environment strategy and promotion flow - - Add a staging environment that mirrors production structure with lower-cost sizing defaults. - - Promote chart and configuration changes through `dev` -> `staging` -> `prod` instead of deploying directly to production first. - - Separate environment-specific Terraform roots, Helm values, and runtime secret mappings more explicitly. - -12. Deployment reproducibility and upgrade safety - - Pin wrapper chart dependency versions instead of relying on `version: "*"` in `helm/plane/Chart.yaml`. - - Use `helm dependency build` from `Chart.lock` in CI for deterministic installs. - - Add `helm diff` and optional progressive rollout strategies for safer upgrades. - -13. Policy and security automation - - Add Terraform/Helm security checks such as `tfsec`, `checkov`, `trivy`, and secret scanning in CI. - - Enforce policy-as-code rules for public endpoints, encryption, tags, backup retention, and deletion protection. - - Consider cluster policy enforcement with Kyverno or Gatekeeper as the platform footprint grows. - -14. Workload resilience and scaling maturity - - Add PodDisruptionBudgets, topology spread constraints, and rollout safety settings for critical Plane workloads. - - Evaluate Karpenter or mixed on-demand/spot worker strategies once workload behavior is better understood. - - Tune HPA thresholds and resource requests from observed production metrics instead of static bootstrap defaults. - -15. Cost visibility and governance - - Add AWS Budgets and Cost Anomaly Detection for the account or project tags. - - Review retention and lifecycle settings for backups, logs, and object storage to control long-term cost. - - Document expected monthly cost envelopes per environment and revisit expensive defaults regularly. +## Repository Layout +- `.github/workflows/ci.yml`: secret-free validation for PRs and pushes +- `.github/workflows/terraform-bootstrap.yml`: manual state backend workflow +- `.github/workflows/terraform-prod.yml`: manual plan/apply workflow +- `.github/iam/terraform-prod-policy.json`: IAM policy for the GitHub OIDC role +- `scripts/bootstrap-oidc.sh`: helper for creating/updating the OIDC role +- `terraform/environments/bootstrap`: S3 + DynamoDB backend bootstrap +- `terraform/environments/prod`: production Terraform root +- `terraform/modules`: reusable AWS modules +- `helm/plane`: Plane wrapper chart diff --git a/docs/iam.md b/docs/iam.md new file mode 100644 index 0000000..f1d4d5e --- /dev/null +++ b/docs/iam.md @@ -0,0 +1,38 @@ +# IAM Model + +GitHub Actions uses OpenID Connect to assume one short-lived AWS IAM role. The role is created by `scripts/bootstrap-oidc.sh` and receives `.github/iam/terraform-prod-policy.json` as an inline policy. + +## Why The Policy Is Broad + +The first deploy creates infrastructure across several AWS control planes: + +- EC2 networking for VPCs, subnets, NAT gateways, security groups, route tables, and service-managed network interfaces +- EKS cluster, managed node group, access entries, and add-ons +- IAM roles, policies, OIDC providers, and service-linked roles +- RDS, Amazon MQ, ElastiCache, S3, DynamoDB, and Secrets Manager +- AWS Load Balancer Controller and Cluster Autoscaler IAM roles + +Several AWS APIs require `Resource: "*"` for create, describe, tagging, service-linked-role, or dependent actions. The policy narrows the highest-risk operations where AWS supports it, including role name patterns, service-linked-role service names, Terraform tags, and `iam:PassedToService`. + +## Trust Policy Scope + +The OIDC trust policy should be scoped to one repository and branch: + +- `repo:/:ref:refs/heads/main` + +The helper can also allow `pull_request`, but official upstream deployments should avoid AWS-authenticated work on public fork pull requests. This repository's `CI` workflow is secret-free; deployment workflows are manual. + +## State And Secrets + +Terraform-generated service credentials are stored in AWS Secrets Manager, then injected into Helm at deploy time. Terraform state may still contain sensitive generated values. + +Keep the state backend: + +- Private +- Encrypted +- Versioned +- Restricted to trusted administrators and the GitHub OIDC deploy role + +## Operational Boundary + +This kit is designed to create Plane infrastructure in one AWS account and region. It should not be attached to an unrestricted organization-wide admin role. If an organization adopts it, create a dedicated deployment role and review the inline policy against the services and modes it plans to support. diff --git a/docs/proposal-to-plane.md b/docs/proposal-to-plane.md new file mode 100644 index 0000000..1ace2a6 --- /dev/null +++ b/docs/proposal-to-plane.md @@ -0,0 +1,117 @@ +# Proposal: Plane AWS Deployment Kit + +## Summary + +We propose contributing this repository as a fork-and-deploy AWS deployment kit for Plane Community Edition. + +The goal is to let one operator deploy Plane into their own AWS account using GitHub Actions, Terraform, and Helm with minimal manual work: + +1. Fork the repository. +2. Create a GitHub OIDC IAM role in AWS. +3. Add one required GitHub secret: `AWS_GITHUB_OIDC_ROLE_ARN`. +4. Run the bootstrap workflow. +5. Run the production workflow. + +The default path provisions AWS-managed infrastructure, while advanced users can switch individual dependencies to in-cluster or external services. + +## Why This Helps Plane + +Plane already has strong self-hosting interest. A maintained AWS deployment kit would reduce friction for teams that want a cloud-native installation but do not want to assemble EKS, RDS, Redis, RabbitMQ, S3, IRSA, ingress, and CI/CD wiring from scratch. + +This kit is designed to be: + +- Fork friendly: users can run it from their own GitHub repository and AWS account. +- Secret conscious: the default path uses GitHub OIDC, not long-lived AWS access keys. +- Reviewable: pull request CI is secret-free and deployment workflows are manual. +- Configurable: PostgreSQL, Redis, RabbitMQ, and object storage can be AWS-managed, in-cluster, or external. +- Operationally explicit: IAM, service modes, and runbook behavior are documented. + +## What The Kit Deploys + +By default, Terraform creates: + +- VPC across multiple Availability Zones +- EKS cluster and managed node group +- EBS CSI addon and controller IRSA roles +- RDS PostgreSQL +- Amazon MQ for RabbitMQ +- ElastiCache Redis +- S3 document store bucket +- Secrets Manager entries for generated service credentials and Plane application secrets +- Terraform S3 backend and DynamoDB lock table through a separate bootstrap workflow + +The production workflow then installs: + +- metrics-server +- AWS Load Balancer Controller +- Cluster Autoscaler +- Plane CE through the wrapper Helm chart +- ALB ingress, with a hostless first-deploy option + +## Configuration Model + +The default deployment uses managed AWS services. Operators can override service modes with repository variables: + +- `POSTGRES_MODE=aws-managed|in-cluster|external` +- `REDIS_MODE=aws-managed|in-cluster|external` +- `RABBITMQ_MODE=aws-managed|in-cluster|external` +- `OBJECT_STORE_MODE=s3-managed|minio-in-cluster|external-s3` + +External modes read explicitly configured GitHub secrets for existing service URLs or S3-compatible credentials. In-cluster modes use generated credentials stored in AWS Secrets Manager and injected at deploy time. + +## Security And Workflow Design + +The repository separates validation from deployment: + +- `CI` runs on pull requests and pushes without AWS credentials. +- `Terraform Bootstrap` is manual and creates/imports the remote state backend. +- `Terraform Prod` is manual and runs `plan` or `apply`. + +The default OIDC trust policy is branch-scoped and does not trust public pull request subjects. Production apply uses a GitHub Environment named `prod`, so maintainers can require reviewer approval. + +Terraform state may contain generated secrets, so the backend is private, encrypted, versioned, and intended to be accessed only by trusted administrators and the GitHub deployment role. + +## Current Validation + +The repository currently passes local static validation for: + +- Terraform formatting +- Terraform validation for bootstrap and production roots +- Workflow YAML parsing +- IAM policy JSON parsing +- Helm lint +- Helm template rendering for AWS-managed, in-cluster, and external service profiles +- Secret and hardcoded account scans over tracked text files + +The remaining validation needed before publishing as an official Plane-maintained option is a clean end-to-end AWS apply in a disposable AWS account, including teardown notes and cost/quota observations. + +## Proposed Adoption Path + +1. Plane maintainers review the architecture, IAM policy, workflow model, and Helm wrapper. +2. We run a clean end-to-end deployment in a fresh AWS account and share logs/screenshots. +3. Plane maintainers decide whether this should live as: + - an official Plane repository, + - a community-maintained repository under the Plane organization, + - or a documented reference implementation linked from Plane self-hosting docs. +4. After review, we can tighten naming, defaults, IAM boundaries, and docs to match Plane's preferred maintenance standards. + +## Review Questions For Plane + +- Should this target Plane CE only, or leave room for other Plane editions later? +- Should AWS-managed remain the default profile? +- Are in-cluster dependency modes useful enough to keep in the first version? +- Should this repository vendor the Plane chart archive or rely only on `helm dependency build` from `Chart.lock`? +- What IAM boundary is acceptable for a first-deploy kit? +- Where should this live if accepted: Plane org, community examples, or self-hosting docs? + +## Short Outreach Message + +Hi Plane team, + +I built a fork-and-deploy AWS deployment kit for Plane CE using GitHub Actions, Terraform, and Helm. The goal is to let one operator deploy Plane into their own AWS account with GitHub OIDC, a bootstrap workflow, and a manual production workflow. + +The default profile provisions EKS, RDS PostgreSQL, Amazon MQ RabbitMQ, ElastiCache Redis, S3, IRSA roles, ALB ingress, and generated secrets. It also supports configurable service modes, so PostgreSQL, Redis, RabbitMQ, and object storage can be AWS-managed, in-cluster, or external. + +I would like to propose this as a Plane-maintained or Plane-linked deployment kit for AWS self-hosting. The repository is structured so PR CI is secret-free, deploy workflows are manual, and IAM/service-mode/runbook documentation is included. + +I would appreciate your review on whether this fits Plane's self-hosting direction and what changes would be required for it to become an official or community-supported deployment option. diff --git a/docs/runbook.md b/docs/runbook.md new file mode 100644 index 0000000..5ff862e --- /dev/null +++ b/docs/runbook.md @@ -0,0 +1,43 @@ +# Runbook + +## First Deploy + +1. Create the GitHub OIDC role: + + ```bash + chmod +x scripts/bootstrap-oidc.sh + ./scripts/bootstrap-oidc.sh --repo / --branch main --profile + ``` + +2. Add the printed ARN as the GitHub secret `AWS_GITHUB_OIDC_ROLE_ARN`. +3. Run `Terraform Bootstrap` with `action=apply`. +4. Run `Terraform Prod` with `action=plan`. +5. Review the plan. +6. Run `Terraform Prod` with `action=apply`. + +## Find The Plane URL + +```bash +aws eks update-kubeconfig --name plane-prod-eks --region eu-west-1 +kubectl get ingress plane-ingress -n plane +``` + +Use the ALB hostname unless `PLANE_APP_HOST` points to it through DNS. + +## Update + +Run `Terraform Prod` with `action=plan`, review the plan, then run `action=apply`. + +Changing service modes can add or remove managed resources. Review plan output carefully before applying. + +## Common Failures + +- Missing `AWS_GITHUB_OIDC_ROLE_ARN`: configure the repository secret after running the OIDC helper. +- Backend bucket missing: run `Terraform Bootstrap` with `action=apply`. +- EKS API not reachable from GitHub-hosted runners: keep public endpoint enabled or run Actions on a network that can reach the private endpoint. +- AWS quota errors: check NAT Gateway, Elastic IP, EKS, RDS, Amazon MQ, ElastiCache, and instance-type availability in the selected region. +- External mode missing secret: check [service-modes.md](service-modes.md) for the required GitHub secrets. + +## Teardown + +There is no destructive workflow by default. Run `terraform destroy` manually from a trusted workstation after configuring the same backend and variables used by the workflows. diff --git a/docs/service-modes.md b/docs/service-modes.md new file mode 100644 index 0000000..bc5912d --- /dev/null +++ b/docs/service-modes.md @@ -0,0 +1,62 @@ +# Service Modes + +The default deployment uses AWS-managed dependencies. Advanced users can move one or more dependencies in-cluster or point Plane at existing external services. + +## Mode Variables + +Set these as GitHub repository variables. + +| Variable | Default | Allowed values | +| --- | --- | --- | +| `POSTGRES_MODE` | `aws-managed` | `aws-managed`, `in-cluster`, `external` | +| `REDIS_MODE` | `aws-managed` | `aws-managed`, `in-cluster`, `external` | +| `RABBITMQ_MODE` | `aws-managed` | `aws-managed`, `in-cluster`, `external` | +| `OBJECT_STORE_MODE` | `s3-managed` | `s3-managed`, `minio-in-cluster`, `external-s3` | + +## AWS-Managed + +This is the recommended first deploy path. + +- `POSTGRES_MODE=aws-managed`: Terraform creates RDS PostgreSQL. +- `REDIS_MODE=aws-managed`: Terraform creates ElastiCache Redis. +- `RABBITMQ_MODE=aws-managed`: Terraform creates Amazon MQ for RabbitMQ. +- `OBJECT_STORE_MODE=s3-managed`: Terraform creates an S3 bucket and a Plane IRSA role. + +No additional service connection secrets are required. + +## In-Cluster + +Use this for lower-cost test environments or when you want Kubernetes StatefulSets instead of AWS managed services. + +- `POSTGRES_MODE=in-cluster` +- `REDIS_MODE=in-cluster` +- `RABBITMQ_MODE=in-cluster` +- `OBJECT_STORE_MODE=minio-in-cluster` + +The workflow still creates EKS and stores generated runtime credentials in AWS Secrets Manager, but it does not create RDS, ElastiCache, Amazon MQ, or the Plane S3 bucket for the services switched to in-cluster mode. + +## External + +Use this when you already run service dependencies elsewhere. + +Set these repository secrets only for the external modes you enable: + +| Mode | Required secret or variable | +| --- | --- | +| `POSTGRES_MODE=external` | secret `PLANE_PGDB_REMOTE_URL` | +| `REDIS_MODE=external` | secret `PLANE_REDIS_URL` | +| `RABBITMQ_MODE=external` | secret `PLANE_RABBITMQ_URL` | +| `OBJECT_STORE_MODE=external-s3` | variable `PLANE_DOCSTORE_BUCKET`, secrets `PLANE_EXTERNAL_S3_ACCESS_KEY_ID` and `PLANE_EXTERNAL_S3_SECRET_ACCESS_KEY` | + +Optional external object-store settings: + +- `PLANE_EXTERNAL_S3_REGION`: repository variable; defaults to `AWS_REGION` +- `PLANE_S3_ENDPOINT_URL`: repository secret for S3-compatible storage endpoints + +## Choosing A Profile + +- Use AWS-managed for the strongest one-person production-style deploy story. +- Use in-cluster for demos and low-cost experiments. +- Use external when an organization already owns databases, queues, Redis, or object storage. + +Changing modes after the first deploy can replace or abandon managed resources. Always run `Terraform Prod` with `action=plan` first. diff --git a/helm/plane/Chart.lock b/helm/plane/Chart.lock index fbee74c..0130620 100644 --- a/helm/plane/Chart.lock +++ b/helm/plane/Chart.lock @@ -2,5 +2,5 @@ dependencies: - name: plane-ce repository: https://helm.plane.so version: 1.4.1 -digest: sha256:52379a38f729f098883acbfb907b23749fc948e238dd01aa9f912533dffe7d1f -generated: "2026-03-10T13:41:43.9114805+03:00" +digest: sha256:189ed638020990ce0dec7991b4c789ca53ddaf9aee9cb6750d87623da5c29f3f +generated: "2026-06-16T20:22:11.2984383+03:00" diff --git a/helm/plane/Chart.yaml b/helm/plane/Chart.yaml index ba06040..a6fc229 100644 --- a/helm/plane/Chart.yaml +++ b/helm/plane/Chart.yaml @@ -9,4 +9,4 @@ appVersion: "1.0.0" dependencies: - name: plane-ce repository: https://helm.plane.so - version: "*" + version: "1.4.1" diff --git a/helm/plane/templates/doc-store-secret.yaml b/helm/plane/templates/doc-store-secret.yaml index cb49956..a6943fb 100644 --- a/helm/plane/templates/doc-store-secret.yaml +++ b/helm/plane/templates/doc-store-secret.yaml @@ -4,6 +4,11 @@ Create the Plane doc-store secret from chart values so releases are self-contain {{- $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) -}} +{{- $minio := $plane.minio | default dict -}} +{{- $minioLocal := false -}} +{{- if hasKey $minio "local_setup" -}} +{{- $minioLocal = $minio.local_setup -}} +{{- end -}} apiVersion: v1 kind: Secret metadata: @@ -16,9 +21,19 @@ type: Opaque stringData: FILE_SIZE_LIMIT: "5242880" AWS_S3_BUCKET_NAME: {{ (default "" $plane.env.docstore_bucket) | quote }} - USE_MINIO: "0" + USE_MINIO: {{ ternary "1" "0" $minioLocal | quote }} + {{- if $minioLocal }} + MINIO_ROOT_USER: {{ (default "admin" $minio.root_user) | quote }} + MINIO_ROOT_PASSWORD: {{ (default "password" $minio.root_password) | quote }} + AWS_ACCESS_KEY_ID: {{ (default "admin" $minio.root_user) | quote }} + AWS_SECRET_ACCESS_KEY: {{ (default "password" $minio.root_password) | quote }} + AWS_S3_ENDPOINT_URL: {{ printf "http://%s-minio:9000" .Release.Name | quote }} + {{- else }} + AWS_ACCESS_KEY_ID: {{ (default "" $plane.env.aws_access_key) | quote }} + AWS_SECRET_ACCESS_KEY: {{ (default "" $plane.env.aws_secret_access_key) | quote }} AWS_REGION: {{ (default "eu-west-1" $plane.env.aws_region) | quote }} {{- if $plane.env.aws_s3_endpoint_url }} AWS_S3_ENDPOINT_URL: {{ $plane.env.aws_s3_endpoint_url | quote }} {{- end }} + {{- end }} {{- end }} diff --git a/helm/plane/templates/ingress.yaml b/helm/plane/templates/ingress.yaml new file mode 100644 index 0000000..e3e0930 --- /dev/null +++ b/helm/plane/templates/ingress.yaml @@ -0,0 +1,80 @@ +{{- if .Values.ingress.enabled }} +{{- $wrapperIngress := .Values.ingress | default dict -}} +{{- $plane := index .Values "plane-ce" | default dict -}} +{{- $planeIngress := $plane.ingress | default dict -}} +{{- $planeEnv := $plane.env | default dict -}} +{{- $planeMinio := $plane.minio | default dict -}} +{{- $appHost := $planeIngress.appHost | default "" -}} +{{- $annotations := $wrapperIngress.annotations | default dict -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + namespace: {{ .Release.Namespace }} + name: {{ .Release.Name }}-ingress + {{- if gt (len $annotations) 0 }} + annotations: + {{- range $key, $value := $annotations }} + {{ $key }}: {{ $value | quote }} + {{- end }} + {{- end }} +spec: + ingressClassName: {{ ($wrapperIngress.ingressClass | default "alb") | quote }} + rules: + - + {{- if $appHost }} + host: {{ $appHost | quote }} + {{- end }} + http: + paths: + - backend: + service: + port: + number: 3000 + name: {{ .Release.Name }}-web + path: / + pathType: Prefix + - backend: + service: + port: + number: 8000 + name: {{ .Release.Name }}-api + path: /api + pathType: Prefix + - backend: + service: + port: + number: 8000 + name: {{ .Release.Name }}-api + path: /auth + pathType: Prefix + - backend: + service: + port: + number: 3000 + name: {{ .Release.Name }}-live + path: /live/ + pathType: Prefix + - backend: + service: + port: + number: 3000 + name: {{ .Release.Name }}-space + path: /spaces + pathType: Prefix + - backend: + service: + port: + number: 3000 + name: {{ .Release.Name }}-admin + path: /god-mode + pathType: Prefix + {{- if and $planeMinio.local_setup $planeEnv.docstore_bucket }} + - backend: + service: + port: + number: 9000 + name: {{ .Release.Name }}-minio + path: /{{ $planeEnv.docstore_bucket }} + pathType: Prefix + {{- end }} +{{- end }} diff --git a/helm/plane/templates/required-runtime-values.yaml b/helm/plane/templates/required-runtime-values.yaml new file mode 100644 index 0000000..ad36c50 --- /dev/null +++ b/helm/plane/templates/required-runtime-values.yaml @@ -0,0 +1,56 @@ +{{- $plane := index .Values "plane-ce" | default dict -}} +{{- $env := $plane.env | default dict -}} +{{- $postgres := $plane.postgres | default dict -}} +{{- $redis := $plane.redis | default dict -}} +{{- $rabbitmq := $plane.rabbitmq | default dict -}} +{{- $minio := $plane.minio | default dict -}} + +{{- $postgresLocal := true -}} +{{- if hasKey $postgres "local_setup" -}} +{{- $postgresLocal = $postgres.local_setup -}} +{{- end -}} +{{- $redisLocal := true -}} +{{- if hasKey $redis "local_setup" -}} +{{- $redisLocal = $redis.local_setup -}} +{{- end -}} +{{- $rabbitmqLocal := true -}} +{{- if hasKey $rabbitmq "local_setup" -}} +{{- $rabbitmqLocal = $rabbitmq.local_setup -}} +{{- end -}} +{{- $minioLocal := true -}} +{{- if hasKey $minio "local_setup" -}} +{{- $minioLocal = $minio.local_setup -}} +{{- end -}} + +{{- if and (not $postgresLocal) (empty $env.pgdb_remote_url) -}} +{{- fail "plane-ce.env.pgdb_remote_url is required when plane-ce.postgres.local_setup=false. CI injects it from Terraform; local deploys must pass a runtime values file." -}} +{{- end -}} +{{- if and (not $redisLocal) (empty $env.remote_redis_url) -}} +{{- fail "plane-ce.env.remote_redis_url is required when plane-ce.redis.local_setup=false. CI injects it from Terraform; local deploys must pass a runtime values file." -}} +{{- end -}} +{{- if and (not $rabbitmqLocal) (empty $rabbitmq.external_rabbitmq_url) -}} +{{- fail "plane-ce.rabbitmq.external_rabbitmq_url is required when plane-ce.rabbitmq.local_setup=false. CI injects it from Terraform; local deploys must pass a runtime values file." -}} +{{- end -}} +{{- if and (not $minioLocal) (empty $env.docstore_bucket) -}} +{{- fail "plane-ce.env.docstore_bucket is required when plane-ce.minio.local_setup=false. CI injects it from Terraform; local deploys must pass a runtime values file." -}} +{{- end -}} +{{- if and $postgresLocal (or (empty $env.pgdb_password) (eq $env.pgdb_password "plane")) -}} +{{- fail "plane-ce.env.pgdb_password must be set to a non-default value when plane-ce.postgres.local_setup=true." -}} +{{- end -}} +{{- if and $rabbitmqLocal (or (empty $rabbitmq.default_password) (eq $rabbitmq.default_password "plane")) -}} +{{- fail "plane-ce.rabbitmq.default_password must be set to a non-default value when plane-ce.rabbitmq.local_setup=true." -}} +{{- end -}} +{{- if and $minioLocal (or (empty $minio.root_password) (eq $minio.root_password "password")) -}} +{{- fail "plane-ce.minio.root_password must be set to a non-default value when plane-ce.minio.local_setup=true." -}} +{{- end -}} + +{{- $upstreamSecretKey := "60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5" -}} +{{- $upstreamLiveSecretKey := "htbqvBJAgpm9bzvf3r4urJer0ENReatceh" -}} +{{- $secretKey := default "" $env.secret_key -}} +{{- $liveSecretKey := default "" $env.live_server_secret_key -}} +{{- if or (empty $secretKey) (eq $secretKey $upstreamSecretKey) -}} +{{- fail "plane-ce.env.secret_key must be set to a stable random value. CI injects it from Terraform; local deploys must pass a runtime values file." -}} +{{- end -}} +{{- if or (empty $liveSecretKey) (eq $liveSecretKey $upstreamLiveSecretKey) -}} +{{- fail "plane-ce.env.live_server_secret_key must be set to a stable random value. CI injects it from Terraform; local deploys must pass a runtime values file." -}} +{{- end -}} diff --git a/helm/plane/values.yaml b/helm/plane/values.yaml index 7640f3d..bcc4888 100644 --- a/helm/plane/values.yaml +++ b/helm/plane/values.yaml @@ -30,4 +30,9 @@ autoscaling: minReplicas: 1 maxReplicas: 2 +ingress: + enabled: true + ingressClass: alb + annotations: {} + plane-ce: {} diff --git a/helm/plane/values/dev.runtime.example.yaml b/helm/plane/values/dev.runtime.example.yaml index b7e279a..587a4bf 100644 --- a/helm/plane/values/dev.runtime.example.yaml +++ b/helm/plane/values/dev.runtime.example.yaml @@ -1,4 +1,5 @@ # Example local runtime overrides for manual helm upgrades. +# This example uses external PostgreSQL, Redis, RabbitMQ, and S3-compatible storage. plane-ce: postgres: local_setup: false @@ -7,6 +8,15 @@ plane-ce: rabbitmq: local_setup: false external_rabbitmq_url: "amqps://:@:5671/" + minio: + local_setup: false env: pgdb_remote_url: "postgresql://:@:5432/" remote_redis_url: "rediss://:@:6379/0" + docstore_bucket: "" + aws_region: "" + aws_access_key: "" + aws_secret_access_key: "" + aws_s3_endpoint_url: "" + secret_key: "" + live_server_secret_key: "" diff --git a/helm/plane/values/dev.yaml b/helm/plane/values/dev.yaml index 3dee32b..bca3f84 100644 --- a/helm/plane/values/dev.yaml +++ b/helm/plane/values/dev.yaml @@ -18,9 +18,17 @@ autoscaling: minReplicas: 1 maxReplicas: 1 +ingress: + enabled: true + ingressClass: alb + annotations: + alb.ingress.kubernetes.io/scheme: internet-facing + alb.ingress.kubernetes.io/target-type: ip + alb.ingress.kubernetes.io/listen-ports: '[{"HTTP":80},{"HTTP":3000}]' + plane-ce: external_secrets: - doc_store_existingSecret: plane-dev-doc-store-secrets + doc_store_existingSecret: plane-doc-store-secrets postgres: local_setup: false @@ -40,21 +48,19 @@ plane-ce: pgdb_remote_url: "" # Set by CI from Terraform ElastiCache output. remote_redis_url: "" - docstore_bucket: "plane-dev-uploads" + # Set by CI from Terraform S3 output. + docstore_bucket: "" aws_region: "eu-west-1" aws_s3_endpoint_url: "" + # Set by CI from Terraform-managed AWS Secrets Manager value. + secret_key: "" + live_server_secret_key: "" 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 - ingressClass: alb - ingress_annotations: - alb.ingress.kubernetes.io/scheme: internet-facing - alb.ingress.kubernetes.io/target-type: ip - # Temporary workaround until custom domain + TLS are in place. - # Keeps legacy redirects that target :3000 reachable. - alb.ingress.kubernetes.io/listen-ports: '[{"HTTP":80},{"HTTP":3000}]' + # The upstream chart only renders host-based ingress. The wrapper chart renders + # the portable ALB ingress instead, using this host only when it is set. + enabled: false + appHost: "" ssl: createIssuer: false diff --git a/scripts/bootstrap-oidc.sh b/scripts/bootstrap-oidc.sh index 370dfd4..7c4f1d4 100644 --- a/scripts/bootstrap-oidc.sh +++ b/scripts/bootstrap-oidc.sh @@ -16,7 +16,7 @@ Options: --policy-file Policy JSON file path (default: .github/iam/terraform-prod-policy.json) --account-id AWS account id (default: autodetect via sts) --profile AWS CLI profile name - --allow-pull-request Allow repo::pull_request subject (default: true) + --allow-pull-request Allow repo::pull_request subject (default: false) -h, --help Show this help EOF } @@ -28,7 +28,7 @@ POLICY_NAME="terraform-prod-policy" POLICY_FILE=".github/iam/terraform-prod-policy.json" ACCOUNT_ID="" PROFILE="" -ALLOW_PULL_REQUEST="true" +ALLOW_PULL_REQUEST="false" OIDC_URL="https://token.actions.githubusercontent.com" OIDC_HOST="token.actions.githubusercontent.com" OIDC_AUDIENCE="sts.amazonaws.com" diff --git a/terraform/environments/prod/main.tf b/terraform/environments/prod/main.tf index f5ef40f..93c5176 100644 --- a/terraform/environments/prod/main.tf +++ b/terraform/environments/prod/main.tf @@ -2,6 +2,15 @@ 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}" + use_aws_postgres = var.postgres_mode == "aws-managed" + use_aws_redis = var.redis_mode == "aws-managed" + use_aws_rabbitmq = var.rabbitmq_mode == "aws-managed" + use_managed_s3 = var.object_store_mode == "s3-managed" + rds_allowed_cidr_blocks = length(var.rds_allowed_cidr_blocks) > 0 ? var.rds_allowed_cidr_blocks : [var.vpc_cidr] + rabbitmq_allowed_cidr_blocks = ( + length(var.rabbitmq_allowed_cidr_blocks) > 0 ? var.rabbitmq_allowed_cidr_blocks : [var.vpc_cidr] + ) + redis_allowed_cidr_blocks = length(var.redis_allowed_cidr_blocks) > 0 ? var.redis_allowed_cidr_blocks : [var.vpc_cidr] } module "vpc" { @@ -56,23 +65,26 @@ module "eks" { } module "rds_postgres" { + count = local.use_aws_postgres ? 1 : 0 + source = "../../modules/rds-postgres" - name = "${var.project_name}-${var.environment}-postgres" - vpc_id = module.vpc.vpc_id - subnet_ids = module.vpc.private_subnet_ids - allowed_cidr_blocks = var.rds_allowed_cidr_blocks - db_name = var.rds_db_name - master_username = var.rds_master_username - master_password = var.rds_master_password - instance_class = var.rds_instance_class - allocated_storage = var.rds_allocated_storage - max_allocated_storage = var.rds_max_allocated_storage - engine_version = var.rds_engine_version - backup_retention_period = var.rds_backup_retention_period - multi_az = var.rds_multi_az - deletion_protection = var.rds_deletion_protection - skip_final_snapshot = var.rds_skip_final_snapshot + name = "${var.project_name}-${var.environment}-postgres" + vpc_id = module.vpc.vpc_id + subnet_ids = module.vpc.private_subnet_ids + allowed_cidr_blocks = local.rds_allowed_cidr_blocks + db_name = var.rds_db_name + master_username = var.rds_master_username + master_password = var.rds_master_password + instance_class = var.rds_instance_class + allocated_storage = var.rds_allocated_storage + max_allocated_storage = var.rds_max_allocated_storage + engine_version = var.rds_engine_version + backup_retention_period = var.rds_backup_retention_period + multi_az = var.rds_multi_az + deletion_protection = var.rds_deletion_protection + skip_final_snapshot = var.rds_skip_final_snapshot + performance_insights_enabled = var.rds_performance_insights_enabled tags = { Project = var.project_name @@ -82,12 +94,14 @@ module "rds_postgres" { } module "rabbitmq_amazonmq" { + count = local.use_aws_rabbitmq ? 1 : 0 + source = "../../modules/amazonmq-rabbitmq" name = "${var.project_name}-${var.environment}-rabbitmq" vpc_id = module.vpc.vpc_id subnet_ids = module.vpc.private_subnet_ids - allowed_cidr_blocks = var.rabbitmq_allowed_cidr_blocks + allowed_cidr_blocks = local.rabbitmq_allowed_cidr_blocks engine_version = var.rabbitmq_engine_version host_instance_type = var.rabbitmq_host_instance_type deployment_mode = var.rabbitmq_deployment_mode @@ -104,12 +118,14 @@ module "rabbitmq_amazonmq" { } module "redis_elasticache" { + count = local.use_aws_redis ? 1 : 0 + source = "../../modules/elasticache-redis" name = "${var.project_name}-${var.environment}-redis" vpc_id = module.vpc.vpc_id subnet_ids = module.vpc.private_subnet_ids - allowed_cidr_blocks = var.redis_allowed_cidr_blocks + allowed_cidr_blocks = local.redis_allowed_cidr_blocks node_type = var.redis_node_type engine_version = var.redis_engine_version port = var.redis_port @@ -125,6 +141,8 @@ module "redis_elasticache" { } resource "aws_s3_bucket" "plane_docstore" { + count = local.use_managed_s3 ? 1 : 0 + bucket = local.plane_docstore_bucket_name tags = { @@ -137,7 +155,9 @@ resource "aws_s3_bucket" "plane_docstore" { } resource "aws_s3_bucket_versioning" "plane_docstore" { - bucket = aws_s3_bucket.plane_docstore.id + count = local.use_managed_s3 ? 1 : 0 + + bucket = aws_s3_bucket.plane_docstore[0].id versioning_configuration { status = "Enabled" @@ -145,7 +165,9 @@ resource "aws_s3_bucket_versioning" "plane_docstore" { } resource "aws_s3_bucket_server_side_encryption_configuration" "plane_docstore" { - bucket = aws_s3_bucket.plane_docstore.id + count = local.use_managed_s3 ? 1 : 0 + + bucket = aws_s3_bucket.plane_docstore[0].id rule { apply_server_side_encryption_by_default { @@ -155,7 +177,9 @@ resource "aws_s3_bucket_server_side_encryption_configuration" "plane_docstore" { } resource "aws_s3_bucket_public_access_block" "plane_docstore" { - bucket = aws_s3_bucket.plane_docstore.id + count = local.use_managed_s3 ? 1 : 0 + + bucket = aws_s3_bucket.plane_docstore[0].id block_public_acls = true block_public_policy = true @@ -164,6 +188,8 @@ resource "aws_s3_bucket_public_access_block" "plane_docstore" { } resource "aws_iam_role" "plane_irsa" { + count = local.use_managed_s3 ? 1 : 0 + name = "${var.project_name}-${var.environment}-plane-irsa-role" assume_role_policy = jsonencode({ @@ -194,6 +220,8 @@ resource "aws_iam_role" "plane_irsa" { } resource "aws_iam_policy" "plane_docstore_s3" { + count = local.use_managed_s3 ? 1 : 0 + name = "${var.project_name}-${var.environment}-plane-docstore-s3" description = "S3 access policy for Plane doc-store via IRSA" @@ -206,7 +234,7 @@ resource "aws_iam_policy" "plane_docstore_s3" { "s3:ListBucket", "s3:GetBucketLocation" ] - Resource = aws_s3_bucket.plane_docstore.arn + Resource = aws_s3_bucket.plane_docstore[0].arn }, { Effect = "Allow" @@ -215,13 +243,63 @@ resource "aws_iam_policy" "plane_docstore_s3" { "s3:PutObject", "s3:DeleteObject" ] - Resource = "${aws_s3_bucket.plane_docstore.arn}/*" + Resource = "${aws_s3_bucket.plane_docstore[0].arn}/*" } ] }) } resource "aws_iam_role_policy_attachment" "plane_docstore_s3" { - role = aws_iam_role.plane_irsa.name - policy_arn = aws_iam_policy.plane_docstore_s3.arn + count = local.use_managed_s3 ? 1 : 0 + + role = aws_iam_role.plane_irsa[0].name + policy_arn = aws_iam_policy.plane_docstore_s3[0].arn +} + +resource "random_password" "plane_secret_key" { + length = 64 + special = false +} + +resource "random_password" "plane_live_server_secret_key" { + length = 48 + special = false +} + +resource "random_password" "plane_local_postgres_password" { + length = 24 + special = false +} + +resource "random_password" "plane_local_rabbitmq_password" { + length = 24 + special = false +} + +resource "random_password" "plane_local_minio_password" { + length = 32 + special = false +} + +resource "aws_secretsmanager_secret" "plane_app" { + name_prefix = "${var.project_name}-${var.environment}-plane-app-" + recovery_window_in_days = 7 + + tags = { + Name = "${var.project_name}-${var.environment}-plane-app" + Project = var.project_name + Environment = var.environment + ManagedBy = "terraform" + } +} + +resource "aws_secretsmanager_secret_version" "plane_app" { + secret_id = aws_secretsmanager_secret.plane_app.id + secret_string = jsonencode({ + secret_key = random_password.plane_secret_key.result + live_server_secret_key = random_password.plane_live_server_secret_key.result + postgres_password = random_password.plane_local_postgres_password.result + rabbitmq_password = random_password.plane_local_rabbitmq_password.result + minio_root_password = random_password.plane_local_minio_password.result + }) } diff --git a/terraform/environments/prod/outputs.tf b/terraform/environments/prod/outputs.tf index 6f4de6a..db78eac 100644 --- a/terraform/environments/prod/outputs.tf +++ b/terraform/environments/prod/outputs.tf @@ -60,70 +60,75 @@ output "eks_cluster_autoscaler_irsa_role_arn" { output "rds_instance_identifier" { description = "RDS PostgreSQL instance identifier" - value = module.rds_postgres.db_instance_identifier + value = local.use_aws_postgres ? module.rds_postgres[0].db_instance_identifier : null } output "rds_endpoint" { description = "RDS PostgreSQL endpoint address" - value = module.rds_postgres.db_instance_endpoint + value = local.use_aws_postgres ? module.rds_postgres[0].db_instance_endpoint : null } output "rds_port" { description = "RDS PostgreSQL port" - value = module.rds_postgres.db_instance_port + value = local.use_aws_postgres ? module.rds_postgres[0].db_instance_port : null } output "rds_db_name" { description = "RDS PostgreSQL database name" - value = module.rds_postgres.db_name + value = local.use_aws_postgres ? module.rds_postgres[0].db_name : null } output "rds_credentials_secret_arn" { description = "Secrets Manager ARN containing PostgreSQL credentials" - value = module.rds_postgres.credentials_secret_arn + value = local.use_aws_postgres ? module.rds_postgres[0].credentials_secret_arn : null } output "rabbitmq_broker_id" { description = "Amazon MQ broker ID" - value = module.rabbitmq_amazonmq.broker_id + value = local.use_aws_rabbitmq ? module.rabbitmq_amazonmq[0].broker_id : null } output "rabbitmq_broker_endpoint" { description = "Amazon MQ RabbitMQ endpoint" - value = module.rabbitmq_amazonmq.broker_endpoint + value = local.use_aws_rabbitmq ? module.rabbitmq_amazonmq[0].broker_endpoint : null } output "rabbitmq_credentials_secret_arn" { description = "Secrets Manager ARN containing RabbitMQ credentials" - value = module.rabbitmq_amazonmq.credentials_secret_arn + value = local.use_aws_rabbitmq ? module.rabbitmq_amazonmq[0].credentials_secret_arn : null } output "redis_replication_group_id" { description = "ElastiCache Redis replication group ID" - value = module.redis_elasticache.replication_group_id + value = local.use_aws_redis ? module.redis_elasticache[0].replication_group_id : null } output "redis_primary_endpoint_address" { description = "ElastiCache Redis primary endpoint address" - value = module.redis_elasticache.primary_endpoint_address + value = local.use_aws_redis ? module.redis_elasticache[0].primary_endpoint_address : null } output "redis_port" { description = "ElastiCache Redis port" - value = module.redis_elasticache.port + value = local.use_aws_redis ? module.redis_elasticache[0].port : null } output "redis_credentials_secret_arn" { description = "Secrets Manager ARN containing Redis endpoint and auth token" - value = module.redis_elasticache.credentials_secret_arn + value = local.use_aws_redis ? module.redis_elasticache[0].credentials_secret_arn : null } output "plane_docstore_bucket_name" { description = "Plane doc-store S3 bucket name" - value = aws_s3_bucket.plane_docstore.bucket + value = local.use_managed_s3 ? aws_s3_bucket.plane_docstore[0].bucket : null } output "plane_irsa_role_arn" { description = "IAM role ARN used by Plane service account via IRSA" - value = aws_iam_role.plane_irsa.arn + value = local.use_managed_s3 ? aws_iam_role.plane_irsa[0].arn : null +} + +output "plane_app_secret_arn" { + description = "Secrets Manager ARN containing Plane application secrets" + value = aws_secretsmanager_secret.plane_app.arn } diff --git a/terraform/environments/prod/variables.tf b/terraform/environments/prod/variables.tf index ebf450e..d0d0773 100644 --- a/terraform/environments/prod/variables.tf +++ b/terraform/environments/prod/variables.tf @@ -16,6 +16,50 @@ variable "environment" { default = "prod" } +variable "postgres_mode" { + description = "PostgreSQL deployment mode: aws-managed creates RDS, in-cluster uses the Plane chart PostgreSQL, external expects PLANE_PGDB_REMOTE_URL." + type = string + default = "aws-managed" + + validation { + condition = contains(["aws-managed", "in-cluster", "external"], var.postgres_mode) + error_message = "postgres_mode must be aws-managed, in-cluster, or external." + } +} + +variable "redis_mode" { + description = "Redis deployment mode: aws-managed creates ElastiCache, in-cluster uses the Plane chart Redis, external expects PLANE_REDIS_URL." + type = string + default = "aws-managed" + + validation { + condition = contains(["aws-managed", "in-cluster", "external"], var.redis_mode) + error_message = "redis_mode must be aws-managed, in-cluster, or external." + } +} + +variable "rabbitmq_mode" { + description = "RabbitMQ deployment mode: aws-managed creates Amazon MQ, in-cluster uses the Plane chart RabbitMQ, external expects PLANE_RABBITMQ_URL." + type = string + default = "aws-managed" + + validation { + condition = contains(["aws-managed", "in-cluster", "external"], var.rabbitmq_mode) + error_message = "rabbitmq_mode must be aws-managed, in-cluster, or external." + } +} + +variable "object_store_mode" { + description = "Object store mode: s3-managed creates an S3 bucket with IRSA, minio-in-cluster uses the Plane chart MinIO, external-s3 expects an existing S3-compatible bucket and credentials." + type = string + default = "s3-managed" + + validation { + condition = contains(["s3-managed", "minio-in-cluster", "external-s3"], var.object_store_mode) + error_message = "object_store_mode must be s3-managed, minio-in-cluster, or external-s3." + } +} + variable "vpc_cidr" { description = "CIDR block for the VPC" type = string @@ -79,9 +123,9 @@ variable "eks_bootstrap_cluster_creator_admin" { } variable "eks_cluster_admin_principal_arns" { - description = "IAM principal ARNs that should have cluster-admin access" + description = "IAM principal ARNs that should have cluster-admin access. CI sets this to the GitHub OIDC role ARN." type = list(string) - default = ["arn:aws:iam::211125458668:role/github-actions-terraform-prod"] + default = [] } variable "eks_node_group_name" { @@ -243,16 +287,22 @@ variable "rds_skip_final_snapshot" { default = true } +variable "rds_performance_insights_enabled" { + description = "Whether RDS Performance Insights is enabled" + type = bool + default = false +} + variable "rds_allowed_cidr_blocks" { - description = "CIDR blocks allowed to reach PostgreSQL on port 5432" + description = "CIDR blocks allowed to reach PostgreSQL on port 5432. Empty means the VPC CIDR." type = list(string) - default = ["172.26.0.0/16"] + default = [] } variable "rabbitmq_allowed_cidr_blocks" { - description = "CIDR blocks allowed to reach Amazon MQ RabbitMQ on port 5671" + description = "CIDR blocks allowed to reach Amazon MQ RabbitMQ on port 5671. Empty means the VPC CIDR." type = list(string) - default = ["172.26.0.0/16"] + default = [] } variable "rabbitmq_engine_version" { @@ -300,9 +350,9 @@ variable "rabbitmq_password" { } variable "redis_allowed_cidr_blocks" { - description = "CIDR blocks allowed to reach Redis on port 6379" + description = "CIDR blocks allowed to reach Redis on port 6379. Empty means the VPC CIDR." type = list(string) - default = ["172.26.0.0/16"] + default = [] } variable "redis_node_type" { @@ -346,13 +396,13 @@ variable "redis_auth_token" { variable "plane_namespace" { description = "Kubernetes namespace where Plane is deployed" type = string - default = "plane-dev" + default = "plane" } variable "plane_service_account_name" { description = "Plane Kubernetes service account name used by workloads" type = string - default = "plane-dev-srv-account" + default = "plane-srv-account" } variable "plane_docstore_bucket_name" { diff --git a/terraform/modules/amazonmq-rabbitmq/main.tf b/terraform/modules/amazonmq-rabbitmq/main.tf index b05997c..ec66f63 100644 --- a/terraform/modules/amazonmq-rabbitmq/main.tf +++ b/terraform/modules/amazonmq-rabbitmq/main.tf @@ -1,5 +1,5 @@ locals { - resolved_password = coalesce(var.password, random_password.user[0].result) + resolved_password = var.password != null ? var.password : random_password.user[0].result is_multi_az_mode = var.deployment_mode == "CLUSTER_MULTI_AZ" credentials_secret_name_prefix = coalesce( var.credentials_secret_name_prefix, diff --git a/terraform/modules/elasticache-redis/main.tf b/terraform/modules/elasticache-redis/main.tf index ae51b18..3f05661 100644 --- a/terraform/modules/elasticache-redis/main.tf +++ b/terraform/modules/elasticache-redis/main.tf @@ -1,5 +1,5 @@ locals { - resolved_auth_token = coalesce(var.auth_token, random_password.auth_token[0].result) + resolved_auth_token = var.auth_token != null ? var.auth_token : random_password.auth_token[0].result credentials_secret_name_prefix = coalesce( var.credentials_secret_name_prefix, "${var.name}/credentials-" diff --git a/terraform/modules/rds-postgres/main.tf b/terraform/modules/rds-postgres/main.tf index 9027dd9..8caa750 100644 --- a/terraform/modules/rds-postgres/main.tf +++ b/terraform/modules/rds-postgres/main.tf @@ -1,5 +1,5 @@ locals { - resolved_master_password = coalesce(var.master_password, random_password.master[0].result) + resolved_master_password = var.master_password != null ? var.master_password : random_password.master[0].result } resource "random_password" "master" { @@ -61,7 +61,7 @@ resource "aws_db_instance" "this" { deletion_protection = var.deletion_protection skip_final_snapshot = var.skip_final_snapshot auto_minor_version_upgrade = true - performance_insights_enabled = true + performance_insights_enabled = var.performance_insights_enabled storage_encrypted = true apply_immediately = false diff --git a/terraform/modules/rds-postgres/variables.tf b/terraform/modules/rds-postgres/variables.tf index 3b85144..6af3615 100644 --- a/terraform/modules/rds-postgres/variables.tf +++ b/terraform/modules/rds-postgres/variables.tf @@ -86,6 +86,12 @@ variable "skip_final_snapshot" { default = true } +variable "performance_insights_enabled" { + description = "Enable RDS Performance Insights" + type = bool + default = false +} + variable "tags" { description = "Common tags" type = map(string) From 0434f58dce113dba205c3cf2b3b60f9330679a37 Mon Sep 17 00:00:00 2001 From: semih702 Date: Wed, 17 Jun 2026 23:22:28 +0300 Subject: [PATCH 2/2] Support existing EKS cluster deployments --- .github/workflows/terraform-prod.yml | 103 +++++++++++++++++++---- README.md | 25 ++++++ docs/existing-cluster.md | 100 ++++++++++++++++++++++ docs/proposal-to-plane.md | 8 +- docs/runbook.md | 12 ++- docs/service-modes.md | 6 ++ terraform/environments/prod/main.tf | 102 ++++++++++++++++++---- terraform/environments/prod/outputs.tf | 40 ++++----- terraform/environments/prod/variables.tf | 36 ++++++++ 9 files changed, 376 insertions(+), 56 deletions(-) create mode 100644 docs/existing-cluster.md diff --git a/.github/workflows/terraform-prod.yml b/.github/workflows/terraform-prod.yml index d6466fb..c3c65bf 100644 --- a/.github/workflows/terraform-prod.yml +++ b/.github/workflows/terraform-prod.yml @@ -19,9 +19,25 @@ env: 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_NAME: ${{ vars.TF_LOCK_TABLE_NAME || 'plane-cloud-platform-tf-locks' }} + CREATE_EKS_CLUSTER: ${{ vars.CREATE_EKS_CLUSTER || 'true' }} + INSTALL_METRICS_SERVER: ${{ vars.INSTALL_METRICS_SERVER || 'auto' }} + INSTALL_AWS_LOAD_BALANCER_CONTROLLER: ${{ vars.INSTALL_AWS_LOAD_BALANCER_CONTROLLER || 'auto' }} + INSTALL_CLUSTER_AUTOSCALER: ${{ vars.INSTALL_CLUSTER_AUTOSCALER || 'auto' }} + CREATE_K8S_NAMESPACE: ${{ vars.CREATE_K8S_NAMESPACE || 'auto' }} + CREATE_STORAGE_CLASS: ${{ vars.CREATE_STORAGE_CLASS || 'auto' }} + STORAGE_CLASS_NAME: ${{ vars.STORAGE_CLASS_NAME || 'gp3-csi' }} + PLANE_STORAGE_CLASS_NAME: ${{ vars.PLANE_STORAGE_CLASS_NAME || '' }} + INGRESS_ENABLED: ${{ vars.INGRESS_ENABLED || 'true' }} + INGRESS_CLASS_NAME: ${{ vars.INGRESS_CLASS_NAME || 'alb' }} TF_VAR_aws_region: ${{ vars.AWS_REGION || 'eu-west-1' }} TF_VAR_project_name: ${{ vars.PROJECT_NAME || 'plane' }} TF_VAR_environment: ${{ vars.DEPLOY_ENVIRONMENT || 'prod' }} + TF_VAR_create_eks_cluster: ${{ vars.CREATE_EKS_CLUSTER || 'true' }} + TF_VAR_existing_eks_cluster_name: ${{ vars.EXISTING_EKS_CLUSTER_NAME || '' }} + TF_VAR_existing_vpc_id: ${{ vars.EXISTING_VPC_ID || '' }} + TF_VAR_existing_private_subnet_ids: ${{ vars.EXISTING_PRIVATE_SUBNET_IDS_JSON || '[]' }} + TF_VAR_existing_vpc_cidr: ${{ vars.EXISTING_VPC_CIDR || '' }} + TF_VAR_existing_eks_oidc_provider_arn: ${{ vars.EXISTING_EKS_OIDC_PROVIDER_ARN || '' }} TF_VAR_postgres_mode: ${{ vars.POSTGRES_MODE || 'aws-managed' }} TF_VAR_redis_mode: ${{ vars.REDIS_MODE || 'aws-managed' }} TF_VAR_rabbitmq_mode: ${{ vars.RABBITMQ_MODE || 'aws-managed' }} @@ -33,7 +49,7 @@ env: REDIS_MODE: ${{ vars.REDIS_MODE || 'aws-managed' }} RABBITMQ_MODE: ${{ vars.RABBITMQ_MODE || 'aws-managed' }} OBJECT_STORE_MODE: ${{ vars.OBJECT_STORE_MODE || 's3-managed' }} - EKS_CLUSTER_NAME: ${{ vars.EKS_CLUSTER_NAME || format('{0}-{1}-eks', vars.PROJECT_NAME || 'plane', vars.DEPLOY_ENVIRONMENT || 'prod') }} + EKS_CLUSTER_NAME: ${{ vars.EKS_CLUSTER_NAME || vars.EXISTING_EKS_CLUSTER_NAME || format('{0}-{1}-eks', vars.PROJECT_NAME || 'plane', vars.DEPLOY_ENVIRONMENT || 'prod') }} HELM_RELEASE_NAME: ${{ vars.HELM_RELEASE_NAME || 'plane' }} K8S_NAMESPACE: ${{ vars.K8S_NAMESPACE || 'plane' }} PLANE_SERVICE_ACCOUNT_NAME: ${{ format('{0}-srv-account', vars.HELM_RELEASE_NAME || 'plane') }} @@ -167,10 +183,13 @@ jobs: - name: Ensure AWS Service-Linked Roles run: | set -euo pipefail - services=( - "eks.amazonaws.com:AWSServiceRoleForAmazonEKS" - "eks-nodegroup.amazonaws.com:AWSServiceRoleForAmazonEKSNodegroup" - ) + services=() + if [ "${CREATE_EKS_CLUSTER}" = "true" ]; then + services+=( + "eks.amazonaws.com:AWSServiceRoleForAmazonEKS" + "eks-nodegroup.amazonaws.com:AWSServiceRoleForAmazonEKSNodegroup" + ) + fi if [ "${POSTGRES_MODE}" = "aws-managed" ]; then services+=("rds.amazonaws.com:AWSServiceRoleForRDS") fi @@ -215,6 +234,7 @@ jobs: helm dependency build "${{ env.HELM_CHART_DIR }}" - name: Ensure namespace exists + if: (env.CREATE_EKS_CLUSTER == 'true' && env.CREATE_K8S_NAMESPACE != 'false') || env.CREATE_K8S_NAMESPACE == 'true' run: kubectl get namespace "${{ env.K8S_NAMESPACE }}" || kubectl create namespace "${{ env.K8S_NAMESPACE }}" - name: Read Terraform Outputs @@ -245,10 +265,29 @@ jobs: terraform -chdir=${{ env.TF_DIR }} output -raw "$name" 2>/dev/null || true } - VPC_ID="$(terraform -chdir=${{ env.TF_DIR }} output -raw vpc_id)" - NODE_GROUP_NAME="$(terraform -chdir=${{ env.TF_DIR }} output -raw eks_node_group_name)" + SHOULD_INSTALL_AWS_LBC="false" + SHOULD_INSTALL_CLUSTER_AUTOSCALER="false" + + if [ "${CREATE_EKS_CLUSTER}" = "true" ] && [ "${INSTALL_AWS_LOAD_BALANCER_CONTROLLER}" != "false" ]; then + SHOULD_INSTALL_AWS_LBC="true" + elif [ "${INSTALL_AWS_LOAD_BALANCER_CONTROLLER}" = "true" ]; then + echo "INSTALL_AWS_LOAD_BALANCER_CONTROLLER=true is only supported when CREATE_EKS_CLUSTER=true." + echo "For existing clusters, manage cluster-level ingress controllers outside this workflow." + exit 1 + fi + + if [ "${CREATE_EKS_CLUSTER}" = "true" ] && [ "${INSTALL_CLUSTER_AUTOSCALER}" != "false" ]; then + SHOULD_INSTALL_CLUSTER_AUTOSCALER="true" + elif [ "${INSTALL_CLUSTER_AUTOSCALER}" = "true" ]; then + echo "INSTALL_CLUSTER_AUTOSCALER=true is only supported when CREATE_EKS_CLUSTER=true." + echo "For existing clusters, keep autoscaling ownership with the platform cluster." + exit 1 + fi + + VPC_ID="$(output_raw_or_empty vpc_id)" + NODE_GROUP_NAME="$(output_raw_or_empty eks_node_group_name)" PLANE_APP_SECRET_ARN="$(terraform -chdir=${{ env.TF_DIR }} output -raw plane_app_secret_arn)" - AWS_LBC_IRSA_ROLE_ARN="$(terraform -chdir=${{ env.TF_DIR }} output -raw eks_aws_load_balancer_controller_irsa_role_arn)" + AWS_LBC_IRSA_ROLE_ARN="$(output_raw_or_empty eks_aws_load_balancer_controller_irsa_role_arn)" CLUSTER_AUTOSCALER_IRSA_ROLE_ARN="$(output_raw_or_empty eks_cluster_autoscaler_irsa_role_arn)" DOCSTORE_BUCKET="" PLANE_IRSA_ROLE_ARN="" @@ -256,17 +295,22 @@ jobs: RABBITMQ_SECRET_ARN="" REDIS_SECRET_ARN="" - if [ -z "$CLUSTER_AUTOSCALER_IRSA_ROLE_ARN" ]; then + if [ "${SHOULD_INSTALL_CLUSTER_AUTOSCALER}" = "true" ] && [ -z "$CLUSTER_AUTOSCALER_IRSA_ROLE_ARN" ]; then ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)" CLUSTER_AUTOSCALER_IRSA_ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/${{ env.EKS_CLUSTER_NAME }}-cluster-autoscaler-role" echo "eks_cluster_autoscaler_irsa_role_arn output not found in state; using fallback ARN: ${CLUSTER_AUTOSCALER_IRSA_ROLE_ARN}" fi - require_nonempty "vpc_id" "$VPC_ID" - require_nonempty "eks_node_group_name" "$NODE_GROUP_NAME" require_nonempty "plane_app_secret_arn" "$PLANE_APP_SECRET_ARN" - require_iam_role_arn "eks_aws_load_balancer_controller_irsa_role_arn" "$AWS_LBC_IRSA_ROLE_ARN" - require_iam_role_arn "eks_cluster_autoscaler_irsa_role_arn" "$CLUSTER_AUTOSCALER_IRSA_ROLE_ARN" + + if [ "${SHOULD_INSTALL_AWS_LBC}" = "true" ]; then + require_nonempty "vpc_id" "$VPC_ID" + require_iam_role_arn "eks_aws_load_balancer_controller_irsa_role_arn" "$AWS_LBC_IRSA_ROLE_ARN" + fi + if [ "${SHOULD_INSTALL_CLUSTER_AUTOSCALER}" = "true" ]; then + require_nonempty "eks_node_group_name" "$NODE_GROUP_NAME" + require_iam_role_arn "eks_cluster_autoscaler_irsa_role_arn" "$CLUSTER_AUTOSCALER_IRSA_ROLE_ARN" + fi if [ "${POSTGRES_MODE}" = "aws-managed" ]; then RDS_SECRET_ARN="$(terraform -chdir=${{ env.TF_DIR }} output -raw rds_credentials_secret_arn)" @@ -299,6 +343,7 @@ jobs: echo "cluster_autoscaler_irsa_role_arn=$CLUSTER_AUTOSCALER_IRSA_ROLE_ARN" >> "$GITHUB_OUTPUT" - name: Verify Cluster Autoscaler IRSA Role Exists + if: env.CREATE_EKS_CLUSTER == 'true' && env.INSTALL_CLUSTER_AUTOSCALER != 'false' && steps.tf_outputs.outputs.cluster_autoscaler_irsa_role_arn != '' run: | set -euo pipefail ROLE_ARN="${{ steps.tf_outputs.outputs.cluster_autoscaler_irsa_role_arn }}" @@ -307,7 +352,7 @@ jobs: - name: Resolve Node Group ASG For Cluster Autoscaler id: nodegroup_scaling - if: steps.tf_outputs.outputs.cluster_autoscaler_irsa_role_arn != '' + if: env.CREATE_EKS_CLUSTER == 'true' && env.INSTALL_CLUSTER_AUTOSCALER != 'false' && steps.tf_outputs.outputs.cluster_autoscaler_irsa_role_arn != '' run: | set -euo pipefail NODEGROUP_NAME="${{ steps.tf_outputs.outputs.eks_node_group_name }}" @@ -342,6 +387,7 @@ jobs: echo "max_size=${MAX_SIZE}" >> "$GITHUB_OUTPUT" - name: Install/Upgrade Metrics Server + if: (env.CREATE_EKS_CLUSTER == 'true' && env.INSTALL_METRICS_SERVER != 'false') || env.INSTALL_METRICS_SERVER == 'true' run: | helm repo add metrics-server https://kubernetes-sigs.github.io/metrics-server/ helm repo update @@ -353,6 +399,7 @@ jobs: kubectl rollout status deployment/metrics-server -n kube-system --timeout=5m - name: Install/Upgrade AWS Load Balancer Controller + if: env.CREATE_EKS_CLUSTER == 'true' && env.INSTALL_AWS_LOAD_BALANCER_CONTROLLER != 'false' && steps.tf_outputs.outputs.aws_load_balancer_controller_irsa_role_arn != '' run: | helm repo add eks https://aws.github.io/eks-charts helm repo update @@ -371,6 +418,7 @@ jobs: kubectl rollout status deployment/aws-load-balancer-controller -n kube-system --timeout=5m - name: Ensure AWS LB Webhook CA Bundle Is Synced + if: env.CREATE_EKS_CLUSTER == 'true' && env.INSTALL_AWS_LOAD_BALANCER_CONTROLLER != 'false' && steps.tf_outputs.outputs.aws_load_balancer_controller_irsa_role_arn != '' run: | set -euo pipefail @@ -391,7 +439,7 @@ jobs: | kubectl apply -f - - name: Install/Upgrade Cluster Autoscaler - if: steps.tf_outputs.outputs.cluster_autoscaler_irsa_role_arn != '' + if: env.CREATE_EKS_CLUSTER == 'true' && env.INSTALL_CLUSTER_AUTOSCALER != 'false' && steps.tf_outputs.outputs.cluster_autoscaler_irsa_role_arn != '' run: | set -euo pipefail helm repo add autoscaler https://kubernetes.github.io/autoscaler @@ -679,10 +727,35 @@ jobs: run: | set -euo pipefail + storage_class_create="true" + if [ "${CREATE_STORAGE_CLASS}" = "false" ]; then + storage_class_create="false" + elif [ "${CREATE_EKS_CLUSTER}" != "true" ] && [ "${CREATE_STORAGE_CLASS}" != "true" ]; then + storage_class_create="false" + fi + + plane_storage_class_name="${PLANE_STORAGE_CLASS_NAME}" + if [ -z "${plane_storage_class_name}" ] && [ "${storage_class_create}" = "true" ]; then + plane_storage_class_name="${STORAGE_CLASS_NAME}" + fi + helm_set_args=( + --set "storageClass.create=${storage_class_create}" + --set-string "storageClass.name=${STORAGE_CLASS_NAME}" + --set "ingress.enabled=${INGRESS_ENABLED}" + --set-string "ingress.ingressClass=${INGRESS_CLASS_NAME}" --set-string "plane-ce.external_secrets.doc_store_existingSecret=${{ env.PLANE_DOCSTORE_SECRET_NAME }}" ) + if [ -n "${plane_storage_class_name}" ]; then + helm_set_args+=( + --set-string "plane-ce.postgres.storageClass=${plane_storage_class_name}" + --set-string "plane-ce.redis.storageClass=${plane_storage_class_name}" + --set-string "plane-ce.rabbitmq.storageClass=${plane_storage_class_name}" + --set-string "plane-ce.minio.storageClass=${plane_storage_class_name}" + ) + fi + if [ -n "${{ env.PLANE_APP_HOST }}" ]; then helm_set_args+=(--set-string "plane-ce.ingress.appHost=${{ env.PLANE_APP_HOST }}") fi diff --git a/README.md b/README.md index 5f35c85..7a58bbe 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ Helm installs: - AWS Load Balancer Controller-backed ALB ingress - metrics-server and Cluster Autoscaler +This dedicated-cluster path is the default. Organizations that already have a platform EKS cluster can set `CREATE_EKS_CLUSTER=false` and deploy Plane into that cluster instead. + ## Workflows - `CI`: runs on pull requests and pushes to `main`; it does not request AWS credentials or GitHub secrets. @@ -57,6 +59,28 @@ The defaults work for the standard AWS-managed path. - `HELM_RELEASE_NAME`: default `plane` - `K8S_NAMESPACE`: default `plane` - `PLANE_APP_HOST`: optional custom host; when empty, the ALB DNS name is usable after deploy +- `CREATE_EKS_CLUSTER`: default `true`; set `false` to deploy into an existing EKS cluster +- `INGRESS_CLASS_NAME`: default `alb` +- `INGRESS_ENABLED`: default `true` +- `STORAGE_CLASS_NAME`: default `gp3-csi` when this kit creates the StorageClass +- `PLANE_STORAGE_CLASS_NAME`: optional existing StorageClass name for in-cluster service PVCs + +## Existing EKS Cluster + +Use existing-cluster mode when a company wants Plane in a separate namespace on an EKS cluster it already operates, avoiding the extra EKS control-plane cost and keeping cluster autoscaling ownership with the platform team. + +Set these repository variables at minimum: + +- `CREATE_EKS_CLUSTER=false` +- `EXISTING_EKS_CLUSTER_NAME=` + +If any data service remains AWS-managed, also set: + +- `EXISTING_VPC_ID=` +- `EXISTING_PRIVATE_SUBNET_IDS_JSON=["subnet-a","subnet-b","subnet-c"]` +- `EXISTING_VPC_CIDR=` + +The workflow does not install Cluster Autoscaler, AWS Load Balancer Controller, or the wrapper StorageClass in existing-cluster mode by default. The GitHub OIDC role must already have Kubernetes RBAC in the target cluster and namespace. Set `PLANE_STORAGE_CLASS_NAME` if in-cluster services should use a specific existing StorageClass. See [docs/existing-cluster.md](docs/existing-cluster.md). ## Service Modes @@ -106,6 +130,7 @@ Open the ALB hostname unless you configured `PLANE_APP_HOST`. More detail: - [docs/proposal-to-plane.md](docs/proposal-to-plane.md) +- [docs/existing-cluster.md](docs/existing-cluster.md) - [docs/iam.md](docs/iam.md) - [docs/runbook.md](docs/runbook.md) - [.github/README-OIDC.md](.github/README-OIDC.md) diff --git a/docs/existing-cluster.md b/docs/existing-cluster.md new file mode 100644 index 0000000..c252496 --- /dev/null +++ b/docs/existing-cluster.md @@ -0,0 +1,100 @@ +# Existing EKS Cluster Mode + +By default, this kit creates a dedicated EKS cluster for Plane. That is the cleanest separation-of-concerns path and remains the backward-compatible default. + +Set `CREATE_EKS_CLUSTER=false` when an organization already has a platform EKS cluster and wants to deploy Plane into a separate namespace instead of paying for another EKS control plane. + +## What Changes + +When `CREATE_EKS_CLUSTER=false`: + +- Terraform does not create the VPC module. +- Terraform does not create the EKS cluster, managed node group, EBS CSI addon, or EKS cluster add-on IRSA roles. +- The production workflow does not install or update Cluster Autoscaler. +- The production workflow does not install or update AWS Load Balancer Controller by default. +- The Helm release does not create the wrapper `StorageClass` by default. +- The workflow does not create the Kubernetes namespace by default. +- Plane is still installed by Helm into `K8S_NAMESPACE`. +- AWS-managed data services can still be created, but they use the supplied existing VPC and private subnets. + +This keeps ownership of the shared cluster, autoscaling layer, Karpenter, node groups, and cluster-level controllers with the platform team. + +## Required Repository Variables + +Set these GitHub repository variables: + +| Variable | Required when | Example | +| --- | --- | --- | +| `CREATE_EKS_CLUSTER` | Existing cluster mode | `false` | +| `EXISTING_EKS_CLUSTER_NAME` | Existing cluster mode | `company-prod` | +| `EXISTING_VPC_ID` | Any of PostgreSQL, Redis, or RabbitMQ is `aws-managed` | `vpc-0123456789abcdef0` | +| `EXISTING_PRIVATE_SUBNET_IDS_JSON` | Any of PostgreSQL, Redis, or RabbitMQ is `aws-managed` | `["subnet-aaa","subnet-bbb","subnet-ccc"]` | +| `EXISTING_VPC_CIDR` | AWS-managed data services use default allowed CIDR blocks | `10.20.0.0/16` | +| `EXISTING_EKS_OIDC_PROVIDER_ARN` | Optional override for `OBJECT_STORE_MODE=s3-managed` | `arn:aws:iam::123456789012:oidc-provider/oidc.eks.eu-west-1.amazonaws.com/id/EXAMPLE` | + +For `OBJECT_STORE_MODE=s3-managed`, the existing cluster must already have an IAM OIDC provider associated for IRSA. Terraform derives the expected provider ARN from the cluster OIDC issuer and AWS account when `EXISTING_EKS_OIDC_PROVIDER_ARN` is empty. + +The AWS role in `AWS_GITHUB_OIDC_ROLE_ARN` must also be authorized inside the existing Kubernetes cluster. At minimum, it needs enough Kubernetes RBAC to install or upgrade the Plane Helm release in `K8S_NAMESPACE`, create/update the Plane service account, and manage the namespace-scoped objects rendered by the chart. + +## Add-On Ownership + +These variables default to `auto`: + +| Variable | Dedicated cluster default | Existing cluster default | +| --- | --- | --- | +| `INSTALL_METRICS_SERVER` | Installed | Not installed unless set to `true` | +| `INSTALL_AWS_LOAD_BALANCER_CONTROLLER` | Installed | Not managed by this workflow | +| `INSTALL_CLUSTER_AUTOSCALER` | Installed | Not managed by this workflow | +| `CREATE_K8S_NAMESPACE` | Created if missing | Not created unless set to `true` | +| `CREATE_STORAGE_CLASS` | Created | Not created unless set to `true` | +| `STORAGE_CLASS_NAME` | `gp3-csi` | Name to use if `CREATE_STORAGE_CLASS=true` | +| `PLANE_STORAGE_CLASS_NAME` | Uses `STORAGE_CLASS_NAME` when this kit creates it | Existing StorageClass name for in-cluster service PVCs | + +In existing cluster mode, the platform cluster should already provide the ingress controller and autoscaling stack it wants to use. The chart defaults to an `alb` IngressClass, so an existing AWS Load Balancer Controller installation is the lowest-friction path. + +Set `INGRESS_CLASS_NAME` if the existing cluster uses a different ingress class. Set `INGRESS_ENABLED=false` if ingress is managed separately. + +If you enable any in-cluster service mode on an existing cluster, either make sure the cluster has a default StorageClass or set `PLANE_STORAGE_CLASS_NAME` to an existing StorageClass. + +## Example: Reuse Existing EKS With AWS-Managed Data Services + +Repository variables: + +```text +CREATE_EKS_CLUSTER=false +EXISTING_EKS_CLUSTER_NAME=company-prod +EXISTING_VPC_ID=vpc-0123456789abcdef0 +EXISTING_PRIVATE_SUBNET_IDS_JSON=["subnet-aaa","subnet-bbb","subnet-ccc"] +EXISTING_VPC_CIDR=10.20.0.0/16 +K8S_NAMESPACE=plane +``` + +Keep the service mode defaults: + +```text +POSTGRES_MODE=aws-managed +REDIS_MODE=aws-managed +RABBITMQ_MODE=aws-managed +OBJECT_STORE_MODE=s3-managed +``` + +This creates RDS, ElastiCache, Amazon MQ, S3, Secrets Manager entries, and the Plane IRSA role, then installs Plane into the existing EKS cluster. + +Before running `Terraform Prod`, make sure the `plane` namespace exists or set `CREATE_K8S_NAMESPACE=true` and grant the GitHub OIDC role permission to create namespaces. + +## Example: Namespace-Only Application Deploy + +If the organization already owns the databases, Redis, RabbitMQ, object storage, ingress controller, and autoscaling, use external service modes: + +```text +CREATE_EKS_CLUSTER=false +EXISTING_EKS_CLUSTER_NAME=company-prod +K8S_NAMESPACE=plane +POSTGRES_MODE=external +REDIS_MODE=external +RABBITMQ_MODE=external +OBJECT_STORE_MODE=external-s3 +INGRESS_CLASS_NAME=alb +``` + +Then set the external service secrets documented in [service-modes.md](service-modes.md). diff --git a/docs/proposal-to-plane.md b/docs/proposal-to-plane.md index 1ace2a6..f54de07 100644 --- a/docs/proposal-to-plane.md +++ b/docs/proposal-to-plane.md @@ -12,7 +12,7 @@ The goal is to let one operator deploy Plane into their own AWS account using Gi 4. Run the bootstrap workflow. 5. Run the production workflow. -The default path provisions AWS-managed infrastructure, while advanced users can switch individual dependencies to in-cluster or external services. +The default path provisions a dedicated EKS cluster and AWS-managed infrastructure, while advanced users can deploy into an existing EKS cluster or switch individual dependencies to in-cluster or external services. ## Why This Helps Plane @@ -24,6 +24,7 @@ This kit is designed to be: - Secret conscious: the default path uses GitHub OIDC, not long-lived AWS access keys. - Reviewable: pull request CI is secret-free and deployment workflows are manual. - Configurable: PostgreSQL, Redis, RabbitMQ, and object storage can be AWS-managed, in-cluster, or external. +- Cost-aware: teams with an existing platform EKS cluster can avoid another EKS control-plane charge by deploying Plane into a dedicated namespace. - Operationally explicit: IAM, service modes, and runbook behavior are documented. ## What The Kit Deploys @@ -48,6 +49,8 @@ The production workflow then installs: - Plane CE through the wrapper Helm chart - ALB ingress, with a hostless first-deploy option +For organizations that already operate EKS, `CREATE_EKS_CLUSTER=false` skips VPC, EKS, managed node group, and autoscaler ownership. The workflow deploys Plane into the supplied cluster namespace and leaves shared cluster add-ons such as Karpenter, Cluster Autoscaler, and ingress controllers under the platform team's control. + ## Configuration Model The default deployment uses managed AWS services. Operators can override service modes with repository variables: @@ -59,6 +62,8 @@ The default deployment uses managed AWS services. Operators can override service External modes read explicitly configured GitHub secrets for existing service URLs or S3-compatible credentials. In-cluster modes use generated credentials stored in AWS Secrets Manager and injected at deploy time. +Cluster ownership is controlled separately with `CREATE_EKS_CLUSTER=true|false`. The default remains `true` for the simplest one-person deploy, while existing-cluster mode reduces duplicate infrastructure for teams that already have an EKS platform. + ## Security And Workflow Design The repository separates validation from deployment: @@ -99,6 +104,7 @@ The remaining validation needed before publishing as an official Plane-maintaine - Should this target Plane CE only, or leave room for other Plane editions later? - Should AWS-managed remain the default profile? +- Should dedicated EKS remain the default, with existing-cluster mode documented as the cost-saving enterprise path? - Are in-cluster dependency modes useful enough to keep in the first version? - Should this repository vendor the Plane chart archive or rely only on `helm dependency build` from `Chart.lock`? - What IAM boundary is acceptable for a first-deploy kit? diff --git a/docs/runbook.md b/docs/runbook.md index 5ff862e..26c2ae2 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -10,10 +10,11 @@ ``` 2. Add the printed ARN as the GitHub secret `AWS_GITHUB_OIDC_ROLE_ARN`. -3. Run `Terraform Bootstrap` with `action=apply`. -4. Run `Terraform Prod` with `action=plan`. -5. Review the plan. -6. Run `Terraform Prod` with `action=apply`. +3. Optional: if deploying into an existing EKS cluster, set the variables in [existing-cluster.md](existing-cluster.md) before planning. +4. Run `Terraform Bootstrap` with `action=apply`. +5. Run `Terraform Prod` with `action=plan`. +6. Review the plan. +7. Run `Terraform Prod` with `action=apply`. ## Find The Plane URL @@ -24,6 +25,8 @@ kubectl get ingress plane-ingress -n plane Use the ALB hostname unless `PLANE_APP_HOST` points to it through DNS. +For existing-cluster mode, replace `plane-prod-eks` with `EXISTING_EKS_CLUSTER_NAME`. + ## Update Run `Terraform Prod` with `action=plan`, review the plan, then run `action=apply`. @@ -35,6 +38,7 @@ Changing service modes can add or remove managed resources. Review plan output c - Missing `AWS_GITHUB_OIDC_ROLE_ARN`: configure the repository secret after running the OIDC helper. - Backend bucket missing: run `Terraform Bootstrap` with `action=apply`. - EKS API not reachable from GitHub-hosted runners: keep public endpoint enabled or run Actions on a network that can reach the private endpoint. +- Existing cluster variables missing: set `EXISTING_EKS_CLUSTER_NAME`, and set the existing VPC/subnet variables if any data service remains AWS-managed. - AWS quota errors: check NAT Gateway, Elastic IP, EKS, RDS, Amazon MQ, ElastiCache, and instance-type availability in the selected region. - External mode missing secret: check [service-modes.md](service-modes.md) for the required GitHub secrets. diff --git a/docs/service-modes.md b/docs/service-modes.md index bc5912d..25ed3fd 100644 --- a/docs/service-modes.md +++ b/docs/service-modes.md @@ -2,6 +2,8 @@ The default deployment uses AWS-managed dependencies. Advanced users can move one or more dependencies in-cluster or point Plane at existing external services. +Service modes are independent from cluster ownership. The default cluster path creates a dedicated EKS cluster; [existing-cluster mode](existing-cluster.md) deploys Plane into a supplied EKS cluster while keeping the same service-mode choices. + ## Mode Variables Set these as GitHub repository variables. @@ -35,6 +37,10 @@ Use this for lower-cost test environments or when you want Kubernetes StatefulSe The workflow still creates EKS and stores generated runtime credentials in AWS Secrets Manager, but it does not create RDS, ElastiCache, Amazon MQ, or the Plane S3 bucket for the services switched to in-cluster mode. +If `CREATE_EKS_CLUSTER=false`, the workflow uses the existing EKS cluster instead of creating a new one. + +In-cluster services need a working Kubernetes StorageClass. Dedicated-cluster mode uses `STORAGE_CLASS_NAME` with a default of `gp3-csi`; existing-cluster mode can use the cluster default StorageClass or an explicit `PLANE_STORAGE_CLASS_NAME`. + ## External Use this when you already run service dependencies elsewhere. diff --git a/terraform/environments/prod/main.tf b/terraform/environments/prod/main.tf index 93c5176..e0b434b 100644 --- a/terraform/environments/prod/main.tf +++ b/terraform/environments/prod/main.tf @@ -1,19 +1,67 @@ data "aws_caller_identity" "current" {} +data "aws_partition" "current" {} + +data "aws_eks_cluster" "existing" { + count = var.create_eks_cluster ? 0 : 1 + + name = var.existing_eks_cluster_name +} + locals { + cluster_name = var.create_eks_cluster ? "${var.project_name}-${var.environment}-eks" : var.existing_eks_cluster_name + active_vpc_id = var.create_eks_cluster ? module.vpc[0].vpc_id : var.existing_vpc_id + active_private_subnet_ids = var.create_eks_cluster ? module.vpc[0].private_subnet_ids : var.existing_private_subnet_ids + managed_service_vpc_cidr = var.create_eks_cluster ? var.vpc_cidr : var.existing_vpc_cidr + default_allowed_cidr_blocks = ( + local.managed_service_vpc_cidr != "" ? [local.managed_service_vpc_cidr] : [] + ) + existing_eks_oidc_issuer_url = var.create_eks_cluster ? "" : data.aws_eks_cluster.existing[0].identity[0].oidc[0].issuer + existing_eks_oidc_issuer_hostpath = replace(local.existing_eks_oidc_issuer_url, "https://", "") + existing_eks_oidc_provider_arn = ( + var.existing_eks_oidc_provider_arn != "" ? + var.existing_eks_oidc_provider_arn : + "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:oidc-provider/${local.existing_eks_oidc_issuer_hostpath}" + ) + plane_oidc_provider_arn = ( + local.use_managed_s3 ? + (var.create_eks_cluster ? module.eks[0].oidc_provider_arn : data.aws_iam_openid_connect_provider.existing[0].arn) : + null + ) + plane_oidc_issuer_hostpath = var.create_eks_cluster ? module.eks[0].oidc_issuer_hostpath : local.existing_eks_oidc_issuer_hostpath + needs_existing_vpc = ( + !var.create_eks_cluster && + (local.use_aws_postgres || local.use_aws_redis || local.use_aws_rabbitmq) + ) + needs_existing_vpc_cidr = ( + !var.create_eks_cluster && + ( + (local.use_aws_postgres && length(var.rds_allowed_cidr_blocks) == 0) || + (local.use_aws_redis && length(var.redis_allowed_cidr_blocks) == 0) || + (local.use_aws_rabbitmq && length(var.rabbitmq_allowed_cidr_blocks) == 0) + ) + ) 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}" use_aws_postgres = var.postgres_mode == "aws-managed" use_aws_redis = var.redis_mode == "aws-managed" use_aws_rabbitmq = var.rabbitmq_mode == "aws-managed" use_managed_s3 = var.object_store_mode == "s3-managed" - rds_allowed_cidr_blocks = length(var.rds_allowed_cidr_blocks) > 0 ? var.rds_allowed_cidr_blocks : [var.vpc_cidr] + rds_allowed_cidr_blocks = length(var.rds_allowed_cidr_blocks) > 0 ? var.rds_allowed_cidr_blocks : local.default_allowed_cidr_blocks rabbitmq_allowed_cidr_blocks = ( - length(var.rabbitmq_allowed_cidr_blocks) > 0 ? var.rabbitmq_allowed_cidr_blocks : [var.vpc_cidr] + length(var.rabbitmq_allowed_cidr_blocks) > 0 ? var.rabbitmq_allowed_cidr_blocks : local.default_allowed_cidr_blocks ) - redis_allowed_cidr_blocks = length(var.redis_allowed_cidr_blocks) > 0 ? var.redis_allowed_cidr_blocks : [var.vpc_cidr] + redis_allowed_cidr_blocks = length(var.redis_allowed_cidr_blocks) > 0 ? var.redis_allowed_cidr_blocks : local.default_allowed_cidr_blocks +} + +data "aws_iam_openid_connect_provider" "existing" { + count = var.create_eks_cluster || !local.use_managed_s3 ? 0 : 1 + + arn = local.existing_eks_oidc_provider_arn } module "vpc" { + count = var.create_eks_cluster ? 1 : 0 + source = "../../modules/vpc" name = "${var.project_name}-${var.environment}-vpc" @@ -21,7 +69,7 @@ module "vpc" { az_count = var.az_count public_subnet_cidrs = var.public_subnet_cidrs private_subnet_cidrs = var.private_subnet_cidrs - kubernetes_cluster_name = "${var.project_name}-${var.environment}-eks" + kubernetes_cluster_name = local.cluster_name tags = { Project = var.project_name @@ -31,18 +79,20 @@ module "vpc" { } module "eks" { + count = var.create_eks_cluster ? 1 : 0 + source = "../../modules/eks" - cluster_name = "${var.project_name}-${var.environment}-eks" + cluster_name = local.cluster_name cluster_version = var.eks_cluster_version - subnet_ids = module.vpc.private_subnet_ids + subnet_ids = module.vpc[0].private_subnet_ids endpoint_public_access = var.eks_endpoint_public_access endpoint_private_access = var.eks_endpoint_private_access endpoint_public_access_cidrs = var.eks_endpoint_public_access_cidrs bootstrap_cluster_creator_admin = var.eks_bootstrap_cluster_creator_admin cluster_admin_principal_arns = var.eks_cluster_admin_principal_arns node_group_name = var.eks_node_group_name - node_subnet_ids = module.vpc.private_subnet_ids + node_subnet_ids = module.vpc[0].private_subnet_ids node_instance_types = var.eks_node_instance_types node_capacity_type = var.eks_node_capacity_type node_desired_size = var.eks_node_desired_size @@ -70,8 +120,8 @@ module "rds_postgres" { source = "../../modules/rds-postgres" name = "${var.project_name}-${var.environment}-postgres" - vpc_id = module.vpc.vpc_id - subnet_ids = module.vpc.private_subnet_ids + vpc_id = local.active_vpc_id + subnet_ids = local.active_private_subnet_ids allowed_cidr_blocks = local.rds_allowed_cidr_blocks db_name = var.rds_db_name master_username = var.rds_master_username @@ -99,8 +149,8 @@ module "rabbitmq_amazonmq" { source = "../../modules/amazonmq-rabbitmq" name = "${var.project_name}-${var.environment}-rabbitmq" - vpc_id = module.vpc.vpc_id - subnet_ids = module.vpc.private_subnet_ids + vpc_id = local.active_vpc_id + subnet_ids = local.active_private_subnet_ids allowed_cidr_blocks = local.rabbitmq_allowed_cidr_blocks engine_version = var.rabbitmq_engine_version host_instance_type = var.rabbitmq_host_instance_type @@ -123,8 +173,8 @@ module "redis_elasticache" { source = "../../modules/elasticache-redis" name = "${var.project_name}-${var.environment}-redis" - vpc_id = module.vpc.vpc_id - subnet_ids = module.vpc.private_subnet_ids + vpc_id = local.active_vpc_id + subnet_ids = local.active_private_subnet_ids allowed_cidr_blocks = local.redis_allowed_cidr_blocks node_type = var.redis_node_type engine_version = var.redis_engine_version @@ -198,13 +248,13 @@ resource "aws_iam_role" "plane_irsa" { { Effect = "Allow" Principal = { - Federated = module.eks.oidc_provider_arn + Federated = local.plane_oidc_provider_arn } Action = "sts:AssumeRoleWithWebIdentity" 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}" + "${local.plane_oidc_issuer_hostpath}:aud" = "sts.amazonaws.com" + "${local.plane_oidc_issuer_hostpath}:sub" = "system:serviceaccount:${var.plane_namespace}:${var.plane_service_account_name}" } } } @@ -285,6 +335,26 @@ resource "aws_secretsmanager_secret" "plane_app" { name_prefix = "${var.project_name}-${var.environment}-plane-app-" recovery_window_in_days = 7 + lifecycle { + precondition { + condition = var.create_eks_cluster || var.existing_eks_cluster_name != "" + error_message = "existing_eks_cluster_name is required when create_eks_cluster is false." + } + + precondition { + condition = ( + !local.needs_existing_vpc || + (var.existing_vpc_id != "" && length(var.existing_private_subnet_ids) > 0) + ) + error_message = "existing_vpc_id and existing_private_subnet_ids are required when create_eks_cluster is false and any data service mode is aws-managed." + } + + precondition { + condition = !local.needs_existing_vpc_cidr || var.existing_vpc_cidr != "" + error_message = "existing_vpc_cidr is required when create_eks_cluster is false and aws-managed data services use default allowed CIDR blocks." + } + } + tags = { Name = "${var.project_name}-${var.environment}-plane-app" Project = var.project_name diff --git a/terraform/environments/prod/outputs.tf b/terraform/environments/prod/outputs.tf index db78eac..425c57f 100644 --- a/terraform/environments/prod/outputs.tf +++ b/terraform/environments/prod/outputs.tf @@ -1,61 +1,61 @@ output "vpc_id" { - description = "Created VPC ID" - value = module.vpc.vpc_id + description = "VPC ID used by this deployment" + value = local.active_vpc_id } output "availability_zones" { - description = "Availability zones selected for this VPC" - value = module.vpc.availability_zones + description = "Availability zones selected for the created VPC, or empty when using an existing cluster" + value = var.create_eks_cluster ? module.vpc[0].availability_zones : [] } output "public_subnet_ids" { - description = "Public subnet IDs in AZ order" - value = module.vpc.public_subnet_ids + description = "Public subnet IDs in AZ order, or empty when using an existing cluster" + value = var.create_eks_cluster ? module.vpc[0].public_subnet_ids : [] } output "private_subnet_ids" { - description = "Private subnet IDs in AZ order" - value = module.vpc.private_subnet_ids + description = "Private subnet IDs used by this deployment" + value = local.active_private_subnet_ids } output "nat_gateway_ids" { - description = "NAT gateway IDs in AZ order" - value = module.vpc.nat_gateway_ids + description = "NAT gateway IDs in AZ order, or empty when using an existing cluster" + value = var.create_eks_cluster ? module.vpc[0].nat_gateway_ids : [] } output "eks_cluster_name" { description = "EKS cluster name" - value = module.eks.cluster_name + value = local.cluster_name } output "eks_cluster_arn" { description = "EKS cluster ARN" - value = module.eks.cluster_arn + value = var.create_eks_cluster ? module.eks[0].cluster_arn : data.aws_eks_cluster.existing[0].arn } output "eks_cluster_endpoint" { description = "EKS API server endpoint" - value = module.eks.cluster_endpoint + value = var.create_eks_cluster ? module.eks[0].cluster_endpoint : data.aws_eks_cluster.existing[0].endpoint } output "eks_cluster_security_group_id" { description = "EKS cluster managed security group ID" - value = module.eks.cluster_security_group_id + value = var.create_eks_cluster ? module.eks[0].cluster_security_group_id : data.aws_eks_cluster.existing[0].vpc_config[0].cluster_security_group_id } output "eks_node_group_name" { - description = "EKS managed node group name" - value = module.eks.node_group_name + description = "EKS managed node group name created by this kit, or empty when using an existing cluster" + value = var.create_eks_cluster ? module.eks[0].node_group_name : "" } output "eks_aws_load_balancer_controller_irsa_role_arn" { - description = "IRSA role ARN for aws-load-balancer-controller" - value = module.eks.aws_load_balancer_controller_irsa_role_arn + description = "IRSA role ARN for aws-load-balancer-controller created by this kit, or null when using an existing cluster" + value = var.create_eks_cluster ? module.eks[0].aws_load_balancer_controller_irsa_role_arn : null } output "eks_cluster_autoscaler_irsa_role_arn" { - description = "IRSA role ARN for cluster-autoscaler" - value = module.eks.cluster_autoscaler_irsa_role_arn + description = "IRSA role ARN for cluster-autoscaler created by this kit, or null when using an existing cluster" + value = var.create_eks_cluster ? module.eks[0].cluster_autoscaler_irsa_role_arn : null } output "rds_instance_identifier" { diff --git a/terraform/environments/prod/variables.tf b/terraform/environments/prod/variables.tf index d0d0773..333e316 100644 --- a/terraform/environments/prod/variables.tf +++ b/terraform/environments/prod/variables.tf @@ -16,6 +16,42 @@ variable "environment" { default = "prod" } +variable "create_eks_cluster" { + description = "Create a dedicated EKS cluster and VPC for Plane. Set false to deploy into an existing EKS cluster." + type = bool + default = true +} + +variable "existing_eks_cluster_name" { + description = "Existing EKS cluster name used when create_eks_cluster is false." + type = string + default = "" +} + +variable "existing_vpc_id" { + description = "Existing VPC ID used for AWS-managed data services when create_eks_cluster is false." + type = string + default = "" +} + +variable "existing_private_subnet_ids" { + description = "Existing private subnet IDs used for AWS-managed data services when create_eks_cluster is false." + type = list(string) + default = [] +} + +variable "existing_vpc_cidr" { + description = "Existing VPC CIDR used as the default allowed CIDR for AWS-managed data services when create_eks_cluster is false." + type = string + default = "" +} + +variable "existing_eks_oidc_provider_arn" { + description = "Optional IAM OIDC provider ARN for the existing EKS cluster. When empty, Terraform derives it from the cluster OIDC issuer and account ID." + type = string + default = "" +} + variable "postgres_mode" { description = "PostgreSQL deployment mode: aws-managed creates RDS, in-cluster uses the Plane chart PostgreSQL, external expects PLANE_PGDB_REMOTE_URL." type = string