commit 3bf59603023aa16823d3f6e7de1f7c394ce2950e Author: Lutz Finsterle Date: Fri Mar 6 19:17:15 2026 +0100 Initial Commit diff --git a/.gitea/workflows/deploy-k8s.yml b/.gitea/workflows/deploy-k8s.yml new file mode 100644 index 0000000..9068b9a --- /dev/null +++ b/.gitea/workflows/deploy-k8s.yml @@ -0,0 +1,215 @@ +name: Deploy / Update K8s Apps + +on: + push: + branches: + - main + paths: + - "k8s/apps/**.tfvars" + +env: + TOFU_VERSION: "1.9.0" + TOFU_WORKING_DIR: "k8s" + +jobs: + detect-changes: + name: Detect changed K8s tfvars + runs-on: ubuntu-latest + outputs: + added_modified: ${{ steps.diff.outputs.added_modified }} + deleted: ${{ steps.diff.outputs.deleted }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Compute diff + id: diff + run: | + ADDED_MODIFIED=$(git diff --name-only --diff-filter=ACM HEAD~1 HEAD -- 'k8s/apps/*.tfvars' | jq -R -s -c 'split("\n") | map(select(length > 0))') + DELETED=$(git diff --name-only --diff-filter=D HEAD~1 HEAD -- 'k8s/apps/*.tfvars' | jq -R -s -c 'split("\n") | map(select(length > 0))') + echo "added_modified=$ADDED_MODIFIED" >> "$GITHUB_OUTPUT" + echo "deleted=$DELETED" >> "$GITHUB_OUTPUT" + + # ─── Deploy / Update ────────────────────────────────────────────────────── + deploy: + name: Deploy ${{ matrix.tfvars }} + needs: detect-changes + if: ${{ needs.detect-changes.outputs.added_modified != '[]' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + tfvars: ${{ fromJson(needs.detect-changes.outputs.added_modified) }} + + steps: + - uses: actions/checkout@v4 + + - name: Install OpenTofu + run: | + curl -fsSL https://get.opentofu.org/install-opentofu.sh | sh -s -- --install-method=standalone --opentofu-version=${{ env.TOFU_VERSION }} + + - name: Install gopass + run: | + curl -fsSL https://github.com/gopasspw/gopass/releases/latest/download/gopass-linux-amd64.tar.gz | tar xz + sudo mv gopass /usr/local/bin/ + + - name: Configure gopass + env: + GOPASS_GPG_KEY: ${{ secrets.GOPASS_GPG_KEY }} + GOPASS_STORE_REPO: ${{ secrets.GOPASS_STORE_REPO }} + run: | + echo "$GOPASS_GPG_KEY" | gpg --batch --import + gopass clone "$GOPASS_STORE_REPO" + + - name: Resolve app name from tfvars filename + id: app + run: | + APP=$(basename "${{ matrix.tfvars }}" .tfvars) + echo "name=$APP" >> "$GITHUB_OUTPUT" + + - name: Fetch kubeconfig from gopass + run: | + # Adjust the gopass path to match your store layout. + # The cluster name can be embedded in the tfvars or derived from the app name. + gopass show -o "infra/kubeconfigs/${{ steps.app.outputs.name }}" > /tmp/kubeconfig + chmod 600 /tmp/kubeconfig + + - name: Fetch secrets from gopass + id: secrets + run: | + RABBITMQ_PASSWORD=$(gopass show -o "apps/${{ steps.app.outputs.name }}/rabbitmq_password" 2>/dev/null || echo "") + LOKI_AUTH_TOKEN=$(gopass show -o "apps/${{ steps.app.outputs.name }}/loki_token" 2>/dev/null || echo "") + echo "::add-mask::$RABBITMQ_PASSWORD" + echo "::add-mask::$LOKI_AUTH_TOKEN" + echo "rabbitmq_password=$RABBITMQ_PASSWORD" >> "$GITHUB_OUTPUT" + echo "loki_auth_token=$LOKI_AUTH_TOKEN" >> "$GITHUB_OUTPUT" + + - name: tofu init + working-directory: ${{ env.TOFU_WORKING_DIR }} + env: + AWS_ACCESS_KEY_ID: ${{ secrets.SEAWEED_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.SEAWEED_SECRET_KEY }} + run: | + tofu init \ + -backend-config="bucket=tofu-state" \ + -backend-config="key=apps-k8s/${{ steps.app.outputs.name }}.tfstate" \ + -backend-config="endpoint=${{ secrets.SEAWEED_S3_ENDPOINT }}" \ + -backend-config="region=us-east-1" \ + -backend-config="force_path_style=true" + + - name: Select or create workspace + working-directory: ${{ env.TOFU_WORKING_DIR }} + run: | + tofu workspace select "${{ steps.app.outputs.name }}" \ + || tofu workspace new "${{ steps.app.outputs.name }}" + + - name: tofu apply + working-directory: ${{ env.TOFU_WORKING_DIR }} + env: + AWS_ACCESS_KEY_ID: ${{ secrets.SEAWEED_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.SEAWEED_SECRET_KEY }} + run: | + tofu apply -auto-approve \ + -var-file="../${{ matrix.tfvars }}" \ + -var="kubeconfig_path=/tmp/kubeconfig" \ + -var="rabbitmq_password=${{ steps.secrets.outputs.rabbitmq_password }}" \ + -var="loki_auth_token=${{ steps.secrets.outputs.loki_auth_token }}" + + - name: Cleanup + if: always() + run: rm -f /tmp/kubeconfig + + # ─── Destroy (tfvars file deleted) ──────────────────────────────────────── + destroy: + name: Destroy ${{ matrix.tfvars }} + needs: detect-changes + if: ${{ needs.detect-changes.outputs.deleted != '[]' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + tfvars: ${{ fromJson(needs.detect-changes.outputs.deleted) }} + + steps: + - uses: actions/checkout@v4 + with: + # Checkout previous commit to recover the deleted tfvars file + ref: ${{ github.event.before }} + + - name: Install OpenTofu + run: | + curl -fsSL https://get.opentofu.org/install-opentofu.sh | sh -s -- --install-method=standalone --opentofu-version=${{ env.TOFU_VERSION }} + + - name: Install gopass + run: | + curl -fsSL https://github.com/gopasspw/gopass/releases/latest/download/gopass-linux-amd64.tar.gz | tar xz + sudo mv gopass /usr/local/bin/ + + - name: Configure gopass + env: + GOPASS_GPG_KEY: ${{ secrets.GOPASS_GPG_KEY }} + GOPASS_STORE_REPO: ${{ secrets.GOPASS_STORE_REPO }} + run: | + echo "$GOPASS_GPG_KEY" | gpg --batch --import + gopass clone "$GOPASS_STORE_REPO" + + - name: Resolve app name + id: app + run: | + APP=$(basename "${{ matrix.tfvars }}" .tfvars) + echo "name=$APP" >> "$GITHUB_OUTPUT" + + - name: Fetch kubeconfig from gopass + run: | + gopass show -o "infra/kubeconfigs/${{ steps.app.outputs.name }}" > /tmp/kubeconfig + chmod 600 /tmp/kubeconfig + + - name: Fetch secrets from gopass + id: secrets + run: | + RABBITMQ_PASSWORD=$(gopass show -o "apps/${{ steps.app.outputs.name }}/rabbitmq_password" 2>/dev/null || echo "") + LOKI_AUTH_TOKEN=$(gopass show -o "apps/${{ steps.app.outputs.name }}/loki_token" 2>/dev/null || echo "") + echo "::add-mask::$RABBITMQ_PASSWORD" + echo "::add-mask::$LOKI_AUTH_TOKEN" + echo "rabbitmq_password=$RABBITMQ_PASSWORD" >> "$GITHUB_OUTPUT" + echo "loki_auth_token=$LOKI_AUTH_TOKEN" >> "$GITHUB_OUTPUT" + + - name: tofu init + working-directory: ${{ env.TOFU_WORKING_DIR }} + env: + AWS_ACCESS_KEY_ID: ${{ secrets.SEAWEED_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.SEAWEED_SECRET_KEY }} + run: | + tofu init \ + -backend-config="bucket=tofu-state" \ + -backend-config="key=apps-k8s/${{ steps.app.outputs.name }}.tfstate" \ + -backend-config="endpoint=${{ secrets.SEAWEED_S3_ENDPOINT }}" \ + -backend-config="region=us-east-1" \ + -backend-config="force_path_style=true" + + - name: Select workspace + working-directory: ${{ env.TOFU_WORKING_DIR }} + run: tofu workspace select "${{ steps.app.outputs.name }}" + + - name: tofu destroy + working-directory: ${{ env.TOFU_WORKING_DIR }} + env: + AWS_ACCESS_KEY_ID: ${{ secrets.SEAWEED_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.SEAWEED_SECRET_KEY }} + run: | + tofu destroy -auto-approve \ + -var-file="../${{ matrix.tfvars }}" \ + -var="kubeconfig_path=/tmp/kubeconfig" \ + -var="rabbitmq_password=${{ steps.secrets.outputs.rabbitmq_password }}" \ + -var="loki_auth_token=${{ steps.secrets.outputs.loki_auth_token }}" + + - name: Delete workspace + working-directory: ${{ env.TOFU_WORKING_DIR }} + run: | + tofu workspace select default + tofu workspace delete "${{ steps.app.outputs.name }}" + + - name: Cleanup + if: always() + run: rm -f /tmp/kubeconfig diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..fbcf142 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,208 @@ +name: Deploy / Update Apps + +on: + push: + branches: + - main + paths: + - "apps/**.tfvars" + +env: + TOFU_VERSION: "1.9.0" + +jobs: + detect-changes: + name: Detect changed tfvars files + runs-on: ubuntu-latest + outputs: + added_modified: ${{ steps.diff.outputs.added_modified }} + deleted: ${{ steps.diff.outputs.deleted }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Compute diff + id: diff + run: | + ADDED_MODIFIED=$(git diff --name-only --diff-filter=ACM HEAD~1 HEAD -- 'apps/*.tfvars' | jq -R -s -c 'split("\n") | map(select(length > 0))') + DELETED=$(git diff --name-only --diff-filter=D HEAD~1 HEAD -- 'apps/*.tfvars' | jq -R -s -c 'split("\n") | map(select(length > 0))') + echo "added_modified=$ADDED_MODIFIED" >> "$GITHUB_OUTPUT" + echo "deleted=$DELETED" >> "$GITHUB_OUTPUT" + + # ─── Deploy / Update ────────────────────────────────────────────────────── + deploy: + name: Deploy ${{ matrix.tfvars }} + needs: detect-changes + if: ${{ needs.detect-changes.outputs.added_modified != '[]' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + tfvars: ${{ fromJson(needs.detect-changes.outputs.added_modified) }} + + steps: + - uses: actions/checkout@v4 + + - name: Install OpenTofu + run: | + curl -fsSL https://get.opentofu.org/install-opentofu.sh | sh -s -- --install-method=standalone --opentofu-version=${{ env.TOFU_VERSION }} + + - name: Install gopass + run: | + curl -fsSL https://github.com/gopasspw/gopass/releases/latest/download/gopass-linux-amd64.tar.gz | tar xz + sudo mv gopass /usr/local/bin/ + + - name: Configure gopass + env: + GOPASS_GPG_KEY: ${{ secrets.GOPASS_GPG_KEY }} + GOPASS_STORE_REPO: ${{ secrets.GOPASS_STORE_REPO }} + run: | + echo "$GOPASS_GPG_KEY" | gpg --batch --import + gopass clone "$GOPASS_STORE_REPO" + + - name: Resolve app name from tfvars filename + id: app + run: | + TFVARS="${{ matrix.tfvars }}" + APP=$(basename "$TFVARS" .tfvars) + echo "name=$APP" >> "$GITHUB_OUTPUT" + + - name: Fetch SSH key from gopass + run: | + # Adjust the gopass path to match your store layout + gopass show -o "infra/ssh-keys/${{ steps.app.outputs.name }}" > /tmp/deploy_key + chmod 600 /tmp/deploy_key + + - name: Fetch secrets from gopass + id: secrets + run: | + DB_PASSWORD=$(gopass show -o "apps/${{ steps.app.outputs.name }}/db_password") + GIT_TOKEN=$(gopass show -o "apps/${{ steps.app.outputs.name }}/git_token" 2>/dev/null || echo "") + echo "::add-mask::$DB_PASSWORD" + echo "::add-mask::$GIT_TOKEN" + echo "db_password=$DB_PASSWORD" >> "$GITHUB_OUTPUT" + echo "git_token=$GIT_TOKEN" >> "$GITHUB_OUTPUT" + + - name: tofu init + env: + # SeaweedFS S3 credentials (set in Gitea repo secrets) + AWS_ACCESS_KEY_ID: ${{ secrets.SEAWEED_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.SEAWEED_SECRET_KEY }} + run: | + tofu init \ + -backend-config="bucket=tofu-state" \ + -backend-config="key=apps/${{ steps.app.outputs.name }}.tfstate" \ + -backend-config="endpoint=${{ secrets.SEAWEED_S3_ENDPOINT }}" \ + -backend-config="region=us-east-1" \ + -backend-config="force_path_style=true" + + - name: Select or create workspace + run: | + tofu workspace select "${{ steps.app.outputs.name }}" \ + || tofu workspace new "${{ steps.app.outputs.name }}" + + - name: tofu apply + env: + AWS_ACCESS_KEY_ID: ${{ secrets.SEAWEED_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.SEAWEED_SECRET_KEY }} + run: | + tofu apply -auto-approve \ + -var-file="${{ matrix.tfvars }}" \ + -var="ssh_key_path=/tmp/deploy_key" \ + -var="db_password=${{ steps.secrets.outputs.db_password }}" \ + -var="openresty_git_token=${{ steps.secrets.outputs.git_token }}" + + - name: Cleanup SSH key + if: always() + run: rm -f /tmp/deploy_key + + # ─── Destroy (tfvars file deleted) ──────────────────────────────────────── + destroy: + name: Destroy ${{ matrix.tfvars }} + needs: detect-changes + if: ${{ needs.detect-changes.outputs.deleted != '[]' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + tfvars: ${{ fromJson(needs.detect-changes.outputs.deleted) }} + + steps: + - uses: actions/checkout@v4 + with: + # Checkout the previous commit so we still have the tfvars file + ref: ${{ github.event.before }} + + - name: Install OpenTofu + run: | + curl -fsSL https://get.opentofu.org/install-opentofu.sh | sh -s -- --install-method=standalone --opentofu-version=${{ env.TOFU_VERSION }} + + - name: Install gopass + run: | + curl -fsSL https://github.com/gopasspw/gopass/releases/latest/download/gopass-linux-amd64.tar.gz | tar xz + sudo mv gopass /usr/local/bin/ + + - name: Configure gopass + env: + GOPASS_GPG_KEY: ${{ secrets.GOPASS_GPG_KEY }} + GOPASS_STORE_REPO: ${{ secrets.GOPASS_STORE_REPO }} + run: | + echo "$GOPASS_GPG_KEY" | gpg --batch --import + gopass clone "$GOPASS_STORE_REPO" + + - name: Resolve app name + id: app + run: | + APP=$(basename "${{ matrix.tfvars }}" .tfvars) + echo "name=$APP" >> "$GITHUB_OUTPUT" + + - name: Fetch SSH key from gopass + run: | + gopass show -o "infra/ssh-keys/${{ steps.app.outputs.name }}" > /tmp/deploy_key + chmod 600 /tmp/deploy_key + + - name: Fetch secrets from gopass + id: secrets + run: | + DB_PASSWORD=$(gopass show -o "apps/${{ steps.app.outputs.name }}/db_password") + GIT_TOKEN=$(gopass show -o "apps/${{ steps.app.outputs.name }}/git_token" 2>/dev/null || echo "") + echo "::add-mask::$DB_PASSWORD" + echo "::add-mask::$GIT_TOKEN" + echo "db_password=$DB_PASSWORD" >> "$GITHUB_OUTPUT" + echo "git_token=$GIT_TOKEN" >> "$GITHUB_OUTPUT" + + - name: tofu init + env: + AWS_ACCESS_KEY_ID: ${{ secrets.SEAWEED_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.SEAWEED_SECRET_KEY }} + run: | + tofu init \ + -backend-config="bucket=tofu-state" \ + -backend-config="key=apps/${{ steps.app.outputs.name }}.tfstate" \ + -backend-config="endpoint=${{ secrets.SEAWEED_S3_ENDPOINT }}" \ + -backend-config="region=us-east-1" \ + -backend-config="force_path_style=true" + + - name: Select workspace + run: tofu workspace select "${{ steps.app.outputs.name }}" + + - name: tofu destroy + env: + AWS_ACCESS_KEY_ID: ${{ secrets.SEAWEED_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.SEAWEED_SECRET_KEY }} + run: | + tofu destroy -auto-approve \ + -var-file="${{ matrix.tfvars }}" \ + -var="ssh_key_path=/tmp/deploy_key" \ + -var="db_password=${{ steps.secrets.outputs.db_password }}" \ + -var="openresty_git_token=${{ steps.secrets.outputs.git_token }}" + + - name: Delete workspace + run: | + tofu workspace select default + tofu workspace delete "${{ steps.app.outputs.name }}" + + - name: Cleanup SSH key + if: always() + run: rm -f /tmp/deploy_key diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..c276e21 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,172 @@ +name: Test + +on: + push: + branches: + - main + - "feature/**" + pull_request: + branches: + - main + +env: + TOFU_VERSION: "1.9.0" + +jobs: + # ─── Level 1: Static Analysis ───────────────────────────────────────────── + static: + name: Static Analysis + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install OpenTofu + run: | + curl -fsSL https://get.opentofu.org/install-opentofu.sh \ + | sh -s -- --install-method=standalone --opentofu-version=${{ env.TOFU_VERSION }} + + - name: tofu fmt check (all .tf files) + run: tofu fmt -check -recursive + + - name: tofu validate — Docker stack + run: | + tofu init -backend=false -input=false + tofu validate + + - name: tofu validate — K8s stack + run: | + cd k8s + tofu init -backend=false -input=false + tofu validate + + # ─── Level 2: Unit Tests (mocked providers) ─────────────────────────────── + unit-docker: + name: Unit Tests — Docker Stack + runs-on: ubuntu-latest + needs: static + steps: + - uses: actions/checkout@v4 + + - name: Install OpenTofu + run: | + curl -fsSL https://get.opentofu.org/install-opentofu.sh \ + | sh -s -- --install-method=standalone --opentofu-version=${{ env.TOFU_VERSION }} + + - name: tofu test — Docker stack + run: | + tofu init -backend=false -input=false + tofu test + + unit-k8s: + name: Unit Tests — K8s Stack + runs-on: ubuntu-latest + needs: static + steps: + - uses: actions/checkout@v4 + + - name: Install OpenTofu + run: | + curl -fsSL https://get.opentofu.org/install-opentofu.sh \ + | sh -s -- --install-method=standalone --opentofu-version=${{ env.TOFU_VERSION }} + + - name: tofu test — K8s stack + run: | + cd k8s + tofu init -backend=false -input=false + tofu test + + # ─── Level 3: Integration — K8s (k3d) ──────────────────────────────────── + # Runs on push to main only. Creates a real k3d cluster, applies, verifies, + # then destroys. Skipped on PRs to keep feedback fast. + integration-k8s: + name: Integration Test — K8s (k3d) + runs-on: ubuntu-latest + needs: [unit-docker, unit-k8s] + if: github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + + - name: Install OpenTofu + run: | + curl -fsSL https://get.opentofu.org/install-opentofu.sh \ + | sh -s -- --install-method=standalone --opentofu-version=${{ env.TOFU_VERSION }} + + - name: Install k3d + run: | + curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash + + - name: Install kubectl + run: | + curl -fsSL "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" \ + -o /usr/local/bin/kubectl + chmod +x /usr/local/bin/kubectl + + - name: Create k3d cluster (Traefik disabled — we install CRDs manually) + run: | + k3d cluster create tofu-test \ + --agents 1 \ + --k3s-arg "--disable=traefik@server:0" \ + --wait + k3d kubeconfig get tofu-test > /tmp/test-kubeconfig + chmod 600 /tmp/test-kubeconfig + + - name: Install Traefik CRDs + run: | + kubectl --kubeconfig /tmp/test-kubeconfig apply \ + -f https://raw.githubusercontent.com/traefik/traefik/v2.11/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml + kubectl --kubeconfig /tmp/test-kubeconfig wait \ + --for=condition=established --timeout=60s \ + crd/ingressroutes.traefik.io crd/middlewares.traefik.io + + - name: tofu init — K8s stack + working-directory: k8s + run: tofu init -backend=false -input=false + + - name: Select or create workspace + working-directory: k8s + run: | + tofu workspace select integration-test \ + || tofu workspace new integration-test + + - name: tofu apply + working-directory: k8s + run: | + tofu apply -auto-approve \ + -var-file="apps/example-nodered.tfvars" \ + -var="kubeconfig_path=/tmp/test-kubeconfig" \ + -var="app_name=integration-test" \ + -var="rabbitmq_password=testpass-ci" \ + -var="loki_auth_token=" + + - name: Verify K8s resources exist + run: | + NS="integration-test" + KC=/tmp/test-kubeconfig + echo "── Namespace ──" + kubectl --kubeconfig $KC get namespace $NS + echo "── Deployment ──" + kubectl --kubeconfig $KC get deployment -n $NS + echo "── Services ──" + kubectl --kubeconfig $KC get service -n $NS + echo "── PVCs ──" + kubectl --kubeconfig $KC get pvc -n $NS + echo "── IngressRoutes ──" + kubectl --kubeconfig $KC get ingressroute.traefik.io -n $NS 2>/dev/null || \ + kubectl --kubeconfig $KC get ingressroute.traefik.containo.us -n $NS + + - name: tofu destroy + working-directory: k8s + if: always() + run: | + tofu destroy -auto-approve \ + -var-file="apps/example-nodered.tfvars" \ + -var="kubeconfig_path=/tmp/test-kubeconfig" \ + -var="app_name=integration-test" \ + -var="rabbitmq_password=testpass-ci" \ + -var="loki_auth_token=" + + - name: Cleanup + if: always() + run: | + k3d cluster delete tofu-test 2>/dev/null || true + rm -f /tmp/test-kubeconfig diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..6d980db --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,39 @@ +# ───────────────────────────────────────────────────────────────────────────── +# GitLab CI/CD — OpenTofu Playground +# +# Three included pipelines: +# test.gitlab-ci.yml Static analysis + unit tests + K8s integration +# deploy.gitlab-ci.yml Docker stack deploy/destroy via apps/*.tfvars +# deploy-k8s.gitlab-ci.yml K8s stack deploy/destroy via k8s/apps/*.tfvars +# +# Required GitLab CI/CD variables (Settings > CI/CD > Variables): +# TOFU_VERSION OpenTofu version to install (default: 1.9.0) +# GOPASS_GPG_KEY GPG private key (armored) for the gopass store +# GOPASS_STORE_REPO Git URL of the gopass password store +# SEAWEED_S3_ENDPOINT SeaweedFS S3 endpoint, e.g. http://seaweedfs.home:8333 +# SEAWEED_ACCESS_KEY SeaweedFS access key (mark as Masked) +# SEAWEED_SECRET_KEY SeaweedFS secret key (mark as Masked) +# +# gopass store layout expected by the deploy pipelines: +# infra/ssh-keys/ — SSH private key for Docker host +# infra/kubeconfigs/ — kubeconfig for K8s cluster +# apps//db_password — PostgreSQL password +# apps//git_token — optional git token for git_clone mode +# apps//rabbitmq_password — RabbitMQ password (K8s stack) +# apps//loki_token — optional Loki auth token (K8s stack) +# ───────────────────────────────────────────────────────────────────────────── + +stages: + - static + - unit + - integration + - deploy + - destroy + +variables: + TOFU_VERSION: "1.9.0" + +include: + - local: .gitlab/workflows/test.gitlab-ci.yml + - local: .gitlab/workflows/deploy.gitlab-ci.yml + - local: .gitlab/workflows/deploy-k8s.gitlab-ci.yml diff --git a/.gitlab/workflows/deploy-k8s.gitlab-ci.yml b/.gitlab/workflows/deploy-k8s.gitlab-ci.yml new file mode 100644 index 0000000..d972dcb --- /dev/null +++ b/.gitlab/workflows/deploy-k8s.gitlab-ci.yml @@ -0,0 +1,198 @@ +# ───────────────────────────────────────────────────────────────────────────── +# K8s stack deploy pipeline — mirrors .gitea/workflows/deploy-k8s.yml +# +# Triggered on pushes to main when files under k8s/apps/*.tfvars change. +# Two jobs run in sequence: +# deploy-k8s — loops over added/modified tfvars and applies each app +# destroy-k8s — loops over deleted tfvars, recovers content from git +# history, destroys each app and removes its workspace +# +# gopass paths used: +# infra/kubeconfigs/ — kubeconfig for the target cluster +# apps//rabbitmq_password — RabbitMQ admin password +# apps//loki_token — optional Loki auth bearer token +# ───────────────────────────────────────────────────────────────────────────── + +# ─── Shared template: OpenTofu + gopass setup ──────────────────────────────── +.deploy_k8s_base: + image: ubuntu:22.04 + variables: + GIT_DEPTH: "0" + TOFU_WORKING_DIR: "k8s" + before_script: + - apt-get update -qq && apt-get install -y -qq curl git jq gnupg + - | + curl -fsSL https://get.opentofu.org/install-opentofu.sh \ + | sh -s -- --install-method=standalone --opentofu-version=$TOFU_VERSION + - | + curl -fsSL https://github.com/gopasspw/gopass/releases/latest/download/gopass-linux-amd64.tar.gz \ + | tar xz + mv gopass /usr/local/bin/ + - | + echo "$GOPASS_GPG_KEY" | gpg --batch --import + gopass clone "$GOPASS_STORE_REPO" + +# ─── Deploy / Update ───────────────────────────────────────────────────────── +deploy-k8s: + extends: .deploy_k8s_base + stage: deploy + rules: + - if: '$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"' + changes: + - k8s/apps/*.tfvars + script: + - | + ADDED_MODIFIED=$(git diff --name-only --diff-filter=ACM \ + "$CI_COMMIT_BEFORE_SHA" "$CI_COMMIT_SHA" -- 'k8s/apps/*.tfvars') + + if [ -z "$ADDED_MODIFIED" ]; then + echo "No K8s tfvars files added or modified. Nothing to deploy." + exit 0 + fi + + echo "Files to deploy: $ADDED_MODIFIED" + FAILED=0 + + for TFVARS in $ADDED_MODIFIED; do + APP=$(basename "$TFVARS" .tfvars) + echo "" + echo "══════════════════════════════════════════" + echo " Deploying K8s app: $APP" + echo "══════════════════════════════════════════" + + if ! ( + set -e + + # ── Fetch kubeconfig from gopass ── + gopass show -o "infra/kubeconfigs/$APP" > /tmp/kubeconfig + chmod 600 /tmp/kubeconfig + + # ── Fetch app secrets from gopass ── + RABBITMQ_PASSWORD=$(gopass show -o "apps/$APP/rabbitmq_password" 2>/dev/null || echo "") + LOKI_AUTH_TOKEN=$(gopass show -o "apps/$APP/loki_token" 2>/dev/null || echo "") + + # ── tofu init with SeaweedFS backend ── + export AWS_ACCESS_KEY_ID="$SEAWEED_ACCESS_KEY" + export AWS_SECRET_ACCESS_KEY="$SEAWEED_SECRET_KEY" + + cd "$TOFU_WORKING_DIR" + tofu init \ + -backend-config="bucket=tofu-state" \ + -backend-config="key=apps-k8s/$APP.tfstate" \ + -backend-config="endpoint=$SEAWEED_S3_ENDPOINT" \ + -backend-config="region=us-east-1" \ + -backend-config="force_path_style=true" \ + -reconfigure + + # ── Workspace ── + tofu workspace select "$APP" 2>/dev/null || tofu workspace new "$APP" + + # ── Apply ── + tofu apply -auto-approve \ + -var-file="../$TFVARS" \ + -var="kubeconfig_path=/tmp/kubeconfig" \ + -var="rabbitmq_password=$RABBITMQ_PASSWORD" \ + -var="loki_auth_token=$LOKI_AUTH_TOKEN" + + rm -f /tmp/kubeconfig + echo " Deployed K8s app: $APP" + ); then + echo "ERROR: Deployment of K8s app $APP failed — continuing with remaining apps" + FAILED=1 + rm -f /tmp/kubeconfig + fi + done + + if [ $FAILED -ne 0 ]; then + echo "" + echo "One or more K8s deployments failed. See logs above for details." + exit 1 + fi + after_script: + - rm -f /tmp/kubeconfig + +# ─── Destroy (tfvars file deleted) ─────────────────────────────────────────── +destroy-k8s: + extends: .deploy_k8s_base + stage: destroy + rules: + - if: '$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"' + changes: + - k8s/apps/*.tfvars + script: + - | + DELETED=$(git diff --name-only --diff-filter=D \ + "$CI_COMMIT_BEFORE_SHA" "$CI_COMMIT_SHA" -- 'k8s/apps/*.tfvars') + + if [ -z "$DELETED" ]; then + echo "No K8s tfvars files deleted. Nothing to destroy." + exit 0 + fi + + echo "Files to destroy: $DELETED" + FAILED=0 + + for TFVARS in $DELETED; do + APP=$(basename "$TFVARS" .tfvars) + echo "" + echo "══════════════════════════════════════════" + echo " Destroying K8s app: $APP" + echo "══════════════════════════════════════════" + + if ! ( + set -e + + # ── Recover deleted tfvars from git history ── + git show "$CI_COMMIT_BEFORE_SHA:$TFVARS" > /tmp/${APP}.tfvars + + # ── Fetch kubeconfig from gopass ── + gopass show -o "infra/kubeconfigs/$APP" > /tmp/kubeconfig + chmod 600 /tmp/kubeconfig + + # ── Fetch app secrets from gopass ── + RABBITMQ_PASSWORD=$(gopass show -o "apps/$APP/rabbitmq_password" 2>/dev/null || echo "") + LOKI_AUTH_TOKEN=$(gopass show -o "apps/$APP/loki_token" 2>/dev/null || echo "") + + # ── tofu init ── + export AWS_ACCESS_KEY_ID="$SEAWEED_ACCESS_KEY" + export AWS_SECRET_ACCESS_KEY="$SEAWEED_SECRET_KEY" + + cd "$TOFU_WORKING_DIR" + tofu init \ + -backend-config="bucket=tofu-state" \ + -backend-config="key=apps-k8s/$APP.tfstate" \ + -backend-config="endpoint=$SEAWEED_S3_ENDPOINT" \ + -backend-config="region=us-east-1" \ + -backend-config="force_path_style=true" \ + -reconfigure + + # ── Select existing workspace ── + tofu workspace select "$APP" + + # ── Destroy ── + tofu destroy -auto-approve \ + -var-file="/tmp/${APP}.tfvars" \ + -var="kubeconfig_path=/tmp/kubeconfig" \ + -var="rabbitmq_password=$RABBITMQ_PASSWORD" \ + -var="loki_auth_token=$LOKI_AUTH_TOKEN" + + # ── Remove workspace ── + tofu workspace select default + tofu workspace delete "$APP" + + rm -f /tmp/${APP}.tfvars /tmp/kubeconfig + echo " Destroyed K8s app: $APP" + ); then + echo "ERROR: Destroy of K8s app $APP failed — continuing with remaining apps" + FAILED=1 + rm -f /tmp/${APP}.tfvars /tmp/kubeconfig + fi + done + + if [ $FAILED -ne 0 ]; then + echo "" + echo "One or more K8s destroy operations failed. See logs above for details." + exit 1 + fi + after_script: + - rm -f /tmp/kubeconfig /tmp/*.tfvars diff --git a/.gitlab/workflows/deploy.gitlab-ci.yml b/.gitlab/workflows/deploy.gitlab-ci.yml new file mode 100644 index 0000000..1bf7891 --- /dev/null +++ b/.gitlab/workflows/deploy.gitlab-ci.yml @@ -0,0 +1,202 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Docker stack deploy pipeline — mirrors .gitea/workflows/deploy.yml +# +# Triggered on pushes to main when files under apps/*.tfvars change. +# Two jobs run in sequence: +# deploy-docker — loops over added/modified tfvars and applies each app +# destroy-docker — loops over deleted tfvars, recovers content from git +# history, destroys each app and removes its workspace +# +# Both jobs process all changed files sequentially (GitLab CI does not support +# dynamic matrix jobs natively). A failure in one app is recorded but processing +# continues for the remaining apps (fail-fast: false equivalent). +# +# Key difference from Gitea: deleted tfvars content is recovered via +# git show $CI_COMMIT_BEFORE_SHA:$TFVARS +# instead of checking out the previous commit. +# ───────────────────────────────────────────────────────────────────────────── + +# ─── Shared template: OpenTofu + gopass setup ──────────────────────────────── +.deploy_base: + image: ubuntu:22.04 + variables: + # Full clone so git show $CI_COMMIT_BEFORE_SHA works for deleted files + GIT_DEPTH: "0" + before_script: + - apt-get update -qq && apt-get install -y -qq curl git jq gnupg + - | + curl -fsSL https://get.opentofu.org/install-opentofu.sh \ + | sh -s -- --install-method=standalone --opentofu-version=$TOFU_VERSION + - | + curl -fsSL https://github.com/gopasspw/gopass/releases/latest/download/gopass-linux-amd64.tar.gz \ + | tar xz + mv gopass /usr/local/bin/ + - | + echo "$GOPASS_GPG_KEY" | gpg --batch --import + gopass clone "$GOPASS_STORE_REPO" + +# ─── Deploy / Update ───────────────────────────────────────────────────────── +deploy-docker: + extends: .deploy_base + stage: deploy + rules: + - if: '$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"' + changes: + - apps/*.tfvars + script: + - | + ADDED_MODIFIED=$(git diff --name-only --diff-filter=ACM \ + "$CI_COMMIT_BEFORE_SHA" "$CI_COMMIT_SHA" -- 'apps/*.tfvars') + + if [ -z "$ADDED_MODIFIED" ]; then + echo "No Docker tfvars files added or modified. Nothing to deploy." + exit 0 + fi + + echo "Files to deploy: $ADDED_MODIFIED" + FAILED=0 + + for TFVARS in $ADDED_MODIFIED; do + APP=$(basename "$TFVARS" .tfvars) + echo "" + echo "══════════════════════════════════════════" + echo " Deploying: $APP" + echo "══════════════════════════════════════════" + + if ! ( + set -e + + # ── Fetch SSH key from gopass ── + gopass show -o "infra/ssh-keys/$APP" > /tmp/deploy_key + chmod 600 /tmp/deploy_key + + # ── Fetch app secrets from gopass ── + DB_PASSWORD=$(gopass show -o "apps/$APP/db_password") + GIT_TOKEN=$(gopass show -o "apps/$APP/git_token" 2>/dev/null || echo "") + + # ── tofu init with SeaweedFS backend ── + export AWS_ACCESS_KEY_ID="$SEAWEED_ACCESS_KEY" + export AWS_SECRET_ACCESS_KEY="$SEAWEED_SECRET_KEY" + + tofu init \ + -backend-config="bucket=tofu-state" \ + -backend-config="key=apps/$APP.tfstate" \ + -backend-config="endpoint=$SEAWEED_S3_ENDPOINT" \ + -backend-config="region=us-east-1" \ + -backend-config="force_path_style=true" \ + -reconfigure + + # ── Workspace ── + tofu workspace select "$APP" 2>/dev/null || tofu workspace new "$APP" + + # ── Apply ── + tofu apply -auto-approve \ + -var-file="$TFVARS" \ + -var="ssh_key_path=/tmp/deploy_key" \ + -var="db_password=$DB_PASSWORD" \ + -var="openresty_git_token=$GIT_TOKEN" + + rm -f /tmp/deploy_key + echo " Deployed: $APP" + ); then + echo "ERROR: Deployment of $APP failed — continuing with remaining apps" + FAILED=1 + rm -f /tmp/deploy_key + fi + done + + if [ $FAILED -ne 0 ]; then + echo "" + echo "One or more deployments failed. See logs above for details." + exit 1 + fi + after_script: + # Safety cleanup in case the job was interrupted + - rm -f /tmp/deploy_key + +# ─── Destroy (tfvars file deleted) ─────────────────────────────────────────── +destroy-docker: + extends: .deploy_base + stage: destroy + rules: + - if: '$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"' + changes: + - apps/*.tfvars + script: + - | + DELETED=$(git diff --name-only --diff-filter=D \ + "$CI_COMMIT_BEFORE_SHA" "$CI_COMMIT_SHA" -- 'apps/*.tfvars') + + if [ -z "$DELETED" ]; then + echo "No Docker tfvars files deleted. Nothing to destroy." + exit 0 + fi + + echo "Files to destroy: $DELETED" + FAILED=0 + + for TFVARS in $DELETED; do + APP=$(basename "$TFVARS" .tfvars) + echo "" + echo "══════════════════════════════════════════" + echo " Destroying: $APP" + echo "══════════════════════════════════════════" + + if ! ( + set -e + + # ── Recover deleted tfvars from git history ── + # No need to check out the previous commit — git show reads it directly. + git show "$CI_COMMIT_BEFORE_SHA:$TFVARS" > /tmp/${APP}.tfvars + + # ── Fetch SSH key from gopass ── + gopass show -o "infra/ssh-keys/$APP" > /tmp/deploy_key + chmod 600 /tmp/deploy_key + + # ── Fetch app secrets from gopass ── + DB_PASSWORD=$(gopass show -o "apps/$APP/db_password") + GIT_TOKEN=$(gopass show -o "apps/$APP/git_token" 2>/dev/null || echo "") + + # ── tofu init ── + export AWS_ACCESS_KEY_ID="$SEAWEED_ACCESS_KEY" + export AWS_SECRET_ACCESS_KEY="$SEAWEED_SECRET_KEY" + + tofu init \ + -backend-config="bucket=tofu-state" \ + -backend-config="key=apps/$APP.tfstate" \ + -backend-config="endpoint=$SEAWEED_S3_ENDPOINT" \ + -backend-config="region=us-east-1" \ + -backend-config="force_path_style=true" \ + -reconfigure + + # ── Select existing workspace ── + tofu workspace select "$APP" + + # ── Destroy ── + tofu destroy -auto-approve \ + -var-file="/tmp/${APP}.tfvars" \ + -var="ssh_key_path=/tmp/deploy_key" \ + -var="db_password=$DB_PASSWORD" \ + -var="openresty_git_token=$GIT_TOKEN" + + # ── Remove workspace ── + tofu workspace select default + tofu workspace delete "$APP" + + rm -f /tmp/${APP}.tfvars /tmp/deploy_key + echo " Destroyed: $APP" + ); then + echo "ERROR: Destroy of $APP failed — continuing with remaining apps" + FAILED=1 + rm -f /tmp/${APP}.tfvars /tmp/deploy_key + fi + done + + if [ $FAILED -ne 0 ]; then + echo "" + echo "One or more destroy operations failed. See logs above for details." + exit 1 + fi + after_script: + # Safety cleanup in case the job was interrupted + - rm -f /tmp/deploy_key /tmp/*.tfvars diff --git a/.gitlab/workflows/test.gitlab-ci.yml b/.gitlab/workflows/test.gitlab-ci.yml new file mode 100644 index 0000000..050753c --- /dev/null +++ b/.gitlab/workflows/test.gitlab-ci.yml @@ -0,0 +1,144 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Test pipeline — mirrors .gitea/workflows/test.yml +# +# Level 1 (static) — fmt check + validate, all MRs and feature branches +# Level 2 (unit) — tofu test with mock_provider, all MRs and branches +# Level 3 (integration) — real k3d cluster apply/destroy, main branch only +# ───────────────────────────────────────────────────────────────────────────── + +# ─── Shared template: installs OpenTofu and common tools ───────────────────── +.tofu_setup: + image: ubuntu:22.04 + before_script: + - apt-get update -qq && apt-get install -y -qq curl git jq + - | + curl -fsSL https://get.opentofu.org/install-opentofu.sh \ + | sh -s -- --install-method=standalone --opentofu-version=$TOFU_VERSION + +# ─── Level 1: Static Analysis ──────────────────────────────────────────────── +static-analysis: + extends: .tofu_setup + stage: static + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + - if: '$CI_COMMIT_BRANCH == "main"' + - if: '$CI_COMMIT_BRANCH =~ /^feature\//' + script: + - tofu fmt -check -recursive + - | + echo "── Validating Docker stack ──" + tofu init -backend=false -input=false + tofu validate + - | + echo "── Validating K8s stack ──" + cd k8s + tofu init -backend=false -input=false + tofu validate + +# ─── Level 2: Unit Tests (mocked providers) ────────────────────────────────── +unit-tests-docker: + extends: .tofu_setup + stage: unit + needs: [static-analysis] + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + - if: '$CI_COMMIT_BRANCH == "main"' + - if: '$CI_COMMIT_BRANCH =~ /^feature\//' + script: + - echo "── Docker stack unit tests ──" + - tofu init -backend=false -input=false + - tofu test + +unit-tests-k8s: + extends: .tofu_setup + stage: unit + needs: [static-analysis] + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + - if: '$CI_COMMIT_BRANCH == "main"' + - if: '$CI_COMMIT_BRANCH =~ /^feature\//' + script: + - echo "── K8s stack unit tests ──" + - cd k8s + - tofu init -backend=false -input=false + - tofu test + +# ─── Level 3: Integration Test — K8s (k3d) ─────────────────────────────────── +# Runs only on push to main. Creates a real k3d cluster, applies the example +# app, verifies resources exist, then destroys. Skipped on MRs for fast feedback. +integration-k8s: + extends: .tofu_setup + stage: integration + needs: [unit-tests-docker, unit-tests-k8s] + rules: + - if: '$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"' + # Full clone needed so git show works for the integration workspace + variables: + GIT_DEPTH: "0" + script: + - | + echo "── Installing k3d ──" + curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash + - | + echo "── Installing kubectl ──" + curl -fsSL \ + "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" \ + -o /usr/local/bin/kubectl + chmod +x /usr/local/bin/kubectl + - | + echo "── Creating k3d cluster (Traefik disabled — CRDs installed manually) ──" + k3d cluster create tofu-test \ + --agents 1 \ + --k3s-arg "--disable=traefik@server:0" \ + --wait + k3d kubeconfig get tofu-test > /tmp/test-kubeconfig + chmod 600 /tmp/test-kubeconfig + - | + echo "── Installing Traefik CRDs ──" + kubectl --kubeconfig /tmp/test-kubeconfig apply \ + -f https://raw.githubusercontent.com/traefik/traefik/v2.11/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml + kubectl --kubeconfig /tmp/test-kubeconfig wait \ + --for=condition=established --timeout=60s \ + crd/ingressroutes.traefik.io crd/middlewares.traefik.io + - | + echo "── tofu init + workspace ──" + cd k8s + tofu init -backend=false -input=false + tofu workspace select integration-test 2>/dev/null \ + || tofu workspace new integration-test + - | + echo "── tofu apply ──" + cd k8s + tofu apply -auto-approve \ + -var-file="apps/example-nodered.tfvars" \ + -var="kubeconfig_path=/tmp/test-kubeconfig" \ + -var="app_name=integration-test" \ + -var="rabbitmq_password=testpass-ci" \ + -var="loki_auth_token=" + - | + echo "── Verify K8s resources ──" + NS="integration-test" + KC=/tmp/test-kubeconfig + echo "── Namespace ──" + kubectl --kubeconfig $KC get namespace $NS + echo "── Deployment ──" + kubectl --kubeconfig $KC get deployment -n $NS + echo "── Services ──" + kubectl --kubeconfig $KC get service -n $NS + echo "── PVCs ──" + kubectl --kubeconfig $KC get pvc -n $NS + echo "── IngressRoutes ──" + kubectl --kubeconfig $KC get ingressroute.traefik.io -n $NS 2>/dev/null || \ + kubectl --kubeconfig $KC get ingressroute.traefik.containo.us -n $NS + - | + echo "── tofu destroy ──" + cd k8s + tofu destroy -auto-approve \ + -var-file="apps/example-nodered.tfvars" \ + -var="kubeconfig_path=/tmp/test-kubeconfig" \ + -var="app_name=integration-test" \ + -var="rabbitmq_password=testpass-ci" \ + -var="loki_auth_token=" + after_script: + - k3d cluster delete tofu-test 2>/dev/null || true + - rm -f /tmp/test-kubeconfig diff --git a/.terraform.lock.hcl b/.terraform.lock.hcl new file mode 100644 index 0000000..6c7c7fb --- /dev/null +++ b/.terraform.lock.hcl @@ -0,0 +1,23 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/kreuzwerker/docker" { + version = "3.9.0" + constraints = "~> 3.9" + hashes = [ + "h1:Eglp2bA01CEx8KV/K9CMZ1deQaWKW5HVhE2n+2jIp20=", + "zh:0ead8281830e9b9496651282235d9a139ba1b1b6ff79e395eb8c78658dc446b9", + "zh:0f17d37d8d3872df3fb75c68b5272e0c981343f53b506a9675b4405191edd3ef", + "zh:11d50b37323874427c6d2a08b737d3c7707c8301fdd236c94485cf2828d0b14b", + "zh:32f6f9b847446054e2db3d72886ef2f1d1aa51a6d0dac42340b07dad18e3f28f", + "zh:5ea5c67668b5dcbda560dc6104b788a9bfc974d52f02f7886889b77cc0e5d248", + "zh:5fb19a0b07edc344cd3ddeeb9cfb3d183089deb7a6a94a7b22a583aa1712596b", + "zh:602a7ece444e2a142ec5245abb98e7a1a990a68afae2df63b6c85ec084f0c5d7", + "zh:693dce278524ad8a6d6c9dd7a01bcd63bb85189639198f8d0b044ab0e5099401", + "zh:72e9911568103576c6a78fa38841cfd45eeb88ad22a2c649eb140a377a5b3c26", + "zh:956b62b6857cbb467b50158601f01b1203daa34cbd447dcc7f044c327e878b68", + "zh:9d372bac0d4479868b34485fb4966ba7bb525938f818b6a625f4977004ea83f9", + "zh:e06658a51427f9f53dbdb06263406fc1bc56d1a4fb5e7eb660d7cdfc22f596bd", + "zh:eee38dadf672b946419af25160eae7c03fc2afbb14f39f2f1d2a7404d647e2f7", + ] +} diff --git a/.terraform/providers/registry.opentofu.org/kreuzwerker/docker/3.0.2/linux_arm64.lock b/.terraform/providers/registry.opentofu.org/kreuzwerker/docker/3.0.2/linux_arm64.lock new file mode 100644 index 0000000..e69de29 diff --git a/.terraform/providers/registry.opentofu.org/kreuzwerker/docker/3.6.2/linux_arm64.lock b/.terraform/providers/registry.opentofu.org/kreuzwerker/docker/3.6.2/linux_arm64.lock new file mode 100644 index 0000000..e69de29 diff --git a/.terraform/providers/registry.opentofu.org/kreuzwerker/docker/3.9.0/linux_arm64.lock b/.terraform/providers/registry.opentofu.org/kreuzwerker/docker/3.9.0/linux_arm64.lock new file mode 100644 index 0000000..e69de29 diff --git a/.terraform/providers/registry.opentofu.org/kreuzwerker/docker/3.9.0/linux_arm64/CHANGELOG.md b/.terraform/providers/registry.opentofu.org/kreuzwerker/docker/3.9.0/linux_arm64/CHANGELOG.md new file mode 100644 index 0000000..35b0c05 --- /dev/null +++ b/.terraform/providers/registry.opentofu.org/kreuzwerker/docker/3.9.0/linux_arm64/CHANGELOG.md @@ -0,0 +1,870 @@ + + +## [v3.9.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.8.0...v3.9.0) (2025-11-09) + +### Chore + +* Add file requested by hashicorp ([#813](https://github.com/kreuzwerker/terraform-provider-docker/issues/813)) +* Prepare release v3.8.0 ([#806](https://github.com/kreuzwerker/terraform-provider-docker/issues/806)) + +### Feat + +* Implement caching of docker provider ([#808](https://github.com/kreuzwerker/terraform-provider-docker/issues/808)) + +### Fix + +* test attribute of docker_service healthcheck is not required ([#815](https://github.com/kreuzwerker/terraform-provider-docker/issues/815)) +* docker_service label can be updated without recreate ([#814](https://github.com/kreuzwerker/terraform-provider-docker/issues/814)) + + + +## [v3.8.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.7.0...v3.8.0) (2025-10-08) + +### Feat + +* Add build attribute for docker_registry_image ([#805](https://github.com/kreuzwerker/terraform-provider-docker/issues/805)) +* Add build option for additional contexts ([#798](https://github.com/kreuzwerker/terraform-provider-docker/issues/798)) +* implement mac_address for networks_advanced ([#794](https://github.com/kreuzwerker/terraform-provider-docker/issues/794)) +* Implement docker cluster volume ([#793](https://github.com/kreuzwerker/terraform-provider-docker/issues/793)) + + + +## [v3.7.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.6.2...v3.7.0) (2025-08-19) + +### Chore + +* Prepare release v3.7.0 ([#774](https://github.com/kreuzwerker/terraform-provider-docker/issues/774)) + +### Feat + +* Implement memory_reservation and network_mode enhancements ([#773](https://github.com/kreuzwerker/terraform-provider-docker/issues/773)) +* Implement cache_from and cache_to for docker_image ([#772](https://github.com/kreuzwerker/terraform-provider-docker/issues/772)) + +### Fix + +* Correctly get and set nanoCPUs for docker_container ([#771](https://github.com/kreuzwerker/terraform-provider-docker/issues/771)) + + + +## [v3.6.2](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.6.1...v3.6.2) (2025-06-13) + +### Chore + +* Prepare release v3.6.2 ([#750](https://github.com/kreuzwerker/terraform-provider-docker/issues/750)) + +### Feat + +* Allow digest in image name ([#744](https://github.com/kreuzwerker/terraform-provider-docker/issues/744)) + +### Fix + +* Remove wrong buildkit version assignment ([#747](https://github.com/kreuzwerker/terraform-provider-docker/issues/747)) +* Reading non existant volume should recreate ([#749](https://github.com/kreuzwerker/terraform-provider-docker/issues/749)) +* Typo in cgroup_parent handling ([#746](https://github.com/kreuzwerker/terraform-provider-docker/issues/746)) + + + +## [v3.6.1](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.6.0...v3.6.1) (2025-06-05) + +### Chore + +* Prepare release v3.6.1 ([#743](https://github.com/kreuzwerker/terraform-provider-docker/issues/743)) + +### Feat + +* allow to set the cgroup parent for container ([#609](https://github.com/kreuzwerker/terraform-provider-docker/issues/609)) + + + +## [v3.6.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.5.0...v3.6.0) (2025-05-25) + +### Chore + +* Prepare release v3.6.0 ([#735](https://github.com/kreuzwerker/terraform-provider-docker/issues/735)) + +### Feat + +* Implement correct cpu scheduler settings ([#732](https://github.com/kreuzwerker/terraform-provider-docker/issues/732)) +* Add implementaion of capabilities in docker servic ([#727](https://github.com/kreuzwerker/terraform-provider-docker/issues/727)) +* implement Buildx builder resource ([#724](https://github.com/kreuzwerker/terraform-provider-docker/issues/724)) + +### Fix + +* Implement buildx fixes for general buildkit support and platform handling ([#734](https://github.com/kreuzwerker/terraform-provider-docker/issues/734)) +* Make endpoint validation less strict ([#733](https://github.com/kreuzwerker/terraform-provider-docker/issues/733)) + + + +## [v3.5.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.4.0...v3.5.0) (2025-05-06) + +### Chore + +* Prepare release v3.5.0 ([#721](https://github.com/kreuzwerker/terraform-provider-docker/issues/721)) + +### Feat + +* Implement using of buildx for docker_image ([#717](https://github.com/kreuzwerker/terraform-provider-docker/issues/717)) +* Support registries that return empty auth scope [#646](https://github.com/kreuzwerker/terraform-provider-docker/issues/646) +* Implement registry_image_manifests data source ([#714](https://github.com/kreuzwerker/terraform-provider-docker/issues/714)) +* Implement healthcheck start interval ([#713](https://github.com/kreuzwerker/terraform-provider-docker/issues/713)) + + + +## [v3.4.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.3.0...v3.4.0) (2025-04-25) + +### Chore + +* Prepare release v3.4.0 ([#712](https://github.com/kreuzwerker/terraform-provider-docker/issues/712)) + +### Feat + +* Implement volume_options subpath ([#710](https://github.com/kreuzwerker/terraform-provider-docker/issues/710)) + +### Fix + +* Prevent recreation of image name is intentionally set to a fixed value ([#711](https://github.com/kreuzwerker/terraform-provider-docker/issues/711)) +* Improve container wait handling ([#709](https://github.com/kreuzwerker/terraform-provider-docker/issues/709)) +* Use auth_config block also for registry_image delete functionality ([#708](https://github.com/kreuzwerker/terraform-provider-docker/issues/708)) + + + +## [v3.3.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.2.0...v3.3.0) (2025-04-19) + +### Chore + +* Prepare release v3.3.0 ([#705](https://github.com/kreuzwerker/terraform-provider-docker/issues/705)) +* Update terraform-plugin-sdk/v2 dependency ([#699](https://github.com/kreuzwerker/terraform-provider-docker/issues/699)) +* Update docker/docker and docker/cli to newest stable ([#695](https://github.com/kreuzwerker/terraform-provider-docker/issues/695)) + +### Feat + +* Implement support for docker context ([#704](https://github.com/kreuzwerker/terraform-provider-docker/issues/704)) +* disable_docker_daemon_check for provider ([#703](https://github.com/kreuzwerker/terraform-provider-docker/issues/703)) +* Implement tag triggers for docker_tag resource ([#702](https://github.com/kreuzwerker/terraform-provider-docker/issues/702)) +* Implement auth_config for docker_registry_image ([#701](https://github.com/kreuzwerker/terraform-provider-docker/issues/701)) + +### Fix + +* Store correctly ports from server ([#698](https://github.com/kreuzwerker/terraform-provider-docker/issues/698)) + + + +## [v3.2.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.1.2...v3.2.0) (2025-04-16) + +### Chore + +* Prepare release v3.2.0 ([#694](https://github.com/kreuzwerker/terraform-provider-docker/issues/694)) +* Upgrade golangci-lint to next major version ([#686](https://github.com/kreuzwerker/terraform-provider-docker/issues/686)) + +### Docs + +* Consolidated update of docs from several PRs ([#691](https://github.com/kreuzwerker/terraform-provider-docker/issues/691)) + +### Feat + +* Implement upload permissions in docker_container resource ([#693](https://github.com/kreuzwerker/terraform-provider-docker/issues/693)) +* Implement docker_image timeouts ([#692](https://github.com/kreuzwerker/terraform-provider-docker/issues/692)) +* Add support for build-secrets ([#604](https://github.com/kreuzwerker/terraform-provider-docker/issues/604)) + +### Fix + +* Authentication to ECR public ([#690](https://github.com/kreuzwerker/terraform-provider-docker/issues/690)) + + + +## [v3.1.2](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.1.1...v3.1.2) (2025-04-15) + +### Chore + +* prepare release 3.1.2 ([#688](https://github.com/kreuzwerker/terraform-provider-docker/issues/688)) + + + +## [v3.1.1](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.1.0...v3.1.1) (2025-04-14) + +### Chore + +* Prepare release 3.1.1 ([#687](https://github.com/kreuzwerker/terraform-provider-docker/issues/687)) + + + +## [v3.1.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.0.2...v3.1.0) (2025-04-14) + +### Chore + +* Prepare release 3.1.0 ([#685](https://github.com/kreuzwerker/terraform-provider-docker/issues/685)) +* update Go version to 1.22 for consistency across workflows, jo… ([#613](https://github.com/kreuzwerker/terraform-provider-docker/issues/613)) + +### Feat + +* support setting cpu shares ([#575](https://github.com/kreuzwerker/terraform-provider-docker/issues/575)) + +### Fix + +* Use build_args everywhere and update documentation ([#681](https://github.com/kreuzwerker/terraform-provider-docker/issues/681)) +* Compress build context before sending it to Docker ([#461](https://github.com/kreuzwerker/terraform-provider-docker/issues/461)) +* Set correct default network driver and fix a test ([#677](https://github.com/kreuzwerker/terraform-provider-docker/issues/677)) + +### Typo + +* s/presend/present/ ([#606](https://github.com/kreuzwerker/terraform-provider-docker/issues/606)) + + + +## [v3.0.2](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.0.1...v3.0.2) (2023-03-17) + +### Chore + +* Prepare release v3.0.2 + +### Docs + +* correct spelling of "networks_advanced" ([#517](https://github.com/kreuzwerker/terraform-provider-docker/issues/517)) + +### Fix + +* Implement proxy support. ([#529](https://github.com/kreuzwerker/terraform-provider-docker/issues/529)) + + + +## [v3.0.1](https://github.com/kreuzwerker/terraform-provider-docker/compare/v3.0.0...v3.0.1) (2023-01-13) + +### Chore + +* Prepare release v3.0.1 + +### Fix + +* Access health of container correctly. ([#506](https://github.com/kreuzwerker/terraform-provider-docker/issues/506)) + + + +## [v3.0.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.25.0...v3.0.0) (2023-01-13) + +### Chore + +* Prepare release v3.0.0 + +### Docs + +* Update documentation. +* Add migration guide and update README ([#502](https://github.com/kreuzwerker/terraform-provider-docker/issues/502)) + +### Feat + +* Prepare v3 release ([#503](https://github.com/kreuzwerker/terraform-provider-docker/issues/503)) + + + +## [v2.25.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.24.0...v2.25.0) (2023-01-05) + +### Chore + +* Prepare release v2.25.0 + +### Docs + +* Add documentation of remote hosts. ([#498](https://github.com/kreuzwerker/terraform-provider-docker/issues/498)) + +### Feat + +* Migrate build block to `docker_image` ([#501](https://github.com/kreuzwerker/terraform-provider-docker/issues/501)) +* Add platform attribute to docker_image resource ([#500](https://github.com/kreuzwerker/terraform-provider-docker/issues/500)) +* Add sysctl implementation to container of docker_service. ([#499](https://github.com/kreuzwerker/terraform-provider-docker/issues/499)) + + + +## [v2.24.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.23.1...v2.24.0) (2022-12-23) + +### Chore + +* Prepare release v2.24.0 + +### Docs + +* Fix generated website. +* Update command typo ([#487](https://github.com/kreuzwerker/terraform-provider-docker/issues/487)) + +### Feat + +* cgroupns support ([#497](https://github.com/kreuzwerker/terraform-provider-docker/issues/497)) +* Add triggers attribute to docker_registry_image ([#496](https://github.com/kreuzwerker/terraform-provider-docker/issues/496)) +* Support registries with disabled auth ([#494](https://github.com/kreuzwerker/terraform-provider-docker/issues/494)) +* add IPAM options block for docker networks ([#491](https://github.com/kreuzwerker/terraform-provider-docker/issues/491)) + +### Fix + +* Pin data source specific tag test to older tag. + +### Tests + +* Add test for parsing auth headers. + + + +## [v2.23.1](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.23.0...v2.23.1) (2022-11-23) + +### Chore + +* Prepare release v2.23.1 + +### Fix + +* Update shasum of busybox:1.35.0 tag in test. +* Handle Auth Header Scopes ([#482](https://github.com/kreuzwerker/terraform-provider-docker/issues/482)) +* Set OS_ARCH from GOHOSTOS and GOHOSTARCH ([#477](https://github.com/kreuzwerker/terraform-provider-docker/issues/477)) + + + +## [v2.23.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.22.0...v2.23.0) (2022-11-02) + +### Chore + +* Prepare release v2.23.0 + +### Feat + +* wait container healthy state ([#467](https://github.com/kreuzwerker/terraform-provider-docker/issues/467)) +* add docker logs data source ([#471](https://github.com/kreuzwerker/terraform-provider-docker/issues/471)) + +### Fix + +* Update shasum of busybox:1.35.0 tag in test. +* Update shasum of busybox:1.35.0 tag +* Correct provider name to match the public registry ([#462](https://github.com/kreuzwerker/terraform-provider-docker/issues/462)) + + + +## [v2.22.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.21.0...v2.22.0) (2022-09-20) + +### Chore + +* Prepare release v2.22.0 + +### Feat + +* Configurable timeout for docker_container resource stateChangeConf ([#454](https://github.com/kreuzwerker/terraform-provider-docker/issues/454)) + +### Fix + +* oauth authorization support for azurecr ([#451](https://github.com/kreuzwerker/terraform-provider-docker/issues/451)) + + + +## [v2.21.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.20.3...v2.21.0) (2022-09-05) + +### Chore + +* Prepare release v2.21.0 + +### Docs + +* Fix docker config example. + +### Feat + +* Add image_id attribute to docker_image resource. ([#450](https://github.com/kreuzwerker/terraform-provider-docker/issues/450)) +* Update used goversion to 1.18. ([#449](https://github.com/kreuzwerker/terraform-provider-docker/issues/449)) + +### Fix + +* Replace deprecated .latest attribute with new image_id. ([#453](https://github.com/kreuzwerker/terraform-provider-docker/issues/453)) +* Remove reading part of docker_tag resource. ([#448](https://github.com/kreuzwerker/terraform-provider-docker/issues/448)) +* Fix repo_digest value for DockerImageDatasource test. + + + +## [v2.20.3](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.20.2...v2.20.3) (2022-08-31) + +### Chore + +* Prepare release v2.20.3 + +### Fix + +* Docker Registry Image data source use HEAD request to query image digest ([#433](https://github.com/kreuzwerker/terraform-provider-docker/issues/433)) +* Adding Support for Windows Paths in Bash ([#438](https://github.com/kreuzwerker/terraform-provider-docker/issues/438)) + + + +## [v2.20.2](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.20.1...v2.20.2) (2022-08-10) + +### Chore + +* Prepare release v2.20.2 + +### Fix + +* Check the operating system for determining the default Docker socket ([#427](https://github.com/kreuzwerker/terraform-provider-docker/issues/427)) + +### Reverts + +* fix(deps): update module github.com/golangci/golangci-lint to v1.48.0 ([#423](https://github.com/kreuzwerker/terraform-provider-docker/issues/423)) + + + +## [v2.20.1](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.20.0...v2.20.1) (2022-08-10) + +### Chore + +* Prepare release v2.20.1 +* Reduce time to setup AccTests ([#430](https://github.com/kreuzwerker/terraform-provider-docker/issues/430)) + +### Docs + +* Improve docker network usage documentation [skip-ci] + +### Feat + +* Implement triggers attribute for docker_image. ([#425](https://github.com/kreuzwerker/terraform-provider-docker/issues/425)) + +### Fix + +* Add ForceTrue to docker_image name attribute. ([#421](https://github.com/kreuzwerker/terraform-provider-docker/issues/421)) + + + +## [v2.20.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.19.0...v2.20.0) (2022-07-28) + +### Chore + +* Prepare release v2.20.0 +* Fix release targets in Makefile. + +### Feat + +* Implementation of `docker_tag` resource. ([#418](https://github.com/kreuzwerker/terraform-provider-docker/issues/418)) +* Implement support for insecure registries ([#414](https://github.com/kreuzwerker/terraform-provider-docker/issues/414)) + + + +## [v2.19.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.18.1...v2.19.0) (2022-07-15) + +### Chore + +* Prepare release v2.19.0 + +### Feat + +* Add gpu flag to docker_container resource ([#405](https://github.com/kreuzwerker/terraform-provider-docker/issues/405)) + +### Fix + +* Enable authentication to multiple registries again. ([#400](https://github.com/kreuzwerker/terraform-provider-docker/issues/400)) +* ECR authentication ([#409](https://github.com/kreuzwerker/terraform-provider-docker/issues/409)) + + + +## [v2.18.1](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.18.0...v2.18.1) (2022-07-14) + +### Chore + +* Prepare release v2.18.1 +* Automate changelog generation [skip ci] + +### Fix + +* Improve searchLocalImages error handling. ([#407](https://github.com/kreuzwerker/terraform-provider-docker/issues/407)) +* Throw errors when any part of docker config file handling goes wrong. ([#406](https://github.com/kreuzwerker/terraform-provider-docker/issues/406)) +* Enables having a Dockerfile outside the context ([#402](https://github.com/kreuzwerker/terraform-provider-docker/issues/402)) + + + +## [v2.18.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.17.0...v2.18.0) (2022-07-11) + +### Chore + +* prepare release v2.18.0 + +### Feat + +* add runtime, stop_signal and stop_timeout properties to the docker_container resource ([#364](https://github.com/kreuzwerker/terraform-provider-docker/issues/364)) + +### Fix + +* Correctly handle build files and context for docker_registry_image ([#398](https://github.com/kreuzwerker/terraform-provider-docker/issues/398)) +* Switch to proper go tools mechanism to fix website-* workflows. ([#399](https://github.com/kreuzwerker/terraform-provider-docker/issues/399)) +* compare relative paths when excluding, fixes kreuzwerker[#280](https://github.com/kreuzwerker/terraform-provider-docker/issues/280) ([#397](https://github.com/kreuzwerker/terraform-provider-docker/issues/397)) + + + +## [v2.17.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.16.0...v2.17.0) (2022-06-23) + +### Chore + +* prepare release v2.17.0 +* Exclude examples directory from renovate. +* remove the workflow to close stale issues and pull requests ([#371](https://github.com/kreuzwerker/terraform-provider-docker/issues/371)) + +### Fix + +* update go package files directly on master to fix build. +* correct authentication for ghcr.io registry([#349](https://github.com/kreuzwerker/terraform-provider-docker/issues/349)) + + + +## [v2.16.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.15.0...v2.16.0) (2022-01-24) + +### Chore + +* prepare release v2.16.0 + +### Docs + +* fix service options ([#337](https://github.com/kreuzwerker/terraform-provider-docker/issues/337)) +* update registry_image.md ([#321](https://github.com/kreuzwerker/terraform-provider-docker/issues/321)) +* fix r/registry_image truncated docs ([#304](https://github.com/kreuzwerker/terraform-provider-docker/issues/304)) + +### Feat + +* add parameter for SSH options ([#335](https://github.com/kreuzwerker/terraform-provider-docker/issues/335)) + +### Fix + +* pass container rm flag ([#322](https://github.com/kreuzwerker/terraform-provider-docker/issues/322)) +* add nil check of DriverConfig ([#315](https://github.com/kreuzwerker/terraform-provider-docker/issues/315)) +* fmt of go files for go 1.17 + + + +## [v2.15.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.14.0...v2.15.0) (2021-08-11) + +### Chore + +* prepare release v2.15.0 +* re go gets terraform-plugin-docs + +### Docs + +* corrects authentication misspell. Closes [#264](https://github.com/kreuzwerker/terraform-provider-docker/issues/264) + +### Feat + +* add container storage opts ([#258](https://github.com/kreuzwerker/terraform-provider-docker/issues/258)) + +### Fix + +* add current timestamp for file upload to container ([#259](https://github.com/kreuzwerker/terraform-provider-docker/issues/259)) + + + +## [v2.14.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.13.0...v2.14.0) (2021-07-09) + +### Chore + +* prepare release v2.14.0 + +### Docs + +* update to absolute path for registry image context ([#246](https://github.com/kreuzwerker/terraform-provider-docker/issues/246)) +* update readme with logos and subsections ([#235](https://github.com/kreuzwerker/terraform-provider-docker/issues/235)) + +### Feat + +* support terraform v1 ([#242](https://github.com/kreuzwerker/terraform-provider-docker/issues/242)) + +### Fix + +* Update the URL of the docker hub registry ([#230](https://github.com/kreuzwerker/terraform-provider-docker/issues/230)) + + + +## [v2.13.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.12.2...v2.13.0) (2021-06-22) + +### Chore + +* prepare release v2.13.0 + +### Docs + +* fix a few typos ([#216](https://github.com/kreuzwerker/terraform-provider-docker/issues/216)) +* fix typos in docker_image example usage ([#213](https://github.com/kreuzwerker/terraform-provider-docker/issues/213)) + + + +## [v2.12.2](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.12.1...v2.12.2) (2021-05-26) + +### Chore + +* prepare release v2.12.2 + + + +## [v2.12.1](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.12.0...v2.12.1) (2021-05-26) + +### Chore + +* update changelog for v2.12.1 + +### Fix + +* add service host flattener with space split ([#205](https://github.com/kreuzwerker/terraform-provider-docker/issues/205)) +* service state upgradeV2 for empty auth + + + +## [v2.12.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.11.0...v2.12.0) (2021-05-23) + +### Chore + +* update changelog for v2.12.0 +* ignore dist folder +* configure actions/stale ([#157](https://github.com/kreuzwerker/terraform-provider-docker/issues/157)) +* add the guide about Terraform Configuration in Bug Report ([#139](https://github.com/kreuzwerker/terraform-provider-docker/issues/139)) +* bump docker dependency to v20.10.5 ([#119](https://github.com/kreuzwerker/terraform-provider-docker/issues/119)) + +### Ci + +* run acceptance tests with multiple Terraform versions ([#129](https://github.com/kreuzwerker/terraform-provider-docker/issues/129)) + +### Docs + +* update for v2.12.0 +* add releasing steps +* format `Guide of Bug report` ([#159](https://github.com/kreuzwerker/terraform-provider-docker/issues/159)) +* add an example to build an image with docker_image ([#158](https://github.com/kreuzwerker/terraform-provider-docker/issues/158)) +* add a guide about writing issues to CONTRIBUTING.md ([#149](https://github.com/kreuzwerker/terraform-provider-docker/issues/149)) +* fix Github repository URL in README ([#136](https://github.com/kreuzwerker/terraform-provider-docker/issues/136)) + +### Feat + +* support darwin arm builds and golang 1.16 ([#140](https://github.com/kreuzwerker/terraform-provider-docker/issues/140)) +* migrate to terraform-sdk v2 ([#102](https://github.com/kreuzwerker/terraform-provider-docker/issues/102)) + +### Fix + +* rewriting tar header fields ([#198](https://github.com/kreuzwerker/terraform-provider-docker/issues/198)) +* test spaces for windows ([#190](https://github.com/kreuzwerker/terraform-provider-docker/issues/190)) +* replace for loops with StateChangeConf ([#182](https://github.com/kreuzwerker/terraform-provider-docker/issues/182)) +* skip sign on compile action +* assign map to rawState when it is nil to prevent panic ([#180](https://github.com/kreuzwerker/terraform-provider-docker/issues/180)) +* search local images with Docker image ID ([#151](https://github.com/kreuzwerker/terraform-provider-docker/issues/151)) +* set "ForceNew: true" to labelSchema ([#152](https://github.com/kreuzwerker/terraform-provider-docker/issues/152)) + + + +## [v2.11.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.10.0...v2.11.0) (2021-01-22) + +### Chore + +* update changelog for v2.11.0 +* updates changelog for v2.10.0 + +### Docs + +* fix legacy configuration style ([#126](https://github.com/kreuzwerker/terraform-provider-docker/issues/126)) + +### Feat + +* add properties -it (tty and stdin_opn) to docker container + + + +## [v2.10.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.9.0...v2.10.0) (2021-01-08) + +### Chore + +* updates changelog for 2.10.0 +* ignores testing folders +* adds separate bug and ft req templates + +### Ci + +* bumps to docker version 20.10.1 +* pins workflows to ubuntu:20.04 image + +### Docs + +* add labels to arguments of docker_service ([#105](https://github.com/kreuzwerker/terraform-provider-docker/issues/105)) +* cleans readme +* adds coc and contributing + +### Feat + +* supports Docker plugin ([#35](https://github.com/kreuzwerker/terraform-provider-docker/issues/35)) +* support max replicas of Docker Service Task Spec ([#112](https://github.com/kreuzwerker/terraform-provider-docker/issues/112)) +* add force_remove option to r/image ([#104](https://github.com/kreuzwerker/terraform-provider-docker/issues/104)) +* add local semantic commit validation ([#99](https://github.com/kreuzwerker/terraform-provider-docker/issues/99)) +* add ability to lint/check of links in documentation locally ([#98](https://github.com/kreuzwerker/terraform-provider-docker/issues/98)) + +### Fix + +* set "latest" to tag when tag isn't specified ([#117](https://github.com/kreuzwerker/terraform-provider-docker/issues/117)) +* image label for workflows +* remove all azure cps + +### Pull Requests + +* Merge pull request [#38](https://github.com/kreuzwerker/terraform-provider-docker/issues/38) from kreuzwerker/ci-ubuntu2004-workflow +* Merge pull request [#36](https://github.com/kreuzwerker/terraform-provider-docker/issues/36) from kreuzwerker/chore-gh-issue-tpl + + + +## [v2.9.0](https://github.com/kreuzwerker/terraform-provider-docker/compare/v2.8.0...v2.9.0) (2020-12-25) + +### Chore + +* updates changelog for 2.9.0 +* update changelog 2.8.0 release date +* introduces golangci-lint ([#32](https://github.com/kreuzwerker/terraform-provider-docker/issues/32)) +* fix changelog links + +### Ci + +* add gofmt's '-s' option +* remove unneeded make tasks +* fix test of website + +### Doc + +* devices is a block, not a boolean + +### Feat + +* adds support for OCI manifests ([#316](https://github.com/kreuzwerker/terraform-provider-docker/issues/316)) +* adds security_opts to container config. ([#308](https://github.com/kreuzwerker/terraform-provider-docker/issues/308)) +* adds support for init process injection for containers. ([#300](https://github.com/kreuzwerker/terraform-provider-docker/issues/300)) + +### Fix + +* changing mounts requires ForceNew ([#314](https://github.com/kreuzwerker/terraform-provider-docker/issues/314)) +* allow healthcheck to be computed as container can specify ([#312](https://github.com/kreuzwerker/terraform-provider-docker/issues/312)) +* treat null user as a no-op ([#318](https://github.com/kreuzwerker/terraform-provider-docker/issues/318)) +* workdir null behavior ([#320](https://github.com/kreuzwerker/terraform-provider-docker/issues/320)) + +### Style + +* format with gofumpt + +### Pull Requests + +* Merge pull request [#33](https://github.com/kreuzwerker/terraform-provider-docker/issues/33) from brandonros/patch-1 +* Merge pull request [#11](https://github.com/kreuzwerker/terraform-provider-docker/issues/11) from suzuki-shunsuke/format-with-gofumpt +* Merge pull request [#26](https://github.com/kreuzwerker/terraform-provider-docker/issues/26) from kreuzwerker/ci/fix-website-ci +* Merge pull request [#8](https://github.com/kreuzwerker/terraform-provider-docker/issues/8) from dubo-dubon-duponey/patch1 + + + +## v2.8.0 (2020-11-11) + +### Chore + +* updates changelog for 2.8.0 +* removes travis.yml +* deactivates travis +* removes vendor dir ([#298](https://github.com/kreuzwerker/terraform-provider-docker/issues/298)) +* bump go 115 ([#297](https://github.com/kreuzwerker/terraform-provider-docker/issues/297)) +* documentation updates ([#286](https://github.com/kreuzwerker/terraform-provider-docker/issues/286)) +* updates link syntax ([#287](https://github.com/kreuzwerker/terraform-provider-docker/issues/287)) +* fix typo ([#292](https://github.com/kreuzwerker/terraform-provider-docker/issues/292)) + +### Ci + +* reactivats all workflows +* fix website +* only run website workflow +* exports gopath manually +* fix absolute gopath for website +* make website check separate workflow +* fix workflow names +* adds website test to unit test +* adds acc test +* adds compile +* adds go version and goproxy env +* enables unit tests for master branch +* adds unit test workflow +* adds goreleaser and gh action +* bumps docker and ubuntu versions ([#241](https://github.com/kreuzwerker/terraform-provider-docker/issues/241)) +* removes debug option from acc tests +* skips test which is flaky only on travis + +### Deps + +* github.com/hashicorp/terraform[@sdk](https://github.com/sdk)-v0.11-with-go-modules Updated via: go get github.com/hashicorp/terraform[@sdk](https://github.com/sdk)-v0.11-with-go-modules and go mod tidy +* use go modules for dep mgmt run go mod tidy remove govendor from makefile and travis config set appropriate env vars for go modules + +### Docker + +* improve validation of runtime constraints + +### Docs + +* update container.html.markdown ([#278](https://github.com/kreuzwerker/terraform-provider-docker/issues/278)) +* update service.html.markdown ([#281](https://github.com/kreuzwerker/terraform-provider-docker/issues/281)) +* update restart_policy for service. Closes [#228](https://github.com/kreuzwerker/terraform-provider-docker/issues/228) +* adds new label structure. Closes [#214](https://github.com/kreuzwerker/terraform-provider-docker/issues/214) +* update anchors with -1 suffix ([#178](https://github.com/kreuzwerker/terraform-provider-docker/issues/178)) +* Fix misspelled words +* Fix exported attribute name in docker_registry_image +* Fix example for docker_registry_image ([#8308](https://github.com/kreuzwerker/terraform-provider-docker/issues/8308)) +* provider/docker - network settings attrs + +### Feat + +* conditionally adding port binding ([#293](https://github.com/kreuzwerker/terraform-provider-docker/issues/293)). +* adds docker Image build feature ([#283](https://github.com/kreuzwerker/terraform-provider-docker/issues/283)) +* adds complete support for Docker credential helpers ([#253](https://github.com/kreuzwerker/terraform-provider-docker/issues/253)) +* Expose IPv6 properties as attributes +* allow use of source file instead of content / content_base64 ([#240](https://github.com/kreuzwerker/terraform-provider-docker/issues/240)) +* supports to update docker_container ([#236](https://github.com/kreuzwerker/terraform-provider-docker/issues/236)) +* support to import some docker_container's attributes ([#234](https://github.com/kreuzwerker/terraform-provider-docker/issues/234)) +* adds config file content as plain string ([#232](https://github.com/kreuzwerker/terraform-provider-docker/issues/232)) +* make UID, GID, & mode for secrets and configs configurable ([#231](https://github.com/kreuzwerker/terraform-provider-docker/issues/231)) +* adds import for resources ([#196](https://github.com/kreuzwerker/terraform-provider-docker/issues/196)) +* add container ipc mode. ([#182](https://github.com/kreuzwerker/terraform-provider-docker/issues/182)) +* adds container working dir ([#181](https://github.com/kreuzwerker/terraform-provider-docker/issues/181)) + +### Fix + +* ignores 'remove_volumes' on container import +* duplicated buildImage function +* port objects with the same internal port but different protocol trigger recreation of container ([#274](https://github.com/kreuzwerker/terraform-provider-docker/issues/274)) +* panic to migrate schema of docker_container from v1 to v2 ([#271](https://github.com/kreuzwerker/terraform-provider-docker/issues/271)). Closes [#264](https://github.com/kreuzwerker/terraform-provider-docker/issues/264) +* pins docker registry for tests to v2.7.0 +* prevent force recreate of container about some attributes ([#269](https://github.com/kreuzwerker/terraform-provider-docker/issues/269)) +* service endpoint spec flattening +* corrects IPAM config read on the data provider ([#229](https://github.com/kreuzwerker/terraform-provider-docker/issues/229)) +* replica to 0 in current schema. Closes [#221](https://github.com/kreuzwerker/terraform-provider-docker/issues/221) +* label for network and volume after improt +* binary upload as base 64 content ([#194](https://github.com/kreuzwerker/terraform-provider-docker/issues/194)) +* service env truncation for multiple delimiters ([#193](https://github.com/kreuzwerker/terraform-provider-docker/issues/193)) +* destroy_grace_seconds are considered ([#179](https://github.com/kreuzwerker/terraform-provider-docker/issues/179)) + +### Make + +* Add website + website-test targets + +### Provider + +* Ensured Go 1.11 in TravisCI and README provider: Run go fix provider: Run go fmt provider: Encode go version 1.11.5 to .go-version file +* Require Go 1.11 in TravisCI and README provider: Run go fix provider: Run go fmt + +### Tests + +* Skip test if swap limit isn't available ([#136](https://github.com/kreuzwerker/terraform-provider-docker/issues/136)) +* Simplify Dockerfile(s) + +### Vendor + +* github.com/hashicorp/terraform/...[@v0](https://github.com/v0).10.0 +* Ignore github.com/hashicorp/terraform/backend + +### Website + +* Docs sweep for lists & maps +* note on docker +* docker docs + +### Pull Requests + +* Merge pull request [#134](https://github.com/kreuzwerker/terraform-provider-docker/issues/134) from terraform-providers/go-modules-2019-03-01 +* Merge pull request [#135](https://github.com/kreuzwerker/terraform-provider-docker/issues/135) from terraform-providers/t-simplify-dockerfile +* Merge pull request [#47](https://github.com/kreuzwerker/terraform-provider-docker/issues/47) from captn3m0/docker-link-warning +* Merge pull request [#60](https://github.com/kreuzwerker/terraform-provider-docker/issues/60) from terraform-providers/f-make-website +* Merge pull request [#23](https://github.com/kreuzwerker/terraform-provider-docker/issues/23) from JamesLaverack/patch-1 +* Merge pull request [#18](https://github.com/kreuzwerker/terraform-provider-docker/issues/18) from terraform-providers/vendor-tf-0.10 +* Merge pull request [#5046](https://github.com/kreuzwerker/terraform-provider-docker/issues/5046) from tpounds/use-built-in-schema-string-hash +* Merge pull request [#3761](https://github.com/kreuzwerker/terraform-provider-docker/issues/3761) from ryane/f-provider-docker-improvements +* Merge pull request [#3383](https://github.com/kreuzwerker/terraform-provider-docker/issues/3383) from apparentlymart/docker-container-command-docs +* Merge pull request [#1564](https://github.com/kreuzwerker/terraform-provider-docker/issues/1564) from nickryand/docker_links + diff --git a/.terraform/providers/registry.opentofu.org/kreuzwerker/docker/3.9.0/linux_arm64/LICENSE b/.terraform/providers/registry.opentofu.org/kreuzwerker/docker/3.9.0/linux_arm64/LICENSE new file mode 100644 index 0000000..a612ad9 --- /dev/null +++ b/.terraform/providers/registry.opentofu.org/kreuzwerker/docker/3.9.0/linux_arm64/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/.terraform/providers/registry.opentofu.org/kreuzwerker/docker/3.9.0/linux_arm64/README.md b/.terraform/providers/registry.opentofu.org/kreuzwerker/docker/3.9.0/linux_arm64/README.md new file mode 100644 index 0000000..6be3b5f --- /dev/null +++ b/.terraform/providers/registry.opentofu.org/kreuzwerker/docker/3.9.0/linux_arm64/README.md @@ -0,0 +1,117 @@ + + Docker logo + + + Terraform logo + + + Kreuzwerker logo + + +# Terraform Provider for Docker + +[![Release](https://img.shields.io/github/v/release/kreuzwerker/terraform-provider-docker)](https://github.com/kreuzwerker/terraform-provider-docker/releases) +[![Installs](https://img.shields.io/badge/dynamic/json?logo=terraform&label=installs&query=$.data.attributes.downloads&url=https%3A%2F%2Fregistry.terraform.io%2Fv2%2Fproviders%2F713)](https://registry.terraform.io/providers/kreuzwerker/docker) +[![Registry](https://img.shields.io/badge/registry-doc%40latest-lightgrey?logo=terraform)](https://registry.terraform.io/providers/kreuzwerker/docker/latest/docs) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/kreuzwerker/terraform-provider-docker/blob/main/LICENSE) +[![Go Status](https://github.com/kreuzwerker/terraform-provider-docker/workflows/Acc%20Tests/badge.svg)](https://github.com/kreuzwerker/terraform-provider-docker/actions) +[![Lint Status](https://github.com/kreuzwerker/terraform-provider-docker/workflows/golangci-lint/badge.svg)](https://github.com/kreuzwerker/terraform-provider-docker/actions) +[![Go Report Card](https://goreportcard.com/badge/github.com/kreuzwerker/terraform-provider-docker)](https://goreportcard.com/report/github.com/kreuzwerker/terraform-provider-docker) + +## Documentation + +The documentation for the provider is available on the [Terraform Registry](https://registry.terraform.io/providers/kreuzwerker/docker/latest/docs). + +Do you want to migrate from `v2.x` to `v3.x`? Please read the [migration guide](docs/v2_v3_migration.md) + +## Example usage + +Take a look at the examples in the [documentation](https://registry.terraform.io/providers/kreuzwerker/docker/3.9.0/docs) of the registry +or use the following example: + + +```hcl +# Set the required provider and versions +terraform { + required_providers { + # We recommend pinning to the specific version of the Docker Provider you're using + # since new versions are released frequently + docker = { + source = "kreuzwerker/docker" + version = "3.9.0" + } + } +} + +# Configure the docker provider +provider "docker" { +} + +# Create a docker image resource +# -> docker pull nginx:latest +resource "docker_image" "nginx" { + name = "nginx:latest" + keep_locally = true +} + +# Create a docker container resource +# -> same as 'docker run --name nginx -p8080:80 -d nginx:latest' +resource "docker_container" "nginx" { + name = "nginx" + image = docker_image.nginx.image_id + + ports { + external = 8080 + internal = 80 + } +} + +# Or create a service resource +# -> same as 'docker service create -d -p 8081:80 --name nginx-service --replicas 2 nginx:latest' +resource "docker_service" "nginx_service" { + name = "nginx-service" + task_spec { + container_spec { + image = docker_image.nginx.repo_digest + } + } + + mode { + replicated { + replicas = 2 + } + } + + endpoint_spec { + ports { + published_port = 8081 + target_port = 80 + } + } +} +``` + +## Building The Provider + +[Go](https://golang.org/doc/install) 1.18.x (to build the provider plugin) + + +```sh +$ git clone git@github.com:kreuzwerker/terraform-provider-docker +$ make build +``` + +## Contributing + +The Terraform Docker Provider is the work of many of contributors. We appreciate your help! + +To contribute, please read the contribution guidelines: [Contributing to Terraform - Docker Provider](CONTRIBUTING.md) + +## License + +The Terraform Provider Docker is available to everyone under the terms of the Mozilla Public License Version 2.0. [Take a look the LICENSE file](LICENSE). + + +## Stargazers over time + +[![Stargazers over time](https://starchart.cc/kreuzwerker/terraform-provider-docker.svg)](https://starchart.cc/kreuzwerker/terraform-provider-docker) diff --git a/.terraform/providers/registry.opentofu.org/kreuzwerker/docker/3.9.0/linux_arm64/terraform-provider-docker_v3.9.0 b/.terraform/providers/registry.opentofu.org/kreuzwerker/docker/3.9.0/linux_arm64/terraform-provider-docker_v3.9.0 new file mode 100755 index 0000000..67e12d0 Binary files /dev/null and b/.terraform/providers/registry.opentofu.org/kreuzwerker/docker/3.9.0/linux_arm64/terraform-provider-docker_v3.9.0 differ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5f9fb81 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,123 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Common Commands + +All commands run from the repo root unless noted. + +### Validate & format + +```sh +make validate # tofu validate on both stacks (Docker root + k8s/) +make fmt-check # tofu fmt -check -recursive +make fmt-fix # auto-format all .tf files +make lint # tflint on both stacks (requires tflint in PATH) +``` + +### Unit tests (no infrastructure required) + +```sh +make test-unit # both stacks +make test-unit-docker # Docker stack only (runs from repo root) +make test-unit-k8s # K8s stack only (runs from k8s/) +tofu test -filter= # single test by name +tofu test -verbose # verbose output +``` + +### Integration tests (real infrastructure) + +```sh +make test-integration-k8s # spins up local k3d cluster, applies, verifies, destroys +make test-integration-docker # requires Docker + SSH to localhost +``` + +### Full fast suite (no infra) + +```sh +make test-all # validate + fmt-check + unit tests +make clean # remove .terraform dirs and local state files +``` + +### Manual tofu workflow (local deploy, bypassing CI) + +```sh +# Docker stack +tofu init -backend=false # or with -backend-config flags for SeaweedFS +tofu workspace select myapp || tofu workspace new myapp +tofu apply -var-file="apps/myapp.tfvars" -var="ssh_key_path=/tmp/key" -var="db_password=..." + +# K8s stack (always run from k8s/) +cd k8s +tofu init -backend=false +tofu workspace select myapp || tofu workspace new myapp +tofu apply -var-file="apps/myapp.tfvars" -var="kubeconfig_path=/tmp/kubeconfig" ... +``` + +## Architecture + +This is a **GitOps multi-stack infrastructure blueprint**. There is no application code — only OpenTofu (Terraform-compatible) HCL and CI/CD pipelines. + +### Two independent stacks + +| Stack | Root dir | Module | What it deploys | +|-------|----------|--------|-----------------| +| Docker | `/` (repo root) | `modules/app-openresty-pg-redis` | OpenResty + PostgreSQL + Redis on a remote Docker host via SSH | +| Kubernetes | `k8s/` | `modules/app-k8s-nodered-rabbitmq` | Node-RED + optional RabbitMQ on a k3s cluster via kubeconfig | + +Each stack is a fully independent OpenTofu root. They share no state, no providers, and no modules. + +### One app = one `.tfvars` file = one workspace + +The CI/CD pipelines detect which `apps/*.tfvars` (or `k8s/apps/*.tfvars`) files were added, changed, or deleted, then run `tofu apply` or `tofu destroy` per file in its own **Tofu workspace**. App name is derived from the filename (`apps/myapp.tfvars` → workspace `myapp`). + +### State backend + +Both `backend.tf` files default to local state (no backend block active). To use SeaweedFS S3, uncomment the `terraform { backend "s3" {} }` block and run `scripts/setup-backend.sh`, or pass `-backend-config` flags manually. The backend key pattern is `apps/.tfstate` (Docker) and `apps-k8s/.tfstate` (K8s). + +### Secrets pattern + +**No secrets in the repository.** Three values are always injected at CI runtime from gopass: +- Docker stack: `ssh_key_path`, `db_password`, optionally `openresty_git_token` +- K8s stack: `kubeconfig_path`, `rabbitmq_password`, optionally `loki_auth_token` + +These are passed as `-var` flags and never appear in `.tfvars` files. + +### Module: `modules/app-openresty-pg-redis` + +Three OpenResty source modes controlled by `openresty_source_type`: +- `bind_mount` — mounts an existing path from the remote host +- `local_build` — builds a Docker image from a local Dockerfile and sends it over SSH +- `git_clone` — clones a repo at container startup; `openresty_git_ref` **must** be a pinned tag or SHA (branch names are rejected by variable validation) + +`environment = "prod"` creates named Docker volumes; `"dev"` uses ephemeral containers. + +### Module: `modules/app-k8s-nodered-rabbitmq` + +Key design decisions: +- **`kubectl_manifest` (gavinbunney/kubectl provider)** is used for all Traefik CRDs (`IngressRoute`, `Middleware`) instead of `kubernetes_manifest`. This is intentional — `kubectl_manifest` does not validate CRD schemas at plan time, avoiding failures when the CRDs are not yet installed. +- **`traefik_api_group`** variable (default `traefik.io/v1alpha1`) controls which Traefik API version is used. Set to `traefik.containo.us/v1alpha1` for older k3s versions. +- **Init container** copies files from a custom image to the app PVC at startup, but **skips `flows.json` and `flows_cred.json`** if they already exist (preserves user-modified Node-RED flows across redeployments). +- **Grafana Alloy sidecar** tails `/data/logs/*.log` and ships to Loki. It has its own 100Mi PVC (`-alloy-wal`) mounted at `/var/lib/alloy/data` to persist WAL across pod restarts. +- **RabbitMQ** is a StatefulSet (single replica) deployed only when `enable_rabbitmq = true`. + +### Test files + +- `tests/docker_validation.tftest.hcl` — Docker stack unit tests using `mock_provider "docker"` +- `k8s/tests/k8s_validation.tftest.hcl` — K8s stack unit tests using `mock_provider "kubernetes"` and `mock_provider "kubectl"` + +### CI/CD + +Two equivalent implementations — use whichever matches your git host: +- **Gitea Actions**: `.gitea/workflows/{test,deploy,deploy-k8s}.yml` +- **GitLab CI**: `.gitlab-ci.yml` (root entry point) + `.gitlab/workflows/{test,deploy,deploy-k8s}.gitlab-ci.yml` + +Key difference: Gitea uses dynamic matrix jobs (one job per changed tfvars). GitLab CI uses a `for` loop in a single job (`CI_COMMIT_BEFORE_SHA` + `git show` to recover deleted tfvars without checking out the previous commit). + +## Important constraints + +- `openresty_git_ref` has a built-in validation that rejects: `main`, `master`, `develop`, `dev`, `staging`, `HEAD`, `latest`, `trunk`. Always use a version tag or commit SHA. +- `environment` only accepts `"prod"` or `"dev"`. +- `openresty_source_type` only accepts `"bind_mount"`, `"local_build"`, or `"git_clone"`. +- The K8s stack **must be run from the `k8s/` directory** (it references `../modules/`). +- `tofu init -backend=false` is required for unit tests and validate — both stacks' `backend.tf` files have the backend block commented out by default. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..36fb88d --- /dev/null +++ b/Makefile @@ -0,0 +1,174 @@ +.PHONY: help validate fmt fmt-fix test-unit test-unit-docker test-unit-k8s \ + test-integration-docker test-integration-k8s test-all clean + +TOFU ?= tofu +TFLINT ?= tflint +K3D ?= k3d +DOCKER ?= docker + +# Names used for local integration test clusters / containers +TEST_K3D_CLUSTER ?= tofu-test +TEST_DOCKER_NET ?= tofu-test-net +TEST_APP_NAME ?= testapp + +##───────────────────────────────────────────────────────────────────────────── +## help Show this message +##───────────────────────────────────────────────────────────────────────────── +help: + @grep -E '^##' Makefile | sed 's/^## //' + +##───────────────────────────────────────────────────────────────────────────── +## Static analysis (no infrastructure required) +##───────────────────────────────────────────────────────────────────────────── + +## validate Run 'tofu validate' on both stacks +validate: + @echo "── Validating Docker stack ──" + $(TOFU) init -backend=false -input=false > /dev/null + $(TOFU) validate + @echo "── Validating K8s stack ──" + cd k8s && $(TOFU) init -backend=false -input=false > /dev/null + cd k8s && $(TOFU) validate + @echo "All configurations valid." + +## fmt-check Check HCL formatting in all .tf files +fmt-check: + $(TOFU) fmt -check -recursive + +## fmt-fix Auto-format all .tf files +fmt-fix: + $(TOFU) fmt -recursive + +## lint Run tflint on both stacks (requires tflint in PATH) +lint: + @echo "── Linting Docker stack ──" + $(TFLINT) --recursive + @echo "── Linting K8s stack ──" + cd k8s && $(TFLINT) --recursive + +##───────────────────────────────────────────────────────────────────────────── +## Unit tests (mocked providers — no real infrastructure) +##───────────────────────────────────────────────────────────────────────────── + +## test-unit-docker Run Docker stack unit tests (tofu test) +test-unit-docker: + @echo "── Docker stack unit tests ──" + $(TOFU) init -backend=false -input=false > /dev/null + $(TOFU) test + +## test-unit-k8s Run K8s stack unit tests (tofu test) +test-unit-k8s: + @echo "── K8s stack unit tests ──" + cd k8s && $(TOFU) init -backend=false -input=false > /dev/null + cd k8s && $(TOFU) test + +## test-unit Run all unit tests +test-unit: test-unit-docker test-unit-k8s + +##───────────────────────────────────────────────────────────────────────────── +## Integration tests (real local infrastructure) +##───────────────────────────────────────────────────────────────────────────── + +## test-integration-docker Deploy to local Docker, verify, destroy +## Requires: Docker running locally with SSH to localhost +## SSH key: set DOCKER_TEST_KEY env var (default: ~/.ssh/id_ed25519) +DOCKER_TEST_KEY ?= $(HOME)/.ssh/id_ed25519 +DOCKER_TEST_USER ?= $(shell whoami) + +test-integration-docker: + @echo "── Docker integration test ──" + @echo "Using local Docker socket via SSH to localhost" + $(TOFU) init -backend=false -input=false > /dev/null + $(TOFU) workspace select $(TEST_APP_NAME) 2>/dev/null || $(TOFU) workspace new $(TEST_APP_NAME) + $(TOFU) apply -auto-approve \ + -var="ssh_host=localhost" \ + -var="ssh_user=$(DOCKER_TEST_USER)" \ + -var="ssh_key_path=$(DOCKER_TEST_KEY)" \ + -var="app_name=$(TEST_APP_NAME)" \ + -var="environment=dev" \ + -var="openresty_source_type=bind_mount" \ + -var="openresty_remote_config_path=/tmp/tofu-test-openresty" \ + -var="openresty_external_port=18080" \ + -var="db_name=testdb" \ + -var="db_user=testuser" \ + -var="db_password=testpass" + @echo "── Verifying containers are running ──" + $(DOCKER) ps --filter "name=$(TEST_APP_NAME)" --format "table {{.Names}}\t{{.Status}}" + @echo "── Tearing down ──" + $(TOFU) destroy -auto-approve \ + -var="ssh_host=localhost" \ + -var="ssh_user=$(DOCKER_TEST_USER)" \ + -var="ssh_key_path=$(DOCKER_TEST_KEY)" \ + -var="app_name=$(TEST_APP_NAME)" \ + -var="environment=dev" \ + -var="openresty_source_type=bind_mount" \ + -var="openresty_remote_config_path=/tmp/tofu-test-openresty" \ + -var="openresty_external_port=18080" \ + -var="db_name=testdb" \ + -var="db_user=testuser" \ + -var="db_password=testpass" + $(TOFU) workspace select default + $(TOFU) workspace delete $(TEST_APP_NAME) + @echo "Docker integration test passed." + +## test-integration-k8s Deploy to local k3d cluster, verify, destroy +## Requires: k3d and kubectl in PATH +test-integration-k8s: _k3d-cluster-create + @echo "── K8s integration test ──" + $(K3D) kubeconfig get $(TEST_K3D_CLUSTER) > /tmp/test-kubeconfig + chmod 600 /tmp/test-kubeconfig + cd k8s && $(TOFU) init -backend=false -input=false > /dev/null + cd k8s && $(TOFU) workspace select $(TEST_APP_NAME) 2>/dev/null \ + || $(TOFU) workspace new $(TEST_APP_NAME) + cd k8s && $(TOFU) apply -auto-approve \ + -var-file="apps/example-nodered.tfvars" \ + -var="kubeconfig_path=/tmp/test-kubeconfig" \ + -var="app_name=$(TEST_APP_NAME)" \ + -var="rabbitmq_password=testpass" \ + -var="loki_auth_token=" + @echo "── Verifying K8s resources ──" + kubectl --kubeconfig /tmp/test-kubeconfig get all -n $(TEST_APP_NAME) + @echo "── Tearing down ──" + cd k8s && $(TOFU) destroy -auto-approve \ + -var-file="apps/example-nodered.tfvars" \ + -var="kubeconfig_path=/tmp/test-kubeconfig" \ + -var="app_name=$(TEST_APP_NAME)" \ + -var="rabbitmq_password=testpass" \ + -var="loki_auth_token=" + cd k8s && $(TOFU) workspace select default + cd k8s && $(TOFU) workspace delete $(TEST_APP_NAME) + $(MAKE) _k3d-cluster-delete + rm -f /tmp/test-kubeconfig + @echo "K8s integration test passed." + +_k3d-cluster-create: + @if ! $(K3D) cluster list | grep -q $(TEST_K3D_CLUSTER); then \ + echo "Creating k3d cluster '$(TEST_K3D_CLUSTER)'..."; \ + $(K3D) cluster create $(TEST_K3D_CLUSTER) \ + --agents 1 \ + --k3s-arg "--disable=traefik@server:0" \ + --wait; \ + echo "Installing Traefik CRDs..."; \ + kubectl --kubeconfig $$($(K3D) kubeconfig get $(TEST_K3D_CLUSTER)) \ + apply -f https://raw.githubusercontent.com/traefik/traefik/v2.11/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml; \ + else \ + echo "k3d cluster '$(TEST_K3D_CLUSTER)' already exists."; \ + fi + +_k3d-cluster-delete: + $(K3D) cluster delete $(TEST_K3D_CLUSTER) 2>/dev/null || true + +##───────────────────────────────────────────────────────────────────────────── +## Combined targets +##───────────────────────────────────────────────────────────────────────────── + +## test-all Run validate + fmt-check + unit tests +test-all: validate fmt-check test-unit + @echo "All tests passed." + +## clean Remove local .terraform dirs and state files +clean: + find . -name '.terraform' -type d -exec rm -rf {} + 2>/dev/null || true + find . -name 'terraform.tfstate*' -type f -delete 2>/dev/null || true + find . -name '.terraform.lock.hcl' -type f -delete 2>/dev/null || true + @echo "Cleaned." diff --git a/README.md b/README.md new file mode 100644 index 0000000..37e71d4 --- /dev/null +++ b/README.md @@ -0,0 +1,958 @@ +# OpenTofu App Deployment Blueprint + +A gitops-style infrastructure repository for deploying isolated application stacks to remote +hosts using [OpenTofu](https://opentofu.org/). Secrets are managed by **gopass**, state is +stored in **SeaweedFS** (S3-compatible), and deployments are triggered automatically whenever +an app configuration file is added, changed, or deleted. Two CI/CD systems are supported: +**Gitea Actions** and **GitLab CI** — both implement identical pipeline logic. + +--- + +## Table of Contents + +1. [Architecture Overview](#architecture-overview) +2. [Repository Structure](#repository-structure) +3. [Prerequisites](#prerequisites) +4. [State Backend Setup (SeaweedFS)](#state-backend-setup-seaweedfs) +5. [Secrets Setup (gopass)](#secrets-setup-gopass) +6. [CI/CD Variables](#cicd-variables) + - [Gitea Repository Secrets](#gitea-repository-secrets) + - [GitLab CI/CD Variables](#gitlab-cicd-variables) +7. [Stack: Docker — OpenResty + PostgreSQL + Redis](#stack-docker--openresty--postgresql--redis) + - [How It Works](#how-it-works-docker) + - [OpenResty Source Modes](#openresty-source-modes) + - [Variables Reference](#variables-reference-docker) + - [Adding a New Docker App](#adding-a-new-docker-app) +8. [Stack: Kubernetes — Node-RED + RabbitMQ](#stack-kubernetes--node-red--rabbitmq) + - [How It Works](#how-it-works-kubernetes) + - [Init Container Behaviour](#init-container-behaviour) + - [Grafana Alloy Sidecar](#grafana-alloy-sidecar) + - [RabbitMQ (Optional)](#rabbitmq-optional) + - [Traefik Ingress Routes](#traefik-ingress-routes) + - [Variables Reference](#variables-reference-kubernetes) + - [Adding a New K8s App](#adding-a-new-k8s-app) +9. [CI/CD Pipelines](#cicd-pipelines) + - [Docker Pipeline](#docker-pipeline) + - [Kubernetes Pipeline](#kubernetes-pipeline) + - [Destroy on Delete](#destroy-on-delete) + - [Gitea vs GitLab differences](#gitea-vs-gitlab-differences) +10. [Running Locally](#running-locally) +11. [Adding a New Stack Module](#adding-a-new-stack-module) +12. [Testing](#testing) +13. [Caveats and Known Limitations](#caveats-and-known-limitations) + +--- + +## Architecture Overview + +``` +Gitea repo +│ +├── apps/*.tfvars ← Docker app configs +└── k8s/apps/*.tfvars ← Kubernetes app configs + +Push to main +│ +├── deploy.yml ← detects apps/*.tfvars changes +│ └── tofu apply ← connects to remote Docker host via SSH +│ +└── deploy-k8s.yml ← detects k8s/apps/*.tfvars changes + └── tofu apply ← connects to K8s cluster via kubeconfig + │ + └── Per-app workspace in SeaweedFS state bucket +``` + +Each `.tfvars` file = one app deployment. One Tofu workspace = one isolated state. +Secrets are never stored in the repository — they are fetched from **gopass** at pipeline +runtime and passed to `tofu apply` via `-var` flags. + +--- + +## Repository Structure + +``` +. +├── modules/ +│ ├── app-openresty-pg-redis/ # Docker stack module +│ │ ├── variables.tf +│ │ ├── main.tf +│ │ └── outputs.tf +│ └── app-k8s-nodered-rabbitmq/ # Kubernetes stack module +│ ├── variables.tf +│ ├── main.tf +│ └── outputs.tf +│ +├── apps/ # Docker app configs (one file per app) +│ ├── example-dev.tfvars +│ └── example-prod.tfvars +│ +├── main.tf # Docker stack root: SSH provider + module call +├── variables.tf # Docker stack root variables +├── backend.tf # State backend options +│ +├── tests/ +│ └── docker_validation.tftest.hcl # Docker stack unit tests (tofu test) +│ +├── k8s/ # Kubernetes stack root +│ ├── main.tf # K8s provider + module call +│ ├── variables.tf +│ ├── backend.tf +│ ├── apps/ # K8s app configs (one file per app) +│ │ └── example-nodered.tfvars +│ └── tests/ +│ └── k8s_validation.tftest.hcl # K8s stack unit tests (tofu test) +│ +├── scripts/ +│ └── setup-backend.sh # One-time SeaweedFS backend setup +│ +├── Makefile # Test runner (validate, test-unit, test-integration) +│ +├── .gitea/ +│ └── workflows/ +│ ├── deploy.yml # Docker CI/CD pipeline (Gitea Actions) +│ ├── deploy-k8s.yml # Kubernetes CI/CD pipeline (Gitea Actions) +│ └── test.yml # Test pipeline (Gitea Actions) +│ +└── .gitlab/ + ├── .gitlab-ci.yml # Root entry point (stages + includes) + └── workflows/ + ├── deploy.gitlab-ci.yml # Docker CI/CD pipeline (GitLab CI) + ├── deploy-k8s.gitlab-ci.yml # Kubernetes CI/CD pipeline (GitLab CI) + └── test.gitlab-ci.yml # Test pipeline (GitLab CI) +``` + +--- + +## Prerequisites + +### Tools (CI runner and local workstation) + +| Tool | Minimum version | Purpose | +|------|-----------------|---------| +| OpenTofu | 1.9.0 | Infrastructure provisioning | +| gopass | latest | Secret management | +| GPG | 2.x | gopass store encryption | +| Docker | 24+ | Remote target host | +| k3s / Kubernetes | 1.27+ | K8s target cluster | + +### Remote Docker Host + +- SSH daemon running, key-based auth configured +- Docker daemon running and accessible to the deploy user +- No additional Docker daemon configuration needed — the Tofu Docker provider + connects over SSH + +### Kubernetes Cluster + +- k3s 1.27+ (or any conformant cluster) +- Traefik installed with CRD support (`traefik.io/v1alpha1`) + — pre-installed on k3s by default +- A `StorageClass` available for PVCs (k3s default: `local-path`) +- Loki instance reachable from within the cluster (for Alloy log forwarding) + +--- + +## State Backend Setup (SeaweedFS) + +OpenTofu state is stored in a SeaweedFS S3 bucket. Each app gets its own state file, +isolated by Tofu workspace. + +### 1. Create the state bucket (once) + +```sh +weed shell +> s3.bucket.create -name tofu-state +``` + +### 2. Create S3 access credentials in SeaweedFS + +Add an IAM user with read/write access to the `tofu-state` bucket and note the +access key and secret key. + +### 3. State key layout + +| Stack | State key pattern | +|-------|------------------| +| Docker | `apps/.tfstate` | +| Kubernetes | `apps-k8s/.tfstate` | + +### 4. Initialise the backend (automated) + +```sh +chmod +x scripts/setup-backend.sh +./scripts/setup-backend.sh +``` + +The script prompts for the endpoint and credentials, enables the `backend "s3" {}` block +in both `backend.tf` files, and runs `tofu init` for both stacks. + +### 4a. Manual initialisation + +Docker stack: +```sh +tofu init \ + -backend-config="bucket=tofu-state" \ + -backend-config="key=apps/.tfstate" \ + -backend-config="endpoint=http://seaweedfs.example.com:8333" \ + -backend-config="region=us-east-1" \ + -backend-config="force_path_style=true" +``` + +Kubernetes stack (run from `k8s/`): +```sh +cd k8s/ +tofu init \ + -backend-config="bucket=tofu-state" \ + -backend-config="key=apps-k8s/.tfstate" \ + -backend-config="endpoint=http://seaweedfs.example.com:8333" \ + -backend-config="region=us-east-1" \ + -backend-config="force_path_style=true" +``` + +Uncomment the `backend "s3" {}` block in the respective `backend.tf` before running `init`. + +--- + +## Secrets Setup (gopass) + +All sensitive values are stored in gopass and fetched at CI/CD runtime. Nothing sensitive +ever touches the repository. + +### Store layout convention + +``` +gopass store +│ +├── infra/ +│ ├── ssh-keys/ +│ │ └── # SSH private key for the remote Docker host +│ └── kubeconfigs/ +│ └── # kubeconfig for the target K8s cluster +│ +└── apps/ + └── / + ├── db_password # PostgreSQL password (Docker stack) + ├── git_token # Git token for private OpenResty repos (optional) + ├── rabbitmq_password # RabbitMQ password (K8s stack) + └── loki_token # Loki bearer token (K8s stack, optional) +``` + +### Adding a secret + +```sh +gopass insert infra/ssh-keys/myapp +gopass insert apps/myapp/db_password +``` + +### Key rotation + +Update the secret in gopass and re-run the pipeline. The next `tofu apply` will pick up +the new value and update the K8s Secret or re-create the container with the new env var. + +For SSH keys specifically: update `infra/ssh-keys/` in gopass and update the +`authorized_keys` on the remote host before the next pipeline run. + +--- + +## CI/CD Variables + +The same five values are required regardless of which CI/CD system you use. + +### Gitea Repository Secrets + +Configure in **Gitea → Repository → Settings → Secrets**. + +| Secret | Purpose | +|--------|---------| +| `GOPASS_GPG_KEY` | GPG private key (armored) to decrypt the gopass store | +| `GOPASS_STORE_REPO` | Git URL of the gopass password store repository | +| `SEAWEED_S3_ENDPOINT` | SeaweedFS S3 endpoint, e.g. `http://seaweedfs.example.com:8333` | +| `SEAWEED_ACCESS_KEY` | SeaweedFS S3 access key | +| `SEAWEED_SECRET_KEY` | SeaweedFS S3 secret key (mark as masked) | + +### GitLab CI/CD Variables + +Configure in **GitLab → Project → Settings → CI/CD → Variables**. Mark sensitive values +as **Masked** so they are redacted from job logs. + +| Variable | Masked | Purpose | +|----------|--------|---------| +| `GOPASS_GPG_KEY` | yes | GPG private key (armored) to decrypt the gopass store | +| `GOPASS_STORE_REPO` | no | Git URL of the gopass password store repository | +| `SEAWEED_S3_ENDPOINT` | no | SeaweedFS S3 endpoint, e.g. `http://seaweedfs.example.com:8333` | +| `SEAWEED_ACCESS_KEY` | yes | SeaweedFS S3 access key | +| `SEAWEED_SECRET_KEY` | yes | SeaweedFS S3 secret key | + +> **Note:** GitLab CI variables are plain environment variables — no `${{ secrets.X }}` +> syntax is needed. The pipelines reference them directly as `$VARIABLE_NAME`. + +--- + +## Stack: Docker — OpenResty + PostgreSQL + Redis + +Deploys an isolated application stack to a **remote Docker host** over SSH. Each app gets: + +- A dedicated Docker **bridge network** (containers cannot reach other apps) +- An **OpenResty** container as the public-facing frontend (configurable source) +- A **PostgreSQL** container (internal only) +- A **Redis** container (internal only) +- Named Docker **volumes** in `prod`, ephemeral containers in `dev` + +### How It Works (Docker) + +``` +Remote Docker host +└── -network (bridge) + ├── -openresty → port on host + ├── -postgres (internal only) + └── -redis (internal only) +``` + +OpenResty receives all external traffic. It communicates with Postgres and Redis using +their container names as hostnames (Docker DNS resolution within the network). +Environment variables `POSTGRES_HOST`, `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, +`REDIS_HOST` and `APP_NAME` are injected into the OpenResty container and are accessible +from Lua code via `os.getenv()`. + +### OpenResty Source Modes + +The `openresty_source_type` variable controls how the OpenResty config and Lua code +are provided. Choose one mode per app. + +#### `bind_mount` — mount from remote host filesystem + +The config directory already exists on the remote server (managed separately via rsync, +Ansible, etc.). It is mounted read-only into the container. + +```hcl +openresty_source_type = "bind_mount" +openresty_remote_config_path = "/opt/apps/myapp/openresty" +``` + +- The path must exist on the **remote host** before `tofu apply` +- Content mounted to `/usr/local/openresty/nginx/conf` inside the container +- Best for: quick iteration, config managed outside Tofu + +#### `local_build` — build Docker image from a local Dockerfile + +The build context (directory containing a `Dockerfile`) is transferred from the machine +running OpenTofu to the remote Docker daemon over SSH, and built there. The image is +stored locally on the remote host. + +```hcl +openresty_source_type = "local_build" +openresty_local_build_context = "./openresty" +openresty_dockerfile = "Dockerfile" +``` + +- The Dockerfile must produce a valid OpenResty image +- Image rebuilds automatically when any file in the context directory changes + (detected via SHA-1 hash of all files in the context) +- Best for: self-contained deployments, full control over the image + +#### `git_clone` — clone a git repo at container startup + +The standard OpenResty Alpine image starts, installs git via `apk`, clones the specified +repository at the pinned ref, then starts OpenResty with the cloned directory as its +config prefix. + +```hcl +openresty_source_type = "git_clone" +openresty_git_repo = "https://gitea.example.com/org/myapp-openresty.git" +openresty_git_ref = "v1.4.2" +``` + +- **Always pin to a tag or full commit SHA** — never use a branch name in production, + as it would cause unintended updates on container restarts +- The repository must contain an `openresty/` subdirectory with a valid `nginx.conf` +- For private repositories, the auth token is supplied at CI runtime from gopass + (never stored in the tfvars file) +- Best for: production deployments with git-based config versioning + +### Variables Reference (Docker) + +#### Connection + +| Variable | Type | Required | Description | +|----------|------|----------|-------------| +| `ssh_host` | string | yes | Hostname or IP of the remote Docker host | +| `ssh_user` | string | yes | SSH user on the remote host | +| `ssh_key_path` | string | yes | Path to SSH private key on the CI runner (from gopass) | + +#### App + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `app_name` | string | — | Unique app name, used as prefix for all resource names | +| `environment` | string | `"dev"` | `"prod"` = persistent volumes, `"dev"` = ephemeral | + +#### OpenResty + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `openresty_source_type` | string | — | `"bind_mount"`, `"local_build"`, or `"git_clone"` | +| `openresty_image` | string | `openresty/openresty:1.25.3-alpine` | Base image (bind_mount / git_clone) | +| `openresty_external_port` | number | — | Port exposed on the remote host | +| `openresty_remote_config_path` | string | `""` | Remote host path (bind_mount only) | +| `openresty_local_build_context` | string | `""` | Local Dockerfile context path (local_build only) | +| `openresty_dockerfile` | string | `"Dockerfile"` | Dockerfile filename (local_build only) | +| `openresty_git_repo` | string | `""` | Git repo URL (git_clone only) | +| `openresty_git_ref` | string | `""` | Git ref — tag, branch, or SHA (git_clone only) | +| `openresty_git_token` | string | `""` | Auth token for private repos (git_clone only, sensitive) | + +#### Database & Cache + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `db_name` | string | — | PostgreSQL database name | +| `db_user` | string | — | PostgreSQL user | +| `db_password` | string | — | PostgreSQL password (sensitive, from gopass) | +| `postgres_image` | string | `postgres:16-alpine` | PostgreSQL image | +| `redis_image` | string | `redis:7-alpine` | Redis image | + +### Adding a New Docker App + +1. Copy `apps/example-dev.tfvars` or `apps/example-prod.tfvars` to `apps/.tfvars` +2. Fill in all required variables. Leave `ssh_key_path`, `db_password`, and + `openresty_git_token` commented out — they are injected by CI/CD from gopass +3. Add secrets to gopass: + ```sh + gopass insert infra/ssh-keys/ + gopass insert apps//db_password + gopass insert apps//git_token # only if using git_clone with a private repo + ``` +4. Commit and push `apps/.tfvars` to `main` +5. The `deploy.yml` pipeline detects the new file and runs `tofu apply` + +--- + +## Stack: Kubernetes — Node-RED + RabbitMQ + +Deploys an application pod to a **Kubernetes cluster** (tested on k3s). Each app gets: + +- A dedicated **Namespace** +- A **Deployment** with three containers: app, init, and Grafana Alloy sidecar +- A **PersistentVolumeClaim** for the app data directory (`/data`) +- A **Service** (ClusterIP) for the app +- **Traefik IngressRoute** + **StripPrefix Middleware** for path-based routing +- A **K8s Secret** for all sensitive values +- A **ConfigMap** with the generated Grafana Alloy River config +- An optional **RabbitMQ StatefulSet** with its own PVC, headless service, ClusterIP + service, and management UI IngressRoute + +### How It Works (Kubernetes) + +``` +Namespace: +│ +├── Deployment: +│ ├── init container ← copies defaults to PVC, preserves flows.json +│ ├── app container ← Node-RED (or any image), mounts PVC at /data +│ └── alloy sidecar ← tails /data/logs/*.log → Loki +│ +├── PVC: -data ← shared between init, app, and alloy +├── Service: ← ClusterIP port 1880 (configurable) +├── Middleware: strip-prefix +├── IngressRoute: ← PathPrefix(/) → strips prefix → Service +│ +└── (optional) StatefulSet: -rabbitmq + ├── Service: -rabbitmq-headless (for StatefulSet DNS) + ├── Service: -rabbitmq (ClusterIP, AMQP + management) + ├── Middleware: rabbitmq-strip-prefix + └── IngressRoute: -rabbitmq ← PathPrefix(/rabbitmq) → port 15672 +``` + +### Init Container Behaviour + +The init container runs before the app starts on every pod creation. Its purpose is to +populate the app's PVC with default configuration files and Node-RED modules from a +custom image, while protecting the user's flows from being overwritten. + +**Logic:** + +``` +for each file in / (inside the init image): + if file is flows.json or flows_cred.json: + if file already exists on the PVC → skip (preserve user flows) + else → copy (first-time setup) + else: + copy unconditionally (always update settings, modules, etc.) +``` + +This means: +- **First deployment**: all files including flows are copied from the init image +- **Redeployments / updates**: settings and modules are updated, flows are never touched +- **Init image updates**: new modules and config changes are applied automatically on + the next pod restart + +The init container image and source path are fully configurable: + +```hcl +init_container_image = "gitea.example.com/myorg/myapp-init:v1.2.0" +init_data_src_path = "/app-data" +``` + +**Expected init image layout:** + +``` +/app-data/ +├── settings.js ← Node-RED settings +├── package.json ← module dependencies +└── nodes/ ← custom node modules +``` + +### Grafana Alloy Sidecar + +An Alloy container runs alongside the app in the same pod, sharing the app PVC (read-only). +It tails all `*.log` files under `/data/logs/` and forwards them to Loki. + +Alloy configuration is generated automatically as a Kubernetes ConfigMap. The Loki +endpoint and bearer token are configurable variables: + +```hcl +loki_endpoint = "http://loki.monitoring.svc:3100/loki/api/v1/push" +loki_auth_token = "" # leave empty for unauthenticated Loki; supplied by CI/CD if set +``` + +The bearer token is never written to the ConfigMap — it is stored in the K8s Secret and +read by Alloy at runtime via `env("LOKI_AUTH_TOKEN")`. + +**Log streams include the external labels `app` and `env`**, making it easy to filter +in Grafana. + +The app must write logs to `/data/logs/.log`. Node-RED can be configured to +do this via the `settings.js` logging section. + +### RabbitMQ (Optional) + +Enabled by setting `enable_rabbitmq = true`. Deploys a single-node RabbitMQ StatefulSet +using the official `rabbitmq:*-management-alpine` image (no Bitnami dependency). + +```hcl +enable_rabbitmq = true +rabbitmq_user = "myapp" +rabbitmq_vhost = "myapp" +rabbitmq_pvc_size = "2Gi" +rabbitmq_path_prefix = "/myapp-mq" +# rabbitmq_password supplied at CI runtime from gopass +``` + +The app container receives `RABBITMQ_HOST`, `RABBITMQ_USER`, `RABBITMQ_PASSWORD`, and +`RABBITMQ_VHOST` as environment variables. The in-cluster AMQP URL is also available as +a Tofu output: + +``` +amqp://-rabbitmq..svc:5672/ +``` + +The management UI (port 15672) is exposed via a dedicated Traefik IngressRoute at +the configured path prefix. + +### Traefik Ingress Routes + +Both the app and the RabbitMQ management UI use Traefik CRDs (`traefik.io/v1alpha1`). +The `StripPrefix` middleware removes the path prefix before forwarding to the backend, +so the app receives requests at `/` regardless of the external path. + +``` +External request: GET /mynodered/flows +Traefik strips /mynodered +App receives: GET /flows +``` + +The Traefik entrypoint (default: `"web"`) is configurable per app via `traefik_entrypoint`. + +> **k3s compatibility note:** k3s 1.27+ ships Traefik with `traefik.io/v1alpha1` CRDs. +> For older k3s versions using `traefik.containo.us`, update the `apiVersion` in +> [modules/app-k8s-nodered-rabbitmq/main.tf](modules/app-k8s-nodered-rabbitmq/main.tf) +> on the `kubernetes_manifest` resources. + +### Variables Reference (Kubernetes) + +#### Connection + +| Variable | Type | Required | Description | +|----------|------|----------|-------------| +| `kubeconfig_path` | string | yes | Path to kubeconfig on CI runner (from gopass) | + +#### App + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `app_name` | string | — | Unique app name, becomes K8s namespace and resource prefix | +| `environment` | string | `"dev"` | Environment label on Loki log streams | +| `app_image` | string | — | Container image for the application | +| `app_port` | number | `1880` | Internal container port | + +#### Init Container + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `init_container_image` | string | — | Init image containing default data files | +| `init_data_src_path` | string | `"/app-data"` | Path inside the init image with default files | + +#### Storage + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `storage_class` | string | `""` | K8s StorageClass (empty = cluster default, k3s: `local-path`) | +| `app_pvc_size` | string | `"2Gi"` | Size of the app data PVC | + +#### Ingress + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `app_path_prefix` | string | — | Traefik path prefix, e.g. `"/myapp"` | +| `traefik_entrypoint` | string | `"web"` | Traefik entryPoint name | + +#### Grafana Alloy + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `alloy_image` | string | `grafana/alloy:v1.5.0` | Alloy container image | +| `loki_endpoint` | string | — | Loki push API URL | +| `loki_auth_token` | string | `""` | Loki bearer token (sensitive, from gopass) | + +#### RabbitMQ + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `enable_rabbitmq` | bool | `false` | Deploy RabbitMQ StatefulSet | +| `rabbitmq_image` | string | `rabbitmq:3.13-management-alpine` | RabbitMQ image | +| `rabbitmq_user` | string | `"guest"` | RabbitMQ default user | +| `rabbitmq_password` | string | `""` | RabbitMQ password (sensitive, from gopass) | +| `rabbitmq_vhost` | string | `"/"` | RabbitMQ default virtual host | +| `rabbitmq_pvc_size` | string | `"2Gi"` | Size of the RabbitMQ data PVC | +| `rabbitmq_path_prefix` | string | `"/rabbitmq"` | Traefik path prefix for the management UI | + +### Adding a New K8s App + +1. Copy `k8s/apps/example-nodered.tfvars` to `k8s/apps/.tfvars` +2. Fill in all required variables. Leave `kubeconfig_path`, `loki_auth_token`, and + `rabbitmq_password` commented out — they are injected by CI/CD from gopass +3. Add secrets to gopass: + ```sh + gopass insert infra/kubeconfigs/ + gopass insert apps//rabbitmq_password # if enable_rabbitmq = true + gopass insert apps//loki_token # if Loki requires auth + ``` +4. Commit and push `k8s/apps/.tfvars` to `main` +5. The `deploy-k8s.yml` pipeline detects the new file and runs `tofu apply` + +--- + +## CI/CD Pipelines + +Both Gitea Actions and GitLab CI implement the same three pipelines: + +| Pipeline | Gitea file | GitLab file | Trigger | +|----------|-----------|-------------|---------| +| Test | `.gitea/workflows/test.yml` | `.gitlab/workflows/test.gitlab-ci.yml` | all branches + MRs | +| Docker deploy | `.gitea/workflows/deploy.yml` | `.gitlab/workflows/deploy.gitlab-ci.yml` | `main` + `apps/*.tfvars` changed | +| K8s deploy | `.gitea/workflows/deploy-k8s.yml` | `.gitlab/workflows/deploy-k8s.gitlab-ci.yml` | `main` + `k8s/apps/*.tfvars` changed | + +### Docker Pipeline + +**Trigger:** push to `main` with changes to `apps/*.tfvars` + +**Steps per changed file:** + +1. Resolve app name from filename (`apps/myapp.tfvars` → `myapp`) +2. Fetch SSH private key from gopass → `/tmp/deploy_key` +3. Fetch `db_password` and optional `git_token` from gopass +4. `tofu init` against SeaweedFS backend (state key: `apps/.tfstate`) +5. `tofu workspace select ` or create if new +6. `tofu apply` with the tfvars file + runtime secrets via `-var` flags +7. Cleanup: `rm /tmp/deploy_key` + +### Kubernetes Pipeline + +**Trigger:** push to `main` with changes to `k8s/apps/*.tfvars` + +**Steps per changed file:** + +1. Resolve app name from filename +2. Fetch kubeconfig from gopass → `/tmp/kubeconfig` +3. Fetch `rabbitmq_password` and optional `loki_auth_token` from gopass +4. `tofu init` (working dir: `k8s/`) against SeaweedFS backend (key: `apps-k8s/.tfstate`) +5. `tofu workspace select ` or create if new +6. `tofu apply` (working dir: `k8s/`) with tfvars + runtime secrets +7. Cleanup: `rm /tmp/kubeconfig` + +### Destroy on Delete + +Both pipelines include a `destroy` job. When a `.tfvars` file is **deleted** from the +repository and pushed to `main`, the pipeline destroys the corresponding app: + +1. Secrets are fetched from gopass as usual +2. `tofu destroy` is run using the recovered tfvars and the existing workspace state +3. The Tofu workspace is deleted after successful destroy + +> **Note:** This requires the secrets to still exist in gopass at destroy time. +> Remove them from gopass only after the destroy pipeline has completed successfully. + +### Gitea vs GitLab differences + +| Behaviour | Gitea Actions | GitLab CI | +|-----------|--------------|-----------| +| One job per changed file | dynamic matrix (`fromJson`) | loop inside a single job | +| Recover deleted tfvars | checkout previous commit (`ref: github.event.before`) | `git show $CI_COMMIT_BEFORE_SHA:$TFVARS` (no extra checkout) | +| Secret syntax | `${{ secrets.VAR }}` | `$VAR` (plain env variable) | +| Fail-fast behaviour | `fail-fast: false` on matrix | subshell per app, collect exit codes | +| Cleanup on failure | `if: always()` step | `after_script:` block | + +--- + +## Running Locally + +To apply an app config from your local machine (bypassing CI/CD): + +### Docker stack + +```sh +# Fetch the SSH key +gopass show -o infra/ssh-keys/myapp > /tmp/deploy_key && chmod 600 /tmp/deploy_key + +# Init and select workspace +tofu init # configure backend-config flags as described in the backend section +tofu workspace select myapp || tofu workspace new myapp + +# Apply +tofu apply \ + -var-file="apps/myapp.tfvars" \ + -var="ssh_key_path=/tmp/deploy_key" \ + -var="db_password=$(gopass show -o apps/myapp/db_password)" + +rm /tmp/deploy_key +``` + +### Kubernetes stack + +```sh +gopass show -o infra/kubeconfigs/myapp > /tmp/kubeconfig && chmod 600 /tmp/kubeconfig + +cd k8s/ +tofu init # configure backend-config flags +tofu workspace select myapp || tofu workspace new myapp + +tofu apply \ + -var-file="apps/myapp.tfvars" \ + -var="kubeconfig_path=/tmp/kubeconfig" \ + -var="rabbitmq_password=$(gopass show -o apps/myapp/rabbitmq_password)" + +rm /tmp/kubeconfig +``` + +--- + +## Adding a New Stack Module + +The blueprint is designed so that new technology stacks can be added as additional modules +without changing the CI/CD pipelines or the existing stacks. + +1. Create a new module directory: + ``` + modules/ + ├── app-openresty-pg-redis/ # existing + ├── app-k8s-nodered-rabbitmq/ # existing + └── app-/ # new + ├── variables.tf + ├── main.tf + └── outputs.tf + ``` + +2. Create a stack root directory with its own `main.tf`, `variables.tf`, and `backend.tf` + following the pattern of the existing Docker or K8s roots + +3. Add a new workflow that watches `/apps/*.tfvars`: + - Gitea: `.gitea/workflows/deploy-.yml` (copy and adapt `deploy.yml`) + - GitLab: `.gitlab/workflows/deploy-.gitlab-ci.yml` + add the `include:` line to `.gitlab-ci.yml` + +4. Add per-app `.tfvars` files under `/apps/` + +The `apps/*.tfvars` lifecycle (add = deploy, change = update, delete = destroy) and the +gopass + SeaweedFS patterns apply identically to all stacks. + +--- + +## Testing + +Testing is organised into four levels. Each level requires more infrastructure but +gives stronger confidence. The `Makefile` provides convenient targets for all levels. + +``` +Level 1 — Static No infrastructure tofu validate, tofu fmt, tflint +Level 2 — Unit No infrastructure tofu test (mocked providers) +Level 3 — Integration Real local infra Docker or k3d cluster on your machine +Level 4 — CI Gitea Actions test.yml runs L1+L2 on every push, L3 on main +``` + +### Level 1: Static Analysis + +Checks syntax, types, and formatting. Runs in under 10 seconds, no network required. + +```sh +make validate # tofu validate on both stacks +make fmt-check # tofu fmt -check -recursive +make lint # tflint (install separately: https://github.com/terraform-linters/tflint) +``` + +Or manually: +```sh +# Docker stack +tofu init -backend=false && tofu validate + +# K8s stack +cd k8s && tofu init -backend=false && tofu validate +``` + +### Level 2: Unit Tests (mocked providers) + +Uses the OpenTofu native test framework (`tofu test`) with `mock_provider` blocks. +**No real Docker host or Kubernetes cluster is needed.** All provider API calls are +intercepted and return mock values. + +**Requires OpenTofu >= 1.7.0.** + +```sh +make test-unit # both stacks +make test-unit-docker # Docker stack only +make test-unit-k8s # K8s stack only +``` + +#### What is tested + +**Docker stack** ([tests/docker_validation.tftest.hcl](tests/docker_validation.tftest.hcl)): + +| Test | Type | +|------|------| +| Valid bind_mount / git_clone / local_build configs plan without error | Smoke | +| `openresty_git_ref = "main"` is rejected | Validation | +| `openresty_git_ref = "master"`, `"develop"`, `"HEAD"`, `"latest"` are rejected | Validation | +| `openresty_git_ref = "v2.1.0"` (tag) is accepted | Validation | +| `environment = "staging"` is rejected | Validation | +| `openresty_source_type = "s3_bucket"` is rejected | Validation | + +**K8s stack** ([k8s/tests/k8s_validation.tftest.hcl](k8s/tests/k8s_validation.tftest.hcl)): + +| Test | Type | +|------|------| +| Valid plan without RabbitMQ | Smoke | +| Valid plan with RabbitMQ enabled | Smoke | +| `traefik.io/v1alpha1` and `traefik.containo.us/v1alpha1` both plan | Smoke | +| `output.rabbitmq_amqp_url == ""` when RabbitMQ disabled | Assertion | +| `output.namespace == var.app_name` | Assertion | +| `output.app_url_path == var.app_path_prefix` | Assertion | + +#### Running a single test + +```sh +tofu test -filter=reject_git_ref_main +``` + +#### Verbose output + +```sh +tofu test -verbose +``` + +### Level 3: Integration Tests (real local infrastructure) + +These tests apply real resources, verify them, then destroy. They are the strongest +confidence check before deploying to production. + +#### Docker integration test + +Tests the Docker stack against your **local Docker daemon** via SSH to localhost. +Requires: Docker running, SSH server on localhost with your key in `authorized_keys`. + +```sh +make test-integration-docker + +# Override the SSH key and user if needed: +DOCKER_TEST_KEY=~/.ssh/id_ed25519 DOCKER_TEST_USER=deploy make test-integration-docker +``` + +What happens: +1. Creates OpenTofu workspace `testapp` +2. `tofu apply` deploys OpenResty + Postgres + Redis to local Docker +3. `docker ps` verifies containers are running +4. `tofu destroy` tears everything down +5. Workspace is deleted + +#### K8s integration test + +Tests the K8s stack against a **local k3d cluster**. Requires: k3d and kubectl in `PATH`. + +```sh +make test-integration-k8s +``` + +What happens: +1. Creates a k3d cluster `tofu-test` with Traefik disabled (CRDs installed manually) +2. `tofu apply` deploys the example Node-RED app with RabbitMQ +3. Verifies namespace, Deployment, Services, PVCs, and IngressRoutes exist +4. `tofu destroy` tears everything down +5. k3d cluster is deleted + +> **Note on Traefik in k3d**: the test installs Traefik CRDs manually from the +> upstream Traefik repo so `kubectl_manifest` resources apply correctly. The Traefik +> controller itself is not installed (not needed to test resource creation). + +### Level 4: CI Pipeline + +Two equivalent pipelines are provided — use whichever matches your git host. + +#### Gitea Actions ([.gitea/workflows/test.yml](.gitea/workflows/test.yml)) + +| Trigger | Jobs run | +|---------|---------| +| Push to any branch / PR to `main` | Static (L1) + Unit tests (L2) | +| Push to `main` | Static + Unit + K8s integration (L3) | + +#### GitLab CI ([.gitlab/workflows/test.gitlab-ci.yml](.gitlab/workflows/test.gitlab-ci.yml)) + +| Trigger | Jobs run | +|---------|---------| +| Merge request / push to `feature/*` | `static-analysis` + `unit-tests-docker` + `unit-tests-k8s` | +| Push to `main` | All of the above + `integration-k8s` | + +Both pipelines follow the same stage dependency graph: + +``` +static ──► unit-docker ──► + └─► unit-k8s ──► integration-k8s (main branch only) +``` + +`integration-k8s` always cleans up the k3d cluster even if apply or verify fails +(Gitea: `if: always()` step; GitLab CI: `after_script:` block). + +### Quick reference + +```sh +make test-all # L1 + L2 (fast, no infra needed) +make test-integration-k8s # L3 K8s (needs k3d) +make test-integration-docker # L3 Docker (needs local Docker + SSH) +make clean # remove .terraform dirs and local state files +``` + +--- + +## Caveats and Known Limitations + +**Single-node RabbitMQ** +The RabbitMQ StatefulSet deploys a single replica without clustering configuration. +This is appropriate for development and low-traffic production workloads. For +high-availability RabbitMQ, use the +[RabbitMQ Cluster Operator](https://www.rabbitmq.com/kubernetes/operator/operator-overview) +instead of this module's built-in StatefulSet. + +All other original caveats have been resolved: + +| Was | Resolution | +|-----|-----------| +| Traefik API version hardcoded | `traefik_api_group` variable — default `traefik.io/v1alpha1`, override to `traefik.containo.us/v1alpha1` for older k3s | +| `kubernetes_manifest` fails at plan time if CRDs absent | Switched to `kubectl_manifest` (`gavinbunney/kubectl` provider) — no CRD schema validation at plan time | +| git_clone accepts mutable branch names silently | `openresty_git_ref` validation rejects `main`, `master`, `develop`, `dev`, `staging`, `HEAD`, `latest`, `trunk` at `tofu validate` time | +| Alloy loses log position on pod restart, re-sends duplicates | Dedicated 100Mi PVC (`-alloy-wal`) mounted at `/var/lib/alloy/data` — WAL persists across restarts | +| State backend disabled and manual to enable | `scripts/setup-backend.sh` — interactive script that enables the backend block in both `backend.tf` files and runs `tofu init` | diff --git a/apps/example-dev.tfvars b/apps/example-dev.tfvars new file mode 100644 index 0000000..3f874b2 --- /dev/null +++ b/apps/example-dev.tfvars @@ -0,0 +1,24 @@ +# ─── Remote Host ────────────────────────────────────────────────────────────── +ssh_host = "dev-server.example.com" +ssh_user = "deploy" +# ssh_key_path is supplied at runtime by CI/CD from gopass — not stored here. + +# ─── App Identity ───────────────────────────────────────────────────────────── +app_name = "myapp" +environment = "dev" # ephemeral containers, no persistent volumes + +# ─── OpenResty: bind_mount mode ─────────────────────────────────────────────── +# Mount a directory that already exists on the remote server. +# Manage the files there separately (rsync, Ansible, etc.). +openresty_source_type = "bind_mount" +openresty_remote_config_path = "/opt/apps/myapp/openresty" +openresty_external_port = 8080 + +# ─── PostgreSQL ─────────────────────────────────────────────────────────────── +db_name = "myapp_dev" +db_user = "myapp" +db_password = "changeme-dev" # use gopass / CI secret for real deployments + +# ─── Images (optional overrides) ────────────────────────────────────────────── +# postgres_image = "postgres:16-alpine" +# redis_image = "redis:7-alpine" diff --git a/apps/example-prod.tfvars b/apps/example-prod.tfvars new file mode 100644 index 0000000..b69ea5f --- /dev/null +++ b/apps/example-prod.tfvars @@ -0,0 +1,27 @@ +# ─── Remote Host ────────────────────────────────────────────────────────────── +ssh_host = "prod-server.example.com" +ssh_user = "deploy" +# ssh_key_path is supplied at runtime by CI/CD from gopass — not stored here. + +# ─── App Identity ───────────────────────────────────────────────────────────── +app_name = "myapp" +environment = "prod" # named Docker volumes, data persists across container restarts + +# ─── OpenResty: git_clone mode ──────────────────────────────────────────────── +# The OpenResty Alpine image starts, installs git via apk, then clones the repo. +# Pin to a tag or full commit SHA — never use a branch name in prod. +# The repo must contain an 'openresty/' directory with a valid nginx.conf. +openresty_source_type = "git_clone" +openresty_git_repo = "https://gitea.example.com/myorg/myapp-openresty.git" +openresty_git_ref = "v1.4.2" # pinned tag — never 'main' in prod +# openresty_git_token is supplied at runtime by CI/CD from gopass — not stored here. +openresty_external_port = 80 + +# ─── PostgreSQL ─────────────────────────────────────────────────────────────── +db_name = "myapp_prod" +db_user = "myapp" +# db_password is supplied at runtime by CI/CD from gopass — not stored here. + +# ─── Images (optional overrides) ────────────────────────────────────────────── +# postgres_image = "postgres:16-alpine" +# redis_image = "redis:7-alpine" diff --git a/backend.tf b/backend.tf new file mode 100644 index 0000000..2ddee7b --- /dev/null +++ b/backend.tf @@ -0,0 +1,50 @@ +# ─── State Backend ──────────────────────────────────────────────────────────── +# +# Each app deployment uses its own Tofu workspace so state is isolated. +# The CI/CD pipeline selects (or creates) the workspace named after the app +# before running plan/apply. +# +# OPTION A — SeaweedFS S3 API (recommended for self-hosted Gitea CI/CD) +# ───────────────────────────────────────────────────────────────────────────── +# SeaweedFS exposes an S3-compatible API (default port 8333). +# Create the state bucket once: `weed shell` → `s3.bucket.create -name tofu-state` +# +# Configure via environment variables in the Gitea runner (no secrets in code): +# +# AWS_ACCESS_KEY_ID = +# AWS_SECRET_ACCESS_KEY = +# +# Then initialise with: +# tofu init \ +# -backend-config="bucket=tofu-state" \ +# -backend-config="key=apps/${APP_NAME}.tfstate" \ +# -backend-config="endpoint=http://seaweedfs.example.com:8333" \ +# -backend-config="region=us-east-1" \ +# -backend-config="force_path_style=true" +# +# Uncomment to enable: +# +# terraform { +# backend "s3" {} +# } + +# OPTION B — HTTP backend (e.g., a custom state server or Gitlab-compatible endpoint) +# ───────────────────────────────────────────────────────────────────────────── +# tofu init \ +# -backend-config="address=https://state.example.com/apps/${APP_NAME}" \ +# -backend-config="lock_address=https://state.example.com/apps/${APP_NAME}/lock" \ +# -backend-config="unlock_address=https://state.example.com/apps/${APP_NAME}/lock" \ +# -backend-config="username=${TF_HTTP_USERNAME}" \ +# -backend-config="password=${TF_HTTP_PASSWORD}" +# +# Uncomment to enable: +# +# terraform { +# backend "http" {} +# } + +# OPTION C — Local backend (default, useful for local development) +# ───────────────────────────────────────────────────────────────────────────── +# State is stored in terraform.tfstate.d//terraform.tfstate +# Commit .gitignore entries for *.tfstate and *.tfstate.backup. +# Not suitable for concurrent CI/CD runs. diff --git a/k8s/apps/example-nodered.tfvars b/k8s/apps/example-nodered.tfvars new file mode 100644 index 0000000..e6efec5 --- /dev/null +++ b/k8s/apps/example-nodered.tfvars @@ -0,0 +1,37 @@ +# ─── Kubernetes Connection ──────────────────────────────────────────────────── +# kubeconfig_path is supplied at runtime by CI/CD from gopass — not stored here. + +# ─── App Identity ───────────────────────────────────────────────────────────── +app_name = "mynodered" +environment = "prod" + +# ─── App Container ──────────────────────────────────────────────────────────── +app_image = "nodered/node-red:3.1.9" +app_port = 1880 + +# ─── Init Container ─────────────────────────────────────────────────────────── +# Custom image containing default settings.js, package.json and node modules. +# flows.json and flows_cred.json are preserved if they already exist on the PVC. +init_container_image = "gitea.example.com/myorg/mynodered-init:v1.2.0" +init_data_src_path = "/app-data" + +# ─── Storage ────────────────────────────────────────────────────────────────── +storage_class = "local-path" # k3s default provisioner +app_pvc_size = "2Gi" + +# ─── Ingress (Traefik) ──────────────────────────────────────────────────────── +app_path_prefix = "/mynodered" +traefik_entrypoint = "web" + +# ─── Grafana Alloy ──────────────────────────────────────────────────────────── +loki_endpoint = "http://loki.monitoring.svc:3100/loki/api/v1/push" +# loki_auth_token is supplied at runtime by CI/CD from gopass — not stored here. + +# ─── RabbitMQ (optional) ────────────────────────────────────────────────────── +enable_rabbitmq = true +rabbitmq_image = "rabbitmq:3.13-management-alpine" +rabbitmq_user = "mynodered" +rabbitmq_vhost = "mynodered" +rabbitmq_pvc_size = "2Gi" +rabbitmq_path_prefix = "/mynodered-mq" +# rabbitmq_password is supplied at runtime by CI/CD from gopass — not stored here. diff --git a/k8s/backend.tf b/k8s/backend.tf new file mode 100644 index 0000000..c610ffb --- /dev/null +++ b/k8s/backend.tf @@ -0,0 +1,30 @@ +# ─── State Backend ──────────────────────────────────────────────────────────── +# +# Mirrors the Docker stack backend configuration — same SeaweedFS store, +# separate state keys under apps-k8s/. +# +# OPTION A — SeaweedFS S3 API (recommended) +# ───────────────────────────────────────────────────────────────────────────── +# Initialise with: +# cd k8s/ +# tofu init \ +# -backend-config="bucket=tofu-state" \ +# -backend-config="key=apps-k8s/${APP_NAME}.tfstate" \ +# -backend-config="endpoint=http://seaweedfs.example.com:8333" \ +# -backend-config="region=us-east-1" \ +# -backend-config="force_path_style=true" +# +# Credentials via environment variables on the CI runner: +# AWS_ACCESS_KEY_ID = +# AWS_SECRET_ACCESS_KEY = +# +# Uncomment to enable: +# +# terraform { +# backend "s3" {} +# } + +# OPTION B — Local backend (default, for local development) +# ───────────────────────────────────────────────────────────────────────────── +# State stored at k8s/terraform.tfstate.d//terraform.tfstate +# Add k8s/**/.terraform and k8s/terraform.tfstate* to .gitignore. diff --git a/k8s/main.tf b/k8s/main.tf new file mode 100644 index 0000000..a796da6 --- /dev/null +++ b/k8s/main.tf @@ -0,0 +1,76 @@ +terraform { + required_providers { + kubernetes = { + source = "hashicorp/kubernetes" + version = "~> 2.31" + } + kubectl = { + source = "gavinbunney/kubectl" + version = "~> 1.14" + } + } +} + +# ─── Providers ──────────────────────────────────────────────────────────────── +# Both providers connect to the same cluster via the same kubeconfig. +# kubectl is used exclusively for Traefik CRD resources (IngressRoute, Middleware) +# because it does not validate CRD schemas at plan time, unlike kubernetes_manifest. + +provider "kubernetes" { + config_path = var.kubeconfig_path +} + +provider "kubectl" { + config_path = var.kubeconfig_path +} + +# ─── App Deployment ─────────────────────────────────────────────────────────── + +module "app" { + source = "../modules/app-k8s-nodered-rabbitmq" + + app_name = var.app_name + environment = var.environment + + app_image = var.app_image + app_port = var.app_port + + init_container_image = var.init_container_image + init_data_src_path = var.init_data_src_path + + storage_class = var.storage_class + app_pvc_size = var.app_pvc_size + + app_path_prefix = var.app_path_prefix + traefik_entrypoint = var.traefik_entrypoint + traefik_api_group = var.traefik_api_group + + alloy_image = var.alloy_image + loki_endpoint = var.loki_endpoint + loki_auth_token = var.loki_auth_token + + enable_rabbitmq = var.enable_rabbitmq + rabbitmq_image = var.rabbitmq_image + rabbitmq_user = var.rabbitmq_user + rabbitmq_password = var.rabbitmq_password + rabbitmq_vhost = var.rabbitmq_vhost + rabbitmq_pvc_size = var.rabbitmq_pvc_size + rabbitmq_path_prefix = var.rabbitmq_path_prefix +} + +# ─── Outputs ────────────────────────────────────────────────────────────────── + +output "namespace" { + description = "Kubernetes namespace for this app." + value = module.app.namespace +} + +output "app_url_path" { + description = "Path prefix the app is accessible at via Traefik." + value = module.app.app_path_prefix +} + +output "rabbitmq_amqp_url" { + description = "In-cluster AMQP URL (empty if RabbitMQ is disabled)." + value = module.app.rabbitmq_amqp_url +} diff --git a/k8s/tests/k8s_validation.tftest.hcl b/k8s/tests/k8s_validation.tftest.hcl new file mode 100644 index 0000000..e45c703 --- /dev/null +++ b/k8s/tests/k8s_validation.tftest.hcl @@ -0,0 +1,122 @@ +# ───────────────────────────────────────────────────────────────────────────── +# K8s stack unit tests — run with: tofu test (from k8s/ directory) +# Requires OpenTofu >= 1.7.0 (mock_provider support) +# +# Both providers are mocked — no real Kubernetes cluster needed. +# Tests cover: variable validation, plan with/without RabbitMQ, Traefik API group. +# ───────────────────────────────────────────────────────────────────────────── + +mock_provider "kubernetes" {} + +mock_provider "kubectl" { + source = "gavinbunney/kubectl" +} + +# ─── Shared baseline variables ──────────────────────────────────────────────── + +variables { + kubeconfig_path = "/tmp/test-kubeconfig" + + app_name = "testapp" + environment = "dev" + + app_image = "nodered/node-red:3.1.9" + app_port = 1880 + init_container_image = "gitea.example.com/org/testapp-init:v1.0.0" + + app_path_prefix = "/testapp" + loki_endpoint = "http://loki.monitoring.svc:3100/loki/api/v1/push" +} + +# ─── Smoke tests: valid configurations plan without error ───────────────────── + +run "valid_minimal_no_rabbitmq" { + command = plan + # Baseline: no RabbitMQ, default storage class, no Loki auth +} + +run "valid_with_rabbitmq" { + command = plan + variables { + enable_rabbitmq = true + rabbitmq_user = "testapp" + rabbitmq_password = "rabbit-secret" + rabbitmq_vhost = "testapp" + } +} + +run "valid_prod_environment" { + command = plan + variables { + environment = "prod" + enable_rabbitmq = true + rabbitmq_password = "rabbit-secret" + } +} + +run "valid_with_loki_auth" { + command = plan + variables { + loki_auth_token = "glc_supersecrettoken" + } +} + +run "valid_custom_storage_class" { + command = plan + variables { + storage_class = "longhorn" + app_pvc_size = "5Gi" + } +} + +run "valid_websecure_entrypoint" { + command = plan + variables { + traefik_entrypoint = "websecure" + } +} + +# ─── Traefik API group: both supported values plan without error ─────────────── + +run "valid_traefik_api_group_new" { + command = plan + variables { + traefik_api_group = "traefik.io/v1alpha1" + } +} + +run "valid_traefik_api_group_legacy" { + command = plan + variables { + traefik_api_group = "traefik.containo.us/v1alpha1" + } +} + +# ─── Output assertions ──────────────────────────────────────────────────────── + +run "rabbitmq_amqp_url_empty_when_disabled" { + command = plan + variables { + enable_rabbitmq = false + } + assert { + condition = output.rabbitmq_amqp_url == "" + error_message = "AMQP URL should be empty string when RabbitMQ is disabled." + } +} + +run "namespace_matches_app_name" { + command = plan + assert { + condition = output.namespace == var.app_name + error_message = "Namespace should equal app_name." + } +} + +run "app_url_path_matches_prefix" { + command = plan + assert { + condition = output.app_url_path == var.app_path_prefix + error_message = "app_url_path output should match app_path_prefix variable." + } +} diff --git a/k8s/variables.tf b/k8s/variables.tf new file mode 100644 index 0000000..f9a6143 --- /dev/null +++ b/k8s/variables.tf @@ -0,0 +1,143 @@ +# ─── Kubernetes Connection ──────────────────────────────────────────────────── + +variable "kubeconfig_path" { + description = "Absolute path to the kubeconfig file on the CI runner (fetched from gopass at pipeline runtime)." + type = string +} + +# ─── App Identity ───────────────────────────────────────────────────────────── + +variable "app_name" { + description = "Unique name for this app deployment. Becomes the K8s namespace and resource prefix." + type = string +} + +variable "environment" { + description = "Deployment environment label (attached to Loki log streams)." + type = string + default = "dev" +} + +# ─── App Container ──────────────────────────────────────────────────────────── + +variable "app_image" { + description = "Container image for the main application." + type = string +} + +variable "app_port" { + description = "Port the app container listens on." + type = number + default = 1880 +} + +# ─── Init Container ─────────────────────────────────────────────────────────── + +variable "init_container_image" { + description = "Image for the init container (must contain default data files at init_data_src_path)." + type = string +} + +variable "init_data_src_path" { + description = "Path inside the init container image where default data files live." + type = string + default = "/app-data" +} + +# ─── Storage ────────────────────────────────────────────────────────────────── + +variable "storage_class" { + description = "Kubernetes StorageClass for PVCs. Empty string uses cluster default (k3s: local-path)." + type = string + default = "" +} + +variable "app_pvc_size" { + description = "Storage size for the app data PVC." + type = string + default = "2Gi" +} + +# ─── Ingress ────────────────────────────────────────────────────────────────── + +variable "app_path_prefix" { + description = "Traefik URL path prefix for the app (e.g. '/myapp')." + type = string +} + +variable "traefik_api_group" { + description = "Traefik CRD API group. k3s >= 1.27: 'traefik.io/v1alpha1'. Older: 'traefik.containo.us/v1alpha1'." + type = string + default = "traefik.io/v1alpha1" +} + +variable "traefik_entrypoint" { + description = "Traefik entryPoint name (e.g. 'web' or 'websecure')." + type = string + default = "web" +} + +# ─── Grafana Alloy ──────────────────────────────────────────────────────────── + +variable "alloy_image" { + description = "Grafana Alloy container image." + type = string + default = "grafana/alloy:v1.5.0" +} + +variable "loki_endpoint" { + description = "Loki push API URL." + type = string +} + +variable "loki_auth_token" { + description = "Bearer token for Loki. Leave empty for unauthenticated Loki." + type = string + default = "" + sensitive = true +} + +# ─── RabbitMQ ───────────────────────────────────────────────────────────────── + +variable "enable_rabbitmq" { + description = "Deploy RabbitMQ StatefulSet alongside the app." + type = bool + default = false +} + +variable "rabbitmq_image" { + description = "RabbitMQ Docker image." + type = string + default = "rabbitmq:3.13-management-alpine" +} + +variable "rabbitmq_user" { + description = "RabbitMQ default user." + type = string + default = "guest" +} + +variable "rabbitmq_password" { + description = "RabbitMQ default user password." + type = string + sensitive = true + default = "" +} + +variable "rabbitmq_vhost" { + description = "RabbitMQ default virtual host." + type = string + default = "/" +} + +variable "rabbitmq_pvc_size" { + description = "Storage size for the RabbitMQ data PVC." + type = string + default = "2Gi" +} + +variable "rabbitmq_path_prefix" { + description = "Traefik path prefix for the RabbitMQ management UI." + type = string + default = "/rabbitmq" +} diff --git a/main.tf b/main.tf new file mode 100644 index 0000000..dc1f2ed --- /dev/null +++ b/main.tf @@ -0,0 +1,64 @@ +terraform { + required_providers { + docker = { + source = "kreuzwerker/docker" + version = "~> 3.9" + } + } +} + +# ─── Provider ───────────────────────────────────────────────────────────────── +# Connects to the remote Docker daemon over SSH. +# The SSH key path is supplied at runtime (typically via CI/CD from gopass). + +provider "docker" { + host = "ssh://${var.ssh_user}@${var.ssh_host}" + ssh_opts = [ + "-i", var.ssh_key_path, + "-o", "StrictHostKeyChecking=no", + "-o", "BatchMode=yes", + ] +} + +# ─── App Deployment ─────────────────────────────────────────────────────────── +# One module call = one app instance. The CI/CD pipeline runs tofu separately +# per app using its own tfvars file and workspace. + +module "app" { + source = "./modules/app-openresty-pg-redis" + + app_name = var.app_name + environment = var.environment + + openresty_source_type = var.openresty_source_type + openresty_image = var.openresty_image + openresty_external_port = var.openresty_external_port + openresty_remote_config_path = var.openresty_remote_config_path + openresty_local_build_context = var.openresty_local_build_context + openresty_dockerfile = var.openresty_dockerfile + openresty_git_repo = var.openresty_git_repo + openresty_git_ref = var.openresty_git_ref + openresty_git_token = var.openresty_git_token + + db_name = var.db_name + db_user = var.db_user + db_password = var.db_password + postgres_image = var.postgres_image + redis_image = var.redis_image +} + +# ─── Outputs ────────────────────────────────────────────────────────────────── + +output "openresty_url" { + description = "URL to reach the deployed app." + value = "http://${var.ssh_host}:${var.openresty_external_port}" +} + +output "app_containers" { + description = "Names of all deployed containers." + value = { + openresty = module.app.openresty_container_name + postgres = module.app.postgres_container_name + redis = module.app.redis_container_name + } +} diff --git a/modules/app-k8s-nodered-rabbitmq/main.tf b/modules/app-k8s-nodered-rabbitmq/main.tf new file mode 100644 index 0000000..239d9f4 --- /dev/null +++ b/modules/app-k8s-nodered-rabbitmq/main.tf @@ -0,0 +1,561 @@ +locals { + # Alloy config: bearer_token block only when a token is provided + alloy_auth_line = var.loki_auth_token != "" ? " bearer_token = env(\"LOKI_AUTH_TOKEN\")\n" : "" + + # Init container shell script: + # - Copies all files from the image's data dir to the shared PVC mount (/data) + # - Skips flows.json and flows_cred.json if they already exist on the PVC + # (preserves user-modified flows across pod restarts / redeployments) + # Shell variables ($SRC, $DEST etc.) are NOT HCL interpolations — only + # ${var.init_data_src_path} is resolved by Tofu before the string reaches K8s. + init_script = <<-EOT + set -e + SRC="${var.init_data_src_path}" + DEST="/data_init" + find "$SRC" -type f | while IFS= read -r src_file; do + rel=$(echo "$src_file" | sed "s|^$SRC/||") + dest_file="$DEST/$rel" + if [ "$rel" = "flows.json" ] || [ "$rel" = "flows_cred.json" ]; then + if [ -f "$dest_file" ]; then + echo "Preserving existing $rel" + continue + fi + fi + mkdir -p "$(dirname "$dest_file")" + cp "$src_file" "$dest_file" + echo "Copied $rel" + done + echo "Init complete." + EOT +} + +# ─── Namespace ──────────────────────────────────────────────────────────────── + +resource "kubernetes_namespace" "app" { + metadata { + name = var.app_name + labels = { + "app.kubernetes.io/managed-by" = "opentofu" + } + } +} + +# ─── Secrets ────────────────────────────────────────────────────────────────── + +resource "kubernetes_secret" "app" { + metadata { + name = "${var.app_name}-secret" + namespace = kubernetes_namespace.app.metadata[0].name + } + + data = { + LOKI_AUTH_TOKEN = var.loki_auth_token + RABBITMQ_USER = var.rabbitmq_user + RABBITMQ_PASSWORD = var.rabbitmq_password + } +} + +# ─── Alloy ConfigMap ────────────────────────────────────────────────────────── +# Generates the Grafana Alloy River config to tail /data/logs/*.log and push to Loki. + +resource "kubernetes_config_map" "alloy" { + metadata { + name = "${var.app_name}-alloy-config" + namespace = kubernetes_namespace.app.metadata[0].name + } + + data = { + "config.alloy" = <<-EOT + local.file_match "app_logs" { + path_targets = [{ + __path__ = "/data/logs/*.log", + app = "${var.app_name}", + env = "${var.environment}", + }] + } + + loki.source.file "app_logs" { + targets = local.file_match.app_logs.targets + forward_to = [loki.write.default.receiver] + } + + loki.write "default" { + endpoint { + url = "${var.loki_endpoint}" + ${local.alloy_auth_line} } + external_labels = { + app = "${var.app_name}", + env = "${var.environment}", + } + } + EOT + } +} + +# ─── PVC: Alloy WAL ─────────────────────────────────────────────────────────── +# Small dedicated volume for the Grafana Alloy write-ahead log. +# Storing the WAL here means Alloy remembers which log lines it has already sent +# to Loki, preventing duplicate log entries after pod restarts. + +resource "kubernetes_persistent_volume_claim" "alloy_wal" { + metadata { + name = "${var.app_name}-alloy-wal" + namespace = kubernetes_namespace.app.metadata[0].name + } + + spec { + access_modes = ["ReadWriteOnce"] + + resources { + requests = { + storage = "100Mi" + } + } + + storage_class_name = var.storage_class != "" ? var.storage_class : null + } +} + +# ─── PVC: App Data ──────────────────────────────────────────────────────────── +# Persists Node-RED data directory across pod restarts. +# The init container writes defaults here on first start; flows.json is never overwritten. + +resource "kubernetes_persistent_volume_claim" "app_data" { + metadata { + name = "${var.app_name}-data" + namespace = kubernetes_namespace.app.metadata[0].name + } + + spec { + access_modes = ["ReadWriteOnce"] + + resources { + requests = { + storage = var.app_pvc_size + } + } + + storage_class_name = var.storage_class != "" ? var.storage_class : null + } +} + +# ─── Deployment ─────────────────────────────────────────────────────────────── + +resource "kubernetes_deployment" "app" { + metadata { + name = var.app_name + namespace = kubernetes_namespace.app.metadata[0].name + labels = { + "app.kubernetes.io/name" = var.app_name + "app.kubernetes.io/managed-by" = "opentofu" + } + } + + spec { + replicas = 1 + + selector { + match_labels = { + app = var.app_name + } + } + + template { + metadata { + labels = { + app = var.app_name + } + } + + spec { + # ── Init container ────────────────────────────────────────────────── + # Copies default configs and modules from the init image to the shared PVC. + # flows.json and flows_cred.json are left untouched if they already exist. + init_container { + name = "${var.app_name}-init" + image = var.init_container_image + + command = ["/bin/sh", "-c", local.init_script] + + volume_mount { + name = "app-data" + mount_path = "/data_init" + } + } + + # ── App container ─────────────────────────────────────────────────── + container { + name = "app" + image = var.app_image + + port { + name = "http" + container_port = var.app_port + } + + volume_mount { + name = "app-data" + mount_path = "/data" + } + + # RabbitMQ connection info (available even if RabbitMQ is disabled) + env { + name = "RABBITMQ_HOST" + value = var.enable_rabbitmq ? "${var.app_name}-rabbitmq" : "" + } + env { + name = "RABBITMQ_VHOST" + value = var.rabbitmq_vhost + } + env { + name = "RABBITMQ_USER" + value_from { + secret_key_ref { + name = kubernetes_secret.app.metadata[0].name + key = "RABBITMQ_USER" + optional = true + } + } + } + env { + name = "RABBITMQ_PASSWORD" + value_from { + secret_key_ref { + name = kubernetes_secret.app.metadata[0].name + key = "RABBITMQ_PASSWORD" + optional = true + } + } + } + } + + # ── Alloy sidecar ─────────────────────────────────────────────────── + # Tails /data/logs/*.log and forwards to Loki. + # WAL is stored on its own PVC so log positions survive pod restarts — + # no duplicate entries sent to Loki after a restart. + container { + name = "alloy" + image = var.alloy_image + + args = [ + "run", + "--server.http.listen-addr=0.0.0.0:12345", + "--storage.path=/var/lib/alloy/data", + "/etc/alloy/config.alloy", + ] + + port { + name = "alloy-http" + container_port = 12345 + } + + # App data mounted read-only — Alloy only reads logs, never writes to /data + volume_mount { + name = "app-data" + mount_path = "/data" + read_only = true + } + + volume_mount { + name = "alloy-config" + mount_path = "/etc/alloy" + read_only = true + } + + # Persistent WAL — survives pod restarts, prevents duplicate log shipping + volume_mount { + name = "alloy-wal" + mount_path = "/var/lib/alloy/data" + } + + env { + name = "LOKI_AUTH_TOKEN" + value_from { + secret_key_ref { + name = kubernetes_secret.app.metadata[0].name + key = "LOKI_AUTH_TOKEN" + optional = true + } + } + } + } + + # ── Volumes ───────────────────────────────────────────────────────── + volume { + name = "app-data" + persistent_volume_claim { + claim_name = kubernetes_persistent_volume_claim.app_data.metadata[0].name + } + } + + volume { + name = "alloy-config" + config_map { + name = kubernetes_config_map.alloy.metadata[0].name + } + } + + volume { + name = "alloy-wal" + persistent_volume_claim { + claim_name = kubernetes_persistent_volume_claim.alloy_wal.metadata[0].name + } + } + } + } + } +} + +# ─── Service: App ───────────────────────────────────────────────────────────── + +resource "kubernetes_service" "app" { + metadata { + name = var.app_name + namespace = kubernetes_namespace.app.metadata[0].name + } + + spec { + selector = { + app = var.app_name + } + + port { + name = "http" + port = var.app_port + target_port = var.app_port + } + } +} + +# ─── Traefik: App IngressRoute + StripPrefix Middleware ─────────────────────── +# Uses kubectl_manifest (gavinbunney/kubectl provider) instead of kubernetes_manifest. +# kubectl_manifest does NOT validate CRD schemas at plan time, so tofu plan succeeds +# even on a fresh cluster before Traefik CRDs are installed. +# The API group is configurable via var.traefik_api_group to support both +# k3s >= 1.27 (traefik.io/v1alpha1) and older clusters (traefik.containo.us/v1alpha1). + +resource "kubectl_manifest" "app_middleware" { + yaml_body = <<-YAML + apiVersion: ${var.traefik_api_group} + kind: Middleware + metadata: + name: ${var.app_name}-strip-prefix + namespace: ${kubernetes_namespace.app.metadata[0].name} + spec: + stripPrefix: + prefixes: + - "${var.app_path_prefix}" + YAML +} + +resource "kubectl_manifest" "app_ingress" { + yaml_body = <<-YAML + apiVersion: ${var.traefik_api_group} + kind: IngressRoute + metadata: + name: ${var.app_name} + namespace: ${kubernetes_namespace.app.metadata[0].name} + spec: + entryPoints: + - ${var.traefik_entrypoint} + routes: + - match: "PathPrefix(`${var.app_path_prefix}`)" + kind: Rule + middlewares: + - name: ${var.app_name}-strip-prefix + namespace: ${kubernetes_namespace.app.metadata[0].name} + services: + - name: ${var.app_name} + port: ${var.app_port} + YAML + + depends_on = [kubectl_manifest.app_middleware] +} + +# ─── RabbitMQ (optional) ────────────────────────────────────────────────────── + +resource "kubernetes_stateful_set" "rabbitmq" { + count = var.enable_rabbitmq ? 1 : 0 + + metadata { + name = "${var.app_name}-rabbitmq" + namespace = kubernetes_namespace.app.metadata[0].name + labels = { + "app.kubernetes.io/name" = "${var.app_name}-rabbitmq" + "app.kubernetes.io/managed-by" = "opentofu" + } + } + + spec { + service_name = "${var.app_name}-rabbitmq-headless" + replicas = 1 + + selector { + match_labels = { + app = "${var.app_name}-rabbitmq" + } + } + + template { + metadata { + labels = { + app = "${var.app_name}-rabbitmq" + } + } + + spec { + container { + name = "rabbitmq" + image = var.rabbitmq_image + + port { + name = "amqp" + container_port = 5672 + } + port { + name = "management" + container_port = 15672 + } + + env { + name = "RABBITMQ_DEFAULT_USER" + value_from { + secret_key_ref { + name = kubernetes_secret.app.metadata[0].name + key = "RABBITMQ_USER" + } + } + } + env { + name = "RABBITMQ_DEFAULT_PASS" + value_from { + secret_key_ref { + name = kubernetes_secret.app.metadata[0].name + key = "RABBITMQ_PASSWORD" + } + } + } + env { + name = "RABBITMQ_DEFAULT_VHOST" + value = var.rabbitmq_vhost + } + + volume_mount { + name = "rabbitmq-data" + mount_path = "/var/lib/rabbitmq" + } + } + } + } + + volume_claim_template { + metadata { + name = "rabbitmq-data" + } + spec { + access_modes = ["ReadWriteOnce"] + resources { + requests = { + storage = var.rabbitmq_pvc_size + } + } + storage_class_name = var.storage_class != "" ? var.storage_class : null + } + } + } +} + +# Headless service — required by the StatefulSet for stable pod DNS names +resource "kubernetes_service" "rabbitmq_headless" { + count = var.enable_rabbitmq ? 1 : 0 + + metadata { + name = "${var.app_name}-rabbitmq-headless" + namespace = kubernetes_namespace.app.metadata[0].name + } + + spec { + cluster_ip = "None" + selector = { + app = "${var.app_name}-rabbitmq" + } + port { + name = "amqp" + port = 5672 + } + port { + name = "management" + port = 15672 + } + } +} + +# ClusterIP service — used by the app container and the management IngressRoute +resource "kubernetes_service" "rabbitmq" { + count = var.enable_rabbitmq ? 1 : 0 + + metadata { + name = "${var.app_name}-rabbitmq" + namespace = kubernetes_namespace.app.metadata[0].name + } + + spec { + selector = { + app = "${var.app_name}-rabbitmq" + } + port { + name = "amqp" + port = 5672 + target_port = 5672 + } + port { + name = "management" + port = 15672 + target_port = 15672 + } + } +} + +# Traefik Middleware: StripPrefix for RabbitMQ management UI +resource "kubectl_manifest" "rabbitmq_middleware" { + count = var.enable_rabbitmq ? 1 : 0 + + yaml_body = <<-YAML + apiVersion: ${var.traefik_api_group} + kind: Middleware + metadata: + name: ${var.app_name}-rabbitmq-strip-prefix + namespace: ${kubernetes_namespace.app.metadata[0].name} + spec: + stripPrefix: + prefixes: + - "${var.rabbitmq_path_prefix}" + YAML +} + +# Traefik IngressRoute for RabbitMQ management UI (port 15672) +resource "kubectl_manifest" "rabbitmq_ingress" { + count = var.enable_rabbitmq ? 1 : 0 + + yaml_body = <<-YAML + apiVersion: ${var.traefik_api_group} + kind: IngressRoute + metadata: + name: ${var.app_name}-rabbitmq + namespace: ${kubernetes_namespace.app.metadata[0].name} + spec: + entryPoints: + - ${var.traefik_entrypoint} + routes: + - match: "PathPrefix(`${var.rabbitmq_path_prefix}`)" + kind: Rule + middlewares: + - name: ${var.app_name}-rabbitmq-strip-prefix + namespace: ${kubernetes_namespace.app.metadata[0].name} + services: + - name: ${var.app_name}-rabbitmq + port: 15672 + YAML + + depends_on = [kubectl_manifest.rabbitmq_middleware] +} diff --git a/modules/app-k8s-nodered-rabbitmq/outputs.tf b/modules/app-k8s-nodered-rabbitmq/outputs.tf new file mode 100644 index 0000000..9922216 --- /dev/null +++ b/modules/app-k8s-nodered-rabbitmq/outputs.tf @@ -0,0 +1,39 @@ +output "namespace" { + description = "Kubernetes namespace created for this app." + value = kubernetes_namespace.app.metadata[0].name +} + +output "app_service_name" { + description = "Kubernetes Service name for the app (use for in-cluster DNS: ..svc)." + value = kubernetes_service.app.metadata[0].name +} + +output "app_path_prefix" { + description = "Traefik path prefix the app is reachable at." + value = var.app_path_prefix +} + +output "app_pvc_name" { + description = "Name of the PVC holding the app data directory." + value = kubernetes_persistent_volume_claim.app_data.metadata[0].name +} + +output "alloy_config_map" { + description = "Name of the Alloy ConfigMap." + value = kubernetes_config_map.alloy.metadata[0].name +} + +output "rabbitmq_service_name" { + description = "Kubernetes Service name for RabbitMQ. Empty string if RabbitMQ is disabled." + value = var.enable_rabbitmq ? kubernetes_service.rabbitmq[0].metadata[0].name : "" +} + +output "rabbitmq_amqp_url" { + description = "AMQP URL for in-cluster app connections. Empty string if RabbitMQ is disabled." + value = var.enable_rabbitmq ? "amqp://${var.app_name}-rabbitmq.${kubernetes_namespace.app.metadata[0].name}.svc:5672/${var.rabbitmq_vhost}" : "" +} + +output "rabbitmq_path_prefix" { + description = "Traefik path prefix for the RabbitMQ management UI. Empty string if disabled." + value = var.enable_rabbitmq ? var.rabbitmq_path_prefix : "" +} diff --git a/modules/app-k8s-nodered-rabbitmq/variables.tf b/modules/app-k8s-nodered-rabbitmq/variables.tf new file mode 100644 index 0000000..1e3f882 --- /dev/null +++ b/modules/app-k8s-nodered-rabbitmq/variables.tf @@ -0,0 +1,146 @@ +# ─── App Identity ───────────────────────────────────────────────────────────── + +variable "app_name" { + description = "Unique name for this app. Used as K8s namespace, deployment name, and resource prefix." + type = string +} + +variable "environment" { + description = "Deployment environment label (attached to Loki log streams as an external label)." + type = string + default = "dev" +} + +# ─── App Container ──────────────────────────────────────────────────────────── + +variable "app_image" { + description = "Container image for the main application (e.g. nodered/node-red:3.1.9)." + type = string +} + +variable "app_port" { + description = "Port the app container listens on internally." + type = number + default = 1880 +} + +# ─── Init Container ─────────────────────────────────────────────────────────── + +variable "init_container_image" { + description = <<-EOT + Image for the init container. Must contain default config and module files + at init_data_src_path. The init container copies those files to the shared + PVC, but preserves flows.json and flows_cred.json if they already exist. + EOT + type = string +} + +variable "init_data_src_path" { + description = "Absolute path inside the init container image where default data files live." + type = string + default = "/app-data" +} + +# ─── Storage ────────────────────────────────────────────────────────────────── + +variable "storage_class" { + description = "Kubernetes StorageClass for PVCs. Empty string uses the cluster default (k3s: local-path)." + type = string + default = "" +} + +variable "app_pvc_size" { + description = "Storage size for the app data PVC (e.g. '2Gi')." + type = string + default = "2Gi" +} + +# ─── Ingress (Traefik CRDs) ─────────────────────────────────────────────────── + +variable "app_path_prefix" { + description = "URL path prefix for the app IngressRoute (e.g. '/myapp'). Prefix is stripped before forwarding." + type = string +} + +variable "traefik_entrypoint" { + description = "Traefik entryPoint name to attach IngressRoutes to (e.g. 'web' or 'websecure')." + type = string + default = "web" +} + +# ─── Grafana Alloy Sidecar ──────────────────────────────────────────────────── + +variable "alloy_image" { + description = "Grafana Alloy container image." + type = string + default = "grafana/alloy:v1.5.0" +} + +variable "loki_endpoint" { + description = "Loki push API URL (e.g. 'http://loki.monitoring.svc:3100/loki/api/v1/push')." + type = string +} + +variable "loki_auth_token" { + description = "Bearer token for Loki authentication. Leave empty for unauthenticated Loki." + type = string + default = "" + sensitive = true +} + +# ─── RabbitMQ (optional) ────────────────────────────────────────────────────── + +variable "enable_rabbitmq" { + description = "Deploy a RabbitMQ StatefulSet alongside the app." + type = bool + default = false +} + +variable "rabbitmq_image" { + description = "RabbitMQ Docker image. Use the -management variant to enable the management UI." + type = string + default = "rabbitmq:3.13-management-alpine" +} + +variable "rabbitmq_user" { + description = "RabbitMQ default user." + type = string + default = "guest" +} + +variable "rabbitmq_password" { + description = "RabbitMQ default user password." + type = string + sensitive = true + default = "" +} + +variable "rabbitmq_vhost" { + description = "RabbitMQ default virtual host." + type = string + default = "/" +} + +variable "rabbitmq_pvc_size" { + description = "Storage size for the RabbitMQ data PVC." + type = string + default = "2Gi" +} + +variable "rabbitmq_path_prefix" { + description = "URL path prefix for the RabbitMQ management UI IngressRoute." + type = string + default = "/rabbitmq" +} + +# ─── Traefik CRD API group ──────────────────────────────────────────────────── + +variable "traefik_api_group" { + description = <<-EOT + Traefik CRD API group and version used for IngressRoute and Middleware resources. + k3s >= 1.27 (Traefik v2.9+): "traefik.io/v1alpha1" + k3s < 1.27 (Traefik v2.x): "traefik.containo.us/v1alpha1" + EOT + type = string + default = "traefik.io/v1alpha1" +} diff --git a/modules/app-openresty-pg-redis/main.tf b/modules/app-openresty-pg-redis/main.tf new file mode 100644 index 0000000..a64c357 --- /dev/null +++ b/modules/app-openresty-pg-redis/main.tf @@ -0,0 +1,180 @@ +locals { + persistent = var.environment == "prod" + + # Inject oauth2 token into git URL for private repos + git_repo_url = ( + var.openresty_git_token != "" + ? replace(var.openresty_git_repo, "://", "://oauth2:${var.openresty_git_token}@") + : var.openresty_git_repo + ) + + # Entrypoint for git_clone mode: installs git via apk, clones the pinned ref, + # then hands off to openresty. The cloned repo must have an 'openresty/' subdirectory + # containing a valid nginx.conf (used as the -p prefix path). + git_clone_entrypoint = [ + "/bin/sh", "-c", + "apk add --no-cache git && git clone --depth 1 --branch '${var.openresty_git_ref}' '${local.git_repo_url}' /tmp/app && exec openresty -g 'daemon off;' -p /tmp/app/openresty" + ] +} + +# ─── Network ────────────────────────────────────────────────────────────────── +# Each app gets its own isolated bridge network. Postgres and Redis are not +# exposed externally; only OpenResty has a published port. + +resource "docker_network" "app" { + name = "${var.app_name}-network" + driver = "bridge" +} + +# ─── Volumes (prod only) ────────────────────────────────────────────────────── +# In dev mode containers are ephemeral; volumes are created only for prod. + +resource "docker_volume" "postgres" { + count = local.persistent ? 1 : 0 + name = "${var.app_name}-postgres-data" +} + +resource "docker_volume" "redis" { + count = local.persistent ? 1 : 0 + name = "${var.app_name}-redis-data" +} + +# ─── Images ─────────────────────────────────────────────────────────────────── + +# Standard OpenResty image — used for bind_mount and git_clone modes. +resource "docker_image" "openresty" { + count = var.openresty_source_type != "local_build" ? 1 : 0 + name = var.openresty_image + keep_locally = true +} + +# Custom-built OpenResty image — used for local_build mode. +# The build context is transferred from the local machine to the remote Docker +# daemon over SSH and built there. Rebuilds are triggered by changes to any +# file in the context directory. +resource "docker_image" "openresty_custom" { + count = var.openresty_source_type == "local_build" ? 1 : 0 + name = "${var.app_name}-openresty:latest" + keep_locally = true + + build { + context = var.openresty_local_build_context + dockerfile = var.openresty_dockerfile + } + + triggers = { + # Rebuild when any file in the build context changes. + context_hash = sha1(join("", [ + for f in sort(fileset(var.openresty_local_build_context, "**/*")) : + filesha1("${var.openresty_local_build_context}/${f}") + ])) + } +} + +resource "docker_image" "postgres" { + name = var.postgres_image + keep_locally = true +} + +resource "docker_image" "redis" { + name = var.redis_image + keep_locally = true +} + +# ─── PostgreSQL ─────────────────────────────────────────────────────────────── + +resource "docker_container" "postgres" { + image = docker_image.postgres.image_id + name = "${var.app_name}-postgres" + restart = "unless-stopped" + + networks_advanced { + name = docker_network.app.name + } + + env = [ + "POSTGRES_DB=${var.db_name}", + "POSTGRES_USER=${var.db_user}", + "POSTGRES_PASSWORD=${var.db_password}", + ] + + dynamic "volumes" { + for_each = local.persistent ? [1] : [] + content { + volume_name = docker_volume.postgres[0].name + container_path = "/var/lib/postgresql/data" + } + } +} + +# ─── Redis ──────────────────────────────────────────────────────────────────── + +resource "docker_container" "redis" { + image = docker_image.redis.image_id + name = "${var.app_name}-redis" + restart = "unless-stopped" + + networks_advanced { + name = docker_network.app.name + } + + dynamic "volumes" { + for_each = local.persistent ? [1] : [] + content { + volume_name = docker_volume.redis[0].name + container_path = "/data" + } + } +} + +# ─── OpenResty ──────────────────────────────────────────────────────────────── + +resource "docker_container" "openresty" { + image = ( + var.openresty_source_type == "local_build" + ? docker_image.openresty_custom[0].image_id + : docker_image.openresty[0].image_id + ) + name = "${var.app_name}-openresty" + restart = "unless-stopped" + + networks_advanced { + name = docker_network.app.name + } + + ports { + internal = 80 + external = var.openresty_external_port + } + + # bind_mount: mount a pre-existing directory from the remote host. + # The directory must contain a valid nginx.conf and any Lua files. + dynamic "volumes" { + for_each = var.openresty_source_type == "bind_mount" ? [1] : [] + content { + host_path = var.openresty_remote_config_path + container_path = "/usr/local/openresty/nginx/conf" + read_only = true + } + } + + # git_clone: override the container entrypoint to clone the repo and start openresty. + # The base image must support apk (Alpine). The cloned repo must have an openresty/ + # subdirectory with nginx.conf. + entrypoint = var.openresty_source_type == "git_clone" ? local.git_clone_entrypoint : null + + # Expose service connection info as env vars so Lua code can use them via os.getenv(). + env = [ + "APP_NAME=${var.app_name}", + "POSTGRES_HOST=${var.app_name}-postgres", + "POSTGRES_DB=${var.db_name}", + "POSTGRES_USER=${var.db_user}", + "POSTGRES_PASSWORD=${var.db_password}", + "REDIS_HOST=${var.app_name}-redis", + ] + + depends_on = [ + docker_container.postgres, + docker_container.redis, + ] +} diff --git a/modules/app-openresty-pg-redis/outputs.tf b/modules/app-openresty-pg-redis/outputs.tf new file mode 100644 index 0000000..741b4f6 --- /dev/null +++ b/modules/app-openresty-pg-redis/outputs.tf @@ -0,0 +1,34 @@ +output "openresty_container_name" { + description = "Name of the OpenResty container on the remote host." + value = docker_container.openresty.name +} + +output "openresty_external_port" { + description = "External port OpenResty is reachable on." + value = var.openresty_external_port +} + +output "postgres_container_name" { + description = "Name of the PostgreSQL container (reachable within the app network)." + value = docker_container.postgres.name +} + +output "redis_container_name" { + description = "Name of the Redis container (reachable within the app network)." + value = docker_container.redis.name +} + +output "network_name" { + description = "Name of the Docker bridge network shared by all app containers." + value = docker_network.app.name +} + +output "postgres_volume_name" { + description = "Name of the PostgreSQL data volume. Empty string in dev (ephemeral) mode." + value = local.persistent ? docker_volume.postgres[0].name : "" +} + +output "redis_volume_name" { + description = "Name of the Redis data volume. Empty string in dev (ephemeral) mode." + value = local.persistent ? docker_volume.redis[0].name : "" +} diff --git a/modules/app-openresty-pg-redis/variables.tf b/modules/app-openresty-pg-redis/variables.tf new file mode 100644 index 0000000..870f2d3 --- /dev/null +++ b/modules/app-openresty-pg-redis/variables.tf @@ -0,0 +1,152 @@ +# ─── App Identity ───────────────────────────────────────────────────────────── + +variable "app_name" { + description = "Unique name for this app deployment. Used as prefix for all container, network and volume names." + type = string +} + +variable "environment" { + description = "Deployment environment. Controls volume persistence: 'prod' = named volumes, 'dev' = ephemeral." + type = string + default = "dev" + + validation { + condition = contains(["prod", "dev"], var.environment) + error_message = "environment must be 'prod' or 'dev'." + } +} + +# ─── OpenResty: source type ──────────────────────────────────────────────────── + +variable "openresty_source_type" { + description = <<-EOT + How to provide OpenResty config / Lua code. One of: + bind_mount - mount an existing directory from the remote host filesystem + local_build - build a Docker image from a local Dockerfile context (sent to remote daemon) + git_clone - clone a git repo at container startup (requires git-capable base image or apk) + EOT + type = string + + validation { + condition = contains(["bind_mount", "local_build", "git_clone"], var.openresty_source_type) + error_message = "openresty_source_type must be 'bind_mount', 'local_build', or 'git_clone'." + } +} + +# ─── OpenResty: base image (bind_mount / git_clone) ─────────────────────────── + +variable "openresty_image" { + description = "OpenResty Docker image used for bind_mount and git_clone modes." + type = string + default = "openresty/openresty:1.25.3-alpine" +} + +# ─── OpenResty: bind_mount options ──────────────────────────────────────────── + +variable "openresty_remote_config_path" { + description = <<-EOT + Absolute path on the REMOTE HOST to mount as /usr/local/openresty/nginx/conf inside the container. + Only used when openresty_source_type = 'bind_mount'. + EOT + type = string + default = "" +} + +# ─── OpenResty: local_build options ─────────────────────────────────────────── + +variable "openresty_local_build_context" { + description = <<-EOT + Path to the local Dockerfile build context directory (relative to the tofu working directory). + The context is transferred to the remote Docker daemon over SSH and built there. + Only used when openresty_source_type = 'local_build'. + EOT + type = string + default = "" +} + +variable "openresty_dockerfile" { + description = "Dockerfile filename inside the build context. Only used when openresty_source_type = 'local_build'." + type = string + default = "Dockerfile" +} + +# ─── OpenResty: git_clone options ───────────────────────────────────────────── + +variable "openresty_git_repo" { + description = <<-EOT + Git repository URL to clone at container startup. + Only used when openresty_source_type = 'git_clone'. + The cloned repo must contain an 'openresty/' directory with a valid nginx.conf. + EOT + type = string + default = "" +} + +variable "openresty_git_ref" { + description = <<-EOT + Git ref to checkout. Must be a pinned tag or full commit SHA — never a branch name. + Mutable branch names cause non-reproducible container restarts (the same image + could contain different code each time the container is recreated). + Only used when openresty_source_type = 'git_clone'. + EOT + type = string + default = "" + + validation { + condition = var.openresty_git_ref == "" || !contains( + ["main", "master", "develop", "dev", "staging", "HEAD", "latest", "trunk"], + var.openresty_git_ref + ) + error_message = "openresty_git_ref must be a pinned tag or commit SHA, not a mutable branch name. Got: '${var.openresty_git_ref}'." + } +} + +variable "openresty_git_token" { + description = <<-EOT + Optional personal access token for private git repositories. + Injected into the clone URL as oauth2:@. + Only used when openresty_source_type = 'git_clone'. + EOT + type = string + default = "" + sensitive = true +} + +# ─── Networking & Ports ─────────────────────────────────────────────────────── + +variable "openresty_external_port" { + description = "Port exposed on the remote host that forwards to OpenResty port 80." + type = number +} + +# ─── PostgreSQL ─────────────────────────────────────────────────────────────── + +variable "db_name" { + description = "PostgreSQL database name." + type = string +} + +variable "db_user" { + description = "PostgreSQL user." + type = string +} + +variable "db_password" { + description = "PostgreSQL password." + type = string + sensitive = true +} + +variable "postgres_image" { + description = "PostgreSQL Docker image." + type = string + default = "postgres:16-alpine" +} + +# ─── Redis ──────────────────────────────────────────────────────────────────── + +variable "redis_image" { + description = "Redis Docker image." + type = string + default = "redis:7-alpine" +} diff --git a/scripts/setup-backend.sh b/scripts/setup-backend.sh new file mode 100644 index 0000000..84f82b5 --- /dev/null +++ b/scripts/setup-backend.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# setup-backend.sh — One-time SeaweedFS state backend initialisation +# +# Run this once per machine (or CI runner) before using the pipelines. +# It uncommets the backend block in both stack backend.tf files and runs +# tofu init with the correct -backend-config flags. +# +# Usage: +# chmod +x scripts/setup-backend.sh +# ./scripts/setup-backend.sh +# ───────────────────────────────────────────────────────────────────────────── +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# ── Collect config ───────────────────────────────────────────────────────── +echo "" +echo "SeaweedFS S3 State Backend Setup" +echo "═════════════════════════════════" +echo "" + +read -rp "SeaweedFS S3 endpoint (e.g. http://seaweedfs.example.com:8333): " ENDPOINT +read -rp "Access key: " ACCESS_KEY +read -rsp "Secret key: " SECRET_KEY +echo "" +read -rp "State bucket name [tofu-state]: " BUCKET +BUCKET="${BUCKET:-tofu-state}" + +export AWS_ACCESS_KEY_ID="$ACCESS_KEY" +export AWS_SECRET_ACCESS_KEY="$SECRET_KEY" + +BACKEND_ARGS=( + "-backend-config=bucket=${BUCKET}" + "-backend-config=endpoint=${ENDPOINT}" + "-backend-config=region=us-east-1" + "-backend-config=force_path_style=true" +) + +# ── Helper: enable backend block in a backend.tf ────────────────────────── +enable_backend() { + local file="$1" + if grep -q '# terraform {' "$file"; then + sed -i \ + -e 's|^# terraform {|terraform {|' \ + -e 's|^# backend "s3" {}| backend "s3" {}|' \ + -e 's|^# }$|}|' \ + "$file" + echo " Enabled S3 backend block in $file" + else + echo " Backend block already enabled in $file" + fi +} + +# ── Docker stack ────────────────────────────────────────────────────────── +echo "" +echo "── Docker stack ──────────────────────────────────────────────────" +enable_backend "$REPO_ROOT/backend.tf" + +cd "$REPO_ROOT" +echo " Running: tofu init (Docker stack)" +tofu init "${BACKEND_ARGS[@]}" "-backend-config=key=apps/PLACEHOLDER.tfstate" -reconfigure +echo " Docker stack backend initialised." + +# ── Kubernetes stack ────────────────────────────────────────────────────── +echo "" +echo "── Kubernetes stack ──────────────────────────────────────────────" +enable_backend "$REPO_ROOT/k8s/backend.tf" + +cd "$REPO_ROOT/k8s" +echo " Running: tofu init (K8s stack)" +tofu init "${BACKEND_ARGS[@]}" "-backend-config=key=apps-k8s/PLACEHOLDER.tfstate" -reconfigure +echo " Kubernetes stack backend initialised." + +# ── Done ────────────────────────────────────────────────────────────────── +echo "" +echo "Done. Both stacks are now configured to use SeaweedFS for state storage." +echo "" +echo "Next: commit the updated backend.tf files, then add these as Gitea secrets:" +echo " SEAWEED_S3_ENDPOINT = ${ENDPOINT}" +echo " SEAWEED_ACCESS_KEY = ${ACCESS_KEY}" +echo " SEAWEED_SECRET_KEY = (not shown)" +echo " SEAWEED_BUCKET = ${BUCKET}" +echo "" +echo "Do NOT commit the access/secret keys. They go in Gitea secrets only." diff --git a/tests/docker_validation.tftest.hcl b/tests/docker_validation.tftest.hcl new file mode 100644 index 0000000..d91e73b --- /dev/null +++ b/tests/docker_validation.tftest.hcl @@ -0,0 +1,146 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Docker stack unit tests — run with: tofu test (from repo root) +# Requires OpenTofu >= 1.7.0 (mock_provider support) +# +# All providers are mocked — no real Docker host needed. +# Tests cover: variable validation, plan structure, source type modes. +# ───────────────────────────────────────────────────────────────────────────── + +mock_provider "docker" {} + +# ─── Shared baseline variables ──────────────────────────────────────────────── +# Every run block below can override individual values. + +variables { + ssh_host = "test-host.example.com" + ssh_user = "deploy" + ssh_key_path = "/tmp/test-key" + + app_name = "testapp" + environment = "dev" + + openresty_source_type = "bind_mount" + openresty_remote_config_path = "/opt/apps/testapp/openresty" + openresty_external_port = 8080 + + db_name = "testdb" + db_user = "testuser" + db_password = "test-secret" +} + +# ─── Smoke tests: valid configurations plan without error ───────────────────── + +run "valid_dev_bind_mount" { + command = plan + # Verifies the baseline dev/bind_mount config produces a valid plan. +} + +run "valid_prod_bind_mount" { + command = plan + variables { + environment = "prod" + } +} + +run "valid_git_clone_with_tag" { + command = plan + variables { + openresty_source_type = "git_clone" + openresty_git_repo = "https://gitea.example.com/org/myapp.git" + openresty_git_ref = "v2.1.0" + } +} + +run "valid_git_clone_with_commit_sha" { + command = plan + variables { + openresty_source_type = "git_clone" + openresty_git_repo = "https://gitea.example.com/org/myapp.git" + openresty_git_ref = "a3f8c1d2e4b56789abcdef0123456789abcdef01" + } +} + +run "valid_local_build" { + command = plan + variables { + openresty_source_type = "local_build" + openresty_local_build_context = "./openresty" + openresty_dockerfile = "Dockerfile" + } +} + +# ─── Validation: git_ref must not be a mutable branch name ─────────────────── + +run "reject_git_ref_main" { + command = plan + variables { + openresty_source_type = "git_clone" + openresty_git_repo = "https://gitea.example.com/org/myapp.git" + openresty_git_ref = "main" + } + expect_failures = [var.openresty_git_ref] +} + +run "reject_git_ref_master" { + command = plan + variables { + openresty_source_type = "git_clone" + openresty_git_ref = "master" + } + expect_failures = [var.openresty_git_ref] +} + +run "reject_git_ref_develop" { + command = plan + variables { + openresty_source_type = "git_clone" + openresty_git_ref = "develop" + } + expect_failures = [var.openresty_git_ref] +} + +run "reject_git_ref_HEAD" { + command = plan + variables { + openresty_source_type = "git_clone" + openresty_git_ref = "HEAD" + } + expect_failures = [var.openresty_git_ref] +} + +run "reject_git_ref_latest" { + command = plan + variables { + openresty_source_type = "git_clone" + openresty_git_ref = "latest" + } + expect_failures = [var.openresty_git_ref] +} + +# ─── Validation: environment must be 'prod' or 'dev' ───────────────────────── + +run "reject_environment_staging" { + command = plan + variables { + environment = "staging" + } + expect_failures = [var.environment] +} + +run "reject_environment_production" { + command = plan + variables { + environment = "production" + } + expect_failures = [var.environment] +} + +# ─── Validation: openresty_source_type must be one of the three modes ───────── + +run "reject_invalid_source_type" { + command = plan + variables { + openresty_source_type = "s3_bucket" + } + expect_failures = [var.openresty_source_type] +} diff --git a/variables.tf b/variables.tf new file mode 100644 index 0000000..14ea1c9 --- /dev/null +++ b/variables.tf @@ -0,0 +1,127 @@ +# ─── Remote Host (SSH) ──────────────────────────────────────────────────────── + +variable "ssh_host" { + description = "Hostname or IP of the remote Docker host." + type = string +} + +variable "ssh_user" { + description = "SSH user on the remote Docker host." + type = string +} + +variable "ssh_key_path" { + description = "Absolute path to the SSH private key file used to connect to the remote host." + type = string +} + +# ─── App Identity ───────────────────────────────────────────────────────────── + +variable "app_name" { + description = "Unique name for this app deployment. Used as prefix for all resource names." + type = string +} + +variable "environment" { + description = "Deployment environment: 'prod' (persistent volumes) or 'dev' (ephemeral)." + type = string + default = "dev" +} + +# ─── OpenResty ──────────────────────────────────────────────────────────────── + +variable "openresty_source_type" { + description = "OpenResty config source: 'bind_mount', 'local_build', or 'git_clone'." + type = string +} + +variable "openresty_image" { + description = "Base OpenResty Docker image (bind_mount and git_clone modes)." + type = string + default = "openresty/openresty:1.25.3-alpine" +} + +variable "openresty_external_port" { + description = "External port on the remote host that maps to OpenResty port 80." + type = number +} + +# bind_mount +variable "openresty_remote_config_path" { + description = "Path on the REMOTE HOST to mount as OpenResty config dir (bind_mount mode)." + type = string + default = "" +} + +# local_build +variable "openresty_local_build_context" { + description = "Local path to Dockerfile build context directory (local_build mode)." + type = string + default = "" +} + +variable "openresty_dockerfile" { + description = "Dockerfile filename within the build context (local_build mode)." + type = string + default = "Dockerfile" +} + +# git_clone +variable "openresty_git_repo" { + description = "Git repository URL to clone at container startup (git_clone mode)." + type = string + default = "" +} + +variable "openresty_git_ref" { + description = "Git ref (tag, branch, commit SHA) to checkout (git_clone mode). Must be a pinned tag or SHA, not a branch name." + type = string + default = "" + + validation { + condition = var.openresty_git_ref == "" || !contains( + ["main", "master", "develop", "dev", "staging", "HEAD", "latest", "trunk"], + var.openresty_git_ref + ) + error_message = "openresty_git_ref must be a pinned tag or commit SHA, not a mutable branch name. Got: '${var.openresty_git_ref}'." + } +} + +variable "openresty_git_token" { + description = "Optional auth token for private git repos (git_clone mode)." + type = string + default = "" + sensitive = true +} + +# ─── PostgreSQL ─────────────────────────────────────────────────────────────── + +variable "db_name" { + description = "PostgreSQL database name." + type = string +} + +variable "db_user" { + description = "PostgreSQL user." + type = string +} + +variable "db_password" { + description = "PostgreSQL password." + type = string + sensitive = true +} + +variable "postgres_image" { + description = "PostgreSQL Docker image." + type = string + default = "postgres:16-alpine" +} + +# ─── Redis ──────────────────────────────────────────────────────────────────── + +variable "redis_image" { + description = "Redis Docker image." + type = string + default = "redis:7-alpine" +}