diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f56aa1b --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Local script defaults for direct merge testing. +# The composite action normally receives these values from action inputs and +# udx/rabbit-lifecycle outputs. + +INPUT_SOURCE_DIR=.rabbit +INPUT_ENV_NAME=development +INPUT_LIFECYCLE=development +INPUT_IS_PROTECTED=false +INPUT_RESOLUTION_REASON=local-example +INPUT_OUTPUT_FORMAT=yaml +INPUT_RECURSIVE=true +INPUT_FILE_PATTERNS=*.yml,*.yaml +INPUT_EXCLUDE=**/merged*.yml,**/merged*.yaml +INPUT_DEBUG=false diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c75e875 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2c41dc5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: ci + +on: + pull_request: + push: + branches: + - production + - lifecycle-action-integration + +permissions: + contents: read + +jobs: + validate: + name: validate action contract + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install pinned yq + shell: bash + run: | + set -euo pipefail + version="v4.44.3" + sha256="a2c097180dd884a8d50c956ee16a9cec070f30a7947cf4ebf87d5f36213e9ed7" + install_dir="${RUNNER_TEMP}/rabbit-action-bin" + mkdir -p "$install_dir" + curl -fsSL "https://github.com/mikefarah/yq/releases/download/${version}/yq_linux_amd64" -o "$install_dir/yq" + printf '%s %s\n' "$sha256" "$install_dir/yq" | sha256sum --check --status + chmod +x "$install_dir/yq" + echo "$install_dir" >> "$GITHUB_PATH" + + - name: Run validation + shell: bash + run: make test + + - name: Refresh repo context + shell: bash + run: npx -y @udx/dev-kit@0.12.0 repo diff --git a/.rabbit/README.md b/.rabbit/README.md new file mode 100644 index 0000000..34aa0ed --- /dev/null +++ b/.rabbit/README.md @@ -0,0 +1,7 @@ +# Rabbit Context + +This directory keeps Rabbit-facing repo context and generated `dev.kit` evidence. + +- `context.yaml` is generated by `dev.kit repo`; do not edit it manually. +- Keep human-authored Rabbit CI notes, delivery entry points, and repo-specific workflow guidance in this README. +- After changing repo docs, manifests, scripts, or workflows, run `dev.kit repo` to refresh generated context. diff --git a/.rabbit/context.yaml b/.rabbit/context.yaml new file mode 100644 index 0000000..4828734 --- /dev/null +++ b/.rabbit/context.yaml @@ -0,0 +1,76 @@ +# Generated by dev.kit repo — do not edit manually. +# Run `dev.kit repo` to refresh. +kind: repoContext +version: udx.dev/dev.kit/v1 +generator: + tool: dev.kit + repo: https://github.com/udx/dev.kit + version: 0.20.1 + generated_at: 2026-07-29T14:41:16Z + sources: + homepage: https://udx.dev/kit + repository: https://github.com/udx/dev.kit + package: https://www.npmjs.com/package/@udx/dev-kit + installation: https://github.com/udx/dev.kit/blob/latest/docs/installation.md + +repo: + name: github-rabbit-action + archetype: manifest-repo + +# Refs — Direct-read files and paths that define the repo contract. +# Note: Include only files or directories a repo consumer should read before code exploration. +# Note: Prefer README, focused docs, workflows, manifests, and explicit operational files. +# Note: Exclude broad implementation directories unless they are the contract themselves. + +refs: + - ./README.md + - ./Makefile + - ./docs/configuration.md + - ./docs/validation.md + - ./src/configs/lifecycle-policy.yaml + - ./action.yml + - ./.github/workflows + - ./docs + +# Commands — Canonical repo entrypoints detected from strong repo signals. +# Note: Prefer declared make targets and package scripts before regex matches in docs. +# Note: Emit only commands that can be traced to a concrete source. +# Note: Record the source path so the command can be reviewed and corrected. + +commands: + verify: + run: make test + source: Makefile + +# Dependencies — Meaningful dependency-repo contracts such as reusable workflows, images, or versioned manifests this repo relies on. +# Note: Capture execution-shaping behavior defined outside the current checkout. +# Note: Avoid promoting standard package inventory or ordinary GitHub action refs into top-level context. +# Note: Normalize same-org versioned refs into repo slugs when possible. + +dependencies: + - repo: udx/rabbit-infra-config + kind: manifest contract (v1) + resolved: false + declared_as: udx.dev/rabbit-infra-config/v1 + used_by: + - src/configs/lifecycle-policy.yaml + +# Manifests — YAML files that define repo-specific workflow, deploy, or contract behavior. +# Note: Include custom config/manifests that materially shape repo behavior or contract understanding. +# Note: Do not include workflow YAML only because it lives under .github/workflows. +# Note: Promote workflow files only when they declare reusable workflow refs or other repo-specific execution contracts. +# Note: Prefer structured kind and description metadata from the manifest itself. +# Note: Include hidden or nested contract dirs when they contain repo-owned manifests with meaningful metadata. + +manifests: + - path: src/configs/lifecycle-policy.yaml + kind: rabbitConfigLayout + declared_as: udx.dev/rabbit-infra-config/v1 + source_repo: udx/rabbit-infra-config + used_by: + - bin/lib/config.sh + - docs/configuration.md + evidence: + - version: udx.dev/rabbit-infra-config/v1 + - path reference: bin/lib/config.sh + - path reference: docs/configuration.md diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7c6c8d5 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +.PHONY: test validate-shell validate-action validate-workflow + +test: validate-shell validate-action validate-workflow + tests/run-merge-tests.sh + +validate-shell: + bash -n \ + bin/merge-configs.sh \ + bin/resolve-lifecycle.sh \ + bin/lib/config.sh \ + bin/lib/github.sh \ + bin/lib/lifecycle.sh \ + bin/lib/validation.sh \ + bin/lib/environment.sh \ + tests/run-merge-tests.sh + +validate-action: + yq eval '.' action.yml >/dev/null + +validate-workflow: + yq eval '.' .github/workflows/ci.yml >/dev/null diff --git a/README.md b/README.md index 98ef1f3..d78069c 100644 --- a/README.md +++ b/README.md @@ -50,15 +50,22 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + + - uses: google-github-actions/auth@v3 + with: + workload_identity_provider: ${{ vars.GCP_AUTH_PROVIDER }} + service_account: ${{ vars.GCP_SERVICE_ACCOUNT }} + + - uses: aws-actions/configure-aws-credentials@v6 + if: vars.AWS_REGION != '' + with: + role-to-assume: ${{ secrets.AWS_GITHUB_ACTIONS_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} - uses: udx/github-rabbit-action@v1 with: project_id: ${{ vars.GCP_PROJECT_ID }} - gcp_auth_provider: ${{ vars.GCP_AUTH_PROVIDER }} - gcp_service_account: ${{ vars.GCP_SERVICE_ACCOUNT }} - aws_region: ${{ vars.AWS_REGION }} - aws_role_arn: ${{ secrets.AWS_GITHUB_ACTIONS_ROLE_ARN }} slack_webhook: ${{ secrets.SLACK_WEBHOOK_ROUTINE }} dockerhub_username: ${{ vars.DOCKERHUB_USER_LOGIN }} dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN_PULL_R2A }} @@ -132,31 +139,29 @@ services: │ GitHub Action Trigger (push / PR / delete / manual) │ └──────────────────────┬──────────────────────────────┘ │ - ┌─────────────▼──────────────┐ - │ 1. Merge Configs │ - │ Discover .rabbit/ YAML │ - │ Resolve lifecycle │ - │ Deep merge by module::id │ + ┌─────────────▼──────────────┐ + │ 1. Resolve Lifecycle │ + │ In-repo branch/env │ + │ policy and protection │ + └─────────────┬──────────────┘ + │ + ┌─────────────▼──────────────┐ + │ 2. Merge Configs │ + │ Discover .rabbit/ YAML │ + │ Deep merge by module::id │ └─────────────┬──────────────┘ │ ┌─────────────▼──────────────┐ - │ 2. Safety Checks │ + │ 3. Safety Checks │ │ Block production manual │ │ Block production destroy │ │ Auto plan-only for PRs │ └─────────────┬──────────────┘ │ ┌─────────────▼──────────────┐ - │ 3. Cloud Auth │ - │ GCP Workload Identity │ - │ AWS OIDC (optional) │ - └─────────────┬──────────────┘ - │ - ┌─────────────▼──────────────┐ │ 4. Terraform Engine │ - │ Docker: r2a container │ - │ Per-service init/plan/ │ - │ apply in deploy order │ + │ Caller credentials │ + │ passed into R2A │ └─────────────┬──────────────┘ │ ┌─────────────▼──────────────┐ @@ -170,7 +175,7 @@ services: ### Environment Detection -The environment is automatically resolved from: +The environment is automatically resolved from the workflow event, then resolved against this action's lifecycle policy: | Trigger | Environment Source | | --- | --- | @@ -201,7 +206,10 @@ Infrastructure configs live in `.rabbit/` directories organized by lifecycle: - Files are sorted by name (`10-infra.yaml` before `20-monitoring.yaml`) - Services with the same `module::id` are deep-merged across files -- Root-level files in `.rabbit/` are ignored (must be in a lifecycle directory) +- Root-level files in the configured `source_dir` are ignored (must be in a lifecycle directory) +- Only direct lifecycle roots under `source_dir` are eligible; use `source_dir: .rabbit/infra_configs` for nested config roots + +See [docs/configuration.md](docs/configuration.md) for the repo-owned Rabbit config layout and merge contract. ### Plan Mode @@ -449,15 +457,19 @@ The workflow dispatch inputs provide safe manual control: --- +## Authentication and state ownership + +The caller workflow owns cloud authentication. Authenticate with Google Cloud before invoking the action; the action mounts the resulting `GOOGLE_APPLICATION_CREDENTIALS` file read-only into the R2A container and never copies it into the workspace. Configure AWS credentials in the caller when the configuration uses AWS; the action forwards the resulting AWS session variables to R2A. + +The optional state-backend inputs are passed through to the IaC engine. Omit them to retain its existing GCS default; provide the backend type, configuration, and state-path key only when the selected backend requires an override. + +--- + ## Inputs Reference | Input | Required | Default | Description | | --- | --- | --- | --- | -| `project_id` | ✅ | — | GCP project ID | -| `gcp_auth_provider` | ✅ | — | GCP Workload Identity Provider | -| `gcp_service_account` | ✅ | — | GCP Service Account email | -| `aws_role_arn` | — | — | AWS IAM OIDC role ARN | -| `aws_region` | — | — | AWS region | +| `project_id` | ✅ | — | Project identifier for state and resource operations | | `dockerhub_username` | — | — | Docker Hub username | | `dockerhub_token` | — | — | Docker Hub pull token | | `dockerhub_helm_token` | — | — | Docker Hub Helm OCI token | @@ -472,8 +484,12 @@ The workflow dispatch inputs provide safe manual control: | `newrelic_account_id` | — | — | New Relic account ID | | `newrelic_api_key` | — | — | New Relic API key | | `slack_webhook` | — | — | Slack webhook URL | +| `state_backend` | — | container default (GCS) | Optional backend override, such as `s3`, `azurerm`, `http`, or `consul` | +| `state_backend_config` | — | — | Backend configuration as `key=value` lines | +| `state_prefix_key` | — | — | Backend configuration key for the state path, such as `prefix` or `key` | | `source_dir` | — | `.rabbit` | Config source directory | -| `github_token` | — | `github.token` | GitHub token for PR comments | +| `lifecycle_policy_path` | — | bundled policy | Optional caller-repository lifecycle policy shared by resolution and config merging | +| `github_token` | — | `github.token` | GitHub token passed to lifecycle resolution and used for PR comments | ## Outputs @@ -481,6 +497,9 @@ The workflow dispatch inputs provide safe manual control: | --- | --- | | `environment` | Resolved environment name | | `lifecycle` | Resolved lifecycle (production/staging/development) | +| `is_protected` | Whether GitHub reported the environment branch as protected | +| `resolution_reason` | Lifecycle rule that selected the lifecycle | +| `lifecycle_policy_path` | Lifecycle policy used for resolution and config merging | | `plan_only` | Whether run was plan-only | | `terraform_action` | Action executed (apply/destroy/skip) | | `has_changes` | Whether Terraform detected changes | @@ -528,9 +547,14 @@ Notifications include environment, change counts, failure stage, and a link to t - **Pin `r2a_version`** to a specific tag for reproducible deploys (e.g., `4.8.0` instead of `latest`) - **Name files with numeric prefixes** (`10-dns.yaml`, `20-cdn.yaml`, `30-app.yaml`) for deterministic ordering - **Use `#{Environment}` placeholders** in service IDs to keep configs environment-aware +- **Set `source_dir` explicitly** when configs live below `.rabbit/infra_configs` or another nested root - **Schedule nightly runs** (`cron: "0 2 * * *"`) to detect infrastructure drift - **Keep `.rabbit/` configs small and focused** — one concern per file +## Development + +The local validation contract is documented in [docs/validation.md](docs/validation.md). Run `make test` and `dev.kit repo` before updating a PR. + --- ## License diff --git a/action.yml b/action.yml index 8301afc..de871fa 100644 --- a/action.yml +++ b/action.yml @@ -9,23 +9,8 @@ branding: inputs: # Required project_id: - description: "GCP project ID for Terraform state and resource operations" + description: "Project identifier for Terraform state and resource operations" required: true - gcp_auth_provider: - description: "GCP Workload Identity Federation provider resource name" - required: true - gcp_service_account: - description: "GCP service account email for Workload Identity" - required: true - - # Auth - aws_role_arn: - description: "AWS IAM OIDC role ARN for Route53, CloudFront, WAF, ACM" - required: false - aws_region: - description: "AWS region" - required: false - # Docker dockerhub_username: description: "Docker Hub username for pulling R2A image" @@ -82,13 +67,27 @@ inputs: description: "Slack incoming webhook URL for deployment notifications" required: false + # State backend + state_backend: + description: "Optional state backend override passed to the IaC engine (for example s3, azurerm, http, or consul); omit to use the container default (GCS)" + required: false + state_backend_config: + description: "Optional backend configuration as key=value lines" + required: false + state_prefix_key: + description: "Optional backend configuration key for the state path (for example prefix or key)" + required: false + # Config source_dir: description: "Source directory for .rabbit configs" required: false default: ".rabbit" + lifecycle_policy_path: + description: "Optional caller-repository lifecycle policy path; defaults to the bundled policy" + required: false github_token: - description: "GitHub token for branch protection detection and PR comments" + description: "GitHub token passed to lifecycle resolution and used for PR comments" required: false default: ${{ github.token }} @@ -98,7 +97,16 @@ outputs: value: ${{ steps.resolve-env.outputs.environment }} lifecycle: description: "Resolved lifecycle (production, staging, development)" - value: ${{ steps.merge-configs.outputs.lifecycle }} + value: ${{ steps.resolve-env.outputs.lifecycle }} + is_protected: + description: "Whether GitHub reported the resolved environment branch as protected" + value: ${{ steps.resolve-lifecycle.outputs.is_protected }} + resolution_reason: + description: "Lifecycle rule that selected the lifecycle" + value: ${{ steps.resolve-lifecycle.outputs.resolution_reason }} + lifecycle_policy_path: + description: "Lifecycle policy path used for resolution and config merging" + value: ${{ steps.resolve-lifecycle.outputs.lifecycle_policy_path }} plan_only: description: "Whether the run was plan-only" value: ${{ steps.plan-mode.outputs.plan_only }} @@ -125,29 +133,55 @@ runs: using: "composite" steps: # ========================================================================= - # 1. Install yq + # 1. Install pinned yq # ========================================================================= - - name: Install yq + - name: Install pinned yq shell: bash run: | set -euo pipefail - if ! command -v yq >/dev/null 2>&1; then - echo "Installing yq..." - curl -sSL "https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64" -o /usr/local/bin/yq - chmod +x /usr/local/bin/yq - fi + version="v4.44.3" + sha256="a2c097180dd884a8d50c956ee16a9cec070f30a7947cf4ebf87d5f36213e9ed7" + install_dir="${RUNNER_TEMP:-/tmp}/rabbit-action-bin" + mkdir -p "$install_dir" + + url="https://github.com/mikefarah/yq/releases/download/${version}/yq_linux_amd64" + curl -fsSL "$url" -o "$install_dir/yq" + printf '%s %s\n' "$sha256" "$install_dir/yq" | sha256sum --check --status + chmod +x "$install_dir/yq" + echo "$install_dir" >> "$GITHUB_PATH" + export PATH="$install_dir:$PATH" yq --version # ========================================================================= - # 2. Merge infrastructure configs + # 2. Resolve Rabbit lifecycle # ========================================================================= - - name: Merge infrastructure configs - id: merge-configs + - name: Resolve Rabbit lifecycle + id: resolve-lifecycle shell: bash env: INPUT_SOURCE_DIR: ${{ inputs.source_dir }} + INPUT_LIFECYCLE_POLICY_PATH: ${{ inputs.lifecycle_policy_path }} INPUT_ENV_NAME: ${{ inputs.environment || (github.event_name == 'pull_request' && github.base_ref) || (github.event_name == 'delete' && github.event.ref) || github.ref_name }} GITHUB_TOKEN: ${{ inputs.github_token }} + ENABLE_ANNOTATIONS: "false" + INPUT_DEBUG: "false" + run: | + set -euo pipefail + bash "${GITHUB_ACTION_PATH}/bin/resolve-lifecycle.sh" + + # ========================================================================= + # 3. Merge infrastructure configs + # ========================================================================= + - name: Merge infrastructure configs + id: merge-configs + shell: bash + env: + INPUT_SOURCE_DIR: ${{ inputs.source_dir }} + INPUT_LIFECYCLE_POLICY_PATH: ${{ inputs.lifecycle_policy_path }} + INPUT_ENV_NAME: ${{ steps.resolve-lifecycle.outputs.environment }} + INPUT_LIFECYCLE: ${{ steps.resolve-lifecycle.outputs.lifecycle }} + INPUT_IS_PROTECTED: ${{ steps.resolve-lifecycle.outputs.is_protected }} + INPUT_RESOLUTION_REASON: ${{ steps.resolve-lifecycle.outputs.resolution_reason }} INPUT_EXCLUDE: "**/merged*.yml,**/merged*.yaml" INPUT_RECURSIVE: "true" INPUT_FILE_PATTERNS: "*.yml,*.yaml" @@ -160,7 +194,7 @@ runs: bash "${GITHUB_ACTION_PATH}/bin/merge-configs.sh" # ========================================================================= - # 3. Resolve environment + # 4. Resolve environment # ========================================================================= - name: Resolve environment id: resolve-env @@ -169,13 +203,15 @@ runs: MERGED_ENV: ${{ steps.merge-configs.outputs.environment }} MERGED_LIFECYCLE: ${{ steps.merge-configs.outputs.lifecycle }} MERGED_CONFIG_PATH: ${{ steps.merge-configs.outputs.merged_config }} + RESOLVED_ENV: ${{ steps.resolve-lifecycle.outputs.environment }} + RESOLVED_LIFECYCLE: ${{ steps.resolve-lifecycle.outputs.lifecycle }} EVENT_NAME: ${{ github.event_name }} EVENT_REF_TYPE: ${{ github.event.ref_type }} REQUESTED_ENV: ${{ inputs.environment || (github.event_name == 'pull_request' && github.base_ref) || (github.event_name == 'delete' && github.event.ref) || github.ref_name }} run: | set -euo pipefail - environment="$MERGED_ENV" - lifecycle="$MERGED_LIFECYCLE" + environment="${RESOLVED_ENV:-$MERGED_ENV}" + lifecycle="${RESOLVED_LIFECYCLE:-$MERGED_LIFECYCLE}" if [[ "$EVENT_NAME" == "delete" && "$EVENT_REF_TYPE" == "branch" && -n "$REQUESTED_ENV" && "$environment" != "$REQUESTED_ENV" ]]; then echo "::error title=❌ Environment Resolution Mismatch::Delete requested '$REQUESTED_ENV' but resolved '$environment'. Aborting to prevent wrong-environment destroy." @@ -191,7 +227,7 @@ runs: echo "Config path: $MERGED_CONFIG_PATH" # ========================================================================= - # 4. Determine plan mode + # 5. Determine plan mode # ========================================================================= - name: Determine plan mode id: plan-mode @@ -223,7 +259,7 @@ runs: fi # ========================================================================= - # 5. Determine terraform action + # 6. Determine terraform action # ========================================================================= - name: Determine terraform action id: terraform-action @@ -253,13 +289,14 @@ runs: echo "Terraform action: $terraform_action" # ========================================================================= - # 6. Safety checks + # 7. Safety checks # ========================================================================= - name: Safety checks id: safety shell: bash env: LIFECYCLE: ${{ steps.resolve-env.outputs.lifecycle }} + LIFECYCLE_POLICY_PATH: ${{ steps.resolve-lifecycle.outputs.lifecycle_policy_path }} PLAN_ONLY: ${{ steps.plan-mode.outputs.plan_only }} TERRAFORM_ACTION: ${{ steps.terraform-action.outputs.terraform_action }} EVENT_NAME: ${{ github.event_name }} @@ -289,7 +326,7 @@ runs: echo "should_deploy=true" >> "$GITHUB_OUTPUT" # ========================================================================= - # 7. Resolve deployment metadata + # 8. Resolve deployment metadata # ========================================================================= - name: Resolve deployment metadata id: deployment-metadata @@ -305,7 +342,7 @@ runs: echo "K8s namespace: $namespace" # ========================================================================= - # 8. Detect config services for summary + # 9. Detect config services for summary # ========================================================================= - name: Detect configured services id: detect-services @@ -345,7 +382,7 @@ runs: echo "detected_configs=$detected_configs" >> "$GITHUB_OUTPUT" # ========================================================================= - # 9. Write configuration summary + # 10. Write configuration summary # ========================================================================= - name: Write configuration summary if: always() @@ -361,9 +398,13 @@ runs: TRIGGER: ${{ github.event_name }} R2A_VERSION: ${{ inputs.r2a_version }} MULTI_REPO: ${{ inputs.multi_repo }} - HAS_AWS: ${{ inputs.aws_role_arn != '' && inputs.aws_region != '' }} + LIFECYCLE_POLICY_PATH: ${{ steps.resolve-lifecycle.outputs.lifecycle_policy_path }} run: | config_items="$(printf '%s' "${DETECTED_CONFIGS:-none}" | sed "s/#{Environment}/$ENV_NAME/g" | tr ',' '\n' | sed 's/^ *//; s/ *$//' | sed '/^$/d')" + aws_credentials_available=false + if [ -n "${AWS_ACCESS_KEY_ID:-}" ]; then + aws_credentials_available=true + fi { echo "## Configuration Summary" @@ -377,9 +418,10 @@ runs: echo "| Field | Value |" echo "| --- | --- |" echo "| Lifecycle | \`${LIFECYCLE:-unknown}\` |" + echo "| Lifecycle Policy | \`${LIFECYCLE_POLICY_PATH:-bundled}\` |" echo "| Plan Only | \`${PLAN_ONLY:-true}\` |" echo "| Multi Repo | \`${MULTI_REPO:-false}\` |" - echo "| AWS Enabled | \`${HAS_AWS}\` |" + echo "| AWS runner credentials | \`${aws_credentials_available}\` |" echo "" echo "### Detected Configs" echo "" @@ -401,28 +443,7 @@ runs: } >> "$GITHUB_STEP_SUMMARY" # ========================================================================= - # 10. Authenticate with Google Cloud - # ========================================================================= - - name: Authenticate with Google Cloud - id: gcp-auth - if: steps.safety.outputs.should_deploy == 'true' - uses: google-github-actions/auth@v3 - with: - workload_identity_provider: ${{ inputs.gcp_auth_provider }} - service_account: ${{ inputs.gcp_service_account }} - - # ========================================================================= - # 11. Configure AWS credentials (optional) - # ========================================================================= - - name: Configure AWS credentials - if: steps.safety.outputs.should_deploy == 'true' && inputs.aws_role_arn != '' && inputs.aws_region != '' - uses: aws-actions/configure-aws-credentials@v6 - with: - role-to-assume: ${{ inputs.aws_role_arn }} - aws-region: ${{ inputs.aws_region }} - - # ========================================================================= - # 12. Prepare artifacts directory + # 11. Prepare artifacts directory # ========================================================================= - name: Prepare artifacts directory if: steps.safety.outputs.should_deploy == 'true' @@ -432,22 +453,31 @@ runs: chmod -R 777 terraform/plans # ========================================================================= - # 13. Prepare GCP credentials for Docker volume mount + # 12. Resolve caller-provided GCP credentials for a read-only Docker mount # ========================================================================= - - name: Prepare Workload Identity credentials + - name: Resolve GCP credentials id: prepare-creds if: steps.safety.outputs.should_deploy == 'true' shell: bash - env: - CRED_FILE_PATH: ${{ steps.gcp-auth.outputs.credentials_file_path }} run: | set -euo pipefail - cp "$CRED_FILE_PATH" gcp-credentials.json - chmod 644 gcp-credentials.json - echo "credentials_path=$(pwd)/gcp-credentials.json" >> "$GITHUB_OUTPUT" + + credentials_path="${GOOGLE_APPLICATION_CREDENTIALS:-}" + if [[ -z "$credentials_path" ]]; then + echo "::error title=GCP credentials unavailable::Authenticate with Google Cloud before invoking github-rabbit-action." + exit 1 + fi + + if [[ ! -f "$credentials_path" ]]; then + echo "::error title=GCP credentials unavailable::GOOGLE_APPLICATION_CREDENTIALS does not point to a readable file." + exit 1 + fi + + echo "gcp_credentials_available=true" >> "$GITHUB_OUTPUT" + echo "credentials_path=$credentials_path" >> "$GITHUB_OUTPUT" # ========================================================================= - # 14. Docker login and pull R2A image + # 13. Docker login and pull R2A image # ========================================================================= - name: Docker login and pull R2A image if: steps.safety.outputs.should_deploy == 'true' @@ -470,7 +500,7 @@ runs: echo "✓ Image pulled successfully" # ========================================================================= - # 15. Run Rabbit Automation Action (Terraform engine) + # 14. Run Rabbit Automation Action (Terraform engine) # ========================================================================= - name: Run Rabbit Automation Action id: terraform @@ -494,6 +524,11 @@ runs: DOCKERHUB_USERNAME: ${{ inputs.dockerhub_username }} DOCKERHUB_TOKEN: ${{ inputs.dockerhub_helm_token || inputs.dockerhub_token }} SHARED_PROJECT: ${{ inputs.shared_project }} + STATE_BACKEND: ${{ inputs.state_backend }} + STATE_BACKEND_CONFIG: ${{ inputs.state_backend_config }} + STATE_PREFIX_KEY: ${{ inputs.state_prefix_key }} + GCP_CREDENTIALS_AVAILABLE: ${{ steps.prepare-creds.outputs.gcp_credentials_available }} + GCP_CREDENTIALS_PATH: ${{ steps.prepare-creds.outputs.credentials_path }} REPO_OWNER: ${{ github.repository_owner }} REPO_NAME: ${{ github.event.repository.name }} run: | @@ -501,18 +536,22 @@ runs: IMAGE="usabilitydynamics/rabbit-automation-action:${R2A_VERSION}" ARTIFACTS_PATH="terraform/plans" - # Build AWS env vars (may be empty if AWS not configured) + # Forward AWS credentials configured by the caller workflow. aws_env=() if [[ -n "${AWS_ACCESS_KEY_ID:-}" ]]; then aws_env+=(-e "AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}") - aws_env+=(-e "AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}") - aws_env+=(-e "AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN}") + aws_env+=(-e "AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}") + aws_env+=(-e "AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN:-}") aws_env+=(-e "AWS_REGION=${AWS_REGION:-}") - else - aws_env+=(-e "AWS_ACCESS_KEY_ID=") - aws_env+=(-e "AWS_SECRET_ACCESS_KEY=") - aws_env+=(-e "AWS_SESSION_TOKEN=") - aws_env+=(-e "AWS_REGION=") + fi + + gcp_mount=() + if [[ "$GCP_CREDENTIALS_AVAILABLE" == "true" ]]; then + if [[ ! -f "$GCP_CREDENTIALS_PATH" ]]; then + echo "::error title=GCP credentials unavailable::Resolved credentials file is no longer readable." + exit 1 + fi + gcp_mount=(-v "$GCP_CREDENTIALS_PATH:/tmp/gcp-credentials.json:ro" -e "GOOGLE_APPLICATION_CREDENTIALS=/tmp/gcp-credentials.json") fi echo "🐰 Running Rabbit Automation Action..." @@ -537,7 +576,10 @@ runs: -e "DOCKERHUB_USERNAME=${DOCKERHUB_USERNAME:-}" \ -e "DOCKERHUB_TOKEN=${DOCKERHUB_TOKEN:-}" \ -e "SHARED_PROJECT=${SHARED_PROJECT:-}" \ - -e "GOOGLE_APPLICATION_CREDENTIALS=/github/workspace/gcp-credentials.json" \ + -e "STATE_BACKEND=${STATE_BACKEND:-}" \ + -e "STATE_BACKEND_CONFIG=${STATE_BACKEND_CONFIG:-}" \ + -e "STATE_PREFIX_KEY=${STATE_PREFIX_KEY:-}" \ + ${gcp_mount[@]+"${gcp_mount[@]}"} \ "${aws_env[@]}" \ "$IMAGE" \ /usr/local/bin/entrypoint.sh @@ -564,19 +606,19 @@ runs: echo "aws_cloudfront_distribution_id=${cloudfront_distribution_id}" >> "$GITHUB_OUTPUT" # ========================================================================= - # 16. Upload terraform artifacts + # 18. Upload terraform artifacts # ========================================================================= - name: Upload terraform artifacts if: steps.safety.outputs.should_deploy == 'true' continue-on-error: true - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: terraform-artifacts-${{ steps.resolve-env.outputs.environment }} path: terraform/plans if-no-files-found: warn # ========================================================================= - # 17. Render plan summary + # 19. Render plan summary # ========================================================================= - name: Render plan summary id: plan-summary @@ -625,7 +667,7 @@ runs: bash "${GITHUB_ACTION_PATH}/bin/render-plan-summary.sh" "$summary_file" "$ARTIFACTS_PATH/plan-summary.md" # ========================================================================= - # 18. Write deployment summary + # 20. Write deployment summary # ========================================================================= - name: Write deployment summary if: always() && steps.safety.outputs.should_deploy == 'true' @@ -669,7 +711,7 @@ runs: fi # ========================================================================= - # 19. Post PR comment with plan summary + # 21. Post PR comment with plan summary # ========================================================================= - name: Post PR comment with plan summary if: github.event_name == 'pull_request' && steps.plan-summary.outputs.has_changes == 'true' @@ -730,19 +772,19 @@ runs: } # ========================================================================= - # 20. Upload terraform plan files + # 22. Upload terraform plan files # ========================================================================= - name: Upload terraform plans if: steps.plan-summary.outputs.has_changes == 'true' continue-on-error: true - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: terraform-plans-${{ steps.resolve-env.outputs.environment }} path: terraform/plans/*.tfplan if-no-files-found: ignore # ========================================================================= - # 21. Send Slack notification + # 23. Send Slack notification # ========================================================================= - name: Send Slack notification if: always() && inputs.slack_webhook != '' && (steps.plan-summary.outputs.has_changes == 'true' || steps.terraform.outcome == 'failure' || steps.safety.outcome == 'failure') diff --git a/bin/lib/config.sh b/bin/lib/config.sh index 0bc06d8..fd70482 100755 --- a/bin/lib/config.sh +++ b/bin/lib/config.sh @@ -8,7 +8,22 @@ fi LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$LIB_DIR/../.." && pwd)" -LIFECYCLE_POLICY_FILE="${LIFECYCLE_POLICY_FILE:-$PROJECT_ROOT/src/configs/lifecycle-policy.yaml}" +BUNDLED_LIFECYCLE_POLICY_FILE="$PROJECT_ROOT/src/configs/lifecycle-policy.yaml" + +# A caller may provide a policy relative to the checked-out repository. The +# same resolved file is used by lifecycle resolution and config merging. +if [[ -z "${LIFECYCLE_POLICY_FILE:-}" ]]; then + if [[ -n "${INPUT_LIFECYCLE_POLICY_PATH:-}" ]]; then + if [[ "$INPUT_LIFECYCLE_POLICY_PATH" == /* ]]; then + LIFECYCLE_POLICY_FILE="$INPUT_LIFECYCLE_POLICY_PATH" + else + LIFECYCLE_POLICY_FILE="${GITHUB_WORKSPACE:-$PWD}/$INPUT_LIFECYCLE_POLICY_PATH" + fi + else + LIFECYCLE_POLICY_FILE="$BUNDLED_LIFECYCLE_POLICY_FILE" + fi +fi +export LIFECYCLE_POLICY_FILE _yaml_get_or_default() { local query="$1" @@ -54,7 +69,7 @@ export NO_ENVIRONMENT_VALUE="${INPUT_NO_ENVIRONMENT_VALUE:-none}" # Discovery configuration (can be overridden via GitHub Action inputs) export SKIP_DIRECTORIES="${INPUT_SKIP_DIRECTORIES:-node_modules,vendor}" -# Lifecycle configuration (can be overridden via GitHub Action inputs) +# Rabbit config layout (can be overridden via GitHub Action inputs) DEFAULT_LIFECYCLE_PRODUCTION="production" DEFAULT_LIFECYCLE_STAGING="staging" DEFAULT_LIFECYCLE_DEVELOPMENT="development" @@ -80,8 +95,11 @@ export SUBDIR_PREFERRED_LIFECYCLE="${INPUT_SUBDIR_PREFERRED_LIFECYCLE:-$DEFAULT_ export PROTECTED_BRANCH_LIFECYCLE="${INPUT_PROTECTED_BRANCH_LIFECYCLE:-$DEFAULT_PROTECTED_BRANCH_LIFECYCLE}" export FALLBACK_LIFECYCLE="${INPUT_FALLBACK_LIFECYCLE:-$DEFAULT_FALLBACK_LIFECYCLE}" -# Exported lifecycle (set in main function) -export LIFECYCLE="" +# Exported lifecycle metadata. The composite action resolves these in-repo; +# local script runs retain the compatibility fallback when no value is supplied. +export LIFECYCLE="${INPUT_LIFECYCLE:-}" +export IS_PROTECTED="${INPUT_IS_PROTECTED:-}" +export RESOLUTION_REASON="${INPUT_RESOLUTION_REASON:-}" # ============================================================================ # INPUT PARAMETERS @@ -95,6 +113,7 @@ export RECURSIVE="${INPUT_RECURSIVE:-true}" export FILE_PATTERNS="${INPUT_FILE_PATTERNS:-*.yml,*.yaml}" export OUTPUT_FORMAT="${INPUT_OUTPUT_FORMAT:-yaml}" export DEBUG="${INPUT_DEBUG:-false}" +export ENABLE_ANNOTATIONS="${ENABLE_ANNOTATIONS:-false}" export GITHUB_OUTPUT="${GITHUB_OUTPUT:-/dev/null}" readonly CONFIG_LIB_LOADED="true" diff --git a/bin/lib/environment.sh b/bin/lib/environment.sh index 825ea0d..bd1387a 100755 --- a/bin/lib/environment.sh +++ b/bin/lib/environment.sh @@ -32,13 +32,13 @@ _should_include_file() { local file="$1" local best_dir="$2" local env_type="$3" - local is_fallback="$4" + local is_lifecycle_root="$4" # File must be in the selected directory [[ "$file" != "$best_dir"* ]] && return 1 - # Fallback or stable lifecycles: only direct files - if [[ "$is_fallback" == "true" ]] || ! _lifecycle_allows_subdirectories "$env_type"; then + # Lifecycle roots and root-only lifecycles use direct files only. + if [[ "$is_lifecycle_root" == "true" ]] || ! _lifecycle_allows_subdirectories "$env_type"; then _is_direct_file "$file" "$best_dir" && return 0 return 1 fi diff --git a/bin/lib/github.sh b/bin/lib/github.sh old mode 100755 new mode 100644 index 191c0d8..c90b671 --- a/bin/lib/github.sh +++ b/bin/lib/github.sh @@ -1,102 +1,46 @@ #!/usr/bin/env bash -# GitHub API interaction utilities +# Minimal GitHub API helpers for lifecycle resolution. -# Guard against multiple sourcing if [[ "${GITHUB_LIB_LOADED:-}" == "true" ]]; then return 0 fi -# Source dependencies LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$LIB_DIR/logging.sh" -# Constants readonly GITHUB_API_BASE="https://api.github.com" readonly GITHUB_API_VERSION="2022-11-28" -# Simple protection check - no need for approval counting - -# PRIVATE: Make a GitHub API request -# Usage: _github_api_request "GET" "/repos/owner/repo/branches/main" "github_token" -# Returns: response body (stdout) and http code (via return value check) -_github_api_request() { - local method="$1" - local endpoint="$2" - local token="$3" - - local response - response=$(curl -s -w "\n%{http_code}" \ - -X "$method" \ - -H "Authorization: token $token" \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: $GITHUB_API_VERSION" \ - "${GITHUB_API_BASE}${endpoint}" 2>/dev/null) - - local http_code=$(echo "$response" | tail -n1) - local body=$(echo "$response" | sed '$d') - - # Output body to stdout - echo "$body" - - # Return 0 for 2xx status codes, 1 otherwise - if [[ "$http_code" =~ ^2[0-9][0-9]$ ]]; then - return 0 - else - return 1 - fi -} - -# PRIVATE: Get branch info to check protection status -# Usage: _get_branch_info "owner/repo" "branch-name" "token" -# Returns: 0 if successful, 1 otherwise -_get_branch_info() { - local repository="$1" - local branch="$2" - local token="$3" - - dbg "Fetching branch info for '$branch' in '$repository'" >&2 - - local response - response=$(_github_api_request "GET" "/repos/${repository}/branches/${branch}" "$token") - local status=$? - - if [[ $status -eq 0 ]]; then - echo "$response" - return 0 - fi - - return 1 -} - -# PUBLIC: Check if branch is protected via GitHub API -# Returns: 0 if protected, 1 if not protected -# Usage: github_check_branch_protection "branch-name" ["repository"] ["token"] -# - branch: Branch name to check (required) -# - repository: GitHub repository (optional, defaults to GITHUB_REPOSITORY env) -# - token: GitHub token (optional, defaults to GITHUB_TOKEN env) +# Return success only when GitHub confirms the branch is protected. A missing +# token or an unavailable branch remains compatible with the historical +# fallback-to-development behavior, but is visible in debug logs. github_check_branch_protection() { local branch="$1" local repository="${2:-${GITHUB_REPOSITORY:-}}" local token="${3:-${GITHUB_TOKEN:-}}" - - # Skip if missing required parameters - if [[ -z "$branch" ]] || [[ -z "$token" ]] || [[ -z "$repository" ]]; then - dbg "Skipping branch protection check - missing parameters" >&2 + + if [[ -z "$branch" || -z "$repository" || -z "$token" ]]; then + dbg "Skipping branch protection check because branch, repository, or token is unavailable" return 1 fi - - dbg "Checking branch protection for '$branch' in '$repository'" >&2 - - # Get branch info and check protected field - local branch_info - if branch_info=$(_get_branch_info "$repository" "$branch" "$token"); then - if echo "$branch_info" | grep -q '"protected": *true'; then - dbg "Branch '$branch' is protected" >&2 + + local response http_code body + response="$(curl -sS -w $'\n%{http_code}' \ + -H "Authorization: Bearer $token" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: $GITHUB_API_VERSION" \ + "$GITHUB_API_BASE/repos/$repository/branches/$branch" 2>/dev/null || true)" + http_code="${response##*$'\n'}" + body="${response%$'\n'*}" + + if [[ "$http_code" =~ ^2[0-9][0-9]$ ]]; then + if grep -q '"protected"[[:space:]]*:[[:space:]]*true' <<<"$body"; then return 0 fi + return 1 fi - - dbg "Branch '$branch' is not protected" >&2 + + warn "Could not determine branch protection for '$branch' (GitHub API HTTP ${http_code:-unknown}); using lifecycle fallback rules" return 1 } diff --git a/bin/lib/lifecycle.sh b/bin/lib/lifecycle.sh index fc3ae46..f2864ee 100755 --- a/bin/lib/lifecycle.sh +++ b/bin/lib/lifecycle.sh @@ -9,7 +9,6 @@ fi # Source dependencies LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$LIB_DIR/logging.sh" -source "$LIB_DIR/github.sh" source "$LIB_DIR/discovery.sh" # Note: Configuration is set in index.sh and passed via environment variables: @@ -17,13 +16,12 @@ source "$LIB_DIR/discovery.sh" # - STABLE_LIFECYCLES_STR, ALL_LIFECYCLES_STR (comma-separated) # - SUBDIRECTORY_LIFECYCLES (comma-separated) # - SUBDIR_PREFERRED_LIFECYCLE -# - PROTECTED_BRANCH_LIFECYCLE # - FALLBACK_LIFECYCLE # # Lifecycle Rules (applied throughout the codebase): # - Only configured subdirectory lifecycles support lifecycle// smart merge -# - Protected branches map to configured protected lifecycle -# - Unmatched environments map to configured fallback lifecycle +# - The composite action resolves lifecycle metadata before config merging +# - Local direct script runs retain a simple fallback without GitHub metadata # Build lifecycle arrays from environment variables IFS=',' read -ra STABLE_LIFECYCLES <<< "${STABLE_LIFECYCLES_STR}" @@ -101,14 +99,8 @@ _find_env_directory() { echo "$result" } -# PRIVATE: Determine lifecycle for environment -# Returns: lifecycle name (production/staging/development) -# Resolution order: -# 1. Check if env name matches configured explicit lifecycle names -# 2. Check if configured subdir-preferred lifecycle has env subdirectory -# 3. Check branch protection: -# - Protected → configured protected lifecycle -# - Not protected → configured fallback lifecycle +# PRIVATE: Minimal fallback for direct script usage when no lifecycle metadata +# is supplied. The composite action resolves lifecycle before config merging. _determine_lifecycle_for_env() { local env_name="$1" shift @@ -130,15 +122,39 @@ _determine_lifecycle_for_env() { echo "$SUBDIR_PREFERRED_LIFECYCLE" return fi - - # Check branch protection - if github_check_branch_protection "$env_name"; then - dbg "Branch '$env_name' is protected → $PROTECTED_BRANCH_LIFECYCLE lifecycle" >&2 - echo "$PROTECTED_BRANCH_LIFECYCLE" - else - dbg "Branch '$env_name' not protected → $FALLBACK_LIFECYCLE lifecycle" >&2 - echo "$FALLBACK_LIFECYCLE" + + dbg "No pre-resolved lifecycle for '$env_name'; using local fallback lifecycle '$FALLBACK_LIFECYCLE'" >&2 + echo "$FALLBACK_LIFECYCLE" +} + +# PUBLIC: Resolve a lifecycle and preserve the rule that selected it. +# Usage: lifecycle_resolve "environment" "is_protected" "directories..." +lifecycle_resolve() { + local env_name="$1" + local is_protected="$2" + shift 2 + local unique_dirs=("$@") + local lifecycle + + for lifecycle in "${ALL_LIFECYCLES[@]}"; do + if [[ "$env_name" == "$lifecycle" ]]; then + echo "$lifecycle|explicit_lifecycle" + return 0 + fi + done + + if _lifecycle_allows_subdirectories "$SUBDIR_PREFERRED_LIFECYCLE" && \ + discovery_find_dir_matching "*/$SUBDIR_PREFERRED_LIFECYCLE/$env_name" "${unique_dirs[@]}" >/dev/null; then + echo "$SUBDIR_PREFERRED_LIFECYCLE|environment_subdirectory" + return 0 + fi + + if [[ "$is_protected" == "true" ]]; then + echo "$PROTECTED_BRANCH_LIFECYCLE|protected_branch" + return 0 fi + + echo "$FALLBACK_LIFECYCLE|fallback" } # PUBLIC: Get lifecycle and directory info for environment @@ -152,21 +168,28 @@ lifecycle_get_info() { shift local unique_dirs=("$@") - # Check if this is a protected branch (before determining lifecycle) - local is_protected="false" - if github_check_branch_protection "$env_name"; then - is_protected="true" + local lifecycle="${LIFECYCLE:-}" + local is_protected="${IS_PROTECTED:-false}" + local result + + if [[ -n "$lifecycle" ]]; then + dbg "Using pre-resolved lifecycle for '$env_name': $lifecycle" >&2 + result=$(_find_directory_for_lifecycle "$lifecycle" "$env_name" "${unique_dirs[@]}") + else + lifecycle=$(_determine_lifecycle_for_env "$env_name" "${unique_dirs[@]}") + dbg "Determined lifecycle for '$env_name': $lifecycle" >&2 + + # Find directory for this environment + result=$(_find_env_directory "$env_name" "${unique_dirs[@]}") fi - - # Determine lifecycle for this environment - local lifecycle=$(_determine_lifecycle_for_env "$env_name" "${unique_dirs[@]}") - dbg "Determined lifecycle for '$env_name': $lifecycle" >&2 - - # Find directory for this environment - local result=$(_find_env_directory "$env_name" "${unique_dirs[@]}") + local best_dir=$(echo "$result" | cut -d'|' -f1) local actual_lifecycle=$(echo "$result" | cut -d'|' -f2) - + + if [[ -z "$actual_lifecycle" ]]; then + actual_lifecycle="$lifecycle" + fi + echo "$actual_lifecycle|$best_dir|$is_protected" } @@ -184,8 +207,15 @@ lifecycle_detect_environments() { # Find all directories with YAML files while IFS= read -r dir; do + local relative_dir="${dir#$source_dir/}" + local lifecycle_root="${relative_dir%%/*}" local dir_name=$(basename "$dir") local parent_name=$(basename "$(dirname "$dir")") + + if [[ ",$ALL_LIFECYCLES_STR," != *",$lifecycle_root,"* ]]; then + dbg "Ignoring directory outside configured lifecycle roots: $dir" >&2 + continue + fi # Skip hidden/common directories if discovery_should_skip_directory "$dir_name"; then diff --git a/bin/lib/validation.sh b/bin/lib/validation.sh index 48402b3..837b8f0 100755 --- a/bin/lib/validation.sh +++ b/bin/lib/validation.sh @@ -64,22 +64,9 @@ _require_non_empty() { return 0 } -_require_not_equal() { - local left="$1" - local right="$2" - local message="$3" - - if [[ "$left" == "$right" ]]; then - err "$message" - return 1 - fi - - return 0 -} - _validation_check_policy() { if [[ ! -f "$LIFECYCLE_POLICY_FILE" ]]; then - err "Lifecycle policy file not found: $LIFECYCLE_POLICY_FILE" + err "Rabbit config layout file not found: $LIFECYCLE_POLICY_FILE" return 1 fi @@ -107,11 +94,11 @@ _validation_check_policy() { protected_lifecycle="${policy_meta[5]:-}" fallback_lifecycle="${policy_meta[6]:-}" - if ! _require_equal "$kind" "lifecyclePolicy" "Lifecycle policy kind must be 'lifecyclePolicy', got '$kind'"; then + if ! _require_equal "$kind" "rabbitConfigLayout" "Rabbit config layout kind must be 'rabbitConfigLayout', got '$kind'"; then return 1 fi - if ! _require_non_empty "$version" "Lifecycle policy must define a version"; then + if ! _require_non_empty "$version" "Rabbit config layout must define a version"; then return 1 fi @@ -133,7 +120,8 @@ _validation_check_policy() { return 1 fi - if ! _require_not_equal "$protected_lifecycle" "$fallback_lifecycle" "Protected lifecycle and fallback lifecycle must be different"; then + if [[ "$protected_lifecycle" == "$fallback_lifecycle" ]]; then + err "Protected lifecycle and fallback lifecycle must be different" return 1 fi diff --git a/bin/merge-configs.sh b/bin/merge-configs.sh index 86b407d..780027e 100755 --- a/bin/merge-configs.sh +++ b/bin/merge-configs.sh @@ -42,8 +42,15 @@ main() { # Validate that files are in lifecycle directories (not root level) local valid_files=() for file in "${all_files[@]}"; do + local relative_file="${file#$SOURCE_DIR/}" + local lifecycle_root="${relative_file%%/*}" local file_dir=$(dirname "$file") local parent_dir=$(basename "$file_dir") + + if [[ ",$ALL_LIFECYCLES_STR," != *",$lifecycle_root,"* ]]; then + dbg "Skipping file outside configured lifecycle roots: $file" + continue + fi # Skip root-level files - configs must be in lifecycle directories if [[ "$parent_dir" == "$(basename "$SOURCE_DIR")" ]]; then diff --git a/bin/resolve-lifecycle.sh b/bin/resolve-lifecycle.sh new file mode 100755 index 0000000..1c3cc9d --- /dev/null +++ b/bin/resolve-lifecycle.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +source "$SCRIPT_DIR/lib/config.sh" +source "$SCRIPT_DIR/lib/logging.sh" +source "$SCRIPT_DIR/lib/validation.sh" +source "$SCRIPT_DIR/lib/discovery.sh" +source "$SCRIPT_DIR/lib/github.sh" +source "$SCRIPT_DIR/lib/lifecycle.sh" + +write_output() { + local key="$1" + local value="$2" + printf '%s=%s\n' "$key" "$value" >> "$GITHUB_OUTPUT" +} + +collect_dirs() { + local source_dir="$1" + local -n dirs_ref=$2 + dirs_ref=() + + if [[ ! -d "$source_dir" ]]; then + dbg "Source directory '$source_dir' does not exist; resolving without directory hints" + return 0 + fi + + while IFS= read -r dir; do + dirs_ref+=("$dir") + done < <(find "$source_dir" -type d -mindepth 1 2>/dev/null | sort) +} + +main() { + if [[ -z "$ENV_NAME" ]]; then + err "Environment name is required. Pass environment or run inside a GitHub event with a resolvable ref." + exit 1 + fi + + if ! validation_check_policy; then + exit 1 + fi + + local directories=() + collect_dirs "$SOURCE_DIR" directories + + local is_protected="false" + if github_check_branch_protection "$ENV_NAME"; then + is_protected="true" + fi + + local resolution lifecycle reason + resolution="$(lifecycle_resolve "$ENV_NAME" "$is_protected" "${directories[@]}")" + lifecycle="${resolution%%|*}" + reason="${resolution#*|}" + + log "Resolved environment '$ENV_NAME' to lifecycle '$lifecycle' ($reason)" + write_output "environment" "$ENV_NAME" + write_output "lifecycle" "$lifecycle" + write_output "is_protected" "$is_protected" + write_output "resolution_reason" "$reason" + write_output "lifecycle_policy_path" "$LIFECYCLE_POLICY_FILE" +} + +main "$@" diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..5418e8e --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,44 @@ +# Configuration Contract + +`github-rabbit-action` consumes Rabbit infrastructure config from a lifecycle-rooted source directory. The default source directory is `.rabbit`, and callers can set `source_dir` when configs live under another root such as `.rabbit/infra_configs`. + +## Layout + +Config files must live under one of the direct lifecycle roots in `source_dir`: + +```text +.rabbit/ +├── production/ +│ ├── 10-infra.yaml +│ └── customer-branch/ +│ └── 10-infra.yaml +├── staging/ +│ └── 10-infra.yaml +└── development/ + ├── 10-infra.yaml + └── feature-branch/ + └── 10-infra.yaml +``` + +Root-level files in `source_dir` are ignored. Nested lifecycle roots are also ignored unless `source_dir` points at the nested root. + +## Merge Order + +For the lifecycle and environment resolved by this action, config files are discovered in this order: + +1. Lifecycle root files, sorted by path. +2. Environment or branch override files under `/`, sorted by path. + +The lifecycle root is the base config. Environment or branch files override and extend that base config. Services with the same `module::id` are merged by the action's manifest merge logic. + +## Lifecycle Boundary + +This action resolves lifecycle, environment, protected-branch status, and resolution reason before merging configuration. It then owns Rabbit config discovery, merge ordering, manifest merge behavior, deployment safety checks, cloud auth, Terraform/R2A execution, PR comments, summaries, and Slack notifications. It has no runtime dependency on `udx/rabbit-lifecycle`. + +Direct local script runs that do not provide `INPUT_LIFECYCLE` use a simple compatibility fallback: explicit lifecycle name, preferred lifecycle subdirectory, then `development`. + +## Source Contract + +The bundled layout manifest is [src/configs/lifecycle-policy.yaml](../src/configs/lifecycle-policy.yaml). Its `kind: rabbitConfigLayout` declares which lifecycle roots support subdirectory overrides, which lifecycle is selected for a protected branch, and which is the fallback. Set the optional `lifecycle_policy_path` input to a caller-repository policy when a repository needs a different policy; the same selected policy is validated and used for both lifecycle resolution and merging. + +For local direct script runs, [.env.example](../.env.example) documents the merge-script environment variables that mirror action inputs and lifecycle outputs. diff --git a/docs/validation.md b/docs/validation.md new file mode 100644 index 0000000..d3d67d9 --- /dev/null +++ b/docs/validation.md @@ -0,0 +1,16 @@ +# Validation Contract + +This repository validates the composite action with shell syntax checks, action metadata parsing, lifecycle-resolution scenarios, and config-merge smoke tests. + +## Local Checks + +Run these before opening or updating a PR: + +```bash +make test +dev.kit repo +``` + +## CI + +The `ci` workflow runs on pull requests and pushes to `production` and `lifecycle-action-integration`. It installs a pinned `yq` binary, runs `make test`, and refreshes repo context with `@udx/dev-kit`. diff --git a/src/configs/lifecycle-policy.yaml b/src/configs/lifecycle-policy.yaml index 46f578b..845a922 100644 --- a/src/configs/lifecycle-policy.yaml +++ b/src/configs/lifecycle-policy.yaml @@ -1,4 +1,4 @@ -kind: lifecyclePolicy +kind: rabbitConfigLayout version: udx.dev/rabbit-infra-config/v1 config: @@ -6,10 +6,14 @@ config: production: allow_subdirs: true protected_only: true + is_fallback: false staging: allow_subdirs: false + protected_only: false + is_fallback: false development: allow_subdirs: true + protected_only: false is_fallback: true diff --git a/tests/run-merge-tests.sh b/tests/run-merge-tests.sh new file mode 100755 index 0000000..c21a018 --- /dev/null +++ b/tests/run-merge-tests.sh @@ -0,0 +1,250 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +PASSED=0 +FAILED=0 + +TEMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TEMP_DIR"' EXIT + +write_yaml() { + local path="$1" + local body="$2" + + mkdir -p "$(dirname "$path")" + printf '%s\n' "$body" > "$path" +} + +read_output() { + local file="$1" + local key="$2" + + awk -F= -v key="$key" '$1 == key { value=substr($0, length(key) + 2) } END { print value }' "$file" +} + +run_merge() { + local source_dir="$1" + local env_name="$2" + local lifecycle="$3" + local output_file="$4" + local policy_path="${5:-}" + + : > "$output_file" + GITHUB_WORKSPACE="$ROOT" \ + INPUT_SOURCE_DIR="$source_dir" \ + INPUT_ENV_NAME="$env_name" \ + INPUT_LIFECYCLE="$lifecycle" \ + INPUT_LIFECYCLE_POLICY_PATH="$policy_path" \ + INPUT_IS_PROTECTED="false" \ + GITHUB_OUTPUT="$output_file" \ + "$PROJECT_ROOT/bin/merge-configs.sh" >/dev/null +} + +run_resolve() { + local source_dir="$1" + local env_name="$2" + local output_file="$3" + local policy_path="${4:-}" + local mock_bin="${5:-}" + local github_token="${6:-}" + local github_repository="${7:-}" + + : > "$output_file" + PATH="${mock_bin:+$mock_bin:}$PATH" \ + GITHUB_WORKSPACE="$ROOT" \ + INPUT_SOURCE_DIR="$source_dir" \ + INPUT_ENV_NAME="$env_name" \ + INPUT_LIFECYCLE_POLICY_PATH="$policy_path" \ + GITHUB_TOKEN="$github_token" \ + GITHUB_REPOSITORY="$github_repository" \ + GITHUB_OUTPUT="$output_file" \ + "$PROJECT_ROOT/bin/resolve-lifecycle.sh" >/dev/null +} + +assert_eq() { + local actual="$1" + local expected="$2" + local name="$3" + + if [[ "$actual" == "$expected" ]]; then + echo " PASS $name" + PASSED=$((PASSED + 1)) + else + echo " FAIL $name - expected '$expected', got '$actual'" + FAILED=$((FAILED + 1)) + fi +} + +assert_file_exists() { + local path="$1" + local name="$2" + + if [[ -f "$path" ]]; then + echo " PASS $name" + PASSED=$((PASSED + 1)) + else + echo " FAIL $name - missing '$path'" + FAILED=$((FAILED + 1)) + fi +} + +scenario() { + echo "" + echo "$1" + echo "------------------------------------------------------------" +} + +ROOT="$TEMP_DIR/workspace" +SOURCE="$ROOT/.rabbit" +INFRA_SOURCE="$ROOT/.rabbit/infra_configs" + +scenario "Action requires caller-managed cloud credentials" +assert_eq "$(yq -r '.inputs | has("gcp_auth_provider")' "$PROJECT_ROOT/action.yml")" "false" "GCP provider input is not accepted" +assert_eq "$(yq -r '.inputs | has("gcp_service_account")' "$PROJECT_ROOT/action.yml")" "false" "GCP service account input is not accepted" +assert_eq "$(yq -r '.inputs | has("aws_role_arn")' "$PROJECT_ROOT/action.yml")" "false" "AWS role input is not accepted" +assert_eq "$(yq -r '.inputs | has("aws_region")' "$PROJECT_ROOT/action.yml")" "false" "AWS region input is not accepted" +assert_eq "$(yq -r '.inputs.state_backend.required' "$PROJECT_ROOT/action.yml")" "false" "State backend input is optional" +assert_eq "$(yq -r '.runs.steps[] | select(.name == "Upload terraform artifacts") | .uses' "$PROJECT_ROOT/action.yml")" "actions/upload-artifact@v6" "Artifact upload uses Node.js 24 action runtime" +assert_eq "$(yq -r '.runs.steps[] | select(.name == "Upload terraform plans") | .uses' "$PROJECT_ROOT/action.yml")" "actions/upload-artifact@v6" "Plan upload uses Node.js 24 action runtime" +assert_eq "$(yq -r '[.runs.steps[] | select(.uses == "google-github-actions/auth@v3" or .uses == "aws-actions/configure-aws-credentials@v6")] | length' "$PROJECT_ROOT/action.yml")" "0" "Action does not configure cloud credentials" +assert_eq "$(grep -c 'Authenticate with Google Cloud before invoking github-rabbit-action' "$PROJECT_ROOT/action.yml")" "1" "Action requires caller-provided GCP credentials" +assert_eq "$(grep -c 'AWS credentials configured by the caller workflow' "$PROJECT_ROOT/action.yml")" "1" "Action forwards caller AWS credentials" + +write_yaml "$SOURCE/production/10-base.yaml" 'services: + - module: test-module + id: app + replicas: 2 + base: true' + +write_yaml "$SOURCE/production/main/20-override.yaml" 'services: + - module: test-module + id: app + replicas: 4 + override: true' + +write_yaml "$SOURCE/staging/10-stage.yaml" 'services: + - module: test-module + id: stage + replicas: 1' + +write_yaml "$SOURCE/staging/ignored/20-ignored.yaml" 'services: + - module: test-module + id: ignored + replicas: 99' + +write_yaml "$SOURCE/development/10-base.yaml" 'services: + - module: test-module + id: app + replicas: 1 + base: true' + +write_yaml "$SOURCE/development/dev-alice/20-override.yaml" 'services: + - module: test-module + id: app + replicas: 3 + developer: alice' + +write_yaml "$INFRA_SOURCE/production/10-base.yaml" 'services: + - module: test-module + id: infra + replicas: 2' + +write_yaml "$INFRA_SOURCE/production/main/20-override.yaml" 'services: + - module: test-module + id: infra + replicas: 5' + +write_yaml "$SOURCE/lifecycle-policy.yaml" 'kind: rabbitConfigLayout +version: udx.dev/rabbit-infra-config/v1 +config: + lifecycles: + production: + allow_subdirs: true + protected_only: true + is_fallback: false + staging: + allow_subdirs: false + protected_only: false + is_fallback: false + development: + allow_subdirs: true + protected_only: false + is_fallback: true' + +echo "Rabbit config merge smoke tests" +echo "===============================" + +scenario "Scenario: production branch uses lifecycle base plus branch override" +production_out="$TEMP_DIR/production.out" +run_merge "$SOURCE" "main" "production" "$production_out" +production_config="$(read_output "$production_out" merged_config)" +assert_file_exists "$production_config" "Merged production config exists" +assert_eq "$(read_output "$production_out" environment)" "main" "Environment output is branch name" +assert_eq "$(read_output "$production_out" lifecycle)" "production" "Lifecycle output uses pre-resolved lifecycle" +assert_eq "$(yq -r '.services[0].replicas' "$production_config")" "4" "Production override wins" +assert_eq "$(yq -r '.services[0].base' "$production_config")" "true" "Production base field is retained" +assert_eq "$(yq -r '.services[0].override' "$production_config")" "true" "Production override field is retained" + +scenario "Scenario: staging lifecycle is root-only" +staging_out="$TEMP_DIR/staging.out" +run_merge "$SOURCE" "staging" "staging" "$staging_out" +staging_config="$(read_output "$staging_out" merged_config)" +assert_file_exists "$staging_config" "Merged staging config exists" +assert_eq "$(yq -r '.services | length' "$staging_config")" "1" "Staging ignores nested override directory" +assert_eq "$(yq -r '.services[0].id' "$staging_config")" "stage" "Staging uses root config" + +scenario "Scenario: development branch uses lifecycle base plus branch override" +development_out="$TEMP_DIR/development.out" +run_merge "$SOURCE" "dev-alice" "development" "$development_out" +development_config="$(read_output "$development_out" merged_config)" +assert_file_exists "$development_config" "Merged development config exists" +assert_eq "$(yq -r '.services[0].replicas' "$development_config")" "3" "Development override wins" +assert_eq "$(yq -r '.services[0].base' "$development_config")" "true" "Development base field is retained" +assert_eq "$(yq -r '.services[0].developer' "$development_config")" "alice" "Development override field is retained" + +scenario "Scenario: configurable source_dir supports infra_configs-style layouts" +infra_out="$TEMP_DIR/infra.out" +run_merge "$INFRA_SOURCE" "main" "production" "$infra_out" +infra_config="$(read_output "$infra_out" merged_config)" +assert_file_exists "$infra_config" "Merged infra_configs config exists" +assert_eq "$(yq -r '.services[0].replicas' "$infra_config")" "5" "infra_configs override wins" + +scenario "Scenario: in-repo lifecycle resolver honors explicit and subdirectory rules" +explicit_out="$TEMP_DIR/explicit.out" +run_resolve "$SOURCE" "staging" "$explicit_out" +assert_eq "$(read_output "$explicit_out" lifecycle)" "staging" "Explicit lifecycle resolves in-repo" +assert_eq "$(read_output "$explicit_out" resolution_reason)" "explicit_lifecycle" "Explicit lifecycle reason is recorded" + +subdir_out="$TEMP_DIR/subdir.out" +run_resolve "$SOURCE" "dev-alice" "$subdir_out" +assert_eq "$(read_output "$subdir_out" lifecycle)" "development" "Development subdirectory resolves in-repo" +assert_eq "$(read_output "$subdir_out" resolution_reason)" "environment_subdirectory" "Subdirectory reason is recorded" + +scenario "Scenario: resolver uses a caller lifecycle policy for both policy metadata and merge" +policy_out="$TEMP_DIR/policy.out" +run_resolve "$SOURCE" "dev-alice" "$policy_out" ".rabbit/lifecycle-policy.yaml" +assert_eq "$(read_output "$policy_out" lifecycle_policy_path)" "$SOURCE/lifecycle-policy.yaml" "Caller policy path is reported" +run_merge "$SOURCE" "dev-alice" "development" "$TEMP_DIR/policy-merge.out" ".rabbit/lifecycle-policy.yaml" +assert_file_exists "$(read_output "$TEMP_DIR/policy-merge.out" merged_config)" "Merge remains compatible with caller policy" + +scenario "Scenario: protected branch resolves to production" +mock_bin="$TEMP_DIR/mock-bin" +mkdir -p "$mock_bin" +write_yaml "$mock_bin/curl" "#!/usr/bin/env bash +printf '%s\\n%s\\n' '{\"protected\":true}' '200'" +chmod +x "$mock_bin/curl" +protected_out="$TEMP_DIR/protected.out" +run_resolve "$SOURCE" "main" "$protected_out" "" "$mock_bin" "test-token" "udx/github-rabbit-action" +assert_eq "$(read_output "$protected_out" lifecycle)" "production" "Protected branch resolves to production" +assert_eq "$(read_output "$protected_out" is_protected)" "true" "Protected status is recorded" +assert_eq "$(read_output "$protected_out" resolution_reason)" "protected_branch" "Protected branch reason is recorded" + +echo "" +echo "Results: $PASSED passed, $FAILED failed" + +if [[ $FAILED -gt 0 ]]; then + exit 1 +fi