diff --git a/.github/workflows/cloud-smoke.yml b/.github/workflows/cloud-smoke.yml index a90d8dc..c766af3 100644 --- a/.github/workflows/cloud-smoke.yml +++ b/.github/workflows/cloud-smoke.yml @@ -7,7 +7,6 @@ on: permissions: contents: read - id-token: write concurrency: group: cloud-compose-smoke @@ -20,6 +19,14 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install contract tools + run: | + sudo apt-get update + sudo apt-get install --yes ripgrep - name: Install Terraform uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4 @@ -27,8 +34,14 @@ jobs: terraform_version: 1.14.6 terraform_wrapper: false - - name: Run Terraform lint - run: make terraform-lint-check + - name: Install Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: "1.26.x" + cache: false + + - name: Run repository lint + run: make lint-check - name: Validate fallback cleanup behavior run: bash ci/cloud-smoke-cleanup-contract.sh @@ -51,6 +64,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 150 if: github.event.pull_request.head.repo.full_name == github.repository + environment: cloud-smoke-linode strategy: fail-fast: false matrix: @@ -91,6 +105,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 150 if: github.event.pull_request.head.repo.full_name == github.repository + environment: cloud-smoke-${{ matrix.provider }} strategy: fail-fast: false matrix: @@ -101,9 +116,6 @@ jobs: - name: Linode WordPress provider: linode template: wp - - name: GCP WordPress - provider: gcp - template: wp concurrency: group: cloud-compose-smoke-${{ matrix.provider }}-${{ matrix.template }} cancel-in-progress: false @@ -113,16 +125,67 @@ jobs: CLOUD_COMPOSE_SMOKE_SWEEP_ORPHANS: "true" CLOUD_COMPOSE_SMOKE_RUN_ID: ${{ github.run_id }} CLOUD_COMPOSE_SOURCE_REF: ${{ github.event.pull_request.head.sha }} - DIGITALOCEAN_TOKEN: ${{ secrets.DIGITALOCEAN_TOKEN }} + DIGITALOCEAN_TOKEN: ${{ matrix.provider == 'digitalocean' && secrets.DIGITALOCEAN_TOKEN || '' }} + LINODE_TOKEN: ${{ matrix.provider == 'linode' && secrets.LINODE_TOKEN || '' }} + + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Install Terraform + uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4 + with: + terraform_version: 1.14.6 + terraform_wrapper: false + + - name: Install sitectl + run: ci/install-sitectl-apt.sh + + - name: Run smoke test + run: make smoke-test PROVIDER=${{ matrix.provider }} TEMPLATE=${{ matrix.template }} + + - name: Destroy smoke resources + if: always() + run: ci/cloud-smoke.sh destroy-${{ matrix.provider }}-${{ matrix.template }} + + gcp-smoke: + name: GCP WordPress + needs: + - terraform-lint + - config-management-lint + runs-on: ubuntu-24.04 + timeout-minutes: 210 + if: github.event.pull_request.head.repo.full_name == github.repository + environment: cloud-smoke-gcp + permissions: + contents: read + id-token: write + concurrency: + group: cloud-compose-smoke-gcp-wp + cancel-in-progress: false + env: + CLOUD_COMPOSE_SMOKE_AUTO_APPROVE: "true" + CLOUD_COMPOSE_SMOKE_DESTROY_TIMEOUT: "1800" + CLOUD_COMPOSE_SMOKE_SWEEP_ORPHANS: "true" + CLOUD_COMPOSE_SMOKE_RUN_ID: ${{ github.run_id }} + CLOUD_COMPOSE_SOURCE_REF: ${{ github.event.pull_request.head.sha }} + CLOUD_COMPOSE_UPGRADE_BASE_SHA: f33117cdbbf4a9c7d59006a4db986baef118e6bb + CLOUD_COMPOSE_UPGRADE_CURRENT_REF: ${{ github.sha }} GCLOUD_OIDC_POOL: ${{ vars.GCLOUD_OIDC_POOL || secrets.GCLOUD_OIDC_POOL }} GCLOUD_PROJECT: ${{ vars.GCLOUD_PROJECT || secrets.GCLOUD_PROJECT }} GCLOUD_REGION: ${{ vars.GCLOUD_REGION || secrets.GCLOUD_REGION }} + GCLOUD_NETWORK_PROJECT_ID: ${{ vars.GCLOUD_NETWORK_PROJECT_ID || secrets.GCLOUD_NETWORK_PROJECT_ID }} + GCLOUD_NETWORK_NAME: ${{ vars.GCLOUD_NETWORK_NAME || secrets.GCLOUD_NETWORK_NAME }} + GCLOUD_SUBNETWORK_NAME: ${{ vars.GCLOUD_SUBNETWORK_NAME || secrets.GCLOUD_SUBNETWORK_NAME }} + GCLOUD_POWER_START_ROLE: ${{ vars.GCLOUD_POWER_START_ROLE || secrets.GCLOUD_POWER_START_ROLE }} + GCLOUD_POWER_SUSPEND_ROLE: ${{ vars.GCLOUD_POWER_SUSPEND_ROLE || secrets.GCLOUD_POWER_SUSPEND_ROLE }} GSA: ${{ vars.GSA || secrets.GSA }} - LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} - steps: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + fetch-depth: 0 + persist-credentials: false - name: Install Terraform uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4 @@ -130,15 +193,23 @@ jobs: terraform_version: 1.14.6 terraform_wrapper: false - - name: Check GCP smoke secrets - if: matrix.provider == 'gcp' + - name: Check GCP smoke configuration + run: | + test -n "$GCLOUD_OIDC_POOL" || { echo "GCLOUD_OIDC_POOL environment variable or secret is required for GCP smoke tests"; exit 1; } + test -n "$GSA" || { echo "GSA environment variable or secret is required for GCP smoke tests"; exit 1; } + test -n "$GCLOUD_PROJECT" || { echo "GCLOUD_PROJECT environment variable or secret is required for GCP smoke tests"; exit 1; } + + - name: Check GCP upgrade configuration + if: ${{ startsWith(github.event.pull_request.title, '[major]') }} run: | - test -n "$GCLOUD_OIDC_POOL" || { echo "GCLOUD_OIDC_POOL Actions variable or secret is required for GCP smoke tests"; exit 1; } - test -n "$GSA" || { echo "GSA Actions variable or secret is required for GCP smoke tests"; exit 1; } - test -n "$GCLOUD_PROJECT" || { echo "GCLOUD_PROJECT Actions variable or secret is required for GCP smoke tests"; exit 1; } + test -n "$GCLOUD_NETWORK_PROJECT_ID" || { echo "GCLOUD_NETWORK_PROJECT_ID environment variable or secret is required for the GCP upgrade smoke test"; exit 1; } + test -n "$GCLOUD_NETWORK_NAME" || { echo "GCLOUD_NETWORK_NAME environment variable or secret is required for the GCP upgrade smoke test"; exit 1; } + test -n "$GCLOUD_SUBNETWORK_NAME" || { echo "GCLOUD_SUBNETWORK_NAME environment variable or secret is required for the GCP upgrade smoke test"; exit 1; } + test -n "$GCLOUD_POWER_START_ROLE" || { echo "GCLOUD_POWER_START_ROLE environment variable or secret is required for the GCP upgrade smoke test"; exit 1; } + test -n "$GCLOUD_POWER_SUSPEND_ROLE" || { echo "GCLOUD_POWER_SUSPEND_ROLE environment variable or secret is required for the GCP upgrade smoke test"; exit 1; } + test "$GCLOUD_NETWORK_PROJECT_ID" = "$GCLOUD_PROJECT" || { echo "GCLOUD_NETWORK_PROJECT_ID must equal GCLOUD_PROJECT for the 0.10.2 baseline"; exit 1; } - name: Authenticate to Google Cloud - if: matrix.provider == 'gcp' uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3 with: workload_identity_provider: ${{ env.GCLOUD_OIDC_POOL }} @@ -146,19 +217,23 @@ jobs: project_id: ${{ env.GCLOUD_PROJECT }} - name: Install gcloud - if: matrix.provider == 'gcp' uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3 - name: Install sitectl run: ci/install-sitectl-apt.sh - - name: Run smoke test - run: make smoke-test PROVIDER=${{ matrix.provider }} TEMPLATE=${{ matrix.template }} + - name: Run fresh smoke test + if: ${{ !startsWith(github.event.pull_request.title, '[major]') }} + run: make smoke-test PROVIDER=gcp TEMPLATE=wp - - name: Destroy smoke resources - if: always() && (matrix.provider != 'gcp' || (env.GCLOUD_OIDC_POOL != '' && env.GSA != '' && env.GCLOUD_PROJECT != '')) - run: ci/cloud-smoke.sh destroy-${{ matrix.provider }}-${{ matrix.template }} + - name: Run 0.10.2 upgrade smoke test + if: ${{ startsWith(github.event.pull_request.title, '[major]') }} + run: ci/gcp-upgrade-smoke.sh run + + - name: Destroy fresh smoke resources + if: ${{ always() && !startsWith(github.event.pull_request.title, '[major]') && env.GCLOUD_OIDC_POOL != '' && env.GSA != '' && env.GCLOUD_PROJECT != '' }} + run: ci/cloud-smoke.sh destroy-gcp-wp - - name: Skip GCP destroy - if: always() && matrix.provider == 'gcp' && (env.GCLOUD_OIDC_POOL == '' || env.GSA == '' || env.GCLOUD_PROJECT == '') - run: echo "Skipping GCP destroy because GCP smoke secrets are not configured and no GCP resources were applied." + - name: Destroy upgrade smoke resources + if: ${{ always() && startsWith(github.event.pull_request.title, '[major]') && env.GCLOUD_OIDC_POOL != '' && env.GSA != '' && env.GCLOUD_PROJECT != '' && env.GCLOUD_NETWORK_PROJECT_ID != '' && env.GCLOUD_NETWORK_NAME != '' && env.GCLOUD_SUBNETWORK_NAME != '' }} + run: ci/gcp-upgrade-smoke.sh destroy diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 937e4c5..0a99bc2 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,6 +1,13 @@ name: "Build docs" on: + pull_request: + branches: + - main + paths: + - "docs/**" + - ".github/workflows/docs.yml" + - "Makefile" push: branches: - main @@ -11,12 +18,25 @@ on: permissions: contents: read - pages: write - id-token: write jobs: + validate: + if: github.event_name == 'pull_request' + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Build docs + run: make docs-build + deploy: + if: github.event_name == 'push' runs-on: ubuntu-24.04 + permissions: + contents: read + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} diff --git a/.github/workflows/github-release.yaml b/.github/workflows/github-release.yaml index e7103bb..0956dd4 100644 --- a/.github/workflows/github-release.yaml +++ b/.github/workflows/github-release.yaml @@ -8,7 +8,9 @@ on: jobs: release: if: github.event.pull_request.merged == true && !contains(github.event.pull_request.title, 'skip-release') - uses: libops/.github/.github/workflows/bump-release.yaml@main + uses: libops/.github/.github/workflows/bump-release.yaml@8dfaf9c854df71d9bbffde48c5676ff07c543c51 + with: + workflow_file: publish-rootfs.yaml permissions: contents: write actions: write diff --git a/.github/workflows/publish-rootfs.yaml b/.github/workflows/publish-rootfs.yaml new file mode 100644 index 0000000..2592899 --- /dev/null +++ b/.github/workflows/publish-rootfs.yaml @@ -0,0 +1,30 @@ +name: Publish rootfs release asset + +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + publish: + runs-on: ubuntu-24.04 + steps: + - name: Checkout tagged source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Build and verify canonical rootfs asset + run: | + ci/rootfs-package-contract.sh + ci/package-rootfs.sh dist + + - name: Upload rootfs asset and checksum + env: + GH_TOKEN: ${{ github.token }} + run: >- + gh release upload "$GITHUB_REF_NAME" + dist/cloud-compose-rootfs.tar.gz + dist/cloud-compose-rootfs.tar.gz.sha256 + --clobber diff --git a/.terraform.lock.hcl b/.terraform.lock.hcl deleted file mode 100644 index 4f3e87e..0000000 --- a/.terraform.lock.hcl +++ /dev/null @@ -1,131 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/digitalocean/digitalocean" { - version = "2.93.0" - constraints = "~> 2.0" - hashes = [ - "h1:furxWCm/4BbtUX/XHa3TsP0eI+YjQekvcUTyx4BLqnM=", - "zh:2509716282fb0d04027279b2d6ffb449d5f4b4a5f380a2ad7b7b6221d2f2fdc4", - "zh:3a6407123d127b6948040583c29371b6ea92120d2a239d8bcd27673725673013", - "zh:71443c90f85572fce0c563e3587d956afb8be9f0a0090f1c40b33b1005718824", - "zh:790c8a3694baba6e28b13d45d86523995ffda4693e0c8ad1584e34deee914fe2", - "zh:7fe00fd5d4d322923550651b6ebca9dea14b3b7a68c09ba4c20f9b416a34cb40", - "zh:8bf4d5ccb634bacba929fc632d554eddb349b890d26e5f4dbcb53481ca34dbff", - "zh:8f545ec1622fa4d42a7112de8cbd0c42f8967e8dafd5c514d77b7ec25914951d", - "zh:98300c632e9eabdcecb9f77586a4a48e19dab62b7365410f4ab857ab4007e159", - "zh:9db648994459fe62a9e91f9d875dd9220db5b063a00f4eec22dbdf2247a67730", - "zh:9f21713916718613da2a42c8b39c503e46399b2d06c3b492979bde939d05d2bb", - "zh:a4b4afa0ef6f672d50ac072cae1a39ec6d97716e4ed118900b5db1a553a3c1cd", - "zh:bebed73ab49be830ef03346cb91e1da83fff06b555695f69fc3de13c51a456ec", - "zh:d207d54f3ab86a0f71c194b8b34a49a219f97dd40ec6db0ed13ed70df9f26aab", - "zh:ee4caa17a2e62bfa9c2669ddd909026a3ded3294c4f2bb325cf563f5766e1981", - "zh:efb59a5436f103a4930a7cab2e77ba75d31fd08f2f024c9a4177c70a89cd3264", - "zh:ffaf97ab916b12c2181bdf5f7232aaac88cce59bb8edbad07a527207da232f90", - ] -} - -provider "registry.terraform.io/hashicorp/cloudinit" { - version = "2.4.0" - constraints = "~> 2.3" - hashes = [ - "h1:Bx3XQkBSY3RAGwLZb8hyi8AhvahPNlt4mlyZhW9guOI=", - "zh:1b0fe71b8e87a068f7cd9faaa733100ab72ab61ce812b8bd2b8e3e6ea3907b2d", - "zh:2aa9631ad64cfda1eb58f147619b631dadedfaf9453b422aa5ada2d3861183c1", - "zh:2c5f35463bdfb2f87d3576b81e62c30f8109e67bb6f21ffcbc46a855811455c0", - "zh:5970bcad151ea236bd262ada1a5a23bfbc1716f94a4e8b16ab2bcdda91d6a671", - "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:79a0676909732b6ec0441a733af6383513cde3bd2cef5c1ad0a74131e1286a04", - "zh:818f16141481a1202b3977becd19a12d4d46cd2e3f5753f5d0d0049adacf8f8c", - "zh:948d98716831087e69eca99f91ed7964cc537f3aca279f7494645ee56c9dc4ec", - "zh:a75e78889565a51df3e8e3af207e36e5ddb25e47ce1780a784c82dc3c3109b67", - "zh:a9c6e455d52b1bba5272bd87a35cfabcfd6d903dcbe42e2de926228dbb1e39b2", - "zh:b846805d8c2f5d1d6c2ffeeaf32109d9af7db7fa3c56929bfc1dcfaadf9c8bd8", - "zh:c3e5279756b46c4f49a6f4c81347fbe2fffebb2bf18a5c24664830304a1f6a8e", - "zh:c8be7b31893163d0046b0137a6100533f07e8efd192a1903b6bb4c42be12dceb", - ] -} - -provider "registry.terraform.io/hashicorp/google" { - version = "7.39.0" - constraints = "~> 7.0" - hashes = [ - "h1:+g+1r2/+69mViWFaY+C9OkZY9bYC69wlwSmDtWgjJOc=", - "h1:+zr/4txpVAI/LXHyMsAyf1bH3ZsepJUACJMepT+J92Q=", - "h1:9acvx5lIVxB0vNONg+LLSHKsPAv5MI9jJ3esRniDdDY=", - "h1:DC40I41eb+XKMtq0J6a4Bb7iPC0XZy4KRxaPb3oh77I=", - "h1:Jcxz4tziN3MkT5h/vmXDegYbY2HG4UJkxd1cK8MfR0g=", - "h1:MInhBhcYBueURY+EbC6m0IODWWq9Ml47PHxbqsnz05s=", - "h1:n6fal0gOUI1IbFMFwITnhmqA7453sWeqS229XqpXAB4=", - "h1:orrxQKsWos0+lBCgLSE2LrH+afNyM8sG9kj5oTUejaQ=", - "h1:puGPa0+bhExUuGnYGf1i54/kYSJ1ZEruF0fsAat831M=", - "h1:rk2YwbN0TWOWZ/7Hf2043zy/WKy2GpsAFhmAl5JdgAQ=", - "h1:ubipK1X7rcU+tndiCCd6W4RlG8SBUEz0DpTJdIC+BEc=", - "zh:00eea2ae6d24e580a107574e3a93fcace52e2f1d912c3f7d1cdeadd9f0449443", - "zh:3b626035e0ea3ec8aa0720cd976cda6f252645cfa90253d5d3b087c56a74d514", - "zh:42505c5304e30208a30edb62385cca4f5f648a38f386158f86b39d39516d59c1", - "zh:97b1959fc2c5f39db12ee7a8929227efd004bc389935f893d4a3e4fb48713c72", - "zh:9d4ccd2f4f0c89affabd07ab3ec6ddfae1de5ee9633cc3233a5dc63980ca89ca", - "zh:9e1dccb6efff92b2221c68ab00dbe82e184a48aeb5f21392ada4767ac64cd08e", - "zh:b752408bb4d9693565526eeb73986e5cbc8626acd906df49eef0cdd7e13cac14", - "zh:d3eea63e3b58ba564d6a3065f839cde790ddf091501f931fe08002133797b0a3", - "zh:eb1f9fc07e7a1d23d64194534d03ff43e0f95bd2f65a7286ed247290461d3dfc", - "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", - "zh:f9bf75d75117a1da609042a2ee63a8fab31b6206c93260da3fc343bd67453f97", - "zh:ff18322b786ad6216ee3aebb02e6374223eaa16b5f181109089786128718afc3", - ] -} - -provider "registry.terraform.io/hashicorp/time" { - version = "0.14.0" - constraints = "~> 0.14" - hashes = [ - "h1:4EThC3ocCFiFPMZQSUvSGSxoJqBcGWxMcFYmL67uS7Y=", - "zh:12abfd6b800e4d7fa6db7310dec8ffd440b31993861ef188c7ed5260b3073937", - "zh:23005521e800bb19e1597bf755c5f70d675d30b685d4255001ed5fa47d9df3f1", - "zh:2fea249b582ae97cd1cc10385187ea50993bb47c28cc5df0305e57ceaabf0a10", - "zh:322018d3b987b7aad08697178029a2bb667bed699e88328f0c89c52a2fd41341", - "zh:32a08e98fce2d273cb9b2c89d6c54727cc9f0a32e15bfd896be4e02cc6b48f95", - "zh:3db89aabd0e619616bd4b0f8b373a7586dfe60feffcea12a84a0bdbc445714b3", - "zh:7488f56c81d742dc020f29063626c8f07ca188aa97be61e7307e8d62397020a2", - "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:7cb4067f2e7559b13f7562ef722f948950901eb37834873e98360ab28f66e9d7", - "zh:9d552c8345f61e1b7db8e725144981345f18ac1014d58d6f5ddf0928a195fffb", - "zh:a8e69fb6b97fc9d86fb19a9f4d42abe33c4a68e700b15387ce2e17d2b9934bed", - "zh:aeeb900eb8dd0f790c60ea5c0e0c8d42bd6e4a54f391681d4decca15b544394b", - "zh:c239c619101a8c95e1f14061eb973c57a8d15fa0e68878ced5bbd76858ee5b79", - ] -} - -provider "registry.terraform.io/linode/linode" { - version = "4.1.0" - constraints = "~> 4.0" - hashes = [ - "h1:7pe73OPAS7wPA4m+S/jhlX8tGaC2uW8fyxvEYg5ZJgY=", - "h1:AHwHN8KPg4D5DWpU89GTjrAGXJd5cr8vHK/5h24mc7k=", - "h1:GNHVBjXB6ATeYN7LTjZHVV20XR8SkK4kz5I5BvRsmYw=", - "h1:JhLQen0YJ6Z8jE3aKW84QQJ5fVVPYwtY9F9aR0qNomE=", - "h1:RADa7jK0xbyqVkHwpWga9//vq+dPFOpyQoSibS7gLEM=", - "h1:RIUdiH4mT7k4HJbsoH5vPLXcRzIjWiWLeNo/BBmD6HA=", - "h1:cheTe6Vx4sCJNIXYMBCjRQF5T8T+hk2OI07rW4Q7FTA=", - "h1:dgJCJcn4Q6VUjoSLz3HatT8K++JbBsxdpysWVLt4Dkw=", - "h1:mCEmVTmu5S1yr9jYrddCgcA1oo+E2hfyqVpzD5axPtY=", - "h1:noBniuGgYN++C6/IhO5EOiGhhzACGj2XYGrf/MPSQkw=", - "h1:pTe9ByrW0oQaRIvwbWZTSZrsQ+1rHH21MwXv5ExgWA0=", - "h1:pq6T/EcMe9E5BOAMd+QH+UzrKNjOeaFIx9oxDl8iSmY=", - "h1:w6ClwC0DC3vXkeVCpBeiq+/W3kHU+/hCNe2KeLHybMk=", - "zh:04f458ff111c43159754a47844982a28986742112c2a23589c53997836768e96", - "zh:454a593fb3449ecb4688eeca1550c4df24cdf7698d1d4f1444dc9e57c6ce425f", - "zh:7235dba97c808bf845f21669c7de5252c4939321a81a63dc5957199a880a7ceb", - "zh:7fe415e05cb009c7f97037745b3b23af03797cec1196e5af2b511309b54b1b5e", - "zh:83f40818c4efa1b396888cb53e7f0eedfc7865df08316787cdc020f337a6c008", - "zh:89ceb1af0d8f76b4376c3bd99e2684d66d22bf7037a4a57e0972daec4e646054", - "zh:a7c9fb4244804a2e484bc521ced495f9a950b17cf96feb5aabbd509e751b8704", - "zh:badcc9ef591998c55fb5252ae30d1a7961a581968867c330ae74f5713194bf6c", - "zh:ce1b8f350f1be00e7a89a5ee23c6e3851c55be55c1e88ab9fe113428d3c19e09", - "zh:d91c9a088bd6685fbdd454edcbd6e5ac967c965fbec5f8f31a626a4d9bf721ab", - "zh:e1503679849f01e1ce524e75452842635c701209c2fa90b8eb677402b099de90", - "zh:f7938980d9445cb3011a24c91cb61b8cbc8863c15d0ee69005c3ed6588ff3d29", - "zh:fb57468a25b8074c36bbf6e6abe4b1ad531d3ea444ad5c08e900e412b5f480c7", - ] -} diff --git a/Makefile b/Makefile index 8759482..3fe694a 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -.PHONY: lint lint-check shell-lint config-management-smoke -.PHONY: terraform-fmt terraform-fmt-check terraform-validate terraform-lint-check terraform-docs +.PHONY: lint lint-check actionlint shell-lint runtime-config-contract application-env-contract compose-runtime-contract backup-contract overlay-contract filesystem-prep-contract key-rotation-contract vault-runtime-contract managed-artifact-contract config-management-input-contract systemd-contract sitectl-version-contract template-version-contract rollout-parity-contract rootfs-package-contract host-runtime-security cos-jq-portability-contract source-trust-contract cloud-smoke-cleanup-contract gcp-upgrade-smoke-contract artifact-install-contract config-management-smoke +.PHONY: terraform-fmt terraform-fmt-check terraform-validate terraform-lint-check terraform-docs terraform-docs-check .PHONY: config-management-cloud-smoke config-management-cloud-smoke-ansible-drupal config-management-cloud-smoke-salt-drupal .PHONY: destroy-config-management-cloud-smoke destroy-config-management-cloud-smoke-ansible-drupal destroy-config-management-cloud-smoke-salt-drupal .PHONY: smoke-test-clouds smoke-test smoke-test-digitalocean-isle smoke-test-linode-wp smoke-test-gcp-wp @@ -9,10 +9,15 @@ DOCS_IMAGE ?= cloud-compose-docs DOCS_PORT ?= 8888 DOCS_DOCKER_USER ?= $(shell id -u):$(shell id -g) +ACTIONLINT_VERSION ?= v1.7.12 +TERRAFORM_DOCS_VERSION ?= v0.21.0 -lint: terraform-fmt shell-lint terraform-validate +lint: terraform-fmt actionlint shell-lint host-runtime-security cos-jq-portability-contract application-env-contract compose-runtime-contract backup-contract overlay-contract filesystem-prep-contract key-rotation-contract vault-runtime-contract managed-artifact-contract config-management-input-contract systemd-contract source-trust-contract cloud-smoke-cleanup-contract gcp-upgrade-smoke-contract sitectl-version-contract template-version-contract rollout-parity-contract rootfs-package-contract artifact-install-contract terraform-validate terraform-docs-check -lint-check: terraform-fmt-check shell-lint terraform-validate +lint-check: terraform-fmt-check actionlint shell-lint host-runtime-security cos-jq-portability-contract application-env-contract compose-runtime-contract backup-contract overlay-contract filesystem-prep-contract key-rotation-contract vault-runtime-contract managed-artifact-contract config-management-input-contract systemd-contract source-trust-contract cloud-smoke-cleanup-contract gcp-upgrade-smoke-contract sitectl-version-contract template-version-contract rollout-parity-contract rootfs-package-contract artifact-install-contract terraform-validate terraform-docs-check + +actionlint: + go run github.com/rhysd/actionlint/cmd/actionlint@$(ACTIONLINT_VERSION) terraform-fmt: terraform fmt -recursive @@ -29,9 +34,73 @@ shell-lint: @find . \ -path "./.terraform" -prune -o \ -path "./docs/site" -prune -o \ - -type f -name "*.sh" -print0 | xargs -0 shellcheck + -type f -name "*.sh" -print0 | xargs -0 shellcheck --severity=warning + +runtime-config-contract: + bash ci/runtime-config-contract.sh + +application-env-contract: + bash ci/application-env-contract.sh + +compose-runtime-contract: + bash ci/compose-runtime-contract.sh + +backup-contract: + bash ci/backup-contract.sh + +overlay-contract: + bash ci/overlay-contract.sh + +filesystem-prep-contract: + bash ci/filesystem-prep-contract.sh + +key-rotation-contract: + bash ci/key-rotation-contract.sh + +vault-runtime-contract: + bash ci/vault-runtime-contract.sh + +managed-artifact-contract: + bash ci/managed-artifact-contract.sh + +config-management-input-contract: + bash ci/config-management-input-contract.sh + +systemd-contract: + bash ci/systemd-contract.sh -config-management-smoke: +sitectl-version-contract: + bash ci/sitectl-version-contract.sh + +template-version-contract: + bash ci/template-version-contract.sh + +rollout-parity-contract: + bash ci/rollout-parity-contract.sh + +rootfs-package-contract: + bash ci/rootfs-package-contract.sh + +host-runtime-security: + bash ci/host-runtime-security.sh + +cos-jq-portability-contract: + bash ci/cos-jq-portability-contract.sh + +source-trust-contract: + bash ci/source-trust-contract.sh + +cloud-smoke-cleanup-contract: + bash ci/cloud-smoke-cleanup-contract.sh + +gcp-upgrade-smoke-contract: + bash ci/gcp-upgrade-smoke-contract.sh + +artifact-install-contract: + bash ci/docker-plugin-install-contract.sh + bash ci/cos-bootstrap-contract.sh + +config-management-smoke: runtime-config-contract artifact-install-contract ci/config-management-smoke.sh config-management-cloud-smoke: @@ -55,7 +124,10 @@ destroy-config-management-cloud-smoke-salt-drupal: $(MAKE) destroy-config-management-cloud-smoke METHOD=salt terraform-docs: - terraform-docs markdown table --sort-by required --output-file README.md . + go run github.com/terraform-docs/terraform-docs@$(TERRAFORM_DOCS_VERSION) markdown table --sort-by required --output-file README.md . + +terraform-docs-check: + go run github.com/terraform-docs/terraform-docs@$(TERRAFORM_DOCS_VERSION) markdown table --sort-by required --output-file README.md --output-check . smoke-test-clouds: ci/cloud-smoke.sh all diff --git a/README.md b/README.md index 9631244..cc30b15 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,33 @@ contract through the Ansible role or Salt formula. Template defaults live in `templates/apps.json` and are shared by Terraform, Ansible, and Salt. The default deployment shape is one app per VM or host; pass `runtime.compose.projects` when several apps should share the same machine. +Terraform template presets include an exact, reviewed sitectl core/plugin +release set. Override individual selectors with +`runtime.sitectl.package_versions` when intentionally testing or promoting a +different compatible release set. + +GCP deployments have two Terraform ownership layers. Apply +[`modules/gcp-foundation`](modules/gcp-foundation/README.md) once per service +project from a small, long-lived state; it owns required APIs, the Cloud Run +service identity, the reusable least-privilege power roles, and any Shared VPC +attachment and service-agent grants. Per-application states consume the role +names and bind their service accounts only to their own Compute Engine instance. +They must not recreate or destroy the singleton foundation. The +[runtime contracts](docs/runtime-contracts.md#gcp-foundation-and-application-states) +cover the state boundary, Shared VPC setup, and Cloud Run Direct VPC egress +requirements. ## Requirements | Name | Version | -| ---- | ------- | -| [terraform](#requirement\_terraform) | >= 1.2.4 | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.3.0 | +| [cloudinit](#requirement\_cloudinit) | ~> 2.3 | +| [digitalocean](#requirement\_digitalocean) | ~> 2.0 | +| [google](#requirement\_google) | ~> 7.0 | +| [linode](#requirement\_linode) | ~> 4.0 | +| [time](#requirement\_time) | ~> 0.14 | ## Providers @@ -23,10 +43,11 @@ No providers. ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [digitalocean](#module\_digitalocean) | ./modules/digitalocean | n/a | | [gcp](#module\_gcp) | ./modules/gcp | n/a | | [linode](#module\_linode) | ./modules/linode | n/a | +| [managed\_artifacts](#module\_managed\_artifacts) | ./modules/managed-artifacts | n/a | ## Resources @@ -35,19 +56,19 @@ No resources. ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [name](#input\_name) | Deployment name. | `string` | n/a | yes | | [cloud\_provider](#input\_cloud\_provider) | Cloud provider to deploy to. Supported values are gcp, digitalocean, and linode. | `string` | `"gcp"` | no | -| [digitalocean](#input\_digitalocean) | DigitalOcean infrastructure settings. |
object({
region = optional(string, "tor1")
tags = optional(list(string), ["cloud-compose"])

droplet = optional(object({
size = optional(string, "s-2vcpu-4gb")
image = optional(string, "ubuntu-24-04-x64")
ssh_keys = optional(list(string), [])
vpc_uuid = optional(string, null)
monitoring = optional(bool, true)
ipv6 = optional(bool, true)
backups = optional(bool, false)
}), {})

ssh = optional(object({
cloud_compose_keys = optional(list(string), [])
users = optional(map(list(string)), {})
}), {})

volumes = optional(object({
data_size_gb = optional(number, 50)
docker_volumes_size_gb = optional(number, 100)
}), {})

firewall = optional(object({
enabled = optional(bool, true)
ssh_source_addresses = optional(list(string), ["0.0.0.0/0", "::/0"])
web_source_addresses = optional(list(string), ["0.0.0.0/0", "::/0"])
}), {})
})
| `{}` | no | -| [gcp](#input\_gcp) | Google Cloud infrastructure settings. |
object({
project_id = optional(string, "")
project_number = optional(string, "")
region = optional(string, "us-east5")
zone = optional(string, "us-east5-b")

identity = optional(object({
vm_service_account_email = optional(string, "")
app_service_account_email = optional(string, "")
}), {})

instance = optional(object({
machine_type = optional(string, "n4-standard-2")
os = optional(string, "cos-125-19216-220-185")
production = optional(bool, false)
}), {})

disks = optional(object({
type = optional(string, "hyperdisk-balanced")
docker_volumes_size_gb = optional(number, 50)
}), {})

network = optional(object({
create = optional(bool, true)
name = optional(string, "")
subnetwork = optional(string, "")
ip_cidr_range = optional(string, "10.42.0.0/24")
power_button_allowed_ips = optional(list(string), [])
ssh_ipv4 = optional(list(string), [])
ssh_ipv6 = optional(list(string), [])
}), {})

snapshots = optional(object({
enabled = optional(bool, false)
}), {})

overlay = optional(object({
source_instance = optional(string, "")
volume_names = optional(list(string), [])
}), {})

cloud_init = optional(object({
initcmd = optional(list(string), [])
runcmd = optional(list(string), [])
}), {})

artifact_registry = optional(object({
repository = optional(string, "")
location = optional(string, "us")
}), {})

power_management = optional(object({
enabled = optional(bool, true)
frontend = optional(object({
image = string
port = optional(number, 8080)
cpu = optional(string, "1000m")
memory = optional(string, "1Gi")
}), null)
}), {})

rollout = optional(object({
enabled = optional(bool, false)
release_url = optional(string, "")
release_sha256 = optional(string, "")
port = optional(number, 8081)
jwks_uri = optional(string, "")
jwt_audience = optional(string, "")
custom_claims = optional(string, "")
allowed_ipv4 = optional(list(string), ["10.0.0.0/8"])
}), {})
})
| `{}` | no | -| [linode](#input\_linode) | Linode infrastructure settings. |
object({
region = optional(string, "us-east")
tags = optional(list(string), ["cloud-compose"])

instance = optional(object({
type = optional(string, "g6-standard-2")
image = optional(string, "linode/ubuntu22.04")
authorized_keys = optional(list(string), [])
authorized_users = optional(list(string), [])
root_pass = optional(string, null)
private_ip = optional(bool, true)
backups_enabled = optional(bool, false)
watchdog_enabled = optional(bool, true)
}), {})

ssh = optional(object({
cloud_compose_keys = optional(list(string), [])
users = optional(map(list(string)), {})
}), {})

volumes = optional(object({
data_size_gb = optional(number, 50)
docker_volumes_size_gb = optional(number, 100)
}), {})

firewall = optional(object({
enabled = optional(bool, true)
ssh_source_ipv4 = optional(list(string), ["0.0.0.0/0"])
ssh_source_ipv6 = optional(list(string), ["::/0"])
web_source_ipv4 = optional(list(string), ["0.0.0.0/0"])
web_source_ipv6 = optional(list(string), ["::/0"])
}), {})
})
| `{}` | no | -| [runtime](#input\_runtime) | Provider-neutral compose/runtime settings. |
object({
rootfs = optional(string, "")
rootfs_archive_url = optional(string, "")
rootfs_archive_sha256 = optional(string, "")
users = optional(map(list(string)), {})

compose = optional(object({
primary = optional(string, "")
ingress_port = optional(number, 80)
ingress = optional(object({
letsencrypt = optional(bool, false)
bot_mitigation = optional(bool, false)
mode = optional(string, "")
domain = optional(string, "")
acme_email = optional(string, "")
trusted_ips = optional(list(string), [])
max_upload_size = optional(string, "")
upload_timeout = optional(string, "")
}), {})
repo = optional(string, "")
branch = optional(string, "main")
projects = optional(map(object({
docker_compose_repo = string
docker_compose_branch = optional(string)
project_dir = optional(string)
compose_project_name = optional(string)
ingress_port = optional(number)
ingress = optional(object({
letsencrypt = optional(bool)
bot_mitigation = optional(bool)
mode = optional(string)
domain = optional(string)
acme_email = optional(string)
trusted_ips = optional(list(string))
max_upload_size = optional(string)
upload_timeout = optional(string)
}), {})
sitectl_context_name = optional(string)
sitectl_plugin = optional(string)
sitectl_environment = optional(string)
sitectl_packages = optional(list(string), [])
sitectl_verify_args = optional(list(string), [])
docker_compose_init = optional(list(string))
docker_compose_up = optional(list(string))
docker_compose_down = optional(list(string))
docker_compose_rollout = optional(list(string))
})), {})
init = optional(list(string))
up = optional(list(string))
down = optional(list(string))
rollout = optional(list(string))
}), {})

sitectl = optional(object({
packages = optional(list(string), ["sitectl"])
version = optional(string, "latest")
context_name = optional(string, "")
plugin = optional(string, "core")
environment = optional(string, "production")
verify_args = optional(list(string), [])
}), {})

docker = optional(object({
# renovate: datasource=github-releases depName=docker-compose packageName=docker/compose versioning=semver
compose_version = optional(string, "v5.3.0")
# renovate: datasource=github-releases depName=docker-buildx packageName=docker/buildx versioning=semver
buildx_version = optional(string, "v0.35.0")
}), {})

managed_runtime = optional(object({
enabled = optional(bool, true)
internal_services_enabled = optional(bool, true)
internal_services_auto_update = optional(bool, true)
artifacts = optional(list(object({
name = string
url = string
sha256 = string
path = string
mode = optional(string, "0755")
owner = optional(string, "root")
group = optional(string, "root")
restart = optional(string, "")
})), [])
}), {})

vault = optional(object({
addr = optional(string, "")
namespace = optional(string, "")
role = optional(string, "")
agent_enabled = optional(bool, false)
auth_method = optional(string, "gcp-iam")
gcp_auth_mount_path = optional(string, "auth/gcp")
agent_token_path = optional(string, "/mnt/disks/data/vault/token")
agent_additional_config = optional(string, "")
agent_templates = optional(list(object({
destination = string
contents = string
perms = optional(string, "0640")
command = optional(string, "")
})), [])
}), {})

extra_env = optional(map(string), {})
})
| `{}` | no | +| [digitalocean](#input\_digitalocean) | DigitalOcean infrastructure settings. droplet.backups covers only the Droplet boot disk; attached application and Docker volumes require a separate offsite backup policy. |
object({
region = optional(string, "tor1")
tags = optional(list(string), ["cloud-compose"])

droplet = optional(object({
size = optional(string, "s-2vcpu-4gb")
image = optional(string, "ubuntu-24-04-x64")
ssh_keys = optional(list(string), [])
vpc_uuid = optional(string, null)
monitoring = optional(bool, true)
ipv6 = optional(bool, true)
backups = optional(bool, false)
}), {})

ssh = optional(object({
cloud_compose_keys = optional(list(string), [])
users = optional(map(list(string)), {})
}), {})

volumes = optional(object({
data_size_gb = optional(number, 50)
docker_volumes_size_gb = optional(number, 100)
}), {})

firewall = optional(object({
enabled = optional(bool, true)
ssh_source_addresses = optional(list(string), ["0.0.0.0/0", "::/0"])
web_source_addresses = optional(list(string), ["0.0.0.0/0", "::/0"])
}), {})
})
| `{}` | no | +| [gcp](#input\_gcp) | Google Cloud infrastructure settings. |
object({
project_id = optional(string, "")
project_number = optional(string, "")
region = optional(string, "us-east5")
zone = optional(string, "us-east5-b")

identity = optional(object({
vm_service_account_email = optional(string, "")
app_service_account_email = optional(string, "")
app_credentials_enabled = optional(bool, false)
}), {})

instance = optional(object({
machine_type = optional(string, "n4-standard-2")
os = optional(string, "cos-125-19216-220-185")
production = optional(bool, false)
}), {})

disks = optional(object({
type = optional(string, "hyperdisk-balanced")
docker_volumes_size_gb = optional(number, 50)
}), {})

network = optional(object({
create = optional(bool, true)
project_id = optional(string, "")
name = optional(string, "")
subnetwork = optional(string, "")
ip_cidr_range = optional(string, "10.42.0.0/24")
mtu = optional(number, 1460)
power_button_allowed_ips = optional(list(string), [])
power_button_ip_depth = optional(number)
ssh_ipv4 = optional(list(string), [])
ssh_ipv6 = optional(list(string), [])
}), {})

snapshots = optional(object({
enabled = optional(bool, false)
}), {})

overlay = optional(object({
source_instance = optional(string, "")
volume_names = optional(list(string), [])
}), {})

cloud_init = optional(object({
initcmd = optional(list(string), [])
runcmd = optional(list(string), [])
}), {})

artifact_registry = optional(object({
repository = optional(string, "")
location = optional(string, "us")
}), {})

power_management = optional(object({
enabled = optional(bool, false)
start_role = optional(string, "")
suspend_role = optional(string, "")
frontend = optional(object({
image = string
port = optional(number, 8080)
cpu = optional(string, "1000m")
memory = optional(string, "1Gi")
}), null)
}), {})

rollout = optional(object({
enabled = optional(bool, false)
release_url = optional(string, "")
release_sha256 = optional(string, "")
port = optional(number, 8081)
jwks_uri = optional(string, "")
jwt_audience = optional(string, "")
custom_claims = optional(string, "")
allowed_ipv4 = optional(list(string), ["10.0.0.0/8"])
}), {})
})
| `{}` | no | +| [linode](#input\_linode) | Linode infrastructure settings. instance.backups\_enabled covers only the instance disk; attached application and Docker Block Storage volumes require a separate offsite backup policy. |
object({
region = optional(string, "us-east")
tags = optional(list(string), ["cloud-compose"])

instance = optional(object({
type = optional(string, "g6-standard-2")
image = optional(string, "linode/ubuntu22.04")
authorized_keys = optional(list(string), [])
authorized_users = optional(list(string), [])
root_pass = optional(string, null)
private_ip = optional(bool, true)
backups_enabled = optional(bool, false)
watchdog_enabled = optional(bool, true)
}), {})

ssh = optional(object({
cloud_compose_keys = optional(list(string), [])
users = optional(map(list(string)), {})
}), {})

volumes = optional(object({
data_size_gb = optional(number, 50)
docker_volumes_size_gb = optional(number, 100)
}), {})

firewall = optional(object({
enabled = optional(bool, true)
ssh_source_ipv4 = optional(list(string), ["0.0.0.0/0"])
ssh_source_ipv6 = optional(list(string), ["::/0"])
web_source_ipv4 = optional(list(string), ["0.0.0.0/0"])
web_source_ipv6 = optional(list(string), ["::/0"])
}), {})
})
| `{}` | no | +| [runtime](#input\_runtime) | Provider-neutral compose/runtime settings. |
object({
rootfs = optional(string, "")
rootfs_archive_url = optional(string, "")
rootfs_archive_sha256 = optional(string, "")
users = optional(map(list(string)), {})

compose = optional(object({
primary = optional(string, "")
ingress_port = optional(number, 80)
ingress = optional(object({
letsencrypt = optional(bool, false)
bot_mitigation = optional(bool, false)
mode = optional(string, "")
domain = optional(string, "")
acme_email = optional(string, "")
trusted_ips = optional(list(string), [])
max_upload_size = optional(string, "")
upload_timeout = optional(string, "")
}), {})
repo = optional(string, "")
branch = optional(string, "main")
projects = optional(map(object({
docker_compose_repo = string
docker_compose_branch = optional(string)
project_dir = optional(string)
compose_project_name = optional(string)
ingress_port = optional(number)
ingress = optional(object({
letsencrypt = optional(bool)
bot_mitigation = optional(bool)
mode = optional(string)
domain = optional(string)
acme_email = optional(string)
trusted_ips = optional(list(string))
max_upload_size = optional(string)
upload_timeout = optional(string)
}), {})
sitectl_context_name = optional(string)
sitectl_plugin = optional(string)
sitectl_environment = optional(string)
sitectl_packages = optional(list(string))
sitectl_verify_args = optional(list(string))
docker_compose_init = optional(list(string))
docker_compose_up = optional(list(string))
docker_compose_down = optional(list(string))
docker_compose_rollout = optional(list(string))
})), {})
init = optional(list(string))
up = optional(list(string))
down = optional(list(string))
rollout = optional(list(string))
}), {})

sitectl = optional(object({
packages = optional(list(string))
version = optional(string, "latest")
package_versions = optional(map(string), {})
context_name = optional(string, "")
plugin = optional(string, "core")
environment = optional(string, "production")
verify_args = optional(list(string), [])
}), {})

docker = optional(object({
# renovate: datasource=github-releases depName=docker-compose packageName=docker/compose versioning=semver
compose_version = optional(string, "v5.3.1")
# renovate: datasource=github-releases depName=docker-buildx packageName=docker/buildx versioning=semver
buildx_version = optional(string, "v0.35.0")
}), {})

managed_runtime = optional(object({
enabled = optional(bool, true)
internal_services_enabled = optional(bool, false)
internal_services_auto_update = optional(bool, false)
artifacts = optional(list(object({
name = string
url = string
sha256 = string
path = string
mode = optional(string, "0755")
owner = optional(string, "root")
group = optional(string, "root")
restart = optional(string, "")
})), [])
}), {})

vault = optional(object({
addr = optional(string, "")
namespace = optional(string, "")
role = optional(string, "")
agent_enabled = optional(bool, false)
auth_method = optional(string, "auto")
gcp_auth_mount_path = optional(string, "auth/gcp")
agent_token_path = optional(string, "/mnt/disks/data/vault/token")
agent_additional_config = optional(string, "")
agent_templates = optional(list(object({
destination = string
contents = string
perms = optional(string, "0640")
command = optional(string, "")
})), [])
}), {})

extra_env = optional(map(string), {})
})
| `{}` | no | | [template](#input\_template) | Optional compose template preset. Supported values are archivesspace, ojs, isle, drupal, wp, omeka-s, and omeka-classic. Explicit runtime settings override preset defaults. | `string` | `""` | no | ## Outputs | Name | Description | -| ---- | ----------- | +|------|-------------| | [appGsa](#output\_appGsa) | The Google Service Account the app can use for app-scoped auth. | | [backend](#output\_backend) | Backend service ID for attaching Cloud Run ingress to an external HTTPS load balancer. | | [cloud\_provider](#output\_cloud\_provider) | Selected cloud provider. | @@ -56,10 +77,12 @@ No resources. | [instance](#output\_instance) | Selected provider VM instance details. | | [instance\_id](#output\_instance\_id) | Selected provider VM instance ID. | | [internal\_ip](#output\_internal\_ip) | Selected provider VM private IPv4 address. | +| [network](#output\_network) | Resolved GCP network and regional subnetwork, or null for non-GCP providers. | | [primary\_compose\_project](#output\_primary\_compose\_project) | Normalized primary compose project. | | [rollout](#output\_rollout) | Optional rollout API endpoint details. | | [serviceGsa](#output\_serviceGsa) | The Google Service Account internal services run as. | +| [sitectl\_package\_versions](#output\_sitectl\_package\_versions) | Effective release selector for every installed sitectl package; values may be exact tags or latest. | | [template](#output\_template) | Selected compose template preset. | | [urls](#output\_urls) | Cloud Run ingress URLs by region. | -| [volumes](#output\_volumes) | Selected provider persistent volume details where available. | +| [volumes](#output\_volumes) | Selected provider persistent application-data and Docker-volume details. | diff --git a/ansible/README.md b/ansible/README.md index 11d7d6a..824843d 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -9,6 +9,62 @@ Terraform provider modules. The packaged runtime currently uses fixed host paths: `/home/cloud-compose`, `/mnt/disks/data`, and `/mnt/disks/volumes`. +Every resolved Compose `project_dir` must be a normalized, non-root descendant +of `/mnt/disks/data`. Before installing packages, creating accounts, or changing +files, the role resolves existing symlinks on the target and rejects `/`, +`/etc`, traversal, repeated/empty segments, control characters, and any symlink +escape outside that boundary. The production boundary is intentionally not +configurable. + +The same preflight validates `runtime.managed_runtime.artifacts` before writing +the host manifest. Artifact names are safe basenames of at most 128 characters; +URLs are HTTPS; checksums are 64 lowercase hex characters; target paths are +non-root, normalized absolute paths; modes match `0?[0-7]{3}`; owners/groups are +safe local account names; and an optional restart target is a safe `.service` +unit. Names and target paths must be unique. + +At install time, every artifact target directory must already exist through a +canonical installer-controlled directory chain. Symlinked ancestors and +group/other-writable non-sticky directories are rejected before download. + +This role is intentionally limited to a dedicated, empty application host. It +installs cloud-compose files over host Docker, Fluent Bit, Vault Agent, and +systemd locations and restarts Docker during bootstrap. Confirm durable storage +is mounted at the fixed paths and that no unrelated workloads use those host +services before setting the required acknowledgement: + +```yaml +cloud_compose_dedicated_host_acknowledged: true +``` + +The role rejects `cloud_compose_runtime.vault.agent_enabled: true`; generated +Vault Agent configuration is currently Terraform-only. It also rejects +`cloud_compose_runtime.extra_env` entries named `HOME` or `PATH`, or beginning with +`CLOUD_COMPOSE_`, `COMPOSE_`, `DOCKER_`, `SITECTL_`, `LIBOPS_`, `GCP_`, +`VAULT_`, `ROLLOUT_`, or `POWER_MANAGEMENT_`. Those names belong to the host +control plane; use unreserved application-specific names for tuning. The role +writes those settings as JSON data at +`/home/cloud-compose/application-env.json`, and app init reconciles them into +each Compose project without exporting them into host processes. +`cloud_compose_extra_env` remains a compatibility fallback when the nested map +is omitted. + +The on-prem adapter rejects +`cloud_compose_runtime.managed_runtime.internal_services_enabled: true` because +the current CAP/lightsout stack and its credentials are GCP-specific. Compose +ingress ports must be whole numbers from 1 through 65535. Lifecycle values +(`compose.init`, `up`, `down`, and `rollout`, plus their per-project +`docker_compose_*` overrides) must be lists of strings. An explicit empty list +is meaningful: it disables that phase and is not replaced by the default. +Runtime feature switches must be YAML booleans, not quoted strings; ambiguous +values are rejected before host mutation. + +The role installs lifecycle dispatchers as `root:cloud-compose` mode `0750` and +the root-consumed `.env`, project/application JSON, and managed-artifact +manifest as `root:cloud-compose` mode `0640`. Reapplying the role restores that +ownership boundary while leaving app checkout directories writable by the +`cloud-compose` account. + The normal on-prem shape is one app per machine. Put each machine in the `cloud_compose` inventory group and set that host's template/runtime variables. For multiple apps on one host, pass the same @@ -25,6 +81,7 @@ all: ansible_user: debian cloud_compose_name: isle-prod cloud_compose_template: isle + cloud_compose_dedicated_host_acknowledged: true cloud_compose_runtime: compose: ingress: @@ -32,10 +89,13 @@ all: acme_email: admin@example.edu sitectl: environment: production + package_versions: + sitectl-isle: v0.18.0 wp-prod.example.edu: ansible_user: debian cloud_compose_name: wp-prod cloud_compose_template: wp + cloud_compose_dedicated_host_acknowledged: true cloud_compose_runtime: compose: ingress: @@ -48,3 +108,21 @@ Run: ```bash ansible-playbook ansible/playbooks/site.yml ``` + +`cloud_compose_runtime.sitectl.package_versions` pins individual release +packages. The selected template supplies an exact compatible default set; an +explicit entry overrides the matching template selector. Template selectors +are filtered to the effective installed package list, so replacing `packages` +does not retain selectors for removed plugins. An installed package with no +template or explicit selector uses the legacy +`cloud_compose_runtime.sitectl.version` fallback, which defaults to `latest`. +The older `cloud_compose_sitectl_version` and +`cloud_compose_sitectl_package_versions` role variables remain supported as +fallbacks when their nested runtime equivalents are omitted. + +Omit `cloud_compose_runtime.sitectl.packages` to use the selected template's +package set. An explicit empty list selects only the core `sitectl` package. +For a multi-project host, an omitted project `sitectl_packages` value inherits +the global set. The host installs the union of global and project package +lists, so a project can add a package but cannot remove one selected globally +or by another project. diff --git a/ansible/inventory.example.yml b/ansible/inventory.example.yml index 1684fe7..98212b1 100644 --- a/ansible/inventory.example.yml +++ b/ansible/inventory.example.yml @@ -6,6 +6,7 @@ all: ansible_user: debian cloud_compose_name: isle-prod cloud_compose_template: isle + cloud_compose_dedicated_host_acknowledged: true cloud_compose_runtime: compose: ingress: @@ -17,6 +18,7 @@ all: ansible_user: debian cloud_compose_name: wp-prod cloud_compose_template: wp + cloud_compose_dedicated_host_acknowledged: true cloud_compose_runtime: compose: ingress: @@ -28,6 +30,7 @@ all: ansible_user: debian cloud_compose_name: drupal-prod cloud_compose_template: drupal + cloud_compose_dedicated_host_acknowledged: true cloud_compose_runtime: compose: ingress: diff --git a/ansible/roles/cloud_compose/defaults/main.yml b/ansible/roles/cloud_compose/defaults/main.yml index 4a01c51..4ddd92b 100644 --- a/ansible/roles/cloud_compose/defaults/main.yml +++ b/ansible/roles/cloud_compose/defaults/main.yml @@ -12,9 +12,10 @@ cloud_compose_home: /home/cloud-compose cloud_compose_data_dir: /mnt/disks/data cloud_compose_volumes_dir: /mnt/disks/volumes -cloud_compose_docker_compose_version: v5.3.0 +cloud_compose_docker_compose_version: v5.3.1 cloud_compose_docker_buildx_version: v0.35.0 cloud_compose_sitectl_version: latest +cloud_compose_sitectl_package_versions: {} cloud_compose_managed_runtime_enabled: true cloud_compose_internal_services_enabled: false @@ -25,6 +26,7 @@ cloud_compose_install_packages: true cloud_compose_reload_systemd: true cloud_compose_run_bootstrap: true cloud_compose_force_bootstrap: false +cloud_compose_dedicated_host_acknowledged: false cloud_compose_bootstrap_timeout: 3600 cloud_compose_bootstrap_poll_interval: 15 @@ -50,7 +52,7 @@ cloud_compose_default_up: cloud_compose_default_down: - 'sitectl compose --context "${SITECTL_CONTEXT_NAME}" down' cloud_compose_default_rollout: - - 'TARGET_REF="${GIT_REF:-${GIT_BRANCH:-${DOCKER_COMPOSE_BRANCH:-main}}}"' - - 'if [ -x ./scripts/rollout.sh ]; then ./scripts/rollout.sh; else sitectl deploy --context "${SITECTL_CONTEXT_NAME}" --branch "$TARGET_REF"; fi' + - 'TARGET_REF="${GIT_REF:-${GIT_BRANCH:-}}"' + - 'if [ -n "$TARGET_REF" ]; then sitectl deploy --context "${SITECTL_CONTEXT_NAME}" --ref "$TARGET_REF"; else sitectl deploy --context "${SITECTL_CONTEXT_NAME}" --skip-git; fi' - 'sitectl healthcheck --context "${SITECTL_CONTEXT_NAME}" --persist' - 'if [ "${SITECTL_ENVIRONMENT}" != "production" ]; then sitectl verify --context "${SITECTL_CONTEXT_NAME}" ${SITECTL_VERIFY_ARGS:-}; fi' diff --git a/ansible/roles/cloud_compose/files/validate-runtime-inputs.py b/ansible/roles/cloud_compose/files/validate-runtime-inputs.py new file mode 100755 index 0000000..5b24be1 --- /dev/null +++ b/ansible/roles/cloud_compose/files/validate-runtime-inputs.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 + +"""Validate host-mutating cloud-compose inputs before an adapter applies them.""" + +import argparse +import base64 +import json +import os +import re +import sys + + +PRODUCTION_DATA_ROOT = "/mnt/disks/data" +CONTROL_CHARACTERS = re.compile(r"[\x00-\x1f\x7f]") +ARTIFACT_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +HTTPS_URL = re.compile(r"^https://[^\s]+$") +LOWER_SHA256 = re.compile(r"^[0-9a-f]{64}$") +FILE_MODE = re.compile(r"^0?[0-7]{3}$") +ACCOUNT_NAME = re.compile(r"^[a-z_][a-z0-9_-]{0,31}\$?$") +SERVICE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.@:-]*\.service$") + + +def normalized_absolute_path(value): + if not isinstance(value, str) or not value.startswith("/") or value == "/": + return False + if CONTROL_CHARACTERS.search(value): + return False + segments = value[1:].split("/") + return all(segment not in ("", ".", "..") for segment in segments) + + +def validate_project_paths(projects, data_root): + errors = [] + if not isinstance(projects, list): + return ["Compose projects must be a list after adapter normalization."] + + for index, project in enumerate(projects): + label = f"project[{index}]" + if not isinstance(project, dict): + errors.append(f"{label} must be an object.") + continue + if isinstance(project.get("name"), str) and project["name"]: + label = f"project {project['name']!r}" + path = project.get("project_dir") + if not normalized_absolute_path(path): + errors.append( + f"{label} project_dir must be a normalized non-root absolute path " + "without empty, dot, or control-character segments." + ) + continue + if not path.startswith(f"{data_root}/"): + errors.append( + f"{label} project_dir must be a non-root descendant of {data_root}." + ) + continue + + resolved = os.path.realpath(path) + try: + within_data_root = os.path.commonpath((data_root, resolved)) == data_root + except ValueError: + within_data_root = False + if not within_data_root or resolved == data_root: + errors.append( + f"{label} project_dir resolves outside the fixed {data_root} boundary: " + f"{path!r} -> {resolved!r}." + ) + return errors + + +def validate_artifacts(artifacts): + errors = [] + if not isinstance(artifacts, list): + return ["Managed artifacts must be a list."] + + names = [] + paths = [] + required_fields = {"name", "url", "sha256", "path"} + + for index, artifact in enumerate(artifacts): + label = f"artifact[{index}]" + if not isinstance(artifact, dict): + errors.append(f"{label} must be an object.") + continue + + missing = sorted(required_fields - artifact.keys()) + if missing: + errors.append(f"{label} is missing required fields: {', '.join(missing)}.") + + name = artifact.get("name") + if not isinstance(name, str) or ARTIFACT_NAME.fullmatch(name) is None: + errors.append(f"{label} name must be a safe basename.") + else: + names.append(name) + + url = artifact.get("url") + if not isinstance(url, str) or HTTPS_URL.fullmatch(url) is None: + errors.append(f"{label} url must be a non-whitespace HTTPS URL.") + + sha256 = artifact.get("sha256") + if not isinstance(sha256, str) or LOWER_SHA256.fullmatch(sha256) is None: + errors.append(f"{label} sha256 must contain exactly 64 lowercase hex characters.") + + path = artifact.get("path") + if not normalized_absolute_path(path): + errors.append( + f"{label} path must be a non-root absolute path without empty, dot, " + "or control-character segments." + ) + else: + paths.append(path) + + mode = artifact.get("mode", "0755") + if not isinstance(mode, str) or FILE_MODE.fullmatch(mode) is None: + errors.append(f"{label} mode must match 0?[0-7]{{3}}.") + + for field in ("owner", "group"): + value = artifact.get(field, "root") + if not isinstance(value, str) or ACCOUNT_NAME.fullmatch(value) is None: + errors.append(f"{label} {field} must be a safe Linux account name.") + + restart = artifact.get("restart", "") + if not isinstance(restart, str) or ( + restart and SERVICE_NAME.fullmatch(restart) is None + ): + errors.append(f"{label} restart must be empty or a safe .service unit name.") + + if len(set(names)) != len(names): + errors.append("Managed artifact names must be unique.") + if len(set(paths)) != len(paths): + errors.append("Managed artifact target paths must be unique.") + return errors + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument( + "--data-root", + default=PRODUCTION_DATA_ROOT, + help=argparse.SUPPRESS, + ) + return parser.parse_args() + + +def main(): + args = parse_args() + if not normalized_absolute_path(args.data_root): + print("The validation data root itself is unsafe.", file=sys.stderr) + return 2 + + raw_payload = os.environ.get("CLOUD_COMPOSE_VALIDATION_PAYLOAD", "") + encoded_payload = os.environ.get("CLOUD_COMPOSE_VALIDATION_PAYLOAD_B64", "") + if encoded_payload: + try: + raw_payload = base64.b64decode(encoded_payload, validate=True).decode("utf-8") + except (ValueError, UnicodeDecodeError) as exc: + print(f"Could not decode base64 cloud-compose validation payload: {exc}", file=sys.stderr) + return 2 + try: + payload = json.loads(raw_payload) + except json.JSONDecodeError as exc: + print(f"Could not decode cloud-compose validation payload: {exc}", file=sys.stderr) + return 2 + if not isinstance(payload, dict): + print("Cloud-compose validation payload must be an object.", file=sys.stderr) + return 2 + + errors = validate_project_paths(payload.get("projects"), args.data_root) + errors.extend(validate_artifacts(payload.get("artifacts"))) + if errors: + print("Cloud-compose host input validation failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 2 + + print(json.dumps({"changed": False, "comment": "Cloud-compose host inputs are valid."})) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ansible/roles/cloud_compose/tasks/main.yml b/ansible/roles/cloud_compose/tasks/main.yml index 595bbf1..5b5675f 100644 --- a/ansible/roles/cloud_compose/tasks/main.yml +++ b/ansible/roles/cloud_compose/tasks/main.yml @@ -14,6 +14,40 @@ - cloud_compose_volumes_dir == "/mnt/disks/volumes" fail_msg: "The packaged cloud-compose rootfs currently requires the cloud-compose user/group and /home/cloud-compose, /mnt/disks/data, and /mnt/disks/volumes paths." +- name: Require valid cloud-compose adapter roots + ansible.builtin.assert: + that: + - cloud_compose_runtime is mapping + - cloud_compose_extra_env is mapping + - (cloud_compose_runtime.extra_env | default(cloud_compose_extra_env)) is mapping + - cloud_compose_provider == "onprem" + - cloud_compose_name is string + - cloud_compose_name is match('^[a-z][a-z0-9-]*$') + - cloud_compose_template is string + - cloud_compose_dedicated_host_acknowledged is boolean + - cloud_compose_dedicated_host_acknowledged + - cloud_compose_install_packages is boolean + - cloud_compose_reload_systemd is boolean + - cloud_compose_run_bootstrap is boolean + - cloud_compose_force_bootstrap is boolean + - cloud_compose_managed_runtime_enabled is boolean + - cloud_compose_internal_services_enabled is boolean + - cloud_compose_internal_services_auto_update is boolean + fail_msg: "The adapter requires an onprem provider, a runtime/extra_env map, a name matching ^[a-z][a-z0-9-]*$, and cloud_compose_dedicated_host_acknowledged=true before it can own Docker and host runtime configuration." + +- name: Require safe cloud-compose environment entries + ansible.builtin.assert: + that: + - item.key is string + - item.key is match('^[A-Za-z_][A-Za-z0-9_]*$') + - item.key not in ['HOME', 'PATH'] + - (item.key | regex_search('^(CLOUD_COMPOSE_|COMPOSE_|DOCKER_|SITECTL_|LIBOPS_|GCP_|VAULT_|ROLLOUT_|POWER_MANAGEMENT_)')) is none + - item.value is string + fail_msg: "cloud_compose_extra_env names must match ^[A-Za-z_][A-Za-z0-9_]*$, must not replace HOME, PATH, or reserved control-plane prefixes, and values must be strings." + loop: "{{ (cloud_compose_runtime.extra_env | default(cloud_compose_extra_env)) | dict2items }}" + loop_control: + label: "{{ item.key }}" + - name: Load cloud-compose app template registry ansible.builtin.set_fact: _cc_registry: "{{ lookup('ansible.builtin.file', cloud_compose_templates_file) | from_json }}" @@ -21,27 +55,133 @@ _cc_sitectl: "{{ cloud_compose_runtime.sitectl | default({}) }}" _cc_managed: "{{ cloud_compose_runtime.managed_runtime | default({}) }}" _cc_vault: "{{ cloud_compose_runtime.vault | default({}) }}" + _cc_extra_env: "{{ cloud_compose_runtime.extra_env | default(cloud_compose_extra_env) }}" + +- name: Require valid cloud-compose runtime sections and template + ansible.builtin.assert: + that: + - _cc_compose is mapping + - _cc_sitectl is mapping + - _cc_managed is mapping + - _cc_vault is mapping + - (_cc_compose.ingress | default({})) is mapping + - (_cc_compose.projects | default({})) is mapping + - (_cc_compose.primary | default('')) is string + - (_cc_sitectl.package_versions | default(cloud_compose_sitectl_package_versions)) is mapping + - (_cc_sitectl.packages | default([])) is sequence + - (_cc_sitectl.packages | default([])) is not string + - (_cc_sitectl.packages | default([])) is not mapping + - (cloud_compose_template | trim | length) == 0 or (cloud_compose_template | lower | trim) in _cc_registry.templates + - (_cc_managed.enabled | default(cloud_compose_managed_runtime_enabled)) is boolean + - (_cc_managed.internal_services_enabled | default(cloud_compose_internal_services_enabled)) is boolean + - (_cc_managed.internal_services_auto_update | default(cloud_compose_internal_services_auto_update)) is boolean + - (_cc_vault.agent_enabled | default(false)) is boolean + - not (_cc_vault.agent_enabled | default(false)) + fail_msg: "runtime sections must be maps, runtime feature switches must be booleans, template must name a supported app, and Vault Agent is currently supported only by Terraform providers (set vault.agent_enabled=false for Ansible)." - name: Resolve cloud-compose template defaults ansible.builtin.set_fact: _cc_template_name: "{{ cloud_compose_template | lower | trim }}" _cc_template: "{{ (_cc_registry.templates[cloud_compose_template | lower | trim] if (cloud_compose_template | trim | length) > 0 else _cc_registry.default) | default(_cc_registry.default, true) }}" +- name: Require a whole-number global ingress port + ansible.builtin.assert: + that: + - >- + _cc_compose.ingress_port is not defined or + ( + _cc_compose.ingress_port is number and + _cc_compose.ingress_port is not boolean and + _cc_compose.ingress_port >= 1 and + _cc_compose.ingress_port <= 65535 and + _cc_compose.ingress_port == (_cc_compose.ingress_port | int) + ) + fail_msg: "compose.ingress_port must be a whole number between 1 and 65535." + - name: Normalize cloud-compose runtime settings ansible.builtin.set_fact: _cc_repo: "{{ _cc_compose.repo | default(_cc_template.repo, true) }}" _cc_branch: "{{ _cc_compose.branch | default(_cc_template.branch, true) }}" - _cc_ingress_port: "{{ _cc_compose.ingress_port | default(80, true) }}" + _cc_ingress_port: "{{ _cc_compose.ingress_port | default(80, true) | int }}" _cc_ingress: "{{ cloud_compose_default_ingress | combine(_cc_compose.ingress | default({}), recursive=True) }}" _cc_sitectl_plugin: "{{ _cc_sitectl.plugin | default(_cc_template.plugin, true) }}" - _cc_sitectl_packages: "{{ (['sitectl'] + (_cc_sitectl.packages | default(_cc_template.packages, true))) | unique }}" + _cc_sitectl_packages: "{{ (['sitectl'] + (_cc_sitectl.packages | default(_cc_template.packages))) | unique }}" + _cc_sitectl_version: "{{ _cc_sitectl.version | default(cloud_compose_sitectl_version) }}" + _cc_template_sitectl_package_versions: "{{ _cc_template.package_versions | default({}) }}" + _cc_sitectl_package_version_overrides: "{{ _cc_sitectl.package_versions | default(cloud_compose_sitectl_package_versions) }}" _cc_sitectl_environment: "{{ _cc_sitectl.environment | default('production', true) }}" _cc_sitectl_context_name: "{{ _cc_sitectl.context_name | default(cloud_compose_name, true) }}" _cc_verify_args: "{{ _cc_sitectl.verify_args | default([], true) }}" - _cc_init_commands: "{{ _cc_compose.init | default(cloud_compose_default_init, true) }}" - _cc_up_commands: "{{ _cc_compose.up | default(cloud_compose_default_up, true) }}" - _cc_down_commands: "{{ _cc_compose.down | default(cloud_compose_default_down, true) }}" - _cc_rollout_commands: "{{ _cc_compose.rollout | default(cloud_compose_default_rollout, true) }}" + _cc_init_commands: "{{ _cc_compose.init | default(cloud_compose_default_init) }}" + _cc_up_commands: "{{ _cc_compose.up | default(cloud_compose_default_up) }}" + _cc_down_commands: "{{ _cc_compose.down | default(cloud_compose_default_down) }}" + _cc_rollout_commands: "{{ _cc_compose.rollout | default(cloud_compose_default_rollout) }}" + _cc_internal_services_enabled: "{{ _cc_managed.internal_services_enabled | default(cloud_compose_internal_services_enabled) }}" + +- name: Reject GCP-only internal services on an on-prem host + ansible.builtin.assert: + that: + - _cc_internal_services_enabled is boolean + - not _cc_internal_services_enabled + fail_msg: "The privileged internal-services stack is GCP-specific and is not supported by the on-prem Ansible adapter; set managed_runtime.internal_services_enabled=false." + +- name: Require valid single-project runtime inputs + ansible.builtin.assert: + that: + - _cc_repo is string + - _cc_branch is string + - _cc_sitectl_packages is sequence + - _cc_sitectl_packages is not string + fail_msg: "compose repository and branch must be strings and sitectl.packages must be a list." + +- name: Require global lifecycle command lists + ansible.builtin.assert: + that: + - _cc_init_commands is sequence + - _cc_init_commands is not string + - _cc_init_commands is not mapping + - _cc_up_commands is sequence + - _cc_up_commands is not string + - _cc_up_commands is not mapping + - _cc_down_commands is sequence + - _cc_down_commands is not string + - _cc_down_commands is not mapping + - _cc_rollout_commands is sequence + - _cc_rollout_commands is not string + - _cc_rollout_commands is not mapping + fail_msg: "compose init, up, down, and rollout lifecycle commands must be lists of strings." + +- name: Require global lifecycle command strings + ansible.builtin.assert: + that: + - (_cc_init_commands | reject('string') | list | length) == 0 + - (_cc_up_commands | reject('string') | list | length) == 0 + - (_cc_down_commands | reject('string') | list | length) == 0 + - (_cc_rollout_commands | reject('string') | list | length) == 0 + fail_msg: "compose init, up, down, and rollout lifecycle commands must be lists of strings." + +- name: Require valid sitectl version inputs + ansible.builtin.assert: + that: + - _cc_sitectl_package_version_overrides is mapping + - >- + (_cc_sitectl_version | string) == 'latest' or + (_cc_sitectl_version | string) is match('^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$') + fail_msg: "sitectl.version must be latest or an exact semantic-version release tag, and sitectl.package_versions must be a map." + +- name: Require valid sitectl package version overrides + ansible.builtin.assert: + that: + - item.key is string + - (item.key | string) is match('^sitectl(-[a-z0-9]+)*$') + - item.value is string + - >- + (item.value | string) == 'latest' or + (item.value | string) is match('^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$') + fail_msg: "sitectl.package_versions keys must be sitectl package names and values must be latest or exact semantic-version release tags." + loop: "{{ _cc_sitectl_package_version_overrides | dict2items }}" + loop_control: + label: "{{ item.key }}" - name: Derive cloud-compose project paths ansible.builtin.set_fact: @@ -51,6 +191,38 @@ _cc_explicit_projects: "{{ _cc_compose.projects | default({}, true) }}" _cc_managed_artifacts: "{{ _cc_managed.artifacts | default(cloud_compose_managed_artifacts, true) }}" +- name: Require an explicit project map + ansible.builtin.assert: + that: + - _cc_explicit_projects is mapping + fail_msg: "cloud_compose_runtime.compose.projects must be a map." + +- name: Require valid explicit cloud-compose projects + ansible.builtin.assert: + that: + - item.key is string + - item.key is match('^[a-z][a-z0-9-]*$') + - item.value is mapping + - (item.value.docker_compose_repo | default(item.value.repo | default('', true), true)) is string + - (item.value.docker_compose_repo | default(item.value.repo | default('', true), true) | string | trim | length) > 0 + - (item.value.ingress | default({})) is mapping + - >- + item.value.ingress_port is not defined or + ( + item.value.ingress_port is number and + item.value.ingress_port is not boolean and + item.value.ingress_port >= 1 and + item.value.ingress_port <= 65535 and + item.value.ingress_port == (item.value.ingress_port | int) + ) + - (item.value.sitectl_packages | default(_cc_sitectl_packages)) is sequence + - (item.value.sitectl_packages | default(_cc_sitectl_packages)) is not string + - (item.value.sitectl_packages | default(_cc_sitectl_packages)) is not mapping + fail_msg: "compose.projects keys must match ^[a-z][a-z0-9-]*$, repositories are required, ingress ports must be whole numbers between 1 and 65535, and sitectl_packages must be lists." + loop: "{{ _cc_explicit_projects | dict2items }}" + loop_control: + label: "{{ item.key }}" + - name: Require a compose repository ansible.builtin.assert: that: @@ -87,11 +259,11 @@ _cc_explicit_project_map: "{{ _cc_explicit_project_map | combine({item.key: _cc_project}, recursive=False) }}" vars: _cc_app: "{{ item.value }}" - _cc_app_repo: "{{ item.value.docker_compose_repo | default(item.value.repo | default(_cc_repo, true), true) }}" + _cc_app_repo: "{{ item.value.docker_compose_repo | default(item.value.repo | default('', true), true) }}" _cc_app_branch: "{{ item.value.docker_compose_branch | default(item.value.branch | default(_cc_branch, true), true) }}" _cc_app_repo_path: "{{ item.value.repo_path | default((_cc_app_repo | regex_replace('^[^:]+://[^/]+/', '') | regex_replace('/+$', '')), true) }}" _cc_app_ingress: "{{ _cc_ingress | combine(item.value.ingress | default({}), recursive=True) }}" - _cc_app_packages: "{{ (['sitectl'] + (item.value.sitectl_packages | default(_cc_sitectl_packages, true))) | unique }}" + _cc_app_packages: "{{ (['sitectl'] + (item.value.sitectl_packages | default(_cc_sitectl_packages))) | unique }}" _cc_project: name: "{{ item.key }}" docker_compose_repo: "{{ _cc_app_repo }}" @@ -99,24 +271,56 @@ repo_path: "{{ _cc_app_repo_path }}" project_dir: "{{ item.value.project_dir | default(cloud_compose_data_dir ~ '/' ~ _cc_app_repo_path ~ '/' ~ _cc_app_branch, true) }}" compose_project_name: "{{ item.value.compose_project_name | default((_cc_app_repo_path ~ '-' ~ _cc_app_branch) | lower | regex_replace('\\.git$', '') | regex_replace('[^a-zA-Z0-9]', '-') | regex_replace('-+', '-'), true) }}" - ingress_port: "{{ item.value.ingress_port | default(_cc_ingress_port, true) }}" + ingress_port: "{{ item.value.ingress_port | default(_cc_ingress_port) }}" ingress: "{{ _cc_app_ingress }}" sitectl_context_name: "{{ item.value.sitectl_context_name | default(item.key, true) }}" sitectl_plugin: "{{ item.value.sitectl_plugin | default(_cc_sitectl_plugin, true) }}" sitectl_environment: "{{ item.value.sitectl_environment | default(_cc_sitectl_environment, true) }}" sitectl_packages: "{{ _cc_app_packages }}" sitectl_verify_args: "{{ item.value.sitectl_verify_args | default(_cc_verify_args, true) }}" - init_commands: "{{ item.value.init_commands | default(item.value.docker_compose_init | default(_cc_init_commands, true), true) }}" - up_commands: "{{ item.value.up_commands | default(item.value.docker_compose_up | default(_cc_up_commands, true), true) }}" - down_commands: "{{ item.value.down_commands | default(item.value.docker_compose_down | default(_cc_down_commands, true), true) }}" - rollout_commands: "{{ item.value.rollout_commands | default(item.value.docker_compose_rollout | default(_cc_rollout_commands, true), true) }}" + init_commands: "{{ item.value.init_commands | default(item.value.docker_compose_init | default(_cc_init_commands)) }}" + up_commands: "{{ item.value.up_commands | default(item.value.docker_compose_up | default(_cc_up_commands)) }}" + down_commands: "{{ item.value.down_commands | default(item.value.docker_compose_down | default(_cc_down_commands)) }}" + rollout_commands: "{{ item.value.rollout_commands | default(item.value.docker_compose_rollout | default(_cc_rollout_commands)) }}" loop: "{{ _cc_explicit_projects | dict2items }}" when: _cc_explicit_projects | length > 0 - name: Select cloud-compose projects ansible.builtin.set_fact: _cc_compose_projects: "{{ _cc_explicit_project_map if (_cc_explicit_projects | length) > 0 else dict([(cloud_compose_name, _cc_single_project)]) }}" - _cc_primary_key: "{{ _cc_compose.primary | default(((_cc_explicit_project_map.keys() | list | first) if (_cc_explicit_projects | length) > 0 else cloud_compose_name), true) }}" + _cc_primary_key: "{{ _cc_compose.primary | default(((_cc_explicit_project_map.keys() | list | sort | first) if (_cc_explicit_projects | length) > 0 else cloud_compose_name), true) }}" + +- name: Require normalized lifecycle command lists + ansible.builtin.assert: + that: + - item.value.init_commands is sequence + - item.value.init_commands is not string + - item.value.init_commands is not mapping + - item.value.up_commands is sequence + - item.value.up_commands is not string + - item.value.up_commands is not mapping + - item.value.down_commands is sequence + - item.value.down_commands is not string + - item.value.down_commands is not mapping + - item.value.rollout_commands is sequence + - item.value.rollout_commands is not string + - item.value.rollout_commands is not mapping + fail_msg: "Lifecycle init, up, down, and rollout commands for every compose project must be lists of strings." + loop: "{{ _cc_compose_projects | dict2items }}" + loop_control: + label: "{{ item.key }}" + +- name: Require normalized lifecycle command strings + ansible.builtin.assert: + that: + - (item.value.init_commands | reject('string') | list | length) == 0 + - (item.value.up_commands | reject('string') | list | length) == 0 + - (item.value.down_commands | reject('string') | list | length) == 0 + - (item.value.rollout_commands | reject('string') | list | length) == 0 + fail_msg: "Lifecycle init, up, down, and rollout commands for every compose project must be lists of strings." + loop: "{{ _cc_compose_projects | dict2items }}" + loop_control: + label: "{{ item.key }}" - name: Require a valid primary cloud-compose project ansible.builtin.assert: @@ -128,6 +332,39 @@ ansible.builtin.set_fact: _cc_primary_project: "{{ _cc_compose_projects[_cc_primary_key] }}" +- name: Validate project directory and managed artifact host boundaries + ansible.builtin.script: + cmd: "{{ role_path }}/files/validate-runtime-inputs.py" + executable: "{{ ansible_facts.discovered_interpreter_python | default(ansible_python_interpreter | default('/usr/bin/python3')) }}" + environment: + CLOUD_COMPOSE_VALIDATION_PAYLOAD_B64: >- + {{ {'projects': (_cc_compose_projects.values() | list), 'artifacts': _cc_managed_artifacts} | to_json | b64encode }} + changed_when: false + +- name: Initialize resolved sitectl package versions + ansible.builtin.set_fact: + _cc_all_sitectl_packages: "{{ (_cc_sitectl_packages + (_cc_compose_projects.values() | map(attribute='sitectl_packages') | flatten)) | unique }}" + _cc_sitectl_package_versions: {} + +- name: Require package version overrides to select installed packages + ansible.builtin.assert: + that: + - (_cc_sitectl_package_version_overrides.keys() | difference(_cc_all_sitectl_packages) | list | length) == 0 + fail_msg: "package_versions may only select installed sitectl packages." + +- name: Require valid installed sitectl package names + ansible.builtin.assert: + that: + - item is string + - (item | string) is match('^sitectl(-[a-z0-9]+)*$') + fail_msg: "sitectl packages must use valid release package names." + loop: "{{ _cc_all_sitectl_packages }}" + +- name: Resolve sitectl package versions + ansible.builtin.set_fact: + _cc_sitectl_package_versions: "{{ _cc_sitectl_package_versions | combine({item: (_cc_sitectl_package_version_overrides[item] | default(_cc_template_sitectl_package_versions[item] | default(_cc_sitectl_version)) | string)}) }}" + loop: "{{ _cc_all_sitectl_packages }}" + - name: Build cloud-compose environment ansible.builtin.set_fact: _cc_env_base: @@ -150,8 +387,9 @@ GCP_REGION: "" GCP_ZONE: "" GCP_APP_SERVICE_ACCOUNT_EMAIL: "" - SITECTL_PACKAGES: "{{ (_cc_sitectl_packages + (_cc_compose_projects.values() | map(attribute='sitectl_packages') | flatten)) | unique | join(' ') }}" - SITECTL_VERSION: "{{ cloud_compose_sitectl_version }}" + SITECTL_PACKAGES: "{{ _cc_all_sitectl_packages | join(' ') }}" + SITECTL_VERSION: "{{ _cc_sitectl_version | string }}" + SITECTL_PACKAGE_VERSIONS: "{{ _cc_sitectl_package_versions | to_json }}" SITECTL_CONTEXT_NAME: "{{ _cc_primary_project.sitectl_context_name }}" SITECTL_PLUGIN: "{{ _cc_primary_project.sitectl_plugin }}" SITECTL_ENVIRONMENT: "{{ _cc_primary_project.sitectl_environment }}" @@ -164,14 +402,14 @@ VAULT_AGENT_ENABLED: "{{ ((_cc_vault.agent_enabled | default(false, true)) and (_cc_vault.addr | default('', true) | trim | length > 0)) | ternary('true', 'false') }}" VAULT_AUTH_METHOD: "{{ _cc_vault.auth_method | default('consumer-managed', true) }}" VAULT_AGENT_TOKEN_PATH: "{{ _cc_vault.agent_token_path | default('/mnt/disks/data/vault/token', true) }}" - LIBOPS_MANAGED_RUNTIME_ENABLED: "{{ (_cc_managed.enabled | default(cloud_compose_managed_runtime_enabled, true)) | ternary('true', 'false') }}" - LIBOPS_INTERNAL_SERVICES_ENABLED: "{{ (_cc_managed.internal_services_enabled | default(cloud_compose_internal_services_enabled, true)) | ternary('true', 'false') }}" - LIBOPS_INTERNAL_SERVICES_AUTO_UPDATE: "{{ (_cc_managed.internal_services_auto_update | default(cloud_compose_internal_services_auto_update, true)) | ternary('true', 'false') }}" + LIBOPS_MANAGED_RUNTIME_ENABLED: "{{ (_cc_managed.enabled | default(cloud_compose_managed_runtime_enabled)) | ternary('true', 'false') }}" + LIBOPS_INTERNAL_SERVICES_ENABLED: "{{ _cc_internal_services_enabled | ternary('true', 'false') }}" + LIBOPS_INTERNAL_SERVICES_AUTO_UPDATE: "{{ (_cc_managed.internal_services_auto_update | default(cloud_compose_internal_services_auto_update)) | ternary('true', 'false') }}" INTERNAL_SERVICES_COMPOSE_PROFILES: "" -- name: Merge extra cloud-compose environment +- name: Keep host controls separate from application environment ansible.builtin.set_fact: - _cc_env: "{{ _cc_env_base | combine(cloud_compose_extra_env, recursive=False) }}" + _cc_env: "{{ _cc_env_base }}" - name: Install Debian runtime dependencies ansible.builtin.apt: @@ -182,6 +420,7 @@ - git - jq - make + - openssl state: present update_cache: true when: cloud_compose_install_packages | bool @@ -232,6 +471,35 @@ dest: / mode: preserve +- name: Inspect cloud-compose lifecycle lock directory + ansible.builtin.stat: + path: /run/lock/cloud-compose + follow: false + register: cloud_compose_lifecycle_lock_dir + +- name: Inspect cloud-compose lifecycle lock file + ansible.builtin.stat: + path: /run/lock/cloud-compose/lifecycle.lock + follow: false + register: cloud_compose_lifecycle_lock_file + +- name: Prepare cloud-compose lifecycle lock + ansible.builtin.command: + cmd: systemd-tmpfiles --create /etc/tmpfiles.d/cloud-compose.conf + changed_when: >- + not ( + (cloud_compose_lifecycle_lock_dir.stat.isdir | default(false)) and + not (cloud_compose_lifecycle_lock_dir.stat.islnk | default(false)) and + (cloud_compose_lifecycle_lock_dir.stat.uid | default(-1)) == 0 and + (cloud_compose_lifecycle_lock_dir.stat.gr_name | default('')) == cloud_compose_group and + (cloud_compose_lifecycle_lock_dir.stat.mode | default('')) == '0750' and + (cloud_compose_lifecycle_lock_file.stat.isreg | default(false)) and + not (cloud_compose_lifecycle_lock_file.stat.islnk | default(false)) and + (cloud_compose_lifecycle_lock_file.stat.uid | default(-1)) == 0 and + (cloud_compose_lifecycle_lock_file.stat.gr_name | default('')) == cloud_compose_group and + (cloud_compose_lifecycle_lock_file.stat.mode | default('')) == '0660' + ) + - name: Find cloud-compose shell scripts ansible.builtin.find: paths: "{{ cloud_compose_home }}" @@ -249,9 +517,9 @@ - name: Write cloud-compose lifecycle dispatchers ansible.builtin.copy: dest: "{{ cloud_compose_home }}/{{ item }}" - owner: "{{ cloud_compose_user }}" + owner: root group: "{{ cloud_compose_group }}" - mode: "0755" + mode: "0750" content: | #!/usr/bin/env bash @@ -269,15 +537,24 @@ ansible.builtin.template: src: env.j2 dest: "{{ cloud_compose_home }}/.env" - owner: "{{ cloud_compose_user }}" + owner: root + group: "{{ cloud_compose_group }}" + mode: "0640" + +- name: Write Compose application environment data + ansible.builtin.copy: + dest: "{{ cloud_compose_home }}/application-env.json" + owner: root group: "{{ cloud_compose_group }}" mode: "0640" + content: "{{ _cc_extra_env | to_json }}\n" + no_log: true - name: Write cloud-compose project manifest ansible.builtin.template: src: compose-projects.json.j2 dest: "{{ cloud_compose_home }}/compose-projects.json" - owner: "{{ cloud_compose_user }}" + owner: root group: "{{ cloud_compose_group }}" mode: "0640" @@ -285,9 +562,10 @@ ansible.builtin.template: src: managed-runtime-artifacts.tsv.j2 dest: "{{ cloud_compose_home }}/managed-runtime-artifacts.tsv" - owner: "{{ cloud_compose_user }}" + owner: root group: "{{ cloud_compose_group }}" mode: "0640" + no_log: true - name: Reload systemd units ansible.builtin.systemd: diff --git a/ansible/roles/cloud_compose/templates/env.j2 b/ansible/roles/cloud_compose/templates/env.j2 index 9699cd3..9961ddf 100644 --- a/ansible/roles/cloud_compose/templates/env.j2 +++ b/ansible/roles/cloud_compose/templates/env.j2 @@ -1,3 +1,4 @@ +{# cloud-compose-env-contract: Docker Compose double-quoted dotenv data; never source this file #} {% for item in _cc_env | dict2items | sort(attribute='key') %} -{{ item.key }}={{ item.value | string | to_json }} +{{ item.key }}="{{ item.value | string | replace("\\", "\\\\") | replace('"', '\\"') | replace("$", "$$") | replace("\n", "\\n") | replace("\r", "\\r") | replace("\t", "\\t") }}" {% endfor %} diff --git a/ci/application-env-contract.sh b/ci/application-env-contract.sh new file mode 100755 index 0000000..7fad2f5 --- /dev/null +++ b/ci/application-env-contract.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +tmp="$(mktemp -d "${TMPDIR:-/tmp}/cloud-compose-application-env.XXXXXX")" +trap 'rm -rf "$tmp"' EXIT + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || { + echo "Missing required command: $1" >&2 + exit 1 + } +} + +require_cmd bash +require_cmd base64 +require_cmd jq + +profile="$repo_root/rootfs/home/cloud-compose/profile.sh" +rollout_service="$repo_root/rootfs/home/cloud-compose/run-rollout-service.sh" +host_env="$tmp/host.env" +application_env="$tmp/application-env.json" +bash_env_payload="$tmp/untrusted-bash-env.sh" +bash_env_marker="$tmp/bash-env-executed" + +cat >"$host_env" <<'EOF' +HOME="/home/cloud-compose" +ROLLOUT_PORT="8081" +ROLLOUT_JWKS_URI="https://trusted.example/.well-known/jwks.json" +ROLLOUT_JWT_AUD="trusted-controller" +ROLLOUT_CUSTOM_CLAIMS="{\"role\":\"deployer\"}" +EOF + +cat >"$bash_env_payload" <"$application_env" + +for app in alpha beta; do + mkdir -p "$tmp/$app" + cat >"$tmp/$app/.env" <<'EOF' +# Downstream syntax is opaque and must survive reconciliation. +DOMAIN=example.org +EXPANDED=${DOMAIN}/path +EOF +done + +# The child shell receives file paths as positional parameters. +# shellcheck disable=SC2016 +env -i \ + PATH=/usr/bin:/bin \ + CLOUD_COMPOSE_ENV_FILE="$host_env" \ + CLOUD_COMPOSE_APPLICATION_ENV_FILE="$application_env" \ + bash --noprofile --norc -c ' + set -euo pipefail + source "$1" + shift + for project_dir in "$@"; do + cd "$project_dir" + sync_compose_application_env + update_compose_env COMPOSE_PROJECT_NAME "managed-${project_dir##*/}" + update_compose_env SITE_NAME "site-${project_dir##*/}" + update_compose_env COMPOSE_BIND_PORT "8080" + done + test -z "${BASH_ENV+x}" + test -z "${LD_PRELOAD+x}" + bash --noprofile --norc -c true + ' application-env "$profile" "$tmp/alpha" "$tmp/beta" + +test ! -e "$bash_env_marker" + +assert_compose_value() { + local env_file="$1" name="$2" expected="$3" assignment encoded decoded + + # The dollar expression belongs to awk. + # shellcheck disable=SC2016 + assignment="$(awk -v marker="# cloud-compose application: $name" ' + found { print; exit } + $0 == marker { found = 1 } + ' "$env_file")" + [[ "$assignment" == "$name="* ]] || { + echo "$name was not reconciled into $env_file" >&2 + exit 1 + } + encoded="${assignment#*=\"}" + encoded="${encoded%\"}" + # The child shell receives values as positional parameters. + # shellcheck disable=SC2016 + decoded="$( + CLOUD_COMPOSE_ENV_FILE="$host_env" bash --noprofile --norc -c ' + source "$1" + decode_runtime_env_value "$2" + printf "%s\037" "$RUNTIME_ENV_DECODED" + ' application-env "$profile" "$encoded" + )" + decoded="${decoded%$'\x1f'}" + [[ "$decoded" == "$expected" ]] || { + echo "$name changed while being written to $env_file" >&2 + exit 1 + } +} + +for app in alpha beta; do + assert_compose_value "$tmp/$app/.env" BASH_ENV "$bash_env_payload" + assert_compose_value "$tmp/$app/.env" LD_PRELOAD "$tmp/untrusted-preload.so" + assert_compose_value "$tmp/$app/.env" PORT "9999" + assert_compose_value "$tmp/$app/.env" JWKS_URI "https://attacker.invalid/jwks.json" + assert_compose_value "$tmp/$app/.env" JWT_AUD "attacker" + assert_compose_value "$tmp/$app/.env" CUSTOM_CLAIMS '{"admin":true}' + assert_compose_value "$tmp/$app/.env" APPLICATION_LITERAL $'quotes " and dollars $HOME $(touch /tmp/never)\nline two\n' + grep -Fq 'EXPANDED=${DOMAIN}/path' "$tmp/$app/.env" + test "$(grep -Fc '# cloud-compose managed: COMPOSE_PROJECT_NAME' "$tmp/$app/.env")" = 1 + test "$(tail -n 1 "$tmp/$app/.env")" = 'COMPOSE_BIND_PORT="8080"' +done + +mkdir -p "$tmp/invalid" +for invalid_application_data in \ + '{"SAFE\n":"value"}' \ + '{"SAFE\u0000":"value"}'; do + printf '%s' "$invalid_application_data" >"$tmp/application-env.invalid.json" + if env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_ENV_FILE="$host_env" \ + CLOUD_COMPOSE_APPLICATION_ENV_FILE="$tmp/application-env.invalid.json" \ + bash --noprofile --norc -c ' + source "$1" + cd "$2" + sync_compose_application_env + ' application-env "$profile" "$tmp/invalid" >/dev/null 2>&1; then + echo "Application environment accepted a control-character name" >&2 + exit 1 + fi + test ! -e "$tmp/invalid/.env" +done + +# Removing a key from desired state removes only its marked application entry. +jq 'del(.APPLICATION_LITERAL)' "$application_env" >"$tmp/application-env.next.json" +# The child shell receives file paths as positional parameters. +# shellcheck disable=SC2016 +env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_ENV_FILE="$host_env" \ + CLOUD_COMPOSE_APPLICATION_ENV_FILE="$tmp/application-env.next.json" \ + bash --noprofile --norc -c ' + source "$1" + cd "$2" + sync_compose_application_env + ' application-env "$profile" "$tmp/alpha" +! grep -Fq '# cloud-compose application: APPLICATION_LITERAL' "$tmp/alpha/.env" +grep -Fq '# cloud-compose managed: COMPOSE_BIND_PORT' "$tmp/alpha/.env" + +fake_rollout="$tmp/fake-rollout" +rollout_result="$tmp/rollout-result" +cat >"$fake_rollout" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +test -z "${BASH_ENV+x}" +test -z "${LD_PRELOAD+x}" +jq -n \ + --arg port "$PORT" \ + --arg jwks "$JWKS_URI" \ + --arg aud "$JWT_AUD" \ + --arg claims "$CUSTOM_CLAIMS" \ + '{port: $port, jwks: $jwks, aud: $aud, claims: $claims}' >"$ROLLOUT_RESULT" +EOF +chmod +x "$fake_rollout" + +env -i \ + PATH=/usr/bin:/bin \ + CLOUD_COMPOSE_ENV_FILE="$host_env" \ + CLOUD_COMPOSE_PROFILE_PATH="$profile" \ + CLOUD_COMPOSE_ROLLOUT_BIN="$fake_rollout" \ + ROLLOUT_RESULT="$rollout_result" \ + bash --noprofile --norc "$rollout_service" + +jq -e ' + .port == "8081" and + .jwks == "https://trusted.example/.well-known/jwks.json" and + .aud == "trusted-controller" and + .claims == "{\"role\":\"deployer\"}" +' "$rollout_result" >/dev/null +test ! -e "$bash_env_marker" + +insecure_rollout_env="$tmp/host-insecure-rollout.env" +invalid_claims_env="$tmp/host-invalid-claims.env" +sed 's#^ROLLOUT_JWKS_URI=.*#ROLLOUT_JWKS_URI="http://insecure.invalid/jwks.json"#' \ + "$host_env" >"$insecure_rollout_env" +sed 's#^ROLLOUT_CUSTOM_CLAIMS=.*#ROLLOUT_CUSTOM_CLAIMS="[\\"not-an-object\\"]"#' \ + "$host_env" >"$invalid_claims_env" + +if env -i \ + PATH=/usr/bin:/bin \ + CLOUD_COMPOSE_ENV_FILE="$insecure_rollout_env" \ + CLOUD_COMPOSE_PROFILE_PATH="$profile" \ + CLOUD_COMPOSE_ROLLOUT_BIN="$fake_rollout" \ + ROLLOUT_RESULT="$rollout_result" \ + bash --noprofile --norc "$rollout_service" >/dev/null 2>&1; then + echo "Rollout runtime accepted an insecure JWKS URL" >&2 + exit 1 +fi + +if env -i \ + PATH=/usr/bin:/bin \ + CLOUD_COMPOSE_ENV_FILE="$invalid_claims_env" \ + CLOUD_COMPOSE_PROFILE_PATH="$profile" \ + CLOUD_COMPOSE_ROLLOUT_BIN="$fake_rollout" \ + ROLLOUT_RESULT="$rollout_result" \ + bash --noprofile --norc "$rollout_service" >/dev/null 2>&1; then + echo "Rollout runtime accepted non-object custom claims" >&2 + exit 1 +fi + +if grep -Eq '(^|[,[:space:]])var\.extra_env\)?$' \ + "$repo_root/modules/gcp/main.tf" "$repo_root/modules/linux-vm-runtime/main.tf"; then + echo "Application extra_env is still merged into a host environment map" >&2 + exit 1 +fi + +echo "Application environment trust-boundary contract passed" diff --git a/ci/backup-contract.sh b/ci/backup-contract.sh new file mode 100644 index 0000000..fac4c02 --- /dev/null +++ b/ci/backup-contract.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +backup_script="$repo_root/rootfs/home/cloud-compose/mariadb-backup.sh" +tmp="$(mktemp -d)" +trap 'rm -rf -- "$tmp"' EXIT + +fail() { + echo "backup contract: $*" >&2 + exit 1 +} + +mkdir -p "$tmp/bin" +cat >"$tmp/profile.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +export PATH="${TEST_BIN:?}:/usr/bin:/bin" +acquire_cloud_compose_lifecycle_lock() { + printf '%s\n' "$1" >>"${LOCK_LOG:?}" +} +EOF +cat >"$tmp/compose-apps.sh" <<'EOF' +#!/usr/bin/env bash +compose_app_names_array() { + local -n result="$1" + result=(alpha) +} +source_compose_app_env() { + SITECTL_CONTEXT_NAME="$1-context" + export SITECTL_CONTEXT_NAME +} +EOF +cat >"$tmp/bin/systemctl" <<'EOF' +#!/usr/bin/env bash +[[ "${1:-}" == "is-active" && "${FAKE_APP_ACTIVE:-true}" == "true" ]] +EOF +cat >"$tmp/bin/sitectl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +output="" +while (($# > 0)); do + if [[ "$1" == "--output" ]]; then + output="$2" + shift 2 + continue + fi + shift +done +[[ -n "$output" ]] +printf 'CALL\n' >>"${SITECTL_LOG:?}" +case "${FAKE_BACKUP_MODE:-success}" in + success) printf 'SQL backup\n' | gzip -c >"$output" ;; + partial) printf 'not gzip\n' >"$output"; exit 1 ;; + *) exit 1 ;; +esac +EOF +chmod +x "$tmp/bin/systemctl" "$tmp/bin/sitectl" + +export TEST_BIN="$tmp/bin" +export LOCK_LOG="$tmp/lock.log" +export SITECTL_LOG="$tmp/sitectl.log" +export CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" +export CLOUD_COMPOSE_COMPOSE_APPS_PATH="$tmp/compose-apps.sh" +export MARIADB_BACKUP_ROOT="$tmp/backups" +: >"$LOCK_LOG" +: >"$SITECTL_LOG" + +FAKE_APP_ACTIVE=false bash "$backup_script" +[[ ! -s "$SITECTL_LOG" ]] || fail "inactive application was backed up or started" +grep -Fxq mariadb-backup "$LOCK_LOG" || fail "backup did not acquire the shared lifecycle lock" + +: >"$LOCK_LOG" +FAKE_APP_ACTIVE=true bash "$backup_script" +backup="$(find "$MARIADB_BACKUP_ROOT/alpha" -maxdepth 1 -type f -name '*.sql.gz' -print -quit)" +[[ -n "$backup" && -s "$backup" ]] || fail "valid backup was not published" +gzip -t -- "$backup" || fail "published backup is not valid gzip" +[[ "$(find "$MARIADB_BACKUP_ROOT/alpha" -maxdepth 1 -type d -name '*.staging.*' | wc -l)" == 0 ]] || \ + fail "successful backup retained staging data" + +calls_before="$(wc -l <"$SITECTL_LOG")" +FAKE_APP_ACTIVE=true bash "$backup_script" +[[ "$(wc -l <"$SITECTL_LOG")" == "$calls_before" ]] || fail "validated existing backup was regenerated" + +rm -f -- "$backup" +if FAKE_APP_ACTIVE=true FAKE_BACKUP_MODE=partial bash "$backup_script" >/dev/null 2>&1; then + fail "partial backup command was accepted" +fi +[[ -z "$(find "$MARIADB_BACKUP_ROOT/alpha" -maxdepth 1 -type f -name '*.sql.gz' -print -quit)" ]] || \ + fail "partial backup was published as final" +[[ "$(find "$MARIADB_BACKUP_ROOT/alpha" -maxdepth 1 -type d -name '*.staging.*' | wc -l)" == 0 ]] || \ + fail "failed backup retained staging data" + +printf 'corrupt\n' >"$MARIADB_BACKUP_ROOT/alpha/$(date -u +%Y%m%d)-alpha.sql.gz" +if FAKE_APP_ACTIVE=true bash "$backup_script" >/dev/null 2>&1; then + fail "corrupt existing backup was treated as complete" +fi + +echo "Backup contract passed" diff --git a/ci/cloud-smoke-cleanup-contract.sh b/ci/cloud-smoke-cleanup-contract.sh index fabfbb5..e12683c 100644 --- a/ci/cloud-smoke-cleanup-contract.sh +++ b/ci/cloud-smoke-cleanup-contract.sh @@ -80,7 +80,9 @@ case "${1:-} ${2:-} ${3:-} ${4:-}" in ;; "compute firewall-rules list "*) if ! mutation_done firewalls-delete; then - printf 'allow-ssh-ipv4-cc-g-wp-12345678-abcd\n' + printf '%s\n' \ + 'allow-ssh-ipv4-cc-g-wp-12345678-abcd' \ + 'allow-cloud-run-cc-g-wp-12345678-abcd' fi exit 0 ;; @@ -234,6 +236,8 @@ grep -F 'gcloud compute instances list' "$success_log" | grep -Fq 'cc-g-wp-12345 fail "GCP cleanup did not constrain resources to the originating workflow run" grep -F 'gcloud iam service-accounts list' "$success_log" | grep -Fq 'ppb-' || \ fail "GCP cleanup does not select the power-button service account" +grep -F 'gcloud compute firewall-rules list' "$success_log" | grep -Fq 'allow-cloud-run-' || \ + fail "GCP cleanup does not select the VM ingress firewall for Direct VPC egress" for role in \ roles/logging.logWriter \ roles/monitoring.metricWriter \ diff --git a/ci/cloud-smoke.sh b/ci/cloud-smoke.sh index c5f6a8a..7813396 100755 --- a/ci/cloud-smoke.sh +++ b/ci/cloud-smoke.sh @@ -318,7 +318,7 @@ gcp_smoke_residuals() { if ! output="$(gcloud compute firewall-rules list \ --project "$project" \ - --filter="name~'^(allow-ssh-ipv4-|allow-ssh-ipv6-|allow-rollout-ipv4-)${name_filter#^}'" \ + --filter="name~'^(allow-ssh-ipv4-|allow-ssh-ipv6-|allow-rollout-ipv4-|allow-cloud-run-)${name_filter#^}'" \ --format='value(name)')"; then echo "Could not verify GCP firewall cleanup" >&2 return 1 @@ -570,7 +570,7 @@ provider_tag_cleanup() { if ! firewall_names="$(gcloud compute firewall-rules list \ --project "$project" \ - --filter="name~'^(allow-ssh-ipv4-|allow-ssh-ipv6-|allow-rollout-ipv4-)${name_filter#^}'" \ + --filter="name~'^(allow-ssh-ipv4-|allow-ssh-ipv6-|allow-rollout-ipv4-|allow-cloud-run-)${name_filter#^}'" \ --format='value(name)')"; then cleanup_status=1 firewall_names="" @@ -706,6 +706,7 @@ target_workdir() { target_var_args() { local root="$1" key_path="$2" target="$3" public_key provider template + local source_ref source_sha256 source_cache_key checksum_dir checksum_file archive_tmp provider="$(target_provider "$target")" template="$(target_template "$target")" @@ -724,7 +725,34 @@ target_var_args() { printf '%s\0%s\0' "-var" "template=${template}" fi if grep -q 'variable "cloud_compose_source_ref"' "$root/variables.tf"; then - printf '%s\0%s\0' "-var" "cloud_compose_source_ref=${CLOUD_COMPOSE_SOURCE_REF:-${GITHUB_SHA:-main}}" + source_ref="${CLOUD_COMPOSE_SOURCE_REF:-${GITHUB_SHA:-main}}" + printf '%s\0%s\0' "-var" "cloud_compose_source_ref=${source_ref}" + fi + if grep -q 'variable "cloud_compose_source_sha256"' "$root/variables.tf"; then + source_sha256="${CLOUD_COMPOSE_SOURCE_SHA256:-}" + if [[ -z "$source_sha256" ]]; then + checksum_dir="$(target_workdir "$target")" + source_cache_key="$(printf '%s' "$source_ref" | sha256sum | awk '{print $1}')" + checksum_file="${checksum_dir}/cloud-compose-source-${source_cache_key}.sha256" + mkdir -p "$checksum_dir" + if [[ -s "$checksum_file" ]]; then + source_sha256="$(<"$checksum_file")" + else + archive_tmp="$(mktemp "${checksum_dir}/cloud-compose-source.XXXXXX.tar.gz")" + echo "Calculating SHA-256 for cloud-compose source archive ${source_ref}" >&2 + curl -fsSL --retry 3 \ + "https://github.com/libops/cloud-compose/archive/${source_ref}.tar.gz" \ + -o "$archive_tmp" + source_sha256="$(sha256sum "$archive_tmp" | awk '{print $1}')" + rm -f "$archive_tmp" + printf '%s\n' "$source_sha256" > "$checksum_file" + fi + fi + if [[ ! "$source_sha256" =~ ^[0-9a-fA-F]{64}$ ]]; then + echo "CLOUD_COMPOSE_SOURCE_SHA256 must be a 64-character SHA-256 digest" >&2 + return 1 + fi + printf '%s\0%s\0' "-var" "cloud_compose_source_sha256=${source_sha256}" fi if grep -q 'variable "smoke_run_id"' "$root/variables.tf" && [[ -n "$(smoke_run_id)" ]]; then printf '%s\0%s\0' "-var" "smoke_run_id=$(smoke_run_id)" @@ -887,6 +915,8 @@ echo \"--- /home/cloud-compose/run.log ---\" sudo tail -n 400 /home/cloud-compose/run.log echo \"--- cloud-compose unit ---\" sudo journalctl -u cloud-compose --no-pager -n 300 +echo \"--- lifecycle lock permissions ---\" +sudo stat -Lc '%A %a %U:%G %u:%g %n' /run/lock/cloud-compose /run/lock/cloud-compose/lifecycle.lock echo \"--- docker ps ---\" sudo docker ps -a echo \"--- docker compose ps ---\" @@ -1198,4 +1228,6 @@ main() { esac } -main "$@" +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/ci/compose-runtime-contract.sh b/ci/compose-runtime-contract.sh new file mode 100755 index 0000000..31537ad --- /dev/null +++ b/ci/compose-runtime-contract.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +fail() { + echo "compose runtime contract: $*" >&2 + exit 1 +} + +grep -Fq 'cd "$script_dir"' "$repo_root/rootfs/home/cloud-compose/prepare-app-sources.sh" || \ + fail "source preparation does not enter an accessible working directory before dropping privileges" +grep -Fq 'run_as_cloud_compose() (' "$repo_root/rootfs/home/cloud-compose/run.sh" || \ + fail "privilege-drop helper does not isolate its working-directory change" +grep -Fq 'cd /home/cloud-compose' "$repo_root/rootfs/home/cloud-compose/run.sh" || \ + fail "privilege-drop helper can inherit an inaccessible caller working directory" + +export COMPOSE_PROJECTS_FILE="$tmp/compose-projects.json" +export COMPOSE_APPS_ENV_DIR="$tmp/apps" +export COMPOSE_APPS_STATE_DIR="$tmp/state" +export CLOUD_COMPOSE_DATA_ROOT="$tmp/data" +mkdir -p "$CLOUD_COMPOSE_DATA_ROOT/project" + +# shellcheck disable=SC1091 +source "$repo_root/rootfs/home/cloud-compose/compose-apps.sh" + +jq -n \ + --arg project_dir "$CLOUD_COMPOSE_DATA_ROOT/project" \ + --arg trailing_command $'printf "trailing newline preserved"\n' '{ + app: { + docker_compose_repo: "https://github.com/libops/wp.git", + docker_compose_branch: "main", + project_dir: $project_dir, + compose_project_name: "app", + sitectl_verify_args: ["--label", "value with spaces"], + ingress: {trusted_ips: ["192.0.2.1/32"]}, + init_commands: ["printf \"line one\\nline two\\n\"", $trailing_command], + up_commands: [], + down_commands: [], + rollout_commands: [] + } +}' >"$COMPOSE_PROJECTS_FILE" + +apps=() +compose_app_names_array apps +[[ "${apps[*]}" == "app" ]] || fail "valid app names did not materialize" +commands=() +compose_app_array_values app init_commands commands +[[ "${#commands[@]}" == 2 && "${commands[0]}" == *'line one\nline two'* ]] || \ + fail "lifecycle command boundaries were not preserved" +[[ "${commands[1]}" == $'printf "trailing newline preserved"\n' ]] || \ + fail "a trailing lifecycle-command newline was normalized" +write_compose_app_env app +[[ -f "$COMPOSE_APPS_ENV_DIR/app.env" ]] || fail "validated app env was not written" + +printf '{' >"$COMPOSE_PROJECTS_FILE" +if compose_app_names_array apps >/dev/null 2>&1; then + fail "malformed JSON was accepted" +fi + +jq -n --arg project_dir "$CLOUD_COMPOSE_DATA_ROOT/project" '{"../escape": { + docker_compose_repo: "https://github.com/libops/wp.git", + docker_compose_branch: "main", + project_dir: $project_dir, + compose_project_name: "escape" +}}' >"$COMPOSE_PROJECTS_FILE" +if compose_app_names_array apps >/dev/null 2>&1; then + fail "path-traversing app name was accepted" +fi +[[ ! -e "$tmp/escape.env" ]] || fail "invalid app name escaped the app env directory" + +for unsafe_app_json in \ + '{"app\n":{"docker_compose_repo":"https://github.com/libops/wp.git","docker_compose_branch":"main","project_dir":"PROJECT_DIR","compose_project_name":"app"}}' \ + '{"app\u0000":{"docker_compose_repo":"https://github.com/libops/wp.git","docker_compose_branch":"main","project_dir":"PROJECT_DIR","compose_project_name":"app"}}'; do + printf '%s' "${unsafe_app_json//PROJECT_DIR/$CLOUD_COMPOSE_DATA_ROOT\/project}" >"$COMPOSE_PROJECTS_FILE" + if compose_app_names_array apps >/dev/null 2>&1; then + fail "control-character app name was accepted" + fi +done + +jq -n --arg project_dir "$CLOUD_COMPOSE_DATA_ROOT/project" '{app: { + docker_compose_repo: "https://github.com/libops/wp.git", + docker_compose_branch: "main", + project_dir: $project_dir, + compose_project_name: "app", + up_commands: "not-an-array" +}}' >"$COMPOSE_PROJECTS_FILE" +if compose_app_names_array apps >/dev/null 2>&1; then + fail "invalid lifecycle command type was accepted" +fi + +for unsafe_project_dir in "/" "/etc" "$CLOUD_COMPOSE_DATA_ROOT" "$CLOUD_COMPOSE_DATA_ROOT/../escape" "$CLOUD_COMPOSE_DATA_ROOT//escape" "$CLOUD_COMPOSE_DATA_ROOT/project"$'\n'; do + jq -n --arg project_dir "$unsafe_project_dir" '{app: { + docker_compose_repo: "https://github.com/libops/wp.git", + docker_compose_branch: "main", + project_dir: $project_dir, + compose_project_name: "app" + }}' >"$COMPOSE_PROJECTS_FILE" + if compose_app_names_array apps >/dev/null 2>&1; then + fail "unsafe project directory was accepted: $unsafe_project_dir" + fi +done + +ln -s "$tmp" "$CLOUD_COMPOSE_DATA_ROOT/outside-link" +jq -n --arg project_dir "$CLOUD_COMPOSE_DATA_ROOT/outside-link/escape" '{app: { + docker_compose_repo: "https://github.com/libops/wp.git", + docker_compose_branch: "main", + project_dir: $project_dir, + compose_project_name: "app" +}}' >"$COMPOSE_PROJECTS_FILE" +if compose_app_names_array apps >/dev/null 2>&1; then + fail "symlink-escaping project directory was accepted" +fi + +mkdir -p "$tmp/bin" +cat >"$tmp/bin/docker" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "${FAKE_COMPOSE_CONFIG:?}" +EOF +chmod +x "$tmp/bin/docker" +PATH="$tmp/bin:$PATH" +export PATH CLOUD_COMPOSE_PROVIDER=gcp +FAKE_COMPOSE_CONFIG="$(jq -cn '{services:{web:{network_mode:"host"}}}')" +export FAKE_COMPOSE_CONFIG +if reject_host_network_compose_services >/dev/null 2>&1; then + fail "GCP host-network Compose service was accepted" +fi +FAKE_COMPOSE_CONFIG="$(jq -cn '{services:{web:{network_mode:"default"}}}')" +export FAKE_COMPOSE_CONFIG +reject_host_network_compose_services + +FAKE_COMPOSE_CONFIG="$(jq -cn '{services:{web:{build:{context:".",network:"host"}}}}')" +export FAKE_COMPOSE_CONFIG +if reject_host_network_compose_services >/dev/null 2>&1; then + fail "GCP host-network Compose build was accepted" +fi + +FAKE_COMPOSE_CONFIG="$(jq -cn '{services:{web:{build:{context:".",entitlements:["network.host"]}}}}')" +export FAKE_COMPOSE_CONFIG +if reject_host_network_compose_services >/dev/null 2>&1; then + fail "GCP host-network BuildKit entitlement was accepted" +fi + +FAKE_COMPOSE_CONFIG="$(jq -cn '{services:{web:{build:{context:".",network:"default"}}}}')" +export FAKE_COMPOSE_CONFIG +reject_host_network_compose_services + +# Preserve list(string) verify arguments as argv. A value containing spaces is +# one argument, not an unquoted scalar split by the lifecycle shell. +jq -n --arg project_dir "$CLOUD_COMPOSE_DATA_ROOT/project" '{app: { + docker_compose_repo: "https://github.com/libops/wp.git", + docker_compose_branch: "main", + project_dir: $project_dir, + compose_project_name: "app", + sitectl_context_name: "app", + sitectl_verify_args: ["--label", "value with spaces"], + up_commands: ["sitectl verify --context \"$SITECTL_CONTEXT_NAME\" ${SITECTL_VERIFY_ARGS:-}"], + init_commands: [], down_commands: [], rollout_commands: [] +}}' >"$COMPOSE_PROJECTS_FILE" +cat >"$tmp/bin/sitectl" <<'EOF' +#!/usr/bin/env bash +printf '<%s>\n' "$@" >"${SITECTL_ARGV_LOG:?}" +EOF +chmod +x "$tmp/bin/sitectl" +export SITECTL_ARGV_LOG="$tmp/sitectl.argv" +clone_or_update_compose_app() { source_compose_app_env "$1"; } +record_compose_app_head() { return 0; } +CLOUD_COMPOSE_PROVIDER=linode +run_compose_app_lifecycle app up +cat >"$tmp/expected.argv" <<'EOF' + +<--context> + +<--label> + +EOF +cmp -s "$tmp/expected.argv" "$SITECTL_ARGV_LOG" || fail "sitectl verify argument boundaries were lost" + +echo "Compose runtime contract passed" diff --git a/ci/config-management-cloud-smoke-inner.sh b/ci/config-management-cloud-smoke-inner.sh index c67ca57..5ef5f00 100755 --- a/ci/config-management-cloud-smoke-inner.sh +++ b/ci/config-management-cloud-smoke-inner.sh @@ -4,12 +4,17 @@ set -euo pipefail : "${SMOKE_METHOD:?}" : "${SMOKE_HOST:?}" -: "${SMOKE_SSH_KEY_B64:?}" : "${SMOKE_NAME:?}" : "${SMOKE_TEMPLATE:?}" : "${SMOKE_ENVIRONMENT:?}" : "${SMOKE_PROJECT_DIR:?}" +readonly smoke_ssh_key_mount="/run/secrets/cloud-compose-ssh-key" +if [[ -L "$smoke_ssh_key_mount" || ! -f "$smoke_ssh_key_mount" ]]; then + echo "Config-management smoke SSH key mount is missing or unsafe" >&2 + exit 1 +fi + apt-get update >/tmp/cloud-compose-apt-update.log DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ ca-certificates \ @@ -29,8 +34,7 @@ python -m pip install --no-cache-dir \ Jinja2==3.1.4 >/tmp/cloud-compose-pip-install.log install -d -m 0700 /tmp/cloud-compose-ssh -printf '%s' "$SMOKE_SSH_KEY_B64" | base64 -d >/tmp/cloud-compose-ssh/id_ed25519 -chmod 0600 /tmp/cloud-compose-ssh/id_ed25519 +install -m 0600 "$smoke_ssh_key_mount" /tmp/cloud-compose-ssh/id_ed25519 touch /tmp/cloud-compose-ssh/known_hosts chmod 0600 /tmp/cloud-compose-ssh/known_hosts ssh-keyscan -H "$SMOKE_HOST" >>/tmp/cloud-compose-ssh/known_hosts 2>/dev/null @@ -40,15 +44,36 @@ ssh_opts=( -i /tmp/cloud-compose-ssh/id_ed25519 -o BatchMode=yes -o ConnectTimeout=20 + -o ServerAliveCountMax=10 + -o ServerAliveInterval=30 -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/tmp/cloud-compose-ssh/known_hosts ) +retry_remote_operation() { + local label="$1" attempt status + shift + + for attempt in 1 2 3; do + if "$@"; then + return 0 + else + status=$? + fi + if [[ "$attempt" -eq 3 ]]; then + echo "${label} failed after ${attempt} attempts" >&2 + return "$status" + fi + echo "${label} failed; retrying in 15s (attempt $((attempt + 1)) of 3)" >&2 + sleep 15 + done +} + deploy_ansible() { export ANSIBLE_RETRY_FILES_ENABLED=false export ANSIBLE_ROLES_PATH=/work/ansible/roles - cat >/tmp/cloud-compose-ansible-inventory.yml </tmp/cloud-compose-ansible-inventory.yml </dev/null +python3 -m json.tool /home/cloud-compose/application-env.json >/dev/null python3 - <<'PY' import json import os +import subprocess from pathlib import Path name = os.environ["SMOKE_NAME"] template = os.environ["SMOKE_TEMPLATE"] project_dir = os.environ["SMOKE_PROJECT_DIR"] -env = {} -for line in Path("/home/cloud-compose/.env").read_text().splitlines(): - if not line.strip(): - continue - key, raw = line.split("=", 1) - env[key] = json.loads(raw) +def load_runtime_env(path): + result = subprocess.run( + [ + "env", "-i", "PATH=/usr/bin:/bin", f"CLOUD_COMPOSE_ENV_FILE={path}", + "bash", "--noprofile", "--norc", "-c", + "source /home/cloud-compose/profile.sh; env -0", + ], + check=True, + stdout=subprocess.PIPE, + ) + return { + entry.split(b"=", 1)[0].decode(): entry.split(b"=", 1)[1].decode() + for entry in result.stdout.split(b"\0") if b"=" in entry + } + +env = load_runtime_env(Path("/home/cloud-compose/.env")) projects = json.loads(Path("/home/cloud-compose/compose-projects.json").read_text()) project = projects[name] @@ -199,12 +247,12 @@ REMOTE } case "$SMOKE_METHOD" in - ansible) deploy_ansible ;; - salt) deploy_salt ;; + ansible) retry_remote_operation "Ansible deployment" deploy_ansible ;; + salt) retry_remote_operation "Salt deployment" deploy_salt ;; *) echo "Unknown config-management smoke method: ${SMOKE_METHOD}" >&2 exit 2 ;; esac -verify_remote +retry_remote_operation "Remote verification" verify_remote diff --git a/ci/config-management-cloud-smoke.sh b/ci/config-management-cloud-smoke.sh index 5cafa97..9c7a5ae 100755 --- a/ci/config-management-cloud-smoke.sh +++ b/ci/config-management-cloud-smoke.sh @@ -3,9 +3,11 @@ set -euo pipefail repo_root="$(cd "$(dirname "$0")/.." && pwd)" +# renovate: datasource=docker depName=python packageName=python versioning=docker +CONFIG_MANAGEMENT_IMAGE_DEFAULT="python:3.11-slim@sha256:e031123e3d85762b141ad1cbc56452ba69c6e722ebf2f042cc0dc86c47c0d8b3" usage() { - cat <<'EOF' + cat <&2 + return 1 + fi if [[ -f "$key_path" ]]; then + if [[ -L "${key_path}.pub" || ! -f "${key_path}.pub" ]]; then + echo "Config-management smoke SSH public-key path is missing or unsafe: ${key_path}.pub" >&2 + return 1 + fi + chmod 0600 "$key_path" return 0 fi mkdir -p "$(dirname "$key_path")" @@ -132,7 +143,7 @@ api_request() { if response="$(curl -sS -X "$method" -H "Authorization: Bearer ${LINODE_TOKEN}" -w $'\n%{http_code}' "https://api.linode.com/v4${path}")"; then http_code="${response##*$'\n'}" - body="${response%$'\n'$http_code}" + body="${response%$'\n'"$http_code"}" else echo "linode API request failed for ${method} ${path}; check LINODE_TOKEN and network access." >&2 return 1 @@ -170,35 +181,47 @@ api_delete() { delete_ids() { local path_prefix="$1" id attempt + local failed=0 deleted while IFS= read -r id; do [[ -n "$id" ]] || continue + deleted=false for attempt in {1..12}; do echo "Deleting Linode resource ${path_prefix}/${id} (attempt ${attempt})" if api_delete "${path_prefix}/${id}"; then + deleted=true break fi sleep 10 done + if [[ "$deleted" != "true" ]]; then + echo "Failed to delete Linode resource ${path_prefix}/${id} after 12 attempts" >&2 + failed=1 + fi done + + return "$failed" } provider_tag_cleanup() { local target="$1" run_id="${2:-}" run_tag tag + local cleanup_status=0 run_tag="$(smoke_run_tag "$run_id")" tag="$(target_tag "$target")" api_get "/networking/firewalls?page_size=500" | jq -r --arg tag "$tag" --arg run_tag "$run_tag" '.data[]? | select((.tags // []) | index("cloud-compose-smoke") and index("config-management-smoke") and index($tag)) | select($run_tag == "" or ((.tags // []) | index($run_tag))) | .id' | - delete_ids "/networking/firewalls" + delete_ids "/networking/firewalls" || cleanup_status=1 api_get "/linode/instances?page_size=500" | jq -r --arg tag "$tag" --arg run_tag "$run_tag" '.data[]? | select((.tags // []) | index("cloud-compose-smoke") and index("config-management-smoke") and index($tag)) | select($run_tag == "" or ((.tags // []) | index($run_tag))) | .id' | - delete_ids "/linode/instances" + delete_ids "/linode/instances" || cleanup_status=1 sleep 10 api_get "/volumes?page_size=500" | jq -r --arg tag "$tag" --arg run_tag "$run_tag" '.data[]? | select((.tags // []) | index("cloud-compose-smoke") and index("config-management-smoke") and index($tag)) | select($run_tag == "" or ((.tags // []) | index($run_tag))) | .id' | - delete_ids "/volumes" + delete_ids "/volumes" || cleanup_status=1 + + return "$cleanup_status" } scan_host_key() { @@ -307,7 +330,7 @@ target_var_args() { deploy_config_management() { local target="$1" key_path="$2" output_json="$3" - local method host name template environment project_dir image key_b64 + local method host name template environment project_dir image method="$(jq -r '.method' "$output_json")" host="$(jq -r '.host' "$output_json")" @@ -315,24 +338,31 @@ deploy_config_management() { template="$(jq -r '.app' "$output_json")" environment="$(jq -r '.environment' "$output_json")" project_dir="$(jq -r '.project_dir' "$output_json")" - image="${CLOUD_COMPOSE_CONFIG_MANAGEMENT_IMAGE:-python:3.11-slim}" - key_b64="$(base64 <"$key_path" | tr -d '\n')" + image="${CLOUD_COMPOSE_CONFIG_MANAGEMENT_IMAGE:-$CONFIG_MANAGEMENT_IMAGE_DEFAULT}" + + if [[ -L "$key_path" || ! -f "$key_path" ]]; then + echo "Config-management smoke SSH private-key path is missing or unsafe: $key_path" >&2 + return 1 + fi + key_path="$(cd -P -- "$(dirname -- "$key_path")" && pwd)/$(basename -- "$key_path")" + if [[ "$key_path" == *,* ]]; then + echo "Config-management smoke SSH private-key path cannot contain a comma: $key_path" >&2 + return 1 + fi echo "Deploying ${template} to ${host} with ${method}" - tar \ - --exclude="./.git" \ - --exclude="./.terraform" \ - --exclude="./docs/site" \ - -C "$repo_root" \ - -cf - . | + # Stream only the committed source under test. Archiving the working tree + # would also copy ignored Terraform state and the generated private key into + # the helper container and, for Salt, onward to the provisioned VM. + git -C "$repo_root" archive --format=tar HEAD | docker run --rm -i \ --env "SMOKE_METHOD=${method}" \ --env "SMOKE_HOST=${host}" \ - --env "SMOKE_SSH_KEY_B64=${key_b64}" \ --env "SMOKE_NAME=${name}" \ --env "SMOKE_TEMPLATE=${template}" \ --env "SMOKE_ENVIRONMENT=${environment}" \ --env "SMOKE_PROJECT_DIR=${project_dir}" \ + --mount "type=bind,src=${key_path},dst=/run/secrets/cloud-compose-ssh-key,readonly" \ --tmpfs /run \ --tmpfs /tmp \ "$image" \ @@ -340,14 +370,13 @@ deploy_config_management() { } require_run_commands() { - require_cmd base64 require_cmd curl require_cmd docker + require_cmd git require_cmd jq require_cmd ssh require_cmd ssh-keygen require_cmd ssh-keyscan - require_cmd tar require_cmd terraform } @@ -392,9 +421,11 @@ run_target() ( fi cleanup_started=false + # This function is invoked indirectly by trap. + # shellcheck disable=SC2317 cleanup() { local status=$? - local destroy_status=0 destroy_timeout + local destroy_status=0 destroy_timeout cleanup_status=0 trap - EXIT INT TERM HUP if [[ "$cleanup_started" == "true" ]]; then @@ -420,7 +451,10 @@ run_target() ( destroy_status=$? fi if [[ "$destroy_status" -ne 0 ]]; then - provider_tag_cleanup "$target" || true + provider_tag_cleanup "$target" || cleanup_status=$? + fi + if [[ "$status" -eq 0 && "$destroy_status" -ne 0 && "$cleanup_status" -ne 0 ]]; then + exit "$destroy_status" fi exit "$status" } @@ -455,7 +489,7 @@ run_target() ( destroy_target() ( set -euo pipefail - local target="$1" root workdir key_path destroy_status=0 destroy_timeout + local target="$1" root workdir key_path destroy_status=0 destroy_timeout cleanup_status=0 local -a auto_args var_args valid_target "$target" || exit 2 @@ -491,8 +525,15 @@ destroy_target() ( set -e fi - provider_tag_cleanup "$target" "$(smoke_run_id)" - return "$destroy_status" + provider_tag_cleanup "$target" "$(smoke_run_id)" || cleanup_status=$? + if [[ "$destroy_status" -ne 0 && "$cleanup_status" -eq 0 ]]; then + echo "Provider tag cleanup completed for ${target} after Terraform destroy failed" + return 0 + fi + if [[ "$destroy_status" -ne 0 ]]; then + return "$destroy_status" + fi + return "$cleanup_status" ) main() { diff --git a/ci/config-management-input-contract.sh b/ci/config-management-input-contract.sh new file mode 100755 index 0000000..21a182c --- /dev/null +++ b/ci/config-management-input-contract.sh @@ -0,0 +1,364 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +validator="$repo_root/ansible/roles/cloud_compose/files/validate-runtime-inputs.py" +salt_validator="$repo_root/salt/cloud-compose/files/validate-runtime-inputs.py" + +cmp -s "$validator" "$salt_validator" || { + echo "config-management input contract: Ansible and Salt validators diverged" >&2 + exit 1 +} + +python3 - "$repo_root" "$validator" <<'PY' +import base64 +import copy +import json +import os +import stat +import subprocess +import sys +import tempfile +from pathlib import Path + +repo_root = Path(sys.argv[1]) +validator = Path(sys.argv[2]) + + +def fail(message): + raise SystemExit(f"config-management input contract: {message}") + + +def snapshot(root): + entries = [] + for path in sorted(root.rglob("*")): + metadata = path.lstat() + relative = str(path.relative_to(root)) + if path.is_symlink(): + payload = ("symlink", os.readlink(path)) + elif path.is_file(): + payload = ("file", path.read_bytes()) + else: + payload = ("other", b"") + entries.append( + ( + relative, + stat.S_IMODE(metadata.st_mode), + metadata.st_uid, + metadata.st_gid, + payload, + ) + ) + return entries + + +with tempfile.TemporaryDirectory(prefix="cloud-compose-input-contract.") as temp_dir: + test_root = Path(temp_dir) + data_root = test_root / "mnt" / "disks" / "data" + outside = test_root / "outside" + data_root.mkdir(parents=True) + outside.mkdir() + (outside / "sentinel").write_text("must-not-change\n") + (data_root / "escape").symlink_to(outside, target_is_directory=True) + + safe_artifact = { + "name": "rollout-agent", + "url": "https://example.invalid/rollout-agent", + "sha256": "a" * 64, + "path": "/usr/local/bin/rollout-agent", + "mode": "0750", + "owner": "root", + "group": "root", + "restart": "cloud-compose-rollout.service", + } + safe_payload = { + "projects": [{"name": "app", "project_dir": str(data_root / "app")}], + "artifacts": [safe_artifact], + } + + def run(payload): + before = snapshot(test_root) + environment = os.environ.copy() + environment["CLOUD_COMPOSE_VALIDATION_PAYLOAD_B64"] = base64.b64encode( + json.dumps(payload).encode("utf-8") + ).decode("ascii") + result = subprocess.run( + [sys.executable, str(validator), "--data-root", str(data_root)], + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + after = snapshot(test_root) + if before != after: + fail("validator mutated the host fixture") + return result + + accepted = run(safe_payload) + if accepted.returncode != 0: + fail(f"safe payload was rejected: {accepted.stderr}") + + def reject(label, payload, expected): + result = run(payload) + if result.returncode == 0: + fail(f"{label} was accepted") + if expected not in result.stderr: + fail(f"{label} did not report {expected!r}: {result.stderr}") + + invalid_project_paths = { + "root project path": "/", + "system project path": "/etc", + "data root itself": str(data_root), + "traversal project path": str(data_root / ".." / "outside"), + "dot project segment": f"{data_root}/./app", + "empty project segment": f"{data_root}//app", + "control-character project segment": f"{data_root}/bad\x7fname", + "symlink escape": str(data_root / "escape" / "app"), + } + for label, project_dir in invalid_project_paths.items(): + payload = copy.deepcopy(safe_payload) + payload["projects"][0]["project_dir"] = project_dir + reject(label, payload, "project_dir") + + artifact_cases = [] + + def artifact_case(label, field, value, expected): + payload = copy.deepcopy(safe_payload) + payload["artifacts"][0][field] = value + artifact_cases.append((label, payload, expected)) + + artifact_case("unsafe artifact name", "name", "../agent", "safe basename") + artifact_case("overlong artifact name", "name", "a" * 129, "safe basename") + artifact_case("non-HTTPS artifact URL", "url", "http://example.invalid/agent", "HTTPS URL") + artifact_case("uppercase artifact SHA", "sha256", "A" * 64, "64 lowercase hex") + artifact_case("root artifact path", "path", "/", "non-root absolute path") + artifact_case("relative artifact path", "path", "usr/local/bin/agent", "non-root absolute path") + artifact_case("dot artifact path", "path", "/usr/local/../bin/agent", "non-root absolute path") + artifact_case("empty artifact segment", "path", "/usr//local/bin/agent", "non-root absolute path") + artifact_case("control artifact segment", "path", "/usr/local/bad\x7fname", "non-root absolute path") + artifact_case("unsafe artifact mode", "mode", "4755", "mode must match") + artifact_case("unsafe artifact owner", "owner", "root:root", "owner must be") + artifact_case("unsafe artifact group", "group", "root:root", "group must be") + artifact_case("unsafe restart unit", "restart", "../docker.service", "safe .service") + + duplicate_name = copy.deepcopy(safe_payload) + second = copy.deepcopy(safe_artifact) + second["path"] = "/usr/local/bin/rollout-agent-two" + duplicate_name["artifacts"].append(second) + artifact_cases.append(("duplicate artifact name", duplicate_name, "names must be unique")) + + duplicate_path = copy.deepcopy(safe_payload) + second = copy.deepcopy(safe_artifact) + second["name"] = "rollout-agent-two" + duplicate_path["artifacts"].append(second) + artifact_cases.append(("duplicate artifact path", duplicate_path, "target paths must be unique")) + + for label, payload, expected in artifact_cases: + reject(label, payload, expected) + +ansible_tasks = (repo_root / "ansible/roles/cloud_compose/tasks/main.yml").read_text() +ansible_gate = ansible_tasks.find("Validate project directory and managed artifact host boundaries") +ansible_first_mutation = ansible_tasks.find("Install Debian runtime dependencies") +if ansible_gate < 0 or ansible_first_mutation < 0 or ansible_gate > ansible_first_mutation: + fail("Ansible host-input validation does not precede its first host mutation") +if "files/validate-runtime-inputs.py" not in ansible_tasks: + fail("Ansible does not execute the shared host-input validator") +if "--data-root" in ansible_tasks: + fail("Ansible makes the production project ownership boundary configurable") + +salt_state = (repo_root / "salt/cloud-compose/init.sls").read_text() +cloud_smoke_driver = (repo_root / "ci/config-management-cloud-smoke.sh").read_text() +cloud_smoke_inner = (repo_root / "ci/config-management-cloud-smoke-inner.sh").read_text() +salt_gate = salt_state.find("cloud-compose-host-inputs-valid:") +salt_first_mutation = salt_state.find("cloud-compose-packages:") +if salt_gate < 0 or salt_first_mutation < 0 or salt_gate > salt_first_mutation: + fail("Salt host-input validation does not precede its first host mutation") +gate_block = salt_state[salt_gate:salt_first_mutation] +for marker in ( + "cmd.run:", + '/usr/bin/env python3 "$CLOUD_COMPOSE_RUNTIME_VALIDATOR"', + "failhard: True", + "order: 2", +): + if marker not in gate_block: + fail(f"Salt host-input validation gate is missing {marker!r}") +for state_name in ("cloud-compose-packages:", "cloud-compose-docker-group:", "cloud-compose-group:"): + state_start = salt_state.find(state_name) + state_end = salt_state.find("\n\n", state_start) + if "cmd: cloud-compose-host-inputs-valid" not in salt_state[state_start:state_end]: + fail(f"Salt mutating state {state_name} does not require host-input validation") + +for marker in ( + "_cc_compose.init | default(cloud_compose_default_init)", + "_cc_compose.up | default(cloud_compose_default_up)", + "_cc_compose.down | default(cloud_compose_default_down)", + "_cc_compose.rollout | default(cloud_compose_default_rollout)", + "item.value.docker_compose_up | default(_cc_up_commands)", +): + if marker not in ansible_tasks: + fail(f"Ansible explicit-empty lifecycle parity marker is missing: {marker!r}") + +for marker in ( + "_cc_template.package_versions | default({})", + "_cc_template_sitectl_package_versions[item] | default(_cc_sitectl_version)", + "_cc_sitectl_package_version_overrides[item] | default(", + "_cc_sitectl.packages | default(_cc_template.packages)", + "item.value.sitectl_packages | default(_cc_sitectl_packages)", + "(_cc_managed.enabled | default(cloud_compose_managed_runtime_enabled)) is boolean", + "(_cc_vault.agent_enabled | default(false)) is boolean", +): + if marker not in ansible_tasks: + fail(f"Ansible template package-version parity marker is missing: {marker!r}") + +for marker in ( + "configured_commands = compose.get(lifecycle)", + "configured_commands if lifecycle in compose else default_commands", + "if legacy_key in app", + "elif docker_key in app", +): + if marker not in salt_state: + fail(f"Salt explicit-empty lifecycle parity marker is missing: {marker!r}") + +for marker in ( + "template_sitectl_package_versions = template.get('package_versions', {})", + "sitectl_package_version_overrides.get(package, template_sitectl_package_versions.get(package, sitectl_version))", + "sitectl.get('packages') if 'packages' in sitectl else template.packages", + "app.get('sitectl_packages', sitectl_packages)", + "all_packages = sitectl_packages | list", + "'managed_runtime.enabled': managed_runtime_enabled", + "'vault.agent_enabled': vault.get('agent_enabled', False)", + "run_bootstrap is sameas true", +): + if marker not in salt_state: + fail(f"Salt template package-version parity marker is missing: {marker!r}") + +for marker in ( + 'retry_remote_operation "Ansible deployment" deploy_ansible', + 'retry_remote_operation "Salt deployment" deploy_salt', + 'retry_remote_operation "Remote verification" verify_remote', + '-o ServerAliveInterval=30', + 'for attempt in 1 2 3; do', +): + if marker not in cloud_smoke_inner: + fail(f"hosted adapter smoke retry contract is missing: {marker!r}") + +for text in (cloud_smoke_driver, cloud_smoke_inner): + if "SMOKE_SSH_KEY_B64" in text: + fail("hosted adapter smoke exposes its SSH private key through the environment") +if 'type=bind,src=${key_path},dst=/run/secrets/cloud-compose-ssh-key,readonly' not in cloud_smoke_driver: + fail("hosted adapter smoke does not mount its SSH private key read-only") +if 'git -C "$repo_root" archive --format=tar HEAD' not in cloud_smoke_driver: + fail("hosted adapter smoke does not limit its source stream to committed files") +if 'tar \\\n --exclude="./.git"' in cloud_smoke_driver: + fail("hosted adapter smoke still archives the secret-bearing working tree") +for marker in ( + '[[ -L "$key_path" || ! -f "$key_path" ]]', + 'key_path="$(cd -P -- "$(dirname -- "$key_path")" && pwd)/$(basename -- "$key_path")"', +): + if marker not in cloud_smoke_driver: + fail(f"hosted adapter smoke key-source validation is missing: {marker!r}") +for marker in ( + 'readonly smoke_ssh_key_mount="/run/secrets/cloud-compose-ssh-key"', + 'install -m 0600 "$smoke_ssh_key_mount" /tmp/cloud-compose-ssh/id_ed25519', + '-i /tmp/cloud-compose-ssh/id_ed25519', + 'ansible_ssh_private_key_file: /tmp/cloud-compose-ssh/id_ed25519', +): + if marker not in cloud_smoke_inner: + fail(f"hosted adapter smoke key-file contract is missing: {marker!r}") + +for label, text, owner_marker, mode_marker in ( + ("Ansible", ansible_tasks, "owner: root", 'mode: "0640"'), + ("Salt", salt_state, "- user: root", "- mode: '0640'"), +): + if text.count(owner_marker) < 4 or text.count(mode_marker) < 4: + fail(f"{label} does not keep every root-consumed runtime input root-owned and mode 0640") + +if "no_log: true" not in ansible_tasks[ansible_tasks.find("Write Compose application environment data"):]: + fail("Ansible may expose application environment data in task output") +for state_name in ("cloud-compose-env:", "cloud-compose-application-env:", "cloud-compose-managed-runtime-artifacts:"): + state_start = salt_state.find(state_name) + state_end = salt_state.find("\n\n", state_start) + if "- show_changes: False" not in salt_state[state_start:state_end]: + fail(f"Salt sensitive state {state_name} may expose rendered data in state output") + +terraform_artifacts = (repo_root / "modules/managed-artifacts/main.tf").read_text() +validator_source = validator.read_text() +if 'PRODUCTION_DATA_ROOT = "/mnt/disks/data"' not in validator_source: + fail("configuration-management validator changed the fixed production data boundary") +if "--data-root" in salt_state: + fail("Salt makes the production project ownership boundary configurable") +if '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$' not in terraform_artifacts: + fail("Terraform managed-artifact name bound changed without adapter parity") +if '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$' not in validator_source: + fail("configuration-management validator is missing the 128-character artifact name bound") + +project_fixture_values = { + "invalid-project-dir-root": "project_dir: /", + "invalid-project-dir-etc": "project_dir: /etc", + "invalid-project-dir-traversal": "project_dir: /mnt/disks/data/../escape", + "invalid-project-dir-symlink": "project_dir: /mnt/disks/data/escape/site", +} +for fixture_name, marker in project_fixture_values.items(): + for relative_path in ( + f"tests/config-management/ansible/{fixture_name}.yml", + f"tests/config-management/salt-pillar/{fixture_name}.sls", + ): + fixture = repo_root / relative_path + if not fixture.is_file() or marker not in fixture.read_text(): + fail(f"project boundary fixture {relative_path} is missing {marker!r}") + +artifact_fixture_markers = ( + "../unsafe-name", + "a" * 129, + "http://example.invalid/unsafe-url", + "A" * 64, + "path: /", + "path: usr/local/bin/relative-path", + "path: /usr/local/../bin/dot-path", + "path: /usr//local/bin/empty-path-segment", + "bad\\u007fname", + 'mode: "4755"', + "owner: root:root", + "group: root:root", + "restart: ../docker.service", + "name: duplicate-name", + "path: /usr/local/bin/duplicate-path", +) +for relative_path in ( + "tests/config-management/ansible/invalid-artifacts.yml", + "tests/config-management/salt-pillar/invalid-artifacts.sls", +): + fixture_text = (repo_root / relative_path).read_text() + for marker in artifact_fixture_markers: + if marker not in fixture_text: + fail(f"managed artifact fixture {relative_path} is missing {marker!r}") + +adapter_fixture_markers = { + "invalid-ingress-port": "ingress_port: 80.5", + "invalid-project-port": "ingress_port: 443.5", + "invalid-lifecycle": "- 42", + "invalid-project-lifecycle": "docker_compose_up: not-a-list", + "invalid-internal-services": "internal_services_enabled: true", +} +for fixture_name, marker in adapter_fixture_markers.items(): + for relative_path in ( + f"tests/config-management/ansible/{fixture_name}.yml", + f"tests/config-management/salt-pillar/{fixture_name}.sls", + ): + fixture = repo_root / relative_path + if not fixture.is_file() or marker not in fixture.read_text(): + fail(f"adapter parity fixture {relative_path} is missing {marker!r}") + +for relative_path in ( + "tests/config-management/ansible/smoke.yml", + "tests/config-management/salt-pillar/wp-prod.sls", +): + fixture_text = (repo_root / relative_path).read_text() + for marker in ("init: []", "docker_compose_up: []", "app-down-command"): + if marker not in fixture_text: + fail(f"explicit-empty lifecycle fixture {relative_path} is missing {marker!r}") + +print("Configuration-management input contracts passed") +PY diff --git a/ci/config-management-smoke-inner.sh b/ci/config-management-smoke-inner.sh index a984eef..3973507 100755 --- a/ci/config-management-smoke-inner.sh +++ b/ci/config-management-smoke-inner.sh @@ -2,6 +2,10 @@ set -euo pipefail +apt-get update -qq +DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + systemd-standalone-tmpfiles >/tmp/cloud-compose-apt-install.log + python -m pip install --no-cache-dir \ ansible-core==2.18.6 \ salt==3007.1 \ @@ -15,6 +19,103 @@ python -m pip install --no-cache-dir \ export ANSIBLE_RETRY_FILES_ENABLED=false export ANSIBLE_ROLES_PATH=/work/ansible/roles +rm -f \ + /tmp/cloud-compose-ansible-backtick-injection \ + /tmp/cloud-compose-ansible-command-injection \ + /tmp/cloud-compose-salt-backtick-injection \ + /tmp/cloud-compose-salt-command-injection + +tamper_adapter_control_ownership() { + chown cloud-compose:cloud-compose \ + /home/cloud-compose/init \ + /home/cloud-compose/up \ + /home/cloud-compose/down \ + /home/cloud-compose/rollout \ + /home/cloud-compose/.env \ + /home/cloud-compose/application-env.json \ + /home/cloud-compose/compose-projects.json \ + /home/cloud-compose/managed-runtime-artifacts.tsv + chmod 0777 \ + /home/cloud-compose/init \ + /home/cloud-compose/up \ + /home/cloud-compose/down \ + /home/cloud-compose/rollout + chmod 0666 \ + /home/cloud-compose/.env \ + /home/cloud-compose/application-env.json \ + /home/cloud-compose/compose-projects.json \ + /home/cloud-compose/managed-runtime-artifacts.tsv +} + +verify_lifecycle_lock_contract() { + local lock_dir=/run/lock/cloud-compose + local lock_file="$lock_dir/lifecycle.lock" + local profile=/home/cloud-compose/profile.sh + local ready=/tmp/cloud-compose-lifecycle-lock-ready + local holder_pid contention_status passwd_sha + + command -v flock >/dev/null + command -v runuser >/dev/null + command -v systemd-tmpfiles >/dev/null + [[ "$(stat -c '%U:%G:%a' "$lock_dir" "$lock_file")" == \ + $'root:cloud-compose:750\nroot:cloud-compose:660' ]] + + rm -f -- "$ready" + CLOUD_COMPOSE_ENV_FILE=/home/cloud-compose/.env \ + bash -c ' + set -euo pipefail + source "$1" + acquire_cloud_compose_lifecycle_lock root-first-contract + touch "$2" + sleep 3 + release_cloud_compose_lifecycle_lock + ' _ "$profile" "$ready" & + holder_pid=$! + for _ in {1..50}; do + [[ -e "$ready" ]] && break + sleep 0.1 + done + [[ -e "$ready" ]] + + set +e + runuser -u cloud-compose -- env \ + HOME=/home/cloud-compose \ + CLOUD_COMPOSE_ENV_FILE=/home/cloud-compose/.env \ + CLOUD_COMPOSE_LIFECYCLE_LOCK_TIMEOUT_SECONDS=1 \ + bash -c ' + source "$1" + acquire_cloud_compose_lifecycle_lock contention-contract + ' _ "$profile" >/dev/null 2>&1 + contention_status=$? + set -e + [[ "$contention_status" -ne 0 ]] + wait "$holder_pid" + + runuser -u cloud-compose -- env \ + HOME=/home/cloud-compose \ + CLOUD_COMPOSE_ENV_FILE=/home/cloud-compose/.env \ + bash -c ' + set -euo pipefail + source "$1" + ( + acquire_cloud_compose_lifecycle_lock subshell-contract + release_cloud_compose_lifecycle_lock + ) + ' _ "$profile" + + passwd_sha="$(sha256sum /etc/passwd)" + mv -- "$lock_file" "${lock_file}.real" + ln -s /etc/passwd "$lock_file" + if CLOUD_COMPOSE_ENV_FILE=/home/cloud-compose/.env \ + bash -c 'source "$1"; acquire_cloud_compose_lifecycle_lock symlink-contract' _ "$profile" \ + >/dev/null 2>&1; then + echo "Lifecycle lock accepted a symbolic-link target" >&2 + exit 1 + fi + [[ "$(sha256sum /etc/passwd)" == "$passwd_sha" ]] + rm -f -- "$lock_file" + mv -- "${lock_file}.real" "$lock_file" +} ansible-playbook \ -i /work/ansible/inventory.example.yml \ @@ -26,35 +127,169 @@ ansible-playbook \ /work/tests/config-management/ansible/smoke.yml \ --syntax-check +ansible_invalid_cases=( + invalid-env + invalid-sitectl + invalid-reserved-env + invalid-project + invalid-project-repo + invalid-project-port + invalid-ingress-port + invalid-lifecycle + invalid-project-lifecycle + invalid-internal-services + invalid-primary + invalid-template + invalid-vault + invalid-package + invalid-host-ack + invalid-project-dir-root + invalid-project-dir-etc + invalid-project-dir-traversal + invalid-project-dir-symlink + invalid-artifacts +) + +for invalid_case in "${ansible_invalid_cases[@]}"; do + ansible-playbook \ + -i /work/tests/config-management/ansible/inventory.yml \ + "/work/tests/config-management/ansible/${invalid_case}.yml" \ + --syntax-check +done + +run_invalid_ansible_case() { + local invalid_case="$1" expected="$2" + local log="/tmp/cloud-compose-ansible-${invalid_case}.log" + local root_before etc_before passwd_before group_before + + rm -rf /home/cloud-compose /mnt/disks /tmp/cloud-compose-symlink-target + if [[ "$invalid_case" == "invalid-project-dir-symlink" ]]; then + mkdir -p /mnt/disks/data /tmp/cloud-compose-symlink-target + printf 'must-not-change\n' >/tmp/cloud-compose-symlink-target/sentinel + ln -s /tmp/cloud-compose-symlink-target /mnt/disks/data/escape + fi + root_before="$(stat -c '%u:%g:%a' /)" + etc_before="$(stat -c '%u:%g:%a' /etc)" + passwd_before="$(getent passwd cloud-compose || true)" + group_before="$(getent group cloud-compose || true)" + + if ansible-playbook \ + -i /work/tests/config-management/ansible/inventory.yml \ + "/work/tests/config-management/ansible/${invalid_case}.yml" \ + >"$log" 2>&1; then + echo "Ansible accepted invalid settings from ${invalid_case}.yml" >&2 + exit 1 + fi + grep -Fq -- "$expected" "$log" + [[ "$(stat -c '%u:%g:%a' /)" == "$root_before" ]] + [[ "$(stat -c '%u:%g:%a' /etc)" == "$etc_before" ]] + [[ "$(getent passwd cloud-compose || true)" == "$passwd_before" ]] + [[ "$(getent group cloud-compose || true)" == "$group_before" ]] + [[ ! -e /home/cloud-compose ]] + [[ ! -e /mnt/disks/volumes ]] + if [[ "$invalid_case" == "invalid-project-dir-symlink" ]]; then + grep -Fxq 'must-not-change' /tmp/cloud-compose-symlink-target/sentinel + [[ -L /mnt/disks/data/escape ]] + fi +} + +run_invalid_ansible_case invalid-env 'cloud_compose_extra_env names must match' +run_invalid_ansible_case invalid-sitectl 'sitectl.version must be latest or an exact semantic-version release tag' +run_invalid_ansible_case invalid-reserved-env 'must not replace HOME, PATH, or reserved control-plane prefixes' +run_invalid_ansible_case invalid-project 'compose.projects keys must match' +run_invalid_ansible_case invalid-project-repo 'repositories are required' +run_invalid_ansible_case invalid-project-port 'ingress ports must be whole numbers between 1 and 65535' +run_invalid_ansible_case invalid-ingress-port 'compose.ingress_port must be a whole number between 1 and 65535' +run_invalid_ansible_case invalid-lifecycle 'lifecycle commands must be lists of strings' +run_invalid_ansible_case invalid-project-lifecycle 'Lifecycle init, up, down, and rollout commands for every compose project must be lists of strings' +run_invalid_ansible_case invalid-internal-services 'internal-services stack is GCP-specific' +run_invalid_ansible_case invalid-primary 'compose.primary must match' +run_invalid_ansible_case invalid-template 'template must name a supported app' +run_invalid_ansible_case invalid-vault 'Vault Agent is currently supported only by Terraform' +run_invalid_ansible_case invalid-package 'sitectl packages must use valid release package names' +run_invalid_ansible_case invalid-host-ack 'dedicated_host_acknowledged=true' +run_invalid_ansible_case invalid-project-dir-root 'project_dir must be a normalized non-root absolute path' +run_invalid_ansible_case invalid-project-dir-etc 'project_dir must be a non-root descendant of /mnt/disks/data' +run_invalid_ansible_case invalid-project-dir-traversal 'project_dir must be a normalized non-root absolute path' +run_invalid_ansible_case invalid-project-dir-symlink 'project_dir resolves outside the fixed /mnt/disks/data boundary' +run_invalid_ansible_case invalid-artifacts 'name must be a safe basename' + ansible-playbook \ -i /work/tests/config-management/ansible/inventory.yml \ /work/tests/config-management/ansible/smoke.yml +# A second adapter apply must repair the root-owned runtime control boundary. +tamper_adapter_control_ownership +rm -rf -- /run/lock/cloud-compose +ansible-playbook \ + -i /work/tests/config-management/ansible/inventory.yml \ + /work/tests/config-management/ansible/smoke.yml + +verify_lifecycle_lock_contract + python - <<'PY' import json +import grp import os +import stat +import subprocess from pathlib import Path -env = {} -for line in Path("/home/cloud-compose/.env").read_text().splitlines(): - if not line.strip(): - continue - key, raw = line.split("=", 1) - env[key] = json.loads(raw) +def load_runtime_env(path): + result = subprocess.run( + [ + "env", "-i", "PATH=/usr/bin:/bin", f"CLOUD_COMPOSE_ENV_FILE={path}", + "bash", "--noprofile", "--norc", "-c", + "source /home/cloud-compose/profile.sh; env -0", + ], + check=True, + stdout=subprocess.PIPE, + ) + return { + entry.split(b"=", 1)[0].decode(): entry.split(b"=", 1)[1].decode() + for entry in result.stdout.split(b"\0") if b"=" in entry + } + +env = load_runtime_env(Path("/home/cloud-compose/.env")) +application_env = json.loads(Path("/home/cloud-compose/application-env.json").read_text()) projects = json.loads(Path("/home/cloud-compose/compose-projects.json").read_text()) project = projects["isle-prod"] assert env["CLOUD_COMPOSE_PROVIDER"] == "onprem" -assert env["DOCKER_COMPOSE_DIR"] == "/opt/isle" +assert env["DOCKER_COMPOSE_DIR"] == "/mnt/disks/data/libops/isle/main" assert env["DOCKER_COMPOSE_REPO"] == "https://github.com/libops/isle" assert env["SITECTL_PLUGIN"] == "isle" assert "sitectl-isle" in env["SITECTL_PACKAGES"].split() +assert json.loads(env["SITECTL_PACKAGE_VERSIONS"]) == { + "sitectl": "v0.39.0", + "sitectl-drupal": "v0.11.0", + "sitectl-isle": "v0.12.0", +} assert project["docker_compose_repo"] == "https://github.com/libops/isle" -assert project["project_dir"] == "/opt/isle" +assert project["project_dir"] == "/mnt/disks/data/libops/isle/main" assert project["ingress"]["domain"] == "isle.example.edu" assert project["sitectl_plugin"] == "isle" -assert Path("/opt/isle").is_dir() +assert project["init_commands"] == [] +assert project["up_commands"] == [] +assert project["down_commands"] == ["app-down-command"] +assert project["rollout_commands"] == ["global-rollout-command"] +assert Path("/mnt/disks/data/libops/isle/main").is_dir() +assert "BASH_ENV" not in env +assert "LD_PRELOAD" not in env +assert "PORT" not in env +assert application_env["BASH_ENV"] == "/tmp/cloud-compose-ansible-untrusted-bash-env" +assert application_env["LD_PRELOAD"] == "/tmp/cloud-compose-ansible-untrusted-preload.so" +assert application_env["PORT"] == "9999" +assert application_env["CONTRACT_BACKTICKS"] == "`touch /tmp/cloud-compose-ansible-backtick-injection`" +assert application_env["CONTRACT_BACKSLASH"] == "a\\path\\ends\\" +assert application_env["CONTRACT_COMMAND_SUB"] == "$(touch /tmp/cloud-compose-ansible-command-injection)" +assert application_env["CONTRACT_DOLLARS"] == "$HOME ${HOME}" +assert application_env["CONTRACT_QUOTES"] == 'a "double" and a single quote: O\'Reilly' +assert application_env["CONTRACT_WHITESPACE"] == " leading and trailing " +assert application_env["CONTRACT_MULTILINE"] == "line one\nline two" +assert not Path("/tmp/cloud-compose-ansible-backtick-injection").exists() +assert not Path("/tmp/cloud-compose-ansible-command-injection").exists() for path in [ "/home/cloud-compose/init", @@ -65,9 +300,25 @@ for path in [ ]: assert Path(path).exists(), path assert os.access(path, os.X_OK), path + +cloud_compose_gid = grp.getgrnam("cloud-compose").gr_gid +for path, expected_mode in { + "/home/cloud-compose/init": 0o750, + "/home/cloud-compose/up": 0o750, + "/home/cloud-compose/down": 0o750, + "/home/cloud-compose/rollout": 0o750, + "/home/cloud-compose/.env": 0o640, + "/home/cloud-compose/application-env.json": 0o640, + "/home/cloud-compose/compose-projects.json": 0o640, + "/home/cloud-compose/managed-runtime-artifacts.tsv": 0o640, +}.items(): + metadata = Path(path).stat() + assert metadata.st_uid == 0, (path, metadata.st_uid) + assert metadata.st_gid == cloud_compose_gid, (path, metadata.st_gid) + assert stat.S_IMODE(metadata.st_mode) == expected_mode, (path, oct(stat.S_IMODE(metadata.st_mode))) PY -rm -rf /home/cloud-compose /mnt/disks /opt/isle /opt/wp /opt/drupal +rm -rf /home/cloud-compose /mnt/disks mkdir -p /tmp/salt/etc /tmp/salt/cache /tmp/salt/pki write_salt_config() { @@ -92,7 +343,7 @@ MINION run_salt_case() { local minion_id="$1" expected_name="$2" expected_repo="$3" expected_plugin="$4" expected_package="$5" expected_domain="$6" expected_project_dir="$7" - rm -rf /home/cloud-compose /mnt/disks /opt/isle /opt/wp /opt/drupal + rm -rf /home/cloud-compose /mnt/disks write_salt_config "$minion_id" salt-call \ @@ -107,24 +358,73 @@ run_salt_case() { --config-dir=/tmp/salt/etc \ state.apply cloud-compose + # Reapplying the formula must repair controls changed by the app user. + tamper_adapter_control_ownership + rm -rf -- /run/lock/cloud-compose + salt-call \ + --local \ + --retcode-passthrough \ + --config-dir=/tmp/salt/etc \ + state.apply cloud-compose + + [[ "$(stat -c '%U:%G:%a' \ + /run/lock/cloud-compose \ + /run/lock/cloud-compose/lifecycle.lock)" == \ + $'root:cloud-compose:750\nroot:cloud-compose:660' ]] + + salt-call \ + --local \ + --retcode-passthrough \ + --config-dir=/tmp/salt/etc \ + --out=json \ + state.apply cloud-compose >/tmp/cloud-compose-salt-noop.json + python - <<'PY' +import json +from pathlib import Path + +states = json.loads(Path("/tmp/cloud-compose-salt-noop.json").read_text())["local"] +lock_states = [ + state for state_id, state in states.items() + if "|-cloud-compose-lifecycle-lock_|-" in state_id +] +assert len(lock_states) == 1, lock_states +assert lock_states[0]["result"] is True, lock_states[0] +assert lock_states[0]["changes"] == {}, lock_states[0] +PY + python -m json.tool /home/cloud-compose/compose-projects.json >/dev/null python - "$expected_name" "$expected_repo" "$expected_plugin" "$expected_package" "$expected_domain" "$expected_project_dir" <<'PY' import json +import grp import os +import stat +import subprocess import sys from pathlib import Path expected_name, expected_repo, expected_plugin, expected_package, expected_domain, expected_project_dir = sys.argv[1:] -env = {} -for line in Path("/home/cloud-compose/.env").read_text().splitlines(): - if not line.strip(): - continue - key, raw = line.split("=", 1) - env[key] = json.loads(raw) +def load_runtime_env(path): + result = subprocess.run( + [ + "env", "-i", "PATH=/usr/bin:/bin", f"CLOUD_COMPOSE_ENV_FILE={path}", + "bash", "--noprofile", "--norc", "-c", + "source /home/cloud-compose/profile.sh; env -0", + ], + check=True, + stdout=subprocess.PIPE, + ) + return { + entry.split(b"=", 1)[0].decode(): entry.split(b"=", 1)[1].decode() + for entry in result.stdout.split(b"\0") if b"=" in entry + } + +env = load_runtime_env(Path("/home/cloud-compose/.env")) +application_env = json.loads(Path("/home/cloud-compose/application-env.json").read_text()) projects = json.loads(Path("/home/cloud-compose/compose-projects.json").read_text()) project = projects[expected_name] +artifact_manifest = Path("/home/cloud-compose/managed-runtime-artifacts.tsv").read_text() assert env["CLOUD_COMPOSE_PROVIDER"] == "onprem" assert env["CLOUD_COMPOSE_APPS"] == expected_name @@ -139,6 +439,36 @@ assert project["sitectl_plugin"] == expected_plugin assert project["ingress"]["domain"] == expected_domain assert Path(expected_project_dir).is_dir() +if expected_name == "wp-prod": + assert json.loads(env["SITECTL_PACKAGE_VERSIONS"]) == { + "sitectl": "v0.39.0", + "sitectl-wp": "v0.10.0", + } + assert "BASH_ENV" not in env + assert "LD_PRELOAD" not in env + assert "PORT" not in env + assert application_env["BASH_ENV"] == "/tmp/cloud-compose-salt-untrusted-bash-env" + assert application_env["LD_PRELOAD"] == "/tmp/cloud-compose-salt-untrusted-preload.so" + assert application_env["PORT"] == "9999" + assert application_env["CONTRACT_BACKTICKS"] == "`touch /tmp/cloud-compose-salt-backtick-injection`" + assert application_env["CONTRACT_BACKSLASH"] == "a\\path\\ends\\" + assert application_env["CONTRACT_COMMAND_SUB"] == "$(touch /tmp/cloud-compose-salt-command-injection)" + assert application_env["CONTRACT_DOLLARS"] == "$HOME ${HOME}" + assert application_env["CONTRACT_QUOTES"] == 'a "double" and a single quote: O\'Reilly' + assert application_env["CONTRACT_WHITESPACE"] == " leading and trailing " + assert application_env["CONTRACT_MULTILINE"] == "line one\nline two" + assert env["LIBOPS_MANAGED_RUNTIME_ENABLED"] == "false" + assert env["LIBOPS_INTERNAL_SERVICES_ENABLED"] == "false" + assert env["LIBOPS_INTERNAL_SERVICES_AUTO_UPDATE"] == "false" + assert project["init_commands"] == [] + assert project["up_commands"] == [] + assert project["down_commands"] == ["app-down-command"] + assert project["rollout_commands"] == ["global-rollout-command"] + assert "contract-agent\thttps://example.invalid/contract-agent\t" in artifact_manifest + assert "\t/usr/local/bin/contract-agent\t0750\troot\troot\tcloud-compose.service" in artifact_manifest + assert not Path("/tmp/cloud-compose-salt-backtick-injection").exists() + assert not Path("/tmp/cloud-compose-salt-command-injection").exists() + for path in [ "/home/cloud-compose/init", "/home/cloud-compose/up", @@ -148,6 +478,22 @@ for path in [ ]: assert Path(path).exists(), path assert os.access(path, os.X_OK), path + +cloud_compose_gid = grp.getgrnam("cloud-compose").gr_gid +for path, expected_mode in { + "/home/cloud-compose/init": 0o750, + "/home/cloud-compose/up": 0o750, + "/home/cloud-compose/down": 0o750, + "/home/cloud-compose/rollout": 0o750, + "/home/cloud-compose/.env": 0o640, + "/home/cloud-compose/application-env.json": 0o640, + "/home/cloud-compose/compose-projects.json": 0o640, + "/home/cloud-compose/managed-runtime-artifacts.tsv": 0o640, +}.items(): + metadata = Path(path).stat() + assert metadata.st_uid == 0, (path, metadata.st_uid) + assert metadata.st_gid == cloud_compose_gid, (path, metadata.st_gid) + assert stat.S_IMODE(metadata.st_mode) == expected_mode, (path, oct(stat.S_IMODE(metadata.st_mode))) PY } @@ -158,7 +504,7 @@ run_salt_case \ wp \ sitectl-wp \ wp.example.edu \ - /opt/wp + /mnt/disks/data/libops/wp/main run_salt_case \ drupal-prod \ @@ -167,4 +513,63 @@ run_salt_case \ drupal \ sitectl-drupal \ drupal.example.edu \ - /opt/drupal + /mnt/disks/data/libops/drupal/main + +run_invalid_salt_case() { + local invalid_case="$1" expected="$2" + local log="/tmp/cloud-compose-salt-${invalid_case}.log" + local root_before etc_before passwd_before group_before + + rm -rf /home/cloud-compose /mnt/disks /tmp/cloud-compose-symlink-target + if [[ "$invalid_case" == "invalid-project-dir-symlink" ]]; then + mkdir -p /mnt/disks/data /tmp/cloud-compose-symlink-target + printf 'must-not-change\n' >/tmp/cloud-compose-symlink-target/sentinel + ln -s /tmp/cloud-compose-symlink-target /mnt/disks/data/escape + fi + root_before="$(stat -c '%u:%g:%a' /)" + etc_before="$(stat -c '%u:%g:%a' /etc)" + passwd_before="$(getent passwd cloud-compose || true)" + group_before="$(getent group cloud-compose || true)" + write_salt_config "$invalid_case" + if salt-call \ + --local \ + --retcode-passthrough \ + --config-dir=/tmp/salt/etc \ + state.apply cloud-compose \ + >"$log" 2>&1; then + echo "Salt accepted invalid settings from ${invalid_case}.sls" >&2 + exit 1 + fi + grep -Fq -- "$expected" "$log" + [[ "$(stat -c '%u:%g:%a' /)" == "$root_before" ]] + [[ "$(stat -c '%u:%g:%a' /etc)" == "$etc_before" ]] + [[ "$(getent passwd cloud-compose || true)" == "$passwd_before" ]] + [[ "$(getent group cloud-compose || true)" == "$group_before" ]] + [[ ! -e /home/cloud-compose ]] + [[ ! -e /mnt/disks/volumes ]] + if [[ "$invalid_case" == "invalid-project-dir-symlink" ]]; then + grep -Fxq 'must-not-change' /tmp/cloud-compose-symlink-target/sentinel + [[ -L /mnt/disks/data/escape ]] + fi +} + +run_invalid_salt_case invalid-env 'extra_env name must match' +run_invalid_salt_case invalid-sitectl 'sitectl.version must be latest or an exact semantic-version release tag' +run_invalid_salt_case invalid-reserved-env 'reserved host control' +run_invalid_salt_case invalid-project 'compose.projects key must match' +run_invalid_salt_case invalid-project-repo 'docker_compose_repo is required' +run_invalid_salt_case invalid-project-port 'ingress_port must be a whole number between 1 and 65535' +run_invalid_salt_case invalid-ingress-port 'compose.ingress_port must be a whole number between 1 and 65535' +run_invalid_salt_case invalid-lifecycle 'compose.init must be a list of strings' +run_invalid_salt_case invalid-project-lifecycle 'up_commands or docker_compose_up must be a list of strings' +run_invalid_salt_case invalid-internal-services 'internal-services stack is GCP-specific' +run_invalid_salt_case invalid-primary 'compose.primary must match' +run_invalid_salt_case invalid-template 'template must name a supported cloud-compose app' +run_invalid_salt_case invalid-vault 'Vault Agent is currently supported only by Terraform' +run_invalid_salt_case invalid-package 'invalid installed package' +run_invalid_salt_case invalid-host-ack 'dedicated_host_acknowledged=true' +run_invalid_salt_case invalid-project-dir-root 'project_dir must be a normalized non-root absolute path' +run_invalid_salt_case invalid-project-dir-etc 'project_dir must be a non-root descendant of /mnt/disks/data' +run_invalid_salt_case invalid-project-dir-traversal 'project_dir must be a normalized non-root absolute path' +run_invalid_salt_case invalid-project-dir-symlink 'project_dir resolves outside the fixed /mnt/disks/data boundary' +run_invalid_salt_case invalid-artifacts 'name must be a safe basename' diff --git a/ci/config-management-smoke.sh b/ci/config-management-smoke.sh index 791886b..42e356d 100755 --- a/ci/config-management-smoke.sh +++ b/ci/config-management-smoke.sh @@ -3,7 +3,9 @@ set -euo pipefail repo_root="$(cd "$(dirname "$0")/.." && pwd)" -image="${CLOUD_COMPOSE_CONFIG_MANAGEMENT_IMAGE:-python:3.11-slim}" +# renovate: datasource=docker depName=python packageName=python versioning=docker +CONFIG_MANAGEMENT_IMAGE_DEFAULT="python:3.11-slim@sha256:e031123e3d85762b141ad1cbc56452ba69c6e722ebf2f042cc0dc86c47c0d8b3" +image="${CLOUD_COMPOSE_CONFIG_MANAGEMENT_IMAGE:-$CONFIG_MANAGEMENT_IMAGE_DEFAULT}" require_cmd() { command -v "$1" >/dev/null 2>&1 || { diff --git a/ci/cos-bootstrap-contract.sh b/ci/cos-bootstrap-contract.sh new file mode 100755 index 0000000..1b87b41 --- /dev/null +++ b/ci/cos-bootstrap-contract.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +INSTALLER="${ROOT_DIR}/rootfs/home/cloud-compose/install-dependencies-cos.sh" +TEST_ROOT="$(mktemp -d)" +trap 'rm -rf -- "$TEST_ROOT"' EXIT + +BIN_DIR="${TEST_ROOT}/bin" +CLOUD_HOME="${TEST_ROOT}/home/cloud-compose" +DATA_DIR="${TEST_ROOT}/mnt/disks/data" +DOCKER_CONFIG_DIR="${TEST_ROOT}/docker-config" +HARNESS="${TEST_ROOT}/harness.sh" +CALL_LOG="${TEST_ROOT}/calls.log" +mkdir -p "$BIN_DIR" "$CLOUD_HOME" "$DATA_DIR" "$DOCKER_CONFIG_DIR" + +# Static trust contract: the build environment is an immutable multi-platform +# image, and the GNU Make archive is verified before any extraction occurs. +if ! grep -Eq '^ALPINE_BUILD_IMAGE="alpine:3\.22@sha256:[0-9a-f]{64}"$' "$INSTALLER"; then + echo "COS bootstrap Alpine image is not digest pinned" >&2 + exit 1 +fi +# Assert the literal command in the bootstrap script. +# shellcheck disable=SC2016 +if ! grep -Fq 'echo "${MAKE_SHA256} /tmp/make.tar.gz" | sha256sum -c -' "$INSTALLER"; then + echo "COS bootstrap does not verify the GNU Make source archive" >&2 + exit 1 +fi +checksum_line="$(grep -nF 'sha256sum -c -' "$INSTALLER" | head -n1)" +archive_line="$(grep -nF 'tar -xzf /tmp/make.tar.gz' "$INSTALLER" | head -n1)" +if (( ${checksum_line%%:*} >= ${archive_line%%:*} )); then + echo "COS bootstrap extracts GNU Make before checksum verification" >&2 + exit 1 +fi +if grep -Eq '^[[:space:]]*--network[[:space:]]+host([[:space:]\\]|$)' "$INSTALLER"; then + echo "COS bootstrap exposes third-party build code to the host network" >&2 + exit 1 +fi +grep -Fq 'iptables -C DOCKER-USER -d 169.254.169.254/32 -j DROP' "$INSTALLER" || { + echo "COS bootstrap does not require the GCP metadata deny policy" >&2 + exit 1 +} +grep -Fq 'COS_TOOL_STATE_DIR:-/mnt/disks/data/cloud-compose-tools' "$INSTALLER" || { + echo "COS bootstrap stores an executable tool on a potentially noexec filesystem" >&2 + exit 1 +} +if grep -Fq '/var/lib/cloud-compose/tools' "$INSTALLER"; then + echo "COS bootstrap stores GNU Make on the noexec stateful /var mount" >&2 + exit 1 +fi +grep -Fq '== *",noexec,"*' "$INSTALLER" || { + echo "COS bootstrap does not reject a noexec tool-state mount" >&2 + exit 1 +} + +# The package bootstrap must move between independent official HTTPS mirrors, +# while retaining apk's normal index and package signature verification. +for mirror in \ + https://dl-cdn.alpinelinux.org/alpine \ + https://mirror.math.princeton.edu/pub/alpinelinux \ + https://mirror.fel.cvut.cz/alpine; do + grep -Fq "$mirror" "$INSTALLER" || { + echo "COS bootstrap is missing Alpine package mirror: $mirror" >&2 + exit 1 + } +done +grep -Fq 'for alpine_mirror in ${alpine_mirrors}; do' "$INSTALLER" || { + echo "COS bootstrap does not fail over between Alpine package mirrors" >&2 + exit 1 +} +grep -Fq 'if apk update && apk add build-base curl make tar; then' "$INSTALLER" || { + echo "COS bootstrap does not validate an index before installing packages" >&2 + exit 1 +} +grep -Fq 'All configured Alpine package mirrors failed' "$INSTALLER" || { + echo "COS bootstrap accepts exhaustion of all Alpine package mirrors" >&2 + exit 1 +} +if grep -Fq -- '--allow-untrusted' "$INSTALLER"; then + echo "COS bootstrap disables Alpine package signature verification" >&2 + exit 1 +fi + +cat >"$HARNESS" <<'EOF' +#!/usr/bin/env bash +source "$COS_BOOTSTRAP_INSTALLER" +retry_until_success() { + "$@" +} +export DOCKER_CONFIG="$MOCK_DOCKER_CONFIG" +install_cos_dependencies "$TEST_CLOUD_HOME" "" "$TEST_DOCKER_BIN" +EOF + +cat >"${BIN_DIR}/bash" <<'EOF' +#!/bin/bash +printf 'plugin:%s:%s\n' "${DOCKER_CLI_PLUGIN_DIR:-}" "$*" >>"$MOCK_CALL_LOG" +EOF + +cat >"${BIN_DIR}/chown" <<'EOF' +#!/bin/bash +printf 'chown:%s\n' "$*" >>"$MOCK_CALL_LOG" +EOF + +cat >"${BIN_DIR}/docker" <<'EOF' +#!/bin/bash +set -euo pipefail + +printf 'docker:' >>"$MOCK_CALL_LOG" +printf ' %q' "$@" >>"$MOCK_CALL_LOG" +printf '\n' >>"$MOCK_CALL_LOG" + +output_dir="" +while (($# > 0)); do + if [[ "$1" == "-v" ]]; then + output_dir="${2%:/out}" + break + fi + shift +done +[[ -n "$output_dir" ]] +printf '%s' partial >"${output_dir}/.cloud-compose-make.pending" +if [[ "${FAKE_DOCKER_FAIL:-false}" == "true" ]]; then + exit 1 +fi +cat >"${output_dir}/.cloud-compose-make.pending" <<'MAKE' +#!/bin/sh +echo 'GNU Make 4.4.1' +MAKE +chmod 0755 "${output_dir}/.cloud-compose-make.pending" +mv -f "${output_dir}/.cloud-compose-make.pending" "${output_dir}/make" +EOF + +cat >"${BIN_DIR}/findmnt" <<'EOF' +#!/bin/bash +if [[ "${FAKE_FINDMNT_NOEXEC:-false}" == "true" ]]; then + printf '%s\n' 'rw,nosuid,nodev,noexec,relatime' +else + printf '%s\n' 'rw,relatime' +fi +EOF + +chmod +x "$HARNESS" "${BIN_DIR}/bash" "${BIN_DIR}/chown" "${BIN_DIR}/docker" "${BIN_DIR}/findmnt" + +PATH="${BIN_DIR}:$PATH" \ + COS_TOOL_STATE_DIR="$DATA_DIR" \ + COS_BOOTSTRAP_INSTALLER="$INSTALLER" \ + TEST_CLOUD_HOME="$CLOUD_HOME" \ + TEST_DATA_DIR="$DATA_DIR" \ + TEST_DOCKER_BIN="${BIN_DIR}/docker" \ + MOCK_DOCKER_CONFIG="$DOCKER_CONFIG_DIR" \ + MOCK_CALL_LOG="$CALL_LOG" \ + /bin/bash "$HARNESS" + +grep -Fq "plugin:${DOCKER_CONFIG_DIR}/cli-plugins:${CLOUD_HOME}/install-docker-plugins.sh" "$CALL_LOG" +grep -Fq "plugin:${CLOUD_HOME}/.docker/cli-plugins:${CLOUD_HOME}/install-docker-plugins.sh" "$CALL_LOG" +grep -Eq 'docker: .*alpine:3\.22@sha256:[0-9a-f]{64}' "$CALL_LOG" +[[ -x "${DATA_DIR}/make" ]] +"${DATA_DIR}/make" --version | grep -Fq 'GNU Make 4.4.1' +[[ -f "${DATA_DIR}/make.state" ]] +[[ -L "${CLOUD_HOME}/bin/make" ]] +[[ "$(readlink "${CLOUD_HOME}/bin/make")" == "${DATA_DIR}/make" ]] + +# A failed replacement must not promote a partial binary or leave a pending +# artifact that a later boot could mistake for a completed build. +printf '%s' known-invalid-old >"${DATA_DIR}/make" +chmod 0755 "${DATA_DIR}/make" +old_checksum="$(sha256sum "${DATA_DIR}/make")" +if PATH="${BIN_DIR}:$PATH" \ + FAKE_DOCKER_FAIL=true \ + COS_TOOL_STATE_DIR="$DATA_DIR" \ + COS_BOOTSTRAP_INSTALLER="$INSTALLER" \ + TEST_CLOUD_HOME="$CLOUD_HOME" \ + TEST_DATA_DIR="$DATA_DIR" \ + TEST_DOCKER_BIN="${BIN_DIR}/docker" \ + MOCK_DOCKER_CONFIG="$DOCKER_CONFIG_DIR" \ + MOCK_CALL_LOG="$CALL_LOG" \ + /bin/bash "$HARNESS" >/dev/null 2>&1; then + echo "COS bootstrap accepted a failed partial Make build" >&2 + exit 1 +fi +[[ "$(sha256sum "${DATA_DIR}/make")" == "$old_checksum" ]] +[[ ! -e "${DATA_DIR}/.cloud-compose-make.pending" ]] + +# The production default is on an executable persistent disk. Any operator +# override that resolves to a noexec mount must fail before a build container +# or cached binary can be accepted. +docker_calls_before_noexec="$(grep -c '^docker:' "$CALL_LOG")" +if PATH="${BIN_DIR}:$PATH" \ + FAKE_FINDMNT_NOEXEC=true \ + COS_TOOL_STATE_DIR="$DATA_DIR" \ + COS_BOOTSTRAP_INSTALLER="$INSTALLER" \ + TEST_CLOUD_HOME="$CLOUD_HOME" \ + TEST_DATA_DIR="$DATA_DIR" \ + TEST_DOCKER_BIN="${BIN_DIR}/docker" \ + MOCK_DOCKER_CONFIG="$DOCKER_CONFIG_DIR" \ + MOCK_CALL_LOG="$CALL_LOG" \ + /bin/bash "$HARNESS" >/dev/null 2>&1; then + echo "COS bootstrap accepted a noexec tool-state mount" >&2 + exit 1 +fi +docker_calls_after_noexec="$(grep -c '^docker:' "$CALL_LOG")" +if [[ "$docker_calls_after_noexec" != "$docker_calls_before_noexec" ]]; then + echo "COS bootstrap launched a build container before rejecting a noexec mount" >&2 + exit 1 +fi + +if grep -Eq '"\$make_path"[[:space:]]+--version' "$INSTALLER"; then + echo "COS bootstrap executes a mutable host Make path during validation" >&2 + exit 1 +fi + +echo "COS bootstrap artifact trust contract passed" diff --git a/ci/cos-jq-portability-contract.sh b/ci/cos-jq-portability-contract.sh new file mode 100644 index 0000000..9780f3c --- /dev/null +++ b/ci/cos-jq-portability-contract.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +runtime_root="$repo_root/rootfs/home/cloud-compose" +regex_call_pattern='(^|[^[:alnum:]_])(test|match|capture|scan|splits|sub|gsub)[[:space:]]*\(' +nul_contains_pattern='contains[[:space:]]*\([[:space:]]*"\\u0000"[[:space:]]*\)' +failed=false + +# Container-Optimized OS ships jq without Oniguruma. Its runtime scripts may +# use jq for JSON structure and types, but text validation belongs in Bash. +# The remaining sub(/.../) and gsub(/.../) calls are awk regex literals, not +# jq filters; jq has no slash-delimited regex syntax. +while IFS= read -r call; do + if [[ "$call" == *'sub(/'* || "$call" == *'gsub(/'* ]]; then + continue + fi + printf 'COS runtime contains a regex-dependent jq-style call: %s\n' "$call" >&2 + failed=true +done < <(grep -ERn --include='*.sh' "$regex_call_pattern" "$runtime_root" || true) + +# jq 1.6 treats every string as containing a NUL when contains("\u0000") is +# used. COS and supported configuration-management hosts can still run jq 1.6, +# so deployed validation must inspect Unicode code points instead. +while IFS= read -r call; do + printf 'Runtime contains jq 1.6-incompatible NUL validation: %s\n' "$call" >&2 + failed=true +done < <(grep -ERn --include='*.sh' "$nul_contains_pattern" "$runtime_root" || true) + +if [[ "$failed" == "true" ]]; then + exit 1 +fi + +echo "COS jq portability contract passed" diff --git a/ci/docker-plugin-install-contract.sh b/ci/docker-plugin-install-contract.sh new file mode 100755 index 0000000..1efdf94 --- /dev/null +++ b/ci/docker-plugin-install-contract.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +INSTALLER="${ROOT_DIR}/rootfs/home/cloud-compose/install-docker-plugins.sh" +TEST_ROOT="$(mktemp -d)" +trap 'rm -rf -- "$TEST_ROOT"' EXIT + +BIN_DIR="${TEST_ROOT}/bin" +PLUGIN_DIR="${TEST_ROOT}/plugins" +HARNESS="${TEST_ROOT}/harness.sh" +CURL_LOG="${TEST_ROOT}/curl.log" +mkdir -p "$BIN_DIR" "$PLUGIN_DIR" + +cat >"$HARNESS" <<'EOF' +#!/usr/bin/env bash +source "$DOCKER_PLUGIN_INSTALLER" +retry_until_success() { + "$@" +} +install_docker_plugins +EOF + +cat >"${BIN_DIR}/uname" <<'EOF' +#!/usr/bin/env bash +if [[ "${1:-}" == "-m" ]]; then + printf '%s\n' x86_64 + exit 0 +fi +exec /usr/bin/uname "$@" +EOF + +cat >"${BIN_DIR}/curl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +url="" +output="" +while (($# > 0)); do + case "$1" in + -o) + output="$2" + shift 2 + ;; + -*) shift ;; + *) + url="$1" + shift + ;; + esac +done + +[[ -n "$url" && -n "$output" ]] +printf '%s\n' "$url" >>"$MOCK_CURL_LOG" +asset="${url##*/}" + +if [[ "$asset" == "checksums.txt" ]]; then + version="${url%/checksums.txt}" + version="${version##*/}" + case "$url" in + */docker/compose/*) + asset="docker-compose-linux-x86_64" + payload="$MOCK_COMPOSE_EXPECTED_PAYLOAD" + ;; + */docker/buildx/*) + asset="buildx-${version}.linux-amd64" + payload="$MOCK_BUILDX_EXPECTED_PAYLOAD" + ;; + *) + echo "unexpected checksum URL: $url" >&2 + exit 1 + ;; + esac + checksum="$(printf '%s' "$payload" | sha256sum)" + printf '%s *%s\n' "${checksum%% *}" "$asset" >"$output" + exit 0 +fi + +case "$asset" in + docker-compose-linux-x86_64) + printf '%s' "$MOCK_COMPOSE_DOWNLOAD_PAYLOAD" >"$output" + ;; + buildx-v*.linux-amd64) + printf '%s' "$MOCK_BUILDX_DOWNLOAD_PAYLOAD" >"$output" + ;; + *) + echo "unexpected binary URL: $url" >&2 + exit 1 + ;; +esac +EOF + +chmod +x "$HARNESS" "${BIN_DIR}/uname" "${BIN_DIR}/curl" + +run_installer() { + local compose_version="$1" + local buildx_version="$2" + local compose_expected="$3" + local compose_download="$4" + local buildx_expected="$5" + local buildx_download="$6" + + PATH="${BIN_DIR}:$PATH" \ + DOCKER_PLUGIN_INSTALLER="$INSTALLER" \ + DOCKER_CLI_PLUGIN_DIR="$PLUGIN_DIR" \ + DOCKER_COMPOSE_VERSION="$compose_version" \ + DOCKER_BUILDX_VERSION="$buildx_version" \ + MOCK_CURL_LOG="$CURL_LOG" \ + MOCK_COMPOSE_EXPECTED_PAYLOAD="$compose_expected" \ + MOCK_COMPOSE_DOWNLOAD_PAYLOAD="$compose_download" \ + MOCK_BUILDX_EXPECTED_PAYLOAD="$buildx_expected" \ + MOCK_BUILDX_DOWNLOAD_PAYLOAD="$buildx_download" \ + bash "$HARNESS" +} + +assert_content() { + local expected="$1" + local path="$2" + local actual + + actual="$(<"$path")" + if [[ "$actual" != "$expected" ]]; then + echo "unexpected content in $path: $actual" >&2 + exit 1 + fi +} + +# A valid release installs both plugins only after their selected manifest +# entries verify, and leaves executable final files. +run_installer v1.2.3 v0.10.0 \ + compose-v1 compose-v1 \ + buildx-v1 buildx-v1 +assert_content compose-v1 "${PLUGIN_DIR}/docker-compose" +assert_content buildx-v1 "${PLUGIN_DIR}/docker-buildx" +[[ -x "${PLUGIN_DIR}/docker-compose" ]] +[[ -x "${PLUGIN_DIR}/docker-buildx" ]] + +# An executable modified in place is not trusted merely because it exists; the +# next run repairs it from a verified download for the configured release. +printf '%s' locally-tampered >"${PLUGIN_DIR}/docker-buildx" +run_installer v1.2.3 v0.10.0 \ + compose-v1 should-not-be-downloaded \ + buildx-v1 buildx-v1 +assert_content compose-v1 "${PLUGIN_DIR}/docker-compose" +assert_content buildx-v1 "${PLUGIN_DIR}/docker-buildx" + +# A tampered upgrade must fail closed and preserve the previously verified +# executable instead of replacing it with the failed download. +if run_installer v2.0.0 v0.10.0 \ + compose-v2 tampered-compose \ + buildx-v1 tampered-buildx; then + echo "tampered Docker Compose download unexpectedly installed" >&2 + exit 1 +fi +assert_content compose-v1 "${PLUGIN_DIR}/docker-compose" +assert_content buildx-v1 "${PLUGIN_DIR}/docker-buildx" + +# Configured version changes reconcile existing executables for both plugins. +run_installer v2.0.0 v0.11.0 \ + compose-v2 compose-v2 \ + buildx-v2 buildx-v2 +assert_content compose-v2 "${PLUGIN_DIR}/docker-compose" +assert_content buildx-v2 "${PLUGIN_DIR}/docker-buildx" + +if find "$PLUGIN_DIR" -mindepth 1 -maxdepth 1 -type d -name '.docker-*' -print -quit | grep -q .; then + echo "plugin installer left a temporary directory behind" >&2 + exit 1 +fi + +echo "Docker CLI plugin verification contract passed" diff --git a/ci/filesystem-prep-contract.sh b/ci/filesystem-prep-contract.sh new file mode 100644 index 0000000..97ea748 --- /dev/null +++ b/ci/filesystem-prep-contract.sh @@ -0,0 +1,539 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +prep_script="$repo_root/rootfs/home/cloud-compose/prepare-filesystem.sh" +persist_script="$repo_root/rootfs/home/cloud-compose/persist-filesystems.sh" +tmp="$(mktemp -d)" +trap 'rm -rf -- "$tmp"' EXIT + +fail() { + echo "filesystem preparation contract: $*" >&2 + exit 1 +} + +mkdir -p "$tmp/bin" "$tmp/dev" "$tmp/mount" "$tmp/systemd" +device="$tmp/dev/data" +: >"$device" +calls="$tmp/calls" + +cat >"$tmp/bin/readlink" <<'EOF' +#!/usr/bin/env bash +case "${!#}" in + /dev/disk/by-id/*) printf '%s\n' "${FAKE_DEVICE:-}" ;; + *) printf '%s\n' "${FAKE_RESOLVED_MOUNT_SOURCE:-${!#}}" ;; +esac +EOF +cat >"$tmp/bin/blkid" <<'EOF' +#!/usr/bin/env bash +if [[ "${FAKE_BLKID_STATUS:-0}" == "0" ]]; then + printf '%s\n' "${FAKE_FILESYSTEM_TYPE:-ext4}" +fi +exit "${FAKE_BLKID_STATUS:-0}" +EOF +cat >"$tmp/bin/fsck.ext4" <<'EOF' +#!/usr/bin/env bash +printf 'fsck\n' >>"${CALLS:?}" +printf 'fsck-args %s\n' "$*" >>"${CALLS:?}" +exit "${FAKE_FSCK_STATUS:-0}" +EOF +cat >"$tmp/bin/mkfs.ext4" <<'EOF' +#!/usr/bin/env bash +printf 'mkfs\n' >>"${CALLS:?}" +EOF +cat >"$tmp/bin/resize2fs" <<'EOF' +#!/usr/bin/env bash +printf 'resize\n' >>"${CALLS:?}" +exit "${FAKE_RESIZE_STATUS:-0}" +EOF +cat >"$tmp/bin/mountpoint" <<'EOF' +#!/usr/bin/env bash +target="${!#}" +active_targets="${FAKE_DEVICE_MOUNT_TARGETS:-${FAKE_DEVICE_MOUNT_TARGET:-}}" +if [[ -s "${FAKE_MOUNT_STATE:?}" ]]; then + active_targets="$(<"$FAKE_MOUNT_STATE")" +fi +if [[ "$target" == "${MOUNT_PATH:?}" && "${FAKE_MOUNTED:-1}" == "0" ]]; then + exit 0 +fi +while IFS= read -r active_target; do + [[ -n "$active_target" && "$target" == "$active_target" ]] && exit 0 +done <<<"$active_targets" +exit 1 +EOF +cat >"$tmp/bin/mount" <<'EOF' +#!/usr/bin/env bash +printf 'mount\n' >>"${CALLS:?}" +if [[ "${1:-}" == "--move" ]]; then + printf '%s\n' "${3:?}" >"${FAKE_MOUNT_STATE:?}" +fi +EOF +cat >"$tmp/bin/findmnt" <<'EOF' +#!/usr/bin/env bash +printf 'findmnt\n' >>"${CALLS:?}" +active_targets="${FAKE_DEVICE_MOUNT_TARGETS:-${FAKE_DEVICE_MOUNT_TARGET:-}}" +if [[ -s "${FAKE_MOUNT_STATE:?}" ]]; then + active_targets="$(<"$FAKE_MOUNT_STATE")" +fi +if [[ "${FAKE_MOUNTED:-1}" == "0" ]]; then + active_targets="${MOUNT_PATH:?}${active_targets:+$'\n'}${active_targets}" +fi +case " $* " in + *" -o SOURCE --target "*) + target="${!#}" + while IFS= read -r active_target; do + if [[ -n "$active_target" && "$target" == "$active_target" ]]; then + printf '%s\n' "${FAKE_MOUNT_SOURCE:-${FAKE_DEVICE:?}}" + exit 0 + fi + done <<<"$active_targets" + exit 1 + ;; + *" -o TARGET --source "*) + count=0 + if [[ -s "${FAKE_FINDMNT_COUNT:?}" ]]; then + count="$(<"$FAKE_FINDMNT_COUNT")" + fi + count=$((count + 1)) + printf '%s\n' "$count" >"$FAKE_FINDMNT_COUNT" + if [[ -n "${FAKE_LATE_MOUNT_TARGET:-}" && "$count" -gt "${FAKE_LATE_MOUNT_AFTER:-1}" ]]; then + active_targets="${active_targets}${active_targets:+$'\n'}${FAKE_LATE_MOUNT_TARGET}" + fi + if [[ -n "$active_targets" ]]; then + printf '%s\n' "$active_targets" + exit 0 + fi + exit 1 + ;; +esac +exit 2 +EOF +cat >"$tmp/bin/udevadm" <<'EOF' +#!/usr/bin/env bash +printf 'udevadm\n' >>"${CALLS:?}" +if [[ -n "${FAKE_UDEV_MOUNT_TARGET:-}" ]]; then + printf '%s\n' "$FAKE_UDEV_MOUNT_TARGET" >"${FAKE_MOUNT_STATE:?}" +fi +exit "${FAKE_UDEV_STATUS:-0}" +EOF +cat >"$tmp/bin/systemctl" <<'EOF' +#!/usr/bin/env bash +printf 'systemctl %s\n' "$*" >>"${CALLS:?}" +if [[ "${1:-}" == "start" && -n "${FAKE_SYSTEMD_MOUNT_TARGET:-}" ]]; then + printf '%s\n' "$FAKE_SYSTEMD_MOUNT_TARGET" >"${FAKE_MOUNT_STATE:?}" +fi +exit "${FAKE_SYSTEMCTL_STATUS:-0}" +EOF +cat >"$tmp/bin/sleep" <<'EOF' +#!/usr/bin/env bash +printf 'sleep\n' >>"${CALLS:?}" +EOF +chmod +x "$tmp/bin/"* + +run_prep() { + local configured_device="${TEST_FAKE_DEVICE-$device}" + : >"$calls" + rm -f -- "$tmp/mount-state" + rm -f -- "$tmp/findmnt-count" + FAKE_DEVICE="$configured_device" FAKE_MOUNT_STATE="$tmp/mount-state" \ + FAKE_FINDMNT_COUNT="$tmp/findmnt-count" CALLS="$calls" \ + PATH="$tmp/bin:/usr/bin:/bin" PREP_SCRIPT="$prep_script" MOUNT_PATH="$tmp/mount" \ + CLOUD_COMPOSE_SYSTEMD_DIR="$tmp/systemd" \ + DEVICE_PATH="${TEST_DEVICE_PATH:-/dev/disk/by-id/test-data}" \ + bash --noprofile --norc -c ' + source "$PREP_SCRIPT" + if [[ -n "${TEST_PROVIDER_MOUNT:-}" ]]; then + digitalocean_automatic_mount() { printf "%s\n" "$TEST_PROVIDER_MOUNT"; } + fi + is_block_device() { return 0; } + main "$DEVICE_PATH" "$MOUNT_PATH" + ' +} + +for status in 0 1; do + FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS="$status" run_prep + grep -Fxq fsck "$calls" || fail "fsck status $status did not check the existing filesystem" + grep -Fxq "fsck-args -f -p -- $device" "$calls" || + fail "fsck status $status did not force a complete offline ext4 check" + grep -Fxq resize "$calls" || fail "fsck status $status did not grow the existing filesystem" + grep -Fxq mount "$calls" || fail "fsck status $status did not mount the usable filesystem" + if grep -Fxq mkfs "$calls"; then + fail "fsck status $status reformatted an existing filesystem" + fi +done + +for status in 2 3 4 8 16 32 128; do + if FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS="$status" run_prep >/dev/null 2>&1; then + fail "fsck failure status $status was accepted" + fi + grep -Fxq fsck "$calls" || fail "fsck failure status $status was not observed" + if grep -Eq '^(mkfs|mount|resize)$' "$calls"; then + fail "fsck failure status $status reformatted or mounted the existing filesystem" + fi +done + +FAKE_BLKID_STATUS=2 FAKE_FSCK_STATUS=0 run_prep +grep -Fxq mkfs "$calls" || fail "an unsigned device was not formatted" +grep -Fxq mount "$calls" || fail "a newly formatted device was not mounted" +if grep -Fxq fsck "$calls"; then + fail "an unsigned device was checked before its first format" +fi +if grep -Fxq resize "$calls"; then + fail "a newly formatted whole-disk filesystem was resized redundantly" +fi + +if FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=xfs FAKE_FSCK_STATUS=0 run_prep >/dev/null 2>&1; then + fail "a non-ext4 filesystem was accepted" +fi +if grep -Eq '^(mkfs|mount|resize)$' "$calls"; then + fail "a non-ext4 filesystem was reformatted or mounted as ext4" +fi + +for status in 1 3 4; do + if FAKE_BLKID_STATUS="$status" FAKE_FSCK_STATUS=0 run_prep >/dev/null 2>&1; then + fail "blkid failure status $status was accepted" + fi + if grep -Eq '^(fsck|mkfs|mount|resize)$' "$calls"; then + fail "blkid failure status $status mutated the device" + fi +done + +FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS=0 \ +FAKE_MOUNTED=0 FAKE_MOUNT_SOURCE=/dev/correct FAKE_RESOLVED_MOUNT_SOURCE="$device" run_prep +if grep -Fxq mount "$calls"; then + fail "an already-correct mount was mounted again" +fi +if grep -Fxq fsck "$calls"; then + fail "an already-mounted filesystem was checked offline" +fi +grep -Fxq resize "$calls" || fail "an already-mounted filesystem was not grown online" + +if FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS=0 \ + FAKE_MOUNTED=0 FAKE_MOUNT_SOURCE=/dev/wrong FAKE_RESOLVED_MOUNT_SOURCE=/dev/wrong \ + run_prep >/dev/null 2>&1; then + fail "an existing mount from the wrong device was accepted" +fi +if grep -Eq '^(fsck|mkfs|mount|resize)$' "$calls"; then + fail "wrong-device mount detection mutated the filesystem" +fi + +expected_do_mount="$( + # shellcheck source=../rootfs/home/cloud-compose/prepare-filesystem.sh + source "$prep_script" + digitalocean_automatic_mount \ + /dev/disk/by-id/scsi-0DO_Volume_cc-do-isle-123-data +)" +[[ "$expected_do_mount" == "/mnt/cc_do_isle_123_data" ]] || + fail "DigitalOcean automatic mount naming was not derived correctly" +if ( + # shellcheck source=../rootfs/home/cloud-compose/prepare-filesystem.sh + source "$prep_script" + digitalocean_automatic_mount /dev/disk/by-id/scsi-0DO_Volume_bad/name +) >/dev/null 2>&1; then + fail "an unsafe DigitalOcean volume name was accepted" +fi + +provider_mount="$tmp/provider-owned-mount" +mkdir -p "$provider_mount" +FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS=0 \ + FAKE_DEVICE_MOUNT_TARGET="$provider_mount" TEST_PROVIDER_MOUNT="$provider_mount" \ + TEST_DEVICE_PATH=/dev/disk/by-id/scsi-0DO_Volume_cc-do-isle-data run_prep +grep -Fxq mount "$calls" || fail "the provider-owned mount was not moved" +grep -Fxq resize "$calls" || fail "the moved filesystem was not grown online" +if grep -Eq '^(fsck|mkfs)$' "$calls"; then + fail "the mounted provider filesystem was checked or formatted" +fi +[[ "$(<"$tmp/mount-state")" == "$tmp/mount" ]] || + fail "the provider-owned mount was not moved to the requested target" + +# Device discovery must not race DigitalOcean's udev automount. Model the +# provider mount appearing only when udevadm settle runs; an offline fsck of +# this mounted filesystem would reproduce the hosted failure. +mkdir -p "$provider_mount" +FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS=8 \ + FAKE_UDEV_MOUNT_TARGET="$provider_mount" TEST_PROVIDER_MOUNT="$provider_mount" \ + TEST_DEVICE_PATH=/dev/disk/by-id/scsi-0DO_Volume_cc-do-race-data run_prep +[[ "$(sed -n '1p' "$calls")" == "udevadm" ]] || + fail "DigitalOcean mount inspection did not wait for udev completion" +if grep -Eq '^(fsck|mkfs)$' "$calls"; then + fail "a provider mount that completed during udev settle was checked or formatted offline" +fi + +# When udev has generated a unit but not completed its systemd job, bootstrap +# must synchronously finish the validated provider unit before inspecting the +# mount set. +systemd_device=/dev/disk/by-id/scsi-0DO_Volume_cc-do-systemd-data +systemd_mount="$tmp/systemd-provider-mount" +systemd_unit="$tmp/systemd/mnt-$(basename -- "$systemd_mount").mount" +mkdir -p "$systemd_mount" +printf '[Mount]\nWhat=%s\nWhere=%s\n' "$systemd_device" "$systemd_mount" >"$systemd_unit" +FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS=8 \ + FAKE_SYSTEMD_MOUNT_TARGET="$systemd_mount" TEST_PROVIDER_MOUNT="$systemd_mount" \ + TEST_DEVICE_PATH="$systemd_device" run_prep +grep -Fq "systemctl start -- $(basename -- "$systemd_unit")" "$calls" || + fail "DigitalOcean's pending systemd automount was not completed" +if grep -Eq '^(fsck|mkfs)$' "$calls"; then + fail "a provider mount completed by systemd was checked or formatted offline" +fi +rm -f -- "$systemd_unit" + +# A desired mount and a stale provider alias for the same device is corruption- +# prone. Detect the complete mount set and stop without moving, checking, or +# resizing either alias. +if FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 \ + FAKE_DEVICE_MOUNT_TARGETS="$tmp/mount"$'\n'"$provider_mount" \ + TEST_PROVIDER_MOUNT="$provider_mount" \ + TEST_DEVICE_PATH=/dev/disk/by-id/scsi-0DO_Volume_cc-do-dual-data \ + run_prep >/dev/null 2>&1; then + fail "duplicate desired and provider mount aliases were accepted" +fi +if grep -Eq '^(fsck|mkfs|mount|resize)$' "$calls"; then + fail "duplicate mount detection mutated the filesystem" +fi + +# A mount may appear after the first inspection even when it is not managed by +# the DigitalOcean unit. Recheck at the mutation boundary and leave the device +# untouched if any actor mounted it late. +late_mount="$tmp/late-external-mount" +if FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS=0 \ + FAKE_LATE_MOUNT_TARGET="$late_mount" FAKE_LATE_MOUNT_AFTER=1 \ + run_prep >/dev/null 2>&1; then + fail "a mount that appeared immediately before offline fsck was accepted" +fi +if grep -Eq '^(fsck|mkfs|mount|resize)$' "$calls"; then + fail "late mount detection mutated the filesystem" +fi + +unexpected_mount="$tmp/operator-mount" +mkdir -p "$unexpected_mount" +if FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 \ + FAKE_DEVICE_MOUNT_TARGET="$unexpected_mount" TEST_PROVIDER_MOUNT="$provider_mount" \ + TEST_DEVICE_PATH=/dev/disk/by-id/scsi-0DO_Volume_cc-do-isle-data \ + run_prep >/dev/null 2>&1; then + fail "an unexpected mounted-device target was relocated" +fi +if grep -Eq '^(fsck|mkfs|mount|resize)$' "$calls"; then + fail "unexpected mounted-device detection mutated the filesystem" +fi + +mkdir -p "$provider_mount" +if FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 \ + FAKE_DEVICE_MOUNT_TARGET="$provider_mount" TEST_PROVIDER_MOUNT="$provider_mount" \ + TEST_DEVICE_PATH=/dev/disk/by-id/provider-data FAKE_MOUNT_SOURCE=/dev/wrong \ + FAKE_RESOLVED_MOUNT_SOURCE=/dev/wrong run_prep >/dev/null 2>&1; then + fail "a provider mount from the wrong block device was accepted" +fi +if grep -Eq '^(fsck|mkfs|mount|resize)$' "$calls"; then + fail "wrong provider mount source detection mutated the filesystem" +fi + +mkdir -p "$provider_mount" +printf 'operator data\n' >"$tmp/mount/operator-file" +if FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 \ + FAKE_DEVICE_MOUNT_TARGET="$provider_mount" TEST_PROVIDER_MOUNT="$provider_mount" \ + TEST_DEVICE_PATH=/dev/disk/by-id/scsi-0DO_Volume_cc-do-isle-data \ + run_prep >/dev/null 2>&1; then + fail "a provider mount hid a non-empty target directory" +fi +rm -f -- "$tmp/mount/operator-file" +if grep -Eq '^(fsck|mkfs|mount|resize)$' "$calls"; then + fail "non-empty target detection mutated the filesystem" +fi + +mkdir -p "$provider_mount" +if FAKE_BLKID_STATUS=2 FAKE_DEVICE_MOUNT_TARGET="$provider_mount" \ + TEST_PROVIDER_MOUNT="$provider_mount" \ + TEST_DEVICE_PATH=/dev/disk/by-id/scsi-0DO_Volume_cc-do-isle-data \ + run_prep >/dev/null 2>&1; then + fail "a mounted provider device without a filesystem signature was accepted" +fi +if grep -Eq '^(fsck|mkfs)$' "$calls"; then + fail "a mounted unsigned provider device was checked or formatted" +fi + +if FAKE_BLKID_STATUS=0 FAKE_FILESYSTEM_TYPE=ext4 FAKE_FSCK_STATUS=0 \ + FAKE_RESIZE_STATUS=1 run_prep >/dev/null 2>&1; then + fail "a failed ext4 resize was accepted" +fi +grep -Fxq resize "$calls" || fail "resize failure path did not invoke resize2fs" +if grep -Fxq mount "$calls"; then + fail "filesystem was mounted after resize2fs failed" +fi + +if TEST_FAKE_DEVICE='' FILESYSTEM_DEVICE_WAIT_SECONDS=3 run_prep >/dev/null 2>&1; then + fail "missing by-id device was accepted" +fi +[[ "$(grep -c '^sleep$' "$calls")" == 2 ]] || fail "device wait was not bounded to the configured deadline" + +if grep -Eq 'fsck[^\n]*\|\|[^\n]*mkfs|fsck[^\n]*mkfs' "$repo_root/templates/cloud-init.yml"; then + fail "GCP cloud-init still formats after an fsck failure" +fi +grep -Fq 'FILESYSTEM_PREP_SCRIPT_B64' "$repo_root/templates/cloud-init.yml" || \ + fail "GCP cloud-init does not bootstrap the tested filesystem helper" +grep -Fq 'bash "$filesystem_prep" /dev/disk/by-id/google-data /mnt/disks/data' "$repo_root/templates/cloud-init.yml" || \ + fail "GCP cloud-init executes a temporary filesystem helper directly on potentially noexec /run" +grep -Fq 'bash "$filesystem_prep" '\''${DATA_DEVICE}'\'' /mnt/disks/data' "$repo_root/modules/linux-vm-runtime/templates/cloud-init.yml" || \ + fail "Linux VM cloud-init executes a temporary filesystem helper directly on potentially noexec /run" +if grep -Eq '^[[:space:]]*"?\$filesystem_(prep|persist)"?[[:space:]]' \ + "$repo_root/templates/cloud-init.yml" "$repo_root/modules/linux-vm-runtime/templates/cloud-init.yml"; then + fail "cloud-init directly executes a temporary helper from potentially noexec /run" +fi +for cloud_init_template in \ + "$repo_root/templates/cloud-init.yml" \ + "$repo_root/modules/linux-vm-runtime/templates/cloud-init.yml"; do + grep -Fq 'for required_mount in /mnt/disks/data /mnt/disks/volumes /mnt/disks/data/docker/volumes; do' \ + "$cloud_init_template" || fail "cloud-init does not verify every required mount before initialization" + grep -Fq 'Required cloud-compose mount is unavailable:' "$cloud_init_template" || + fail "cloud-init mount gate does not report the unavailable path" + grep -Fq ' bash /home/cloud-compose/run.sh > /home/cloud-compose/run.log 2>&1' \ + "$cloud_init_template" || fail "cloud-init application startup is outside the fail-closed mount block" + marker_reset_line="$(grep -nF ' rm -f /home/cloud-compose/.cloud-compose-bootstrap-complete' \ + "$cloud_init_template" | cut -d: -f1)" + run_line="$(grep -nF ' bash /home/cloud-compose/run.sh > /home/cloud-compose/run.log 2>&1' \ + "$cloud_init_template" | cut -d: -f1)" + [[ -n "$marker_reset_line" && -n "$run_line" && "$marker_reset_line" -lt "$run_line" ]] || + fail "cloud-init does not clear stale bootstrap readiness before application startup" +done +grep -Fq 'install -m 0600 /dev/null /run/cloud-compose-filesystems-ready' \ + "$repo_root/templates/cloud-init.yml" || fail "GCP bootcmd does not publish filesystem readiness" +grep -Fq 'test -f /run/cloud-compose-filesystems-ready || {' \ + "$repo_root/templates/cloud-init.yml" || fail "GCP runcmd does not require filesystem readiness" +grep -Fq 'test -f /run/cloud-compose-filesystems-ready || {' \ + "$repo_root/modules/gcp/main.tf" || fail "GCP archive installation is not gated by filesystem readiness" + +persist_tmp="$tmp/persist" +mkdir -p "$persist_tmp/bin" +cat >"$persist_tmp/bin/systemctl" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"${PERSIST_SYSTEMCTL_LOG:?}" +EOF +chmod +x "$persist_tmp/bin/systemctl" +fstab="$persist_tmp/fstab" +systemctl_log="$persist_tmp/systemctl.log" +printf '# existing filesystem table\n' >"$fstab" +: >"$systemctl_log" + +run_persist() { + PATH="$persist_tmp/bin:/usr/bin:/bin" \ + CLOUD_COMPOSE_FSTAB_PATH="$fstab" \ + CLOUD_COMPOSE_FSTAB_LOCK_PATH="$persist_tmp/fstab.lock" \ + PERSIST_SYSTEMCTL_LOG="$systemctl_log" \ + bash "$persist_script" \ + /dev/disk/by-id/provider-data \ + /dev/disk/by-id/provider-volumes +} + +run_persist +grep -Fq $'/dev/disk/by-id/provider-data\t/mnt/disks/data\text4\tdefaults,nofail,x-systemd.device-timeout=120s\t0\t2' "$fstab" || \ + fail "persistent data mount was not recorded" +grep -Fq $'/dev/disk/by-id/provider-volumes\t/mnt/disks/volumes\text4\tdefaults,nofail,x-systemd.device-timeout=120s\t0\t2' "$fstab" || \ + fail "persistent Docker-volume mount was not recorded" +grep -Fq $'/mnt/disks/volumes\t/mnt/disks/data/docker/volumes\tnone\tbind,nofail,x-systemd.requires=/mnt/disks/volumes\t0\t0' "$fstab" || \ + fail "persistent Docker bind mount was not recorded" +[[ "$(grep -Fc '# BEGIN cloud-compose persistent mounts' "$fstab")" == "1" ]] || \ + fail "filesystem persistence did not write one managed block" +run_persist +[[ "$(grep -Fc '# BEGIN cloud-compose persistent mounts' "$fstab")" == "1" ]] || \ + fail "filesystem persistence is not idempotent" +grep -Fxq 'daemon-reload' "$systemctl_log" || fail "filesystem persistence did not reload systemd" + +printf '# existing filesystem table\n' >"$fstab" +PATH="$persist_tmp/bin:/usr/bin:/bin" \ + CLOUD_COMPOSE_FSTAB_PATH="$fstab" \ + CLOUD_COMPOSE_FSTAB_LOCK_PATH="$persist_tmp/fstab.lock" \ + PERSIST_SYSTEMCTL_LOG="$systemctl_log" \ + bash "$persist_script" \ + /dev/disk/by-id/provider-data \ + /dev/disk/by-id/provider-volumes \ + /dev/disk/by-id/provider-prod +grep -Fq $'/dev/disk/by-id/provider-prod\t/mnt/disks/prod-readonly\text4\tro,nofail,x-systemd.device-timeout=120s\t0\t2' "$fstab" || \ + fail "persistent read-only overlay source was not recorded" + +do_data_device=/dev/disk/by-id/scsi-0DO_Volume_cc-do-data +do_volumes_device=/dev/disk/by-id/scsi-0DO_Volume_cc-do-docker-volumes +do_data_mount=/mnt/cc_do_data +do_volumes_mount=/mnt/cc_do_docker_volumes +do_systemd_dir="$persist_tmp/systemd" +mkdir -p "$do_systemd_dir/multi-user.target.wants" +printf '%s\n' \ + "$do_data_device $do_data_mount ext4 defaults,nofail,discard,noatime 0 2" \ + "$do_volumes_device $do_volumes_mount ext4 defaults,nofail,discard,noatime 0 2" \ + >"$fstab" +printf '[Mount]\nWhat=%s\nWhere=%s\n' "$do_data_device" "$do_data_mount" \ + >"$do_systemd_dir/mnt-cc_do_data.mount" +printf '[Mount]\nWhat=%s\nWhere=%s\n' "$do_volumes_device" "$do_volumes_mount" \ + >"$do_systemd_dir/mnt-cc_do_docker_volumes.mount" +ln -s ../mnt-cc_do_data.mount \ + "$do_systemd_dir/multi-user.target.wants/mnt-cc_do_data.mount" +ln -s ../mnt-cc_do_docker_volumes.mount \ + "$do_systemd_dir/multi-user.target.wants/mnt-cc_do_docker_volumes.mount" +PATH="$persist_tmp/bin:/usr/bin:/bin" \ + CLOUD_COMPOSE_FSTAB_PATH="$fstab" \ + CLOUD_COMPOSE_FSTAB_LOCK_PATH="$persist_tmp/fstab.lock" \ + CLOUD_COMPOSE_SYSTEMD_DIR="$do_systemd_dir" \ + PERSIST_SYSTEMCTL_LOG="$systemctl_log" \ + bash "$persist_script" "$do_data_device" "$do_volumes_device" +if grep -Eq "^[^#]+[[:space:]]+($do_data_mount|$do_volumes_mount)[[:space:]]" "$fstab"; then + fail "superseded DigitalOcean automatic fstab entries were retained" +fi +[[ ! -e "$do_systemd_dir/mnt-cc_do_data.mount" ]] || + fail "the provider-owned DigitalOcean data mount unit was retained" +[[ ! -e "$do_systemd_dir/mnt-cc_do_docker_volumes.mount" ]] || + fail "the provider-owned DigitalOcean Docker-volume mount unit was retained" +[[ ! -L "$do_systemd_dir/multi-user.target.wants/mnt-cc_do_data.mount" ]] || + fail "the provider-owned DigitalOcean data mount wants link was retained" + +printf '/dev/operator %s ext4 defaults 0 2\n' "$do_data_mount" >"$fstab" +if PATH="$persist_tmp/bin:/usr/bin:/bin" \ + CLOUD_COMPOSE_FSTAB_PATH="$fstab" \ + CLOUD_COMPOSE_FSTAB_LOCK_PATH="$persist_tmp/fstab.lock" \ + CLOUD_COMPOSE_SYSTEMD_DIR="$do_systemd_dir" \ + PERSIST_SYSTEMCTL_LOG="$systemctl_log" \ + bash "$persist_script" "$do_data_device" "$do_volumes_device" >/dev/null 2>&1; then + fail "an unexpected source at a DigitalOcean automatic mount target was replaced" +fi +grep -Fq "/dev/operator $do_data_mount" "$fstab" || + fail "a conflicting DigitalOcean automatic mount entry was mutated" + +printf '# existing filesystem table\n' >"$fstab" +printf '[Mount]\nWhat=/dev/operator\nWhere=%s\n' "$do_data_mount" \ + >"$do_systemd_dir/mnt-cc_do_data.mount" +if PATH="$persist_tmp/bin:/usr/bin:/bin" \ + CLOUD_COMPOSE_FSTAB_PATH="$fstab" \ + CLOUD_COMPOSE_FSTAB_LOCK_PATH="$persist_tmp/fstab.lock" \ + CLOUD_COMPOSE_SYSTEMD_DIR="$do_systemd_dir" \ + PERSIST_SYSTEMCTL_LOG="$systemctl_log" \ + bash "$persist_script" "$do_data_device" "$do_volumes_device" >/dev/null 2>&1; then + fail "an unexpected DigitalOcean mount unit was removed" +fi +[[ -f "$do_systemd_dir/mnt-cc_do_data.mount" ]] || + fail "an unexpected DigitalOcean mount unit was mutated" + +printf '/dev/operator /mnt/disks/data ext4 defaults 0 2\n' >"$fstab" +if run_persist >/dev/null 2>&1; then + fail "filesystem persistence replaced an unmanaged mount target" +fi +grep -Fq '/dev/operator /mnt/disks/data' "$fstab" || \ + fail "filesystem persistence mutated a conflicting operator entry" +if CLOUD_COMPOSE_FSTAB_PATH="$fstab" \ + CLOUD_COMPOSE_FSTAB_LOCK_PATH="$persist_tmp/fstab.lock" \ + bash "$persist_script" '/dev/disk/by-id/bad device' /dev/disk/by-id/provider-volumes >/dev/null 2>&1; then + fail "filesystem persistence accepted an unsafe device path" +fi + +grep -Fq 'FILESYSTEM_PERSIST_SCRIPT_B64' "$repo_root/templates/cloud-init.yml" || \ + fail "GCP cloud-init does not bootstrap persistent mount configuration" +grep -Fq 'FILESYSTEM_PERSIST_SCRIPT_B64' "$repo_root/modules/linux-vm-runtime/templates/cloud-init.yml" || \ + fail "Linux VM cloud-init does not bootstrap persistent mount configuration" +grep -Fq '/var/lib/cloud-compose/mounted-rootfs/mnt/disks' "$repo_root/modules/linux-vm-runtime/templates/cloud-init.yml" || \ + fail "Linux VM cloud-init does not copy mounted-root files after mounting durable storage" + +do_module="$repo_root/modules/digitalocean/main.tf" +if grep -Eq 'initial_filesystem_type[[:space:]]*=' "$do_module"; then + fail "new DigitalOcean volumes still request provider-side formatting and automount" +fi +[[ "$(grep -Fc 'ignore_changes = [initial_filesystem_type]' "$do_module")" -eq 2 ]] || + fail "DigitalOcean volume upgrades do not preserve existing volume identity across the formatting transition" + +echo "Filesystem preparation contract passed" diff --git a/ci/gcp-upgrade-smoke-contract.sh b/ci/gcp-upgrade-smoke-contract.sh new file mode 100755 index 0000000..55b62c7 --- /dev/null +++ b/ci/gcp-upgrade-smoke-contract.sh @@ -0,0 +1,540 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +tmp="$(mktemp -d "${TMPDIR:-/tmp}/gcp-upgrade-contract.XXXXXX")" +trap 'rm -rf "$tmp"' EXIT + +fail() { + echo "GCP upgrade smoke contract: $*" >&2 + exit 1 +} + +[[ "$(grep -Fc 'replace_triggered_by = [google_compute_instance.cloud-compose]' "$repo_root/modules/gcp/main.tf")" -eq 2 ]] || + fail "instance-scoped power IAM is not recreated when the same-named VM is replaced" +ppb_module="$(sed -n '/^module "ppb" {/,/^}/p' "$repo_root/modules/gcp/main.tf")" +grep -Fq 'depends_on = [google_compute_firewall.allow-cloud-run-ingress]' <<<"$ppb_module" || + fail "the power-button module must wait for its ingress firewall" +if grep -Fq 'google_compute_instance_iam_member.gce-start' <<<"$ppb_module"; then + fail "the power-button module-wide dependency recreates the hosted VM replacement cycle" +fi + +script="$repo_root/ci/gcp-upgrade-smoke.sh" +fixture="$repo_root/tests/smoke/gcp-upgrade/main.tf" +variables="$repo_root/tests/smoke/gcp-upgrade/variables.tf" +workflow="$repo_root/.github/workflows/cloud-smoke.yml" +docs="$repo_root/docs/runtime-contracts.md" + +for required in "$script" "$fixture" "$variables" "$workflow" "$docs"; do + [[ -f "$required" ]] || fail "required file is missing: $required" +done + +grep -Fq 'f33117cdbbf4a9c7d59006a4db986baef118e6bb' "$script" || + fail "upgrade baseline is not pinned to the exact 0.10.2 commit" +[[ "$(grep -Fc 'worktree add --detach' "$script")" -eq 2 ]] || + fail "upgrade runner does not create detached baseline and current worktrees" +grep -Fq "[[ \"\$work_root\" == /* ]]" "$script" || + fail "upgrade runner does not require an absolute work directory" +grep -Fq "backend-config=\"path=\${state_path}\"" "$script" || + fail "upgrade runner does not use one explicit local-backend state path" +grep -Fq 'trap cleanup EXIT' "$script" || + fail "upgrade runner does not install an EXIT cleanup trap" +grep -Fq 'run_upgrade() (' "$script" || + fail "upgrade runner cleanup can outlive its function-local lifecycle context" +grep -Fq "provider_tag_cleanup gcp-wp \"\$run_id\"" "$script" || + fail "upgrade runner does not finish cleanup with the verified provider sweep" +grep -Fq 'target_env gcp-wp' "$script" || + fail "upgrade cleanup does not load the concrete GCP WordPress target environment" +grep -Fq 'CLOUD_COMPOSE_SMOKE_RUN_ID must match GITHUB_RUN_ID in GitHub Actions' "$script" || + fail "hosted cleanup ownership is not bound to the actual GitHub run id" +grep -Fq 'CLOUD_COMPOSE_SMOKE_RUN_ID must be set explicitly outside GitHub Actions' "$script" || + fail "manual upgrade runs can accidentally reuse an implicit cleanup scope" +grep -Fq '/mnt/disks/data/.cloud-compose-upgrade-sentinel' "$script" || + fail "upgrade runner omits the persistent data-disk sentinel" +grep -Fq '/mnt/disks/volumes/.cloud-compose-upgrade-sentinel' "$script" || + fail "upgrade runner omits the Docker-volume disk sentinel" +grep -Fq 'capture_state_resource_attribute "$state_json" "$boot_disk_address" disk_id' "$script" || + fail "upgrade runner does not compare the replaced boot disk's immutable numeric identity" +grep -Fq 'capture_state_resource_attribute "$state_json" "$vm_address" instance_id' "$script" || + fail "upgrade runner does not compare the replaced VM's immutable numeric identity" +[[ "$(grep -Fc 'sudo tee /mnt/disks/data/.cloud-compose-upgrade-sentinel' "$script")" -eq 1 ]] || + fail "upgrade runner must write the data-disk sentinel exactly once" +[[ "$(grep -Fc 'sudo tee /mnt/disks/volumes/.cloud-compose-upgrade-sentinel' "$script")" -eq 1 ]] || + fail "upgrade runner must write the Docker-volume sentinel exactly once" + +grep -Fq 'backend "local" {}' "$fixture" || + fail "upgrade fixture does not declare the shared local backend" +grep -Fq 'create = false' "$fixture" || + fail "upgrade fixture still owns an ephemeral network" +grep -Fq 'project_id = var.gcp_network_project_id' "$fixture" || + fail "upgrade fixture does not pin the persistent network project" +grep -Fq 'trimspace(var.gcp_network_project_id) == local.project_id' "$fixture" || + fail "direct fixture use does not reject Shared VPC unsupported by the baseline" +grep -Fq 'name = var.gcp_network_name' "$fixture" || + fail "upgrade fixture does not use the persistent CI network" +grep -Fq 'subnetwork = var.gcp_subnetwork_name' "$fixture" || + fail "upgrade fixture does not use the persistent Direct VPC subnet" +grep -Eq 'ssh_ipv4[[:space:]]*=[[:space:]]*\[var\.runner_ipv4_cidr\]' "$fixture" || + fail "upgrade fixture does not constrain SSH to the runner CIDR" +grep -Fq 'power_button_allowed_ips = [var.runner_ipv4_cidr]' "$fixture" || + fail "upgrade fixture does not constrain PPB wake-up to the runner CIDR" +grep -Fq 'power_button_ip_depth = 0' "$fixture" || + fail "upgrade fixture does not declare the direct Cloud Run proxy depth" +grep -Fq 'ingress_url = try(module.app.urls[var.gcp_region], "")' "$fixture" || + fail "upgrade fixture does not expose the public Cloud Run URL" +grep -Eq 'enabled[[:space:]]*=[[:space:]]*true' "$fixture" || + fail "upgrade fixture does not explicitly enable power management" +grep -Fq 'start_role = var.gcp_power_start_role' "$fixture" || + fail "upgrade fixture does not use the foundation start role" +grep -Fq 'suspend_role = var.gcp_power_suspend_role' "$fixture" || + fail "upgrade fixture does not use the foundation suspend role" +grep -Fq 'internal_services_enabled = true' "$fixture" || + fail "upgrade fixture does not retain the pre-1.0 internal-service topology" +grep -Fq 'internal_services_auto_update = true' "$fixture" || + fail "upgrade fixture does not retain the pre-1.0 automatic-update behavior" +grep -Fq 'primary = var.legacy_baseline ? "" : "wordpress"' "$fixture" || + fail "upgrade fixture does not select the legacy single-project fallback only for the baseline" +grep -Fq 'repo = "https://github.com/libops/wp.git"' "$fixture" || + fail "upgrade fixture omits the baseline-compatible Compose repository input" +grep -Fq 'branch = var.wordpress_compose_ref' "$fixture" || + fail "upgrade fixture does not pin the baseline-compatible Compose branch input" +grep -Fq 'projects = var.legacy_baseline ? {} : {' "$fixture" || + fail "upgrade fixture does not transition from the legacy inputs to the current project map" +for unit in internal-services.timer cloud-compose-internal-services.timer; do + grep -Fq "$unit" "$fixture" || fail "upgrade fixture does not disable ${unit}" +done +grep -Fq 'initcmd = [' "$fixture" || + fail "upgrade fixture does not disable internal-service timers before bootstrap" +grep -Fq 'git -c safe.directory=\"$project\" -C \"$project\"' "$fixture" || + fail "upgrade fixture does not explicitly trust its preserved pinned repository" +if grep -Fq 'runcmd = [' "$fixture"; then + fail "upgrade fixture defers its timer shutdown until after bootstrap" +fi +git -C "$repo_root" show f33117cdbbf4a9c7d59006a4db986baef118e6bb:templates/cloud-init.yml \ + >"$tmp/baseline-cloud-init.yml" || fail "could not inspect the pinned baseline cloud-init" +for cloud_init_template in "$repo_root/templates/cloud-init.yml" "$tmp/baseline-cloud-init.yml"; do + run_script_line="$(grep -nF 'bash /home/cloud-compose/run.sh' "$cloud_init_template" | cut -d: -f1)" + template_initcmd_line="$(grep -nF 'for CMD in ADDITIONAL_INITCMD' "$cloud_init_template" | cut -d: -f1)" + [[ -n "$template_initcmd_line" && -n "$run_script_line" && "$template_initcmd_line" -lt "$run_script_line" ]] || + fail "baseline or current cloud-init does not execute fixture initcmd before run.sh" +done +grep -Fq 'for unit in internal-services.timer internal-services.service cloud-compose-internal-services.timer cloud-compose-internal-services.service' "$script" || + fail "upgrade runner does not assert that both timer generations remain inactive after boot" +grep -Fq 'run_direct_vpc_cold_start "$new_home" "$key_path" "$new_output" "$name" "$zone"' "$script" || + fail "upgrade runner does not exercise the upgraded Direct VPC cold-start path" +grep -Fq 'verify_metadata_isolation "$new_home" "$key_path" "$new_output"' "$script" || + fail "upgrade runner does not verify metadata isolation after the replacement boot" +grep -Fq 'nslookup metadata.google.internal' "$script" || + fail "upgrade runner does not prove that Compute internal DNS survives metadata isolation" +grep -Fq 'sudo -u cloud-compose curl' "$script" || + fail "upgrade runner does not prove that unprivileged host metadata access is denied" +grep -Fq -- '--header '\''X-Forwarded-For: 10.0.0.8'\''' "$script" || + fail "Direct VPC smoke does not test an attacker-controlled forwarded prefix" +grep -Fq -- '--max-time 600' "$script" || + fail "Direct VPC smoke does not cover the bounded Cloud Run request window" +if sed -n '/run_direct_vpc_cold_start()/,/^}/p' "$script" | grep -Eq -- '--retry|while .*curl'; then + fail "Direct VPC smoke hides first-request failure behind an HTTP retry" +fi +if grep -Eiq 'operator_ssh|jcorall|MacBook|AAAAC3NzaC1lZDI1NTE5AAAAIOuUg' "$fixture" "$variables"; then + fail "upgrade fixture contains a non-ephemeral operator SSH key" +fi +grep -Fq 'endswith(var.runner_ipv4_cidr, "/32")' "$variables" || + fail "upgrade fixture does not require a runner /32" +for variable in gcp_network_project_id gcp_network_name gcp_subnetwork_name gcp_power_start_role gcp_power_suspend_role; do + grep -Fq "variable \"${variable}\"" "$variables" || + fail "upgrade fixture omits required variable ${variable}" +done +grep -Fq 'variable "legacy_baseline"' "$variables" || + fail "upgrade fixture cannot distinguish its legacy and current configuration shapes" +grep -Fq 'GCLOUD_NETWORK_PROJECT_ID must equal GCLOUD_PROJECT for the 0.10.2 baseline' "$script" || + fail "upgrade runner does not reject a Shared VPC unsupported by the baseline" +grep -Fq '(.mtu | tonumber) == 1460' "$script" || + fail "upgrade runner does not attest the Cloud Run Direct VPC network MTU" +grep -Fq 'valid_direct_vpc_cidr "$subnet_cidr"' "$script" || + fail "upgrade runner does not enforce Cloud Run Direct VPC supported IPv4 ranges" +grep -Fq '((10#$subnet_prefix <= 26))' "$script" || + fail "upgrade runner does not require a /26-or-larger persistent Direct VPC subnet" +bash -c ' + source "$1" + valid_direct_vpc_cidr "10.60.0.0/26" + valid_direct_vpc_cidr "172.20.0.0/24" + valid_direct_vpc_cidr "100.64.0.0/26" + valid_direct_vpc_cidr "240.0.0.0/26" +' _ "$script" || fail "upgrade runner rejected a supported Direct VPC IPv4 range" +if bash -c 'source "$1"; valid_direct_vpc_cidr "203.0.113.0/24"' _ "$script"; then + fail "upgrade runner accepted an unsupported Direct VPC IPv4 range" +fi +bash -c 'source "$1"; validate_upgrade_network_ownership "service-project" "service-project" "ci-network" "ci-subnet"' \ + _ "$script" || fail "upgrade runner rejected safe persistent-network ownership" +if bash -c 'source "$1"; validate_upgrade_network_ownership "service-project" "host-project" "ci-network" "ci-subnet"' \ + _ "$script" >/dev/null 2>&1; then + fail "upgrade runner accepted Shared VPC input unsupported by the baseline" +fi +if bash -c 'source "$1"; validate_upgrade_network_ownership "service-project" "service-project" "cc-g-wp-owned" "ci-subnet"' \ + _ "$script" >/dev/null 2>&1; then + fail "upgrade runner placed its persistent network inside the disposable sweep boundary" +fi +network_validation_line="$(grep -nF ' validate_upgrade_network \' "$script" | cut -d: -f1)" +initial_sweep_line="$(grep -nF ' cleanup_resources "$work_root"' "$script" | head -n 1 | cut -d: -f1)" +[[ -n "$network_validation_line" && -n "$initial_sweep_line" && "$network_validation_line" -lt "$initial_sweep_line" ]] || + fail "upgrade runner does not validate persistent network ownership before its initial orphan sweep" +grep -Fq 'validate_upgrade_network_ownership \' "$script" || + fail "upgrade cleanup does not preserve the external network ownership boundary" +wordpress_ref='5058610fddc7267ace92d65a5c49713dce570ac3' +[[ "$(grep -Fc "$wordpress_ref" "$variables")" -eq 2 ]] || + fail "upgrade fixture variable does not default to and enforce the pinned WordPress commit" +grep -Fq 'docker_compose_branch = var.wordpress_compose_ref' "$fixture" || + fail "upgrade fixture does not use its pinned WordPress commit" +grep -Fq "git_project checkout --detach \${var.wordpress_compose_ref}" "$fixture" || + fail "legacy bootstrap does not pre-check out the exact WordPress commit" +grep -Fq 'wordpress_project_dir = "/mnt/disks/data/libops/wp.git/${var.wordpress_compose_ref}"' "$fixture" || + fail "upgrade fixture does not derive the exact legacy single-project checkout path" +grep -Fq 'project=${local.wordpress_project_dir};' "$fixture" || + fail "upgrade fixture does not pre-check out the shared baseline/current Compose path" +grep -Fq 'project_dir = local.wordpress_project_dir' "$fixture" || + fail "baseline and current phases do not share the pinned Compose checkout path" + +tfvars_dir="$tmp/tfvars" +mkdir -p "$tfvars_dir" +bash -c ' + set -euo pipefail + source "$1" + write_tfvars "$2" name project us-east5 us-east5-b key 192.0.2.1/32 \ + project network subnet projects/project/roles/startVM projects/project/roles/suspendVM true +' _ "$script" "$tfvars_dir" +jq -e '.legacy_baseline == true' "$tfvars_dir/upgrade.auto.tfvars.json" >/dev/null || + fail "upgrade runner does not mark the old phase as a legacy baseline" + +cleanup_log="$tmp/cleanup.log" +set +e +GITHUB_ACTIONS='' \ +GITHUB_RUN_ID='' \ +CLOUD_COMPOSE_SMOKE_RUN_ID=contract-run \ +CLOUD_COMPOSE_UPGRADE_CURRENT_REF=HEAD \ +GCLOUD_PROJECT=contract-project \ +GCLOUD_NETWORK_PROJECT_ID=contract-project \ +GCLOUD_NETWORK_NAME=contract-network \ +GCLOUD_SUBNETWORK_NAME=contract-subnet \ +GCLOUD_POWER_START_ROLE=projects/contract-project/roles/startVM \ +GCLOUD_POWER_SUSPEND_ROLE=projects/contract-project/roles/suspendVM \ +CLOUD_COMPOSE_GCP_UPGRADE_WORKDIR="$tmp/cleanup-work" \ +CLEANUP_LOG="$cleanup_log" \ + bash -c ' + set -euo pipefail + source "$1" + repo_root="$2" + require_cmd() { :; } + require_env() { :; } + validate_upgrade_network() { :; } + cleanup_calls=0 + cleanup_resources() { + cleanup_calls=$((cleanup_calls + 1)) + printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n" "$@" >>"$CLEANUP_LOG" + [[ "$cleanup_calls" -gt 1 ]] + } + run_upgrade + ' _ "$script" "$repo_root" +cleanup_status=$? +set -e +[[ "$cleanup_status" -eq 1 ]] || + fail "upgrade runner did not preserve the original preflight cleanup failure" +[[ "$(wc -l <"$cleanup_log")" -eq 2 ]] || + fail "upgrade EXIT trap did not retry cleanup with retained lifecycle context" +awk -F '\t' 'NF != 7 { exit 1 } { for (i = 1; i <= NF; i++) if ($i == "") exit 1 }' "$cleanup_log" || + fail "upgrade EXIT cleanup lost one or more lifecycle arguments" + +grep -Fq 'name: GCP WordPress' "$workflow" || + fail "the stable required GCP check name changed" +grep -Fq 'timeout-minutes: 210' "$workflow" || + fail "the GCP job does not allow enough time for both upgrade boots" +grep -Fq 'group: cloud-compose-smoke-gcp-wp' "$workflow" || + fail "the GCP upgrade does not share the existing concurrency boundary" +grep -Fq "startsWith(github.event.pull_request.title, '[major]')" "$workflow" || + fail "the hosted upgrade is not selected by the major-release marker" +grep -Fq "!startsWith(github.event.pull_request.title, '[major]')" "$workflow" || + fail "the fresh and upgrade GCP variants are not mutually exclusive" +grep -Fq "CLOUD_COMPOSE_UPGRADE_CURRENT_REF: \${{ github.sha }}" "$workflow" || + fail "the upgrade does not target the exact tested merge commit" +grep -Fq 'ci/gcp-upgrade-smoke.sh run' "$workflow" || + fail "the GCP job does not invoke the upgrade runner" +grep -Fq 'ci/gcp-upgrade-smoke.sh destroy' "$workflow" || + fail "the GCP job lacks same-job upgrade cleanup" +grep -Fq "env.GCLOUD_NETWORK_PROJECT_ID != '' && env.GCLOUD_NETWORK_NAME != '' && env.GCLOUD_SUBNETWORK_NAME != ''" "$workflow" || + fail "the hosted upgrade cleanup can run without its persistent-network ownership boundary" +grep -Fq 'make smoke-test PROVIDER=gcp TEMPLATE=wp' "$workflow" || + fail "the non-major fresh GCP smoke path was removed" +for variable in GCLOUD_NETWORK_PROJECT_ID GCLOUD_NETWORK_NAME GCLOUD_SUBNETWORK_NAME GCLOUD_POWER_START_ROLE GCLOUD_POWER_SUSPEND_ROLE; do + grep -Fq "${variable}: \${{ vars.${variable} || secrets.${variable} }}" "$workflow" || + fail "the GCP job does not expose required upgrade setting ${variable}" + grep -Fq "test -n \"\$${variable}\"" "$workflow" || + fail "the GCP job does not fail closed when upgrade setting ${variable} is absent" +done +grep -Fq 'test "$GCLOUD_NETWORK_PROJECT_ID" = "$GCLOUD_PROJECT"' "$workflow" || + fail "the GCP job does not reject Shared VPC input unsupported by the baseline before authentication" + +gcp_job="$(sed -n '/^ gcp-smoke:/,$p' "$workflow")" +grep -Fq 'fetch-depth: 0' <<<"$gcp_job" || + fail "the GCP checkout does not fetch the pinned baseline history" +grep -Fq 'persist-credentials: false' <<<"$gcp_job" || + fail "the GCP checkout persists credentials into detached worktrees" +grep -Fq 'id-token: write' <<<"$gcp_job" || + fail "the GCP job lost its scoped OIDC permission" + +grep -Fq 'f33117cdbbf4a9c7d59006a4db986baef118e6bb' "$docs" || + fail "upgrade documentation omits the exact hosted baseline" +grep -Fq 'pull-request titles beginning with `[major]`' "$docs" || + fail "upgrade documentation does not explain when the expensive gate runs" +grep -Fq "$wordpress_ref" "$docs" || + fail "upgrade documentation omits the pinned WordPress Compose revision" + +cat >"$tmp/good-plan.json" <<'EOF' +{ + "format_version": "1.2", + "resource_changes": [ + { + "address": "module.app.module.gcp.google_service_account.internal-services[0]", + "previous_address": "module.app.module.gcp.google_service_account.internal-services", + "change": {"actions": ["no-op"]} + }, + { + "address": "module.app.module.gcp.google_service_account_iam_member.internal-services-keys[0]", + "previous_address": "module.app.module.gcp.google_service_account_iam_member.internal-services-keys", + "change": {"actions": ["no-op"]} + }, + { + "address": "module.app.module.gcp.google_project_iam_member.stackdriver[0]", + "previous_address": "module.app.module.gcp.google_project_iam_member.stackdriver", + "change": {"actions": ["no-op"]} + }, + { + "address": "module.app.module.gcp.google_project_iam_member.gce-start[0]", + "change": {"actions": ["delete"]} + }, + { + "address": "module.app.module.gcp.google_project_iam_member.gce-suspend", + "change": {"actions": ["delete"]} + }, + { + "address": "module.app.module.gcp.google_service_account_iam_member.gsa-user", + "change": {"actions": ["delete"]} + }, + { + "address": "module.app.module.gcp.google_service_account_iam_member.token-creator", + "change": {"actions": ["delete"]} + }, + { + "address": "module.app.module.gcp.google_service_account_iam_member.vault_agent_jwt_signer_policy[0]", + "previous_address": "module.app.module.gcp.google_service_account_iam_member.self_jwt_signer_policy", + "change": {"actions": ["delete"]} + }, + { + "address": "module.app.module.gcp.google_service_account_iam_member.app-keys[0]", + "previous_address": "module.app.module.gcp.google_service_account_iam_member.app-keys", + "change": {"actions": ["no-op"]} + }, + { + "address": "module.app.module.gcp.google_compute_instance_iam_member.gce-start[0]", + "change": {"actions": ["create"]} + }, + { + "address": "module.app.module.gcp.google_compute_instance_iam_member.gce-suspend[0]", + "change": {"actions": ["create"]} + }, + { + "address": "module.app.module.gcp.google_compute_disk.data", + "change": {"actions": ["no-op"]} + }, + { + "address": "module.app.module.gcp.google_compute_disk.docker-volumes", + "change": {"actions": ["no-op"]} + }, + { + "address": "module.app.module.gcp.google_compute_disk.boot", + "change": {"actions": ["delete", "create"]} + }, + { + "address": "module.app.module.gcp.google_compute_instance.cloud-compose", + "change": {"actions": ["delete", "create"]} + }, + { + "address": "module.app.module.gcp.data.google_project_iam_custom_role.gce-suspend", + "mode": "data", + "change": {"actions": ["delete"]} + } + ] +} +EOF + +bash "$script" check-plan "$tmp/good-plan.json" + +cat >"$tmp/state-identities.json" <<'EOF' +{ + "values": { + "root_module": { + "resources": [ + { + "address": "module.contract.google_compute_disk.boot", + "values": {"id": "projects/p/zones/z/disks/same-name", "disk_id": "111"} + }, + { + "address": "module.contract.google_compute_instance.vm", + "values": {"id": "projects/p/zones/z/instances/same-name", "instance_id": "222"} + } + ] + } + } +} +EOF +[[ "$(bash -c 'source "$1"; state_resource_attribute "$2" "$3" disk_id' _ \ + "$script" "$tmp/state-identities.json" 'module.contract.google_compute_disk.boot')" == "111" ]] || + fail "state identity reader did not select google_compute_disk.disk_id" +[[ "$(bash -c 'source "$1"; state_resource_attribute "$2" "$3" instance_id' _ \ + "$script" "$tmp/state-identities.json" 'module.contract.google_compute_instance.vm')" == "222" ]] || + fail "state identity reader did not select google_compute_instance.instance_id" +if bash -c 'source "$1"; capture_state_resource_attribute "$2" "$3" disk_id' _ \ + "$script" "$tmp/state-identities.json" 'module.contract.google_compute_instance.vm' >/dev/null 2>&1; then + fail "state identity capture accepted a missing immutable provider attribute" +fi + +jq '(.resource_changes[] | select(.address | endswith("stackdriver[0]")) | .previous_address) = "wrong"' \ + "$tmp/good-plan.json" >"$tmp/bad-move.json" +if bash "$script" check-plan "$tmp/bad-move.json" >/dev/null 2>&1; then + fail "plan checker accepted a missing moved-resource provenance" +fi + +jq '(.resource_changes[] | select(.address | endswith("docker-volumes")) | .change.actions) = ["delete", "create"]' \ + "$tmp/good-plan.json" >"$tmp/bad-disk.json" +if bash "$script" check-plan "$tmp/bad-disk.json" >/dev/null 2>&1; then + fail "plan checker accepted persistent-disk replacement" +fi + +jq '.resource_changes += [{"address":"module.app.google_compute_disk.unexpected","change":{"actions":["delete"]}}]' \ + "$tmp/good-plan.json" >"$tmp/bad-delete.json" +if bash "$script" check-plan "$tmp/bad-delete.json" >/dev/null 2>&1; then + fail "plan checker accepted an unexpected destructive action" +fi + +jq '(.resource_changes[] | select(.address | endswith("google_project_iam_member.gce-start[0]")) | .change.actions) = ["no-op"]' \ + "$tmp/good-plan.json" >"$tmp/bad-legacy-iam.json" +if bash "$script" check-plan "$tmp/bad-legacy-iam.json" >/dev/null 2>&1; then + fail "plan checker accepted a retained legacy project-wide power binding" +fi + +jq '(.resource_changes[] | select(.address | endswith("google_service_account_iam_member.gsa-user")) | .change.actions) = ["no-op"]' \ + "$tmp/good-plan.json" >"$tmp/bad-legacy-gsa-user.json" +if bash "$script" check-plan "$tmp/bad-legacy-gsa-user.json" >/dev/null 2>&1; then + fail "plan checker accepted the legacy default Compute service-account impersonation grant" +fi + +jq '(.resource_changes[] | select(.address | endswith("google_service_account_iam_member.token-creator")) | .change.actions) = ["no-op"]' \ + "$tmp/good-plan.json" >"$tmp/bad-legacy-vm-token-creator.json" +if bash "$script" check-plan "$tmp/bad-legacy-vm-token-creator.json" >/dev/null 2>&1; then + fail "plan checker accepted the unused VM self token-creator grant" +fi + +jq 'del(.resource_changes[] | select(.address | endswith("google_service_account_iam_member.vault_agent_jwt_signer_policy[0]")) | .previous_address)' \ + "$tmp/good-plan.json" >"$tmp/bad-app-token-move.json" +if bash "$script" check-plan "$tmp/bad-app-token-move.json" >/dev/null 2>&1; then + fail "plan checker accepted a missing app token-creator moved-resource provenance" +fi + +jq 'del(.resource_changes[] | select(.address | endswith("google_compute_instance_iam_member.gce-suspend[0]")))' \ + "$tmp/good-plan.json" >"$tmp/bad-scoped-iam.json" +if bash "$script" check-plan "$tmp/bad-scoped-iam.json" >/dev/null 2>&1; then + fail "plan checker accepted a missing instance-scoped power binding" +fi + +cat >"$tmp/old-ids.json" <<'EOF' +{ + "data_disk": "data-1", + "docker_disk": "docker-1", + "boot_disk": "boot-old", + "vm": "vm-old", + "internal_service_account": "sa-1", + "internal_service_keys": "keys-1", + "stackdriver": "metrics-1", + "app_key_admin": "app-keys-1", + "legacy_start": "legacy-start-1", + "legacy_suspend": "legacy-suspend-1" +} +EOF +cat >"$tmp/new-ids.json" <<'EOF' +{ + "data_disk": "data-1", + "docker_disk": "docker-1", + "boot_disk": "boot-new", + "vm": "vm-new", + "internal_service_account": "sa-1", + "internal_service_keys": "keys-1", + "stackdriver": "metrics-1", + "app_key_admin": "app-keys-1", + "scoped_start": "scoped-start-1", + "scoped_suspend": "scoped-suspend-1" +} +EOF +cat >"$tmp/old-state.txt" <<'EOF' +module.app.module.gcp.google_service_account.internal-services +module.app.module.gcp.google_service_account_iam_member.internal-services-keys +module.app.module.gcp.google_project_iam_member.stackdriver +module.app.module.gcp.google_project_iam_member.gce-start[0] +module.app.module.gcp.google_project_iam_member.gce-suspend +module.app.module.gcp.google_service_account_iam_member.gsa-user +module.app.module.gcp.google_service_account_iam_member.token-creator +module.app.module.gcp.google_service_account_iam_member.self_jwt_signer_policy +module.app.module.gcp.google_service_account_iam_member.app-keys +EOF +cat >"$tmp/new-state.txt" <<'EOF' +module.app.module.gcp.google_service_account.internal-services[0] +module.app.module.gcp.google_service_account_iam_member.internal-services-keys[0] +module.app.module.gcp.google_project_iam_member.stackdriver[0] +module.app.module.gcp.google_compute_instance_iam_member.gce-start[0] +module.app.module.gcp.google_compute_instance_iam_member.gce-suspend[0] +module.app.module.gcp.google_service_account_iam_member.app-keys[0] +EOF + +bash "$script" check-transition \ + "$tmp/old-ids.json" "$tmp/new-ids.json" "$tmp/old-state.txt" "$tmp/new-state.txt" + +jq '.docker_disk = "docker-replaced"' "$tmp/new-ids.json" >"$tmp/bad-new-ids.json" +if bash "$script" check-transition \ + "$tmp/old-ids.json" "$tmp/bad-new-ids.json" "$tmp/old-state.txt" "$tmp/new-state.txt" >/dev/null 2>&1; then + fail "state checker accepted a changed persistent disk id" +fi + +cp "$tmp/new-state.txt" "$tmp/bad-new-state.txt" +printf '%s\n' 'module.app.module.gcp.google_service_account.internal-services' >>"$tmp/bad-new-state.txt" +if bash "$script" check-transition \ + "$tmp/old-ids.json" "$tmp/new-ids.json" "$tmp/old-state.txt" "$tmp/bad-new-state.txt" >/dev/null 2>&1; then + fail "state checker accepted a retained legacy resource address" +fi + +cp "$tmp/new-state.txt" "$tmp/bad-legacy-power-state.txt" +printf '%s\n' 'module.app.module.gcp.google_project_iam_member.gce-start[0]' >>"$tmp/bad-legacy-power-state.txt" +if bash "$script" check-transition \ + "$tmp/old-ids.json" "$tmp/new-ids.json" "$tmp/old-state.txt" "$tmp/bad-legacy-power-state.txt" >/dev/null 2>&1; then + fail "state checker accepted a retained legacy project-wide power binding" +fi + +cp "$tmp/new-state.txt" "$tmp/bad-legacy-gsa-state.txt" +printf '%s\n' 'module.app.module.gcp.google_service_account_iam_member.gsa-user' >>"$tmp/bad-legacy-gsa-state.txt" +if bash "$script" check-transition \ + "$tmp/old-ids.json" "$tmp/new-ids.json" "$tmp/old-state.txt" "$tmp/bad-legacy-gsa-state.txt" >/dev/null 2>&1; then + fail "state checker accepted the legacy default Compute service-account impersonation grant" +fi + +cp "$tmp/new-state.txt" "$tmp/bad-legacy-token-state.txt" +printf '%s\n' 'module.app.module.gcp.google_service_account_iam_member.token-creator' >>"$tmp/bad-legacy-token-state.txt" +if bash "$script" check-transition \ + "$tmp/old-ids.json" "$tmp/new-ids.json" "$tmp/old-state.txt" "$tmp/bad-legacy-token-state.txt" >/dev/null 2>&1; then + fail "state checker accepted the unused VM self token-creator grant" +fi + +grep -Fvx 'module.app.module.gcp.google_compute_instance_iam_member.gce-suspend[0]' \ + "$tmp/new-state.txt" >"$tmp/bad-missing-scoped-state.txt" +if bash "$script" check-transition \ + "$tmp/old-ids.json" "$tmp/new-ids.json" "$tmp/old-state.txt" "$tmp/bad-missing-scoped-state.txt" >/dev/null 2>&1; then + fail "state checker accepted a missing instance-scoped power binding" +fi + +echo "GCP upgrade smoke contracts passed" diff --git a/ci/gcp-upgrade-smoke.sh b/ci/gcp-upgrade-smoke.sh new file mode 100755 index 0000000..e6bdc35 --- /dev/null +++ b/ci/gcp-upgrade-smoke.sh @@ -0,0 +1,1019 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd -- "$script_dir/.." && pwd)" + +# Reuse the production smoke runner's strict SSH, bootstrap, diagnostics, +# sitectl context, healthcheck, provider-sweep, and timeout behavior. +# shellcheck source=ci/cloud-smoke.sh +source "$script_dir/cloud-smoke.sh" + +readonly upgrade_base_sha="f33117cdbbf4a9c7d59006a4db986baef118e6bb" +readonly resource_prefix="module.app.module.gcp" +readonly data_disk_address="${resource_prefix}.google_compute_disk.data" +readonly docker_disk_address="${resource_prefix}.google_compute_disk.docker-volumes" +readonly boot_disk_address="${resource_prefix}.google_compute_disk.boot" +readonly vm_address="${resource_prefix}.google_compute_instance.cloud-compose" +readonly legacy_start_address="${resource_prefix}.google_project_iam_member.gce-start[0]" +readonly legacy_suspend_address="${resource_prefix}.google_project_iam_member.gce-suspend" +readonly legacy_gsa_user_address="${resource_prefix}.google_service_account_iam_member.gsa-user" +readonly legacy_vm_token_creator_address="${resource_prefix}.google_service_account_iam_member.token-creator" +readonly legacy_app_token_creator_address="${resource_prefix}.google_service_account_iam_member.self_jwt_signer_policy" +readonly conditional_app_token_creator_address="${resource_prefix}.google_service_account_iam_member.vault_agent_jwt_signer_policy[0]" +readonly scoped_start_address="${resource_prefix}.google_compute_instance_iam_member.gce-start[0]" +readonly scoped_suspend_address="${resource_prefix}.google_compute_instance_iam_member.gce-suspend[0]" +readonly -a legacy_removed_from_addresses=( + "$legacy_start_address" + "$legacy_suspend_address" + "$legacy_gsa_user_address" + "$legacy_vm_token_creator_address" + "$legacy_app_token_creator_address" +) +readonly -a legacy_removed_to_addresses=( + "$legacy_start_address" + "$legacy_suspend_address" + "$legacy_gsa_user_address" + "$legacy_vm_token_creator_address" + "$conditional_app_token_creator_address" +) + +readonly -a moved_from_addresses=( + "${resource_prefix}.google_service_account.internal-services" + "${resource_prefix}.google_service_account_iam_member.internal-services-keys" + "${resource_prefix}.google_project_iam_member.stackdriver" + "${resource_prefix}.google_service_account_iam_member.app-keys" +) +readonly -a moved_to_addresses=( + "${resource_prefix}.google_service_account.internal-services[0]" + "${resource_prefix}.google_service_account_iam_member.internal-services-keys[0]" + "${resource_prefix}.google_project_iam_member.stackdriver[0]" + "${resource_prefix}.google_service_account_iam_member.app-keys[0]" +) + +usage() { + cat <<'EOF' +Usage: + ci/gcp-upgrade-smoke.sh run + ci/gcp-upgrade-smoke.sh destroy + ci/gcp-upgrade-smoke.sh check-plan PLAN_JSON + ci/gcp-upgrade-smoke.sh check-transition OLD_IDS NEW_IDS OLD_STATE_LIST NEW_STATE_LIST + +The run command provisions the exact cloud-compose 0.10.2 release, preserves +its local Terraform state, upgrades that state with the checked-out commit, +proves the expected moves/replacements and disk persistence, then destroys the +disposable resources. The pre-provisioned CI network and subnet remain outside +the upgrade state. The destroy command is an idempotent same-job cleanup fallback. +EOF +} + +fail() { + echo "GCP upgrade smoke: $*" >&2 + return 1 +} + +sanitize_run_fragment() { + local value="$1" + + value="$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | cut -c1-8)" + if [[ -z "$value" ]]; then + fail "the smoke run id did not contain a usable name fragment" + return 1 + fi + printf '%s\n' "$value" +} + +upgrade_run_id() { + local requested="${CLOUD_COMPOSE_SMOKE_RUN_ID:-}" + + if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then + [[ -n "${GITHUB_RUN_ID:-}" ]] || fail "GITHUB_RUN_ID is required in GitHub Actions" + if [[ -n "$requested" && "$requested" != "$GITHUB_RUN_ID" ]]; then + fail "CLOUD_COMPOSE_SMOKE_RUN_ID must match GITHUB_RUN_ID in GitHub Actions" + return 1 + fi + printf '%s\n' "$GITHUB_RUN_ID" + return 0 + fi + + [[ -n "$requested" ]] || + fail "CLOUD_COMPOSE_SMOKE_RUN_ID must be set explicitly outside GitHub Actions" + printf '%s\n' "$requested" +} + +valid_ipv4() { + local value="$1" part + local -a parts=() + + [[ "$value" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1 + IFS=. read -r -a parts <<<"$value" + [[ "${#parts[@]}" -eq 4 ]] || return 1 + for part in "${parts[@]}"; do + [[ "$part" =~ ^[0-9]{1,3}$ ]] || return 1 + ((10#$part <= 255)) || return 1 + done +} + +valid_custom_role() { + local value="$1" + + [[ "$value" =~ ^(projects/([a-z0-9][a-z0-9.-]*:)?[a-z][a-z0-9-]{4,28}[a-z0-9]|organizations/[0-9]+)/roles/[A-Za-z0-9_.]+$ ]] +} + +valid_direct_vpc_cidr() { + local address="${1%/*}" prefix="${1##*/}" + local first second + local -a octets=() + + valid_ipv4 "$address" || return 1 + [[ "$prefix" =~ ^[0-9]+$ ]] || return 1 + IFS=. read -r -a octets <<<"$address" + first=$((10#${octets[0]})) + second=$((10#${octets[1]})) + prefix=$((10#$prefix)) + + (( + (first == 10 && prefix >= 8) || + (first == 172 && second >= 16 && second <= 31 && prefix >= 12) || + (first == 192 && second == 168 && prefix >= 16) || + (first == 100 && second >= 64 && second <= 127 && prefix >= 10) || + (first >= 240 && prefix >= 4) + )) +} + +validate_upgrade_network_ownership() { + local project="$1" network_project="$2" network_reference="$3" subnetwork_reference="$4" + local network_name subnetwork_name + + [[ "$network_project" == "$project" ]] || + fail "GCLOUD_NETWORK_PROJECT_ID must equal GCLOUD_PROJECT for the 0.10.2 baseline" + + network_reference="${network_reference%/}" + subnetwork_reference="${subnetwork_reference%/}" + network_name="${network_reference##*/}" + subnetwork_name="${subnetwork_reference##*/}" + [[ -n "$network_name" && "$network_name" != cc-g-wp-* ]] || + fail "the persistent upgrade network must not use the disposable cc-g-wp- prefix" + [[ -n "$subnetwork_name" && "$subnetwork_name" != cc-g-wp-* ]] || + fail "the persistent upgrade subnet must not use the disposable cc-g-wp- prefix" +} + +validate_upgrade_network() { + local project="$1" region="$2" network_reference="$3" subnetwork_reference="$4" + local network_name subnetwork_name network_json subnetwork_json subnet_cidr subnet_prefix + + validate_upgrade_network_ownership \ + "$project" \ + "$GCLOUD_NETWORK_PROJECT_ID" \ + "$network_reference" \ + "$subnetwork_reference" + + network_reference="${network_reference%/}" + subnetwork_reference="${subnetwork_reference%/}" + network_name="${network_reference##*/}" + subnetwork_name="${subnetwork_reference##*/}" + + network_json="$(gcloud compute networks describe "$network_name" \ + --project "$GCLOUD_NETWORK_PROJECT_ID" \ + --format=json)" + subnetwork_json="$(gcloud compute networks subnets describe "$subnetwork_name" \ + --project "$GCLOUD_NETWORK_PROJECT_ID" \ + --region "$region" \ + --format=json)" + + jq -e \ + --arg project "$GCLOUD_NETWORK_PROJECT_ID" \ + --arg network "$network_name" ' + .name == $network and + (.selfLink | endswith("/projects/" + $project + "/global/networks/" + $network)) and + (.mtu | tonumber) == 1460 + ' <<<"$network_json" >/dev/null || + fail "the persistent upgrade network must exist in the configured project with the Cloud Run default MTU of 1460" + + jq -e \ + --arg project "$GCLOUD_NETWORK_PROJECT_ID" \ + --arg region "$region" \ + --arg network "$network_name" \ + --arg subnetwork "$subnetwork_name" ' + .name == $subnetwork and + (.selfLink | endswith("/projects/" + $project + "/regions/" + $region + "/subnetworks/" + $subnetwork)) and + (.network | endswith("/projects/" + $project + "/global/networks/" + $network)) and + (.ipCidrRange | type == "string") + ' <<<"$subnetwork_json" >/dev/null || + fail "the persistent upgrade subnet must exist in the configured region and belong to the configured network" + + subnet_cidr="$(jq -er '.ipCidrRange' <<<"$subnetwork_json")" + subnet_prefix="${subnet_cidr##*/}" + valid_direct_vpc_cidr "$subnet_cidr" || + fail "the persistent subnet CIDR is outside Cloud Run Direct VPC supported IPv4 ranges: ${subnet_cidr}" + [[ "$subnet_prefix" =~ ^[0-9]+$ ]] && ((10#$subnet_prefix <= 26)) || + fail "the persistent Direct VPC subnet must be /26 or larger (found ${subnet_cidr})" +} + +assert_upgrade_plan() { + local plan_json="$1" index previous_address current_address + + [[ -s "$plan_json" ]] || fail "plan JSON is missing: $plan_json" + jq -e '.format_version and (.resource_changes | type == "array")' "$plan_json" >/dev/null || + fail "Terraform plan JSON is invalid" + + for index in "${!moved_from_addresses[@]}"; do + previous_address="${moved_from_addresses[$index]}" + current_address="${moved_to_addresses[$index]}" + jq -e \ + --arg previous "$previous_address" \ + --arg current "$current_address" ' + any(.resource_changes[]?; + .address == $current and + .previous_address == $previous and + ((.change.actions | index("delete")) == null) + ) + ' "$plan_json" >/dev/null || + fail "plan did not preserve moved resource ${previous_address} -> ${current_address}" + done + + for current_address in "$data_disk_address" "$docker_disk_address"; do + jq -e --arg address "$current_address" ' + any(.resource_changes[]?; + .address == $address and .change.actions == ["no-op"] + ) + ' "$plan_json" >/dev/null || + fail "persistent disk was not a no-op in the upgrade plan: ${current_address}" + done + + for index in "${!legacy_removed_to_addresses[@]}"; do + previous_address="${legacy_removed_from_addresses[$index]}" + current_address="${legacy_removed_to_addresses[$index]}" + jq -e --arg address "$current_address" ' + any(.resource_changes[]?; + .address == $address and .change.actions == ["delete"] + ) + ' "$plan_json" >/dev/null || + fail "upgrade plan did not remove the legacy over-broad IAM binding: ${current_address}" + if [[ "$previous_address" != "$current_address" ]]; then + jq -e --arg previous "$previous_address" --arg current "$current_address" ' + any(.resource_changes[]?; + .address == $current and .previous_address == $previous + ) + ' "$plan_json" >/dev/null || + fail "plan did not preserve moved-resource provenance before removing ${previous_address}" + fi + done + + for current_address in "$scoped_start_address" "$scoped_suspend_address"; do + jq -e --arg address "$current_address" ' + any(.resource_changes[]?; + .address == $address and .change.actions == ["create"] + ) + ' "$plan_json" >/dev/null || + fail "upgrade plan did not create the instance-scoped power binding: ${current_address}" + done + + jq -e \ + --arg boot "$boot_disk_address" \ + --arg vm "$vm_address" \ + --arg legacy_start "$legacy_start_address" \ + --arg legacy_suspend "$legacy_suspend_address" \ + --arg legacy_gsa_user "$legacy_gsa_user_address" \ + --arg legacy_vm_token_creator "$legacy_vm_token_creator_address" \ + --arg conditional_app_token_creator "$conditional_app_token_creator_address" ' + def replacement: + . == ["delete", "create"] or . == ["create", "delete"]; + + ([ + .resource_changes[]? | + select((.mode // "managed") == "managed") | + select(.change.actions | index("delete")) | + .address + ] | sort) == ([$boot, $vm, $legacy_start, $legacy_suspend, $legacy_gsa_user, $legacy_vm_token_creator, $conditional_app_token_creator] | sort) and + all(.resource_changes[]?; + if (.mode // "managed") == "data" then + true + elif .address == $boot or .address == $vm then + (.change.actions | replacement) + elif .address == $legacy_start or + .address == $legacy_suspend or + .address == $legacy_gsa_user or + .address == $legacy_vm_token_creator or + .address == $conditional_app_token_creator then + .change.actions == ["delete"] + else + ((.change.actions | index("delete")) == null) + end + ) + ' "$plan_json" >/dev/null || + fail "upgrade plan contained a managed-resource deletion beyond the expected VM/boot replacements and legacy IAM removals" +} + +assert_state_transition() { + local old_ids="$1" new_ids="$2" old_state_list="$3" new_state_list="$4" + local key address index previous_address current_address + + for key in data_disk docker_disk internal_service_account internal_service_keys stackdriver app_key_admin; do + jq -ne \ + --slurpfile old "$old_ids" \ + --slurpfile new "$new_ids" \ + --arg key "$key" ' + ($old[0][$key] | type == "string" and length > 0) and + $old[0][$key] == $new[0][$key] + ' >/dev/null || + fail "resource identity changed across the upgrade: ${key}" + done + + for key in boot_disk vm; do + jq -ne \ + --slurpfile old "$old_ids" \ + --slurpfile new "$new_ids" \ + --arg key "$key" ' + ($old[0][$key] | type == "string" and length > 0) and + ($new[0][$key] | type == "string" and length > 0) and + $old[0][$key] != $new[0][$key] + ' >/dev/null || + fail "expected replacement did not change resource identity: ${key}" + done + + for key in legacy_start legacy_suspend; do + jq -e --arg key "$key" '.[$key] | type == "string" and length > 0' "$old_ids" >/dev/null || + fail "baseline state did not capture the legacy project-wide power binding: ${key}" + done + for key in scoped_start scoped_suspend; do + jq -e --arg key "$key" '.[$key] | type == "string" and length > 0' "$new_ids" >/dev/null || + fail "upgraded state did not capture the instance-scoped power binding: ${key}" + done + + for address in "${moved_from_addresses[@]}"; do + grep -Fxq "$address" "$old_state_list" || + fail "baseline state did not contain expected legacy address: ${address}" + if grep -Fxq "$address" "$new_state_list"; then + fail "upgraded state retained legacy address: ${address}" + fi + done + for address in "${moved_to_addresses[@]}"; do + grep -Fxq "$address" "$new_state_list" || + fail "upgraded state did not contain moved address: ${address}" + done + for index in "${!legacy_removed_from_addresses[@]}"; do + previous_address="${legacy_removed_from_addresses[$index]}" + current_address="${legacy_removed_to_addresses[$index]}" + grep -Fxq "$previous_address" "$old_state_list" || + fail "baseline state did not contain expected legacy IAM binding: ${previous_address}" + if grep -Fxq "$previous_address" "$new_state_list" || grep -Fxq "$current_address" "$new_state_list"; then + fail "upgraded state retained legacy IAM binding: ${previous_address}" + fi + done + for address in "$scoped_start_address" "$scoped_suspend_address"; do + if grep -Fxq "$address" "$old_state_list"; then + fail "baseline state unexpectedly contained current instance-scoped power binding: ${address}" + fi + grep -Fxq "$address" "$new_state_list" || + fail "upgraded state did not contain instance-scoped power binding: ${address}" + done +} + +state_resource_attribute() { + local state_json="$1" address="$2" attribute="${3:-id}" + + jq -er --arg address "$address" --arg attribute "$attribute" ' + def all_resources: + .resources[]?, + (.child_modules[]? | all_resources); + + .values.root_module | + all_resources | + select(.address == $address) | + .values[$attribute] | + select(type == "string" and length > 0) + ' "$state_json" +} + +capture_state_resource_attribute() { + local state_json="$1" address="$2" attribute="${3:-id}" + local value + + if ! value="$(state_resource_attribute "$state_json" "$address" "$attribute")"; then + fail "state did not expose ${attribute} for ${address}" + return 1 + fi + printf '%s\n' "$value" +} + +write_phase_ids() { + local phase="$1" state_json="$2" destination="$3" + local internal_service_account internal_service_keys stackdriver app_key_admin start_binding suspend_binding + local data_disk_id docker_disk_id boot_disk_id vm_id internal_service_account_id + local internal_service_keys_id stackdriver_id app_key_admin_id start_binding_id suspend_binding_id + + case "$phase" in + old) + internal_service_account="${moved_from_addresses[0]}" + internal_service_keys="${moved_from_addresses[1]}" + stackdriver="${moved_from_addresses[2]}" + app_key_admin="${moved_from_addresses[3]}" + start_binding="$legacy_start_address" + suspend_binding="$legacy_suspend_address" + ;; + new) + internal_service_account="${moved_to_addresses[0]}" + internal_service_keys="${moved_to_addresses[1]}" + stackdriver="${moved_to_addresses[2]}" + app_key_admin="${moved_to_addresses[3]}" + start_binding="$scoped_start_address" + suspend_binding="$scoped_suspend_address" + ;; + *) fail "unknown state-capture phase: $phase" ;; + esac + + data_disk_id="$(capture_state_resource_attribute "$state_json" "$data_disk_address")" || return 1 + docker_disk_id="$(capture_state_resource_attribute "$state_json" "$docker_disk_address")" || return 1 + boot_disk_id="$(capture_state_resource_attribute "$state_json" "$boot_disk_address" disk_id)" || return 1 + vm_id="$(capture_state_resource_attribute "$state_json" "$vm_address" instance_id)" || return 1 + internal_service_account_id="$(capture_state_resource_attribute "$state_json" "$internal_service_account")" || return 1 + internal_service_keys_id="$(capture_state_resource_attribute "$state_json" "$internal_service_keys")" || return 1 + stackdriver_id="$(capture_state_resource_attribute "$state_json" "$stackdriver")" || return 1 + app_key_admin_id="$(capture_state_resource_attribute "$state_json" "$app_key_admin")" || return 1 + start_binding_id="$(capture_state_resource_attribute "$state_json" "$start_binding")" || return 1 + suspend_binding_id="$(capture_state_resource_attribute "$state_json" "$suspend_binding")" || return 1 + + jq -n \ + --arg data_disk "$data_disk_id" \ + --arg docker_disk "$docker_disk_id" \ + --arg boot_disk "$boot_disk_id" \ + --arg vm "$vm_id" \ + --arg internal_service_account "$internal_service_account_id" \ + --arg internal_service_keys "$internal_service_keys_id" \ + --arg stackdriver "$stackdriver_id" \ + --arg app_key_admin "$app_key_admin_id" \ + --arg start_binding "$start_binding_id" \ + --arg suspend_binding "$suspend_binding_id" \ + --arg phase "$phase" ' + ({ + data_disk: $data_disk, + docker_disk: $docker_disk, + boot_disk: $boot_disk, + vm: $vm, + internal_service_account: $internal_service_account, + internal_service_keys: $internal_service_keys, + stackdriver: $stackdriver, + app_key_admin: $app_key_admin + }) + if $phase == "old" then { + legacy_start: $start_binding, + legacy_suspend: $suspend_binding + } else { + scoped_start: $start_binding, + scoped_suspend: $suspend_binding + } end + ' >"$destination" + chmod 0600 "$destination" +} + +tf_in() { + local root="$1" data_dir="$2" + shift 2 + + TF_DATA_DIR="$data_dir" terraform -chdir="$root" "$@" +} + +initialize_phase() { + local root="$1" data_dir="$2" state_path="$3" + + mkdir -p "$data_dir" + tf_in "$root" "$data_dir" init \ + -input=false \ + -reconfigure \ + -backend-config="path=${state_path}" + tf_in "$root" "$data_dir" validate +} + +phase_output() { + local root="$1" data_dir="$2" output_json="$3" + + tf_in "$root" "$data_dir" output -json smoke >"$output_json" + chmod 0600 "$output_json" +} + +run_phase_healthcheck() { + local phase="$1" home_dir="$2" key_path="$3" output_json="$4" + local host port user project_dir + + host="$(jq -er '.host' "$output_json")" + port="$(jq -er '.ssh_port' "$output_json")" + user="$(jq -er '.ssh_user' "$output_json")" + project_dir="$(jq -er '.project_dir' "$output_json")" + + mkdir -p "$home_dir/.ssh" + chmod 0700 "$home_dir/.ssh" + echo "Waiting for the ${phase} GCP host" + wait_for_ssh "$home_dir" "$key_path" "$host" "$port" "$user" + if ! wait_for_cloud_init "$home_dir" "$key_path" "$host" "$port" "$user"; then + dump_remote_logs "$home_dir" "$key_path" "$host" "$port" "$user" "$project_dir" + return 1 + fi + ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" "bash -lc 'set -euo pipefail +sudo systemctl disable --now internal-services.timer internal-services.service 2>/dev/null || true +sudo systemctl disable --now cloud-compose-internal-services.timer cloud-compose-internal-services.service 2>/dev/null || true +for unit in internal-services.timer internal-services.service cloud-compose-internal-services.timer cloud-compose-internal-services.service; do + if sudo systemctl is-active --quiet \"\$unit\" 2>/dev/null; then + echo \"Fixture-only internal service remained active: \$unit\" >&2 + exit 1 + fi +done +'" + configure_sitectl_context "$home_dir" "$key_path" "$output_json" + if ! run_healthcheck "$home_dir" "$key_path" "$output_json"; then + dump_remote_logs "$home_dir" "$key_path" "$host" "$port" "$user" "$project_dir" + return 1 + fi +} + +run_direct_vpc_cold_start() { + local home_dir="$1" key_path="$2" output_json="$3" instance_name="$4" zone="$5" + local ingress_url status_code attempt status + + ingress_url="$(jq -er '.ingress_url | select(type == "string" and length > 0)' "$output_json")" + echo "Stopping ${instance_name} before the Cloud Run Direct VPC egress first-request test" + gcloud compute instances stop "$instance_name" \ + --project "$GCLOUD_PROJECT" \ + --zone "$zone" \ + --quiet + + status="" + for ((attempt = 1; attempt <= 60; attempt++)); do + status="$(gcloud compute instances describe "$instance_name" \ + --project "$GCLOUD_PROJECT" \ + --zone "$zone" \ + --format='value(status)')" + [[ "$status" == "TERMINATED" ]] && break + sleep 5 + done + [[ "$status" == "TERMINATED" ]] || fail "instance did not stop before the Direct VPC test (status: ${status})" + + # One curl invocation is deliberate: PPB may retry TCP establishment before + # sending bytes, but the smoke must not hide a failed first request with an + # HTTP-level replay. Google must append the actual runner after this hostile + # prefix so PPB's depth-zero client selection still matches the runner /32. + echo "Sending one adversarial first request through public Cloud Run with Direct VPC egress" + status_code="$(curl -4sS \ + --connect-timeout 30 \ + --max-time 600 \ + --output /dev/null \ + --write-out '%{http_code}' \ + --header 'X-Forwarded-For: 10.0.0.8' \ + --header "X-Cloud-Compose-Smoke: ${CLOUD_COMPOSE_SMOKE_RUN_ID}" \ + "$ingress_url")" + [[ "$status_code" =~ ^(2|3)[0-9][0-9]$ ]] || + fail "the first Cloud Run request over the Direct VPC egress path returned HTTP ${status_code}" + + run_phase_healthcheck "post-Direct-VPC cold start" "$home_dir" "$key_path" "$output_json" +} + +verify_metadata_isolation() { + local home_dir="$1" key_path="$2" output_json="$3" + local host port user remote_script encoded_script quoted_script + + host="$(jq -er '.host' "$output_json")" + port="$(jq -er '.ssh_port' "$output_json")" + user="$(jq -er '.ssh_user' "$output_json")" + + read -r -d '' remote_script <<'EOF' || true +set -euo pipefail + +metadata_address="169.254.169.254" +metadata_header="Metadata-Flavor: Google" +alpine_image="alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce" + +# The default bridge must retain normal and Compute internal DNS through the +# metadata resolver, while metadata HTTP and HTTPS remain unreachable. +docker run --rm --network bridge "$alpine_image" /bin/sh -ec ' + nslookup dl-cdn.alpinelinux.org >/dev/null + nslookup metadata.google.internal | grep -Fq "169.254.169.254" + for metadata_port in 80 443; do + if nc -z -w 3 169.254.169.254 "$metadata_port"; then + echo "Container reached GCP metadata TCP port ${metadata_port}" >&2 + exit 1 + fi + done +' + +# Root retains the narrow access required by key rotation. +curl -fsS --connect-timeout 3 --max-time 10 \ + --header "$metadata_header" \ + "http://${metadata_address}/computeMetadata/v1/instance/id" >/dev/null + +# No unprivileged host process may reach either metadata transport. +if sudo -u cloud-compose curl -kfsS --connect-timeout 3 --max-time 5 \ + --header "$metadata_header" \ + "http://${metadata_address}/computeMetadata/v1/instance/id" >/dev/null 2>&1; then + echo "Unprivileged host process reached GCP metadata HTTP" >&2 + exit 1 +fi +if sudo -u cloud-compose curl -kfsS --connect-timeout 3 --max-time 5 \ + --header "$metadata_header" \ + "https://${metadata_address}/computeMetadata/v1/instance/id" >/dev/null 2>&1; then + echo "Unprivileged host process reached GCP metadata HTTPS" >&2 + exit 1 +fi +EOF + + encoded_script="$(printf '%s' "$remote_script" | base64 | tr -d '\n')" + quoted_script="$(shell_quote "$encoded_script")" + echo "Verifying GCP DNS continuity and metadata isolation after the replacement boot" + ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \ + "printf %s ${quoted_script} | base64 -d | sudo bash" +} + +write_disk_sentinels() { + local home_dir="$1" key_path="$2" output_json="$3" nonce="$4" + local host port user encoded_nonce quoted_nonce + + host="$(jq -er '.host' "$output_json")" + port="$(jq -er '.ssh_port' "$output_json")" + user="$(jq -er '.ssh_user' "$output_json")" + encoded_nonce="$(printf '%s' "$nonce" | base64 | tr -d '\n')" + quoted_nonce="$(shell_quote "$encoded_nonce")" + + ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" "bash -lc 'set -euo pipefail +sudo findmnt -n /mnt/disks/data >/dev/null +sudo findmnt -n /mnt/disks/volumes >/dev/null +printf %s ${quoted_nonce} | base64 -d | sudo tee /mnt/disks/data/.cloud-compose-upgrade-sentinel >/dev/null +printf %s ${quoted_nonce} | base64 -d | sudo tee /mnt/disks/volumes/.cloud-compose-upgrade-sentinel >/dev/null +sudo sync +'" +} + +verify_disk_sentinels() { + local home_dir="$1" key_path="$2" output_json="$3" nonce="$4" + local host port user actual_data actual_volumes + + host="$(jq -er '.host' "$output_json")" + port="$(jq -er '.ssh_port' "$output_json")" + user="$(jq -er '.ssh_user' "$output_json")" + actual_data="$(ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \ + "sudo cat /mnt/disks/data/.cloud-compose-upgrade-sentinel")" + actual_volumes="$(ssh_cmd "$home_dir" "$key_path" "$host" "$port" "$user" \ + "sudo cat /mnt/disks/volumes/.cloud-compose-upgrade-sentinel")" + + [[ "$actual_data" == "$nonce" ]] || fail "the persistent data-disk sentinel did not survive the upgrade" + [[ "$actual_volumes" == "$nonce" ]] || fail "the Docker-volume disk sentinel did not survive the upgrade" +} + +write_tfvars() { + local root="$1" name="$2" project="$3" region="$4" zone="$5" public_key="$6" runner_cidr="$7" + local network_project="$8" network_name="$9" subnetwork_name="${10}" + local power_start_role="${11}" power_suspend_role="${12}" legacy_baseline="${13}" + + [[ "$legacy_baseline" == "true" || "$legacy_baseline" == "false" ]] || + fail "legacy_baseline must be true or false" + + jq -n \ + --arg name "$name" \ + --arg project "$project" \ + --arg region "$region" \ + --arg zone "$zone" \ + --arg public_key "$public_key" \ + --arg runner_cidr "$runner_cidr" \ + --arg network_project "$network_project" \ + --arg network_name "$network_name" \ + --arg subnetwork_name "$subnetwork_name" \ + --arg power_start_role "$power_start_role" \ + --arg power_suspend_role "$power_suspend_role" \ + --argjson legacy_baseline "$legacy_baseline" ' + { + name: $name, + gcp_project_id: $project, + gcp_region: $region, + gcp_zone: $zone, + gcp_network_project_id: $network_project, + gcp_network_name: $network_name, + gcp_subnetwork_name: $subnetwork_name, + gcp_power_start_role: $power_start_role, + gcp_power_suspend_role: $power_suspend_role, + legacy_baseline: $legacy_baseline, + ssh_public_key: $public_key, + runner_ipv4_cidr: $runner_cidr + } + ' >"$root/upgrade.auto.tfvars.json" + chmod 0600 "$root/upgrade.auto.tfvars.json" +} + +remove_worktree() { + local path="$1" + + if [[ -e "$path" || -L "$path" ]]; then + git -C "$repo_root" worktree remove --force "$path" >/dev/null 2>&1 || rm -rf "$path" + fi +} + +destroy_terraform_state() { + local root="$1" data_dir="$2" timeout_seconds destroy_status + + timeout_seconds="$(destroy_timeout_seconds)" + destroy_status=0 + if command -v timeout >/dev/null 2>&1; then + timeout "${timeout_seconds}s" \ + env TF_DATA_DIR="$data_dir" \ + terraform -chdir="$root" destroy -lock-timeout=10m -input=false -auto-approve || destroy_status=$? + else + TF_DATA_DIR="$data_dir" terraform -chdir="$root" destroy \ + -lock-timeout=10m -input=false -auto-approve || destroy_status=$? + fi + return "$destroy_status" +} + +cleanup_resources() { + local work_root="$1" old_source="$2" new_source="$3" old_data="$4" new_data="$5" state_path="$6" run_id="$7" + local cleanup_status=0 destroy_status=0 destroy_root="" destroy_data="" + + if [[ -s "$state_path" ]]; then + if [[ -d "$new_source/tests/smoke/gcp-upgrade" && -d "$new_data" ]]; then + destroy_root="$new_source/tests/smoke/gcp-upgrade" + destroy_data="$new_data" + elif [[ -d "$old_source/tests/smoke/gcp-upgrade" && -d "$old_data" ]]; then + destroy_root="$old_source/tests/smoke/gcp-upgrade" + destroy_data="$old_data" + fi + fi + + if [[ -n "$destroy_root" ]]; then + echo "Destroying GCP upgrade resources from preserved Terraform state" + destroy_terraform_state "$destroy_root" "$destroy_data" || destroy_status=$? + elif [[ -s "$state_path" ]]; then + echo "Terraform state remains but neither initialized source tree is available; using provider sweep" >&2 + destroy_status=1 + fi + + target_env gcp-wp + if ! provider_tag_cleanup gcp-wp "$run_id"; then + cleanup_status=1 + fi + if [[ "$destroy_status" -ne 0 && "$cleanup_status" -eq 0 ]]; then + echo "Provider sweep verified cleanup after Terraform destroy was unavailable or failed" + fi + + if [[ "$cleanup_status" -eq 0 ]]; then + remove_worktree "$old_source" + remove_worktree "$new_source" + git -C "$repo_root" worktree prune >/dev/null 2>&1 || true + rm -rf "$work_root" + else + echo "Preserving GCP upgrade state under ${work_root} for the same-job cleanup retry" >&2 + fi + + return "$cleanup_status" +} + +run_upgrade() ( + # Keep the EXIT trap inside this subshell so Bash retains all function-local + # cleanup context until the trap has finished. A brace-bodied function loses + # its locals before a process-level EXIT trap runs. + set -euo pipefail + + require_cmd base64 + require_cmd curl + require_cmd gcloud + require_cmd git + require_cmd jq + require_cmd sha256sum + require_cmd sitectl + require_cmd ssh + require_cmd ssh-keygen + require_cmd ssh-keyscan + require_cmd terraform + require_env GCLOUD_PROJECT + require_env GCLOUD_NETWORK_PROJECT_ID + require_env GCLOUD_NETWORK_NAME + require_env GCLOUD_SUBNETWORK_NAME + require_env GCLOUD_POWER_START_ROLE + require_env GCLOUD_POWER_SUSPEND_ROLE + + local requested_base current_ref current_sha base_sha run_id run_fragment name + local work_root old_source new_source state_path old_data new_data key_path public_key + local runner_ipv4 runner_cidr region zone plan_file plan_json nonce + local old_root new_root old_output new_output old_home new_home + local old_state_json new_state_json old_ids new_ids old_state_list new_state_list + local cleanup_started=false + + requested_base="${CLOUD_COMPOSE_UPGRADE_BASE_SHA:-$upgrade_base_sha}" + [[ "$requested_base" == "$upgrade_base_sha" ]] || + fail "CLOUD_COMPOSE_UPGRADE_BASE_SHA must remain pinned to ${upgrade_base_sha}" + base_sha="$(git -C "$repo_root" rev-parse --verify "${requested_base}^{commit}")" + [[ "$base_sha" == "$upgrade_base_sha" ]] || fail "the local baseline commit did not resolve exactly" + + current_ref="${CLOUD_COMPOSE_UPGRADE_CURRENT_REF:-HEAD}" + current_sha="$(git -C "$repo_root" rev-parse --verify "${current_ref}^{commit}")" + [[ "$current_sha" =~ ^[0-9a-f]{40}$ ]] || fail "current upgrade ref did not resolve to a full commit" + + run_id="$(upgrade_run_id)" + run_fragment="$(sanitize_run_fragment "$run_id")" + name="cc-g-wp-${run_fragment}-up" + export CLOUD_COMPOSE_SMOKE_RUN_ID="$run_id" + + work_root="${CLOUD_COMPOSE_GCP_UPGRADE_WORKDIR:-${RUNNER_TEMP:-/tmp}/cloud-compose-gcp-upgrade-${run_fragment}}" + [[ "$work_root" == /* ]] || fail "CLOUD_COMPOSE_GCP_UPGRADE_WORKDIR must be an absolute path" + old_source="$work_root/source-0.10.2" + new_source="$work_root/source-current" + state_path="$work_root/state/terraform.tfstate" + old_data="$work_root/terraform-data/old" + new_data="$work_root/terraform-data/new" + key_path="$work_root/ssh/id_ed25519" + old_home="$work_root/home-old" + new_home="$work_root/home-new" + region="${GCLOUD_REGION:-us-east5}" + zone="${GCLOUD_ZONE:-${region}-b}" + valid_custom_role "$GCLOUD_POWER_START_ROLE" || + fail "GCLOUD_POWER_START_ROLE must be a full project- or organization-custom-role name" + valid_custom_role "$GCLOUD_POWER_SUSPEND_ROLE" || + fail "GCLOUD_POWER_SUSPEND_ROLE must be a full project- or organization-custom-role name" + # Validate the externally owned references before the initial orphan sweep; + # a disposable-prefix typo must never bring the persistent CI network into + # the sweep's ownership boundary. + validate_upgrade_network \ + "$GCLOUD_PROJECT" \ + "$region" \ + "$GCLOUD_NETWORK_NAME" \ + "$GCLOUD_SUBNETWORK_NAME" + + # shellcheck disable=SC2317 + cleanup() { + local status=$? cleanup_result=0 + + trap - EXIT INT TERM HUP + if [[ "$cleanup_started" == "true" ]]; then + exit "$status" + fi + cleanup_started=true + set +e + cleanup_resources "$work_root" "$old_source" "$new_source" "$old_data" "$new_data" "$state_path" "$run_id" + cleanup_result=$? + set -e + if [[ "$status" -eq 0 && "$cleanup_result" -ne 0 ]]; then + exit "$cleanup_result" + fi + exit "$status" + } + trap cleanup EXIT + trap 'exit 130' INT TERM HUP + + cleanup_resources "$work_root" "$old_source" "$new_source" "$old_data" "$new_data" "$state_path" "$run_id" + mkdir -p "$work_root/state" "$work_root/terraform-data" "$work_root/ssh" + chmod 0700 "$work_root" "$work_root/state" "$work_root/terraform-data" "$work_root/ssh" + + ensure_key "$key_path" + public_key="$(<"${key_path}.pub")" + runner_ipv4="${CLOUD_COMPOSE_SMOKE_RUNNER_IPV4:-}" + if [[ -z "$runner_ipv4" ]]; then + runner_ipv4="$(curl -4fsS --proto '=https' --tlsv1.2 --retry 5 --retry-all-errors \ + --connect-timeout 10 --max-time 60 https://api.ipify.org)" + fi + valid_ipv4 "$runner_ipv4" || fail "could not determine a valid public IPv4 address for the runner" + runner_cidr="${runner_ipv4}/32" + + git -C "$repo_root" worktree add --detach "$old_source" "$base_sha" + git -C "$repo_root" worktree add --detach "$new_source" "$current_sha" + [[ -d "$new_source/tests/smoke/gcp-upgrade" ]] || fail "current source does not contain the GCP upgrade fixture" + cp -a "$new_source/tests/smoke/gcp-upgrade" "$old_source/tests/smoke/gcp-upgrade" + + old_root="$old_source/tests/smoke/gcp-upgrade" + new_root="$new_source/tests/smoke/gcp-upgrade" + write_tfvars \ + "$old_root" \ + "$name" \ + "$GCLOUD_PROJECT" \ + "$region" \ + "$zone" \ + "$public_key" \ + "$runner_cidr" \ + "$GCLOUD_NETWORK_PROJECT_ID" \ + "$GCLOUD_NETWORK_NAME" \ + "$GCLOUD_SUBNETWORK_NAME" \ + "$GCLOUD_POWER_START_ROLE" \ + "$GCLOUD_POWER_SUSPEND_ROLE" \ + true + write_tfvars \ + "$new_root" \ + "$name" \ + "$GCLOUD_PROJECT" \ + "$region" \ + "$zone" \ + "$public_key" \ + "$runner_cidr" \ + "$GCLOUD_NETWORK_PROJECT_ID" \ + "$GCLOUD_NETWORK_NAME" \ + "$GCLOUD_SUBNETWORK_NAME" \ + "$GCLOUD_POWER_START_ROLE" \ + "$GCLOUD_POWER_SUSPEND_ROLE" \ + false + + initialize_phase "$old_root" "$old_data" "$state_path" + echo "Applying cloud-compose 0.10.2 at ${base_sha}" + tf_in "$old_root" "$old_data" apply -lock-timeout=10m -input=false -auto-approve + chmod 0600 "$state_path" + + old_output="$work_root/old-smoke.json" + old_state_json="$work_root/old-state.json" + old_ids="$work_root/old-ids.json" + old_state_list="$work_root/old-state-list.txt" + phase_output "$old_root" "$old_data" "$old_output" + run_phase_healthcheck "0.10.2 baseline" "$old_home" "$key_path" "$old_output" + tf_in "$old_root" "$old_data" show -json >"$old_state_json" + tf_in "$old_root" "$old_data" state list >"$old_state_list" + chmod 0600 "$old_state_json" "$old_state_list" + write_phase_ids old "$old_state_json" "$old_ids" + + nonce="$(printf '%s' "${run_id}:${base_sha}:${current_sha}" | sha256sum | awk '{print $1}')" + write_disk_sentinels "$old_home" "$key_path" "$old_output" "$nonce" + + initialize_phase "$new_root" "$new_data" "$state_path" + plan_file="$work_root/upgrade.tfplan" + plan_json="$work_root/upgrade-plan.json" + echo "Planning the upgrade to ${current_sha}" + tf_in "$new_root" "$new_data" plan -lock-timeout=10m -input=false -out="$plan_file" + tf_in "$new_root" "$new_data" show -json "$plan_file" >"$plan_json" + chmod 0600 "$plan_file" "$plan_json" + assert_upgrade_plan "$plan_json" + + echo "Applying the verified upgrade plan" + tf_in "$new_root" "$new_data" apply -input=false "$plan_file" + new_output="$work_root/new-smoke.json" + new_state_json="$work_root/new-state.json" + new_ids="$work_root/new-ids.json" + new_state_list="$work_root/new-state-list.txt" + phase_output "$new_root" "$new_data" "$new_output" + tf_in "$new_root" "$new_data" show -json >"$new_state_json" + tf_in "$new_root" "$new_data" state list >"$new_state_list" + chmod 0600 "$new_state_json" "$new_state_list" + write_phase_ids new "$new_state_json" "$new_ids" + assert_state_transition "$old_ids" "$new_ids" "$old_state_list" "$new_state_list" + + run_phase_healthcheck "upgraded current" "$new_home" "$key_path" "$new_output" + run_direct_vpc_cold_start "$new_home" "$key_path" "$new_output" "$name" "$zone" + verify_metadata_isolation "$new_home" "$key_path" "$new_output" + verify_disk_sentinels "$new_home" "$key_path" "$new_output" "$nonce" + echo "GCP cloud-compose 0.10.2 -> ${current_sha} upgrade smoke test passed" +) + +destroy_upgrade() { + require_cmd git + require_cmd gcloud + require_cmd jq + require_cmd terraform + require_env GCLOUD_PROJECT + require_env GCLOUD_NETWORK_PROJECT_ID + require_env GCLOUD_NETWORK_NAME + require_env GCLOUD_SUBNETWORK_NAME + + local current_sha run_id run_fragment work_root old_source new_source state_path old_data new_data + + current_sha="$(git -C "$repo_root" rev-parse --verify "${CLOUD_COMPOSE_UPGRADE_CURRENT_REF:-HEAD}^{commit}")" + run_id="$(upgrade_run_id)" + run_fragment="$(sanitize_run_fragment "$run_id")" + export CLOUD_COMPOSE_SMOKE_RUN_ID="$run_id" + work_root="${CLOUD_COMPOSE_GCP_UPGRADE_WORKDIR:-${RUNNER_TEMP:-/tmp}/cloud-compose-gcp-upgrade-${run_fragment}}" + [[ "$work_root" == /* ]] || fail "CLOUD_COMPOSE_GCP_UPGRADE_WORKDIR must be an absolute path" + old_source="$work_root/source-0.10.2" + new_source="$work_root/source-current" + state_path="$work_root/state/terraform.tfstate" + old_data="$work_root/terraform-data/old" + new_data="$work_root/terraform-data/new" + + # Keep the externally owned network outside the provider sweep even when + # this explicit fallback runs after an earlier upgrade failure. + validate_upgrade_network_ownership \ + "$GCLOUD_PROJECT" \ + "$GCLOUD_NETWORK_PROJECT_ID" \ + "$GCLOUD_NETWORK_NAME" \ + "$GCLOUD_SUBNETWORK_NAME" + + cleanup_resources "$work_root" "$old_source" "$new_source" "$old_data" "$new_data" "$state_path" "$run_id" +} + +upgrade_main() { + case "${1:-}" in + run) + [[ "$#" -eq 1 ]] || { usage >&2; return 2; } + run_upgrade + ;; + destroy) + [[ "$#" -eq 1 ]] || { usage >&2; return 2; } + destroy_upgrade + ;; + check-plan) + [[ "$#" -eq 2 ]] || { usage >&2; return 2; } + require_cmd jq + assert_upgrade_plan "$2" + ;; + check-transition) + [[ "$#" -eq 5 ]] || { usage >&2; return 2; } + require_cmd jq + assert_state_transition "$2" "$3" "$4" "$5" + ;; + *) + usage >&2 + return 2 + ;; + esac +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + upgrade_main "$@" +fi diff --git a/ci/host-runtime-security.sh b/ci/host-runtime-security.sh new file mode 100644 index 0000000..c40d4ab --- /dev/null +++ b/ci/host-runtime-security.sh @@ -0,0 +1,327 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +fail() { + echo "host runtime security contract: $*" >&2 + exit 1 +} + +assert_contains() { + local file="$1" pattern="$2" + grep -Fq -- "$pattern" "$file" || fail "$file does not contain: $pattern" +} + +mkdir -p "$tmp/bin" + +cat >"$tmp/profile.sh" <<'EOF' +#!/usr/bin/env bash +export CLOUD_COMPOSE_PROVIDER="${TEST_PROVIDER:-}" +EOF + +cat >"$tmp/bin/iptables" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +: "${IPTABLES_LOG:?}" +printf '%q ' "$@" >>"$IPTABLES_LOG.calls" +printf '\n' >>"$IPTABLES_LOG.calls" + +table=filter +if [[ "$1" == "-t" ]]; then + table="$2" + shift 2 +fi + +if [[ "$1" == "-nL" ]]; then + [[ "${IPTABLES_DOCKER_USER_MISSING:-false}" != "true" && "$2" == "DOCKER-USER" ]] + exit +fi + +operation="$1" +chain="$2" +shift 2 +if [[ "$operation" == "-I" ]]; then + shift +fi +rule_prefix="" +if [[ "$table" != "filter" ]]; then + rule_prefix="${table} " +fi +rule="${rule_prefix}${chain} $*" + +case "$operation" in + -C) + grep -Fxq -- "$rule" "$IPTABLES_LOG" + ;; + -I) + { + printf '%s\n' "$rule" + cat "$IPTABLES_LOG" + } >"${IPTABLES_LOG}.tmp" + mv -f "${IPTABLES_LOG}.tmp" "$IPTABLES_LOG" + ;; + *) + echo "unexpected fake iptables operation: $operation" >&2 + exit 2 + ;; +esac +EOF +chmod +x "$tmp/bin/iptables" + +firewall_script="$repo_root/rootfs/home/cloud-compose/configure-metadata-firewall.sh" +firewall_log="$tmp/iptables.rules" +: >"$firewall_log" +: >"$firewall_log.calls" + +for _ in 1 2; do + TEST_PROVIDER=gcp \ + CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \ + IPTABLES_LOG="$firewall_log" \ + PATH="$tmp/bin:$PATH" \ + bash "$firewall_script" +done + +expected_rules="$tmp/expected.rules" +cat >"$expected_rules" <<'EOF' +FORWARD -d 169.254.169.254/32 -p udp --dport 53 -j ACCEPT +FORWARD -d 169.254.169.254/32 -p tcp --dport 53 -j ACCEPT +FORWARD -d 169.254.169.254/32 -j DROP +DOCKER-USER -d 169.254.169.254/32 -p udp --dport 53 -j ACCEPT +DOCKER-USER -d 169.254.169.254/32 -p tcp --dport 53 -j ACCEPT +DOCKER-USER -d 169.254.169.254/32 -j DROP +OUTPUT -m owner ! --uid-owner 0 -d 169.254.169.254/32 -p tcp --dport 443 -j DROP +OUTPUT -m owner ! --uid-owner 0 -d 169.254.169.254/32 -p tcp --dport 80 -j DROP +mangle PREROUTING -d 169.254.169.254/32 -p udp --dport 53 -j ACCEPT +mangle PREROUTING -d 169.254.169.254/32 -p tcp --dport 53 -j ACCEPT +mangle PREROUTING -d 169.254.169.254/32 -j DROP +EOF +cmp -s "$expected_rules" "$firewall_log" || fail "metadata firewall rules are incomplete or not idempotent" + +pre_firewall_log="$tmp/pre-docker.rules" +: >"$pre_firewall_log" +: >"$pre_firewall_log.calls" +TEST_PROVIDER=gcp \ +CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \ +IPTABLES_LOG="$pre_firewall_log" \ +IPTABLES_DOCKER_USER_MISSING=true \ +PATH="$tmp/bin:$PATH" \ + bash "$firewall_script" pre-docker +awk '$1 == "OUTPUT" || $1 == "mangle"' "$expected_rules" | cmp -s - "$pre_firewall_log" || \ + fail "pre-Docker metadata firewall does not protect containers and unprivileged host processes" + +calls_before="$(wc -l <"$firewall_log.calls")" +TEST_PROVIDER=linode \ +CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \ +IPTABLES_LOG="$firewall_log" \ +PATH="$tmp/bin:$PATH" \ + bash "$firewall_script" +calls_after="$(wc -l <"$firewall_log.calls")" +[[ "$calls_before" == "$calls_after" ]] || fail "non-GCP metadata policy invoked iptables" + +if TEST_PROVIDER='' CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" IPTABLES_LOG="$firewall_log" PATH="$tmp/bin:$PATH" \ + bash "$firewall_script" >/dev/null 2>&1; then + fail "metadata policy accepted a missing provider" +fi + +if TEST_PROVIDER=gcp CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" IPTABLES_LOG="$firewall_log" \ + IPTABLES_DOCKER_USER_MISSING=true PATH="$tmp/bin:$PATH" bash "$firewall_script" >/dev/null 2>&1; then + fail "metadata policy accepted a missing DOCKER-USER chain" +fi + +host_conf="$repo_root/rootfs/home/cloud-compose/host-conf.sh" +assert_contains "$host_conf" 'systemctl enable --now cloud-compose-metadata-firewall-pre.service' +assert_contains "$host_conf" 'systemctl enable --now cloud-compose-metadata-firewall.service' +docker_restart_line="$(grep -nF 'systemctl restart docker' "$host_conf" | head -n1 | cut -d: -f1)" +early_firewall_line="$(grep -nF 'bash /home/cloud-compose/configure-metadata-firewall.sh' "$host_conf" | head -n1 | cut -d: -f1)" +late_firewall_line="$(grep -nF 'bash /home/cloud-compose/configure-metadata-firewall.sh' "$host_conf" | tail -n1 | cut -d: -f1)" +dependency_line="$(grep -nF 'bash /home/cloud-compose/install-dependencies.sh' "$host_conf" | head -n1 | cut -d: -f1)" +[[ -n "$early_firewall_line" && -n "$docker_restart_line" && -n "$late_firewall_line" && + -n "$dependency_line" && "$early_firewall_line" -lt "$dependency_line" && + "$dependency_line" -lt "$docker_restart_line" && + "$docker_restart_line" -lt "$late_firewall_line" ]] || \ + fail "GCP metadata isolation is not converged before and after Docker bootstrap" +if grep -Eq '8\.8\.8\.8|8\.8\.4\.4|1\.1\.1\.1' \ + "$host_conf" "$repo_root/rootfs/home/cloud-compose/install-dependencies-cos.sh"; then + fail "GCP runtime bypasses Compute Engine and private-zone DNS" +fi +if grep -Fq -- '-i docker0' "$host_conf"; then + fail "metadata policy still depends on docker0" +fi +assert_contains "$repo_root/rootfs/etc/systemd/system/cloud-compose-metadata-firewall.service" 'After=docker.service' +assert_contains "$repo_root/rootfs/etc/systemd/system/cloud-compose-metadata-firewall.service" 'PartOf=docker.service' +assert_contains "$repo_root/rootfs/etc/systemd/system/cloud-compose-metadata-firewall-pre.service" 'Before=docker.service' +assert_contains "$repo_root/rootfs/etc/systemd/system/cloud-compose-metadata-firewall-pre.service" 'WantedBy=multi-user.target' +assert_contains "$repo_root/rootfs/etc/systemd/system/docker.service.d/cloud-compose-metadata-firewall.conf" 'Requires=cloud-compose-metadata-firewall-pre.service' +assert_contains "$repo_root/docs/runtime-contracts.md" 'COS reconstructs `/etc` after networking' + +profile_script="$repo_root/rootfs/home/cloud-compose/profile.sh" +assert_contains "$profile_script" 'if ((EUID == 0)); then' +assert_contains "$profile_script" 'export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"' +tmpfiles_conf="$repo_root/rootfs/etc/tmpfiles.d/cloud-compose.conf" +assert_contains "$tmpfiles_conf" 'd /run/lock/cloud-compose 0750 root cloud-compose -' +assert_contains "$tmpfiles_conf" 'f /run/lock/cloud-compose/lifecycle.lock 0660 root cloud-compose -' +host_init="$repo_root/rootfs/home/cloud-compose/host-init.sh" +if grep -Eq 'chown[[:space:]]+-R[[:space:]]+cloud-compose[^[:space:]]*[[:space:]]+/home/cloud-compose' "$host_init"; then + fail "host initialization gives the app account recursive ownership of root-executed code" +fi +if grep -Eq 'chown[[:space:]]+-R[[:space:]]+cloud-compose[^[:space:]]*[[:space:]]+/mnt/disks' "$host_init"; then + fail "host initialization recursively changes ownership of persistent data" +fi +assert_contains "$host_init" 'chown root:root /home/cloud-compose' +assert_contains "$host_init" "-exec chown root:root {} +" +assert_contains "$host_init" '/home/cloud-compose/.sitectl \' +assert_contains "$host_init" 'install -d -m 0750 -o cloud-compose -g cloud-compose "$mutable_dir"' +assert_contains "$host_init" 'for mutable_root in /mnt/disks/data /mnt/disks/volumes; do' +assert_contains "$host_init" 'install -d -m 0775 -o cloud-compose -g cloud-compose "$mutable_root"' +assert_contains "$host_init" 'install -d -m 0775 -o cloud-compose -g cloud-compose /mnt/disks/data/libops' +if grep -A12 -F 'source_compose_app_env()' "$repo_root/rootfs/home/cloud-compose/compose-apps.sh" | \ + grep -Eq 'source[[:space:]]+.*COMPOSE_APPS_ENV_DIR'; then + fail "privileged Compose manifest loading still sources an app-writable shell file" +fi + +curl_marker="$tmp/curl.called" +cat >"$tmp/bin/curl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +: "${CURL_MARKER:?}" +touch "$CURL_MARKER" +exit 22 +EOF +chmod +x "$tmp/bin/curl" + +daily_script="$repo_root/rootfs/home/cloud-compose/rotate-keys-daily.sh" +TEST_PROVIDER=linode \ +CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \ +CURL_MARKER="$curl_marker" \ +PATH="$tmp/bin:$PATH" \ + bash "$daily_script" +[[ ! -e "$curl_marker" ]] || fail "non-GCP rotation contacted a GCP endpoint" + +if TEST_PROVIDER='' CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" CURL_MARKER="$curl_marker" PATH="$tmp/bin:$PATH" \ + bash "$daily_script" >/dev/null 2>&1; then + fail "rotation accepted a missing provider" +fi + +rotate_script="$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" +if TEST_PROVIDER=gcp CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" CURL_MARKER="$curl_marker" PATH="$tmp/bin:$PATH" \ + bash "$rotate_script" prepare app@example.invalid test-project "$tmp/credentials.json" >/dev/null 2>&1; then + fail "rotation masked an unavailable metadata endpoint" +fi + +assert_contains "$rotate_script" 'prepare|status|audit|recover|authenticate|ready|commit|rollback|rollback-ready' +assert_contains "$rotate_script" 'ROTATION_DISABLE_GRACE_SECONDS' +assert_contains "$rotate_script" 'https://oauth2.googleapis.com/token' +assert_contains "$rotate_script" 'KEY_OPERATION_RESULT=absent' + +run_script="$repo_root/rootfs/home/cloud-compose/run.sh" +assert_contains "$run_script" 'systemctl enable --now cloud-compose-key-rotation.timer' +if grep -Eq 'rotate-keys[^[:space:]]*\.sh[[:space:]]*\|\|[[:space:]]*true' "$run_script"; then + fail "GCP key-rotation failures are still masked during bootstrap" +fi +if grep -Fq 'rotate-keys' "$repo_root/rootfs/home/cloud-compose/docker-prune.sh"; then + fail "provider-neutral Docker prune still invokes GCP key rotation" +fi + +archive_source="$repo_root/modules/linux-vm-runtime/main.tf" +verify_line="$(grep -n 'sha256sum -c -' "$archive_source" | head -n 1 | cut -d: -f1)" +extract_line="$(grep -n 'tar -xzf "\$tmp/rootfs.tar.gz"' "$archive_source" | head -n 1 | cut -d: -f1)" +[[ -n "$verify_line" && -n "$extract_line" && "$verify_line" -lt "$extract_line" ]] || \ + fail "rootfs archive is not verified before extraction" + +for variables_file in \ + "$repo_root/variables.tf" \ + "$repo_root/modules/digitalocean/variables.tf" \ + "$repo_root/modules/linode/variables.tf" \ + "$repo_root/providers/gcp/variables.tf" \ + "$repo_root/providers/do/variables.tf" \ + "$repo_root/providers/linode/variables.tf"; do + assert_contains "$variables_file" 'runtime.rootfs_archive_url and a 64-character runtime.rootfs_archive_sha256 must be supplied together.' +done + +gcp_module="$repo_root/modules/gcp/main.tf" +assert_contains "$gcp_module" 'internal_services_enabled = var.libops_internal_services_enabled || var.power_management_enabled' +assert_contains "$gcp_module" 'count = local.internal_services_enabled ? 1 : 0' +assert_contains "$gcp_module" 'count = var.power_management_enabled ? 1 : 0' +assert_contains "$repo_root/modules/gcp/outputs.tf" '} : null' +assert_contains "$daily_script" 'case "${LIBOPS_INTERNAL_SERVICES_ENABLED:-false}" in' +assert_contains "$daily_script" 'true) bash "$script_dir/rotate-keys-internal.sh" ;;' +assert_contains "$daily_script" 'bash "$script_dir/rotate-keys-app.sh"' +assert_contains "$repo_root/rootfs/home/cloud-compose/run.sh" 'runtime_enabled "${LIBOPS_INTERNAL_SERVICES_ENABLED:-false}"' +assert_contains "$repo_root/rootfs/home/cloud-compose/run.sh" 'systemctl disable --now cloud-compose-internal-services.timer cloud-compose-internal-services.service' + +managed_services_dir="$tmp/internal-services" +managed_services_log="$tmp/internal-services.log" +mkdir -p "$managed_services_dir" +: >"$managed_services_dir/docker-compose.yaml" +: >"$managed_services_log" + +cat >"$tmp/bin/docker" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf 'docker %s\n' "$*" >>"${MANAGED_SERVICES_LOG:?}" +EOF +cat >"$tmp/bin/systemctl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf 'systemctl %s\n' "$*" >>"${MANAGED_SERVICES_LOG:?}" +if [[ "${1:-}" == "is-active" ]]; then + [[ "${MANAGED_SERVICE_ACTIVE:-false}" == "true" ]] +fi +EOF +chmod +x "$tmp/bin/docker" "$tmp/bin/systemctl" + +( + export CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" + export LIBOPS_INTERNAL_SERVICES_DIR="$managed_services_dir" + export LIBOPS_INTERNAL_SERVICES_ENABLED=false + export LIBOPS_INTERNAL_SERVICES_AUTO_UPDATE=true + export MANAGED_SERVICES_LOG="$managed_services_log" + export PATH="$tmp/bin:$PATH" + # shellcheck disable=SC1090 + source "$repo_root/rootfs/home/cloud-compose/libops-managed-runtime.sh" + retry_until_success() { "$@"; } + update_internal_services +) +[[ ! -s "$managed_services_log" ]] || fail "disabled internal services invoked Docker or systemd during auto-update" + +( + export CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" + export LIBOPS_INTERNAL_SERVICES_DIR="$managed_services_dir" + export LIBOPS_INTERNAL_SERVICES_ENABLED=true + export LIBOPS_INTERNAL_SERVICES_AUTO_UPDATE=true + export MANAGED_SERVICES_LOG="$managed_services_log" + export PATH="$tmp/bin:$PATH" + # shellcheck disable=SC1090 + source "$repo_root/rootfs/home/cloud-compose/libops-managed-runtime.sh" + retry_until_success() { "$@"; } + update_internal_services +) +grep -Fq 'docker compose pull' "$managed_services_log" || fail "enabled internal service update did not pull" +if grep -Eq 'docker compose up|systemctl restart' "$managed_services_log"; then + fail "auto-update started or restarted inactive internal services" +fi + +: >"$managed_services_log" +( + export CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" + export LIBOPS_INTERNAL_SERVICES_DIR="$managed_services_dir" + export LIBOPS_INTERNAL_SERVICES_ENABLED=true + export LIBOPS_INTERNAL_SERVICES_AUTO_UPDATE=true + export MANAGED_SERVICE_ACTIVE=true + export MANAGED_SERVICES_LOG="$managed_services_log" + export PATH="$tmp/bin:$PATH" + # shellcheck disable=SC1090 + source "$repo_root/rootfs/home/cloud-compose/libops-managed-runtime.sh" + retry_until_success() { "$@"; } + update_internal_services +) +grep -Fq 'docker compose pull' "$managed_services_log" || fail "active internal service update did not pull" +grep -Fq 'systemctl restart cloud-compose-internal-services.service' "$managed_services_log" || \ + fail "active internal service update did not restart its existing unit" + +echo "Host runtime security contracts passed" diff --git a/ci/key-rotation-contract.sh b/ci/key-rotation-contract.sh new file mode 100644 index 0000000..4f0eb0d --- /dev/null +++ b/ci/key-rotation-contract.sh @@ -0,0 +1,633 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +tmp="$(mktemp -d)" +trap 'rm -rf -- "$tmp"' EXIT + +fail() { + echo "key rotation contract: $*" >&2 + exit 1 +} + +mkdir -p "$tmp/bin" "$tmp/apps/alpha" "$tmp/apps/beta" +order_log="$tmp/order.log" +post_count="$tmp/post-count" +auth_count="$tmp/auth-count" +key_state="$tmp/key-state.json" +: >"$order_log" +printf '0\n' >"$post_count" +printf '0\n' >"$auth_count" + +cat >"$tmp/profile.sh" <<'EOF' +#!/usr/bin/env bash +export CLOUD_COMPOSE_PROVIDER=gcp +export PATH="${TEST_BIN:?}:/usr/bin:/bin" +EOF + +cat >"$tmp/compose-apps.sh" <<'EOF' +#!/usr/bin/env bash +compose_app_names_array() { + local -n result="$1" + result=(alpha beta) +} +source_compose_app_env() { + case "$1" in + alpha) DOCKER_COMPOSE_DIR="${TEST_ROOT:?}/apps/alpha" ;; + beta) DOCKER_COMPOSE_DIR="${TEST_ROOT:?}/apps/beta" ;; + *) return 1 ;; + esac +} +validate_compose_git_source() { + [[ "${FAKE_SOURCE_INVALID:-false}" != "true" ]] +} +EOF + +cat >"$tmp/bin/chown" <<'EOF' +#!/usr/bin/env bash +printf 'CHOWN %s\n' "$*" >>"${ORDER_LOG:?}" +exit 0 +EOF +cat >"$tmp/bin/chgrp" <<'EOF' +#!/usr/bin/env bash +if [[ "${FAKE_CHGRP_FAIL_BETA:-false}" == "true" && "${!#}" == */apps/beta/secrets ]]; then + exit 1 +fi +exit 0 +EOF +cat >"$tmp/bin/sleep" <<'EOF' +#!/usr/bin/env bash +printf 'SLEEP %s\n' "${1:-}" >>"${ORDER_LOG:?}" +EOF +cat >"$tmp/bin/openssl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "${1:-}" == "dgst" ]]; then + cat >/dev/null + printf 'test-signature' + exit 0 +fi +echo "unexpected openssl call: $*" >&2 +exit 2 +EOF + +cat >"$tmp/bin/systemctl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +case "${1:-}" in + is-active) + unit="${!#}" + case "$unit" in + cloud-compose.service) [[ "${FAKE_APP_ACTIVE:-true}" == "true" ]] ;; + cloud-compose-internal-services.service) [[ "${FAKE_INTERNAL_ACTIVE:-true}" == "true" ]] ;; + *) exit 1 ;; + esac + ;; + restart) + unit="${2:-}" + expected="${EXPECTED_CONSUMER_KEY_ID:?}" + if [[ "$unit" == "cloud-compose.service" ]]; then + jq -e --arg id "$expected" '.private_key_id == $id' \ + "${TEST_ROOT:?}/apps/alpha/secrets/GOOGLE_APPLICATION_CREDENTIALS" >/dev/null + jq -e --arg id "$expected" '.private_key_id == $id' \ + "$TEST_ROOT/apps/beta/secrets/GOOGLE_APPLICATION_CREDENTIALS" >/dev/null + fi + if [[ "${FAKE_RESTART_FAIL:-false}" == "true" ]]; then + printf 'RESTART_FAILED %s %s\n' "$unit" "$expected" >>"${ORDER_LOG:?}" + exit 1 + fi + printf 'RESTART %s %s\n' "$unit" "$expected" >>"${ORDER_LOG:?}" + ;; + *) + echo "unexpected systemctl call: $*" >&2 + exit 2 + ;; +esac +EOF + +cat >"$tmp/bin/curl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +method=GET +url="" +output="" +write_format="" +previous="" +for argument in "$@"; do + case "$previous" in + -X) method="$argument" ;; + -o) output="$argument" ;; + -w) write_format="$argument" ;; + esac + if [[ "$argument" == http* ]]; then + url="$argument" + fi + previous="$argument" +done + +write_response() { + local body="$1" + if [[ -n "$output" ]]; then + printf '%s\n' "$body" >"$output" + else + printf '%s\n' "$body" + fi +} + +write_status() { + local status="$1" body="${2:-{}}" + write_response "$body" + if [[ -n "$write_format" ]]; then + printf '%s' "$status" + fi +} + +if [[ "$url" == *169.254.169.254* ]]; then + write_response '{"access_token":"metadata-token"}' + exit 0 +fi + +if [[ "$url" == "https://oauth2.googleapis.com/token" ]]; then + count="$(<"${AUTH_COUNT:?}")" + count=$((count + 1)) + printf '%s\n' "$count" >"$AUTH_COUNT" + printf 'AUTH %s\n' "$count" >>"${ORDER_LOG:?}" + if ((count <= ${FAKE_AUTH_FAIL_UNTIL:-0})); then + exit 22 + fi + write_response '{"access_token":"replacement-token","expires_in":3600,"token_type":"Bearer"}' + exit 0 +fi + +if [[ "$method" == GET && "$url" == */keys ]]; then + jq -c '{keys: [.[] | { + keyType: "USER_MANAGED", + name: .name, + disabled: .disabled + }]}' "${KEY_STATE:?}" + exit 0 +fi + +if [[ "$method" == POST && "$url" == */keys ]]; then + count="$(<"${POST_COUNT:?}")" + printf '%s\n' "$((count + 1))" >"$POST_COUNT" + printf 'POST\n' >>"${ORDER_LOG:?}" + service_account="${url#*serviceAccounts/}" + service_account="${service_account%/keys}" + key_name="projects/test-project/serviceAccounts/${service_account}/keys/${NEW_KEY_ID:?}" + if [[ "${FAKE_POST_MODE:-success}" != "fail-no-create" ]]; then + jq --arg name "$key_name" '. + [{name: $name, disabled: false}] | unique_by(.name)' \ + "$KEY_STATE" >"$KEY_STATE.tmp" + mv "$KEY_STATE.tmp" "$KEY_STATE" + fi + case "${FAKE_POST_MODE:-success}" in + fail-no-create | ambiguous-create) exit 22 ;; + esac + key_data="$(jq -nc --arg id "$NEW_KEY_ID" --arg email "$service_account" '{ + type: "service_account", + project_id: "test-project", + private_key_id: $id, + private_key: "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n", + client_email: $email, + token_uri: "https://oauth2.googleapis.com/token" + }' | base64 | tr -d '\n')" + printf '{"name":"%s","privateKeyData":"%s"}\n' "$key_name" "$key_data" + exit 0 +fi + +if [[ "$method" == POST && "$url" == *:disable ]]; then + key_name="${url#https://iam.googleapis.com/v1/}" + key_name="${key_name%:disable}" + if ! jq -e --arg name "$key_name" 'any(.[]; .name == $name)' "$KEY_STATE" >/dev/null; then + write_status 404 + exit 0 + fi + if [[ "${FAKE_DISABLE_FAIL:-false}" == "true" ]]; then + write_status 500 + exit 0 + fi + jq --arg name "$key_name" 'map(if .name == $name then .disabled = true else . end)' \ + "$KEY_STATE" >"$KEY_STATE.tmp" + mv "$KEY_STATE.tmp" "$KEY_STATE" + printf 'DISABLE %s\n' "$key_name" >>"${ORDER_LOG:?}" + write_status 200 + exit 0 +fi + +if [[ "$method" == POST && "$url" == *:enable ]]; then + key_name="${url#https://iam.googleapis.com/v1/}" + key_name="${key_name%:enable}" + if ! jq -e --arg name "$key_name" 'any(.[]; .name == $name)' "$KEY_STATE" >/dev/null; then + write_status 404 + exit 0 + fi + jq --arg name "$key_name" 'map(if .name == $name then .disabled = false else . end)' \ + "$KEY_STATE" >"$KEY_STATE.tmp" + mv "$KEY_STATE.tmp" "$KEY_STATE" + printf 'ENABLE %s\n' "$key_name" >>"${ORDER_LOG:?}" + write_status 200 + exit 0 +fi + +if [[ "$method" == DELETE ]]; then + key_name="${url#https://iam.googleapis.com/v1/}" + if [[ "${FAKE_DELETE_FAIL:-false}" == "true" ]]; then + write_status 500 + exit 0 + fi + if ! jq -e --arg name "$key_name" 'any(.[]; .name == $name)' "$KEY_STATE" >/dev/null; then + printf 'DELETE404 %s\n' "$key_name" >>"${ORDER_LOG:?}" + write_status 404 + exit 0 + fi + jq --arg name "$key_name" 'map(select(.name != $name))' "$KEY_STATE" >"$KEY_STATE.tmp" + mv "$KEY_STATE.tmp" "$KEY_STATE" + printf 'DELETE %s\n' "$key_name" >>"${ORDER_LOG:?}" + write_status 200 + exit 0 +fi + +echo "unexpected curl call: $method $url" >&2 +exit 22 +EOF + +chmod +x "$tmp/bin/"* + +export TEST_ROOT="$tmp" +export TEST_BIN="$tmp/bin" +export ORDER_LOG="$order_log" +export POST_COUNT="$post_count" +export AUTH_COUNT="$auth_count" +export KEY_STATE="$key_state" +export CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" +export CLOUD_COMPOSE_COMPOSE_APPS_PATH="$tmp/compose-apps.sh" +export CLOUD_COMPOSE_ROTATE_KEYS_PATH="$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" +export APP_CREDENTIALS_FILE="$tmp/central/GOOGLE_APPLICATION_CREDENTIALS" +export GCP_APP_SERVICE_ACCOUNT_EMAIL="app@example.invalid" +export GCP_APP_CREDENTIALS_ENABLED=true +export GCP_PROJECT="test-project" +export GCP_INSTANCE_NAME="test-instance" +export ROTATION_MIN_AGE_SECONDS=0 +export ROTATION_DISABLE_GRACE_SECONDS=86400 +export ROTATION_AUTH_MAX_RETRIES=3 +export ROTATION_AUTH_SLEEP_SECONDS=0 +export ROTATION_RECOVERY_SETTLE_SECONDS=0 +export ROTATION_CREDENTIAL_OWNER= +export ROTATION_CREDENTIAL_GROUP=test-group +export NEW_KEY_ID="replacement-key" +export EXPECTED_CONSUMER_KEY_ID="$NEW_KEY_ID" + +key_name() { + printf 'projects/test-project/serviceAccounts/%s/keys/%s\n' "$1" "$2" +} + +reset_key_state() { + local email="$1" old_id="$2" + jq -n --arg name "$(key_name "$email" "$old_id")" \ + '[{name: $name, disabled: false}]' >"$key_state" + : >"$order_log" + printf '0\n' >"$post_count" + printf '0\n' >"$auth_count" +} + +write_credentials() { + local file="$1" key_id="$2" email="${3:-app@example.invalid}" + + install -d "$(dirname -- "$file")" + jq -n --arg key_id "$key_id" --arg email "$email" '{ + type: "service_account", + project_id: "test-project", + private_key_id: $key_id, + private_key: "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n", + client_email: $email, + token_uri: "https://oauth2.googleapis.com/token" + }' >"$file" +} + +central_cleanup() { + rm -f -- "$APP_CREDENTIALS_FILE" "${APP_CREDENTIALS_FILE}.rotation-"* \ + "${APP_CREDENTIALS_FILE}.rotation.lock" 2>/dev/null || true + rm -rf -- "$tmp/apps/alpha/secrets" "$tmp/apps/beta/secrets" +} + +# File credentials are opt-in. A clean disabled deployment is a no-op, while a +# stale credential fails closed until its remote key is explicitly retired. +central_cleanup +reset_key_state app@example.invalid old-key +GCP_APP_CREDENTIALS_ENABLED=false \ + bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null +[[ "$(<"$post_count")" == 0 ]] || fail "disabled app credentials created a cloud key" +write_credentials "$APP_CREDENTIALS_FILE" old-key +if GCP_APP_CREDENTIALS_ENABLED=false \ + bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null 2>&1; then + fail "disabled app credentials accepted a stale local key" +fi +if grep -Eq 'DELETE |POST' "$order_log"; then + fail "disabled stale-credential detection mutated IAM" +fi + +# JSON strings are structurally parsed by jq, while key-ID text validation is +# performed by Bash for COS compatibility. Preserve trailing control bytes +# through extraction so they cannot be normalized into an accepted key ID. +write_credentials "$APP_CREDENTIALS_FILE" $'old-key\n' +if bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" prepare \ + app@example.invalid test-project "$APP_CREDENTIALS_FILE" >/dev/null 2>&1; then + fail "rotation normalized a trailing newline in a credential key ID" +fi +jq '.private_key_id = "old-key\u0000"' "$APP_CREDENTIALS_FILE" >"$APP_CREDENTIALS_FILE.tmp" +mv "$APP_CREDENTIALS_FILE.tmp" "$APP_CREDENTIALS_FILE" +if bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" prepare \ + app@example.invalid test-project "$APP_CREDENTIALS_FILE" >/dev/null 2>&1; then + fail "rotation normalized a NUL in a credential key ID" +fi +if grep -Eq 'DELETE |POST' "$order_log"; then + fail "invalid credential key-ID validation mutated IAM" +fi + +invalid_state_credentials="$tmp/invalid-state/GOOGLE_APPLICATION_CREDENTIALS" +install -d "$(dirname -- "$invalid_state_credentials")" +jq -n \ + --arg credentials_file "$invalid_state_credentials" \ + --arg baseline_name "$(key_name app@example.invalid old-key)"$'\n' '{ + version: 2, + phase: "creating", + service_account: "app@example.invalid", + project_id: "test-project", + credentials_file: $credentials_file, + current_key_id: "old-key", + new_key_id: "", + new_key_name: "", + baseline_key_names: [$baseline_name], + created_at: 1, + ready_at: 0, + disabled_at: 0 + }' >"${invalid_state_credentials}.rotation-pending.json" +if bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" status \ + app@example.invalid test-project "$invalid_state_credentials" >/dev/null 2>&1; then + fail "rotation normalized a trailing newline in a baseline key name" +fi +write_credentials "$APP_CREDENTIALS_FILE" old-key + +# Retirement deletes the currently installed remote key before removing every +# local and per-app credential artifact. +bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" retire >/dev/null +[[ ! -e "$APP_CREDENTIALS_FILE" ]] || fail "credential retirement retained the central key file" +if jq -e --arg name "$(key_name app@example.invalid old-key)" \ + 'any(.[]; .name == $name)' "$key_state" >/dev/null; then + fail "credential retirement retained the remote user-managed key" +fi + +# Losing the local credential does not prove that its remote private key was +# revoked. Retirement must surface the remaining audited key IDs and fail +# closed rather than silently making the disabled state look clean. +central_cleanup +reset_key_state app@example.invalid orphaned-remote-key +if bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" retire \ + app@example.invalid test-project "$APP_CREDENTIALS_FILE" >"$tmp/retire.out" 2>"$tmp/retire.err"; then + fail "credential retirement accepted an absent local file with a live remote key" +fi +grep -Fq 'orphaned-remote-key' "$tmp/retire.err" || fail "retirement did not report the audited remote key ID" +if grep -Eq '^DELETE ' "$order_log"; then + fail "retirement guessed which remote key to delete without a local credential" +fi +jq -n '[]' >"$key_state" +bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" retire \ + app@example.invalid test-project "$APP_CREDENTIALS_FILE" >/dev/null + +reset_key_state app@example.invalid old-key +central_cleanup +write_credentials "$APP_CREDENTIALS_FILE" old-key + +# Every application source/path is validated before IAM is mutated. +if FAKE_SOURCE_INVALID=true bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null 2>&1; then + fail "rotation accepted an invalid Compose source" +fi +[[ "$(<"$post_count")" == 0 ]] || fail "invalid Compose source triggered key creation" +if ROTATION_CREDENTIAL_GROUP='--reference=unsafe' \ + bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null 2>&1; then + fail "rotation accepted an option-like credential group" +fi +[[ "$(<"$post_count")" == 0 ]] || fail "invalid credential group triggered key creation" + +# Authenticate before distribution, and retain authenticated state if only part +# of the app credential fan-out succeeds. +if FAKE_CHGRP_FAIL_BETA=true bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null 2>&1; then + fail "rotation accepted a partial credential distribution failure" +fi +[[ "$(<"$post_count")" == 1 ]] || fail "distribution failure did not create exactly one replacement key" +[[ "$(<"$auth_count")" == 1 ]] || fail "replacement key was not authenticated before distribution" +jq -e '.phase == "authenticated" and .current_key_id == "old-key" and .new_key_id == "replacement-key"' \ + "${APP_CREDENTIALS_FILE}.rotation-pending.json" >/dev/null || fail "distribution failure did not retain authenticated state" +[[ -s "${APP_CREDENTIALS_FILE}.rotation-previous.json" ]] || fail "previous credentials were not preserved" +grep -Fq "CHOWN -- cloud-compose $tmp/apps/alpha/secrets" "$order_log" || \ + fail "application secret directory is not assigned to the cloud-compose owner" +[[ "$(stat -c %a "$tmp/apps/alpha/secrets")" == "750" ]] || \ + fail "application secret directory does not preserve private owner-write intent" +[[ "$(stat -c %a "$tmp/apps/alpha/secrets/GOOGLE_APPLICATION_CREDENTIALS")" == "440" ]] || \ + fail "application credential is not read-only and group-readable" +if grep -Eq 'RESTART |DISABLE |DELETE ' "$order_log"; then + fail "distribution failure restarted consumers or changed the previous key" +fi + +# A failed reload cannot advance readiness or disable the previous key. +if FAKE_RESTART_FAIL=true FAKE_APP_ACTIVE=true bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null 2>&1; then + fail "rotation accepted a failed app-container restart" +fi +jq -e '.phase == "authenticated"' "${APP_CREDENTIALS_FILE}.rotation-pending.json" >/dev/null || \ + fail "failed reload advanced rotation readiness" +if grep -Eq 'DISABLE |DELETE ' "$order_log"; then + fail "failed reload changed the previous cloud key" +fi + +# An intentionally inactive app remains inactive. Authentication and file +# distribution are sufficient for its next start; the old key is disabled but +# retained through the configured rollback grace. +: >"$order_log" +FAKE_RESTART_FAIL=false FAKE_APP_ACTIVE=false \ + bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null +if grep -Eq '^RESTART ' "$order_log"; then + fail "rotation started or restarted an inactive application" +fi +jq -e '.phase == "grace" and .disabled_at > 0' "${APP_CREDENTIALS_FILE}.rotation-pending.json" >/dev/null || \ + fail "successful propagation did not enter rollback grace" +jq -e --arg name "$(key_name app@example.invalid old-key)" \ + 'any(.[]; .name == $name and .disabled == true)' "$key_state" >/dev/null || fail "previous key was not disabled" +if grep -Eq '^DELETE ' "$order_log"; then + fail "previous key was deleted before rollback grace elapsed" +fi + +# Re-running during grace neither creates nor deletes a key. +: >"$order_log" +bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null +[[ "$(<"$post_count")" == 1 ]] || fail "grace retry created another replacement key" +if grep -Eq '^DELETE ' "$order_log"; then + fail "grace retry deleted the disabled previous key too early" +fi + +# Rollback re-enables and authenticates the previous key, distributes it, +# reloads only the already-active service, then removes the abandoned key. +: >"$order_log" +export EXPECTED_CONSUMER_KEY_ID=old-key +# Simulate a crash after the core restored the old credential but before the +# wrapper distributed/reloaded consumers. Re-running the documented wrapper +# command must resume rather than reject the rollback phase. +bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" rollback \ + app@example.invalid test-project "$APP_CREDENTIALS_FILE" >/dev/null +jq -e '.phase == "rollback"' "${APP_CREDENTIALS_FILE}.rotation-pending.json" >/dev/null || \ + fail "core rollback did not reach the resumable consumer phase" +FAKE_APP_ACTIVE=true bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" rollback >/dev/null +[[ ! -e "${APP_CREDENTIALS_FILE}.rotation-pending.json" ]] || fail "rollback left pending state" +jq -e '.private_key_id == "old-key"' "$APP_CREDENTIALS_FILE" >/dev/null || fail "rollback did not restore central credentials" +jq -e --arg name "$(key_name app@example.invalid old-key)" \ + 'any(.[]; .name == $name and .disabled == false)' "$key_state" >/dev/null || fail "rollback did not re-enable the previous key" +if jq -e --arg name "$(key_name app@example.invalid replacement-key)" 'any(.[]; .name == $name)' "$key_state" >/dev/null; then + fail "rollback retained the abandoned replacement key" +fi +enable_line="$(grep -n '^ENABLE ' "$order_log" | cut -d: -f1)" +restart_line="$(grep -n '^RESTART cloud-compose.service old-key$' "$order_log" | cut -d: -f1)" +delete_line="$(grep -n '/keys/replacement-key$' "$order_log" | tail -n1 | cut -d: -f1)" +[[ -n "$enable_line" && -n "$restart_line" && -n "$delete_line" ]] || fail "rollback audit sequence is incomplete" +(( enable_line < restart_line && restart_line < delete_line )) || fail "rollback key/service ordering is unsafe" + +# Authentication propagation failure occurs before app credential replacement +# or reload, and the retry count is bounded. +central_cleanup +reset_key_state app@example.invalid old-key +write_credentials "$APP_CREDENTIALS_FILE" old-key +export NEW_KEY_ID=auth-failure-key EXPECTED_CONSUMER_KEY_ID=auth-failure-key +if FAKE_AUTH_FAIL_UNTIL=99 FAKE_APP_ACTIVE=true \ + bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-app.sh" >/dev/null 2>&1; then + fail "rotation accepted replacement credentials that could not authenticate" +fi +[[ "$(<"$auth_count")" == "$ROTATION_AUTH_MAX_RETRIES" ]] || fail "authentication backoff was not bounded" +jq -e '.phase == "staged"' "${APP_CREDENTIALS_FILE}.rotation-pending.json" >/dev/null || \ + fail "authentication failure did not retain resumable staged state" +[[ ! -e "$tmp/apps/alpha/secrets/GOOGLE_APPLICATION_CREDENTIALS" ]] || \ + fail "authentication failure distributed an unproven credential" +if grep -Eq '^RESTART ' "$order_log"; then + fail "authentication failure restarted an app on an unproven key" +fi + +# An indeterminate create is never retried automatically. Audit exposes only +# key IDs, and recovery deletes only the explicitly confirmed single delta. +ambiguous="$tmp/ambiguous/GOOGLE_APPLICATION_CREDENTIALS" +reset_key_state app@example.invalid old-key +write_credentials "$ambiguous" old-key +export NEW_KEY_ID=ambiguous-key +if FAKE_POST_MODE=ambiguous-create bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" prepare \ + app@example.invalid test-project "$ambiguous" >/dev/null 2>&1; then + fail "rotation accepted an indeterminate key-creation response" +fi +if FAKE_POST_MODE=success bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" prepare \ + app@example.invalid test-project "$ambiguous" >/dev/null 2>&1; then + fail "rotation retried an indeterminate key creation" +fi +[[ "$(<"$post_count")" == 1 ]] || fail "indeterminate creation issued another POST" +audit="$(bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" audit \ + app@example.invalid test-project "$ambiguous")" +jq -e '.phase == "creating" and .recovery_required == true and .candidate_key_ids == ["ambiguous-key"]' \ + <<<"$audit" >/dev/null || fail "ambiguous creation audit was incomplete" +if bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" recover \ + app@example.invalid test-project "$ambiguous" wrong-key >/dev/null 2>&1; then + fail "ambiguous recovery accepted the wrong key confirmation" +fi +bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" recover \ + app@example.invalid test-project "$ambiguous" ambiguous-key >/dev/null +[[ ! -e "${ambiguous}.rotation-pending.json" ]] || fail "confirmed recovery left ambiguous state" +if jq -e --arg name "$(key_name app@example.invalid ambiguous-key)" 'any(.[]; .name == $name)' "$key_state" >/dev/null; then + fail "confirmed orphan key was not deleted" +fi + +# If the API audit proves that no key was created, recovery can clear state +# without a destructive confirmation. +no_create="$tmp/no-create/GOOGLE_APPLICATION_CREDENTIALS" +reset_key_state app@example.invalid old-key +write_credentials "$no_create" old-key +export NEW_KEY_ID=no-create-key +if FAKE_POST_MODE=fail-no-create bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" prepare \ + app@example.invalid test-project "$no_create" >/dev/null 2>&1; then + fail "rotation accepted a failed no-create request" +fi +bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" recover \ + app@example.invalid test-project "$no_create" >/dev/null +[[ ! -e "${no_create}.rotation-pending.json" ]] || fail "zero-delta recovery did not clear state" + +# Deletion is idempotent: a 404 after grace means the desired absent state has +# already been reached and local rollback state can be cleaned up. +idempotent="$tmp/idempotent/GOOGLE_APPLICATION_CREDENTIALS" +reset_key_state app@example.invalid old-key +write_credentials "$idempotent" old-key +export NEW_KEY_ID=idempotent-key ROTATION_DISABLE_GRACE_SECONDS=86400 +bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" prepare app@example.invalid test-project "$idempotent" >/dev/null +bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" authenticate app@example.invalid test-project "$idempotent" >/dev/null +bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" ready app@example.invalid test-project "$idempotent" >/dev/null +bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" commit app@example.invalid test-project "$idempotent" >/dev/null +jq --arg name "$(key_name app@example.invalid old-key)" 'map(select(.name != $name))' "$key_state" >"$key_state.tmp" +mv "$key_state.tmp" "$key_state" +ROTATION_DISABLE_GRACE_SECONDS=0 bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" commit \ + app@example.invalid test-project "$idempotent" >/dev/null +[[ ! -e "${idempotent}.rotation-pending.json" ]] || fail "idempotent 404 deletion retained grace state" +grep -Fq "DELETE404 $(key_name app@example.invalid old-key)" "$order_log" || fail "404 deletion path was not exercised" + +# Disable and delete failures retain the exact retry phase and both credential +# generations. Neither retry may create another replacement key. +failure_retry="$tmp/failure-retry/GOOGLE_APPLICATION_CREDENTIALS" +reset_key_state app@example.invalid old-key +write_credentials "$failure_retry" old-key +export NEW_KEY_ID=failure-retry-key ROTATION_DISABLE_GRACE_SECONDS=0 +bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" prepare app@example.invalid test-project "$failure_retry" >/dev/null +bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" authenticate app@example.invalid test-project "$failure_retry" >/dev/null +bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" ready app@example.invalid test-project "$failure_retry" >/dev/null +if FAKE_DISABLE_FAIL=true bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" commit \ + app@example.invalid test-project "$failure_retry" >/dev/null 2>&1; then + fail "rotation accepted a failed old-key disable" +fi +jq -e '.phase == "ready"' "${failure_retry}.rotation-pending.json" >/dev/null || \ + fail "failed disable did not retain ready state" +FAKE_DISABLE_FAIL=false bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" commit \ + app@example.invalid test-project "$failure_retry" >/dev/null +jq -e '.phase == "grace"' "${failure_retry}.rotation-pending.json" >/dev/null || \ + fail "successful disable did not enter grace" +if FAKE_DELETE_FAIL=true bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" commit \ + app@example.invalid test-project "$failure_retry" >/dev/null 2>&1; then + fail "rotation accepted a failed old-key deletion" +fi +jq -e '.phase == "grace"' "${failure_retry}.rotation-pending.json" >/dev/null || \ + fail "failed deletion did not retain grace state" +[[ -s "${failure_retry}.rotation-previous.json" ]] || fail "failed deletion discarded rollback credentials" +FAKE_DELETE_FAIL=false bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" commit \ + app@example.invalid test-project "$failure_retry" >/dev/null +[[ ! -e "${failure_retry}.rotation-pending.json" ]] || fail "delete retry did not finish rotation" +[[ "$(<"$post_count")" == 1 ]] || fail "disable/delete retries created another replacement key" + +# First provisioning has no rollback key and therefore never disables/deletes +# another service-account key. +first="$tmp/first/GOOGLE_APPLICATION_CREDENTIALS" +jq -n '[]' >"$key_state" +: >"$order_log" +export NEW_KEY_ID=first-key ROTATION_DISABLE_GRACE_SECONDS=0 +bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" prepare app@example.invalid test-project "$first" >/dev/null +bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" authenticate app@example.invalid test-project "$first" >/dev/null +bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" ready app@example.invalid test-project "$first" >/dev/null +bash "$repo_root/rootfs/home/cloud-compose/rotate-keys.sh" commit app@example.invalid test-project "$first" >/dev/null +if grep -Eq '^(DISABLE|DELETE) ' "$order_log"; then + fail "first-time provisioning changed a key without a previous local credential" +fi + +# The internal-service wrapper follows the same no-start rule. +internal_email="internal-test-instance@test-project.iam.gserviceaccount.com" +export INTERNAL_CREDENTIALS_FILE="$tmp/internal/GOOGLE_APPLICATION_CREDENTIALS" +reset_key_state "$internal_email" internal-old +write_credentials "$INTERNAL_CREDENTIALS_FILE" internal-old "$internal_email" +export NEW_KEY_ID=internal-new EXPECTED_CONSUMER_KEY_ID=internal-new ROTATION_DISABLE_GRACE_SECONDS=86400 +: >"$order_log" +FAKE_INTERNAL_ACTIVE=false bash "$repo_root/rootfs/home/cloud-compose/rotate-keys-internal.sh" >/dev/null +if grep -Fq 'RESTART cloud-compose-internal-services.service' "$order_log"; then + fail "rotation started or restarted inactive internal services" +fi +jq -e '.phase == "grace"' "${INTERNAL_CREDENTIALS_FILE}.rotation-pending.json" >/dev/null || \ + fail "inactive internal service did not complete authenticated propagation" + +echo "Key rotation contract passed" diff --git a/ci/managed-artifact-contract.sh b/ci/managed-artifact-contract.sh new file mode 100644 index 0000000..608b56f --- /dev/null +++ b/ci/managed-artifact-contract.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +runtime_script="$repo_root/rootfs/home/cloud-compose/libops-managed-runtime.sh" +tmp="$(mktemp -d)" +trap 'rm -rf -- "$tmp"' EXIT + +fail() { + echo "managed artifact contract: $*" >&2 + exit 1 +} + +mkdir -p "$tmp/bin" "$tmp/downloads" "$tmp/target" "$tmp/state/tmp" "$tmp/state/artifacts" +: >"$tmp/profile.sh" +curl_log="$tmp/curl.log" +systemctl_log="$tmp/systemctl.log" + +cat >"$tmp/bin/curl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +output="" +while (($# > 0)); do + case "$1" in + -o) output="$2"; shift 2 ;; + *) shift ;; + esac +done +printf 'curl\n' >>"${CURL_LOG:?}" +cp -- "${ARTIFACT_PAYLOAD:?}" "$output" +EOF +cat >"$tmp/bin/systemctl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >>"${SYSTEMCTL_LOG:?}" +[[ "${SYSTEMCTL_FAIL:-false}" != "true" ]] +EOF +chmod +x "$tmp/bin/"* + +payload="$tmp/downloads/tool" +printf 'new-artifact\n' >"$payload" +sha="$(sha256sum "$payload")" +sha="${sha%% *}" +manifest="$tmp/manifest.tsv" +target="$tmp/target/tool" +artifact_owner="$(id -un)" +artifact_group="$(id -gn)" +printf 'tool\thttps://example.invalid/tool\t%s\t%s\t0755\t%s\t%s\tcloud-compose.service\n' \ + "$sha" "$target" "$artifact_owner" "$artifact_group" >"$manifest" + +run_install() { + CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \ + TEST_STATE="$tmp/state" \ + TEST_MANIFEST="$manifest" \ + ARTIFACT_PAYLOAD="$payload" \ + CURL_LOG="$curl_log" \ + SYSTEMCTL_LOG="$systemctl_log" \ + PATH="$tmp/bin:/usr/bin:/bin" \ + bash --noprofile --norc -c ' + set -euo pipefail + source "$1" + STATE_DIR="$TEST_STATE" + TMP_DIR="$STATE_DIR/tmp" + ARTIFACT_STATE_DIR="$STATE_DIR/artifacts" + ARTIFACT_MANIFEST="$TEST_MANIFEST" + retry_until_success() { "$@"; } + install_managed_artifacts + ' managed-artifact "$runtime_script" +} + +run_install +cmp -s "$payload" "$target" || fail "verified artifact was not installed" +[[ "$(<"$tmp/state/artifacts/tool.sha256")" == "$sha" ]] || fail "successful artifact state was not recorded" +grep -Fxq 'try-restart -- cloud-compose.service' "$systemctl_log" || fail "artifact service was not restarted" + +# A complete matching spec is the only fast path. Checksum state cannot mask +# mode, owner, or group drift. +: >"$curl_log" +run_install +[[ ! -s "$curl_log" ]] || fail "fully matching artifact spec was downloaded again" +CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" bash --noprofile --norc -c ' + source "$1" + managed_artifact_metadata_matches "$2" 0755 "$3" "$4" + ! managed_artifact_metadata_matches "$2" 0755 wrong-owner "$4" + ! managed_artifact_metadata_matches "$2" 0755 "$3" wrong-group +' managed-artifact-metadata "$runtime_script" "$target" "$artifact_owner" "$artifact_group" || \ + fail "owner/group metadata does not participate in the installed-spec gate" +chmod 0644 "$target" +: >"$curl_log" +run_install +[[ "$(stat -c %a "$target")" == "755" ]] || fail "artifact mode drift was not reconciled" +[[ "$(wc -l <"$curl_log")" == 1 ]] || fail "mode drift did not leave the checksum fast path" + +# State alone is not trusted: a modified target must be re-downloaded and healed. +printf 'tampered\n' >"$target" +: >"$curl_log" +run_install +cmp -s "$payload" "$target" || fail "tampered artifact target was trusted from stale state" +[[ "$(wc -l <"$curl_log")" == 1 ]] || fail "tampered target was not downloaded exactly once" + +# Restart failure restores the exact prior target and records failure instead +# of claiming that the new artifact is installed. +printf 'prior-artifact\n' >"$target" +rm -f "$tmp/state/artifacts/tool.sha256" +prior_sha="$(sha256sum "$target")" +if SYSTEMCTL_FAIL=true run_install >/dev/null 2>&1; then + fail "artifact update accepted a failed service restart" +fi +[[ "$(sha256sum "$target")" == "$prior_sha" ]] || fail "restart failure did not restore the previous target" +[[ ! -e "$tmp/state/artifacts/tool.sha256" ]] || fail "restart failure recorded successful artifact state" +[[ -s "$tmp/state/artifacts/tool.failed" ]] || fail "restart failure did not record an audit state" + +SYSTEMCTL_FAIL=false run_install +cmp -s "$payload" "$target" || fail "artifact did not recover after restart failure" +[[ ! -e "$tmp/state/artifacts/tool.failed" ]] || fail "successful recovery retained stale failure state" + +# Invalid rows fail before any download or target mutation. +assert_rejected() { + local row="$1" + printf '%s\n' "$row" >"$manifest" + : >"$curl_log" + if run_install >/dev/null 2>&1; then + fail "invalid manifest row was accepted: $row" + fi + [[ ! -s "$curl_log" ]] || fail "invalid manifest row reached the network: $row" +} + +assert_rejected $'../escape\thttps://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t0755\t0\t0\t' +assert_rejected $'tool\thttp://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t0755\t0\t0\t' +assert_rejected $'tool\thttps://example.invalid/tool\tnot-a-sha\t'"$target"$'\t0755\t0\t0\t' +assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t../escape\t0755\t0\t0\t' +assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t/tmp//tool\t0755\troot\troot\t' +assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t/tmp/tool/\t0755\troot\troot\t' +assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t/tmp/control\x7ftool\t0755\troot\troot\t' +assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t4755\troot\troot\t' +assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t0755\t0\troot\t' +assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t0755\tRoot\troot\t' +assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t0755\troot.user\troot\t' +assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t0755\troot;id\troot\t' +assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t0755\troot\troot\t-unit.service' +assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t0755\troot\troot\tunit;id.service' +assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t0755\troot\troot' +long_name="$(printf 'a%.0s' {1..129})" +assert_rejected "$long_name"$'\thttps://example.invalid/tool\t'"$sha"$'\t'"$target"$'\t0755\troot\troot\t' + +# A later invalid row and duplicate names/paths are rejected by the complete +# preflight before the first valid row can download or mutate its target. +preflight_target="$tmp/target/preflight" +valid_preflight=$'first\thttps://example.invalid/first\t'"$sha"$'\t'"$preflight_target"$'\t0755\t'"$artifact_owner"$'\t'"$artifact_group"$'\t' +assert_manifest_preflight_rejected() { + local second_row="$1" + + printf '%s\n%s\n' "$valid_preflight" "$second_row" >"$manifest" + rm -f -- "$preflight_target" + : >"$curl_log" + if run_install >/dev/null 2>&1; then + fail "invalid multi-row manifest was accepted" + fi + [[ ! -s "$curl_log" ]] || fail "invalid later row allowed an earlier download" + [[ ! -e "$preflight_target" ]] || fail "invalid later row allowed an earlier target mutation" +} + +assert_manifest_preflight_rejected $'bad\thttp://example.invalid/bad\t'"$sha"$'\t'"$tmp/target/bad"$'\t0755\t'"$artifact_owner"$'\t'"$artifact_group"$'\t' +assert_manifest_preflight_rejected $'first\thttps://example.invalid/two\t'"$sha"$'\t'"$tmp/target/two"$'\t0755\t'"$artifact_owner"$'\t'"$artifact_group"$'\t' +assert_manifest_preflight_rejected $'second\thttps://example.invalid/two\t'"$sha"$'\t'"$preflight_target"$'\t0755\t'"$artifact_owner"$'\t'"$artifact_group"$'\t' +assert_manifest_preflight_rejected $'second\thttps://example.invalid/two\t'"$sha"$'\t'"$tmp/missing/second"$'\t0755\t'"$artifact_owner"$'\t'"$artifact_group"$'\t' +assert_manifest_preflight_rejected $'second\thttps://example.invalid/two\t'"$sha"$'\t'"$tmp/target/second"$'\t0755\tnonexistent-cloud-compose-owner\t'"$artifact_group"$'\t' + +# A root-run updater must not follow a symlinked or attacker-writable target +# directory chain, even when the final artifact path is lexically normalized. +ln -s -- "$tmp/target" "$tmp/target-link" +assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t'"$tmp/target-link/tool"$'\t0755\t'"$artifact_owner"$'\t'"$artifact_group"$'\t' +mkdir -m 0777 "$tmp/writable-target" +assert_rejected $'tool\thttps://example.invalid/tool\t'"$sha"$'\t'"$tmp/writable-target/tool"$'\t0755\t'"$artifact_owner"$'\t'"$artifact_group"$'\t' + +CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" bash --noprofile --norc -c ' + source "$1" + validate_managed_artifact valid https://example.invalid/a "$2" /tmp/a 0755 root root "cloud-compose@tenant:worker.service" +' managed-artifact-colon "$runtime_script" "$sha" || fail "runtime rejected the centrally allowed colon restart unit" + +echo "Managed artifact contract passed" diff --git a/ci/overlay-contract.sh b/ci/overlay-contract.sh new file mode 100644 index 0000000..5e39d1a --- /dev/null +++ b/ci/overlay-contract.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +overlay_script="$repo_root/rootfs/home/cloud-compose/overlay-init.sh" +tmp="$(mktemp -d)" +trap 'rm -rf -- "$tmp"' EXIT + +fail() { + echo "overlay contract: $*" >&2 + exit 1 +} + +volumes_root="$tmp/volumes" +lower_root="$tmp/lower" +mkdir -p "$volumes_root" "$lower_root/test-volume" +mount_marker="$tmp/mounted" +mount_log="$tmp/mount.log" +: >"$mount_log" + +# Source the functions so the contract can model mount state without requiring +# CAP_SYS_ADMIN in local CI. +# shellcheck disable=SC1090 +source "$overlay_script" + +verify_overlay_mount() { + if [[ "${FAKE_MOUNT_MISMATCH:-false}" == "true" ]]; then + return 2 + fi + [[ -e "$mount_marker" ]] +} +mount() { + printf 'mount %s\n' "$*" >>"$mount_log" + : >"$mount_marker" +} +umount() { + printf 'umount %s\n' "$*" >>"$mount_log" + rm -f -- "$mount_marker" +} + +export CLOUD_COMPOSE_VOLUMES_ROOT="$volumes_root" +export CLOUD_COMPOSE_OVERLAY_LOWER_ROOT="$lower_root" + +if main '../escape' false >/dev/null 2>&1; then + fail "path-traversing volume name was accepted" +fi +if main test-volume maybe >/dev/null 2>&1; then + fail "ambiguous reset flag was accepted" +fi + +main test-volume false +[[ -e "$mount_marker" ]] || fail "overlay was not mounted" +upper="$volumes_root/.overlay/test-volume/upper" +work="$volumes_root/.overlay/test-volume/work" +printf 'keep\n' >"$upper/kept" +printf 'keep\n' >"$work/kept" +: >"$mount_log" + +# Literal false must be idempotent and must not clear writable state. +main test-volume false +[[ -e "$upper/kept" && -e "$work/kept" ]] || fail "false reset flag cleared overlay state" +[[ ! -s "$mount_log" ]] || fail "matching mounted overlay was remounted" + +# Explicit reset unmounts, clears both upper and work (including dotfiles), and +# mounts the expected overlay again. +printf 'hidden\n' >"$upper/.hidden" +main test-volume true +[[ ! -e "$upper/kept" && ! -e "$upper/.hidden" && ! -e "$work/kept" ]] || \ + fail "explicit reset retained writable overlay state" +grep -Fq 'umount ' "$mount_log" || fail "explicit reset did not unmount" +grep -Fq 'mount -t overlay overlay' "$mount_log" || fail "explicit reset did not remount" + +FAKE_MOUNT_MISMATCH=true +if main test-volume false >/dev/null 2>&1; then + fail "unexpected existing mount was accepted" +fi +FAKE_MOUNT_MISMATCH=false + +rm -f -- "$mount_marker" +outside="$tmp/outside" +mkdir -p "$outside" +rm -rf -- "$volumes_root/.overlay" +ln -s "$outside" "$volumes_root/.overlay" +if main test-volume false >/dev/null 2>&1; then + fail "symbolic-link overlay state boundary was accepted" +fi +[[ -z "$(find "$outside" -mindepth 1 -print -quit)" ]] || \ + fail "symbolic-link overlay state boundary was mutated before rejection" + +echo "Overlay contract passed" diff --git a/ci/package-rootfs.sh b/ci/package-rootfs.sh new file mode 100755 index 0000000..6e3af96 --- /dev/null +++ b/ci/package-rootfs.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd -- "$script_dir/.." && pwd)" +output_dir="${1:-$repo_root/dist}" +asset_name="cloud-compose-rootfs.tar.gz" + +if [[ "$output_dir" != /* ]]; then + output_dir="$PWD/$output_dir" +fi +install -d -m 0755 -- "$output_dir" +tmp="$(mktemp -d "${TMPDIR:-/tmp}/cloud-compose-rootfs.XXXXXX")" +trap 'rm -rf -- "$tmp"' EXIT + +LC_ALL=C tar \ + --sort=name \ + --mtime='UTC 1970-01-01' \ + --owner=0 \ + --group=0 \ + --numeric-owner \ + -C "$repo_root" \ + -cf - rootfs | gzip -n -9 >"$tmp/$asset_name" + +( + cd "$tmp" + sha256sum "$asset_name" >"${asset_name}.sha256" +) +install -m 0644 "$tmp/$asset_name" "$output_dir/$asset_name" +install -m 0644 "$tmp/${asset_name}.sha256" "$output_dir/${asset_name}.sha256" diff --git a/ci/rollout-parity-contract.sh b/ci/rollout-parity-contract.sh new file mode 100755 index 0000000..9218dae --- /dev/null +++ b/ci/rollout-parity-contract.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" + +python3 - "$repo_root" <<'PY' +import ast +import json +import re +import sys +from pathlib import Path + +root = Path(sys.argv[1]) + +expected_rollout = [ + 'TARGET_REF="${GIT_REF:-${GIT_BRANCH:-}}"', + 'if [ -n "$TARGET_REF" ]; then sitectl deploy --context "${SITECTL_CONTEXT_NAME}" --ref "$TARGET_REF"; else sitectl deploy --context "${SITECTL_CONTEXT_NAME}" --skip-git; fi', + 'sitectl healthcheck --context "${SITECTL_CONTEXT_NAME}" --persist', + 'if [ "${SITECTL_ENVIRONMENT}" != "production" ]; then sitectl verify --context "${SITECTL_CONTEXT_NAME}" ${SITECTL_VERIFY_ARGS:-}; fi', +] + + +def fail(message): + raise SystemExit(f"rollout parity contract: {message}") + + +def require_match(pattern, text, label, flags=0): + match = re.search(pattern, text, flags) + if match is None: + fail(f"could not find {label}") + return match + + +def parse_hcl_rollout(path, pattern, label): + text = path.read_text() + block = require_match(pattern, text, label, re.MULTILINE | re.DOTALL).group("items") + commands = [] + for raw_line in block.splitlines(): + line = raw_line.strip() + if not line: + continue + if line.endswith(","): + line = line[:-1] + try: + command = json.loads(line) + except json.JSONDecodeError as exc: + fail(f"could not parse {label} command {line!r}: {exc}") + commands.append(command.replace("$${", "${")) + return commands + + +def parse_ansible_rollout(path): + text = path.read_text() + block = require_match( + r"^cloud_compose_default_rollout:\n(?P(?:^ - .*\n)+)", + text, + "Ansible default rollout list", + re.MULTILINE, + ).group("items") + commands = [] + for raw_line in block.splitlines(): + match = re.fullmatch(r" - '(.*)'", raw_line) + if match is None: + fail(f"could not parse Ansible rollout command {raw_line!r}") + commands.append(match.group(1)) + return commands + + +def parse_salt_rollout(path): + text = path.read_text() + block = require_match( + r"^\{% set default_rollout = \[\n(?P.*?)^\] %\}", + text, + "Salt default rollout list", + re.MULTILINE | re.DOTALL, + ).group("items") + commands = [] + for raw_line in block.splitlines(): + line = raw_line.strip().removesuffix(",") + if not line: + continue + try: + command = ast.literal_eval(line) + except (SyntaxError, ValueError) as exc: + fail(f"could not parse Salt rollout command {line!r}: {exc}") + if not isinstance(command, str): + fail(f"Salt rollout entry is not a string: {line!r}") + commands.append(command) + return commands + + +rollout_sources = { + "GCP Terraform": ( + root / "modules/gcp/variables.tf", + parse_hcl_rollout( + root / "modules/gcp/variables.tf", + r'^variable "docker_compose_rollout" \{.*?^ default = \[\n(?P.*?)^ \]\n', + "GCP Terraform default rollout list", + ), + ), + "Linux VM Terraform": ( + root / "modules/linux-vm-runtime/variables.tf", + parse_hcl_rollout( + root / "modules/linux-vm-runtime/variables.tf", + r'^variable "docker_compose_rollout" \{.*?^ default = \[\n(?P.*?)^ \]\n', + "Linux VM Terraform default rollout list", + ), + ), + "Ansible": ( + root / "ansible/roles/cloud_compose/defaults/main.yml", + parse_ansible_rollout(root / "ansible/roles/cloud_compose/defaults/main.yml"), + ), + "Salt": ( + root / "salt/cloud-compose/init.sls", + parse_salt_rollout(root / "salt/cloud-compose/init.sls"), + ), + "rollout documentation": ( + root / "docs/rollout.md", + parse_hcl_rollout( + root / "docs/rollout.md", + r"^ rollout = \[\n(?P.*?)^ \]\n", + "documented rollout list", + ), + ), +} + +for label, (path, commands) in rollout_sources.items(): + if commands != expected_rollout: + fail( + f"{label} rollout list in {path.relative_to(root)} diverged:\n" + f"expected {json.dumps(expected_rollout, indent=2)}\n" + f"actual {json.dumps(commands, indent=2)}" + ) + +legacy_rollout_script = "scripts/" + "rollout.sh" +for label, (path, _) in rollout_sources.items(): + if legacy_rollout_script in path.read_text(): + fail(f"{label} still delegates lifecycle ownership to {legacy_rollout_script}") + +compose_versions = {} +version_patterns = { + "root Terraform": ( + "variables.tf", + r'compose_version\s*=\s*optional\(string,\s*"(?P[^"]+)"\)', + ), + "GCP provider": ( + "providers/gcp/variables.tf", + r'compose_version\s*=\s*optional\(string,\s*"(?P[^"]+)"\)', + ), + "DigitalOcean provider": ( + "providers/do/variables.tf", + r'compose_version\s*=\s*optional\(string,\s*"(?P[^"]+)"\)', + ), + "Linode provider": ( + "providers/linode/variables.tf", + r'compose_version\s*=\s*optional\(string,\s*"(?P[^"]+)"\)', + ), + "DigitalOcean module": ( + "modules/digitalocean/variables.tf", + r'compose_version\s*=\s*optional\(string,\s*"(?P[^"]+)"\)', + ), + "Linode module": ( + "modules/linode/variables.tf", + r'compose_version\s*=\s*optional\(string,\s*"(?P[^"]+)"\)', + ), + "GCP module": ( + "modules/gcp/variables.tf", + r'variable "docker_compose_version" \{.*?default\s*=\s*"(?P[^"]+)"', + ), + "Linux VM module": ( + "modules/linux-vm-runtime/variables.tf", + r'variable "docker_compose_version" \{.*?default\s*=\s*"(?P[^"]+)"', + ), + "Ansible": ( + "ansible/roles/cloud_compose/defaults/main.yml", + r'^cloud_compose_docker_compose_version:\s*(?P\S+)\s*$', + ), + "Salt": ( + "salt/cloud-compose/init.sls", + r"docker\.get\('compose_version',\s*cc\.get\('docker_compose_version',\s*'(?P[^']+)'\)\)", + ), + "host installer fallback": ( + "rootfs/home/cloud-compose/install-docker-plugins.sh", + r'DOCKER_COMPOSE_VERSION="\$\{DOCKER_COMPOSE_VERSION:-(?P[^}]+)\}"', + ), +} + +for label, (relative_path, pattern) in version_patterns.items(): + path = root / relative_path + version = require_match( + pattern, + path.read_text(), + f"{label} Docker Compose default", + re.MULTILINE | re.DOTALL, + ).group("version") + if re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", version) is None: + fail(f"{label} has an invalid Docker Compose release tag: {version!r}") + compose_versions[label] = version + +if len(set(compose_versions.values())) != 1: + fail(f"Docker Compose defaults diverged: {json.dumps(compose_versions, indent=2)}") + +renovate = (root / "renovate.json5").read_text() +for marker in ( + "Update Docker Compose configuration-management defaults", + "ansible/roles/cloud_compose/defaults/main", + "salt/cloud-compose/init", +): + if marker not in renovate: + fail(f"Renovate configuration is missing configuration-management marker {marker!r}") + +print("Rollout and Docker Compose adapter parity contracts passed") +PY diff --git a/ci/rootfs-package-contract.sh b/ci/rootfs-package-contract.sh new file mode 100755 index 0000000..0c00046 --- /dev/null +++ b/ci/rootfs-package-contract.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +tmp="$(mktemp -d)" +trap 'rm -rf -- "$tmp"' EXIT + +bash "$script_dir/package-rootfs.sh" "$tmp/one" +bash "$script_dir/package-rootfs.sh" "$tmp/two" +cmp "$tmp/one/cloud-compose-rootfs.tar.gz" "$tmp/two/cloud-compose-rootfs.tar.gz" +cmp "$tmp/one/cloud-compose-rootfs.tar.gz.sha256" "$tmp/two/cloud-compose-rootfs.tar.gz.sha256" +( + cd "$tmp/one" + sha256sum -c cloud-compose-rootfs.tar.gz.sha256 +) +tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | awk ' + $0 !~ /^rootfs\// { bad = 1 } + /(^|\/)\.\.($|\/)/ { bad = 1 } + END { exit bad } +' +tar -tzf "$tmp/one/cloud-compose-rootfs.tar.gz" | grep -Fx 'rootfs/home/cloud-compose/run.sh' >/dev/null + +echo "Rootfs package contract passed" diff --git a/ci/runtime-config-contract.sh b/ci/runtime-config-contract.sh new file mode 100644 index 0000000..0fade8e --- /dev/null +++ b/ci/runtime-config-contract.sh @@ -0,0 +1,413 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +tmp="$(mktemp -d "${TMPDIR:-/tmp}/cloud-compose-runtime-config.XXXXXX")" +trap 'rm -rf "$tmp"' EXIT + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || { + echo "Missing required command: $1" >&2 + exit 1 + } +} + +require_line() { + local file="$1" + local value="$2" + + grep -Fq -- "$value" "$repo_root/$file" || { + echo "$file does not implement the cloud-compose environment contract: $value" >&2 + exit 1 + } +} + +require_pattern() { + local file="$1" + local pattern="$2" + + grep -Eq -- "$pattern" "$repo_root/$file" || { + echo "$file does not implement the cloud-compose environment contract: $pattern" >&2 + exit 1 + } +} + +quote_dotenv_value() { + local value="$1" + + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + value="${value//\$/\$\$}" + value="${value//$'\n'/\\n}" + value="${value//$'\r'/\\r}" + value="${value//$'\t'/\\t}" + printf '"%s"' "$value" +} + +write_contract_env() { + local file="$1" + local variable_name value + + { + for variable_name in \ + BACKTICKS \ + BACKSLASH \ + COMMAND_SUB \ + DOLLAR_VALUE \ + DOUBLE_QUOTES \ + MULTILINE \ + SINGLE_QUOTE \ + TRAILING_SLASH \ + WHITESPACE \ + name; do + value="${!variable_name}" + printf '%s=' "$variable_name" + quote_dotenv_value "$value" + printf '\n' + done + } >"$file" +} + +assert_runtime_values() { + local env_file="$1" + + # The child shell receives values as positional parameters. + # shellcheck disable=SC2016 + env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_ENV_FILE="$env_file" bash --noprofile --norc -c ' + source "$1" + shift + test "$BACKTICKS" = "$1" + test "$BACKSLASH" = "$2" + test "$COMMAND_SUB" = "$3" + test "$DOLLAR_VALUE" = "$4" + test "$DOUBLE_QUOTES" = "$5" + test "$MULTILINE" = "$6" + test "$SINGLE_QUOTE" = "$7" + test "$TRAILING_SLASH" = "$8" + test "$WHITESPACE" = "$9" + test "$name" = "${10}" + ' cloud-compose-env \ + "$repo_root/rootfs/home/cloud-compose/profile.sh" \ + "$BACKTICKS" \ + "$BACKSLASH" \ + "$COMMAND_SUB" \ + "$DOLLAR_VALUE" \ + "$DOUBLE_QUOTES" \ + "$MULTILINE" \ + "$SINGLE_QUOTE" \ + "$TRAILING_SLASH" \ + "$WHITESPACE" \ + "$name" +} + +assert_compose_values() { + local env_file="$1" + local compose_file="$tmp/compose.yaml" + local rendered="$tmp/compose-environment.txt" + + cat >"$compose_file" <<'EOF' +services: + contract: + image: scratch + environment: + BACKTICKS: ${BACKTICKS} + BACKSLASH: ${BACKSLASH} + COMMAND_SUB: ${COMMAND_SUB} + DOLLAR_VALUE: ${DOLLAR_VALUE} + DOUBLE_QUOTES: ${DOUBLE_QUOTES} + MULTILINE: ${MULTILINE} + SINGLE_QUOTE: ${SINGLE_QUOTE} + TRAILING_SLASH: ${TRAILING_SLASH} + WHITESPACE: ${WHITESPACE} + name: ${name} +EOF + + docker compose --env-file "$env_file" -f "$compose_file" config --environment >"$rendered" + python3 - \ + "$rendered" \ + "$BACKTICKS" \ + "$BACKSLASH" \ + "$COMMAND_SUB" \ + "$DOLLAR_VALUE" \ + "$DOUBLE_QUOTES" \ + "$MULTILINE" \ + "$SINGLE_QUOTE" \ + "$TRAILING_SLASH" \ + "$WHITESPACE" \ + "$name" <<'PY' +import sys +from pathlib import Path + +rendered, *expected = sys.argv[1:] +environment = Path(rendered).read_text(encoding="utf-8") +names = ["BACKTICKS", "BACKSLASH", "COMMAND_SUB", "DOLLAR_VALUE", "DOUBLE_QUOTES", "MULTILINE", "SINGLE_QUOTE", "TRAILING_SLASH", "WHITESPACE", "name"] +for name, value in zip(names, expected, strict=True): + rendered_value = f"{name}={value}\n" + assert rendered_value in environment, (name, value, environment) +PY +} + +require_cmd bash +require_cmd docker +require_cmd grep +require_cmd jq +require_cmd python3 + +require_line modules/runtime-env/main.tf "cloud-compose-env-contract" +require_line modules/runtime-env/main.tf 'escaped_dollars' +require_line modules/runtime-env/main.tf 'escaped_newlines' +require_line modules/runtime-env/main.tf 'escaped_quotes' +require_line modules/runtime-env/main.tf 'escaped_returns' +require_line modules/runtime-env/main.tf 'escaped_tabs' +require_line modules/linux-vm-runtime/main.tf 'module "runtime_env"' +require_line modules/linux-vm-runtime/main.tf 'base64gzip(module.runtime_env.content)' +require_line modules/linux-vm-runtime/main.tf 'path: "/home/cloud-compose/application-env.json"' +require_line modules/linux-vm-runtime/main.tf 'base64gzip(jsonencode(var.extra_env))' +require_line modules/linux-vm-runtime/main.tf 'SITECTL_PACKAGE_VERSIONS' +require_line modules/gcp/main.tf 'module "runtime_env"' +require_line modules/gcp/main.tf 'base64gzip(module.runtime_env.content)' +require_line modules/gcp/main.tf 'path: "/home/cloud-compose/application-env.json"' +require_line modules/gcp/main.tf 'base64gzip(jsonencode(var.extra_env))' +require_line modules/gcp/main.tf 'SITECTL_PACKAGE_VERSIONS' +require_line ansible/roles/cloud_compose/templates/env.j2 "cloud-compose-env-contract" +require_line ansible/roles/cloud_compose/templates/env.j2 'replace("\\", "\\\\")' +require_line ansible/roles/cloud_compose/templates/env.j2 'replace("$", "$$")' +require_line ansible/roles/cloud_compose/templates/env.j2 'replace("\n", "\\n")' +require_line ansible/roles/cloud_compose/templates/env.j2 'replace("\r", "\\r")' +require_line ansible/roles/cloud_compose/templates/env.j2 'replace("\t", "\\t")' +require_line ansible/roles/cloud_compose/tasks/main.yml 'SITECTL_PACKAGE_VERSIONS' +require_line ansible/roles/cloud_compose/tasks/main.yml 'application-env.json' +require_line salt/cloud-compose/files/env.jinja "cloud-compose-env-contract" +require_line salt/cloud-compose/files/env.jinja 'replace("\\", "\\\\")' +require_line salt/cloud-compose/files/env.jinja 'replace("$", "$$")' +require_line salt/cloud-compose/files/env.jinja 'replace("\n", "\\n")' +require_line salt/cloud-compose/files/env.jinja 'replace("\r", "\\r")' +require_line salt/cloud-compose/files/env.jinja 'replace("\t", "\\t")' +require_line salt/cloud-compose/init.sls 'SITECTL_PACKAGE_VERSIONS' +require_line salt/cloud-compose/init.sls 'application-env.json' +require_line modules/linux-vm-runtime/templates/cloud-init.yml "\${jsonencode(username)}" +require_line modules/linux-vm-runtime/templates/cloud-init.yml "\${jsonencode(key)}" +require_line templates/cloud-init.yml "\${jsonencode(username)}" +require_line templates/cloud-init.yml "\${jsonencode(key)}" +require_pattern main.tf 'extra_env[[:space:]]*=[[:space:]]*local\.runtime\.extra_env' +require_pattern providers/gcp/main.tf 'extra_env[[:space:]]*=[[:space:]]*local\.runtime\.extra_env' +require_line modules/gcp/variables.tf 'users names must be safe Linux usernames' +require_line modules/linux-vm-runtime/variables.tf 'ssh_users names must be safe Linux usernames' +# Match the literal expansion in the profile implementation. +# shellcheck disable=SC2016 +require_line rootfs/home/cloud-compose/profile.sh 'load_runtime_env "${CLOUD_COMPOSE_ENV_FILE:-/home/cloud-compose/.env}"' +require_line rootfs/home/cloud-compose/profile.sh 'acquire_cloud_compose_lifecycle_lock() {' +require_line rootfs/home/cloud-compose/profile.sh 'local lock_file="/run/lock/cloud-compose/lifecycle.lock"' +require_line rootfs/home/cloud-compose/profile.sh 'lock_fd_path="/proc/${BASHPID}/fd/8"' +require_line rootfs/home/cloud-compose/profile.sh 'if ! exec 8<>"$lock_file"; then' +require_line rootfs/home/cloud-compose/profile.sh 'if [[ -e /proc/${BASHPID}/fd/8 ]]; then' +require_line rootfs/home/cloud-compose/compose-dispatch.sh 'acquire_cloud_compose_lifecycle_lock "$lifecycle"' +require_line rootfs/home/cloud-compose/mariadb-backup.sh 'acquire_cloud_compose_lifecycle_lock mariadb-backup' +require_line rootfs/home/cloud-compose/host-init.sh 'update_runtime_env_file .env GCP_PUBLIC_IP' +require_line rootfs/home/cloud-compose/host-init.sh 'source /home/cloud-compose/profile.sh' +require_line rootfs/home/cloud-compose/app-init.sh 'sync_compose_application_env' +require_line rootfs/home/cloud-compose/run-rollout-service.sh 'PORT="${ROLLOUT_PORT:?ROLLOUT_PORT is required}"' +require_line rootfs/home/cloud-compose/run-rollout-service.sh 'JWKS_URI="${ROLLOUT_JWKS_URI:?ROLLOUT_JWKS_URI is required}"' +require_line rootfs/home/cloud-compose/run-rollout-service.sh 'export PORT JWKS_URI JWT_AUD CUSTOM_CLAIMS' + +if grep -Eq '},[[:space:]]*var\.extra_env\)' \ + "$repo_root/modules/gcp/main.tf" "$repo_root/modules/linux-vm-runtime/main.tf"; then + echo "Application extra_env must not be merged into the host environment" >&2 + exit 1 +fi + +if grep -R -Fq 'EnvironmentFile=/home/cloud-compose/.env' "$repo_root/rootfs/etc/systemd/system"; then + echo "Systemd units must use the non-executable runtime environment loader" >&2 + exit 1 +fi + +if grep -R -Eq '^set -[^[:space:]]*x|^set -o xtrace' "$repo_root/rootfs/home/cloud-compose"; then + echo "Privileged runtime boot scripts must not enable unconditional xtrace" >&2 + exit 1 +fi + +BACKTICKS="\`touch $tmp/backtick-injection\`" +BACKSLASH='a\path\with\slashes' +COMMAND_SUB="\$(touch $tmp/command-injection)" +DOLLAR_VALUE="\$HOME \${HOME}" +DOUBLE_QUOTES='a "double-quoted" value' +MULTILINE=$'line one\nline two' +SINGLE_QUOTE="O'Reilly" +TRAILING_SLASH="ends\\" +WHITESPACE=' leading and trailing ' +name='literal-name-value' +env_file="$tmp/.env" +write_contract_env "$env_file" +assert_runtime_values "$env_file" +assert_compose_values "$env_file" + +test ! -e "$tmp/backtick-injection" +test ! -e "$tmp/command-injection" + +update_dir="$tmp/update" +mkdir -p "$update_dir" +cp "$env_file" "$update_dir/.env" +UPDATE_VALUE=$'updated $(touch update-injection) "quote" \\ path\nnext line' +env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_ENV_FILE="$update_dir/.env" \ + bash --noprofile --norc -c ' + source "$1" + cd "$2" + update_env UPDATED "$3" + load_runtime_env .env + test "$UPDATED" = "$3" + ' cloud-compose-env \ + "$repo_root/rootfs/home/cloud-compose/profile.sh" \ + "$update_dir" \ + "$UPDATE_VALUE" +assert_compose_values "$update_dir/.env" +test ! -e "$update_dir/update-injection" + +compose_update_dir="$tmp/compose-update" +mkdir -p "$compose_update_dir" +cat >"$compose_update_dir/.env" <<'EOF' +# Existing downstream Compose dotenv syntax must remain opaque. +COMPOSE_PROJECT_NAME=legacy-project +DOMAIN=example.org +EXPANDED=${DOMAIN}/path +SINGLE_QUOTED='literal $DOMAIN' +EOF +env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_ENV_FILE="$env_file" \ + bash --noprofile --norc -c ' + source "$1" + cd "$2" + update_compose_env COMPOSE_PROJECT_NAME "managed-project" + update_compose_env COMPOSE_PROJECT_NAME "managed-project" + ' cloud-compose-env \ + "$repo_root/rootfs/home/cloud-compose/profile.sh" \ + "$compose_update_dir" +grep -Fq 'COMPOSE_PROJECT_NAME=legacy-project' "$compose_update_dir/.env" +grep -Fq 'EXPANDED=${DOMAIN}/path' "$compose_update_dir/.env" +grep -Fq "SINGLE_QUOTED='literal \$DOMAIN'" "$compose_update_dir/.env" +test "$(grep -Fc '# cloud-compose managed: COMPOSE_PROJECT_NAME' "$compose_update_dir/.env")" = 1 +test "$(tail -n 1 "$compose_update_dir/.env")" = 'COMPOSE_PROJECT_NAME="managed-project"' + +application_update_dir="$tmp/application-update" +mkdir -p "$application_update_dir" +printf '%s\n' '# Existing downstream Compose data.' 'UNCHANGED=${DOMAIN}/path' >"$application_update_dir/.env" +# The dollar expression belongs to jq. +# shellcheck disable=SC2016 +jq -n \ + --arg BACKTICKS "$BACKTICKS" \ + --arg BACKSLASH "$BACKSLASH" \ + --arg COMMAND_SUB "$COMMAND_SUB" \ + --arg DOLLAR_VALUE "$DOLLAR_VALUE" \ + --arg DOUBLE_QUOTES "$DOUBLE_QUOTES" \ + --arg MULTILINE "$MULTILINE" \ + --arg SINGLE_QUOTE "$SINGLE_QUOTE" \ + --arg TRAILING_SLASH "$TRAILING_SLASH" \ + --arg WHITESPACE "$WHITESPACE" \ + --arg name "$name" \ + '$ARGS.named' >"$tmp/application-env.json" +# The child shell receives file paths as positional parameters. +# shellcheck disable=SC2016 +env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_ENV_FILE="$env_file" \ + CLOUD_COMPOSE_APPLICATION_ENV_FILE="$tmp/application-env.json" \ + bash --noprofile --norc -c ' + source "$1" + cd "$2" + sync_compose_application_env + ' cloud-compose-env \ + "$repo_root/rootfs/home/cloud-compose/profile.sh" \ + "$application_update_dir" +assert_compose_values "$application_update_dir/.env" +grep -Fq 'UNCHANGED=${DOMAIN}/path' "$application_update_dir/.env" +test ! -e "$tmp/backtick-injection" +test ! -e "$tmp/command-injection" + +unsafe_update_dir="$tmp/unsafe-update" +mkdir -p "$unsafe_update_dir" +ln -s /etc/passwd "$unsafe_update_dir/.env" +if env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_ENV_FILE="$env_file" \ + bash --noprofile --norc -c ' + source "$1" + cd "$2" + update_env SAFE value + ' cloud-compose-env \ + "$repo_root/rootfs/home/cloud-compose/profile.sh" \ + "$unsafe_update_dir" >/dev/null 2>&1; then + echo "Runtime environment updater followed an unsafe symlink" >&2 + exit 1 +fi + +if env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_ENV_FILE="$env_file" \ + bash --noprofile --norc -c ' + source "$1" + cd "$2" + update_env "BAD-NAME" value + ' cloud-compose-env \ + "$repo_root/rootfs/home/cloud-compose/profile.sh" \ + "$update_dir" >/dev/null 2>&1; then + echo "Runtime environment updater accepted an unsafe variable name" >&2 + exit 1 +fi + +printf '%s\n' 'BAD-NAME="value"' >"$tmp/invalid.env" +# Source runs in the intentionally isolated child shell. +# shellcheck disable=SC2016 +if env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_ENV_FILE="$tmp/invalid.env" \ + bash --noprofile --norc -c 'source "$1"' cloud-compose-env \ + "$repo_root/rootfs/home/cloud-compose/profile.sh" >/dev/null 2>&1; then + echo "Runtime environment loader accepted an unsafe variable name" >&2 + exit 1 +fi + +for invalid_value in \ + "BAD=\"raw\$HOME\"" \ + 'BAD="raw"quote"' \ + 'BAD="unknown\qescape"' \ + 'BAD="trailing\"'; do + printf '%s\n' "$invalid_value" >"$tmp/invalid.env" + # Source runs in the intentionally isolated child shell. + # shellcheck disable=SC2016 + if env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_ENV_FILE="$tmp/invalid.env" \ + bash --noprofile --norc -c 'source "$1"' cloud-compose-env \ + "$repo_root/rootfs/home/cloud-compose/profile.sh" >/dev/null 2>&1; then + echo "Runtime environment loader accepted data outside the encoding contract: $invalid_value" >&2 + exit 1 + fi +done + +retry_log="$tmp/retry.log" +if env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_ENV_FILE="$env_file" MAX_RETRIES=1 \ + bash --noprofile --norc -c ' + source "$1" + retry_until_success /bin/false "https://example.invalid/?token=must-not-appear" + ' cloud-compose-retry "$repo_root/rootfs/home/cloud-compose/profile.sh" 2>"$retry_log"; then + echo "Retry contract unexpectedly accepted a failing command" >&2 + exit 1 +fi +grep -Fq "Operation 'false' failed" "$retry_log" +if grep -Fq 'must-not-appear' "$retry_log"; then + echo "Retry logging exposed command arguments" >&2 + exit 1 +fi + +for assignment in \ + 'MAX_RETRIES=0' \ + 'MAX_RETRIES=101' \ + 'MAX_RETRIES=1+1' \ + 'MAX_RETRIES=a[$(touch /tmp/cloud-compose-retry-injection)]' \ + 'SLEEP_INCREMENT=-1' \ + 'SLEEP_INCREMENT=3601' \ + 'SLEEP_INCREMENT=1+1' \ + 'SLEEP_INCREMENT=a[$(touch /tmp/cloud-compose-retry-injection)]'; do + rm -f /tmp/cloud-compose-retry-injection + if env -i PATH=/usr/bin:/bin CLOUD_COMPOSE_ENV_FILE="$env_file" "$assignment" \ + bash --noprofile --norc -c ' + source "$1" + retry_until_success /bin/true + ' cloud-compose-retry "$repo_root/rootfs/home/cloud-compose/profile.sh" >/dev/null 2>&1; then + echo "Retry contract accepted an unsafe bound: $assignment" >&2 + exit 1 + fi + if [[ -e /tmp/cloud-compose-retry-injection ]]; then + echo "Retry bound evaluation executed attacker-controlled arithmetic: $assignment" >&2 + exit 1 + fi +done diff --git a/ci/sitectl-version-contract.sh b/ci/sitectl-version-contract.sh new file mode 100644 index 0000000..bd585f5 --- /dev/null +++ b/ci/sitectl-version-contract.sh @@ -0,0 +1,350 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +runtime_script="$repo_root/rootfs/home/cloud-compose/libops-managed-runtime.sh" +tmp="$(mktemp -d "${TMPDIR:-/tmp}/cloud-compose-sitectl-versions.XXXXXX")" +trap 'rm -rf "$tmp"' EXIT + +touch "$tmp/profile.sh" + +run_contract() { + CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \ + SITECTL_PACKAGES="$1" \ + SITECTL_VERSION="$2" \ + SITECTL_PACKAGE_VERSIONS="$3" \ + bash --noprofile --norc -c ' + set -euo pipefail + source "$1" + shift + validate_sitectl_configuration + while (( $# > 0 )); do + package="$1" + expected="$2" + shift 2 + actual="$(sitectl_package_version "$package")" + test "$actual" = "$expected" + done + ' cloud-compose-sitectl-versions "$runtime_script" "${@:4}" +} + +reject_contract() { + local packages="$1" version="$2" package_versions="$3" + + if CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \ + SITECTL_PACKAGES="$packages" \ + SITECTL_VERSION="$version" \ + SITECTL_PACKAGE_VERSIONS="$package_versions" \ + bash --noprofile --norc -c ' + set -euo pipefail + source "$1" + validate_sitectl_configuration + touch "$2" + ' cloud-compose-sitectl-versions "$runtime_script" "$tmp/download-started" >/dev/null 2>&1; then + echo "Invalid sitectl version contract was accepted: $package_versions" >&2 + return 1 + fi + test ! -e "$tmp/download-started" +} + +run_contract \ + "sitectl sitectl-isle sitectl-drupal" \ + "v0.38.0" \ + '{"sitectl":"v0.39.0-rc.1","sitectl-isle":"v0.12.0"}' \ + sitectl v0.39.0-rc.1 \ + sitectl-isle v0.12.0 \ + sitectl-drupal v0.38.0 + +run_contract \ + "sitectl sitectl-isle" \ + "v0.38.0" \ + '{}' \ + sitectl v0.38.0 \ + sitectl-isle v0.38.0 + +run_contract \ + "sitectl sitectl-isle" \ + "latest" \ + '{"sitectl":"v0.38.0","sitectl-isle":"latest"}' \ + sitectl v0.38.0 \ + sitectl-isle latest + +reject_contract "sitectl sitectl-isle" "v0.38.0" '{' +reject_contract "sitectl sitectl-isle" "v0.38.0" '[]' +reject_contract "sitectl sitectl-isle" "v0.38.0" '{"sitectl-isle":12}' +reject_contract "sitectl sitectl-isle" "v0.38.0" '{"../../sitectl":"v0.1.0"}' +reject_contract "sitectl sitectl-isle" "v0.38.0" '{"sitectl-isle":"v0.12.0/../../latest"}' +reject_contract "sitectl sitectl-isle" "v0.38.0" '{"sitectl-wp":"v0.9.0"}' +reject_contract "sitectl sitectl-isle" "v0.38.0" '{"sitectl\n":"v0.38.0"}' +reject_contract "sitectl sitectl-isle" "v0.38.0" '{"sitectl\u0000":"v0.38.0"}' +reject_contract "sitectl sitectl-isle" "v0.38.0" '{"sitectl":"latest\n"}' +reject_contract "sitectl sitectl-isle" "v0.38.0" '{"sitectl":"v0.38.0\u0000"}' +reject_contract "sitectl sitectl-isle" "v0.38.0/../../latest" '{}' +reject_contract "sitectl --installroot" "latest" '{}' + +CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \ + bash --noprofile --norc -c ' + set -euo pipefail + source "$1" + TMP_DIR="$2" + mkdir -p "$TMP_DIR" + retry_until_success() { + "$@" + } + curl() { + local output="" url="" + while (( $# > 0 )); do + case "$1" in + -o) + output="$2" + shift 2 + ;; + http*) + url="$1" + shift + ;; + *) + shift + ;; + esac + done + test "$url" = "https://api.github.com/repos/libops/sitectl-isle/releases/latest" + printf "%s\n" "{\"tag_name\":\"v0.12.0\"}" >"$output" + } + tag="$(latest_release_tag sitectl-isle)" + test "$tag" = "v0.12.0" + base_url="$(release_base_url sitectl-isle "$tag")" + test "$base_url" = "https://github.com/libops/sitectl-isle/releases/download/v0.12.0" + [[ "$base_url" != */releases/latest/download ]] + ' cloud-compose-sitectl-latest "$runtime_script" "$tmp/latest" + +reject_latest_metadata() { + local metadata="$1" + + if CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \ + LATEST_METADATA="$metadata" \ + bash --noprofile --norc -c ' + set -euo pipefail + source "$1" + TMP_DIR="$2" + mkdir -p "$TMP_DIR" + retry_until_success() { + "$@" + } + curl() { + local output="" + while (( $# > 0 )); do + if [[ "$1" == "-o" ]]; then + output="$2" + shift 2 + else + shift + fi + done + printf "%s\n" "$LATEST_METADATA" >"$output" + } + latest_release_tag sitectl-isle + ' cloud-compose-sitectl-latest "$runtime_script" "$tmp/latest-invalid" >/dev/null 2>&1; then + echo "Latest release resolution accepted invalid metadata: $metadata" >&2 + return 1 + fi +} + +reject_latest_metadata '{}' +reject_latest_metadata '{"tag_name":"latest"}' +reject_latest_metadata '{"tag_name":"v0.12.0\n"}' +reject_latest_metadata '{"tag_name":"v0.12.0\u0000"}' + +CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \ + SITECTL_PACKAGES="sitectl sitectl-isle" \ + SITECTL_VERSION="latest" \ + SITECTL_PACKAGE_VERSIONS='{"sitectl":"latest","sitectl-isle":"latest"}' \ + bash --noprofile --norc -c ' + set -euo pipefail + source "$1" + STATE_DIR="$2/install-state" + BIN_DIR="$STATE_DIR/bin" + TMP_DIR="$STATE_DIR/tmp" + PACKAGE_STATE_DIR="$STATE_DIR/packages" + ARTIFACT_STATE_DIR="$STATE_DIR/artifacts" + fixture_root="$2" + url_log="$fixture_root/install-urls" + + mkdirs() { + mkdir -p "$BIN_DIR" "$TMP_DIR" "$PACKAGE_STATE_DIR" "$ARTIFACT_STATE_DIR" + } + machine_arch() { + printf "%s\n" x86_64 + } + latest_release_tag() { + test "$1" = "sitectl-isle" + printf "%s\n" v0.12.0 + } + retry_until_success() { + "$@" + } + ln() { + : + } + curl() { + local output="" url="" archive_dir archive_path payload_dir checksum + while (( $# > 0 )); do + case "$1" in + -o) + output="$2" + shift 2 + ;; + http*) + url="$1" + shift + ;; + *) + shift + ;; + esac + done + printf "%s\n" "$url" >>"$url_log" + case "$url" in + */sitectl-isle_Linux_x86_64.tar.gz) + payload_dir="$(mktemp -d "$fixture_root/payload.XXXXXX")" + printf "#!/usr/bin/env bash\n" >"$payload_dir/sitectl-isle" + printf "release documentation\n" >"$payload_dir/README.md" + chmod 0755 "$payload_dir/sitectl-isle" + tar -czf "$output" -C "$payload_dir" sitectl-isle README.md + rm -rf "$payload_dir" + ;; + */checksums.txt) + archive_dir="$(dirname "$output")" + archive_path="$archive_dir/sitectl-isle_Linux_x86_64.tar.gz" + checksum="$(sha256sum "$archive_path")" + checksum="${checksum%% *}" + printf "%s %s\n" "$checksum" "$(basename "$archive_path")" >"$output" + ;; + *) + return 1 + ;; + esac + } + + mkdirs + install_release_package sitectl-isle + test "$(cat "$PACKAGE_STATE_DIR/sitectl-isle.version")" = "v0.12.0" + test "$(wc -l <"$url_log")" -eq 2 + grep -Fxq "https://github.com/libops/sitectl-isle/releases/download/v0.12.0/sitectl-isle_Linux_x86_64.tar.gz" "$url_log" + grep -Fxq "https://github.com/libops/sitectl-isle/releases/download/v0.12.0/checksums.txt" "$url_log" + if grep -Fq "/releases/latest/download" "$url_log"; then + echo "Installer mixed a resolved latest tag with a mutable latest URL" >&2 + exit 1 + fi + ' cloud-compose-sitectl-install "$runtime_script" "$tmp" + +# Package sets are staged as one generation. A later plugin failure cannot +# replace an already-installed core, and a promotion failure restores every +# binary and version/checksum state file. +CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \ + SITECTL_PACKAGES="sitectl sitectl-isle" \ + SITECTL_VERSION="v1.0.0" \ + SITECTL_PACKAGE_VERSIONS='{"sitectl":"v1.0.0","sitectl-isle":"v1.0.0"}' \ + bash --noprofile --norc -c ' + set -euo pipefail + source "$1" + STATE_DIR="$2/generation-state" + BIN_DIR="$STATE_DIR/bin" + TMP_DIR="$STATE_DIR/tmp" + PACKAGE_STATE_DIR="$STATE_DIR/packages" + ARTIFACT_STATE_DIR="$STATE_DIR/artifacts" + PUBLISHED_BIN_DIR="$STATE_DIR/published" + mkdirs() { + mkdir -p "$BIN_DIR" "$TMP_DIR" "$PACKAGE_STATE_DIR" "$ARTIFACT_STATE_DIR" "$PUBLISHED_BIN_DIR" + } + mkdirs + + for package in sitectl sitectl-isle; do + printf "old-%s\n" "$package" >"$BIN_DIR/$package" + chmod 0755 "$BIN_DIR/$package" + printf "v0.9.0\n" >"$PACKAGE_STATE_DIR/$package.version" + sha="$(sha256sum "$BIN_DIR/$package")" + printf "%s\n" "${sha%% *}" >"$PACKAGE_STATE_DIR/$package.sha256" + command ln -s "$BIN_DIR/$package" "$PUBLISHED_BIN_DIR/$package" + done + + install_release_package() { + local package="$1" target="$2" + if [[ "${FAKE_STAGE_FAIL_PACKAGE:-}" == "$package" ]]; then + return 1 + fi + printf "new-%s\n" "$package" >"$target/$package" + chmod 0755 "$target/$package" + RELEASE_PACKAGE_TAG=v1.0.0 + sha="$(sha256sum "$target/$package")" + RELEASE_PACKAGE_SHA256="${sha%% *}" + } + ln() { + if [[ "${FAKE_LINK_FAIL_PACKAGE:-}" != "" && "${!#}" == */"$FAKE_LINK_FAIL_PACKAGE" ]]; then + return 1 + fi + command ln "$@" + } + + if FAKE_STAGE_FAIL_PACKAGE=sitectl-isle install_sitectl_packages; then + echo "staging failure was accepted" >&2 + exit 1 + fi + grep -Fxq old-sitectl "$BIN_DIR/sitectl" + grep -Fxq old-sitectl-isle "$BIN_DIR/sitectl-isle" + grep -Fxq v0.9.0 "$PACKAGE_STATE_DIR/sitectl.version" + + if FAKE_LINK_FAIL_PACKAGE=sitectl-isle install_sitectl_packages; then + echo "promotion failure was accepted" >&2 + exit 1 + fi + grep -Fxq old-sitectl "$BIN_DIR/sitectl" + grep -Fxq old-sitectl-isle "$BIN_DIR/sitectl-isle" + grep -Fxq v0.9.0 "$PACKAGE_STATE_DIR/sitectl.version" + grep -Fxq v0.9.0 "$PACKAGE_STATE_DIR/sitectl-isle.version" + + install_sitectl_packages + grep -Fxq new-sitectl "$BIN_DIR/sitectl" + grep -Fxq new-sitectl-isle "$BIN_DIR/sitectl-isle" + grep -Fxq v1.0.0 "$PACKAGE_STATE_DIR/sitectl.version" + grep -Fxq v1.0.0 "$PACKAGE_STATE_DIR/sitectl-isle.version" + + # Dropping a plugin is part of the same generation transaction. A + # later core-promotion failure restores the removed plugin exactly. + if SITECTL_PACKAGES=sitectl \ + SITECTL_PACKAGE_VERSIONS='"'"'{"sitectl":"v1.0.0"}'"'"' \ + FAKE_LINK_FAIL_PACKAGE=sitectl \ + install_sitectl_packages; then + echo "promotion failure after stale-package removal was accepted" >&2 + exit 1 + fi + grep -Fxq new-sitectl-isle "$BIN_DIR/sitectl-isle" + grep -Fxq v1.0.0 "$PACKAGE_STATE_DIR/sitectl-isle.version" + test "$(readlink "$PUBLISHED_BIN_DIR/sitectl-isle")" = "$BIN_DIR/sitectl-isle" + + SITECTL_PACKAGES=sitectl \ + SITECTL_PACKAGE_VERSIONS='"'"'{"sitectl":"v1.0.0"}'"'"' \ + install_sitectl_packages + test ! -e "$BIN_DIR/sitectl-isle" + test ! -e "$PACKAGE_STATE_DIR/sitectl-isle.version" + test ! -e "$PACKAGE_STATE_DIR/sitectl-isle.sha256" + test ! -e "$PUBLISHED_BIN_DIR/sitectl-isle" + grep -Fxq new-sitectl "$BIN_DIR/sitectl" + + # The managed namespace is fail-closed: an untracked command without + # matching version/checksum state is reported, never deleted. + printf "operator-owned\n" >"$BIN_DIR/sitectl-local" + chmod 0755 "$BIN_DIR/sitectl-local" + command ln -s "$BIN_DIR/sitectl-local" "$PUBLISHED_BIN_DIR/sitectl-local" + if SITECTL_PACKAGES=sitectl \ + SITECTL_PACKAGE_VERSIONS='"'"'{"sitectl":"v1.0.0"}'"'"' \ + install_sitectl_packages; then + echo "unproven stale package was removed" >&2 + exit 1 + fi + grep -Fxq operator-owned "$BIN_DIR/sitectl-local" + test "$(readlink "$PUBLISHED_BIN_DIR/sitectl-local")" = "$BIN_DIR/sitectl-local" + ' cloud-compose-sitectl-generation "$runtime_script" "$tmp" + +echo "sitectl package-version contract passed" diff --git a/ci/source-trust-contract.sh b/ci/source-trust-contract.sh new file mode 100644 index 0000000..8dede1e --- /dev/null +++ b/ci/source-trust-contract.sh @@ -0,0 +1,297 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +fail() { + echo "source trust contract: $*" >&2 + exit 1 +} + +assert_head() { + local repo="$1" expected="$2" + local actual + + actual="$(git -C "$repo" rev-parse --verify HEAD)" + [[ "$actual" == "$expected" ]] || fail "expected $repo at $expected, found $actual" +} + +write_project() { + local name="$1" ref="$2" checkout="$3" + + jq -n \ + --arg name "$name" \ + --arg repo "$remote" \ + --arg ref "$ref" \ + --arg checkout "$checkout" \ + '{($name): { + docker_compose_repo: $repo, + docker_compose_branch: $ref, + project_dir: $checkout, + compose_project_name: $name + }}' > "$projects_file" +} + +export HOME="$tmp/home" +mkdir -p "$HOME" + +remote="$tmp/remote.git" +source_repo="$tmp/source" +git init --bare --initial-branch=main "$remote" >/dev/null +git init --initial-branch=main "$source_repo" >/dev/null +git -C "$source_repo" config user.name "Cloud Compose Contract" +git -C "$source_repo" config user.email "cloud-compose-contract@example.invalid" +printf 'one\n' > "$source_repo/version.txt" +git -C "$source_repo" add version.txt +git -C "$source_repo" commit -m one >/dev/null +commit_one="$(git -C "$source_repo" rev-parse HEAD)" +git -C "$source_repo" tag release-one +printf 'two\n' > "$source_repo/version.txt" +git -C "$source_repo" commit -am two >/dev/null +git -C "$source_repo" remote add origin "$remote" +git -C "$source_repo" push --set-upstream origin main --tags >/dev/null + +projects_file="$tmp/compose-projects.json" +export COMPOSE_PROJECTS_FILE="$projects_file" +export COMPOSE_APPS_ENV_DIR="$tmp/apps" +export COMPOSE_APPS_STATE_DIR="$tmp/state" +export CLOUD_COMPOSE_DATA_ROOT="$tmp" + +retry_until_success() { + "$@" +} + +# shellcheck disable=SC1091 +source "$repo_root/rootfs/home/cloud-compose/compose-apps.sh" + +pinned_checkout="$tmp/pinned" +write_project pinned "$commit_one" "$pinned_checkout" +clone_or_update_compose_app pinned +assert_head "$pinned_checkout" "$commit_one" +if git -C "$pinned_checkout" symbolic-ref -q HEAD >/dev/null; then + fail "full commit checkout did not use detached HEAD" +fi +[[ ! -e "$COMPOSE_APPS_STATE_DIR/pinned.deployed-head" ]] || \ + fail "checkout was recorded as deployed before its lifecycle succeeded" +run_compose_app_lifecycle pinned init +[[ "$(<"$COMPOSE_APPS_STATE_DIR/pinned.deployed-head")" == "$commit_one" ]] || \ + fail "pinned deployed HEAD was not recorded" + +printf 'three\n' > "$source_repo/version.txt" +git -C "$source_repo" commit -am three >/dev/null +commit_three="$(git -C "$source_repo" rev-parse HEAD)" +git -C "$source_repo" push origin main >/dev/null +clone_or_update_compose_app pinned +assert_head "$pinned_checkout" "$commit_one" + +branch_checkout="$tmp/branch" +write_project branch main "$branch_checkout" +clone_or_update_compose_app branch +assert_head "$branch_checkout" "$commit_three" +run_compose_app_lifecycle branch init +printf 'four\n' > "$source_repo/version.txt" +git -C "$source_repo" commit -am four >/dev/null +commit_four="$(git -C "$source_repo" rev-parse HEAD)" +git -C "$source_repo" push origin main >/dev/null +clone_or_update_compose_app branch +assert_head "$branch_checkout" "$commit_four" +[[ "$(<"$COMPOSE_APPS_STATE_DIR/branch.deployed-head")" == "$commit_three" ]] || \ + fail "source update changed deployed state before lifecycle success" +run_compose_app_lifecycle branch init +[[ "$(<"$COMPOSE_APPS_STATE_DIR/branch.deployed-head")" == "$commit_four" ]] || \ + fail "moving branch deployed HEAD was not recorded" + +# A rollout may deliberately deploy a feature or provider-specific ref that is +# not an ancestor of the configured baseline branch. Ordinary service starts +# must preserve that deployed revision, and a later rollout must remain able to +# move back to the baseline instead of failing before its rollout command runs. +git -C "$source_repo" checkout -b feature >/dev/null +printf 'feature\n' >"$source_repo/feature.txt" +git -C "$source_repo" add feature.txt +git -C "$source_repo" commit -m feature >/dev/null +feature_commit="$(git -C "$source_repo" rev-parse HEAD)" +git -C "$source_repo" push origin feature >/dev/null +git -C "$source_repo" checkout main >/dev/null +jq '.branch.rollout_commands = [ + "git fetch -- origin feature", + "git checkout --detach FETCH_HEAD" + ] | .branch.up_commands = ["true"]' \ + "$projects_file" >"$projects_file.tmp" +mv "$projects_file.tmp" "$projects_file" +run_compose_app_lifecycle branch rollout +assert_head "$branch_checkout" "$feature_commit" +[[ "$(<"$COMPOSE_APPS_STATE_DIR/branch.deployed-head")" == "$feature_commit" ]] || \ + fail "feature rollout HEAD was not recorded" +run_compose_app_lifecycle branch up +assert_head "$branch_checkout" "$feature_commit" +jq '.branch.rollout_commands = [ + "git fetch -- origin main", + "git checkout --detach FETCH_HEAD" + ]' "$projects_file" >"$projects_file.tmp" +mv "$projects_file.tmp" "$projects_file" +run_compose_app_lifecycle branch rollout +assert_head "$branch_checkout" "$commit_four" + +# Full bootstrap/source preparation may restore the configured baseline after +# a recorded rollout. It must not grant the same reset authority to an +# unrecorded local-ahead commit. +jq '.branch.rollout_commands = [ + "git fetch -- origin feature", + "git checkout --detach FETCH_HEAD" + ]' "$projects_file" >"$projects_file.tmp" +mv "$projects_file.tmp" "$projects_file" +run_compose_app_lifecycle branch rollout +assert_head "$branch_checkout" "$feature_commit" +clone_or_update_compose_app branch +assert_head "$branch_checkout" "$commit_four" + +# A local commit must never be treated as an already-up-to-date moving branch. +# Deployment state must converge exactly to the fetched remote object. +git -C "$branch_checkout" config user.name "Cloud Compose Contract" +git -C "$branch_checkout" config user.email "cloud-compose-contract@example.invalid" +printf 'local-ahead\n' >"$branch_checkout/local-only.txt" +git -C "$branch_checkout" add local-only.txt +git -C "$branch_checkout" commit -m local-ahead >/dev/null +local_ahead_commit="$(git -C "$branch_checkout" rev-parse HEAD)" +if clone_or_update_compose_app branch >/dev/null 2>&1; then + fail "moving branch accepted a local-ahead checkout" +fi +assert_head "$branch_checkout" "$local_ahead_commit" + +tag_checkout="$tmp/tag" +write_project tag release-one "$tag_checkout" +clone_or_update_compose_app tag +assert_head "$tag_checkout" "$commit_one" + +printf 'dirty-tracked\n' >"$tag_checkout/version.txt" +if clone_or_update_compose_app tag >/dev/null 2>&1; then + fail "pinned checkout accepted a tracked worktree modification" +fi +git -C "$tag_checkout" restore -- version.txt +printf 'staged\n' >"$tag_checkout/staged.txt" +git -C "$tag_checkout" add staged.txt +if clone_or_update_compose_app tag >/dev/null 2>&1; then + fail "pinned checkout accepted a staged worktree modification" +fi +git -C "$tag_checkout" restore --staged -- staged.txt +rm -f -- "$tag_checkout/staged.txt" +printf 'services: {}\n' >"$tag_checkout/compose.override.yaml" +if clone_or_update_compose_app tag >/dev/null 2>&1; then + fail "pinned checkout accepted an untracked Compose override" +fi +rm -f -- "$tag_checkout/compose.override.yaml" +clone_or_update_compose_app tag +assert_head "$tag_checkout" "$commit_one" +run_compose_app_lifecycle tag init +[[ "$(<"$COMPOSE_APPS_STATE_DIR/tag.deployed-head")" == "$commit_one" ]] || \ + fail "tag deployed HEAD was not recorded" +clone_or_update_compose_app tag +assert_head "$tag_checkout" "$commit_one" + +origin_checkout="$tmp/origin-mismatch" +write_project origin main "$origin_checkout" +clone_or_update_compose_app origin +git -C "$origin_checkout" remote set-url origin "$tmp/not-the-configured-origin.git" +if clone_or_update_compose_app origin >/dev/null 2>&1; then + fail "existing checkout accepted an origin that did not match the configured repository" +fi + +failure_checkout="$tmp/lifecycle-failure" +write_project failure "$commit_one" "$failure_checkout" +jq '.failure.init_commands = ["false"]' "$projects_file" >"$projects_file.tmp" +mv "$projects_file.tmp" "$projects_file" +if run_compose_app_lifecycle failure init >/dev/null 2>&1; then + fail "failing lifecycle command unexpectedly succeeded" +fi +[[ ! -e "$COMPOSE_APPS_STATE_DIR/failure.deployed-head" ]] || \ + fail "failing lifecycle was recorded as deployed" + +unsafe_ref_checkout="$tmp/unsafe-ref" +write_project unsafe-ref '--upload-pack=/tmp/not-a-command' "$unsafe_ref_checkout" +if clone_or_update_compose_app unsafe-ref >/dev/null 2>&1; then + fail "unsafe Git ref was accepted as a fetch option" +fi +[[ ! -e "$unsafe_ref_checkout" ]] || fail "unsafe Git ref mutated the checkout path" + +unsafe_repo_checkout="$tmp/unsafe-repo" +write_project unsafe-repo main "$unsafe_repo_checkout" +jq '."unsafe-repo".docker_compose_repo = "--upload-pack=/tmp/not-a-command"' \ + "$projects_file" >"$projects_file.tmp" +mv "$projects_file.tmp" "$projects_file" +if clone_or_update_compose_app unsafe-repo >/dev/null 2>&1; then + fail "unsafe repository location was accepted as a Git option" +fi +[[ ! -e "$unsafe_repo_checkout" ]] || fail "unsafe repository location mutated the checkout path" + +coreos_installer="$repo_root/rootfs/home/cloud-compose/install-dependencies-coreos.sh" +grep -Fq 'for package in git jq make openssl; do' "$coreos_installer" || \ + fail "CoreOS base dependency set changed unexpectedly" +if grep -Eq 'packages\.libops\.io|sitectl\.repo|SITECTL_PACKAGES' "$coreos_installer"; then + fail "CoreOS bootstrap still has a redundant sitectl RPM ownership path" +fi + +grep -Eq '^[[:space:]]+openssl([[:space:]\\]|$)' \ + "$repo_root/rootfs/home/cloud-compose/install-dependencies-debian.sh" || \ + fail "Debian bootstrap does not install the explicit openssl runtime dependency" +grep -Fq 'command -v openssl' "$repo_root/rootfs/home/cloud-compose/install-dependencies-cos.sh" || \ + fail "COS bootstrap does not fail closed when openssl is unavailable" +grep -Fq -- '- openssl' "$repo_root/ansible/roles/cloud_compose/tasks/main.yml" || \ + fail "Ansible bootstrap does not install the explicit openssl runtime dependency" +grep -Fq -- '- openssl' "$repo_root/salt/cloud-compose/init.sls" || \ + fail "Salt bootstrap does not install the explicit openssl runtime dependency" + +host_conf="$repo_root/rootfs/home/cloud-compose/host-conf.sh" +dependencies_line="$(grep -nF 'bash /home/cloud-compose/install-dependencies.sh' "$host_conf" | cut -d: -f1)" +managed_runtime_line="$(grep -nF 'bash /home/cloud-compose/libops-managed-runtime.sh install-tools' "$host_conf" | cut -d: -f1)" +[[ -n "$dependencies_line" && -n "$managed_runtime_line" && "$dependencies_line" -lt "$managed_runtime_line" ]] || \ + fail "verified managed-runtime package installation does not follow OS dependency installation" + +release_workflow="$repo_root/.github/workflows/github-release.yaml" +grep -Fq 'libops/.github/.github/workflows/bump-release.yaml@8dfaf9c854df71d9bbffde48c5676ff07c543c51' "$release_workflow" || \ + fail "write-capable release workflow is not pinned to the approved commit" + +gcp_module="$repo_root/modules/gcp/main.tf" +grep -Eq 'source = "https://github\.com/libops/terraform-cloudrun-v2/archive/[0-9a-f]{40}\.zip//terraform-cloudrun-v2-[0-9a-f]{40}"' \ + "$gcp_module" || fail "GCP power-button Terraform dependency is not pinned to a full commit" +if grep -Eq 'source = "https://github\.com/.*/archive/(refs/)?(heads|tags)/' "$gcp_module"; then + fail "GCP module still consumes a mutable or unverified GitHub archive source" +fi + +linux_runtime="$repo_root/modules/linux-vm-runtime/main.tf" +grep -Fq "archive_url_b64='\${base64encode(local.rootfs_archive_url)}'" "$linux_runtime" || \ + fail "Linux rootfs archive URL is not rendered as base64 shell data" +if grep -Fq 'archive_url=${jsonencode(local.rootfs_archive_url)}' "$linux_runtime"; then + fail "Linux rootfs archive URL is still rendered as executable shell syntax" +fi +grep -Fq 'archive_additional_rootfs_commands' "$linux_runtime" || \ + fail "Additional rootfs content is not reapplied after archive extraction" +for runtime_module in "$linux_runtime" "$gcp_module"; do + grep -Fq -- "curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2" "$runtime_module" || \ + fail "rootfs archive download is not restricted to HTTPS with TLS 1.2 or newer in $runtime_module" + grep -Fq -- '--connect-timeout 10 --max-time 300 -o "$tmp/rootfs.tar.gz" -- "$archive_url"' "$runtime_module" || \ + fail "rootfs archive download is not bounded or separated from curl options in $runtime_module" + grep -Fq 'rootfs_dir="$(find "$tmp" -mindepth 1 -maxdepth 3 -type d -name rootfs -print -quit)"' "$runtime_module" || \ + fail "rootfs archive discovery does not accept the documented depth range in $runtime_module" +done + +rollout_installer="$repo_root/rootfs/home/cloud-compose/deploy-rollout.sh" +grep -Fq 'ROLLOUT_DOWNLOAD_URL must be an HTTPS URL without whitespace' "$rollout_installer" || \ + fail "rollout installer does not require HTTPS" +grep -Fq -- "curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2" "$rollout_installer" || \ + fail "rollout download is not restricted to HTTPS with TLS 1.2 or newer" +grep -Fq -- '--connect-timeout 10 --max-time 300 -o "$tmp" -- "$ROLLOUT_DOWNLOAD_URL"' "$rollout_installer" || \ + fail "rollout download is not bounded or separated from curl options" +grep -Fq 'mv -f -- "$install_tmp" /usr/local/bin/cloud-compose-rollout' "$rollout_installer" || \ + fail "rollout binary is not promoted atomically" + +for smoke_script in \ + "$repo_root/ci/config-management-smoke.sh" \ + "$repo_root/ci/config-management-cloud-smoke.sh"; do + grep -Eq 'CONFIG_MANAGEMENT_IMAGE_DEFAULT="python:[^@"]+@sha256:[0-9a-f]{64}"' "$smoke_script" || \ + fail "configuration-management test image is not digest pinned in $smoke_script" +done + +echo "Source trust contracts passed" diff --git a/ci/systemd-contract.sh b/ci/systemd-contract.sh new file mode 100755 index 0000000..29e6a96 --- /dev/null +++ b/ci/systemd-contract.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +unit_dir="$repo_root/rootfs/etc/systemd/system" + +fail() { + echo "systemd contract: $*" >&2 + exit 1 +} + +assert_contains() { + local file="$1" value="$2" + grep -Fq -- "$value" "$file" || fail "$file does not contain: $value" +} + +[[ ! -e "$unit_dir/cron.service" && ! -e "$unit_dir/cron.timer" ]] || \ + fail "generic cron units still shadow host cron" +[[ ! -e "$unit_dir/vault-agent.service" ]] || fail "generic Vault Agent unit still shadows the host agent" +[[ ! -e "$unit_dir/internal-services.service" && ! -e "$unit_dir/internal-services.timer" ]] || \ + fail "generic internal-services units still shadow host units" +[[ -f "$unit_dir/cloud-compose-docker-prune.service" && -f "$unit_dir/cloud-compose-docker-prune.timer" ]] || \ + fail "dedicated Docker prune units are missing" +[[ -f "$unit_dir/cloud-compose-vault-agent.service" ]] || fail "namespaced Vault Agent unit is missing" +[[ -f "$unit_dir/cloud-compose-overlay.service" ]] || fail "Docker overlay mount unit is missing" +[[ -f "$unit_dir/cloud-compose-internal-services.service" && -f "$unit_dir/cloud-compose-internal-services.timer" ]] || \ + fail "namespaced internal-services units are missing" + +assert_contains "$unit_dir/cloud-compose.service" 'Requires=docker.service cloud-compose-metadata-firewall.service' +assert_contains "$unit_dir/cloud-compose.service" 'RequiresMountsFor=/mnt/disks/data /mnt/disks/volumes /mnt/disks/data/docker/volumes' +assert_contains "$unit_dir/cloud-compose.service" 'After=network-online.target docker.service cloud-compose-metadata-firewall.service' +assert_contains "$unit_dir/cloud-compose-internal-services.service" 'Requires=cloud-compose.service cloud-compose-metadata-firewall.service' +assert_contains "$unit_dir/cloud-compose.service" 'TimeoutStartSec=45min' +assert_contains "$unit_dir/cloud-compose-mariadb-backup.service" 'TimeoutStartSec=12h' +if grep -Fq 'Wants=cloud-compose.service' "$unit_dir/cloud-compose-mariadb-backup.service"; then + fail "backup service starts an intentionally inactive application" +fi +assert_contains "$unit_dir/libops-managed-runtime.service" 'TimeoutStartSec=1h' +assert_contains "$unit_dir/cloud-compose-key-rotation.service" 'TimeoutStartSec=1h' +assert_contains "$unit_dir/cloud-compose-key-rotation.service" 'RequiresMountsFor=/mnt/disks/data' +assert_contains "$unit_dir/cloud-compose-vault-agent.service" 'RequiresMountsFor=/mnt/disks/data' +assert_contains "$unit_dir/cloud-compose-rollout.service" 'RequiresMountsFor=/mnt/disks/data' +assert_contains "$unit_dir/libops-managed-runtime.service" 'RequiresMountsFor=/mnt/disks/data /mnt/disks/volumes /mnt/disks/data/docker/volumes' + +docker_mount_dropin="$unit_dir/docker.service.d/cloud-compose-mounts.conf" +[[ -f "$docker_mount_dropin" ]] || fail "Docker persistent-mount drop-in is missing" +assert_contains "$docker_mount_dropin" 'RequiresMountsFor=/mnt/disks/data /mnt/disks/volumes /mnt/disks/data/docker/volumes' +assert_contains "$docker_mount_dropin" 'Requires=cloud-compose-overlay.service' +docker_metadata_dropin="$unit_dir/docker.service.d/cloud-compose-metadata-firewall.conf" +metadata_pre_unit="$unit_dir/cloud-compose-metadata-firewall-pre.service" +[[ -f "$docker_metadata_dropin" && -f "$metadata_pre_unit" ]] || \ + fail "pre-Docker metadata firewall units are missing" +assert_contains "$docker_metadata_dropin" 'Requires=cloud-compose-metadata-firewall-pre.service' +assert_contains "$docker_metadata_dropin" 'After=cloud-compose-metadata-firewall-pre.service' +assert_contains "$metadata_pre_unit" 'Before=docker.service' +assert_contains "$metadata_pre_unit" 'WantedBy=multi-user.target' +assert_contains "$metadata_pre_unit" 'ExecStart=/bin/bash /home/cloud-compose/configure-metadata-firewall.sh pre-docker' +assert_contains "$unit_dir/cloud-compose-overlay.service" 'Before=docker.service cloud-compose.service' +assert_contains "$unit_dir/cloud-compose-overlay.service" 'ExecStart=/bin/bash /home/cloud-compose/mount-overlays.sh' + +run_script="$repo_root/rootfs/home/cloud-compose/run.sh" +assert_contains "$run_script" 'systemctl enable --now cloud-compose.service' +assert_contains "$run_script" 'systemd-tmpfiles is required to prepare the cloud-compose lifecycle lock' +assert_contains "$run_script" 'systemd-tmpfiles --create /etc/tmpfiles.d/cloud-compose.conf' +assert_contains "$run_script" 'systemctl restart cloud-compose-overlay.service' +assert_contains "$run_script" 'systemctl enable --now cloud-compose-internal-services.timer' +assert_contains "$run_script" 'systemctl disable --now cloud-compose-internal-services.timer cloud-compose-internal-services.service' +assert_contains "$run_script" 'systemctl enable --now libops-managed-runtime.timer' +assert_contains "$run_script" 'systemctl disable --now libops-managed-runtime.timer libops-managed-runtime.service' +assert_contains "$run_script" 'systemctl enable --now cloud-compose-docker-prune.timer' +assert_contains "$run_script" 'systemctl disable --now cloud-compose-docker-prune.timer cloud-compose-docker-prune.service' +assert_contains "$run_script" 'systemctl enable --now cloud-compose-mariadb-backup.timer' + +migration_script="$repo_root/rootfs/home/cloud-compose/migrate-legacy-systemd-units.sh" +migration_tmp="$(mktemp -d)" +trap 'rm -rf "$migration_tmp"' EXIT +mkdir -p "$migration_tmp/units" "$migration_tmp/bin" +cat >"$migration_tmp/units/internal-services.service" <<'EOF' +[Unit] +Description=Internal Services (Ping, Metrics, Power Management) +[Service] +WorkingDirectory=/mnt/disks/data/libops-internal +EOF +cat >"$migration_tmp/units/internal-services.timer" <<'EOF' +[Unit] +Description=Delay Internal Services until 20m after initial boot +[Timer] +OnBootSec=20min +Unit=internal-services.service +EOF +cat >"$migration_tmp/units/vault-agent.service" <<'EOF' +[Unit] +Description=Host-owned Vault Agent +[Service] +ExecStart=/usr/bin/vault agent -config=/etc/vault-agent.hcl +EOF +cat >"$migration_tmp/bin/systemctl" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"${MIGRATION_SYSTEMCTL_LOG:?}" +EOF +chmod +x "$migration_tmp/bin/systemctl" +: >"$migration_tmp/systemctl.log" +PATH="$migration_tmp/bin:$PATH" \ + CLOUD_COMPOSE_SYSTEMD_UNIT_DIR="$migration_tmp/units" \ + MIGRATION_SYSTEMCTL_LOG="$migration_tmp/systemctl.log" \ + bash "$migration_script" +[[ ! -e "$migration_tmp/units/internal-services.service" && ! -e "$migration_tmp/units/internal-services.timer" ]] || \ + fail "legacy Cloud Compose internal-services units were not migrated" +[[ -e "$migration_tmp/units/vault-agent.service" ]] || fail "migration removed an unrelated host Vault Agent" +grep -Fq 'disable --now internal-services.service' "$migration_tmp/systemctl.log" || \ + fail "legacy internal-services service was not stopped and disabled" + +if rg -n '(^|[^-])vault-agent\.service|\bcron\.(service|timer)|/cron\.sh' \ + "$repo_root/rootfs" \ + --glob '!**/migrate-legacy-systemd-units.sh' >/dev/null; then + fail "rootfs still references a generic cron or Vault Agent unit" +fi +if rg -n '(^|[^-])internal-services\.(service|timer)' \ + "$repo_root/rootfs" \ + --glob '!**/migrate-legacy-systemd-units.sh' >/dev/null; then + fail "rootfs still references generic internal-services units" +fi + +# systemd-analyze is absent from some minimal CI images. When present, reject +# parser/ordering errors while allowing only missing host executable/dependency +# diagnostics caused by verifying an unpacked rootfs. +if command -v systemd-analyze >/dev/null 2>&1; then + verify_output="$(mktemp)" + if ! systemd-analyze verify "$unit_dir"/*.service "$unit_dir"/*.timer 2>"$verify_output"; then + unexpected="$(grep -Ev \ + 'Command .* is not executable|Unit .* not found|Failed to add dependency on .*: Invalid argument' \ + "$verify_output" || true)" + if [[ -n "$unexpected" ]]; then + cat "$verify_output" >&2 + rm -f "$verify_output" + fail "systemd-analyze rejected the Cloud Compose units" + fi + fi + rm -f "$verify_output" +fi + +echo "Systemd contract passed" diff --git a/ci/template-version-contract.sh b/ci/template-version-contract.sh new file mode 100755 index 0000000..c4a2dda --- /dev/null +++ b/ci/template-version-contract.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +registry="$repo_root/templates/apps.json" + +command -v jq >/dev/null 2>&1 || { + echo "Missing required command: jq" >&2 + exit 1 +} + +jq -e ' + .default.package_versions == { + "sitectl": "v0.39.0" + } and + .templates.archivesspace.package_versions == { + "sitectl": "v0.39.0", + "sitectl-archivesspace": "v0.6.0" + } and + .templates.drupal.package_versions == { + "sitectl": "v0.39.0", + "sitectl-drupal": "v0.11.0" + } and + .templates.isle.package_versions == { + "sitectl": "v0.39.0", + "sitectl-drupal": "v0.11.0", + "sitectl-isle": "v0.18.0" + } and + .templates.ojs.package_versions == { + "sitectl": "v0.39.0", + "sitectl-ojs": "v0.6.0" + } and + .templates["omeka-classic"].package_versions == { + "sitectl": "v0.39.0", + "sitectl-omeka-classic": "v0.6.0" + } and + .templates["omeka-s"].package_versions == { + "sitectl": "v0.39.0", + "sitectl-omeka-s": "v0.6.0" + } and + .templates.wp.package_versions == { + "sitectl": "v0.39.0", + "sitectl-wp": "v0.5.0" + } and + all([.default, .templates[]][]; (.packages | sort) == (.package_versions | keys | sort)) +' "$registry" >/dev/null + +for entrypoint in \ + "$repo_root/main.tf" \ + "$repo_root/providers/do/main.tf" \ + "$repo_root/providers/gcp/main.tf" \ + "$repo_root/providers/linode/main.tf"; do + grep -Fq 'if contains(keys(local.template.package_versions), package)' "$entrypoint" || { + echo "Template package versions are not filtered in ${entrypoint#"$repo_root"/}" >&2 + exit 1 + } + grep -Fq 'package_versions = merge(local.template_sitectl_package_versions, local.input_sitectl.package_versions)' "$entrypoint" || { + echo "Explicit package versions do not override template defaults in ${entrypoint#"$repo_root"/}" >&2 + exit 1 + } + grep -Fq 'local.input_sitectl.packages == null ? local.template.packages : local.input_sitectl.packages' "$entrypoint" || { + echo "Template package omission is not distinguished from an explicit package set in ${entrypoint#"$repo_root"/}" >&2 + exit 1 + } +done + +echo "Template sitectl package-version contract passed" diff --git a/ci/terraform-validate.sh b/ci/terraform-validate.sh index 7a53b88..da6c15d 100644 --- a/ci/terraform-validate.sh +++ b/ci/terraform-validate.sh @@ -27,7 +27,7 @@ safe_name() { validate_root() { local root="$1" data_root="$2" - local rel data_dir lockfile created_lock init_status validate_status + local rel data_dir lockfile created_lock init_status validate_status test_status local -a init_args rel="${root#"$repo_root"/}" @@ -66,6 +66,11 @@ validate_root() { TF_DATA_DIR="$data_dir" terraform -chdir="$root" validate -no-color || validate_status=$? fi + test_status=0 + if [[ "$init_status" -eq 0 && "$validate_status" -eq 0 ]] && find "$root" -maxdepth 1 -name '*.tftest.hcl' -print -quit | grep -q .; then + TF_DATA_DIR="$data_dir" terraform -chdir="$root" test -no-color || test_status=$? + fi + if [[ "$created_lock" == "true" ]]; then rm -f "$lockfile" fi @@ -73,7 +78,10 @@ validate_root() { if [[ "$init_status" -ne 0 ]]; then return "$init_status" fi - return "$validate_status" + if [[ "$validate_status" -ne 0 ]]; then + return "$validate_status" + fi + return "$test_status" } main() { diff --git a/ci/vault-runtime-contract.sh b/ci/vault-runtime-contract.sh new file mode 100644 index 0000000..78da7d2 --- /dev/null +++ b/ci/vault-runtime-contract.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +readiness_script="$repo_root/rootfs/home/cloud-compose/vault-agent-readiness.sh" +assert_script="$repo_root/rootfs/home/cloud-compose/assert-vault-ready.sh" +tmp="$(mktemp -d)" +trap 'rm -rf -- "$tmp"' EXIT + +fail() { + echo "Vault runtime contract: $*" >&2 + exit 1 +} + +mkdir -p "$tmp/data" "$tmp/run" "$tmp/bin" +chmod 0775 "$tmp/data" +: >"$tmp/profile.sh" + +run_readiness() { + local action="$1" token_path="$2" + + CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \ + TEST_SAFE_DIR="$tmp/data/vault" \ + TEST_READY_MARKER="$tmp/run/vault.ready" \ + VAULT_AGENT_TOKEN_PATH="$token_path" \ + READINESS_SCRIPT="$readiness_script" \ + bash --noprofile --norc -c ' + set -euo pipefail + source "$READINESS_SCRIPT" + vault_safe_dir() { printf "%s\n" "$TEST_SAFE_DIR"; } + READY_MARKER="$TEST_READY_MARKER" + install() { + local args=() + while (($# > 0)); do + case "$1" in + -o | -g) shift 2 ;; + *) args+=("$1"); shift ;; + esac + done + command install "${args[@]}" + } + main "$1" + ' vault-readiness "$action" +} + +safe_token="$tmp/data/vault/token" +run_readiness prepare "$safe_token" +[[ -d "$tmp/data/vault" ]] || fail "dedicated Vault directory was not created" +[[ "$(stat -c %a "$tmp/data")" == "775" ]] || fail "Vault preparation chmodded a broad parent" +[[ "$(stat -c %a "$tmp/data/vault")" == "700" ]] || fail "dedicated Vault directory is not private" + +printf 'vault-token\n' >"$safe_token" +VAULT_AGENT_START_TIMEOUT_SECONDS=1 run_readiness wait "$safe_token" +[[ -f "$tmp/run/vault.ready" ]] || fail "Vault readiness marker was not published" +run_readiness prepare "$safe_token" +[[ ! -e "$safe_token" ]] || fail "Vault restart preparation retained a stale token" +[[ ! -e "$tmp/run/vault.ready" ]] || fail "Vault restart preparation retained stale readiness" +if VAULT_AGENT_START_TIMEOUT_SECONDS=1 run_readiness wait "$safe_token" >/dev/null 2>&1; then + fail "Vault restart published readiness without a fresh token" +fi +printf 'fresh-vault-token\n' >"$safe_token" +VAULT_AGENT_START_TIMEOUT_SECONDS=1 run_readiness wait "$safe_token" +run_readiness clear "$safe_token" +[[ ! -e "$tmp/run/vault.ready" ]] || fail "Vault readiness marker was not cleared" + +for unsafe_token in \ + "$tmp/data/token" \ + "$tmp/data/vault/nested/token" \ + "$tmp/data/vault/../token" \ + "$tmp/data/vault/token/name"; do + if run_readiness prepare "$unsafe_token" >/dev/null 2>&1; then + fail "unsafe Vault token path was accepted: $unsafe_token" + fi +done + +rm -rf "$tmp/data/vault" +ln -s "$tmp/run" "$tmp/data/vault" +if run_readiness prepare "$safe_token" >/dev/null 2>&1; then + fail "symbolic-link Vault state directory was accepted" +fi +rm -f "$tmp/data/vault" +mkdir -m 0700 "$tmp/data/vault" + +cat >"$tmp/bin/systemctl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf 'systemctl\n' >>"${SYSTEMCTL_LOG:?}" +[[ "${FAKE_VAULT_ACTIVE:-true}" == "true" ]] +EOF +chmod +x "$tmp/bin/systemctl" +systemctl_log="$tmp/systemctl.log" +: >"$systemctl_log" + +printf 'export VAULT_AGENT_ENABLED=false\n' >"$tmp/profile.sh" +CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \ +VAULT_AGENT_READY_MARKER="$tmp/run/vault.ready" \ +SYSTEMCTL_LOG="$systemctl_log" PATH="$tmp/bin:$PATH" \ + bash "$assert_script" +[[ ! -s "$systemctl_log" ]] || fail "disabled Vault readiness contacted systemd" + +printf 'export VAULT_AGENT_ENABLED=invalid\n' >"$tmp/profile.sh" +if CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \ + VAULT_AGENT_READY_MARKER="$tmp/run/vault.ready" \ + SYSTEMCTL_LOG="$systemctl_log" PATH="$tmp/bin:$PATH" \ + bash "$assert_script" >/dev/null 2>&1; then + fail "invalid explicit Vault enablement was treated as disabled" +fi + +printf 'export VAULT_AGENT_ENABLED=true\n' >"$tmp/profile.sh" +if CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \ + VAULT_AGENT_READY_MARKER="$tmp/run/vault.ready" \ + SYSTEMCTL_LOG="$systemctl_log" PATH="$tmp/bin:$PATH" \ + bash "$assert_script" >/dev/null 2>&1; then + fail "enabled Vault accepted a missing readiness marker" +fi +: >"$tmp/run/vault.ready" +CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \ +VAULT_AGENT_READY_MARKER="$tmp/run/vault.ready" \ +SYSTEMCTL_LOG="$systemctl_log" PATH="$tmp/bin:$PATH" \ + bash "$assert_script" +if CLOUD_COMPOSE_PROFILE_PATH="$tmp/profile.sh" \ + VAULT_AGENT_READY_MARKER="$tmp/run/vault.ready" \ + SYSTEMCTL_LOG="$systemctl_log" FAKE_VAULT_ACTIVE=false PATH="$tmp/bin:$PATH" \ + bash "$assert_script" >/dev/null 2>&1; then + fail "enabled Vault accepted an inactive agent service" +fi + +vault_init="$repo_root/rootfs/home/cloud-compose/vault-agent-init.sh" +grep -Fq 'Vault Agent is enabled but its config is missing or unsafe' "$vault_init" || \ + fail "enabled Vault does not fail closed on a missing config" +grep -Fq 'Vault Agent is enabled but the Vault binary is not installed' "$vault_init" || \ + fail "enabled Vault does not fail closed on a missing binary" +if grep -Fq 'vault-agent-init.sh || true' "$repo_root/rootfs/home/cloud-compose/run.sh"; then + fail "bootstrap still masks explicit Vault initialization failure" +fi +vault_line="$(grep -nF 'bash /home/cloud-compose/vault-agent-init.sh' "$repo_root/rootfs/home/cloud-compose/run.sh" | cut -d: -f1)" +source_line="$(grep -nF 'run_as_cloud_compose bash /home/cloud-compose/prepare-app-sources.sh' "$repo_root/rootfs/home/cloud-compose/run.sh" | cut -d: -f1)" +rotation_line="$(grep -nF 'bash /home/cloud-compose/rotate-keys-daily.sh' "$repo_root/rootfs/home/cloud-compose/run.sh" | cut -d: -f1)" +init_line="$(grep -nF 'run_as_cloud_compose bash /home/cloud-compose/app-init.sh' "$repo_root/rootfs/home/cloud-compose/run.sh" | cut -d: -f1)" +app_line="$(grep -nF 'systemctl enable --now cloud-compose.service' "$repo_root/rootfs/home/cloud-compose/run.sh" | cut -d: -f1)" +[[ -n "$source_line" && -n "$rotation_line" && -n "$vault_line" && -n "$init_line" && -n "$app_line" ]] || \ + fail "bootstrap is missing a source, rotation, Vault, init, or app-service phase" +(( source_line < rotation_line && rotation_line < vault_line && vault_line < init_line && init_line < app_line )) || \ + fail "bootstrap must order source preparation -> rotation -> Vault readiness -> app lifecycle -> app service" +if rg -n 'run_compose_app_lifecycle|scaffold_compose_app_defaults|configure_sitectl_app_features' \ + "$repo_root/rootfs/home/cloud-compose/prepare-app-sources.sh" >/dev/null; then + fail "source preparation executes application lifecycle work before Vault readiness" +fi +if grep -Fq 'clone_or_update_compose_app' "$repo_root/rootfs/home/cloud-compose/app-init.sh"; then + fail "application lifecycle still performs source checkout after the dedicated preparation phase" +fi + +source_log="$tmp/source-preparation.log" +cat >"$tmp/source-profile.sh" <<'EOF' +#!/usr/bin/env bash +: +EOF +cat >"$tmp/source-functions.sh" <<'EOF' +#!/usr/bin/env bash +compose_app_names_array() { + local -n result="$1" + result=(alpha beta) +} +clone_or_update_compose_app() { + printf 'clone %s\n' "$1" >>"${SOURCE_LOG:?}" +} +run_compose_app_lifecycle() { + printf 'lifecycle %s\n' "$*" >>"${SOURCE_LOG:?}" + return 99 +} +EOF +CLOUD_COMPOSE_PROFILE_PATH="$tmp/source-profile.sh" \ +CLOUD_COMPOSE_COMPOSE_APPS_PATH="$tmp/source-functions.sh" \ +SOURCE_LOG="$source_log" \ + bash "$repo_root/rootfs/home/cloud-compose/prepare-app-sources.sh" +printf 'clone alpha\nclone beta\n' >"$tmp/expected-source-preparation.log" +cmp -s "$tmp/expected-source-preparation.log" "$source_log" || \ + fail "source preparation did not clone every app without executing lifecycle work" +grep -Fq 'ExecStartPre=/bin/bash /home/cloud-compose/assert-vault-ready.sh' \ + "$repo_root/rootfs/etc/systemd/system/cloud-compose.service" || fail "app service lacks a Vault readiness gate" +grep -Fq 'ExecStartPost=/bin/bash /home/cloud-compose/vault-agent-readiness.sh wait' \ + "$repo_root/rootfs/etc/systemd/system/cloud-compose-vault-agent.service" || fail "Vault unit does not publish token readiness" + +echo "Vault runtime contract passed" diff --git a/docs/Dockerfile b/docs/Dockerfile index 866a07e..5011d6f 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -3,7 +3,7 @@ FROM python:3.14-alpine3.22@sha256:6b91e66ab2a880ce9ca5a1b91c70f45963ff71ff68268 WORKDIR /work RUN apk add --no-cache git && \ - pip install uv && \ + pip install uv==0.11.28 && \ uv pip install --break-system-packages --system zensical==0.0.46 EXPOSE 8080 diff --git a/docs/examples.md b/docs/examples.md index 3ec3fdc..3b4d135 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -22,7 +22,7 @@ module path and supplies the template: ```hcl module "app" { - source = "github.com/libops/cloud-compose//providers/do" + source = "github.com/libops/cloud-compose//providers/do?ref=1.0.0" name = "cc-wp" template = "wp" @@ -34,11 +34,105 @@ module "app" { } ``` +The examples pin the reviewed `1.0.0` release. Replace that ref only with the +exact release or full commit your organization has reviewed; omitting `?ref=` +makes future plans consume a moving module source. + +## GCP foundation and application states + +Apply the GCP foundation once from a long-lived state, not once per site: + +```hcl +module "cloud_compose_foundation" { + source = "github.com/libops/cloud-compose//modules/gcp-foundation?ref=1.0.0" + + service_project_id = "library-production" +} + +output "cloud_compose_start_role" { + value = module.cloud_compose_foundation.cloud_compose_start_role_name +} + +output "cloud_compose_suspend_role" { + value = module.cloud_compose_foundation.cloud_compose_suspend_role_name +} +``` + +Publish those output values through your reviewed remote-state or deployment +configuration boundary. A separate application state consumes them; it does not +own the foundation: + +```hcl +module "wp" { + source = "github.com/libops/cloud-compose//providers/gcp?ref=1.0.0" + + name = "cc-wp" + template = "wp" + gcp = { + project_id = "library-production" + region = "us-east5" + zone = "us-east5-b" + network = { + power_button_allowed_ips = var.operator_cidrs + power_button_ip_depth = 0 # direct public Cloud Run URL + } + power_management = { + enabled = true + start_role = var.cloud_compose_start_role + suspend_role = var.cloud_compose_suspend_role + } + } +} +``` + +The application module derives the project number. Do not duplicate that +identity as caller configuration. It binds the two power principals to this +application's VM instance only. + +For Shared VPC, configure the foundation with the service project, host +project, and each permitted regional subnet first. Then select that network in +the application with full resource names: + +```hcl +gcp = { + project_id = "library-production" + region = "us-east5" + zone = "us-east5-b" + network = { + create = false + project_id = "organization-network" + name = "projects/organization-network/global/networks/library" + subnetwork = "projects/organization-network/regions/us-east5/subnetworks/cloud-run-us-east5" + mtu = 1460 # attests the reviewed network's real MTU + power_button_allowed_ips = var.operator_cidrs + power_button_ip_depth = 0 # direct public Cloud Run URL + } + power_management = { + enabled = true + start_role = var.cloud_compose_start_role + suspend_role = var.cloud_compose_suspend_role + } +} +``` + +The subnet must be in the Cloud Run region, use a supported IPv4 `/26` or larger +range, and have enough free addresses for allocation blocks and overlapping +revisions. The foundation grants the Cloud Run service agent Network Viewer on +the host project and Network User on the explicit subnet. The application +deployment identity separately needs permission to inspect and use the network +and to manage its app-specific firewall rules. + Use `runtime` only for overrides such as branch, ingress, Vault Agent, or healthcheck settings: ```hcl runtime = { + sitectl = { + package_versions = { + sitectl = "v0.39.0" + sitectl-wp = "v0.5.0" + } + } compose = { branch = "main" ingress = { @@ -52,7 +146,9 @@ runtime = { ``` The same template defaults are stored in `templates/apps.json` and are reused by -the Ansible role and Salt formula for existing Debian/Ubuntu hosts. +the Ansible role and Salt formula for existing Debian/Ubuntu hosts. Terraform +presets also consume the registry's exact `package_versions`; an explicit +`runtime.sitectl.package_versions` entry replaces the matching preset selector. ## Bin packing @@ -81,4 +177,10 @@ runtime = { ``` `cloud-compose` provides the VM/runtime primitives for bin packing. Placement -policy is intentionally left to consumers for now. +policy is intentionally left to consumers for now. Every project in one GCP +application state shares the host, Docker daemon and kernel boundary, app +service account, managed Vault workload identity, and—when legacy file +credentials are explicitly enabled—the same app JSON credential. Bin-pack only +applications that belong to the same IAM and secret trust boundary. Use +separate application states and VMs when an app needs an independent workload +identity, credential, or host isolation boundary. diff --git a/docs/index.md b/docs/index.md index d77bc96..cab6c43 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,7 +16,7 @@ the same lifecycle path used by later rollouts. - Compose app lifecycle management through `sitectl` - Multi-app VM support through `compose_projects` -- GCP app and VM identity separation +- GCP foundation/application state and identity separation - Optional Vault Agent contract - Optional GCP power management through Cloud Run and lightsout - Provider-neutral runtime contracts for DigitalOcean and Linode @@ -26,6 +26,7 @@ the same lifecycle path used by later rollouts. ## Start here - [Runtime contracts](runtime-contracts.md) explains the VM/app contract. +- [GCP foundation and application states](runtime-contracts.md#gcp-foundation-and-application-states) explains singleton API/IAM ownership, Shared VPC, and Direct VPC egress. - [Managed runtime](managed-runtime.md) covers host tools and internal services. - [Rollout API](rollout.md) covers authenticated deploy triggers. - [Provider entrypoints and on-prem](non-gcp-providers.md) covers non-GCP and existing-host boundaries. diff --git a/docs/managed-runtime.md b/docs/managed-runtime.md index 10a43a4..06c42b1 100644 --- a/docs/managed-runtime.md +++ b/docs/managed-runtime.md @@ -8,36 +8,119 @@ Managed by default: - `sitectl` from `https://github.com/libops/sitectl/releases/latest` - additional sitectl plugin packages listed in `sitectl_packages` -- the internal LibOps compose project, including CAP and lightsout images -- optional verified artifacts listed in `libops_managed_artifacts` +- optional verified artifacts listed in `runtime.managed_runtime.artifacts` + +The privileged internal LibOps compose project is opt-in. Set +`runtime.managed_runtime.internal_services_enabled = true` only after accepting +the documented Docker-socket, host-filesystem, and observability access used by +CAP and cAdvisor. On GCP, enabling power management also enables this runtime +because lightsout depends on it. Disabled internal services create no dedicated +internal service account or IAM bindings, and the `serviceGsa` output is null. The updater runs once during boot before the application initializes, then on a -daily systemd timer. `sitectl_version` defaults to `latest`; set it to a tag such -as `v0.19.7` to pin all configured sitectl packages. +daily systemd timer. Each package follows its own GitHub release stream. The +Terraform template presets select an exact, reviewed core/plugin release set +from `templates/apps.json`. Use `runtime.sitectl.package_versions` to override +core and plugins independently. The provider-neutral +`runtime.sitectl.version` value defaults to `latest` and remains the +backward-compatible fallback only for an installed package with neither a +template selector nor an explicit per-package selector. + +Omitting `runtime.sitectl.packages` selects the template's package set. An +explicit list replaces that set; `packages = []` or `packages = ["sitectl"]` +selects the core CLI only. A per-project `sitectl_packages` value follows the +same omission rule in the project manifest. The updater installs the union of +the global and all per-project package lists because its binary directory is +host-global; a project list can add packages but cannot remove a package needed +elsewhere on the host. Application-specific plugins should be passed as package names: ```hcl -sitectl_packages = [ - "sitectl", - "sitectl-isle", -] -sitectl_plugin = "isle" +runtime = { + sitectl = { + packages = [ + "sitectl", + "sitectl-drupal", + "sitectl-isle", + ] + package_versions = { + sitectl = "v0.39.0" + sitectl-drupal = "v0.11.0" + sitectl-isle = "v0.18.0" + } + plugin = "isle" + } +} ``` +An explicit override wins for its named package while the other packages retain +their template selectors. When callers replace a template's package list, +Terraform filters the template selectors to that effective installed set; a +removed plugin cannot leave behind an unknown-package selector. Packages absent +from the template registry use `version` as their fallback. Package names and +tags are validated before any download. When `latest` is selected, the updater +resolves the current release tag first and downloads both the archive and +checksum through that immutable tag URL. Tool, plugin, and managed-artifact +downloads and redirects are restricted to HTTPS with TLS 1.2 or newer. + +Package versions are host-global because the binaries are installed into one +shared host path. Do not bin-pack applications that need incompatible versions +of the same sitectl plugin; deploy them to separate hosts instead. Pin a +compatible core/plugin set, and advance core before a plugin that raises its +minimum core requirement. + +The updater resolves and verifies the complete requested package set in a +staging generation before replacing any live executable. It takes the shared +application lifecycle lock during the short promotion, records a SHA-256 beside +each installed version, and repairs local binary drift. If a later plugin cannot +download or verify, the old set remains active; if promotion itself fails, the +runtime restores every previous binary and state file instead of leaving a +mixed core/plugin generation. + Additional LibOps-owned binaries or scripts can use the artifact manifest: ```hcl -libops_managed_artifacts = [ - { - name = "libops-controller" - url = "https://example.com/libops-controller-linux-amd64" - sha256 = "..." - path = "/usr/local/bin/libops-controller" - restart = "libops-controller.service" +runtime = { + managed_runtime = { + artifacts = [ + { + name = "libops-controller" + url = "https://example.com/libops-controller-linux-amd64" + sha256 = "..." + path = "/usr/local/bin/libops-controller" + restart = "libops-controller.service" + } + ] } -] +} ``` -Artifacts are downloaded, SHA-256 verified, installed atomically with `install`, -and then optionally restart a systemd unit with `systemctl try-restart`. +Artifacts are downloaded, SHA-256 verified, installed atomically, verified +again at the final path, and then optionally restart a systemd unit. Terraform, +Ansible, Salt, and the host updater enforce the same manifest contract: names +are safe basenames of at most 128 characters, URLs use HTTPS, checksums are +lowercase SHA-256, target paths are non-root absolute paths without dot, empty, +or control-character segments, modes match `0?[0-7]{3}`, owners and groups are +safe local account names, and an optional restart target is a safe `.service` +unit. Names and target paths must be unique. A failed restart restores the +previous artifact and records the failed update instead of leaving an +unverified replacement active. The updater validates and de-duplicates the +entire manifest before the first download, so a bad later row cannot partially +apply earlier rows. Valid rows are independent transactions: a later download +or restart failure does not roll back an earlier artifact that was already +installed and restarted successfully. Package tightly coupled files as one +verified artifact, or use a separately versioned installer that owns their +cross-file transaction. The installed fast path rechecks checksum, mode, owner, +and group; permission drift is reconciled instead of being hidden by stale +state. + +Artifact target directories must already exist through a canonical directory +chain controlled by the root-run installer. Symlinked ancestors and +group/other-writable non-sticky directories are rejected before download. Put +mutable application data in Compose volumes instead of using the privileged +artifact installer to write into an application-owned checkout. + +Internal-service auto-update pulls images only when both internal services and +auto-update are enabled. It restarts the systemd unit only when that unit is +already active; an intentionally stopped internal stack remains stopped. diff --git a/docs/non-gcp-providers.md b/docs/non-gcp-providers.md index 2e23f08..4dfaefc 100644 --- a/docs/non-gcp-providers.md +++ b/docs/non-gcp-providers.md @@ -22,6 +22,30 @@ Destroying or replacing the VM should not destroy either persistent volume unless the whole Terraform stack is destroyed. This matches the GCP module's data disk and Docker-volume disk behavior. +Cloud-init records both stable provider `/dev/disk/by-id` devices and the +Docker bind mount in `/etc/fstab`. A Docker systemd drop-in and every service +that reads durable state use `RequiresMountsFor`, so a reboot fails the service +closed instead of writing app or Docker data into an unmounted root-disk +directory. The filesystem helper verifies ext4, repairs only safe fsck statuses, +and runs `resize2fs` after a provider volume grows; increasing either Terraform +volume size therefore exposes the added capacity on the next boot without +reformatting the filesystem. Never shrink these volume inputs. + +Provider VM backup toggles do **not** protect application state: +DigitalOcean's `droplet.backups` covers the Droplet disk but excludes attached +Volumes, and Linode's `instance.backups_enabled` excludes Block Storage. The +nightly MariaDB dumps under `/mnt/disks/data/backups` are useful for logical +recovery, but they live on the same data volume and are not disaster recovery. +Also, `terraform destroy` intentionally deletes both managed volumes. + +Before production, establish an independently owned offsite policy for both +volumes (provider volume snapshots where available, or encrypted export to +separate object storage/account), define retention separately from this +application state, and test a restore into disposable new volumes. A restore is +complete only after the Compose projects start, `sitectl healthcheck` passes, +and representative files plus database records are verified. Do not enable a +boot-disk backup toggle and record the application as protected. + Fedora CoreOS should use the CoreOS installer path. Debian and Ubuntu should use the apt installer path. Both paths install the same minimum runtime surface: @@ -34,9 +58,16 @@ the apt installer path. Both paths install the same minimum runtime surface: - `sitectl` and selected plugins DigitalOcean and Linode do not currently have a cloud-compose workload identity -contract equivalent to GCP IAM for Vault Agent. Their modules default -`vault_auth_method` to `consumer-managed`. When `vault_agent_enabled=true`, pass -the auth method through `vault_agent_additional_config` or a rootfs overlay. +contract equivalent to GCP IAM for Vault Agent. The provider-neutral root module +resolves `runtime.vault.auth_method = "auto"` to `gcp-iam` on GCP and +`consumer-managed` on DigitalOcean and Linode. Provider-specific entrypoints +default directly to their corresponding method. When the Terraform-managed +agent is enabled with `consumer-managed`, supply the auth stanza through +`runtime.vault.agent_additional_config` or a rootfs overlay. + +Provider-neutral `runtime.users` applies on every cloud. DigitalOcean and Linode +also accept users under their provider-specific `ssh.users` map; a +provider-specific entry wins when the same username appears in both maps. ## Existing Debian/Ubuntu Hosts @@ -45,6 +76,52 @@ disk, firewall, or DNS. These adapters install the packaged cloud-compose rootfs write `.env`, write `compose-projects.json`, reload systemd, and optionally run the bootstrap. +These adapters are for a dedicated, empty application host. They install and +restart Docker with `/mnt/disks/data/docker` as its data root, install +cloud-compose systemd services and timers, and can replace host Fluent Bit and +Vault Agent unit configuration. Applying either adapter to a shared Docker, +logging, or Vault host can interrupt unrelated workloads. The destructive +ownership boundary is enforced by an acknowledgement that defaults to false: + +```yaml +# Ansible inventory +cloud_compose_dedicated_host_acknowledged: true + +# Salt pillar +cloud_compose: + dedicated_host_acknowledged: true +``` + +Set it only after mounting durable storage at the fixed paths below and +confirming the host has no unrelated Docker containers, logging agent, Vault +Agent, cron workload, or conflicting systemd units. A repeated configuration- +management apply updates runtime files but does not automatically rerun app +initialization. Use `cloud_compose_force_bootstrap: true` with Ansible or +`force_bootstrap: true` in Salt only in a planned maintenance window. + +The first adapter upgrade from a release that used +`/mnt/disks/data/.cloud-compose-lifecycle.lock` must also happen in a planned +maintenance window. Stop the rollout service and lifecycle timers, allow any +in-flight rollout, backup, or managed-runtime service to finish, apply the +adapter, and then restore the previously enabled services and timers. This +prevents an old process holding the data-disk lock from overlapping a new +process using `/run/lock/cloud-compose/lifecycle.lock`. The adapter creates and +repairs the new root-owned lock on every apply; this one-time quiescence is not +needed for later upgrades that already use the `/run` lock. + +The adapters reject `runtime.vault.agent_enabled = true`; Terraform is currently +the only supported owner of generated Vault Agent configuration. They also +reserve `HOME`, `PATH`, and the `CLOUD_COMPOSE_*`, `COMPOSE_*`, `DOCKER_*`, +`SITECTL_*`, `LIBOPS_*`, `GCP_*`, `VAULT_*`, `ROLLOUT_*`, and +`POWER_MANAGEMENT_*` host-control namespaces. Use `runtime.extra_env` only for +unreserved application settings such as `PHP_*` or `NGINX_*` tuning. The +adapter writes these values to `/home/cloud-compose/application-env.json`; init +reconciles them into every Compose project's `.env` without exporting them to +host processes. +The older Ansible `cloud_compose_extra_env` variable and Salt top-level +`extra_env` pillar remain compatibility fallbacks when the nested map is +omitted. + - Ansible role: `ansible/roles/cloud_compose` - Ansible playbook example: `ansible/playbooks/site.yml` - Salt formula: `salt/cloud-compose` @@ -87,9 +164,10 @@ base: ``` CI verifies the local adapters with `make config-management-smoke`. The cloud -smoke path provisions a raw Linode VM without attached volumes, then deploys -Drupal through Ansible or Salt into `/opt/drupal` without using the -cloud-compose Terraform module: +smoke path provisions a raw Linode VM, prepares the fixed data boundary, then +deploys Drupal through Ansible or Salt into +`/mnt/disks/data/libops/drupal/main` without using the cloud-compose Terraform +module: ```bash LINODE_TOKEN=... make config-management-cloud-smoke METHOD=ansible @@ -105,7 +183,7 @@ repo, `sitectl` plugin, and plugin package defaults: ```hcl module "wp" { - source = "github.com/libops/cloud-compose//providers/do" + source = "github.com/libops/cloud-compose//providers/do?ref=1.0.0" name = "cc-wp" template = "wp" @@ -115,18 +193,48 @@ module "wp" { cloud_compose_keys = var.operator_ssh_keys } } + runtime = { + rootfs_archive_url = "https://github.com/libops/cloud-compose/releases/download/1.0.0/cloud-compose-rootfs.tar.gz" + rootfs_archive_sha256 = var.cloud_compose_rootfs_sha256 + } } ``` +The `1.0.0` ref is intentional. Replace it only with an exact reviewed release +or full commit. DigitalOcean limits Droplet `user_data` to 64 KiB, and the full +managed runtime no longer fits safely inline. Use the integrity-pinned archive +pair shown above; Terraform rejects an oversized inline payload before calling +the Droplet API. The default DigitalOcean SSH firewall sources include the +public internet; production callers should set +`digitalocean.firewall.ssh_source_addresses` to institutional or operator +CIDRs instead of inheriting that default. + +Cloud-compose, rather than DigitalOcean's creation-time automount, owns new +volume formatting and the fixed mount paths. The module ignores the historical +`initial_filesystem_type` state so upgrading an existing deployment does not +replace either durable volume. When an older volume still has DigitalOcean's +generated `/mnt/` unit, bootstrap waits for udev, validates the +exact device, unit, and single mount source, moves that mount to the fixed data +boundary, and retires only the verified provider persistence entry. An +unexpected or duplicate mount fails closed before `fsck`, formatting, or mount +mutation. Do not add `initial_filesystem_type` back downstream or separately +mount the same writable device; review upgrade plans to ensure both volume IDs +remain unchanged. + ## Linode Use `LINODE_TOKEN` or an explicit Linode provider configuration in the calling -stack. Linode metadata is tighter than DigitalOcean, so CI and examples can pass -`rootfs_archive_url` when the embedded cloud-init payload would be too large: +stack. Linode metadata has a 16 KiB limit, so CI and examples pass +`rootfs_archive_url` instead of embedding the managed rootfs. Archive mode is +an integrity-pinned input: set an immutable HTTPS source ref and the SHA-256 of +the exact downloaded archive together. Terraform rejects a non-HTTPS URL, a URL +without a checksum, a checksum without a URL, and malformed digests. The VM +restricts redirects to HTTPS with TLS 1.2 or newer and verifies the checksum +before extracting any archive content. ```hcl module "drupal" { - source = "github.com/libops/cloud-compose//providers/linode" + source = "github.com/libops/cloud-compose//providers/linode?ref=1.0.0" name = "cc-drupal" template = "drupal" @@ -140,7 +248,21 @@ module "drupal" { } } runtime = { - rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/main.tar.gz" + rootfs_archive_url = "https://github.com/libops/cloud-compose/releases/download/1.0.0/cloud-compose-rootfs.tar.gz" + rootfs_archive_sha256 = var.cloud_compose_rootfs_sha256 } } ``` + +Linode also defaults SSH ingress to the public internet. Set +`linode.firewall.ssh_source_ipv4` and `ssh_source_ipv6` to reviewed operator +CIDRs. Replace the `1.0.0` module ref only with an exact release or full commit +your organization has reviewed. + +Use the canonical `cloud-compose-rootfs.tar.gz` and adjacent `.sha256` release +assets. They are built reproducibly from the tagged `rootfs/` tree; GitHub's +generated repository source archives are not a stable long-term checksum +boundary because GitHub may regenerate their outer compression. CI may use a +commit archive only within the same run while testing an unreleased commit. +Download the release asset through the same URL consumers will use and verify +its published checksum before planning the deployment. diff --git a/docs/rollout.md b/docs/rollout.md index 5d92a5c..9b7e325 100644 --- a/docs/rollout.md +++ b/docs/rollout.md @@ -3,43 +3,70 @@ `cloud-compose` can optionally run a small authenticated HTTP service on the VM that triggers a compose project rollout. This is generic infrastructure: callers send a signed request, the VM validates the bearer JWT, and the service runs the -commands configured in `docker_compose_rollout`. +commands configured in `runtime.compose.rollout`. ## Terraform ```hcl module "site" { - source = "github.com/libops/cloud-compose//providers/gcp" - - # existing cloud-compose inputs... - - rollout_enabled = true - rollout_release_url = "https://example.com/releases/cloud-compose-rollout-linux-amd64" - rollout_release_sha256 = "..." - rollout_jwks_uri = "https://www.googleapis.com/oauth2/v3/certs" - rollout_jwt_audience = "libops-site-controller:" - # Default shown for clarity: controller-ingress reaches rollout over direct - # VPC egress to the VM internal IP. - rollout_allowed_ipv4 = ["10.0.0.0/8"] - - docker_compose_rollout = [ - "TARGET_REF=\"$${GIT_REF:-$${GIT_BRANCH:-$${DOCKER_COMPOSE_BRANCH:-main}}}\"", - "if [ -x ./scripts/rollout.sh ]; then ./scripts/rollout.sh; else sitectl deploy --context \"$${SITECTL_CONTEXT_NAME}\" --branch \"$TARGET_REF\"; fi", - "sitectl healthcheck --context \"$${SITECTL_CONTEXT_NAME}\" --persist", - "if [ \"$${SITECTL_ENVIRONMENT}\" != \"production\" ]; then sitectl verify --context \"$${SITECTL_CONTEXT_NAME}\" $${SITECTL_VERIFY_ARGS:-}; fi", - ] + source = "github.com/libops/cloud-compose//providers/gcp?ref=1.0.0" + + name = "example-site" + gcp = { + project_id = var.gcp_project_id + rollout = { + enabled = true + release_url = "https://example.com/releases/cloud-compose-rollout-linux-amd64" + release_sha256 = var.rollout_release_sha256 + jwks_uri = "https://www.googleapis.com/oauth2/v3/certs" + jwt_audience = "libops-site-controller:" + # controller-ingress reaches rollout over direct VPC egress to the VM. + allowed_ipv4 = ["10.0.0.0/8"] + } + } + + runtime = { + compose = { + rollout = [ + "TARGET_REF=\"$${GIT_REF:-$${GIT_BRANCH:-}}\"", + "if [ -n \"$TARGET_REF\" ]; then sitectl deploy --context \"$${SITECTL_CONTEXT_NAME}\" --ref \"$TARGET_REF\"; else sitectl deploy --context \"$${SITECTL_CONTEXT_NAME}\" --skip-git; fi", + "sitectl healthcheck --context \"$${SITECTL_CONTEXT_NAME}\" --persist", + "if [ \"$${SITECTL_ENVIRONMENT}\" != \"production\" ]; then sitectl verify --context \"$${SITECTL_CONTEXT_NAME}\" $${SITECTL_VERIFY_ARGS:-}; fi", + ] + } + } } ``` -`rollout_release_url` should point to a pinned Linux binary built from the -generic rollout service. `rollout_release_sha256` is required so the VM verifies -the binary before installing it. - -The rollout service listens on `rollout_port`, which defaults to `8081`. -`rollout_allowed_ipv4` controls the VM firewall rule for that port and defaults -to `10.0.0.0/8`. LibOps controller-ingress calls the VM's internal IP over Cloud -Run direct VPC egress, so this allows private control-plane traffic without -opening the rollout endpoint to the public internet. +The example pins the reviewed cloud-compose `1.0.0` release. Replace that ref +only with an exact reviewed release or full commit. `gcp.rollout.release_url` +should point to a pinned Linux binary built from the generic rollout service; +`gcp.rollout.release_sha256` is required so the VM verifies the binary before +installing it. Both the release URL and JWKS URI must use HTTPS. Binary download +and redirects are restricted to HTTPS with TLS 1.2 or newer. The VM rejects +an insecure JWKS transport before starting the command-executing rollout +service, and `custom_claims` must be empty or a JSON object. Apply the singleton GCP foundation before this application +state; cloud-compose derives project metadata from `gcp.project_id` rather than +requiring a caller-supplied numeric project identifier. + +The rollout service listens on `gcp.rollout.port`, which defaults to `8081`. +`gcp.rollout.allowed_ipv4` controls the VM firewall rule for that port and +defaults to `10.0.0.0/8`. LibOps controller-ingress calls the VM's internal IP +over Cloud Run Direct VPC egress, so this allows private control-plane traffic +without opening the rollout endpoint to the public internet. This is outbound +VPC connectivity from the Cloud Run service, not Direct VPC ingress. In +production, replace the broad default with the exact controller subnet CIDR and +ensure that subnet is `/26` or larger, is in the controller's Cloud Run region, +and uses the default MTU `1460`. Controller-ingress networking and service-agent +IAM remain owned by its own foundation; this application state owns only the VM +firewall rule. + +On the VM these trusted settings use the `ROLLOUT_PORT`, `ROLLOUT_JWKS_URI`, +`ROLLOUT_JWT_AUD`, and `ROLLOUT_CUSTOM_CLAIMS` host-control names. The service +maps them to the generic names expected by the rollout binary only when it is +executed. Values supplied through `runtime.extra_env` are application-only +Compose data, so an app-level `PORT`, `JWKS_URI`, `JWT_AUD`, or `CUSTOM_CLAIMS` +cannot change the rollout listener or authentication policy. ## HTTP Surface @@ -56,9 +83,9 @@ Authorization: Bearer Content-Type: application/json ``` -The JWT must validate against `rollout_jwks_uri`, include the configured -`rollout_jwt_audience` as `aud`, and match any JSON object in -`rollout_custom_claims`. +The JWT must validate against `gcp.rollout.jwks_uri`, include the configured +`gcp.rollout.jwt_audience` as `aud`, and match any JSON object in +`gcp.rollout.custom_claims`. Example request: @@ -75,7 +102,7 @@ Example request: ``` Every non-empty field accepted by the service is exported as an environment -variable before `docker_compose_rollout` runs: +variable before `runtime.compose.rollout` runs: - `GIT_REF` - `GIT_BRANCH` @@ -92,8 +119,24 @@ variable before `docker_compose_rollout` runs: - `ROLLOUT_ARG3` The generated rollout script runs from the checked-out compose repository after -sourcing `/home/cloud-compose/profile.sh`. First-class LibOps app templates -should commit `scripts/rollout.sh`; the default `docker_compose_rollout` will -delegate to that script when it exists, otherwise it falls back to -`sitectl deploy`. The default path always runs `sitectl healthcheck`; non- -production environments also run `sitectl verify`. +sourcing `/home/cloud-compose/profile.sh`. The default contract prefers +`GIT_REF`, then `GIT_BRANCH`. When either is present, `sitectl deploy --ref` +fetches that exact remote ref into a dedicated local ref, verifies it resolves +to a commit, and checks it out detached before invoking the active plugin's +component lifecycle. This supports branch names, advertised commit IDs, and +provider refs such as `refs/pull/123/head` without rewriting the checkout's +configured branch. When neither is present, `sitectl deploy --skip-git` +reconciles the current checkout without moving its source ref. A successful +rollout becomes the deployed checkout: ordinary `up` operations and service +restarts preserve it rather than first forcing it through the configured +baseline branch. A later explicit rollout can therefore move from a feature or +pull-request ref back to the baseline cleanly. The default then always runs +`sitectl healthcheck`; non-production environments also run `sitectl verify` +with the project's configured verify arguments. + +Templates do not need a host-specific rollout script. Keep application rollout +behavior in the sitectl plugin's component definitions so Terraform, Ansible, +Salt, operator-driven deploys, and the authenticated rollout service all use the +same lifecycle contract. Override `runtime.compose.rollout` only when the whole +command contract needs to change, and preserve the deploy, healthcheck, and +non-production verification gates in any override. diff --git a/docs/runtime-contracts.md b/docs/runtime-contracts.md index cfe81dd..23f91c8 100644 --- a/docs/runtime-contracts.md +++ b/docs/runtime-contracts.md @@ -5,6 +5,102 @@ runtime reconciler. Terraform provisions durable infrastructure and writes the VM contract; boot and rollout then use `sitectl` to move each compose project to the desired state. +## Trust Boundary + +Terraform module sources, rootfs overlays and archives, Compose repositories, +lifecycle command lists, managed artifacts, Vault Agent templates, and +configuration-management pillars/inventory are privileged deployment inputs. +An operator who can change them can execute as root on the dedicated host. Pin +and review these inputs with the same controls used for infrastructure code; +checksums provide integrity but do not make an untrusted artifact safe. + +Terraform state, cloud-init/user-data, plans, and CI logs can expose rendered +configuration. Do not place application secrets in `runtime.extra_env`, Vault +template contents, lifecycle commands, or ordinary Terraform variables. Pass +secret references through the deployment contract and let the runtime secret +manager materialize values on the host. Restrict access to state and CI +artifacts even when the intended inputs are non-secret. + +GCP deployment names are 6–21 lowercase letters, numbers, or hyphens, starting +with a letter and ending with a letter or number. That bound ensures the same +name remains valid after the `vm-`, `internal-`, and `ppb-` prefixes used for +generated service accounts. DigitalOcean and Linode retain their documented +provider-specific name limits. + +## GCP foundation and application states + +GCP prerequisites that are shared by many sites belong in one small, +long-lived Terraform state. Apply `modules/gcp-foundation` once per Cloud Run +service project before applying any application state. By default, the +foundation owns: + +- the Cloud Resource Manager, Compute Engine, IAM, IAM Service Account + Credentials, Logging, Monitoring, and Cloud Run APIs; +- explicit creation of the Google-managed Cloud Run service identity and its + documented `roles/run.serviceAgent` service-project binding; +- the reusable `cloudComposeStart` and `cloudComposeSuspend` custom-role + definitions; and +- for Shared VPC, the service-project attachment when requested plus the Cloud + Run service agent's host-project Network Viewer and per-subnet Network User + grants. + +The foundation queries the service project itself. Application callers do not +need to pass a numeric project number. The identity running the foundation must +be able to enable services, manage project custom roles, and, for Shared VPC, +manage the host-project attachment and IAM grants. The Service Usage API must +already be enabled so Terraform can enable the workload APIs. + +Each application state owns its VM, disks, application service accounts, +firewall rules, optional Cloud Run proxy, and IAM membership for that one VM. +If callers supply existing VM or app service accounts, each account must still +be dedicated to exactly one cloud-compose application state. The state owns +project telemetry memberships and optional target-service-account IAM members; +reusing an identity across states would let either state's destroy revoke +access required by the other. Leave the email inputs empty to get the safer +per-state identities by default. +When power management is enabled, pass the foundation's full +`cloud_compose_start_role_name` and `cloud_compose_suspend_role_name` outputs as +`gcp.power_management.start_role` and `gcp.power_management.suspend_role`. +Cloud-compose binds the proxy identity to the start/resume role and the +internal-services identity to the suspend role on the selected Compute Engine +instance only. The custom-role definitions live at project or organization +scope because Google custom roles do, but application principals do not receive +those permissions across the project. It also does not let the project's +default Compute Engine workload service account impersonate the VM service +account; Google attaches that identity through the Compute Engine service agent. +The principal applying the application state must already have +`iam.serviceAccounts.actAs` (normally `roles/iam.serviceAccountUser`) on the VM +service account; cloud-compose does not grant its deployer permissions to +itself. + +Keep these states independent. Publish the two role names through a reviewed +remote-state or deployment-variable boundary, apply the foundation first, and +do not make a site destroy own foundation resources. The foundation protects +custom roles from ordinary destroy and abandons API and optional Shared VPC +attachment ownership rather than disabling or detaching infrastructure used by +surviving sites. Decommission it only after every Cloud Run revision is +disconnected, all application states are retired, and remaining consumers have +been inventoried. + +For a same-project VPC, leave the foundation's `host_project_id` empty. The +foundation creates no extra Network Viewer or Network User membership; the +foundation owns the Cloud Run service agent's standard +`roles/run.serviceAgent` membership in the service project, which remains the +authority for Direct VPC use. + +For Shared VPC, set the foundation's `host_project_id`, enumerate every regional +subnet Cloud Run may use in `shared_vpc_subnetworks`, and set +`attach_shared_vpc = true` only when this foundation state should own the +service-project association. The foundation grants the derived Cloud Run +service agent `roles/compute.networkViewer` on the host project and +`roles/compute.networkUser` on each listed subnet. It deliberately does not +grant Network User across the host project. The application deployment identity +still needs its separately reviewed host-project permissions to read the +network, use the subnet, and manage that application's firewall rules. External +IPv6 subnets additionally require the Google-documented Compute Public IP Admin +grant, which is outside the foundation module's default IPv4/internal-IPv6 +contract. + ## Compose Apps The default shape is one compose app per VM or existing host. Use @@ -24,49 +120,422 @@ not implemented yet; callers decide which apps share a VM. Every app should expose a distinct host port through its compose/Traefik config. The generated app env file exports `COMPOSE_BIND_PORT` for that purpose. +`docker_compose_branch` (the `runtime.compose.branch` input at the public +module boundary) accepts either a branch/tag name or a full 40-character Git +commit. The source-preparation and `init` phases follow a configured moving ref. +An ordinary `up` or service restart instead validates and preserves the commit +selected by the last successful init or rollout. This keeps a detached feature +or pull-request deployment operable until an explicit rollout selects another +ref. A later source-preparation or `init` phase can restore the configured +baseline only when the current clean `HEAD` exactly matches the recorded +successful deployment; unrecorded local-ahead or divergent work is rejected. +A full configured commit is treated as an immutable source pin: +cloud-compose fetches that object, checks it out with a detached HEAD, verifies +that `HEAD` exactly matches the configured value, and does not advance when the +remote branch moves. The deployed commit for every app is recorded at +`/home/cloud-compose/state/.deployed-head`, including branch/tag +deployments, so operators can compare desired and observed source state. + +Use a full commit for reproducible production rollouts. A commit pin fixes the +repository contents but does not prove who authored them; protect the selected +repository and review/sign commits according to your downstream governance. + ## Sitectl -During init, the VM creates a `sitectl` context for every app. The default up and -rollout commands use `sitectl deploy`, which now reuses the compose reconcile -path before `docker compose up`. This means plugin-declared init artifacts, -volumes, and buildable images are repaired during normal deployment. +During init, the VM creates a `sitectl` context for every app. The default `up` +commands use `sitectl compose up`, followed by a healthcheck. The default +rollout command uses `sitectl deploy`, whose compose reconcile path repairs +plugin-declared init artifacts, volumes, and buildable images before starting +services. A plain service start does not promise that repair; run a rollout or +an explicit `sitectl deploy` when reconciliation is required. + +The host installs one shared `sitectl` core and plugin package set. Pin releases +with `runtime.sitectl.package_versions`; its keys are package names such as +`sitectl` and `sitectl-isle`. A package-specific value overrides the legacy +`runtime.sitectl.version` fallback. Named Terraform templates start from the +exact compatible package set recorded in `templates/apps.json`; explicit +per-package values win, and template values are filtered to the installed +package list before validation. Terraform, Ansible, and Salt serialize their +resolved map as `SITECTL_PACKAGE_VERSIONS` JSON and the privileged installer +validates it again before downloading anything. Per-project package versions +are not supported because projects on one host share the same binaries. + +`sitectl_verify_args` remains a real argument list. The host stores it as JSON +and appends each value through an argv-aware wrapper when a lifecycle command +invokes `sitectl verify`; spaces in one value never become additional arguments. +Newlines, carriage returns, and NUL bytes are rejected instead of being flattened +into an ambiguous shell scalar. ## Vault `cloud-compose` defines the Vault contract and leaves product-specific Vault roles, policies, mounts, and secret paths to consumers. -GCP defaults to Vault GCP IAM auth with the app GSA identity, not the VM GSA. The -VM still uses its broader GSA for host work such as logs and key rotation. The -app GSA key is rotated into: +The provider-neutral root defaults `runtime.vault.auth_method` to `auto`, which +resolves to GCP IAM on GCP and consumer-managed auth elsewhere. GCP IAM uses the +app GSA identity, not the VM GSA. Vault Agent uses the VM's attached identity +through Application Default Credentials to call the IAM Credentials `signJwt` +API for that app identity. Terraform grants the VM GSA Token Creator on that +one app GSA only while managed GCP IAM auto-auth is enabled; it does not create +or download a JSON key for Vault. The foundation therefore enables +`iamcredentials.googleapis.com`. + +Terraform providers render the Vault Agent configuration. The Ansible role and +Salt formula deliberately reject +`vault.agent_enabled = true` until they can render and own the same files; leave +it disabled and manage a separate Vault Agent through the host configuration +system if an existing host needs one. -- `/mnt/disks/data/cloud-compose/app/GOOGLE_APPLICATION_CREDENTIALS` -- each app's `secrets/GOOGLE_APPLICATION_CREDENTIALS` +When the managed agent is enabled, its token must be one regular file directly +inside `/mnt/disks/data/vault`; symbolic-link and broad-parent paths fail +closed. Bootstrap prepares Compose sources without running application +lifecycle commands, authenticates the Vault Agent without a file credential, +waits for a freshly written Vault token readiness marker, and only then runs +app/plugin initialization and starts the app service. A missing config, Vault +binary, fresh token, or active agent service stops bootstrap rather than +silently starting an app without an authenticated agent. Token readiness does +not prove that every optional template destination has rendered; an app whose +initialization consumes a rendered file must include its own bounded file/content +check in the configured init lifecycle. -Vault Agent can use that credential to authenticate as the app identity and -render files for compose projects. DigitalOcean and Linode do not currently -provide a matching GCP-style workload identity contract here; use -`vault_auth_method = "consumer-managed"` and provide auth config through -`vault_agent_additional_config` or consumer rootfs overlays. +DigitalOcean and Linode do not currently provide a matching GCP-style workload +identity contract. Their Terraform entrypoints use `consumer-managed` auth and +require `vault.agent_additional_config` when the Terraform-managed agent is +enabled. ## OS Families Dependency installation dispatches by OS family: -- COS: installs Docker Compose, Buildx, and `make` into writable paths. -- Fedora CoreOS: configures the LibOps RPM repo, installs host packages with - `rpm-ostree install --apply-live`, and installs Docker CLI plugins. -- Debian/Ubuntu: installs Docker, Git, jq, make, and CA/curl packages with apt, - then installs Docker CLI plugins. +- COS: installs Docker Compose, Buildx, and `make` into writable paths and + fails bootstrap if the host-provided OpenSSL prerequisite is unavailable. + The verified Make artifact lives in a dedicated directory on the executable + persistent disk under `/mnt/disks/data`, not the stateful `/home` or `/var` + filesystems that COS mounts `noexec`. On + GCP, the metadata policy permits only DNS to the Compute Engine resolver and + denies all other container traffic to the metadata address before the pinned + Alpine build runs. The build uses Docker's bridge and never `--network host`. +- Fedora CoreOS: installs `git`, `jq`, `make`, and OpenSSL with + `rpm-ostree install --apply-live`, then installs Docker CLI plugins. +- Debian/Ubuntu: installs Docker, Git, jq, make, OpenSSL, and CA/curl packages + with apt, then installs Docker CLI plugins. Provider modules should mount persistent data and Docker-volume disks before the runtime starts. Destroying/recreating the VM must not destroy these volumes. +Every Compose `project_dir` must be a normalized descendant of the fixed +`/mnt/disks/data` ownership boundary. Terraform rejects paths such as `/`, +`/etc`, repeated separators, and dot-segment traversal during planning. The +Ansible and Salt adapters apply the same validation before any filesystem +mutation, and the host runtime resolves existing symlinks again before cloning +or changing ownership. This boundary is intentionally not configurable on a +production host. + +GCP snapshot overlays accept Docker volume basenames only. The overlay helper +verifies that an existing mount uses the configured lower, upper, and work +directories before treating it as idempotent. Its destructive reset accepts +only an explicit `true`, unmounts the verified overlay, clears both upper and +work state (including dotfiles), and remounts it. Values such as `maybe` are +errors; the literal value `false` never resets data. + +Fedora CoreOS bootstrap installs only the OS dependencies `git`, `jq`, and +`make` through `rpm-ostree`; it does not configure the LibOps RPM repository. +After OS setup, the same managed-runtime path used by Debian and COS downloads +each requested sitectl release archive and its release checksum manifest, +verifies the selected archive, and installs it under the persistent managed +runtime directory. Keeping a single sitectl ownership path avoids an RPM copy +being immediately replaced by a separately downloaded release binary. + The Ansible role and Salt formula skip cloud infrastructure creation. They assume Debian/Ubuntu hosts already have suitable network, DNS, firewall, and storage policy, then install the same rootfs and write the same runtime files Terraform would have written through cloud-init. +### Bootstrap artifact trust + +Hosts that install Docker Compose and Buildx outside an OS package repository +download the binary and `checksums.txt` from the same exact upstream release +tag. The installer requires one checksum entry for the selected architecture, +verifies the temporary binary, and then renames it into place atomically. Every +run also hashes an existing plugin against that release manifest, so changing a +configured version upgrades the binary and a modified local binary is repaired. +A failed or mismatched download leaves the last verified executable in place. + +On Container-Optimized OS, the Alpine environment used to compile GNU Make is +pinned by image-index digest. The Make source archive has its own fixed SHA-256 +digest and is verified before extraction. Renovate updates both the Docker +release versions and the Alpine tag/digest pair; review those changes as +supply-chain updates rather than accepting an unpinned replacement. The image +and its network-fetched package build scripts execute only after the metadata +firewall is installed and use the bridge network, so they cannot inherit the +host network's root exemption. + +The GCP power-button Terraform dependency is sourced from a full Git commit, +not a mutable branch or tag archive. Advance that commit deliberately with a +reviewed plan; provider lockfiles do not checksum remote Terraform modules. + +## Host and application environment boundaries + +Terraform, Ansible, and Salt render two deliberately separate data files: + +- `/home/cloud-compose/.env` contains trusted cloud-compose host controls only. +- `/home/cloud-compose/application-env.json` contains `runtime.extra_env` + application tuning as a JSON object and is never sourced or exported by the + host runtime. + +The host file uses a narrow dotenv encoding. Runtime commands source +`/home/cloud-compose/profile.sh`, whose strict parser decodes that format +without `eval` or sourcing `.env`. Systemd units invoke the same loader instead +of `EnvironmentFile`. Terraform centralizes host encoding in +`modules/runtime-env`; the Ansible and Salt renderers implement the same +contract. + +During init, cloud-compose parses the application JSON with `jq` and reconciles +every entry into every configured Compose project's own `.env`. It writes +double-quoted Compose values with backslash, double quote, dollar, newline, +carriage return, and tab encoded as `\\`, `\"`, `$$`, `\n`, `\r`, and `\t`. +Pass raw strings through `extra_env`; do not pre-escape quotes, dollars, or +multiline values. Marked application entries are idempotent, and entries removed +from desired state are removed on the next init. Existing downstream dotenv +syntax remains opaque and is preserved. + +Application values are written before cloud-compose's final managed +`COMPOSE_PROJECT_NAME`, `SITE_NAME`, and `COMPOSE_BIND_PORT` overrides. Reserved +control-plane names and prefixes are rejected. Security-sensitive but +application-valid names such as `BASH_ENV`, `LD_PRELOAD`, `PORT`, or `JWKS_URI` +remain Compose data and never enter privileged host shells. The rollout service +stores listener and authentication controls as `ROLLOUT_PORT`, +`ROLLOUT_JWKS_URI`, `ROLLOUT_JWT_AUD`, and `ROLLOUT_CUSTOM_CLAIMS`, then maps +them to the rollout binary's generic names immediately before `exec`. +Application settings therefore cannot replace rollout authentication. + +The JSON and project `.env` files are mode `0640`, but `extra_env` is not a +secret store: its values also exist in Terraform state or configuration- +management input. Use the template's secret-file or Vault contract for +credentials. + +`make runtime-config-contract` tests the strict host encoding with Docker +Compose. `make application-env-contract` tests the host/application boundary, +multi-app reconciliation, literal special characters, and rollout namespacing +without starting containers. `make config-management-smoke` renders and +validates the Ansible and Salt forms. + +SSH user names are restricted to safe Linux account names, and public-key +entries must be non-empty single lines. Rejecting multiline values before +cloud-init rendering prevents user or key data from changing the generated +YAML structure. + +## Host metadata and GCP key rotation + +On GCP, metadata isolation has a pre-Docker and post-Docker layer. The +`cloud-compose-metadata-firewall-pre.service` unit is required before +`docker.service` on hosts with a persistent systemd unit directory. It installs +an idempotent `mangle/PREROUTING` guard plus the unprivileged-host HTTP and HTTPS +denies before managed containers can start. After Docker starts, +`cloud-compose-metadata-firewall.service` installs equivalent defense-in-depth +rules in Docker's `DOCKER-USER` hook and the host `FORWARD` chain. The rules +match the metadata destination rather than a bridge name, so existing and +future Compose networks are covered. + +[Compute Engine DNS](https://cloud.google.com/compute/docs/internal-dns) also +serves VPC, private, forwarding, and peering answers from `169.254.169.254`, so +container TCP and UDP port 53 are explicitly accepted ahead of each catch-all +drop. Metadata HTTP and HTTPS remain unavailable to containers, and both +endpoints are blocked for unprivileged host processes; root retains access for +the GCP-only key-rotation service. Bootstrap installs the policy before the COS +dependency builder, restarts Docker, and immediately verifies the policy again +before application containers proceed. Other providers neither enable these +units nor receive GCP-specific firewall rules. + +COS reconstructs `/etc` after networking on each boot, so a unit shipped there +cannot claim pre-network enforcement. This follows the documented +[COS filesystem and cloud-init model](https://cloud.google.com/container-optimized-os/docs/concepts/disks-and-filesystem). +The COS base image, Google guest agents, cloud-init, and verified cloud-compose +rootfs download are therefore privileged bootstrap dependencies. The initial +Docker data root does not contain the managed persistent Compose workload; +bootstrap installs the full policy before the first third-party build container +and before restarting Docker with its managed data root. The post-restart +service then gates application startup. On stateful `/etc` hosts, the +pre-Docker unit also closes the ordinary reboot and daemon-restart window. This +boundary is deliberate: deployments requiring metadata isolation before +cloud-init must bake the guard into their boot image. + +Before any lifecycle command runs on GCP, cloud-compose renders the effective +Compose model and rejects service `network_mode: host`, build `network: host`, +and BuildKit `network.host` or `security.insecure` entitlements. Those modes put +container/build code in a path that bypasses the forwarding deny rules. This is +defense in depth, not a sandbox for an untrusted Compose repository: lifecycle +commands, privileged containers, host mounts, and Docker socket access are +root-equivalent inputs under the trust contract above. + +GCP power-management ingress uses [Cloud Run Direct VPC +egress](https://cloud.google.com/run/docs/configuring/vpc-direct-vpc), without +a Serverless VPC Access connector. Cloud Run services do not support Direct VPC +ingress: the public request terminates at the proxy service, and that service +sends its backend connection through Direct VPC egress to the VM's private +address. The VM and Cloud Run revision must therefore resolve to the same VPC +network and regional subnet. + +Callers may provide a network, a subnet, or both. A network-only input selects +the same-named subnet in `gcp.region`; a subnet-only input derives its parent +network. Leave both empty to create a per-application managed network, or set +`gcp.network.create = false` to resolve the regional `default` subnet. For an +existing network, simple names are resolved in `gcp.network.project_id`. Shared +VPC callers should prefer the unambiguous full resource forms: + +```text +projects/HOST_PROJECT/global/networks/NETWORK +projects/HOST_PROJECT/regions/REGION/subnetworks/SUBNET +``` + +Cloud-compose rejects a configured network project that disagrees with either +resource, a subnet from a different project or parent network, and a subnet +whose region differs from `gcp.region`. Apply the singleton foundation's Shared +VPC service-agent grants first; the application stack does not own those +long-lived IAM members. + +[Direct VPC address allocation](https://cloud.google.com/run/docs/configuring/vpc-direct-vpc#ip-address-allocation) +is ephemeral. When power management is enabled, cloud-compose therefore +requires an IPv4 `/26` or larger subnet in RFC1918, RFC6598 +(`100.64.0.0/10`), or Class E (`240.0.0.0/4`) space. It permits the subnet +CIDR—not individual revision IPs or a Cloud Run identity—to reach only the +selected application port on the VM. + +The Google network data source used here does not expose MTU. An +existing/Shared VPC caller must review the actual network and set +`gcp.network.mtu` as an explicit attestation; cloud-compose rejects power +management unless that value is Cloud Run's default `1460`. Managed networks +set `1460` directly. Changing the input without changing the real network does +not fix an MTU mismatch. + +The firewall source is a network trust zone: every workload allocated an +address from the subnet can reach the selected VM port. Prefer a dedicated +subnet, especially in Shared VPC, or share it only with workloads in the same +trust boundary. Keep free capacity for Cloud Run's `/28` allocation blocks, +steady-state use of twice as many addresses as service instances, overlapping +revisions, and the post-scale-down retention window. A nominal `/26` that is +already occupied is not sufficient. + +The proxy uses a 360-second power-on budget and a separate bounded 120-second +connection retry window, while Cloud Run allows the request 600 seconds. TCP +connection establishment is retried before request bytes are sent; PPB adds no +application-level request or response-status retry. Go's standard transport can +retry requests it defines as replayable when a pooled connection is found +stale. Connection resets after establishment remain retryable events for +clients. + +Cloud Run caps PPB at one active instance so concurrent requests share its +in-process power-transition coordinator. During a revision overlap, the old and +new instances can still race; PPB treats a failed start/resume as joinable when +the VM is then observed transitioning or running. + +Power management also requires an explicit `gcp.network.power_button_allowed_ips` +list. Cloud-compose does not prepend private or Direct VPC ranges: those are +backend network identities, not original web clients. PPB selects the client +from the right edge of `X-Forwarded-For`, fails closed when the header is +missing or too shallow, and uses `power_button_ip_depth` for any trusted proxy +hops appended after the client. Power management requires this value +explicitly: use `0` for the direct public Cloud Run URL, where the hosted +contract proves Cloud Run appends the original client at the right edge. An +external Application Load Balancer adds a different trusted suffix; set a +larger depth only after that exact topology is verified. [Google's load +balancer appends the client and forwarding-rule +addresses](https://cloud.google.com/load-balancing/docs/https#x-forwarded-for_header) +and does not validate caller-supplied values that precede them, so a +direct-path depth must never be silently reused behind another proxy. The +hosted direct-Cloud-Run smoke sends an attacker-controlled prefix and must +prove depth zero still selects the real runner before this filter is treated +as operational. This is a wake-up filter, not a replacement for application +authentication or Cloud Armor. + +Cloud Run recommends a startup probe that verifies an egress destination for +ordinary Direct VPC workloads. The power-button proxy is an intentional +exception: its `/healthcheck` startup probe verifies only that the PPB process +is ready. It cannot gate startup on the application VM because that VM is +allowed to be stopped, and the proxy must accept the first request in order to +start it. PPB's bounded backend dial loop is therefore the connectivity retry +boundary. A healthy startup probe does not prove that the Direct VPC path or +the stopped VM is reachable. + +When destroying or moving the Cloud Run revision, Google can retain its subnet +addresses for one to two hours. A same-apply destroy of a module-managed subnet +can therefore fail after the service is gone; wait for address release and +apply destroy again rather than manually deleting serverless reservations. +For a shared or persistent subnet, destroy only the application resources and +leave the network foundation in place. Disconnect all Cloud Run revisions and +wait for address release before a separately reviewed subnet or Shared VPC +decommission. + +User-managed app JSON keys are disabled by default. Set +`gcp.identity.app_credentials_enabled = true` only when an application +explicitly requires a file credential. That opt-in grants the VM GSA Key Admin +on the app GSA, rotates the key into +`/mnt/disks/data/cloud-compose/app/GOOGLE_APPLICATION_CREDENTIALS`, and +distributes it to each app's `secrets/GOOGLE_APPLICATION_CREDENTIALS`. Managed +Vault GCP IAM auth does not imply this option. + +GCP service-account key rotation has its own +`cloud-compose-key-rotation.timer`. Bootstrap runs its fail-closed check once, +but enables the recurring timer only when app file credentials or privileged +internal-service credentials are enabled. Rotation requires an explicit +`CLOUD_COMPOSE_PROVIDER=gcp`, a reachable metadata token endpoint, successful +IAM responses, and an organization policy that permits user-managed service +account key creation. Organizations commonly enforce +`constraints/iam.disableServiceAccountKeyCreation`; prefer the keyless defaults +instead of weakening that policy. Power management and explicitly enabled +internal services still use an internal-service JSON key and therefore require +a reviewed exception until that runtime is migrated to attached identity. +DigitalOcean, Linode, and on-premises hosts do not enable or invoke this path. + +Rotation preserves the prior credential, proves the replacement key through a +bounded OAuth JWT exchange, distributes it, and reloads only consumers that +were already active. It then disables—rather than immediately deletes—the old +key. `ROTATION_DISABLE_GRACE_SECONDS` controls the rollback window (24 hours by +default); a later timer pass deletes the disabled key, treating an IAM 404 as +the already-complete state. During grace, an operator can safely restore the +previous app credential with: + +```bash +sudo /home/cloud-compose/rotate-keys-app.sh rollback +``` + +To disable an existing app file credential, first leave +`app_credentials_enabled = true`, apply the 1.0 runtime, and retire the remote +key plus every distributed local copy: + +```bash +sudo /home/cloud-compose/rotate-keys-app.sh retire +``` + +Then set the input to false and apply again. The disabled runtime fails closed +if it finds a stale credential artifact; removing Key Admin without retiring +the already-issued key does not revoke that key. + +If the central JSON file was deleted before retirement, the runtime cannot +safely infer which remote private key it owned. It lists the remaining +user-managed key IDs and fails closed without deleting any of them. Audit and +explicitly revoke those IDs before disabling Key Admin. A missing local file is +reported as complete only when the service account has no user-managed key. + +An ambiguous key-creation response never triggers an automatic second create. +`rotate-keys.sh audit ...` reports only the baseline delta/key IDs. Recovery +requires the single audited orphan key ID as explicit confirmation to +`rotate-keys.sh recover ... KEY_ID`; if no key was created, recovery clears the +pending state without deleting anything. Recovery also waits +`ROTATION_RECOVERY_SETTLE_SECONDS` (60 seconds by default) before trusting an +empty IAM delta, avoiding a false “no key created” result during propagation. + +All Terraform entrypoints, including GCP, DigitalOcean, and Linode, support the +same verified rootfs archive contract. When `runtime.rootfs_archive_url` is +used, it must be an HTTPS URL without whitespace and +`runtime.rootfs_archive_sha256` is mandatory with a 64-character SHA-256 +digest. Boot restricts curl and redirects to HTTPS with TLS 1.2 or newer, +downloads to a temporary path, and verifies the complete file before extracting +its `rootfs` directory. The GCP path stages +the caller's packaged rootfs overlay and reapplies it after the archive, so +consumer overrides still win. Use an immutable archive URL; a moving branch and +a pinned checksum intentionally fail as soon as the branch content changes. + ## Backups `cloud-compose-mariadb-backup.timer` runs nightly between 9pm and 7am EST. It @@ -80,27 +549,241 @@ sitectl mariadb backup --context "$SITECTL_CONTEXT_NAME" --gzip --output "$path" for every app context. Backups are written under `/mnt/disks/data/backups/mariadb//` by default. +The backup and Compose lifecycle paths share one host lock, so a rollout, +service restart, key-rotation reload, and database dump cannot overlap. The +timer skips an intentionally inactive `cloud-compose.service`; it does not pull +that unit in as a dependency. Each dump is written under a private staging +directory, checked for non-zero size and valid gzip structure, and renamed into +the daily final path only after validation. An invalid pre-existing daily file +fails closed for operator review instead of being treated as a completed backup. +Cloud-compose does not delete retained backups automatically; downstream +operators must define reviewed retention and off-host recovery policy. + ## Power Management -`power_management_enabled` gates GCP-specific cost-saving behavior: +`gcp.power_management.enabled` is disabled by default and gates GCP-specific +cost-saving behavior: - Cloud Run proxy-power-button ingress - the `lightsout` internal-service profile +Enabling power management is an explicit opt-in to the privileged internal +services required by lightsout. Enabling internal services without power +management runs CAP/cAdvisor without the lightsout profile. When both are +disabled, Terraform does not create the internal-services Google service +account, KeyAdmin grant, monitoring writer grant, or suspend-role binding. +Any optional power-management frontend used for a production VM must use an +OCI image reference ending in an immutable `@sha256` digest. + +Apply `modules/gcp-foundation` first and pass its full start and suspend role +names into `gcp.power_management`. The application stack grants each service +account that role on its own VM instance only; it never grants either principal +power permissions across the project. Organization custom roles with the same +reviewed permission sets are also accepted when an organization foundation owns +them. + DigitalOcean and Linode deployments should leave power management disabled because stopped VMs do not provide the same cost profile. -## Hosted smoke cleanup credentials +### Upgrading to 1.0.0 + +Release `1.0.0` changes power management, privileged internal services, and +their automatic updates from implicit opt-out behavior to explicit opt-in. +Before upgrading, decide which behavior the deployment should retain: + +- To preserve the earlier GCP power-management stack, set + `gcp.power_management.enabled = true`, + set `gcp.power_management.start_role` and `suspend_role` from an already + applied foundation state, + set `gcp.network.power_button_allowed_ips` to reviewed original-client CIDRs, + set `gcp.network.power_button_ip_depth = 0` for direct public Cloud Run (or a + separately verified larger trusted-proxy suffix), + `runtime.managed_runtime.internal_services_enabled = true`, and + `runtime.managed_runtime.internal_services_auto_update = true` before + changing the module source ref. +- To preserve the legacy app service-account JSON key during the first 1.0 + apply, set `gcp.identity.app_credentials_enabled = true`. File credentials + are now an explicit compatibility mode, not the default. +- To adopt the safer defaults, leave power management and + `runtime.managed_runtime.internal_services_enabled` false. + +Cloud-init and identity changes can cause Terraform to replace a VM and remove +the internal-services service account and IAM bindings. Take and verify an +application backup, confirm the persistent data and Docker-volume disks remain +in the plan, and apply during a maintenance window. Never approve a plan that +destroys those disks unless data removal is intentional. + +Apply the singleton GCP foundation before the application upgrade. The new +application plan intentionally removes the legacy project-level start and +suspend IAM memberships and creates instance-level memberships for the same +application identities. It does not create the reusable custom-role definitions +inside each application state. Review those IAM deletions and replacements +explicitly; do not preserve the broader legacy memberships as a compatibility +shortcut. + +The upgrade also intentionally removes the legacy `roles/iam.serviceAccountUser` +membership that let the project's default Compute Engine workload service +account act as the VM service account. The Google-managed Compute Engine service +agent, not the default workload identity, performs service-account attachment. +Expect that legacy member to be deleted and do not re-add it to make the plan +smaller. + +Keep the caller's module source path and module block address unchanged during +this upgrade. The in-module `moved` blocks cover five legacy GCP addresses: +the internal-services identity, its Key Admin member, its monitoring member, +the app Key Admin member, and the legacy app self-signing member. The first four +preserve identity when their corresponding compatibility features remain +enabled. The fifth gives Terraform explicit deletion provenance when the new, +more narrowly scoped managed Vault signer grant is disabled. These moves cannot +migrate state between the provider-neutral root and `providers/gcp` +entrypoints, or between differently named caller module blocks. Treat either +refactor as a separate reviewed state migration using caller-owned `moved` +blocks or explicit `terraform state mv` commands. If old state records a +legacy provider source address, run a separately reviewed `terraform state +replace-provider` migration before planning; never edit state by hand. + +To move an existing deployment from file credentials to the keyless default, +use two reviewed applies. First upgrade with +`gcp.identity.app_credentials_enabled = true`, run +`/home/cloud-compose/rotate-keys-app.sh retire` on the VM, and verify that both +the remote user-managed key and distributed credential files are gone. Then +set the option to false and apply again. A host that is configured keyless but +still contains an app key fails closed instead of silently retaining it. + +The VM uses an ephemeral public address, so replacement can change it even when +both persistent disks survive. Inventory DNS records, TLS issuance/routing, and +external allowlists before applying; update and verify them against the new +address before ending the maintenance window. The hosted upgrade smoke proves +the module's state moves and disk/sentinel preservation on its fixture, not the +DNS, TLS, backups, or application-specific migrations of a downstream stack. + +The `GCP WordPress` pull-request check runs this upgrade path only for +pull-request titles beginning with `[major]`; other pull requests retain the +fresh-current GCP smoke. The gate provisions the exact `0.10.2` commit +`f33117cdbbf4a9c7d59006a4db986baef118e6bb` in one detached worktree and the +tested merge commit in another, with both configurations using one absolute +local-backend state path. Both phases use a pre-provisioned, same-project CI +network and regional subnet that are outside the disposable application state. +The `0.10.2` baseline predates Shared VPC support, and a persistent subnet also +prevents Direct VPC's one-to-two-hour address-retention window from turning an +otherwise successful destroy into a false upgrade failure. CI verifies that the +network uses MTU `1460`, that the subnet belongs to it in the configured region, +and that its supported IPv4 range is `/26` or larger. The subnet and network do +not use the disposable smoke prefix, so neither the application destroy nor the +fallback sweeper owns them. + +The fixture explicitly preserves power management, internal services, +internal-service automatic updates, and app file credentials. That keeps the +four identity-preserving moved resources in state while also exercising +deletion provenance for the obsolete app self-signing member, both legacy +project-level power memberships, the unused VM self-TokenCreator member, and +the legacy default Compute service-account impersonation member. Both phases +deploy the same +`libops/wp` Compose revision, +`5058610fddc7267ace92d65a5c49713dce570ac3`; an early exact checkout bridges +the legacy runtime's branch-only clone behavior without following a moving +branch. Its `gcp.cloud_init.initcmd` disables both generations of the +internal-service timer after cloud-init writes the units but before +`/home/cloud-compose/run.sh` starts the potentially long bootstrap, so the +disposable VM cannot suspend itself. The runner checks the units again after +each boot. +Only the ephemeral runner key is authorized, and SSH is limited to that +runner's public IPv4 `/32`. + +Before applying the current source, the gate requires Terraform plan JSON to +report all five `previous_address` transitions, requires the four preserved +resources plus the data and Docker-volume disks to be no-ops, requires removal +of the two legacy project-level power memberships, the legacy default Compute +service-account impersonation member, the unused VM self-TokenCreator member, +and the obsolete app self-signing member, and requires creation of the two +instance-level power memberships. It permits no other managed-resource +deletion beyond the expected VM and boot-disk replacements. It healthchecks +WordPress before and after the upgrade, +compares provider resource IDs, rejects legacy addresses in upgraded state, and +checks sentinels on both persistent disks. An exit trap performs Terraform +destroy followed by the name- and run-scoped provider sweep; the same GCP job +also has an `always()` cleanup step, and the trusted default-branch cleanup +workflow remains the final fallback. In GitHub Actions the cleanup scope must +match `GITHUB_RUN_ID`; a manual invocation must provide an explicit +`CLOUD_COMPOSE_SMOKE_RUN_ID`, preventing an implicit ref fragment from owning +an unrelated prior run's resources. Because `0.10.2` predates independent +sitectl package-version selectors, its baseline bootstrap still resolves the +then-current compatible package releases; the test freezes module source and +state shape, not that legacy package repository response. + +## Hosted smoke-test credentials + +The pull-request workflow and the emergency cleanup workflow use separate +GitHub Environments. This is a security boundary, not only an organizational +convention: + +| Purpose | GitHub Environment | Credential or configuration | +| --- | --- | --- | +| DigitalOcean create/test/destroy | `cloud-smoke-digitalocean` | `DIGITALOCEAN_TOKEN` | +| Linode create/test/destroy | `cloud-smoke-linode` | `LINODE_TOKEN` | +| GCP create/test/destroy | `cloud-smoke-gcp` | `GCLOUD_OIDC_POOL`, `GSA`, `GCLOUD_PROJECT`, optional `GCLOUD_REGION` | +| GCP major-version upgrade | `cloud-smoke-gcp` | the GCP values above plus `GCLOUD_NETWORK_PROJECT_ID`, `GCLOUD_NETWORK_NAME`, `GCLOUD_SUBNETWORK_NAME`, `GCLOUD_POWER_START_ROLE`, and `GCLOUD_POWER_SUSPEND_ROLE` | +| DigitalOcean fallback deletion | `cloud-smoke-cleanup-digitalocean` | a distinct cleanup-only `DIGITALOCEAN_TOKEN` | +| Linode fallback deletion | `cloud-smoke-cleanup-linode` | a distinct cleanup-only `LINODE_TOKEN` | +| GCP fallback deletion | `cloud-smoke-cleanup-gcp` | cleanup-specific `GCLOUD_OIDC_POOL`, `GSA`, `GCLOUD_PROJECT`, optional `GCLOUD_REGION` | + +Configure required reviewers and prevent self-review on the three +`cloud-smoke-*` environments. Permit only the same-repository feature branches +that the team intends to test; jobs for forks are skipped. A reviewer is +approving execution of code from that pull request with a credential that can +create cloud resources. Each running smoke job performs its Terraform destroy +in an `always()` step, so normal cleanup uses the already-approved environment +and does not create another deployment approval. + +Configure each `cloud-smoke-cleanup-*` environment with **no required +reviewers**, and set its deployment branch policy to the selected branch +`main` only. Those environments are consumed solely by the `workflow_run` +workflow loaded from the default branch. It checks out `github.sha` (the trusted +default-branch revision for that event), rejects fork-originated runs, and uses +the originating workflow run ID to constrain the sweep. This fallback runs +automatically after a failed, cancelled, or timed-out smoke workflow, including +cases where cancellation prevented the in-job destroy from finishing. Never +allow pull-request branches in a cleanup environment's deployment policy. + +Use separate provider identities for smoke and fallback cleanup: + +- The DigitalOcean cleanup token needs list/delete access only for firewalls, + droplets, and volumes. It does not need create access. +- The Linode cleanup token needs read/write access for firewalls, instances, and + volumes because Linode's token scopes combine mutation operations. It should + be issued in a dedicated smoke account with no production resources. +- The GCP cleanup service account needs + `run.services.get/list/delete/getIamPolicy/setIamPolicy`, + `compute.instances.list/delete`, `compute.firewalls.list/delete`, + `compute.disks.list/delete`, `compute.subnetworks.list/delete`, + `compute.networks.list/delete`, `iam.serviceAccounts.list/delete`, and + `resourcemanager.projects.getIamPolicy/setIamPolicy`. Bind the cleanup + workload-identity principal only to the `cloud-smoke-cleanup-gcp` environment + subject. No service-account key is stored in GitHub. -Pull-request smoke jobs destroy resources in the same already-approved job. -When a runner is cancelled or lost, the `workflow_run` fallback checks out only -trusted default-branch code and uses separate GitHub Environments: +For the `libops/cloud-compose` repository, the two accepted GitHub OIDC `sub` +claims are `repo:libops/cloud-compose:environment:cloud-smoke-gcp` for the smoke +identity and +`repo:libops/cloud-compose:environment:cloud-smoke-cleanup-gcp` for the cleanup +identity. Use separate workload-identity providers or exact attribute +conditions; do not authorize a repository-wide subject wildcard. -- `cloud-smoke-cleanup-digitalocean` -- `cloud-smoke-cleanup-linode` -- `cloud-smoke-cleanup-gcp` +The create/test identities need the broader permissions exercised by the +Terraform smoke modules. Put every provider's tests in an isolated smoke +project or account with quotas and billing alerts, never in a production +project. Store values as environment-level secrets or variables, and remove +all matching repository-level and organization-level secrets inherited by this +repository; otherwise pull-request code can bypass the intended environment +boundary. Only the two GCP jobs receive GitHub `id-token: write` permission. -Configure these cleanup environments without reviewer gates, restrict them to -the default branch, and give each a distinct least-privilege cleanup credential. -Do not expose a cleanup environment to pull-request-controlled code. The -ordinary smoke environments and their create/test credentials remain separate. +The GCP fallback removes the public Cloud Run invoker and Cloud Run service +first, then compute instances, firewall rules, and disks. It removes per-run +logging and monitoring members and any legacy pre-1.0 project-level power +members before deleting matching VM, internal, power-button, and app service +accounts. It deletes only smoke-prefixed regional subnetworks and their parent +VPC networks; it must not own the singleton foundation or the deliberately +persistent upgrade network. Every mutation is retried, failures are accumulated +so one stuck resource does not prevent unrelated resources from being examined, +and a final query requires zero matching disposable resources and IAM members. +The workflow fails if a mutation exhausts its retry budget or residual resources +remain, making leaks visible to operators. diff --git a/examples/app/main.tf b/examples/app/main.tf index 40031ad..68c74bc 100644 --- a/examples/app/main.tf +++ b/examples/app/main.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.2.4" + required_version = ">= 1.3.0" } module "app" { diff --git a/examples/archivesspace/main.tf b/examples/archivesspace/main.tf index 7d988cb..36a2d59 100644 --- a/examples/archivesspace/main.tf +++ b/examples/archivesspace/main.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.2.4" + required_version = ">= 1.3.0" } module "archivesspace" { diff --git a/examples/binpack/main.tf b/examples/binpack/main.tf index d494785..f72ac92 100644 --- a/examples/binpack/main.tf +++ b/examples/binpack/main.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.2.4" + required_version = ">= 1.3.0" } module "apps" { @@ -7,8 +7,7 @@ module "apps" { name = var.name gcp = { - project_id = var.project_id - project_number = var.project_number + project_id = var.project_id } runtime = { compose = { diff --git a/examples/binpack/variables.tf b/examples/binpack/variables.tf index 4ab76b5..9292615 100644 --- a/examples/binpack/variables.tf +++ b/examples/binpack/variables.tf @@ -6,7 +6,3 @@ variable "name" { variable "project_id" { type = string } - -variable "project_number" { - type = string -} diff --git a/examples/digitalocean/main.tf b/examples/digitalocean/main.tf index ae9c47d..a0ed4ea 100644 --- a/examples/digitalocean/main.tf +++ b/examples/digitalocean/main.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.2.4" + required_version = ">= 1.3.0" } module "wp" { @@ -18,6 +18,8 @@ module "wp" { } } runtime = { + rootfs_archive_url = "https://github.com/libops/cloud-compose/releases/download/${var.cloud_compose_source_ref}/cloud-compose-rootfs.tar.gz" + rootfs_archive_sha256 = var.cloud_compose_source_sha256 compose = { repo = var.docker_compose_repo branch = var.docker_compose_branch diff --git a/examples/digitalocean/variables.tf b/examples/digitalocean/variables.tf index a0a440c..d9b71af 100644 --- a/examples/digitalocean/variables.tf +++ b/examples/digitalocean/variables.tf @@ -28,6 +28,27 @@ variable "cloud_compose_ssh_keys" { description = "SSH public keys for the cloud-compose user." } +variable "cloud_compose_source_ref" { + type = string + default = "1.0.0" + description = "Exact cloud-compose release tag whose canonical rootfs asset is fetched by DigitalOcean cloud-init." + + validation { + condition = can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+$", trimspace(var.cloud_compose_source_ref))) + error_message = "cloud_compose_source_ref must be an exact semantic-version release tag." + } +} + +variable "cloud_compose_source_sha256" { + type = string + description = "SHA-256 of the canonical rootfs asset published for cloud_compose_source_ref." + + validation { + condition = can(regex("^[0-9a-fA-F]{64}$", trimspace(var.cloud_compose_source_sha256))) + error_message = "cloud_compose_source_sha256 must be a 64-character SHA-256 digest." + } +} + variable "docker_compose_repo" { type = string default = "" diff --git a/examples/drupal/main.tf b/examples/drupal/main.tf index d95a81f..242ce87 100644 --- a/examples/drupal/main.tf +++ b/examples/drupal/main.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.2.4" + required_version = ">= 1.3.0" } module "drupal" { diff --git a/examples/isle/main.tf b/examples/isle/main.tf index d3c008c..886ba7e 100644 --- a/examples/isle/main.tf +++ b/examples/isle/main.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.2.4" + required_version = ">= 1.3.0" } module "isle" { diff --git a/examples/linode/main.tf b/examples/linode/main.tf index 46fce12..34f358f 100644 --- a/examples/linode/main.tf +++ b/examples/linode/main.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.2.4" + required_version = ">= 1.3.0" } module "drupal" { @@ -20,7 +20,8 @@ module "drupal" { } } runtime = { - rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/${var.cloud_compose_source_ref}.tar.gz" + rootfs_archive_url = "https://github.com/libops/cloud-compose/releases/download/${var.cloud_compose_source_ref}/cloud-compose-rootfs.tar.gz" + rootfs_archive_sha256 = var.cloud_compose_source_sha256 compose = { repo = var.docker_compose_repo branch = var.docker_compose_branch diff --git a/examples/linode/variables.tf b/examples/linode/variables.tf index fc512c2..d65d708 100644 --- a/examples/linode/variables.tf +++ b/examples/linode/variables.tf @@ -43,8 +43,23 @@ variable "cloud_compose_ssh_keys" { variable "cloud_compose_source_ref" { type = string - default = "main" - description = "cloud-compose Git ref whose rootfs is fetched by Linode cloud-init." + default = "1.0.0" + description = "Exact cloud-compose release tag whose canonical rootfs asset is fetched by Linode cloud-init." + + validation { + condition = can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+$", trimspace(var.cloud_compose_source_ref))) + error_message = "cloud_compose_source_ref must be an exact semantic-version release tag." + } +} + +variable "cloud_compose_source_sha256" { + type = string + description = "SHA-256 of the canonical rootfs asset published for cloud_compose_source_ref." + + validation { + condition = can(regex("^[0-9a-fA-F]{64}$", trimspace(var.cloud_compose_source_sha256))) + error_message = "cloud_compose_source_sha256 must be a 64-character SHA-256 digest." + } } variable "docker_compose_repo" { diff --git a/examples/ojs/main.tf b/examples/ojs/main.tf index a7ab725..83ead6d 100644 --- a/examples/ojs/main.tf +++ b/examples/ojs/main.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.2.4" + required_version = ">= 1.3.0" } module "ojs" { diff --git a/examples/omeka-classic/main.tf b/examples/omeka-classic/main.tf index c249cb3..0321d5c 100644 --- a/examples/omeka-classic/main.tf +++ b/examples/omeka-classic/main.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.2.4" + required_version = ">= 1.3.0" } module "omeka_classic" { diff --git a/examples/omeka-s/main.tf b/examples/omeka-s/main.tf index abffad5..79779bd 100644 --- a/examples/omeka-s/main.tf +++ b/examples/omeka-s/main.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.2.4" + required_version = ">= 1.3.0" } module "omeka_s" { diff --git a/examples/wp/main.tf b/examples/wp/main.tf index d48efc1..0e32140 100644 --- a/examples/wp/main.tf +++ b/examples/wp/main.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.2.4" + required_version = ">= 1.3.0" } module "wp" { diff --git a/main.tf b/main.tf index 8371f1e..ac4a580 100644 --- a/main.tf +++ b/main.tf @@ -1,5 +1,28 @@ terraform { - required_version = ">= 1.2.4" + required_version = ">= 1.3.0" + + required_providers { + cloudinit = { + source = "hashicorp/cloudinit" + version = "~> 2.3" + } + digitalocean = { + source = "digitalocean/digitalocean" + version = "~> 2.0" + } + google = { + source = "hashicorp/google" + version = "~> 7.0" + } + linode = { + source = "linode/linode" + version = "~> 4.0" + } + time = { + source = "hashicorp/time" + version = "~> 0.14" + } + } } locals { @@ -14,6 +37,16 @@ locals { input_compose = var.runtime.compose input_sitectl = var.runtime.sitectl + sitectl_packages = distinct(concat( + ["sitectl"], + local.input_sitectl.packages == null ? local.template.packages : local.input_sitectl.packages, + )) + template_sitectl_package_versions = { + for package in local.sitectl_packages : + package => local.template.package_versions[package] + if contains(keys(local.template.package_versions), package) + } + runtime = merge(var.runtime, { compose = merge(local.input_compose, { repo = ( @@ -28,11 +61,8 @@ locals { ) }) sitectl = merge(local.input_sitectl, { - packages = ( - local.template_name != "" && length(local.input_sitectl.packages) == 1 && local.input_sitectl.packages[0] == "sitectl" - ? local.template.packages - : local.input_sitectl.packages - ) + packages = local.sitectl_packages + package_versions = merge(local.template_sitectl_package_versions, local.input_sitectl.package_versions) plugin = ( local.template_name != "" && local.input_sitectl.plugin == "core" ? local.template.plugin @@ -45,7 +75,13 @@ locals { sitectl = local.runtime.sitectl docker = local.runtime.docker managed = local.runtime.managed_runtime - vault = local.runtime.vault + vault = merge(local.runtime.vault, { + auth_method = ( + local.runtime.vault.auth_method == "auto" + ? (local.cloud_provider == "gcp" ? "gcp-iam" : "consumer-managed") + : local.runtime.vault.auth_method + ) + }) gcp_instance = var.gcp.instance gcp_disks = var.gcp.disks @@ -62,6 +98,7 @@ locals { rootfs = local.runtime.rootfs rootfs_archive_url = local.runtime.rootfs_archive_url rootfs_archive_sha256 = local.runtime.rootfs_archive_sha256 + users = local.runtime.users compose = local.compose sitectl = local.sitectl docker = local.docker @@ -69,7 +106,7 @@ locals { enabled = local.managed.enabled internal_services_enabled = local.managed.internal_services_enabled internal_services_auto_update = local.managed.internal_services_auto_update - artifacts = local.managed.artifacts + artifacts = module.managed_artifacts.artifacts } vault = { addr = local.vault.addr @@ -85,6 +122,12 @@ locals { } } +module "managed_artifacts" { + source = "./modules/managed-artifacts" + + artifacts = local.managed.artifacts +} + module "gcp" { count = local.cloud_provider == "gcp" ? 1 : 0 source = "./modules/gcp" @@ -97,6 +140,7 @@ module "gcp" { service_account_email = local.gcp_identity.vm_service_account_email app_service_account_email = local.gcp_identity.app_service_account_email + app_credentials_enabled = local.gcp_identity.app_credentials_enabled machine_type = local.gcp_instance.machine_type os = local.gcp_instance.os @@ -116,12 +160,13 @@ module "gcp" { docker_compose_down = local.compose.down docker_compose_rollout = local.compose.rollout - sitectl_packages = local.sitectl.packages - sitectl_version = local.sitectl.version - sitectl_context_name = local.sitectl.context_name - sitectl_plugin = local.sitectl.plugin - sitectl_environment = local.sitectl.environment - sitectl_verify_args = local.sitectl.verify_args + sitectl_packages = local.sitectl.packages + sitectl_version = local.sitectl.version + sitectl_package_versions = local.sitectl.package_versions + sitectl_context_name = local.sitectl.context_name + sitectl_plugin = local.sitectl.plugin + sitectl_environment = local.sitectl.environment + sitectl_verify_args = local.sitectl.verify_args docker_compose_version = local.docker.compose_version docker_buildx_version = local.docker.buildx_version @@ -129,30 +174,38 @@ module "gcp" { libops_managed_runtime_enabled = local.managed.enabled libops_internal_services_enabled = local.managed.internal_services_enabled libops_internal_services_auto_update = local.managed.internal_services_auto_update - libops_managed_artifacts = local.managed.artifacts + libops_managed_artifacts = module.managed_artifacts.artifacts - allowed_ips = local.gcp_network.power_button_allowed_ips - allowed_ssh_ipv4 = local.gcp_network.ssh_ipv4 - allowed_ssh_ipv6 = local.gcp_network.ssh_ipv6 + allowed_ips = local.gcp_network.power_button_allowed_ips + allowed_ip_forwarded_depth = local.gcp_network.power_button_ip_depth + allowed_ssh_ipv4 = local.gcp_network.ssh_ipv4 + allowed_ssh_ipv6 = local.gcp_network.ssh_ipv6 create_network = local.gcp_network.create + network_project_id = local.gcp_network.project_id network_name = local.gcp_network.name subnetwork_name = local.gcp_network.subnetwork network_ip_cidr_range = local.gcp_network.ip_cidr_range + network_mtu = local.gcp_network.mtu run_snapshots = local.gcp_snapshots.enabled overlay_source_instance = local.gcp_overlay.source_instance volume_names = local.gcp_overlay.volume_names - users = local.runtime.users - rootfs = local.runtime.rootfs - runcmd = local.gcp_cloud_init.runcmd - initcmd = local.gcp_cloud_init.initcmd + users = local.runtime.users + rootfs = local.runtime.rootfs + rootfs_archive_url = local.runtime.rootfs_archive_url + rootfs_archive_sha256 = local.runtime.rootfs_archive_sha256 + extra_env = local.runtime.extra_env + runcmd = local.gcp_cloud_init.runcmd + initcmd = local.gcp_cloud_init.initcmd artifact_registry_repository = local.gcp_artifact_registry.repository artifact_registry_location = local.gcp_artifact_registry.location power_management_enabled = local.gcp_power_management.enabled + power_start_role = local.gcp_power_management.start_role + power_suspend_role = local.gcp_power_management.suspend_role frontend = local.gcp_power_management.frontend rollout_enabled = local.gcp_rollout.enabled diff --git a/modules/digitalocean/main.tf b/modules/digitalocean/main.tf index 1e2d6e1..f155d65 100644 --- a/modules/digitalocean/main.tf +++ b/modules/digitalocean/main.tf @@ -18,7 +18,7 @@ module "runtime" { region = local.do.region data_device = "/dev/disk/by-id/scsi-0DO_Volume_${local.data_volume_name}" volumes_device = "/dev/disk/by-id/scsi-0DO_Volume_${local.docker_volumes_volume_name}" - ssh_users = local.do.ssh.users + ssh_users = merge(local.runtime.users, local.do.ssh.users) cloud_compose_ssh_keys = local.do.ssh.cloud_compose_keys rootfs = local.runtime.rootfs rootfs_archive_url = local.runtime.rootfs_archive_url @@ -34,14 +34,15 @@ module "runtime" { docker_compose_down = local.compose.down docker_compose_rollout = local.compose.rollout - sitectl_packages = local.sitectl.packages - sitectl_version = local.sitectl.version - sitectl_context_name = local.sitectl.context_name - sitectl_plugin = local.sitectl.plugin - sitectl_environment = local.sitectl.environment - sitectl_verify_args = local.sitectl.verify_args - docker_compose_version = local.docker.compose_version - docker_buildx_version = local.docker.buildx_version + sitectl_packages = local.sitectl.packages + sitectl_version = local.sitectl.version + sitectl_package_versions = local.sitectl.package_versions + sitectl_context_name = local.sitectl.context_name + sitectl_plugin = local.sitectl.plugin + sitectl_environment = local.sitectl.environment + sitectl_verify_args = local.sitectl.verify_args + docker_compose_version = local.docker.compose_version + docker_buildx_version = local.docker.buildx_version libops_managed_runtime_enabled = local.managed.enabled libops_internal_services_enabled = local.managed.internal_services_enabled @@ -64,21 +65,33 @@ locals { } resource "digitalocean_volume" "data" { - region = local.do.region - name = local.data_volume_name - size = local.do.volumes.data_size_gb - initial_filesystem_type = "ext4" - description = "cloud-compose persistent data for ${var.name}" - tags = local.do.tags + region = local.do.region + name = local.data_volume_name + size = local.do.volumes.data_size_gb + description = "cloud-compose persistent data for ${var.name}" + tags = local.do.tags + + lifecycle { + # Older cloud-compose releases asked DigitalOcean to format and automount + # this volume. Ignore that creation-only state when upgrading so removing + # the request never replaces a durable volume. New volumes stay raw until + # the verified host bootstrap formats and mounts them exactly once. + ignore_changes = [initial_filesystem_type] + } } resource "digitalocean_volume" "docker_volumes" { - region = local.do.region - name = local.docker_volumes_volume_name - size = local.do.volumes.docker_volumes_size_gb - initial_filesystem_type = "ext4" - description = "cloud-compose Docker volumes for ${var.name}" - tags = local.do.tags + region = local.do.region + name = local.docker_volumes_volume_name + size = local.do.volumes.docker_volumes_size_gb + description = "cloud-compose Docker volumes for ${var.name}" + tags = local.do.tags + + lifecycle { + # See the data-volume lifecycle above. Existing volume identity and data + # must survive the transition away from provider-side initial formatting. + ignore_changes = [initial_filesystem_type] + } } resource "digitalocean_droplet" "cloud_compose" { @@ -96,6 +109,14 @@ resource "digitalocean_droplet" "cloud_compose" { user_data = module.runtime.cloud_init lifecycle { + precondition { + # DigitalOcean accepts at most 64 KiB of raw user_data. Bounding the + # base64 representation at 87,380 characters conservatively caps the + # UTF-8 payload at 65,535 bytes without treating Unicode code points as + # bytes. Archive mode keeps the bootstrap comfortably below this limit. + condition = length(base64encode(module.runtime.cloud_init)) <= 87380 + error_message = "DigitalOcean Droplet user_data is limited to 64 KiB. Set runtime.rootfs_archive_url and runtime.rootfs_archive_sha256 so the verified cloud-compose rootfs is fetched during boot instead of embedded in cloud-init." + } precondition { condition = alltrue([ for _, app in module.runtime.compose_projects : trimspace(app.docker_compose_repo) != "" @@ -118,6 +139,10 @@ resource "digitalocean_droplet" "cloud_compose" { condition = !local.vault.agent_enabled || local.vault.auth_method != "consumer-managed" || trimspace(local.vault.agent_additional_config) != "" error_message = "vault_agent_additional_config is required when vault_agent_enabled uses consumer-managed auth." } + precondition { + condition = !local.managed.internal_services_enabled + error_message = "DigitalOcean does not support the GCP-specific privileged internal-services stack. Leave runtime.managed_runtime.internal_services_enabled false." + } } } diff --git a/modules/digitalocean/outputs.tf b/modules/digitalocean/outputs.tf index 0c88cf8..0becaeb 100644 --- a/modules/digitalocean/outputs.tf +++ b/modules/digitalocean/outputs.tf @@ -34,3 +34,8 @@ output "primary_compose_project" { value = module.runtime.primary_compose_project description = "Normalized primary compose project." } + +output "sitectl_package_versions" { + value = module.runtime.sitectl_package_versions + description = "Effective release selector for every installed sitectl package; values may be exact tags or latest." +} diff --git a/modules/digitalocean/runtime_inputs.tftest.hcl b/modules/digitalocean/runtime_inputs.tftest.hcl new file mode 100644 index 0000000..19c9150 --- /dev/null +++ b/modules/digitalocean/runtime_inputs.tftest.hcl @@ -0,0 +1,90 @@ +mock_provider "digitalocean" {} + +run "merges_provider_neutral_and_provider_specific_ssh_users" { + command = plan + + variables { + name = "do-contract" + digitalocean = { + ssh = { + users = { + shared = ["ssh-ed25519 AAAAPROVIDER"] + provider-only = ["ssh-ed25519 AAAADO"] + } + } + } + runtime = { + rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz" + rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + users = { + shared = ["ssh-ed25519 AAAARUNTIME"] + runtime-only = ["ssh-ed25519 AAAANEUTRAL"] + } + compose = { + repo = "https://github.com/libops/wp.git" + } + } + } + + assert { + condition = ( + strcontains(module.runtime.cloud_init, "ssh-ed25519 AAAAPROVIDER") && + strcontains(module.runtime.cloud_init, "ssh-ed25519 AAAADO") && + strcontains(module.runtime.cloud_init, "ssh-ed25519 AAAANEUTRAL") && + !strcontains(module.runtime.cloud_init, "ssh-ed25519 AAAARUNTIME") + ) + error_message = "Provider-specific SSH users must override matching provider-neutral users while preserving distinct entries." + } +} + +run "exposes_independent_sitectl_package_versions" { + command = plan + + variables { + name = "do-contract" + runtime = { + rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz" + rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + compose = { + repo = "https://github.com/libops/isle.git" + } + sitectl = { + packages = ["sitectl-drupal", "sitectl-isle"] + version = "v0.38.0" + package_versions = { + sitectl-drupal = "v0.11.0" + sitectl-isle = "v0.12.0" + } + } + } + } + + assert { + condition = output.sitectl_package_versions == { + sitectl = "v0.38.0" + sitectl-drupal = "v0.11.0" + sitectl-isle = "v0.12.0" + } + error_message = "The DigitalOcean module must expose the effective version selector for every package." + } +} + +run "rejects_reserved_extra_environment" { + command = plan + + variables { + name = "do-contract" + runtime = { + rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz" + rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + compose = { + repo = "https://github.com/libops/wp.git" + } + extra_env = { + CLOUD_COMPOSE_PROVIDER = "gcp" + } + } + } + + expect_failures = [var.runtime] +} diff --git a/modules/digitalocean/variables.tf b/modules/digitalocean/variables.tf index af72b66..3cd41d0 100644 --- a/modules/digitalocean/variables.tf +++ b/modules/digitalocean/variables.tf @@ -9,7 +9,7 @@ variable "name" { } variable "digitalocean" { - description = "DigitalOcean infrastructure settings." + description = "DigitalOcean infrastructure settings. droplet.backups covers only the Droplet boot disk; attached application and Docker volumes require a separate offsite backup policy." type = object({ region = optional(string, "tor1") tags = optional(list(string), ["cloud-compose"]) @@ -49,6 +49,7 @@ variable "runtime" { rootfs = optional(string, "") rootfs_archive_url = optional(string, "") rootfs_archive_sha256 = optional(string, "") + users = optional(map(list(string)), {}) compose = optional(object({ primary = optional(string, "") @@ -84,8 +85,8 @@ variable "runtime" { sitectl_context_name = optional(string) sitectl_plugin = optional(string) sitectl_environment = optional(string) - sitectl_packages = optional(list(string), []) - sitectl_verify_args = optional(list(string), []) + sitectl_packages = optional(list(string)) + sitectl_verify_args = optional(list(string)) docker_compose_init = optional(list(string)) docker_compose_up = optional(list(string)) docker_compose_down = optional(list(string)) @@ -98,12 +99,13 @@ variable "runtime" { }), {}) sitectl = optional(object({ - packages = optional(list(string), ["sitectl"]) - version = optional(string, "latest") - context_name = optional(string, "") - plugin = optional(string, "core") - environment = optional(string, "production") - verify_args = optional(list(string), []) + packages = optional(list(string), ["sitectl"]) + version = optional(string, "latest") + package_versions = optional(map(string), {}) + context_name = optional(string, "") + plugin = optional(string, "core") + environment = optional(string, "production") + verify_args = optional(list(string), []) }), {}) docker = optional(object({ @@ -149,19 +151,55 @@ variable "runtime" { }) default = {} + validation { + condition = ( + (trimspace(var.runtime.rootfs_archive_url) == "") == (trimspace(var.runtime.rootfs_archive_sha256) == "") && + (trimspace(var.runtime.rootfs_archive_sha256) == "" || can(regex("^[0-9a-fA-F]{64}$", trimspace(var.runtime.rootfs_archive_sha256)))) + ) + error_message = "runtime.rootfs_archive_url and a 64-character runtime.rootfs_archive_sha256 must be supplied together." + } + validation { condition = alltrue([ - for name, app in var.runtime.compose.projects : - can(regex("^[a-z][a-z0-9-]*$", name)) && - trimspace(app.docker_compose_repo) != "" && - try(app.ingress_port, var.runtime.compose.ingress_port) > 0 && - try(app.ingress_port, var.runtime.compose.ingress_port) <= 65535 + for name in keys(var.runtime.extra_env) : + can(regex("^[A-Za-z_][A-Za-z0-9_]*$", name)) && + !contains(["HOME", "PATH"], name) && + alltrue([ + for prefix in [ + "CLOUD_COMPOSE_", + "COMPOSE_", + "DOCKER_", + "SITECTL_", + "LIBOPS_", + "GCP_", + "VAULT_", + "ROLLOUT_", + "POWER_MANAGEMENT_", + ] : !startswith(name, prefix) + ]) ]) - error_message = "runtime.compose.projects keys must match ^[a-z][a-z0-9-]*$, docker_compose_repo is required, and ingress_port must be between 1 and 65535." + error_message = "runtime.extra_env names must be valid environment names and must not override cloud-compose control-plane keys (HOME, PATH, or CLOUD_COMPOSE_/COMPOSE_/DOCKER_/SITECTL_/LIBOPS_/GCP_/VAULT_/ROLLOUT_/POWER_MANAGEMENT_ prefixes)." + } + + validation { + condition = ( + var.runtime.compose.ingress_port >= 1 && + var.runtime.compose.ingress_port <= 65535 && + floor(var.runtime.compose.ingress_port) == var.runtime.compose.ingress_port && + alltrue([ + for name, app in var.runtime.compose.projects : + can(regex("^[a-z][a-z0-9-]*$", name)) && + trimspace(app.docker_compose_repo) != "" && + coalesce(try(app.ingress_port, null), var.runtime.compose.ingress_port) >= 1 && + coalesce(try(app.ingress_port, null), var.runtime.compose.ingress_port) <= 65535 && + floor(coalesce(try(app.ingress_port, null), var.runtime.compose.ingress_port)) == coalesce(try(app.ingress_port, null), var.runtime.compose.ingress_port) + ]) + ) + error_message = "runtime.compose.projects keys must match ^[a-z][a-z0-9-]*$, docker_compose_repo is required, and ingress ports must be whole numbers between 1 and 65535." } validation { - condition = contains(["gcp-iam", "consumer-managed"], var.runtime.vault.auth_method) - error_message = "runtime.vault.auth_method must be gcp-iam or consumer-managed." + condition = var.runtime.vault.auth_method == "consumer-managed" + error_message = "runtime.vault.auth_method must be consumer-managed on DigitalOcean." } } diff --git a/modules/digitalocean/versions.tf b/modules/digitalocean/versions.tf index e6e546b..85bd4c7 100644 --- a/modules/digitalocean/versions.tf +++ b/modules/digitalocean/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.2.4" + required_version = ">= 1.3.0" required_providers { digitalocean = { diff --git a/modules/gcp-foundation/README.md b/modules/gcp-foundation/README.md new file mode 100644 index 0000000..733da3e --- /dev/null +++ b/modules/gcp-foundation/README.md @@ -0,0 +1,184 @@ +# GCP foundation + +This module owns the singleton Google Cloud prerequisites shared by Cloud +Compose application stacks. Keep it in a small, long-lived Terraform state and +apply it before any per-site or per-application state. + +It manages, by default: + +- `cloudresourcemanager.googleapis.com`, `compute.googleapis.com`, + `iam.googleapis.com`, `iamcredentials.googleapis.com`, + `logging.googleapis.com`, `monitoring.googleapis.com`, and + `run.googleapis.com`; +- the Cloud Run Google-managed service identity, created explicitly after the + API is enabled so Shared VPC IAM does not race service-agent provisioning; +- the documented `roles/run.serviceAgent` binding for that identity in the + service project; +- the project-scoped `cloudComposeStart` role with only + `compute.instances.get`, `compute.instances.start`, and + `compute.instances.resume`; +- the project-scoped `cloudComposeSuspend` role with only + `compute.instances.get` and `compute.instances.suspend`; +- Cloud Run Direct VPC IAM when the VPC host project differs from the service + project; and +- optionally, the Shared VPC service-project attachment itself. + +The module queries the service project and derives the Cloud Run service agent +`service-PROJECT_NUMBER@serverless-robot-prod.iam.gserviceaccount.com` from +authoritative project metadata. Downstream callers supply only the project ID, +not a separate numeric identity. + +The two custom roles are reusable definitions. They are project-scoped because +Google custom roles must have a project or organization parent, but this module +does not grant them to application principals. Each Cloud Compose application +state consumes the role names and binds its proxy and internal-services service +accounts only to that application's Compute Engine instance. + +## Lifecycle contract + +These resources are deliberately safer than ordinary app-stack resources: + +- Required APIs use `disable_on_destroy = false` and `deletion_policy = + "ABANDON"`, so removing the foundation state does not disable APIs used by + surviving workloads. +- Custom roles use `deletion_policy = "PREVENT"` plus Terraform + `prevent_destroy`. A normal destroy or a change that would replace either + role fails at planning instead of soft-deleting a long-lived project role. + Change both controls only during a reviewed decommission. +- The Cloud Run service-agent role membership also uses `prevent_destroy`, so + external ownership of the power roles cannot make an ordinary foundation + destroy revoke a Google-managed identity that surviving services require. +- A module-managed Shared VPC attachment uses `deletion_policy = "ABANDON"` so + state removal does not unexpectedly detach a service project. +- Shared VPC IAM uses non-authoritative member resources. Network Viewer is + scoped to the host project, while Network User is scoped to one explicit + regional subnet. + +Because role deletion is prevented, this module is not intended to be destroyed +as part of an application teardown. Disconnect every Cloud Run revision, retire +all application states, verify that no other workloads consume the APIs, roles, +service identity, subnets, or Shared VPC attachment, and wait for Direct VPC +addresses to be released before a separately reviewed decommission. Do not +disable the APIs or detach Shared VPC merely because one site is removed. + +## Usage + +Same-project networking: + +```hcl +module "gcp_foundation" { + source = "github.com/libops/cloud-compose//modules/gcp-foundation?ref=1.0.0" + + service_project_id = "library-production" +} +``` + +Shared VPC whose association should also be managed here: + +```hcl +module "gcp_foundation" { + source = "github.com/libops/cloud-compose//modules/gcp-foundation?ref=1.0.0" + + service_project_id = "library-production" + host_project_id = "organization-network" + shared_vpc_subnetworks = [{ + name = "cloud-run-us-east5" + region = "us-east5" + }] + attach_shared_vpc = true +} +``` + +Use an exact reviewed release or full commit in production. Keep this module in +a state whose lifetime is the service project, not the application. Apply it +before any application state and publish only the role-name outputs needed by +those states. + +For same-project networking, this module creates no additional Network Viewer +or Network User membership. It manages the Cloud Run service agent's standard +`roles/run.serviceAgent` membership in the service project, restoring that +documented grant if an earlier project policy removed it. + +When `host_project_id` differs from `service_project_id`, at least one explicit +subnet is required even if the Shared VPC attachment is managed in a different +state. Add every subnet used by Cloud Compose stacks in that service project. +The module always grants the derived Cloud Run service agent +`roles/compute.networkViewer` on the host project and +`roles/compute.networkUser` on that subnet, matching the Direct VPC Shared VPC +contract without granting Network User across the whole host project. + +In each consuming application, set `gcp.network.project_id` to the host project +and prefer full resource names so project and region selection is explicit: + +```text +projects/HOST_PROJECT/global/networks/NETWORK +projects/HOST_PROJECT/regions/REGION/subnetworks/SUBNET +``` + +The application rejects a project mismatch, a subnet attached to a different +network, or a subnet outside the Cloud Run region. Its deployment identity also +needs separately reviewed permission to inspect and use the host network and to +manage application-specific firewall rules; this foundation's service-agent IAM +does not grant permissions to the Terraform caller. + +This least-privilege contract targets IPv4 and internal-IPv6 subnets. A +dual-stack subnet with external IPv6 also requires Compute Public IP Admin; +manage that exceptional grant separately rather than broadening this module's +default role set. Size Direct VPC subnets to `/26` or larger in the network +foundation or consuming app-stack validation, use Cloud Run's default MTU +`1460`, and reserve capacity for `/28` allocation blocks, two addresses per +steady-state service instance, and overlapping revisions. Cloud Run addresses +are ephemeral, so firewall rules must use the entire subnet CIDR rather than +individual revision addresses. + +Existing-network callers attest the reviewed network's real MTU through the +application input; changing the Terraform value cannot correct a network whose +actual MTU differs. Cloud Run can retain addresses for one to two hours after a +revision is disconnected. Prefer a persistent shared subnet where lifecycle +independence matters, and wait for address release before deleting or moving +that subnet. A persistent CI subnet is especially useful for upgrade tests: it +keeps delayed serverless address release from making disposable application +teardown flaky and must remain outside smoke-name cleanup ownership. + +Set `manage_project_services = false` or `manage_power_roles = false` only when +another long-lived foundation state owns those exact resources. Role-name +outputs remain canonical in externally managed mode. Treat these booleans as +initial ownership choices: after this module creates a `PREVENT` role, hand its +state to another foundation deliberately rather than toggling +`manage_power_roles` and expecting Terraform to delete it. + +The identity running Terraform needs permission to inspect the service project, +enable project services, manage project custom roles, and update IAM on the +Shared VPC host project and subnet. `serviceusage.googleapis.com` must already +be enabled so Terraform can enable the seven workload APIs. + +The separate identity that applies each application state also needs +`iam.serviceAccounts.actAs` (normally `roles/iam.serviceAccountUser`) on the VM +service account it attaches. Do not grant that role to the project's default +Compute Engine workload service account; the app module deliberately removes +that legacy impersonation edge. + +See Google's [Direct VPC Shared VPC +guide](https://cloud.google.com/run/docs/configuring/shared-vpc-direct-vpc) for +the service-agent IAM and subnet-sizing contract. + +## Inputs + +| Name | Default | Description | +| --- | --- | --- | +| `service_project_id` | required | Project that owns Cloud Run and Compute workloads. | +| `host_project_id` | `""` | Shared VPC host; empty means the service project. | +| `shared_vpc_subnetworks` | `[]` | Explicit Shared VPC subnet names and regions available to Direct VPC egress. | +| `manage_project_services` | `true` | Manage the seven required APIs. | +| `manage_power_roles` | `true` | Manage the two least-privilege custom roles. | +| `attach_shared_vpc` | `false` | Manage the Shared VPC service-project association. | + +## Outputs + +| Name | Description | +| --- | --- | +| `project_number` | Derived numeric service project ID. | +| `cloud_run_service_agent_email` | Derived Cloud Run service-agent email. | +| `cloud_run_service_agent_member` | IAM member form of the service agent. | +| `cloud_compose_start_role_name` | Canonical start/resume custom-role name. | +| `cloud_compose_suspend_role_name` | Canonical suspend custom-role name. | diff --git a/modules/gcp-foundation/foundation.tftest.hcl b/modules/gcp-foundation/foundation.tftest.hcl new file mode 100644 index 0000000..0add7ea --- /dev/null +++ b/modules/gcp-foundation/foundation.tftest.hcl @@ -0,0 +1,274 @@ +mock_provider "google" { + mock_data "google_project" { + override_during = plan + defaults = { + number = "123456789012" + } + } +} + +mock_provider "google-beta" { + mock_resource "google_project_service_identity" { + override_during = plan + defaults = { + email = "service-123456789012@serverless-robot-prod.iam.gserviceaccount.com" + member = "serviceAccount:service-123456789012@serverless-robot-prod.iam.gserviceaccount.com" + } + } +} + +run "creates_long_lived_service_and_role_foundation" { + command = plan + + variables { + service_project_id = "service-project" + } + + assert { + condition = ( + toset(keys(google_project_service.required)) == toset([ + "cloudresourcemanager.googleapis.com", + "compute.googleapis.com", + "iam.googleapis.com", + "iamcredentials.googleapis.com", + "logging.googleapis.com", + "monitoring.googleapis.com", + "run.googleapis.com", + ]) && + alltrue([ + for name, service in google_project_service.required : + service.project == "service-project" && service.service == name + ]) + ) + error_message = "The default foundation must manage exactly the Cloud Resource Manager, Compute Engine, IAM, IAM Credentials, Logging, Monitoring, and Cloud Run APIs." + } + + assert { + condition = alltrue([ + for service in google_project_service.required : + !service.disable_on_destroy && service.deletion_policy == "ABANDON" + ]) + error_message = "Required APIs must remain enabled and be abandoned instead of disabled during state removal." + } + + assert { + condition = ( + length(google_project_iam_custom_role.cloud_compose_start) == 1 && + google_project_iam_custom_role.cloud_compose_start[0].project == "service-project" && + google_project_iam_custom_role.cloud_compose_start[0].role_id == "cloudComposeStart" && + google_project_iam_custom_role.cloud_compose_start[0].deletion_policy == "PREVENT" && + toset(google_project_iam_custom_role.cloud_compose_start[0].permissions) == toset([ + "compute.instances.get", + "compute.instances.resume", + "compute.instances.start", + ]) + ) + error_message = "cloudComposeStart must be long-lived and limited to get, resume, and start." + } + + assert { + condition = ( + length(google_project_iam_custom_role.cloud_compose_suspend) == 1 && + google_project_iam_custom_role.cloud_compose_suspend[0].project == "service-project" && + google_project_iam_custom_role.cloud_compose_suspend[0].role_id == "cloudComposeSuspend" && + google_project_iam_custom_role.cloud_compose_suspend[0].deletion_policy == "PREVENT" && + toset(google_project_iam_custom_role.cloud_compose_suspend[0].permissions) == toset([ + "compute.instances.get", + "compute.instances.suspend", + ]) + ) + error_message = "cloudComposeSuspend must be long-lived and limited to get and suspend." + } + + assert { + condition = ( + output.cloud_run_service_agent_email == "service-123456789012@serverless-robot-prod.iam.gserviceaccount.com" && + output.cloud_run_service_agent_member == "serviceAccount:service-123456789012@serverless-robot-prod.iam.gserviceaccount.com" && + output.cloud_compose_start_role_name == "projects/service-project/roles/cloudComposeStart" && + output.cloud_compose_suspend_role_name == "projects/service-project/roles/cloudComposeSuspend" + ) + error_message = "Foundation service-agent and role outputs must use canonical project-derived names." + } + + + assert { + condition = ( + google_project_service_identity.cloud_run.project == "service-project" && + google_project_service_identity.cloud_run.service == "run.googleapis.com" && + google_project_iam_member.cloud_run_service_agent.project == "service-project" && + google_project_iam_member.cloud_run_service_agent.role == "roles/run.serviceAgent" && + google_project_iam_member.cloud_run_service_agent.member == output.cloud_run_service_agent_member + ) + error_message = "The foundation must explicitly materialize the Cloud Run service agent and restore its documented service-project role before granting network IAM." + } + + assert { + condition = ( + length(google_compute_shared_vpc_service_project.attachment) == 0 && + length(google_project_iam_member.cloud_run_network_viewer) == 0 && + length(google_compute_subnetwork_iam_member.cloud_run_network_user) == 0 + ) + error_message = "Same-project networking must not create Shared VPC attachment or cross-project IAM resources." + } +} + +run "configures_explicit_shared_vpc_access" { + command = plan + + variables { + service_project_id = "service-project" + host_project_id = "network-host-project" + shared_vpc_subnetworks = [ + { + name = "cloud-run-egress" + region = "us-east5" + }, + { + name = "cloud-run-egress-west" + region = "us-west1" + }, + ] + attach_shared_vpc = true + } + + assert { + condition = ( + length(google_compute_shared_vpc_service_project.attachment) == 1 && + google_compute_shared_vpc_service_project.attachment[0].host_project == "network-host-project" && + google_compute_shared_vpc_service_project.attachment[0].service_project == "service-project" && + google_compute_shared_vpc_service_project.attachment[0].deletion_policy == "ABANDON" + ) + error_message = "The optional Shared VPC attachment must be explicit and abandoned rather than detached on state removal." + } + + assert { + condition = ( + length(google_project_iam_member.cloud_run_network_viewer) == 1 && + google_project_iam_member.cloud_run_network_viewer[0].project == "network-host-project" && + google_project_iam_member.cloud_run_network_viewer[0].role == "roles/compute.networkViewer" && + google_project_iam_member.cloud_run_network_viewer[0].member == output.cloud_run_service_agent_member + ) + error_message = "Cloud Run must receive Compute Network Viewer only at the Shared VPC host project." + } + + assert { + condition = ( + length(google_compute_subnetwork_iam_member.cloud_run_network_user) == 2 && + alltrue([ + for key, binding in google_compute_subnetwork_iam_member.cloud_run_network_user : + binding.project == "network-host-project" && + binding.role == "roles/compute.networkUser" && + binding.member == output.cloud_run_service_agent_member && + key == "${binding.region}/${binding.subnetwork}" + ]) + ) + error_message = "Cloud Run Network User must be scoped to the explicit regional Shared VPC subnet." + } +} + +run "supports_an_externally_managed_shared_vpc_attachment" { + command = plan + + variables { + service_project_id = "service-project" + host_project_id = "network-host-project" + shared_vpc_subnetworks = [{ + name = "cloud-run-egress" + region = "us-east5" + }] + attach_shared_vpc = false + } + + assert { + condition = ( + length(google_compute_shared_vpc_service_project.attachment) == 0 && + length(google_project_iam_member.cloud_run_network_viewer) == 1 && + length(google_compute_subnetwork_iam_member.cloud_run_network_user) == 1 + ) + error_message = "An externally managed Shared VPC association must still receive the required Cloud Run host and subnet grants." + } +} + +run "supports_externally_managed_services_and_roles" { + command = plan + + variables { + service_project_id = "service-project" + manage_project_services = false + manage_power_roles = false + } + + assert { + condition = ( + length(google_project_service.required) == 0 && + length(google_project_iam_custom_role.cloud_compose_start) == 0 && + length(google_project_iam_custom_role.cloud_compose_suspend) == 0 && + output.cloud_compose_start_role_name == "projects/service-project/roles/cloudComposeStart" && + output.cloud_compose_suspend_role_name == "projects/service-project/roles/cloudComposeSuspend" + ) + error_message = "External ownership must suppress singleton resources while preserving canonical role references." + } +} + +run "supports_legacy_domain_scoped_project_ids" { + command = plan + + variables { + service_project_id = "example.com:service-project" + } + + assert { + condition = ( + output.cloud_compose_start_role_name == "projects/example.com:service-project/roles/cloudComposeStart" && + output.cloud_compose_suspend_role_name == "projects/example.com:service-project/roles/cloudComposeSuspend" + ) + error_message = "Legacy domain-scoped project IDs must produce valid canonical custom-role names." + } +} + +run "rejects_invalid_project_id" { + command = plan + + variables { + service_project_id = "INVALID_PROJECT" + } + + expect_failures = [var.service_project_id] +} + +run "requires_explicit_shared_vpc_subnet" { + command = plan + + variables { + service_project_id = "service-project" + host_project_id = "network-host-project" + } + + expect_failures = [data.google_project.service] +} + +run "rejects_same_project_attachment" { + command = plan + + variables { + service_project_id = "service-project" + host_project_id = "service-project" + attach_shared_vpc = true + } + + expect_failures = [data.google_project.service] +} + +run "rejects_invalid_shared_vpc_subnetwork" { + command = plan + + variables { + service_project_id = "service-project" + shared_vpc_subnetworks = [{ + name = "Invalid_Subnet" + region = "US_EAST5" + }] + } + + expect_failures = [var.shared_vpc_subnetworks] +} diff --git a/modules/gcp-foundation/main.tf b/modules/gcp-foundation/main.tf new file mode 100644 index 0000000..68c39f0 --- /dev/null +++ b/modules/gcp-foundation/main.tf @@ -0,0 +1,173 @@ +locals { + service_project_id = trimspace(var.service_project_id) + host_project_id = ( + trimspace(var.host_project_id) != "" + ? trimspace(var.host_project_id) + : local.service_project_id + ) + uses_shared_vpc = local.host_project_id != local.service_project_id + shared_vpc_subnetworks = { + for subnet in var.shared_vpc_subnetworks : + "${trimspace(subnet.region)}/${trimspace(subnet.name)}" => { + name = trimspace(subnet.name) + region = trimspace(subnet.region) + } + } + + required_services = toset([ + "cloudresourcemanager.googleapis.com", + "compute.googleapis.com", + "iam.googleapis.com", + "iamcredentials.googleapis.com", + "logging.googleapis.com", + "monitoring.googleapis.com", + "run.googleapis.com", + ]) + + project_number = tostring(data.google_project.service.number) + expected_cloud_run_service_agent_email = "service-${local.project_number}@serverless-robot-prod.iam.gserviceaccount.com" + cloud_compose_start_role_name = "projects/${local.service_project_id}/roles/cloudComposeStart" + cloud_compose_suspend_role_name = "projects/${local.service_project_id}/roles/cloudComposeSuspend" +} + +data "google_project" "service" { + project_id = local.service_project_id + + depends_on = [google_project_service.required] + + lifecycle { + precondition { + condition = ( + !local.uses_shared_vpc || + length(local.shared_vpc_subnetworks) > 0 + ) + error_message = "At least one shared_vpc_subnetworks entry is required when host_project_id differs from service_project_id." + } + + precondition { + condition = !var.attach_shared_vpc || local.uses_shared_vpc + error_message = "attach_shared_vpc can be true only when host_project_id differs from service_project_id." + } + } +} + +resource "google_project_service" "required" { + for_each = var.manage_project_services ? local.required_services : toset([]) + + project = local.service_project_id + service = each.value + disable_on_destroy = false + deletion_policy = "ABANDON" +} + +# Enabling run.googleapis.com normally creates this Google-managed identity, +# but making the one-time service-identity call explicit removes an eventual +# consistency race before Shared VPC IAM is applied. +resource "google_project_service_identity" "cloud_run" { + provider = google-beta + + project = local.service_project_id + service = "run.googleapis.com" + + lifecycle { + postcondition { + condition = self.email == local.expected_cloud_run_service_agent_email + error_message = "Cloud Run returned an unexpected service-agent identity for service_project_id." + } + } + + depends_on = [google_project_service.required] +} + +# Keep the Google-managed Cloud Run identity on its documented service-agent +# role even when a project IAM policy was previously edited by hand. +resource "google_project_iam_member" "cloud_run_service_agent" { + project = local.service_project_id + role = "roles/run.serviceAgent" + member = google_project_service_identity.cloud_run.member + + lifecycle { + prevent_destroy = true + } +} + +resource "google_project_iam_custom_role" "cloud_compose_start" { + count = var.manage_power_roles ? 1 : 0 + + project = local.service_project_id + role_id = "cloudComposeStart" + title = "Cloud Compose Start" + description = "Allows the Cloud Compose power controller to inspect and start or resume Compute Engine instances." + permissions = [ + "compute.instances.get", + "compute.instances.resume", + "compute.instances.start", + ] + stage = "GA" + deletion_policy = "PREVENT" + + lifecycle { + prevent_destroy = true + } + + depends_on = [google_project_service.required] +} + +resource "google_project_iam_custom_role" "cloud_compose_suspend" { + count = var.manage_power_roles ? 1 : 0 + + project = local.service_project_id + role_id = "cloudComposeSuspend" + title = "Cloud Compose Suspend" + description = "Allows the Cloud Compose runtime to inspect and suspend Compute Engine instances." + permissions = [ + "compute.instances.get", + "compute.instances.suspend", + ] + stage = "GA" + deletion_policy = "PREVENT" + + lifecycle { + prevent_destroy = true + } + + depends_on = [google_project_service.required] +} + +resource "google_compute_shared_vpc_service_project" "attachment" { + count = var.attach_shared_vpc ? 1 : 0 + + host_project = local.host_project_id + service_project = local.service_project_id + deletion_policy = "ABANDON" + + depends_on = [google_project_service.required] +} + +resource "google_project_iam_member" "cloud_run_network_viewer" { + count = local.uses_shared_vpc ? 1 : 0 + + project = local.host_project_id + role = "roles/compute.networkViewer" + member = google_project_service_identity.cloud_run.member + + depends_on = [ + google_project_service.required, + google_compute_shared_vpc_service_project.attachment, + ] +} + +resource "google_compute_subnetwork_iam_member" "cloud_run_network_user" { + for_each = local.uses_shared_vpc ? local.shared_vpc_subnetworks : {} + + project = local.host_project_id + region = each.value.region + subnetwork = each.value.name + role = "roles/compute.networkUser" + member = google_project_service_identity.cloud_run.member + + depends_on = [ + google_project_service.required, + google_compute_shared_vpc_service_project.attachment, + ] +} diff --git a/modules/gcp-foundation/outputs.tf b/modules/gcp-foundation/outputs.tf new file mode 100644 index 0000000..2b4bef0 --- /dev/null +++ b/modules/gcp-foundation/outputs.tf @@ -0,0 +1,24 @@ +output "project_number" { + description = "Numeric service project identifier used in Google-managed service-agent identities." + value = local.project_number +} + +output "cloud_run_service_agent_email" { + description = "Cloud Run service agent email derived from project_number." + value = google_project_service_identity.cloud_run.email +} + +output "cloud_run_service_agent_member" { + description = "Cloud Run service agent IAM member string." + value = google_project_service_identity.cloud_run.member +} + +output "cloud_compose_start_role_name" { + description = "Canonical project custom-role name for starting or resuming instances. The caller must provision it externally when manage_power_roles is false." + value = local.cloud_compose_start_role_name +} + +output "cloud_compose_suspend_role_name" { + description = "Canonical project custom-role name for suspending instances. The caller must provision it externally when manage_power_roles is false." + value = local.cloud_compose_suspend_role_name +} diff --git a/modules/gcp-foundation/variables.tf b/modules/gcp-foundation/variables.tf new file mode 100644 index 0000000..5bcc76c --- /dev/null +++ b/modules/gcp-foundation/variables.tf @@ -0,0 +1,65 @@ +variable "service_project_id" { + description = "GCP project ID that owns the Cloud Run and Compute workloads." + type = string + nullable = false + + validation { + condition = can(regex("^([a-z0-9][a-z0-9.-]*:)?[a-z][a-z0-9-]{4,28}[a-z0-9]$", trimspace(var.service_project_id))) + error_message = "service_project_id must be a valid lowercase GCP project ID; legacy domain-scoped prefixes are accepted." + } +} + +variable "host_project_id" { + description = "Shared VPC host project ID. Leave empty when networking belongs to the service project." + type = string + default = "" + nullable = false + + validation { + condition = ( + trimspace(var.host_project_id) == "" || + can(regex("^([a-z0-9][a-z0-9.-]*:)?[a-z][a-z0-9-]{4,28}[a-z0-9]$", trimspace(var.host_project_id))) + ) + error_message = "host_project_id must be empty or a valid lowercase GCP project ID; legacy domain-scoped prefixes are accepted." + } +} + +variable "shared_vpc_subnetworks" { + description = "Shared VPC subnets available to Cloud Run Direct VPC egress. At least one is required when host_project_id differs from service_project_id." + type = set(object({ + name = string + region = string + })) + default = [] + nullable = false + + validation { + condition = alltrue([ + for subnet in var.shared_vpc_subnetworks : + can(regex("^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$", trimspace(subnet.name))) && + can(regex("^[a-z]+(-[a-z0-9]+)+[0-9]$", trimspace(subnet.region))) + ]) + error_message = "Each shared_vpc_subnetworks entry must contain a valid lowercase GCP subnet name and region such as us-east5." + } +} + +variable "manage_project_services" { + description = "Whether this module enables the required Compute Engine, IAM, and Cloud Run APIs. Disable only when a separate foundation state manages them." + type = bool + default = true + nullable = false +} + +variable "manage_power_roles" { + description = "Whether this module creates the cloudComposeStart and cloudComposeSuspend project custom roles. Disable only when they are managed externally with the same permissions." + type = bool + default = true + nullable = false +} + +variable "attach_shared_vpc" { + description = "Whether to associate the service project with host_project_id as a Shared VPC service project. Leave false when that singleton association is managed elsewhere." + type = bool + default = false + nullable = false +} diff --git a/modules/gcp-foundation/versions.tf b/modules/gcp-foundation/versions.tf new file mode 100644 index 0000000..cd45bf5 --- /dev/null +++ b/modules/gcp-foundation/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.3.0" + + required_providers { + google = { + source = "hashicorp/google" + version = "~> 7.39" + } + google-beta = { + source = "hashicorp/google-beta" + version = "~> 7.39" + } + } +} diff --git a/modules/gcp/main.tf b/modules/gcp/main.tf index ff01d8e..d813126 100644 --- a/modules/gcp/main.tf +++ b/modules/gcp/main.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.2.4" + required_version = ">= 1.3.0" required_providers { cloudinit = { @@ -18,13 +18,19 @@ terraform { } locals { - rootFs = "${path.module}/../../rootfs" - additional_rootfs = var.rootfs != "" ? var.rootfs : "" + rootFs = "${path.module}/../../rootfs" + additional_rootfs = var.rootfs != "" ? var.rootfs : "" + rootfs_archive_url = trimspace(var.rootfs_archive_url) + rootfs_archive_sha256 = lower(trimspace(var.rootfs_archive_sha256)) + rootfs_overlay_staging_path = "/var/lib/cloud-compose/rootfs-overlay" + project_number = tostring(data.google_project.service.number) single_compose_project = { (var.name) = { docker_compose_repo = var.docker_compose_repo docker_compose_branch = var.docker_compose_branch + project_dir = null + compose_project_name = null ingress_port = var.ingress_port ingress = var.sitectl_ingress sitectl_context_name = trimspace(var.sitectl_context_name) != "" ? trimspace(var.sitectl_context_name) : var.name @@ -38,7 +44,7 @@ locals { docker_compose_rollout = var.docker_compose_rollout } } - raw_compose_projects = length(var.compose_projects) > 0 ? var.compose_projects : local.single_compose_project + raw_compose_projects = length(var.compose_projects) > 0 ? var.compose_projects : tomap(local.single_compose_project) primary_compose_project_key = trimspace(var.primary_compose_project) != "" ? trimspace(var.primary_compose_project) : keys(local.raw_compose_projects)[0] compose_projects = { for app_name, app in local.raw_compose_projects : app_name => { @@ -47,13 +53,13 @@ locals { docker_compose_branch = trimspace(coalesce(try(app.docker_compose_branch, null), var.docker_compose_branch)) repo_path = trim(replace(trimspace(app.docker_compose_repo), "/^[^:]+://[^/]+/", ""), "/") project_dir = ( - trimspace(try(app.project_dir, "")) != "" - ? trimspace(try(app.project_dir, "")) + try(trimspace(app.project_dir), "") != "" + ? try(trimspace(app.project_dir), "") : "/mnt/disks/data/${trim(replace(trimspace(app.docker_compose_repo), "/^[^:]+://[^/]+/", ""), "/")}/${trimspace(coalesce(try(app.docker_compose_branch, null), var.docker_compose_branch))}" ) compose_project_name = ( - trimspace(try(app.compose_project_name, "")) != "" - ? trimspace(try(app.compose_project_name, "")) + try(trimspace(app.compose_project_name), "") != "" + ? try(trimspace(app.compose_project_name), "") : replace(lower(replace(replace(format("%s-%s", trim(replace(trimspace(app.docker_compose_repo), "/^[^:]+://[^/]+/", ""), "/"), trimspace(coalesce(try(app.docker_compose_branch, null), var.docker_compose_branch))), ".git", ""), "/[^a-zA-Z0-9]/", "-")), "/-+/", "-") ) ingress_port = coalesce(try(app.ingress_port, null), var.ingress_port) @@ -68,48 +74,171 @@ locals { upload_timeout = try(app.ingress.upload_timeout, null) != null ? trimspace(app.ingress.upload_timeout) : trimspace(var.sitectl_ingress.upload_timeout) } sitectl_context_name = ( - trimspace(try(app.sitectl_context_name, "")) != "" - ? trimspace(try(app.sitectl_context_name, "")) + trimspace(app.sitectl_context_name != null ? app.sitectl_context_name : "") != "" + ? trimspace(app.sitectl_context_name) : app_name ) sitectl_plugin = trimspace(coalesce(try(app.sitectl_plugin, null), var.sitectl_plugin)) sitectl_environment = trimspace(coalesce(try(app.sitectl_environment, null), var.sitectl_environment)) - sitectl_packages = distinct(concat(["sitectl"], try(app.sitectl_packages, []))) - sitectl_verify_args = try(app.sitectl_verify_args, var.sitectl_verify_args) + sitectl_packages = distinct(concat( + ["sitectl"], + coalesce(try(app.sitectl_packages, null), var.sitectl_packages), + )) + sitectl_verify_args = coalesce(try(app.sitectl_verify_args, null), var.sitectl_verify_args) init_commands = try(app.docker_compose_init, null) != null ? app.docker_compose_init : var.docker_compose_init up_commands = try(app.docker_compose_up, null) != null ? app.docker_compose_up : var.docker_compose_up down_commands = try(app.docker_compose_down, null) != null ? app.docker_compose_down : var.docker_compose_down rollout_commands = try(app.docker_compose_rollout, null) != null ? app.docker_compose_rollout : var.docker_compose_rollout } } - primary_compose_project = local.compose_projects[local.primary_compose_project_key] + validated_compose_projects = { + for app_name, app in local.compose_projects : app_name => merge(app, { + project_dir = module.project_directories.project_dirs[app_name] + }) + } + primary_compose_project = local.validated_compose_projects[local.primary_compose_project_key] sitectl_packages = distinct(concat( ["sitectl"], var.sitectl_packages, flatten([for _, app in local.compose_projects : app.sitectl_packages]) )) - create_network = var.create_network && trimspace(var.network_name) == "" && trimspace(var.subnetwork_name) == "" - network_name = trimspace(var.network_name) != "" ? trimspace(var.network_name) : (local.create_network ? google_compute_network.cloud-compose[0].self_link : "default") - subnetwork_name = trimspace(var.subnetwork_name) != "" ? trimspace(var.subnetwork_name) : (local.create_network ? google_compute_subnetwork.cloud-compose[0].self_link : null) + requested_network_name = trimsuffix(trimspace(var.network_name), "/") + requested_subnetwork_name = trimsuffix(trimspace(var.subnetwork_name), "/") + create_network = var.create_network && local.requested_network_name == "" && local.requested_subnetwork_name == "" + configured_network_project_id = trimspace(var.network_project_id) != "" ? trimspace(var.network_project_id) : var.project_id + requested_network_self_link_parts = try( + regex("projects/([^/]+)/global/networks/([^/]+)$", local.requested_network_name), + [], + ) + selected_subnetwork_reference = ( + local.requested_subnetwork_name != "" ? local.requested_subnetwork_name : + local.requested_network_name != "" ? basename(local.requested_network_name) : + "default" + ) + selected_subnetwork_self_link_parts = try( + regex("projects/([^/]+)/regions/([^/]+)/subnetworks/([^/]+)$", local.selected_subnetwork_reference), + [], + ) + selected_subnetwork_is_self_link = length(local.selected_subnetwork_self_link_parts) == 3 + selected_subnetwork_lookup_project = ( + local.selected_subnetwork_is_self_link ? local.selected_subnetwork_self_link_parts[0] : + length(local.requested_network_self_link_parts) == 2 ? local.requested_network_self_link_parts[0] : + local.configured_network_project_id + ) + selected_subnetwork_lookup_region = local.selected_subnetwork_is_self_link ? local.selected_subnetwork_self_link_parts[1] : var.region + selected_network_reference = ( + local.requested_network_name != "" ? local.requested_network_name : + local.create_network ? "" : data.google_compute_subnetwork.selected[0].network + ) + selected_network_self_link_parts = try( + regex("projects/([^/]+)/global/networks/([^/]+)$", local.selected_network_reference), + [], + ) + selected_network_lookup_project = ( + length(local.selected_network_self_link_parts) == 2 ? local.selected_network_self_link_parts[0] : + local.selected_subnetwork_is_self_link ? local.selected_subnetwork_self_link_parts[0] : + local.configured_network_project_id + ) + selected_network_lookup_name = length(local.selected_network_self_link_parts) == 2 ? local.selected_network_self_link_parts[1] : basename(local.selected_network_reference) + network_name = ( + local.create_network ? google_compute_network.cloud-compose[0].self_link : + data.google_compute_network.selected[0].self_link + ) + subnetwork_name = ( + local.create_network ? google_compute_subnetwork.cloud-compose[0].self_link : + data.google_compute_subnetwork.selected[0].self_link + ) + resolved_network_self_link_parts = try(regex("projects/([^/]+)/global/networks/([^/]+)$", local.network_name), []) + resolved_subnetwork_self_link_parts = try( + regex("projects/([^/]+)/regions/([^/]+)/subnetworks/([^/]+)$", local.subnetwork_name), + [], + ) + network_project_id = local.create_network ? var.project_id : local.resolved_network_self_link_parts[0] + subnetwork_project_id = local.create_network ? var.project_id : local.resolved_subnetwork_self_link_parts[0] + subnetwork_region = local.create_network ? var.region : local.resolved_subnetwork_self_link_parts[1] + # Cloud Run v2 accepts canonical relative Compute resource names for Direct + # VPC egress. Keep the HTTPS self links above for Compute resources, but do + # not pass them through to the Cloud Run API. + cloud_run_network_resource_name = format( + "projects/%s/global/networks/%s", + local.network_project_id, + local.create_network ? google_compute_network.cloud-compose[0].name : local.resolved_network_self_link_parts[1], + ) + cloud_run_subnetwork_resource_name = format( + "projects/%s/regions/%s/subnetworks/%s", + local.subnetwork_project_id, + local.subnetwork_region, + local.create_network ? google_compute_subnetwork.cloud-compose[0].name : local.resolved_subnetwork_self_link_parts[2], + ) + network_namespace = ( + local.network_project_id == var.project_id + ? var.name + : "${var.name}-${substr(sha256(var.project_id), 0, 8)}" + ) + cloud_run_subnetwork_cidr = ( + local.create_network ? var.network_ip_cidr_range : + data.google_compute_subnetwork.selected[0].ip_cidr_range + ) + # The Google network data source does not expose MTU. For existing or Shared + # VPC networks, network_mtu is an explicit caller attestation. + cloud_run_network_mtu = var.network_mtu + cloud_run_subnetwork_prefix_length = try( + tonumber(element(split("/", local.cloud_run_subnetwork_cidr), 1)), + null, + ) + cloud_run_subnetwork_network_octets = try([ + for octet in split(".", cidrhost(local.cloud_run_subnetwork_cidr, 0)) : tonumber(octet) + ], []) + cloud_run_subnetwork_range_supported = try( + length(local.cloud_run_subnetwork_network_octets) == 4 && ( + local.cloud_run_subnetwork_network_octets[0] == 10 && local.cloud_run_subnetwork_prefix_length >= 8 || + local.cloud_run_subnetwork_network_octets[0] == 172 && local.cloud_run_subnetwork_network_octets[1] >= 16 && local.cloud_run_subnetwork_network_octets[1] <= 31 && local.cloud_run_subnetwork_prefix_length >= 12 || + local.cloud_run_subnetwork_network_octets[0] == 192 && local.cloud_run_subnetwork_network_octets[1] == 168 && local.cloud_run_subnetwork_prefix_length >= 16 || + local.cloud_run_subnetwork_network_octets[0] == 100 && local.cloud_run_subnetwork_network_octets[1] >= 64 && local.cloud_run_subnetwork_network_octets[1] <= 127 && local.cloud_run_subnetwork_prefix_length >= 10 || + local.cloud_run_subnetwork_network_octets[0] >= 240 && local.cloud_run_subnetwork_prefix_length >= 4 + ), + false, + ) + selected_network_matches_subnetwork = ( + local.create_network || + data.google_compute_subnetwork.selected[0].network == data.google_compute_network.selected[0].self_link + ) + configured_network_project_matches = ( + trimspace(var.network_project_id) == "" || + local.network_project_id == trimspace(var.network_project_id) + ) + selected_subnetwork_project_matches = local.network_project_id == local.subnetwork_project_id + selected_subnetwork_region_matches = local.subnetwork_region == var.region - # Get files from base rootfs - base_files = fileset(local.rootFs, "**") + # Archive mode fetches the packaged base rootfs at boot. A consumer-provided + # rootfs is staged separately and applied after extraction so it still wins. + base_files = local.rootfs_archive_url == "" ? fileset(local.rootFs, "**") : [] # Get files from additional rootfs if path is provided additional_files = local.additional_rootfs != "" ? fileset(local.additional_rootfs, "**") : [] - # Combine both file sets (additional files will override base files with same path) + # Combine both file sets (additional files override base files with the same path). all_files = merge( - { for file in local.base_files : file => "${local.rootFs}/${file}" }, - { for file in local.additional_files : file => "${local.additional_rootfs}/${file}" } + { + for file in local.base_files : file => { + destination = "/${file}" + source = "${local.rootFs}/${file}" + } + }, + { + for file in local.additional_files : file => { + destination = local.rootfs_archive_url == "" ? "/${file}" : "${local.rootfs_overlay_staging_path}/${file}" + source = "${local.additional_rootfs}/${file}" + } + } ) write_files_content = join("\n", [ - for file, fullpath in local.all_files : <<-EOT - - path: ${jsonencode("/${file}")} + for file, config in local.all_files : <<-EOT + - path: ${jsonencode(config.destination)} permissions: ${jsonencode(endswith(file, ".sh") ? "0755" : "0644")} encoding: gzip+base64 - content: ${jsonencode(base64gzip(file(fullpath)))} + content: ${jsonencode(base64gzip(file(config.source)))} EOT ]) docker_compose_scripts = join("\n", [ @@ -132,10 +261,10 @@ compose_projects_file = <<-EOT - path: "/home/cloud-compose/compose-projects.json" permissions: "0640" encoding: gzip+base64 - content: ${jsonencode(base64gzip(jsonencode(local.compose_projects)))} + content: ${jsonencode(base64gzip(jsonencode(local.validated_compose_projects)))} EOT managed_runtime_artifact_lines = [ - for artifact in var.libops_managed_artifacts : join("\t", [ + for artifact in module.managed_artifacts.artifacts : join("\t", [ artifact.name, artifact.url, artifact.sha256, @@ -191,7 +320,6 @@ vault_agent_env_content = <<-EOT VAULT_NAMESPACE=${trimspace(var.vault_namespace)} VAULT_ROLE=${trimspace(var.vault_role)} VAULT_AUTH_METHOD=${var.vault_auth_method} - GOOGLE_APPLICATION_CREDENTIALS=/mnt/disks/data/cloud-compose/app/GOOGLE_APPLICATION_CREDENTIALS EOT vault_agent_config_content = <<-EOT vault { @@ -216,80 +344,130 @@ vault_agent_files_raw = <<-EOT content: ${jsonencode(base64gzip(local.vault_agent_config_content))} EOT vault_agent_files = var.vault_agent_enabled && trimspace(var.vault_addr) != "" ? local.vault_agent_files_raw : "" -rollout_env_lines = var.rollout_enabled ? [ - "ROLLOUT_ENABLED=true", - "ROLLOUT_DOWNLOAD_URL=\"${trimspace(var.rollout_release_url)}\"", - "ROLLOUT_DOWNLOAD_SHA256=\"${trimspace(var.rollout_release_sha256)}\"", - "PORT=\"${var.rollout_port}\"", - "JWKS_URI=\"${trimspace(var.rollout_jwks_uri)}\"", - "JWT_AUD=\"${trimspace(var.rollout_jwt_audience)}\"", - "CUSTOM_CLAIMS='${trimspace(var.rollout_custom_claims)}'", - "ROLLOUT_CMD=\"/bin/bash\"", - "ROLLOUT_ARGS=\"/home/cloud-compose/rollout\"", - "ROLLOUT_LOCK_FILE=\"/mnt/disks/data/rollout.lock\"", - ] : [ - "ROLLOUT_ENABLED=false", -] -rollout_env = join("\n", local.rollout_env_lines) -env_file_plain = <<-EOT - HOME=/home/cloud-compose - GCP_PROJECT="${var.project_id}" - GCP_PROJECT_NUMBER="${var.project_number}" - GCP_INSTANCE_NAME="${var.name}" - CLOUD_COMPOSE_INSTANCE_NAME="${var.name}" - GCP_REGION="${var.region}" - GCP_ZONE="${var.zone}" - CLOUD_COMPOSE_PROVIDER="gcp" - CLOUD_COMPOSE_APPS="${join(" ", keys(local.compose_projects))}" - CLOUD_COMPOSE_PRIMARY_APP="${local.primary_compose_project_key}" - COMPOSE_PROJECTS_FILE="/home/cloud-compose/compose-projects.json" - COMPOSE_PROJECT_NAME=${local.primary_compose_project.compose_project_name} - COMPOSE_BIND_PORT="${local.primary_compose_project.ingress_port}" - DOCKER_COMPOSE_DIR=${local.primary_compose_project.project_dir} - DOCKER_COMPOSE_REPO="${local.primary_compose_project.docker_compose_repo}" - DOCKER_COMPOSE_BRANCH="${local.primary_compose_project.docker_compose_branch}" - DOCKER_COMPOSE_VERSION="${var.docker_compose_version}" - DOCKER_BUILDX_VERSION="${var.docker_buildx_version}" - SITECTL_PACKAGES="${join(" ", local.sitectl_packages)}" - SITECTL_VERSION="${var.sitectl_version}" - SITECTL_CONTEXT_NAME="${local.primary_compose_project.sitectl_context_name}" - SITECTL_PLUGIN="${local.primary_compose_project.sitectl_plugin}" - SITECTL_ENVIRONMENT="${local.primary_compose_project.sitectl_environment}" - PRODUCTION="${var.production}" - SITECTL_VERIFY_ARGS="${join(" ", local.primary_compose_project.sitectl_verify_args)}" - GCP_APP_SERVICE_ACCOUNT_EMAIL="${local.app_service_account_email}" - POWER_MANAGEMENT_ENABLED="${var.power_management_enabled}" - COMPOSE_PROFILES="${local.internal_services_compose_profiles}" - VAULT_ADDR="${trimspace(var.vault_addr)}" - VAULT_NAMESPACE="${trimspace(var.vault_namespace)}" - VAULT_ROLE="${trimspace(var.vault_role)}" - VAULT_AGENT_ENABLED="${var.vault_agent_enabled && trimspace(var.vault_addr) != "" ? "true" : "false"}" - VAULT_AUTH_METHOD="${var.vault_auth_method}" - VAULT_AGENT_TOKEN_PATH="${var.vault_agent_token_path}" - LIBOPS_MANAGED_RUNTIME_ENABLED="${var.libops_managed_runtime_enabled}" - LIBOPS_INTERNAL_SERVICES_ENABLED="${var.libops_internal_services_enabled}" - LIBOPS_INTERNAL_SERVICES_AUTO_UPDATE="${var.libops_internal_services_auto_update}" - ${local.rollout_env} - EOT -env_file_content = <<-EOT +rollout_env = var.rollout_enabled ? { + ROLLOUT_ENABLED = "true" + ROLLOUT_DOWNLOAD_URL = trimspace(var.rollout_release_url) + ROLLOUT_DOWNLOAD_SHA256 = trimspace(var.rollout_release_sha256) + ROLLOUT_PORT = tostring(var.rollout_port) + ROLLOUT_JWKS_URI = trimspace(var.rollout_jwks_uri) + ROLLOUT_JWT_AUD = trimspace(var.rollout_jwt_audience) + ROLLOUT_CUSTOM_CLAIMS = trimspace(var.rollout_custom_claims) + ROLLOUT_CMD = "/bin/bash" + ROLLOUT_ARGS = "/home/cloud-compose/rollout" + ROLLOUT_LOCK_FILE = "/mnt/disks/data/rollout.lock" + } : { + ROLLOUT_ENABLED = "false" +} +host_env = merge({ + HOME = "/home/cloud-compose" + GCP_PROJECT = var.project_id + GCP_PROJECT_NUMBER = local.project_number + GCP_INSTANCE_NAME = var.name + CLOUD_COMPOSE_INSTANCE_NAME = var.name + GCP_REGION = var.region + GCP_ZONE = var.zone + CLOUD_COMPOSE_PROVIDER = "gcp" + CLOUD_COMPOSE_APPS = join(" ", keys(local.compose_projects)) + CLOUD_COMPOSE_PRIMARY_APP = local.primary_compose_project_key + COMPOSE_PROJECTS_FILE = "/home/cloud-compose/compose-projects.json" + COMPOSE_PROJECT_NAME = local.primary_compose_project.compose_project_name + COMPOSE_BIND_PORT = tostring(local.primary_compose_project.ingress_port) + DOCKER_COMPOSE_DIR = local.primary_compose_project.project_dir + DOCKER_COMPOSE_REPO = local.primary_compose_project.docker_compose_repo + DOCKER_COMPOSE_BRANCH = local.primary_compose_project.docker_compose_branch + DOCKER_COMPOSE_VERSION = var.docker_compose_version + DOCKER_BUILDX_VERSION = var.docker_buildx_version + DOCKER_VOLUME_OVERLAYS = join(" ", var.volume_names) + SITECTL_PACKAGES = join(" ", module.sitectl_runtime.packages) + SITECTL_VERSION = var.sitectl_version + SITECTL_PACKAGE_VERSIONS = jsonencode(module.sitectl_runtime.package_versions) + SITECTL_CONTEXT_NAME = local.primary_compose_project.sitectl_context_name + SITECTL_PLUGIN = local.primary_compose_project.sitectl_plugin + SITECTL_ENVIRONMENT = local.primary_compose_project.sitectl_environment + PRODUCTION = tostring(var.production) + SITECTL_VERIFY_ARGS = join(" ", local.primary_compose_project.sitectl_verify_args) + GCP_APP_SERVICE_ACCOUNT_EMAIL = local.app_service_account_email + GCP_APP_CREDENTIALS_ENABLED = tostring(local.app_credentials_enabled) + POWER_MANAGEMENT_ENABLED = tostring(var.power_management_enabled) + COMPOSE_PROFILES = local.internal_services_compose_profiles + VAULT_ADDR = trimspace(var.vault_addr) + VAULT_NAMESPACE = trimspace(var.vault_namespace) + VAULT_ROLE = trimspace(var.vault_role) + VAULT_AGENT_ENABLED = var.vault_agent_enabled && trimspace(var.vault_addr) != "" ? "true" : "false" + VAULT_AUTH_METHOD = var.vault_auth_method + VAULT_AGENT_TOKEN_PATH = var.vault_agent_token_path + LIBOPS_MANAGED_RUNTIME_ENABLED = tostring(var.libops_managed_runtime_enabled) + LIBOPS_INTERNAL_SERVICES_ENABLED = tostring(local.internal_services_enabled) + LIBOPS_INTERNAL_SERVICES_AUTO_UPDATE = tostring(local.internal_services_enabled && var.libops_internal_services_auto_update) +}, local.rollout_env) +env_file_content = <<-EOT - path: "/home/cloud-compose/.env" permissions: "0640" encoding: gzip+base64 - content: ${jsonencode(base64gzip(local.env_file_plain))} + content: ${jsonencode(base64gzip(module.runtime_env.content))} +EOT +application_env_file_content = <<-EOT + - path: "/home/cloud-compose/application-env.json" + permissions: "0640" + encoding: gzip+base64 + content: ${jsonencode(base64gzip(jsonencode(var.extra_env)))} EOT -use_overlay = length(var.volume_names) > 0 -prod_disk_name = var.overlay_source_instance != "" ? format("%s-data-disk", var.overlay_source_instance) : "" -prod_disk_url = var.overlay_source_instance != "" ? format("https://www.googleapis.com/compute/v1/projects/%s/zones/%s/disks/%s-docker-volumes", var.project_id, var.zone, var.overlay_source_instance) : "" +rootfs_archive_command_raw = <<-EOT + - | + set -eu + test -f /run/cloud-compose-filesystems-ready || { + echo "Cloud Compose filesystems were not prepared; refusing rootfs installation" >&2 + exit 1 + } + archive_url="$(printf '%s' '${base64encode(local.rootfs_archive_url)}' | base64 -d)" + archive_sha256=${jsonencode(local.rootfs_archive_sha256)} + overlay_dir=${jsonencode(local.rootfs_overlay_staging_path)} + case "$archive_url" in + https://*) ;; + *) echo "rootfs archive URL must use HTTPS" >&2; exit 1 ;; + esac + case "$archive_url" in + *[[:space:]]*) echo "rootfs archive URL must not contain whitespace" >&2; exit 1 ;; + esac + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 \ + --retry 5 --retry-all-errors --retry-delay 2 --retry-max-time 900 \ + --connect-timeout 10 --max-time 300 -o "$tmp/rootfs.tar.gz" -- "$archive_url" + if ! command -v sha256sum >/dev/null 2>&1; then + echo "sha256sum is required to verify $archive_url" >&2 + exit 1 + fi + printf '%s %s\n' "$archive_sha256" "$tmp/rootfs.tar.gz" | sha256sum -c - + tar -xzf "$tmp/rootfs.tar.gz" -C "$tmp" + rootfs_dir="$(find "$tmp" -mindepth 1 -maxdepth 3 -type d -name rootfs -print -quit)" + if [ -z "$rootfs_dir" ]; then + echo "rootfs directory not found in $archive_url" >&2 + exit 1 + fi + cp -a "$rootfs_dir"/. / + if [ -d "$overlay_dir" ]; then + cp -a "$overlay_dir"/. / + rm -rf "$overlay_dir" + fi + EOT +rootfs_archive_command = local.rootfs_archive_url != "" ? local.rootfs_archive_command_raw : "" +use_overlay = length(var.volume_names) > 0 +prod_disk_url = var.overlay_source_instance != "" ? format("https://www.googleapis.com/compute/v1/projects/%s/zones/%s/disks/%s-docker-volumes", var.project_id, var.zone, var.overlay_source_instance) : "" rollout_runcmd = var.rollout_enabled ? [ "bash /home/cloud-compose/deploy-rollout.sh >> /home/cloud-compose/run.log 2>&1", ] : [] cloud_init_yaml = templatefile("${path.module}/../../templates/cloud-init.yml", { + FILESYSTEM_PREP_SCRIPT_B64 = filebase64("${local.rootFs}/home/cloud-compose/prepare-filesystem.sh"), + FILESYSTEM_PERSIST_SCRIPT_B64 = filebase64("${local.rootFs}/home/cloud-compose/persist-filesystems.sh"), WRITE_FILES_CONTENT = local.write_files_content, DOCKER_COMPOSE_SCRIPTS = local.docker_compose_scripts, COMPOSE_PROJECTS_FILE = local.compose_projects_file, ENV_FILE_CONTENT = local.env_file_content, + APPLICATION_ENV_FILE_CONTENT = local.application_env_file_content, VAULT_AGENT_FILES = local.vault_agent_files, MANAGED_RUNTIME_ARTIFACTS_FILE = local.managed_runtime_artifacts_file, + ROOTFS_ARCHIVE_COMMAND = local.rootfs_archive_command, USE_OVERLAY = local.use_overlay, DOCKER_VOLUME_OVERLAYS = var.volume_names, CLOUD_COMPOSE_SSH_KEYS = try(var.users["cloud-compose"], []), @@ -298,14 +476,16 @@ cloud_init_yaml = templatefile("${path.module}/../../templates/cloud-init.yml", ADDITIONAL_RUNCMD = concat(local.rollout_runcmd, var.runcmd), }) -vm_service_account_email = var.service_account_email != "" ? var.service_account_email : google_service_account.cloud-compose[0].email -vm_service_account_id = var.service_account_email != "" ? "projects/${var.project_id}/serviceAccounts/${var.service_account_email}" : google_service_account.cloud-compose[0].id -vm_service_account_name = var.service_account_email != "" ? local.vm_service_account_id : google_service_account.cloud-compose[0].name +vm_service_account_email = var.service_account_email != "" ? data.google_service_account.vm[0].email : google_service_account.cloud-compose[0].email +vm_service_account_id = var.service_account_email != "" ? data.google_service_account.vm[0].name : google_service_account.cloud-compose[0].id +vm_service_account_name = var.service_account_email != "" ? data.google_service_account.vm[0].name : google_service_account.cloud-compose[0].name -app_service_account_email = var.app_service_account_email != "" ? var.app_service_account_email : google_service_account.app[0].email -app_service_account_id = var.app_service_account_email != "" ? "projects/${var.project_id}/serviceAccounts/${var.app_service_account_email}" : google_service_account.app[0].id -app_service_account_name = var.app_service_account_email != "" ? local.app_service_account_id : google_service_account.app[0].name +app_service_account_email = var.app_service_account_email != "" ? data.google_service_account.app[0].email : google_service_account.app[0].email +app_service_account_id = var.app_service_account_email != "" ? data.google_service_account.app[0].name : google_service_account.app[0].id +app_service_account_name = var.app_service_account_email != "" ? data.google_service_account.app[0].name : google_service_account.app[0].name +app_credentials_enabled = var.app_credentials_enabled +internal_services_enabled = var.libops_internal_services_enabled || var.power_management_enabled internal_services_compose_profiles = var.power_management_enabled ? "lightsout" : "" scheduled_snapshots_enabled = var.production && var.run_snapshots # have prod snapshot begin near the initial run so non-prod overlays can @@ -313,6 +493,50 @@ scheduled_snapshots_enabled = var.production && var.run_snapshots snapshot_start_time = local.scheduled_snapshots_enabled ? formatdate("h:00", time_static.snapshot_time_static[0].rfc3339) : "00:00" } +data "google_project" "service" { + project_id = var.project_id +} + +data "google_service_account" "vm" { + count = var.service_account_email != "" ? 1 : 0 + + project = var.project_id + account_id = var.service_account_email +} + +data "google_service_account" "app" { + count = var.app_service_account_email != "" ? 1 : 0 + + project = var.project_id + account_id = var.app_service_account_email +} + +module "sitectl_runtime" { + source = "../sitectl-runtime" + + packages = local.sitectl_packages + fallback_version = var.sitectl_version + package_versions = var.sitectl_package_versions +} + +module "managed_artifacts" { + source = "../managed-artifacts" + + artifacts = var.libops_managed_artifacts +} + +module "project_directories" { + source = "../project-directories" + + project_dirs = { for app_name, app in local.compose_projects : app_name => app.project_dir } +} + +module "runtime_env" { + source = "../runtime-env" + + env = local.host_env +} + resource "time_static" "snapshot_time_static" { count = local.scheduled_snapshots_enabled ? 1 : 0 } @@ -340,19 +564,6 @@ resource "google_artifact_registry_repository_iam_member" "private-policy-cloud- member = "serviceAccount:${local.vm_service_account_email}" } -# let VM run as the GSA -resource "google_service_account_iam_member" "gsa-user" { - service_account_id = local.vm_service_account_id - role = "roles/iam.serviceAccountUser" - member = "serviceAccount:${var.project_number}-compute@developer.gserviceaccount.com" -} - -resource "google_service_account_iam_member" "token-creator" { - service_account_id = local.vm_service_account_id - role = "roles/iam.serviceAccountTokenCreator" - member = "serviceAccount:${local.vm_service_account_email}" -} - # push logs to GCP resource "google_project_iam_member" "log" { project = var.project_id @@ -360,11 +571,20 @@ resource "google_project_iam_member" "log" { member = "serviceAccount:${local.vm_service_account_email}" } +# The host monitoring agent runs as the VM identity, not the optional +# internal-services identity used by the privileged Compose project. +resource "google_project_iam_member" "monitoring" { + project = var.project_id + role = "roles/monitoring.metricWriter" + member = "serviceAccount:${local.vm_service_account_email}" +} + resource "google_compute_network" "cloud-compose" { count = local.create_network ? 1 : 0 name = var.name project = var.project_id auto_create_subnetworks = false + mtu = var.network_mtu } resource "google_compute_subnetwork" "cloud-compose" { @@ -377,6 +597,22 @@ resource "google_compute_subnetwork" "cloud-compose" { private_ip_google_access = true } +data "google_compute_network" "selected" { + count = local.create_network ? 0 : 1 + + name = local.selected_network_lookup_name + project = local.selected_network_lookup_project +} + +data "google_compute_subnetwork" "selected" { + count = local.create_network ? 0 : 1 + + self_link = local.selected_subnetwork_is_self_link ? local.selected_subnetwork_reference : null + name = local.selected_subnetwork_is_self_link ? null : basename(local.selected_subnetwork_reference) + project = local.selected_subnetwork_is_self_link ? null : local.selected_subnetwork_lookup_project + region = local.selected_subnetwork_is_self_link ? null : local.selected_subnetwork_lookup_region +} + resource "google_compute_disk" "boot" { # force re-create VM when cloud-init changes name = format("%s-boot-%s", var.name, md5(data.cloudinit_config.ci.rendered)) @@ -535,7 +771,7 @@ resource "google_compute_instance" "cloud-compose" { machine_type = var.machine_type zone = var.zone allow_stopping_for_update = true - tags = ["cloud-compose", var.name] + tags = ["cloud-compose", local.network_namespace] can_ip_forward = "false" boot_disk { @@ -610,10 +846,109 @@ resource "google_compute_instance" "cloud-compose" { } lifecycle { + precondition { + condition = var.project_number == "" || var.project_number == local.project_number + error_message = "project_number does not match the number derived from project_id; omit the deprecated assertion or correct it." + } + precondition { + condition = ( + var.service_account_email == "" || + ( + data.google_service_account.vm[0].email == var.service_account_email && + startswith(data.google_service_account.vm[0].name, "projects/${var.project_id}/serviceAccounts/") + ) + ) + error_message = "service_account_email must belong to project_id; cross-project VM service accounts require a separate, explicitly managed organization-policy and service-agent contract." + } + precondition { + condition = ( + var.app_service_account_email == "" || + ( + data.google_service_account.app[0].email == var.app_service_account_email && + startswith(data.google_service_account.app[0].name, "projects/${var.project_id}/serviceAccounts/") + ) + ) + error_message = "app_service_account_email must belong to project_id; cross-project app service accounts are not supported by this module." + } + precondition { + condition = !var.power_management_enabled || trimspace(var.power_start_role) != "" + error_message = "power_start_role is required when power management is enabled; use the singleton GCP foundation module output." + } + precondition { + condition = !var.power_management_enabled || trimspace(var.power_suspend_role) != "" + error_message = "power_suspend_role is required when power management is enabled; use the singleton GCP foundation module output." + } + precondition { + condition = ( + !var.power_management_enabled || + startswith(var.power_start_role, "organizations/") || + startswith(var.power_start_role, "projects/${var.project_id}/roles/") + ) + error_message = "A project-scoped power_start_role must belong to project_id; organization-scoped custom roles are also accepted." + } + precondition { + condition = ( + !var.power_management_enabled || + startswith(var.power_suspend_role, "organizations/") || + startswith(var.power_suspend_role, "projects/${var.project_id}/roles/") + ) + error_message = "A project-scoped power_suspend_role must belong to project_id; organization-scoped custom roles are also accepted." + } + precondition { + condition = replace(var.zone, "/-[a-z]$/", "") == var.region + error_message = "zone must belong to region." + } + precondition { + condition = ( + (local.rootfs_archive_url == "") == (local.rootfs_archive_sha256 == "") && + (local.rootfs_archive_sha256 == "" || can(regex("^[0-9a-f]{64}$", local.rootfs_archive_sha256))) + ) + error_message = "rootfs_archive_url and a 64-character rootfs_archive_sha256 must be supplied together." + } precondition { condition = contains(keys(local.compose_projects), local.primary_compose_project_key) error_message = "primary_compose_project must be one of the compose_projects keys." } + precondition { + condition = local.selected_network_matches_subnetwork + error_message = "The selected GCP subnetwork must belong to the selected network." + } + precondition { + condition = local.configured_network_project_matches + error_message = "network_project_id must match the project embedded in a network or subnetwork self link." + } + precondition { + condition = local.selected_subnetwork_project_matches + error_message = "The selected GCP network and subnetwork must belong to the same project." + } + precondition { + condition = local.selected_subnetwork_region_matches + error_message = "The selected GCP subnetwork must belong to the Cloud Run region." + } + precondition { + condition = !var.power_management_enabled || try( + can(cidrnetmask(local.cloud_run_subnetwork_cidr)) && + tonumber(element(split("/", local.cloud_run_subnetwork_cidr), 1)) <= 26, + false, + ) + error_message = "Direct VPC egress requires an IPv4 subnetwork with a /26 or larger address range." + } + precondition { + condition = !var.power_management_enabled || local.cloud_run_subnetwork_range_supported + error_message = "Direct VPC egress requires a Cloud Run-supported IPv4 range: RFC1918, RFC6598 (100.64.0.0/10), or Class E (240.0.0.0/4)." + } + precondition { + condition = !var.power_management_enabled || local.cloud_run_network_mtu == 1460 + error_message = "Direct VPC egress requires the selected network to use the Cloud Run default MTU of 1460." + } + precondition { + condition = !var.power_management_enabled || length(var.allowed_ips) > 0 + error_message = "Power management requires at least one explicit original-client CIDR in allowed_ips; use 0.0.0.0/0 and ::/0 only when public wake-up is intentional." + } + precondition { + condition = !var.power_management_enabled || var.allowed_ip_forwarded_depth != null + error_message = "Power management requires an explicit allowed_ip_forwarded_depth. Use 0 for direct public Cloud Run and verify a larger right-edge suffix before placing another trusted proxy or external Application Load Balancer in front." + } precondition { condition = !var.vault_agent_enabled || trimspace(var.vault_addr) != "" error_message = "vault_addr is required when vault_agent_enabled is true." @@ -664,45 +999,68 @@ resource "google_compute_instance" "cloud-compose" { condition = !var.rollout_enabled || trimspace(var.rollout_jwt_audience) != "" error_message = "rollout_jwt_audience is required when rollout_enabled is true." } + precondition { + condition = ( + !var.production || + !var.power_management_enabled || + var.frontend == null || + can(regex("@sha256:[0-9a-f]{64}$", trimspace(var.frontend.image))) + ) + error_message = "A production power-management frontend image must be pinned by @sha256 digest." + } } - depends_on = [google_compute_disk.overlay_disk] -} - -# machine needs to be able to suspend itself -data "google_project_iam_custom_role" "gce-suspend" { - project = var.project_id - role_id = "suspendVM" + depends_on = [ + google_compute_disk.overlay_disk, + google_project_iam_member.log, + google_project_iam_member.monitoring, + google_service_account_iam_member.app-keys, + google_service_account_iam_member.internal-services-keys, + google_service_account_iam_member.vault_agent_jwt_signer_policy, + ] } - # ============================================================================= # LIBOPS ADMIN SERVICES IDENTITY # ============================================================================= resource "google_service_account" "internal-services" { + count = local.internal_services_enabled ? 1 : 0 account_id = format("internal-%s", var.name) project = var.project_id } resource "google_service_account_iam_member" "internal-services-keys" { - service_account_id = google_service_account.internal-services.id + count = local.internal_services_enabled ? 1 : 0 + service_account_id = google_service_account.internal-services[0].id role = "roles/iam.serviceAccountKeyAdmin" member = "serviceAccount:${local.vm_service_account_email}" } # push metrics to GCP resource "google_project_iam_member" "stackdriver" { + count = local.internal_services_enabled ? 1 : 0 project = var.project_id role = "roles/monitoring.metricWriter" - member = "serviceAccount:${google_service_account.internal-services.email}" + member = "serviceAccount:${google_service_account.internal-services[0].email}" } -# suspend the GCP instance -resource "google_project_iam_member" "gce-suspend" { - project = var.project_id - role = data.google_project_iam_custom_role.gce-suspend.name - member = "serviceAccount:${google_service_account.internal-services.email}" +# Suspend only this deployment's VM. The custom role itself is a long-lived, +# singleton project-foundation concern shared by application stacks. +resource "google_compute_instance_iam_member" "gce-suspend" { + count = var.power_management_enabled ? 1 : 0 + + project = var.project_id + zone = var.zone + instance_name = google_compute_instance.cloud-compose.name + role = var.power_suspend_role + member = "serviceAccount:${google_service_account.internal-services[0].email}" + + # Instance IAM policies are deleted with the VM even when its replacement + # reuses the same name, zone, role, and member values. + lifecycle { + replace_triggered_by = [google_compute_instance.cloud-compose] + } } # ============================================================================= @@ -716,15 +1074,19 @@ resource "google_service_account" "app" { } resource "google_service_account_iam_member" "app-keys" { + count = local.app_credentials_enabled ? 1 : 0 + service_account_id = local.app_service_account_id role = "roles/iam.serviceAccountKeyAdmin" member = "serviceAccount:${local.vm_service_account_email}" } -resource "google_service_account_iam_member" "self_jwt_signer_policy" { +resource "google_service_account_iam_member" "vault_agent_jwt_signer_policy" { + count = var.vault_agent_enabled && var.vault_auth_method == "gcp-iam" ? 1 : 0 + service_account_id = local.app_service_account_id role = "roles/iam.serviceAccountTokenCreator" - member = format("serviceAccount:%s", local.app_service_account_email) + member = "serviceAccount:${local.vm_service_account_email}" } # ============================================================================= @@ -732,16 +1094,20 @@ resource "google_service_account_iam_member" "self_jwt_signer_policy" { # ============================================================================= locals { + trusted_client_proxy_depth = var.allowed_ip_forwarded_depth != null ? var.allowed_ip_forwarded_depth : 0 base_config = yamldecode( < 0 ? var.allowed_ssh_ipv4 : ["127.0.0.1/32"] } resource "google_compute_firewall" "allow_ssh_ipv6" { - project = var.project_id - name = format("allow-ssh-ipv6-%s", var.name) + project = local.network_project_id + name = format("allow-ssh-ipv6-%s", local.network_namespace) network = local.network_name priority = 10 direction = "INGRESS" @@ -878,15 +1251,15 @@ resource "google_compute_firewall" "allow_ssh_ipv6" { ports = ["22"] } - target_tags = [var.name] + target_tags = [local.network_namespace] source_ranges = length(var.allowed_ssh_ipv6) > 0 ? var.allowed_ssh_ipv6 : ["127.0.0.1/32"] } resource "google_compute_firewall" "allow_rollout_ipv4" { count = var.rollout_enabled ? 1 : 0 - project = var.project_id - name = format("allow-rollout-ipv4-%s", var.name) + project = local.network_project_id + name = format("allow-rollout-ipv4-%s", local.network_namespace) network = local.network_name priority = 20 direction = "INGRESS" @@ -896,6 +1269,28 @@ resource "google_compute_firewall" "allow_rollout_ipv4" { ports = [tostring(var.rollout_port)] } - target_tags = [var.name] + target_tags = [local.network_namespace] source_ranges = length(var.rollout_allowed_ipv4) > 0 ? var.rollout_allowed_ipv4 : ["127.0.0.1/32"] } + +resource "google_compute_firewall" "allow-cloud-run-ingress" { + count = var.power_management_enabled ? 1 : 0 + + project = local.network_project_id + name = format("allow-cloud-run-%s", local.network_namespace) + network = local.network_name + priority = 20 + direction = "INGRESS" + + allow { + protocol = "tcp" + ports = [tostring(local.primary_compose_project.ingress_port)] + } + + target_tags = [local.network_namespace] + + # Direct VPC egress addresses are ephemeral. Google requires ingress rules + # to use the complete source subnet rather than individual IPs or Cloud Run + # service identities/network tags. + source_ranges = [local.cloud_run_subnetwork_cidr] +} diff --git a/modules/gcp/moved.tf b/modules/gcp/moved.tf new file mode 100644 index 0000000..089857c --- /dev/null +++ b/modules/gcp/moved.tf @@ -0,0 +1,24 @@ +moved { + from = google_service_account.internal-services + to = google_service_account.internal-services[0] +} + +moved { + from = google_service_account_iam_member.internal-services-keys + to = google_service_account_iam_member.internal-services-keys[0] +} + +moved { + from = google_project_iam_member.stackdriver + to = google_project_iam_member.stackdriver[0] +} + +moved { + from = google_service_account_iam_member.self_jwt_signer_policy + to = google_service_account_iam_member.vault_agent_jwt_signer_policy[0] +} + +moved { + from = google_service_account_iam_member.app-keys + to = google_service_account_iam_member.app-keys[0] +} diff --git a/modules/gcp/outputs.tf b/modules/gcp/outputs.tf index 67cedf0..8d62e29 100644 --- a/modules/gcp/outputs.tf +++ b/modules/gcp/outputs.tf @@ -1,17 +1,70 @@ output "instance" { value = { - id : google_compute_instance.cloud-compose.instance_id, - name : google_compute_instance.cloud-compose.name, - disk : google_compute_disk.docker-volumes.name, - zone : google_compute_instance.cloud-compose.zone, - internal_ip : google_compute_instance.cloud-compose.network_interface[0].network_ip, + id = google_compute_instance.cloud-compose.instance_id + name = google_compute_instance.cloud-compose.name + disk = google_compute_disk.docker-volumes.name + zone = google_compute_instance.cloud-compose.zone + internal_ip = google_compute_instance.cloud-compose.network_interface[0].network_ip + network = { + project_id = local.network_project_id + self_link = local.network_name + subnetwork = local.subnetwork_name + ipv4_cidr = local.cloud_run_subnetwork_cidr + mtu = local.cloud_run_network_mtu + direct_vpc = var.power_management_enabled + vm_tag = local.network_namespace + } + boot_disk = { + id = google_compute_disk.boot.id + name = google_compute_disk.boot.name + self_link = google_compute_disk.boot.self_link + } + data_disk = { + id = google_compute_disk.data.id + name = google_compute_disk.data.name + self_link = google_compute_disk.data.self_link + } + docker_volumes_disk = { + id = google_compute_disk.docker-volumes.id + name = google_compute_disk.docker-volumes.name + self_link = google_compute_disk.docker-volumes.self_link + } gsa : { email : local.vm_service_account_email, id : local.vm_service_account_id, name : local.vm_service_account_name, } } - description = "The Google Compute instance ID, name, zone, data disk, GSA for the instance." + description = "The GCP instance identity, network placement, replaceable boot disk, persistent disks, and VM service account. The legacy disk field remains the Docker-volume disk name." +} + +output "network" { + value = { + project_id = local.network_project_id + self_link = local.network_name + subnetwork = local.subnetwork_name + ipv4_cidr = local.cloud_run_subnetwork_cidr + mtu = local.cloud_run_network_mtu + direct_vpc = var.power_management_enabled + vm_tag = local.network_namespace + } + description = "Resolved GCP network and regional subnetwork shared by the VM and optional Cloud Run Direct VPC egress." +} + +output "volumes" { + value = { + data = { + id = google_compute_disk.data.id + name = google_compute_disk.data.name + self_link = google_compute_disk.data.self_link + } + docker_volumes = { + id = google_compute_disk.docker-volumes.id + name = google_compute_disk.docker-volumes.name + self_link = google_compute_disk.docker-volumes.self_link + } + } + description = "Persistent GCP application-data and Docker-volume disks. The replaceable boot disk is exposed under instance.boot_disk." } output "instance_id" { @@ -30,12 +83,12 @@ output "internal_ip" { } output "serviceGsa" { - value = { - email : google_service_account.internal-services.email, - id : google_service_account.internal-services.id, - name : google_service_account.internal-services.name, - } - description = "The Google Service Account internal services that manage the VM runs as" + value = local.internal_services_enabled ? { + email : google_service_account.internal-services[0].email, + id : google_service_account.internal-services[0].id, + name : google_service_account.internal-services[0].name, + } : null + description = "The internal-services Google service account when that privileged runtime is enabled, otherwise null." } output "appGsa" { @@ -43,8 +96,9 @@ output "appGsa" { email : local.app_service_account_email, id : local.app_service_account_id, name : local.app_service_account_name, + credentials_enabled : local.app_credentials_enabled, } - description = "The Google Service Account the app can leverage to auth to other Google services" + description = "The Google Service Account the app can leverage to authenticate to other Google services, including whether managed file credentials are enabled." } output "urls" { @@ -69,7 +123,7 @@ output "rollout" { } output "compose_projects" { - value = local.compose_projects + value = local.validated_compose_projects description = "Normalized compose project manifest." } @@ -77,3 +131,8 @@ output "primary_compose_project" { value = local.primary_compose_project description = "Normalized primary compose project." } + +output "sitectl_package_versions" { + value = module.sitectl_runtime.package_versions + description = "Effective release selector for every installed sitectl package; values may be exact tags or latest." +} diff --git a/modules/gcp/runtime_contracts.tftest.hcl b/modules/gcp/runtime_contracts.tftest.hcl new file mode 100644 index 0000000..9fcc6c6 --- /dev/null +++ b/modules/gcp/runtime_contracts.tftest.hcl @@ -0,0 +1,871 @@ +mock_provider "cloudinit" {} +mock_provider "google" { + mock_resource "google_service_account" { + override_during = plan + defaults = { + email = "mock-service-account@test-project.iam.gserviceaccount.com" + id = "projects/test-project/serviceAccounts/mock-service-account@test-project.iam.gserviceaccount.com" + name = "projects/test-project/serviceAccounts/mock-service-account@test-project.iam.gserviceaccount.com" + } + } + mock_data "google_compute_network" { + defaults = { + name = "existing-network" + self_link = "https://www.googleapis.com/compute/v1/projects/network-project/global/networks/existing-network" + } + } + mock_data "google_compute_subnetwork" { + defaults = { + name = "existing-network" + self_link = "https://www.googleapis.com/compute/v1/projects/network-project/regions/us-east5/subnetworks/existing-network" + network = "https://www.googleapis.com/compute/v1/projects/network-project/global/networks/existing-network" + ip_cidr_range = "10.50.0.0/24" + } + } + mock_data "google_project" { + defaults = { + number = "123456789" + } + } + mock_data "google_service_account" { + defaults = { + email = "existing@test-project.iam.gserviceaccount.com" + name = "projects/test-project/serviceAccounts/existing@test-project.iam.gserviceaccount.com" + } + } +} +mock_provider "time" {} + +run "disables_privileged_services_by_default" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + project_number = "123456789" + docker_compose_repo = "https://github.com/libops/wp.git" + } + + assert { + condition = length(google_service_account.internal-services) == 0 + error_message = "The internal-services identity must not exist by default." + } + + assert { + condition = length(module.ppb) == 0 + error_message = "The power-button module must not exist by default." + } + + assert { + condition = output.serviceGsa == null + error_message = "serviceGsa must be null while privileged services are disabled." + } + + assert { + condition = length(google_service_account_iam_member.vault_agent_jwt_signer_policy) == 0 + error_message = "An inactive Vault Agent must not grant the app identity token-signing permission." + } + + assert { + condition = ( + length(google_service_account_iam_member.app-keys) == 0 && + local.host_env.GCP_APP_CREDENTIALS_ENABLED == "false" + ) + error_message = "User-managed app credentials must be disabled by default." + } + + assert { + condition = local.host_env.GCP_PROJECT_NUMBER == "123456789" + error_message = "The runtime project number must come from project_id discovery, not the deprecated caller assertion." + } + + assert { + condition = ( + google_project_iam_member.log.member == "serviceAccount:${local.vm_service_account_email}" && + google_project_iam_member.monitoring.member == "serviceAccount:${local.vm_service_account_email}" + ) + error_message = "The VM identity must receive both host logging and monitoring writer roles." + } +} + +run "creates_app_key_management_only_when_explicitly_enabled" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + docker_compose_repo = "https://github.com/libops/wp.git" + app_credentials_enabled = true + } + + assert { + condition = ( + length(google_service_account_iam_member.app-keys) == 1 && + google_service_account_iam_member.app-keys[0].role == "roles/iam.serviceAccountKeyAdmin" && + google_service_account_iam_member.app-keys[0].member == "serviceAccount:${local.vm_service_account_email}" && + local.host_env.GCP_APP_CREDENTIALS_ENABLED == "true" + ) + error_message = "Explicit app file credentials must scope Key Admin to the app identity and enable runtime rotation." + } +} + +run "scopes_token_signing_to_managed_gcp_iam_vault_auth" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + docker_compose_repo = "https://github.com/libops/wp.git" + vault_agent_enabled = true + vault_auth_method = "gcp-iam" + vault_addr = "https://vault.example" + vault_role = "wordpress" + } + + assert { + condition = ( + length(google_service_account_iam_member.vault_agent_jwt_signer_policy) == 1 && + google_service_account_iam_member.vault_agent_jwt_signer_policy[0].role == "roles/iam.serviceAccountTokenCreator" && + google_service_account_iam_member.vault_agent_jwt_signer_policy[0].member == "serviceAccount:${local.vm_service_account_email}" && + length(google_service_account_iam_member.app-keys) == 0 && + length(regexall("GOOGLE_APPLICATION_CREDENTIALS", local.vault_agent_env_content)) == 0 + ) + error_message = "Managed GCP IAM Vault auto-auth must let only the VM identity sign as the app identity." + } +} + +run "omits_token_signing_for_consumer_managed_vault_auth" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + docker_compose_repo = "https://github.com/libops/wp.git" + vault_agent_enabled = true + vault_auth_method = "consumer-managed" + vault_addr = "https://vault.example" + vault_agent_additional_config = "auto_auth { method \"approle\" { config = {} } }" + } + + assert { + condition = length(google_service_account_iam_member.vault_agent_jwt_signer_policy) == 0 + error_message = "Consumer-managed Vault auth must not add GCP token-signing permission." + } +} + +run "normalizes_minimal_compose_project" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + project_number = "123456789" + compose_projects = { + wordpress = { + docker_compose_repo = "https://github.com/libops/wp.git" + } + } + sitectl_verify_args = ["--route", "/"] + } + + assert { + condition = ( + local.compose_projects.wordpress.name == "wordpress" && + local.compose_projects.wordpress.docker_compose_repo == "https://github.com/libops/wp.git" && + local.compose_projects.wordpress.docker_compose_branch == "main" && + local.compose_projects.wordpress.repo_path == "libops/wp.git" && + local.compose_projects.wordpress.project_dir == "/mnt/disks/data/libops/wp.git/main" && + local.compose_projects.wordpress.compose_project_name == "libops-wp-main" && + local.compose_projects.wordpress.ingress_port == 80 && + local.compose_projects.wordpress.ingress.letsencrypt == var.sitectl_ingress.letsencrypt && + local.compose_projects.wordpress.sitectl_context_name == "wordpress" && + local.compose_projects.wordpress.sitectl_plugin == "core" && + local.compose_projects.wordpress.sitectl_environment == "production" && + local.compose_projects.wordpress.sitectl_packages == tolist(["sitectl"]) && + local.compose_projects.wordpress.sitectl_verify_args == tolist(["--route", "/"]) && + local.compose_projects.wordpress.init_commands == var.docker_compose_init && + local.compose_projects.wordpress.up_commands == var.docker_compose_up && + local.compose_projects.wordpress.down_commands == var.docker_compose_down && + local.compose_projects.wordpress.rollout_commands == var.docker_compose_rollout + ) + error_message = "A compose project containing only docker_compose_repo must inherit and derive every optional field." + } +} + +run "distinguishes_inherited_and_explicit_core_only_project_packages" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + sitectl_packages = [ + "sitectl-drupal", + "sitectl-isle", + ] + compose_projects = { + inherited = { + docker_compose_repo = "https://github.com/libops/isle.git" + } + core-only = { + docker_compose_repo = "https://github.com/libops/wp.git" + sitectl_packages = [] + } + } + } + + assert { + condition = ( + local.compose_projects.inherited.sitectl_packages == tolist(["sitectl", "sitectl-drupal", "sitectl-isle"]) && + local.compose_projects["core-only"].sitectl_packages == tolist(["sitectl"]) && + module.sitectl_runtime.packages == tolist(["sitectl", "sitectl-drupal", "sitectl-isle"]) + ) + error_message = "Project manifests must distinguish inherited packages from an explicit list while the host package set remains their union." + } +} + +run "power_management_enables_required_identity_and_ingress" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + project_number = "123456789" + docker_compose_repo = "https://github.com/libops/wp.git" + power_management_enabled = true + power_start_role = "projects/test-project/roles/cloudComposeStart" + power_suspend_role = "projects/test-project/roles/cloudComposeSuspend" + allowed_ips = ["198.51.100.25/32"] + allowed_ip_forwarded_depth = 0 + } + + assert { + condition = length(google_service_account.internal-services) == 1 + error_message = "Power management must create its internal-services identity." + } + + assert { + condition = length(google_compute_instance_iam_member.gce-suspend) == 1 + error_message = "Power management must grant the internal-services identity permission to suspend the VM." + } + + assert { + condition = length(module.ppb) == 1 + error_message = "Power management must create the pinned power-button ingress module." + } + + assert { + condition = output.serviceGsa != null + error_message = "serviceGsa must expose the enabled internal-services identity." + } + + assert { + condition = ( + length(google_compute_instance_iam_member.gce-start) == 1 && + length(google_compute_firewall.allow-cloud-run-ingress) == 1 && + length(google_compute_firewall.allow-cloud-run-ingress[0].source_ranges) == 1 && + contains(google_compute_firewall.allow-cloud-run-ingress[0].source_ranges, "10.42.0.0/24") && + length(one(google_compute_firewall.allow-cloud-run-ingress[0].allow).ports) == 1 && + contains(one(google_compute_firewall.allow-cloud-run-ingress[0].allow).ports, "80") && + local.base_config.powerOnTimeout == 360 && + local.base_config.ipDepth == 0 && + local.dynamic_properties.allowedIps == tolist(["198.51.100.25/32"]) && + local.ppb_container.startup_probe == "/healthcheck" && + local.base_config.proxyTimeouts.dialTimeout == 120 && + local.base_config.proxyTimeouts.dialAttemptTimeout == 5 && + local.base_config.proxyTimeouts.dialRetryInterval == 1 + ) + error_message = "Power management must bind least-privilege roles to the VM, allow only the selected subnet to reach the app port, use a process-only startup probe, and bound VM/network readiness." + } +} + +run "rejects_power_management_without_explicit_client_cidrs" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + project_number = "123456789" + docker_compose_repo = "https://github.com/libops/wp.git" + power_management_enabled = true + power_start_role = "projects/test-project/roles/cloudComposeStart" + power_suspend_role = "projects/test-project/roles/cloudComposeSuspend" + allowed_ip_forwarded_depth = 0 + } + + expect_failures = [google_compute_instance.cloud-compose] +} + +run "rejects_power_management_without_explicit_proxy_depth" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + project_number = "123456789" + docker_compose_repo = "https://github.com/libops/wp.git" + power_management_enabled = true + power_start_role = "projects/test-project/roles/cloudComposeStart" + power_suspend_role = "projects/test-project/roles/cloudComposeSuspend" + allowed_ips = ["198.51.100.25/32"] + } + + expect_failures = [google_compute_instance.cloud-compose] +} + +run "rejects_negative_proxy_depth" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + docker_compose_repo = "https://github.com/libops/wp.git" + allowed_ip_forwarded_depth = -1 + } + + expect_failures = [var.allowed_ip_forwarded_depth] +} + +run "supports_explicit_additional_trusted_proxy_depth" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + docker_compose_repo = "https://github.com/libops/wp.git" + allowed_ip_forwarded_depth = 2 + } + + assert { + condition = local.base_config.ipDepth == 2 + error_message = "An explicitly reviewed additional trusted proxy must increase PPB's right-edge X-Forwarded-For depth." + } +} + +run "rejects_wrong_family_firewall_cidrs" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + docker_compose_repo = "https://github.com/libops/wp.git" + allowed_ssh_ipv4 = ["2001:db8::/64"] + allowed_ssh_ipv6 = ["10.0.0.0/8"] + rollout_allowed_ipv4 = ["2001:db8::/64"] + } + + expect_failures = [ + var.allowed_ssh_ipv4, + var.allowed_ssh_ipv6, + var.rollout_allowed_ipv4, + ] +} + +run "rejects_invalid_ingress_and_frontend_ports" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + docker_compose_repo = "https://github.com/libops/wp.git" + ingress_port = 65536 + frontend = { + image = "example.invalid/frontend@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + port = 0 + } + } + + expect_failures = [ + var.ingress_port, + var.frontend, + ] +} + +run "rejects_fractional_compose_project_port" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + compose_projects = { + wordpress = { + docker_compose_repo = "https://github.com/libops/wp.git" + ingress_port = 80.5 + } + } + } + + expect_failures = [var.compose_projects] +} + +run "renders_verified_archive_before_downstream_overlay" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + project_number = "123456789" + docker_compose_repo = "https://github.com/libops/wp.git" + rootfs = "testdata/rootfs" + rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz?literal=$(id)" + rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + extra_env = { + NGINX_CLIENT_MAX_BODY_SIZE = "512m" + PHP_UPLOAD_MAX_FILESIZE = "512M" + BASH_ENV = "/tmp/application-only-bash-env" + LD_PRELOAD = "/tmp/application-only-preload.so" + } + } + + assert { + condition = ( + strcontains(local.cloud_init_yaml, "sha256sum -c -") && + strcontains(local.cloud_init_yaml, base64encode(var.rootfs_archive_url)) && + !strcontains(local.cloud_init_yaml, var.rootfs_archive_url) + ) + error_message = "The GCP archive URL must be transported as literal base64 data and verified before extraction." + } + + assert { + condition = can(regex( + "(?s)cp -a \\\"\\$rootfs_dir\\\"/\\. /.*cp -a \\\"\\$overlay_dir\\\"/\\. /", + local.cloud_init_yaml, + )) + error_message = "The consumer rootfs overlay must be applied after the verified base archive." + } + + assert { + condition = ( + strcontains(local.cloud_init_yaml, "/var/lib/cloud-compose/rootfs-overlay/etc/cloud-compose-overlay-marker") && + !strcontains(module.runtime_env.content, "NGINX_CLIENT_MAX_BODY_SIZE") && + !strcontains(module.runtime_env.content, "PHP_UPLOAD_MAX_FILESIZE") && + !contains(keys(local.host_env), "BASH_ENV") && + !contains(keys(local.host_env), "LD_PRELOAD") && + strcontains( + local.application_env_file_content, + jsonencode(base64gzip(jsonencode(var.extra_env))), + ) + ) + error_message = "The downstream overlay and isolated application environment data must be present in cloud-init." + } +} + +run "namespaces_rollout_auth_away_from_application_environment" { + command = plan + + variables { + name = "gcp-rollout-contract" + project_id = "test-project" + project_number = "123456789" + docker_compose_repo = "https://github.com/libops/wp.git" + rollout_enabled = true + rollout_release_url = "https://example.invalid/cloud-compose-rollout" + rollout_release_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + rollout_jwks_uri = "https://trusted.example/.well-known/jwks.json" + rollout_jwt_audience = "trusted-controller" + rollout_custom_claims = "{\"role\":\"deployer\"}" + extra_env = { + PORT = "9999" + JWKS_URI = "https://attacker.invalid/jwks.json" + JWT_AUD = "attacker" + CUSTOM_CLAIMS = "{\"admin\":true}" + } + } + + assert { + condition = ( + local.host_env.ROLLOUT_PORT == "8081" && + local.host_env.ROLLOUT_JWKS_URI == "https://trusted.example/.well-known/jwks.json" && + local.host_env.ROLLOUT_JWT_AUD == "trusted-controller" && + local.host_env.ROLLOUT_CUSTOM_CLAIMS == "{\"role\":\"deployer\"}" && + !contains(keys(local.host_env), "PORT") && + !contains(keys(local.host_env), "JWKS_URI") && + !contains(keys(local.host_env), "JWT_AUD") && + !contains(keys(local.host_env), "CUSTOM_CLAIMS") && + strcontains( + local.application_env_file_content, + jsonencode(base64gzip(jsonencode(var.extra_env))), + ) + ) + error_message = "Rollout listener and auth settings must be sourced only from the namespaced host-control contract." + } +} + +run "rejects_insecure_rollout_trust_inputs" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + docker_compose_repo = "https://github.com/libops/wp.git" + rollout_release_url = "http://downloads.example/rollout" + rollout_jwks_uri = "http://identity.example/jwks" + rollout_custom_claims = "[]" + } + + expect_failures = [ + var.rollout_release_url, + var.rollout_jwks_uri, + var.rollout_custom_claims, + ] +} + +run "rejects_archive_without_checksum" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + project_number = "123456789" + docker_compose_repo = "https://github.com/libops/wp.git" + rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz" + } + + expect_failures = [google_compute_instance.cloud-compose] +} + +run "rejects_non_https_rootfs_archive" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + project_number = "123456789" + docker_compose_repo = "https://github.com/libops/wp.git" + rootfs_archive_url = "http://example.invalid/cloud-compose.tar.gz" + rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + + expect_failures = [var.rootfs_archive_url] +} + +run "supports_network_only_direct_vpc" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + project_number = "123456789" + docker_compose_repo = "https://github.com/libops/wp.git" + create_network = false + network_project_id = "network-project" + network_name = "existing-network" + power_management_enabled = true + power_start_role = "projects/test-project/roles/cloudComposeStart" + power_suspend_role = "projects/test-project/roles/cloudComposeSuspend" + allowed_ips = ["198.51.100.25/32"] + allowed_ip_forwarded_depth = 0 + } + + assert { + condition = ( + local.network_name == "https://www.googleapis.com/compute/v1/projects/network-project/global/networks/existing-network" && + local.subnetwork_name == "https://www.googleapis.com/compute/v1/projects/network-project/regions/us-east5/subnetworks/existing-network" && + local.cloud_run_network_resource_name == "projects/network-project/global/networks/existing-network" && + local.cloud_run_subnetwork_resource_name == "projects/network-project/regions/us-east5/subnetworks/existing-network" && + local.cloud_run_subnetwork_cidr == "10.50.0.0/24" && + local.dynamic_properties.allowedIps == tolist(["198.51.100.25/32"]) && + google_compute_firewall.allow-cloud-run-ingress[0].project == "network-project" && + local.network_namespace == "gcp-contract-${substr(sha256("test-project"), 0, 8)}" && + google_compute_firewall.allow-cloud-run-ingress[0].name == "allow-cloud-run-${local.network_namespace}" && + google_compute_instance.cloud-compose.tags == toset(["cloud-compose", local.network_namespace]) && + length(google_compute_firewall.allow-cloud-run-ingress[0].source_ranges) == 1 && + contains(google_compute_firewall.allow-cloud-run-ingress[0].source_ranges, "10.50.0.0/24") + ) + error_message = "Network-only Direct VPC input must select the same-named subnet, use canonical Cloud Run resource names, and use its full CIDR for VM ingress." + } +} + +run "rejects_mismatched_project_number_assertion" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + project_number = "987654321" + docker_compose_repo = "https://github.com/libops/wp.git" + } + + expect_failures = [google_compute_instance.cloud-compose] +} + +run "rejects_power_role_from_another_project" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + docker_compose_repo = "https://github.com/libops/wp.git" + power_management_enabled = true + power_start_role = "projects/other-project/roles/cloudComposeStart" + power_suspend_role = "projects/test-project/roles/cloudComposeSuspend" + allowed_ips = ["198.51.100.25/32"] + allowed_ip_forwarded_depth = 0 + } + + expect_failures = [google_compute_instance.cloud-compose] +} + +run "rejects_cross_project_supplied_service_accounts" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + docker_compose_repo = "https://github.com/libops/wp.git" + service_account_email = "vm@other-project.iam.gserviceaccount.com" + app_service_account_email = "app@other-project.iam.gserviceaccount.com" + } + + expect_failures = [google_compute_instance.cloud-compose] +} + +run "resolves_a_supplied_same_project_service_account" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + docker_compose_repo = "https://github.com/libops/wp.git" + service_account_email = "existing@test-project.iam.gserviceaccount.com" + } + + assert { + condition = ( + local.vm_service_account_email == "existing@test-project.iam.gserviceaccount.com" && + local.vm_service_account_id == "projects/test-project/serviceAccounts/existing@test-project.iam.gserviceaccount.com" + ) + error_message = "A supplied service account must resolve through its authoritative same-project data source." + } +} + +run "supports_power_roles_in_a_legacy_domain_scoped_project" { + command = plan + + variables { + name = "gcp-contract" + project_id = "example.com:test-project" + docker_compose_repo = "https://github.com/libops/wp.git" + power_management_enabled = true + power_start_role = "projects/example.com:test-project/roles/cloudComposeStart" + power_suspend_role = "projects/example.com:test-project/roles/cloudComposeSuspend" + allowed_ips = ["198.51.100.25/32"] + allowed_ip_forwarded_depth = 0 + } + + assert { + condition = ( + google_compute_instance_iam_member.gce-start[0].role == "projects/example.com:test-project/roles/cloudComposeStart" && + google_compute_instance_iam_member.gce-suspend[0].role == "projects/example.com:test-project/roles/cloudComposeSuspend" + ) + error_message = "Legacy domain-scoped project custom roles must remain valid for instance-scoped bindings." + } +} + +run "rejects_network_project_id_that_conflicts_with_self_link" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + docker_compose_repo = "https://github.com/libops/wp.git" + create_network = false + network_project_id = "different-network-project" + network_name = "projects/network-project/global/networks/existing-network" + } + + expect_failures = [google_compute_instance.cloud-compose] +} + +run "rejects_network_and_subnetwork_from_different_projects" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + docker_compose_repo = "https://github.com/libops/wp.git" + create_network = false + network_name = "projects/network-project/global/networks/existing-network" + subnetwork_name = "projects/other-network-project/regions/us-east5/subnetworks/existing-network" + } + + override_data { + target = data.google_compute_subnetwork.selected + values = { + name = "existing-network" + self_link = "https://www.googleapis.com/compute/v1/projects/other-network-project/regions/us-east5/subnetworks/existing-network" + network = "https://www.googleapis.com/compute/v1/projects/other-network-project/global/networks/existing-network" + ip_cidr_range = "10.50.0.0/24" + } + } + + expect_failures = [google_compute_instance.cloud-compose] +} + +run "rejects_subnetwork_from_another_region" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + docker_compose_repo = "https://github.com/libops/wp.git" + create_network = false + subnetwork_name = "projects/network-project/regions/us-central1/subnetworks/existing-network" + } + + override_data { + target = data.google_compute_subnetwork.selected + values = { + name = "existing-network" + self_link = "https://www.googleapis.com/compute/v1/projects/network-project/regions/us-central1/subnetworks/existing-network" + network = "https://www.googleapis.com/compute/v1/projects/network-project/global/networks/existing-network" + ip_cidr_range = "10.50.0.0/24" + } + } + + expect_failures = [google_compute_instance.cloud-compose] +} + +run "supports_subnetwork_only_direct_vpc" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + project_number = "123456789" + docker_compose_repo = "https://github.com/libops/wp.git" + create_network = false + network_project_id = "network-project" + subnetwork_name = "existing-subnetwork" + power_management_enabled = true + power_start_role = "projects/test-project/roles/cloudComposeStart" + power_suspend_role = "projects/test-project/roles/cloudComposeSuspend" + allowed_ips = ["198.51.100.25/32"] + allowed_ip_forwarded_depth = 0 + } + + assert { + condition = ( + local.network_name == data.google_compute_subnetwork.selected[0].network && + local.subnetwork_name == data.google_compute_subnetwork.selected[0].self_link && + local.cloud_run_subnetwork_cidr == data.google_compute_subnetwork.selected[0].ip_cidr_range + ) + error_message = "Subnetwork-only Direct VPC input must derive and share the subnet's parent network." + } +} + +run "derives_a_simple_network_project_from_subnetwork_self_link" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + docker_compose_repo = "https://github.com/libops/wp.git" + create_network = false + network_name = "existing-network" + subnetwork_name = "projects/network-project/regions/us-east5/subnetworks/existing-network" + } + + assert { + condition = ( + local.selected_network_lookup_project == "network-project" && + local.network_project_id == "network-project" && + local.subnetwork_project_id == "network-project" + ) + error_message = "A fully qualified subnet must supply the project for a simple parent-network name." + } +} + +run "rejects_undersized_direct_vpc_subnet" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + project_number = "123456789" + docker_compose_repo = "https://github.com/libops/wp.git" + network_ip_cidr_range = "10.42.0.0/28" + power_management_enabled = true + power_start_role = "projects/test-project/roles/cloudComposeStart" + power_suspend_role = "projects/test-project/roles/cloudComposeSuspend" + allowed_ips = ["198.51.100.25/32"] + allowed_ip_forwarded_depth = 0 + } + + expect_failures = [google_compute_instance.cloud-compose] +} + +run "rejects_unsupported_direct_vpc_subnet_range" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + project_number = "123456789" + docker_compose_repo = "https://github.com/libops/wp.git" + network_ip_cidr_range = "203.0.113.0/24" + power_management_enabled = true + power_start_role = "projects/test-project/roles/cloudComposeStart" + power_suspend_role = "projects/test-project/roles/cloudComposeSuspend" + allowed_ips = ["198.51.100.25/32"] + allowed_ip_forwarded_depth = 0 + } + + expect_failures = [google_compute_instance.cloud-compose] +} + +run "supports_rfc6598_direct_vpc_subnet_range" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + project_number = "123456789" + docker_compose_repo = "https://github.com/libops/wp.git" + network_ip_cidr_range = "100.64.0.0/26" + power_management_enabled = true + power_start_role = "projects/test-project/roles/cloudComposeStart" + power_suspend_role = "projects/test-project/roles/cloudComposeSuspend" + allowed_ips = ["198.51.100.25/32"] + allowed_ip_forwarded_depth = 0 + } + + assert { + condition = local.cloud_run_subnetwork_range_supported + error_message = "RFC6598 subnets must be accepted for Direct VPC egress." + } +} + +run "rejects_custom_direct_vpc_network_mtu" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + project_number = "123456789" + docker_compose_repo = "https://github.com/libops/wp.git" + network_mtu = 1500 + power_management_enabled = true + power_start_role = "projects/test-project/roles/cloudComposeStart" + power_suspend_role = "projects/test-project/roles/cloudComposeSuspend" + allowed_ips = ["198.51.100.25/32"] + allowed_ip_forwarded_depth = 0 + } + + expect_failures = [google_compute_instance.cloud-compose] +} + +run "rejects_reserved_extra_environment" { + command = plan + + variables { + name = "gcp-contract" + project_id = "test-project" + project_number = "123456789" + docker_compose_repo = "https://github.com/libops/wp.git" + extra_env = { + GCP_PROJECT = "different-project" + } + } + + expect_failures = [var.extra_env] +} diff --git a/modules/gcp/testdata/rootfs/etc/cloud-compose-overlay-marker b/modules/gcp/testdata/rootfs/etc/cloud-compose-overlay-marker new file mode 100644 index 0000000..7a25921 --- /dev/null +++ b/modules/gcp/testdata/rootfs/etc/cloud-compose-overlay-marker @@ -0,0 +1 @@ +downstream overlay wins diff --git a/modules/gcp/upgrade_dependency.tftest.hcl b/modules/gcp/upgrade_dependency.tftest.hcl new file mode 100644 index 0000000..a45c1dc --- /dev/null +++ b/modules/gcp/upgrade_dependency.tftest.hcl @@ -0,0 +1,61 @@ +mock_provider "cloudinit" {} + +mock_provider "google" { + mock_resource "google_service_account" { + defaults = { + email = "mock-service-account@test-project.iam.gserviceaccount.com" + id = "projects/test-project/serviceAccounts/mock-service-account@test-project.iam.gserviceaccount.com" + name = "projects/test-project/serviceAccounts/mock-service-account@test-project.iam.gserviceaccount.com" + } + } + + mock_data "google_project" { + defaults = { + number = "123456789" + } + } +} + +mock_provider "time" {} + +variables { + name = "gcp-cycle-contract" + project_id = "test-project" + project_number = "123456789" + docker_compose_repo = "https://github.com/libops/wp.git" + power_management_enabled = true + power_start_role = "projects/test-project/roles/cloudComposeStart" + power_suspend_role = "projects/test-project/roles/cloudComposeSuspend" + allowed_ips = ["198.51.100.25/32"] + allowed_ip_forwarded_depth = 0 + vault_agent_enabled = true + vault_auth_method = "gcp-iam" + vault_addr = "https://vault.example" + vault_role = "wordpress" +} + +run "apply_power_managed_vault_baseline" { + command = apply +} + +run "plan_vm_replacement_while_retiring_vault_signer" { + command = plan + + variables { + vault_agent_enabled = false + } + + plan_options { + replace = [google_compute_instance.cloud-compose] + } + + assert { + condition = ( + length(google_compute_instance_iam_member.gce-start) == 1 && + length(google_compute_instance_iam_member.gce-suspend) == 1 && + google_compute_instance_iam_member.gce-start[0].instance_name == google_compute_instance.cloud-compose.name && + google_compute_instance_iam_member.gce-suspend[0].instance_name == google_compute_instance.cloud-compose.name + ) + error_message = "A VM replacement must retain both instance-scoped power bindings without introducing an upgrade dependency cycle." + } +} diff --git a/modules/gcp/variables.tf b/modules/gcp/variables.tf index 38b7801..eb1e3f9 100644 --- a/modules/gcp/variables.tf +++ b/modules/gcp/variables.tf @@ -1,11 +1,50 @@ variable "project_id" { description = "The GCP project ID" type = string + + validation { + condition = can(regex("^([a-z0-9][a-z0-9.-]*:)?[a-z][a-z0-9-]{4,28}[a-z0-9]$", var.project_id)) + error_message = "project_id must be a valid lowercase GCP project ID (legacy domain-scoped prefixes are accepted)." + } } variable "project_number" { type = string - description = "The GCP project number" + default = "" + description = "Deprecated optional project-number assertion. The module derives the authoritative number from project_id and rejects a mismatched non-empty value." + + validation { + condition = var.project_number == "" || can(regex("^[0-9]+$", var.project_number)) + error_message = "project_number must be empty or contain only decimal digits." + } +} + +variable "power_start_role" { + type = string + default = "" + description = "Full project- or organization-custom-role name granting compute.instances.get, start, and resume. Required when power management is enabled; create it in the singleton GCP foundation module." + + validation { + condition = var.power_start_role == "" || can(regex( + "^(projects/([a-z0-9][a-z0-9.-]*:)?[a-z][a-z0-9-]{4,28}[a-z0-9]|organizations/[0-9]+)/roles/[A-Za-z0-9_.]+$", + var.power_start_role, + )) + error_message = "power_start_role must be empty or a full projects/.../roles/... or organizations/.../roles/... custom-role name." + } +} + +variable "power_suspend_role" { + type = string + default = "" + description = "Full project- or organization-custom-role name granting compute.instances.get and suspend. Required when power management is enabled; create it in the singleton GCP foundation module." + + validation { + condition = var.power_suspend_role == "" || can(regex( + "^(projects/([a-z0-9][a-z0-9.-]*:)?[a-z][a-z0-9-]{4,28}[a-z0-9]|organizations/[0-9]+)/roles/[A-Za-z0-9_.]+$", + var.power_suspend_role, + )) + error_message = "power_suspend_role must be empty or a full projects/.../roles/... or organizations/.../roles/... custom-role name." + } } variable "region" { @@ -23,18 +62,39 @@ variable "zone" { variable "name" { type = string description = "The site name (will be the name of the GCP instance)" + + validation { + condition = can(regex("^[a-z][a-z0-9-]{4,19}[a-z0-9]$", var.name)) + error_message = "name must be 6 through 21 lowercase letters, numbers, or hyphens; it must start with a letter and end with a letter or number so every generated GCP service-account ID is valid." + } } variable "service_account_email" { - description = "Existing service account email for the VM. When empty, this module creates one." + description = "Existing same-project service account email dedicated to this application state for the VM. When empty, this module creates one. Reuse across application states and cross-project attachment are not supported." type = string default = "" + + validation { + condition = var.service_account_email == "" || can(regex("^[a-z0-9-]+@[a-z0-9.-]+\\.iam\\.gserviceaccount\\.com$", var.service_account_email)) + error_message = "service_account_email must be empty or a valid Google service-account email." + } } variable "app_service_account_email" { - description = "Existing service account email for the compose app identity. When empty, this module creates one. On GCP this identity is used for app-scoped credentials such as Vault GCP IAM auth." + description = "Existing same-project service account email dedicated to this application state for the compose app identity. When empty, this module creates one. Reuse across application states is not supported. On GCP this identity is used for optional app-scoped credentials and managed Vault GCP IAM auth." type = string default = "" + + validation { + condition = var.app_service_account_email == "" || can(regex("^[a-z0-9-]+@[a-z0-9.-]+\\.iam\\.gserviceaccount\\.com$", var.app_service_account_email)) + error_message = "app_service_account_email must be empty or a valid Google service-account email." + } +} + +variable "app_credentials_enabled" { + description = "Create and rotate a user-managed JSON key for the app service account. Leave false unless an application explicitly requires a file credential; managed Vault Agent GCP IAM auth uses VM attached identity and does not require this." + type = bool + default = false } variable "disk_type" { @@ -85,6 +145,11 @@ variable "ingress_port" { type = number default = 80 description = "TCP port on the VM that the Cloud Run ingress should connect to." + + validation { + condition = var.ingress_port >= 1 && var.ingress_port <= 65535 && floor(var.ingress_port) == var.ingress_port + error_message = "ingress_port must be a whole number between 1 and 65535." + } } variable "primary_compose_project" { @@ -152,8 +217,8 @@ variable "compose_projects" { sitectl_context_name = optional(string) sitectl_plugin = optional(string) sitectl_environment = optional(string) - sitectl_packages = optional(list(string), []) - sitectl_verify_args = optional(list(string), []) + sitectl_packages = optional(list(string)) + sitectl_verify_args = optional(list(string)) docker_compose_init = optional(list(string)) docker_compose_up = optional(list(string)) docker_compose_down = optional(list(string)) @@ -166,10 +231,11 @@ variable "compose_projects" { for name, app in var.compose_projects : can(regex("^[a-z][a-z0-9-]*$", name)) && trimspace(app.docker_compose_repo) != "" && - try(app.ingress_port, 80) > 0 && - try(app.ingress_port, 80) <= 65535 + coalesce(try(app.ingress_port, null), var.ingress_port) >= 1 && + coalesce(try(app.ingress_port, null), var.ingress_port) <= 65535 && + floor(coalesce(try(app.ingress_port, null), var.ingress_port)) == coalesce(try(app.ingress_port, null), var.ingress_port) ]) - error_message = "compose_projects keys must match ^[a-z][a-z0-9-]*$, docker_compose_repo is required, and ingress_port must be between 1 and 65535." + error_message = "compose_projects keys must match ^[a-z][a-z0-9-]*$, docker_compose_repo is required, and ingress_port must be a whole number between 1 and 65535." } } @@ -211,13 +277,13 @@ variable "docker_compose_down" { variable "docker_compose_rollout" { type = list(string) default = [ - "TARGET_REF=\"$${GIT_REF:-$${GIT_BRANCH:-$${DOCKER_COMPOSE_BRANCH:-main}}}\"", - "if [ -x ./scripts/rollout.sh ]; then ./scripts/rollout.sh; else sitectl deploy --context \"$${SITECTL_CONTEXT_NAME}\" --branch \"$TARGET_REF\"; fi", + "TARGET_REF=\"$${GIT_REF:-$${GIT_BRANCH:-}}\"", + "if [ -n \"$TARGET_REF\" ]; then sitectl deploy --context \"$${SITECTL_CONTEXT_NAME}\" --ref \"$TARGET_REF\"; else sitectl deploy --context \"$${SITECTL_CONTEXT_NAME}\" --skip-git; fi", "sitectl healthcheck --context \"$${SITECTL_CONTEXT_NAME}\" --persist", "if [ \"$${SITECTL_ENVIRONMENT}\" != \"production\" ]; then sitectl verify --context \"$${SITECTL_CONTEXT_NAME}\" $${SITECTL_VERIFY_ARGS:-}; fi" ] nullable = false - description = "Command to roll out a new git ref for the docker compose project. The optional rollout service sets GIT_REF/GIT_BRANCH from the trigger request." + description = "Commands used by rollout triggers. GIT_REF/GIT_BRANCH selects a source ref; without one, sitectl reconciles the current checkout." } variable "sitectl_packages" { @@ -237,11 +303,31 @@ variable "sitectl_packages" { variable "sitectl_version" { type = string default = "latest" - description = "Sitectl release tag to install for sitectl packages, or latest to follow https://github.com/libops/sitectl/releases/latest." + description = "Legacy fallback sitectl release tag for packages without a package-specific override." validation { - condition = var.sitectl_version == "latest" || can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+", var.sitectl_version)) - error_message = "sitectl_version must be latest or a release tag such as v0.19.7." + condition = ( + var.sitectl_version == "latest" || + can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$", var.sitectl_version)) + ) + error_message = "sitectl_version must be latest or an exact semantic-version release tag such as v0.38.0." + } +} + +variable "sitectl_package_versions" { + type = map(string) + default = {} + description = "Per-package release tags that override sitectl_version." + + validation { + condition = alltrue([ + for package, version in var.sitectl_package_versions : + can(regex("^sitectl(-[a-z0-9]+)*$", package)) && ( + version == "latest" || + can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$", version)) + ) + ]) + error_message = "sitectl_package_versions keys must be sitectl package names and values must be latest or exact semantic-version release tags." } } @@ -297,20 +383,20 @@ variable "libops_managed_runtime_enabled" { variable "libops_internal_services_auto_update" { type = bool - default = true + default = false description = "Whether the managed runtime updater should pull and restart the internal LibOps compose project." } variable "libops_internal_services_enabled" { type = bool - default = true - description = "Whether to start the internal LibOps services timer. GCP deployments usually keep this enabled; non-GCP modules disable it by default." + default = false + description = "Whether to start the privileged internal LibOps services. Enable explicitly only when the Docker socket and host-observability access are accepted." } variable "power_management_enabled" { type = bool - default = true - description = "Enable GCP power-management support services such as lightsout and Cloud Run proxy-power-button. Disable for providers that do not save cost when VMs are stopped." + default = false + description = "Enable GCP power-management support services such as lightsout and Cloud Run proxy-power-button. This explicitly enables the privileged internal-services runtime required by lightsout." } variable "libops_managed_artifacts" { @@ -343,31 +429,79 @@ variable "libops_managed_artifacts" { variable "allowed_ips" { type = list(string) default = [] - description = "CIDR IP Addresses allowed to turn on this site's GCP instance" + description = "Original-client CIDRs allowed to turn on this site's GCP instance after the configured trusted X-Forwarded-For suffix is removed. Required when power management is enabled." + + validation { + condition = alltrue([for cidr in var.allowed_ips : can(cidrhost(cidr, 0))]) + error_message = "allowed_ips entries must be valid IPv4 or IPv6 CIDR ranges." + } +} + +variable "allowed_ip_forwarded_depth" { + type = number + default = null + nullable = true + description = "Explicit number of trusted proxy addresses after the original client in X-Forwarded-For. Direct public Cloud Run uses 0 because Google appends the original client as the rightmost value. Any additional proxy requires a separately verified larger value. Required when power management is enabled." + + validation { + condition = var.allowed_ip_forwarded_depth == null ? true : ( + var.allowed_ip_forwarded_depth >= 0 && + var.allowed_ip_forwarded_depth <= 10 && + floor(var.allowed_ip_forwarded_depth) == var.allowed_ip_forwarded_depth + ) + error_message = "allowed_ip_forwarded_depth must be null or a whole number from 0 through 10." + } } variable "allowed_ssh_ipv4" { type = list(string) default = [] - description = "CIDR IPv4 Addresses allowed to to SSH into this site's GCP instance" + description = "IPv4 CIDR ranges allowed to SSH into this site's GCP instance." + + validation { + condition = alltrue([ + for cidr in var.allowed_ssh_ipv4 : + can(cidrhost(cidr, 0)) && length(regexall(":", cidr)) == 0 + ]) + error_message = "allowed_ssh_ipv4 entries must be valid IPv4 CIDR ranges." + } } variable "allowed_ssh_ipv6" { type = list(string) default = [] - description = "CIDR IPv6 Addresses allowed to SSH into this site's GCP instance" + description = "IPv6 CIDR ranges allowed to SSH into this site's GCP instance." + + validation { + condition = alltrue([ + for cidr in var.allowed_ssh_ipv6 : + can(cidrhost(cidr, 0)) && length(regexall(":", cidr)) > 0 + ]) + error_message = "allowed_ssh_ipv6 entries must be valid IPv6 CIDR ranges." + } } variable "network_name" { type = string default = "" - description = "Existing VPC network name or self link for the VM and firewall rules. When empty and create_network is true, this module creates a per-deployment network." + description = "Existing VPC network name or self link for the VM, Cloud Run Direct VPC egress, and firewall rules. When supplied without subnetwork_name, Cloud Run and the module select the same-named regional subnet." } variable "subnetwork_name" { type = string default = "" - description = "Existing regional subnetwork name or self link for the VM. When empty and create_network is true, this module creates a per-deployment subnetwork." + description = "Existing regional subnetwork name or self link for the VM and Cloud Run Direct VPC egress. When supplied without network_name, the module derives its parent network." +} + +variable "network_project_id" { + type = string + default = "" + description = "Project containing an existing network and subnetwork. Defaults to project_id. Set it for Shared VPC; the singleton GCP foundation owns Cloud Run subnet IAM, while this stack's Terraform caller must be able to inspect the network and manage its per-stack firewall rules." + + validation { + condition = var.network_project_id == "" || can(regex("^([a-z0-9][a-z0-9.-]*:)?[a-z][a-z0-9-]{4,28}[a-z0-9]$", var.network_project_id)) + error_message = "network_project_id must be empty or a valid lowercase GCP project ID." + } } variable "create_network" { @@ -382,8 +516,19 @@ variable "network_ip_cidr_range" { description = "CIDR range used for the managed GCP subnetwork when create_network is true." validation { - condition = can(cidrhost(var.network_ip_cidr_range, 1)) - error_message = "network_ip_cidr_range must be a valid CIDR range." + condition = can(cidrhost(var.network_ip_cidr_range, 1)) && length(regexall(":", var.network_ip_cidr_range)) == 0 + error_message = "network_ip_cidr_range must be a valid IPv4 CIDR range." + } +} + +variable "network_mtu" { + type = number + default = 1460 + description = "MTU for a managed network, or the caller-attested MTU of an existing/Shared VPC network. Cloud Run Direct VPC egress requires 1460." + + validation { + condition = var.network_mtu >= 1300 && var.network_mtu <= 8896 && floor(var.network_mtu) == var.network_mtu + error_message = "network_mtu must be a whole number from 1300 through 8896." } } @@ -408,7 +553,19 @@ variable "volume_names" { variable "users" { type = map(list(string)) default = {} - description = "Map of usernames to lists of SSH public keys. Users will be created with docker group membership. Example: { \"alice\" = [\"ssh-rsa AAAA...\"], \"bob\" = [\"ssh-ed25519 AAAA...\", \"ssh-rsa BBBB...\"] }" + description = "Map of safe Linux usernames to lists of single-line SSH public keys. Example: { \"alice\" = [\"ssh-rsa AAAA...\"], \"bob\" = [\"ssh-ed25519 AAAA...\", \"ssh-rsa BBBB...\"] }" + + validation { + condition = alltrue(concat( + [for username in keys(var.users) : can(regex("^[a-z_][a-z0-9_-]{0,31}\\$?$", username))], + flatten([ + for _, keys in var.users : [ + for key in keys : trimspace(key) != "" && !can(regex("[\\r\\n]", key)) + ] + ]), + )) + error_message = "users names must be safe Linux usernames and SSH keys must be non-empty single-line values." + } } variable "rootfs" { @@ -417,6 +574,54 @@ variable "rootfs" { description = "Path to additional rootfs files to copy into the VM. Files will be merged with the base rootfs. Example: '/path/to/custom/rootfs'" } +variable "rootfs_archive_url" { + type = string + default = "" + description = "Optional HTTPS tar.gz URL containing a rootfs directory to fetch during boot instead of embedding the packaged rootfs. Must be set with rootfs_archive_sha256." + + validation { + condition = ( + trimspace(var.rootfs_archive_url) == "" || + can(regex("^https://[^[:space:]]+$", trimspace(var.rootfs_archive_url))) + ) + error_message = "rootfs_archive_url must be empty or an HTTPS URL without whitespace." + } +} + +variable "rootfs_archive_sha256" { + type = string + default = "" + description = "Required 64-character SHA-256 checksum when rootfs_archive_url is set." +} + +variable "extra_env" { + type = map(string) + default = {} + description = "Application-only Compose environment values written as JSON data and reconciled into every project .env." + + validation { + condition = alltrue([ + for name in keys(var.extra_env) : + can(regex("^[A-Za-z_][A-Za-z0-9_]*$", name)) && + !contains(["HOME", "PATH"], name) && + alltrue([ + for prefix in [ + "CLOUD_COMPOSE_", + "COMPOSE_", + "DOCKER_", + "SITECTL_", + "LIBOPS_", + "GCP_", + "VAULT_", + "ROLLOUT_", + "POWER_MANAGEMENT_", + ] : !startswith(name, prefix) + ]) + ]) + error_message = "extra_env names must be valid environment names and must not override cloud-compose control-plane keys (HOME, PATH, or CLOUD_COMPOSE_/COMPOSE_/DOCKER_/SITECTL_/LIBOPS_/GCP_/VAULT_/ROLLOUT_/POWER_MANAGEMENT_ prefixes)." + } +} + variable "runcmd" { type = list(string) default = [] @@ -456,6 +661,15 @@ variable "frontend" { memory = optional(string, "1Gi") }) default = null + + validation { + condition = var.frontend == null ? true : ( + var.frontend.port >= 1 && + var.frontend.port <= 65535 && + floor(var.frontend.port) == var.frontend.port + ) + error_message = "frontend.port must be a whole number between 1 and 65535." + } } variable "rollout_enabled" { @@ -468,6 +682,11 @@ variable "rollout_release_url" { description = "HTTPS URL for the pinned rollout Linux binary." type = string default = "" + + validation { + condition = var.rollout_release_url == "" || can(regex("^https://[^[:space:]]+$", var.rollout_release_url)) + error_message = "rollout_release_url must be empty or an HTTPS URL." + } } variable "rollout_release_sha256" { @@ -485,8 +704,8 @@ variable "rollout_port" { type = number default = 8081 validation { - condition = var.rollout_port > 0 && var.rollout_port <= 65535 - error_message = "rollout_port must be between 1 and 65535." + condition = var.rollout_port >= 1 && var.rollout_port <= 65535 && floor(var.rollout_port) == var.rollout_port + error_message = "rollout_port must be a whole number between 1 and 65535." } } @@ -494,6 +713,11 @@ variable "rollout_jwks_uri" { description = "JWKS URI used by the rollout service to validate bearer JWTs." type = string default = "" + + validation { + condition = var.rollout_jwks_uri == "" || can(regex("^https://[^[:space:]]+$", var.rollout_jwks_uri)) + error_message = "rollout_jwks_uri must be empty or an HTTPS URL." + } } variable "rollout_jwt_audience" { @@ -506,12 +730,28 @@ variable "rollout_custom_claims" { description = "Optional JSON object of additional JWT claims required by the rollout service." type = string default = "" + + validation { + condition = ( + trimspace(var.rollout_custom_claims) == "" || + can(keys(jsondecode(var.rollout_custom_claims))) + ) + error_message = "rollout_custom_claims must be empty or a JSON object." + } } variable "rollout_allowed_ipv4" { description = "CIDR IPv4 ranges allowed to reach the rollout service port." type = list(string) default = ["10.0.0.0/8"] + + validation { + condition = alltrue([ + for cidr in var.rollout_allowed_ipv4 : + can(cidrhost(cidr, 0)) && length(regexall(":", cidr)) == 0 + ]) + error_message = "rollout_allowed_ipv4 entries must be valid IPv4 CIDR ranges." + } } variable "vault_addr" { @@ -533,7 +773,7 @@ variable "vault_role" { } variable "vault_agent_enabled" { - description = "Write Vault Agent configuration and start vault-agent.service when Vault is configured." + description = "Write Vault Agent configuration and start cloud-compose-vault-agent.service when Vault is configured." type = bool default = false } diff --git a/modules/linode/main.tf b/modules/linode/main.tf index ceeb582..2f9e50a 100644 --- a/modules/linode/main.tf +++ b/modules/linode/main.tf @@ -21,7 +21,7 @@ module "runtime" { region = local.linode.region data_device = "/dev/disk/by-id/scsi-0Linode_Volume_${local.data_volume_label}" volumes_device = "/dev/disk/by-id/scsi-0Linode_Volume_${local.docker_volumes_volume_label}" - ssh_users = local.linode.ssh.users + ssh_users = merge(local.runtime.users, local.linode.ssh.users) cloud_compose_ssh_keys = local.linode.ssh.cloud_compose_keys rootfs = local.runtime.rootfs rootfs_archive_url = local.runtime.rootfs_archive_url @@ -37,14 +37,15 @@ module "runtime" { docker_compose_down = local.compose.down docker_compose_rollout = local.compose.rollout - sitectl_packages = local.sitectl.packages - sitectl_version = local.sitectl.version - sitectl_context_name = local.sitectl.context_name - sitectl_plugin = local.sitectl.plugin - sitectl_environment = local.sitectl.environment - sitectl_verify_args = local.sitectl.verify_args - docker_compose_version = local.docker.compose_version - docker_buildx_version = local.docker.buildx_version + sitectl_packages = local.sitectl.packages + sitectl_version = local.sitectl.version + sitectl_package_versions = local.sitectl.package_versions + sitectl_context_name = local.sitectl.context_name + sitectl_plugin = local.sitectl.plugin + sitectl_environment = local.sitectl.environment + sitectl_verify_args = local.sitectl.verify_args + docker_compose_version = local.docker.compose_version + docker_buildx_version = local.docker.buildx_version libops_managed_runtime_enabled = local.managed.enabled libops_internal_services_enabled = local.managed.internal_services_enabled @@ -104,7 +105,7 @@ resource "linode_instance" "cloud_compose" { } precondition { condition = length(base64gzip(module.runtime.cloud_init)) <= 16384 - error_message = "Linode metadata.user_data is limited to 16 KiB. Set runtime.rootfs_archive_url so cloud-compose rootfs is fetched during boot instead of embedded in cloud-init." + error_message = "Linode metadata.user_data is limited to 16 KiB after gzip and base64 encoding. Use a verified runtime.rootfs_archive_url for the managed rootfs and reduce unusually large SSH or runtime inputs." } precondition { condition = !local.vault.agent_enabled || trimspace(local.vault.addr) != "" @@ -114,6 +115,10 @@ resource "linode_instance" "cloud_compose" { condition = !local.vault.agent_enabled || local.vault.auth_method != "consumer-managed" || trimspace(local.vault.agent_additional_config) != "" error_message = "vault_agent_additional_config is required when vault_agent_enabled uses consumer-managed auth." } + precondition { + condition = !local.managed.internal_services_enabled + error_message = "Linode does not support the GCP-specific privileged internal-services stack. Leave runtime.managed_runtime.internal_services_enabled false." + } } } diff --git a/modules/linode/outputs.tf b/modules/linode/outputs.tf index 05f097b..23dab76 100644 --- a/modules/linode/outputs.tf +++ b/modules/linode/outputs.tf @@ -36,3 +36,8 @@ output "primary_compose_project" { value = module.runtime.primary_compose_project description = "Normalized primary compose project." } + +output "sitectl_package_versions" { + value = module.runtime.sitectl_package_versions + description = "Effective release selector for every installed sitectl package; values may be exact tags or latest." +} diff --git a/modules/linode/runtime_inputs.tftest.hcl b/modules/linode/runtime_inputs.tftest.hcl new file mode 100644 index 0000000..201fb71 --- /dev/null +++ b/modules/linode/runtime_inputs.tftest.hcl @@ -0,0 +1,227 @@ +mock_provider "linode" {} + +run "merges_provider_neutral_and_provider_specific_ssh_users" { + command = plan + + variables { + name = "contract-test" + linode = { + instance = { + authorized_keys = ["ssh-ed25519 AAAAINSTANCE"] + } + ssh = { + users = { + shared = ["ssh-ed25519 AAAAPROVIDER"] + provider-only = ["ssh-ed25519 AAAALINODE"] + } + } + } + runtime = { + rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz" + rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + users = { + shared = ["ssh-ed25519 AAAARUNTIME"] + runtime-only = ["ssh-ed25519 AAAANEUTRAL"] + } + compose = { + repo = "https://github.com/libops/wp.git" + } + } + } + + assert { + condition = ( + strcontains(module.runtime.cloud_init, "ssh-ed25519 AAAAPROVIDER") && + strcontains(module.runtime.cloud_init, "ssh-ed25519 AAAALINODE") && + strcontains(module.runtime.cloud_init, "ssh-ed25519 AAAANEUTRAL") && + !strcontains(module.runtime.cloud_init, "ssh-ed25519 AAAARUNTIME") + ) + error_message = "Provider-specific SSH users must override matching provider-neutral users while preserving distinct entries." + } +} + +run "rejects_multiline_authorized_key" { + command = plan + + variables { + name = "contract-test" + linode = { + instance = { + authorized_keys = [ + "ssh-ed25519 AAAATEST\nruncmd: [touch /tmp/cloud-compose-linode-key-injection]", + ] + } + } + runtime = { + rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz" + rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + compose = { + repo = "https://github.com/libops/wp.git" + } + } + } + + expect_failures = [var.linode] +} + +run "rejects_unsafe_authorized_username" { + command = plan + + variables { + name = "contract-test" + linode = { + instance = { + authorized_users = ["operator\nruncmd"] + } + } + runtime = { + rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz" + rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + compose = { + repo = "https://github.com/libops/wp.git" + } + } + } + + expect_failures = [var.linode] +} + +run "rejects_archive_without_checksum" { + command = plan + + variables { + name = "contract-test" + runtime = { + rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz" + compose = { + repo = "https://github.com/libops/wp.git" + } + } + } + + expect_failures = [var.runtime] +} + +run "exposes_independent_sitectl_package_versions" { + command = plan + + variables { + name = "contract-test" + linode = { + instance = { + authorized_keys = ["ssh-ed25519 AAAATEST"] + } + } + runtime = { + rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz" + rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + compose = { + repo = "https://github.com/libops/isle.git" + } + sitectl = { + packages = ["sitectl-drupal", "sitectl-isle"] + version = "v0.38.0" + package_versions = { + sitectl-drupal = "v0.11.0" + sitectl-isle = "v0.12.0" + } + } + } + } + + assert { + condition = output.sitectl_package_versions == { + sitectl = "v0.38.0" + sitectl-drupal = "v0.11.0" + sitectl-isle = "v0.12.0" + } + error_message = "The Linode module must expose the effective version selector for every package." + } +} + +run "rejects_reserved_extra_environment" { + command = plan + + variables { + name = "contract-test" + linode = { + instance = { + authorized_keys = ["ssh-ed25519 AAAATEST"] + } + } + runtime = { + rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz" + rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + compose = { + repo = "https://github.com/libops/wp.git" + } + extra_env = { + LIBOPS_INTERNAL_SERVICES_ENABLED = "true" + } + } + } + + expect_failures = [var.runtime] +} + +run "archive_bootstrap_fits_linode_metadata_limit" { + command = plan + + variables { + name = "cc-ln-wp-29209714811-a1b2c3" + linode = { + instance = { + authorized_keys = [ + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOuUgUvvcJyWVZkgLrBGGI9RfcNmQsw32QNftNS5/Iiv operator@example.org", + ] + } + ssh = { + cloud_compose_keys = [ + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOuUgUvvcJyWVZkgLrBGGI9RfcNmQsw32QNftNS5/Iiv operator@example.org", + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestOnlyCloudComposeKey smoke@example.org", + ] + } + } + runtime = { + rootfs_archive_url = "https://github.com/libops/cloud-compose/releases/download/1.0.0/cloud-compose-rootfs.tar.gz" + rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + compose = { + repo = "https://github.com/libops/wp.git" + branch = "main" + ingress_port = 80 + ingress = { + mode = "http" + trusted_ips = ["198.51.100.0/24", "2001:db8::/32"] + } + } + sitectl = { + packages = ["sitectl", "sitectl-wp"] + version = "v0.39.0" + package_versions = { + sitectl = "v0.39.0" + sitectl-wp = "v0.5.0" + } + plugin = "wp" + environment = "smoke" + } + managed_runtime = { + enabled = true + internal_services_enabled = false + internal_services_auto_update = false + } + } + } + + assert { + condition = length(base64gzip(module.runtime.cloud_init)) <= 16384 + error_message = "A realistic archive-backed Linode bootstrap must fit within the 16 KiB encoded metadata limit." + } + + assert { + condition = ( + !strcontains(module.runtime.cloud_init, filebase64("${path.module}/../../rootfs/home/cloud-compose/prepare-filesystem.sh")) && + !strcontains(module.runtime.cloud_init, filebase64("${path.module}/../../rootfs/home/cloud-compose/persist-filesystems.sh")) + ) + error_message = "The realistic Linode bootstrap must not regain embedded filesystem helper payloads." + } +} diff --git a/modules/linode/variables.tf b/modules/linode/variables.tf index fc5f62b..c316ac9 100644 --- a/modules/linode/variables.tf +++ b/modules/linode/variables.tf @@ -9,7 +9,7 @@ variable "name" { } variable "linode" { - description = "Linode infrastructure settings." + description = "Linode infrastructure settings. instance.backups_enabled covers only the instance disk; attached application and Docker Block Storage volumes require a separate offsite backup policy." type = object({ region = optional(string, "us-east") tags = optional(list(string), ["cloud-compose"]) @@ -44,6 +44,15 @@ variable "linode" { }), {}) }) default = {} + + validation { + condition = alltrue([ + for key in var.linode.instance.authorized_keys : trimspace(key) != "" && !can(regex("[\\r\\n]", key)) + ]) && alltrue([ + for username in var.linode.instance.authorized_users : can(regex("^[A-Za-z0-9._-]+$", username)) + ]) + error_message = "linode.instance authorized_keys must be non-empty single-line values and authorized_users must contain safe single-line usernames." + } } variable "runtime" { @@ -52,6 +61,7 @@ variable "runtime" { rootfs = optional(string, "") rootfs_archive_url = optional(string, "") rootfs_archive_sha256 = optional(string, "") + users = optional(map(list(string)), {}) compose = optional(object({ primary = optional(string, "") @@ -87,8 +97,8 @@ variable "runtime" { sitectl_context_name = optional(string) sitectl_plugin = optional(string) sitectl_environment = optional(string) - sitectl_packages = optional(list(string), []) - sitectl_verify_args = optional(list(string), []) + sitectl_packages = optional(list(string)) + sitectl_verify_args = optional(list(string)) docker_compose_init = optional(list(string)) docker_compose_up = optional(list(string)) docker_compose_down = optional(list(string)) @@ -101,12 +111,13 @@ variable "runtime" { }), {}) sitectl = optional(object({ - packages = optional(list(string), ["sitectl"]) - version = optional(string, "latest") - context_name = optional(string, "") - plugin = optional(string, "core") - environment = optional(string, "production") - verify_args = optional(list(string), []) + packages = optional(list(string), ["sitectl"]) + version = optional(string, "latest") + package_versions = optional(map(string), {}) + context_name = optional(string, "") + plugin = optional(string, "core") + environment = optional(string, "production") + verify_args = optional(list(string), []) }), {}) docker = optional(object({ @@ -152,19 +163,55 @@ variable "runtime" { }) default = {} + validation { + condition = ( + (trimspace(var.runtime.rootfs_archive_url) == "") == (trimspace(var.runtime.rootfs_archive_sha256) == "") && + (trimspace(var.runtime.rootfs_archive_sha256) == "" || can(regex("^[0-9a-fA-F]{64}$", trimspace(var.runtime.rootfs_archive_sha256)))) + ) + error_message = "runtime.rootfs_archive_url and a 64-character runtime.rootfs_archive_sha256 must be supplied together." + } + validation { condition = alltrue([ - for name, app in var.runtime.compose.projects : - can(regex("^[a-z][a-z0-9-]*$", name)) && - trimspace(app.docker_compose_repo) != "" && - try(app.ingress_port, var.runtime.compose.ingress_port) > 0 && - try(app.ingress_port, var.runtime.compose.ingress_port) <= 65535 + for name in keys(var.runtime.extra_env) : + can(regex("^[A-Za-z_][A-Za-z0-9_]*$", name)) && + !contains(["HOME", "PATH"], name) && + alltrue([ + for prefix in [ + "CLOUD_COMPOSE_", + "COMPOSE_", + "DOCKER_", + "SITECTL_", + "LIBOPS_", + "GCP_", + "VAULT_", + "ROLLOUT_", + "POWER_MANAGEMENT_", + ] : !startswith(name, prefix) + ]) ]) - error_message = "runtime.compose.projects keys must match ^[a-z][a-z0-9-]*$, docker_compose_repo is required, and ingress_port must be between 1 and 65535." + error_message = "runtime.extra_env names must be valid environment names and must not override cloud-compose control-plane keys (HOME, PATH, or CLOUD_COMPOSE_/COMPOSE_/DOCKER_/SITECTL_/LIBOPS_/GCP_/VAULT_/ROLLOUT_/POWER_MANAGEMENT_ prefixes)." + } + + validation { + condition = ( + var.runtime.compose.ingress_port >= 1 && + var.runtime.compose.ingress_port <= 65535 && + floor(var.runtime.compose.ingress_port) == var.runtime.compose.ingress_port && + alltrue([ + for name, app in var.runtime.compose.projects : + can(regex("^[a-z][a-z0-9-]*$", name)) && + trimspace(app.docker_compose_repo) != "" && + coalesce(try(app.ingress_port, null), var.runtime.compose.ingress_port) >= 1 && + coalesce(try(app.ingress_port, null), var.runtime.compose.ingress_port) <= 65535 && + floor(coalesce(try(app.ingress_port, null), var.runtime.compose.ingress_port)) == coalesce(try(app.ingress_port, null), var.runtime.compose.ingress_port) + ]) + ) + error_message = "runtime.compose.projects keys must match ^[a-z][a-z0-9-]*$, docker_compose_repo is required, and ingress ports must be whole numbers between 1 and 65535." } validation { - condition = contains(["gcp-iam", "consumer-managed"], var.runtime.vault.auth_method) - error_message = "runtime.vault.auth_method must be gcp-iam or consumer-managed." + condition = var.runtime.vault.auth_method == "consumer-managed" + error_message = "runtime.vault.auth_method must be consumer-managed on Linode." } } diff --git a/modules/linode/versions.tf b/modules/linode/versions.tf index 77ed60a..baff60d 100644 --- a/modules/linode/versions.tf +++ b/modules/linode/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.2.4" + required_version = ">= 1.3.0" required_providers { linode = { diff --git a/modules/linux-vm-runtime/extra_env.tftest.hcl b/modules/linux-vm-runtime/extra_env.tftest.hcl new file mode 100644 index 0000000..5dff893 --- /dev/null +++ b/modules/linux-vm-runtime/extra_env.tftest.hcl @@ -0,0 +1,68 @@ +run "allows_application_tuning_environment" { + command = plan + + variables { + name = "extra-env-contract" + provider_name = "linode" + region = "us-east" + data_device = "/dev/test-data" + volumes_device = "/dev/test-volumes" + docker_compose_repo = "https://github.com/libops/wp.git" + extra_env = { + NGINX_CLIENT_MAX_BODY_SIZE = "512m" + PHP_UPLOAD_MAX_FILESIZE = "512M" + BASH_ENV = "/tmp/application-only-bash-env" + LD_PRELOAD = "/tmp/application-only-preload.so" + } + } + + assert { + condition = ( + !strcontains(module.runtime_env.content, "NGINX_CLIENT_MAX_BODY_SIZE") && + !strcontains(module.runtime_env.content, "PHP_UPLOAD_MAX_FILESIZE") && + !contains(keys(local.host_env), "BASH_ENV") && + !contains(keys(local.host_env), "LD_PRELOAD") && + strcontains( + local.application_env_file_content, + jsonencode(base64gzip(jsonencode(var.extra_env))), + ) + ) + error_message = "Application tuning must be serialized as data without entering the host process environment." + } +} + +run "rejects_reserved_environment_prefix" { + command = plan + + variables { + name = "extra-env-contract" + provider_name = "linode" + region = "us-east" + data_device = "/dev/test-data" + volumes_device = "/dev/test-volumes" + docker_compose_repo = "https://github.com/libops/wp.git" + extra_env = { + SITECTL_PACKAGE_VERSIONS = "{}" + } + } + + expect_failures = [var.extra_env] +} + +run "rejects_reserved_environment_name" { + command = plan + + variables { + name = "extra-env-contract" + provider_name = "linode" + region = "us-east" + data_device = "/dev/test-data" + volumes_device = "/dev/test-volumes" + docker_compose_repo = "https://github.com/libops/wp.git" + extra_env = { + PATH = "/tmp/untrusted" + } + } + + expect_failures = [var.extra_env] +} diff --git a/modules/linux-vm-runtime/main.tf b/modules/linux-vm-runtime/main.tf index e7776d8..0091d46 100644 --- a/modules/linux-vm-runtime/main.tf +++ b/modules/linux-vm-runtime/main.tf @@ -2,12 +2,14 @@ locals { rootfs = "${path.module}/../../rootfs" additional_rootfs = var.rootfs != "" ? var.rootfs : "" rootfs_archive_url = trimspace(var.rootfs_archive_url) - rootfs_archive_sha256 = trimspace(var.rootfs_archive_sha256) + rootfs_archive_sha256 = lower(trimspace(var.rootfs_archive_sha256)) single_compose_project = { (var.name) = { docker_compose_repo = var.docker_compose_repo docker_compose_branch = var.docker_compose_branch + project_dir = null + compose_project_name = null ingress_port = var.ingress_port ingress = var.sitectl_ingress sitectl_context_name = trimspace(var.sitectl_context_name) != "" ? trimspace(var.sitectl_context_name) : var.name @@ -21,7 +23,7 @@ locals { docker_compose_rollout = var.docker_compose_rollout } } - raw_compose_projects = length(var.compose_projects) > 0 ? var.compose_projects : local.single_compose_project + raw_compose_projects = length(var.compose_projects) > 0 ? var.compose_projects : tomap(local.single_compose_project) primary_compose_project_key = trimspace(var.primary_compose_project) != "" ? trimspace(var.primary_compose_project) : keys(local.raw_compose_projects)[0] compose_projects = { for app_name, app in local.raw_compose_projects : app_name => { @@ -30,13 +32,13 @@ locals { docker_compose_branch = trimspace(coalesce(try(app.docker_compose_branch, null), var.docker_compose_branch)) repo_path = trim(replace(trimspace(app.docker_compose_repo), "/^[^:]+://[^/]+/", ""), "/") project_dir = ( - trimspace(try(app.project_dir, "")) != "" - ? trimspace(try(app.project_dir, "")) + try(trimspace(app.project_dir), "") != "" + ? try(trimspace(app.project_dir), "") : "/mnt/disks/data/${trim(replace(trimspace(app.docker_compose_repo), "/^[^:]+://[^/]+/", ""), "/")}/${trimspace(coalesce(try(app.docker_compose_branch, null), var.docker_compose_branch))}" ) compose_project_name = ( - trimspace(try(app.compose_project_name, "")) != "" - ? trimspace(try(app.compose_project_name, "")) + try(trimspace(app.compose_project_name), "") != "" + ? try(trimspace(app.compose_project_name), "") : replace(lower(replace(replace(format("%s-%s", trim(replace(trimspace(app.docker_compose_repo), "/^[^:]+://[^/]+/", ""), "/"), trimspace(coalesce(try(app.docker_compose_branch, null), var.docker_compose_branch))), ".git", ""), "/[^a-zA-Z0-9]/", "-")), "/-+/", "-") ) ingress_port = coalesce(try(app.ingress_port, null), var.ingress_port) @@ -51,37 +53,55 @@ locals { upload_timeout = try(app.ingress.upload_timeout, null) != null ? trimspace(app.ingress.upload_timeout) : trimspace(var.sitectl_ingress.upload_timeout) } sitectl_context_name = ( - trimspace(try(app.sitectl_context_name, "")) != "" - ? trimspace(try(app.sitectl_context_name, "")) + trimspace(app.sitectl_context_name != null ? app.sitectl_context_name : "") != "" + ? trimspace(app.sitectl_context_name) : app_name ) sitectl_plugin = trimspace(coalesce(try(app.sitectl_plugin, null), var.sitectl_plugin)) sitectl_environment = trimspace(coalesce(try(app.sitectl_environment, null), var.sitectl_environment)) - sitectl_packages = distinct(concat(["sitectl"], try(app.sitectl_packages, []))) - sitectl_verify_args = try(app.sitectl_verify_args, var.sitectl_verify_args) + sitectl_packages = distinct(concat( + ["sitectl"], + coalesce(try(app.sitectl_packages, null), var.sitectl_packages), + )) + sitectl_verify_args = coalesce(try(app.sitectl_verify_args, null), var.sitectl_verify_args) init_commands = try(app.docker_compose_init, null) != null ? app.docker_compose_init : var.docker_compose_init up_commands = try(app.docker_compose_up, null) != null ? app.docker_compose_up : var.docker_compose_up down_commands = try(app.docker_compose_down, null) != null ? app.docker_compose_down : var.docker_compose_down rollout_commands = try(app.docker_compose_rollout, null) != null ? app.docker_compose_rollout : var.docker_compose_rollout } } - primary_compose_project = local.compose_projects[local.primary_compose_project_key] + validated_compose_projects = { + for app_name, app in local.compose_projects : app_name => merge(app, { + project_dir = module.project_directories.project_dirs[app_name] + }) + } + primary_compose_project = local.validated_compose_projects[local.primary_compose_project_key] sitectl_packages = distinct(concat( ["sitectl"], var.sitectl_packages, flatten([for _, app in local.compose_projects : app.sitectl_packages]) )) - base_files = local.rootfs_archive_url == "" ? fileset(local.rootfs, "**") : [] - additional_files = local.additional_rootfs != "" ? fileset(local.additional_rootfs, "**") : [] + base_files = local.rootfs_archive_url == "" ? fileset(local.rootfs, "**") : [] + additional_files = local.additional_rootfs != "" ? fileset(local.additional_rootfs, "**") : [] + embedded_additional_files = local.rootfs_archive_url == "" ? local.additional_files : [] all_files = merge( { for file in local.base_files : file => "${local.rootfs}/${file}" }, - { for file in local.additional_files : file => "${local.additional_rootfs}/${file}" } + { for file in local.embedded_additional_files : file => "${local.additional_rootfs}/${file}" } ) + archive_additional_rootfs_commands = join("\n", [ + for file in local.additional_files : <<-EOT + destination="$(printf '%s' '${base64encode("/${file}")}' | base64 -d)" + install -d "$(dirname "$destination")" + printf '%s' '${filebase64("${local.additional_rootfs}/${file}")}' | base64 -d >"$destination" + chmod ${endswith(file, ".sh") ? "0755" : "0644"} "$destination" + EOT + ]) + write_files_content = join("\n", [ for file, fullpath in local.all_files : <<-EOT - - path: ${jsonencode("/${file}")} + - path: ${jsonencode(startswith(file, "mnt/disks/") ? "/var/lib/cloud-compose/mounted-rootfs/${file}" : "/${file}")} permissions: ${jsonencode(endswith(file, ".sh") ? "0755" : "0644")} encoding: gzip+base64 content: ${jsonencode(base64gzip(file(fullpath)))} @@ -105,7 +125,7 @@ EOT EOT ]) -compose_projects_content = jsonencode(local.compose_projects) +compose_projects_content = jsonencode(local.validated_compose_projects) compose_projects_file = <<-EOT - path: "/home/cloud-compose/compose-projects.json" permissions: "0640" @@ -114,7 +134,7 @@ compose_projects_file = <<-EOT EOT managed_runtime_artifact_lines = [ - for artifact in var.libops_managed_artifacts : join("\t", [ + for artifact in module.managed_artifacts.artifacts : join("\t", [ artifact.name, artifact.url, artifact.sha256, @@ -147,25 +167,7 @@ vault_agent_template_stanzas = join("\n", [ } EOT ]) -vault_agent_auto_auth_gcp = <<-EOT - auto_auth { - method "gcp" { - mount_path = ${jsonencode(var.vault_gcp_auth_mount_path)} - config = { - type = "iam" - role = ${jsonencode(trimspace(var.vault_role))} - jwt_exp = 15 - } - } - - sink "file" { - config = { - path = ${jsonencode(var.vault_agent_token_path)} - } - } - } - EOT -vault_agent_auto_auth = var.vault_auth_method == "gcp-iam" ? local.vault_agent_auto_auth_gcp : trimspace(var.vault_agent_additional_config) +vault_agent_auto_auth = trimspace(var.vault_agent_additional_config) vault_agent_env_content = <<-EOT VAULT_ADDR=${trimspace(var.vault_addr)} VAULT_NAMESPACE=${trimspace(var.vault_namespace)} @@ -196,7 +198,7 @@ vault_agent_files_raw = <<-EOT EOT vault_agent_files = var.vault_agent_enabled && trimspace(var.vault_addr) != "" ? local.vault_agent_files_raw : "" -env = merge({ +host_env = { HOME = "/home/cloud-compose" CLOUD_COMPOSE_PROVIDER = var.provider_name CLOUD_COMPOSE_INSTANCE_NAME = var.name @@ -216,8 +218,10 @@ env = merge({ GCP_REGION = var.region GCP_ZONE = var.zone != "" ? var.zone : var.region GCP_APP_SERVICE_ACCOUNT_EMAIL = "" - SITECTL_PACKAGES = join(" ", local.sitectl_packages) + GCP_APP_CREDENTIALS_ENABLED = "false" + SITECTL_PACKAGES = join(" ", module.sitectl_runtime.packages) SITECTL_VERSION = var.sitectl_version + SITECTL_PACKAGE_VERSIONS = jsonencode(module.sitectl_runtime.package_versions) SITECTL_CONTEXT_NAME = local.primary_compose_project.sitectl_context_name SITECTL_PLUGIN = local.primary_compose_project.sitectl_plugin SITECTL_ENVIRONMENT = local.primary_compose_project.sitectl_environment @@ -234,50 +238,85 @@ env = merge({ LIBOPS_INTERNAL_SERVICES_ENABLED = tostring(var.libops_internal_services_enabled) LIBOPS_INTERNAL_SERVICES_AUTO_UPDATE = tostring(var.libops_internal_services_auto_update) INTERNAL_SERVICES_COMPOSE_PROFILES = "" -}, var.extra_env) +} -env_file_plain = join("\n", [for key, value in local.env : "${key}=${jsonencode(tostring(value))}"]) env_file_content = <<-EOT - path: "/home/cloud-compose/.env" permissions: "0640" encoding: gzip+base64 - content: ${jsonencode(base64gzip(local.env_file_plain))} + content: ${jsonencode(base64gzip(module.runtime_env.content))} EOT -rootfs_archive_command_raw = <<-EOT - - | - set -eu - archive_url=${jsonencode(local.rootfs_archive_url)} - archive_sha256=${jsonencode(local.rootfs_archive_sha256)} - if ! command -v curl >/dev/null 2>&1; then - if command -v apt-get >/dev/null 2>&1; then - export DEBIAN_FRONTEND=noninteractive - apt-get update - apt-get install -y ca-certificates curl tar - elif command -v dnf >/dev/null 2>&1; then - dnf install -y ca-certificates curl tar - elif command -v rpm-ostree >/dev/null 2>&1; then - rpm-ostree install --apply-live ca-certificates curl tar - else - echo "No supported package manager found to install curl" >&2 - exit 1 - fi - fi - tmp="$(mktemp -d)" - trap 'rm -rf "$tmp"' EXIT - curl -fsSL "$archive_url" -o "$tmp/rootfs.tar.gz" - if [ -n "$archive_sha256" ]; then - printf '%s %s\n' "$archive_sha256" "$tmp/rootfs.tar.gz" | sha256sum -c - +application_env_file_content = <<-EOT + - path: "/home/cloud-compose/application-env.json" + permissions: "0640" + encoding: gzip+base64 + content: ${jsonencode(base64gzip(jsonencode(var.extra_env)))} + EOT + +rootfs_archive_prepare_command_raw = <<-EOT + archive_url_b64='${base64encode(local.rootfs_archive_url)}' + archive_url="$(printf '%s' "$archive_url_b64" | base64 -d)" + archive_sha256='${local.rootfs_archive_sha256}' + case "$archive_url" in + https://*) ;; + *) echo "rootfs archive URL must use HTTPS" >&2; exit 1 ;; + esac + case "$archive_url" in + *[[:space:]]*) echo "rootfs archive URL must not contain whitespace" >&2; exit 1 ;; + esac + if ! command -v curl >/dev/null 2>&1 || ! command -v tar >/dev/null 2>&1; then + if command -v apt-get >/dev/null 2>&1; then + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y ca-certificates curl tar + elif command -v dnf >/dev/null 2>&1; then + dnf install -y ca-certificates curl tar + elif command -v rpm-ostree >/dev/null 2>&1; then + rpm-ostree install --apply-live ca-certificates curl tar + else + echo "No supported package manager found to install curl and tar" >&2 + exit 1 fi - tar -xzf "$tmp/rootfs.tar.gz" -C "$tmp" - rootfs_dir="$(find "$tmp" -mindepth 2 -maxdepth 2 -type d -name rootfs | head -n 1)" - if [ -z "$rootfs_dir" ]; then - echo "rootfs directory not found in $archive_url" >&2 + fi + for required_command in curl tar sha256sum; do + if ! command -v "$required_command" >/dev/null 2>&1; then + echo "$required_command is required to install the verified rootfs archive" >&2 exit 1 fi - cp -a "$rootfs_dir"/. / + done + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 \ + --retry 5 --retry-all-errors --retry-delay 2 --retry-max-time 900 \ + --connect-timeout 10 --max-time 300 -o "$tmp/rootfs.tar.gz" -- "$archive_url" + printf '%s %s\n' "$archive_sha256" "$tmp/rootfs.tar.gz" | sha256sum -c - + tar -xzf "$tmp/rootfs.tar.gz" -C "$tmp" + rootfs_dir="$(find "$tmp" -mindepth 1 -maxdepth 3 -type d -name rootfs -print -quit)" + if [ -z "$rootfs_dir" ] || [ ! -d "$rootfs_dir" ]; then + echo "rootfs directory not found in verified archive $archive_url" >&2 + exit 1 + fi + filesystem_prep_source="$rootfs_dir/home/cloud-compose/prepare-filesystem.sh" + filesystem_persist_source="$rootfs_dir/home/cloud-compose/persist-filesystems.sh" + if [ ! -f "$filesystem_prep_source" ] || [ ! -f "$filesystem_persist_source" ]; then + echo "verified rootfs archive is missing filesystem preparation scripts" >&2 + exit 1 + fi + install -m 0600 -- "$filesystem_prep_source" "$filesystem_prep" + install -m 0600 -- "$filesystem_persist_source" "$filesystem_persist" EOT -rootfs_archive_command = local.rootfs_archive_url != "" ? local.rootfs_archive_command_raw : "" + +rootfs_archive_install_command_raw = <<-EOT + if [ -z "$${rootfs_dir:-}" ] || [ ! -d "$rootfs_dir" ]; then + echo "verified rootfs directory is unavailable during installation" >&2 + exit 1 + fi + cp -a "$rootfs_dir"/. / + EOT + +rootfs_archive_prepare_command = local.rootfs_archive_url != "" ? local.rootfs_archive_prepare_command_raw : "" +rootfs_archive_install_command = local.rootfs_archive_url != "" ? local.rootfs_archive_install_command_raw : "" cloud_init = templatefile("${path.module}/templates/cloud-init.yml", { CLOUD_COMPOSE_SSH_KEYS = var.cloud_compose_ssh_keys @@ -288,8 +327,40 @@ cloud_init = templatefile("${path.module}/templates/cloud-init.yml", { DOCKER_COMPOSE_SCRIPTS = local.docker_compose_scripts COMPOSE_PROJECTS_FILE = local.compose_projects_file ENV_FILE_CONTENT = local.env_file_content + APPLICATION_ENV_FILE_CONTENT = local.application_env_file_content VAULT_AGENT_FILES = local.vault_agent_files MANAGED_RUNTIME_ARTIFACTS_FILE = local.managed_runtime_artifacts_file - ROOTFS_ARCHIVE_COMMAND = local.rootfs_archive_command + ROOTFS_ARCHIVE_ENABLED = local.rootfs_archive_url != "" + ROOTFS_ARCHIVE_PREPARE_COMMAND = local.rootfs_archive_prepare_command + ROOTFS_ARCHIVE_INSTALL_COMMAND = local.rootfs_archive_install_command + ARCHIVE_ADDITIONAL_ROOTFS = local.archive_additional_rootfs_commands + FILESYSTEM_PREP_SCRIPT_B64 = local.rootfs_archive_url == "" ? filebase64("${local.rootfs}/home/cloud-compose/prepare-filesystem.sh") : "" + FILESYSTEM_PERSIST_SCRIPT_B64 = local.rootfs_archive_url == "" ? filebase64("${local.rootfs}/home/cloud-compose/persist-filesystems.sh") : "" }) } + +module "sitectl_runtime" { + source = "../sitectl-runtime" + + packages = local.sitectl_packages + fallback_version = var.sitectl_version + package_versions = var.sitectl_package_versions +} + +module "managed_artifacts" { + source = "../managed-artifacts" + + artifacts = var.libops_managed_artifacts +} + +module "project_directories" { + source = "../project-directories" + + project_dirs = { for app_name, app in local.compose_projects : app_name => app.project_dir } +} + +module "runtime_env" { + source = "../runtime-env" + + env = local.host_env +} diff --git a/modules/linux-vm-runtime/outputs.tf b/modules/linux-vm-runtime/outputs.tf index c1358b8..d021d89 100644 --- a/modules/linux-vm-runtime/outputs.tf +++ b/modules/linux-vm-runtime/outputs.tf @@ -1,10 +1,18 @@ output "cloud_init" { value = local.cloud_init description = "Rendered cloud-init user data." + + precondition { + condition = ( + (local.rootfs_archive_url == "") == (local.rootfs_archive_sha256 == "") && + (local.rootfs_archive_sha256 == "" || can(regex("^[0-9a-f]{64}$", local.rootfs_archive_sha256))) + ) + error_message = "rootfs_archive_url and a 64-character rootfs_archive_sha256 must be supplied together." + } } output "compose_projects" { - value = local.compose_projects + value = local.validated_compose_projects description = "Normalized compose project manifest." } @@ -19,6 +27,11 @@ output "primary_compose_project_key" { } output "sitectl_packages" { - value = local.sitectl_packages + value = module.sitectl_runtime.packages description = "Normalized sitectl package list." } + +output "sitectl_package_versions" { + value = module.sitectl_runtime.package_versions + description = "Effective release selector for every installed sitectl package; values may be exact tags or latest." +} diff --git a/modules/linux-vm-runtime/runtime_inputs.tftest.hcl b/modules/linux-vm-runtime/runtime_inputs.tftest.hcl new file mode 100644 index 0000000..a66ba28 --- /dev/null +++ b/modules/linux-vm-runtime/runtime_inputs.tftest.hcl @@ -0,0 +1,316 @@ +run "renders_safe_ssh_values" { + command = plan + + variables { + name = "contract-test" + provider_name = "linode" + region = "us-east" + data_device = "/dev/test-data" + volumes_device = "/dev/test-volumes" + docker_compose_repo = "https://github.com/libops/wp.git" + cloud_compose_ssh_keys = [ + "ssh-ed25519 AAAATEST literal-$(not-a-command)", + ] + ssh_users = { + app_operator = ["ssh-ed25519 AAAAOPERATOR operator@example.org"] + } + } + + assert { + condition = strcontains(output.cloud_init, jsonencode("ssh-ed25519 AAAATEST literal-$(not-a-command)")) + error_message = "SSH keys must be YAML encoded as literal values." + } + + assert { + condition = strcontains(output.cloud_init, "name: ${jsonencode("app_operator")}") + error_message = "SSH usernames must be YAML encoded as literal values." + } +} + +run "normalizes_minimal_compose_project" { + command = plan + + variables { + name = "contract-test" + provider_name = "linode" + region = "us-east" + data_device = "/dev/test-data" + volumes_device = "/dev/test-volumes" + compose_projects = { + wordpress = { + docker_compose_repo = "https://github.com/libops/wp.git" + } + } + sitectl_verify_args = ["--route", "/"] + } + + assert { + condition = ( + local.compose_projects.wordpress.name == "wordpress" && + local.compose_projects.wordpress.docker_compose_repo == "https://github.com/libops/wp.git" && + local.compose_projects.wordpress.docker_compose_branch == "main" && + local.compose_projects.wordpress.repo_path == "libops/wp.git" && + local.compose_projects.wordpress.project_dir == "/mnt/disks/data/libops/wp.git/main" && + local.compose_projects.wordpress.compose_project_name == "libops-wp-main" && + local.compose_projects.wordpress.ingress_port == 80 && + local.compose_projects.wordpress.ingress.letsencrypt == var.sitectl_ingress.letsencrypt && + local.compose_projects.wordpress.sitectl_context_name == "wordpress" && + local.compose_projects.wordpress.sitectl_plugin == "core" && + local.compose_projects.wordpress.sitectl_environment == "production" && + local.compose_projects.wordpress.sitectl_packages == tolist(["sitectl"]) && + local.compose_projects.wordpress.sitectl_verify_args == tolist(["--route", "/"]) && + local.compose_projects.wordpress.init_commands == var.docker_compose_init && + local.compose_projects.wordpress.up_commands == var.docker_compose_up && + local.compose_projects.wordpress.down_commands == var.docker_compose_down && + local.compose_projects.wordpress.rollout_commands == var.docker_compose_rollout + ) + error_message = "A compose project containing only docker_compose_repo must inherit and derive every optional field." + } +} + +run "distinguishes_inherited_and_explicit_core_only_project_packages" { + command = plan + + variables { + name = "contract-test" + provider_name = "linode" + region = "us-east" + data_device = "/dev/test-data" + volumes_device = "/dev/test-volumes" + sitectl_packages = [ + "sitectl-drupal", + "sitectl-isle", + ] + compose_projects = { + inherited = { + docker_compose_repo = "https://github.com/libops/isle.git" + } + core-only = { + docker_compose_repo = "https://github.com/libops/wp.git" + sitectl_packages = [] + } + } + } + + assert { + condition = ( + local.compose_projects.inherited.sitectl_packages == tolist(["sitectl", "sitectl-drupal", "sitectl-isle"]) && + local.compose_projects["core-only"].sitectl_packages == tolist(["sitectl"]) && + module.sitectl_runtime.packages == tolist(["sitectl", "sitectl-drupal", "sitectl-isle"]) + ) + error_message = "Project manifests must distinguish inherited packages from an explicit list while the host package set remains their union." + } +} + +run "resolves_independent_sitectl_package_versions" { + command = plan + + variables { + name = "contract-test" + provider_name = "linode" + region = "us-east" + data_device = "/dev/test-data" + volumes_device = "/dev/test-volumes" + docker_compose_repo = "https://github.com/libops/isle.git" + sitectl_packages = ["sitectl-drupal", "sitectl-isle"] + sitectl_version = "v0.38.0" + sitectl_package_versions = { + sitectl-drupal = "v0.11.0" + sitectl-isle = "v0.12.0" + } + } + + assert { + condition = output.sitectl_package_versions == { + sitectl = "v0.38.0" + sitectl-drupal = "v0.11.0" + sitectl-isle = "v0.12.0" + } + error_message = "The Linux VM runtime must preserve independent package versions." + } +} + +run "rejects_multiline_cloud_compose_key" { + command = plan + + variables { + name = "contract-test" + provider_name = "linode" + region = "us-east" + data_device = "/dev/test-data" + volumes_device = "/dev/test-volumes" + docker_compose_repo = "https://github.com/libops/wp.git" + cloud_compose_ssh_keys = [ + "ssh-ed25519 AAAATEST\nruncmd: [touch /tmp/cloud-compose-ssh-injection]", + ] + } + + expect_failures = [var.cloud_compose_ssh_keys] +} + +run "rejects_unsafe_ssh_username" { + command = plan + + variables { + name = "contract-test" + provider_name = "linode" + region = "us-east" + data_device = "/dev/test-data" + volumes_device = "/dev/test-volumes" + docker_compose_repo = "https://github.com/libops/wp.git" + ssh_users = { + "operator\nruncmd" = ["ssh-ed25519 AAAAOPERATOR"] + } + } + + expect_failures = [var.ssh_users] +} + +run "renders_verified_rootfs_archive" { + command = plan + + variables { + name = "contract-test" + provider_name = "linode" + region = "us-east" + data_device = "/dev/test-data" + volumes_device = "/dev/test-volumes" + docker_compose_repo = "https://github.com/libops/wp.git" + rootfs_archive_url = "https://example.invalid/cloud-compose-$(id).tar.gz" + rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + + assert { + condition = strcontains(output.cloud_init, "sha256sum -c -") + error_message = "Archive cloud-init must verify SHA-256 before extraction." + } + + assert { + condition = ( + strcontains(output.cloud_init, "tar -xzf \"$tmp/rootfs.tar.gz\"") && + strcontains(output.cloud_init, "install -m 0600 -- \"$filesystem_prep_source\" \"$filesystem_prep\"") && + strcontains(output.cloud_init, "bash \"$filesystem_prep\"") && + strcontains(output.cloud_init, "bash \"$filesystem_persist\"") && + strcontains(output.cloud_init, "cp -a \"$rootfs_dir\"/. /") && + length(split("sha256sum -c -", output.cloud_init)[0]) < length(split("tar -xzf \"$tmp/rootfs.tar.gz\"", output.cloud_init)[0]) && + length(split("tar -xzf \"$tmp/rootfs.tar.gz\"", output.cloud_init)[0]) < length(split("install -m 0600 -- \"$filesystem_prep_source\" \"$filesystem_prep\"", output.cloud_init)[0]) && + length(split("install -m 0600 -- \"$filesystem_prep_source\" \"$filesystem_prep\"", output.cloud_init)[0]) < length(split("bash \"$filesystem_prep\"", output.cloud_init)[0]) && + length(split("bash \"$filesystem_persist\"", output.cloud_init)[0]) < length(split("cp -a \"$rootfs_dir\"/. /", output.cloud_init)[0]) + ) + error_message = "Archive cloud-init must verify and extract before loading its helpers, then install the rootfs only after filesystem preparation and persistence." + } + + assert { + condition = ( + !strcontains(output.cloud_init, filebase64("${path.module}/../../rootfs/home/cloud-compose/prepare-filesystem.sh")) && + !strcontains(output.cloud_init, filebase64("${path.module}/../../rootfs/home/cloud-compose/persist-filesystems.sh")) + ) + error_message = "Archive-backed cloud-init must not embed the filesystem helper payloads." + } + + assert { + condition = ( + strcontains(output.cloud_init, "verified rootfs archive is missing filesystem preparation scripts") && + strcontains(output.cloud_init, "verified rootfs directory is unavailable during installation") + ) + error_message = "Archive-backed cloud-init must fail closed when the verified rootfs or its filesystem helpers are missing." + } + + assert { + condition = ( + strcontains(output.cloud_init, "archive_url_b64=") && + !strcontains(output.cloud_init, "$(id)") + ) + error_message = "Archive URLs must be rendered as base64 data rather than executable shell syntax." + } +} + +run "embeds_filesystem_helpers_without_archive" { + command = plan + + variables { + name = "contract-test" + provider_name = "digitalocean" + region = "nyc3" + data_device = "/dev/test-data" + volumes_device = "/dev/test-volumes" + docker_compose_repo = "https://github.com/libops/wp.git" + } + + assert { + condition = ( + strcontains(output.cloud_init, filebase64("${path.module}/../../rootfs/home/cloud-compose/prepare-filesystem.sh")) && + strcontains(output.cloud_init, filebase64("${path.module}/../../rootfs/home/cloud-compose/persist-filesystems.sh")) && + !strcontains(output.cloud_init, "archive_url_b64=") + ) + error_message = "Inline cloud-init must retain the embedded filesystem-helper bootstrap when no archive is configured." + } +} + +run "rejects_rootfs_archive_without_checksum" { + command = plan + + variables { + name = "contract-test" + provider_name = "linode" + region = "us-east" + data_device = "/dev/test-data" + volumes_device = "/dev/test-volumes" + docker_compose_repo = "https://github.com/libops/wp.git" + rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz" + rootfs_archive_sha256 = "" + } + + expect_failures = [output.cloud_init] +} + +run "rejects_non_https_rootfs_archive" { + command = plan + + variables { + name = "contract-test" + provider_name = "linode" + region = "us-east" + data_device = "/dev/test-data" + volumes_device = "/dev/test-volumes" + docker_compose_repo = "https://github.com/libops/wp.git" + rootfs_archive_url = "http://example.invalid/cloud-compose.tar.gz" + rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + + expect_failures = [var.rootfs_archive_url] +} + +run "rejects_rootfs_checksum_without_archive" { + command = plan + + variables { + name = "contract-test" + provider_name = "linode" + region = "us-east" + data_device = "/dev/test-data" + volumes_device = "/dev/test-volumes" + docker_compose_repo = "https://github.com/libops/wp.git" + rootfs_archive_url = "" + rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + + expect_failures = [output.cloud_init] +} + +run "rejects_malformed_rootfs_checksum" { + command = plan + + variables { + name = "contract-test" + provider_name = "linode" + region = "us-east" + data_device = "/dev/test-data" + volumes_device = "/dev/test-volumes" + docker_compose_repo = "https://github.com/libops/wp.git" + rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz" + rootfs_archive_sha256 = "not-a-sha256" + } + + expect_failures = [output.cloud_init] +} diff --git a/modules/linux-vm-runtime/templates/cloud-init.yml b/modules/linux-vm-runtime/templates/cloud-init.yml index 80daaa0..fdd24af 100644 --- a/modules/linux-vm-runtime/templates/cloud-init.yml +++ b/modules/linux-vm-runtime/templates/cloud-init.yml @@ -13,15 +13,15 @@ users: %{ if length(CLOUD_COMPOSE_SSH_KEYS) > 0 ~} ssh_authorized_keys: %{ for key in CLOUD_COMPOSE_SSH_KEYS ~} - - ${key} + - ${jsonencode(key)} %{ endfor ~} %{ endif ~} %{ for username, ssh_keys in SSH_USERS ~} -- name: ${username} +- name: ${jsonencode(username)} shell: /bin/bash ssh_authorized_keys: %{ for key in ssh_keys ~} - - ${key} + - ${jsonencode(key)} %{ endfor ~} %{ endfor ~} @@ -30,6 +30,7 @@ ${WRITE_FILES_CONTENT} ${DOCKER_COMPOSE_SCRIPTS} ${COMPOSE_PROJECTS_FILE} ${ENV_FILE_CONTENT} +${APPLICATION_ENV_FILE_CONTENT} ${VAULT_AGENT_FILES} ${MANAGED_RUNTIME_ARTIFACTS_FILE} @@ -58,34 +59,39 @@ runcmd: chmod 0440 /etc/sudoers.d/90-cloud-compose } - mount_cloud_compose_disk() { - device_path="$1" - mount_path="$2" - device="$(readlink -f "$device_path" 2>/dev/null || true)" - until [ -n "$device" ] && [ -b "$device" ]; do - echo "Waiting for $device_path" - sleep 2 - device="$(readlink -f "$device_path" 2>/dev/null || true)" - done - mkdir -p "$mount_path" - if ! blkid "$device" >/dev/null 2>&1; then - mkfs.ext4 -m 0 -E lazy_itable_init=1,lazy_journal_init=1,nodiscard "$device" - fi - if ! mountpoint -q "$mount_path"; then - mount -o defaults,nofail "$device" "$mount_path" - fi - chmod a+w "$mount_path" - } - ensure_cloud_compose_user - mount_cloud_compose_disk '${DATA_DEVICE}' /mnt/disks/data - mount_cloud_compose_disk '${VOLUMES_DEVICE}' /mnt/disks/volumes + filesystem_prep=/run/cloud-compose-prepare-filesystem + filesystem_persist=/run/cloud-compose-persist-filesystems +%{ if ROOTFS_ARCHIVE_ENABLED } + ${indent(2, ROOTFS_ARCHIVE_PREPARE_COMMAND)} +%{ else } + printf '%s' '${FILESYSTEM_PREP_SCRIPT_B64}' | base64 -d >"$filesystem_prep" + printf '%s' '${FILESYSTEM_PERSIST_SCRIPT_B64}' | base64 -d >"$filesystem_persist" + chmod 0600 "$filesystem_prep" "$filesystem_persist" +%{ endif } + bash "$filesystem_prep" '${DATA_DEVICE}' /mnt/disks/data + bash "$filesystem_prep" '${VOLUMES_DEVICE}' /mnt/disks/volumes mkdir -p /mnt/disks/data/docker/volumes if ! mountpoint -q /mnt/disks/data/docker/volumes; then mount --bind /mnt/disks/volumes /mnt/disks/data/docker/volumes fi -${ROOTFS_ARCHIVE_COMMAND} + for required_mount in /mnt/disks/data /mnt/disks/volumes /mnt/disks/data/docker/volumes; do + if ! mountpoint -q -- "$required_mount"; then + echo "Required cloud-compose mount is unavailable: $required_mount" >&2 + exit 1 + fi + done + bash "$filesystem_persist" '${DATA_DEVICE}' '${VOLUMES_DEVICE}' +%{ if ROOTFS_ARCHIVE_ENABLED } + ${indent(2, ROOTFS_ARCHIVE_INSTALL_COMMAND)} + ${indent(2, ARCHIVE_ADDITIONAL_ROOTFS)} +%{ else } + if [ -d /var/lib/cloud-compose/mounted-rootfs/mnt/disks ]; then + cp -a /var/lib/cloud-compose/mounted-rootfs/mnt/disks/. /mnt/disks/ + fi +%{ endif } chown cloud-compose:cloud-compose /mnt/disks/data /mnt/disks/volumes chmod 0775 /mnt/disks/data /mnt/disks/volumes install -d -m 0775 -o cloud-compose -g cloud-compose /mnt/disks/data/libops -- bash /home/cloud-compose/run.sh > /home/cloud-compose/run.log 2>&1 + rm -f /home/cloud-compose/.cloud-compose-bootstrap-complete + bash /home/cloud-compose/run.sh > /home/cloud-compose/run.log 2>&1 diff --git a/modules/linux-vm-runtime/variables.tf b/modules/linux-vm-runtime/variables.tf index cccd51d..8a89894 100644 --- a/modules/linux-vm-runtime/variables.tf +++ b/modules/linux-vm-runtime/variables.tf @@ -22,23 +22,52 @@ variable "zone" { variable "data_device" { type = string description = "Stable device path for the persistent data disk." + + validation { + condition = can(regex("^/dev/[A-Za-z0-9._/+:-]+$", var.data_device)) + error_message = "data_device must be a safe absolute /dev path without whitespace." + } } variable "volumes_device" { type = string description = "Stable device path for the persistent Docker volumes disk." + + validation { + condition = can(regex("^/dev/[A-Za-z0-9._/+:-]+$", var.volumes_device)) + error_message = "volumes_device must be a safe absolute /dev path without whitespace." + } } variable "ssh_users" { type = map(list(string)) default = {} description = "Additional Linux users and authorized SSH keys to create through cloud-init." + + validation { + condition = alltrue(concat( + [for username in keys(var.ssh_users) : can(regex("^[a-z_][a-z0-9_-]{0,31}\\$?$", username))], + flatten([ + for _, keys in var.ssh_users : [ + for key in keys : trimspace(key) != "" && !can(regex("[\\r\\n]", key)) + ] + ]), + )) + error_message = "ssh_users names must be safe Linux usernames and SSH keys must be non-empty single-line values." + } } variable "cloud_compose_ssh_keys" { type = list(string) default = [] description = "Authorized SSH keys for the cloud-compose Linux user." + + validation { + condition = alltrue([ + for key in var.cloud_compose_ssh_keys : trimspace(key) != "" && !can(regex("[\\r\\n]", key)) + ]) + error_message = "cloud_compose_ssh_keys entries must be non-empty single-line values." + } } variable "rootfs" { @@ -50,19 +79,32 @@ variable "rootfs" { variable "rootfs_archive_url" { type = string default = "" - description = "Optional tar.gz URL containing a rootfs directory to fetch during boot instead of embedding the packaged rootfs in cloud-init." + description = "Optional HTTPS tar.gz URL containing a rootfs directory to fetch during boot instead of embedding the packaged rootfs in cloud-init. Must be set with rootfs_archive_sha256." + + validation { + condition = ( + trimspace(var.rootfs_archive_url) == "" || + can(regex("^https://[^[:space:]]+$", trimspace(var.rootfs_archive_url))) + ) + error_message = "rootfs_archive_url must be empty or an HTTPS URL without whitespace." + } } variable "rootfs_archive_sha256" { type = string default = "" - description = "Optional SHA-256 checksum for rootfs_archive_url." + description = "Required 64-character SHA-256 checksum when rootfs_archive_url is set." } variable "ingress_port" { type = number default = 80 description = "Default TCP port exposed by a compose project on the VM." + + validation { + condition = var.ingress_port >= 1 && var.ingress_port <= 65535 && floor(var.ingress_port) == var.ingress_port + error_message = "ingress_port must be a whole number between 1 and 65535." + } } variable "primary_compose_project" { @@ -119,8 +161,8 @@ variable "compose_projects" { sitectl_context_name = optional(string) sitectl_plugin = optional(string) sitectl_environment = optional(string) - sitectl_packages = optional(list(string), []) - sitectl_verify_args = optional(list(string), []) + sitectl_packages = optional(list(string)) + sitectl_verify_args = optional(list(string)) docker_compose_init = optional(list(string)) docker_compose_up = optional(list(string)) docker_compose_down = optional(list(string)) @@ -133,10 +175,11 @@ variable "compose_projects" { for name, app in var.compose_projects : can(regex("^[a-z][a-z0-9-]*$", name)) && trimspace(app.docker_compose_repo) != "" && - try(app.ingress_port, 80) > 0 && - try(app.ingress_port, 80) <= 65535 + coalesce(try(app.ingress_port, null), var.ingress_port) >= 1 && + coalesce(try(app.ingress_port, null), var.ingress_port) <= 65535 && + floor(coalesce(try(app.ingress_port, null), var.ingress_port)) == coalesce(try(app.ingress_port, null), var.ingress_port) ]) - error_message = "compose_projects keys must match ^[a-z][a-z0-9-]*$, docker_compose_repo is required, and ingress_port must be between 1 and 65535." + error_message = "compose_projects keys must match ^[a-z][a-z0-9-]*$, docker_compose_repo is required, and ingress_port must be a whole number between 1 and 65535." } } @@ -172,23 +215,58 @@ variable "docker_compose_down" { variable "docker_compose_rollout" { type = list(string) default = [ - "sitectl deploy --context \"$${SITECTL_CONTEXT_NAME}\" --skip-git", - "sitectl healthcheck --context \"$${SITECTL_CONTEXT_NAME}\" --persist" + "TARGET_REF=\"$${GIT_REF:-$${GIT_BRANCH:-}}\"", + "if [ -n \"$TARGET_REF\" ]; then sitectl deploy --context \"$${SITECTL_CONTEXT_NAME}\" --ref \"$TARGET_REF\"; else sitectl deploy --context \"$${SITECTL_CONTEXT_NAME}\" --skip-git; fi", + "sitectl healthcheck --context \"$${SITECTL_CONTEXT_NAME}\" --persist", + "if [ \"$${SITECTL_ENVIRONMENT}\" != \"production\" ]; then sitectl verify --context \"$${SITECTL_CONTEXT_NAME}\" $${SITECTL_VERIFY_ARGS:-}; fi" ] nullable = false - description = "Commands used by rollout triggers." + description = "Commands used by rollout triggers. GIT_REF/GIT_BRANCH selects a source ref; without one, sitectl reconciles the current checkout." } variable "sitectl_packages" { type = list(string) default = ["sitectl"] description = "sitectl release packages to install." + + validation { + condition = alltrue([ + for package in var.sitectl_packages : + can(regex("^sitectl(-[a-z0-9]+)*$", package)) + ]) + error_message = "sitectl_packages entries must be release package names such as sitectl, sitectl-isle, or sitectl-wp." + } } variable "sitectl_version" { type = string default = "latest" - description = "sitectl release tag to install, or latest." + description = "Legacy fallback sitectl release tag for packages without a package-specific override." + + validation { + condition = ( + var.sitectl_version == "latest" || + can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$", var.sitectl_version)) + ) + error_message = "sitectl_version must be latest or an exact semantic-version release tag such as v0.38.0." + } +} + +variable "sitectl_package_versions" { + type = map(string) + default = {} + description = "Per-package release tags that override sitectl_version." + + validation { + condition = alltrue([ + for package, version in var.sitectl_package_versions : + can(regex("^sitectl(-[a-z0-9]+)*$", package)) && ( + version == "latest" || + can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$", version)) + ) + ]) + error_message = "sitectl_package_versions keys must be sitectl package names and values must be latest or exact semantic-version release tags." + } } variable "sitectl_context_name" { @@ -283,26 +361,20 @@ variable "vault_role" { variable "vault_agent_enabled" { type = bool default = false - description = "Write Vault Agent configuration and start vault-agent.service when Vault is configured." + description = "Write Vault Agent configuration and start cloud-compose-vault-agent.service when Vault is configured." } variable "vault_auth_method" { type = string default = "consumer-managed" - description = "Vault Agent auth method contract. Non-GCP providers default to consumer-managed." + description = "Vault Agent auth method contract. Linux VM providers require caller-owned consumer-managed auth configuration." validation { - condition = contains(["gcp-iam", "consumer-managed"], var.vault_auth_method) - error_message = "vault_auth_method must be gcp-iam or consumer-managed." + condition = var.vault_auth_method == "consumer-managed" + error_message = "vault_auth_method must be consumer-managed for the Linux VM runtime; GCP IAM auth is implemented by modules/gcp." } } -variable "vault_gcp_auth_mount_path" { - type = string - default = "auth/gcp" - description = "Vault GCP auth mount path used by Vault Agent for gcp-iam auth." -} - variable "vault_agent_token_path" { type = string default = "/mnt/disks/data/vault/token" @@ -329,5 +401,27 @@ variable "vault_agent_additional_config" { variable "extra_env" { type = map(string) default = {} - description = "Additional shell environment variables written to /home/cloud-compose/.env." + description = "Application-only Compose environment values written as JSON data and reconciled into every project .env." + + validation { + condition = alltrue([ + for name in keys(var.extra_env) : + can(regex("^[A-Za-z_][A-Za-z0-9_]*$", name)) && + !contains(["HOME", "PATH"], name) && + alltrue([ + for prefix in [ + "CLOUD_COMPOSE_", + "COMPOSE_", + "DOCKER_", + "SITECTL_", + "LIBOPS_", + "GCP_", + "VAULT_", + "ROLLOUT_", + "POWER_MANAGEMENT_", + ] : !startswith(name, prefix) + ]) + ]) + error_message = "extra_env names must be valid environment names and must not override cloud-compose control-plane keys (HOME, PATH, or CLOUD_COMPOSE_/COMPOSE_/DOCKER_/SITECTL_/LIBOPS_/GCP_/VAULT_/ROLLOUT_/POWER_MANAGEMENT_ prefixes)." + } } diff --git a/modules/linux-vm-runtime/versions.tf b/modules/linux-vm-runtime/versions.tf new file mode 100644 index 0000000..12ad22a --- /dev/null +++ b/modules/linux-vm-runtime/versions.tf @@ -0,0 +1,3 @@ +terraform { + required_version = ">= 1.3.0" +} diff --git a/modules/managed-artifacts/contracts.tftest.hcl b/modules/managed-artifacts/contracts.tftest.hcl new file mode 100644 index 0000000..8867d73 --- /dev/null +++ b/modules/managed-artifacts/contracts.tftest.hcl @@ -0,0 +1,73 @@ +run "accepts_safe_artifact" { + command = plan + + variables { + artifacts = [{ + name = "rollout-agent" + url = "https://github.com/libops/example/releases/download/v1.2.3/rollout-agent" + sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + path = "/usr/local/bin/rollout-agent" + mode = "0750" + owner = "root" + group = "root" + restart = "cloud-compose-rollout.service" + }] + } +} + +run "rejects_unsafe_artifact_fields" { + command = plan + + variables { + artifacts = [{ + name = "../escape" + url = "http://example.invalid/agent" + sha256 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + path = "/usr/local/../etc/agent" + mode = "4777" + owner = "root:root" + group = "root" + restart = "../../docker.service" + }] + } + + expect_failures = [output.artifacts] +} + +run "rejects_root_target" { + command = plan + + variables { + artifacts = [{ + name = "agent" + url = "https://example.invalid/agent" + sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + path = "/" + }] + } + + expect_failures = [output.artifacts] +} + +run "rejects_duplicate_names_and_paths" { + command = plan + + variables { + artifacts = [ + { + name = "agent" + url = "https://example.invalid/agent-one" + sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + path = "/usr/local/bin/agent-one" + }, + { + name = "agent" + url = "https://example.invalid/agent-two" + sha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + path = "/usr/local/bin/agent-one" + }, + ] + } + + expect_failures = [output.artifacts] +} diff --git a/modules/managed-artifacts/main.tf b/modules/managed-artifacts/main.tf new file mode 100644 index 0000000..2f8827f --- /dev/null +++ b/modules/managed-artifacts/main.tf @@ -0,0 +1,43 @@ +terraform { + required_version = ">= 1.3.0" +} + +locals { + artifacts_valid = alltrue([ + for artifact in var.artifacts : + can(regex("^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$", artifact.name)) && + can(regex("^https://[^[:space:]]+$", artifact.url)) && + can(regex("^[0-9a-f]{64}$", artifact.sha256)) && + startswith(artifact.path, "/") && + artifact.path != "/" && + !can(regex("[\\x00-\\x1f\\x7f]", artifact.path)) && + alltrue([ + for segment in split("/", trimprefix(artifact.path, "/")) : + segment != "" && segment != "." && segment != ".." + ]) && + can(regex("^0?[0-7]{3}$", artifact.mode)) && + can(regex("^[a-z_][a-z0-9_-]{0,31}\\$?$", artifact.owner)) && + can(regex("^[a-z_][a-z0-9_-]{0,31}\\$?$", artifact.group)) && + (artifact.restart == "" || can(regex("^[A-Za-z0-9][A-Za-z0-9_.@:-]*\\.service$", artifact.restart))) + ]) + + artifacts_unique = ( + length(distinct([for artifact in var.artifacts : artifact.name])) == length(var.artifacts) && + length(distinct([for artifact in var.artifacts : artifact.path])) == length(var.artifacts) + ) +} + +output "artifacts" { + value = var.artifacts + description = "Managed artifacts after enforcing the shared installation contract." + + precondition { + condition = local.artifacts_valid + error_message = "Managed artifacts require a safe basename, HTTPS URL, lowercase SHA-256, non-root absolute path without dot/empty/control segments, octal mode, safe owner/group, and an optional safe .service restart unit." + } + + precondition { + condition = local.artifacts_unique + error_message = "Managed artifact names and target paths must be unique." + } +} diff --git a/modules/managed-artifacts/variables.tf b/modules/managed-artifacts/variables.tf new file mode 100644 index 0000000..6bbb803 --- /dev/null +++ b/modules/managed-artifacts/variables.tf @@ -0,0 +1,14 @@ +variable "artifacts" { + type = list(object({ + name = string + url = string + sha256 = string + path = string + mode = optional(string, "0755") + owner = optional(string, "root") + group = optional(string, "root") + restart = optional(string, "") + })) + default = [] + description = "Host artifacts downloaded and installed by the managed runtime." +} diff --git a/modules/project-directories/contracts.tftest.hcl b/modules/project-directories/contracts.tftest.hcl new file mode 100644 index 0000000..98ad7b5 --- /dev/null +++ b/modules/project-directories/contracts.tftest.hcl @@ -0,0 +1,38 @@ +run "accepts_managed_descendants" { + command = plan + + variables { + project_dirs = { + wp = "/mnt/disks/data/libops/wp/main" + isle = "/mnt/disks/data/libops/isle/v2" + } + } +} + +run "rejects_host_paths_and_traversal" { + command = plan + + variables { + project_dirs = { + root = "/" + etc = "/etc" + traversal = "/mnt/disks/data/../etc" + } + } + + expect_failures = [output.project_dirs] +} + +run "rejects_ambiguous_segments" { + command = plan + + variables { + project_dirs = { + duplicate_separator = "/mnt/disks/data/libops//wp" + trailing_separator = "/mnt/disks/data/libops/wp/" + padded = " /mnt/disks/data/libops/wp" + } + } + + expect_failures = [output.project_dirs] +} diff --git a/modules/project-directories/main.tf b/modules/project-directories/main.tf new file mode 100644 index 0000000..5de1532 --- /dev/null +++ b/modules/project-directories/main.tf @@ -0,0 +1,31 @@ +terraform { + required_version = ">= 1.3.0" +} + +locals { + data_root = trimsuffix(var.data_root, "/") + project_directories_valid = alltrue([ + for project_dir in values(var.project_dirs) : + project_dir == trimspace(project_dir) && + startswith(project_dir, "${local.data_root}/") && + !endswith(project_dir, "/") && + !can(regex("//", project_dir)) && + !can(regex("[\\x00-\\x1f\\x7f]", project_dir)) && + !can(regex("(^|/)\\.\\.?(/|$)", project_dir)) + ]) +} + +output "project_dirs" { + value = var.project_dirs + description = "Compose project directories after enforcing the managed-data ownership boundary." + + precondition { + condition = startswith(local.data_root, "/") && local.data_root != "/" + error_message = "data_root must be a non-root absolute path." + } + + precondition { + condition = local.project_directories_valid + error_message = "Compose project directories must be normalized descendants of the managed data root, without whitespace padding, control characters, empty segments, trailing slashes, or dot segments." + } +} diff --git a/modules/project-directories/variables.tf b/modules/project-directories/variables.tf new file mode 100644 index 0000000..8bb7fca --- /dev/null +++ b/modules/project-directories/variables.tf @@ -0,0 +1,10 @@ +variable "project_dirs" { + type = map(string) + description = "Normalized Compose project directories keyed by application name." +} + +variable "data_root" { + type = string + default = "/mnt/disks/data" + description = "Exclusive host ownership boundary for Compose project directories." +} diff --git a/modules/runtime-env/main.tf b/modules/runtime-env/main.tf new file mode 100644 index 0000000..1e48fe5 --- /dev/null +++ b/modules/runtime-env/main.tf @@ -0,0 +1,27 @@ +locals { + # cloud-compose-env-contract: Docker Compose double-quoted dotenv data. + # Every significant character is escaped, and profile.sh decodes the same + # narrow grammar without evaluating the file as shell code. + escaped_backslashes = { + for name, value in var.env : name => replace(value, "\\", "\\\\") + } + escaped_quotes = { + for name, value in local.escaped_backslashes : name => replace(value, "\"", "\\\"") + } + escaped_dollars = { + for name, value in local.escaped_quotes : name => replace(value, "$", "$$") + } + escaped_newlines = { + for name, value in local.escaped_dollars : name => replace(value, "\n", "\\n") + } + escaped_returns = { + for name, value in local.escaped_newlines : name => replace(value, "\r", "\\r") + } + escaped_tabs = { + for name, value in local.escaped_returns : name => replace(value, "\t", "\\t") + } + content = join("\n", [ + for name in sort(keys(var.env)) : + "${name}=\"${local.escaped_tabs[name]}\"" + ]) +} diff --git a/modules/runtime-env/outputs.tf b/modules/runtime-env/outputs.tf new file mode 100644 index 0000000..7b684d0 --- /dev/null +++ b/modules/runtime-env/outputs.tf @@ -0,0 +1,4 @@ +output "content" { + value = local.content + description = "Environment file content using the cloud-compose double-quoted dotenv encoding contract." +} diff --git a/modules/runtime-env/runtime_env.tftest.hcl b/modules/runtime-env/runtime_env.tftest.hcl new file mode 100644 index 0000000..008fcda --- /dev/null +++ b/modules/runtime-env/runtime_env.tftest.hcl @@ -0,0 +1,83 @@ +run "renders_values_as_literal_data" { + command = plan + + variables { + env = { + BACKTICKS = "`touch /tmp/cloud-compose-backtick-injection`" + BACKSLASH = "a\\path\\with\\slashes" + COMMAND_SUB = "$(touch /tmp/cloud-compose-command-injection)" + DOLLAR_VALUE = "$HOME $${HOME}" + DOUBLE_QUOTES = "a \"quoted\" value" + MULTILINE = "line one\nline two" + SINGLE_QUOTE = "O'Reilly" + SITECTL_PACKAGE_VERSIONS = jsonencode({ + sitectl = "v0.38.0" + sitectl-isle = "v0.12.0" + }) + TRAILING_SLASH = "ends\\" + WHITESPACE = " leading and trailing " + } + } + + assert { + condition = strcontains(output.content, "BACKTICKS=\"`touch /tmp/cloud-compose-backtick-injection`\"") + error_message = "Backticks must remain literal dotenv data." + } + + assert { + condition = strcontains(output.content, "BACKSLASH=\"a\\\\path\\\\with\\\\slashes\"") + error_message = "Backslashes must use the Docker Compose double-quote escape." + } + + assert { + condition = strcontains(output.content, "COMMAND_SUB=\"$$(touch /tmp/cloud-compose-command-injection)\"") + error_message = "Command substitutions must remain literal dotenv data." + } + + assert { + condition = strcontains(output.content, "DOLLAR_VALUE=\"$$HOME $$${HOME}\"") + error_message = "Dollar-prefixed values must not be interpolated by Docker Compose." + } + + assert { + condition = strcontains(output.content, "DOUBLE_QUOTES=\"a \\\"quoted\\\" value\"") + error_message = "Double quotes must use the Docker Compose escape." + } + + assert { + condition = strcontains(output.content, "MULTILINE=\"line one\\nline two\"") + error_message = "Multiline values must use the Docker Compose newline escape." + } + + assert { + condition = strcontains(output.content, "SINGLE_QUOTE=\"O'Reilly\"") + error_message = "Single quotes must remain literal dotenv data." + } + + assert { + condition = strcontains(output.content, "SITECTL_PACKAGE_VERSIONS=\"{\\\"sitectl\\\":\\\"v0.38.0\\\",\\\"sitectl-isle\\\":\\\"v0.12.0\\\"}\"") + error_message = "Structured sitectl package versions must remain valid JSON after dotenv encoding." + } + + assert { + condition = strcontains(output.content, "TRAILING_SLASH=\"ends\\\\\"") + error_message = "A trailing backslash must round-trip through Docker Compose." + } + + assert { + condition = strcontains(output.content, "WHITESPACE=\" leading and trailing \"") + error_message = "Leading and trailing whitespace must be preserved." + } +} + +run "rejects_invalid_environment_names" { + command = plan + + variables { + env = { + "BAD-NAME" = "value" + } + } + + expect_failures = [var.env] +} diff --git a/modules/runtime-env/variables.tf b/modules/runtime-env/variables.tf new file mode 100644 index 0000000..03e8acb --- /dev/null +++ b/modules/runtime-env/variables.tf @@ -0,0 +1,11 @@ +variable "env" { + type = map(string) + description = "Trusted host-control values rendered as non-executable dotenv data for the strict profile.sh loader." + + validation { + condition = alltrue([ + for name in keys(var.env) : can(regex("^[A-Za-z_][A-Za-z0-9_]*$", name)) + ]) + error_message = "Environment variable names must match ^[A-Za-z_][A-Za-z0-9_]*$." + } +} diff --git a/modules/sitectl-runtime/main.tf b/modules/sitectl-runtime/main.tf new file mode 100644 index 0000000..e051367 --- /dev/null +++ b/modules/sitectl-runtime/main.tf @@ -0,0 +1,15 @@ +terraform { + required_version = ">= 1.3.0" +} + +locals { + packages = distinct(concat(["sitectl"], var.packages)) + package_versions = { + for package in local.packages : + package => lookup(var.package_versions, package, var.fallback_version) + } + unknown_package_versions = setsubtract( + toset(keys(var.package_versions)), + toset(local.packages), + ) +} diff --git a/modules/sitectl-runtime/outputs.tf b/modules/sitectl-runtime/outputs.tf new file mode 100644 index 0000000..763275a --- /dev/null +++ b/modules/sitectl-runtime/outputs.tf @@ -0,0 +1,14 @@ +output "packages" { + value = local.packages + description = "Normalized package list, always including sitectl." +} + +output "package_versions" { + value = local.package_versions + description = "Resolved release tag for every installed package." + + precondition { + condition = length(local.unknown_package_versions) == 0 + error_message = "package_versions contains packages that are not installed: ${join(", ", sort(tolist(local.unknown_package_versions)))}." + } +} diff --git a/modules/sitectl-runtime/package_versions.tftest.hcl b/modules/sitectl-runtime/package_versions.tftest.hcl new file mode 100644 index 0000000..b87fbc6 --- /dev/null +++ b/modules/sitectl-runtime/package_versions.tftest.hcl @@ -0,0 +1,93 @@ +run "resolves_independent_package_versions" { + command = plan + + variables { + packages = ["sitectl-isle", "sitectl-drupal"] + fallback_version = "v0.38.0" + package_versions = { + sitectl = "v0.39.0-rc.1" + sitectl-drupal = "v0.11.0" + sitectl-isle = "v0.12.0" + } + } + + assert { + condition = output.package_versions == { + sitectl = "v0.39.0-rc.1" + sitectl-drupal = "v0.11.0" + sitectl-isle = "v0.12.0" + } + error_message = "Each package override must resolve independently." + } +} + +run "preserves_legacy_version_fallback" { + command = plan + + variables { + packages = ["sitectl-isle"] + fallback_version = "v0.38.0" + package_versions = {} + } + + assert { + condition = output.package_versions == { + sitectl = "v0.38.0" + sitectl-isle = "v0.38.0" + } + error_message = "An empty package_versions map must preserve the legacy all-packages version behavior." + } +} + +run "uses_fallback_for_unlisted_package" { + command = plan + + variables { + packages = ["sitectl-isle"] + fallback_version = "latest" + package_versions = { + sitectl = "v0.38.0" + } + } + + assert { + condition = output.package_versions == { + sitectl = "v0.38.0" + sitectl-isle = "latest" + } + error_message = "Packages without an override must use the legacy version fallback." + } +} + +run "rejects_unknown_package_override" { + command = plan + + variables { + packages = ["sitectl-isle"] + package_versions = { + sitectl-wp = "v0.9.0" + } + } + + expect_failures = [output.package_versions] +} + +run "rejects_invalid_package_name" { + command = plan + + variables { + packages = ["../../sitectl"] + } + + expect_failures = [var.packages] +} + +run "rejects_trailing_version_data" { + command = plan + + variables { + fallback_version = "v0.38.0/../../latest" + } + + expect_failures = [var.fallback_version] +} diff --git a/modules/sitectl-runtime/variables.tf b/modules/sitectl-runtime/variables.tf new file mode 100644 index 0000000..20c0cdc --- /dev/null +++ b/modules/sitectl-runtime/variables.tf @@ -0,0 +1,44 @@ +variable "packages" { + type = list(string) + default = ["sitectl"] + description = "sitectl release package names installed on the host." + + validation { + condition = alltrue([ + for package in var.packages : + can(regex("^sitectl(-[a-z0-9]+)*$", package)) + ]) + error_message = "packages entries must be release package names such as sitectl, sitectl-isle, or sitectl-wp." + } +} + +variable "fallback_version" { + type = string + default = "latest" + description = "Legacy fallback release tag for packages without a package_versions override." + + validation { + condition = ( + var.fallback_version == "latest" || + can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$", var.fallback_version)) + ) + error_message = "fallback_version must be latest or an exact semantic-version release tag such as v0.38.0 or v0.39.0-rc.1." + } +} + +variable "package_versions" { + type = map(string) + default = {} + description = "Per-package release tags. Entries override version for the named package." + + validation { + condition = alltrue([ + for package, version in var.package_versions : + can(regex("^sitectl(-[a-z0-9]+)*$", package)) && ( + version == "latest" || + can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$", version)) + ) + ]) + error_message = "package_versions keys must be sitectl package names and values must be latest or exact semantic-version release tags." + } +} diff --git a/outputs.tf b/outputs.tf index 5ea4e55..75e78d6 100644 --- a/outputs.tf +++ b/outputs.tf @@ -48,13 +48,19 @@ output "internal_ip" { description = "Selected provider VM private IPv4 address." } +output "network" { + value = try(module.gcp[0].network, null) + description = "Resolved GCP network and regional subnetwork, or null for non-GCP providers." +} + output "volumes" { value = try( + module.gcp[0].volumes, module.digitalocean[0].volumes, module.linode[0].volumes, null, ) - description = "Selected provider persistent volume details where available." + description = "Selected provider persistent application-data and Docker-volume details." } output "serviceGsa" { @@ -101,3 +107,13 @@ output "primary_compose_project" { ) description = "Normalized primary compose project." } + +output "sitectl_package_versions" { + value = try( + module.gcp[0].sitectl_package_versions, + module.digitalocean[0].sitectl_package_versions, + module.linode[0].sitectl_package_versions, + {}, + ) + description = "Effective release selector for every installed sitectl package; values may be exact tags or latest." +} diff --git a/providers/do/main.tf b/providers/do/main.tf index ad193f9..a2a6625 100644 --- a/providers/do/main.tf +++ b/providers/do/main.tf @@ -1,5 +1,16 @@ terraform { - required_version = ">= 1.2.4" + required_version = ">= 1.3.0" + + required_providers { + cloudinit = { + source = "hashicorp/cloudinit" + version = "~> 2.3" + } + digitalocean = { + source = "digitalocean/digitalocean" + version = "~> 2.0" + } + } } locals { @@ -13,6 +24,16 @@ locals { input_compose = var.runtime.compose input_sitectl = var.runtime.sitectl + sitectl_packages = distinct(concat( + ["sitectl"], + local.input_sitectl.packages == null ? local.template.packages : local.input_sitectl.packages, + )) + template_sitectl_package_versions = { + for package in local.sitectl_packages : + package => local.template.package_versions[package] + if contains(keys(local.template.package_versions), package) + } + runtime = merge(var.runtime, { compose = merge(local.input_compose, { repo = ( @@ -27,11 +48,8 @@ locals { ) }) sitectl = merge(local.input_sitectl, { - packages = ( - local.template_name != "" && length(local.input_sitectl.packages) == 1 && local.input_sitectl.packages[0] == "sitectl" - ? local.template.packages - : local.input_sitectl.packages - ) + packages = local.sitectl_packages + package_versions = merge(local.template_sitectl_package_versions, local.input_sitectl.package_versions) plugin = ( local.template_name != "" && local.input_sitectl.plugin == "core" ? local.template.plugin diff --git a/providers/do/outputs.tf b/providers/do/outputs.tf index 8354fc7..86ea9c2 100644 --- a/providers/do/outputs.tf +++ b/providers/do/outputs.tf @@ -30,7 +30,7 @@ output "internal_ip" { output "volumes" { value = module.digitalocean.volumes - description = "Selected provider persistent volume details where available." + description = "Selected provider persistent application-data and Docker-volume details." } output "serviceGsa" { @@ -67,3 +67,8 @@ output "primary_compose_project" { value = module.digitalocean.primary_compose_project description = "Normalized primary compose project." } + +output "sitectl_package_versions" { + value = module.digitalocean.sitectl_package_versions + description = "Effective release selector for every installed sitectl package; values may be exact tags or latest." +} diff --git a/providers/do/template_versions.tftest.hcl b/providers/do/template_versions.tftest.hcl new file mode 100644 index 0000000..45d2cf7 --- /dev/null +++ b/providers/do/template_versions.tftest.hcl @@ -0,0 +1,53 @@ +mock_provider "cloudinit" {} +mock_provider "digitalocean" {} + +run "custom_package_set_merges_only_applicable_template_versions" { + command = plan + + variables { + name = "template-versions" + template = "isle" + runtime = { + rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz" + rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + sitectl = { + packages = ["sitectl", "sitectl-wp"] + package_versions = { + sitectl = "v0.40.0" + sitectl-wp = "v0.5.1" + } + } + } + } + + assert { + condition = local.runtime.sitectl.package_versions == { + sitectl = "v0.40.0" + sitectl-wp = "v0.5.1" + } + error_message = "The DigitalOcean entrypoint must filter template selectors and preserve explicit overrides." + } +} + +run "explicit_core_only_package_set_disables_template_plugins" { + command = plan + + variables { + name = "template-versions" + template = "isle" + runtime = { + rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz" + rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + sitectl = { + packages = [] + } + } + } + + assert { + condition = local.runtime.sitectl.packages == tolist(["sitectl"]) && local.runtime.sitectl.package_versions == { + sitectl = "v0.39.0" + } + error_message = "The DigitalOcean entrypoint must preserve an explicit core-only package set." + } +} diff --git a/providers/do/variables.tf b/providers/do/variables.tf index 2016887..60de3c9 100644 --- a/providers/do/variables.tf +++ b/providers/do/variables.tf @@ -20,7 +20,7 @@ variable "template" { } variable "digitalocean" { - description = "DigitalOcean infrastructure settings." + description = "DigitalOcean infrastructure settings. droplet.backups covers only the Droplet boot disk; attached application and Docker volumes require a separate offsite backup policy." type = object({ region = optional(string, "tor1") tags = optional(list(string), ["cloud-compose"]) @@ -60,6 +60,7 @@ variable "runtime" { rootfs = optional(string, "") rootfs_archive_url = optional(string, "") rootfs_archive_sha256 = optional(string, "") + users = optional(map(list(string)), {}) compose = optional(object({ primary = optional(string, "") @@ -95,8 +96,8 @@ variable "runtime" { sitectl_context_name = optional(string) sitectl_plugin = optional(string) sitectl_environment = optional(string) - sitectl_packages = optional(list(string), []) - sitectl_verify_args = optional(list(string), []) + sitectl_packages = optional(list(string)) + sitectl_verify_args = optional(list(string)) docker_compose_init = optional(list(string)) docker_compose_up = optional(list(string)) docker_compose_down = optional(list(string)) @@ -109,12 +110,13 @@ variable "runtime" { }), {}) sitectl = optional(object({ - packages = optional(list(string), ["sitectl"]) - version = optional(string, "latest") - context_name = optional(string, "") - plugin = optional(string, "core") - environment = optional(string, "production") - verify_args = optional(list(string), []) + packages = optional(list(string)) + version = optional(string, "latest") + package_versions = optional(map(string), {}) + context_name = optional(string, "") + plugin = optional(string, "core") + environment = optional(string, "production") + verify_args = optional(list(string), []) }), {}) docker = optional(object({ @@ -160,19 +162,82 @@ variable "runtime" { }) default = {} + validation { + condition = ( + (trimspace(var.runtime.rootfs_archive_url) == "") == (trimspace(var.runtime.rootfs_archive_sha256) == "") && + (trimspace(var.runtime.rootfs_archive_sha256) == "" || can(regex("^[0-9a-fA-F]{64}$", trimspace(var.runtime.rootfs_archive_sha256)))) + ) + error_message = "runtime.rootfs_archive_url and a 64-character runtime.rootfs_archive_sha256 must be supplied together." + } + + validation { + condition = alltrue([ + for name in keys(var.runtime.extra_env) : + can(regex("^[A-Za-z_][A-Za-z0-9_]*$", name)) && + !contains(["HOME", "PATH"], name) && + alltrue([ + for prefix in [ + "CLOUD_COMPOSE_", + "COMPOSE_", + "DOCKER_", + "SITECTL_", + "LIBOPS_", + "GCP_", + "VAULT_", + "ROLLOUT_", + "POWER_MANAGEMENT_", + ] : !startswith(name, prefix) + ]) + ]) + error_message = "runtime.extra_env names must be valid environment names and must not override cloud-compose control-plane keys (HOME, PATH, or CLOUD_COMPOSE_/COMPOSE_/DOCKER_/SITECTL_/LIBOPS_/GCP_/VAULT_/ROLLOUT_/POWER_MANAGEMENT_ prefixes)." + } + + validation { + condition = ( + var.runtime.compose.ingress_port >= 1 && + var.runtime.compose.ingress_port <= 65535 && + floor(var.runtime.compose.ingress_port) == var.runtime.compose.ingress_port && + alltrue([ + for name, app in var.runtime.compose.projects : + can(regex("^[a-z][a-z0-9-]*$", name)) && + trimspace(app.docker_compose_repo) != "" && + coalesce(try(app.ingress_port, null), var.runtime.compose.ingress_port) >= 1 && + coalesce(try(app.ingress_port, null), var.runtime.compose.ingress_port) <= 65535 && + floor(coalesce(try(app.ingress_port, null), var.runtime.compose.ingress_port)) == coalesce(try(app.ingress_port, null), var.runtime.compose.ingress_port) + ]) + ) + error_message = "runtime.compose.projects keys must match ^[a-z][a-z0-9-]*$, docker_compose_repo is required, and ingress ports must be whole numbers between 1 and 65535." + } + + validation { + condition = alltrue([ + for package in coalesce(var.runtime.sitectl.packages, []) : + can(regex("^sitectl(-[a-z0-9]+)*$", package)) + ]) + error_message = "runtime.sitectl.packages entries must be release package names such as sitectl, sitectl-isle, or sitectl-wp." + } + + validation { + condition = ( + var.runtime.sitectl.version == "latest" || + can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$", var.runtime.sitectl.version)) + ) + error_message = "runtime.sitectl.version must be latest or an exact semantic-version release tag such as v0.38.0." + } + validation { condition = alltrue([ - for name, app in var.runtime.compose.projects : - can(regex("^[a-z][a-z0-9-]*$", name)) && - trimspace(app.docker_compose_repo) != "" && - try(app.ingress_port, var.runtime.compose.ingress_port) > 0 && - try(app.ingress_port, var.runtime.compose.ingress_port) <= 65535 + for package, version in var.runtime.sitectl.package_versions : + can(regex("^sitectl(-[a-z0-9]+)*$", package)) && ( + version == "latest" || + can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$", version)) + ) ]) - error_message = "runtime.compose.projects keys must match ^[a-z][a-z0-9-]*$, docker_compose_repo is required, and ingress_port must be between 1 and 65535." + error_message = "runtime.sitectl.package_versions keys must be sitectl package names and values must be latest or exact semantic-version release tags." } validation { - condition = contains(["gcp-iam", "consumer-managed"], var.runtime.vault.auth_method) - error_message = "runtime.vault.auth_method must be gcp-iam or consumer-managed." + condition = var.runtime.vault.auth_method == "consumer-managed" + error_message = "runtime.vault.auth_method must be consumer-managed on DigitalOcean." } } diff --git a/providers/gcp/main.tf b/providers/gcp/main.tf index 060fd4f..f81a1f1 100644 --- a/providers/gcp/main.tf +++ b/providers/gcp/main.tf @@ -1,5 +1,20 @@ terraform { - required_version = ">= 1.2.4" + required_version = ">= 1.3.0" + + required_providers { + cloudinit = { + source = "hashicorp/cloudinit" + version = "~> 2.3" + } + google = { + source = "hashicorp/google" + version = "~> 7.0" + } + time = { + source = "hashicorp/time" + version = "~> 0.14" + } + } } locals { @@ -13,6 +28,16 @@ locals { input_compose = var.runtime.compose input_sitectl = var.runtime.sitectl + sitectl_packages = distinct(concat( + ["sitectl"], + local.input_sitectl.packages == null ? local.template.packages : local.input_sitectl.packages, + )) + template_sitectl_package_versions = { + for package in local.sitectl_packages : + package => local.template.package_versions[package] + if contains(keys(local.template.package_versions), package) + } + runtime = merge(var.runtime, { compose = merge(local.input_compose, { repo = ( @@ -27,11 +52,8 @@ locals { ) }) sitectl = merge(local.input_sitectl, { - packages = ( - local.template_name != "" && length(local.input_sitectl.packages) == 1 && local.input_sitectl.packages[0] == "sitectl" - ? local.template.packages - : local.input_sitectl.packages - ) + packages = local.sitectl_packages + package_versions = merge(local.template_sitectl_package_versions, local.input_sitectl.package_versions) plugin = ( local.template_name != "" && local.input_sitectl.plugin == "core" ? local.template.plugin @@ -69,6 +91,7 @@ module "gcp" { service_account_email = local.gcp_identity.vm_service_account_email app_service_account_email = local.gcp_identity.app_service_account_email + app_credentials_enabled = local.gcp_identity.app_credentials_enabled machine_type = local.gcp_instance.machine_type os = local.gcp_instance.os @@ -88,12 +111,13 @@ module "gcp" { docker_compose_down = local.compose.down docker_compose_rollout = local.compose.rollout - sitectl_packages = local.sitectl.packages - sitectl_version = local.sitectl.version - sitectl_context_name = local.sitectl.context_name - sitectl_plugin = local.sitectl.plugin - sitectl_environment = local.sitectl.environment - sitectl_verify_args = local.sitectl.verify_args + sitectl_packages = local.sitectl.packages + sitectl_version = local.sitectl.version + sitectl_package_versions = local.sitectl.package_versions + sitectl_context_name = local.sitectl.context_name + sitectl_plugin = local.sitectl.plugin + sitectl_environment = local.sitectl.environment + sitectl_verify_args = local.sitectl.verify_args docker_compose_version = local.docker.compose_version docker_buildx_version = local.docker.buildx_version @@ -103,28 +127,36 @@ module "gcp" { libops_internal_services_auto_update = local.managed.internal_services_auto_update libops_managed_artifacts = local.managed.artifacts - allowed_ips = local.gcp_network.power_button_allowed_ips - allowed_ssh_ipv4 = local.gcp_network.ssh_ipv4 - allowed_ssh_ipv6 = local.gcp_network.ssh_ipv6 + allowed_ips = local.gcp_network.power_button_allowed_ips + allowed_ip_forwarded_depth = local.gcp_network.power_button_ip_depth + allowed_ssh_ipv4 = local.gcp_network.ssh_ipv4 + allowed_ssh_ipv6 = local.gcp_network.ssh_ipv6 create_network = local.gcp_network.create + network_project_id = local.gcp_network.project_id network_name = local.gcp_network.name subnetwork_name = local.gcp_network.subnetwork network_ip_cidr_range = local.gcp_network.ip_cidr_range + network_mtu = local.gcp_network.mtu run_snapshots = local.gcp_snapshots.enabled overlay_source_instance = local.gcp_overlay.source_instance volume_names = local.gcp_overlay.volume_names - users = local.runtime.users - rootfs = local.runtime.rootfs - runcmd = local.gcp_cloud_init.runcmd - initcmd = local.gcp_cloud_init.initcmd + users = local.runtime.users + rootfs = local.runtime.rootfs + rootfs_archive_url = local.runtime.rootfs_archive_url + rootfs_archive_sha256 = local.runtime.rootfs_archive_sha256 + extra_env = local.runtime.extra_env + runcmd = local.gcp_cloud_init.runcmd + initcmd = local.gcp_cloud_init.initcmd artifact_registry_repository = local.gcp_artifact_registry.repository artifact_registry_location = local.gcp_artifact_registry.location power_management_enabled = local.gcp_power_management.enabled + power_start_role = local.gcp_power_management.start_role + power_suspend_role = local.gcp_power_management.suspend_role frontend = local.gcp_power_management.frontend rollout_enabled = local.gcp_rollout.enabled diff --git a/providers/gcp/outputs.tf b/providers/gcp/outputs.tf index 175b5bc..0f4b214 100644 --- a/providers/gcp/outputs.tf +++ b/providers/gcp/outputs.tf @@ -28,9 +28,14 @@ output "internal_ip" { description = "Selected provider VM private IPv4 address." } +output "network" { + value = module.gcp.network + description = "Resolved GCP network and regional subnetwork used by the VM and optional Cloud Run Direct VPC egress." +} + output "volumes" { - value = null - description = "Selected provider persistent volume details where available." + value = module.gcp.volumes + description = "Selected provider persistent application-data and Docker-volume details." } output "serviceGsa" { @@ -67,3 +72,8 @@ output "primary_compose_project" { value = module.gcp.primary_compose_project description = "Normalized primary compose project." } + +output "sitectl_package_versions" { + value = module.gcp.sitectl_package_versions + description = "Effective release selector for every installed sitectl package; values may be exact tags or latest." +} diff --git a/providers/gcp/template_versions.tftest.hcl b/providers/gcp/template_versions.tftest.hcl new file mode 100644 index 0000000..a6e4c69 --- /dev/null +++ b/providers/gcp/template_versions.tftest.hcl @@ -0,0 +1,103 @@ +mock_provider "cloudinit" {} +mock_provider "google" { + mock_data "google_project" { + defaults = { + number = "123456789" + } + } +} +mock_provider "time" {} + +run "custom_package_set_merges_only_applicable_template_versions" { + command = plan + + variables { + name = "template-versions" + template = "isle" + gcp = { + project_id = "test-project" + project_number = "123456789" + } + runtime = { + sitectl = { + packages = ["sitectl", "sitectl-wp"] + package_versions = { + sitectl = "v0.40.0" + sitectl-wp = "v0.5.1" + } + } + } + } + + assert { + condition = local.runtime.sitectl.package_versions == { + sitectl = "v0.40.0" + sitectl-wp = "v0.5.1" + } + error_message = "The GCP entrypoint must filter template selectors and preserve explicit overrides." + } +} + +run "explicit_core_only_package_set_disables_template_plugins" { + command = plan + + variables { + name = "template-versions" + template = "isle" + gcp = { + project_id = "test-project" + project_number = "123456789" + } + runtime = { + sitectl = { + packages = [] + } + } + } + + assert { + condition = local.runtime.sitectl.packages == tolist(["sitectl"]) && local.runtime.sitectl.package_versions == { + sitectl = "v0.39.0" + } + error_message = "The GCP entrypoint must preserve an explicit core-only package set." + } +} + +run "accepts_direct_cloud_run_proxy_depth" { + command = plan + + variables { + name = "template-versions" + template = "wp" + gcp = { + project_id = "test-project" + project_number = "123456789" + network = { + power_button_ip_depth = 0 + } + } + } + + assert { + condition = local.gcp_network.power_button_ip_depth == 0 + error_message = "The direct public Cloud Run path must accept the proven right-edge client depth of zero." + } +} + +run "rejects_negative_power_button_proxy_depth" { + command = plan + + variables { + name = "template-versions" + template = "wp" + gcp = { + project_id = "test-project" + project_number = "123456789" + network = { + power_button_ip_depth = -1 + } + } + } + + expect_failures = [var.gcp] +} diff --git a/providers/gcp/variables.tf b/providers/gcp/variables.tf index d4c6237..d041df4 100644 --- a/providers/gcp/variables.tf +++ b/providers/gcp/variables.tf @@ -1,6 +1,11 @@ variable "name" { type = string description = "Deployment name." + + validation { + condition = can(regex("^[a-z][a-z0-9-]{4,19}[a-z0-9]$", var.name)) + error_message = "name must be 6 through 21 lowercase letters, numbers, or hyphens; it must start with a letter and end with a letter or number so every generated GCP service-account ID is valid." + } } variable "template" { @@ -25,6 +30,7 @@ variable "gcp" { identity = optional(object({ vm_service_account_email = optional(string, "") app_service_account_email = optional(string, "") + app_credentials_enabled = optional(bool, false) }), {}) instance = optional(object({ @@ -40,10 +46,13 @@ variable "gcp" { network = optional(object({ create = optional(bool, true) + project_id = optional(string, "") name = optional(string, "") subnetwork = optional(string, "") ip_cidr_range = optional(string, "10.42.0.0/24") + mtu = optional(number, 1460) power_button_allowed_ips = optional(list(string), []) + power_button_ip_depth = optional(number) ssh_ipv4 = optional(list(string), []) ssh_ipv6 = optional(list(string), []) }), {}) @@ -68,7 +77,9 @@ variable "gcp" { }), {}) power_management = optional(object({ - enabled = optional(bool, true) + enabled = optional(bool, false) + start_role = optional(string, "") + suspend_role = optional(string, "") frontend = optional(object({ image = string port = optional(number, 8080) @@ -127,6 +138,50 @@ variable "gcp" { condition = var.gcp.rollout.release_sha256 == "" || can(regex("^[0-9a-f]{64}$", var.gcp.rollout.release_sha256)) error_message = "gcp.rollout.release_sha256 must be empty or a lowercase SHA256 hex digest." } + + validation { + condition = ( + (var.gcp.rollout.release_url == "" || can(regex("^https://[^[:space:]]+$", var.gcp.rollout.release_url))) && + (var.gcp.rollout.jwks_uri == "" || can(regex("^https://[^[:space:]]+$", var.gcp.rollout.jwks_uri))) && + (trimspace(var.gcp.rollout.custom_claims) == "" || can(keys(jsondecode(var.gcp.rollout.custom_claims)))) + ) + error_message = "GCP rollout release_url and jwks_uri must use HTTPS, and custom_claims must be empty or a JSON object." + } + + validation { + condition = ( + var.gcp.rollout.port >= 1 && + var.gcp.rollout.port <= 65535 && + floor(var.gcp.rollout.port) == var.gcp.rollout.port && + (var.gcp.power_management.frontend == null ? true : ( + var.gcp.power_management.frontend.port >= 1 && + var.gcp.power_management.frontend.port <= 65535 && + floor(var.gcp.power_management.frontend.port) == var.gcp.power_management.frontend.port + )) + ) + error_message = "gcp.rollout.port and gcp.power_management.frontend.port must be whole numbers between 1 and 65535." + } + + validation { + condition = ( + can(cidrhost(var.gcp.network.ip_cidr_range, 0)) && + length(regexall(":", var.gcp.network.ip_cidr_range)) == 0 && + alltrue([for cidr in var.gcp.network.power_button_allowed_ips : can(cidrhost(cidr, 0))]) && + alltrue([for cidr in var.gcp.network.ssh_ipv4 : can(cidrhost(cidr, 0)) && length(regexall(":", cidr)) == 0]) && + alltrue([for cidr in var.gcp.network.ssh_ipv6 : can(cidrhost(cidr, 0)) && length(regexall(":", cidr)) > 0]) && + alltrue([for cidr in var.gcp.rollout.allowed_ipv4 : can(cidrhost(cidr, 0)) && length(regexall(":", cidr)) == 0]) + ) + error_message = "GCP network CIDRs must be valid and match their advertised IPv4 or IPv6 family." + } + + validation { + condition = var.gcp.network.power_button_ip_depth == null ? true : ( + var.gcp.network.power_button_ip_depth >= 0 && + var.gcp.network.power_button_ip_depth <= 10 && + floor(var.gcp.network.power_button_ip_depth) == var.gcp.network.power_button_ip_depth + ) + error_message = "gcp.network.power_button_ip_depth must be null or a whole number from 0 through 10." + } } variable "runtime" { @@ -171,8 +226,8 @@ variable "runtime" { sitectl_context_name = optional(string) sitectl_plugin = optional(string) sitectl_environment = optional(string) - sitectl_packages = optional(list(string), []) - sitectl_verify_args = optional(list(string), []) + sitectl_packages = optional(list(string)) + sitectl_verify_args = optional(list(string)) docker_compose_init = optional(list(string)) docker_compose_up = optional(list(string)) docker_compose_down = optional(list(string)) @@ -185,12 +240,13 @@ variable "runtime" { }), {}) sitectl = optional(object({ - packages = optional(list(string), ["sitectl"]) - version = optional(string, "latest") - context_name = optional(string, "") - plugin = optional(string, "core") - environment = optional(string, "production") - verify_args = optional(list(string), []) + packages = optional(list(string)) + version = optional(string, "latest") + package_versions = optional(map(string), {}) + context_name = optional(string, "") + plugin = optional(string, "core") + environment = optional(string, "production") + verify_args = optional(list(string), []) }), {}) docker = optional(object({ @@ -202,8 +258,8 @@ variable "runtime" { managed_runtime = optional(object({ enabled = optional(bool, true) - internal_services_enabled = optional(bool, true) - internal_services_auto_update = optional(bool, true) + internal_services_enabled = optional(bool, false) + internal_services_auto_update = optional(bool, false) artifacts = optional(list(object({ name = string url = string @@ -237,28 +293,78 @@ variable "runtime" { }) default = {} + validation { + condition = ( + (trimspace(var.runtime.rootfs_archive_url) == "") == (trimspace(var.runtime.rootfs_archive_sha256) == "") && + (trimspace(var.runtime.rootfs_archive_sha256) == "" || can(regex("^[0-9a-fA-F]{64}$", trimspace(var.runtime.rootfs_archive_sha256)))) + ) + error_message = "runtime.rootfs_archive_url and a 64-character runtime.rootfs_archive_sha256 must be supplied together." + } + validation { condition = alltrue([ - for name, app in var.runtime.compose.projects : - can(regex("^[a-z][a-z0-9-]*$", name)) && - trimspace(app.docker_compose_repo) != "" && - try(app.ingress_port, var.runtime.compose.ingress_port) > 0 && - try(app.ingress_port, var.runtime.compose.ingress_port) <= 65535 + for name in keys(var.runtime.extra_env) : + can(regex("^[A-Za-z_][A-Za-z0-9_]*$", name)) && + !contains(["HOME", "PATH"], name) && + alltrue([ + for prefix in [ + "CLOUD_COMPOSE_", + "COMPOSE_", + "DOCKER_", + "SITECTL_", + "LIBOPS_", + "GCP_", + "VAULT_", + "ROLLOUT_", + "POWER_MANAGEMENT_", + ] : !startswith(name, prefix) + ]) ]) - error_message = "runtime.compose.projects keys must match ^[a-z][a-z0-9-]*$, docker_compose_repo is required, and ingress_port must be between 1 and 65535." + error_message = "runtime.extra_env names must be valid environment names and must not override cloud-compose control-plane keys (HOME, PATH, or CLOUD_COMPOSE_/COMPOSE_/DOCKER_/SITECTL_/LIBOPS_/GCP_/VAULT_/ROLLOUT_/POWER_MANAGEMENT_ prefixes)." + } + + validation { + condition = ( + var.runtime.compose.ingress_port >= 1 && + var.runtime.compose.ingress_port <= 65535 && + floor(var.runtime.compose.ingress_port) == var.runtime.compose.ingress_port && + alltrue([ + for name, app in var.runtime.compose.projects : + can(regex("^[a-z][a-z0-9-]*$", name)) && + trimspace(app.docker_compose_repo) != "" && + coalesce(try(app.ingress_port, null), var.runtime.compose.ingress_port) >= 1 && + coalesce(try(app.ingress_port, null), var.runtime.compose.ingress_port) <= 65535 && + floor(coalesce(try(app.ingress_port, null), var.runtime.compose.ingress_port)) == coalesce(try(app.ingress_port, null), var.runtime.compose.ingress_port) + ]) + ) + error_message = "runtime.compose.projects keys must match ^[a-z][a-z0-9-]*$, docker_compose_repo is required, and ingress ports must be whole numbers between 1 and 65535." } validation { condition = alltrue([ - for package in var.runtime.sitectl.packages : + for package in coalesce(var.runtime.sitectl.packages, []) : can(regex("^sitectl(-[a-z0-9]+)*$", package)) ]) error_message = "runtime.sitectl.packages entries must be release package names such as sitectl, sitectl-isle, or sitectl-wp." } validation { - condition = var.runtime.sitectl.version == "latest" || can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+", var.runtime.sitectl.version)) - error_message = "runtime.sitectl.version must be latest or a release tag such as v0.19.7." + condition = ( + var.runtime.sitectl.version == "latest" || + can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$", var.runtime.sitectl.version)) + ) + error_message = "runtime.sitectl.version must be latest or an exact semantic-version release tag such as v0.38.0." + } + + validation { + condition = alltrue([ + for package, version in var.runtime.sitectl.package_versions : + can(regex("^sitectl(-[a-z0-9]+)*$", package)) && ( + version == "latest" || + can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$", version)) + ) + ]) + error_message = "runtime.sitectl.package_versions keys must be sitectl package names and values must be latest or exact semantic-version release tags." } validation { diff --git a/providers/linode/main.tf b/providers/linode/main.tf index 02e7811..f43f9f6 100644 --- a/providers/linode/main.tf +++ b/providers/linode/main.tf @@ -1,5 +1,16 @@ terraform { - required_version = ">= 1.2.4" + required_version = ">= 1.3.0" + + required_providers { + cloudinit = { + source = "hashicorp/cloudinit" + version = "~> 2.3" + } + linode = { + source = "linode/linode" + version = "~> 4.0" + } + } } locals { @@ -13,6 +24,16 @@ locals { input_compose = var.runtime.compose input_sitectl = var.runtime.sitectl + sitectl_packages = distinct(concat( + ["sitectl"], + local.input_sitectl.packages == null ? local.template.packages : local.input_sitectl.packages, + )) + template_sitectl_package_versions = { + for package in local.sitectl_packages : + package => local.template.package_versions[package] + if contains(keys(local.template.package_versions), package) + } + runtime = merge(var.runtime, { compose = merge(local.input_compose, { repo = ( @@ -27,11 +48,8 @@ locals { ) }) sitectl = merge(local.input_sitectl, { - packages = ( - local.template_name != "" && length(local.input_sitectl.packages) == 1 && local.input_sitectl.packages[0] == "sitectl" - ? local.template.packages - : local.input_sitectl.packages - ) + packages = local.sitectl_packages + package_versions = merge(local.template_sitectl_package_versions, local.input_sitectl.package_versions) plugin = ( local.template_name != "" && local.input_sitectl.plugin == "core" ? local.template.plugin diff --git a/providers/linode/outputs.tf b/providers/linode/outputs.tf index 3b431aa..2f08930 100644 --- a/providers/linode/outputs.tf +++ b/providers/linode/outputs.tf @@ -30,7 +30,7 @@ output "internal_ip" { output "volumes" { value = module.linode.volumes - description = "Selected provider persistent volume details where available." + description = "Selected provider persistent application-data and Docker-volume details." } output "serviceGsa" { @@ -67,3 +67,8 @@ output "primary_compose_project" { value = module.linode.primary_compose_project description = "Normalized primary compose project." } + +output "sitectl_package_versions" { + value = module.linode.sitectl_package_versions + description = "Effective release selector for every installed sitectl package; values may be exact tags or latest." +} diff --git a/providers/linode/template_versions.tftest.hcl b/providers/linode/template_versions.tftest.hcl new file mode 100644 index 0000000..a61e428 --- /dev/null +++ b/providers/linode/template_versions.tftest.hcl @@ -0,0 +1,63 @@ +mock_provider "cloudinit" {} +mock_provider "linode" {} + +run "custom_package_set_merges_only_applicable_template_versions" { + command = plan + + variables { + name = "template-versions" + template = "isle" + linode = { + instance = { + authorized_keys = ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestOnlyKey cloud-compose-test"] + } + } + runtime = { + rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz" + rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + sitectl = { + packages = ["sitectl", "sitectl-wp"] + package_versions = { + sitectl = "v0.40.0" + sitectl-wp = "v0.5.1" + } + } + } + } + + assert { + condition = local.runtime.sitectl.package_versions == { + sitectl = "v0.40.0" + sitectl-wp = "v0.5.1" + } + error_message = "The Linode entrypoint must filter template selectors and preserve explicit overrides." + } +} + +run "explicit_core_only_package_set_disables_template_plugins" { + command = plan + + variables { + name = "template-versions" + template = "isle" + linode = { + instance = { + authorized_keys = ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestOnlyKey cloud-compose-test"] + } + } + runtime = { + rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz" + rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + sitectl = { + packages = [] + } + } + } + + assert { + condition = local.runtime.sitectl.packages == tolist(["sitectl"]) && local.runtime.sitectl.package_versions == { + sitectl = "v0.39.0" + } + error_message = "The Linode entrypoint must preserve an explicit core-only package set." + } +} diff --git a/providers/linode/variables.tf b/providers/linode/variables.tf index 36ad524..4ad3d7d 100644 --- a/providers/linode/variables.tf +++ b/providers/linode/variables.tf @@ -20,7 +20,7 @@ variable "template" { } variable "linode" { - description = "Linode infrastructure settings." + description = "Linode infrastructure settings. instance.backups_enabled covers only the instance disk; attached application and Docker Block Storage volumes require a separate offsite backup policy." type = object({ region = optional(string, "us-east") tags = optional(list(string), ["cloud-compose"]) @@ -55,6 +55,15 @@ variable "linode" { }), {}) }) default = {} + + validation { + condition = alltrue([ + for key in var.linode.instance.authorized_keys : trimspace(key) != "" && !can(regex("[\\r\\n]", key)) + ]) && alltrue([ + for username in var.linode.instance.authorized_users : can(regex("^[A-Za-z0-9._-]+$", username)) + ]) + error_message = "linode.instance authorized_keys must be non-empty single-line values and authorized_users must contain safe single-line usernames." + } } variable "runtime" { @@ -63,6 +72,7 @@ variable "runtime" { rootfs = optional(string, "") rootfs_archive_url = optional(string, "") rootfs_archive_sha256 = optional(string, "") + users = optional(map(list(string)), {}) compose = optional(object({ primary = optional(string, "") @@ -98,8 +108,8 @@ variable "runtime" { sitectl_context_name = optional(string) sitectl_plugin = optional(string) sitectl_environment = optional(string) - sitectl_packages = optional(list(string), []) - sitectl_verify_args = optional(list(string), []) + sitectl_packages = optional(list(string)) + sitectl_verify_args = optional(list(string)) docker_compose_init = optional(list(string)) docker_compose_up = optional(list(string)) docker_compose_down = optional(list(string)) @@ -112,12 +122,13 @@ variable "runtime" { }), {}) sitectl = optional(object({ - packages = optional(list(string), ["sitectl"]) - version = optional(string, "latest") - context_name = optional(string, "") - plugin = optional(string, "core") - environment = optional(string, "production") - verify_args = optional(list(string), []) + packages = optional(list(string)) + version = optional(string, "latest") + package_versions = optional(map(string), {}) + context_name = optional(string, "") + plugin = optional(string, "core") + environment = optional(string, "production") + verify_args = optional(list(string), []) }), {}) docker = optional(object({ @@ -163,19 +174,82 @@ variable "runtime" { }) default = {} + validation { + condition = ( + (trimspace(var.runtime.rootfs_archive_url) == "") == (trimspace(var.runtime.rootfs_archive_sha256) == "") && + (trimspace(var.runtime.rootfs_archive_sha256) == "" || can(regex("^[0-9a-fA-F]{64}$", trimspace(var.runtime.rootfs_archive_sha256)))) + ) + error_message = "runtime.rootfs_archive_url and a 64-character runtime.rootfs_archive_sha256 must be supplied together." + } + + validation { + condition = alltrue([ + for name in keys(var.runtime.extra_env) : + can(regex("^[A-Za-z_][A-Za-z0-9_]*$", name)) && + !contains(["HOME", "PATH"], name) && + alltrue([ + for prefix in [ + "CLOUD_COMPOSE_", + "COMPOSE_", + "DOCKER_", + "SITECTL_", + "LIBOPS_", + "GCP_", + "VAULT_", + "ROLLOUT_", + "POWER_MANAGEMENT_", + ] : !startswith(name, prefix) + ]) + ]) + error_message = "runtime.extra_env names must be valid environment names and must not override cloud-compose control-plane keys (HOME, PATH, or CLOUD_COMPOSE_/COMPOSE_/DOCKER_/SITECTL_/LIBOPS_/GCP_/VAULT_/ROLLOUT_/POWER_MANAGEMENT_ prefixes)." + } + + validation { + condition = ( + var.runtime.compose.ingress_port >= 1 && + var.runtime.compose.ingress_port <= 65535 && + floor(var.runtime.compose.ingress_port) == var.runtime.compose.ingress_port && + alltrue([ + for name, app in var.runtime.compose.projects : + can(regex("^[a-z][a-z0-9-]*$", name)) && + trimspace(app.docker_compose_repo) != "" && + coalesce(try(app.ingress_port, null), var.runtime.compose.ingress_port) >= 1 && + coalesce(try(app.ingress_port, null), var.runtime.compose.ingress_port) <= 65535 && + floor(coalesce(try(app.ingress_port, null), var.runtime.compose.ingress_port)) == coalesce(try(app.ingress_port, null), var.runtime.compose.ingress_port) + ]) + ) + error_message = "runtime.compose.projects keys must match ^[a-z][a-z0-9-]*$, docker_compose_repo is required, and ingress ports must be whole numbers between 1 and 65535." + } + + validation { + condition = alltrue([ + for package in coalesce(var.runtime.sitectl.packages, []) : + can(regex("^sitectl(-[a-z0-9]+)*$", package)) + ]) + error_message = "runtime.sitectl.packages entries must be release package names such as sitectl, sitectl-isle, or sitectl-wp." + } + + validation { + condition = ( + var.runtime.sitectl.version == "latest" || + can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$", var.runtime.sitectl.version)) + ) + error_message = "runtime.sitectl.version must be latest or an exact semantic-version release tag such as v0.38.0." + } + validation { condition = alltrue([ - for name, app in var.runtime.compose.projects : - can(regex("^[a-z][a-z0-9-]*$", name)) && - trimspace(app.docker_compose_repo) != "" && - try(app.ingress_port, var.runtime.compose.ingress_port) > 0 && - try(app.ingress_port, var.runtime.compose.ingress_port) <= 65535 + for package, version in var.runtime.sitectl.package_versions : + can(regex("^sitectl(-[a-z0-9]+)*$", package)) && ( + version == "latest" || + can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$", version)) + ) ]) - error_message = "runtime.compose.projects keys must match ^[a-z][a-z0-9-]*$, docker_compose_repo is required, and ingress_port must be between 1 and 65535." + error_message = "runtime.sitectl.package_versions keys must be sitectl package names and values must be latest or exact semantic-version release tags." } validation { - condition = contains(["gcp-iam", "consumer-managed"], var.runtime.vault.auth_method) - error_message = "runtime.vault.auth_method must be gcp-iam or consumer-managed." + condition = var.runtime.vault.auth_method == "consumer-managed" + error_message = "runtime.vault.auth_method must be consumer-managed on Linode." } } diff --git a/renovate.json5 b/renovate.json5 index f1ca847..169e5fc 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -16,6 +16,22 @@ ], versioningTemplate: '{{#if versioning}}{{{versioning}}}{{else}}semver{{/if}}', }, + { + customType: 'regex', + description: 'Update Docker Compose configuration-management defaults', + managerFilePatterns: [ + '/^ansible/roles/cloud_compose/defaults/main\\.yml$/', + '/^salt/cloud-compose/init\\.sls$/', + ], + matchStrings: [ + 'cloud_compose_docker_compose_version:\\s*(?v?[^\\s]+)', + "docker\\.get\\('compose_version',\\s*cc\\.get\\('docker_compose_version',\\s*'(?v?[^']+)'\\)\\)", + ], + datasourceTemplate: 'github-releases', + depNameTemplate: 'docker-compose', + packageNameTemplate: 'docker/compose', + versioningTemplate: 'semver', + }, { customType: 'regex', description: 'Update Docker Compose and Buildx shell fallback defaults', @@ -27,5 +43,40 @@ ], versioningTemplate: '{{#if versioning}}{{{versioning}}}{{else}}semver{{/if}}', }, + { + customType: 'regex', + description: 'Update the digest-pinned Alpine COS build environment', + managerFilePatterns: [ + '/(^|/)install-dependencies-cos\\.sh$/', + ], + matchStrings: [ + '# renovate: datasource=(?[^\\s]+) depName=(?[^\\s]+)(?: packageName=(?[^\\s]+))?(?: versioning=(?[^\\s]+))?\\s+ALPINE_BUILD_IMAGE="alpine:(?[^@"]+)@(?sha256:[0-9a-f]{64})"', + ], + versioningTemplate: '{{#if versioning}}{{{versioning}}}{{else}}docker{{/if}}', + }, + { + customType: 'regex', + description: 'Update the digest-pinned configuration-management test image', + managerFilePatterns: [ + '/(^|/)config-management(-cloud)?-smoke\\.sh$/', + ], + matchStrings: [ + '# renovate: datasource=(?[^\\s]+) depName=(?[^\\s]+)(?: packageName=(?[^\\s]+))?(?: versioning=(?[^\\s]+))?\\s+CONFIG_MANAGEMENT_IMAGE_DEFAULT="python:(?[^@"]+)@(?sha256:[0-9a-f]{64})"', + ], + versioningTemplate: '{{#if versioning}}{{{versioning}}}{{else}}docker{{/if}}', + }, + { + customType: 'regex', + description: 'Update pinned Python tools in the documentation image', + managerFilePatterns: [ + '/^docs/Dockerfile$/', + ], + matchStrings: [ + 'pip install (?uv)==(?[^\\s&]+)', + 'uv pip install [^\\n]* (?zensical)==(?[^\\s&]+)', + ], + datasourceTemplate: 'pypi', + versioningTemplate: 'pep440', + }, ], } diff --git a/rootfs/etc/systemd/system/cloud-compose-docker-prune.service b/rootfs/etc/systemd/system/cloud-compose-docker-prune.service new file mode 100644 index 0000000..03f8d29 --- /dev/null +++ b/rootfs/etc/systemd/system/cloud-compose-docker-prune.service @@ -0,0 +1,12 @@ +[Unit] +Description=Prune old unused Cloud Compose Docker data +Requires=docker.service +RequiresMountsFor=/mnt/disks/data /mnt/disks/volumes /mnt/disks/data/docker/volumes +After=docker.service + +[Service] +Type=oneshot +User=root +Group=root +ExecStart=/bin/bash /home/cloud-compose/docker-prune.sh +TimeoutStartSec=30min diff --git a/rootfs/etc/systemd/system/cloud-compose-docker-prune.timer b/rootfs/etc/systemd/system/cloud-compose-docker-prune.timer new file mode 100644 index 0000000..e1fb7dd --- /dev/null +++ b/rootfs/etc/systemd/system/cloud-compose-docker-prune.timer @@ -0,0 +1,13 @@ +[Unit] +Description=Prune old unused Cloud Compose Docker data daily + +[Timer] +OnBootSec=10m +OnUnitInactiveSec=24h +RandomizedDelaySec=2h +FixedRandomDelay=true +Persistent=true +Unit=cloud-compose-docker-prune.service + +[Install] +WantedBy=timers.target diff --git a/rootfs/etc/systemd/system/internal-services.service b/rootfs/etc/systemd/system/cloud-compose-internal-services.service similarity index 51% rename from rootfs/etc/systemd/system/internal-services.service rename to rootfs/etc/systemd/system/cloud-compose-internal-services.service index 4834638..e4eb748 100644 --- a/rootfs/etc/systemd/system/internal-services.service +++ b/rootfs/etc/systemd/system/cloud-compose-internal-services.service @@ -1,9 +1,9 @@ [Unit] -Description=Internal Services (Ping, Metrics, Power Management) +Description=Cloud Compose Internal Services (Ping, Metrics, Power Management) BindsTo=docker.service -After=cloud-compose.service -Requires=internal-services.timer -After=internal-services.timer +Requires=cloud-compose.service cloud-compose-metadata-firewall.service +RequiresMountsFor=/mnt/disks/data /mnt/disks/volumes /mnt/disks/data/docker/volumes +After=docker.service cloud-compose-metadata-firewall.service cloud-compose.service StartLimitIntervalSec=120 StartLimitBurst=3 @@ -16,6 +16,7 @@ ExecStart=/usr/bin/docker compose up ExecStop=/usr/bin/docker compose down Restart=on-failure RestartSec=30s +TimeoutStopSec=10min [Install] WantedBy=multi-user.target diff --git a/rootfs/etc/systemd/system/cloud-compose-internal-services.timer b/rootfs/etc/systemd/system/cloud-compose-internal-services.timer new file mode 100644 index 0000000..e6e029c --- /dev/null +++ b/rootfs/etc/systemd/system/cloud-compose-internal-services.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Delay Cloud Compose internal services until 20m after initial boot + +[Timer] +# If no traffic follows creation, this machine may suspend after 21m30s. +OnBootSec=20min +Unit=cloud-compose-internal-services.service + +[Install] +WantedBy=timers.target diff --git a/rootfs/etc/systemd/system/cloud-compose-key-rotation.service b/rootfs/etc/systemd/system/cloud-compose-key-rotation.service new file mode 100644 index 0000000..b9b917d --- /dev/null +++ b/rootfs/etc/systemd/system/cloud-compose-key-rotation.service @@ -0,0 +1,12 @@ +[Unit] +Description=Rotate GCP service-account keys for Cloud Compose +Wants=network-online.target +After=network-online.target +RequiresMountsFor=/mnt/disks/data + +[Service] +Type=oneshot +ExecStart=/bin/bash /home/cloud-compose/rotate-keys-daily.sh +User=root +Group=root +TimeoutStartSec=1h diff --git a/rootfs/etc/systemd/system/cloud-compose-key-rotation.timer b/rootfs/etc/systemd/system/cloud-compose-key-rotation.timer new file mode 100644 index 0000000..b389ab3 --- /dev/null +++ b/rootfs/etc/systemd/system/cloud-compose-key-rotation.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Rotate Cloud Compose GCP service-account keys daily + +[Timer] +OnCalendar=daily +Persistent=true +RandomizedDelaySec=6h +FixedRandomDelay=true +Unit=cloud-compose-key-rotation.service + +[Install] +WantedBy=timers.target diff --git a/rootfs/etc/systemd/system/cloud-compose-mariadb-backup.service b/rootfs/etc/systemd/system/cloud-compose-mariadb-backup.service index 1337d52..bc45f65 100644 --- a/rootfs/etc/systemd/system/cloud-compose-mariadb-backup.service +++ b/rootfs/etc/systemd/system/cloud-compose-mariadb-backup.service @@ -1,11 +1,11 @@ [Unit] Description=Cloud Compose MariaDB Backup After=cloud-compose.service -Wants=cloud-compose.service +RequiresMountsFor=/mnt/disks/data [Service] Type=oneshot User=cloud-compose Group=cloud-compose -EnvironmentFile=/home/cloud-compose/.env ExecStart=/bin/bash /home/cloud-compose/mariadb-backup.sh +TimeoutStartSec=12h diff --git a/rootfs/etc/systemd/system/cloud-compose-metadata-firewall-pre.service b/rootfs/etc/systemd/system/cloud-compose-metadata-firewall-pre.service new file mode 100644 index 0000000..fb04f77 --- /dev/null +++ b/rootfs/etc/systemd/system/cloud-compose-metadata-firewall-pre.service @@ -0,0 +1,14 @@ +[Unit] +Description=Install the pre-Docker GCP metadata guard +DefaultDependencies=no +After=local-fs.target +Before=docker.service + +[Service] +Type=oneshot +ExecStart=/bin/bash /home/cloud-compose/configure-metadata-firewall.sh pre-docker +RemainAfterExit=yes +TimeoutStartSec=2min + +[Install] +WantedBy=multi-user.target diff --git a/rootfs/etc/systemd/system/cloud-compose-metadata-firewall.service b/rootfs/etc/systemd/system/cloud-compose-metadata-firewall.service new file mode 100644 index 0000000..40dec57 --- /dev/null +++ b/rootfs/etc/systemd/system/cloud-compose-metadata-firewall.service @@ -0,0 +1,14 @@ +[Unit] +Description=Isolate GCP metadata from containers and unprivileged host processes +Requires=docker.service +After=docker.service +PartOf=docker.service + +[Service] +Type=oneshot +ExecStart=/bin/bash /home/cloud-compose/configure-metadata-firewall.sh +RemainAfterExit=yes +TimeoutStartSec=2min + +[Install] +WantedBy=multi-user.target diff --git a/rootfs/etc/systemd/system/cloud-compose-overlay.service b/rootfs/etc/systemd/system/cloud-compose-overlay.service new file mode 100644 index 0000000..c280d3f --- /dev/null +++ b/rootfs/etc/systemd/system/cloud-compose-overlay.service @@ -0,0 +1,15 @@ +[Unit] +Description=Mount Cloud Compose Docker volume overlays +Before=docker.service cloud-compose.service +RequiresMountsFor=/mnt/disks/data /mnt/disks/volumes /mnt/disks/data/docker/volumes + +[Service] +Type=oneshot +User=root +Group=root +EnvironmentFile=-/home/cloud-compose/.env +ExecStart=/bin/bash /home/cloud-compose/mount-overlays.sh +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target diff --git a/rootfs/etc/systemd/system/cloud-compose-rollout.service b/rootfs/etc/systemd/system/cloud-compose-rollout.service index 026ae66..40ab9de 100644 --- a/rootfs/etc/systemd/system/cloud-compose-rollout.service +++ b/rootfs/etc/systemd/system/cloud-compose-rollout.service @@ -2,13 +2,13 @@ Description=Cloud Compose Rollout API After=network-online.target cloud-compose.service Wants=network-online.target +RequiresMountsFor=/mnt/disks/data [Service] User=root Group=root -EnvironmentFile=/home/cloud-compose/.env WorkingDirectory=/mnt/disks/data -ExecStart=/usr/local/bin/cloud-compose-rollout +ExecStart=/bin/bash /home/cloud-compose/run-rollout-service.sh Restart=on-failure RestartSec=5s diff --git a/rootfs/etc/systemd/system/vault-agent.service b/rootfs/etc/systemd/system/cloud-compose-vault-agent.service similarity index 58% rename from rootfs/etc/systemd/system/vault-agent.service rename to rootfs/etc/systemd/system/cloud-compose-vault-agent.service index e61268d..d3d1f63 100644 --- a/rootfs/etc/systemd/system/vault-agent.service +++ b/rootfs/etc/systemd/system/cloud-compose-vault-agent.service @@ -3,14 +3,19 @@ Description=Cloud Compose Vault Agent After=network-online.target Wants=network-online.target ConditionPathExists=/etc/vault-agent.d/cloud-compose.hcl +RequiresMountsFor=/mnt/disks/data [Service] User=root Group=root EnvironmentFile=-/etc/default/vault-agent +ExecStartPre=/bin/bash /home/cloud-compose/vault-agent-readiness.sh prepare ExecStart=/usr/local/bin/vault agent -config=/etc/vault-agent.d/cloud-compose.hcl +ExecStartPost=/bin/bash /home/cloud-compose/vault-agent-readiness.sh wait +ExecStopPost=/bin/bash /home/cloud-compose/vault-agent-readiness.sh clear Restart=on-failure RestartSec=10s +TimeoutStartSec=2min [Install] WantedBy=multi-user.target diff --git a/rootfs/etc/systemd/system/cloud-compose.service b/rootfs/etc/systemd/system/cloud-compose.service index ddeecd7..bc7fd51 100644 --- a/rootfs/etc/systemd/system/cloud-compose.service +++ b/rootfs/etc/systemd/system/cloud-compose.service @@ -1,16 +1,21 @@ [Unit] Description=Docker Compose Application PartOf=docker.service -After=docker.service +Requires=docker.service cloud-compose-metadata-firewall.service +RequiresMountsFor=/mnt/disks/data /mnt/disks/volumes /mnt/disks/data/docker/volumes +Wants=network-online.target +After=network-online.target docker.service cloud-compose-metadata-firewall.service cloud-compose-vault-agent.service [Service] Type=oneshot RemainAfterExit=yes User=cloud-compose Group=cloud-compose -EnvironmentFile=/home/cloud-compose/.env +ExecStartPre=/bin/bash /home/cloud-compose/assert-vault-ready.sh ExecStart=/bin/bash /home/cloud-compose/up ExecStop=/bin/bash /home/cloud-compose/down +TimeoutStartSec=45min +TimeoutStopSec=15min [Install] WantedBy=multi-user.target diff --git a/rootfs/etc/systemd/system/cron.service b/rootfs/etc/systemd/system/cron.service deleted file mode 100644 index c7b4cbb..0000000 --- a/rootfs/etc/systemd/system/cron.service +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=cron -After=internal-services.service - -[Service] -Type=oneshot -ExecStart=/bin/bash /home/cloud-compose/cron.sh -User=root -Group=root -SuccessExitStatus=0 diff --git a/rootfs/etc/systemd/system/cron.timer b/rootfs/etc/systemd/system/cron.timer deleted file mode 100644 index 7a56faa..0000000 --- a/rootfs/etc/systemd/system/cron.timer +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=cron - -[Timer] -OnBootSec=10m -OnUnitInactiveSec=24h -WakeSystem=true - -[Install] -WantedBy=timers.target diff --git a/rootfs/etc/systemd/system/docker.service.d/cloud-compose-metadata-firewall.conf b/rootfs/etc/systemd/system/docker.service.d/cloud-compose-metadata-firewall.conf new file mode 100644 index 0000000..2bfcc2d --- /dev/null +++ b/rootfs/etc/systemd/system/docker.service.d/cloud-compose-metadata-firewall.conf @@ -0,0 +1,3 @@ +[Unit] +Requires=cloud-compose-metadata-firewall-pre.service +After=cloud-compose-metadata-firewall-pre.service diff --git a/rootfs/etc/systemd/system/docker.service.d/cloud-compose-mounts.conf b/rootfs/etc/systemd/system/docker.service.d/cloud-compose-mounts.conf new file mode 100644 index 0000000..509e9e4 --- /dev/null +++ b/rootfs/etc/systemd/system/docker.service.d/cloud-compose-mounts.conf @@ -0,0 +1,4 @@ +[Unit] +Requires=cloud-compose-overlay.service +RequiresMountsFor=/mnt/disks/data /mnt/disks/volumes /mnt/disks/data/docker/volumes +After=mnt-disks-data.mount mnt-disks-volumes.mount mnt-disks-data-docker-volumes.mount cloud-compose-overlay.service diff --git a/rootfs/etc/systemd/system/internal-services.timer b/rootfs/etc/systemd/system/internal-services.timer deleted file mode 100644 index 64035f1..0000000 --- a/rootfs/etc/systemd/system/internal-services.timer +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=Delay Internal Services until 20m after initial boot - -[Timer] -# if no traffic after creation this machine will suspend after 21m30s -OnBootSec=20min -Unit=internal-services.service - -[Install] -WantedBy=timers.target diff --git a/rootfs/etc/systemd/system/libops-managed-runtime.service b/rootfs/etc/systemd/system/libops-managed-runtime.service index 25a71aa..5ddf8ec 100644 --- a/rootfs/etc/systemd/system/libops-managed-runtime.service +++ b/rootfs/etc/systemd/system/libops-managed-runtime.service @@ -2,14 +2,14 @@ Description=Update LibOps Managed Runtime After=network-online.target docker.service Wants=network-online.target +RequiresMountsFor=/mnt/disks/data /mnt/disks/volumes /mnt/disks/data/docker/volumes [Service] Type=oneshot User=root Group=root -EnvironmentFile=/home/cloud-compose/.env ExecStart=/bin/bash /home/cloud-compose/libops-managed-runtime.sh update +TimeoutStartSec=1h StandardOutput=journal StandardError=journal SyslogIdentifier=libops-managed-runtime - diff --git a/rootfs/etc/tmpfiles.d/cloud-compose.conf b/rootfs/etc/tmpfiles.d/cloud-compose.conf new file mode 100644 index 0000000..9c5eb15 --- /dev/null +++ b/rootfs/etc/tmpfiles.d/cloud-compose.conf @@ -0,0 +1,2 @@ +d /run/lock/cloud-compose 0750 root cloud-compose - +f /run/lock/cloud-compose/lifecycle.lock 0660 root cloud-compose - diff --git a/rootfs/home/cloud-compose/app-init.sh b/rootfs/home/cloud-compose/app-init.sh index d49e5ba..80d4d95 100644 --- a/rootfs/home/cloud-compose/app-init.sh +++ b/rootfs/home/cloud-compose/app-init.sh @@ -10,20 +10,23 @@ cd /home/cloud-compose # shellcheck disable=SC1091 source /home/cloud-compose/compose-apps.sh -while read -r app; do - if [ -z "$app" ]; then - continue - fi - - clone_or_update_compose_app "$app" +apps=() +compose_app_names_array apps +for app in "${apps[@]}"; do source_compose_app_env "$app" + if [[ ! -d "$DOCKER_COMPOSE_DIR/.git" ]]; then + echo "Compose source was not prepared before application initialization: $DOCKER_COMPOSE_DIR" >&2 + exit 1 + fi + pushd "$DOCKER_COMPOSE_DIR" >/dev/null scaffold_compose_app_defaults "$app" - update_env COMPOSE_PROJECT_NAME "$COMPOSE_PROJECT_NAME" - update_env SITE_NAME "${CLOUD_COMPOSE_INSTANCE_NAME:-${GCP_INSTANCE_NAME:-$app}}" - update_env COMPOSE_BIND_PORT "$COMPOSE_BIND_PORT" + sync_compose_application_env + update_compose_env COMPOSE_PROJECT_NAME "$COMPOSE_PROJECT_NAME" + update_compose_env SITE_NAME "${CLOUD_COMPOSE_INSTANCE_NAME:-${GCP_INSTANCE_NAME:-$app}}" + update_compose_env COMPOSE_BIND_PORT "$COMPOSE_BIND_PORT" run_compose_app_lifecycle "$app" init configure_sitectl_app_features "$app" popd >/dev/null -done < <(compose_app_names) +done diff --git a/rootfs/home/cloud-compose/assert-vault-ready.sh b/rootfs/home/cloud-compose/assert-vault-ready.sh new file mode 100644 index 0000000..4662a49 --- /dev/null +++ b/rootfs/home/cloud-compose/assert-vault-ready.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +set -euo pipefail + +profile_path="${CLOUD_COMPOSE_PROFILE_PATH:-/home/cloud-compose/profile.sh}" +# shellcheck disable=SC1090 +source "$profile_path" + +case "${VAULT_AGENT_ENABLED:-false}" in + false) exit 0 ;; + true) ;; + *) + echo "VAULT_AGENT_ENABLED must be true or false" >&2 + exit 1 + ;; +esac + +ready_marker="${VAULT_AGENT_READY_MARKER:-/run/cloud-compose/vault-agent.ready}" +if [[ -L "$ready_marker" || ! -f "$ready_marker" ]]; then + echo "Vault Agent is enabled but has not published readiness" >&2 + exit 1 +fi +if ! systemctl is-active --quiet cloud-compose-vault-agent.service; then + echo "Vault Agent is enabled but its service is not active" >&2 + exit 1 +fi diff --git a/rootfs/home/cloud-compose/compose-apps.sh b/rootfs/home/cloud-compose/compose-apps.sh index f3d9b4f..df23bf8 100644 --- a/rootfs/home/cloud-compose/compose-apps.sh +++ b/rootfs/home/cloud-compose/compose-apps.sh @@ -4,6 +4,8 @@ set -euo pipefail COMPOSE_PROJECTS_FILE="${COMPOSE_PROJECTS_FILE:-/home/cloud-compose/compose-projects.json}" COMPOSE_APPS_ENV_DIR="${COMPOSE_APPS_ENV_DIR:-/home/cloud-compose/apps}" +COMPOSE_APPS_STATE_DIR="${COMPOSE_APPS_STATE_DIR:-/home/cloud-compose/state}" +CLOUD_COMPOSE_DATA_ROOT="${CLOUD_COMPOSE_DATA_ROOT:-/mnt/disks/data}" shell_env_line() { local name="$1" @@ -12,42 +14,293 @@ shell_env_line() { printf '%s=%q\n' "$name" "$value" } +validate_compose_app_name() { + local app="$1" + + if [[ ! "$app" =~ ^[a-z][a-z0-9-]*$ ]]; then + echo "Invalid cloud-compose app name: $app" >&2 + return 1 + fi +} + +validate_compose_project_dir() { + local project_dir="$1" + local data_root resolved_project_dir resolved_data_root + + data_root="${CLOUD_COMPOSE_DATA_ROOT%/}" + [[ -n "$data_root" && "$data_root" == /* && "$project_dir" == /* ]] || { + echo "Compose project directory must be absolute: $project_dir" >&2 + return 1 + } + [[ "$project_dir" != "$data_root" && "$project_dir" == "$data_root/"* ]] || { + echo "Compose project directory is outside the managed data boundary: $project_dir" >&2 + return 1 + } + [[ ! "$project_dir" =~ [[:cntrl:]] && "$project_dir" != *"//"* && "$project_dir" != */ ]] || { + echo "Compose project directory contains an unsafe segment: $project_dir" >&2 + return 1 + } + [[ ! "$project_dir" =~ (^|/)\.\.?(/|$) ]] || { + echo "Compose project directory contains a dot segment: $project_dir" >&2 + return 1 + } + + resolved_data_root="$(realpath -m -- "$data_root")" || return 1 + resolved_project_dir="$(realpath -m -- "$project_dir")" || return 1 + [[ "$resolved_project_dir" == "$resolved_data_root/"* ]] || { + echo "Compose project directory resolves outside the managed data boundary: $project_dir" >&2 + return 1 + } +} + +validate_compose_projects_manifest() { + if [[ -L "$COMPOSE_PROJECTS_FILE" || ! -f "$COMPOSE_PROJECTS_FILE" ]]; then + echo "Cloud-compose project manifest is missing or unsafe: $COMPOSE_PROJECTS_FILE" >&2 + return 1 + fi + + if ! jq -e ' + type == "object" and length > 0 and + all(to_entries[]; . as $entry | + ($entry.key | explode | index(0) == null) and + ($entry.value | type == "object") and + (all($entry.value | .. | select(type == "string"); + explode | index(0) == null)) and + ($entry.value.docker_compose_repo | type == "string" and length > 0) and + ($entry.value.docker_compose_branch | type == "string" and length > 0) and + ($entry.value.project_dir | type == "string" and length > 0) and + ($entry.value.compose_project_name | type == "string" and length > 0) and + (all(["init_commands", "up_commands", "down_commands", "rollout_commands"][]; + . as $field | + ($entry.value[$field] == null) or + (($entry.value[$field] | type) == "array" and all($entry.value[$field][]; type == "string")) + )) and + (($entry.value.sitectl_verify_args == null) or + (($entry.value.sitectl_verify_args | type) == "array" and all($entry.value.sitectl_verify_args[]; + type == "string" and + (explode | index(0) == null) and + (contains("\n") | not) and + (contains("\r") | not) + ))) and + (($entry.value.ingress == null) or ($entry.value.ingress | type == "object")) + ) + ' "$COMPOSE_PROJECTS_FILE" >/dev/null; then + echo "Invalid cloud-compose project manifest: $COMPOSE_PROJECTS_FILE" >&2 + return 1 + fi + + local encoded_app app encoded_project_dir project_dir + while IFS= read -r encoded_app; do + app="$( + if ! printf '%s' "$encoded_app" | base64 --decode; then + exit 1 + fi + printf '\037' + )" || return 1 + app="${app%$'\x1f'}" + validate_compose_app_name "$app" || return 1 + done < <(jq -r 'keys[] | @base64' "$COMPOSE_PROJECTS_FILE") + + while IFS= read -r encoded_project_dir; do + project_dir="$( + if ! printf '%s' "$encoded_project_dir" | base64 --decode; then + exit 1 + fi + printf '\037' + )" || return 1 + project_dir="${project_dir%$'\x1f'}" + validate_compose_project_dir "$project_dir" || return 1 + done < <(jq -r '.[] | .project_dir | @base64' "$COMPOSE_PROJECTS_FILE") +} + +compose_app_exists() { + local app="$1" + + validate_compose_app_name "$app" || return 1 + validate_compose_projects_manifest || return 1 + jq -e --arg app "$app" 'has($app)' "$COMPOSE_PROJECTS_FILE" >/dev/null || { + echo "Cloud-compose app is not present in the manifest: $app" >&2 + return 1 + } +} + +compose_app_names_array() { + local result_name="$1" + local names app + local -n "result=$result_name" + + validate_compose_projects_manifest || return 1 + names="$(jq -er 'keys[]' "$COMPOSE_PROJECTS_FILE")" || return 1 + result=() + while IFS= read -r app; do + validate_compose_app_name "$app" || return 1 + result+=("$app") + done <<<"$names" +} + compose_app_names() { - jq -r 'keys[]' "$COMPOSE_PROJECTS_FILE" + local -a apps=() + + compose_app_names_array apps || return 1 + printf '%s\n' "${apps[@]}" } compose_app_field() { local app="$1" local field="$2" - jq -r --arg app "$app" --arg field "$field" '.[$app][$field] // ""' "$COMPOSE_PROJECTS_FILE" + compose_app_exists "$app" || return 1 + jq -er --arg app "$app" --arg field "$field" ' + (.[$app][$field] // "" | tostring) as $value | + if $value | (explode | index(0) != null) or contains("\n") or contains("\r") then + error("invalid scalar field") + else + $value + end + ' "$COMPOSE_PROJECTS_FILE" +} + +compose_app_array_values() { + local app="$1" + local field="$2" + local result_name="$3" + local payload encoded_lines encoded value + local -n "result=$result_name" + + compose_app_exists "$app" || return 1 + payload="$(jq -ce --arg app "$app" --arg field "$field" ' + (.[$app][$field] // []) as $values | + if ($values | type) != "array" or any($values[]; type != "string") then + error("invalid string array") + else + $values + end + ' "$COMPOSE_PROJECTS_FILE")" || { + echo "Invalid $field array for cloud-compose app $app" >&2 + return 1 + } + + result=() + encoded_lines="$(jq -r '.[] | @base64' <<<"$payload")" || return 1 + if [[ -z "$encoded_lines" ]]; then + return 0 + fi + while IFS= read -r encoded; do + value="$( + if ! printf '%s' "$encoded" | base64 -d; then + exit 1 + fi + printf '\037' + )" || return 1 + value="${value%$'\x1f'}" + result+=("$value") + done <<<"$encoded_lines" } compose_app_array() { local app="$1" local field="$2" + local value + local -a values=() - jq -r --arg app "$app" --arg field "$field" '.[$app][$field][]?' "$COMPOSE_PROJECTS_FILE" + compose_app_array_values "$app" "$field" values || return 1 + for value in "${values[@]}"; do + printf '%s\n' "$value" + done } compose_app_verify_args() { local app="$1" - jq -r --arg app "$app" '.[$app].sitectl_verify_args // [] | join(" ")' "$COMPOSE_PROJECTS_FILE" + compose_app_exists "$app" || return 1 + jq -er --arg app "$app" ' + (.[$app].sitectl_verify_args // []) as $values | + if ($values | type) != "array" or any($values[]; type != "string") then + error("invalid verify args") + else + $values | join(" ") + end + ' "$COMPOSE_PROJECTS_FILE" +} + +compose_app_verify_args_json() { + local app="$1" + + compose_app_exists "$app" || return 1 + jq -cer --arg app "$app" ' + (.[$app].sitectl_verify_args // []) as $values | + if ($values | type) != "array" or any($values[]; + type != "string" or (explode | index(0) != null) or contains("\n") or contains("\r") + ) then + error("invalid verify args") + else + $values + end + ' "$COMPOSE_PROJECTS_FILE" } compose_app_ingress_field() { local app="$1" local field="$2" - jq -r --arg app "$app" --arg field "$field" '.[$app].ingress[$field] // ""' "$COMPOSE_PROJECTS_FILE" + compose_app_exists "$app" || return 1 + jq -er --arg app "$app" --arg field "$field" ' + (.[$app].ingress[$field] // "" | tostring) as $value | + if $value | (explode | index(0) != null) or contains("\n") or contains("\r") then + error("invalid ingress scalar field") + else + $value + end + ' "$COMPOSE_PROJECTS_FILE" +} + +compose_app_ingress_array_values() { + local app="$1" + local field="$2" + local result_name="$3" + local payload encoded_lines encoded value + local -n "result=$result_name" + + compose_app_exists "$app" || return 1 + payload="$(jq -ce --arg app "$app" --arg field "$field" ' + (.[$app].ingress[$field] // []) as $values | + if ($values | type) != "array" or any($values[]; type != "string") then + error("invalid ingress string array") + else + $values + end + ' "$COMPOSE_PROJECTS_FILE")" || { + echo "Invalid ingress $field array for cloud-compose app $app" >&2 + return 1 + } + + result=() + encoded_lines="$(jq -r '.[] | @base64' <<<"$payload")" || return 1 + if [[ -z "$encoded_lines" ]]; then + return 0 + fi + while IFS= read -r encoded; do + value="$( + if ! printf '%s' "$encoded" | base64 -d; then + exit 1 + fi + printf '\037' + )" || return 1 + value="${value%$'\x1f'}" + result+=("$value") + done <<<"$encoded_lines" } compose_app_ingress_array() { local app="$1" local field="$2" + local value + local -a values=() - jq -r --arg app "$app" --arg field "$field" '.[$app].ingress[$field] // [] | .[]?' "$COMPOSE_PROJECTS_FILE" + compose_app_ingress_array_values "$app" "$field" values || return 1 + for value in "${values[@]}"; do + printf '%s\n' "$value" + done } sitectl_truthy() { @@ -267,8 +520,11 @@ scaffold_compose_app_defaults() { write_compose_app_env() { local app="$1" local env_file="${COMPOSE_APPS_ENV_DIR}/${app}.env" + local env_tmp - mkdir -p "$COMPOSE_APPS_ENV_DIR" + compose_app_exists "$app" || return 1 + install -d -m 0750 "$COMPOSE_APPS_ENV_DIR" + env_tmp="$(mktemp "${env_file}.tmp.XXXXXX")" || return 1 { shell_env_line APP_NAME "$app" shell_env_line DOCKER_COMPOSE_REPO "$(compose_app_field "$app" docker_compose_repo)" @@ -279,59 +535,339 @@ write_compose_app_env() { shell_env_line SITECTL_CONTEXT_NAME "$(compose_app_field "$app" sitectl_context_name)" shell_env_line SITECTL_PLUGIN "$(compose_app_field "$app" sitectl_plugin)" shell_env_line SITECTL_ENVIRONMENT "$(compose_app_field "$app" sitectl_environment)" - shell_env_line SITECTL_VERIFY_ARGS "$(compose_app_verify_args "$app")" - } > "$env_file" - chown cloud-compose:cloud-compose "$env_file" 2>/dev/null || true - chmod 0640 "$env_file" + # SITECTL_VERIFY_ARGS remains an empty compatibility scalar. Lifecycle + # shells use the JSON array below through an exported sitectl wrapper so + # an argument containing spaces remains exactly one argv element. + shell_env_line SITECTL_VERIFY_ARGS "" + shell_env_line SITECTL_VERIFY_ARGS_JSON "$(compose_app_verify_args_json "$app")" + } > "$env_tmp" + chown cloud-compose:cloud-compose "$env_tmp" 2>/dev/null || true + chmod 0640 "$env_tmp" + mv -f -- "$env_tmp" "$env_file" } source_compose_app_env() { local app="$1" - write_compose_app_env "$app" - set -a - # shellcheck disable=SC1090 - source "${COMPOSE_APPS_ENV_DIR}/${app}.env" - set +a + compose_app_exists "$app" || return 1 + if ((EUID != 0)); then + write_compose_app_env "$app" || return 1 + fi + + # Privileged callers must never source a shell file from the app-writable + # env directory. Materialize the same validated manifest values as data. + APP_NAME="$app" + DOCKER_COMPOSE_REPO="$(compose_app_field "$app" docker_compose_repo)" + DOCKER_COMPOSE_BRANCH="$(compose_app_field "$app" docker_compose_branch)" + DOCKER_COMPOSE_DIR="$(compose_app_field "$app" project_dir)" + COMPOSE_PROJECT_NAME="$(compose_app_field "$app" compose_project_name)" + COMPOSE_BIND_PORT="$(compose_app_field "$app" ingress_port)" + SITECTL_CONTEXT_NAME="$(compose_app_field "$app" sitectl_context_name)" + SITECTL_PLUGIN="$(compose_app_field "$app" sitectl_plugin)" + SITECTL_ENVIRONMENT="$(compose_app_field "$app" sitectl_environment)" + SITECTL_VERIFY_ARGS="" + SITECTL_VERIFY_ARGS_JSON="$(compose_app_verify_args_json "$app")" + export APP_NAME DOCKER_COMPOSE_REPO DOCKER_COMPOSE_BRANCH DOCKER_COMPOSE_DIR + export COMPOSE_PROJECT_NAME COMPOSE_BIND_PORT SITECTL_CONTEXT_NAME SITECTL_PLUGIN + export SITECTL_ENVIRONMENT SITECTL_VERIFY_ARGS SITECTL_VERIFY_ARGS_JSON +} + +configure_sitectl_verify_argv() { + # Resolve eagerly when available, but allow lifecycle command sets that do + # not invoke sitectl (for example a source-policy contract). The exported + # wrapper fails at the actual call site if the executable is missing. + SITECTL_EXECUTABLE="$(type -P sitectl || true)" + if ! jq -e 'type == "array" and all(.[]; type == "string")' \ + <<<"${SITECTL_VERIFY_ARGS_JSON:-[]}" >/dev/null; then + echo "Invalid sitectl verify argument JSON for ${APP_NAME:-unknown app}" >&2 + return 1 + fi + + sitectl() { + local encoded decoded executable + local -a configured_verify_args=() + + executable="${SITECTL_EXECUTABLE:-}" + if [[ -z "$executable" ]]; then + executable="$(type -P sitectl || true)" + fi + if [[ -z "$executable" ]]; then + echo "sitectl is not installed" >&2 + return 127 + fi + if [[ "${1:-}" == "verify" ]]; then + while IFS= read -r encoded; do + [[ -n "$encoded" ]] || continue + decoded="$(printf '%s' "$encoded" | base64 -d)" || return 1 + configured_verify_args+=("$decoded") + done < <(jq -r '.[] | @base64' <<<"${SITECTL_VERIFY_ARGS_JSON:-[]}") + fi + "$executable" "$@" "${configured_verify_args[@]}" + } + export SITECTL_EXECUTABLE SITECTL_VERIFY_ARGS SITECTL_VERIFY_ARGS_JSON + export -f sitectl } target_compose_apps() { local lifecycle="$1" + local -a apps=() + + target_compose_apps_array "$lifecycle" apps || return 1 + printf '%s\n' "${apps[@]}" +} + +target_compose_apps_array() { + local lifecycle="$1" + local result_name="$2" local target="${CLOUD_COMPOSE_APP:-${COMPOSE_APP:-${APP_NAME:-}}}" + local -n "result=$result_name" - if [ -z "$target" ] && [ "$lifecycle" = "rollout" ]; then + if [[ -z "$target" && "$lifecycle" == "rollout" ]]; then target="${CLOUD_COMPOSE_PRIMARY_APP:-}" fi + if [[ -n "$target" ]]; then + compose_app_exists "$target" || return 1 + result=("$target") + return 0 + fi + compose_app_names_array "$result_name" +} - if [ -n "$target" ]; then - printf '%s\n' "$target" +compose_ref_is_full_commit() { + [[ "$1" =~ ^[0-9a-fA-F]{40}$ ]] +} + +validate_compose_git_source() { + validate_compose_project_dir "$DOCKER_COMPOSE_DIR" || return 1 + if [[ -z "$DOCKER_COMPOSE_REPO" || "$DOCKER_COMPOSE_REPO" == -* || + "$DOCKER_COMPOSE_REPO" == *$'\n'* || "$DOCKER_COMPOSE_REPO" == *$'\r'* ]]; then + echo "Invalid Compose repository location for $DOCKER_COMPOSE_DIR" >&2 + return 1 + fi + if compose_ref_is_full_commit "$DOCKER_COMPOSE_BRANCH"; then return 0 fi + if [[ "$DOCKER_COMPOSE_BRANCH" == -* ]] || + ! git check-ref-format "refs/cloud-compose/$DOCKER_COMPOSE_BRANCH" >/dev/null 2>&1; then + echo "Invalid Compose Git ref: $DOCKER_COMPOSE_BRANCH" >&2 + return 1 + fi +} + +record_compose_app_head() { + local app="$1" + local head state_file state_tmp - compose_app_names + validate_compose_app_name "$app" || return 1 + head="$(git rev-parse --verify HEAD)" + if [[ ! "$head" =~ ^[0-9a-f]{40}$ ]]; then + echo "Could not resolve a full deployed Git commit for ${app}" >&2 + return 1 + fi + + install -d -m 0750 "$COMPOSE_APPS_STATE_DIR" + state_file="${COMPOSE_APPS_STATE_DIR}/${app}.deployed-head" + state_tmp="$(mktemp "${state_file}.tmp.XXXXXX")" + printf '%s\n' "$head" > "$state_tmp" + chmod 0640 "$state_tmp" + chown cloud-compose:cloud-compose "$state_tmp" 2>/dev/null || true + mv -f "$state_tmp" "$state_file" } -clone_or_update_compose_app() { +checkout_exact_compose_commit() { local app="$1" + local requested_commit="$2" + local deployed_head - source_compose_app_env "$app" + retry_until_success git fetch --force --no-tags -- origin "$requested_commit" || return 1 + if ! git cat-file -e "${requested_commit}^{commit}" 2>/dev/null; then + echo "Configured commit ${requested_commit} is not a commit in ${DOCKER_COMPOSE_REPO}" >&2 + return 1 + fi + + git checkout --detach "$requested_commit" || return 1 + deployed_head="$(git rev-parse --verify HEAD)" || return 1 + if [[ "${deployed_head,,}" != "${requested_commit,,}" ]]; then + echo "Expected ${app} at ${requested_commit}, but Git checked out ${deployed_head}" >&2 + return 1 + fi + + echo "Checked out ${app} at pinned commit ${deployed_head} (detached HEAD)." +} + +verify_compose_origin() { + local actual_origin + + actual_origin="$(git remote get-url origin 2>/dev/null)" || { + echo "Compose repository has no origin remote: $DOCKER_COMPOSE_DIR" >&2 + return 1 + } + if [[ "$actual_origin" != "$DOCKER_COMPOSE_REPO" ]]; then + echo "Compose origin mismatch for $DOCKER_COMPOSE_DIR: expected $DOCKER_COMPOSE_REPO, found $actual_origin" >&2 + return 1 + fi +} + +verify_clean_compose_checkout() { + local untracked_compose_control + + if ! git diff --quiet --ignore-submodules -- || + ! git diff --cached --quiet --ignore-submodules --; then + echo "Compose checkout contains tracked or staged changes; commit them in the downstream fork before deployment" >&2 + return 1 + fi + untracked_compose_control="$(git ls-files --others --exclude-standard -- \ + ':(glob)**/compose*.yml' ':(glob)**/compose*.yaml' \ + ':(glob)**/docker-compose*.yml' ':(glob)**/docker-compose*.yaml')" || return 1 + if [[ -n "$untracked_compose_control" ]]; then + echo "Compose checkout contains an untracked Compose control file: $untracked_compose_control" >&2 + return 1 + fi +} + +reject_host_network_compose_services() { + local config_json + + if [[ "${CLOUD_COMPOSE_PROVIDER:-}" != "gcp" ]]; then + return 0 + fi + if ! command -v docker >/dev/null 2>&1; then + echo "Docker is required to validate Compose network isolation" >&2 + return 1 + fi + config_json="$(docker compose config --format json)" || { + echo "Could not render Compose configuration for metadata-isolation validation" >&2 + return 1 + } + if ! jq -e '.services | type == "object"' <<<"$config_json" >/dev/null; then + echo "Docker Compose returned an invalid service configuration" >&2 + return 1 + fi + if jq -e ' + any(.services[]; + (.network_mode // "") == "host" or + ( + (.build | type) == "object" and + ( + (.build.network // "") == "host" or + any((.build.entitlements // [])[]; + . == "network.host" or . == "security.insecure" + ) + ) + ) + ) + ' <<<"$config_json" >/dev/null; then + echo "Host runtime/build networking and insecure BuildKit entitlements are not allowed on GCP because they bypass container metadata isolation" >&2 + return 1 + fi +} - git config --global --add safe.directory "$DOCKER_COMPOSE_DIR" +clone_or_update_compose_app() { + local app="$1" + local local_head fetched_head recorded_head="" + local deployed_state_file="${COMPOSE_APPS_STATE_DIR}/${app}.deployed-head" + + source_compose_app_env "$app" || return 1 + validate_compose_git_source || return 1 if [ ! -d "$DOCKER_COMPOSE_DIR/.git" ]; then echo "Directory '$DOCKER_COMPOSE_DIR' not found. Cloning repository for ${app}." - mkdir -p "$DOCKER_COMPOSE_DIR" - pushd "$DOCKER_COMPOSE_DIR" >/dev/null - retry_until_success git clone -b "$DOCKER_COMPOSE_BRANCH" "$DOCKER_COMPOSE_REPO" . + mkdir -p "$DOCKER_COMPOSE_DIR" || return 1 + pushd "$DOCKER_COMPOSE_DIR" >/dev/null || return 1 + if compose_ref_is_full_commit "$DOCKER_COMPOSE_BRANCH"; then + git init . || { popd >/dev/null; return 1; } + git remote add -- origin "$DOCKER_COMPOSE_REPO" || { popd >/dev/null; return 1; } + checkout_exact_compose_commit "$app" "$DOCKER_COMPOSE_BRANCH" || { popd >/dev/null; return 1; } + else + retry_until_success git clone -b "$DOCKER_COMPOSE_BRANCH" -- "$DOCKER_COMPOSE_REPO" . || { + popd >/dev/null + return 1 + } + fi + verify_clean_compose_checkout || { popd >/dev/null; return 1; } if [ "$(id -u)" -eq 0 ]; then - chown -R cloud-compose:cloud-compose . + chown -R cloud-compose:cloud-compose . || { popd >/dev/null; return 1; } fi popd >/dev/null else - pushd "$DOCKER_COMPOSE_DIR" >/dev/null - retry_until_success git pull origin "$DOCKER_COMPOSE_BRANCH" + pushd "$DOCKER_COMPOSE_DIR" >/dev/null || return 1 + verify_compose_origin || { popd >/dev/null; return 1; } + verify_clean_compose_checkout || { popd >/dev/null; return 1; } + if compose_ref_is_full_commit "$DOCKER_COMPOSE_BRANCH"; then + checkout_exact_compose_commit "$app" "$DOCKER_COMPOSE_BRANCH" || { popd >/dev/null; return 1; } + else + retry_until_success git fetch --prune -- origin "$DOCKER_COMPOSE_BRANCH" || { + popd >/dev/null + return 1 + } + local_head="$(git rev-parse --verify HEAD)" || { popd >/dev/null; return 1; } + if ! git merge-base --is-ancestor HEAD FETCH_HEAD; then + if [[ -e "$deployed_state_file" || -L "$deployed_state_file" ]]; then + if [[ -L "$deployed_state_file" || ! -f "$deployed_state_file" ]]; then + echo "Recorded Compose deployment state is unsafe for ${app}: $deployed_state_file" >&2 + popd >/dev/null + return 1 + fi + recorded_head="$(<"$deployed_state_file")" + if [[ ! "$recorded_head" =~ ^[0-9a-f]{40}$ ]]; then + echo "Recorded Compose deployment state is invalid for ${app}: $deployed_state_file" >&2 + popd >/dev/null + return 1 + fi + fi + if [[ -n "$recorded_head" && "$local_head" == "$recorded_head" ]]; then + # A successful rollout may intentionally leave a detached + # feature/PR commit divergent from the baseline. Explicit + # source preparation or init may restore that baseline, but + # only when HEAD is exactly the commit we previously + # recorded after a successful lifecycle. + git checkout --detach FETCH_HEAD || { popd >/dev/null; return 1; } + else + echo "Local Compose HEAD is not an ancestor of origin/$DOCKER_COMPOSE_BRANCH and does not match the recorded deployment; refusing a local-ahead or divergent deployment" >&2 + popd >/dev/null + return 1 + fi + else + git merge --ff-only FETCH_HEAD || { popd >/dev/null; return 1; } + fi + local_head="$(git rev-parse --verify HEAD)" || { popd >/dev/null; return 1; } + fetched_head="$(git rev-parse --verify FETCH_HEAD)" || { popd >/dev/null; return 1; } + if [[ "$local_head" != "$fetched_head" ]]; then + echo "Compose checkout did not converge exactly to fetched origin/$DOCKER_COMPOSE_BRANCH" >&2 + popd >/dev/null + return 1 + fi + fi + verify_clean_compose_checkout || { popd >/dev/null; return 1; } + popd >/dev/null + fi +} + +verify_existing_compose_app_checkout() { + local app="$1" + local current_head + + source_compose_app_env "$app" || return 1 + validate_compose_git_source || return 1 + if [[ ! -d "$DOCKER_COMPOSE_DIR/.git" ]]; then + echo "Compose checkout is missing for ${app}: $DOCKER_COMPOSE_DIR" >&2 + return 1 + fi + + pushd "$DOCKER_COMPOSE_DIR" >/dev/null || return 1 + verify_compose_origin || { popd >/dev/null; return 1; } + verify_clean_compose_checkout || { popd >/dev/null; return 1; } + current_head="$(git rev-parse --verify 'HEAD^{commit}')" || { + echo "Compose checkout has no deployed commit for ${app}: $DOCKER_COMPOSE_DIR" >&2 popd >/dev/null + return 1 + } + if [[ ! "$current_head" =~ ^[0-9a-f]{40}$ ]]; then + echo "Compose checkout resolved an invalid commit for ${app}: $current_head" >&2 + popd >/dev/null + return 1 fi + popd >/dev/null } configure_sitectl_app_features() { @@ -340,6 +876,7 @@ configure_sitectl_app_features() { local configure_ingress=false local changed=false local trusted_ip + local -a trusted_ips=() source_compose_app_env "$app" @@ -368,12 +905,13 @@ configure_sitectl_app_features() { ingress_args+=(--acme-email "$acme_email") configure_ingress=true fi - while IFS= read -r trusted_ip; do + compose_app_ingress_array_values "$app" trusted_ips trusted_ips || return 1 + for trusted_ip in "${trusted_ips[@]}"; do if [ -n "$trusted_ip" ]; then ingress_args+=(--trusted-ip "$trusted_ip") configure_ingress=true fi - done < <(compose_app_ingress_array "$app" trusted_ips) + done if [ -n "$max_upload_size" ]; then ingress_args+=(--max-upload-size "$max_upload_size") configure_ingress=true @@ -400,21 +938,57 @@ run_compose_app_lifecycle() { local app="$1" local lifecycle="$2" local field="${lifecycle}_commands" - local command - - if [ "$lifecycle" != "down" ]; then - clone_or_update_compose_app "$app" - else - source_compose_app_env "$app" - fi + local command command_status + local -a commands=() + + case "$lifecycle" in + init) + # Initialization is the explicit baseline-source convergence phase. + # It follows a configured moving ref or restores a configured pin. + clone_or_update_compose_app "$app" || return 1 + ;; + up | rollout) + source_compose_app_env "$app" || return 1 + validate_compose_git_source || return 1 + if [[ -d "$DOCKER_COMPOSE_DIR/.git" ]]; then + # Preserve the revision selected by the last successful + # rollout. In particular, do not force a detached PR/feature + # commit back through the configured baseline branch before a + # service restart or the next rollout can operate on it. + verify_existing_compose_app_checkout "$app" || return 1 + else + clone_or_update_compose_app "$app" || return 1 + fi + ;; + down) + source_compose_app_env "$app" || return 1 + validate_compose_git_source || return 1 + ;; + *) + echo "Unsupported cloud-compose lifecycle: $lifecycle" >&2 + return 2 + ;; + esac echo "Running cloud-compose ${lifecycle} for ${app}" - pushd "$DOCKER_COMPOSE_DIR" >/dev/null - while IFS= read -r command; do + compose_app_array_values "$app" "$field" commands || return 1 + configure_sitectl_verify_argv || return 1 + pushd "$DOCKER_COMPOSE_DIR" >/dev/null || return 1 + if [[ "$lifecycle" != "down" ]]; then + reject_host_network_compose_services || { popd >/dev/null; return 1; } + fi + for command in "${commands[@]}"; do if [ -z "$command" ]; then continue fi - bash -c "$command" - done < <(compose_app_array "$app" "$field") + bash -c "$command" || { + command_status=$? + popd >/dev/null + return "$command_status" + } + done + if [[ "$lifecycle" != "down" ]]; then + record_compose_app_head "$app" || { popd >/dev/null; return 1; } + fi popd >/dev/null } diff --git a/rootfs/home/cloud-compose/compose-dispatch.sh b/rootfs/home/cloud-compose/compose-dispatch.sh index 4d3a7a1..d5e1a5f 100644 --- a/rootfs/home/cloud-compose/compose-dispatch.sh +++ b/rootfs/home/cloud-compose/compose-dispatch.sh @@ -21,9 +21,10 @@ case "$lifecycle" in ;; esac -while read -r app; do - if [ -z "$app" ]; then - continue - fi +acquire_cloud_compose_lifecycle_lock "$lifecycle" + +apps=() +target_compose_apps_array "$lifecycle" apps +for app in "${apps[@]}"; do run_compose_app_lifecycle "$app" "$lifecycle" -done < <(target_compose_apps "$lifecycle") +done diff --git a/rootfs/home/cloud-compose/configure-metadata-firewall.sh b/rootfs/home/cloud-compose/configure-metadata-firewall.sh new file mode 100644 index 0000000..257734e --- /dev/null +++ b/rootfs/home/cloud-compose/configure-metadata-firewall.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash + +set -euo pipefail + +mode="${1:-full}" +case "$mode" in + full | pre-docker) + ;; + *) + echo "Unknown metadata firewall mode: $mode" >&2 + exit 2 + ;; +esac + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +profile_path="${CLOUD_COMPOSE_PROFILE_PATH:-$script_dir/profile.sh}" + +# shellcheck disable=SC1090 +source "$profile_path" + +case "${CLOUD_COMPOSE_PROVIDER:-}" in + gcp) + ;; + "") + echo "CLOUD_COMPOSE_PROVIDER is required before configuring metadata isolation" >&2 + exit 1 + ;; + *) + exit 0 + ;; +esac + +iptables_bin="$(command -v iptables || true)" +if [ -z "$iptables_bin" ]; then + echo "iptables is required to isolate the GCP metadata service" >&2 + exit 1 +fi + +ensure_table_insert_rule() { + local table="$1" chain="$2" + shift 2 + + if ! "$iptables_bin" -t "$table" -C "$chain" "$@" >/dev/null 2>&1; then + "$iptables_bin" -t "$table" -I "$chain" 1 "$@" + fi +} + +ensure_insert_rule() { + ensure_table_insert_rule filter "$@" +} + +metadata_address="169.254.169.254/32" + +# This Docker-independent hook is installed before docker.service on subsequent +# boots. Traffic arriving from a bridge crosses mangle/PREROUTING before Docker +# can accept it in FORWARD, closing the restart-policy window after a crash. +# Host-originated root metadata traffic does not traverse PREROUTING. +ensure_table_insert_rule mangle PREROUTING -d "$metadata_address" -j DROP +ensure_table_insert_rule mangle PREROUTING -d "$metadata_address" -p tcp --dport 53 -j ACCEPT +ensure_table_insert_rule mangle PREROUTING -d "$metadata_address" -p udp --dport 53 -j ACCEPT + +# Install the Docker-independent host guard in the same early phase. Keep root +# access for key rotation while blocking unprivileged processes from the +# documented HTTP and Shielded VM HTTPS metadata endpoints. Other platform +# traffic to this address (including NTP) remains available to the OS. +ensure_insert_rule OUTPUT -m owner ! --uid-owner 0 -d "$metadata_address" -p tcp --dport 80 -j DROP +ensure_insert_rule OUTPUT -m owner ! --uid-owner 0 -d "$metadata_address" -p tcp --dport 443 -j DROP + +if [ "$mode" = "pre-docker" ]; then + exit 0 +fi + +if ! "$iptables_bin" -nL DOCKER-USER >/dev/null 2>&1; then + echo "Docker did not create the DOCKER-USER chain; refusing to start without metadata isolation" >&2 + exit 1 +fi + +# DOCKER-USER is Docker's stable user-policy hook. The FORWARD rule is a +# fail-closed fallback if another daemon bypasses or rewrites that hook. Neither +# rule affects traffic originating from the host itself. Compute Engine also +# serves VPC and private-zone DNS from this address, so permit only DNS ahead of +# the catch-all metadata deny. Insert the deny first because -I prepends rules. +ensure_insert_rule DOCKER-USER -d "$metadata_address" -j DROP +ensure_insert_rule DOCKER-USER -d "$metadata_address" -p tcp --dport 53 -j ACCEPT +ensure_insert_rule DOCKER-USER -d "$metadata_address" -p udp --dport 53 -j ACCEPT +ensure_insert_rule FORWARD -d "$metadata_address" -j DROP +ensure_insert_rule FORWARD -d "$metadata_address" -p tcp --dport 53 -j ACCEPT +ensure_insert_rule FORWARD -d "$metadata_address" -p udp --dport 53 -j ACCEPT diff --git a/rootfs/home/cloud-compose/cron.sh b/rootfs/home/cloud-compose/cron.sh deleted file mode 100644 index c688591..0000000 --- a/rootfs/home/cloud-compose/cron.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash - -set -eou pipefail - -echo "Running daily cron" - -pushd /home/cloud-compose - -bash rotate-keys-internal.sh -bash rotate-keys-app.sh - -/usr/bin/docker system prune -af - -popd diff --git a/rootfs/home/cloud-compose/deploy-rollout.sh b/rootfs/home/cloud-compose/deploy-rollout.sh index debb6e8..d34aa7a 100644 --- a/rootfs/home/cloud-compose/deploy-rollout.sh +++ b/rootfs/home/cloud-compose/deploy-rollout.sh @@ -1,7 +1,6 @@ #!/usr/bin/env bash set -eou pipefail -set -x # shellcheck disable=SC1091 source /home/cloud-compose/profile.sh @@ -20,13 +19,28 @@ if [ -z "${ROLLOUT_DOWNLOAD_SHA256:-}" ]; then echo "ROLLOUT_DOWNLOAD_SHA256 is required" >&2 exit 1 fi +if [[ ! "$ROLLOUT_DOWNLOAD_URL" =~ ^https://[^[:space:]]+$ ]]; then + echo "ROLLOUT_DOWNLOAD_URL must be an HTTPS URL without whitespace" >&2 + exit 2 +fi +if [[ ! "$ROLLOUT_DOWNLOAD_SHA256" =~ ^[0-9a-f]{64}$ ]]; then + echo "ROLLOUT_DOWNLOAD_SHA256 must be a lowercase SHA-256 digest" >&2 + exit 2 +fi tmp="$(mktemp)" -trap 'rm -f "$tmp"' EXIT - -retry_until_success curl -fsSL --retry 5 --retry-delay 2 -o "$tmp" "$ROLLOUT_DOWNLOAD_URL" -echo "${ROLLOUT_DOWNLOAD_SHA256} ${tmp}" | sha256sum -c - -install -o root -g root -m 0755 "$tmp" /usr/local/bin/cloud-compose-rollout +install_tmp="" +trap 'rm -f -- "$tmp" "$install_tmp"' EXIT + +retry_until_success curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 \ + --retry 5 --retry-all-errors --retry-delay 2 \ + --connect-timeout 10 --max-time 300 -o "$tmp" -- "$ROLLOUT_DOWNLOAD_URL" +printf '%s %s\n' "$ROLLOUT_DOWNLOAD_SHA256" "$tmp" | sha256sum -c - +install_tmp="$(mktemp /usr/local/bin/.cloud-compose-rollout.XXXXXXXXXX)" +install -o root -g root -m 0755 "$tmp" "$install_tmp" +printf '%s %s\n' "$ROLLOUT_DOWNLOAD_SHA256" "$install_tmp" | sha256sum -c - +mv -f -- "$install_tmp" /usr/local/bin/cloud-compose-rollout +install_tmp="" systemctl daemon-reload systemctl enable cloud-compose-rollout.service diff --git a/rootfs/home/cloud-compose/docker-prune.sh b/rootfs/home/cloud-compose/docker-prune.sh new file mode 100755 index 0000000..e8a5568 --- /dev/null +++ b/rootfs/home/cloud-compose/docker-prune.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# shellcheck disable=SC1091 +source /home/cloud-compose/profile.sh + +case "${CLOUD_COMPOSE_DOCKER_PRUNE_ENABLED:-false}" in + true | TRUE | 1 | yes | YES) ;; + *) + echo "Cloud Compose Docker pruning is disabled" + exit 0 + ;; +esac + +prune_until="${CLOUD_COMPOSE_DOCKER_PRUNE_UNTIL:-168h}" +if [[ ! "$prune_until" =~ ^[0-9]+(s|m|h)$ ]]; then + echo "CLOUD_COMPOSE_DOCKER_PRUNE_UNTIL must be a Docker duration such as 168h" >&2 + exit 2 +fi + +exec 9>/run/cloud-compose-docker-prune.lock +if command -v flock >/dev/null 2>&1 && ! flock -n 9; then + echo "A Cloud Compose Docker prune is already running" + exit 0 +fi + +echo "Pruning unused Docker data older than $prune_until" +docker system prune --all --force --filter "until=$prune_until" diff --git a/rootfs/home/cloud-compose/host-conf.sh b/rootfs/home/cloud-compose/host-conf.sh index cbc1a0c..f29da2d 100644 --- a/rootfs/home/cloud-compose/host-conf.sh +++ b/rootfs/home/cloud-compose/host-conf.sh @@ -5,32 +5,39 @@ set -eou pipefail # shellcheck disable=SC1091 source /home/cloud-compose/profile.sh -if [ "${CLOUD_COMPOSE_PROVIDER:-}" = "gcp" ]; then - # block metadata server from docker and non-root - /sbin/iptables -I FORWARD -d 169.254.169.254/32 -i docker0 -j DROP || true - /sbin/iptables -A OUTPUT -m owner ! --uid-owner 0 -d 169.254.169.254/32 -p tcp --dport 80 -j DROP || true +if [ -z "${CLOUD_COMPOSE_PROVIDER:-}" ]; then + echo "CLOUD_COMPOSE_PROVIDER is required" >&2 + exit 1 fi if systemctl list-unit-files fluent-bit.service >/dev/null 2>&1; then systemctl restart fluent-bit || true fi +# COS already has a running Docker daemon. Install the metadata deny policy +# before any bootstrap container is allowed to execute third-party image or +# package content. Debian-family images install Docker below and receive the +# same policy immediately after that daemon is restarted. +if [ "$CLOUD_COMPOSE_PROVIDER" = "gcp" ] && command -v docker >/dev/null 2>&1; then + bash /home/cloud-compose/configure-metadata-firewall.sh +fi + bash /home/cloud-compose/install-dependencies.sh -if [ "${CLOUD_COMPOSE_PROVIDER:-}" = "gcp" ]; then - # Docker build containers cannot use GCE metadata DNS after we block metadata - # access from docker0, so give Docker explicit external resolvers. - install -d /etc/docker - cat >/etc/docker/daemon.json <<'EOF' -{ - "data-root": "/mnt/disks/data/docker", - "dns": ["8.8.8.8", "8.8.4.4", "1.1.1.1"] -} -EOF -fi +for required_command in curl flock gzip jq openssl; do + if ! command -v "$required_command" >/dev/null 2>&1; then + echo "Required cloud-compose host dependency is missing: $required_command" >&2 + exit 1 + fi +done # restart services we've overwritten files for systemctl restart docker +if [ "$CLOUD_COMPOSE_PROVIDER" = "gcp" ]; then + # Docker may reprogram its chains during restart. Reassert the deny policy + # before any persistent application container is allowed to proceed. + bash /home/cloud-compose/configure-metadata-firewall.sh +fi # wait until our data-root /etc/docker/daemon.json setting is applied deadline=$((SECONDS + 120)) @@ -47,6 +54,18 @@ while [ "$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)" != "/ sleep 2 done -bash /home/cloud-compose/libops-managed-runtime.sh install-tools +bash /home/cloud-compose/migrate-legacy-systemd-units.sh systemctl daemon-reload -systemctl enable libops-managed-runtime.timer +if [ "$CLOUD_COMPOSE_PROVIDER" = "gcp" ]; then + # Gate later Docker starts behind the early guard on stateful hosts, and make + # the dependency available after each COS cloud-init reconstruction. Reapply + # the Docker-specific layer after the daemon creates DOCKER-USER. + systemctl enable --now cloud-compose-metadata-firewall-pre.service + systemctl enable --now cloud-compose-metadata-firewall.service +else + systemctl disable --now \ + cloud-compose-metadata-firewall-pre.service \ + cloud-compose-metadata-firewall.service >/dev/null 2>&1 || true +fi + +bash /home/cloud-compose/libops-managed-runtime.sh install-tools diff --git a/rootfs/home/cloud-compose/host-init.sh b/rootfs/home/cloud-compose/host-init.sh index 50b50bd..66b577d 100644 --- a/rootfs/home/cloud-compose/host-init.sh +++ b/rootfs/home/cloud-compose/host-init.sh @@ -1,9 +1,14 @@ #!/usr/bin/env bash -set -euxo pipefail +set -euo pipefail + +# shellcheck disable=SC1091 +source /home/cloud-compose/profile.sh cleanup() { - rm -f tmp.attr .env.tmp + if [ -n "${metadata_file:-}" ]; then + rm -f "$metadata_file" + fi popd >/dev/null } @@ -11,27 +16,16 @@ pushd /home/cloud-compose >/dev/null trap cleanup EXIT -if [ -f .env ]; then - cp .env .env.tmp -else - touch .env .env.tmp -fi - if [ "${CLOUD_COMPOSE_PROVIDER:-}" = "gcp" ]; then + metadata_file="$(mktemp /home/cloud-compose/.metadata.XXXXXXXXXX)" curl -sf \ -H "Metadata-Flavor: Google" \ - "http://metadata.google.internal/computeMetadata/v1/?recursive=true" > tmp.attr + "http://metadata.google.internal/computeMetadata/v1/?recursive=true" >"$metadata_file" - { - echo "GCP_PUBLIC_IP=$(jq -r '.instance.networkInterfaces[0].accessConfigs[0].externalIp' tmp.attr)" - echo "GCP_PRIVATE_IP=$(jq -r '.instance.networkInterfaces[0].ip' tmp.attr)" - } >> .env.tmp -fi - -if ! diff <(md5sum .env.tmp) <(md5sum .env) >/dev/null 2>&1; then - mv .env.tmp .env -else - rm -f .env.tmp + update_runtime_env_file .env GCP_PUBLIC_IP \ + "$(jq -er '.instance.networkInterfaces[0].accessConfigs[0].externalIp' "$metadata_file")" + update_runtime_env_file .env GCP_PRIVATE_IP \ + "$(jq -er '.instance.networkInterfaces[0].ip' "$metadata_file")" fi if [ "${LIBOPS_INTERNAL_SERVICES_ENABLED:-false}" = "true" ]; then @@ -40,7 +34,38 @@ if [ "${LIBOPS_INTERNAL_SERVICES_ENABLED:-false}" = "true" ]; then chown cloud-compose:cloud-compose /mnt/disks/data/libops-internal/.env fi -chown -R cloud-compose:cloud-compose /home/cloud-compose +# Root-owned services execute scripts and load control data from this tree. +# Keep the home boundary itself non-writable and grant the application account +# only explicit mutable subdirectories. In particular, sitectl owns its state +# below .sitectl; scripts at the home boundary remain root-owned. +chown root:root /home/cloud-compose +chmod 0755 /home/cloud-compose +for mutable_dir in \ + /home/cloud-compose/apps \ + /home/cloud-compose/bin \ + /home/cloud-compose/state \ + /home/cloud-compose/.sitectl \ + /home/cloud-compose/.cache \ + /home/cloud-compose/.config \ + /home/cloud-compose/.local; do + install -d -m 0750 -o cloud-compose -g cloud-compose "$mutable_dir" +done + +find /home/cloud-compose -maxdepth 1 -type f -name '*.sh' \ + -exec chown root:root {} + \ + -exec chmod 0755 {} + +for dispatcher in init up down rollout; do + if [ -f "/home/cloud-compose/$dispatcher" ]; then + chown root:root "/home/cloud-compose/$dispatcher" + chmod 0755 "/home/cloud-compose/$dispatcher" + fi +done +for runtime_input in .env compose-projects.json application-env.json; do + if [ -f "/home/cloud-compose/$runtime_input" ]; then + chown root:cloud-compose "/home/cloud-compose/$runtime_input" + chmod 0640 "/home/cloud-compose/$runtime_input" + fi +done groupadd --force docker if ! id -u cloud-compose >/dev/null 2>&1; then useradd --create-home --shell /bin/bash --groups docker cloud-compose @@ -51,3 +76,12 @@ elif ! id -nG cloud-compose | tr ' ' '\n' | grep -qx docker; then getent group docker >&2 || true } fi + +# Cloud-init prepares these mount roots before invoking run.sh, while the +# Ansible and Salt adapters can install the runtime onto pre-mounted storage. +# Normalize only the mount roots here so every entry path can create lifecycle +# locks without granting recursive ownership of application or control data. +for mutable_root in /mnt/disks/data /mnt/disks/volumes; do + install -d -m 0775 -o cloud-compose -g cloud-compose "$mutable_root" +done +install -d -m 0775 -o cloud-compose -g cloud-compose /mnt/disks/data/libops diff --git a/rootfs/home/cloud-compose/install-dependencies-coreos.sh b/rootfs/home/cloud-compose/install-dependencies-coreos.sh index 1338cb2..6c97d00 100644 --- a/rootfs/home/cloud-compose/install-dependencies-coreos.sh +++ b/rootfs/home/cloud-compose/install-dependencies-coreos.sh @@ -5,32 +5,16 @@ set -euo pipefail # shellcheck disable=SC1091 source /home/cloud-compose/profile.sh -install -d /etc/yum.repos.d /usr/local/lib/docker/cli-plugins -cat >/etc/yum.repos.d/sitectl.repo <<'EOF' -[sitectl] -name=sitectl -baseurl=https://packages.libops.io/sitectl/rpm -enabled=1 -gpgcheck=0 -repo_gpgcheck=1 -gpgkey=https://packages.libops.io/sitectl/sitectl-archive-keyring.asc -EOF - +install -d /usr/local/lib/docker/cli-plugins bash /home/cloud-compose/install-docker-plugins.sh packages=() -for package in git jq make; do +for package in git jq make openssl; do if ! rpm -q "$package" >/dev/null 2>&1; then packages+=("$package") fi done -for package in ${SITECTL_PACKAGES:-sitectl}; do - if [ -n "$package" ] && ! rpm -q "$package" >/dev/null 2>&1; then - packages+=("$package") - fi -done - if [ "${#packages[@]}" -gt 0 ]; then rpm-ostree install --apply-live "${packages[@]}" fi diff --git a/rootfs/home/cloud-compose/install-dependencies-cos.sh b/rootfs/home/cloud-compose/install-dependencies-cos.sh index ea0c5b1..0f6320e 100644 --- a/rootfs/home/cloud-compose/install-dependencies-cos.sh +++ b/rootfs/home/cloud-compose/install-dependencies-cos.sh @@ -2,36 +2,165 @@ set -euo pipefail -# shellcheck disable=SC1091 -source /home/cloud-compose/profile.sh - -mkdir -p "${DOCKER_CONFIG}/cli-plugins" /home/cloud-compose/.docker/cli-plugins /home/cloud-compose/bin /mnt/disks/data -DOCKER_CLI_PLUGIN_DIR="${DOCKER_CONFIG}/cli-plugins" \ - bash /home/cloud-compose/install-docker-plugins.sh -DOCKER_CLI_PLUGIN_DIR=/home/cloud-compose/.docker/cli-plugins \ - bash /home/cloud-compose/install-docker-plugins.sh -chown -R cloud-compose:cloud-compose "$DOCKER_CONFIG" /home/cloud-compose/.docker /home/cloud-compose/bin - -if [ ! -f "/mnt/disks/data/make" ]; then - # COS can start Docker before bridge egress is reliable; this bootstrap container does not run app code. - # shellcheck disable=SC2016 - retry_until_success /usr/bin/docker run --rm \ - --network host \ - -v /mnt/disks/data:/out \ - alpine:3.22 \ - /bin/sh -euxc ' - MAKE_VERSION="4.4.1" - MAKE_SHA256="dd16fb1d67bfab79a72f5e8390735c49e3e8e70b4945a15ab1f81ddb78658fb3" - - apk add --no-cache build-base curl make tar - curl -fsSL "https://ftp.gnu.org/gnu/make/make-${MAKE_VERSION}.tar.gz" -o /tmp/make.tar.gz - echo "${MAKE_SHA256} /tmp/make.tar.gz" | sha256sum -c - - tar -xzf /tmp/make.tar.gz -C /tmp - cd "/tmp/make-${MAKE_VERSION}" - LDFLAGS="-static" ./configure --disable-nls - make -j2 - cp make /out/make - ' +# renovate: datasource=docker depName=alpine packageName=alpine versioning=docker +ALPINE_BUILD_IMAGE="alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce" +MAKE_VERSION="4.4.1" + +valid_make_binary() { + local make_path="$1" state_path="$2" state_version state_sha actual_sha + + [[ ! -L "$make_path" && -f "$make_path" && -x "$make_path" ]] || return 1 + [[ ! -L "$state_path" && -f "$state_path" ]] || return 1 + read -r state_version state_sha <"$state_path" || return 1 + [[ "$state_version" == "$MAKE_VERSION" && "$state_sha" =~ ^[0-9a-f]{64}$ ]] || return 1 + actual_sha="$(sha256sum -- "$make_path")" || return 1 + [[ "${actual_sha%% *}" == "$state_sha" ]] +} + +# This function is sourced by the COS bootstrap contract, which supplies all +# three arguments even though the executable entrypoint below uses defaults. +# shellcheck disable=SC2120 +install_cos_dependencies() { + local cloud_compose_home="${1:-/home/cloud-compose}" + # COS mounts both /home and /var with noexec. Keep the verified Make binary + # on the executable persistent data disk; only the unprivileged application + # PATH consumes the published symlink. + local tool_state_dir="${2:-${COS_TOOL_STATE_DIR:-/mnt/disks/data/cloud-compose-tools}}" + local docker_bin="${3:-/usr/bin/docker}" + local make_path pending_make_path make_state_path make_state_tmp make_sha + local tool_mount_options installer_uid installer_gid + + if ! command -v openssl >/dev/null 2>&1; then + echo "COS must provide openssl for service-account authentication and application key scaffolding" >&2 + return 1 + fi + + if [[ "$tool_state_dir" != /* || "$tool_state_dir" == "/" || -L "$tool_state_dir" || + "$tool_state_dir" =~ (^|/)\.\.?(/|$) ]]; then + echo "COS tool state directory is unsafe: $tool_state_dir" >&2 + return 1 + fi + mkdir -p \ + "${DOCKER_CONFIG}/cli-plugins" \ + "${cloud_compose_home}/.docker/cli-plugins" \ + "${cloud_compose_home}/bin" + mkdir -p -- "$tool_state_dir" + if [[ -L "$tool_state_dir" || ! -d "$tool_state_dir" ]]; then + echo "COS tool state directory is unsafe: $tool_state_dir" >&2 + return 1 + fi + tool_mount_options="$(findmnt -n -o OPTIONS --target "$tool_state_dir")" || { + echo "Could not inspect COS tool state mount: $tool_state_dir" >&2 + return 1 + } + if [[ ",${tool_mount_options}," == *",noexec,"* ]]; then + echo "COS tool state directory is on a noexec filesystem: $tool_state_dir" >&2 + return 1 + fi + installer_uid="$(id -u)" + installer_gid="$(id -g)" + chown "${installer_uid}:${installer_gid}" "$tool_state_dir" + chmod 0755 "$tool_state_dir" + DOCKER_CLI_PLUGIN_DIR="${DOCKER_CONFIG}/cli-plugins" \ + bash "${cloud_compose_home}/install-docker-plugins.sh" + DOCKER_CLI_PLUGIN_DIR="${cloud_compose_home}/.docker/cli-plugins" \ + bash "${cloud_compose_home}/install-docker-plugins.sh" + chown -R cloud-compose:cloud-compose \ + "$DOCKER_CONFIG" \ + "${cloud_compose_home}/.docker" \ + "${cloud_compose_home}/bin" + + make_path="${tool_state_dir}/make" + make_state_path="${tool_state_dir}/make.state" + pending_make_path="${tool_state_dir}/.cloud-compose-make.pending" + if ! valid_make_binary "$make_path" "$make_state_path"; then + if [[ "${CLOUD_COMPOSE_PROVIDER:-}" == "gcp" ]]; then + if ! command -v iptables >/dev/null 2>&1 || + ! iptables -C DOCKER-USER -d 169.254.169.254/32 -j DROP >/dev/null 2>&1; then + echo "Refusing COS bootstrap container before the GCP metadata deny policy is active" >&2 + return 1 + fi + fi + rm -f -- "$pending_make_path" + # The GCP metadata deny policy is installed before this container runs. + # Keep the build on Docker's isolated bridge: `--network host` would + # bypass FORWARD/DOCKER-USER and expose the VM service-account token to + # image entrypoints and network-fetched package build scripts. + # shellcheck disable=SC2016 + if ! retry_until_success "$docker_bin" run --rm \ + -v "${tool_state_dir}:/out" \ + "$ALPINE_BUILD_IMAGE" \ + /bin/sh -euxc ' + MAKE_VERSION="4.4.1" + MAKE_SHA256="dd16fb1d67bfab79a72f5e8390735c49e3e8e70b4945a15ab1f81ddb78658fb3" + + # A single Alpine CDN outage must not make a healthy VM + # replacement fail. These are HTTPS endpoints from the Alpine + # official mirror list; apk still verifies the signed indexes + # and packages with the keys baked into the pinned image. + alpine_mirrors=" + https://dl-cdn.alpinelinux.org/alpine + https://mirror.math.princeton.edu/pub/alpinelinux + https://mirror.fel.cvut.cz/alpine + " + packages_installed=false + for alpine_mirror in ${alpine_mirrors}; do + printf "%s\n%s\n" \ + "${alpine_mirror}/v3.22/main" \ + "${alpine_mirror}/v3.22/community" \ + >/etc/apk/repositories + rm -f /var/cache/apk/* + if apk update && apk add build-base curl make tar; then + packages_installed=true + break + fi + echo "Alpine package mirror failed: ${alpine_mirror}" >&2 + done + if [ "${packages_installed}" != true ]; then + echo "All configured Alpine package mirrors failed" >&2 + exit 1 + fi + curl -fsSL --proto "=https" --proto-redir "=https" --tlsv1.2 \ + --retry 5 --retry-all-errors --retry-delay 2 --retry-max-time 900 \ + --connect-timeout 10 --max-time 300 \ + "https://ftp.gnu.org/gnu/make/make-${MAKE_VERSION}.tar.gz" -o /tmp/make.tar.gz + echo "${MAKE_SHA256} /tmp/make.tar.gz" | sha256sum -c - + tar -xzf /tmp/make.tar.gz -C /tmp + cd "/tmp/make-${MAKE_VERSION}" + LDFLAGS="-static" ./configure --disable-nls + make -j2 + install -m 0755 make /out/.cloud-compose-make.pending + /out/.cloud-compose-make.pending --version | grep -Fqm 1 "GNU Make ${MAKE_VERSION}" + mv -f /out/.cloud-compose-make.pending /out/make + '; then + rm -f -- "$pending_make_path" + return 1 + fi + rm -f -- "$pending_make_path" + if [[ -L "$make_path" || ! -f "$make_path" || ! -x "$make_path" ]]; then + echo "COS bootstrap did not produce a regular executable Make artifact" >&2 + return 1 + fi + make_sha="$(sha256sum -- "$make_path")" || return 1 + make_sha="${make_sha%% *}" + make_state_tmp="$(mktemp "${make_state_path}.tmp.XXXXXX")" || return 1 + printf '%s %s\n' "$MAKE_VERSION" "$make_sha" >"$make_state_tmp" + chmod 0644 "$make_state_tmp" + mv -f -- "$make_state_tmp" "$make_state_path" + fi + if ! valid_make_binary "$make_path" "$make_state_path"; then + echo "COS bootstrap did not produce a valid GNU Make ${MAKE_VERSION} binary" >&2 + return 1 + fi + ln -sfn "$make_path" "${cloud_compose_home}/bin/make" +} + +main() { + # shellcheck disable=SC1091 + source /home/cloud-compose/profile.sh + install_cos_dependencies +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" fi -chmod a+x /mnt/disks/data/make -ln -sf /mnt/disks/data/make /home/cloud-compose/bin/make diff --git a/rootfs/home/cloud-compose/install-dependencies-debian.sh b/rootfs/home/cloud-compose/install-dependencies-debian.sh index d8099cb..e493c22 100644 --- a/rootfs/home/cloud-compose/install-dependencies-debian.sh +++ b/rootfs/home/cloud-compose/install-dependencies-debian.sh @@ -14,7 +14,8 @@ retry_until_success apt-get install -y \ docker.io \ git \ jq \ - make + make \ + openssl install -d /usr/local/lib/docker/cli-plugins bash /home/cloud-compose/install-docker-plugins.sh diff --git a/rootfs/home/cloud-compose/install-docker-plugins.sh b/rootfs/home/cloud-compose/install-docker-plugins.sh index 74472e1..50b7671 100644 --- a/rootfs/home/cloud-compose/install-docker-plugins.sh +++ b/rootfs/home/cloud-compose/install-docker-plugins.sh @@ -2,9 +2,6 @@ set -euo pipefail -# shellcheck disable=SC1091 -source /home/cloud-compose/profile.sh - docker_arch() { case "$(uname -m)" in x86_64 | amd64) echo "x86_64" ;; @@ -27,20 +24,79 @@ buildx_arch() { esac } -install_docker_cli_plugin() { +release_checksum() { + local manifest="$1" + local asset="$2" + + awk -v asset="$asset" ' + { + filename = $2 + sub(/^\*/, "", filename) + if (filename == asset) { + checksum = $1 + matches++ + } + } + END { + if (matches != 1) { + exit 1 + } + print checksum + } + ' "$manifest" +} + +validate_release_version() { local name="$1" - local url="$2" - local path="$3" + local version="$2" - if [ -x "$path" ]; then - return 0 + if [[ ! "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "invalid ${name} release version: ${version}" >&2 + return 1 fi +} + +install_docker_cli_plugin() ( + local name="$1" + local release_url="$2" + local asset="$3" + local path="$4" + local tmp_dir manifest download expected actual - echo "Installing Docker CLI plugin ${name}" mkdir -p "$(dirname "$path")" - retry_until_success curl -fsSL "$url" -o "$path" - chmod a+x "$path" -} + if [[ -d "$path" ]]; then + echo "Refusing Docker CLI plugin directory at file path: ${path}" >&2 + return 1 + fi + tmp_dir="$(mktemp -d "$(dirname "$path")/.${name}.XXXXXXXXXX")" + trap 'rm -rf -- "$tmp_dir"' EXIT + manifest="${tmp_dir}/checksums.txt" + download="${tmp_dir}/${asset}" + + retry_until_success curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 \ + --connect-timeout 10 --max-time 300 -o "$manifest" -- "${release_url}/checksums.txt" + if ! expected="$(release_checksum "$manifest" "$asset")" || + [[ ! "$expected" =~ ^[0-9a-f]{64}$ ]]; then + echo "No unique SHA-256 checksum for ${asset} in the ${name} release manifest" >&2 + return 1 + fi + + if [[ -f "$path" && ! -L "$path" && -x "$path" ]]; then + actual="$(sha256sum "$path")" + actual="${actual%% *}" + if [[ "$actual" == "$expected" ]]; then + echo "Docker CLI plugin ${name} is already verified" + return 0 + fi + fi + + echo "Installing verified Docker CLI plugin ${name}" + retry_until_success curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 \ + --connect-timeout 10 --max-time 300 -o "$download" -- "${release_url}/${asset}" + printf '%s %s\n' "$expected" "$download" | sha256sum -c - + chmod 0755 "$download" + mv -f -- "$download" "$path" +) install_docker_plugins() { local plugin_dir="${DOCKER_CLI_PLUGIN_DIR:-/usr/local/lib/docker/cli-plugins}" @@ -51,16 +107,28 @@ install_docker_plugins() { # renovate: datasource=github-releases depName=docker-buildx packageName=docker/buildx versioning=semver DOCKER_BUILDX_VERSION="${DOCKER_BUILDX_VERSION:-v0.35.0}" + validate_release_version docker-compose "$DOCKER_COMPOSE_VERSION" + validate_release_version docker-buildx "$DOCKER_BUILDX_VERSION" compose_arch="$(docker_arch)" buildx_asset_arch="$(buildx_arch)" install_docker_cli_plugin \ docker-compose \ - "https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}/docker-compose-linux-${compose_arch}" \ + "https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}" \ + "docker-compose-linux-${compose_arch}" \ "${plugin_dir}/docker-compose" install_docker_cli_plugin \ docker-buildx \ - "https://github.com/docker/buildx/releases/download/${DOCKER_BUILDX_VERSION}/buildx-${DOCKER_BUILDX_VERSION}.linux-${buildx_asset_arch}" \ + "https://github.com/docker/buildx/releases/download/${DOCKER_BUILDX_VERSION}" \ + "buildx-${DOCKER_BUILDX_VERSION}.linux-${buildx_asset_arch}" \ "${plugin_dir}/docker-buildx" } -install_docker_plugins "$@" +main() { + # shellcheck disable=SC1091 + source /home/cloud-compose/profile.sh + install_docker_plugins "$@" +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/rootfs/home/cloud-compose/libops-managed-runtime.sh b/rootfs/home/cloud-compose/libops-managed-runtime.sh index afc74ca..17f9dff 100644 --- a/rootfs/home/cloud-compose/libops-managed-runtime.sh +++ b/rootfs/home/cloud-compose/libops-managed-runtime.sh @@ -2,8 +2,8 @@ set -euo pipefail -# shellcheck disable=SC1091 -source /home/cloud-compose/profile.sh +# shellcheck disable=SC1090 +source "${CLOUD_COMPOSE_PROFILE_PATH:-/home/cloud-compose/profile.sh}" LOG_PREFIX="[libops-managed-runtime]" STATE_DIR="/mnt/disks/data/libops-managed" @@ -13,6 +13,7 @@ PACKAGE_STATE_DIR="${STATE_DIR}/packages" ARTIFACT_STATE_DIR="${STATE_DIR}/artifacts" ARTIFACT_MANIFEST="/home/cloud-compose/managed-runtime-artifacts.tsv" GITHUB_OWNER="${SITECTL_GITHUB_OWNER:-libops}" +PUBLISHED_BIN_DIR="/home/cloud-compose/bin" log() { echo "${LOG_PREFIX} $*" >&2 @@ -26,7 +27,7 @@ enabled() { } mkdirs() { - mkdir -p "$BIN_DIR" "$TMP_DIR" "$PACKAGE_STATE_DIR" "$ARTIFACT_STATE_DIR" /home/cloud-compose/bin + mkdir -p "$BIN_DIR" "$TMP_DIR" "$PACKAGE_STATE_DIR" "$ARTIFACT_STATE_DIR" "$PUBLISHED_BIN_DIR" } with_lock() { @@ -69,16 +70,31 @@ release_base_url() { latest_release_tag() { local package="$1" - local metadata + local metadata tag metadata="$(mktemp "${TMP_DIR}/${package}.latest.XXXXXX.json")" - if ! retry_until_success curl -fsSL -o "$metadata" "https://api.github.com/repos/${GITHUB_OWNER}/${package}/releases/latest"; then + if ! retry_until_success curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 \ + --connect-timeout 10 --max-time 300 -o "$metadata" -- \ + "https://api.github.com/repos/${GITHUB_OWNER}/${package}/releases/latest"; then rm -f "$metadata" return 1 fi - sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$metadata" | head -n 1 + if ! tag="$(jq -jr ' + (.tag_name | select(type == "string" and length > 0 and (explode | index(0) == null))), + "\u001f" + ' "$metadata")"; then + log "latest release metadata for ${package} did not contain a tag" + rm -f "$metadata" + return 1 + fi + tag="${tag%$'\x1f'}" rm -f "$metadata" + if [[ "$tag" == "latest" ]] || ! valid_sitectl_version "$tag"; then + log "latest release metadata for ${package} contained an invalid tag" + return 1 + fi + printf '%s\n' "$tag" } installed_release_tag() { @@ -90,17 +106,43 @@ installed_release_tag() { fi } -write_installed_release_tag() { +installed_release_sha256() { local package="$1" - local version="$2" + local state_file="${PACKAGE_STATE_DIR}/${package}.sha256" + + if [ -f "$state_file" ]; then + cat "$state_file" + fi +} - printf '%s\n' "$version" > "${PACKAGE_STATE_DIR}/${package}.version" +write_installed_release_state() { + local package="$1" + local version="$2" + local binary="$3" + local version_file="${PACKAGE_STATE_DIR}/${package}.version" + local sha_file="${PACKAGE_STATE_DIR}/${package}.sha256" + local version_tmp sha_tmp binary_sha + + binary_sha="$(sha256sum -- "$binary")" || return 1 + binary_sha="${binary_sha%% *}" + version_tmp="$(mktemp "${version_file}.tmp.XXXXXX")" || return 1 + sha_tmp="$(mktemp "${sha_file}.tmp.XXXXXX")" || { + rm -f -- "$version_tmp" + return 1 + } + printf '%s\n' "$version" >"$version_tmp" + printf '%s\n' "$binary_sha" >"$sha_tmp" + chmod 0640 "$version_tmp" "$sha_tmp" + mv -f -- "$version_tmp" "$version_file" + mv -f -- "$sha_tmp" "$sha_file" } install_release_package() { local package="$1" - local version="${SITECTL_VERSION:-latest}" - local arch archive base_url tag installed tmp checksum_file binary + local target_bin_dir="${2:-$BIN_DIR}" + local publish="${3:-true}" + local version + local arch archive base_url tag installed installed_sha actual_sha tmp checksum_file archive_listing binary target_tmp if [ -z "$package" ]; then return 0 @@ -113,47 +155,100 @@ install_release_package() { ;; esac + version="$(sitectl_package_version "$package")" + arch="$(machine_arch)" archive="${package}_Linux_${arch}.tar.gz" - base_url="$(release_base_url "$package" "$version")" tag="$version" if [ "$version" = "latest" ]; then - tag="$(latest_release_tag "$package" || true)" + tag="$(latest_release_tag "$package")" + if [ "$tag" = "latest" ] || ! valid_sitectl_version "$tag"; then + log "latest release for ${package} returned an invalid tag: ${tag}" + return 1 + fi fi + base_url="$(release_base_url "$package" "$tag")" installed="$(installed_release_tag "$package" || true)" - - if [ -n "$tag" ] && [ "$installed" = "$tag" ] && [ -x "${BIN_DIR}/${package}" ]; then - ln -sf "${BIN_DIR}/${package}" "/home/cloud-compose/bin/${package}" - log "${package} is already ${tag}" - return 0 + installed_sha="$(installed_release_sha256 "$package" || true)" + + mkdir -p -- "$target_bin_dir" + if [ -n "$tag" ] && [ "$installed" = "$tag" ] && + [[ "$installed_sha" =~ ^[0-9a-f]{64}$ ]] && + [[ ! -L "${BIN_DIR}/${package}" && -f "${BIN_DIR}/${package}" && -x "${BIN_DIR}/${package}" ]]; then + actual_sha="$(sha256sum -- "${BIN_DIR}/${package}")" || return 1 + actual_sha="${actual_sha%% *}" + if [[ "$actual_sha" == "$installed_sha" ]]; then + if [[ "$target_bin_dir" != "$BIN_DIR" ]]; then + target_tmp="$(mktemp "${target_bin_dir}/.${package}.XXXXXX")" || return 1 + install -m 0755 "${BIN_DIR}/${package}" "$target_tmp" || { + rm -f -- "$target_tmp" + return 1 + } + mv -f -- "$target_tmp" "${target_bin_dir}/${package}" + elif [[ "$publish" == "true" ]]; then + ln -sfn "${BIN_DIR}/${package}" "${PUBLISHED_BIN_DIR}/${package}" + fi + RELEASE_PACKAGE_TAG="$tag" + RELEASE_PACKAGE_SHA256="$installed_sha" + log "${package} is already ${tag}" + return 0 + fi fi tmp="$(mktemp -d "${TMP_DIR}/${package}.XXXXXX")" log "installing ${package} from ${base_url}/${archive}" - retry_until_success curl -fsSL -o "${tmp}/${archive}" "${base_url}/${archive}" - retry_until_success curl -fsSL -o "${tmp}/checksums.txt" "${base_url}/checksums.txt" + retry_until_success curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 \ + --connect-timeout 10 --max-time 300 -o "${tmp}/${archive}" -- "${base_url}/${archive}" + retry_until_success curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 \ + --connect-timeout 10 --max-time 300 -o "${tmp}/checksums.txt" -- "${base_url}/checksums.txt" checksum_file="${tmp}/checksums.selected.txt" - grep -E "[[:space:]]${archive}$" "${tmp}/checksums.txt" > "$checksum_file" + awk -v archive="$archive" '$2 == archive { print }' "${tmp}/checksums.txt" >"$checksum_file" + if [[ "$(wc -l <"$checksum_file")" -ne 1 ]]; then + log "release checksums must contain exactly one entry for ${archive}" + rm -rf -- "$tmp" + return 1 + fi (cd "$tmp" && sha256sum -c "$(basename "$checksum_file")") - tar -xzf "${tmp}/${archive}" -C "$tmp" - binary="$(find "$tmp" -type f -name "$package" | head -n 1)" - if [ -z "$binary" ]; then - log "release archive did not contain ${package}" + # Extract only the exact top-level binary. Release archives also contain + # documentation, but no other member needs filesystem access on the host. + archive_listing="${tmp}/archive.list" + tar -tzf "${tmp}/${archive}" >"$archive_listing" + if [[ "$(grep -Fxc -- "$package" "$archive_listing")" -ne 1 ]]; then + log "release archive must contain exactly one top-level ${package} binary" + rm -rf -- "$tmp" + return 1 + fi + tar -xzf "${tmp}/${archive}" -C "$tmp" -- "$package" + binary="${tmp}/${package}" + if [[ -L "$binary" || ! -f "$binary" ]]; then + log "release archive member ${package} is not a regular file" + rm -rf -- "$tmp" return 1 fi - install -m 0755 "$binary" "${BIN_DIR}/${package}" - ln -sf "${BIN_DIR}/${package}" "/home/cloud-compose/bin/${package}" - if [ -n "$tag" ]; then - write_installed_release_tag "$package" "$tag" + target_tmp="$(mktemp "${target_bin_dir}/.${package}.XXXXXX")" || return 1 + install -m 0755 "$binary" "$target_tmp" || { + rm -f -- "$target_tmp" + rm -rf -- "$tmp" + return 1 + } + actual_sha="$(sha256sum -- "$target_tmp")" || { + rm -f -- "$target_tmp" + return 1 + } + actual_sha="${actual_sha%% *}" + mv -f -- "$target_tmp" "${target_bin_dir}/${package}" + RELEASE_PACKAGE_TAG="$tag" + RELEASE_PACKAGE_SHA256="$actual_sha" + if [[ "$publish" == "true" ]]; then + ln -sfn "${BIN_DIR}/${package}" "${PUBLISHED_BIN_DIR}/${package}" + write_installed_release_state "$package" "$tag" "${target_bin_dir}/${package}" log "installed ${package} ${tag}" - else - log "installed ${package}" fi - rm -rf "$tmp" + rm -rf -- "$tmp" } sitectl_package_list() { @@ -173,58 +268,563 @@ sitectl_package_list() { done } -install_sitectl_packages() { - local package +valid_sitectl_version() { + local version="$1" + + [[ "$version" == "latest" || "$version" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]] +} + +sitectl_package_versions_json() { + if [ -n "${SITECTL_PACKAGE_VERSIONS:-}" ]; then + printf '%s\n' "$SITECTL_PACKAGE_VERSIONS" + else + printf '%s\n' '{}' + fi +} + +sitectl_package_version() { + local package="$1" + local fallback="${SITECTL_VERSION:-latest}" + + jq -er --arg package "$package" --arg fallback "$fallback" \ + '.[$package] // $fallback' \ + <<<"$(sitectl_package_versions_json)" +} + +validate_sitectl_configuration() { + local fallback="${SITECTL_VERSION:-latest}" + local versions_json package encoded_override override override_version + local -A installed_packages=() + + if ! valid_sitectl_version "$fallback"; then + log "invalid SITECTL_VERSION: ${fallback}" + return 1 + fi + + versions_json="$(sitectl_package_versions_json)" + if ! jq -e ' + type == "object" and + all(to_entries[]; + (.key | explode | index(0) == null) and + (.value | type == "string") and + (.value | explode | index(0) == null) + ) + ' <<<"$versions_json" >/dev/null; then + log "SITECTL_PACKAGE_VERSIONS must be a JSON object of sitectl package names to latest or exact semantic-version release tags" + return 1 + fi while read -r package; do - install_release_package "$package" + if [[ ! "$package" =~ ^sitectl(-[a-z0-9]+)*$ ]]; then + log "invalid package in SITECTL_PACKAGES: ${package}" + return 1 + fi + installed_packages["$package"]=true done < <(sitectl_package_list) + + while IFS= read -r encoded_override; do + override="$( + if ! printf '%s' "$encoded_override" | base64 --decode; then + exit 1 + fi + printf '\037' + )" || return 1 + override="${override%$'\x1f'}" + if [[ ! "$override" =~ ^sitectl(-[a-z0-9]+)*$ ]]; then + log "SITECTL_PACKAGE_VERSIONS contains an invalid package name: ${override}" + return 1 + fi + override_version="$(jq -jr --arg package "$override" '(.[$package]), "\u001f"' <<<"$versions_json")" || return 1 + override_version="${override_version%$'\x1f'}" + if ! valid_sitectl_version "$override_version"; then + log "SITECTL_PACKAGE_VERSIONS contains an invalid release tag for ${override}: ${override_version}" + return 1 + fi + if [[ -z "${installed_packages[$override]:-}" ]]; then + log "SITECTL_PACKAGE_VERSIONS contains an uninstalled package: ${override}" + return 1 + fi + done < <(jq -r 'keys[] | @base64' <<<"$versions_json") +} + +validate_stale_managed_sitectl_package() { + local package="$1" + local version_file="${PACKAGE_STATE_DIR}/${package}.version" + local sha_file="${PACKAGE_STATE_DIR}/${package}.sha256" + local binary="${BIN_DIR}/${package}" + local published="${PUBLISHED_BIN_DIR}/${package}" + local version expected_sha actual_sha published_target + + if [[ "$package" == "sitectl" || ! "$package" =~ ^sitectl(-[a-z0-9]+)+$ ]]; then + log "refusing to prune invalid managed package name: ${package}" + return 1 + fi + if [[ -L "$version_file" || ! -f "$version_file" || + -L "$sha_file" || ! -f "$sha_file" || + -L "$binary" || ! -f "$binary" || ! -x "$binary" ]]; then + log "refusing to prune ${package}: binary and regular version/checksum state are required" + return 1 + fi + + version="$(<"$version_file")" + expected_sha="$(<"$sha_file")" + if [[ "$version" == "latest" ]] || ! valid_sitectl_version "$version" || + [[ ! "$expected_sha" =~ ^[0-9a-f]{64}$ ]]; then + log "refusing to prune ${package}: installed release state is invalid" + return 1 + fi + actual_sha="$(sha256sum -- "$binary")" || return 1 + actual_sha="${actual_sha%% *}" + if [[ "$actual_sha" != "$expected_sha" ]]; then + log "refusing to prune ${package}: binary does not match managed checksum state" + return 1 + fi + + if [[ -e "$published" || -L "$published" ]]; then + if [[ ! -L "$published" ]]; then + log "refusing to prune ${package}: published command is not a managed symlink" + return 1 + fi + published_target="$(readlink -- "$published")" || return 1 + if [[ "$published_target" != "$binary" ]]; then + log "refusing to prune ${package}: published command targets ${published_target}" + return 1 + fi + fi +} + +install_sitectl_packages() { + local package path name stage_dir payload_dir tags_dir backup_dir + local promotion_failed=false + local -a packages=() candidate_paths=() stale_packages=() transaction_packages=() + local -A desired_packages=() stale_seen=() + + validate_sitectl_configuration + mkdirs + + mapfile -t packages < <(sitectl_package_list) + for package in "${packages[@]}"; do + desired_packages["$package"]=true + done + stage_dir="$(mktemp -d "${TMP_DIR}/sitectl-generation.XXXXXX")" || return 1 + payload_dir="${stage_dir}/payload" + tags_dir="${stage_dir}/tags" + backup_dir="${stage_dir}/backup" + mkdir -p "$payload_dir" "$tags_dir" "$backup_dir/bin" "$backup_dir/state" + + # Download or copy every verified package before replacing any live binary. + # A later plugin failure therefore leaves the complete previous set active. + for package in "${packages[@]}"; do + if ! install_release_package "$package" "$payload_dir" false; then + rm -rf -- "$stage_dir" + return 1 + fi + printf '%s\n' "$RELEASE_PACKAGE_TAG" >"${tags_dir}/${package}.version" + printf '%s\n' "$RELEASE_PACKAGE_SHA256" >"${tags_dir}/${package}.sha256" + done + + # Downloads and checksum verification do not mutate the live set. Take the + # application lock only for the backup/promotion window so a slow release + # download cannot block an unrelated restart, rollout, or database backup. + if declare -F acquire_cloud_compose_lifecycle_lock >/dev/null 2>&1; then + if ! acquire_cloud_compose_lifecycle_lock managed-runtime-package-update; then + rm -rf -- "$stage_dir" + return 1 + fi + fi + + # Converge the complete managed plugin set, not just packages that are + # still desired. Search each managed location so an incomplete old + # generation fails closed instead of leaving a stale command discoverable. + shopt -s nullglob + candidate_paths=( + "${PACKAGE_STATE_DIR}"/sitectl-*.version + "${PACKAGE_STATE_DIR}"/sitectl-*.sha256 + "${BIN_DIR}"/sitectl-* + "${PUBLISHED_BIN_DIR}"/sitectl-* + ) + shopt -u nullglob + for path in "${candidate_paths[@]}"; do + name="$(basename -- "$path")" + package="${name%.version}" + package="${package%.sha256}" + if [[ -n "${desired_packages[$package]:-}" || -n "${stale_seen[$package]:-}" ]]; then + continue + fi + stale_seen["$package"]=true + if ! validate_stale_managed_sitectl_package "$package"; then + rm -rf -- "$stage_dir" + if declare -F release_cloud_compose_lifecycle_lock >/dev/null 2>&1; then + release_cloud_compose_lifecycle_lock + fi + return 1 + fi + stale_packages+=("$package") + done + transaction_packages=("${packages[@]}" "${stale_packages[@]}") + + # Preserve the exact previous package/state set for rollback before the + # short promotion phase. Missing markers distinguish absent old files from + # zero-length/corrupt state that must also be restored exactly. + mkdir -p "$backup_dir/published" + for package in "${transaction_packages[@]}"; do + if [[ -e "${BIN_DIR}/${package}" || -L "${BIN_DIR}/${package}" ]]; then + cp -a -- "${BIN_DIR}/${package}" "${backup_dir}/bin/${package}" || promotion_failed=true + else + : >"${backup_dir}/bin/${package}.missing" + fi + for suffix in version sha256; do + if [[ -e "${PACKAGE_STATE_DIR}/${package}.${suffix}" || -L "${PACKAGE_STATE_DIR}/${package}.${suffix}" ]]; then + cp -a -- "${PACKAGE_STATE_DIR}/${package}.${suffix}" \ + "${backup_dir}/state/${package}.${suffix}" || promotion_failed=true + else + : >"${backup_dir}/state/${package}.${suffix}.missing" + fi + done + if [[ -e "${PUBLISHED_BIN_DIR}/${package}" || -L "${PUBLISHED_BIN_DIR}/${package}" ]]; then + cp -a -- "${PUBLISHED_BIN_DIR}/${package}" \ + "${backup_dir}/published/${package}" || promotion_failed=true + else + : >"${backup_dir}/published/${package}.missing" + fi + done + + if [[ "$promotion_failed" == "true" ]]; then + log "could not preserve the current sitectl generation; package set was not changed" + rm -rf -- "$stage_dir" + if declare -F release_cloud_compose_lifecycle_lock >/dev/null 2>&1; then + release_cloud_compose_lifecycle_lock + fi + return 1 + fi + + # Removing dropped plugins and promoting desired binaries are one locked + # transaction. Rollback below restores both updated and removed packages. + for package in "${stale_packages[@]}"; do + if ! rm -f -- \ + "${BIN_DIR}/${package}" \ + "${PUBLISHED_BIN_DIR}/${package}" \ + "${PACKAGE_STATE_DIR}/${package}.version" \ + "${PACKAGE_STATE_DIR}/${package}.sha256"; then + promotion_failed=true + break + fi + log "removed stale managed package ${package}" + done + if [[ "$promotion_failed" != "true" ]]; then + for package in "${packages[@]}"; do + mv -f -- "${payload_dir}/${package}" "${BIN_DIR}/${package}" || { + promotion_failed=true + break + } + if ! write_installed_release_state "$package" "$(<"${tags_dir}/${package}.version")" "${BIN_DIR}/${package}" || + ! ln -sfn "${BIN_DIR}/${package}" "${PUBLISHED_BIN_DIR}/${package}"; then + promotion_failed=true + break + fi + done + fi + + if [[ "$promotion_failed" == "true" ]]; then + log "package-set promotion failed; restoring the previous sitectl generation" + for package in "${transaction_packages[@]}"; do + if [[ -e "${backup_dir}/bin/${package}.missing" ]]; then + rm -f -- "${BIN_DIR}/${package}" + elif [[ -e "${backup_dir}/bin/${package}" || -L "${backup_dir}/bin/${package}" ]]; then + rm -f -- "${BIN_DIR}/${package}" + cp -a -- "${backup_dir}/bin/${package}" "${BIN_DIR}/${package}" || true + fi + for suffix in version sha256; do + if [[ -e "${backup_dir}/state/${package}.${suffix}.missing" ]]; then + rm -f -- "${PACKAGE_STATE_DIR}/${package}.${suffix}" + elif [[ -e "${backup_dir}/state/${package}.${suffix}" || -L "${backup_dir}/state/${package}.${suffix}" ]]; then + rm -f -- "${PACKAGE_STATE_DIR}/${package}.${suffix}" + cp -a -- "${backup_dir}/state/${package}.${suffix}" \ + "${PACKAGE_STATE_DIR}/${package}.${suffix}" || true + fi + done + rm -f -- "${PUBLISHED_BIN_DIR}/${package}" + if [[ -e "${backup_dir}/published/${package}" || -L "${backup_dir}/published/${package}" ]]; then + cp -a -- "${backup_dir}/published/${package}" \ + "${PUBLISHED_BIN_DIR}/${package}" || true + fi + done + rm -rf -- "$stage_dir" + if declare -F release_cloud_compose_lifecycle_lock >/dev/null 2>&1; then + release_cloud_compose_lifecycle_lock + fi + return 1 + fi + + rm -rf -- "$stage_dir" + if declare -F release_cloud_compose_lifecycle_lock >/dev/null 2>&1; then + release_cloud_compose_lifecycle_lock + fi +} + +validate_managed_artifact() { + local name="$1" url="$2" sha="$3" path="$4" + local mode="$5" owner="$6" group="$7" restart="$8" + + if [[ ! "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]]; then + log "invalid managed artifact name: ${name}" + return 1 + fi + if [[ ! "$url" =~ ^https://[^[:space:]]+$ ]]; then + log "managed artifact ${name} must use an HTTPS URL without whitespace" + return 1 + fi + if [[ ! "$sha" =~ ^[0-9a-f]{64}$ ]]; then + log "managed artifact ${name} has an invalid SHA-256 digest" + return 1 + fi + if [[ "$path" != /* || "$path" == "/" || "$path" == */ || "$path" == *//* || + "$path" =~ [[:cntrl:]] || "$path" =~ (^|/)\.\.?(/|$) ]]; then + log "managed artifact ${name} has an unsafe target path" + return 1 + fi + if [[ ! "$mode" =~ ^0?[0-7]{3}$ ]]; then + log "managed artifact ${name} has an invalid file mode" + return 1 + fi + if [[ ! "$owner" =~ ^[a-z_][a-z0-9_-]{0,31}\$?$ ]] || + [[ ! "$group" =~ ^[a-z_][a-z0-9_-]{0,31}\$?$ ]]; then + log "managed artifact ${name} has an invalid owner or group" + return 1 + fi + if [[ -n "$restart" && ! "$restart" =~ ^[A-Za-z0-9][A-Za-z0-9_.@:-]*\.service$ ]]; then + log "managed artifact ${name} has an invalid restart unit" + return 1 + fi +} + +managed_artifact_matches() { + local path="$1" expected_sha="$2" actual_sha + + [[ ! -L "$path" && -f "$path" ]] || return 1 + actual_sha="$(sha256sum -- "$path")" || return 1 + [[ "${actual_sha%% *}" == "$expected_sha" ]] +} + +managed_artifact_metadata_matches() { + local path="$1" mode="$2" owner="$3" group="$4" + local actual_mode actual_owner actual_group desired_mode + + [[ ! -L "$path" && -f "$path" ]] || return 1 + desired_mode="$(printf '%o' "$((8#$mode))")" + actual_mode="$(stat -c %a -- "$path")" || return 1 + actual_owner="$(stat -c %U -- "$path")" || return 1 + actual_group="$(stat -c %G -- "$path")" || return 1 + [[ "$actual_mode" == "$desired_mode" && "$actual_owner" == "$owner" && "$actual_group" == "$group" ]] +} + +preflight_managed_artifact_target() { + local name="$1" path="$2" owner="$3" group="$4" target_dir + + target_dir="$(dirname -- "$path")" + if ! managed_artifact_target_directory_safe "$target_dir"; then + log "managed artifact ${name} target directory chain is missing, redirected, or writable by another account: ${target_dir}" + return 1 + fi + if [[ -L "$path" || ( -e "$path" && ! -f "$path" ) ]]; then + log "managed artifact ${name} target is not a regular file: ${path}" + return 1 + fi + if ! getent passwd "$owner" >/dev/null; then + log "managed artifact ${name} owner does not exist: ${owner}" + return 1 + fi + if ! getent group "$group" >/dev/null; then + log "managed artifact ${name} group does not exist: ${group}" + return 1 + fi +} + +managed_artifact_target_directory_safe() { + local target_dir="$1" resolved current owner_uid mode mode_value + local installer_uid="$EUID" + + [[ "$target_dir" == /* && "$target_dir" != "/" && -d "$target_dir" && ! -L "$target_dir" ]] || return 1 + resolved="$(readlink -f -- "$target_dir")" || return 1 + [[ "$resolved" == "$target_dir" ]] || return 1 + + current="$target_dir" + while [[ "$current" != "/" ]]; do + [[ -d "$current" && ! -L "$current" ]] || return 1 + owner_uid="$(stat -c %u -- "$current")" || return 1 + mode="$(stat -c %a -- "$current")" || return 1 + [[ "$mode" =~ ^[0-7]{3,4}$ ]] || return 1 + mode_value=$((8#$mode)) + + # The production updater runs as root, so every directory component + # must be root-controlled. A non-root contract runner may also trust + # its own directories. Writable shared ancestors are safe only when + # root-owned and sticky (for example /tmp). + if [[ "$owner_uid" != "0" && "$owner_uid" != "$installer_uid" ]]; then + return 1 + fi + if (((mode_value & 8#022) != 0)); then + if [[ "$owner_uid" != "0" ]] || (((mode_value & 8#1000) == 0)); then + return 1 + fi + fi + current="$(dirname -- "$current")" + done +} + +write_artifact_state() { + local state_file="$1" value="$2" state_tmp + + state_tmp="$(mktemp "${state_file}.tmp.XXXXXX")" || return 1 + printf '%s\n' "$value" >"$state_tmp" + chmod 0640 "$state_tmp" + mv -f -- "$state_tmp" "$state_file" } install_managed_artifacts() { - local name url sha path mode owner group restart - local state_file tmp + local line name url sha path mode owner group restart index + local state_file failed_state download_tmp install_tmp target_dir target_name backup + local field_count + local -a names=() urls=() shas=() paths=() modes=() owners=() groups=() restarts=() + local -A seen_names=() seen_paths=() if [ ! -f "$ARTIFACT_MANIFEST" ]; then return 0 fi - while IFS=$'\t' read -r name url sha path mode owner group restart || [ -n "${name:-}" ]; do - if [ -z "${name:-}" ] || [[ "$name" == \#* ]]; then + # Validate and de-duplicate the complete manifest before touching the + # network, an install target, or service state. A bad later row must not + # leave earlier artifacts partially applied. + while IFS= read -r line || [ -n "$line" ]; do + if [ -z "$line" ] || [[ "$line" == \#* ]]; then continue fi - mode="${mode:-0755}" - owner="${owner:-root}" - group="${group:-root}" - restart="${restart:-}" + field_count="$(awk -F '\t' '{ print NF }' <<<"$line")" + if [[ "$field_count" != "8" ]]; then + log "managed artifact manifest row must contain exactly eight tab-separated fields" + return 1 + fi + IFS=$'\t' read -r name url sha path mode owner group restart <<<"$line" + validate_managed_artifact "$name" "$url" "$sha" "$path" "$mode" "$owner" "$group" "$restart" || return 1 + preflight_managed_artifact_target "$name" "$path" "$owner" "$group" || return 1 + + if [[ -n "${seen_names[$name]:-}" ]]; then + log "managed artifact manifest repeats name: $name" + return 1 + fi + if [[ -n "${seen_paths[$path]:-}" ]]; then + log "managed artifact manifest repeats target path: $path" + return 1 + fi + seen_names["$name"]=1 + seen_paths["$path"]=1 + names+=("$name") + urls+=("$url") + shas+=("$sha") + paths+=("$path") + modes+=("$mode") + owners+=("$owner") + groups+=("$group") + restarts+=("$restart") + done < "$ARTIFACT_MANIFEST" + + for ((index = 0; index < ${#names[@]}; index++)); do + name="${names[$index]}" + url="${urls[$index]}" + sha="${shas[$index]}" + path="${paths[$index]}" + mode="${modes[$index]}" + owner="${owners[$index]}" + group="${groups[$index]}" + restart="${restarts[$index]}" + state_file="${ARTIFACT_STATE_DIR}/${name}.sha256" + failed_state="${ARTIFACT_STATE_DIR}/${name}.failed" - if [ -f "$state_file" ] && [ "$(cat "$state_file")" = "$sha" ] && [ -e "$path" ]; then + if [ -f "$state_file" ] && [ "$(cat "$state_file")" = "$sha" ] && + managed_artifact_matches "$path" "$sha" && + managed_artifact_metadata_matches "$path" "$mode" "$owner" "$group"; then log "${name} artifact is already installed" continue fi - tmp="$(mktemp "${TMP_DIR}/${name}.XXXXXX")" + target_dir="$(dirname -- "$path")" + target_name="$(basename -- "$path")" + download_tmp="$(mktemp "${TMP_DIR}/${name}.download.XXXXXX")" + install_tmp="$(mktemp "${target_dir}/.${target_name}.cloud-compose.XXXXXX")" || { + rm -f -- "$download_tmp" + return 1 + } + backup="" log "installing managed artifact ${name}" - retry_until_success curl -fsSL -o "$tmp" "$url" - echo "${sha} ${tmp}" | sha256sum -c - - install -o "$owner" -g "$group" -m "$mode" "$tmp" "$path" - rm -f "$tmp" - printf '%s\n' "$sha" > "$state_file" + if ! retry_until_success curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 \ + --retry 5 --retry-all-errors --retry-delay 2 \ + --connect-timeout 10 --max-time 300 -o "$download_tmp" -- "$url" || + ! printf '%s %s\n' "$sha" "$download_tmp" | sha256sum -c - >/dev/null || + ! install -o "$owner" -g "$group" -m "$mode" "$download_tmp" "$install_tmp" || + ! managed_artifact_matches "$install_tmp" "$sha"; then + rm -f -- "$download_tmp" "$install_tmp" + log "managed artifact ${name} failed download or target verification" + return 1 + fi + rm -f -- "$download_tmp" + + if [[ -f "$path" ]]; then + backup="$(mktemp "${target_dir}/.${target_name}.rollback.XXXXXX")" || { + rm -f -- "$install_tmp" + return 1 + } + if ! cp -p -- "$path" "$backup"; then + rm -f -- "$install_tmp" "$backup" + log "managed artifact ${name} could not preserve the previous target" + return 1 + fi + fi + if ! mv -f -- "$install_tmp" "$path" || ! managed_artifact_matches "$path" "$sha"; then + rm -f -- "$install_tmp" + if [[ -n "$backup" ]]; then + mv -f -- "$backup" "$path" || \ + log "managed artifact ${name} also failed to restore its previous target" + fi + log "managed artifact ${name} failed atomic target verification" + return 1 + fi if [ -n "$restart" ]; then - systemctl try-restart "$restart" || true + if ! systemctl try-restart -- "$restart"; then + if [[ -n "$backup" ]]; then + if ! mv -f -- "$backup" "$path"; then + log "managed artifact ${name} restart failed and its previous target could not be restored" + write_artifact_state "$failed_state" "restart-and-rollback-failed ${sha} $(date -u +%Y-%m-%dT%H:%M:%SZ)" || true + return 1 + fi + else + rm -f -- "$path" + fi + write_artifact_state "$failed_state" "restart-failed ${sha} $(date -u +%Y-%m-%dT%H:%M:%SZ)" || true + systemctl try-restart -- "$restart" >/dev/null 2>&1 || true + log "managed artifact ${name} restart failed; restored the previous target" + return 1 + fi fi - done < "$ARTIFACT_MANIFEST" + rm -f -- "$backup" "$failed_state" + write_artifact_state "$state_file" "$sha" + done } update_internal_services() { - case "${LIBOPS_INTERNAL_SERVICES_AUTO_UPDATE:-true}" in + local services_dir="${LIBOPS_INTERNAL_SERVICES_DIR:-/mnt/disks/data/libops-internal}" + + case "${LIBOPS_INTERNAL_SERVICES_ENABLED:-false}" in + true | TRUE | 1 | yes | YES) ;; + *) return 0 ;; + esac + case "${LIBOPS_INTERNAL_SERVICES_AUTO_UPDATE:-false}" in true | TRUE | 1 | yes | YES) ;; *) return 0 ;; esac - if [ ! -f /mnt/disks/data/libops-internal/docker-compose.yaml ]; then + if [ ! -f "${services_dir}/docker-compose.yaml" ]; then return 0 fi if ! command -v docker >/dev/null 2>&1; then @@ -232,13 +832,13 @@ update_internal_services() { return 0 fi - pushd /mnt/disks/data/libops-internal >/dev/null + pushd "$services_dir" >/dev/null log "updating internal LibOps services" retry_until_success docker compose pull - if systemctl is-active --quiet internal-services.service; then - systemctl restart internal-services.service + if systemctl is-active --quiet cloud-compose-internal-services.service; then + systemctl restart cloud-compose-internal-services.service else - docker compose up -d --remove-orphans + log "internal LibOps services are inactive; leaving them stopped after pull" fi popd >/dev/null } @@ -279,4 +879,6 @@ main() { esac } -main "$@" +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/rootfs/home/cloud-compose/mariadb-backup.sh b/rootfs/home/cloud-compose/mariadb-backup.sh index 7779f9d..39d0069 100644 --- a/rootfs/home/cloud-compose/mariadb-backup.sh +++ b/rootfs/home/cloud-compose/mariadb-backup.sh @@ -2,32 +2,80 @@ set -euo pipefail -# shellcheck disable=SC1091 -source /home/cloud-compose/profile.sh -# shellcheck disable=SC1091 -source /home/cloud-compose/compose-apps.sh +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +profile_path="${CLOUD_COMPOSE_PROFILE_PATH:-$script_dir/profile.sh}" +compose_apps_path="${CLOUD_COMPOSE_COMPOSE_APPS_PATH:-$script_dir/compose-apps.sh}" +# shellcheck disable=SC1090 +source "$profile_path" +# shellcheck disable=SC1090 +source "$compose_apps_path" BACKUP_ROOT="${MARIADB_BACKUP_ROOT:-/mnt/disks/data/backups/mariadb}" today="$(date -u +%Y%m%d)" -while read -r app; do - if [ -z "$app" ]; then - continue - fi +acquire_cloud_compose_lifecycle_lock mariadb-backup + +# A backup timer must not turn on an application that an operator deliberately +# stopped. Holding the lifecycle lock closes the race with a concurrent stop or +# rollout before this state check. +if ! systemctl is-active --quiet cloud-compose.service; then + echo "Cloud Compose application service is inactive; skipping MariaDB backup" + exit 0 +fi + +backup_app() ( + local app="$1" + local backup_dir output staging_dir staging_output source_compose_app_env "$app" backup_dir="${BACKUP_ROOT}/${app}" - mkdir -p "$backup_dir" + if [[ -L "$BACKUP_ROOT" || ( -e "$BACKUP_ROOT" && ! -d "$BACKUP_ROOT" ) ]]; then + echo "MariaDB backup root is unsafe: $BACKUP_ROOT" >&2 + return 1 + fi + mkdir -p -- "$backup_dir" + if [[ -L "$backup_dir" || ! -d "$backup_dir" ]]; then + echo "MariaDB backup directory is unsafe: $backup_dir" >&2 + return 1 + fi - output="${backup_dir}/${today}-${SITECTL_CONTEXT_NAME}.sql.gz" - if [ -f "$output" ]; then - echo "MariaDB backup already exists for ${app}: ${output}" - continue + # The app key is already constrained to a safe basename by the project + # manifest, so it cannot change the backup destination. + output="${backup_dir}/${today}-${app}.sql.gz" + if [[ -e "$output" || -L "$output" ]]; then + if [[ ! -L "$output" && -f "$output" && -s "$output" ]] && gzip -t -- "$output"; then + echo "MariaDB backup already exists for ${app}: ${output}" + return 0 + fi + echo "Existing MariaDB backup is incomplete or unsafe: $output" >&2 + return 1 fi + staging_dir="$(mktemp -d "${backup_dir}/.${today}-${app}.staging.XXXXXX")" || return 1 + trap 'rm -rf -- "$staging_dir"' EXIT + staging_output="${staging_dir}/backup.sql.gz" + echo "Running MariaDB backup for ${app}" sitectl mariadb backup \ --context "$SITECTL_CONTEXT_NAME" \ --gzip \ - --output "$output" -done < <(compose_app_names) + --output "$staging_output" + if [[ -L "$staging_output" || ! -f "$staging_output" || ! -s "$staging_output" ]] || + ! gzip -t -- "$staging_output"; then + echo "MariaDB backup did not produce a valid gzip artifact for ${app}" >&2 + return 1 + fi + chmod 0640 "$staging_output" + if [[ -e "$output" || -L "$output" ]]; then + echo "MariaDB backup target appeared during staging: $output" >&2 + return 1 + fi + mv -- "$staging_output" "$output" + echo "MariaDB backup completed for ${app}: ${output}" +) + +apps=() +compose_app_names_array apps +for app in "${apps[@]}"; do + backup_app "$app" +done diff --git a/rootfs/home/cloud-compose/migrate-legacy-systemd-units.sh b/rootfs/home/cloud-compose/migrate-legacy-systemd-units.sh new file mode 100755 index 0000000..8319c65 --- /dev/null +++ b/rootfs/home/cloud-compose/migrate-legacy-systemd-units.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +set -euo pipefail + +unit_dir="${CLOUD_COMPOSE_SYSTEMD_UNIT_DIR:-/etc/systemd/system}" + +remove_legacy_unit() { + local unit="$1" + shift + local signature + local unit_file="${unit_dir}/${unit}" + + # Never remove a host unit merely because its generic name collides. Only + # migrate the exact unit shape previously shipped by Cloud Compose. + if [[ -L "$unit_file" || ! -f "$unit_file" ]]; then + return 0 + fi + for signature in "$@"; do + if ! grep -Fq -- "$signature" "$unit_file"; then + return 0 + fi + done + + systemctl disable --now "$unit" >/dev/null 2>&1 || true + rm -f -- "$unit_file" +} + +remove_legacy_unit cron.service \ + 'Description=cron' \ + 'ExecStart=/bin/bash /home/cloud-compose/cron.sh' +remove_legacy_unit cron.timer \ + 'Description=cron' \ + 'OnBootSec=10m' \ + 'OnUnitInactiveSec=24h' \ + 'WakeSystem=true' +remove_legacy_unit vault-agent.service \ + 'ConditionPathExists=/etc/vault-agent.d/cloud-compose.hcl' \ + 'ExecStart=/usr/local/bin/vault agent -config=/etc/vault-agent.d/cloud-compose.hcl' +remove_legacy_unit internal-services.service \ + 'Description=Internal Services (Ping, Metrics, Power Management)' \ + 'WorkingDirectory=/mnt/disks/data/libops-internal' +remove_legacy_unit internal-services.timer \ + 'Description=Delay Internal Services until 20m after initial boot' \ + 'OnBootSec=20min' \ + 'Unit=internal-services.service' diff --git a/rootfs/home/cloud-compose/mount-overlays.sh b/rootfs/home/cloud-compose/mount-overlays.sh new file mode 100755 index 0000000..81ba681 --- /dev/null +++ b/rootfs/home/cloud-compose/mount-overlays.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash + +set -euo pipefail + +profile_path="${CLOUD_COMPOSE_PROFILE_PATH:-/home/cloud-compose/profile.sh}" +overlay_script="${CLOUD_COMPOSE_OVERLAY_INIT_PATH:-/home/cloud-compose/overlay-init.sh}" + +# shellcheck disable=SC1090 +source "$profile_path" + +if [[ -z "${DOCKER_VOLUME_OVERLAYS:-}" ]]; then + exit 0 +fi +if [[ "${CLOUD_COMPOSE_PROVIDER:-}" != "gcp" ]]; then + echo "Docker volume overlays are supported only on GCP" >&2 + exit 1 +fi +if ! mountpoint -q /mnt/disks/prod-readonly; then + echo "The production overlay source is not mounted read-only" >&2 + exit 1 +fi + +read -r -a volumes <<<"$DOCKER_VOLUME_OVERLAYS" +if [[ "${#volumes[@]}" -eq 0 ]]; then + echo "DOCKER_VOLUME_OVERLAYS did not contain a volume name" >&2 + exit 1 +fi +for volume in "${volumes[@]}"; do + bash "$overlay_script" "$volume" +done diff --git a/rootfs/home/cloud-compose/overlay-init.sh b/rootfs/home/cloud-compose/overlay-init.sh index f16f6a2..79033e0 100644 --- a/rootfs/home/cloud-compose/overlay-init.sh +++ b/rootfs/home/cloud-compose/overlay-init.sh @@ -1,55 +1,172 @@ #!/usr/bin/env bash -set -eou pipefail -set -x - -# Argument 1 (MANDATORY): The docker volume name (e.g.,compose_ojs-public) -VOLUME="$1" -# Argument 2 (OPTIONAL): Flag to perform a destructive reset. -# Defaults to 'false'. Check for non-empty argument. -# We check if $2 is passed at all to handle '1' or 'true'. -RESET_FLAG="${2:-}" -PERFORM_RESET=false -if [[ -n "$RESET_FLAG" ]]; then - PERFORM_RESET=true -fi +set -euo pipefail -DIR="/mnt/disks/volumes/$VOLUME" -LOWER_DIR="/mnt/disks/prod-readonly/$VOLUME" -UPPER_DIR="/mnt/disks/volumes/.overlay/$VOLUME/upper" -WORK_DIR="/mnt/disks/volumes/.overlay/$VOLUME/work" +log() { + printf '[overlay-init] %s\n' "$*" >&2 +} -if ! $PERFORM_RESET; then - mkdir -p "/mnt/disks/volumes/${VOLUME}" - mkdir -p "/mnt/disks/volumes/.overlay/${VOLUME}/upper" - mkdir -p "/mnt/disks/volumes/.overlay/${VOLUME}/work" -fi +usage() { + echo "Usage: $0 VOLUME [true|false]" >&2 +} -if [ ! -d "$DIR" ] || [ ! -d "$LOWER_DIR" ] || [ ! -d "$UPPER_DIR" ] || [ ! -d "$WORK_DIR" ]; then - echo "$DIR doesn't exist as an overlay" - exit 1 -fi +validate_root() { + local root="$1" label="$2" canonical -if $PERFORM_RESET; then - if mountpoint -q "$DIR"; then - echo "Unmounting current volume at $DIR..." - umount "$DIR" - else - echo "Volume $DIR is not mounted. Skipping unmount." - fi - rm -rf "/mnt/disks/volumes/.overlay/$VOLUME/upper/*" -fi + if [[ "$root" != /* || "$root" == "/" || "$root" =~ [[:space:]] || + "$root" == *","* || "$root" == *":"* || "$root" == *"\\"* || + "$root" =~ (^|/)\.\.?(/|$) || + -L "$root" || ! -d "$root" ]]; then + log "$label root is missing or unsafe: $root" + return 1 + fi + canonical="$(readlink -f -- "$root")" || return 1 + if [[ "$canonical" != "$root" ]]; then + log "$label root contains a symbolic-link traversal: $root" + return 1 + fi +} -# make this script idempotent -if mountpoint -q "$DIR"; then - if ! $PERFORM_RESET; then - echo "Volume $DIR is already mounted. Skipping mount (Run with '1' to reset)." - exit 0 +ensure_directory() { + local path="$1" boundary="$2" canonical + + if [[ "$path" != "$boundary/"* || -L "$path" || ( -e "$path" && ! -d "$path" ) ]]; then + log "Overlay directory is outside its boundary or unsafe: $path" + return 1 fi -fi + # Resolve the would-be path before creating it. `install -d` follows a + # symbolic-link parent, so checking only the final directory would detect + # traversal after already creating attacker-selected directories. + canonical="$(realpath -m -- "$path")" || return 1 + if [[ "$canonical" != "$path" ]]; then + log "Overlay directory contains a symbolic-link traversal: $path" + return 1 + fi + install -d -m 0755 -- "$path" + if [[ -L "$path" || ! -d "$path" ]]; then + log "Overlay directory changed during preparation: $path" + return 1 + fi + canonical="$(readlink -f -- "$path")" || return 1 + if [[ "$canonical" != "$path" ]]; then + log "Overlay directory contains a symbolic-link traversal: $path" + return 1 + fi +} + +verify_overlay_mount() { + local target="$1" lower="$2" upper="$3" work="$4" + local filesystem options + + if ! mountpoint -q -- "$target"; then + return 1 + fi + filesystem="$(findmnt -n -o FSTYPE --target "$target")" || return 2 + options="$(findmnt -n -o OPTIONS --target "$target")" || return 2 + if [[ "$filesystem" != "overlay" || + ",$options," != *",lowerdir=$lower,"* || + ",$options," != *",upperdir=$upper,"* || + ",$options," != *",workdir=$work,"* ]]; then + log "$target is mounted, but not from the expected overlay directories" + return 2 + fi +} + +clear_overlay_state() { + local path -mount \ - -t overlay overlay \ - -o "lowerdir=$LOWER_DIR,upperdir=$UPPER_DIR,workdir=$WORK_DIR" \ - "/mnt/disks/volumes/$VOLUME" -echo "Volume $VOLUME is now active and mounted." + for path in "$@"; do + if [[ -L "$path" || ! -d "$path" ]]; then + log "Refusing to clear unsafe overlay state directory: $path" + return 1 + fi + find "$path" -xdev -mindepth 1 -delete + if [[ -n "$(find "$path" -xdev -mindepth 1 -print -quit)" ]]; then + log "Overlay state directory could not be emptied: $path" + return 1 + fi + done +} + +main() { + local volume reset_flag perform_reset=false + local volumes_root lower_root target lower_dir upper_dir work_dir + local mount_status=0 + + if [[ $# -lt 1 || $# -gt 2 ]]; then + usage + return 2 + fi + volume="$1" + reset_flag="${2:-false}" + if [[ ! "$volume" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,254}$ ]]; then + log "Volume must be a safe Docker volume name" + return 2 + fi + case "${reset_flag,,}" in + true | 1 | yes) perform_reset=true ;; + false | 0 | no | "") perform_reset=false ;; + *) + log "Reset flag must be true or false" + return 2 + ;; + esac + + volumes_root="${CLOUD_COMPOSE_VOLUMES_ROOT:-/mnt/disks/volumes}" + lower_root="${CLOUD_COMPOSE_OVERLAY_LOWER_ROOT:-/mnt/disks/prod-readonly}" + validate_root "$volumes_root" "Volumes" || return 1 + validate_root "$lower_root" "Read-only lower" || return 1 + + target="$volumes_root/$volume" + lower_dir="$lower_root/$volume" + upper_dir="$volumes_root/.overlay/$volume/upper" + work_dir="$volumes_root/.overlay/$volume/work" + + ensure_directory "$target" "$volumes_root" || return 1 + ensure_directory "$upper_dir" "$volumes_root" || return 1 + ensure_directory "$work_dir" "$volumes_root" || return 1 + if [[ -L "$lower_dir" || ! -d "$lower_dir" || "$(readlink -f -- "$lower_dir")" != "$lower_dir" ]]; then + log "Read-only lower directory is missing or unsafe: $lower_dir" + return 1 + fi + if [[ "$(stat -c %d -- "$upper_dir")" != "$(stat -c %d -- "$work_dir")" ]]; then + log "Overlay upper and work directories must be on the same filesystem" + return 1 + fi + + verify_overlay_mount "$target" "$lower_dir" "$upper_dir" "$work_dir" || mount_status=$? + case "$mount_status" in + 0) + if [[ "$perform_reset" != "true" ]]; then + log "Volume $volume is already mounted from the expected overlay" + return 0 + fi + log "Unmounting overlay volume $volume for reset" + umount -- "$target" + ;; + 1) + ;; + *) + return 1 + ;; + esac + + if [[ "$perform_reset" == "true" ]]; then + log "Clearing writable overlay state for $volume" + clear_overlay_state "$upper_dir" "$work_dir" || return 1 + fi + + mount -t overlay overlay \ + -o "lowerdir=$lower_dir,upperdir=$upper_dir,workdir=$work_dir" \ + "$target" + if ! verify_overlay_mount "$target" "$lower_dir" "$upper_dir" "$work_dir"; then + umount -- "$target" >/dev/null 2>&1 || true + log "Overlay mount verification failed for $volume" + return 1 + fi + log "Volume $volume is active" +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/rootfs/home/cloud-compose/persist-filesystems.sh b/rootfs/home/cloud-compose/persist-filesystems.sh new file mode 100755 index 0000000..03a761a --- /dev/null +++ b/rootfs/home/cloud-compose/persist-filesystems.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash + +set -euo pipefail + +log() { + printf '[filesystem-persist] %s\n' "$*" >&2 +} + +usage() { + echo "Usage: $0 DATA_DEVICE VOLUMES_DEVICE [READ_ONLY_OVERLAY_DEVICE]" >&2 +} + +validate_device_path() { + local value="$1" + + [[ "$value" =~ ^/dev/[A-Za-z0-9._/+:-]+$ ]] && + [[ "$value" != *$'\n'* ]] && [[ "$value" != *$'\r'* ]] +} + +digitalocean_automatic_mount() { + local device_path="$1" volume_name + + case "$device_path" in + /dev/disk/by-id/scsi-0DO_Volume_*) + volume_name="${device_path#/dev/disk/by-id/scsi-0DO_Volume_}" + ;; + *) + return 1 + ;; + esac + if [[ ! "$volume_name" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,254}$ ]]; then + return 2 + fi + printf '/mnt/%s\n' "${volume_name//-/_}" +} + +remove_digitalocean_mount_unit() { + local device_path="$1" action="${2:-remove}" provider_mount unit_name systemd_dir unit_path + local link resolved_link + local -a what_values where_values links + + provider_mount="$(digitalocean_automatic_mount "$device_path")" || return 0 + unit_name="mnt-${provider_mount#/mnt/}.mount" + systemd_dir="${CLOUD_COMPOSE_SYSTEMD_DIR:-/etc/systemd/system}" + if [[ "$systemd_dir" != /* || "$systemd_dir" == "/" || -L "$systemd_dir" || + "$systemd_dir" == *$'\n'* || "$systemd_dir" == *$'\r'* ]]; then + log "Refusing unsafe systemd unit directory: $systemd_dir" + return 1 + fi + if [[ ! -d "$systemd_dir" ]]; then + return 0 + fi + + unit_path="$systemd_dir/$unit_name" + links=() + shopt -s nullglob + links=("$systemd_dir"/*.wants/"$unit_name") + shopt -u nullglob + + if [[ ! -e "$unit_path" && ! -L "$unit_path" ]]; then + if ((${#links[@]} > 0)); then + log "DigitalOcean mount unit link exists without its expected unit: $unit_path" + return 1 + fi + return 0 + fi + if [[ -L "$unit_path" || ! -f "$unit_path" ]]; then + log "DigitalOcean mount unit is not a regular provider-owned file: $unit_path" + return 1 + fi + + mapfile -t what_values < <(sed -n 's/^[[:space:]]*What=//p' "$unit_path") + mapfile -t where_values < <(sed -n 's/^[[:space:]]*Where=//p' "$unit_path") + if ((${#what_values[@]} != 1 || ${#where_values[@]} != 1)) || + [[ "${what_values[0]}" != "$device_path" || "${where_values[0]}" != "$provider_mount" ]]; then + log "Refusing to remove unexpected DigitalOcean mount unit contents: $unit_path" + return 1 + fi + + for link in "${links[@]}"; do + if [[ -L "$(dirname -- "$link")" || ! -d "$(dirname -- "$link")" || ! -L "$link" ]]; then + log "DigitalOcean mount unit wants entry is not a symlink: $link" + return 1 + fi + resolved_link="$(readlink -f -- "$link" 2>/dev/null || true)" + if [[ "$resolved_link" != "$unit_path" ]]; then + log "DigitalOcean mount unit wants link has an unexpected target: $link" + return 1 + fi + done + + if [[ "$action" == "validate" ]]; then + return 0 + fi + + if command -v systemctl >/dev/null 2>&1; then + if ! systemctl disable --now -- "$unit_name"; then + log "Could not disable superseded DigitalOcean mount unit: $unit_name" + return 1 + fi + fi + + for link in "${links[@]}"; do + rm -f -- "$link" + done + rm -f -- "$unit_path" + log "Removed superseded provider mount unit $unit_name" +} + +main() { + local data_device volumes_device overlay_device fstab_path lock_path tmp status + local data_provider_mount="" volumes_provider_mount="" + local begin_marker="# BEGIN cloud-compose persistent mounts" + local end_marker="# END cloud-compose persistent mounts" + + if [[ $# -lt 2 || $# -gt 3 ]]; then + usage + return 2 + fi + + data_device="$1" + volumes_device="$2" + overlay_device="${3:-}" + if ! validate_device_path "$data_device" || ! validate_device_path "$volumes_device"; then + log "Device paths must be safe absolute /dev paths" + return 2 + fi + if [[ "$data_device" == "$volumes_device" ]]; then + log "Data and Docker-volume devices must be distinct" + return 2 + fi + if [[ -n "$overlay_device" ]]; then + if ! validate_device_path "$overlay_device"; then + log "Read-only overlay device must be a safe absolute /dev path" + return 2 + fi + if [[ "$overlay_device" == "$data_device" || "$overlay_device" == "$volumes_device" ]]; then + log "Read-only overlay device must be distinct from writable devices" + return 2 + fi + fi + if [[ "$data_device" == /dev/disk/by-id/scsi-0DO_Volume_* ]]; then + data_provider_mount="$(digitalocean_automatic_mount "$data_device")" || { + log "Invalid DigitalOcean data-volume device path: $data_device" + return 2 + } + fi + if [[ "$volumes_device" == /dev/disk/by-id/scsi-0DO_Volume_* ]]; then + volumes_provider_mount="$(digitalocean_automatic_mount "$volumes_device")" || { + log "Invalid DigitalOcean Docker-volume device path: $volumes_device" + return 2 + } + fi + + fstab_path="${CLOUD_COMPOSE_FSTAB_PATH:-/etc/fstab}" + lock_path="${CLOUD_COMPOSE_FSTAB_LOCK_PATH:-/run/cloud-compose-fstab.lock}" + if [[ "$fstab_path" != /* || "$fstab_path" == "/" || -L "$fstab_path" || + "$fstab_path" == *$'\n'* || "$fstab_path" == *$'\r'* ]]; then + log "Refusing unsafe fstab path: $fstab_path" + return 2 + fi + if [[ "$lock_path" != /* || "$lock_path" == "/" || "$lock_path" == *$'\n'* || + "$lock_path" == *$'\r'* ]]; then + log "Refusing unsafe fstab lock path: $lock_path" + return 2 + fi + if [[ -e "$fstab_path" && ! -f "$fstab_path" ]]; then + log "fstab path is not a regular file: $fstab_path" + return 2 + fi + + install -d -m 0755 -- "$(dirname -- "$fstab_path")" "$(dirname -- "$lock_path")" + touch "$fstab_path" + exec 9>"$lock_path" + if command -v flock >/dev/null 2>&1; then + flock -x 9 + fi + + # Validate provider-owned persistence before mutating fstab. Validation is + # repeated during removal to keep an unexpected local unit fail-closed. + remove_digitalocean_mount_unit "$data_device" validate + remove_digitalocean_mount_unit "$volumes_device" validate + + tmp="$(mktemp "${fstab_path}.tmp.XXXXXX")" + trap 'rm -f -- "$tmp"' EXIT + awk -v begin="$begin_marker" -v end="$end_marker" \ + -v data_device="$data_device" -v data_provider_mount="$data_provider_mount" \ + -v volumes_device="$volumes_device" -v volumes_provider_mount="$volumes_provider_mount" ' + $0 == begin { managed = 1; next } + $0 == end { managed = 0; next } + !managed { + if (data_provider_mount != "" && $2 == data_provider_mount) { + if ($1 == data_device) next + conflict = 1 + } + if (volumes_provider_mount != "" && $2 == volumes_provider_mount) { + if ($1 == volumes_device) next + conflict = 1 + } + if ($2 == "/mnt/disks/data" || + $2 == "/mnt/disks/volumes" || + $2 == "/mnt/disks/data/docker/volumes" || + $2 == "/mnt/disks/prod-readonly") { + conflict = 1 + } + print + } + END { if (managed || conflict) exit 42 } + ' "$fstab_path" >"$tmp" || { + status=$? + if [[ "$status" -eq 42 ]]; then + log "fstab contains an unterminated managed block or an unmanaged cloud-compose mount target" + else + log "Could not inspect $fstab_path" + fi + return 1 + } + + { + printf '%s\n' "$begin_marker" + printf '%s\t/mnt/disks/data\text4\tdefaults,nofail,x-systemd.device-timeout=120s\t0\t2\n' "$data_device" + printf '%s\t/mnt/disks/volumes\text4\tdefaults,nofail,x-systemd.device-timeout=120s\t0\t2\n' "$volumes_device" + printf '/mnt/disks/volumes\t/mnt/disks/data/docker/volumes\tnone\tbind,nofail,x-systemd.requires=/mnt/disks/volumes\t0\t0\n' + if [[ -n "$overlay_device" ]]; then + printf '%s\t/mnt/disks/prod-readonly\text4\tro,nofail,x-systemd.device-timeout=120s\t0\t2\n' "$overlay_device" + fi + printf '%s\n' "$end_marker" + } >>"$tmp" + # Retire the validated provider units before committing the replacement + # fstab. If unit cleanup fails, the original provider persistence remains + # intact and the prepared fstab is discarded by the EXIT trap. + remove_digitalocean_mount_unit "$data_device" + remove_digitalocean_mount_unit "$volumes_device" + + chmod --reference="$fstab_path" "$tmp" + chown --reference="$fstab_path" "$tmp" + mv -f -- "$tmp" "$fstab_path" + trap - EXIT + + if command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload + fi + log "Persistent data, Docker-volume, and bind mounts are recorded in $fstab_path" +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/rootfs/home/cloud-compose/prepare-app-sources.sh b/rootfs/home/cloud-compose/prepare-app-sources.sh new file mode 100644 index 0000000..6b98879 --- /dev/null +++ b/rootfs/home/cloud-compose/prepare-app-sources.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +profile_path="${CLOUD_COMPOSE_PROFILE_PATH:-$script_dir/profile.sh}" +compose_apps_path="${CLOUD_COMPOSE_COMPOSE_APPS_PATH:-$script_dir/compose-apps.sh}" +cd "$script_dir" +# shellcheck disable=SC1090 +source "$profile_path" +# shellcheck disable=SC1090 +source "$compose_apps_path" + +apps=() +compose_app_names_array apps +for app in "${apps[@]}"; do + # This phase performs source validation and checkout only. Application + # scaffold/plugin lifecycle commands must wait for Vault readiness. + clone_or_update_compose_app "$app" +done diff --git a/rootfs/home/cloud-compose/prepare-filesystem.sh b/rootfs/home/cloud-compose/prepare-filesystem.sh new file mode 100644 index 0000000..7b78362 --- /dev/null +++ b/rootfs/home/cloud-compose/prepare-filesystem.sh @@ -0,0 +1,332 @@ +#!/usr/bin/env bash + +set -euo pipefail + +log() { + printf '[filesystem-prep] %s\n' "$*" >&2 +} + +usage() { + echo "Usage: $0 DEVICE_PATH MOUNT_PATH" >&2 +} + +is_block_device() { + [[ -b "$1" ]] +} + +digitalocean_automatic_mount() { + local device_path="$1" volume_name + + case "$device_path" in + /dev/disk/by-id/scsi-0DO_Volume_*) + volume_name="${device_path#/dev/disk/by-id/scsi-0DO_Volume_}" + ;; + *) + return 1 + ;; + esac + + if [[ ! "$volume_name" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,254}$ ]]; then + return 2 + fi + + # DigitalOcean's automatic format-and-mount convention replaces hyphens + # in the volume name with underscores beneath /mnt. + printf '/mnt/%s\n' "${volume_name//-/_}" +} + +settle_digitalocean_automount() { + local device_path="$1" settle_seconds="$2" + + # DigitalOcean's legacy automatic-format path is driven by udev and a + # generated systemd mount unit. Device-node appearance alone does not mean + # that workflow is finished, so wait for udev before inspecting or mutating + # the filesystem. + if command -v udevadm >/dev/null 2>&1; then + if ! udevadm settle --timeout="$settle_seconds"; then + log "DigitalOcean automount udev processing did not settle for $device_path" + return 1 + fi + fi +} + +start_digitalocean_automount() { + local device_path="$1" provider_mount="$2" + local systemd_dir unit_name unit_path + local -a what_values where_values + + systemd_dir="${CLOUD_COMPOSE_SYSTEMD_DIR:-/etc/systemd/system}" + if [[ "$systemd_dir" != /* || "$systemd_dir" == "/" || -L "$systemd_dir" || + "$systemd_dir" == *$'\n'* || "$systemd_dir" == *$'\r'* ]]; then + log "Refusing unsafe systemd unit directory: $systemd_dir" + return 1 + fi + if [[ ! -d "$systemd_dir" ]]; then + return 0 + fi + + unit_name="mnt-$(basename -- "$provider_mount").mount" + unit_path="$systemd_dir/$unit_name" + if [[ ! -e "$unit_path" && ! -L "$unit_path" ]]; then + return 0 + fi + if [[ -L "$unit_path" || ! -f "$unit_path" ]]; then + log "DigitalOcean mount unit is not a regular provider-owned file: $unit_path" + return 1 + fi + + mapfile -t what_values < <(sed -n 's/^[[:space:]]*What=//p' "$unit_path") + mapfile -t where_values < <(sed -n 's/^[[:space:]]*Where=//p' "$unit_path") + if ((${#what_values[@]} != 1 || ${#where_values[@]} != 1)) || + [[ "${what_values[0]}" != "$device_path" || "${where_values[0]}" != "$provider_mount" ]]; then + log "Refusing unexpected DigitalOcean mount unit contents: $unit_path" + return 1 + fi + + if ! command -v systemctl >/dev/null 2>&1; then + log "A DigitalOcean mount unit exists but systemctl is unavailable: $unit_path" + return 1 + fi + systemctl daemon-reload + # systemctl start waits for the mount job unless explicitly told not to. + # Completing it here closes the race between udev device discovery and an + # offline fsck or format by cloud-compose. + if ! systemctl start -- "$unit_name"; then + log "DigitalOcean automount unit failed: $unit_name" + return 1 + fi +} + +device_mount_targets() { + local device="$1" status=0 output + + output="$(findmnt -rn -o TARGET --source "$device" 2>/dev/null)" || status=$? + case "$status" in + 0 | 1) printf '%s' "$output" ;; + *) + log "Could not inspect existing mounts for $device" + return 1 + ;; + esac +} + +require_device_unmounted() { + local device="$1" operation="$2" targets + + targets="$(device_mount_targets "$device")" || return 1 + if [[ -n "$targets" ]]; then + log "$device became mounted before $operation; refusing offline mutation: ${targets//$'\n'/ }" + return 1 + fi +} + +main() { + local device_path mount_path device filesystem_type mounted_source resolved_mounted_source + local already_mounted blkid_status fsck_status wait_seconds sleep_seconds settle_seconds + local device_mounts_output provider_mount="" moved_source resolved_moved_source + local target_contents + local -a device_mounts + + if [[ $# -ne 2 ]]; then + usage + return 2 + fi + + device_path="$1" + mount_path="$2" + + if [[ "$device_path" != /dev/* || "$device_path" == *$'\n'* || "$device_path" == *$'\r'* ]]; then + log "Refusing unsafe device path: $device_path" + return 2 + fi + if [[ "$mount_path" != /* || "$mount_path" == "/" || "$mount_path" == *$'\n'* || + "$mount_path" == *$'\r'* || "$mount_path" =~ (^|/)\.\.?(/|$) ]]; then + log "Refusing unsafe mount path: $mount_path" + return 2 + fi + + wait_seconds="${FILESYSTEM_DEVICE_WAIT_SECONDS:-120}" + if [[ ! "$wait_seconds" =~ ^[1-9][0-9]{0,2}$ ]] || ((10#$wait_seconds > 600)); then + log "FILESYSTEM_DEVICE_WAIT_SECONDS must be an integer from 1 through 600" + return 2 + fi + while true; do + device="$(readlink -f -- "$device_path" 2>/dev/null || true)" + if [[ -n "$device" ]] && is_block_device "$device"; then + break + fi + if ((10#$wait_seconds == 0)); then + log "Block device did not appear before the wait deadline: $device_path" + return 1 + fi + sleep_seconds=2 + if ((10#$wait_seconds < sleep_seconds)); then + sleep_seconds=$((10#$wait_seconds)) + fi + log "Waiting for block device: $device_path" + sleep "$sleep_seconds" + wait_seconds=$((10#$wait_seconds - sleep_seconds)) + done + + settle_seconds="${FILESYSTEM_AUTOMOUNT_SETTLE_SECONDS:-60}" + if [[ ! "$settle_seconds" =~ ^[1-9][0-9]{0,2}$ ]] || ((10#$settle_seconds > 600)); then + log "FILESYSTEM_AUTOMOUNT_SETTLE_SECONDS must be an integer from 1 through 600" + return 2 + fi + if provider_mount="$(digitalocean_automatic_mount "$device_path")"; then + settle_digitalocean_automount "$device_path" "$settle_seconds" + else + provider_mount="" + fi + + already_mounted=false + mkdir -p -- "$mount_path" + if [[ -L "$mount_path" || ! -d "$mount_path" ]]; then + log "Target mount path is not a safe directory: $mount_path" + return 1 + fi + device_mounts_output="$(device_mount_targets "$device")" || return 1 + device_mounts=() + if [[ -n "$device_mounts_output" ]]; then + mapfile -t device_mounts <<<"$device_mounts_output" + fi + # If the legacy provider unit exists but udev has not started it yet, + # finish that job synchronously and inspect the resulting mount set before + # any offline filesystem operation. Do not start it when another mount is + # already present, which would create a duplicate alias. + if ((${#device_mounts[@]} == 0)) && [[ -n "$provider_mount" ]]; then + start_digitalocean_automount "$device_path" "$provider_mount" + device_mounts_output="$(device_mount_targets "$device")" || return 1 + if [[ -n "$device_mounts_output" ]]; then + mapfile -t device_mounts <<<"$device_mounts_output" + fi + fi + if ((${#device_mounts[@]} > 1)); then + log "$device is mounted at multiple targets; refusing to continue: ${device_mounts[*]}" + return 1 + fi + + if ((${#device_mounts[@]} == 1)); then + if [[ "${device_mounts[0]}" == "$mount_path" ]]; then + mounted_source="$(findmnt -n -o SOURCE --target "$mount_path")" || { + log "Could not determine the mounted source for $mount_path" + return 1 + } + resolved_mounted_source="$(readlink -f -- "$mounted_source" 2>/dev/null || true)" + if [[ -z "$resolved_mounted_source" || "$resolved_mounted_source" != "$device" ]]; then + log "$mount_path is already mounted from $mounted_source, expected $device" + return 1 + fi + already_mounted=true + elif [[ -n "$provider_mount" && "${device_mounts[0]}" == "$provider_mount" && + ! -L "$provider_mount" && -d "$provider_mount" ]] && mountpoint -q -- "$provider_mount"; then + mounted_source="$(findmnt -n -o SOURCE --target "$provider_mount")" || { + log "Could not determine the mounted source for $provider_mount" + return 1 + } + resolved_mounted_source="$(readlink -f -- "$mounted_source" 2>/dev/null || true)" + if [[ -z "$resolved_mounted_source" || "$resolved_mounted_source" != "$device" ]]; then + log "$provider_mount is mounted from $mounted_source, expected $device" + return 1 + fi + target_contents="$(find "$mount_path" -mindepth 1 -maxdepth 1 -print -quit)" || { + log "Could not safely inspect target mount directory: $mount_path" + return 1 + } + if [[ -n "$target_contents" ]]; then + log "Target mount directory is not empty; refusing to hide its contents: $mount_path" + return 1 + fi + + mount --move "$provider_mount" "$mount_path" + device_mounts_output="$(device_mount_targets "$device")" || return 1 + device_mounts=() + if [[ -n "$device_mounts_output" ]]; then + mapfile -t device_mounts <<<"$device_mounts_output" + fi + if ((${#device_mounts[@]} != 1)) || [[ "${device_mounts[0]}" != "$mount_path" ]]; then + log "Relocated mount did not become the device's only target: ${device_mounts[*]:-none}" + mount --move "$mount_path" "$provider_mount" >/dev/null 2>&1 || true + return 1 + fi + moved_source="$(findmnt -n -o SOURCE --target "$mount_path")" || { + log "Could not verify the relocated mount at $mount_path" + mount --move "$mount_path" "$provider_mount" >/dev/null 2>&1 || true + return 1 + } + resolved_moved_source="$(readlink -f -- "$moved_source" 2>/dev/null || true)" + if [[ -z "$resolved_moved_source" || "$resolved_moved_source" != "$device" ]]; then + log "Relocated mount at $mount_path is from $moved_source, expected $device" + mount --move "$mount_path" "$provider_mount" >/dev/null 2>&1 || true + return 1 + fi + rmdir -- "$provider_mount" 2>/dev/null || true + already_mounted=true + log "Moved provider-owned mount $provider_mount to $mount_path" + else + log "$device is mounted at unexpected target ${device_mounts[0]}; refusing to continue" + return 1 + fi + fi + + filesystem_type="" + blkid_status=0 + filesystem_type="$(blkid -p -s TYPE -o value -- "$device" 2>/dev/null)" || blkid_status=$? + + case "$blkid_status" in + 0) + if [[ "$filesystem_type" != "ext4" ]]; then + log "Existing filesystem on $device is '$filesystem_type', expected ext4; refusing to format" + return 1 + fi + + if [[ "$already_mounted" != "true" ]]; then + require_device_unmounted "$device" "filesystem check" + fsck_status=0 + # resize2fs can require a complete offline check even when the + # ext4 clean bit lets a normal preen return immediately. Force + # that check while the device is still proven unmounted so a + # preserved disk can be grown safely during a VM replacement. + fsck.ext4 -f -p -- "$device" || fsck_status=$? + case "$fsck_status" in + 0 | 1) + ;; + 2 | 3) + log "Filesystem check for $device requires a reboot; refusing to mount or format" + return 2 + ;; + *) + log "Filesystem check for $device failed with status $fsck_status; refusing to format" + return 1 + ;; + esac + fi + if ! resize2fs -- "$device"; then + log "Could not grow the ext4 filesystem on $device" + return 1 + fi + ;; + 2) + if [[ "$already_mounted" == "true" ]]; then + log "Mounted device $device has no detectable filesystem signature" + return 1 + fi + log "No filesystem signature found on $device; creating ext4" + require_device_unmounted "$device" "filesystem creation" + mkfs.ext4 -m 0 -E lazy_itable_init=1,lazy_journal_init=1,nodiscard -- "$device" + ;; + *) + log "Could not safely inspect filesystem signatures on $device (blkid status $blkid_status)" + return 1 + ;; + esac + + if [[ "$already_mounted" != "true" ]]; then + require_device_unmounted "$device" "cloud-compose mount" + mount -o defaults -- "$device" "$mount_path" + fi +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/rootfs/home/cloud-compose/profile.sh b/rootfs/home/cloud-compose/profile.sh index ba8cd25..7602d40 100644 --- a/rootfs/home/cloud-compose/profile.sh +++ b/rootfs/home/cloud-compose/profile.sh @@ -1,11 +1,118 @@ #!/usr/bin/env bash -# shellcheck disable=SC1091 -set -a -. /home/cloud-compose/.env -set +a +decode_runtime_env_value() { + local encoded="$1" + local output="" character next index -export PATH="/home/cloud-compose/bin:$PATH" + for ((index = 0; index < ${#encoded}; index++)); do + character="${encoded:index:1}" + if [[ "$character" != "\\" ]]; then + if [[ "$character" == '$' ]]; then + if ((index + 1 >= ${#encoded})) || [[ "${encoded:index+1:1}" != '$' ]]; then + echo "Unescaped dollar in cloud-compose environment value" >&2 + return 1 + fi + output+='$' + ((index += 1)) + continue + fi + if [[ "$character" == '"' || "$character" == $'\r' || "$character" == $'\t' ]]; then + echo "Unescaped character in cloud-compose environment value: $character" >&2 + return 1 + fi + output+="$character" + continue + fi + + if ((index + 1 >= ${#encoded})); then + echo "Invalid trailing escape in cloud-compose environment value" >&2 + return 1 + fi + next="${encoded:index+1:1}" + case "$next" in + "\\" | '"') + output+="$next" + ((index += 1)) + ;; + n) + output+=$'\n' + ((index += 1)) + ;; + r) + output+=$'\r' + ((index += 1)) + ;; + t) + output+=$'\t' + ((index += 1)) + ;; + *) + echo "Invalid escape in cloud-compose environment value: \\$next" >&2 + return 1 + ;; + esac + done + + RUNTIME_ENV_DECODED="$output" +} + +write_runtime_env_assignment() { + local name="$1" + local value="$2" + + if [[ ! "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + echo "Invalid cloud-compose environment variable name: $name" >&2 + return 1 + fi + + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + value="${value//\$/\$\$}" + value="${value//$'\n'/\\n}" + value="${value//$'\r'/\\r}" + value="${value//$'\t'/\\t}" + printf '%s="%s"\n' "$name" "$value" +} + +load_runtime_env() { + local file="$1" + local line name encoded + + if [[ ! -f "$file" ]]; then + echo "cloud-compose environment file not found: $file" >&2 + return 1 + fi + + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ -z "$line" || "$line" == \#* ]]; then + continue + fi + if [[ ! "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=\"(.*)\"$ ]]; then + echo "Invalid cloud-compose environment assignment" >&2 + return 1 + fi + + name="${BASH_REMATCH[1]}" + encoded="${BASH_REMATCH[2]}" + decode_runtime_env_value "$encoded" || return 1 + declare -gx -- "$name=$RUNTIME_ENV_DECODED" || return 1 + done <"$file" +} + +if ! load_runtime_env "${CLOUD_COMPOSE_ENV_FILE:-/home/cloud-compose/.env}"; then + if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then + return 1 + fi + exit 1 +fi + +if ((EUID == 0)); then + # Root-owned systemd/bootstrap paths must never resolve commands from the + # cloud-compose-writable tool directory. + export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +else + export PATH="/home/cloud-compose/bin:$PATH" +fi export DOCKER_CONFIG="${DOCKER_CONFIG:-/mnt/disks/data/docker-config}" DEFAULT_MAX_RETRIES=10 @@ -14,10 +121,25 @@ DEFAULT_SLEEP_INCREMENT=5 # helper to wrap commands that go over the network in "exponential" backoff # e.g. docker pull and git pull retry_until_success() { + if (($# == 0)); then + echo "retry_until_success requires a command." >&2 + return 2 + fi local command_to_run=("$@") - local MAX_RETRIES="${MAX_RETRIES:-$DEFAULT_MAX_RETRIES}" - local SLEEP_INCREMENT="${SLEEP_INCREMENT:-$DEFAULT_SLEEP_INCREMENT}" - local RETRIES=0 + local operation="${1##*/}" + local max_retries="${MAX_RETRIES:-$DEFAULT_MAX_RETRIES}" + local sleep_increment="${SLEEP_INCREMENT:-$DEFAULT_SLEEP_INCREMENT}" + local retries=0 + local exit_code + + if [[ ! "$max_retries" =~ ^[1-9][0-9]{0,2}$ ]] || ((10#$max_retries > 100)); then + echo "MAX_RETRIES must be an integer from 1 through 100." >&2 + return 2 + fi + if [[ ! "$sleep_increment" =~ ^[0-9]{1,4}$ ]] || ((10#$sleep_increment > 3600)); then + echo "SLEEP_INCREMENT must be an integer from 0 through 3600 seconds." >&2 + return 2 + fi while true; do exit_code=0 @@ -27,33 +149,246 @@ retry_until_success() { return 0 fi - RETRIES=$((RETRIES + 1)) + retries=$((retries + 1)) - if [ "$RETRIES" -ge "$MAX_RETRIES" ]; then - echo "FAILURE: Command '${command_to_run[*]}' failed after $MAX_RETRIES attempts (Last exit code: $exit_code)." >&2 + if [ "$retries" -ge "$max_retries" ]; then + echo "FAILURE: Operation '$operation' failed after $max_retries attempts (last exit code: $exit_code)." >&2 return 1 fi - local SLEEP=$(( SLEEP_INCREMENT * RETRIES )) - echo "Command '${command_to_run[*]}' failed (Exit code: $exit_code). Retrying in $SLEEP seconds... (Attempt $RETRIES/$MAX_RETRIES)" >&2 - sleep "$SLEEP" + local sleep_seconds=$((10#$sleep_increment * retries)) + echo "Operation '$operation' failed (exit code: $exit_code). Retrying in $sleep_seconds seconds... (attempt $retries/$max_retries)" >&2 + sleep "$sleep_seconds" done } -# update .env variables -# usage: -# update_env var value -# e.g. update_env DOCKER_COMPOSE_PROJECT foo -# will created/override DOCKER_COMPOSE_PROJECT=foo in $(pwd)/.env -update_env() { - VAR_NAME="$1" - VALUE="$2" - if [ ! -f .env ]; then - touch .env +# Serialize host lifecycle operations that can move a checkout, restart a +# Compose stack, or read a live database. systemd-tmpfiles creates this shared +# root/application lock before any privileged package update or app service. +acquire_cloud_compose_lifecycle_lock() { + local operation="${1:-operation}" + local lock_file="/run/lock/cloud-compose/lifecycle.lock" + local timeout_seconds="${CLOUD_COMPOSE_LIFECYCLE_LOCK_TIMEOUT_SECONDS:-900}" + local lock_parent lock_fd_path lock_identity + + if [[ ! "$timeout_seconds" =~ ^[1-9][0-9]{0,4}$ ]] || ((10#$timeout_seconds > 43200)); then + echo "CLOUD_COMPOSE_LIFECYCLE_LOCK_TIMEOUT_SECONDS must be an integer from 1 through 43200" >&2 + return 2 + fi + if ! command -v flock >/dev/null 2>&1; then + echo "flock is required to serialize cloud-compose lifecycle operations" >&2 + return 1 + fi + + lock_parent="$(dirname -- "$lock_file")" + if [[ "$lock_parent" != "/run/lock/cloud-compose" || ! -d "$lock_parent" || + -L "$lock_parent" || -L "$lock_file" || ! -f "$lock_file" ]]; then + echo "Unsafe cloud-compose lifecycle lock target: $lock_file" >&2 + return 1 + fi + lock_identity="$(stat -Lc '%U:%G:%a' -- "$lock_parent" "$lock_file")" || return 1 + if [[ "$lock_identity" != $'root:cloud-compose:750\nroot:cloud-compose:660' ]]; then + echo "Unsafe cloud-compose lifecycle lock ownership or mode: $lock_file" >&2 + return 1 + fi + + if ! exec 8<>"$lock_file"; then + echo "Could not open shared cloud-compose lifecycle lock: $lock_file" >&2 + return 1 + fi + lock_fd_path="/proc/${BASHPID}/fd/8" + if [[ ! -f "$lock_fd_path" ]] || + [[ "$(stat -Lc '%d:%i' -- "$lock_fd_path")" != "$(stat -Lc '%d:%i' -- "$lock_file")" ]]; then + echo "Cloud-compose lifecycle lock changed while opening: $lock_file" >&2 + exec 8>&- + return 1 fi - if grep -Eq "^${VAR_NAME}=" .env; then - sed -i "s/^$VAR_NAME=.*/$VAR_NAME=$VALUE/" .env - else - echo "${VAR_NAME}=${VALUE}" | tee -a .env + if ! flock -w "$timeout_seconds" 8; then + echo "Timed out waiting ${timeout_seconds}s for cloud-compose lifecycle lock during $operation" >&2 + exec 8>&- + return 1 fi } + +release_cloud_compose_lifecycle_lock() { + if [[ -e /proc/${BASHPID}/fd/8 ]]; then + flock -u 8 >/dev/null 2>&1 || true + exec 8>&- + fi +} + +# Strictly update the host-owned cloud-compose environment contract. +update_runtime_env_file() ( + local env_file="$1" + local name="$2" + local value="$3" + local line existing_name tmp_file + + if [[ ! "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + echo "Invalid cloud-compose environment variable name: $name" >&2 + return 1 + fi + if [[ -L "$env_file" || ( -e "$env_file" && ! -f "$env_file" ) ]]; then + echo "Refusing unsafe cloud-compose environment path: $env_file" >&2 + return 1 + fi + + umask 027 + tmp_file=$(mktemp "${env_file}.tmp.XXXXXXXXXX") || return 1 + trap 'rm -f -- "$tmp_file"' EXIT + + if [[ -f "$env_file" ]]; then + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ -z "$line" || "$line" == \#* ]]; then + printf '%s\n' "$line" >>"$tmp_file" + continue + fi + if [[ ! "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=\"(.*)\"$ ]]; then + echo "Invalid cloud-compose environment assignment" >&2 + return 1 + fi + existing_name="${BASH_REMATCH[1]}" + decode_runtime_env_value "${BASH_REMATCH[2]}" || return 1 + if [[ "$existing_name" != "$name" ]]; then + printf '%s\n' "$line" >>"$tmp_file" + fi + done <"$env_file" + fi + + write_runtime_env_assignment "$name" "$value" >>"$tmp_file" || return 1 + chmod 0640 "$tmp_file" + mv -f -- "$tmp_file" "$env_file" +) + +update_runtime_env() { + update_runtime_env_file .env "$@" +} + +# Compose application .env files use the broader Compose dotenv grammar and +# may be owned by downstream forks. Preserve their contents as opaque data and +# maintain one final, clearly marked override instead of parsing or sourcing +# untrusted application values. +update_compose_env() ( + local name="$1" + local value="$2" + local env_file=".env" + local marker line managed_line tmp_file + + if [[ ! "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + echo "Invalid Compose environment variable name: $name" >&2 + return 1 + fi + if [[ -L "$env_file" || ( -e "$env_file" && ! -f "$env_file" ) ]]; then + echo "Refusing unsafe Compose environment path: $env_file" >&2 + return 1 + fi + + marker="# cloud-compose managed: ${name}" + umask 027 + tmp_file=$(mktemp "${env_file}.tmp.XXXXXXXXXX") || return 1 + trap 'rm -f -- "$tmp_file"' EXIT + + if [[ -f "$env_file" ]]; then + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$line" == "$marker" ]]; then + if ! IFS= read -r managed_line; then + echo "Incomplete managed Compose environment assignment for $name" >&2 + return 1 + fi + if [[ ! "$managed_line" =~ ^${name}=\".*\"$ ]]; then + echo "Invalid managed Compose environment assignment for $name" >&2 + return 1 + fi + continue + fi + printf '%s\n' "$line" >>"$tmp_file" + done <"$env_file" + fi + + printf '%s\n' "$marker" >>"$tmp_file" + write_runtime_env_assignment "$name" "$value" >>"$tmp_file" || return 1 + chmod 0640 "$tmp_file" + mv -f -- "$tmp_file" "$env_file" +) + +# Reconcile application-only tuning values into the current Compose project's +# dotenv file. The JSON file is parsed as data and is never exported into the +# host process environment. A distinct marker lets later runs remove values +# that were deleted from runtime.extra_env while preserving downstream dotenv +# syntax and cloud-compose's final control-plane overrides. +sync_compose_application_env() ( + local application_env_file="${1:-${CLOUD_COMPOSE_APPLICATION_ENV_FILE:-/home/cloud-compose/application-env.json}}" + local env_file=".env" + local entries_file tmp_file line managed_line application_name encoded entry_json name value + + if [[ -L "$application_env_file" || ! -f "$application_env_file" ]]; then + echo "Compose application environment file is missing or unsafe: $application_env_file" >&2 + return 1 + fi + if [[ -L "$env_file" || ( -e "$env_file" && ! -f "$env_file" ) ]]; then + echo "Refusing unsafe Compose environment path: $env_file" >&2 + return 1 + fi + if ! jq -e ' + type == "object" and + all(to_entries[]; + (.key | explode | index(0) == null) and + (.value | type == "string") and + (.value | explode | index(0) == null) + ) + ' "$application_env_file" >/dev/null; then + echo "Invalid Compose application environment data: $application_env_file" >&2 + return 1 + fi + + umask 027 + entries_file=$(mktemp "${TMPDIR:-/tmp}/cloud-compose-application-env.XXXXXXXXXX") || return 1 + tmp_file=$(mktemp "${env_file}.tmp.XXXXXXXXXX") || { + rm -f -- "$entries_file" + return 1 + } + trap 'rm -f -- "$entries_file" "$tmp_file"' EXIT + + jq -r 'to_entries | sort_by(.key)[] | @base64' "$application_env_file" >"$entries_file" || return 1 + + if [[ -f "$env_file" ]]; then + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$line" =~ ^\#\ cloud-compose\ application:\ ([A-Za-z_][A-Za-z0-9_]*)$ ]]; then + application_name="${BASH_REMATCH[1]}" + if ! IFS= read -r managed_line; then + echo "Incomplete Compose application environment assignment for $application_name" >&2 + return 1 + fi + if [[ ! "$managed_line" =~ ^${application_name}=\".*\"$ ]]; then + echo "Invalid Compose application environment assignment for $application_name" >&2 + return 1 + fi + continue + fi + printf '%s\n' "$line" >>"$tmp_file" + done <"$env_file" + fi + + while IFS= read -r encoded || [[ -n "$encoded" ]]; do + [[ -n "$encoded" ]] || continue + entry_json="$(printf '%s' "$encoded" | base64 -d)" || return 1 + name="$(jq -jr '.key, "\u001f"' <<<"$entry_json")" || return 1 + name="${name%$'\x1f'}" + if [[ ! "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + echo "Invalid Compose application environment data: $application_env_file" >&2 + return 1 + fi + value="$(jq -jr '.value, "\u001f"' <<<"$entry_json")" || return 1 + value="${value%$'\x1f'}" + printf '# cloud-compose application: %s\n' "$name" >>"$tmp_file" + write_runtime_env_assignment "$name" "$value" >>"$tmp_file" || return 1 + done <"$entries_file" + + chmod 0640 "$tmp_file" + mv -f -- "$tmp_file" "$env_file" +) + +# Backward-compatible helper name used by older rootfs extensions. +update_env() { + update_compose_env "$@" +} diff --git a/rootfs/home/cloud-compose/rotate-keys-app.sh b/rootfs/home/cloud-compose/rotate-keys-app.sh index c19840b..9730c89 100644 --- a/rootfs/home/cloud-compose/rotate-keys-app.sh +++ b/rootfs/home/cloud-compose/rotate-keys-app.sh @@ -1,34 +1,207 @@ #!/usr/bin/env bash -set -eou pipefail +set -euo pipefail -pushd /home/cloud-compose +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +profile_path="${CLOUD_COMPOSE_PROFILE_PATH:-$script_dir/profile.sh}" +rotate_keys_script="${CLOUD_COMPOSE_ROTATE_KEYS_PATH:-$script_dir/rotate-keys.sh}" +compose_apps_path="${CLOUD_COMPOSE_COMPOSE_APPS_PATH:-/home/cloud-compose/compose-apps.sh}" -# shellcheck disable=SC1091 -source /home/cloud-compose/profile.sh +# shellcheck disable=SC1090 +source "$profile_path" -# shellcheck disable=SC1091 -source /home/cloud-compose/compose-apps.sh +case "${CLOUD_COMPOSE_PROVIDER:-}" in + gcp) ;; + "") + echo "CLOUD_COMPOSE_PROVIDER is required for service-account key rotation" >&2 + exit 1 + ;; + *) exit 0 ;; +esac -APP_CREDENTIALS_FILE="/mnt/disks/data/cloud-compose/app/GOOGLE_APPLICATION_CREDENTIALS" -mkdir -p "$(dirname "$APP_CREDENTIALS_FILE")" -bash rotate-keys.sh \ +# shellcheck disable=SC1090 +source "$compose_apps_path" + +APP_CREDENTIALS_FILE="${APP_CREDENTIALS_FILE:-/mnt/disks/data/cloud-compose/app/GOOGLE_APPLICATION_CREDENTIALS}" +ROTATION_APP_CREDENTIAL_OWNER="${ROTATION_CREDENTIAL_OWNER-100}" +ROTATION_CENTRAL_CREDENTIAL_OWNER="${ROTATION_CENTRAL_CREDENTIAL_OWNER-root}" +ROTATION_CREDENTIAL_GROUP="${ROTATION_CREDENTIAL_GROUP-cloud-compose}" +rotation_action="${1:-rotate}" + +if [[ -n "$ROTATION_CREDENTIAL_GROUP" && + ! "$ROTATION_CREDENTIAL_GROUP" =~ ^[a-z_][a-z0-9_-]{0,31}\$?$ ]]; then + echo "ROTATION_CREDENTIAL_GROUP must be empty or a safe local group name" >&2 + exit 2 +fi +if [[ ! "$ROTATION_CENTRAL_CREDENTIAL_OWNER" =~ ^([0-9]+|[a-z_][a-z0-9_-]{0,31}\$?)$ ]]; then + echo "ROTATION_CENTRAL_CREDENTIAL_OWNER must be a numeric UID or safe local account name" >&2 + exit 2 +fi + +case "$rotation_action" in + rotate | rollback | retire) ;; + *) + echo "Usage: $0 [rotate|rollback|retire]" >&2 + exit 2 + ;; +esac + +case "${GCP_APP_CREDENTIALS_ENABLED:-false}" in + true) app_credentials_enabled=true ;; + false) app_credentials_enabled=false ;; + *) + echo "GCP_APP_CREDENTIALS_ENABLED must be true or false" >&2 + exit 2 + ;; +esac + +rotate_keys() { + ROTATION_CREDENTIAL_OWNER="$ROTATION_CENTRAL_CREDENTIAL_OWNER" \ + bash "$rotate_keys_script" "$1" \ "$GCP_APP_SERVICE_ACCOUNT_EMAIL" \ "$GCP_PROJECT" \ "$APP_CREDENTIALS_FILE" +} + +install_app_credentials() { + local target="$1" + local target_dir target_tmp -chgrp cloud-compose "$APP_CREDENTIALS_FILE" + target_dir="$(dirname -- "$target")" + if [[ -L "$target_dir" || ( -e "$target_dir" && ! -d "$target_dir" ) ]]; then + echo "Application credential directory is unsafe: $target_dir" >&2 + return 2 + fi + install -d -m 0750 "$target_dir" || return 2 + if [[ -L "$target_dir" || ! -d "$target_dir" ]]; then + echo "Application credential directory changed during preparation: $target_dir" >&2 + return 2 + fi + if ! chown -- cloud-compose "$target_dir" || ! chmod 0750 "$target_dir"; then + return 2 + fi + if [[ -n "$ROTATION_CREDENTIAL_GROUP" ]] && ! chgrp -- "$ROTATION_CREDENTIAL_GROUP" "$target_dir"; then + return 2 + fi -while read -r app; do - if [ -z "$app" ]; then - continue + if [[ -f "$target" && ! -L "$target" ]] && cmp -s -- "$APP_CREDENTIALS_FILE" "$target"; then + return 1 fi + target_tmp="$(mktemp "${target}.tmp.XXXXXX")" || return 2 + if ! install -m 0440 "$APP_CREDENTIALS_FILE" "$target_tmp"; then + rm -f -- "$target_tmp" + return 2 + fi + if [[ -n "$ROTATION_APP_CREDENTIAL_OWNER" ]] && ! chown -- "$ROTATION_APP_CREDENTIAL_OWNER" "$target_tmp"; then + rm -f -- "$target_tmp" + return 2 + fi + if [[ -n "$ROTATION_CREDENTIAL_GROUP" ]] && ! chgrp -- "$ROTATION_CREDENTIAL_GROUP" "$target_tmp"; then + rm -f -- "$target_tmp" + return 2 + fi + if ! mv -f -- "$target_tmp" "$target"; then + rm -f -- "$target_tmp" + return 2 + fi + return 0 +} + +apps=() +app_credential_targets=() +compose_app_names_array apps + +# Resolve every target before creating a cloud key. A malformed manifest or an +# invalid app path must fail without changing IAM state. +for app in "${apps[@]}"; do source_compose_app_env "$app" - mkdir -p "$DOCKER_COMPOSE_DIR/secrets" - install -o 100 -g cloud-compose -m 0440 \ - "$APP_CREDENTIALS_FILE" \ - "$DOCKER_COMPOSE_DIR/secrets/GOOGLE_APPLICATION_CREDENTIALS" -done < <(compose_app_names) + validate_compose_git_source + app_credential_targets+=("$DOCKER_COMPOSE_DIR/secrets/GOOGLE_APPLICATION_CREDENTIALS") +done + +credential_artifacts=( + "$APP_CREDENTIALS_FILE" + "${APP_CREDENTIALS_FILE}.rotation-pending.json" + "${APP_CREDENTIALS_FILE}.rotation-staged.json" + "${APP_CREDENTIALS_FILE}.rotation-previous.json" + "${APP_CREDENTIALS_FILE}.rotation-replacement.json" + "${APP_CREDENTIALS_FILE}.rotation.lock" + "${app_credential_targets[@]}" +) +for target in "${credential_artifacts[@]}"; do + if [[ -L "$target" || ( -e "$target" && ! -f "$target" ) ]]; then + echo "Application credential artifact is unsafe: $target" >&2 + exit 2 + fi +done + +if [[ "$rotation_action" == "retire" ]]; then + rotate_keys retire + rm -f -- "${credential_artifacts[@]}" + echo "Application file credentials retired" + exit 0 +fi + +if [[ "$app_credentials_enabled" != "true" ]]; then + for target in "${credential_artifacts[@]}"; do + if [[ -e "$target" ]]; then + echo "Managed app credentials are disabled but $target remains; re-enable credentials, run '$0 retire', then disable them" >&2 + exit 1 + fi + done + echo "Application file credentials are disabled" + exit 0 +fi + +install -d -m 0750 "$(dirname -- "$APP_CREDENTIALS_FILE")" +if [[ "$rotation_action" == "rollback" ]]; then + rotate_keys rollback +else + rotate_keys prepare +fi +phase="$(rotate_keys status)" + +if [[ "$phase" == "staged" ]]; then + # Prove that IAM accepts the new private key before any running consumer is + # switched to its credential inode. Propagation lag is handled with backoff. + rotate_keys authenticate + phase=authenticated +fi + +if [[ -n "$ROTATION_CREDENTIAL_GROUP" ]]; then + chgrp -- "$ROTATION_CREDENTIAL_GROUP" "$APP_CREDENTIALS_FILE" +fi + +credentials_changed=false +for target in "${app_credential_targets[@]}"; do + if install_app_credentials "$target"; then + credentials_changed=true + else + install_status=$? + if [[ "$install_status" -ne 1 ]]; then + echo "Failed to install application credentials at $target" >&2 + exit "$install_status" + fi + fi +done + +if [[ "$phase" == "authenticated" || "$phase" == "rollback" || "$credentials_changed" == "true" ]]; then + # A file bind mount keeps its original inode. Reload only a service that was + # already active; rotation must never turn an intentionally stopped app on. + if systemctl is-active --quiet cloud-compose.service; then + systemctl restart cloud-compose.service + fi +fi -popd +if [[ "$phase" == "authenticated" ]]; then + rotate_keys ready + phase=ready +fi +if [[ "$phase" == "rollback" ]]; then + rotate_keys rollback-ready + phase=idle +fi +if [[ "$phase" == "ready" || "$phase" == "grace" ]]; then + rotate_keys commit +fi diff --git a/rootfs/home/cloud-compose/rotate-keys-daily.sh b/rootfs/home/cloud-compose/rotate-keys-daily.sh new file mode 100644 index 0000000..fb30a69 --- /dev/null +++ b/rootfs/home/cloud-compose/rotate-keys-daily.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +profile_path="${CLOUD_COMPOSE_PROFILE_PATH:-$script_dir/profile.sh}" + +# shellcheck disable=SC1090 +source "$profile_path" + +case "${CLOUD_COMPOSE_PROVIDER:-}" in + gcp) + ;; + "") + echo "CLOUD_COMPOSE_PROVIDER is required for service-account key rotation" >&2 + exit 1 + ;; + *) + exit 0 + ;; +esac + +case "${LIBOPS_INTERNAL_SERVICES_ENABLED:-false}" in + true) bash "$script_dir/rotate-keys-internal.sh" ;; + false) ;; + *) + echo "LIBOPS_INTERNAL_SERVICES_ENABLED must be true or false" >&2 + exit 2 + ;; +esac + +# The app wrapper is also the fail-closed stale-credential check when managed +# file credentials are disabled, so run it in both states. +bash "$script_dir/rotate-keys-app.sh" diff --git a/rootfs/home/cloud-compose/rotate-keys-internal.sh b/rootfs/home/cloud-compose/rotate-keys-internal.sh index 6aa7592..5546e9a 100644 --- a/rootfs/home/cloud-compose/rotate-keys-internal.sh +++ b/rootfs/home/cloud-compose/rotate-keys-internal.sh @@ -1,17 +1,81 @@ #!/usr/bin/env bash -set -eou pipefail +set -euo pipefail -pushd /home/cloud-compose +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +profile_path="${CLOUD_COMPOSE_PROFILE_PATH:-$script_dir/profile.sh}" +rotate_keys_script="${CLOUD_COMPOSE_ROTATE_KEYS_PATH:-$script_dir/rotate-keys.sh}" -# shellcheck disable=SC1091 -source /home/cloud-compose/profile.sh +# shellcheck disable=SC1090 +source "$profile_path" -bash rotate-keys.sh \ - "internal-$GCP_INSTANCE_NAME@$GCP_PROJECT.iam.gserviceaccount.com" \ +case "${CLOUD_COMPOSE_PROVIDER:-}" in + gcp) ;; + "") + echo "CLOUD_COMPOSE_PROVIDER is required for service-account key rotation" >&2 + exit 1 + ;; + *) exit 0 ;; +esac + +INTERNAL_CREDENTIALS_FILE="${INTERNAL_CREDENTIALS_FILE:-/mnt/disks/data/libops-internal/GOOGLE_APPLICATION_CREDENTIALS}" +ROTATION_CREDENTIAL_GROUP="${ROTATION_CREDENTIAL_GROUP-cloud-compose}" +internal_service_account="internal-$GCP_INSTANCE_NAME@$GCP_PROJECT.iam.gserviceaccount.com" +rotation_action="${1:-rotate}" + +if [[ -n "$ROTATION_CREDENTIAL_GROUP" && + ! "$ROTATION_CREDENTIAL_GROUP" =~ ^[a-z_][a-z0-9_-]{0,31}\$?$ ]]; then + echo "ROTATION_CREDENTIAL_GROUP must be empty or a safe local group name" >&2 + exit 2 +fi + +case "$rotation_action" in + rotate | rollback) ;; + *) + echo "Usage: $0 [rotate|rollback]" >&2 + exit 2 + ;; +esac + +rotate_keys() { + bash "$rotate_keys_script" "$1" \ + "$internal_service_account" \ "$GCP_PROJECT" \ - /mnt/disks/data/libops-internal/GOOGLE_APPLICATION_CREDENTIALS + "$INTERNAL_CREDENTIALS_FILE" +} + +if [[ "$rotation_action" == "rollback" ]]; then + rotate_keys rollback +else + rotate_keys prepare +fi +phase="$(rotate_keys status)" + +if [[ "$phase" == "staged" ]]; then + rotate_keys authenticate + phase=authenticated +fi + +if [[ -n "$ROTATION_CREDENTIAL_GROUP" ]]; then + chgrp -- "$ROTATION_CREDENTIAL_GROUP" "$INTERNAL_CREDENTIALS_FILE" +fi -chgrp cloud-compose /mnt/disks/data/libops-internal/GOOGLE_APPLICATION_CREDENTIALS +if [[ "$phase" == "authenticated" || "$phase" == "rollback" ]]; then + # Recreate containers so their credential bind mounts reference the newly + # installed inode, but do not start an intentionally inactive service. + if systemctl is-active --quiet cloud-compose-internal-services.service; then + systemctl restart cloud-compose-internal-services.service + fi +fi -popd +if [[ "$phase" == "authenticated" ]]; then + rotate_keys ready + phase=ready +fi +if [[ "$phase" == "rollback" ]]; then + rotate_keys rollback-ready + phase=idle +fi +if [[ "$phase" == "ready" || "$phase" == "grace" ]]; then + rotate_keys commit +fi diff --git a/rootfs/home/cloud-compose/rotate-keys.sh b/rootfs/home/cloud-compose/rotate-keys.sh index 0acf233..57b4cba 100644 --- a/rootfs/home/cloud-compose/rotate-keys.sh +++ b/rootfs/home/cloud-compose/rotate-keys.sh @@ -1,128 +1,1086 @@ -#!/bin/bash +#!/usr/bin/env bash -set -eou pipefail +set -euo pipefail -SERVICE_ACCOUNT="$1" -PROJECT_ID="$2" -CREDENTIALS_FILE="$3" +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +profile_path="${CLOUD_COMPOSE_PROFILE_PATH:-$script_dir/profile.sh}" -# do not rotate if the file is less than 24h old -if [[ -f "$CREDENTIALS_FILE" ]]; then - FILE_AGE_SECONDS=$(( $(date +%s) - $(stat -c %Y "$CREDENTIALS_FILE" 2>/dev/null || stat -f %m "$CREDENTIALS_FILE" 2>/dev/null) )) - if [[ $FILE_AGE_SECONDS -lt 86400 ]]; then +# shellcheck disable=SC1090 +source "$profile_path" + +log_info() { + printf '[key-rotation] %s\n' "$1" >&2 +} + +log_error() { + printf '[key-rotation] ERROR: %s\n' "$1" >&2 +} + +usage() { + cat >&2 < maximum)); then + log_error "$name must be an integer from $minimum through $maximum" + return 1 fi +} + +validate_integer_setting ROTATION_MIN_AGE_SECONDS "$ROTATION_MIN_AGE_SECONDS" 0 315360000 || exit 2 +validate_integer_setting ROTATION_DISABLE_GRACE_SECONDS "$ROTATION_DISABLE_GRACE_SECONDS" 0 31536000 || exit 2 +validate_integer_setting ROTATION_AUTH_MAX_RETRIES "$ROTATION_AUTH_MAX_RETRIES" 1 20 || exit 2 +validate_integer_setting ROTATION_AUTH_SLEEP_SECONDS "$ROTATION_AUTH_SLEEP_SECONDS" 0 300 || exit 2 +validate_integer_setting ROTATION_RECOVERY_SETTLE_SECONDS "$ROTATION_RECOVERY_SETTLE_SECONDS" 0 3600 || exit 2 +if [[ -n "$ROTATION_CREDENTIAL_OWNER" && + ! "$ROTATION_CREDENTIAL_OWNER" =~ ^([0-9]+|[a-z_][a-z0-9_-]{0,31}\$?)$ ]]; then + log_error "ROTATION_CREDENTIAL_OWNER must be empty, a numeric UID, or a safe local account name" + exit 2 fi +if [[ -L "$credentials_dir" || ( -e "$credentials_dir" && ! -d "$credentials_dir" ) ]]; then + log_error "Credentials directory is unsafe: $credentials_dir" + exit 1 +fi +install -d -m 0750 -- "$credentials_dir" +if [[ -L "$credentials_dir" || ! -d "$credentials_dir" ]]; then + log_error "Credentials directory did not resolve to a regular directory: $credentials_dir" + exit 1 +fi -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' +exec 9>"$ROTATION_LOCK" +if command -v flock >/dev/null 2>&1 && ! flock -w 30 9; then + log_error "Timed out waiting for rotation lock: $ROTATION_LOCK" + exit 1 +fi -log_info() { - echo -e "${GREEN}[INFO]${NC} $1" +STATE_PHASE="" +STATE_CURRENT_KEY_ID="" +STATE_NEW_KEY_ID="" +STATE_NEW_KEY_NAME="" +STATE_BASELINE_KEY_NAMES='[]' +STATE_CREATED_AT=0 +STATE_READY_AT=0 +STATE_DISABLED_AT=0 +ACCESS_TOKEN="" +KEY_OPERATION_RESULT="" + +now_epoch() { + date +%s } -log_warn() { - echo -e "${YELLOW}[WARN]${NC} $1" +valid_iam_key_id() { + local key_id="$1" + + [[ "$key_id" =~ ^[A-Za-z0-9_-]+$ ]] } -log_error() { - echo -e "${RED}[ERROR]${NC} $1" +credential_key_id() { + local file="$1" key_id + + key_id="$(jq -jr ' + (.private_key_id | + select(type == "string" and length > 0 and (explode | index(0) == null))), + "\u001f" + ' "$file")" || return 1 + key_id="${key_id%$'\x1f'}" + valid_iam_key_id "$key_id" || return 1 + printf '%s\n' "$key_id" } -CURRENT_KEY_ID= -if [[ -f "$CREDENTIALS_FILE" ]]; then - log_info "Reading service account from existing credentials file..." - CURRENT_KEY_ID=$(jq -r '.private_key_id' "$CREDENTIALS_FILE") -else - log_warn "Credentials file not found: $CREDENTIALS_FILE" -fi +valid_iam_key_name() { + local key_name="$1" + local prefix="$SA_RESOURCE/keys/" + local key_id -log_info "Service Account: $SERVICE_ACCOUNT" -log_info "Project ID: $PROJECT_ID" -log_info "Current Key ID: $CURRENT_KEY_ID" + [[ "$key_name" == "$prefix"* ]] || return 1 + key_id="${key_name#"$prefix"}" + valid_iam_key_id "$key_id" && [[ "$key_name" == "$prefix$key_id" ]] +} -log_info "Getting access token from metadata server..." -ACCESS_TOKEN=$(curl -s -H "Metadata-Flavor: Google" \ - "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token" | jq -er '.access_token') +write_state() { + local state_tmp -if [[ -z "$ACCESS_TOKEN" ]]; then - log_error "Failed to get access token from metadata server" - exit 1 -fi + state_tmp="$(mktemp "${PENDING_STATE}.tmp.XXXXXX")" || return 1 + if ! jq -n \ + --arg phase "$STATE_PHASE" \ + --arg service_account "$SERVICE_ACCOUNT" \ + --arg project_id "$PROJECT_ID" \ + --arg credentials_file "$CREDENTIALS_FILE" \ + --arg current_key_id "$STATE_CURRENT_KEY_ID" \ + --arg new_key_id "$STATE_NEW_KEY_ID" \ + --arg new_key_name "$STATE_NEW_KEY_NAME" \ + --argjson baseline_key_names "$STATE_BASELINE_KEY_NAMES" \ + --argjson created_at "$STATE_CREATED_AT" \ + --argjson ready_at "$STATE_READY_AT" \ + --argjson disabled_at "$STATE_DISABLED_AT" \ + '{ + version: 2, + phase: $phase, + service_account: $service_account, + project_id: $project_id, + credentials_file: $credentials_file, + current_key_id: $current_key_id, + new_key_id: $new_key_id, + new_key_name: $new_key_name, + baseline_key_names: $baseline_key_names, + created_at: $created_at, + ready_at: $ready_at, + disabled_at: $disabled_at + }' >"$state_tmp"; then + rm -f -- "$state_tmp" + return 1 + fi + chmod 0600 "$state_tmp" + mv -f -- "$state_tmp" "$PENDING_STATE" +} -SA_RESOURCE="projects/$PROJECT_ID/serviceAccounts/$SERVICE_ACCOUNT" +load_state() { + local payload encoded_baseline_name baseline_name + + if [[ -L "$PENDING_STATE" || ! -f "$PENDING_STATE" ]]; then + log_error "Pending rotation state is missing or unsafe: $PENDING_STATE" + return 1 + fi + payload="$(jq -ce \ + --arg service_account "$SERVICE_ACCOUNT" \ + --arg project_id "$PROJECT_ID" \ + --arg credentials_file "$CREDENTIALS_FILE" ' + select( + .version == 2 and + (.phase == "creating" or .phase == "staged" or .phase == "authenticated" or + .phase == "ready" or .phase == "grace" or .phase == "rolling-back" or + .phase == "rollback" or .phase == "revoke-new") and + .service_account == $service_account and + .project_id == $project_id and + .credentials_file == $credentials_file and + (.current_key_id | type == "string" and (explode | index(0) == null)) and + (.new_key_id | type == "string" and (explode | index(0) == null)) and + (.new_key_name | type == "string" and (explode | index(0) == null)) and + (.baseline_key_names | type == "array") and + all(.baseline_key_names[]; + type == "string" and (explode | index(0) == null)) and + (.created_at | type == "number" and . >= 0 and floor == .) and + (.ready_at | type == "number" and . >= 0 and floor == .) and + (.disabled_at | type == "number" and . >= 0 and floor == .) + ) + ' "$PENDING_STATE")" || { + log_error "Pending rotation state is invalid or belongs to another target: $PENDING_STATE" + return 1 + } -log_info "Listing all keys for service account..." -KEYS_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \ - "https://iam.googleapis.com/v1/$SA_RESOURCE/keys") -USER_KEYS=$(echo "$KEYS_RESPONSE" | jq -r '.keys[] | select(.keyType == "USER_MANAGED") | .name') -KEY_COUNT=$(echo "$USER_KEYS" | grep -c "^" || true) -log_info "Found $KEY_COUNT user-managed key(s)" + STATE_PHASE="$(jq -r '.phase' <<<"$payload")" + STATE_CURRENT_KEY_ID="$(jq -jr '(.current_key_id), "\u001f"' <<<"$payload")" + STATE_CURRENT_KEY_ID="${STATE_CURRENT_KEY_ID%$'\x1f'}" + STATE_NEW_KEY_ID="$(jq -jr '(.new_key_id), "\u001f"' <<<"$payload")" + STATE_NEW_KEY_ID="${STATE_NEW_KEY_ID%$'\x1f'}" + STATE_NEW_KEY_NAME="$(jq -jr '(.new_key_name), "\u001f"' <<<"$payload")" + STATE_NEW_KEY_NAME="${STATE_NEW_KEY_NAME%$'\x1f'}" + STATE_BASELINE_KEY_NAMES="$(jq -c '.baseline_key_names' <<<"$payload")" + STATE_CREATED_AT="$(jq -r '.created_at' <<<"$payload")" + STATE_READY_AT="$(jq -r '.ready_at' <<<"$payload")" + STATE_DISABLED_AT="$(jq -r '.disabled_at' <<<"$payload")" -log_info "Deleting old keys (keeping current key: $CURRENT_KEY_ID)..." -DELETED_COUNT=0 -while IFS= read -r key_name; do - if [[ -z "$key_name" ]]; then - continue + if [[ -n "$STATE_CURRENT_KEY_ID" && ! "$STATE_CURRENT_KEY_ID" =~ ^[A-Za-z0-9_-]+$ ]]; then + log_error "Pending rotation state contains an invalid previous key ID" + return 1 fi + while IFS= read -r encoded_baseline_name; do + baseline_name="$( + if ! printf '%s' "$encoded_baseline_name" | base64 --decode; then + exit 1 + fi + printf '\037' + )" || return 1 + baseline_name="${baseline_name%$'\x1f'}" + if ! valid_iam_key_name "$baseline_name"; then + log_error "Pending rotation state contains an invalid baseline key name" + return 1 + fi + done < <(jq -r '.[] | @base64' <<<"$STATE_BASELINE_KEY_NAMES") - # Extract just the key ID from the full resource name - # Format: projects/{PROJECT}/serviceAccounts/{EMAIL}/keys/{KEY_ID} - KEY_ID=$(basename "$key_name") + case "$STATE_PHASE" in + staged | authenticated | ready | grace | rolling-back | rollback | revoke-new) + if [[ ! "$STATE_NEW_KEY_ID" =~ ^[A-Za-z0-9_-]+$ ]] || + [[ "$STATE_NEW_KEY_NAME" != "$SA_RESOURCE/keys/$STATE_NEW_KEY_ID" ]]; then + log_error "Pending rotation state contains an invalid replacement key" + return 1 + fi + ;; + esac +} + +cleanup_state() { + rm -f -- "$STAGED_CREDENTIALS" "$PREVIOUS_CREDENTIALS" \ + "$REPLACEMENT_CREDENTIALS" "$PENDING_STATE" +} - if [[ "$KEY_ID" == "$CURRENT_KEY_ID" ]]; then - log_warn "Skipping current key: $KEY_ID" - continue +fetch_access_token() { + local token_response + + token_response="$(curl -fsS --connect-timeout 2 --max-time 10 \ + -H "Metadata-Flavor: Google" \ + "http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token")" || { + log_error "Failed to get access token from metadata server" + return 1 + } + ACCESS_TOKEN="$(jq -er '.access_token | select(type == "string" and length > 0)' <<<"$token_response")" || { + log_error "Metadata server returned an invalid access-token response" + return 1 + } +} + +write_access_header_file() { + local header_tmp + + header_tmp="$(mktemp "${credentials_dir}/.iam-header.XXXXXX")" || return 1 + chmod 0600 "$header_tmp" + printf 'Authorization: Bearer %s\n' "$ACCESS_TOKEN" >"$header_tmp" + ACCESS_HEADER_FILE="$header_tmp" +} + +list_user_keys() { + local keys_response curl_status=0 + + write_access_header_file || return 1 + keys_response="$(curl -fsS --retry 5 --retry-all-errors --retry-delay 2 --retry-max-time 120 \ + --connect-timeout 5 --max-time 30 \ + -H "@$ACCESS_HEADER_FILE" \ + "https://iam.googleapis.com/v1/$SA_RESOURCE/keys")" || curl_status=$? + rm -f -- "$ACCESS_HEADER_FILE" + if ((curl_status != 0)); then + log_error "Failed to list service-account keys" + return 1 fi + jq -cer --arg prefix "$SA_RESOURCE/keys/" ' + (.keys // []) as $keys | + if ($keys | type) != "array" then error("invalid key list") else + [$keys[] | + select(.keyType == "USER_MANAGED") | + select((.name | type) == "string" and (.name | startswith($prefix))) | + {name: .name, disabled: (.disabled // false)} + ] | + if all(.[]; (.disabled | type) == "boolean") then . else error("invalid disabled state") end + end + ' <<<"$keys_response" +} - log_info "Deleting key: $KEY_ID" - DELETE_RESPONSE=$(curl -s -X DELETE \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -w "\nHTTP_CODE:%{http_code}" \ - "https://iam.googleapis.com/v1/$key_name") +list_user_key_names() { + list_user_keys | jq -c '[.[].name] | sort' +} - HTTP_CODE=$(echo "$DELETE_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2) +key_remote_status() { + local key_name="$1" keys - if [[ "$HTTP_CODE" == "200" || "$HTTP_CODE" == "204" ]]; then - log_info "Successfully deleted key: $KEY_ID" - ((DELETED_COUNT++)) + keys="$(list_user_keys)" || return 1 + if ! jq -e --arg name "$key_name" 'any(.[]; .name == $name)' <<<"$keys" >/dev/null; then + printf 'absent\n' + elif jq -e --arg name "$key_name" 'any(.[]; .name == $name and .disabled == true)' <<<"$keys" >/dev/null; then + printf 'disabled\n' else - log_error "Failed to delete key: $KEY_ID (HTTP $HTTP_CODE)" - echo "$DELETE_RESPONSE" + printf 'enabled\n' + fi +} + +iam_mutation() { + local method="$1" url="$2" status response_tmp curl_status=0 + local -a curl_args + + response_tmp="$(mktemp "${credentials_dir}/.iam-response.XXXXXX")" || return 1 + chmod 0600 "$response_tmp" + write_access_header_file || { + rm -f -- "$response_tmp" + return 1 + } + curl_args=(-sS --connect-timeout 5 --max-time 30 \ + -X "$method" \ + -H "@$ACCESS_HEADER_FILE" \ + -o "$response_tmp" -w '%{http_code}') + if [[ "$method" == "POST" ]]; then + curl_args+=(-H "Content-Type: application/json" --data '{}') + fi + status="$(curl "${curl_args[@]}" "$url")" || curl_status=$? + rm -f -- "$response_tmp" "$ACCESS_HEADER_FILE" + if ((curl_status != 0)); then + return 1 + fi + if [[ "$status" == "404" ]]; then + KEY_OPERATION_RESULT=absent + return 0 fi -done <<< "$USER_KEYS" + if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then + KEY_OPERATION_RESULT=success + return 0 + fi + return 1 +} -log_info "Deleted $DELETED_COUNT old key(s)" +delete_key() { + local key_name="$1" -# Create a new key -log_info "Creating new service account key..." -NEW_KEY_RESPONSE=$(curl -s -X POST \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -H "Content-Type: application/json" \ - "https://iam.googleapis.com/v1/$SA_RESOURCE/keys") + iam_mutation DELETE "https://iam.googleapis.com/v1/$key_name" +} -# Check if key creation was successful -if echo "$NEW_KEY_RESPONSE" | jq -e '.privateKeyData' > /dev/null 2>&1; then - PRIVATE_KEY_DATA=$(echo "$NEW_KEY_RESPONSE" | jq -r '.privateKeyData') - NEW_KEY_ID=$(echo "$NEW_KEY_RESPONSE" | jq -r '.name' | xargs basename) +disable_key() { + local key_name="$1" remote_status - log_info "New key created: $NEW_KEY_ID" + remote_status="$(key_remote_status "$key_name")" || return 1 + case "$remote_status" in + absent) + KEY_OPERATION_RESULT=absent + return 0 + ;; + disabled) + KEY_OPERATION_RESULT=success + return 0 + ;; + enabled) + iam_mutation POST "https://iam.googleapis.com/v1/${key_name}:disable" + ;; + esac +} - log_info "Saving new credentials to: $CREDENTIALS_FILE" - echo "$PRIVATE_KEY_DATA" | base64 -d > "$CREDENTIALS_FILE" +enable_key() { + local key_name="$1" remote_status - chown 100 "$CREDENTIALS_FILE" - chmod 440 "$CREDENTIALS_FILE" + remote_status="$(key_remote_status "$key_name")" || return 1 + case "$remote_status" in + absent) + log_error "Cannot enable absent rollback key $key_name" + return 1 + ;; + enabled) + KEY_OPERATION_RESULT=success + return 0 + ;; + disabled) + iam_mutation POST "https://iam.googleapis.com/v1/${key_name}:enable" + [[ "$KEY_OPERATION_RESULT" != "absent" ]] + ;; + esac +} - log_info "Successfully rotated service account key!" - log_info "Old key ID: $CURRENT_KEY_ID (deleted)" - log_info "New key ID: $NEW_KEY_ID" - log_info "Total keys deleted: $DELETED_COUNT" -else - log_error "Failed to create new key" - echo "$NEW_KEY_RESPONSE" | jq '.' - exit 1 -fi +validate_credentials_key() { + local file="$1" expected_key_id="$2" + + [[ ! -L "$file" && -f "$file" ]] || return 1 + jq -e \ + --arg key_id "$expected_key_id" \ + --arg service_account "$SERVICE_ACCOUNT" \ + --arg project_id "$PROJECT_ID" ' + .type == "service_account" and + .private_key_id == $key_id and + .client_email == $service_account and + .project_id == $project_id and + .token_uri == "https://oauth2.googleapis.com/token" and + (.private_key | type == "string" and startswith("-----BEGIN PRIVATE KEY-----") and contains("-----END PRIVATE KEY-----")) + ' "$file" >/dev/null +} + +install_credentials_file() { + local source="$1" target="$2" mode="${3:-0440}" + local credential_owner="${4-${ROTATION_CREDENTIAL_OWNER}}" target_tmp + + target_tmp="$(mktemp "${target}.tmp.XXXXXX")" || return 1 + if ! install -m "$mode" "$source" "$target_tmp"; then + rm -f -- "$target_tmp" + return 1 + fi + if [[ -n "$credential_owner" ]] && ! chown -- "$credential_owner" "$target_tmp"; then + rm -f -- "$target_tmp" + return 1 + fi + mv -f -- "$target_tmp" "$target" +} + +preserve_previous_credentials() { + if [[ -z "$STATE_CURRENT_KEY_ID" ]]; then + return 0 + fi + if validate_credentials_key "$PREVIOUS_CREDENTIALS" "$STATE_CURRENT_KEY_ID"; then + return 0 + fi + if [[ -e "$PREVIOUS_CREDENTIALS" || -L "$PREVIOUS_CREDENTIALS" ]]; then + log_error "Previous credential backup is invalid or unsafe: $PREVIOUS_CREDENTIALS" + return 1 + fi + validate_credentials_key "$CREDENTIALS_FILE" "$STATE_CURRENT_KEY_ID" || { + log_error "Current credentials cannot be preserved before replacement" + return 1 + } + install_credentials_file "$CREDENTIALS_FILE" "$PREVIOUS_CREDENTIALS" 0400 root +} + +install_staged_credentials() { + validate_credentials_key "$STAGED_CREDENTIALS" "$STATE_NEW_KEY_ID" || { + log_error "Staged replacement credentials are invalid" + return 1 + } + preserve_previous_credentials || return 1 + install_credentials_file "$STAGED_CREDENTIALS" "$CREDENTIALS_FILE" 0440 || return 1 + rm -f -- "$STAGED_CREDENTIALS" +} + +base64url() { + base64 | tr -d '\n=' | tr '+/' '-_' +} + +authenticate_credentials_once() { + local file="$1" expected_key_id="$2" + local email token_uri private_key_tmp request_tmp response_tmp + local header payload signing_input signature assertion now curl_status=0 + + validate_credentials_key "$file" "$expected_key_id" || return 1 + command -v openssl >/dev/null 2>&1 || { + log_error "openssl is required to authenticate replacement credentials" + return 1 + } + email="$(jq -er '.client_email' "$file")" || return 1 + token_uri="$(jq -er '.token_uri' "$file")" || return 1 + private_key_tmp="$(mktemp "${credentials_dir}/.auth-key.XXXXXX")" || return 1 + request_tmp="$(mktemp "${credentials_dir}/.auth-request.XXXXXX")" || { + rm -f -- "$private_key_tmp" + return 1 + } + response_tmp="$(mktemp "${credentials_dir}/.auth-response.XXXXXX")" || { + rm -f -- "$private_key_tmp" "$request_tmp" + return 1 + } + chmod 0600 "$private_key_tmp" "$request_tmp" "$response_tmp" + jq -er '.private_key' "$file" >"$private_key_tmp" || { + rm -f -- "$private_key_tmp" "$request_tmp" "$response_tmp" + return 1 + } + + now="$(now_epoch)" + header="$(printf '%s' '{"alg":"RS256","typ":"JWT"}' | base64url)" + payload="$(jq -cn \ + --arg iss "$email" \ + --arg aud "$token_uri" \ + --argjson iat "$now" \ + '{iss: $iss, scope: "https://www.googleapis.com/auth/cloud-platform", aud: $aud, iat: $iat, exp: ($iat + 3600)}' | base64url)" + signing_input="${header}.${payload}" + signature="$(printf '%s' "$signing_input" | openssl dgst -sha256 -sign "$private_key_tmp" | base64url)" || { + rm -f -- "$private_key_tmp" "$request_tmp" "$response_tmp" + return 1 + } + assertion="${signing_input}.${signature}" + printf 'grant_type=urn%%3Aietf%%3Aparams%%3Aoauth%%3Agrant-type%%3Ajwt-bearer&assertion=%s' \ + "$assertion" >"$request_tmp" + curl -fsS --connect-timeout 5 --max-time 30 \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + --data-binary "@$request_tmp" \ + -o "$response_tmp" \ + "$token_uri" || curl_status=$? + rm -f -- "$private_key_tmp" "$request_tmp" + if ((curl_status != 0)) || + ! jq -e '.access_token | type == "string" and length > 0' "$response_tmp" >/dev/null; then + rm -f -- "$response_tmp" + return 1 + fi + rm -f -- "$response_tmp" +} + +authenticate_credentials_with_backoff() { + local file="$1" expected_key_id="$2" attempt sleep_seconds + + for ((attempt = 1; attempt <= 10#$ROTATION_AUTH_MAX_RETRIES; attempt++)); do + if authenticate_credentials_once "$file" "$expected_key_id"; then + log_info "Authenticated service-account key $expected_key_id" + return 0 + fi + if ((attempt == 10#$ROTATION_AUTH_MAX_RETRIES)); then + break + fi + sleep_seconds=$((10#$ROTATION_AUTH_SLEEP_SECONDS * attempt)) + log_info "Replacement credential authentication is not ready; retrying in ${sleep_seconds}s (${attempt}/${ROTATION_AUTH_MAX_RETRIES})" + sleep "$sleep_seconds" + done + log_error "Replacement credentials could not authenticate after $ROTATION_AUTH_MAX_RETRIES attempts" + return 1 +} + +resume_pending_prepare() { + local recovered_key_id + + load_state || return 1 + case "$STATE_PHASE" in + creating) + if [[ -L "$STAGED_CREDENTIALS" || ! -f "$STAGED_CREDENTIALS" ]]; then + log_error "Key creation has an ambiguous result; run audit, then recover with an explicitly confirmed orphan key ID" + return 1 + fi + recovered_key_id="$(credential_key_id "$STAGED_CREDENTIALS")" || { + log_error "Could not recover replacement key ID from staged credentials" + return 1 + } + STATE_NEW_KEY_ID="$recovered_key_id" + STATE_NEW_KEY_NAME="$SA_RESOURCE/keys/$STATE_NEW_KEY_ID" + STATE_PHASE=staged + write_state + install_staged_credentials + log_info "Recovered and installed staged key $STATE_NEW_KEY_ID" + ;; + staged | authenticated | ready | grace) + validate_credentials_key "$CREDENTIALS_FILE" "$STATE_NEW_KEY_ID" || { + if [[ "$STATE_PHASE" == "staged" ]] && validate_credentials_key "$STAGED_CREDENTIALS" "$STATE_NEW_KEY_ID"; then + install_staged_credentials || return 1 + else + log_error "Replacement key $STATE_NEW_KEY_ID is not installed at $CREDENTIALS_FILE" + return 1 + fi + } + if [[ -n "$STATE_CURRENT_KEY_ID" ]] && ! validate_credentials_key "$PREVIOUS_CREDENTIALS" "$STATE_CURRENT_KEY_ID"; then + log_error "Previous credentials required for grace/rollback are missing or invalid" + return 1 + fi + log_info "Resuming $STATE_PHASE rotation for key $STATE_NEW_KEY_ID" + ;; + rolling-back) + if ! validate_credentials_key "$CREDENTIALS_FILE" "$STATE_CURRENT_KEY_ID"; then + validate_credentials_key "$PREVIOUS_CREDENTIALS" "$STATE_CURRENT_KEY_ID" || return 1 + install_credentials_file "$PREVIOUS_CREDENTIALS" "$CREDENTIALS_FILE" 0440 || return 1 + fi + STATE_PHASE=rollback + write_state + log_info "Resumed rollback to previous key $STATE_CURRENT_KEY_ID" + ;; + rollback) + validate_credentials_key "$CREDENTIALS_FILE" "$STATE_CURRENT_KEY_ID" || { + log_error "Rollback credentials are not installed" + return 1 + } + ;; + revoke-new) + fetch_access_token || return 1 + if ! delete_key "$STATE_NEW_KEY_NAME"; then + log_error "Failed to delete unusable replacement key $STATE_NEW_KEY_ID" + return 1 + fi + cleanup_state + log_error "Cleaned up an unusable replacement key; retry rotation on the next run" + return 1 + ;; + esac +} + +prepare_rotation() { + local current_key_id="" file_modified_at file_age_seconds + local new_key_response="" new_key_name new_key_id private_key_data staged_tmp + local create_succeeded=true curl_status=0 + + if [[ -L "$CREDENTIALS_FILE" || ( -e "$CREDENTIALS_FILE" && ! -f "$CREDENTIALS_FILE" ) ]]; then + log_error "Credentials path is unsafe: $CREDENTIALS_FILE" + return 1 + fi + if [[ -e "$PENDING_STATE" || -L "$PENDING_STATE" ]]; then + resume_pending_prepare + return + fi + + if [[ -f "$CREDENTIALS_FILE" ]]; then + file_modified_at="$(stat -c %Y "$CREDENTIALS_FILE" 2>/dev/null || stat -f %m "$CREDENTIALS_FILE" 2>/dev/null)" || { + log_error "Could not determine credentials age: $CREDENTIALS_FILE" + return 1 + } + file_age_seconds=$(( $(now_epoch) - file_modified_at )) + if ((file_age_seconds < 10#$ROTATION_MIN_AGE_SECONDS)); then + log_info "Credentials are younger than the rotation interval" + return 0 + fi + current_key_id="$(credential_key_id "$CREDENTIALS_FILE")" || { + log_error "Existing credentials file is invalid: $CREDENTIALS_FILE" + return 1 + } + validate_credentials_key "$CREDENTIALS_FILE" "$current_key_id" || { + log_error "Existing credentials file does not match the rotation target: $CREDENTIALS_FILE" + return 1 + } + fi + + fetch_access_token || return 1 + STATE_PHASE=creating + STATE_CURRENT_KEY_ID="$current_key_id" + STATE_NEW_KEY_ID="" + STATE_NEW_KEY_NAME="" + STATE_BASELINE_KEY_NAMES="$(list_user_key_names)" || return 1 + STATE_CREATED_AT="$(now_epoch)" + STATE_READY_AT=0 + STATE_DISABLED_AT=0 + write_state + + log_info "Creating replacement key for $SERVICE_ACCOUNT" + write_access_header_file || return 1 + new_key_response="$(curl -fsS --connect-timeout 5 --max-time 30 -X POST \ + -H "@$ACCESS_HEADER_FILE" \ + -H "Content-Type: application/json" \ + "https://iam.googleapis.com/v1/$SA_RESOURCE/keys")" || curl_status=$? + rm -f -- "$ACCESS_HEADER_FILE" + if ((curl_status != 0)); then + create_succeeded=false + fi + if [[ "$create_succeeded" != "true" ]]; then + log_error "Key creation had an indeterminate result; pending state prevents another key until audited recovery" + return 1 + fi + + new_key_name="$(jq -er '.name | select(type == "string" and length > 0)' <<<"$new_key_response")" || { + log_error "Key creation response omitted the key resource name; use audited recovery" + return 1 + } + new_key_id="${new_key_name##*/}" + if [[ ! "$new_key_id" =~ ^[A-Za-z0-9_-]+$ ]] || [[ "$new_key_name" != "$SA_RESOURCE/keys/$new_key_id" ]]; then + log_error "Key creation response contained an unexpected resource name; use audited recovery" + return 1 + fi + + STATE_NEW_KEY_ID="$new_key_id" + STATE_NEW_KEY_NAME="$new_key_name" + private_key_data="$(jq -er '.privateKeyData | select(type == "string" and length > 0)' <<<"$new_key_response")" || { + STATE_PHASE=revoke-new + write_state + if delete_key "$new_key_name"; then + cleanup_state + fi + log_error "Key creation response omitted replacement credentials" + return 1 + } + + staged_tmp="$(mktemp "${STAGED_CREDENTIALS}.tmp.XXXXXX")" || return 1 + if ! printf '%s' "$private_key_data" | base64 -d >"$staged_tmp" || + ! validate_credentials_key "$staged_tmp" "$new_key_id"; then + rm -f -- "$staged_tmp" + STATE_PHASE=revoke-new + write_state + if delete_key "$new_key_name"; then + cleanup_state + fi + log_error "IAM returned invalid replacement credentials" + return 1 + fi + chmod 0600 "$staged_tmp" + mv -f -- "$staged_tmp" "$STAGED_CREDENTIALS" + + STATE_PHASE=staged + write_state + if ! install_staged_credentials; then + STATE_PHASE=revoke-new + write_state + if delete_key "$new_key_name"; then + cleanup_state + fi + log_error "Could not safely preserve and install replacement credentials" + return 1 + fi + log_info "Installed replacement key $new_key_id; previous credentials remain available for rollback" +} + +authenticate_rotation() { + load_state || return 1 + case "$STATE_PHASE" in + authenticated | ready | grace) + return 0 + ;; + staged) + authenticate_credentials_with_backoff "$CREDENTIALS_FILE" "$STATE_NEW_KEY_ID" || return 1 + STATE_PHASE=authenticated + write_state + ;; + *) + log_error "Rotation cannot authenticate from phase $STATE_PHASE" + return 1 + ;; + esac +} + +mark_rotation_ready() { + load_state || return 1 + case "$STATE_PHASE" in + ready | grace) + return 0 + ;; + authenticated) + validate_credentials_key "$CREDENTIALS_FILE" "$STATE_NEW_KEY_ID" || { + log_error "Authenticated replacement credentials are no longer installed" + return 1 + } + STATE_PHASE=ready + STATE_READY_AT="$(now_epoch)" + write_state + log_info "Consumers are ready on authenticated replacement key $STATE_NEW_KEY_ID" + ;; + *) + log_error "Rotation cannot become ready from phase $STATE_PHASE" + return 1 + ;; + esac +} + +commit_rotation() { + local current_key_name elapsed + + load_state || return 1 + validate_credentials_key "$CREDENTIALS_FILE" "$STATE_NEW_KEY_ID" || { + log_error "Replacement credentials are not installed" + return 1 + } + + if [[ "$STATE_PHASE" == "ready" ]]; then + if [[ -z "$STATE_CURRENT_KEY_ID" ]]; then + cleanup_state + log_info "No previous local key was known; replacement committed without revocation" + return 0 + fi + if [[ "$STATE_CURRENT_KEY_ID" == "$STATE_NEW_KEY_ID" ]]; then + log_error "Refusing to disable the installed replacement key" + return 1 + fi + validate_credentials_key "$PREVIOUS_CREDENTIALS" "$STATE_CURRENT_KEY_ID" || { + log_error "Previous credentials are unavailable; refusing to remove rollback capability" + return 1 + } + + current_key_name="$SA_RESOURCE/keys/$STATE_CURRENT_KEY_ID" + fetch_access_token || return 1 + log_info "Disabling previous local key $STATE_CURRENT_KEY_ID" + if ! disable_key "$current_key_name"; then + log_error "Failed to disable previous local key $STATE_CURRENT_KEY_ID; ready state will retry" + return 1 + fi + if [[ "$KEY_OPERATION_RESULT" == "absent" ]]; then + cleanup_state + log_info "Previous local key was already absent; replacement committed" + return 0 + fi + STATE_PHASE=grace + STATE_DISABLED_AT="$(now_epoch)" + write_state + log_info "Previous key is disabled and retained for a ${ROTATION_DISABLE_GRACE_SECONDS}s rollback grace period" + return 0 + fi + + if [[ "$STATE_PHASE" != "grace" ]]; then + log_error "Rotation cannot commit from phase $STATE_PHASE" + return 1 + fi + elapsed=$(( $(now_epoch) - STATE_DISABLED_AT )) + if ((elapsed < 10#$ROTATION_DISABLE_GRACE_SECONDS)); then + log_info "Previous key remains in rollback grace for $((10#$ROTATION_DISABLE_GRACE_SECONDS - elapsed))s" + return 0 + fi + + current_key_name="$SA_RESOURCE/keys/$STATE_CURRENT_KEY_ID" + fetch_access_token || return 1 + log_info "Deleting previous disabled key $STATE_CURRENT_KEY_ID after rollback grace" + if ! delete_key "$current_key_name"; then + log_error "Failed to delete previous key $STATE_CURRENT_KEY_ID; grace state will retry" + return 1 + fi + cleanup_state + log_info "Committed replacement key $STATE_NEW_KEY_ID" +} + +begin_rollback() { + local current_key_name + + load_state || return 1 + case "$STATE_PHASE" in + rollback) + validate_credentials_key "$CREDENTIALS_FILE" "$STATE_CURRENT_KEY_ID" || return 1 + log_info "Rollback credentials are already restored; resuming consumer reload" + return 0 + ;; + rolling-back) + validate_credentials_key "$PREVIOUS_CREDENTIALS" "$STATE_CURRENT_KEY_ID" || return 1 + if ! validate_credentials_key "$CREDENTIALS_FILE" "$STATE_CURRENT_KEY_ID"; then + install_credentials_file "$PREVIOUS_CREDENTIALS" "$CREDENTIALS_FILE" 0440 || return 1 + fi + STATE_PHASE=rollback + write_state + log_info "Resumed interrupted rollback to previous key $STATE_CURRENT_KEY_ID" + return 0 + ;; + grace) ;; + *) + log_error "Rollback is available only while the previous key is in grace" + return 1 + ;; + esac + validate_credentials_key "$PREVIOUS_CREDENTIALS" "$STATE_CURRENT_KEY_ID" || { + log_error "Previous credential backup is unavailable or invalid" + return 1 + } + validate_credentials_key "$CREDENTIALS_FILE" "$STATE_NEW_KEY_ID" || { + log_error "Replacement credentials are unavailable; refusing an ambiguous rollback" + return 1 + } + + current_key_name="$SA_RESOURCE/keys/$STATE_CURRENT_KEY_ID" + fetch_access_token || return 1 + log_info "Re-enabling previous key $STATE_CURRENT_KEY_ID for rollback" + enable_key "$current_key_name" || return 1 + authenticate_credentials_with_backoff "$PREVIOUS_CREDENTIALS" "$STATE_CURRENT_KEY_ID" || return 1 + + install_credentials_file "$CREDENTIALS_FILE" "$REPLACEMENT_CREDENTIALS" 0400 root || return 1 + STATE_PHASE=rolling-back + write_state + install_credentials_file "$PREVIOUS_CREDENTIALS" "$CREDENTIALS_FILE" 0440 || return 1 + STATE_PHASE=rollback + write_state + log_info "Previous credentials restored; consumers must reload them before rollback completion" +} + +finish_rollback() { + load_state || return 1 + if [[ "$STATE_PHASE" != "rollback" ]]; then + log_error "Rollback consumers cannot become ready from phase $STATE_PHASE" + return 1 + fi + validate_credentials_key "$CREDENTIALS_FILE" "$STATE_CURRENT_KEY_ID" || return 1 + authenticate_credentials_with_backoff "$CREDENTIALS_FILE" "$STATE_CURRENT_KEY_ID" || return 1 + fetch_access_token || return 1 + log_info "Disabling abandoned replacement key $STATE_NEW_KEY_ID" + if ! disable_key "$STATE_NEW_KEY_NAME"; then + log_error "Failed to disable abandoned replacement key; rollback state will retry" + return 1 + fi + if [[ "$KEY_OPERATION_RESULT" != "absent" ]]; then + log_info "Deleting abandoned replacement key $STATE_NEW_KEY_ID" + delete_key "$STATE_NEW_KEY_NAME" || return 1 + fi + cleanup_state + log_info "Rollback to key $STATE_CURRENT_KEY_ID completed" +} + +retire_credentials() { + local current_key_id current_key_name remaining_keys remaining_key_ids + + if [[ -e "$PENDING_STATE" || -L "$PENDING_STATE" ]]; then + log_error "Resolve the pending rotation before retiring credentials: $PENDING_STATE" + return 1 + fi + if [[ ! -e "$CREDENTIALS_FILE" && ! -L "$CREDENTIALS_FILE" ]]; then + # Local absence alone cannot prove revocation. The file may have been + # removed manually while its remote private key remained valid. List + # only USER_MANAGED keys and fail closed until the operator audits any + # remaining IDs; never guess which externally managed key to delete. + fetch_access_token || return 1 + remaining_keys="$(list_user_keys)" || return 1 + if [[ "$(jq -r 'length' <<<"$remaining_keys")" == "0" ]]; then + log_info "No local credential or remote user-managed key remains; retirement is complete" + return 0 + fi + remaining_key_ids="$(jq -r '[.[].name | split("/")[-1]] | sort | join(", ")' <<<"$remaining_keys")" || return 1 + log_error "No local credential is available to identify the managed key, but remote user-managed keys remain: $remaining_key_ids" + log_error "Audit and explicitly revoke the remaining key IDs before disabling managed credentials" + return 1 + fi + if [[ -L "$CREDENTIALS_FILE" || ! -f "$CREDENTIALS_FILE" ]]; then + log_error "Credentials path is unsafe: $CREDENTIALS_FILE" + return 1 + fi + current_key_id="$(credential_key_id "$CREDENTIALS_FILE")" || { + log_error "Existing credentials file is invalid: $CREDENTIALS_FILE" + return 1 + } + validate_credentials_key "$CREDENTIALS_FILE" "$current_key_id" || { + log_error "Existing credentials do not match the retirement target" + return 1 + } + current_key_name="$SA_RESOURCE/keys/$current_key_id" + fetch_access_token || return 1 + log_info "Deleting retired service-account key $current_key_id" + if ! delete_key "$current_key_name"; then + log_error "Failed to delete retired key $current_key_id" + return 1 + fi + rm -f -- "$CREDENTIALS_FILE" "$STAGED_CREDENTIALS" "$PREVIOUS_CREDENTIALS" \ + "$REPLACEMENT_CREDENTIALS" + log_info "Retired local credentials for $SERVICE_ACCOUNT" +} + +creation_candidates() { + local current_names + + fetch_access_token || return 1 + current_names="$(list_user_key_names)" || return 1 + jq -cn --argjson before "$STATE_BASELINE_KEY_NAMES" --argjson after "$current_names" \ + '$after - $before | sort' +} + +rotation_audit() { + local candidates='[]' grace_remaining=0 elapsed + + if [[ ! -e "$PENDING_STATE" && ! -L "$PENDING_STATE" ]]; then + jq -n '{version: 1, phase: "idle", recovery_required: false, candidate_key_ids: [], grace_remaining_seconds: 0}' + return 0 + fi + load_state || return 1 + if [[ "$STATE_PHASE" == "creating" ]]; then + candidates="$(creation_candidates)" || return 1 + fi + if [[ "$STATE_PHASE" == "grace" ]]; then + elapsed=$(( $(now_epoch) - STATE_DISABLED_AT )) + if ((elapsed < 10#$ROTATION_DISABLE_GRACE_SECONDS)); then + grace_remaining=$((10#$ROTATION_DISABLE_GRACE_SECONDS - elapsed)) + fi + fi + jq -n \ + --arg phase "$STATE_PHASE" \ + --arg current_key_id "$STATE_CURRENT_KEY_ID" \ + --arg new_key_id "$STATE_NEW_KEY_ID" \ + --argjson candidate_names "$candidates" \ + --argjson created_at "$STATE_CREATED_AT" \ + --argjson ready_at "$STATE_READY_AT" \ + --argjson disabled_at "$STATE_DISABLED_AT" \ + --argjson grace_remaining "$grace_remaining" ' + { + version: 1, + phase: $phase, + current_key_id: $current_key_id, + new_key_id: $new_key_id, + recovery_required: ($phase == "creating"), + candidate_key_ids: [$candidate_names[] | split("/")[-1]], + created_at: $created_at, + ready_at: $ready_at, + disabled_at: $disabled_at, + grace_remaining_seconds: $grace_remaining + }' +} + +recover_ambiguous_creation() { + local candidates candidate_id creation_age + + load_state || return 1 + if [[ "$STATE_PHASE" != "creating" ]]; then + log_error "Audited recovery is valid only for an ambiguous creating state" + return 1 + fi + creation_age=$(( $(now_epoch) - STATE_CREATED_AT )) + if ((creation_age < 10#$ROTATION_RECOVERY_SETTLE_SECONDS)); then + rotation_audit + log_error "Ambiguous creation must settle for ${ROTATION_RECOVERY_SETTLE_SECONDS}s before recovery; no key was changed" + return 1 + fi + candidates="$(creation_candidates)" || return 1 + if [[ "$(jq -r 'length' <<<"$candidates")" == "0" ]]; then + cleanup_state + log_info "Audit found no key beyond the pre-create baseline; cleared ambiguous state" + return 0 + fi + if [[ "$(jq -r 'length' <<<"$candidates")" != "1" ]]; then + rotation_audit + log_error "Recovery is ambiguous because more than one post-baseline key exists; no key was changed" + return 1 + fi + candidate_id="$(jq -r '.[0] | split("/")[-1]' <<<"$candidates")" + if [[ -z "$RECOVERY_KEY_ID" || "$RECOVERY_KEY_ID" != "$candidate_id" ]]; then + rotation_audit + log_error "Recovery requires the single audited candidate key ID as the final argument; no key was changed" + return 1 + fi + fetch_access_token || return 1 + log_info "Deleting explicitly confirmed orphan key $candidate_id from ambiguous creation" + delete_key "$SA_RESOURCE/keys/$candidate_id" || return 1 + cleanup_state + log_info "Audited recovery completed; a later run may create a new replacement" +} + +rotation_status() { + if [[ ! -e "$PENDING_STATE" && ! -L "$PENDING_STATE" ]]; then + printf 'idle\n' + return 0 + fi + load_state || return 1 + printf '%s\n' "$STATE_PHASE" +} + +case "$ACTION" in + prepare) prepare_rotation ;; + status) rotation_status ;; + audit) rotation_audit ;; + recover) recover_ambiguous_creation ;; + authenticate) authenticate_rotation ;; + ready) mark_rotation_ready ;; + commit) commit_rotation ;; + rollback) begin_rollback ;; + rollback-ready) finish_rollback ;; + retire) retire_credentials ;; +esac diff --git a/rootfs/home/cloud-compose/run-rollout-service.sh b/rootfs/home/cloud-compose/run-rollout-service.sh new file mode 100644 index 0000000..413973f --- /dev/null +++ b/rootfs/home/cloud-compose/run-rollout-service.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# shellcheck disable=SC1090,SC1091 +source "${CLOUD_COMPOSE_PROFILE_PATH:-/home/cloud-compose/profile.sh}" + +unset BASH_ENV ENV LD_PRELOAD LD_LIBRARY_PATH +PORT="${ROLLOUT_PORT:?ROLLOUT_PORT is required}" +JWKS_URI="${ROLLOUT_JWKS_URI:?ROLLOUT_JWKS_URI is required}" +JWT_AUD="${ROLLOUT_JWT_AUD:?ROLLOUT_JWT_AUD is required}" +CUSTOM_CLAIMS="${ROLLOUT_CUSTOM_CLAIMS:-}" + +if [[ ! "$PORT" =~ ^[0-9]{1,5}$ ]] || ((10#$PORT < 1 || 10#$PORT > 65535)); then + echo "ROLLOUT_PORT must be an integer from 1 through 65535" >&2 + exit 2 +fi +if [[ ! "$JWKS_URI" =~ ^https://[^[:space:]]+$ ]]; then + echo "ROLLOUT_JWKS_URI must be an HTTPS URL without whitespace" >&2 + exit 2 +fi +if [[ "$JWT_AUD" == *$'\n'* || "$JWT_AUD" == *$'\r'* || -z "$JWT_AUD" ]]; then + echo "ROLLOUT_JWT_AUD must be a non-empty single-line value" >&2 + exit 2 +fi +if [[ -n "$CUSTOM_CLAIMS" ]] && + ! jq -e 'type == "object"' <<<"$CUSTOM_CLAIMS" >/dev/null; then + echo "ROLLOUT_CUSTOM_CLAIMS must be empty or a JSON object" >&2 + exit 2 +fi +export PORT JWKS_URI JWT_AUD CUSTOM_CLAIMS + +exec "${CLOUD_COMPOSE_ROLLOUT_BIN:-/usr/local/bin/cloud-compose-rollout}" diff --git a/rootfs/home/cloud-compose/run.sh b/rootfs/home/cloud-compose/run.sh index af1450e..c6c58f2 100644 --- a/rootfs/home/cloud-compose/run.sh +++ b/rootfs/home/cloud-compose/run.sh @@ -1,12 +1,15 @@ #!/usr/bin/env bash set -eou pipefail -set -x # shellcheck disable=SC1091 source /home/cloud-compose/profile.sh -run_as_cloud_compose() { +run_as_cloud_compose() ( + # Configuration-management entrypoints commonly launch run.sh from /root. + # Enter an accessible directory before dropping privileges so child scripts + # can safely use directory stacks and relative tool behavior. + cd /home/cloud-compose if command -v runuser >/dev/null 2>&1; then runuser -u cloud-compose -- env HOME=/home/cloud-compose PATH="$PATH" "$@" elif command -v sudo >/dev/null 2>&1; then @@ -17,23 +20,69 @@ run_as_cloud_compose() { echo "No supported user-switching command found for cloud-compose app init" >&2 return 1 fi +) + +runtime_enabled() { + case "${1:-false}" in + true | TRUE | 1 | yes | YES) return 0 ;; + *) return 1 ;; + esac +} + +# The shared lifecycle lock must exist before the root-owned managed-runtime +# installer and the unprivileged application service can contend for it. This +# explicit creation covers first boot; systemd-tmpfiles recreates it thereafter. +command -v systemd-tmpfiles >/dev/null 2>&1 || { + echo "systemd-tmpfiles is required to prepare the cloud-compose lifecycle lock" >&2 + exit 1 } +systemd-tmpfiles --create /etc/tmpfiles.d/cloud-compose.conf + +# Overlay mounts must exist before Docker inspects its volume data root. The +# unit is a no-op when no GCP overlay volumes are configured, but remains a +# hard Docker dependency on later boots so a missing source fails closed. +systemctl daemon-reload +systemctl enable cloud-compose-overlay.service +systemctl restart cloud-compose-overlay.service bash /home/cloud-compose/host-conf.sh bash /home/cloud-compose/host-init.sh -run_as_cloud_compose bash /home/cloud-compose/app-init.sh +# Source preparation performs no application lifecycle work. It gives key +# rotation a validated destination without allowing app/plugin initialization +# to run before an explicitly enabled Vault Agent has authenticated. +run_as_cloud_compose bash /home/cloud-compose/prepare-app-sources.sh if [ "${CLOUD_COMPOSE_PROVIDER:-}" = "gcp" ]; then - bash /home/cloud-compose/rotate-keys-app.sh || true - bash /home/cloud-compose/rotate-keys-internal.sh || true + bash /home/cloud-compose/rotate-keys-daily.sh + if runtime_enabled "${GCP_APP_CREDENTIALS_ENABLED:-false}" || + runtime_enabled "${LIBOPS_INTERNAL_SERVICES_ENABLED:-false}"; then + systemctl enable --now cloud-compose-key-rotation.timer + else + systemctl disable --now cloud-compose-key-rotation.timer cloud-compose-key-rotation.service >/dev/null 2>&1 || true + fi +else + systemctl disable --now cloud-compose-key-rotation.timer cloud-compose-key-rotation.service >/dev/null 2>&1 || true fi -bash /home/cloud-compose/vault-agent-init.sh || true +# An explicitly enabled Vault Agent is a startup dependency. Its systemd unit +# also publishes a readiness marker before the application service may start. +bash /home/cloud-compose/vault-agent-init.sh +run_as_cloud_compose bash /home/cloud-compose/app-init.sh -systemctl start cloud-compose -if [ "${LIBOPS_INTERNAL_SERVICES_ENABLED:-true}" = "true" ]; then - systemctl start internal-services.timer +systemctl enable --now cloud-compose.service +if runtime_enabled "${LIBOPS_INTERNAL_SERVICES_ENABLED:-false}"; then + systemctl enable --now cloud-compose-internal-services.timer +else + systemctl disable --now cloud-compose-internal-services.timer cloud-compose-internal-services.service >/dev/null 2>&1 || true +fi +if runtime_enabled "${LIBOPS_MANAGED_RUNTIME_ENABLED:-true}"; then + systemctl enable --now libops-managed-runtime.timer +else + systemctl disable --now libops-managed-runtime.timer libops-managed-runtime.service >/dev/null 2>&1 || true +fi +if runtime_enabled "${CLOUD_COMPOSE_DOCKER_PRUNE_ENABLED:-false}"; then + systemctl enable --now cloud-compose-docker-prune.timer +else + systemctl disable --now cloud-compose-docker-prune.timer cloud-compose-docker-prune.service >/dev/null 2>&1 || true fi -systemctl start libops-managed-runtime.timer -systemctl start cron.timer -systemctl start cloud-compose-mariadb-backup.timer +systemctl enable --now cloud-compose-mariadb-backup.timer touch /home/cloud-compose/.cloud-compose-bootstrap-complete chown cloud-compose:cloud-compose /home/cloud-compose/.cloud-compose-bootstrap-complete diff --git a/rootfs/home/cloud-compose/vault-agent-init.sh b/rootfs/home/cloud-compose/vault-agent-init.sh index 1377566..bf6d969 100644 --- a/rootfs/home/cloud-compose/vault-agent-init.sh +++ b/rootfs/home/cloud-compose/vault-agent-init.sh @@ -5,24 +5,40 @@ set -euo pipefail # shellcheck disable=SC1091 source /home/cloud-compose/profile.sh -if [ "${VAULT_AGENT_ENABLED:-false}" != "true" ]; then - echo "Vault Agent is disabled" - exit 0 -fi +case "${VAULT_AGENT_ENABLED:-false}" in + false) + echo "Vault Agent is disabled" + systemctl disable --now cloud-compose-vault-agent.service >/dev/null 2>&1 || true + rm -f -- /run/cloud-compose/vault-agent.ready + exit 0 + ;; + true) ;; + *) + echo "VAULT_AGENT_ENABLED must be true or false" >&2 + exit 1 + ;; +esac -if [ ! -f /etc/vault-agent.d/cloud-compose.hcl ]; then - echo "Vault Agent config is missing" - exit 0 +if [ -L /etc/vault-agent.d/cloud-compose.hcl ] || [ ! -f /etc/vault-agent.d/cloud-compose.hcl ]; then + echo "Vault Agent is enabled but its config is missing or unsafe" >&2 + systemctl disable --now cloud-compose-vault-agent.service >/dev/null 2>&1 || true + exit 1 fi -if ! command -v vault >/dev/null 2>&1; then - echo "Vault binary is not installed; skipping vault-agent.service" - exit 0 +if [ ! -x /usr/local/bin/vault ]; then + echo "Vault Agent is enabled but the Vault binary is not installed" >&2 + systemctl disable --now cloud-compose-vault-agent.service >/dev/null 2>&1 || true + exit 1 fi -mkdir -p "$(dirname "${VAULT_AGENT_TOKEN_PATH:-/mnt/disks/data/vault/token}")" -chmod 0700 "$(dirname "${VAULT_AGENT_TOKEN_PATH:-/mnt/disks/data/vault/token}")" +bash /home/cloud-compose/vault-agent-readiness.sh prepare systemctl daemon-reload -systemctl enable vault-agent.service -systemctl restart vault-agent.service +systemctl enable cloud-compose-vault-agent.service +systemctl restart cloud-compose-vault-agent.service +if ! systemctl is-active --quiet cloud-compose-vault-agent.service || + [ -L /run/cloud-compose/vault-agent.ready ] || [ ! -f /run/cloud-compose/vault-agent.ready ]; then + echo "Vault Agent failed to initialize while explicitly enabled" >&2 + systemctl disable --now cloud-compose-vault-agent.service >/dev/null 2>&1 || true + exit 1 +fi diff --git a/rootfs/home/cloud-compose/vault-agent-readiness.sh b/rootfs/home/cloud-compose/vault-agent-readiness.sh new file mode 100644 index 0000000..af9f7df --- /dev/null +++ b/rootfs/home/cloud-compose/vault-agent-readiness.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash + +set -euo pipefail + +profile_path="${CLOUD_COMPOSE_PROFILE_PATH:-/home/cloud-compose/profile.sh}" +# shellcheck disable=SC1090 +source "$profile_path" + +READY_MARKER="/run/cloud-compose/vault-agent.ready" +DEFAULT_TOKEN_PATH="/mnt/disks/data/vault/token" + +vault_safe_dir() { + printf '/mnt/disks/data/vault\n' +} + +validate_vault_token_path() { + local safe_dir safe_parent canonical_parent token_path token_name + + safe_dir="$(vault_safe_dir)" + safe_parent="$(dirname -- "$safe_dir")" + token_path="${VAULT_AGENT_TOKEN_PATH:-$DEFAULT_TOKEN_PATH}" + token_name="$(basename -- "$token_path")" + + if [[ "$token_path" != "$safe_dir/$token_name" || + ! "$token_name" =~ ^[A-Za-z0-9._-]+$ || "$token_name" == "." || "$token_name" == ".." ]]; then + echo "Vault Agent token path must be one file directly inside $safe_dir" >&2 + return 1 + fi + if [[ ! -d "$safe_parent" || -L "$safe_parent" ]]; then + echo "Vault Agent safe-directory parent is missing or unsafe: $safe_parent" >&2 + return 1 + fi + canonical_parent="$(readlink -f -- "$safe_parent")" || return 1 + if [[ "$canonical_parent" != "$safe_parent" ]]; then + echo "Vault Agent safe-directory parent contains a symbolic-link traversal" >&2 + return 1 + fi + if [[ -L "$safe_dir" || ( -e "$safe_dir" && ! -d "$safe_dir" ) ]]; then + echo "Vault Agent safe directory is not a regular directory: $safe_dir" >&2 + return 1 + fi + if [[ -L "$token_path" || ( -e "$token_path" && ! -f "$token_path" ) ]]; then + echo "Vault Agent token target is unsafe: $token_path" >&2 + return 1 + fi + + VAULT_SAFE_DIR="$safe_dir" + VAULT_TOKEN_PATH="$token_path" +} + +prepare_vault_agent() { + validate_vault_token_path || return 1 + rm -f -- "$READY_MARKER" + # A pre-existing sink token does not prove the replacement agent has + # authenticated. Remove only the validated dedicated token file so + # ExecStartPost must observe a fresh write from this start. + rm -f -- "$VAULT_TOKEN_PATH" + install -d -m 0700 -o root -g root -- "$VAULT_SAFE_DIR" + if [[ -L "$VAULT_SAFE_DIR" || ! -d "$VAULT_SAFE_DIR" ]]; then + echo "Vault Agent safe directory changed during preparation" >&2 + return 1 + fi +} + +wait_for_vault_agent() { + local timeout_seconds deadline + + validate_vault_token_path || return 1 + timeout_seconds="${VAULT_AGENT_START_TIMEOUT_SECONDS:-60}" + if [[ ! "$timeout_seconds" =~ ^[1-9][0-9]{0,2}$ ]] || ((10#$timeout_seconds > 300)); then + echo "VAULT_AGENT_START_TIMEOUT_SECONDS must be an integer from 1 through 300" >&2 + return 2 + fi + deadline=$((SECONDS + 10#$timeout_seconds)) + while ((SECONDS < deadline)); do + if [[ ! -L "$VAULT_TOKEN_PATH" && -f "$VAULT_TOKEN_PATH" && -s "$VAULT_TOKEN_PATH" ]]; then + install -d -m 0755 -- "$(dirname -- "$READY_MARKER")" + : >"$READY_MARKER" + chmod 0644 "$READY_MARKER" + return 0 + fi + sleep 1 + done + echo "Vault Agent did not publish a token within ${timeout_seconds}s" >&2 + return 1 +} + +clear_vault_agent_ready() { + rm -f -- "$READY_MARKER" +} + +main() { + case "${1:-}" in + prepare) prepare_vault_agent ;; + wait) wait_for_vault_agent ;; + clear) clear_vault_agent_ready ;; + *) + echo "Usage: $0 prepare|wait|clear" >&2 + return 2 + ;; + esac +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/rootfs/mnt/disks/data/libops-internal/docker-compose.yaml b/rootfs/mnt/disks/data/libops-internal/docker-compose.yaml index 6ce62f1..bf01f6e 100644 --- a/rootfs/mnt/disks/data/libops-internal/docker-compose.yaml +++ b/rootfs/mnt/disks/data/libops-internal/docker-compose.yaml @@ -19,7 +19,7 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - /mnt/disks/data:/mnt/disks/data - - ./GOOGLE_APPLICATION_CREDENTIALS:/app/GOOGLE_APPLICATION_CREDENTIALS:r + - ./GOOGLE_APPLICATION_CREDENTIALS:/app/GOOGLE_APPLICATION_CREDENTIALS:ro cadvisor: image: ghcr.io/google/cadvisor:v0.60.5@sha256:763aecf1c32c2be8a1a75f9abfc2fc461005c9dbbaa39cb356b354aac1296dbe command: @@ -52,6 +52,6 @@ services: CADVISOR_HOST: "cadvisor:8080" GOOGLE_APPLICATION_CREDENTIALS: /app/GOOGLE_APPLICATION_CREDENTIALS volumes: - - ./GOOGLE_APPLICATION_CREDENTIALS:/app/GOOGLE_APPLICATION_CREDENTIALS:r + - ./GOOGLE_APPLICATION_CREDENTIALS:/app/GOOGLE_APPLICATION_CREDENTIALS:ro depends_on: - cadvisor diff --git a/runtime_contracts.tftest.hcl b/runtime_contracts.tftest.hcl new file mode 100644 index 0000000..8f10f86 --- /dev/null +++ b/runtime_contracts.tftest.hcl @@ -0,0 +1,142 @@ +mock_provider "cloudinit" {} +mock_provider "digitalocean" {} +mock_provider "google" { + mock_data "google_project" { + defaults = { + number = "123456789" + } + } +} +mock_provider "linode" {} +mock_provider "time" {} + +run "provider_neutral_vault_auth_defaults_follow_provider" { + command = plan + + variables { + name = "root-contract" + cloud_provider = "digitalocean" + template = "wp" + runtime = { + rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz" + rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + + assert { + condition = local.vault.auth_method == "consumer-managed" + error_message = "The provider-neutral Vault default must resolve to consumer-managed outside GCP." + } +} + +run "gcp_vault_auth_default_uses_gcp_iam" { + command = plan + + variables { + name = "root-contract" + cloud_provider = "gcp" + template = "wp" + gcp = { + project_id = "test-project" + } + } + + assert { + condition = local.vault.auth_method == "gcp-iam" + error_message = "The provider-neutral Vault default must resolve to gcp-iam on GCP." + } +} + +run "public_entrypoint_exposes_sitectl_package_versions" { + command = plan + + variables { + name = "root-contract" + cloud_provider = "gcp" + template = "isle" + gcp = { + project_id = "test-project" + project_number = "123456789" + } + runtime = { + sitectl = { + package_versions = { + sitectl = "v0.38.0" + sitectl-drupal = "v0.11.0" + sitectl-isle = "v0.12.0" + } + } + } + } + + assert { + condition = output.sitectl_package_versions == { + sitectl = "v0.38.0" + sitectl-drupal = "v0.11.0" + sitectl-isle = "v0.12.0" + } + error_message = "The public entrypoint must expose the effective selector for every template package." + } +} + +run "public_entrypoint_rejects_reserved_extra_environment" { + command = plan + + variables { + name = "root-contract" + cloud_provider = "gcp" + template = "wp" + gcp = { + project_id = "test-project" + project_number = "123456789" + } + runtime = { + extra_env = { + DOCKER_COMPOSE_DIR = "/tmp/untrusted" + } + } + } + + expect_failures = [var.runtime] +} + +run "public_entrypoint_accepts_direct_cloud_run_proxy_depth" { + command = plan + + variables { + name = "root-contract" + cloud_provider = "gcp" + template = "wp" + gcp = { + project_id = "test-project" + project_number = "123456789" + network = { + power_button_ip_depth = 0 + } + } + } + + assert { + condition = local.gcp_network.power_button_ip_depth == 0 + error_message = "The public entrypoint must accept the proven direct Cloud Run client depth of zero." + } +} + +run "public_entrypoint_rejects_negative_power_button_proxy_depth" { + command = plan + + variables { + name = "root-contract" + cloud_provider = "gcp" + template = "wp" + gcp = { + project_id = "test-project" + project_number = "123456789" + network = { + power_button_ip_depth = -1 + } + } + } + + expect_failures = [var.gcp] +} diff --git a/salt/cloud-compose/README.md b/salt/cloud-compose/README.md index f22073c..70aa573 100644 --- a/salt/cloud-compose/README.md +++ b/salt/cloud-compose/README.md @@ -10,6 +10,64 @@ the Terraform provider modules and the Ansible role. The packaged runtime currently uses fixed host paths: `/home/cloud-compose`, `/mnt/disks/data`, and `/mnt/disks/volumes`. +Every resolved Compose `project_dir` must be a normalized, non-root descendant +of `/mnt/disks/data`. Before installing packages, creating accounts, or changing +files, the formula resolves existing symlinks on the minion and rejects `/`, +`/etc`, traversal, repeated/empty segments, control characters, and any symlink +escape outside that boundary. The production boundary is intentionally not +configurable. + +The same preflight validates `runtime.managed_runtime.artifacts` before writing +the host manifest. Artifact names are safe basenames of at most 128 characters; +URLs are HTTPS; checksums are 64 lowercase hex characters; target paths are +non-root, normalized absolute paths; modes match `0?[0-7]{3}`; owners/groups are +safe local account names; and an optional restart target is a safe `.service` +unit. Names and target paths must be unique. + +At install time, every artifact target directory must already exist through a +canonical installer-controlled directory chain. Symlinked ancestors and +group/other-writable non-sticky directories are rejected before download. + +This formula is intentionally limited to a dedicated, empty application host. +It installs cloud-compose files over host Docker, Fluent Bit, Vault Agent, and +systemd locations and restarts Docker during bootstrap. Confirm durable storage +is mounted at the fixed paths and that no unrelated workloads use those host +services before setting the required acknowledgement: + +```yaml +cloud_compose: + dedicated_host_acknowledged: true +``` + +The formula rejects `runtime.vault.agent_enabled: true`; generated Vault Agent +configuration is currently Terraform-only. It also rejects +`runtime.extra_env` entries +named `HOME` or `PATH`, or beginning with `CLOUD_COMPOSE_`, `COMPOSE_`, +`DOCKER_`, `SITECTL_`, `LIBOPS_`, `GCP_`, `VAULT_`, `ROLLOUT_`, or +`POWER_MANAGEMENT_`. Those names belong to the host control plane; use +unreserved application-specific names for tuning. The formula writes those +settings as JSON data at `/home/cloud-compose/application-env.json`, and app +init reconciles them into each Compose project without exporting them into host +processes. +Top-level `extra_env` remains a compatibility fallback when the nested map is +omitted. + +The on-prem formula rejects +`cloud_compose.runtime.managed_runtime.internal_services_enabled: true` +because the current CAP/lightsout stack and its credentials are GCP-specific. +Compose ingress ports must be whole numbers from 1 through 65535. Lifecycle +values (`runtime.compose.init`, `up`, `down`, and `rollout`, plus their +per-project `docker_compose_*` overrides) must be lists of strings. An explicit +empty list disables that phase and is preserved instead of restoring a default. +Runtime feature switches must be YAML booleans, not quoted strings; ambiguous +values are rejected before host mutation. + +The formula installs lifecycle dispatchers as `root:cloud-compose` mode `0750` +and the root-consumed `.env`, project/application JSON, and managed-artifact +manifest as `root:cloud-compose` mode `0640`. Reapplying the state restores that +ownership boundary while leaving application checkout directories writable by +the `cloud-compose` account. + The normal on-prem shape is one app per machine. Use pillar targeting to give each minion its own `cloud_compose` values, then apply the same `cloud-compose` state to every app host. @@ -44,6 +102,7 @@ Example per-host pillar: cloud_compose: name: isle-prod template: isle + dedicated_host_acknowledged: true runtime: compose: ingress: @@ -51,6 +110,8 @@ cloud_compose: acme_email: admin@example.edu sitectl: environment: production + package_versions: + sitectl-isle: v0.18.0 ``` Apply: @@ -59,3 +120,21 @@ Apply: salt 'isle-prod.example.edu' state.apply cloud-compose salt 'wp-prod.example.edu' state.apply cloud-compose ``` + +`cloud_compose.runtime.sitectl.package_versions` pins individual release +packages. The selected template supplies an exact compatible default set; an +explicit entry overrides the matching template selector. Template selectors +are filtered to the effective installed package list, so replacing `packages` +does not retain selectors for removed plugins. An installed package with no +template or explicit selector uses the legacy +`cloud_compose.runtime.sitectl.version` fallback, which defaults to `latest`. +The top-level `cloud_compose.sitectl_version` and +`cloud_compose.sitectl_package_versions` values remain supported as fallbacks +for existing pillars. + +Omit `cloud_compose.runtime.sitectl.packages` to use the selected template's +package set. An explicit empty list selects only the core `sitectl` package. +For a multi-project host, an omitted project `sitectl_packages` value inherits +the global set. The host installs the union of global and project package +lists, so a project can add a package but cannot remove one selected globally +or by another project. diff --git a/salt/cloud-compose/files/application-env.json.jinja b/salt/cloud-compose/files/application-env.json.jinja new file mode 100644 index 0000000..2683307 --- /dev/null +++ b/salt/cloud-compose/files/application-env.json.jinja @@ -0,0 +1 @@ +{{ application_env | json }} diff --git a/salt/cloud-compose/files/env.jinja b/salt/cloud-compose/files/env.jinja index 72f5e87..bbcb813 100644 --- a/salt/cloud-compose/files/env.jinja +++ b/salt/cloud-compose/files/env.jinja @@ -1,3 +1,4 @@ +{# cloud-compose-env-contract: Docker Compose double-quoted dotenv data; never source this file #} {% for key, value in env | dictsort %} -{{ key }}={{ value | string | json }} +{{ key }}="{{ value | string | replace("\\", "\\\\") | replace('"', '\\"') | replace("$", "$$") | replace("\n", "\\n") | replace("\r", "\\r") | replace("\t", "\\t") }}" {% endfor %} diff --git a/salt/cloud-compose/files/managed-runtime-artifacts.tsv.jinja b/salt/cloud-compose/files/managed-runtime-artifacts.tsv.jinja index 732e21f..3acb465 100644 --- a/salt/cloud-compose/files/managed-runtime-artifacts.tsv.jinja +++ b/salt/cloud-compose/files/managed-runtime-artifacts.tsv.jinja @@ -1,3 +1,4 @@ +{% set managed_artifacts = salt['hashutil.base64_b64decode'](managed_artifacts_json_b64) | load_json %} {% for artifact in managed_artifacts %} {{ artifact.name }} {{ artifact.url }} {{ artifact.sha256 }} {{ artifact.path }} {{ artifact.get('mode', '0755') }} {{ artifact.get('owner', 'root') }} {{ artifact.get('group', 'root') }} {{ artifact.get('restart', '') }} {% endfor %} diff --git a/salt/cloud-compose/files/validate-runtime-inputs.py b/salt/cloud-compose/files/validate-runtime-inputs.py new file mode 100755 index 0000000..5b24be1 --- /dev/null +++ b/salt/cloud-compose/files/validate-runtime-inputs.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 + +"""Validate host-mutating cloud-compose inputs before an adapter applies them.""" + +import argparse +import base64 +import json +import os +import re +import sys + + +PRODUCTION_DATA_ROOT = "/mnt/disks/data" +CONTROL_CHARACTERS = re.compile(r"[\x00-\x1f\x7f]") +ARTIFACT_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +HTTPS_URL = re.compile(r"^https://[^\s]+$") +LOWER_SHA256 = re.compile(r"^[0-9a-f]{64}$") +FILE_MODE = re.compile(r"^0?[0-7]{3}$") +ACCOUNT_NAME = re.compile(r"^[a-z_][a-z0-9_-]{0,31}\$?$") +SERVICE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.@:-]*\.service$") + + +def normalized_absolute_path(value): + if not isinstance(value, str) or not value.startswith("/") or value == "/": + return False + if CONTROL_CHARACTERS.search(value): + return False + segments = value[1:].split("/") + return all(segment not in ("", ".", "..") for segment in segments) + + +def validate_project_paths(projects, data_root): + errors = [] + if not isinstance(projects, list): + return ["Compose projects must be a list after adapter normalization."] + + for index, project in enumerate(projects): + label = f"project[{index}]" + if not isinstance(project, dict): + errors.append(f"{label} must be an object.") + continue + if isinstance(project.get("name"), str) and project["name"]: + label = f"project {project['name']!r}" + path = project.get("project_dir") + if not normalized_absolute_path(path): + errors.append( + f"{label} project_dir must be a normalized non-root absolute path " + "without empty, dot, or control-character segments." + ) + continue + if not path.startswith(f"{data_root}/"): + errors.append( + f"{label} project_dir must be a non-root descendant of {data_root}." + ) + continue + + resolved = os.path.realpath(path) + try: + within_data_root = os.path.commonpath((data_root, resolved)) == data_root + except ValueError: + within_data_root = False + if not within_data_root or resolved == data_root: + errors.append( + f"{label} project_dir resolves outside the fixed {data_root} boundary: " + f"{path!r} -> {resolved!r}." + ) + return errors + + +def validate_artifacts(artifacts): + errors = [] + if not isinstance(artifacts, list): + return ["Managed artifacts must be a list."] + + names = [] + paths = [] + required_fields = {"name", "url", "sha256", "path"} + + for index, artifact in enumerate(artifacts): + label = f"artifact[{index}]" + if not isinstance(artifact, dict): + errors.append(f"{label} must be an object.") + continue + + missing = sorted(required_fields - artifact.keys()) + if missing: + errors.append(f"{label} is missing required fields: {', '.join(missing)}.") + + name = artifact.get("name") + if not isinstance(name, str) or ARTIFACT_NAME.fullmatch(name) is None: + errors.append(f"{label} name must be a safe basename.") + else: + names.append(name) + + url = artifact.get("url") + if not isinstance(url, str) or HTTPS_URL.fullmatch(url) is None: + errors.append(f"{label} url must be a non-whitespace HTTPS URL.") + + sha256 = artifact.get("sha256") + if not isinstance(sha256, str) or LOWER_SHA256.fullmatch(sha256) is None: + errors.append(f"{label} sha256 must contain exactly 64 lowercase hex characters.") + + path = artifact.get("path") + if not normalized_absolute_path(path): + errors.append( + f"{label} path must be a non-root absolute path without empty, dot, " + "or control-character segments." + ) + else: + paths.append(path) + + mode = artifact.get("mode", "0755") + if not isinstance(mode, str) or FILE_MODE.fullmatch(mode) is None: + errors.append(f"{label} mode must match 0?[0-7]{{3}}.") + + for field in ("owner", "group"): + value = artifact.get(field, "root") + if not isinstance(value, str) or ACCOUNT_NAME.fullmatch(value) is None: + errors.append(f"{label} {field} must be a safe Linux account name.") + + restart = artifact.get("restart", "") + if not isinstance(restart, str) or ( + restart and SERVICE_NAME.fullmatch(restart) is None + ): + errors.append(f"{label} restart must be empty or a safe .service unit name.") + + if len(set(names)) != len(names): + errors.append("Managed artifact names must be unique.") + if len(set(paths)) != len(paths): + errors.append("Managed artifact target paths must be unique.") + return errors + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument( + "--data-root", + default=PRODUCTION_DATA_ROOT, + help=argparse.SUPPRESS, + ) + return parser.parse_args() + + +def main(): + args = parse_args() + if not normalized_absolute_path(args.data_root): + print("The validation data root itself is unsafe.", file=sys.stderr) + return 2 + + raw_payload = os.environ.get("CLOUD_COMPOSE_VALIDATION_PAYLOAD", "") + encoded_payload = os.environ.get("CLOUD_COMPOSE_VALIDATION_PAYLOAD_B64", "") + if encoded_payload: + try: + raw_payload = base64.b64decode(encoded_payload, validate=True).decode("utf-8") + except (ValueError, UnicodeDecodeError) as exc: + print(f"Could not decode base64 cloud-compose validation payload: {exc}", file=sys.stderr) + return 2 + try: + payload = json.loads(raw_payload) + except json.JSONDecodeError as exc: + print(f"Could not decode cloud-compose validation payload: {exc}", file=sys.stderr) + return 2 + if not isinstance(payload, dict): + print("Cloud-compose validation payload must be an object.", file=sys.stderr) + return 2 + + errors = validate_project_paths(payload.get("projects"), args.data_root) + errors.extend(validate_artifacts(payload.get("artifacts"))) + if errors: + print("Cloud-compose host input validation failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 2 + + print(json.dumps({"changed": False, "comment": "Cloud-compose host inputs are valid."})) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/salt/cloud-compose/init.sls b/salt/cloud-compose/init.sls index 2fbd435..435ad33 100644 --- a/salt/cloud-compose/init.sls +++ b/salt/cloud-compose/init.sls @@ -1,5 +1,12 @@ {% import_json "templates/apps.json" as app_registry %} -{% set cc = salt['pillar.get']('cloud_compose', {}) %} +{% set invalid_runtime_inputs = [] %} +{% set raw_cc = salt['pillar.get']('cloud_compose', {}) %} +{% if raw_cc is mapping %} +{% set cc = raw_cc %} +{% else %} +{% set cc = {} %} +{% set ignored = invalid_runtime_inputs.append('cloud_compose must be a map') %} +{% endif %} {% set name = cc.get('name', 'cloud-compose') %} {% set provider = cc.get('provider', 'onprem') %} {% set user = 'cloud-compose' %} @@ -7,18 +14,131 @@ {% set home = '/home/cloud-compose' %} {% set data_dir = '/mnt/disks/data' %} {% set volumes_dir = '/mnt/disks/volumes' %} -{% set runtime = cc.get('runtime', {}) %} -{% set compose = runtime.get('compose', {}) %} -{% set sitectl = runtime.get('sitectl', {}) %} -{% set docker = runtime.get('docker', {}) %} -{% set managed = runtime.get('managed_runtime', {}) %} -{% set vault = runtime.get('vault', {}) %} +{% set raw_runtime = cc.get('runtime', {}) %} +{% if raw_runtime is mapping %} +{% set runtime = raw_runtime %} +{% else %} +{% set runtime = {} %} +{% set ignored = invalid_runtime_inputs.append('runtime must be a map') %} +{% endif %} +{% set runtime_sections = { + 'compose': runtime.get('compose', {}), + 'sitectl': runtime.get('sitectl', {}), + 'docker': runtime.get('docker', {}), + 'managed_runtime': runtime.get('managed_runtime', {}), + 'vault': runtime.get('vault', {}) +} %} +{% for section_name, section_value in runtime_sections.items() %} +{% if section_value is not mapping %} +{% set ignored = invalid_runtime_inputs.append('runtime.' ~ section_name ~ ' must be a map') %} +{% endif %} +{% endfor %} +{% set compose = runtime_sections.compose if runtime_sections.compose is mapping else {} %} +{% set sitectl = runtime_sections.sitectl if runtime_sections.sitectl is mapping else {} %} +{% set docker = runtime_sections.docker if runtime_sections.docker is mapping else {} %} +{% set managed = runtime_sections.managed_runtime if runtime_sections.managed_runtime is mapping else {} %} +{% set vault = runtime_sections.vault if runtime_sections.vault is mapping else {} %} +{% set raw_extra_env = runtime.get('extra_env', cc.get('extra_env', {})) %} +{% if raw_extra_env is mapping %} +{% set extra_env = raw_extra_env %} +{% else %} +{% set extra_env = {} %} +{% set ignored = invalid_runtime_inputs.append('extra_env must be a map') %} +{% endif %} +{% set env_name_first = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_' %} +{% set env_name_rest = env_name_first ~ '0123456789' %} +{% set reserved_env_names = [ + 'HOME', 'PATH', + 'CLOUD_COMPOSE_PROVIDER', 'CLOUD_COMPOSE_INSTANCE_NAME', 'CLOUD_COMPOSE_APPS', 'CLOUD_COMPOSE_PRIMARY_APP', + 'COMPOSE_PROJECTS_FILE', 'COMPOSE_PROJECT_NAME', 'COMPOSE_BIND_PORT', 'COMPOSE_PROFILES', + 'DOCKER_COMPOSE_DIR', 'DOCKER_COMPOSE_REPO', 'DOCKER_COMPOSE_BRANCH', 'DOCKER_COMPOSE_VERSION', 'DOCKER_BUILDX_VERSION', + 'GCP_PROJECT', 'GCP_PROJECT_NUMBER', 'GCP_INSTANCE_NAME', 'GCP_REGION', 'GCP_ZONE', 'GCP_PUBLIC_IP', 'GCP_PRIVATE_IP', 'GCP_APP_SERVICE_ACCOUNT_EMAIL', + 'SITECTL_PACKAGES', 'SITECTL_VERSION', 'SITECTL_PACKAGE_VERSIONS', 'SITECTL_CONTEXT_NAME', 'SITECTL_PLUGIN', 'SITECTL_ENVIRONMENT', 'SITECTL_VERIFY_ARGS', + 'POWER_MANAGEMENT_ENABLED', + 'VAULT_ADDR', 'VAULT_NAMESPACE', 'VAULT_ROLE', 'VAULT_AGENT_ENABLED', 'VAULT_AUTH_METHOD', 'VAULT_AGENT_TOKEN_PATH', + 'LIBOPS_MANAGED_RUNTIME_ENABLED', 'LIBOPS_INTERNAL_SERVICES_ENABLED', 'LIBOPS_INTERNAL_SERVICES_AUTO_UPDATE', 'INTERNAL_SERVICES_COMPOSE_PROFILES' +] %} +{% set reserved_env_prefixes = [ + 'CLOUD_COMPOSE_', 'COMPOSE_', 'DOCKER_', 'SITECTL_', 'LIBOPS_', + 'GCP_', 'VAULT_', 'ROLLOUT_', 'POWER_MANAGEMENT_' +] %} +{% set safe_extra_env = {} %} +{% for env_name, env_value in extra_env.items() %} +{% set env_name_check = namespace(valid=(env_name is string and env_name | length > 0 and env_name[0] in env_name_first)) %} +{% set env_reserved_check = namespace(reserved=(env_name in reserved_env_names)) %} +{% if env_name is string %} +{% for character in env_name %} +{% if character not in env_name_rest %} +{% set env_name_check.valid = False %} +{% endif %} +{% endfor %} +{% for reserved_prefix in reserved_env_prefixes %} +{% if env_name.startswith(reserved_prefix) %} +{% set env_reserved_check.reserved = True %} +{% endif %} +{% endfor %} +{% endif %} +{% if not env_name_check.valid %} +{% set ignored = invalid_runtime_inputs.append('extra_env name must match ^[A-Za-z_][A-Za-z0-9_]*$: ' ~ (env_name | string)) %} +{% elif env_reserved_check.reserved %} +{% set ignored = invalid_runtime_inputs.append('extra_env cannot replace reserved host control: ' ~ env_name) %} +{% elif env_value is not string %} +{% set ignored = invalid_runtime_inputs.append('extra_env value must be a string: ' ~ env_name) %} +{% else %} +{% set ignored = safe_extra_env.update({env_name: env_value}) %} +{% endif %} +{% endfor %} {% set install_packages = cc.get('install_packages', True) %} {% set reload_systemd = cc.get('reload_systemd', True) %} -{% set template_name = cc.get('template', '') | lower | trim %} +{% set run_bootstrap = cc.get('run_bootstrap', True) %} +{% set force_bootstrap = cc.get('force_bootstrap', False) %} +{% set raw_template_name = cc.get('template', '') %} +{% if raw_template_name is string %} +{% set template_name = raw_template_name | lower | trim %} +{% else %} +{% set template_name = '' %} +{% set ignored = invalid_runtime_inputs.append('template must be a string') %} +{% endif %} {% set template = app_registry.default %} {% if template_name and template_name in app_registry.templates %} {% set template = app_registry.templates[template_name] %} +{% elif template_name %} +{% set ignored = invalid_runtime_inputs.append('template must name a supported cloud-compose app: ' ~ template_name) %} +{% endif %} +{% set template_sitectl_package_versions = template.get('package_versions', {}) %} +{% if provider != 'onprem' %} +{% set ignored = invalid_runtime_inputs.append('the Salt adapter requires provider=onprem') %} +{% endif %} +{% if name is not string or not (name is match('^[a-z][a-z0-9-]*$')) %} +{% set ignored = invalid_runtime_inputs.append('name must match ^[a-z][a-z0-9-]*$') %} +{% endif %} +{% if cc.get('dedicated_host_acknowledged', False) is not sameas true %} +{% set ignored = invalid_runtime_inputs.append('dedicated_host_acknowledged=true is required before Salt can own Docker and host runtime configuration') %} +{% endif %} +{% if vault.get('agent_enabled', False) %} +{% set ignored = invalid_runtime_inputs.append('Vault Agent is currently supported only by Terraform providers; set vault.agent_enabled=false for Salt') %} +{% endif %} +{% set internal_services_enabled = managed.get('internal_services_enabled') if 'internal_services_enabled' in managed else cc.get('internal_services_enabled', False) %} +{% set managed_runtime_enabled = managed.get('enabled', cc.get('managed_runtime_enabled', True)) %} +{% set internal_services_auto_update = managed.get('internal_services_auto_update', cc.get('internal_services_auto_update', False)) %} +{% set boolean_settings = { + 'install_packages': install_packages, + 'reload_systemd': reload_systemd, + 'run_bootstrap': run_bootstrap, + 'force_bootstrap': force_bootstrap, + 'managed_runtime.enabled': managed_runtime_enabled, + 'managed_runtime.internal_services_auto_update': internal_services_auto_update, + 'vault.agent_enabled': vault.get('agent_enabled', False) +} %} +{% for setting_name, setting_value in boolean_settings.items() %} +{% if setting_value is not boolean %} +{% set ignored = invalid_runtime_inputs.append(setting_name ~ ' must be a boolean') %} +{% endif %} +{% endfor %} +{% if internal_services_enabled is not boolean %} +{% set ignored = invalid_runtime_inputs.append('managed_runtime.internal_services_enabled must be a boolean') %} +{% elif internal_services_enabled %} +{% set ignored = invalid_runtime_inputs.append('the privileged internal-services stack is GCP-specific and is not supported by the on-prem Salt adapter; set managed_runtime.internal_services_enabled=false') %} {% endif %} {% set default_ingress = { 'letsencrypt': False, @@ -42,38 +162,165 @@ 'sitectl compose --context "${SITECTL_CONTEXT_NAME}" down' ] %} {% set default_rollout = [ - 'TARGET_REF="${GIT_REF:-${GIT_BRANCH:-${DOCKER_COMPOSE_BRANCH:-main}}}"', - 'if [ -x ./scripts/rollout.sh ]; then ./scripts/rollout.sh; else sitectl deploy --context "${SITECTL_CONTEXT_NAME}" --branch "$TARGET_REF"; fi', + 'TARGET_REF="${GIT_REF:-${GIT_BRANCH:-}}"', + 'if [ -n "$TARGET_REF" ]; then sitectl deploy --context "${SITECTL_CONTEXT_NAME}" --ref "$TARGET_REF"; else sitectl deploy --context "${SITECTL_CONTEXT_NAME}" --skip-git; fi', 'sitectl healthcheck --context "${SITECTL_CONTEXT_NAME}" --persist', 'if [ "${SITECTL_ENVIRONMENT}" != "production" ]; then sitectl verify --context "${SITECTL_CONTEXT_NAME}" ${SITECTL_VERIFY_ARGS:-}; fi' ] %} +{% set lifecycle_defaults = { + 'init': default_init, + 'up': default_up, + 'down': default_down, + 'rollout': default_rollout +} %} +{% set lifecycle_commands = {} %} +{% for lifecycle, default_commands in lifecycle_defaults.items() %} +{% set configured_commands = compose.get(lifecycle) %} +{% set resolved_commands = configured_commands if lifecycle in compose else default_commands %} +{% if resolved_commands is string or resolved_commands is mapping or resolved_commands is not sequence %} +{% set ignored = invalid_runtime_inputs.append('compose.' ~ lifecycle ~ ' must be a list of strings') %} +{% set resolved_commands = [] %} +{% else %} +{% for command in resolved_commands %} +{% if command is not string %} +{% set ignored = invalid_runtime_inputs.append('compose.' ~ lifecycle ~ ' must be a list of strings') %} +{% endif %} +{% endfor %} +{% endif %} +{% set ignored = lifecycle_commands.update({lifecycle: resolved_commands}) %} +{% endfor %} {% set repo = compose.get('repo') or template.repo %} {% set branch = compose.get('branch') or template.branch %} +{% if repo is not string %} +{% set ignored = invalid_runtime_inputs.append('compose.repo must be a string') %} +{% set repo = '' %} +{% endif %} +{% if branch is not string %} +{% set ignored = invalid_runtime_inputs.append('compose.branch must be a string') %} +{% set branch = 'main' %} +{% endif %} {% set ingress_port = compose.get('ingress_port', 80) %} +{% if ingress_port is boolean or ingress_port is not number or ingress_port < 1 or ingress_port > 65535 or ingress_port != (ingress_port | int) %} +{% set ignored = invalid_runtime_inputs.append('compose.ingress_port must be a whole number between 1 and 65535') %} +{% set ingress_port = 80 %} +{% endif %} +{% set raw_ingress = compose.get('ingress', {}) %} +{% if raw_ingress is mapping %} +{% set ingress_overrides = raw_ingress %} +{% else %} +{% set ingress_overrides = {} %} +{% set ignored = invalid_runtime_inputs.append('compose.ingress must be a map') %} +{% endif %} {% set ingress = default_ingress.copy() %} -{% set ignored = ingress.update(compose.get('ingress', {})) %} -{% set sitectl_packages = sitectl.get('packages') or template.packages %} +{% set ignored = ingress.update(ingress_overrides) %} +{% set raw_sitectl_packages = sitectl.get('packages') if 'packages' in sitectl else template.packages %} +{% if raw_sitectl_packages is string or raw_sitectl_packages is mapping or raw_sitectl_packages is not sequence %} +{% set ignored = invalid_runtime_inputs.append('sitectl.packages must be a list') %} +{% set sitectl_packages = template.packages %} +{% else %} +{% set sitectl_packages = raw_sitectl_packages %} +{% endif %} {% if 'sitectl' not in sitectl_packages %} {% set sitectl_packages = ['sitectl'] + sitectl_packages %} {% endif %} +{% set sitectl_version = sitectl.get('version', cc.get('sitectl_version', 'latest')) %} +{% set raw_sitectl_package_version_overrides = sitectl.get('package_versions', cc.get('sitectl_package_versions', {})) %} +{% set invalid_sitectl_version_inputs = [] %} +{% if raw_sitectl_package_version_overrides is mapping %} +{% set sitectl_package_version_overrides = raw_sitectl_package_version_overrides %} +{% else %} +{% set sitectl_package_version_overrides = {} %} +{% set ignored = invalid_sitectl_version_inputs.append('sitectl.package_versions must be a map') %} +{% endif %} +{% if sitectl_version is not string or not (sitectl_version == 'latest' or sitectl_version is match('^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$')) %} +{% set ignored = invalid_sitectl_version_inputs.append('sitectl.version must be latest or an exact semantic-version release tag') %} +{% endif %} +{% for package, version in sitectl_package_version_overrides.items() %} +{% if package is not string or not (package is match('^sitectl(-[a-z0-9]+)*$')) %} +{% set ignored = invalid_sitectl_version_inputs.append('invalid package_versions key: ' ~ (package | string)) %} +{% endif %} +{% if version is not string or not (version == 'latest' or version is match('^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$')) %} +{% set ignored = invalid_sitectl_version_inputs.append('invalid package_versions value for ' ~ (package | string)) %} +{% endif %} +{% endfor %} {% set repo_path_source = repo | replace('https://github.com/', '') | replace('http://github.com/', '') | replace('git@github.com:', '') %} {% set repo_path = compose.get('repo_path') or (repo_path_source.strip('/') if repo_path_source else name) %} {% set project_dir = compose.get('project_dir') or data_dir ~ '/' ~ repo_path ~ '/' ~ branch %} {% set compose_project_name = compose.get('compose_project_name') or ((repo_path ~ '-' ~ branch) | lower | replace('.git', '') | replace('/', '-') | replace('_', '-')) %} -{% set explicit_projects = compose.get('projects', {}) %} +{% set raw_explicit_projects = compose.get('projects', {}) %} +{% if raw_explicit_projects is mapping %} +{% set explicit_projects = raw_explicit_projects %} +{% else %} +{% set explicit_projects = {} %} +{% set ignored = invalid_runtime_inputs.append('compose.projects must be a map') %} +{% endif %} {% set normalized_projects = {} %} {% if explicit_projects %} {% for app_name, app in explicit_projects.items() %} -{% set app_repo = app.get('docker_compose_repo') or app.get('repo') or repo %} +{% set app_name_valid = app_name is string and app_name is match('^[a-z][a-z0-9-]*$') %} +{% if not app_name_valid %} +{% set ignored = invalid_runtime_inputs.append('compose.projects key must match ^[a-z][a-z0-9-]*$: ' ~ (app_name | string)) %} +{% endif %} +{% if app is not mapping %} +{% set ignored = invalid_runtime_inputs.append('compose.projects entry must be a map: ' ~ (app_name | string)) %} +{% elif app_name_valid %} +{% set app_repo = app.get('docker_compose_repo') or app.get('repo') or '' %} {% set app_branch = app.get('docker_compose_branch') or app.get('branch') or branch %} +{% if app_repo is not string or not (app_repo | trim) %} +{% set ignored = invalid_runtime_inputs.append('docker_compose_repo is required and must be a string for project: ' ~ (app_name | string)) %} +{% set app_repo = '' %} +{% endif %} +{% if app_branch is not string %} +{% set ignored = invalid_runtime_inputs.append('docker_compose_branch must be a string for project: ' ~ (app_name | string)) %} +{% set app_branch = branch %} +{% endif %} {% set app_repo_path_source = app_repo | replace('https://github.com/', '') | replace('http://github.com/', '') | replace('git@github.com:', '') %} {% set app_repo_path = app.get('repo_path') or (app_repo_path_source.strip('/') if app_repo_path_source else app_name) %} +{% set raw_app_ingress = app.get('ingress', {}) %} +{% if raw_app_ingress is mapping %} +{% set app_ingress_overrides = raw_app_ingress %} +{% else %} +{% set app_ingress_overrides = {} %} +{% set ignored = invalid_runtime_inputs.append('ingress must be a map for project: ' ~ app_name) %} +{% endif %} {% set app_ingress = ingress.copy() %} -{% set ignored = app_ingress.update(app.get('ingress', {})) %} +{% set ignored = app_ingress.update(app_ingress_overrides) %} {% set app_packages = app.get('sitectl_packages', sitectl_packages) %} +{% if app_packages is string or app_packages is mapping or app_packages is not sequence %} +{% set ignored = invalid_runtime_inputs.append('sitectl_packages must be a list for project: ' ~ (app_name | string)) %} +{% set app_packages = sitectl_packages %} +{% endif %} {% if 'sitectl' not in app_packages %} {% set app_packages = ['sitectl'] + app_packages %} {% endif %} +{% set app_ingress_port = app.get('ingress_port', ingress_port) %} +{% if app_ingress_port is boolean or app_ingress_port is not number or app_ingress_port < 1 or app_ingress_port > 65535 or app_ingress_port != (app_ingress_port | int) %} +{% set ignored = invalid_runtime_inputs.append('ingress_port must be a whole number between 1 and 65535 for project: ' ~ (app_name | string)) %} +{% set app_ingress_port = ingress_port %} +{% endif %} +{% set app_lifecycle_commands = {} %} +{% for lifecycle in ['init', 'up', 'down', 'rollout'] %} +{% set legacy_key = lifecycle ~ '_commands' %} +{% set docker_key = 'docker_compose_' ~ lifecycle %} +{% if legacy_key in app %} +{% set resolved_commands = app.get(legacy_key) %} +{% elif docker_key in app %} +{% set resolved_commands = app.get(docker_key) %} +{% else %} +{% set resolved_commands = lifecycle_commands.get(lifecycle, []) %} +{% endif %} +{% if resolved_commands is string or resolved_commands is mapping or resolved_commands is not sequence %} +{% set ignored = invalid_runtime_inputs.append(legacy_key ~ ' or ' ~ docker_key ~ ' must be a list of strings for project: ' ~ (app_name | string)) %} +{% set resolved_commands = [] %} +{% else %} +{% for command in resolved_commands %} +{% if command is not string %} +{% set ignored = invalid_runtime_inputs.append(legacy_key ~ ' or ' ~ docker_key ~ ' must be a list of strings for project: ' ~ (app_name | string)) %} +{% endif %} +{% endfor %} +{% endif %} +{% set ignored = app_lifecycle_commands.update({lifecycle: resolved_commands}) %} +{% endfor %} {% set app_project = { 'name': app_name, 'docker_compose_repo': app_repo, @@ -81,19 +328,20 @@ 'repo_path': app_repo_path, 'project_dir': app.get('project_dir') or data_dir ~ '/' ~ app_repo_path ~ '/' ~ app_branch, 'compose_project_name': app.get('compose_project_name') or ((app_repo_path ~ '-' ~ app_branch) | lower | replace('.git', '') | replace('/', '-') | replace('_', '-')), - 'ingress_port': app.get('ingress_port', ingress_port), + 'ingress_port': app_ingress_port, 'ingress': app_ingress, 'sitectl_context_name': app.get('sitectl_context_name', app_name), 'sitectl_plugin': app.get('sitectl_plugin', sitectl.get('plugin', template.plugin)), 'sitectl_environment': app.get('sitectl_environment', sitectl.get('environment', 'production')), 'sitectl_packages': app_packages, 'sitectl_verify_args': app.get('sitectl_verify_args', sitectl.get('verify_args', [])), - 'init_commands': app.get('init_commands') or app.get('docker_compose_init') or compose.get('init') or default_init, - 'up_commands': app.get('up_commands') or app.get('docker_compose_up') or compose.get('up') or default_up, - 'down_commands': app.get('down_commands') or app.get('docker_compose_down') or compose.get('down') or default_down, - 'rollout_commands': app.get('rollout_commands') or app.get('docker_compose_rollout') or compose.get('rollout') or default_rollout + 'init_commands': app_lifecycle_commands.get('init', []), + 'up_commands': app_lifecycle_commands.get('up', []), + 'down_commands': app_lifecycle_commands.get('down', []), + 'rollout_commands': app_lifecycle_commands.get('rollout', []) } %} {% set ignored = normalized_projects.update({app_name: app_project}) %} +{% endif %} {% endfor %} {% else %} {% set single_project = { @@ -110,17 +358,26 @@ 'sitectl_environment': sitectl.get('environment', 'production'), 'sitectl_packages': sitectl_packages, 'sitectl_verify_args': sitectl.get('verify_args', []), - 'init_commands': compose.get('init') or default_init, - 'up_commands': compose.get('up') or default_up, - 'down_commands': compose.get('down') or default_down, - 'rollout_commands': compose.get('rollout') or default_rollout + 'init_commands': lifecycle_commands.get('init', []), + 'up_commands': lifecycle_commands.get('up', []), + 'down_commands': lifecycle_commands.get('down', []), + 'rollout_commands': lifecycle_commands.get('rollout', []) } %} {% set ignored = normalized_projects.update({name: single_project}) %} {% endif %} {% set compose_projects = normalized_projects %} -{% set primary_key = compose.get('primary') or (compose_projects.keys() | list | first) %} +{% set raw_primary_key = compose.get('primary', '') %} +{% if raw_primary_key is not string %} +{% set ignored = invalid_runtime_inputs.append('compose.primary must be a string') %} +{% set raw_primary_key = '' %} +{% endif %} +{% set project_keys = compose_projects.keys() | list | sort %} +{% set primary_key = raw_primary_key or ((project_keys | first) if project_keys else '') %} +{% if not primary_key or primary_key not in compose_projects %} +{% set ignored = invalid_runtime_inputs.append('compose.primary must match a compose.projects key') %} +{% endif %} {% set primary_project = compose_projects.get(primary_key, {}) %} -{% set all_packages = [] %} +{% set all_packages = sitectl_packages | list %} {% for project in compose_projects.values() %} {% for package in project.get('sitectl_packages', []) %} {% if package not in all_packages %} @@ -131,6 +388,20 @@ {% if 'sitectl' not in all_packages %} {% set ignored = all_packages.insert(0, 'sitectl') %} {% endif %} +{% for package in all_packages %} +{% if package is not string or not (package is match('^sitectl(-[a-z0-9]+)*$')) %} +{% set ignored = invalid_sitectl_version_inputs.append('invalid installed package: ' ~ (package | string)) %} +{% endif %} +{% endfor %} +{% for package in sitectl_package_version_overrides.keys() %} +{% if package not in all_packages %} +{% set ignored = invalid_sitectl_version_inputs.append('package_versions selects an uninstalled package: ' ~ package) %} +{% endif %} +{% endfor %} +{% set sitectl_package_versions = {} %} +{% for package in all_packages %} +{% set ignored = sitectl_package_versions.update({package: sitectl_package_version_overrides.get(package, template_sitectl_package_versions.get(package, sitectl_version))}) %} +{% endfor %} {% set vault_addr = vault.get('addr', '') %} {% set env = { 'HOME': home, @@ -144,7 +415,7 @@ 'DOCKER_COMPOSE_DIR': primary_project.get('project_dir', project_dir), 'DOCKER_COMPOSE_REPO': primary_project.get('docker_compose_repo', repo), 'DOCKER_COMPOSE_BRANCH': primary_project.get('docker_compose_branch', branch), - 'DOCKER_COMPOSE_VERSION': docker.get('compose_version', cc.get('docker_compose_version', 'v5.3.0')), + 'DOCKER_COMPOSE_VERSION': docker.get('compose_version', cc.get('docker_compose_version', 'v5.3.1')), 'DOCKER_BUILDX_VERSION': docker.get('buildx_version', cc.get('docker_buildx_version', 'v0.35.0')), 'GCP_PROJECT': '', 'GCP_PROJECT_NUMBER': '', @@ -153,7 +424,8 @@ 'GCP_ZONE': '', 'GCP_APP_SERVICE_ACCOUNT_EMAIL': '', 'SITECTL_PACKAGES': all_packages | join(' '), - 'SITECTL_VERSION': sitectl.get('version', cc.get('sitectl_version', 'latest')), + 'SITECTL_VERSION': sitectl_version, + 'SITECTL_PACKAGE_VERSIONS': sitectl_package_versions | tojson, 'SITECTL_CONTEXT_NAME': primary_project.get('sitectl_context_name', name), 'SITECTL_PLUGIN': primary_project.get('sitectl_plugin', sitectl.get('plugin', template.plugin)), 'SITECTL_ENVIRONMENT': primary_project.get('sitectl_environment', sitectl.get('environment', 'production')), @@ -166,18 +438,63 @@ 'VAULT_AGENT_ENABLED': 'true' if vault.get('agent_enabled', False) and vault_addr else 'false', 'VAULT_AUTH_METHOD': vault.get('auth_method', 'consumer-managed'), 'VAULT_AGENT_TOKEN_PATH': vault.get('agent_token_path', '/mnt/disks/data/vault/token'), - 'LIBOPS_MANAGED_RUNTIME_ENABLED': 'true' if managed.get('enabled', cc.get('managed_runtime_enabled', True)) else 'false', - 'LIBOPS_INTERNAL_SERVICES_ENABLED': 'true' if managed.get('internal_services_enabled', cc.get('internal_services_enabled', False)) else 'false', - 'LIBOPS_INTERNAL_SERVICES_AUTO_UPDATE': 'true' if managed.get('internal_services_auto_update', cc.get('internal_services_auto_update', False)) else 'false', + 'LIBOPS_MANAGED_RUNTIME_ENABLED': 'true' if managed_runtime_enabled else 'false', + 'LIBOPS_INTERNAL_SERVICES_ENABLED': 'true' if internal_services_enabled else 'false', + 'LIBOPS_INTERNAL_SERVICES_AUTO_UPDATE': 'true' if internal_services_auto_update else 'false', 'INTERNAL_SERVICES_COMPOSE_PROFILES': '' } %} -{% set ignored = env.update(cc.get('extra_env', {})) %} {% set managed_artifacts = managed.get('artifacts', cc.get('managed_artifacts', [])) %} +{% set validation_payload = {'projects': compose_projects.values() | list, 'artifacts': managed_artifacts} %} +{% set validation_payload_b64 = salt['hashutil.base64_b64encode'](validation_payload | json) %} +{% set managed_artifacts_json_b64 = salt['hashutil.base64_b64encode'](managed_artifacts | json) %} +{% set runtime_validator_path = salt['cp.cache_file']('salt://cloud-compose/files/validate-runtime-inputs.py') %} +{% if not runtime_validator_path %} +{% set ignored = invalid_runtime_inputs.append('could not cache the cloud-compose host input validator') %} +{% endif %} + +cloud-compose-runtime-inputs-valid: +{% if invalid_runtime_inputs %} + test.fail_without_changes: + - name: {{ ('Invalid cloud_compose runtime settings: ' ~ (invalid_runtime_inputs | join(', '))) | json }} + - failhard: True + - order: 1 +{% else %} + test.nop: + - name: cloud-compose runtime inputs are valid + - order: 1 +{% endif %} + +cloud-compose-sitectl-inputs-valid: +{% if invalid_sitectl_version_inputs %} + test.fail_without_changes: + - name: {{ ('Invalid cloud_compose sitectl settings: ' ~ (invalid_sitectl_version_inputs | join(', '))) | json }} + - failhard: True + - order: 1 +{% else %} + test.nop: + - name: cloud-compose sitectl inputs are valid + - order: 1 +{% endif %} + +cloud-compose-host-inputs-valid: + cmd.run: + - name: /usr/bin/env python3 "$CLOUD_COMPOSE_RUNTIME_VALIDATOR" + - env: + CLOUD_COMPOSE_RUNTIME_VALIDATOR: {{ runtime_validator_path | json }} + CLOUD_COMPOSE_VALIDATION_PAYLOAD_B64: {{ validation_payload_b64 | json }} + - stateful: True + - failhard: True + - order: 2 + - require: + - test: cloud-compose-runtime-inputs-valid + - test: cloud-compose-sitectl-inputs-valid {% if not explicit_projects and not repo %} cloud-compose-requires-repo: test.fail_without_changes: - name: Set cloud_compose.template, cloud_compose.runtime.compose.repo, or cloud_compose.runtime.compose.projects. + - failhard: True + - order: 1 {% endif %} {% if install_packages %} @@ -190,6 +507,9 @@ cloud-compose-packages: - git - jq - make + - openssl + - require: + - cmd: cloud-compose-host-inputs-valid cloud-compose-docker: service.running: @@ -202,10 +522,14 @@ cloud-compose-docker: cloud-compose-docker-group: group.present: - name: docker + - require: + - cmd: cloud-compose-host-inputs-valid cloud-compose-group: group.present: - name: {{ group | json }} + - require: + - cmd: cloud-compose-host-inputs-valid cloud-compose-user: user.present: @@ -236,6 +560,7 @@ cloud-compose-data-dirs: - require: - user: cloud-compose-user +{% if compose_projects %} cloud-compose-project-dirs: file.directory: - names: @@ -248,6 +573,7 @@ cloud-compose-project-dirs: - makedirs: True - require: - user: cloud-compose-user +{% endif %} cloud-compose-rootfs: file.recurse: @@ -258,6 +584,19 @@ cloud-compose-rootfs: - require: - user: cloud-compose-user +cloud-compose-lifecycle-lock: + cmd.run: + - name: systemd-tmpfiles --create /etc/tmpfiles.d/cloud-compose.conf + - unless: >- + test ! -L /run/lock/cloud-compose && + test -d /run/lock/cloud-compose && + test "$(stat -c '%U:%G:%a' /run/lock/cloud-compose)" = root:cloud-compose:750 && + test ! -L /run/lock/cloud-compose/lifecycle.lock && + test -f /run/lock/cloud-compose/lifecycle.lock && + test "$(stat -c '%U:%G:%a' /run/lock/cloud-compose/lifecycle.lock)" = root:cloud-compose:660 + - require: + - file: cloud-compose-rootfs + cloud-compose-rootfs-script-modes: cmd.run: - name: find /home/cloud-compose -type f -name '*.sh' -exec chmod 0755 {} + @@ -269,9 +608,9 @@ cloud-compose-rootfs-script-modes: cloud-compose-lifecycle-{{ lifecycle }}: file.managed: - name: {{ (home ~ '/' ~ lifecycle) | json }} - - user: {{ user | json }} + - user: root - group: {{ group | json }} - - mode: '0755' + - mode: '0750' - contents: | #!/usr/bin/env bash @@ -288,12 +627,29 @@ cloud-compose-env: - name: {{ (home ~ '/.env') | json }} - source: salt://cloud-compose/files/env.jinja - template: jinja - - user: {{ user | json }} + - user: root - group: {{ group | json }} - mode: '0640' + - show_changes: False - context: env: {{ env | json }} - require: + - test: cloud-compose-runtime-inputs-valid + - file: cloud-compose-rootfs + +cloud-compose-application-env: + file.managed: + - name: {{ (home ~ '/application-env.json') | json }} + - source: salt://cloud-compose/files/application-env.json.jinja + - template: jinja + - user: root + - group: {{ group | json }} + - mode: '0640' + - show_changes: False + - context: + application_env: {{ safe_extra_env | json }} + - require: + - test: cloud-compose-runtime-inputs-valid - file: cloud-compose-rootfs cloud-compose-project-manifest: @@ -301,7 +657,7 @@ cloud-compose-project-manifest: - name: {{ (home ~ '/compose-projects.json') | json }} - source: salt://cloud-compose/files/compose-projects.json.jinja - template: jinja - - user: {{ user | json }} + - user: root - group: {{ group | json }} - mode: '0640' - context: @@ -314,11 +670,12 @@ cloud-compose-managed-runtime-artifacts: - name: {{ (home ~ '/managed-runtime-artifacts.tsv') | json }} - source: salt://cloud-compose/files/managed-runtime-artifacts.tsv.jinja - template: jinja - - user: {{ user | json }} + - user: root - group: {{ group | json }} - mode: '0640' + - show_changes: False - context: - managed_artifacts: {{ managed_artifacts | json }} + managed_artifacts_json_b64: {{ managed_artifacts_json_b64 | json }} - require: - file: cloud-compose-rootfs @@ -330,13 +687,15 @@ cloud-compose-systemd-reload: - file: cloud-compose-rootfs {% endif %} -{% if cc.get('force_bootstrap', False) %} +{% if force_bootstrap is sameas true %} cloud-compose-clear-bootstrap-marker: file.absent: - name: {{ (home ~ '/.cloud-compose-bootstrap-complete') | json }} + - require: + - cmd: cloud-compose-host-inputs-valid {% endif %} -{% if cc.get('run_bootstrap', True) %} +{% if run_bootstrap is sameas true %} cloud-compose-bootstrap: cmd.run: - name: bash {{ (home ~ '/run.sh') | json }} @@ -346,7 +705,10 @@ cloud-compose-bootstrap: - service: cloud-compose-docker {% endif %} - file: cloud-compose-env + - file: cloud-compose-application-env - file: cloud-compose-project-manifest - file: cloud-compose-managed-runtime-artifacts +{% if compose_projects %} - file: cloud-compose-project-dirs {% endif %} +{% endif %} diff --git a/salt/pillar.example/cloud-compose/drupal-prod.sls b/salt/pillar.example/cloud-compose/drupal-prod.sls index f0b213c..5daea1a 100644 --- a/salt/pillar.example/cloud-compose/drupal-prod.sls +++ b/salt/pillar.example/cloud-compose/drupal-prod.sls @@ -2,6 +2,7 @@ cloud_compose: name: drupal-prod provider: onprem template: drupal + dedicated_host_acknowledged: true runtime: compose: ingress: diff --git a/salt/pillar.example/cloud-compose/isle-prod.sls b/salt/pillar.example/cloud-compose/isle-prod.sls index dc413ea..62737a0 100644 --- a/salt/pillar.example/cloud-compose/isle-prod.sls +++ b/salt/pillar.example/cloud-compose/isle-prod.sls @@ -2,6 +2,7 @@ cloud_compose: name: isle-prod provider: onprem template: isle + dedicated_host_acknowledged: true runtime: compose: ingress: diff --git a/salt/pillar.example/cloud-compose/wp-prod.sls b/salt/pillar.example/cloud-compose/wp-prod.sls index 0efcd98..180f49e 100644 --- a/salt/pillar.example/cloud-compose/wp-prod.sls +++ b/salt/pillar.example/cloud-compose/wp-prod.sls @@ -2,6 +2,7 @@ cloud_compose: name: wp-prod provider: onprem template: wp + dedicated_host_acknowledged: true runtime: compose: ingress: diff --git a/template_versions.tftest.hcl b/template_versions.tftest.hcl new file mode 100644 index 0000000..d7b3376 --- /dev/null +++ b/template_versions.tftest.hcl @@ -0,0 +1,115 @@ +mock_provider "cloudinit" {} +mock_provider "digitalocean" {} +mock_provider "google" { + mock_data "google_project" { + defaults = { + number = "123456789" + } + } +} +mock_provider "linode" {} +mock_provider "time" {} + +run "template_uses_released_package_set" { + command = plan + + variables { + name = "template-versions" + cloud_provider = "digitalocean" + template = "isle" + runtime = { + rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz" + rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + + assert { + condition = local.sitectl.package_versions == { + sitectl = "v0.39.0" + sitectl-drupal = "v0.11.0" + sitectl-isle = "v0.18.0" + } + error_message = "The Isle template must select its reviewed core and plugin release set by default." + } +} + +run "explicit_package_versions_override_template_defaults" { + command = plan + + variables { + name = "template-versions" + cloud_provider = "digitalocean" + template = "isle" + runtime = { + rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz" + rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + sitectl = { + package_versions = { + sitectl = "v0.40.0" + sitectl-isle = "v0.19.0" + } + } + } + } + + assert { + condition = local.sitectl.package_versions == { + sitectl = "v0.40.0" + sitectl-drupal = "v0.11.0" + sitectl-isle = "v0.19.0" + } + error_message = "Explicit per-package selectors must override only their matching template defaults." + } +} + +run "custom_package_set_filters_template_versions" { + command = plan + + variables { + name = "template-versions" + cloud_provider = "digitalocean" + template = "isle" + runtime = { + rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz" + rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + sitectl = { + packages = ["sitectl", "sitectl-wp"] + package_versions = { + sitectl-wp = "v0.5.1" + } + } + } + } + + assert { + condition = local.sitectl.package_versions == { + sitectl = "v0.39.0" + sitectl-wp = "v0.5.1" + } + error_message = "Template selectors for packages omitted by a custom package set must not reach the runtime." + } +} + +run "explicit_core_only_package_set_disables_template_plugins" { + command = plan + + variables { + name = "template-versions" + cloud_provider = "digitalocean" + template = "isle" + runtime = { + rootfs_archive_url = "https://example.invalid/cloud-compose.tar.gz" + rootfs_archive_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + sitectl = { + packages = ["sitectl"] + } + } + } + + assert { + condition = local.sitectl.packages == tolist(["sitectl"]) && local.sitectl.package_versions == { + sitectl = "v0.39.0" + } + error_message = "An explicit core-only package set must not be mistaken for an omitted template package selection." + } +} diff --git a/templates/apps.json b/templates/apps.json index 15db409..788d965 100644 --- a/templates/apps.json +++ b/templates/apps.json @@ -3,50 +3,82 @@ "repo": "", "branch": "main", "plugin": "core", - "packages": ["sitectl"] + "packages": ["sitectl"], + "package_versions": { + "sitectl": "v0.39.0" + } }, "templates": { "archivesspace": { "repo": "https://github.com/libops/archivesspace.git", "branch": "main", "plugin": "archivesspace", - "packages": ["sitectl", "sitectl-archivesspace"] + "packages": ["sitectl", "sitectl-archivesspace"], + "package_versions": { + "sitectl": "v0.39.0", + "sitectl-archivesspace": "v0.6.0" + } }, "ojs": { "repo": "https://github.com/libops/ojs.git", "branch": "main", "plugin": "ojs", - "packages": ["sitectl", "sitectl-ojs"] + "packages": ["sitectl", "sitectl-ojs"], + "package_versions": { + "sitectl": "v0.39.0", + "sitectl-ojs": "v0.6.0" + } }, "isle": { "repo": "https://github.com/libops/isle", "branch": "main", "plugin": "isle", - "packages": ["sitectl", "sitectl-drupal", "sitectl-isle"] + "packages": ["sitectl", "sitectl-drupal", "sitectl-isle"], + "package_versions": { + "sitectl": "v0.39.0", + "sitectl-drupal": "v0.11.0", + "sitectl-isle": "v0.18.0" + } }, "drupal": { "repo": "https://github.com/libops/drupal.git", "branch": "main", "plugin": "drupal", - "packages": ["sitectl", "sitectl-drupal"] + "packages": ["sitectl", "sitectl-drupal"], + "package_versions": { + "sitectl": "v0.39.0", + "sitectl-drupal": "v0.11.0" + } }, "wp": { "repo": "https://github.com/libops/wp.git", "branch": "main", "plugin": "wp", - "packages": ["sitectl", "sitectl-wp"] + "packages": ["sitectl", "sitectl-wp"], + "package_versions": { + "sitectl": "v0.39.0", + "sitectl-wp": "v0.5.0" + } }, "omeka-s": { "repo": "https://github.com/libops/omeka-s.git", "branch": "main", "plugin": "omeka-s", - "packages": ["sitectl", "sitectl-omeka-s"] + "packages": ["sitectl", "sitectl-omeka-s"], + "package_versions": { + "sitectl": "v0.39.0", + "sitectl-omeka-s": "v0.6.0" + } }, "omeka-classic": { "repo": "https://github.com/libops/omeka-classic.git", "branch": "main", "plugin": "omeka-classic", - "packages": ["sitectl", "sitectl-omeka-classic"] + "packages": ["sitectl", "sitectl-omeka-classic"], + "package_versions": { + "sitectl": "v0.39.0", + "sitectl-omeka-classic": "v0.6.0" + } } } } diff --git a/templates/cloud-init.yml b/templates/cloud-init.yml index ea8d173..705b11e 100644 --- a/templates/cloud-init.yml +++ b/templates/cloud-init.yml @@ -13,58 +13,94 @@ users: %{ if length(CLOUD_COMPOSE_SSH_KEYS) > 0 ~} ssh_authorized_keys: %{ for key in CLOUD_COMPOSE_SSH_KEYS ~} - - ${key} + - ${jsonencode(key)} %{ endfor ~} %{ endif ~} %{ for username, ssh_keys in SSH_USERS ~} -- name: ${username} +- name: ${jsonencode(username)} shell: /bin/bash ssh_authorized_keys: %{ for key in ssh_keys ~} - - ${key} + - ${jsonencode(key)} %{ endfor ~} %{ endfor ~} bootcmd: # mount the main Data disk -- fsck.ext4 -tvy $(readlink -f /dev/disk/by-id/google-data) || mkfs.ext4 -m 0 -E lazy_itable_init=1,lazy_journal_init=1,nodiscard $(readlink -f /dev/disk/by-id/google-data) -- mkdir -p /mnt/disks/data -- mount -o defaults $(readlink -f /dev/disk/by-id/google-data) /mnt/disks/data -- chmod a+w /mnt/disks/data +- | + set -eu + rm -f /run/cloud-compose-filesystems-ready + filesystem_prep=/run/cloud-compose-prepare-filesystem + filesystem_persist=/run/cloud-compose-persist-filesystems + printf '%s' '${FILESYSTEM_PREP_SCRIPT_B64}' | base64 -d >"$filesystem_prep" + printf '%s' '${FILESYSTEM_PERSIST_SCRIPT_B64}' | base64 -d >"$filesystem_persist" + chmod 0600 "$filesystem_prep" "$filesystem_persist" + bash "$filesystem_prep" /dev/disk/by-id/google-data /mnt/disks/data # mount the specific Volumes disk -- fsck.ext4 -tvy $(readlink -f /dev/disk/by-id/google-docker-volumes) || mkfs.ext4 -m 0 -E lazy_itable_init=1,lazy_journal_init=1,nodiscard $(readlink -f /dev/disk/by-id/google-docker-volumes) -- mkdir -p /mnt/disks/volumes -- mount -o defaults $(readlink -f /dev/disk/by-id/google-docker-volumes) /mnt/disks/volumes -- chmod a+w /mnt/disks/volumes +- bash /run/cloud-compose-prepare-filesystem /dev/disk/by-id/google-docker-volumes /mnt/disks/volumes - mkdir -p /mnt/disks/data/docker/volumes -- mount --bind /mnt/disks/volumes /mnt/disks/data/docker/volumes - +- | + set -eu + if ! mountpoint -q /mnt/disks/data/docker/volumes; then + mount --bind /mnt/disks/volumes /mnt/disks/data/docker/volumes + fi + for required_mount in /mnt/disks/data /mnt/disks/volumes /mnt/disks/data/docker/volumes; do + if ! mountpoint -q -- "$required_mount"; then + echo "Required cloud-compose mount is unavailable: $required_mount" >&2 + exit 1 + fi + done %{ if USE_OVERLAY ~} -- mkdir -p /mnt/disks/prod-readonly -- mount -o ro $(readlink -f /dev/disk/by-id/google-prod-volumes) /mnt/disks/prod-readonly + mkdir -p /mnt/disks/prod-readonly + if ! mountpoint -q /mnt/disks/prod-readonly; then + mount -o ro "$(readlink -f /dev/disk/by-id/google-prod-volumes)" /mnt/disks/prod-readonly + fi + bash /run/cloud-compose-persist-filesystems \ + /dev/disk/by-id/google-data \ + /dev/disk/by-id/google-docker-volumes \ + /dev/disk/by-id/google-prod-volumes +%{ else ~} + bash /run/cloud-compose-persist-filesystems \ + /dev/disk/by-id/google-data \ + /dev/disk/by-id/google-docker-volumes %{ endif ~} + install -m 0600 /dev/null /run/cloud-compose-filesystems-ready write_files: ${WRITE_FILES_CONTENT} ${DOCKER_COMPOSE_SCRIPTS} ${COMPOSE_PROJECTS_FILE} ${ENV_FILE_CONTENT} +${APPLICATION_ENV_FILE_CONTENT} ${VAULT_AGENT_FILES} ${MANAGED_RUNTIME_ARTIFACTS_FILE} runcmd: +${ROOTFS_ARCHIVE_COMMAND} +- | + set -eu + test -f /run/cloud-compose-filesystems-ready || { + echo "Cloud Compose filesystems were not prepared; refusing application initialization" >&2 + exit 1 + } %{ for CMD in ADDITIONAL_INITCMD ~} -- ${CMD} -%{ endfor ~} -- chown cloud-compose:cloud-compose /mnt/disks/data /mnt/disks/volumes -- chmod 0775 /mnt/disks/data /mnt/disks/volumes -- install -d -m 0775 -o cloud-compose -g cloud-compose /mnt/disks/data/libops -- bash /home/cloud-compose/run.sh > /home/cloud-compose/run.log 2>&1 -%{ for VOLUME in DOCKER_VOLUME_OVERLAYS ~} -- bash /home/cloud-compose/overlay-init.sh "${VOLUME}" >> /home/cloud-compose/run.log 2>&1 + ${indent(2, CMD)} %{ endfor ~} + chown cloud-compose:cloud-compose /mnt/disks/data /mnt/disks/volumes + chmod 0775 /mnt/disks/data /mnt/disks/volumes + install -d -m 0775 -o cloud-compose -g cloud-compose /mnt/disks/data/libops + rm -f /home/cloud-compose/.cloud-compose-bootstrap-complete + bash /home/cloud-compose/run.sh > /home/cloud-compose/run.log 2>&1 +%{ if length(ADDITIONAL_RUNCMD) > 0 ~} +- | + set -eu + test -f /home/cloud-compose/.cloud-compose-bootstrap-complete || { + echo "Cloud Compose application initialization did not complete; refusing post-initialization commands" >&2 + exit 1 + } %{ for CMD in ADDITIONAL_RUNCMD ~} -- ${CMD} + ${indent(2, CMD)} %{ endfor ~} +%{ endif ~} diff --git a/tests/config-management/ansible/invalid-artifacts.yml b/tests/config-management/ansible/invalid-artifacts.yml new file mode 100644 index 0000000..f2ae3a3 --- /dev/null +++ b/tests/config-management/ansible/invalid-artifacts.yml @@ -0,0 +1,87 @@ +- name: Reject every unsafe managed artifact field class + hosts: localhost + gather_facts: true + vars: + cloud_compose_name: invalid-artifacts + cloud_compose_template: wp + cloud_compose_dedicated_host_acknowledged: true + cloud_compose_install_packages: false + cloud_compose_reload_systemd: false + cloud_compose_run_bootstrap: false + cloud_compose_runtime: + managed_runtime: + artifacts: + - name: ../unsafe-name + url: https://example.invalid/unsafe-name + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: /usr/local/bin/unsafe-name + - name: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + url: https://example.invalid/overlong-name + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: /usr/local/bin/overlong-name + - name: unsafe-url + url: http://example.invalid/unsafe-url + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: /usr/local/bin/unsafe-url + - name: unsafe-sha + url: https://example.invalid/unsafe-sha + sha256: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + path: /usr/local/bin/unsafe-sha + - name: root-path + url: https://example.invalid/root-path + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: / + - name: relative-path + url: https://example.invalid/relative-path + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: usr/local/bin/relative-path + - name: dot-path + url: https://example.invalid/dot-path + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: /usr/local/../bin/dot-path + - name: empty-path-segment + url: https://example.invalid/empty-path-segment + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: /usr//local/bin/empty-path-segment + - name: control-path-segment + url: https://example.invalid/control-path-segment + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: "/usr/local/bin/bad\u007fname" + - name: unsafe-mode + url: https://example.invalid/unsafe-mode + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: /usr/local/bin/unsafe-mode + mode: "4755" + - name: unsafe-owner + url: https://example.invalid/unsafe-owner + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: /usr/local/bin/unsafe-owner + owner: root:root + - name: unsafe-group + url: https://example.invalid/unsafe-group + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: /usr/local/bin/unsafe-group + group: root:root + - name: unsafe-restart + url: https://example.invalid/unsafe-restart + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: /usr/local/bin/unsafe-restart + restart: ../docker.service + - name: duplicate-name + url: https://example.invalid/duplicate-name-one + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: /usr/local/bin/duplicate-name-one + - name: duplicate-name + url: https://example.invalid/duplicate-name-two + sha256: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + path: /usr/local/bin/duplicate-name-two + - name: duplicate-path-one + url: https://example.invalid/duplicate-path-one + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: /usr/local/bin/duplicate-path + - name: duplicate-path-two + url: https://example.invalid/duplicate-path-two + sha256: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + path: /usr/local/bin/duplicate-path + roles: + - role: cloud_compose diff --git a/tests/config-management/ansible/invalid-env.yml b/tests/config-management/ansible/invalid-env.yml new file mode 100644 index 0000000..ef74134 --- /dev/null +++ b/tests/config-management/ansible/invalid-env.yml @@ -0,0 +1,14 @@ +- name: Reject unsafe cloud-compose environment names + hosts: localhost + gather_facts: true + vars: + cloud_compose_name: invalid-env + cloud_compose_template: wp + cloud_compose_install_packages: false + cloud_compose_reload_systemd: false + cloud_compose_run_bootstrap: false + cloud_compose_dedicated_host_acknowledged: true + cloud_compose_extra_env: + BAD-NAME: value + roles: + - role: cloud_compose diff --git a/tests/config-management/ansible/invalid-host-ack.yml b/tests/config-management/ansible/invalid-host-ack.yml new file mode 100644 index 0000000..f1e6b3f --- /dev/null +++ b/tests/config-management/ansible/invalid-host-ack.yml @@ -0,0 +1,11 @@ +- name: Require explicit dedicated-host acknowledgement + hosts: localhost + gather_facts: true + vars: + cloud_compose_name: invalid-host-ack + cloud_compose_template: wp + cloud_compose_install_packages: false + cloud_compose_reload_systemd: false + cloud_compose_run_bootstrap: false + roles: + - role: cloud_compose diff --git a/tests/config-management/ansible/invalid-ingress-port.yml b/tests/config-management/ansible/invalid-ingress-port.yml new file mode 100644 index 0000000..d2242d3 --- /dev/null +++ b/tests/config-management/ansible/invalid-ingress-port.yml @@ -0,0 +1,15 @@ +- name: Reject a fractional global cloud-compose ingress port + hosts: localhost + gather_facts: true + vars: + cloud_compose_name: invalid-ingress-port + cloud_compose_template: wp + cloud_compose_dedicated_host_acknowledged: true + cloud_compose_install_packages: false + cloud_compose_reload_systemd: false + cloud_compose_run_bootstrap: false + cloud_compose_runtime: + compose: + ingress_port: 80.5 + roles: + - role: cloud_compose diff --git a/tests/config-management/ansible/invalid-internal-services.yml b/tests/config-management/ansible/invalid-internal-services.yml new file mode 100644 index 0000000..4f8ec4c --- /dev/null +++ b/tests/config-management/ansible/invalid-internal-services.yml @@ -0,0 +1,15 @@ +- name: Reject GCP-only internal services on an on-prem host + hosts: localhost + gather_facts: true + vars: + cloud_compose_name: invalid-internal-services + cloud_compose_template: wp + cloud_compose_dedicated_host_acknowledged: true + cloud_compose_install_packages: false + cloud_compose_reload_systemd: false + cloud_compose_run_bootstrap: false + cloud_compose_runtime: + managed_runtime: + internal_services_enabled: true + roles: + - role: cloud_compose diff --git a/tests/config-management/ansible/invalid-lifecycle.yml b/tests/config-management/ansible/invalid-lifecycle.yml new file mode 100644 index 0000000..787c980 --- /dev/null +++ b/tests/config-management/ansible/invalid-lifecycle.yml @@ -0,0 +1,17 @@ +- name: Reject non-string global lifecycle commands + hosts: localhost + gather_facts: true + vars: + cloud_compose_name: invalid-lifecycle + cloud_compose_template: wp + cloud_compose_dedicated_host_acknowledged: true + cloud_compose_install_packages: false + cloud_compose_reload_systemd: false + cloud_compose_run_bootstrap: false + cloud_compose_runtime: + compose: + init: + - valid-command + - 42 + roles: + - role: cloud_compose diff --git a/tests/config-management/ansible/invalid-package.yml b/tests/config-management/ansible/invalid-package.yml new file mode 100644 index 0000000..729385b --- /dev/null +++ b/tests/config-management/ansible/invalid-package.yml @@ -0,0 +1,17 @@ +- name: Reject unsafe sitectl package names + hosts: localhost + gather_facts: true + vars: + cloud_compose_name: invalid-package + cloud_compose_template: wp + cloud_compose_dedicated_host_acknowledged: true + cloud_compose_install_packages: false + cloud_compose_reload_systemd: false + cloud_compose_run_bootstrap: false + cloud_compose_runtime: + sitectl: + packages: + - sitectl + - bad/package + roles: + - role: cloud_compose diff --git a/tests/config-management/ansible/invalid-primary.yml b/tests/config-management/ansible/invalid-primary.yml new file mode 100644 index 0000000..52cecdd --- /dev/null +++ b/tests/config-management/ansible/invalid-primary.yml @@ -0,0 +1,18 @@ +- name: Reject an unknown primary cloud-compose project + hosts: localhost + gather_facts: true + vars: + cloud_compose_name: invalid-primary + cloud_compose_template: wp + cloud_compose_dedicated_host_acknowledged: true + cloud_compose_install_packages: false + cloud_compose_reload_systemd: false + cloud_compose_run_bootstrap: false + cloud_compose_runtime: + compose: + primary: missing + projects: + wp: + docker_compose_repo: https://github.com/libops/wp.git + roles: + - role: cloud_compose diff --git a/tests/config-management/ansible/invalid-project-dir-etc.yml b/tests/config-management/ansible/invalid-project-dir-etc.yml new file mode 100644 index 0000000..feba93d --- /dev/null +++ b/tests/config-management/ansible/invalid-project-dir-etc.yml @@ -0,0 +1,15 @@ +- name: Reject a system directory as a Compose project directory + hosts: localhost + gather_facts: true + vars: + cloud_compose_name: invalid-project-dir-etc + cloud_compose_template: wp + cloud_compose_dedicated_host_acknowledged: true + cloud_compose_install_packages: false + cloud_compose_reload_systemd: false + cloud_compose_run_bootstrap: false + cloud_compose_runtime: + compose: + project_dir: /etc + roles: + - role: cloud_compose diff --git a/tests/config-management/ansible/invalid-project-dir-root.yml b/tests/config-management/ansible/invalid-project-dir-root.yml new file mode 100644 index 0000000..969ca0b --- /dev/null +++ b/tests/config-management/ansible/invalid-project-dir-root.yml @@ -0,0 +1,15 @@ +- name: Reject the filesystem root as a Compose project directory + hosts: localhost + gather_facts: true + vars: + cloud_compose_name: invalid-project-dir-root + cloud_compose_template: wp + cloud_compose_dedicated_host_acknowledged: true + cloud_compose_install_packages: false + cloud_compose_reload_systemd: false + cloud_compose_run_bootstrap: false + cloud_compose_runtime: + compose: + project_dir: / + roles: + - role: cloud_compose diff --git a/tests/config-management/ansible/invalid-project-dir-symlink.yml b/tests/config-management/ansible/invalid-project-dir-symlink.yml new file mode 100644 index 0000000..3be0be6 --- /dev/null +++ b/tests/config-management/ansible/invalid-project-dir-symlink.yml @@ -0,0 +1,15 @@ +- name: Reject a Compose project directory that escapes through a symlink + hosts: localhost + gather_facts: true + vars: + cloud_compose_name: invalid-project-dir-symlink + cloud_compose_template: wp + cloud_compose_dedicated_host_acknowledged: true + cloud_compose_install_packages: false + cloud_compose_reload_systemd: false + cloud_compose_run_bootstrap: false + cloud_compose_runtime: + compose: + project_dir: /mnt/disks/data/escape/site + roles: + - role: cloud_compose diff --git a/tests/config-management/ansible/invalid-project-dir-traversal.yml b/tests/config-management/ansible/invalid-project-dir-traversal.yml new file mode 100644 index 0000000..f292336 --- /dev/null +++ b/tests/config-management/ansible/invalid-project-dir-traversal.yml @@ -0,0 +1,15 @@ +- name: Reject traversal in a Compose project directory + hosts: localhost + gather_facts: true + vars: + cloud_compose_name: invalid-project-dir-traversal + cloud_compose_template: wp + cloud_compose_dedicated_host_acknowledged: true + cloud_compose_install_packages: false + cloud_compose_reload_systemd: false + cloud_compose_run_bootstrap: false + cloud_compose_runtime: + compose: + project_dir: /mnt/disks/data/../escape + roles: + - role: cloud_compose diff --git a/tests/config-management/ansible/invalid-project-lifecycle.yml b/tests/config-management/ansible/invalid-project-lifecycle.yml new file mode 100644 index 0000000..f327765 --- /dev/null +++ b/tests/config-management/ansible/invalid-project-lifecycle.yml @@ -0,0 +1,17 @@ +- name: Reject a non-list per-project lifecycle value + hosts: localhost + gather_facts: true + vars: + cloud_compose_name: invalid-project-lifecycle + cloud_compose_dedicated_host_acknowledged: true + cloud_compose_install_packages: false + cloud_compose_reload_systemd: false + cloud_compose_run_bootstrap: false + cloud_compose_runtime: + compose: + projects: + wp: + docker_compose_repo: https://github.com/libops/wp.git + docker_compose_up: not-a-list + roles: + - role: cloud_compose diff --git a/tests/config-management/ansible/invalid-project-port.yml b/tests/config-management/ansible/invalid-project-port.yml new file mode 100644 index 0000000..fb643b5 --- /dev/null +++ b/tests/config-management/ansible/invalid-project-port.yml @@ -0,0 +1,17 @@ +- name: Reject cloud-compose projects with invalid ingress ports + hosts: localhost + gather_facts: true + vars: + cloud_compose_name: invalid-project-port + cloud_compose_dedicated_host_acknowledged: true + cloud_compose_install_packages: false + cloud_compose_reload_systemd: false + cloud_compose_run_bootstrap: false + cloud_compose_runtime: + compose: + projects: + wp: + docker_compose_repo: https://github.com/libops/wp.git + ingress_port: 443.5 + roles: + - role: cloud_compose diff --git a/tests/config-management/ansible/invalid-project-repo.yml b/tests/config-management/ansible/invalid-project-repo.yml new file mode 100644 index 0000000..f0820ae --- /dev/null +++ b/tests/config-management/ansible/invalid-project-repo.yml @@ -0,0 +1,16 @@ +- name: Reject cloud-compose projects without repositories + hosts: localhost + gather_facts: true + vars: + cloud_compose_name: invalid-project-repo + cloud_compose_dedicated_host_acknowledged: true + cloud_compose_install_packages: false + cloud_compose_reload_systemd: false + cloud_compose_run_bootstrap: false + cloud_compose_runtime: + compose: + projects: + wp: + ingress_port: 80 + roles: + - role: cloud_compose diff --git a/tests/config-management/ansible/invalid-project.yml b/tests/config-management/ansible/invalid-project.yml new file mode 100644 index 0000000..8f506d3 --- /dev/null +++ b/tests/config-management/ansible/invalid-project.yml @@ -0,0 +1,17 @@ +- name: Reject invalid cloud-compose projects + hosts: localhost + gather_facts: true + vars: + cloud_compose_name: invalid-project + cloud_compose_dedicated_host_acknowledged: true + cloud_compose_install_packages: false + cloud_compose_reload_systemd: false + cloud_compose_run_bootstrap: false + cloud_compose_runtime: + compose: + projects: + "Bad/Name": + docker_compose_repo: https://github.com/libops/wp.git + ingress_port: 80 + roles: + - role: cloud_compose diff --git a/tests/config-management/ansible/invalid-reserved-env.yml b/tests/config-management/ansible/invalid-reserved-env.yml new file mode 100644 index 0000000..4b03da5 --- /dev/null +++ b/tests/config-management/ansible/invalid-reserved-env.yml @@ -0,0 +1,14 @@ +- name: Reject reserved cloud-compose environment names + hosts: localhost + gather_facts: true + vars: + cloud_compose_name: invalid-reserved-env + cloud_compose_template: wp + cloud_compose_dedicated_host_acknowledged: true + cloud_compose_install_packages: false + cloud_compose_reload_systemd: false + cloud_compose_run_bootstrap: false + cloud_compose_extra_env: + LIBOPS_FUTURE_CONTROL: "true" + roles: + - role: cloud_compose diff --git a/tests/config-management/ansible/invalid-sitectl.yml b/tests/config-management/ansible/invalid-sitectl.yml new file mode 100644 index 0000000..ad64715 --- /dev/null +++ b/tests/config-management/ansible/invalid-sitectl.yml @@ -0,0 +1,15 @@ +- name: Reject unsafe sitectl release versions + hosts: localhost + gather_facts: true + vars: + cloud_compose_name: invalid-sitectl + cloud_compose_template: wp + cloud_compose_install_packages: false + cloud_compose_reload_systemd: false + cloud_compose_run_bootstrap: false + cloud_compose_dedicated_host_acknowledged: true + cloud_compose_runtime: + sitectl: + version: v0.38.0/../../latest + roles: + - role: cloud_compose diff --git a/tests/config-management/ansible/invalid-template.yml b/tests/config-management/ansible/invalid-template.yml new file mode 100644 index 0000000..e8a3d7d --- /dev/null +++ b/tests/config-management/ansible/invalid-template.yml @@ -0,0 +1,12 @@ +- name: Reject an unknown cloud-compose template + hosts: localhost + gather_facts: true + vars: + cloud_compose_name: invalid-template + cloud_compose_template: wordpres + cloud_compose_dedicated_host_acknowledged: true + cloud_compose_install_packages: false + cloud_compose_reload_systemd: false + cloud_compose_run_bootstrap: false + roles: + - role: cloud_compose diff --git a/tests/config-management/ansible/invalid-vault.yml b/tests/config-management/ansible/invalid-vault.yml new file mode 100644 index 0000000..401307d --- /dev/null +++ b/tests/config-management/ansible/invalid-vault.yml @@ -0,0 +1,15 @@ +- name: Reject unsupported Vault Agent settings + hosts: localhost + gather_facts: true + vars: + cloud_compose_name: invalid-vault + cloud_compose_template: wp + cloud_compose_dedicated_host_acknowledged: true + cloud_compose_install_packages: false + cloud_compose_reload_systemd: false + cloud_compose_run_bootstrap: false + cloud_compose_runtime: + vault: + agent_enabled: true + roles: + - role: cloud_compose diff --git a/tests/config-management/ansible/smoke.yml b/tests/config-management/ansible/smoke.yml index 9476212..6c42e97 100644 --- a/tests/config-management/ansible/smoke.yml +++ b/tests/config-management/ansible/smoke.yml @@ -7,14 +7,61 @@ cloud_compose_install_packages: false cloud_compose_reload_systemd: false cloud_compose_run_bootstrap: false + cloud_compose_dedicated_host_acknowledged: true + cloud_compose_managed_runtime_enabled: true + cloud_compose_internal_services_enabled: true + cloud_compose_internal_services_auto_update: true cloud_compose_runtime: + extra_env: + BASH_ENV: /tmp/cloud-compose-ansible-untrusted-bash-env + LD_PRELOAD: /tmp/cloud-compose-ansible-untrusted-preload.so + PORT: "9999" + CONTRACT_BACKTICKS: '`touch /tmp/cloud-compose-ansible-backtick-injection`' + CONTRACT_BACKSLASH: "a\\path\\ends\\" + CONTRACT_COMMAND_SUB: '$(touch /tmp/cloud-compose-ansible-command-injection)' + CONTRACT_DOLLARS: '$HOME ${HOME}' + CONTRACT_QUOTES: 'a "double" and a single quote: O''Reilly' + CONTRACT_WHITESPACE: ' leading and trailing ' + CONTRACT_MULTILINE: |- + line one + line two compose: - project_dir: /opt/isle + project_dir: /mnt/disks/data/libops/isle/main + init: [] + up: + - global-up-command + down: + - global-down-command + rollout: + - global-rollout-command ingress: domain: isle.example.edu acme_email: admin@example.edu + projects: + isle-prod: + docker_compose_repo: https://github.com/libops/isle + project_dir: /mnt/disks/data/libops/isle/main + docker_compose_up: [] + docker_compose_down: + - app-down-command sitectl: environment: production + package_versions: + sitectl-drupal: v0.11.0 + sitectl-isle: v0.12.0 + managed_runtime: + enabled: false + internal_services_enabled: false + internal_services_auto_update: false + artifacts: + - name: contract-agent + url: https://example.invalid/contract-agent + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: /usr/local/bin/contract-agent + mode: "0750" + owner: root + group: root + restart: cloud-compose.service roles: - role: cloud_compose post_tasks: @@ -28,20 +75,54 @@ src: /home/cloud-compose/.env register: cloud_compose_env_file + - name: Read rendered Compose application environment + ansible.builtin.slurp: + src: /home/cloud-compose/application-env.json + register: cloud_compose_application_env_file + - name: Read rendered compose project manifest ansible.builtin.slurp: src: /home/cloud-compose/compose-projects.json register: cloud_compose_manifest_file + - name: Read rendered managed artifact manifest + ansible.builtin.slurp: + src: /home/cloud-compose/managed-runtime-artifacts.tsv + register: cloud_compose_artifact_manifest_file + - name: Assert rendered Ansible runtime contract ansible.builtin.assert: that: - - '"CLOUD_COMPOSE_PROVIDER=\"onprem\"" in (cloud_compose_env_file.content | b64decode)' - - '"DOCKER_COMPOSE_DIR=\"/opt/isle\"" in (cloud_compose_env_file.content | b64decode)' - - '"DOCKER_COMPOSE_REPO=\"https://github.com/libops/isle\"" in (cloud_compose_env_file.content | b64decode)' - - '"SITECTL_PLUGIN=\"isle\"" in (cloud_compose_env_file.content | b64decode)' + - >- + 'CLOUD_COMPOSE_PROVIDER="onprem"' in (cloud_compose_env_file.content | b64decode) + - >- + 'DOCKER_COMPOSE_DIR="/mnt/disks/data/libops/isle/main"' in (cloud_compose_env_file.content | b64decode) + - >- + 'DOCKER_COMPOSE_REPO="https://github.com/libops/isle"' in (cloud_compose_env_file.content | b64decode) + - >- + 'SITECTL_PLUGIN="isle"' in (cloud_compose_env_file.content | b64decode) - '"sitectl-isle" in (cloud_compose_env_file.content | b64decode)' + - '"SITECTL_PACKAGE_VERSIONS" in (cloud_compose_env_file.content | b64decode)' + - >- + 'LIBOPS_MANAGED_RUNTIME_ENABLED="false"' in (cloud_compose_env_file.content | b64decode) + - >- + 'LIBOPS_INTERNAL_SERVICES_ENABLED="false"' in (cloud_compose_env_file.content | b64decode) + - >- + 'LIBOPS_INTERNAL_SERVICES_AUTO_UPDATE="false"' in (cloud_compose_env_file.content | b64decode) + - "'BASH_ENV' not in (cloud_compose_env_file.content | b64decode)" + - "'LD_PRELOAD' not in (cloud_compose_env_file.content | b64decode)" + - "not (cloud_compose_env_file.content | b64decode).startswith('PORT=')" + - "'\nPORT=' not in (cloud_compose_env_file.content | b64decode)" + - '(cloud_compose_application_env_file.content | b64decode | from_json).BASH_ENV == "/tmp/cloud-compose-ansible-untrusted-bash-env"' + - '(cloud_compose_application_env_file.content | b64decode | from_json).LD_PRELOAD == "/tmp/cloud-compose-ansible-untrusted-preload.so"' + - '(cloud_compose_application_env_file.content | b64decode | from_json).PORT == "9999"' - '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].docker_compose_repo == "https://github.com/libops/isle"' - - '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].project_dir == "/opt/isle"' + - '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].project_dir == "/mnt/disks/data/libops/isle/main"' - '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].ingress.domain == "isle.example.edu"' - '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].sitectl_plugin == "isle"' + - '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].init_commands == []' + - '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].up_commands == []' + - '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].down_commands == ["app-down-command"]' + - '(cloud_compose_manifest_file.content | b64decode | from_json)["isle-prod"].rollout_commands == ["global-rollout-command"]' + - >- + 'contract-agent\thttps://example.invalid/contract-agent\taaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\t/usr/local/bin/contract-agent\t0750\troot\troot\tcloud-compose.service' in (cloud_compose_artifact_manifest_file.content | b64decode) diff --git a/tests/config-management/salt-pillar/drupal-prod.sls b/tests/config-management/salt-pillar/drupal-prod.sls index 43b5bb6..b899ac9 100644 --- a/tests/config-management/salt-pillar/drupal-prod.sls +++ b/tests/config-management/salt-pillar/drupal-prod.sls @@ -4,10 +4,11 @@ cloud_compose: install_packages: false reload_systemd: false run_bootstrap: false + dedicated_host_acknowledged: true template: drupal runtime: compose: - project_dir: /opt/drupal + project_dir: /mnt/disks/data/libops/drupal/main ingress: domain: drupal.example.edu acme_email: admin@example.edu diff --git a/tests/config-management/salt-pillar/invalid-artifacts.sls b/tests/config-management/salt-pillar/invalid-artifacts.sls new file mode 100644 index 0000000..59303cc --- /dev/null +++ b/tests/config-management/salt-pillar/invalid-artifacts.sls @@ -0,0 +1,83 @@ +cloud_compose: + name: invalid-artifacts + provider: onprem + template: wp + dedicated_host_acknowledged: true + install_packages: false + reload_systemd: false + run_bootstrap: false + runtime: + managed_runtime: + artifacts: + - name: ../unsafe-name + url: https://example.invalid/unsafe-name + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: /usr/local/bin/unsafe-name + - name: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + url: https://example.invalid/overlong-name + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: /usr/local/bin/overlong-name + - name: unsafe-url + url: http://example.invalid/unsafe-url + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: /usr/local/bin/unsafe-url + - name: unsafe-sha + url: https://example.invalid/unsafe-sha + sha256: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + path: /usr/local/bin/unsafe-sha + - name: root-path + url: https://example.invalid/root-path + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: / + - name: relative-path + url: https://example.invalid/relative-path + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: usr/local/bin/relative-path + - name: dot-path + url: https://example.invalid/dot-path + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: /usr/local/../bin/dot-path + - name: empty-path-segment + url: https://example.invalid/empty-path-segment + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: /usr//local/bin/empty-path-segment + - name: control-path-segment + url: https://example.invalid/control-path-segment + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: "/usr/local/bin/bad\u007fname" + - name: unsafe-mode + url: https://example.invalid/unsafe-mode + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: /usr/local/bin/unsafe-mode + mode: "4755" + - name: unsafe-owner + url: https://example.invalid/unsafe-owner + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: /usr/local/bin/unsafe-owner + owner: root:root + - name: unsafe-group + url: https://example.invalid/unsafe-group + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: /usr/local/bin/unsafe-group + group: root:root + - name: unsafe-restart + url: https://example.invalid/unsafe-restart + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: /usr/local/bin/unsafe-restart + restart: ../docker.service + - name: duplicate-name + url: https://example.invalid/duplicate-name-one + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: /usr/local/bin/duplicate-name-one + - name: duplicate-name + url: https://example.invalid/duplicate-name-two + sha256: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + path: /usr/local/bin/duplicate-name-two + - name: duplicate-path-one + url: https://example.invalid/duplicate-path-one + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: /usr/local/bin/duplicate-path + - name: duplicate-path-two + url: https://example.invalid/duplicate-path-two + sha256: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + path: /usr/local/bin/duplicate-path diff --git a/tests/config-management/salt-pillar/invalid-env.sls b/tests/config-management/salt-pillar/invalid-env.sls new file mode 100644 index 0000000..4b330db --- /dev/null +++ b/tests/config-management/salt-pillar/invalid-env.sls @@ -0,0 +1,10 @@ +cloud_compose: + name: invalid-env + provider: onprem + install_packages: false + reload_systemd: false + run_bootstrap: false + dedicated_host_acknowledged: true + template: wp + extra_env: + BAD-NAME: value diff --git a/tests/config-management/salt-pillar/invalid-host-ack.sls b/tests/config-management/salt-pillar/invalid-host-ack.sls new file mode 100644 index 0000000..3f48f8b --- /dev/null +++ b/tests/config-management/salt-pillar/invalid-host-ack.sls @@ -0,0 +1,7 @@ +cloud_compose: + name: invalid-host-ack + provider: onprem + template: wp + install_packages: false + reload_systemd: false + run_bootstrap: false diff --git a/tests/config-management/salt-pillar/invalid-ingress-port.sls b/tests/config-management/salt-pillar/invalid-ingress-port.sls new file mode 100644 index 0000000..b8312f1 --- /dev/null +++ b/tests/config-management/salt-pillar/invalid-ingress-port.sls @@ -0,0 +1,11 @@ +cloud_compose: + name: invalid-ingress-port + provider: onprem + template: wp + dedicated_host_acknowledged: true + install_packages: false + reload_systemd: false + run_bootstrap: false + runtime: + compose: + ingress_port: 80.5 diff --git a/tests/config-management/salt-pillar/invalid-internal-services.sls b/tests/config-management/salt-pillar/invalid-internal-services.sls new file mode 100644 index 0000000..dc8bc8c --- /dev/null +++ b/tests/config-management/salt-pillar/invalid-internal-services.sls @@ -0,0 +1,11 @@ +cloud_compose: + name: invalid-internal-services + provider: onprem + template: wp + dedicated_host_acknowledged: true + install_packages: false + reload_systemd: false + run_bootstrap: false + runtime: + managed_runtime: + internal_services_enabled: true diff --git a/tests/config-management/salt-pillar/invalid-lifecycle.sls b/tests/config-management/salt-pillar/invalid-lifecycle.sls new file mode 100644 index 0000000..1261cf8 --- /dev/null +++ b/tests/config-management/salt-pillar/invalid-lifecycle.sls @@ -0,0 +1,13 @@ +cloud_compose: + name: invalid-lifecycle + provider: onprem + template: wp + dedicated_host_acknowledged: true + install_packages: false + reload_systemd: false + run_bootstrap: false + runtime: + compose: + init: + - valid-command + - 42 diff --git a/tests/config-management/salt-pillar/invalid-package.sls b/tests/config-management/salt-pillar/invalid-package.sls new file mode 100644 index 0000000..166eb86 --- /dev/null +++ b/tests/config-management/salt-pillar/invalid-package.sls @@ -0,0 +1,13 @@ +cloud_compose: + name: invalid-package + provider: onprem + template: wp + dedicated_host_acknowledged: true + install_packages: false + reload_systemd: false + run_bootstrap: false + runtime: + sitectl: + packages: + - sitectl + - bad/package diff --git a/tests/config-management/salt-pillar/invalid-primary.sls b/tests/config-management/salt-pillar/invalid-primary.sls new file mode 100644 index 0000000..ae73b2d --- /dev/null +++ b/tests/config-management/salt-pillar/invalid-primary.sls @@ -0,0 +1,14 @@ +cloud_compose: + name: invalid-primary + provider: onprem + template: wp + dedicated_host_acknowledged: true + install_packages: false + reload_systemd: false + run_bootstrap: false + runtime: + compose: + primary: missing + projects: + wp: + docker_compose_repo: https://github.com/libops/wp.git diff --git a/tests/config-management/salt-pillar/invalid-project-dir-etc.sls b/tests/config-management/salt-pillar/invalid-project-dir-etc.sls new file mode 100644 index 0000000..235282f --- /dev/null +++ b/tests/config-management/salt-pillar/invalid-project-dir-etc.sls @@ -0,0 +1,11 @@ +cloud_compose: + name: invalid-project-dir-etc + provider: onprem + template: wp + dedicated_host_acknowledged: true + install_packages: false + reload_systemd: false + run_bootstrap: false + runtime: + compose: + project_dir: /etc diff --git a/tests/config-management/salt-pillar/invalid-project-dir-root.sls b/tests/config-management/salt-pillar/invalid-project-dir-root.sls new file mode 100644 index 0000000..8774ae3 --- /dev/null +++ b/tests/config-management/salt-pillar/invalid-project-dir-root.sls @@ -0,0 +1,11 @@ +cloud_compose: + name: invalid-project-dir-root + provider: onprem + template: wp + dedicated_host_acknowledged: true + install_packages: false + reload_systemd: false + run_bootstrap: false + runtime: + compose: + project_dir: / diff --git a/tests/config-management/salt-pillar/invalid-project-dir-symlink.sls b/tests/config-management/salt-pillar/invalid-project-dir-symlink.sls new file mode 100644 index 0000000..b83438f --- /dev/null +++ b/tests/config-management/salt-pillar/invalid-project-dir-symlink.sls @@ -0,0 +1,11 @@ +cloud_compose: + name: invalid-project-dir-symlink + provider: onprem + template: wp + dedicated_host_acknowledged: true + install_packages: false + reload_systemd: false + run_bootstrap: false + runtime: + compose: + project_dir: /mnt/disks/data/escape/site diff --git a/tests/config-management/salt-pillar/invalid-project-dir-traversal.sls b/tests/config-management/salt-pillar/invalid-project-dir-traversal.sls new file mode 100644 index 0000000..a29e973 --- /dev/null +++ b/tests/config-management/salt-pillar/invalid-project-dir-traversal.sls @@ -0,0 +1,11 @@ +cloud_compose: + name: invalid-project-dir-traversal + provider: onprem + template: wp + dedicated_host_acknowledged: true + install_packages: false + reload_systemd: false + run_bootstrap: false + runtime: + compose: + project_dir: /mnt/disks/data/../escape diff --git a/tests/config-management/salt-pillar/invalid-project-lifecycle.sls b/tests/config-management/salt-pillar/invalid-project-lifecycle.sls new file mode 100644 index 0000000..a10e3e8 --- /dev/null +++ b/tests/config-management/salt-pillar/invalid-project-lifecycle.sls @@ -0,0 +1,13 @@ +cloud_compose: + name: invalid-project-lifecycle + provider: onprem + dedicated_host_acknowledged: true + install_packages: false + reload_systemd: false + run_bootstrap: false + runtime: + compose: + projects: + wp: + docker_compose_repo: https://github.com/libops/wp.git + docker_compose_up: not-a-list diff --git a/tests/config-management/salt-pillar/invalid-project-port.sls b/tests/config-management/salt-pillar/invalid-project-port.sls new file mode 100644 index 0000000..2d67cbf --- /dev/null +++ b/tests/config-management/salt-pillar/invalid-project-port.sls @@ -0,0 +1,13 @@ +cloud_compose: + name: invalid-project-port + provider: onprem + dedicated_host_acknowledged: true + install_packages: false + reload_systemd: false + run_bootstrap: false + runtime: + compose: + projects: + wp: + docker_compose_repo: https://github.com/libops/wp.git + ingress_port: 443.5 diff --git a/tests/config-management/salt-pillar/invalid-project-repo.sls b/tests/config-management/salt-pillar/invalid-project-repo.sls new file mode 100644 index 0000000..3f33532 --- /dev/null +++ b/tests/config-management/salt-pillar/invalid-project-repo.sls @@ -0,0 +1,12 @@ +cloud_compose: + name: invalid-project-repo + provider: onprem + dedicated_host_acknowledged: true + install_packages: false + reload_systemd: false + run_bootstrap: false + runtime: + compose: + projects: + wp: + ingress_port: 80 diff --git a/tests/config-management/salt-pillar/invalid-project.sls b/tests/config-management/salt-pillar/invalid-project.sls new file mode 100644 index 0000000..302f1a2 --- /dev/null +++ b/tests/config-management/salt-pillar/invalid-project.sls @@ -0,0 +1,13 @@ +cloud_compose: + name: invalid-project + provider: onprem + dedicated_host_acknowledged: true + install_packages: false + reload_systemd: false + run_bootstrap: false + runtime: + compose: + projects: + "Bad/Name": + docker_compose_repo: https://github.com/libops/wp.git + ingress_port: 80 diff --git a/tests/config-management/salt-pillar/invalid-reserved-env.sls b/tests/config-management/salt-pillar/invalid-reserved-env.sls new file mode 100644 index 0000000..37ae8fa --- /dev/null +++ b/tests/config-management/salt-pillar/invalid-reserved-env.sls @@ -0,0 +1,10 @@ +cloud_compose: + name: invalid-reserved-env + provider: onprem + template: wp + dedicated_host_acknowledged: true + install_packages: false + reload_systemd: false + run_bootstrap: false + extra_env: + LIBOPS_FUTURE_CONTROL: "true" diff --git a/tests/config-management/salt-pillar/invalid-sitectl.sls b/tests/config-management/salt-pillar/invalid-sitectl.sls new file mode 100644 index 0000000..e3ac233 --- /dev/null +++ b/tests/config-management/salt-pillar/invalid-sitectl.sls @@ -0,0 +1,11 @@ +cloud_compose: + name: invalid-sitectl + provider: onprem + install_packages: false + reload_systemd: false + run_bootstrap: false + dedicated_host_acknowledged: true + template: wp + runtime: + sitectl: + version: v0.38.0/../../latest diff --git a/tests/config-management/salt-pillar/invalid-template.sls b/tests/config-management/salt-pillar/invalid-template.sls new file mode 100644 index 0000000..e48cb9b --- /dev/null +++ b/tests/config-management/salt-pillar/invalid-template.sls @@ -0,0 +1,8 @@ +cloud_compose: + name: invalid-template + provider: onprem + template: wordpres + dedicated_host_acknowledged: true + install_packages: false + reload_systemd: false + run_bootstrap: false diff --git a/tests/config-management/salt-pillar/invalid-vault.sls b/tests/config-management/salt-pillar/invalid-vault.sls new file mode 100644 index 0000000..0d44791 --- /dev/null +++ b/tests/config-management/salt-pillar/invalid-vault.sls @@ -0,0 +1,11 @@ +cloud_compose: + name: invalid-vault + provider: onprem + template: wp + dedicated_host_acknowledged: true + install_packages: false + reload_systemd: false + run_bootstrap: false + runtime: + vault: + agent_enabled: true diff --git a/tests/config-management/salt-pillar/top.sls b/tests/config-management/salt-pillar/top.sls index 8b2c548..e652068 100644 --- a/tests/config-management/salt-pillar/top.sls +++ b/tests/config-management/salt-pillar/top.sls @@ -3,3 +3,43 @@ base: - wp-prod 'drupal-prod': - drupal-prod + 'invalid-env': + - invalid-env + 'invalid-sitectl': + - invalid-sitectl + 'invalid-reserved-env': + - invalid-reserved-env + 'invalid-project': + - invalid-project + 'invalid-project-repo': + - invalid-project-repo + 'invalid-project-port': + - invalid-project-port + 'invalid-ingress-port': + - invalid-ingress-port + 'invalid-lifecycle': + - invalid-lifecycle + 'invalid-project-lifecycle': + - invalid-project-lifecycle + 'invalid-internal-services': + - invalid-internal-services + 'invalid-primary': + - invalid-primary + 'invalid-template': + - invalid-template + 'invalid-vault': + - invalid-vault + 'invalid-package': + - invalid-package + 'invalid-host-ack': + - invalid-host-ack + 'invalid-project-dir-root': + - invalid-project-dir-root + 'invalid-project-dir-etc': + - invalid-project-dir-etc + 'invalid-project-dir-traversal': + - invalid-project-dir-traversal + 'invalid-project-dir-symlink': + - invalid-project-dir-symlink + 'invalid-artifacts': + - invalid-artifacts diff --git a/tests/config-management/salt-pillar/wp-prod.sls b/tests/config-management/salt-pillar/wp-prod.sls index 2f91a8c..2951e73 100644 --- a/tests/config-management/salt-pillar/wp-prod.sls +++ b/tests/config-management/salt-pillar/wp-prod.sls @@ -4,12 +4,58 @@ cloud_compose: install_packages: false reload_systemd: false run_bootstrap: false + dedicated_host_acknowledged: true + managed_runtime_enabled: true + internal_services_enabled: true + internal_services_auto_update: true template: wp runtime: + extra_env: + BASH_ENV: /tmp/cloud-compose-salt-untrusted-bash-env + LD_PRELOAD: /tmp/cloud-compose-salt-untrusted-preload.so + PORT: "9999" + CONTRACT_BACKTICKS: '`touch /tmp/cloud-compose-salt-backtick-injection`' + CONTRACT_BACKSLASH: "a\\path\\ends\\" + CONTRACT_COMMAND_SUB: '$(touch /tmp/cloud-compose-salt-command-injection)' + CONTRACT_DOLLARS: '$HOME ${HOME}' + CONTRACT_QUOTES: 'a "double" and a single quote: O''Reilly' + CONTRACT_WHITESPACE: ' leading and trailing ' + CONTRACT_MULTILINE: |- + line one + line two compose: - project_dir: /opt/wp + project_dir: /mnt/disks/data/libops/wp/main + init: [] + up: + - global-up-command + down: + - global-down-command + rollout: + - global-rollout-command ingress: domain: wp.example.edu acme_email: admin@example.edu + projects: + wp-prod: + docker_compose_repo: https://github.com/libops/wp.git + project_dir: /mnt/disks/data/libops/wp/main + docker_compose_up: [] + docker_compose_down: + - app-down-command sitectl: environment: production + package_versions: + sitectl-wp: v0.10.0 + managed_runtime: + enabled: false + internal_services_enabled: false + internal_services_auto_update: false + artifacts: + - name: contract-agent + url: https://example.invalid/contract-agent + sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + path: /usr/local/bin/contract-agent + mode: "0750" + owner: root + group: root + restart: cloud-compose.service diff --git a/tests/smoke/config-management-linode/main.tf b/tests/smoke/config-management-linode/main.tf index ffc000f..7ccaf6c 100644 --- a/tests/smoke/config-management-linode/main.tf +++ b/tests/smoke/config-management-linode/main.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.2.4" + required_version = ">= 1.3.0" required_providers { linode = { @@ -27,7 +27,7 @@ locals { name = substr(join("-", compact(["cc-cm-ln", local.method, "dr", local.smoke_run_id, random_id.suffix.hex])), 0, 46) firewall_label = substr("${local.name}-fw", 0, 32) - project_dir = "/opt/${local.template}" + project_dir = "/mnt/disks/data/libops/${local.template}/main" target_tag = "config-management-${local.method}-${local.template}" tags = distinct(concat(var.tags, ["cloud-compose-smoke", "config-management-smoke", local.target_tag], local.run_tag != "" ? [local.run_tag] : [])) } diff --git a/tests/smoke/do/main.tf b/tests/smoke/do/main.tf index 6041074..852e030 100644 --- a/tests/smoke/do/main.tf +++ b/tests/smoke/do/main.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.2.4" + required_version = ">= 1.3.0" required_providers { digitalocean = { @@ -21,6 +21,8 @@ module "context" { smoke_run_id = var.smoke_run_id docker_compose_branch = var.docker_compose_branch ingress_port = var.ingress_port + rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/${var.cloud_compose_source_ref}.tar.gz" + rootfs_archive_sha256 = var.cloud_compose_source_sha256 tags = var.tags } diff --git a/tests/smoke/do/variables.tf b/tests/smoke/do/variables.tf index 170e022..80a89d4 100644 --- a/tests/smoke/do/variables.tf +++ b/tests/smoke/do/variables.tf @@ -80,3 +80,19 @@ variable "tags" { default = ["cloud-compose"] description = "Extra provider tags applied to smoke-test resources." } + +variable "cloud_compose_source_ref" { + type = string + default = "main" + description = "cloud-compose Git ref whose rootfs is fetched to keep DigitalOcean user_data below 64 KiB." +} + +variable "cloud_compose_source_sha256" { + type = string + description = "SHA-256 of the cloud-compose source archive selected by cloud_compose_source_ref." + + validation { + condition = can(regex("^[0-9a-fA-F]{64}$", trimspace(var.cloud_compose_source_sha256))) + error_message = "cloud_compose_source_sha256 must be a 64-character SHA-256 digest." + } +} diff --git a/tests/smoke/gcp-upgrade/main.tf b/tests/smoke/gcp-upgrade/main.tf new file mode 100644 index 0000000..8a0babd --- /dev/null +++ b/tests/smoke/gcp-upgrade/main.tf @@ -0,0 +1,150 @@ +terraform { + required_version = ">= 1.3.0" + + backend "local" {} + + required_providers { + google = { + source = "hashicorp/google" + version = "~> 7.0" + } + } +} + +provider "google" { + project = local.project_id != "" ? local.project_id : "unused" + region = var.gcp_region + zone = var.gcp_zone +} + +data "google_project" "current" { + project_id = local.project_id + + lifecycle { + precondition { + condition = trimspace(var.gcp_network_project_id) == local.project_id + error_message = "gcp_network_project_id must equal gcp_project_id because the 0.10.2 baseline does not support Shared VPC." + } + } +} + +locals { + project_id = trimspace(var.gcp_project_id) + wordpress_project_dir = "/mnt/disks/data/libops/wp.git/${var.wordpress_compose_ref}" +} + +module "app" { + source = "../../../providers/gcp" + + name = var.name + template = "wp" + gcp = { + project_id = local.project_id + project_number = data.google_project.current.number + region = var.gcp_region + zone = var.gcp_zone + identity = { + # Preserve the pre-1.0 app JSON-key behavior through the state upgrade. + # Keyless migration is a separate, explicit credential-retirement step. + app_credentials_enabled = true + } + instance = { + machine_type = "e2-medium" + os = "cos-125-19216-220-185" + production = false + } + disks = { + type = "pd-standard" + docker_volumes_size_gb = 30 + } + snapshots = { + enabled = false + } + network = { + create = false + project_id = var.gcp_network_project_id + name = var.gcp_network_name + subnetwork = var.gcp_subnetwork_name + power_button_allowed_ips = [var.runner_ipv4_cidr] + power_button_ip_depth = 0 + ssh_ipv4 = [var.runner_ipv4_cidr] + ssh_ipv6 = [] + } + cloud_init = { + # These commands run after write_files but before run.sh. The legacy + # timer can suspend the disposable host during a long bootstrap, so + # disabling it in the later runcmd phase is too late. + initcmd = [ + "systemctl disable --now internal-services.timer internal-services.service 2>/dev/null || true", + "systemctl disable --now cloud-compose-internal-services.timer cloud-compose-internal-services.service 2>/dev/null || true", + "bash -ceu 'project=${local.wordpress_project_dir}; git_project() { git -c safe.directory=\"$project\" -C \"$project\" \"$@\"; }; install -d -m 0755 \"$project\"; if [[ ! -d \"$project/.git\" ]]; then git_project init; git_project remote add origin https://github.com/libops/wp.git; fi; git_project remote set-url origin https://github.com/libops/wp.git; git_project fetch --force --no-tags --depth=1 origin ${var.wordpress_compose_ref}; git_project checkout --detach ${var.wordpress_compose_ref}; test \"$(git_project rev-parse HEAD)\" = ${var.wordpress_compose_ref}; chown -R cloud-compose:cloud-compose \"$project\"'", + ] + } + power_management = { + enabled = true + start_role = var.gcp_power_start_role + suspend_role = var.gcp_power_suspend_role + } + } + runtime = { + users = { + cloud-compose = [var.ssh_public_key] + } + compose = { + # The pinned 0.10.2 baseline had a Terraform type error when a non-empty + # compose_projects map was combined with its dynamically keyed legacy + # fallback. Exercise that release through its supported single-project + # inputs, then move to the current explicit project map during upgrade. + primary = var.legacy_baseline ? "" : "wordpress" + ingress_port = 80 + repo = "https://github.com/libops/wp.git" + branch = var.wordpress_compose_ref + projects = var.legacy_baseline ? {} : { + wordpress = { + docker_compose_repo = "https://github.com/libops/wp.git" + docker_compose_branch = var.wordpress_compose_ref + project_dir = local.wordpress_project_dir + ingress_port = 80 + } + } + up = [ + "sitectl compose --context \"$${SITECTL_CONTEXT_NAME}\" up -d --remove-orphans", + "sitectl healthcheck --context \"$${SITECTL_CONTEXT_NAME}\" --persist", + ] + } + sitectl = { + environment = "smoke" + } + managed_runtime = { + enabled = true + internal_services_enabled = true + internal_services_auto_update = true + } + vault = { + auth_method = "consumer-managed" + } + } +} + +locals { + primary_compose_project = module.app.primary_compose_project +} + +output "smoke" { + value = { + provider = "gcp" + app = "wp" + host = module.app.external_ip + ssh_user = "cloud-compose" + ssh_port = 22 + context_name = local.primary_compose_project.sitectl_context_name + plugin = local.primary_compose_project.sitectl_plugin + environment = local.primary_compose_project.sitectl_environment + site = var.name + project_name = var.name + project_dir = local.primary_compose_project.project_dir + compose_project_name = local.primary_compose_project.compose_project_name + ingress_url = try(module.app.urls[var.gcp_region], "") + } + description = "Remote sitectl context details for both phases of the GCP upgrade smoke test." +} diff --git a/tests/smoke/gcp-upgrade/variables.tf b/tests/smoke/gcp-upgrade/variables.tf new file mode 100644 index 0000000..e607f21 --- /dev/null +++ b/tests/smoke/gcp-upgrade/variables.tf @@ -0,0 +1,130 @@ +variable "name" { + type = string + description = "Unique disposable deployment name." + + validation { + condition = can(regex("^cc-g-wp-[a-z0-9-]+$", var.name)) && length(var.name) <= 21 + error_message = "name must use the cc-g-wp smoke prefix and contain at most 21 characters." + } +} + +variable "gcp_project_id" { + type = string + description = "Google Cloud project used for disposable upgrade resources." + + validation { + condition = trimspace(var.gcp_project_id) != "" + error_message = "gcp_project_id is required." + } +} + +variable "gcp_network_project_id" { + type = string + description = "Google Cloud project containing the persistent CI network. It must equal gcp_project_id because the 0.10.2 baseline predates Shared VPC support." + + validation { + condition = can(regex("^([a-z0-9][a-z0-9.-]*:)?[a-z][a-z0-9-]{4,28}[a-z0-9]$", trimspace(var.gcp_network_project_id))) + error_message = "gcp_network_project_id must be a valid lowercase GCP project ID." + } +} + +variable "gcp_network_name" { + type = string + description = "Existing persistent CI VPC network name or self link. The upgrade state never owns or deletes this network." + + validation { + condition = ( + trimspace(var.gcp_network_name) != "" && + !startswith(basename(trimsuffix(trimspace(var.gcp_network_name), "/")), "cc-g-wp-") + ) + error_message = "gcp_network_name is required and must not use the disposable cc-g-wp- prefix." + } +} + +variable "gcp_subnetwork_name" { + type = string + description = "Existing persistent /26-or-larger regional CI subnet name or self link. Cloud Run Direct VPC allocations may outlive the disposable stack." + + validation { + condition = ( + trimspace(var.gcp_subnetwork_name) != "" && + !startswith(basename(trimsuffix(trimspace(var.gcp_subnetwork_name), "/")), "cc-g-wp-") + ) + error_message = "gcp_subnetwork_name is required and must not use the disposable cc-g-wp- prefix." + } +} + +variable "gcp_power_start_role" { + type = string + description = "Full project- or organization-custom-role name used for the upgraded instance-scoped PPB start binding." + + validation { + condition = can(regex( + "^(projects/([a-z0-9][a-z0-9.-]*:)?[a-z][a-z0-9-]{4,28}[a-z0-9]|organizations/[0-9]+)/roles/[A-Za-z0-9_.]+$", + trimspace(var.gcp_power_start_role), + )) + error_message = "gcp_power_start_role must be a full projects/.../roles/... or organizations/.../roles/... custom-role name." + } +} + +variable "gcp_power_suspend_role" { + type = string + description = "Full project- or organization-custom-role name used for the upgraded instance-scoped suspend binding." + + validation { + condition = can(regex( + "^(projects/([a-z0-9][a-z0-9.-]*:)?[a-z][a-z0-9-]{4,28}[a-z0-9]|organizations/[0-9]+)/roles/[A-Za-z0-9_.]+$", + trimspace(var.gcp_power_suspend_role), + )) + error_message = "gcp_power_suspend_role must be a full projects/.../roles/... or organizations/.../roles/... custom-role name." + } +} + +variable "gcp_region" { + type = string + default = "us-east5" + description = "Google Cloud region for disposable upgrade resources." +} + +variable "gcp_zone" { + type = string + default = "us-east5-b" + description = "Google Cloud zone for disposable upgrade resources." +} + +variable "ssh_public_key" { + type = string + description = "Ephemeral CI SSH public key authorized on the disposable VM." + + validation { + condition = can(regex("^ssh-(ed25519|rsa) [A-Za-z0-9+/=]+( .*)?$", trimspace(var.ssh_public_key))) + error_message = "ssh_public_key must be a supported OpenSSH public key." + } +} + +variable "runner_ipv4_cidr" { + type = string + description = "Ephemeral GitHub runner public IPv4 address as a single-host /32 CIDR." + + validation { + condition = can(cidrnetmask(var.runner_ipv4_cidr)) && endswith(var.runner_ipv4_cidr, "/32") + error_message = "runner_ipv4_cidr must be a valid IPv4 /32 CIDR." + } +} + +variable "legacy_baseline" { + type = bool + default = false + description = "Use the supported legacy single-project inputs while provisioning the pinned 0.10.2 baseline. The current phase uses the explicit project map." +} + +variable "wordpress_compose_ref" { + type = string + default = "5058610fddc7267ace92d65a5c49713dce570ac3" + description = "Exact libops/wp commit shared by the baseline and current upgrade phases." + + validation { + condition = var.wordpress_compose_ref == "5058610fddc7267ace92d65a5c49713dce570ac3" + error_message = "wordpress_compose_ref is intentionally pinned for a reproducible cross-version upgrade test." + } +} diff --git a/tests/smoke/gcp/main.tf b/tests/smoke/gcp/main.tf index ca657f5..f547f82 100644 --- a/tests/smoke/gcp/main.tf +++ b/tests/smoke/gcp/main.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.2.4" + required_version = ">= 1.3.0" required_providers { google = { @@ -15,10 +15,6 @@ provider "google" { zone = var.gcp_zone } -data "google_project" "current" { - project_id = local.project_id -} - locals { project_id = trimspace(var.gcp_project_id) } @@ -41,10 +37,9 @@ module "app" { name = module.context.name template = module.context.template gcp = { - project_id = local.project_id - project_number = data.google_project.current.number - region = var.gcp_region - zone = var.gcp_zone + project_id = local.project_id + region = var.gcp_region + zone = var.gcp_zone instance = { machine_type = var.gcp_machine_type os = var.gcp_os diff --git a/tests/smoke/linode/main.tf b/tests/smoke/linode/main.tf index 998b02f..7fa37ca 100644 --- a/tests/smoke/linode/main.tf +++ b/tests/smoke/linode/main.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.2.4" + required_version = ">= 1.3.0" required_providers { linode = { @@ -22,6 +22,7 @@ module "context" { docker_compose_branch = var.docker_compose_branch ingress_port = var.ingress_port rootfs_archive_url = "https://github.com/libops/cloud-compose/archive/${var.cloud_compose_source_ref}.tar.gz" + rootfs_archive_sha256 = var.cloud_compose_source_sha256 tags = var.tags } diff --git a/tests/smoke/linode/variables.tf b/tests/smoke/linode/variables.tf index 0db80f7..aac5b0b 100644 --- a/tests/smoke/linode/variables.tf +++ b/tests/smoke/linode/variables.tf @@ -86,3 +86,13 @@ variable "cloud_compose_source_ref" { default = "main" description = "cloud-compose Git ref whose rootfs is fetched by providers with metadata size limits." } + +variable "cloud_compose_source_sha256" { + type = string + description = "SHA-256 of the cloud-compose source archive selected by cloud_compose_source_ref." + + validation { + condition = can(regex("^[0-9a-fA-F]{64}$", trimspace(var.cloud_compose_source_sha256))) + error_message = "cloud_compose_source_sha256 must be a 64-character SHA-256 digest." + } +} diff --git a/tests/smoke/modules/context/main.tf b/tests/smoke/modules/context/main.tf index 936ab06..b0bfbb9 100644 --- a/tests/smoke/modules/context/main.tf +++ b/tests/smoke/modules/context/main.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.2.4" + required_version = ">= 1.3.0" required_providers { random = { @@ -41,7 +41,8 @@ locals { tags = distinct(concat(var.tags, ["cloud-compose-smoke", local.target], local.run_tag != "" ? [local.run_tag] : [])) ssh_keys = distinct(concat([var.ssh_public_key], var.operator_ssh_public_keys)) runtime_base = { - rootfs_archive_url = var.rootfs_archive_url + rootfs_archive_url = var.rootfs_archive_url + rootfs_archive_sha256 = var.rootfs_archive_sha256 compose = { branch = var.docker_compose_branch ingress_port = var.ingress_port diff --git a/tests/smoke/modules/context/variables.tf b/tests/smoke/modules/context/variables.tf index 82ea242..a4710e0 100644 --- a/tests/smoke/modules/context/variables.tf +++ b/tests/smoke/modules/context/variables.tf @@ -55,7 +55,13 @@ variable "ingress_port" { variable "rootfs_archive_url" { type = string default = "" - description = "Optional cloud-compose rootfs archive URL for providers with metadata size limits." + description = "Optional cloud-compose rootfs archive URL for providers with metadata size limits. Must be set with rootfs_archive_sha256." +} + +variable "rootfs_archive_sha256" { + type = string + default = "" + description = "SHA-256 for rootfs_archive_url." } variable "tags" { diff --git a/variables.tf b/variables.tf index 00569d5..c57ba85 100644 --- a/variables.tf +++ b/variables.tf @@ -36,6 +36,7 @@ variable "gcp" { identity = optional(object({ vm_service_account_email = optional(string, "") app_service_account_email = optional(string, "") + app_credentials_enabled = optional(bool, false) }), {}) instance = optional(object({ @@ -51,10 +52,13 @@ variable "gcp" { network = optional(object({ create = optional(bool, true) + project_id = optional(string, "") name = optional(string, "") subnetwork = optional(string, "") ip_cidr_range = optional(string, "10.42.0.0/24") + mtu = optional(number, 1460) power_button_allowed_ips = optional(list(string), []) + power_button_ip_depth = optional(number) ssh_ipv4 = optional(list(string), []) ssh_ipv6 = optional(list(string), []) }), {}) @@ -79,7 +83,9 @@ variable "gcp" { }), {}) power_management = optional(object({ - enabled = optional(bool, true) + enabled = optional(bool, false) + start_role = optional(string, "") + suspend_role = optional(string, "") frontend = optional(object({ image = string port = optional(number, 8080) @@ -138,10 +144,54 @@ variable "gcp" { condition = var.gcp.rollout.release_sha256 == "" || can(regex("^[0-9a-f]{64}$", var.gcp.rollout.release_sha256)) error_message = "gcp.rollout.release_sha256 must be empty or a lowercase SHA256 hex digest." } + + validation { + condition = ( + (var.gcp.rollout.release_url == "" || can(regex("^https://[^[:space:]]+$", var.gcp.rollout.release_url))) && + (var.gcp.rollout.jwks_uri == "" || can(regex("^https://[^[:space:]]+$", var.gcp.rollout.jwks_uri))) && + (trimspace(var.gcp.rollout.custom_claims) == "" || can(keys(jsondecode(var.gcp.rollout.custom_claims)))) + ) + error_message = "GCP rollout release_url and jwks_uri must use HTTPS, and custom_claims must be empty or a JSON object." + } + + validation { + condition = ( + var.gcp.rollout.port >= 1 && + var.gcp.rollout.port <= 65535 && + floor(var.gcp.rollout.port) == var.gcp.rollout.port && + (var.gcp.power_management.frontend == null ? true : ( + var.gcp.power_management.frontend.port >= 1 && + var.gcp.power_management.frontend.port <= 65535 && + floor(var.gcp.power_management.frontend.port) == var.gcp.power_management.frontend.port + )) + ) + error_message = "gcp.rollout.port and gcp.power_management.frontend.port must be whole numbers between 1 and 65535." + } + + validation { + condition = ( + can(cidrhost(var.gcp.network.ip_cidr_range, 0)) && + length(regexall(":", var.gcp.network.ip_cidr_range)) == 0 && + alltrue([for cidr in var.gcp.network.power_button_allowed_ips : can(cidrhost(cidr, 0))]) && + alltrue([for cidr in var.gcp.network.ssh_ipv4 : can(cidrhost(cidr, 0)) && length(regexall(":", cidr)) == 0]) && + alltrue([for cidr in var.gcp.network.ssh_ipv6 : can(cidrhost(cidr, 0)) && length(regexall(":", cidr)) > 0]) && + alltrue([for cidr in var.gcp.rollout.allowed_ipv4 : can(cidrhost(cidr, 0)) && length(regexall(":", cidr)) == 0]) + ) + error_message = "GCP network CIDRs must be valid and match their advertised IPv4 or IPv6 family." + } + + validation { + condition = var.gcp.network.power_button_ip_depth == null ? true : ( + var.gcp.network.power_button_ip_depth >= 0 && + var.gcp.network.power_button_ip_depth <= 10 && + floor(var.gcp.network.power_button_ip_depth) == var.gcp.network.power_button_ip_depth + ) + error_message = "gcp.network.power_button_ip_depth must be null or a whole number from 0 through 10." + } } variable "digitalocean" { - description = "DigitalOcean infrastructure settings." + description = "DigitalOcean infrastructure settings. droplet.backups covers only the Droplet boot disk; attached application and Docker volumes require a separate offsite backup policy." type = object({ region = optional(string, "tor1") tags = optional(list(string), ["cloud-compose"]) @@ -176,7 +226,7 @@ variable "digitalocean" { } variable "linode" { - description = "Linode infrastructure settings." + description = "Linode infrastructure settings. instance.backups_enabled covers only the instance disk; attached application and Docker Block Storage volumes require a separate offsite backup policy." type = object({ region = optional(string, "us-east") tags = optional(list(string), ["cloud-compose"]) @@ -211,6 +261,15 @@ variable "linode" { }), {}) }) default = {} + + validation { + condition = alltrue([ + for key in var.linode.instance.authorized_keys : trimspace(key) != "" && !can(regex("[\\r\\n]", key)) + ]) && alltrue([ + for username in var.linode.instance.authorized_users : can(regex("^[A-Za-z0-9._-]+$", username)) + ]) + error_message = "linode.instance authorized_keys must be non-empty single-line values and authorized_users must contain safe single-line usernames." + } } variable "runtime" { @@ -255,8 +314,8 @@ variable "runtime" { sitectl_context_name = optional(string) sitectl_plugin = optional(string) sitectl_environment = optional(string) - sitectl_packages = optional(list(string), []) - sitectl_verify_args = optional(list(string), []) + sitectl_packages = optional(list(string)) + sitectl_verify_args = optional(list(string)) docker_compose_init = optional(list(string)) docker_compose_up = optional(list(string)) docker_compose_down = optional(list(string)) @@ -269,12 +328,13 @@ variable "runtime" { }), {}) sitectl = optional(object({ - packages = optional(list(string), ["sitectl"]) - version = optional(string, "latest") - context_name = optional(string, "") - plugin = optional(string, "core") - environment = optional(string, "production") - verify_args = optional(list(string), []) + packages = optional(list(string)) + version = optional(string, "latest") + package_versions = optional(map(string), {}) + context_name = optional(string, "") + plugin = optional(string, "core") + environment = optional(string, "production") + verify_args = optional(list(string), []) }), {}) docker = optional(object({ @@ -286,8 +346,8 @@ variable "runtime" { managed_runtime = optional(object({ enabled = optional(bool, true) - internal_services_enabled = optional(bool, true) - internal_services_auto_update = optional(bool, true) + internal_services_enabled = optional(bool, false) + internal_services_auto_update = optional(bool, false) artifacts = optional(list(object({ name = string url = string @@ -305,7 +365,7 @@ variable "runtime" { namespace = optional(string, "") role = optional(string, "") agent_enabled = optional(bool, false) - auth_method = optional(string, "gcp-iam") + auth_method = optional(string, "auto") gcp_auth_mount_path = optional(string, "auth/gcp") agent_token_path = optional(string, "/mnt/disks/data/vault/token") agent_additional_config = optional(string, "") @@ -321,33 +381,83 @@ variable "runtime" { }) default = {} + validation { + condition = ( + (trimspace(var.runtime.rootfs_archive_url) == "") == (trimspace(var.runtime.rootfs_archive_sha256) == "") && + (trimspace(var.runtime.rootfs_archive_sha256) == "" || can(regex("^[0-9a-fA-F]{64}$", trimspace(var.runtime.rootfs_archive_sha256)))) + ) + error_message = "runtime.rootfs_archive_url and a 64-character runtime.rootfs_archive_sha256 must be supplied together." + } + validation { condition = alltrue([ - for name, app in var.runtime.compose.projects : - can(regex("^[a-z][a-z0-9-]*$", name)) && - trimspace(app.docker_compose_repo) != "" && - try(app.ingress_port, var.runtime.compose.ingress_port) > 0 && - try(app.ingress_port, var.runtime.compose.ingress_port) <= 65535 + for name in keys(var.runtime.extra_env) : + can(regex("^[A-Za-z_][A-Za-z0-9_]*$", name)) && + !contains(["HOME", "PATH"], name) && + alltrue([ + for prefix in [ + "CLOUD_COMPOSE_", + "COMPOSE_", + "DOCKER_", + "SITECTL_", + "LIBOPS_", + "GCP_", + "VAULT_", + "ROLLOUT_", + "POWER_MANAGEMENT_", + ] : !startswith(name, prefix) + ]) ]) - error_message = "runtime.compose.projects keys must match ^[a-z][a-z0-9-]*$, docker_compose_repo is required, and ingress_port must be between 1 and 65535." + error_message = "runtime.extra_env names must be valid environment names and must not override cloud-compose control-plane keys (HOME, PATH, or CLOUD_COMPOSE_/COMPOSE_/DOCKER_/SITECTL_/LIBOPS_/GCP_/VAULT_/ROLLOUT_/POWER_MANAGEMENT_ prefixes)." + } + + validation { + condition = ( + var.runtime.compose.ingress_port >= 1 && + var.runtime.compose.ingress_port <= 65535 && + floor(var.runtime.compose.ingress_port) == var.runtime.compose.ingress_port && + alltrue([ + for name, app in var.runtime.compose.projects : + can(regex("^[a-z][a-z0-9-]*$", name)) && + trimspace(app.docker_compose_repo) != "" && + coalesce(try(app.ingress_port, null), var.runtime.compose.ingress_port) >= 1 && + coalesce(try(app.ingress_port, null), var.runtime.compose.ingress_port) <= 65535 && + floor(coalesce(try(app.ingress_port, null), var.runtime.compose.ingress_port)) == coalesce(try(app.ingress_port, null), var.runtime.compose.ingress_port) + ]) + ) + error_message = "runtime.compose.projects keys must match ^[a-z][a-z0-9-]*$, docker_compose_repo is required, and ingress ports must be whole numbers between 1 and 65535." } validation { condition = alltrue([ - for package in var.runtime.sitectl.packages : + for package in coalesce(var.runtime.sitectl.packages, []) : can(regex("^sitectl(-[a-z0-9]+)*$", package)) ]) error_message = "runtime.sitectl.packages entries must be release package names such as sitectl, sitectl-isle, or sitectl-wp." } validation { - condition = var.runtime.sitectl.version == "latest" || can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+", var.runtime.sitectl.version)) - error_message = "runtime.sitectl.version must be latest or a release tag such as v0.19.7." + condition = ( + var.runtime.sitectl.version == "latest" || + can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$", var.runtime.sitectl.version)) + ) + error_message = "runtime.sitectl.version must be latest or an exact semantic-version release tag such as v0.38.0." + } + + validation { + condition = alltrue([ + for package, version in var.runtime.sitectl.package_versions : + can(regex("^sitectl(-[a-z0-9]+)*$", package)) && ( + version == "latest" || + can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$", version)) + ) + ]) + error_message = "runtime.sitectl.package_versions keys must be sitectl package names and values must be latest or exact semantic-version release tags." } validation { - condition = contains(["gcp-iam", "consumer-managed"], var.runtime.vault.auth_method) - error_message = "runtime.vault.auth_method must be gcp-iam or consumer-managed." + condition = contains(["auto", "gcp-iam", "consumer-managed"], var.runtime.vault.auth_method) + error_message = "runtime.vault.auth_method must be auto, gcp-iam, or consumer-managed." } validation {